什麼是多租戶?
多租戶技術或稱多重租賃技術,簡稱SaaS
,是一種軟體架構技術,是實現如何在多使用者環境下(此處的多使用者一般是面向企業使用者)共用相同的系統或程式元件,並且可確保各使用者間資料的隔離性。
多租戶資料隔離方案介紹
多租戶資料隔離方案通常有三種:DataBase級別隔離
、Schema級隔離
和Table級隔離
-
DataBase級別隔離
即一個租戶一個資料庫,這種方案的使用者資料隔離級別最高,安全性最好,但成本較高
-
Schema級隔離
多個或所有租戶共享Database,但是每個租戶一個Schema
-
Table級隔離
即租戶共享同一個Database、同一個Schema,但在表中增加TenantID多租戶的資料欄位。這是共享程度最高、隔離級別最低的模式。
多租戶資料隔離方案對比
實現方案 | 資料隔離程度 | 安全性 |
---|---|---|
DataBase級別隔離 | 高 | 低 |
Schema級隔離 | 中 | 中 |
Table級隔離 | 低 | 高 |
Django多租戶方案的實現
django 是Python語言中非常流行的Web框架,但是Django本身沒有提供一種多租戶的實現方案,也沒有一個比較成熟的Django擴充套件包來實現多租戶,這裡通過分析並改造Django原始碼來實現多租戶。
這裡以我自己的實現過程為例分享一下大概思路,對於實現思路不喜勿噴,歡迎issue
原始碼:
Django多租戶實現方案的核心
-
通過django
DATABASE_ROUTERS
來實現不同租戶訪問不同的DataBase
或者Schame
-
通過Python動態語言的特性在執行時修改Django部分原始碼,讓Django支援相應的邏輯
Django多租戶模組劃分
在多租戶模型中我們將資料分為兩部分:公共資料和租戶資料
-
公共資料,指和租戶無關的資料,通常這裡指租戶資訊和全域性使用者資訊
-
租戶資料,指的是和屬於某個租戶的資料,資料與資料之間相關隔離
根據資料許可權可知:
-
每個租戶只能訪問自己的資料
-
使用者只有完成認證之後才能訪問租戶資料
-
使用者最多隻能屬於某一個租戶
-
超級管理員預設不能屬於任何一個租戶
這裡我們將按照以上原則,將DjangoApp
分為公共App
和租戶APP
,
公共App相關models
# 租戶表,租戶相關資訊和對應的資料庫資訊
class Tenant(models.Model):
name: str = models.CharField(max_length=20, unique=True)
label: str = models.CharField(max_length=200)
code: str = models.CharField(max_length=10, unique=True)
db_options: str = models.JSONField(null=True, blank=True)
is_active: bool = models.BooleanField(default=True)
# 全域性使用者表,全域性使用者
class GloabalUser(models.Model):
username = models.CharField(max_length=50, unique=True)
password = models.CharField(max_length=128)
is_super = models.BooleanField(default=False)
tenant = models.ForeignKey(Tenant,to_field='code',on_delete=models.CASCADE, null=True, blank=True)
多租戶架構中的租戶識別流程
全域性變數
## 執行緒全域性變數儲存當前租戶資訊和其資料庫連線名
from threading import local
_thread_local = local()
def get_current_db():
return getattr(_thread_local, 'db_name', 'default')
def set_current_db(db_name):
setattr(_thread_local, 'db_name', db_name)
檢查使用者是否屬於某個租戶
採用替換django.contrib.auth.middleware.AuthenticationMiddleware
認證中介軟體,讓django在認證過程中判斷當前使用者是否屬於全域性使用者,是否屬於某個租戶,並在請求的執行緒變數中快取租戶資訊
class MultTenantAuthenticationMiddleware(AuthenticationMiddleware):
def process_request(self, request:HttpRequest):
super().process_request(request)
if hasattr(request,'user'):
user = request.user
if not user.is_anonymous and user.tenant:
code = user.tenant.code
set_current_db(code)
根據資料庫路由切換連線的資料庫
通過配置公共app
和租戶app
的方式,一旦使用者訪問是租戶app
裡面的資料,則連線租戶資料庫
## 資料庫對映,這裡只需要定義共用的app,預設其他app為租戶app
DATABASE_APPS_MAPPING = {
'tenant': 'default',
'admin': 'default',
'sessions': 'default'
}
...
## DATABASE_ROUTER
class MultTenantDBRouter:
def db_for_read(self, model:Model, **hints) -> str:
if model._meta.app_label in settings.DATABASE_APPS_MAPPING:
## 如果訪問的是公共app資訊,返回預設資料連線資訊
return settings.DATABASE_APPS_MAPPING[model._meta.app_label]
## 否則返回租戶資料連線資訊
return get_current_db()
def db_for_write(self, model:Model, **hints):
if model._meta.app_label in settings.DATABASE_APPS_MAPPING:
return settings.DATABASE_APPS_MAPPING[model._meta.app_label]
return get_current_db()
def allow_migrate(self, db:str, app_label:str, **hints) -> bool:
if app_label == 'contenttypes':
return True
app_db = settings.DATABASE_APPS_MAPPING.get(app_label)
if app_db == 'default' and db == 'default':
return True
elif app_db != 'default' and db != 'default':
return True
else:
return False
至此就完成了一個最簡單的django多租戶解決方案。
Django多租戶方案的優化
但是作為一個多租戶方案上面的解決方案實在是太簡單了,存在很多問題。
-
多租戶的租戶是動態增加的,django初始化的時候會載入settings裡面的
DATABASES
變數,用來初始資料連線池,但是在專案運營過程中,租戶都是動態增加或者刪除的,總不能每次發生租戶的增加或者刪除我們修改DATABASES
變數,然後重啟整個專案吧,因此資料庫連線池都需要支援動態增加或者刪除 -
django認證完成之後,
request.user
是一個django.contrib.auth.models.User
物件而不是我們的GlobalUser
物件,因此我們必須替換request.user
物件 -
很多人選擇django作為Python框架的原因是因為django一些內建的App十分好用,因此如果保證租戶業務邏輯中能完整的使用django一些內建App,例如
Auth
模組(User
、Group
和Permission
),Admin
模組、migration
模組、contenttypes
模組等 -
Django
ContentType
作為django內建的通用外來鍵模型,在很多地方被廣泛使用,該模型自帶快取,可以在一定程度上提升ContentType
的使用效率,這特性通常沒有任何問題,但是在多租戶場景下,因為專案的迭代開發,不同的租戶加入的時間不一致,contentType內容每個租戶可能不一致,因為帶有快取,預設會以第一個ContentType
資料作為快取,這樣可能會導致其他使用租戶使用這個模型時資料異常 -
按照我們對多租戶資料劃分的原則,如果想使用Django
admin
模組,超級使用者只能訪問公共app
資訊,租戶使用者只能訪問租戶相關資料,因此Adamin 模組也必須進行對應適配 -
django中通常我們使用django
migration
做資料庫的遷移,因為租戶是動態新增或者減少的,通常我們需要動態的對新租戶進行資料遷移操作 -
rest_framework
作為django領域最流行的rest
框架,我們在對應的認證、許可權方面也需要進行適配
資料庫模組適配
在專案新部署的時候,預設DATABASES
裡面只配置公共資料庫,用來儲存公共app
相關資料,當有租戶加入的時候,要求租戶必須提供資料庫配置資訊,我們根據資料庫配置資訊,動態建立資料庫、資料遷移、動態為django載入資料連線。
動態建立資料庫連線
我們來看一段django原始碼
# django.utils.connection
class BaseConnectionHandler:
...
def __getitem__(self, alias):
try:
return getattr(self._connections, alias)
except AttributeError:
if alias not in self.settings:
raise self.exception_class(f"The connection '{alias}' doesn't exist.")
conn = self.create_connection(alias)
setattr(self._connections, alias, conn)
return conn
BaseConnectionHandler
作為django資料庫連線基類,實現了__getitem__
魔法函式,意味著django 在多資料庫連線的情況採取類似字典取值的方式方式返回具體的資料庫連線,根據程式碼可知,如果資料庫連線不存在的話,會丟擲一個The connection '{alias}' doesn't exist.
的異常,因為我們租戶的資料庫配置是在專案執行起來,之後動態增加了,因此資料庫連線池裡面肯定沒有我們新加入的資料庫連線,因此我們需要在ConnectionHandler
找不到對應的資料庫連線的時候去建立對應的資料庫連線
import logging
from django.db.utils import ConnectionHandler
from multi_tenant.tenant import get_tenant_db
logger = logging.getLogger('django.db.backends')
def __connection_handler__getitem__(self, alias: str) -> ConnectionHandler:
if isinstance(alias, str):
try:
return getattr(self._connections, alias)
except AttributeError:
if alias not in self.settings:
tenant_db = get_tenant_db(alias)
if tenant_db:
self.settings[alias] = tenant_db
else:
logger.error(f"The connection '{alias}' doesn't exist.")
raise self.exception_class(f"The connection '{alias}' doesn't exist.")
conn = self.create_connection(alias)
setattr(self._connections, alias, conn)
return conn
else:
logger.error(f'The connection alias [{alias}] must be string')
raise Exception(f'The connection alias [{alias}] must be string')
ConnectionHandler.__getitem__ = __connection_handler__getitem__
在這裡get_tenant_db
是我們實現的根據租戶別名獲取租戶資料連線的方法
def get_tenant_db(alias: str) -> Dict[str,str]:
Tenant = get_tenant_model()
try:
# 租戶資訊全部儲存在default資料庫連線裡面
tenant = Tenant.objects.using('default').filter(is_active=True).get(code=alias)
return tenant.get_db_config()
except Tenant.DoesNotExist:
logger.warning(f'db alias [{alias}] dont exists')
pass
執行對於新租戶執行資料庫遷移
當一個租戶被建立的時候,採用django的post_save
訊號觸發對應的建立資料庫連線和執行遷移的動作
@receiver(post_save, sender=Tenant)
def create_data_handler(sender, signal, instance, created, **kwargs):
# 如果租戶被建立
if created:
try:
# 建立資料庫
instance.create_database()
logger.info(f'create database : [{instance.db_name}] successfuly for {instance.code}')
# 線上程中執行migrate 命令
thread = Thread(target=migrate,args=[instance.code])
thread.start()
except Exception as e:
logger.error(e)
instance.delete(force=True)
def migrate(database: str):
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
logger.error('migrate fail')
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(['manage.py', 'migrate', f'--database={database}'])
logger.info('migrate successfuly!')
建立資料庫
因為django目前只支援SQLite
、Posgres
、MySQL
和Oracle
四種關係型資料庫,因為我們的租戶model,根據這四種資料庫模型實現對應的create_daatabase
方法
class AbstractTenant(models.Model):
Mysql, SQLite, Postgres, Oracle = ('Mysql', 'SQLite3', 'Postgres', 'Oracle')
...
def create_database(self) -> bool:
from multi_tenant.tenant.utils.db import MutlTenantOriginConnection
# 建立資料原生連線
if self.engine.lower() == self.SQLite.lower():
connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=False)
return True
elif self.engine.lower() == self.Postgres.lower():
connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=True, **{'NAME':'postgres'})
else:
connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=True)
create_database_sql = self.create_database_sql
if create_database_sql:
with connection.cursor() as cursor:
# 執行建立資料庫SQL語句
cursor.execute(create_database_sql)
return True
def _create_sqlite3_database(self) -> str:
pass
def _create_mysql_database(self) -> str:
return f"CREATE DATABASE IF NOT EXISTS {self.db_name} character set utf8;"
def _create_postgres_database(self) -> str:
return f"CREATE DATABASE \"{self.db_name}\" encoding 'UTF8';"
def _create_oracle_database(self) -> str:
return f"CREATE DATABASE {self.db_name} DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;"
Auth 模組適配
django的settings裡面的AUTH_USER_MODEL
配置專案為django自定義全域性User的配置項,為了便於在租戶模組完整的使用django.contrib.auth
模組,我們將AUTH_USER_MODEL
指定為GlobalUser
,但是通常這裡的AUTH_USER_MODEL
必須繼承django.contrib.auth.models.AbstractUser
物件,為了保證這一點,django.contrib.auth
模組,通常在app初始化的是會檢查User
model
# django.contrib.auth.apps
class AuthConfig(AppConfig):
...
def ready(self):
...
if isinstance(last_login_field, DeferredAttribute):
from .models import update_last_login
user_logged_in.connect(update_last_login, dispatch_uid='update_last_login')
checks.register(check_user_model, checks.Tags.models)
checks.register(check_models_permissions, checks.Tags.models)
但是對於GlobalUser
而言我們沒必要使用完整的django.contrib.auth
功能,因此不能簡單指定GlobalUser
,必須保證GlobalUser
通過check_user_model
檢查,因此我們必須實現例如normalize_username
、check_password
、set_password
、USERNAME_FIELD
、PASSWORD_FIELD
等常見的屬性和方法
然後我們將django.contrib.auth.models.AbstractUser.Meta.swappable
屬性改為AUTH_TENANT_USER_MODEL
,即租戶級別的使用者
from django.contrib.auth.models import AbstractUser, User
class Meta(AbstractUser.Meta):
swappable = 'AUTH_TENANT_USER_MODEL'
User.Meta = Meta
這樣我們就可以愉快地租戶模型中完整的使用django.contrib.auth
模組了
Admin模組適配
Admin模組即要在公共app
中使用,又要在租戶模組
使用,我們只需要保證根據登陸的使用者不同載入不同的app下的admin即可,
在這裡我們需要讓GlobalUser
實現兩個方法has_module_perms
和has_perm
class AbstractGlobalUser(models.Model):
...
def has_module_perms(self, app_label:str) -> bool:
# 是否有模組許可權
common_applist = get_common_apps()
# 如果是租戶使用者
if self.tenant:
# 租戶使用者不能訪問公共app
if app_label in common_applist:
return False
else:
return True
else:
# 只有非租戶用並且是超級使用者的才能訪問公共app
if app_label in common_applist and self.is_super:
return True
else:
return False
def has_perm(self, permission:str) -> bool:
# 使用者是否有許可權(permission表中的許可權)
TenantUser = get_tenant_user_model()
# 如果是租戶使用者
if self.tenant:
# 檢查租戶使用者的許可權
try:
tenant_user = TenantUser.objects.using(self.tenant.code).get(username=self.username)
all_permissions = tenant_user.get_all_permissions()
if permission in all_permissions:
result = tenant_user.has_perm(permission)
return result
else:
return False
except Exception as e:
print(e)
return False
else:
# 非租戶使用者因為只有超級使用者可以登陸,因此可以擁有公共app的所有許可權
True
return True
migrate適配
因為經過我們的改造django 已經支援動態增加資料庫連線,因此可以在migrate --database
引數指定一個資料庫連線別名,migrate
命令會自行判斷,如果不存在會建立
rest_framework適配
認證
我們需要在rest_framework
完成認證之後,增加判斷使用者是否屬於某個租戶的邏輯即可
from rest_framework.request import Request
from rest_framework import exceptions
from multi_tenant.local import set_current_db
def __request_authenticate(self):
"""
Attempt to authenticate the request using each authentication instance
in turn.
"""
for authenticator in self.authenticators:
try:
user_auth_tuple = authenticator.authenticate(self)
except exceptions.APIException:
self._not_authenticated()
raise
if user_auth_tuple is not None:
self._authenticator = authenticator
self.user, self.auth = user_auth_tuple
if self.user and self.user.tenant:
set_current_db(self.user.tenant.code)
return
self._not_authenticated()
Request._authenticate = __request_authenticate
許可權
因為request.user
現在是GlobalUser
,因此沒有has_perms
方法,因此rest_framework.permissions
的IsAdminUser
、DjangoModelPermissions
、DjangoObjectPermissions
許可權類,需要將request.user
的GlobalUser
相關的邏輯判斷切換為django.contrib.auth.User
物件,
這裡以DjangoModelPermissions
為例
rest_framework
原始的許可權類
class DjangoModelPermissions(BasePermission):
...
def has_permission(self, request, view):
# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, '_ignore_model_permissions', False):
return True
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only):
return False
queryset = self._queryset(view)
perms = self.get_required_permissions(request.method, queryset.model)
return request.user.has_perms(perms)
轉化之後的許可權類
class DjangoModelPermissions(BasePermission):
def has_permission(self, request, view):
username = request.user.username
current_user = None
try:
current_user = self.TenantUser.objects.filter(is_active=True).get(username=username)
except self.TenantUser.DoesNotExist:
return False
if getattr(view, '_ignore_model_permissions', False):
return True
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only):
return False
queryset = self._queryset(view)
perms = self.get_required_permissions(request.method, queryset.model)
return current_user.has_perms(perms)
至此django多租戶改造的核心已經完成改造,可以完整的使用django
所有功能,完美相容rest_framework
及其第三方外掛。
外掛使用
pip install django-multi-tenancy
使用方式詳見,原始碼README