Xây dựng web app multi-tenant đơn giản với Citus data và AspNet Core

Xây dựng web app multi-tenant đơn giản với Citus data và AspNet Core

December 15, 2020 0 By Nam Vu
Bài viết trước mình có đề cập đến một tính năng nổi bật của Citus Data đó là việc mở rộng multi-tenants. Bạn có thể đọc lại tại đây
Ở bài viết này mình sẽ giúp bạn việc mà citus scale multi-tenants thông qua việc xây dựng một ứng dụng nhỏ được viết băng aspnet core kết hợp với postgres.
Bắt đầu thôi!
Khi một ứng dụng multi tenants được xây dựng có khả năng mở rộng lớn thì việc chọn asp net platform là một lựa chọn không tồi. Cũng như những platform phổ biến khác như Express và Django thì Asp Net được sử dụng để xây dựng một ứng dụng web application và api cực kỳ mạnh mẽ.

Trong quá khứ, ứng dụng Asp Net chỉ hoạt động được trên nền windows servers, nhưng đối với phiên bản mới của AspNet Core thì nó lại có thể chạy được trên mọi nền tàng (cross platfrom) bên cạnh việc nó còn là mã nguồn mở và tăng hiệu suất đáng kinh ngạc nữa chứ.
Như mình đã đề cập ở bài viết trước thì vấn đề scale database chúng ta cần tổ chức kiến trúc tốt ngay từ ban đầu. Ngày trước các bạn thường biết đến với 2 kiểu tổ chức kiến trúc đó là dồn tất cả tenants vào chung một database, hoặc mỗi tenants bạn có thể để một database riêng (Cơ sở dữ liệu phân tán theo chiều dọc với khóa chốt là tenant)
Trong bài viết này mình sẽ sử dụng sharding để chia mỗi tenant thành khóa để các câu lệnh query sau đó sẽ được gắn tenant id vào khi đó sẽ truy vấn nhanh hơn. Bên cạnh đó sau này cũng dễ dàng mở rộng theo tenant mới trên từng node.
Đầu tiên, bạn phải cài docker trước đã, có nhiều trang hướng dẫn cài docker rồi nên mình cũng không đề cập nó tại đây nữa.
Đến đây bạn có 2 cách để có thể cài đặt postgres citus
Cách 1: Sử dụng trực tiếp psql
> psql connection-string
> docker exec -it citus_master psql -U postgres
Cách 2: Tạo file docker-compose.yml và docker-compose.override.yml
Cho những ai chưa biết về 2 file này:
Docker compose là một công cụ vô cùng đơn giản để thực thi nhiều container cùng một lúc cho ứng dụng của bạn. Để có thể dùng được docker compose, bạn cần tạo một compose file như docker-compose.yml để thiết lập các container cần cho ứng dụng của bạn.
Và sau đó để build, run và stop các container, các bạn có thể sử dụng các command sau:
– “docker-compose build” dùng để build tất cả container được định nghĩa trong compose file.
– “docker-compose up” dùng để thực hiện tạo và khởi chạy các container.
– “docker-compose down” dùng để dừng các container và xóa hết những gì được tạo từ lệnh up.
Còn Docker compose override thì như cái tên của nó, nó dùng để ghi đè những biến môi trường mà mình định nghĩa khi chạy docker compose thôi.

Vậy là khi chạy mình chỉ sử dụng duy nhất 1 câu lệnh cmd này thôi
docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d –build
Mình thích sử dụng cách thứ 2 hơn vì mỗi lần restart lại máy và docker thì các containers này tự động restart up theo. Chứ cách 1 chỉ dùng ở tại thời điểm đó thôi. Nhưng cách 2 thì bạn lại phải giữ một image trong máy, càng về sau dữ liệu càng phình to thì máy bạn sẽ phình theo nếu bạn không pune image (xóa nó đi).
Sau khi chạy câu lệnh trên thì bạn đã có một môi trường postgres citus
Bây giờ mình đã có cơ sở dữ liệu postgres rồi và mình sẽ viết câu sql cơ bản để tạo 2 bảng tenants và questions nhằm mục đích thể hiện bộ câu hỏi theo từng tenant mà thôi. Đây là 2 bảng cha con với khóa ngoại là tenant_id
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);
Đến phần quan trọng nhất mà postgres citus data hỗ trợ đó là sharding với loại sharding distributed (Mình sẽ viết riêng 1 bài viết khác với chủ đề sharding này sau). Ở đây mình chạy localhost và chỉ tạo 1 node duy nhất nên sau khi chạy sharding nó sẽ tạo ra một loạt các bảng và có khoảng giá trị tenant khác nhau.
SELECT create_distributed_table(‘tenants’, ‘id’);
SELECT create_distributed_table(‘questions’, ‘tenant_id’);
Hãy tưởng tượng bạn có 1 tenant mới được thêm vào thì lập tức citus sẽ trigger phân loại tenant_id thuộc khoảng nào sau đó nhét vào bảng tưng ứng. Khi truy vấn lên nó cũng dựa vào tenant_id để biết được vào chính xác bảng nào thuộc node nào để lấy dữ liệu lên. Đó là cách tối ưu hiệu năng mà citus thực hiện.

Được rồi phần chính sharding mình đã xong, vấn đề bây giờ là seed data thôi.
INSERT INTO tenants VALUES (
‘c620f7ec-6b49-41e0-9913-08cfe81199af’,
‘ntechdevelopers.local’,
‘Buffer Overflow’,
‘Ask anything code-related!’,
now(),
now());
INSERT INTO tenants VALUES (
‘b8a83a82-bb41-4bb3-bfaa-e923faab2ca4’,
‘api.ntechdevelopers.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());
Sau khi có data thì tạo project, hiện tại thì mình sử dụng dot net core nên mình sẽ tạo nhanh với các câu lệnh sau để đỡ mất thời gian. Bạn cũng có thể clone source code ở cuối bài viết về chạy thử
> dotnet new mvc -o Ntech.CitusData
> cd Ntech.CitusData
> dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
> dotnet new sln -o Ntech.CitusData
> dotnet sln Ntech.CitusData add .\Ntech.CitusData\Ntech.CitusData.csproj
> cd Ntech.CitusData
Add package cần sử dụng.
> dotnet add package Microsoft.AspNetCore.App
> dotnet add package SaasKit.Multitenancy
Nói thêm một chút về SaasKit thì đây là một thư viện mã nguồn mở, nó được tạo ra nhằm mục đích cho những ứng dụng multi tenants can thiệp ở tầng middleware. Hiểu đơn giản thì khi bạn truy vấn mọi dữ liệu của 1 tenant thì dường như truy vấn tenant đó là không đổi, ví dụ http://vietanhtran.spiderum.com/ thì mọi thông tin chung trong trang tenant vietanhtran đó sẽ không đổi . Vậy tại sao mình không cache (tạo bộ nhớ đệm) cho những thử ít hoặc không bao giờ thay đổi. SaasKit.Multitenancy được sinh ra nhằm mục đích như vậy.
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 tenants = await _context.Tenants.ToListAsync();
      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 };
}
Dùng thư viện này thì nhớ 2 dòng đăng ký trong startup.cs nhé
services.AddMultitenancy<Tenant, CachingTenantResolver>();
app.UseMultitenancy<Tenant>();
Về model Question hay Tenant dùng để mapping với dữ liệu trong database hay tạo dataContext không có gì khó hiểu. Điều mình lưu ý ở đây là rule convention của postgres là snack case (kiểu viết thường nối nhau bằng _ như database_postgres_citus) vậy nên trong OnModelCreating() mình có thêm đoạn chuyển snack case cho table và column để mapping dữ liệu bảng cho chính xác thôi.
Vấn đề còn lại là build controller và view, 2 cái này thì sau khi mapping xong dữ liệu bảng rồi thì khá đơn giản và hiển thị như nào là tùy ý mỗi người. Vậy nên mình lướt nhanh nhé.
ModelView:
public class QuestionListViewModel
{
    public IEnumerable<Question> Questions { get; set; }
}
Controller:
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);
}
View:
@inject Tenant Tenant
@model QuestionListViewModel
@{
ViewData[“Title”] = “Home Page”;
}
<div class=”text-center”>
<h1 class=”display-4″>Welcome</h1>
<p>Learn about <a href=”https://docs.microsoft.com/aspnet/core”>building Citus Data with ASP.NET Core</a>.</p>
</div>
<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>
Ok, đến đây là kết thúc phần code rồi. Do mình chạy local và muốn tạo domain (tên miền giả lập nên mình gắn đoạn config này vào file host “C:\Windows\System32\drivers\etc\hosts”
# Update host for domain
127.0.0.1    ntechdevelopers.local
127.0.0.1    api.ntechdevelopers.local
Nếu bạn để ý thì nó trùng khớp với domain mà mình seed data trong DB bên trên mục đích để redirect trang về đúng tenant domain tương ứng.
OK chạy thử thì nó sẽ như này
> cd src\Ntech.CitusData
> dotnet build
> dotnet run

Toàn bộ source code bạn có thể tham khảo tại:
Chúc các bạn thành công!