前言

由于 Golang 是静态编译,所以 plugin 机制一直是一个难解的问题,官方提供的 plugin 机制又特别难用,但插件无疑是扩展原始功能的一种最方便的途径。于是乎,各路软件自家都有各种插件机制。caddy 使用 xcaddy 来实现插件机制,我们来看看它是如何做的。

结论

首先上来先给结论,它必须重新编译,没办法,这也是一个必然选择。很多人听到这,只能叹气一下。

使用方式

使用命令安装 xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

然后使用命令选择需要的插件进行重新编译:

1
2
xcaddy build master \
--with github.com/caddyserver/ntlm-transport

其中 master 是指定 caddy 的版本

原理

其实原理非常简单,看源码一下就明白了

1. 生成 main.go

xcaddy 运行 build 命令的时候首先会创建一个临时文件夹,然后按照模板写入一个 main.go 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const mainModuleTemplate = `package main

import (
caddycmd "{{.CaddyModule}}/cmd"

// plug in Caddy modules here
_ "{{.CaddyModule}}/modules/standard"
{{- range .Plugins}}
_ "{{.}}"
{{- end}}
)

func main() {
caddycmd.Main()
}
`

其中包含你需要打包进来的 moudles 放在 import 中,以匿名的方式引入。
并且在 main 函数中运行 caddycmd.Main() 也就是 caddy 主项目的主函数。

2. 运行 go mod tidy 和 go build

然后 xcaddy 就直接运行 go mod tidy 进行 go 相关依赖下载,然后使用 go build 根据相关参数进行构建打包。

3. 插件的注册原理

那为什么只需要引入包,即可完成插件的注册呢?它里面的注册原理其实也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package mymodule

import "github.com/caddyserver/caddy/v2"

func init() {
caddy.RegisterModule(Gizmo{})
}

// Gizmo is an example; put your own type here.
type Gizmo struct {
}

// CaddyModule returns the Caddy module information.
func (Gizmo) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "foo.gizmo",
New: func() caddy.Module { return new(Gizmo) },
}
}

插件注册是通过 caddy.RegisterModule() 来实现的,只需要将这个方法放在 init() 函数中,由于在第二步 import 语句中引入了插件,则运行时就会有依赖并执行 init 函数,从而实现注册。

4. 插件的使用原理

既然插件已经通过 RegisterModule 方法注册上了,那么如何使用对应的插件呢?
首先 caddy 定义了形如下面类似的接口:

1
2
3
type Unmarshaler interface {
UnmarshalCaddyfile(d *Dispenser) error
}

然后插件注册的方法 RegisterModule 中会将插件保存到 modules 这个 map 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func RegisterModule(instance Module) {
mod := instance.CaddyModule()

if mod.ID == "" {
panic("module ID missing")
}
if mod.ID == "caddy" || mod.ID == "admin" {
panic(fmt.Sprintf("module ID '%s' is reserved", mod.ID))
}
if mod.New == nil {
panic("missing ModuleInfo.New")
}
if val := mod.New(); val == nil {
panic("ModuleInfo.New must return a non-nil module instance")
}

modulesMu.Lock()
defer modulesMu.Unlock()

if _, ok := modules[string(mod.ID)]; ok {
panic(fmt.Sprintf("module already registered: %s", mod.ID))
}
modules[string(mod.ID)] = mod
}

只要插件实现了对应的接口,并注册上去,caddy 会通过下面的方法获取:

1
2
3
4
5
6
7
8
9
10
// GetModule returns module information from its ID (full name).
func GetModule(name string) (ModuleInfo, error) {
modulesMu.RLock()
defer modulesMu.RUnlock()
m, ok := modules[name]
if !ok {
return ModuleInfo{}, fmt.Errorf("module not registered: %s", name)
}
return m, nil
}

然后通过反射进行类型推导,转换为对应的接口类型,从而进行使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取模块
mod, err := caddy.GetModule("http.matchers." + matcherName)
if err != nil {
return fmt.Errorf("getting matcher module '%s': %v", matcherName, err)
}

// 转换为对应接口类型
unm, ok := mod.New().(caddyfile.Unmarshaler)
if !ok {
return fmt.Errorf("matcher module '%s' is not a Caddyfile unmarshaler", matcherName)
}

// 调用接口对应的实现方法
err = unm.UnmarshalCaddyfile(caddyfile.NewDispenser(tokens))
if err != nil {
return err
}

设计思路

如果让我以这样的思路去设计插件,我一定会说,先把源码下载下来,然后再 main 函数中添加对应的模块依赖,然后重新编译。这样的问题是,你需要手动下载并编辑代码,不太友好和方便,而 caddy 提供了另一种解决思路,就是将依赖倒置给 xcaddy。由 xcaddy 生成一个新的项目,而新项目中依赖了 caddy 原始项目和 需要的插件。这样的好处是,用户不用关心源码,原项目不需要修改,只需关心插件即可。

总结

虽然 golang 中的插件思路往往都有些看着难办,但这也不失为一种解决方式。

参考链接

https://github.com/caddyserver/xcaddy
https://caddyserver.com/docs/extending-caddy