IDシーケンスの管理
データベースを利用したシステムでは、各レコードにそれを一意に識別するための識別子 (ID) を付与する必要があります。 多くの場合、この識別子はレコードがデータベースに登録された順に連番で割り振られるわけですが、こうした連番での識別子発行は、PostgreSQL では "SERIAL" を、MySQL であれば "AUTO_INCREMENT" を利用して実装するのが一般的な手法でしょう。
ところが、こうしたデータベース管理システムによって提供される機能を利用できない (したくない) 場合もあります。 当ブログシステム「鵞鳥の羽根ペン」もそのひとつ。 このシステムは複数のブログを管理するように作られているのですが、スケーリングなどの将来的な拡張を見越して、エントリやコメントなどのレコード識別子はブログごとに独立したシーケンスになるように設計されています。 例えば、テーブル "entry" は以下のように定義 (一部) されています:
カラム | 型 | 制約 | 説明 |
---|---|---|---|
blog | INTEGER | PRIMARY KEY | ブログ識別子 (blog.id) |
id | INTEGER | エントリ識別子 | |
title | TEXT | NOT NULL | 表題 |
content | TEXT | - | 本文 |
author | INTEGER | NOT NULL | 投稿者 (user.id) |
time_post | TIMESTAMP | NOT NULL | 投稿日時 |
そのため、同じデータベース内であっても、異なるブログに属していれば、複数のエントリに同一の識別子値が割り当てられることになります。 より正確に表現するならば、システムはブログ識別子 (blog.id) とエントリ識別子 (entry .id) の組 (ペア) を「完全な」識別子として個々の entry レコードを識別している、となるわけですが、いずれにしても、SERIAL や AUTO_INCREMENT によるテーブル単位での連番付加では、こうした運用には対応できません。
当初の設計
ではどのようにするかと云えば、話は簡単。 エントリ, コメントといった各種のレコードについて、ブログごとに、「識別子空間をどこまで使ったか」という情報を記録しておけば良いのです。 そこで、その機能を実装した当時 (2007年頃) の私は、テーブル "idseq" をこのようにを定義 (一部) しました。
カラム | 型 | 制約 | 説明 |
---|---|---|---|
blog | INTEGER | PRIMARY KEY | ブログ識別子 (blog.id) |
entry | INTEGER | NOT NULL | 前回発行した識別子の値 (エントリ) |
comment | INTEGER | NOT NULL | 前回発行した識別子の値 (コメント) |
そして、新しくブログが作成される (テーブル "blog" にレコードが追加される) ごとに、"idseq" にもに対応するシーケンス情報が追加されるようにしていました。 例えば、識別子として 4 を割り当てられたブログの追加時には、次のようなSQLを発行するわけです:
INSERT INTO "idseq" ("blog", "entry", "comment") VALUES (4, 0, 0);
この "idseq" テーブルにアクセスして、新規レコードの識別子を発行する手順は、コメント (テーブル "comment") に対してであれば、以下のようになるでしょう:
class Comment # [DB] 新規IDの発行 def self.db_new_id(dbh, blog) check_type(DBI::DatabaseHandle, dbh, false) check_type(Integer, blog, false) row =dbh.select_one('SELECT "comment" FROM "idseq" WHERE "blog" = ?;', blog) return nil unless row id_prev =row[:comment] return nil unless id_prev id_new =id_prev + 1 dbh.do('UPDATE "idseq" SET "comment" = ? WHERE "blog" = ?;', id_new, blog) return id_new end end # class Comment
当時はすべてのテーブル、つい先日までは一部のテーブルに対して、識別子はこの方式によって発行されていました。
リファクタリング
ところが昨年、私は「このやり方はあまりスマートではない」と考え始めました。
この設計では、機能追加などによってデータベースに新しいテーブルが作られるたびに、"idseq" にもカラムの追加、即ち、定義の変更が必要になってしまいます。 また、それに合わせて、前節に掲載したような識別子発行のためのメソッド、そしてその内部で使用されるSQL文を書き足さなければなりません。
対象とするカラムの名前を指定することで、SQLを動的に生成するようにすれば、メソッドを共通化することも可能ですが、操作対象となるカラムがパラメタとなるため、「準備された文 (prepared statement)」 などの安全・高速化のための仕組みとの親和性の低い、「お行儀の悪い」実装になってしまいます。
そこで、新しくテーブル "idseq2" を以下のように定義することに:
カラム | 型 | 制約 | 説明 |
---|---|---|---|
blog | INTEGER | PRIMARY KEY | ブログ識別子 (blog.id) |
table | TEXT |
テーブル名 (例: ' entry ', 'comment ') |
|
prev | INTEGER | NOT NULL | 前回発行した識別子の値 |
このようにすれば、SQLのパラメタ (ブログ識別子, テーブル名) がすべて「値」である「お行儀の良い」実装ができるようになります。
class IDSeq # [DB] 新規IDの発行 def self.db_new_id(dbh, blog, table) check_type(DBI::DatabaseHandle, dbh, false) check_type(Integer, blog, false) check_type(String, table, false) row =dbh.select_one('SELECT * FROM "idseq2" WHERE "blog" = ? AND "table" = ?; ', blog, table) return nil unless row id_prev =row[:prev] return nil unless id_prev id_new =id_prev + 1 dbh.do('UPDATE "idseq2" SET "prev" = ? WHERE "blog" = ? AND "table" = ?;', id_new, blog, table) return id_new end end # class IDSeq
より良い形を求めて
「鵞鳥の羽根ペン」のデータベースには、ここで例に出したエントリやコメントの他にも、タグ, ユーザ, セッション, アクセス等々、ブログごとに連番で発行される識別子を持つテーブルが数多く存在しています。 そのため、暇 (とやる気) のあるときに少しずつ移行作業を行っていたのですが、先日ついにすべてのテーブルについて移行が完了し、旧形式の "idseq" を削除 (ついでに、仮に "idseq2" としていた新形式のテーブルを "idseq" に変更) することができました。
DROP TABLE "idseq"; ALTER TABLE "idseq2" RENAME TO "idseq";
また、Entry, Comment といった各クラスにそれぞれ記述されていた識別子発行のロジックが、IDSeq のクラスメソッドに統一されることでコードの分量が減り、可読性・保守性も向上しました。
この「バッサリ削る快感」があるので、リファクタリングは止められません。
Comments