基於Citus和ASP.NET Core開發多租戶應用

  Citus是基於PsotgreSQL的擴展,用於切分PsotgreSQL的數據,很是簡單地實現數據「切片(sharp)」。若是不使用Citus,則須要開發者本身實現分佈式數據訪問層(DDAL),實現路由和結果彙總等邏輯,藉助Citus可簡化開發,是開發者把精力集中在具體的業務邏輯上。html

  對於多租戶程序來講,Citus能夠幫助企業對數據進行切片,相比於傳統的數據管理方式,Citus更智能,操做更爲簡單,運維成本更低廉。下面演示Citus的簡單使用。git

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。github

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。web

  • 安裝「SaasKit.Multitenancy」包

  SaasKit.Multitenancy:支持ASP.NET Core開發多租戶應用。sql

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文件,添加:docker

127.0.0.1 bufferoverflow.local
127.0.0.1 dboverflow.local

  運行cmd(命令行),輸入如下命令,刷新DNS:數據庫

ipconfig /flushdns

  分別使用不一樣Url瀏覽站點,能夠看到以前插入的測試數據在不一樣租戶下顯示不一樣:app

  以上,簡單演示瞭如何基於Citus開發多租戶應用。此外,Citus還比較適合開發須要快速返回查詢結果的應用(好比「儀表板」等)。運維

  本文演示的例子比較簡單,僅僅是演示了使用Citus開發多租戶應用的可能。具體實踐中,還涉及到具體業務以及數據庫切片技巧等。建議閱讀微軟的《Cloud Design Patterns Book》中的Sharding模式部分,以及Citus的官方技術文檔。dom

 

參考資料:

  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

相關文章
相關標籤/搜索