CakePHP
cakephp3
CakePHPDay 4

[CakePHP3]現場で使えるCollectionクラスの15選

CakePHP Advent Calendar 2017、第4日目です!
私は最近だとCake3を利用しているのですが、触っていた期間でいうとまだまだCake2の方が長い〜と思います。
なので、昨日のカスタムファインダーのエントリはとても興味深かったです・・・数えるほどしか使ってない(書いていない)機能だったので、改めて理解した上で機会があれば活用してみたいと思いました。

Collection の話をします!

さて、今回はCakePHP3の話になります。
もともとCake2からHashクラスは大好きなのですが、Collectionは同じく「集合をよしなに扱う便利クラス」になります。
ORM層に見られるように「プリミティブな連想配列を脱却した良さを分かち合おう」というCake3時代の思想を、色濃く感じることができる場面です。

ドキュメントはこちら: https://book.cakephp.org/3.0/ja/core-libraries/collections.html

こんな話をします

↓はdocのキャプチャなのですが、まぁたくさんのAPIが生えているのですね・・・
image.png
個人的には、使い込めば使い込むほど味のあるクラスだと思っています。食わず嫌いや「これ知らなかった〜!」というものがあったら、ちょっともったいないかもな?と。
また、「他のやり方もできる」ためにピンと来づらいかも?というものもあるのは確かです。

そこで、実際に業務で利用していて「これは確かによく使うな!」というものを、サンプルを交えつつ振り返ってみたいと思います。

:warning: 免責
前提として「ドキュメントがよく書かれている」と思うので、時間があるようならCollectionのページに一通り目を通してみることをおすすめします!
改めて、何か発見があるかもしれません。

  • 執筆当時の最新版である cakephp/cakephp:3.5.6 をベースに書いています
  • ピックアップしている内容は主観に基づくので、「このメソッドも便利なのに漏れている」「こんな使い方もある!」という内容はご容赦ください。
    • 編集リクエストは大歓迎です!!
  • 特にパフォーマンス面については、Collectionインスタンスに噛ませるデータ量によって影響が大きそうなので最適なやりかたを検討する必要があると思います
  • 分量的に膨らんでしまったので、本文中では「APIのサンプル」程度の事しか触れられていません・・・
    • 「具体的にプロダクトetcにどう使っていますか?」のような質問は、コメント等でいただけたらお答えできるかもです!

こんな人向け

  • もっと「Cake3っぽい書き方」をしたい!
  • プロダクトコードやテストコードをシュッとさせたい!
  • cakeを使っているわけではないが、配列/集合の操作を直感的に行いたい
    • cakephp/collection自体はスタンドアロンになっているので、cake3以外でも自由に利用することができます

本題

「使っているものを端から紹介していく!」とあまり差異がなくなってしまいそうだったので、今回は(無理やり)「15項目」に絞ってリストアップしていくことにしました。1
選ばれたのは以下のメソッドたちです!

  1. toList(), toArray()
  2. each()
  3. first() / last()
  4. match()
  5. filter() /reject
  6. stopWhen()
  7. take()
  8. sample()
  9. extract()
  10. map()
  11. groupBy(), indexBy()
  12. combine()
  13. isEmpty()
  14. some(), every()
  15. sortBy()
  16. +アルファ!

特に記述がなければ、サンプルには以下のデータを用います。

$c = new Collection([
    'a' => ['name' => 'Taro', 'pref' => 'Tokyo'],
    'b' => ['name' => 'Jiro', 'pref' => 'Saitama'],
    'c' => ['name' => 'Hanako', 'pref' => 'Saitama']
]);

全般編

toList(), toArray()

そういえば、これらは「メソッド一覧」に載っていないのですね・・非常によく利用します。

内部的にはiterator_to_arrayを呼んでいるもので、「コレクションを連想配列やリストに変換する」ものです。
この両者なら、元々のインデックスを保持したい!という強い意図がある場合を除き、 toList() を利用するのが良いと思っています。
「配列を一時的にCollection化、groupByやmapを使った加工を行い、そのままmethod chainで再度配列に」なんて時にも有用に思います。

>>> $c->toList()
=> [
     [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
     [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]
>>> $c->toArray()
=> [
     "a" => [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     "b" => [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
     "c" => [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

テーブルからfindした結果を配列にする、なんてときにも見かけるでしょうか。
余談ですが、 Table::find() が返す Query 型は元をたどると IteratorAggregate も実装しているのでそのまま foreachなどのイテレータが必要な処理を行うこともできます。また、queryをそのままmap()処理にかけるなんてことも可能です。

$this->Table->find()
    ->map($func)
    ->toList();

each()

要素に対して反復処理を行います。 foreach と異なり、引数は「1つ目がvalue, 2つ目がkey」となります。keyを利用しないのであれば、第2引数は省略してしまいましょう。
反復処理ということで、あとで紹介する map() と似たような動きをしますがeach()は 新しいCollectionを返しません 。 つまりオリジナルの値に対しての処理をメソッドチェインで連ねることも可能です(そういうニーズがあれば・・)

>>> $c->each(function ($v, $k) {return printf('%s-%s', $v['name'], $k) . PHP_EOL;})->toList()
Taro-aJiro-bHanako-c
=> [
     [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
     [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

抽出・フィルター編

「コレクション中の特定の要素を抜き出す」という類のものです。

first() / last()

その名の通り、「最初(最後)の要素を抜き出す」ためのものです。

>>> $c->first()
=> [
     "name" => "Taro",
     "pref" => "Tokyo",
   ]
>>> $c->last()
=> [
     "name" => "Hanako",
     "pref" => "Saitama",
   ]

match()

特定の要素を持つものだけを通す、というものです。

>>> $c->match(['pref' => 'Saitama'])->toArray()
=> [
     "b" => [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
     "c" => [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

filter() /reject

filtermatch() よりも高度な処理を行うフィルタリングです。
rejectfilter の逆で、「合致したものを弾く」処理を行います。

match, filter, rejectは少し似たものいなりますが、覚えるのが大変!!であれば、filterだけ利用しても良いかもしれません。

>>> $f = function ($item) { return strpos($item['name'], 'ro') !== false;};
=> Closure {#889
     parameters: {
       $item: {},
     },
   }
>>> $c->filter ($f)->toArray()
=> [
     "a" => [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     "b" => [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
   ]
>>> $c->reject ($f)->toArray()
=> [
     "c" => [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

stopWhen()

「指定した条件を満たす要素」が見つかる手前までの要素を取り出します。

// cake内にて、backtraceから「initializeの手前」までを取り出して
// 「呼び出されたアクション」を抜き出している処理です
$traceMap = collection(debug_backtrace());
$isInitializeStack = function ($item, $key) {
    return (
        $item['function'] === 'initialize' &&
        $item['object'] === $this
    );
};
$afterInitialized = $traceMap->stopWhen($isInitializeStack);
$calledBy = $traceMap->take(1, $afterInitialized->buffered()->count() + 1);

buffered() を呼び出していることに注意してください。 stopWhen()をした結果は、そのままではcountする事ができません。

take()

指定した個数を先頭から取り出すという処理です。

>>> $c->take(2)->toList()
=> [
     [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
   ]

sample()

指定した個数を、ランダムに取り出すという処理です。
データベースから複数のレコードを取ってきて、ビューに合わせて「適当に3個だけ出すぞ!!」という処理の時に利用したりしました。

>>> $c->sample(1)->extract('name')->toList()
=> [
     "Jiro",
   ]
>>> $c->sample(2)->extract('name')->toList()
=> [
     "Taro",
     "Jiro",
   ]

同様の処理は、shuffle() + take() で実現可能です。

フォーマット編

もともとのデータを加工したり、構造自体を変更するものです。

extract()

extractというと、Cake的には Hash::extract() を思い出すのですが、「各要素の指定したフィールドを抽出する」ものです。

$items = [
    ['price' => 500, 'name' => 'apple'],
    ['price' => 1000, 'name' => 'grape']
];
collection($items)
    ->extract('price')
    ->toList();
=> [
     500,
     1000,
   ]

ただし、「stringでmatcherを指定するもの」となり、Entity::extractのように配列を取れないことに注意してください。
(私はたまに混乱するので・・・)

// coreコードのphpdocから抜粋
$items = [                                                                                                                                                                                                    
    ['comment' => ['votes' => [ ['value' => 1], ['value' => 2], ['value' => 3] ] ]],                                                                                                                            
    ['comment' => ['votes' => [ ['value' => 4] ] ] ]                                                                                                                                                      ];
(new Collection($items))->extract('comment.votes.{*}.value')->toList();
=> [
     1,
     2,
     3,
     4,
   ]

map()

名前の通り、map処理です。

>>> $c = collection(['a', 'b', 'c'])
>>> $c->map(function($v, $k) { return printf('%d-%s' . PHP_EOL, $k, $v);})->toArray()
0-a
1-b
2-c
=> [
     4,
     4,
     4,
   ]

eachと異なり、「加工されたデータを受け取れている」ということに注目してください。(printfの返り値は出力文字列の長さです)

ドキュメントの表現を用いれば「コールバックの出力に基づいて 新しいコレクションを作成します。」という事になります。

groupBy(), indexBy()

「特定の要素で集約する」というものです。 Hash::combine()で第3引数指定でやっていたアレ・・!みたいな感じでしょうか。
両者の違いは、新たに作成するcollectionにおいて「そのkeyのvalueを単一にできるか否か」です。返されるvalueが単一の要素になっているか、要素の配列になっているかに注目してください。
例えば、例に上げているケースではSaitamaにはJiroとHanakoがいますが、indexByを利用するとJiroが見当たらなくなってしまいます。

>>> $c->groupBy('pref')->toArray()
=> [
     "Tokyo" => [
       [
         "name" => "Taro",
         "pref" => "Tokyo",
       ],
     ],
     "Saitama" => [
       [
         "name" => "Jiro",
         "pref" => "Saitama",
       ],
       [
         "name" => "Hanako",
         "pref" => "Saitama",
       ],
     ],
   ]
>>> $c->indexBy('pref')->toArray()
=> [
     "Tokyo" => [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     "Saitama" => [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

パスの指定には、クロージャを用いることもできます。

combine()

combine処理も健在です!「キーとバリューのパスを指定して、返す」ものです。
それぞれ、クロージャを用いることもできます。

>>> $c->combine('name', 'pref', function($i) { return $i['name'][0];} )->toArray()
=> [
     "T" => [
       "Taro" => "Tokyo",
     ],
     "J" => [
       "Jiro" => "Saitama",
     ],
     "H" => [
       "Hanako" => "Saitama",
     ],
   ]

検査編

コレクションに対して、ある条件を満たすか?という検査を行うものです。
特にテストを書く時には、この部類の書き方を知っているとコードがスッキリするなという体感があります。

isEmpty()

「要素がない」ことを検査します。

$this->post('/users/leave');
$subject = $this->Article->findByUserId($user->id);
$actual = $subject->isEmpty();
$this->assertTrue($actual, '退会したユーザーの記事が削除されていない');

some(), every()

some()は「1つでも含まれているか」、 every()は「すべてが条件に当てはまっているか」を検査します。

$subject = $this->Players->findByTeamId($myTeam->id);
$actual = $subject->some(function ($player) {
    return $player->is_camptain;
});
$this->assertTrue($actual, 'チーム内に1人もキャプテンが1人も含まれていない');
$subject = $this->Books->getNewArrival();
$actual = $subject->every(function ($book) {
    return $book->is_new;
});
$this->assertTrue($actual, '新着商品でないものが含まれている');

ソート編

コレクションの並び替えを行います。

sortBy()

要素を並び替えます。
クロージャを取ることももちろん可能ですが、その際には(usort等と異なり)「絶対位置」を返すことに留意してください。(-1, 0, 1を返すのではないのです・・)

>>> $c->sortBy('name', SORT_DESC)->toList()
=> [
     [
       "name" => "Taro",
       "pref" => "Tokyo",
     ],
     [
       "name" => "Jiro",
       "pref" => "Saitama",
     ],
     [
       "name" => "Hanako",
       "pref" => "Saitama",
     ],
   ]

shuffle()

要素をランダムに並び替えます

>>> $c->shuffle()->extract('name')->toList()
=> [
     "Hanako",
     "Taro",
     "Jiro",
   ]
>>> $c->shuffle()->extract('name')->toList()
=> [
     "Taro",
     "Hanako",
     "Jiro",
   ]
>>> $c->shuffle()->extract('name')->toList()
=> [
     "Taro",
     "Jiro",
     "Hanako",
   ]

その他

メソッドの紹介ではないのですが(ゆえに15個のうちに含んでいません><)、Collectionを活用していく上で知っておいて損はなさそう〜という知識です。

collection() 関数

source: https://github.com/cakephp/cakephp/blob/3.5.6/src/Collection/functions.php#L24

ヘルパー関数です。配列やIteratorオブジェクトを、どこからでも気軽に生成することができます!
フィルタリング処理やソート処理をリッチに行いたい時など、Arrayを一旦Collectionに変換して〜というように使ったりします。
もしくは、Utility系のクラスやデータを加工する類のメソッドを作ったときに、配列ではなくcollection()で返すようにしているものもあります。その際には地味に便利です。

>>> collection([1, 2, 3])
=> Cake\Collection\Collection {#270
     +"count": 3,
     innerIterator: ArrayIterator {#272
       +0: 1,
       +1: 2,
       +2: 3,
     },
   }

Traitを使って「Collection的な機能」は簡単に作れる

Collectionの機能は、実は大体Traitに切り出されています。 source
ResultSet なども、このTraitを利用して実現されているものです。
もし「いい感じに集合を扱いたい!!」というクラスを作成したいときは、継承ではなくTraitで実現できるので、Collection機能の活用も検討してみてはいかがでしょうか。

処理の切り出しについて

コレクションメソッドの再利用に記載されている内容は、コードを短く・凝集性を保ったり単体テストを作りやすくするのに役立つ内容だと思います。
Collection操作は。主にTabelや場合によってはControllerに置かれることが多いと思いますが、例えば「並び替え」や「フィルタリング」の処理に行数を割いてしまうと、そればっかりが目立ってしまい本来の責務について見通しの悪い・・本質的でない部分で存在感をに漂わせるコードになってしまいがちです。

__invoke() を実装したクラスに別出しすることで、容易に「コードを薄くする」ことが可能になります。
例えば「投稿一覧ページにおいて、最近の注目度の高い投稿が含まれていたら優先的に表示する」といったような処理をする時。PostsTablegetHomeFeed() という粒度でメソッドを生やしたとします。
純然と新着順に並んでいるフィード(collection)を引数として受け取り、

  1. 投稿にぶら下がっているアクティビティを取得
  2. そのうちの、最近のものに限定
  3. 「最近の注目度」の重みを計算する
  4. 算出した重みと、投稿自体の日時から最終的な重みを計算
  5. 重み順に並び替える

・・・ というような複数段階からなる処理を、「投稿一覧取得」メソッドの内部にインラインで書けば、どうしても行数がかさばることは免れません。
これに対して、 表示優先度計算機 たるクラスを別に定義しておけば

$feed = $this->getRecentPosts()
    ->map(new PostDisplayPriorityCalculator)
    ->sortBy('priority', 'DESC');

くらいの厚さで記述することが可能で、汚れにくくなります。
また、「優先度をちゃんと計算できているか」という単体テストを行いやすくなるので、テスタブルともいえるでしょう。

まとめ

Collectionは、本当に便利でいつも助けられています!
そして、これらは実装されている機能のほんの一部です。実際に自分の関わっているPJでは、もっと派手にCollectionの機能が使われています
ピックアップした15項目の中でも働きが重複するようなAPIがあり、今回言及していないものも含めると「細かくて伝わりにくい」ようなものもあります。なので、必ずしも「全部覚えて使いこなすぞ!」という気概は必要ないと思います。あくまで「イディオムの1つ」くらいの割り切り方をしながら、自身やチームのナレッジとして温度感を揃えることで「便利な書き方で、コードをスマートにできる」ことが大事ではないでしょうか。きっと、「直感的なコード」を書くための強力な助っ人になると思います!

明日は @gorogoroyasu さんのエントリーです・・・っ!


  1. 「15 methods」ではないのは、似たような処理、逆の処理を同一の項目として数えているからです。