Help us shape the future of Go! Take the 2026 Go Developer Survey

reqx/

directory
v2.2.44 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: Apache-2.0

README

go-reqx:链式 HTTP 客户端

License Go Report Card Gitee tag (latest SemVer) Go Version

go-reqx 对标准库 net/http 进行二次封装,提供:

  • 链式调用风格 的 HTTP 客户端(rest.Client),用最少的模板代码发起请求
  • 模板 Client + 派生 Request 架构:Client 在运行期只读,Request 拥有独立的覆盖位, 多 goroutine 同享一个 Client 互不污染(go test -race 验证)
  • 统一的日志体系:可配置日志级别,支持自定义 Logger 实现
  • 可插拔的请求 Hook:用于链路追踪、监控等(已内置 OpenTelemetry 示例)
  • 丰富的高级能力:认证、重定向控制、Cookie、TLS、代理、DNS、上传文件等

核心目标是:让 HTTP 调用在业务代码里更“干净”、更可观测、且易于扩展。


安装

迁移提示:本包原仓库 gitee.com/hexug/go-reqx 已于 v2 周期合并入 go-tools 仓库、以子包形式提供;包名与导出 API 与历史保持一致,迁移只需替换 import 路径。

go get gitee.com/hexug/go-tools/v2/reqx/rest

在代码中导入:

import "gitee.com/hexug/go-tools/v2/reqx/rest"

项目结构

. (gitee.com/hexug/go-tools/v2/reqx)
├── README.md                     # 包说明(当前文件)
├── internal/
│   ├── negotiator/               # 内部编码/解码协商实现(JSON、表单、YAML 等)
│   ├── compress/                 # 压缩/解压底层工厂(gzip/zstd/br/deflate/snappy)
│   ├── ssrf/                     # SSRF 防护:IP/网段黑名单判定
│   └── security/                 # HTTP 输入校验:Header/Cookie/Proxy 安全验证
├── loadtest/                     # 压测 SDK(外部可导入使用)
├── tests/                        # 独立测试套件(性能 / 安全 / 混沌 / 泄漏 / fuzz 等,不进默认 make test)
└── rest/                         # 链式 HTTP 客户端实现包(主要对外出口在这里)
    │
    │── 📦 模型定义层
    ├── 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 中的 TestRest,展示一个“规范写法”的 POST 请求:

package main

import (
	"context"
	"fmt"
	"time"

	"gitee.com/hexug/go-tools/v2/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. 创建客户端(默认日志级别为 info,跟随全局默认 logger 的兜底口径)
	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:

c := rest.NewLogLevelClient("info")
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:

type Payload struct {
	A string `json:"A"`
	B int    `json:"B"`
}

c := rest.NewLogLevelClient("debug")
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 中的多部分表单示例:

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() 就能拿到所有类别的错误:

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
}

或:

resp := c.Get("/users").Do(ctx)
if resp.Error() != nil {
    return resp.Error()
}

用法二:发起请求前主动检查(适合 fail-fast 场景)

如果希望 Client 初始化阶段就显式判断(例如全局单例、init() 中构造、希望配置错就让进程起不来),可以使用 Client.Err():

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) 包装,可以用标准库进行解包:

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

适用于“按文件路径上传”的常见场景:

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:

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 用于对上传过程进行可观测化,函数签名:

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,仅供展示已发送进度。

典型用法:

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() 查询,详见“响应体内存上限与流式下载”章节。

加上进度条的例子 记住不要在单元测试用使用,测试,单元测试中无法正常显示进度条

package main

import (
	"context"
	"fmt"
	"os"

	"gitee.com/hexug/go-tools/v2/reqx/rest"
	"gitee.com/hexug/go-tools/v2/format"
	"gitee.com/hexug/go-tools/v2/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 流式打包目录,适合大目录/大文件(推荐)。

两者函数签名一致:

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

内部会自动设置:

r.SetHeader("Content-Type", "application/x-tar")
3.1 典型 Docker 上传示例

以下示例演示将本地 ./ubuntu-16.04.7-server-amd64.template 文件(或目录)打包后上传到容器根目录 /:

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 包装,避免单文件拷贝期间无法被中断。

使用建议:

// 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 等目录:

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 场景)
resp := c.Get("/v1/users/1").Do(ctx)
var user User
if err := resp.Into(&user); err != nil {
    // 如果命中"响应体超过内存上限 N 字节",会建议你改用 SaveTo
    log.Fatal(err)
}
2. 流式下载到文件(推荐用于大文件)
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:

resp.SaveTo(myWriter)
3. 异步下载 + 进度查询
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 上统一调整:

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:AddCleanup 不会复活对象、不阻碍 GC、且回调不持有 *Response 本身,资源回收彻底;但这仍只是"最后一道防线",显式 defer resp.Close() 更及时也更可控。

资源释放推荐写法:

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 压缩
resp := c.Post("/v1/ingest").
    DataJs(payload).
    CompressBody().                     // 默认 gzip
    Do(ctx)
2. 指定算法
// 用 zstd 上传大 JSON / Protobuf
c.Post(url).DataProtobuf(msg).
    CompressBody(rest.EncodingZstd).
    Do(ctx)
3. 指定算法 + 压缩级别
// 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. 调整压缩阈值
// 自定义最小压缩字节阈值(默认 1KB)
c.Post(url).DataJs(payload).
    CompressBody(rest.EncodingGzip).
    CompressBodyMinSize(4 * 1024).      // < 4KB 跳过压缩
    Do(ctx)

阈值仅对"已知请求体大小"的非流式场景生效(如 DataJs / DataXML / RowText / FormData 等); 流式 body(TarFilesStream / 大文件 multipart pipe)由于无法预知大小,不参与阈值判断。

5. 禁用压缩(默认行为,仅作示例)
// 不调用 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 传输等可控链路;
  • 如果不确定对端是否支持,建议保持默认(不压缩)。

日志系统

日志级别与创建客户端

v2 迁移提示:LogLevel 枚举已于 v2 移除,统一使用字符串传递日志级别,与 logger 包 / ioc/log 配置文件口径完全一致:

// 旧 (v1)
c := rest.NewLogLevelClient(rest.LogLevelInfo)
c.SetLogLevel(rest.LogLevelInfo)

// 新 (v2)
c := rest.NewLogLevelClient("info")
c.SetLogLevel("info")

字符串取值大小写不敏感:"info" / "INFO" / "Info" / " info " 等价;"warn" 与 "warning" 等价;非法值统一兜底为 debug,保证日志不会因配置笔误而丢失。

另外,v2 起 NewDefaultClient / NewLogLevelClient 会以全局默认 logger(logger.L())的 配置快照为基底派生独立实例。当业务通过 ioc/log 装配过全局 logger 后,reqx 客户端 会自动跟随 level / format / file 口径,不再出现 "ioc/log 已切到 info+json,reqx 还在 stdout 打 debug 文本" 的配置割裂问题。

自定义 Logger:实现接口并注入

rest.Client 使用了一个自定义的 Logger 接口(在 [rest/client_model.go](./rest/client_model.go))。 v2 起接口面向 zap.SugaredLogger 风格收窄至常用的 14 个方法(Debug/Info/Warn/Error 各 3 个变体 + 级别 Get/Set);删除了 Fatal* / Panic* (库代码不应主动终止调用方进程)与 *ln 变体(与 * 变体语义重叠,仅是换行处理差异的历史包袱)。

// Logger 日志接口,用于统一日志输出,便于业务方注入自定义实现。
type Logger interface {
	Debug(args ...interface{})
	Debugf(format string, args ...interface{})
	Debugw(msg string, keysAndValues ...interface{})

	Info(args ...interface{})
	Infof(format string, args ...interface{})
	Infow(msg string, keysAndValues ...interface{})

	Warn(args ...interface{})
	Warnf(format string, args ...interface{})
	Warnw(msg string, keysAndValues ...interface{})

	Error(args ...interface{})
	Errorf(format string, args ...interface{})
	Errorw(msg string, keysAndValues ...interface{})

	GetLevel() string
	SetLevel(level string)
}

*logger.MyLogger 仍然天然满足该接口(它本身是 zap.SugaredLogger 的超集)。默认使用的就是 gitee.com/hexug/go-tools/v2/logger 中的实现,你也可以:

  • 实现一份自己的 Logger(比如基于 zap 或 logrus 的适配器)。

  • 通过 SetLogger 注入到 rest.Client:

    c := rest.NewLogLevelClient("debug")
    
    // 这里以 go-tools/logger 为例,自定义一些配置
    l := logger.L()
    l.SetShowFullPath(true)
    l.SetLevel("info")
    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,并额外打印一条 流式下载完成 日志,包含写出字节数和平均速率,便于观察大文件下载性能;
  • 流式响应不会经过 反序列化 阶段。

日志示例

# 普通 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):

// 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 中,演示了完整流程:

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 函数用于优雅关闭

你在真实服务中可以直接参考该实现:

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 进行链路追踪

说明:自 2025 年起,OpenTelemetry 官方 exporters/zipkin 已被弃用,并将在 2027 年初前移除。 因此 zipkin_tp 包已迁移为:通过 OTLP/HTTP 将 trace 发送到本地的 OpenTelemetry Collector,再由 Collector 端的 zipkin exporter 转发到 Zipkin 后端。 对应的 Collector 配置与 docker-compose 编排见 rest/example/trace/zipkin_tp 目录。

[rest/example/trace/zipkin_tp](./rest/example/trace/zipkin_tp) 中提供了相应的初始化实现,对应 TestTracingZipkin:

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 的实现和上报链路(应用 → Collector → Zipkin)不同。

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:

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 30s ✅
SetIdleConnTimeout(d) 空闲长连接的最大保活时长 Transport.IdleConnTimeout 90s ✅
SetExpectContinueTimeout(d) Expect: 100-continue 握手等待时长 Transport.ExpectContinueTimeout 1s ✅
PerAttemptTimeout(d) 重试循环里单次尝试的超时(不含退避) context.WithTimeout 包裹 未设 = 关闭 ✅(仅 Request)

默认值:ResponseHeaderTimeout = 30s(防止服务端不返回响应头时无限挂住),MaxIdleConnsPerHost = 10(适合并发连接复用的常见场景),ExpectContinueTimeout = 1s(与标准库一致)。

整体 vs 粒度:谁管哪一段

SetTimeout 是从拨号一直管到响应体读完的"全程总闸";其他 5 个粒度超时是各自阶段的"局部小闸"。它们可以同时配置,互不冲突:

连接建立 ─── TLS 握手 ─── 写请求 ─── 等响应头 ─── 读响应体
   ↑              ↑                     ↑               ↑
 Connect       TLSHandshake        ResponseHeader      (只受 Timeout 管)
   └──────────────┴────────────┬──────┴──────────────────┘
                               │
                            SetTimeout(整体兜底,含 Body 读取)

要点:

  • 小闸先跳:拨号阶段卡死,3 秒就能由 SetConnectTimeout 砍掉,无需等整体超时。
  • 总闸兜底:哪怕响应头很快返回,SetTimeout 仍然会限制读 Body 的总时长,防止 Body 慢悠悠读到天荒地老。
  • SetTimeout(0) = 不设上限:默认值,配合 context.WithTimeout(ctx, ...) 也能起到一样的兜底效果。

两层覆盖:Client 模板 + Request 覆盖

所有超时 setter 在 Client 与 Request 上同名同语义,遵循"未设即继承、设了即覆盖":

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:内部服务调用(绝大多数业务)

只配整体超时即可,其它粒度沿用默认:

c := rest.NewDefaultClient().
    SetBaseURL("http://internal-svc").
    SetTimeout(10 * time.Second)             // 一行兜底

场景 B:调用外部不可信网络(公网 API、第三方)

补上前置阶段的"早期失败检测",避免被慢服务卡死:

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:大文件下载 / 长连接拉流

整体超时要放宽到能容纳完整传输,前置阶段反而要卡严:

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:

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 在 NewDefaultHttpClient 中预设了一组连接池参数,兼顾通用场景与资源占用。高并发或特殊场景下,可通过以下 Setter 按需调整。

一张总览表

Setter 作用 底层字段 默认值
SetMaxIdleConns(n) 所有 Host 的空闲连接总数上限 Transport.MaxIdleConns 100
SetMaxIdleConnsPerHost(n) 每个 Host 的最大空闲连接数 Transport.MaxIdleConnsPerHost 10(标准库默认仅 2)
SetMaxConnsPerHost(n) 每个 Host 的最大连接数(含活跃+空闲) Transport.MaxConnsPerHost 0(不限制)
SetIdleConnTimeout(d) 空闲连接的最大保活时长 Transport.IdleConnTimeout 90s

MaxIdleConnsPerHost 是最容易被忽视的参数:值过小时会在高并发访问同一 Host 时导致连接复用不足、频繁 TCP/TLS 握手、吞吐下降、延迟升高。go-reqx 默认值为 10。

典型场景

场景 A:通用业务(默认即可)

c := rest.NewDefaultClient().
    SetBaseURL("https://api.example.com")
// MaxIdleConnsPerHost=10,满足大多数业务

场景 B:高并发调用同一服务

c := rest.NewDefaultClient().
    SetBaseURL("https://api.example.com").
    SetMaxIdleConns(200).           // 提高空闲连接总数
    SetMaxIdleConnsPerHost(100)     // 每个 Host 保持更多空闲连接

场景 C:限流保护下游

c := rest.NewDefaultClient().
    SetBaseURL("https://downstream.example.com").
    SetMaxConnsPerHost(50)          // 限制对单个 Host 的最大并发连接数

场景 D:压测场景

压测场景建议使用 loadtest 包提供的 NewSharedHTTPClient,它预设了更激进的连接池参数(MaxIdleConnsPerHost=1000)。


生产推荐配置:NewProductionClient

NewDefaultClient 的默认配置偏保守(不设整体超时),适合需要精细控制的场景。对于大多数生产环境的内部服务调用,NewProductionClient 提供了开箱即用的推荐配置:

c := rest.NewProductionClient().
    SetBaseURL("https://api.example.com")

与 NewDefaultClient 的区别

参数 NewDefaultClient NewProductionClient
整体超时 http.Client.Timeout 0(不限制) 60s
TCP 拨号超时 net.Dialer.Timeout 30s 10s
响应头超时 ResponseHeaderTimeout 30s 15s
每 Host 最大空闲连接 MaxIdleConnsPerHost 10 100

如何选择

  • 内部服务调用 / API 网关:推荐 NewProductionClient,开箱即用。
  • 大文件下载 / SSE 长连接 / 流式响应:使用 NewDefaultClient,按需配置超时。
  • 公网第三方 API:使用 NewDefaultClient,手动配置各阶段超时。

覆盖个别参数

NewProductionClient 返回的 Client 仍然支持链式覆盖:

c := rest.NewProductionClient().
    SetBaseURL("https://api.example.com").
    SetTimeout(120 * time.Second).          // 覆盖整体超时
    SetMaxIdleConnsPerHost(200)             // 覆盖连接池参数

重试机制:可插拔策略 + 幂等保护

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)

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 上设置一次:

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

// 业务侧承诺 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:

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 包一层,单次尝试超时后立即返回,由重试循环决定要不要再来一次:

// 整体最多等 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 日志:

当前请求体不可重放(流式上传 / 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,作为兜底唤醒,确保任何路径下生产者都能退出

由此带来的两点使用建议:

// 建议 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 级默认策略等核心场景。


会话型 API:对标 Python requests.Session

很多用户从 Python 生态过来,习惯了 requests.Session() 的体验:复用 TCP/TLS 连接、自动维护 cookie、共享默认 header / params。go-reqx 的 Client 天然具备这些能力,本节给出对照表与"开箱即用"语法糖,让爬虫 / 长会话场景一行搞定。

1. NewSession() / Close():会话风格的构造与释放

NewSession() 等价于 NewDefaultClient().SetCarryCookies(),默认开启 cookiejar,让"登录一次,后续请求自动带 sid"成为默认行为。Close() 则在标准库 http.Transport.CloseIdleConnections 之上做了一层兜底封装,对自定义 RoundTripper 也安全。

s := rest.NewSession().
    SetBaseURL("https://target.com").
    SetUserAgent("Mozilla/5.0 ...")
defer s.Close() // 释放空闲连接,长跑任务建议显式调用

// 1) 登录:服务端通过 Set-Cookie 下发 sid
_ = s.Post("/login").DataForm(...).Do(ctx).Error()

// 2) 后续请求自动带登录态,等价于 requests.Session 的行为
_ = s.Get("/profile").Do(ctx).Error()
_ = s.Get("/orders").Do(ctx).Error()

适用场景:

  • 需要共享登录态 / Cookie 的爬虫;
  • 同 Host 高频调用、希望复用 TCP/TLS 连接的客户端;
  • 长跑任务希望阶段性主动释放空闲连接,或切换网络环境后丢弃旧连接池。

与 NewProductionClient 平行:如需"生产推荐参数 + cookie 自持",组合使用 rest.NewProductionClient().SetCarryCookies() 即可。

requests 生态常见用法:pickle 一份 session.cookies,进程重启后免登录直接复用。go-reqx 提供等价能力,序列化为可读 JSON。

s := rest.NewSession().SetBaseURL("https://target.com")

// —— 启动时尝试恢复上次会话 ——
if err := s.LoadCookiesFromFile("session.json"); err != nil {
    if errors.Is(err, fs.ErrNotExist) {
        // 首次运行,走登录流程
        _ = s.Post("/login").DataForm(...).Do(ctx).Error()
    } else {
        log.Printf("session 恢复失败: %v", err)
    }
}

// —— 业务请求 ——
_ = s.Get("/orders").Do(ctx).Error()

// —— 退出前落盘:下次启动直接复用 ——
_ = s.DumpCookiesToFile("session.json")

API 一览:

API 说明
Client.DumpCookies(io.Writer) 把 jar 序列化为 JSON 写入流
Client.DumpCookiesToFile(path) 便捷版:写入磁盘文件(权限 0600)
Client.LoadCookies(io.Reader) 从 JSON 流还原 jar
Client.LoadCookiesFromFile(path) 便捷版:从文件还原;文件不存在时返回 fs.ErrNotExist 包装错误
rest.ErrCookieJarNotEnabled 未先 SetCarryCookies() / NewSession() 时返回

实现要点(不影响使用,仅供进阶用户参考):

  • SetCarryCookies 内部装载的是本库的 *persistableJar,完全实现 http.CookieJar 接口,对外行为与标准库 cookiejar.Jar 等价;
  • 标准库 jar 不暴露遍历,本库通过"在 SetCookies 写入侧记录访问过的 host"实现 dump 时按 host 反查;
  • 序列化字段裁剪到 scheme/host/name/value/path:标准库 Jar.Cookies(u) 出口本来就只剩这些;Load 时按相同 URL 灌回 jar,等价于"再次访问该域时收到 Set-Cookie",足够覆盖会话恢复;
  • 不持久化 Expires/MaxAge:保鲜期由调用方按需控制(sid 过期就重新登录),避免序列化已过期 cookie;
  • JSON schema 带 version 字段,向后兼容;未知字段忽略不报错。

适用场景:

  • 长会话爬虫:扫码登录一次 → 落盘 → 进程重启免登录;
  • 多账号管理:每个账号一份 cookies/<account>.json,按需 Load;
  • 调试:把 jar 内容导出为 JSON 人工查看 / 编辑。

3. Client 级默认查询参数:SetParam / AddParam / SetParams

对标 requests.Session.params:把所有请求都要带的 query 参数(API key、租户 ID、固定 trace 标识)一次配齐,不用在每个 c.Get(...).Param(...) 调用点重复书写。

c := rest.NewSession().
    SetBaseURL("https://api.example.com").
    SetParam("api_key", "AKIA...").     // 同名覆盖
    SetParam("tenant", "t-001").
    AddParam("tag", "a").AddParam("tag", "b") // 同名追加:?tag=a&tag=b

c.Get("/items")                          // 自动带 ?api_key=AKIA...&tenant=t-001&tag=a&tag=b
c.Get("/items").Param("page", "2")       // Request 级 Param 在快照基础上再追加
API 语义
Client.SetParam(name, value) 同名 key 覆盖(url.Values.Set)
Client.AddParam(name, value) 同名 key 追加为多值(url.Values.Add)
Client.SetParams(map[string]string) 批量"覆盖式"设置;想要追加批量请循环 AddParam

与 Header / Cookie 同样的并发安全契约:Client 是只读模板,NewRequest 时会做"map+slice 双层拷贝"到 Request 私有副本,Request 上 Param() 不会回写 Client,多 goroutine 并发派生 Request 互不污染。

适用场景:

  • 第三方 API 要求所有请求都带固定鉴权参数(如 Google Maps 的 ?key=...);
  • 服务化场景中所有请求都需带统一 app_id / trace_id;
  • 多租户系统按租户 Group() / Clone() 派生 Client 后注入 tenant 参数。

requests.Session ↔ go-reqx 速查表

Python requests.Session go-reqx 对应能力
s = requests.Session() s := rest.NewSession()
s.headers.update({...}) s.SetHeader(...) / s.AddHeader(...)
s.params = {...} s.SetParam(...) / s.SetParams(...)
s.cookies 自动维护 NewSession() 已默认 SetCarryCookies()
s.cookies.update(jar) 持久化 s.DumpCookiesToFile / s.LoadCookiesFromFile
s.proxies = {...} s.SetProxyUrl(...)
s.verify = False s.SetIgnoreCert()
s.close() s.Close()
连接池复用 自动;按 MaxIdleConnsPerHost 调优

并发安全模型:模板 Client + 派生 Request

go-reqx 把"配置时机"严格分成两层:

角色 时机 是否可写
*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

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 的典型用法

// 应用启动时:一次性配置好 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) 覆盖:

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。

示例

// 不需要任何额外开关——直接用就好
client := rest.NewLogLevelClient("debug").
    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               ← 凭证已遮蔽

做了什么

  • 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 边界破坏。

示例

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 版本兜底。

示例

// 默认就是 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。

示例

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 → 启动时直接拒绝,避免连到不可控协议。

示例

// 正确用法
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

做了什么

  • 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 字段决定是否携带。

示例

// 默认行为:跨主机不透传 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,不会命中黑名单)
调用自家内网服务、调试本地服务 ❌ 不要开(会被拒绝)

示例

// ① 一行代码开启 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. 安全能力一览(速查)

// ────── 默认就开,无需配置 ──────
// 日志中 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)

与标准库 / 三方 SDK 互操作

很多三方 SDK(如 golang.org/x/oauth2、k8s.io/client-go、aws-sdk-go-v2 等)只接受标准库 *http.Client,而不会感知 reqx 的链式 API。 reqx 暴露两个面向互操作的 getter,让业务方能在保留 reqx 配置(连接池 / 超时 / TLS / 代理 / DNS 等)的同时,把底层句柄交给三方 SDK:

// 返回 reqx 内部 *http.Client 引用(不是副本),可直接交给三方 SDK
func (c *Client) HTTPClient() *http.Client

// 返回 *http.Client 当前持有的 RoundTripper,常用于链路追踪 / 监控埋点
func (c *Client) GetTransport() http.RoundTripper

典型用法:把 reqx 配置过的 *http.Client 传给 oauth2

import (
    "context"
    "golang.org/x/oauth2"
    "gitee.com/hexug/go-tools/v2/reqx/rest"
)

cli := rest.NewProductionClient().
    SetTimeout(30 * time.Second).
    SetProxyUrl("http://proxy.internal:8080")

// 复用 reqx 配好的连接池 / 代理 / TLS,无需重新装配
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, cli.HTTPClient())
tokenSource := cfg.TokenSource(ctx, refreshToken)

注意事项(务必阅读)

  • 返回的是引用而非副本:外部修改 Timeout / Transport / Jar / CheckRedirect 字段会影响所有共享该 Client 的并发请求。 如需独立改造,请先 c.Clone() 派生新实例。
  • 绕过 Request 的请求会绕过中间层:直接通过 HTTPClient() 返回值发起请求时,reqx 的 RequestHook / TraceHook / 默认 Header / UA 注入 / SSRF 预检等能力都不会生效*, 因为这些能力都构建在 *Request 层之上。互操作场景下"绕开 Request 等于绕开它们"是预期行为,强烈建议优先使用 reqx 自身链式 API,仅在三方 SDK 强约束时才用本节方式。
  • Clone 后是独立实例:c.Clone() 派生的子 Client 与父 Client 持有互相独立的 *http.Client 与 *http.Transport,互不影响。

  • 路由组合:
    • Client 阶段 SetBaseURL / Group(Group 会克隆出新 Client)
    • Request 阶段 Group / Prefix / Suffix / URL / AbsURL
  • 超时控制:
    • SetTimeout:整体超时(拨号 → 握手 → 等响应头 → 读响应体的总闸刀)
    • SetConnectTimeout / SetTLSHandshakeTimeout / SetResponseHeaderTimeout / SetIdleConnTimeout / SetExpectContinueTimeout:分阶段精细控制
    • PerAttemptTimeout:重试场景下的"单次尝试"超时
    • 详见超时控制章节
  • 连接池调优:
    • SetMaxIdleConns / SetMaxIdleConnsPerHost / SetMaxConnsPerHost:按需调整连接池参数
    • 详见连接池调优章节
  • 生产推荐配置:
    • NewProductionClient:预设合理超时 + 连接池参数,适合内部服务调用
    • 详见生产推荐配置章节
  • 认证相关:
    • 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(对标 requests.Session):
    • NewSession():默认开启 cookiejar 的 Client 构造糖
    • Client.Close():释放底层连接池中的空闲连接
    • Client.DumpCookies / DumpCookiesToFile / LoadCookies / LoadCookiesFromFile:登录态落盘 / 还原
    • Client.SetParam / AddParam / SetParams:默认查询参数(API key / 租户 ID / 全局 trace 等)
    • 详见会话型 API章节

这些能力都遵循同一个设计原则:用链式 API 暴露配置,用测试用例给出权威示例;并且只要是 Client 与 Request 同名的方法,都遵循"未设置即继承、设置即覆盖"的语义。


压测与稳定性实测

下面是一次在仓库当前版本上的真实执行记录,可作为“内存安全性、性能、高 QPS 低延迟、低内存占用、稳定性”的验收基线。

测试环境:linux/amd64,CPU AMD EPYC 9754 128-Core Processor。 说明:性能数字与机器规格、内核参数、网络栈状态相关,跨机器只比较趋势,不直接比较绝对值。

如何测试(可直接复制执行)

# 1) 内存安全 + 数据竞争
go test -race -count=2 -timeout=600s \
  ./tests/unit/... ./tests/integration/... ./tests/concurrency/... \
  ./tests/retry/... ./tests/timeout/... ./tests/transport/... \
  ./tests/streaming/... ./tests/tls/... ./tests/pool/... ./tests/leak/... ./tests/security/...

# 2) 资源泄漏(含 FD 检查,Linux 推荐打开)
GOREQX_FD_LEAK_CHECK=1 go test -count=1 -timeout=180s ./tests/leak/...

# 3) 高并发稳定性(heavy chaos)
GOREQX_TEST_HEAVY=1 go test -count=1 -timeout=300s ./tests/chaos/...

# 4) 常规性能与内存占用(全量 benchmark)
GOREQX_BENCH_TIER=normal go test -bench=. -benchmem -run=^$ -count=1 -timeout=600s ./tests/benchmark/...

# 5) 高 QPS / 低延迟复核(固定迭代,避免默认 benchtime 下的采样失真)
GOREQX_BENCH_TIER=normal go test \
  -bench='BenchmarkConcurrent_GetEcho$|BenchmarkConcurrent_PostJSON$|BenchmarkGet_Small_NoConsume$|BenchmarkKeepAlive_Reused$|BenchmarkKeepAlive_NoReuse$' \
  -benchmem -run=^$ -count=1 -benchtime=10000x ./tests/benchmark/...

最终结果总览(双机型)

口径说明:

  • 8核16G 档:本次归档机器为 8 vCPU / 15Gi,可视为 16G 档位。
  • 112 vCPU / 378Gi 档:你要求按该档位汇总;本次归档环境文件记录为 112 vCPU / 378Gi,以下数据均按归档实测值给出。

8核16G档(基线)

  • 环境:go1.25.7 linux/amd64,8 vCPU / 15Gi
  • 安全结论:race、leak(FD)、stability(chaos+security+concurrency) 全通过
  • 热点性能(10000x):
    • BenchmarkConcurrent_GetEcho:26,670 ns/op(8-way parallel,per-op 摊算耗时;非串行延迟,不直接换算为单流 QPS)
    • BenchmarkConcurrent_PostJSON:30,563 ns/op(8-way parallel,per-op 摊算耗时;非串行延迟,不直接换算为单流 QPS)
    • BenchmarkGet_Small_NoConsume:132,102 ns/op(约 7,570 req/s)
    • BenchmarkKeepAlive_Reused:155,166 ns/op(约 6,445 req/s)
    • BenchmarkKeepAlive_NoReuse:350,644 ns/op(约 2,852 req/s)
  • 资源占用(Max RSS):race≈251MB、leak≈221MB、stability≈233MB、bench normal≈227MB、bench hotpath≈217MB
  • 结论:在中小机器上,库的稳定性与安全性已达可上线水准;连接复用场景具备明显吞吐优势。

高CPU复测

  • 环境:go1.25.8 X:nodwarf5 linux/amd64,112 vCPU / 378Gi
  • 安全结论:11 个包 race 全通过;leak(FD) 通过;chaos+security+concurrency 通过
  • 热点性能(10000x):
    • BenchmarkConcurrent_GetEcho:111,393 ns/op(8-way parallel,per-op 摊算耗时;非串行延迟,不直接换算为单流 QPS)
    • BenchmarkConcurrent_PostJSON:88,314 ns/op(8-way parallel,per-op 摊算耗时;非串行延迟,不直接换算为单流 QPS)
    • BenchmarkGet_Small_NoConsume:133,816 ns/op(约 7,472 req/s)
    • BenchmarkKeepAlive_Reused:150,979 ns/op(约 6,623 req/s)
    • BenchmarkKeepAlive_NoReuse:339,974 ns/op(约 2,941 req/s)
  • 资源占用(Max RSS):race≈283MB、leak≈237MB、stability≈233MB、bench normal≈1.20GB、bench hotpath≈236MB
  • 结论:在高并发与异常扰动下依旧稳定,串行单请求吞吐稳定在 2.9k~7.5k req/s 区间(KeepAlive_Reused 复用、KeepAlive_NoReuse 重建、Get_Small_NoConsume 单请求三类);并发路径以 ns/op 口径汇报,避免与单流 QPS 混淆;连接复用延迟降低约 55.6%、吞吐提升约 2.25x。

性能与安全达到什么水准

  • 安全性水准:生产可用(高)
    • race + leak + chaos + security + concurrency 在两档机器均通过。
    • 表明并发安全、资源回收、异常扰动恢复能力已达到工程化上线要求。
  • 性能水准:中高(偏稳健型)
    • 对常见 HTTP 调用链路,连接复用下可达稳定中高吞吐。
    • 非复用连接成本显著更高,符合网络栈与握手开销规律。
  • 资源效率水准:高
    • 热点单请求内存分配主要落在 12KB~39KB 区间。
    • 大机下仅全量 benchmark 阶段出现约 1.20GB 峰值,整体仍处于可控区间。

双机差异原因(为何部分指标不随核数线性提升)

  • 单核频率与调度差异:高核机器在虚拟化与调度策略下,单线程热点可能不如小机高频核。
  • 并发竞争放大:核数上升会放大锁竞争、队列竞争与缓存一致性成本。
  • 运行口径与环境背景负载:不同 Go 版本、系统背景任务、限流配置都会影响绝对值。
  • 测试形态差异:10000x 固定迭代更偏稳定横比,不代表所有线上流量模型的极限吞吐。

当前客户端库处于什么水准

  • 综合判断:go-reqx 已处于 生产可用、稳定性优先、性能可优化 的成熟水准。
  • 适用场景:微服务内部调用、网关到下游 API 调用、需要 tracing/hook/log 的可观测请求链路。

如何使用(按目标优先级)

最安全
  • 全部请求使用 context 超时与取消;按接口级别设置合理 timeout。
  • 默认开启 TLS 校验,不关闭证书校验;仅在受控内网临时放开。
  • 仅对幂等请求启用重试,并设置退避与最大重试次数。
  • CI 固化 race + leak + chaos/security/concurrency 作为发布门禁。
效率最高
  • 复用全局 Client 与连接池,避免每请求新建客户端。
  • 优先 Keep-Alive 复用;减少非必要 hook 与高频日志格式化。
  • 对热点接口使用固定请求模板,减少重复对象构建。
最省资源
  • 以流式处理大包体(避免整包读入内存)。
  • 限制并发与连接上限,设置合理 MaxIdleConns/MaxConnsPerHost。
  • 对非关键链路降低日志级别,减少分配和 I/O。
最中和(默认推荐)
  • 采用“连接复用 + 合理超时 + 幂等重试 + 关键链路观测”组合。
  • 保持 race/leak 常驻检查,性能基准按固定口径(如 10000x 或 -count=5)定期回归。
  • 线上以 P95/P99 延迟和错误率为主,吞吐作为辅助优化指标。

注意事项(避免误读)

  • tests/benchmark 使用了分层限额(ClampN)避免极限模式跑爆;在默认 -benchtime 下,个别“超快”用例会出现不具参考意义的极小 ns/op。
  • 生产对比建议统一使用固定迭代(如 -benchtime=10000x)或固定时长并重复多轮(例如 -count=5)再比较。
  • 若要做更强压测(extreme),可执行:
GOREQX_BENCH_TIER=extreme GOREQX_EXTREME=1 GOREQX_TEST_HEAVY=1 \
  go test -bench=. -benchmem -run=^$ -count=1 -timeout=1200s ./tests/benchmark/...
GOREQX_EXTREME=1 GOREQX_TEST_HEAVY=1 \
  go test -count=1 -timeout=600s ./tests/chaos/... ./tests/security/... ./tests/concurrency/...

目录与示例

  • 项目根 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 协议开源。


🙌 致谢

感谢 Gitee 提供稳定的代码托管平台。

Built with ❤️ by hexug

如果你觉得这个项目对你有帮助,不妨点个 Star ⭐ 支持一下!

Directories

Path Synopsis
internal
compress
Package compress 已迁移到 gitee.com/hexug/go-tools/v2/common/compress。
Package compress 已迁移到 gitee.com/hexug/go-tools/v2/common/compress。
negotiator
触发 format/serialize 包的 init() 注册,确保即便外部只 import reqx/internal/negotiator,全部 codec(json/cbor/msgpack/protobuf/xml/yaml/ yaml_k8s/post_form/text_plain/text_html)也已注册到全局注册表。
触发 format/serialize 包的 init() 注册,确保即便外部只 import reqx/internal/negotiator,全部 codec(json/cbor/msgpack/protobuf/xml/yaml/ yaml_k8s/post_form/text_plain/text_html)也已注册到全局注册表。
security
Package security 是 reqx 内部对 common/netx/httpsec 的薄转发壳。
Package security 是 reqx 内部对 common/netx/httpsec 的薄转发壳。
ssrf
Package ssrf 是 reqx 内部对 common/netx/ssrf 的薄转发壳。
Package ssrf 是 reqx 内部对 common/netx/ssrf 的薄转发壳。
demo command
Package rest
Package rest
tests
internal/fixtures
Package fixtures 提供 go-reqx 测试体系下的"标准输入数据"。
Package fixtures 提供 go-reqx 测试体系下的"标准输入数据"。
internal/testkit/benchmark
Package benchmark 提供 go-reqx 测试体系下的 *testing.B 辅助工具。
Package benchmark 提供 go-reqx 测试体系下的 *testing.B 辅助工具。
internal/testkit/fuzz
Package fuzz 提供 go-reqx 测试体系下的 fuzz 种子库与谓词工具。
Package fuzz 提供 go-reqx 测试体系下的 fuzz 种子库与谓词工具。
internal/testkit/helper
Package helper —— assert.go
Package helper —— assert.go
internal/testkit/leak
Package leak —— cleanup.go
Package leak —— cleanup.go
internal/testkit/netutil
Package netutil 提供 go-reqx 测试体系下的网络小工具。
Package netutil 提供 go-reqx 测试体系下的网络小工具。
internal/testkit/proxy
Package proxy —— connect_proxy.go
Package proxy —— connect_proxy.go
internal/testkit/server
Package server 提供 go-reqx 测试体系所需的 mock 服务端集合。
Package server 提供 go-reqx 测试体系所需的 mock 服务端集合。
internal/testkit/tls
Package tls 提供 go-reqx 测试体系下"把内存 PEM 落到临时文件"等 TLS 配套工具。
Package tls 提供 go-reqx 测试体系下"把内存 PEM 落到临时文件"等 TLS 配套工具。
internal/testkit/transport
Package transport —— canned_roundtripper.go
Package transport —— canned_roundtripper.go

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL