域名客户端
域名客户端(DomainClient)提供针对特定域名的请求管理,自动维护 Cookie 和请求头。它解决了普通 Client 在多请求间需要手动传递认证头、追踪 Cookie 的痛点——会话状态自动注入每个请求,响应 Cookie 自动捕获。
DomainClient 架构
├── client 底层 Client(复用连接池、中间件链)
├── baseURL 域名作用域(如 https://api.example.com/v1)
├── parsedURL 缓存解析结果(避免每次请求重复 url.Parse)
├── domain 主机名(不含端口)
└── SessionManager 会话状态
├── headers map[string]string 会话级请求头
└── cookies map[string]*Cookie 会话级 CookieDomainClienter 接口
type DomainClienter interface {
Client
URL() string
Domain() string
SetHeader(key, value string) error
SetHeaders(headers map[string]string) error
DeleteHeader(key string)
ClearHeaders()
GetHeaders() map[string]string
SetCookie(cookie *http.Cookie) error
SetCookies(cookies []*http.Cookie) error
DeleteCookie(name string)
ClearCookies()
GetCookies() []*http.Cookie
GetCookie(name string) *http.Cookie
Session() *SessionManager
}DomainClienter 同时实现 Client 接口(含 Get/Post/Put/Patch/Delete/Head/Options/Request/Download/Close)和会话管理方法。推荐使用接口类型而非具体类型,便于测试和替换实现。
完整方法表
HTTP 请求方法(继承自 Client)
| 方法 | 签名 | 说明 |
|---|---|---|
Get | (path string, opts ...RequestOption) (*Result, error) | GET 请求 |
Post | (path string, opts ...RequestOption) (*Result, error) | POST 请求 |
Put | (path string, opts ...RequestOption) (*Result, error) | PUT 请求 |
Patch | (path string, opts ...RequestOption) (*Result, error) | PATCH 请求 |
Delete | (path string, opts ...RequestOption) (*Result, error) | DELETE 请求 |
Head | (path string, opts ...RequestOption) (*Result, error) | HEAD 请求 |
Options | (path string, opts ...RequestOption) (*Result, error) | OPTIONS 请求 |
Request | (ctx, method, path string, opts ...RequestOption) (*Result, error) | 带上下文的通用请求 |
Download | (ctx, path string, cfg *DownloadConfig, opts ...RequestOption) (*DownloadResult, error) | 文件下载 |
Close | () error | 关闭客户端释放资源 |
URL 访问方法
| 方法 | 返回类型 | 说明 |
|---|---|---|
URL() | string | 基础 URL(构造时传入的 baseURL) |
Domain() | string | 域名(主机名,不含端口) |
Session() | *SessionManager | 底层会话管理器 |
会话头管理
| 方法 | 说明 |
|---|---|
SetHeader(key, value string) error | 设置单个会话头(校验 CRLF 安全性) |
SetHeaders(headers map[string]string) error | 批量设置会话头 |
DeleteHeader(key string) | 删除单个会话头 |
ClearHeaders() | 清空全部会话头 |
GetHeaders() map[string]string | 获取会话头副本 |
会话 Cookie 管理
| 方法 | 说明 |
|---|---|
SetCookie(cookie *http.Cookie) error | 设置单个会话 Cookie |
SetCookies(cookies []*http.Cookie) error | 批量设置会话 Cookie |
DeleteCookie(name string) | 按名称删除 Cookie |
ClearCookies() | 清空全部 Cookie |
GetCookies() []*http.Cookie | 获取全部 Cookie 副本 |
GetCookie(name string) *http.Cookie | 按名称获取 Cookie 副本 |
NewDomain
func NewDomain(baseURL string, cfg Config) (DomainClienter, error)创建域名作用域客户端。Cookie 自动启用。传入 Config 值,或使用 NewDomainDefault(baseURL) 作为零参快捷方式。
// 使用默认配置(或调用 NewDomainDefault 效果相同)
dc, err := httpc.NewDomain("https://api.example.com", httpc.DefaultConfig())
if err != nil {
log.Fatal(err)
}
defer dc.Close()
// 使用自定义配置
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 60 * time.Second
dc, err := httpc.NewDomain("https://api.example.com", cfg)
if err != nil {
log.Fatal(err)
}
defer dc.Close()参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
baseURL | string | 基础 URL(必须包含 scheme 和 host) |
cfg | Config | 配置值(推荐用 DefaultConfig() 或预设函数获取) |
返回: DomainClienter 接口(非具体类型 *DomainClient)。
错误条件:
| 条件 | 错误信息 |
|---|---|
baseURL 缺少 scheme 或 host | base URL must include scheme and host |
| 配置校验失败 | invalid configuration: ... |
Cookie 自动启用
NewDomain 内部强制设置 cfg.Connection.EnableCookies = true,无论你传入的 cfg 是否启用 Cookie。这是因为域名客户端的核心价值就是跨请求维护 Cookie 会话。
NewDomainDefault
func NewDomainDefault(baseURL string) (DomainClienter, error)便捷构造,等价于 NewDomain(baseURL, DefaultConfig())。
dc, err := httpc.NewDomainDefault("https://api.example.com")
if err != nil {
log.Fatal(err)
}
defer dc.Close()HTTP 方法
所有方法接受相对路径或绝对 URL:
// 相对路径:自动拼接 baseURL
result, err := dc.Get("/users")
result, err := dc.Post("/users", httpc.WithJSON(data))
result, err := dc.Put("/users/1", httpc.WithJSON(data))
result, err := dc.Patch("/users/1", httpc.WithJSON(data))
result, err := dc.Delete("/users/1")
result, err := dc.Head("/users/1")
result, err := dc.Options("/users")
// 绝对 URL:直接使用
result, err := dc.Get("https://other-api.com/data")Request
result, err := dc.Request(ctx, "GET", "/users", options...)带上下文的通用请求方法,支持超时和取消控制。DomainClient 通过实现此方法满足 Client 接口。
URL 拼接规则详解
buildURL 方法负责将请求路径与 baseURL 拼接。规则如下:
buildURL(pathStr):
① pathStr 为空 → 返回 baseURL
② pathStr 以 http:// 或 https:// 开头 → 绝对 URL,直接返回
③ 否则 → 相对路径拼接:
a. 克隆缓存的 parsedURL(避免修改原始)
b. 解析 pathStr 分离 path / query / fragment
c. result.Path = path.Join(basePath, parsedPath)
d. 尾斜杠保留:原路径以 / 结尾则拼接结果也以 / 结尾
e. 路径穿越防护:结果必须在 base 路径作用域内
f. 查询参数合并:base 查询参数 + path 查询参数
g. fragment 透传拼接示例
| 输入路径 | 拼接结果(baseURL = https://api.example.com/v1) |
|---|---|
"" | https://api.example.com/v1 |
/users | https://api.example.com/v1/users |
users | https://api.example.com/v1/users |
/users/ | https://api.example.com/v1/users/(尾斜杠保留) |
/users?page=1 | https://api.example.com/v1/users?page=1 |
search?q=go | https://api.example.com/v1/search?q=go |
https://other.com/api | https://other.com/api(绝对 URL 直接使用) |
查询参数合并
当 baseURL 自带查询参数时,请求路径的查询参数会追加到其后:
baseURL = https://api.example.com/v1?lang=zh
path = /users?page=1
拼接结果 = https://api.example.com/v1/users?lang=zh&page=1路径穿越防护
buildURL 检查拼接后的路径是否在 base 路径作用域内,防止路径穿越攻击:
baseURL = https://api.example.com/v1
path = ../admin/delete ← path.Join 清理后为 /admin/delete
检查:/admin/delete 是否在 /v1 作用域内?
结果:否 → 返回错误 "path escapes base URL scope"绝对 URL 识别
只有 http:// 和 https:// 开头的路径会被识别为绝对 URL;其他协议(如 ftp://)不会被识别为绝对路径,会按相对路径拼接,通常导致请求失败。base 路径为空或 / 时不做作用域检查。
会话自动管理
域名客户端的会话管理分为三个环节:
请求生命周期中的会话管理:
① prepareOptions()(发送前)
从 SessionManager 读取会话头和 Cookie
→ 转为 RequestOption 注入到请求中
② captureFromOptions()(发送前)
从用户传入的 RequestOption 中提取 Cookie 和头
→ 存入 SessionManager(有则更新,无则跳过)
③ UpdateFromResult()(发送后)
从响应中提取 Set-Cookie
→ 存入 SessionManagerdc, _ := httpc.NewDomainDefault("https://api.example.com")
// 会话头:注入到每个后续请求
dc.SetHeader("Authorization", "Bearer token-abc")
dc.SetHeader("Accept-Language", "zh-CN")
// 登录后响应的 Set-Cookie 自动捕获
dc.Post("/login", httpc.WithJSON(loginData))
// 后续请求自动携带登录 Cookie
// 也可手动设置 Cookie
dc.SetCookie(&http.Cookie{Name: "session", Value: "xyz"})
// 查询会话状态
dc.GetCookie("session") // → *http.Cookie 副本
dc.GetHeaders() // → map[string]string 副本线程安全
SessionManager 内部使用 sync.RWMutex 保护,SetHeader/SetCookie/GetCookie 等方法可安全并发调用。prepareOptions 采用读后写的非原子序列——会话状态设计为最终一致,并发请求可能在 prepareOptions 时交错,但每个请求在 prepareOptions 时刻捕获的是一致的快照。
选项双重执行注意事项
prepareSessionOptions 在请求发送前对用户传入的 RequestOption 应用两次:一次在 captureFromOptions 中用于捕获会话状态,一次在 client.Request 中用于实际请求。
prepareSessionOptions(options):
① managedOptions = prepareOptions() ← 读取会话状态
② allOptions = managedOptions + options ← 合并
③ captureFromOptions(options) ← 对临时请求应用一次(捕获会话)
④ return allOptions → client.Request() ← 对实际请求应用第二次避免带副作用的选项
以下选项在 DomainClient 中会执行两次,导致非预期行为:
| 有问题的选项 | 原因 |
|---|---|
| 计数器递增 | 每次请求递增两次 |
| nonce 随机生成 | 捕获阶段和请求阶段生成不同值 |
WithOnRequest / WithOnResponse | 回调被显式清除,不会重复触发(安全) |
如需带副作用的选项,请改用底层 Client 直接发请求,或在选项外部管理状态。
Download 方法
func (dc *DomainClient) Download(ctx context.Context, path string, cfg *DownloadConfig, options ...RequestOption) (*DownloadResult, error)下载文件到 cfg.FilePath,path 相对于 baseURL 拼接。与包级 Download 和 Client.Download 签名一致——Download 是贯穿三者的唯一规范下载入口。cfg 不能为 nil,cfg.FilePath 必须设置(否则返回 ErrEmptyFilePath)。
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/report.pdf"
cfg.Overwrite = true
result, err := dc.Download(ctx, "/files/report.pdf", cfg)下载的响应 Cookie 会自动捕获到会话中(经 UpdateFromCookies)。与 Request 一样,请求选项会应用两次。
与 Client 接口的关系
DomainClient 通过编译时断言同时实现两个接口:
var _ Client = (*DomainClient)(nil) // 实现 Client 接口
var _ DomainClienter = (*DomainClient)(nil) // 实现 DomainClienter 接口接口层级:
Doer ← 最小接口(仅 Request)
└── Client ← + HTTP 方法 + Download + Close
└── DomainClienter ← + URL/Domain/Session + 会话头/Cookie 管理
└── *DomainClient ← 具体实现DomainClienter 内嵌 Client,因此任何接受 Client 参数的函数都能接受 DomainClienter。这使得 DomainClient 可在需要 Client 的地方无缝使用,同时提供额外的会话管理能力。
完整 REST API 客户端封装示例
以下示例展示如何用 DomainClient 封装一个 GitHub API 客户端,自动管理认证头和分页查询参数。
package main
import (
"fmt"
"time"
"github.com/cybergodev/httpc"
)
// GitHubClient 封装 GitHub REST API
type GitHubClient struct {
dc httpc.DomainClienter
}
// NewGitHubClient 创建 GitHub API 客户端
func NewGitHubClient(token string) (*GitHubClient, error) {
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 30 * time.Second
dc, err := httpc.NewDomain("https://api.github.com", cfg)
if err != nil {
return nil, err
}
// 设置会话级请求头
if err := dc.SetHeader("Authorization", "Bearer "+token); err != nil {
return nil, fmt.Errorf("set auth header: %w", err)
}
if err := dc.SetHeader("Accept", "application/vnd.github+json"); err != nil {
return nil, fmt.Errorf("set accept header: %w", err)
}
if err := dc.SetHeader("X-GitHub-Api-Version", "2022-11-28"); err != nil {
return nil, fmt.Errorf("set api version: %w", err)
}
return &GitHubClient{dc: dc}, nil
}
// Close 释放资源
func (g *GitHubClient) Close() error { return g.dc.Close() }
// GetUser 获取用户信息(相对路径自动拼接 baseURL)
func (g *GitHubClient) GetUser(username string) (*httpc.Result, error) {
return g.dc.Get(fmt.Sprintf("/users/%s", username))
}
// ListUserRepos 列出用户仓库(带分页参数)
func (g *GitHubClient) ListUserRepos(username string, page, perPage int) (*httpc.Result, error) {
return g.dc.Get(fmt.Sprintf("/users/%s/repos?page=%d&per_page=%d", username, page, perPage))
}
func main() {
client, err := NewGitHubClient("ghp_your_token_here")
if err != nil {
panic(err)
}
defer client.Close()
// 每个请求自动携带 Authorization、Accept、X-GitHub-Api-Version 头
result, err := client.GetUser("torvalds")
if err != nil {
panic(err)
}
fmt.Printf("状态码: %d\n", result.StatusCode())
// 经 Unmarshal 解析 JSON 响应
var user struct {
Login string `json:"login"`
}
if err := result.Unmarshal(&user); err != nil {
panic(err)
}
fmt.Printf("用户名: %s\n", user.Login)
repos, err := client.ListUserRepos("torvalds", 1, 5)
if err != nil {
panic(err)
}
fmt.Printf("仓库列表状态码: %d\n", repos.StatusCode())
// 输出示例:
// 状态码: 200
// 用户名: torvalds
// 仓库列表状态码: 200
}接口返回值
NewDomain 和 NewDomainDefault 返回 DomainClienter 接口而非 *DomainClient 具体类型,方便在测试中用 mock 替换。需要访问具体类型时做类型断言即可。