geopro/docs/superpowers/specs/2026-06-11-apiclient-async-...

16 KiB
Raw Blame History

设计ApiClient 异步化DatasetDetail 路径试点)

日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。 后续会按此模式铺开到导航/登录路径(本期不做)。

1. 背景

geopro 现网络栈三层全同步阻塞:

  • ApiClientsrc/net/):单个 QNetworkAccessManagerget/postJson 内部用 QNetworkReply + QEventLoop 死等响应。共享 cookie / JSESSIONID。
  • RepositoryApiDatasetRepositoryApiProjectRepository):同步调 api_.get/postJson,解析后返回 typed 值或抛 std::runtime_error
  • ControllerDatasetDetailControllerWorkbenchNavController):在 slot 内同步调 repoemit 结果信号。

核心问题:每次请求都用 QEventLoop 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 dd/ert/inversion/rows/{id} 请求因服务端网格化波动 14s,期间界面完全卡死。

QEventLoop 阻塞的副作用是可重入:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot因此现有控制器到处用 busy_ 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。

QNetworkAccessManager 本就是异步设计;当前架构用 QEventLoop 把它人为变同步,是反模式。本设计回归 Qt 原生异步。

2. 目标与非目标

目标

  1. 数据集详情路径(原数据 + 网格数据)全程不阻塞 UI 线程
  2. 慢请求在飞时切换/关闭数据集页签可主动 abort 过期请求,结果不回灌。
  3. 同一逻辑加载内的多个独立请求并发发出(原数据 2 个、网格 3 个),缩短总等待。
  4. 加载期间给用户轻量"加载中"反馈(不再因冻结而"看起来在响应")。
  5. 产出可复用的异步原语ApiCall / ApiBatch / 句柄模式),供后续导航/登录路径照搬。
  6. 现有测试不退化;新增异步路径的单测可离线运行。

非目标(本期不做)

  • WorkbenchNavController / ApiProjectRepository / AuthService / 登录流程——它们继续走同步 ApiClient.get/postJson
  • 做后台工作线程:网络是 I/O 密集,QNetworkAccessManager 原生异步即可,不引入 QThread/QtConcurrent
  • 追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。
  • LocalSampleRepository(它实现旧同步 IDatasetRepository,但不经详情控制器使用)。

3. 已定决策(及理由)

决策 选择 理由
范围 仅 DatasetDetail 路径 风险小、最痛点14s rows先解决、产出可复用模式
取消语义 主动 abort 过期请求 体验最好;句柄对象使 abort 成为一等公民
加载反馈 轻量"加载中"遮罩 + 禁用交互 不阻塞后必须给反馈;先跑通机制
异步机制 方案 A每请求一个 QObject 句柄(信号 + abort 纯 Qt 信号、贴合现有信号驱动代码库abort 用具体句柄最干净QFuture 的 cancel 不中断底层 reply反而要再加句柄测试用 QSignalSpy 顺手
多请求汇聚 gather-all等全部完成再回报校验留 repo 避免 fail-fast 的部分-abort 竞态;与现有"任一失败→整体失败"语义对齐(校验在 repo 逐项判定)

4. 架构

DatasetDetailController (QObject状态/编排)
   │ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄
   │ 切换/关页 → 句柄.abort()
   ▼
IAsyncDatasetRepository纯抽象 ←── ApiDatasetRepository 实现
   │ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall
   │ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg)
   ▼
ApiClient.getAsync(path) → ApiCall包一个 QNetworkReply实现 IApiCall
   ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager

信号面保持不变DatasetDetailController 对外仍发 chartReady(ChartData) / gridReady(GridData) / loadFailed(dsId,msg) / focusRequested(dsId),新增 loadStarted(dsId, Phase)。因此 main.cpp 接线与 DatasetDetailPanel 几乎不动,仅接 loadStarted 显示遮罩。

5. 组件设计

5.1 net 层

IApiCall(新增,抽象,可测试缝)

// src/net/IApiCall.hpp
class IApiCall : public QObject {
    Q_OBJECT
public:
    using QObject::QObject;
    virtual void abort() = 0;          // 中断;不再 emit finisheddeleteLater
signals:
    void finished(const geopro::net::ApiResponse& resp);  // 成功/错误均经此(错误写 rawError
};

存在意义:ApiBatch 只依赖 IApiCall,单测可注入假 call不碰真实网络离线测 gather/abort。

ApiCall(新增,实现 IApiCall

  • 构造接管一个 QNetworkReply*;连接 reply->finished → 解析为 ApiResponse(复用现 parseBody)→ emit finisheddeleteLater() 自删。
  • abort():断开自身与 reply 的连接、reply->abort()reply->deleteLater()this->deleteLater()保证 abort 后不再 emit finished(杜绝旧结果回灌)。
  • 一处微妙abort 与 reply 即将 finished 的竞态——先 disconnectabort,确保 finished 不触达本对象的 emit。

ApiClient(扩展,不破坏现有)

net::IApiCall* getAsync(const QString& path);                       // 立即返回,不阻塞
net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
// 保留ApiResponse get(path); ApiResponse postJson(path, body);   // Nav/登录继续用
  • getAsync/postJsonAsync 用同一 nam_ 发起,返回 new ApiCall(reply)。token 注入、buildRequest 复用。
  • 已知交互(记录为可接受)Nav 的同步嵌套 QEventLoop 期间,详情的异步 reply 若完成会被一并泵出、其槽重入执行——无害(详情切换会 abort且互不写同一状态。全异步后该同步路径消失。

ApiBatch(新增,汇聚原语)

// src/net/ApiBatch.hpp
class ApiBatch : public QObject {
    Q_OBJECT
public:
    explicit ApiBatch(QList<net::IApiCall*> calls, QObject* parent=nullptr); // 接管 calls
    void abort();                          // abort 全部子 callself deleteLater不 emit
signals:
    void finished(const QList<geopro::net::ApiResponse>& responses);  // 按 calls 下标对齐
};
  • gather-all记录每个 call 的 finishedresponses_[i],计数到齐后 emit finisheddeleteLater
  • abort():对每个未完成子 call 调 abort(),自删,不 emit。
  • 不做成功/失败判定(留 repo传输/HTTP 错误体现在对应 ApiResponse.rawError/httpStatus

5.2 data 层

data 层结果载体(中性,避免 data→controller 反向依赖)

// src/data/api/DatasetLoads.hpp
struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; };
struct GridParts  { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector<core::Anomaly> anomalies; };

*Load 句柄(每次加载一个,承载 typed 结果 + abort

class ChartLoad : public QObject {
    Q_OBJECT
public:
    void abort();                         // 转发给内部 ApiBatch
signals:
    void done(const geopro::data::ChartParts& parts);
    void failed(const QString& message);
};
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
  • 由 repo 创建并持有内部 ApiBatchbatch 完成时逐项校验+解析→ emit done/failed→ self deleteLater
  • 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。

IAsyncDatasetRepository(新增抽象,详情控制器依赖它)

// src/data/repo/IAsyncDatasetRepository.hpp
class IAsyncDatasetRepository {
public:
    virtual ~IAsyncDatasetRepository() = default;
    virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0;  // scatter + scatterScale(type1)
    virtual data::GridLoad*  loadGridAsync(const std::string& dsId)  = 0;  // grid(rows) + scale(type2) + anomalies
};
  • 复合方法(而非旧 6 个原子 loadXxx):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo它本就知道请求集是正确归属控制器保持轻薄。

ApiDatasetRepository(改造)

  • 不再继承同步 IDatasetRepository;改实现 IAsyncDatasetRepository
  • loadChartAsyncApiBatch{ getScatter, getScatterScale } → 完成后复用现有 DTO 解析(DatasetChartDto 等)填 ChartParts;任一项错误 → failed
  • loadGridAsyncApiBatch{ getRows, getColorScale, getException } → 解析填 GridParts;同上。
  • 现有同步解析/校验逻辑DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。

5.3 controller 层

DatasetDetailController(改造)

enum class Phase { Chart, Grid };
signals:
    void loadStarted(const QString& dsId, Phase phase);   // 新增:驱动加载遮罩
    // 不变chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId)
private:
    IAsyncDatasetRepository& repo_;
    QPointer<data::ChartLoad> chartLoad_;   // 当前在飞QPointer 防悬垂)
    QPointer<data::GridLoad>  gridLoad_;
  • openDataset(dsId, ddCode)ddCode 非 dd_inversion_dataloadFailed;否则 chartLoad_ 在飞先 abortrepo_.loadChartAsync 取新句柄,连 done→组 ChartDatachartReadyfailedloadFailedemit loadStarted(dsId, Chart)
  • loadGridData(dsId, ddCode):同构,针对 gridLoad_gridReady
  • 移除 busy_ 守卫:由 abort-and-replace 取代新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。
  • 句柄完成/失败后清空对应 QPointer。控制器析构时 abort 在飞句柄。

5.4 UI 层(加载反馈)

  • 新增 LoadingOverlayQWidget,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。
  • DatasetDetailPanel
    • loadStarted(dsId, Grid) → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;gridReady/loadFailed → 隐藏。
    • 原数据初次加载(~0.8s):页面仍于 chartReady 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 14s
  • 像素级对齐原版 loading 留后续。

6. 数据流(时序)

原数据(双击数据集)

  1. detailCtrl.openDataset(id, "dd_inversion_data") → emit loadStarted(id,Chart)UI 置 busy
  2. abort 旧 chartLoad_(若有)→ repo.loadChartAsync(id) 返回 ChartLoad*
  3. repo 内 ApiBatch{getScatter, getScatterScale} 并发UI 线程保持响应
  4. 两 reply 到齐 → batch.finished → repo 解析 → ChartLoad::done(ChartParts)
  5. controller 组 ChartDatachartReadydetailPanel->openOrUpdate(清 busy
    • 任一错误 → ChartLoad::failedloadFailed → 状态栏提示(清 busy

网格数据(首次切到网格页签)

  1. gridDataNeededloadGridData(id, ...) → emit loadStarted(id,Grid)(网格视图遮罩)。
  2. abort 旧 gridLoad_repo.loadGridAsync(id)
  3. ApiBatch{getRows(慢14s), getColorScale, getException} 并发。
  4. 到齐 → 解析 → done(GridParts)gridReadysetGridData(隐遮罩)。
  5. 期间用户切走/关页 → controller abort gridLoad_ → 子 reply 全 abort无回灌。

7. 错误处理与边界

  • 传输/HTTP 错误:体现在 ApiResponse.rawError/httpStatusrepo 在解析处判定并 failed(msg),控制器转 loadFailed → 状态栏(沿用现有 UX
  • abort 竞态ApiCall::abortdisconnectabort,确保 abort 后不 emit。*LoadApiBatch abort 后 deleteLater 且不 emit。控制器用 QPointer 防悬垂。
  • 重复触发abort-and-replace 天然幂等(新覆旧)。
  • 控制器析构abort 所有在飞句柄,避免回调打到已析构对象。
  • 共享 NAM 同步/异步并存:见 5.1,记录为可接受,全异步后消除。
  • 空/缺数据异常列表可能为空——repo 判定"业务成功但无数据"应走 done(空),非 failed,与现状一致。

8. 测试策略

  • ApiBatch(离线单测):注入假 IApiCall(受测可控 emit finished/记录 abort)。
    • gather-all全部 finished 后 emit 一次、下标对齐。
    • abort未完成子 call 均被 abort、不 emit。
    • 含错误响应仍 gather-all不短路
  • DatasetDetailController离线单测QSignalSpy:用 StubAsyncRepo 返回假 *Load,可控 emit done/failed(即时或 QMetaObject::invokeMethod 排队)。
    • done → 一次 chartReady/gridReady
    • failed → 一次 loadFailed
    • 在飞时再 openDataset → 旧句柄被 abortstub 记录)。
    • dd_inversion_data → 直接 loadFailed
  • ApiCall/getAsync(可选 live test:依现有 AuthLiveTest 先例,标记为 live不计入离线覆盖门槛。
  • 现有 75 个测试中,DatasetDetailController.* 两个改为异步桩版本;其余不动。LocalRepo.* 不受影响(同步接口保留)。
  • 目标异步新增逻辑batch/controller离线单测覆盖 ≥ 80%。

9. 迁移步骤(供 writing-plans 细化)

  1. netIApiCall + ApiCall + ApiClient::getAsync/postJsonAsync(保留同步)。parseBody 抽为可复用。
  2. netApiBatch + 离线单测。
  3. dataDatasetLoads.hppChartParts/GridParts+ ChartLoad/GridLoad + IAsyncDatasetRepository
  4. dataApiDatasetRepository 改实现异步接口(搬入现有 DTO 解析)。
  5. controllerDatasetDetailController 改异步 + abort-and-replace + loadStarted;改其单测。
  6. UILoadingOverlay + DatasetDetailPanelloadStartedmain.cpp 装配换 IAsyncDatasetRepository
  7. 全量构建 + 测试dev-build / dev-test手动验证切换/关页 abort、网格遮罩、并发加载、UI 不冻。

10. 风险

风险 缓解
abort 后回调悬垂/二次释放 QPointer + abort 先 disconnect + 仅 deleteLater
句柄/batch 生命周期泄漏 完成或 abort 后统一 deleteLater;父子 QObject 归属清晰
同步/异步共用 NAM 重入 记录为可接受;详情 abort 隔离;全异步后消除
DTO 解析逻辑搬迁出错 原样搬入、不重写;保留现有解析单测
接口改形(原子→复合)波及面 仅详情控制器消费该接口;LocalSampleRepository 不碰

11. 相关文件

  • 改:src/net/ApiClient.{hpp,cpp}src/data/api/ApiDatasetRepository.{hpp,cpp}src/controller/DatasetDetailController.{hpp,cpp}src/app/main.cppsrc/app/panels/.../DatasetDetailPanel.*tests/controller/test_dataset_detail_controller.cpp、各 CMakeLists.txt
  • 新:src/net/IApiCall.hppsrc/net/ApiCall.{hpp,cpp}src/net/ApiBatch.{hpp,cpp}src/data/api/DatasetLoads.hppsrc/data/api/DatasetLoadHandles.{hpp,cpp}(ChartLoad/GridLoad)、src/data/repo/IAsyncDatasetRepository.hppsrc/app/.../LoadingOverlay.{hpp,cpp}tests/net/test_api_batch.cpp
  • 不动:src/data/repo/IDatasetRepository.hpp(同步,留给 LocalSampleRepository、Nav/Auth/Project 路径。