一种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

posted @ 2022-01-22 21:05  派对实验室  阅读(1272)  评论(1编辑  收藏  举报