今回はGoで出来たWebサーバーであるCaddyの自作モジュール(プラグイン)を書いてみたいと思います。(Caddy 2.8.4 で検証)
この記事で使用しているコードは以下で入手可能です。
どうやってCaddyで自作モジュールを実装すればいいのか?
以下の公式ドキュメントで詳しく説明されているのですが、要は自作パッケージの init()
関数にモジュール登録のコード func init () {caddy.RegisterModule(...)}
を仕込んでおき、それをxcaddyというツールでCaddy側からパッケージimportしてもらえば自作モジュール組み込み完了です。
詳しく書くと以下のようになります。
- 自作モジュールの
init
関数でcaddy.RegisterModule(Moduleインタフェースを実装したインスタンス)
を呼び出す-
func CaddyModule() caddy.ModuleInfo
を実装した構造体を登録すればOKです
-
- 上記の
init
関数を呼び出すため(=パッケージインポートで読み込ませるため)に自作モジュールを組み込んだCaddyを xcaddy でビルドする
プラグインといいつつ*.so
のロードやIPCやgRPCといった難しい要素はありません。(逆に言うとビルド後、他のWebサーバーのように動的に後でプラグインを組み込む方法がないとも言えます)
とはいえ、デバッグ時にxcaddy読んで云々はちょっと面倒なのでこの記事ではいくつかショートカットする方法について述べていきます。
では、さっそく自作モジュールを書いてみましょう!
すごく簡単なCaddyモジュールの実装
Goプロジェクトを新規作成します。
$ mkdir my-caddy-mod
$ cd my-caddy-mod
$ go mod init my-caddy-mod
まずは最小の自作Caddyモジュールを定義します。
package mycaddymod
import "github.com/caddyserver/caddy/v2"
func init() {
caddy.RegisterModule(MyCaddyMod{})
}
type MyCaddyMod struct {
}
func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "mycaddymod",
New: func() caddy.Module { return new(MyCaddyMod) },
}
}
あとはxcaddyで上記パッケージimportをCaddyに組み込んでもらえばOKですが、いちいち自作プロジェクトをcaddyプロジェクト側から組み込むのは面倒くさいので、Caddy自体を自作プロジェクトから呼び出して検証します。
package main
import (
// 自作モジュールの呼び出し
_ "my-caddy-mod/mycaddymod"
// Caddyのコア
caddycmd "github.com/caddyserver/caddy/v2/cmd"
// Caddyfileのサポートなど標準モジュールの組み込み
_ "github.com/caddyserver/caddy/v2/modules/standard"
)
func main() {
// https://github.com/caddyserver/caddy/blob/master/cmd/caddy/main.go と内容は同じ
caddycmd.Main()
}
そもそも import
すら省略して main.go
にモジュール定義そのものを直接書くこともできます。 この記事では分かりやすさ優先でこの方法を採用します。
package main
import (
"github.com/caddyserver/caddy/v2"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
_ "github.com/caddyserver/caddy/v2/modules/standard"
)
func main() {
caddy.RegisterModule(MyCaddyMod{})
caddycmd.Main()
}
type MyCaddyMod struct {
}
func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "mycaddymod",
New: func() caddy.Module { return new(MyCaddyMod) },
}
}
ではこれを実行してみましょう。caddy list-modules
コマンド相当を動かし、自作モジュールが組み込まれているか確認してみます。 Non-standard modules
として mycaddymod
が認識されていることが分かります。
# go mod tidy は適宜動かしてください
$ go mod tidy
# go run main.go list-modules でも可
$ go run . list-modules
admin.api.load
caddy.config_loaders.http
caddy.logging.writers.discard
caddy.logging.writers.stderr
caddy.logging.writers.stdout
Standard modules: 5
mycaddymod
Non-standard modules: 1
Unknown modules: 0
自作モジュールが組み込まれたCaddyをWebサーバーとして稼働させてみましょう。設定ファイル Caddyfile
を用意して run
コマンドを実行するだけです。2
http://localhost:18080 {
respond "hello!"
}
$ go run . run --config Caddyfile
2024/11/20 05:41:31.802 INFO using config from file {"file": "Caddyfile"}
2024/11/20 05:41:31.805 INFO adapted config to JSON {"adapter": "caddyfile"}
...
$ curl localhost:18080
hello!
無事HTTPサーバーとして応答してくれました。
設定ファイルを読み込んだり、Webサーバーとしての機能を追加してみる
何もしないモジュールではつまらないので、何か機能を追加したいと思います。
ここでは特定のディレクティブを書いたパスに自作APIサーバーを生やしたいと思います。応答は設定の message = "...."
の内容を返すこととしましょう。
http://localhost:18080 {
mycaddymod {
message = "hello from mycaddymod Caddyfile config!"
}
}
// => curl http://localhost:18080 にアクセスすると↑のメッセージが返ってくる
設定ファイルを読み込む実装を追加する
mycaddymod { message = "something" }
を設定として読み込むには以下のコードを追加します。
func main() {
caddy.RegisterModule(MyCaddyMod{})
// 以下の2行を追加
// 1行目: `mycaddymod` というキーワードをparseCaddyfileHandler関数に結びつけるようにhttpcaddyfile上に登録
// 2行目: そのままだと適用優先順位が分からないので `route` ディレクティブ上にしか書けないがとりあえず `header` ディレクティブの後ぐらいと適当に宣言
httpcaddyfile.RegisterHandlerDirective("mycaddymod", parseCaddyfileHandler)
httpcaddyfile.RegisterDirectiveOrder("mycaddymod", httpcaddyfile.After, "header")
caddycmd.Main()
}
type MyCaddyMod struct {
// 設定値を保存するフィールド
Message string `json:"message_on_json,omitempty"`
}
parseCaddyfileHandler
関連は以下の定義を追加します。
// parseCaddyfileHandler は設定ファイルの値が設定された構造体を返す
func parseCaddyfileHandler(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var m MyCaddyMod
err := m.UnmarshalCaddyfile(h.Dispenser)
return m, err
}
// UnmarshalCaddyfile は設定ファイルをパースして設定ファイルで出会った値をフィールドにコピーする
func (m *MyCaddyMod) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
d.Next() // `mycaddymod` 部分を読み飛ばす
for d.NextBlock(0) {
switch d.Val() {
case "message": // `message = "..."` の部分の読み込み
if !d.AllArgs(&m.Message) {
return d.ArgErr()
}
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
}
return nil
}
上記のコードで設定ファイルの読み込みを行い、モジュールのフィールドに設定値を保存できるようになりました。
ところで、Caddyの設定ファイル(設定モデル)は本来的にはCaddyfileではなくJSONです。上記の設定ファイルをJSONで出してみると……
// `go run . adapt --pretty --config Caddyfile` コマンドで設定ファイルをJSONにしたものを出力
// (記事掲載のため整形済)
{
"apps": { "http": { "servers": { "srv0": { "listen": [ ":18080" ],
"routes": [ { "match": [ { "host": [ "localhost" ] } ],
"handle": [ { "handler": "subroute", "routes": [
{ "handle": [
// Caddyfile 上の設定が反映されていることがわかる
{
"handler": "mycaddymod",
"message_on_json": "hello from mycaddymod Caddyfile config!"
}
// 以下省略
構造体で指定されている json:"message_on_json,omitempty"
タグが使われていることがわかります。
http.handlers
に属するミドルウェアとしてモジュールを追加する
CaddyモジュールでHTTPリクエストを扱いたい場合はどうすればよいのでしょうか?
そういったHTTPミドルウェアを書く際はモジュールの所属先となるCaddy上の http.handlers
名前空間に所属しつつ、モジュール自体も caddyhttp.MiddlewareHandler
(ServeHTTP()
) を実装する必要があります。
func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.mycaddymod", // http.handlers.~~ に名前を変更
New: func() caddy.Module { return new(MyCaddyMod) },
}
}
func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
// 実際のHTTPリクエスト処理
w.Write([]byte(m.Message)) // 設定ファイルのメッセージが書き出されるはず
return nil
}
var (
_ caddyhttp.MiddlewareHandler = (*MyCaddyMod)(nil)
)
これで準備は整いました。
動かしてみる
それでは実際に自作APIサーバーを組み込んだCaddyを動かしてみましょう。
$ go run . run --config Caddyfile
...
$ curl localhost:18080
hello from mycaddymod Caddyfile config!
設定ファイルに書いた内容で応答が返ってきました!
他のディレクティブに処理を委譲することも可能です。50%の確率で下流のモジュールに処理を委譲するように書き換えてみましょう。
func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
// 50%の確率で next を呼ぶ
if rand.Intn(2) == 0 {
return next.ServeHTTP(w, req)
}
w.Write([]byte(m.Message))
return nil
}
http://localhost:18080 {
mycaddymod {
message "50% hit!"
}
respond "delegated"
}
実行結果は以下の通りです。半々ぐらいで処理が委譲されています。
$ curl localhost:18080
delegated
$ curl localhost:18080
50% hit!
$ curl localhost:18080
delegated
$ curl localhost:18080
50% hit!
$ curl localhost:18080
50% hit!
$ curl localhost:18080
delegated
CaddyにGinを直接組み込んで最強のマイWebサーバーを誕生させる
GinはGoの有名なWebフレームワークです。
これをCaddyに直接組み込んで、 ただのリバースプロキシを脱却し、最強のWebサーバーに進化してもらいます。
とはいってもやることは簡単です。単純にGinのハンドラにCaddyからのリクエストを横流しすればいいだけです。
type MyCaddyMod struct {
Message string `json:"message_on_json,omitempty"`
// 追加
handler http.Handler
}
// Provision でモジュールの初期化を行う
func (m *MyCaddyMod) Provision(ctx caddy.Context) error {
// Ginのハンドラを初期化する
r := gin.Default()
r.GET("/message", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": m.Message,
})
})
m.handler = r.Handler()
return nil
}
func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
// Ginのハンドラに横流しするだけ
m.handler.ServeHTTP(w, req)
return nil
}
var (
_ caddy.Provisioner = (*MyCaddyMod)(nil)
_ caddyhttp.MiddlewareHandler = (*MyCaddyMod)(nil)
)
http://localhost:18080 {
mycaddymod {
message "GIN on Caddy"
}
}
実行してみましょう。
$ go run . run --config Caddyfile
$ curl localhost:18080
404 page not found
$ curl localhost:18080/message
{"message":"GIN on Caddy"}
Ginが応答を返していることがわかります。(実際に組み込む際はCaddy側のロガーの流儀に合わせたりなど課題はあります)
まとめ
main.go
から起動するプログラムにCaddyと自作モジュールを組み込み、設定ファイルを読み込んだり、HTTPミドルウェアとして面白い機能を追加する方法を見てきました。この記事の知識があればCaddyのモジュールのソースコードをある程度理解できると思います。
最後のGinと合成するような方法は通常のHTTPハンドラが整理されているGoならではの何かしら使い方がありそうかなと思います。Caddy本来のLet's Encrypt(ACME)へのネイティブ対応やいろんなディレクティブと自作サーバーを組み合わせて柔軟に処理ができるシングルバイナリの最強マイWebサーバーも作れるでしょう。
-
内部的にはGoコマンドを呼び出して既存のCaddyの依存性 +
--with
で指定したモジュールを組み合わせて、go build のお膳立てをした後にビルドをしているようです https://github.com/caddyserver/xcaddy/blob/2977c7faa42817239cf6bb63bb0dd30790c96674/cmd/commands.go#L76 ↩ -
途中のソースコードにも記述していますが、設定ファイルである
Caddyfile
はmain.go
でimport _ "github.com/caddyserver/caddy/v2/modules/standard"
していないとCaddyfile
アダプタの不在で読み込むことができません(裸のCaddyが対応している設定ファイルはJSONフォーマットだけです) ↩