内藤 裕二/ 2024年 10月 29日/ 技術

こんにちは!内藤です!
札幌ではユキムシも舞いはじめ、秋を通り越して冬の足音が聞こえています。

先日、DjangoでSaaS的なシステムを構築する機会がありました。
その際にサブドメインに応じてアクセス先データベースを切り替える処理を実装しましたので、まとめます。

やりたいこと

契約ユーザごとにサブドメインを発行してアクセスしてもらう、SaaS的なシステムを想像してください。
以下、「データベースを切り替える」と記載していますが、実際にはPostgresのスキーマを切り替える実装でした。

  1. 契約ユーザ(≒サブドメイン)ごとに参照先のデータベースを切り替えたい
  2. システム一意なマスタについては、重複を避けるために特定のデータベースに接続したい
  3. システムメンテナンスに管理コマンドを使用しているので、対応が必要
  4. 管理コマンドでfactory_boyを使用しているので、対応が必要
  5. ORMだけでなく、直接SQLを実行する箇所もあるので対応が必要

TL;DR

  • サンプルコードを下記にコミットしています
  • django-dynamic-db-router を使用して、データベース接続を切り替える
    • DynamicDbRouter 継承クラスを作成して、特定のDjangoアプリケーションに所属するモデルだけ別のデータベース接続を使用する(やりたいことの2.対応)
  • MiddleWareでサブドメインを判別して、先述のRouterを使用してデータベース接続先を切り替える(やりたいことの1.対応)
  • ORMのMigrationは接続先データベースの数だけ繰り返す
    • システム一意なマスタについては、マスタ用のデータベースにだけmigrateする必要がある(やりたいことの2.対応)
    • その他アプリケーション(Djangoの管理テーブル含む)についてはすべてのデータベースに適用する
  • 管理コマンドでは対象のデータベースをオプションで指定し、Routerを使用して接続先を切り替える(やりたいことの3.対応)
  • factory_boy は ModelFactory単位で固定的なデータベース指定しかできない
    • ModelFactory継承クラスを作成して、Routerを使用してmanagerのusingを呼び出すようにする(やりたいことの4.対応)
  • 直接SQLを実行する場合、使用するデータベース接続をRouter経由で取得するようにする(やりたいことの5.対応)

いくつかの前提条件

djangoのDB参照先の決定方法

公式のドキュメントに詳しく書いてありますが、DBアクセスを行う際に、下記のような流れで接続先を探します。

  1. settingsのDATABASESに複数の接続設定を記載しておく
  2. settingsに記載しているDATABASE_ROUTERSに記載の順にDBルータを作成する
  3. アクセス種類に応じて、DBルータのメソッド(db_for_read/db_for_write等)へ問い合わせる
  4. DBルータが返した文字列でsettingsのDATABASEから接続情報を取得し、接続先を決定する

3.にてDBルータのメソッドが None を返した場合、settingsのDATABASE_ROUTERSに書かれている次のDBルータクラスに問い合わせが行われます。

django.db.utils.ConnectionRouter

settingsに従ってDB参照先を決定する動作を実現するために、django.db.utilsパッケージのConnectionRouterクラスが提供されています。
ConnectionRouterはdjango.dbパッケージの初期化コードでインスタンス生成されていて、下記のようにインポートすることで使用できます。

from django.db import router

ConnectionRouterクラスは自身でDBルータと同名のメソッド(db_for_read/db_for_write等)を持っており、メソッド内でsettingsに従ってDBルータに問い合わせた結果を返してくれます。
従って、複数データベース接続対応が必要な場合、このConnectionRouterに対して問合せを行うことで、settingsに従ってアクセス先DBを決定する動きが実現できます。

django-dynamic-db-router

先述のDBルーティングを動的に変更できるようなコードが、Django Dynamic DB Routerとしてpypiに存在しています。
最終コミットがだいぶ古いので採用にちょっと勇気が必要でしたが、コードはすごくシンプルなので、現時点でも有用です。
自前で似たようなコードを書いても良いですが、インストールしてしまった方が手間がないと思います。

インストールおよび設定

インストールはpipでできます。

pip install django-dynamic-db-router

インストール後、settingsのDATABASE_ROUTERSにDBルータを記載します。

DATABASE_ROUTERS = ['dynamic_db_router.DynamicDbRouter']

使用方法

公式ドキュメント参照してほしいですが、PythonのContext Managerとして使用できます。
公式のサンプルコードでは、

from dynamic_db_router import in_database

from my_app.models import MyModel
from some_app.utils import complex_query_function

with in_database('external'):
    input = MyModel.objects.filter(field_a="okay")
    output = complex_query_function(input)

のように記載すると、in_databaseで囲まれている中の処理だけ、settingsのDATABASESにexternalとして定義したDB接続が使用されるようになります。

詳しくは解説しませんが、settingsのDATABASESの定義名でなく、in_databaseにいきなりDB接続情報のdictを渡すこともできるようです。

動作原理

ソースコードを見ると、Context Managerであるin_databaseクラスと、DBルータであるDynamicDbRouterが連携して動作しています。

in_databaseクラスはコンストラクタでDB接続設定名、またはDB接続設定そのもののdictを受け取り、インスタンスに保持します。
Contextに入るとき(__enter__メソッドが呼び出されたとき)に、コンストラクタで受け取ったDB接続情報をTHREAD_LOCALに保存します。

DynamicDbRouterクラスはdjangoのDBルータそのもので、参照先を返すメソッドで、THREAD_LOCALの内容を参照して応答します。

やりたいことの実現方法

契約ユーザ(≒サブドメイン)ごとに参照先のデータベースを切り替えたい

djangoのMiddleWareを使用します。
クライアントからリクエストが来た際に、クライアントがアクセスしたホスト名を判別し、dynamic_db_routerの仕組みを使用して、アクセス先DBを切り替えます。
サンプルコードでは、settingsにDYNAMIC_DB_ROUTESとしてホスト名とDB接続設定名のdictを定義しておき、それを参照しています。
また、このMiddleWareは認証その他のMiddlewareよりも先に動作する必要がありますので、settingsのMIDDLEWAREの先頭に記述します。

システム一意なマスタについては、重複を避けるために特定のデータベースに接続したい

dynamic_db_router側に仕掛けを入れます。
dynamic_db_routerのDynamicDbRouterを継承し、公式ドキュメントのサンプルでやっているような仕組みを組み込みます。
特定のmodelに対するアクセスだけ、デフォルトのDB接続を使用する。
サンプルコードではDBルータクラス内に埋め込んでしまっていますが、settingsに記載できるようにすると汎用性が増すかもしれません。

システムメンテナンスに管理コマンドを使用しているので、対応が必要

djangoの管理コマンドでの対応になります。
djangoの管理コマンドは、クライアントがアクセスしたホスト名が取得できないため、別の方法でアクセスDBを振り分ける必要があります。
サンプルコードでは、--databaseオブションで直接DB接続先設定名を指定するようにしています。
実装としては、今まで通りin_databaseで実際の処理を囲んであげるだけです。

管理コマンドでfactory_boyを使用しているので、対応が必要

factory_boyはテスト用にModelデータを作成するためのパッケージです。
簡単にModel
本家factory_boyで複数データベース対応のIssueがあり、複数データベースに対応はしているようです。
DjangoModelFactory`は、`classmethod``_get_managerというメソッドでModelManagerを取得しているようで、ModelManagerを返却する前にusingメソッドでDB参照先を設定すれば良いようです。
ただし、factory_boyで採用された複数データベース対応はMataクラスに固定的にDB接続名を指定する必要があり、動的に変更はできません。

    @classmethod
    def _get_manager(cls, model_class):
        if model_class is None:
            raise errors.AssociatedClassError(
                f"No model set on {cls.__module__}.{cls.__name__}.Meta")

        try:
            manager = model_class.objects
        except AttributeError:
            # When inheriting from an abstract model with a custom
            # manager, the class has no 'objects' field.
            manager = model_class._default_manager

        if cls._meta.database != DEFAULT_DB_ALIAS:
            manager = manager.using(cls._meta.database)
        return manager

サンプルコードでは、factory_boyのDjangoModelFactory`を継承した基底クラスを作成し、`_get_manager_メソッドをオーバーライドしています。
_get_managerの中で、設定する参照先をMetaクラスからではなく、ConnectionRouter経由で取得するようにしてあげれば、settingsに従って参照先のDBが取得できます。

import factory
from django.db.utils import ConnectionRouter

router = ConnectionRouter()

class DjangoModelFactoryForMultiDatabase(factory.django.DjangoModelFactory):

    @classmethod
    def _get_manager(cls, model_class):
        if model_class is None:
            raise factory.errors.AssociatedClassError(f"No model set on {cls.__module__}.{cls.__name__}.Meta")

        try:
            manager = model_class.objects
        except AttributeError:
            # When inheriting from an abstract model with a custom
            # manager, the class has no 'objects' field.
            manager = model_class._default_manager

        db = router.db_for_write(model_class, instance=None)
        manager = manager.using(db)
        return manager

ORMだけでなく、直接SQLを実行する箇所もあるので対応が必要

サンプルコードの用意はないのですが、理屈だけまとめておきます。
直接SQLを実行する時は、下記のようなコードを記載します。

from django.db import connection

cursor = connection.cursor()
cursor.execute(<SQL文>)

上記でインポートしているconnectionがdjangoのデフォルトのDB接続先になるので、これを動的に切り替えることができればよい、という事になります。
django.db`パッケージは、すべてのDB接続先を`connectionsというdictで持っていて、参照することができます。
使用するDB接続先名は、いままでやったようにConnectionRouterから取得すればOK
ですので、上記のコードを複数データベース対応する場合、下記のように直す必要があります。

from django.db import connections, router

db_setting_name = router.db_for_write(model, instance=None)
connection = connections[db_setting_name]

cursor = connection.cursor()
cursor.execute(<SQL文>)

終わりに

複数データベース対応は初めてでしたが、djangoで対応する機能が用意されていたため、若干の調査で実現できました。
実際の製品環境ではもうちょっと複雑な条件で振り分けたいケースもあるかと思います。
何かのお役に立てれば幸いです!

参照URL