delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

DjangoのModelで「お約束カラム(作成者、作成日時、更新者、更新日時)」を共通化する方法 ※デメリットあり

概要

一般的に、RDBのテーブルを定義する際には、管理目的で「どんなテーブルにも以下の4つのカラムは必ず定義すること」という決まりになっているシステムが多いと思う。
※ 括弧内はよくあるカラム名の例

  • 作成者 (created_by)
  • 作成日時 (created_at)
  • 更新者 (updated_by)
  • 更新日時 (updated_at)

少なくとも、私が今まで経験してきたPJでは、どのPJでもほぼ例外なくこの4つのカラムは定義するという決まりになっていた。

システムの運用保守を経験したことがある身としても、これらのカラムがなかったり、定義してあるがNullが入っていたりすると、運用時に往々にして困ったので、その方針自体は正しいと思っている。

今回は、DjangoのModelにこれらのカラムを定義する方法について考えてみたい。
※ 以下、この4つのカラムのことを便宜的に「お約束カラム」と呼ぶことにする。(正式な名称とかあったら知りたい)

以下の2つの方法について、それぞれメリット/デメリットをみていく。

  1. 愚直に全てのModelに「お約束カラム」を定義する。
  2. 「お約束カラム」を親のモデルに共通化する。

結論

通化は可能だが、マイグレーション時のカラムの並び順が、「お約束カラム」が先頭にきてしまうというデメリットがある。

検証

前提

SQLiteは「.schma」コマンドでテーブル構造を表示する際のカラムの順番が決まってなさそうだったため。

1.愚直に全てのModelに「お約束カラム」を定義する方法

特に説明することはないと思うが、以下のように愚直に全てのModelに「お約束カラム」を定義する。

from django.db import models


class Author(models.Model):
    class Meta:
        db_table = "author"

    name = models.CharField(max_length=255)
    # 以下、「お約束カラム」
    created_by = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_by = models.CharField(max_length=255)
    updated_at = models.DateTimeField(auto_now=True)


class Publisher(models.Model):
    class Meta:
        db_table = "publisher"

    name = models.CharField(max_length=255)
    # 以下、「お約束カラム」
    created_by = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_by = models.CharField(max_length=255)
    updated_at = models.DateTimeField(auto_now=True)

※ DateTimeFieldの「auto_now_add」、「auto_now」というオプションについては本記事では説明しないので、知らなかった方は以下の記事を参照
akiyoko.hatenablog.jp

マイグレーション実行結果

マイグレーションを実行すると、以下のように意図した通りのテーブルが作成される。

mysql> desc author;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | NO   |     | NULL    |                |
| created_by | varchar(255) | NO   |     | NULL    |                |
| created_at | datetime(6)  | NO   |     | NULL    |                |
| updated_by | varchar(255) | NO   |     | NULL    |                |
| updated_at | datetime(6)  | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)

mysql> desc publisher;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | NO   |     | NULL    |                |
| created_by | varchar(255) | NO   |     | NULL    |                |
| created_at | datetime(6)  | NO   |     | NULL    |                |
| updated_by | varchar(255) | NO   |     | NULL    |                |
| updated_at | datetime(6)  | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)
メリット
  • カラムの順番が意図した並び順になる。

→ 後述するが、2の方は意図した並び順にならない。

デメリット
  • 全てのModelで繰り返し同じフィールドを定義するので、DRY原則に反する。(具体的なリスクとしては、コピペミスでバグが混入するリスクがある、同じフィールド定義が何回も出てくるので可読性が下がるなど)

→ ただ、たかだかフィールド定義4行の話なので、チーム内で意識が共有されていれば、絶対に避けるべきレベルの重複ではないのかなと思う。

2.「お約束カラム」を親のモデルに共通化する。

以下の方法で「お約束カラム」のフィールドを共通化して切り出すことができる。

  1. abstractな親のモデル(仮にBaseModelとする)を定義してそこに「お約束カラム」のフィールドを定義する。
  2. 全てのModelはBaseModelを継承するようにする。

この場合は以下のような実装となる。

from django.db import models


class BaseModel(models.Model):
    class Meta:
        # マイグレーション時にテーブルを作成しないModelは以下のオプションが必要
        abstract = True

    # 以下、「お約束カラム」
    created_by = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_by = models.CharField(max_length=255)
    updated_at = models.DateTimeField(auto_now=True)


class Author(BaseModel):
    class Meta:
        db_table = "author"

    name = models.CharField(max_length=255)


class Publisher(BaseModel):
    class Meta:
        db_table = "publisher"

    name = models.CharField(max_length=255)

尚、この方法は、Djangoの世界では『Two Scoops of Django 1.11』という英語の本の中で、「TimeStampedModel」という名前で紹介されているプラクティスらしい。(私はこの本を読んだことがない)

qiita.com

マイグレーション実行結果

マイグレーション結果をみると、以下のように「お約束カラム」が並び順の先頭にきてしまっている。
(厳密にはPK → BaseModelで定義したカラム → 継承先のModelで定義したカラムの順番になっている。)

mysql> desc author;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| created_by | varchar(255) | NO   |     | NULL    |                |
| created_at | datetime(6)  | NO   |     | NULL    |                |
| updated_by | varchar(255) | NO   |     | NULL    |                |
| updated_at | datetime(6)  | NO   |     | NULL    |                |
| name       | varchar(255) | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

mysql> desc publisher;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| created_by | varchar(255) | NO   |     | NULL    |                |
| created_at | datetime(6)  | NO   |     | NULL    |                |
| updated_by | varchar(255) | NO   |     | NULL    |                |
| updated_at | datetime(6)  | NO   |     | NULL    |                |
| name       | varchar(255) | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

「お約束カラム」はあくまで管理用の共通カラムなので、目立たないように一番後ろにきてほしいところではある。

このような並び順になってしまうのは、「Modelを継承している場合は親Modelで定義しているフィールドのカラムから先に並ぶ」というDjangoの仕様であり、調べたところお手軽な解決方法はなさそうだった。

stackoverflow.com

※ なお、余談だが、Djangoの組み込みのUserモデルから作成される「auth_user」テーブルを初めてdescした時に、「なんでusernameがこんな後ろにあるんだろう?」と違和感を持ったが、今思うとこれが原因だった。

メリット
  • 重複するフィールド定義を共通化できる。
デメリット
  • カラムの順番が意図した並び順にならない。(お約束カラムが継承先のModelで定義したフィールドのカラムよりも前に来てしまう。)

どっちを採用するか

「カラムの順番が意図した並び順にならない」というデメリットをどれだけ深刻に受け止めるかは人によるのかなと思う。

私自身は、RDBテーブルのカラムの並び順は、直観的にわかりやすいシステムを作るという意味において、かなり重要だと思っているので、このデメリットはけっこう気になる派である。

ただ、現実的には、定義を共通化できるのは魅力的だし、やはりDRY原則違反は避けたいので、苦渋の決断で「2.「お約束カラム」を親のモデルに共通化する。」を採用するのかなと思う。