扩展 Caddy
由于其模块化架构,Caddy 易于扩展。大多数类型的 Caddy 扩展(或插件)如果扩展或插入到 Caddy 的配置结构中,则称为 模块(modules)。需要说明的是,Caddy 模块与 Go modules 是不同的概念(但它们本身通常也是 Go module)。
先决条件:
快速入门
当某个包被导入时,任何在导入时向 Caddy 注册自身为 Caddy 模块的命名类型就是一个 Caddy 模块。关键在于,模块始终实现 caddy.Module 接口,该接口提供模块的名称和构造函数。
在一个新的 Go 模块中,将下面的模板粘贴到一个 Go 文件中,并根据需要自定义你的包名、类型名和 Caddy 模块 ID:
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) },
}
}
然后在你项目的目录下运行此命令,你应该能在列表中看到你的模块:
xcaddy list-modules
...
foo.gizmo
...
恭喜,你的模块已向 Caddy 注册,并且可以在 Caddy 的配置文档 中,在使用相同命名空间的地方使用。
在底层,xcaddy 只是创建了一个新的 Go module,该模块同时依赖 Caddy 和你的插件(并使用适当的 replace 指向本地开发版本),然后添加一个导入以确保它被编译进来:
import _ "github.com/example/mymodule"
模块基础
Caddy 模块需满足:
- 实现
caddy.Module接口以提供 ID 和构造函数 - 在适当的命名空间内具有唯一名称
- 通常还会满足对该命名空间的宿主模块有意义的某些接口
宿主模块(或 parent modules)是会加载/初始化其他模块的模块。它们通常为来宾模块定义命名空间。
来宾模块(或 child modules)是被加载或初始化的模块。所有模块在被加载时都是来宾模块。
模块 ID
每个 Caddy 模块都有一个唯一的 ID,由命名空间和名称组成:
- 完整的 ID 看起来像
foo.bar.module_name - 命名空间是
foo.bar - 名称是
module_name,必须在其命名空间中保持唯一
模块 ID 必须使用 snake_case 约定。
命名空间
命名空间类似于类,也就是说命名空间定义了该命名空间内所有模块的共通功能。例如,我们可以期望 http.handlers 命名空间中的所有模块都是 HTTP 处理器。因此,宿主模块可能会将该命名空间中的来宾模块从 interface{} 类型断言为更具体、有用的类型,例如 caddyhttp.MiddlewareHandler。
来宾模块必须有正确的命名空间,宿主模块才会识别它们,因为宿主模块会向 Caddy 请求某个命名空间内的模块以提供宿主模块所需的功能。例如,如果你要编写一个名为 gizmo 的 HTTP 处理器模块,那么你的模块名称应为 http.handlers.gizmo,因为 http 应用会在 http.handlers 命名空间中查找处理器。
换句话说,Caddy 模块通常会根据其模块命名空间实现某些接口。有了这一约定,模块开发者可以直观地说:“http.handlers 命名空间中的所有模块都是 HTTP 处理器。” 更技术化地说,这通常意味着:“http.handlers 命名空间中的所有模块都实现 caddyhttp.MiddlewareHandler 接口。” 因为这个方法集是已知的,所以可以将更具体的类型断言并使用它。
查看将所有标准 Caddy 命名空间映射到其 Go 类型的表。
caddy 和 admin 命名空间是保留的,不能被用作应用名。
要编写可以插入第三方宿主模块的模块,请查阅那些模块的命名空间文档。
名称
命名空间内的名称对用户非常重要且高度可见,但并不是特别敏感,只要它在命名空间内唯一、简洁并且对其功能有意义即可。
应用模块(App Modules)
应用是命名空间为空的模块,并且惯例上会成为自己的顶级命名空间。应用模块实现了 caddy.App 接口。
这些模块出现在 Caddy 配置顶层的 "apps" 属性中:
{
"apps": {}
}
示例应用有 http 和 tls。它们的命名空间为空。
为这些应用编写的来宾模块应当位于由应用名派生的命名空间中。例如,HTTP 处理器使用 http.handlers 命名空间,TLS 证书加载器使用 tls.certificates 命名空间。
模块实现
模块可以是几乎任何类型,但结构体最常见,因为它们可以保存用户配置。
配置
大多数模块需要一些配置。只要你的类型兼容 JSON,Caddy 会自动处理这部分。因此,如果模块是一个结构体类型,则其字段需要 struct 标签,这些字段应按照 Caddy 约定使用 snake_casing:
type Gizmo struct {
MyField string `json:"my_field,omitempty"`
Number int `json:"number,omitempty"`
}
在 struct 标签中使用 omitempty 选项将在字段为其类型的零值时从 JSON 输出中省略该字段。这在序列化(例如从 Caddyfile 转换为 JSON)时可以保持配置干净简洁。
当模块被初始化时,其配置已经被填充完毕。也可以在模块初始化后执行额外的预配置(provisioning)和验证(validating)步骤。
模块生命周期
模块的生命周期从其被宿主模块加载时开始。会发生以下步骤:
- 调用
New()来获取模块实例的值。 - 模块的配置被反序列化(unmarshal)到该实例中。
- 如果模块实现了
caddy.Provisioner,则会调用Provision()方法。 - 如果模块实现了
caddy.Validator,则会调用Validate()方法。 - 此时,宿主模块以
interface{}值的形式接收已加载的来宾模块,因此宿主模块通常会将来宾模块断言为更有用的类型。请查看宿主模块的文档以了解在其命名空间中来宾模块需要实现什么,例如需要实现哪些方法。 - 当模块不再需要时,如果模块实现了
caddy.CleanerUpper,则会调用Cleanup()方法。
注意,你的模块可能在同一时间点有多个已加载的实例重叠存在!在配置更改期间,新模块会在旧模块停止之前启动。务必小心使用全局状态。使用 caddy.UsagePool 类型来帮助跨模块加载管理全局状态。如果你的模块监听一个 socket,请使用 caddy.Listen*() 来获取一个支持重叠使用的 socket。
预配置(Provisioning)
模块的配置会在加载 JSON 配置时自动反序列化到其值中。这意味着,例如,结构体字段会被填充。
然而,如果你的模块需要额外的预配置步骤,你可以实现可选的 caddy.Provisioner 接口:
// Provision sets up the module.
func (g *Gizmo) Provision(ctx caddy.Context) error {
// TODO: set up the module
return nil
}
这里你应该为用户未提供的字段设置默认值(即字段仍为其零值时)。如果某个字段是必需的,当其未设置时你可以返回一个错误。对于零值有特定含义的数值字段(例如某些超时持续时间),你可能想支持用 -1 表示“关闭”而不是 0,因此当用户未配置时可以设置默认值。
通常宿主模块也会在这里加载其来宾/子模块。
模块可以通过调用 ctx.App() 访问其他应用,但模块之间不得存在循环依赖。换言之,被 http 应用加载的模块不能依赖 tls 应用,如果被 tls 应用加载的模块又依赖 http 应用。(这类似于 Go 中禁止导入循环的规则。)
此外,应避免在 Provision 中执行昂贵的操作,因为预配置即使在仅验证配置时也会被执行。在预配置阶段,不要期望模块一定会被实际使用。
日志
参见 Caddy 中的 日志工作原理。如果你的模块需要记录日志,不要使用 Go 标准库的 log.Print*()。换言之,不要使用 Go 的全局日志器。Caddy 使用性能高、灵活且结构化的日志库 zap。
要输出日志,请在模块的 Provision 方法中获取一个日志记录器:
func (g *Gizmo) Provision(ctx caddy.Context) error {
g.logger = ctx.Logger() // g.logger is a *zap.Logger
}
随后你可以使用 g.logger 输出结构化、分级的日志。详见 zap 的 godoc。
验证(Validating)
希望验证其配置的模块可以通过实现可选的 caddy.Validator 接口来完成验证:
// Validate validates that the module has a usable config.
func (g Gizmo) Validate() error {
// TODO: validate the module's setup
return nil
}
Validate 应当是一个只读函数。它在 Provision() 方法之后运行。
接口守卫(Interface guards)
Caddy 模块的行为是隐式的,因为 Go 的接口是隐式满足的。只需向模块类型添加正确的方法即可决定模块是否正确。因此,拼写错误或方法签名错误可能导致意外的(缺失的)行为。
幸运的是,你可以在代码中添加一种简单且无运行时开销的编译期检查来确保已添加正确的方法。这些称为接口守卫:
var _ InterfaceName = (*YourType)(nil)
将 InterfaceName 替换为你希望满足的接口,将 YourType 替换为你的模块类型名。
例如,像静态文件服务器这样的 HTTP 处理器可能会满足多个接口:
// Interface guards
var (
_ caddy.Provisioner = (*FileServer)(nil)
_ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
)
如果 *FileServer 未满足这些接口,上述写法会阻止程序编译通过。
没有接口守卫,容易引入令人困惑的错误。例如,如果你的模块在被使用前必须先完成预配置,但你的 Provision() 方法有错误(例如拼写错误或签名不正确),则预配置永远不会发生,进而导致难以理解的问题。接口守卫非常简单且能预防此类问题。它们通常放在文件底部。
宿主模块(Host Modules)
当一个模块加载其自己的来宾模块时,它就成为了宿主模块。这在模块的一部分功能可以通过不同方式实现时非常有用。
宿主模块几乎总是一个结构体。通常,支持来宾模块需要两个结构体字段:一个用于保存其原始 JSON,另一个用于保存其解码后的值:
type Gizmo struct {
GadgetRaw json.RawMessage `json:"gadget,omitempty" caddy:"namespace=foo.gizmo.gadgets inline_key=gadgeter"`
Gadget Gadgeter `json:"-"`
}
第一个字段(在此示例中为 GadgetRaw)是保存来宾模块的原始、未预配置的 JSON 形式的位置。
第二个字段(此示例中的 Gadget)是最终预配置后的值最终将被存放的地方。由于第二个字段不是面向用户的,我们在 struct 标签中将其从 JSON 中排除。(如果不需要被其他包访问,也可以将其设为未导出字段,这样就不需要 struct 标签了。)
Caddy struct 标签
原始模块字段上的 caddy struct 标签帮助 Caddy 确定要加载的模块的命名空间和名称(构成完整的 ID)。它也用于生成文档。
该 struct 标签格式非常简单:key1=val1 key2=val2 ...
对于模块字段,struct 标签看起来像:
`caddy:"namespace=foo.bar inline_key=baz"`
namespace= 部分是必需的。它定义了查找模块时使用的命名空间。
inline_key= 部分仅在模块名称与模块本身“内联”出现时使用;这意味着该值是一个对象,其中的某个键就是 inline key,其值为模块的名称。如果省略该项,则字段类型必须是 caddy.ModuleMap 或 []caddy.ModuleMap,其中 map 的键为模块名称。
加载来宾模块
在预配置阶段,调用 ctx.LoadModule() 来加载来宾模块:
// Provision sets up g and loads its gadget.
func (g *Gizmo) Provision(ctx caddy.Context) error {
if g.GadgetRaw != nil {
val, err := ctx.LoadModule(g, "GadgetRaw")
if err != nil {
return fmt.Errorf("loading gadget module: %v", err)
}
g.Gadget = val.(Gadgeter)
}
return nil
}
注意,LoadModule() 调用接受指向结构体的指针和字段名的字符串。看起来有点奇怪,对吧?为什么不直接传递结构体字段?这是因为根据配置布局有几种不同的加载模块方式。这个方法签名允许 Caddy 使用反射来找出最佳的加载方式,并且最重要的是读取其 struct 标签。
如果某个来宾模块必须由用户显式设置,则在尝试加载之前,当 Raw 字段为 nil 或空时你应返回错误。
注意已加载模块是如何被类型断言的:g.Gadget = val.(Gadgeter)——这是因为返回的 val 是 interface{} 类型,不够有用。然而,我们期望在声明的命名空间内的所有模块(在示例的 struct 标签中为 foo.gizmo.gadgets)都实现 Gadgeter 接口,因此这种类型断言是安全的,随后我们就可以使用它了!
如果你的宿主模块定义了一个新命名空间,务必为开发者记录该命名空间及其 Go 类型(如我们在此处所做的)文档。
模块文档
注册模块以使新的 Caddy 模块出现在模块文档中,并可在 http://caddyserver.com/download 上获取。注册入口位于 http://caddyserver.com/account。如果你还没有账户,请创建一个新账户并点击 “Register package”。
完整示例
假设我们要编写一个 HTTP 处理器模块。这里将示例化一个用于演示的中间件:在每次 HTTP 请求时将访问者的 IP 地址写到一个流(stream)上。
我们还希望它可以通过 Caddyfile 进行配置,因为大多数人在非自动化场景下更喜欢使用 Caddyfile。我们为此注册一个 Caddyfile 处理器指令,这是一种可以向 HTTP 路由添加处理器的指令。我们还实现了 caddyfile.Unmarshaler 接口。只需添加这些代码行,该模块就可以通过 Caddyfile 配置了!例如:visitor_ip stdout。
下面是该模块的代码,并附有说明性注释:
package visitorip
import (
"fmt"
"io"
"net/http"
"os"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
func init() {
caddy.RegisterModule(Middleware{})
httpcaddyfile.RegisterHandlerDirective("visitor_ip", parseCaddyfile)
}
// Middleware implements an HTTP handler that writes the
// visitor's IP address to a file or stream.
type Middleware struct {
// The file or stream to write to. Can be "stdout"
// or "stderr".
Output string `json:"output,omitempty"`
w io.Writer
}
// CaddyModule returns the Caddy module information.
func (Middleware) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.visitor_ip",
New: func() caddy.Module { return new(Middleware) },
}
}
// Provision implements caddy.Provisioner.
func (m *Middleware) Provision(ctx caddy.Context) error {
switch m.Output {
case "stdout":
m.w = os.Stdout
case "stderr":
m.w = os.Stderr
default:
return fmt.Errorf("an output stream is required")
}
return nil
}
// Validate implements caddy.Validator.
func (m *Middleware) Validate() error {
if m.w == nil {
return fmt.Errorf("no writer")
}
return nil
}
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (m Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
m.w.Write([]byte(r.RemoteAddr))
return next.ServeHTTP(w, r)
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (m *Middleware) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
d.Next() // consume directive name
// require an argument
if !d.NextArg() {
return d.ArgErr()
}
// store the argument
m.Output = d.Val()
return nil
}
// parseCaddyfile unmarshals tokens from h into a new Middleware.
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var m Middleware
err := m.UnmarshalCaddyfile(h.Dispenser)
return m, err
}
// Interface guards
var (
_ caddy.Provisioner = (*Middleware)(nil)
_ caddy.Validator = (*Middleware)(nil)
_ caddyhttp.MiddlewareHandler = (*Middleware)(nil)
_ caddyfile.Unmarshaler = (*Middleware)(nil)
)