mirror of
https://github.com/infinite-iroha/touka.git
synced 2026-02-03 00:41:10 +08:00
Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e278d458 | ||
|
|
7b536ac137 | ||
|
|
b348d7d41f | ||
|
|
60b2936eff | ||
|
|
9cfc82a347 | ||
|
|
904aea5df8 | ||
|
|
ee0ebc986c | ||
|
|
e4aaaa1583 | ||
|
|
1361f6e237 | ||
|
|
a6458cca16 | ||
|
|
76a89800a2 | ||
|
|
4955fb9d03 | ||
|
|
5b98310de5 | ||
|
|
f1ac0dd6ff | ||
|
|
38ff5126e3 | ||
|
|
b4e073ae2f | ||
|
|
af0a99acda | ||
|
|
3ffde5742c | ||
|
|
016df0efe4 | ||
|
|
3590a77f90 | ||
|
|
74f5770b42 | ||
|
|
0f4d90faeb | ||
|
|
783370fd79 | ||
|
|
295852e1a1 | ||
|
|
99b48371b3 | ||
|
|
e43b12e343 | ||
|
|
1e7682ad84 | ||
|
|
3cd8ef84a2 | ||
|
|
2c60e84067 | ||
|
|
895cd6222b | ||
|
|
c9b8e966c4 | ||
|
|
dee05b048e | ||
|
|
3e76566917 | ||
|
|
ccf25dee46 | ||
|
|
1f0724af94 |
12 changed files with 1403 additions and 411 deletions
186
README.md
186
README.md
|
|
@ -1,34 +1,10 @@
|
|||
# Touka(灯花)框架
|
||||
|
||||
Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化**
|
||||
Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化**。
|
||||
|
||||
## Touka 的设计特点
|
||||
**想深入了解 Touka 吗?请阅读我们的 -> [深度指南 (about-touka.md)](about-touka.md)**
|
||||
|
||||
Touka 在一些特定方面进行了细致的设计与实现,旨在提供便利的工具与更清晰的控制:
|
||||
|
||||
* **统一且可定制的错误处理**
|
||||
Touka 提供了灵活的错误处理机制,允许开发者通过 `Engine.SetErrorHandler` 设置统一的错误响应逻辑。此机制不仅适用于框架内部产生的错误,更特别之处在于它能够**捕获由 `http.FileServer` 等标准库处理器返回的 404 Not Found、403 Forbidden 等错误状态码**。
|
||||
* **设计考量:** 默认情况下,`http.FileServer` 在文件未找到或权限不足时会直接返回标准错误响应。Touka 的设计能够拦截这些由 `http.FileServer` 发出的错误信号,并将其转发给框架统一的 `ErrorHandler`。这使得开发者可以为文件服务中的异常情况提供**与应用其他部分风格一致的自定义错误响应**,从而提升整体的用户体验和错误管理效率。
|
||||
|
||||
* **客户端 IP 来源的透明解析**
|
||||
Touka 提供了可配置的客户端 IP 获取机制。开发者可以通过 `Engine.SetRemoteIPHeaders` 指定框架优先从哪些 HTTP 头部(如 `X-Forwarded-For`、`X-Real-IP`)解析客户端真实 IP,并通过 `Engine.SetForwardByClientIP` 控制此机制的启用。
|
||||
* **实现细节:** `Context.RequestIP()` 方法会根据这些配置,从 `http.Request.Header` 中解析并返回第一个有效的 IP 地址。如果未配置或头部中未找到有效 IP,则回退到 `http.Request.RemoteAddr`,并对 IP 格式进行验证。这有助于在存在多层代理的环境中获取准确的源 IP。
|
||||
|
||||
* **内置日志与出站 HTTP 客户端的 Context 绑定**
|
||||
Touka 的核心 `Context` 对象直接包含了对 `reco.Logger`(一个异步、结构化日志库)和 `httpc.Client`(一个功能增强的 HTTP 客户端)的引用。开发者可以直接通过 `c.GetLogger()` 和 `c.Client()` 在请求处理函数中访问这些工具。
|
||||
* **设计考量:** 这种集成方式旨在提供这些核心工具在**特定请求生命周期内的统一访问点**。所有日志记录和出站 HTTP 请求操作都与当前请求上下文绑定,并能利用框架层面的全局配置,有助于简化复杂请求处理场景下的代码组织。
|
||||
|
||||
* **强健的 Panic 恢复与连接状态感知**
|
||||
Touka 提供的 `Recovery` 中间件能够捕获处理链中的 `panic`。它会记录详细的堆栈信息和请求快照。此外,它能**识别由客户端意外断开连接**引起的网络错误(如 `broken pipe` 或 `connection reset by peer`),在这些情况下,框架会避免尝试向已失效的连接写入响应。
|
||||
* **设计考量:** 这有助于防止因底层网络问题或客户端行为导致的二次 `panic`,避免在关闭的连接上进行无效写入,从而提升服务的稳定性。
|
||||
|
||||
* **HTTP 协议版本与服务器行为的细致控制**
|
||||
Touka 允许开发者通过 `Engine.SetProtocols` 方法,精确定义服务器支持的 HTTP 协议版本(HTTP/1.1、HTTP/2、H2C)。框架也提供了对重定向行为、未匹配路由处理和文件服务行为的配置选项。
|
||||
* **设计考量:** 这种协议和行为的细致化控制,为开发者提供了在特定部署环境(如 gRPC-Web 对 HTTP/2 的要求)中对服务器通信栈进行调整的能力。
|
||||
|
||||
* **Context 对象的高效复用**
|
||||
Touka 对其核心 `Context` 对象进行了池化管理。每个请求处理结束后,`Context` 对象会被重置并返回到对象池中,以便后续请求复用。
|
||||
* **设计考量:** 这种机制旨在减少每次请求的内存分配和垃圾回收(GC)压力,尤其在高并发场景下,有助于提供更平滑和可预测的性能表现。
|
||||
这份深度指南包含了对框架设计哲学、核心功能(路由、上下文、中间件、错误处理等)的全面剖析,并提供了大量可直接使用的代码示例,帮助您快速上手并精通 Touka。
|
||||
|
||||
### 快速上手
|
||||
|
||||
|
|
@ -48,166 +24,68 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
r := touka.New()
|
||||
r := touka.Default() // 使用带 Recovery 中间件的默认引擎
|
||||
|
||||
// 配置日志记录器 (可选,不设置则使用默认配置)
|
||||
// 配置日志记录器 (可选)
|
||||
logConfig := reco.Config{
|
||||
Level: reco.LevelDebug,
|
||||
Mode: reco.ModeText, // 或 reco.ModeJSON
|
||||
Mode: reco.ModeText,
|
||||
Output: os.Stdout,
|
||||
Async: true,
|
||||
BufferSize: 4096,
|
||||
}
|
||||
r.SetLogger(logConfig)
|
||||
r.SetLoggerCfg(logConfig)
|
||||
|
||||
// 配置统一错误处理器
|
||||
// Touka 允许您为 404, 500 等错误定义统一的响应。
|
||||
// 特别地,它能捕获 http.FileServer 产生的 404/403 错误并统一处理。
|
||||
r.SetErrorHandler(func(c *touka.Context, code int) {
|
||||
// 这里可以根据 code 返回 JSON, HTML, 或其他自定义错误页面
|
||||
r.SetErrorHandler(func(c *touka.Context, code int, err error) {
|
||||
c.JSON(code, touka.H{"error_code": code, "message": http.StatusText(code)})
|
||||
c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s", code, c.Request.URL.Path) // 记录错误
|
||||
c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s, 错误: %v", code, c.Request.URL.Path, err)
|
||||
})
|
||||
|
||||
// 注册基本路由
|
||||
r.GET("/hello", func(c *touka.Context) {
|
||||
// 设置响应头部
|
||||
c.SetHeader("X-Framework", "Touka") // 设置一个头部
|
||||
c.AddHeader("X-Custom-Info", "Hello") // 添加一个头部 (如果已有则追加)
|
||||
c.AddHeader("X-Custom-Info", "World") // 再次添加,Content-Type: X-Custom-Info: Hello, World
|
||||
|
||||
// 获取请求头部
|
||||
acceptEncoding := c.GetReqHeader("Accept-Encoding")
|
||||
userAgent := c.UserAgent() // 便捷获取 User-Agent
|
||||
|
||||
c.String(http.StatusOK, "Hello from Touka! Your Accept-Encoding: %s, User-Agent: %s", acceptEncoding, userAgent)
|
||||
c.GetLogger().Infof("请求 /hello 来自 IP: %s", c.ClientIP())
|
||||
// 注册路由
|
||||
r.GET("/hello/:name", func(c *touka.Context) {
|
||||
name := c.Param("name")
|
||||
query := c.DefaultQuery("mood", "happy")
|
||||
c.String(http.StatusOK, "Hello, %s! You seem %s.", name, query)
|
||||
})
|
||||
|
||||
r.GET("/json", func(c *touka.Context) {
|
||||
// 删除响应头部
|
||||
c.DelHeader("X-Powered-By") // 假设有这个头部,可以删除它
|
||||
c.JSON(http.StatusOK, touka.H{"message": "Welcome to Touka", "timestamp": time.Now()})
|
||||
})
|
||||
|
||||
// 注册包含路径参数的路由
|
||||
r.GET("/user/:id", func(c *touka.Context) {
|
||||
userID := c.Param("id") // 获取路径参数
|
||||
c.String(http.StatusOK, "User ID: %s", userID)
|
||||
})
|
||||
|
||||
// 注册使用查询参数的路由
|
||||
r.GET("/search", func(c *touka.Context) {
|
||||
query := c.DefaultQuery("q", "default_query") // 获取查询参数,提供默认值
|
||||
paramB := c.Query("paramB") // 获取另一个查询参数
|
||||
c.String(http.StatusOK, "Search query: %s, Param B: %s", query, paramB)
|
||||
})
|
||||
|
||||
// 注册处理 POST 表单的路由
|
||||
r.POST("/submit-form", func(c *touka.Context) {
|
||||
name := c.PostForm("name") // 获取表单字段值
|
||||
email := c.DefaultPostForm("email", "no_email@example.com") // 获取表单字段,提供默认值
|
||||
c.String(http.StatusOK, "Form submitted: Name=%s, Email=%s", name, email)
|
||||
})
|
||||
|
||||
// 演示 Set 和 Get 方法在中间件中传递数据
|
||||
// 在中间件中 Set 数据
|
||||
r.Use(func(c *touka.Context) {
|
||||
c.Set("requestID", "req-12345") // 设置一个数据
|
||||
c.Next()
|
||||
})
|
||||
// 在路由处理函数中 Get 数据
|
||||
r.GET("/context-data", func(c *touka.Context) {
|
||||
requestID, exists := c.Get("requestID") // 获取数据
|
||||
if !exists {
|
||||
requestID = "N/A"
|
||||
}
|
||||
c.String(http.StatusOK, "Request ID from Context: %s", requestID)
|
||||
})
|
||||
|
||||
// 服务静态文件
|
||||
// 使用 r.Static 方法,其错误(如 404)将由上面设置的 ErrorHandler 统一处理
|
||||
// 假设您的静态文件在项目根目录的 'static' 文件夹
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// 演示出站 HTTP 请求 (使用 Context 中绑定的 httpc.Client)
|
||||
r.GET("/fetch-example", func(c *touka.Context) {
|
||||
resp, err := c.Client().Get("https://example.com", httpc.WithTimeout(5*time.Second))
|
||||
if err != nil {
|
||||
c.Errorf("出站请求失败: %v", err) // 记录错误
|
||||
c.String(http.StatusInternalServerError, "Failed to fetch external resource")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
c.String(http.StatusOK, "Fetched from example.com (first 100 bytes): %s...", bodyBytes[:min(len(bodyBytes), 100)])
|
||||
})
|
||||
|
||||
// 演示 HTTP 协议控制
|
||||
// 默认已启用 HTTP/1.1。如果需要 HTTP/2,通常需在 TLS 模式下启用。
|
||||
// r.SetProtocols(&touka.ProtocolsConfig{
|
||||
// Http1: true,
|
||||
// Http2: true, // 启用 HTTP/2 (需要 HTTPS)
|
||||
// Http2_Cleartext: false,
|
||||
// })
|
||||
|
||||
// 启动服务器 (支持优雅关闭)
|
||||
log.Println("Touka Server starting on :8080...")
|
||||
err := r.RunShutdown(":8080", 10*time.Second) // 优雅关闭超时10秒
|
||||
if err != nil {
|
||||
if err := r.RunShutdown(":8080", 10*time.Second); err != nil {
|
||||
log.Fatalf("Touka server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
```
|
||||
|
||||
## 中间件支持
|
||||
|
||||
### 内置
|
||||
|
||||
Recovery `r.Use(touka.Recovery())`
|
||||
- **Recovery:** `r.Use(touka.Recovery())` (已包含在 `touka.Default()` 中)
|
||||
|
||||
### fenthope
|
||||
### 第三方 (fenthope)
|
||||
|
||||
[访问日志-record](https://github.com/fenthope/record)
|
||||
|
||||
[Gzip](https://github.com/fenthope/gzip)
|
||||
|
||||
[压缩-Compress(Deflate,Gzip,Zstd)](https://github.com/fenthope/compress)
|
||||
|
||||
[请求速率限制-ikumi](https://github.com/fenthope/ikumi)
|
||||
|
||||
[sessions](https://github.com/fenthope/sessions)
|
||||
|
||||
[jwt](https://github.com/fenthope/jwt)
|
||||
|
||||
[带宽限制](https://github.com/fenthope/toukautil/blob/main/bandwithlimiter.go)
|
||||
- [访问日志-record](https://github.com/fenthope/record)
|
||||
- [Gzip](https://github.com/fenthope/gzip)
|
||||
- [压缩-Compress(Deflate,Gzip,Zstd)](https://github.com/fenthope/compress)
|
||||
- [请求速率限制-ikumi](https://github.com/fenthope/ikumi)
|
||||
- [sessions](https://github.com/fenthope/sessions)
|
||||
- [jwt](https://github.com/fenthope/jwt)
|
||||
- [带宽限制](https://github.com/fenthope/toukautil/blob/main/bandwithlimiter.go)
|
||||
|
||||
## 文档与贡献
|
||||
|
||||
* **API 文档:** 访问 [pkg.go.dev/github.com/infinite-iroha/touka](https://pkg.go.dev/github.com/infinite-iroha/touka) 查看完整的 API 参考
|
||||
* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南
|
||||
|
||||
* [](https://deepwiki.com/infinite-iroha/touka) 可供参考, AI生成存在幻觉, 不完全可靠, 请注意辨别
|
||||
* **深度指南:** **[about-touka.md](about-touka.md)**
|
||||
* **API 文档:** 访问 [pkg.go.dev/github.com/infinite-iroha/touka](https://pkg.go.dev/github.com/infinite-iroha/touka) 查看完整的 API 参考。
|
||||
* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南。
|
||||
|
||||
## 相关项目
|
||||
|
||||
[gin](https://github.com/gin-gonic/gin) 参考并引用了相关部分代码
|
||||
|
||||
[reco](https://github.com/fenthope/reco) 灯花框架的默认日志库
|
||||
|
||||
[httpc](https://github.com/WJQSERVER-STUDIO/httpc) 原[touka-httpc](https://github.com/satomitouka/touka-httpc), 一个现代化且易用的HTTP Client, 作为Touka框架Context携带的HTTPC
|
||||
- [gin](https://github.com/gin-gonic/gin): Touka 在路由和 API 设计上参考了 Gin。
|
||||
- [reco](https://github.com/fenthope/reco): Touka 框架的默认日志库。
|
||||
- [httpc](https://github.com/WJQSERVER-STUDIO/httpc): 一个现代化且易用的 HTTP Client,作为 Touka 框架 Context 携带的 HTTPC。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目使用MPL许可证
|
||||
本项目基于 [Mozilla Public License, v. 2.0](https://mozilla.org/MPL/2.0/) 许可。
|
||||
|
||||
tree部分来自[gin](https://github.com/gin-gonic/gin)与[httprouter](https://github.com/julienschmidt/httprouter)
|
||||
|
||||
[WJQSERVER/httproute](https://github.com/WJQSERVER/httprouter)是本项目的前身(一个[httprouter](https://github.com/julienschmidt/httprouter)的fork版本)
|
||||
`tree.go` 部分代码源自 [gin](https://github.com/gin-gonic/gin) 与 [httprouter](https://github.com/julienschmidt/httprouter),其原始许可为 BSD-style。
|
||||
|
|
|
|||
577
about-touka.md
Normal file
577
about-touka.md
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
# 关于 Touka (灯花) 框架:一份深度指南
|
||||
|
||||
Touka (灯花) 是一个基于 Go 语言构建的、功能丰富且高性能的 Web 框架。它的核心设计目标是为开发者提供一个既强大又灵活的工具集,允许对框架行为进行深度定制,同时通过精心设计的组件和机制,优化在真实业务场景中的开发体验和运行性能。
|
||||
|
||||
本文档旨在提供一份全面而深入的指南,帮助您理解 Touka 的核心概念、设计哲学以及如何利用其特性来构建健壮、高效的 Web 应用。
|
||||
|
||||
---
|
||||
|
||||
## 核心设计哲学
|
||||
|
||||
Touka 的设计哲学根植于以下几个核心原则:
|
||||
|
||||
* **控制力与可扩展性:** 框架在提供强大默认功能的同时,也赋予开发者充分的控制权。我们相信开发者最了解自己的业务需求。因此,无论是路由行为、错误处理逻辑,还是服务器协议,都可以根据具体需求进行精细调整和扩展。
|
||||
* **明确性与可预测性:** API 设计力求直观和一致,使得框架的行为易于理解和预测,减少开发过程中的意外。我们避免使用过多的“魔法”,倾向于让代码的意图清晰可见。
|
||||
* **性能意识:** 在核心组件的设计中,性能是一个至关重要的考量因素。通过采用如对象池、优化的路由算法等技术,Touka 致力于在高并发场景下保持低延迟和高吞吐。
|
||||
* **开发者体验:** 框架内置了丰富的辅助工具和便捷的 API,例如与请求上下文绑定的日志记录器和 HTTP 客户端,旨在简化常见任务,提升开发效率。
|
||||
|
||||
---
|
||||
|
||||
## 核心功能深度剖析
|
||||
|
||||
### 1. 引擎 (Engine):框架的中央枢纽
|
||||
|
||||
`Engine` 是 Touka 框架的实例,也是所有功能的入口和协调者。它实现了 `http.Handler` 接口,可以无缝集成到 Go 的标准 HTTP 生态中。
|
||||
|
||||
#### 1.1. 初始化引擎
|
||||
|
||||
```go
|
||||
// 创建一个“干净”的引擎,不包含任何默认中间件
|
||||
r := touka.New()
|
||||
|
||||
// 创建一个带有默认中间件的引擎,目前仅包含 Recovery()
|
||||
// 推荐在生产环境中使用,以防止 panic 导致整个服务崩溃
|
||||
r := touka.Default()
|
||||
```
|
||||
|
||||
#### 1.2. 引擎配置
|
||||
|
||||
`Engine` 提供了丰富的配置选项,允许您定制其核心行为。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := touka.New()
|
||||
|
||||
// === 路由行为配置 ===
|
||||
|
||||
// 自动重定向尾部带斜杠的路径,默认为 true
|
||||
// e.g., /foo/ 会被重定向到 /foo
|
||||
r.SetRedirectTrailingSlash(true)
|
||||
|
||||
// 自动修复路径的大小写,默认为 true
|
||||
// e.g., /FOO 会被重定向到 /foo (如果 /foo 存在)
|
||||
r.SetRedirectFixedPath(true)
|
||||
|
||||
// 当路由存在但方法不匹配时,自动处理 405 Method Not Allowed,默认为 true
|
||||
r.SetHandleMethodNotAllowed(true)
|
||||
|
||||
// === IP 地址解析配置 ===
|
||||
|
||||
// 是否信任 X-Forwarded-For, X-Real-IP 等头部来获取客户端 IP,默认为 true
|
||||
// 在反向代理环境下非常有用
|
||||
r.SetForwardByClientIP(true)
|
||||
// 自定义用于解析 IP 的头部列表,按顺序查找
|
||||
r.SetRemoteIPHeaders([]string{"X-Forwarded-For", "X-App-Client-IP", "X-Real-IP"})
|
||||
|
||||
// === 请求体大小限制 ===
|
||||
|
||||
// 设置全局默认的请求体最大字节数,-1 表示不限制
|
||||
// 这有助于防止 DoS 攻击
|
||||
r.SetGlobalMaxRequestBodySize(10 * 1024 * 1024) // 10 MB
|
||||
|
||||
// ... 其他配置
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3. 服务器生命周期管理
|
||||
|
||||
Touka 提供了对底层 `*http.Server` 的完全控制,并内置了优雅关闭的逻辑。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := touka.New()
|
||||
|
||||
// 通过 ServerConfigurator 对 http.Server 进行自定义配置
|
||||
r.SetServerConfigurator(func(server *http.Server) {
|
||||
// 设置自定义的读写超时时间
|
||||
server.ReadTimeout = 15 * time.Second
|
||||
server.WriteTimeout = 15 * time.Second
|
||||
fmt.Println("自定义的 HTTP 服务器配置已应用")
|
||||
})
|
||||
|
||||
// 启动服务器,并支持优雅关闭
|
||||
// RunShutdown 会阻塞,直到收到 SIGINT 或 SIGTERM 信号
|
||||
// 第二个参数是优雅关闭的超时时间
|
||||
fmt.Println("服务器启动于 :8080")
|
||||
if err := r.RunShutdown(":8080", 10*time.Second); err != nil {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 路由系统 (Routing):强大、灵活、高效
|
||||
|
||||
Touka 的路由系统基于一个经过优化的**基数树 (Radix Tree)**,它支持静态路径、路径参数和通配符,并能实现极高的查找性能。
|
||||
|
||||
#### 2.1. 基本路由
|
||||
|
||||
```go
|
||||
// 精确匹配的静态路由
|
||||
r.GET("/ping", func(c *touka.Context) {
|
||||
c.String(http.StatusOK, "pong")
|
||||
})
|
||||
|
||||
// 注册多个 HTTP 方法
|
||||
r.HandleFunc([]string{"GET", "POST"}, "/data", func(c *touka.Context) {
|
||||
c.String(http.StatusOK, "Data received via %s", c.Request.Method)
|
||||
})
|
||||
|
||||
// 注册所有常见 HTTP 方法
|
||||
r.ANY("/any", func(c *touka.Context) {
|
||||
c.String(http.StatusOK, "Handled with ANY for method %s", c.Request.Method)
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.2. 参数化路由
|
||||
|
||||
使用冒号 `:` 来定义路径参数。
|
||||
|
||||
```go
|
||||
r.GET("/users/:id", func(c *touka.Context) {
|
||||
// 通过 c.Param() 获取路径参数
|
||||
userID := c.Param("id")
|
||||
c.String(http.StatusOK, "获取用户 ID: %s", userID)
|
||||
})
|
||||
|
||||
r.GET("/articles/:category/:article_id", func(c *touka.Context) {
|
||||
category := c.Param("category")
|
||||
articleID := c.Param("article_id")
|
||||
c.JSON(http.StatusOK, touka.H{
|
||||
"category": category,
|
||||
"id": articleID,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.3. 通配符路由 (Catch-all)
|
||||
|
||||
使用星号 `*` 来定义通配符路由,它会捕获该点之后的所有路径段。**通配符路由必须位于路径的末尾**。
|
||||
|
||||
```go
|
||||
// 匹配如 /static/js/main.js, /static/css/style.css 等
|
||||
r.GET("/static/*filepath", func(c *touka.Context) {
|
||||
// 捕获的路径可以通过参数名 "filepath" 获取
|
||||
filePath := c.Param("filepath")
|
||||
c.String(http.StatusOK, "请求的文件路径是: %s", filePath)
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.4. 路由组 (RouterGroup)
|
||||
|
||||
路由组是组织和管理路由的强大工具,特别适用于构建结构化的 API。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := touka.New()
|
||||
|
||||
// 所有 /api/v1 下的路由都需要经过 AuthMiddleware
|
||||
v1 := r.Group("/api/v1")
|
||||
v1.Use(AuthMiddleware()) // 应用组级别的中间件
|
||||
{
|
||||
// 匹配 /api/v1/products
|
||||
v1.GET("/products", getProducts)
|
||||
// 匹配 /api/v1/products/:id
|
||||
v1.GET("/products/:id", getProductByID)
|
||||
|
||||
// 可以在组内再嵌套组
|
||||
ordersGroup := v1.Group("/orders")
|
||||
ordersGroup.Use(OrderPermissionsMiddleware()) // 更具体的中间件
|
||||
{
|
||||
// 匹配 /api/v1/orders
|
||||
ordersGroup.GET("", getOrders)
|
||||
// 匹配 /api/v1/orders/:id
|
||||
ordersGroup.GET("/:id", getOrderByID)
|
||||
}
|
||||
}
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
|
||||
func AuthMiddleware() touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
// 模拟认证逻辑
|
||||
fmt.Println("V1 Auth Middleware: Checking credentials...")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
// ... 其他处理器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 上下文 (Context):请求的灵魂
|
||||
|
||||
`touka.Context` 是框架中最为核心的结构,它作为每个 HTTP 请求的上下文,在中间件和最终处理器之间流转。它提供了海量的便捷 API 来简化开发。
|
||||
|
||||
#### 3.1. 请求数据解析
|
||||
|
||||
##### 获取查询参数
|
||||
|
||||
```go
|
||||
// 请求 URL: /search?q=touka&lang=go&page=1
|
||||
r.GET("/search", func(c *touka.Context) {
|
||||
// c.Query() 获取指定参数,不存在则返回空字符串
|
||||
query := c.Query("q") // "touka"
|
||||
|
||||
// c.DefaultQuery() 获取参数,不存在则返回指定的默认值
|
||||
lang := c.DefaultQuery("lang", "en") // "go"
|
||||
category := c.DefaultQuery("cat", "all") // "all"
|
||||
|
||||
c.JSON(http.StatusOK, touka.H{
|
||||
"query": query,
|
||||
"language": lang,
|
||||
"category": category,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
##### 获取 POST 表单数据
|
||||
|
||||
```go
|
||||
// 使用 curl 测试:
|
||||
// curl -X POST http://localhost:8080/register -d "username=test&email=test@example.com"
|
||||
r.POST("/register", func(c *touka.Context) {
|
||||
username := c.PostForm("username")
|
||||
email := c.DefaultPostForm("email", "anonymous@example.com")
|
||||
// 也可以获取所有表单数据
|
||||
// form, _ := c.Request.MultipartForm()
|
||||
|
||||
c.String(http.StatusOK, "注册成功: 用户名=%s, 邮箱=%s", username, email)
|
||||
})
|
||||
```
|
||||
|
||||
##### JSON 数据绑定
|
||||
|
||||
Touka 可以轻松地将请求体中的 JSON 数据绑定到 Go 结构体。
|
||||
|
||||
```go
|
||||
type UserProfile struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Age int `json:"age" binding:"gte=18"`
|
||||
Tags []string `json:"tags"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// 使用 curl 测试:
|
||||
// curl -X POST http://localhost:8080/profile -H "Content-Type: application/json" -d '''
|
||||
// {
|
||||
// "name": "Alice",
|
||||
// "age": 25,
|
||||
// "tags": ["go", "web"]
|
||||
// }
|
||||
// '''
|
||||
r.POST("/profile", func(c *touka.Context) {
|
||||
var profile UserProfile
|
||||
|
||||
// c.ShouldBindJSON() 会解析 JSON 并填充到结构体中
|
||||
if err := c.ShouldBindJSON(&profile); err != nil {
|
||||
// 如果 JSON 格式错误或不满足绑定标签,会返回错误
|
||||
c.JSON(http.StatusBadRequest, touka.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, touka.H{
|
||||
"status": "success",
|
||||
"profile": profile,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.2. 响应构建
|
||||
|
||||
##### 发送 JSON, String, Text
|
||||
|
||||
```go
|
||||
r.GET("/responses", func(c *touka.Context) {
|
||||
// c.JSON(http.StatusOK, touka.H{"framework": "Touka"})
|
||||
// c.String(http.StatusOK, "Hello, %s", "World")
|
||||
c.Text(http.StatusOK, "This is plain text.")
|
||||
})
|
||||
```
|
||||
|
||||
##### 渲染 HTML 模板
|
||||
|
||||
首先,需要为引擎配置一个模板渲染器。
|
||||
|
||||
```go
|
||||
// main.go
|
||||
import "html/template"
|
||||
|
||||
func main() {
|
||||
r := touka.New()
|
||||
// 加载模板文件
|
||||
r.HTMLRender = template.Must(template.ParseGlob("templates/*.html"))
|
||||
|
||||
r.GET("/index", func(c *touka.Context) {
|
||||
// 渲染 index.html 模板,并传入数据
|
||||
c.HTML(http.StatusOK, "index.html", touka.H{
|
||||
"title": "Touka 模板渲染",
|
||||
"user": "Guest",
|
||||
})
|
||||
})
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
|
||||
// templates/index.html
|
||||
// <h1>{{ .title }}</h1>
|
||||
// <p>Welcome, {{ .user }}!</p>
|
||||
```
|
||||
|
||||
##### 文件和流式响应
|
||||
|
||||
```go
|
||||
// 直接发送一个文件
|
||||
r.GET("/download/report", func(c *touka.Context) {
|
||||
// 浏览器会提示下载
|
||||
c.File("./reports/latest.pdf")
|
||||
})
|
||||
|
||||
// 将文件内容作为响应体
|
||||
r.GET("/show/config", func(c *touka.Context) {
|
||||
// 浏览器会直接显示文件内容(如果支持)
|
||||
c.SetRespBodyFile(http.StatusOK, "./config.yaml")
|
||||
})
|
||||
|
||||
// 流式响应,适用于大文件或实时数据
|
||||
r.GET("/stream", func(c *touka.Context) {
|
||||
// 假设 getRealTimeDataStream() 返回一个 io.Reader
|
||||
// dataStream := getRealTimeDataStream()
|
||||
// c.WriteStream(dataStream)
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.3. Cookie 操作
|
||||
|
||||
Touka 提供了简单的 API 来管理 Cookie。
|
||||
|
||||
```go
|
||||
r.GET("/login", func(c *touka.Context) {
|
||||
// 设置一个有效期为 1 小时的 cookie
|
||||
c.SetCookie("session_id", "user-12345", 3600, "/", "localhost", false, true)
|
||||
c.String(http.StatusOK, "登录成功!")
|
||||
})
|
||||
|
||||
r.GET("/me", func(c *touka.Context) {
|
||||
sessionID, err := c.GetCookie("session_id")
|
||||
if err != nil {
|
||||
c.String(http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "您的会话 ID 是: %s", sessionID)
|
||||
})
|
||||
|
||||
r.GET("/logout", func(c *touka.Context) {
|
||||
// 通过将 MaxAge 设置为 -1 来删除 cookie
|
||||
c.DeleteCookie("session_id")
|
||||
c.String(http.StatusOK, "已退出登录")
|
||||
})
|
||||
```
|
||||
|
||||
#### 3.4. 中间件数据传递
|
||||
|
||||
使用 `c.Set()` 和 `c.Get()` 可以在处理链中传递数据。
|
||||
|
||||
```go
|
||||
// 中间件:生成并设置请求 ID
|
||||
func RequestIDMiddleware() touka.HandlerFunc {
|
||||
return func(c *touka.Context) {
|
||||
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||
c.Set("RequestID", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := touka.New()
|
||||
r.Use(RequestIDMiddleware())
|
||||
|
||||
r.GET("/status", func(c *touka.Context) {
|
||||
// 在处理器中获取由中间件设置的数据
|
||||
// c.MustGet() 在 key 不存在时会 panic,适用于确定存在的场景
|
||||
requestID := c.MustGet("RequestID").(string)
|
||||
|
||||
// 或者使用安全的 Get
|
||||
// requestID, exists := c.GetString("RequestID")
|
||||
|
||||
c.JSON(http.StatusOK, touka.H{"status": "ok", "request_id": requestID})
|
||||
})
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.5. 集成的工具
|
||||
|
||||
##### 日志记录
|
||||
|
||||
Touka 集成了 `reco` 日志库,可以直接在 `Context` 中使用。
|
||||
|
||||
```go
|
||||
r.GET("/log-test", func(c *touka.Context) {
|
||||
userID := "user-abc"
|
||||
c.Infof("用户 %s 访问了 /log-test", userID)
|
||||
|
||||
err := errors.New("一个模拟的错误")
|
||||
if err != nil {
|
||||
c.Errorf("处理请求时发生错误: %v, 用户: %s", err, userID)
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "日志已记录")
|
||||
})
|
||||
```
|
||||
|
||||
##### HTTP 客户端
|
||||
|
||||
Touka 集成了 `httpc` 客户端,方便发起出站请求。
|
||||
|
||||
```go
|
||||
r.GET("/fetch-data", func(c *touka.Context) {
|
||||
// 使用 Context 携带的 httpc 客户端
|
||||
resp, err := c.GetHTTPC().Get("https://api.github.com/users/WJQSERVER-STUDIO", httpc.WithTimeout(5*time.Second))
|
||||
if err != nil {
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 将外部响应直接流式传输给客户端
|
||||
c.SetHeader("Content-Type", resp.Header.Get("Content-Type"))
|
||||
c.WriteStream(resp.Body)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 错误处理:统一且强大
|
||||
|
||||
Touka 的一个标志性特性是其统一的错误处理机制。
|
||||
|
||||
#### 4.1. 自定义全局错误处理器
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := touka.New()
|
||||
|
||||
// 设置一个自定义的全局错误处理器
|
||||
r.SetErrorHandler(func(c *touka.Context, code int, err error) {
|
||||
// 检查是否是客户端断开连接
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return // 不做任何事
|
||||
}
|
||||
|
||||
// 记录详细错误
|
||||
c.GetLogger().Errorf("捕获到错误: code=%d, err=%v, path=%s", code, err, c.Request.URL.Path)
|
||||
|
||||
// 根据错误码返回不同的响应
|
||||
switch code {
|
||||
case http.StatusNotFound:
|
||||
c.JSON(code, touka.H{"error": "您要找的页面去火星了"})
|
||||
case http.StatusMethodNotAllowed:
|
||||
c.JSON(code, touka.H{"error": "不支持的请求方法"})
|
||||
default:
|
||||
c.JSON(code, touka.H{"error": "服务器内部错误"})
|
||||
}
|
||||
})
|
||||
|
||||
// 这个路由不存在,会触发 404
|
||||
// r.GET("/this-route-does-not-exist", ...)
|
||||
|
||||
// 静态文件服务,如果文件不存在,也会被上面的 ErrorHandler 捕获
|
||||
r.StaticDir("/files", "./non-existent-dir")
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2. `errorCapturingResponseWriter` 的魔力
|
||||
|
||||
Touka 如何捕获 `http.FileServer` 的错误?答案是 `errorCapturingResponseWriter`。
|
||||
|
||||
当您使用 `r.StaticDir` 或类似方法时,Touka 不会直接将 `http.FileServer` 作为处理器。相反,它会用一个自定义的 `ResponseWriter` 实现(即 `ecw`)来包装原始的 `ResponseWriter`,然后才调用 `http.FileServer.ServeHTTP`。
|
||||
|
||||
这个包装器会:
|
||||
1. **拦截 `WriteHeader(statusCode)` 调用:** 当 `http.FileServer` 内部决定要写入一个例如 `404 Not Found` 的状态码时,`ecw` 会捕获这个 `statusCode`。
|
||||
2. **判断是否为错误:** 如果 `statusCode >= 400`,`ecw` 会将此视为一个错误信号。
|
||||
3. **阻止原始响应:** `ecw` 会阻止 `http.FileServer` 继续向客户端写入任何内容(包括响应体)。
|
||||
4. **调用全局 `ErrorHandler`:** 最后,`ecw` 会调用您通过 `r.SetErrorHandler` 设置的全局错误处理器,并将捕获到的 `statusCode` 和一个通用错误传递给它。
|
||||
|
||||
这个机制确保了无论是动态 API 的错误还是静态文件服务的错误,都能被统一、优雅地处理,从而提供一致的用户体验。
|
||||
|
||||
---
|
||||
|
||||
### 5. 静态文件服务与嵌入式资源
|
||||
|
||||
#### 5.1. 服务本地文件
|
||||
|
||||
```go
|
||||
// 将 URL /assets/ 映射到本地的 ./static 目录
|
||||
r.StaticDir("/assets", "./static")
|
||||
|
||||
// 将 URL /favicon.ico 映射到本地的 ./static/img/favicon.ico 文件
|
||||
r.StaticFile("/favicon.ico", "./static/img/favicon.ico")
|
||||
```
|
||||
|
||||
#### 5.2. 服务嵌入式资源 (Go 1.16+)
|
||||
|
||||
使用 `go:embed` 可以将静态资源直接编译到二进制文件中,实现真正的单体应用部署。
|
||||
|
||||
```go
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"github.com/infinite-iroha/touka"
|
||||
)
|
||||
|
||||
//go:embed frontend/dist
|
||||
var embeddedFS embed.FS
|
||||
|
||||
func main() {
|
||||
r := touka.New()
|
||||
|
||||
// 创建一个子文件系统,根目录为 embeddedFS 中的 frontend/dist
|
||||
subFS, err := fs.Sub(embeddedFS, "frontend/dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 使用 StaticFS 来服务这个嵌入式文件系统
|
||||
// 所有对 / 的访问都会映射到嵌入的 frontend/dist 目录
|
||||
r.StaticFS("/", http.FS(subFS))
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 与标准库的无缝集成
|
||||
|
||||
Touka 提供了适配器,可以轻松使用任何实现了标准 `http.Handler` 或 `http.HandlerFunc` 接口的组件。
|
||||
|
||||
```go
|
||||
import "net/http/pprof"
|
||||
|
||||
// 适配一个标准的 http.HandlerFunc
|
||||
r.GET("/legacy-handler", touka.AdapterStdFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("这是一个标准的 http.HandlerFunc"))
|
||||
}))
|
||||
|
||||
// 适配一个标准的 http.Handler,例如 pprof
|
||||
debugGroup := r.Group("/debug/pprof")
|
||||
{
|
||||
debugGroup.GET("/", touka.AdapterStdFunc(pprof.Index))
|
||||
debugGroup.GET("/cmdline", touka.AdapterStdFunc(pprof.Cmdline))
|
||||
debugGroup.GET("/profile", touka.AdapterStdFunc(pprof.Profile))
|
||||
// ... 其他 pprof 路由
|
||||
}
|
||||
```
|
||||
|
||||
这使得您可以方便地利用 Go 生态中大量现有的、遵循标准接口的第三方中间件和工具。
|
||||
262
context.go
262
context.go
|
|
@ -14,16 +14,16 @@ import (
|
|||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/WJQSERVER/wanf"
|
||||
"github.com/fenthope/reco"
|
||||
"github.com/go-json-experiment/json"
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ type Context struct {
|
|||
index int8 // 当前执行到处理链的哪个位置
|
||||
|
||||
mu sync.RWMutex
|
||||
Keys map[string]interface{} // 用于在中间件之间传递数据
|
||||
Keys map[string]any // 用于在中间件之间传递数据
|
||||
|
||||
Errors []error // 用于收集处理过程中的错误
|
||||
|
||||
|
|
@ -65,6 +65,10 @@ type Context struct {
|
|||
|
||||
// 请求体Body大小限制
|
||||
MaxRequestBodySize int64
|
||||
|
||||
// skippedNodes 用于记录跳过的节点信息,以便回溯
|
||||
// 通常在处理嵌套路由时使用
|
||||
SkippedNodes []skippedNode
|
||||
}
|
||||
|
||||
// --- Context 相关方法实现 ---
|
||||
|
|
@ -78,20 +82,30 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) {
|
|||
} else {
|
||||
c.Writer = newResponseWriter(w)
|
||||
}
|
||||
//c.Writer = newResponseWriter(w)
|
||||
|
||||
c.Request = req
|
||||
c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组
|
||||
//c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组
|
||||
//避免params长度为0
|
||||
if cap(c.Params) > 0 {
|
||||
c.Params = c.Params[:0]
|
||||
} else {
|
||||
c.Params = make(Params, 0, c.engine.maxParams)
|
||||
}
|
||||
c.handlers = nil
|
||||
c.index = -1 // 初始为 -1,`Next()` 将其设置为 0
|
||||
c.Keys = make(map[string]interface{}) // 每次请求重新创建 map,避免数据污染
|
||||
c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染
|
||||
c.Errors = c.Errors[:0] // 清空 Errors 切片
|
||||
c.queryCache = nil // 清空查询参数缓存
|
||||
c.formCache = nil // 清空表单数据缓存
|
||||
c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值
|
||||
c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式
|
||||
c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize
|
||||
// c.HTTPClient 和 c.engine 保持不变,它们引用 Engine 实例的成员
|
||||
|
||||
if cap(c.SkippedNodes) > 0 {
|
||||
c.SkippedNodes = c.SkippedNodes[:0]
|
||||
} else {
|
||||
c.SkippedNodes = make([]skippedNode, 0, 256)
|
||||
}
|
||||
}
|
||||
|
||||
// Next 在处理链中执行下一个处理函数
|
||||
|
|
@ -123,10 +137,10 @@ func (c *Context) AbortWithStatus(code int) {
|
|||
|
||||
// Set 将一个键值对存储到 Context 中
|
||||
// 这是一个线程安全的操作,用于在中间件之间传递数据
|
||||
func (c *Context) Set(key string, value interface{}) {
|
||||
func (c *Context) Set(key string, value any) {
|
||||
c.mu.Lock() // 加写锁
|
||||
if c.Keys == nil {
|
||||
c.Keys = make(map[string]interface{})
|
||||
c.Keys = make(map[string]any)
|
||||
}
|
||||
c.Keys[key] = value
|
||||
c.mu.Unlock() // 解写锁
|
||||
|
|
@ -134,7 +148,7 @@ func (c *Context) Set(key string, value interface{}) {
|
|||
|
||||
// Get 从 Context 中获取一个值
|
||||
// 这是一个线程安全的操作
|
||||
func (c *Context) Get(key string) (value interface{}, exists bool) {
|
||||
func (c *Context) Get(key string) (value any, exists bool) {
|
||||
c.mu.RLock() // 加读锁
|
||||
value, exists = c.Keys[key]
|
||||
c.mu.RUnlock() // 解读锁
|
||||
|
|
@ -209,7 +223,7 @@ func (c *Context) GetDuration(key string) (value time.Duration, exists bool) {
|
|||
|
||||
// MustGet 从 Context 中获取一个值,如果不存在则 panic
|
||||
// 适用于确定值一定存在的场景
|
||||
func (c *Context) MustGet(key string) interface{} {
|
||||
func (c *Context) MustGet(key string) any {
|
||||
if value, exists := c.Get(key); exists {
|
||||
return value
|
||||
}
|
||||
|
|
@ -270,7 +284,7 @@ func (c *Context) Raw(code int, contentType string, data []byte) {
|
|||
}
|
||||
|
||||
// String 向响应写入格式化的字符串
|
||||
func (c *Context) String(code int, format string, values ...interface{}) {
|
||||
func (c *Context) String(code int, format string, values ...any) {
|
||||
c.Writer.WriteHeader(code)
|
||||
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
|
||||
}
|
||||
|
|
@ -282,13 +296,121 @@ func (c *Context) Text(code int, text string) {
|
|||
c.Writer.Write([]byte(text))
|
||||
}
|
||||
|
||||
// FileText
|
||||
func (c *Context) FileText(code int, filePath string) {
|
||||
// 清理path
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
if !filepath.IsAbs(cleanPath) {
|
||||
c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed"))
|
||||
return
|
||||
}
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
c.AddError(fmt.Errorf("file not found: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
file, err := os.Open(cleanPath)
|
||||
if err != nil {
|
||||
c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 获取文件信息以获取文件大小
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err))
|
||||
return
|
||||
}
|
||||
// 判断是否是dir
|
||||
if fileInfo.IsDir() {
|
||||
c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory"))
|
||||
return
|
||||
}
|
||||
|
||||
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
c.SetBodyStream(file, int(fileInfo.Size()))
|
||||
}
|
||||
|
||||
/*
|
||||
// not fot work
|
||||
// FileTextSafeDir
|
||||
func (c *Context) FileTextSafeDir(code int, filePath string, safeDir string) {
|
||||
|
||||
// 清理path
|
||||
cleanPath := path.Clean(filePath)
|
||||
if !filepath.IsAbs(cleanPath) {
|
||||
c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected"))
|
||||
return
|
||||
}
|
||||
|
||||
// 判断filePath是否包含在safeDir内, 防止路径穿越
|
||||
relPath, err := filepath.Rel(safeDir, cleanPath)
|
||||
if err != nil {
|
||||
c.AddError(fmt.Errorf("failed to get relative path: %w", err))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("failed to get relative path: %w", err))
|
||||
return
|
||||
}
|
||||
cleanPath = filepath.Join(safeDir, relPath)
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
c.AddError(fmt.Errorf("file not found: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
file, err := os.Open(cleanPath)
|
||||
if err != nil {
|
||||
c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 获取文件信息以获取文件大小
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err))
|
||||
return
|
||||
}
|
||||
// 判断是否是dir
|
||||
if fileInfo.IsDir() {
|
||||
c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath))
|
||||
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory"))
|
||||
return
|
||||
}
|
||||
|
||||
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
c.SetBodyStream(file, int(fileInfo.Size()))
|
||||
}
|
||||
*/
|
||||
|
||||
// JSON 向响应写入 JSON 数据
|
||||
// 设置 Content-Type 为 application/json
|
||||
func (c *Context) JSON(code int, obj interface{}) {
|
||||
func (c *Context) JSON(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
if err := json.MarshalWrite(c.Writer, obj); err != nil {
|
||||
c.AddError(fmt.Errorf("failed to marshal JSON: %w", err))
|
||||
c.Errorf("failed to marshal JSON: %s", err)
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to marshal JSON: %w", err))
|
||||
return
|
||||
}
|
||||
|
|
@ -296,7 +418,7 @@ func (c *Context) JSON(code int, obj interface{}) {
|
|||
|
||||
// GOB 向响应写入GOB数据
|
||||
// 设置 Content-Type 为 application/octet-stream
|
||||
func (c *Context) GOB(code int, obj interface{}) {
|
||||
func (c *Context) GOB(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/octet-stream") // 设置合适的 Content-Type
|
||||
c.Writer.WriteHeader(code)
|
||||
// GOB 编码
|
||||
|
|
@ -308,11 +430,25 @@ func (c *Context) GOB(code int, obj interface{}) {
|
|||
}
|
||||
}
|
||||
|
||||
// WANF向响应写入WANF数据
|
||||
// 设置 application/vnd.wjqserver.wanf; charset=utf-8
|
||||
func (c *Context) WANF(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/vnd.wjqserver.wanf; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
// WANF 编码
|
||||
encoder := wanf.NewStreamEncoder(c.Writer)
|
||||
if err := encoder.Encode(obj); err != nil {
|
||||
c.AddError(fmt.Errorf("failed to encode WANF: %w", err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to encode WANF: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HTML 渲染 HTML 模板
|
||||
// 如果 Engine 配置了 HTMLRender,则使用它进行渲染
|
||||
// 否则,会进行简单的字符串输出
|
||||
// 预留接口,可以扩展为支持多种模板引擎
|
||||
func (c *Context) HTML(code int, name string, obj interface{}) {
|
||||
func (c *Context) HTML(code int, name string, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
|
||||
|
|
@ -343,7 +479,7 @@ func (c *Context) Redirect(code int, location string) {
|
|||
}
|
||||
|
||||
// ShouldBindJSON 尝试将请求体绑定到 JSON 对象
|
||||
func (c *Context) ShouldBindJSON(obj interface{}) error {
|
||||
func (c *Context) ShouldBindJSON(obj any) error {
|
||||
if c.Request.Body == nil {
|
||||
return errors.New("request body is empty")
|
||||
}
|
||||
|
|
@ -354,10 +490,28 @@ func (c *Context) ShouldBindJSON(obj interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ShouldBindWANF
|
||||
func (c *Context) ShouldBindWANF(obj any) error {
|
||||
if c.Request.Body == nil {
|
||||
return errors.New("request body is empty")
|
||||
}
|
||||
decoder, err := wanf.NewStreamDecoder(c.Request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WANF decoder: %w", err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(obj); err != nil {
|
||||
return fmt.Errorf("WANF binding error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: This function is a reserved placeholder for future API extensions
|
||||
// and is not yet implemented. It will either be properly defined or removed in v2.0.0. Do not use.
|
||||
// ShouldBind 尝试将请求体绑定到各种类型(JSON, Form, XML 等)
|
||||
// 这是一个复杂的通用绑定接口,通常根据 Content-Type 或其他头部来判断绑定方式
|
||||
// 预留接口,可根据项目需求进行扩展
|
||||
func (c *Context) ShouldBind(obj interface{}) error {
|
||||
func (c *Context) ShouldBind(obj any) error {
|
||||
// TODO: 完整的通用绑定逻辑
|
||||
// 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等
|
||||
// 例如:
|
||||
|
|
@ -410,7 +564,7 @@ func (c *Context) Err() error {
|
|||
// Value returns the value associated with this context for key, or nil if no
|
||||
// value is associated with key.
|
||||
// 可以用于从 Context 中获取与特定键关联的值,包括 Go 原生 Context 的值和 Touka Context 的 Keys
|
||||
func (c *Context) Value(key interface{}) interface{} {
|
||||
func (c *Context) Value(key any) any {
|
||||
if keyAsString, ok := key.(string); ok {
|
||||
if val, exists := c.Get(keyAsString); exists {
|
||||
return val
|
||||
|
|
@ -523,39 +677,59 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) {
|
|||
func (c *Context) RequestIP() string {
|
||||
if c.engine.ForwardByClientIP {
|
||||
for _, headerName := range c.engine.RemoteIPHeaders {
|
||||
if ipValue := c.Request.Header.Get(headerName); ipValue != "" {
|
||||
// X-Forwarded-For 可能包含多个 IP,约定第一个(最左边)是客户端 IP
|
||||
// 其他头部(如 X-Real-IP)通常只有一个
|
||||
ips := strings.Split(ipValue, ",")
|
||||
for _, singleIP := range ips {
|
||||
trimmedIP := strings.TrimSpace(singleIP)
|
||||
// 使用 netip.ParseAddr 进行 IP 地址的解析和格式验证
|
||||
addr, err := netip.ParseAddr(trimmedIP)
|
||||
if err == nil {
|
||||
// 成功解析到合法的 IP 地址格式,立即返回
|
||||
return addr.String()
|
||||
}
|
||||
// 如果当前 singleIP 无效,继续检查列表中的下一个
|
||||
ipValue := c.Request.Header.Get(headerName)
|
||||
if ipValue == "" {
|
||||
continue // 头部为空, 继续检查下一个
|
||||
}
|
||||
|
||||
// 使用索引高效遍历逗号分隔的 IP 列表, 避免 strings.Split 的内存分配
|
||||
currentPos := 0
|
||||
for currentPos < len(ipValue) {
|
||||
nextComma := strings.IndexByte(ipValue[currentPos:], ',')
|
||||
|
||||
var ipSegment string
|
||||
if nextComma == -1 {
|
||||
// 这是列表中的最后一个 IP
|
||||
ipSegment = ipValue[currentPos:]
|
||||
currentPos = len(ipValue) // 结束循环
|
||||
} else {
|
||||
// 截取当前 IP 段
|
||||
ipSegment = ipValue[currentPos : currentPos+nextComma]
|
||||
currentPos += nextComma + 1 // 移动到下一个 IP 段的起始位置
|
||||
}
|
||||
|
||||
// 去除空格并检查是否为空 (例如 "ip1,,ip2")
|
||||
trimmedIP := strings.TrimSpace(ipSegment)
|
||||
if trimmedIP == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 netip.ParseAddr 进行 IP 地址的解析和验证
|
||||
addr, err := netip.ParseAddr(trimmedIP)
|
||||
if err == nil {
|
||||
// 成功解析到合法的 IP, 立即返回
|
||||
return addr.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有启用 ForwardByClientIP 或头部中没有找到有效 IP,回退到 Request.RemoteAddr
|
||||
// RemoteAddr 通常是 "host:port" 格式,但也可能直接就是 IP 地址
|
||||
remoteAddrStr := c.Request.RemoteAddr
|
||||
ip, _, err := net.SplitHostPort(remoteAddrStr) // 尝试分离 host 和 port
|
||||
if err != nil {
|
||||
// 如果分离失败,意味着 remoteAddrStr 可能直接就是 IP 地址(或畸形)
|
||||
ip = remoteAddrStr // 此时将整个 remoteAddrStr 作为候选 IP
|
||||
// 回退到 Request.RemoteAddr 的处理
|
||||
// 优先使用 netip.ParseAddrPort, 它比 net.SplitHostPort 更高效且分配更少
|
||||
addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr)
|
||||
if err == nil {
|
||||
// 成功从 "ip:port" 格式中解析出 IP
|
||||
return addrp.Addr().String()
|
||||
}
|
||||
|
||||
// 对从 RemoteAddr 中提取/使用的 IP 进行最终的合法性验证
|
||||
addr, parseErr := netip.ParseAddr(ip)
|
||||
if parseErr == nil {
|
||||
return addr.String() // 成功解析并返回合法 IP
|
||||
// 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口),
|
||||
// 则尝试将整个字符串作为 IP 地址进行解析
|
||||
addr, err := netip.ParseAddr(c.Request.RemoteAddr)
|
||||
if err == nil {
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
// 所有方法都失败, 返回空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -705,7 +879,7 @@ func (c *Context) GetRequestURIPath() string {
|
|||
// 将文件内容作为响应body
|
||||
func (c *Context) SetRespBodyFile(code int, filePath string) {
|
||||
// 清理path
|
||||
cleanPath := path.Clean(filePath)
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
|
||||
// 打开文件
|
||||
file, err := os.Open(cleanPath)
|
||||
|
|
@ -725,7 +899,7 @@ func (c *Context) SetRespBodyFile(code int, filePath string) {
|
|||
}
|
||||
|
||||
// 尝试根据文件扩展名猜测 Content-Type
|
||||
contentType := mime.TypeByExtension(path.Ext(cleanPath))
|
||||
contentType := mime.TypeByExtension(filepath.Ext(cleanPath))
|
||||
if contentType == "" {
|
||||
// 如果无法猜测,则使用默认的二进制流类型
|
||||
contentType = "application/octet-stream"
|
||||
|
|
|
|||
53
engine.go
53
engine.go
|
|
@ -7,7 +7,6 @@ package touka
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
|
@ -148,7 +147,7 @@ func defaultErrorWarp(handler ErrorHandler) ErrorHandler {
|
|||
return
|
||||
default:
|
||||
if c.Writer.Written() {
|
||||
log.Printf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err)
|
||||
c.Debugf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +162,7 @@ func defaultErrorWarp(handler ErrorHandler) ErrorHandler {
|
|||
// 避免在客户端已关闭连接后写入响应导致的问题
|
||||
// 检查 context.Context 是否已取消
|
||||
if errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||
log.Printf("errpage: client disconnected, skipping error page rendering for status %d, err: %v", code, err)
|
||||
c.Debugf("errpage: client disconnected, skipping error page rendering for status %d, err: %v", code, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +421,41 @@ func getHandlerName(h HandlerFunc) string {
|
|||
|
||||
}
|
||||
|
||||
const MaxSkippedNodesCap = 256
|
||||
|
||||
// TempSkippedNodesPool 存储 *[]skippedNode 以复用内存
|
||||
var TempSkippedNodesPool = sync.Pool{
|
||||
New: func() any {
|
||||
// 返回一个指向容量为 256 的新切片的指针
|
||||
s := make([]skippedNode, 0, MaxSkippedNodesCap)
|
||||
return &s
|
||||
},
|
||||
}
|
||||
|
||||
// GetTempSkippedNodes 从 Pool 中获取一个 *[]skippedNode 指针
|
||||
func GetTempSkippedNodes() *[]skippedNode {
|
||||
// 直接返回 Pool 中存储的指针
|
||||
return TempSkippedNodesPool.Get().(*[]skippedNode)
|
||||
}
|
||||
|
||||
// PutTempSkippedNodes 将用完的 *[]skippedNode 指针放回 Pool
|
||||
func PutTempSkippedNodes(skippedNodes *[]skippedNode) {
|
||||
if skippedNodes == nil || *skippedNodes == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查容量是否符合预期。如果容量不足,则丢弃,不放回 Pool。
|
||||
if cap(*skippedNodes) < MaxSkippedNodesCap {
|
||||
return // 丢弃该对象,让 Pool 在下次 Get 时通过 New 重新分配
|
||||
}
|
||||
|
||||
// 长度重置为 0,保留容量,实现复用
|
||||
*skippedNodes = (*skippedNodes)[:0]
|
||||
|
||||
// 将指针存回 Pool
|
||||
TempSkippedNodesPool.Put(skippedNodes)
|
||||
}
|
||||
|
||||
// 405中间件
|
||||
func MethodNotAllowed() HandlerFunc {
|
||||
return func(c *Context) {
|
||||
|
|
@ -433,9 +467,10 @@ func MethodNotAllowed() HandlerFunc {
|
|||
// 如果是 OPTIONS 请求,尝试查找所有允许的方法
|
||||
allowedMethods := []string{}
|
||||
for _, treeIter := range engine.methodTrees {
|
||||
var tempSkippedNodes []skippedNode
|
||||
// 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型
|
||||
value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false)
|
||||
tempSkippedNodes := GetTempSkippedNodes()
|
||||
value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false)
|
||||
PutTempSkippedNodes(tempSkippedNodes)
|
||||
if value.handlers != nil {
|
||||
allowedMethods = append(allowedMethods, treeIter.method)
|
||||
}
|
||||
|
|
@ -452,9 +487,10 @@ func MethodNotAllowed() HandlerFunc {
|
|||
if treeIter.method == httpMethod { // 已经处理过当前方法,跳过
|
||||
continue
|
||||
}
|
||||
var tempSkippedNodes []skippedNode // 用于临时查找,不影响主 Context
|
||||
// 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型
|
||||
value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) // 只查找是否存在,不需要参数
|
||||
tempSkippedNodes := GetTempSkippedNodes()
|
||||
value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) // 只查找是否存在,不需要参数
|
||||
PutTempSkippedNodes(tempSkippedNodes)
|
||||
if value.handlers != nil {
|
||||
// 使用定义的ErrorHandle处理
|
||||
engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed"))
|
||||
|
|
@ -662,9 +698,8 @@ func (engine *Engine) handleRequest(c *Context) {
|
|||
// 查找匹配的节点和处理函数
|
||||
// 这里传递 &c.Params 而不是重新创建,以利用 Context 中预分配的容量
|
||||
// skippedNodes 内部使用,因此无需从外部传入已分配的 slice
|
||||
var skippedNodes []skippedNode // 用于回溯的跳过节点
|
||||
// 直接在 rootNode 上调用 getValue 方法
|
||||
value := rootNode.getValue(requestPath, &c.Params, &skippedNodes, true) // unescape=true 对路径参数进行 URL 解码
|
||||
value := rootNode.getValue(requestPath, &c.Params, &c.SkippedNodes, true) // unescape=true 对路径参数进行 URL 解码
|
||||
|
||||
if value.handlers != nil {
|
||||
//c.handlers = engine.combineHandlers(engine.globalHandlers, value.handlers) // 组合全局中间件和路由处理函数
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ package touka
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -19,13 +18,19 @@ var allowedFileServerMethods = map[string]struct{}{
|
|||
http.MethodHead: {},
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInputFSisNil = errors.New("input FS is nil")
|
||||
ErrMethodNotAllowed = errors.New("method not allowed")
|
||||
)
|
||||
|
||||
// FileServer方式, 返回一个HandleFunc, 统一化处理
|
||||
func FileServer(fs http.FileSystem) HandlerFunc {
|
||||
if fs == nil {
|
||||
return func(c *Context) {
|
||||
c.ErrorUseHandle(500, errors.New("Input FileSystem is nil"))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil)
|
||||
}
|
||||
}
|
||||
|
||||
fileServerInstance := http.FileServer(fs)
|
||||
return func(c *Context) {
|
||||
FileServerHandleServe(c, fileServerInstance)
|
||||
|
|
@ -37,7 +42,6 @@ func FileServer(fs http.FileSystem) HandlerFunc {
|
|||
|
||||
func FileServerHandleServe(c *Context, fsHandle http.Handler) {
|
||||
if fsHandle == nil {
|
||||
ErrInputFSisNil := errors.New("Input FileSystem Handle is nil")
|
||||
c.AddError(ErrInputFSisNil)
|
||||
// 500
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil)
|
||||
|
|
@ -59,7 +63,7 @@ func FileServerHandleServe(c *Context, fsHandle http.Handler) {
|
|||
return
|
||||
} else {
|
||||
// 否则,返回 405 Method Not Allowed
|
||||
c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, fmt.Errorf("Method %s is Not Allowed on FileServer", c.Request.Method))
|
||||
c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, ErrMethodNotAllowed)
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
|
|
@ -240,7 +244,7 @@ func (engine *Engine) StaticFS(relativePath string, fs http.FileSystem) {
|
|||
relativePath += "/"
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(fs)
|
||||
fileServer := http.StripPrefix(relativePath, http.FileServer(fs))
|
||||
engine.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer))
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +258,7 @@ func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) {
|
|||
relativePath += "/"
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(fs)
|
||||
fileServer := http.StripPrefix(relativePath, http.FileServer(fs))
|
||||
group.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer))
|
||||
}
|
||||
|
||||
|
|
|
|||
12
go.mod
12
go.mod
|
|
@ -1,16 +1,16 @@
|
|||
module github.com/infinite-iroha/touka
|
||||
|
||||
go 1.24.4
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.1
|
||||
github.com/fenthope/reco v0.0.3
|
||||
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2
|
||||
github.com/WJQSERVER/wanf v0.0.3
|
||||
github.com/fenthope/reco v0.0.4
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
)
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -1,14 +1,14 @@
|
|||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6 h1:/50VJYXd6jcu+p5BnEBDyiX0nAyGxas1W3DCnrYMxMY=
|
||||
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc=
|
||||
github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbekZgxOuvVwiMfyM=
|
||||
github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.1 h1:/eG8aYKL3WfQILIRbG+cbzQjPkNHEPTqfGUdQS5rtI4=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.1/go.mod h1:mxXBf2hqbQGNHkVy/7wfU7Xi2s09MyZpbY2hyR+4uD4=
|
||||
github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
|
||||
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
|
||||
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d h1:+d6m5Bjvv0/RJct1VcOw2P5bvBOGjENmxORJYnSYDow=
|
||||
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y=
|
||||
github.com/WJQSERVER/wanf v0.0.3 h1:OqhG7ETiR5Knqr0lmbb+iUMw9O7re2vEogjVf06QevM=
|
||||
github.com/WJQSERVER/wanf v0.0.3/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE=
|
||||
github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM=
|
||||
github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
|
|
|
|||
89
serve.go
89
serve.go
|
|
@ -128,6 +128,79 @@ func handleGracefulShutdown(servers []*http.Server, timeout time.Duration, logge
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleGracefulShutdownWithContext(servers []*http.Server, ctx context.Context, timeout time.Duration, logger *reco.Logger) error {
|
||||
// 创建一个 channel 来接收操作系统信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 监听中断和终止信号
|
||||
|
||||
// 启动服务器
|
||||
serverStopped := make(chan error, 1)
|
||||
for _, srv := range servers {
|
||||
go func(s *http.Server) {
|
||||
serverStopped <- s.ListenAndServe()
|
||||
}(srv)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context 被取消 (例如,通过外部取消函数)
|
||||
log.Println("Context cancelled, shutting down Touka server(s)...")
|
||||
case err := <-serverStopped:
|
||||
// 服务器自身停止 (例如,端口被占用,或 ListenAndServe 返回错误)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("Touka HTTP server failed: %w", err)
|
||||
}
|
||||
log.Println("Touka HTTP server stopped gracefully.")
|
||||
return nil // 服务器已自行优雅关闭,无需进一步处理
|
||||
case <-quit:
|
||||
// 接收到操作系统信号
|
||||
log.Println("Shutting down Touka server(s) due to OS signal...")
|
||||
}
|
||||
|
||||
// 关闭日志记录器
|
||||
if logger != nil {
|
||||
go func() {
|
||||
log.Println("Closing Touka logger...")
|
||||
CloseLogger(logger)
|
||||
}()
|
||||
}
|
||||
|
||||
// 创建一个带超时的上下文,用于 Shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(servers)) // 用于收集关闭错误的 channel
|
||||
|
||||
// 并发地关闭所有服务器
|
||||
for _, srv := range servers {
|
||||
wg.Add(1)
|
||||
go func(s *http.Server) {
|
||||
defer wg.Done()
|
||||
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||
// 将错误发送到 channel
|
||||
errChan <- fmt.Errorf("server on %s shutdown failed: %w", s.Addr, err)
|
||||
}
|
||||
}(srv)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan) // 关闭 channel,以便可以安全地遍历它
|
||||
|
||||
// 收集所有关闭过程中发生的错误
|
||||
var shutdownErrors []error
|
||||
for err := range errChan {
|
||||
shutdownErrors = append(shutdownErrors, err)
|
||||
log.Printf("Shutdown error: %v", err)
|
||||
}
|
||||
|
||||
if len(shutdownErrors) > 0 {
|
||||
return errors.Join(shutdownErrors...) // Go 1.20+ 的 errors.Join,用于合并多个错误
|
||||
}
|
||||
log.Println("Touka server(s) exited gracefully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 公共 Run 方法 ---
|
||||
|
||||
// Run 启动一个不支持优雅关闭的 HTTP 服务器
|
||||
|
|
@ -163,6 +236,22 @@ func (engine *Engine) RunShutdown(addr string, timeouts ...time.Duration) error
|
|||
return handleGracefulShutdown([]*http.Server{srv}, getShutdownTimeout(timeouts), engine.LogReco)
|
||||
}
|
||||
|
||||
// RunShutdown 启动一个支持优雅关闭的 HTTP 服务器
|
||||
func (engine *Engine) RunShutdownWithContext(addr string, ctx context.Context, timeouts ...time.Duration) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: engine,
|
||||
}
|
||||
|
||||
// 应用框架的默认配置和用户提供的自定义配置
|
||||
//engine.applyDefaultServerConfig(srv)
|
||||
if engine.ServerConfigurator != nil {
|
||||
engine.ServerConfigurator(srv)
|
||||
}
|
||||
|
||||
return handleGracefulShutdownWithContext([]*http.Server{srv}, ctx, getShutdownTimeout(timeouts), engine.LogReco)
|
||||
}
|
||||
|
||||
// RunTLS 启动一个支持优雅关闭的 HTTPS 服务器
|
||||
func (engine *Engine) RunTLS(addr string, tlsConfig *tls.Config, timeouts ...time.Duration) error {
|
||||
if tlsConfig == nil {
|
||||
|
|
|
|||
184
sse.go
Normal file
184
sse.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
// Copyright 2025 WJQSERVER. All rights reserved.
|
||||
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
|
||||
package touka
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event 代表一个服务器发送事件(SSE).
|
||||
type Event struct {
|
||||
// Event 是事件的名称.
|
||||
Event string
|
||||
// Data 是事件的内容, 可以是多行文本.
|
||||
Data string
|
||||
// Id 是事件的唯一标识符.
|
||||
Id string
|
||||
// Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接.
|
||||
Retry string
|
||||
}
|
||||
|
||||
// Render 将事件格式化并写入给定的 writer.
|
||||
// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题.
|
||||
// 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配.
|
||||
func (e *Event) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if len(e.Id) > 0 {
|
||||
buf.WriteString("id: ")
|
||||
buf.WriteString(e.Id)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if len(e.Event) > 0 {
|
||||
buf.WriteString("event: ")
|
||||
buf.WriteString(e.Event)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if len(e.Data) > 0 {
|
||||
lines := strings.Split(e.Data, "\n")
|
||||
for _, line := range lines {
|
||||
buf.WriteString("data: ")
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(e.Retry) > 0 {
|
||||
buf.WriteString("retry: ")
|
||||
buf.WriteString(e.Retry)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// 每个事件都以一个额外的换行符结尾.
|
||||
buf.WriteString("\n")
|
||||
|
||||
// 直接将 buffer 的内容写入 writer, 避免生成中间字符串.
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// EventStream 启动一个 SSE 事件流.
|
||||
// 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期.
|
||||
//
|
||||
// 详细用法:
|
||||
//
|
||||
// r.GET("/sse/callback", func(c *touka.Context) {
|
||||
// // streamer 回调函数会在一个循环中被调用.
|
||||
// c.EventStream(func(w io.Writer) bool {
|
||||
// event := touka.Event{
|
||||
// Event: "time-tick",
|
||||
// Data: time.Now().Format(time.RFC1123),
|
||||
// }
|
||||
//
|
||||
// if err := event.Render(w); err != nil {
|
||||
// // 发生写入错误, 停止发送.
|
||||
// return false // 返回 false 结束事件流.
|
||||
// }
|
||||
//
|
||||
// time.Sleep(2 * time.Second)
|
||||
// return true // 返回 true 继续事件流.
|
||||
// })
|
||||
// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行.
|
||||
// fmt.Println("Client disconnected from /sse/callback")
|
||||
// })
|
||||
func (c *Context) EventStream(streamer func(w io.Writer) bool) {
|
||||
// 为现代网络协议优化头部.
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Del("Connection")
|
||||
c.Writer.Header().Del("Transfer-Encoding")
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
default:
|
||||
if !streamer(c.Writer) {
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EventStreamChan 返回用于 SSE 事件流的 channel.
|
||||
// 这是为高级并发场景设计的、更灵活的API.
|
||||
//
|
||||
// 重要:
|
||||
// - 调用者必须 close(eventChan) 来结束事件流.
|
||||
// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开.
|
||||
// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done().
|
||||
//
|
||||
// 详细用法:
|
||||
//
|
||||
// r.GET("/sse/channel", func(c *touka.Context) {
|
||||
// eventChan, errChan := c.EventStreamChan()
|
||||
//
|
||||
// // 必须在独立的goroutine中处理错误和连接断开.
|
||||
// go func() {
|
||||
// if err := <-errChan; err != nil {
|
||||
// c.Errorf("SSE channel error: %v", err)
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// // 在另一个goroutine中异步发送事件.
|
||||
// go func() {
|
||||
// // 重要: 必须在逻辑结束时关闭channel, 以通知框架.
|
||||
// defer close(eventChan)
|
||||
//
|
||||
// for i := 1; i <= 5; i++ {
|
||||
// select {
|
||||
// case <-c.Request.Context().Done():
|
||||
// return // 客户端已断开, 退出 goroutine.
|
||||
// default:
|
||||
// eventChan <- touka.Event{
|
||||
// Id: fmt.Sprintf("%d", i),
|
||||
// Data: "hello from channel",
|
||||
// }
|
||||
// time.Sleep(2 * time.Second)
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
// })
|
||||
func (c *Context) EventStreamChan() (chan<- Event, <-chan error) {
|
||||
eventChan := make(chan Event)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Del("Connection")
|
||||
c.Writer.Header().Del("Transfer-Encoding")
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Flush()
|
||||
|
||||
go func() {
|
||||
defer close(errChan)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-eventChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := event.Render(c.Writer); err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
case <-c.Request.Context().Done():
|
||||
errChan <- c.Request.Context().Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return eventChan, errChan
|
||||
}
|
||||
358
tree.go
358
tree.go
|
|
@ -2,51 +2,43 @@
|
|||
// Use of this source code is governed by a BSD-style license that can be found
|
||||
// at https://github.com/julienschmidt/httprouter/blob/master/LICENSE
|
||||
// This tree.go is gin's fork, you can see https://github.com/gin-gonic/gin/blob/master/tree.go
|
||||
|
||||
package touka // 定义包名为 touka,该包可能是一个路由或Web框架的核心组件
|
||||
package touka
|
||||
|
||||
import (
|
||||
"bytes" // 导入 bytes 包,用于操作字节切片
|
||||
"net/url" // 导入 net/url 包,用于 URL 解析和转义
|
||||
"strings" // 导入 strings 包,用于字符串操作
|
||||
"unicode" // 导入 unicode 包,用于处理 Unicode 字符
|
||||
"unicode/utf8" // 导入 unicode/utf8 包,用于 UTF-8 编码和解码
|
||||
"unsafe" // 导入 unsafe 包,用于不安全的类型转换,以避免内存分配
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// StringToBytes 将字符串转换为字节切片,不进行内存分配。
|
||||
// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。
|
||||
// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。
|
||||
// StringToBytes 将字符串转换为字节切片, 不进行内存分配.
|
||||
// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077.
|
||||
// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全.
|
||||
func StringToBytes(s string) []byte {
|
||||
return unsafe.Slice(unsafe.StringData(s), len(s))
|
||||
}
|
||||
|
||||
// BytesToString 将字节切片转换为字符串,不进行内存分配。
|
||||
// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。
|
||||
// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。
|
||||
// BytesToString 将字节切片转换为字符串, 不进行内存分配.
|
||||
// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077.
|
||||
// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全.
|
||||
func BytesToString(b []byte) string {
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
var (
|
||||
strColon = []byte(":") // 定义字节切片常量,表示冒号,用于路径参数识别
|
||||
strStar = []byte("*") // 定义字节切片常量,表示星号,用于捕获所有路径识别
|
||||
strSlash = []byte("/") // 定义字节切片常量,表示斜杠,用于路径分隔符识别
|
||||
)
|
||||
|
||||
// Param 是单个 URL 参数,由键和值组成。
|
||||
// Param 是单个 URL 参数, 由键和值组成.
|
||||
type Param struct {
|
||||
Key string // 参数的键名
|
||||
Value string // 参数的值
|
||||
}
|
||||
|
||||
// Params 是 Param 类型的切片,由路由器返回。
|
||||
// 该切片是有序的,第一个 URL 参数也是切片中的第一个值。
|
||||
// 因此,按索引读取值是安全的。
|
||||
// Params 是 Param 类型的切片, 由路由器返回.
|
||||
// 该切片是有序的, 第一个 URL 参数也是切片中的第一个值.
|
||||
// 因此, 按索引读取值是安全的.
|
||||
type Params []Param
|
||||
|
||||
// Get 返回键名与给定名称匹配的第一个 Param 的值,并返回一个布尔值 true。
|
||||
// 如果未找到匹配的 Param,则返回空字符串和布尔值 false。
|
||||
// Get 返回键名与给定名称匹配的第一个 Param 的值, 并返回一个布尔值 true.
|
||||
// 如果未找到匹配的 Param, 则返回空字符串和布尔值 false.
|
||||
func (ps Params) Get(name string) (string, bool) {
|
||||
for _, entry := range ps {
|
||||
if entry.Key == name {
|
||||
|
|
@ -56,24 +48,24 @@ func (ps Params) Get(name string) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
// ByName 返回键名与给定名称匹配的第一个 Param 的值。
|
||||
// 如果未找到匹配的 Param,则返回空字符串。
|
||||
// ByName 返回键名与给定名称匹配的第一个 Param 的值.
|
||||
// 如果未找到匹配的 Param, 则返回空字符串.
|
||||
func (ps Params) ByName(name string) (va string) {
|
||||
va, _ = ps.Get(name) // 调用 Get 方法获取值,忽略第二个返回值
|
||||
va, _ = ps.Get(name) // 调用 Get 方法获取值, 忽略第二个返回值
|
||||
return
|
||||
}
|
||||
|
||||
// methodTree 表示特定 HTTP 方法的路由树。
|
||||
// methodTree 表示特定 HTTP 方法的路由树.
|
||||
type methodTree struct {
|
||||
method string // HTTP 方法(例如 "GET", "POST")
|
||||
method string // HTTP 方法(例如 "GET", "POST")
|
||||
root *node // 该方法的根节点
|
||||
}
|
||||
|
||||
// methodTrees 是 methodTree 的切片。
|
||||
// methodTrees 是 methodTree 的切片.
|
||||
type methodTrees []methodTree
|
||||
|
||||
// get 根据给定的 HTTP 方法查找并返回对应的根节点。
|
||||
// 如果找不到,则返回 nil。
|
||||
// get 根据给定的 HTTP 方法查找并返回对应的根节点.
|
||||
// 如果找不到, 则返回 nil.
|
||||
func (trees methodTrees) get(method string) *node {
|
||||
for _, tree := range trees {
|
||||
if tree.method == method {
|
||||
|
|
@ -83,7 +75,7 @@ func (trees methodTrees) get(method string) *node {
|
|||
return nil
|
||||
}
|
||||
|
||||
// longestCommonPrefix 计算两个字符串的最长公共前缀的长度。
|
||||
// longestCommonPrefix 计算两个字符串的最长公共前缀的长度.
|
||||
func longestCommonPrefix(a, b string) int {
|
||||
i := 0
|
||||
max_ := min(len(a), len(b)) // 找出两个字符串中较短的长度
|
||||
|
|
@ -93,64 +85,61 @@ func longestCommonPrefix(a, b string) int {
|
|||
return i // 返回公共前缀的长度
|
||||
}
|
||||
|
||||
// addChild 添加一个子节点,并将通配符子节点(如果存在)保持在数组的末尾。
|
||||
// addChild 添加一个子节点, 并将通配符子节点(如果存在)保持在数组的末尾.
|
||||
func (n *node) addChild(child *node) {
|
||||
if n.wildChild && len(n.children) > 0 {
|
||||
// 如果当前节点有通配符子节点,且已有子节点,则将通配符子节点移到末尾
|
||||
// 如果当前节点有通配符子节点, 且已有子节点, 则将通配符子节点移到末尾
|
||||
wildcardChild := n.children[len(n.children)-1]
|
||||
n.children = append(n.children[:len(n.children)-1], child, wildcardChild)
|
||||
} else {
|
||||
// 否则,直接添加子节点
|
||||
// 否则, 直接添加子节点
|
||||
n.children = append(n.children, child)
|
||||
}
|
||||
}
|
||||
|
||||
// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量。
|
||||
// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量.
|
||||
func countParams(path string) uint16 {
|
||||
var n uint16
|
||||
s := StringToBytes(path) // 将路径字符串转换为字节切片
|
||||
n += uint16(bytes.Count(s, strColon)) // 统计冒号的数量
|
||||
n += uint16(bytes.Count(s, strStar)) // 统计星号的数量
|
||||
return n
|
||||
colons := strings.Count(path, ":")
|
||||
stars := strings.Count(path, "*")
|
||||
return uint16(colons + stars)
|
||||
}
|
||||
|
||||
// countSections 计算路径中斜杠('/')的数量,即路径段的数量。
|
||||
// countSections 计算路径中斜杠('/')的数量, 即路径段的数量.
|
||||
func countSections(path string) uint16 {
|
||||
s := StringToBytes(path) // 将路径字符串转换为字节切片
|
||||
return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量
|
||||
return uint16(strings.Count(path, "/"))
|
||||
}
|
||||
|
||||
// nodeType 定义了节点的类型。
|
||||
// nodeType 定义了节点的类型.
|
||||
type nodeType uint8
|
||||
|
||||
const (
|
||||
static nodeType = iota // 静态节点,路径中不包含参数或通配符
|
||||
static nodeType = iota // 静态节点, 路径中不包含参数或通配符
|
||||
root // 根节点
|
||||
param // 参数节点(例如:name)
|
||||
catchAll // 捕获所有节点(例如*path)
|
||||
param // 参数节点(例如:name)
|
||||
catchAll // 捕获所有节点(例如*path)
|
||||
)
|
||||
|
||||
// node 表示路由树中的一个节点。
|
||||
// node 表示路由树中的一个节点.
|
||||
type node struct {
|
||||
path string // 当前节点的路径段
|
||||
indices string // 子节点第一个字符的索引字符串,用于快速查找子节点
|
||||
wildChild bool // 是否包含通配符子节点(:param 或 *catchAll)
|
||||
nType nodeType // 节点的类型(静态、根、参数、捕获所有)
|
||||
priority uint32 // 节点的优先级,用于查找时优先匹配
|
||||
children []*node // 子节点切片,最多有一个 :param 风格的节点位于数组末尾
|
||||
indices string // 子节点第一个字符的索引字符串, 用于快速查找子节点
|
||||
wildChild bool // 是否包含通配符子节点(:param 或 *catchAll)
|
||||
nType nodeType // 节点的类型(静态, 根, 参数, 捕获所有)
|
||||
priority uint32 // 节点的优先级, 用于查找时优先匹配
|
||||
children []*node // 子节点切片, 最多有一个 :param 风格的节点位于数组末尾
|
||||
handlers HandlersChain // 绑定到此节点的处理函数链
|
||||
fullPath string // 完整路径,用于调试和错误信息
|
||||
fullPath string // 完整路径, 用于调试和错误信息
|
||||
}
|
||||
|
||||
// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序。
|
||||
// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序.
|
||||
func (n *node) incrementChildPrio(pos int) int {
|
||||
cs := n.children // 获取子节点切片
|
||||
cs[pos].priority++ // 增加指定位置子节点的优先级
|
||||
prio := cs[pos].priority // 获取新的优先级
|
||||
|
||||
// 调整位置(向前移动)
|
||||
// 调整位置(向前移动)
|
||||
newPos := pos
|
||||
// 从当前位置向前遍历,如果前一个子节点的优先级小于当前子节点,则交换位置
|
||||
// 从当前位置向前遍历, 如果前一个子节点的优先级小于当前子节点, 则交换位置
|
||||
for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
|
||||
// 交换节点位置
|
||||
cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
|
||||
|
|
@ -158,9 +147,9 @@ func (n *node) incrementChildPrio(pos int) int {
|
|||
|
||||
// 构建新的索引字符字符串
|
||||
if newPos != pos {
|
||||
// 如果位置发生变化,则重新构建 indices 字符串
|
||||
// 如果位置发生变化, 则重新构建 indices 字符串
|
||||
// 前缀部分 + 移动的索引字符 + 剩余部分
|
||||
n.indices = n.indices[:newPos] + // 未改变的前缀,可能为空
|
||||
n.indices = n.indices[:newPos] + // 未改变的前缀, 可能为空
|
||||
n.indices[pos:pos+1] + // 被移动的索引字符
|
||||
n.indices[newPos:pos] + n.indices[pos+1:] // 除去原位置字符的其余部分
|
||||
}
|
||||
|
|
@ -168,13 +157,13 @@ func (n *node) incrementChildPrio(pos int) int {
|
|||
return newPos // 返回新的位置
|
||||
}
|
||||
|
||||
// addRoute 为给定路径添加一个带有处理函数的节点。
|
||||
// 非并发安全!
|
||||
// addRoute 为给定路径添加一个带有处理函数的节点.
|
||||
// 非并发安全!
|
||||
func (n *node) addRoute(path string, handlers HandlersChain) {
|
||||
fullPath := path // 记录完整的路径
|
||||
n.priority++ // 增加当前节点的优先级
|
||||
|
||||
// 如果是空树(根节点)
|
||||
// 如果是空树(根节点)
|
||||
if len(n.path) == 0 && len(n.children) == 0 {
|
||||
n.insertChild(path, fullPath, handlers) // 直接插入子节点
|
||||
n.nType = root // 设置为根节点类型
|
||||
|
|
@ -185,12 +174,12 @@ func (n *node) addRoute(path string, handlers HandlersChain) {
|
|||
|
||||
walk: // 外部循环用于遍历和构建路由树
|
||||
for {
|
||||
// 找到最长公共前缀。
|
||||
// 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符。
|
||||
// 找到最长公共前缀.
|
||||
// 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符.
|
||||
i := longestCommonPrefix(path, n.path)
|
||||
|
||||
// 分裂边 (Split edge)
|
||||
// 如果公共前缀小于当前节点的路径长度,说明当前节点需要被分裂
|
||||
// 如果公共前缀小于当前节点的路径长度, 说明当前节点需要被分裂
|
||||
if i < len(n.path) {
|
||||
child := node{
|
||||
path: n.path[i:], // 子节点路径是当前节点路径的剩余部分
|
||||
|
|
@ -199,27 +188,27 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
indices: n.indices, // 继承索引
|
||||
children: n.children, // 继承子节点
|
||||
handlers: n.handlers, // 继承处理函数
|
||||
priority: n.priority - 1, // 优先级减1,因为分裂会降低优先级
|
||||
priority: n.priority - 1, // 优先级减1, 因为分裂会降低优先级
|
||||
fullPath: n.fullPath, // 继承完整路径
|
||||
}
|
||||
|
||||
n.children = []*node{&child} // 当前节点现在只有一个子节点:新分裂出的子节点
|
||||
n.children = []*node{&child} // 当前节点现在只有一个子节点: 新分裂出的子节点
|
||||
// 将当前节点的 indices 设置为新子节点路径的第一个字符
|
||||
n.indices = BytesToString([]byte{n.path[i]}) // []byte 用于正确的 Unicode 字符转换
|
||||
n.path = path[:i] // 当前节点的路径更新为公共前缀
|
||||
n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了)
|
||||
n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了)
|
||||
n.wildChild = false // 当前节点不再是通配符子节点
|
||||
n.fullPath = fullPath[:parentFullPathIndex+i] // 更新完整路径
|
||||
}
|
||||
|
||||
// 将新节点作为当前节点的子节点
|
||||
// 如果路径仍然有剩余部分(即未完全匹配)
|
||||
// 如果路径仍然有剩余部分(即未完全匹配)
|
||||
if i < len(path) {
|
||||
path = path[i:] // 移除已匹配的前缀
|
||||
c := path[0] // 获取剩余路径的第一个字符
|
||||
|
||||
// '/' 在参数之后
|
||||
// 如果当前节点是参数类型,且剩余路径以 '/' 开头,并且只有一个子节点
|
||||
// 如果当前节点是参数类型, 且剩余路径以 '/' 开头, 并且只有一个子节点
|
||||
// 则继续遍历其唯一的子节点
|
||||
if n.nType == param && c == '/' && len(n.children) == 1 {
|
||||
parentFullPathIndex += len(n.path) // 更新父节点完整路径索引
|
||||
|
|
@ -238,8 +227,8 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
}
|
||||
}
|
||||
|
||||
// 否则,插入新节点
|
||||
// 如果第一个字符不是 ':' 也不是 '*',且当前节点不是 catchAll 类型
|
||||
// 否则, 插入新节点
|
||||
// 如果第一个字符不是 ':' 也不是 '*', 且当前节点不是 catchAll 类型
|
||||
if c != ':' && c != '*' && n.nType != catchAll {
|
||||
// 将新字符添加到索引字符串
|
||||
n.indices += BytesToString([]byte{c}) // []byte 用于正确的 Unicode 字符转换
|
||||
|
|
@ -250,18 +239,18 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
n.incrementChildPrio(len(n.indices) - 1) // 增加新子节点的优先级并重新排序
|
||||
n = child // 移动到新子节点
|
||||
} else if n.wildChild {
|
||||
// 正在插入一个通配符节点,需要检查是否与现有通配符冲突
|
||||
// 正在插入一个通配符节点, 需要检查是否与现有通配符冲突
|
||||
n = n.children[len(n.children)-1] // 移动到现有的通配符子节点
|
||||
n.priority++ // 增加其优先级
|
||||
|
||||
// 检查通配符是否匹配
|
||||
// 如果剩余路径长度大于等于通配符节点的路径长度,且通配符节点路径是剩余路径的前缀
|
||||
// 并且不是 catchAll 类型(不能有子路由),
|
||||
// 如果剩余路径长度大于等于通配符节点的路径长度, 且通配符节点路径是剩余路径的前缀
|
||||
// 并且不是 catchAll 类型(不能有子路由),
|
||||
// 并且通配符之后没有更多字符或紧跟着 '/'
|
||||
if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
|
||||
// 不能向 catchAll 添加子节点
|
||||
n.nType != catchAll &&
|
||||
// 检查更长的通配符,例如 :name 和 :names
|
||||
// 检查更长的通配符, 例如 :name 和 :names
|
||||
(len(n.path) >= len(path) || path[len(n.path)] == '/') {
|
||||
continue walk // 继续外部循环
|
||||
}
|
||||
|
|
@ -269,7 +258,7 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
// 通配符冲突
|
||||
pathSeg := path
|
||||
if n.nType != catchAll {
|
||||
pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll,则截取到下一个 '/'
|
||||
pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll, 则截取到下一个 '/'
|
||||
}
|
||||
prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path // 构造冲突前缀
|
||||
panic("'" + pathSeg + // 抛出 panic 表示通配符冲突
|
||||
|
|
@ -279,13 +268,13 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
"'")
|
||||
}
|
||||
|
||||
n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符)
|
||||
n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符)
|
||||
return // 完成添加路由
|
||||
}
|
||||
|
||||
// 否则,将处理函数添加到当前节点
|
||||
// 否则, 将处理函数添加到当前节点
|
||||
if n.handlers != nil {
|
||||
panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数,则报错
|
||||
panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数, 则报错
|
||||
}
|
||||
n.handlers = handlers // 设置处理函数
|
||||
n.fullPath = fullPath // 设置完整路径
|
||||
|
|
@ -293,20 +282,20 @@ walk: // 外部循环用于遍历和构建路由树
|
|||
}
|
||||
}
|
||||
|
||||
// findWildcard 搜索通配符段并检查名称是否包含无效字符。
|
||||
// 如果未找到通配符,则返回 -1 作为索引。
|
||||
// findWildcard 搜索通配符段并检查名称是否包含无效字符.
|
||||
// 如果未找到通配符, 则返回 -1 作为索引.
|
||||
func findWildcard(path string) (wildcard string, i int, valid bool) {
|
||||
// 查找开始位置
|
||||
escapeColon := false // 是否正在处理转义字符
|
||||
for start, c := range []byte(path) {
|
||||
if escapeColon {
|
||||
escapeColon = false
|
||||
if c == ':' { // 如果转义字符是 ':',则跳过
|
||||
if c == ':' { // 如果转义字符是 ':', 则跳过
|
||||
continue
|
||||
}
|
||||
panic("invalid escape string in path '" + path + "'") // 无效的转义字符串
|
||||
}
|
||||
if c == '\\' { // 如果是反斜杠,则设置转义标志
|
||||
if c == '\\' { // 如果是反斜杠, 则设置转义标志
|
||||
escapeColon = true
|
||||
continue
|
||||
}
|
||||
|
|
@ -319,36 +308,36 @@ func findWildcard(path string) (wildcard string, i int, valid bool) {
|
|||
valid = true // 默认为有效
|
||||
for end, c := range []byte(path[start+1:]) {
|
||||
switch c {
|
||||
case '/': // 如果遇到斜杠,说明通配符段结束
|
||||
case '/': // 如果遇到斜杠, 说明通配符段结束
|
||||
return path[start : start+1+end], start, valid
|
||||
case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*',则无效
|
||||
case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*', 则无效
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
return path[start:], start, valid // 返回找到的通配符、起始索引和有效性
|
||||
return path[start:], start, valid // 返回找到的通配符, 起始索引和有效性
|
||||
}
|
||||
return "", -1, false // 未找到通配符
|
||||
}
|
||||
|
||||
// insertChild 插入一个带有处理函数的节点。
|
||||
// 此函数处理包含通配符的路径插入逻辑。
|
||||
// insertChild 插入一个带有处理函数的节点.
|
||||
// 此函数处理包含通配符的路径插入逻辑.
|
||||
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
|
||||
for {
|
||||
// 找到第一个通配符之前的前缀
|
||||
wildcard, i, valid := findWildcard(path)
|
||||
if i < 0 { // 未找到通配符,结束循环
|
||||
if i < 0 { // 未找到通配符, 结束循环
|
||||
break
|
||||
}
|
||||
|
||||
// 通配符名称只能包含一个 ':' 或 '*' 字符
|
||||
if !valid {
|
||||
panic("only one wildcard per path segment is allowed, has: '" +
|
||||
wildcard + "' in path '" + fullPath + "'") // 报错:每个路径段只允许一个通配符
|
||||
wildcard + "' in path '" + fullPath + "'") // 报错: 每个路径段只允许一个通配符
|
||||
}
|
||||
|
||||
// 检查通配符是否有名称
|
||||
if len(wildcard) < 2 {
|
||||
panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错:通配符必须有非空名称
|
||||
panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错: 通配符必须有非空名称
|
||||
}
|
||||
|
||||
if wildcard[0] == ':' { // 如果是参数节点 (param)
|
||||
|
|
@ -368,7 +357,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
|
|||
n = child // 移动到新创建的参数节点
|
||||
n.priority++ // 增加优先级
|
||||
|
||||
// 如果路径不以通配符结束,则会有一个以 '/' 开头的子路径
|
||||
// 如果路径不以通配符结束, 则会有一个以 '/' 开头的子路径
|
||||
if len(wildcard) < len(path) {
|
||||
path = path[len(wildcard):] // 剩余路径去除通配符部分
|
||||
|
||||
|
|
@ -376,19 +365,19 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
|
|||
priority: 1, // 新子节点优先级
|
||||
fullPath: fullPath, // 设置子节点的完整路径
|
||||
}
|
||||
n.addChild(child) // 添加子节点(通常是斜杠后的静态部分)
|
||||
n.addChild(child) // 添加子节点(通常是斜杠后的静态部分)
|
||||
n = child // 移动到这个新子节点
|
||||
continue // 继续循环,查找下一个通配符或结束
|
||||
continue // 继续循环, 查找下一个通配符或结束
|
||||
}
|
||||
|
||||
// 否则,我们已经完成。将处理函数插入到新叶节点中
|
||||
// 否则, 我们已经完成. 将处理函数插入到新叶节点中
|
||||
n.handlers = handlers // 设置处理函数
|
||||
return // 完成
|
||||
}
|
||||
|
||||
// 如果是捕获所有节点 (catchAll)
|
||||
if i+len(wildcard) != len(path) {
|
||||
panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错:捕获所有路由只能在路径末尾
|
||||
panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错: 捕获所有路由只能在路径末尾
|
||||
}
|
||||
|
||||
// 检查路径段冲突
|
||||
|
|
@ -397,34 +386,34 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
|
|||
if len(n.children) != 0 {
|
||||
pathSeg, _, _ = strings.Cut(n.children[0].path, "/")
|
||||
}
|
||||
panic("catch-all wildcard '" + path + // 报错:捕获所有通配符与现有路径段冲突
|
||||
panic("catch-all wildcard '" + path + // 报错: 捕获所有通配符与现有路径段冲突
|
||||
"' in new path '" + fullPath +
|
||||
"' conflicts with existing path segment '" + pathSeg +
|
||||
"' in existing prefix '" + n.path + pathSeg +
|
||||
"'")
|
||||
}
|
||||
|
||||
// 当前固定宽度为 1,用于 '/'
|
||||
// 当前固定宽度为 1, 用于 '/'
|
||||
i--
|
||||
if i < 0 || path[i] != '/' {
|
||||
panic("no / before catch-all in path '" + fullPath + "'") // 报错:捕获所有之前没有 '/'
|
||||
panic("no / before catch-all in path '" + fullPath + "'") // 报错: 捕获所有之前没有 '/'
|
||||
}
|
||||
|
||||
n.path = path[:i] // 当前节点路径更新为 catchAll 之前的部分
|
||||
|
||||
// 第一个节点:路径为空的 catchAll 节点
|
||||
// 第一个节点: 路径为空的 catchAll 节点
|
||||
child := &node{
|
||||
wildChild: true, // 标记为有通配符子节点
|
||||
nType: catchAll, // 类型为 catchAll
|
||||
fullPath: fullPath, // 设置完整路径
|
||||
}
|
||||
|
||||
n.addChild(child) // 添加子节点
|
||||
n.indices = string('/') // 索引设置为 '/'
|
||||
n = child // 移动到新创建的 catchAll 节点
|
||||
n.priority++ // 增加优先级
|
||||
n.addChild(child) // 添加子节点
|
||||
n.indices = "/" // 索引设置为 '/'
|
||||
n = child // 移动到新创建的 catchAll 节点
|
||||
n.priority++ // 增加优先级
|
||||
|
||||
// 第二个节点:包含变量的节点
|
||||
// 第二个节点: 包含变量的节点
|
||||
child = &node{
|
||||
path: path[i:], // 路径为 catchAll 的实际路径段
|
||||
nType: catchAll, // 类型为 catchAll
|
||||
|
|
@ -437,7 +426,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
|
|||
return // 完成
|
||||
}
|
||||
|
||||
// 如果没有找到通配符,简单地插入路径和处理函数
|
||||
// 如果没有找到通配符, 简单地插入路径和处理函数
|
||||
n.path = path // 设置当前节点路径
|
||||
n.handlers = handlers // 设置处理函数
|
||||
n.fullPath = fullPath // 设置完整路径
|
||||
|
|
@ -451,16 +440,16 @@ type nodeValue struct {
|
|||
fullPath string // 匹配到的完整路径
|
||||
}
|
||||
|
||||
// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息,以便回溯。
|
||||
// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息, 以便回溯.
|
||||
type skippedNode struct {
|
||||
path string // 跳过时的当前路径
|
||||
node *node // 跳过的节点
|
||||
paramsCount int16 // 跳过时已收集的参数数量
|
||||
}
|
||||
|
||||
// getValue 返回注册到给定路径(key)的处理函数。通配符的值会保存到 map 中。
|
||||
// 如果找不到处理函数,则在存在一个带有额外(或不带)尾部斜杠的处理函数时,
|
||||
// 建议进行 TSR(尾部斜杠重定向)。
|
||||
// getValue 返回注册到给定路径(key)的处理函数. 通配符的值会保存到 map 中.
|
||||
// 如果找不到处理函数, 则在存在一个带有额外(或不带)尾部斜杠的处理函数时,
|
||||
// 建议进行 TSR(尾部斜杠重定向).
|
||||
func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
|
||||
var globalParamsCount int16 // 全局参数计数
|
||||
|
||||
|
|
@ -471,11 +460,16 @@ walk: // 外部循环用于遍历路由树
|
|||
if path[:len(prefix)] == prefix { // 如果路径以当前节点的前缀开头
|
||||
path = path[len(prefix):] // 移除已匹配的前缀
|
||||
|
||||
// 优先尝试所有非通配符子节点,通过匹配索引字符
|
||||
// 在访问 path[0] 之前进行安全检查
|
||||
if len(path) == 0 {
|
||||
continue walk
|
||||
}
|
||||
|
||||
// 优先尝试所有非通配符子节点, 通过匹配索引字符
|
||||
idxc := path[0] // 剩余路径的第一个字符
|
||||
for i, c := range []byte(n.indices) {
|
||||
if c == idxc { // 如果找到匹配的索引字符
|
||||
// 如果当前节点有通配符子节点,则将当前节点添加到 skippedNodes,以便回溯
|
||||
// 如果当前节点有通配符子节点, 则将当前节点添加到 skippedNodes, 以便回溯
|
||||
if n.wildChild {
|
||||
index := len(*skippedNodes)
|
||||
*skippedNodes = (*skippedNodes)[:index+1]
|
||||
|
|
@ -518,20 +512,20 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
|
||||
// 未找到。
|
||||
// 如果存在一个带有额外(或不带)尾部斜杠的处理函数,
|
||||
// 我们可以建议重定向到相同 URL,不带尾部斜杠。
|
||||
value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数,则建议 TSR
|
||||
// 未找到.
|
||||
// 如果存在一个带有额外(或不带)尾部斜杠的处理函数,
|
||||
// 我们可以建议重定向到相同 URL, 不带尾部斜杠.
|
||||
value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数, 则建议 TSR
|
||||
return value
|
||||
}
|
||||
|
||||
// 处理通配符子节点,它总是位于数组的末尾
|
||||
// 处理通配符子节点, 它总是位于数组的末尾
|
||||
n = n.children[len(n.children)-1] // 移动到通配符子节点
|
||||
globalParamsCount++ // 增加全局参数计数
|
||||
|
||||
switch n.nType {
|
||||
case param: // 参数节点
|
||||
// 查找参数结束位置('/' 或路径末尾)
|
||||
// 查找参数结束位置('/' 或路径末尾)
|
||||
end := 0
|
||||
for end < len(path) && path[end] != '/' {
|
||||
end++
|
||||
|
|
@ -539,7 +533,7 @@ walk: // 外部循环用于遍历路由树
|
|||
|
||||
// 保存参数值
|
||||
if params != nil {
|
||||
// 如果需要,预分配容量
|
||||
// 如果需要, 预分配容量
|
||||
if cap(*params) < int(globalParamsCount) {
|
||||
newParams := make(Params, len(*params), globalParamsCount)
|
||||
copy(newParams, *params)
|
||||
|
|
@ -559,12 +553,12 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
(*value.params)[i] = Param{ // 存储参数
|
||||
Key: n.path[1:], // 参数键名(去除冒号)
|
||||
Key: n.path[1:], // 参数键名(去除冒号)
|
||||
Value: val, // 参数值
|
||||
}
|
||||
}
|
||||
|
||||
// 我们需要继续深入!
|
||||
// 我们需要继续深入!
|
||||
if end < len(path) {
|
||||
if len(n.children) > 0 {
|
||||
path = path[end:] // 移除已提取的参数部分
|
||||
|
|
@ -573,16 +567,16 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
|
||||
// ... 但我们无法继续
|
||||
value.tsr = len(path) == end+1 // 如果路径只剩下斜杠,则建议 TSR
|
||||
value.tsr = len(path) == end+1 // 如果路径只剩下斜杠, 则建议 TSR
|
||||
return value
|
||||
}
|
||||
|
||||
if value.handlers = n.handlers; value.handlers != nil {
|
||||
value.fullPath = n.fullPath
|
||||
return value // 如果当前节点有处理函数,则返回
|
||||
return value // 如果当前节点有处理函数, 则返回
|
||||
}
|
||||
if len(n.children) == 1 {
|
||||
// 未找到处理函数。检查是否存在此路径加尾部斜杠的处理函数,以进行 TSR 建议
|
||||
// 未找到处理函数. 检查是否存在此路径加尾部斜杠的处理函数, 以进行 TSR 建议
|
||||
n = n.children[0]
|
||||
value.tsr = (n.path == "/" && n.handlers != nil) || (n.path == "" && n.indices == "/")
|
||||
}
|
||||
|
|
@ -591,7 +585,7 @@ walk: // 外部循环用于遍历路由树
|
|||
case catchAll: // 捕获所有节点
|
||||
// 保存参数值
|
||||
if params != nil {
|
||||
// 如果需要,预分配容量
|
||||
// 如果需要, 预分配容量
|
||||
if cap(*params) < int(globalParamsCount) {
|
||||
newParams := make(Params, len(*params), globalParamsCount)
|
||||
copy(newParams, *params)
|
||||
|
|
@ -611,7 +605,7 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
(*value.params)[i] = Param{ // 存储参数
|
||||
Key: n.path[2:], // 参数键名(去除星号)
|
||||
Key: n.path[2:], // 参数键名(去除星号)
|
||||
Value: val, // 参数值
|
||||
}
|
||||
}
|
||||
|
|
@ -627,7 +621,7 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
|
||||
if path == prefix { // 如果路径完全匹配当前节点的前缀
|
||||
// 如果当前路径不等于 '/' 且节点没有注册的处理函数,且最近匹配的节点有子节点
|
||||
// 如果当前路径不等于 '/' 且节点没有注册的处理函数, 且最近匹配的节点有子节点
|
||||
// 当前节点需要回溯到最后一个有效的 skippedNode
|
||||
if n.handlers == nil && path != "/" {
|
||||
for length := len(*skippedNodes); length > 0; length-- {
|
||||
|
|
@ -644,26 +638,26 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
}
|
||||
// 我们应该已经到达包含处理函数的节点。
|
||||
// 检查此节点是否注册了处理函数。
|
||||
// 我们应该已经到达包含处理函数的节点.
|
||||
// 检查此节点是否注册了处理函数.
|
||||
if value.handlers = n.handlers; value.handlers != nil {
|
||||
value.fullPath = n.fullPath
|
||||
return value // 如果有处理函数,则返回
|
||||
return value // 如果有处理函数, 则返回
|
||||
}
|
||||
|
||||
// 如果此路由没有处理函数,但此路由有通配符子节点,
|
||||
// 则此路径必须有一个带有额外尾部斜杠的处理函数。
|
||||
// 如果此路由没有处理函数, 但此路由有通配符子节点,
|
||||
// 则此路径必须有一个带有额外尾部斜杠的处理函数.
|
||||
if path == "/" && n.wildChild && n.nType != root {
|
||||
value.tsr = true // 建议 TSR
|
||||
return value
|
||||
}
|
||||
|
||||
if path == "/" && n.nType == static {
|
||||
value.tsr = true // 如果是静态节点且路径是根,则建议 TSR
|
||||
value.tsr = true // 如果是静态节点且路径是根, 则建议 TSR
|
||||
return value
|
||||
}
|
||||
|
||||
// 未找到处理函数。检查此路径加尾部斜杠是否存在处理函数,以进行尾部斜杠重定向建议
|
||||
// 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数, 以进行尾部斜杠重定向建议
|
||||
for i, c := range []byte(n.indices) {
|
||||
if c == '/' { // 如果索引中包含 '/'
|
||||
n = n.children[i] // 移动到对应的子节点
|
||||
|
|
@ -676,11 +670,11 @@ walk: // 外部循环用于遍历路由树
|
|||
return value
|
||||
}
|
||||
|
||||
// 未找到。我们可以建议重定向到相同 URL,添加一个额外的尾部斜杠,
|
||||
// 如果该路径的叶节点存在。
|
||||
// 未找到. 我们可以建议重定向到相同 URL, 添加一个额外的尾部斜杠,
|
||||
// 如果该路径的叶节点存在.
|
||||
value.tsr = path == "/" || // 如果路径是根路径
|
||||
(len(prefix) == len(path)+1 && prefix[len(path)] == '/' && // 或者前缀比路径多一个斜杠
|
||||
path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠,且有处理函数
|
||||
path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠, 且有处理函数
|
||||
|
||||
// 回溯到最后一个有效的 skippedNode
|
||||
if !value.tsr && path != "/" {
|
||||
|
|
@ -703,17 +697,17 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
|
||||
// findCaseInsensitivePath 对给定路径进行不区分大小写的查找,并尝试找到处理函数。
|
||||
// 它还可以选择修复尾部斜杠。
|
||||
// 它返回大小写校正后的路径和一个布尔值,指示查找是否成功。
|
||||
// findCaseInsensitivePath 对给定路径进行不区分大小写的查找, 并尝试找到处理函数.
|
||||
// 它还可以选择修复尾部斜杠.
|
||||
// 它返回大小写校正后的路径和一个布尔值, 指示查找是否成功.
|
||||
func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]byte, bool) {
|
||||
const stackBufSize = 128 // 栈上缓冲区的默认大小
|
||||
|
||||
// 在常见情况下使用栈上静态大小的缓冲区。
|
||||
// 如果路径太长,则在堆上分配缓冲区。
|
||||
// 在常见情况下使用栈上静态大小的缓冲区.
|
||||
// 如果路径太长, 则在堆上分配缓冲区.
|
||||
buf := make([]byte, 0, stackBufSize)
|
||||
if length := len(path) + 1; length > stackBufSize {
|
||||
buf = make([]byte, 0, length) // 如果路径太长,则分配更大的缓冲区
|
||||
buf = make([]byte, 0, length) // 如果路径太长, 则分配更大的缓冲区
|
||||
}
|
||||
|
||||
ciPath := n.findCaseInsensitivePathRec(
|
||||
|
|
@ -726,7 +720,7 @@ func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]by
|
|||
return ciPath, ciPath != nil // 返回校正后的路径和是否成功找到
|
||||
}
|
||||
|
||||
// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节。
|
||||
// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节.
|
||||
func shiftNRuneBytes(rb [4]byte, n int) [4]byte {
|
||||
switch n {
|
||||
case 0:
|
||||
|
|
@ -742,12 +736,12 @@ func shiftNRuneBytes(rb [4]byte, n int) [4]byte {
|
|||
}
|
||||
}
|
||||
|
||||
// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数。
|
||||
// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数.
|
||||
func (n *node) findCaseInsensitivePathRec(path string, ciPath []byte, rb [4]byte, fixTrailingSlash bool) []byte {
|
||||
npLen := len(n.path) // 当前节点的路径长度
|
||||
|
||||
walk: // 外部循环用于遍历路由树
|
||||
// 只要剩余路径长度大于等于当前节点路径长度,且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径
|
||||
// 只要剩余路径长度大于等于当前节点路径长度, 且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径
|
||||
for len(path) >= npLen && (npLen == 0 || strings.EqualFold(path[1:npLen], n.path[1:])) {
|
||||
// 将公共前缀添加到结果中
|
||||
oldPath := path // 保存原始路径
|
||||
|
|
@ -755,13 +749,13 @@ walk: // 外部循环用于遍历路由树
|
|||
ciPath = append(ciPath, n.path...) // 将当前节点的路径添加到不区分大小写路径中
|
||||
|
||||
if len(path) == 0 { // 如果路径已完全匹配
|
||||
// 我们应该已经到达包含处理函数的节点。
|
||||
// 检查此节点是否注册了处理函数。
|
||||
// 我们应该已经到达包含处理函数的节点.
|
||||
// 检查此节点是否注册了处理函数.
|
||||
if n.handlers != nil {
|
||||
return ciPath // 如果有处理函数,则返回校正后的路径
|
||||
return ciPath // 如果有处理函数, 则返回校正后的路径
|
||||
}
|
||||
|
||||
// 未找到处理函数。
|
||||
// 未找到处理函数.
|
||||
// 尝试通过添加尾部斜杠来修复路径
|
||||
if fixTrailingSlash {
|
||||
for i, c := range []byte(n.indices) {
|
||||
|
|
@ -775,11 +769,11 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
}
|
||||
return nil // 未找到,返回 nil
|
||||
return nil // 未找到, 返回 nil
|
||||
}
|
||||
|
||||
// 如果此节点没有通配符(参数或捕获所有)子节点,
|
||||
// 我们可以直接查找下一个子节点并继续遍历树。
|
||||
// 如果此节点没有通配符(参数或捕获所有)子节点,
|
||||
// 我们可以直接查找下一个子节点并继续遍历树.
|
||||
if !n.wildChild {
|
||||
// 跳过已处理的 rune 字节
|
||||
rb = shiftNRuneBytes(rb, npLen)
|
||||
|
|
@ -799,9 +793,9 @@ walk: // 外部循环用于遍历路由树
|
|||
// 处理一个新的 rune
|
||||
var rv rune
|
||||
|
||||
// 查找 rune 的开始位置。
|
||||
// Runes 最长为 4 字节。
|
||||
// -4 肯定会是另一个 rune。
|
||||
// 查找 rune 的开始位置.
|
||||
// Runes 最长为 4 字节.
|
||||
// -4 肯定会是另一个 rune.
|
||||
var off int
|
||||
for max_ := min(npLen, 3); off < max_; off++ {
|
||||
if i := npLen - off; utf8.RuneStart(oldPath[i]) {
|
||||
|
|
@ -822,17 +816,17 @@ walk: // 外部循环用于遍历路由树
|
|||
for i, c := range []byte(n.indices) {
|
||||
// 小写匹配
|
||||
if c == idxc {
|
||||
// 必须使用递归方法,因为大写字节和小写字节都可能作为索引存在
|
||||
// 必须使用递归方法, 因为大写字节和小写字节都可能作为索引存在
|
||||
if out := n.children[i].findCaseInsensitivePathRec(
|
||||
path, ciPath, rb, fixTrailingSlash,
|
||||
); out != nil {
|
||||
return out // 如果找到,则返回
|
||||
return out // 如果找到, 则返回
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未找到匹配项,则对大写 rune 执行相同操作(如果它不同)
|
||||
// 如果未找到匹配项, 则对大写 rune 执行相同操作(如果它不同)
|
||||
if up := unicode.ToUpper(rv); up != lo {
|
||||
utf8.EncodeRune(rb[:], up) // 将大写 rune 编码到缓冲区
|
||||
rb = shiftNRuneBytes(rb, off)
|
||||
|
|
@ -850,18 +844,18 @@ walk: // 外部循环用于遍历路由树
|
|||
}
|
||||
}
|
||||
|
||||
// 未找到。我们可以建议重定向到相同 URL,不带尾部斜杠,
|
||||
// 如果该路径的叶节点存在。
|
||||
// 未找到. 我们可以建议重定向到相同 URL, 不带尾部斜杠,
|
||||
// 如果该路径的叶节点存在.
|
||||
if fixTrailingSlash && path == "/" && n.handlers != nil {
|
||||
return ciPath // 如果可以修复尾部斜杠且有处理函数,则返回
|
||||
return ciPath // 如果可以修复尾部斜杠且有处理函数, 则返回
|
||||
}
|
||||
return nil // 未找到,返回 nil
|
||||
return nil // 未找到, 返回 nil
|
||||
}
|
||||
|
||||
n = n.children[0] // 移动到通配符子节点(通常是唯一一个)
|
||||
n = n.children[0] // 移动到通配符子节点(通常是唯一一个)
|
||||
switch n.nType {
|
||||
case param: // 参数节点
|
||||
// 查找参数结束位置('/' 或路径末尾)
|
||||
// 查找参数结束位置('/' 或路径末尾)
|
||||
end := 0
|
||||
for end < len(path) && path[end] != '/' {
|
||||
end++
|
||||
|
|
@ -870,7 +864,7 @@ walk: // 外部循环用于遍历路由树
|
|||
// 将参数值添加到不区分大小写路径中
|
||||
ciPath = append(ciPath, path[:end]...)
|
||||
|
||||
// 我们需要继续深入!
|
||||
// 我们需要继续深入!
|
||||
if end < len(path) {
|
||||
if len(n.children) > 0 {
|
||||
// 继续处理子节点
|
||||
|
|
@ -882,45 +876,45 @@ walk: // 外部循环用于遍历路由树
|
|||
|
||||
// ... 但我们无法继续
|
||||
if fixTrailingSlash && len(path) == end+1 {
|
||||
return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠,则返回
|
||||
return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠, 则返回
|
||||
}
|
||||
return nil // 未找到,返回 nil
|
||||
return nil // 未找到, 返回 nil
|
||||
}
|
||||
|
||||
if n.handlers != nil {
|
||||
return ciPath // 如果有处理函数,则返回
|
||||
return ciPath // 如果有处理函数, 则返回
|
||||
}
|
||||
|
||||
if fixTrailingSlash && len(n.children) == 1 {
|
||||
// 未找到处理函数。检查此路径加尾部斜杠是否存在处理函数
|
||||
// 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数
|
||||
n = n.children[0]
|
||||
if n.path == "/" && n.handlers != nil {
|
||||
return append(ciPath, '/') // 返回添加斜杠后的路径
|
||||
}
|
||||
}
|
||||
|
||||
return nil // 未找到,返回 nil
|
||||
return nil // 未找到, 返回 nil
|
||||
|
||||
case catchAll: // 捕获所有节点
|
||||
return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有)
|
||||
return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有)
|
||||
|
||||
default:
|
||||
panic("invalid node type") // 无效的节点类型
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到。
|
||||
// 未找到.
|
||||
// 尝试通过添加/删除尾部斜杠来修复路径
|
||||
if fixTrailingSlash {
|
||||
if path == "/" {
|
||||
return ciPath // 如果路径是根路径,则返回
|
||||
return ciPath // 如果路径是根路径, 则返回
|
||||
}
|
||||
// 如果路径长度比当前节点路径少一个斜杠,且末尾是斜杠,
|
||||
// 且不区分大小写匹配,且当前节点有处理函数
|
||||
// 如果路径长度比当前节点路径少一个斜杠, 且末尾是斜杠,
|
||||
// 且不区分大小写匹配, 且当前节点有处理函数
|
||||
if len(path)+1 == npLen && n.path[len(path)] == '/' &&
|
||||
strings.EqualFold(path[1:], n.path[1:len(path)]) && n.handlers != nil {
|
||||
return append(ciPath, n.path...) // 返回添加当前节点路径后的路径
|
||||
}
|
||||
}
|
||||
return nil // 未找到,返回 nil
|
||||
return nil // 未找到, 返回 nil
|
||||
}
|
||||
|
|
|
|||
57
tree_test.go
57
tree_test.go
|
|
@ -159,6 +159,7 @@ func TestTreeWildcard(t *testing.T) {
|
|||
"/doc/go1.html",
|
||||
"/info/:user/public",
|
||||
"/info/:user/project/:project",
|
||||
"/info/:user/project/:project/*filepath",
|
||||
"/info/:user/project/golang",
|
||||
"/aa/*xx",
|
||||
"/ab/*xx",
|
||||
|
|
@ -226,6 +227,7 @@ func TestTreeWildcard(t *testing.T) {
|
|||
{"/info/gordon/public", false, "/info/:user/public", Params{Param{Key: "user", Value: "gordon"}}},
|
||||
{"/info/gordon/project/go", false, "/info/:user/project/:project", Params{Param{Key: "user", Value: "gordon"}, Param{Key: "project", Value: "go"}}},
|
||||
{"/info/gordon/project/golang", false, "/info/:user/project/golang", Params{Param{Key: "user", Value: "gordon"}}},
|
||||
{"/info/gordon/project/go/src/file.go", false, "/info/:user/project/:project/*filepath", Params{Param{Key: "user", Value: "gordon"}, Param{Key: "project", Value: "go"}, Param{Key: "filepath", Value: "/src/file.go"}}},
|
||||
{"/aa/aa", false, "/aa/*xx", Params{Param{Key: "xx", Value: "/aa"}}},
|
||||
{"/ab/ab", false, "/ab/*xx", Params{Param{Key: "xx", Value: "/ab"}}},
|
||||
{"/a", false, "/:cc", Params{Param{Key: "cc", Value: "a"}}},
|
||||
|
|
@ -1019,3 +1021,58 @@ func TestWildcardInvalidSlash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplexBacktrackingWithCatchAll 是一个更复杂的回归测试.
|
||||
// 它确保在静态路径匹配失败后, 路由器能够正确地回溯并成功匹配一个
|
||||
// 包含多个命名参数、静态部分和捕获所有参数的复杂路由.
|
||||
// 这个测试对于验证在禁用 RedirectTrailingSlash 时的算法健壮性至关重要.
|
||||
func TestComplexBacktrackingWithCatchAll(t *testing.T) {
|
||||
// 1. Arrange: 初始化路由树并设置复杂的路由结构
|
||||
tree := &node{}
|
||||
routes := [...]string{
|
||||
"/abc/b", // 静态诱饵路由
|
||||
"/abc/:p1/cde", // 一个不相关的、不会被匹配到的干扰路由
|
||||
"/abc/:p1/:p2/def/*filepath", // 最终应该匹配到的复杂目标路由
|
||||
}
|
||||
for _, route := range routes {
|
||||
tree.addRoute(route, fakeHandler(route))
|
||||
}
|
||||
|
||||
// 2. Act: 执行一个会触发深度回溯的请求
|
||||
// 这个路径会首先尝试匹配静态的 /abc/b, 但因为后续路径不匹配而失败,
|
||||
// 从而强制回溯到 /abc/ 节点, 并重新尝试匹配通配符路径.
|
||||
reqPath := "/abc/b/d/def/some/file.txt"
|
||||
wantRoute := "/abc/:p1/:p2/def/*filepath"
|
||||
wantParams := Params{
|
||||
{Key: "p1", Value: "b"},
|
||||
{Key: "p2", Value: "d"},
|
||||
{Key: "filepath", Value: "/some/file.txt"}, // 注意: catch-all 会包含前导斜杠
|
||||
}
|
||||
|
||||
// 使用 defer/recover 来断言整个过程不会发生 panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("预期不应发生 panic, 但在处理路径 '%s' 时捕获到了: %v", reqPath, r)
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行查找操作
|
||||
value := tree.getValue(reqPath, getParams(), getSkippedNodes(), false)
|
||||
|
||||
// 3. Assert: 验证回溯后的结果是否正确
|
||||
// 断言找到了一个有效的句柄
|
||||
if value.handlers == nil {
|
||||
t.Fatalf("处理路径 '%s' 时句柄不匹配: 期望得到非空的句柄, 但实际为 nil", reqPath)
|
||||
}
|
||||
|
||||
// 断言匹配到了正确的路由
|
||||
value.handlers[0](nil)
|
||||
if fakeHandlerValue != wantRoute {
|
||||
t.Errorf("处理路径 '%s' 时句柄不匹配: \n 得到: %s\n 想要: %s", reqPath, fakeHandlerValue, wantRoute)
|
||||
}
|
||||
|
||||
// 断言URL参数被正确地解析和提取
|
||||
if value.params == nil || !reflect.DeepEqual(*value.params, wantParams) {
|
||||
t.Errorf("处理路径 '%s' 时参数不匹配: \n 得到: %v\n 想要: %v", reqPath, *value.params, wantParams)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue