09. 客户端 SDK
1. 背景与原理
1.1 为什么不用 reqwest
项目选择 hyper + hyper-util + tower 而非 reqwest,原因有三:
- 可替换 connector:需要支持 Unix domain socket(含 Linux abstract socket),reqwest 的 connector 定制能力有限。
- Tower 生态复用:超时、限流、重试、追踪都可以用标准 Tower 层,与服务端同构。
- 只依赖 models:客户端不依赖服务端任何代码,避免"为了调 HTTP 而引入整个 Axum 栈"。
1.2 版本协商:Discovery
SOVD 的 base_uri 由服务端下发而非客户端拼装。因此客户端需要一个两阶段过程:
1. 用"版本无关"的 root URI 访问 /version-info
2. 从 sovd_info[] 中挑选匹配的版本,取其 base_uri
3. 用该 base_uri 构造绑定版本的 Client
这样客户端可以自动适配 1.1/1.2/未来版本,也支持网关级联。
1.3 延迟求值(Lazy Request Builder)
链式构建器(.schema().groups([...]).send())的价值:参数累积期间不发请求,只有 send() 才真正发起,便于组合与复用。
2. 当前实现架构
2.1 类型结构
52:57:opensovd-client/src/client.rs
pub struct ClientBuilder<Conn = HttpConnector, Layers = Identity> {
base_uri: Option<http::Uri>,
timeout: Option<Duration>,
connector: Conn,
layer: Layers,
}
pub struct Client {
base_uri: http::Uri,
timeout: Option<Duration>,
http: BoxCloneSyncService<Request<Full<Bytes>>, Response<BoxResponseBody>, BoxError>,
}
BoxCloneSyncService 类型擦除后仍可 Clone + Send + Sync,因此 Client 可 #[derive(Clone)] 且廉价克隆。
Discovery 额外持有 Arc<OnceCell<VersionInfo<Value>>>(跨克隆共享的 /version-info 缓存)。
2.2 API 层次
| 层次 | API |
|---|---|
| 实体列举 | list_components() / list_apps() / list_areas() → ListEntitiesRequest |
| 实体句柄 | component(id) / app(id) / area(id) → Component / App / Area |
| 数据 | .data() → DataRequest → .read() / .write(value) |
| 通用 | get::<T>(path, query) / put(path, query, body) |
| 发现 | Discovery::versions::<V>() / select::<V>(pred) |
| 传输 | connect(uri) / connect_unix(uri, path) / connect_unix_abstract(uri, name) |
2.3 UnixConnector
55:81:opensovd-client/src/unix.rs
impl tower_service::Service<Uri> for UnixConnector {
type Response = TokioIo<UnixStream>;
type Error = std::io::Error;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _uri: Uri) -> Self::Future {
let addr = self.addr.clone();
Box::pin(async move {
let stream = match addr {
SocketAddr::Path(ref path) => UnixStream::connect(path).await?,
SocketAddr::Abstract(ref name) => {
let path = Path::new(std::ffi::OsStr::from_bytes(name));
UnixStream::connect(path).await?
}
};
Ok(TokioIo::new(stream))
})
}
}
_uri 被完全忽略 (host/port/scheme 全部丢弃),所有请求打到同一 socket。集成方式:靠 hyper_util 的 Connect 对 Service<Uri> 的 blanket impl,通过 ClientBuilder::connector() 换掉泛型参数。
3. 核心流程与算法
3.1 一次 GET 请求全流程
client.component("ecu1").data().data("voltage").read().schema(true).send()
│
├─ ① 路径拼装:"/components/ecu1/data/voltage"(ID 在此处 encode)
├─ ② build_uri_with_query(base_uri, path, [("include-schema","true")])
│ base 去尾斜杠 → push path → "?k=v&k2=v2"
├─ ③ http::Request::builder().method(GET).uri(&uri).body(Full::new(Bytes::new()))
├─ ④ self.http.clone().oneshot(req)
│ └─ [timeout 包裹发送 + 收 body 全过程]
├─ ⑤ collect() 全量收 body
├─ ⑥ 状态检查:!is_success() → 尝试解析 GenericError → Error::ApiError{status, error}
└─ ⑦ serde_json::from_slice::<Response<T>>(&bytes)
3.2 URI 拼接与编码
407:435:opensovd-client/src/client.rs
pub(crate) fn encode(segment: &str) -> String {
percent_encoding::utf8_percent_encode(segment, percent_encoding::NON_ALPHANUMERIC).to_string()
}
pub(crate) fn build_uri_with_query(base_uri: &http::Uri, path: &str,
query: &[(&str, &str)]) -> Result<http::Uri> {
let mut base = base_uri.to_string();
if path.starts_with('/') && base.ends_with('/') { base.pop(); }
base.push_str(path);
Ok(build_uri_query_string(&base, query).parse()?)
}
encode() 只用于路径段 (实体 ID、data ID);查询串不编码。
3.3 链式构建器的延迟求值
11:29:opensovd-client/src/list.rs
pub struct ListEntitiesRequest<'a> {
pub(crate) client: &'a Client,
pub(crate) path: String,
pub(crate) schema: bool,
}
impl ListEntitiesRequest<'_> {
pub fn schema(mut self, include: bool) -> Self { self.schema = include; self }
pub async fn send(&self) -> Result<Response<Entities>> {
self.client.get(&self.path, schema_query(self.schema)).await
}
}
要点 :结构体只持有 &'a Client 与累积状态;send(&self) 是借用而非消费,构建器可重复使用。
3.4 版本协商算法
35:86:opensovd-client/src/discovery.rs
pub async fn versions<V: DeserializeOwned>(&self) -> Result<Vec<SovdInfo<V>>> {
let info = self.cache
.get_or_try_init(|| self.inner.get::<VersionInfo<serde_json::Value>>("/version-info", &[]))
.await?;
// 把缓存中的 raw Value 重新类型化为请求的 V
info.sovd_info.iter().map(|s| Ok(SovdInfo {
version: s.version.clone(),
base_uri: s.base_uri.clone(),
vendor_info: s.vendor_info.clone().map(serde_json::from_value).transpose()?,
})).collect()
}
pub async fn select<V: DeserializeOwned>(&self, mut pred: impl FnMut(&SovdInfo<V>) -> bool)
-> Result<Client> {
let advertised = self.versions::<V>().await?
.into_iter().find(|s| pred(s))
.ok_or(Error::NoMatchingVersion)?
.base_uri.0;
Ok(Client { base_uri: advertised.parse()?, timeout: self.inner.timeout, http: self.inner.http.clone() })
}
算法要点:
OnceCell缓存:/version-info同会话只取一次,克隆共享- vendor_info 保留为 raw
Value:一份缓存服务任意具体V类型(避免为每种 V 各缓存一次) select复用已有 transport(connector + layers),只替换 base_uri
4. 待完善与风险
4.1 URI 构造(严重)
- 查询串完全不编码(高) :
build_uri_query_string直接format!("{k}={v}"),含&、=、#、空格的值会破坏查询串 。data.rs因手动调encode()侥幸规避,但公共get(path, query)API 不安全。 encode()用NON_ALPHANUMERIC过度编码(中) :把-._~等非保留字符也编码,产生非规范 URI。功能上服务端解码可恢复,但与日志、网关缓存键、精确串匹配易出问题。应使用与服务端一致的 path-segment 编码集(见 07 章 §3.3)。- base 与 path 拼接边界处理不全(中) :仅当
path以/开头时才去尾斜杠;base 自带 query/fragment 时拼接直接损坏。
4.2 健壮性(严重)
Client::connect()不设超时(高) :connect/connect_unix走builder().base_uri(..).build(),未设置 timeout ,请求可能无限期挂起。MCP server 与 gateway 的默认路径正是connect()------这是实际会被触发的风险。- 无重试 / 熔断(中) :网络抖动即失败;agent/长任务场景需要。社区已有对应 issue(
feat(client): Add retry policy)。 - 全量
collect()无大小上限(中):错误响应与大数据项都会全量读入内存。 - 无
accept/user-agent头(低):不利于服务端内容协商与问题排查。
4.3 错误模型(中)
- 所有非 2xx 统一
ApiError(中) :虽有status字段,但没有is_not_found()/is_unauthorized()等便捷判断,调用方(含 MCP)无法按状态分支,只能比字符串。 Error::Hyper是死变体(低) :错误已被MapErrLayer盒化为Error::Service,该变体实际不可达,误导使用者。put()丢弃响应体(低) :成功但空 body 的get::<T>会退化为Error::Json。
4.4 API 覆盖(中)
- 关系查询返回裸
Entities(中) :Component::hosts()/belongs_to()、Area::contains()返回Entities而非Response<Entities>,与list_*不一致,且拿不到 schema。 Area缺数据 API(中) :只有contains(),没有data()/data_categories()/data_groups()------但服务端路由也只给 component/app 提供数据路由,故属于协议侧缺口在客户端的映射。- 无
capabilities()便捷方法(中) :需手动get::<EntityCapabilities>;社区已有 issue(feat(client): add capabilities() to query entity capabilities)。
4.5 UDS(中)
- 不校验 scheme(中) :即使 base_uri 是
https://,UDS connector 也走明文 HTTP/1------配置错误时"静默不安全"。 - abstract socket 名无长度/空值校验(低) :
sun_path有上限,超限会导致连接失败且错误信息不友好。 - 无 connect timeout / retry(中) :
poll_ready恒Ready(无背压)。
4.6 建议的改进顺序
| 优先级 | 事项 |
|---|---|
| P0 | 查询串编码 + 与服务端统一的 path 编码集 |
| P0 | connect() 系列提供默认超时(或强制显式设置) |
| P1 | 错误类型细分(is_not_found/is_unauthorized...),供 MCP 等调用方分支 |
| P1 | 补 capabilities() 与关系查询的一致性返回 |
| P2 | 可选重试层(Tower retry + 指数退避) |
| P2 | 响应体大小上限 |
| P3 | 移除死变体 Error::Hyper |
5. 关键代码位置
| 内容 | 路径 |
|---|---|
ClientBuilder 与 build() |
opensovd-client/src/client.rs:52-183 |
Client 与连接入口 |
opensovd-client/src/client.rs:212-390 |
get / put / request |
opensovd-client/src/client.rs:295-355 |
| URI 拼接与编码 | opensovd-client/src/client.rs:398-435 |
UnixConnector |
opensovd-client/src/unix.rs:46-81 |
| 链式构建器(列举) | opensovd-client/src/list.rs:11-29 |
| 链式构建器(数据) | opensovd-client/src/data.rs:14-149 |
| 实体句柄 | opensovd-client/src/entities/{component,app,area}.rs |
| 版本协商 | opensovd-client/src/discovery.rs:35-129 |
| 错误模型 | opensovd-client/src/error.rs:9-49 |