基于Django实现多租户项目连接不同数据库

本文介绍了如何基于Django框架构建一个多租户的CMDB资产管理系统,吸取了之前项目的经验教训,通过高度抽象租户层,实现了真正的业务代码共享。文中详细阐述了如何利用自定义数据库路由实现不同租户连接不同数据库,并通过缓存租户ID的中间件确保数据隔离。同时,还展示了如何处理数据库迁移和Django后台工具的支持,确保多租户架构的高效运作。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

之前也参与过一个医疗行业的saas平台建设与维护工作,但说实话那个系统架构太low了,总监也是菜得很,租户层和业务层根本没分开,每个view中判断请求所属租户,然后操作对应数据库。

本人最近也是基于django框架实现saas多租户CMDB资产管理系统,鉴于之前经验教训,高度抽象了租户层,真正实现多租户共用一套业务代码的架构,业务逻辑不需要关心租户的概念,系统自动处理,程序员只需要安心写逻辑就行了。

多租户数据库架构可分为三种类型,即

  • 独立数据库
  • 共享数据库、独立 Schema
  • 共享数据库、共享 Schema、共享数据表

独立数据库是一个租户独享一个数据库实例,它提供了最强的分离度,租户的数据彼此物理不可见,备份与恢复都很灵活;

共享数据库、独立 Schema 将每个租户关联到同一个数据库的不同 Schema,租户间数据彼此逻辑不可见,上层应用程序的实现和独立数据库一样简单,但备份恢复稍显复杂;

最后一种模式则是租户数据在数据表级别实现共享,它提供了最低的成本,但引入了额外的编程复杂性(程序的数据访问需要用 tenantId 来区分不同租户),备份与恢复也更复杂。

以 Django+第一或第二种数据库隔离架构 为例,实现多租户saas平台。

django项目创建过程请自行完成,以下只演示关键步骤。

一、编写db_router

不同租户连接不同数据库,django通过自定义数据库路由来实现:

from itom_cmdb.libs.utils.tenant import TenantUtil


class DBRouter:
    def db_for_read(self, model, **hints):
        tenant_id = TenantUtil.get_current_tenant()
        if not tenant_id:
            raise Exception('DBRouter get `tenant_id` failed')
        return tenant_id 
        #django用这个返回值去匹配settings中DATABASES数据库,
        #例如tenant_id=tenant_1,那么查询时会选择DATABASES中名为tenant_1的数据库

    def db_for_write(self, model, **hints):
        tenant_id = TenantUtil.get_current_tenant()
        if not tenant_id:
            raise Exception('DBRouter get `tenant_id` failed')
        return tenant_id

    def allow_relation(self, obj1, obj2, **hints):
        return None

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        return True

settings中声明DATABASE_ROUTERS:

....
DATABASES = {
    'default': {# 必须有个名为default的默认数据库
            'ENGINE': 'django.db.backends.mysql',
            'NAME': os.getenv('DATABASE_NAME', 'default'),
            'USER': os.getenv('DATABASE_USER', 'root'),
            'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
            'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
            'PORT': os.getenv('DATABASE_PORT', 3306),
            'ATOMIC_REQUESTS': True,
            'OPTIONS': {'charset': 'utf8mb4'},
    },
    'tenant_1': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': os.getenv('DATABASE_NAME', 'tenant_1'),
        'USER': os.getenv('DATABASE_USER', 'root'),
        'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
        'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
        'PORT': os.getenv('DATABASE_PORT', 3306),
        'ATOMIC_REQUESTS': True,
        'OPTIONS': {'charset': 'utf8mb4'},
    },
    'tenant_2': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': os.getenv('DATABASE_NAME', 'tenant_2'),
        'USER': os.getenv('DATABASE_USER', 'root'),
        'PASSWORD': os.getenv('DATABASE_PASSWORD', 'pwd123456'),
        'HOST': os.getenv('DATABASE_HOST', '127.0.0.1'),
        'PORT': os.getenv('DATABASE_PORT', 3306),
        'ATOMIC_REQUESTS': True,
        'OPTIONS': {'charset': 'utf8mb4'},
    },
}


DATABASE_ROUTERS = ['itom_cmdb.libs.utils.db_router.DBRouter']

TenantUtil工具主要实现对租户ID进行缓存和读取,下面来看 TenantUtil 工具的实现:

二、缓存tenant_id

try:
    # Werkzeug.Local is a reliable cache(https://blog.youkuaiyun.com/bocai_xiaodaidai/article/details/118569734)
    from werkzeug.local import Local as LocalContext
except ImportError:
    from threading import local as LocalContext


thread_local = LocalContext()

TENANT_KEY = 'current_tenant_id'


class TenantUtil:
    @classmethod
    def set_current_tenant(cls, tenant_id):
        setattr(thread_local, TENANT_KEY, tenant_id)

    @classmethod
    def get_current_tenant(cls):
        return getattr(thread_local, TENANT_KEY, None)

    @classmethod
    def release_current_tenant(cls):
        thread_local.__delattr__(TENANT_KEY)

三、添加租户ID缓存中间件

class CacheTenantMiddleWare(MiddlewareMixin):
    def process_request(self, request):
        tenant_id = request.header.get(HTTP_TENANT-ID)
        if not tenant_id:
            raise PermissionDenied(_('租户ID为设置'))
        TenantUtil.set_current_tenant(tenant_id)
        return None

    def process_response(self, request, response):
        TenantUtil.release_current_tenant()
        return response



MIDDLEWARE = [
    ...
    'itom_cmdb.libs.frameworks.middlewares.CacheTenantMiddleWare',
]

流程梳理:

  1. 前端发起请求时,在header中加入tenant_id参数
  2. 中间件通过获取header中的租户id并缓存到本地线程局部变量中(这一步变种很多,比如后端可以通过登录用户的邮箱后缀或者访问域名来区分租户)
  3. Django读写数据时,会调用我们自己配置的DATABASE_ROUTERS
  4. DATABASE_ROUTERS通过获取本地缓存的租户id去匹配对应的数据库

tips:

  • 数据库migrate迁移命令需要重写,因为默认的migrate拿到不tenant_id

  以下是重写之后的migrate命令,使用时指定要迁移的数据库即可。如:

python manage.py makemigrations

python manage.py migrate --database  tenant_1   (仅迁移tenant_1数据库)

python manage.py migrate --database  all    (all代表迁移所有数据库)

# -*- coding:utf-8 -*-
from django.conf import settings
from django.core.management.commands.migrate import Command as DjangoCommand

from itom_cmdb.libs.utils.tenant_util import TenantUtil


class Command(DjangoCommand):

    def add_arguments(self, parser):
        parser.add_argument(
            '--database',
            required=True,
            help='Nominates a database to synchronize, Defaults to all databases.',
        )
        parser.add_argument(
            '--skip-checks', action='store_true',
            help='Skip system checks.',
        )
        parser.add_argument(
            'app_label', nargs='?',
            help='App label of an application to synchronize the state.',
        )
        parser.add_argument(
            'migration_name', nargs='?',
            help='Database state will be brought to the state after that '
                 'migration. Use the name "zero" to unapply all migrations.',
        )
        parser.add_argument(
            '--noinput', '--no-input', action='store_false', dest='interactive',
            help='Tells Django to NOT prompt the user for input of any kind.',
        )
        parser.add_argument(
            '--fake', action='store_true',
            help='Mark migrations as run without actually running them.',
        )
        parser.add_argument(
            '--fake-initial', action='store_true',
            help='Detect if tables already exist and fake-apply initial migrations if so. Make sure '
                 'that the current database schema matches your initial migration before using this '
                 'flag. Django will only check for an existing table name.',
        )
        parser.add_argument(
            '--plan', action='store_true',
            help='Shows a list of the migration actions that will be performed.',
        )
        parser.add_argument(
            '--run-syncdb', action='store_true',
            help='Creates tables for apps without migrations.',
        )
        parser.add_argument(
            '--check', action='store_true', dest='check_unapplied',
            help='Exits with a non-zero status if unapplied migrations exist.',
        )

    def handle(self, *args, **options):
        database = options['database']
        if database == 'all':
            self.stdout.write(self.style.SUCCESS("Nominates all database to synchronize:"))
            for d in settings.DATABASES:
                self.stdout.write(self.style.SUCCESS(f'Sync {d}:'))
                TenantUtil.set_current_tenant(d)
                options['database'] = d
                super().handle(*args, **options)
                TenantUtil.release_current_tenant()
        else:
            TenantUtil.set_current_tenant(database)
            super(Command, self).handle(*args, **options)
            TenantUtil.release_current_tenant()

  1. 需要用到django admin后台工具时,也要写个中间件做支持

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值