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

249 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 设计ApiClient 异步化DatasetDetail 路径试点)
> 日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。
> 后续会按此模式铺开到导航/登录路径(本期不做)。
## 1. 背景
geopro 现网络栈三层全同步阻塞:
- **`ApiClient`**`src/net/`):单个 `QNetworkAccessManager``get/postJson` 内部用 `QNetworkReply + QEventLoop` 死等响应。共享 cookie / JSESSIONID。
- **Repository**`ApiDatasetRepository`、`ApiProjectRepository`):同步调 `api_.get/postJson`,解析后返回 typed 值或抛 `std::runtime_error`
- **Controller**`DatasetDetailController`、`WorkbenchNavController`):在 slot 内同步调 repo`emit` 结果信号。
**核心问题**:每次请求都用 `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`(新增,抽象,可测试缝)
```cpp
// 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 `finished``deleteLater()` 自删。
- `abort()`:断开自身与 reply 的连接、`reply->abort()`、`reply->deleteLater()`、`this->deleteLater()`**保证 abort 后不再 emit `finished`**(杜绝旧结果回灌)。
- 一处微妙abort 与 reply 即将 finished 的竞态——先 `disconnect``abort`,确保 finished 不触达本对象的 emit。
#### `ApiClient`(扩展,不破坏现有)
```cpp
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`(新增,汇聚原语)
```cpp
// 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 的 `finished``responses_[i]`,计数到齐后 emit `finished``deleteLater`
- `abort()`:对每个未完成子 call 调 `abort()`,自删,不 emit。
- 不做成功/失败判定(留 repo传输/HTTP 错误体现在对应 `ApiResponse.rawError`/`httpStatus`。
### 5.2 data 层
#### data 层结果载体(中性,避免 data→controller 反向依赖)
```cpp
// 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
```cpp
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 创建并持有内部 `ApiBatch`batch 完成时逐项校验+解析→ emit `done`/`failed`→ self `deleteLater`
- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。
#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它)
```cpp
// 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`
- `loadChartAsync``ApiBatch{ getScatter, getScatterScale }` → 完成后复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts`;任一项错误 → `failed`
- `loadGridAsync``ApiBatch{ getRows, getColorScale, getException }` → 解析填 `GridParts`;同上。
- 现有同步解析/校验逻辑DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。
### 5.3 controller 层
#### `DatasetDetailController`(改造)
```cpp
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_data``loadFailed`;否则 **若 `chartLoad_` 在飞先 abort**`repo_.loadChartAsync` 取新句柄,连 `done`→组 `ChartData`→`chartReady`、`failed`→`loadFailed`emit `loadStarted(dsId, Chart)`
- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_``gridReady`
- **移除 `busy_` 守卫**:由 abort-and-replace 取代新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。
- 句柄完成/失败后清空对应 `QPointer`。控制器析构时 abort 在飞句柄。
### 5.4 UI 层(加载反馈)
- 新增 `LoadingOverlay``QWidget`,半透明 + "加载中…" + 可选 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 组 `ChartData``chartReady``detailPanel->openOrUpdate`(清 busy
- 任一错误 → `ChartLoad::failed``loadFailed` → 状态栏提示(清 busy
**网格数据(首次切到网格页签)**
1. `gridDataNeeded``loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。
2. abort 旧 `gridLoad_``repo.loadGridAsync(id)`
3. `ApiBatch{getRows(慢14s), getColorScale, getException}` 并发。
4. 到齐 → 解析 → `done(GridParts)``gridReady``setGridData`(隐遮罩)。
5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort无回灌。
## 7. 错误处理与边界
- **传输/HTTP 错误**:体现在 `ApiResponse.rawError`/`httpStatus`repo 在解析处判定并 `failed(msg)`,控制器转 `loadFailed` → 状态栏(沿用现有 UX
- **abort 竞态**`ApiCall::abort` 先 `disconnect``abort`,确保 abort 后不 emit。`*Load` 与 `ApiBatch` 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` → 旧句柄被 `abort`stub 记录)。
-`dd_inversion_data` → 直接 `loadFailed`
- **`ApiCall`/`getAsync`(可选 live test**:依现有 `AuthLiveTest` 先例,标记为 live不计入离线覆盖门槛。
- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本;其余不动。`LocalRepo.*` 不受影响(同步接口保留)。
- 目标异步新增逻辑batch/controller离线单测覆盖 ≥ 80%。
## 9. 迁移步骤(供 writing-plans 细化)
1. net`IApiCall` + `ApiCall` + `ApiClient::getAsync/postJsonAsync`(保留同步)。`parseBody` 抽为可复用。
2. net`ApiBatch` + 离线单测。
3. data`DatasetLoads.hpp`ChartParts/GridParts+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`
4. data`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。
5. controller`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。
6. UI`LoadingOverlay` + `DatasetDetailPanel``loadStarted``main.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.cpp`、`src/app/panels/.../DatasetDetailPanel.*`、`tests/controller/test_dataset_detail_controller.cpp`、各 `CMakeLists.txt`
- 新:`src/net/IApiCall.hpp`、`src/net/ApiCall.{hpp,cpp}`、`src/net/ApiBatch.{hpp,cpp}`、`src/data/api/DatasetLoads.hpp`、`src/data/api/DatasetLoadHandles.{hpp,cpp}`(ChartLoad/GridLoad)、`src/data/repo/IAsyncDatasetRepository.hpp`、`src/app/.../LoadingOverlay.{hpp,cpp}`、`tests/net/test_api_batch.cpp`。
- 不动:`src/data/repo/IDatasetRepository.hpp`(同步,留给 LocalSampleRepository、Nav/Auth/Project 路径。
</content>
</invoke>