Skip to content

路由

介绍

路由是任何 Goyave 应用程序的重要组成部分。定义路由是将 URI(有时带有参数)与将处理请求并响应的处理器关联起来的操作。清晰分离和命名路由对于使您的 API 清晰且表达性强非常重要。

定义路由的入口点是主路由注册函数,您将传递给服务器的 server.RegisterRoutes() 方法。

go
func Register(server *goyave.Server, router *goyave.Router) {
	//...
}

基本路由

定义路由时,必须提供一个 goyave.Handler,这是一个具有以下签名的函数:

go
func(response *Response, request *Request)

通常,给定的处理器是一个具有控制器结构接收器的函数。在控制器页面和下面的控制器部分中了解更多信息。

支持所有 HTTP 方法,并且为最常用的方法提供了快捷方式:

go
func Register(_ *goyave.Server, router *goyave.Router) {
	router.Get("/get", handler)
	router.Post("/post", handler)
	router.Put("/put", handler)
	router.Patch("/patch", handler)
	router.Delete("/delete", handler)
	router.Options("/options", handler)
}

如果您希望单个路由匹配多个方法,请使用 router.Route()

go
func Register(_ *goyave.Server, router *goyave.Router) {
	router.Route([]string{http.MethodGet, http.MethodPost}, "/path", handler)
}

匹配算法

Goyave 路由器使用树状结构,由应用程序开发人员使用子路由器定义。

  • 首先检查当前路由器是否有部分匹配。部分匹配是指请求的 URI 以路由器的前缀开头。
  • 如果路由器匹配,则按注册顺序检查其子路由器。其中第一个匹配的子路由器将使用递归进行检查。当探索一个分支时,没有回头路,意味着在部分匹配的子路由器之后定义的所有子路由器将不会被检查。在子路由器之后检查的路由在这种情况下也不会被检查。
  • 如果没有子路由器匹配,则按注册顺序检查与当前路由器关联的路由。
  • 如果路由匹配但方法不对应请求,过程不会停止,会检查当前路由器的其他路由。
  • 不接受尾部斜杠。例如,如果路由的路径是 /categories,URI /categories/ 将不匹配。
  • 路由组(具有空前缀的子路由器)如果匹配到方法不对应的路由,不会停止过程:它们不被视为树结构中的分支。

TIP

当没有路由匹配时,可以返回两个特殊路由:"Not found" 和 "Method not allowed" 路由。

这两个路由被命名,以便可以从全局中间件轻松识别:

  • Not found 命名为 goyave.RouteNotFound = "goyave.not-found"
  • Method not allowed 命名为 goyave.RouteMethodNotAllowed = "goyave.method-not-allowed"

处理 HEAD

使用 HEAD 方法请求路由的客户端期望仅返回响应标头,这些标头与使用 GET 方法的相同路由返回的标头相同。

如果未在路由定义中明确指定,HEAD 方法会自动添加到所有匹配 GET 方法的路由。当路由与 HEAD 方法匹配时,它会照常执行,但响应体会被丢弃。这意味着数据库查询和其他操作仍会执行。

在特定场景中,您可能希望专门为 HEAD 方法添加路由定义,以防止执行昂贵的操作。在相应的 GET 路由之前注册它,以便首先匹配。请记住返回的标头应与 GET 处理器返回的标头相同。

go
func Register(_ *goyave.Server, router *goyave.Router) {
	router.Route([]string{http.MethodHead}, "/expensive", func(response *goyave.Response, _ *goyave.Request) {
		response.Header().Set("Content-Type", "application/json; charset=utf-8")
		response.Status(http.StatusOK)
	})
	router.Get("/expensive", handler)
}

闭包

虽然不推荐,但可以使用闭包定义路由。这是定义路由的一种非常简单的方式,可用于脚手架或快速测试。

go
func Register(server *goyave.Server, router *goyave.Router) {
	router.Get("/closure", func(response *goyave.Response, request *goyave.Request) {
		response.String(http.StatusOK, "Hi!")
	})
}

路由参数

URI 可以具有参数,使用格式 {name}{name:pattern} 定义。如果未定义正则表达式模式,匹配的变量将是直到下一个斜杠的任何内容。

示例:

go
router.Get("/product/{key}", showProduct)
router.Get("/product/{id:[0-9]+}", showProductById)
router.Get("/category/{category}/{id:[0-9]+}", showCategory)

可以在模式内使用正则组,只要它们是非捕获组((?:re))。例如:

go
router.Get("/category/{category}/{sort:(?:asc|desc|new)}", showCategorySorted)

路由参数可以在处理器中使用请求的 RouteParams 字段作为 map[string]string 检索。

go
router.Get("/product/{key}", func(response *goyave.Response, request *goyave.Request) {
	key := request.RouteParams["key"]
	//...
})
router.Get("/category/{category}/{id:[0-9]+}", func(response *goyave.Response, request *goyave.Request) {
	id, err := strconv.ParseInt(request.RouteParams["id"], 10, 64)
	if err != nil {
		response.Status(http.StatusNotFound)
		return
	}
	//...
})

WARNING

如果您期望路由参数是数字,并将其作为数字使用(例如在 WHERE id = ? SQL 语句中),您应始终在之前解析它,而不是直接将其作为 string 使用。字符串可以保存数字表示,这些数字可能太大,无法适应任何原生 Go 数字类型(也不适合您的数据库引擎类型)。

为避免在这种错误的用户输入情况下出现数据库错误。解析并在失败时返回 404 Not Found 状态错误,如上例所示。

子路由器

您可以使用子路由器在路由器树中按 URI 段创建分支。除了提高匹配性能外,这还有助于您更好地组织代码,在应用程序的特定部分应用中间件和元数据,并以最细的粒度管理路由相关设置。

通常,为应用程序中的每个资源创建一个子路由器:

go
users := router.Subrouter("/users")
// 注册用户相关路由

articles := router.Subrouter("/articles")
// 注册文章相关路由

INFO

在这种情况下,以 /users 开头的传入请求将引导路由器进入 users 分支,而 articles 分支将永远不会被检查匹配。

您的应用程序越大,将路由拆分为多个分支的性能好处就越大。良好的结构将使路由器需要检查的路由尽可能少。

子路由器在路由之前检查,意味着它们比后者具有优先级。如果您有一个与更高级别路由共享前缀的路由器,它将永远不会匹配,因为子路由器会首先匹配。

go
subrouter := router.Subrouter("/product")
subrouter.Get("/{id:[0-9]+}", handler)

router.Get("/product/{id:[0-9]+}", handler) // 此路由将永远不会匹配
router.Get("/product/category", handler)    // 此路由也不会

组是具有空前缀的子路由器。它不被视为路由器树结构中的分支,因此如果其路由和子路由器都不匹配,或者路由匹配但方法不正确("Method not allowed"),它不会停止匹配过程。

您可以使用组来避免在许多路由上单独应用中间件或元数据。例如,资源可能公开可读,但需要认证才能更新。在这种情况下,您可以为需要认证的路由创建一个组:

go
router.Middleware(auth.ConfigBasicAuth())
resource := router.Subrouter("/resource")
resource.Get("/{id:[0-9]+}", func(response *goyave.Response, request *goyave.Request) {
	// 用户未认证
	//...
})

resourceAuth := resource.Group().SetMeta(auth.MetaAuth, true)
resourceAuth.Patch("/{id:[0-9]+}", func(response *goyave.Response, request *goyave.Request) {
	// 用户已认证
	//...
})

控制器

如果控制器实现了 goyave.Registrer 接口,它们可以自己注册路由:

go
type Controller struct {
	goyave.Component
}

func (ctrl *Controller) RegisterRoutes(router *goyave.Router) {
	subrouter := router.Subrouter("/users")

	subrouter.Get("/{userID:[0-9+]}", ctrl.Show)
}

实现此接口的控制器然后可以在 router.Controller() 中使用:

go
func Register(_ *goyave.Server, router *goyave.Router) {
	router.Controller(&user.Controller{})
}

这将初始化组件,然后调用 RegisterRoutes。因此,可以从 RegisterRoutes 内部访问所有服务器资源。这样,与资源相关的路由位于与该资源的处理器相同的位置。

TIP

专用部分中了解更多关于控制器的信息。

命名路由

可以为路由命名,以便以后更容易检索它们并构建动态 URL。路由名称必须唯一。

go
router.Get("/product/{id:[0-9]+}", handler).Name("product.show")

然后您可以从请求中检索路由名称:

go
request.Route.GetName()

最后,您可以使用 GetRoute() 从任何路由器检索命名路由。存储路由名称的映射存储在主路由器中,并且对其所有子路由器是全局的。

go
router := request.Route.GetParent()
route := router.GetRoute("product.show")

WARNING

如果路由是特殊路由("Not found" 和 "Method Not Allowed"),route.GetParent() 可能返回 nil。在开发全局中间件时请记住这一点。

元数据

每个路由和路由器都持有一个 Meta map[string]any,可用于存储有关它的附加信息。此信息通常由中间件使用。

例如,内置认证中间件在开始认证过程之前检查路由是否具有(或继承)元 auth.MetaAuth。这样,路由(或组)可以单独标记为需要认证或不需要,只需一次认证中间件注册。

定义元数据

您可以使用 SetMeta() 在路由和路由器上定义元数据:

go
router.SetMeta("key", "value")
router.Get("/hello", handler).SetMeta("key", "value")

// 或者,直接使用映射
router.Meta["key"] = "value"

TIP

  • 使用方法而不是直接操作映射允许链式调用
  • 建议使用命名空间常量作为键名,以便更容易检索,减少错误(拼写错误)的风险,以及模块之间冲突的风险。
go
const MetaCustom = "myapp.custom"

也可以移除键。这不会从父路由器中移除使用相同键的元数据。因此,如果父路由器具有已移除键的元数据,当前路由/路由器现在将从该父路由器继承它。

go
router.RemoveMeta("key")
// 或
delete(router.Meta, "key")

访问元数据

从任何处理器中,您可以从请求的路由访问元数据:

go
request.Route.LookupMeta("key")
value, ok := request.Route.Meta["key"]

DANGER

虽然可以从处理器直接访问元数据映射,但您应永远不要修改它,因为这不是并发安全的操作。

一旦您离开路由注册步骤,请将元数据视为只读

继承

LookupMeta() 在当前路由/路由器中搜索值。如果找不到,则在父路由器中查找值,一直向上直到主路由器。这允许元数据继承

go
router.SetMeta("key", "value")
router.Get("/hello", func(response *goyave.Response, request *goyave.Request) {
	request.Route.LookupMeta("key") // "value"(来自路由器)
})

使用这种继承概念,可以细粒度地覆盖元数据。例如,如果您有一个需要认证的路由组,但该组中的单个路由不需要,则在该路由上专门覆盖元数据以禁用认证:

go
router.Middleware(auth.ConfigBasicAuth())
router.SetMeta(auth.MetaAuth, true)
router.Get("/authenticated", authHandler) // 此路由需要认证
router.Get("/hello", handler).SetMeta(auth.MetaAuth, false) // 此路由不需要认证

中间件

中间件是堆叠在控制器处理器之上的处理器,并按注册顺序一个一个执行。来自父路由器的中间件在當前路由器的中间件之前执行。

中间件栈图

TIP

专用部分中了解更多关于中间件的信息。

让我们为我们的示例使用一个简单的 Gzip 压缩中间件:

go
import (
	"compress/gzip"
	"goyave.dev/goyave/v5/middleware/compress"
)

//...

compressMiddleware := &compress.Middleware{
	Encoders: []compress.Encoder{
		&compress.Gzip{Level: gzip.BestCompression},
	},
}

在以下示例中,只有 /compressed 路由会有压缩,因为中间件专门应用在此路由上:

go
router.Get("/hello", handler)
router.Get("/compressed", handler).Middleware(compressMiddleware)

在以下示例中,两个路由都会有压缩,因为中间件应用在路由器上:

go
router.Middleware(compressMiddleware)
router.Get("/hello", handler)
router.Get("/compressed", handler)

全局中间件

全局中间件就像常规中间件,但仅存储在主路由器内部,并为每个请求执行,即使匹配的路由是 "Not found" 或 "Method not allowed"。所有子路由器共享相同的全局中间件切片。

如上一节栈图所示,全局中间件首先执行,按注册顺序

全局中间件的典型用例是解析、访问日志记录或速率限制。如果您不全局使用日志记录中间件,不匹配路由的请求将不会被记录,因为常规中间件仅在匹配非特殊路由时执行。

go
import "goyave.dev/goyave/v5/middleware/parse"

// 所有请求的查询和正文将被解析
router.GlobalMiddleware(&parse.Middleware{})

验证

您可以为路由定义验证规则,用于客户端在查询和请求正文中发送的数据。

当调用 ValidateBody()ValidateQuery() 时,如果此路由上尚未存在内置验证中间件,则会自动添加。在路由上应用中间件时请记住这一点,以便它们按您希望的顺序执行。通常,建议最后验证

go
router.Get("/", ctrl.Index).ValidateQuery(IndexRequest)
router.Post("/", ctrl.Create).ValidateBody(CreateRequest)

ValidateBody()ValidateQuery() 接受 goyave.RuleSetFuncfunc(func(*goyave.Request) validation.RuleSet))作为参数。以下是规则集函数的示例:

go
import (
	"goyave.dev/goyave/v5"
	v "goyave.dev/goyave/v5/validation"
)

func IndexRequest(_ *goyave.Request) v.RuleSet {
	return v.RuleSet{
		{Path: "page", Rules: v.List{v.Int(), v.Min(1)}},
		{Path: "perPage", Rules: v.List{v.Int(), v.Between(1, 100)}},
	}
}

TIP

  • 您可以使用 *goyave.Request 参数根据请求的某些方面返回不同的规则集。
  • 规则集不适用于重用。规则集函数必须返回新初始化的非全局规则集。
  • 专用部分中了解更多关于验证的信息。

URL 生成

使用路由的 BuildURI()BuildURL()BuildProxyURL(),您可以生成到此路由的路径或完整 URL:

go
route := router.Get("/product/{id:[0-9]+}", handler)
route.BuildURI("42") // "/product/42"
route.BuildURL("42") // "http://localhost:8080/product/42"
route.BuildProxyURL("42") // "https://myproxydomain.example.com/product/42"

基础 URL

您可以使用 server.BaseURL() 生成应用程序的基础 URL:

go
server.BaseURL() // "http://localhost:8080"

此函数使用配置。如果设置了 server.domain,则将使用它而不是 server.host。如果服务器监听的端口等于 80,则不会添加到结果字符串中。

代理 URL

如果您在反向代理(如 nginx 或 apache)后面运行应用程序,您可能需要生成不直接指向您的应用程序,而是指向您的代理的 URL。为此使用 server.ProxyBaseURL()

如果端口匹配协议的标准端口(HTTP 为 80,HTTPS 为 443),则不会添加到结果字符串中。

使用以下配置的示例:

json
{
    "server": {
        ...
        "proxy": {
            "protocol": "https",
            "host": "myproxydomain.example.com",
            "port": 443,
            "base": "/basepath"
        }
    }
}
go
server.ProxyBaseURL() // "https://myproxydomain.example.com/basepath"
route.BuildProxyURL("42") // "https://myproxydomain.example.com/basepath/product/42"

TIP

在生成将返回给客户端的 URL 时,建议始终使用 ProxyBaseURL()BuildProxyURL()。如果未配置代理 URL,将返回常规基础 URL,因此无论您的应用程序部署在哪个环境中,使用都是安全的。

提供静态资源

您可以使用 router.Static() 轻松地从任何源提供静态资源:

go
import "goyave.dev/goyave/v5/util/fsutil/osfs"

router.Static(&osfs.FS{}, "/static", false)

任何实现 fs.StatFS 的文件系统(FS)都可以用作源:操作系统文件系统(osfs.FS)、embed、远程云存储桶文件系统等。

对于嵌入式文件系统,建议使用 Goyave 的 fsutil.Embed 包装器:

go
import (
	"embed"
	"goyave.dev/goyave/v5/util/fsutil"
)

//go:embed resources
var resources embed.FS

//...
fs := fsutil.NewEmbed(resources)
router.Static(fs, "/resources", false)

INFO

如果将第三个参数(download)设置为 true,响应将包含标头 Content-Disposition: attachment; filename="filename.txt" 而不是 Content-Disposition: inline。这导致大多数浏览器提示用户下载文件,而不是在新标签页中显示它。

内置的静态处理器使用智能路径:

  • 如果请求的路径为空,如果存在 index.html 文件,则将返回它。
  • 如果请求的路径是目录(带或不带尾部斜杠),如果存在 index.html 文件,则将返回它。
  • 如果找不到文件,则返回 404 Not Found
  • 支持目录和子目录。