HttpClient
用
HttpClient发起请求,用IHttpClientFactory收敛配置与处理器管道,把 Chat 传输逻辑从业务代码里拆出来。
适用场景
- Chat 应用需要调用模型服务、向量服务或内部网关,但不想在业务代码里反复拼 URL、补请求头和处理超时。
- 同一个上游服务有统一的
baseAddress、Authorization、User-Agent等默认配置。 - 需要把日志、认证、重试这类横切逻辑收进
DelegatingHandler,而不是散落在每个调用点。 - 应用已经接入 DI,希望 HTTP 客户端和其他基础设施一样通过容器管理。
当这些问题同时出现时,要统一的不是单个请求的写法,而是 HTTP 调用的配置、复用和扩展方式。
核心问题
应用里真正棘手的通常不是"怎么写一个 GET 请求",而是"底层发送器该活多久"。如果长期持有同一套底层处理器,连接虽然能复用,但地址解析结果也可能长期停留在旧状态,DNS 变化后请求可能继续打到过期地址;如果每次调用都重新创建,连接池复用又会被破坏。容器集成的核心价值,就是把这类底层处理器生命周期和轮换问题交给工厂管理。
当前 HTTP 客户端能力分成两层:
soulsoft_net_http:提供HttpClient、HttpRequestMessage、HttpResponseMessage、HttpContent及多种正文类型。soulsoft_extensions_http:提供IHttpClientFactory、addHttpClient()、HttpClientBuilder和处理器生命周期管理。
HttpClient 更像对外暴露的调用入口,本身负责承接 get()、post()、send() 这类使用方式;真正负责解析并执行 HTTP 请求、与上游建立连接的是 HttpClientHandler——它位于处理器链末端。IHttpClientFactory 负责按名称创建客户端并管理底层处理器生命周期,DelegatingHandler 则负责把日志、认证等逻辑串进请求路径。
业务代码
│ 面向 HttpClient 发请求,不感知管道内部
▼
HttpClient ───────────── 入口,组织默认头和超时
│
▼
DelegatingHandler ────── 日志(记录请求/响应)
│ │
│ └────────────────── 认证(自动补 Authorization)
│ │
│ └────────────────── 重试(非 2xx 自动重试)
│
▼
HttpClientHandler ────── 管道末端,真正建立连接、发送 HTTP 请求
│
▼
上游 HTTP 服务这张图的重点只有一个:业务代码面向 HttpClient 这个入口发起调用,真正的发送复杂度留在 HttpClientHandler 和处理器链内部,认证、默认头和处理器管道则在客户端构建阶段完成。
相关依赖
| 包名 | 作用 |
|---|---|
soulsoft_net_http | HttpClient、请求/响应对象、正文类型、处理器抽象 |
soulsoft_serialization | JsonContent.create() 和 readFromJson<T>() 依赖的序列化能力 |
soulsoft_extensions_http | IHttpClientFactory、命名客户端、类型化客户端、处理器管道配置 |
soulsoft_extensions_options | 选项模式,适合承载 Chat 上游地址、API Key、超时等配置 |
soulsoft_extensions_injection | DI 集成 |
只想直接发请求时,最低只需要 soulsoft_net_http。一旦要做命名客户端、类型化客户端或 Handler 管道,就应同时引入 soulsoft_extensions_http。
快速开始
先看最小路径:创建 HttpClient,构造一个 Chat 请求,再读取响应。
import soulsoft_net_http.*
main(): Int64 {
let client = HttpClient()
let request = HttpRequestMessage(HttpMethod.Post, "https://api.example.com/v1/chat/completions")
request.headers.add("Accept", "application/json")
request.content = JsonContent.create("""{"model":"gpt-4o","messages":["hello"]}""")
let response = client.send(request)
println(response.content.readAsString())
client.close()
return 0
}不想依赖 DI 时,也可以用静态工厂 HttpClient.create { builder => ... } 创建带初始配置的客户端,效果与手动设置 baseAddress、timeout 相同。
整个流程只有三个角色:
| 角色 | 职责 | 常用入口 |
|---|---|---|
HttpClient | 暴露请求入口并组织处理器链使用 | get() / post() / send() |
HttpRequestMessage | 描述方法、地址、头和正文 | HttpMethod / headers / content |
HttpResponseMessage | 承载状态码、头和响应正文 | statusCode / content / ensureSuccessStatusCode() |
信息
本页仓颉示例已通过 cjpm build 验证,包括"最佳实践"章节里的 Chat 客户端注册代码。
核心概念
请求管线:从 HttpClient 到上游
HttpClient 当前更像一个面向调用方的窗口。它暴露统一入口,把请求交给内部处理器链,而不是自己承担底层网络发送细节。
HttpClient 对外公开了两类入口:
| 用法 | 入口 | 适合场景 |
|---|---|---|
| 快捷方法 | get()、post()、put()、delete() | 请求结构简单 |
| 自定义请求 | send(HttpRequestMessage) | 需要自己控制头、正文或请求对象 |
其中 getString()、getByteArray()、getStream() 会先检查响应状态码,非 2xx 时抛 HttpRequestException。如果你要自己决定非 2xx 怎么处理,优先先拿 HttpResponseMessage。
HttpClient 继承自 HttpMessageInvoker,实现了 Resource 接口——用完应调用 close() 释放底层连接资源。getString() 等快捷方法内部已自动处理资源关闭。
如果说 HttpClient 解决的是"调用方从哪里进入",那 HttpClientHandler 解决的就是"请求最终由谁发出去"。
HttpClientHandler位于处理器链末端,负责真正执行 HTTP 请求。DelegatingHandler负责在它之前一层层包裹横切逻辑。HttpClient只是把调用入口、默认配置和处理器链使用方式组织起来,并不等于底层发送器本身。
因此在理解容器集成、生命周期管理和 DNS 刷新时,真正需要被工厂管理和轮换关注的是底层处理器,而不是把 HttpClient 单纯看成"发请求的那个类"。
默认配置:baseAddress、timeout 与默认头
HttpClient 有三个高频配置点:
| 配置 | 作用 | 注意点 |
|---|---|---|
baseAddress | 解析相对路径请求 | 首次发送请求后不能再修改 |
timeout | 为请求写入超时 | 首次发送请求后不能再修改 |
defaultRequestHeaders | 为每个请求附加默认头 | 请求级同名头优先,默认头会跳过该名称 |
这样设计的原因是:客户端一旦开始发送请求,基础地址和超时就应该稳定,否则同一个实例的行为会在运行中漂移,排错成本会明显升高。
响应处理:HttpResponseMessage 与正文读取
HttpResponseMessage 实现了 Resource,用完应关闭。常用信息包括:
statusCodeisSuccessStatusCodeheaderscontentrequest
HttpContent 则负责正文读取与反序列化:
| 读取方式 | 入口 | 说明 |
|---|---|---|
| 字符串 | readAsString() | 首次读取会把内容载入缓冲区 |
| 字节数组 | readAsByteArray() | 同样会走缓冲区 |
| 流 | readAsStream() | 适合流式读取 |
| JSON 对象 | readFromJson<T>() | T 需满足序列化约束 |
readAsString() 和 readAsByteArray() 会先把内容缓冲下来,因此同一份响应正文可以被多次读取,不会出现"前一个读取者把流消耗掉,后一个读取者拿不到数据"的问题。
常见用法
发起 GET/POST 请求
先看 getString() 和 get() 的区别:前者直接返回字符串并自动校验 2xx,后者把状态码处理权交还给你。
import soulsoft_net_http.*
let client = HttpClient()
let modelsJson = client.getString("https://api.example.com/v1/models")
try (response = client.get("https://api.example.com/v1/models")) {
response.ensureSuccessStatusCode()
let body = response.content.readAsString()
println(body)
}
client.close()如果只关心成功结果,用 getString();如果还要判断状态码、读取头或调试请求对象,保留 HttpResponseMessage。
构造 JSON、表单和原始正文
先看正文类型,它们的职责是把"要发什么"转换成标准 HttpContent。
import soulsoft_net_http.*
let jsonContent = JsonContent.create("""{"model":"gpt-4o","messages":["hello"]}""")
let plainText = StringContent("hello", "text/plain")
let form = FormUrlContent([
("model", "gpt-4o"),
("prompt", "hello")
])
let bytes = ByteArrayContent("hello".toArray())
bytes.headers.add("Content-Type", "application/octet-stream")上游是 JSON API 时优先用 JsonContent.create();文件上传用 MultipartFormDataContent,流式请求体用 StreamContent。
HeaderNames 常量
头名称建议使用 HeaderNames 预定义常量替代手写字符串,减少拼写错误。常用常量如 HeaderNames.Authorization、HeaderNames.Accept、HeaderNames.ContentType、HeaderNames.UserAgent 等。
读取 JSON 响应
先看 readFromJson<T>(),它把响应正文和序列化对象直接连起来。
import soulsoft_net_http.*
import soulsoft_serialization.*
import soulsoft_serialization.macros.*
@Serialization
class ChatCompletionResponse {
public var reply: String = ""
}
let client = HttpClient()
try (response = client.get("https://api.example.com/v1/chat/result")) {
response.ensureSuccessStatusCode()
let completion = response.content.readFromJson<ChatCompletionResponse>()
println(completion.reply)
}
client.close()业务层拿到的是明确的类型,而不是到处传递原始 JSON 字符串。
使用 baseAddress 和默认头
如果同一个 Chat 上游会被反复调用,把公共前缀和默认头放到客户端上更稳定。
import soulsoft_net_http.*
import stdx.encoding.url.*
let client = HttpClient()
client.baseAddress = URL.parse("https://api.example.com")
client.timeout = Duration.second * 30
client.defaultRequestHeaders.add("Authorization", "Bearer sk-demo")
client.defaultRequestHeaders.add("User-Agent", "ChatClient/1.0")
let request = HttpRequestMessage(HttpMethod.Get, "/v1/models")
request.headers.add("Accept", "application/json")请求开始发出后,baseAddress 和 timeout 不能再改。
容器集成
单独 new HttpClient() 适合脚本、最小示例或一次性调用。进入应用级开发后,只要开始出现多个上游服务、默认头、认证逻辑或类型化客户端需求,就应该把注册收敛到容器。
对应用级代码来说,容器集成最重要的价值不是"注册写法更统一",而是把 HttpClient 背后的底层处理器生命周期和轮换交给工厂处理,让连接复用和 DNS 刷新不再由业务代码自己兜底。
为什么需要容器管理
容器集成不是为了把 new 换成 getOrThrow(),而是为了把 HTTP 调用相关的共性规则收在一处:
- 统一注册不同上游服务的
baseAddress、timeout和默认头。 - 通过
IHttpClientFactory按名称创建客户端,避免业务层重复初始化。 - 通过类型化客户端把 Chat、网关、向量检索等领域请求封装成明确类型。
- 通过
DelegatingHandler把认证、日志、重试等横切逻辑接到统一管道里。 - 由工厂管理底层处理器生命周期,在复用连接的同时提供地址刷新机会,减少 DNS 变化直接暴露给业务层。
如果业务代码里同时在做"拼地址、补头、取配置、写认证、决定重试",还要自己考虑"这个客户端该活多久、上游地址变了怎么办",那就说明这些职责已经应该进入容器注册阶段。
命名客户端
命名客户端适合"一个应用要访问多个上游服务"的场景。注册阶段负责配置,使用阶段只拿名字创建。
import soulsoft_extensions_http.*
import soulsoft_extensions_injection.*
import soulsoft_net_http.*
import stdx.encoding.url.*
let services = ServiceCollection()
services.addHttpClient("chat.transport") { client =>
client.baseAddress = URL.parse("https://api.example.com")
client.timeout = Duration.second * 30
client.defaultRequestHeaders.add("User-Agent", "ChatClient/1.0")
}
// 设置的是底层处理器复用时长,不是 HttpClient 变量本身的存活时间
.setHandlerLifetime(Duration.minute * 5)
let provider = services.build()
let factory = provider.getOrThrow<IHttpClientFactory>()
let client = factory.createClient("chat.transport")
let request = HttpRequestMessage(HttpMethod.Get, "/v1/models")
request.headers.add("Accept", "application/json")
client.close()同一个应用里可以同时维护多个上游服务配置,不需要让业务代码记住每个客户端应该怎样初始化。
setHandlerLifetime(Duration.minute * 5) 表示底层处理器最多复用 5 分钟,之后工厂会在后续创建流程中轮换处理器,为连接状态更新和地址重新解析提供机会。当前默认值是 2 分钟;最小值是 1 秒;Duration.Zero 表示永不过期。
类型化客户端
如果业务层不应该直接操作路径和 JSON 细节,就把 HttpClient 包进类型化客户端。
import soulsoft_extensions_http.*
import soulsoft_extensions_injection.*
import soulsoft_net_http.*
import stdx.encoding.url.*
class ChatClient {
private let client: HttpClient
public init(client: HttpClient) {
this.client = client
}
public func getModels(): String {
return this.client.getString("/v1/models")
}
}
let services = ServiceCollection()
services.addHttpClient<ChatClient, ChatClient> { client =>
client.baseAddress = URL.parse("https://api.example.com")
}
let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()类型化客户端的价值是把"这个上游怎么调用"变成一个明确类型,而不是让每个调用点都自行拼字符串、构造请求和处理公共头。
用 DelegatingHandler 抽离横切逻辑
DelegatingHandler 适合把"每个请求都要做"的逻辑抽出来。
import soulsoft_extensions_options.*
import soulsoft_net_http.*
class ChatOptions {
public var apiKey: String = ""
}
class ChatAuthHandler <: DelegatingHandler {
private let options: ChatOptions
public init(options: IOptions<ChatOptions>) {
super()
this.options = options.value
}
protected override func send(request: HttpRequestMessage): HttpResponseMessage {
request.headers.set("Authorization", "Bearer ${options.apiKey}")
return super.send(request)
}
}认证头交给 Handler 处理,业务请求只负责方法、路径和正文。
构建 Handler 管道
addHttpClient() 返回 HttpClientBuilder,因此可以继续把处理器按注册顺序接进请求链路。
import soulsoft_extensions_http.*
import soulsoft_extensions_injection.*
import stdx.encoding.url.*
let services = ServiceCollection()
services.addHttpClient("chat.transport") { client =>
client.baseAddress = URL.parse("https://api.example.com")
}
// 先定处理器生命周期,再按顺序把横切逻辑接进管道
.setHandlerLifetime(Duration.minute * 5)
// 注册顺序 = 请求经过处理器的顺序
.addHttpMessageHandler<LoggingHandler>()
.addHttpMessageHandler<ChatAuthHandler>()这样做的意义是把"每个请求都要做"的逻辑从业务代码里拿掉。调用方仍然只关心 get()、post() 或 send(),但请求会按顺序经过日志、认证或其他自定义处理器;底层处理器生命周期则由 setHandlerLifetime(...) 统一控制,而不是由调用方自己决定何时替换。
全局默认配置
多个命名客户端共享相同的超时、User-Agent 等设置时,用 configureHttpClientDefaults 把公共配置收在一处:
let services = ServiceCollection()
services.configureHttpClientDefaults { builder =>
builder.configureHttpClient { client =>
client.timeout = Duration.second * 30
client.defaultRequestHeaders.add("User-Agent", "ChatClient/1.0")
}
}
// 之后注册的命名客户端会继承上述默认值
services.addHttpClient("chat.transport") { client =>
client.baseAddress = URL.parse("https://api.example.com")
}当前实现限制
当前版本中 configureHttpClientDefaults 的默认值仅对无名称客户端(createClient())生效,暂不会自动继承到 "chat.transport" 等命名客户端。如需多客户端共享配置,建议封装 extend ServiceCollection 方法统一注册(见最佳实践)。
替换底层传输处理器
如果默认的 HttpClientHandler 不满足需求(如需要配置代理、自定义 TLS),通过 configurePrimaryHttpMessageHandler 替换底层传输:
services.addHttpClient("chat.transport") { client =>
client.baseAddress = URL.parse("https://api.example.com")
}
.configurePrimaryHttpMessageHandler<CustomHandler>()CustomHandler 需继承 HttpMessageHandler(非 DelegatingHandler),因为它是处理器链末端。替换后,DelegatingHandler 管道不受影响,横切逻辑保持不变。
生命周期约定
这一节最该关注的是:容器集成把"客户端实例怎么创建"升级成了"底层处理器生命周期如何管理"。
如果长期持有同一个底层客户端或处理器实例,连接虽然能复用,但上游地址解析结果也可能长期停留在旧状态。对微服务、网关或 Kubernetes Service 这类地址可能变化的场景,这会直接影响请求是否还能正确命中新的后端实例。
把 HttpClient 接入 IHttpClientFactory 后,调用方应关注"请求和响应对象怎么用",而不是"底层处理器怎么回收"。处理器生命周期要点:
IHttpClientFactory按名称构建客户端并管理底层处理器生命周期。工厂创建的HttpClient以closeHandler=false构造——调用close()不会释放底层共享处理器(处理器生命周期由工厂统一管理)。但用完HttpClient后仍建议调用close()释放客户端自身的资源- 用
setHandlerLifetime控制轮换频率(默认 2 分钟,K8s 环境可缩到 1-3 分钟),不要手动定时重建 Duration.Zero= 永不过期,仅内网固定 IP 使用baseAddress和timeout在注册阶段设好,运行时不要改。需动态地址时注册多个命名客户端
最佳实践
收敛注册入口:extend ServiceCollection
以 Chat 传输层为例,更稳妥的做法是把"配置 + 客户端 + 容器扩展入口"收在一起,让调用方只保留一行注册。
import soulsoft_extensions_http.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
import soulsoft_net_http.*
import stdx.encoding.url.*
class ChatOptions {
public var endpoint: String = ""
public var apiKey: String = ""
public var model: String = "gpt-4o"
}
class ChatClient {
private let client: HttpClient
private let options: ChatOptions
public init(client: HttpClient, options: IOptions<ChatOptions>) {
this.client = client
this.options = options.value
}
public func createChatRequest(prompt: String): HttpRequestMessage {
let request = HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions")
request.content = JsonContent.create(
"""{"model":"${options.model}","messages":["${prompt}"]}"""
)
return request
}
}
extend ServiceCollection {
public func addChatClient(configureAction: (ChatOptions) -> Unit): HttpClientBuilder {
this.configure<ChatOptions>(configureAction)
return this.addHttpClient<ChatClient, ChatClient>({ client, sp =>
let options = sp.getOrThrow<IOptions<ChatOptions>>().value
client.baseAddress = URL.parse(options.endpoint)
client.defaultRequestHeaders.add("Authorization", "Bearer ${options.apiKey}")
client.defaultRequestHeaders.add("User-Agent", "ChatClient/1.0")
})
}
}
let services = ServiceCollection()
services.addChatClient { options =>
options.endpoint = "https://api.example.com"
options.apiKey = "sk-demo"
options.model = "gpt-4o"
// addChatClient 返回 HttpClientBuilder,因此还能继续补生命周期和客户端细节
}.setHandlerLifetime(Duration.minute * 5)
.configureHttpClient { client =>
client.defaultRequestHeaders.add("Accept", "application/json")
}
let provider = services.build()
let chatClient = provider.getOrThrow<ChatClient>()
let request = chatClient.createChatRequest("hello")三个要点:
ChatOptions承载地址、密钥和模型。ChatClient只封装 Chat 领域的请求构造。extend ServiceCollection把注册收成一个入口,调用方只补配置值。
如果后续要补日志、重试或自定义 Handler,可以继续在 addChatClient(...) 返回的 HttpClientBuilder 上链式追加,而不必改 ChatClient 的构造函数。
相关专题
| 模块 | 组合方式 |
|---|---|
| 依赖注入 | 通过 addHttpClient() 注册命名/类型化客户端,由容器管理处理器生命周期 |
| 选项 | 用 IOptions<ChatOptions> 承载上游地址、API Key 等配置,在客户端注册时读取 |
| 序列化 | JsonContent.create() 和 readFromJson<T>() 依赖 soulsoft_serialization |
| 日志 | 通过 DelegatingHandler 接入日志管道,统一记录请求/响应信息 |
生产建议
baseAddress和timeout只能在首次发送请求前设置;请求一旦开始发送,再修改会抛异常。getString()、getByteArray()和getStream()都会把非 2xx 视为异常路径。如果你需要自己处理错误状态码,先拿HttpResponseMessage。HttpResponseMessage是资源对象,用完应关闭;getString()这类快捷方法内部已经处理了响应关闭。IHttpClientFactory当前解决的是"按名称构建客户端、应用配置和管理处理器生命周期"。文档不把未在源码中暴露的特定高级策略 API 写成现状。- 多个上游服务应注册为多个命名客户端,而不是共用一个
HttpClient实例并在调用时动态改地址——后者会让defaultRequestHeaders和timeout的语义变得不可控。
警告
如果你直接把认证头、地址拼接和模型名称散落在业务方法里,短期能跑,长期会让调用点越来越难统一替换。HTTP 客户端最容易失控的不是"发不出请求",而是配置和横切逻辑四处复制。
常见问题
什么时候直接 new HttpClient(),什么时候用 IHttpClientFactory?
只写一个独立脚本或最小示例时,直接 new HttpClient() 更直接。进入应用级开发后,只要出现多个上游服务、默认头或 Handler 管道需求,就应切到 IHttpClientFactory。
getString() 和 get() + readAsString() 有什么区别?
getString() 会自动校验成功状态码并返回字符串。get() 则保留完整响应对象,适合你自己判断状态码、读取头或做更细粒度的错误处理。
请求级头和 defaultRequestHeaders 冲突时谁生效?
请求级头优先。当前实现会按头名称整体判断:如果请求对象里已经有同名头,默认头会跳过该名称,不做合并。
类型化客户端一定要真的发请求吗?
不一定。它的核心价值是把"Chat 领域如何构造请求"收进一个明确类型里。即使某些方法只负责构造 HttpRequestMessage,也依然比在业务层拼字符串更稳定。