Skip to content

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 上。

typescript
// 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 的实现,只保留全局初始化和导出。

例如:

typescript
// 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 继续聚合导出,对外保持统一入口

例如:

typescript
// 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:

typescript
// 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 可以退回成一个入口页,只列出验证链接:

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:

typescript
// apps/web/src/api/client.ts
import { getWebClientEnv } from '@/config/env/client'

const env = getWebClientEnv()

export function serverURL(path: string) {

  return new URL(path, env.NEXT_PUBLIC_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 文件只做一件事:调用一个接口。

例如:

typescript
// 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

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

基于 MIT 协议开源