2
0

hono-testing-testclient-standards.md 13 KB

Hono RPC Client 调用规范

版本信息

版本 日期 描述 作者
1.0 2026-01-03 初始版本,基于故事010.003修复经验总结 James (Claude Code)
1.1 2026-01-03 添加中线(kebab-case)路由规范 James (Claude Code)
1.2 2026-01-03 明确规范同样适用于前端RPC client (hc) James (Claude Code)
1.3 2026-01-03 修正前端使用rpcClient而非直接使用hc James (Claude Code)

概述

本文档定义了使用 Hono RPC 客户端时的正确调用方式,包括:

  • 测试环境: hono/testingtestClient - 用于后端集成测试
  • 生产环境: @d8d/shared-ui-components/utils/hcrpcClient - 用于前端 UI 包 RPC 调用

两者的路径映射规则完全一致,本文档的规范适用于所有 Hono RPC 客户端。

基本原理

Hono RPC 客户端(包括 testClientrpcClient)会根据路由的路径结构自动生成类型安全的客户端对象。关键是理解路径到属性的映射规则。

所有 Hono RPC 客户端共享相同的路径映射规则,无论是:

  • 后端测试使用的 testClient(来自 hono/testing
  • 前端 UI 包使用的 rpcClient(来自 @d8d/shared-ui-components/utils/hc

核心映射规则

  1. 路径分隔符 / 转换为嵌套对象
  2. 路径参数 :param 转换为 [':param'] 索引
  3. 开头的 / 被移除
  4. 连续的 / 被压缩为单层嵌套
  5. 中线 - (kebab-case) 必须使用方括号 ['path-with-hyphen']

调用方式详解

1. 简单路由(无参数)

// 路由定义
const route = createRoute({
  method: 'get',
  path: '/',  // ✅ 根路径
  // ...
});
app.openapi(route, handler);

// 测试调用
const client = testClient(app);
const response = await client.$get({  // ✅ 使用 .$get()
  query: { page: 1 }
});
// 路由定义
const route = createRoute({
  method: 'post',
  path: '/users',  // 单层路径
  // ...
});
app.openapi(route, handler);

// 测试调用
const response = await client.users.$post({  // ✅ client.users.$post()
  json: userData
});

2. 路由带参数

// 路由定义
const route = createRoute({
  method: 'get',
  path: '/:id',  // 参数路径
  // ...
});
app.openapi(route, handler);

// 测试调用
const response = await client[':id'].$get({  // ✅ client[':id'].$get()
  param: { id: 123 }
});
// 路由定义
const route = createRoute({
  method: 'put',
  path: '/users/:id',  // 嵌套路由+参数
  // ...
});
app.openapi(route, handler);

// 测试调用
const response = await client.users[':id'].$put({  // ✅ client.users[':id'].$put()
  param: { id: 123 },
  json: updateData
});

3. 嵌套路由

// 路由定义
const route = createRoute({
  method: 'get',
  path: '/admin/users/:id',  // 多层嵌套+参数
  // ...
});
app.openapi(route, handler);

// 测试调用
const response = await client.admin.users[':id'].$get({  // ✅ 嵌套调用
  param: { id: 123 }
});

4. 路由带中线(kebab-case)

重要: JavaScript中带连字符的属性名无法用点号访问,必须使用方括号表示法。

// 路由定义
const route = createRoute({
  method: 'get',
  path: '/admin-users',  // 中线路径
  // ...
});
app.openapi(route, handler);

// ❌ 错误:点号访问中线路径会报语法错误
const response = await client.admin-users.$get();

// ✅ 正确:使用方括号表示法
const response = await client['admin-users'].$get();
// 路由定义 - 中线+参数
const route = createRoute({
  method: 'put',
  path: '/unified-advertisements/:id',
  // ...
});
app.openapi(route, handler);

// ✅ 正确:方括号 + 嵌套参数
const response = await client['unified-advertisements'][':id'].$put({
  param: { id: 123 },
  json: updateData
});
// 路由定义 - 嵌套+中线
const route = createRoute({
  method: 'get',
  path: '/admin/unified-advertisement-types',
  // ...
});
app.openapi(route, handler);

// ✅ 正确:嵌套对象 + 方括号
const response = await client.admin['unified-advertisement-types'].$get();

5. 前端 UI 包 RPC Client 使用

重要: 前端 UI 包使用 rpcClient 创建的 RPC 客户端与 testClient 使用完全相同的路径映射规则

// 前端 API 客户端管理器(UI包标准模式)
// src/api/<module>Client.ts
import { <module>Routes } from '@d8d/<module-name>-module';
import { rpcClient } from '@d8d/shared-ui-components/utils/hc'

export class <Module>ClientManager {
  private client: ReturnType<typeof rpcClient<typeof <module>Routes>> | null = null;

  public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof <module>Routes>> {
    return this.client = rpcClient<typeof <module>Routes>(baseUrl);
  }

  public get(): ReturnType<typeof rpcClient<typeof <module>Routes>> {
    if (!this.client) {
      return this.init();
    }
    return this.client;
  }
}

// 使用示例
const <module>ClientManager = <Module>ClientManager.getInstance();
const client = <module>ClientManager.get();

// 路由定义: path: '/' (在 server 包注册为 '/api/v1/admin/<module>s')
const response = await client.index.$get({
  query: { page: 1, pageSize: 10 }
});

// 路由定义: path: '/:id'
const response = await client[':id'].$put({
  param: { id: 123 },
  json: updateData
});

关键点:

  1. rpcClient 调用路径与 testClient 完全一致: 如果测试用 adminClient.$get(),前端也用 client.index.$get()
  2. 不需要重复前缀: rpcClient 会配置 baseURL,调用时只需要使用路由的相对路径
  3. 类型安全: TypeScript 会自动推断正确的路径和方法

路由名称对应关系: | 模块内路由定义 | Server注册 | rpcClient调用 | |--------------|-----------|--------------| | path: '/' | .route('/api/v1/admin/<module>s', routes) | client.index.$get() | | path: '/:id' | .route('/api/v1/admin/<module>s', routes) | client[':id'].$get() | | path: '/search' | 自定义路由 | client.search.$get() |

参考实现: UI包开发规范 - RPC客户端实现规范

常见错误 ❌

错误1: 使用完整路径字符串

// ❌ 错误:使用完整路径作为属性
const response = await client['/api/v1/users'].$get();

// ✅ 正确:去掉前缀,使用相对路径映射
const response = await client.users.$get();

错误2: 路径与模块定义不匹配

// 模块路由定义为 /:id
const route = createRoute({
  path: '/:id',
  // ...
});

// ❌ 错误:使用了完整路径
const response = await client['/api/v1/:id'].$get();

// ✅ 正确:使用模块内的相对路径
const response = await client[':id'].$get();

错误3: 忘记使用 :id 索引

// 路由定义: path: '/users/:id'

// ❌ 错误:直接访问id属性
const response = await client.users[id].$get();  // 编译错误

// ✅ 正确:使用字符串索引
const response = await client.users[':id'].$get({
  param: { id }
});

错误4: 嵌套路由使用错误的分隔方式

// 路由定义: path: '/admin/users/:id'

// ❌ 错误:用斜杠分隔
const response = await client['admin/users/:id'].$get();

// ❌ 错误:用点号分隔参数
const response = await client.admin.users.:id.$get();

// ✅ 正确:用嵌套对象+字符串索引
const response = await client.admin.users[':id'].$get();

错误5: 中线路径使用点号访问

// 路由定义: path: '/admin-users' 或 path: '/unified-advertisements'

// ❌ 错误:JavaScript不允许用点号访问带连字符的属性
const response = await client.admin-users.$get();  // 语法错误
const response = await client['unified-advertisements'].$get();  // ✅ 正确:必须用方括号

// ❌ 错误:混合使用点号和方括号
const response = await client.admin['unified-advertisements'].$get();  // 如果路由是 /admin/unified-advertisements

// ✅ 正确:全部使用方括号访问中线路径
const response = await client['admin-users'].$get();
const response = await client['unified-advertisements'][':id'].$put();

后端模块开发规范关联

路由定义规范

模块内部路由应使用相对路径(不以 / 开头的完整路径):

// ✅ 正确:模块内使用相对路径
const listRoute = createRoute({
  method: 'get',
  path: '/',  // 或 '/:id'
  middleware: [authMiddleware],
  // ...
});

// ❌ 错误:模块内使用完整API路径
const listRoute = createRoute({
  method: 'get',
  path: '/api/v1/admin/users',  // 不应在模块内包含前缀
  middleware: [authMiddleware],
  // ...
});

Server包注册时添加完整前缀

// packages/server/src/index.ts
import { unifiedAdvertisementAdminRoutes } from '@d8d/unified-advertisements-module';

// ✅ 正确:Server包注册时添加完整前缀
app.route('/api/v1/admin/unified-advertisements', unifiedAdvertisementAdminRoutes);

测试调用规范

// ✅ 正确:测试时使用模块的相对路径
const adminClient = testClient(unifiedAdvertisementAdminRoutes);

// 路由定义: path: '/'
const response = await adminClient.$get({
  query: { page: 1 }
});

// 路由定义: path: '/:id'
const response = await adminClient[':id'].$put({
  param: { id: 123 },
  json: data
});

完整示例对照表

路由定义 (模块内) Server注册 测试调用方式
path: '/' .route('/api/v1/users', routes) client.$get()
path: '/users' .route('/api/v1/users', routes) client.users.$get()
path: '/:id' .route('/api/v1/users', routes) client[':id'].$get()
path: '/users/:id' .route('/api/v1/users', routes) client.users[':id'].$get()
path: '/admin/users/:id' .route('/api/v1/admin', routes) client.admin.users[':id'].$get()
path: '/admin-users' .route('/api/v1', routes) client['admin-users'].$get()
path: '/unified-advertisements/:id' .route('/api/v1/admin', routes) client['unified-advertisements'][':id'].$get()
path: '/admin/ad-types' .route('/api/v1/admin', routes) client.admin['ad-types'].$get()

调用技巧

1. 使用 TypeScript 类型提示

const client = testClient<typeof userRoutes>(userRoutes);

// IDE会自动补全可用的方法和路径
client.$get()        // 如果路由有GET /
client.users.$get()   // 如果路由有GET /users
client[':id'].$get()  // 如果路由有GET /:id

2. 查看路由定义确认调用方式

// 查看路由文件中的 path 定义
// src/routes/users.ts
const listRoute = createRoute({
  method: 'get',
  path: '/',  // ← 这里决定了测试调用方式
  // ...
});

// 根据path确定测试调用
await client.$get()  // path: '/'  →  .$get()

3. 调试错误信息

// 如果看到类似错误:
// "Cannot read property '$get' of undefined"
// 说明路径映射不正确

// 检查:
// 1. 路由 path 是否正确定义
// 2. testClient 是否使用了正确的路由实例
// 3. 是否有多余的路径前缀

总结

快速参考

路由 path testClient 调用
/ .$get(), .$post(), etc.
/:id [':id'].$get(), [':id'].$put(), etc.
/users .users.$get(), .users.$post(), etc.
/users/:id .users[':id'].$get(), .users[':id'].$put(), etc.
/admin/users/:id .admin.users[':id'].$get(), etc.
/admin-users ['admin-users'].$get(), etc.
/unified-advertisements/:id ['unified-advertisements'][':id'].$get(), etc.
/admin/ad-types .admin['ad-types'].$get(), etc.

核心原则

  1. 模块内使用相对路径path: '/'path: '/:id'
  2. Server注册时添加前缀.route('/api/v1/xxx', routes)
  3. 测试时映射路径结构/.$method(), /:id[':id'].$method()
  4. 嵌套路径反映为嵌套对象/admin/users/:id.admin.users[':id'].$method()
  5. 中线路径必须用方括号/admin-users['admin-users'].$method()

相关文档


文档状态: 正式版 创建原因: 故事010.003修复过程中发现缺乏testClient调用规范 适用范围: 所有使用 Hono RPC 客户端的地方 覆盖范围:

  • 后端集成测试 (hono/testingtestClient)
  • 前端 UI 包 RPC 调用 (@d8d/shared-ui-components/utils/hcrpcClient)