API 代码组织
要点
- api 子站这边最合适的做法,是保留 apps/api/src/app.ts 负责 app 级职责,把具体接口拆进 routes/
- 拆完 route 文件之后,还需要一个统一挂载入口,也就是 apps/api/src/routes/index.ts
- 如果 route 已经按域拆了,packages/contracts 继续全塞在一个 index.ts 里,就又会形成新的堆积点
- web 这边同样不能继续把所有请求直接写在页面里
- 请求逻辑应该单独放到 apps/web/src/api/
内容
1. 概述
在 api 子站中,为了防止当接口变多之后,文件变多会不好维护,我们需要提前约定一种代码组织结构。在前面的基础知识中,我们也提到了这一点
目前我们还是把接口直接堆在 apps/api/src/app.ts 里,web 侧也是在 page.tsx 里直接写 hc<AppType>() 和请求逻辑。这肯定是不合理的
如果继续这样任由其发展,后面很快会出现几个结果:
- 所有 api route 都挤在一个文件里
- 全局错误处理和具体业务实现混在一起
- 想找某个接口时,要在整份
app.ts里来回滚 AppType虽然还能导出,但维护成本会越来越高
我们可以先模拟一大堆 api route,然后看看应该怎么组织代码
核心的方式就是:
- API 按业务域拆 route
- web 按页面和 api.ts 分层消费接口
2. api 子站应该怎么按域拆 route
api 子站这边最合适的做法,是保留 apps/api/src/app.ts 负责 app 级职责,把具体接口拆进 routes/。
也就是说,app.ts 只做这些事:
new Hono()onErrornotFound- 挂载所有子路由
- 导出
AppType
而具体 route 则拆成这种结构:
api
src
routes 按业务域拆分的路由目录
index.ts 统一挂载所有子路由
system
catalog
user
order
这种拆法的关键不是「为了拆而拆」,而是先按业务域把接口聚在一起。
- system 放探活和系统类接口
- catalog 放列表类接口
- user 放用户类接口
- order 放订单类接口
一旦目录按域稳定下来,后面 route 数量再多,也不会继续把所有逻辑挤回 app.ts。
4. routes/index.ts
拆完 route 文件之后,还需要一个统一挂载入口,也就是 apps/api/src/routes/index.ts。
它的职责很单纯:把各域路由挂到总 app 上。
// apps/api/src/routes/index.ts
import { Hono } from 'hono'
import catalogRoute from './catalog/list.route'
import orderRoute from './order/detail.route'
import healthRoute from './system/health.route'
import pingRoute from './system/ping.route'
import userRoute from './user/profile.route'
type Bindings = {
APP_ENV: 'development' | 'test' | 'production'
}
const routes = new Hono<{ Bindings: Bindings }>()
const appRoutes = routes
.route('/health', healthRoute)
.route('/rpc/system/ping', pingRoute)
.route('/rpc/catalog', catalogRoute)
.route('/rpc/user', userRoute)
.route('/rpc/order', orderRoute)
export type RoutesType = typeof appRoutes
export default appRoutes这里有两个好处。
第一,挂载关系集中可见。
要看整个 API 暴露了哪些入口,不需要翻所有 route 文件,看 routes/index.ts 就够了。
第二,app.ts 会非常干净。
app.ts 不再堆积每条 route 的实现,只保留全局初始化和导出。
例如:
// apps/api/src/app.ts
import { Hono } from 'hono'
import routes from './routes'
type AppErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 504
type Bindings = {
APP_ENV: 'development' | 'test' | 'production'
}
const app = new Hono<{ Bindings: Bindings }>()
app.onError((error, c) => {
...
})
app.notFound((c) => {
...
})
app.route('/', routes)
export type AppType = typeof routes
export default app这里最重要的一点是:export type AppType = typeof routes 必须发生在所有子路由挂载完成之后。
原因很直接。hc<AppType>() 的嵌套路由类型,是从这个最终 routes 实例推导出来的。如果你在挂载前就导出,web 侧的类型链会丢。
5. 共享 contract 也要按域拆分
如果 route 已经按域拆了,packages/contracts 继续全塞在一个 index.ts 里,就又会形成新的堆积点。
当前更合理的结构是这样:
packages 共享包目录
contracts
src 按业务域拆分的共享 contract
index.ts 统一聚合导出
common
biz-code.ts 业务错误码
response.ts 统一响应 envelope
system
ping.contract.ts 系统 ping 协议
catalog
list.contract.ts 列表协议
user
profile.contract.ts 用户资料协议
order
detail.contract.ts 订单详情协议 这里的分层思路也很清楚。
common/放所有域都会复用的公共约定- 各业务域目录只放自己的 request / response contract
index.ts继续聚合导出,对外保持统一入口
例如:
// packages/contracts/src/common/response.ts
import type { BizCode } from './biz-code'
// meta 放请求级别的信息,data/error 放业务结果本身。
export type ApiMeta = {
requestId: string
timestamp: string
}
export type ApiSuccess<T> = {
ok: true
data: T
meta: ApiMeta
}
export type ApiError = {
code: BizCode
message: string
details?: unknown
}
export type ApiFailure = {
ok: false
error: ApiError
meta: ApiMeta
}
// 所有接口最终都落在 success 或 failure 这两个分支里。
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure
// 这两个 helper 只是统一拼装响应结构,调用方不用每次手写 ok/data/meta。
export function buildSuccess<T>(data: T, meta: ApiMeta): ApiSuccess<T> {
return {
ok: true,
data,
meta,
}
}
export function buildFailure(
error: ApiError,
meta: ApiMeta,
): ApiFailure {
return {
ok: false,
error,
meta,
}
}然后每个域只关心自己的 contract:
// packages/contracts/src/catalog/list.contract.ts
import { z } from 'zod'
export const CatalogListResponseSchema = z.object({
items: z.array(
z.object({
id: z.string(),
name: z.string(),
category: z.string(),
}),
),
})
export type CatalogListResponse = z.infer<typeof CatalogListResponseSchema>这样 route 和 contract 的目录结构基本一一对应,后面找接口会非常容易。
6. web 侧页面和请求文件也要拆开
web 这边同样不能继续把所有请求直接写在页面里。
更合理的结构是两层:
- 页面只负责展示
- 请求文件只负责调接口
首页 apps/web/app/page.tsx 可以退回成一个入口页,只列出验证链接:
// apps/web/app/page.tsx
import Link from 'next/link'
const links = [
'/verify/system/health',
'/verify/system/ping',
'/verify/catalog/list',
'/verify/user/profile',
'/verify/order/detail',
]
export default function Home() {
return (
<main>
{links.map((href) => (
<Link key={href} href={href}>
{href}
</Link>
))}
</main>
)
}然后每个页面单独负责验证一个接口:
apps 工作区应用目录
web
app
verify 逐页验证各接口的页面路由
system
health
page.tsx 验证探活接口
ping
page.tsx 验证 ping 接口
catalog
list
page.tsx 验证列表接口
user
profile
page.tsx 验证用户资料接口
order
detail
page.tsx 验证订单详情接口 每个页面只做三件事:
- 调对应的
api.ts - 展示 request / response
- 展示成功态或错误码
页面里不再直接写 hc() 和具体请求细节。
7. 每个接口单独一个 api.ts
请求逻辑应该单独放到 apps/web/src/api/。
建议结构:
apps 工作区应用目录
web
src
api 每个接口单独一个请求文件
client.ts 集中创建 Hono client
system
health.api.ts 探活请求
ping.api.ts ping 请求
catalog
list.api.ts 列表请求
user
profile.api.ts 用户资料请求
order
detail.api.ts
订单详情请求
其中 client.ts 负责集中创建 Hono client:
// apps/web/src/api/client.ts
import { getWebServerEnv } from '@/env.server'
const env = getWebServerEnv()
export function serverURL(path: string) {
return new URL(path, env.API_BASE_URL).toString()
}
export function createJsonRequestInit(body?: unknown): RequestInit {
if (body === undefined) {
return {
method: 'GET',
}
}
return {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
}
}每个 api.ts 文件只做一件事:调用一个接口。
例如:
// apps/web/src/api/system/ping.api.ts
import type {
ApiResponse,
PingRequest,
PingResponse,
} from '@repo/contracts'
import { BizCode } from '@repo/contracts'
import { createJsonRequestInit, serverURL } from '@/api/client'
export async function postPing(
payload: PingRequest,
): Promise<ApiResponse<PingResponse>> {
try {
const response = await fetch(
serverURL('/rpc/system/ping'),
createJsonRequestInit(payload),
)
return await response.json()
} catch (error) {
return {
ok: false,
error: {
code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,
message: error instanceof Error ? error.message : 'API request failed',
},
meta: {
requestId: 'unavailable',
timestamp: new Date().toISOString(),
},
}
}
}8. 页面和接口文件要一一对应
这一点在当前模拟场景里很重要。
页面和 API 文件的对应关系应该固定下来:
verify/system/health/page.tsx↔src/api/system/health.api.tsverify/system/ping/page.tsx↔src/api/system/ping.api.tsverify/catalog/list/page.tsx↔src/api/catalog/list.api.tsverify/user/profile/page.tsx↔src/api/user/profile.api.tsverify/order/detail/page.tsx↔src/api/order/detail.api.ts
这样做的好处是,每个验证页的来源都很清楚。
想查某个页面为什么请求失败,不需要在整个项目里全局搜索,直接看对应的 api.ts 就能定位。
同时,这也满足了这次模拟的核心要求:
- 每个接口请求单独管理
- 每个页面分别验证
- 页面与请求文件职责分离
NOTE
注意,本文中的内容仅用于接口测试,不用于实际业务开发,在后续的实践开发中,代码会逐渐发生变化