たごもりすメモ

コードとかその他の話とか。

追記型O/R Mapper "Stratum" を公開した(公開しただけ)

仕事で作っているアプリ用に書いたO/Rマッパのライブラリ、隠してても何の嬉しいこともないので、社内に置いてたgitのリポジトリgithubに移した。さすがにもう機能追加の必要もなくなってきたなーという段階になったので。

https://github.com/tagomoris/Stratum

ライセンスは Apache License v2.0 としました。なにかしたい方はお好きにどうぞ。READMEとか書き中。

何のためのもの?

世の中にORMなんていくらでもあるのになんで書きはじめたんだ、ということですが、要するに以下の理由です。

  • 誰が、いつ、どのようにデータを追加・更新・削除したのかをすべて残す
  • そのような履歴データに簡易にアクセスする

最近監査とかなんだとかうるさいですからね。

で、こういう条件をきっちり満たしたアプリケーションを普通のORMを使って書くというのは存外に面倒。全データを残しておくことになるとデータのunique検査にDBの制約を使うわけにはいかなくなるし、普通のORMの制約も不都合がいろいろと大きくなる。
それに各更新操作で以下のような処理をいちいち書くハメになる。

  1. 検索キーをもとにSELECTする
  2. 結果から最新のものを選んで、あとは捨てる
  3. 選んだものをコピーして新しいインスタンスを作成する
  4. 対象のフィールドの値を更新して、同時にオペレータ情報と更新日時を埋める
  5. 更新後のインスタンスを保存する

いやいやこれを全部書いて回るとかありえないでしょ……。ないない。has_many 的にオブジェクト参照までカラムに入ってきたらマジで死ぬって。

ということで、このあたりを全部やってくれるものを作りました。おかげでアプリケーション側のコードはそんなに変なことにはなってないんじゃないかなーと思います。ただちょっとクセが強いシロモノになっちゃったけど。誰か使いやすく改良してw

あと複数対応する理由が手元にないので、ruby-mysql専用、Ruby 1.9専用です。今だとRails3のArel対応とかしたかったけど、タイミングが微妙で検討できないまま作りはじめちゃったので、してません。

大雑把な使いかた

まずMySQL側でスキーマ定義。自動でやってくれるとか、ありません。アプリケーション全体でひとつの通し番号のid("oid"と呼んでます)を共有するのでそれを払出すためのテーブル、更新処理をするためには必ずオペレータとしての型をもったユーザを登録しないといけないのでそのテーブル*1が最低限必要です。またoids以外の各テーブルには id, oid, inserted_at, operated_by, head, removed の各カラムが必要です。(Stratumが使用します。)

まず oid 払出し用のテーブルと認証情報用のテーブルを定義。認証情報のテーブル名やカラムは特に固定ではありません。使い方によって fullname とか privilege とか好きに増やすといいと思います。要するに oid を持った何かがあればいい。

CREATE TABLE oids (
id INT PRIMARY KEY NOT NULL AUTO_INCREMENT
) ENGINE=InnoDB;

CREATE TABLE auth_info (
id          INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
oid         INT             NOT NULL,
valid       ENUM('0','1')   NOT NULL DEFAULT '1',
name        VARCHAR(32)     NOT NULL,
inserted_at TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
operated_by INT             NOT NULL,
head        ENUM('0','1')   NOT NULL DEFAULT '1',
removed     ENUM('0','1')   NOT NULL DEFAULT '0'
) ENGINE=InnoDB charset='utf8';

INSERT INTO oids SET id=1;
INSERT INTO auth_info SET oid=0,name='root',operated_by=0;

最後にrootという名前でINSERTしてるのは、セットアップなり何なりでデータを投入するときに使うユーザ情報がないと初期データ投入すらできないから。初期データ投入後は使いません。使ってはいけません。ENUM('0','1') はBOOL値ですね。

で、これに対応するモデルデータを次のように定義します。

class AuthInfo < Stratum::Model
  table :auth_info
  field :name, :string, :validator => 'accountname_checker'
  field :valid, :bool, :default => true

  def accountname_checker(str)
    # some code to validate username
    str =~ /\Ald.*\Z/
  end
end

簡単ですね! table でテーブル名、field で各フィールドの定義をやります。field 引数は順にフィールド名、型、オプション(ハッシュ)。オプションは型によってあれこれで、型ごとに必須のものがあります。テーブル上のカラム名はフィールド名と違えば指定できますが、同一なら省略可能。
上記モデルのデータ(認証データっぽいもの)を扱うときは以下のようなコードで。

root_user = AuthInfo.get(0)     # Stratum::Model.get は oid を受け取って、最新(現在)の状態を返す
root_user = AuthInfo.query(:name => "root", :unique => true)
# Stratum::Model.query でフィールドの値から検索してヒットしたリストを返す、が :unique => true 指定の場合、検索時にユニーク性の検査を行って複数ヒットしたら例外を投げる

Stratum.operator_model(AuthInfo) # まずどのモデルがオペレータを表すのかを指定
Stratum.current_operator(root_user) # 初期データ投入用に root をセット

user1 = AuthInfo.new()
user1.name = "tagomoris"  # 代入時に validation チェックが行われて、この場合だと例外 FieldValidationError
user1.name = "ld-tagomoris" # ok
user1.save

さて上記のコードの結果だと以下みたいな状態になりますね。oidは新規オブジェクトの作成時に自動的に採番して割り当てられます。validフィールドは :default 指定してある(:boolの場合は :default は必須)ので、その値で true に。

+----+-----+-------+--------------+---------------------+-------------+------+---------+
| id | oid | valid | name         | inserted_at         | operated_by | head | removed |
+----+-----+-------+--------------+---------------------+-------------+------+---------+
|  1 |   0 | 1     | root         | 2010-10-12 19:03:12 |           0 | 1    | 0       |
|  2 |   1 | 1     | ld-tagomoris | 2010-10-12 21:07:54 |           0 | 1    | 0       |
+----+-----+-------+--------------+---------------------+-------------+------+---------+

では簡単にいじるため、validフィールドを false にしてみます。

tagomoris = AuthInfo.query(:name => 'ld-tagomoris').first

Stratum.current_operator(tagomoris) # 普通はWebアプリケーションのログイン処理などでセット

tagomoris.saved? == true # 永続化済みのオブジェクトかどうか
tagomoris.valid? == true # :bool の場合は ? つきのメソッド名も自動的に作られる、けど #valid でも同じ
tagomoris.valid = false
tagomoris.saved? == false
tagomoris.save                    # もし Stratum.current_operator(obj) が行われてなければ、ここで例外が発生
tagomoris.saved? == true

# もし削除したかったら tagomoris.remove するだけ

で、この結果は以下のような状態に。

+----+-----+-------+--------------+---------------------+-------------+------+---------+
| id | oid | valid | name         | inserted_at         | operated_by | head | removed |
+----+-----+-------+--------------+---------------------+-------------+------+---------+
|  1 |   0 | 1     | root         | 2010-10-12 19:03:12 |           0 | 1    | 0       |
|  2 |   1 | 1     | ld-tagomoris | 2010-10-12 21:07:54 |           0 | 0    | 0       |
|  3 |   1 | 0     | ld-tagomoris | 2010-10-12 21:13:21 |           1 | 1    | 0       |
+----+-----+-------+--------------+---------------------+-------------+------+---------+

前の状態のレコード(id=2)は head='0' として更新され、最新の状態を表すレコード(id=3)が head='1' として挿入されています。通常の query などでは head='1' AND removed='0' のもの(「現在」「存在している」レコード)のみが対象となるため、過去の履歴分や論理削除されたデータはアプリケーションから意識せずに扱うことができます。
特定のオブジェクトのこれまでの履歴を取得するのも簡単。

tagomoris_history = AuthInfo.retrospect(AuthInfo.query(:name => 'ld-tagomoris', :oidonly => true, :unique => true))
# oid で対象を指定するため、Stratum::Model.query のオプションで oid のみを返すよう指定
# 新しい方から過去に向かって順に取得される

tagomoris_history[0].valid? == false
tagomoris_history[0].head == true
tagomoris_history[0].id == 3
tagomoris_history[0].inserted_at.to_s == '2010-10-12 21:13:21'
tagomoris_history[0].operated_by.name == 'tagomoris' # AuthInfoクラスのインスタンスが返されて、その名前は 'tagomoris'
tagomoris_history[0].updatable? == true # 最新のレコードから生成されたオブジェクトのみが更新可能

tagomoris_history[1].valid? == true  # 更新される前の状態
tagomoris_history[1].head == false
tagomoris_history[1].id == 2
tagomoris_history[1].inserted_at.to_s == '2010-10-12 21:07:54'
tagomoris_history[1].operated_by.name == 'root' # このときは root によるデータ
tagomoris_history[1].updatable? == false # 最新のレコードから生成されたオブジェクトのみが更新可能

tagomoris_history[1].name = 'ld-tagomoris2'  # Stratum::InvalidUpdateError 例外

また他のモデルへの参照を保持する型 (:ref および :reflist) もあるため、モデル間のリレーションを作ることができます。またそういう処理をするときにはトランザクションが必須でしょう。ということで以下簡単なコード例。(テーブル定義は省略。)

class HostData < Stratum::Model
  table :hosts
  field :name, :string, :length => 32
  field :type, :string, :selector => ['real', 'xen', 'vmware', 'switch', 'storage'], :default => 'real'
  field :hardware, :ref, :model => 'HardwareType', :empty => :ok
  field :ipaddresses, :reflist, :model => 'IPAddress'
end

class HardwareType < Stratum::Model
  table :hardwaretypes
  field :name, :string, :length => 32
  field :units, :string, :validator => 'units_validator', :normalizer => 'units_upcase', :default => '1U'
  def units_validator(units)
    units =~ /\A[.0-9]+U\Z/
  end
  def self.units_upcase(units)
    units.upcase
  end
end

class IPAddress < Stratum::Model
  table :ipaddrs
  field :addr, :string, :validator => 'any_ipaddress_validator'  # 実装は省略…
  field :hosts, :reflist, :model => 'HostData', :empty => :ok
end

# ホストデータを作成し、IPアドレスもセットして保存したいが、失敗したら全部ロールバック
Stratum.transaction do |conn|
  host1 = HostData.new
  host1.name = '謎のスイッチ'  # 日本語はutf-8で入れましょう
  host1.type = 'switch'  # セレクタの場合はリストで指定されたもの以外を入れると例外
  host1.hardware = HardwareType.query_or_create(:name => 'catalyst2950') # 既に存在していればそれを引っ張ってくる、なければ作って保存した上で渡す
  host1.ipaddresses = IPAddress.query_or_create(:addr => '192.168.1.1') # :reflist なんで本当は  host1.ipaddresses = [obj] (もしくは +=)なんだけど、これでも大丈夫
  host1.save
end
# end に到達した時点でデータベースに対してコミットが行われ、途中で例外が発生した場合はロールバック

# 参照してみると、こんな。
hostdata = HostData.query(:name => '謎のスイッチ').first
hostdata.type # 'switch'
hostdata.hardware.name # 'catalyst2950'
hostdata.ipaddresses.first.name # '192.168.1.1'
hostdata.ipaddresses.first.hosts.map(&:name) # ref/reflistへの代入時に逆方向の参照も同時に更新されるので ['謎のスイッチ'] となる
hostdata.ipaddresses.first.hosts.first == hostdata

あと、queryするときに何年何月何日時点の情報が欲しい!とやると取得できたり、そうやって取得したオブジェクトのref/reflistを辿ったときにも過去日時参照が引き継がれた状態で参照されたり、論理削除済みデータまで含めて検索したり、生きてるデータの全件に対してオンメモリで正規表現検索したり、そんな感じの処理があれこれあります。
データ型は :bool / :string / :stringlist / :ref / :reflist と、あとタグづけを実現するのに :taglist がありますが、:taglist はMySQL全文検索エンジンにべったり依存(つまりMyISAM依存)なので汎用的には使えません。まさにタグづけ専用。
他にはやっつけっぽくコネクションプールを作っていたり、データのキャッシュを持っていたり、レンダリングエンジンのpartial上でモデル参照(つまりDBへのSELECT)が走るときのためにpreload機能があったり。自分でアプリケーション作る上で必要そうな機能はだいたい入れました。

ないもの

整数型を含む数値型、DBスキーマ生成、MySQL以外のRDBMSサポート。数十万件以上のレコードに対するスケーラビリティ*2クラスタリングやシャーディングへの対応。などなどなどなど。
APIリファレンス。

感想

正直誰が使うんだっつーシロモノなのでドキュメントとかは放置状態になるんじゃないかなーと思います。ただ実用している某プロジェクトはgithub上のコードをそのまま使ってるので、必要な機能追加や更新は自分でやってると思います。興味があればどうぞ。

DSL書くにはやっぱりRubyだよなーとか、トランザクション処理をブロックとして書けるのはやっぱり便利だよなーとか、そんなRubyのすばらしさを噛みしめつつ作ってました。楽しいです。

*1:これもStratum::Modelのサブクラスとして定義・アクセスする

*2:多分、論理的なオブジェクト数として数万まではそこそこ問題なく使えると思う。それ以上はどうかな……。