首先简单说下多租户的几种实现方式
多租户(Multi-Tenant ),即多个租户共用一个实例,租户的数据既有隔离又有共享,说到底是要解决数据存储的问题。
常用的数据存储方式有三种。
方案一:独立数据库
一个Tenant,一个Database的数据存储方式。隔离级别最高、最安全,但成本也高。
优点:a.为不同租户提供独立数据库,有助于简化数据模型的扩展设计,满足个性化需求;
b.数据恢复简单;
缺点:增大了数据库的安装数量,购置和维护成本高;
方案二:共享数据库,隔离数据架构
多个租户或所有租户共享Database,但一个Tenant,一个Schema的方式。
优点:a.一定程度的逻辑数据隔离(并非完全),可满足较高程度的安全性保障;
b.每个数据库,可支持更多租户数量;
缺点:a.恢复数据较困难,因为将牵扯到其他租户数据;
b.跨租户统计数据,实现难度大;
方案三:共享数据库,共享数据架构
一种租户共享同一个Database、同一个Schema,而另行通过TenantID区分租户数据的方式。
优点:a.每个数据库可支持租户数量多,维护和购置成本低;
缺点:a. 隔离级别低,安全性低,开发时需做大量安全开发工作;
b. 逐表逐条备份和还原数据,数据备份和恢复困难。
今天主要讲的就是用WTM 改造简易的多租户,我这里用的是Layui版本,其他UI也可以用这种方式实现,我还没有试过,大家有空可以自己试一试。我用的是方案一 独立数据库方式。技术有限,只是希望在这里可以给大家提供一个思路。
开始说下整体步骤
咱们先来创建一个租户表,我这里简单创建几个字段,为了演示,大家根据实际需要自己调整。我这里为了演示方便租户角色直接用系统自带的角色表了,大家自己可以增加一个租户角色表。
public class Tenant : BasePoco { [Display(Name = "编号")] [Required(ErrorMessage = "{0}是必填项")] public string Code { get; set; } [Display(Name = "域名")] [Required(ErrorMessage = "{0}是必填项")] public string DomainName { get; set; } [Display(Name = "租户角色")] [Required(ErrorMessage = "{0}是必填项")] public Guid RoleId { get; set; } [Display(Name = "租户角色")] public FrameworkRole Role { get; set; } [Display(Name = "账号")] [Required(ErrorMessage = "{0}是必填项")] public string Account { get; set; } [Display(Name = "名称")] [Required(ErrorMessage = "{0}是必填项")] public string Name { get; set; } }
添加完租户信息后,Create方法里需要创建好 这个租户的库、基本信息和提供的域名。我这里租户的库生成规则直接就是默认用主库名+编号生成的。
[HttpPost] [ActionDescription("Sys.Create")] public ActionResult Create(TenantVM vm) { using (var trans = DC.BeginTransaction()) { if (!ModelState.IsValid) { return PartialView(vm); } else { vm.DoAdd(); if (!ModelState.IsValid) { vm.DoReInit(); return PartialView(vm); } else { //我这代码直接写这了 生成租户库和基本信息 随便写了下简单的系统表数据 var NDC = new DataContext(Wtm.ConfigInfo.Connections[0].Value.Replace("SAASDEMODB", "SAASDEMODB" + vm.Entity.Code), DBTypeEnum.SqlServer); var Result = NDC.Database.EnsureCreated(); if (Result) { var role = DC.Set<FrameworkRole>().Where(x => x.ID == vm.Entity.RoleId).FirstOrDefault(); //角色拥有的菜单权限 var pr = DC.Set<FunctionPrivilege>().Where(x => x.RoleCode == role.RoleCode).ToList(); var user = new FrameworkUser { ITCode = vm.Entity.Account, Password = Utils.GetMD5String("000000"), IsValid = true, Name = vm.Entity.Name }; var userrole = new FrameworkUserRole { UserCode = vm.Entity.Account, RoleCode = role.RoleCode }; NDC.Set<FrameworkUser>().Add(user); NDC.Set<FrameworkRole>().Add(role); NDC.Set<FrameworkUserRole>().Add(userrole); //这里框架自带角色表 页面权限FunctionPrivilege表没有加父级菜单数据 会导致约束冲突。后期自己添加租户角色表吧 NDC.Set<FrameworkMenu>().AddRange(DC.Set<FrameworkMenu>().CheckContain(pr.Select(x => x.MenuItemId).ToList(), x => x.ID).ToList()); NDC.Set<FunctionPrivilege>().AddRange(pr); NDC.SaveChanges(); //云解析DNS-添加解析记录 if (!new CommonHelp().DomainNameResolution(vm.Entity.Code)) { trans.Rollback(); return FFResult().CloseDialog().RefreshGrid().Alert("域名解析失败!"); } } else { trans.Rollback(); return FFResult().CloseDialog().RefreshGrid().Alert("租户信息初始化失败!"); } trans.Commit(); return FFResult().CloseDialog().RefreshGrid(); } } } }
主要用到了一个云解析DNS-添加解析记录的方法。调用AddDomainRecord根据传入参数添加解析记录。
云解析 DNS(Domain Name System,简称DNS) 是一种安全、快速、稳定、可靠的权威DNS解析管理服务。 它能够帮助企业和开发者将易于管理识别的域名转换为计算机用于互连通信的数字IP地址,从而将用户的访问路由到相应的网站或应用服务器。
具体看文档 添加解析记录 (aliyun.com)
#region 云解析DNS-添加解析记录 可以去阿里云地址看文档 https://help.aliyun.com/document_detail/29772.html 但是这种方式服务器要求 80端口只允许部署这一套系统,因为现在这种方式是域名直接指向服务器IP public bool DomainNameResolution(string Name) { IClientProfile profile = DefaultProfile.GetProfile("cn-hangzhou", "", "");//域名 AccessKeyID Secret DefaultAcsClient client = new DefaultAcsClient(profile); var request = new AddDomainRecordRequest(); request._Value = ""; //指向服务器IP request.Type = "A"; request.RR = Name; //随便定义 request.DomainName = "xxx.com"; //域名 try { var response = client.GetAcsResponse(request); return true; //Console.WriteLine(System.Text.Encoding.Default.GetString(response.HttpResponse.Content)); } catch (ServerException e) { return false; } catch (ClientException e) { return false; } } #endregion
这种方式不好的一点是 服务器要求 80端口只允许部署这一套系统,因为现在这种方式是域名直接指向服务器IP。我是部署在IIS上,需要注意的一点是应用中不要绑定主机名。(如果大家有更好的办法可以一起沟通沟通)
到这里创建的这个租户的库和基本信息和域名就创建好了。
这个时候所有域名都可以访问到部署的系统了,但是appsettings.json文件中Connections只有一个默认的库,当然不可能添加一个租户就在这加一个连接字符串,不现实。
正好框架支持动态选择连接字符串。框架可以根据页面传递过来的数据,或者session里的信息等动态选择需要连接的数据库,只需编辑Startup文件中的CSSelector方法。
访问系统肯定会先读主库,我这里是根据域名去租户表里查,如果存在就动态添加一个ConnectionStrings,利用Wtm.Session.Set("TenantKey", CS.key);,否则就正常访问主库。
#region 获取当前url public string GetAbsoluteUri(HttpRequest request) { return new StringBuilder() .Append(request.Scheme) .Append("://") .Append(request.Host) .Append(request.PathBase) .Append(request.Path) .Append(request.QueryString) .ToString(); } #endregion [Public] [ActionDescription("Login")] public IActionResult Login() { LoginVM vm = Wtm.CreateVM<LoginVM>(); string TenantKey = "default"; string displayUrl = GetAbsoluteUri(HttpContext.Request); displayUrl = displayUrl.Replace("http://", "").Replace("https://", "") + "/"; displayUrl = displayUrl.Substring(0, displayUrl.IndexOf("/")); var ZDC = new DataContext(Wtm.ConfigInfo.Connections[0].Value, DBTypeEnum.SqlServer); var Tenant = ZDC.Set<Tenant>().Where(x => x.DomainName.Replace("http://", "").Replace("https://", "") == displayUrl).FirstOrDefault(); if (Tenant != null) { TenantKey = "SAASDEMODB" + Tenant.Code; Wtm.Session.Set("TenantKey", "SAASDEMODB" + Tenant.Code); } else { Wtm.Session.Set("TenantKey", TenantKey); } int i = 0; foreach (var item in Wtm.ConfigInfo.Connections) { if (item.Key == TenantKey) { i++; break; } } if (i == 0) { CS cs = new CS(); cs.DbContext = "DataContext"; cs.DbType = DBTypeEnum.SqlServer; cs.Key = TenantKey; cs.Value = Wtm.ConfigInfo.Connections[0].Value.Replace("SAASDEMODB", cs.Key); cs.DcConstructor = Wtm.ConfigInfo.Connections[0].DcConstructor; Wtm.ConfigInfo.Connections.Add(cs); } vm.Redirect = HttpContext.Request.Query["ReturnUrl"]; if (Wtm.ConfigInfo.IsQuickDebug == true) { vm.ITCode = "admin"; vm.Password = "000000"; } return View(vm); } Startup文件中的CSSelector方法 public string CSSelector(ActionExecutingContext context) { var wtm = (context.Controller as IBaseController)?.Wtm; var TenantKey = wtm.Session.Get<string>("TenantKey"); return TenantKey; }
OK,咱们来测试一下下。
我这里就用默认超级管理员角色创建租户了,为了添加一个租户让大家看下效果。
添加成功,访问一下租户的地址
添加一条新的角色数据,跟主库作下比较,发下数据已经隔离了。
如果你跟着测试到这一步,说明已经通了,可以自己多试试。有问题或者有好的想法,可以在群里一起沟通学习学习。
有些可能需要用到数据共享,框架本身支持在控制器中设置[FixConnection(DBOperationEnum.Default, CsName = "")]设置Cs指定连接字符串。
项目我已经上传到Gitee上了,大家可以下载看一下。
地址:wtm-layui版本多租户: wtm框架 layui版本都租户改造
下载完项目,如果想直接运行调试的话,记得去Common文件下的CommonHelp类中把DomainNameResolution方法中的参数补充全,就可以直接运行看效果了。
目前这种方式有正式的项目,目前也比较稳定。第一次写文章,希望大家多支持哈。
==========================================================
WTM框架地址 https://wtmdoc.walkingtec.cn
支持4个版本:Layui React Vue Blazor
WtmPlus是建立在WTM开源框架基础上的低代码开发平台,他提供了可视化的模型和页面编辑,更加复杂和智能的代码生成,可使开发效率提升50%以上。
感兴趣可以看一下 地址 WtmPlus