Movable Type CMSプラットフォーム Movable Type
ドキュメントサイト

Blogブログ

更新履歴管理(リビジョン管理)の詳説(寄稿)

こんにちは!シックス・アパートの長内です。

「MTDDC Meetup 2011 Tokyo」に登壇いただいた皆様に、Movable TypeのTips、ハウトゥなどについて寄稿をいただく特別企画、第五回目です。

寄稿いただくのは、小粋空間で、Movable Typeに関わる多数の情報発信を行っている、荒木勇次郎さんです。

MTの履歴管理機能に関する解説を寄稿いただきました。


更新履歴管理について

Movable Typeでは更新履歴管理フレームワークが用意されています。このフレームワークを利用して、プラグインや独自アプリケーションなどでオブジェクトの更新履歴管理(リビジョン管理)を実現できますが、公式サイトのドキュメント「更新履歴をプラグインで利用する」では充分な情報が得られないため、ソースコードをトレースする必要があります。

本稿ではブログ記事の更新履歴管理を例に、フレームワークの内部動作について紹介します。更新履歴管理機能を利用する際の参考になれば幸いです。

更新履歴管理用テーブル

データベース上の更新履歴管理用テーブルは、オブジェクト別に生成されます。テーブル名はオブジェクトテーブル名の末尾に「_rev」をつけた名称になります。ブログ記事の場合、オブジェクトテーブル名は「entry」なので、ブログ記事の更新履歴管理用テーブル名は「entry_rev」になります(実際には各テーブルに「mt_」という接頭辞がつきます)。

図のように、entry_revテーブルはentryテーブルとIDで紐づきます。entryテーブルのひとつのデータに、entry_revテーブルの複数のデータ(=複数のリビジョン)が結びつきます。

mtddc2011special_araki_1.png

更新履歴管理用テーブルの主なフィールドの意味は次のとおりです。

  • description:編集画面の「変更メモ」の内容
  • entry:記事のリビジョンデータのハッシュ。フィールド名はオブジェクトテーブルのデータソース名
  • changed:変更があったフィールド名
  • rev_number:リビジョン番号

公式ドキュメントの「更新履歴をプラグインで利用する - 任意のオブジェクトの更新履歴を管理する」で触れられている通り、更新履歴管理を行うには、オブジェクトの継承関係にMT::Revisableを追加します。MT::Entryでは次のようになっています。

use base
    qw( MT::Object MT::Taggable MT::Scorable MT::Summarizable MT::Revisable );

また、履歴管理の対象にしたいフィールドにrevisioned属性を追加します。MT::Entryでは次のようになっています。

__PACKAGE__->install_properties(
    {   column_defs => {
            'id'      => 'integer not null auto_increment',
            'blog_id' => 'integer not null',
            'status'  => {
                type       => 'smallint',
                not_null   => 1,
                label      => 'Status',
                revisioned => 1
            },
            'author_id' => {
                type       => 'integer',
                not_null   => 1,
                label      => 'Author',
                revisioned => 1
            },

            # ...

ブログ記事の更新履歴管理概要

ブログ記事のリビジョンを保存する場合、次のような流れになっています。

1.差分チェック

直近に保存したオブジェクトデータと、今回保存するオブジェクトデータの差分をチェックします。差分の比較はMT::Revisable::gather_changed_colsで行います。gather_changed_colsはフックポイントcms_pre_save.entryから起動されます。

MT::Revisable::gather_changed_cols

sub gather_changed_cols {
    my $obj = shift;
    my ($orig) = @_;
 
    my @changed_cols;
    my $revisioned_cols = $obj->revisioned_columns;
 
    my %date_cols = map { $_ => 1 }
        @{ $obj->columns_of_type( 'datetime', 'timestamp' ) };
 
    foreach my $col (@$revisioned_cols) {
        next if $orig && $obj->$col eq $orig->$col;
        next
            if $orig
                && exists $date_cols{$col}
                && $orig->$col eq MT::Object::_db2ts( $obj->$col );
 
        push @changed_cols, $col;
    }
 
    $obj->{changed_revisioned_cols} = \@changed_cols
        if @changed_cols;
 
    my $class = ref $obj || $obj;
    MT->run_callbacks( $class . '::gather_changed_cols', $obj, @_ );
 
    1;
}

MT::Revisable::gather_changed_colsの引数は、編集中のオブジェクトデータ$objと、データベースに保存されているオブジェクトデータ$origです。MT::Object::revisioned_columnsを使ってrevisioned属性がついたフィールドを収集し、$objと$origで差分のあるフィールド名の配列を$obj->{changed_revisioned_cols}に保存します。

例えば、ブログ記事のタイトルと本文を変更して更新した場合、$obj->{changed_revisioned_cols}をData::Dumperで出力すると、次のような配列データが保存されていることが分かります。この内容はentry_revテーブルのchangedフィールドに保存されます。

$VAR1 = [ 'text', 'title' ];

entryテーブルに追加した独自フィールドにrevisioned属性を指定しておくことで、MT::Revisable::gather_changed_colsで差分を自動で比較してくれます。プラグインで追加した、entryテーブルに紐づいた他のオブジェクトデータの差分を比較したい場合は、gather_changed_colsフックポイントにハンドラメソッドを登録します。

    MT->run_callbacks( $class . '::gather_changed_cols', $obj, @_ );

ハンドラメソッドの登録は、プラグインのconfig.yamlに次のように記述します。

callbacks:
    MT::Entry::gather_changed_cols: $Foo::Foo::gather_changed_cols

カスタムフィールドもこのgather_changed_colsフックポイントを起動しています。なお、カスタムフィールドのハンドラメソッドCustomFields::Util::gather_changed_colsは、パフォーマンスを考慮し、差分が1つみつかった時点で処理を終了しているので、entry_revテーブルのchangedにはすべての差分フィールド名は保存されません。

2.リビジョンの保存

1項で$obj->{changed_revisioned_cols}にフィールド名が設定された場合、つまり新旧のオブジェクトデータに差分がある場合、リビジョンとして更新履歴管理用テーブルに保存します。リビジョンの保存は、MT::Revisable::save_revisionで行われます。save_revisionはフックポイントcms_post_save.entryから起動されます。MT::Revisable::save_revisionはフックポイント(save_revision_filter/pre_save_revision/post_save_revision)の提供だけで、実際の処理はMT::Revisable::Local::save_revisionで行われます。

MT::Revisable::Local::save_revision

sub save_revision {
    my $driver = shift;
    my ( $obj, $description ) = @_;
    my $datasource       = $obj->datasource;
    my $obj_id           = $datasource . '_id';
    my $packed_obj       = $obj->pack_revision();
    my $changed_cols     = $obj->{changed_revisioned_cols};
    my $current_revision = $obj->current_revision;
 
    require MT::Serialize;
    my $rev_class = MT->model( $datasource . ':revision' );
    my $revision  = $rev_class->new;
    $revision->set_values(
        {   $obj_id     => $obj->id,
            $datasource => MT::Serialize->serialize( \$packed_obj ),
            changed     => join ',',
            @$changed_cols,
        }
    );
    $revision->rev_number( ++$current_revision );
    $revision->description($description)
        if defined($description);
    $revision->save or return;
 
    return $current_revision;
}

引数の$objは編集中のオブジェクトデータ、$descriptionは編集画面の「変更メモ」の内容です。リビジョンデータはMT::Revisable::pack_revisionを利用して、フィールド名と値のハッシュを生成します。

    my $packed_obj       = $obj->pack_revision();

MT::Revisable::pack_revisionの処理は次のようになっています。

MT::Revisable::pack_revision

sub pack_revision {
    my $obj    = shift;
    my $class  = ref $obj || $obj;
    my $values = $obj->column_values;
 
    my $meta_values = $obj->meta;
    foreach my $key ( keys %$meta_values ) {
        next if $key eq 'current_revision';
        $values->{$key} = $meta_values->{$key};
    }
 
    MT->run_callbacks( $class . '::pack_revision', $obj, $values );
 
    return $values;
}

任意のデータをハッシュに含めたい場合は、メソッドの最後にあるpack_revisionフックポイントを起動します。

    MT->run_callbacks( $class . '::pack_revision', $obj, $values );

save_revisionで扱うリビジョンオブジェクト$revisionは、entry_revテーブルにアクセスする場合、MT::modelの引数に「データソース名+':revision'」を指定して取得した更新履歴用テーブルのパッケージ名でnewを実行して生成します。ブログ記事の場合、パッケージ名$rev_classは「MT::Entry::Revision」となります。

    my $rev_class = MT->model( $datasource . ':revision' );
    my $revision  = $rev_class->new;

$revisionの各フィールドにデータを設定し、saveメソッドで更新履歴管理用テーブルに保存します。リビジョンデータ$packed_objは、シリアライズを行いバイナリデータに変換します。オブジェクトから取得したリビジョン番号$current_revisionに1加算した値がrev_numberに設定され、save_revisionの返却値となります。保存前の最新リビジョン番号が2であれば、3が設定・返却されます。

    require MT::Serialize;
    $revision->set_values(
        {   $obj_id     => $obj->id,
            $datasource => MT::Serialize->serialize( \$packed_obj ),
            changed     => join ',',
            @$changed_cols,
        }
    );
    $revision->rev_number( ++$current_revision );
    $revision->description($description)
        if defined($description);
    $revision->save or return;

MT::Revisable::Local::save_revisionでのリビジョン保存イメージを図に示します。

MT::Revisable::Local::save_revisionでのリビジョン保存イメージ

3.リビジョンデータの取得

entry_revテーブルから特定リビジョン番号のオブジェクトデータを収集する動作は、次のような流れになっています。

まず、記事編集画面にある「リビジョン表示」のリンクをクリックすると更新履歴一覧が表示されます。

更新履歴一覧

一覧にある日付のテキストリンク(赤枠部分)をクリックすると、クエリーパラメータに「r=x」というリビジョン番号がついた形で、MT::CMS::Entry::editを起動します。

http://.../mt.cgi?__mode=view&_type=entry&id=1&blog_id=2&r=3

MT::CMS::Entry::editでは、クエリーパラメータの「r=x」を取得し、MT::Revisable::load_revisionを起動します(実際の処理はMT::Revisable::Local::load_revisionに実装されています)。load_revisionでは、save_revisionと逆の動作を行います。

MT::Revisable::Local::load_revision

sub load_revision {
    my $driver = shift;
    my ( $obj, $terms, $args ) = @_;
    my $datasource = $obj->datasource;
    my $rev_class  = MT->model( $datasource . ':revision' );
 
    # Only specified a rev_number
    if ( defined $terms && ref $terms ne 'HASH' ) {
        $terms = { rev_number => $_[0] };
    }
    $terms->{ $datasource . '_id' } ||= $obj->id;
 
    if (wantarray) {
        my @rev = map { $obj->object_from_revision($_); }
            $rev_class->load( $terms, $args );
        unless (@rev) {
            return $obj->error( $rev_class->errstr );
        }
        return @rev;
    }
    else {
        my $rev = $rev_class->load( $terms, $args )
            or return $obj->error( $rev_class->errstr );
        my $array = $obj->object_from_revision($rev);
        return $array;
    }
}

引数の$objはブログ記事のオブジェクトデータ、$termsおよび$argsにはオブジェクトをロードする条件を指定します。MT::CMS::Entry::editからload_revisionを起動するときは、引数にリビジョン番号のみを指定しています。

MT::CMS::Entry::edit

sub edit {
 
    # ...
 
    my $rn = $q->param('r');
    if ( defined($rn) && $rn != $obj->current_revision ) {
 
        # ...
 
        my $rev = $obj->load_revision( { rev_number => $rn } );

load_revisionでのオブジェクトデータ取得は、entry_revテーブルを指定してロードします。

    my $datasource = $obj->datasource;
    my $rev_class  = MT->model( $datasource . ':revision' );
 
    # ...
 
        my $rev = $rev_class->load( $terms, $args )

load_revisionの中で起動されるobject_from_revisionでは、entry_revテーブルから取得した特定リビジョンのデータ(リビジョンオブジェクト・差分フィールド名・リビジョン番号)を配列で返却します。object_from_revisionは、更新履歴一覧を表示する際にも起動されます。

MT::Revisable::Local::object_from_revision

sub object_from_revision {
    my $driver = shift;
    my ( $obj, $rev ) = @_;
    my $datasource = $obj->datasource;
 
    my $rev_obj        = $obj->clone;
    my $serialized_obj = $rev->$datasource;
    require MT::Serialize;
    my $packed_obj = MT::Serialize->unserialize($serialized_obj);
    $rev_obj->unpack_revision($$packed_obj);
 
    # Here we cheat since audit columns aren't revisioned
    $rev_obj->modified_by( $rev->created_by );
    $rev_obj->modified_on( $rev->modified_on );
 
    my @changed = split ',', $rev->changed;
 
    return [ $rev_obj, \@changed, $rev->rev_number ];
}

引数の$objは編集中のオブジェクト、$revはリビジョンオブジェクトです。最初に$objのクローン$rev_objを生成し、記事編集画面に表示するための元データとします。$revから取得したリビジョンデータ$serialized_objはアンシリアライズして$packed_objに設定し、さらにMT::Revisable::unpack_revisionを使って、フィールド名と値のハッシュを復元します。unpack_revisionの処理が完了した時点で、$rev_objにはリビジョンデータが復元されています。

    require MT::Serialize;
    my $packed_obj = MT::Serialize->unserialize($serialized_obj);
    $rev_obj->unpack_revision($$packed_obj);

更新のタイムスタンプとユーザーは、$revから$rev_objにコピーします。

    $rev_obj->modified_by( $rev->created_by );
    $rev_obj->modified_on( $rev->modified_on );

unpack_revisionの処理は次のようになっています。

MT::Revisable::unpack_revision

sub unpack_revision {
    my $obj          = shift;
    my ($packed_obj) = @_;
    my $class        = ref $obj || $obj;
 
    delete $packed_obj->{current_revision}
        if exists $packed_obj->{current_revision};
 
    $obj->set_values($packed_obj);
 
    MT->run_callbacks( $class . '::unpack_revision', $obj, $packed_obj );
}

任意のデータをハッシュから取得したい場合は、メソッドの最後にあるunpack_revisionフックポイントを起動します。

    MT->run_callbacks( $class . '::unpack_revision', $obj, $packed_obj );

データベースから特定のリビジョンデータを取得し、記事編集画面の表示データとなる$rev_objを生成するまでのイメージを図に示します。

$rev_objを生成

リビジョン管理メソッドのコールバック登録

リビジョン管理メソッドのコールバック登録は、MT::Revisable::install_propertiesで行っています。

MT::Revisable::install_properties

sub install_properties {
    my $pkg        = shift;
    my ($class)    = @_;
    my $props      = $class->properties;
    my $datasource = $class->datasource;
 
    # ...
 
    MT->add_callback( 'api_pre_save.' . $datasource,
        9, undef, \&mt_presave_obj );
    MT->add_callback( 'cms_pre_save.' . $datasource,
        9, undef, \&mt_presave_obj );
 
    # ...
 
    MT->add_callback( 'api_post_save.' . $datasource,
        9, undef, \&mt_postsave_obj );
    MT->add_callback( 'cms_post_save.' . $datasource,
        9, undef, \&mt_postsave_obj );
 
    $class->add_callback( 'post_remove', 0, MT->component('core'),
        \&mt_postremove_obj );
}

コールバック登録MT::add_callbackで使われている変数$datasourceには、MT::Revisableを継承しているオブジェクトのデータソース名が設定されます。よって、ブログ記事のコールバックとして、次の5種類が自動的に登録されることになります。

  • api_pre_save.entry
  • cms_pre_save.entry
  • api_post_save.entry
  • cms_post_save.entry
  • post_remove

ハンドラメソッドのmt_presave_objでは、「1.差分チェック」で紹介したgather_changed_colsを実行します。mt_postsave_objでは、「2.リビジョンの保存」で紹介したsave_revisionを実行します。mt_postremove_objでは、remove_revisionsを実行します。

ハンドラメソッドのオーバーライド

ブログ記事では、各ハンドラメソッドgather_changed_cols、pack_revision、unpack_revisionをオーバーライドしています。理由は、ブログ記事データを保存するmt_entryテーブルに含まれない、タグやカテゴリといったデータをリビジョンデータとして扱う必要があるためです。

MT::Revisable::gather_changed_colsをオーバーライドしたMT::Entry::gather_changed_colsでは、次のようにMT::Revisable::gather_changed_colsの処理を行ってから、タグやカテゴリなどの独自処理を行っています。

MT::Entry::gather_changed_cols

sub gather_changed_cols {
    my $obj = shift;
    my ( $orig, $app ) = @_;
 
    MT::Revisable::gather_changed_cols( $obj, @_ );
 
    # ...

更新履歴数の制御

ブログ記事やテンプレートなどの更新履歴数は「設定」→「全般」で設定しています。この更新履歴数を超えないよう、mt_postsave_objの中でMT::Revisable::handle_max_revisionsを起動し、リビジョンデータ保存時に更新履歴数を超えた一番古いデータをリビジョンデータから削除しています。

MT::Revisable::Local::handle_max_revisions

sub handle_max_revisions {
    my $driver = shift;
    my ( $obj, $max ) = @_;
    return unless $max;
 
    my $datasource = $obj->datasource;
    my $rev_class  = MT->model( $datasource . ':revision' );
    my $terms      = { $datasource . '_id' => $obj->id };
    my $count      = $rev_class->count($terms);
    if ( $max <= $count ) {
        my $rev_iter = $rev_class->load_iter(
            $terms,
            {   sort      => 'created_on',
                direction => 'ascend',
                limit     => $count - $max + 1
            }
        );
        while ( my $rev = $rev_iter->() ) {
            $rev->remove;
        }
        return $max - 1;
    }
    return $count;
}

更新履歴管理メソッド

更新履歴管理(MT::Revisable)で提供されているメソッドを紹介します。

$obj->gather_changed_cols($orig, $app)

$origと$objの差分フィールドを$obj->{changed_revisioned_columns}に設定します。

$obj->save_revision()

差分がある場合のみ、$objをリビジョンとして保存します。

$obj->load_revision(\%terms, \%args)

%termsと%argsで指定した条件のリビジョンデータをロードします。%termsには次の値を指定できます。

  • id
  • label
  • description
  • rev_number
  • changed
$obj->apply_revision(\%terms, \%args)

%termsと%argsを引数としてload_revisionを起動し、ロードしたリビジョンデータを最新データとして適用(オブジェクトテーブルに保存)します。

$obj->pack_revision()

オブジェクトデータのハッシュを生成し、ハッシュリファレンスを返却します。

$obj->unpack_revision($packed_obj)

引数のハッシュリファレンスから取り出した値を$objに設定します。

$obj->remove_revisions()

$objのリビジョンデータを削除します。

$obj->diff_object($obj_b)

$objと$obj_bの差分をハッシュリファレンスで返却します。

$obj->diff_revision(\%terms, \%diff_args)

%termsで指定した2つのリビジョン番号のリビジョンデータの差分をハッシュリファレンスで返却します。

$class->revision_pkg

$classの更新履歴管理テーブルのパッケージ名を返却します

$class->revisioned_columns

$classのrevisioned属性のフィールドを配列のリファレンスで返却します

$class->is_revisioned_column($col)

引数で指定した$classのフィールドがrevisioned属性か判定します

プラグイン開発のポイント

既存テーブルへのフィールド追加を行った場合は、前述したとおり、フィールドにrevisioned属性を追加することでリビジョン管理対象となります。独自テーブルについては、プラグインのハンドラメソッドに次の実装を行うことでリビジョン管理が実現できます。

リビジョンデータ保存

  • gather_changed_colsフックポイントで起動したハンドラメソッドで、差分のあるフィールド名を$obj->{changed_revisioned_cols}に保存
  • pack_revisionフックポイントで起動したハンドラメソッドで、独自データをリビジョンテーブルに保存

リビジョンデータ取得

  • unpack_revisionフックポイントで起動したハンドラメソッドで、リビジョンデータを復元

関連アイテムをリビジョンに含めるプラグイン

筆者の運営するブログ「小粋空間」では、関連アイテムを更新履歴管理に含めるプラグイン「RevisableAsset」を公開しています。個人無償版やMovable Type Open Sourceであれば無償でご利用いただけます。

Movable Typeで記事アイテムのリビジョン管理ができる「RevisableAssetプラグイン」

独自オブジェクトを既存オブジェクトに含めて更新履歴管理対象にする場合のサンプルとして参考になれば幸いです。

執筆者
荒木勇次郎(あらき ゆうじろう)
プロフィール
Movable Typeの情報を提供するブログ「小粋空間」の管理人。SE、プログラマー。
2010年「MTDDC Tokyo」、2011年「MTDDC Meetup Tokyo 2011」に講師として出演。
著書に「Movable Type 5プロフェッショナルガイド」(毎日コミュニケーションズ)、「Movable Type 5.1プロの現場の仕事術」(毎日コミュニケーションズ)など多数。
ブログなど
個人サイト小粋空間
Facebook:koikikukan
Twitter:@yujiro

免責事項

本連載企画は、MTを利用したカスタマイズテクニックの一つで、実際の製品仕様範囲外の利用方法を含む場合がございます。製品仕様範囲外の利用方法については、製品サポートの範囲外となりますのでご注意ください。

本連載企画で説明されているテクニックを実際に適用する場合は、ご自身の環境でお試しいただいた上で適用くださいますようお願い申し上げます。

  • このエントリーをはてなブックマークに追加