検索/2つのランタイムで動くプラグイン式のデータパイプラインを、壊さず直す

2つのランタイムで動くプラグイン式のデータパイプラインを、壊さず直す

2つのランタイムにまたがるプラグイン式のバックエンドで、AIにコードを直させる時に使う。バージョンを1つのファイルで管理し、テストの絞り込みを正しいタスクにかけ、プラグイン・並行処理・サービスのレイヤーの決まりを守り、新しいテストが変更なしでは失敗するかを確かめる。

このAIルールを使うには
## バージョンを1つのファイルで管理し、同じ場所からビルドする バージョンとツールチェーンの座標を正本として管理し、常に同じ場所からビルドする。 - すべてのバージョン番号とツールチェーンの座標を1つの正本ファイルに保存する。利用側(ビルドスクリプト、ヘルパー、テスト、パッケージ記述子)はすべてそこから読み込み、更新もそのファイルだけに対して行う。 - ランタイム互換性のためのパスの一部のように、値が意図的に複数箇所へハードコードまたは重複配置されている場合、それをバグとみなしたり動的に計算しようとしたりしない。正本の場所で現在の値を確認し、それに合わせる。 - 相対的な参照関係が毎回同じように解決されるよう、すべてのビルド・テストコマンドは一貫した1つのルートから実行する。 - グローバルにインストールされた言語やバージョンマネージャーよりも、ブートストラップ手順で用意されるベンダリング済みの固定バージョンのツールチェーンを優先する。そうすることで、どの環境でも同じようにビルドできる。 ## テストの絞り込みは、そのテストを持つタスクにかける 絞り込んだサブセットを実行する前に、テストタスクがどう配線されているかを理解する。 - テストタスクの親玉(アンブレラタスク)はそれ自体では全クラスを除外し、サブタスクへの委譲だけを行っていることが多い。そのため親玉タスクに名前フィルタをかけると、何もマッチしないまま静かに成功したように見えてしまう。フィルタは、実際にテストを所有している具体的なタスクに対してかける。 - スイートは意図的に分割されている。たとえば、もう1つのランタイムを起動するテストは、そのランタイムを提供するスイート側に移されている、というように、テストが本来の直感的なスイートから外れて置かれていることがある。フィルタで何も見つからない場合、対象はほぼ別のスイートにある。 - ユニットテスト、統合テスト、受け入れテストがそれぞれどこにあるかを把握し、正しいエントリーポイントを呼び出せるようにする。 ## 新しいケースも最初から考えていたように、コードを組み直す コードを変更する時は、既存のものに単純に付け足すだけにしない。自分の変更を加えた後も、周囲のコードが意味を成しているかを評価する。ある抽象化が1つのケース向けに設計されていて、そこに2つ目のケースを加えるのであれば、最初から両方のケースを想定していたかのように読めるよう再構成する。異なる概念にはそれぞれ明確な名前を持つ独立した単位を与え、それらの単位をより上位のコードで組み合わせることで、各実装を読まなくても意図が明白になるようにする。 ## プラグインは、フレームワークの基底クラスとレジストリの上で作る 拡張機能は、フレームワークの拡張ポイントを迂回せず、その上に構築する。 - 型ごとに用意された基底クラスを継承し、そのライフサイクルフック(セットアップまたは登録フックと、その型のエントリーポイント)をオーバーライドする。 - 設定は生のパラメータを直接読み込むのではなく、フレームワークが提供する宣言的なオプションDSLを通じて宣言する。そうすればバリデーション・型変換・ドキュメント生成を自前で行わずに済む。 - 並行処理の方針はクラスレベルで設定する。複数のワーカースレッドが同時にインスタンスを呼び出しうる場合は shared、一度に1つのスレッドしか実行できないよう呼び出しを直列化する必要がある場合は single にする。 - プラグインの検索はすべて中央のレジストリを経由させる。新しい組み込みプラグインもそこに登録し、外部プラグインはパッケージ化された単位として発見されるようにする。 ## メトリクスなどの共通の処理を、プラグインを包むデリゲータに置く 横断的関心事はラップするレイヤーにまとめ、協調オブジェクトはフレームワークから受け取る。 - すべてのプラグインは、メトリクスやスレッドセーフティといった横断的関心事を付加するデリゲータの背後で動かす。挙動を変更する際は、それがデリゲータに属するべき変更なのか、プラグイン自体に属するべき変更なのかを判断する。 - メトリクスはネームスペースの階層に従わせ、子ネームスペースはネームスペース呼び出しを通じて作成する。 - メトリクスが無効な場合やテスト時のために、同じインターフェースを実装しつつデータを破棄するnullオブジェクト版のメトリクスを用意する。 - メトリクスオブジェクトを直接インスタンス化してはいけない。必ずフレームワークから受け取る。ロギングや設定といった標準的なミックスインは自動的に含まれる一方、より重い機能はオプトインのままにしておく。 ## ワーカーループを重くせず、起動と停止の順番を守る ホットパスを保護し、開始と停止の順序を守る。 - ワーカーループはホットパスとして扱う。バッチを取得し、コンパイル済みのパイプラインに通し、完了をシグナルする処理だ。この中にブロッキング呼び出しや重いメモリ確保を追加してはいけない。 - 厳格なライフサイクルの順序に従う。まず出力とフィルタが登録され、次にワーカーが起動し、最後に入力が起動する。シャットダウン時はこの逆で、まず入力が停止し、ワーカーが処理を出し切り、その後フィルタと出力がクローズする。この順序を破るとデータ損失やデッドロックを引き起こす。 - スレッド間で共有される状態には、単純な boolean フィールドではなくアトミックなプリミティブを使う。そうしないとスレッドをまたいだメモリの可視性が保証されない。 ## パイプラインの設定をコードにコンパイルし、デバッグ用に書き出せるようにする エンジンによっては、宣言的な設定を段階的なパイプラインを通じて実行可能なクラスへとランタイムでコンパイルするものがある。まず設定テキストを文法に基づいて構文木にパースし、その木をノードと条件分岐からなる有向非巡回グラフという中間表現へ下げる。次に各ノードのソースコードを生成し、専用のネームスペース内でランタイムにクラスへコンパイルする。最後に、コンパイル済みの単位を実行ループへ組み込む。条件分岐は、分岐ノードに組み込まれる述語関数へとコンパイルされる。実際に何が実行されているかを確認できるよう、生成されたソースをディレクトリへ書き出すデバッグフラグを用意しておく。 ## エラー・null・ログの書き方を、コード全体でそろえる コードベース全体で構造・エラー・null・ロギングの規約を一貫させる。 - モジュールはアーキテクチャ上のレイヤーではなく機能ドメインごとに整理し、新しい型はそれが属するドメインに配置する。 - 命名は一貫させる。拡張・デリゲータ・抽象型には決まったサフィックスやプレフィックスを予約し、サブクラス化を意図していないクラスには final を付ける。 - 未検査(unchecked)のランタイム例外を優先する。検査例外はどうしても避けられない場合のみ使い、その際も必ず説明的なメッセージと原因(cause)の連鎖を保持する。 - null に関しては、コンストラクタの引数を明示的な非null チェックで検証し、意図的に null を許容する契約にはアノテーションを付け、失敗しうる検索処理には optional 型を使う。ただし optional を null の汎用的な置き換えとして使ってはいけない。 - 1クラスにつき static かつ final なロガーを1つ宣言し、ログレベルは意図的に選ぶ。細かい診断情報は低いレベルに、回復可能な問題は警告に、キャッチされなかった失敗はエラーにする。 - 不変な final フィールドを優先し、共有キャッシュには並行処理対応の map 型を使い、型が共有される場合はスレッドセーフティをドキュメントに明記する。 ## APIのレスポンスは1つのヘルパーを通し、処理はコマンドクラスに置く ルーティング、レスポンス、ロジックがそれぞれ1つの置き場所を持つよう、サービスをレイヤー化する。 - APIモジュールは、Webフレームワークの基底クラスを拡張し、コンストラクタを通じて共通の依存関係を受け取る共有ベースを継承させる。 - エンドポイントは、1つの中央のネームスペースマップで宣言されたパスプレフィックスにマウントする。新しいネームスペースはそこに追加し、その場しのぎのルーティングで追加しない。 - すべてのレスポンスは、ペイロードにデフォルトのメタデータを付与する単一のレスポンスヘルパーを通す。生のレスポンスを手書きしない。 - エラーは、not-found・bad-request・timeout のような少数の型付きエラーサブクラスで表現する。ハンドラからそれらを送出し、フォーマット自体はベース側に一貫したエンベロープとして整形させる。 - ビジネスロジックはルートハンドラではなく専用のコマンドクラスに置く。ハンドラはコマンドを組み立て、その結果をレスポンスヘルパーへ渡すだけにする。 ## 2つのランタイムをつなぐ所は、両方を通してテストする ホストの仮想マシンが2つ目の言語を駆動するといった形で、コードが2つのランタイムを橋渡しする場合、橋渡しされるクラスはフレームワークが指定する登録ポイントに登録し、そこに既にあるパターンに従う。スレッドごとのコンテキストオブジェクトは使用する箇所で取得し、構築時に保存してはいけない。保存したコンテキストを別スレッドから再利用すると失敗するためだ。片側だけでなく両方のレイヤーを通したラウンドトリップをテストすることで、片方のレイヤーだけのユニットテストでは見逃してしまう型変換や登録まわりのバグを捕まえられる。2つ目のランタイムを読み込むテストは、モジュール検索パスをベンダリングされた場所に向けなければならない。そのパスは意図的なものなので、そうしたテストを編集する際に変更してはいけない。 ## 本物のパイプラインのヘルパーでテストし、状態を後に残さない 目的に応じて作られたヘルパーを使って実際の挙動を検証し、状態を後に残さない。 - パイプラインオブジェクトを手作業で組み立てるより、実際のパイプラインを立ち上げて結果を捕捉する、用意されたパイプラインヘルパーを優先する。 - 分離性のために、使い捨てのプラグインクラスをspecファイル内で定義し、実際のレジストリに登録する。 - インターフェースの契約には共有例(shared examples)を、共通のセットアップには共有コンテキスト(shared context)を使う。統合テストは、フィクスチャファイルから起動するフィクスチャ・アンド・サービスのパターンで駆動する。 - 既存の rescue や catch ブロックが、新しく追加したコードパスを実際にカバーしていることを確認する。 - グローバルまたは仮想マシンの状態は teardown で片付ける。設定したプロパティにはそれぞれ対応するクリア処理を用意し、状態が他のテストへ漏れ出さないようにする。 - 汎用的なモックよりも、実際の基底クラスを継承した具体的で目的特化のテストダブルを優先する。モックはインフラの境界部分だけに使い、スタブが単なるセットアップ目的である場合は実際のメソッドを実行させる。 ## 新しいテストが、変更なしでは失敗するかを確かめる 各テストが、自分が守っている変更に実際に依存していることを確認する。 - 新しいテストは、対応する変更がない状態では必ず失敗することを確認する。修正の有無に関わらず通ってしまうテストは何も証明していない。 - 既存の条件ではなく、その変更が導入する具体的な挙動に対してアサーションを書く。新しいフィールド、変化した値、これまで存在しなかったログ行といった、観測可能な差分にアサーションを固定する。 - バグ修正の場合は、まずテストでバグを再現する。バグが現れるのと同じ理由でテストが失敗することを確認したうえで修正を適用し、テストが通ることを確認する。 - 同語反復的なモックに注意する。アサーションで検証する値をそのまま返すようにメソッドをスタブするのは循環論法であり、実装を削除してモックだけを残しても通ってしまうなら、そのテストはモックしか検証していない。 ## 互換性が壊れる変更は段階を追って入れ、秘密の値をログに出さない 挙動の変更は段階的なはしごを踏んで行い、機密性の高い値は保護し、ドキュメントを常に足並みそろえておく。 - 破壊的変更は段階を踏む。マイナーリリースでは新しい挙動をオプトインとして導入し非推奨警告を追加する。次にオプトアウトを残したままデフォルトを切り替え、最終的にメジャーリリースで古い挙動を削除する。 - 機密性の高い値を持つ設定には、フレームワークが提供する専用のシークレット型やラッパーを適用し、文字列表現をオーバーライドして値がマスクされるようにする。そうした値をデフォルトでどのログレベルにも出力してはいけない。 - ドキュメントは変更の一部として扱う。挙動が変わるたびに関連するリファレンスとコントリビューター向けドキュメントを更新し、各フォーマットの規約に従い、ユーザー向け機能はドキュメントが揃うまでマージしない。