← 一覧へ戻る

gpdf API 設計: chainable vs builder パターンの選択

gpdf の API を chainable ではなく closure callback ベースの builder にした理由。Go の error 集約と PDF tree 構造に効く設計判断を実装ベースで残す。

結論を先に: gpdf は chain しない、closure で nest する

gpdf の API を設計するとき、私が最初に消した選択肢が method chaining だった。

// chainable ならこうなったはず (採用していない)
doc.AddPage().Row().Col(6).Text("Left").Done().Col(6).Text("Right")

実際の gpdf v1.0.0 はこう書く。

doc := template.New(
    template.WithPageSize(document.A4),
    template.WithMargins(document.UniformEdges(document.Mm(15))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
    r.Col(6, func(c *template.ColBuilder) { c.Text("Left") })
    r.Col(6, func(c *template.ColBuilder) { c.Text("Right") })
})
data, err := doc.Generate()

closure を引数に取る builder。chain しない。styling だけ functional options で渡す (template.FontSize(24) 等)。この三層構造に落ち着いた経緯と、別言語ライブラリから何を借りて何を切ったかを残しておく。

chainable の何が嫌だったか

設計プロトタイプ段階 (2026 年 1 月) では、Java の iText 風や Python builder pattern で見るような chainable も普通に検討していた。読みやすさで言えば、短いケースは chain のほうが綺麗に見える。

doc.Page().Row().Col(6).Text("Hello").Bold().Color(red)

問題は三つあった。

一つ目: error の持ち回し方が Go と噛み合わない。 chainable で途中の op が失敗したとき、選択肢は二つしかない。(a) error を builder に貯めて最後に取り出す、(b) panic する。(a) は使う側の「error チェック忘れ」を生む。(b) は Go の文化に合わない。gpdf は最終的に doc.Generate() ([]byte, error) の一箇所だけで error を返す設計にしている。途中で fail しても Generate 時に集約された error が返る。chainable だとこの一箇所性が崩れていた。

二つ目: PDF は本質的に tree であって列ではない。 Page は複数の Row を持ち、Row は複数の Col を持ち、Col は Text/Image/Table を持つ。tree を chain で書くと、.Done().End() のような「親に戻る」呼び出しが必要になり、ネストが深い箇所で何階層上の親に戻っているのか読んで分からなくなる。gofmt も chain を整形してはくれない。

三つ目: 変数の lexical scope が握れない。 c.Text("hi")c がどの Col のものなのか、chain では関数全体に流れていて、途中でどこから来た c なのかが曖昧になる。closure callback なら func(c *ColBuilder) の引数として lexical に閉じる。

closure callback で書くとどう見えるか

採用したのは「closure を引数に取る builder + 変数 c/r/p で lexical 化」というパターン。実装上は template/grid.goRow / Col / AutoRow / Absolute / Box がすべて func(...) を受け取る形になっている。

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(8, func(c *template.ColBuilder) {
        c.Text("Invoice", template.FontSize(24), template.Bold())
        c.Spacer(document.Mm(4))
        c.Table(headers, rows, template.Striped())
    })
    r.Col(4, func(c *template.ColBuilder) {
        c.Box(func(c *template.ColBuilder) {
            c.Text("Total", template.AlignRight())
            c.Text("$1,234.00", template.FontSize(18), template.Bold())
        }, template.BoxPadding(document.Mm(8)))
    })
})

インデント = ネスト構造、という対応関係がそのままコードに出る。Go の formatter (gofmt) もインデントの意味を保つので、レイアウトを変えても勝手に潰されない。

副次的な利点として、各 Col の中で local 変数を素直に使える。chainable だと chain の途中で変数を作る場所がない。動的にスタイルを切り替える系のコードは、closure 内のほうが圧倒的に書きやすかった。

三層に責務を分けた

最終的に gpdf の API は、3 つの異なる構築スタイルを混ぜている。

レベル スタイル
Document 構成 Functional Options template.New(WithPageSize(A4), WithMargins(...))
構造 (tree) Closure callback page.AutoRow(func(r) { r.Col(6, func(c) {...}) })
葉ノードの styling Variadic option args c.Text("hi", FontSize(24), Bold(), TextColor(blue))

Functional Options は構成に。 順序が任意で、追加しても既存コードが壊れない。Dave Cheney が Go の慣習として広めたパターンを踏襲した。

Closure callback は構造に。 ネストする tree に対しては callback が一番素直に階層を表現できる。

Variadic options は葉に。 Text に何個オプションが付くかは要素ごとに違うので、可変長引数で受ける。

責務でスタイルを分けると、利用者は「設定 → 構造 → スタイル」の 3 つの粒度を意識するだけで、どこに何を書けばいいかが決まる。一つのパターンで全部やろうとすると、設定と構造とスタイルが混ざってどこに何があるか追えなくなる。

他言語ライブラリから取ったもの

参考にしたのは Go の中だけではない。

  • gofpdf (Go, アーカイブ済み) — フラットな命令列 (pdf.Cell(40, 10, "Hello")) で、Pdf 構造体に状態を持たせる方式。低レベル直接操作の発想は学んだが、高レベル API として真似はしなかった。状態を持つオブジェクトに対して順序依存の命令を投げる設計は、規模が出るとテストが書きにくい。
  • Maroto (Go) — closure callback に近い col/row 構造を持つレイアウトライブラリで、発想がかなり近い。12-column grid のアイデアもここから取った。ただし Maroto は内部で gofpdf に依存しており、その依存元がアーカイブされた今は積極採用しない判断になっている。
  • Flutter (Dart) — Column(children: [Row(children: [Text(...)])]) の widget tree。子を slice/list で渡す構造は明示性が高く、gpdf の closure 引き渡しと並んで「tree 構造の自然な書き方」の参考になった。
  • Dave Cheney の Functional Options — Document レベルの設定はこれを踏襲。WithXxx(value) Option の形に統一している。

ちなみに JavaScript の jsPDF も触ってみたが、こちらは命令型 + 状態保持の gofpdf 系で、参考になったのは「最終出力を output() の一箇所に集約する」点だけだった。

半年触ってみて気になっている点

正直、closure callback パターンには「インデントが深くなる」という欠点がある。Box の中に Box を入れて、その中に Row を入れて、と書くと 6 階層くらい平気で潜る。これは認めざるを得ないところで、現状はネストが深くなりそうな箇所では Component を切り出して関数にまとめてもらう想定で逃げている。

もう一つ、func(r *RowBuilder) を取るシグネチャは、IDE 補完が苦手なケースがあるように感じている。引数名 r を書いた瞬間に何のメソッドが生えているかサジェストする IDE と、それを諦める IDE がある。これは IDE 側の問題ではあるのだが、利用者の体感には影響する。

それでも、error 集約の一箇所性と tree の自然な表現を優先して、chainable に戻す気は今のところない。半年後にもう一度書き直すかもしれないが、当面は closure callback で進めようと思っている。

関連

© 2026 nadai · 日本Set in Instrument Serif · Inter