プロジェクト構成と設計原則
Popcorn Web のプロジェクト構成は、フォルダツリーを見るだけでは理解できません。
プロジェクト全体では pw がアプリケーションのビルドと開発環境の起動を担い、
個々の処理ではハンドラがリクエストを受け取ってレスポンスを返します。フォルダ構造は、
この2つの役割をつなぐものです。
どの階層でも方針は変わりません。Go と Web の標準的なインターフェースは隠さず、 その周辺で繰り返し必要になる処理だけを再利用できる形にまとめます。
開発環境を抱える pw
Section titled “開発環境を抱える pw”開発中、アプリケーションバイナリは単独で動いているわけではありません。pw dev がソースを監視し、
Go コードを生成し、マイグレーションとアセットのビルドを済ませてからバイナリを起動し、
変更があれば入れ替えます。その周囲では開発用 IdP、構造化ログ、テレメトリ受信、
データベース操作、テンプレートの storybook、診断機能も動いています。
外側の箱はツールの境界であって、実行時の依存ではありません。pw build が
アプリケーションバイナリを作り、本番環境ではそのバイナリだけを動かします。pw も、
開発コンソールも、開発用 IdP も、storybook も要りません。開発環境が豊かなのは、
リリースバイナリに道具を隠したからではなく、pw dev が周囲の道具をまとめて動かすからです。
原則: 1つのコマンドで開発環境を管理する
Section titled “原則: 1つのコマンドで開発環境を管理する”新しいプロジェクトの最初の仕事を、互いに無関係なツールのインストールと接続先の転記には
しません。pw init が動く環境を作り、以後は pw dev の1コマンドで戻ってこられます。
コード生成、マイグレーション、seed data、ローカル認証、ログ、trace、テンプレートと
データベースの確認。多くのアプリケーションが必要とするものは、最初からプロジェクトへ
接続されています。
これは Web フロントエンドのツール群が進めてきた考え方を取り込んだものです。コンパイラ、 watcher、stylesheet tool、標準プロトコルを、各チームが README を見ながら毎回つなぐよりも 便利な形にパッケージする。ただし、部品の正体は隠しません。Tailwind は Tailwind、 テレメトリは OTLP、認証は OIDC、そして動くサービスは Go のバイナリのままです。
2種類の設定と、2つの読み手
Section titled “2種類の設定と、2つの読み手”プロジェクトには TOML が2種類あります。見た目が似ていても、読むプログラムが違います。
| 入力 | 読むもの | 決めるもの |
|---|---|---|
popcornweb.toml |
pw |
プロジェクトルート、main package、生成範囲、migration、assets、開発ツール |
config.{APP_ENV}.toml |
application binary | server、database、authentication、session、observability、アプリケーション設定 |
| 環境変数とアプリケーションのフラグ | application binary | 実行環境での runtime 設定の上書き |
dev.logs が popcornweb.toml にあるのは、アプリケーションの隣で動く開発プロセスを
制御するからです。server.port は実際に bind するバイナリの設定なので
config.dev.toml に置きます。本番でも境界は同じです。リリースバイナリには runtime 設定が
必要ですが、自分をビルドしたプロジェクトのフォルダ構成を読む理由はありません。
フォルダは feature に従う
Section titled “フォルダは feature に従う”cradle の中身は普通の Go module です。pw init は、1つの handler package と1つの
query package から浅く始めます。利用者や所有者の異なる領域が生まれたら、技術レイヤー
ではなく feature に沿ってツリーを育てます。
myapp/├── popcornweb.toml├── config.dev.toml├── cmd/myapp/main.go├── templates/│ ├── document.pw.html 唯一の document shell│ └── 400|404|500.pw.html├── migrations/├── webroot/│ ├── index.go root mux。feature を mount する│ ├── home_handler.go│ ├── home.pw.html│ ├── admin/│ │ ├── index.go admin mux│ │ ├── dashboard_handler.go│ │ ├── dashboard.pw.html│ │ └── queries/reports.pw.sql│ └── accounts/│ ├── index.go accounts mux│ ├── signup_handler.go│ ├── signup.pw.html│ └── queries/accounts.pw.sql└── queries/ └── users.pw.sql 複数の feature が共有する queryハンドラと、それが描画するテンプレートは隣に置きます。feature だけが使う query は feature が所有し、複数の feature が実際に共有するようになってから上へ移す。どこが どの機能が変更を担うかは、設計資料を開かなくてもパスから判断できます。
各 feature が mux を持つ
Section titled “各 feature が mux を持つ”feature package の形は、小さな scaffold と同じです。
package admin
import "github.com/shibukawayoshiki/popcornweb/pw"
var mux = pw.NewServeMux()
func Handlers() *pw.ServeMux { return mux }package admin
func init() { mux.HandleFunc("GET /dashboard", dashboard) }root が子を import して mount します。
package webroot
func init() { mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handlers())) mux.Handle("/", accounts.Handlers())}admin 内の path は mount 位置からの相対です。親が子を import するため、子の init が
route を登録してから親が mount します。子は親を import しないので循環もありません。
subtree pattern と http.StripPrefix は、素の net/http による合成です。
コード生成が読む範囲
Section titled “コード生成が読む範囲”生成範囲は用途ごとに明示します。
[generate]handlers = ["webroot"]templates = ["webroot", "templates"]queries = ["webroot", "queries"]config = ["cmd/myapp"]webroot/admin/queries は webroot の指定に含まれます。編集が必要なのは、新しい
トップレベルのソースディレクトリを増やしたときだけです。暗黙の既定値はありません。
キーが無ければエラー、意図して生成しない用途は [] です。生成物と範囲外ソースの診断は
pw generateにあります。
feature package をいくつ増やしても、次の3つは全体で1つです。
document.pw.htmlの document shellmigration.dirにある順序付き migration 集合- 登録された設定 prefix が共有する名前空間
原則: layer by feature
Section titled “原則: layer by feature”トップレベルを controllers、services、repositories、models に分けると、1つの
feature が一般名の package へ散らばります。境界ごとに request 用、永続化用、domain 用の
型と mapper を作っても、新しく増える知識は値のコピー方法だけかもしれません。レビュー時間、
バイナリ、人間の注意、AI が読むコンテキストには、どれも実在するコストがかかります。
Popcorn Web の既定は逆です。feature の内部は浅く保ち、Go package と mux で feature を 合成し、共有所有が生まれてから共有 package を取り出します。異なる知識を持つ、または実在する 依存の向きを反転させる。layer は、そのどちらかを担って初めて置く理由を得ます。
domain knowledge を SQL から追い出す必要もありません。schema constraint、query の形、index、 transaction boundary は、アプリケーションが何を許し、どのように失敗するかを決めています。 生成された query はそれを見えるままにし、database を遠く見せるためだけの汎用 CRUD repository を途中に置きません。
1つのハンドラは net/http のまま
Section titled “1つのハンドラは net/http のまま”最小のスケールは1リクエストです。ここでも、フレームワークは馴染みのある中心を置き換えず、 その周囲を支えます。
| 関心事 | 動作 |
|---|---|
| 処理の単位 | 1つの http.Handler |
| リンク | 既定では完全な document request |
| フォーム | 通常の submit と redirect |
| 変更操作 | handler または application service |
| 変更後の browser default | Post/Redirect/Get |
| transaction boundary | pw.Transaction で明示 |
| client-side enhancement | 任意 |
さらに1段引いて見ると、このハンドラは見慣れた server stack の中にあります。
http.Server が接続を受け、framework middleware が mux を包み、http.ServeMux が
application code を選ぶ。次の図では、標準ライブラリ、framework runtime、application code、
application source から生成された code を色で分けています。
コードも Go 開発者が知っている形のままです。
type createMemoInput struct { Body string `form:"body" check:"required,maxlen=1000"`}
func createMemo(w http.ResponseWriter, r *http.Request) { input, err := pw.Parse[createMemoInput](r) if err != nil { pw.WriteProblem(w, r, pw.BadRequest(err)) return } if _, err := queries.CreateMemo(r.Context(), input.Body); err != nil { pw.WriteProblem(w, r, err) return } http.Redirect(w, r, "/memos", http.StatusSeeOther)}ハンドラが受け取るのは http.ResponseWriter と *http.Request です。control flow、status と
redirect、外部システムの呼び出し、transaction boundary はハンドラが所有します。
r.Context() も Go のライブラリが理解する carrier のままです。
消えるのは、表現間の移動を手で書く仕事です。pw.Parse は生成された binder を使い、path、
query、header、form、JSON の入力を型付き struct へ移して検証します。生成された query 関数は、
型付き parameter と row を SQL との間で運びます。生成された template 関数は型付き parameter
から HTML を作り、pw.WriteProblem と HTML response helper が protocol の形を統一します。
生成の境界は有限で、ソースから見えます。
| 自分が持つソース | 生成される Go |
|---|---|
*.pw.html |
型付き component 関数と parameter struct |
*.pw.sql |
context を取る型付き query 関数と row scan |
pw.Parse[T] の呼び出し箇所 |
T の request binding と validation |
pw.WriteAPI[T] / pw.WriteStream[T] の呼び出し箇所 |
T の response encoding |
pw.RegisterConfig[T] の呼び出し箇所 |
T の起動時 configuration binding |
| 上記すべて | OpenAPI 3.1 fragment |
生成ファイルはソースの隣に置かれ、名前は _pw_gen.go で終わります。これは build output です。
pw generate が上書きし、pw dev は関係するソースの変更後に再生成します。query の row 不一致、
template parameter の渡し忘れ、不正な出力 context、binding error は request ではなく build を
止めます。request 時の reflection も消えるため、TinyGo を実用的な target にできます。
原則: common sense を残し、境界を生成する
Section titled “原則: common sense を残し、境界を生成する”net/http を置き換えると、Go 開発者、library、debugger、test が共有している知識まで
捨てることになります。Popcorn Web は mux pattern、handler signature、request context、
middleware model、redirect、status code をそのまま使います。
ただし、馴染みがあることと、表現間のデータ移動を毎回手で書くことは別です。request binding、 SQL row、configuration、template parameter は、生成によって型検査と診断を追加できる機械的な 境界です。framework の abstraction はそこへ使い、中央は普通の Go に残します。
browser runtime も同じ原則に従います。標準 link、form、完全な response、型付き binding、 template、error、configuration、OpenAPI は browser runtime に依存しません。server-driven update が必要な画面だけ追加の layer を import し、最小の application は component graph、patch protocol、 hydration dependency のコストを払いません。
3つの構造はここで揃います。pw は release に入り込まず開発環境を package する。feature package
は Go の合成を隠さず所有権を表す。生成された境界は、request の意味を決める net/http handler を
隠さず、データを運ぶためだけのコードを取り除きます。
