18
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

django データベースの接続先をログインユーザ別に切り替える

Last updated at Posted at 2018-06-01

表題の件について日本語も英語も情報がほとんどみあたらなかったので、投稿します。

自己紹介

Qiita初投稿なので、まずは簡単に自己紹介させてください。

とある企業でSE兼プログラマ的なことを10年ほどやっていました。
当時の環境は、Solaris/Linux & C/C++、Windows VC++/C#などです。
いろいろあって退職し、今はフリーでアプリを開発しています。
Web型アプリはあまり経験がありませんが、最近自分の技術が時代遅れなことに危機感を抱いて、
Python+djangoを勉強中です。投稿時点でdjango歴1ヶ月。

結論

急いでいる人の為に、結論を先に書きます。

if テナント数 > 100:
    print("別の方法を検討しましょう")
elif "ModelsにForeignKey/ManyToManyField/OneToOneFieldが1つも無い":
    print("出来る")
elif "rawのsqlを書く覚悟がある":
   print("大変だけど出来る")
else: 
   print("別の方法を検討しましょう")

##やりたいこと
そもそも、なぜ表題のことをしたいかというと、マルチテナント型のアプリを作りたいからです。
マルチテナントの実装については長くなるので、別の記事で投稿したいと思います。

イメージしているのは、次のような動作です。

  • 全てのテナントはひとつのアドレスexample.comにアクセス(テナント毎にサブドメインを分けない)
  • ログイン後、ユーザ別に接続先DBを切り替える

つまり、DBが

  • dafault.db.sqlite3 <-- djangoシステム用DB(テナント情報)
  • user1.db.sqlite3 <-- テナント1用DB
  • user2.db.sqlite3 <-- テナント2用DB

のような構成になります。
sqliteを使うのは、

  • レコード数がたいして多くない
  • ユーザが解約した場合、ファイルを1つ削除すればよいので管理が簡単

だからです。
また、サブドメインを別けたくない理由は、

  • SSL証明書が高い(Let's Encryptなら只でできる?)
  • 設定が面倒そう(じつはよく分かっていない)
  • FacebookもTwitterもユーザ別にサブドメインが別れていない

という怠惰な理由からです。
あと、

  • ソースコードは絶対に別けたくない

という条件があります。

##やったこと

お約束

$ python3 -m venv venv
$ source venv/bin/activate
(venv)$ pip install django
(venv)$ pip freeze
Django==2.0.5
pytz==2018.4
(venv)$ django-admin startproject mysite .
(venv)$ python manage.py startapp myapp

###フォルダ構成
ss_dir.png

###setting.py

setting.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.default.sqlite3'),
    },
    'user': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.user.sqlite3'),
    },
    'sato': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sato.sqlite3'),
    },
    'yamada': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.yamada.sqlite3'),
    },
}

まず、ここで全テナントのデータベース参照先を定義します。
defaultはdjangoのシステム用(テナント情報を含む)
userはテナントのひな形です。
sato, yamada はテナントのユーザ名を想定しています。

つぎにrouter.pyを記述します。

###router.py

router.py
class MyAppRouter:
    def db_for_read(self, model, **hints):
        if model._meta.app_label == 'myapp':
            return 'user'
        return None

    def db_for_write(self, model, **hints):
        if model._meta.app_label == 'myapp':
            return 'user'
        return None

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

    def allow_migrate(self, db, app_label, model=None, **hints):
        if app_label == 'auth' or \
           app_label == 'contenttypes' or \
           app_label == 'sessions' or \
           app_label == 'admin':
            return db == 'default'
        if app_label == 'myapp':
            return db == 'user'
        return None

ここでは、defaultとuserしか記述していません。
実はこのrouter.pyはmakemigrationsの為だけに記述したもので、
サーバ起動後は、使いません。

setting.py
LOGIN_REDIRECT_URL = '/'
DATABASE_ROUTERS = ['mysite.router.MyAppRouter']

setting.pyにrouter.pyの場所を定義します。

###models.py

models.py
from django.db import models
class Person(models.Model):
    name     = models.CharField(verbose_name='氏名', max_length=50, default='')
    age      = models.IntegerField(verbose_name='年齢', default=0)
    objects  = models.Manager()
    def __str__(self):
        return "%s(%d)" % (self.name, age)

サンプルなので簡単なモデルです。

###DBの初期化

(venv)$ python manage.py makemigrations myapp
(venv)$ python manage.py migrate
(venv)$ python manage.py migrate --database=user

まず、db.default.sqlite3とdb.user.sqlite3を作成します。
ここで作成したdb.user.sqlite3はテナント個別の情報を格納するdbのひな形で、実際には利用しません。
(常にレコード数は0です)

(fig1) db.default.sqlite3のテーブル構成
ss_db_default.png

(fig2) db.user.sqlite3のテーブル構成
ss_db_user.png

myapp_personテーブルがdb.user.sqlite3だけに作成されていることが確認できます。

このdb.user.sqlite3をテナントにあわせて、手動でコピーします。

(venv)$ cp db.user.sqlite3 db.yamada.sqlite3
(venv)$ cp db.user.sqlite3 db.sato.sqlite3

このコピー作業はテナントが増える度に行う必要があります。

###テナントの登録

(venv)$ python manage.py createsuperuser
Username (leave blank to use 'test'): sato

(venv)$ python manage.py createsuperuser
Username (leave blank to use 'test'): yamada

このテナントのユーザ名はsetting.pyのDATABASESに記述した

setting.py
    'sato': {
        ...
    },
    'yamada': {
        ...
    },

と一致させる必要があります。

さて、いよいよ、DBを動的に切り替える部分です。

###view.py

view.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from .models import *

@login_required
def top(request):
    user = request.user.username
    people = Person.objects.using(user).all()
    return render(request, 'top.html', {'user':user,'people':people})

@login_required
def add_person(request):
    if request.method == 'GET':
        return render(request, 'add.html')
    else:
        user = request.user.username
        name  = request.POST.get('name')
        age   = request.POST.get('age')
        person = Person(name=name, age=age)
        person.save(using=user)
        return redirect('myapp:view_top')

ここで注目すべきは、

user = request.user.username
people = Person.objects.using(user).all()
person.save(using=user)

の部分です。

user = request.user.username
でログイン中のユーザ名を取得し、
using(user)save(using=user)
でユーザ別のdbに接続しています。

以上で、表題に関連した部分の解説は終わりです。
*.html、urls.pyの記述は本題とは関係ないので割愛させていただきす。
(githubのソースを参照ください)

###確認
まずyamadaでログインします。
ss_login_yamada.png

ss_yamada1.png

1行目にログイン中のユーザ名が表示されています。
まだレコードは空です。
Add Personをクリックして、レコードを追加します。

ss_add_mari.png

mari 42が追加されました。
ss_yamada2.png

ログアウトして、こんどはsatoでログインします。
ss_login_sato.png

同様に、hanako 25taro 33を追加します。
ss_sato1_.png

db.yamada.sqlite3のレコードを確認します。
ss_db_yamada.png

db.sato.sqlite3のレコードを確認します。
ss_db_sato.png

ちゃんと、別れて収録されています。
お疲れ様でした。

##この方法は実用出来るのか?
###テナント追加の問題
この方法は、テナントを追加する度に以下の作業を行う必要があります。

  • createsuperuserでテナント登録
  • setting.pyのDATABASESにdbの場所を登録
  • cp db.user.sqlite3 db.****.sqlite3でテナントのdb作成
  • setting.pyを修正しているので、サーバ再起動が必要

setting.pyを修正する必要があるというのは、なんか致命的ですね。
(だから探しても情報が無かったのか!)

Facebookレベルの億単位のユーザを想定したSaasでは不可能でしょう。
(百単位でもやりたくない)

でも私が想定してるのはユーザ数十単位なので、まぁいいかと思います。

###ForeignKey問題 (6/3追記)
https://docs.djangoproject.com/ja/2.0/topics/db/multi-db/#topics-db-multi-db-routing
に以下の記述があります。

Limitations of multiple databases

Django doesn't currently provide any support for foreign key or many-to-many relationships spanning multiple databases. If you have used a router to partition models to different databases, any foreign key and many-to-many relationships defined by those models must be internal to a single database.

これを読む限り、「データベースをまたがるForeignKeyはサポートしていない」と解釈できます。

つまり、
database A -- table a
database B -- table b

があったときに、a -> bのForeignKeyはダメということです。

database A -- table a
database A -- table b
a -> b

ならOKだと。

ところがどっこい、

database A -- table a
database A -- table b
a -> b

の場合でも、ForeignKeyがあると動きませんでした。(Django==2.0.6で確認)

これはかなりイタイです。

なので、結論としては データベースの接続先をログインユーザ別に切り替えるのはオススメできない ということです。

・・・で私がどうしたかって?

  • OneToOneFieldはテーブルを展開
  • ForeignKeyはIntegerFieldに変更
  • select_relatedは使えないので、代わりにrawでsqlを書く

で対応しました。 :sob:

##ソースコード
ここで使用したサンプルのソースは
https://github.com/kanekawa-dev/MultiUserDB
からダウンロードできます。

##開発環境
Macbook Pro + macOS sierra
pytnon 3.6
django 2.0

##使用したツール
Atom editor
DB Browser for SQLite

参考

https://docs.djangoproject.com/ja/2.0/topics/db/multi-db/#topics-db-multi-db-routing
Djangoで複数のデータベースを使う際に、モデルごとに使われるデータベース名を取得する

18
22
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?