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

Symfony Advent Calendar 2018 13日目の記事です。

同種のクラス群(主に同じinterfaceを実装したクラス群)を大量に使う場合、DIどうしてますか?

# config/services.yaml
services:
  app_sample.foo:
    class: App\Sample\SampleFoo

  app_sample.bar:
    class: App\Sample\SampleBar

  # ...

  App\SampleResolver:
    class: App\SampleResolver
    calls:
      - ["addSample", ["@app_sample.foo"]]
      - ["addSample", ["@app_sample.bar"]]
      - ["addSample", ["@app_sample.baz"]]
      - ["addSample", ["@app_sample.foo2"]]
      - ["addSample", ["@app_sample.foo3"]]
      # ...
      - ["addSample", ["@app_sample.foo100"]]

1個や2個ならまだいいですが、10個を超えるとさすがにサービス定義がめんどくさく&可読性が低くなりますよね。

SymfonyのDependencyInjectionのタグとは

Symfonyを使って開発していると tags: [{ name: console.command }] とか tags: [{name: form.type }] とか tags: [{ name: kernel.event_subscriber }] とか使ったことがありますよね? あれです。
SymfonyのDependencyInjectionにおける組み込みタグは、 特定の種類のサービスにつけておくと自動で必要なサービスに注入してくれる 働きをしています。

タグ、自作できるんです

便利なタグですが、実は自分で作れます!
使いどころとしては、同種のサービス群を大量に他のサービスに注入したいときに役立ちます。

たとえば、先程の可読性が低くなってきたservices.yamlをこのように書き換えることができます。

# config/services.yaml
services:
  app_sample.foo:
    class: App\Sample\SampleFoo
    tags:
      - "myapp.sample"

  app_sample.bar:
    class: App\Sample\SampleBar
    tags:
      - "myapp.sample"

  # ...

  App\SampleResolver:
    class: App\SampleResolver

可読性も向上し、Sampleを追加したり削除したときのメンテも楽になることがわかりますね。

SymfonyのDependencyInjectionで自作のタグを使う方法

便利な自作タグを使う方法を、Symfonyのバージョン別に見ていきましょう。

Symfony2.8でのやり方

サービスにタグを使用する方法 Symfony2日本語ドキュメント にやり方の説明があります。古い記事ですが内容はSymfony2.8でも通用します。

やることは2つだけです。

  • CompilerPassクラスを作る
  • CompilerPassをバンドルに読み込ませる

CompilerPassのサンプル

<?php

namespace Acme\DemoBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class SamplePass implements CompilerPassInterface
{
    /**
     * {@inheritdoc}
     */
    public function process(ContainerBuilder $container)
    {
        $resolverDefinition = $container->getDefinition('app_sample_resolver');

        foreach ($container->findTaggedServiceIds('myapp.sample') as $id => $tags) {
            $resolverDefinition->addMethodCall('addSample', [new Reference($id)]);
        }
    }
}

CompilerPassをBundleに読み込ませる

<?php

namespace Acme\DemoBundle\AcmeDemoBundle;

use Acme\DemoBundle\DependencyInjection\Compiler\SamplePass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

class AcmeDemoBundle extends Bundle
{
    /**
     * {@inheritdoc}
     */
    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new SamplePass());
    }
}

Symfony4でのやり方

Symfony4になっても基本的なやり方は同じです。CompilerPassを読み込ませる対象がBundleではなくKernelに変わっただけです。

  • CompilerPassクラスを作る
  • CompilerPassをKernelに読み込ませる

CompilerPassのサンプル

<?php

namespace App\DependencyInjection\Compiler;

use App\SampleResolver;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class SamplePass implements CompilerPassInterface
{
    /**
     * {@inheritdoc}
     */
    public function process(ContainerBuilder $container)
    {
        $resolverDefinition = $container->getDefinition(SampleResolver::class);

        foreach ($container->findTaggedServiceIds('myapp.sample') as $id => $tags) {
            $resolverDefinition->addMethodCall('addSample', [new Reference($id)]);
        }
    }
}

CompilerPassをKernelに読み込ませる

<?php
// src/Kernel.php
namespace App;

use App\DependencyInjection\Compiler\SamplePass;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
// ...

class Kernel extends BaseKernel
{
    // ...

    protected function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new SamplePass());
    }
}

CompilerPassを作らなくてもyaml上でタグ付けられたサービス全てが取得できるようになりました

Symfony3.4以降で、CompilerPassを作ることなくservices.yaml上で 特定のタグが付いたサービス全部 を取得できるようになっています。SampleResolver::addSample() のように、注入するメソッドに渡す追加の引数が特に必要ない場合はこちらのほうが便利だと思います。

# config/services.yaml
services:
  app_sample.foo:
    class: App\Sample\SampleFoo
    tags:
      - "myapp.sample"

  app_sample.bar:
    class: App\Sample\SampleBar
    tags:
      - "myapp.sample"

  # ...

  App\SampleResolver:
    class: App\SampleResolver
    arguments: [!tagged myapp.sample]

まとめ

タグ付けられたサービスをyaml上でまとめて参照できる機能、個人的にはとても便利で嬉しいです。
Symfony2.8→Symfony3.4→Symfony4でDX(DeveloperExperience)が向上しているのを感じます。

参考リンク


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

Symfony Advent Calendar 2018 11日目の記事です。

SymfonyでWebアプリケーションを作るとき、HTTPリクエストから直接実行するには重い処理があったらどうするか?
処理をコマンド化して、バックグラウンドで(非同期)実行させることが多いと思います。

Symfonyアプリケーションからコマンドを非同期で実行する仕組みとして、Symfony2時代には JMSJobQueueBundle が事実上の標準となっていました。
JMSJobQueueBundleは、Webアプリケーションの通常の処理の中からデータベース上の所定のテーブルにコマンド名や引数・オプション等を保存(予約)したものを、順に実行していってくれるバンドルです。
専用のコマンド jms-job-queue:run をsupervisordを使ってバックグラウンドで常駐させておくことで、予約したコマンドが次々に実行されていきます。

しかし長らくSymfony4に対応されず、代替方法を探す必要がありました。
今回はそのひとつ、 php-enqueueasync-command 機能について使い方をご紹介します。

※ ちなみについ最近(2018年11月)、ようやくJMSJobQueueBundleが Symfony4に対応しました!
内部の実装では結構変更があったのですが、動作のカスタマイズなしでJMSJobQueueBundleを単に使っている分には特に変更なく使えました。 JMSJobQueueBundleは広く使われているので、今後Symfony4時代になっても標準でありつづけることになりそうですね。

php-enqueueのasync-commandをジョブキューとして使う

enqueue/enqueue(php-enqueue)とは

FormaProという海外の開発会社が開発しているPHPのキュー実装です。JavaのJMSというキュー実装を参考に作られています。
統一的なインターフェイスで様々なキューにメッセージを送ることができます。

例えば、SQSキューにメッセージを送りたい場合、生のままのAWSのSDKを使って送信機能を開発すると、バックエンドのキューを変えることになったときにアプリケーション側で変更が多くなってしまいます。php-enqueueを使うと、バックエンドのキューを変えてもアプリケーション側の変更点は設定ぐらいになります(バックエンドのRDBMSが何であってもアプリケーション層からはDoctrineを使う、みたいな感じですね)

Symfonyから使う場合は enqueue/enqueue-bundle を使うと便利です。
といっても、 EnqueueBundle はただのキュー送受信の仕組みなので、キューにメッセージを送ったりキューからメッセージを読み出したりするだけで、JMSJobQueueBundleのように非同期でSymfonyコマンドを実行する機能まではついていません。

enqueue/async-commandとは

enqueue/async-command は、php-enqueueをキューとして使って非同期でSymfonyコマンドを実行するライブラリです。
JMSJobQueueBundleの代替として検討するため、早速使ってみましょう。

インストール

composerで enqueue/async-command と関連パッケージを依存に追加します。

$ composer require enqueue/async-command:"^0.9" enqueue/fs enqueue/enqueue-bundle symfony/process

composerからレシピを使うか尋ねられるのでyesを選ぶと Enqueue\Bundle\EnqueueBundle が自動で有効化されます。
enqueueの設定で、async-commandsを使う設定をします。

# config/packages/enqueue.yaml
enqueue:
  default:
    transport: '%env(ENQUEUE_DSN)%'
    client: ~
+    async_commands: true

今回キューのバックエンドとしてローカルファイルシステム enqueue/fs を使うので、キューに送られたメッセージを保存するためのファイルパスを ENQUEUE_DSN として設定します。

# .env
###> enqueue/fs ###
-ENQUEUE_DSN=file:///var/enqueue
+ENQUEUE_DSN=file:///path/to/project/async-command-demo/var/enqueue
###< enqueue/fs ###

コマンド実行をキューに入れる

ProducerInterface::sendCommand() で実行したいコマンドをメッセージ化してキューに送ることができます。

<?php

use Enqueue\Client\ProducerInterface;
use Enqueue\AsyncCommand\Commands;
use Enqueue\AsyncCommand\RunCommand;
use Symfony\Component\DependencyInjection\ContainerInterface;

/** @var $container ContainerInterface */

/** @var ProducerInterface $producer */
$producer = $container->get(ProducerInterface::class);

$producer->sendCommand(Commands::RUN_COMMAND, new RunCommand('some:command', ['arg1', 'arg2'], ['--foo' => 'bar']));

5分後のコマンド実行をキューに入れる

ProducerInterface::sendCommand() で送るメッセージを Enqueue\Client\Message でラップして、delayを指定することでコマンド実行を指定時間になるまで遅らせることができます。

<?php

use Enqueue\Client\Message;
use Enqueue\Client\ProducerInterface;
use Enqueue\AsyncCommand\Commands;
use Enqueue\AsyncCommand\RunCommand;
use Symfony\Component\DependencyInjection\ContainerInterface;

/** @var $container ContainerInterface */

/** @var ProducerInterface $producer */
$producer = $container->get(ProducerInterface::class);

$job = new Message(new RunCommand('some:command', ['arg1', 'arg2'], ['--foo' => 'bar']));
$job->setDelay(300 * 1000); // ドキュメントでは秒をセットすることになっているがなぜかミリ秒?
$producer->sendCommand(Commands::RUN_COMMAND, $job);

ジョブを実行するコマンド

キューを読んで処理するコマンドを起動すると、予約したコマンドが実行されます。

$ ./bin/console enqueue:consume --setup-broker

※ 2018年12月12日現在の最新バージョン enqueque/enqueue:0.9.0 ではキューに入れるメッセージのID保存の仕組みに バグ があり、動かない状態となってしまいました :sweat_drops:

残る課題

JMSJobQueueBundleからの移行先として見た場合、下記2点の課題がありました。

  • キューに入れ終わった際にジョブIDを返す仕組みがない
  • ジョブ実行が終わるとレコードがenqueueテーブルから削除されてしまう(キューのバックエンドとして enqueue/dbal を使った場合でも)

JMSJobQueueBundleではジョブのIDが返され、ジョブ完了(成功でも失敗でも)後もジョブのレコードがデータベースに保存され続けていましたが、php-enqueueのasync-commandにはその機能がありません。
async-commandを使うとしたら、ジョブの実行ログを保存したい場合はメッセージの返信をログする仕組みを、ジョブが成功したかどうかを追跡したい場合にはジョブステータス管理用のエンティティを自前で実装してステータス管理する必要があります。

まとめ

結果的にJMSJobQueueBundleの便利な点を再確認することになりました。
メジャーなバンドルが次々とSymfony4対応を完了させて、いよいよSymfony4時代の到来ですね。

参考リンク


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

Symfony Advent Calendar 2018 7日目の記事(遅刻)です。
昨日も私の記事でした :sweat_smile:

巨大なプロジェクトの構成要素を小さなコンポーネントに分けて開発するようになると、バリデーションをSymfonyプロジェクト外で書くことも増えます。

こういうときSymfonyがコンポーネントごとに分かれていて、独立して使用することもできるのが生きてきます。
昔のsymfony1時代との大きな違いですね!

実際にSymfony Validatorでバリデーションしてみる

下記のような Term クラスがあるとします。

<?php

namespace Quartetcom\DecBlogDemo\Entity;

class Term
{
    /**
     * @var \DateTime
     */
    private $from;

    /**
     * @var \DateTime
     */
    private $to;

    public function __construct(\DateTime $from, \DateTime $to)
    {
        $this->from = $from;
        $this->to = $to;
    }

    public function validateLessThan90Days()
    {
        if ($this->to->diff($this->from)->days > 90) {
            throw new \LogicException('Term is too long.');
        }
    }

    // ...
}

このTermクラスのバリデーションをSymfonyのValidatorコンポーネントを使って書き換えてみましょう。

まず、composerで symfony/validator への依存を追加します。

$ composer require symfony/validator

そしてvalidateLessThan90days() メソッドをSymfonyのValidator用の書き方に変更します。

<?php

//...
+use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\ExecutionContextInterface;


class Term
{
    // ...

+    /**
+     * @Assert\Callback
+     */
+    public function validateLessThan90Days(ExecutionContextInterface $context)
+    {
+        if ($this->to->diff($this->from)->days > 90) {
+            $context->buildViolation('term.too_long')
+                    ->atPath('to')
+                    ->addViolation()
+            ;
+        }
+    }
-    public function validateLessThan90Days()
-    {
-        if ($to->diff($from)->days > 90) {
-            throw new \LogicException('Term is too long.');
-        }
-    }

    // ...
}

テストを書いて、このバリデーションが期待通りに動くかどうか確認してみましょう。

<?php


namespace Quartetcom\DecBlogDemo\Entity;


use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;

class TermTest extends TestCase
{
    public function test_validate_90days()
    {
        $term = new Term(new \DateTime('2018-11-01'), new \DateTime('2019-01-30'));
        $this->assertValidTerm($term);
    }

    public function test_validate_91days()
    {
        $term = new Term(new \DateTime('2018-11-01'), new \DateTime('2019-01-31'));
        $this->assertInvalidTerm($term);
    }

    private function assertValidTerm(Term $term)
    {
        $this->assertEquals(0, $this->getViolationCount($term));
    }

    private function assertInvalidTerm(Term $term)
    {
        $this->assertGreaterThan(0, $this->getViolationCount($term));
    }

    /**
     * @param Term $term
     * @return int length of errors
     */
    private function getViolationCount(Term $term): int
    {
        $validator = Validation::createValidator();

        return count($validator->validate($term));
    }
}

2018-12-08 22 28 29

バリデーションが行われなかったようです… :cry:
おっと、Validator初期化時にアノテーションによるバリデーション設定を有効化する必要があるのを忘れていました。

<?php

namespace Quartetcom\DecBlogDemo;

// ...

class TermTest extends TestCase
{
    // ...

    private function getViolationCount(Term $term)
    {
-        $validator = Validation::createValidator();
+        $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();

        return count($validator->validate($term));
    }
}

今度こそバリデーションできるはず!
2018-12-08 22 35 11

今度は doctrine/annotationsdoctrine/cache が必要だというエラーが出てしまいました。
メッセージに従い、追加します。

$ composer require doctrine/annotations doctrine/cache

今度こそバリデーションできるはず!
2018-12-08 22 38 15

Symfony\Component\Validator\Constraints\Callback がクラスとして読み込めていないようです :thinking:
AnnotationRegistryにautoloadできるクラスが存在すればAnnotationとして利用できる設定を追加します。
※ この設定は https://github.com/doctrine/annotations/issues/103 によるとDoctrine v3から不要になるようです。

<?php

namespace Quartetcom\DecBlogDemo;

// ...
+use Doctrine\Common\Annotations\AnnotationRegistry;


class TermTest extends TestCase
{
    // ...

    private function getViolationCount(Term $term)
    {
+        AnnotationRegistry::registerLoader('class_exists');
        $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();

        return count($validator->validate($term));
    }
}

ついにバリデーションが動きました! :tada:
2018-12-08 22 44 17

まとめ

私はガッチリ密結合だったsymfony1.0の頃からsymfonyを使っているので、Symfonyのバージョンが1→2→3→4と進むにつれて、疎結合がどんどん加速しているのを感じます。
マイクロサービス志向・コンポーネント志向が進む中、安心して使うことができるSymfony Componentの役割はますます大きくなりそうです。

※ 本記事のサンプルコードを こちらのレポジトリ で公開しています。ご参考にどうぞ