コンテンツにスキップ

探索型ルーティング

ウェブサイトの大半は GET のページであり、それを配信するコードの大半は同じ3行の 繰り返しです。パターンを登録し、URL を復号し、テンプレートを描画する。ディレクトリ 構成は既に URL 空間を表しているのに、ルータはそれを Go で言い直しているだけで、 どちらか片方だけをリネームした瞬間に両者はずれます。

探索型ルータはこの言い直しを取り除きます。ページテンプレートを入れたディレクトリを 作れば、そのディレクトリがルートです。

pages/
├── page.pw.html → GET /
├── layout.pw.html
└── users/id_/page.pw.html → GET /users/{id}

これらを登録するコードはどこにもありません。pw generate がツリーを歩いて登録を 書くので、ファイルシステムは真実の写しではなく真実そのものになります。

このツリー歩きは、プロジェクトの他の部分と同じ生成ステップです。ツリーの根に routes_pw_gen.go を書き、あわせてページの描画関数とパラメータ構造体をそれぞれの テンプレートの隣に書きます。どれもビルド出力で、Git は無視し、生成し直せば作り直されます。

走らせ方は 3 つあります。pw dev はプロジェクトのソースを監視していて、変わるたびに 生成し直し、リビルドして再起動します。ディレクトリを作るだけでルートが増えるのは このためです。pw build はコンパイルの前に生成します。 pw generate はその同じ作業をコンパイラの手前で止めた もので、TinyGo や自分で書いた go build がコンパイルを持つ場合に使います。手で 1 回 走らせるときも同じコマンドです。

popcornweb.toml では、このツリーを pages の目的に挙げます。

[generate]
pages = ["pages"]

ツリーの根を挙げるのはここだけで、他の目的には書きません。ディレクトリ構造まるごとで 1 回の生成であって、独立したソースが並んだフォルダではないからです。中身が .pw.html であるにもかかわらず pw initgenerate.templates に入れないのは、そういう理由です。 両方のルータを持つプロジェクトなら、generate.templates がハンドラとテンプレートの ディレクトリを、generate.pages がツリーを挙げることになります。目的の一覧は pw generateにあります。

これはハンドラパッケージを置き換えるものではありません。両者は共存し、プロジェクトは どちらか一方でも両方でも持てます。

登録型ルータ 探索型ルータ
ルートの出どころ Go で書いた登録 page.pw.html を持つディレクトリ
メソッド 任意 ページは GET、アクションは POST
レスポンス ハンドラが書いたもの 描画されたページ(ハンドラの段を取らない限り)
入力 パス、クエリ、ヘッダ、Cookie、ボディ、multipart パスとクエリ
OpenAPI 生成 出る 設計として出ない
失敗する条件 パターンがコンパイル時定数でない ディレクトリ名が正当な Go パッケージ名でない

違いは目的ではなく適用範囲です。登録型ルートから HTML を返すのはごく普通のことで きちんとサポートされています。探索型はその汎用性を1つの形と引き換えにし、その形の 内側では登録を一切書きません。

大事なのは、その形の中に何があるかより、どこで終わるかです。ページはテンプレートを 描画する GET であり、そのアクションはレスポンスを自分で組み立てる POST です。 ファイルのダウンロード、Webhook、PUT、OpenAPI ドキュメントに載せたいエンドポイント ——どれもページではありませんが、どれも扱いにくくはなりません。それらは登録型ルート であり、このルータが存在する前から本来そこが居場所だったからです。

だから2つは交渉せずに1つの mux を共有します。

mux := handlers.Handlers() // 登録型: 自分の API
pages.Register(mux) // 探索型: ウェブサイト

登録順は関係ありません。生成された GET /{$} は手で登録したサブツリーを覆わず、 未マッチのパスは 404 のまま、GET しか無いページへの POST は 405 のままです。 現実に起きる衝突は1つだけで、同じメソッドとパスを2回登録すると標準ライブラリが起動時 に panic します。黙って上書きされるよりは大きな音を立てる失敗ですが、同時に、その パターンを既に手で登録しているサーバーにページを1つ足すと壊れうる、ということでもあり ます。

OpenAPI ドキュメントは公開された API 契約を記述するものです。HTML のページはそれでは なく、ページのアクションエンドポイントはそのページの実装詳細なので、どちらも載りません。 この除外は設計から自動的に出てくるものではなく、意図的に維持されています。生成された レジストリは登録の塊であり、それを読み返す実行はすべてのページを API ルートとして 文書化してしまうからです。

ルートディレクトリは Go パッケージでもあり、それが動的セグメントの綴りを決めます。

pages/users/id_/page.pw.html → GET /users/{id}
pages/files/rest__/page.pw.html → GET /files/{rest...}

末尾のアンダースコア1つが動的セグメント、2つが catch-all です。

ファイルベースのルータを使ったことがあれば、users/[id]/ を期待したはずです。そう 綴らない理由は趣味ではありません。Go のツールチェーンは、ビルド制約を評価するより 前、パッケージパターンの照合中に不正な import パス要素を拒否します。つまり pages/users/[id]/page.go が1つあると、そのパッケージが壊れるのではなく、モジュール 全体の go build ./... が壊れます。{id} $id @id :id (group) -id も 同じように失敗し、探索はそれらを先に拒否して理由を述べます。

除外も同じ権威に従います。先頭が _. のディレクトリ、そして testdata は 無視されます。Go のツールチェーンが既に無視しているからです。ツリー内のプライベート なフォルダは、先頭のアンダースコア1文字で済みます。

1つだけ自明でない綴りがあります。ルートページは GET / ではなく GET /{$} で 登録されます。標準ライブラリでは裸の / は前方一致パターンであり、未マッチのパスを 404 にせず全部拾ってしまうためです。

1つのファイルがページです。その隣に何を置くかで、リクエストと描画の間で走る Go の 量が決まります。

ファイル 得られるもの
page.pw.html テンプレートのみ ハンドラは全部生成される。データはテンプレート自身の external 呼び出しが取る
+ page.gofunc Load(w http.ResponseWriter, r *http.Request) ハンドラ 登録だけが生成される。レスポンスはハンドラ側で組み立てる

段は2つで、問いは page.go があるかどうかだけです。ハンドラのシグネチャでない Load は生成エラーになり、いまどうなっていて何でなければならないかを名指しします。

データを取るページに専用の段は要りません。ローダを external として宣言し、 val で束縛すれば、呼び出しは ページ自身のソースに現れます。

package id_
external LoadUser(id: string): User
export component Page(id: string): html {
{val user = LoadUser(id)}
<h1>{user.name}</h1>
}

かつてはこの2つの間に3つ目の段があり、page.gofunc Load(id string) (User, error) を宣言して生成ハンドラがそれを呼んでいました。無くなりましたが、失ったというより得た 方が大きい。あの段の引数はロードの結果で、結果でキーが決まるページはキャッシュ できません——キーを計算するのにロードが要るからです。上のように id でキーが決まれば、 ページは@cache 1つで取得と描画をまとめて覆えるところまで来ます。

入力はコンポーネントに宣言します。先頭の引数はそのルートの動的セグメントでルート順、 それ以降は引数名をキーとするクエリパラメータです。

package id_
external LoadUser(id: string): User
export component Page(id: string, page: int?): html {
{val user = LoadUser(id)}
<h1>{user.Name}</h1>
<p>page {page}</p>
}

このリストは page.go の有無にかかわらずコンポーネントのものです。ページの入力とは URL が運ぶものであって、それ以外に読む者はいません。

URL はオブジェクトを運ばないので、入力はスカラーです(ひとつだけ例外があり、後述します)。 末尾に疑問符を付けて宣言したクエリパラメータはポインタにバインドされ、それをローダが読みます。

external LoadUser(id: string, page: int?): View
export component Page(id: string, page: int?): html {
{val view = LoadUser(id, page)}
<h1>{view.name}</h1>
<p>page {view.page}</p>
}
func LoadUser(id string, page *int) (View, error) {
number := 1
if page != nil {
number = *page
}
return View{Name: "user " + id, Page: number}, nil
}

例外は配列です。繰り返しキーは URL がもともと運べるものだからです。チェックボックス群や 複数選択は、チェックされたコントロールごとに1組のペアを送ります(?tag=boots&tag=hats)。 配列として宣言した入力は、それを URL が書いた順に全部受け取ります。

export component Page(tag: string[]): html {
<ul>{for t in tag}<li>{t}</li>{/for}</ul>
}

配列を宣言するのは、そのキーが実際に繰り返されうる場所だけにしてください。 ?tag=a&tag=btag: string で受けると a だけが入り、警告は出ません。 配列宣言はまさにその状況を置き換えるためにあります。

繰り返しキーだけが読まれる綴りであることから、三つのことが決まります。

  • キーが無い場合と、空値しか持たない場合は、どちらも空配列になります。空欄の フィルタは何も足しません。配列入力に ? を付けないのはこのためです。 空であること自体が「無い」を意味します。
  • ?tag[]=boots は何にもバインドされません。ブラケットは Go でもブラウザでも ただのキー文字なので、この URL は tag[] という名前のキーを指しています。 どんな宣言でも綴れない名前です。ブラケット記法は PHP と Rack 系のものです。
  • ?tag=boots,hats はカンマを含む1要素です。%2C にエンコードしても変わりません。 クエリは分割されうるより先にデコードされるからです。カンマ結合の綴りを まったく読まないのはこのためです。

パスセグメントは配列にできません。セグメントは1つの値しか運ばず、キャッチオールは 残り全体を1つの文字列として束縛します。

省略可能な形はこのためにあります。これがないと ?page の未指定と明示的な ?page=0 が 同じ値になり、既定値は誰にも見えないデコーダの中に置くしかありません。あれば、?page の 既定値は探す人の目に入るローダの中に置けます。

末尾の error が、このローダにレスポンスを決めさせます。ページ本体の最上位にある束縛は 最初の1バイトより前に評価されるので、失敗しても残りがストリームされたままステータスを 選べます。

func LoadUser(id string, page *int) (View, error) {
row, ok := store.User(id)
if !ok {
return View{}, pw.NotFound("no user " + id)
}
}

problem コンストラクタならどれでも 使えます——pw.NotFoundpw.Forbiddenpw.BadRequest。生成ハンドラが描画の戻り値を pw.WriteProblem に渡し、そこがエラーからステータスを読むからです。

リダイレクトも同じ理由で、書くのではなく返します。

if _, ok := auth.User(ctx); !ok {
return View{}, pw.SeeOther("/auth/login")
}

名前はステータス名で、返し方は pw.NotFound と同じです。どちらも書くのではなく関数が 返す値だからです。リダイレクトには軸が2本あるので4つあります。

メソッドが GET になりうる メソッドを保つ
一時的 pw.SeeOther — 303 pw.TemporaryRedirect — 307
恒久的 pw.MovedPermanently — 301 pw.PermanentRedirect — 308

ページからは pw.SeeOther を使います。元のリクエストが何であれ遷移先が GET で取得される ので、再読み込みが何も繰り返しません。

ローダの中でメソッドの軸が効くことはあまりありません。応答している描画は GET で、そこでは 303 と 307 を区別できないからです。同じコードに POST が届きうる場所から効き始めます。

返したリダイレクトは書いたリダイレクトと同じ経路を通ります。スクリプトを走らせないと辿れ ない遷移先は拒否され、更新リクエストには 303 ではなく navigate 指示が返ります。

祖先の layout.pw.html が、その下の全ページを外側から順に包みます。

package pages
export component Layout(children: html): html {
<div class="page"><slot required /></div>
}

レイアウトが取り得る形は2つの規則で縛られます。

children: html を宣言しなければなりません。 テンプレートコンパイラがラッパー バインダを出力するのはその形に対してだけなので、宣言がなければ生成されたチェーンが 呼ぶものが存在しません。探索は宣言の欠落を報告し、Go コンパイラには任せません。

読めるのは自分のディレクトリ以上の動的セグメントだけです。 pages/users/ の レイアウトは /users/{id}id を読めません。深いセグメントに依存するラッパーは そのセグメントが変わったときに再利用できず、それこそが祖先レイアウトの価値そのもの だからです。

ドキュメントシェルはフレームワークのものであり続けます。templates/document.pw.html が doctype・<head><body> を所有し、ページはクラシックなハンドラのページと まったく同じようにその内側で描画されます。だからページテンプレートはページの内容だけ を持ちます。ツリーの中に置かれた document.pw.html は適用されません。

pages/
├── layout.pw.html
├── layout_pw_gen.go コンパイル済みレイアウトコンポーネント
├── page.pw.html
├── page_pw_gen.go コンパイル済みページコンポーネント
├── route_pw_gen.go そのルートの引数とデコーダ
├── routes_pw_gen.go Register、Routes、Actions
└── users/id_/
├── page.pw.html
├── page.go 任意の Load とサーバーアクション
├── page_pw_gen.go
└── route_pw_gen.go

レジストリはツリーのルートに置かれ、他のどこにも置かれません。自然な設計は各ページの 隣に composer を置くことですが、それは動きません。リーフは祖先レイアウトのためにルート を import するので、ルートがリーフを import すると循環になります。したがって合成は レジストリに置かれ、生成される import はすべてツリーの下向きになり、上向きの辺は どこにも存在しません。

この制約はハンドラの段まで届きます。手書きの Load は自分より上の composer を呼べない ので、ハンドラ段のページは自分でチェーンを組みます。祖先のレイアウトごとに生成された BindLayout を外側から順に並べ、葉を pwpage.Render で包みます。下の段でレジストリが 組み立てているものを、そのまま書き下したものです。

func Load(w http.ResponseWriter, r *http.Request) {
route, err := DecodeRoute(r)
if err != nil {
pw.WriteProblem(w, r, err)
return
}
_ = route
wrappers := []pwpage.Wrapper{BindLayout(LayoutParams{})}
if err := pwpage.Render(w, r, wrappers, Page(PageParams{})); err != nil {
pw.WriteProblem(w, r, err)
}
}

BindLayout はレイアウトを持つパッケージに生成されるので、木の深いところにあるページは 祖先のものを名前で呼びます。ルートのぶんは pages.BindLayout(...)、以下同様です。

レスポンスを自分で組み立てるための階層では、妥当なトレードオフです。

すべてのページはクラシックなハンドラと同じレスポンス経路で描画されます。ドキュメント シェル、非同期レンダリング、クローラの扱い、圧縮、 プロジェクトのエラーページは、ページが何も要求しなくても効きます。

ページは GET ですが、ウェブサイトはそうではありません。フォームやボタンはどこかで Go に届かなければならず、その届け先は URL としては書きません。テンプレートが export された Go のハンドラを名指しし、生成がアドレスを供給します。

<button server-action="Rename" data-target="#name">rename</button>
func Rename(w http.ResponseWriter, r *http.Request) { /* レスポンス全体を組み立てる */ }

属性はエンドポイントを運ぶ属性に下がり、それ以外の属性は手を付けられずに残ります。

<button data-tb-action="/_action/00369cf962b6/Rename" data-target="#name">rename</button>

data-tb-action を読むのはフレームワークのランタイムです。その隣のものには手が 入りません。だから data-target の意味は自分のコードが決められます。この下げ方が 決めるのは名前からアドレスへの解決までで、クリックがページに何をするかは何も モデル化しません。

手書きの action="/users/42/rename" に対してこれが買うものはコンパイラです。URL は、 それが指すハンドラと照合されることのない文字列にすぎません。名前は解決を要求される シンボルです。Go の関数をリネームすれば、それを参照したテンプレートの位置で生成が 落ちます。

ハンドラは普通の http.HandlerFunc なので、httptest でテストでき、動かすのに登録は 要りません。リクエストの読み方も他のハンドラと同じです。

type renameRequest struct {
Name string `json:"name" check:"required"`
}
func Rename(w http.ResponseWriter, r *http.Request) {
request, err := pw.Parse[renameRequest](r)
if err != nil {
pw.WriteProblem(w, r, err)
return
}
...
}

ルートパッケージの export されたハンドラ形の関数は、テンプレートが言及するかどうかに 関わらず、すべてエンドポイントを得ます。広すぎるように聞こえますが、ルートパッケージ が何であるかを思い出すと違って見えます。生成レジストリ以外に import されないパッケージ であり、そこで export されたシンボルは一般的な API ではなく、そのルートの表面です。

非公開にしたければ関数を小文字にします。それがオプトアウトであり、宣言は一切要りません。 別パッケージの生成コードは unexported なシンボルに到達できないからです。Load は 除外されます。ページ自身のエントリポイントだからです。

/_action/<hash>/<HandlerName> です。ハッシュは宣言側ディレクトリとハンドラ名の ダイジェストの先頭12桁の16進数です。ビルドソルトが無いので、変更のないプロジェクトを 再生成すれば同じアドレスが再現され、デプロイをまたいで開いたままのページもサーバーが 認識できる先に POST します。可読な名前が一緒に乗るので、ネットワークトレースが走った Go の関数を名指しします。

ダイジェストに入るのが配信ルートのパスではなく宣言側ディレクトリである点は、レイアウト で意味を持ちます。レイアウトは1回だけコンパイルされて配下の全ページで描画されるので、 ルートパスをハッシュすると1つのハンドラがページごとに違うアドレスを持ち、ハッシュの 存在理由である決定性が壊れます。

アドレスは構造を隠しますが、何の権限も与えません。ケイパビリティトークンではないので、 各ハンドラは依然として自分の呼び出し元を認証・認可します。生成される Actions テーブル が全エンドポイントを列挙し、それがこの表面を暗黙ではなく検査可能なものにしています。

レジストリはファイルシステムが知っていることを出力し、それ以上は意図的に出しません。

var Routes = []RouteInfo{
{Pattern: "GET /{$}", Path: "/", Dir: "", Params: nil},
{Pattern: "GET /users/{id}", Path: "/users/{id}", Dir: "users/id_", Params: []string{"id"}},
}
var Actions = []ActionInfo{
{Pattern: "POST /_action/00369cf962b6/Rename", Path: "/_action/00369cf962b6/Rename",
Dir: "users/id_", Handler: "Rename", Hash: "00369cf962b6"},
}

Routes がサイトマップやルートインスペクタの接合部になります。パターン、メソッド、 どのセグメントが動的かはツリーから来ます。動的セグメントが実際に取る値はアプリケー ションのデータなので、アプリケーションのもとに残ります。

Terminal window
pw init mysite --router=discovered

ルータの質問には3つの答えがあります。registered はハンドラパッケージを、 discovered はページツリーを、both は両方を1つの mux 上に書きます。これはモード ではなく出発点で、pw add discoveredpw add registered が後からもう一方を インストールします。

Terminal window
pw new page

URL を尋ね、ディレクトリに変換し(/users/{id}users/id_/)、何かを書く前に 全セグメントを検証し、どの段にするかを尋ねます。ページは何も登録しないので、後から 配線する作業はありません。

ツリーのルートが pages なのは pw init がそう書くからであって、その名前に意味が あるからではありません。生成が読むのは purpose のリストです。

[generate]
handlers = ["handlers"]
templates = ["templates"]
pages = ["pages"]

ディレクトリを移動してこのエントリを書き換えれば、生成・生成されるパッケージ名・ pw newpw dev のリロードがすべて追随します。ツリーのルートを templates にも 列挙することはありません。ツリーの実行が既にそのページとレイアウトのテンプレートを コンパイルしており、フラットな実行が同じ出力を別の内容で奪うことになるからです。

pw dev は追加設定なしで新しいルートを拾います。監視はイベントの購読ではなくファイル の比較であり、ルートには必ずそれをルートたらしめるテンプレートが伴うからです。

この router が従わない2つの慣習

Section titled “この router が従わない2つの慣習”
  • URL セグメントを持たないルートグループ。 他のフレームワークが使う括弧の綴りが、 import パスとして不正な文字だからです。
  • catch-all のより豊かな型付け。 catch-all は文字列としてバインドされます。

この2つは同じ衝突を2度見ているにすぎません。Go の規則とルーティングの慣習が食い違う とき、勝つのは Go の規則のほうです。

アクションはこのリストに入りません。<form server-action> は JavaScript 無しで ページ自身のパスへ POST して 303 で戻り、ランタイムが載っていれば送信を横取りして レスポンスをその場で適用します。トークンもフォームの中と、ランタイムが発行する全リクエストに 載るので、手で配線するものは残っていません。 サーバーアクションを参照してください。

そこで残るのは欠落というより規則です。ページの描画もリンクによる遷移も JavaScript を 必要とせず、フォームのアクションはそれを保ちます。ボタンに直接置いた server-action は 保ちません。HTML にはフォームの外のボタンを起動する仕組みが無いからです。それはページが ぶつかった限界ではなく、ページが選んだことです。