Skip to content

HttpClient

HttpClient 发起请求,用 IHttpClientFactory 收敛配置与处理器管道,把 Chat 传输逻辑从业务代码里拆出来。

适用场景

  • Chat 应用需要调用模型服务、向量服务或内部网关,但不想在业务代码里反复拼 URL、补请求头和处理超时。
  • 同一个上游服务有统一的 baseAddressAuthorizationUser-Agent 等默认配置。
  • 需要把日志、认证、重试这类横切逻辑收进 DelegatingHandler,而不是散落在每个调用点。
  • 应用已经接入 DI,希望 HTTP 客户端和其他基础设施一样通过容器管理。

当这些问题同时出现时,要统一的不是单个请求的写法,而是 HTTP 调用的配置、复用和扩展方式。

核心问题

应用里真正棘手的通常不是"怎么写一个 GET 请求",而是"底层发送器该活多久"。如果长期持有同一套底层处理器,连接虽然能复用,但地址解析结果也可能长期停留在旧状态,DNS 变化后请求可能继续打到过期地址;如果每次调用都重新创建,连接池复用又会被破坏。容器集成的核心价值,就是把这类底层处理器生命周期和轮换问题交给工厂管理。

当前 HTTP 客户端能力分成两层:

  • soulsoft_net_http:提供 HttpClientHttpRequestMessageHttpResponseMessageHttpContent 及多种正文类型。
  • soulsoft_extensions_http:提供 IHttpClientFactoryaddHttpClient()HttpClientBuilder 和处理器生命周期管理。

HttpClient 更像对外暴露的调用入口,本身负责承接 get()post()send() 这类使用方式;真正负责解析并执行 HTTP 请求、与上游建立连接的是 HttpClientHandler——它位于处理器链末端。IHttpClientFactory 负责按名称创建客户端并管理底层处理器生命周期,DelegatingHandler 则负责把日志、认证等逻辑串进请求路径。

text
业务代码
  │  面向 HttpClient 发请求,不感知管道内部

HttpClient  ───────────── 入口,组织默认头和超时


DelegatingHandler ────── 日志(记录请求/响应)
  │  │
  │  └────────────────── 认证(自动补 Authorization)
  │  │
  │  └────────────────── 重试(非 2xx 自动重试)


HttpClientHandler ────── 管道末端,真正建立连接、发送 HTTP 请求


上游 HTTP 服务

这张图的重点只有一个:业务代码面向 HttpClient 这个入口发起调用,真正的发送复杂度留在 HttpClientHandler 和处理器链内部,认证、默认头和处理器管道则在客户端构建阶段完成。

相关依赖

包名作用
soulsoft_net_httpHttpClient、请求/响应对象、正文类型、处理器抽象
soulsoft_serializationJsonContent.create()readFromJson<T>() 依赖的序列化能力
soulsoft_extensions_httpIHttpClientFactory、命名客户端、类型化客户端、处理器管道配置
soulsoft_extensions_options选项模式,适合承载 Chat 上游地址、API Key、超时等配置
soulsoft_extensions_injectionDI 集成

只想直接发请求时,最低只需要 soulsoft_net_http。一旦要做命名客户端、类型化客户端或 Handler 管道,就应同时引入 soulsoft_extensions_http

快速开始

先看最小路径:创建 HttpClient,构造一个 Chat 请求,再读取响应。

cangjie
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 => ... } 创建带初始配置的客户端,效果与手动设置 baseAddresstimeout 相同。

整个流程只有三个角色:

角色职责常用入口
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,用完应关闭。常用信息包括:

  • statusCode
  • isSuccessStatusCode
  • headers
  • content
  • request

HttpContent 则负责正文读取与反序列化:

读取方式入口说明
字符串readAsString()首次读取会把内容载入缓冲区
字节数组readAsByteArray()同样会走缓冲区
readAsStream()适合流式读取
JSON 对象readFromJson<T>()T 需满足序列化约束

readAsString()readAsByteArray() 会先把内容缓冲下来,因此同一份响应正文可以被多次读取,不会出现"前一个读取者把流消耗掉,后一个读取者拿不到数据"的问题。

常见用法

发起 GET/POST 请求

先看 getString()get() 的区别:前者直接返回字符串并自动校验 2xx,后者把状态码处理权交还给你。

cangjie
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

cangjie
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.AuthorizationHeaderNames.AcceptHeaderNames.ContentTypeHeaderNames.UserAgent 等。

读取 JSON 响应

先看 readFromJson<T>(),它把响应正文和序列化对象直接连起来。

cangjie
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 上游会被反复调用,把公共前缀和默认头放到客户端上更稳定。

cangjie
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")

请求开始发出后,baseAddresstimeout 不能再改。

容器集成

单独 new HttpClient() 适合脚本、最小示例或一次性调用。进入应用级开发后,只要开始出现多个上游服务、默认头、认证逻辑或类型化客户端需求,就应该把注册收敛到容器。

对应用级代码来说,容器集成最重要的价值不是"注册写法更统一",而是把 HttpClient 背后的底层处理器生命周期和轮换交给工厂处理,让连接复用和 DNS 刷新不再由业务代码自己兜底。

为什么需要容器管理

容器集成不是为了把 new 换成 getOrThrow(),而是为了把 HTTP 调用相关的共性规则收在一处:

  • 统一注册不同上游服务的 baseAddresstimeout 和默认头。
  • 通过 IHttpClientFactory 按名称创建客户端,避免业务层重复初始化。
  • 通过类型化客户端把 Chat、网关、向量检索等领域请求封装成明确类型。
  • 通过 DelegatingHandler 把认证、日志、重试等横切逻辑接到统一管道里。
  • 由工厂管理底层处理器生命周期,在复用连接的同时提供地址刷新机会,减少 DNS 变化直接暴露给业务层。

如果业务代码里同时在做"拼地址、补头、取配置、写认证、决定重试",还要自己考虑"这个客户端该活多久、上游地址变了怎么办",那就说明这些职责已经应该进入容器注册阶段。

命名客户端

命名客户端适合"一个应用要访问多个上游服务"的场景。注册阶段负责配置,使用阶段只拿名字创建。

cangjie
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 包进类型化客户端。

cangjie
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 适合把"每个请求都要做"的逻辑抽出来。

cangjie
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,因此可以继续把处理器按注册顺序接进请求链路。

cangjie
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 把公共配置收在一处:

cangjie
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 替换底层传输:

cangjie
services.addHttpClient("chat.transport") { client =>
    client.baseAddress = URL.parse("https://api.example.com")
}
.configurePrimaryHttpMessageHandler<CustomHandler>()

CustomHandler 需继承 HttpMessageHandler(非 DelegatingHandler),因为它是处理器链末端。替换后,DelegatingHandler 管道不受影响,横切逻辑保持不变。

生命周期约定

这一节最该关注的是:容器集成把"客户端实例怎么创建"升级成了"底层处理器生命周期如何管理"。

如果长期持有同一个底层客户端或处理器实例,连接虽然能复用,但上游地址解析结果也可能长期停留在旧状态。对微服务、网关或 Kubernetes Service 这类地址可能变化的场景,这会直接影响请求是否还能正确命中新的后端实例。

HttpClient 接入 IHttpClientFactory 后,调用方应关注"请求和响应对象怎么用",而不是"底层处理器怎么回收"。处理器生命周期要点:

  • IHttpClientFactory 按名称构建客户端并管理底层处理器生命周期。工厂创建的 HttpClientcloseHandler=false 构造——调用 close() 不会释放底层共享处理器(处理器生命周期由工厂统一管理)。但用完 HttpClient 后仍建议调用 close() 释放客户端自身的资源
  • setHandlerLifetime 控制轮换频率(默认 2 分钟,K8s 环境可缩到 1-3 分钟),不要手动定时重建
  • Duration.Zero = 永不过期,仅内网固定 IP 使用
  • baseAddresstimeout 在注册阶段设好,运行时不要改。需动态地址时注册多个命名客户端

最佳实践

收敛注册入口:extend ServiceCollection

以 Chat 传输层为例,更稳妥的做法是把"配置 + 客户端 + 容器扩展入口"收在一起,让调用方只保留一行注册。

cangjie
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 接入日志管道,统一记录请求/响应信息

生产建议

  • baseAddresstimeout 只能在首次发送请求前设置;请求一旦开始发送,再修改会抛异常。
  • getString()getByteArray()getStream() 都会把非 2xx 视为异常路径。如果你需要自己处理错误状态码,先拿 HttpResponseMessage
  • HttpResponseMessage 是资源对象,用完应关闭;getString() 这类快捷方法内部已经处理了响应关闭。
  • IHttpClientFactory 当前解决的是"按名称构建客户端、应用配置和管理处理器生命周期"。文档不把未在源码中暴露的特定高级策略 API 写成现状。
  • 多个上游服务应注册为多个命名客户端,而不是共用一个 HttpClient 实例并在调用时动态改地址——后者会让 defaultRequestHeaderstimeout 的语义变得不可控。

警告

如果你直接把认证头、地址拼接和模型名称散落在业务方法里,短期能跑,长期会让调用点越来越难统一替换。HTTP 客户端最容易失控的不是"发不出请求",而是配置和横切逻辑四处复制。

常见问题

什么时候直接 new HttpClient(),什么时候用 IHttpClientFactory

只写一个独立脚本或最小示例时,直接 new HttpClient() 更直接。进入应用级开发后,只要出现多个上游服务、默认头或 Handler 管道需求,就应切到 IHttpClientFactory

getString()get() + readAsString() 有什么区别?

getString() 会自动校验成功状态码并返回字符串。get() 则保留完整响应对象,适合你自己判断状态码、读取头或做更细粒度的错误处理。

请求级头和 defaultRequestHeaders 冲突时谁生效?

请求级头优先。当前实现会按头名称整体判断:如果请求对象里已经有同名头,默认头会跳过该名称,不做合并。

类型化客户端一定要真的发请求吗?

不一定。它的核心价值是把"Chat 领域如何构造请求"收进一个明确类型里。即使某些方法只负责构造 HttpRequestMessage,也依然比在业务层拼字符串更稳定。