From 4f262b2497b7dbb1c266ee55421c9507dfaaca5d Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:13:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20httpc=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=96=87=E6=A1=A3=E5=92=8C=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 examples/httpc 示例代码 - 新增 docs/httpc.md 文档说明 --- docs/httpc.md | 188 +++++++++++++++++++++++++++++++++++++++++ examples/httpc/main.go | 103 ++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 docs/httpc.md create mode 100644 examples/httpc/main.go diff --git a/docs/httpc.md b/docs/httpc.md new file mode 100644 index 0000000..8742c18 --- /dev/null +++ b/docs/httpc.md @@ -0,0 +1,188 @@ +# HTTP Client (httpc) + +Touka 内置了 [httpc](https://github.com/WJQSERVER-STUDIO/httpc) HTTP 客户端,方便在请求处理函数中发起出站 HTTP 请求。 + +## 核心特性 + +- **自动 Context 关联**:使用 `HTTPC()` 方法时,出站请求会自动关联当前请求的 Context +- **请求取消传播**:当客户端断开连接时,出站请求会自动取消,避免资源泄漏 +- **链式调用**:保持 httpc 原有的组合式构建器风格 + +## 基本用法 + +### 简单 GET 请求 + +```go +r.GET("/proxy", func(c *touka.Context) { + body, err := c.HTTPC(). + GET("https://api.example.com/data"). + Text() + if err != nil { + c.JSON(500, touka.H{"error": err.Error()}) + return + } + c.String(200, body) +}) +``` + +### POST JSON 请求 + +```go +r.POST("/users", func(c *touka.Context) { + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + c.ShouldBindJSON(&req) + + var result struct { + ID int `json:"id"` + Name string `json:"name"` + } + + err := c.HTTPC(). + POST("https://api.example.com/users"). + SetHeader("Authorization", "Bearer "+token). + SetJSONBody(req). + DecodeJSON(&result) + if err != nil { + c.JSON(500, touka.H{"error": err.Error()}) + return + } + c.JSON(200, result) +}) +``` + +### 带查询参数 + +```go +r.GET("/search", func(c *touka.Context) { + query := c.Query("q") + + var result SearchResult + err := c.HTTPC(). + GET("https://api.example.com/search"). + SetQueryParam("q", query). + SetQueryParam("limit", "10"). + DecodeJSON(&result) + if err != nil { + c.JSON(500, touka.H{"error": err.Error()}) + return + } + c.JSON(200, result) +}) +``` + +## API 对比 + +### 旧方式(Deprecated) + +```go +// 需要手动 WithContext,容易忘记 +resp, err := c.Client(). + WithContext(c.Context()). + GET(url). + Execute() +``` + +### 新方式(推荐) + +```go +// 自动关联请求 Context +resp, err := c.HTTPC(). + GET(url). + Execute() +``` + +## Context 取消机制 + +使用 `HTTPC()` 时,当客户端断开连接(如关闭浏览器),出站请求会自动取消: + +```go +r.GET("/long-task", func(c *touka.Context) { + // 这个请求会在客户端断开时自动取消 + resp, err := c.HTTPC(). + GET("https://slow-api.example.com/data"). + Execute() + + // 如果客户端已断开,err 会包含 context.Canceled + if errors.Is(err, context.Canceled) { + return // 客户端已断开,无需处理 + } + // ... +}) +``` + +## 完整 API + +### contextHTTPClient 方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `NewRequestBuilder(method, url)` | `*httpc.RequestBuilder` | 创建通用请求构建器 | +| `GET(url)` | `*httpc.RequestBuilder` | 创建 GET 请求 | +| `POST(url)` | `*httpc.RequestBuilder` | 创建 POST 请求 | +| `PUT(url)` | `*httpc.RequestBuilder` | 创建 PUT 请求 | +| `DELETE(url)` | `*httpc.RequestBuilder` | 创建 DELETE 请求 | +| `PATCH(url)` | `*httpc.RequestBuilder` | 创建 PATCH 请求 | +| `HEAD(url)` | `*httpc.RequestBuilder` | 创建 HEAD 请求 | +| `OPTIONS(url)` | `*httpc.RequestBuilder` | 创建 OPTIONS 请求 | + +### httpc.RequestBuilder 链式方法 + +返回 `*httpc.RequestBuilder`(用于链式调用): + +| 方法 | 说明 | +|------|------| +| `WithContext(ctx)` | 设置 Context(通常不需要,已自动关联) | +| `NoDefaultHeaders()` | 不添加默认 Header | +| `SetHeader(key, value)` | 设置 Header | +| `AddHeader(key, value)` | 添加 Header(可重复) | +| `SetHeaders(map)` | 批量设置 Headers | +| `SetQueryParam(key, value)` | 设置查询参数 | +| `AddQueryParam(key, value)` | 添加查询参数(可重复) | +| `SetQueryParams(map)` | 批量设置查询参数 | +| `SetBody(io.Reader)` | 设置请求 Body | +| `SetRawBody([]byte)` | 设置字节 Body | + +返回 `(*httpc.RequestBuilder, error)`(可能失败): + +| 方法 | 说明 | +|------|------| +| `SetJSONBody(any)` | 设置 JSON Body | +| `SetXMLBody(any)` | 设置 XML Body | +| `SetGOBBody(any)` | 设置 GOB Body | + +### 终结方法 + +| 方法 | 返回类型 | 说明 | +|------|----------|------| +| `Build()` | `(*http.Request, error)` | 构建请求但不执行 | +| `Execute()` | `(*http.Response, error)` | 执行并返回原始响应 | +| `DecodeJSON(v)` | `error` | 执行并解码 JSON | +| `DecodeXML(v)` | `error` | 执行并解码 XML | +| `DecodeGOB(v)` | `error` | 执行并解码 GOB | +| `Text()` | `(string, error)` | 执行并返回文本 | +| `Bytes()` | `([]byte, error)` | 执行并返回字节 | +| `SSE()` | `(*SSEStream, error)` | 建立 SSE 流连接 | + +## 迁移指南 + +### go:fix inline 兼容 + +旧代码 `c.GetHTTPC()` 可通过 `go fix` 自动迁移到 `c.Client()`: + +```bash +go fix ./... +``` + +### 手动迁移 + +| 旧代码 | 新代码 | +|--------|--------| +| `c.GetHTTPC()` | `c.Client()` 或 `c.HTTPC()` | +| `c.Client().WithContext(ctx).GET(url)` | `c.HTTPC().GET(url)` | + +## 示例 + +完整示例请参考 [examples/httpc](../examples/httpc)。 diff --git a/examples/httpc/main.go b/examples/httpc/main.go new file mode 100644 index 0000000..f50ec9a --- /dev/null +++ b/examples/httpc/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/infinite-iroha/touka" +) + +func main() { + r := touka.Default() + + // 示例 1:简单 GET 请求(自动关联请求 Context) + r.GET("/proxy", func(c *touka.Context) { + // 使用 HTTPC() 方法,自动关联请求 Context + // 当客户端断开连接时,出站请求也会自动取消 + body, err := c.HTTPC(). + GET("https://httpbin.org/get"). + Text() + if err != nil { + c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) + return + } + c.String(http.StatusOK, body) + }) + + // 示例 2:带 Header 的 POST 请求 + r.POST("/users", func(c *touka.Context) { + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, touka.H{"error": err.Error()}) + return + } + + var result struct { + ID int `json:"id"` + Name string `json:"name"` + } + + // 链式调用,保持 httpc 风格 + // 注意:SetJSONBody 返回 (*RequestBuilder, error) + rb, err := c.HTTPC(). + POST("https://httpbin.org/post"). + SetHeader("X-API-Key", "secret"). + SetJSONBody(req) + if err != nil { + c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) + return + } + if err := rb.DecodeJSON(&result); err != nil { + c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) + }) + + // 示例 3:带查询参数的请求 + r.GET("/search", func(c *touka.Context) { + query := c.DefaultQuery("q", "") + page := c.DefaultQuery("page", "1") + + var result struct { + Items []string `json:"items"` + Total int `json:"total"` + } + + err := c.HTTPC(). + GET("https://httpbin.org/get"). + SetQueryParam("q", query). + SetQueryParam("page", page). + DecodeJSON(&result) + if err != nil { + c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) + }) + + // 示例 4:使用底层 httpc.Client(旧方式,仍可用但不推荐) + r.GET("/legacy", func(c *touka.Context) { + // 旧方式:需要手动 WithContext + body, err := c.Client(). + GET("https://httpbin.org/get"). + WithContext(c.Context()). + Text() + if err != nil { + c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) + return + } + c.String(http.StatusOK, body) + }) + + fmt.Println("Server running on :8080") + fmt.Println("Try:") + fmt.Println(" curl http://localhost:8080/proxy") + fmt.Println(" curl -X POST -d '{\"name\":\"test\",\"email\":\"test@example.com\"}' http://localhost:8080/users") + fmt.Println(" curl 'http://localhost:8080/search?q=golang&page=1'") + + // r.Run(touka.WithAddr(":8080")) +}