一種Django多租戶解決方案

派對實驗室 發表於 2022-01-22
Django

什麼是多租戶?

多租戶技術或稱多重租賃技術,簡稱SaaS,是一種軟體架構技術,是實現如何在多使用者環境下(此處的多使用者一般是面向企業使用者)共用相同的系統或程式元件,並且可確保各使用者間資料的隔離性。

多租戶資料隔離方案介紹

多租戶資料隔離方案通常有三種:DataBase級別隔離Schema級隔離Table級隔離

  • DataBase級別隔離

    即一個租戶一個資料庫,這種方案的使用者資料隔離級別最高,安全性最好,但成本較高

  • Schema級隔離

    多個或所有租戶共享Database,但是每個租戶一個Schema

  • Table級隔離

    即租戶共享同一個Database、同一個Schema,但在表中增加TenantID多租戶的資料欄位。這是共享程度最高、隔離級別最低的模式。

多租戶資料隔離方案對比

實現方案 資料隔離程度 安全性
DataBase級別隔離
Schema級隔離
Table級隔離

Django多租戶方案的實現

django 是Python語言中非常流行的Web框架,但是Django本身沒有提供一種多租戶的實現方案,也沒有一個比較成熟的Django擴充套件包來實現多租戶,這裡通過分析並改造Django原始碼來實現多租戶。

這裡以我自己的實現過程為例分享一下大概思路,對於實現思路不喜勿噴,歡迎issue

原始碼:

Django多租戶實現方案的核心

  • 通過djangoDATABASE_ROUTERS來實現不同租戶訪問不同的DataBase或者  Schame

  • 通過Python動態語言的特性在執行時修改Django部分原始碼,讓Django支援相應的邏輯

Django多租戶模組劃分

在多租戶模型中我們將資料分為兩部分:公共資料和租戶資料

  • 公共資料,指和租戶無關的資料,通常這裡指租戶資訊和全域性使用者資訊

  • 租戶資料,指的是和屬於某個租戶的資料,資料與資料之間相關隔離

根據資料許可權可知:

  1. 每個租戶只能訪問自己的資料

  2. 使用者只有完成認證之後才能訪問租戶資料

  3. 使用者最多隻能屬於某一個租戶

  4. 超級管理員預設不能屬於任何一個租戶

這裡我們將按照以上原則,將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多租戶方案的優化

但是作為一個多租戶方案上面的解決方案實在是太簡單了,存在很多問題。

  1. 多租戶的租戶是動態增加的,django初始化的時候會載入settings裡面的DATABASES變數,用來初始資料連線池,但是在專案運營過程中,租戶都是動態增加或者刪除的,總不能每次發生租戶的增加或者刪除我們修改DATABASES變數,然後重啟整個專案吧,因此資料庫連線池都需要支援動態增加或者刪除

  2. django認證完成之後,request.user是一個django.contrib.auth.models.User物件而不是我們的GlobalUser物件,因此我們必須替換request.user物件

  3. 很多人選擇django作為Python框架的原因是因為django一些內建的App十分好用,因此如果保證租戶業務邏輯中能完整的使用django一些內建App,例如Auth模組(UserGroupPermission),Admin模組、migration模組、contenttypes模組等

  4. DjangoContentType作為django內建的通用外來鍵模型,在很多地方被廣泛使用,該模型自帶快取,可以在一定程度上提升ContentType的使用效率,這特性通常沒有任何問題,但是在多租戶場景下,因為專案的迭代開發,不同的租戶加入的時間不一致,contentType內容每個租戶可能不一致,因為帶有快取,預設會以第一個ContentType資料作為快取,這樣可能會導致其他使用租戶使用這個模型時資料異常

  5. 按照我們對多租戶資料劃分的原則,如果想使用Djangoadmin模組,超級使用者只能訪問公共app資訊,租戶使用者只能訪問租戶相關資料,因此Adamin 模組也必須進行對應適配

  6. django中通常我們使用djangomigration做資料庫的遷移,因為租戶是動態新增或者減少的,通常我們需要動態的對新租戶進行資料遷移操作

  7. 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目前只支援SQLitePosgresMySQLOracle四種關係型資料庫,因為我們的租戶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初始化的是會檢查Usermodel


# 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_usernamecheck_passwordset_passwordUSERNAME_FIELDPASSWORD_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_permshas_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.permissionsIsAdminUserDjangoModelPermissionsDjangoObjectPermissions許可權類,需要將request.userGlobalUser相關的邏輯判斷切換為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