Skip to content

域名客户端

域名客户端(DomainClient)提供针对特定域名的请求管理,自动维护 Cookie 和请求头。它解决了普通 Client 在多请求间需要手动传递认证头、追踪 Cookie 的痛点——会话状态自动注入每个请求,响应 Cookie 自动捕获。

text
DomainClient 架构
├── client         底层 Client(复用连接池、中间件链)
├── baseURL        域名作用域(如 https://api.example.com/v1)
├── parsedURL      缓存解析结果(避免每次请求重复 url.Parse)
├── domain         主机名(不含端口)
└── SessionManager 会话状态
      ├── headers  map[string]string  会话级请求头
      └── cookies  map[string]*Cookie 会话级 Cookie

DomainClienter 接口

go
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获取会话头副本
方法说明
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

go
func NewDomain(baseURL string, cfg Config) (DomainClienter, error)

创建域名作用域客户端。Cookie 自动启用。传入 Config 值,或使用 NewDomainDefault(baseURL) 作为零参快捷方式。

go
// 使用默认配置(或调用 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()

参数说明:

参数类型说明
baseURLstring基础 URL(必须包含 scheme 和 host)
cfgConfig配置值(推荐用 DefaultConfig() 或预设函数获取)

返回: DomainClienter 接口(非具体类型 *DomainClient)。

错误条件:

条件错误信息
baseURL 缺少 scheme 或 hostbase URL must include scheme and host
配置校验失败invalid configuration: ...

Cookie 自动启用

NewDomain 内部强制设置 cfg.Connection.EnableCookies = true,无论你传入的 cfg 是否启用 Cookie。这是因为域名客户端的核心价值就是跨请求维护 Cookie 会话。

NewDomainDefault

go
func NewDomainDefault(baseURL string) (DomainClienter, error)

便捷构造,等价于 NewDomain(baseURL, DefaultConfig())

go
dc, err := httpc.NewDomainDefault("https://api.example.com")
if err != nil {
    log.Fatal(err)
}
defer dc.Close()

HTTP 方法

所有方法接受相对路径或绝对 URL:

go
// 相对路径:自动拼接 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

go
result, err := dc.Request(ctx, "GET", "/users", options...)

带上下文的通用请求方法,支持超时和取消控制。DomainClient 通过实现此方法满足 Client 接口。

URL 拼接规则详解

buildURL 方法负责将请求路径与 baseURL 拼接。规则如下:

text
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
/usershttps://api.example.com/v1/users
usershttps://api.example.com/v1/users
/users/https://api.example.com/v1/users/(尾斜杠保留)
/users?page=1https://api.example.com/v1/users?page=1
search?q=gohttps://api.example.com/v1/search?q=go
https://other.com/apihttps://other.com/api(绝对 URL 直接使用)

查询参数合并

baseURL 自带查询参数时,请求路径的查询参数会追加到其后:

text
baseURL  = https://api.example.com/v1?lang=zh
path     = /users?page=1
拼接结果 = https://api.example.com/v1/users?lang=zh&page=1

路径穿越防护

buildURL 检查拼接后的路径是否在 base 路径作用域内,防止路径穿越攻击:

text
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 路径为空或 / 时不做作用域检查。

会话自动管理

域名客户端的会话管理分为三个环节:

text
请求生命周期中的会话管理:

  ① prepareOptions()(发送前)
     从 SessionManager 读取会话头和 Cookie
     → 转为 RequestOption 注入到请求中

  ② captureFromOptions()(发送前)
     从用户传入的 RequestOption 中提取 Cookie 和头
     → 存入 SessionManager(有则更新,无则跳过)

  ③ UpdateFromResult()(发送后)
     从响应中提取 Set-Cookie
     → 存入 SessionManager
go
dc, _ := 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 中用于实际请求。

text
prepareSessionOptions(options):
  ① managedOptions = prepareOptions()        ← 读取会话状态
  ② allOptions = managedOptions + options    ← 合并
  ③ captureFromOptions(options)              ← 对临时请求应用一次(捕获会话)
  ④ return allOptions → client.Request()     ← 对实际请求应用第二次

避免带副作用的选项

以下选项在 DomainClient 中会执行两次,导致非预期行为:

有问题的选项原因
计数器递增每次请求递增两次
nonce 随机生成捕获阶段和请求阶段生成不同值
WithOnRequest / WithOnResponse回调被显式清除,不会重复触发(安全)

如需带副作用的选项,请改用底层 Client 直接发请求,或在选项外部管理状态。

Download 方法

go
func (dc *DomainClient) Download(ctx context.Context, path string, cfg *DownloadConfig, options ...RequestOption) (*DownloadResult, error)

下载文件到 cfg.FilePathpath 相对于 baseURL 拼接。与包级 DownloadClient.Download 签名一致——Download 是贯穿三者的唯一规范下载入口。cfg 不能为 nil,cfg.FilePath 必须设置(否则返回 ErrEmptyFilePath)。

go
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 通过编译时断言同时实现两个接口:

go
var _ Client = (*DomainClient)(nil)           // 实现 Client 接口
var _ DomainClienter = (*DomainClient)(nil)   // 实现 DomainClienter 接口
text
接口层级:
  Doer                                    ← 最小接口(仅 Request)
    └── Client                             ← + HTTP 方法 + Download + Close
          └── DomainClienter               ← + URL/Domain/Session + 会话头/Cookie 管理
                └── *DomainClient          ← 具体实现

DomainClienter 内嵌 Client,因此任何接受 Client 参数的函数都能接受 DomainClienter。这使得 DomainClient 可在需要 Client 的地方无缝使用,同时提供额外的会话管理能力。

完整 REST API 客户端封装示例

以下示例展示如何用 DomainClient 封装一个 GitHub API 客户端,自动管理认证头和分页查询参数。

go
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
}

接口返回值

NewDomainNewDomainDefault 返回 DomainClienter 接口而非 *DomainClient 具体类型,方便在测试中用 mock 替换。需要访问具体类型时做类型断言即可。

另见