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

長らくブランクが空きましたが当エントリはcfn-initはUbuntuのどの起動システムにも対応しているのか?(其の一) の続きです。今回はservicesキーの調査であることがより明確であるエントリ名に変更いたしました。

前回のおさらいと今回の目的

CloudFormationには便利なツールcfn-initが用意されています。これを利用することでservicesキーに指定したサービスの停止などの制御を行うことができます。ドキュメント によるとこれは起動システムsysvinitを前提としたものとのこと。しかし、Ubuntu14でもUbuntu16でもcronデーモンを停止することができました。起動システムとして、Ubuntu14はUpstart、Ubuntu16はsystemdが採用されているにも関わらず、です。前回はその仕組みについては棚上げにしていましたので今回はそこを調査したいと思います。

調査方法

今回は Ubuntu14.04 および Ubuntu16.04 の各々において以下の流れで調査を行いたいと思います。

  1. EC2インスタンスを起動
  2. 自作のデーモンを作成&設定
  3. このインスタンスのAMIを作成
  4. 3.で作成したAMIを指定してCloudFormationでインスタンス作成。この際にサービスの停止を定義する。
  5. 検証

Ubuntu14.04

1. EC2インスタンスを起動

ami-db7311bd を起動します。

2. 自作のデーモンを作成&設定

起動サービスで実行されるデーモンスクリプトを作成します。

#!/usr/bin/env bash

filename=$1
while (true); do
  sleep 1
  date >> /opt/daemon_test/${filename}.log
done

実行結果は以下となります。

$ sudo ./hello.sh foo
$ cat /opt/daemon_test/foo.log
Tue Jan  9 10:25:59 UTC 2018
Tue Jan  9 10:26:00 UTC 2018
Tue Jan  9 10:26:01 UTC 2018
Tue Jan  9 10:26:02 UTC 2018

Upstart準拠の起動設定を行います。/etc/init/hello-upstart.conf として以下のようなファイルを設置します。

description "hello-upstart"

start on runlevel [2345]
stop on runlevel [016]

chdir /opt/daemon_test
exec ./hello.sh upstart
respawn

次にデーモンの設定を反映します。

$ sudo initctl reload-configuration
$ sudo initctl list | grep hello
hello-upstart stop/waiting

インスタンスを再起動してデーモンが起動していることを確認します。

$ sudo initctl list | grep hello
hello-upstart start/running, process 958

$ tail -f /opt/daemon_test/upstart.log
Wed Jan 10 06:29:12 UTC 2018
Wed Jan 10 06:29:13 UTC 2018
Wed Jan 10 06:29:14 UTC 2018
Wed Jan 10 06:29:15 UTC 2018
Wed Jan 10 06:29:16 UTC 2018

3. このインスタンスを基にAMIを作成

Webコンソールから行いました。

4. 3.で作成したAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義。

実行したところ、cronデーモンは停止しましたが、自作デーモンは停止しませんでした。原因を調査してみます。

5. 検証

/var/log/cfn-init.logを見ると/etc/init.d/hello-upstartが無いという理由で落ちているようです。

 :
2018-01-26 08:30:03,817 [DEBUG] Setting service hello-upstart to disabled
2018-01-26 08:30:03,824 [ERROR] update-rc.d failed with error 1. Output: update-rc.d: /etc/init.d/hello-upstart: file does not exist

2018-01-26 08:30:03,824 [ERROR] Error encountered during build of config: Could not disable service hello-upstart (return code 1)
 :
ToolError: Could not disable service hello-upstart (return code 1)

参考にcronデーモンを見てみます。
/etc/init/cron.confが存在しますし、以下の実行結果からもUpstartで起動管理されているように見えます。

$ initctl list | grep cron
cron start/running, process 1073

しかし、sysvinitスタイルの定義の置き場/etc/init.dにも設定は存在していました。というかシンボリックリンクが設置されているようですね。

$ ls -la /etc/init.d/cron
lrwxrwxrwx 1 root root 21 Feb  9  2013 /etc/init.d/cron -> /lib/init/upstart-job

/lib/init/upstart-jobのコメントにはSymlink target for initscripts that have been converted to Upstart.と記してあります。どうやらこれがキモのようですね。自作デーモンにおいてもこれを真似して設置してみました。

$ cd /etc/init.d
$ sudo ln -s /lib/init/upstart-job hello-upstart
$ ls -la hello-upstart
lrwxrwxrwx 1 root root 21 Jan 28 10:05 hello-upstart -> /lib/init/upstart-job

これを基にAMIを作成しました。
そして、このAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義しました。
結果、cronデーモンも自作デーモンも停止しました。

Ubuntu16.04

1. EC2インスタンスを起動

ami-a07012c6 を起動します。

2. 自作のデーモンを作成&設定

起動サービスで実行されるデーモンスクリプトを作成します。Ubuntu14.04 の場合と同じですね。

#!/usr/bin/env bash

filename=$1
while (true); do
  sleep 1
  date >> /opt/daemon_test/${filename}.log
done

systemd準拠の起動設定を行います。/etc/systemd/system/hello.service として以下のようなファイルを設置します。

[Unit]
Description = hello systemd

[Service]
ExecStart = /opt/daemon_test/hello.sh systemd
Restart = always
Type = simple

[Install]
WantedBy = multi-user.target

次にデーモンの設定反映します。

# 状態確認
$ systemctl list-unit-files --type=service | grep hello
hello.service                              disabled

# 有効化
$ sudo systemctl enable hello
Created symlink from /etc/systemd/system/multi-user.target.wants/hello.service to /etc/systemd/system/hello.service.
$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: inactive (dead)
$ systemctl list-unit-files --type=service | grep hello
hello.service                              enabled

# 起動
$ sudo systemctl start hello
$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-16 09:22:38 UTC; 3s ago
 Main PID: 6463 (bash)
    Tasks: 2
   Memory: 348.0K
      CPU: 8ms
   CGroup: /system.slice/hello.service
           ├─6463 bash /opt/daemon_test/hello.sh
           └─6475 sleep 1

Jan 16 09:22:38 ubuntu-xenial systemd[1]: Started hello systemd.

インスタンスを再起動してデーモンが起動していることを確認します。

$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-16 09:22:38 UTC; 3s ago
 Main PID: 6463 (bash)
    Tasks: 2
   Memory: 348.0K
      CPU: 8ms
   CGroup: /system.slice/hello.service
           ├─6463 bash /opt/daemon_test/hello.sh
           └─6475 sleep 1

Jan 16 09:22:38 ubuntu-xenial systemd[1]: Started hello systemd.

$ tail -f /opt/daemon_test/systemd.log
Wed Jan 10 06:31:12 UTC 2018
Wed Jan 10 06:31:13 UTC 2018
Wed Jan 10 06:31:14 UTC 2018
Wed Jan 10 06:31:15 UTC 2018
Wed Jan 10 06:31:16 UTC 2018

3. このインスタンスを基にAMIを作成

Webコンソールから行いました。

4. 3.で作成したAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義。

実行したところ、cronデーモンは停止しましたが、自作デーモンは停止しませんでした。原因を調査してみます。

5. 検証

/var/log/cfn-init.logを見るとcannot find a LSB script for helloという理由で落ちているようです。

2018-01-28 10:41:34,511 [DEBUG] Setting service hello to disabled
2018-01-28 10:41:34,520 [ERROR] update-rc.d failed with error 1. Output: update-rc.d: error: cannot find a LSB script for hello

2018-01-28 10:41:34,520 [ERROR] Error encountered during build of config: Could not disable service hello (return code 1)
Traceback (most recent call last):
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/construction.py", line 542, in run_config
    CloudFormationCarpenter(config, self._auth_config).build(worklog)
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/construction.py", line 270, in build
    CloudFormationCarpenter._serviceTools[manager]().apply(services, changes)
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/service_tools.py", line 155, in apply

参考にcronデーモンを見てみます。
以下の実行結果からもsystemdで起動管理されているように見えます。

$ sudo systemctl status cron
● cron.service - Regular background program processing daemon
   Loaded: loaded (/lib/systemd/system/cron.service; disabled; vendor preset: enabled)
   Active: inactive (dead)
     Docs: man:cron(8)

しかし、sysvinitスタイルの定義の置き場/etc/init.dにも設定は存在していました。ただ、Ubuntu14.04とは違ってシンボリックリンクではなさそうです。

$ ls -la /etc/init.d | grep cron
-rwxr-xr-x  1 root root 3049 Apr  5  2016 cron

内容を見るといわゆる「普通の」sysvinit用の設定ファイルのように見えます。
自作デーモンにおいてもこれを真似して設置してみました。

スケルトンを基にコピーして編集。実行権限を付与します。

$ sudo cp -iv /etc/init.d/skeleton /etc/init.d/hello-sysvinit
'/etc/init.d/skeleton' -> '/etc/init.d/hello-sysvinit'
$ sudo chmod +x hello-sysvinit

スケルトンとの差分はこんな感じ。

### BEGIN INIT INFO
-# Provides:          skeleton
+# Provides:          hello-sysvinit
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5

DESC=“Description of the service”
-DAEMON=/usr/sbin/daemonexecutablename
+NAME=hello-sysvinit
+DAEMON=/opt/daemon_test/hello.sh
+DAEMON_ARGS=“sysvinit”

sysvinitの起動設定と同様の作業をすることで

$ sudo update-rc.d hello-sysvinit defaults

以下のようにsystemdの起動設定が作られました。

$ ls -la /run/systemd/generator.late/hello-sysvinit.service
-rw-r--r-- 1 root root 592 Jan 29 15:45 /run/systemd/generator.late/hello-sysvinit.service

これを基にAMIを作成しました。
そして、このAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義しました。
結果、cronデーモンは無事に停止しましたが自作デーモンは停止しませんでした。状態は以下の通り。Activefailedとなっています。

$ systemctl status hello-sysvinit
● hello-sysvinit.service - LSB: Example initscript
  Loaded: loaded (/etc/init.d/hello-sysvinit; bad; vendor preset: enabled)
  Active: failed (Result: timeout) since Tue 2018-01-30 08:45:22 UTC; 4min 6s ago
    Docs: man:systemd-sysv-generator(8)
  CGroup: /system.slice/hello-sysvinit.service
          ├─ 1266 bash /opt/daemon_test/hello.sh sysvinit
          └─11182 sleep 1

Jan 30 08:40:22 ip-172-31-20-228 systemd[1]: Starting LSB: Example initscript...
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Start operation timed out. Terminating.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: Failed to start LSB: Example initscript.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Unit entered failed state.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Failed with result ‘timeout’.

/var/log/cfn-init.logを見ると当該サービスを停止する必要がないとみなされたようです。

2018-01-30 08:45:54,198 [DEBUG] Setting service hello-sysvinit to disabled
2018-01-30 08:45:54,295 [INFO] disabled service hello-sysvinit
2018-01-30 08:45:54,311 [DEBUG] No need to modify running state of service hello-sysvinit

これはデーモン稼働時のステータスがactivating (start)になっていることに起因しているのではないかと考えられます。

$ systemctl status hello-sysvinit
● hello-sysvinit.service - LSB: Example initscript
   Loaded: loaded (/etc/init.d/hello-sysvinit; bad; vendor preset: enabled)
   Active: activating (start) since Tue 2018-01-30 05:09:55 UTC; 1min 6s ago
     Docs: man:systemd-sysv-generator(8)
  Control: 1210 (hello-sysvinit)
    Tasks: 3
   Memory: 560.0K
      CPU: 118ms
   CGroup: /system.slice/hello-sysvinit.service
           ├─1210 /bin/sh /etc/init.d/hello-sysvinit start
           ├─1252 bash /opt/daemon_test/hello.sh sysvinit
           └─1762 sleep 1

Jan 30 05:09:55 ip-172-31-29-245 systemd[1]: Starting LSB: Example initscript...

ちなみにcronデーモンだと以下のように active (running) になっています。/etc/init.d/hello-sysvinitの書き方に一工夫必要なように感じられます。

$ systemctl status cron
● cron.service - Regular background program processing daemon
   Loaded: loaded (/lib/systemd/system/cron.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-30 05:09:55 UTC; 57s ago
     Docs: man:cron(8)
 Main PID: 1099 (cron)
    Tasks: 1
   Memory: 356.0K
      CPU: 1ms
   CGroup: /system.slice/cron.service
           └─1099 /usr/sbin/cron -f

Jan 30 05:09:55 ip-172-31-29-245 cron[1099]: (CRON) INFO (pidfile fd = 3)
Jan 30 05:09:55 ip-172-31-29-245 cron[1099]: (CRON) INFO (Running @reboot jobs)
Jan 30 05:09:55 ip-172-31-29-245 systemd[1]: Started Regular background program processing daemon.

systemdと正しくコンバーチブルなsysvinit設定ができていなかったようですね…
今回はこれ以上は深追いしないでおきます。

まとめ

今回の結果から確実に言えるのは、プレーンなUpstartおよびsystemdの設定のみから起動されるデーモンはcfn-initservicesキーでは制御できないということです。cronデーモンはsysvinitとのコンバーチブルな設定が配置されていたためcfn-initservicesキーで制御できていたのですね。もし制御したいサービスがsysvinitコンバーチブルな設定が配置されていない場合は自前で準備してやる必要がありそうです。少々面倒ですね。

くわえて、今回の実験においてcfn-initのインストールに非常に時間がかかるという事実がありました。インストール完了までは各種デーモンが稼働してしまっているのが確認できました。リソースのスペックによるとは思うのですが、この点は要注意です。

これらを総合して考えると、cfn-initservicesキーを使ってのサービスの停止が有用な場面は限られてくると思われます。「sysvinit設定が配置されていて、稼働時間が完全にゼロにならなくても実害がないサービス」のみで使えそうですね。cfn-initの使用を前提とした場合は、OSの起動システムでの起動設定は行わずcfn-initにて所望のデーモンを起動するのが良さそうです。 何ごとも適材適所ですね。


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

いきなりですが、PHPでオブジェクトの比較をするのって面倒ではないですか?
かといってバグ対応の方が面倒なのでテストをサボらずにしぶしぶ書く、そんな毎日でした。

そこで、厳密なオブジェクトの比較を簡単にできないかと考え解決策に至るまでの道のりの話です。

面倒な例

<?php

class Foo {
  private $bar;
  private $baz;

  public function __construct($bar, $baz)
  {
    $this->bar = $bar;
    $this->baz = $baz;
  }

  public function getBar()
  {
    return $this->bar;
  }

  public function getBaz()
  {
    return $this->baz;
  }
}

戻り値のチェック

タイプ量が多いのでまあまあ面倒です。
PHPのテストは本当に手が疲れます。

<?php

$fooList = $service->doSomething();
self::assertCount(3, $fooList);

// プロパティが多いほどタイプ量が増える
self::assertEquals('bar1', $fooList[0]->getBar());
self::assertEquals('baz1', $fooList[0]->getBaz());
self::assertEquals('bar2', $fooList[1]->getBar());
self::assertEquals('baz2', $fooList[1]->getBaz());

assertメソッドを定義するのは機能としてイマイチ

誰もがメソッドにまとめた事があるかと思いますが、あまり便利じゃありませんよね?
値の種類(?)が増えると引数を増やすしかなく、早々にやめました。

<?php

$fooOrNull = $service->doSomething();

// 例えばnullableになったとした場合、フラグを足さない限り`assert`メソッドにまとめられない
self::assertNotNull($fooOrNull);
self::assertFoo('bar1', 'bar2', $fooOrNull);

// それともフラグを追加する?
self::assertFoo('bar1', 'bar2', $allowNull = false, $fooOrNull);

引数のチェック

複数回コールされる場合はもはやテストなのかすら怪しいですね。

<?php

$service
  ->expect(self::exactly(2))
  ->method('doSomething')
  ->with(self::callback(function (Foo $foo) {
    if ('bar1' === $foo->getBar()) {
      self::assertEquals('baz1', $foo->getBaz());
    } else {
      self::assertEquals('baz2', $foo->getBaz());
    }
  }));

$service->doSomething(new Foo('bar1', 'baz1'));
$service->doSomething(new Foo('bar2', 'baz2'));

労力の割にメリットがないので、妥協できる範囲でサボりますよね・・・

面倒くさがってサボる

結局、最低限しかテストできない状態に。

<?php

// 間違ってはいないが・・・
$service
  ->expect(self::exactly(3))
  ->method('doSomething')
  ->with(self::isInstanceOf(Foo::class))

解決策(ボツ): 制約(Constraint)を定義する

特定のオブジェクトに対するPHPUnitの制約を定義しておく事で、手軽さと厳密さを両立する事が出来ました。
比較ロジック自体がPHPUnitの資産をあまり有効活用できていない点や、比較処理の実装が必要になる点はイマイチですね。

<?php

pubic function test()
{
  // 比較が複数回必要でもタイプ数は少なめ
  self::assertThat($fooList[0], isFoo('bar1', 'baz1'));
  self::assertThat($fooList[1], isFoo('bar2', 'baz2'));

  // 引数の比較がそれらしい方法で実現できる
  $this->service
    ->expect(self::exactly(2))
    ->method('doSomething')
    ->with(self::logicalOr(
      self::isFoo('bar1', 'baz1'),
      self::isFoo('bar2', 'baz2')
    ));

  // 比較の柔軟性が高い
  $fooOrNull = $this->doSomething();
  self::assertThat(self::isNullableFoo('bar', 'baz'), $fooOrNull);
}

private static function isFoo($bar, $baz)
{
  return self::logicalAnd(
    self::isInstanceOf(Foo::class),
    self::callback(function (Foo $foo) use ($bar, $baz) {
      // 比較方法は自前実装なので、テストとしてはPHPUnitの比較には劣る
      return $bar === $foo->getBar() && $baz === $foo->getBaz();
    })
  );
}

private static function isNullableFoo($bar, $baz)
{
  return self::logicalOr(
    self::identialTo(null),
    self::isFoo($bar, $baz)
  );
}

他にも、テストに失敗した場合のメッセージが親切でない(何と比較したか表示できない)ので、テストに失敗した時はまあまあ最悪です。

# テストが失敗した結果

1) SomeTest::test
Failed asserting that Foo Object &000000005b85f7750000000027609f23 (
    'bar' => 'bar'
    'baz' => 'baz'
) is instance of class "Foo" and is accepted by specified callback.

解決策: プロパティベースの制約を定義する

オブジェクトの比較ロジックを自前で実装せずに、プロパティベースの制約を組み合わせて制約を生成するといい感じになりました。
素の方法と比べると以下のようなメリットがあります。

  • テストに失敗した時に何が原因なのかが分かりやすい
  • 比較ロジックにPHPUnitの資産をそのまま使える
  • 柔軟性が高い
  • 定義が楽

制約の組み立て方のイメージは以下の通り。

<?php

class SomeTest extends \PHPUnit_Framework_TestCase
{
  private static function isFoo($bar, $baz)
  {
    return self::logicalAnd(
      // 型が `Foo` である
      self::isInstanceOf(Foo::class),
      // プロパティ `bar` が `$bar`と一致する
      self::property(self::equalTo($bar), 'bar'),
      // プロパティ `baz` が `$baz`と一致する
      self::property(self::equalTo($baz), 'baz')
    );
  }
}

テストに失敗した時に何が原因なのかが分かりやすい

何と比較したのかが表示されるため、原因が分かりやすい。

<?php

self::assertThat(new Foo('bar', 'baz'), self::isFoo('a', 'b'));
1) SomeTest::test
Failed asserting that Foo Object &000000003c51c718000000004dc4af2d (
    'bar' => 'bar'
    'baz' => 'baz'
) is instance of class "Foo" and property "bar" is equal to <string:a> and property "baz" is equal to <string:b>.

比較ロジックにPHPUnitの資産をそのまま使える

PHPUnitの制約をプロパティ$barに適用させるだけなので、再実装はありません。

<?php
// プロパティ `bar` が `$bar`と一致する
self::property(self::equalTo($bar), 'bar'),

柔軟性が高い

値の種類(?)が増えても、好きな粒度で制約を合成でき柔軟に対応できる。

<?php

public function test()
{
  $constraint = self::isFoo('bar', self::isBaz());
}

// 定義
private function isFoo(string $bar, \PHPUnit_Framework_Constraint $baz = null)
{
  // `Foo#baz` の比較を切り替えた後に、制約を合成すればnullableも簡単に実現できる
  $baz = $baz ?: self::isNull();

  return self::logicalAnd(
    self::isInstanceOf(Foo::class),
    self::property($bar, 'bar'),
    self::property($baz, 'baz')
  );
}

定義が楽

楽ですね

self::property() なんてメソッドないんだけど・・・?

素のPHPUnitにはこの制約の実装がないので実装が必要です。
何度も実装するのは面倒なので、制約の実装ついでに便利メソッドを生やしOSSとして公開しました。

https://packagist.org/packages/hshn/phpunit-object-constraint

概念は今回紹介した内容と同じですが、APIがよりシンプルになっています。

<?php

// この制約は以下のような意味を持ちます
//   - \stdClassインスタンスである かつ
//   - プロパティ `foo` が 'a' で始まり、'e'で終わる かつ
//   - プロパティ `bar` が true である
$constraint = $this->constraintFor(\stdClass::class)
    ->property('foo')
        ->stringStartsWith('a')
        ->stringEndsWith('e')
    ->property('bar')
        ->isTrue()
    ->getConstraint();

制約を作ったらあとはassertするだけです。

<?php

self::assertThat($value, $constraint);

まとめ

2つの面倒くささに板挟みにされても、トレードオフだと言い聞かせて片方の面倒くささを受け入れずに済むようになりました。
まあまあいいと思いますよ〜


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

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の理解を深めることができました。

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