探索型ルーティング
ウェブサイトの大半は 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 init が generate.templates に入れないのは、そういう理由です。
両方のルータを持つプロジェクトなら、generate.templates がハンドラとテンプレートの
ディレクトリを、generate.pages がツリーを挙げることになります。目的の一覧は
pw generateにあります。
2つのルータ、1つの mux
Section titled “2つのルータ、1つの mux”これはハンドラパッケージを置き換えるものではありません。両者は共存し、プロジェクトは どちらか一方でも両方でも持てます。
| 登録型ルータ | 探索型ルータ | |
|---|---|---|
| ルートの出どころ | Go で書いた登録 | page.pw.html を持つディレクトリ |
| メソッド | 任意 | ページは GET、アクションは POST |
| レスポンス | ハンドラが書いたもの | 描画されたページ(ハンドラの段を取らない限り) |
| 入力 | パス、クエリ、ヘッダ、Cookie、ボディ、multipart | パスとクエリ |
| OpenAPI 生成 | 出る | 設計として出ない |
| 失敗する条件 | パターンがコンパイル時定数でない | ディレクトリ名が正当な Go パッケージ名でない |
違いは目的ではなく適用範囲です。登録型ルートから HTML を返すのはごく普通のことで きちんとサポートされています。探索型はその汎用性を1つの形と引き換えにし、その形の 内側では登録を一切書きません。
大事なのは、その形の中に何があるかより、どこで終わるかです。ページはテンプレートを
描画する GET であり、そのアクションはレスポンスを自分で組み立てる POST です。
ファイルのダウンロード、Webhook、PUT、OpenAPI ドキュメントに載せたいエンドポイント
——どれもページではありませんが、どれも扱いにくくはなりません。それらは登録型ルート
であり、このルータが存在する前から本来そこが居場所だったからです。
だから2つは交渉せずに1つの mux を共有します。
mux := handlers.Handlers() // 登録型: 自分の APIpages.Register(mux) // 探索型: ウェブサイト登録順は関係ありません。生成された GET /{$} は手で登録したサブツリーを覆わず、
未マッチのパスは 404 のまま、GET しか無いページへの POST は 405 のままです。
現実に起きる衝突は1つだけで、同じメソッドとパスを2回登録すると標準ライブラリが起動時
に panic します。黙って上書きされるよりは大きな音を立てる失敗ですが、同時に、その
パターンを既に手で登録しているサーバーにページを1つ足すと壊れうる、ということでもあり
ます。
OpenAPI ドキュメントは公開された API 契約を記述するものです。HTML のページはそれでは なく、ページのアクションエンドポイントはそのページの実装詳細なので、どちらも載りません。 この除外は設計から自動的に出てくるものではなく、意図的に維持されています。生成された レジストリは登録の塊であり、それを読み返す実行はすべてのページを API ルートとして 文書化してしまうからです。
ルートディレクトリの命名
Section titled “ルートディレクトリの命名”ルートディレクトリは 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 にせず全部拾ってしまうためです。
ページとは何か
Section titled “ページとは何か”1つのファイルがページです。その隣に何を置くかで、リクエストと描画の間で走る Go の 量が決まります。
| ファイル | 段 | 得られるもの |
|---|---|---|
page.pw.html |
テンプレートのみ | ハンドラは全部生成される。データはテンプレート自身の external 呼び出しが取る |
+ page.go の func 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.go が func 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}同じキーが繰り返される場合
Section titled “同じキーが繰り返される場合”例外は配列です。繰り返しキーは 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=b を tag: 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.NotFound、pw.Forbidden、pw.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 は適用されません。
生成されるもの
Section titled “生成されるもの”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(...)、以下同様です。
レスポンスを自分で組み立てるための階層では、妥当なトレードオフです。
すべてのページはクラシックなハンドラと同じレスポンス経路で描画されます。ドキュメント シェル、非同期レンダリング、クローラの扱い、圧縮、 プロジェクトのエラーページは、ページが何も要求しなくても効きます。
サーバーアクション
Section titled “サーバーアクション”ページは 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 } ...}何が到達可能か
Section titled “何が到達可能か”ルートパッケージの export されたハンドラ形の関数は、テンプレートが言及するかどうかに 関わらず、すべてエンドポイントを得ます。広すぎるように聞こえますが、ルートパッケージ が何であるかを思い出すと違って見えます。生成レジストリ以外に import されないパッケージ であり、そこで export されたシンボルは一般的な API ではなく、そのルートの表面です。
非公開にしたければ関数を小文字にします。それがオプトアウトであり、宣言は一切要りません。
別パッケージの生成コードは unexported なシンボルに到達できないからです。Load は
除外されます。ページ自身のエントリポイントだからです。
/_action/<hash>/<HandlerName> です。ハッシュは宣言側ディレクトリとハンドラ名の
ダイジェストの先頭12桁の16進数です。ビルドソルトが無いので、変更のないプロジェクトを
再生成すれば同じアドレスが再現され、デプロイをまたいで開いたままのページもサーバーが
認識できる先に POST します。可読な名前が一緒に乗るので、ネットワークトレースが走った
Go の関数を名指しします。
ダイジェストに入るのが配信ルートのパスではなく宣言側ディレクトリである点は、レイアウト で意味を持ちます。レイアウトは1回だけコンパイルされて配下の全ページで描画されるので、 ルートパスをハッシュすると1つのハンドラがページごとに違うアドレスを持ち、ハッシュの 存在理由である決定性が壊れます。
アドレスは構造を隠しますが、何の権限も与えません。ケイパビリティトークンではないので、
各ハンドラは依然として自分の呼び出し元を認証・認可します。生成される Actions テーブル
が全エンドポイントを列挙し、それがこの表面を暗黙ではなく検査可能なものにしています。
ルートテーブル
Section titled “ルートテーブル”レジストリはファイルシステムが知っていることを出力し、それ以上は意図的に出しません。
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 がサイトマップやルートインスペクタの接合部になります。パターン、メソッド、
どのセグメントが動的かはツリーから来ます。動的セグメントが実際に取る値はアプリケー
ションのデータなので、アプリケーションのもとに残ります。
pw init mysite --router=discoveredルータの質問には3つの答えがあります。registered はハンドラパッケージを、
discovered はページツリーを、both は両方を1つの mux 上に書きます。これはモード
ではなく出発点で、pw add discovered と pw add registered が後からもう一方を
インストールします。
pw new pageURL を尋ね、ディレクトリに変換し(/users/{id} → users/id_/)、何かを書く前に
全セグメントを検証し、どの段にするかを尋ねます。ページは何も登録しないので、後から
配線する作業はありません。
ツリーのルートが pages なのは pw init がそう書くからであって、その名前に意味が
あるからではありません。生成が読むのは purpose のリストです。
[generate]handlers = ["handlers"]templates = ["templates"]pages = ["pages"]ディレクトリを移動してこのエントリを書き換えれば、生成・生成されるパッケージ名・
pw new・pw 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 にはフォームの外のボタンを起動する仕組みが無いからです。それはページが
ぶつかった限界ではなく、ページが選んだことです。
