本記事は『実装で学ぶフルスタックWeb開発 エンジニアの視野と知識を広げる「一気通貫」型ハンズオン』(株式会社オープントーン、佐藤大輔、伊東直喜、上野啓二)の「8-2 マスタデータ(DML)の管理」から一部を抜粋したものです。掲載にあたって編集しています。
なぜDMLを管理するか
DBに保存されているデータも運用の過程で日々変更されていきます。データ定義は変わらないが新しいマスタデータが必要になった、他のシステムからデータ移行する必要が生じた、といったときにアプリケーションではなくデータメンテナンスの一環としてDMLを扱うケースもあります。そのため、DDL(データ構造)と同じように変更を管理する必要があります。
DjangoにおけるDMLの管理方法も考えてみましょう。
DjangoにおけるDML管理
Djangoはマイグレーションファイルを使ってDDL操作を管理しています。しかし、Djangoではフレームワークの標準の機能として、DML操作についてはマイグレーションで提供されていません。機能としてはDDLの操作に特化しています。そこで今回は、Djangoのマイグレーションファイルの仕組みを利用してDML管理も可能になるようカスタマイズしてみます。
マイグレーションファイルの分析
カスタマイズ方針を考えるために、生成されるマイグレーションファイルの中身を少し分析してみましょう。コード8-2-1はマイグレーションファイルの一部を抜粋したものです。
# Generated by Django 4.1.3 on 2022-11-13 14:58 from django.db import migrations, models class Migration(migrations.Migration): # (1) Migrationクラスを継承 dependencies = [ ("inventory", "0001_initial"), ] # (2) 処理の依存関係の注入 operations = [ # (3) DB操作に関する指定 migrations.CreateModel( name="Category", … ), migrations.AddField( model_name="product", … ),
まず(1)からdjango.db.migrations.Migrationクラスを継承していることがわかります。マイグレーションファイルになる条件だと予想できます。Djangoの実装は公開されているので、コードの詳細を確認してみましょう(コード8-2-2)。英語の箇所は日本語に置き換えています。
import re from django.db.migrations.utils import get_migration_name_timestamp from django.db.transaction import atomic from .exceptions import IrreversibleError class Migration: """ すべての移行の基本クラス。 移行ファイルはこれを django.db.migrations.Migration からインポートします Migration というクラスとしてサブクラス化します。1つ以上 次の属性の: - 操作: おそらくからの操作インスタンスのリスト django.db.migrations.operations - 依存関係: (app_path, migration_name) のタプルのリスト - run_before: (app_path, migration_name) のタプルのリスト - 置換: migration_names のリスト すべての移行は移行から始まり、ローダーまたは アプリのラベルと名前で初期化されたインスタンスとしてグラフ化します。 """ # この移行中に適用する操作 operations = [] # この移行の前に実行する必要があるその他の移行。 # (app, migration_name) のリストである必要があります。 dependencies = [] (中略)
コード8-2-1に戻ると、(2)でどのDDLの次に実行するか指定し、(3)でDMLの操作を記載できればカスタマイズしたマイグレーションファイルとして組み込めそうです。
(2)の部分は次のように、直前に実行されるマイグレーションファイルを指定しています。
class Migration(migrations.Migration): dependencies = [ (<アプリケーション名>, <直前に実行されるmigrationファイル名>), ]
では(3)はどうでしょうか。Operations で指定できる動作は決まっていそうなのでMigration Operationsを確認してみましょう。以下の2つがテーブルのレコード操作に使用できそうです。
- RunSQL
- RunPython
RunSQL
今回は次のような構成でOperations からレコード操作する関数を呼び出し、DMLとして使用してみます。
RunSQLでは実行したいSQLを直接記述できます。次のように、0003_dml_insert_catagory_data.pyを作成してください(コード8-2-3)。
from django.db import migrations class Migration(migrations.Migration): dependencies = [ ("inventory", "0002_category_product_category"), ] operations = [ migrations.RunSQL( "INSERT INTO category( name, parent_category_id ) VALUES( 'メンズ', null ) ;", ) ]
categoryテーブルにデータを追加するSQLが記載されています。さっそく、実行してみましょう。
$ python manage.py migrate inventory 0003_dml_insert_catagory_data --settings config.settings.development … Operations to perform: Target specific migration: 0003_dml_insert_catagory_data, from inventory Running migrations: (0.004) SHOW FULL TABLES; args=None; alias=default Applying inventory.0003_dml_insert_catagory_data...INSERT INTO category( name, parent_category_id ) VALUES( 'メンズ', null ) ;; (params None) (0.014) INSERT INTO category( name, parent_category_id ) VALUES( 'メンズ', null ) ;; args=None; alias=default …
想定された通り、INSERT文が実行されました。Categoryテーブルの中に意図したデータが登録されたかどうかも確認しましょう(図8-2-2)。
このようにマイグレーションファイルを自分で新たに作成し、RunSQLを使うことで、任意のSQLを実行できることがわかりました。
RunPython
しかし、1点問題があります。せっかくモデルに依存したテーブル管理が実現したのに、このようにSQLを直接書いてしまうと、そのモデルファーストな管理の利点を生かすことができません。また、SQLで書くことは、DBの種類によっては実行SQLの依存にもつながります。もう少しモデルに依存した書き方はできないでしょうか。
そこでもう1つの、RunPythonを使用します。こちらは先ほどのSQLを実行するためのRunSQLとは異なり、Pythonを実行することができます。これを使えばモデルを操作することができそうです。次のファイルを追加してみましょう(コード8-2-4)。
from django.db import migrations def insert_category(apps, schema_editor): Category = apps.get_model('inventory', 'Category') Category.objects.create(name='レディース', parent_category=None) class Migration(migrations.Migration): dependencies = [ ("inventory", "0003_dml_insert_catagory_data"), ] operations = [ migrations.RunPython(insert_category), ]
RunPythonから呼び出されている関数insert_categoryのappsとschema_editorという2つの引数の値は、どこから渡されているのでしょうか。実はこの2つの引数は自動的に入れられていて、1つ目のappsにはmigrationに関連するモデルの情報、2つ目のschema_editorにはデータベース の変更や実行を管理するインスタンスが渡されます。
そのため、1つ目のインスタンスに対して操作を加えたいモデルのインスタンスを、アプリケーション名とモデル名を指定して取得し、その後、モデルにデータを追加しています。さっそく、実行してみましょう。
$ python manage.py migrate inventory 0004_dml_insert_catagory_data_by_model--settings config.settings.development … Operations to perform: Target specific migration: 0004_dml_insert_catagory_data_by_model, from inventory Running migrations: (0.004) SHOW FULL TABLES; args=None; alias=default Applying inventory.0004_dml_insert_catagory_data_by_model...(0.006) INSERT INTO `category` (`name`, `parent_category_id`) VALUES ('レディース', NULL); args=['レディース', None]; alias=default …
想定した通り、INSERT文が実行されました。Categoryテーブルの中に意図したデータが登録されたかも確認しましょう(図8-2-3)。
また、RunSQLもRunPythonもデフォルトではマイグレーション履歴を元に戻すことはできません。もし戻せるようにしたい場合は、引数reverse_sql、もしくはreverse_codeに元に戻す用のコードを実装する必要があります。わかりやすいSQLの対応を載せておきます(コード8-2-5)。こちらは実装をする必要はなく、説明を見るだけで大丈夫です。
migrations.RunSQL( "INSERT INTO category( name, parent_category_id ) VALUES( 'メンズ', null ) ;", "DELETE FROM category WHERE name = 'メンズ';", )
コードだけだとわかりにくいので、図と対応させて整理してみましょう。まず次の3つのマイグレーションファイルがそれぞれmigrateコマンドによって実行されたと思ってください。
from django.db import migrations def forwards_func(apps, schema_editor): print("0002_dmlに進む") def reverse_func(apps, schema_editor): print("0001_dmlに戻る") class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.RunPython(forwards_func, reverse_func), ]
from django.db import migrations def forwards_func(apps, schema_editor): print("0003_dmlに進む") def reverse_func(apps, schema_editor): print("0002_dmlに戻る") class Migration(migrations.Migration): dependencies = [ ("examlple", "0001_dml"), ] operations = [ migrations.RunPython(forwards_func, reverse_func), ]
class Migration(migrations.Migration): dependencies = [ ("examlple", "0002_dml"), ] operations = [] # 説明のために用意した実行しないファイルなので処理を記載していません
内部的には若い番号のファイルから実行されます。順番に見ていきましょう。
まずmigrationファイル(0001_dml)のRunPythonの第一引数に指定されている関数forwards_funcが実行され、「0002_dmlに進む」という出力が得られます。マイグレーションなのにテーブルやデータ操作を行っていないのでは、と気になるかもしれませんが、RunPython自体は任意のPythonの処理を実行するだけなので、今回のように出力するだけでも大丈夫です。
次にマイグレーションファイル(0002_dml)のRunPython の第一引数に指定されている関数forwards_funcが実行され、「0003_dmlに進む」という出力が得られます。
さて、今度はこれまでのマイグレーション操作を戻してみましょう。
直前のmigrationファイル(0002_dml)のRunPythonの第二引数に指定されている関数reverse_funcが実行され、「0002_dmlに戻る」という出力が得られます。次に0001_dmlのreverse_funcが実行されると、「0001_dmlに戻る」という出力が得られ、処理が終了します。
このようにマイグレーションはSQLのロールバックのように、単純にある地点までデータベースの状態を戻しているのではなく、期待した状態になるように戻るための操作をしてあげなければいけません。DDLのマイグレーションファイルの場合は、その操作をフレームワークが解決してくれているため意識をする必要がなかったのです。
環境(開発/ステージング/本番)ごとのマスタデータの管理
DDLの場合は、開発/ステージング/本番環境と全ての環境についてDDLを適用していました。ではDMLの場合はどうでしょうか。下記の3種類のデータについて分けて考えてみましょう。
- マスタデータ
- トランザクションデータ
- テストデータ
マスタデータ
データベースやそのアプリケーションにおいて基本的な参照データになるものを指します。本章でいえば、カテゴリがこれにあたります。よくある例としては、国や都道県といった情報を持つ地域マスターや、製造業や宿泊業といった業種マスターなどがあります。基本的なデータになるため、全ての環境に適用させます。
トランザクションデータ
マスタデータと対象的に、随時利用者によってメンテナンスされていくデータをトランザクションデータと呼びます。例えば、社員データや商品のデータなどアプリケーションの機能によって頻繁に変更されるデータなどです。利用者の操作によってデータが登録されるため、環境によって異なるデータになります。
そうした場合に、画面からの変更ができないが値の変更をしたい、メンテナンスをしたいといったレコードが発生してきます。特定の環境にのみ適用させるDMLが必要でしょう。
テストデータ
2つ目のトランザクションデータの派生したデータになります。テスト環境などのテストデータを大量に登録する場合などがあります。また開発環境では、アプリケーションの基本的なマスタデータ以外にも、新たにプロジェクトに参加した開発者がすぐアプリケーションを動かすことができるようにサンプルデータを用意する場合もあります。この場合は、開発環境にのみ適用させるDMLが必要でしょう。
適用するシーンに違いはあるものの、環境別にDMLを適用する仕組みが必要です。環境ごとへの適用方法とマスタデータの用意方法の2つに分けて作成してみましょう。
環境ごとへの適用方法
マイグレーションコマンドの引数で、実行対象の環境を分けられます。しかし、この方法だと図8-2-7のようにマイグレーションフォルダ配下にある全てのマイグレーションファイルが実行されてしまうため、適用対象を選択できても個別のマイグレーションファイル単位で適用の要否を設定することができません。
例えば、上記のようにマイグレーションファイルは全ての環境に適用されます。そのため、図8-2-8のように特定のDMLをステージング環境にのみ反映させることができません。
この問題を解決するために、発想を少し変えて、マイグレーションファイルが適用されても特定の環境でしか処理が実行されないような実装をしてみましょう(コード8-2-9)。
from django.db import migrations def forwards_func(apps, schema_editor): print("0003_dmlに進む") def reverse_func(apps, schema_editor): print("0002_dmlに戻る") class Migration(migrations.Migration): dependencies = [ ("examlple", "0001_dml"), ] operations = [ migrations.RunPython(forwards_func, reverse_func), ]
from django.conf import settings from django.db import migrations def insert_category(apps, schema_editor): # 環境に依存する名称の設定ファイルを作成しているため、そこから環境を特定する setting_file = settings.SETTINGS_MODULE env_name = setting_file.split('.')[-1] # 環境ごとに処理を分ける if env_name == 'development': # 開発環境での処理 Category = apps.get_model('inventory', 'Category') Category.objects.create(name='開発環境用のカテゴリ', parent_category=None) else: # ステージング環境や本番環境での処理 pass class Migration(migrations.Migration): dependencies = [ ("inventory", "0004_dml_insert_catagory_data_by_model"), ] operations = [ migrations.RunPython(insert_category), ]
さっそくコードを見てみましょう。前項で説明した通り、このアプリケーションではmanage.pyに引数として渡す設定ファイルで、環境を切り分けています。そのため、プログラムも設定ファイル名を利用して環境別の実装を実現します。具体的には次のコードです。
setting_file = settings.SETTINGS_MODULE env_name = setting_file.split('.')[-1]
ここで、設定ファイル名を加工して、変数env_nameに環境名を代入しています。またDjangoの環境変数が格納されるsettingsのSETTING_MODULEにはファイル名が入ってきます。その環境名を元にした条件分岐で処理を切り分けています。
if env_name == 'development':
また、ここで出てきたpassとは、Pythonの何もしないという意味のコードです。構文法的には文が必要なものの、コードとしては何も実行したくない場合に使用します。この例では、開発環境以外であるelse側では何も処理をしない、というのを強調する意味で使用しています。
もちろん早期リターンといった書き方もあるので1つの実装例として紹介します。
# 開発環境以外は早期returnし何もしない if env_name != 'development': return Category = apps.get_model('inventory', 'Category') Category.objects.create(name='開発環境用のカテゴリ', parent_category=None)
さて、開発環境とステージング環境のそれぞれに実行してみましょう。
$ python manage.py migrate inventory --settings config.settings.development … Operations to perform: Apply all migrations: inventory Running migrations: (0.003) SHOW FULL TABLES; args=None; alias=default Applying inventory.0005_dml_insert_catagory_data_by_environment...(0.004) INSERT INTO `category` (`name`, `parent_category_id`) VALUES ('開発環境用のカテゴリ', NULL); args=['開発環境用のカテゴリ', None]; alias=default …
$ python manage.py migrate inventory --settings config.settings.staging … Operations to perform: Apply all migrations: inventory Running migrations: (0.002) SHOW FULL TABLES; args=None; alias=default Applying inventory.0005_dml_insert_catagory_data_by_environment...(0.003)
開発環境とステージング環境のCategoryテーブルをそれぞれ確認してみてください。開発環境のCategoryテーブルのみにデータが追加されていることが確認できます。
マスタデータの用意方法
環境ごとに適用するマイグレーションファイルを分けることができました。次はマスタデータの用意方法について考えてみましょう。同じような方法で用意しようとするとコード8-2-10のようになります。
画面から登録されていく商品と異なり、カテゴリは登録機能を想定していないため、最初からデータを用意しておかないといけません。次に示すのはサンプルです。内容を見るだけでよいので実装をする必要はありません。
from django.db import migrations def insert_category(apps, schema_editor): Category = apps.get_model('inventory', 'Category') categories = [ # (1) {'name': 'メンズ', 'parent_category': None}, {'name': 'レディース', 'parent_category': None}, {'name': 'キッズ', 'parent_category': None}, ] for category in categories: Category.objects.create(**category) class Migration(migrations.Migration): dependencies = [ ("inventory", "0003_dml_insert_catagory_data"), ] operations = [ migrations.RunPython(insert_category), ]
大きく変更したのは、登録データを(1)のcategoriesという変数で定義し、createを用いた登録処理と分離したことです。2つのアスタリスクがついた見慣れない引数**categoryは可変長引数といいます。前の項までは、createメソッドの引数を一つ一つ指定していましたが、本例では可変長引数を用いてfor文で展開した登録データをそのまま渡しています。
前の項までは、データ数が少なくあまり気になりませんでしたが、例えばこのcategoryの件数が100件あったらどうでしょうか。大量のデータと処理が1つのファイルに同居することで見通しが悪くなりますし、データと処理それぞれの再利用性も下がります。
これをフレームワークの機能であるfixtureを利用してファイル単位でデータと処理を分離しましょう。
fixture
図8-2-9はfixtureを使用するときのフォルダ構成のイメージです。
fixture自体はマイグレーションとは独立した機能なので、まずfixture単体でのマスタデータの登録を行い、その後マイグレーションファイルに組み込んでみます。
まずはfixturesフォルダを作成して、そのフォルダ直下に登録データを記載したfixtureファイルを作成しましょう(コード8-2-11)。
- model: inventory.category fields: name: メンズ parent_category: null - model: inventory.category fields: name: レディース parent_category: null - model: inventory.category fields: name: キッズ parent_category: null
3件のデータを登録します。いずれもデータ構造は同じフォーマットなので、1件目の構成を見てみましょう。
まずmodelが指定されています。ここにはModelクラスを継承して作成したモデルを指定します。モデルはアプリケーション配下で定義するためアプリケーション名も含めています。
次はモデルで操作する対象をfieldsで指定しています。プライマリーキーとなるidは自動採番されるため、それ以外のfieldsに登録するデータを記載しています。もしプライマリーキーも指定して登録したい場合は、modelやfieldsと同じレベルでpkキーを指定してください。
- model: <アプリケーション名>.<モデルクラス名> pk: <操作したいプライマリーキー> fields: <変数1>: <操作したいデータ1> <変数2>: <操作したいデータ2> …
また今回はyaml形式で記載していますが、json形式もサポートされています。
データを登録してみましょう。マイグレーションファイルの場合はmigrateコマンドで処理を実行しましたが、fixtureの場合はloaddataコマンドを使用します。
$ python manage.py loaddata api/inventory/fixtures/catagory_initial_data --settings config.settings.development … import yaml ModuleNotFoundError: No module named 'yaml'
yamlファイルを扱うためのモジュールがないとエラーが出ました。次のコマンドを実行してインストールしましょう。
$ pip install pyyaml
それでは改めてデータを読み込んでみましょう。
$ pytpython manage.py loaddata api/inventory/fixtures/catagory_initial_data --settings config.settings.development … (0.006) INSERT INTO `category` (`name`, `parent_category_id`) VALUES ('メンズ', NULL); args=['メンズ', None]; alias=default (0.002) INSERT INTO `category` (`name`, `parent_category_id`) VALUES ('レディース', NULL); args=['レディース', None]; alias=default (0.002) INSERT INTO `category` (`name`, `parent_category_id`) VALUES ('キッズ', NULL); args=['キッズ', None]; alias=default … Installed 3 object(s) from 1 fixture(s)
migrateコマンドでは、引数にアプリケーション名を指定していましたが、fixtureの場合はパスを含めたfixtureファイル名を指定しています。もちろんアプリケーション名を指定して読み込ませることもできます。もし、ファイル名のみで指定した場合は、全てのfixtureフォルダの中を探して同名のfixtureファイルを見つけて実行します。
マイグレーションファイル経由でのfixtureファイルの利用
コマンド単位でマスタデータを登録できることはわかりました。しかし、このままだと運用時に複雑なオペレーションになってしまい、ミスが起こりやすくなりそうです。マイグレーションファイルのように読み込ませることはできないでしょうか。
そこでRunPythonコマンドを使って、上記の処理をマイグレーションファイルに組み込みます。前の項ではモデルの操作にRunPythonを利用しました。実はRunPythonはモデル操作に限らず、いろいろなPython処理を実行できます。ここではRunPythonを経由してloaddata処理を呼び出し、fixtures配下に置いたyaml形式のデータをマイグレーション時に読み込ませて登録します。作成したyamlファイルを元にfixtureを使ったマイグレーションファイルを作成してみましょう。
from common.migrate_util import common_load_fixture from django.conf import settings from django.core.management import call_command from django.db import migrations def load_fixture(apps, schema_editor): common_load_fixture(__file__) # (2) class Migration(migrations.Migration): dependencies = [ ('inventory', '0005_dml_insert_catagory_data_by_environment'), ] operations = [ migrations.RunPython(load_fixture), # (1) ]
import os from django.conf import settings from django.core.management import call_command def common_load_fixture(migration_filename): setting_file = settings.SETTINGS_MODULE # (1) target = os.path.splitext(migration_filename)[0].replace('migrations', 'fixtures') # (2) base_yaml_name = target + '/base.yaml' call_command('loaddata', '--settings', setting_file, '--format=yaml', base_yaml_name) # (3)
最後にfixturesフォルダ配下に、新たに「0006_dml_insert_catagory_data_by_」フォルダを作成し、その中にbase.yamlファイルを作成します。base.yaml の内容はコード8-2-11で使用したcatagory_initial_data.yamlと同じです。
機能単位でファイルを分割しています。ファイルごとに次に示す役割を持っています。
-
0006_dml_insert_catagory_data_by_fixture.py
- - migrate コマンド実行時に呼び出されるマイグレーションファイル
- - ここからfixtureを実行するための関数を呼ぶ
-
migrate_util.py
- - 本項でのポイントとなるloaddataを実行するファイル
- - fixtureファイルの保存場所を解決する - Pythonプログラムからloaddataコマンドを呼び出す - fixtureファイルパスを動的に生成する
-
0006_dml_insert_catagory_data_by_fixture/base.yaml
- - fixtureファイル本体
- - マイグレーションファイルからの読込対象を特定できるファイル名にしている
まずは、0006_dml_insert_catagory_data_by_fixture.pyから見ていきましょう。(1)で今まで通り、処理を行う関数load_fixtureを呼んでいます。(2)では引数は特に利用せず、common_load_fixtureという共通の関数に__file__という変数を渡しています。__file__にはその関数が実行されているファイルの絶対パスを取得します。こういった2つのアンダーバーで囲まれた変数やメソッドを特殊メソッドといいます。以前出てきた__init__もこれに該当します。
では、この絶対パスが渡されたmigrate_util.pyファイルでは何をしているか見ていきましょう。(1)ではこの処理で使用する設定ファイル名を取得しています。(2)ではこの処理で使用するfixtureファイルのパスを取得しています。マイグレーションファイル名と対応するようなfixtureファイルを用意しているため、直前のフォルダ名と拡張子のみ置き換えています。最後に(3)です。Djangoの管理コマンドをコードから実行するcall_command関数を用いてloaddataコマンドを実行しています。
それでは、さっそくマイグレーションを実行してみましょう。
$ python manage.py migrate inventory --settings config.settings.develop ment … Operations to perform: Apply all migrations: inventory Running migrations: (0.004) SHOW FULL TABLES; args=None; alias=default Applying inventory.0006_dml_insert_catagory_data_by_fixture...(0.002) SAVEPOINT `s139721006020416_x1`; args=None; alias=default … Installed 3 object(s) from 1 fixture(s) …
catagoryテーブルのデータを確認してください。図8-2-10のように、データを登録することはできたでしょうか。これでデータと処理を分割し、お互いに再利用性の高いコードにすることができました。