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()
  • onError
  • notFound
  • 挂载所有子路由
  • 导出 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&lt;AppType&gt;() 的嵌套路由类型,是从这个最终 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.tsxsrc/api/system/health.api.ts
  • verify/system/ping/page.tsxsrc/api/system/ping.api.ts
  • verify/catalog/list/page.tsxsrc/api/catalog/list.api.ts
  • verify/user/profile/page.tsxsrc/api/user/profile.api.ts
  • verify/order/detail/page.tsxsrc/api/order/detail.api.ts

这样做的好处是,每个验证页的来源都很清楚。

想查某个页面为什么请求失败,不需要在整个项目里全局搜索,直接看对应的 api.ts 就能定位。

同时,这也满足了这次模拟的核心要求:

  • 每个接口请求单独管理
  • 每个页面分别验证
  • 页面与请求文件职责分离

NOTE

注意,本文中的内容仅用于接口测试,不用于实际业务开发,在后续的实践开发中,代码会逐渐发生变化