# go-reqx **Repository Path**: hexug/go-reqx ## Basic Information - **Project Name**: go-reqx - **Description**: 基于 net/http 的链式 HTTP 客户端库,内置日志体系和 OpenTelemetry 链路追踪,适合在微服务中统一 HTTP 请求、日志和链路追踪规范。 - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: https://gitee.com/hexug/go-reqx - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2025-11-25 - **Last Updated**: 2026-05-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: HttpClient, Go语言, trace, log ## README ## go-reqx:链式 HTTP 客户端 [![License](https://img.shields.io/badge/License-Apache_2.0-green?logo=apache&style=flat-square)](./LICENSE) [![Go Report Card](https://img.shields.io/badge/Go%20Report-A+-brightgreen?logo=go&style=flat-square)](https://goreportcard.com/report/gitee.com/hexug/go-reqx) [![Gitee tag (latest SemVer)](https://img.shields.io/badge/dynamic/json?&logo=gitee&logoColor=ee1c25&url=https://gitee.com/api/v5/repos/hexug/go-reqx/tags?per_page=1%26direction=desc%26sort=updated&label=Version&query=$[0].name&color=brightgreen)](https://gitee.com/hexug/go-reqx/tags) ![Go Version](https://img.shields.io/badge/Go-1.24-blue?logo=go&style=flat-square) `go-reqx` 对标准库 `net/http` 进行二次封装,提供: - **链式调用风格** 的 HTTP 客户端(`rest.Client`),用最少的模板代码发起请求 - **模板 Client + 派生 Request 架构**:Client 在运行期只读,Request 拥有独立的覆盖位, 多 goroutine 同享一个 Client 互不污染(`go test -race` 验证) - **统一的日志体系**:可配置日志级别,支持自定义 `Logger` 实现 - **可插拔的请求 Hook**:用于链路追踪、监控等(已内置 OpenTelemetry 示例) - **丰富的高级能力**:认证、重定向控制、Cookie、TLS、代理、DNS、上传文件等 核心目标是:**让 HTTP 调用在业务代码里更“干净”、更可观测、且易于扩展**。 --- ## 安装 ```bash go get gitee.com/hexug/go-reqx/rest ``` 在代码中导入: ```go import "gitee.com/hexug/go-reqx/rest" ``` --- ## 项目结构 ```text . ├── go.mod / go.sum # Go 模块定义 ├── LICENSE # 开源协议(Apache License 2.0) ├── README.md # 项目说明(当前文件) ├── internal/ │ ├── negotiator/ # 内部编码/解码协商实现(JSON、表单、YAML 等) │ ├── compress/ # 压缩/解压底层工厂(gzip/zstd/br/deflate/snappy) │ ├── ssrf/ # SSRF 防护:IP/网段黑名单判定 │ └── security/ # HTTP 输入校验:Header/Cookie/Proxy 安全验证 └── rest/ │ │── 📦 模型定义层 ├── client_model.go # Client 结构体、Logger 接口、RequestHook 等核心模型 ├── request_model.go # Request 结构体与构造函数 ├── response_model.go # Response 结构体与构造函数 ├── const.go # 常量与类型定义(HTTP 方法、Header 名、日志级别等) │ │── 🔧 Client 功能层 ├── client.go # 核心功能:HTTP 客户端管理、Clone/Group、HTTP 方法快捷入口 ├── client_config.go # 配置 setter:BaseURL/Header/Cookie/TLS/代理/日志/Hook 等 │ │── 📤 Request 功能层 ├── request_url.go # URL 构建:Method/URL/AbsURL/Prefix/Suffix/Params ├── request_header.go # Header/Cookie/Auth 设置 ├── request_body.go # 请求体编码:DataJs/DataXML/DataYaml/Body/FormData/RowText ├── request_upload.go # 文件上传:multipart 表单/TarFiles/TarFilesStream/进度回调 ├── request_config.go # 请求级配置:超时/TLS/代理/DNS/重定向/SSRF/压缩等 ├── request_execute.go # 执行引擎:Do/doOnce/重试循环/错误处理/日志输出 ├── request_effective.go # 运行期配置合成器(buildEffectiveClient) ├── request_compress.go # 请求体压缩(gzip/zstd/br/deflate/snappy) │ │── 📥 Response 功能层 ├── response.go # 核心 getter:StatusCode/Headers/Cookies/Url 等 ├── response_decode.go # 响应体读取、解压、反序列化(Into/Raw/Text/Error) ├── response_save.go # 流式下载:SaveTo/SaveToFile/SaveToFileAsync │ │── 🛡️ 基础设施层 ├── retry.go # 重试策略接口与 DefaultRetryPolicy 实现 ├── httptrace.go # 细粒度耗时追踪(DNS/TCP/TLS/TTFB/ContentTransfer) ├── security.go # 安全工具:SSRF 防护、IP 黑名单、Header 脱敏 ├── version.go # 库版本号与 User-Agent 生成 │ │── 🧪 测试文件 ├── rest_test.go # 请求能力示例与回归测试(API 功能清单) ├── clone_test.go # Clone/Group 深拷贝隔离测试 ├── concurrency_test.go # 并发安全验证(go test -race) ├── data_type_test.go # 多种请求体编码格式测试 ├── download_test.go # 下载场景测试 ├── encoding_test.go # 编码/解码协商测试 ├── freeapi_test.go # 公开 API 集成测试示例 ├── httptrace_test.go # 细粒度耗时追踪测试 ├── request_compress_test.go # 请求体压缩测试 ├── retry_test.go # 重试机制单元测试集 ├── snipe_it_test.go # Snipe-IT API 集成测试示例 ├── tracing_test.go # 链路追踪(Jaeger/Zipkin)测试与示例 │ └── example/ ├── trace/ # OpenTelemetry Hook + Jaeger/Zipkin 初始化示例 │ ├── hook.go │ ├── jaeger/ │ └── zipkin_tp/ └── example_service/ # 多服务调用示例(gateway/user/order/payment) ├── README.md ├── gateway/ ├── order/ ├── payment/ └── user/ ``` 各层职责说明: - **internal 基础设施层**:不对外暴露的底层实现,包含编解码协商、压缩算法工厂、SSRF 判定、输入安全校验等纯工具逻辑 - **模型定义层**:定义核心数据结构(Client/Request/Response)与接口,不包含业务逻辑 - **Client 功能层**:Client 的核心操作与配置方法,负责"模板"的创建与管理 - **Request 功能层**:按职责拆分为 URL 构建、Header 设置、请求体编码、文件上传、执行引擎等独立模块 - **Response 功能层**:按职责拆分为基础 getter、解码/反序列化、流式下载三个独立模块 - **基础设施层**:重试、追踪、安全等横切关注点 > 你可以把 `rest/rest_test.go` 当作 **API 功能清单**,把 `rest/example/` 视为 **实战级示例**,二者结合可以快速上手并验证你的集成方式。 --- ## 快速开始:一个规范的请求示例 下面这个示例基于 `[rest/rest_test.go](/rest/rest_test.go)` 中的 `TestRest` 改造而来,展示了一个“规范写法”的 POST 请求: ```go package main import ( "context" "fmt" "time" "gitee.com/hexug/go-reqx/rest" ) var ( baseURL = "http://httpbin.org" // 示例地址,可替换为你的接口 ctx = context.Background() ) type Data struct { Args map[string]any `json:"args"` Data string `json:"data"` Files map[string]string `json:"files"` Form map[string]string `json:"form"` Headers map[string]string `json:"headers"` Json any `json:"json"` Origin string `json:"origin"` URL string `json:"url"` } func main() { var data Data // 1. 创建客户端(默认日志级别为 debug) c := rest.NewDefaultClient() // 2. 设置超时时间 c.SetTimeout(5 * time.Second) // 3. 构建并发送请求 // - SetBaseURL:设置基础地址 // - SetBasicAuth:设置 Basic 认证 // - Post:选择 POST 方法 // - Param:设置 URL 查询参数 // - Do:发起请求 // - Into:将响应体解码到指定结构体 if err := c.SetBaseURL(baseURL). SetBasicAuth("abc", "bbb"). Post("/post"). Param("abc", "ccc"). Do(ctx). Into(&data); err != nil { fmt.Println("request failed:", err) return } fmt.Println("args: ", data.Args) fmt.Println("data部分:", data.Data) fmt.Println("文件部分:", data.Files) fmt.Println("表单部分:", data.Form) fmt.Println("头部份:", data.Headers) fmt.Println("json部分:", data.Json) fmt.Println("origin部分:", data.Origin) fmt.Println("url部分:", data.URL) } ``` ### 这样写有什么好处? - **DSL 风格清晰**:`SetBaseURL().Post().Param().Do().Into()` 一眼能看懂请求的配置和生命周期。 - **自动日志**:默认会打印请求/响应的关键信息,便于排查问题。 - **响应处理统一**:`Do(ctx)` 始终返回一个 `Response` 对象,统一提供 `Error() / Into() / Raw() / Text()` 等方法。 - **易于扩展**:后续要加超时、代理、链路追踪等,只需要在链式调用里再拼一两段即可。 --- ## 请求构建与响应处理规范 ### 常见请求模式 #### 1. GET 请求 参考 `[rest/rest_test.go](/rest/rest_test.go)` 中的 `TestGet`: ```go c := rest.NewLogLevelClient(rest.LogLevelInfo) resp := c.SetBaseURL(baseURL). Get("/get"). SetHeader("aaa", "b"). Param("aaa", "ccc"). Param("aaa", "bbb"). Do(ctx) if resp.Error() != nil { // 统一错误处理 panic(resp.Error()) } data := new(Data) if err := resp.Into(data); err != nil { panic(err) } fmt.Println("Status:", resp.StatusCode()) fmt.Println("args:", data.Args) fmt.Println("headers:", data.Headers) ``` #### 2. JSON POST 请求 参考 `TestPost`: ```go type Payload struct { A string `json:"A"` B int `json:"B"` } c := rest.NewLogLevelClient(rest.LogLevelDebug) resp := c.SetBaseURL(baseURL). Post("/post"). Param("ccc", "这个是中文"). DataJs(Payload{A: "中文能显示么", B: 2}). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } fmt.Println("Text:", resp.Text()) fmt.Println("Status:", resp.Status()) fmt.Println("Headers:", resp.Headers()) ``` #### 3. 文件上传 + 表单 参考 `TestRest` 中的多部分表单示例: ```go resp := c.SetBaseURL(baseURL). Post("/post"). MultiFormData("aaaa", "bbbbbbbbbbb"). UploadFiles("file", "const.go"). Param("abc", "ccc"). Do(ctx) ``` #### 4. 其他高级能力(示例在 `rest/rest_test.go` 中) - **Put/Delete 请求**:`TestPut` / `TestDelete` - **Basic / Bearer 认证**:`TestBasicAuth` / `TestBearerAuth` - **自动 / 手动重定向**:`TestStatusCodes` / `TestRedirects` - **Cookie 管理**:`TestCookies` - **HTTPS & TLS 配置**:`TestHttps`(如 `SetIgnoreCert()`、`SetMinVersionTLS(...)`) - **代理配置**:`TestTransport`(`SetProxyAdd/SetProxyPort/SetProxyScheme`) - **自定义 DNS 解析**:`TestClient` / `TestClient1`(`SetDNS` / `CustomDialContext`) 你可以把这些测试用例视为“**功能清单 + 使用范本**”。 --- ## 错误处理:粘性错误(sticky error) `go-reqx` 的链式 API 在配置阶段(如 `SetBaseURL` / `SetCert` / `SetProxyUrl` / `Group` 等)遇到非法参数或加载失败时,会把错误以"粘性错误"的形式挂在 `Client` / `Request` 上,并让后续 Setter 自动短路;错误最终会沿着 `Do() → Response → Into()` 链路返回给调用方,不会被吞掉,也不会终止调用方进程。 ### 错误传播链路 ``` 配置错误发生 ↓ c.err = fmt.Errorf(...) ↓ NewRequest(c) 时 → r.err = c.err (透传) ↓ r.Do(ctx) 入口 → resp.err = r.err (短路返回) ↓ resp.Into(&data) 入口 → return r.err (最终抛给业务) ``` 也就是说:**配置错误、网络错误、解码错误、状态码错误**都会以同一个 `error` 走到业务层,业务代码可以用统一的方式处理。 ### 用法一:按原有写法直接拿 error(推荐,最常见) 不需要任何额外检查,跟过去一样使用 `Do().Into()` / `Do().Error()` 就能拿到所有类别的错误: ```go c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetCert("ca.crt", "client.crt", "client.key"). SetProxyUrl("http://127.0.0.1:8080") var data User if err := c.Get("/users").Do(ctx).Into(&data); err != nil { // 这里能拿到: // - 配置错误(证书加载失败、URL 非法、CA 读取失败……) // - 网络错误 // - 状态码非 2xx/3xx // - 解码错误 log.Errorf("请求失败: %v", err) return err } ``` 或: ```go resp := c.Get("/users").Do(ctx) if resp.Error() != nil { return resp.Error() } ``` ### 用法二:发起请求前主动检查(适合 fail-fast 场景) 如果希望 Client 初始化阶段就显式判断(例如全局单例、`init()` 中构造、希望配置错就让进程起不来),可以使用 `Client.Err()`: ```go c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetCert("ca.crt", "client.crt", "client.key"). SetProxyUrl("http://127.0.0.1:8080") if err := c.Err(); err != nil { // 由业务自行决定 log.Fatal / 降级 / return ... log.Fatalf("rest client 配置失败: %v", err) } ``` `Client.Err()` 与 `Do().Into()` 路径并不冲突——**两种用法可以任选其一,也可以同时使用**:即使不主动检查,错误也会原样在请求阶段返回。 ### 错误包装与解包 配置阶段产生的错误均使用 `fmt.Errorf("...: %w", err)` 包装,可以用标准库进行解包: ```go if err := c.Get("/users").Do(ctx).Into(&data); err != nil { var pathErr *os.PathError if errors.As(err, &pathErr) { // 例如 CA 文件不存在 } if errors.Is(err, context.DeadlineExceeded) { // 超时 } return err } ``` --- ## 上传文件与大文件优化 `go-reqx` 内部对多部分表单上传和 tar 打包上传做了增强: - **小文件自动缓冲到内存**,便于简单场景开发与调试; - **大文件自动切换为流式上传**,避免一次性占用大量内存; - **提供上传进度回调**,方便在 CLI 或 Web UI 中实现进度条; - **内置 Docker 远程文件上传场景的 tar 打包能力**。 ### 1. 多部分表单上传(UploadFiles / UploadFileReader) #### 1.1 基本用法:`UploadFiles` 适用于“按文件路径上传”的常见场景: ```go resp := c.SetBaseURL(baseURL). Post("/upload"). // 普通表单字段 MultiFormData("biz_id", "order-123"). MultiFormData("comment", "这是备注"). // 通过文件路径上传 UploadFiles("file", "./const.go"). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } ``` 内部会自动使用 `multipart/form-data` 组装请求体,无需手动拼 `Content-Type` 和 boundary。 #### 1.2 进阶:`UploadFileReader` 支持 `io.Reader` 当文件内容并不来自本地文件路径,而是: - 内存中的数据流; - 其他网络流; - 动态生成的数据(例如加密、压缩后的流); 可以使用 `UploadFileReader`: ```go f, err := os.Open("./ubuntu-16.04.7-server-amd64.template") if err != nil { panic(err) } defer f.Close() stat, _ := f.Stat() resp := c.SetBaseURL(baseURL). Post("/upload"). // 设置上传进度回调(后面详述) WithUploadProgress(func(written, total int64) { fmt.Printf("已发送:%s / %s\n", format.FormatBodySize(written), format.FormatBodySize(total)) }). // 通过 io.Reader 上传,size>0 时可用于预估总大小 UploadFileReader("file", stat.Name(), stat.Size(), f). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } ``` - `fieldName`:表单字段名,如 `"file"`; - `fileName`:multipart 中的 `filename`; - `size`:文件大小,`>0` 时用于总大小预估,`<=0` 表示未知大小(比如真正的流式数据); - `reader`:实际数据来源。 当大小未知(`size<=0`)时,该部分不会参与“是否超过阈值”的判断,通常会直接走流式上传分支。 #### 1.3 小文件 vs 大文件:4MB 阈值 多部分表单内部会根据所有文件的“已知大小”自动选择实现方式(实现见 `prepareMultipartFormData`): - **小文件模式(缓冲)**: - 条件:所有文件的已知大小之和 `<= 4MB`,且不存在未知大小的 `Reader`; - 行为:使用 `bytes.Buffer` 一次性在内存中构建完整 multipart 请求体; - 特点: - 逻辑简单,适合绝大多数小文件上传场景; - 内存占用与文件总大小相当; - 若配置了 `WithUploadProgress`,会在缓冲完成时直接回调一次(`written == total`),表示**已准备好要发送的数据**。 - **大文件模式(流式)**: - 条件: - 已知大小总和 `> 4MB`,或 - 存在 `size<=0` 的 `UploadFileReader`(未知大小 Reader); - 行为: - 使用 `io.Pipe + multipart.Writer` 边写边发,真正做到“流式上传”; - 进度统计包装在 pipe 层,因此 multipart 边界、字段头、文件头、结束符等 overhead 均会计入已发送字节; - 特点: - 内存占用稳定,不随文件大小线性增长,适合大文件或大量文件; - 若配置了 `WithUploadProgress`,会在实际写入网络数据时持续回调,可用于**实时进度条**; - 当所有文件大小都已知时,库会按真实 boundary 精确预估 `total`(包含 multipart overhead),保证 `written` 达到 `total` 时恰好对应“最后一个字节已发出”; - 当存在未知大小 Reader 时,`total` 为 `0`,此时只能展示“已发送字节数”。 ### 2. 上传进度回调:`WithUploadProgress` `WithUploadProgress` 用于对上传过程进行可观测化,函数签名: ```go func (r *Request) WithUploadProgress(hook func(written, total int64)) *Request ``` - **written**:截至当前回调时,已写入到底层 pipe(即将通过网络发送)的字节数; - **total**:预估的「请求体编码后总字节」(与 written 同基准,包含 multipart 边界/字段头/文件头/结束符等 overhead): - 对于小文件缓冲模式,会在缓冲完成后回调一次,`written == total`; - 对于流式上传: - 若所有文件大小均已知,库会按真实 boundary 精确预估 `total`,`written` 达到 `total` 时恰好代表“最后一个字节已写入 pipe”; - 若存在未知大小 Reader(`size<=0`)或使用 tar 流式上传,则 `total` 为 `0`,仅供展示已发送进度。 典型用法: ```go resp := c.SetBaseURL(baseURL). Post("/upload"). WithUploadProgress(func(written, total int64) { if total > 0 { fmt.Printf("上传进度:%s / %s\n", format.FormatBodySize(written), format.FormatBodySize(total)) } else { fmt.Printf("已发送:%s(总大小未知,流式上传)\n", format.FormatBodySize(written)) } }). UploadFiles("file", "./big.iso"). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } ``` > 注意: > - 该回调在请求体写入阶段触发,与响应体读取无关; > - 如需“下载进度”,请使用 `Response.SaveToFileAsync` 流式下载并通过 `task.Progress()` 查询,详见“响应体内存上限与流式下载”章节。 加上进度条的例子 记住不要在单元测试用使用,测试,单元测试中无法正常显示进度条 ```go package main import ( "context" "fmt" "os" "gitee.com/hexug/go-reqx/rest" "gitee.com/hexug/go-tools/format" "gitee.com/hexug/go-tools/logger" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" ) func main() { url := "http://httpbin.org" ctx := context.Background() c := rest.NewDefaultClient() filePath := "./rest/ubuntu-16.04.7-server-amd64.template" // 要上传的本地文件 f, err := os.Open(filePath) if err != nil { // 单元测试环境下,没有这个大文件就直接跳过,避免 CI 直接红 logger.L().Fatal("skip big file upload test, open %s failed: %v", filePath, err) } defer f.Close() fi, err := f.Stat() if err != nil { logger.L().Fatal(err) } totalSize := fi.Size() // 1) 创建 mpb 容器,设置进度条宽度 p := mpb.New(mpb.WithWidth(64)) name := "上传 ubuntu-16.04.7-server-amd64.template:" // 2) 创建一个「字节」进度条 bar := p.New( totalSize, // 自定义样式 mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding("-").Rbound("]"), mpb.PrependDecorators( // 左侧显示名称,并指定一个宽度即可,C 字段用默认值 decor.Name(name, decor.WC{W: len(name) + 1}), // 左侧显示平均剩余时间,完成后替换成 done decor.OnComplete( decor.AverageETA(decor.ET_STYLE_GO, decor.WC{W: 4}), "done", ), ), mpb.AppendDecorators( // 右侧显示 「已传 / 总大小」 decor.CountersKibiByte(" % .1f / % .1f"), // 以及百分比 decor.Percentage(), ), ) // 记录上一轮回调的 written,用于计算本次增量 var lastWritten int64 resp := c.SetBaseURL(url). Post("post"). // 这里保持你原来的 UploadFiles 写法 UploadFiles( "ubuntu-16.04.7-server-amd64.template", // form 字段名 "./rest/ubuntu-16.04.7-server-amd64.template", // 文件名 ). WithUploadProgress(func(written, total int64) { // 简化:不再依赖 bar.Total(),因为我们本身已经知道 totalSize // 只用 delta 来推进进度条即可 // 计算本次新增的字节数 delta := written - lastWritten if delta <= 0 { return } lastWritten = written // 推进进度条(mpb 内部是并发安全的) bar.IncrBy(int(delta)) }). Do(ctx) if resp.Error() != nil { logger.L().Fatal(resp.Error().Error()) } // 等待进度条渲染完成并退出 p.Wait() fmt.Println("上传完成,状态码:", resp.StatusCode()) fmt.Println("总大小:", format.FormatBodySize(totalSize)) } ``` ### 3. Docker 打包上传:`TarFiles` / `TarFilesStream` 在与 Docker 引擎交互时,官方 API 支持通过 `PUT /containers/{id}/archive` 将一个 tar 包写入容器内文件系统: - 请求方法:`PUT`; - 路径:`/containers/{id}/archive`; - 查询参数:`path=/target/dir`(容器内目标目录,**必传**); - 请求体:`Content-Type: application/x-tar` 的 tar 流。 `go-reqx` 提供了两组方法帮助你构造请求体: - `TarFiles`:在内存中打包目录为 tar,适合**小目录/小文件**; - `TarFilesStream`:基于 `io.Pipe` 流式打包目录,适合**大目录/大文件**(推荐)。 两者函数签名一致: ```go func (r *Request) TarFiles( folderPath string, // base 目录,例如 Dockerfile 所在目录 fileWhitelist []string, // 文件白名单 dirWhitelist []string, // 目录白名单 fileBlacklist []string, // 文件黑名单 dirBlacklist []string, // 目录黑名单 ) *Request func (r *Request) TarFilesStream( folderPath string, fileWhitelist []string, dirWhitelist []string, fileBlacklist []string, dirBlacklist []string, ) *Request ``` 内部会自动设置: ```go r.SetHeader("Content-Type", "application/x-tar") ``` #### 3.1 典型 Docker 上传示例 以下示例演示将本地 `./ubuntu-16.04.7-server-amd64.template` 文件(或目录)打包后上传到容器根目录 `/`: ```go ctx := context.Background() c := rest.NewDefaultClient() containerID := "8745d0f437aee5a56483c1e75ecdd72b5fc734644d4745e477022508ae21a669" folderPath := "./ubuntu-16.04.7-server-amd64.template" // 可以是单个文件或目录 resp := c.SetBaseURL("http://10.0.3.11:2376"). Put("/containers/" + containerID + "/archive"). // 注意:path 必须通过 Param 传递,Docker API 要求该参数不能为空 Param("path", "/"). // 推荐为大文件/目录启用流式打包上传 WithUploadProgress(func(written, total int64) { fmt.Printf("已发送 tar 数据:%s\n", format.FormatBodySize(written)) }). TarFilesStream(folderPath, nil, nil, nil, nil). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } fmt.Println("状态码:", resp.StatusCode()) ``` 说明: - `TarFilesStream` 内部使用 `io.Pipe + tar.Writer`,会边打包边发送,避免一次性在内存中构建 100MB+ 的 tar; - 若配置了 `WithUploadProgress`,会在写入 tar 数据时持续回调: - Docker 该接口响应体通常为空(`Content-Length: 0`),这是正常行为; - 进度日志显示的字节数是**请求体(tar 数据)发送量**,与响应体大小无关。 ##### 流式打包的启动时机与 ctx 绑定 `TarFilesStream` 采用「**延迟启动**」模型,使用方式与缓冲版本完全一致,仅启动时机有所不同: - 调用 `req.TarFilesStream(...)` 时**不会**立即启动后台 goroutine,方法只是登记一个延迟启动器; - 真正的 `io.Pipe` 与生产者 goroutine 在 `Do(ctx)` 阶段才被建立,并将 `ctx` 绑定到内部 watcher; - 一旦 `ctx` 被取消(例如 `context.WithTimeout` 到期、`cancel()` 被调用),内部会立即关闭 pipe, 生产者 goroutine 会在下一次写入时退出,不会卡住; - 打包过程中也会主动响应 `ctx` 取消:`filepath.Walk` 进入每一个文件前会检查 `ctx`; 对超大单文件,`io.Copy` 内部使用 ctx 感知的 reader 包装,避免单文件拷贝期间无法被中断。 使用建议: ```go // 1) 推荐:传入带超时或可取消的 ctx,便于在异常场景下迅速回收资源 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() resp := client.Put(url). Param("path", "/"). TarFilesStream("./big-folder", nil, nil, nil, nil). Do(ctx) // 2) 如果链式调用上发生了前置错误(例如参数校验失败),库不会启动任何后台 goroutine, // 无需担心“调了 TarFilesStream 但没调 Do”导致的资源泄漏。 // 3) 流式请求体不可重放,不要与重试策略组合(库会自动降级为不重试,并打印 Warn 日志)。 ``` #### 3.2 白名单 / 黑名单过滤 `TarFiles` / `TarFilesStream` 的白名单和黑名单参数用于控制哪些文件会被打包: - `fileWhitelist` / `fileBlacklist`:按文件名(或相对路径)控制; - `dirWhitelist` / `dirBlacklist`:按目录名(或相对路径)控制; - 传入 `nil` 或空切片表示“不做限制”,会将整个目录递归打包。 一个常见的用法是排除 `.git`、`node_modules` 等目录: ```go resp := c.SetBaseURL("http://10.0.3.11:2376"). Put("/containers/" + containerID + "/archive"). Param("path", "/app"). TarFilesStream( "./app", // 本地项目目录 nil, // 文件白名单(留空) nil, // 目录白名单(留空) []string{"*.log"}, // 文件黑名单(示例) []string{".git", "node_modules"}, // 目录黑名单 ). Do(ctx) if resp.Error() != nil { panic(resp.Error()) } ``` > 提示:`TarFiles` 与 `TarFilesStream` 的差别只在于是否在内存中缓冲 tar 内容,使用方式完全一致,可以根据文件规模灵活选择。 你可以把这些上传相关的 API(`UploadFiles`、`UploadFileReader`、`WithUploadProgress`、`TarFiles`、`TarFilesStream`)理解为对标准 HTTP 上传能力的一层“增强适配”,既兼容常见 Web 接口,又适用于 Docker 镜像/大文件等场景。 --- ## 响应体内存上限与流式下载 为了避免下载大响应体时把整个 body 一次性灌进内存(极端情况会触发 OOM),`go-reqx` 在 `Response` 上做了两件事: 1. **基于 Content-Type / Content-Length 的"流式判定"**:明显的二进制类型(`octet-stream / image / video / audio / zip / tar / pdf / event-stream` 等)会被自动识别为"流式响应";JSON / 文本类型也会结合 `Content-Length` 与内存上限阈值进一步判定是否需要按流式处理。 2. **带上限的内存读取**:即便 `Content-Length` 缺失或被服务端伪造,`Raw() / Into() / Text()` 内部读取响应体时也会通过 `io.LimitReader(limit+1)` 探测超限,超出上限直接返回明确错误,提示调用方切到 `SaveTo / SaveToFile`。 > 默认上限:**32MB**(`rest.DefaultMaxInMemoryBody`)。该值同时承担"流式判定阈值"和"内存读取硬上限"两个角色,逻辑保持一致。 ### 行为速查表 | 响应类型 | Content-Length | 行为 | | --- | --- | --- | | `application/json`、`text/*`、`xml/html/urlencoded/graphql` | 未知或 ≤ 上限 | 走内存路径:`Raw/Into/Text` 可用;超过上限会被 `readAllWithLimit` 拦截并报错 | | `application/json`、`text/*`、… | > 上限 | 直接判定为流式:`Raw/Into/Text` 报错,请改用 `SaveTo/SaveToFile` | | `image/*`、`video/*`、`audio/*`、`octet-stream`、`zip/tar/gzip/pdf` 等 | 任意 | 直接判定为流式:必须使用 `SaveTo/SaveToFile/SaveToFileAsync` | | `text/event-stream`(SSE) | 任意 | 直接判定为流式 | | 缺少 Content-Type | 任意 | 默认按非流式(API 场景兜底);仍受内存上限保护 | ### 使用方式 #### 1. 一次性读取(适合 API 场景) ```go resp := c.Get("/v1/users/1").Do(ctx) var user User if err := resp.Into(&user); err != nil { // 如果命中"响应体超过内存上限 N 字节",会建议你改用 SaveTo log.Fatal(err) } ``` #### 2. 流式下载到文件(推荐用于大文件) ```go resp := c.Get(largeFileURL).Do(ctx) if err := resp.Error(); err != nil { log.Fatal(err) } n, err := resp.SaveToFile("./big.iso") // SaveTo / SaveToFile 内部会按 Content-Encoding 自动解压,并以 io.Copy 流式写出, // 不会把整个 body 装进内存。 ``` 也可以写到任意 `io.Writer`: ```go resp.SaveTo(myWriter) ``` #### 3. 异步下载 + 进度查询 ```go task, err := resp.SaveToFileAsync(ctx, "./big.iso") if err != nil { log.Fatal(err) } go func() { t := time.NewTicker(time.Second) defer t.Stop() for { select { case <-task.Done(): return case <-t.C: written, total := task.Progress() fmt.Printf("已下载 %d / %d\n", written, total) } } }() <-task.Done() if err := task.Err(); err != nil { log.Fatal(err) } ``` ### 调整内存上限:`SetMaxInMemoryBody` 如果默认 32MB 不满足业务需求,可以在 `Client` 上统一调整: ```go c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetMaxInMemoryBody(128 * 1024 * 1024) // 128MB ``` 取值约定: - `n > 0`:使用指定字节数作为上限; - `n = 0`:恢复默认值(`DefaultMaxInMemoryBody`,32MB); - `n < 0`:**关闭上限保护**(仅用于完全可信的内网调用,不推荐对公网/不可控来源使用)。 > 设置后会同时影响: > - `isStreamBody` 中针对 JSON / 文本类型的"过大转流式"阈值; > - `readBody` 中针对所有非流式响应体的内存硬上限。 > > 因此你通常**只需要调一个值**,无需在多个地方分别配置。 ### 并发与"读一次"语义 - `Raw() / Into() / Text()` 在同一个 `Response` 上可以**安全地重复调用**:第一次调用会把响应体读入内存并缓存到 `r.bf`,后续调用直接复用缓存,不会重复消费底层 body 流。 - 内部使用 `sync.Once` 实现,第一次读取(包括解压 + IO)即便耗时较长,**也不会阻塞同一 `Response` 上的只读 getter**(`StatusCode() / Headers() / Cookies() / Url() / GetHeader()` 等)。 - `SaveTo / SaveToFile / SaveToFileAsync` 与 `Raw/Into/Text` **互斥**:一旦响应体被 `SaveTo*` 流式消费,再调用 `Raw/Into/Text` 不会拿到内容(这是为了避免在大文件场景下把内容意外缓存到内存)。请在调用方按需二选一。 - **非流式响应(API 场景)的 body 会在 `Do(ctx)` 返回前就读完并归还连接**:这意味着即便业务侧只看 `StatusCode()` / `Headers()`、不再调用 `Into/Raw/Text`,底层 HTTP 连接也会立刻回到 `keep-alive` 池,不会出现"连接长期挂在 read 上耗尽连接资源"的隐式泄漏。 - **流式响应(`SaveTo*` 路径)请按需自行消费**:如果业务侧拿到 `resp` 后没有调用 `SaveTo*` 也没有调用 `Error()`,可以使用 `defer resp.Close()` 主动释放底层连接;即便忘记调用,库内部还有一道 `runtime.AddCleanup`(Go 1.24+)兜底,会在对象被 GC 时关闭未消费的 body——相比旧版 `SetFinalizer`,`AddCleanup` 不会复活对象、不阻碍 GC、且回调不持有 `*Response` 本身,资源回收更彻底;但这仍只是"最后一道防线",**显式 `defer resp.Close()` 更及时也更可控**。 资源释放推荐写法: ```go resp := client.Get(url).Do(ctx) defer resp.Close() // 兜底释放,幂等可重入;非流式路径下其实是 no-op if err := resp.Error(); err != nil { return err } return resp.Into(&out) ``` ### 何时应该改用流式 API? 满足以下任一条件,建议直接走 `SaveTo / SaveToFile`: - 响应大小可能超过几十 MB; - 接口返回的是文件下载、镜像、归档等天然二进制内容; - 服务端不返回 `Content-Length`(chunked),且无法限定上限; - 需要边下载边写盘 / 转发,避免占用峰值内存。 --- ## 请求体压缩(上行) `go-reqx` 在响应侧会按 `Content-Encoding` 自动解压(支持 `gzip / deflate / br / zstd / snappy`); 在请求侧也提供了对称的**显式开启**式压缩能力,由 `CompressBody*` 系列方法控制。 > **默认行为:关闭**。只有显式调用 `CompressBody / CompressBodyWithLevel` 后才会对请求体进行压缩,避免对小 body 形成负收益、也避免被默认不解压请求体的服务端拒绝。 ### 行为速查表 | 场景 | 是否压缩 | 备注 | | --- | --- | --- | | 未调用 `CompressBody*` | ❌ | 默认行为,与历史版本完全兼容 | | 调用 `CompressBody()`,未指定算法 | ✅ gzip | 兼容性最好,作为推荐默认 | | 调用 `CompressBody(EncodingZstd \| EncodingBr \| EncodingDeflate \| EncodingSnappy)` | ✅ | 服务端必须支持对应解压算法 | | 已知请求体大小 `< 1KB`(默认阈值) | ❌ | 小 body 压缩通常负收益,自动跳过 | | 调用方已通过 `SetHeader("Content-Encoding", ...)` 显式声明 | ❌(不二次叠加) | 视为"上层已自行压缩" | | 流式请求体(`TarFilesStream` / 大文件 multipart) | ✅ | 不参与阈值判断,直接进入流式压缩管道 | | 启用了重试(`SetRetry / WithRetryPolicy`) | ✅ 重试安全 | 每次重试新建一份压缩 writer,原始 body 通过 `bodySnapshot` 重放 | ### 支持的算法 | 算法常量 | 头部值 | 适用场景 | | --- | --- | --- | | `rest.EncodingGzip`(默认) | `gzip` | 通用首选,几乎所有后端框架可解 | | `rest.EncodingDeflate` | `deflate` | 通用次选,兼容性略低于 gzip | | `rest.EncodingZstd` | `zstd` | 压缩比/速度均衡,需服务端显式支持 | | `rest.EncodingBr` | `br` | 压缩比好,但请求体侧服务端支持率较低,慎用 | | `rest.EncodingSnappy` | `snappy` | 速度极快、压缩比一般,常用于服务内部通信 | ### 使用方式 #### 1. 默认 gzip 压缩 ```go resp := c.Post("/v1/ingest"). DataJs(payload). CompressBody(). // 默认 gzip Do(ctx) ``` #### 2. 指定算法 ```go // 用 zstd 上传大 JSON / Protobuf c.Post(url).DataProtobuf(msg). CompressBody(rest.EncodingZstd). Do(ctx) ``` #### 3. 指定算法 + 压缩级别 ```go // gzip 1=最快, 9=最大压缩;CompressLevelDefault 走算法默认值 c.Post(url).DataJs(payload). CompressBodyWithLevel(rest.EncodingGzip, 9). Do(ctx) ``` 不同算法的 level 语义: - `gzip`:1 ~ 9,传 `CompressLevelDefault` 走 `gzip.DefaultCompression(-1)`; - `deflate`:1 ~ 9,传 `CompressLevelDefault` 走 `flate.DefaultCompression(-1)`; - `zstd`:1=Fastest / 3=Default / 7=Better / 11=Best(不在表内会回退到 Default); - `br`:0 ~ 11,传 `CompressLevelDefault` 走 `brotli.DefaultCompression(6)`; - `snappy`:不支持 level,传值会被忽略。 #### 4. 调整压缩阈值 ```go // 自定义最小压缩字节阈值(默认 1KB) c.Post(url).DataJs(payload). CompressBody(rest.EncodingGzip). CompressBodyMinSize(4 * 1024). // < 4KB 跳过压缩 Do(ctx) ``` > 阈值仅对"已知请求体大小"的非流式场景生效(如 `DataJs / DataXML / RowText / FormData` 等); > 流式 body(`TarFilesStream` / 大文件 multipart pipe)由于无法预知大小,不参与阈值判断。 #### 5. 禁用压缩(默认行为,仅作示例) ```go // 不调用 CompressBody* = 不压缩;与不写一样 c.Post(url).DataJs(payload).Do(ctx) ``` > 注意 `DisableCompression()` 关闭的是**响应解压**(删除 `Accept-Encoding`),与请求体压缩开关相互独立。 ### 实现要点 - **流式管道**:内部使用 `io.Pipe + 后台 goroutine`,**不会**先在内存里把整个请求体压缩完再发送,对大 body 友好; - **自动调整请求头**:成功包装后自动写 `Content-Encoding`,并删除 `Content-Length`(实际由 `net/http` 走 `Transfer-Encoding: chunked`); - **进度统计退化**:压缩后总字节数无法精确预知,`uploadTotal` 会被重置为 0;`uploadHook` 仍会持续收到 `written` 累加值; - **重试安全**:`bodySnapshot` 会被升级为"原始 reader → 全新压缩 writer → pipe reader",每次重试都能正确重放; - **不与响应解压互相影响**:`Accept-Encoding`(响应)与 `Content-Encoding`(请求)是完全独立的两套机制。 ### 兼容性提醒 HTTP 请求体压缩并不是所有服务端默认都支持: - **Go / Java Spring / .NET Core / Node** 等主流后端框架通常需要显式开启对应中间件; - **Nginx 默认不会自动解压请求体**,需要 `ngx_http_gunzip_module` 或上层应用解; - 典型适用场景:OTLP 上报、日志/埋点上报、内部服务间大 JSON / Protobuf 传输等可控链路; - 如果不确定对端是否支持,建议保持默认(不压缩)。 --- ## 日志系统 ### 日志级别与创建客户端 `rest` 内置了 `LogLevel` 枚举: ```go type LogLevel int const ( LogLevelDebug LogLevel = iota LogLevelInfo LogLevelWarn LogLevelError LogLevelFatal LogLevelPanic ) ``` 常见用法: - **在创建客户端时指定日志级别**: ```go c := rest.NewLogLevelClient(rest.LogLevelInfo) ``` - **在运行期动态调整日志级别**: ```go c := rest.NewLogLevelClient(rest.LogLevelDebug) c.SetLogLevel(rest.LogLevelInfo) // 将该 Client 的日志级别切换为 info ``` 这种设计的好处: - **全局统一控制**:通过 `NewLogLevelClient` 在创建时指定级别,后续可随时通过 `SetLogLevel` 调整。 - **便于按环境切换**:比如生产环境默认 `info`,排查问题时临时切到 `debug`。 ### 自定义 Logger:实现接口并注入 `rest.Client` 使用了一个自定义的 `Logger` 接口(在 `[rest/client_model.go](/rest/client_model.go)`): ```go // Logger 日志接口,用于统一日志输出 // 这样可以在不同业务中设置统一的logger实现 type Logger interface { Debug(args ...interface{}) Debugf(format string, args ...interface{}) Debugw(msg string, keysAndValues ...interface{}) Debugln(args ...interface{}) Info(args ...interface{}) Infof(format string, args ...interface{}) Infow(msg string, keysAndValues ...interface{}) Infoln(args ...interface{}) Warn(args ...interface{}) Warnf(format string, args ...interface{}) Warnw(msg string, keysAndValues ...interface{}) Warnln(args ...interface{}) Error(args ...interface{}) Errorf(format string, args ...interface{}) Errorw(msg string, keysAndValues ...interface{}) Errorln(args ...interface{}) Fatal(args ...interface{}) Fatalf(format string, args ...interface{}) Fatalw(msg string, keysAndValues ...interface{}) Fatalln(args ...interface{}) Panic(args ...interface{}) Panicf(format string, args ...interface{}) Panicw(msg string, keysAndValues ...interface{}) Panicln(args ...interface{}) SetLevel(level string) } ``` 默认使用的是 `gitee.com/hexug/go-tools/logger` 中的实现,你也可以: - 实现一份自己的 `Logger`(比如基于 `zap` 或 `logrus` 的适配器)。 - 通过 `SetLogger` 注入到 `rest.Client`: ```go c := rest.NewLogLevelClient(rest.LogLevelDebug) // 这里以 go-tools/logger 为例,自定义一些配置 l := logger.L() l.SetShowFullPath(true) l.SetLevel(rest.LogLevelInfo.String()) l.SetPathMsgSeparator(true) l.SetSeparator("/////////////") // 注入自定义 logger resp := c.SetBaseURL(baseURL). SetLogger(l). Get("/get"). Do(ctx) ``` 这样可以在不同项目里 **重用自己的日志规范**,又不用改动 `rest` 的内部实现。 --- ## 请求耗时阶段说明 `go-reqx` 把一次完整的 HTTP 请求拆成 **五个互不重叠的阶段**,每条请求在 `INFO` 日志里都会输出一行 `完整请求计时汇总`,让"时间到底花在哪"一目了然: | 阶段 | 时间区间 | 含义 | | --- | --- | --- | | 请求准备 | `RequestCreatedAt → RequestSentAt` | 链式 setter / build HTTP 请求 / 编码请求体的开销 | | 网络往返 | `RequestSentAt → ResponseRecvAt` | 含 DNS / TCP / TLS / 等待响应首字节(TTFB) | | Body读取 | `ResponseRecvAt → BodyReadAt` | 读完整个响应体 + 按 `Content-Encoding` 自动解压 | | 反序列化 | `BodyReadAt → dataParsedAt` | JSON/XML/Msgpack/CBOR/Protobuf 等 `Decode` 耗时 | | 总耗时 | `RequestCreatedAt → 终点` | 终点取上述各阶段中实际触达的最后一个 | > 各阶段是 `Response` 上的对应 `time.Time` 字段,重试场景下记录的是"最终成功"那一次的时间点;历史每一次尝试的耗时仍可通过 `RetryAttempts() → Timings` 拿到(粒度更细:DNS/TCP/TLS/TTFB/ContentTransfer)。 ### 阶段在不同 API 路径下的归属 非流式响应(典型 API 场景:`Get/Post + Into/Raw/Text`): - `请求准备`、`网络往返`、`Body读取`:都在 `Do(ctx)` 返回前就完成(库内部对非流式响应做 eager-read,把 body 读完并立即归还连接); - `反序列化`:仅在 `Into() / IntoAs()` 调用时发生,会写入 `dataParsedAt`;`Raw()` / `Text()` 不计入该阶段; - `Do(ctx)` 末尾会输出一条 `完整请求计时汇总`,业务侧即便只调 `StatusCode()` 也能直接看到"网络/Body 耗时分布"。 流式响应(`SaveTo / SaveToFile / SaveToFileAsync`): - 进入 `Do(ctx)` 时只能拿到`请求准备`、`网络往返`两个阶段(body 还没读); - 调用 `SaveTo*` 后,库会在 body 拷贝完成时写入 `BodyReadAt`,并额外打印一条 `流式下载完成` 日志,包含写出字节数和平均速率,便于观察大文件下载性能; - 流式响应不会经过 `反序列化` 阶段。 ### 日志示例 ```text # 普通 JSON API:用户调了 Into completed_request_summary 方法=GET URL=https://api.x/u/1 请求准备=0ms 网络往返=12ms Body读取=3ms 反序列化=1ms 总耗时=16ms # 普通 JSON API:用户只看 StatusCode 不读 body completed_request_summary 方法=GET URL=https://api.x/health 请求准备=0ms 网络往返=12ms Body读取=0ms 总耗时=12ms # 大文件下载 completed_request_summary 方法=GET URL=https://cdn.x/big.iso 请求准备=0ms 网络往返=12ms 总耗时=12ms 流式下载完成 方法=GET URL=https://cdn.x/big.iso Body读取=4.2s 字节数=128MB 平均速率=30.5 MB/s ``` ### 排障建议 - 慢在 **请求准备** → 业务侧 setter 过多 / 序列化大对象慢; - 慢在 **网络往返** → 网络/对端处理慢,可结合 `Response.TraceTimings()` 进一步拆 DNS / TCP / TLS / TTFB; - 慢在 **Body读取** → 对端发送速率低 / 带宽不足 / 解压算法重; - 慢在 **反序列化** → payload 大或 schema 复杂,可考虑换更高效的 negotiator(如 Msgpack/CBOR 替代 JSON)。 --- ## 链路追踪:OpenTelemetry + Jaeger/Zipkin `go-reqx` 没有强制绑定具体的链路追踪系统,而是通过 **请求 Hook** 接口来对接 OpenTelemetry。 核心接口定义在 `[rest/client_model.go](/rest/client_model.go)`: ```go // RequestHook 用于对每次 HTTP 请求进行统一的前后拦截,常用于链路追踪/监控等。 // ctx: 本次调用的 Context // req: 即将发出的 HTTP 请求(可以在这里注入 trace header) // 返回值: // newCtx: 可选的新 Context(比如携带了 span),如果不需要可返回原 ctx // afterFunc: 请求结束后调用的函数,入参是服务端返回的 *http.Response 和 error type RequestHook func(ctx context.Context, req *http.Request) (newCtx context.Context, afterFunc func(resp *http.Response, err error)) ``` 在 `[rest/example/trace/hook.go](/rest/example/trace/hook.go)` 中,已经给出一个 **标准的 OpenTelemetry Hook 实现**:`NewReqxOtelHook`。 ### 1. 使用 Jaeger 进行链路追踪 在 `[rest/tracing_test.go](/rest/tracing_test.go)` 的 `TestTracingJaeger` 中,演示了完整流程: ```go func TestTracingJaeger(t *testing.T) { // 1. 初始化 Jaeger TracerProvider shutdown, err := jaeger.InitTracer(ctx, "go-reqx-client-demo") if err != nil { log.Fatalf("init tracer failed: %v", err) } defer func() { _ = shutdown(context.Background()) }() // 2. 获取一个 tracer(可按组件名区分) tcer := otel.Tracer("go-reqx-client") // 3. 创建带 OpenTelemetry Hook 的客户端 client := rest.NewDefaultClient(). SetRequestHook(trace.NewReqxOtelHook(tcer)) // 4. 发起请求(ctx 中若已有上游 trace,将自动串联;否则从这里开始) resp := client.SetBaseURL(url). Get("/get"). SetHeader("aaa", "b"). Param("aaa", "ccc"). Param("aaa", "bbb"). Do(ctx) if resp.Error() != nil { t.Fatal(resp.Error().Error()) } } ``` 其中 `jaeger.InitTracer` 的实现位于 `[rest/example/trace/jaeger](/rest/example/trace/jaeger)` 目录下,已经帮你封装好了: - 使用 OTLP/HTTP 或 Zipkin 风格的 Jaeger 采集端点 - 设置 `ServiceName`、版本等 Resource 信息 - 返回 `shutdown` 函数用于优雅关闭 你在真实服务中可以直接参考该实现: ```go ctx := context.Background() shutdown, err := jaeger.InitTracer(ctx, "gateway-service") if err != nil { log.Fatalf("init tracer failed: %v", err) } defer func() { _ = shutdown(ctx) }() tracer := otel.Tracer("gateway-service") client := rest.NewDefaultClient(). SetRequestHook(trace.NewReqxOtelHook(tracer)) ``` ### 2. 使用 Zipkin 进行链路追踪 `[rest/example/trace/zipkin_tp](/rest/example/trace/zipkin_tp)` 中提供了 Zipkin 的初始化实现,对应 `TestTracingZipkin`: ```go func TestTracingZipkin(t *testing.T) { shutdown, err := zipkin_tp.InitTracer(ctx, "go-reqx-client-demo") if err != nil { log.Fatalf("init tracer failed: %v", err) } defer func() { _ = shutdown(context.Background()) }() tcer := otel.Tracer("go-reqx-client") client := rest.NewDefaultClient(). SetRequestHook(trace.NewReqxOtelHook(tcer)) resp := client.SetBaseURL(url). Get("/get"). Do(ctx) if resp.Error() != nil { t.Fatal(resp.Error().Error()) } } ``` 使用方式与 Jaeger 基本一致,只是 `InitTracer` 的实现和上报目标不同。 ### 3. `NewReqxOtelHook` 做了什么? `NewReqxOtelHook` 主要完成: - 在出站 HTTP 请求前,创建 `SpanKindClient` 类型的 Span - 标注标准 HTTP 语义属性: - `semconv.HTTPRequestMethodKey`:HTTP 方法 - `semconv.URLFull`:完整 URL - 以及响应阶段的 `HTTPResponseStatusCodeKey` 等 - 通过 `propagation.TextMapPropagator` 将 Trace 上下文注入到请求头 - 在回调中: - 记录错误(`RecordError`) - 根据状态码设置 Span Status - 从 `SpanContext` 里拿 `TraceID`,打到日志里 - 可选:将 `TraceID` 写入自定义请求头(例如 `X-Trace-ID`)以兼容旧系统 配合入口的 gin/go-restful 中间件,你可以获得 **完整的跨服务调用链**。多服务串联的完整例子见: - `[rest/example/example_service](/rest/example/example_service)` 多服务 Demo - 对应的集成测试:`TestGatewayMultiServiceTracing`(在 `[rest/tracing_test.go](/rest/tracing_test.go)`) ### 4. 与 otelhttp.Transport 的对比 除了 RequestHook 方案,你也可以选择在底层 `http.Transport` 层使用官方的 `otelhttp.NewTransport`: ```go client := rest.NewDefaultClient() baseTransport := client.GetTransport() client.UseRoundTripper(otelhttp.NewTransport(baseTransport)) ``` 注意: - **两种方式(二选一)**: - 使用 `SetRequestHook(NewReqxOtelHook(...))` - 或使用 `UseRoundTripper(otelhttp.NewTransport(...))` - 不要在同一请求上同时使用两种方式,以免重复打点。 --- ## 超时控制:整体超时 + 粒度超时 HTTP 请求的"超时"并不是一个单一概念,它至少覆盖了 **TCP 拨号、TLS 握手、等待响应头、读取响应体** 四个阶段。`go-reqx` 把这些阶段拆成独立的 setter,让你按需精细化控制;同时保留一个"总闸刀"用于兜底,绝大多数业务只需要配它一个。 ### 一张总览表 | Setter | 作用阶段 | 底层字段 | 默认值 | Client / Request 都可用 | |---|---|---|---|---| | `SetTimeout(d)` | **整体**:拨号 + 握手 + 写请求 + 等响应头 + **读响应体** | `http.Client.Timeout` | `0`(不限制) | ✅ | | `SetConnectTimeout(d)` | 仅 TCP 拨号 | `net.Dialer.Timeout` | `30s` | ✅ | | `SetTLSHandshakeTimeout(d)` | 仅 TLS 握手 | `Transport.TLSHandshakeTimeout` | `10s` | ✅ | | `SetResponseHeaderTimeout(d)` | 写完请求到收到响应头之间 | `Transport.ResponseHeaderTimeout` | `0`(不限制) | ✅ | | `SetIdleConnTimeout(d)` | 空闲长连接的最大保活时长 | `Transport.IdleConnTimeout` | `90s` | ✅ | | `SetExpectContinueTimeout(d)` | `Expect: 100-continue` 握手等待时长 | `Transport.ExpectContinueTimeout` | `10s` | ✅ | | `PerAttemptTimeout(d)` | 重试循环里**单次尝试**的超时(不含退避) | `context.WithTimeout` 包裹 | 未设 = 关闭 | ✅(仅 Request) | > 默认值与 Go 标准库 `http.DefaultTransport` 完全一致,迁移到 go-reqx 不会改变默认行为。 ### 整体 vs 粒度:谁管哪一段 `SetTimeout` 是从拨号一直管到响应体读完的"全程总闸";其他 5 个粒度超时是各自阶段的"局部小闸"。它们可以同时配置,互不冲突: ```text 连接建立 ─── TLS 握手 ─── 写请求 ─── 等响应头 ─── 读响应体 ↑ ↑ ↑ ↑ Connect TLSHandshake ResponseHeader (只受 Timeout 管) └──────────────┴────────────┬──────┴──────────────────┘ │ SetTimeout(整体兜底,含 Body 读取) ``` 要点: - **小闸先跳**:拨号阶段卡死,3 秒就能由 `SetConnectTimeout` 砍掉,无需等整体超时。 - **总闸兜底**:哪怕响应头很快返回,`SetTimeout` 仍然会限制读 Body 的总时长,防止 Body 慢悠悠读到天荒地老。 - **`SetTimeout(0)` = 不设上限**:默认值,配合 `context.WithTimeout(ctx, ...)` 也能起到一样的兜底效果。 ### 两层覆盖:Client 模板 + Request 覆盖 所有超时 setter 在 Client 与 Request 上同名同语义,遵循"**未设即继承、设了即覆盖**": ```go c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetConnectTimeout(3 * time.Second). // 模板默认:3s 拨号 SetTimeout(30 * time.Second) // 模板默认:30s 整体 // 普通请求:继承上面所有默认 c.Get("/v1/ping").Do(ctx).Into(&pong) // 长任务请求:仅本次覆盖整体超时为 5 分钟,拨号超时仍继承 3s c.Get("/v1/export"). SetTimeout(5 * time.Minute). Do(ctx).Into(&result) ``` Request 上的覆盖仅在该次 `Do` 内部克隆出独立的 `*http.Client / *http.Transport` 时生效,**不会修改模板 Client**,多 goroutine 并发下安全。 ### 三种典型场景 **场景 A:内部服务调用(绝大多数业务)** 只配整体超时即可,其它粒度沿用默认: ```go c := rest.NewDefaultClient(). SetBaseURL("http://internal-svc"). SetTimeout(10 * time.Second) // 一行兜底 ``` **场景 B:调用外部不可信网络(公网 API、第三方)** 补上前置阶段的"早期失败检测",避免被慢服务卡死: ```go c := rest.NewDefaultClient(). SetBaseURL("https://thirdparty.example.com"). SetConnectTimeout(3 * time.Second). // 拨不通秒级失败 SetTLSHandshakeTimeout(5 * time.Second). // 握手卡死秒级失败 SetResponseHeaderTimeout(10 * time.Second). // 服务端必须 10s 内回响应头 SetTimeout(60 * time.Second) // 整体兜底(含 Body) ``` **场景 C:大文件下载 / 长连接拉流** 整体超时要放宽到能容纳完整传输,前置阶段反而要卡严: ```go c.Get("/big-file"). SetConnectTimeout(5 * time.Second). SetResponseHeaderTimeout(15 * time.Second). SetTimeout(30 * time.Minute). // 给 Body 充分时间 Do(ctx).Into(&dst) ``` > 反例:只配 `SetTimeout(30s)` 去下 1GB 文件——30s 一到,正在读到一半的 Body 也会被强制中断。 ### 重试场景:`PerAttemptTimeout` `SetTimeout` 限制的是"整次请求",开启重试后这"整次"包括了所有重试 + 退避;如果想限制"每一次尝试"自己不能跑太久,请用 `PerAttemptTimeout`: ```go ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp := client.Get("/flaky"). SetRetry(3, 200*time.Millisecond). PerAttemptTimeout(2 * time.Second). // 每次尝试最多 2s Do(ctx) ``` `PerAttemptTimeout` 与 `SetTimeout` 互不冲突——前者管"单次尝试",后者(或外部 `ctx`)管"整体上限",两者叠加即可覆盖"快失败 + 重试兜底"的需求。详见[重试机制](#重试机制可插拔策略--幂等保护)章节的 `PerAttemptTimeout` 小节。 ### 速记口诀 > **`SetTimeout` 是全程总闸**; > **`SetConnectTimeout / SetTLSHandshakeTimeout / SetResponseHeaderTimeout / SetIdleConnTimeout / SetExpectContinueTimeout` 是分段小闸**; > **`PerAttemptTimeout` 是重试循环里的单次小闸**。 > > 普通业务:一个 `SetTimeout` 搞定; > 公网调用:补前置三段(Connect + TLSHandshake + ResponseHeader); > 大文件 / 流式:放宽整体、卡严前置; > 重试场景:再加 `PerAttemptTimeout`。 --- ## 重试机制:可插拔策略 + 幂等保护 `go-reqx` 在 `Do` 方法内部内置了一个可插拔的重试循环,覆盖了"网络抖动"和"5xx 偶发"这两类最常见的瞬时故障。设计上遵循"**默认安全,按需开启**"的原则:未显式配置时,行为与旧版完全一致(不重试)。 ### 核心设计 - **策略接口化**:所有重试行为都通过 `rest.RetryPolicy` 抽象,内置 `DefaultRetryPolicy` 覆盖 90% 的场景;高级用户可以实现自己的策略。 - **幂等优先**:默认仅对幂等方法(`GET / HEAD / PUT / DELETE / OPTIONS`)重试,`POST / PATCH` 等非幂等方法默认会被跳过,避免"无声地重复提交订单"。 - **请求体可重放**:所有内置的请求体编码出口(`DataJs / DataXML / RowText / FormData / 内存模式 multipart / 内存模式 tar`)都会同步设置 `bodySnapshot`,重试时自动重新构造 `io.Reader`。 - **流式上传自动降级**:当请求体来自 `TarFilesStream` 或大文件流式 `multipart`(`io.Pipe`)时,`bodySnapshot` 为 `nil`,库会自动**降级为不重试**并打 `Warn` 日志,避免发送空 body。 - **遵守 `Retry-After`**:服务端返回 `Retry-After` 头时(数字秒或 HTTP-Date)优先使用其值,再叠加用户配置的最大间隔与抖动。 - **响应 Context**:等待退避期间会监听 `ctx.Done()`,调用方取消 / 超时时立即返回。 - **DNS 失败默认不重试**:`no such host` 通常短时间内重试也不会成功(域名拼错 / DNS 不通),默认会被识别为不可重试,避免浪费 DNS 配额。如有特殊需求,可通过自定义 `RetryPolicy` 覆盖。 ### 何时会重试 / 不会重试(默认策略) | 场景 | 行为 | |------|------| | `5xx` 状态码 | ✅ 重试(`RetryOn5xx=true`) | | `408 Request Timeout` / `429 Too Many Requests` | ✅ 重试 | | 连接被重置 / `i/o timeout` / `broken pipe` / `EOF` / TLS 握手超时 | ✅ 重试 | | `connection refused` | ✅ 重试(服务可能正在重启) | | `context.DeadlineExceeded` | ✅ 重试 | | `no such host`(DNS 解析失败) | ❌ 不重试 | | `context.Canceled`(调用方主动取消) | ❌ 立即返回 | | `POST` / `PATCH` 方法 | ❌ 不重试(除非显式声明幂等) | | 编码 / 协议级错误 | ❌ 不重试 | ### 最常用的开启方式:`SetRetry(maxAttempts, baseDelay)` ```go client := rest.NewDefaultClient() // 最多 3 次(含首次);首次失败后等 200ms,之后按 2 倍指数退避 resp := client.Get("/api/users"). SetRetry(3, 200*time.Millisecond). Do(ctx) if err := resp.Error(); err != nil { log.Fatal(err) } // 通过 RetryAttempts 观察重试历史 for _, a := range resp.RetryAttempts() { log.Printf("attempt=%d code=%d cost=%s reason=%s err=%v", a.Attempt, a.StatusCode, a.Duration, a.Reason, a.Err) } ``` ### Client 级别的默认策略:`SetDefaultRetry` 如果你希望整个客户端的所有请求都默认启用重试(典型的"网关型 Client"场景),可以在 Client 上设置一次: ```go client := rest.NewDefaultClient() client.SetDefaultRetry(rest.NewDefaultRetryPolicy()) // 内置默认配置:3 次 + 200ms 起步 + RetryOn5xx=true + 408/429 resp := client.Get("/users").Do(ctx) // 自动重试 resp2 := client.Get("/x").WithRetryPolicy(nil).Do(ctx) // 单独关闭某个请求的重试 ``` ### 自定义策略:`WithRetryPolicy` ```go // 业务侧承诺 POST 接口幂等(带了 Idempotency-Key),允许重试 5xx / 503 policy := &rest.DefaultRetryPolicy{ MaxAttempts: 5, BaseDelay: 300 * time.Millisecond, MaxDelay: 10 * time.Second, Multiplier: 2.0, Jitter: 0.3, RetryOn5xx: true, RetryOnStatus: []int{http.StatusTooManyRequests, http.StatusServiceUnavailable}, AllowMethods: map[string]bool{http.MethodPost: true}, // 显式允许 POST 重试 } resp := client.Post("/api/order"). WithRetryPolicy(policy). DataJs(payload). Do(ctx) ``` 也可以实现你自己的 `RetryPolicy`: ```go type AlwaysOnceMore struct{} func (a *AlwaysOnceMore) ShouldRetry(ctx context.Context, attempt int, resp *http.Response, err error) rest.RetryDecision { if attempt >= 2 { return rest.RetryDecision{ShouldRetry: false, Reason: "max-attempts"} } if err != nil || (resp != nil && resp.StatusCode >= 500) { return rest.RetryDecision{ShouldRetry: true, Wait: time.Second, Reason: "custom"} } return rest.RetryDecision{ShouldRetry: false, Reason: "ok"} } ``` ### 单次尝试超时:`PerAttemptTimeout` `Client.SetTimeout` 改的是模板的默认超时,对所有派生 Request 生效;`Request.SetTimeout` 仅覆盖本次请求(在 buildEffective 阶段克隆出独立 *http.Client),**不会污染 Client**。但二者都覆盖的是"整次请求",重试场景下还需要单次粒度的话就用 `PerAttemptTimeout`:它只在 `doOnce` 内用 `context.WithTimeout` 包一层,单次尝试超时后立即返回,由重试循环决定要不要再来一次: ```go // 整体最多等 10s,单次尝试最多 2s,最多重试 3 次 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp := client.Get(url). SetRetry(3, 200*time.Millisecond). PerAttemptTimeout(2 * time.Second). Do(ctx) ``` ### 关于流式上传 以下两种方式构造的请求体**不可重放**: - `TarFilesStream(...)`:流式打包目录上传 - 大文件触发的流式 `multipart`(自动判断 `>4MB` 或 `Reader Size<=0` 时启用) 库在检测到 `bodySnapshot == nil` 时会自动降级为"不重试",并打印一条 `Warn` 日志: ```text 当前请求体不可重放(流式上传 / io.Pipe),已自动禁用重试 ``` 如果你确实需要对大文件上传也加重试,建议先把内容缓冲到本地文件,然后用 `UploadFiles(field, filePath)` 走可重放路径——库会在第二次重试时重新 `os.Open` 并读取文件。 #### 流式请求体的启动时机与 ctx 行为 两种流式构造方式都采用「**延迟启动**」模型,对调用方来说使用方式没有任何变化(依旧是 `xxxStream(...).Do(ctx)` 链式调用),但理解其内部时序有助于在异常场景下做正确的资源管理: | 阶段 | 内部行为 | |---|---| | 调用 `TarFilesStream(...)` / 大文件 `MultiFormData/UploadFiles` 触发流式分支 | **不**启动后台 goroutine,仅登记一个延迟启动器 | | 调用 `Do(ctx)` | 进入 `prepareRequestBody(ctx)` 时才真正建立 `io.Pipe` 与生产者 goroutine,并将 `ctx` 绑定给 watcher | | `ctx` 取消 / 超时 | watcher 立刻关闭 pipe,生产者会在下一次写入时返回,goroutine 自然退出 | | `Do(ctx)` 退出(成功 / 失败均会执行) | 库会无条件 cancel 一个内部派生 ctx,作为兜底唤醒,确保任何路径下生产者都能退出 | 由此带来的两点使用建议: ```go // 建议 1:为流式上传请求传入带超时或可取消的 ctx,便于在异常场景下迅速回收 goroutine ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() resp := client.Put(url). Param("path", "/"). TarFilesStream("./big-folder", nil, nil, nil, nil). Do(ctx) // 建议 2:链式调用过程中如果发生前置错误,没有调用 Do(ctx) 也是安全的—— // 因为流式 goroutine 只会在 Do(ctx) 内部启动,调用 builder 方法本身不产生后台任务。 req := client.Put(url).TarFilesStream("./folder", nil, nil, nil, nil) if someError != nil { return // 不需要任何额外清理,无 goroutine 泄漏风险 } resp := req.Do(ctx) ``` > 完整测试用例参见 `[rest/retry_test.go](/rest/retry_test.go)`,覆盖了 5xx 重试、POST 幂等保护、`Retry-After`、Context 取消、DNS 不重试、Client 级默认策略等核心场景。 --- ## 并发安全模型:模板 Client + 派生 Request `go-reqx` 在 v2 重构后,把"配置时机"严格分成两层: | 角色 | 时机 | 是否可写 | |---|---|---| | `*Client` | 创建期、应用启动时 | ✅ 写一次 | | `*Client` | 多 goroutine 进入运行期后 | ❌ 视为模板,**只读** | | `*Request` | 由 `c.Get/Post/...` 派生 | ✅ 仅作用于自身 | 也就是说: - `c.Get("/x")` / `c.Post("/x")` 等方法**不会修改 Client**,只是派生一个新的 `*Request`; - `*Request` 上的所有 setter 都只写自己的私有字段; - 真正发起请求时,`buildEffective` 会按需克隆出独立的 `*http.Client / *http.Transport / *tls.Config`,**不污染 Client 的共享实例**。 ### 三类配置的生效规则 | 类别 | 例子 | 规则 | |---|---|---| | **A. 标量·继承+覆盖** | `SetTimeout` / `SetConnectTimeout` / `SetTLSHandshakeTimeout` / `SetResponseHeaderTimeout` / `SetIdleConnTimeout` / `SetExpectContinueTimeout` / `SetBasicAuth` / `SetBearerTokenAuth` / `SetContentType` / `SetUserAgent` / `SetIgnoreCert` / `SetMinVersionTLS` / `SetCert` / `SetProxyUrl` / `SetDNS` / `SetNoAutoRedirect` / `SetCarryCookies` / `SetCarryQueryParameters` | Request 没设 → 用 Client;设了 → 仅当前请求覆盖 | | **B. 集合·合并** | `SetHeader` / `AddHeader` / `SetCookie` / `Param` / `Group(=Prefix)` | Request 在 Client 的快照基础上叠加;同 key 由 `SetHeader`(覆盖) vs `AddHeader`(追加) 决定行为 | | **C. 仅 Client** | `SetLogger` / `SetLogLevel` / `SetLogSaveFile` / `SetLogDirPath` / `SetHTTPClient` / `UseRoundTripper` / `SetRequestHook` / `SetDefaultRetry` / `Clone` / `Group` | 这些是模板/全局设施,只在 Client 阶段调用 | | **D. 仅 Request** | `Method` / `URL` / `AbsURL` / `Prefix` / `Suffix` / `Body` / `DataJs` / `DataXML` / `DataYaml` / `DataMsgpack` / `DataCBOR` / `DataProtobuf` / `DataForm` / `RowText` / `MultiFormData` / `UploadFiles` / `UploadFileReader` / `TarFiles` / `TarFilesStream` / `WithRetryPolicy` / `SetRetry` / `PerAttemptTimeout` / `StrictContentType` / `DisableCompression` / `CompressBody` / `CompressBodyWithLevel` / `CompressBodyMinSize` / `Do` ... | 请求体、请求级行为只在 Request 上设置 | ### `SetHeader` vs `AddHeader` ```go c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetHeader("X-API-Version", "1") // 模板默认头 // Request#A:覆盖同 key c.Get("/users"). SetHeader("X-API-Version", "2"). // 覆盖:本请求 X-API-Version=2 Do(ctx) // Request#B:追加多值 c.Get("/users"). AddHeader("X-Forwarded-For", "1.1.1.1"). // 追加 AddHeader("X-Forwarded-For", "2.2.2.2"). // 同 key 多值 Do(ctx) ``` > Client 与 Request 都同时提供 `SetHeader / AddHeader`,命名与语义保持完全一致。 ### 多 goroutine 共享 Client 的典型用法 ```go // 应用启动时:一次性配置好 Client(模板) c := rest.NewDefaultClient(). SetBaseURL("https://api.example.com"). SetTimeout(5 * time.Second). SetBasicAuth("appuser", "globalpwd"). SetHeader("X-API-Version", "1") // 之后多 goroutine 并发使用,互不影响: go func() { // 仅本次请求改成 root + 30s 超时 c.Get("/admin"). SetBasicAuth("root", "override"). SetTimeout(30 * time.Second). Do(ctx) }() go func() { // 同一时刻另一个 goroutine 完全不会受到上面的影响 c.Get("/users"). SetHeader("X-Trace-Id", traceID). Do(ctx) }() ``` 并发安全保证由 `[rest/concurrency_test.go](/rest/concurrency_test.go)` 覆盖: ```bash go test -race -run TestConcurrent ./rest/... ``` 测试包括: - Client 字段(Timeout / Transport / TLS / CheckRedirect / Jar)在并发请求下保持不变; - Request 上的 `SetTimeout / SetIgnoreCert / SetMinVersionTLS / SetNoAutoRedirect / SetCarryCookies / SetCarryQueryParameters / SetDNS` 等覆盖项真正生效; - Request 没有任何覆盖时复用 Client.client(零开销路径); - 多 goroutine 在派生 Request 上设置 header 没有互相串扰。 --- ## 安全能力 `go-reqx` 在通用 HTTP 客户端基础上,沿"**默认安全 / 入口拦截 / 一行启用**"的思路提供了一组安全护栏: 默认就具备的能力(如 TLS 1.2 兜底、日志脱敏、Header/Cookie 注入校验)会在你毫无感知时生效; 对外部输入有信任要求的能力(如 SSRF 防护)则采用**显式开关**,避免误伤内网工具。 下表先给出全景,后面分小节逐项展开: | 能力 | 默认状态 | 触发位置 | 启用方式 | |---|---|---|---| | 日志中敏感 Header 脱敏 | ✅ 默认开启 | 所有日志输出路径 | 无需配置 | | 日志/Trace 中 URL 凭证(userinfo)脱敏 | ✅ 默认开启 | 请求日志、`SetBaseURL`、`Group`、OTel Hook | 无需配置 | | Header 值 CRLF/NUL 注入校验 | ✅ 默认开启 | `SetHeader` / `AddHeader`(Client & Request) | 无需配置 | | Cookie Name/Value 非法字符校验 | ✅ 默认开启 | `SetCookie`(Client & Request) | 无需配置 | | TLS 最低版本显式锁定 1.2 | ✅ 默认开启 | `NewDefaultHttpClient` / 派生 Transport / 注入 Transport 兜底 | 无需配置;如需更高,调用 `SetMinVersionTLS` | | `InsecureSkipVerify=true` 自动 Warn | ✅ 默认开启 | `SetIgnoreCert` / 注入的 `*http.Client` | 无需配置(启用时会打印一次告警) | | 代理 scheme 白名单 | ✅ 默认开启 | `SetProxyUrl` / `SetProxyScheme` / `SetProxyAdd` | 无需配置 | | 跨主机重定向 Cookie 不透传 | ✅ 默认开启 | `CheckRedirect`(Client & Request) | 无需配置(跨域时会打印 Warn) | | `Group()` 拼接 URL 兜底剥离凭证 | ✅ 默认开启 | `Client.Group` | 无需配置 | | **SSRF 防护**(IP 黑名单 + DNS rebinding 抗性) | ❌ 默认关闭 | URL 入口 + Dial 后双重校验 | `SetSSRFProtect(true)` | > 设计哲学:**通用安全能力默认就开,业务侧无需感知;可能对内网 / 调试场景产生误伤的能力(仅 SSRF 防护)走显式开关。** ### 1. 日志脱敏:敏感 Header 与 URL 凭证 **做了什么** - 任何路径下的请求/响应日志,都会自动遮蔽以下敏感请求头的值(保留前后各 4 位以便排错): `Authorization`、`Cookie`、`Set-Cookie`、`X-Api-Key`、`Proxy-Authorization`。 - `SetBaseURL` / `Group` / 请求日志在打印 URL 前会**自动剥离** `http://user:pass@host/...` 形式的凭证; - OTel 链路追踪示例(`rest/example/trace/hook.go`)也对 Span 的 `URLFull` 与日志做了同等脱敏。 **典型场景** - 排查问题时把 Debug 日志贴到群里 / Issue 里,**不再担心**误把 Bearer Token、Cookie 凭证带出去; - 老旧接口仍用 `http://user:pass@api.example.com/` 这种凭证内嵌的 URL,凭证不会落入 access log。 **示例** ```go // 不需要任何额外开关——直接用就好 client := rest.NewLogLevelClient(rest.LogLevelDebug). SetBaseURL("http://user:secret@api.example.com"). SetHeader("Authorization", "Bearer eyJhbGciOi...verylongtoken") _ = client.Get("/me").Do(ctx).Error() // 日志中你会看到: // URL: http://api.example.com/me ← userinfo 已脱敏 // Authorization=Bear***oken ← 凭证已遮蔽 ``` ### 2. Header / Cookie 注入校验 **做了什么** - `SetHeader / AddHeader`:在写入前拒绝包含 `\r` `\n` `\x00` 的值,命中后通过粘性错误 `Client.Err()` / `Response.Err()` 暴露; - `SetCookie`:拒绝 Name 含 `\r\n\x00=;,`、Value 含 `\r\n\x00` 的非法 Cookie。 **典型场景** - 某接口允许用户传一段字符串作为追踪标识,业务直接 `SetHeader("X-Trace-Id", userInput)`, 攻击者构造 `abc\r\nX-Admin: true` 试图伪造管理员请求头 → 被库内拦截,不会发出去。 - 对接老旧 SSO 服务时把 Cookie 值拼装出来时不慎带入了换行符 → 被立刻发现,避免 Cookie 边界破坏。 **示例** ```go resp := rest.NewDefaultClient(). SetHeader("X-Trace-Id", "abc\r\nX-Admin: true"). // ← 非法 Get("/api").Do(ctx) if err := resp.Error(); err != nil { // err: SetHeader("X-Trace-Id"): header 值包含非法字符 (CR/LF/NUL) log.Println(err) } ``` ### 3. TLS 最低版本:显式锁定 1.2 **做了什么** - `NewDefaultHttpClient` 内置 `TLSClientConfig.MinVersion = SecureMinTLSVersion`(即 `tls.VersionTLS12`); - Request 派生独立 `*tls.Config` 时(`SetIgnoreCert` / `SetMinVersionTLS` / `SetCert` 等), 零值分支同样会写入 `MinVersion = TLS 1.2`; - 通过 `SetHTTPClient` / `UseRoundTripper` 注入的 `*http.Transport`,若未带 `TLSClientConfig`, 库会在 `ensurePrivateTLSConfig` 中补上 `MinVersion = TLS 1.2`。 不依赖 Go 版本的隐式默认值,安全意图在代码评审时一目了然。 **典型场景** - 升级 Go 版本时,TLS 默认值变化不会"偷偷"改变线上行为; - 团队即便把 `*http.Client` 从外部注入进来,也能拿到统一的最低 TLS 版本兜底。 **示例** ```go // 默认就是 TLS 1.2 c := rest.NewDefaultClient() // 业务对安全有更高要求,整体抬升到 1.3(仅当前请求生效) _ = c.Get("/").SetMinVersionTLS(tls.VersionTLS13).Do(ctx).Error() ``` ### 4. `InsecureSkipVerify` 自动告警 **做了什么** - 任何路径下出现 `tlsConfig.InsecureSkipVerify = true`(无论是 `SetIgnoreCert`,还是用户通过 `SetHTTPClient` 注入), 库都会在初始化阶段打印一条醒目的 `Warn` 日志,便于在 CI / 上线前发现误用。 **典型场景** - 开发同学为了对接自签证书测试环境,临时加了 `SetIgnoreCert()`,提交时忘记移除 → 上线前 Warn 日志能在第一时间被发现; - 内部网关通过 `SetHTTPClient(myClient)` 注入了一份带 `InsecureSkipVerify` 的 `*http.Client` → 同样会被打 Warn。 **示例** ```go client := rest.NewDefaultClient(). SetIgnoreCert() // 日志输出:[安全警告] InsecureSkipVerify=true:已禁用 TLS 证书校验... ``` ### 5. 代理 scheme 白名单 + 含 `://` 拒绝 **做了什么** - `SetProxyUrl` / `SetProxyScheme` 仅允许 `http`、`https`、`socks5`、`socks5h`,其他(`ftp`、`file`、`gopher` 等)一律拒绝; - `SetProxyAdd` 一旦发现传入字符串包含 `://`(说明用户给了完整 URL),直接拒绝,避免拼出 `http://http://evil/` 这类畸形 URL; - 明文 `http://` 代理会触发一条 Warn,提醒可能被中间人观测。 **典型场景** - 配置管理系统把代理地址作为整 URL 下发,业务误传到 `SetProxyAdd` → 立即报错,不会"看似工作但其实流量被劫持"; - 老配置里残留 `ftp://proxy.internal:8080` → 启动时直接拒绝,避免连到不可控协议。 **示例** ```go // 正确用法 c := rest.NewDefaultClient().SetProxyUrl("socks5h://127.0.0.1:1080") // 错误用法 1:协议非法 c2 := rest.NewDefaultClient().SetProxyScheme("ftp") // c2.Err() => 不支持的代理协议 "ftp",仅允许 http/https/socks5/socks5h // 错误用法 2:把完整 URL 传给了 SetProxyAdd c3 := rest.NewDefaultClient().SetProxyAdd("http://proxy:8080") // c3.Err() => 代理地址不应包含 scheme,请改用 SetProxyUrl ``` ### 6. 跨主机重定向 Cookie 不透传 **做了什么** - `Client.retryOnRedirect` 与 Request 级 `makeRetryOnRedirect` 在跨主机重定向时**不再**复制原始请求的 cookie,避免凭证被发到第三方 host; - 同主机重定向不受影响,cookie 正常透传; - `All` 模式跨主机透传查询参数时(query 中也常含 `access_token`),会打 Warn 提醒。 **典型场景** - 站点 A 返回 `302 Location: https://evil.example.com/...`,本库不会把站点 A 的会话 cookie 发到 evil; - 业务真的需要跨域透传 cookie 时,请改用 `SetCarryCookies()` 启用 cookiejar,由它按 `Domain` 字段决定是否携带。 **示例** ```go // 默认行为:跨主机不透传 cookie,日志会打印 // "[安全警告] 跨主机重定向 a.com -> b.com,已自动跳过原始 cookie 透传以避免凭证泄漏" _ = rest.NewDefaultClient(). SetCookie(&http.Cookie{Name: "session", Value: "secret"}). Get("https://a.com/redirect-to-b").Do(ctx).Error() // 真的需要跨域 cookie?请用 cookiejar _ = rest.NewDefaultClient(). Get("https://a.com/redirect-to-b"). SetCarryCookies(). // ← 标准库 cookiejar,按 Domain 字段安全管理 Do(ctx).Error() ``` ### 7. `Group()` 拼接兜底剥离凭证 **做了什么** - `Client.Group(urlPath)` 在拼接 baseURL 后会强制 `parsed.User = nil`,与 `SetBaseURL` 处理保持一致; - 即便 `urlPath` 形如 `//user:pass@evil/api` 这种"看起来无害但实际带 userinfo"的字符串被传入,凭证也不会落入 baseURL。 **典型场景** - 多租户 SaaS:基础路径来自配置中心,业务用 `Group(tenantPath)` 派生出每个租户的 Client,配置侧的失误不会污染所有派生 Client。 ### 8. SSRF 防护(**显式开关**) > 这一项是**默认关闭**的——本库是通用 HTTP 客户端,不应在内网/调试场景误伤。处理"用户输入 URL → 服务端代理抓取"等高风险场景时务必开启。 **做了什么** - 双重校验: 1. **URL 入口校验**:`req.URL.Host` 是字面量 IP 时,命中受限网段直接拒绝,不会拨号; 2. **DialContext 包装**:DNS 解析后再校验对端真实 IP,对抗 **DNS rebinding**(攻击者把 `evil.com` 解析到 `127.0.0.1`)。 - 黑名单覆盖: - IPv4:`127.0.0.0/8`(loopback)、`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`(RFC1918)、 `169.254.0.0/16`(链路本地,含 AWS/GCP/Azure/阿里云元数据 `169.254.169.254`)、 `100.64.0.0/10`(CGNAT)、`0.0.0.0/8`(保留); - IPv6:`::1/128`(loopback)、`fc00::/7`(ULA 私网)、`fe80::/10`(链路本地); - 同时覆盖 Go 标准库的 `IsLoopback / IsPrivate / IsLinkLocalUnicast / IsInterfaceLocalMulticast / IsUnspecified`。 **典型场景** | 场景 | 是否建议开启 | |---|---| | 链接预览、Webhook、网页截图、内容抓取(用户输入 URL) | ✅ 必须开 | | 服务端代理任意 URL 的"代抓"接口 | ✅ 必须开 | | 对接已知第三方公网 API(公有云对象存储、支付网关等) | ✅ 推荐开(这些都是公网 IP,不会命中黑名单) | | 调用自家内网服务、调试本地服务 | ❌ 不要开(会被拒绝) | **示例** ```go // ① 一行代码开启 SSRF 防护 client := rest.NewDefaultClient().SetSSRFProtect(true) // ② 攻击者尝试请求云元数据 → 直接拒绝 err := client.Get("http://169.254.169.254/latest/meta-data/").Do(ctx).Error() // err: SSRF 防护:拒绝向受限网段的字面量 IP 169.254.169.254 发起请求 // ③ DNS rebinding:evil.com 的 A 记录指向 127.0.0.1 // 字面量校验通不过 → DialContext 包装会在 dial 阶段再次校验对端 IP err = client.Get("http://evil.com/").Do(ctx).Error() // err: SSRF 防护:DNS 解析后命中受限网段 IP 127.0.0.1 ``` > 实现位置:`[rest/security.go](/rest/security.go)` 中的 `ssrfBlockedCIDRs` / `isSSRFBlockedIP`, > 以及 `[rest/request_effective.go](/rest/request_effective.go)` 中的 `applySSRFGuardToTransport`。 ### 9. 安全能力一览(速查) ```go // ────── 默认就开,无需配置 ────── // 日志中 Authorization/Cookie/Set-Cookie/X-Api-Key/Proxy-Authorization 自动脱敏 // URL 中 user:pass@host 自动剥离 // SetHeader/AddHeader/SetCookie 自动拒绝 CR/LF/NUL 等非法字符 // 默认 TLS 1.2,跨主机重定向不透传 cookie client := rest.NewDefaultClient() // ────── 显式开关 ────── client.SetSSRFProtect(true) // 用户输入 URL 场景必开 client.SetMinVersionTLS(tls.VersionTLS13) // 抬升最低 TLS 版本 client.Get("/").SetIgnoreCert() // 仅当前请求跳证书(库会 Warn) ``` --- ## 高级特性总览 更多高级能力都可以在 `[rest/rest_test.go](/rest/rest_test.go)` 中找到对应测试用例: - **请求头**: - `SetHeader` 覆盖、`AddHeader` 追加、`SetContentType` / `SetUserAgent` 语义糖 - **路由组合**: - Client 阶段 `SetBaseURL` / `Group`(`Group` 会克隆出新 Client) - Request 阶段 `Group` / `Prefix` / `Suffix` / `URL` / `AbsURL` - **超时控制**: - `SetTimeout`:整体超时(拨号 → 握手 → 等响应头 → 读响应体的总闸刀) - `SetConnectTimeout` / `SetTLSHandshakeTimeout` / `SetResponseHeaderTimeout` / `SetIdleConnTimeout` / `SetExpectContinueTimeout`:分阶段精细控制 - `PerAttemptTimeout`:重试场景下的"单次尝试"超时 - 详见[超时控制](#超时控制整体超时--粒度超时)章节 - **认证相关**: - `SetBasicAuth` / `SetBearerTokenAuth`(Client、Request 同名同语义) - **重定向控制**: - `SetNoAutoRedirect` + 手动跟踪 `Location` - `SetCarryQueryParameters` 在跳转链路上透传查询参数 - **Cookie 管理**: - `SetCookie`(合并)、`SetCarryCookies`(启用 cookiejar) - **HTTPS/TLS**: - `SetIgnoreCert` / `SetMinVersionTLS` / `SetCert`(Request 级覆盖在 Do 时克隆 *tls.Config) - **代理与网络**: - `SetProxyUrl` / `SetProxyAdd` / `SetProxyPort` / `SetProxyScheme` - `SetDNS` + `CustomDialContext` 自定义 DNS 解析 - **自定义 http.Client / Transport**(仅 Client 阶段): - `SetHTTPClient`:整体替换底层 `*http.Client` - `UseRoundTripper`:只替换/包装 `Transport` - **请求 Hook**(仅 Client 阶段): - `SetRequestHook`:统一接入链路追踪、监控、埋点 - **重试机制**: - `Request.SetRetry` / `WithRetryPolicy` / `PerAttemptTimeout` - `Client.SetDefaultRetry`、`Response.RetryAttempts()` - 详见[重试机制](#重试机制可插拔策略--幂等保护)章节 这些能力都遵循同一个设计原则:**用链式 API 暴露配置,用测试用例给出权威示例;并且只要是 Client 与 Request 同名的方法,都遵循"未设置即继承、设置即覆盖"的语义**。 --- ## 目录与示例 - 项目根 README:当前文件 - 核心客户端实现: - `rest/client_model.go`:`Client`、`Logger`、`RequestHook` 等核心模型 - `rest/client.go`:链式 API 的具体实现 - 通用请求示例与能力展示: - `[rest/rest_test.go](/rest/rest_test.go)`:覆盖绝大多数功能场景 - `[rest/concurrency_test.go](/rest/concurrency_test.go)`:模板 Client + 派生 Request 的并发安全验证(`go test -race` 必跑) - 重试机制示例与测试: - `[rest/retry.go](/rest/retry.go)`:`RetryPolicy` 接口与 `DefaultRetryPolicy` 实现 - `[rest/retry_test.go](/rest/retry_test.go)`:重试场景全覆盖单元测试 - 链路追踪示例: - `[rest/example/trace/hook.go](/rest/example/trace/hook.go)`:OpenTelemetry Hook 实现 - `[rest/example/trace/jaeger](/rest/example/trace/jaeger)`:Jaeger 初始化与配置 - `[rest/example/trace/zipkin_tp](/rest/example/trace/zipkin_tp)`:Zipkin 初始化与配置 - `[rest/tracing_test.go](/rest/tracing_test.go)`:Jaeger/Zipkin + 客户端链路追踪测试 - 多服务调用链示例: - `[rest/example/example_service](/rest/example/example_service)`:gateway/user/order/payment 多服务串联 - 结合 Jaeger UI,可完整观察跨服务 Trace。 --- ## 总结 `go-reqx` 通过: - **链式 HTTP 客户端** 简化请求构建 - **统一日志接口 + 可插拔 Logger** 让日志可控、可规范 - **OpenTelemetry 友好的 Hook 设计** 让链路追踪深度融合到 HTTP 调用 非常适合作为 **微服务项目的标准 HTTP 客户端封装**。你可以直接基于本仓库的单元测试和示例服务,按需裁剪/复制到自己的项目中。 --- ## License 本项目采用 [**Apache License 2.0**](./LICENSE) 协议开源。 --- ## 🙌 致谢 感谢 Gitee 提供稳定的代码托管平台。 Built with ❤️ by [hexug](https://gitee.com/hexug) > 如果你觉得这个项目对你有帮助,不妨点个 Star ⭐ 支持一下!