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

Ray.Di を知っていますか?
私は普段の開発ではSymfonyを利用しているので、Symfonyの DependencyInjection 機能を使っていますが、最近Symfonyフレームワークを使うほどでない小さなcliアプリで実際に使ってみました。初めて使う場合に戸惑うところ・引っかかったところがあるので、使い方をまとめました。

Ray.Diとは

Javaの Guice というDIフレームワークの主要な機能を網羅したPHPのDIフレームワークです。
PHPフレームワークの BEAR.Sunday で使われていますが、単独でも利用できます。
BEAR.Sundayで使う場合は専用のアノテーションを使うことが多いのですが、アノテーションを使わずにPHPコードだけでも設定できるので、サードパーティのクラスを使う場合でも問題なく利用することができます。

Ray.Diを使う

早速使ってみましょう。

例として時間帯によって違う挨拶を返すcliアプリを作ってみることにします。
環境変数 lang によって言語を切り替える機能がついています。

# 朝9時に実行
$ php scripts/run.php 77web
Good morning, 77web.

DIを使わずに書いてみると、アプリケーションの入口になる scripts/run.php が依存関係のコードで雑然としていますね。
https://github.com/77web/ray-di-sample/tree/no-di/scripts/run.php

#!/usr/bin/env php
<?php

use PHPMentors\DomainCommons\DateTime\Clock;
use Psr\Log\NullLogger;
use Quartetcom\RayDiSample\Command\GreetingCommand;
use Quartetcom\RayDiSample\Greeter;
use Quartetcom\RayDiSample\GreetingFormatter;
use Quartetcom\RayDiSample\GreetingRegistry;
use Quartetcom\RayDiSample\TimeDetector;
use Symfony\Component\Console\Application;
use Symfony\Component\Yaml\Yaml;

require __DIR__.'/../vendor/autoload.php';

ini_set('date.timezone', 'Asia/Tokyo');
if (!getenv('lang')) {
    putenv('lang=de');
}

$timeDetector = TimeDetector::createFromConfig([
    'morning' => range(4, 10),
    'day' => range(11, 15),
    'night' => array_merge(range(0, 3), range(16, 23)),
]);
$greetings = Yaml::parseFile(__DIR__.'/../src/Resources/config/greetings.yml')['greetings'];
$lang = getenv('lang');
$greetRegistry = new GreetingRegistry($greetings[$lang]);
$greeter = new Greeter($greetRegistry, $timeDetector, new GreetingFormatter('%greeting%, %name%.'), new Clock());
$command = new GreetingCommand($greeter, new NullLogger());

$app = new Application();
$app->add($command);
$app->setDefaultCommand($command->getName(), true);

$app->run();

Ray.Diを使って書き直していきます。

composerでray/diを入れる

composerで追加します。

$ composer require ray/di

Moduleクラスを作る

DI設定をするためのModuleクラスを作ります。
Ray\Di\AbstractModule クラスのサブクラスにする必要があります。

<?php
namespace Quartetcom\RayDiSample\DependencyInjection;

use Ray\Di\AbstractModule;

class GreeterModule extends AbstractModule
{
}

DIを設定する

いよいよ GreeterModule に実際にDIの設定を書いていきましょう。
AbstractModule には抽象メソッドとして configure() メソッドが定義されており、実際のDI設定は GreeterModule::configure() メソッドに記述します。

<?php
// ...

class GreeterModule extends AbstractModule
{
+    public function configure()
+    {
+    }
}

コンストラクタ引数がない、インターフェイスで指定されたクラスの設定 to()

一番基本の書き方です。
bind() にインターフェイス名、 to() に実際に利用する具象クラス名を指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(LoggerInterface::class)->to(NullLogger::class);
    }
}

コンストラクタ引数がない具象クラスの設定 bind()

bind() にクラス名を指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(Clock::class);
    }
}

コンストラクタで他のクラスのインスタンスだけを注入される具象クラスの設定 bind()

依存クラスをすべて設定した上で、 bind() にクラス名を指定します。
設定を記述する順序は自由なので、前後しても大丈夫です。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(LoggerInterface::class)->to(NullLogger::class);
+        $this->bind(GreetingCommand::class); // GreeterとLoggerInterfaceに依存
+        $this->bind(Greeter::class); // 依存対象を後に書いても良い
    }
}

独自のファクトリが存在するクラス toInstance()

toInstance() を使います。
bind() にクラス名を指定し、 toInstance() にファクトリで作成した実際のクラスのインスタンスを指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(TimeDetector::class)->toInstance(TimeDetector::createFromConfig([
+            'morning' => range(4, 10),
+            'day' => range(11, 15),
+            'night' => array_merge(range(0, 3), range(16, 23)),
+        ]));
    }
}

コンストラクタでパラメータ(クラスでなく単なる値)を注入されるクラスの設定 toConstructor()

bind()toConstructor() の両方にクラス名を指定し、 toConstructor() の第2引数でコンストラクタ引数の変数名と仮想クラス名(パラメータ名)の対応を指定します。
実際に渡したい値は、 annotatedWith() に仮想クラス名を指定し、さきほどの toInstance() を使って設定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(GreetingFormatter::class)->toConstructor(GreetingFormatter::class, [
+            'format' => 'greeting_formatter_format',
+        ]);
+        $this->bind()->annotatedWith('greeting_formatter_format')->toInstance('%greeting%, %name%.');
    }
}

使うインスタンスを切り替える

toProvider() を使います。 まず、Ray\Di\ProviderInterface を実装した GreetingRegistryProvider クラスを追加します。
Providerは、引数なしの get() メソッドから目的のクラスのインスタンスを返すように実装します。

<?php

namespace Quartetcom\RayDiSample\DependencyInjection;

use Quartetcom\RayDiSample\GreetingRegistry;
use Ray\Di\Di\Named;
use Ray\Di\ProviderInterface;
use Ray\Di\SetContextInterface;

class GreetingRegistryProvider implements ProviderInterface, SetContextInterface
{
    /**
     * @var array
     */
    private $greetings;

    /**
     * @var string
     */
    private $context;

    /**
     * Providerクラスに何かの値をDIしたい場合は@Namedアノテーションが必要
     *
     * @Named("greetings=greeting_registry_greetings")
     * @param array $greetings
     */
    public function __construct(array $greetings)
    {
        $this->greetings = $greetings;
    }

    /**
     * @param string $context
     */
    public function setContext($context)
    {
        $this->context = $context;
    }

    /**
     * @return GreetingRegistry
     */
    public function get()
    {
        return new GreetingRegistry($this->greetings[$this->context]);
    }
}

注意すべきなのはProviderクラスに何かの値をDIしたい場合は @Named アノテーションを使う必要があるということです。 @Named アノテーションは通常、同じクラスのインスタンスを複数使用する場合などにDI対象を特定するために使うものです。通常のクラスなら annotatedWith() を用いてModule側で指定可能ですが、Providerには @Named を使って指定しないといけないようです。
ソースコードにDIのための概念が入ってしまいますが、ProviderはModuleと同様にRay.Diを使うのをやめた場合は使わないクラスなので問題ないでしょう。

Providerクラスができたら、GreeterModuleでは bind() にクラス名を指定し、 プロバイダークラス名を toProvider() に指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(GreetingRegistry::class)->toProvider(GreetingRegistryProvider::class, getenv('lang')); // getenv('lang')はGreetingRegistryProvider::$contextになる
+        $this->bind()->annotatedWith('greeting_registry_greetings')->toInstance(Yaml::parseFile(__DIR__.'/../Resources/config/greetings.yml')['greetings']);
    }
}

注入されたクラスを実際に使う

最終的な GreeterModule::configure() は下記のようになりました。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
        $this->bind(TimeDetector::class)->toInstance(TimeDetector::createFromConfig([
            'morning' => range(4, 10),
            'day' => range(11, 15),
            'night' => array_merge(range(0, 3), range(16, 23)),
        ]));
        $this->bind(Clock::class);
        $this->bind(Greeter::class);
        $this->bind(GreetingFormatter::class)->toConstructor(GreetingFormatter::class, [
            'format' => 'greeting_formatter_format',
        ]);
        $this->bind()->annotatedWith('greeting_formatter_format')->toInstance('%greeting%, %name%.');
        $this->bind(GreetingRegistry::class)->toProvider(GreetingRegistryProvider::class, getenv('lang'));
        $this->bind()->annotatedWith('greeting_registry_greetings')->toInstance(Yaml::parseFile(__DIR__.'/../Resources/config/greetings.yml')['greetings']);
        $this->bind(LoggerInterface::class)->to(NullLogger::class);
        $this->bind(GreetingCommand::class);
    }
}

では、 scripts/run.php で、DI設定に従って GreetingCommand のインスタンスを取得してみましょう。

<?php

// ...

-$timeDetector = TimeDetector::createFromConfig([
-    'morning' => range(4, 10),
-    'day' => range(11, 15),
-    'night' => array_merge(range(0, 3), range(16, 23)),
-]);
-$greetings = Yaml::parseFile(__DIR__.'/../src/Resources/config/greetings.yml')['greetings'];
-$lang = getenv('lang');
-$greetRegistry = new GreetingRegistry($greetings[$lang]);
-$greeter = new Greeter($greetRegistry, $timeDetector, new GreetingFormatter('%greeting%, %name%.'), new Clock());
-$command = new GreetingCommand($greeter, new NullLogger());

+/** @var GreeterCommand $command */
+$command = new Injector(new GreeterModule())->getInstance(GreetCommand::class);

// ...

DIなし に比べて Ray.Diを使った版 はスッキリしましたね!
編集したのは 3ファイルだけ なので簡単に導入できました。

まとめ

Ray.Diを使うことで scripts/run.php での処理の流れは圧倒的に追いやすくなりました。
ライブラリとして実装したクラスに全くDI専用のアノテーションを書かなくても使えるので、Ray.Di用のModule(とProvider)を追加するだけで気軽に試すことができますし、他のDIフレームワークへの切り替えも楽にできそうです。


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

最初に思いついたやり方

image

やってみた

今回は Symfony4 で実装した例を示しますが、コンセプトは Symfony2/3 でもまったく同じなので、適宜読み替えてください :pray:

エンティティ

<?php
namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

class User
{
    // ...

    /**
     * @var Collection|User[]
     *
     * @ORM\ManyToMany(targetEntity="User", mappedBy="followees")
     */
    public $followers;
    
    /**
     * @var Collection|User[]
     *
     * @ORM\ManyToMany(targetEntity="User", inversedBy="followers")
     * @ORM\JoinTable(
     *     name="followers_followees",
     *     joinColumns={@ORM\JoinColumn(name="follower_id", referencedColumnName="id")},
     *     inverseJoinColumns={@ORM\JoinColumn(name="followee_id", referencedColumnName="id")}
     * )
     */
    public $followees;

    public function __construct()
    {
        $this->followers = new ArrayCollection();
        $this->followees = new ArrayCollection();
    }

    public function follow(User $user): self
    {
        if ($user === $this) {
            throw new \LogicException('自分自身をフォローすることは出来ません。');
        }
        
        $this->followees->add($user);
        $user->followers->add($this);

        return $this;
    }

    public function unfollow(User $user): self
    {
        $this->followees->removeElement($user);
        $user->followers->removeElement($this);
        
        return $this;
    }

    public function __toString()
    {
        return $this->username;
    }

    // ...
}

コントローラー

<?php
namespace App\Controller;

use App\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

/**
 * @Route("/user", name="user_")
 */
class UserController extends Controller
{
    // ...

    /**
     * @Route("/follow/{username}", name="follow")
     */
    public function follow(User $user, Request $request)
    {
        $this->getUser()->follow($user);

        $em = $this->getDoctrine()->getManager();

        // フォローする側もされる側も参照している中間テーブルのレコードは同じなので、片方だけ persist すればOK
        $em->persist($this->getUser());
        $em->flush();
        
        return $this->redirect($request->headers->get('referer'));
    }

    /**
     * @Route("/unfollow/{username}", name="unfollow")
     */
    public function unfollow(User $user, Request $request)
    {
        $this->getUser()->unfollow($user);

        $em = $this->getDoctrine()->getManager();

        // フォローする側もされる側も参照している中間テーブルのレコードは同じなので、片方だけ persist すればOK
        $em->persist($this->getUser());
        $em->flush();

        return $this->redirect($request->headers->get('referer'));
    }

    // ...
}

ビュー(フォロワー一覧を表示する例)

<ul>
    {% for follower in app.user.followers %}
        <li>{{ follower }}</li>
    {% else %}
        <li>フォロワーはまだいません。</li>
    {% endfor %}
</ul>

起こった問題

  • 中間テーブルは Doctrine が自動生成するので、フォローし始めた日時などの付帯情報を持たせることができない
  • 一般的には、フォロワー一覧画面はフォローし始めた日時の降順で並んでいることが多いので、そういう実装にしたい

解決策

  • ManyToMany をやめて、 User - 中間エンティティ - User の OneToMany - ManyToOne という構造にする
  • 中間エンティティにフォローし始めた日時(つまり中間エンティティの createdAt)を持たせる

image

やってみた

中間エンティティ

<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Model\Timestampable\Timestampable;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRelationRepository")
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="unique_follower_followee", columns={"follower_id", "followee_id"})})
 */
class UserRelation
{
    use Timestampable;  // 今回は createdAt, updatedAt の記録はこの Trait に任せる

    // ...
    
    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="followeeRelations")
     * @ORM\JoinColumn(name="follower_id", referencedColumnName="id")
     */
    public $follower;

    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="followerRelations")
     * @ORM\JoinColumn(name="followee_id", referencedColumnName="id")
     */
    public $followee;

    public function __construct(User $follower, User $followee)
    {
        $this->follower = $follower;
        $this->followee = $followee;
    }

    // ...
}

解説

  • ここでは createdAt の記録に KnpLabs/DoctrineBehaviors を使っています
  • $follower に「フォローしている側の User」、 $followee に「フォローされている側の User」が入ります
  • $follower $followee の組み合わせに対して複合ユニーク制約を定義しています(クラスの @ORM\Table アノテーションで設定しています)
  • ManyToOne の設定内容は 公式ドキュメントに書かれている とおりです

User エンティティ

    // ...

    /**
-    * @var Collection|User[]
+    * @var Collection|UserRelation[]
*
-    * @ORM\ManyToMany(targetEntity="User", mappedBy="followees")
+    * @ORM\OneToMany(targetEntity="UserRelation", mappedBy="followee", cascade={"persist"}, orphanRemoval=true)
+    * @ORM\OrderBy({"createdAt"="DESC"})
     */
-   public $followers;
+   public $followerRelations;

    /**
-    * @var Collection|User[]
+    * @var Collection|UserRelation[]
     *
-    * @ORM\ManyToMany(targetEntity="User", inversedBy="followers")
-    * @ORM\JoinTable(
-    *     name="followers_followees",
-    *     joinColumns={@ORM\JoinColumn(name="follower_id", referencedColumnName="id")},
-    *     inverseJoinColumns={@ORM\JoinColumn(name="followee_id", referencedColumnName="id")}
-    * )
+    * @ORM\OneToMany(targetEntity="UserRelation", mappedBy="follower", cascade={"persist"}, orphanRemoval=true)
+    * @ORM\OrderBy({"createdAt"="DESC"})
     */
-   public $followees;
+   public $followeeRelations;

    // ...

    public function __construct()
    {
-       $this->followers = new ArrayCollection();
-       $this->followees = new ArrayCollection();
+       $this->followerRelations = new ArrayCollection();
+       $this->followeeRelations = new ArrayCollection();
    }

    public function follow(User $user): self
    {
        if ($user === $this) {
            throw new \LogicException('自分自身をフォローすることは出来ません。');
        }

-       $this->followees->add($user);
-       $user->followers->add($this);
+       $this->followeeRelations->add($relation = new UserRelation($this, $user));
+       $user->followerRelations->add($relation);

        return $this;
    }

    public function unfollow(User $user): self
    {
-       $this->followees->removeElement($user);
-       $user->followers->removeElement($this);
+       foreach ($this->followeeRelations as $followeeRelation) {
+           if ($followeeRelation->followee === $user) {
+               $this->followeeRelations->removeElement($followeeRelation);
+               break;
+           }
+       }
+    
+       foreach ($user->followerRelations as $followerRelation) {
+           if ($followerRelation->follower === $this) {
+               $user->followerRelations->removeElement($followerRelation);
+               break;
+           }
+       }

        return $this;
    }

+   /**
+    * @return User[]
+    */
+   public function getFollowers(): array
+   {
+       return $this->followerRelations->map(function (UserRelation $userRelation) {
+           return $userRelation->follower;
+       })->toArray();
+   }
+
+   /**
+    * @return User[]
+    */
+   public function getFollowees(): array
+   {
+       return $this->followeeRelations->map(function (UserRelation $userRelation) {
+           return $userRelation->followee;
+       })->toArray();
+   }

    // ...

解説

User と UserRelation の関係

  • $followers $followees の代わりに $followerRelations $followeeRelations という中間テーブルのコレクションを持たせました
  • @ORM\OrderBy アノテーションUserRelation::createdAt の降順で取得するようにしています

cascade の設定

  • @ORM\OneToMany アノテーションの中で、 cascade={"persist"}orphanRemoval=true を設定しています
  • cascade={"persist"} によって、User を persist したときに一緒に UserRelation も persist されるようになります
    • なので、コントローラーのコードには UserRelation を persist するコードを追記する必要はなく、先ほどのコードから変更はありません
  • orphanRemoval=true によって、User::$followerRelationsUser::$followeeRelations から UserRelation のインスタンスが削除された(関係が断たれて宙ぶらりんになった)ときに、当該の UserRelation が remove されるようになります

cascade の設定は followerRelations/followeeRelations 両方に必要?

  • $followerRelations$followeeRelations の両方に cascade={"persist"}orphanRemoval=true を設定しています
  • 今回のようにコントローラーが「フォローする側(される側ではなく)の User」「フォロー解除する側(される側ではなく)の User」だけを persist するようなコードになっている場合、$followerRelations にだけ設定してあれば問題なく動作します
  • ここでは、逆のコードが出てきた場合に備えて一応両方に設定してあります

followers/followees へのアクシビリティ

  • フォロワーやフォロイーのリストを外部に公開するために、getFollowers() getFollowees() というメソッドを作ってあります
    • これにより、ビューのコードには変更がありません

コントローラー

変更なし。

先述のとおり、User エンティティを変更するときに cascade 設定によって UserRelation エンティティも適切に変更・削除されるため、コントローラーは UserRelation エンティティの存在を意識する必要がありません。

ビュー

変更なし。

先述のとおり、User エンティティに getFollowers() getFollowees() を実装しておいたので、ビューからはあたかも $followers $followees にアクセスするかのようにフォロワーリスト・フォロイーリストを取得できます。

サンプルコードとライブデモ

サンプルコードとライブデモを用意してみたので、ぜひコードを確認したり手元で動かしたりしてみてください!

:point_right: サンプルコード
https://github.com/ttskch/symfony4-doctrine2-user-following-feature-sample

:point_right: ライブデモ
http://symfony4-doctrine2-user-follow.herokuapp.com

おわりに

ググっても意外と日本語の情報がなさそうだったので書いてみました。 もっとエレガントな方法あるよ!という方がいらっしゃいましたらぜひ @ttskch までコメントをお寄せください :pray:


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

カルテット開発部の後藤です。 PHPerKaigi 2018 にスピーカーとして参加してきました。そしてなんと、参加者の皆様からの投票の結果、ベストトーク賞を頂きました!

image tweet

発表を聞いてくださった方、投票してくださった方、そして素晴らしいカンファレンスを企画・運営してくださったスタッフの皆様、本当にありがとうございました!

スライドの公開とKeynoteファイルの配布

発表に使ったスライド(公開用に一部修正したバージョン)は、以下です。

また、トークスクリプトも含めて読んでみたい、自分たちの勉強会で使いたいという方向けに、Keynoteファイルも配布します。

クリエイティブ・コモンズ BY-ND(表示 - 改変禁止 4.0 国際)ライセンスの範囲でご自由にお使いください。

コード設計に興味を持たれた方へ、オススメ過去記事

謝辞

未完成段階のトーク練習に付き合い、様々な意見をくれたシステム開発部の同僚らの協力なくして、このトークは完成しませんでした。ありがとう!

次回

PHPカンファレンス(福岡|関西|東京)でお会いしましょう!


カルテット開発部では、コード設計に興味のある仲間を募集しています!