From e43b12e34322b0f8834265cb9bafbafd880dc421 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:49:53 +0000 Subject: [PATCH 01/19] fix: correct shallow copy in router backtracking The router could panic with a 'slice bounds out of range' error when handling requests that trigger its backtracking logic. The root cause was a shallow copy of the node's `children` slice when creating a `skippedNode` for backtracking. This could lead to a corrupted state if the router needed to backtrack and then proceed down a wildcard path. This commit fixes the issue by introducing a `copyChildren` method on the `node` struct, which creates a safe copy of the children slice. This method is now used when creating a `skippedNode`, ensuring that the backtracking logic is isolated and robust. --- tree.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tree.go b/tree.go index 6f99223..1082629 100644 --- a/tree.go +++ b/tree.go @@ -293,6 +293,12 @@ walk: // 外部循环用于遍历和构建路由树 } } +func (n *node) copyChildren() []*node { + children := make([]*node, len(n.children)) + copy(children, n.children) + return children +} + // findWildcard 搜索通配符段并检查名称是否包含无效字符。 // 如果未找到通配符,则返回 -1 作为索引。 func findWildcard(path string) (wildcard string, i int, valid bool) { @@ -486,7 +492,7 @@ walk: // 外部循环用于遍历路由树 wildChild: n.wildChild, nType: n.nType, priority: n.priority, - children: n.children, + children: n.copyChildren(), handlers: n.handlers, fullPath: n.fullPath, }, From 99b48371b3e02e5cf4578c6d586463f5844b2178 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:05:00 +0800 Subject: [PATCH 02/19] update test --- tree_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tree_test.go b/tree_test.go index dc0bb60..635272c 100644 --- a/tree_test.go +++ b/tree_test.go @@ -159,6 +159,7 @@ func TestTreeWildcard(t *testing.T) { "/doc/go1.html", "/info/:user/public", "/info/:user/project/:project", + "/info/:user/project/:project/*filepath", "/info/:user/project/golang", "/aa/*xx", "/ab/*xx", @@ -226,6 +227,7 @@ func TestTreeWildcard(t *testing.T) { {"/info/gordon/public", false, "/info/:user/public", Params{Param{Key: "user", Value: "gordon"}}}, {"/info/gordon/project/go", false, "/info/:user/project/:project", Params{Param{Key: "user", Value: "gordon"}, Param{Key: "project", Value: "go"}}}, {"/info/gordon/project/golang", false, "/info/:user/project/golang", Params{Param{Key: "user", Value: "gordon"}}}, + {"/info/gordon/project/go/src/file.go", false, "/info/:user/project/:project/*filepath", Params{Param{Key: "user", Value: "gordon"}, Param{Key: "project", Value: "go"}, Param{Key: "filepath", Value: "/src/file.go"}}}, {"/aa/aa", false, "/aa/*xx", Params{Param{Key: "xx", Value: "/aa"}}}, {"/ab/ab", false, "/ab/*xx", Params{Param{Key: "xx", Value: "/ab"}}}, {"/a", false, "/:cc", Params{Param{Key: "cc", Value: "a"}}}, From 295852e1a1864e33d636f6157e6b5622d3ca7336 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:05:09 +0800 Subject: [PATCH 03/19] update reqip --- context.go | 69 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index fa689a1..14edc53 100644 --- a/context.go +++ b/context.go @@ -14,7 +14,6 @@ import ( "io" "math" "mime" - "net" "net/http" "net/netip" "net/url" @@ -523,39 +522,59 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) { func (c *Context) RequestIP() string { if c.engine.ForwardByClientIP { for _, headerName := range c.engine.RemoteIPHeaders { - if ipValue := c.Request.Header.Get(headerName); ipValue != "" { - // X-Forwarded-For 可能包含多个 IP,约定第一个(最左边)是客户端 IP - // 其他头部(如 X-Real-IP)通常只有一个 - ips := strings.Split(ipValue, ",") - for _, singleIP := range ips { - trimmedIP := strings.TrimSpace(singleIP) - // 使用 netip.ParseAddr 进行 IP 地址的解析和格式验证 - addr, err := netip.ParseAddr(trimmedIP) - if err == nil { - // 成功解析到合法的 IP 地址格式,立即返回 - return addr.String() - } - // 如果当前 singleIP 无效,继续检查列表中的下一个 + ipValue := c.Request.Header.Get(headerName) + if ipValue == "" { + continue // 头部为空, 继续检查下一个 + } + + // 使用索引高效遍历逗号分隔的 IP 列表, 避免 strings.Split 的内存分配 + currentPos := 0 + for currentPos < len(ipValue) { + nextComma := strings.IndexByte(ipValue[currentPos:], ',') + + var ipSegment string + if nextComma == -1 { + // 这是列表中的最后一个 IP + ipSegment = ipValue[currentPos:] + currentPos = len(ipValue) // 结束循环 + } else { + // 截取当前 IP 段 + ipSegment = ipValue[currentPos : currentPos+nextComma] + currentPos += nextComma + 1 // 移动到下一个 IP 段的起始位置 + } + + // 去除空格并检查是否为空 (例如 "ip1,,ip2") + trimmedIP := strings.TrimSpace(ipSegment) + if trimmedIP == "" { + continue + } + + // 使用 netip.ParseAddr 进行 IP 地址的解析和验证 + addr, err := netip.ParseAddr(trimmedIP) + if err == nil { + // 成功解析到合法的 IP, 立即返回 + return addr.String() } } } } - // 如果没有启用 ForwardByClientIP 或头部中没有找到有效 IP,回退到 Request.RemoteAddr - // RemoteAddr 通常是 "host:port" 格式,但也可能直接就是 IP 地址 - remoteAddrStr := c.Request.RemoteAddr - ip, _, err := net.SplitHostPort(remoteAddrStr) // 尝试分离 host 和 port - if err != nil { - // 如果分离失败,意味着 remoteAddrStr 可能直接就是 IP 地址(或畸形) - ip = remoteAddrStr // 此时将整个 remoteAddrStr 作为候选 IP + // 回退到 Request.RemoteAddr 的处理 + // 优先使用 netip.ParseAddrPort, 它比 net.SplitHostPort 更高效且分配更少 + addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr) + if err == nil { + // 成功从 "ip:port" 格式中解析出 IP + return addrp.String() } - // 对从 RemoteAddr 中提取/使用的 IP 进行最终的合法性验证 - addr, parseErr := netip.ParseAddr(ip) - if parseErr == nil { - return addr.String() // 成功解析并返回合法 IP + // 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口), + // 则尝试将整个字符串作为 IP 地址进行解析 + addr, err := netip.ParseAddr(c.Request.RemoteAddr) + if err == nil { + return addr.String() } + // 所有方法都失败, 返回空字符串 return "" } From 783370fd7918b3416f3e3ed967ce479e3d8790e7 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:09:46 +0800 Subject: [PATCH 04/19] update --- tree.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tree.go b/tree.go index 1082629..f78f1d1 100644 --- a/tree.go +++ b/tree.go @@ -489,6 +489,7 @@ walk: // 外部循环用于遍历路由树 path: prefix + path, // 记录跳过的路径 node: &node{ // 复制当前节点的状态 path: n.path, + indices: n.indices, wildChild: n.wildChild, nType: n.nType, priority: n.priority, From 74f5770b4280af28f77ed82a47d882b0688148fa Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:21:32 +0800 Subject: [PATCH 05/19] update tree --- go.sum | 2 - tree.go | 349 +++++++++++++++++++++++++-------------------------- tree_test.go | 55 ++++++++ 3 files changed, 228 insertions(+), 178 deletions(-) diff --git a/go.sum b/go.sum index 4675a6b..d9a63e3 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk= github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= -github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4= -github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y= github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= diff --git a/tree.go b/tree.go index f78f1d1..09711a1 100644 --- a/tree.go +++ b/tree.go @@ -2,51 +2,50 @@ // Use of this source code is governed by a BSD-style license that can be found // at https://github.com/julienschmidt/httprouter/blob/master/LICENSE // This tree.go is gin's fork, you can see https://github.com/gin-gonic/gin/blob/master/tree.go - -package touka // 定义包名为 touka,该包可能是一个路由或Web框架的核心组件 +package touka import ( - "bytes" // 导入 bytes 包,用于操作字节切片 - "net/url" // 导入 net/url 包,用于 URL 解析和转义 - "strings" // 导入 strings 包,用于字符串操作 - "unicode" // 导入 unicode 包,用于处理 Unicode 字符 - "unicode/utf8" // 导入 unicode/utf8 包,用于 UTF-8 编码和解码 - "unsafe" // 导入 unsafe 包,用于不安全的类型转换,以避免内存分配 + "bytes" + "net/url" + "strings" + "unicode" + "unicode/utf8" + "unsafe" ) -// StringToBytes 将字符串转换为字节切片,不进行内存分配。 -// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。 -// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。 +// StringToBytes 将字符串转换为字节切片, 不进行内存分配. +// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077. +// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全. func StringToBytes(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) } -// BytesToString 将字节切片转换为字符串,不进行内存分配。 -// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。 -// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。 +// BytesToString 将字节切片转换为字符串, 不进行内存分配. +// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077. +// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全. func BytesToString(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) } var ( - strColon = []byte(":") // 定义字节切片常量,表示冒号,用于路径参数识别 - strStar = []byte("*") // 定义字节切片常量,表示星号,用于捕获所有路径识别 - strSlash = []byte("/") // 定义字节切片常量,表示斜杠,用于路径分隔符识别 + strColon = []byte(":") // 定义字节切片常量, 表示冒号, 用于路径参数识别 + strStar = []byte("*") // 定义字节切片常量, 表示星号, 用于捕获所有路径识别 + strSlash = []byte("/") // 定义字节切片常量, 表示斜杠, 用于路径分隔符识别 ) -// Param 是单个 URL 参数,由键和值组成。 +// Param 是单个 URL 参数, 由键和值组成. type Param struct { Key string // 参数的键名 Value string // 参数的值 } -// Params 是 Param 类型的切片,由路由器返回。 -// 该切片是有序的,第一个 URL 参数也是切片中的第一个值。 -// 因此,按索引读取值是安全的。 +// Params 是 Param 类型的切片, 由路由器返回. +// 该切片是有序的, 第一个 URL 参数也是切片中的第一个值. +// 因此, 按索引读取值是安全的. type Params []Param -// Get 返回键名与给定名称匹配的第一个 Param 的值,并返回一个布尔值 true。 -// 如果未找到匹配的 Param,则返回空字符串和布尔值 false。 +// Get 返回键名与给定名称匹配的第一个 Param 的值, 并返回一个布尔值 true. +// 如果未找到匹配的 Param, 则返回空字符串和布尔值 false. func (ps Params) Get(name string) (string, bool) { for _, entry := range ps { if entry.Key == name { @@ -56,24 +55,24 @@ func (ps Params) Get(name string) (string, bool) { return "", false } -// ByName 返回键名与给定名称匹配的第一个 Param 的值。 -// 如果未找到匹配的 Param,则返回空字符串。 +// ByName 返回键名与给定名称匹配的第一个 Param 的值. +// 如果未找到匹配的 Param, 则返回空字符串. func (ps Params) ByName(name string) (va string) { - va, _ = ps.Get(name) // 调用 Get 方法获取值,忽略第二个返回值 + va, _ = ps.Get(name) // 调用 Get 方法获取值, 忽略第二个返回值 return } -// methodTree 表示特定 HTTP 方法的路由树。 +// methodTree 表示特定 HTTP 方法的路由树. type methodTree struct { - method string // HTTP 方法(例如 "GET", "POST") + method string // HTTP 方法(例如 "GET", "POST") root *node // 该方法的根节点 } -// methodTrees 是 methodTree 的切片。 +// methodTrees 是 methodTree 的切片. type methodTrees []methodTree -// get 根据给定的 HTTP 方法查找并返回对应的根节点。 -// 如果找不到,则返回 nil。 +// get 根据给定的 HTTP 方法查找并返回对应的根节点. +// 如果找不到, 则返回 nil. func (trees methodTrees) get(method string) *node { for _, tree := range trees { if tree.method == method { @@ -83,7 +82,7 @@ func (trees methodTrees) get(method string) *node { return nil } -// longestCommonPrefix 计算两个字符串的最长公共前缀的长度。 +// longestCommonPrefix 计算两个字符串的最长公共前缀的长度. func longestCommonPrefix(a, b string) int { i := 0 max_ := min(len(a), len(b)) // 找出两个字符串中较短的长度 @@ -93,19 +92,19 @@ func longestCommonPrefix(a, b string) int { return i // 返回公共前缀的长度 } -// addChild 添加一个子节点,并将通配符子节点(如果存在)保持在数组的末尾。 +// addChild 添加一个子节点, 并将通配符子节点(如果存在)保持在数组的末尾. func (n *node) addChild(child *node) { if n.wildChild && len(n.children) > 0 { - // 如果当前节点有通配符子节点,且已有子节点,则将通配符子节点移到末尾 + // 如果当前节点有通配符子节点, 且已有子节点, 则将通配符子节点移到末尾 wildcardChild := n.children[len(n.children)-1] n.children = append(n.children[:len(n.children)-1], child, wildcardChild) } else { - // 否则,直接添加子节点 + // 否则, 直接添加子节点 n.children = append(n.children, child) } } -// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量。 +// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量. func countParams(path string) uint16 { var n uint16 s := StringToBytes(path) // 将路径字符串转换为字节切片 @@ -114,43 +113,43 @@ func countParams(path string) uint16 { return n } -// countSections 计算路径中斜杠('/')的数量,即路径段的数量。 +// countSections 计算路径中斜杠('/')的数量, 即路径段的数量. func countSections(path string) uint16 { s := StringToBytes(path) // 将路径字符串转换为字节切片 return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量 } -// nodeType 定义了节点的类型。 +// nodeType 定义了节点的类型. type nodeType uint8 const ( - static nodeType = iota // 静态节点,路径中不包含参数或通配符 + static nodeType = iota // 静态节点, 路径中不包含参数或通配符 root // 根节点 - param // 参数节点(例如:name) - catchAll // 捕获所有节点(例如*path) + param // 参数节点(例如:name) + catchAll // 捕获所有节点(例如*path) ) -// node 表示路由树中的一个节点。 +// node 表示路由树中的一个节点. type node struct { path string // 当前节点的路径段 - indices string // 子节点第一个字符的索引字符串,用于快速查找子节点 - wildChild bool // 是否包含通配符子节点(:param 或 *catchAll) - nType nodeType // 节点的类型(静态、根、参数、捕获所有) - priority uint32 // 节点的优先级,用于查找时优先匹配 - children []*node // 子节点切片,最多有一个 :param 风格的节点位于数组末尾 + indices string // 子节点第一个字符的索引字符串, 用于快速查找子节点 + wildChild bool // 是否包含通配符子节点(:param 或 *catchAll) + nType nodeType // 节点的类型(静态, 根, 参数, 捕获所有) + priority uint32 // 节点的优先级, 用于查找时优先匹配 + children []*node // 子节点切片, 最多有一个 :param 风格的节点位于数组末尾 handlers HandlersChain // 绑定到此节点的处理函数链 - fullPath string // 完整路径,用于调试和错误信息 + fullPath string // 完整路径, 用于调试和错误信息 } -// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序。 +// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序. func (n *node) incrementChildPrio(pos int) int { cs := n.children // 获取子节点切片 cs[pos].priority++ // 增加指定位置子节点的优先级 prio := cs[pos].priority // 获取新的优先级 - // 调整位置(向前移动) + // 调整位置(向前移动) newPos := pos - // 从当前位置向前遍历,如果前一个子节点的优先级小于当前子节点,则交换位置 + // 从当前位置向前遍历, 如果前一个子节点的优先级小于当前子节点, 则交换位置 for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- { // 交换节点位置 cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1] @@ -158,9 +157,9 @@ func (n *node) incrementChildPrio(pos int) int { // 构建新的索引字符字符串 if newPos != pos { - // 如果位置发生变化,则重新构建 indices 字符串 + // 如果位置发生变化, 则重新构建 indices 字符串 // 前缀部分 + 移动的索引字符 + 剩余部分 - n.indices = n.indices[:newPos] + // 未改变的前缀,可能为空 + n.indices = n.indices[:newPos] + // 未改变的前缀, 可能为空 n.indices[pos:pos+1] + // 被移动的索引字符 n.indices[newPos:pos] + n.indices[pos+1:] // 除去原位置字符的其余部分 } @@ -168,13 +167,13 @@ func (n *node) incrementChildPrio(pos int) int { return newPos // 返回新的位置 } -// addRoute 为给定路径添加一个带有处理函数的节点。 -// 非并发安全! +// addRoute 为给定路径添加一个带有处理函数的节点. +// 非并发安全! func (n *node) addRoute(path string, handlers HandlersChain) { fullPath := path // 记录完整的路径 n.priority++ // 增加当前节点的优先级 - // 如果是空树(根节点) + // 如果是空树(根节点) if len(n.path) == 0 && len(n.children) == 0 { n.insertChild(path, fullPath, handlers) // 直接插入子节点 n.nType = root // 设置为根节点类型 @@ -185,12 +184,12 @@ func (n *node) addRoute(path string, handlers HandlersChain) { walk: // 外部循环用于遍历和构建路由树 for { - // 找到最长公共前缀。 - // 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符。 + // 找到最长公共前缀. + // 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符. i := longestCommonPrefix(path, n.path) // 分裂边 (Split edge) - // 如果公共前缀小于当前节点的路径长度,说明当前节点需要被分裂 + // 如果公共前缀小于当前节点的路径长度, 说明当前节点需要被分裂 if i < len(n.path) { child := node{ path: n.path[i:], // 子节点路径是当前节点路径的剩余部分 @@ -199,27 +198,27 @@ walk: // 外部循环用于遍历和构建路由树 indices: n.indices, // 继承索引 children: n.children, // 继承子节点 handlers: n.handlers, // 继承处理函数 - priority: n.priority - 1, // 优先级减1,因为分裂会降低优先级 + priority: n.priority - 1, // 优先级减1, 因为分裂会降低优先级 fullPath: n.fullPath, // 继承完整路径 } - n.children = []*node{&child} // 当前节点现在只有一个子节点:新分裂出的子节点 + n.children = []*node{&child} // 当前节点现在只有一个子节点: 新分裂出的子节点 // 将当前节点的 indices 设置为新子节点路径的第一个字符 n.indices = BytesToString([]byte{n.path[i]}) // []byte 用于正确的 Unicode 字符转换 n.path = path[:i] // 当前节点的路径更新为公共前缀 - n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了) + n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了) n.wildChild = false // 当前节点不再是通配符子节点 n.fullPath = fullPath[:parentFullPathIndex+i] // 更新完整路径 } // 将新节点作为当前节点的子节点 - // 如果路径仍然有剩余部分(即未完全匹配) + // 如果路径仍然有剩余部分(即未完全匹配) if i < len(path) { path = path[i:] // 移除已匹配的前缀 c := path[0] // 获取剩余路径的第一个字符 // '/' 在参数之后 - // 如果当前节点是参数类型,且剩余路径以 '/' 开头,并且只有一个子节点 + // 如果当前节点是参数类型, 且剩余路径以 '/' 开头, 并且只有一个子节点 // 则继续遍历其唯一的子节点 if n.nType == param && c == '/' && len(n.children) == 1 { parentFullPathIndex += len(n.path) // 更新父节点完整路径索引 @@ -238,8 +237,8 @@ walk: // 外部循环用于遍历和构建路由树 } } - // 否则,插入新节点 - // 如果第一个字符不是 ':' 也不是 '*',且当前节点不是 catchAll 类型 + // 否则, 插入新节点 + // 如果第一个字符不是 ':' 也不是 '*', 且当前节点不是 catchAll 类型 if c != ':' && c != '*' && n.nType != catchAll { // 将新字符添加到索引字符串 n.indices += BytesToString([]byte{c}) // []byte 用于正确的 Unicode 字符转换 @@ -250,18 +249,18 @@ walk: // 外部循环用于遍历和构建路由树 n.incrementChildPrio(len(n.indices) - 1) // 增加新子节点的优先级并重新排序 n = child // 移动到新子节点 } else if n.wildChild { - // 正在插入一个通配符节点,需要检查是否与现有通配符冲突 + // 正在插入一个通配符节点, 需要检查是否与现有通配符冲突 n = n.children[len(n.children)-1] // 移动到现有的通配符子节点 n.priority++ // 增加其优先级 // 检查通配符是否匹配 - // 如果剩余路径长度大于等于通配符节点的路径长度,且通配符节点路径是剩余路径的前缀 - // 并且不是 catchAll 类型(不能有子路由), + // 如果剩余路径长度大于等于通配符节点的路径长度, 且通配符节点路径是剩余路径的前缀 + // 并且不是 catchAll 类型(不能有子路由), // 并且通配符之后没有更多字符或紧跟着 '/' if len(path) >= len(n.path) && n.path == path[:len(n.path)] && // 不能向 catchAll 添加子节点 n.nType != catchAll && - // 检查更长的通配符,例如 :name 和 :names + // 检查更长的通配符, 例如 :name 和 :names (len(n.path) >= len(path) || path[len(n.path)] == '/') { continue walk // 继续外部循环 } @@ -269,7 +268,7 @@ walk: // 外部循环用于遍历和构建路由树 // 通配符冲突 pathSeg := path if n.nType != catchAll { - pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll,则截取到下一个 '/' + pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll, 则截取到下一个 '/' } prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path // 构造冲突前缀 panic("'" + pathSeg + // 抛出 panic 表示通配符冲突 @@ -279,13 +278,13 @@ walk: // 外部循环用于遍历和构建路由树 "'") } - n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符) + n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符) return // 完成添加路由 } - // 否则,将处理函数添加到当前节点 + // 否则, 将处理函数添加到当前节点 if n.handlers != nil { - panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数,则报错 + panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数, 则报错 } n.handlers = handlers // 设置处理函数 n.fullPath = fullPath // 设置完整路径 @@ -293,26 +292,20 @@ walk: // 外部循环用于遍历和构建路由树 } } -func (n *node) copyChildren() []*node { - children := make([]*node, len(n.children)) - copy(children, n.children) - return children -} - -// findWildcard 搜索通配符段并检查名称是否包含无效字符。 -// 如果未找到通配符,则返回 -1 作为索引。 +// findWildcard 搜索通配符段并检查名称是否包含无效字符. +// 如果未找到通配符, 则返回 -1 作为索引. func findWildcard(path string) (wildcard string, i int, valid bool) { // 查找开始位置 escapeColon := false // 是否正在处理转义字符 for start, c := range []byte(path) { if escapeColon { escapeColon = false - if c == ':' { // 如果转义字符是 ':',则跳过 + if c == ':' { // 如果转义字符是 ':', 则跳过 continue } panic("invalid escape string in path '" + path + "'") // 无效的转义字符串 } - if c == '\\' { // 如果是反斜杠,则设置转义标志 + if c == '\\' { // 如果是反斜杠, 则设置转义标志 escapeColon = true continue } @@ -325,36 +318,36 @@ func findWildcard(path string) (wildcard string, i int, valid bool) { valid = true // 默认为有效 for end, c := range []byte(path[start+1:]) { switch c { - case '/': // 如果遇到斜杠,说明通配符段结束 + case '/': // 如果遇到斜杠, 说明通配符段结束 return path[start : start+1+end], start, valid - case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*',则无效 + case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*', 则无效 valid = false } } - return path[start:], start, valid // 返回找到的通配符、起始索引和有效性 + return path[start:], start, valid // 返回找到的通配符, 起始索引和有效性 } return "", -1, false // 未找到通配符 } -// insertChild 插入一个带有处理函数的节点。 -// 此函数处理包含通配符的路径插入逻辑。 +// insertChild 插入一个带有处理函数的节点. +// 此函数处理包含通配符的路径插入逻辑. func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) { for { // 找到第一个通配符之前的前缀 wildcard, i, valid := findWildcard(path) - if i < 0 { // 未找到通配符,结束循环 + if i < 0 { // 未找到通配符, 结束循环 break } // 通配符名称只能包含一个 ':' 或 '*' 字符 if !valid { panic("only one wildcard per path segment is allowed, has: '" + - wildcard + "' in path '" + fullPath + "'") // 报错:每个路径段只允许一个通配符 + wildcard + "' in path '" + fullPath + "'") // 报错: 每个路径段只允许一个通配符 } // 检查通配符是否有名称 if len(wildcard) < 2 { - panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错:通配符必须有非空名称 + panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错: 通配符必须有非空名称 } if wildcard[0] == ':' { // 如果是参数节点 (param) @@ -374,7 +367,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) n = child // 移动到新创建的参数节点 n.priority++ // 增加优先级 - // 如果路径不以通配符结束,则会有一个以 '/' 开头的子路径 + // 如果路径不以通配符结束, 则会有一个以 '/' 开头的子路径 if len(wildcard) < len(path) { path = path[len(wildcard):] // 剩余路径去除通配符部分 @@ -382,19 +375,19 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) priority: 1, // 新子节点优先级 fullPath: fullPath, // 设置子节点的完整路径 } - n.addChild(child) // 添加子节点(通常是斜杠后的静态部分) + n.addChild(child) // 添加子节点(通常是斜杠后的静态部分) n = child // 移动到这个新子节点 - continue // 继续循环,查找下一个通配符或结束 + continue // 继续循环, 查找下一个通配符或结束 } - // 否则,我们已经完成。将处理函数插入到新叶节点中 + // 否则, 我们已经完成. 将处理函数插入到新叶节点中 n.handlers = handlers // 设置处理函数 return // 完成 } // 如果是捕获所有节点 (catchAll) if i+len(wildcard) != len(path) { - panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错:捕获所有路由只能在路径末尾 + panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错: 捕获所有路由只能在路径末尾 } // 检查路径段冲突 @@ -403,22 +396,22 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) if len(n.children) != 0 { pathSeg, _, _ = strings.Cut(n.children[0].path, "/") } - panic("catch-all wildcard '" + path + // 报错:捕获所有通配符与现有路径段冲突 + panic("catch-all wildcard '" + path + // 报错: 捕获所有通配符与现有路径段冲突 "' in new path '" + fullPath + "' conflicts with existing path segment '" + pathSeg + "' in existing prefix '" + n.path + pathSeg + "'") } - // 当前固定宽度为 1,用于 '/' + // 当前固定宽度为 1, 用于 '/' i-- if i < 0 || path[i] != '/' { - panic("no / before catch-all in path '" + fullPath + "'") // 报错:捕获所有之前没有 '/' + panic("no / before catch-all in path '" + fullPath + "'") // 报错: 捕获所有之前没有 '/' } n.path = path[:i] // 当前节点路径更新为 catchAll 之前的部分 - // 第一个节点:路径为空的 catchAll 节点 + // 第一个节点: 路径为空的 catchAll 节点 child := &node{ wildChild: true, // 标记为有通配符子节点 nType: catchAll, // 类型为 catchAll @@ -430,7 +423,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) n = child // 移动到新创建的 catchAll 节点 n.priority++ // 增加优先级 - // 第二个节点:包含变量的节点 + // 第二个节点: 包含变量的节点 child = &node{ path: path[i:], // 路径为 catchAll 的实际路径段 nType: catchAll, // 类型为 catchAll @@ -443,7 +436,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) return // 完成 } - // 如果没有找到通配符,简单地插入路径和处理函数 + // 如果没有找到通配符, 简单地插入路径和处理函数 n.path = path // 设置当前节点路径 n.handlers = handlers // 设置处理函数 n.fullPath = fullPath // 设置完整路径 @@ -457,16 +450,16 @@ type nodeValue struct { fullPath string // 匹配到的完整路径 } -// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息,以便回溯。 +// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息, 以便回溯. type skippedNode struct { path string // 跳过时的当前路径 node *node // 跳过的节点 paramsCount int16 // 跳过时已收集的参数数量 } -// getValue 返回注册到给定路径(key)的处理函数。通配符的值会保存到 map 中。 -// 如果找不到处理函数,则在存在一个带有额外(或不带)尾部斜杠的处理函数时, -// 建议进行 TSR(尾部斜杠重定向)。 +// getValue 返回注册到给定路径(key)的处理函数. 通配符的值会保存到 map 中. +// 如果找不到处理函数, 则在存在一个带有额外(或不带)尾部斜杠的处理函数时, +// 建议进行 TSR(尾部斜杠重定向). func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) { var globalParamsCount int16 // 全局参数计数 @@ -477,11 +470,16 @@ walk: // 外部循环用于遍历路由树 if path[:len(prefix)] == prefix { // 如果路径以当前节点的前缀开头 path = path[len(prefix):] // 移除已匹配的前缀 - // 优先尝试所有非通配符子节点,通过匹配索引字符 + // 在访问 path[0] 之前进行安全检查 + if len(path) == 0 { + continue walk + } + + // 优先尝试所有非通配符子节点, 通过匹配索引字符 idxc := path[0] // 剩余路径的第一个字符 for i, c := range []byte(n.indices) { if c == idxc { // 如果找到匹配的索引字符 - // 如果当前节点有通配符子节点,则将当前节点添加到 skippedNodes,以便回溯 + // 如果当前节点有通配符子节点, 则将当前节点添加到 skippedNodes, 以便回溯 if n.wildChild { index := len(*skippedNodes) *skippedNodes = (*skippedNodes)[:index+1] @@ -489,11 +487,10 @@ walk: // 外部循环用于遍历路由树 path: prefix + path, // 记录跳过的路径 node: &node{ // 复制当前节点的状态 path: n.path, - indices: n.indices, wildChild: n.wildChild, nType: n.nType, priority: n.priority, - children: n.copyChildren(), + children: n.children, handlers: n.handlers, fullPath: n.fullPath, }, @@ -525,20 +522,20 @@ walk: // 外部循环用于遍历路由树 } } - // 未找到。 - // 如果存在一个带有额外(或不带)尾部斜杠的处理函数, - // 我们可以建议重定向到相同 URL,不带尾部斜杠。 - value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数,则建议 TSR + // 未找到. + // 如果存在一个带有额外(或不带)尾部斜杠的处理函数, + // 我们可以建议重定向到相同 URL, 不带尾部斜杠. + value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数, 则建议 TSR return value } - // 处理通配符子节点,它总是位于数组的末尾 + // 处理通配符子节点, 它总是位于数组的末尾 n = n.children[len(n.children)-1] // 移动到通配符子节点 globalParamsCount++ // 增加全局参数计数 switch n.nType { case param: // 参数节点 - // 查找参数结束位置('/' 或路径末尾) + // 查找参数结束位置('/' 或路径末尾) end := 0 for end < len(path) && path[end] != '/' { end++ @@ -546,7 +543,7 @@ walk: // 外部循环用于遍历路由树 // 保存参数值 if params != nil { - // 如果需要,预分配容量 + // 如果需要, 预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -566,12 +563,12 @@ walk: // 外部循环用于遍历路由树 } } (*value.params)[i] = Param{ // 存储参数 - Key: n.path[1:], // 参数键名(去除冒号) + Key: n.path[1:], // 参数键名(去除冒号) Value: val, // 参数值 } } - // 我们需要继续深入! + // 我们需要继续深入! if end < len(path) { if len(n.children) > 0 { path = path[end:] // 移除已提取的参数部分 @@ -580,16 +577,16 @@ walk: // 外部循环用于遍历路由树 } // ... 但我们无法继续 - value.tsr = len(path) == end+1 // 如果路径只剩下斜杠,则建议 TSR + value.tsr = len(path) == end+1 // 如果路径只剩下斜杠, 则建议 TSR return value } if value.handlers = n.handlers; value.handlers != nil { value.fullPath = n.fullPath - return value // 如果当前节点有处理函数,则返回 + return value // 如果当前节点有处理函数, 则返回 } if len(n.children) == 1 { - // 未找到处理函数。检查是否存在此路径加尾部斜杠的处理函数,以进行 TSR 建议 + // 未找到处理函数. 检查是否存在此路径加尾部斜杠的处理函数, 以进行 TSR 建议 n = n.children[0] value.tsr = (n.path == "/" && n.handlers != nil) || (n.path == "" && n.indices == "/") } @@ -598,7 +595,7 @@ walk: // 外部循环用于遍历路由树 case catchAll: // 捕获所有节点 // 保存参数值 if params != nil { - // 如果需要,预分配容量 + // 如果需要, 预分配容量 if cap(*params) < int(globalParamsCount) { newParams := make(Params, len(*params), globalParamsCount) copy(newParams, *params) @@ -618,7 +615,7 @@ walk: // 外部循环用于遍历路由树 } } (*value.params)[i] = Param{ // 存储参数 - Key: n.path[2:], // 参数键名(去除星号) + Key: n.path[2:], // 参数键名(去除星号) Value: val, // 参数值 } } @@ -634,7 +631,7 @@ walk: // 外部循环用于遍历路由树 } if path == prefix { // 如果路径完全匹配当前节点的前缀 - // 如果当前路径不等于 '/' 且节点没有注册的处理函数,且最近匹配的节点有子节点 + // 如果当前路径不等于 '/' 且节点没有注册的处理函数, 且最近匹配的节点有子节点 // 当前节点需要回溯到最后一个有效的 skippedNode if n.handlers == nil && path != "/" { for length := len(*skippedNodes); length > 0; length-- { @@ -651,26 +648,26 @@ walk: // 外部循环用于遍历路由树 } } } - // 我们应该已经到达包含处理函数的节点。 - // 检查此节点是否注册了处理函数。 + // 我们应该已经到达包含处理函数的节点. + // 检查此节点是否注册了处理函数. if value.handlers = n.handlers; value.handlers != nil { value.fullPath = n.fullPath - return value // 如果有处理函数,则返回 + return value // 如果有处理函数, 则返回 } - // 如果此路由没有处理函数,但此路由有通配符子节点, - // 则此路径必须有一个带有额外尾部斜杠的处理函数。 + // 如果此路由没有处理函数, 但此路由有通配符子节点, + // 则此路径必须有一个带有额外尾部斜杠的处理函数. if path == "/" && n.wildChild && n.nType != root { value.tsr = true // 建议 TSR return value } if path == "/" && n.nType == static { - value.tsr = true // 如果是静态节点且路径是根,则建议 TSR + value.tsr = true // 如果是静态节点且路径是根, 则建议 TSR return value } - // 未找到处理函数。检查此路径加尾部斜杠是否存在处理函数,以进行尾部斜杠重定向建议 + // 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数, 以进行尾部斜杠重定向建议 for i, c := range []byte(n.indices) { if c == '/' { // 如果索引中包含 '/' n = n.children[i] // 移动到对应的子节点 @@ -683,11 +680,11 @@ walk: // 外部循环用于遍历路由树 return value } - // 未找到。我们可以建议重定向到相同 URL,添加一个额外的尾部斜杠, - // 如果该路径的叶节点存在。 + // 未找到. 我们可以建议重定向到相同 URL, 添加一个额外的尾部斜杠, + // 如果该路径的叶节点存在. value.tsr = path == "/" || // 如果路径是根路径 (len(prefix) == len(path)+1 && prefix[len(path)] == '/' && // 或者前缀比路径多一个斜杠 - path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠,且有处理函数 + path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠, 且有处理函数 // 回溯到最后一个有效的 skippedNode if !value.tsr && path != "/" { @@ -710,17 +707,17 @@ walk: // 外部循环用于遍历路由树 } } -// findCaseInsensitivePath 对给定路径进行不区分大小写的查找,并尝试找到处理函数。 -// 它还可以选择修复尾部斜杠。 -// 它返回大小写校正后的路径和一个布尔值,指示查找是否成功。 +// findCaseInsensitivePath 对给定路径进行不区分大小写的查找, 并尝试找到处理函数. +// 它还可以选择修复尾部斜杠. +// 它返回大小写校正后的路径和一个布尔值, 指示查找是否成功. func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]byte, bool) { const stackBufSize = 128 // 栈上缓冲区的默认大小 - // 在常见情况下使用栈上静态大小的缓冲区。 - // 如果路径太长,则在堆上分配缓冲区。 + // 在常见情况下使用栈上静态大小的缓冲区. + // 如果路径太长, 则在堆上分配缓冲区. buf := make([]byte, 0, stackBufSize) if length := len(path) + 1; length > stackBufSize { - buf = make([]byte, 0, length) // 如果路径太长,则分配更大的缓冲区 + buf = make([]byte, 0, length) // 如果路径太长, 则分配更大的缓冲区 } ciPath := n.findCaseInsensitivePathRec( @@ -733,7 +730,7 @@ func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]by return ciPath, ciPath != nil // 返回校正后的路径和是否成功找到 } -// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节。 +// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节. func shiftNRuneBytes(rb [4]byte, n int) [4]byte { switch n { case 0: @@ -749,12 +746,12 @@ func shiftNRuneBytes(rb [4]byte, n int) [4]byte { } } -// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数。 +// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数. func (n *node) findCaseInsensitivePathRec(path string, ciPath []byte, rb [4]byte, fixTrailingSlash bool) []byte { npLen := len(n.path) // 当前节点的路径长度 walk: // 外部循环用于遍历路由树 - // 只要剩余路径长度大于等于当前节点路径长度,且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径 + // 只要剩余路径长度大于等于当前节点路径长度, 且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径 for len(path) >= npLen && (npLen == 0 || strings.EqualFold(path[1:npLen], n.path[1:])) { // 将公共前缀添加到结果中 oldPath := path // 保存原始路径 @@ -762,13 +759,13 @@ walk: // 外部循环用于遍历路由树 ciPath = append(ciPath, n.path...) // 将当前节点的路径添加到不区分大小写路径中 if len(path) == 0 { // 如果路径已完全匹配 - // 我们应该已经到达包含处理函数的节点。 - // 检查此节点是否注册了处理函数。 + // 我们应该已经到达包含处理函数的节点. + // 检查此节点是否注册了处理函数. if n.handlers != nil { - return ciPath // 如果有处理函数,则返回校正后的路径 + return ciPath // 如果有处理函数, 则返回校正后的路径 } - // 未找到处理函数。 + // 未找到处理函数. // 尝试通过添加尾部斜杠来修复路径 if fixTrailingSlash { for i, c := range []byte(n.indices) { @@ -782,11 +779,11 @@ walk: // 外部循环用于遍历路由树 } } } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil } - // 如果此节点没有通配符(参数或捕获所有)子节点, - // 我们可以直接查找下一个子节点并继续遍历树。 + // 如果此节点没有通配符(参数或捕获所有)子节点, + // 我们可以直接查找下一个子节点并继续遍历树. if !n.wildChild { // 跳过已处理的 rune 字节 rb = shiftNRuneBytes(rb, npLen) @@ -806,9 +803,9 @@ walk: // 外部循环用于遍历路由树 // 处理一个新的 rune var rv rune - // 查找 rune 的开始位置。 - // Runes 最长为 4 字节。 - // -4 肯定会是另一个 rune。 + // 查找 rune 的开始位置. + // Runes 最长为 4 字节. + // -4 肯定会是另一个 rune. var off int for max_ := min(npLen, 3); off < max_; off++ { if i := npLen - off; utf8.RuneStart(oldPath[i]) { @@ -829,17 +826,17 @@ walk: // 外部循环用于遍历路由树 for i, c := range []byte(n.indices) { // 小写匹配 if c == idxc { - // 必须使用递归方法,因为大写字节和小写字节都可能作为索引存在 + // 必须使用递归方法, 因为大写字节和小写字节都可能作为索引存在 if out := n.children[i].findCaseInsensitivePathRec( path, ciPath, rb, fixTrailingSlash, ); out != nil { - return out // 如果找到,则返回 + return out // 如果找到, 则返回 } break } } - // 如果未找到匹配项,则对大写 rune 执行相同操作(如果它不同) + // 如果未找到匹配项, 则对大写 rune 执行相同操作(如果它不同) if up := unicode.ToUpper(rv); up != lo { utf8.EncodeRune(rb[:], up) // 将大写 rune 编码到缓冲区 rb = shiftNRuneBytes(rb, off) @@ -857,18 +854,18 @@ walk: // 外部循环用于遍历路由树 } } - // 未找到。我们可以建议重定向到相同 URL,不带尾部斜杠, - // 如果该路径的叶节点存在。 + // 未找到. 我们可以建议重定向到相同 URL, 不带尾部斜杠, + // 如果该路径的叶节点存在. if fixTrailingSlash && path == "/" && n.handlers != nil { - return ciPath // 如果可以修复尾部斜杠且有处理函数,则返回 + return ciPath // 如果可以修复尾部斜杠且有处理函数, 则返回 } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil } - n = n.children[0] // 移动到通配符子节点(通常是唯一一个) + n = n.children[0] // 移动到通配符子节点(通常是唯一一个) switch n.nType { case param: // 参数节点 - // 查找参数结束位置('/' 或路径末尾) + // 查找参数结束位置('/' 或路径末尾) end := 0 for end < len(path) && path[end] != '/' { end++ @@ -877,7 +874,7 @@ walk: // 外部循环用于遍历路由树 // 将参数值添加到不区分大小写路径中 ciPath = append(ciPath, path[:end]...) - // 我们需要继续深入! + // 我们需要继续深入! if end < len(path) { if len(n.children) > 0 { // 继续处理子节点 @@ -889,45 +886,45 @@ walk: // 外部循环用于遍历路由树 // ... 但我们无法继续 if fixTrailingSlash && len(path) == end+1 { - return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠,则返回 + return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠, 则返回 } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil } if n.handlers != nil { - return ciPath // 如果有处理函数,则返回 + return ciPath // 如果有处理函数, 则返回 } if fixTrailingSlash && len(n.children) == 1 { - // 未找到处理函数。检查此路径加尾部斜杠是否存在处理函数 + // 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数 n = n.children[0] if n.path == "/" && n.handlers != nil { return append(ciPath, '/') // 返回添加斜杠后的路径 } } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil case catchAll: // 捕获所有节点 - return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有) + return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有) default: panic("invalid node type") // 无效的节点类型 } } - // 未找到。 + // 未找到. // 尝试通过添加/删除尾部斜杠来修复路径 if fixTrailingSlash { if path == "/" { - return ciPath // 如果路径是根路径,则返回 + return ciPath // 如果路径是根路径, 则返回 } - // 如果路径长度比当前节点路径少一个斜杠,且末尾是斜杠, - // 且不区分大小写匹配,且当前节点有处理函数 + // 如果路径长度比当前节点路径少一个斜杠, 且末尾是斜杠, + // 且不区分大小写匹配, 且当前节点有处理函数 if len(path)+1 == npLen && n.path[len(path)] == '/' && strings.EqualFold(path[1:], n.path[1:len(path)]) && n.handlers != nil { return append(ciPath, n.path...) // 返回添加当前节点路径后的路径 } } - return nil // 未找到,返回 nil + return nil // 未找到, 返回 nil } diff --git a/tree_test.go b/tree_test.go index 635272c..d3ffdfa 100644 --- a/tree_test.go +++ b/tree_test.go @@ -1021,3 +1021,58 @@ func TestWildcardInvalidSlash(t *testing.T) { } } } + +// TestComplexBacktrackingWithCatchAll 是一个更复杂的回归测试. +// 它确保在静态路径匹配失败后, 路由器能够正确地回溯并成功匹配一个 +// 包含多个命名参数、静态部分和捕获所有参数的复杂路由. +// 这个测试对于验证在禁用 RedirectTrailingSlash 时的算法健壮性至关重要. +func TestComplexBacktrackingWithCatchAll(t *testing.T) { + // 1. Arrange: 初始化路由树并设置复杂的路由结构 + tree := &node{} + routes := [...]string{ + "/abc/b", // 静态诱饵路由 + "/abc/:p1/cde", // 一个不相关的、不会被匹配到的干扰路由 + "/abc/:p1/:p2/def/*filepath", // 最终应该匹配到的复杂目标路由 + } + for _, route := range routes { + tree.addRoute(route, fakeHandler(route)) + } + + // 2. Act: 执行一个会触发深度回溯的请求 + // 这个路径会首先尝试匹配静态的 /abc/b, 但因为后续路径不匹配而失败, + // 从而强制回溯到 /abc/ 节点, 并重新尝试匹配通配符路径. + reqPath := "/abc/b/d/def/some/file.txt" + wantRoute := "/abc/:p1/:p2/def/*filepath" + wantParams := Params{ + {Key: "p1", Value: "b"}, + {Key: "p2", Value: "d"}, + {Key: "filepath", Value: "/some/file.txt"}, // 注意: catch-all 会包含前导斜杠 + } + + // 使用 defer/recover 来断言整个过程不会发生 panic + defer func() { + if r := recover(); r != nil { + t.Fatalf("预期不应发生 panic, 但在处理路径 '%s' 时捕获到了: %v", reqPath, r) + } + }() + + // 执行查找操作 + value := tree.getValue(reqPath, getParams(), getSkippedNodes(), false) + + // 3. Assert: 验证回溯后的结果是否正确 + // 断言找到了一个有效的句柄 + if value.handlers == nil { + t.Fatalf("处理路径 '%s' 时句柄不匹配: 期望得到非空的句柄, 但实际为 nil", reqPath) + } + + // 断言匹配到了正确的路由 + value.handlers[0](nil) + if fakeHandlerValue != wantRoute { + t.Errorf("处理路径 '%s' 时句柄不匹配: \n 得到: %s\n 想要: %s", reqPath, fakeHandlerValue, wantRoute) + } + + // 断言URL参数被正确地解析和提取 + if value.params == nil || !reflect.DeepEqual(*value.params, wantParams) { + t.Errorf("处理路径 '%s' 时参数不匹配: \n 得到: %v\n 想要: %v", reqPath, *value.params, wantParams) + } +} From 3590a77f904e1226a7ce7b78c3e6a853283b791f Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:23:49 +0800 Subject: [PATCH 06/19] fix reqip val --- context.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context.go b/context.go index 14edc53..c1e2bb8 100644 --- a/context.go +++ b/context.go @@ -564,7 +564,7 @@ func (c *Context) RequestIP() string { addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr) if err == nil { // 成功从 "ip:port" 格式中解析出 IP - return addrp.String() + return addrp.Addr().String() } // 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口), From 3ffde5742ce5201369cec388843c38f1396d1727 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:50:26 +0800 Subject: [PATCH 07/19] add wanf --- context.go | 61 ++++++++++++++++++++++++++++++++++++++------------- fileserver.go | 12 ++++++---- go.mod | 5 +++-- go.sum | 10 +++++---- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index c1e2bb8..b6fbd46 100644 --- a/context.go +++ b/context.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/WJQSERVER/wanf" "github.com/fenthope/reco" "github.com/go-json-experiment/json" @@ -42,7 +43,7 @@ type Context struct { index int8 // 当前执行到处理链的哪个位置 mu sync.RWMutex - Keys map[string]interface{} // 用于在中间件之间传递数据 + Keys map[string]any // 用于在中间件之间传递数据 Errors []error // 用于收集处理过程中的错误 @@ -77,20 +78,18 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { } else { c.Writer = newResponseWriter(w) } - //c.Writer = newResponseWriter(w) c.Request = req c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组 c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 - c.Keys = make(map[string]interface{}) // 每次请求重新创建 map,避免数据污染 + c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染 c.Errors = c.Errors[:0] // 清空 Errors 切片 c.queryCache = nil // 清空查询参数缓存 c.formCache = nil // 清空表单数据缓存 c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值 c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式 c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize - // c.HTTPClient 和 c.engine 保持不变,它们引用 Engine 实例的成员 } // Next 在处理链中执行下一个处理函数 @@ -122,10 +121,10 @@ func (c *Context) AbortWithStatus(code int) { // Set 将一个键值对存储到 Context 中 // 这是一个线程安全的操作,用于在中间件之间传递数据 -func (c *Context) Set(key string, value interface{}) { +func (c *Context) Set(key string, value any) { c.mu.Lock() // 加写锁 if c.Keys == nil { - c.Keys = make(map[string]interface{}) + c.Keys = make(map[string]any) } c.Keys[key] = value c.mu.Unlock() // 解写锁 @@ -133,7 +132,7 @@ func (c *Context) Set(key string, value interface{}) { // Get 从 Context 中获取一个值 // 这是一个线程安全的操作 -func (c *Context) Get(key string) (value interface{}, exists bool) { +func (c *Context) Get(key string) (value any, exists bool) { c.mu.RLock() // 加读锁 value, exists = c.Keys[key] c.mu.RUnlock() // 解读锁 @@ -208,7 +207,7 @@ func (c *Context) GetDuration(key string) (value time.Duration, exists bool) { // MustGet 从 Context 中获取一个值,如果不存在则 panic // 适用于确定值一定存在的场景 -func (c *Context) MustGet(key string) interface{} { +func (c *Context) MustGet(key string) any { if value, exists := c.Get(key); exists { return value } @@ -269,7 +268,7 @@ func (c *Context) Raw(code int, contentType string, data []byte) { } // String 向响应写入格式化的字符串 -func (c *Context) String(code int, format string, values ...interface{}) { +func (c *Context) String(code int, format string, values ...any) { c.Writer.WriteHeader(code) c.Writer.Write([]byte(fmt.Sprintf(format, values...))) } @@ -283,7 +282,7 @@ func (c *Context) Text(code int, text string) { // JSON 向响应写入 JSON 数据 // 设置 Content-Type 为 application/json -func (c *Context) JSON(code int, obj interface{}) { +func (c *Context) JSON(code int, obj any) { c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") c.Writer.WriteHeader(code) if err := json.MarshalWrite(c.Writer, obj); err != nil { @@ -295,7 +294,7 @@ func (c *Context) JSON(code int, obj interface{}) { // GOB 向响应写入GOB数据 // 设置 Content-Type 为 application/octet-stream -func (c *Context) GOB(code int, obj interface{}) { +func (c *Context) GOB(code int, obj any) { c.Writer.Header().Set("Content-Type", "application/octet-stream") // 设置合适的 Content-Type c.Writer.WriteHeader(code) // GOB 编码 @@ -307,11 +306,25 @@ func (c *Context) GOB(code int, obj interface{}) { } } +// WANF向响应写入WANF数据 +// 设置 application/vnd.wjqserver.wanf; charset=utf-8 +func (c *Context) WANF(code int, obj any) { + c.Writer.Header().Set("Content-Type", "application/vnd.wjqserver.wanf; charset=utf-8") + c.Writer.WriteHeader(code) + // WANF 编码 + encoder := wanf.NewStreamEncoder(c.Writer) + if err := encoder.Encode(obj); err != nil { + c.AddError(fmt.Errorf("failed to encode WANF: %w", err)) + c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to encode WANF: %w", err)) + return + } +} + // HTML 渲染 HTML 模板 // 如果 Engine 配置了 HTMLRender,则使用它进行渲染 // 否则,会进行简单的字符串输出 // 预留接口,可以扩展为支持多种模板引擎 -func (c *Context) HTML(code int, name string, obj interface{}) { +func (c *Context) HTML(code int, name string, obj any) { c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.WriteHeader(code) @@ -342,7 +355,7 @@ func (c *Context) Redirect(code int, location string) { } // ShouldBindJSON 尝试将请求体绑定到 JSON 对象 -func (c *Context) ShouldBindJSON(obj interface{}) error { +func (c *Context) ShouldBindJSON(obj any) error { if c.Request.Body == nil { return errors.New("request body is empty") } @@ -353,10 +366,28 @@ func (c *Context) ShouldBindJSON(obj interface{}) error { return nil } +// ShouldBindWANF +func (c *Context) ShouldBindWANF(obj any) error { + if c.Request.Body == nil { + return errors.New("request body is empty") + } + decoder, err := wanf.NewStreamDecoder(c.Request.Body) + if err != nil { + return fmt.Errorf("failed to create WANF decoder: %w", err) + } + + if err := decoder.Decode(obj); err != nil { + return fmt.Errorf("WANF binding error: %w", err) + } + return nil +} + +// Deprecated: This function is a reserved placeholder for future API extensions +// and is not yet implemented. It will either be properly defined or removed in v2.0.0. Do not use. // ShouldBind 尝试将请求体绑定到各种类型(JSON, Form, XML 等) // 这是一个复杂的通用绑定接口,通常根据 Content-Type 或其他头部来判断绑定方式 // 预留接口,可根据项目需求进行扩展 -func (c *Context) ShouldBind(obj interface{}) error { +func (c *Context) ShouldBind(obj any) error { // TODO: 完整的通用绑定逻辑 // 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等 // 例如: @@ -409,7 +440,7 @@ func (c *Context) Err() error { // Value returns the value associated with this context for key, or nil if no // value is associated with key. // 可以用于从 Context 中获取与特定键关联的值,包括 Go 原生 Context 的值和 Touka Context 的 Keys -func (c *Context) Value(key interface{}) interface{} { +func (c *Context) Value(key any) any { if keyAsString, ok := key.(string); ok { if val, exists := c.Get(keyAsString); exists { return val diff --git a/fileserver.go b/fileserver.go index 5b7f248..197b681 100644 --- a/fileserver.go +++ b/fileserver.go @@ -6,7 +6,6 @@ package touka import ( "errors" - "fmt" "net/http" "path" "strings" @@ -19,13 +18,19 @@ var allowedFileServerMethods = map[string]struct{}{ http.MethodHead: {}, } +var ( + ErrInputFSisNil = errors.New("input FS is nil") + ErrMethodNotAllowed = errors.New("method not allowed") +) + // FileServer方式, 返回一个HandleFunc, 统一化处理 func FileServer(fs http.FileSystem) HandlerFunc { if fs == nil { return func(c *Context) { - c.ErrorUseHandle(500, errors.New("Input FileSystem is nil")) + c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil) } } + fileServerInstance := http.FileServer(fs) return func(c *Context) { FileServerHandleServe(c, fileServerInstance) @@ -37,7 +42,6 @@ func FileServer(fs http.FileSystem) HandlerFunc { func FileServerHandleServe(c *Context, fsHandle http.Handler) { if fsHandle == nil { - ErrInputFSisNil := errors.New("Input FileSystem Handle is nil") c.AddError(ErrInputFSisNil) // 500 c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil) @@ -59,7 +63,7 @@ func FileServerHandleServe(c *Context, fsHandle http.Handler) { return } else { // 否则,返回 405 Method Not Allowed - c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, fmt.Errorf("Method %s is Not Allowed on FileServer", c.Request.Method)) + c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, ErrMethodNotAllowed) } } else { c.Next() diff --git a/go.mod b/go.mod index e9d0304..f1c81da 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.24.5 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 + github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 + github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.42.0 // indirect + golang.org/x/net v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index d9a63e3..dcd4f26 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,13 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk= github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= +github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= +github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= +github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= From af0a99acdaefb63da6cf9a61c7e3b0586d550c6d Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sat, 6 Sep 2025 17:55:45 +0000 Subject: [PATCH 08/19] add sse intn support --- go.mod | 2 +- sse.go | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 sse.go diff --git a/go.mod b/go.mod index f1c81da..0b8d97b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/infinite-iroha/touka -go 1.24.5 +go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 diff --git a/sse.go b/sse.go new file mode 100644 index 0000000..b856a08 --- /dev/null +++ b/sse.go @@ -0,0 +1,191 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2024 WJQSERVER. All rights reserved. +// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization. +package touka + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" +) + +// Event 代表一个服务器发送事件(SSE). +type Event struct { + // Event 是事件的名称. + Event string + // Data 是事件的内容, 可以是多行文本. + Data string + // Id 是事件的唯一标识符. + Id string + // Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接. + Retry string +} + +// Render 将事件格式化并写入给定的 writer. +// 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配. +func (e *Event) Render(w io.Writer) error { + var buf bytes.Buffer + + if len(e.Id) > 0 { + buf.WriteString("id: ") + buf.WriteString(e.Id) + buf.WriteString("\n") + } + if len(e.Event) > 0 { + buf.WriteString("event: ") + buf.WriteString(e.Event) + buf.WriteString("\n") + } + if len(e.Data) > 0 { + lines := strings.Split(e.Data, "\n") + for _, line := range lines { + buf.WriteString("data: ") + buf.WriteString(line) + buf.WriteString("\n") + } + } + if len(e.Retry) > 0 { + buf.WriteString("retry: ") + buf.WriteString(e.Retry) + buf.WriteString("\n") + } + + // 每个事件都以一个额外的换行符结尾. + buf.WriteString("\n") + + // 直接将 buffer 的内容写入 writer, 避免生成中间字符串. + _, err := buf.WriteTo(w) + return err +} + +// EventStream 启动一个 SSE 事件流. +// 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. +// +// 详细用法: +// +// r.GET("/sse/callback", func(c *touka.Context) { +// // streamer 回调函数会在一个循环中被调用. +// c.EventStream(func(w io.Writer) bool { +// event := touka.Event{ +// Event: "time-tick", +// Data: time.Now().Format(time.RFC1123), +// } +// +// if err := event.Render(w); err != nil { +// // 发生写入错误, 停止发送. +// return false // 返回 false 结束事件流. +// } +// +// time.Sleep(2 * time.Second) +// return true // 返回 true 继续事件流. +// }) +// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. +// fmt.Println("Client disconnected from /sse/callback") +// }) +func (c *Context) EventStream(streamer func(w io.Writer) bool) { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") + return + } + + c.Writer.WriteHeader(http.StatusOK) + flusher.Flush() + + for { + select { + case <-c.Request.Context().Done(): + return + default: + if !streamer(c.Writer) { + return + } + flusher.Flush() + } + } +} + +// EventStreamChan 返回用于 SSE 事件流的 channel. +// 这是为高级并发场景设计的、更灵活的API. +// 调用者必须负责关闭 event channel 并处理 error channel 以避免 goroutine 泄漏. +// +// 详细用法: +// +// r.GET("/sse/channel", func(c *touka.Context) { +// eventChan, errChan := c.EventStreamChan() +// +// // 必须在独立的goroutine中处理错误和连接断开. +// go func() { +// if err := <-errChan; err != nil { +// c.Errorf("SSE channel error: %v", err) +// } +// }() +// +// // 在另一个goroutine中异步发送事件. +// go func() { +// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. +// defer close(eventChan) +// +// for i := 1; i <= 5; i++ { +// event := touka.Event{ +// Id: fmt.Sprintf("%d", i), +// Data: "hello from channel", +// } +// eventChan <- event +// time.Sleep(2 * time.Second) +// } +// }() +// }) +func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { + eventChan := make(chan Event) + errChan := make(chan error, 1) + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + err := fmt.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") + c.Errorf(err.Error()) + errChan <- err + close(errChan) + close(eventChan) + return eventChan, errChan + } + + c.Writer.WriteHeader(http.StatusOK) + flusher.Flush() + + go func() { + defer close(errChan) + + for { + select { + case event, ok := <-eventChan: + if !ok { + return + } + if err := event.Render(c.Writer); err != nil { + errChan <- err + return + } + flusher.Flush() + case <-c.Request.Context().Done(): + errChan <- c.Request.Context().Err() + return + } + } + }() + + return eventChan, errChan +} From b4e073ae2f179cb93820fb95fde7fabcafe50d2c Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 7 Sep 2025 02:24:28 +0800 Subject: [PATCH 09/19] Update sse.go --- sse.go | 134 +++++++++++++++++++++++++++------------------------------ 1 file changed, 63 insertions(+), 71 deletions(-) diff --git a/sse.go b/sse.go index b856a08..597e930 100644 --- a/sse.go +++ b/sse.go @@ -1,6 +1,6 @@ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2024 WJQSERVER. All rights reserved. +// Copyright 2025 WJQSERVER. All rights reserved. // All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization. package touka @@ -25,6 +25,7 @@ type Event struct { } // Render 将事件格式化并写入给定的 writer. +// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题. // 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配. func (e *Event) Render(w io.Writer) error { var buf bytes.Buffer @@ -65,40 +66,34 @@ func (e *Event) Render(w io.Writer) error { // 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. // // 详细用法: +// r.GET("/sse/callback", func(c *touka.Context) { +// // streamer 回调函数会在一个循环中被调用. +// c.EventStream(func(w io.Writer) bool { +// event := touka.Event{ +// Event: "time-tick", +// Data: time.Now().Format(time.RFC1123), +// } // -// r.GET("/sse/callback", func(c *touka.Context) { -// // streamer 回调函数会在一个循环中被调用. -// c.EventStream(func(w io.Writer) bool { -// event := touka.Event{ -// Event: "time-tick", -// Data: time.Now().Format(time.RFC1123), -// } +// if err := event.Render(w); err != nil { +// // 发生写入错误, 停止发送. +// return false // 返回 false 结束事件流. +// } // -// if err := event.Render(w); err != nil { -// // 发生写入错误, 停止发送. -// return false // 返回 false 结束事件流. -// } -// -// time.Sleep(2 * time.Second) -// return true // 返回 true 继续事件流. -// }) -// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. -// fmt.Println("Client disconnected from /sse/callback") -// }) +// time.Sleep(2 * time.Second) +// return true // 返回 true 继续事件流. +// }) +// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. +// fmt.Println("Client disconnected from /sse/callback") +// }) func (c *Context) EventStream(streamer func(w io.Writer) bool) { - c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") - c.Writer.Header().Set("Transfer-Encoding", "chunked") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - c.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") - return - } + // 为现代网络协议优化头部. + c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + c.Writer.Header().Set("Cache-Control", "no-cache, no-transform") + c.Writer.Header().Del("Connection") + c.Writer.Header().Del("Transfer-Encoding") c.Writer.WriteHeader(http.StatusOK) - flusher.Flush() + c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在. for { select { @@ -108,63 +103,60 @@ func (c *Context) EventStream(streamer func(w io.Writer) bool) { if !streamer(c.Writer) { return } - flusher.Flush() + c.Writer.Flush() } } } // EventStreamChan 返回用于 SSE 事件流的 channel. // 这是为高级并发场景设计的、更灵活的API. -// 调用者必须负责关闭 event channel 并处理 error channel 以避免 goroutine 泄漏. +// +// 重要: +// - 调用者必须 close(eventChan) 来结束事件流. +// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. +// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). // // 详细用法: +// r.GET("/sse/channel", func(c *touka.Context) { +// eventChan, errChan := c.EventStreamChan() // -// r.GET("/sse/channel", func(c *touka.Context) { -// eventChan, errChan := c.EventStreamChan() +// // 必须在独立的goroutine中处理错误和连接断开. +// go func() { +// if err := <-errChan; err != nil { +// c.Errorf("SSE channel error: %v", err) +// } +// }() // -// // 必须在独立的goroutine中处理错误和连接断开. -// go func() { -// if err := <-errChan; err != nil { -// c.Errorf("SSE channel error: %v", err) -// } -// }() +// // 在另一个goroutine中异步发送事件. +// go func() { +// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. +// defer close(eventChan) // -// // 在另一个goroutine中异步发送事件. -// go func() { -// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. -// defer close(eventChan) -// -// for i := 1; i <= 5; i++ { -// event := touka.Event{ -// Id: fmt.Sprintf("%d", i), -// Data: "hello from channel", -// } -// eventChan <- event -// time.Sleep(2 * time.Second) -// } -// }() -// }) +// for i := 1; i <= 5; i++ { +// select { +// case <-c.Request.Context().Done(): +// return // 客户端已断开, 退出 goroutine. +// default: +// eventChan <- touka.Event{ +// Id: fmt.Sprintf("%d", i), +// Data: "hello from channel", +// } +// time.Sleep(2 * time.Second) +// } +// } +// }() +// }) func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { eventChan := make(chan Event) errChan := make(chan error, 1) - c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") - c.Writer.Header().Set("Transfer-Encoding", "chunked") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - err := fmt.Errorf("streaming unsupported: http.ResponseWriter does not implement http.Flusher") - c.Errorf(err.Error()) - errChan <- err - close(errChan) - close(eventChan) - return eventChan, errChan - } + c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + c.Writer.Header().Set("Cache-Control", "no-cache, no-transform") + c.Writer.Header().Del("Connection") + c.Writer.Header().Del("Transfer-Encoding") c.Writer.WriteHeader(http.StatusOK) - flusher.Flush() + c.Writer.Flush() go func() { defer close(errChan) @@ -179,7 +171,7 @@ func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { errChan <- err return } - flusher.Flush() + c.Writer.Flush() case <-c.Request.Context().Done(): errChan <- c.Request.Context().Err() return From 38ff5126e326f0a19065f2507737cc8f70e3cfc2 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Wed, 10 Sep 2025 02:40:41 +0800 Subject: [PATCH 10/19] fix --- sse.go | 97 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/sse.go b/sse.go index 597e930..3b98800 100644 --- a/sse.go +++ b/sse.go @@ -6,7 +6,6 @@ package touka import ( "bytes" - "fmt" "io" "net/http" "strings" @@ -66,25 +65,26 @@ func (e *Event) Render(w io.Writer) error { // 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期. // // 详细用法: -// r.GET("/sse/callback", func(c *touka.Context) { -// // streamer 回调函数会在一个循环中被调用. -// c.EventStream(func(w io.Writer) bool { -// event := touka.Event{ -// Event: "time-tick", -// Data: time.Now().Format(time.RFC1123), -// } // -// if err := event.Render(w); err != nil { -// // 发生写入错误, 停止发送. -// return false // 返回 false 结束事件流. -// } +// r.GET("/sse/callback", func(c *touka.Context) { +// // streamer 回调函数会在一个循环中被调用. +// c.EventStream(func(w io.Writer) bool { +// event := touka.Event{ +// Event: "time-tick", +// Data: time.Now().Format(time.RFC1123), +// } // -// time.Sleep(2 * time.Second) -// return true // 返回 true 继续事件流. -// }) -// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. -// fmt.Println("Client disconnected from /sse/callback") -// }) +// if err := event.Render(w); err != nil { +// // 发生写入错误, 停止发送. +// return false // 返回 false 结束事件流. +// } +// +// time.Sleep(2 * time.Second) +// return true // 返回 true 继续事件流. +// }) +// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行. +// fmt.Println("Client disconnected from /sse/callback") +// }) func (c *Context) EventStream(streamer func(w io.Writer) bool) { // 为现代网络协议优化头部. c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") @@ -112,40 +112,41 @@ func (c *Context) EventStream(streamer func(w io.Writer) bool) { // 这是为高级并发场景设计的、更灵活的API. // // 重要: -// - 调用者必须 close(eventChan) 来结束事件流. -// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. -// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). +// - 调用者必须 close(eventChan) 来结束事件流. +// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开. +// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done(). // // 详细用法: -// r.GET("/sse/channel", func(c *touka.Context) { -// eventChan, errChan := c.EventStreamChan() // -// // 必须在独立的goroutine中处理错误和连接断开. -// go func() { -// if err := <-errChan; err != nil { -// c.Errorf("SSE channel error: %v", err) -// } -// }() +// r.GET("/sse/channel", func(c *touka.Context) { +// eventChan, errChan := c.EventStreamChan() // -// // 在另一个goroutine中异步发送事件. -// go func() { -// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. -// defer close(eventChan) +// // 必须在独立的goroutine中处理错误和连接断开. +// go func() { +// if err := <-errChan; err != nil { +// c.Errorf("SSE channel error: %v", err) +// } +// }() // -// for i := 1; i <= 5; i++ { -// select { -// case <-c.Request.Context().Done(): -// return // 客户端已断开, 退出 goroutine. -// default: -// eventChan <- touka.Event{ -// Id: fmt.Sprintf("%d", i), -// Data: "hello from channel", -// } -// time.Sleep(2 * time.Second) -// } -// } -// }() -// }) +// // 在另一个goroutine中异步发送事件. +// go func() { +// // 重要: 必须在逻辑结束时关闭channel, 以通知框架. +// defer close(eventChan) +// +// for i := 1; i <= 5; i++ { +// select { +// case <-c.Request.Context().Done(): +// return // 客户端已断开, 退出 goroutine. +// default: +// eventChan <- touka.Event{ +// Id: fmt.Sprintf("%d", i), +// Data: "hello from channel", +// } +// time.Sleep(2 * time.Second) +// } +// } +// }() +// }) func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { eventChan := make(chan Event) errChan := make(chan error, 1) @@ -156,7 +157,7 @@ func (c *Context) EventStreamChan() (chan<- Event, <-chan error) { c.Writer.Header().Del("Transfer-Encoding") c.Writer.WriteHeader(http.StatusOK) - c.Writer.Flush() + c.Writer.Flush() go func() { defer close(errChan) From 5b98310de532e9545fed2233b411e7050e4edfd2 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Sep 2025 08:24:01 +0800 Subject: [PATCH 11/19] fix StaticFS --- fileserver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fileserver.go b/fileserver.go index 197b681..1aa1aaf 100644 --- a/fileserver.go +++ b/fileserver.go @@ -244,7 +244,7 @@ func (engine *Engine) StaticFS(relativePath string, fs http.FileSystem) { relativePath += "/" } - fileServer := http.FileServer(fs) + fileServer := http.StripPrefix(relativePath, http.FileServer(fs)) engine.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer)) } @@ -258,7 +258,7 @@ func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) { relativePath += "/" } - fileServer := http.FileServer(fs) + fileServer := http.StripPrefix(relativePath, http.FileServer(fs)) group.ANY(relativePath+"*filepath", GetStaticFSHandleFunc(fileServer)) } From 76a89800a2df065e2dc37a1cf0b41fe4a0fb2545 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 12 Oct 2025 15:47:02 +0800 Subject: [PATCH 12/19] update --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0b8d97b..d8b0dfc 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,10 @@ require ( github.com/WJQSERVER-STUDIO/httpc v0.8.2 github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b + github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index dcd4f26..ca56e55 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,11 @@ github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= +github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= From 1361f6e2379c038b91e777b89509ef7a86ed305d Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:47:29 +0800 Subject: [PATCH 13/19] update --- context.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/context.go b/context.go index b6fbd46..0a57ee1 100644 --- a/context.go +++ b/context.go @@ -19,6 +19,7 @@ import ( "net/url" "os" "path" + "path/filepath" "strings" "sync" "time" @@ -280,6 +281,118 @@ func (c *Context) Text(code int, text string) { c.Writer.Write([]byte(text)) } +// FileText +func (c *Context) FileText(code int, filePath string) { + // 清理path + cleanPath := path.Clean(filePath) + if !filepath.IsAbs(cleanPath) { + c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed")) + return + } + if strings.Contains(cleanPath, "..") { + c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected")) + return + } + // 检查文件是否存在 + if _, err := os.Stat(cleanPath); os.IsNotExist(err) { + c.AddError(fmt.Errorf("file not found: %s", cleanPath)) + c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found")) + return + } + + // 打开文件 + file, err := os.Open(cleanPath) + if err != nil { + c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err)) + c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err)) + return + } + defer file.Close() + + // 获取文件信息以获取文件大小 + fileInfo, err := file.Stat() + if err != nil { + c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err)) + c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err)) + return + } + // 判断是否是dir + if fileInfo.IsDir() { + c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory")) + return + } + + c.SetHeader("Content-Type", "text/plain; charset=utf-8") + + c.SetBodyStream(file, int(fileInfo.Size())) +} + +/* +// not fot work +// FileTextSafeDir +func (c *Context) FileTextSafeDir(code int, filePath string, safeDir string) { + + // 清理path + cleanPath := path.Clean(filePath) + if !filepath.IsAbs(cleanPath) { + c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed")) + return + } + if strings.Contains(cleanPath, "..") { + c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected")) + return + } + + // 判断filePath是否包含在safeDir内, 防止路径穿越 + relPath, err := filepath.Rel(safeDir, cleanPath) + if err != nil { + c.AddError(fmt.Errorf("failed to get relative path: %w", err)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("failed to get relative path: %w", err)) + return + } + cleanPath = filepath.Join(safeDir, relPath) + + // 检查文件是否存在 + if _, err := os.Stat(cleanPath); os.IsNotExist(err) { + c.AddError(fmt.Errorf("file not found: %s", cleanPath)) + c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found")) + return + } + + // 打开文件 + file, err := os.Open(cleanPath) + if err != nil { + c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err)) + c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err)) + return + } + defer file.Close() + + // 获取文件信息以获取文件大小 + fileInfo, err := file.Stat() + if err != nil { + c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err)) + c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err)) + return + } + // 判断是否是dir + if fileInfo.IsDir() { + c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath)) + c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory")) + return + } + + c.SetHeader("Content-Type", "text/plain; charset=utf-8") + + c.SetBodyStream(file, int(fileInfo.Size())) +} +*/ + // JSON 向响应写入 JSON 数据 // 设置 Content-Type 为 application/json func (c *Context) JSON(code int, obj any) { From e4aaaa1583d74310f42cad4ddf1d271f19fd58b9 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:06:26 +0800 Subject: [PATCH 14/19] fix path to filepath --- context.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/context.go b/context.go index 0a57ee1..c79e4cc 100644 --- a/context.go +++ b/context.go @@ -18,7 +18,6 @@ import ( "net/netip" "net/url" "os" - "path" "path/filepath" "strings" "sync" @@ -284,17 +283,12 @@ func (c *Context) Text(code int, text string) { // FileText func (c *Context) FileText(code int, filePath string) { // 清理path - cleanPath := path.Clean(filePath) + cleanPath := filepath.Clean(filePath) if !filepath.IsAbs(cleanPath) { c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath)) c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed")) return } - if strings.Contains(cleanPath, "..") { - c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath)) - c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected")) - return - } // 检查文件是否存在 if _, err := os.Stat(cleanPath); os.IsNotExist(err) { c.AddError(fmt.Errorf("file not found: %s", cleanPath)) @@ -868,7 +862,7 @@ func (c *Context) GetRequestURIPath() string { // 将文件内容作为响应body func (c *Context) SetRespBodyFile(code int, filePath string) { // 清理path - cleanPath := path.Clean(filePath) + cleanPath := filepath.Clean(filePath) // 打开文件 file, err := os.Open(cleanPath) @@ -888,7 +882,7 @@ func (c *Context) SetRespBodyFile(code int, filePath string) { } // 尝试根据文件扩展名猜测 Content-Type - contentType := mime.TypeByExtension(path.Ext(cleanPath)) + contentType := mime.TypeByExtension(filepath.Ext(cleanPath)) if contentType == "" { // 如果无法猜测,则使用默认的二进制流类型 contentType = "application/octet-stream" From 904aea5df88280fbbf65456a9bf80ced68624618 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:56:37 +0800 Subject: [PATCH 15/19] refactor: Improve engine's tree processing and context handling. --- context.go | 18 +++++++++++++++++- engine.go | 9 +++------ tree.go | 26 ++++++++------------------ 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/context.go b/context.go index c79e4cc..644bbc6 100644 --- a/context.go +++ b/context.go @@ -65,6 +65,10 @@ type Context struct { // 请求体Body大小限制 MaxRequestBodySize int64 + + // skippedNodes 用于记录跳过的节点信息,以便回溯 + // 通常在处理嵌套路由时使用 + SkippedNodes []skippedNode } // --- Context 相关方法实现 --- @@ -80,7 +84,13 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { } c.Request = req - c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组 + //c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组 + //避免params长度为0 + if cap(c.Params) > 0 { + c.Params = c.Params[:0] + } else { + c.Params = make(Params, 0, 5) + } c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染 @@ -90,6 +100,12 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值 c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式 c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize + + if cap(c.SkippedNodes) > 0 { + c.SkippedNodes = c.SkippedNodes[:0] + } else { + c.SkippedNodes = make([]skippedNode, 0, 256) + } } // Next 在处理链中执行下一个处理函数 diff --git a/engine.go b/engine.go index 581258c..0cdd5cc 100644 --- a/engine.go +++ b/engine.go @@ -432,9 +432,8 @@ func MethodNotAllowed() HandlerFunc { // 如果是 OPTIONS 请求,尝试查找所有允许的方法 allowedMethods := []string{} for _, treeIter := range engine.methodTrees { - var tempSkippedNodes []skippedNode // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) + value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) if value.handlers != nil { allowedMethods = append(allowedMethods, treeIter.method) } @@ -451,9 +450,8 @@ func MethodNotAllowed() HandlerFunc { if treeIter.method == httpMethod { // 已经处理过当前方法,跳过 continue } - var tempSkippedNodes []skippedNode // 用于临时查找,不影响主 Context // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) // 只查找是否存在,不需要参数 + value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) // 只查找是否存在,不需要参数 if value.handlers != nil { // 使用定义的ErrorHandle处理 engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) @@ -661,9 +659,8 @@ func (engine *Engine) handleRequest(c *Context) { // 查找匹配的节点和处理函数 // 这里传递 &c.Params 而不是重新创建,以利用 Context 中预分配的容量 // skippedNodes 内部使用,因此无需从外部传入已分配的 slice - var skippedNodes []skippedNode // 用于回溯的跳过节点 // 直接在 rootNode 上调用 getValue 方法 - value := rootNode.getValue(requestPath, &c.Params, &skippedNodes, true) // unescape=true 对路径参数进行 URL 解码 + value := rootNode.getValue(requestPath, &c.Params, &c.SkippedNodes, true) // unescape=true 对路径参数进行 URL 解码 if value.handlers != nil { //c.handlers = engine.combineHandlers(engine.globalHandlers, value.handlers) // 组合全局中间件和路由处理函数 diff --git a/tree.go b/tree.go index 09711a1..31246a5 100644 --- a/tree.go +++ b/tree.go @@ -5,7 +5,6 @@ package touka import ( - "bytes" "net/url" "strings" "unicode" @@ -27,12 +26,6 @@ func BytesToString(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) } -var ( - strColon = []byte(":") // 定义字节切片常量, 表示冒号, 用于路径参数识别 - strStar = []byte("*") // 定义字节切片常量, 表示星号, 用于捕获所有路径识别 - strSlash = []byte("/") // 定义字节切片常量, 表示斜杠, 用于路径分隔符识别 -) - // Param 是单个 URL 参数, 由键和值组成. type Param struct { Key string // 参数的键名 @@ -106,17 +99,14 @@ func (n *node) addChild(child *node) { // countParams 计算路径中参数(冒号)和捕获所有(星号)的数量. func countParams(path string) uint16 { - var n uint16 - s := StringToBytes(path) // 将路径字符串转换为字节切片 - n += uint16(bytes.Count(s, strColon)) // 统计冒号的数量 - n += uint16(bytes.Count(s, strStar)) // 统计星号的数量 - return n + colons := strings.Count(path, ":") + stars := strings.Count(path, "*") + return uint16(colons + stars) } // countSections 计算路径中斜杠('/')的数量, 即路径段的数量. func countSections(path string) uint16 { - s := StringToBytes(path) // 将路径字符串转换为字节切片 - return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量 + return uint16(strings.Count(path, "/")) } // nodeType 定义了节点的类型. @@ -418,10 +408,10 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) fullPath: fullPath, // 设置完整路径 } - n.addChild(child) // 添加子节点 - n.indices = string('/') // 索引设置为 '/' - n = child // 移动到新创建的 catchAll 节点 - n.priority++ // 增加优先级 + n.addChild(child) // 添加子节点 + n.indices = "/" // 索引设置为 '/' + n = child // 移动到新创建的 catchAll 节点 + n.priority++ // 增加优先级 // 第二个节点: 包含变量的节点 child = &node{ From 9cfc82a3470df126ee2e824cc61af25726758912 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:57:48 +0800 Subject: [PATCH 16/19] chore: update go module dependencies. --- go.mod | 6 +++--- go.sum | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d8b0dfc..bd8f41d 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 - github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee + github.com/WJQSERVER/wanf v0.0.2 github.com/fenthope/reco v0.0.4 - github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 + github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index ca56e55..3e9287c 100644 --- a/go.sum +++ b/go.sum @@ -4,15 +4,21 @@ github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/ github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= +github.com/WJQSERVER/wanf v0.0.2 h1:E3dfHP6AACYamKn5BVUpi7pkO3L26WJycKF4AhGusXY= +github.com/WJQSERVER/wanf v0.0.2/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= From 60b2936efff513254e0786fd84d566b0a5fb4b28 Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:16:29 +0800 Subject: [PATCH 17/19] add TempSkippedNodesPool --- context.go | 2 +- engine.go | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/context.go b/context.go index 644bbc6..00b842f 100644 --- a/context.go +++ b/context.go @@ -89,7 +89,7 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) { if cap(c.Params) > 0 { c.Params = c.Params[:0] } else { - c.Params = make(Params, 0, 5) + c.Params = make(Params, 0, c.engine.maxParams) } c.handlers = nil c.index = -1 // 初始为 -1,`Next()` 将其设置为 0 diff --git a/engine.go b/engine.go index 0cdd5cc..b880b94 100644 --- a/engine.go +++ b/engine.go @@ -421,6 +421,22 @@ func getHandlerName(h HandlerFunc) string { } +// TempSkippedNodes池 +var TempSkippedNodesPool = sync.Pool{ + New: func() any { + return make([]skippedNode, 0, 256) + }, +} + +func GetTempSkippedNodes() *[]skippedNode { + return TempSkippedNodesPool.Get().(*[]skippedNode) +} + +func PutTempSkippedNodes(skippedNodes *[]skippedNode) { + *skippedNodes = (*skippedNodes)[:0] // 重置slice + TempSkippedNodesPool.Put(skippedNodes) +} + // 405中间件 func MethodNotAllowed() HandlerFunc { return func(c *Context) { @@ -433,7 +449,9 @@ func MethodNotAllowed() HandlerFunc { allowedMethods := []string{} for _, treeIter := range engine.methodTrees { // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) + tempSkippedNodes := GetTempSkippedNodes() + value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) + PutTempSkippedNodes(tempSkippedNodes) if value.handlers != nil { allowedMethods = append(allowedMethods, treeIter.method) } @@ -451,7 +469,9 @@ func MethodNotAllowed() HandlerFunc { continue } // 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型 - value := treeIter.root.getValue(requestPath, nil, &c.SkippedNodes, false) // 只查找是否存在,不需要参数 + tempSkippedNodes := GetTempSkippedNodes() + value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) // 只查找是否存在,不需要参数 + PutTempSkippedNodes(tempSkippedNodes) if value.handlers != nil { // 使用定义的ErrorHandle处理 engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed")) From b348d7d41f393a06cae4569edaa6b8a7a8a6064f Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:42:50 +0800 Subject: [PATCH 18/19] update TempSkippedNodesPool --- engine.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/engine.go b/engine.go index b880b94..0a95765 100644 --- a/engine.go +++ b/engine.go @@ -421,19 +421,38 @@ func getHandlerName(h HandlerFunc) string { } -// TempSkippedNodes池 +const MaxSkippedNodesCap = 256 + +// TempSkippedNodesPool 存储 *[]skippedNode 以复用内存 var TempSkippedNodesPool = sync.Pool{ New: func() any { - return make([]skippedNode, 0, 256) + // 返回一个指向容量为 256 的新切片的指针 + s := make([]skippedNode, 0, MaxSkippedNodesCap) + return &s }, } +// GetTempSkippedNodes 从 Pool 中获取一个 *[]skippedNode 指针 func GetTempSkippedNodes() *[]skippedNode { + // 直接返回 Pool 中存储的指针 return TempSkippedNodesPool.Get().(*[]skippedNode) } +// PutTempSkippedNodes 将用完的 *[]skippedNode 指针放回 Pool func PutTempSkippedNodes(skippedNodes *[]skippedNode) { - *skippedNodes = (*skippedNodes)[:0] // 重置slice + if skippedNodes == nil || *skippedNodes == nil { + return + } + + // 检查容量是否符合预期。如果容量不足,则丢弃,不放回 Pool。 + if cap(*skippedNodes) < MaxSkippedNodesCap { + return // 丢弃该对象,让 Pool 在下次 Get 时通过 New 重新分配 + } + + // 长度重置为 0,保留容量,实现复用 + *skippedNodes = (*skippedNodes)[:0] + + // 将指针存回 Pool TempSkippedNodesPool.Put(skippedNodes) } From a6e278d458a06fe92da353d291e13bf71855ff2b Mon Sep 17 00:00:00 2001 From: WJQSERVER <114663932+WJQSERVER@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:08:01 +0800 Subject: [PATCH 19/19] print errlog (jsonv2 marshal) --- context.go | 1 + go.mod | 4 ++-- go.sum | 18 ++++-------------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/context.go b/context.go index 00b842f..8c52b1f 100644 --- a/context.go +++ b/context.go @@ -410,6 +410,7 @@ func (c *Context) JSON(code int, obj any) { c.Writer.WriteHeader(code) if err := json.MarshalWrite(c.Writer, obj); err != nil { c.AddError(fmt.Errorf("failed to marshal JSON: %w", err)) + c.Errorf("failed to marshal JSON: %s", err) c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to marshal JSON: %w", err)) return } diff --git a/go.mod b/go.mod index bd8f41d..f9d10a9 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.25.1 require ( github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 github.com/WJQSERVER-STUDIO/httpc v0.8.2 - github.com/WJQSERVER/wanf v0.0.2 + github.com/WJQSERVER/wanf v0.0.3 github.com/fenthope/reco v0.0.4 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e ) require ( github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect ) diff --git a/go.sum b/go.sum index 3e9287c..b75fec4 100644 --- a/go.sum +++ b/go.sum @@ -2,23 +2,13 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok= github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk= github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y= -github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc= -github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= -github.com/WJQSERVER/wanf v0.0.2 h1:E3dfHP6AACYamKn5BVUpi7pkO3L26WJycKF4AhGusXY= -github.com/WJQSERVER/wanf v0.0.2/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= +github.com/WJQSERVER/wanf v0.0.3 h1:OqhG7ETiR5Knqr0lmbb+iUMw9O7re2vEogjVf06QevM= +github.com/WJQSERVER/wanf v0.0.3/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE= github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM= github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg= -github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU= -github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3 h1:02WINGfSX5w0Mn+F28UyRoSt9uvMhKguwWMlOAh6U/0= -github.com/go-json-experiment/json v0.0.0-20250910080747-cc2cfa0554c3/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=