Plugin Architecture は、コア(core、常に動く中心部分)に手を入れずに機能を追加できるようにする設計です。コアが持つのは、拡張を呼び出す処理の流れと、拡張とのやり取りを定める規約だけです。エディタに拡張機能を入れると、本体を差し替えずに機能が増えます。Go の database/sql がデータベースドライバを Register 関数で受け取るのも、同じ形です。

Plugin Architecture が特に役に立つのは、対応する宛先や形式が増え続けるプログラムです。ログ収集ツールの出力先が 3 つで始まっても、半年後には別の宛先を求められます。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    C["コア"] --> IF{{"境界<br/>(共通の契約)"}}
    P1["拡張 A"] -.実装.-> IF
    P2["拡張 B"] -.実装.-> IF
    P3["拡張 C"] -.実装.-> IF
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

上記の図でコアが依存するのは境界だけで、拡張 A・B・C の実装の中身には依存しません。同一プロセスに載せる Go 実装なら、この境界をインターフェースで表せます。


本記事では、Plugin Architecture の境界の設計に加えて、拡張を同一プロセスに載せるか(in-process)、別プロセスに分けるか(out-of-process)という選択までを説明します。主な疑問は次の 5 つです。

  • コアに直接機能を足すと、何が困るのか。
  • Unix のパイプとは、何が違うのか。
  • plugin の境界は、どこに引くのか。
  • 公開した境界の互換性は、どう保つのか。
  • 同じプロセスの拡張が落ちた時、コアの recover はどこまで守れるのか。

コアに直接足す形の限界

新しい出力先を追加する最も手早い方法は、コアの処理に if を足すことです。問題は、宛先が増えるたびにコアの分岐と依存が両方とも増える点です。Elasticsearch 用のクライアントと S3 用の SDK が同じ関数に並び、使わない宛先の依存まで抱えます。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    subgraph D["直接 if を足す形"]
        direction LR
        C1["コアの処理"] --> F1{"宛先は?"}
        F1 -->|ES| E1["Elasticsearch<br/>クライアント"]
        F1 -->|S3| E2["S3 SDK"]
        F1 -->|stdout| E3["標準出力"]
    end
    subgraph B["境界で受ける形"]
        direction LR
        C2["コアの処理"] --> I2{{"境界"}}
        I2 -.実装.-> P1["Elasticsearch 拡張"]
        I2 -.実装.-> P2["S3 拡張"]
        I2 -.実装.-> P3["標準出力拡張"]
    end
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

境界で受ける形なら、宛先を 1 つ足してもコアの処理と依存は変わりません。増えるのは拡張だけです。


Unix のパイプとの違い

小さな部品を組み合わせる点は、Unix のパイプも同じです。Unix パイプの考案者 Doug McIlroy 氏の言葉として、Eric S. Raymond 氏は「Make each program do one thing well.」(それぞれのプログラムに 1 つの仕事だけをうまくやらせる)を引いています。

違うのは、組み合わせる主体です。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    subgraph U["Unix のパイプ"]
        direction LR
        Sh["シェルなど<br/>外側の存在"] -.繋ぐ.-> Cm1["cmd1"]
        Sh -.繋ぐ.-> Cm2["cmd2"]
        Cm1 -- "バイト列" --> Cm2
    end
    subgraph PA["Plugin Architecture"]
        direction LR
        Co["コア"] --> Bd{{"境界"}}
        Ex["拡張"] -.実装.-> Bd
    end
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

Unix のパイプでプログラムを繋いでいるのはシェルで、cmd1 は cmd2 を知りません。Plugin Architecture で組み合わせを決めているのはコアです。

Unix のパイプPlugin Architecture
合成する主体シェルなど外側の存在コア
部品の形独立したプログラム境界を実装するコード、または別プロセス
運ばれるものバイト列(テキストで揃えるのは慣習)境界で定めた型やメッセージ
部品の間の約束入出力のバイト形式と、読み手が終了したことの伝わり方インターフェースや通信プロトコルの形と、エラーの扱い

plugin の境界の引き方

Go の in-process 実装なら、コアがインターフェースを宣言し、拡張がそれを実装できます。ただし、インターフェースを切っただけでは Plugin Architecture になりません。どの実装を使うかをコアの処理に埋め込まず、設定から選べるようにします。

Go では、拡張パッケージを import するとその init() が名前と実装の対応表に自分を登録し、使える候補はビルド時に決まります。以下は、その流れです。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    B1["コンパイル"] --> B2["リンク<br/>候補が確定する(Go の場合)"]
    B2 --> B3["起動<br/>拡張が自分を登録する"]
    B3 --> B4["設定を読み<br/>名前で 1 つ選び構築する"]
    B4 --> B5["コアが呼び出す"]
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

書籍『Patterns of Enterprise Application Architecture』で Plugin の項を書いた David Rice 氏と Matt Foemmel 氏が問題として挙げているのは 2 つで、どの実装を使うかの決定があちこちに散ることと、設定を変えるのに再ビルドや再デプロイが必要なことです。インターフェースを実装とは別のパッケージに置く Separated Interface だけでは、if name == "s3" のような選択の分岐が Exporter を使う箇所ごとに残ります。

同書には、この 2 つについて「Plugin solves both problems by providing centralized, runtime configuration.」(集中管理された実行時の設定を用意することで解決する)とあります。集中管理の実体は、名前と実装を対応付ける 1 つのレジストリです。選択はそこに集まり、コアは設定から読んだ名前でレジストリを引きます。

レジストリに登録するものは、組み立て済みの実装でも構いません。database/sql.Register は driver.Driver をそのまま受け取ります。拡張ごとに接続先や認証情報が必要なら、設定を受け取って実装を組み立てる Factory を登録できます。

package export

import (
	"fmt"
	"sync"
)

// Exporter は出力先を表す境界です。
type Exporter interface {
	Export(data []byte) error
}

// Config は設定ファイルなどから読んだ、拡張ごとの設定です。
// S3 なら bucket や region、Elasticsearch なら endpoint が入ります。
type Config map[string]any

// Factory は設定を受け取って Exporter を組み立てます。
type Factory func(Config) (Exporter, error)

var (
	mu        sync.RWMutex
	factories = map[string]Factory{}
)

// Register は拡張の init() から呼ぶ。実行中に呼ばれても壊れないよう保護する。
// nil と二重登録は database/sql と同じく panic させ、黙って上書きしない。
func Register(name string, f Factory) {
	mu.Lock()
	defer mu.Unlock()
	if f == nil {
		panic("export: Register with nil Factory")
	}
	if _, dup := factories[name]; dup {
		panic("export: duplicate Register for " + name)
	}
	factories[name] = f
}

// New は設定から読んだ名前で Factory を 1 つ選び、設定を渡して Exporter を組み立てる。
func New(name string, cfg Config) (Exporter, error) {
	mu.RLock()
	f, ok := factories[name]
	mu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("export: unknown exporter %q", name)
	}
	return f(cfg)
}

このコードでは 2 つのことが別々の時点で決まります。

  • 候補の集合は、実行バイナリがどの拡張パッケージを import しているかで、ビルドの時点に確定する
  • その候補のうちどれを使うかは、New に渡す名前として設定から決まり、実行時に切り替わる

境界を宣言するパッケージ自身は拡張を import しません。database/sql も同じで、ドライバを import するのはアプリケーションです。

そのため、拡張を 1 つ増やすにはバイナリを作り直します。候補そのものをバイナリの外から足すなら、Go の plugin や WebAssembly のようにコアが後からコードを読み込むか、別プロセスで動く実装に接続する仕組みが必要です。


公開した境界と互換性

境界を公開すると、自チームの外の人も拡張を書くようになります。ここからは、拡張の作者が自分たちの管理下に居ない場合の話です。

Go のようにインターフェースが既定実装を持てない言語で境界を表している限り、メソッドの追加も破壊的変更です。追加したメソッドを持たない既存の実装は、その時点でインターフェースを満たさなくなり、ビルドが落ちます。安全なのは「メソッドを足すこと」ではなく「既存のインターフェースを触らないこと」です。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    subgraph K["ビルドで気付ける"]
        direction LR
        A1["メソッドを足す"] --> R1["実装が境界を<br/>満たさなくなる"]
        A2["引数や返り値の<br/>型を変える"] --> R1
        A4["メソッドを消す"] --> R3["実装は満たしたまま<br/>呼んでいた側が壊れる"]
    end
    subgraph U["実行するまで気付けない"]
        direction LR
        A3["引数や返り値の<br/>意味を変える"] --> R2["型は満たしたまま<br/>結果だけが変わる"]
    end
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

厄介なのは実行するまで気付けない側で、型検査もビルドも通るため、誤りが表面化するのは拡張が動いた後になります。

互換性を保って機能を追加するには、既存のインターフェースを変更せず、新しいメソッドを別のインターフェースで定義します。コアは、受け取った拡張が新しいインターフェースを満たすかを実行時に(Go なら型アサーションで)確かめ、満たす拡張にだけ新しい呼び出しを行います。Go の標準ライブラリでは、http.ResponseWriter に対する http.Flusher や http.Pusher がこの形です。

拡張を別プロセスに分けても、公開した境界が API になる点は変わりません。互換を保つ対象が Go のインターフェースではなく、JSON や Protocol Buffers、RPC の呼び出し形式に移るだけです。フィールドを必須にする、意味を変えるといった変更が何を壊すかは、その形式の規約で決まります。


in-process と out-of-process の選び方

境界を挟んで拡張を呼ぶ手段には、同一プロセスに組み込む形と、別プロセスに分ける形があります。この 2 つの間には、WebAssembly や組み込みスクリプトのように、同一プロセスのまま別のランタイムを挟む形もあります。隔離の度合いは、コア(拡張を載せる側)が拡張にどの関数やデータを渡すかで決まります。

コアが呼び出しを recover で包んでも、同一プロセスの拡張はプロセスごと落とし得ます。Go 1.26 で、recover 付きの呼び出し口から前述の Exporter の実装を 2 つ順に呼ぶと、次のようになりました。実装 direct は Export の中で、実装 background は Export が起動した goroutine の中で panic します(出力は抜粋)。

direct: recovered: bug in Export
background: <nil>
panic: bug in goroutine
exit status 2

direct の panic は error として返りました。background の呼び出しは nil を返して成功に見えましたが、その後に goroutine の panic でプロセスごと終了し、コアが続けて出すはずの core is still running は表示されませんでした。Go では、recover が止められるのは同じ goroutine の panic だけで、止められなかった panic はプログラムを終了させます。

in-process(直接リンク)out-of-process
呼び出しの速さ関数呼び出しと同等通信の往復が挟まる
拡張がクラッシュした時プロセスごと落ち得る。メモリ安全でない言語ならコアのメモリも壊し得る拡張のプロセスだけを終了させられ、クラッシュがコアを直接巻き込みにくい
拡張がハングした時呼び出した側が返らず、コアから安全に強制終了する手段を持ちにくいタイムアウトの後にプロセスを kill して回収できる
言語の制約コアと ABI(Application Binary Interface。関数の呼び方やデータの並びを機械語の水準で決めた取り決め)を共有できる言語に限る(Go の plugin はビルド条件や共通依存まで強く揃える必要がある)拡張を別の言語で書ける
直列化の要否不要必要

表の in-process は、拡張をコアに直接リンクする形に限った話です。WebAssembly のランタイムを挟めば、同一プロセスのまま隔離もできます。代わりに、値の受け渡しに変換が必要です。

拡張が使える資源や権限の制限は、プロセスを分けるかどうかとは別に設計します。fork して exec しただけの子プロセスは、親と同じ利用者の資格で動くので、渡していないファイルでも自分で開けます。

  %%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","actorLineColor":"#9cabb5","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteBorderColor":"#c5b99b","noteTextColor":"#403c32","activationBkgColor":"#edf3ef","activationBorderColor":"#9aafa1"}}}%%
flowchart LR
    subgraph N["別プロセスにしただけ"]
        direction LR
        C1["コア"] -- "ファイルの一覧" --> P1["拡張"]
        P1 -.自分で開ける.-> X["渡していない<br/>ファイル"]
    end
    subgraph Y["隔離してから渡す"]
        direction LR
        C2["コア"] -- "ファイルの一覧" --> P2["拡張<br/>mount namespace や<br/>コンテナ、sandbox で<br/>見える範囲を削る"]
        P2 -.届かない.-> X2["渡していない<br/>ファイル"]
    end
    classDef default fill:#eaf1f5,stroke:#9cabb5,color:#28343b;

mount namespace で見えるファイルの木を組み替えたり、コンテナや sandbox で見える範囲や権限を制限したりしてから渡せば、拡張が触れるファイルの範囲は、渡したものとその周りまで狭められます。namespace を新しく作るだけではファイルへのアクセスは制限されず、マウントを組み替えて初めて見えなくなります。実行する利用者を分けただけでは、他人が読めるファイルは開けたままです。


利点

  • コアを変えずに機能を追加・削除でき、コアの分岐と依存が増えない
  • 拡張の書き手が増えても、コアが依存するのは境界だけで済む
  • 別プロセスに分ければ、拡張のクラッシュをコアから分離し、失敗を検知して再起動できる
  • 見える範囲を削った環境で拡張を動かせば、拡張にできることを狭められる

欠点

Plugin Architecture は、コアを変えずに機能を足せるように境界を固定します。その結果、次の制約を受けます。

  • 既定実装を持てない言語では、公開した後の境界にメソッドを追加できず、別のインターフェースを足す形になる
  • 同一プロセスに直接リンクする場合、ハングした拡張をコア側から安全に強制終了する手段を持ちにくい。context.Context のような協調的なキャンセルは拡張が対応していて初めて機能するもので、対応しない拡張を外から打ち切る手段にはならない
  • 別プロセスに分ける場合、通信と直列化に加えて、ハングを止めるタイムアウトが必要

適さないケース

  • 対応する宛先や形式が固定で、増減する見込みが無いシステム
  • 拡張が満たすべき形を 1 つに決められず、境界のインターフェースが定まらない処理
  • 拡張を書くのがコアの開発チームだけで、境界を公開する意味が薄い場合