对多个数据库使用 Breeze/EntityFramework/WebAPI

Using Breeze/EntityFramework/WebAPI with multiple databases

我们目前有一个使用 DevForce 2012 的 Silverlight 应用程序。与 Silverlight 世界的大部分内容一样,我们已经开始移植到 HTML5。我们将使用由 Breeze 支持的 Angular 和 EntityFramework/WebAPI。

我们的每个客户都有自己的数据库,都共享相同的模型。由于我们有数百个客户,因此我们的 web.config 包含数百个连接字符串。当用户登录时,他们输入他们的帐户代码,该代码直接链接到连接字符串。 DevForce 有一个 "data source extensions" 的概念,我们的 Silverlight 应用程序使用它来获得正确的连接。所以我们的配置示例是

<connectionStrings>
   <add name="Entities_123" connectionString="myConnectionString" />    
   <add name="Entities_456" connectionString="myConnectionString2" />
   ...
</connectionStrings>

所以用户在登录时输入“456”作为他们的帐户代码,我们将该值作为 "data source extension" 传递给 DevForce,并且该连接在会话的其余部分与用户相关联,谢谢DevForce 魔法。

让我费尽心思的是如何用 Breeze/EF 做类似的事情。我在网上搜索过,但找不到任何有关如何使用 Breeze 连接到多个数据库而无需创建多个 Controller/Context 类 的示例。我猜我需要以某种方式使用 DBContextFactory,但我什至不知道从哪里开始。

我认为这与数据库选择问题一样是安全问题。因此,我会继续让服务器根据经过身份验证的用户确定数据库 ID。

客户端不应该直接知道或影响数据库id的选择。这是属于服务器的私事。

因此,您无需在客户端进行任何更改。从客户端的角度来看,只有一个端点,并且该端点对每个人都是相同的。

服务器(网络API)

不需要每个数据库一个控制器。您可能出于其他原因需要多个控制器,但这是由其他问题驱动的,而不是这个。

在您的(也许 唯一)Web API 控制器中,您以某种方式获取数据库 ID。我不知道你今天在 Silverlight + DevForce 中是怎么做到的;在您的 Web API 控制器中可能采用相同的方法。

您的控制器将实例化一个 EFContextProvider ... 或者,更好的是,一个 repository/unit-of-work 组件包装 EFContextProvider,并传递数据库 ID。

您可能无法在控制器的构造函数中获取数据库 ID,因为此时请求对象不可用。在此示例中,我们将在控制器的 Initialize 方法中将其告知存储库。

这是可能适合您的 Web API 控制器的开头:

[BreezeController]
public class YourController : ApiController {
    private readonly YourRepository _repository;

    // default ctor
    public YourController() : this(null) { }

    // Test / Dependency Injection ctor.
    // Todo: inject via an IYourRepository interface rather than "new" the concrete class
    public YourController(YourRepository repository) {
        _repository = repository ?? new YourRepository();
    }

    protected override void Initialize(HttpControllerContext controllerContext) {
        base.Initialize(controllerContext);
        _repository.SetDatabaseId(getDatabaseId());
    }

    /// <summary>
    /// Get the DatabaseId from ???
    /// </summary>
    private string getDatabaseId() {
        try {
            return ...; // your logic here. The 'Request' object is available now
        } catch  {
            return String.Empty;
        }
    }

    ...
}

当然 YourRepository 会延迟 EFContextProvider 的实例化,直到有人调用 SetDatabaseId

现在,如果您不是即时更改连接字符串,您就完成了。但是因为您是在最后一刻定义连接字符串,所以您需要创建 EFContextProvider 的子类并重写 CreateContext 方法,其默认实现是:

protected virtual T CreateContext() // 'T' is your DbContext type
{
  return Activator.CreateInstance<T>();
}

很明显,您必须做一些其他事情...任何适合实例化您的 DbContext 连接到与提供的数据库 ID 匹配的数据库的方法。这是您提到的 DBContextFactory 所在的位置。我假设你知道如何处理这个。

假设您的数据库中有这三个连接字符串,其中第一个是在您创建模型时由 EF 在设计时创建的。剩下的两个是你自己添加的,想在运行时使用。

<connectionStrings>
        <add name="TestDbContext" connectionString="metadata=res://*/Models.TestModel.csdl|res://*/Models.TestModel.ssdl|res://*/Models.TestModel.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=TEST_DB_SERVER\test_dev;initial catalog=Test_1;persist security info=True;user id=dbuser_1;password=pwd1;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
        <add name="TestDbContext_1" connectionString="metadata=res://*/Models.TestModel.csdl|res://*/Models.TestModel.ssdl|res://*/Models.TestModel.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=TEST_DB_SERVER\test_dev;initial catalog=Test_1;persist security info=True;user id=dbuser_1;password=pwd1;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
        <add name="TestDbContext_2" connectionString="metadata=res://*/Models.TestModel.csdl|res://*/Models.TestModel.ssdl|res://*/Models.TestModel.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=TEST_DB_SERVER\test_dev;initial catalog=Test_2;persist security info=True;user id=dbuser_2;password=pwd2;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
</connectionStrings>

假设您有一个 Breeze WebAPI 控制器 TestController,它在内部使用实现 ITestRepo 接口的 Repository class TestRepo。如果不是这种情况,那么您将必须遵循此模式,因为 Unity 依赖注入 (DI) 需要它。顺便说一下,我不打算深入研究如何获取 Unity DI 包以及类似内容的细节。因此,假设您安装了 Unity DI,下面是 UnityResolver class

的完整实现
using Microsoft.Practices.Unity;
using System;
using System.Collections.Generic;
using System.Web.Http.Dependencies;

namespace Test.Common.DI
{
    public class UnityResolver : IDependencyResolver
    {
        public IUnityContainer container;

        public UnityResolver(IUnityContainer container)
        {
            if (container == null)
            {
                throw new ArgumentNullException("container");
            }
            this.container = container;
        }

        public object GetService(Type serviceType)
        {
            try
            {
                return container.Resolve(serviceType);
            }
            catch (ResolutionFailedException)
            {
                return null;
            }
        }

 public IEnumerable<object> GetServices(Type serviceType)
        {
            try
            {
                return container.ResolveAll(serviceType);
            }
            catch (ResolutionFailedException)
            {
                return new List<object>();
            }
        }

        public IDependencyScope BeginScope()
        {
            var child = container.CreateChildContainer();
            return new UnityResolver(child);
        }

        public void Dispose()
        {
            container.Dispose();
        }
    }
}

下面是 Unity DI 在 WebApiConfig.cs 文件中的配置方式

using Test.Common.DI;
using Microsoft.Practices.Unity;
using QuickStaff.Controllers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;

namespace Test
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {

            string[] connNames = TestController.GetConnectionStringNamesCore();

                if (connNames.Length <= 0)
                {
                    throw new Exception("ERROR: There needs to be at least one connection string configured in the web.config file with a name starting with 'TestDbContext_'");
                }

                // Web API configuration and services
                var container = new UnityContainer();
                container.RegisterType<ITestRepo, TestRepo>(new HierarchicalLifetimeManager());
                container.RegisterInstance(new TestRepo(connNames[0])); // THIS IS NEEDED IN OERDER TO TRIGGER THE "TestController" CONSTRUCTOR THAT HAS ONE STRING ARGUMENT RATHER THAN THE DEFAULT
                config.DependencyResolver = new UnityResolver(container);


                // Web API routes
                config.MapHttpAttributeRoutes();

                config.Routes.MapHttpRoute(
                    name: "DefaultApi",
                    routeTemplate: "api/{controller}/{id}",
                    defaults: new { id = RouteParameter.Optional }
                );
            }
        }
    }

这是 EF 生成的模型和 DBContext class 采用数据库优先方法

//--------------------------------------------------------------------------    ----
// <auto-generated>
//     This code was generated from a template.
//
//     Manual changes to this file may cause unexpected behavior in your application.
//     Manual changes to this file will be overwritten if the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

namespace TestModels
{
    using System;
    using System.Data.Entity;
    using System.Data.Entity.Infrastructure;

    public partial class TestDbContext : DbContext
    {
        public TestDbContext()
            : base("name=TestDbContext")
        {
        }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            throw new UnintentionalCodeFirstException();
        }

        public virtual DbSet<EMP_EDUCATION> EMP_EDUCATION { get; set; }
        public virtual DbSet<EMP_POSITIONS> EMP_POSITIONS { get; set; }
        public virtual DbSet<EMP_STATUS> EMP_STATUS { get; set; }
        public virtual DbSet<EMP_TALENT_TYPES> EMP_TALENT_TYPES { get; set; }
        public virtual DbSet<EMPLOYEES> EMPLOYEES { get; set; }
        public virtual DbSet<LOCATION_TYPES> LOCATION_TYPES { get; set; }
        public virtual DbSet<LOCATIONS> LOCATIONS { get; set; }
        public virtual DbSet<POSITION_CATEGORIES> POSITION_CATEGORIES { get; set; }
        public virtual DbSet<PosJobClass> PosJobClass { get; set; }
        public virtual DbSet<PRJ_LOCATIONS> PRJ_LOCATIONS { get; set; }
        public virtual DbSet<PRJ_POSITIONS> PRJ_POSITIONS { get; set; }
        public virtual DbSet<PRJ_STATUS> PRJ_STATUS { get; set; }
        public virtual DbSet<PROJECTS> PROJECTS { get; set; }
        public virtual DbSet<REPORTS> REPORTS { get; set; }
    }
}

现在我们需要实现一个与上面同名的部分 class 以引入另一个构造函数,该构造函数将接受一个字符串参数,其中包含我们的用户将在客户端选择的连接字符串.所以这是那段代码

namespace Test.Models
{
    using Breeze.ContextProvider.EF6;
    using System;
    using System.Data.Entity;
    using System.Data.Entity.Core.EntityClient;
    using System.Data.Entity.Infrastructure;
    using System.Data.SqlClient;

    public partial class TestDbContext : DbContext
    {
        public TestDbContext(string connectionString)
            : base(connectionString)
        {
        }
    }
}

现在我们有一个 DbContext class,其构造函数将连接字符串作为其参数,但问题是我们如何调用第二个构造函数,因为我们无法直接调用它,因为我们使用的是 Breeze 的 EFContextProvider,它是负责调用 DbContext。好消息是我们可以覆盖 EFContextProvider,这里是代码

namespace Test.Models
{
    using Breeze.ContextProvider.EF6;
    using System;
    using System.Data.Entity;
    using System.Data.Entity.Core.EntityClient;
    using System.Data.Entity.Infrastructure;
    using System.Data.SqlClient;

    public class EFContextProviderEx<T> : EFContextProvider<T> where T : class, new()
    {
        private string _connectionString;

        public EFContextProviderEx(string connectionString){
            _connectionString = connectionString;
        }
        protected override T CreateContext()
        {
            return (T)Activator.CreateInstance(typeof(T), _connectionString);
        }
    }
}

好的,到目前为止一切顺利。我们现在需要使用我们介绍的上述构造函数。实现 ITestRepo 接口的 TestRepo class 是我们执行此操作的地方,这里是 Respository class 的代码以及用于完成的接口代码

using Breeze.ContextProvider.EF6;
using Test.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace Test.Controllers
{

    public interface ITestRepo
    {
        string Metadata();
        SaveResult SaveChanges(JObject saveBundle);

        IQueryable<POSITION_CATEGORIES> PositionCategories();
    }

    public class TestRepo : ITestRepo
    {
        //public readonly EFContextProvider<TestDbContext> _contextProvider = new EFContextProvider<TestDbContext>();
        public readonly EFContextProvider<TestDbContext> _contextProvider;

        public TestRepo(string connectionString)
        {
            _contextProvider = new EFContextProviderEx<TestDbContext>(connectionString);
        }

        public string Metadata()
        {
            return _contextProvider.Metadata();
        }

        public Breeze.ContextProvider.SaveResult SaveChanges(Newtonsoft.Json.Linq.JObject saveBundle)
        {
            return _contextProvider.SaveChanges(saveBundle);
        }


        public IQueryable<POSITION_CATEGORIES> PositionCategories()
        {
            return _contextProvider.Context.POSITION_CATEGORIES;
        }
    }
}

现在最后一块是我们的 Breeze 控制器。我们需要能够以某种方式将连接字符串信息传递给我们的 Breeze 控制器。我们这样做的方式是通过两件事的结合。 1) 通过提供一个构造函数,通过接口接收我们存储库 class 的一个实例,以及 2) 通过在我们的控制器上创建一个 HttpPost API 方法 (SetConnectionString(…)) 来设置所需的连接string 以便每当我们想要更改连接字符串时,我们只需调用它 API 然后我们的控制器开始针对适当的数据库工作。

让我们看一下控制器的代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using Breeze.ContextProvider;
using Breeze.ContextProvider.EF6;
using Breeze.WebApi2;
using Test.Models;
using System.Web.Http.Controllers;
using System.Web;
using Microsoft.Practices.Unity;
using System.Configuration;
using Test.Common.DI;


namespace Test.Controllers
{
    [BreezeController]
    public class TestController : ApiController
    {
        private const string TEST_DB_CNTXT_PREFIX = "testdbcontext_";

        //public readonly EFContextProvider<TestDbContext> _contextProvider = new EFContextProvider<TestDbContext>();
        private readonly ITestRepo _repo;

        //public TestController()
        //{
        // UNCOMMENT THIS IN CASE YOU HAVE SOME COMPILE ERROR ASKING FOR THE DEFAULT CONSTRUCTOR    
        //}

        public TestController(ITestRepo repository)  
        {
            _repo = repository;
        }

        [HttpGet]
        public string[] GetConnectionStringNames()
        {
            string[] connNames = GetConnectionStringNamesCore();

            SetConnectionStringCore(connNames[0]); // select this as the default on the UI too

            return connNames;
        }
        public static string[] GetConnectionStringNamesCore()
        {
            string[] connNames = new string[0];
            List<string> temp = new List<string>();
            for (int i = 0; i < ConfigurationManager.ConnectionStrings.Count; i++)
            {
                string cn = ConfigurationManager.ConnectionStrings[i].Name;
                if (cn.ToLower().StartsWith(TEST_DB_CNTXT_PREFIX))
                {
                    temp.Add(cn.Substring(TEST_DB_CNTXT_PREFIX.Length));
                }

            }
            connNames = temp.ToArray();
            return connNames;
        } 

        [HttpPost]
        public void SetConnectionString([FromUri] string connectionString)
        {
            connectionString = SetConnectionStringCore(connectionString);
        }
        private string SetConnectionStringCore(string connectionString)
        {
            connectionString = TEST_DB_CNTXT_PREFIX + connectionString;

            if (!string.IsNullOrEmpty(connectionString))
            {
                // REGISTER A NEW INSTANCE OF THE REPO CLASS WITH THE NEW CONN. STRING SO THAT ANY SUBSEQUENT CALLS TO OUR CONTROLLER WILL USE THIS INSTANCE AND THUS WE WILL BE TALKING TO THAT DATABASE
                UnityResolver r = (UnityResolver)(this.ControllerContext.Configuration.DependencyResolver);
                r.container.RegisterInstance(new TestRepo(connectionString));
            }
            return connectionString;
        } 

        [HttpGet]
        public string Metadata()
        {
            return _repo.Metadata();
        }
        [HttpPost]
        public SaveResult SaveChanges(Newtonsoft.Json.Linq.JObject saveBundle)
        {
            return _repo.SaveChanges(saveBundle);
        }
        [HttpGet]
        public IQueryable<POSITION_CATEGORIES> PositionCategories()
        {
            return _repo.PositionCategories().OrderBy(pc => pc.POS_CAT_CODE);
        }

        //// GET api/<controller>
        //public IEnumerable<string> Get()
        //{
        //    return new string[] { "value1", "value2" };
        //}

        //// GET api/<controller>/5
        //public string Get(int id)
        //{
        //    return "value";
        //}

        //// POST api/<controller>
        //public void Post([FromBody]string value)
        //{
        //}

        //// PUT api/<controller>/5
        //public void Put(int id, [FromBody]string value)
        //{
        //}

        //// DELETE api/<controller>/5
        //public void Delete(int id)
        //{
        //}
    }
}

正如您在上面的代码中看到的,魔术发生在 SetConnectionStringCore(…) 内部,它由 SetConnectionString(…) 调用。基本上我们正在做的是,在 UnityResolver 的帮助下,我们告诉 WebAPI 框架将哪个 TestRepo class 实例注入我们的 WebAPI 控制器。

如果您正在浏览其余代码,那么客户端(在我的例子中是 Angular SPA)预计将对 GetConnectionStringNames() 方法进行 http 调用我们的控制器获取所有可用的连接字符串并将其呈现给用户,以便他可以选择一个。一旦他选择了一个连接字符串,客户端就会调用控制器上的 SetConnectionString(…) 方法将其传送到 WebAPI 并且客户端之后进行的任何调用都会针对该数据库执行。另请注意,由于存在一些解析代码,我已选择向客户端提供连接字符串的一部分。但是你可以有自己的逻辑。要记住的一点是,在 WebApiConfig.cs 文件中,我们最初使用遇到的第一个连接字符串。

我希望这对其他人有帮助,因为我真的很难做到这一点。但我仍然要感谢那些通过他们的帖子帮助过我的人。这是我查阅过的页面列表。

http://www.asp.net/web-api/overview/advanced/dependency-injection

https://myadventuresincoding.wordpress.com/2013/03/27/c-using-unity-for-dependency-injection-in-an-asp-net-mvc-4-web-api/

Using a dynamic connection string with the Breeze EFContextProvider

http://cosairus.com/Blog/2015/3/10/programmatic-connection-strings-in-entity-framework-6

http://blogs.msdn.com/b/jmstall/archive/2012/05/11/per-controller-configuration-in-webapi.aspx

如您所见,我不需要重写 WebAPI 控制器的 Initialize 方法 protected override void Initialize(HttpControllerContext controllerContext)

您也可以在以下位置找到此解决方案 https://sskasim.wordpress.com/

更新: 上面有问题。它不适用于多用户场景,因为我们正在更改 Web API 控制器的连接字符串,而不是控制器的实例。因此,您将必须使用 ASP.NET Session 并使用适当的 connectionString 存储 _repo 的实例。

sskasim,你的回答很好,我很抱歉没有早点回复这个来解释我最后做了什么。我最终使用 DbContextFactory 建立与正确数据库的连接,客户端在每次调用时发送它想要连接的数据库。我也没有在这里使用 Unity,尽管这将是一个很好的改进。这是我所做的,以防将来对其他人有所帮助。

当用户登录时,他们提供一个帐号,该帐号对应于 web.config 中的 connectionString 条目,指向要使用的数据库。连接字符串的名称采用 "MyEntities_XXX" 的格式,其中 XXX 是帐号。所以在我客户端的 entityManagerFactory 中,我添加了以下几行以将帐号添加到每次回调到服务器的标题中。

    var adapter = breeze.config.getAdapterInstance('ajax');
    adapter.defaultSettings = {
        headers: { "account": account.user.accountNumber }
    };

然后在 Breeze 控制器中,我覆盖了 Initialize 方法以从 headers 中解析出帐号并将其传递到我的存储库。

[BreezeController]
public class MyController : ApiController
{
    private readonly MyRepository _repository = new MyRepository();

    protected override void Initialize(HttpControllerContext controllerContext)
    {
        base.Initialize(controllerContext);

        IEnumerable<string> values;
        if (Request.Headers.TryGetValues("account", out values))
            _repository.SetAccountNumber(values.FirstOrDefault());
    }

    ...
}

当在 repo 上调用 SetAccountNumber 方法时,它会初始化一个新的 MyContextProvider,将帐号传递给构造函数。我覆盖了 EFContextProvider 中的 CreateContext 方法,以使用我的工厂创建上下文。这些 class 片段在下面。

public class MyRepository
{
    private MyContextProvider _contextProvider = new MyContextProvider("");

    private MyContext Context { get { return _contextProvider.Context; } }


    public void SetAccountNumber(string accountNumber)
    {
        _contextProvider = new MyContextProvider(accountNumber);
    }
}


public class MyContextProvider : EFContextProvider<MyContext>
{
    private readonly MyContextFactory _contextFactory;

    public MyContextProvider(string accountNumber)
    {
        _contextFactory = new MyContextFactory();
        _contextFactory.AccountNumber = accountNumber;
    }

    protected override MyContext CreateContext()
    {
        var context = _contextFactory.Create();
        return context;
    }
}


public class MyContextFactory : IDbContextFactory<MyContext>
{
    public string AccountNumber { get; set; }

    public MyContext Create()
    {
        var dbName = "MyEntities" + (string.IsNullOrEmpty(AccountNumber) ? "" : "_" + AccountNumber);
        var contextInfo = new DbContextInfo(typeof(MyContext), new DbConnectionInfo(dbName));
        var context = contextInfo.CreateInstance() as MyContext;

        return context;
    }
}

此处唯一的 "gotcha" 是需要在 web.config 中为 "MyEntities" 提供通用条目,EF 在生成元数据时将使用该条目。这就是为什么你看到我最初创建了一个空字符串作为帐号的 ContextProvider。