コネヒト開発者ブログ

コネヒト開発者ブログ

CakePHP3のORMの中核を担う「Entity」とは何か 〜CakePHP2ユーザー向けに〜

こんにちは、サーバーサイドにコードを放り込んでいます金城 (o0h_)です。

https://cdn.mamari.jp/authorized/594fc0f8-f2e8-49c7-a174-2ce30a0103ab.jpg

週に数回の頻度で「はじめてのメーガン・トレイナー」を聴いています。 まったりする〜


ここのところ、弊社では「社内でエンジニーアズのLTしよーぜ!」をしています。
私も発表する機会があったので、CakePHP3の紹介トークのようなものをしました。

特に「2.xなら触ったことがある!」ような人を意識した内容にして、2.x→3.xの最も目覚ましい変化の1つである「ORM/モデルレイヤーの(大)変革!!」を取り上げました。様々な変更内容がある中、フォーカスしたのは「PHPとデータベース間の、データ形式の変換の流れ」についてです。1

その際に質問を受けたり、自分でも「今までそんなに気にしていなかったかもなー?」という点がいくつか湧いてきました。
今回は、LTを行った内容をベースとしながら、私なりに「CakePHP3のモデルについて」改めて調べた内容を交えて紹介してみたいと思います。
主に前メジャーバージョンである2.xとの比較を意識しながら進めて参ります。

「Model」にまとめられていた働きの分割

皆さんはCakePHP2での開発を行った経験はありますでしょうか?
今回は「こんなに良くなったよ!」という話も主旨の1つであるため、最初にちょっとしたコードを載せてみます。

public function index() {
        // postの一覧を取得
        $posts = $this->Post->find('all', ['order' => 'created DESC']);
}

public function add() {
        // 新規にpostを保存
        $this->Post->create();
        $postDatum = ['Article' => ['title' => 'title_4', 'author' => 'user_4', 'body' => 'bodybodybody', 'created' => date('Y-m-d H:i:s')]];
        $this->Post->save($postDatum);
}

これに対して、3.x系では以下のような書き方になります。

public function index() {
        // postの一覧を最新順に取得
        $postsQuery = $this->PostsTable->find('all')->orderDesc('created');
        $posts = $postsQuery->all()->toList();
}

public function add() {
        // 新規にpostを保存
        $postDatum = ['title' => 'title_4', 'author' => 'user_4', 'body' => 'bodybodybody', 'created' => time()];
        $post = $this->PostsTable->newEntity($postDatum);
        $this->PostsTable->save($post);
}

おぉ、行数増えてますね・・
まず目につく違いは、

  1. find() の結果が、配列ではなくなったの?
  2. find()した結果に対して行う all() とは?
  3. all() した結果に対して行う toList() とは?
  4. $Table->newEntity()とは何を行うのか?

といった点ですかね。
・・・何だか、「ただデータベースからコンテンツをフェッチしたい」「ごく単純にデータを保存したい」という需要に対しては「いささか冗長になった」という印象もあります。
なぜなのでしょう。その謎を探ってみます。

「複雑」にした理由とは? / する必要があったのか

CakePHP3では、ORMが複数のレイヤーから成り立っています。
具体的には、 DataSource,Driver/Table/Query/ResultSet/Entityというレイヤーに分割されることとなりました。
2.xでは DataSource,Driver/Model という構成だったため、かなり多層化したと言えるのではないでしょうか。

この背景となる課題意識は、 Mark Story氏の「Model api changes」という提起に源流があります。 github.com

この内、先述のサンプルから見てとれる状況に焦点を当てて関連を考えると

  1. (モデルの指すところが)レコードなのかテーブルなのか、の区別ができない
  2. クエリを扱えるオブジェクトがなく、すなわち「全て配列で内容を指示する」こととなっている。結果、制約や限界がある
  3. 「レコード」に相当するクラスがなく、コンテンツの整形を困難たらしめている

辺りが「解決されていそうだな」と言えるでしょうか。

データベースとPHPの仲介者としての「Entity」

上記の1-3の課題を抽象化すると、「"データ"の表現力の乏しさ」なのかと思います。

  1. 「レコード」の表現力不足
    • ユーザー(=実装者)側が、意識的に「データベースに直接渡せる形式」に変換をしなければならない。
    • すなわち、save()できるように全てのデータをスカラ値の集合に解いておく必要がある
  2. 「クエリ」の表現力不足
    • これもユーザーに対して「データベースに渡す前に直しておく」作業を強いる事となる
    • 「配列」形式での(一括)指定を強制される事が、柔軟な指定・改変のハードルを上げている

と感じます。
しかし、従来の「1つの大きなレイヤーで、テーブル・レコード・クエリの全てを一挙に担う」というデザインでは、これらを「上手くやり取りする」のは難しかったように思います。2
つまり、先の課題意識の1などは、それ自体が「問題」でありつつ、その他にも広い問題を引き起こす原因となっていたと言えそうです。そうした中で、レイヤーの複層化を以て責務の分離・明確化を行い、より専門化した各種APIを生やす!という結論に向かうのは自然なことではないでしょうか。
これでコーダーは「よりリッチな機能」を扱えるという恩恵を受けられることになります。

DataMapper

公式ドキュメントを参照すると、「データマッパーパターン」という言葉が出てきます。
これは正に、「データベースの構造と(プログラム内の)オブジェクトの組成が異なる」状況を描写し、「互いにおいてのデータのやり取りをうまくやる!」というパターンです3。 「レコード不在」の問題に対するアプローチとなりそうですね。
これがあると、ユーザーの関心を「オブジェクト」に集中させることができます。 結論として、CakePHP3においては「(データをマップして)翻訳を担うクラスを用意し、データベースとのやりとりはその実体を介して行う」方式となりました。
それが Entity です。

「データを翻訳する」役割を分離したことで 意識的に「データベースに直接渡せる形式」に変換 しなくて良い事になりました。

Entityの利用

データベースに保存をする時や或いはフェッチしてきた時に、モデルのユーザー(モデルクラスの各メソッドの呼び出し元)はEntityとしてデータのやり取りを行うことになります。 f:id:o0h:20170625214059p:plain

先程「翻訳」という表現を用いましたが、では実際には各フェーズにおいて「データ」はどのような変遷を辿るのでしょうか?
例として、「MySQL的にDATETIME型」なカラムを持つテーブルとのデータのやり取りをイメージしてみます。

  1. Request -> CakePHP: ブラウザからのリクエストとして値を渡されて
  2. CakePHP -> Entity: 値をモデル側に渡し
  3. Entity -> Database: データベースへ保存をし
  4. Database -> Entity: データベースから取り出す

という流れを示したのが以下の図です。

f:id:o0h:20170625214658p:plain

  1. Request -> CakePHP:「単純な文字列」として渡ってきて
  2. CakePHP -> Entity:「Entity化」が行われ、適応する型 = DateTimeInterfaceに変換し
  3. Entity -> Database: SQL文として扱うために再び「単純な文字列」として渡し(ちなみに、時刻部分が補完されました)
  4. Database -> Entity: 「文字列 => PHPのオブジェクト」への変換を以て「Entity化」が行われる

CakePHP2では、「スカラ値とその集合によるデータ」を「implodeしてSQL文に起こす」といった単純な仕事に留まっていたORMが、より高度にパス回しを行うようになっています!

Entityにおけるデータの出し入れと Type

「Entity化」される際に、渡されたデータが「文字列 => PHPのオブジェクト」へ変換されると述べました。
では、この「変換」の仕組みはどのようになっているのでしょう?
それを説明するのが Typeです。公式ドキュメント中ではデータの型というセクションで言及されています。
これはその名の通り、「受け取った値/渡す値を、どのような型で扱うか?」という判断を行う中枢です。
「受け取る」「渡す」というのは、先の例で言う2-4のフェーズで発生している内容を指します。

「変換」の実務を担うAPI

TypeInterfaceは、それぞれについて「どのように変換するか」を実装することを矯正しています。
公式ドキュメント4の表現を借りると、以下のメソッドが必要です。

  • toPHP: 与えられた値をデータベース型から PHP で等価な値にキャストします。
  • toDatabase: 与えられた値を PHP 型からデータベースで受け入れ可能な値にキャストします。
  • toStatement: 与えられた値をステートメントの型にキャストします。 5
  • marshal: フラットデータを PHP オブジェクトに変換します。

これらの内容を、先程の図に照らし合わせて示したのが以下の図です。

f:id:o0h:20170625221551p:plain

値 -> PHPへの変換を行うメソッド2種

このうち、toPHP()toStatement()は共に「値を、PHPで扱うための型に変更する」ものです。
例を挙げれば、(string)'1' を BOOLとして扱う場合に、 toPHP('1') === true marshal('1') === trueを返します。
どのような違いがあるのでしょう?私自身も、最初はこの区別が付きにくくて混乱していました。

考え方としては、「どこに由来するデータを変換し、後にその値を誰が扱うか?」という点が肝要です。
そのイメージを掴むのに、デフォルトで梱包されているJsonTypeが非常に助けになりました。その例として、次の図に各フェーズで生じる変換内容を示します。

f:id:o0h:20170626002337p:plain

marshal()の段階では、受け取った値をそのままEntityに埋め込みます。これは、(当然ながら)PHPとして受け取った値を、またPHPとして再利用するためです。
他方で、toPHP()はどうでしょう?これはデータベースから受け取った値を処理することになるので、すなわち直列化6された文字列を受け取ることとなります。しかし、PHPとして求めているのは構造化データです。そのため、受け取った値をデコードしてEntityに埋め込むのでした。

CakePHP2時代の目線で眺める、「Entity化」の威力

こうした処理は、CakePHP2式のORMであればどのように扱っていたでしょうか?
例えば「jsonにシリアライズしてデータベースに保持する」処理を賄うモデルクラスのコードを考えてみましょう。

// 構造化データの保存
$this->save(['content' => json_encode($data)]);

// 取り出し時の処理
public function afterFind($results, $primary = false) {
    foreach ($results as $i => $result) {
        if (isset($result['content']) && $result['content']) {
            $results[$i]['content'] - json_decode($result['content']);
        }
    }

    return $results;
}

ちょっとした「手間」のように感じてしまいます。 考えるのが1つのモデルだけならまだ良いのですが、jsonをやり取りするカラムが複数のモデルが現れると煩雑になりそうですね・・・
3.xでは、これらの処理をデータマッパーに「翻訳」の責務として担わせることで、ユーザーは普段は意識しなくて済むようになります。 先程、新しいORMシステムについて「(内容によっては)いささか冗長になった」と表現をしました。
しかしながら、この例を鑑みるに、これは素晴らしいカプセル化だという気がしませんか?

2.x時代には、テーブル/レコードのレイヤーが分離していなかったことで、1つのモデル(クラス)に「フェッチした結果のそれぞれに加工処理を入れる」という責任を与える他ありませんでした。
それが、Table/Entity/(ResultSet)の登場で「データの取得やその仕方について関心を持つ主体」と「個々の中身について関心を持つ主体」はハッキリと分離されるのです。

コードを追ってみての感想(みたいなもの)

書いてみたらメチャクチャ長くなりました・・・・・・ここまで読んでいただけておりましたら、ありがとうございます。

さて「複層レイヤーへの責務の分離」ですが、実際に自分で開発しながら「領域が区分されたことで、クラスの肥大化が自然と防がれるようになった」と感じます。
正直に申せば、「CakePHP」の初期学習コストは、相応に高くなったかな・・・?という感も否めません。しかしながら、こうした設計意図や背景にある課題意識、翻って「歴史」を知る事で、そのフレームワークの見据えているものや「ノリ」の習得に繋がるのかな?と思います。

先のエントリーでも触れましたが、直に「よりよくなった次世代のCakePHP」が来ます。
メジャーバージョンアップはコミュニティ全体で「今までのココが良くなかった」「他のWAFではこうしている」という議論が活発になる時期だと思います。
頭の片隅で、そうした意識高〜い情報摂取もしながら、日常の業務や自身のスキル強化に紐付けていきたいものだなぁと考えるのでした!


  1. 過去にQiitaにも晒した内容の焼き直しとなりました。社内的に「CakePHP3に触れる面子が増える」という事情があったので、改めて紹介しておくか〜と思ったのが動機です。

  2. この辺りは、Mark氏が述べているように「今見ると時代に取り残されてしまっている」のかな、と感じます。。
    引用: The current model class/system has served CakePHP well for the last 6 years, however its showing its age, and some of the decisions made in the past have aged poorly. There have been advancements in PHP as well, which allow the creation of a better data access layer.

  3. P of EAA: Data Mapper

  4. データベースの基本 / データの型 / 独自の型を作成する

  5. 本記事中では toStatementについては触れませんが、公式ドキュメント及び PHPをマニュアル PDO・定義済み定数を参照していただくとイメージが掴めるかと思います。

  6. ここで toStatement()PDO::PARAM_STRで規定されています