扩展 Caddy
Caddy 由于其模块化架构,易于扩展。大多数 Caddy 扩展(或插件)被称为模块,如果它们扩展或插入 Caddy 的配置结构。为了清楚起见,Caddy 模块与 Go 模块 不同(但它们也是 Go 模块)。
先决条件
快速入门
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 模块,该模块需要 Caddy 和您的插件(使用适当的 replace
来使用您本地的开发版本),然后添加一个导入以确保它被编译在内
import _ "github.com/example/mymodule"
模块基础
Caddy 模块
- 实现
caddy.Module
接口以提供 ID 和构造函数 - 在正确的命名空间中具有唯一的名称
- 通常满足对该命名空间的主模块有意义的某些接口
主模块(或父模块)是加载/初始化其他模块的模块。它们通常为来宾模块定义命名空间。
来宾模块(或子模块)是被加载或初始化的模块。所有模块都是来宾模块。
模块 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
命名空间是保留的,不能是应用程序名称。
要编写插入第三方主模块的模块,请咨询这些模块以获取其命名空间文档。
名称
命名空间中的名称很重要,对用户来说非常明显,但并不特别重要,只要它是唯一的、简洁的,并且对它所做的事情有意义即可。
应用程序模块
应用程序是具有空命名空间的模块,并且通常会成为它们自己的顶级命名空间。应用程序模块实现 caddy.App 接口。
这些模块出现在 Caddy 配置的顶级 "apps"
属性中
{
"apps": {}
}
示例 应用程序 是 http
和 tls
。它们是空命名空间。
为这些应用程序编写的来宾模块应该位于从应用程序名称派生的命名空间中。例如,HTTP 处理程序使用 http.handlers
命名空间,而 TLS 证书加载程序使用 tls.certificates
命名空间。
模块实现
模块实际上可以是任何类型,但结构体是最常见的,因为它们可以保存用户配置。
配置
大多数模块需要一些配置。Caddy 会自动处理此问题,只要您的类型与 JSON 兼容即可。因此,如果模块是结构体类型,它将需要在其字段上使用结构体标签,这些标签应该根据 Caddy 约定使用 snake_casing
type Gizmo struct {
MyField string `json:"my_field,omitempty"`
Number int `json:"number,omitempty"`
}
以这种方式使用结构体标签将确保配置属性在整个 Caddy 中始终如一地命名。
当模块被初始化时,它将已经填充了它的配置。在模块被初始化后,还可以执行额外的 配置 和 验证 步骤。
模块生命周期
模块的生命始于它被主模块加载时。以下将发生
New()
被调用以获取模块值的实例。- 模块的配置被反序列化到该实例中。
- 如果模块是 caddy.Provisioner,则会调用
Provision()
方法。 - 如果模块是 caddy.Validator,则会调用
Validate()
方法。 - 此时,主模块将加载的来宾模块作为
interface{}
值提供,因此主模块通常会将来宾模块类型断言为更实用的类型。查看主模块的文档以了解其命名空间中对来宾模块的要求,例如需要实现哪些方法。 - 当模块不再需要时,如果它是 caddy.CleanerUpper,则会调用
Cleanup()
方法。
请注意,您的模块的多个加载实例可能在给定时间重叠!在配置更改期间,新模块将在旧模块停止之前启动。确保谨慎使用全局状态。使用 caddy.UsagePool 类型来帮助管理模块加载之间的全局状态。如果您的模块监听套接字,请使用 caddy.Listen*()
获取支持重叠使用的套接字。
配置
模块的配置将自动反序列化到其值中。这意味着,例如,结构体字段将为您填充。
但是,如果您的模块需要额外的配置步骤,您可以实现(可选)caddy.Provisioner 接口
// Provision sets up the module.
func (g *Gizmo) Provision(ctx caddy.Context) error {
// TODO: set up the module
return nil
}
这通常是主模块加载其来宾/子模块的地方,但它可以用于几乎任何事情。模块配置以任意顺序完成。
模块可以通过调用 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。
验证
希望验证其配置的模块可以通过满足(可选)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()
方法之后运行。
接口保护
Caddy 模块行为是隐式的,因为 Go 接口是隐式满足的。只需在您的模块的类型中添加正确的方法,就足以使您的模块正确或错误。因此,打错字或方法签名错误会导致意外的(缺乏)行为。
幸运的是,您可以添加一个简单的、无开销的、编译时检查到您的代码中,以确保您添加了正确的方法。这些被称为接口保护
var _ InterfaceName = (*YourType)(nil)
将 InterfaceName
替换为您要满足的接口,并将 YourType
替换为您的模块类型的名称。
例如,静态文件服务器等 HTTP 处理程序可能会满足多个接口
// Interface guards
var (
_ caddy.Provisioner = (*FileServer)(nil)
_ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
)
这将阻止程序编译,如果 *FileServer
不满足这些接口。
没有接口保护,令人困惑的错误可能会潜入。例如,如果您的模块必须在使用之前配置自身,但您的 Provision()
方法存在错误(例如拼写错误或签名错误),则配置将永远不会发生,从而导致抓耳挠腮。接口保护非常容易,可以防止这种情况发生。它们通常放在文件的底部。
主模块
当模块加载自己的来宾模块时,它就变成了主模块。如果模块的功能的一部分可以以不同的方式实现,这将很有用。
主模块几乎总是结构体。通常,支持来宾模块需要两个结构体字段:一个用于保存其原始 JSON,另一个用于保存其解码后的值
type Gizmo struct {
GadgetRaw json.RawMessage `json:"gadget,omitempty" caddy:"namespace=foo.gizmo.gadgets inline_key=gadgeter"`
Gadget Gadgeter `json:"-"`
}
第一个字段(本例中的 GadgetRaw
)是原始的、未配置的来宾模块的 JSON 形式可以找到的地方。
第二个字段(Gadget
)是最终的、配置后的值最终将被存储的地方。由于第二个字段不是面向用户的,因此我们使用结构体标签将其从 JSON 中排除。(如果其他包不需要它,您也可以将其隐藏,然后不需要结构体标签。)
Caddy 结构体标签
在原始模块字段上使用 `caddy` 结构体标签可以帮助 Caddy 识别要加载的模块的命名空间和名称(构成完整 ID)。它还用于生成文档。
结构体标签的格式非常简单:`key1=val1 key2=val2 ...`
对于模块字段,结构体标签将如下所示
`caddy:"namespace=foo.bar inline_key=baz"`
`namespace=` 部分是必需的。它定义了查找模块的命名空间。
`inline_key=` 部分仅在模块名称与模块本身内联时使用;这意味着该值是一个对象,其中一个键是内联键,其值是模块的名称。如果省略,则字段类型必须是 caddy.ModuleMap
或 []caddy.ModuleMap
,其中映射键是模块名称。
加载访客模块
要加载访客模块,请在配置阶段调用 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 使用反射来找出加载模块的最佳方式,最重要的是读取其结构体标签。
如果访客模块必须由用户显式设置,则在尝试加载它之前,如果 Raw 字段为 nil 或为空,则应返回错误。
请注意加载的模块是如何类型断言的:`g.Gadget = val.(Gadgeter)` - 这是因为返回的 `val` 是一个 `interface{}` 类型,这不太有用。但是,我们期望声明的命名空间(来自我们示例中结构体标签的 `foo.gizmo.gadgets`)中的所有模块都实现 `Gadgeter` 接口,因此这种类型断言是安全的,然后我们可以使用它!
如果您的主机模块定义了一个新的命名空间,请务必为开发人员记录该命名空间及其 Go 类型 就像我们在这里所做的那样。
模块文档
注册模块以使新的 Caddy 模块显示在模块文档中,并在 https://caddyserver.com.cn/download 中可用。注册可在 https://caddyserver.com.cn/account 中进行。如果您还没有帐户,请创建一个新帐户,然后点击“注册包”。
完整示例
假设我们要编写一个 HTTP 处理程序模块。这将是一个为了演示目的而设计的虚构中间件,它会在每个 HTTP 请求中将访问者的 IP 地址打印到流中。
我们还希望它可以通过 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)
)