Django 使用心得 (四)多数据库

博客原文地址:elfgzp.cn/2019/01/09/…html

相信有开发者在项目中可能会有须要将不一样的 app 数据库分离,这样就须要使用多个数据库。
网上也有很是多的与 db_router 相关的文章,本篇文章也会简单介绍一下。
除此以外,还会介绍一下笔者在具体项目中使用多数据库的一些心得和一些。但愿能给读者带来必定的帮助,如果读者们也有相关的心得别忘了留言,能够一块儿交流学习。python

使用 Router 来实现多数据库

首先咱们能够从 Django 的官方文档了解到如何使用 routers 来使用多数据库。mysql

官方文档 Using Routersgit

官方文档中定义了一个 AuthRouter 用于存储将 Auth app 相关的表结构。github

class AuthRouter:
    """ A router to control all database operations on models in the auth application. """
    def db_for_read(self, model, **hints):
        """ Attempts to read auth models go to auth_db. """
        if model._meta.app_label == 'auth':
            return 'auth_db'
        return None

    def db_for_write(self, model, **hints):
        """ Attempts to write auth models go to auth_db. """
        if model._meta.app_label == 'auth':
            return 'auth_db'
        return None

    def allow_relation(self, obj1, obj2, **hints):
        """ Allow relations if a model in the auth app is involved. """
        if obj1._meta.app_label == 'auth' or \
           obj2._meta.app_label == 'auth':
           return True
        return None

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        """ Make sure the auth app only appears in the 'auth_db' database. """
        if app_label == 'auth':
            return db == 'auth_db'
        return None
复制代码

可是我在实际使用中遇到一个问题,在运行 python manage.py test 来进行单元测试时,这个数据库内依然会生成其余 app 的表结构。
正常状况下是没什么问题的,可是我使用了 mysqlmongodb 的多数据库结构,形成了一些异常。sql

因而我去查阅 Django 单元测试的源码发现这样一段代码,他是用于判断某个 app 的 migrations(数据库迁移)是否要在某个数据库执行。mongodb

django/db/utils.py view raw
def allow_migrate(self, db, app_label, **hints):
        for router in self.routers:
            try:
                method = router.allow_migrate
            except AttributeError:
                # If the router doesn't have a method, skip to the next one.
                continue

            allow = method(db, app_label, **hints)

            if allow is not None:
                return allow
        return True
复制代码

他这个函数至关因而在执行 Router 中的 allow_migrate,并取其结果来判断是否要执行数据库迁移。
也就是官方给的例子:数据库

def allow_migrate(self, db, app_label, model_name=None, **hints):
    """ Make sure the auth app only appears in the 'auth_db' database. """
    if app_label == 'auth':
        return db == 'auth_db'
    return None
复制代码

可是这里有一个问题,假设 app_label 不等于 auth(至关于你设定的 app 名称),可是 db 却等于 auth_db,此时这个函数会返回 Nonedjango

回到 utils.py 的函数中来,能够看到 allow 就获得了这个 None 的返回值,可是他判断了 is not None假命题,那么循环继续。bash

这样致使了全部对于这个数据库 auth_db 而且 app_label 不为 auth 的结果均返回 None。最后循环结束,返回结果为 True,这意味着, 全部其余 app_label 的数据库迁移均会在这个数据库中执行。

为了解决这个问题,咱们须要对官方给出的示例做出修改:

def allow_migrate(self, db, app_label, model_name=None, **hints):
    """ Make sure the auth app only appears in the 'auth_db' database. """
    if app_label == 'auth':
        return db == 'auth_db'
    elif db == 'auth_db':  # 若数据库名称为 auth_db 但 app_label 不为 auth 直接返回 False
        return False
    else:
        return None
复制代码

执行 migrate 时指定 –database

咱们定义好 Router 后,在执行 python manage.py migrate 时能够发现,数据库迁移动做并无执行到除默认数据库之外的数据库中, 这是由于 migrate 这个 command 必需要指定额外的参数。

官方文档 Synchronizing your databases

阅读官方文档能够知道,若要将数据库迁移执行到非默认数据库中时,必须要指定数据库 --database

$ ./manage.py migrate --database=users
$ ./manage.py migrate --database=customers
复制代码

可是这样的话会致使咱们使用 CI/CD 部署服务很是的不方便,因此咱们能够经过自定义 command来实现 migrate 指定数据库。

其实实现方式很是简单,就是基于 django 默认的 migrate 进行改造,在最外层加一个循环,而后在自定义成一个新的命令 multidbmigrate

multidatabases/management/commands/multidbmigrate.py view raw
...
    def handle(self, *args, **options):
        self.verbosity = options['verbosity']
        self.interactive = options['interactive']

        # Import the 'management' module within each installed app, to register
        # dispatcher events.
        for app_config in apps.get_app_configs():
            if module_has_submodule(app_config.module, "management"):
                import_module('.management', app_config.name)

        db_routers = [import_string(router)() for router in conf.settings.DATABASE_ROUTERS] # 对全部的 routers 进行 migrate 操做
        for connection in connections.all():
            # Hook for backends needing any database preparation
            connection.prepare_database()
            # Work out which apps have migrations and which do not
            executor = MigrationExecutor(connection, self.migration_progress_callback)

            # Raise an error if any migrations are applied before their dependencies.
            executor.loader.check_consistent_history(connection)

            # Before anything else, see if there's conflicting apps and drop out
            # hard if there are any
            conflicts = executor.loader.detect_conflicts()
...
复制代码

因为代码过长,这里就不所有 copy 出来,只放出其中最关键部分,完整部分能够参阅 elfgzp/django_experience 仓库。

在支持事务数据库与不支持事务数据库混用在单元测试遇到的问题

在笔者使用 Mysql 和 Mongodb 时,遇到了个问题。

总所周知,Mysql 是支持事务的数据库,而 Mongodb 是不支持的。在项目中笔者同时使用了这两个数据库,而且运行了单元测试。

发如今运行完某一个单元测试后,我在 Mysql 数据库所生成的初始化数据(即笔者在 migrate 中使用 RunPython 生成了一些 demo 数据)所有被清除了,致使其余单元测试测试失败。

经过 TestCase 类的特性能够知道,单元测试在运行完后会去执行 tearDown 来作清除垃圾的操做。因而顺着这个函数,笔者去阅读了 Django 中对应函数的源码,发现有一段这样的逻辑。

...
def connections_support_transactions():  # 判断是否全部数据库支持事务
    """Return True if all connections support transactions."""
    return all(conn.features.supports_transactions for conn in connections.all())
...

class TransactionTestCase(SimpleTestCase):
    ...
    multi_db = False
    ...
    @classmethod
        def _databases_names(cls, include_mirrors=True):
            # If the test case has a multi_db=True flag, act on all databases,
            # including mirrors or not. Otherwise, just on the default DB.
            if cls.multi_db:
                return [
                    alias for alias in connections
                    if include_mirrors or not connections[alias].settings_dict['TEST']['MIRROR']
                ]
            else:
                return [DEFAULT_DB_ALIAS]
    ...
    def _fixture_teardown(self):
        # Allow TRUNCATE ... CASCADE and don't emit the post_migrate signal
        # when flushing only a subset of the apps
        for db_name in self._databases_names(include_mirrors=False):
            # Flush the database
            inhibit_post_migrate = (
                self.available_apps is not None or
                (   # Inhibit the post_migrate signal when using serialized
                    # rollback to avoid trying to recreate the serialized data.
                    self.serialized_rollback and
                    hasattr(connections[db_name], '_test_serialized_contents')
                )
            )
            call_command('flush', verbosity=0, interactive=False,  # 清空数据库表
                         database=db_name, reset_sequences=False,
                         allow_cascade=self.available_apps is not None,
                         inhibit_post_migrate=inhibit_post_migrate)
    ...

class TestCase(TransactionTestCase):
    ...
        def _fixture_teardown(self):
            if not connections_support_transactions():  # 判断是否全部数据库支持事务
                return super()._fixture_teardown()
            try:
                for db_name in reversed(self._databases_names()):
                    if self._should_check_constraints(connections[db_name]):
                        connections[db_name].check_constraints()
            finally:
                self._rollback_atomics(self.atomics)
    ...
复制代码

看到这段代码后笔者都快气死了,这个单元测试明明只是只对单个数据库起做用,multi_db 这个属性默认也是为 False,这个单元测试做用在 Mysql 跟 Mongodb 有什么关系呢!?正确的逻辑应应该是判断 _databases_names 即这个单元测试所涉及的数据库支不支持事务才对。

因而须要对 TestCase 进行了改造,而且将单元测试继承的 TestCase 修改成新的 TestCase。修改结果以下:

multidatabases/testcases.py view raw
class TestCase(TransactionTestCase):
    """ 此类修复 Django TestCase 中因为使用了多数据库,可是 multi_db 并未指定多数据库,单元测试依然只是在一个数据库上运行。 可是源码中的 connections_support_transactions 将全部数据库都包含进来了,致使在同时使用 MangoDB 和 MySQL 数据库时, MySQL 数据库没法回滚,清空了全部的初始化数据,致使单元测试没法使用初始化的数据。 """

    @classmethod
    def _databases_support_transactions(cls):
        return all(
            conn.features.supports_transactions
            for conn in connections.all()
            if conn.alias in cls._databases_names()
        )
    ...
    
    def _fixture_setup(self):
        if not self._databases_support_transactions():
            # If the backend does not support transactions, we should reload
            # class data before each test
            self.setUpTestData()
            return super()._fixture_setup()

        assert not self.reset_sequences, 'reset_sequences cannot be used on TestCase instances'
        self.atomics = self._enter_atomics()
    ... 
复制代码

除了 _fixture_setup 之外还有其余成员函数须要将判断函数改成 _databases_support_transactions,完整代码参考 elfgzp/django_experience 仓库

总结

踩过这些坑,笔者更加坚信不能太相信官方文档和源码,要本身去学习研究源码的实现,才能找到解决问题的办法。

相关文章
相关标签/搜索