基于Citus和ASP.NET Core开发多租户应用
Citus是基于PsotgreSQL的扩展,用于切分PsotgreSQL的数据,非常简单地实现数据“切片(sharp)”。如果不使用Citus,则需要开发者自己实现分布式数据访问层(DDAL),实现路由和结果汇总等逻辑,借助Citus可简化开发,是开发者把精力集中在具体的业务逻辑上。
对于多租户程序来说,Citus可以帮助企业对数据进行切片,相比于传统的数据管理方式,Citus更智能,操作更为简单,运维成本更低廉。下面演示Citus的简单使用。
Step 01 安装docker和docker-compose(以Docker方式部署Citus)
curl -sSL https://get.docker.com/ | sh sudo usermod -aG docker $USER && exec sg docker newgrp `id -gn` sudo systemctl start docker sudo curl -sSL https://github.com/docker/compose/releases/download/1.19.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose
Step 02 安装并启动Citus
Citus有3个版本Citus Community,Citus Cloud(云端版), Citus Enterprise(支持HA等高级特性),本文使用Citus Community。
curl -sSLO https://raw.githubusercontent.com/citusdata/docker/master/docker-compose.yml docker-compose -p citus up -d
Step 03 连接postgres
docker exec -it citus_master psql -U postgres
Step 04 设置数据库用户密码
postgres=# \password postgres #给postgres用户设置密码 Enter new password: Enter it again:
Step 05 创建表
CREATE TABLE tenants ( id uuid NOT NULL, domain text NOT NULL, name text NOT NULL, description text NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL ); CREATE TABLE questions ( id uuid NOT NULL, tenant_id uuid NOT NULL, title text NOT NULL, votes int NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL ); ALTER TABLE tenants ADD PRIMARY KEY (id); ALTER TABLE questions ADD PRIMARY KEY (id, tenant_id);
Step 06 告知Citus如何对数据进行切片
SELECT create_distributed_table('tenants', 'id'); SELECT create_distributed_table('questions', 'tenant_id');
Step 07 初始化数据
INSERT INTO tenants VALUES ( 'c620f7ec-6b49-41e0-9913-08cfe81199af', 'bufferoverflow.local', 'Buffer Overflow', 'Ask anything code-related!', now(), now()); INSERT INTO tenants VALUES ( 'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4', 'dboverflow.local', 'Database Questions', 'Figure out why your connection string is broken.', now(), now()); INSERT INTO questions VALUES ( '347b7041-b421-4dc9-9e10-c64b8847fedf', 'c620f7ec-6b49-41e0-9913-08cfe81199af', 'How do you build apps in ASP.NET Core?', 1, now(), now()); INSERT INTO questions VALUES ( 'a47ffcd2-635a-496e-8c65-c1cab53702a7', 'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4', 'Using postgresql for multitenant data?', 2, now(), now());
Step 08 新建ASP.NET Core Web应用程序,并添加引用
-
安装“Npgsql.EntityFrameworkCore.PostgreSQL”包
Npgsql.EntityFrameworkCore.PostgreSQL:支持Entity Framework Core操作PostgreSQL。
-
安装“SaasKit.Multitenancy”包
SaasKit.Multitenancy:支持ASP.NET Core开发多租户应用。
Step 09 创建models
using System; namespace QuestionExchange.Models { public class Question { public Guid Id { get; set; } public Tenant Tenant { get; set; } public string Title { get; set; } public int Votes { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } } }
using System; namespace QuestionExchange.Models { public class Tenant { public Guid Id { get; set; } public string Domain { get; set; } public string Name { get; set; } public string Description { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } } }
using System.Collections.Generic; namespace QuestionExchange.Models { public class QuestionListViewModel { public IEnumerable<Question> Questions { get; set; } } }
Step 10 创建数据上下文
using System.Linq; using Microsoft.EntityFrameworkCore; using QuestionExchange.Models; namespace QuestionExchange { public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Tenant> Tenants { get; set; } public DbSet<Question> Questions { get; set; } /// <summary> /// C# classes and properties are PascalCase by convention, but your Postgres tables and columns are lowercase (and snake_case). /// The OnModelCreating method lets you override the default name translation and let Entity Framework Core know how to find /// the entities in your database. /// </summary> /// <param name="modelBuilder"></param> protected override void OnModelCreating(ModelBuilder modelBuilder) { var mapper = new Npgsql.NpgsqlSnakeCaseNameTranslator(); var types = modelBuilder.Model.GetEntityTypes().ToList(); // Refer to tables in snake_case internally types.ForEach(e => e.Relational().TableName = mapper.TranslateMemberName(e.Relational().TableName)); // Refer to columns in snake_case internally types.SelectMany(e => e.GetProperties()) .ToList() .ForEach(p => p.Relational().ColumnName = mapper.TranslateMemberName(p.Relational().ColumnName)); } } }
Step 11 为SaaSKit实现解析器
using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using SaasKit.Multitenancy; using QuestionExchange.Models; namespace QuestionExchange { public class CachingTenantResolver : MemoryCacheTenantResolver<Tenant> { private readonly AppDbContext _context; public CachingTenantResolver( AppDbContext context, IMemoryCache cache, ILoggerFactory loggerFactory) : base(cache, loggerFactory) { _context = context; } // Resolver runs on cache misses protected override async Task<TenantContext<Tenant>> ResolveAsync(HttpContext context) { var subdomain = context.Request.Host.Host.ToLower(); var tenant = await _context.Tenants .FirstOrDefaultAsync(t => t.Domain == subdomain); if (tenant == null) return null; return new TenantContext<Tenant>(tenant); } protected override MemoryCacheEntryOptions CreateCacheEntryOptions() => new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromHours(2)); protected override string GetContextIdentifier(HttpContext context) => context.Request.Host.Host.ToLower(); protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<Tenant> context) => new string[] { context.Tenant.Domain }; } }
Step 12 修改Startup.cs
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using QuestionExchange.Models; namespace QuestionExchange { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { var connectionString = "Server=192.168.99.102;Port=5432;Database=postgres;Userid=postgres;Password=yourpassword;"; services.AddEntityFrameworkNpgsql() .AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString)); services.AddMultitenancy<Tenant, CachingTenantResolver>(); services.AddMvc(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseBrowserLink(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseStaticFiles(); app.UseMultitenancy<Tenant>(); app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); } } }
Step 13 创建View和Controller
@inject Tenant Tenant @model QuestionListViewModel @{ ViewData["Title"] = "Home Page"; } <div class="row"> <div class="col-md-12"> <h1>Welcome to <strong>@Tenant.Name</strong></h1> <h3>@Tenant.Description</h3> </div> </div> <div class="row"> <div class="col-md-12"> <h4>Popular questions</h4> <ul> @foreach (var question in Model.Questions) { <li>@question.Title</li> } </ul> </div> </div>
using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using QuestionExchange.Models; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; namespace QuestionExchange.Controllers { public class HomeController : Controller { private readonly AppDbContext _context; private readonly Tenant _currentTenant; public HomeController(AppDbContext context, Tenant tenant) { _context = context; _currentTenant = tenant; } public async Task<IActionResult> Index() { var topQuestions = await _context .Questions .Where(q => q.Tenant.Id == _currentTenant.Id) .OrderByDescending(q => q.UpdatedAt) .Take(5) .ToArrayAsync(); var viewModel = new QuestionListViewModel { Questions = topQuestions }; return View(viewModel); } public IActionResult About() { ViewData["Message"] = "Your application description page."; return View(); } public IActionResult Contact() { ViewData["Message"] = "Your contact page."; return View(); } public IActionResult Error() { return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } } }
Step 14 运行站点
首先需要修改本地Hosts文件,添加:
127.0.0.1 bufferoverflow.local 127.0.0.1 dboverflow.local
运行cmd(命令行),输入以下命令,刷新DNS:
ipconfig /flushdns
分别使用不同Url浏览站点,可以看到之前插入的测试数据在不同租户下显示不同:
以上,简单演示了如何基于Citus开发多租户应用。此外,Citus还比较适合开发需要快速返回查询结果的应用(比如“仪表板”等)。
本文演示的例子比较简单,仅仅是演示了使用Citus开发多租户应用的可能。具体实践中,还涉及到具体业务以及数据库切片技巧等。建议阅读微软的《Cloud Design Patterns Book》中的Sharding模式部分,以及Citus的官方技术文档。
参考资料:
https://github.com/citusdata/citus
https://www.citusdata.com/blog/2018/01/22/multi-tenant-web-apps-with-dot-net-core-and-postgres
https://docs.citusdata.com/en/v7.1/aboutcitus/what_is_citus.html
上一篇: 论诺基亚之死!