文档
a project

扩展 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 模块

  1. 实现 caddy.Module 接口以提供 ID 和构造函数
  2. 在适当的命名空间中具有唯一的名称
  3. 通常满足对于该命名空间的主机模块有意义的某些接口

宿主模块(或父模块)是加载/初始化其他模块的模块。它们通常为访客模块定义命名空间。

访客模块(或子模块)是被加载或初始化的模块。所有模块都是访客模块。

模块 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 类型的表格。

caddyadmin 命名空间是保留的,不能作为应用程序名称。

要编写插入到第三方宿主模块中的模块,请查阅这些模块的命名空间文档。

名称

命名空间内的名称对用户来说非常重要且高度可见,但并不是特别重要,只要它是唯一的、简洁的并且对其功能有意义即可。

应用模块

应用程序是具有空命名空间的模块,并且按照惯例,它们会成为自己的顶级命名空间。应用模块实现 caddy.App 接口。

这些模块出现在 Caddy 配置顶级的 "apps" 属性中

{
	"apps": {}
}

示例 应用程序httptls。它们是空命名空间。

为这些应用程序编写的访客模块应位于从应用程序名称派生的命名空间中。例如,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"`
}

在结构体标签中使用 omitempty 选项将在 JSON 输出中省略该字段(如果它是其类型的零值)。这对于在编组 JSON 配置时(例如,从 Caddyfile 适配到 JSON)保持 JSON 配置的简洁明了非常有用。

当模块初始化时,它将已经填写了其配置。在模块初始化后,还可以执行额外的配置准备验证步骤。

模块生命周期

模块的生命在它被宿主模块加载时开始。会发生以下情况

  1. New() 被调用以获取模块值的实例。
  2. 模块的配置被解组到该实例中。
  3. 如果模块是 caddy.Provisioner,则调用 Provision() 方法。
  4. 如果模块是 caddy.Validator,则调用 Validate() 方法。
  5. 此时,宿主模块被赋予加载的访客模块作为 interface{} 值,因此宿主模块通常会将访客模块类型断言为更有用的类型。查看宿主模块的文档,了解其命名空间中的访客模块的要求,例如需要实现哪些方法。
  6. 当不再需要模块时,如果它是 caddy.CleanerUpper,则调用 Cleanup() 方法。

请注意,您的模块的多个加载实例可能在给定时间重叠!在配置更改期间,新模块在旧模块停止之前启动。请务必谨慎使用全局状态。使用 caddy.UsagePool 类型来帮助管理跨模块加载的全局状态。如果您的模块监听套接字,请使用 caddy.Listen*() 来获取支持重叠使用的套接字。

配置准备

模块的配置将自动解组到其值中(加载 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() 来访问其他应用程序,但模块不能有循环依赖关系。换句话说,如果 tls 应用程序加载的模块依赖于 http 应用程序,则由 http 应用程序加载的模块不能依赖于 tls 应用程序。(与 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,其中 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 使用反射来找出加载模块的最佳方法,最重要的是,读取其结构体标签。

如果访客模块必须由用户显式设置,则如果 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)
)