飙血推荐
  • HTML教程
  • MySQL教程
  • JavaScript基础教程
  • php入门教程
  • JavaScript正则表达式运用
  • Excel函数教程
  • UEditor使用文档
  • AngularJS教程
  • ThinkPHP5.0教程

一种Django多租户解决方案

时间:2022-01-23  作者:rangger  

什么是多租户?

多租户技术或称多重租赁技术,简称SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。

多租户数据隔离方案介绍

多租户数据隔离方案通常有三种:DataBase级别隔离Schema级隔离Table级隔离

  • DataBase级别隔离

    即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高

  • Schema级隔离

    多个或所有租户共享Database,但是每个租户一个Schema

  • Table级隔离

    即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。

多租户数据隔离方案对比

实现方案 数据隔离程度 安全性
DataBase级别隔离
Schema级隔离
Table级隔离

Django多租户方案的实现

django 是Python语言中非常流行的Web框架,但是Django本身没有提供一种多租户的实现方案,也没有一个比较成熟的Django扩展包来实现多租户,这里通过分析并改造Django源码来实现多租户。

这里以我自己的实现过程为例分享一下大概思路,对于实现思路不喜勿喷,欢迎issue

源码:

  • https://域名/Ranger313/django-multi-tenant

  • https://域名/AnsGoo/djangoMultiTenant

Django多租户实现方案的核心

  • 通过djangoDATABASE_ROUTERS来实现不同租户访问不同的DataBase或者  Schame

  • 通过Python动态语言的特性在运行时修改Django部分源码,让Django支持相应的逻辑

Django多租户模块划分

在多租户模型中我们将数据分为两部分:公共数据和租户数据

  • 公共数据,指和租户无关的数据,通常这里指租户信息和全局用户信息

  • 租户数据,指的是和属于某个租户的数据,数据与数据之间相关隔离

根据数据权限可知:

  1. 每个租户只能访问自己的数据

  2. 用户只有完成认证之后才能访问租户数据

  3. 用户最多只能属于某一个租户

  4. 超级管理员默认不能属于任何一个租户

这里我们将按照以上原则,将DjangoApp分为公共App租户APP,

公共App相关models

# 租户表,租户相关信息和对应的数据库信息
class Tenant(域名l):
    name: str = 域名Field(max_length=20, unique=True)
    label: str = 域名Field(max_length=200)
    code: str = 域名Field(max_length=10, unique=True)
    db_options: str = 域名Field(null=True, blank=True)
    is_active: bool = 域名eanField(default=True)

# 全局用户表,全局用户
class GloabalUser(域名l):
    username = 域名Field(max_length=50, unique=True)
    password = 域名Field(max_length=128)
    is_super = 域名eanField(default=False)
    tenant = 域名ignKey(Tenant,to_field=\'code\',on_delete=域名ADE, 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)

检查用户是否属于某个租户

采用替换域名.域名enticationMiddleware认证中间件,让django在认证过程中判断当前用户是否属于全局用户,是否属于某个租户,并在请求的线程变量中缓存租户信息


class MultTenantAuthenticationMiddleware(AuthenticationMiddleware):
    def process_request(self, request:HttpRequest):
        super().process_request(request)
        if hasattr(request,\'user\'):
            user = 域名
            if not 域名nonymous and 域名nt:
                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 域名label in 域名BASE_APPS_MAPPING:
            ## 如果访问的是公共app信息,返回默认数据连接信息
            return 域名BASE_APPS_MAPPING[域名label]
            ## 否则返回租户数据连接信息
        return get_current_db()

    def db_for_write(self, model:Model, **hints):
        if 域名label in 域名BASE_APPS_MAPPING:
            return 域名BASE_APPS_MAPPING[域名label]

        return get_current_db()

    def allow_migrate(self, db:str, app_label:str, **hints) -> bool:
        if app_label == \'contenttypes\':
            return True
        app_db = 域名(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认证完成之后,域名是一个域名.域名对象而不是我们的GlobalUser对象,因此我们必须替换域名对象

  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源码

# 域名ection
class BaseConnectionHandler:
    ...

    def __getitem__(self, alias):
        try:
            return getattr(域名nections, alias)
        except AttributeError:
            if alias not in 域名ings:
                raise 域名ption_class(f"The connection \'{alias}\' doesn\'t exist.")
        conn = 域名te_connection(alias)
        setattr(域名nections, alias, conn)
        return conn

BaseConnectionHandler作为django数据库连接基类,实现了__getitem__魔法函数,意味着django 在多数据库连接的情况采取类似字典取值的方式方式返回具体的数据库连接,根据代码可知,如果数据库连接不存在的话,会抛出一个The connection \'{alias}\' doesn\'t exist.的异常,因为我们租户的数据库配置是在项目运行起来,之后动态增加了,因此数据库连接池里面肯定没有我们新加入的数据库连接,因此我们需要在ConnectionHandler找不到对应的数据库连接的时候去创建对应的数据库连接

import logging
from 域名s import ConnectionHandler
from 域名nt import get_tenant_db
logger = 域名ogger(\'域名ends\')

def __connection_handler__getitem__(self, alias: str) -> ConnectionHandler:
    if isinstance(alias, str):
        try:
            return getattr(域名nections, alias)
        except AttributeError:
            if alias not in 域名ings:
                tenant_db = get_tenant_db(alias)
                if tenant_db:
                    域名ings[alias] = tenant_db
                else:
                    域名r(f"The connection \'{alias}\' doesn\'t exist.")
                    raise 域名ption_class(f"The connection \'{alias}\' doesn\'t exist.")
        conn = 域名te_connection(alias)
        setattr(域名nections, alias, conn)
        return conn

    else:
        域名r(f\'The  connection alias [{alias}] must be string\')
        raise Exception(f\'The  connection alias [{alias}] must be string\')

域名titem__ = __connection_handler__getitem__

在这里get_tenant_db是我们实现的根据租户别名获取租户数据连接的方法


def get_tenant_db(alias: str) -> Dict[str,str]:
    Tenant = get_tenant_model()
    try:
        # 租户信息全部保存在default数据库连接里面
        tenant  = 域名g(\'default\').filter(is_active=True).get(code=alias)
        return 域名db_config()
    except 域名NotExist:
        域名ing(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:
            # 创建数据库
            域名te_database()
            域名(f\'create database : [{域名ame}] successfuly for {域名}\')
            # 在线程中执行migrate 命令
            thread = Thread(target=migrate,args=[域名])
            域名t()
        except Exception as e:
            域名r(e)
            域名te(force=True)

def migrate(database: str):
    try:
        from 域名gement import execute_from_command_line
    except ImportError as exc:
        域名r(\'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([\'域名\', \'migrate\', f\'--database={database}\'])
    域名(\'migrate successfuly!\')

创建数据库

因为django目前只支持SQLitePosgresMySQLOracle四种关系型数据库,因为我们的租户model,根据这四种数据库模型实现对应的create_daatabase方法


class AbstractTenant(域名l):
    Mysql, SQLite, Postgres, Oracle = (\'Mysql\', \'SQLite3\', \'Postgres\', \'Oracle\')
   ...

    def create_database(self) -> bool:
        from 域名域名 import MutlTenantOriginConnection
        # 创建数据原生连接
        if 域名r() == 域名r():
            connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=False)
            return True
        elif 域名r() == 域名r():
            connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=True, **{\'NAME\':\'postgres\'})
        else:
            connection = MutlTenantOriginConnection().create_connection(tentant=self, popname=True)
            
        create_database_sql = 域名te_database_sql
        if create_database_sql:
            with 域名or() as cursor:
                # 执行创建数据库SQL语句
                域名ute(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 {域名ame} character set utf8;"

    def _create_postgres_database(self) -> str:
        return f"CREATE DATABASE \"{域名ame}\" encoding \'UTF8\';"

    def _create_oracle_database(self) -> str:
  
        return f"CREATE DATABASE {域名ame} DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;"

Auth 模块适配

django的settings里面的AUTH_USER_MODEL配置项目为django自定义全局User的配置项,为了便于在租户模块完整的使用域名模块,我们将AUTH_USER_MODEL指定为GlobalUser,但是通常这里的AUTH_USER_MODEL必须继承域名.域名ractUser对象,为了保证这一点,域名模块,通常在app初始化的是会检查Usermodel


# 域名.apps
class AuthConfig(AppConfig):
    ...

    def ready(self):
        ...
        if isinstance(last_login_field, DeferredAttribute):
            from .models import update_last_login
            域名ect(update_last_login, dispatch_uid=\'update_last_login\')
        域名ster(check_user_model, 域名ls)
        域名ster(check_models_permissions, 域名ls)

但是对于GlobalUser而言我们没必要使用完整的域名功能,因此不能简单指定GlobalUser
,必须保证GlobalUser通过check_user_model检查,因此我们必须实现例如normalize_usernamecheck_passwordset_passwordUSERNAME_FIELDPASSWORD_FIELD等常见的属性和方法

然后我们将域名.域名.swappable属性改为AUTH_TENANT_USER_MODEL,即租户级别的用户

from 域名.models import AbstractUser, User
class Meta(域名):
    swappable = \'AUTH_TENANT_USER_MODEL\'

域名 = Meta

这样我们就可以愉快地租户模型中完整的使用域名模块了

Admin模块适配

Admin模块即要在公共app中使用,又要在租户模块使用,我们只需要保证根据登陆的用户不同加载不同的app下的admin即可,
在这里我们需要让GlobalUser实现两个方法has_module_permshas_perm


class AbstractGlobalUser(域名l):
    ...

    def has_module_perms(self, app_label:str) -> bool:
        # 是否有模块权限
        common_applist = get_common_apps()
        # 如果是租户用户
        if 域名nt:
            # 租户用户不能访问公共app
            if app_label in common_applist:
                return False
            else:
                return True
        else:
            # 只有非租户用并且是超级用户的才能访问公共app
            if app_label in common_applist and 域名uper:
                return True
            else:
                return  False

    def has_perm(self, permission:str) -> bool:
        # 用户是否有权限(permission表中的权限)
        TenantUser = get_tenant_user_model()
        # 如果是租户用户
        if 域名nt:
            # 检查租户用户的权限
            try:
                tenant_user = 域名g(域名).get(username=域名name)
                all_permissions = 域名all_permissions()
                if permission in all_permissions:
                    result = 域名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 域名est import Request
from rest_framework import exceptions

from 域名l import set_current_db


def __request_authenticate(self):
    """
    Attempt to authenticate the request using each authentication instance
    in turn.
    """
    for authenticator in 域名enticators:
        try:
            user_auth_tuple = 域名enticate(self)
        except 域名xception:
            域名_authenticated()
            raise

        if user_auth_tuple is not None:
            域名henticator = authenticator
            域名, 域名 = user_auth_tuple
            if 域名 and 域名nt:
                set_current_db(域名域名)
            return

    域名_authenticated()

域名henticate = __request_authenticate

权限

因为域名现在是GlobalUser,因此没有has_perms方法,因此域名issionsIsAdminUserDjangoModelPermissionsDjangoObjectPermissions权限类,需要将域名GlobalUser相关的逻辑判断切换为域名.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 域名 or (
           not 域名uthenticated and 域名enticated_users_only):
            return False

        queryset = 域名ryset(view)
        perms = 域名required_permissions(域名od, 域名l)

        return 域名perms(perms)

转化之后的权限类


class DjangoModelPermissions(BasePermission):


    def has_permission(self, request, view):
        username = 域名name
        current_user = None
        try:
            current_user = 域名域名er(is_active=True).get(username=username)
        except 域名NotExist:
            return False
        
        if getattr(view, \'_ignore_model_permissions\', False):
            return True

        if not 域名 or (
           not 域名uthenticated and 域名enticated_users_only):
            return False

        queryset = 域名ryset(view)
        perms = 域名required_permissions(域名od, 域名l)

        return 域名perms(perms)

至此django多租户改造的核心已经完成改造,可以完整的使用django所有功能,完美兼容rest_framework及其第三方插件。

插件使用

pip install django-multi-tenancy

使用方式详见,源码README

标签:编程
湘ICP备14001474号-3  投诉建议:234161800@qq.com   部分内容来源于网络,如有侵权,请联系删除。