可维护性设计原则

要点

  • 可维护性指的是「半年后回来改这段代码的人,能不能快速看懂、安全修改」——那个人往往就是当初写代码的你自己
  • 项目按目录做了拆分只是第一步,每个文件内部的写法、命名、组织方式不统一,代码依然难以维护
  • 一致性原则是可维护性的地基:团队约定好统一的写法,代码看起来像一个人写的
  • 命名规范解决「看名字就知道做什么」的问题;文件组织规范解决「想找某个东西时知道去哪找」的问题
  • DRY、单一职责、开闭原则、依赖倒置——这四条原则从日常踩坑里总结出来,不是空中楼阁
  • 重构不需要等到代码改不动才做;代码审查也不只是找 bug,更是维护设计一致性的手段

1. 什么是可维护性

写代码的时候,注意力通常放在「让它跑起来」上面。但代码写完之后,真正的工作才刚开始:修 bug、加参数、排查超时、拆模块——这些场景有一个共同点:改代码的人,需要先在脑子里重建整段代码的上下文

如果这个重建过程很快,改起来就快。如果要翻五六个文件才能拼出全貌,改起来就痛苦。

可维护性衡量的是:一个新来的人(或者半年后的你自己),在只读代码、不看文档的情况下,能不能快速理解代码在做什么,并安全地修改它。

具体分成四个维度:

  1. 可读性:代码能不能被快速读懂
  2. 可定位性:想找某个逻辑时,能不能快速找到
  3. 可修改性:改一个功能时,会不会牵连一大片
  4. 可测试性:改完之后,能不能快速验证改对了

2. 代码一致性原则

先看一个反面例子。同一个项目里,两个文件对同一件事用了两种写法:

// src/routes/users.ts
// 有的文件用 camelCase
const getAllUsers = async (c: Context) => { /* ... */ }
const errorMessage = 'user_not_found'
// src/routes/posts.ts
// 另一个文件用 PascalCase
const GetAllPosts = async (c: Context) => { /* ... */ }
const errorMessage = 'postNotFound'

两个文件放在一起,读者要同时处理两套规则。函数名什么时候 camelCase、什么时候 PascalCase?错误信息什么时候下划线、什么时候驼峰?这些规则没有写在任何文档里,只能靠「碰见了再猜」。

一致性解决的就是这个问题。当所有文件都遵循同一套写法时,读者的认知成本会降低。

怎么落地?把能自动检查的部分交给工具:

// biome.json
{
  "linter": {
    "rules": {
      "style": {
        "useNamingConvention": { "level": "error", "options": { "strictCase": true } }
      }
    }
  }
}

工具覆盖不了的部分(比如业务语义的命名规则),靠文档和代码审查来维护。

3. 命名规范

名字是代码给读者留下的第一条线索。

变量和函数:让名字承载意图

// 不好的命名
const d = await db.select().from(usersTable).where(eq(usersTable.id, id))
const r = d[0]
if (!r) return c.json({ error: 'not found' }, 404)
 
// 好的命名——看名字就知道变量代表什么
const matchedUsers = await db.select().from(usersTable).where(eq(usersTable.id, id))
const user = matchedUsers[0]
if (!user) return c.json({ error: 'not found' }, 404)

dr 需要往下读几行才能猜到含义,matchedUsersuser 不需要。

几条实用建议:

  • 变量用名词:usermatchedUserstotalAmount
  • 函数用动词:getUserByIdvalidateEmailformatResponse
  • 布尔值用判断性命名:isActivehasPermissioncanEdit
  • 避免单字母缩写(循环变量除外):用 userId 代替 uid

常量和枚举:用大写区分

// src/lib/constants.ts
// 常量用 UPPER_SNAKE_CASE,一眼和变量区分开
export const MAX_PAGE_SIZE = 50
export const DEFAULT_PAGE_SIZE = 20
 
// 枚举值集中定义,避免散落魔法数字
export const UserRole = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer',
} as const

文件名:见名知义

  • 路由文件用复数名词:users.tsposts.ts
  • 中间件文件用功能描述:auth.tslogger.tsrate-limiter.ts
  • 工具函数文件用用途描述:format.tsvalidation.ts

避免用 utils.ts 这种万能文件名。超过 100 行的大概率说明承担了太多职责。

4. 文件组织规范

文件内部的组织顺序也影响可读性。Hono 路由文件比较舒服的阅读顺序是:导入 → 类型/常量 → 实例创建 → 中间件 → 路由处理函数。

// src/routes/users.ts
// 1. 导入
import { Hono } from 'hono'
import { eq } from 'drizzle-orm'
import type { AppEnv } from '../types'
import { usersTable } from '../db/schema'
import { authMiddleware } from '../middleware/auth'
 
// 2. 类型和常量
type CreateUserInput = { email: string; name: string; role: string }
const ALLOWED_ROLES = ['admin', 'editor', 'viewer'] as const
 
// 3. 实例创建
export const usersApp = new Hono<AppEnv>()
 
// 4. 路由级中间件
usersApp.use('/admin/*', authMiddleware)
 
// 5. 路由处理函数
usersApp.get('/', async (c) => {
  const db = c.get('db')
  const users = await db.select().from(usersTable)
  return c.json(users)
})
 
usersApp.post('/', async (c) => {
  const body = await c.req.json()
  // 参数校验 → 写入数据库 → 返回响应
  // ...
})

读者从上往下读,先看到依赖,再看到类型和常量,最后看到路由逻辑。这个顺序和大脑理解代码的自然顺序一致。

5. 注释规范

注释的目的不是解释代码在做什么——代码本身应该做到。注释要解释代码没说出来的信息:为什么这样写、有什么约束、踩过什么坑。

// 不好的注释——复述代码
// 获取用户列表
const users = await db.select().from(usersTable)
 
// 好的注释——解释「为什么」
// Cloudflare D1 单次查询最多 100 行,超过会静默截断
// 这里用 Math.min 做保护,防止前端传入过大的 pageSize
const limit = Math.min(pageSize, 100)
 
// 好的注释——标注约束和风险
// 这个中间件必须在 authMiddleware 之后挂载
// 因为它依赖 c.get('user') 提供的用户信息
usersApp.use('/admin/*', adminOnlyMiddleware)
 
// 好的注释——记录历史决策
// 不能把 email 字段加 UNIQUE 约束:历史数据存在大小写不一致的脏数据
// 等数据清洗完成后再迁移(预计 2026-Q3)

几条判断标准:

  • 删掉注释、看代码就能理解 → 多半多余
  • 注释和代码行为矛盾 → 读者会信代码不信注释,过时的注释比没有更糟
  • 临时方案、已知限制 → 值得标出来,方便后续跟进

6. DRY 原则

DRY(Don't Repeat Yourself)的意思是:同一段逻辑,只在代码里出现一次。

先看一个常见的重复场景:三个路由里都出现了相同的「查用户、找不到返回 404」逻辑:

// src/routes/users.ts
// 以下逻辑在 GET /:id、PUT /:id、DELETE /:id 三个路由里各出现一次
const id = Number(c.req.param('id'))
const db = c.get('db')
const user = await db.select().from(usersTable).where(eq(usersTable.id, id))
if (!user[0]) return c.json({ error: 'user not found' }, 404)

如果以后查询要加 is_deleted 过滤条件,要改三个地方,漏掉任何一个都是 bug。提取成共享函数:

// src/lib/user-helpers.ts
export async function findUserOr404(c: Context<AppEnv>, id: number) {
  const db = c.get('db')
  const user = await db.select().from(usersTable).where(eq(usersTable.id, id))
  if (!user[0]) return { response: c.json({ error: 'user not found' }, 404) }
  return { user: user[0] }
}
 
// src/routes/users.ts - 调用方式
app.put('/users/:id', async (c) => {
  const result = await findUserOr404(c, Number(c.req.param('id')))
  if ('response' in result) return result.response
  // 直接用 result.user,不需要再查一次
})

现在查询逻辑只存在一个地方,要加过滤条件改一处就够了。

不过 DRY 有适用边界。两段代码看起来相似但业务含义不同时(比如「查用户」和「查文章」用的是不同的表),不要为了消除表面重复而强行抽象。两条独立的代码各自演进,比一条纠缠不清的抽象更容易维护。

7. 单一职责原则

一个函数只做一件事,一个文件只承担一组相关的职责。

先看反面例子:

// src/routes/posts.ts
app.post('/posts', async (c) => {
  const body = await c.req.json()
  // 参数校验(十几行 if/else)
  if (!body.title || body.title.length < 2) return c.json({ error: 'title too short' }, 400)
  // ...
  // 权限检查(好几行)
  const currentUser = c.get('user')
  if (currentUser.role !== 'admin' && currentUser.role !== 'editor')
    return c.json({ error: 'forbidden' }, 403)
  // 生成 slug、检查重复、插入数据库、发送通知
  // ... 又是二十几行
  return c.json(post, 201)
})

一个函数做了五件事:参数校验、权限检查、slug 生成、数据持久化、发送通知。按单一职责拆开:

// src/routes/posts.ts
import { validatePostInput } from '../lib/validation'
import { requireRole } from '../lib/permissions'
import { generateSlug } from '../lib/slug'
import { notifyNewPost } from '../lib/notifications'
 
app.post('/posts', async (c) => {
  const body = await c.req.json()
  const validation = validatePostInput(body)
  if (!validation.success) return c.json({ error: validation.error }, 400)
 
  const auth = requireRole(c, ['admin', 'editor'])
  if (!auth.ok) return c.json({ error: auth.error }, auth.status)
 
  const slug = await generateSlug(c, body.title)
  const db = c.get('db')
  const [post] = await db.insert(postsTable).values({ ...body, slug, authorId: auth.user.id }).returning()
 
  notifyNewPost(c, post.id)
  return c.json(post, 201)
})

路由函数现在只做「按顺序调用各个步骤、组装响应」。每个步骤的具体实现藏在各自的函数里,改校验规则去看 validation.ts,改权限逻辑去看 permissions.ts

单一职责还有一个好处:测试变得容易。可以单独给 validatePostInput 写测试,不需要启动 HTTP 服务——直接传一个对象进去,检查返回值。

8. 开闭原则

开闭原则(Open-Closed Principle):对扩展开放,对修改关闭。添加新功能时,应该通过增加新代码实现,而不是修改已有代码。

先看一个违反开闭原则的例子:

// src/lib/notifications.ts
// 每加一种通知渠道,就要改这个函数、加一个 else if
export async function sendNotification(env: AppEnv['Bindings'], type: 'email' | 'sms' | 'webhook', message: string, recipient: string) {
  if (type === 'email') {
    await fetch(env.EMAIL_API_URL, { /* ... */ })
  } else if (type === 'sms') {
    await fetch(env.SMS_API_URL, { /* ... */ })
  } else if (type === 'webhook') {
    await fetch(env.WEBHOOK_URL, { /* ... */ })
  }
}

按开闭原则重新设计:

// src/lib/notifications.ts
type Notifier = {
  send: (env: AppEnv['Bindings'], message: string, recipient: string) => Promise<void>
}
 
const emailNotifier: Notifier = {
  async send(env, message, recipient) { await fetch(env.EMAIL_API_URL, { /* ... */ }) },
}
const smsNotifier: Notifier = {
  async send(env, message, recipient) { await fetch(env.SMS_API_URL, { /* ... */ }) },
}
 
// 注册表:新增渠道只需要在这里加一行
const notifiers: Record<string, Notifier> = {
  email: emailNotifier,
  sms: smsNotifier,
}
 
export async function sendNotification(env: AppEnv['Bindings'], channel: string, message: string, recipient: string) {
  const notifier = notifiers[channel]
  if (!notifier) throw new Error(`Unknown channel: ${channel}`)
  await notifier.send(env, message, recipient)
}

要加 Slack 通知,只需要写一个 slackNotifier 对象、在注册表里加一行。sendNotification 函数和调用它的路由代码完全不用动。

Hono 的中间件本身就是按开闭原则设计的——加日志、加鉴权、加限流,写一个新中间件挂上去就行,不需要改已有的路由。

9. 依赖倒置原则

依赖倒置解决的是模块之间的耦合问题。高层模块不应该直接依赖低层模块的实现细节,两者都应该依赖抽象。

换句话说:路由不应该知道数据库具体怎么查询。路由只需要说「我要查一个用户」,具体怎么查交给另一个模块。

先看直接耦合的例子——路由里直接 import &#123; db &#125; from '../db',查询、表定义全写在路由文件中。换数据库、改查询方式、加缓存层,每个路由文件都要改。

按依赖倒置的思路,把数据访问抽成接口:

// src/repositories/user-repository.ts
export interface UserRepository {
  findById(id: number): Promise<User | undefined>
  create(data: Omit<User, 'id' | 'createdAt'>): Promise<User>
}
 
export function createDrizzleUserRepository(db: DrizzleD1Database): UserRepository {
  return {
    async findById(id) {
      const rows = await db.select().from(usersTable).where(eq(usersTable.id, id))
      return rows[0]
    },
    async create(data) {
      const [user] = await db.insert(usersTable).values(data).returning()
      return user
    },
  }
}
 
// src/routes/users.ts - 路由只依赖接口
export function createUsersApp(userRepo: UserRepository) {
  const app = new Hono<AppEnv>()
  app.get('/:id', async (c) => {
    const user = await userRepo.findById(Number(c.req.param('id')))
    if (!user) return c.json({ error: 'not found' }, 404)
    return c.json(user)
  })
  return app
}

路由只认 UserRepository 接口。换 Drizzle 为 Kysely,或者加缓存层,写一个新实现就行,路由代码一行不改。

项目规模小的时候不一定需要这么正式地落地。但「依赖接口而不是依赖具体实现」这个思路,在写任何代码时都值得留意。

10. 什么时候该重构

重构是把代码改得更好维护,但不改变外部行为。不需要等代码烂到改不动,日常做小规模重构风险更低。

几种触发重构的信号:

  • 同一个修改要改多个文件:加一个新字段要同时改路由、数据库操作、类型定义、响应格式——通常意味着有重复逻辑可以提取
  • 函数超过 50 行:大概率做了不止一件事,按职责拆分成更小的函数
  • 条件分支超过三层if/else 嵌套三层以上,可以用 early return 或查表法简化
  • 改一个功能总连带着改别的地方:模块之间耦合太紧,需要理清边界
  • 不敢动某段代码:没人敢改因为「不知道改了会怎样」——至少先补测试

重构时注意两件事:

  1. 每次只做一种重构。不要同时重命名变量、调整函数参数、改变数据结构
  2. 重构和功能修改分开提交。先提交纯粹的重构(不改行为),再提交功能变更,git log 记录清晰

11. 代码审查要点

代码审查不只是找 bug。从可维护性角度,还应该关注:

命名是否清晰

  • 变量名能不能一看就知道它代表什么
  • 函数名是不是动词开头、描述了做什么
  • 有没有缩写需要读者去猜

职责是否单一

  • 一个函数是不是只做了一件事
  • 有没有路由文件里混进了业务逻辑,或工具函数里混进了业务判断

是否有重复

  • 相似的逻辑是否已经提取成共享函数
  • 新增的代码是不是在复制已有的模式

依赖方向是否合理

  • 高层模块有没有直接依赖低层模块的实现细节
  • 新增的 import 是否指向了正确的层级
  • 有没有循环依赖(A 导入 B,B 又导入 A)

错误处理是否完整

  • 异步操作有没有处理失败情况
  • 返回给用户的错误信息是否安全(不泄露内部实现细节)
  • 错误码和 HTTP 状态码是否匹配

总结

回顾一下这篇的要点:

  • 可维护性衡量的是「新人能不能快速读懂并安全修改代码」,分成可读性、可定位性、可修改性、可测试性四个维度
  • 一致性是可维护性的地基:命名规则、文件结构、代码风格统一后读者的认知成本最低
  • 命名让名字承载意图——变量用名词,函数用动词,常量用大写,文件名见名知义
  • 文件内部按「导入 → 类型 → 常量 → 实例 → 中间件 → 路由」的顺序组织
  • 注释只解释代码没说出来的信息——为什么这样写、有什么约束、踩过什么坑
  • DRY 要求同一段逻辑只出现一次,但不要为消除表面重复而强行抽象
  • 单一职责让每个函数和文件只做一件事,改起来影响面小、测试容易
  • 开闭原则通过接口和注册表实现「加功能不改老代码」
  • 依赖倒置让路由依赖接口而不是数据库实现,降低耦合
  • 重构不需要等代码烂透——函数超 50 行、改一处牵动多处、不敢动某段代码,都是可以动手的信号
  • 代码审查除了找 bug,还要关注命名、职责、重复、依赖方向和错误处理

这些原则不需要一次全部落地。每次写新功能时多想一步:这段代码半年后再看,能不能快速理解?小规模、持续的维护,比集中大重构的代价低得多。