コーディングエージェントと会話して Enter を押す仕事について半年

この記事はtacoms Advent Calendar 2025の7日目である。

qiita.com

tennashi.hatenablog.com

の続き。

コーディングエージェントの登場によって、開発という業務自体を捉え直す必要が出てきた。

プロダクトの機能開発プロセスを整理すると、以下のような流れになる。

  1. 顧客が要望を述べる
    • プロダクトとして解決すべき要望を選定する
    • 要望を課題に整理する
  2. 課題を要求に整理する
  3. 要求を満たす機能を企画する
  4. 機能が実装されたことを検証する受け入れ条件を書く
  5. 受け入れ条件を満たす仕様を書く
    • 仕様が受け入れ条件を満たすことを形式手法で検証する
  6. 仕様を満たす実装を書く
    • 実装が仕様を満たすことをユニットテストで検証する
    • 実装が受け入れ条件を満たすことを受け入れテストで検証する

このプロセスのどこまでを人間が担い、どこからをコーディングエージェントに任せるか。 受け入れ条件を決めるまでが人間の関与する範囲で、そこから先をコーディングエージェントにまかせる世界を想像した。

これまでの記事では、PRD をどう構造化するか、受け入れテストをどう整備するか、仕様の検証に形式手法が使えないかといった実践例を紹介してきた。 今回は紹介していないが、実装プロセスをコーディングエージェントに任せるための仕組みとして以下も整備した。

  • プロダクト単位のモノレポの実践
  • コーディングエージェントに与える知識ドキュメント設計
  • 人間が知るべき知識とコーディングエージェントが知るべき知識の棲み分け
  • C4 Context を使ったインフラモデルの実践
  • アプリケーションインフラにおける terraform module 設計
  • Saga ライブラリの実装
  • アプリケーションログ設計

初期構築コストが高いことは分かっていた。 しかしコーディングエージェントとの協業で横展開コストを削減できれば、新規開発プロセスの導入もまかなえる見込みだった。

現実とのギャップ

実際にやってみると、見込みどおりにはいかなかった。

1つ実践したあと複数に展開すること自体は確かに高速化した。 しかし考えながら実践していたため、横展開している最中にも「こう理解すればいいのか」と気付き、都度反映していた。 生成速度が上がった結果、フィードバックループが早くなったということだ。

下位のドキュメントや実装の生成中にも上位の生成物へのフィードバックが発生する。 学習は個人に閉じるので必然的にタスクの手離れが悪くなった。

上位のドキュメントが決まりきっていないと下位のドキュメントの精度が下がる。 もちろん上位のドキュメントが変化したら折を見て再度生成すればいいのだが、生成プロセスの精度向上と生成元の精度向上を同時に進めていることになる。 生成結果が悪いときのフィードバック対象がどちらになるのかを判断するには、コーディングエージェントそのものへの理解とプロセスへの理解の双方が求められる。

今回はリアーキテクチャプロジェクトだったこともあり、ある程度アプリケーション設計が確定していた。 そのため多少精度が悪くても上流のドキュメントが生成されたあとは既存実装も情報源に加えて実装作業は進められた。 しかしプロセス整備によって見込んでいた速度には到達せず、スケジュール内で完結できなかったという結果に繋がった。

開発者からすればこれまでの働き方が一夜にして変化したことも、導入コストを上げる要因になった。

コーディングエージェントがある世界でプログラマは何をするのか

コーディングエージェントの登場で我々は新しい武器を手に入れた。 その実装が LLM かどうかはあまり関係ない。 自然言語を入れたらコードを生成できてコマンドを実行できる。 この体験そのものが重要だ。 モデルの進化はドキュメントを減らすことや生成速度の改善に効くことはあるが、それがボトルネックになるまでは重要度は低い。

それよりもコーディングエージェントとは何であるのかという探求が重要だと考えている。 SDD を内面化した Kiro、Claude Code の独自機能。 コーディングエージェントそのものが進化することで、実装における補足は減っていく。

最終的には、これまで PO が主体となって取り組んできた事柄がソフトウェアエンジニアの仕事に含まれるようになるだろう。 これまでの記事で紹介してきた、要求の整理や受け入れ条件の定義といった領域だ。

実装における補足が減っていくといっても、いきなりゼロになることはない。 開発プロセス上でのコーディングエージェントとの協業もなくならないだろう。 このプロセス設計や整備に必要な技能も徐々に専門性を帯びていく。 コーディングエージェントのおもりと開発プロセスそのものの面倒を見る仕事が、ソフトウェアエンジニアリングから枝分かれするかもしれない。 あまりイメージの良い言葉ではないかもしれないが、一昔前の Jenkins おじさんのような立ち位置だ。 これはプラットフォームエンジニアリングなども関連して発展していくだろう。

かつて夢見た AI はまだ実現されていない

プロダクトを作る人間として、もう一つ目を向けておくべきことがある。

人工知能分野の研究は以下のようなことを目指したものだった。

学習のあらゆる面または知能の他のあらゆる機能は正確に説明できるので、機械でそれをシミュレートすることができる

人工知能の歴史 - Wikipedia より

LLM はその系譜にある一つのマイルストーンでしかない。

ここで元の定義の是非や LLM の限界への批判は本質的ではない。 重要なのは LLM はただの実装であるということだ。 LLM とは体系の異なる、より知性が感じられる実装が出てくればこぞってみなそちらに切り替え、LLM はさらなる進化がない限り見向きもされなくなる。

プロダクトが LLM に依存するほど、新実装が出てきたときの移行コストがかかる。 そこに手間取ると一気に時代遅れのプロダクトになるというリスクを想定しておかなければならない。 新しい実装が LLM と同様に統計的なアプローチに沿ってくれるなら API 仕様に大きな変化がないと期待するのもいいが、そもそものアプローチが変わるレベルの進化が発生するとそれもどこまで期待していいものやらわからない。

ある日突然すべてを作りかえることになっても、自分たちのコアなのだから全ての実装をその日中に捨ててでも追従する心意気があるのであればいい。 そうでないなら LLM を中心に据えるのはそこそこリスクのある行為だ。 もちろんハマれば唯一無二のリターンが得られる可能性はある。

LLM および生成 AI を用いたエージェントシステムは、実際にコーディングエージェントとして一般に受け入れられている。 ここからはじめるのが堅実だろう。 エージェントとは何か。 エージェントが自身のプロダクトでどのように活躍するのか。

このような問いを考えてみるのはどうだろう。

API 仕様が要件に従うことを Alloy で検証してみる

この記事はtacoms Advent Calendar 2025の6日目である。

qiita.com

tennashi.hatenablog.com

の続き。

前回、受け入れ条件を Gherkin 記法で表現し、受け入れテストで検証できるようにした。

ここで検証の構造を整理する。

実装が要件を満たすことは受け入れテストで確認できる。 実装が仕様を満たすことはユニットテスト結合テストで確認できる。 仕様が要件を満たすことはこれにより間接的に確認できる。

だがこれは仕様が適切かどうかは実装を書いてみるまで分からないということになる。 仕様を書き、実装し、テストを実行して初めて「この仕様では要件を満たせない」と気づく。 そこから仕様を修正し、実装を修正し、またテストを実行する。

コーディングエージェントによって実装までの速度向上が見込めるといっても、手戻りするなら早めに越したことはない。

仕様が要件を満たすことを直接検証できないだろうか。

今回は Alloy による形式手法によってこれを実現できないかを考えた。

なぜ Alloy を選んだか

形式手法のツールはいくつかあるが、Alloy を選んだのは単に書き味が Go や TypeScript など現状利用しているプログラミング言語に近かったからだ。

その代わり Alloy は小スコープ仮説に基づいて動作するため、完全な証明にはならない。 有限の範囲内で反例がないことを確認するだけだ。

それでも Alloy の文法で表現されている時点で自然言語より誤解の余地が少なく、有限の範囲内での検証だったとしても人間の直感による検証よりはましだ。

そのため証明にならないことは許容することにした。

API をモデル化する

モデル化の対象として API を選んだ。 API は入力と出力と状態遷移で捉えられる。 これは Alloy でモデル化しやすい構造だと考えた。

API に限らず何かの処理を考えるときには、前提を揃えて何か処理してレスポンスを返す。 この考え方を深めると契約プログラミングと同じような主張に近づくだろうと想像した。 なので契約プログラミングに現れる用語を拝借することにした。 適切かどうかはさておき、使ってみてわかることもあるだろう。

主体となるのは以下の3つだ。

  • 事前条件(preCondition):リクエストが受け入れられるための条件
  • 事後条件(postCondition):成功時にシステムの状態がどう変わるか
  • 不変条件(invariants):操作によって変わらないもの

これらを表現するために入力型と出力型と状態を定義する。

  • 入力型(Request):API が受け取るデータの構造
  • 出力型(Response):API が返すデータの構造
  • 状態(SystemState):外部から観測されるシステムの状態

さらに各種条件を補足するものとして以下を定義した。

  • エラー条件(errorCondition):事前条件を満たさないときにどのエラーが返るか
  • 副作用(sideEffects):成功時に発生する外部への影響

例えば注文を作成する API をモデル化するとこうなる。

事前条件は API 実行前の状態とリクエストについての条件なので、引数は state: SystemStatereq: Request になる。

pred createOrder_preCondition[state: SystemState, req: Request] {
  let cart = state.carts[req.cartId] | {
    some cart              // カートが存在する
    #cart.items > 0        // アイテムが1個以上ある
    some state.stores[cart.storeId]  // 店舗が存在する
    // ...
  }
}

事後条件は実行前後の状態の関係を記述するので、引数は pre: SystemStatepost: SystemStatereq: Requestres: Response になる。

pred createOrder_postCondition[pre: SystemState, post: SystemState, req: Request, res: Response] {
  let orderId = res.data.orderId | {
    no pre.orders[orderId]     // 事前には存在しない
    some post.orders[orderId]  // 事後には存在する
    no post.carts[req.cartId]  // カートは削除される
    // ...
  }
}

不変条件は実行前後で変わらないものを記述するので、引数は pre: SystemStatepost: SystemState になる。 本来なら pre と post の差分をとって変化の対象を特定する必要があるように思えるが、req: Request を引数に含めることでその手間を省略している。

pred createOrder_invariants[pre: SystemState, post: SystemState, req: Request] {
  post.stores = pre.stores       // 店舗は変わらない
  post.customers = pre.customers // 顧客は変わらない
  // 対象以外のカートは変わらない
  all id: ID | id != req.cartId implies post.carts[id] = pre.carts[id]
}

エラー条件では、事前条件を満たさないときにどのエラーが返るかを記述する。

pred createOrder_errorCondition[state: SystemState, req: Request, res: Response] {
  let cart = state.carts[req.cartId] | {
    // カートが存在しない場合
    (no cart implies res.error = CART_NOT_FOUND) or
    // カートが空の場合
    (#cart.items = 0 implies res.error = CART_EMPTY) or
    // 店舗が営業していない場合
    (state.stores[cart.storeId].isOpen = False implies res.error = STORE_CLOSED)
    // ...
  }
}

副作用では、成功時に発生する外部への影響を記述する。

pred createOrder_sideEffects[state: SystemState, req: Request, res: Response] {
  some e: res.effects | e.type = EMAIL_NOTIFICATION  // メール通知
  some e: res.effects | e.type = INVENTORY_RESERVATION  // 在庫確保
  // ...
}

これらを組み合わせて1つの操作を定義する。

pred createOrder[
  pre: SystemState,
  req: Request,
  post: SystemState,
  res: Response
] {
  createOrder_preCondition[pre, req] implies {
    // 成功ケース
    createOrder_postCondition[pre, post, req, res]
    createOrder_invariants[pre, post, req]
    createOrder_sideEffects[pre, req, res]
  } else {
    // 失敗ケース
    createOrder_errorCondition[pre, req, res]
  }
}

事前条件を満たせば成功し、満たさなければエラーを返す。

このようにモデル化した API を使って実際に検証をしてみた。

API が旧 API の仕様を包含することを検証する

今回のプロジェクトでは既存システムの再構築をしている。 新 API は旧 API と同等以上の機能を持つ必要がある。

既存実装についても実装から先の観点でモデル化した。 新しい実装は PRD や Design Doc から素直に生成してもらった。 そして各観点で包含関係を示した。

例えば事前条件について、旧 API の事前条件を満たすならば新 API の事前条件も満たすことを検証する。

API が旧 API の仕様を包含することを検証する

今回のプロジェクトでは既存システムの再構築をしている。 そのため新 API は旧 API と同等以上の機能を持つ必要がある。

これをこの API モデルを使って検証できないかと考えた。

既存実装も先の観点でモデル化した。 新しい実装は PRD や Design Doc から生成してもらった。 そして新旧それぞれのモデルを突き合わせ、各観点で包含関係を検証した。

例えば事前条件について、旧 API の事前条件を満たすならば新 API の事前条件も満たすことを検証する。 旧 API で受け入れられていたリクエストが新 API でも受け入れられることを保証したい。

assert preConditionCompatibility {
  all state: SystemState, req: Request |
    oldApi_preCondition[state, req] implies newApi_preCondition[state, req]
}
check preConditionCompatibility

事後条件や不変条件についても同様に包含関係を検証した。

受け入れ条件を満たすことを検証する

受け入れ条件は Given-When-Then で記述されている。 これも素直に論理式にできそうだ。

Given を満たしているとき、When ならば Then である。

論理式に書き下せるなら API モデルを使って検証できるだろう。

assert acceptanceCriteria {
  all pre, post: SystemState, req: Request, res: Response |
    given[pre, req] and when[pre, req, post, res]
      implies then[post, res]
}
check acceptanceCriteria

これを満たせれば仕様は正しいと言えそうだ。

まとめ

仕様が要件を満たすことを実装前に検証したいという動機から、Alloy による形式手法を試した。 API を入力と出力と状態遷移で捉え、契約プログラミングの用語を借りてモデル化した。

実際にやってみたが、残念ながらまだ実運用に乗せられるイメージはない。

量化を含んだ論理式の扱いは学習コストがかかる。 思ったよりこのコストが高く、今回のプロジェクトは期限つきだったこともありそこまでかけられなかった。 元々はコーディングエージェントが教えてくれることにも期待していたが、これも残念なことにたまに論理式を誤解釈することがあった。 論理式を読める人間にとっては API モデルが Design Doc や API schema と二重管理になることも気になった。

一方で収穫もあった。 既存実装のモデル化は長大な関数を読み解くのに役立った。 今回は副作用のためのレイヤをモデルに含めてみたが、他にもログ出力についても同様にモデル化できるだろう。 つまり 1 つの実装を他のレイヤを気にすることなく多数の観点でモデル化できるということだ。 現実の実装でもインターフェイスアノテーションを使って出来る限り関係のないレイヤの都合を見せないように努力するが、モデルではこれを自由にできる。

また、モデルを Single Source of Truth にできれば、リアーキテクチャリファクタリングという営み自体が不要になるかもしれないとさえ思えた。 歪な実装から生成されたモデルには歪なロジックが出現する。 それを整理した上で再度実装を生成すればよい。

今回は API だけをモデル化したが、ドメインアーキテクチャもモデル化していけば生成される実装の精度は向上するだろう。 どこをどのようにモデル化するかがまだ見えていない。 しかし、これをうまく設計できるようになれば要件確定から設計完了までの時間を短縮できるだろうと期待できる。

実装と証明を同時にできる Lean のような言語を採用したくなる気持ちもわかる。

受け入れテストを Gherkin 記法で書く

この記事はtacoms Advent Calendar 2025の5日目である。

qiita.com

tennashi.hatenablog.com

の続き。

前回、機能が要求を満たしていることの確認が受け入れ条件であり、受け入れテストで検証すると書いた。

この記事では具体的にどのように受け入れテストを構築したかを書く。

受け入れ条件を自動で検証したい。 そのためには受け入れ条件が機械的に実行可能な形式で書かれている必要がある。 一方で、受け入れ条件の妥当性は人間が判断しなければならない。 人間が読んで判断できる形式でもある必要がある。

機械が実行できて、人間が読める。 この両方を満たすための方法はいくつかあるが、ここでは Gherkin 記法を採用した。

Scenario: 商品をカートに追加する
  Given カートが空である
  When 商品Aをカートに追加する
  Then カートに商品Aが1個入っている

Cucumber.js を使えば、各ステップに対応する実装を書くことでテストとして実行できる。

ステップは宣言的にユーザーの求めることを記述する

まず Gherkin のステップをどういう抽象度で書くべきか。

When 商品Aをカートに追加する

と書くか、

When 商品Aの追加ボタンをクリックする

と書くか。

そもそも受け入れテストは、「機能が要求を満たすこと」を検証するために書いている。 シナリオは要求の変化のみを理由にして変更されるべきで、単なる画面要素の変化に対して変化すべきでない。 後者の書き方ではボタンがドラッグ&ドロップに変わっただけでステップを書き換える必要がある。

そのため下記のポリシーで記述することにした。

  • ユーザーが求めることを宣言的に記述する
  • どうやって実現するかは書かない

Given/When/Then それぞれの責務は何か

Cucumber の公式ドキュメント によれば、Given は前提条件を記述し、When はユーザーの操作を記述し、Then は期待する結果を記述する。

Scenario: 商品をカートに追加する
  Given カートが空である
  When 商品Aをカートに追加する
  Then カートに商品Aが1個入っている

いずれも実装に依存しない表現でユーザーの意図の観点で書く。 これは前のセクションで述べたとおりだ。

シナリオは「要求を満たすとはつまり何であるか」という観点で書く。

では Given はどこまで書くべきか。 例えば注文完了後に注文番号が発行されることを検証したいとする。

Scenario: 注文を確定する
  Given 配送先が東京都渋谷区である
  Given カートに商品Aが入っている
  Given 支払い方法がクレジットカードである
  When 注文を確定する
  Then 注文番号が発行されている

このシナリオで本当に検証したいのは「注文を確定したら注文番号が発行される」ことだ。 配送先や商品や支払い方法は何でもいい。

Given にはユーザーがそのシナリオを辿るときに本当に意識しているもののみ書くようにした。 このシナリオでユーザーが意識しているのは「注文を確定する」という操作と「注文番号が発行される」という結果だけだ。 だから Given には何も書かなくていい。

Scenario: 注文を確定する
  When 注文を確定する
  Then 注文番号が発行されている

ステップ実装は明示されていなくともあらかじめ定められたデフォルトの挙動に従って実行できるように調整した。

ユーザーから見た操作の単位とUI要素の操作単位は違う

次に、ステップをどう実装するか。

「注文を確定する」というステップは、店舗を決定してメニューから商品をカートに入れて注文者の情報を入れて注文をする、という一連の流れになる。

この中の「店舗を決定する」という操作を考える。 ユーザーが体験する操作としては、店舗一覧画面を開いて注文したい店舗名のカードをクリックすることだ。

今回ブラウザ操作にはPlaywrightを使った。

Playwright で実装するとUI要素の粒度で記述することになる。

await page.goto('/stores');
await page.waitForSelector('[data-testid="store-list"]');
await page.click('[data-testid="store-card-123"]');
await page.waitForSelector('[data-testid="store-menu"]');

しかしこれではステップ実装のどこからどこまでがユーザーが体験する1操作にあたるのかを管理するのが難しい。

このギャップを埋めるものとしてPage Object Modelを導入した。

await storePage.selectStore('店舗A');

ユーザーから見た操作の単位で記述できる。 Playwright のUI要素操作への変換は POM の中に隠蔽される。

POM を導入してもステップ実装が読みづらいと感じられる程度に長くなることはあった。 ただ、ステップは要求を実現するための操作であり、それが長くなるのは画面設計が適切ではないことを示唆する感覚的な指標になりうると考え、そのままにした。

POM 自体のメンテナンスコストが高いのでこれまではあまり積極的に採用してこなかったが、ここはコーディングエージェントにより著しくコストが削減されたと実感している。

POM の責務はどこまでか

POM を導入したとき、操作の完了を待つ処理をどこでやるかという問題があった。

例えば「カートに追加する」という操作を考える。 追加ボタンをクリックした後、成功すればカート内の商品が増え、注文上限を超えていればエラーが表示される。

POM の中で完了を待つところまで含めると、エラーケースもハンドリングする必要が出てくる。 しかしエラーかどうかの判定は Then の責務のはずだ。

When 商品Aをカートに追加する
Then カート内の商品が増えていない
Then カートへの追加が失敗したことがわかる

そこで基本的には POM は操作のトリガーだけを担い、wait はステップ実装側でやるという方針にした。

ただし今のところ1つの操作の中に複数の手順があるケースがあり、その場合は各手順間の wait は POM で実装することを許容している。 この許容が本当に適切かどうかはまだ判断しかねている。 もしかしたら POM でエラーハンドリングまでするべきなのかもしれない。

Given ではコンテキストを調整する

そのシナリオにおけるユーザーの操作は When に集約されるので、基本的には Given では前提となるコンテキストを宣言することだけ考えた。

コンテキストの保存には cucumber-js の World を利用した。

Given('配送先が{string}である', function (address: string) {
  this.deliveryAddress = address;
});

When のステップ実装でこのコンテキストを参照し、実際のブラウザ操作に反映する。

When('注文を確定する', async function () {
  await orderPage.fillDeliveryAddress(this.deliveryAddress ?? defaultAddress);
  // ...
});

しかし特定の商品がカートに存在する状況を作りたいときのように、ユーザが一定の操作をしたという前提のもと何かを操作したいというケースもある。

この場合は playwright によるブラウザ操作を Given で実装することを許容した。 本当は API や DB 操作で実現すべきだったのかもしれないが、その仕組みを整えるよりブラウザを操作する方が楽だったので採用した。

このあたりは、「状態を宣言したいのか」「状況(操作を経た結果)を宣言したいのか」の違いで線引きしている感覚がある。

検証は Then でのみ行う

Playwright には expect による検証機能がある。 これは Then でのみ利用するようにした。

先に述べた責務分解から、検証は Then の責務だ。

Then('カートに商品Aが{int}個入っている', async function (count: number) {
  await expect(cartPage.itemCount('商品A')).toBe(count);
});

Given や When で expect を使いたくなったら、それは責務の境界がおかしいシグナルだと考えられる。

まとめ

受け入れ条件を自動で検証しつつ、人間が読んで妥当性を判断できるようにするために、Gherkin 記法と Cucumber.js を使って受け入れテストを構築した。

シナリオは要求を検証するためのものであり、画面構成や UI 要素の変化に引きずられないよう、ステップは「ユーザーが求めること」を宣言的に書く方針にした。 Given はユーザーが意識している前提だけを書くか、必要に応じてコンテキストを調整する場として使い、検証は Then に集約する。 ユーザーから見た操作単位と UI 要素操作のギャップは POM で吸収し、Playwright の細かい操作は POM の内側に閉じ込めた。

POM の責務範囲や、Given でどこまでブラウザ操作を許容するかなど、まだ揺れている部分はある。 それでも、受け入れ条件を「人間が読む仕様」と「機械が実行するテスト」の両方として扱うための足場としては、ある程度形になってきたと感じている。

プロダクトってなんだっけ

この記事はtacoms Advent Calendar 2025の4日目である。 qiita.com

tennashi.hatenablog.com の続き。

前回、コーディングエージェントが書いたコードを人間がレビューする構造である限り、整備された開発プロセスを持つ人間のチームを超えることは難しいという話をした。 ボトルネックはコードレビューにある。

では、コードレビューそのものを不要にするにはどうすればよいか。
そのために、そもそもコードレビューの対象である「実装」とは何か、何を満たしていれば実装が成立していると言えるのか、というところから問い直すことにした。

実装が成立する条件とは何か

そもそも実装を成立させるものは何か。 これは2つの観点に集約されるのではないかという仮説を立てた。

  1. プロダクトとして実現したいことを満たすこと
  2. コードが乱雑でないこと

「コードが乱雑でないこと」については、アプリケーションアーキテクチャの設計、ログ設計、ガイドラインの策定、リンターやフォーマッターの導入など、ソフトウェアエンジニアがこれまで向き合ってきたものをコーディングエージェントに伝えることができればよい。 もちろんプロダクトがどのように成長するかを見越さなければ適切な設計を選択することはできないが、それでもおおむね開発チームで閉じる内容だ。

では「プロダクトとして実現したいことを満たすこと」はどうか。 これを検証可能にするためには、そもそも「実現したいこと」とは何か、「満たす」とは何かが定義されていなければならない。

プロダクトとして実現したいこととは何か

顧客には現状があり、理想とする状態がある。 その理想状態への遷移に金を払う価値があると判断したとき、顧客はプロダクトを買うのだと考えてる。 現状と理想状態のギャップが課題であり、SaaSでは多くの顧客に共通する課題を見つけて抽象化する必要がある。

しかし課題を見つけるには顧客の「現状」と「理想状態」を知る必要があり、これが難しい。 何が起きているか見えていないこともあるし、当たり前になりすぎて問題だと認識できていないこともある。 「検索機能がほしい」と言われたとき、それは課題なのか解決策なのか。 本当の課題は「目当ての情報が見つからない」かもしれない。

現状を把握するアプローチは多岐にわたるだろうが、ここでは「想像上のユーザ」つまりペルソナを起点にすることにした。 抽象的な「顧客」のままだと現状も理想状態も想像しにくいから、具体的な誰かを仮定して考える。 ただし特定の誰かに最適化しすぎると、本来狙うべき共通パターンから外れてしまう。

ペルソナから課題を推測し、要求を導く。 要求とは「何によって理想状態に至るか」である。 要求が導けたら、それを解決するものとして機能を定める。

現実のユーザからの声、つまり要望はこれらを補正するために使う。 プロダクトとして実現すべき要望が現時点で実現できていないなら、ペルソナ・課題・要求のいずれかの解像度が甘い。 要望は整理された結果、これらへの修正として反映される。

機能が要求を満たしていることの確認が受け入れ条件であり、受け入れ条件を満たしていれば「プロダクトとして実現したいことを満たしている」と言える。

PRDは要求を起点に上流と下流を記述する

ここまでの内容をPRDとして整理する。 PRDは要求を起点に、上流と下流を記述するものだと考えている。

上流には、要求が妥当に思える根拠を書く。 つまり課題とそれを抱える人間についての記述だ。 下流には、要求を満たすものとしての機能を書く。

実際に作ったPRDのテンプレートは以下の構造になっている。

# 1. 誰のために問題を解決するか

## 店舗の在庫を管理する担当者
- **属性**: 複数店舗の在庫を一元管理、発注業務を担当
- **利用シーン**: 週次の在庫確認、月末の棚卸し、緊急発注
- **主要チャネル**: 管理画面(PC)、モバイルアプリ(店舗での確認)
- **期待**: リアルタイムで正確な在庫数を把握し、欠品を防ぎたい

# 2. どんな問題を解決するものか

## 課題1: 在庫数が分からず発注タイミングを逃す
- **誰が**: 店舗の在庫を管理する担当者
- **どんな時に**: 週次の在庫確認を行う際に
- **何に困っているか**: 手書き台帳から転記する際にミスが発生し、正確な在庫数が分からない
- **なぜ問題か**: 発注タイミングを逃して欠品が発生し、顧客に迷惑をかけてしまう

# 3. 要求

- [ ] 在庫数をリアルタイムで把握できる
  - 解決する課題: 課題1

# 4. 機能および特長

## 機能1: 在庫リアルタイム確認

### ユーザーストーリー
- **役割**: 店舗の在庫を管理する担当者
- **実現したいこと**: いつでも最新の在庫数を確認したい
- **理由**: 在庫数が不正確だと発注タイミングを逃して欠品が発生するから

ペルソナには属性、利用シーン、主要チャネル、期待を記述する。 課題には誰が、どんな時に、何に困っているか、なぜ問題かを記述する。 要求は課題を解決するために必要なことをWHATで書く。 機能はユーザーストーリーの形式で記述する。

ここで人間が判断すべきことと自動化できることを整理しておく。 要求が本当に課題を解決するか、その課題は金を払う価値があるものか。 これらは実際に売れるかどうかでしか検証できない。 人間が魂を込めるべきはここだ。 一方、機能が要求を満たしているかは受け入れ条件で定義し、受け入れテストで検証する。 受け入れ条件の妥当性は人間が判断しなければならないが、受け入れテストの実行は自動化できる。

PRDから実装へはまだギャップがある

PRDがあれば実装できるかというと、そうではない。 PRDは「何を作るか」を定義するが、「どう作るか」は書いていない。

このギャップを埋めるのが設計だ。 システム設計、コード設計、運用設計、データ設計。 これらが明らかになって初めて実装が得られる。

正直なところ、この領域は探求しきれていない。 PRDから設計への変換は人間の肌感に頼っている部分が大きい。 今回は軽量ドキュメントとしてDesign DocとADRのフォーマットを採用し、設計判断を記録することにした。

Design Docは機能単位の設計を記述する。

# [機能名] Design Document

## 概要
## 背景とゴール
## 設計
### API
### データモデル
### 主要な処理フロー
## 検討した代替案
## エラーハンドリング
## ログ設計
## テスト方針

ADRは個別の技術的意思決定を記録する。

# タイトル

## 背景
## 決定
### 決定理由
## 検討した選択肢
## 想定される結果と影響

ただしこれらのフォーマットには疑問が残っている。

Design Docについては、ここまで自然言語で書くならAPIスキーマやDBスキーマや実装を直接書くのと何が違うのかという問いに明確な回答を出せていない。 「なぜ今のようになっているか分からない」というコーディングエージェント以前からの議論はあるが、その残し方として今のフォーマットが適切かは疑問だ。

ADRについては、実装段階では決定した結果のみが重要であり、決定の経緯や他の選択肢の重要度は比較的低い。 無駄にコンテキストを消費することになる。 決定した結果は実際のアプリケーション設計やterraformファイルやREADMEに反映される。 「開発にあたり決めなければならないことを把握していて、それらをどこに書くべきかも決まっている」という状況が作れれば、ADRは単にログとしての価値だけが残り、コーディングエージェントが読む必要のないものになるのではないか。

設計の検証という観点では別のアプローチも試みた。 APIが受け入れ条件を満たしうるかを形式手法でモデル化し検証するというものだ。 これについては別の記事で述べる。

既存実装から逆生成すると全部がHowに引きずられる

今回のプロジェクトでは、既存実装からPRDを生成するアプローチをとった。 既存実装から機能一覧を読み解き、そこから要求を推測し、課題を推測し、その課題を持つ人間を推測した。

結果として、全部がHowに引きずられた。

たとえばペルソナは、最初こう書かれていた。

- 複数商品やオプションを比較しながら正確な支払総額を把握したい価格重視の顧客
- 買い物を中断しても同じ内容から再開したい多忙なゲストユーザー・会員
- デリバリー / テイクアウト / イートインを切り替えて最適な受取方法を選びたい顧客
- 店舗ごとの注文制約や在庫状況を守りながら離脱を防ぎたい店舗運営担当者

具体的すぎる。 しかも「カート状態を維持」など、既存機能の存在が前提になっている。

調整後は以下のように集約した。

## 商品を購入する顧客
- 属性: 複数商品を比較検討、予算管理が重要
- 利用シーン: ランチやディナーの注文時に複数の商品やオプションを比較検討しながら注文を構成
- 主要チャネル: Webブラウザ、モバイルアプリ
- 期待: 正確な支払総額と料金内訳をリアルタイムで確認したい

課題も同様だ。 最初はこう書かれていた。

## カート状態を維持できず再注文が発生する
- 誰が: 忙しいビジネスパーソンやゲスト利用者
- どんな時に: ブラウザを閉じたり通信が途切れた後に注文を再開するとき
- 何に困っているか: 選択した商品やオプションが失われ、最初から選び直す必要がある
- なぜ問題か: 再入力の手間が離脱を招き、売上機会を損失する

システムの存在が前提になっている。 調整後は以下のようにした。

## 最終的な支払額がわからない
- 誰が: 商品を購入する顧客
- どんな時に: 商品やオプションを選びながら、予算内で注文しようとするとき
- 何に困っているか: 商品合計、配送料を含めた最終支払額がわからない
- なぜ問題か: 決済画面で「思ったより高い」と気づき、離脱する

システムがなくても成立する記述になっている。

要求はさらに顕著だった。 最初はこう書かれていた。

- カートIDを自動発行し、ゲスト・会員問わず中断後に同一カートを再開できる必要がある
- 金額フィールドはすべて整数(円)で管理し、Money型は使用しない必要がある

「カートID」「Money型」など完全に実装の話だ。 調整後は以下のようにした。

- 商品の合計金額(税込)を把握できる
- 配送料を含めた最終支払額を把握できる

Whatに寄せた記述になっている。

それぞれの調整方針を整理する。

機能については、同じ要件から実装をどれだけ自由に想像できるかを基準にした。 私が読んで「この書き方だと別の実装も考えられるな」と思えるかどうかで調整した。

課題については、「もしそもそもシステムが存在しなかったら何に困るのか」という問いをコーディングエージェントに考えてもらった。 また、実装で解決できていない課題を述べてはならないので、それも調整した。

要求については、課題と機能の間を埋める形で調整した。 結果的に最後に決まるものになった。

ペルソナについては、要求の源泉が異なるかどうかだけを基準に分解するようにした。

まとめ

実装が成立する条件を「プロダクトとして実現したいことを満たすこと」と「コードが乱雑でないこと」の2つに分けて考えた。

後者はアーキテクチャ設計やガイドライン、リンターなど、既存の開発プラクティスである程度扱える。

前者については、顧客の現状と理想状態のギャップとしての課題、課題をどう埋めるかという要求、それを満たす機能と受け入れ条件、という形に分解し、PRDとして構造化した。

既存実装から逆算するとすべてがHowに引きずられるため、システム抜きでも成立する課題と、WHATとしての要求に書き戻す必要があった。

これだけでコードレビューがなくなるわけではないが、「何を満たしていれば実装と言えるか」をPRDと受け入れ条件として外に出し、「それをどう満たすか」をエージェントとツール側に寄せていく、という分け方の方向性は見えてきた。

リアーキテクチャをしたかった

この記事はtacoms Advent Calendar 2025の2日目である。 qiita.com

3ヶ月かけてシステムのリアーキテクチャプロジェクトに取り組んだ。 結論から言うと、残念ながら期限内に計画を完遂することはできなかった。

それでも、このプロジェクトを通じて多くのことを考えた。 アーキテクチャ設計の判断、コーディングエージェントの活用と限界、形式手法の実践的な適用、開発プロセスの設計。 これらの経験を記録として残しておきたいと思い、この記事を書くことにした。

本記事では、プロジェクトの背景となった課題と、それに対してどのような計画を立てたかについて書く。

対象となるシステムについて

私が所属するtacomsは飲食店向けにモバイルオーダーや注文管理のサービスを提供している企業だ。

今回のリアーキテクチャの対象となったのは以下の2つのシステムである。

Camelは様々な注文チャネル(Uber Eats、出前館など)から注文を受け取り、一つのタブレットで一元管理できるようにするシステムだ。 複数の注文サービスを導入している店舗では、それぞれのサービスごとにタブレットが必要になり、オペレーションが煩雑になる。 Camelはそれらを集約し、一元的に管理できるようにする。 また、POSシステムへの注文連携機能も提供しており、店舗側での注文ステータスの変更を注文サービス側に同期する役割も担っている。 店舗運営者向けの管理画面も提供している。

Camel Orderはモバイルオーダーサービスで、エンドユーザーがスマートフォンなどから店舗に対して注文を行うためのサービスである。 エンドユーザー向けの注文画面やユーザーアカウントの管理機能を提供している。 Camel Orderで受けた注文もCamelを通じてPOSなどに連携される。 Camel Orderの設定は、Camelの管理画面から行う。

日々の運用で見えてきた課題

CamelとCamel Orderはコードもデータベースも共有している状態だった。 異なる責務を持つシステムが密結合しており、一般的には望ましくない設計だ。 開発初期には効率的だったかもしれないが、システムが成長するにつれて様々な問題が顕在化してきた。

まず、変更障害が多発していた。

  • 片方の修正がもう片方に影響を与える
  • 管理画面のバグでサーバーが落ちると注文処理まで停止する
  • リリース後に誰も知らない機能のバグが発覚する
  • 目の前の致命的なバグを素朴に修正すると、別の致命的なバグが発生する

システム内に暗黙の依存関係や前提条件が多数存在しており、局所的な修正が全体に予期せぬ影響を与えていた。 加えて、Camelはハブ的性質を持つサービスだが、各種連携処理が十分に抽象化されていなかった。 Uber Eats向けの処理、出前館向けの処理、各POS向けの処理がそれぞれ固有のロジックとしてコードベースの至る所に散在しており、これも変更時の影響範囲を予測困難にしていた。

こうした変更障害が起きたとき、影響範囲の調査にも苦労した。 DB上には最終的な状態しか残っておらず、注文がどのような処理を経て現在の状態に至ったのかがわからない。 ログも残っていたり残っていなかったりと一貫性がない。 「どの注文に影響があったか」という問いに答えられなかった。

課題を解決するためのアーキテクチャ設計

Camel Order

Camel Orderについては、管理画面のバグで注文処理が停止するという問題が一番クリティカルだった。 注文という一番重要なものを守るために、まずは注文を分離することを考えた。

操作するユーザーとその目的の観点から見ると、Camel Orderには3つの責務領域があると捉えられた。

  • ユーザーが実際に注文をする面
  • ユーザーが自身のユーザー情報を管理する面
  • 店舗管理者が注文サイトの管理をする面

責務ごとにシステムを分離すれば、管理画面に問題が起きても注文処理は継続できる。

CQRSの適用判断

この分離を前提に考えてみると、「ユーザーが実際に注文をする面」は更新優位で、「ユーザー情報を管理する面」は参照優位に見えた。 そしてこれらに対する品質要件も異なる。 注文処理は高可用性が求められるが、ユーザー情報の参照はそこまでではない。

このように特性が異なるのであれば、CQRSの型で捉えるのが望ましいのではないかと考えた。

一方で、サイト管理やアカウント情報管理などは情報を見ることと編集することがシームレスに切り替わるため、明確に分離されるようには見えなかった。 そのため前者はCQRSで、後者は素朴にCRUDで構成することにした。

Event Sourcingの採用

注文がどのような処理を経て現在の状態に至ったのかがわからないという問題もあった。 注文の流れがデータとして残ることを目指すために、イベントベースでCommand側を設計すればよいのではないかと考えた。

ただし、注文データとイベントを別々に保存すると、データに不整合が出る可能性をケアしなければならない。 それならそもそもイベントだけに頼る方がシンプルに実装できるだろうと考え、Event Sourcingを採用することにした。

Sagaパターンによる処理の分解

既存実装には一つのハンドラでとても長い処理が書かれており、さまざまな例外ケースのハンドリングも含まれていたため全体像を把握しづらくなっていた。

そこでSagaパターンを採用し、ロールバックの仕組みを統一的に扱うことと、処理の単位を機能的凝集の単位に切り分けやすくすることを狙った。 一つのことを上手くやる機能的凝集単位と、それをフローとして並べる仕組みとしてSagaパターンを実現するライブラリを内部に用意した。

これはアーキテクチャ設計というよりコード設計の話だが、既存の問題を解決するための設計判断として一緒に紹介しておく。

Camel

Camelについても当初は注文自体を切り出すことを計画していた。 しかし、連携処理がコードベースの至る所に散在している問題による障害の方が致命的だったため、方針を転換した。

Camelというシステムは、連携先を増やすことがそのままプロダクトとしての価値を高める性質を持つ。 そのため、連携先を増やすコストを減らすこと、連携先ごとの仕様差異を閉じた範囲で吸収すること、変更の影響範囲を限定することを狙い、以下の3層に分離する設計とした。

  • 各種注文サービス(Uber Eats、出前館など)からの注文受付と、各種注文サービスへの注文状態同期
  • データ連携の変換や、Camelが主体となって管理する状態の管理
  • POSなど注文内容を連携してほしいシステムに向けて注文を投げる

可観測性の改善

ログを残すかどうかの判断やログレベルも適切に運用できていなかった。 そこで、いっそログレベルでの分類をあきらめて用途をベースに分類する方針にした。 例えば、外部呼び出しの追跡、エラートラッキングアクセスログといった分類だ。 ログを残す目的と残し方を明確に定めることで、人間の判断を減らし、一定以上の品質のデータが残ることを目指した。

移行戦略 - 段階的検証

元々仕様もテストも不十分な状態だったため、いきなり移行するのはリスクが高い。 仕様を完全に把握している人間がおらず、E2Eテストも限定的だった。 新しいアーキテクチャで実装したものが既存と同じ振る舞いをするかどうかを、テストだけで保証することは難しい。

そこで、新規実装するAPIには、内部的な処理は通常通り行うが外部への副作用は発生させず、代わりにリクエスト内容と処理結果をS3に保存するモードを用意することにした。 既存実装から新旧両方の実装を叩いて、両者の結果を本番環境で比較する。 本番の実トラフィックで検証することで、テスト環境では再現できないエッジケースも発見できる。 新実装が失敗しても既存システムには影響しないため、リスクを最小化しながら段階的に信頼を構築できる。

十分な検証を経てから、実際の切り替えに進む計画を立てた。

3ヶ月という制約とコーディングエージェント

アーキテクチャとして目指すものは見えたものの、集中して取り組める期間は3ヶ月が限度ということになった。 ここまで述べたアーキテクチャ設計を3ヶ月で実装し、段階的検証を経て移行まで完了させるのは、通常の開発プロセスでは難しい。 プロセス上の工夫をするか、スコープを修正するかの選択が必要だった。

当時、Claude MAX planが登場し、開発者にコーディングエージェントを配れる体制がつくれるようになっていた。 tacomsとしても開発へのAI活用に投資していく判断をしていた。

tacoms-inc.hatenablog.com

今回のリアーキテクチャは新規設計であり、採用する設計パターンも明確だ。 コーディングエージェントを活用すれば期間の問題を解決できるかもしれないと考えた。

ただし、コーディングエージェントが書いたコードを人間がレビューする構造では、どれだけ完璧に開発プロセスを整えてもDORAのいうエリート相当で頭打ちになるだろうと考えていた。 コーディングエージェントが高速に大量のコードを出力したとしても、それを読むのは人間であり、修正範囲に応じてレビューのコストは増大する。 それを防ぐために修正範囲を一定サイズに留めると、コーディングエージェントを使う場合と人間が書く場合で実装時間に大差がなくなる。

そこで、コードをレビューしなくてもよい世界において必要なものは何か、から逆算することにした。

開発プロセス設計

コードをレビューしなくてもよい世界において必要なものは何か。 仕様があって、それを満たすとは何かが定義されていて、その範囲で実装されているのであれば、実装には何も問題がないとしてよいのではないか。 極論、要件定義書や仕様書と受け入れ条件だけ用意すれば次の日には実装がある状態をベストと定めた。

アプリケーションアーキテクチャに沿っていないケースも発生するかもしれないが、テストがあるのだから後から対処できる。 なぜ沿えなかったのかを明らかにして、それに応じたドキュメントやリンターを整備し、一気にリファクタリングしてやればいい。 例えば、想定していた構成とは違う使い方が発生して無理矢理合わせようとして破綻した、そもそも設計の意図が明文化されていなかった、など。 原因がわかれば対処できる。

結果的にこのアプローチは現時点で SDD (Spec Driven Development) と呼ばれているものと同様だ。 今ではありふれた方法になっているが、これを考えていた 2025/07 頃は、まだどうすればうまく Context Engineering できるかを模索していた時期だった。

PRDから実装までのフロー

この考えにもとづき、PRDからDesign DocやADR、受け入れテストを生成し、それらからコードを生成するというフローを基本形として考えた。 要件定義と実装の間には、Howを語る人間の読めるドキュメントが必要ではないかと考えた。 実装よりもレビューコストが低いかは諸説あるが、少なくとも設計の意図を明文化できる。

なお、今回のリアーキテクチャについては、まとまった仕様が存在しない以上、苦肉の策としてPRDは既存の実装から生成せざるをえなかった。

PRDの構造化と受け入れ条件

PRDはユーザーと課題から要求を導き、要求を満たすために機能が存在するという建付けにテンプレートを整理した。 機能が要求を満たすとはどのようなことかを表現するものとして受け入れ条件を整備し、「生成された何かがPRDを満たす」とは何かを言語化できるようにした。

ただし、PRDの妥当性は我々が想像する課題とユーザーが実際に直面している課題の整合性にある。 受け入れ条件についても同様だ。 その検証プロセスはまだ整備できておらず、これらの妥当性は今のところ人間の独断に頼らざるをえない。 受け入れ条件の形式化やAPI仕様が受け入れ条件を満たすことの確認、新旧API仕様に包含関係があるかの証明などに形式手法を使ってみた箇所もあるが、全体としてはまだ道半ばだ。

おわりに

本記事では、リアーキテクチャプロジェクトの背景となった課題と計画について書いた。

結果として、最初に述べた通りこのプロジェクトは期限内に完遂できなかった。 新規実装の途中で3ヶ月が終了し、段階的検証や実際の移行は設計を終えたのみで実現には至っていない。 期日に余裕がない中でこれまでと違った考え方の開発プロセスの導入まで目論んだのだから、分かりきった失敗と言われればそれまでである。

それでも収穫はあった。 要望・要求・要件とは何か。 アーキテクチャがビジネスに与える影響とは何か。 仕様を満たすとは何か。 なぜコードはレビューしなければならないのか。 こうした問いに向き合うことができた。 正しい回答が得られたわけではないが、問いを立てられたこと自体に価値があると考える。

LSP 実装メモ(gopls cache `Snapshot` 編)

前回

tennashi.hatenablog.com

先週はやる気が消失したためお休みした。

前回のまとめ

ひたすらに (*Session).NewView() からの呼び出しを追っていった。

今回は残った (*snapshot).load() メソッドの処理を読む。
が、全て詳細に読むと長くなりすぎてしまうので、さっくりと読んだ後、Snapshot は何をするためのものなのかを追う。

(*snapshot).load()

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L43-L199

このメソッドは以下の部分に分割できる。

  • packages.Load() に渡す引数の準備
  • packages.Load() の呼び出し
  • 返り値から必要な情報を抽出して、cache データを構築する

packages.Load() は第一引数にどう package を parse するかなどのコンフィグを取り、第二引数に parse する package をパターン文字列で指定する。

これらの準備を以下の部分で行っている。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L44-L139

そして packages.Load() を呼び、以下の部分で parse した package から情報を cache に溜めていく。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L166-L194

このとき cache としての肝となるのは例えば以下のような x/tools/internal/memoize package を使った部分である。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/snapshot.go#L1369-L1389

例えば上記では、builtin package のファイルを parse した結果を s.generation.Bind() メソッドで登録しておき、s.builtin フィールドに保持している。
必要になれば s.builtin.handle.Get() メソッド呼び出しをすれば登録されたハンドラの処理結果を取得できるという訳だ。

x/tools/internal/memoize package については以下を参照。

godoc.org

snapshot

snapshot 型のフィールドを見てみる。

type snapshot struct {
    memoize.Arg // allow as a memoize.Function arg

    id   uint64
    view *View

    generation *memoize.Generation

    builtin *builtinPackageHandle

    mu sync.Mutex

    ids map[span.URI][]packageID

    metadata map[packageID]*metadata

    importedBy map[packageID][]packageID

    files map[span.URI]source.VersionedFileHandle

    goFiles map[parseKey]*parseGoHandle

    packages map[packageKey]*packageHandle

    actions map[actionKey]*actionHandle

    workspacePackages map[packageID]packagePath

    workspaceDirectories map[span.URI]struct{}

    unloadableFiles map[span.URI]struct{}

    parseModHandles map[span.URI]*parseModHandle

    modTidyHandles    map[span.URI]*modTidyHandle
    modUpgradeHandles map[span.URI]*modUpgradeHandle
    modWhyHandles     map[span.URI]*modWhyHandle

    modules map[span.URI]*moduleRoot

    workspaceModuleHandle *workspaceModuleHandle
}

source.VersionedFileHandle 以外の .*Handle 型は(全部は見てないので恐らく)先に説明した memoize package による cache を保持するための型だ。

つまり Go のコードとしての情報はほとんどこの型に保持されているので、LSP メソッドの処理はこの snapshot を取得するところから始まると推測される。

まとめ

少し詳細に踏み込みすぎて目的を見失ってしまった感があったが、あくまで LSP サーバ実装の一例としての cache 機構がどのようになっているのかを gopls から学びたかったのだ。

LSP サーバを書く際 TextDocument の扱いで私が気になっていたのは以下の点である。

  • いつその言語の文字列として parse するか
    • textDocument/didOpen 通知が飛んできたらその URI のファイルを開き、parse までして cache を構築する
    • ただし goroutine で非同期に処理される
  • いつ関連するファイル(import された package など)を開くか
    • cache を構築するときに必要なので、同じタイミングで行なわれる
  • parse 結果をどのように cache するか
    • ディスク上のファイルは Cache(fileHandle) に保持される
    • LSP により送信された TextDocument は Session(overlays) に保持される
    • Go のコードとして parse された結果は View(snapshot) に保持される

gopls cache 編は一旦ここまでとする。

次回からは LSP 仕様とその実装を追うことに戻るが、何から読んでいこうか。

LSP 実装メモ(gopls cache `View` 生成詳解編)

前回

tennashi.hatenablog.com

引き続き、gopls の cache 実装を読む。

前回のまとめ

View 実装のフィールドとその生成タイミングを見ることで、ディレクトリ単位で何かしらの cache をしていることが分かった。

View の初期化処理をもう少し詳しく追っていく。

View は Session の NewView() メソッドにより生成される。
ここからコールスタックは以下のように積まれる。

  • (*Session).NewView()
    • (*Session).createView()
      • (*View).setBuildInformation()
        • (*View).goVersion()
        • (*View).setGoEnv()
      • (*View).findAndBuildWorkspaceModule()
        • (*snapshot).buildWorkspaceModule()
      • (*View).setBuildConfiguration()
      • (*View).findWorkspaceDirectories()
      • (*View).initialize()
        • (*snapshot).load()

今回はこれらのメソッドの中でやっていることを追う。

(*Session).NewView()

シグネチャは以下。

func (s *Session) NewView(ctx context.Context, name string, folder span.URI, options source.Options) (source.View, source.Snapshot, func(), error)

これは単純なエントリポイントで、メインの実装は次の createView() だ。
ここではロックを取って、createView() が生成したものを View は Session に保持しつつそのまま返す。

(*Session).createView()

シグネチャは以下。

func (s *Session) createView(ctx context.Context, name string, folder span.URI, options source.Options, snapshotID uint64) (*View, *snapshot, func(), error)

引数に snapshotID が追加されており、NewView() からは 0 と指定される。
また source.View source.Snapshot の実装となっている *cache.View *cache.snapshot 型が返り値になっている点が NewView() との違いである。

cache.View 型と cache.session 型のフィールドの初期化して返すのがここでの仕事である。
初期化するためにいくつかの処理が必要なものは別メソッドとして分離されており、それらは以下で説明するものである。

(*View).setBuildInformation()

シグネチャは以下。

func (v *View) setBuildInformation(ctx context.Context, options source.Options) error

options にはユーザが指定した gopls の設定が入る。

まず最初に checkPathCase() という関数を呼んでいる。
これは、case-insensitiveファイルシステムをどうにか扱うために以下のコミットで追加された処理だ。

github.com

ファイルシステム上は Hoge/Fuga.go という名前で保存されたファイルをエディタが hoge/fuga.go などのファイル名で送信してきたとき、gopls では扱えなくなるので、エラーにして叩き落とすという処理をしている。
macOS(darwin) と Windows のみ意味のある処理をしており、それ以外の OS では何もせず nil を返す。
これはファイルシステムの問題であり、OS の問題ではないので、Linuxcase-insensitiveファイルシステムを利用する場合は...まぁ下手に大文字ファイル名を使わなければハマらない...多分。

その後 goVersion() で Go コマンドのバージョンを埋める。

さらに setGoEnv()GO*環境変数の設定をする。
この返り値は $GOMOD になっており、$GOMOD は module-aware mode において、main module が見付からないと /dev/null を返す仕様になっている。
cf. go help environment

もし $GOMOD/dev/null ならこの時点で処理は終了する。
なお注意すべきは GOPATH mode なら、$GOMOD == "" なので処理が続くということだ。

次に (View).modURI(View).sumURI を埋め、ExpandWorkspaceToModule という設定値に応じて v.root を埋めるかどうかを決める。
ExpandWorkspaceToModulegopls が現在開こうとしているディレクトリ(== ワークスペース)からそれを含むモジュール全体へとスコープを拡張するための設定値だ。
これを true にすることで現在開こうとしているディレクトリを含む module からさらにディレクトリ探索をして go.mod ファイルの位置なども cache するように動作する。
monorepo で特に便利になる。 cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L334-L337

TempModfilefalse か GOPATH mode の場合はここで createView() に戻る。

TempModfile は Go 1.14 から追加された、-modfile オプションを gopls が解釈するかどうかを指定する設定値だ。
cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L319-L320

その後に到達するということは TempModfile == true かつ v.modURI != "" なので、v.workspaceMode を設定して終了である。

(*View).goVersion()

シグネチャは以下。

func (v *View) goVersion(ctx context.Context, env []string) (int, error)

GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'v.root で実行すると例えば以下のように Go のバージョンリストが手に入る。

$ GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'
[go1.1 go1.2 go1.3 go1.4 go1.5 go1.6 go1.7 go1.8 go1.9 go1.10 go1.11 go1.12 go1.13 go1.14]

これをパースして、Go 1.14 であれば 14 を取得する。

(*View).setGoEnv()

シグネチャは以下。

func (v *View) setGoEnv(ctx context.Context, configEnv []string) (string, error)

ここでは go env -json GO111MODULE GOFLAGS GOINSECURE GOMOD GOMODCACHE GONOPROXY GONOSUMDB GOPATH GOPROXY GOROOT GOSUMDB を実行して JSON 形式で必要な環境変数を取得している。
実行時の環境変数として configEnv の値を入れることで環境変数の上書きを実現している。

それらの環境変数v.goEnv に入れられ、その中でも GOCACHE GOPATH GOPRIVATE GOMODCACHE の中身は v.go.* という変数にも投入される。
さらに GOMODCACHE は Go 1.15 で追加されたもので、それ以前のバージョンは GOMODCACHE=$GOPATH/pkg/mod として解釈している。

さらに GOPACKAGESDRIVER という環境変数の処理が続く。
これは x/tools/go/packages パッケージで使われるもので、go env では返ってこないので自前でやっている。
この環境変数packages.Load() 関数の処理を自前で用意した実行ファイルに置き替えるための変数である。
詳細は以下を読むとよい。

github.com

(*View).findAndBuildWorkspaceModule()

では setBuildInformation() とそこから呼ばれているメソッドを解説したので、createView() に戻って、次のメソッドを解説する。

シグネチャは以下。

unc (v *View) findAndBuildWorkspaceModule(ctx context.Context, options source.Options) error

このメソッドは v.root から順に go.mod を探していき、見付けたらそれを v.modules に追加していく。

この処理をするためには ExpandWorkspaceToModule オプションと ExperimentalWorkspaceModule オプションの両方が有効になっている必要がある。

この処理をするときには v.workspaceMode には workspaceModule フラグが立てられる。

探索では go コマンドが無視するディレクトリは無視される。
具体的には . _ から始まるディレクトリ名と testdata ディレクトリ、/vendor 配下のディレクトリだ。

探索が終わると (*snapshot).buildWorkspaceModule() を呼ぶ。

(*snapshot).buildWorkspaceModule()

シグネチャは以下。

func (s *snapshot) buildWorkspaceModule(ctx context.Context) (*modfile.File, error)

返り値の *modfile.File は go.mod ファイルそのものである。
cf. https://godoc.org/golang.org/x/mod/modfile#File

ここでやっていることは現在関連付けられている v.modules 全てを依存として持つ大きな(仮想) module (workspace module と呼ぶ)を作成することである。

例えば以下のようなディレクトリ構成があるとする。

main/
  - go.mod # module main
  - sub_a/
    - go.mod # module sub_a
  - sub_b/
    - go.mod # module sub_b

このときに以下のような(仮想) go.mod が作成される。

module gopls-workspace

require (
  main v0.0.0-00010101000000-000000000000
  sub_a v0.0.0-00010101000000-000000000000
  sub_b v0.0.0-00010101000000-000000000000
)

replace (
  main => ./main
  sub_a => ./main/sub_a
  sub_b => ./main/sub_b
)

上記の例では一度目の for ループで完結するが、main sub_a sub_b いずれかでさらに replace 文があることを想定して、それを全て反映するための二度目の for ループが存在する。

この生成結果は v.workspaceModule に保持される。

(*View).setBuildConfiguration()

ではまた createView() から呼ばれているメソッドに戻る。

この処理はあまり名前と処理が合っていないように思うが、やっていることは、ここまでの設定値が "正しい" ことを検証している。

  • GOPACKAGESDRIVER が指定されている場合は問答無用で正しい
  • go.mod が見付かっている場合や v.modules を複数見つけている場合は正しい(module-aware mode)
  • それ以外は GOPATH mode のはずで、このときは v.folder$GOPATH/src 配下に存在すれば正しい

というチェックをして、全て満たさない場合は false が返る。
また同時に v.hasValidBuildConfiguration にもその結果が保持される。

(*snapshot).findWorkspaceDirectories()`

また createView() に戻る。
v.modURI が空でない場合は s.GetFile() を呼び cache に go.mod の内容を保持しておく。
その後この findWorkspaceDirectories() が呼ばれる。

シグネチャは以下

func (s *snapshot) findWorkspaceDirectories(ctx context.Context, modFH source.FileHandle) map[span.URI]struct{}

GOPATH mode なら、その s.view.root (つまりそのディレクトリ自体)だけが返される。

module-aware mode のとき、go.mod の replace 文に記載されたディスク上のディレクトリのみ Set(map[span.URI]struct{}) に追加されて返される。

コメントに書いてあるが、GOPATH mode のときは $GOPATH/src` に含まれるディレクトリ全てを見るのが本来やるべき処理だが、too expensive なのでやらないとのことだ。

この返り値は v.snapshot.workspaceDirectories に保持される。

(*View).initialize()

https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/cache/view.go#L691-L744

シグネチャは以下。

func (v *View) initialize(ctx context.Context, s *snapshot, firstAttempt bool)

これは s.load() が実体であり、それを goroutine safe に呼ぶことが仕事である。

ただあまりよく分かってないのが、semaphore と sync.Once を併用しているところだ。
最初に呼ばれた一回、という制約をかけるためだろうか...?

その後は引数を準備して、s.load() を呼び chan を閉じれば完了だ。

なお、このように初期化処理は並行に行なわれるため、View には AwaitInitialized() というメソッドが用意されており、初期化の完了を待つことができる。

(*session).load()

...つかれたのでここまで...

次回は (*session).load() から

-- 追記(2020/09/28) -- かいた

tennashi.hatenablog.com