Multi-tenancy คืออะไร?
ตามนิยามของ multi-tenancy เมื่อแอปพลิเคชันให้บริการหลาย tenant หมายถึงมีกลุ่มผู้ใช้หลายกลุ่มที่ใช้งานอินสแตนซ์ซอฟต์แวร์ร่วมกัน ตัวอย่างที่ดีคือแพลตฟอร์ม Jira ซึ่งแต่ละบริษัทมี subdomain ของตัวเองเพื่อเข้าถึงซอฟต์แวร์ เช่น mycompany.atlassian.net
ในบทความนี้จะนำเสนอทั้งทฤษฎีและปฏิบัติการเกี่ยวกับ multi-tenancy หลังจากอภิปรายวิธีการยอดนิยมในการรองรับหลาย tenant ในแอปพลิเคชัน จะสาธิตวิธีการนำสองวิธีไปใช้ในแอป Rails เราจะสร้างแอปง่ายๆ ที่รองรับหลาย tenant โดยแต่ละ tenant จะมี subdomain ของตัวเอง
หลังจากอ่านบทความนี้ คุณจะสามารถ:
- อภิปรายประเภทต่างๆ ของแอป multi-tenant และบอกได้ว่าแต่ละวิธีเหมาะสมเมื่อไหร่
- สร้างแอปพลิเคชัน Ruby on Rails ที่รองรับหลาย tenant
- ใช้ custom subdomain สำหรับลูกค้าภายในแอป Ruby on Rails
ต้องมีความรู้พื้นฐานเกี่ยวกับ Rails เพื่อติดตามบทความนี้และได้ประโยชน์สูงสุด
ทฤษฎีของ Multi-tenancy
ตามที่กล่าวไว้ เราบอกได้ว่าแอปรองรับ multi-tenancy เมื่อให้บริการกลุ่มผู้ใช้หลายกลุ่มที่เข้าฟีเจอร์ของแอปพลิเคชันร่วมกัน ตัวอย่างที่ดีคือแพลตฟอร์มบล็อกกิ้ง ซึ่งทุกผู้ใช้ได้ subdomain แยกในโดเมนหลัก แต่ละบล็อกมี subdomain ของตัวเองแต่ใช้ฟีเจอร์เดียวกัน: คอมเมนต์ บทความ และแดชบอร์ดจัดการ
แม้จะดูเหมือนว่าแอป multi-tenant ทำงานคล้ายกัน แต่ภายใต้ฮู้ดอาจใช้สถาปัตยกรรม multi-tenancy ที่ต่างกัน มาดูประเภทยอดนิยมและมีประสิทธิภาพที่สุด
ระดับ Database-row (แถวฐานข้อมูล)
หากไม่ต้องการใช้หลายฐานข้อมูล สามารถเลือกวิธีที่ทำงานบนฐานข้อมูลกลาง ตารางแต่ละตารางมีคอลัมน์ tenant_id ใช้ในทุกคิวรีเพื่อดึงข้อมูลที่เป็นของ tenant นั้นๆ
ข้อดี: ตั้งค่า tenant เร็ว ไม่มีค่าใช้จ่ายเพิ่ม และนำไปใช้ได้ทุกที่ แต่ต้องระวังการรั่วไหลของข้อมูลหากลืมใส่ tenant_id ในคิวรี การแก้โครงสร้างฐานข้อมูลสำหรับกลุ่มผู้ใช้เฉพาะจะยากต่อการบำรุงรักษา
ระดับ Schema (สคีมา)
ยังคงใช้ฐานข้อมูลกลาง แต่มีตารางและ schema แยกสำหรับแต่ละ tenant ต่างจากระดับ row ที่ต้องแก้คิวรี วิธีนี้เปลี่ยน search path ไปยังตารางเมื่อสลับ tenant ใน PostgreSQL ใช้ set search_path และ MySQL ใช้ use statement
ก่อนเลือกวิธีนี้ ต้องแน่ใจว่า DB server รองรับคำสั่งเหล่านี้ การเพิ่ม tenant ใหม่ค่อนข้างช้า เพราะต้องสร้าง schema ใหม่และตารางในฐานข้อมูล
ระดับ Database (ฐานข้อมูล)
หากจ่ายค่าใช้จ่ายสำหรับฐานข้อมูลใหม่ทุกครั้งที่เพิ่ม tenant วิธีนี้อาจเหมาะสม วิชีนี้ช้าที่สุดในการเพิ่ม tenant ใหม่
ข้อดี: ยากที่จะรั่วไหลข้อมูล เพราะต้องสร้างการเชื่อมต่อใหม่เพื่อสลับ tenant สามารถแก้โครงสร้างฐานข้อมูลของแต่ละกลุ่มผู้ใช้ได้ง่าย
สร้างโครงสร้างแอปพลิเคชัน
เราจะสร้างสองแอปแยกกัน: หนึ่งรองรับ multi-tenancy ด้วยฐานข้อมูลเดียว และอีกหนึ่งด้วยหลายฐานข้อมูล แทนวิธีต่างกันในการจัดการ tenant หลายราย แต่ขั้นตอนการตั้งค่าเหมือนกัน
Ruby ล่าสุดคือ 2.7.2 และ Rails 6.1 RC1 ตอนเขียนบทความ ตรวจสอบเวอร์ชัน Ruby ที่ติดตั้งและติดตั้ง framework ให้ถูกต้อง:
gem install rails -v 6.1.0.rc1
สร้างไฟล์โปรเจกต์:
rails _6.1.0.rc1_ new tenantapp -d=mysql
เข้าไดเรกทอรีรันเซิร์ฟเวอร์ตรวจสอบหน้าต้อนรับ:
cd tenantapp/ rails s
แอป Rails Multi-tenant ด้วยฐานข้อมูลเดียว
มาเขียนโค้ดกัน จะสาธิตการ implement multi-tenancy ใน Rails โดยใช้วิธีฐานข้อมูลเดียวและ schema เดียว
แผนการ:
- สร้างโครงสร้าง Rails app ด้วย Ruby และ Rails ล่าสุด
- สcaffold model เพื่อมีข้อมูลให้ทำงาน
- เพิ่มรองรับ multi-tenancy โดยอัปเดตตารางฐานข้อมูลและโค้ด
- เพิ่มรองรับ custom subdomain ให้ผู้ใช้เข้าถึง tenant ได้ง่าย
- สำรวจไอเดียฟีเจอร์เพิ่มเติมที่สามารถทำต่อได้เอง
เตรียมข้อมูลทดสอบ
สมมติเราสร้างแพลตฟอร์มบล็อก ผู้ใช้ดูบทความจากหลายๆ ผู้เขียน
สร้าง model Author ก่อน:
rails g scaffold author slug:string name:string description:string rake db:create rake db:migrate
เข้า https://localhost:3000/authors เพิ่ม author หลายราย
ต่อมา สร้าง articles:
rails g scaffold article title:string content:text rake db:migrate
เพิ่มการรองรับ Multi-tenancy
ในแอปนี้ author คือ tenant แต่ละ author เข้าถึงเฉพาะบทความของตัวเอง ต้องเพิ่มฟิลด์ tenant_id ในทุก model ที่ tenant จัดการ
สร้าง migration ที่เหมาะสม
rails g migration AddTenantIdToArticle tenant_id:integer rake db:migrate
Migration นี้เพิ่มคอลัมน์ใหม่ใน model Article ใช้ในคิวรีทุกครั้งเพื่อดึงข้อมูลเฉพาะ tenant ปัจจุบัน
กำหนดบทความให้ tenant
อัปเดตโค้ดเพื่อกำหนด author ให้ article และแสดงเฉพาะบทความของ author ที่เลือก เปิด app/controllers/articles_controller.rb เพิ่ม:
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
ใน view ใช้ตัวแปร @authors ที่มีคอลเลกชัน author เปิด app/views/articles/_form.html.erb เพิ่ม select field:
<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>
สร้าง author และ article แล้วทดสอบแสดงบทความเฉพาะ author นั้น
เพิ่มการรองรับ Custom Subdomain
สร้าง author ชื่อ John Doe slug = johndoe เป้าหมายคือเข้า https://johndoe.localhost:3000/ แล้วเห็นข้อมูลเฉพาะ John Doe
ตั้งค่า Subdomain
จัดการและเข้าชม article เมื่อ tenant ถูกตั้งค่า โดยอัปเดต config/routes.rb ห่อ resources :articles ใน constraints block:
Rails.application.routes.draw do
constraints subdomain: /.*/ do
resources :articles
end
resources :authors
end
Rails ตั้ง top-level domain length = 1 ต้องตั้งเป็น 0 สำหรับ localhost ใน config/environments/development.rb:
config.action_dispatch.tld_length = 0
ทำให้ Subdomain ทำงานกับ Multi-tenancy
ตอนนี้กำหนด author ให้ article ได้แล้ว แต่เมื่อใช้ subdomain ไมควรให้เลือก author เอง ต้องแก้ ArticlesController ให้เซต author จาก subdomain ที่ร้องขอ:
class ArticlesController < ApplicationController
before_action :set_author
before_action :set_article, only: [:show, :edit, :update, :destroy]
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
การเปลี่ยนแปลง:
- เปลี่ยน
set_authorsเป็นset_authorหา author จาก subdomain เรียกใน before_filter ก่อนset_article - อัปเดต
set_articleค้นหา article ด้วย id และ author ที่กำหนด ไม่ให้ดูบทความของคนอื่น - อัปเดต index action เลือกเฉพาะ article ของ tenant ปัจจุบัน
เมื่อทำงานกับ multi-tenant app แบบหนึ่งฐานข้อมูลหนึ่ง schema ต้องจำได้ว่า scope คิวรีด้วยคอลัมน์ tenant_id เสมอ มิฉะนั้นจะแสดงข้อมูลที่ไม่ใช่ของ tenant ที่ร้องขอ
อัปเดต form view เปิด app/views/articles/_form.html.erb แทนที่ส่วนเดิมด้วย hidden field:
<%= form.hidden_field :tenant_id, value: @author.id %>
ก่อนหน้านี้ user เลือก author ได้จากฟอร์ม ตอนนี้ user เลือก author ผ่าน subdomain ใน URL
ทดสอบ: สร้าง author ชื่อ John Doe slug johndoe เข้า https://johndoe.localhost:3000/articles/new เพิ่ม article แล้วดูรายการที่ https://johndoe.localhost:3000/articles
ยินดีด้วย! คุณสร้าง multi-tenant app ที่แต่ละ author มี subdomain ของตัวเองแล้ว
ปรับปรุงเพิ่มเติม
ขยายโค้ดด้วยฟีเจอร์ใหม่ แพลตฟอร์มบล็อกนี้ง่าย มาเพิ่มส่วนคอมเมนต์กัน ไม่ว่าจะเพิ่มฟังก์ชันไหน ต้องจำว่าเพิ่มคอลัมน์ tenant_id ใน model ใหม่และใช้ตอนคิวรี resource
สร้าง scope מיוחדห่อใน concern:
module Tenantable
extend ActiveSupport::Concern
included do
scope :for_author, -> (author) { where(tenant_id: author.id) }
end
end
ใช้ในทุก model ที่มีข้อมูล tenant:
class Article < ApplicationRecord
include Tenantable
end
author = Author.find(1)
Article.for_author(author)
วิธีนี้ต้องแก้ที่เดียวถ้า rename tenant_id หรือเพิ่มเงื่อนไขคิวรี tenant
แอป Rails Multi-tenant ด้วยหลายฐานข้อมูล
ก่อนหน้านี้สร้างแอปรองรับ multi-tenant ด้วยฐานข้อมูลเดียว schema เดียว ข้อเสียใหญ่คือโอกาสรั่วไหลข้อมูลสูง
ข่าวดีคือ Rails ล่าสุดมี built-in support จัดการหลายฐานข้อมูล จะสำรวจและสาธิตการสร้าง multi-tenant Rails app ด้วยหลายฐานข้อมูลและ custom subdomain
แผนการ:
- สร้างโครงสร้าง Rails app ด้วย Ruby และ Rails ล่าสุด
- สcaffold model เพื่อมีข้อมูลให้ทำงาน
- เพิ่มรองรับ custom subdomain
- เรียนรู้เพิ่ม tenant ใหม่โดยสร้างและตั้งค่าฐานข้อมูลใหม่
- อัปเดตแอปสลับฐานข้อมูลเมื่อร้องขอ subdomain เฉพาะ
เตรียมข้อมูลทดสอบ
สมมติสร้างแพลตฟอร์มบล็อก ผู้ใช้ดูบทความจากหลาย author แต่ละ author มีฐานข้อมูลเฉพาะ
เริ่มสร้าง model Author พร้อม scaffold:
rails g scaffold author slug:string name:string description:string rake db:create rake db:migrate
เข้า https://localhost:3000/authors ดูสิ่งที่ Rails สร้างให้
เพิ่ม author ใหม่
สร้าง author ชื่อ John Doe slug johndoe slug ใช้ตรวจจับ author ที่ควรแสดงผ่าน subdomain
แต่ละ author มีฐานข้อมูลแยก ต้องเพิ่มฐานข้อมูลสำหรับ John แมนูวล เปิด config/database.yml เพิ่ม:
development:
primary: <<: *default
database: tenantapp_development
primary_johndoe: <<: *default
database: tenantapp_johndoe_development
migrations_paths: db/tenants_migrations
ใช้ไดเรกทอรีแยกสำหรับ migration ของ tenant ไม่ต้องอยู่ในฐานข้อมูลกลาง สร้างฐานข้อมูลสำหรับ John:
rake db:create
เพิ่ม articles
Scaffold model Article พร้อม controller และ views:
rails g scaffold article title:string content:text --database primary_johndoe
ส่ง param --database บอก Rails ให้ migration อยู่ในไดเรกทอรี db/tenants_migrations ไม่ใช่ default db/migrations รัน migrate:
rake db:migrate
ตอนนี้มีสอง schema: db/schema.rb และ db/primary_johndoe_schema.rb หากต้องการตารางต่างกันต่อ tenant ตั้ง migrations_path ให้แตกต่างใน database.yml บทความนี้อยากให้ตารางเหมือนกันทุก tenant ดังนั้น path migration เหมือนกัน
เพิ่มการรองรับ Multi-tenancy
ในแอปแบบหนึ่งฐานข้อมูล ใส่ tenant_id ในคิวรี แต่นี้แต่ละ tenant มีฐานข้อมูลตัวเอง ต้องสลับระหว่างฐานข้อมูล
Rails 6.1 มี built-in support horizontal sharding Shard คือ partition แนวนอนที่มี subset ของข้อมูลทั้งหมด สามารถแยกข้อมูลเป็นหลายตารางโครงสร้างเดียวกันแต่อยู่ในฐานข้อมูลต่างกัน
กำหนด shard ใน parent class ของทุก model อยู่ที่ app/models/application_record.rb:
ActiveRecord::Base.connected_to(role: :reading, shard: :johndoe) do
Article.all # get all articles created by John
end
ถ้าไม่ห่อใน connected_to block จะใช้ default shard ก่อนจะต่อ ต้องลบ app/models/primary_johndoe_record.rb ที่สร้างอัตโนมัติตอน scaffold article
แก้ app/models/article.rb เปลี่ยน parent class จาก PrimaryJohndoeRecord เป็น ApplicationRecord:
class Article < ApplicationRecord
end
เพิ่ม tenant เพิ่มเติม
ตอนนี้มี author เดียว ต้องเพิ่มอีกหนึ่งรายเพื่อทดสอบสลับ tenant เปิด https://localhost:3000/authors/new เพิ่ม author ชื่อ Tim Doe slug timdoe
มี record author ใหม่ ต้องกำหนดฐานข้อมูลใหม่:
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
สร้างฐานข้อมูลใหม่และรัน migration:
rake db:create rake db:migrate
อัปเดต ApplicationRecord model กำหนด shard ใหม่:
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
สร้าง article ให้แต่ละ author:
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
เพิ่มการรองรับ Custom Subdomain
ยังเข้า article list ของ author ไม่ได้ เพราะยังไม่รู้ว่าเมื่อไหร่ควรแสดง article ของ John หรือ Tim แก้ด้วย custom subdomain เข้า https://johndoe.localhost:3000/articles เห็น article ของ John เข้า https://timdoe.localhost:3000/articles เห็น article ของ Tim
ตั้งค่า Subdomain
อัปเดต config/routes.rb ห่อ resources :articles ใน constraints block (เหมือนแอปแรก):
Rails.application.routes.draw do
constraints subdomain: /.*/ do
resources :articles
end
resources :authors
end
ตั้ง config.action_dispatch.tld_length = 0 ใน config/environments/development.rb (เหมือนแอปแรก)
ทำให้ Subdomain ทำงานกับ Multi-tenancy
เพื่อมาตรฐานการอ่านฐานข้อมูลสำหรับ tenant ปัจจุบัน สร้าง controller concern ชื่อ Tenantable มี method read_with_tenant รับ block และ execute ใน context ของ tenant ที่ร้องขอ:
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
บันทึกเป็น app/controllers/concerns/tenantable.rb และ include ใน 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
ตอนนี้เข้า https://johndoe.localhost:3000/articles หรือ https://timdoe.localhost:3000/articles จะเห็น article ต่างกัน
หากต้องการสร้าง article ใหม่ผ่านฟอร์ม ต้อง define method write_with_tenant และอัปเดต concern และ method ใน ArticlesController ให้เหมาะสม
ปรับปรุงเพิ่มเติม
วิธีด้านบนเป็น wrapper เรียกง่ายที่ execute code ใน block ภายใต้ connection ฐานข้อมูลที่กำหนด เพื่อทำให้ universal มากขึ้น สร้าง middleware parse subdomain และสร้าง connection ก่อน execute code ใดๆ:
ActiveRecord::Base.establish_connection(:primary_timdoe)
โซลูชันสุดท้ายขึ้นอยู่กับความต้องการและจำนวนที่อยากใช้ข้อมูลที่ assign ให้ tenant เฉพาะ
สรุป
ยินดีด้วย! คุณเพิ่งสร้างสอง version ของ multi-tenant Rails app และได้ความรู้เกี่ยวกับวิธีต่างๆ ในการจัดการ multiple tenant ในเว็บแอปสมัยใหม่
สรุปสิ่งที่เรียนรู้:
- มีสามระดับ multi-tenancy หลักในเว็บแอป: database-row, schema, และ database level
- แต่ละวิธีมีข้อดีข้อเสีย เลือกขึ้นอยู่กับความสามารถของฮาร์ดแวร์และระดับการแยกข้อมูล tenant
- สามารถ implement ทุกระดับ multi-tenancy ภายใน Ruby on Rails ได้โดยไม่ต้องใช้ external libraries
- Rails รองรับ custom subdomain out of the box เหมาะสำหรับ multi-tenant app ที่แต่ละ tenant มี subdomain ของตัวเอง
หวังว่าคุณจะเพลิดเพลินกับบทความและการสร้าง multi-tenant Ruby on Rails application
P.S. หากอยากอ่าน Ruby Magic posts ทันทีที่เผยแพร่ สมัครสมาชิก Ruby Magic newsletter และไม่พลาดโพสต์เด็ดๆ!