From 1f0724af94a28ef14647ca1bc778c4e4c862b6db Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:35:12 +0800 Subject: [PATCH 01/24] fix cfdt --- {license => licenses}/httprouter-license | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {license => licenses}/httprouter-license (100%) diff --git a/license/httprouter-license b/licenses/httprouter-license similarity index 100% rename from license/httprouter-license rename to licenses/httprouter-license From 3e7656691769c3f07c8680ab78ab93c46f9cd162 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sat, 26 Jul 2025 18:51:30 +0800 Subject: [PATCH 02/24] update about --- README.md | 186 +++------------- about-touka.md | 577 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 609 insertions(+), 154 deletions(-) create mode 100644 about-touka.md diff --git a/README.md b/README.md index 615b090..3ab971f 100644 --- a/README.md +++ b/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 参考 -* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南 - -* [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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。 diff --git a/about-touka.md b/about-touka.md new file mode 100644 index 0000000..86a056f --- /dev/null +++ b/about-touka.md @@ -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 +//

{{ .title }}

+//

Welcome, {{ .user }}!

+``` + +##### 文件和流式响应 + +```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 生态中大量现有的、遵循标准接口的第三方中间件和工具。 From c9b8e966c44311a5015c9d2b86fee83b60bfaeba Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:34:46 +0800 Subject: [PATCH 03/24] remove too much log print --- engine.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/engine.go b/engine.go index 32fd3f1..581258c 100644 --- a/engine.go +++ b/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 } From 895cd6222bac8130f0555e0de6b5dac663c53dfd Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:01:18 +0800 Subject: [PATCH 04/24] update deps --- go.mod | 7 +++---- go.sum | 10 ++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 6b14b15..d8697d3 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,15 @@ module github.com/infinite-iroha/touka -go 1.24.4 +go 1.24.5 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 - github.com/WJQSERVER-STUDIO/httpc v0.8.1 + github.com/WJQSERVER-STUDIO/httpc v0.8.2 github.com/fenthope/reco v0.0.3 - github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 ) 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 ) diff --git a/go.sum b/go.sum index 0302b36..636fbe3 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,11 @@ -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/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/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/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 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= From 3cd8ef84a21e9c016f8ad747137f415befc9e9f1 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:18:24 +0800 Subject: [PATCH 05/24] add RunShutdownWithContext --- go.mod | 2 +- go.sum | 2 ++ serve.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d8697d3..e9d0304 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.5 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 - github.com/fenthope/reco v0.0.3 + github.com/fenthope/reco v0.0.4 github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 ) diff --git a/go.sum b/go.sum index 636fbe3..4675a6b 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/ github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4= github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y= +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-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/serve.go b/serve.go index d73efe9..7e05b8c 100644 --- a/serve.go +++ b/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 { From e43b12e34322b0f8834265cb9bafbafd880dc421 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:49:53 +0000 Subject: [PATCH 06/24] fix: correct shallow copy in router backtracking The router could panic with a 'slice bounds out of range' error when handling requests that trigger its backtracking logic. The root cause was a shallow copy of the node's `children` slice when creating a `skippedNode` for backtracking. This could lead to a corrupted state if the router needed to backtrack and then proceed down a wildcard path. This commit fixes the issue by introducing a `copyChildren` method on the `node` struct, which creates a safe copy of the children slice. This method is now used when creating a `skippedNode`, ensuring that the backtracking logic is isolated and robust. --- tree.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tree.go b/tree.go index 6f99223..1082629 100644 --- a/tree.go +++ b/tree.go @@ -293,6 +293,12 @@ walk: // 外部循环用于遍历和构建路由树 } } +func (n *node) copyChildren() []*node { + children := make([]*node, len(n.children)) + copy(children, n.children) + return children +} + // findWildcard 搜索通配符段并检查名称是否包含无效字符。 // 如果未找到通配符,则返回 -1 作为索引。 func findWildcard(path string) (wildcard string, i int, valid bool) { @@ -486,7 +492,7 @@ walk: // 外部循环用于遍历路由树 wildChild: n.wildChild, nType: n.nType, priority: n.priority, - children: n.children, + children: n.copyChildren(), handlers: n.handlers, fullPath: n.fullPath, }, From 99b48371b3e02e5cf4578c6d586463f5844b2178 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:05:00 +0800 Subject: [PATCH 07/24] update test --- tree_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tree_test.go b/tree_test.go index dc0bb60..635272c 100644 --- a/tree_test.go +++ b/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"}}}, From 295852e1a1864e33d636f6157e6b5622d3ca7336 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:05:09 +0800 Subject: [PATCH 08/24] update reqip --- context.go | 69 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index fa689a1..14edc53 100644 --- a/context.go +++ b/context.go @@ -14,7 +14,6 @@ import ( "io" "math" "mime" - "net" "net/http" "net/netip" "net/url" @@ -523,39 +522,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.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 "" } From 783370fd7918b3416f3e3ed967ce479e3d8790e7 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:09:46 +0800 Subject: [PATCH 09/24] update --- tree.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tree.go b/tree.go index 1082629..f78f1d1 100644 --- a/tree.go +++ b/tree.go @@ -489,6 +489,7 @@ walk: // 外部循环用于遍历路由树 path: prefix + path, // 记录跳过的路径 node: &node{ // 复制当前节点的状态 path: n.path, + indices: n.indices, wildChild: n.wildChild, nType: n.nType, priority: n.priority, From 74f5770b4280af28f77ed82a47d882b0688148fa Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:21:32 +0800 Subject: [PATCH 10/24] update tree --- go.sum | 2 - tree.go | 349 +++++++++++++++++++++++++-------------------------- tree_test.go | 55 ++++++++ 3 files changed, 228 insertions(+), 178 deletions(-) diff --git a/go.sum b/go.sum index 4675a6b..d9a63e3 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= 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/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4= -github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y= 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-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= diff --git a/tree.go b/tree.go index f78f1d1..09711a1 100644 --- a/tree.go +++ b/tree.go @@ -2,51 +2,50 @@ // 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 包,用于不安全的类型转换,以避免内存分配 + "bytes" + "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("/") // 定义字节切片常量,表示斜杠,用于路径分隔符识别 + 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 +55,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 +82,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,19 +92,19 @@ 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) // 将路径字符串转换为字节切片 @@ -114,43 +113,43 @@ func countParams(path string) uint16 { return n } -// countSections 计算路径中斜杠('/')的数量,即路径段的数量。 +// countSections 计算路径中斜杠('/')的数量, 即路径段的数量. func countSections(path string) uint16 { s := StringToBytes(path) // 将路径字符串转换为字节切片 return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量 } -// 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 +157,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 +167,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 +184,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 +198,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 +237,8 @@ walk: // 外部循环用于遍历和构建路由树 } } - // 否则,插入新节点 - // 如果第一个字符不是 ':' 也不是 '*',且当前节点不是 catchAll 类型 + // 否则, 插入新节点 + // 如果第一个字符不是 ':' 也不是 '*', 且当前节点不是 catchAll 类型 if c != ':' && c != '*' && n.nType != catchAll { // 将新字符添加到索引字符串 n.indices += BytesToString([]byte{c}) // []byte 用于正确的 Unicode 字符转换 @@ -250,18 +249,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 +268,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 +278,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,26 +292,20 @@ walk: // 外部循环用于遍历和构建路由树 } } -func (n *node) copyChildren() []*node { - children := make([]*node, len(n.children)) - copy(children, n.children) - return children -} - -// 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 } @@ -325,36 +318,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) @@ -374,7 +367,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) n = child // 移动到新创建的参数节点 n.priority++ // 增加优先级 - // 如果路径不以通配符结束,则会有一个以 '/' 开头的子路径 + // 如果路径不以通配符结束, 则会有一个以 '/' 开头的子路径 if len(wildcard) < len(path) { path = path[len(wildcard):] // 剩余路径去除通配符部分 @@ -382,19 +375,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 + "'") // 报错: 捕获所有路由只能在路径末尾 } // 检查路径段冲突 @@ -403,22 +396,22 @@ 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 @@ -430,7 +423,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) n = child // 移动到新创建的 catchAll 节点 n.priority++ // 增加优先级 - // 第二个节点:包含变量的节点 + // 第二个节点: 包含变量的节点 child = &node{ path: path[i:], // 路径为 catchAll 的实际路径段 nType: catchAll, // 类型为 catchAll @@ -443,7 +436,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) return // 完成 } - // 如果没有找到通配符,简单地插入路径和处理函数 + // 如果没有找到通配符, 简单地插入路径和处理函数 n.path = path // 设置当前节点路径 n.handlers = handlers // 设置处理函数 n.fullPath = fullPath // 设置完整路径 @@ -457,16 +450,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 // 全局参数计数 @@ -477,11 +470,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] @@ -489,11 +487,10 @@ walk: // 外部循环用于遍历路由树 path: prefix + path, // 记录跳过的路径 node: &node{ // 复制当前节点的状态 path: n.path, - indices: n.indices, wildChild: n.wildChild, nType: n.nType, priority: n.priority, - children: n.copyChildren(), + children: n.children, handlers: n.handlers, fullPath: n.fullPath, }, @@ -525,20 +522,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++ @@ -546,7 +543,7 @@ walk: // 外部循环用于遍历路由树 // 保存参数值 if params != nil { - // 如果需要,预分配容量 + // 如果需要, 预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -566,12 +563,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:] // 移除已提取的参数部分 @@ -580,16 +577,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 == "/") } @@ -598,7 +595,7 @@ walk: // 外部循环用于遍历路由树 case catchAll: // 捕获所有节点 // 保存参数值 if params != nil { - // 如果需要,预分配容量 + // 如果需要, 预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -618,7 +615,7 @@ walk: // 外部循环用于遍历路由树 } } (*value.params)[i] = Param{ // 存储参数 - Key: n.path[2:], // 参数键名(去除星号) + Key: n.path[2:], // 参数键名(去除星号) Value: val, // 参数值 } } @@ -634,7 +631,7 @@ walk: // 外部循环用于遍历路由树 } if path == prefix { // 如果路径完全匹配当前节点的前缀 - // 如果当前路径不等于 '/' 且节点没有注册的处理函数,且最近匹配的节点有子节点 + // 如果当前路径不等于 '/' 且节点没有注册的处理函数, 且最近匹配的节点有子节点 // 当前节点需要回溯到最后一个有效的 skippedNode if n.handlers == nil && path != "/" { for length := len(*skippedNodes); length > 0; length-- { @@ -651,26 +648,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] // 移动到对应的子节点 @@ -683,11 +680,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 != "/" { @@ -710,17 +707,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( @@ -733,7 +730,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: @@ -749,12 +746,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 // 保存原始路径 @@ -762,13 +759,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) { @@ -782,11 +779,11 @@ walk: // 外部循环用于遍历路由树 } } } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil } - // 如果此节点没有通配符(参数或捕获所有)子节点, - // 我们可以直接查找下一个子节点并继续遍历树。 + // 如果此节点没有通配符(参数或捕获所有)子节点, + // 我们可以直接查找下一个子节点并继续遍历树. if !n.wildChild { // 跳过已处理的 rune 字节 rb = shiftNRuneBytes(rb, npLen) @@ -806,9 +803,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]) { @@ -829,17 +826,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) @@ -857,18 +854,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++ @@ -877,7 +874,7 @@ walk: // 外部循环用于遍历路由树 // 将参数值添加到不区分大小写路径中 ciPath = append(ciPath, path[:end]...) - // 我们需要继续深入! + // 我们需要继续深入! if end < len(path) { if len(n.children) > 0 { // 继续处理子节点 @@ -889,45 +886,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 } diff --git a/tree_test.go b/tree_test.go index 635272c..d3ffdfa 100644 --- a/tree_test.go +++ b/tree_test.go @@ -1021,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) + } +} From 3590a77f904e1226a7ce7b78c3e6a853283b791f Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:23:49 +0800 Subject: [PATCH 11/24] fix reqip val --- context.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context.go b/context.go index 14edc53..c1e2bb8 100644 --- a/context.go +++ b/context.go @@ -564,7 +564,7 @@ func (c *Context) RequestIP() string { addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr) if err == nil { // 成功从 "ip:port" 格式中解析出 IP - return addrp.String() + return addrp.Addr().String() } // 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口), From 3ffde5742ce5201369cec388843c38f1396d1727 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:50:26 +0800 Subject: [PATCH 12/24] add wanf --- context.go | 61 ++++++++++++++++++++++++++++++++++++++------------- fileserver.go | 12 ++++++---- go.mod | 5 +++-- go.sum | 10 +++++---- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index c1e2bb8..b6fbd46 100644 --- a/context.go +++ b/context.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/WJQSERVER/wanf" "github.com/fenthope/reco" "github.com/go-json-experiment/json" @@ -42,7 +43,7 @@ type Context struct { index int8 // 当前执行到处理链的哪个位置 mu sync.RWMutex - Keys map[string]interface{} // 用于在中间件之间传递数据 + Keys map[string]any // 用于在中间件之间传递数据 Errors []error // 用于收集处理过程中的错误 @@ -77,20 +78,18 @@ 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.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 实例的成员 } // Next 在处理链中执行下一个处理函数 @@ -122,10 +121,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() // 解写锁 @@ -133,7 +132,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() // 解读锁 @@ -208,7 +207,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 } @@ -269,7 +268,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...))) } @@ -283,7 +282,7 @@ func (c *Context) Text(code int, text string) { // 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 { @@ -295,7 +294,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 编码 @@ -307,11 +306,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) @@ -342,7 +355,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") } @@ -353,10 +366,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 等 // 例如: @@ -409,7 +440,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 diff --git a/fileserver.go b/fileserver.go index 5b7f248..197b681 100644 --- a/fileserver.go +++ b/fileserver.go @@ -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() diff --git a/go.mod b/go.mod index e9d0304..f1c81da 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.24.5 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 + github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 + github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.42.0 // indirect + golang.org/x/net v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index d9a63e3..dcd4f26 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,13 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= 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.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= +github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/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-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= +github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= From af0a99acdaefb63da6cf9a61c7e3b0586d550c6d Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sat, 6 Sep 2025 17:55:45 +0000 Subject: [PATCH 13/24] add sse intn support --- go.mod | 2 +- sse.go | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 sse.go diff --git a/go.mod b/go.mod index f1c81da..0b8d97b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/infinite-iroha/touka -go 1.24.5 +go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 diff --git a/sse.go b/sse.go new file mode 100644 index 0000000..b856a08 --- /dev/null +++ b/sse.go @@ -0,0 +1,191 @@ +// 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 2024 WJQSERVER. All rights reserved. +// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization. +package touka + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" +) + +// Event 代表一个服务器发送事件(SSE). +type Event struct { + // Event 是事件的名称. + Event string + // Data 是事件的内容, 可以是多行文本. + Data string + // Id 是事件的唯一标识符. + Id string + // Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接. + Retry string +} + +// Render 将事件格式化并写入给定的 writer. +// 为了性能, 它使用 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") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") + return + } + + c.Writer.WriteHeader(http.StatusOK) + flusher.Flush() + + for { + select { + case <-c.Request.Context().Done(): + return + default: + if !streamer(c.Writer) { + return + } + flusher.Flush() + } + } +} + +// EventStreamChan 返回用于 SSE 事件流的 channel. +// 这是为高级并发场景设计的、更灵活的API. +// 调用者必须负责关闭 event channel 并处理 error channel 以避免 goroutine 泄漏. +// +// 详细用法: +// +// 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++ { +// event := touka.Event{ +// Id: fmt.Sprintf("%d", i), +// Data: "hello from channel", +// } +// eventChan <- event +// 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") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + err := fmt.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") + c.Errorf(err.Error()) + errChan <- err + close(errChan) + close(eventChan) + return eventChan, errChan + } + + c.Writer.WriteHeader(http.StatusOK) + flusher.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 + } + flusher.Flush() + case <-c.Request.Context().Done(): + errChan <- c.Request.Context().Err() + return + } + } + }() + + return eventChan, errChan +} From b4e073ae2f179cb93820fb95fde7fabcafe50d2c Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 7 Sep 2025 02:24:28 +0800 Subject: [PATCH 14/24] Update sse.go --- sse.go | 134 +++++++++++++++++++++++++++------------------------------ 1 file changed, 63 insertions(+), 71 deletions(-) diff --git a/sse.go b/sse.go index b856a08..597e930 100644 --- a/sse.go +++ b/sse.go @@ -1,6 +1,6 @@ // 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 2024 WJQSERVER. All rights reserved. +// Copyright 2025 WJQSERVER. All rights reserved. // All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization. package touka @@ -25,6 +25,7 @@ type Event struct { } // Render 将事件格式化并写入给定的 writer. +// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题. // 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配. func (e *Event) Render(w io.Writer) error { var buf bytes.Buffer @@ -65,40 +66,34 @@ func (e *Event) Render(w io.Writer) error { // 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. // // 详细用法: +// 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), +// } // -// 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 结束事件流. +// } // -// 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") -// }) +// 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") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") - c.Writer.Header().Set("Transfer-Encoding", "chunked") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - c.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") - return - } + // 为现代网络协议优化头部. + 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) - flusher.Flush() + c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在. for { select { @@ -108,63 +103,60 @@ func (c *Context) EventStream(streamer func(w io.Writer) bool) { if !streamer(c.Writer) { return } - flusher.Flush() + c.Writer.Flush() } } } // EventStreamChan 返回用于 SSE 事件流的 channel. // 这是为高级并发场景设计的、更灵活的API. -// 调用者必须负责关闭 event channel 并处理 error channel 以避免 goroutine 泄漏. +// +// 重要: +// - 调用者必须 close(eventChan) 来结束事件流. +// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. +// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). // // 详细用法: +// r.GET("/sse/channel", func(c *touka.Context) { +// eventChan, errChan := c.EventStreamChan() // -// 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() { -// if err := <-errChan; err != nil { -// c.Errorf("SSE channel error: %v", err) -// } -// }() +// // 在另一个goroutine中异步发送事件. +// go func() { +// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. +// defer close(eventChan) // -// // 在另一个goroutine中异步发送事件. -// go func() { -// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. -// defer close(eventChan) -// -// for i := 1; i <= 5; i++ { -// event := touka.Event{ -// Id: fmt.Sprintf("%d", i), -// Data: "hello from channel", -// } -// eventChan <- event -// time.Sleep(2 * time.Second) -// } -// }() -// }) +// 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") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") - c.Writer.Header().Set("Transfer-Encoding", "chunked") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - err := fmt.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") - c.Errorf(err.Error()) - errChan <- err - close(errChan) - close(eventChan) - return eventChan, errChan - } + 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) - flusher.Flush() + c.Writer.Flush() go func() { defer close(errChan) @@ -179,7 +171,7 @@ func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { errChan <- err return } - flusher.Flush() + c.Writer.Flush() case <-c.Request.Context().Done(): errChan <- c.Request.Context().Err() return From 38ff5126e326f0a19065f2507737cc8f70e3cfc2 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Wed, 10 Sep 2025 02:40:41 +0800 Subject: [PATCH 15/24] fix --- sse.go | 97 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/sse.go b/sse.go index 597e930..3b98800 100644 --- a/sse.go +++ b/sse.go @@ -6,7 +6,6 @@ package touka import ( "bytes" - "fmt" "io" "net/http" "strings" @@ -66,25 +65,26 @@ func (e *Event) Render(w io.Writer) error { // 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. // // 详细用法: -// 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 结束事件流. -// } +// 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), +// } // -// time.Sleep(2 * time.Second) -// return true // 返回 true 继续事件流. -// }) -// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. -// fmt.Println("Client disconnected from /sse/callback") -// }) +// 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") @@ -112,40 +112,41 @@ func (c *Context) EventStream(streamer func(w io.Writer) bool) { // 这是为高级并发场景设计的、更灵活的API. // // 重要: -// - 调用者必须 close(eventChan) 来结束事件流. -// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. -// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). +// - 调用者必须 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) -// } -// }() +// r.GET("/sse/channel", func(c *touka.Context) { +// eventChan, errChan := c.EventStreamChan() // -// // 在另一个goroutine中异步发送事件. -// go func() { -// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. -// defer close(eventChan) +// // 必须在独立的goroutine中处理错误和连接断开. +// go func() { +// if err := <-errChan; err != nil { +// c.Errorf("SSE channel error: %v", err) +// } +// }() // -// 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) -// } -// } -// }() -// }) +// // 在另一个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) @@ -156,7 +157,7 @@ func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { c.Writer.Header().Del("Transfer-Encoding") c.Writer.WriteHeader(http.StatusOK) - c.Writer.Flush() + c.Writer.Flush() go func() { defer close(errChan) From 5b98310de532e9545fed2233b411e7050e4edfd2 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Sep 2025 08:24:01 +0800 Subject: [PATCH 16/24] fix StaticFS --- fileserver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fileserver.go b/fileserver.go index 197b681..1aa1aaf 100644 --- a/fileserver.go +++ b/fileserver.go @@ -244,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)) } @@ -258,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)) } From 76a89800a2df065e2dc37a1cf0b41fe4a0fb2545 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 12 Oct 2025 15:47:02 +0800 Subject: [PATCH 17/24] update --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0b8d97b..d8b0dfc 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,10 @@ require ( github.com/WJQSERVER-STUDIO/httpc v0.8.2 github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b + github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index dcd4f26..ca56e55 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,11 @@ 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-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= +github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= From 1361f6e2379c038b91e777b89509ef7a86ed305d Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:47:29 +0800 Subject: [PATCH 18/24] update --- context.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/context.go b/context.go index b6fbd46..0a57ee1 100644 --- a/context.go +++ b/context.go @@ -19,6 +19,7 @@ import ( "net/url" "os" "path" + "path/filepath" "strings" "sync" "time" @@ -280,6 +281,118 @@ func (c *Context) Text(code int, text string) { c.Writer.Write([]byte(text)) } +// FileText +func (c *Context) FileText(code int, filePath 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 + } + // 检查文件是否存在 + 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 any) { From e4aaaa1583d74310f42cad4ddf1d271f19fd58b9 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:06:26 +0800 Subject: [PATCH 19/24] fix path to filepath --- context.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/context.go b/context.go index 0a57ee1..c79e4cc 100644 --- a/context.go +++ b/context.go @@ -18,7 +18,6 @@ import ( "net/netip" "net/url" "os" - "path" "path/filepath" "strings" "sync" @@ -284,17 +283,12 @@ func (c *Context) Text(code int, text string) { // FileText func (c *Context) FileText(code int, filePath string) { // 清理path - cleanPath := path.Clean(filePath) + 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 strings.Contains(cleanPath, "..") { - c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected")) - return - } // 检查文件是否存在 if _, err := os.Stat(cleanPath); os.IsNotExist(err) { c.AddError(fmt.Errorf("file not found: %s", cleanPath)) @@ -868,7 +862,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) @@ -888,7 +882,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" From 904aea5df88280fbbf65456a9bf80ced68624618 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:56:37 +0800 Subject: [PATCH 20/24] refactor: Improve engine's tree processing and context handling. --- context.go | 18 +++++++++++++++++- engine.go | 9 +++------ tree.go | 26 ++++++++------------------ 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index c79e4cc..644bbc6 100644 --- a/context.go +++ b/context.go @@ -65,6 +65,10 @@ type Context struct { // 请求体Body大小限制 MaxRequestBodySize int64 + + // skippedNodes 用于记录跳过的节点信息,以便回溯 + // 通常在处理嵌套路由时使用 + SkippedNodes []skippedNode } // --- Context 相关方法实现 --- @@ -80,7 +84,13 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { } 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, 5) + } c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染 @@ -90,6 +100,12 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值 c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式 c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize + + if cap(c.SkippedNodes) > 0 { + c.SkippedNodes = c.SkippedNodes[:0] + } else { + c.SkippedNodes = make([]skippedNode, 0, 256) + } } // Next 在处理链中执行下一个处理函数 diff --git a/engine.go b/engine.go index 581258c..0cdd5cc 100644 --- a/engine.go +++ b/engine.go @@ -432,9 +432,8 @@ 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) + value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) if value.handlers != nil { allowedMethods = append(allowedMethods, treeIter.method) } @@ -451,9 +450,8 @@ func MethodNotAllowed() HandlerFunc { if treeIter.method == httpMethod { // 已经处理过当前方法,跳过 continue } - var tempSkippedNodes []skippedNode // 用于临时查找,不影响主 Context // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) // 只查找是否存在,不需要参数 + value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) // 只查找是否存在,不需要参数 if value.handlers != nil { // 使用定义的ErrorHandle处理 engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) @@ -661,9 +659,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) // 组合全局中间件和路由处理函数 diff --git a/tree.go b/tree.go index 09711a1..31246a5 100644 --- a/tree.go +++ b/tree.go @@ -5,7 +5,6 @@ package touka import ( - "bytes" "net/url" "strings" "unicode" @@ -27,12 +26,6 @@ func BytesToString(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) } -var ( - strColon = []byte(":") // 定义字节切片常量, 表示冒号, 用于路径参数识别 - strStar = []byte("*") // 定义字节切片常量, 表示星号, 用于捕获所有路径识别 - strSlash = []byte("/") // 定义字节切片常量, 表示斜杠, 用于路径分隔符识别 -) - // Param 是单个 URL 参数, 由键和值组成. type Param struct { Key string // 参数的键名 @@ -106,17 +99,14 @@ func (n *node) addChild(child *node) { // 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 计算路径中斜杠('/')的数量, 即路径段的数量. func countSections(path string) uint16 { - s := StringToBytes(path) // 将路径字符串转换为字节切片 - return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量 + return uint16(strings.Count(path, "/")) } // nodeType 定义了节点的类型. @@ -418,10 +408,10 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) 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{ From 9cfc82a3470df126ee2e824cc61af25726758912 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:57:48 +0800 Subject: [PATCH 21/24] chore: update go module dependencies. --- go.mod | 6 +++--- go.sum | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d8b0dfc..bd8f41d 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 - github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee + github.com/WJQSERVER/wanf v0.0.2 github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 + github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index ca56e55..3e9287c 100644 --- a/go.sum +++ b/go.sum @@ -4,15 +4,21 @@ github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/ github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= +github.com/WJQSERVER/wanf v0.0.2 h1:E3dfHP6AACYamKn5BVUpi7pkO3L26WJycKF4AhGusXY= +github.com/WJQSERVER/wanf v0.0.2/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-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= From 60b2936efff513254e0786fd84d566b0a5fb4b28 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:16:29 +0800 Subject: [PATCH 22/24] add TempSkippedNodesPool --- context.go | 2 +- engine.go | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/context.go b/context.go index 644bbc6..00b842f 100644 --- a/context.go +++ b/context.go @@ -89,7 +89,7 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { if cap(c.Params) > 0 { c.Params = c.Params[:0] } else { - c.Params = make(Params, 0, 5) + c.Params = make(Params, 0, c.engine.maxParams) } c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 diff --git a/engine.go b/engine.go index 0cdd5cc..b880b94 100644 --- a/engine.go +++ b/engine.go @@ -421,6 +421,22 @@ func getHandlerName(h HandlerFunc) string { } +// TempSkippedNodes池 +var TempSkippedNodesPool = sync.Pool{ + New: func() any { + return make([]skippedNode, 0, 256) + }, +} + +func GetTempSkippedNodes() *[]skippedNode { + return TempSkippedNodesPool.Get().(*[]skippedNode) +} + +func PutTempSkippedNodes(skippedNodes *[]skippedNode) { + *skippedNodes = (*skippedNodes)[:0] // 重置slice + TempSkippedNodesPool.Put(skippedNodes) +} + // 405中间件 func MethodNotAllowed() HandlerFunc { return func(c *Context) { @@ -433,7 +449,9 @@ func MethodNotAllowed() HandlerFunc { allowedMethods := []string{} for _, treeIter := range engine.methodTrees { // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) + tempSkippedNodes := GetTempSkippedNodes() + value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) + PutTempSkippedNodes(tempSkippedNodes) if value.handlers != nil { allowedMethods = append(allowedMethods, treeIter.method) } @@ -451,7 +469,9 @@ func MethodNotAllowed() HandlerFunc { continue } // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, 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")) From b348d7d41f393a06cae4569edaa6b8a7a8a6064f Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:42:50 +0800 Subject: [PATCH 23/24] update TempSkippedNodesPool --- engine.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/engine.go b/engine.go index b880b94..0a95765 100644 --- a/engine.go +++ b/engine.go @@ -421,19 +421,38 @@ func getHandlerName(h HandlerFunc) string { } -// TempSkippedNodes池 +const MaxSkippedNodesCap = 256 + +// TempSkippedNodesPool 存储 *[]skippedNode 以复用内存 var TempSkippedNodesPool = sync.Pool{ New: func() any { - return make([]skippedNode, 0, 256) + // 返回一个指向容量为 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) { - *skippedNodes = (*skippedNodes)[:0] // 重置slice + if skippedNodes == nil || *skippedNodes == nil { + return + } + + // 检查容量是否符合预期。如果容量不足,则丢弃,不放回 Pool。 + if cap(*skippedNodes) < MaxSkippedNodesCap { + return // 丢弃该对象,让 Pool 在下次 Get 时通过 New 重新分配 + } + + // 长度重置为 0,保留容量,实现复用 + *skippedNodes = (*skippedNodes)[:0] + + // 将指针存回 Pool TempSkippedNodesPool.Put(skippedNodes) } From a6e278d458a06fe92da353d291e13bf71855ff2b Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:08:01 +0800 Subject: [PATCH 24/24] print errlog (jsonv2 marshal) --- context.go | 1 + go.mod | 4 ++-- go.sum | 18 ++++-------------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/context.go b/context.go index 00b842f..8c52b1f 100644 --- a/context.go +++ b/context.go @@ -410,6 +410,7 @@ func (c *Context) JSON(code int, obj any) { 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 } diff --git a/go.mod b/go.mod index bd8f41d..f9d10a9 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 - github.com/WJQSERVER/wanf v0.0.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/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect ) diff --git a/go.sum b/go.sum index 3e9287c..b75fec4 100644 --- a/go.sum +++ b/go.sum @@ -2,23 +2,13 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= 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.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= -github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= -github.com/WJQSERVER/wanf v0.0.2 h1:E3dfHP6AACYamKn5BVUpi7pkO3L26WJycKF4AhGusXY= -github.com/WJQSERVER/wanf v0.0.2/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= +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-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= -github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= -github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=