Theo định nghĩa của đa thuê (multitenancy), khi một ứng dụng phục vụ nhiều tenant, có nghĩa là có một vài nhóm người dùng chia sẻ quyền truy cập chung vào cùng một instance phần mềm. Một ví dụ điển hình của ứng dụng hỗ trợ đa thuê là nền tảng Jira, nơi mỗi công ty có subdomain riêng để truy cập phần mềm, ví dụ: mycompany.atlassian.net.
Trong bài viết này, tôi sẽ giới thiệu cho bạn cả các khía cạnh lý thuyết và thực tế của đa thuê. Sau khi thảo luận một vài cách tiếp cận phổ biến để triển khai hỗ trợ nhiều tenant trong ứng dụng, tôi sẽ chỉ cho bạn cách triển khai hai trong số đó trong ứng dụng Rails. Chúng ta sẽ cùng xây dựng một ứng dụng đơn giản với nhiều tenant, nơi mỗi tenant sẽ có subdomain riêng.
Sau khi đọc bài viết này, bạn sẽ có thể:
- Thảo luận các loại ứng dụng multi-tenant khác nhau và xác định khi nào cách tiếp cận nào là lựa chọn phù hợp,
- Tạo ứng dụng Ruby on Rails với hỗ trợ nhiều tenant, và
- Sử dụng subdomain tùy chỉnh cho khách hàng bên trong ứng dụng Ruby on Rails.
Kiến thức cơ bản về Rails là bắt buộc để theo dõi bài viết này và tận hưởng tối đa lợi ích của nó.
Đa Thuê Trong Lý Thuyết
Như đã đề cập, chúng ta có thể nói ứng dụng hỗ trợ đa thuê khi nó phục vụ một vài nhóm người dùng chia sẻ quyền truy cập chung vào các tính năng của ứng dụng. Ví dụ tốt là nền tảng blogging, nơi mỗi người dùng nhận được một subdomain riêng trong domain chính. Mỗi blog có subdomain riêng nhưng chia sẻ cùng các tính năng như bình luận, bài viết và bảng điều khiển quản trị.
Dù trông có vẻ các ứng dụng multi-tenant hoạt động tương tự nhau trên bề mặt, nhưng chúng có thể triển khai các kiểu kiến trúc đa thuê khác nhau bên dưới. Hãy xem xét các loại phổ biến và hiệu quả nhất.
Cấp Dòng Cơ Sở Dữ Liệu (Database-row Level)
Nếu bạn không muốn sử dụng nhiều cơ sở dữ liệu trong ứng dụng, bạn có thể chọn cách tiếp cận hoạt động trên một cơ sở dữ liệu trung tâm. Mỗi bảng trong cơ sở dữ liệu bao gồm cột tenant_id được sử dụng trong mọi truy vấn được thực hiện bên trong ứng dụng để lấy dữ liệu thuộc về tenant cụ thể.
Thiết lập tenant trong cấu trúc này rất nhanh, không tốn chi phí thêm, và triển khai biến thể này có thể làm ở mọi nơi. Tuy nhiên, chúng ta cần cẩn thận vì có thể dẫn đến rò rỉ dữ liệu nếu quên bao gồm tenant_id trong truy vấn. Việc sửa đổi cấu trúc cơ sở dữ liệu sẽ khó bảo trì cho các nhóm người dùng nhất định, nên nhớ nhược điểm này.
Cấp Schema (Schema Level)
Bạn vẫn có thể có một cơ sở dữ liệu trung tâm nhưng với các bảng và schema riêng cho từng tenant. Khác với cách tiếp cận cấp dòng, bạn phải thay đổi đường dẫn tìm kiếm (search path) đến các bảng khi chuyển đổi giữa các tenant thay vì sửa đổi truy vấn. Trong PostgreSQL, bạn có thể dùng câu lệnh set search_path, và trong MySQL dùng câu lệnh use để chuyển đổi giữa các schema.
Trước khi chọn cách này, hãy đảm bảo rằng các câu lệnh trên có thể dùng trên DB server của bạn. Cũng nhớ rằng thêm tenant mới tương đối chậm vì phải tạo schema mới và tạo bảng trong cơ sở dữ liệu.
Cấp Cơ Sở Dữ Liệu (Database Level)
Nếu bạn chi trả được chi phí cơ sở dữ liệu mới mỗi khi thêm tenant mới, cách này có thể phù hợp. Đây là cách chậm nhất trong các kiến trúc thảo luận khi thêm tenant mới.
Khó dẫn đến rò rỉ dữ liệu nhất vì để chuyển sang tenant khác, bạn phải thiết lập kết nối mới đến cơ sở dữ liệu. Bạn cũng có thể dễ dàng sửa đổi cấu trúc cơ sở dữ liệu của mỗi nhóm người dùng nếu muốn.
Tạo Khung Ứng Dụng
Chúng ta sẽ tạo hai ứng dụng riêng biệt: một hỗ trợ đa thuê bằng một cơ sở dữ liệu và một bằng nhiều cơ sở dữ liệu. Chúng đại diện cho cách tiếp cận khác nhau để xử lý nhiều tenant, nhưng giai đoạn cấu hình là giống nhau.
Phiên bản Ruby mới nhất là 2.7.2 và Ruby on Rails gem 6.1 RC1 tại thời điểm viết bài. Đảm bảo bạn đã cài đúng phiên bản Ruby và cài phiên bản framework đúng:
gem install rails -v 6.1.0.rc1
Bây giờ tạo file dự án với lệnh:
rails _6.1.0.rc1_ new tenantapp -d=mysql
Vào thư mục dự án, chạy server, và kiểm tra màn hình chào mừng để tiếp tục:
cd tenantapp/ rails s
Ứng Dụng Rails Multi-tenant Với Một Cơ Sở Dữ Liệu
Đến lúc code. Phần này sẽ chỉ cách triển khai đa thuê trong ứng dụng Rails. Tôi sẽ dùng cách một cơ sở dữ liệu và một schema.
Kế hoạch như sau:
- Tạo khung ứng dụng Rails dùng phiên bản Ruby và Rails mới nhất
- Scaffold một số model để có dữ liệu thao tác
- Thêm hỗ trợ đa thuê vào chức năng đã tạo bằng cách cập nhật bảng dữ liệu và code
- Thêm hỗ trợ subdomain tùy chỉnh để người dùng truy cập tenant dễ dàng
- Khám phá ý tưởng cho các tính năng có thể triển khai sau
Chuẩn Bị Dữ Liệu Test
Giả sử chúng ta xây dựng nền tảng blog nơi người dùng xem bài viết của nhiều tác giả.
Tạo model author trước để lưu dữ liệu tác giả:
rails g scaffold author slug:string name:string description:string rake db:create rake db:migrate
Bây giờ truy cập https://localhost:3000/authors và thêm vài tác giả để gán cho bài viết sau.
Bước tiếp theo tạo articles:
rails g scaffold article title:string content:text rake db:migrate
Thêm Hỗ Trợ Đa Thuê
Trong ứng dụng này, tác giả là tenant. Mỗi tác giả chỉ truy cập bài viết của mình. Như đã nói, khi triển khai đa thuê với một database và một schema, yêu cầu quan trọng là thêm trường tenant_id vào mọi model được quản lý bởi tenant.
Tạo Migration Phù Hợp Trong Cơ Sở Dữ Liệu
rails g migration AddTenantIdToArticle tenant_id:integer rake db:migrate
Migration trên thêm cột mới vào model Article. Cột này sẽ dùng trong mọi truy vấn để chỉ lấy dữ liệu gán cho tenant hiện tại.
Gán Bài Viết Cho Tenant
Bước này cập nhật code để gán tác giả cho bài viết và sau đó render bài viết chỉ cho tác giả được chọn. Mở app/controllers/articles_controller.rb và thêm thay đổi:
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
before_action :set_authors, only: [:edit, :update, :new, :create]
# ...
private
def article_params
params.require(:article).permit(:title, :content, :tenant_id)
end
def set_authors
@authors = Author.all
end
end
Trong view, giờ dùng biến @authors chứa tập hợp tác giả. Thêm select field chứa tên tác giả và gán tenant_id phù hợp. Mở app/views/articles/_form.html.erb và thêm:
<div class="field">
<%= form.label :author %>
<%= form.select :tenant_id, options_from_collection_for_select(@authors, 'id', 'name', article.tenant_id), include_blank: true %>
</div>
Tạo vài tác giả và bài viết để sau này render bài viết chỉ cho tác giả đã chọn.
Thêm Hỗ Trợ Subdomain Tùy Chỉnh
Tạo tác giả tên John Doe với slug johndoe. Mục tiêu truy cập https://johndoe.localhost:3000/ và thấy dữ liệu chỉ liên quan đến tenant John Doe.
Cấu Hình Subdomain
Chúng ta muốn quản lý và truy cập bài viết khi tenant được set. Được thực hiện bằng cách cập nhật config/routes.rb và bọc định nghĩa resource articles vào block constraints:
Rails.application.routes.draw do
constraints subdomain: /.*/ do
resources :articles
end
resources :authors
end
Mặc định Rails đặt độ dài top-level domain là 1, nhưng dùng localhost nên set thành 0. Thêm dòng sau vào config/environments/development.rb:
config.action_dispatch.tld_length = 0
Làm Subdomain Hoạt Động Với Đa Thuê
Giờ có thể gán tác giả cho bài viết qua form. Nhưng khi dùng subdomain, không nên cho phép. Phải thay đổi hành vi ArticlesController, thay vì set tất cả tác giả, set tác giả mà subdomain yêu cầu:
class ArticlesController < ApplicationController
before_action :set_author
before_action :set_article, only: [:show, :edit, :update, :destroy]
# GET /articles
# GET /articles.json
def index
@articles = Article.where(tenant_id: @author.id)
end
# ...
private
def set_article
@article = Article.find_by!(id: params[:id], tenant_id: @author.id)
end
# ...
def set_author
@author = Author.find_by!(slug: request.subdomain)
end
end
Một vài thay đổi trong controller:
- Thay vì
set_authors, định nghĩaset_authorđể set tác giả được yêu cầu qua subdomain. Quan trọng phải gọi method này trongbefore_filtertrướcset_article. Tác giả phải được gán trước khi tìm bài viết. - Cập nhật
set_articleđể tìm bài viết với id và tác giả gán. Không muốn render bài viết của Tom khi tenant hiện tại là John. - Cập nhật action index để chỉ chọn bài viết gán cho tenant hiện tại.
Khi làm việc với ứng dụng đa thuê dùng một database một schema, luôn nhớ scope mọi truy vấn bằng cột tenant_id; ngược lại sẽ cung cấp dữ liệu không thuộc tenant yêu cầu.
Controller đã cập nhật, bước tiếp theo cập nhật view form. Mở app/views/articles/_form.html.erb và thay thế phần trước bằng hidden field đơn giản:
<%= form.hidden_field :tenant_id, value: @author.id %>
Trước thay đổi, người dùng chọn tác giả qua form. Giờ người dùng chọn tác giả cho mọi action bằng subdomain trong địa chỉ web.
Bây giờ test code. Tạo tác giả mới, ví dụ John Doe slug johndoe. Truy cập https://johndoe.localhost:3000/articles/new và thêm bài viết. Sau khi thêm, xem danh sách tại https://johndoe.localhost:3000/articles.
Chúc mừng! Bạn vừa tạo ứng dụng multi-tenant nơi mỗi tác giả có subdomain riêng.
Cải Tiến Thêm
Giờ bạn có thể mở rộng code bằng tính năng mới. Nền tảng blog đơn giản; có thể thêm phần bình luận? Dù thêm gì, nhớ thêm cột tenant_id vào model mới và dùng khi truy vấn resource.
Có thể tạo scope đặc biệt và bọc vào concern:
module Tenantable
extend ActiveSupport::Concern
included do
scope :for_author, -> (author) { where(tenant_id: author.id) }
end
end
Sau đó dùng trong mọi model chứa dữ liệu tenant:
class Article < ApplicationRecord
include Tenantable
end
author = Author.find(1)
Article.for_author(author)
Với cách này, chỉ cập nhật một nơi nếu đổi tên cột tenant_id hoặc thêm điều kiện khi truy vấn dữ liệu tenant.
Ứng Dụng Rails Multi-tenant Với Nhiều Cơ Sở Dữ Liệu
Trong phần trước, chúng ta xây dựng ứng dụng Rails hỗ trợ nhiều tenant với một database và một schema. Nhược điểm lớn là khả năng rò rỉ dữ liệu cao.
Tin tốt là phiên bản mới nhất Rails có hỗ trợ tích hợp sẵn cho nhiều database. Tôi sẽ khám phá và chỉ cách dùng nó để xây dựng ứng dụng Rails multi-tenant với nhiều database và subdomain tùy chỉnh.
Kế hoạch:
- Tạo khung ứng dụng Rails phiên bản mới nhất
- Scaffold model để có dữ liệu thao tác
- Thêm hỗ trợ subdomain tùy chỉnh
- Học cách thêm tenant mới bằng cách tạo và cấu hình database mới
- Cập nhật ứng dụng để chuyển database khi subdomain được yêu cầu
Chuẩn Bị Dữ Liệu Test
Giả sử xây dựng nền tảng blog nơi người dùng xem bài viết của nhiều tác giả. Mỗi tác giả sẽ có database riêng.
Bắt đầu tạo model Author cùng scaffold features để xem và tạo tác giả mới:
rails g scaffold author slug:string name:string description:string rake db:create rake db:migrate
Truy cập https://localhost:3000/authors xem Rails sinh ra gì.
Thêm Tác Giả Mới
Tạo tác giả John Doe slug johndoe. Giá trị slug dùng sau để phát hiện tác giả nào hiển thị qua subdomain.
Vì mỗi tác giả có database riêng, phải thêm database cho John thủ công. Mở config/database.yml và thêm:
development:
primary: <<: *default
database: tenantapp_development
primary_johndoe: <<: *default
database: tenantapp_johndoe_development
migrations_paths: db/tenants_migrations
Dùng thư mục riêng cho migration của tenant. Không cần chúng trong database trung tâm. Tạo database cho John bằng lệnh mặc định:
rake db:create
Thêm Bài Viết
Scaffold model Article cùng controller và views:
rails g scaffold article title:string content:text --database primary_johndoe
Truyền param --database để Rails biết migration không đặt trong db/migrations mặc định. Chạy migrate:
rake db:migrate
Giờ có hai schema: db/schema.rb và db/primary_johndoe_schema.rb. Nếu muốn bảng khác nhau cho tenant, đặt migrations_path duy nhất trong config/database.yml cho tenant đó. Ở đây muốn bảng giống nhau cho tất cả tenant, nên đường dẫn migration giống nhau.
Thêm Hỗ Trợ Đa Thuê
Ứng dụng multi-tenant một database, để lấy dữ liệu tenant, chỉ cập nhật truy vấn với tenant_id phù hợp. Ở đây mỗi tenant có database riêng, nên phải chuyển đổi giữa các database.
Rails 6.1 hỗ trợ built-in horizontal sharding. Shard là partition ngang dữ liệu chứa subset của dataset tổng. Chúng ta có thể lưu bài viết tất cả tác giả trong một bảng một database, nhưng nhờ sharding, chia dữ liệu thành nhiều bảng cùng cấu trúc nhưng đặt trong database riêng biệt.
Định nghĩa shard trong class cha của tất cả model. Chúng lưu ở app/models/application_record.rb:
ActiveRecord::Base.connected_to(role: :reading, shard: :johndoe) do
Article.all # lấy tất cả bài viết của John
end
Không bọc vào block connected_to, shard mặc định sẽ dùng. Trước khi tiến xa, thêm một thay đổi. Vì tất cả shard chia sẻ cấu trúc dữ liệu, xóa model app/models/primary_johndoe_record.rb tự động tạo khi scaffold articles.
Cũng phải sửa app/models/article.rb đổi class cha từ PrimaryJohndoeRecord sang ApplicationRecord:
class Article < ApplicationRecord
end
Thêm Nhiều Tenant Hơn
Hiện chỉ có một tác giả. Để test chuyển đổi giữa tenant (database), thêm tác giả nữa. Mở https://localhost:3000/authors/new thêm tác giả mới. Tôi thêm Tim Doe slug timdoe.
Có record tác giả mới, phải định nghĩa database mới:
development:
primary: <<: *default
database: tenantapp_development
primary_johndoe: <<: *default
database: tenantapp_johndoe_development
migrations_paths: db/tenants_migrations
primary_timdoe: <<: *default
database: tenantapp_timdoe_development
migrations_paths: db/tenants_migrations
Tạo database mới và chạy migration:
rake db:create rake db:migrate
Bước cuối cập nhật model ApplicationRecord định nghĩa shard mới:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to shards: {
default: { writing: :primary, reading: :primary },
johndoe: { writing: :primary_johndoe, reading: :primary_johndoe },
timdoe: { writing: :primary_timdoe, reading: :primary_timdoe },
}
end
Giờ tạo bài viết cho mỗi tác giả:
ActiveRecord::Base.connected_to(role: :writing, shard: :johndoe) do
Article.create!(title: 'Article from John', content: 'content')
end
ActiveRecord::Base.connected_to(role: :writing, shard: :timdoe) do
Article.create!(title: 'Article from Tim', content: 'content')
end
Thêm Hỗ Trợ Subdomain Tùy Chỉnh
Chưa thể xem danh sách bài viết cho tác giả vì chưa biết khi nào hiển thị bài viết của John hay Tim. Sẽ giải quyết bằng custom subdomain. Truy cập https://johndoe.localhost:3000/articles thấy bài viết của John, https://timdoe.localhost:3000/articles thấy của Tim.
Cấu Hình Subdomain
Muốn quản lý và truy cập bài viết khi tenant set. Cập nhật config/routes.rb bọc resource articles vào constraints block:
Rails.application.routes.draw do
constraints subdomain: /.*/ do
resources :articles
end
resources :authors
end
Mặc định Rails đặt tld_length = 1, nhưng dùng localhost nên set = 0. Thêm vào config/environments/development.rb:
config.action_dispatch.tld_length = 0
Làm Subdomain Hoạt Động Với Đa Thuê
Để chuẩn hóa đọc database cho tenant hiện tại, tạo controller concern tên Tenantable. Nó cung cấp method read_with_tenant nhận block và thực thi trong ngữ cảnh tenant được yêu cầu:
module Tenantable
extend ActiveSupport::Concern
private
def read_with_tenant(&block)
author = Author.find_by!(slug: request.subdomain)
ActiveRecord::Base.connected_to(role: :reading, shard: author.slug.to_sym) do
block.call
end
end
end
Lưu file này là app/controllers/concerns/tenantable.rb và include vào ArticlesController:
class ArticlesController < ApplicationController
include Tenantable
before_action :set_article, only: [:show, :edit, :update, :destroy]
def index
read_with_tenant do
@articles = Article.all
end
end
# ...
end
Giờ truy cập https://johndoe.localhost:3000/articles hoặc https://timdoe.localhost:3000/articles, sẽ thấy bài viết khác nhau hiển thị.
Nếu muốn tạo bài viết mới qua form, phải định nghĩa method write_with_tenant và cập nhật concern Tenantable và methods trong ArticlesController cho phù hợp.
Cải Tiến Thêm
Cách tiếp cận trên chỉ là wrapper method đơn giản thực thi code trong block trong kết nối database cho tenant. Để phổ biến hơn, có thể tạo middleware parse subdomain và thiết lập kết nối trước khi thực thi code:
ActiveRecord::Base.establish_connection(:primary_timdoe)
Giải pháp cuối cùng phụ thuộc nhu cầu và số nơi muốn dùng dữ liệu gán cho tenant cụ thể.
Tóm Tắt
Chúc mừng, bạn vừa xây dựng hai phiên bản ứng dụng Rails multi-tenant và hiểu về các cách xử lý nhiều tenant trong ứng dụng web hiện đại.
Tóm tắt nhanh những gì đã học:
- Có ba cấp đa thuê chính trong ứng dụng web - cấp dòng, schema, và database.
- Mỗi cách có ưu nhược điểm, lựa chọn phụ thuộc khả năng phần cứng và mức độ cô lập thông tin tenant.
- Có thể triển khai tất cả cấp đa thuê trong Ruby on Rails mà không cần thư viện ngoài.
- Rails hỗ trợ custom subdomain out of the box, bổ sung hoàn hảo cho ứng dụng multi-tenant nơi mỗi tenant có subdomain riêng.
Hy vọng bạn thích bài viết và xây dựng ứng dụng Ruby on Rails multi-tenant.
P.S. Nếu muốn đọc bài Ruby Magic ngay khi xuất bản, hãy đăng ký newsletter Ruby Magic để không bỏ lỡ bài nào!