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

Symfony Advent Calendar 2017の21日目の記事です。

はじめに

新人研修でSymfony4のフレームワーク本体のコードリーディングに取り組んでいる澤井です。

弊社が提供するサービスのバックエンドは、主にSymfonyで開発しています。開発に参加するためには、Symfonyに対する深い理解が必要です。そのためにSymfonyの本体のコードリーディングを行っています。

コードリーディングは、PhpStormのステップ実行を使って全体の流れを把握した上で、個々のクラスを詳しく見ながら該当箇所の公式ドキュメントを読む、という手順で進めました。

今回は、特に重点的に調べたHttpKernel Componentについて調べたことをまとめてみました。HttpKernel Componentは、HTTPリクエストを受け取ってHTTPレスポンスを返す、Symfony4アプリケーションの骨格となるコンポーネントです。

全体の処理の流れ

Symfony4アプリケーションがHTTPリクエストを受け取ってHTTPレスポンスを返す流れは、以下のようになります。

symfony4_http_kernel_summury

  1. フロントコントローラ(index.php)が、HTTPリクエストからRequestクラスのオブジェクトを作成
  2. フロントコントローラが、Kernelクラス(Symfony\Component\HttpKernel\Kernelクラスを継承)のオブジェクトを作成
  3. Kernelオブジェクトが、サービスコンテナを初期化
  4. その結果、サービスとしてHttpKernelクラスのオブジェクトを使えるようになる
  5. HttpKernelオブジェクトが、Requestオブジェクトを受け取り、コントローラを実行し、Responseクラスのオブジェクトを返す
  6. フロントコントローラが、ResponseオブジェクトからHTTPレスポンスを作成

以下、本記事の主題であるHttpKernelクラスの働きを見ていきます。

HttpKernelクラスの働き

HttpKernelクラスは、Requestオブジェクトを受け取り、コントローラを実行し、Responseオブジェクトを返します。HttpKernelクラスは、それらの処理をイベントを使って実現します。

HttpKernelクラスが発行する主なイベントは以下のとおりです。各イベントの詳細は、Built-in Symfony Events (Symfony Docs)を参照してください。

イベント名 KernelEventsの定数 リスナーへ渡す引数
kernel.request KernelEvents::REQUEST GetResponseEvent
kernel.controller KernelEvents::CONTROLLER FilterControllerEvent
kernel.controller_arguments KernelEvents::CONTROLLER_ARGUMENTS FilterControllerArgumentsEvent
kernel.view KernelEvents::VIEW GetResponseForControllerResultEvent
kernel.response KernelEvents::RESPONSE FilterResponseEvent

以下のコマンドで、イベントへ設定されているリスナーを確認することができます。

$ bin/console debug:event-dispatcher イベント名

HttpKernelクラスの処理の流れ

HttpKernelクラスの主な処理は、HttpKernel::handle()に呼び出されるHttpKernel::handleRaw()が担います。handleRaw()のコードは以下のようになります。

HttpKernel::handleRaw()の処理内容

コード内に「著者注」として番号を付けている箇所については、このあと解説します。

<?php
// 著者注
// Symfony\Component\HttpKernel\HttpKernel
// vendor/symfony/http-kernel/HttpKernel.php

// ...

private function handleRaw(Request $request, int $type = self::MASTER_REQUEST)
{
    // ...

    // 著者注
    // (1) 
    $event = new GetResponseEvent($this, $request, $type);
    $this->dispatcher->dispatch(KernelEvents::REQUEST, $event);

    if ($event->hasResponse()) {
        return $this->filterResponse($event->getResponse(), $request, $type);
    }

    // 著者注
    // (2)
    if (false === $controller = $this->resolver->getController($request)) {
        throw new NotFoundHttpException(sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $request->getPathInfo()));
    }

    // 著者注
    // (3)
    $event = new FilterControllerEvent($this, $controller, $request, $type);
    $this->dispatcher->dispatch(KernelEvents::CONTROLLER, $event);
    $controller = $event->getController();

    // 著者注
    // (4)
    $arguments = $this->argumentResolver->getArguments($request, $controller);
    $event = new FilterControllerArgumentsEvent($this, $controller, $arguments, $request, $type);
    $this->dispatcher->dispatch(KernelEvents::CONTROLLER_ARGUMENTS, $event);
    $controller = $event->getController();
    $arguments = $event->getArguments();

    // 著者注
    // (5)
    $response = call_user_func_array($controller, $arguments);

    if (!$response instanceof Response) {
        // 著者注
        // (6)
        $event = new GetResponseForControllerResultEvent($this, $request, $type, $response);
        $this->dispatcher->dispatch(KernelEvents::VIEW, $event);

        if ($event->hasResponse()) {
            $response = $event->getResponse();
        } else {
            $msg = sprintf('The controller must return a response (%s given).', $this->varToString($response));

            if (null === $response) {
                $msg .= ' Did you forget to add a return statement somewhere in your controller?';
            }
            throw new \LogicException($msg);
        }
    }
    // 著者注
    // (7)
    return $this->filterResponse($response, $request, $type);
}

(1) kernel.requestイベントを発行

kernel.requestイベントは、複数のリスナーを持ちます。今回は、コントローラを特定するRouterListener::onKernelRequest()リスナーを見ていきます。RouterListener::onKernelRequest()リスナーは、呼び出すコントローラ名とルート名の配列を返します(キーは_controllerと_route)。

返された配列は、Request::attributes属性へ設定されます。この値は、コントローラを取得する処理で使われます。

(2) コントローラを取得

ControllerResolver::getController()は、(1)で設定したRequest::attributes属性からコントローラの情報を取得し、コントローラを作成します。

ControllerResolver::getController()の主な処理は、以下のとおりです。

  • コントローラのインスタンスを作成(引数には何も渡さない)
  • コントローラがContainerAwareInterfaceを実装している場合は、setContainer()でサービスコンテナを設定

(3) kernel.controllerイベントを発行

コントローラを実行する前に、必要な前処理を行います。kernel.controllerイベントが行う処理の例として、以下のものがあります。

  • プロファイラが有効な場合は、プロファイラの情報を集める
  • @ParamConverterが使われている場合は、ParamConverterListener::onKernelController()リスナーが、スカラー値をオブジェクトへ変換し、Requestオブジェクトに保持(このオブジェクトは、コントローラを実行するときに引数として渡される)

(4) 引数を取得

ArgumentValueResolverInterface::getArguments()で、コントローラへ渡す引数を取得します。引数の特定は、以下のように行われます。

  • コントローラの引数と同名のキー名がRequest::attributes属性にあれば、その値を引数として使う
  • コントローラの引数がタイプヒンティングにRequestクラスを指定している場合は、Requestオブジェクトを引数として使う
  • コントローラの引数が可変長引数で、Request::attributes属性に同名のキーがあり、値が配列のときは、その配列を引数として使う

(5) コントローラを実行

引数を指定してコントローラを実行します。

(6) kernel.viewイベントを発行

コントローラからの戻り値がResponseオブジェクトでない場合は、kernel.viewイベントでResponseオブジェクトへ変換します。

デフォルトでは、kernel.viewイベントのリスナーは登録されていませんが、SensioFrameworkExtraBundleをインストールすることで、リスナーが登録されます。例えば、レスポンスにTwigテンプレートを使うときは、TemplateListener::onKernelView()リスナーがテンプレートとデータをパースしてResponseオブジェクトへ変換します。

(7) kernel.responseイベントを発行

HttpKernel::filterResponse()でkernel.responseイベントが発行されます。kernel.responseイベントは、Responseオブジェクトがクライアントへレスポンスを送信する前に発行されます。このイベントをフックすることで、例えば、HTTPレスポンスヘッダを変更したり、Cookieを追加したりといった処理を差し込むすることができます。

HttpKernelクラスの処理イメージ

これまでの流れを図にすると、以下のようになります。

symfony4_http_kernel

Symfony2.8からの変更点

コントローラへ渡す引数は、Symfony2.8ではControllerResolverInterface::getArguments()で取得しますが、Symfony4ではArgumentValueResolverInterface::getArguments()で取得します。

そのため、Symfony4.0ではHttpKernelのコンストラクタの第4引数へ、ArgumentValueResolverInterfaceを実装したオブジェクトを渡す必要があります(Symfony2.8には第4引数はありません)。

また、Symfony2.8にはないkernel.controller_argumentsイベントが、Symfony4にはあります(kernel.controller_argumentsイベントはSymfony3.1から追加)。

ルーティング処理の流れ

HttpKernelクラスが担う処理の中で、ルーティングを処理する部分が複雑だと感じたので、ルーティング処理について詳しく見ていきます。

ルーティングの概要は、リクエストから呼び出すコントローラの情報をRequest::attributes属性へ設定する処理になります。

kernel.requestイベント発行

HttpKernel::handleRaw()内で、イベント名(kernel.request)とGetResponseEventオブジェクトを引数として渡してEventDispatcher::dispatch()を呼び出し、kernel.requestイベントを発行します。

<?php
// 著者注
// Symfony\Component\HttpKernel\HttpKernel
// vendor/symfony/http-kernel/HttpKernel.php

// ...

private function handleRaw(Request $request, int $type = self::MASTER_REQUEST)
{
    // ...

    $event = new GetResponseEvent($this, $request, $type);
    $this->dispatcher->dispatch(KernelEvents::REQUEST, $event); 

    // ...
}

Request::attributes属性へコントローラ情報を設定

EventDispatcher::dispatch()は、リスナーを引数として渡してEventDispatcher::doDispatcher()を呼び出します。

<?php
// 著者注
// Symfony\Component\EventDispatcher\EventDispatcher
// vendor/symfony/event-dispatcher/EventDispatcher.php

// ...

public function dispatch($eventName, Event $event = null)
{
    if ($listeners = $this->getListeners($eventName)) {
        $this->doDispatch($listeners, $eventName, $event);
    }

    // ...
}

EventDispatcher::doDispatch()は、イベントへ設定されているリスナーをすべて実行します。Kernel.requestイベントは、複数のリスナーが設定されています。その中のRouterListener::onKernelRequest()リスナーが、ルーティングを処理します。

<?php
// 著者注
// Symfony\Component\EventDispatcher\EventDispatcher
// vendor/symfony/event-dispatcher/EventDispatcher.php

// ...

protected function doDispatch($listeners, $eventName, Event $event)
{
    foreach ($listeners as $listener) {

        // ...

        \call_user_func($listener, $event, $eventName, $this); 
    }
}

RouterListener::onKernelRequest()リスナーは、Router::matcherRequest()を呼び出し、戻り値として受け取るコントローラ名とルート名の配列をRequest::attributes属性へ設定します。

例えば、Request::attributes属性へ設定される値は、以下のようなものになります。

<?php
[
    '_controlle' => 'App\\Controller\\DefaultController::index',
    '_route' => 'default',
]
<?php
// 著者注
// Symfony\Component\HttpKernel\EventListener\RouterListener

// ...

public function onKernelRequest(GetResponseEvent $event)
{
    // ...

    try {
        if ($this->matcher instanceof RequestMatcherInterface) {   
            $parameters = $this->matcher->matchRequest($request);  
        } else {
            $parameters = $this->matcher->match($request->getPathInfo());
        }

        // ...

        $request->attributes->add($parameters);
        unset($parameters['_route'], $parameters['_controller']);
        $request->attributes->set('_route_params', $parameters);
    } catch (ResourceNotFoundException $e) {

        // ...

    }
}

Router::matchRequest()は、Router::getMatcher()でルーティングのマッチング情報が書かれているsrcDevProjectContainerUrlMatcherクラスのオブジェクトを取得し、取得したオブジェクトのmatchRequest()を呼び出します(matchRequestはSymfony\Component\Routing\Matcher\UrlMatcherから継承)。

<?php
// 著者注
// Symfony\Component\Routing\Router
// vendor/symfony/routing/Router.php

// ...

public function matchRequest(Request $request)
{
    $matcher = $this->getMatcher();

    // ...

    return $matcher->matchRequest($request);
}

srcDevProjectContainerUrlMatcher::matchRequest()は、srcDevProjectContainerUrlMatcher::match()を呼び出します。

<?php
// 著者注
// Symfony\Component\Routing\Matcher\UrlMatcher

// ...

public function matchRequest(Request $request)
{
    $this->request = $request;

    $ret = $this->match($request->getPathInfo());  

    $this->request = null;

    return $ret;
}

srcDevProjectContainerUrlMatcher::match()で、ルーティングのマッチングを行いコントローラを特定しコントローラ情報を返します。

<?php
// 著者注
// srcDevProjectContainerUrlMatcher
// var/cache/dev/srcDevProjectContainerUrlMatcher.php

// ...

class srcDevProjectContainerUrlMatcher extends Symfony\Bundle\FrameworkBundle\Routing\RedirectableUrlMatcher
{
    // ...

    public function match($pathinfo)
    {
        // ...

        // default
        if ('/default' === $pathinfo) {
            return array (  '_controller' => 'App\\Controller\\DefaultController::index',  '_route' => 'default',);
        }

        // ...

補足:サービスコンテナ作成の流れ

本記事では、Symfony4で中心的な役割を担うHttpKernelクラスを見てきました。

Symfony4は、各種の機能をサービスとして提供します。Symfony4内部では、HttpKernelもサービスとして提供されます。そこで、サービスを管理するサービスコンテナが作成される流れを調べました。

サービスコンテナが作成される流れは、以下のようになります。

  1. index.phpがSymfony\Component\HttpKernel\Kernel::handle()を呼び出します(以下のメソッドは全てSymfony\Component\HttpKernel\Kernelクラスのメソッド)。
  2. handle()はboot()を呼び出します。
  3. boot()はinitializeContainer()を呼び出します。
  4. initializeContainer()は、サービスコンテナを作成しKernel::containerへ設定します。

以下、作成の流れを詳しく見ていきます(XXXXはSymfony4が自動で命名するため、環境により異なります)。

(1) Kernel:handle()を呼び出す

<?php
// 著者注
// public/index.php

// ...

$response = $kernel->handle($request);

// ...

(2) Kernel::handle()からKernel::boot()を呼び出す

<?php
// 著者注
// Symfony\Component\HttpKernel\Kernel(App/Kernelの親クラス)
// vendor/symfony/http-kernel/Kernel.php

// ...

namespace Symfony\Component\HttpKernel;

// ...

abstract class Kernel implements KernelInterface, RebootableInterface, TerminableInterface
{
    // ...

    public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
    {
        $this->boot();

        // ...
    }

    // ...
}

(3) Kernel:boot()がサービスコンテナを初期化するinitializeContainer()を呼び出す

<?php
// 著者注
// Symfony\Component\HttpKernel\Kernel
// vendor/symfony/http-kernel/Kernel.php

// ...

public function boot()
{
    // ...

    $this->initializeContainer();

    // ...
}

(4) Kernel::initializeContainer()がKernel::containerへサービスコンテナを設定する

<?php
// 著者注
// Symfony\Component\HttpKernel\Kernel

// ...

protected function initializeContainer()
{
    // ...

    $this->container = require $cache->getPath(); 

    // ...
}

$cache->getPath()が返すvar/cache/dev/srcDevProjectContainer.phpの中身は、以下のようになります。このファイルで、ContainerXXXX\srcDevProjectContainerクラスのオブジェクトを作成します。このオブジェクトがサービスコンテナです。

<?php
// 著者注
// var/cache/dev/srcDevProjectContainer.php
if (!class_exists(\ContainerXXXX\srcDevProjectContainer::class, false)) {
    require __DIR__.'/ContainerXXXX/srcDevProjectContainer.php';   
}

if (!class_exists(srcDevProjectContainer::class, false)) {
    class_alias(\ContainerXXXX\srcDevProjectContainer::class, srcDevProjectContainer::class, false);
}

return new \ContainerXXXX\srcDevProjectContainer();

srcDevProjectContainerクラスは、Symfony\Component\DependencyInjection\Containerを継承しているので、get()で保持しているサービスを取得できます。

<?php
// 著者注
// サービスコンテナ
// var/cache/dev/ContainerXXXX/srcDevProjectContainer.php

// ...

namespace ContainerXXXX;

use Symfony\Component\DependencyInjection\Container;

// ...

class srcDevProjectContainer extends Container
{
   // ...
}

おわりに

今回は、HttpKernelクラスの処理について見てきました。Symfony4が行っている処理を知ることで、Symfony4の理解が深まったと感じています。

最初は、Symfony4のような複雑で巨大なコードを読むことができるか不安でしたが、ステップ実行などの道具を使って少しずつ読んでいくことで、当初考えていたよりもコードを読むことができ、Symfony4の理解を深めることができました。

これからも、自分が使うツールに自信を持つために、コードリーディングを続けていきたいと思います。


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

この記事は Symfony Advent Calendar 2017 15日目の記事です。

はじめに

弊社ではドキュメント管理ツールとして esa を利用しています。

サービス開始直後から3年間ずっとお世話になっていて、まあまあヘビーに使い込んでいるほうだと思っています。
そんな状況もあって、近頃はWeb UIでの操作だけでは痒いところに手が届かないケースがちょこちょこと出始めていました。

特に直近で困ったのが、記事を正規表現で検索したいということでした。
esaは 今のところ正規表現での検索はできない ので、大好きな symfony/console でパパッとCLIツールを作ってしまう ことにしました。

作ったもの

https://github.com/ttskch/esa-cli

esa-cliという名前ですが、いわゆるAPIをラップした感じのCLIツールではなく、少し複雑なアプリケーションとしての機能を提供するためのものです。今のところgrep(っぽいもの)しかありません。

sed(っぽいもの)も作って一括置換とかしたいな〜と思っているので、年末年始もしお暇な方がいらっしゃいましたらPRお待ちしています笑

インストール方法

動作にはPHP 7.1.3以上の環境が必要です。

$ composer create-project ttskch/esa-cli:@dev
$ cd esa-cli
$ cp app/parameters.php{.placeholder,}
$ vi app/parameters.php
<?php
// app/parameters.php
$container['parameters.team_name'] = 'esaチーム名';
$container['parameters.access_token'] = 'APIアクセストークン';
$container['parameters.esa_paging_limit'] = 10;   // 1度のAPIコールで何ページまで自動でページングするか

ページングについては こちらをご参照 ください。

$ ln -s $(pwd)/app/esa /usr/local/bin/esa

使い方

$ esa grep -h

grep サブコマンドのヘルプを確認できます。

image

  • -s [QUERY] で対象の記事群を絞り込む
  • <pattern> 部分で本文内をgrepしたいパターンを指定する
  • [QUERY] 部分の書式は esa公式 と同じ
  • -e オプションで正規表現での検索を有効にする
  • -e オプションなしの場合は単純な文字列の部分一致比較

実際の使い方は以下のような感じになります。

$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){1,3}"
Users/t-kanemoto/tmp/テスト1:あ  
Users/t-kanemoto/tmp/テスト2:いい                                  
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){2}"
Users/t-kanemoto/tmp/テスト2:いい
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" "ううう"
Users/t-kanemoto/tmp/テスト3:ううう

image

実装の要点

DIコンテナ

今回はDIコンテナに Pimple を使いました。
以下のような感じで必要なサービスを組み立てています。

https://github.com/ttskch/esa-cli/blob/master/app/container.php

いくつかのサービスが書かれていますが、symfony/console に関係するものは以下の2つです。

  • console サービス
    • ttskch/esa-cli という名称でコンソールアプリケーションを作成
    • grep_command サービスをコンソールアプリケーションに追加
  • grep_command サービス
    • GrepCommand クラスのインスタンスを作成
      • GrepCommandSymfony\Component\Console\Command\Command を継承している
<?php

// ...

$container = new Container();

require __DIR__ . '/parameters.php';

$container['console'] = function($container) {
    $console = new Application('ttskch/esa-cli');
    $console->add($container['grep_command']);

    return $console;
};

// ...

$container['grep_command'] = function($container) {
    return new GrepCommand($container['esa_proxy']);
};

// ...

return $container;

ブートストラップ

ブートストラップファイルは以下のようにDIコンテナから console サービスを取り出して起動するだけの簡素な内容です。

https://github.com/ttskch/esa-cli/blob/master/app/esa

#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
$container = require_once __DIR__ . '/container.php';
$container['console']->run();

grepコマンド本体

grep コマンド本体のコードは以下のような感じです。記事本文のMarkdownを愚直に preg_match しています。

結果の出力を スタイリングして、grepコマンドらしくマッチ箇所を赤太字にするようにしました。

https://github.com/ttskch/esa-cli/blob/master/src/Command/GrepCommand.php

<?php
// ...
class GrepCommand extends Command
{
    private $esaProxy;

    // ...

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     */
    protected function execute(InputInterface $input, OutputInterface $output): void
    {
        $query = $input->getOption('query');
        $posts = $this->esaProxy->getPosts($query, $input->getOption('force'));

        // convert pattern for preg_match
        $pattern = $input->getArgument('pattern');
        if ($input->getOption('regexp')) {
            $pattern = str_replace('\/', '/', $pattern);
            $pattern = str_replace('/', '\/', $pattern);
        } else {
            $pattern = preg_quote($pattern);
        }
        $pattern = sprintf('/%s/%s', $pattern, $input->getOption('ignore-case') ? 'i' : '');

        $matches = [];

        foreach ($posts as $post) {
            $fullName = sprintf('%s/%s', $post['category'], $post['name']);

            $i = 1;
            foreach (explode("\n", $post['body_md']) as $line) {
                $condition = preg_match($pattern, $line, $m);
                if ($input->getOption('invert-match')) {
                    $condition = !$condition;
                }

                if ($condition) {
                    $matches[] = [
                        'full_name' => $fullName,
                        'line_number' => $i,
                        'line' => $line,
                        'matched' => $m[0] ?? '',
                    ];
                }
                $i++;
            }
        }

        foreach ($matches as $match) {
            $fullName = $match['full_name'];
            $lineNumber = $input->getOption('line-number') ? $match['line_number'] . ':' : '';
            $line = str_replace($match['matched'], sprintf('<fg=red;options=bold>%s</>', $match['matched']), $match['line']);

            $output->writeln(sprintf('%s:%s%s', $fullName, $lineNumber, $line));
        }
    }
}

esa APIクライアント

symfony/console の使い方とは関係ない話ですが、polidog/esa-php をラップして結果をキャッシュしたりページングを隠蔽したりする Esa\Proxy というクラスを作ってあります。

esa APIでは

という制限があるため、コマンド実行時の -s [QUERY] で絞り込んだ結果の記事数があまりに大量だと、闇雲に自動でページングするとリクエスト数制限を簡単にオーバーしてしまいます。なので、自動ページング数の上限を定数で定義する 仕様にしてあります。

https://github.com/ttskch/esa-cli/blob/master/src/Esa/Proxy.php

<?php
// ...
class Proxy
{
    const CACHE_KEY_PREFIX = 'ttskch.esa_cli.esa.proxy';
    const CACHE_LIFETIME = 3600;

    private $esa;
    private $cache;
    private $pagingLimit;

    // ...

    /**
     * @param null|string $query
     * @param bool $force
     * @return array
     */
    public function getPosts(?string $query, $force = false): array
    {
        $cacheKey = self::CACHE_KEY_PREFIX . '.posts.' . hash('md5', $query);

        if ($force || !$posts = $this->cache->fetch($cacheKey)) {
            $posts = $this->mergePages('posts', ['q' => $query]);
            $this->cache->save($cacheKey, $posts, self::CACHE_LIFETIME);
        }

        return $posts;
    }

    /**
     * @param string $methodName
     * @param array $params
     * @param int $firstPage
     * @return array
     */
    public function mergePages(string $methodName, array $params = [], int $firstPage = 1): array
    {
        if (!in_array($methodName, ['posts'])) {
            throw new LogicException('Invalid method name.');
        }

        $results = [];
        $page = $firstPage;

        for ($i = 0; $i < $this->pagingLimit && !is_null($page); $i++) {
            $result = $this->esa->$methodName(array_merge($params, [
                'per_page' => 100,
                'page' => $page,
            ]));
            $results = array_merge($results, $result[$methodName]);
            $page = $result['next_page'];
        }

        return $results;
    }
}

おわりに

個人的に、CLIツールを作成するときに自力でUI(引数の扱い、出力の整形、ヘルプ、etc)を作り込むのが本当に面倒で不毛だと思っているので、symfony/console がその辺全部いい感じにやってくれるとコマンドの処理を書くことに集中できてとても幸せです。

今回のesa-cliでも、まともに実装したクラスは Esa\ProxyCommand\GrepCommand の2つだけでした :v:

とりあえずすぐに使いたかったのでテスト皆無なWIPプロダクトですが、symfony/console のおかげで便利なツールが簡単に作れて業務が捗りましたよ、というお話でした!(もしよければどなたかテスト書いてください)


カルテット開発部では、CLIツールもPHPで作成してしまうエンジニアを募集しています!


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

Symfony Advent Calendar 2017 12日目の記事です。 枠が空いていたので3度めですが書いちゃいます。

REST APIで「フォームにsubmitした値がセットされない!」と思ったら

下記のように、ユーザーの編集を行うREST APIを書いたとします。

<?php


namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Class User
 *
 * @ORM\Entity
 * @package App\Entity
 */
class User
{
    /**
     * @var integer
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    public $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    public $name = 'my name';

    /**
     * @var array|string[]
     * @ORM\Column(type="json_array")
     */
    public $roles = [];
}

フォーム

<?php

namespace App\Form\Type;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserUpdateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('roles', ChoiceType::class, [
                'choices' => [
                    'Admin' => 'ROLE_ADMIN',
                    'User' => 'ROLE_USER',
                    'Tester' => 'ROLE_TESTER',
                ],
                'multiple' => true,
                'expanded' => true,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setDefaults([
                'data_class' => User::class,
                'csrf_protection' => false,
            ])
        ;
    }

}

コントローラ

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
     * @Method("PATCH")
     */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
            'method' => 'PATCH',
        ]);
        $form->handleRequest($request);

        if ($form->isValid()) {
            $this->em->flush();

            return new Response('success!');
        }

        return new Response('failed!');
    }
}

一見何の問題もありませんね。

では、このコントローラの機能テストを書いてみましょう。

App\Entity\User:
  user1:
    name: "user1"
    roles: ["ROLE_ADMIN", "ROLE_USER", "ROLE_TESTER"]
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
        $client->request('PATCH', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

実行してみます。

There was 1 failure:

1) App\Controller\UserControllerTest::test
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     0 => 'ROLE_ADMIN'
     1 => 'ROLE_USER'
+    2 => 'ROLE_TESTER'

.../tests/Controller/UserControllerTest.php:33

FAILURES!
Tests: 3, Assertions: 8, Failures: 1.

$client->request()で送ったパラメータ上ではroleを減らしたはずなのに、保存されたエンティティには反映されていません。 なぜでしょうか?

<?php

namespace Symfony\Component\Form;

// ...

class Form implements FormInterface
{
    // ...
    
    public function submit($submittedData, $clearMissing = true)
    {
        // ...
        if ($this->config->getCompound()) {
            // ...
            foreach ($this->children as $name => $child) {
                $isSubmitted = array_key_exists($name, $submittedData);
                if ($isSubmitted || $clearMissing) {
                    $child->submit($isSubmitted ? $submittedData[$name] : null, $clearMissing);
                    unset($submittedData[$name]);
                    if (null !== $this->clickedButton) {
                        continue;
                    }
                    if ($child instanceof ClickableInterface && $child->isClicked()) {
                        $this->clickedButton = $child;
                        continue;
                    }
                    if (method_exists($child, 'getClickedButton') && null !== $child->getClickedButton()) {
                        $this->clickedButton = $child->getClickedButton();
                    }
                }
            }
        }    
        // ...
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Form.php#L571

なんと、値がsubmitされてない場合(multipleかつexpandedなChoiceTypeで値が減った場合)は $clearMissing=true でないとsubmitした値は無視されてしまいます。つまり、rolesの ROLE_TESTER がsubmitされなかったのは無視されたということです。 clearMissingにfalseを指定した覚えはありませんが、どこでfalseになったのでしょうか?

そもそも Form::submit() でなく Form::handleRequest() でsubmitしたはずなので、Form::handleRequest()を見ると…

<?php

namespace Symfony\Component\Form\Extension\HttpFoundation;

// ...
/**
 * A request processor using the {@link Request} class of the HttpFoundation
 * component.
 *
 * @author Bernhard Schussek <bschussek@gmail.com>
 */
class HttpFoundationRequestHandler implements RequestHandlerInterface
{
    // ...
    
    /**
     * {@inheritdoc}
     */
    public function handleRequest(FormInterface $form, $request = null)
    {
        // ...
        
        $form->submit($data, 'PATCH' !== $method);
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php#L108

HTTPメソッドがPATCHの場合は強制的にclearMissingがfalseになっています。

解決策: PATCHを使わない

コントローラと機能テストをPATCHを使わない形に書き換えてみます。

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
-     * @Method("PATCH")
+     * @Method("PUT")
    */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
-            'method' => 'PATCH',
+            'method' => 'PUT',
        ]);
        $form->handleRequest($request);

        if ($form->isValid()) {
            $this->em->flush();

            return new Response('success!');
        }

        return new Response('failed!');
    }
}
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
-        $client->request('PATCH', '/user/1', [
+        $client->request('PUT', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

テストを実行してみます。

$ vendor/bin/phpunit -c ./
PHPUnit 6.5.2 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 2.49 seconds, Memory: 30.00MB

OK (3 tests, 8 assertions)

通るようになりました!

余談: なぜそうなっているか?

私の推測ですが、フォームについて

  • エンティティに対してFormTypeは一つ
  • PATCHで部分更新したり、POST, PUTで全体更新したりする
  • PATCHのときはsubmit()される値が不完全だが、submitされてない値をクリアしたくない

という使い方が想定されているのではないでしょうか? 今回のサンプルコードのように、編集時の項目構成で作ったフォームにPATCHで値を送信する使い方はイレギュラーなのかもしれません。

まとめ

なぜかうまくいかないなーと思った時、ソースをどんどん読み込んでいけば解決できるのがオープンソースの良いところです。
Symfonyで開発していて「謎現象?!」と思ったら、怖がらずソースを読んでみましょう。(手動で追っていくのは骨が折れるのでIDEの定義ジャンプ機能を活用したいですね)