2015.12.03

Yii2でカスケード保存

Pocket

はじめに

こんにちは。次世代システム研究室のT.Tです。

前回は、PHPとOracle DBを利用するプロジェクトの立ち上げ時期の話題を取り上げてみましたが、そのプロジェクトで開発したサービスの一部は既にリリースされ無事に稼働していて、ひとまず安心しているところです。プロジェクト自体は引き続き進行中で、そろそろ佳境に差し掛かる段階ですが、自分は相変わらずOracle DBと仲良くやっていくためにhackYiiなことばかりやっています。
そんな最中、今回はYii 2でオブジェクトをカスケードで保存するための拡張についてご紹介したいと思います。

カスケードを導入した背景

プロジェクトではドメイン駆動設計からいくつかのパターンをピックアップして導入していて、集約とリポジトリをActiveRecordを継承したモデル層で扱う構成になっています。
集約にはトランザクション整合性を保たなければいけないオブジェクトを持たせているので、集約のオブジェクトを一括で保存できる仕組みがあると、個別に保存するロジックを書かずに済み、更新洩れもなくなるので非常に便利です。
そこで、この仕組みをカスケードで実現することにしました。

カスケードの例

ユーザーのアカウントとプロフィールを管理する場合を例にして進めていきたいと思います。
まずコード上でやりたいことは、次のように集約の作成更新をする際に集約のルートの保存して、集約内のオブジェクトも一括で保存することです。
// 集約の保存(作成と更新)
$user = User::findOne($userId);
if (!isset($user)) {
    $user = new User();
    $user->user_id = $userId;
    $user->userProfile = new UserProfile();
}

$user->name = $name;
$user->email = $email;
$user->password = $password;

$user->userProfile->telephone = $telephone;
$user->userProfile->gender = $gender;

$user->save();
// UserProfileの新規作成時でも更新時でもsaveの明示的な呼び出しは不要
// $user->userProfile->save();
// テーブル定義
CREATE TABLE `USER`
(
    USER_ID NUMBER(10,0) NOT NULL,
    NAME VARCHAR(64) NOT NULL,
    EMAIL VARCHAR2(256) NOT NULL,
    PASSWORD VARCHAR2(256) NOT NULL,
    PRIMARY KEY (USER_ID)
);

CREATE TABLE USER_PROFILE
(
    USER_ID NUMBER(10,0) NOT NULL,
    TELEPHONE VARCHAR2(32),
    GENDER CHAR(1),
    PRIMARY KEY (USER_ID)
);
 

Yii 2のORMでの保存

Yii 2のActiveRecordは標準だとカスケードで保存する仕様はなく、関連するオブジェクトを新規作成する時に明示的にlinkメソッドで関連付ける仕様になっています。
// Yii 2標準の作成
$user = new User();
$user->user_id = $userId;
$user->name = $name;
$user->email = $email;
$user->password = $password;

$userProfile = new UserProfile();
$userProfile->user_id = $userId;
$userProfile->telephone = $telephone;
$userProfile->gender = $gender;

// 新規作成時はリンクの前にユーザーオブジェクトを保存しないといけないので、先に保存する
$user->save();
// 関連付けと同時にCREATE文も実行される
$user->link('userProfile', $userProfile);
// link実行後は$user->userProfileで参照可能になる
// Yii 2標準の更新
$user = User::findOne($userId);
$user->name = $name;
$user->email = $email;
$user->password = $password;

$user->userProfile->user_id = $userId;
$user->userProfile->telephone = $telephone;
$user->userProfile->gender = $gender;

$user->save();
$user->userProfile->save();
標準の方法だと作成と更新で処理が異なるので別々の処理を実装する必要があり、それだけでも煩雑になってしまいます。また、関連オブジェクトが配列の場合や関連オブジェクトが増えるとさらに煩雑な処理を書かなければならなくなってしまいます。
一方、カスケードで保存する場合は作成処理と更新処理をまとめて実行していて、保存も集約のルートを保存するだけで済んでいるので大分簡略化されていることがわかります。標準の方法でも作成と更新を同じ処理でまとめることはできますが、却って複雑な記述になってしまいます。

Yii 1.1では関連の保存をうまく扱うエクステンションも提供されているようですが、Yii 2だと特に良さそうなものが見つかりませんでした。

※保存する際はこのような癖があり扱いにくかったりすのですが、ロードは非常に使いやすく便利です。

ActiveRecordの拡張

そのような経緯で、先述したカスケードで保存するコードで記述できるようにActiveRecordを拡張しました。
戦略は以下のような感じです。
  • 関連オブジェクトが新規作成される場合は先に関連付けだけして保存はしない(参照する側も新規作成する場合は先に保存しないといけない制約があるため)
  • ユーザー(集約のルート)オブジェクトが保存されるタイミングで新規作成された関連オブジェクトを保存する
実装は以下のようになりました。
// エンティティ共通で使うBaseActiveRecordの実装
abstract class BaseActiveRecord extends \yii\db\ActiveRecord
{
    public function __set($name, $value)
    {
        // has-one属性に値が設定されていなかったら関連付ける
        if ((method_exists($this, 'get' . $name) && !$this->{$name}) &&
            !is_array($value)) {
            $this->populateRelation($name, $value);
        // has-many属性に配列をマージして関連付ける
        } elseif (is_array($value) && method_exists($this, 'get' . $name)) {
            $this->populateRelation($name, array_merge($this->__get($name), $value));
        // 関連オブジェクト以外は値を更新する
        } else {
            parent::__set($name, $value);
        }
    }

    // 自身を保存した後に関連オブジェクトを保存する
    public function afterSave($insert, $changedAttributes)
    {
        // 関連オブジェクトだけ保存する
        foreach ($this->relatedRecords as $name => $records) {
            if (is_array($records)) {
                foreach ($records as $record) {
                    $record->save();
                }
            } elseif (isset($records)) {
                $records->save();
            }
        }
    }
}
// Userの実装
class User extends BaseActiveRecord
{
    public function getUserProfile()
    {
        return $this->hasOne(UserProfile::className(), ['USER_ID' => 'USER_ID']);
    }
/* 
    // has-many関連属性のサンプルコード
    public function getUserHasMany()
    {
        return $this->hasMany(UserHasMany::className(), ['USER_ID' => 'USER_ID']);
    }
*/
}
// UserProfileの実装
// UserProfileが関連を持つときのためにBaseActiveRecordの継承だけしておく
class UserProfile extends BaseActiveRecord
{
}

拡張したクラスによる保存のコード

ActiveRecordを拡張したクラスを利用した実際の保存するコードは以下のようになります。
// 実際の保存するコード
$user = User::findOne($userId);
if (!isset($user)) {
    $user = new User();
    $user->user_id = $userId;
    $user->userProfile = new UserProfile();
}

$user->name = $name;
$user->email = $email;
$user->password = $password;

$user->userProfile->user_id = $userId;
$user->userProfile->telephone = $telephone;
$user->userProfile->gender = $gender;

/*
// has-many関連属性のサンプルコード
$userHasMany = new UserHasMany();
$userHasMany->user_id = 1;
$user->userHasMany = [$userHasMany];
*/

$user->save();
このコードは冒頭で目指していたスタイルと同じもので、実際にカスケードで保存できるようになりました。

まとめ

当初の目標は達成できましたが、いくつか副作用や問題があります。
  • ActiveQuery.inverseOf等を使って相互関連を持たせると保存時に循環参照してしまう(無限ループになる)
  • many-to-many関連に対応していない
  • has-many関連の場合は配列を代入する必要がある($user->userHasMany[] = $userHasManyではなく、$user->userHasMany = [$userHasMany]と書かないといけない)
has-one関連やhas-many関連でActiveQuery.inverseOf等で親側と関連付ける場合は、getParentのような特定のメソッド名を使うことで保存時の循環参照を回避できそうですが、many-to-many関連をうまく扱うのは難しそうです。
PHPでは継承元が必要な場合は[]をオーバーライドできないので、呼び出し側でhas-one関連かhas-many関連かによって代入する値をスカラにするか配列にするかを意識する必要があります。少し変則的ですが、規約としては許容範囲かと思います。

このような制限があるため、現在のプロジェクト内で利用する場合も気を付けて使う必要はありますが、実際に利用して集約のデータを作成したり更新するコードは大分すっきり書けるようになっています。
しかし、many-to-many関連がうまく扱えないので他のプロジェクトで適用するのは難しそうです。

オラには合わねぇ!だがそれがYii

Yii 2はここ数ヶ月使い続けていますが、使い易く拡張性も高いフレームワークで、今回はカスケードでの保存も結構な制約はありつつも解決できました。
一方、テーブルの行ロックをORM的にうまくやる方法がわからなかったり、シーケンスで値を保存してもプライマリキー以外だと割り当てられた値が取得できなかったりして、Oracle DBがモデルのレイヤーに顔を出してくる箇所もちらほらあり、うまくいかないところもあります。

最近そんなときは、もうどうで、、じゃなくて、それがYiiと思うようにしています。

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。