【Eclipse OpenSOVD学习之十】客户端 SDK

09. 客户端 SDK

1. 背景与原理

1.1 为什么不用 reqwest

项目选择 hyper + hyper-util + tower 而非 reqwest,原因有三:

  1. 可替换 connector:需要支持 Unix domain socket(含 Linux abstract socket),reqwest 的 connector 定制能力有限。
  2. Tower 生态复用:超时、限流、重试、追踪都可以用标准 Tower 层,与服务端同构。
  3. 只依赖 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 构造(严重)

  1. 查询串完全不编码(高) :build_uri_query_string 直接 format!("{k}={v}"),含 &、=、#、空格的值会破坏查询串 。data.rs 因手动调 encode() 侥幸规避,但公共 get(path, query) API 不安全。
  2. encode() 用 NON_ALPHANUMERIC 过度编码(中) :把 -._~ 等非保留字符也编码,产生非规范 URI。功能上服务端解码可恢复,但与日志、网关缓存键、精确串匹配易出问题。应使用与服务端一致的 path-segment 编码集(见 07 章 §3.3)。
  3. base 与 path 拼接边界处理不全(中) :仅当 path 以 / 开头时才去尾斜杠;base 自带 query/fragment 时拼接直接损坏。

4.2 健壮性(严重)

  1. Client::connect() 不设超时(高) :connect/connect_unix 走 builder().base_uri(..).build(),未设置 timeout ,请求可能无限期挂起。MCP server 与 gateway 的默认路径正是 connect()------这是实际会被触发的风险。
  2. 无重试 / 熔断(中) :网络抖动即失败;agent/长任务场景需要。社区已有对应 issue(feat(client): Add retry policy)。
  3. 全量 collect() 无大小上限(中):错误响应与大数据项都会全量读入内存。
  4. 无 accept/user-agent 头(低):不利于服务端内容协商与问题排查。

4.3 错误模型(中)

  1. 所有非 2xx 统一 ApiError(中) :虽有 status 字段,但没有 is_not_found()/is_unauthorized() 等便捷判断,调用方(含 MCP)无法按状态分支,只能比字符串。
  2. Error::Hyper 是死变体(低) :错误已被 MapErrLayer 盒化为 Error::Service,该变体实际不可达,误导使用者。
  3. put() 丢弃响应体(低) :成功但空 body 的 get::<T> 会退化为 Error::Json。

4.4 API 覆盖(中)

  1. 关系查询返回裸 Entities(中) :Component::hosts()/belongs_to()、Area::contains() 返回 Entities 而非 Response<Entities>,与 list_* 不一致,且拿不到 schema。
  2. Area 缺数据 API(中) :只有 contains(),没有 data()/data_categories()/data_groups()------但服务端路由也只给 component/app 提供数据路由,故属于协议侧缺口在客户端的映射。
  3. 无 capabilities() 便捷方法(中) :需手动 get::<EntityCapabilities>;社区已有 issue(feat(client): add capabilities() to query entity capabilities)。

4.5 UDS(中)

  1. 不校验 scheme(中) :即使 base_uri 是 https://,UDS connector 也走明文 HTTP/1------配置错误时"静默不安全"。
  2. abstract socket 名无长度/空值校验(低) :sun_path 有上限,超限会导致连接失败且错误信息不友好。
  3. 无 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
相关推荐
71-310 小时前
MySQL密码重置
数据库·笔记·学习·mysql
我命由我1234511 小时前
Photoshop - Photoshop 快速共享作品
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop
我命由我1234511 小时前
自动化 / 智能装配的对象、内容、层级
运维·学习·职场和发展·自动化·求职招聘·职场发展·学习方法
AI职业加油站11 小时前
大模型开发工程师证书怎么考?零基础学习路径与价值拆解
大数据·人工智能·学习·职场和发展·数据分析
江苏世纪龙科技11 小时前
让汽车“透明”起来——汽车结构原理VR教学软件
学习
Answer1st11 小时前
【嵌入式学习】嵌入式原理知识-ADC(七)
学习
bllovepigpig12 小时前
操作系统个人学习笔记(四):K8s基础
笔记·学习·kubernetes
司南-704912 小时前
AI infra 学习笔记(三):预处理·第一步——QKV 投影到底算了多少
人工智能·笔记·学习
喜欢打篮球的普通人13 小时前
MiniMind 学习笔记(十):优化器、学习率和数据设置——训练稳定性的三块基石
笔记·python·学习
老王爱玩车13 小时前
深入理解指针5
c语言·开发语言·学习