LoginSignup
10
3

More than 1 year has passed since last update.

コードリーディングのススメとTips風 〜CakePHP4の例を添えて〜

Last updated at Posted at 2021-12-20

この記事は、ランサーズ Advent Calendar 2021 20日目の記事です。

@ktplato です。11月にランサーズに入社しまして、サーバーサイドエンジニアとしてPHPを書いてます。チームのミッションに取り組む傍ら、サービスのアーキテクチャの改善に強く関心を持っており、少しずつ行動してたりもします(この辺はまた別の記事でお話しできたらいいなと思ってます、そのうち)。

はてさて、アドベントカレンダー参加申請したはいいものの、何について書こうか... 最近取り組んでいるメタプログラミングがいいかな... とつい2日前まで悩みながら他の方々のアドベントカレンダーを読んでいると、15日目の@manamin0521さんの記事にて、「言語やフレームワークの内部の実装が気になるようになった」という話を見かけ、それなら関連してとりあえず CakePHP のキャッチアップついでにコードリーディング記事書くか、と無難なところに落ち着きました。他の人の記事に言及するとアドベントカレンダーっぽいかなと思います。
せっかくなので、OSS のコードリーディングによって得られるメリットや、コードリーディング未経験の人でも読みやすくするための工夫についても、自分なりの意見を書いてみます(この辺はあくまで個人的所感です)。

コードリーディングの効能

自分が思うコードリーディングのメリットを雑に列挙します。MECEになってない点はご容赦ください。

  • 普段の実装におけるアイデアの引き出しが増える
    • 特にフレームワーク本体の実装やテストから得られる情報は、普段の開発に活かせることも多いです
  • 各機能の振る舞いについて深い理解が得られる
    • 深い理解に基づいた、より良い実装を考えることができます
    • ドキュメントに書かれるような自然言語での説明よりも、コードの方がより多くを雄弁に語ることも珍しくありません
  • 良いコードに触れることができる
    • 結局の所、良いコードを書けるようになるには、良いコードを見るのが手っ取り早いです
    • 広く使われる OSS に関わる開発者は、やはり技術的に優秀な方が比較的多い傾向はあると思います
  • 設計面の勉強になる
    • OSS にはデザインパターンや設計原則がつきもの
  • 他者が書いたコードを読む際の底力がつく
    • コードリーディングは筋トレです

コードリーディングのコツ

コードを読むコツは関数の入力と出力にのみ着目し極力実装を読まないことである、という意見もあり、一定それは正しいと思いますし、経験と知識に基づいて推測することも大いに必要な力なのですが、ライブラリやフレームワークを使っていると一見不可解な挙動に悩まされ、内部の実装を読む必要性に迫られることもしばしばです。ここでは後者のような状況において、じっくりと実装に向き合う方法について考えてみます。

さて、「OSSのコードを読もう!」と思っても、やはり最初は難しく感じることと思います。難しく感じる理由としては、

  • 真正面から実装コードに当たると複数の関心事や多数の不明点が同時に迫ってくる
    • どこから手をつけて良いかわからない
  • 目の前のコードがどうやって or 何のために使われるのかわからない
    • コードを読む目的が希薄になってしまっている
    • 使う側の視点に立てていない

といったことが挙げられるのではないでしょうか。
ですが、これらは対処法があればすぐにでも解決できる問題です。あとは言語の文法などの基礎理解さえあれば、分解した1つ1つは特に難しくないと感じられることも割と多いのではないかと思っています(もちろん機能や対象となるOSSによっては他の分野の知識が求められることもよくあるのですが)。

これらへの対処法として、取り組みやすいものを考えてみます。

普段よく使用するライブラリやフレームワークから読み始める

全く使ったことのないものよりは、使用側としての知識がある分、使用したことのあるライブラリやフレームワークの方がやはり最初はとっつきやすいでしょう。特に、使ったことのある機能で内部がどのように動いているか気になっているものがあれば、そこから始めてみるのも良いと思います。

大まかな処理の流れを理解しておく

例えばフレームワーク起動〜レスポンスを返すまでといった、フレームワークの共通の処理を大まかにでも一旦把握しておいてからの方が、細部の処理には入っていきやすいです。登場するクラス群がそれぞれ何の役割を持っているのか、把握するためにはやはり一度一連の流れを追ってみるのが良いと思います。

デバッガを利用する

特にステップ実行はコードリーディングにおいて非常に便利なので、大いに頼ると良いです。

ライブラリ・フレームワークの機能のテストコードを利用する

デバッガを併用して、テストコードを起点にするのがオススメです。テストコードは通常、1つの機能だけを確認するために書かれるので、そのテストコードでチェックしたいことが何なのかに注目しながら追うと1つの関心事に集中しやすいためです。調べたいことに対するちょうど良いテストケースが用意されていなければ、自分で書いても良いでしょう。

また、テストコードからその機能の振る舞いが読み取れるので、単純にどのような機能がライブラリ・フレームワークに用意されているのかの勉強にもなります。慣れると直接コードを読む方が早い、と思うことも出てきてドキュメントを読むのがちょっと億劫になります。嘘です。ドキュメントは読みますので叩かないでください。

わかるところから読んでいく

慣れていないうちは、目的の処理を理解するためにそもそも依存先の処理をある程度理解しなければならない、といった事態になりがちです。
その場合、依存先を深く追っていき、せいぜい言語の標準ライブラリぐらいにしか依存していないところから理解していくのも一つの手です。ただでさえじっくり時間をかけて読もうとしているのに輪をかけて迂遠な手段ではありますが、これはこれで普段意識しないようなライブラリの存在を知ることができたりもするので、勉強にはなると思います。

Cake\Console を読む

上記の工夫をいくつか交えつつ、コードリーディングを実践してみましょう。コードリーディング経験の浅い方は実際に動かしながらやってみても良いかもしれません。
今回はランサーズの一部システムでも使用されている、CakePHP4 のコードリーディングをしてみます。どのパッケージを読むかも検討する余地がある点ですが、今回はCake\Consoleパッケージを対象としましょう。
あまり1つ1つ丁寧に説明していくと記事が長くなりすぎるので、適宜 phpdoc や関係のない処理を省略しつつの要所要所の説明になりますがご容赦ください。また、調査にあまり時間をかけられていないので、筆者が意図を誤認している箇所もあるかもしれません。誤りが見つかった場合にはご指摘くださると幸いです。

起動からCommand実行までを調べる

Cake\Consoleは CLI 処理を司るパッケージですが、大まかな処理を把握するために、そもそもどのように起動し、指定された Command を実行するのかをまずは調べてみましょう。適当な Command を作成して実際に叩いて確認することにします。今回は EchoCommandという、引数で受け取った文字列をそのまま標準出力に出力するだけの Command を作成してみました。

App\Command\EchoCommand.php
<?php
declare(strict_types=1);

namespace App\Command;

// ...

class EchoCommand extends Command
{
    public function execute(Arguments $args, ConsoleIo $io)
    {
        $io->out($args->getArgumentAt(0));
    }
}
$ ./bin/cake Echo hello
hello

XDebug を起動し、Command を実行します。この時 XDebug の stopOnEntrytrue にしておくとエントリーポイントに侵入した時点で停止してくれます。

さて、コードを読んでいきましょう。

bin/cake.php
#!/usr/bin/php -q
<?php
// Check platform requirements
require dirname(__DIR__) . '/config/requirements.php';
require dirname(__DIR__) . '/vendor/autoload.php';

use App\Application;
use Cake\Console\CommandRunner;

// Build the runner with an application and root executable name.
$runner = new CommandRunner(new Application(dirname(__DIR__) . '/config'), 'cake');
exit($runner->run($argv));

App\Application は Cake アプリケーション全体のセットアップを管理するクラスです。ミドルウェアや、Cake4 以降導入された DI コンテナにサービスやサービスプロバイダを登録する処理などがここに置かれます。
Cake\Console\CommandRunner::run$argv が渡された上で実行されており、その戻り値が exit()に渡されています。つまり、戻り値は終了ステータスであるのだろうと推測できます。ちなみに $argv は通常の変数と同じような命名なので勘違いされるかもですが、PHP の定義済み変数です(参考:argv - Manual - PHP)。

Cake\Console\CommandRunner::runの処理を追ってみましょう。

Cake\Console\CommandRunner.php
class CommandRunner implements EventDispatcherInterface
{
    /**
     * Run the command contained in $argv.
     *
     * Use the application to do the following:
     *
     * - Bootstrap the application
     * - Create the CommandCollection using the console() hook on the application.
     * - Trigger the `Console.buildCommands` event of auto-wiring plugins.
     * - Run the requested command.
     *
     * @param array $argv The arguments from the CLI environment.
     * @param \Cake\Console\ConsoleIo|null $io The ConsoleIo instance. Used primarily for testing.
     * @return int The exit code of the command.
     * @throws \RuntimeException
     */
    public function run(array $argv, ?ConsoleIo $io = null): int
    {
        $this->bootstrap();

        $commands = new CommandCollection([
            'help' => HelpCommand::class,
        ]);
        if (class_exists(VersionCommand::class)) {
            $commands->add('version', VersionCommand::class);
        }
        $commands = $this->app->console($commands);

        if ($this->app instanceof PluginApplicationInterface) {
            $commands = $this->app->pluginConsole($commands);
        }
        $this->dispatchEvent('Console.buildCommands', ['commands' => $commands]);
        $this->loadRoutes();

        if (empty($argv)) {
            throw new RuntimeException('Cannot run any commands. No arguments received.');
        }
        // Remove the root executable segment
        array_shift($argv);

        $io = $io ?: new ConsoleIo();

        try {
            [$name, $argv] = $this->longestCommandName($commands, $argv);
            $name = $this->resolveName($commands, $io, $name);
        } catch (MissingOptionException $e) {
            $io->error($e->getFullMessage());

            return CommandInterface::CODE_ERROR;
        }

        $result = CommandInterface::CODE_ERROR;
        $shell = $this->getCommand($io, $commands, $name);
        if ($shell instanceof Shell) {
            $result = $this->runShell($shell, $argv);
        }
        if ($shell instanceof CommandInterface) {
            $result = $this->runCommand($shell, $argv, $io);
        }

        if ($result === null || $result === true) {
            return CommandInterface::CODE_SUCCESS;
        }
        if (is_int($result) && $result >= 0 && $result <= 255) {
            return $result;
        }

        return CommandInterface::CODE_ERROR;
    }

1メソッドにしては結構長い処理ですが、大まかにやっていることはコメントに書いてありますね。このように丁寧なコメントが残されていることもあるので、見かけたら軽く目を通してみましょう。

  • Bootstrap the application
  • Create the CommandCollection using the console() hook on the application.
  • Trigger the Console.buildCommands event of auto-wiring plugins.
  • Run the requested command.

脇道に逸れるとだらだらと長ったらしくなってしまうので、ここでは2番目の Create the CommandCollection using the console() hook on the application.と、4番目の Run the requested command.に該当する処理に着目してみます。

Create the CommandCollection using the console() hook on the application.

    public function run(array $argv, ?ConsoleIo $io = null): int
    {
        $this->bootstrap();

        $commands = new CommandCollection([
            'help' => HelpCommand::class,
        ]);
        if (class_exists(VersionCommand::class)) {
            $commands->add('version', VersionCommand::class);
        }
        $commands = $this->app->console($commands);

まず Cake\Console\CommandCollectionを生成しています。大まかには ./bin/cakeに渡す引数に対応する Command を紐づけるためのコレクションです。Cake 標準のHelpCommandVersionCommandadd した後、さらに App\Application::console内で CommandCollection::autoDiscoverを実行し、Command 群を登録しています。

App\Application.php
    public function console(CommandCollection $commands): CommandCollection
    {
        return $commands->addMany($commands->autoDiscover());
    }
Cake\Console\CommandCollection.php
    public function autoDiscover(): array
    {
        $scanner = new CommandScanner();

        $core = $this->resolveNames($scanner->scanCore());
        $app = $this->resolveNames($scanner->scanApp());

        return $app + $core;
    }

Cake\Console\CommandScannerの処理によると、scanCoreで Cake 標準の Command 群を読み込み、scanAppでユーザーが定義した Command 群も読み込んでいます。 ただ、デフォルトで読み込む対象は App\ShellApp\Commandとなり、より細かく分けたい場合は工夫が必要そうに見えます。

Cake\Console\CommandScanner.php
    public function scanApp(): array
    {
        $appNamespace = Configure::read('App.namespace');
        $appShells = $this->scanDir(
            App::classPath('Shell')[0],
            $appNamespace . '\Shell\\',
            '',
            []
        );
        $appCommands = $this->scanDir(
            App::classPath('Command')[0],
            $appNamespace . '\Command\\',
            '',
            []
        );

        return array_merge($appShells, $appCommands);
    }

ということで、一連の定義された Command や Shell を読み込む段階だったとわかります。そして、この段階で最初に定義した EchoCommandが読み込まれたことになります。ただし、インスタンス化はまだされていません。
プラグインが導入されていた場合、プラグイン側で定義されている Command も読み込みますが、今回は割愛します。

Run the requested command.

必須ではないので解説を割愛していますが、Console.buildCommandsのディスパッチ、ルーティングの読み込みなどを終えると、その後ようやく Command の実行に入ります。

Cake\Console\CommandRunner.php
        // ...

        if (empty($argv)) {
            throw new RuntimeException('Cannot run any commands. No arguments received.');
        }
        // Remove the root executable segment
        array_shift($argv);

        $io = $io ?: new ConsoleIo();

        try {
            [$name, $argv] = $this->longestCommandName($commands, $argv);
            $name = $this->resolveName($commands, $io, $name);
        } catch (MissingOptionException $e) {
            $io->error($e->getFullMessage());

            return CommandInterface::CODE_ERROR;
        }

        $result = CommandInterface::CODE_ERROR;
        $shell = $this->getCommand($io, $commands, $name);
        if ($shell instanceof Shell) {
            $result = $this->runShell($shell, $argv);
        }
        if ($shell instanceof CommandInterface) {
            $result = $this->runCommand($shell, $argv, $io);
        }

        if ($result === null || $result === true) {
            return CommandInterface::CODE_SUCCESS;
        }
        if (is_int($result) && $result >= 0 && $result <= 255) {
            return $result;
        }

        return CommandInterface::CODE_ERROR;
    }

$argv の先頭はスクリプト名(今回の場合 path/to/project/bin/cake)になるので、これを array_shiftで取り除いています。その後、longestCommandNameによって、引数全体と命名が最長一致する Command が選ばれるようですが、通常一つ目の引数(ここでは Echo )の時点で一意になるように定義するのが良い習慣だと思います。よって、この点は一旦あまり気にしないことにしましょう(もしかしたらサブコマンド機能で使われる想定なのかもしれません)。

Cake\Console\CommandRunner.php
    protected function longestCommandName(CommandCollection $commands, array $argv): array
    {
        for ($i = 3; $i > 1; $i--) {
            $parts = array_slice($argv, 0, $i);
            $name = implode(' ', $parts);
            if ($commands->has($name)) {
                return [$name, array_slice($argv, $i)];
            }
        }
        $name = array_shift($argv);

        return [$name, $argv];
    }

ただ、この処理の中で再度 array_shiftが行われています。これで $name には Echo が入り、$argvには hello が残るのみです。その $nameを元に、CommandCollectionから該当する Command を探します。その処理が getCommandです。
この時点で、CommandCollectionに登録した Command 群は一切インスタンス化されていません。そのため、get という名前からは少々推測し難いですが、取得した後この中で createCommand が呼ばれ、インスタンスを取得することになります。

Cake\Console\CommandRunner.php
    protected function getCommand(ConsoleIo $io, CommandCollection $commands, string $name)
    {
        $instance = $commands->get($name);
        if (is_string($instance)) {
            $instance = $this->createCommand($instance, $io);
        }

        // ...

        return $instance;
    }

    protected function createCommand(string $className, ConsoleIo $io)
    {
        if (!$this->factory) {
            $container = null;
            if ($this->app instanceof ContainerApplicationInterface) {
                $container = $this->app->getContainer();
            }
            $this->factory = new CommandFactory($container);
        }

        $shell = $this->factory->create($className);
        if ($shell instanceof Shell) {
            $shell->setIo($io);
        }

        return $shell;
    }

また、先ほども簡単に触れましたが、Cake4 からは DI コンテナが導入されており、Command のコンストラクタも DI できる箇所の対象になっています(参考:依存性の注入(DI))。事前にApp\Application::services()内で Command をコンテナに登録しておくことで、必要なインスタンスの注入を終えた状態の Command が取得できます。
CommandFactory 内でコンテナに Command が登録されている場合、コンテナを経由してインスタンスを取得する旨の処理が書かれています。コンテナに登録されていない場合、引数には何も与えずにそのままインスタンスを生成します。

Cake\Console\CommandFactory.php
<?php

// ...

class CommandFactory implements CommandFactoryInterface
{
    // ...

    public function create(string $className)
    {
        if ($this->container && $this->container->has($className)) {
            $command = $this->container->get($className);
        } else {
            $command = new $className();
        }

        // ...

        return $command;
    }

そして、CommandRunnerが Command のインスタンスを受け取り、runCommand内で Cake\Console\BaseCommand::run を実行します。

Cake\Console\BaseCommand.php
    public function run(array $argv, ConsoleIo $io): ?int
    {
        $this->initialize();

        $parser = $this->getOptionParser();
        try {
            [$options, $arguments] = $parser->parse($argv);
            $args = new Arguments(
                $arguments,
                $options,
                $parser->argumentNames()
            );
        } catch (ConsoleException $e) {
            $io->err('Error: ' . $e->getMessage());

            return static::CODE_ERROR;
        }
        $this->setOutputLevel($args, $io);

        if ($args->getOption('help')) {
            $this->displayHelp($parser, $args, $io);

            return static::CODE_SUCCESS;
        }

        if ($args->getOption('quiet')) {
            $io->setInteractive(false);
        }

        return $this->execute($args, $io);
    }

Cake\Console\ConsoleOptionParserなどに関する処理は割愛しますが、パーサーによって処理されたオプションや引数が Cake\Console\Argumentsクラスに格納されていることや、help オプションが選択されている場合はヘルプを表示した後、その時点で成功のステータスを返すことなどが読み取れます。このメソッドの最後に execute が呼ばれ、ようやく自身で定義した処理に入る、という流れになっています。

テストケースを眺める

CLI における大まかな処理の流れがわかったところで、今度は特定の機能を追ってみましょう。起動から Command の実行までを大まかに追った後なので、登場する各クラスやメソッドの役割もある程度把握できている状態です。テストケースも把握しやすくなっていると思います。

自分が調べたいと思う機能あるいはクラスについてテストしているものを探します。今回は例として、Command の基底クラスである Cake\Console\BaseCommandを調べてみます。対応するテストスイートとして、 tests/TestCase/Console/CommandTest.phpが見つかるでしょう。テストケース内に登場するのは Cake\Command\Commandクラスですが、BaseCommandを継承しており、追加の処理があまりないことを考えると、実質的に BaseCommand のテストと言えます。

まずなんとなーく全体的に眺めてみます。先程の一連の処理の中では使われていなかったメソッドのテストがやはり目を引きやすいでしょうか。例えば、以下のようなものが目につきます。

tests/TestCase/Console/CommandTest.php
    /**
     * test executeCommand with a string class
     */
    public function testExecuteCommandString(): void
    {
        $output = new ConsoleOutput();
        $command = new Command();
        $result = $command->executeCommand(DemoCommand::class, [], $this->getMockIo($output));
        $this->assertNull($result);
        $this->assertEquals(['Quiet!', 'Demo Command!'], $output->messages());
    }

BaseCommand::executeCommandというメソッドがあり、どうやら他の Command を実行することができるようです。これを使えば、ユーザーのインタラクティブな入力に応じて大きく処理を切り替えたり、一連の長ーいバッチ処理を複数の小さな Command に分割してそれぞれを堅牢に作りやすくなりますね。
また、executeCommandには、クラス文字列だけでなく、Command のインスタンスを渡すこともできるようです。

tests/TestCase/Console/CommandTest.php
    /**
     * test executeCommand with an instance
     */
    public function testExecuteCommandInstance(): void
    {
        $output = new ConsoleOutput();
        $command = new Command();
        $result = $command->executeCommand(new DemoCommand(), [], $this->getMockIo($output));
        $this->assertNull($result);
        $this->assertEquals(['Quiet!', 'Demo Command!'], $output->messages());
    }

しかし、executeCommand の引数に渡す Command は DI の対象になるのでしょうか。CommandFactoryで行われていたことを思い出すと、クラス文字列で渡した場合は可能なように思えますが、DI コンテナに登録しているようなテストケースはざっと探したところ見つかりませんでした。
そこでexecuteCommandの定義を見てみると、クラス文字列が渡ってきた場合、引数には何も与えずそのままインスタンス化していることがわかります。どうやら、自分で CommandFactoryを通して事前に生成しておくなどの工夫が必要そうです(コメントにも If you are using a string command name, that command's dependencies will not be resolved with the application container. Instead you will need to pass the command as an object with all of its dependencies.と書いてありますね)。

Cake\Console\BaseCommand.php
    /**
     * Execute another command with the provided set of arguments.
     *
     * If you are using a string command name, that command's dependencies
     * will not be resolved with the application container. Instead you will
     * need to pass the command as an object with all of its dependencies.
     *
     * @param \Cake\Console\CommandInterface|string $command The command class name or command instance.
     * @param array $args The arguments to invoke the command with.
     * @param \Cake\Console\ConsoleIo|null $io The ConsoleIo instance to use for the executed command.
     * @return int|null The exit code or null for success of the command.
     */
    public function executeCommand($command, array $args = [], ?ConsoleIo $io = null): ?int
    {
        if (is_string($command)) {
            if (!class_exists($command)) {
                throw new InvalidArgumentException("Command class '{$command}' does not exist.");
            }
            $command = new $command();
        }
        if (!$command instanceof CommandInterface) {
            $commandType = getTypeName($command);
            throw new InvalidArgumentException(
                "Command '{$commandType}' is not a subclass of Cake\Console\CommandInterface."
            );
        }
        $io = $io ?: new ConsoleIo();

        try {
            return $command->run($args, $io);
        } catch (StopException $e) {
            return $e->getCode();
        }
    }

テストケースを読むことで、executeCommandメソッドの存在、及びその振る舞いについて知ることができました。

テストを実行する

他のテストケースも眺めてみると、以下のようなものも目につきました。

tests/TestCase/Console/CommandTest.php
    /**
     * Test abort()
     */
    public function testAbort(): void
    {
        $this->expectException(StopException::class);
        $this->expectExceptionCode(1);

        $command = new Command();
        $command->abort();
    }

なるほど、BaseCommand には Command の処理を失敗として扱い、終了させる abort というメソッドがあるようです。このテストケースによると、abortCake\Console\Exception\StopExceptionを throw することがわかります。実際の定義を眺めてもそのようになっていますね。

Cake\Console\BaseCommand.php
    /**
     * Halt the the current process with a StopException.
     *
     * @param int $code The exit code to use.
     * @throws \Cake\Console\Exception\StopException
     * @return void
     */
    public function abort(int $code = self::CODE_ERROR): void
    {
        throw new StopException('Command aborted', $code);
    }

では、StopExceptionが throw された後はどうなるのでしょうか。その例外はどこで処理されるのでしょう。先ほどの一連の起動処理を追うと答えは見つかるのですが、ここでは例として、再度 Command を叩いて確認します。abort を呼ぶだけの AbortCommandを用意しました。

App\Command\AbortCommand.php
    class AbortCommand extends Command
    {
        public function execute(Arguments $args, ConsoleIo $io)
        {
            $this->abort(127);
        }
    }
$ ./bin/cake Abort
$ echo $?
127

これ以降はエントリーポイントに侵入した時点で停止されると面倒なので、stopOnEntryfalse にしておきましょう。abort をコールしている行にブレークポイントを設置し、XDebug を起動し、テストを回します。
すると、CommandRunner::runCommand内で catch されることがわかりました。ここで例外から終了ステータスコードを取り出し、CommandRunner::runの戻り値になるように返却するようです。

    protected function runCommand(CommandInterface $command, array $argv, ConsoleIo $io): ?int
    {
        try {
            return $command->run($argv, $io);
        } catch (StopException $e) {
            return $e->getCode();
        }
    }

先程の BaseCommand::executeCommand で処理を委譲した Command 内で abort するとどうなるでしょう。委譲元の Command で catch しなければ、同様に runCommand内で自然と catch されるように思えます。
これも先程までの処理をよく見ると答えが判明するのですが、ちょうど良いテストケースが用意されているのを発見しました。せっかくなのでこのテストケースで実験してみましょう(このテストケース内に登場する AbortCommandはフレームワーク側でテスト用に用意されたものですが、先ほど自前で作成したものとほぼ同様の処理です)。

tests/TestCase/Console/CommandTest.php
    /**
     * test executeCommand with an abort
     */
    public function testExecuteCommandAbort(): void
    {
        $output = new ConsoleOutput();
        $command = new Command();
        $result = $command->executeCommand(AbortCommand::class, [], $this->getMockIo($output));
        $this->assertSame(127, $result);
        $this->assertEquals(['<error>Command aborted</error>'], $output->messages());
    }

テストケース内の適当な部分にブレークポイントを貼り、XDebug を起動しテストを実行します。ただ、ここでは1つのテストケースだけに着目しているので、テストスイート内のすべてのテストケースを回すのは余計な時間がかかるだけです。PHPUnit の --filter オプションでテストケースを指定し、目的のテストケース以外が実行されないようにしましょう。

$ ./vendor/bin/phpunit --filter "testExecuteCommandAbort" tests/TestCase/Console/CommandTest.php

処理を追っていくと、最終的に executeCommand内の catch 句で例外の伝播が止まることがわかります。CommandRunner::runCommand に例外の処理を押し付けないのは、おそらく executeCommand をコールした Command クラス内で終了ステータスに基づいて処理を分けることができるようにするためと思われます。

Cake\Console\BaseCommand.php
    public function executeCommand($command, array $args = [], ?ConsoleIo $io = null): ?int
    {
        // ...

        try {
            return $command->run($args, $io);
        } catch (StopException $e) {
            return $e->getCode();
        }
    }

このように、用意されたテストケースを実行して処理を追うことで、フレームワークの機能をより深く探っていくこともできます。

まとめ

以上、Cake\Consoleを例に簡単なコードリーディングを実践してみました。他のテストケースを眺めてみるとまた色々な気づきがあると思います。
コードリーディングに挑戦したいけど二の足を踏んでしまっているという方の参考になれば幸いです。

明日のアドベントカレンダーは @lr_itakura さんです。お楽しみに。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3