多租户技术或称多重租赁技术,简称SaaS
,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。
多租户数据隔离方案通常有三种:DataBase级别隔离
、Schema级隔离
和Table级隔离
DataBase级别隔离
即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高
Schema级隔离
多个或所有租户共享Database,但是每个租户一个Schema
Table级隔离
即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。
实现方案 | 数据隔离程度 | 安全性 |
---|---|---|
DataBase级别隔离 | 高 | 低 |
Schema级隔离 | 中 | 中 |
Table级隔离 | 低 | 高 |
django 是Python语言中非常流行的Web框架,但是Django本身没有提供一种多租户的实现方案,也没有一个比较成熟的Django扩展包来实现多租户,这里通过分析并改造Django源码来实现多租户。
这里以我自己的实现过程为例分享一下大概思路,对于实现思路不喜勿喷,欢迎issue
源码:
https://gitee.com/Ranger313/django-multi-tenant
https://github.com/AnsGoo/djangoMultiTenant
通过djangoDATABASE_ROUTERS
来实现不同租户访问不同的DataBase
或者 Schame
通过Python动态语言的特性在运行时修改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为租户appDATABASE_APPS_MAPPING = { 'tenant': 'default', 'admin': 'default', 'sessions': 'default'}...## DATABASE_ROUTERclass 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初始化的时候会加载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
模块等
DjangoContentType
作为django内置的通用外键模型,在很多地方被广泛使用,该模型自带缓存,可以在一定程度上提升ContentType
的使用效率,这特性通常没有任何问题,但是在多租户场景下,因为项目的迭代开发,不同的租户加入的时间不一致,contentType内容每个租户可能不一致,因为带有缓存,默认会以第一个ContentType
数据作为缓存,这样可能会导致其他使用租户使用这个模型时数据异常
按照我们对多租户数据划分的原则,如果想使用Djangoadmin
模块,超级用户只能访问公共app
信息,租户用户只能访问租户相关数据,因此Adamin 模块也必须进行对应适配
django中通常我们使用djangomigration
做数据库的迁移,因为租户是动态新增或者减少的,通常我们需要动态的对新租户进行数据迁移操作
rest_framework
作为django领域最流行的rest
框架,我们在对应的认证、权限方面也需要进行适配
在项目新部署的时候,默认DATABASES
里面只配置公共数据库,用来保存公共app
相关数据,当有租户加入的时候,要求租户必须提供数据库配置信息,我们根据数据库配置信息,动态创建数据库、数据迁移、动态为django加载数据连接。
我们来看一段django源码
# django.utils.connectionclass 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 loggingfrom django.db.utils import ConnectionHandlerfrom multi_tenant.tenant import get_tenant_dblogger = 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;"
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.appsclass 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, Userclass Meta(AbstractUser.Meta): swappable = 'AUTH_TENANT_USER_MODEL'User.Meta = Meta
这样我们就可以愉快地租户模型中完整的使用django.contrib.auth
模块了
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
因为经过我们的改造django 已经支持动态增加数据库连接,因此可以在migrate --database
参数指定一个数据库连接别名,migrate
命令会自行判断,如果不存在会创建
我们需要在rest_framework
完成认证之后,增加判断用户是否属于某个租户的逻辑即可
from rest_framework.request import Requestfrom rest_framework import exceptionsfrom multi_tenant.local import set_current_dbdef __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