diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 5d5906c..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "gomod" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "daily" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d3e55a2..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Go Test - -on: - push: - tags: - - '*' - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - - - name: Run tests - run: go test -v ./... - - - name: Run tests race - run: go test -race -v ./... diff --git a/README.md b/README.md index 3ab971f..c7d377c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,34 @@ # Touka(灯花)框架 -Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化**。 +Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化** -**想深入了解 Touka 吗?请阅读我们的 -> [深度指南 (about-touka.md)](about-touka.md)** +## Touka 的设计特点 -这份深度指南包含了对框架设计哲学、核心功能(路由、上下文、中间件、错误处理等)的全面剖析,并提供了大量可直接使用的代码示例,帮助您快速上手并精通 Touka。 +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)压力,尤其在高并发场景下,有助于提供更平滑和可预测的性能表现。 ### 快速上手 @@ -24,68 +48,164 @@ import ( ) func main() { - r := touka.Default() // 使用带 Recovery 中间件的默认引擎 + r := touka.New() - // 配置日志记录器 (可选) + // 配置日志记录器 (可选,不设置则使用默认配置) logConfig := reco.Config{ Level: reco.LevelDebug, - Mode: reco.ModeText, + Mode: reco.ModeText, // 或 reco.ModeJSON Output: os.Stdout, Async: true, + BufferSize: 4096, } - r.SetLoggerCfg(logConfig) + r.SetLogger(logConfig) // 配置统一错误处理器 - r.SetErrorHandler(func(c *touka.Context, code int, err error) { + // Touka 允许您为 404, 500 等错误定义统一的响应。 + // 特别地,它能捕获 http.FileServer 产生的 404/403 错误并统一处理。 + r.SetErrorHandler(func(c *touka.Context, code int) { + // 这里可以根据 code 返回 JSON, HTML, 或其他自定义错误页面 c.JSON(code, touka.H{"error_code": code, "message": http.StatusText(code)}) - c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s, 错误: %v", code, c.Request.URL.Path, err) + c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s", code, c.Request.URL.Path) // 记录错误 }) - // 注册路由 - 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("/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("/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...") - if err := r.RunShutdown(":8080", 10*time.Second); err != nil { + err := r.RunShutdown(":8080", 10*time.Second) // 优雅关闭超时10秒 + if 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())` (已包含在 `touka.Default()` 中) +Recovery `r.Use(touka.Recovery())` -### 第三方 (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) ## 文档与贡献 -* **深度指南:** **[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 参考。 -* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南。 +* **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): Touka 在路由和 API 设计上参考了 Gin。 -- [reco](https://github.com/fenthope/reco): Touka 框架的默认日志库。 -- [httpc](https://github.com/WJQSERVER-STUDIO/httpc): 一个现代化且易用的 HTTP Client,作为 Touka 框架 Context 携带的 HTTPC。 +[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 ## 许可证 -本项目基于 [Mozilla Public License, v. 2.0](https://mozilla.org/MPL/2.0/) 许可。 +本项目使用MPL许可证 -`tree.go` 部分代码源自 [gin](https://github.com/gin-gonic/gin) 与 [httprouter](https://github.com/julienschmidt/httprouter),其原始许可为 BSD-style。 +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版本) \ No newline at end of file diff --git a/about-touka.md b/about-touka.md deleted file mode 100644 index 86a056f..0000000 --- a/about-touka.md +++ /dev/null @@ -1,577 +0,0 @@ -# 关于 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 -//
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 生态中大量现有的、遵循标准接口的第三方中间件和工具。 diff --git a/adapter.go b/adapter.go index 88166ee..68a2ddb 100644 --- a/adapter.go +++ b/adapter.go @@ -1,7 +1,4 @@ -// 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. +// 文件: touka/adapter.go package touka import ( diff --git a/context.go b/context.go index 8c52b1f..d4b9a5b 100644 --- a/context.go +++ b/context.go @@ -1,7 +1,3 @@ -// 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 ( @@ -13,21 +9,18 @@ import ( "html/template" "io" "math" - "mime" + "net" "net/http" "net/netip" "net/url" - "os" - "path/filepath" "strings" "sync" "time" - "github.com/WJQSERVER/wanf" "github.com/fenthope/reco" "github.com/go-json-experiment/json" - "github.com/WJQSERVER-STUDIO/go-utils/iox" + "github.com/WJQSERVER-STUDIO/go-utils/copyb" "github.com/WJQSERVER-STUDIO/httpc" ) @@ -43,7 +36,7 @@ type Context struct { index int8 // 当前执行到处理链的哪个位置 mu sync.RWMutex - Keys map[string]any // 用于在中间件之间传递数据 + Keys map[string]interface{} // 用于在中间件之间传递数据 Errors []error // 用于收集处理过程中的错误 @@ -62,13 +55,6 @@ type Context struct { engine *Engine sameSite http.SameSite - - // 请求体Body大小限制 - MaxRequestBodySize int64 - - // skippedNodes 用于记录跳过的节点信息,以便回溯 - // 通常在处理嵌套路由时使用 - SkippedNodes []skippedNode } // --- Context 相关方法实现 --- @@ -82,30 +68,19 @@ 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 切片,而不是重新分配,以复用底层数组 - //避免params长度为0 - if cap(c.Params) > 0 { - c.Params = c.Params[:0] - } else { - c.Params = make(Params, 0, c.engine.maxParams) - } + c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组 c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 - c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染 + c.Keys = make(map[string]interface{}) // 每次请求重新创建 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 - - if cap(c.SkippedNodes) > 0 { - c.SkippedNodes = c.SkippedNodes[:0] - } else { - c.SkippedNodes = make([]skippedNode, 0, 256) - } + // c.HTTPClient 和 c.engine 保持不变,它们引用 Engine 实例的成员 } // Next 在处理链中执行下一个处理函数 @@ -137,10 +112,10 @@ func (c *Context) AbortWithStatus(code int) { // Set 将一个键值对存储到 Context 中 // 这是一个线程安全的操作,用于在中间件之间传递数据 -func (c *Context) Set(key string, value any) { +func (c *Context) Set(key string, value interface{}) { c.mu.Lock() // 加写锁 if c.Keys == nil { - c.Keys = make(map[string]any) + c.Keys = make(map[string]interface{}) } c.Keys[key] = value c.mu.Unlock() // 解写锁 @@ -148,7 +123,7 @@ func (c *Context) Set(key string, value any) { // Get 从 Context 中获取一个值 // 这是一个线程安全的操作 -func (c *Context) Get(key string) (value any, exists bool) { +func (c *Context) Get(key string) (value interface{}, exists bool) { c.mu.RLock() // 加读锁 value, exists = c.Keys[key] c.mu.RUnlock() // 解读锁 @@ -223,18 +198,13 @@ func (c *Context) GetDuration(key string) (value time.Duration, exists bool) { // MustGet 从 Context 中获取一个值,如果不存在则 panic // 适用于确定值一定存在的场景 -func (c *Context) MustGet(key string) any { +func (c *Context) MustGet(key string) interface{} { if value, exists := c.Get(key); exists { return value } panic("Key \"" + key + "\" does not exist in context.") } -// SetMaxRequestBodySize -func (c *Context) SetMaxRequestBodySize(size int64) { - c.MaxRequestBodySize = size -} - // Query 从 URL 查询参数中获取值 // 懒加载解析查询参数,并进行缓存 func (c *Context) Query(key string) string { @@ -284,171 +254,47 @@ func (c *Context) Raw(code int, contentType string, data []byte) { } // String 向响应写入格式化的字符串 -func (c *Context) String(code int, format string, values ...any) { +func (c *Context) String(code int, format string, values ...interface{}) { c.Writer.WriteHeader(code) c.Writer.Write([]byte(fmt.Sprintf(format, values...))) } -// Text 向响应写入无需格式化的string -func (c *Context) Text(code int, text string) { - c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8") - c.Writer.WriteHeader(code) - c.Writer.Write([]byte(text)) -} - -// FileText -func (c *Context) FileText(code int, filePath string) { - // 清理path - cleanPath := filepath.Clean(filePath) - if !filepath.IsAbs(cleanPath) { - c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed")) - return - } - // 检查文件是否存在 - if _, err := os.Stat(cleanPath); os.IsNotExist(err) { - c.AddError(fmt.Errorf("file not found: %s", cleanPath)) - c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found")) - return - } - - // 打开文件 - file, err := os.Open(cleanPath) - if err != nil { - c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err)) - c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err)) - return - } - defer file.Close() - - // 获取文件信息以获取文件大小 - fileInfo, err := file.Stat() - if err != nil { - c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err)) - c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err)) - return - } - // 判断是否是dir - if fileInfo.IsDir() { - c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory")) - return - } - - c.SetHeader("Content-Type", "text/plain; charset=utf-8") - - c.SetBodyStream(file, int(fileInfo.Size())) -} - -/* -// not fot work -// FileTextSafeDir -func (c *Context) FileTextSafeDir(code int, filePath string, safeDir string) { - - // 清理path - cleanPath := path.Clean(filePath) - if !filepath.IsAbs(cleanPath) { - c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed")) - return - } - if strings.Contains(cleanPath, "..") { - c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected")) - return - } - - // 判断filePath是否包含在safeDir内, 防止路径穿越 - relPath, err := filepath.Rel(safeDir, cleanPath) - if err != nil { - c.AddError(fmt.Errorf("failed to get relative path: %w", err)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("failed to get relative path: %w", err)) - return - } - cleanPath = filepath.Join(safeDir, relPath) - - // 检查文件是否存在 - if _, err := os.Stat(cleanPath); os.IsNotExist(err) { - c.AddError(fmt.Errorf("file not found: %s", cleanPath)) - c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found")) - return - } - - // 打开文件 - file, err := os.Open(cleanPath) - if err != nil { - c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err)) - c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err)) - return - } - defer file.Close() - - // 获取文件信息以获取文件大小 - fileInfo, err := file.Stat() - if err != nil { - c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err)) - c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err)) - return - } - // 判断是否是dir - if fileInfo.IsDir() { - c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory")) - return - } - - c.SetHeader("Content-Type", "text/plain; charset=utf-8") - - c.SetBodyStream(file, int(fileInfo.Size())) -} -*/ - // JSON 向响应写入 JSON 数据 // 设置 Content-Type 为 application/json -func (c *Context) JSON(code int, obj any) { +func (c *Context) JSON(code int, obj interface{}) { c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") c.Writer.WriteHeader(code) - if err := json.MarshalWrite(c.Writer, obj); err != nil { + // JSON 编码 + jsonBytes, err := json.Marshal(obj) + if err != nil { c.AddError(fmt.Errorf("failed to marshal JSON: %w", err)) - c.Errorf("failed to marshal JSON: %s", err) + //c.String(http.StatusInternalServerError, "Internal Server Error: Failed to marshal JSON") c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to marshal JSON: %w", err)) return } + c.Writer.Write(jsonBytes) } // GOB 向响应写入GOB数据 // 设置 Content-Type 为 application/octet-stream -func (c *Context) GOB(code int, obj any) { +func (c *Context) GOB(code int, obj interface{}) { c.Writer.Header().Set("Content-Type", "application/octet-stream") // 设置合适的 Content-Type c.Writer.WriteHeader(code) // GOB 编码 encoder := gob.NewEncoder(c.Writer) if err := encoder.Encode(obj); err != nil { c.AddError(fmt.Errorf("failed to encode GOB: %w", err)) + //c.String(http.StatusInternalServerError, "Internal Server Error: Failed to encode GOB") c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to encode GOB: %w", err)) return } } -// 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 any) { +func (c *Context) HTML(code int, name string, obj interface{}) { c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.WriteHeader(code) @@ -458,6 +304,7 @@ func (c *Context) HTML(code int, name string, obj any) { err := tpl.ExecuteTemplate(c.Writer, name, obj) if err != nil { c.AddError(fmt.Errorf("failed to render HTML template '%s': %w", name, err)) + //c.String(http.StatusInternalServerError, "Internal Server Error: Failed to render HTML template") c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to render HTML template '%s': %w", name, err)) } return @@ -479,10 +326,16 @@ func (c *Context) Redirect(code int, location string) { } // ShouldBindJSON 尝试将请求体绑定到 JSON 对象 -func (c *Context) ShouldBindJSON(obj any) error { +func (c *Context) ShouldBindJSON(obj interface{}) error { if c.Request.Body == nil { return errors.New("request body is empty") } + /* + decoder := json.NewDecoder(c.Request.Body) + if err := decoder.Decode(obj); err != nil { + return fmt.Errorf("json binding error: %w", err) + } + */ err := json.UnmarshalRead(c.Request.Body, obj) if err != nil { return fmt.Errorf("json binding error: %w", err) @@ -490,28 +343,10 @@ func (c *Context) ShouldBindJSON(obj any) 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 any) error { +func (c *Context) ShouldBind(obj interface{}) error { // TODO: 完整的通用绑定逻辑 // 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等 // 例如: @@ -564,7 +399,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 any) any { +func (c *Context) Value(key interface{}) interface{} { if keyAsString, ok := key.(string); ok { if val, exists := c.Get(keyAsString); exists { return val @@ -589,7 +424,7 @@ func (c *Context) WriteStream(reader io.Reader) (written int64, err error) { c.Writer.WriteHeader(http.StatusOK) // 默认 200 OK } - written, err = iox.Copy(c.Writer, reader) // 从 reader 读取并写入 ResponseWriter + written, err = copyb.Copy(c.Writer, reader) // 从 reader 读取并写入 ResponseWriter if err != nil { c.AddError(fmt.Errorf("failed to write stream: %w", err)) } @@ -608,28 +443,8 @@ func (c *Context) GetReqBodyFull() ([]byte, error) { if c.Request.Body == nil { return nil, nil } - - var limitBytesReader io.ReadCloser - - if c.MaxRequestBodySize > 0 { - limitBytesReader = NewMaxBytesReader(c.Request.Body, c.MaxRequestBodySize) - defer func() { - err := limitBytesReader.Close() - if err != nil { - c.AddError(fmt.Errorf("failed to close request body: %w", err)) - } - }() - } else { - limitBytesReader = c.Request.Body - defer func() { - err := limitBytesReader.Close() - if err != nil { - c.AddError(fmt.Errorf("failed to close request body: %w", err)) - } - }() - } - - data, err := iox.ReadAll(limitBytesReader) + defer c.Request.Body.Close() // 确保请求体被关闭 + data, err := io.ReadAll(c.Request.Body) if err != nil { c.AddError(fmt.Errorf("failed to read request body: %w", err)) return nil, fmt.Errorf("failed to read request body: %w", err) @@ -642,28 +457,8 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) { if c.Request.Body == nil { return nil, nil } - - var limitBytesReader io.ReadCloser - - if c.MaxRequestBodySize > 0 { - limitBytesReader = NewMaxBytesReader(c.Request.Body, c.MaxRequestBodySize) - defer func() { - err := limitBytesReader.Close() - if err != nil { - c.AddError(fmt.Errorf("failed to close request body: %w", err)) - } - }() - } else { - limitBytesReader = c.Request.Body - defer func() { - err := limitBytesReader.Close() - if err != nil { - c.AddError(fmt.Errorf("failed to close request body: %w", err)) - } - }() - } - - data, err := iox.ReadAll(limitBytesReader) + defer c.Request.Body.Close() // 确保请求体被关闭 + data, err := io.ReadAll(c.Request.Body) if err != nil { c.AddError(fmt.Errorf("failed to read request body: %w", err)) return nil, fmt.Errorf("failed to read request body: %w", err) @@ -677,59 +472,39 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) { func (c *Context) RequestIP() string { if c.engine.ForwardByClientIP { for _, headerName := range c.engine.RemoteIPHeaders { - 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() + 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 无效,继续检查列表中的下一个 } } } } - // 回退到 Request.RemoteAddr 的处理 - // 优先使用 netip.ParseAddrPort, 它比 net.SplitHostPort 更高效且分配更少 - addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr) - if err == nil { - // 成功从 "ip:port" 格式中解析出 IP - return addrp.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 } - // 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口), - // 则尝试将整个字符串作为 IP 地址进行解析 - addr, err := netip.ParseAddr(c.Request.RemoteAddr) - if err == nil { - return addr.String() + // 对从 RemoteAddr 中提取/使用的 IP 进行最终的合法性验证 + addr, parseErr := netip.ParseAddr(ip) + if parseErr == nil { + return addr.String() // 成功解析并返回合法 IP } - // 所有方法都失败, 返回空字符串 return "" } @@ -786,20 +561,6 @@ func (c *Context) GetReqHeader(key string) string { return c.Request.Header.Get(key) } -// SetHeaders 接受headers列表 -func (c *Context) SetHeaders(headers map[string][]string) { - for key, values := range headers { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } -} - -// 获取所有resp Headers -func (c *Context) GetAllRespHeader() http.Header { - return c.Writer.Header() -} - // GetAllReqHeader 获取所有请求头部 func (c *Context) GetAllReqHeader() http.Header { return c.Request.Header @@ -856,7 +617,7 @@ func (c *Context) SetBodyStream(reader io.Reader, contentSize int) { // 将 reader 的内容直接复制到 ResponseWriter // ResponseWriter 实现了 io.Writer 接口 - _, err := iox.Copy(c.Writer, reader) + _, err := copyb.Copy(c.Writer, reader) if err != nil { c.AddError(fmt.Errorf("failed to write stream: %w", err)) // 注意:这里可能无法设置错误状态码,因为头部可能已经发送 @@ -874,56 +635,6 @@ func (c *Context) GetRequestURIPath() string { return c.Request.URL.Path } -// === 文件操作 === - -// 将文件内容作为响应body -func (c *Context) SetRespBodyFile(code int, filePath string) { - // 清理path - cleanPath := filepath.Clean(filePath) - - // 打开文件 - 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() - - // 获取文件信息以获取文件大小和MIME类型 - 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 - } - - // 尝试根据文件扩展名猜测 Content-Type - contentType := mime.TypeByExtension(filepath.Ext(cleanPath)) - if contentType == "" { - // 如果无法猜测,则使用默认的二进制流类型 - contentType = "application/octet-stream" - } - - // 设置响应头 - c.Writer.Header().Set("Content-Type", contentType) - c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size())) - // 还可以设置 Content-Disposition 来控制浏览器是下载还是直接显示 - // c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, path.Base(cleanPath))) - - // 设置状态码 - c.Writer.WriteHeader(code) - - // 将文件内容写入响应体 - _, err = iox.Copy(c.Writer, file) - if err != nil { - c.AddError(fmt.Errorf("failed to write file %s to response: %w", cleanPath, err)) - // 注意:这里可能无法设置错误状态码,因为头部可能已经发送 - // 可以在调用 SetRespBodyFile 之前检查错误,或者在中间件中处理 Context.Errors - } - c.Abort() // 文件发送后中止后续处理 -} - // == cookie === // SetSameSite 设置响应的 SameSite cookie 属性 diff --git a/ecw.go b/ecw.go index c87be28..67b5c7d 100644 --- a/ecw.go +++ b/ecw.go @@ -1,7 +1,3 @@ -// 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 ( diff --git a/engine.go b/engine.go index 0a95765..8742d81 100644 --- a/engine.go +++ b/engine.go @@ -1,17 +1,16 @@ -// 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 ( "context" "errors" + "fmt" + "log" "reflect" "runtime" "strings" "net/http" + "path" "sync" @@ -60,8 +59,8 @@ type Engine struct { noRoute HandlerFunc // NoRoute 处理器 noRoutes HandlersChain // NoRoutes 处理器链 (如果 noRoute 未设置,则使用此链) - unMatchFS UnMatchFS // 未匹配下的处理 - UnMatchFSRoutes HandlersChain // UnMatch 处理器链, 用于扩展自由度, 在此局部链上, unMatchFS相关处理会在最后 + unMatchFS UnMatchFS // 未匹配下的处理 + unMatchFileServer http.Handler // 处理handle serverProtocols *http.Protocols //服务协议 Protocols ProtocolsConfig //协议版本配置 @@ -75,35 +74,6 @@ type Engine struct { // 如果设置了此回调,它将优先于 ServerConfigurator 被用于 HTTPS 服务器 // 如果未设置,HTTPS 服务器将回退使用 ServerConfigurator (如果已设置) TLSServerConfigurator func(*http.Server) - - // GlobalMaxRequestBodySize 全局请求体Body大小限制 - GlobalMaxRequestBodySize int64 -} - -// HandleFunc 注册一个或多个 HTTP 方法的路由 -// methods 参数是一个字符串切片,包含要注册的 HTTP 方法(例如 []string{"GET", "POST"}) -// relativePath 是相对于当前组或 Engine 的路径 -// handlers 是处理函数链 -func (engine *Engine) HandleFunc(methods []string, relativePath string, handlers ...HandlerFunc) { - for _, method := range methods { - if _, ok := MethodsSet[method]; !ok { - panic("invalid method: " + method) - } - engine.Handle(method, relativePath, handlers...) - } -} - -// HandleFunc 注册一个或多个 HTTP 方法的路由 -// methods 参数是一个字符串切片,包含要注册的 HTTP 方法(例如 []string{"GET", "POST"}) -// relativePath 是相对于当前组或 Engine 的路径 -// handlers 是处理函数链 -func (group *RouterGroup) HandleFunc(methods []string, relativePath string, handlers ...HandlerFunc) { - for _, method := range methods { - if _, ok := MethodsSet[method]; !ok { - panic("invalid method: " + method) - } - group.Handle(method, relativePath, handlers...) - } } type ErrorHandle struct { @@ -147,25 +117,10 @@ func defaultErrorWarp(handler ErrorHandler) ErrorHandler { return default: if c.Writer.Written() { - c.Debugf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err) + log.Printf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err) return } } - // 查看context内有没有收集到error - if len(c.Errors) > 0 { - c.Errorf("errpage: context errors: %v, current error: %v", errors.Join(c.Errors...), err) - if err == nil { - err = errors.Join(c.Errors...) - } - } - // 如果客户端已经断开连接,则不尝试写入响应 - // 避免在客户端已关闭连接后写入响应导致的问题 - // 检查 context.Context 是否已取消 - if errors.Is(c.Request.Context().Err(), context.Canceled) { - c.Debugf("errpage: client disconnected, skipping error page rendering for status %d, err: %v", code, err) - return - } - handler(c, code, err) } } @@ -201,11 +156,10 @@ func New() *Engine { unMatchFS: UnMatchFS{ ServeUnmatchedAsFS: false, }, - noRoute: nil, - noRoutes: make(HandlersChain, 0), - ServerConfigurator: nil, - TLSServerConfigurator: nil, - GlobalMaxRequestBodySize: -1, + noRoute: nil, + noRoutes: make(HandlersChain, 0), + ServerConfigurator: nil, + TLSServerConfigurator: nil, } //engine.SetProtocols(GetDefaultProtocolsConfig()) engine.SetDefaultProtocols() @@ -248,21 +202,6 @@ func (engine *Engine) SetTLSServerConfigurator(fn func(*http.Server)) { engine.TLSServerConfigurator = fn } -// 是否开启末尾slash重定向 -func (engine *Engine) SetRedirectTrailingSlash(enable bool) { - engine.RedirectTrailingSlash = enable -} - -// 是否开启固定路径重定向 -func (engine *Engine) SetRedirectFixedPath(enable bool) { - engine.RedirectFixedPath = enable -} - -// 是否开启MethodNotAllowed -func (engine *Engine) SetHandleMethodNotAllowed(enable bool) { - engine.HandleMethodNotAllowed = enable -} - // SetLogger传入实例 func (engine *Engine) SetLogger(logger *reco.Logger) { engine.LogReco = logger @@ -284,22 +223,15 @@ func (engine *Engine) GetDefaultErrHandler() ErrorHandler { return defaultErrorHandle } -func (engine *Engine) SetUnMatchFS(fs http.FileSystem, handlers ...HandlerFunc) { - engine.SetUnMatchFSChain(fs, handlers...) -} - -func (engine *Engine) SetUnMatchFSChain(fs http.FileSystem, handlers ...HandlerFunc) { +// 传入并配置unMatchFS +func (engine *Engine) SetUnMatchFS(fs http.FileSystem) { if fs != nil { engine.unMatchFS.FSForUnmatched = fs engine.unMatchFS.ServeUnmatchedAsFS = true - unMatchFileServer := GetStaticFSHandleFunc(http.FileServer(fs)) - combinedChain := make(HandlersChain, len(handlers)+1) - copy(combinedChain, handlers) - combinedChain[len(handlers)] = unMatchFileServer - engine.UnMatchFSRoutes = combinedChain + engine.unMatchFileServer = http.FileServer(fs) } else { engine.unMatchFS.ServeUnmatchedAsFS = false - engine.UnMatchFSRoutes = nil + engine.unMatchFileServer = nil } } @@ -332,11 +264,6 @@ func (engine *Engine) SetProtocols(config *ProtocolsConfig) { engine.useDefaultProtocols = false } -// 配置全局Req Body大小限制 -func (engine *Engine) SetGlobalMaxRequestBodySize(size int64) { - engine.GlobalMaxRequestBodySize = size -} - // 配置Req IP来源 Headers func (engine *Engine) SetRemoteIPHeaders(headers []string) { engine.RemoteIPHeaders = headers @@ -421,39 +348,133 @@ func getHandlerName(h HandlerFunc) string { } -const MaxSkippedNodesCap = 256 +// ServeHTTP 实现了 http.Handler 接口,是 Engine 处理所有 HTTP 请求的入口 +// 每个传入的 HTTP 请求都会调用此方法 +func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // 从 Context Pool 中获取一个 Context 对象进行复用 + c := engine.pool.Get().(*Context) + c.reset(w, req) // 重置 Context 对象的状态以适应当前请求 -// TempSkippedNodesPool 存储 *[]skippedNode 以复用内存 -var TempSkippedNodesPool = sync.Pool{ - New: func() any { - // 返回一个指向容量为 256 的新切片的指针 - s := make([]skippedNode, 0, MaxSkippedNodesCap) - return &s - }, + // 执行请求处理 + engine.handleRequest(c) + + // 将 Context 对象放回 Context Pool,以供下次复用 + engine.pool.Put(c) } -// GetTempSkippedNodes 从 Pool 中获取一个 *[]skippedNode 指针 -func GetTempSkippedNodes() *[]skippedNode { - // 直接返回 Pool 中存储的指针 - return TempSkippedNodesPool.Get().(*[]skippedNode) +// handleRequest 负责根据请求查找路由并执行相应的处理函数链 +// 这是路由查找和执行的核心逻辑 +func (engine *Engine) handleRequest(c *Context) { + httpMethod := c.Request.Method + requestPath := c.Request.URL.Path + + // 查找对应的路由树的根节点 + rootNode := engine.methodTrees.get(httpMethod) // 这里获取到的 rootNode 已经是 *node 类型 + if rootNode != nil { + // 查找匹配的节点和处理函数 + // 这里传递 &c.Params 而不是重新创建,以利用 Context 中预分配的容量 + // skippedNodes 内部使用,因此无需从外部传入已分配的 slice + var skippedNodes []skippedNode // 用于回溯的跳过节点 + // 直接在 rootNode 上调用 getValue 方法 + value := rootNode.getValue(requestPath, &c.Params, &skippedNodes, true) // unescape=true 对路径参数进行 URL 解码 + + if value.handlers != nil { + //c.handlers = engine.combineHandlers(engine.globalHandlers, value.handlers) // 组合全局中间件和路由处理函数 + c.handlers = value.handlers + c.Next() // 执行处理函数链 + //c.Writer.Flush() // 确保所有缓冲的响应数据被发送 + return + } + + // 如果没有找到处理函数,检查是否需要重定向(尾部斜杠或大小写修复) + if httpMethod != http.MethodConnect && requestPath != "/" { // CONNECT 方法和根路径不进行重定向 + if value.tsr && engine.RedirectTrailingSlash { + // 尾部斜杠重定向:/foo/ -> /foo 或 /foo -> /foo/ + redirectPath := requestPath + if len(requestPath) > 0 && requestPath[len(requestPath)-1] == '/' { + redirectPath = requestPath[:len(requestPath)-1] + } else { + redirectPath = requestPath + "/" + } + c.Redirect(http.StatusMovedPermanently, redirectPath) // 301 永久重定向 + return + } + // 尝试不区分大小写的查找 + // 直接在 rootNode 上调用 findCaseInsensitivePath 方法 + ciPath, found := rootNode.findCaseInsensitivePath(requestPath, engine.RedirectTrailingSlash) + if found && engine.RedirectFixedPath { + c.Redirect(http.StatusMovedPermanently, BytesToString(ciPath)) // 301 永久重定向到修正后的路径 + return + } + } + } + + // 构建处理链 + // 组合全局中间件和路由处理函数 + handlers := engine.globalHandlers + + // 如果启用了 MethodNotAllowed 处理,并且没有找到精确匹配的路由 + // 则在全局中间件之后添加 MethodNotAllowed 处理器 + if engine.HandleMethodNotAllowed { + handlers = append(handlers, MethodNotAllowed()) + } + + // 如果启用了 UnMatchFS 处理,并且没有找到精确匹配的路由和 MethodNotAllowed + // 则在处理链的最后添加 UnMatchFS 处理器 + if engine.unMatchFS.ServeUnmatchedAsFS { + handlers = append(handlers, unMatchFSHandle()) + } + + // 如果用户设置了 NoRoute 处理器,且没有匹配到任何路由、MethodNotAllowed 或 UnMatchFS + // 则在处理链的最后添加 NoRoute 处理器 + if engine.noRoute != nil { + handlers = append(handlers, engine.noRoute) + } else if len(engine.noRoutes) > 0 { + handlers = append(handlers, engine.noRoutes...) + } + + handlers = append(handlers, NotFound()) + + c.handlers = handlers + c.Next() // 执行处理函数链 + //c.Writer.Flush() // 确保所有缓冲的响应数据被发送 } -// PutTempSkippedNodes 将用完的 *[]skippedNode 指针放回 Pool -func PutTempSkippedNodes(skippedNodes *[]skippedNode) { - if skippedNodes == nil || *skippedNodes == nil { - return +// UnMatchFS HandleFunc +func unMatchFSHandle() HandlerFunc { + return func(c *Context) { + engine := c.engine + // 确保 engine.unMatchFileServer 存在 + if !engine.unMatchFS.ServeUnmatchedAsFS || engine.unMatchFileServer == nil { + c.Next() // 如果未配置或 FileSystem 为 nil,则继续处理链 + return + } + if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { + // 使用 http.FileServer 处理未匹配的请求 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) + c.engine.unMatchFileServer.ServeHTTP(ecw, c.Request) + ecw.processAfterFileServer() + c.Abort() + return + } else { + if engine.noRoute == nil { + // 若为OPTIONS + if c.Request.Method == http.MethodOptions { + //返回allow get + c.Writer.Header().Set("Allow", "GET") + c.Status(http.StatusOK) + c.Abort() + return + } else { + engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) + return + } + } else { + c.Next() + } + } } - - // 检查容量是否符合预期。如果容量不足,则丢弃,不放回 Pool。 - if cap(*skippedNodes) < MaxSkippedNodesCap { - return // 丢弃该对象,让 Pool 在下次 Get 时通过 New 重新分配 - } - - // 长度重置为 0,保留容量,实现复用 - *skippedNodes = (*skippedNodes)[:0] - - // 将指针存回 Pool - TempSkippedNodesPool.Put(skippedNodes) } // 405中间件 @@ -467,10 +488,9 @@ func MethodNotAllowed() HandlerFunc { // 如果是 OPTIONS 请求,尝试查找所有允许的方法 allowedMethods := []string{} for _, treeIter := range engine.methodTrees { + var tempSkippedNodes []skippedNode // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - tempSkippedNodes := GetTempSkippedNodes() - value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) - PutTempSkippedNodes(tempSkippedNodes) + value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) if value.handlers != nil { allowedMethods = append(allowedMethods, treeIter.method) } @@ -487,10 +507,9 @@ func MethodNotAllowed() HandlerFunc { if treeIter.method == httpMethod { // 已经处理过当前方法,跳过 continue } + var tempSkippedNodes []skippedNode // 用于临时查找,不影响主 Context // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - tempSkippedNodes := GetTempSkippedNodes() - value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) // 只查找是否存在,不需要参数 - PutTempSkippedNodes(tempSkippedNodes) + value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) // 只查找是否存在,不需要参数 if value.handlers != nil { // 使用定义的ErrorHandle处理 engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) @@ -505,6 +524,7 @@ func NotFound() HandlerFunc { return func(c *Context) { engine := c.engine engine.errorHandle.handler(c, http.StatusNotFound, errors.New("not found")) + return } } @@ -540,8 +560,7 @@ func (engine *Engine) Use(middleware ...HandlerFunc) IRouter { // Handle 注册通用 HTTP 方法的路由 // 这是所有具体 HTTP 方法注册的基础方法 func (engine *Engine) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) { - //absolutePath := path.Join("/", relativePath) // 修正:统一使用 path.Join 进行路径拼接 - absolutePath := resolveRoutePath("/", relativePath) + absolutePath := path.Join("/", relativePath) // 修正:统一使用 path.Join 进行路径拼接 // 修正:将全局中间件与此路由的处理函数合并 fullHandlers := engine.combineHandlers(engine.globalHandlers, handlers) engine.addRoute(httpMethod, absolutePath, "/", fullHandlers) @@ -603,7 +622,7 @@ func (engine *Engine) GetRouterInfo() []RouteInfo { func (engine *Engine) Group(relativePath string, handlers ...HandlerFunc) IRouter { return &RouterGroup{ Handlers: engine.combineHandlers(engine.globalHandlers, handlers), // 继承全局中间件 - basePath: resolveRoutePath("/", relativePath), + basePath: path.Join("/", relativePath), engine: engine, // 指向 Engine 实例 } } @@ -626,7 +645,7 @@ func (group *RouterGroup) Use(middleware ...HandlerFunc) IRouter { // Handle 注册通用 HTTP 方法的路由到当前组 // 路径是相对于当前组的 basePath func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) { - absolutePath := resolveRoutePath(group.basePath, relativePath) + absolutePath := path.Join(group.basePath, relativePath) fullHandlers := group.engine.combineHandlers(group.Handlers, handlers) group.engine.addRoute(httpMethod, absolutePath, group.basePath, fullHandlers) } @@ -667,102 +686,330 @@ func (group *RouterGroup) ANY(relativePath string, handlers ...HandlerFunc) { func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) IRouter { return &RouterGroup{ Handlers: group.engine.combineHandlers(group.Handlers, handlers), - basePath: resolveRoutePath(group.basePath, relativePath), + basePath: path.Join(group.basePath, relativePath), engine: group.engine, // 指向 Engine 实例 } } -// ServeHTTP 实现了 http.Handler 接口,是 Engine 处理所有 HTTP 请求的入口 -// 每个传入的 HTTP 请求都会调用此方法 -func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { - // 从 Context Pool 中获取一个 Context 对象进行复用 - c := engine.pool.Get().(*Context) - c.reset(w, req) // 重置 Context 对象的状态以适应当前请求 +// == 其他操作方式 === - // 执行请求处理 - engine.handleRequest(c) +// StaticDir 传入一个文件夹路径, 使用FileServer进行处理 +// r.StaticDir("/test/*filepath", "/var/www/test") +func (engine *Engine) StaticDir(relativePath, rootPath string) { + // 清理路径 + relativePath = path.Clean(relativePath) + rootPath = path.Clean(rootPath) - // 将 Context 对象放回 Context Pool,以供下次复用 - engine.pool.Put(c) -} + // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 + if !strings.HasSuffix(relativePath, "/") { + relativePath += "/" + } -// handleRequest 负责根据请求查找路由并执行相应的处理函数链 -// 这是路由查找和执行的核心逻辑 -func (engine *Engine) handleRequest(c *Context) { - httpMethod := c.Request.Method - requestPath := c.Request.URL.Path + // 创建一个文件系统处理器 + fileServer := http.FileServer(http.Dir(rootPath)) - // 查找对应的路由树的根节点 - rootNode := engine.methodTrees.get(httpMethod) // 这里获取到的 rootNode 已经是 *node 类型 - if rootNode != nil { - // 查找匹配的节点和处理函数 - // 这里传递 &c.Params 而不是重新创建,以利用 Context 中预分配的容量 - // skippedNodes 内部使用,因此无需从外部传入已分配的 slice - // 直接在 rootNode 上调用 getValue 方法 - value := rootNode.getValue(requestPath, &c.Params, &c.SkippedNodes, true) // unescape=true 对路径参数进行 URL 解码 - - if value.handlers != nil { - //c.handlers = engine.combineHandlers(engine.globalHandlers, value.handlers) // 组合全局中间件和路由处理函数 - c.handlers = value.handlers - c.Next() // 执行处理函数链 - //c.Writer.Flush() // 确保所有缓冲的响应数据被发送 + // 注册一个捕获所有路径的路由,使用自定义处理器 + // 注意:这里使用 ANY 方法,但 FileServer 通常只处理 GET 和 HEAD + // 我们可以通过在处理函数内部检查方法来限制 + engine.ANY(relativePath+"*filepath", func(c *Context) { + // 检查是否是 GET 或 HEAD 方法 + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 + if engine.HandleMethodNotAllowed { + c.Next() + } else { + // 否则,返回 405 Method Not Allowed + engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) + } return } - // 如果没有找到处理函数,检查是否需要重定向(尾部斜杠或大小写修复) - if httpMethod != http.MethodConnect && requestPath != "/" { // CONNECT 方法和根路径不进行重定向 - if value.tsr && engine.RedirectTrailingSlash { - // 尾部斜杠重定向:/foo/ -> /foo 或 /foo -> /foo/ - redirectPath := requestPath - if len(requestPath) > 0 && requestPath[len(requestPath)-1] == '/' { - redirectPath = requestPath[:len(requestPath)-1] - } else { - redirectPath = requestPath + "/" - } - c.Redirect(http.StatusMovedPermanently, redirectPath) // 301 永久重定向 - return - } - // 尝试不区分大小写的查找 - // 直接在 rootNode 上调用 findCaseInsensitivePath 方法 - ciPath, found := rootNode.findCaseInsensitivePath(requestPath, engine.RedirectTrailingSlash) - if found && engine.RedirectFixedPath { - c.Redirect(http.StatusMovedPermanently, BytesToString(ciPath)) // 301 永久重定向到修正后的路径 - return - } - } - } + requestPath := c.Request.URL.Path - // 构建处理链 - // 组合全局中间件和路由处理函数 - handlers := engine.globalHandlers + // 获取捕获到的文件路径参数 + filepath := c.Param("filepath") - // 如果启用了 MethodNotAllowed 处理,并且没有找到精确匹配的路由 - // 则在全局中间件之后添加 MethodNotAllowed 处理器 - if engine.HandleMethodNotAllowed { - handlers = append(handlers, MethodNotAllowed()) - } + // 构造文件服务器需要处理的请求路径 + // FileServer 会将请求路径与 http.Dir 的根路径结合 + // 我们需要移除相对路径前缀,只保留文件路径部分 + // 例如,如果 relativePath 是 "/static/",请求是 "/static/js/app.js" + // FileServer 需要的路径是 "/js/app.js" + // 这里的 filepath 参数已经包含了 "/" 前缀,例如 "/js/app.js" + // 所以直接使用 filepath 即可 + c.Request.URL.Path = filepath - // 如果启用了 UnMatchFS 处理,并且没有找到精确匹配的路由和 MethodNotAllowed - // 则在处理链的最后添加 UnMatchFS 处理器 - if engine.unMatchFS.ServeUnmatchedAsFS { - /* - var unMatchFSHandle = c.engine.unMatchFileServer - handlers = append(handlers, unMatchFSHandle) - */ - handlers = append(handlers, engine.UnMatchFSRoutes...) - } + // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 + // 这样我们可以在 FileServer 返回 404 或 403 时,使用 Engine 的 ErrorHandler 进行统一处理 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) - // 如果用户设置了 NoRoute 处理器,且没有匹配到任何路由、MethodNotAllowed 或 UnMatchFS - // 则在处理链的最后添加 NoRoute 处理器 - if engine.noRoute != nil { - handlers = append(handlers, engine.noRoute) - } else if len(engine.noRoutes) > 0 { - handlers = append(handlers, engine.noRoutes...) - } + // + // 调用 FileServer 处理请求 + fileServer.ServeHTTP(ecw, c.Request) - handlers = append(handlers, NotFound()) + // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler + ecw.processAfterFileServer() - c.handlers = handlers - c.Next() // 执行处理函数链 - //c.Writer.Flush() // 确保所有缓冲的响应数据被发送 + // 恢复原始请求路径,以便后续中间件或日志记录使用 + c.Request.URL.Path = requestPath + + // 中止处理链,因为 FileServer 已经处理了响应 + c.Abort() + }) +} + +// Group的StaticDir方式 +func (group *RouterGroup) StaticDir(relativePath, rootPath string) { + // 清理路径 + relativePath = path.Clean(relativePath) + rootPath = path.Clean(rootPath) + + // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 + if !strings.HasSuffix(relativePath, "/") { + relativePath += "/" + } + + // 创建一个文件系统处理器 + fileServer := http.FileServer(http.Dir(rootPath)) + + // 注册一个捕获所有路径的路由,使用自定义处理器 + // 注意:这里使用 ANY 方法,但 FileServer 通常只处理 GET 和 HEAD + // 我们可以通过在处理函数内部检查方法来限制 + group.ANY(relativePath+"*filepath", func(c *Context) { + // 检查是否是 GET 或 HEAD 方法 + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 + if group.engine.HandleMethodNotAllowed { + c.Next() + } else { + // 否则,返回 405 Method Not Allowed + group.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) + } + return + } + + requestPath := c.Request.URL.Path + + // 获取捕获到的文件路径参数 + filepath := c.Param("filepath") + + // 构造文件服务器需要处理的请求路径 + // FileServer 会将请求路径与 http.Dir 的根路径结合 + // 我们需要移除相对路径前缀,只保留文件路径部分 + // 例如,如果 relativePath 是 "/static/",请求是 "/static/js/app.js" + // FileServer 需要的路径是 "/js/app.js" + // 这里的 filepath 参数已经包含了 "/" 前缀,例如 "/js/app.js" + // 所以直接使用 filepath 即可 + c.Request.URL.Path = filepath + + // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 + // 这样我们可以在 FileServer 返回 404 或 403 时,使用 Engine 的 ErrorHandler 进行统一处理 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) + + // + // 调用 FileServer 处理请求 + fileServer.ServeHTTP(ecw, c.Request) + + // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler + ecw.processAfterFileServer() + + // 恢复原始请求路径,以便后续中间件或日志记录使用 + c.Request.URL.Path = requestPath + + // 中止处理链,因为 FileServer 已经处理了响应 + c.Abort() + }) +} + +// Static File 传入一个文件路径, 使用FileServer进行处理 +func (engine *Engine) StaticFile(relativePath, filePath string) { + // 清理路径 + relativePath = path.Clean(relativePath) + filePath = path.Clean(filePath) + + // 创建一个文件系统处理器,指向包含目标文件的目录 + // http.Dir 需要一个目录路径 + dir := path.Dir(filePath) + fileName := path.Base(filePath) + fileServer := http.FileServer(http.Dir(dir)) + + FileHandle := func(c *Context) { + // 检查是否是 GET 或 HEAD 方法 + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 + if engine.HandleMethodNotAllowed { + c.Next() + } else { + // 否则,返回 405 Method Not Allowed + engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) + } + return + } + + requestPath := c.Request.URL.Path + + // 构造文件服务器需要处理的请求路径 + // FileServer 会将请求路径与 http.Dir 的根路径结合 + // 我们需要将请求路径设置为文件名,以便 FileServer 找到正确的文件 + c.Request.URL.Path = "/" + fileName // FileServer 期望路径以 / 开头 + + // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) + + // 调用 FileServer 处理请求 + fileServer.ServeHTTP(ecw, c.Request) + + // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler + ecw.processAfterFileServer() + + // 恢复原始请求路径 + c.Request.URL.Path = requestPath + + // 中止处理链,因为 FileServer 已经处理了响应 + c.Abort() + } + + // 注册一个精确匹配的路由 + engine.GET(relativePath, FileHandle) + engine.HEAD(relativePath, FileHandle) + engine.OPTIONS(relativePath, FileHandle) + +} + +// Group的StaticFile +func (group *RouterGroup) StaticFile(relativePath, filePath string) { + // 清理路径 + relativePath = path.Clean(relativePath) + filePath = path.Clean(filePath) + + // 创建一个文件系统处理器,指向包含目标文件的目录 + // http.Dir 需要一个目录路径 + dir := path.Dir(filePath) + fileName := path.Base(filePath) + fileServer := http.FileServer(http.Dir(dir)) + + FileHandle := func(c *Context) { + // 检查是否是 GET 或 HEAD 方法 + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 + if group.engine.HandleMethodNotAllowed { + c.Next() + } else { + // 否则,返回 405 Method Not Allowed + group.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) + } + return + } + + requestPath := c.Request.URL.Path + + // 构造文件服务器需要处理的请求路径 + // FileServer 会将请求路径与 http.Dir 的根路径结合 + // 我们需要将请求路径设置为文件名,以便 FileServer 找到正确的文件 + c.Request.URL.Path = "/" + fileName // FileServer 期望路径以 / 开头 + + // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) + + // 调用 FileServer 处理请求 + fileServer.ServeHTTP(ecw, c.Request) + + // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler + ecw.processAfterFileServer() + + // 恢复原始请求路径 + c.Request.URL.Path = requestPath + + // 中止处理链,因为 FileServer 已经处理了响应 + c.Abort() + } + + // 注册一个精确匹配的路由 + group.GET(relativePath, FileHandle) + group.HEAD(relativePath, FileHandle) + group.OPTIONS(relativePath, FileHandle) +} + +// 维护一个Methods列表 +var ( + MethodGet = "GET" + MethodHead = "HEAD" + MethodPost = "POST" + MethodPut = "PUT" + MethodPatch = "PATCH" + MethodDelete = "DELETE" + MethodConnect = "CONNECT" + MethodOptions = "OPTIONS" + MethodTrace = "TRACE" +) + +var MethodsSet = map[string]struct{}{ + MethodGet: {}, + MethodHead: {}, + MethodPost: {}, + MethodPut: {}, + MethodPatch: {}, + MethodDelete: {}, + MethodConnect: {}, + MethodOptions: {}, + MethodTrace: {}, +} + +// HandleFunc 注册一个或多个 HTTP 方法的路由 +// methods 参数是一个字符串切片,包含要注册的 HTTP 方法(例如 []string{"GET", "POST"}) +// relativePath 是相对于当前组或 Engine 的路径 +// handlers 是处理函数链 +func (engine *Engine) HandleFunc(methods []string, relativePath string, handlers ...HandlerFunc) { + for _, method := range methods { + if _, ok := MethodsSet[method]; !ok { + panic("invalid method: " + method) + } + engine.Handle(method, relativePath, handlers...) + } +} + +// HandleFunc 注册一个或多个 HTTP 方法的路由 +// methods 参数是一个字符串切片,包含要注册的 HTTP 方法(例如 []string{"GET", "POST"}) +// relativePath 是相对于当前组或 Engine 的路径 +// handlers 是处理函数链 +func (group *RouterGroup) HandleFunc(methods []string, relativePath string, handlers ...HandlerFunc) { + for _, method := range methods { + if _, ok := MethodsSet[method]; !ok { + panic("invalid method: " + method) + } + group.Handle(method, relativePath, handlers...) + } +} + +// FileServer方式, 返回一个HandleFunc, 统一化处理 +func FileServer(fs http.FileSystem) HandlerFunc { + return func(c *Context) { + // 检查是否是 GET 或 HEAD 方法 + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 + if c.engine.HandleMethodNotAllowed { + c.Next() + } else { + // 否则,返回 405 Method Not Allowed + c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, fmt.Errorf("Method %s is Not Allowed on FileServer", c.Request.Method)) + } + return + } + + // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 + ecw := AcquireErrorCapturingResponseWriter(c) + defer ReleaseErrorCapturingResponseWriter(ecw) + + // 调用 http.FileServer 处理请求 + http.FileServer(fs).ServeHTTP(ecw, c.Request) + + // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler + ecw.processAfterFileServer() + + // 中止处理链,因为 FileServer 已经处理了响应 + c.Abort() + } } diff --git a/fileserver.go b/fileserver.go deleted file mode 100644 index 1aa1aaf..0000000 --- a/fileserver.go +++ /dev/null @@ -1,286 +0,0 @@ -// 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 ( - "errors" - "net/http" - "path" - "strings" -) - -// === FileServer相关 === - -var allowedFileServerMethods = map[string]struct{}{ - http.MethodGet: {}, - 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(http.StatusInternalServerError, ErrInputFSisNil) - } - } - - fileServerInstance := http.FileServer(fs) - return func(c *Context) { - FileServerHandleServe(c, fileServerInstance) - - // 中止处理链,因为 FileServer 已经处理了响应 - c.Abort() - } -} - -func FileServerHandleServe(c *Context, fsHandle http.Handler) { - if fsHandle == nil { - c.AddError(ErrInputFSisNil) - // 500 - c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil) - return - } - - // 检查是否是 GET 或 HEAD 方法 - if _, ok := allowedFileServerMethods[c.Request.Method]; !ok { - // 如果不是,且启用了 MethodNotAllowed 处理,则继续到 MethodNotAllowed 中间件 - if c.engine.HandleMethodNotAllowed { - c.Next() - } else { - if c.engine.noRoute == nil { - if c.Request.Method == http.MethodOptions { - //返回allow get - c.Writer.Header().Set("Allow", "GET, HEAD") - c.Status(http.StatusOK) - c.Abort() - return - } else { - // 否则,返回 405 Method Not Allowed - c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, ErrMethodNotAllowed) - } - } else { - c.Next() - } - } - return - } - - // 使用自定义的 ResponseWriter 包装器来捕获 FileServer 可能返回的错误状态码 - ecw := AcquireErrorCapturingResponseWriter(c) - defer ReleaseErrorCapturingResponseWriter(ecw) - - // 调用 http.FileServer 处理请求 - fsHandle.ServeHTTP(ecw, c.Request) - - // 在 FileServer 处理完成后,检查是否捕获到错误状态码,并调用 ErrorHandler - ecw.processAfterFileServer() -} - -// StaticDir 传入一个文件夹路径, 使用FileServer进行处理 -// r.StaticDir("/test/*filepath", "/var/www/test") -func (engine *Engine) StaticDir(relativePath, rootPath string) { - // 清理路径 - relativePath = path.Clean(relativePath) - rootPath = path.Clean(rootPath) - - // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 - if !strings.HasSuffix(relativePath, "/") { - relativePath += "/" - } - - // 创建一个文件系统处理器 - fileServer := http.FileServer(http.Dir(rootPath)) - - // 注册一个捕获所有路径的路由,使用自定义处理器 - // 注意:这里使用 ANY 方法,但 FileServer 通常只处理 GET 和 HEAD - // 我们可以通过在处理函数内部检查方法来限制 - engine.ANY(relativePath+"*filepath", GetStaticDirHandleFunc(fileServer)) -} - -// Group的StaticDir方式 -func (group *RouterGroup) StaticDir(relativePath, rootPath string) { - // 清理路径 - relativePath = path.Clean(relativePath) - rootPath = path.Clean(rootPath) - - // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 - if !strings.HasSuffix(relativePath, "/") { - relativePath += "/" - } - - // 创建一个文件系统处理器 - fileServer := http.FileServer(http.Dir(rootPath)) - - // 注册一个捕获所有路径的路由,使用自定义处理器 - // 注意:这里使用 ANY 方法,但 FileServer 通常只处理 GET 和 HEAD - // 我们可以通过在处理函数内部检查方法来限制 - group.ANY(relativePath+"*filepath", GetStaticDirHandleFunc(fileServer)) -} - -// GetStaticDirHandleFunc -func (engine *Engine) GetStaticDirHandle(rootPath string) HandlerFunc { - // 清理路径 - rootPath = path.Clean(rootPath) - - // 创建一个文件系统处理器 - fileServer := http.FileServer(http.Dir(rootPath)) - - return GetStaticDirHandleFunc(fileServer) -} - -// GetStaticDirHandleFunc -func (group *RouterGroup) GetStaticDirHandle(rootPath string) HandlerFunc { // 清理路径 - return group.engine.GetStaticDirHandle(rootPath) -} - -// GetStaticDirHandle -func GetStaticDirHandleFunc(fsHandle http.Handler) HandlerFunc { - return func(c *Context) { - requestPath := c.Request.URL.Path - - // 获取捕获到的文件路径参数 - filepath := c.Param("filepath") - - // 构造文件服务器需要处理的请求路径 - c.Request.URL.Path = filepath - - FileServerHandleServe(c, fsHandle) - - // 恢复原始请求路径,以便后续中间件或日志记录使用 - c.Request.URL.Path = requestPath - - // 中止处理链,因为 FileServer 已经处理了响应 - c.Abort() - } -} - -// Static File 传入一个文件路径, 使用FileServer进行处理 -func (engine *Engine) StaticFile(relativePath, filePath string) { - // 清理路径 - relativePath = path.Clean(relativePath) - filePath = path.Clean(filePath) - - FileHandle := engine.GetStaticFileHandle(filePath) - - // 注册一个精确匹配的路由 - engine.GET(relativePath, FileHandle) - engine.HEAD(relativePath, FileHandle) - engine.OPTIONS(relativePath, FileHandle) - -} - -// Group的StaticFile -func (group *RouterGroup) StaticFile(relativePath, filePath string) { - // 清理路径 - relativePath = path.Clean(relativePath) - filePath = path.Clean(filePath) - - FileHandle := group.GetStaticFileHandle(filePath) - - // 注册一个精确匹配的路由 - group.GET(relativePath, FileHandle) - group.HEAD(relativePath, FileHandle) - group.OPTIONS(relativePath, FileHandle) -} - -// GetStaticFileHandleFunc -func (engine *Engine) GetStaticFileHandle(filePath string) HandlerFunc { - // 清理路径 - filePath = path.Clean(filePath) - - // 创建一个文件系统处理器,指向包含目标文件的目录 - dir := path.Dir(filePath) - fileName := path.Base(filePath) - fileServer := http.FileServer(http.Dir(dir)) - - return GetStaticFileHandleFunc(fileServer, fileName) -} - -// GetStaticFileHandleFunc -func (group *RouterGroup) GetStaticFileHandle(filePath string) HandlerFunc { - // 清理路径 - filePath = path.Clean(filePath) - - // 创建一个文件系统处理器,指向包含目标文件的目录 - dir := path.Dir(filePath) - fileName := path.Base(filePath) - fileServer := http.FileServer(http.Dir(dir)) - - return GetStaticFileHandleFunc(fileServer, fileName) -} - -// GetStaticFileHandleFunc -func GetStaticFileHandleFunc(fsHandle http.Handler, fileName string) HandlerFunc { - return func(c *Context) { - requestPath := c.Request.URL.Path - - // 构造文件服务器需要处理的请求路径 - c.Request.URL.Path = "/" + fileName - - FileServerHandleServe(c, fsHandle) - - // 恢复原始请求路径 - c.Request.URL.Path = requestPath - - // 中止处理链,因为 FileServer 已经处理了响应 - c.Abort() - } -} - -// StaticFS -func (engine *Engine) StaticFS(relativePath string, fs http.FileSystem) { - // 清理路径 - relativePath = path.Clean(relativePath) - - // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 - if !strings.HasSuffix(relativePath, "/") { - relativePath += "/" - } - - fileServer := http.StripPrefix(relativePath, http.FileServer(fs)) - engine.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer)) -} - -// Group的StaticFS -func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) { - // 清理路径 - relativePath = path.Clean(relativePath) - - // 确保相对路径以 '/' 结尾,以便 FileServer 正确处理子路径 - if !strings.HasSuffix(relativePath, "/") { - relativePath += "/" - } - - fileServer := http.StripPrefix(relativePath, http.FileServer(fs)) - group.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer)) -} - -// GetStaticFSHandleFunc -func GetStaticFSHandleFunc(fsHandle http.Handler) HandlerFunc { - return func(c *Context) { - - FileServerHandleServe(c, fsHandle) - - // 中止处理链,因为 FileServer 已经处理了响应 - c.Abort() - } -} - -// GetStaticFSHandleFunc -func (engine *Engine) GetStaticFSHandle(fs http.FileSystem) HandlerFunc { - fileServer := http.FileServer(fs) - return GetStaticFSHandleFunc(fileServer) -} - -// GetStaticFSHandleFunc -func (group *RouterGroup) GetStaticFSHandle(fs http.FileSystem) HandlerFunc { - fileServer := http.FileServer(fs) - return GetStaticFSHandleFunc(fileServer) -} diff --git a/go.mod b/go.mod index f9d10a9..74c947b 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,12 @@ module github.com/infinite-iroha/touka -go 1.25.1 +go 1.24.4 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.3 - github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e + github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 + github.com/WJQSERVER-STUDIO/httpc v0.7.0 + github.com/fenthope/reco v0.0.3 + github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 ) -require ( - github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.49.0 // indirect -) +require github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index b75fec4..22fb00b 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,10 @@ -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.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk= -github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= -github.com/WJQSERVER/wanf v0.0.3 h1:OqhG7ETiR5Knqr0lmbb+iUMw9O7re2vEogjVf06QevM= -github.com/WJQSERVER/wanf v0.0.3/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= -github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= -github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 h1:JLtFd00AdFg/TP+dtvIzLkdHwKUGPOAijN1sMtEYoFg= +github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc= +github.com/WJQSERVER-STUDIO/httpc v0.7.0 h1:iHhqlxppJBjlmvsIjvLZKRbWXqSdbeSGGofjHGmqGJc= +github.com/WJQSERVER-STUDIO/httpc v0.7.0/go.mod h1:M7KNUZjjhCkzzcg9lBPs9YfkImI+7vqjAyjdA19+joE= +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-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk= +github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= diff --git a/licenses/httprouter-license b/licenses/httprouter-license deleted file mode 100644 index 3ab5aa6..0000000 --- a/licenses/httprouter-license +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2013, Julien Schmidt -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/logreco.go b/logreco.go index 4bda8d3..2fc1a7a 100644 --- a/logreco.go +++ b/logreco.go @@ -1,7 +1,3 @@ -// 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 ( diff --git a/maxreader.go b/maxreader.go deleted file mode 100644 index c6201e6..0000000 --- a/maxreader.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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 ( - "fmt" - "io" - "sync/atomic" -) - -// ErrBodyTooLarge 是当读取的字节数超过 MaxBytesReader 设置的限制时返回的错误. -// 将其定义为可导出的变量, 方便调用方使用 errors.Is 进行判断. -var ErrBodyTooLarge = fmt.Errorf("body too large") - -// maxBytesReader 是一个实现了 io.ReadCloser 接口的结构体. -// 它包装了另一个 io.ReadCloser, 并限制了从其中读取的最大字节数. -type maxBytesReader struct { - // r 是底层的 io.ReadCloser. - r io.ReadCloser - // n 是允许读取的最大字节数. - n int64 - // read 是一个原子计数器, 用于安全地在多个 goroutine 之间跟踪已读取的字节数. - read atomic.Int64 -} - -// NewMaxBytesReader 创建并返回一个 io.ReadCloser, 它从 r 读取数据, -// 但在读取的字节数超过 n 后会返回 ErrBodyTooLarge 错误. -// -// 如果 r 为 nil, 会 panic. -// 如果 n 小于 0, 则读取不受限制, 直接返回原始的 r. -func NewMaxBytesReader(r io.ReadCloser, n int64) io.ReadCloser { - if r == nil { - panic("NewMaxBytesReader called with a nil reader") - } - // 如果限制为负数, 意味着不限制, 直接返回原始的 ReadCloser. - if n < 0 { - return r - } - return &maxBytesReader{ - r: r, - n: n, - } -} - -// Read 方法从底层的 ReadCloser 读取数据, 同时检查是否超过了字节限制. -func (mbr *maxBytesReader) Read(p []byte) (int, error) { - // 在函数开始时只加载一次原子变量, 减少后续的原子操作开销. - readSoFar := mbr.read.Load() - - // 快速失败路径: 如果在读取之前就已经达到了限制, 立即返回错误. - if readSoFar >= mbr.n { - return 0, ErrBodyTooLarge - } - - // 计算当前还可以读取多少字节. - remaining := mbr.n - readSoFar - - // 如果请求读取的长度大于剩余可读长度, 我们需要限制本次读取的长度. - // 这样可以保证即使 p 很大, 我们也只读取到恰好达到 maxBytes 的字节数. - if int64(len(p)) > remaining { - p = p[:remaining] - } - - // 从底层 Reader 读取数据. - n, err := mbr.r.Read(p) - - // 如果实际读取到了数据, 更新原子计数器. - if n > 0 { - readSoFar = mbr.read.Add(int64(n)) - } - - // 如果底层 Read 返回错误 (例如 io.EOF). - if err != nil { - // 如果是 EOF, 并且我们还没有读满 n 个字节, 这是一个正常的结束. - // 如果已经读满了 n 个字节, 即使是 EOF, 也可以认为成功了. - return n, err - } - - // 读后检查: 如果这次读取使得总字节数超过了限制, 返回超限错误. - // 这是处理"跨越"限制情况的关键. - if readSoFar > mbr.n { - // 返回实际读取的字节数 n, 并附上超限错误. - // 上层调用者知道已经有 n 字节被读入了缓冲区 p, 但流已因超限而关闭. - return n, ErrBodyTooLarge - } - - // 一切正常, 返回读取的字节数和 nil 错误. - return n, nil -} - -// Close 方法关闭底层的 ReadCloser, 保证资源释放. -func (mbr *maxBytesReader) Close() error { - return mbr.r.Close() -} diff --git a/mergectx.go b/mergectx.go deleted file mode 100644 index 7ce2031..0000000 --- a/mergectx.go +++ /dev/null @@ -1,122 +0,0 @@ -// 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 ( - "context" - "sync" - "time" -) - -// mergedContext 实现了 context.Context 接口, 是 Merge 函数返回的实际类型. -type mergedContext struct { - // 嵌入一个基础 context, 它持有最早的 deadline 和取消信号. - context.Context - // 保存了所有的父 context, 用于 Value() 方法的查找. - parents []context.Context - // 用于手动取消此 mergedContext 的函数. - cancel context.CancelFunc -} - -// MergeCtx 创建并返回一个新的 context.Context. -// 这个新的 context 会在任何一个传入的父 contexts 被取消时, 或者当返回的 CancelFunc 被调用时, -// 自动被取消 (逻辑或关系). -// -// 新的 context 会继承: -// - Deadline: 所有父 context 中最早的截止时间. -// - Value: 按传入顺序从第一个能找到值的父 context 中获取值. -func MergeCtx(parents ...context.Context) (ctx context.Context, cancel context.CancelFunc) { - if len(parents) == 0 { - return context.WithCancel(context.Background()) - } - if len(parents) == 1 { - return context.WithCancel(parents[0]) - } - - var earliestDeadline time.Time - for _, p := range parents { - if deadline, ok := p.Deadline(); ok { - if earliestDeadline.IsZero() || deadline.Before(earliestDeadline) { - earliestDeadline = deadline - } - } - } - - var baseCtx context.Context - var baseCancel context.CancelFunc - if !earliestDeadline.IsZero() { - baseCtx, baseCancel = context.WithDeadline(context.Background(), earliestDeadline) - } else { - baseCtx, baseCancel = context.WithCancel(context.Background()) - } - - mc := &mergedContext{ - Context: baseCtx, - parents: parents, - cancel: baseCancel, - } - - // 启动一个监控 goroutine. - go func() { - defer mc.cancel() - - // orDone 会返回一个 channel, 当任何一个父 context 被取消时, 这个 channel 就会关闭. - // 同时监听 baseCtx.Done() 以便支持手动取消. - select { - case <-orDone(mc.parents...): - case <-mc.Context.Done(): - } - }() - - return mc, mc.cancel -} - -// Value 返回当前Ctx Value -func (mc *mergedContext) Value(key any) any { - return mc.Context.Value(key) -} - -// Deadline 实现了 context.Context 的 Deadline 方法. -func (mc *mergedContext) Deadline() (deadline time.Time, ok bool) { - return mc.Context.Deadline() -} - -// Done 实现了 context.Context 的 Done 方法. -func (mc *mergedContext) Done() <-chan struct{} { - return mc.Context.Done() -} - -// Err 实现了 context.Context 的 Err 方法. -func (mc *mergedContext) Err() error { - return mc.Context.Err() -} - -// orDone 是一个辅助函数, 返回一个 channel. -// 当任意一个输入 context 的 Done() channel 关闭时, orDone 返回的 channel 也会关闭. -// 这是一个非阻塞的、不会泄漏 goroutine 的实现. -func orDone(contexts ...context.Context) <-chan struct{} { - done := make(chan struct{}) - - var once sync.Once - closeDone := func() { - once.Do(func() { - close(done) - }) - } - - // 为每个父 context 启动一个 goroutine. - for _, ctx := range contexts { - go func(c context.Context) { - select { - case <-c.Done(): - closeDone() - case <-done: - // orDone 已经被其他 goroutine 关闭了, 当前 goroutine 可以安全退出. - } - }(ctx) - } - - return done -} diff --git a/midware_x.go b/midware_x.go deleted file mode 100644 index 3e21329..0000000 --- a/midware_x.go +++ /dev/null @@ -1,80 +0,0 @@ -// 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 - -type MiddlewareXFunc func() HandlerFunc - -// UseChainIf 是一个条件中间件包装器,用于一组中间件 -// 如果 `condition` 为 true,它将按顺序创建并执行提供的 `factories` 生成的中间件链 -// 否则,它会直接跳过整个链 -func (engine *Engine) UseChainIf(condition bool, factories ...MiddlewareXFunc) HandlerFunc { - // 如果条件不满足或没有提供任何工厂函数,返回一个“穿透”中间件 - if !condition || len(factories) == 0 { - return func(c *Context) { - c.Next() - } - } - - // 在配置路由时就创建好所有中间件实例 - middlewares := make(HandlersChain, 0, len(factories)) - for _, factory := range factories { - if factory != nil { - middlewares = append(middlewares, factory()) - } - } - - // 返回一个处理器,该处理器负责执行这个子链 - // 这个实现通过临时替换 Context 的处理器链来注入子链,是健壮的 - return func(c *Context) { - // 将当前的处理链和索引位置保存下来 - originalHandlers := c.handlers - originalIndex := c.index - - // 创建一个新的临时处理链 - // 它由我们预先创建的 `middlewares` 和一个特殊的“恢复”处理器组成 - subChain := make(HandlersChain, len(middlewares)+1) - copy(subChain, middlewares) - - // 在子链的末尾添加“恢复”处理器 - // 当所有 `middlewares` 都执行完毕并调用了 Next() 后,这个函数会被执行 - subChain[len(middlewares)] = func(ctx *Context) { - // 恢复原始的处理链状态 - ctx.handlers = originalHandlers - ctx.index = originalIndex - // 继续执行原始处理链中 `UseChainIf` 之后的下一个处理器 - ctx.Next() - } - - // 将 Context 的处理器链临时替换新的的子链,并重置索引以从头开始 - c.handlers = subChain - c.index = -1 - - c.Next() - } -} - -// UseIf 是一个条件中间件包装器 -func (engine *Engine) UseIf(condition bool, middlewareX MiddlewareXFunc) HandlerFunc { - if !condition { - return func(c *Context) { - c.Next() - } - } - - // 只有当条件为 true 时,才调用工厂函数创建中间件实例 - // 注意:这会导致每次请求都创建一个新的中间件实例(如果中间件本身有状态) - // 如果中间件是无状态的,可以进行优化 - - // 优化:只创建一次 - - return func(c *Context) { - middleware := middlewareX() - if middleware != nil { - middleware(c) - } else { - c.Next() - } - } -} diff --git a/path.go b/path.go deleted file mode 100644 index 6c5cc4c..0000000 --- a/path.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 ( - "path" - "strings" -) - -// resolveRoutePath 安全地拼接基础路径和相对路径,并正确处理尾部斜杠。 -// 这是一个为高性能路由注册优化的版本。 -func resolveRoutePath(basePath, relativePath string) string { - // 如果相对路径为空,直接返回基础路径 - if relativePath == "" { - return basePath - } - // 如果基础路径为空,直接返回相对路径(确保以/开头) - if basePath == "" { - return relativePath - } - - // 使用 strings.Builder 来高效构建路径,避免多次字符串分配 - var b strings.Builder - // 估算一个合理的容量以减少扩容 - b.Grow(len(basePath) + len(relativePath) + 1) - b.WriteString(basePath) - - // 检查 basePath 是否以斜杠结尾 - if basePath[len(basePath)-1] != '/' { - b.WriteByte('/') // 如果没有,则添加 - } - - // 检查 relativePath 是否以斜杠开头,如果是,则移除 - if relativePath[0] == '/' { - b.WriteString(relativePath[1:]) - } else { - b.WriteString(relativePath) - } - - // path.Clean 仍然是处理 '..' 和 '//' 等复杂情况最可靠的方式。 - // 我们可以只在最终结果上调用一次,而不是在拼接过程中。 - finalPath := path.Clean(b.String()) - - // 关键:如果原始 relativePath 有尾部斜杠,但 Clean 把它移除了,我们要加回来。 - // 只有当最终路径不是根路径 "/" 时才需要加回。 - if strings.HasSuffix(relativePath, "/") && finalPath != "/" { - return finalPath + "/" - } - - return finalPath -} diff --git a/path_test.go b/path_test.go deleted file mode 100644 index 64ff185..0000000 --- a/path_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// touka/path_test.go -package touka - -import ( - "fmt" - "path" - "strings" - "testing" -) - -func TestResolveRoutePath(t *testing.T) { - // 定义一组测试用例 - testCases := []struct { - basePath string - relativePath string - expected string - }{ - // --- 基本情况 --- - {basePath: "/api", relativePath: "/v1", expected: "/api/v1"}, - {basePath: "/api/", relativePath: "v1", expected: "/api/v1"}, - {basePath: "/api", relativePath: "v1", expected: "/api/v1"}, - {basePath: "/api/", relativePath: "/v1", expected: "/api/v1"}, - - // --- 尾部斜杠处理 --- - {basePath: "/api", relativePath: "/v1/", expected: "/api/v1/"}, - {basePath: "/api/", relativePath: "v1/", expected: "/api/v1/"}, - {basePath: "", relativePath: "/v1/", expected: "/v1/"}, - {basePath: "/", relativePath: "/v1/", expected: "/v1/"}, - - // --- 根路径和空路径 --- - {basePath: "/", relativePath: "/", expected: "/"}, - {basePath: "/api", relativePath: "/", expected: "/api/"}, - {basePath: "/api/", relativePath: "/", expected: "/api/"}, - {basePath: "/", relativePath: "/users", expected: "/users"}, - {basePath: "/users", relativePath: "", expected: "/users"}, - {basePath: "", relativePath: "/users", expected: "/users"}, - - // --- 路径清理测试 (由 path.Clean 处理) --- - {basePath: "/api/v1", relativePath: "../v2", expected: "/api/v2"}, - {basePath: "/api/v1/", relativePath: "../v2/", expected: "/api/v2/"}, - {basePath: "/api//v1", relativePath: "/users", expected: "/api/v1/users"}, - {basePath: "/api/./v1", relativePath: "/users", expected: "/api/v1/users"}, - } - - for _, tc := range testCases { - // 使用 t.Run 为每个测试用例创建一个子测试,方便定位问题 - testName := fmt.Sprintf("base:'%s', rel:'%s'", tc.basePath, tc.relativePath) - t.Run(testName, func(t *testing.T) { - result := resolveRoutePath(tc.basePath, tc.relativePath) - if result != tc.expected { - t.Errorf("resolveRoutePath('%s', '%s') = '%s'; want '%s'", - tc.basePath, tc.relativePath, result, tc.expected) - } - }) - } -} - -// 性能基准测试,用于观测优化效果 -func BenchmarkResolveRoutePath(b *testing.B) { - basePath := "/api/v1/some/long/path" - relativePath := "/users/profile/details/" - - // b.N 是由 testing 包提供的循环次数 - for i := 0; i < b.N; i++ { - // 在循环内调用被测试的函数 - resolveRoutePath(basePath, relativePath) - } -} - -// (可选)可以保留旧的实现,进行性能对比 -func resolveRoutePath_Old(basePath, relativePath string) string { - if relativePath == "/" { - if basePath != "" && basePath != "/" && !strings.HasSuffix(basePath, "/") { - return basePath + "/" - } - return basePath - } - finalPath := path.Clean(basePath + "/" + relativePath) - if strings.HasSuffix(relativePath, "/") && !strings.HasSuffix(finalPath, "/") { - return finalPath + "/" - } - return finalPath -} - -func BenchmarkResolveRoutePath_Old(b *testing.B) { - basePath := "/api/v1/some/long/path" - relativePath := "/users/profile/details/" - for i := 0; i < b.N; i++ { - resolveRoutePath_Old(basePath, relativePath) - } -} - -func BenchmarkJoinStd(b *testing.B) { - basePath := "/api/v1/some/long/path" - relativePath := "/users/profile/details/" - for i := 0; i < b.N; i++ { - path.Join(basePath, relativePath) - } -} diff --git a/recovery.go b/recovery.go index 5dfb837..a50f54b 100644 --- a/recovery.go +++ b/recovery.go @@ -1,7 +1,4 @@ -// 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. +// 文件: touka/recovery.go package touka import ( diff --git a/respw.go b/respw.go index 2cf6700..ebbc9ee 100644 --- a/respw.go +++ b/respw.go @@ -1,7 +1,3 @@ -// 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 ( diff --git a/serve.go b/serve.go index 7e05b8c..885f8c3 100644 --- a/serve.go +++ b/serve.go @@ -1,7 +1,3 @@ -// 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 ( @@ -128,79 +124,6 @@ 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 服务器 @@ -236,22 +159,6 @@ 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 { diff --git a/sse.go b/sse.go deleted file mode 100644 index 3b98800..0000000 --- a/sse.go +++ /dev/null @@ -1,184 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2025 WJQSERVER. All rights reserved. -// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization. -package touka - -import ( - "bytes" - "io" - "net/http" - "strings" -) - -// Event 代表一个服务器发送事件(SSE). -type Event struct { - // Event 是事件的名称. - Event string - // Data 是事件的内容, 可以是多行文本. - Data string - // Id 是事件的唯一标识符. - Id string - // Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接. - Retry string -} - -// Render 将事件格式化并写入给定的 writer. -// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题. -// 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配. -func (e *Event) Render(w io.Writer) error { - var buf bytes.Buffer - - if len(e.Id) > 0 { - buf.WriteString("id: ") - buf.WriteString(e.Id) - buf.WriteString("\n") - } - if len(e.Event) > 0 { - buf.WriteString("event: ") - buf.WriteString(e.Event) - buf.WriteString("\n") - } - if len(e.Data) > 0 { - lines := strings.Split(e.Data, "\n") - for _, line := range lines { - buf.WriteString("data: ") - buf.WriteString(line) - buf.WriteString("\n") - } - } - if len(e.Retry) > 0 { - buf.WriteString("retry: ") - buf.WriteString(e.Retry) - buf.WriteString("\n") - } - - // 每个事件都以一个额外的换行符结尾. - buf.WriteString("\n") - - // 直接将 buffer 的内容写入 writer, 避免生成中间字符串. - _, err := buf.WriteTo(w) - return err -} - -// EventStream 启动一个 SSE 事件流. -// 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. -// -// 详细用法: -// -// r.GET("/sse/callback", func(c *touka.Context) { -// // streamer 回调函数会在一个循环中被调用. -// c.EventStream(func(w io.Writer) bool { -// event := touka.Event{ -// Event: "time-tick", -// Data: time.Now().Format(time.RFC1123), -// } -// -// if err := event.Render(w); err != nil { -// // 发生写入错误, 停止发送. -// return false // 返回 false 结束事件流. -// } -// -// time.Sleep(2 * time.Second) -// return true // 返回 true 继续事件流. -// }) -// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. -// fmt.Println("Client disconnected from /sse/callback") -// }) -func (c *Context) EventStream(streamer func(w io.Writer) bool) { - // 为现代网络协议优化头部. - c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - c.Writer.Header().Set("Cache-Control", "no-cache, no-transform") - c.Writer.Header().Del("Connection") - c.Writer.Header().Del("Transfer-Encoding") - - c.Writer.WriteHeader(http.StatusOK) - c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在. - - for { - select { - case <-c.Request.Context().Done(): - return - default: - if !streamer(c.Writer) { - return - } - c.Writer.Flush() - } - } -} - -// EventStreamChan 返回用于 SSE 事件流的 channel. -// 这是为高级并发场景设计的、更灵活的API. -// -// 重要: -// - 调用者必须 close(eventChan) 来结束事件流. -// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. -// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). -// -// 详细用法: -// -// r.GET("/sse/channel", func(c *touka.Context) { -// eventChan, errChan := c.EventStreamChan() -// -// // 必须在独立的goroutine中处理错误和连接断开. -// go func() { -// if err := <-errChan; err != nil { -// c.Errorf("SSE channel error: %v", err) -// } -// }() -// -// // 在另一个goroutine中异步发送事件. -// go func() { -// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. -// defer close(eventChan) -// -// for i := 1; i <= 5; i++ { -// select { -// case <-c.Request.Context().Done(): -// return // 客户端已断开, 退出 goroutine. -// default: -// eventChan <- touka.Event{ -// Id: fmt.Sprintf("%d", i), -// Data: "hello from channel", -// } -// time.Sleep(2 * time.Second) -// } -// } -// }() -// }) -func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { - eventChan := make(chan Event) - errChan := make(chan error, 1) - - c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - c.Writer.Header().Set("Cache-Control", "no-cache, no-transform") - c.Writer.Header().Del("Connection") - c.Writer.Header().Del("Transfer-Encoding") - - c.Writer.WriteHeader(http.StatusOK) - c.Writer.Flush() - - go func() { - defer close(errChan) - - for { - select { - case event, ok := <-eventChan: - if !ok { - return - } - if err := event.Render(c.Writer); err != nil { - errChan <- err - return - } - c.Writer.Flush() - case <-c.Request.Context().Done(): - errChan <- c.Request.Context().Err() - return - } - } - }() - - return eventChan, errChan -} diff --git a/testutil.go b/testutil.go index 3511320..63af207 100644 --- a/testutil.go +++ b/testutil.go @@ -1,7 +1,3 @@ -// 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 ( diff --git a/touka.go b/touka.go index 837d62d..ba8400d 100644 --- a/touka.go +++ b/touka.go @@ -1,7 +1,3 @@ -// 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 ( @@ -46,28 +42,3 @@ type RouteInfo struct { Handler string // 处理函数名称 Group string // 路由分组 } - -// 维护一个Methods列表 -var ( - MethodGet = "GET" - MethodHead = "HEAD" - MethodPost = "POST" - MethodPut = "PUT" - MethodPatch = "PATCH" - MethodDelete = "DELETE" - MethodConnect = "CONNECT" - MethodOptions = "OPTIONS" - MethodTrace = "TRACE" -) - -var MethodsSet = map[string]struct{}{ - MethodGet: {}, - MethodHead: {}, - MethodPost: {}, - MethodPut: {}, - MethodPatch: {}, - MethodDelete: {}, - MethodConnect: {}, - MethodOptions: {}, - MethodTrace: {}, -} diff --git a/tree.go b/tree.go index 31246a5..6f99223 100644 --- a/tree.go +++ b/tree.go @@ -2,43 +2,51 @@ // 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 + +package touka // 定义包名为 touka,该包可能是一个路由或Web框架的核心组件 import ( - "net/url" - "strings" - "unicode" - "unicode/utf8" - "unsafe" + "bytes" // 导入 bytes 包,用于操作字节切片 + "net/url" // 导入 net/url 包,用于 URL 解析和转义 + "strings" // 导入 strings 包,用于字符串操作 + "unicode" // 导入 unicode 包,用于处理 Unicode 字符 + "unicode/utf8" // 导入 unicode/utf8 包,用于 UTF-8 编码和解码 + "unsafe" // 导入 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)) } -// Param 是单个 URL 参数, 由键和值组成. +var ( + strColon = []byte(":") // 定义字节切片常量,表示冒号,用于路径参数识别 + strStar = []byte("*") // 定义字节切片常量,表示星号,用于捕获所有路径识别 + strSlash = []byte("/") // 定义字节切片常量,表示斜杠,用于路径分隔符识别 +) + +// 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 { @@ -48,24 +56,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 { @@ -75,7 +83,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)) // 找出两个字符串中较短的长度 @@ -85,61 +93,64 @@ 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 { - colons := strings.Count(path, ":") - stars := strings.Count(path, "*") - return uint16(colons + stars) + var n uint16 + s := StringToBytes(path) // 将路径字符串转换为字节切片 + n += uint16(bytes.Count(s, strColon)) // 统计冒号的数量 + n += uint16(bytes.Count(s, strStar)) // 统计星号的数量 + return n } -// countSections 计算路径中斜杠('/')的数量, 即路径段的数量. +// countSections 计算路径中斜杠('/')的数量,即路径段的数量。 func countSections(path string) uint16 { - return uint16(strings.Count(path, "/")) + 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] @@ -147,9 +158,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:] // 除去原位置字符的其余部分 } @@ -157,13 +168,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 // 设置为根节点类型 @@ -174,12 +185,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:], // 子节点路径是当前节点路径的剩余部分 @@ -188,27 +199,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) // 更新父节点完整路径索引 @@ -227,8 +238,8 @@ walk: // 外部循环用于遍历和构建路由树 } } - // 否则, 插入新节点 - // 如果第一个字符不是 ':' 也不是 '*', 且当前节点不是 catchAll 类型 + // 否则,插入新节点 + // 如果第一个字符不是 ':' 也不是 '*',且当前节点不是 catchAll 类型 if c != ':' && c != '*' && n.nType != catchAll { // 将新字符添加到索引字符串 n.indices += BytesToString([]byte{c}) // []byte 用于正确的 Unicode 字符转换 @@ -239,18 +250,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 // 继续外部循环 } @@ -258,7 +269,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 表示通配符冲突 @@ -268,13 +279,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 // 设置完整路径 @@ -282,20 +293,20 @@ walk: // 外部循环用于遍历和构建路由树 } } -// findWildcard 搜索通配符段并检查名称是否包含无效字符. -// 如果未找到通配符, 则返回 -1 作为索引. +// findWildcard 搜索通配符段并检查名称是否包含无效字符。 +// 如果未找到通配符,则返回 -1 作为索引。 func findWildcard(path string) (wildcard string, i int, valid bool) { // 查找开始位置 escapeColon := false // 是否正在处理转义字符 for start, c := range []byte(path) { if escapeColon { escapeColon = false - if c == ':' { // 如果转义字符是 ':', 则跳过 + if c == ':' { // 如果转义字符是 ':',则跳过 continue } panic("invalid escape string in path '" + path + "'") // 无效的转义字符串 } - if c == '\\' { // 如果是反斜杠, 则设置转义标志 + if c == '\\' { // 如果是反斜杠,则设置转义标志 escapeColon = true continue } @@ -308,36 +319,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) @@ -357,7 +368,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) n = child // 移动到新创建的参数节点 n.priority++ // 增加优先级 - // 如果路径不以通配符结束, 则会有一个以 '/' 开头的子路径 + // 如果路径不以通配符结束,则会有一个以 '/' 开头的子路径 if len(wildcard) < len(path) { path = path[len(wildcard):] // 剩余路径去除通配符部分 @@ -365,19 +376,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 + "'") // 报错:捕获所有路由只能在路径末尾 } // 检查路径段冲突 @@ -386,34 +397,34 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) if len(n.children) != 0 { pathSeg, _, _ = strings.Cut(n.children[0].path, "/") } - panic("catch-all wildcard '" + path + // 报错: 捕获所有通配符与现有路径段冲突 + panic("catch-all wildcard '" + path + // 报错:捕获所有通配符与现有路径段冲突 "' in new path '" + fullPath + "' conflicts with existing path segment '" + pathSeg + "' in existing prefix '" + n.path + pathSeg + "'") } - // 当前固定宽度为 1, 用于 '/' + // 当前固定宽度为 1,用于 '/' i-- if i < 0 || path[i] != '/' { - panic("no / before catch-all in path '" + fullPath + "'") // 报错: 捕获所有之前没有 '/' + panic("no / before catch-all in path '" + fullPath + "'") // 报错:捕获所有之前没有 '/' } n.path = path[:i] // 当前节点路径更新为 catchAll 之前的部分 - // 第一个节点: 路径为空的 catchAll 节点 + // 第一个节点:路径为空的 catchAll 节点 child := &node{ wildChild: true, // 标记为有通配符子节点 nType: catchAll, // 类型为 catchAll fullPath: fullPath, // 设置完整路径 } - n.addChild(child) // 添加子节点 - n.indices = "/" // 索引设置为 '/' - n = child // 移动到新创建的 catchAll 节点 - n.priority++ // 增加优先级 + n.addChild(child) // 添加子节点 + n.indices = string('/') // 索引设置为 '/' + n = child // 移动到新创建的 catchAll 节点 + n.priority++ // 增加优先级 - // 第二个节点: 包含变量的节点 + // 第二个节点:包含变量的节点 child = &node{ path: path[i:], // 路径为 catchAll 的实际路径段 nType: catchAll, // 类型为 catchAll @@ -426,7 +437,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) return // 完成 } - // 如果没有找到通配符, 简单地插入路径和处理函数 + // 如果没有找到通配符,简单地插入路径和处理函数 n.path = path // 设置当前节点路径 n.handlers = handlers // 设置处理函数 n.fullPath = fullPath // 设置完整路径 @@ -440,16 +451,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 // 全局参数计数 @@ -460,16 +471,11 @@ 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] @@ -512,20 +518,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++ @@ -533,7 +539,7 @@ walk: // 外部循环用于遍历路由树 // 保存参数值 if params != nil { - // 如果需要, 预分配容量 + // 如果需要,预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -553,12 +559,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:] // 移除已提取的参数部分 @@ -567,16 +573,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 == "/") } @@ -585,7 +591,7 @@ walk: // 外部循环用于遍历路由树 case catchAll: // 捕获所有节点 // 保存参数值 if params != nil { - // 如果需要, 预分配容量 + // 如果需要,预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -605,7 +611,7 @@ walk: // 外部循环用于遍历路由树 } } (*value.params)[i] = Param{ // 存储参数 - Key: n.path[2:], // 参数键名(去除星号) + Key: n.path[2:], // 参数键名(去除星号) Value: val, // 参数值 } } @@ -621,7 +627,7 @@ walk: // 外部循环用于遍历路由树 } if path == prefix { // 如果路径完全匹配当前节点的前缀 - // 如果当前路径不等于 '/' 且节点没有注册的处理函数, 且最近匹配的节点有子节点 + // 如果当前路径不等于 '/' 且节点没有注册的处理函数,且最近匹配的节点有子节点 // 当前节点需要回溯到最后一个有效的 skippedNode if n.handlers == nil && path != "/" { for length := len(*skippedNodes); length > 0; length-- { @@ -638,26 +644,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] // 移动到对应的子节点 @@ -670,11 +676,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 != "/" { @@ -697,17 +703,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( @@ -720,7 +726,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: @@ -736,12 +742,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 // 保存原始路径 @@ -749,13 +755,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) { @@ -769,11 +775,11 @@ walk: // 外部循环用于遍历路由树 } } } - return nil // 未找到, 返回 nil + return nil // 未找到,返回 nil } - // 如果此节点没有通配符(参数或捕获所有)子节点, - // 我们可以直接查找下一个子节点并继续遍历树. + // 如果此节点没有通配符(参数或捕获所有)子节点, + // 我们可以直接查找下一个子节点并继续遍历树。 if !n.wildChild { // 跳过已处理的 rune 字节 rb = shiftNRuneBytes(rb, npLen) @@ -793,9 +799,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]) { @@ -816,17 +822,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) @@ -844,18 +850,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++ @@ -864,7 +870,7 @@ walk: // 外部循环用于遍历路由树 // 将参数值添加到不区分大小写路径中 ciPath = append(ciPath, path[:end]...) - // 我们需要继续深入! + // 我们需要继续深入! if end < len(path) { if len(n.children) > 0 { // 继续处理子节点 @@ -876,45 +882,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 deleted file mode 100644 index d3ffdfa..0000000 --- a/tree_test.go +++ /dev/null @@ -1,1078 +0,0 @@ -// Copyright 2013 Julien Schmidt. All rights reserved. -// 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_test.go is gin's fork, you can see https://github.com/gin-gonic/gin/blob/master/tree_test.go - -package touka - -import ( - "fmt" - "reflect" - "regexp" - "strings" - "testing" -) - -// Used as a workaround since we can't compare functions or their addresses -var fakeHandlerValue string - -func fakeHandler(val string) HandlersChain { - return HandlersChain{func(c *Context) { - fakeHandlerValue = val - }} -} - -type testRequests []struct { - path string - nilHandler bool - route string - ps Params -} - -func getParams() *Params { - ps := make(Params, 0, 20) - return &ps -} - -func getSkippedNodes() *[]skippedNode { - ps := make([]skippedNode, 0, 20) - return &ps -} - -func checkRequests(t *testing.T, tree *node, requests testRequests, unescapes ...bool) { - unescape := false - if len(unescapes) >= 1 { - unescape = unescapes[0] - } - - for _, request := range requests { - value := tree.getValue(request.path, getParams(), getSkippedNodes(), unescape) - - if value.handlers == nil { - if !request.nilHandler { - t.Errorf("handle mismatch for route '%s': Expected non-nil handle", request.path) - } - } else if request.nilHandler { - t.Errorf("handle mismatch for route '%s': Expected nil handle", request.path) - } else { - value.handlers[0](nil) - if fakeHandlerValue != request.route { - t.Errorf("handle mismatch for route '%s': Wrong handle (%s != %s)", request.path, fakeHandlerValue, request.route) - } - } - - if value.params != nil { - if !reflect.DeepEqual(*value.params, request.ps) { - t.Errorf("Params mismatch for route '%s'", request.path) - } - } - - } -} - -func checkPriorities(t *testing.T, n *node) uint32 { - var prio uint32 - for i := range n.children { - prio += checkPriorities(t, n.children[i]) - } - - if n.handlers != nil { - prio++ - } - - if n.priority != prio { - t.Errorf( - "priority mismatch for node '%s': is %d, should be %d", - n.path, n.priority, prio, - ) - } - - return prio -} - -func TestCountParams(t *testing.T) { - if countParams("/path/:param1/static/*catch-all") != 2 { - t.Fail() - } - if countParams(strings.Repeat("/:param", 256)) != 256 { - t.Fail() - } -} - -func TestTreeAddAndGet(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/hi", - "/contact", - "/co", - "/c", - "/a", - "/ab", - "/doc/", - "/doc/go_faq.html", - "/doc/go1.html", - "/α", - "/β", - } - for _, route := range routes { - tree.addRoute(route, fakeHandler(route)) - } - - checkRequests(t, tree, testRequests{ - {"/a", false, "/a", nil}, - {"/", true, "", nil}, - {"/hi", false, "/hi", nil}, - {"/contact", false, "/contact", nil}, - {"/co", false, "/co", nil}, - {"/con", true, "", nil}, // key mismatch - {"/cona", true, "", nil}, // key mismatch - {"/no", true, "", nil}, // no matching child - {"/ab", false, "/ab", nil}, - {"/α", false, "/α", nil}, - {"/β", false, "/β", nil}, - }) - - checkPriorities(t, tree) -} - -func TestTreeWildcard(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/", - "/cmd/:tool/", - "/cmd/:tool/:sub", - "/cmd/whoami", - "/cmd/whoami/root", - "/cmd/whoami/root/", - "/src/*filepath", - "/search/", - "/search/:query", - "/search/gin-gonic", - "/search/google", - "/user_:name", - "/user_:name/about", - "/files/:dir/*filepath", - "/doc/", - "/doc/go_faq.html", - "/doc/go1.html", - "/info/:user/public", - "/info/:user/project/:project", - "/info/:user/project/:project/*filepath", - "/info/:user/project/golang", - "/aa/*xx", - "/ab/*xx", - "/:cc", - "/c1/:dd/e", - "/c1/:dd/e1", - "/:cc/cc", - "/:cc/:dd/ee", - "/:cc/:dd/:ee/ff", - "/:cc/:dd/:ee/:ff/gg", - "/:cc/:dd/:ee/:ff/:gg/hh", - "/get/test/abc/", - "/get/:param/abc/", - "/something/:paramname/thirdthing", - "/something/secondthing/test", - "/get/abc", - "/get/:param", - "/get/abc/123abc", - "/get/abc/:param", - "/get/abc/123abc/xxx8", - "/get/abc/123abc/:param", - "/get/abc/123abc/xxx8/1234", - "/get/abc/123abc/xxx8/:param", - "/get/abc/123abc/xxx8/1234/ffas", - "/get/abc/123abc/xxx8/1234/:param", - "/get/abc/123abc/xxx8/1234/kkdd/12c", - "/get/abc/123abc/xxx8/1234/kkdd/:param", - "/get/abc/:param/test", - "/get/abc/123abd/:param", - "/get/abc/123abddd/:param", - "/get/abc/123/:param", - "/get/abc/123abg/:param", - "/get/abc/123abf/:param", - "/get/abc/123abfff/:param", - "/get/abc/escaped_colon/test\\:param", - } - for _, route := range routes { - tree.addRoute(route, fakeHandler(route)) - } - - checkRequests(t, tree, testRequests{ - {"/", false, "/", nil}, - {"/cmd/test", true, "/cmd/:tool/", Params{Param{"tool", "test"}}}, - {"/cmd/test/", false, "/cmd/:tool/", Params{Param{"tool", "test"}}}, - {"/cmd/test/3", false, "/cmd/:tool/:sub", Params{Param{Key: "tool", Value: "test"}, Param{Key: "sub", Value: "3"}}}, - {"/cmd/who", true, "/cmd/:tool/", Params{Param{"tool", "who"}}}, - {"/cmd/who/", false, "/cmd/:tool/", Params{Param{"tool", "who"}}}, - {"/cmd/whoami", false, "/cmd/whoami", nil}, - {"/cmd/whoami/", true, "/cmd/whoami", nil}, - {"/cmd/whoami/r", false, "/cmd/:tool/:sub", Params{Param{Key: "tool", Value: "whoami"}, Param{Key: "sub", Value: "r"}}}, - {"/cmd/whoami/r/", true, "/cmd/:tool/:sub", Params{Param{Key: "tool", Value: "whoami"}, Param{Key: "sub", Value: "r"}}}, - {"/cmd/whoami/root", false, "/cmd/whoami/root", nil}, - {"/cmd/whoami/root/", false, "/cmd/whoami/root/", nil}, - {"/src/", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/"}}}, - {"/src/some/file.png", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/some/file.png"}}}, - {"/search/", false, "/search/", nil}, - {"/search/someth!ng+in+ünìcodé", false, "/search/:query", Params{Param{Key: "query", Value: "someth!ng+in+ünìcodé"}}}, - {"/search/someth!ng+in+ünìcodé/", true, "", Params{Param{Key: "query", Value: "someth!ng+in+ünìcodé"}}}, - {"/search/gin", false, "/search/:query", Params{Param{"query", "gin"}}}, - {"/search/gin-gonic", false, "/search/gin-gonic", nil}, - {"/search/google", false, "/search/google", nil}, - {"/user_gopher", false, "/user_:name", Params{Param{Key: "name", Value: "gopher"}}}, - {"/user_gopher/about", false, "/user_:name/about", Params{Param{Key: "name", Value: "gopher"}}}, - {"/files/js/inc/framework.js", false, "/files/:dir/*filepath", Params{Param{Key: "dir", Value: "js"}, Param{Key: "filepath", Value: "/inc/framework.js"}}}, - {"/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"}}}, - // * Error with argument being intercepted - // new PR handle (/all /all/cc /a/cc) - // fix PR: https://github.com/gin-gonic/gin/pull/2796 - {"/all", false, "/:cc", Params{Param{Key: "cc", Value: "all"}}}, - {"/d", false, "/:cc", Params{Param{Key: "cc", Value: "d"}}}, - {"/ad", false, "/:cc", Params{Param{Key: "cc", Value: "ad"}}}, - {"/dd", false, "/:cc", Params{Param{Key: "cc", Value: "dd"}}}, - {"/dddaa", false, "/:cc", Params{Param{Key: "cc", Value: "dddaa"}}}, - {"/aa", false, "/:cc", Params{Param{Key: "cc", Value: "aa"}}}, - {"/aaa", false, "/:cc", Params{Param{Key: "cc", Value: "aaa"}}}, - {"/aaa/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "aaa"}}}, - {"/ab", false, "/:cc", Params{Param{Key: "cc", Value: "ab"}}}, - {"/abb", false, "/:cc", Params{Param{Key: "cc", Value: "abb"}}}, - {"/abb/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "abb"}}}, - {"/allxxxx", false, "/:cc", Params{Param{Key: "cc", Value: "allxxxx"}}}, - {"/alldd", false, "/:cc", Params{Param{Key: "cc", Value: "alldd"}}}, - {"/all/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "all"}}}, - {"/a/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "a"}}}, - {"/c1/d/e", false, "/c1/:dd/e", Params{Param{Key: "dd", Value: "d"}}}, - {"/c1/d/e1", false, "/c1/:dd/e1", Params{Param{Key: "dd", Value: "d"}}}, - {"/c1/d/ee", false, "/:cc/:dd/ee", Params{Param{Key: "cc", Value: "c1"}, Param{Key: "dd", Value: "d"}}}, - {"/cc/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "cc"}}}, - {"/ccc/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "ccc"}}}, - {"/deedwjfs/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "deedwjfs"}}}, - {"/acllcc/cc", false, "/:cc/cc", Params{Param{Key: "cc", Value: "acllcc"}}}, - {"/get/test/abc/", false, "/get/test/abc/", nil}, - {"/get/te/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "te"}}}, - {"/get/testaa/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "testaa"}}}, - {"/get/xx/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "xx"}}}, - {"/get/tt/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "tt"}}}, - {"/get/a/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "a"}}}, - {"/get/t/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "t"}}}, - {"/get/aa/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "aa"}}}, - {"/get/abas/abc/", false, "/get/:param/abc/", Params{Param{Key: "param", Value: "abas"}}}, - {"/something/secondthing/test", false, "/something/secondthing/test", nil}, - {"/something/abcdad/thirdthing", false, "/something/:paramname/thirdthing", Params{Param{Key: "paramname", Value: "abcdad"}}}, - {"/something/secondthingaaaa/thirdthing", false, "/something/:paramname/thirdthing", Params{Param{Key: "paramname", Value: "secondthingaaaa"}}}, - {"/something/se/thirdthing", false, "/something/:paramname/thirdthing", Params{Param{Key: "paramname", Value: "se"}}}, - {"/something/s/thirdthing", false, "/something/:paramname/thirdthing", Params{Param{Key: "paramname", Value: "s"}}}, - {"/c/d/ee", false, "/:cc/:dd/ee", Params{Param{Key: "cc", Value: "c"}, Param{Key: "dd", Value: "d"}}}, - {"/c/d/e/ff", false, "/:cc/:dd/:ee/ff", Params{Param{Key: "cc", Value: "c"}, Param{Key: "dd", Value: "d"}, Param{Key: "ee", Value: "e"}}}, - {"/c/d/e/f/gg", false, "/:cc/:dd/:ee/:ff/gg", Params{Param{Key: "cc", Value: "c"}, Param{Key: "dd", Value: "d"}, Param{Key: "ee", Value: "e"}, Param{Key: "ff", Value: "f"}}}, - {"/c/d/e/f/g/hh", false, "/:cc/:dd/:ee/:ff/:gg/hh", Params{Param{Key: "cc", Value: "c"}, Param{Key: "dd", Value: "d"}, Param{Key: "ee", Value: "e"}, Param{Key: "ff", Value: "f"}, Param{Key: "gg", Value: "g"}}}, - {"/cc/dd/ee/ff/gg/hh", false, "/:cc/:dd/:ee/:ff/:gg/hh", Params{Param{Key: "cc", Value: "cc"}, Param{Key: "dd", Value: "dd"}, Param{Key: "ee", Value: "ee"}, Param{Key: "ff", Value: "ff"}, Param{Key: "gg", Value: "gg"}}}, - {"/get/abc", false, "/get/abc", nil}, - {"/get/a", false, "/get/:param", Params{Param{Key: "param", Value: "a"}}}, - {"/get/abz", false, "/get/:param", Params{Param{Key: "param", Value: "abz"}}}, - {"/get/12a", false, "/get/:param", Params{Param{Key: "param", Value: "12a"}}}, - {"/get/abcd", false, "/get/:param", Params{Param{Key: "param", Value: "abcd"}}}, - {"/get/abc/123abc", false, "/get/abc/123abc", nil}, - {"/get/abc/12", false, "/get/abc/:param", Params{Param{Key: "param", Value: "12"}}}, - {"/get/abc/123ab", false, "/get/abc/:param", Params{Param{Key: "param", Value: "123ab"}}}, - {"/get/abc/xyz", false, "/get/abc/:param", Params{Param{Key: "param", Value: "xyz"}}}, - {"/get/abc/123abcddxx", false, "/get/abc/:param", Params{Param{Key: "param", Value: "123abcddxx"}}}, - {"/get/abc/123abc/xxx8", false, "/get/abc/123abc/xxx8", nil}, - {"/get/abc/123abc/x", false, "/get/abc/123abc/:param", Params{Param{Key: "param", Value: "x"}}}, - {"/get/abc/123abc/xxx", false, "/get/abc/123abc/:param", Params{Param{Key: "param", Value: "xxx"}}}, - {"/get/abc/123abc/abc", false, "/get/abc/123abc/:param", Params{Param{Key: "param", Value: "abc"}}}, - {"/get/abc/123abc/xxx8xxas", false, "/get/abc/123abc/:param", Params{Param{Key: "param", Value: "xxx8xxas"}}}, - {"/get/abc/123abc/xxx8/1234", false, "/get/abc/123abc/xxx8/1234", nil}, - {"/get/abc/123abc/xxx8/1", false, "/get/abc/123abc/xxx8/:param", Params{Param{Key: "param", Value: "1"}}}, - {"/get/abc/123abc/xxx8/123", false, "/get/abc/123abc/xxx8/:param", Params{Param{Key: "param", Value: "123"}}}, - {"/get/abc/123abc/xxx8/78k", false, "/get/abc/123abc/xxx8/:param", Params{Param{Key: "param", Value: "78k"}}}, - {"/get/abc/123abc/xxx8/1234xxxd", false, "/get/abc/123abc/xxx8/:param", Params{Param{Key: "param", Value: "1234xxxd"}}}, - {"/get/abc/123abc/xxx8/1234/ffas", false, "/get/abc/123abc/xxx8/1234/ffas", nil}, - {"/get/abc/123abc/xxx8/1234/f", false, "/get/abc/123abc/xxx8/1234/:param", Params{Param{Key: "param", Value: "f"}}}, - {"/get/abc/123abc/xxx8/1234/ffa", false, "/get/abc/123abc/xxx8/1234/:param", Params{Param{Key: "param", Value: "ffa"}}}, - {"/get/abc/123abc/xxx8/1234/kka", false, "/get/abc/123abc/xxx8/1234/:param", Params{Param{Key: "param", Value: "kka"}}}, - {"/get/abc/123abc/xxx8/1234/ffas321", false, "/get/abc/123abc/xxx8/1234/:param", Params{Param{Key: "param", Value: "ffas321"}}}, - {"/get/abc/123abc/xxx8/1234/kkdd/12c", false, "/get/abc/123abc/xxx8/1234/kkdd/12c", nil}, - {"/get/abc/123abc/xxx8/1234/kkdd/1", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", Params{Param{Key: "param", Value: "1"}}}, - {"/get/abc/123abc/xxx8/1234/kkdd/12", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", Params{Param{Key: "param", Value: "12"}}}, - {"/get/abc/123abc/xxx8/1234/kkdd/12b", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", Params{Param{Key: "param", Value: "12b"}}}, - {"/get/abc/123abc/xxx8/1234/kkdd/34", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", Params{Param{Key: "param", Value: "34"}}}, - {"/get/abc/123abc/xxx8/1234/kkdd/12c2e3", false, "/get/abc/123abc/xxx8/1234/kkdd/:param", Params{Param{Key: "param", Value: "12c2e3"}}}, - {"/get/abc/12/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "12"}}}, - {"/get/abc/123abdd/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123abdd"}}}, - {"/get/abc/123abdddf/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123abdddf"}}}, - {"/get/abc/123ab/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123ab"}}}, - {"/get/abc/123abgg/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123abgg"}}}, - {"/get/abc/123abff/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123abff"}}}, - {"/get/abc/123abffff/test", false, "/get/abc/:param/test", Params{Param{Key: "param", Value: "123abffff"}}}, - {"/get/abc/123abd/test", false, "/get/abc/123abd/:param", Params{Param{Key: "param", Value: "test"}}}, - {"/get/abc/123abddd/test", false, "/get/abc/123abddd/:param", Params{Param{Key: "param", Value: "test"}}}, - {"/get/abc/123/test22", false, "/get/abc/123/:param", Params{Param{Key: "param", Value: "test22"}}}, - {"/get/abc/123abg/test", false, "/get/abc/123abg/:param", Params{Param{Key: "param", Value: "test"}}}, - {"/get/abc/123abf/testss", false, "/get/abc/123abf/:param", Params{Param{Key: "param", Value: "testss"}}}, - {"/get/abc/123abfff/te", false, "/get/abc/123abfff/:param", Params{Param{Key: "param", Value: "te"}}}, - {"/get/abc/escaped_colon/test\\:param", false, "/get/abc/escaped_colon/test\\:param", nil}, - }) - - checkPriorities(t, tree) -} - -func TestUnescapeParameters(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/", - "/cmd/:tool/:sub", - "/cmd/:tool/", - "/src/*filepath", - "/search/:query", - "/files/:dir/*filepath", - "/info/:user/project/:project", - "/info/:user", - } - for _, route := range routes { - tree.addRoute(route, fakeHandler(route)) - } - - unescape := true - checkRequests(t, tree, testRequests{ - {"/", false, "/", nil}, - {"/cmd/test/", false, "/cmd/:tool/", Params{Param{Key: "tool", Value: "test"}}}, - {"/cmd/test", true, "", Params{Param{Key: "tool", Value: "test"}}}, - {"/src/some/file.png", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/some/file.png"}}}, - {"/src/some/file+test.png", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/some/file test.png"}}}, - {"/src/some/file++++%%%%test.png", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/some/file++++%%%%test.png"}}}, - {"/src/some/file%2Ftest.png", false, "/src/*filepath", Params{Param{Key: "filepath", Value: "/some/file/test.png"}}}, - {"/search/someth!ng+in+ünìcodé", false, "/search/:query", Params{Param{Key: "query", Value: "someth!ng in ünìcodé"}}}, - {"/info/gordon/project/go", false, "/info/:user/project/:project", Params{Param{Key: "user", Value: "gordon"}, Param{Key: "project", Value: "go"}}}, - {"/info/slash%2Fgordon", false, "/info/:user", Params{Param{Key: "user", Value: "slash/gordon"}}}, - {"/info/slash%2Fgordon/project/Project%20%231", false, "/info/:user/project/:project", Params{Param{Key: "user", Value: "slash/gordon"}, Param{Key: "project", Value: "Project #1"}}}, - {"/info/slash%%%%", false, "/info/:user", Params{Param{Key: "user", Value: "slash%%%%"}}}, - {"/info/slash%%%%2Fgordon/project/Project%%%%20%231", false, "/info/:user/project/:project", Params{Param{Key: "user", Value: "slash%%%%2Fgordon"}, Param{Key: "project", Value: "Project%%%%20%231"}}}, - }, unescape) - - checkPriorities(t, tree) -} - -func catchPanic(testFunc func()) (recv any) { - defer func() { - recv = recover() - }() - - testFunc() - return -} - -type testRoute struct { - path string - conflict bool -} - -func testRoutes(t *testing.T, routes []testRoute) { - tree := &node{} - - for _, route := range routes { - recv := catchPanic(func() { - tree.addRoute(route.path, nil) - }) - - if route.conflict { - if recv == nil { - t.Errorf("no panic for conflicting route '%s'", route.path) - } - } else if recv != nil { - t.Errorf("unexpected panic for route '%s': %v", route.path, recv) - } - } -} - -func TestTreeWildcardConflict(t *testing.T) { - routes := []testRoute{ - {"/cmd/:tool/:sub", false}, - {"/cmd/vet", false}, - {"/foo/bar", false}, - {"/foo/:name", false}, - {"/foo/:names", true}, - {"/cmd/*path", true}, - {"/cmd/:badvar", true}, - {"/cmd/:tool/names", false}, - {"/cmd/:tool/:badsub/details", true}, - {"/src/*filepath", false}, - {"/src/:file", true}, - {"/src/static.json", true}, - {"/src/*filepathx", true}, - {"/src/", true}, - {"/src/foo/bar", true}, - {"/src1/", false}, - {"/src1/*filepath", true}, - {"/src2*filepath", true}, - {"/src2/*filepath", false}, - {"/search/:query", false}, - {"/search/valid", false}, - {"/user_:name", false}, - {"/user_x", false}, - {"/user_:name", false}, - {"/id:id", false}, - {"/id/:id", false}, - {"/static/*file", false}, - {"/static/", true}, - {"/escape/test\\:d1", false}, - {"/escape/test\\:d2", false}, - {"/escape/test:param", false}, - } - testRoutes(t, routes) -} - -func TestCatchAllAfterSlash(t *testing.T) { - routes := []testRoute{ - {"/non-leading-*catchall", true}, - } - testRoutes(t, routes) -} - -func TestTreeChildConflict(t *testing.T) { - routes := []testRoute{ - {"/cmd/vet", false}, - {"/cmd/:tool", false}, - {"/cmd/:tool/:sub", false}, - {"/cmd/:tool/misc", false}, - {"/cmd/:tool/:othersub", true}, - {"/src/AUTHORS", false}, - {"/src/*filepath", true}, - {"/user_x", false}, - {"/user_:name", false}, - {"/id/:id", false}, - {"/id:id", false}, - {"/:id", false}, - {"/*filepath", true}, - } - testRoutes(t, routes) -} - -func TestTreeDuplicatePath(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/", - "/doc/", - "/src/*filepath", - "/search/:query", - "/user_:name", - } - for _, route := range routes { - recv := catchPanic(func() { - tree.addRoute(route, fakeHandler(route)) - }) - if recv != nil { - t.Fatalf("panic inserting route '%s': %v", route, recv) - } - - // Add again - recv = catchPanic(func() { - tree.addRoute(route, nil) - }) - if recv == nil { - t.Fatalf("no panic while inserting duplicate route '%s", route) - } - } - - // printChildren(tree, "") - - checkRequests(t, tree, testRequests{ - {"/", false, "/", nil}, - {"/doc/", false, "/doc/", nil}, - {"/src/some/file.png", false, "/src/*filepath", Params{Param{"filepath", "/some/file.png"}}}, - {"/search/someth!ng+in+ünìcodé", false, "/search/:query", Params{Param{"query", "someth!ng+in+ünìcodé"}}}, - {"/user_gopher", false, "/user_:name", Params{Param{"name", "gopher"}}}, - }) -} - -func TestEmptyWildcardName(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/user:", - "/user:/", - "/cmd/:/", - "/src/*", - } - for _, route := range routes { - recv := catchPanic(func() { - tree.addRoute(route, nil) - }) - if recv == nil { - t.Fatalf("no panic while inserting route with empty wildcard name '%s", route) - } - } -} - -func TestTreeCatchAllConflict(t *testing.T) { - routes := []testRoute{ - {"/src/*filepath/x", true}, - {"/src2/", false}, - {"/src2/*filepath/x", true}, - {"/src3/*filepath", false}, - {"/src3/*filepath/x", true}, - } - testRoutes(t, routes) -} - -func TestTreeCatchAllConflictRoot(t *testing.T) { - routes := []testRoute{ - {"/", false}, - {"/*filepath", true}, - } - testRoutes(t, routes) -} - -func TestTreeCatchMaxParams(t *testing.T) { - tree := &node{} - route := "/cmd/*filepath" - tree.addRoute(route, fakeHandler(route)) -} - -func TestTreeDoubleWildcard(t *testing.T) { - const panicMsg = "only one wildcard per path segment is allowed" - - routes := [...]string{ - "/:foo:bar", - "/:foo:bar/", - "/:foo*bar", - } - - for _, route := range routes { - tree := &node{} - recv := catchPanic(func() { - tree.addRoute(route, nil) - }) - - if rs, ok := recv.(string); !ok || !strings.HasPrefix(rs, panicMsg) { - t.Fatalf(`"Expected panic "%s" for route '%s', got "%v"`, panicMsg, route, recv) - } - } -} - -/*func TestTreeDuplicateWildcard(t *testing.T) { - tree := &node{} - routes := [...]string{ - "/:id/:name/:id", - } - for _, route := range routes { - ... - } -}*/ - -func TestTreeTrailingSlashRedirect(t *testing.T) { - tree := &node{} - - routes := [...]string{ - "/hi", - "/b/", - "/search/:query", - "/cmd/:tool/", - "/src/*filepath", - "/x", - "/x/y", - "/y/", - "/y/z", - "/0/:id", - "/0/:id/1", - "/1/:id/", - "/1/:id/2", - "/aa", - "/a/", - "/admin", - "/admin/:category", - "/admin/:category/:page", - "/doc", - "/doc/go_faq.html", - "/doc/go1.html", - "/no/a", - "/no/b", - "/api/:page/:name", - "/api/hello/:name/bar/", - "/api/bar/:name", - "/api/baz/foo", - "/api/baz/foo/bar", - "/blog/:p", - "/posts/:b/:c", - "/posts/b/:c/d/", - "/vendor/:x/*y", - } - for _, route := range routes { - recv := catchPanic(func() { - tree.addRoute(route, fakeHandler(route)) - }) - if recv != nil { - t.Fatalf("panic inserting route '%s': %v", route, recv) - } - } - - tsrRoutes := [...]string{ - "/hi/", - "/b", - "/search/gopher/", - "/cmd/vet", - "/src", - "/x/", - "/y", - "/0/go/", - "/1/go", - "/a", - "/admin/", - "/admin/config/", - "/admin/config/permissions/", - "/doc/", - "/admin/static/", - "/admin/cfg/", - "/admin/cfg/users/", - "/api/hello/x/bar", - "/api/baz/foo/", - "/api/baz/bax/", - "/api/bar/huh/", - "/api/baz/foo/bar/", - "/api/world/abc/", - "/blog/pp/", - "/posts/b/c/d", - "/vendor/x", - } - - for _, route := range tsrRoutes { - value := tree.getValue(route, nil, getSkippedNodes(), false) - if value.handlers != nil { - t.Fatalf("non-nil handler for TSR route '%s", route) - } else if !value.tsr { - t.Errorf("expected TSR recommendation for route '%s'", route) - } - } - - noTsrRoutes := [...]string{ - "/", - "/no", - "/no/", - "/_", - "/_/", - "/api", - "/api/", - "/api/hello/x/foo", - "/api/baz/foo/bad", - "/foo/p/p", - } - for _, route := range noTsrRoutes { - value := tree.getValue(route, nil, getSkippedNodes(), false) - if value.handlers != nil { - t.Fatalf("non-nil handler for No-TSR route '%s", route) - } else if value.tsr { - t.Errorf("expected no TSR recommendation for route '%s'", route) - } - } -} - -func TestTreeRootTrailingSlashRedirect(t *testing.T) { - tree := &node{} - - recv := catchPanic(func() { - tree.addRoute("/:test", fakeHandler("/:test")) - }) - if recv != nil { - t.Fatalf("panic inserting test route: %v", recv) - } - - value := tree.getValue("/", nil, getSkippedNodes(), false) - if value.handlers != nil { - t.Fatalf("non-nil handler") - } else if value.tsr { - t.Errorf("expected no TSR recommendation") - } -} - -func TestRedirectTrailingSlash(t *testing.T) { - data := []struct { - path string - }{ - {"/hello/:name"}, - {"/hello/:name/123"}, - {"/hello/:name/234"}, - } - - node := &node{} - for _, item := range data { - node.addRoute(item.path, fakeHandler("test")) - } - - value := node.getValue("/hello/abx/", nil, getSkippedNodes(), false) - if value.tsr != true { - t.Fatalf("want true, is false") - } -} - -func TestTreeFindCaseInsensitivePath(t *testing.T) { - tree := &node{} - - longPath := "/l" + strings.Repeat("o", 128) + "ng" - lOngPath := "/l" + strings.Repeat("O", 128) + "ng/" - - routes := [...]string{ - "/hi", - "/b/", - "/ABC/", - "/search/:query", - "/cmd/:tool/", - "/src/*filepath", - "/x", - "/x/y", - "/y/", - "/y/z", - "/0/:id", - "/0/:id/1", - "/1/:id/", - "/1/:id/2", - "/aa", - "/a/", - "/doc", - "/doc/go_faq.html", - "/doc/go1.html", - "/doc/go/away", - "/no/a", - "/no/b", - "/Π", - "/u/apfêl/", - "/u/äpfêl/", - "/u/öpfêl", - "/v/Äpfêl/", - "/v/Öpfêl", - "/w/♬", // 3 byte - "/w/♭/", // 3 byte, last byte differs - "/w/𠜎", // 4 byte - "/w/𠜏/", // 4 byte - longPath, - } - - for _, route := range routes { - recv := catchPanic(func() { - tree.addRoute(route, fakeHandler(route)) - }) - if recv != nil { - t.Fatalf("panic inserting route '%s': %v", route, recv) - } - } - - // Check out == in for all registered routes - // With fixTrailingSlash = true - for _, route := range routes { - out, found := tree.findCaseInsensitivePath(route, true) - if !found { - t.Errorf("Route '%s' not found!", route) - } else if string(out) != route { - t.Errorf("Wrong result for route '%s': %s", route, string(out)) - } - } - // With fixTrailingSlash = false - for _, route := range routes { - out, found := tree.findCaseInsensitivePath(route, false) - if !found { - t.Errorf("Route '%s' not found!", route) - } else if string(out) != route { - t.Errorf("Wrong result for route '%s': %s", route, string(out)) - } - } - - tests := []struct { - in string - out string - found bool - slash bool - }{ - {"/HI", "/hi", true, false}, - {"/HI/", "/hi", true, true}, - {"/B", "/b/", true, true}, - {"/B/", "/b/", true, false}, - {"/abc", "/ABC/", true, true}, - {"/abc/", "/ABC/", true, false}, - {"/aBc", "/ABC/", true, true}, - {"/aBc/", "/ABC/", true, false}, - {"/abC", "/ABC/", true, true}, - {"/abC/", "/ABC/", true, false}, - {"/SEARCH/QUERY", "/search/QUERY", true, false}, - {"/SEARCH/QUERY/", "/search/QUERY", true, true}, - {"/CMD/TOOL/", "/cmd/TOOL/", true, false}, - {"/CMD/TOOL", "/cmd/TOOL/", true, true}, - {"/SRC/FILE/PATH", "/src/FILE/PATH", true, false}, - {"/x/Y", "/x/y", true, false}, - {"/x/Y/", "/x/y", true, true}, - {"/X/y", "/x/y", true, false}, - {"/X/y/", "/x/y", true, true}, - {"/X/Y", "/x/y", true, false}, - {"/X/Y/", "/x/y", true, true}, - {"/Y/", "/y/", true, false}, - {"/Y", "/y/", true, true}, - {"/Y/z", "/y/z", true, false}, - {"/Y/z/", "/y/z", true, true}, - {"/Y/Z", "/y/z", true, false}, - {"/Y/Z/", "/y/z", true, true}, - {"/y/Z", "/y/z", true, false}, - {"/y/Z/", "/y/z", true, true}, - {"/Aa", "/aa", true, false}, - {"/Aa/", "/aa", true, true}, - {"/AA", "/aa", true, false}, - {"/AA/", "/aa", true, true}, - {"/aA", "/aa", true, false}, - {"/aA/", "/aa", true, true}, - {"/A/", "/a/", true, false}, - {"/A", "/a/", true, true}, - {"/DOC", "/doc", true, false}, - {"/DOC/", "/doc", true, true}, - {"/NO", "", false, true}, - {"/DOC/GO", "", false, true}, - {"/π", "/Π", true, false}, - {"/π/", "/Π", true, true}, - {"/u/ÄPFÊL/", "/u/äpfêl/", true, false}, - {"/u/ÄPFÊL", "/u/äpfêl/", true, true}, - {"/u/ÖPFÊL/", "/u/öpfêl", true, true}, - {"/u/ÖPFÊL", "/u/öpfêl", true, false}, - {"/v/äpfêL/", "/v/Äpfêl/", true, false}, - {"/v/äpfêL", "/v/Äpfêl/", true, true}, - {"/v/öpfêL/", "/v/Öpfêl", true, true}, - {"/v/öpfêL", "/v/Öpfêl", true, false}, - {"/w/♬/", "/w/♬", true, true}, - {"/w/♭", "/w/♭/", true, true}, - {"/w/𠜎/", "/w/𠜎", true, true}, - {"/w/𠜏", "/w/𠜏/", true, true}, - {lOngPath, longPath, true, true}, - } - // With fixTrailingSlash = true - for _, test := range tests { - out, found := tree.findCaseInsensitivePath(test.in, true) - if found != test.found || (found && (string(out) != test.out)) { - t.Errorf("Wrong result for '%s': got %s, %t; want %s, %t", - test.in, string(out), found, test.out, test.found) - return - } - } - // With fixTrailingSlash = false - for _, test := range tests { - out, found := tree.findCaseInsensitivePath(test.in, false) - if test.slash { - if found { // test needs a trailingSlash fix. It must not be found! - t.Errorf("Found without fixTrailingSlash: %s; got %s", test.in, string(out)) - } - } else { - if found != test.found || (found && (string(out) != test.out)) { - t.Errorf("Wrong result for '%s': got %s, %t; want %s, %t", - test.in, string(out), found, test.out, test.found) - return - } - } - } -} - -func TestTreeInvalidNodeType(t *testing.T) { - const panicMsg = "invalid node type" - - tree := &node{} - tree.addRoute("/", fakeHandler("/")) - tree.addRoute("/:page", fakeHandler("/:page")) - - // set invalid node type - tree.children[0].nType = 42 - - // normal lookup - recv := catchPanic(func() { - tree.getValue("/test", nil, getSkippedNodes(), false) - }) - if rs, ok := recv.(string); !ok || rs != panicMsg { - t.Fatalf("Expected panic '"+panicMsg+"', got '%v'", recv) - } - - // case-insensitive lookup - recv = catchPanic(func() { - tree.findCaseInsensitivePath("/test", true) - }) - if rs, ok := recv.(string); !ok || rs != panicMsg { - t.Fatalf("Expected panic '"+panicMsg+"', got '%v'", recv) - } -} - -func TestTreeInvalidParamsType(t *testing.T) { - tree := &node{} - // add a child with wildcard - route := "/:path" - tree.addRoute(route, fakeHandler(route)) - - // set invalid Params type - params := make(Params, 0) - - // try to trigger slice bounds out of range with capacity 0 - tree.getValue("/test", ¶ms, getSkippedNodes(), false) -} - -func TestTreeExpandParamsCapacity(t *testing.T) { - data := []struct { - path string - }{ - {"/:path"}, - {"/*path"}, - } - - for _, item := range data { - tree := &node{} - tree.addRoute(item.path, fakeHandler(item.path)) - params := make(Params, 0) - - value := tree.getValue("/test", ¶ms, getSkippedNodes(), false) - - if value.params == nil { - t.Errorf("Expected %s params to be set, but they weren't", item.path) - continue - } - - if len(*value.params) != 1 { - t.Errorf("Wrong number of %s params: got %d, want %d", - item.path, len(*value.params), 1) - continue - } - } -} - -func TestTreeWildcardConflictEx(t *testing.T) { - conflicts := [...]struct { - route string - segPath string - existPath string - existSegPath string - }{ - {"/who/are/foo", "/foo", `/who/are/\*you`, `/\*you`}, - {"/who/are/foo/", "/foo/", `/who/are/\*you`, `/\*you`}, - {"/who/are/foo/bar", "/foo/bar", `/who/are/\*you`, `/\*you`}, - {"/con:nection", ":nection", `/con:tact`, `:tact`}, - } - - for _, conflict := range conflicts { - // I have to re-create a 'tree', because the 'tree' will be - // in an inconsistent state when the loop recovers from the - // panic which threw by 'addRoute' function. - tree := &node{} - routes := [...]string{ - "/con:tact", - "/who/are/*you", - "/who/foo/hello", - } - - for _, route := range routes { - tree.addRoute(route, fakeHandler(route)) - } - - recv := catchPanic(func() { - tree.addRoute(conflict.route, fakeHandler(conflict.route)) - }) - - if !regexp.MustCompile(fmt.Sprintf("'%s' in new path .* conflicts with existing wildcard '%s' in existing prefix '%s'", conflict.segPath, conflict.existSegPath, conflict.existPath)).MatchString(fmt.Sprint(recv)) { - t.Fatalf("invalid wildcard conflict error (%v)", recv) - } - } -} - -func TestTreeInvalidEscape(t *testing.T) { - routes := map[string]bool{ - "/r1/r": true, - "/r2/:r": true, - "/r3/\\:r": true, - } - tree := &node{} - for route, valid := range routes { - recv := catchPanic(func() { - tree.addRoute(route, fakeHandler(route)) - }) - if recv == nil != valid { - t.Fatalf("%s should be %t but got %v", route, valid, recv) - } - } -} - -func TestWildcardInvalidSlash(t *testing.T) { - const panicMsgPrefix = "no / before catch-all in path" - - routes := map[string]bool{ - "/foo/bar": true, - "/foo/x*zy": false, - "/foo/b*r": false, - } - - for route, valid := range routes { - tree := &node{} - recv := catchPanic(func() { - tree.addRoute(route, nil) - }) - - if recv == nil != valid { - t.Fatalf("%s should be %t but got %v", route, valid, recv) - } - - if rs, ok := recv.(string); recv != nil && (!ok || !strings.HasPrefix(rs, panicMsgPrefix)) { - t.Fatalf(`"Expected panic "%s" for route '%s', got "%v"`, panicMsgPrefix, route, recv) - } - } -} - -// 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) - } -}