【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 的 ConnectService<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_unixbuilder().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_readyReady(无背压)。

4.6 建议的改进顺序

优先级 事项
P0 查询串编码 + 与服务端统一的 path 编码集
P0 connect() 系列提供默认超时(或强制显式设置)
P1 错误类型细分(is_not_found/is_unauthorized...),供 MCP 等调用方分支
P1 capabilities() 与关系查询的一致性返回
P2 可选重试层(Tower retry + 指数退避)
P2 响应体大小上限
P3 移除死变体 Error::Hyper

5. 关键代码位置

内容 路径
ClientBuilderbuild() 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
相关推荐
gjf05_051 小时前
人该怎样活着呢?版本74.5
学习
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】鼠标点下的坐标为什么会被“吸”走?LibreCAD 对象捕捉系统解析
开发语言·c++·qt·学习·系统架构
明德扬1 小时前
AD9653采集模块怎样连接FPGA底板?从ADC采样到数据处理
学习·fpga开发·fpga
IT古董1 小时前
《FDE前沿部署工程师实战教程》11 - 企业Agent部署实战:Docker、API Gateway与生产环境
人工智能·学习
Terra.K2 小时前
JAVA职业探索和学习----中间件开发目标
java·学习·中间件
动词ing2 小时前
【学习笔记】数据结构(哈希表基础+哈希集合+哈希映射+计数+查找)
数据结构·学习·哈希算法
kaixin_啊啊2 小时前
中国研究生数学建模竞赛(华为杯)学习笔记——数据预处理全流程
人工智能·笔记·学习·数学建模·ai·大模型·数据预处理
dadaobusi8 小时前
学习:RV lkvm SBI
java·学习·spring
传奇开心果编程10 小时前
【Xilem 0.4 基础语法学与练】第一课:从零到计数器
学习·rust·前端框架