小型项目结构

要点

  • 项目结构不需要在第一天就设计好,也不该等到代码失控才去想这件事
  • 一个 Hono 项目的最小结构只需要一个 index.ts,够用到十几个路由以内
  • 单文件应用膨胀到一两百行时,开始出现「找一个路由要上下滚很久」的问题,这就是拆分的信号
  • 5 到 10 个文件的小型结构,按职责分三个目录就够:routes/middleware/lib/
  • 类型定义集中到 types.ts,环境变量通过 AppEnv 统一管理,不在各个路由文件里重复声明
  • 「刚好够用」原则:只解决当前已经出现的组织问题,不为三个月后的规模提前设计
  • 本系列会依次覆盖小型结构、中大型结构、按业务模块拆分、各层职责、类型管理、环境配置等话题

1. 什么时候该开始考虑项目结构

也许你刚搭好一个 Hono 项目,所有代码写在一个 index.ts 里,跑了几天,功能还在增加。这时候心里可能会冒出一个问题:「要不要把代码拆一拆?」

这个问题本身就说明代码已经长到需要认真想了。

反过来说,如果项目还在「五个路由、一个中间件」的阶段,提前花一个小时设计目录结构,大概率是浪费。因为你还没有足够的代码来验证这个结构是否合适,写出来的目录划分更多是凭想象。

那什么时候算「该想了」?几个可以参考的信号:

  1. index.ts 膨胀到一两百行,想找一个路由要上下滚很久。
  2. 修改用户路由的时候,不小心影响到认证中间件,因为它们挤在同一个文件里。
  3. 两个人同时改 index.ts,提交代码时 Git 冲突不断。
  4. 你想给某个路由加测试,但发现没法单独导入,因为它和其他几十个路由写在一起。

这些信号有一个共同点:代码的组织方式已经开始拖累日常开发效率。 不是「将来可能会出问题」,而是「现在已经在制造摩擦」。

反过来,如果没有这些信号,不用急。单文件不是罪过,过早拆分才是。

2. 最小可行结构

一个 Hono 项目的最小结构,就是一个入口文件。

// src/index.ts
import { Hono } from 'hono'
 
type Bindings = {
  DB: D1Database
  JWT_SECRET: string
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.get('/health', (c) => c.json({ status: 'ok' }))
 
app.get('/api/users', async (c) => {
  const db = c.env.DB
  const users = await db.prepare('SELECT * FROM users').all()
  return c.json(users)
})
 
app.get('/api/posts', async (c) => {
  const db = c.env.DB
  const posts = await db.prepare('SELECT * FROM posts').all()
  return c.json(posts)
})
 
export default app

这个文件同时承担了类型定义、路由注册、业务逻辑三个职责。项目小的时候这样做完全没问题——代码量不大,一眼就能看到全貌,拆文件反而多出导入导出的心智开销。

但这个阶段不会永远持续。当路由增加、中间件出现、数据库操作变多,一个文件装不下是自然而然的事。

3. 单文件到多文件:什么时候跳

从单文件拆到多文件,不需要等到代码「非常乱」才动手。可以观察两个比较实际的阈值:

  1. 入口文件超过 150 行。 这个数字不是硬性的,但一个经验规律是,超过这个长度,人类阅读代码时的「一屏可见」优势就消失了。你想确认某个路由的完整逻辑,需要在文件里来回滚动。
  2. 出现第二种职责的代码。 比如路由定义里混入了参数校验、错误处理、数据库连接管理。当一个文件开始同时处理「接收请求」、「校验输入」、「操作数据库」、「格式化响应」这些事,拆分就有了明确的方向。

不需要等到 500 行再动。提前在 150 行左右拆一下,工作量很小,也不会有太多历史包袱要处理。

拆分的方向也不是固定的。可以按路由拆(用户路由一个文件,文章路由一个文件),也可以按职责拆(中间件一个文件,工具函数一个文件),也可以混着来。关键是先解决当前最痛的问题——哪个部分让你最频繁地滚动、最容易改错——就先拆哪个。

4. 一个实用的 5-10 文件小型结构

当项目从单文件走出来,第一个稳定形态大概是这样的:

src/
  index.ts            入口文件,挂载中间件和路由
  types.ts            共享类型定义(Bindings、Variables)
  routes/
    users.ts          用户相关路由
    posts.ts          文章相关路由
    auth.ts           认证相关路由(登录、注册)
  middleware/
    auth.ts           鉴权中间件
  lib/
    response.ts       统一响应格式
wrangler.jsonc        Cloudflare Workers 配置
package.json          依赖和脚本

一共 9 个文件(不含配置文件),每个文件的职责都很单一。

几个值得说明的设计选择:

入口文件 index.ts 只做组装。 它不写任何业务逻辑,只做两件事:挂全局中间件,注册路由模块。

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import type { AppEnv } from './types'
import { usersApp } from './routes/users'
import { postsApp } from './routes/posts'
import { authApp } from './routes/auth'
import { authMiddleware } from './middleware/auth'
 
const app = new Hono<AppEnv>()
 
// 全局中间件
app.use('*', logger())
app.use('*', cors())
 
// 注册路由
app.route('/api/users', usersApp)
app.route('/api/posts', postsApp)
app.route('/api/auth', authApp)
 
export default app

以后加一个新功能模块,只需要在 routes/ 下新建一个文件,然后在这里加一行 app.route()。入口文件本身不会膨胀。

路由模块各管各的。 每个路由文件创建一个独立的 Hono 实例,处理一组相关接口:

// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
 
export const usersApp = new Hono<AppEnv>()
 
usersApp.get('/', async (c) => {
  const db = c.env.DB
  const users = await db.prepare('SELECT * FROM users').all()
  return c.json(users)
})
 
usersApp.post('/', authMiddleware, async (c) => {
  const body = await c.req.json()
  return c.json({ id: 1, ...body }, 201)
})

注意这里的路径都是 //:id,没有写 /api/users。因为入口文件用 app.route('/api/users', usersApp) 把路由挂载到了对应前缀下,所以这个模块里的 / 实际对应 /api/users。这样做的好处是路由模块和路径前缀解耦——哪天想把 /api/users 改成 /v2/users,只需要改入口文件那一行。

中间件独立出来。 middleware/auth.ts 放鉴权相关的中间件,被多个路由模块共享使用。小型项目通常只有一两个中间件,一个文件就够了。

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
 
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) {
    return c.json({ error: 'unauthorized' }, 401)
  }
  // 验证 token,把用户信息存入 c.set()
  await next()
})

5. 小型项目里的关注点分离

即使是 5-10 个文件的小项目,也可以做基本的关注点分离。这里给出一个实用的三层模型。

路由层:定义「哪个路径做什么」

路由层只做路径匹配和方法分发。它告诉 Hono「收到 POST /api/users 时,先过鉴权中间件,再交给这个处理函数」。路由层不写业务逻辑,不做参数校验的细节。

// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
import { createUser, getUserById } from '../lib/users'
 
export const usersApp = new Hono<AppEnv>()
 
usersApp.post('/', authMiddleware, async (c) => {
  const body = await c.req.json()
  const result = await createUser(c.env.DB, body)
  return c.json(result, 201)
})

处理函数:做具体的请求处理

处理函数接收请求、提取数据、调用业务逻辑、返回响应。它关心的是「这个请求的输入是什么、输出是什么」。

当处理函数里的逻辑超过 30 行,或者出现了「从请求里提取参数 → 校验 → 调用数据库 → 格式化响应」这种完整的处理链条时,可以考虑把数据库操作的部分抽到 lib/ 里。

// src/lib/users.ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
 
export async function createUser(db: D1Database, input: { name: string; email: string }) {
  // 校验输入
  if (!input.name || input.name.length < 2) {
    throw new Error('name too short')
  }
  // 写入数据库
  const result = await db.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
    .bind(input.name, input.email)
    .run()
  return { id: result.meta.last_row_id, ...input }
}

工具层:放可复用的纯函数

lib/ 目录放那些「被多个路由使用,但和 HTTP 请求无关」的代码。比如统一响应格式、错误处理工具、数据转换函数。

// src/lib/response.ts
import type { Context } from 'hono'
 
export function success<T>(c: Context, data: T) {
  return c.json({ ok: true, data })
}
 
export function error(c: Context, message: string, status = 400) {
  return c.json({ ok: false, error: message }, status)
}

在小型项目里,这三层不需要严格遵守。如果一个路由的处理函数只有 10 行,完全可以把逻辑内联,不必强行抽到 lib/。分层的目标是让代码更容易找到和理解,不是为了制造仪式感。

判断标准可以简单概括:如果一段代码只在一个路由里用,留在路由文件里;如果在两个以上路由里用,移到 lib/

6. 类型、Schema、工具函数的位置

类型定义

前面的路由模块代码里都用到了 AppEnv 类型。如果每个文件都写一遍 type Bindings = &#123; DB: D1Database &#125;,改一个环境变量要改好几个文件。集中放到 types.ts,改一处全局生效:

// src/types.ts
export type AppEnv = {
  Bindings: {
    JWT_SECRET: string
    API_KEY: string
    DB: D1Database
    KV: KVNamespace
  }
  Variables: {
    user: {
      id: number
      email: string
      role: string
    }
  }
}

其他文件只需要 import type &#123; AppEnv &#125; from '../types',然后 new Hono&lt;AppEnv&gt;() 就能拿到完整的类型提示。

校验 Schema

如果项目引入了 Zod 做请求校验,schema 定义放在哪里取决于使用范围:

  • 只在一个路由里用的 schema — 和路由写在同一个文件里,不需要单独抽出去。
  • 多个路由共享的 schema(比如分页参数、通用 ID 校验) — 放到 lib/schemas.ts,需要时导入。
// src/routes/posts.ts
// 只在这里用,直接写在路由文件里
import { z } from 'zod'
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
})
// src/lib/schemas.ts
// 多个路由共享,集中放
import { z } from 'zod'
 
export const paginationSchema = z.object({
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(20),
})
 
export const idParamSchema = z.object({
  id: z.string().regex(/^\d+$/).transform(Number),
})

不要一开始就把所有 schema 集中管理。等到真正出现跨文件复用的需求再抽,过早集中会增加文件之间的耦合。

工具函数

和 schema 同理:先用后抽。一个工具函数只在一处使用时,留在原地;两处以上使用时,移到 lib/

// src/lib/utils.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
}
 
export function formatDate(date: Date): string {
  return date.toISOString().split('T')[0]
}

7. 环境配置基础

小型项目的环境配置不需要太复杂,但有两个基本的边界需要划清楚。

不怕泄露的配置,直接写在 wrangler.jsoncvars 里,跟代码一起提交到 Git:

// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2024-12-01",
  "vars": {
    "APP_NAME": "My API",
    "ALLOWED_ORIGINS": "https://example.com"
  }
}

不能泄露的密钥,用 wrangler secret 命令单独设置,不出现在任何文件里:

// terminal
wrangler secret put JWT_SECRET
wrangler secret put API_KEY

执行后提示你输入值。这些密钥加密存储在 Cloudflare 的服务器上,你在控制台也只能看到「已设置」,看不到具体内容。

两种方式设置的环境变量,代码里的用法完全一样,都是 c.env.变量名

// src/routes/auth.ts
app.post('/login', async (c) => {
  const secret = c.env.JWT_SECRET  // 来自 wrangler secret
  const appName = c.env.APP_NAME   // 来自 vars
  // 用法完全一样
})

本地开发密钥

wrangler secret 设置的密钥存在 Cloudflare 云端。本地 wrangler dev 时不走云端,拿不到这些密钥。解决办法是在项目根目录创建 .dev.vars 文件:

// .dev.vars
JWT_SECRET=local-dev-secret-key
API_KEY=local-dev-api-key

wrangler dev 启动时会自动读取这个文件。这个文件有密钥,一定要加到 .gitignore

// .gitignore
.dev.vars
.wrangler/
node_modules/

关于多环境配置(staging / production)的详细内容,会在本系列后续的环境配置专题中展开。

8. 「刚好够用」原则

前面给出的结构,核心只有一条原则:只解决当前已经出现的组织问题。

不把路由文件按「controller / service / repository」再拆三层——除非处理函数已经长到需要这样拆。 不提前建 lib/errors/lib/validators/lib/constants/ 这些目录——除非真的出现了需要集中管理的内容。 不引入抽象层——除非有两处以上代码需要复用同一段逻辑。

一个常见的反模式是:在写第一个功能之前,先建好十几个目录,每个目录放一个空的 index.ts。这种做法把「未来可能需要」的假设提前变成了「现在必须遵守」的约束。结果是每新增一个功能都要在多个目录之间创建文件、写导入导出,但这些开销在项目初期没有任何回报。

更好的做法是「先用后抽」:

  1. 代码写在当前最方便的位置(通常是路由文件里)。
  2. 当同一段逻辑出现在两个地方时,考虑抽到 lib/
  3. 当路由文件超过 100 行时,考虑把业务逻辑抽到处理函数。
  4. 当类型定义开始重复时,考虑集中到 types.ts

每次拆分都有具体的代码作为依据,而不是凭想象预判。这样做出来的结构,每一层、每个文件都有真实的需求在支撑。

9. 什么时候小型结构不够用了

小型结构能撑到一定规模,但不会是永久的。以下几个信号表明项目需要进一步拆分:

  1. routes/ 目录下超过 8-10 个文件,且单个文件超过 200 行。 路由之间开始出现功能重叠,比如用户路由里混入了用户权限管理,文章路由里混入了评论管理。
  2. 某个路由文件同时承担了路由定义、参数校验、数据库操作、响应格式化四个职责,且代码超过 150 行。 改一个校验规则要在同一个文件里找到对应的数据库操作。
  3. types.ts 膨胀到 100 行以上。 类型定义的种类太多(环境变量、请求体、响应体、数据库行、错误码),放在一起不好找。
  4. lib/ 下开始出现功能分组。 比如 lib/auth.ts 管认证、lib/db.ts 管数据库、lib/cache.ts 管缓存——这说明 lib/ 本身需要按子目录拆分。
  5. 团队超过两个人,开始按功能分工。 两个人同时改 routes/users.ts 的概率比同时改 index.ts 低得多,但如果路由文件本身太大,冲突还是会出现。

这些信号出现时,可以考虑进入中大型项目结构。但不是直接跳到复杂的企业级架构,而是顺着已经出现的分组做进一步拆分——把相关的几个路由合到一个「模块」里,每个模块有自己的路由、处理函数、类型定义。

这个方向会在 03 中详细展开。

10. 本系列的路线图

这个系列会从项目结构的最小形态出发,逐步展开到中大型项目的组织方式。大致覆盖以下内容:

  1. 01 小型项目结构(本文)— 最小可行结构、单文件到多文件的拆分时机、「刚好够用」原则
  2. 02 中大型项目结构 — 完整目录组织、入口文件只做组装、类型集中管理、环境配置和多环境部署
  3. 03 按业务模块拆分 — 从按职责拆分到按业务域拆分,模块边界的划分依据
  4. 后续各篇 — 各层职责细分、类型管理策略、测试与脚本组织

每篇都会给出具体的目录结构和代码示例,不追求穷举所有可能的组织方式,而是给出一条务实的演进路径:从最小结构出发,按真实需求逐步增加层次。

延伸阅读

总结

这篇讨论了一个 Hono 项目从小到中的结构演进:

  1. 最小形态 — 一个 index.ts 搞定一切,够用到十几个路由
  2. 拆分信号 — 入口文件超过 150 行、出现多种职责混在一起、协作产生冲突
  3. 小型结构 — 按职责分 routes/middleware/lib/ 三个目录,入口文件只做组装
  4. 关注点分离 — 路由层管路径匹配,处理函数管请求处理,工具层管复用逻辑
  5. 演进原则 — 「先用后抽」,每次拆分都有真实代码作为依据

核心判断标准是:结构复杂度跟着代码复杂度走,不提前设计,也不拖延到失控。

下一篇进入中大型项目结构,看目录层次如何进一步展开,以及环境配置和多环境部署的具体做法。