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

Symfony Advent Calendar 2018 23日目の記事です!

メリークリスマスイブイブ!下田です。

今日は僕の大好きな機能の一つである Doctrine Criteria について書きたいと思います。

Doctrine Criteriaとは

ざっくり言うと、検索条件のみを表現できるオブジェクトです。

細かい粒度で作成したCriteriaを使いまわすことで、複雑に入り組んだ絞り込みを表現しやすくしたり、機能の追加や変更に強くすることができます。

ここではシンプルなブログ投稿サービスを構築中であるという例のもと、いろいろな要件が追加になった際に、Criteriaを使わないパターン、またCriteriaを適用するとどう書けるのかを示したいと思います。

使いドコロの例

前提

「投稿」を表す Post エンティティが次のような形であるとします。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * 投稿
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * 投稿タイトル
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * 投稿本文
     * @ORM\Column(type="text")
     */
    private $body;

}

タイトル・本文だけの状態です。シンプルですね。

記事一覧を表示するControllerのアクションはこんな感じ

<?php

namespace App\Controller;

use App\Repository\PostRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/post")
 */
class PostController extends AbstractController
{
    /**
     * @Route("/", name="post_index", methods="GET")
     */
    public function index(PostRepository $postRepository): Response
    {
        return $this->render('post/index.html.twig', ['posts' => $postRepository->findAll()]);
    }
}

現在は特に絞り込みを行わず、 $postRepository->findAll() で全ての投稿を取得しています。

要件その1:投稿の公開期間を絞りたい

ここで、要件追加マンである偉い人に登場してもらいます。(架空の人物です)

偉い人『公開開始日時に自動で公開されるようにして、公開終了日時が来たら見られなくなるようにしてほしいな』

との要件が舞い込んできました。さっそく対応しましょう。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * @ORM\Column(type="datetime")
     */
    private $publishedStart;

    /**
     * @ORM\Column(type="datetime")
     */
    private $publishedEnd;

}

「投稿」に「公開開始日時」「公開終了日時」を追加した状態です。

ブログ一覧画面で公開期間中のもののみ一覧に表示させたいので、Repositoryに公開中のPostを取得するメソッドを実装して、

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /**
     * 公開中の記事
     */
    public function findPublished()
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.publishedStart <= :now')
            ->andWhere('p.publishedEnd > :now')
            ->setParameter('now', new \DateTime())
            ->getQuery()
            ->getResult()
            ;
    }
}

Controllerのアクションで呼んであげれば良いですね。ヨイショ!

class PostController extends AbstractController
{
    /**
     * @Route("/", name="post_index", methods="GET")
     */
    public function index(PostRepository $postRepository): Response
    {
-        return $this->render('post/index.html.twig', ['posts' => $postRepository->findAll()]);
+        return $this->render('post/index.html.twig', ['posts' => $postRepository->findPublished()]);
    }
}

いけましたね!余裕余裕!

要件その2:技術記事だけ絞りたい

偉い人『「技術記事」だけフラグ立てて、技術記事だけの一覧を表示できないかな』

だんだん嫌な予感がしてきましたが、とりあえず実装することに。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * 技術記事かどうか 
     * @var boolean
     * @ORM\Column(type="boolean", options={"default" : false})
     */
    private $isTech;
}

「技術記事」のフラグを作りました。

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 技術記事
     */
    public function findIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.isTech = :flag')
            ->setParameter('flag', $flag)
            ->getQuery()
            ->getResult()
        ;
    }
}

Repositoryにも技術記事を取得するメソッドを書いて…と。

おっと。公開期間のことを忘れていました。公開中 かつ 技術記事である メソッドも必要そうです。

<?php

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 公開中かつ技術記事
     */
    public function findPublishedAndIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.publishedStart <= :now')
            ->andWhere('p.publishedEnd > :now')
            ->setParameter('now', new \DateTime())
            ->andWhere('p.isTech = :flag')
            ->setParameter('flag', $flag)
            ->getQuery()
            ->getResult()
            ;
    }

}

…だんだん雲行きが怪しくなってきました。DRYじゃないですよね。

この実装方針の問題点として、要件追加された 検索条件 を独立して扱えていないことが挙げられます。

DoctrineのQueryBuilderでQueryオブジェクトを組み立てる方法は便利なのですが、このように細かい単位の検索条件を組み合わせて使いたい場合には向いていません。

ではどうすれば良いのでしょうか??…………そうです。お待ちかねのCriteriaの出番です。さっそく適用してみましょう!

改善: Criteriaを使ってみる

Criteriaの書き方はちょっとQueryBuilderと違うのでコツがいるのですが、基本的に比較演算子の代わりに専用のメソッドを使い、引数に値を渡して使うような形です。

まずは「公開中」「技術記事である」という条件を持ったCriteriaを返してくれるクラスを作成してみましょう。

<?php

namespace App\Criteria;

use Doctrine\Common\Collections\Criteria;

class PostCriteria
{
    public static function published()
    {
        return Criteria::create()
            ->andWhere(Criteria::expr()->lte('publishedStart', new \DateTime))
            ->andWhere(Criteria::expr()->gt('publishedEnd', new \DateTime))
            ;
    }

    public static function isTech(bool $flag = true)
    {
        return Criteria::create()
            ->andWhere(Criteria::expr()->eq('isTech', $flag))
            ;
    }
}

↑のような形になりました。

Repositoryでこれまで作ったメソッドに適用すると、こうなります。

<?php

namespace App\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

class PostRepository extends ServiceEntityRepository
{
    /** ... */

    /**
     * 公開中の記事
     */
    public function findPublished()
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::published())
            ->getQuery()
            ->getResult()
            ;
    }

    /**
     * 技術記事
     */
    public function findIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::isTech($flag))
            ->getQuery()
            ->getResult()
            ;
    }

    /**
     * 公開中かつ技術記事
     */
    public function findPublishedAndIsTech(bool $flag = true)
    {
        return $this->createQueryBuilder('p')
            ->addCriteria(PostCriteria::published())
            ->addCriteria(PostCriteria::isTech($flag))
            ->getQuery()
            ->getResult()
            ;
    }
}

まだちょっとDRYでない感はありますが、格段にわかりやすくなりました。

公開中 という、意味としてまとまった絞り込み条件を「公開開始日時より後でかつ公開終了日時より前」と毎回記述しなくてよくなる、というのも地味にポイント高いです。

さらに要件その3: 執筆者ごとに絞りたい

偉い人『記事の執筆者のページで、その人の記事一覧を表示したいんだよね』

偉い人『あ、もちろん公開中のやつね。技術記事だけでいいや』

偉い人に再度登場してもらいました。今回の要件はリレーションでEntityを繋ぎ、かつこれまでの絞り込み機能も生きたまま、のパターンです。

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

class Post
{
    /** ... */

    /**
     * 執筆者 
     * @var Writer
     * @ORM\ManyToOne(targetEntity="Writer", inversedBy="posts")
     */
    private $writer;
}
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * 執筆者
 * @ORM\Entity(repositoryClass="App\Repository\WriterRepository")
 */
class Writer
{
    /** ... */
 
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * 記事一覧
     * @ORM\OneToMany(targetEntity="Post", mappedBy="writer")
     */
    private $posts;
}

Writer(執筆者)Entityを作り、OneToManyでつなぎました。

執筆者のページを表示するためのControllerはこんな感じ

<?php

namespace App\Controller;

use App\Entity\Writer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/writer")
 */
class WriterController extends AbstractController
{
    /** ... */

    /**
     * @Route("/{id}", name="writer_show", methods="GET")
     */
    public function show(Writer $writer): Response
    {
        return $this->render('writer/show.html.twig', [
            'writer' => $writer,
            'posts' => $writer->getPosts()
        ]);
    }
}

posts 変数に $writer->getPosts()を渡して、執筆者の記事だけを絞り込んで表示しています。

ここへ、これまでの 公開中技術記事 かどうかを反映させるにはどうすれば良いでしょうか。

いきなり結論ですが、これまでに作ったCriteriaはなんと リレーションにも直接適用する ことができます!

    /**
     * @Route("/{id}", name="writer_show", methods="GET")
     */
    public function show(Writer $writer): Response
    {
        return $this->render('writer/show.html.twig', [
            'writer' => $writer,
            'posts' => $writer->getPosts()
+                ->matching(PostCriteria::published())
+                ->matching(PostCriteria::isTech())
        ]);
    }

OneToManyでつないだ先のコレクションは Doctrine/ORM/PersistentCollection なので、matching()メソッドにCriteriaを渡して絞り込むことができます。

複数渡したい場合は上記の様にメソッドチェーンで繋げばOKです。

上の例ではControllerで細かく指定していますが、仮に「ある執筆者の公開中の技術記事」というひとまとまりで扱うことが多いのであれば、執筆者Entityに専用のgetterとして実装してしまっても良いですね。

<?php

namespace App\Entity;

class Writer
{
    /** ... */

    /**
     * この執筆者の公開中の技術記事
     */
    public function getPublishedTechPosts()
    {
        return $this->posts
            ->matching(PostCriteria::published())
            ->matching(PostCriteria::isTech())
        ;
    }
}

こうしておけば、Controllerの仕事を減らして、view側で直接取得もできますね。

このような絞り込みの用途でRepositoryを使おうと思うと、サービスコンテナから取得したり予めDIしておいたりしないといけませんが、Criteriaは軽い部品なのでいつでもどこでも組み立てることが可能なため、Entity内部でも取り回しやすいです。

補足: パフォーマンス面はどうなの?

ちょっと気になるのが、 matching() メソッドをたくさんつないだ場合についてです。matching() メソッドのたびに、SQLが発行されて遅くなったりしないんでしょうか?

上記の show アクションを実行した時に発行されているSQLをprofilerで確認してみましょう。

SELECT t0.id AS id_1, t0.title AS title_2, t0.body AS body_3, t0.published_start AS published_start_4, t0.published_end AS published_end_5, t0.is_tech AS is_tech_6, t0.writer_id AS writer_id_7
FROM post t0 
WHERE ((t0.published_start <= ? AND t0.published_end > ?) AND t0.writer_id = ?)

どうやら、複数の matching() メソッドをつなげた場合でも、SQLをうまく1本につなげて実行してくれるようです。Doctrineは賢いですね!

まとめ

ここまで、 Doctrine Criteria の使い方について実例をあげて書いてみました。

絞り込み条件だけを独立させ、小さなフィルタのような部品として作り、それらの組み合わせや使い回しで複雑な条件を表現することができるため、かなり使い勝手が良いと思うのですが、日本語記事がほとんど見つけられなかったので今回ご紹介させていただきました。

LaravelのEloquentにおけるクエリスコープのような考え方をDoctrineの世界でも使えるわけですね。

みなさんも機会があればぜひ、 Doctrine Criteria を使ってみて下さい!


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

Symfony Advent Calendar 2018 19日目の記事です!
昨日は @unio さんの Webpack Encoreをwebpack4に書き直す でした。

はじめに

Symfonyでは、Security Component を使ってアプリケーションにユーザー認証機能を追加することができます。

しかし、メールアドレスとパスワードを使った一般的なユーザー認証だけを見ても、

  • サインアップ時のメールアドレス認証(本人確認)の処理
  • メールアドレス変更時のメールアドレス認証(本人確認)の処理
  • パスワードリセットの処理

など、意外と実装すべきものが多くて面倒だったりします。

また、昨今のWebアプリではお馴染みのSNSアカウントによるサインアップ/ログイン機能を提供したい場合などは、さらなる実装が必要です。(ほとんどバンドルを追加して設定するだけではありますが)

そこで、ユーザー認証の機能は自前では実装せず、Firebase Authentication などの文明の利器を使ってみるのも手ではないでしょうか。

ということで、今回はバックエンドにSymfonyを使っているSPAがあると仮定して、そのユーザー認証機構にFirebase Authenticationを使用する方法について解説してみたいと思います。

どうせFirebase Authentication使うならバックエンドもSymfonyじゃなくFirebase使えばいいじゃん、というツッコミはここではスルーさせていただきます :sweat_smile:

Firebase Authenticationとは

公式サイトによると、

Firebase Authentication には、バックエンド サービス、使いやすい SDK、アプリでのユーザー認証に使用できる UI ライブラリが用意されています。Firebase Authentication では、パスワード、電話番号、一般的なフェデレーション ID プロバイダ(Google、Facebook、Twitter)などを使用した認証を行うことができます。

https://firebase.google.com/docs/auth/?hl=ja

とのことです。

色々な方法でのユーザー認証をFirebaseが一手に担ってくれて、こちらはSDKを使ってその機能にアクセスするだけで良いのでとても楽ができそうです。

大まかな流れ

さて、SymfonyアプリでFirebase Authenticationを使うための大まかな流れは以下のようになります。

前提として、Firebaseの Webコンソール 上で各種設定(認証方法のオン/オフ、OAuthのクレデンシャルの登録など)を済ませておいてください。

  1. フロントエンドからFirebaseにログインしてIDトークン(中身はJWT)をもらう
  2. 認証が必要なリソースにアクセスする際に、リクエストの Authorization ヘッダーでIDトークンを送る
  3. Symfony側でFirebase SDKを使ってIDトークンをデコード&検証し、Firebaseから当該のユーザー情報を取得する
  4. Symfony側で対応するユーザーをログイン状態にする

今回は フロントエンドからFirebaseにログインしてIDトークンをもらう の部分の解説は割愛させていただきます :pray: が、以下に挙げたあたりの公式ドキュメントを参考にすれば、フロントエンドの実装はそんなに難しくないと思います。

もしフロントエンドにAngularを使っている場合は、Angular公式の angularfire2 を使うと便利です。

Guard Componentを使ったカスタム認証機構の実装

さて、やっと本稿の本題ですが、上記「大まかな流れ」の後半部分(フロントエンドからIDトークンをもらい、それを元にFirebase SDKを使ってFirebaseからユーザー情報を取得する)を実装するには、Guard Componentというコンポーネントを使えばいいようです。

以下、上記リンク先のドキュメントの流れに沿って具体的な実装方法を解説していきます。

1. UserクラスにFirebase上のユーザーUIDを持たせる

FirebaseからユーザーUIDを取得したあと、それをSymfony上のユーザーと対応づけるために、UserクラスにFirebaseのユーザーUIDを持たせておきます。

// src/Entity/User.php

// ...

class User implements UserInterface
{
    // ...

+   /**
+    * @ORM\Column(type="string", unique=true)
+    */
+   private $firebaseUid;

    // ...
}

DBマイグレーションも忘れずに。

2. カスタム認証用のAuthenticatorクラスを実装する

まず、Symfony側でIDトークンをデコード&検証するために、PHP用のFirebase SDKである kreait/firebase-php を導入します。

$ composer require kreait/firebase-php

この上で、以下のような内容でAuthenticatorクラスを実装します。

// src/Security/FirebaseJWTAuthenticator.php

namespace App\Security;

use Firebase\Auth\Token\Exception\InvalidToken;
use Kreait\Firebase;
use Kreait\Firebase\Factory;
use Kreait\Firebase\ServiceAccount;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

class FirebaseJWTAuthenticator extends AbstractGuardAuthenticator
{
    /**
     * @var Firebase
     */
    private $firebase;

    public function __construct(string $serviceAccountKeyJson)
    {
        // @see https://firebase-php.readthedocs.io/en/latest/setup.html
        $this->firebase = (new Factory())
            ->withServiceAccount(ServiceAccount::fromJson($serviceAccountKeyJson))
            ->create()
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        return new JsonResponse(['message' => 'Authentication Required'], Response::HTTP_UNAUTHORIZED);
    }

    /**
     * {@inheritdoc}
     */
    public function supports(Request $request)
    {
        return $request->headers->has('Authorization');
    }

    /**
     * {@inheritdoc}
     */
    public function getCredentials(Request $request)
    {
        preg_match('/Bearer +(.+)$/', $request->headers->get('Authorization'), $m);

        if (!isset($m[1])) {
            throw new BadRequestHttpException('Invalid Authorization Header');
        }

        return [
            'id_token' => $m[1],
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        try {
            $verifiedIdToken = $this->firebase->getAuth()->verifyIdToken($credentials['id_token']);
        } catch (InvalidToken $e) {
            return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
        }

        // @see https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
        $uid = $verifiedIdToken->getClaim('sub');

        return $userProvider->loadUserByUsername($uid);
    }

    /**
     * {@inheritdoc}
     */
    public function checkCredentials($credentials, UserInterface $user)
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new JsonResponse(['message' => strtr($exception->getMessageKey(), $exception->getMessageData())], Response::HTTP_FORBIDDEN);
    }

    /**
     * {@inheritdoc}
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
    }

    /**
     * {@inheritdoc}
     */
    public function supportsRememberMe()
    {
        return false;
    }
}

ポイントとしては、

  • GoogleサービスアカウントのクレデンシャルJSONを使ってFirebase SDKを初期化している(コンストラクタ)
  • Authorization ヘッダーを持つリクエストを認証の対象にしている(supports()
  • Authorization: Bearer {IDトークン} という形式でIDトークンを読み取っている(getCredentials()
  • Firebase SDKを使ってIDトークンを検証し、FirebaseからユーザーUIDを取得している(getUser()
  • FirebaseのユーザーUIDを $userProvider->loadUserByUsername($uid) してユーザーを取得している(getUser()
    • 後述の security.yaml の設定で property: firebaseUid としているのでこれが可能

といったあたりでしょうか。

3. security.yamlでAuthenticatorを設定する(Symfony4の例)

Symfony4の例になりますが、以下のような内容で設定する感じになると思います。

# config/packages/security.yaml
security:
    # ...

    providers:
        firebase_provider:
            entity:
                class: App\Entity\User
                property: firebaseUid

    firewalls:
        # ...

        api:
            pattern: ^/api/v\d+/
            anonymous: ~

            stateless: true

            provider: firebase_provider
            guard:
                authenticators:
                    - App\Security\FirebaseJWTAuthenticator

            # ...

おわりに

以上の方法で、Firebaseから取得したIDトークン(JWT)を使ってSymfony側でユーザー認証を行うことができます。

少し前に趣味プロジェクトで初めて導入してみたのですが、個人的にはかなり簡単に実装できて驚きました。ほとんど公式ドキュメントの焼き直しみたいな記事になってしまいましたが :sweat_smile: 、どこかの誰かのお役に立てば幸いです :pray:


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

BEAR.Sunday Advent Calendar 2018 18日目の記事です。

以前も 書いた とおり、主にメインのWEBアプリケーションとは独立して動く小さなコンポーネントやスクリプトを書くときにRay.Diを使っています。

Moduleクラスをテストしたい!

ModuleクラスでDIを定義した場合、困るのはどうやってテストしたら良いか?ということです。

テストを書きたい理由としては、別にカバレッジを上げたいわけではありません。単に私がRay.Diにそれほど慣れていないので正しい記述ができているかどうか自信がなくて、テストで証明して安心したいからです。

どのようなテストを書けば良いか

では、Moduleクラスに対してどんなテストを書けば良いでしょうか?

仮にユニットテストを書くとしましょう。
ユニットテストで検証できるのは、Moduleクラス内で どんなbind()を呼び出したか までです。
そのbind()呼び出しが正しく依存を定義できているかは検証できません。
ユニットテストでは不十分だということがわかります。

Ray.DiのDI定義として正しい記述ができていることを確認したいわけなので、ユニットテストではなく Ray.DiのInjectorから所望のインスタンスが取り出せているか検証するテスト 、いわゆる機能テストを書けば良さそうです!
というわけで書いてみました。

<?php

namespace Quartetcom\RaySample\Functional;
use PHPUnit\Framework\TestCase;
use Quartetcom\RaySample\DependencyInjection\RaySampleModule;
use Quartetcom\RaySample\RaySample;
use Ray\Di\Injector;
class RaySampleModuleTest extends TestCase
{
    public function test()
    {
        $injector = new Injector(new RaySampleModule());
        $this->assertInstanceOf(RaySample::class, $injector->getInstance(RaySample::class));
    }
}

まとめ

カルテット開発部ではよほど軽微な文言変更以外は、必ずテストを書くようにしています。
テストは開発者の安心のためにあるからです。

DIの仕組みとしてRay.Diを使い、かつModuleに対するテストを書いたサンプルプロジェクトを下記に置きました。ご参考にどうぞ
https://github.com/77web/RayDi-usage-testing-module