Compare commits

...

77 commits
v0.3.1 ... main

Author SHA1 Message Date
WJQSERVER
a6e278d458 print errlog (jsonv2 marshal) 2026-01-26 08:08:01 +08:00
WJQSERVER
7b536ac137
Merge pull request #59 from infinite-iroha/fix-slice-panic
refactor: Improve engine's tree processing and context handling.
2025-12-15 00:05:02 +08:00
WJQSERVER
b348d7d41f update TempSkippedNodesPool 2025-12-14 23:42:50 +08:00
WJQSERVER
60b2936eff add TempSkippedNodesPool 2025-12-14 23:16:29 +08:00
WJQSERVER
9cfc82a347 chore: update go module dependencies. 2025-12-14 22:57:48 +08:00
WJQSERVER
904aea5df8 refactor: Improve engine's tree processing and context handling. 2025-12-14 22:56:37 +08:00
WJQSERVER
ee0ebc986c
Merge pull request #54 from infinite-iroha/dev
context added FileText method
2025-10-21 15:06:39 +08:00
wjqserver
e4aaaa1583 fix path to filepath 2025-10-21 15:06:26 +08:00
wjqserver
1361f6e237 update 2025-10-21 14:47:29 +08:00
WJQSERVER
a6458cca16
Merge pull request #53 from infinite-iroha/dev
update
2025-10-12 15:48:48 +08:00
wjqserver
76a89800a2 update 2025-10-12 15:47:02 +08:00
WJQSERVER
4955fb9d03
Merge pull request #52 from infinite-iroha/dev
fix StaticFS
2025-09-14 08:27:29 +08:00
wjqserver
5b98310de5 fix StaticFS 2025-09-14 08:24:01 +08:00
WJQSERVER
f1ac0dd6ff
Merge pull request #51 from infinite-iroha/dev
0.3.7
2025-09-10 02:40:51 +08:00
wjqserver
38ff5126e3 fix 2025-09-10 02:40:41 +08:00
WJQSERVER
b4e073ae2f
Update sse.go 2025-09-07 02:24:28 +08:00
WJQSERVER
af0a99acda add sse intn support 2025-09-06 17:55:45 +00:00
wjqserver
3ffde5742c add wanf 2025-08-20 16:50:26 +08:00
WJQSERVER
016df0efe4
Merge pull request #50 from infinite-iroha/dev
0.3.6
2025-08-01 10:27:01 +08:00
wjqserver
3590a77f90 fix reqip val 2025-08-01 10:23:49 +08:00
wjqserver
74f5770b42 update tree 2025-08-01 10:21:32 +08:00
WJQSERVER
0f4d90faeb
Merge pull request #49 from infinite-iroha/fix-router-panic
Fix router panic
2025-08-01 09:09:59 +08:00
wjqserver
783370fd79 update 2025-08-01 09:09:46 +08:00
wjqserver
295852e1a1 update reqip 2025-08-01 09:05:09 +08:00
wjqserver
99b48371b3 update test 2025-08-01 09:05:00 +08:00
google-labs-jules[bot]
e43b12e343 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.
2025-08-01 00:49:53 +00:00
WJQSERVER
1e7682ad84
Merge pull request #48 from infinite-iroha/dev
add RunShutdownWithContext
2025-07-31 20:18:47 +08:00
wjqserver
3cd8ef84a2 add RunShutdownWithContext 2025-07-31 20:18:24 +08:00
WJQSERVER
2c60e84067
Merge pull request #46 from infinite-iroha/dev
0.3.4
2025-07-28 21:02:20 +08:00
wjqserver
895cd6222b update deps 2025-07-28 21:01:18 +08:00
wjqserver
c9b8e966c4 remove too much log print 2025-07-27 16:34:46 +08:00
WJQSERVER
dee05b048e
Merge pull request #45 from infinite-iroha/dev
update about
2025-07-27 16:07:33 +08:00
wjqserver
3e76566917 update about 2025-07-26 18:51:30 +08:00
WJQSERVER
ccf25dee46
Merge pull request #44 from infinite-iroha/dev
fix cfdt
2025-07-25 00:35:43 +08:00
WJQSERVER
1f0724af94
fix cfdt 2025-07-25 00:35:12 +08:00
WJQSERVER
e77fcb10d3
Merge pull request #43 from infinite-iroha/dev
add testflow
2025-07-24 16:41:14 +08:00
WJQSERVER
5e74fa011e
Merge pull request #42 from infinite-iroha/license-compliance
add MPL 2.0 license headers to all go files
2025-07-24 16:40:46 +08:00
wjqserver
de4bbe7959 add testflow 2025-07-24 16:39:48 +08:00
WJQSERVER
507e2f3813
Merge pull request #41 from infinite-iroha/dev
Dev
2025-07-24 16:09:09 +08:00
google-labs-jules[bot]
504089b748 feat: add MPL 2.0 license headers to all go files 2025-07-24 08:07:38 +00:00
wjqserver
5bb58ee6d3 add tree test 2025-07-24 15:39:17 +08:00
wjqserver
6b3f3335ab replace to iox 2025-07-18 17:40:01 +08:00
WJQSERVER
e10c20c5d6
Merge pull request #40 from infinite-iroha/dev
add Text
2025-07-18 15:30:20 +08:00
WJQSERVER
583609945e
Merge pull request #38 from infinite-iroha/dependabot/go_modules/github.com/WJQSERVER-STUDIO/httpc-0.8.1
Bump github.com/WJQSERVER-STUDIO/httpc from 0.8.0 to 0.8.1
2025-07-17 19:14:53 +08:00
WJQSERVER
18e7b203ec
Merge pull request #39 from infinite-iroha/dev
update deps
2025-07-17 19:14:16 +08:00
dependabot[bot]
e75619fc7a
Bump github.com/WJQSERVER-STUDIO/httpc from 0.8.0 to 0.8.1
Bumps [github.com/WJQSERVER-STUDIO/httpc](https://github.com/WJQSERVER-STUDIO/httpc) from 0.8.0 to 0.8.1.
- [Commits](https://github.com/WJQSERVER-STUDIO/httpc/compare/v0.8.0...v0.8.1)

---
updated-dependencies:
- dependency-name: github.com/WJQSERVER-STUDIO/httpc
  dependency-version: 0.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-17 01:13:22 +00:00
WJQSERVER
a6171241ce
Merge pull request #37 from infinite-iroha/dev
refactor(internal break) Enhance static file serving for modularity a…
2025-07-09 03:20:31 +08:00
WJQSERVER
5d2ab04b6b
Merge pull request #36 from infinite-iroha/dev
add maxBytesReader & ctxMerge
2025-07-09 02:59:11 +08:00
WJQSERVER
780e640253
Merge pull request #35 from infinite-iroha/dev
update deps & use copyb high perfromance pool & switch to stream json…
2025-07-06 18:10:21 +08:00
WJQSERVER
7084f6d004
Merge pull request #33 from infinite-iroha/dev
Update README.md for add DeepWiki badge
2025-06-28 00:57:07 +08:00
WJQSERVER
87fc425dc4
Merge pull request #32 from infinite-iroha/dev
0.2.8
2025-06-25 17:50:00 +08:00
WJQSERVER
6c96e189d3
Merge pull request #30 from infinite-iroha/dev
add slash settings && StaticFS
2025-06-22 18:11:14 +08:00
WJQSERVER
7733dc80cd
Merge pull request #25 from infinite-iroha/dev
update middlewareX
2025-06-18 08:55:27 +08:00
WJQSERVER
b941523186
Merge pull request #24 from infinite-iroha/dev
use new resolveRoutePath replace path.Join && add UseIf
2025-06-17 14:43:10 +08:00
WJQSERVER
229d15d405
Merge pull request #23 from infinite-iroha/dev
[context] add SetHeaders
2025-06-14 17:58:51 +08:00
WJQSERVER
556e23c1a4
Merge pull request #22 from infinite-iroha/dev
fix default errorhandle
2025-06-13 17:54:00 +08:00
WJQSERVER
362ae16640
Merge pull request #21 from infinite-iroha/dev
update serve && add custom srv configure
2025-06-12 09:49:11 +08:00
WJQSERVER
fa8f044b81
Merge pull request #19 from infinite-iroha/dev
update methods
2025-06-11 11:43:34 +08:00
WJQSERVER
37917363e2
Merge pull request #18 from infinite-iroha/dev
update deps
2025-06-10 21:54:22 +08:00
WJQSERVER
76bf441ce8
Merge pull request #17 from infinite-iroha/dev
0.2.0
2025-06-10 21:42:57 +08:00
WJQSERVER
6fd5e84f6d
Merge pull request #16 from infinite-iroha/dev
remove ws, move to github.com/fenthope/ws
2025-06-09 23:34:34 +08:00
WJQSERVER
d5eb1406ae
Merge pull request #15 from infinite-iroha/dev
update Readme
2025-06-08 04:34:35 +08:00
WJQSERVER
0649a05f72
Create dependabot.yml 2025-06-07 23:08:27 +08:00
WJQSERVER
8eeba0df72
Merge pull request #14 from infinite-iroha/dev
0.1.0
2025-06-06 23:28:27 +08:00
WJQSERVER
da5d165de3
update readme typo 2025-06-06 10:15:19 +08:00
WJQSERVER
c46cf7bd03
Merge pull request #13 from infinite-iroha/dev
fix hijack
2025-06-06 01:32:05 +08:00
WJQSERVER
9c2c078f66
Merge pull request #12 from infinite-iroha/dev
fix NoRoutes
2025-06-05 20:48:02 +08:00
WJQSERVER
41af56ebcf
Merge pull request #11 from infinite-iroha/dev
[engine] add NoRoutes
2025-06-05 20:40:06 +08:00
WJQSERVER
677a7f037f
Merge pull request #9 from infinite-iroha/dev
add testutil
2025-06-05 19:15:04 +08:00
WJQSERVER
f1ff1f935f
Merge pull request #8 from infinite-iroha/dev
[engine] add StaticFile && [context] Add Cookie Method, port from gin
2025-06-05 19:03:50 +08:00
WJQSERVER
b1988305bb
Merge pull request #7 from infinite-iroha/dev
ctx add logreco method
2025-06-04 21:31:48 +08:00
WJQSERVER
46fd5c7852
Merge pull request #6 from infinite-iroha/dev
add engine logger
2025-06-04 20:52:57 +08:00
WJQSERVER
52cc857d00
Merge pull request #5 from infinite-iroha/dev
add GetProtocol
2025-06-02 04:49:02 +08:00
WJQSERVER
7f32c15b4b
Merge pull request #4 from infinite-iroha/dev
0.0.4
2025-05-30 21:43:57 +08:00
WJQSERVER
4f83f0ee6a
Merge pull request #3 from infinite-iroha/dev
add Raw & GetReqBodyFull
2025-05-30 16:57:36 +08:00
WJQSERVER
f2c855966b
Merge pull request #2 from infinite-iroha/dev
0.0.2
2025-05-29 21:48:32 +08:00
WJQSERVER
3acf69e387
Merge pull request #1 from infinite-iroha/dev
init(v0.0.1)
2025-05-28 18:30:31 +08:00
25 changed files with 2557 additions and 419 deletions

11
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,11 @@
# 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"

24
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,24 @@
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 ./...

186
README.md
View file

@ -1,34 +1,10 @@
# Touka(灯花)框架
Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化**
Touka(灯花) 是一个基于 Go 语言构建的多层次、高性能 Web 框架。其设计目标是为开发者提供**更直接的控制、有效的扩展能力,以及针对特定场景的行为优化**
## Touka 的设计特点
**想深入了解 Touka 吗?请阅读我们的 -> [深度指南 (about-touka.md)](about-touka.md)**
Touka 在一些特定方面进行了细致的设计与实现,旨在提供便利的工具与更清晰的控制:
* **统一且可定制的错误处理**
Touka 提供了灵活的错误处理机制,允许开发者通过 `Engine.SetErrorHandler` 设置统一的错误响应逻辑。此机制不仅适用于框架内部产生的错误,更特别之处在于它能够**捕获由 `http.FileServer` 等标准库处理器返回的 404 Not Found、403 Forbidden 等错误状态码**。
* **设计考量:** 默认情况下,`http.FileServer` 在文件未找到或权限不足时会直接返回标准错误响应。Touka 的设计能够拦截这些由 `http.FileServer` 发出的错误信号,并将其转发给框架统一的 `ErrorHandler`。这使得开发者可以为文件服务中的异常情况提供**与应用其他部分风格一致的自定义错误响应**,从而提升整体的用户体验和错误管理效率。
* **客户端 IP 来源的透明解析**
Touka 提供了可配置的客户端 IP 获取机制。开发者可以通过 `Engine.SetRemoteIPHeaders` 指定框架优先从哪些 HTTP 头部(如 `X-Forwarded-For``X-Real-IP`)解析客户端真实 IP并通过 `Engine.SetForwardByClientIP` 控制此机制的启用。
* **实现细节:** `Context.RequestIP()` 方法会根据这些配置,从 `http.Request.Header` 中解析并返回第一个有效的 IP 地址。如果未配置或头部中未找到有效 IP则回退到 `http.Request.RemoteAddr`,并对 IP 格式进行验证。这有助于在存在多层代理的环境中获取准确的源 IP。
* **内置日志与出站 HTTP 客户端的 Context 绑定**
Touka 的核心 `Context` 对象直接包含了对 `reco.Logger`(一个异步、结构化日志库)和 `httpc.Client`(一个功能增强的 HTTP 客户端)的引用。开发者可以直接通过 `c.GetLogger()``c.Client()` 在请求处理函数中访问这些工具。
* **设计考量:** 这种集成方式旨在提供这些核心工具在**特定请求生命周期内的统一访问点**。所有日志记录和出站 HTTP 请求操作都与当前请求上下文绑定,并能利用框架层面的全局配置,有助于简化复杂请求处理场景下的代码组织。
* **强健的 Panic 恢复与连接状态感知**
Touka 提供的 `Recovery` 中间件能够捕获处理链中的 `panic`。它会记录详细的堆栈信息和请求快照。此外,它能**识别由客户端意外断开连接**引起的网络错误(如 `broken pipe``connection reset by peer`),在这些情况下,框架会避免尝试向已失效的连接写入响应。
* **设计考量:** 这有助于防止因底层网络问题或客户端行为导致的二次 `panic`,避免在关闭的连接上进行无效写入,从而提升服务的稳定性。
* **HTTP 协议版本与服务器行为的细致控制**
Touka 允许开发者通过 `Engine.SetProtocols` 方法,精确定义服务器支持的 HTTP 协议版本HTTP/1.1、HTTP/2、H2C。框架也提供了对重定向行为、未匹配路由处理和文件服务行为的配置选项。
* **设计考量:** 这种协议和行为的细致化控制,为开发者提供了在特定部署环境(如 gRPC-Web 对 HTTP/2 的要求)中对服务器通信栈进行调整的能力。
* **Context 对象的高效复用**
Touka 对其核心 `Context` 对象进行了池化管理。每个请求处理结束后,`Context` 对象会被重置并返回到对象池中,以便后续请求复用。
* **设计考量:** 这种机制旨在减少每次请求的内存分配和垃圾回收GC压力尤其在高并发场景下有助于提供更平滑和可预测的性能表现。
这份深度指南包含了对框架设计哲学、核心功能(路由、上下文、中间件、错误处理等)的全面剖析,并提供了大量可直接使用的代码示例,帮助您快速上手并精通 Touka。
### 快速上手
@ -48,166 +24,68 @@ import (
)
func main() {
r := touka.New()
r := touka.Default() // 使用带 Recovery 中间件的默认引擎
// 配置日志记录器 (可选,不设置则使用默认配置)
// 配置日志记录器 (可选)
logConfig := reco.Config{
Level: reco.LevelDebug,
Mode: reco.ModeText, // 或 reco.ModeJSON
Mode: reco.ModeText,
Output: os.Stdout,
Async: true,
BufferSize: 4096,
}
r.SetLogger(logConfig)
r.SetLoggerCfg(logConfig)
// 配置统一错误处理器
// Touka 允许您为 404, 500 等错误定义统一的响应。
// 特别地,它能捕获 http.FileServer 产生的 404/403 错误并统一处理。
r.SetErrorHandler(func(c *touka.Context, code int) {
// 这里可以根据 code 返回 JSON, HTML, 或其他自定义错误页面
r.SetErrorHandler(func(c *touka.Context, code int, err error) {
c.JSON(code, touka.H{"error_code": code, "message": http.StatusText(code)})
c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s", code, c.Request.URL.Path) // 记录错误
c.GetLogger().Errorf("发生HTTP错误: %d, 路径: %s, 错误: %v", code, c.Request.URL.Path, err)
})
// 注册基本路由
r.GET("/hello", func(c *touka.Context) {
// 设置响应头部
c.SetHeader("X-Framework", "Touka") // 设置一个头部
c.AddHeader("X-Custom-Info", "Hello") // 添加一个头部 (如果已有则追加)
c.AddHeader("X-Custom-Info", "World") // 再次添加Content-Type: X-Custom-Info: Hello, World
// 获取请求头部
acceptEncoding := c.GetReqHeader("Accept-Encoding")
userAgent := c.UserAgent() // 便捷获取 User-Agent
c.String(http.StatusOK, "Hello from Touka! Your Accept-Encoding: %s, User-Agent: %s", acceptEncoding, userAgent)
c.GetLogger().Infof("请求 /hello 来自 IP: %s", c.ClientIP())
// 注册路由
r.GET("/hello/:name", func(c *touka.Context) {
name := c.Param("name")
query := c.DefaultQuery("mood", "happy")
c.String(http.StatusOK, "Hello, %s! You seem %s.", name, query)
})
r.GET("/json", func(c *touka.Context) {
// 删除响应头部
c.DelHeader("X-Powered-By") // 假设有这个头部,可以删除它
c.JSON(http.StatusOK, touka.H{"message": "Welcome to Touka", "timestamp": time.Now()})
})
// 注册包含路径参数的路由
r.GET("/user/:id", func(c *touka.Context) {
userID := c.Param("id") // 获取路径参数
c.String(http.StatusOK, "User ID: %s", userID)
})
// 注册使用查询参数的路由
r.GET("/search", func(c *touka.Context) {
query := c.DefaultQuery("q", "default_query") // 获取查询参数,提供默认值
paramB := c.Query("paramB") // 获取另一个查询参数
c.String(http.StatusOK, "Search query: %s, Param B: %s", query, paramB)
})
// 注册处理 POST 表单的路由
r.POST("/submit-form", func(c *touka.Context) {
name := c.PostForm("name") // 获取表单字段值
email := c.DefaultPostForm("email", "no_email@example.com") // 获取表单字段,提供默认值
c.String(http.StatusOK, "Form submitted: Name=%s, Email=%s", name, email)
})
// 演示 Set 和 Get 方法在中间件中传递数据
// 在中间件中 Set 数据
r.Use(func(c *touka.Context) {
c.Set("requestID", "req-12345") // 设置一个数据
c.Next()
})
// 在路由处理函数中 Get 数据
r.GET("/context-data", func(c *touka.Context) {
requestID, exists := c.Get("requestID") // 获取数据
if !exists {
requestID = "N/A"
}
c.String(http.StatusOK, "Request ID from Context: %s", requestID)
})
// 服务静态文件
// 使用 r.Static 方法,其错误(如 404将由上面设置的 ErrorHandler 统一处理
// 假设您的静态文件在项目根目录的 'static' 文件夹
r.Static("/static", "./static")
// 演示出站 HTTP 请求 (使用 Context 中绑定的 httpc.Client)
r.GET("/fetch-example", func(c *touka.Context) {
resp, err := c.Client().Get("https://example.com", httpc.WithTimeout(5*time.Second))
if err != nil {
c.Errorf("出站请求失败: %v", err) // 记录错误
c.String(http.StatusInternalServerError, "Failed to fetch external resource")
return
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
c.String(http.StatusOK, "Fetched from example.com (first 100 bytes): %s...", bodyBytes[:min(len(bodyBytes), 100)])
})
// 演示 HTTP 协议控制
// 默认已启用 HTTP/1.1。如果需要 HTTP/2通常需在 TLS 模式下启用。
// r.SetProtocols(&touka.ProtocolsConfig{
// Http1: true,
// Http2: true, // 启用 HTTP/2 (需要 HTTPS)
// Http2_Cleartext: false,
// })
// 启动服务器 (支持优雅关闭)
log.Println("Touka Server starting on :8080...")
err := r.RunShutdown(":8080", 10*time.Second) // 优雅关闭超时10秒
if err != nil {
if err := r.RunShutdown(":8080", 10*time.Second); err != nil {
log.Fatalf("Touka server failed to start: %v", err)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
```
## 中间件支持
### 内置
Recovery `r.Use(touka.Recovery())`
- **Recovery:** `r.Use(touka.Recovery())` (已包含在 `touka.Default()` 中)
### fenthope
### 第三方 (fenthope)
[访问日志-record](https://github.com/fenthope/record)
[Gzip](https://github.com/fenthope/gzip)
[压缩-Compress(Deflate,Gzip,Zstd)](https://github.com/fenthope/compress)
[请求速率限制-ikumi](https://github.com/fenthope/ikumi)
[sessions](https://github.com/fenthope/sessions)
[jwt](https://github.com/fenthope/jwt)
[带宽限制](https://github.com/fenthope/toukautil/blob/main/bandwithlimiter.go)
- [访问日志-record](https://github.com/fenthope/record)
- [Gzip](https://github.com/fenthope/gzip)
- [压缩-Compress(Deflate,Gzip,Zstd)](https://github.com/fenthope/compress)
- [请求速率限制-ikumi](https://github.com/fenthope/ikumi)
- [sessions](https://github.com/fenthope/sessions)
- [jwt](https://github.com/fenthope/jwt)
- [带宽限制](https://github.com/fenthope/toukautil/blob/main/bandwithlimiter.go)
## 文档与贡献
* **API 文档:** 访问 [pkg.go.dev/github.com/infinite-iroha/touka](https://pkg.go.dev/github.com/infinite-iroha/touka) 查看完整的 API 参考
* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南
* [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/infinite-iroha/touka) 可供参考, AI生成存在幻觉, 不完全可靠, 请注意辨别
* **深度指南:** **[about-touka.md](about-touka.md)**
* **API 文档:** 访问 [pkg.go.dev/github.com/infinite-iroha/touka](https://pkg.go.dev/github.com/infinite-iroha/touka) 查看完整的 API 参考。
* **贡献:** 我们欢迎任何形式的贡献,无论是错误报告、功能建议还是代码提交。请遵循项目的贡献指南。
## 相关项目
[gin](https://github.com/gin-gonic/gin) 参考并引用了相关部分代码
[reco](https://github.com/fenthope/reco) 灯花框架的默认日志库
[httpc](https://github.com/WJQSERVER-STUDIO/httpc) 原[touka-httpc](https://github.com/satomitouka/touka-httpc), 一个现代化且易用的HTTP Client, 作为Touka框架Context携带的HTTPC
- [gin](https://github.com/gin-gonic/gin): Touka 在路由和 API 设计上参考了 Gin。
- [reco](https://github.com/fenthope/reco): Touka 框架的默认日志库。
- [httpc](https://github.com/WJQSERVER-STUDIO/httpc): 一个现代化且易用的 HTTP Client作为 Touka 框架 Context 携带的 HTTPC。
## 许可证
本项目使用MPL许可证
本项目基于 [Mozilla Public License, v. 2.0](https://mozilla.org/MPL/2.0/) 许可。
tree部分来自[gin](https://github.com/gin-gonic/gin)与[httprouter](https://github.com/julienschmidt/httprouter)
[WJQSERVER/httproute](https://github.com/WJQSERVER/httprouter)是本项目的前身(一个[httprouter](https://github.com/julienschmidt/httprouter)的fork版本)
`tree.go` 部分代码源自 [gin](https://github.com/gin-gonic/gin) 与 [httprouter](https://github.com/julienschmidt/httprouter),其原始许可为 BSD-style。

577
about-touka.md Normal file
View file

@ -0,0 +1,577 @@
# 关于 Touka (灯花) 框架:一份深度指南
Touka (灯花) 是一个基于 Go 语言构建的、功能丰富且高性能的 Web 框架。它的核心设计目标是为开发者提供一个既强大又灵活的工具集,允许对框架行为进行深度定制,同时通过精心设计的组件和机制,优化在真实业务场景中的开发体验和运行性能。
本文档旨在提供一份全面而深入的指南,帮助您理解 Touka 的核心概念、设计哲学以及如何利用其特性来构建健壮、高效的 Web 应用。
---
## 核心设计哲学
Touka 的设计哲学根植于以下几个核心原则:
* **控制力与可扩展性:** 框架在提供强大默认功能的同时,也赋予开发者充分的控制权。我们相信开发者最了解自己的业务需求。因此,无论是路由行为、错误处理逻辑,还是服务器协议,都可以根据具体需求进行精细调整和扩展。
* **明确性与可预测性:** API 设计力求直观和一致,使得框架的行为易于理解和预测,减少开发过程中的意外。我们避免使用过多的“魔法”,倾向于让代码的意图清晰可见。
* **性能意识:** 在核心组件的设计中性能是一个至关重要的考量因素。通过采用如对象池、优化的路由算法等技术Touka 致力于在高并发场景下保持低延迟和高吞吐。
* **开发者体验:** 框架内置了丰富的辅助工具和便捷的 API例如与请求上下文绑定的日志记录器和 HTTP 客户端,旨在简化常见任务,提升开发效率。
---
## 核心功能深度剖析
### 1. 引擎 (Engine):框架的中央枢纽
`Engine` 是 Touka 框架的实例,也是所有功能的入口和协调者。它实现了 `http.Handler` 接口,可以无缝集成到 Go 的标准 HTTP 生态中。
#### 1.1. 初始化引擎
```go
// 创建一个“干净”的引擎,不包含任何默认中间件
r := touka.New()
// 创建一个带有默认中间件的引擎,目前仅包含 Recovery()
// 推荐在生产环境中使用,以防止 panic 导致整个服务崩溃
r := touka.Default()
```
#### 1.2. 引擎配置
`Engine` 提供了丰富的配置选项,允许您定制其核心行为。
```go
func main() {
r := touka.New()
// === 路由行为配置 ===
// 自动重定向尾部带斜杠的路径,默认为 true
// e.g., /foo/ 会被重定向到 /foo
r.SetRedirectTrailingSlash(true)
// 自动修复路径的大小写,默认为 true
// e.g., /FOO 会被重定向到 /foo (如果 /foo 存在)
r.SetRedirectFixedPath(true)
// 当路由存在但方法不匹配时,自动处理 405 Method Not Allowed默认为 true
r.SetHandleMethodNotAllowed(true)
// === IP 地址解析配置 ===
// 是否信任 X-Forwarded-For, X-Real-IP 等头部来获取客户端 IP默认为 true
// 在反向代理环境下非常有用
r.SetForwardByClientIP(true)
// 自定义用于解析 IP 的头部列表,按顺序查找
r.SetRemoteIPHeaders([]string{"X-Forwarded-For", "X-App-Client-IP", "X-Real-IP"})
// === 请求体大小限制 ===
// 设置全局默认的请求体最大字节数,-1 表示不限制
// 这有助于防止 DoS 攻击
r.SetGlobalMaxRequestBodySize(10 * 1024 * 1024) // 10 MB
// ... 其他配置
r.Run(":8080")
}
```
#### 1.3. 服务器生命周期管理
Touka 提供了对底层 `*http.Server` 的完全控制,并内置了优雅关闭的逻辑。
```go
func main() {
r := touka.New()
// 通过 ServerConfigurator 对 http.Server 进行自定义配置
r.SetServerConfigurator(func(server *http.Server) {
// 设置自定义的读写超时时间
server.ReadTimeout = 15 * time.Second
server.WriteTimeout = 15 * time.Second
fmt.Println("自定义的 HTTP 服务器配置已应用")
})
// 启动服务器,并支持优雅关闭
// RunShutdown 会阻塞,直到收到 SIGINT 或 SIGTERM 信号
// 第二个参数是优雅关闭的超时时间
fmt.Println("服务器启动于 :8080")
if err := r.RunShutdown(":8080", 10*time.Second); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}
```
---
### 2. 路由系统 (Routing):强大、灵活、高效
Touka 的路由系统基于一个经过优化的**基数树 (Radix Tree)**,它支持静态路径、路径参数和通配符,并能实现极高的查找性能。
#### 2.1. 基本路由
```go
// 精确匹配的静态路由
r.GET("/ping", func(c *touka.Context) {
c.String(http.StatusOK, "pong")
})
// 注册多个 HTTP 方法
r.HandleFunc([]string{"GET", "POST"}, "/data", func(c *touka.Context) {
c.String(http.StatusOK, "Data received via %s", c.Request.Method)
})
// 注册所有常见 HTTP 方法
r.ANY("/any", func(c *touka.Context) {
c.String(http.StatusOK, "Handled with ANY for method %s", c.Request.Method)
})
```
#### 2.2. 参数化路由
使用冒号 `:` 来定义路径参数。
```go
r.GET("/users/:id", func(c *touka.Context) {
// 通过 c.Param() 获取路径参数
userID := c.Param("id")
c.String(http.StatusOK, "获取用户 ID: %s", userID)
})
r.GET("/articles/:category/:article_id", func(c *touka.Context) {
category := c.Param("category")
articleID := c.Param("article_id")
c.JSON(http.StatusOK, touka.H{
"category": category,
"id": articleID,
})
})
```
#### 2.3. 通配符路由 (Catch-all)
使用星号 `*` 来定义通配符路由,它会捕获该点之后的所有路径段。**通配符路由必须位于路径的末尾**。
```go
// 匹配如 /static/js/main.js, /static/css/style.css 等
r.GET("/static/*filepath", func(c *touka.Context) {
// 捕获的路径可以通过参数名 "filepath" 获取
filePath := c.Param("filepath")
c.String(http.StatusOK, "请求的文件路径是: %s", filePath)
})
```
#### 2.4. 路由组 (RouterGroup)
路由组是组织和管理路由的强大工具,特别适用于构建结构化的 API。
```go
func main() {
r := touka.New()
// 所有 /api/v1 下的路由都需要经过 AuthMiddleware
v1 := r.Group("/api/v1")
v1.Use(AuthMiddleware()) // 应用组级别的中间件
{
// 匹配 /api/v1/products
v1.GET("/products", getProducts)
// 匹配 /api/v1/products/:id
v1.GET("/products/:id", getProductByID)
// 可以在组内再嵌套组
ordersGroup := v1.Group("/orders")
ordersGroup.Use(OrderPermissionsMiddleware()) // 更具体的中间件
{
// 匹配 /api/v1/orders
ordersGroup.GET("", getOrders)
// 匹配 /api/v1/orders/:id
ordersGroup.GET("/:id", getOrderByID)
}
}
r.Run(":8080")
}
func AuthMiddleware() touka.HandlerFunc {
return func(c *touka.Context) {
// 模拟认证逻辑
fmt.Println("V1 Auth Middleware: Checking credentials...")
c.Next()
}
}
// ... 其他处理器
```
---
### 3. 上下文 (Context):请求的灵魂
`touka.Context` 是框架中最为核心的结构,它作为每个 HTTP 请求的上下文,在中间件和最终处理器之间流转。它提供了海量的便捷 API 来简化开发。
#### 3.1. 请求数据解析
##### 获取查询参数
```go
// 请求 URL: /search?q=touka&lang=go&page=1
r.GET("/search", func(c *touka.Context) {
// c.Query() 获取指定参数,不存在则返回空字符串
query := c.Query("q") // "touka"
// c.DefaultQuery() 获取参数,不存在则返回指定的默认值
lang := c.DefaultQuery("lang", "en") // "go"
category := c.DefaultQuery("cat", "all") // "all"
c.JSON(http.StatusOK, touka.H{
"query": query,
"language": lang,
"category": category,
})
})
```
##### 获取 POST 表单数据
```go
// 使用 curl 测试:
// curl -X POST http://localhost:8080/register -d "username=test&email=test@example.com"
r.POST("/register", func(c *touka.Context) {
username := c.PostForm("username")
email := c.DefaultPostForm("email", "anonymous@example.com")
// 也可以获取所有表单数据
// form, _ := c.Request.MultipartForm()
c.String(http.StatusOK, "注册成功: 用户名=%s, 邮箱=%s", username, email)
})
```
##### JSON 数据绑定
Touka 可以轻松地将请求体中的 JSON 数据绑定到 Go 结构体。
```go
type UserProfile struct {
Name string `json:"name" binding:"required"`
Age int `json:"age" binding:"gte=18"`
Tags []string `json:"tags"`
Address string `json:"address,omitempty"`
}
// 使用 curl 测试:
// curl -X POST http://localhost:8080/profile -H "Content-Type: application/json" -d '''
// {
// "name": "Alice",
// "age": 25,
// "tags": ["go", "web"]
// }
// '''
r.POST("/profile", func(c *touka.Context) {
var profile UserProfile
// c.ShouldBindJSON() 会解析 JSON 并填充到结构体中
if err := c.ShouldBindJSON(&profile); err != nil {
// 如果 JSON 格式错误或不满足绑定标签,会返回错误
c.JSON(http.StatusBadRequest, touka.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, touka.H{
"status": "success",
"profile": profile,
})
})
```
#### 3.2. 响应构建
##### 发送 JSON, String, Text
```go
r.GET("/responses", func(c *touka.Context) {
// c.JSON(http.StatusOK, touka.H{"framework": "Touka"})
// c.String(http.StatusOK, "Hello, %s", "World")
c.Text(http.StatusOK, "This is plain text.")
})
```
##### 渲染 HTML 模板
首先,需要为引擎配置一个模板渲染器。
```go
// main.go
import "html/template"
func main() {
r := touka.New()
// 加载模板文件
r.HTMLRender = template.Must(template.ParseGlob("templates/*.html"))
r.GET("/index", func(c *touka.Context) {
// 渲染 index.html 模板,并传入数据
c.HTML(http.StatusOK, "index.html", touka.H{
"title": "Touka 模板渲染",
"user": "Guest",
})
})
r.Run(":8080")
}
// templates/index.html
// <h1>{{ .title }}</h1>
// <p>Welcome, {{ .user }}!</p>
```
##### 文件和流式响应
```go
// 直接发送一个文件
r.GET("/download/report", func(c *touka.Context) {
// 浏览器会提示下载
c.File("./reports/latest.pdf")
})
// 将文件内容作为响应体
r.GET("/show/config", func(c *touka.Context) {
// 浏览器会直接显示文件内容(如果支持)
c.SetRespBodyFile(http.StatusOK, "./config.yaml")
})
// 流式响应,适用于大文件或实时数据
r.GET("/stream", func(c *touka.Context) {
// 假设 getRealTimeDataStream() 返回一个 io.Reader
// dataStream := getRealTimeDataStream()
// c.WriteStream(dataStream)
})
```
#### 3.3. Cookie 操作
Touka 提供了简单的 API 来管理 Cookie。
```go
r.GET("/login", func(c *touka.Context) {
// 设置一个有效期为 1 小时的 cookie
c.SetCookie("session_id", "user-12345", 3600, "/", "localhost", false, true)
c.String(http.StatusOK, "登录成功!")
})
r.GET("/me", func(c *touka.Context) {
sessionID, err := c.GetCookie("session_id")
if err != nil {
c.String(http.StatusUnauthorized, "请先登录")
return
}
c.String(http.StatusOK, "您的会话 ID 是: %s", sessionID)
})
r.GET("/logout", func(c *touka.Context) {
// 通过将 MaxAge 设置为 -1 来删除 cookie
c.DeleteCookie("session_id")
c.String(http.StatusOK, "已退出登录")
})
```
#### 3.4. 中间件数据传递
使用 `c.Set()``c.Get()` 可以在处理链中传递数据。
```go
// 中间件:生成并设置请求 ID
func RequestIDMiddleware() touka.HandlerFunc {
return func(c *touka.Context) {
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
c.Set("RequestID", requestID)
c.Next()
}
}
func main() {
r := touka.New()
r.Use(RequestIDMiddleware())
r.GET("/status", func(c *touka.Context) {
// 在处理器中获取由中间件设置的数据
// c.MustGet() 在 key 不存在时会 panic适用于确定存在的场景
requestID := c.MustGet("RequestID").(string)
// 或者使用安全的 Get
// requestID, exists := c.GetString("RequestID")
c.JSON(http.StatusOK, touka.H{"status": "ok", "request_id": requestID})
})
r.Run(":8080")
}
```
#### 3.5. 集成的工具
##### 日志记录
Touka 集成了 `reco` 日志库,可以直接在 `Context` 中使用。
```go
r.GET("/log-test", func(c *touka.Context) {
userID := "user-abc"
c.Infof("用户 %s 访问了 /log-test", userID)
err := errors.New("一个模拟的错误")
if err != nil {
c.Errorf("处理请求时发生错误: %v, 用户: %s", err, userID)
}
c.String(http.StatusOK, "日志已记录")
})
```
##### HTTP 客户端
Touka 集成了 `httpc` 客户端,方便发起出站请求。
```go
r.GET("/fetch-data", func(c *touka.Context) {
// 使用 Context 携带的 httpc 客户端
resp, err := c.GetHTTPC().Get("https://api.github.com/users/WJQSERVER-STUDIO", httpc.WithTimeout(5*time.Second))
if err != nil {
c.ErrorUseHandle(http.StatusInternalServerError, err)
return
}
defer resp.Body.Close()
// 将外部响应直接流式传输给客户端
c.SetHeader("Content-Type", resp.Header.Get("Content-Type"))
c.WriteStream(resp.Body)
})
```
---
### 4. 错误处理:统一且强大
Touka 的一个标志性特性是其统一的错误处理机制。
#### 4.1. 自定义全局错误处理器
```go
func main() {
r := touka.New()
// 设置一个自定义的全局错误处理器
r.SetErrorHandler(func(c *touka.Context, code int, err error) {
// 检查是否是客户端断开连接
if errors.Is(err, context.Canceled) {
return // 不做任何事
}
// 记录详细错误
c.GetLogger().Errorf("捕获到错误: code=%d, err=%v, path=%s", code, err, c.Request.URL.Path)
// 根据错误码返回不同的响应
switch code {
case http.StatusNotFound:
c.JSON(code, touka.H{"error": "您要找的页面去火星了"})
case http.StatusMethodNotAllowed:
c.JSON(code, touka.H{"error": "不支持的请求方法"})
default:
c.JSON(code, touka.H{"error": "服务器内部错误"})
}
})
// 这个路由不存在,会触发 404
// r.GET("/this-route-does-not-exist", ...)
// 静态文件服务,如果文件不存在,也会被上面的 ErrorHandler 捕获
r.StaticDir("/files", "./non-existent-dir")
r.Run(":8080")
}
```
#### 4.2. `errorCapturingResponseWriter` 的魔力
Touka 如何捕获 `http.FileServer` 的错误?答案是 `errorCapturingResponseWriter`
当您使用 `r.StaticDir` 或类似方法时Touka 不会直接将 `http.FileServer` 作为处理器。相反,它会用一个自定义的 `ResponseWriter` 实现(即 `ecw`)来包装原始的 `ResponseWriter`,然后才调用 `http.FileServer.ServeHTTP`
这个包装器会:
1. **拦截 `WriteHeader(statusCode)` 调用:**`http.FileServer` 内部决定要写入一个例如 `404 Not Found` 的状态码时,`ecw` 会捕获这个 `statusCode`
2. **判断是否为错误:** 如果 `statusCode >= 400``ecw` 会将此视为一个错误信号。
3. **阻止原始响应:** `ecw` 会阻止 `http.FileServer` 继续向客户端写入任何内容(包括响应体)。
4. **调用全局 `ErrorHandler`** 最后,`ecw` 会调用您通过 `r.SetErrorHandler` 设置的全局错误处理器,并将捕获到的 `statusCode` 和一个通用错误传递给它。
这个机制确保了无论是动态 API 的错误还是静态文件服务的错误,都能被统一、优雅地处理,从而提供一致的用户体验。
---
### 5. 静态文件服务与嵌入式资源
#### 5.1. 服务本地文件
```go
// 将 URL /assets/ 映射到本地的 ./static 目录
r.StaticDir("/assets", "./static")
// 将 URL /favicon.ico 映射到本地的 ./static/img/favicon.ico 文件
r.StaticFile("/favicon.ico", "./static/img/favicon.ico")
```
#### 5.2. 服务嵌入式资源 (Go 1.16+)
使用 `go:embed` 可以将静态资源直接编译到二进制文件中,实现真正的单体应用部署。
```go
// main.go
package main
import (
"embed"
"io/fs"
"net/http"
"github.com/infinite-iroha/touka"
)
//go:embed frontend/dist
var embeddedFS embed.FS
func main() {
r := touka.New()
// 创建一个子文件系统,根目录为 embeddedFS 中的 frontend/dist
subFS, err := fs.Sub(embeddedFS, "frontend/dist")
if err != nil {
panic(err)
}
// 使用 StaticFS 来服务这个嵌入式文件系统
// 所有对 / 的访问都会映射到嵌入的 frontend/dist 目录
r.StaticFS("/", http.FS(subFS))
r.Run(":8080")
}
```
---
### 6. 与标准库的无缝集成
Touka 提供了适配器,可以轻松使用任何实现了标准 `http.Handler``http.HandlerFunc` 接口的组件。
```go
import "net/http/pprof"
// 适配一个标准的 http.HandlerFunc
r.GET("/legacy-handler", touka.AdapterStdFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("这是一个标准的 http.HandlerFunc"))
}))
// 适配一个标准的 http.Handler例如 pprof
debugGroup := r.Group("/debug/pprof")
{
debugGroup.GET("/", touka.AdapterStdFunc(pprof.Index))
debugGroup.GET("/cmdline", touka.AdapterStdFunc(pprof.Cmdline))
debugGroup.GET("/profile", touka.AdapterStdFunc(pprof.Profile))
// ... 其他 pprof 路由
}
```
这使得您可以方便地利用 Go 生态中大量现有的、遵循标准接口的第三方中间件和工具。

View file

@ -1,4 +1,7 @@
// 文件: touka/adapter.go
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (
@ -10,20 +14,20 @@ import (
"io"
"math"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/WJQSERVER/wanf"
"github.com/fenthope/reco"
"github.com/go-json-experiment/json"
"github.com/WJQSERVER-STUDIO/go-utils/copyb"
"github.com/WJQSERVER-STUDIO/go-utils/iox"
"github.com/WJQSERVER-STUDIO/httpc"
)
@ -39,7 +43,7 @@ type Context struct {
index int8 // 当前执行到处理链的哪个位置
mu sync.RWMutex
Keys map[string]interface{} // 用于在中间件之间传递数据
Keys map[string]any // 用于在中间件之间传递数据
Errors []error // 用于收集处理过程中的错误
@ -61,6 +65,10 @@ type Context struct {
// 请求体Body大小限制
MaxRequestBodySize int64
// skippedNodes 用于记录跳过的节点信息,以便回溯
// 通常在处理嵌套路由时使用
SkippedNodes []skippedNode
}
// --- Context 相关方法实现 ---
@ -74,20 +82,30 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) {
} else {
c.Writer = newResponseWriter(w)
}
//c.Writer = newResponseWriter(w)
c.Request = req
c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组
//c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组
//避免params长度为0
if cap(c.Params) > 0 {
c.Params = c.Params[:0]
} else {
c.Params = make(Params, 0, c.engine.maxParams)
}
c.handlers = nil
c.index = -1 // 初始为 -1`Next()` 将其设置为 0
c.Keys = make(map[string]interface{}) // 每次请求重新创建 map避免数据污染
c.Keys = make(map[string]any) // 每次请求重新创建 map避免数据污染
c.Errors = c.Errors[:0] // 清空 Errors 切片
c.queryCache = nil // 清空查询参数缓存
c.formCache = nil // 清空表单数据缓存
c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值
c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式
c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize
// c.HTTPClient 和 c.engine 保持不变,它们引用 Engine 实例的成员
if cap(c.SkippedNodes) > 0 {
c.SkippedNodes = c.SkippedNodes[:0]
} else {
c.SkippedNodes = make([]skippedNode, 0, 256)
}
}
// Next 在处理链中执行下一个处理函数
@ -119,10 +137,10 @@ func (c *Context) AbortWithStatus(code int) {
// Set 将一个键值对存储到 Context 中
// 这是一个线程安全的操作,用于在中间件之间传递数据
func (c *Context) Set(key string, value interface{}) {
func (c *Context) Set(key string, value any) {
c.mu.Lock() // 加写锁
if c.Keys == nil {
c.Keys = make(map[string]interface{})
c.Keys = make(map[string]any)
}
c.Keys[key] = value
c.mu.Unlock() // 解写锁
@ -130,7 +148,7 @@ func (c *Context) Set(key string, value interface{}) {
// Get 从 Context 中获取一个值
// 这是一个线程安全的操作
func (c *Context) Get(key string) (value interface{}, exists bool) {
func (c *Context) Get(key string) (value any, exists bool) {
c.mu.RLock() // 加读锁
value, exists = c.Keys[key]
c.mu.RUnlock() // 解读锁
@ -205,7 +223,7 @@ func (c *Context) GetDuration(key string) (value time.Duration, exists bool) {
// MustGet 从 Context 中获取一个值,如果不存在则 panic
// 适用于确定值一定存在的场景
func (c *Context) MustGet(key string) interface{} {
func (c *Context) MustGet(key string) any {
if value, exists := c.Get(key); exists {
return value
}
@ -266,7 +284,7 @@ func (c *Context) Raw(code int, contentType string, data []byte) {
}
// String 向响应写入格式化的字符串
func (c *Context) String(code int, format string, values ...interface{}) {
func (c *Context) String(code int, format string, values ...any) {
c.Writer.WriteHeader(code)
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}
@ -278,13 +296,121 @@ func (c *Context) Text(code int, text string) {
c.Writer.Write([]byte(text))
}
// FileText
func (c *Context) FileText(code int, filePath string) {
// 清理path
cleanPath := filepath.Clean(filePath)
if !filepath.IsAbs(cleanPath) {
c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed"))
return
}
// 检查文件是否存在
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
c.AddError(fmt.Errorf("file not found: %s", cleanPath))
c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found"))
return
}
// 打开文件
file, err := os.Open(cleanPath)
if err != nil {
c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err))
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err))
return
}
defer file.Close()
// 获取文件信息以获取文件大小
fileInfo, err := file.Stat()
if err != nil {
c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err))
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err))
return
}
// 判断是否是dir
if fileInfo.IsDir() {
c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory"))
return
}
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
c.SetBodyStream(file, int(fileInfo.Size()))
}
/*
// not fot work
// FileTextSafeDir
func (c *Context) FileTextSafeDir(code int, filePath string, safeDir string) {
// 清理path
cleanPath := path.Clean(filePath)
if !filepath.IsAbs(cleanPath) {
c.AddError(fmt.Errorf("relative path not allowed: %s", cleanPath))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("relative path not allowed"))
return
}
if strings.Contains(cleanPath, "..") {
c.AddError(fmt.Errorf("path traversal attempt detected: %s", cleanPath))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path traversal attempt detected"))
return
}
// 判断filePath是否包含在safeDir内, 防止路径穿越
relPath, err := filepath.Rel(safeDir, cleanPath)
if err != nil {
c.AddError(fmt.Errorf("failed to get relative path: %w", err))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("failed to get relative path: %w", err))
return
}
cleanPath = filepath.Join(safeDir, relPath)
// 检查文件是否存在
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
c.AddError(fmt.Errorf("file not found: %s", cleanPath))
c.ErrorUseHandle(http.StatusNotFound, fmt.Errorf("file not found"))
return
}
// 打开文件
file, err := os.Open(cleanPath)
if err != nil {
c.AddError(fmt.Errorf("failed to open file %s: %w", cleanPath, err))
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err))
return
}
defer file.Close()
// 获取文件信息以获取文件大小
fileInfo, err := file.Stat()
if err != nil {
c.AddError(fmt.Errorf("failed to get file info for %s: %w", cleanPath, err))
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to get file info: %w", err))
return
}
// 判断是否是dir
if fileInfo.IsDir() {
c.AddError(fmt.Errorf("path is a directory, not a file: %s", cleanPath))
c.ErrorUseHandle(http.StatusBadRequest, fmt.Errorf("path is a directory"))
return
}
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
c.SetBodyStream(file, int(fileInfo.Size()))
}
*/
// JSON 向响应写入 JSON 数据
// 设置 Content-Type 为 application/json
func (c *Context) JSON(code int, obj interface{}) {
func (c *Context) JSON(code int, obj any) {
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Writer.WriteHeader(code)
if err := json.MarshalWrite(c.Writer, obj); err != nil {
c.AddError(fmt.Errorf("failed to marshal JSON: %w", err))
c.Errorf("failed to marshal JSON: %s", err)
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to marshal JSON: %w", err))
return
}
@ -292,7 +418,7 @@ func (c *Context) JSON(code int, obj interface{}) {
// GOB 向响应写入GOB数据
// 设置 Content-Type 为 application/octet-stream
func (c *Context) GOB(code int, obj interface{}) {
func (c *Context) GOB(code int, obj any) {
c.Writer.Header().Set("Content-Type", "application/octet-stream") // 设置合适的 Content-Type
c.Writer.WriteHeader(code)
// GOB 编码
@ -304,11 +430,25 @@ func (c *Context) GOB(code int, obj interface{}) {
}
}
// WANF向响应写入WANF数据
// 设置 application/vnd.wjqserver.wanf; charset=utf-8
func (c *Context) WANF(code int, obj any) {
c.Writer.Header().Set("Content-Type", "application/vnd.wjqserver.wanf; charset=utf-8")
c.Writer.WriteHeader(code)
// WANF 编码
encoder := wanf.NewStreamEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
c.AddError(fmt.Errorf("failed to encode WANF: %w", err))
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to encode WANF: %w", err))
return
}
}
// HTML 渲染 HTML 模板
// 如果 Engine 配置了 HTMLRender则使用它进行渲染
// 否则,会进行简单的字符串输出
// 预留接口,可以扩展为支持多种模板引擎
func (c *Context) HTML(code int, name string, obj interface{}) {
func (c *Context) HTML(code int, name string, obj any) {
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteHeader(code)
@ -339,7 +479,7 @@ func (c *Context) Redirect(code int, location string) {
}
// ShouldBindJSON 尝试将请求体绑定到 JSON 对象
func (c *Context) ShouldBindJSON(obj interface{}) error {
func (c *Context) ShouldBindJSON(obj any) error {
if c.Request.Body == nil {
return errors.New("request body is empty")
}
@ -350,10 +490,28 @@ func (c *Context) ShouldBindJSON(obj interface{}) error {
return nil
}
// ShouldBindWANF
func (c *Context) ShouldBindWANF(obj any) error {
if c.Request.Body == nil {
return errors.New("request body is empty")
}
decoder, err := wanf.NewStreamDecoder(c.Request.Body)
if err != nil {
return fmt.Errorf("failed to create WANF decoder: %w", err)
}
if err := decoder.Decode(obj); err != nil {
return fmt.Errorf("WANF binding error: %w", err)
}
return nil
}
// Deprecated: This function is a reserved placeholder for future API extensions
// and is not yet implemented. It will either be properly defined or removed in v2.0.0. Do not use.
// ShouldBind 尝试将请求体绑定到各种类型JSON, Form, XML 等)
// 这是一个复杂的通用绑定接口,通常根据 Content-Type 或其他头部来判断绑定方式
// 预留接口,可根据项目需求进行扩展
func (c *Context) ShouldBind(obj interface{}) error {
func (c *Context) ShouldBind(obj any) error {
// TODO: 完整的通用绑定逻辑
// 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等
// 例如:
@ -406,7 +564,7 @@ func (c *Context) Err() error {
// Value returns the value associated with this context for key, or nil if no
// value is associated with key.
// 可以用于从 Context 中获取与特定键关联的值,包括 Go 原生 Context 的值和 Touka Context 的 Keys
func (c *Context) Value(key interface{}) interface{} {
func (c *Context) Value(key any) any {
if keyAsString, ok := key.(string); ok {
if val, exists := c.Get(keyAsString); exists {
return val
@ -431,7 +589,7 @@ func (c *Context) WriteStream(reader io.Reader) (written int64, err error) {
c.Writer.WriteHeader(http.StatusOK) // 默认 200 OK
}
written, err = copyb.Copy(c.Writer, reader) // 从 reader 读取并写入 ResponseWriter
written, err = iox.Copy(c.Writer, reader) // 从 reader 读取并写入 ResponseWriter
if err != nil {
c.AddError(fmt.Errorf("failed to write stream: %w", err))
}
@ -471,7 +629,7 @@ func (c *Context) GetReqBodyFull() ([]byte, error) {
}()
}
data, err := copyb.ReadAll(limitBytesReader)
data, err := iox.ReadAll(limitBytesReader)
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)
@ -505,7 +663,7 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) {
}()
}
data, err := copyb.ReadAll(limitBytesReader)
data, err := iox.ReadAll(limitBytesReader)
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)
@ -519,39 +677,59 @@ func (c *Context) GetReqBodyBuffer() (*bytes.Buffer, error) {
func (c *Context) RequestIP() string {
if c.engine.ForwardByClientIP {
for _, headerName := range c.engine.RemoteIPHeaders {
if ipValue := c.Request.Header.Get(headerName); ipValue != "" {
// X-Forwarded-For 可能包含多个 IP约定第一个最左边是客户端 IP
// 其他头部(如 X-Real-IP通常只有一个
ips := strings.Split(ipValue, ",")
for _, singleIP := range ips {
trimmedIP := strings.TrimSpace(singleIP)
// 使用 netip.ParseAddr 进行 IP 地址的解析和格式验证
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 地址格式,立即返回
// 成功解析到合法的 IP, 立即返回
return addr.String()
}
// 如果当前 singleIP 无效,继续检查列表中的下一个
}
}
}
}
// 如果没有启用 ForwardByClientIP 或头部中没有找到有效 IP回退到 Request.RemoteAddr
// RemoteAddr 通常是 "host:port" 格式,但也可能直接就是 IP 地址
remoteAddrStr := c.Request.RemoteAddr
ip, _, err := net.SplitHostPort(remoteAddrStr) // 尝试分离 host 和 port
if err != nil {
// 如果分离失败,意味着 remoteAddrStr 可能直接就是 IP 地址(或畸形)
ip = remoteAddrStr // 此时将整个 remoteAddrStr 作为候选 IP
// 回退到 Request.RemoteAddr 的处理
// 优先使用 netip.ParseAddrPort, 它比 net.SplitHostPort 更高效且分配更少
addrp, err := netip.ParseAddrPort(c.Request.RemoteAddr)
if err == nil {
// 成功从 "ip:port" 格式中解析出 IP
return addrp.Addr().String()
}
// 对从 RemoteAddr 中提取/使用的 IP 进行最终的合法性验证
addr, parseErr := netip.ParseAddr(ip)
if parseErr == nil {
return addr.String() // 成功解析并返回合法 IP
// 如果上面的解析失败 (例如 RemoteAddr 只有 IP, 没有端口),
// 则尝试将整个字符串作为 IP 地址进行解析
addr, err := netip.ParseAddr(c.Request.RemoteAddr)
if err == nil {
return addr.String()
}
// 所有方法都失败, 返回空字符串
return ""
}
@ -678,7 +856,7 @@ func (c *Context) SetBodyStream(reader io.Reader, contentSize int) {
// 将 reader 的内容直接复制到 ResponseWriter
// ResponseWriter 实现了 io.Writer 接口
_, err := copyb.Copy(c.Writer, reader)
_, err := iox.Copy(c.Writer, reader)
if err != nil {
c.AddError(fmt.Errorf("failed to write stream: %w", err))
// 注意:这里可能无法设置错误状态码,因为头部可能已经发送
@ -701,7 +879,7 @@ func (c *Context) GetRequestURIPath() string {
// 将文件内容作为响应body
func (c *Context) SetRespBodyFile(code int, filePath string) {
// 清理path
cleanPath := path.Clean(filePath)
cleanPath := filepath.Clean(filePath)
// 打开文件
file, err := os.Open(cleanPath)
@ -721,7 +899,7 @@ func (c *Context) SetRespBodyFile(code int, filePath string) {
}
// 尝试根据文件扩展名猜测 Content-Type
contentType := mime.TypeByExtension(path.Ext(cleanPath))
contentType := mime.TypeByExtension(filepath.Ext(cleanPath))
if contentType == "" {
// 如果无法猜测,则使用默认的二进制流类型
contentType = "application/octet-stream"
@ -737,7 +915,7 @@ func (c *Context) SetRespBodyFile(code int, filePath string) {
c.Writer.WriteHeader(code)
// 将文件内容写入响应体
_, err = copyb.Copy(c.Writer, file)
_, err = iox.Copy(c.Writer, file)
if err != nil {
c.AddError(fmt.Errorf("failed to write file %s to response: %w", cleanPath, err))
// 注意:这里可能无法设置错误状态码,因为头部可能已经发送

4
ecw.go
View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,9 +1,12 @@
// 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"
"log"
"reflect"
"runtime"
"strings"
@ -144,7 +147,7 @@ func defaultErrorWarp(handler ErrorHandler) ErrorHandler {
return
default:
if c.Writer.Written() {
log.Printf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err)
c.Debugf("errpage: response already started for status %d, skipping error page rendering, err: %v", code, err)
return
}
}
@ -159,7 +162,7 @@ func defaultErrorWarp(handler ErrorHandler) ErrorHandler {
// 避免在客户端已关闭连接后写入响应导致的问题
// 检查 context.Context 是否已取消
if errors.Is(c.Request.Context().Err(), context.Canceled) {
log.Printf("errpage: client disconnected, skipping error page rendering for status %d, err: %v", code, err)
c.Debugf("errpage: client disconnected, skipping error page rendering for status %d, err: %v", code, err)
return
}
@ -418,6 +421,41 @@ func getHandlerName(h HandlerFunc) string {
}
const MaxSkippedNodesCap = 256
// TempSkippedNodesPool 存储 *[]skippedNode 以复用内存
var TempSkippedNodesPool = sync.Pool{
New: func() any {
// 返回一个指向容量为 256 的新切片的指针
s := make([]skippedNode, 0, MaxSkippedNodesCap)
return &s
},
}
// GetTempSkippedNodes 从 Pool 中获取一个 *[]skippedNode 指针
func GetTempSkippedNodes() *[]skippedNode {
// 直接返回 Pool 中存储的指针
return TempSkippedNodesPool.Get().(*[]skippedNode)
}
// PutTempSkippedNodes 将用完的 *[]skippedNode 指针放回 Pool
func PutTempSkippedNodes(skippedNodes *[]skippedNode) {
if skippedNodes == nil || *skippedNodes == nil {
return
}
// 检查容量是否符合预期。如果容量不足,则丢弃,不放回 Pool。
if cap(*skippedNodes) < MaxSkippedNodesCap {
return // 丢弃该对象,让 Pool 在下次 Get 时通过 New 重新分配
}
// 长度重置为 0保留容量实现复用
*skippedNodes = (*skippedNodes)[:0]
// 将指针存回 Pool
TempSkippedNodesPool.Put(skippedNodes)
}
// 405中间件
func MethodNotAllowed() HandlerFunc {
return func(c *Context) {
@ -429,9 +467,10 @@ func MethodNotAllowed() HandlerFunc {
// 如果是 OPTIONS 请求,尝试查找所有允许的方法
allowedMethods := []string{}
for _, treeIter := range engine.methodTrees {
var tempSkippedNodes []skippedNode
// 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型
value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false)
tempSkippedNodes := GetTempSkippedNodes()
value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false)
PutTempSkippedNodes(tempSkippedNodes)
if value.handlers != nil {
allowedMethods = append(allowedMethods, treeIter.method)
}
@ -448,9 +487,10 @@ func MethodNotAllowed() HandlerFunc {
if treeIter.method == httpMethod { // 已经处理过当前方法,跳过
continue
}
var tempSkippedNodes []skippedNode // 用于临时查找,不影响主 Context
// 注意这里 treeIter.root 才是正确的,因为 treeIter 是 methodTree 类型
value := treeIter.root.getValue(requestPath, nil, &tempSkippedNodes, false) // 只查找是否存在,不需要参数
tempSkippedNodes := GetTempSkippedNodes()
value := treeIter.root.getValue(requestPath, nil, tempSkippedNodes, false) // 只查找是否存在,不需要参数
PutTempSkippedNodes(tempSkippedNodes)
if value.handlers != nil {
// 使用定义的ErrorHandle处理
engine.errorHandle.handler(c, http.StatusMethodNotAllowed, errors.New("method not allowed"))
@ -658,9 +698,8 @@ func (engine *Engine) handleRequest(c *Context) {
// 查找匹配的节点和处理函数
// 这里传递 &c.Params 而不是重新创建,以利用 Context 中预分配的容量
// skippedNodes 内部使用,因此无需从外部传入已分配的 slice
var skippedNodes []skippedNode // 用于回溯的跳过节点
// 直接在 rootNode 上调用 getValue 方法
value := rootNode.getValue(requestPath, &c.Params, &skippedNodes, true) // unescape=true 对路径参数进行 URL 解码
value := rootNode.getValue(requestPath, &c.Params, &c.SkippedNodes, true) // unescape=true 对路径参数进行 URL 解码
if value.handlers != nil {
//c.handlers = engine.combineHandlers(engine.globalHandlers, value.handlers) // 组合全局中间件和路由处理函数

View file

@ -1,8 +1,11 @@
// 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"
"fmt"
"net/http"
"path"
"strings"
@ -15,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)
@ -33,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)
@ -55,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()
@ -236,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))
}
@ -250,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))
}

13
go.mod
View file

@ -1,15 +1,16 @@
module github.com/infinite-iroha/touka
go 1.24.4
go 1.25.1
require (
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6
github.com/WJQSERVER-STUDIO/httpc v0.8.1
github.com/fenthope/reco v0.0.3
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d
github.com/WJQSERVER-STUDIO/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
)
require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/net v0.49.0 // indirect
)

22
go.sum
View file

@ -1,12 +1,14 @@
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6 h1:/50VJYXd6jcu+p5BnEBDyiX0nAyGxas1W3DCnrYMxMY=
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc=
github.com/WJQSERVER-STUDIO/httpc v0.8.1 h1:/eG8aYKL3WfQILIRbG+cbzQjPkNHEPTqfGUdQS5rtI4=
github.com/WJQSERVER-STUDIO/httpc v0.8.1/go.mod h1:mxXBf2hqbQGNHkVy/7wfU7Xi2s09MyZpbY2hyR+4uD4=
github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d h1:+d6m5Bjvv0/RJct1VcOw2P5bvBOGjENmxORJYnSYDow=
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/WJQSERVER-STUDIO/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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=

View file

@ -0,0 +1,29 @@
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.

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,3 +1,7 @@
// 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

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,4 +1,7 @@
// 文件: touka/recovery.go
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (
@ -124,6 +128,79 @@ func handleGracefulShutdown(servers []*http.Server, timeout time.Duration, logge
return nil
}
func handleGracefulShutdownWithContext(servers []*http.Server, ctx context.Context, timeout time.Duration, logger *reco.Logger) error {
// 创建一个 channel 来接收操作系统信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 监听中断和终止信号
// 启动服务器
serverStopped := make(chan error, 1)
for _, srv := range servers {
go func(s *http.Server) {
serverStopped <- s.ListenAndServe()
}(srv)
}
select {
case <-ctx.Done():
// Context 被取消 (例如,通过外部取消函数)
log.Println("Context cancelled, shutting down Touka server(s)...")
case err := <-serverStopped:
// 服务器自身停止 (例如,端口被占用,或 ListenAndServe 返回错误)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("Touka HTTP server failed: %w", err)
}
log.Println("Touka HTTP server stopped gracefully.")
return nil // 服务器已自行优雅关闭,无需进一步处理
case <-quit:
// 接收到操作系统信号
log.Println("Shutting down Touka server(s) due to OS signal...")
}
// 关闭日志记录器
if logger != nil {
go func() {
log.Println("Closing Touka logger...")
CloseLogger(logger)
}()
}
// 创建一个带超时的上下文,用于 Shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var wg sync.WaitGroup
errChan := make(chan error, len(servers)) // 用于收集关闭错误的 channel
// 并发地关闭所有服务器
for _, srv := range servers {
wg.Add(1)
go func(s *http.Server) {
defer wg.Done()
if err := s.Shutdown(shutdownCtx); err != nil {
// 将错误发送到 channel
errChan <- fmt.Errorf("server on %s shutdown failed: %w", s.Addr, err)
}
}(srv)
}
wg.Wait()
close(errChan) // 关闭 channel,以便可以安全地遍历它
// 收集所有关闭过程中发生的错误
var shutdownErrors []error
for err := range errChan {
shutdownErrors = append(shutdownErrors, err)
log.Printf("Shutdown error: %v", err)
}
if len(shutdownErrors) > 0 {
return errors.Join(shutdownErrors...) // Go 1.20+ 的 errors.Join,用于合并多个错误
}
log.Println("Touka server(s) exited gracefully.")
return nil
}
// --- 公共 Run 方法 ---
// Run 启动一个不支持优雅关闭的 HTTP 服务器
@ -159,6 +236,22 @@ func (engine *Engine) RunShutdown(addr string, timeouts ...time.Duration) error
return handleGracefulShutdown([]*http.Server{srv}, getShutdownTimeout(timeouts), engine.LogReco)
}
// RunShutdown 启动一个支持优雅关闭的 HTTP 服务器
func (engine *Engine) RunShutdownWithContext(addr string, ctx context.Context, timeouts ...time.Duration) error {
srv := &http.Server{
Addr: addr,
Handler: engine,
}
// 应用框架的默认配置和用户提供的自定义配置
//engine.applyDefaultServerConfig(srv)
if engine.ServerConfigurator != nil {
engine.ServerConfigurator(srv)
}
return handleGracefulShutdownWithContext([]*http.Server{srv}, ctx, getShutdownTimeout(timeouts), engine.LogReco)
}
// RunTLS 启动一个支持优雅关闭的 HTTPS 服务器
func (engine *Engine) RunTLS(addr string, tlsConfig *tls.Config, timeouts ...time.Duration) error {
if tlsConfig == nil {

184
sse.go Normal file
View file

@ -0,0 +1,184 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright 2025 WJQSERVER. All rights reserved.
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
package touka
import (
"bytes"
"io"
"net/http"
"strings"
)
// Event 代表一个服务器发送事件(SSE).
type Event struct {
// Event 是事件的名称.
Event string
// Data 是事件的内容, 可以是多行文本.
Data string
// Id 是事件的唯一标识符.
Id string
// Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接.
Retry string
}
// Render 将事件格式化并写入给定的 writer.
// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题.
// 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配.
func (e *Event) Render(w io.Writer) error {
var buf bytes.Buffer
if len(e.Id) > 0 {
buf.WriteString("id: ")
buf.WriteString(e.Id)
buf.WriteString("\n")
}
if len(e.Event) > 0 {
buf.WriteString("event: ")
buf.WriteString(e.Event)
buf.WriteString("\n")
}
if len(e.Data) > 0 {
lines := strings.Split(e.Data, "\n")
for _, line := range lines {
buf.WriteString("data: ")
buf.WriteString(line)
buf.WriteString("\n")
}
}
if len(e.Retry) > 0 {
buf.WriteString("retry: ")
buf.WriteString(e.Retry)
buf.WriteString("\n")
}
// 每个事件都以一个额外的换行符结尾.
buf.WriteString("\n")
// 直接将 buffer 的内容写入 writer, 避免生成中间字符串.
_, err := buf.WriteTo(w)
return err
}
// EventStream 启动一个 SSE 事件流.
// 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期.
//
// 详细用法:
//
// r.GET("/sse/callback", func(c *touka.Context) {
// // streamer 回调函数会在一个循环中被调用.
// c.EventStream(func(w io.Writer) bool {
// event := touka.Event{
// Event: "time-tick",
// Data: time.Now().Format(time.RFC1123),
// }
//
// if err := event.Render(w); err != nil {
// // 发生写入错误, 停止发送.
// return false // 返回 false 结束事件流.
// }
//
// time.Sleep(2 * time.Second)
// return true // 返回 true 继续事件流.
// })
// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行.
// fmt.Println("Client disconnected from /sse/callback")
// })
func (c *Context) EventStream(streamer func(w io.Writer) bool) {
// 为现代网络协议优化头部.
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
c.Writer.Header().Del("Connection")
c.Writer.Header().Del("Transfer-Encoding")
c.Writer.WriteHeader(http.StatusOK)
c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在.
for {
select {
case <-c.Request.Context().Done():
return
default:
if !streamer(c.Writer) {
return
}
c.Writer.Flush()
}
}
}
// EventStreamChan 返回用于 SSE 事件流的 channel.
// 这是为高级并发场景设计的、更灵活的API.
//
// 重要:
// - 调用者必须 close(eventChan) 来结束事件流.
// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开.
// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done().
//
// 详细用法:
//
// r.GET("/sse/channel", func(c *touka.Context) {
// eventChan, errChan := c.EventStreamChan()
//
// // 必须在独立的goroutine中处理错误和连接断开.
// go func() {
// if err := <-errChan; err != nil {
// c.Errorf("SSE channel error: %v", err)
// }
// }()
//
// // 在另一个goroutine中异步发送事件.
// go func() {
// // 重要: 必须在逻辑结束时关闭channel, 以通知框架.
// defer close(eventChan)
//
// for i := 1; i <= 5; i++ {
// select {
// case <-c.Request.Context().Done():
// return // 客户端已断开, 退出 goroutine.
// default:
// eventChan <- touka.Event{
// Id: fmt.Sprintf("%d", i),
// Data: "hello from channel",
// }
// time.Sleep(2 * time.Second)
// }
// }
// }()
// })
func (c *Context) EventStreamChan() (chan<- Event, <-chan error) {
eventChan := make(chan Event)
errChan := make(chan error, 1)
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
c.Writer.Header().Del("Connection")
c.Writer.Header().Del("Transfer-Encoding")
c.Writer.WriteHeader(http.StatusOK)
c.Writer.Flush()
go func() {
defer close(errChan)
for {
select {
case event, ok := <-eventChan:
if !ok {
return
}
if err := event.Render(c.Writer); err != nil {
errChan <- err
return
}
c.Writer.Flush()
case <-c.Request.Context().Done():
errChan <- c.Request.Context().Err()
return
}
}
}()
return eventChan, errChan
}

View file

@ -1,3 +1,7 @@
// 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 (

View file

@ -1,3 +1,7 @@
// 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 (

352
tree.go
View file

@ -2,51 +2,43 @@
// Use of this source code is governed by a BSD-style license that can be found
// at https://github.com/julienschmidt/httprouter/blob/master/LICENSE
// This tree.go is gin's fork, you can see https://github.com/gin-gonic/gin/blob/master/tree.go
package touka // 定义包名为 touka该包可能是一个路由或Web框架的核心组件
package touka
import (
"bytes" // 导入 bytes 包,用于操作字节切片
"net/url" // 导入 net/url 包,用于 URL 解析和转义
"strings" // 导入 strings 包,用于字符串操作
"unicode" // 导入 unicode 包,用于处理 Unicode 字符
"unicode/utf8" // 导入 unicode/utf8 包,用于 UTF-8 编码和解码
"unsafe" // 导入 unsafe 包,用于不安全的类型转换,以避免内存分配
"net/url"
"strings"
"unicode"
"unicode/utf8"
"unsafe"
)
// StringToBytes 将字符串转换为字节切片,不进行内存分配。
// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。
// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。
// StringToBytes 将字符串转换为字节切片, 不进行内存分配.
// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077.
// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全.
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
// BytesToString 将字节切片转换为字符串,不进行内存分配。
// 更多详情,请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077。
// 注意:此函数使用 unsafe 包,应谨慎使用,因为它可能导致内存不安全。
// BytesToString 将字节切片转换为字符串, 不进行内存分配.
// 更多详情, 请参见 https://github.com/golang/go/issues/53003#issuecomment-1140276077.
// 注意: 此函数使用 unsafe 包, 应谨慎使用, 因为它可能导致内存不安全.
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
var (
strColon = []byte(":") // 定义字节切片常量,表示冒号,用于路径参数识别
strStar = []byte("*") // 定义字节切片常量,表示星号,用于捕获所有路径识别
strSlash = []byte("/") // 定义字节切片常量,表示斜杠,用于路径分隔符识别
)
// Param 是单个 URL 参数,由键和值组成。
// Param 是单个 URL 参数, 由键和值组成.
type Param struct {
Key string // 参数的键名
Value string // 参数的值
}
// Params 是 Param 类型的切片,由路由器返回。
// 该切片是有序的,第一个 URL 参数也是切片中的第一个值。
// 因此,按索引读取值是安全的。
// Params 是 Param 类型的切片, 由路由器返回.
// 该切片是有序的, 第一个 URL 参数也是切片中的第一个值.
// 因此, 按索引读取值是安全的.
type Params []Param
// Get 返回键名与给定名称匹配的第一个 Param 的值,并返回一个布尔值 true。
// 如果未找到匹配的 Param,则返回空字符串和布尔值 false。
// Get 返回键名与给定名称匹配的第一个 Param 的值, 并返回一个布尔值 true.
// 如果未找到匹配的 Param, 则返回空字符串和布尔值 false.
func (ps Params) Get(name string) (string, bool) {
for _, entry := range ps {
if entry.Key == name {
@ -56,24 +48,24 @@ func (ps Params) Get(name string) (string, bool) {
return "", false
}
// ByName 返回键名与给定名称匹配的第一个 Param 的值
// 如果未找到匹配的 Param,则返回空字符串。
// ByName 返回键名与给定名称匹配的第一个 Param 的值.
// 如果未找到匹配的 Param, 则返回空字符串.
func (ps Params) ByName(name string) (va string) {
va, _ = ps.Get(name) // 调用 Get 方法获取值忽略第二个返回值
va, _ = ps.Get(name) // 调用 Get 方法获取值, 忽略第二个返回值
return
}
// methodTree 表示特定 HTTP 方法的路由树
// methodTree 表示特定 HTTP 方法的路由树.
type methodTree struct {
method string // HTTP 方法(例如 "GET", "POST"
method string // HTTP 方法(例如 "GET", "POST")
root *node // 该方法的根节点
}
// methodTrees 是 methodTree 的切片
// methodTrees 是 methodTree 的切片.
type methodTrees []methodTree
// get 根据给定的 HTTP 方法查找并返回对应的根节点
// 如果找不到,则返回 nil。
// get 根据给定的 HTTP 方法查找并返回对应的根节点.
// 如果找不到, 则返回 nil.
func (trees methodTrees) get(method string) *node {
for _, tree := range trees {
if tree.method == method {
@ -83,7 +75,7 @@ func (trees methodTrees) get(method string) *node {
return nil
}
// longestCommonPrefix 计算两个字符串的最长公共前缀的长度
// longestCommonPrefix 计算两个字符串的最长公共前缀的长度.
func longestCommonPrefix(a, b string) int {
i := 0
max_ := min(len(a), len(b)) // 找出两个字符串中较短的长度
@ -93,64 +85,61 @@ func longestCommonPrefix(a, b string) int {
return i // 返回公共前缀的长度
}
// addChild 添加一个子节点,并将通配符子节点(如果存在)保持在数组的末尾。
// addChild 添加一个子节点, 并将通配符子节点(如果存在)保持在数组的末尾.
func (n *node) addChild(child *node) {
if n.wildChild && len(n.children) > 0 {
// 如果当前节点有通配符子节点,且已有子节点,则将通配符子节点移到末尾
// 如果当前节点有通配符子节点, 且已有子节点, 则将通配符子节点移到末尾
wildcardChild := n.children[len(n.children)-1]
n.children = append(n.children[:len(n.children)-1], child, wildcardChild)
} else {
// 否则直接添加子节点
// 否则, 直接添加子节点
n.children = append(n.children, child)
}
}
// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量。
// countParams 计算路径中参数(冒号)和捕获所有(星号)的数量.
func countParams(path string) uint16 {
var n uint16
s := StringToBytes(path) // 将路径字符串转换为字节切片
n += uint16(bytes.Count(s, strColon)) // 统计冒号的数量
n += uint16(bytes.Count(s, strStar)) // 统计星号的数量
return n
colons := strings.Count(path, ":")
stars := strings.Count(path, "*")
return uint16(colons + stars)
}
// countSections 计算路径中斜杠'/')的数量,即路径段的数量。
// countSections 计算路径中斜杠('/')的数量, 即路径段的数量.
func countSections(path string) uint16 {
s := StringToBytes(path) // 将路径字符串转换为字节切片
return uint16(bytes.Count(s, strSlash)) // 统计斜杠的数量
return uint16(strings.Count(path, "/"))
}
// nodeType 定义了节点的类型
// nodeType 定义了节点的类型.
type nodeType uint8
const (
static nodeType = iota // 静态节点路径中不包含参数或通配符
static nodeType = iota // 静态节点, 路径中不包含参数或通配符
root // 根节点
param // 参数节点(例如:name
catchAll // 捕获所有节点(例如*path
param // 参数节点(例如:name)
catchAll // 捕获所有节点(例如*path)
)
// node 表示路由树中的一个节点
// node 表示路由树中的一个节点.
type node struct {
path string // 当前节点的路径段
indices string // 子节点第一个字符的索引字符串用于快速查找子节点
wildChild bool // 是否包含通配符子节点:param 或 *catchAll
nType nodeType // 节点的类型(静态、根、参数、捕获所有)
priority uint32 // 节点的优先级用于查找时优先匹配
children []*node // 子节点切片最多有一个 :param 风格的节点位于数组末尾
indices string // 子节点第一个字符的索引字符串, 用于快速查找子节点
wildChild bool // 是否包含通配符子节点(:param 或 *catchAll)
nType nodeType // 节点的类型(静态, 根, 参数, 捕获所有)
priority uint32 // 节点的优先级, 用于查找时优先匹配
children []*node // 子节点切片, 最多有一个 :param 风格的节点位于数组末尾
handlers HandlersChain // 绑定到此节点的处理函数链
fullPath string // 完整路径用于调试和错误信息
fullPath string // 完整路径, 用于调试和错误信息
}
// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序
// incrementChildPrio 增加给定子节点的优先级并在必要时重新排序.
func (n *node) incrementChildPrio(pos int) int {
cs := n.children // 获取子节点切片
cs[pos].priority++ // 增加指定位置子节点的优先级
prio := cs[pos].priority // 获取新的优先级
// 调整位置(向前移动)
// 调整位置(向前移动)
newPos := pos
// 从当前位置向前遍历,如果前一个子节点的优先级小于当前子节点,则交换位置
// 从当前位置向前遍历, 如果前一个子节点的优先级小于当前子节点, 则交换位置
for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
// 交换节点位置
cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
@ -158,9 +147,9 @@ func (n *node) incrementChildPrio(pos int) int {
// 构建新的索引字符字符串
if newPos != pos {
// 如果位置发生变化则重新构建 indices 字符串
// 如果位置发生变化, 则重新构建 indices 字符串
// 前缀部分 + 移动的索引字符 + 剩余部分
n.indices = n.indices[:newPos] + // 未改变的前缀可能为空
n.indices = n.indices[:newPos] + // 未改变的前缀, 可能为空
n.indices[pos:pos+1] + // 被移动的索引字符
n.indices[newPos:pos] + n.indices[pos+1:] // 除去原位置字符的其余部分
}
@ -168,13 +157,13 @@ func (n *node) incrementChildPrio(pos int) int {
return newPos // 返回新的位置
}
// addRoute 为给定路径添加一个带有处理函数的节点
// 非并发安全
// addRoute 为给定路径添加一个带有处理函数的节点.
// 非并发安全!
func (n *node) addRoute(path string, handlers HandlersChain) {
fullPath := path // 记录完整的路径
n.priority++ // 增加当前节点的优先级
// 如果是空树(根节点)
// 如果是空树(根节点)
if len(n.path) == 0 && len(n.children) == 0 {
n.insertChild(path, fullPath, handlers) // 直接插入子节点
n.nType = root // 设置为根节点类型
@ -185,12 +174,12 @@ func (n *node) addRoute(path string, handlers HandlersChain) {
walk: // 外部循环用于遍历和构建路由树
for {
// 找到最长公共前缀
// 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符
// 找到最长公共前缀.
// 这也意味着公共前缀不包含 ':' 或 '*',因为现有键不能包含这些字符.
i := longestCommonPrefix(path, n.path)
// 分裂边 (Split edge)
// 如果公共前缀小于当前节点的路径长度说明当前节点需要被分裂
// 如果公共前缀小于当前节点的路径长度, 说明当前节点需要被分裂
if i < len(n.path) {
child := node{
path: n.path[i:], // 子节点路径是当前节点路径的剩余部分
@ -199,27 +188,27 @@ walk: // 外部循环用于遍历和构建路由树
indices: n.indices, // 继承索引
children: n.children, // 继承子节点
handlers: n.handlers, // 继承处理函数
priority: n.priority - 1, // 优先级减1因为分裂会降低优先级
priority: n.priority - 1, // 优先级减1, 因为分裂会降低优先级
fullPath: n.fullPath, // 继承完整路径
}
n.children = []*node{&child} // 当前节点现在只有一个子节点新分裂出的子节点
n.children = []*node{&child} // 当前节点现在只有一个子节点: 新分裂出的子节点
// 将当前节点的 indices 设置为新子节点路径的第一个字符
n.indices = BytesToString([]byte{n.path[i]}) // []byte 用于正确的 Unicode 字符转换
n.path = path[:i] // 当前节点的路径更新为公共前缀
n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了)
n.handlers = nil // 当前节点不再有处理函数(因为它被分裂了)
n.wildChild = false // 当前节点不再是通配符子节点
n.fullPath = fullPath[:parentFullPathIndex+i] // 更新完整路径
}
// 将新节点作为当前节点的子节点
// 如果路径仍然有剩余部分(即未完全匹配)
// 如果路径仍然有剩余部分(即未完全匹配)
if i < len(path) {
path = path[i:] // 移除已匹配的前缀
c := path[0] // 获取剩余路径的第一个字符
// '/' 在参数之后
// 如果当前节点是参数类型,且剩余路径以 '/' 开头,并且只有一个子节点
// 如果当前节点是参数类型, 且剩余路径以 '/' 开头, 并且只有一个子节点
// 则继续遍历其唯一的子节点
if n.nType == param && c == '/' && len(n.children) == 1 {
parentFullPathIndex += len(n.path) // 更新父节点完整路径索引
@ -238,8 +227,8 @@ walk: // 外部循环用于遍历和构建路由树
}
}
// 否则插入新节点
// 如果第一个字符不是 ':' 也不是 '*'且当前节点不是 catchAll 类型
// 否则, 插入新节点
// 如果第一个字符不是 ':' 也不是 '*', 且当前节点不是 catchAll 类型
if c != ':' && c != '*' && n.nType != catchAll {
// 将新字符添加到索引字符串
n.indices += BytesToString([]byte{c}) // []byte 用于正确的 Unicode 字符转换
@ -250,18 +239,18 @@ walk: // 外部循环用于遍历和构建路由树
n.incrementChildPrio(len(n.indices) - 1) // 增加新子节点的优先级并重新排序
n = child // 移动到新子节点
} else if n.wildChild {
// 正在插入一个通配符节点需要检查是否与现有通配符冲突
// 正在插入一个通配符节点, 需要检查是否与现有通配符冲突
n = n.children[len(n.children)-1] // 移动到现有的通配符子节点
n.priority++ // 增加其优先级
// 检查通配符是否匹配
// 如果剩余路径长度大于等于通配符节点的路径长度且通配符节点路径是剩余路径的前缀
// 并且不是 catchAll 类型(不能有子路由),
// 如果剩余路径长度大于等于通配符节点的路径长度, 且通配符节点路径是剩余路径的前缀
// 并且不是 catchAll 类型(不能有子路由),
// 并且通配符之后没有更多字符或紧跟着 '/'
if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
// 不能向 catchAll 添加子节点
n.nType != catchAll &&
// 检查更长的通配符例如 :name 和 :names
// 检查更长的通配符, 例如 :name 和 :names
(len(n.path) >= len(path) || path[len(n.path)] == '/') {
continue walk // 继续外部循环
}
@ -269,7 +258,7 @@ walk: // 外部循环用于遍历和构建路由树
// 通配符冲突
pathSeg := path
if n.nType != catchAll {
pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll则截取到下一个 '/'
pathSeg, _, _ = strings.Cut(pathSeg, "/") // 如果不是 catchAll, 则截取到下一个 '/'
}
prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path // 构造冲突前缀
panic("'" + pathSeg + // 抛出 panic 表示通配符冲突
@ -279,13 +268,13 @@ walk: // 外部循环用于遍历和构建路由树
"'")
}
n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符)
n.insertChild(path, fullPath, handlers) // 插入子节点(可能包含通配符)
return // 完成添加路由
}
// 否则将处理函数添加到当前节点
// 否则, 将处理函数添加到当前节点
if n.handlers != nil {
panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数则报错
panic("handlers are already registered for path '" + fullPath + "'") // 如果已注册处理函数, 则报错
}
n.handlers = handlers // 设置处理函数
n.fullPath = fullPath // 设置完整路径
@ -293,20 +282,20 @@ walk: // 外部循环用于遍历和构建路由树
}
}
// findWildcard 搜索通配符段并检查名称是否包含无效字符
// 如果未找到通配符,则返回 -1 作为索引。
// findWildcard 搜索通配符段并检查名称是否包含无效字符.
// 如果未找到通配符, 则返回 -1 作为索引.
func findWildcard(path string) (wildcard string, i int, valid bool) {
// 查找开始位置
escapeColon := false // 是否正在处理转义字符
for start, c := range []byte(path) {
if escapeColon {
escapeColon = false
if c == ':' { // 如果转义字符是 ':'则跳过
if c == ':' { // 如果转义字符是 ':', 则跳过
continue
}
panic("invalid escape string in path '" + path + "'") // 无效的转义字符串
}
if c == '\\' { // 如果是反斜杠则设置转义标志
if c == '\\' { // 如果是反斜杠, 则设置转义标志
escapeColon = true
continue
}
@ -319,36 +308,36 @@ func findWildcard(path string) (wildcard string, i int, valid bool) {
valid = true // 默认为有效
for end, c := range []byte(path[start+1:]) {
switch c {
case '/': // 如果遇到斜杠说明通配符段结束
case '/': // 如果遇到斜杠, 说明通配符段结束
return path[start : start+1+end], start, valid
case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*'则无效
case ':', '*': // 如果在通配符段中再次遇到 ':' 或 '*', 则无效
valid = false
}
}
return path[start:], start, valid // 返回找到的通配符起始索引和有效性
return path[start:], start, valid // 返回找到的通配符, 起始索引和有效性
}
return "", -1, false // 未找到通配符
}
// insertChild 插入一个带有处理函数的节点
// 此函数处理包含通配符的路径插入逻辑
// insertChild 插入一个带有处理函数的节点.
// 此函数处理包含通配符的路径插入逻辑.
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
for {
// 找到第一个通配符之前的前缀
wildcard, i, valid := findWildcard(path)
if i < 0 { // 未找到通配符结束循环
if i < 0 { // 未找到通配符, 结束循环
break
}
// 通配符名称只能包含一个 ':' 或 '*' 字符
if !valid {
panic("only one wildcard per path segment is allowed, has: '" +
wildcard + "' in path '" + fullPath + "'") // 报错每个路径段只允许一个通配符
wildcard + "' in path '" + fullPath + "'") // 报错: 每个路径段只允许一个通配符
}
// 检查通配符是否有名称
if len(wildcard) < 2 {
panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错通配符必须有非空名称
panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") // 报错: 通配符必须有非空名称
}
if wildcard[0] == ':' { // 如果是参数节点 (param)
@ -368,7 +357,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
n = child // 移动到新创建的参数节点
n.priority++ // 增加优先级
// 如果路径不以通配符结束则会有一个以 '/' 开头的子路径
// 如果路径不以通配符结束, 则会有一个以 '/' 开头的子路径
if len(wildcard) < len(path) {
path = path[len(wildcard):] // 剩余路径去除通配符部分
@ -376,19 +365,19 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
priority: 1, // 新子节点优先级
fullPath: fullPath, // 设置子节点的完整路径
}
n.addChild(child) // 添加子节点(通常是斜杠后的静态部分)
n.addChild(child) // 添加子节点(通常是斜杠后的静态部分)
n = child // 移动到这个新子节点
continue // 继续循环查找下一个通配符或结束
continue // 继续循环, 查找下一个通配符或结束
}
// 否则,我们已经完成。将处理函数插入到新叶节点中
// 否则, 我们已经完成. 将处理函数插入到新叶节点中
n.handlers = handlers // 设置处理函数
return // 完成
}
// 如果是捕获所有节点 (catchAll)
if i+len(wildcard) != len(path) {
panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错捕获所有路由只能在路径末尾
panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") // 报错: 捕获所有路由只能在路径末尾
}
// 检查路径段冲突
@ -397,22 +386,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
@ -420,11 +409,11 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
}
n.addChild(child) // 添加子节点
n.indices = string('/') // 索引设置为 '/'
n.indices = "/" // 索引设置为 '/'
n = child // 移动到新创建的 catchAll 节点
n.priority++ // 增加优先级
// 第二个节点包含变量的节点
// 第二个节点: 包含变量的节点
child = &node{
path: path[i:], // 路径为 catchAll 的实际路径段
nType: catchAll, // 类型为 catchAll
@ -437,7 +426,7 @@ func (n *node) insertChild(path string, fullPath string, handlers HandlersChain)
return // 完成
}
// 如果没有找到通配符简单地插入路径和处理函数
// 如果没有找到通配符, 简单地插入路径和处理函数
n.path = path // 设置当前节点路径
n.handlers = handlers // 设置处理函数
n.fullPath = fullPath // 设置完整路径
@ -451,16 +440,16 @@ type nodeValue struct {
fullPath string // 匹配到的完整路径
}
// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息,以便回溯。
// skippedNode 结构体用于在 getValue 查找过程中记录跳过的节点信息, 以便回溯.
type skippedNode struct {
path string // 跳过时的当前路径
node *node // 跳过的节点
paramsCount int16 // 跳过时已收集的参数数量
}
// getValue 返回注册到给定路径key的处理函数。通配符的值会保存到 map 中。
// 如果找不到处理函数,则在存在一个带有额外(或不带)尾部斜杠的处理函数时,
// 建议进行 TSR(尾部斜杠重定向)。
// getValue 返回注册到给定路径(key)的处理函数. 通配符的值会保存到 map 中.
// 如果找不到处理函数, 则在存在一个带有额外(或不带)尾部斜杠的处理函数时,
// 建议进行 TSR(尾部斜杠重定向).
func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
var globalParamsCount int16 // 全局参数计数
@ -471,11 +460,16 @@ walk: // 外部循环用于遍历路由树
if path[:len(prefix)] == prefix { // 如果路径以当前节点的前缀开头
path = path[len(prefix):] // 移除已匹配的前缀
// 优先尝试所有非通配符子节点,通过匹配索引字符
// 在访问 path[0] 之前进行安全检查
if len(path) == 0 {
continue walk
}
// 优先尝试所有非通配符子节点, 通过匹配索引字符
idxc := path[0] // 剩余路径的第一个字符
for i, c := range []byte(n.indices) {
if c == idxc { // 如果找到匹配的索引字符
// 如果当前节点有通配符子节点,则将当前节点添加到 skippedNodes以便回溯
// 如果当前节点有通配符子节点, 则将当前节点添加到 skippedNodes, 以便回溯
if n.wildChild {
index := len(*skippedNodes)
*skippedNodes = (*skippedNodes)[:index+1]
@ -518,20 +512,20 @@ walk: // 外部循环用于遍历路由树
}
}
// 未找到
// 如果存在一个带有额外(或不带)尾部斜杠的处理函数,
// 我们可以建议重定向到相同 URL,不带尾部斜杠。
value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数则建议 TSR
// 未找到.
// 如果存在一个带有额外(或不带)尾部斜杠的处理函数,
// 我们可以建议重定向到相同 URL, 不带尾部斜杠.
value.tsr = path == "/" && n.handlers != nil // 如果路径是 "/" 且当前节点有处理函数, 则建议 TSR
return value
}
// 处理通配符子节点它总是位于数组的末尾
// 处理通配符子节点, 它总是位于数组的末尾
n = n.children[len(n.children)-1] // 移动到通配符子节点
globalParamsCount++ // 增加全局参数计数
switch n.nType {
case param: // 参数节点
// 查找参数结束位置'/' 或路径末尾)
// 查找参数结束位置('/' 或路径末尾)
end := 0
for end < len(path) && path[end] != '/' {
end++
@ -539,7 +533,7 @@ walk: // 外部循环用于遍历路由树
// 保存参数值
if params != nil {
// 如果需要预分配容量
// 如果需要, 预分配容量
if cap(*params) < int(globalParamsCount) {
newParams := make(Params, len(*params), globalParamsCount)
copy(newParams, *params)
@ -559,12 +553,12 @@ walk: // 外部循环用于遍历路由树
}
}
(*value.params)[i] = Param{ // 存储参数
Key: n.path[1:], // 参数键名(去除冒号)
Key: n.path[1:], // 参数键名(去除冒号)
Value: val, // 参数值
}
}
// 我们需要继续深入
// 我们需要继续深入!
if end < len(path) {
if len(n.children) > 0 {
path = path[end:] // 移除已提取的参数部分
@ -573,16 +567,16 @@ walk: // 外部循环用于遍历路由树
}
// ... 但我们无法继续
value.tsr = len(path) == end+1 // 如果路径只剩下斜杠则建议 TSR
value.tsr = len(path) == end+1 // 如果路径只剩下斜杠, 则建议 TSR
return value
}
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return value // 如果当前节点有处理函数则返回
return value // 如果当前节点有处理函数, 则返回
}
if len(n.children) == 1 {
// 未找到处理函数。检查是否存在此路径加尾部斜杠的处理函数,以进行 TSR 建议
// 未找到处理函数. 检查是否存在此路径加尾部斜杠的处理函数, 以进行 TSR 建议
n = n.children[0]
value.tsr = (n.path == "/" && n.handlers != nil) || (n.path == "" && n.indices == "/")
}
@ -591,7 +585,7 @@ walk: // 外部循环用于遍历路由树
case catchAll: // 捕获所有节点
// 保存参数值
if params != nil {
// 如果需要预分配容量
// 如果需要, 预分配容量
if cap(*params) < int(globalParamsCount) {
newParams := make(Params, len(*params), globalParamsCount)
copy(newParams, *params)
@ -611,7 +605,7 @@ walk: // 外部循环用于遍历路由树
}
}
(*value.params)[i] = Param{ // 存储参数
Key: n.path[2:], // 参数键名(去除星号)
Key: n.path[2:], // 参数键名(去除星号)
Value: val, // 参数值
}
}
@ -627,7 +621,7 @@ walk: // 外部循环用于遍历路由树
}
if path == prefix { // 如果路径完全匹配当前节点的前缀
// 如果当前路径不等于 '/' 且节点没有注册的处理函数且最近匹配的节点有子节点
// 如果当前路径不等于 '/' 且节点没有注册的处理函数, 且最近匹配的节点有子节点
// 当前节点需要回溯到最后一个有效的 skippedNode
if n.handlers == nil && path != "/" {
for length := len(*skippedNodes); length > 0; length-- {
@ -644,26 +638,26 @@ walk: // 外部循环用于遍历路由树
}
}
}
// 我们应该已经到达包含处理函数的节点
// 检查此节点是否注册了处理函数
// 我们应该已经到达包含处理函数的节点.
// 检查此节点是否注册了处理函数.
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return value // 如果有处理函数则返回
return value // 如果有处理函数, 则返回
}
// 如果此路由没有处理函数,但此路由有通配符子节点,
// 则此路径必须有一个带有额外尾部斜杠的处理函数
// 如果此路由没有处理函数, 但此路由有通配符子节点,
// 则此路径必须有一个带有额外尾部斜杠的处理函数.
if path == "/" && n.wildChild && n.nType != root {
value.tsr = true // 建议 TSR
return value
}
if path == "/" && n.nType == static {
value.tsr = true // 如果是静态节点且路径是根则建议 TSR
value.tsr = true // 如果是静态节点且路径是根, 则建议 TSR
return value
}
// 未找到处理函数。检查此路径加尾部斜杠是否存在处理函数,以进行尾部斜杠重定向建议
// 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数, 以进行尾部斜杠重定向建议
for i, c := range []byte(n.indices) {
if c == '/' { // 如果索引中包含 '/'
n = n.children[i] // 移动到对应的子节点
@ -676,11 +670,11 @@ walk: // 外部循环用于遍历路由树
return value
}
// 未找到。我们可以建议重定向到相同 URL添加一个额外的尾部斜杠
// 如果该路径的叶节点存在
// 未找到. 我们可以建议重定向到相同 URL, 添加一个额外的尾部斜杠,
// 如果该路径的叶节点存在.
value.tsr = path == "/" || // 如果路径是根路径
(len(prefix) == len(path)+1 && prefix[len(path)] == '/' && // 或者前缀比路径多一个斜杠
path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠且有处理函数
path == prefix[:len(prefix)-1] && n.handlers != nil) // 且路径是前缀去掉最后一个斜杠, 且有处理函数
// 回溯到最后一个有效的 skippedNode
if !value.tsr && path != "/" {
@ -703,17 +697,17 @@ walk: // 外部循环用于遍历路由树
}
}
// findCaseInsensitivePath 对给定路径进行不区分大小写的查找,并尝试找到处理函数。
// 它还可以选择修复尾部斜杠
// 它返回大小写校正后的路径和一个布尔值,指示查找是否成功。
// findCaseInsensitivePath 对给定路径进行不区分大小写的查找, 并尝试找到处理函数.
// 它还可以选择修复尾部斜杠.
// 它返回大小写校正后的路径和一个布尔值, 指示查找是否成功.
func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]byte, bool) {
const stackBufSize = 128 // 栈上缓冲区的默认大小
// 在常见情况下使用栈上静态大小的缓冲区
// 如果路径太长,则在堆上分配缓冲区。
// 在常见情况下使用栈上静态大小的缓冲区.
// 如果路径太长, 则在堆上分配缓冲区.
buf := make([]byte, 0, stackBufSize)
if length := len(path) + 1; length > stackBufSize {
buf = make([]byte, 0, length) // 如果路径太长则分配更大的缓冲区
buf = make([]byte, 0, length) // 如果路径太长, 则分配更大的缓冲区
}
ciPath := n.findCaseInsensitivePathRec(
@ -726,7 +720,7 @@ func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) ([]by
return ciPath, ciPath != nil // 返回校正后的路径和是否成功找到
}
// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节
// shiftNRuneBytes 将字节数组中的字节向左移动 n 个字节.
func shiftNRuneBytes(rb [4]byte, n int) [4]byte {
switch n {
case 0:
@ -742,12 +736,12 @@ func shiftNRuneBytes(rb [4]byte, n int) [4]byte {
}
}
// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数
// findCaseInsensitivePathRec 由 n.findCaseInsensitivePath 使用的递归不区分大小写查找函数.
func (n *node) findCaseInsensitivePathRec(path string, ciPath []byte, rb [4]byte, fixTrailingSlash bool) []byte {
npLen := len(n.path) // 当前节点的路径长度
walk: // 外部循环用于遍历路由树
// 只要剩余路径长度大于等于当前节点路径长度,且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径
// 只要剩余路径长度大于等于当前节点路径长度, 且当前节点路径(除第一个字符外)不区分大小写匹配剩余路径
for len(path) >= npLen && (npLen == 0 || strings.EqualFold(path[1:npLen], n.path[1:])) {
// 将公共前缀添加到结果中
oldPath := path // 保存原始路径
@ -755,13 +749,13 @@ walk: // 外部循环用于遍历路由树
ciPath = append(ciPath, n.path...) // 将当前节点的路径添加到不区分大小写路径中
if len(path) == 0 { // 如果路径已完全匹配
// 我们应该已经到达包含处理函数的节点
// 检查此节点是否注册了处理函数
// 我们应该已经到达包含处理函数的节点.
// 检查此节点是否注册了处理函数.
if n.handlers != nil {
return ciPath // 如果有处理函数则返回校正后的路径
return ciPath // 如果有处理函数, 则返回校正后的路径
}
// 未找到处理函数
// 未找到处理函数.
// 尝试通过添加尾部斜杠来修复路径
if fixTrailingSlash {
for i, c := range []byte(n.indices) {
@ -775,11 +769,11 @@ walk: // 外部循环用于遍历路由树
}
}
}
return nil // 未找到返回 nil
return nil // 未找到, 返回 nil
}
// 如果此节点没有通配符(参数或捕获所有)子节点,
// 我们可以直接查找下一个子节点并继续遍历树
// 如果此节点没有通配符(参数或捕获所有)子节点,
// 我们可以直接查找下一个子节点并继续遍历树.
if !n.wildChild {
// 跳过已处理的 rune 字节
rb = shiftNRuneBytes(rb, npLen)
@ -799,9 +793,9 @@ walk: // 外部循环用于遍历路由树
// 处理一个新的 rune
var rv rune
// 查找 rune 的开始位置
// Runes 最长为 4 字节
// -4 肯定会是另一个 rune
// 查找 rune 的开始位置.
// Runes 最长为 4 字节.
// -4 肯定会是另一个 rune.
var off int
for max_ := min(npLen, 3); off < max_; off++ {
if i := npLen - off; utf8.RuneStart(oldPath[i]) {
@ -822,17 +816,17 @@ walk: // 外部循环用于遍历路由树
for i, c := range []byte(n.indices) {
// 小写匹配
if c == idxc {
// 必须使用递归方法因为大写字节和小写字节都可能作为索引存在
// 必须使用递归方法, 因为大写字节和小写字节都可能作为索引存在
if out := n.children[i].findCaseInsensitivePathRec(
path, ciPath, rb, fixTrailingSlash,
); out != nil {
return out // 如果找到则返回
return out // 如果找到, 则返回
}
break
}
}
// 如果未找到匹配项,则对大写 rune 执行相同操作(如果它不同)
// 如果未找到匹配项, 则对大写 rune 执行相同操作(如果它不同)
if up := unicode.ToUpper(rv); up != lo {
utf8.EncodeRune(rb[:], up) // 将大写 rune 编码到缓冲区
rb = shiftNRuneBytes(rb, off)
@ -850,18 +844,18 @@ walk: // 外部循环用于遍历路由树
}
}
// 未找到。我们可以建议重定向到相同 URL不带尾部斜杠
// 如果该路径的叶节点存在
// 未找到. 我们可以建议重定向到相同 URL, 不带尾部斜杠,
// 如果该路径的叶节点存在.
if fixTrailingSlash && path == "/" && n.handlers != nil {
return ciPath // 如果可以修复尾部斜杠且有处理函数则返回
return ciPath // 如果可以修复尾部斜杠且有处理函数, 则返回
}
return nil // 未找到返回 nil
return nil // 未找到, 返回 nil
}
n = n.children[0] // 移动到通配符子节点(通常是唯一一个)
n = n.children[0] // 移动到通配符子节点(通常是唯一一个)
switch n.nType {
case param: // 参数节点
// 查找参数结束位置'/' 或路径末尾)
// 查找参数结束位置('/' 或路径末尾)
end := 0
for end < len(path) && path[end] != '/' {
end++
@ -870,7 +864,7 @@ walk: // 外部循环用于遍历路由树
// 将参数值添加到不区分大小写路径中
ciPath = append(ciPath, path[:end]...)
// 我们需要继续深入
// 我们需要继续深入!
if end < len(path) {
if len(n.children) > 0 {
// 继续处理子节点
@ -882,45 +876,45 @@ walk: // 外部循环用于遍历路由树
// ... 但我们无法继续
if fixTrailingSlash && len(path) == end+1 {
return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠则返回
return ciPath // 如果可以修复尾部斜杠且路径只剩下斜杠, 则返回
}
return nil // 未找到返回 nil
return nil // 未找到, 返回 nil
}
if n.handlers != nil {
return ciPath // 如果有处理函数则返回
return ciPath // 如果有处理函数, 则返回
}
if fixTrailingSlash && len(n.children) == 1 {
// 未找到处理函数检查此路径加尾部斜杠是否存在处理函数
// 未找到处理函数. 检查此路径加尾部斜杠是否存在处理函数
n = n.children[0]
if n.path == "/" && n.handlers != nil {
return append(ciPath, '/') // 返回添加斜杠后的路径
}
}
return nil // 未找到返回 nil
return nil // 未找到, 返回 nil
case catchAll: // 捕获所有节点
return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有)
return append(ciPath, path...) // 返回添加剩余路径后的路径(捕获所有)
default:
panic("invalid node type") // 无效的节点类型
}
}
// 未找到
// 未找到.
// 尝试通过添加/删除尾部斜杠来修复路径
if fixTrailingSlash {
if path == "/" {
return ciPath // 如果路径是根路径则返回
return ciPath // 如果路径是根路径, 则返回
}
// 如果路径长度比当前节点路径少一个斜杠,且末尾是斜杠,
// 且不区分大小写匹配且当前节点有处理函数
// 如果路径长度比当前节点路径少一个斜杠, 且末尾是斜杠,
// 且不区分大小写匹配, 且当前节点有处理函数
if len(path)+1 == npLen && n.path[len(path)] == '/' &&
strings.EqualFold(path[1:], n.path[1:len(path)]) && n.handlers != nil {
return append(ciPath, n.path...) // 返回添加当前节点路径后的路径
}
}
return nil // 未找到返回 nil
return nil // 未找到, 返回 nil
}

1078
tree_test.go Normal file

File diff suppressed because it is too large Load diff