efcore使用ShardingCore實現分表分庫下的多租戶
- 2022 年 1 月 11 日
- 筆記
- efcore, Sharding, sharding-core, shardingcore, 分庫, 分表, 多租戶
efcore使用ShardingCore實現分表分庫下的多租戶
介紹
本期主角:ShardingCore
一款ef-core下高性能、輕量級針對分表分庫讀寫分離的解決方案,具有零依賴、零學習成本、零業務程式碼入侵
dotnet下唯一一款全自動分表,多欄位分表框架,擁有高性能,零依賴、零學習成本、零業務程式碼入侵,並且支援讀寫分離動態分表分庫,同一種路由可以完全自定義的新星組件,通過本框架你不但可以學到很多分片的思想和技巧,並且更能學到Expression
的奇思妙用
你的star和點贊是我堅持下去的最大動力,一起為.net生態提供更好的解決方案
項目地址
背景
因為之前有小夥伴在使用ShardingCore
的時候問過我是否可以利用ShardingCore的分庫功能實現多租戶呢,我的回答是可以的,但是需要針對分庫對象進行路由的編寫,相當於我一個項目需要實現多租戶所有的表都需要實現分庫才可以,那麼這個在實際應用中將是不切實際的,所以雖然分庫可以用來進行多租戶但是一般沒人會真的這樣操作,那麼就沒有辦法在ShardingCore使用合理的多租戶外加分表分庫了嗎,針對這個問題ShardingCore
在新的版本x.4.x.x+中進行了實現
功能
ShardingCore
x.4.x.x+版本中具體實現了哪些功能呢
- 多配置支援,可以針對每個租戶或者這個配置進行單獨的分表分庫讀寫分離的鏈接配置
- 多資料庫配置,支援多配置下每個配置都可以擁有自己的資料庫來進行分表分庫讀寫分離
- 動態多配置,支援動態添加多配置(目前不支援動態刪減多配置,後續會支援如果有需要)
場景
假設我們有這麼一個多租戶系統,這個系統在我們創建好帳號後會分配給我們一個單獨的資料庫和對應的表資訊,之後用戶可以利用這個租戶配置資訊進行操作處理
首先我們創建一個AspNetCore的項目
這邊才用的.Net6版本的webapi
添加依賴
這邊我們添加了三個包,分別是ShardingCore
,Microsoft.EntityFrameworkCore.SqlServer
,Pomelo.EntityFrameworkCore.MySql
,其中ShardingCore
用的是預覽版的如果不勾選那麼將無法顯示出來,為什麼我們需要添加額外的兩個資料庫驅動呢,原因是因為我們需要在不同的租戶下實現不同的資料庫的配置,比如租戶A和我們簽訂的協議裡面有說明系統使用開源資料庫,或者希望使用Linux平台那麼可以針對租戶A進行配置MySql
或者PgSql
,租戶B是資深軟粉說需要使用MSSQL
那麼就可以針對其配置MSSQL
.一般情況下我們可能不會出現多資料庫的情況但是為了照顧到特殊情況我們這邊也針對這種情況進行了支援。
公共用戶存儲
首先在我還沒有創建租戶的時候是不存在資料庫的所以我的數據自然而然不會存在當前租戶下,這邊我們採用的是存儲到其他資料庫中,假設我們使用一個公共的資料庫作為用戶系統.
創建用戶系統
創建系統用戶和創建系統用戶在資料庫內的映射關係
public class SysUser
{
public string Id { get; set; }
public string Name { get; set; }
public string Password { get; set; }
public DateTime CreationTime { get; set; }
public bool IsDeleted { get; set; }
}
public class SysUserMap:IEntityTypeConfiguration<SysUser>
{
public void Configure(EntityTypeBuilder<SysUser> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).IsRequired().IsUnicode(false).HasMaxLength(50);
builder.Property(o => o.Name).IsRequired().HasMaxLength(50);
builder.Property(o => o.Password).IsRequired().IsUnicode(false).HasMaxLength(50);
builder.HasQueryFilter(o => o.IsDeleted == false);
builder.ToTable(nameof(SysUser));
}
}
創建這個資料庫該有的配置資訊表,便於後期啟動後重建
public class SysUserTenantConfig
{
public string Id { get; set; }
public string UserId { get; set; }
/// <summary>
/// 添加ShardingCore配置的Json包
/// </summary>
public string ConfigJson { get; set; }
public DateTime CreationTime { get; set; }
public bool IsDeleted { get; set; }
}
public class SysUserTenantConfigMap:IEntityTypeConfiguration<SysUserTenantConfig>
{
public void Configure(EntityTypeBuilder<SysUserTenantConfig> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).IsRequired().IsUnicode(false).HasMaxLength(50);
builder.Property(o => o.UserId).IsRequired().IsUnicode(false).HasMaxLength(50);
builder.Property(o => o.ConfigJson).IsRequired().HasMaxLength(2000);
builder.HasQueryFilter(o => o.IsDeleted == false);
builder.ToTable(nameof(SysUserTenantConfig));
}
}
創建對應的系統用戶存儲DbContext
public class IdentityDbContext:DbContext
{
public IdentityDbContext(DbContextOptions<IdentityDbContext> options):base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new SysUserMap());
modelBuilder.ApplyConfiguration(new SysUserTenantConfigMap());
}
}
創建一個租戶的DbContext
public class TenantDbContext:AbstractShardingDbContext,IShardingTableDbContext
{
public TenantDbContext(DbContextOptions<TenantDbContext> options) : base(options)
{
}
public IRouteTail RouteTail { get; set; }
}
目前我們先定義好後續進行編寫內部的租戶程式碼
創建動態租戶參數
動態租戶分片配置資訊在ShardingCore
只需要實現IVirtualDataSourceConfigurationParams<TShardingDbContext>
介面,但是這個介面有很多參數需要填寫,所以這邊框架針對這個介面進行了默認參數的抽象類AbstractVirtualDataSourceConfigurationParams<TShardingDbContext>
。
這邊我們針對配置參數進行配置採用新建一個配置json的對象
public class ShardingTenantOptions
{
public string ConfigId { get; set;}
public int Priority { get; set;}
public string DefaultDataSourceName { get; set;}
public string DefaultConnectionString { get; set;}
public DbTypeEnum DbType { get; set; }
}
參數裡面配置了當前資料庫,這邊比較簡單我們就暫時使用單表分庫的模式來實現,目前暫時不對每個租戶分庫進行演示。之後並且編寫SqlServer
和MySql
的配置支援
public class SqlShardingConfiguration : AbstractVirtualDataSourceConfigurationParams<TenantDbContext>
{
private static readonly ILoggerFactory efLogger = LoggerFactory.Create(builder =>
{
builder.AddFilter((category, level) => category == DbLoggerCategory.Database.Command.Name && level == LogLevel.Information).AddConsole();
});
public override string ConfigId { get; }
public override int Priority { get; }
public override string DefaultDataSourceName { get; }
public override string DefaultConnectionString { get; }
public override ITableEnsureManager TableEnsureManager { get; }
private readonly DbTypeEnum _dbType;
public SqlShardingConfiguration(ShardingTenantOptions options)
{
ConfigId = options.ConfigId;
Priority = options.Priority;
DefaultDataSourceName = options.DefaultDataSourceName;
DefaultConnectionString = options.DefaultConnectionString;
_dbType = options.DbType;
//用來快速判斷是否存在資料庫中的表
if (_dbType == DbTypeEnum.MSSQL)
{
TableEnsureManager = new SqlServerTableEnsureManager<TenantDbContext>();
}
else if (_dbType == DbTypeEnum.MYSQL)
{
TableEnsureManager = new MySqlTableEnsureManager<TenantDbContext>();
}
else
{
throw new NotImplementedException();
}
}
public override DbContextOptionsBuilder UseDbContextOptionsBuilder(string connectionString,
DbContextOptionsBuilder dbContextOptionsBuilder)
{
switch (_dbType)
{
case DbTypeEnum.MSSQL:
{
dbContextOptionsBuilder.UseSqlServer(connectionString).UseLoggerFactory(efLogger);
}
break;
case DbTypeEnum.MYSQL:
{
dbContextOptionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version())).UseLoggerFactory(efLogger);
}
break;
default: throw new NotImplementedException();
}
return dbContextOptionsBuilder;
}
public override DbContextOptionsBuilder UseDbContextOptionsBuilder(DbConnection dbConnection,
DbContextOptionsBuilder dbContextOptionsBuilder)
{
switch (_dbType)
{
case DbTypeEnum.MSSQL:
{
dbContextOptionsBuilder.UseSqlServer(dbConnection).UseLoggerFactory(efLogger);
}
break;
case DbTypeEnum.MYSQL:
{
dbContextOptionsBuilder.UseMySql(dbConnection, new MySqlServerVersion(new Version())).UseLoggerFactory(efLogger);
}
break;
default: throw new NotImplementedException();
}
return dbContextOptionsBuilder;
}
}
編寫用戶註冊介面
[Route("api/[controller]/[action]")]
[ApiController]
[AllowAnonymous]
public class PassportController:ControllerBase
{
private readonly IdentityDbContext _identityDbContext;
public PassportController(IdentityDbContext identityDbContext)
{
_identityDbContext = identityDbContext;
}
[HttpPost]
public async Task<IActionResult> Register(RegisterRequest request)
{
if (await _identityDbContext.Set<SysUser>().AnyAsync(o => o.Name == request.Name))
return BadRequest("user not exists");
var sysUser = new SysUser()
{
Id = Guid.NewGuid().ToString("n"),
Name = request.Name,
Password = request.Password,
CreationTime=DateTime.Now
};
var shardingTenantOptions = new ShardingTenantOptions()
{
ConfigId = sysUser.Id,
Priority = new Random().Next(1,10),
DbType = request.DbType,
DefaultDataSourceName = "ds0",
DefaultConnectionString = GetDefaultString(request.DbType,sysUser.Id)
};
var sysUserTenantConfig = new SysUserTenantConfig()
{
Id = Guid.NewGuid().ToString("n"),
UserId = sysUser.Id,
CreationTime = DateTime.Now,
ConfigJson = JsonConvert.SerializeObject(shardingTenantOptions)
};
await _identityDbContext.AddAsync(sysUser);
await _identityDbContext.AddAsync(sysUserTenantConfig);
await _identityDbContext.SaveChangesAsync();
//註冊完成後進行配置生成
DynamicShardingHelper.DynamicAppendVirtualDataSourceConfig(new SqlShardingConfiguration(shardingTenantOptions));
return Ok();
}
[HttpPost]
public async Task<IActionResult> Login(LoginRequest request)
{
var sysUser = await _identityDbContext.Set<SysUser>().FirstOrDefaultAsync(o=>o.Name==request.Name&&o.Password==request.Password);
if (sysUser == null)
return BadRequest("name or password error");
//秘鑰,就是標頭,這裡用Hmacsha256演算法,需要256bit的密鑰
var securityKey = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes("123123!@#!@#123123")), SecurityAlgorithms.HmacSha256);
//Claim,JwtRegisteredClaimNames中預定義了好多種默認的參數名,也可以像下面的Guid一樣自己定義鍵名.
//ClaimTypes也預定義了好多類型如role、email、name。Role用於賦予許可權,不同的角色可以訪問不同的介面
//相當於有效載荷
var claims = new Claim[] {
new Claim(JwtRegisteredClaimNames.Iss,"//localhost:5000"),
new Claim(JwtRegisteredClaimNames.Aud,"api"),
new Claim("id",Guid.NewGuid().ToString("n")),
new Claim("uid",sysUser.Id),
};
SecurityToken securityToken = new JwtSecurityToken(
signingCredentials: securityKey,
expires: DateTime.Now.AddHours(2),//過期時間
claims: claims
);
var token = new JwtSecurityTokenHandler().WriteToken(securityToken);
return Ok(token);
}
private string GetDefaultString(DbTypeEnum dbType, string userId)
{
switch (dbType)
{
case DbTypeEnum.MSSQL: return $"Data Source=localhost;Initial Catalog=DB{userId};Integrated Security=True;";
case DbTypeEnum.MYSQL: return $"server=127.0.0.1;port=3306;database=DB{userId};userid=root;password=L6yBtV6qNENrwBy7;";
default: throw new NotImplementedException();
}
}
}
public class RegisterRequest
{
public string Name { get; set; }
public string Password { get; set; }
public DbTypeEnum DbType { get; set; }
}
public class LoginRequest
{
public string Name { get; set; }
public string Password { get; set; }
}
簡單來說明一下,這邊我們採用的是用戶的id作為租戶id,將租戶id作為資料庫配置,來支援多配置模式。到此為止我們的用戶系統就已經完成了是不是十分的簡單僅僅幾段程式碼,用戶這邊註冊完成後將會創建對應的資料庫和對應的表,如果你是分表的那麼將會自動創建對應的資料庫表等資訊。
租戶系統
租戶系統我們做一個訂單的簡單演示,使用訂單id取模,取模取5來進行分表操作
新增租戶系統的訂單資訊
public class Order
{
public string Id { get; set; }
public string Name { get; set; }
public DateTime CreationTime { get; set; }
public bool IsDeleted { get; set; }
}
public class OrderMap:IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).IsRequired().IsUnicode(false).HasMaxLength(50);
builder.Property(o => o.Name).IsRequired().HasMaxLength(100);
builder.HasQueryFilter(o => o.IsDeleted == false);
builder.ToTable(nameof(Order));
}
}
新增訂單路由
public class OrderVirtualTableRoute:AbstractSimpleShardingModKeyStringVirtualTableRoute<Order>
{
public OrderVirtualTableRoute() : base(2, 5)
{
}
public override void Configure(EntityMetadataTableBuilder<Order> builder)
{
builder.ShardingProperty(o => o.Id);
}
}
簡單的字元串取模
添加租戶中間件
添加租戶中間件,在系統中如果使用多配置那麼就必須要指定本次創建的dbcontext使用的是哪個配置
public class TenantSelectMiddleware
{
private readonly RequestDelegate _next;
private readonly IVirtualDataSourceManager<TenantDbContext> _virtualDataSourceManager;
public TenantSelectMiddleware(RequestDelegate next, IVirtualDataSourceManager<TenantDbContext> virtualDataSourceManager)
{
_next = next;
_virtualDataSourceManager = virtualDataSourceManager;
}
public async Task Invoke(HttpContext context)
{
if (context.Request.Path.ToString().StartsWith("/api/tenant", StringComparison.CurrentCultureIgnoreCase))
{
if (!context.User.Identity.IsAuthenticated)
{
await DoUnAuthorized(context, "not found tenant id");
return;
}
var tenantId = context.User.Claims.FirstOrDefault((o) => o.Type == "uid")?.Value;
if (string.IsNullOrWhiteSpace(tenantId))
{
await DoUnAuthorized(context, "not found tenant id");
return;
}
using (_virtualDataSourceManager.CreateScope(tenantId))
{
await _next(context);
}
}
else
{
await _next(context);
}
}
private async Task DoUnAuthorized(HttpContext context, string msg)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync(msg);
}
}
該中間件攔截/api/tenant
路徑下的所有請求並且針對這些請求添加對應的租戶資訊
配置租戶擴展初始化數據
public static class TenantExtension
{
public static void InitTenant(this IServiceProvider serviceProvider)
{
using (var scope = serviceProvider.CreateScope())
{
var identityDbContext = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
identityDbContext.Database.EnsureCreated();
var sysUserTenantConfigs = identityDbContext.Set<SysUserTenantConfig>().ToList();
if (sysUserTenantConfigs.Any())
{
foreach (var sysUserTenantConfig in sysUserTenantConfigs)
{
var shardingTenantOptions = JsonConvert.DeserializeObject<ShardingTenantOptions>(sysUserTenantConfig.ConfigJson);
DynamicShardingHelper.DynamicAppendVirtualDataSourceConfig(
new SqlShardingConfiguration(shardingTenantOptions));
}
}
}
}
}
這邊因為我們針對租戶資訊進行了初始化而不是硬編碼,所以需要一個在啟動的時候對租戶資訊進行動態添加
配置多租戶
啟動配置Startup
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddAuthentication();
#region 用戶系統配置
builder.Services.AddDbContext<IdentityDbContext>(o =>
o.UseSqlServer("Data Source=localhost;Initial Catalog=IdDb;Integrated Security=True;"));
//生成密鑰
var keyByteArray = Encoding.ASCII.GetBytes("123123!@#!@#123123");
var signingKey = new SymmetricSecurityKey(keyByteArray);
//認證參數
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateIssuer = true,
ValidIssuer = "//localhost:5000",
ValidateAudience = true,
ValidAudience = "api",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
RequireExpirationTime = true,
};
});
#endregion
#region 配置ShardingCore
builder.Services.AddShardingDbContext<TenantDbContext>()
.AddEntityConfig(op =>
{
op.CreateShardingTableOnStart = true;
op.EnsureCreatedWithOutShardingTable = true;
op.AddShardingTableRoute<OrderVirtualTableRoute>();
})
.AddConfig(op =>
{
//默認配置一個
op.ConfigId = $"test_{Guid.NewGuid():n}";
op.Priority = 99999;
op.AddDefaultDataSource("ds0", "Data Source=localhost;Initial Catalog=TestTenantDb;Integrated Security=True;");
op.UseShardingQuery((conStr, b) =>
{
b.UseSqlServer(conStr);
});
op.UseShardingTransaction((conn, b) =>
{
b.UseSqlServer(conn);
});
}).EnsureMultiConfig(ShardingConfigurationStrategyEnum.ThrowIfNull);
#endregion
var app = builder.Build();
// Configure the HTTP request pipeline.
app.Services.GetRequiredService<IShardingBootstrapper>().Start();
//初始化啟動配置租戶資訊
app.Services.InitTenant();
app.UseAuthorization();
app.UseAuthorization();
//在認證後啟用租戶選擇中間件
app.UseMiddleware<TenantSelectMiddleware>();
app.MapControllers();
app.Run();
編寫租戶操作
[Route("api/tenant/[controller]/[action]")]
[ApiController]
[Authorize(AuthenticationSchemes = "Bearer")]
public class TenantController : ControllerBase
{
private readonly TenantDbContext _tenantDbContext;
public TenantController(TenantDbContext tenantDbContext)
{
_tenantDbContext = tenantDbContext;
}
public async Task<IActionResult> AddOrder()
{
var order = new Order()
{
Id = Guid.NewGuid().ToString("n"),
CreationTime = DateTime.Now,
Name = new Random().Next(1,100)+"_name"
};
await _tenantDbContext.AddAsync(order);
await _tenantDbContext.SaveChangesAsync();
return Ok(order.Id);
}
public async Task<IActionResult> UpdateOrder([FromQuery]string id)
{
var order =await _tenantDbContext.Set<Order>().FirstOrDefaultAsync(o=>o.Id==id);
if (order == null) return BadRequest();
order.Name = new Random().Next(1, 100) + "_name";
await _tenantDbContext.SaveChangesAsync();
return Ok(order.Id);
}
public async Task<IActionResult> GetOrders()
{
var orders =await _tenantDbContext.Set<Order>().ToListAsync();
return Ok(orders);
}
}
啟動項目
這邊我們基本上已經配置好我們所需要的之後我們就可以直接啟動項目了
這邊我們通過介面註冊了一個TenantA的用戶並且選擇了使用MSSQL,這邊成就幫我們自動生成好了對應的資料庫表結構
接下來我么再註冊一個TenantB用戶選擇MySql
通過截圖我們可以看到ShardingCore
也是為我們創建好了對應的資料庫和對應的表資訊
登錄租戶
首先我們登錄
TenantA用戶token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjoiYXBpIiwiaWQiOiJkNGMwZjZiNzI5MzE0M2VlYWM0Yjg3NzUwYzE4MWUzOSIsInVpZCI6ImMxMWRkZjFmNTY0MjQwZjc5YTQzNTEzZGMwNmVjZGMxIiwiZXhwIjoxNjQxODI4ODQ0fQ.zJefwnmcIEZm-kizlN7DhwTRgGxiCg52Esa8QmHiEKY
TenantB用戶token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjoiYXBpIiwiaWQiOiIwNzY4NzUwMmVjYzY0NTMyOGFkNTcwZDRkYjMwNDI3MSIsInVpZCI6ImVkODg4YTc3MzAwYTQ4NjZhYmUyNWY2MTE1NmEwZTQzIiwiZXhwIjoxNjQxODI4ODgxfQ.cL0d010jdXLXNGT8M0wsRMqn3VeIxFnV0keM0H3SPzo
接下來我們分別對兩個租戶進行交叉處理
AddOrder
租戶A插入一個訂單,訂單Id:aef6905f512a4f72baac5f149ef32d21
TenantB用戶也插入一個訂單,訂單id:450f5dd0e82442eca33dfcf3d57fafa3
兩個用戶處理
通過日誌列印明顯能夠感覺出來兩者是區分了不同的資料庫
UpdateOrder
GetOrders
總結
通過上述功能的演示相信很多小夥伴應該已經知道他具體的運作流程了,通過配置多個租戶資訊,在ShardingCore
上實現多配置,動態配置,來保證在多租戶模式下的分表分庫讀寫分離依然可以使用,並且擁有跟好的適泛性。
如果你需要開發一個大型程式,領導上來就是分庫分表,那麼在以前大概率是會花費非常多的精力在處理分片這件事情上,而最終項目是否可以做完並且使用還是一個巨大的問題,但是現在不一樣了,畢竟ShardingCore
之前並沒有一款非常好用的分片組件在.net上,並且擁有非常完美的orm作為支援,基本上重來沒有一個框架說多租戶模式是可以選擇資料庫的,之前市面上所有的多租戶你只能選擇一種資料庫,目前.Net在開源的狀態下我相信會有越來越好的組件框架誕生,畢竟這麼好的語言如果配上豐富的生態那將是所有.Neter的福音。
最後的最後
demo地址 //github.com/xuejmnet/ShardingCoreMultiTenantSys
您都看到這邊了確定不點個star或者贊嗎,一款.Net不得不學的分庫分表解決方案,簡單理解為sharding-jdbc在.net中的實現並且支援更多特性和更優秀的數據聚合,擁有原生性能的97%,並且無業務侵入性,支援未分片的所有efcore原生查詢