コンテンツにスキップ

コンポーネントパッケージ

管理画面、専用テーブルを持つミドルウェア、スタイル付きのコンポーネント一式。 こうした機能を別のプロジェクトへコピーすると、同じ実装を 2 か所で保守することに なります。片方に加えた変更は、やがてもう片方へ届かなくなります。

コンポーネントパッケージは、その機能をふつうの Go モジュールとして公開した ものです。利用する側が書くのは 1 か所だけです。

[[packages]]
module = "example.com/widget"

インストールに必要な宣言はこれだけです。pw generate がパッケージをリンクするための import を書き、pw migrate up がテーブルを作ります。パッケージのソースは アプリケーション側へコピーされません。

共有したいものが素の Go だけ — .pw.html もマイグレーションもブラウザ資産も 無い — なら、ふつうの Go モジュールとして公開してください。ここから先は全部、 Go モジュール単体では運べないものを運ぶために存在します。それを何も持たない モジュールに package セクションを書いても、誰も読まないマニフェストが増えるだけです。

もう一方の境界はもっと硬いものです。あるモジュールのコンポーネントを、別の モジュールのテンプレートから呼ぶことはできません。 .pw.html が到達できる のは自分と同じ生成単位で宣言されたコンポーネントだけなので、コンポーネントを 公開するためのパッケージは今日のところ作れません。ハンドラ、pages/ ツリー、 ミドルウェア、スキーマ、ブラウザ資産を配るパッケージは動きます。

パッケージは kind = "package" を持ち、エントリポイントを持ちません。main を 定義するのは、それを import するアプリケーションです。

[project]
name = "widget"
kind = "package"
[package]
module = "example.com/widget"
summary = "自前のストレージを持つメモウィジェット"
assets.declared = true
routes.register = "Register"
[package.migrations]
dir = "migrations"
stem = "widget"
engines = ["sqlite"]
[package.generated_with]
pw = "v0.4.0"
tinybind = "v0.3.5"
[generate]
handlers = ["."]
templates = ["."]
queries = []
config = []

Go 側は、モジュールの識別子と埋め込んだファイルを init で登録します。実際に リンクしたバイナリだけが代償を払う形です。

package widget
import (
"embed"
"io/fs"
"github.com/shibukawa/popcornweb/pw"
)
//go:embed assets migrations
var files embed.FS
const version = "v0.1.0"
func init() {
pw.RegisterPackage(pw.Package{
Module: "example.com/widget",
Version: version,
Assets: mustSub(files, "assets"),
Migrations: mustSub(files, "migrations"),
MigrationStem: "widget",
})
}
func mustSub(root fs.FS, dir string) fs.FS {
sub, err := fs.Sub(root, dir)
if err != nil {
panic("widget: " + err.Error())
}
return sub
}

登録が運ぶのは識別子とバイトだけです。ミドルウェアは今までどおり pw.RegisterExtension、設定はすでに自分で登録している生成済みバインディング、 ルートはアプリケーションが呼ぶ公開関数 Register を通ります。ここで登録したもの が単独でリクエストに応えることはありません。

パッケージの Go がモジュールルートより下 — たとえば ui/ ディレクトリ — にある 場合は、package.import でそのパスを指す必要があります。利用側の生成された ブートストラップが import するのはこのパスだからです。

[package]
module = "example.com/widget"
import = "example.com/widget/ui"

書き忘れると、Go の無い場所を import することになり、利用側のビルドは Go ツールの no required module provides package で落ちます。このメッセージは宣言のことも このキーのことも教えてくれません。そうなる前に pw doctorPW0144 として報告します。

パッケージでの pw generate は、アプリケーションでの実行とまったく同じものです。 同じジェネレータが同じオプションで、.pw.html をソースの隣の _pw_gen.go に コンパイルします。パッケージ用のモードはありません。

ひとつだけ引っかかる点があります。生成コードはテンプレートランタイムを直接 import するので、一度生成するとパッケージの go.modtinybind-go を直接 依存として要求します。最初の生成のあとに go mod tidy を走らせれば自動で入り ますが、これは実装の詳細ではなく、公開する依存集合の一部です。import している のは、あなたがコミットした生成物そのものだからです。

生成ファイルはここではコミットする

Section titled “生成ファイルはここではコミットする”

反転するのはこの 1 点だけです。アプリケーションでは _pw_gen.go は git が無視し、 ビルドのたびに作り直します。パッケージではコミットします。利用側のビルドは go build であり、その生成器は依存先を読まないからです。module cache は読み取り 専用ですし、生成のスコープはプロジェクト相対のディレクトリ一覧だという構造上、 依存先はもともと範囲の外にあります。

だからパッケージのリポジトリは **/*_pw_gen.go の無視ルールを持ちません。 そしてリリースの門はこれになります。

Terminal window
pw check

古い生成物を載せたタグは、導入したすべてのプロジェクトでコンパイルに失敗します。 これは手に入る中でいちばん大きな音の失敗で、しかも修復経路がありません。だから このチェックはリリース手順書ではなく CI に置くものです。

パッケージでは generate.queries は空でなければならず、pw generate は空でない ものを拒否します。.pw.sql は公開時に選んだ 1 つのエンジンのプレースホルダ構文 にコンパイルされますが、パッケージは利用側のエンジンを知りようがありません。 出荷すれば、どこでもコンパイルは通り、導入したプロジェクトの半分で最初の呼び出し で落ちるクエリができあがります。クエリは手で書くか、*sql.DB を受け取って アプリケーションに持たせてください。

Terminal window
pw add example.com/widget

このコマンドが書くのは go.mod の require と [[packages]] の 1 エントリで、 あとは残りの手順を表示するだけです。ウィザードもレビュー画面もありません。 何もコピーしないからです。書き込む 2 行が編集のすべてで、手で書いても同じです。

なぜ go.mod だけではなく宣言が要るのか。go.mod が言うのはモジュールが 利用可能だということで、そこには頼んだ覚えのない推移的依存も全部含まれます。 宣言が言うのは使うつもりがあるということです。生成器が必要としているのは後者 です。すでに管理しているブートストラップファイルに、宣言されたパッケージごとに blank import を 1 行ずつ書き出すのですが、依存グラフから意図を読み取ることは できません。行を消せば、次の生成で import も消えます。

package セクションを持ちながら宣言されていないモジュールは、ふつうの Go の依存の ままです。pw doctor はそれを報告します — 推移的依存が 資産とスキーマを持ち込んでくるのは、名指しする価値のある驚きだからです — が、 リンクは何もしません。

宣言したばかりのパッケージは、生成が import を書くまで誰も import していません。 そして go mod tidy は、誰も import していない require を削除します。順序はこう です。

Terminal window
go get example.com/widget # pw add がやってくれることでもある
pw generate # blank import を書く
go mod tidy

先に tidy すると require が消えるので、次の pw generatepackages "example.com/widget": not in the module graph で止まります。pw add はこの順序で動きます。罠になるのは、宣言を手で書いたときです。

ルートを提供するパッケージは Register 関数を公開し、あなたのエントリポイントが それを呼びます。

mux := pw.NewServeMux()
widget.Register(mux)

これは意図的です。ルートを自動で流し込む仕組みはフレームワーク側のルート登録 API になりますが、拡張のモデルはそれを持っていません。そしてマウント先は、 アプリケーションが意見を持っている唯一の貢献です。pw add がこの呼び出しを 表示します。名前はマニフェストの routes.register から取ります。

マイグレーションはコピーされずに届く

Section titled “マイグレーションはコピーされずに届く”

パッケージはそれぞれ自分のマイグレーションストリームを持ちます。番号はあなたの ものとは独立で、記録するバージョンテーブルも別です。

Terminal window
pw migrate up

宣言されたパッケージのマイグレーションは、すべてアプリケーション側のものより先に適用されます。 パッケージどうしの順序は Go のモジュールグラフから来ます。パッケージは見たことの ないテーブルを参照できず、あなたはパッケージのテーブルを参照できる。import の 向きと参照の向きは、もともと一致しているわけです。グラフから導くのはこれだけです。

適用の前に、保留中のバージョンが、それを持つパッケージ名とともに表示されます。 この一覧は重要です。依存パッケージのマイグレーションファイルはリポジトリにコピーされないため、 依存先がこれから自分のデータベースに何をするのかを、する前に見られる唯一の場所 だからです。

取引の内容ははっきり書いておきます。マイグレーションをプロジェクトにコピーすれば、 実行するリポジトリの中ですべての文がレビューを受けます。パッケージ側に置いたまま にすると、go get -upw migrate up だけでアップグレードが終わり、誰かが 忘れうる 2 つ目のコマンドが存在しなくなります。アップグレードのたびに再実行が 必要なコピーは静かにずれていきますし、スキーマについては、見えにくいことより 黙ってずれることのほうが悪い。文が見たければ pw migrate up が表示しますし、 バイト列は go.sum が固定しています。

pw add auth が入れるフレームワークの機能はこの影響を受けません。あちらは今まで どおりあなたのマイグレーションディレクトリに書かれます。運用者が一度ウィザードを 回して入れたもので、下でバージョンが動くモジュールが存在しないからです。

パッケージの生成済み設定バインディングは、リンクされた時点で自分の既定値を登録 します。環境ファイルに何も書かなくても動きます。セクションを足すのは、何かを 上書きしたいときだけです。pw add は登録されるセクション名を表示しますが、 書き込みはしません。

パッケージが埋め込んだファイルは、予約された内容アドレスのパスから配信されます。

/_pw/pkg/<digest>/<name>

識別子はバイト列のダイジェストです。同じファイルを配る 2 つのパッケージは 1 つの URL になり、1 バイト変われば URL が変わり、immutable のキャッシュヘッダは嘘に なりません。あなたの public/ ツリーには何も書かれず、リクエスト時にファイル システムを読むこともありません。ファイルシステムを持たない TinyGo ターゲットでも 動くのはそのためです。

ドキュメントシェルから参照するには、URL を問い合わせます。

url, ok := pw.PackageAssetURL("example.com/widget", "widget.js")

これは暫定の形で、限界についても正直に書いておきます。パッケージが数個を超えると 破綻しますし、アップグレードでリンクしていない資産が増えたときに静かに壊れます。 コンポーネント自身が必要な資産を宣言し、URL はフレームワークが与える — その形は tinybind-go 側の作業待ちです。

アプリケーションはビルドのたびに再生成するので、生成器とランタイムは 1 つの依存の 1 つのバージョンです。パッケージは公開時点で生成物を凍結し、ランタイムは go.mod がビルド時に選び、Go は両者の高いほうを取ります。つまり実際に動く組み 合わせは、作者が試した組み合わせとは限りません。

package.generated_with が、生成物を作ったバージョンを記録します。pw doctor は それをプロジェクトが解決するバージョンと比べ、自分より新しいフレームワークで 生成されたパッケージを報告します。まだ存在しないランタイムのエントリを呼んでいる 可能性があるからです。それはコンパイルエラーになりますが、先に名指ししておくほう がコンパイラから読み取るより安いというだけの話です。

古い生成物をコミットしたまま公開すると、利用側の go build で失敗します。 パッケージ名は表示されます。利用側からの修復経路はありませんし、あってはいけません。 依存先を再生成することは、作者が試したことのないバージョンでそれを再公開することに なるからです。

2 つのパッケージが同じマイグレーション stem を持つことはできません。 登録時にも pw doctor でも拒否します。バージョンテーブルを共有すると、それぞれが相手の適用済み バージョンを自身の適用済みバージョンと誤認してしまうからです。

package.components.exported は拒否されます。 このキーは、モジュールをまたぐ コンポーネントが実現したときの置き場所として存在します。今日書けばロードエラーで、 守れない約束にはなりません。

パッケージのダウングレードはスキーマのダウングレードではありません。 マイグレーションが少ないバージョンに戻すと、ソースの無い適用済みバージョンが残り ます。これは報告されるだけで、自動では戻しません。

ストリームの読み手は 2 つあり、一致していなければなりません。 pw migrate は モジュールディレクトリからマイグレーションファイルを読みます。アプリケーションの バイナリ無しで適用するので、別プロセスの埋め込みには手が届かないからです。 プロセス内の経路と pw test は埋め込みを読みます。同じファイルです — //go:embed のパターンが取りこぼしていない限りは。取りこぼしを見つけられるのは、あなた自身の リリースチェックだけです。

両側のキーはすべて ビルド設定にあります。コマンドは pw addpw generatepw migratepw doctor です。