この記事は、ランサーズ 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 を作成してみました。
<?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 の stopOnEntry
を true
にしておくとエントリーポイントに侵入した時点で停止してくれます。
さて、コードを読んでいきましょう。
#!/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
の処理を追ってみましょう。
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 標準のHelpCommand
と VersionCommand
を add
した後、さらに App\Application::console
内で CommandCollection::autoDiscover
を実行し、Command 群を登録しています。
public function console(CommandCollection $commands): CommandCollection
{
return $commands->addMany($commands->autoDiscover());
}
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\Shell
、App\Command
となり、より細かく分けたい場合は工夫が必要そうに見えます。
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 の実行に入ります。
// ...
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
)の時点で一意になるように定義するのが良い習慣だと思います。よって、この点は一旦あまり気にしないことにしましょう(もしかしたらサブコマンド機能で使われる想定なのかもしれません)。
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
が呼ばれ、インスタンスを取得することになります。
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 が登録されている場合、コンテナを経由してインスタンスを取得する旨の処理が書かれています。コンテナに登録されていない場合、引数には何も与えずにそのままインスタンスを生成します。
<?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
を実行します。
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
のテストと言えます。
まずなんとなーく全体的に眺めてみます。先程の一連の処理の中では使われていなかったメソッドのテストがやはり目を引きやすいでしょうか。例えば、以下のようなものが目につきます。
/**
* 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 のインスタンスを渡すこともできるようです。
/**
* 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.
と書いてありますね)。
/**
* 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
メソッドの存在、及びその振る舞いについて知ることができました。
テストを実行する
他のテストケースも眺めてみると、以下のようなものも目につきました。
/**
* Test abort()
*/
public function testAbort(): void
{
$this->expectException(StopException::class);
$this->expectExceptionCode(1);
$command = new Command();
$command->abort();
}
なるほど、BaseCommand
には Command の処理を失敗として扱い、終了させる abort
というメソッドがあるようです。このテストケースによると、abort
は Cake\Console\Exception\StopException
を throw することがわかります。実際の定義を眺めてもそのようになっていますね。
/**
* 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
を用意しました。
class AbortCommand extends Command
{
public function execute(Arguments $args, ConsoleIo $io)
{
$this->abort(127);
}
}
$ ./bin/cake Abort
$ echo $?
127
これ以降はエントリーポイントに侵入した時点で停止されると面倒なので、stopOnEntry
は false
にしておきましょう。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
はフレームワーク側でテスト用に用意されたものですが、先ほど自前で作成したものとほぼ同様の処理です)。
/**
* 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 クラス内で終了ステータスに基づいて処理を分けることができるようにするためと思われます。
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 さんです。お楽しみに。