Repository Wiki
ChanIok/SpinningMomo

JSON-RPC 服务与端点设计

SpinningMomo 的原生核心(C++20 协程)通过一套统一的 JSON-RPC 2.0 分发层对外暴露能力:传输无关的 core::rpc 核心(请求解析、访问控制、方法注册表)+ WebView 桥接(core::webview::rpc_bridge)+ 前端调用封装(web/src/composables/useRpc.ts)。

Purpose and Scope

本页覆盖 JSON-RPC 服务端点的完整设计与实现,即 src/core/rpc/ 命名空间 core::rpc 中的:

  • 请求/响应/错误协议数据结构(JsonRpcRequest、JsonRpcSuccessResponse、JsonRpcErrorResponse、RpcError)
  • 访问等级模型(AccessLevel)与权限闸门
  • 方法注册机制(register_method 的类型擦除包装)
  • 统一分发入口 process_request 的完整控制流
  • 系统内建方法(system.listMethods、system.getAccessLevel、system.methodSignature)
  • WebView 桥接层(rpc_bridge.hpp)暴露的入口签名

有意留给兄弟页面的内容:

  • WebView2 消息桥接的进程内实现细节(rpc_bridge.cpp 的 WRL/COM 事件适配)与前端 useRpc composable 的响应式封装 —— 本页仅说明其与 core::rpc 的边界。
  • 具体业务 RPC 方法清单(如各功能域注册的端点)属于各自能力页。
  • RPC 状态对象 app_state.rpc(core/rpc/state.hpp)的生命周期管理。

Overview

core::rpc 是一个传输无关的 JSON-RPC 2.0 分发核心。它只关心三件事:

  1. 协议正确性:严格校验 jsonrpc == "2.0"、方法名存在性、参数可解析性,并用标准错误码(-32700 ~ -32003)返回结构化错误。
  2. 类型安全:业务处理器以强类型 Request/Response 结构体编写,register_method 在注册期把泛型 JSON 参数转换为强类型请求,把业务结果转换回 JSON-RPC 成功/错误响应,业务代码完全不接触原始 JSON。
  3. 安全边界:调用者访问等级(local/lan)由传输层确定后注入到 process_request,请求体自身无法声明权限;每个方法注册时声明 required_access,分发时做最终权限闸门。

关键设计意图(WHY):

  • caller_access 由外部确定而非请求体声明(见 rpc.hpp):WebView2 与 loopback HTTP 天然是本机来源(local),经令牌认证的局域网 HTTP 是 lan。权限是传输层事实,不是客户端可伪造的声明。
  • 注册期类型擦除(std::move_only_function):注册表独占业务处理器所有权,包装层只保留可重复 const 调用能力,避免了共享可变状态。
  • 协程贯穿全链路:处理器签名是 asio::awaitable<RpcResult<T>>(RpcAwaitable),分发入口是 asio::awaitable<std::string>(RpcJsonAwaitable),因此任何 asio IO 上下文(WebView 桥接、HTTP 服务)都能直接 co_await。

Architecture

Loading diagram...

分层说明:

层组件职责
传输层core::webview::rpc_bridge、HTTP 服务接收消息,确定 caller_access,调用 process_request,把返回的 JSON 字符串写回对端
分发核心process_request / handle_system_method / registry / execute_registered_method协议解析、版本校验、系统方法分派、方法查找、访问控制、异常兜底
包装层wrapped_handler(register_method 内生成)rfl::from_generic 参数反序列化 → 调业务协程 → 响应序列化
业务层各功能域的 AsyncHandler<Request, Response>强类型业务逻辑,通过注入的 core::AppState& 访问全局状态
状态层app_state.rpc->registrystd::unordered_map<std::string, MethodInfo>,注册表存活于应用状态

分发核心不依赖任何具体传输:它接收 std::string 请求体并返回 std::string 响应体(协程),因此 WebView postMessage 与 HTTP 两种通道共用同一套协议、权限与错误语义。

协议数据模型(types.hpp)

所有协议结构集中在 src/core/rpc/types.hpp,命名空间 core::rpc,并统一通过 rfl::SnakeCaseToCamelCase 做 C++ snake_case ↔ JSON camelCase 映射。

错误码(ErrorCode)

标准 JSON-RPC 2.0 错误码加上一个自定义 AccessDenied:

错误码枚举值含义
-32700ParseErrorJSON 解析失败
-32600InvalidRequest无效请求(如 jsonrpc != "2.0")
-32601MethodNotFound方法未注册
-32602InvalidParams参数无法反序列化为目标 Request 结构
-32603InternalError业务协程抛出未捕获异常
-32000ServerError服务器错误
-32003AccessDenied当前访问等级无权调用该端点

Source: types.hpp

访问等级(AccessLevel)

cpp
1// RPC 调用者访问等级。local 高于 lan;WebView2 和 loopback HTTP 属于 local, 2// 经过令牌认证的局域网 HTTP 属于 lan。 3enum class AccessLevel : std::uint8_t { 4 lan = 0, 5 local = 1, 6};

local = 1 > lan = 0,因此权限比较就是简单的 caller_access < required_access 整数比较。默认注册的方法是 local,公开给 LAN 必须显式标记 —— 这是一个"默认安全"(secure by default)的选择。

Source: types.hpp

核心结构体

  • RpcError:code(int)、message(string)、可选 data。
  • RpcResult<T> = std::expected<T, RpcError>:业务协程的统一返回类型,成功/失败都在类型系统中显式表达,不使用异常做常规错误路径。
  • RpcAwaitable<T> = asio::awaitable<RpcResult<T>>、RpcJsonAwaitable = asio::awaitable<std::string>。
  • JsonRpcRequest:jsonrpc、method、可选 params(rfl::Generic,支持对象/数组/null)、可选 id。
  • JsonRpcSuccessResponse / JsonRpcErrorResponse:jsonrpc 默认 "2.0",分别携带 result 或 error。
  • MethodInfo:注册表条目,含 name、description、params_schema(JSON Schema)、required_access 与类型擦除后的 handler。
  • EmptyParams:无参方法使用的空参数结构。

Source: types.hpp

方法注册:register_method 的类型擦除

register_method 是本设计的核心机制:它把任意 AsyncHandler<Request, Response> 包装成签名统一的 (rfl::Generic params, rfl::Generic id) -> RpcJsonAwaitable 协程,从而让单个 unordered_map<std::string, MethodInfo> 能承载所有方法。

cpp
1template <typename Request, typename Response> 2inline auto register_method(core::AppState& app_state, 3 std::unordered_map<std::string, MethodInfo>& registry, 4 const std::string& method_name, AsyncHandler<Request, Response> handler, 5 const std::string& description = "", 6 // 默认只允许本机,公开给 LAN 的方法必须显式标记。 7 AccessLevel required_access = AccessLevel::local) -> void { 8 // 注册表独占业务处理器,包装层只保留可重复 const 调用能力 9 auto wrapped_handler = [handler = std::move(handler), &app_state]( 10 rfl::Generic params_generic, rfl::Generic id) -> RpcJsonAwaitable { 11 // 把通用 JSON 参数转换为当前方法的强类型请求 12 auto request_result = 13 rfl::from_generic<Request, rfl::SnakeCaseToCamelCase, rfl::DefaultIfMissing>( 14 params_generic); 15 if (!request_result) { 16 co_return create_error_response(id, ErrorCode::InvalidParams, 17 "Invalid parameters: " + request_result.error().what()); 18 } 19 20 // 调用业务协程并保留统一的 AppState 注入方式 21 auto result = co_await handler(app_state, request_result.value()); 22 23 // 将业务结果转换为 JSON-RPC 成功或错误响应 24 if (result) { 25 JsonRpcSuccessResponse success_response; 26 success_response.id = id; 27 success_response.result = rfl::to_generic<rfl::SnakeCaseToCamelCase>(result.value()); 28 co_return rfl::json::write<rfl::SnakeCaseToCamelCase>(success_response); 29 } else { 30 const auto& error = result.error(); 31 Logger().error("Error response: {}", error.message); 32 co_return create_error_response(id, static_cast<ErrorCode>(error.code), error.message); 33 } 34 }; 35 36 std::string params_schema; 37 if constexpr (core::build_config::rpc_json_schema_enabled()) { 38 params_schema = rfl::json::to_schema<Request, rfl::SnakeCaseToCamelCase>(); 39 } 40 41 // 注册表取得包装处理器的唯一所有权 42 registry[method_name] = MethodInfo{.name = method_name, 43 .description = description, 44 .params_schema = std::move(params_schema), 45 .required_access = required_access, 46 .handler = std::move(wrapped_handler)}; 47}

Source: rpc.hpp

逐段解读:

  1. 参数转换:rfl::from_generic<Request, rfl::SnakeCaseToCamelCase, rfl::DefaultIfMissing> 把泛型 JSON 转成强类型 Request;DefaultIfMissing 让缺省字段取类型默认值而不是直接失败。转换失败立即返回 -32602 InvalidParams,并把 reflect-cpp 的错误信息拼进 message。
  2. AppState 注入:app_state 通过引用捕获进闭包,业务协程签名固定为 RpcAwaitable<Response>(core::AppState&, const Request&) const,因此业务层无任何全局单例查找。
  3. 结果双向映射:result(std::expected)成功 → JsonRpcSuccessResponse;失败 → 直接把 RpcError::code 静态转换为 ErrorCode 生成错误响应。也就是说,业务协程自己就能决定返回哪个 JSON-RPC 错误码(例如返回 {-32000, "..."}),分发层不做二次翻译。
  4. Schema 可选编译:core::build_config::rpc_json_schema_enabled() 是编译期开关,关闭时不生成参数 JSON Schema,省去体积/启动开销;system.methodSignature 此时返回空 schema 字符串。
  5. 处理器签名:AsyncHandler 使用 std::move_only_function<... const>,注册表独占所有权,包装后的 MethodInfo::handler 仍是可重复 const 调用的协程工厂。

核心分发流程:process_request

process_request 是所有传输共用的唯一入口,签名刻意把权限判断外部化:

cpp
1// 处理JSON-RPC请求 2// caller_access 由 WebView 或 HTTP 层确定,不能由请求体自行声明。 3auto process_request(core::AppState& app_state, const std::string& request_json, 4 AccessLevel caller_access) -> RpcJsonAwaitable;

Source: rpc.hpp

实现在 rpc.cpp,控制流如下:

Loading diagram...

关键细节(均出自 rpc.cpp):

  • id 保值:const rfl::Generic request_id = request.id.value_or(rfl::Generic())。即使请求未带 id(形似 notification 的请求),错误响应也带 id: null 语义的空 Generic,保证客户端总能关联。
  • 权限闸门位置:注释明确写着"这是所有注册 RPC 的最终权限闸门,不能只依赖前端隐藏按钮"(rpc.cpp L156-L161)。访问被拒时额外 Logger().warn 记录方法名,便于排查可疑探测。
  • 参数兜底:request.params.value_or(rfl::Generic::Object()) —— 缺省参数按空对象处理,交给 rfl::DefaultIfMissing 填默认值。
  • 双层异常兜底:execute_registered_method 内部捕获业务协程异常转为 -32603 InternalError(含 e.what()),process_request 外层再兜一层 Unexpected error,保证任何异常都变成合法 JSON-RPC 错误响应而不是让协程崩溃。
  • 日志分级:成功路径 Logger().trace 记录完整响应,错误路径 Logger().error,拒绝路径 Logger().warn —— 与运维观测需求对齐。

execute_registered_method 本体(rpc.cpp L105-L117)只是 co_await method_info.handler(params_generic, request_id) 加 try/catch,真正的类型转换发生在注册期生成的 wrapped_handler 内 —— 分发层与业务层通过类型擦除协程解耦。

系统内建方法(system.*)

handle_system_method(rpc.cpp L41-L102)在业务注册表查找之前拦截三个元方法,返回 std::nullopt 表示"不是系统方法,继续走业务分发":

方法参数行为权限语义
system.listMethods无返回调用者等级可见的方法名+描述列表get_method_list 内部逐条 caller_access < info.required_access 过滤(rpc.cpp L25-L38)
system.getAccessLevel无返回 "local" 或 "lan" 字符串前端只读传输层已确认的等级,"不重复推断来源"
system.methodSignature{method: string}返回 method/description/params_schema元数据也是能力信息:受限方法的签名同样返回 -32003,防止用它探测受限接口(rpc.cpp L84-L88)

system.methodSignature 的参数校验分两级:params 缺失返回 InvalidParams("Missing required parameter: method"),rfl::from_generic 失败返回 InvalidParams(带 reflect-cpp 详情),方法不存在返回 MethodNotFound。

传输适配:WebView rpc_bridge

桥接层把 WebView2 的原生消息事件接到 core::rpc,接口极小:

cpp
1namespace core::webview::rpc_bridge { 2 3// 初始化rpc桥接 4auto initialize_rpc_bridge(core::AppState& state) -> void; 5 6// 处理来自前端的rpc消息 7auto handle_webview_message(core::AppState& state, const std::string& message) 8 -> asio::awaitable<void>; 9 10// 向前端发送通知 (JSON-RPC notification) 11auto send_notification(core::AppState& state, const std::string& method, const std::string& params) 12 -> void; 13 14// 创建交给 WebView2/WRL 的可复制消息回调;COM 事件适配层需要复制 callable 15auto create_message_handler(core::AppState& state) -> std::function<void(const std::string&)>; 16 17} // namespace core::webview::rpc_bridge

Source: rpc_bridge.hpp

设计意图:

  • create_message_handler 返回 可复制的 std::function,因为 COM/WRL 事件适配层要求 callable 可拷贝 —— 这正是 MethodInfo::handler 用 move_only 而桥接回调用 std::function 的原因:所有权语义按宿主 API 约束分别选择。
  • send_notification 是服务端主动推送(JSON-RPC notification:有 method/params、无 id),用于事件广播,与请求-响应通道分开。
  • WebView 来源固定按 AccessLevel::local 进入 process_request;前端另有 web/src/composables/useRpc.ts 封装调用。

交互时序(端到端)

Loading diagram...

Usage Examples

错误响应构造(分发层统一出口)

cpp
1// 创建标准错误响应 2auto create_error_response(rfl::Generic request_id, ErrorCode error_code, 3 const std::string& message) -> std::string { 4 JsonRpcErrorResponse error_response; 5 error_response.id = request_id; 6 error_response.error = RpcError{.code = static_cast<int>(error_code), .message = message}; 7 return rfl::json::write<rfl::SnakeCaseToCamelCase>(error_response); 8}

Source: rpc.cpp

所有错误路径(解析失败、版本非法、方法未找到、权限不足、参数非法、内部异常)最终都汇聚到这一个函数,保证线上错误响应结构 100% 一致。

可见方法列表的访问过滤

cpp
1auto get_method_list(const core::AppState& app_state, AccessLevel caller_access) 2 -> std::vector<MethodListItem> { 3 std::vector<MethodListItem> methods; 4 const auto& registry = app_state.rpc->registry; 5 6 for (const auto& [name, info] : registry) { 7 if (caller_access < info.required_access) { 8 continue; 9 } 10 methods.emplace_back(MethodListItem{.name = name, .description = info.description}); 11 } 12 13 return methods; 14}

Source: rpc.cpp

system.listMethods 对 LAN 调用者隐藏所有 local 方法,使"能力发现"本身不会泄露本机端点清单。

业务处理器签名(AsyncHandler)

cpp
1// 异步处理器签名 2template <typename Request, typename Response> 3using AsyncHandler = 4 std::move_only_function<RpcAwaitable<Response>(core::AppState&, const Request&) const>;

Source: rpc.hpp

业务端点的标准形态:接收 AppState& 注入 + const Request& 强类型参数,返回 std::expected 风格的 RpcAwaitable<Response>。各功能域如何据此注册具体端点见对应能力页。

Configuration Options

core::rpc 没有运行时配置文件项;可配置面由编译期开关与注册期参数构成:

选项类型 / 位置默认值说明
core::build_config::rpc_json_schema_enabled()编译期常量(见 build_config)为 true 时 register_method 用 rfl::json::to_schema 生成参数 JSON Schema,供 system.methodSignature 返回;为 false 时 params_schema 为空串,节省体积
required_accessregister_method 参数AccessLevel::local单个端点的最低访问等级;开放给 LAN 必须显式传 AccessLevel::lan
descriptionregister_method 参数""方法描述,进入 MethodInfo 并通过 system.listMethods / system.methodSignature 暴露
method_nameregister_method 参数必填注册表键,即 JSON-RPC method 字符串
registryregister_method 参数(引用)必填目标注册表,实际即 app_state.rpc->registry

API Reference

create_error_response(request_id, error_code, message) -> std::string

参数:

  • request_id(rfl::Generic):要回显的请求 ID(可为空 Generic)
  • error_code(ErrorCode):标准/自定义错误码
  • message(const std::string&):人类可读错误信息

返回: 序列化后的 JSON-RPC 错误响应字符串(jsonrpc/id/error{code,message},camelCase)。

Source: rpc.cpp

get_method_list(app_state, caller_access) -> std::vector<MethodListItem>

参数:

  • app_state(const core::AppState&):读取 app_state.rpc->registry
  • caller_access(AccessLevel):调用者等级,用于过滤

返回: 该等级可见的 {name, description} 列表(仅含 caller_access >= required_access 的方法)。

Source: rpc.cpp

handle_system_method(app_state, request, request_id, caller_access) -> std::optional<std::string>

参数:

  • request(const JsonRpcRequest&):已解析的请求
  • request_id(rfl::Generic):回显 ID
  • caller_access(AccessLevel):调用者等级

返回: 命中 system.listMethods / system.getAccessLevel / system.methodSignature 时返回完整响应字符串;否则 std::nullopt,由调用方继续业务分发。system.methodSignature 在参数缺失/非法、方法不存在、权限不足时分别返回 InvalidParams / MethodNotFound / AccessDenied。

Source: rpc.cpp

execute_registered_method(method_info, params_generic, request_id) -> RpcJsonAwaitable

参数:

  • method_info(const MethodInfo&):目标方法条目
  • params_generic(rfl::Generic):泛型参数(缺省时为空对象)
  • request_id(rfl::Generic):回显 ID

返回: 协程,产出响应 JSON。异常: 捕获 std::exception 并转换为 -32603 InternalError(消息含 e.what()),同时 Logger().error 记录。

Source: rpc.cpp

process_request(app_state, request_json, caller_access) -> RpcJsonAwaitable

参数:

  • app_state(core::AppState&):注入业务处理器
  • request_json(const std::string&):原始请求体
  • caller_access(AccessLevel):必须由传输层确定,禁止从请求体读取

返回: 协程,产出 JSON-RPC 响应字符串;任何内部异常都被兜底为合法错误响应。

可能产出的错误: -32700 / -32600 / -32601 / -32602(由 wrapped_handler) / -32603 / -32003,以及业务协程经 RpcError::code 传入的任意码(如 -32000)。

Source: rpc.hpp、rpc.cpp

register_method<Request, Response>(...) -> void(模板)

参数:

  • app_state(core::AppState&):被闭包引用捕获,注入业务协程
  • registry(std::unordered_map<std::string, MethodInfo>&):目标注册表
  • method_name(const std::string&):JSON-RPC 方法名
  • handler(AsyncHandler<Request, Response>):强类型业务协程
  • description(const std::string&,默认 ""):方法描述
  • required_access(AccessLevel,默认 AccessLevel::local):最低访问等级

返回: 无;以 method_name 覆盖写入 registry 一条 MethodInfo。注意:重复注册同名方法会静默覆盖旧条目(registry[method_name] = ...)。

Source: rpc.hpp

rpc_bridge 公开接口

函数签名说明
initialize_rpc_bridge(core::AppState&) -> void初始化桥接
handle_webview_message(core::AppState&, const std::string&) -> asio::awaitable<void>处理前端 RPC 消息(协程)
send_notification(core::AppState&, const std::string& method, const std::string& params) -> void服务端主动推送 JSON-RPC notification
create_message_handler(core::AppState&) -> std::function<void(const std::string&)>生成可复制回调,适配 COM/WRL 事件层

Source: rpc_bridge.hpp

Failure Modes, Edge Cases & Concurrency

错误路径全景

场景检测点响应
请求体不是合法 JSON / 不符 JsonRpcRequestrfl::json::read 失败-32700 ParseError,id 为空 Generic
jsonrpc 字段 ≠ "2.0"版本校验-32600 InvalidRequest
method 未注册registry.find 失败-32601 MethodNotFound(含方法名)
权限不足caller_access < required_access-32003 AccessDenied + Logger().warn(含方法名)
参数无法转成 Requestwrapped_handler 内 rfl::from_generic 失败-32602 InvalidParams(含 reflect-cpp 错误详情)
业务协程抛异常execute_registered_method catch-32603 InternalError(含 e.what())
分发层自身异常process_request 顶层 catch-32603 InternalError("Unexpected error: ...")
业务显式返回 RpcErrorwrapped_handler 的 else 分支以 RpcError::code 作为错误码透传

边界情形

  • 缺省 params:request.params.value_or(rfl::Generic::Object()) → 空对象交给 rfl::DefaultIfMissing 填默认值;因此"无参方法 + EmptyParams"与"带默认值的可选字段"都能优雅处理。
  • 缺省 id:错误响应使用空 Generic(JSON null 语义),成功响应同样回显空 id;协议上未区分 notification 与 request,响应总是返回。
  • system.methodSignature 的元数据权限:受限方法即使只是查询签名也返回 AccessDenied —— 元数据泄露被视为能力泄露(rpc.cpp L84-L88)。
  • 权限来源不可伪造:caller_access 只能来自传输层入参;代码注释明确"不能由请求体自行声明",这是防权限提升的关键不变式。

并发与所有权

  • 所有权模型:AsyncHandler 是 std::move_only_function,MethodInfo::handler 同为 move-only,注册表独占处理器;同一个 MethodInfo 只能同时存在于一个 map 槽位(重复注册即覆盖)。
  • const 可重入:包装层保留 const 调用语义,多个在飞请求可并发 co_await 同一 MethodInfo::handler(协程每次调用独立帧),处理器自身无共享可变状态。
  • AppState 引用捕获:app_state 以引用捕获进闭包,要求注册时 app_state 的存活期覆盖注册表存活期(两者实际同生命周期,见 core/state/app_state.hpp);并发下业务协程对共享状态的访问安全由 AppState 内部各成员的同步策略保证,不属于本层职责。
  • IO 无关:所有协程运行在调用方提供的 asio executor 上,core::rpc 不创建线程、不持有定时器,传输层可自由选择 co_spawn 上下文。

Performance & Operational Notes

  • 零冗余解析:请求只解析一次(rfl::json::read),参数以 rfl::Generic 形式传递到 wrapped_handler 后转强类型,没有二次字符串解析。
  • 日志策略:成功响应走 trace 级(避免高频刷屏),错误走 error,权限拒绝走 warn —— 排查"为什么接口被拒"优先看 warn 日志中的方法名。
  • Schema 成本控制:参数 JSON Schema 在编译期开关控制,rpc_json_schema_enabled() 关闭时完全跳过 rfl::json::to_schema,system.methodSignature 仍可用但返回空 schema。
  • 扩展面:新增端点只需定义 Request/Response 结构体 + 一个 AsyncHandler 并调用 register_method,无需触碰分发核心;新增传输通道只需确定该通道的 caller_access 并复用 process_request。
  • rpc.hpp —— 注册与分发核心声明
  • rpc.cpp —— 分发/系统方法实现
  • types.hpp —— 协议结构与错误码
  • rpc_bridge.hpp —— WebView 传输桥接接口
  • useRpc.ts —— 前端调用封装(兄弟页面主题)

Sources

(4 files)
src/core/webview