淺談.net core如何使用EFCore為一個上下文注型別注入多個例項用於連線主從資料庫

a1010發表於2022-02-14

在很多一主多從資料庫的場景下,很多開發同學為了複用DbContext往往採用建立一個包含所有DbSet<Model>父類通過繼承派生出Write和ReadOnly型別來實現,其實可以通過命名注入來實現一個型別註冊多個例項來實現。下面來用程式碼演示一下。

一、環境準備

資料庫選擇比較流行的postgresql,我們這裡選擇使用helm來快速的從開源包管理社群bitnami拉取一個postgresql的chart來搭建一個簡易的主從資料庫作為環境,,執行命令如下:

注意這裡我們需要申明architecture為replication來建立主從架構,否則預設的standalone只會建立一個例項副本。同時我們需要暴露一下svc的埠用於驗證以及預設一下root的密碼,避免從secret重新查詢。

helm repo add bitnami https://charts.bitnami.com/bitnami
helm install mypg --set global.postgresql.auth.postgresPassword=Mytestpwd#123 --set architecture=replication --set primary.service.type=NodePort --set primary.service.nodePorts.postgresql=32508 --set readReplicas.service.type=NodePort --set readReplicas.service.nodePorts.postgresql=31877 bitnami/postgresql

關於helm安裝叢集其他方面的細節可以檢視文件,這裡不再展開。安裝完成後我們可以get po 以及get svc看到主從例項已經部署好了,並且服務也按照預期暴露好埠了(注意hl開頭的是無頭服務,一般情況下不需要管他預設我們採用k8s自帶的svc轉發。如果有特殊的負載均衡需求時可以使用他們作為dns服務提供真實後端IP來實現定製化的連線)

 接著我們啟動PgAdmin連線一下這兩個庫,看看主從庫是否順利工作

 可以看到能夠正確連線,接著我們建立一個資料庫,看看從庫是否可以正確非同步訂閱並同步過去

 

可以看到資料庫這部分應該是可以正確同步了,當然為了測試多個從庫,你現在可以通過以下命令來實現只讀副本的擴容,接下來我們開始第二階段。

kubectl scale --replicas=n statefulset/mypg-postgresql-read

二、實現單一上下文的多例項注入

首先我們建立一個常規的webapi專案,並且引入ef和pgqsql相關的nuget。同時由於需要做資料庫自動化遷移我們引入efcore.tool包,並且引入autofac作為預設的DI容器(由於預設的DI不支援在長週期例項(HostedService-singleton)注入短週期例項(DbContext-scoped))

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.1">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
    <PackageReference Include="Autofac.Extensions.DependencyInjection" Version="7.2.0" />
  </ItemGroup>

接著我們建立efcontext以及一個model

    public class EfContext : DbContext
    {
        public DbSet<User> User { get; set; }
        public EfContext(DbContextOptions<EfContext> options) : base(options) { }
    }
    public class User
    {
        [Key]
        public int Id { get; set; }
        public string Name { get; set; }
    }

然後我們建立對應的讀寫上下文的工廠用於自動化切換,並建立一個擴充套件函式用於註冊上下文到多個例項,同時要記得建立對應的介面用於DI容器註冊時的key

首先是我們核心的擴充套件庫,這是實現多個例項註冊的關鍵:

    public static class MultipleEfContextExtension
    {
        private static AsyncLocal<ReadWriteType> type = new AsyncLocal<ReadWriteType>();
        public static IServiceCollection AddReadWriteDbContext<Context>(this IServiceCollection services, Action<DbContextOptionsBuilder> writeBuilder, Action<DbContextOptionsBuilder> readBuilder) where Context : DbContext, IContextWrite, IContextRead
        {
            services.AddDbContext<Context>((serviceProvider, builder) =>
            {
                if (type.Value == ReadWriteType.Read)
                    readBuilder(builder);
                else
                    writeBuilder(builder);
            }, contextLifetime: ServiceLifetime.Transient, optionsLifetime: ServiceLifetime.Transient);
            services.AddScoped<IContextWrite, Context>(services => {
                type.Value = ReadWriteType.Write;
                return services.GetService<Context>();
            });
            services.AddScoped<IContextRead, Context>(services => {
                type.Value = ReadWriteType.Read;
                return services.GetService<Context>();
            });
            return services;
        }
    }

接著是我們需要申明的讀寫介面以及註冊上下文工廠:

    public interface IContextRead
    {

    }
    public interface IContextWrite
    {

    }
    public class ContextFactory<TContext> where TContext : DbContext
    {
        private ReadWriteType asyncReadWriteType = ReadWriteType.Read;
        private readonly TContext contextWrite;
        private readonly TContext contextRead;
        public ContextFactory(IContextWrite contextWrite, IContextRead contextRead)
        {
            this.contextWrite = contextWrite as TContext;
            this.contextRead = contextRead as TContext;
        }
        public TContext Current { get { return asyncReadWriteType == ReadWriteType.Read ? contextRead : contextWrite; } }
        public void SetReadWrite(ReadWriteType readWriteType)
        {
            //只有型別為非強制寫時才變化值
            if (asyncReadWriteType != ReadWriteType.ForceWrite)
            {
                asyncReadWriteType = readWriteType;
            }
        }
        public ReadWriteType GetReadWrite()
        {
            return asyncReadWriteType;
        }
    }

同時修改一下EF上下文的繼承,讓上下文繼承這兩個介面:

public class EfContext : DbContext, IContextWrite, IContextRead

然後我們需要在program裡使用這個擴充套件並注入主從庫對應的連線配置

builder.Services.AddReadWriteDbContext<EfContext>(optionsBuilderWrite =>
{
    optionsBuilderWrite.UseNpgsql("User ID=postgres;Password=Mytestpwd#123;Host=192.168.1.x;Port=32508;Database=UserDb;Pooling=true;");
}, optionsBuilderRead =>
{
    optionsBuilderRead.UseNpgsql("User ID=postgres;Password=Mytestpwd#123;Host=192.168.1.x;Port=31877;Database=UserDb;Pooling=true;");
});

同時這裡需要註冊一個啟動服務用於資料庫自動化遷移(注意這裡需要注入寫庫例項,連線只讀庫例項則無法建立資料庫遷移)

builder.Services.AddHostedService<MyHostedService>();
    public class MyHostedService : IHostedService
    {
        private readonly EfContext context;
        public MyHostedService(IContextWrite contextWrite)
        {
            this.context = contextWrite as EfContext;
        }
        public async Task StartAsync(CancellationToken cancellationToken)
        {
            context.Database.EnsureCreated();
            await Task.CompletedTask;
        }

        public async Task StopAsync(CancellationToken cancellationToken)
        {
            await Task.CompletedTask;
        }
    }

再然後我們建立一些傳統的工作單元和倉儲用於簡化orm的操作,並且在準備在控制器開始進行演示

首先定義一個簡單的IRepository並實現幾個常規的方法,接著我們在Repository裡實現它,這裡會有幾個關鍵程式碼我已經標紅

    public interface IRepository<T>
    {
        bool Add(T t);
        bool Update(T t);
        bool Remove(T t);
        T Find(object key);
        IQueryable<T> GetByCond(Expression<Func<T, bool>> cond);
    }
     public class Repository<T> : IRepository<T> where T:class
    {
        private readonly ContextFactory<EfContext> contextFactory;
        private EfContext context { get { return contextFactory.Current; } }
        public Repository(ContextFactory<EfContext> contextFactory)
        {
            this.contextFactory = contextFactory;
        }
        public bool Add(T t)
        {
            contextFactory.SetReadWrite(ReadWriteType.Write);
            context.Add(t);
            return true;
        }

        public bool Remove(T t)
        {
            contextFactory.SetReadWrite(ReadWriteType.Write);
            context.Remove(t);
            return true;
        }

        public T Find(object key)
        {
            contextFactory.SetReadWrite(ReadWriteType.Read);
            var entity = context.Find(typeof(T), key);
            return entity as T;
        }

        public IQueryable<T> GetByCond(Expression<Func<T, bool>> cond)
        {
            contextFactory.SetReadWrite(ReadWriteType.Read);
            return context.Set<T>().Where(cond);
        }

        public bool Update(T t)
        {
            contextFactory.SetReadWrite(ReadWriteType.Write);
            context.Update(t);
            return true;
        }
    }

可以看到這些方法就是自動化切庫的關鍵所在,接著我們再實現對應的工作單元用於統一提交和事務,並注入到容器中,這裡需要注意到工作單元開啟事務後,傳遞的列舉是強制寫,也就是會忽略倉儲預設的讀寫策略,強制工廠返回寫庫例項,從而實現事務一致。

    public interface IUnitofWork
    {
        bool Commit(IDbContextTransaction tran = null);
        Task<bool> CommitAsync(IDbContextTransaction tran = null);
        IDbContextTransaction BeginTransaction();
        Task<IDbContextTransaction> BeginTransactionAsync();
    }

    public class UnitOnWorkImpl<TContext> : IUnitofWork where TContext : DbContext
    {
        private TContext context { get { return contextFactory.Current; } }
        private readonly ContextFactory<TContext> contextFactory;
        public UnitOnWorkImpl(ContextFactory<TContext> contextFactory)
        {
            this.contextFactory = contextFactory;
        }
        public bool Commit(IDbContextTransaction tran = null)
        {
            var result = context.SaveChanges() > -1;
            if (result && tran != null)
                tran.Commit();
            return result;
        }
        public async Task<bool> CommitAsync(IDbContextTransaction tran = null)
        {
            var result = (await context.SaveChangesAsync()) > -1;
            if (result && tran != null)
                await tran.CommitAsync();
            return result;
        }
        public IDbContextTransaction BeginTransaction()
        {
            contextFactory.SetReadWrite(ReadWriteType.ForceWrite);
            return context.Database.BeginTransaction();
        }
        public async Task<IDbContextTransaction> BeginTransactionAsync()
        {
            contextFactory.SetReadWrite(ReadWriteType.ForceWrite);
            return await context.Database.BeginTransactionAsync();
        }
    }

最後我們將工作單元和倉儲註冊到容器裡:

            serviceCollection.AddScoped<IUnitofWork, UnitOnWorkImpl<Context>>();
            serviceCollection.AddScoped<ContextFactory<Context>>();
            typeof(Context).GetProperties().Where(x => x.PropertyType.IsGenericType && typeof(DbSet<>).IsAssignableFrom(x.PropertyType.GetGenericTypeDefinition())).Select(x => x.PropertyType.GetGenericArguments()[0]).ToList().ForEach(x => serviceCollection.AddScoped(typeof(IRepository<>).MakeGenericType(x), typeof(Repository<>).MakeGenericType(x)));

這裡的關鍵點在於開啟事務後所有的資料庫請求必須強制提交到主庫,而非事務情況下那種根據倉儲操作型別去訪問各自的讀寫庫,所以這裡傳遞一個ForceWrite作為區分。基本的工作就差不多做完了,現在我們設計一個控制器來演示,程式碼如下:

    [Route("{Controller}/{Action}")]
    public class HomeController : Controller
    {
        private readonly IUnitofWork unitofWork;
        private readonly IRepository<User> repository;
        public HomeController(IUnitofWork unitofWork, IRepository<User> repository)
        {
            this.unitofWork = unitofWork;
            this.repository = repository;
        }
        [HttpGet]
        [Route("{id}")]
        public string Get(int id)
        {
            return JsonSerializer.Serialize(repository.Find(id), new JsonSerializerOptions() { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) });
        }
        [HttpGet]
        [Route("{id}/{name}")]
        public async Task<bool> Get(int id, string name)
        {
            using var tran = await unitofWork.BeginTransactionAsync();
            var user = repository.Find(id);
            if (user == null)
            {
                user = new User() { Id = id, Name = name };
                repository.Add(user);
            }
            else
            {
                user.Name = name;
                repository.Update(user);
            }
            unitofWork.Commit(tran);
            return true;
        }
        [HttpGet]
        [Route("/all")]
        public async Task<string> GetAll()
        {
            return JsonSerializer.Serialize(await repository.GetByCond(x => true).ToListAsync(), new JsonSerializerOptions() { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) });
        }

控制器就是比較簡單的三個action,根據id和查所有以及開啟一個事務做事務查詢+編輯 or 新增。現在我們啟動專案,來測試一下介面是否正常工作

我們分別訪問/all /home/get/1 和/home/get/1/小王 ,然後再次訪問/all和get/1。可以看到成功的寫入了。

再看看資料庫的情況,可以看到主從庫都已經成功同步了。

 

 現在我們嘗試用事務連線到從庫試試能否寫入,我們修改以下程式碼:讓上下文工廠獲取到列舉值是ForceWrite時返回錯誤的只讀例項試試:

    public class ContextFactory<TContext> where TContext : DbContext
    {
        ......
        public TContext Current { get { return readWriteType.Value == ReadWriteType.ForceWrite ? contextRead : contextWrite; } }
        ......
    }

 接著重啟專案,訪問/home/get/1/小王,可以看到連線從庫的情況下無法正常寫入,同時也驗證了確實可以通過這樣的方式讓單個上下文型別按需連線資料庫了。

 

相關文章