可维护性设计原则
要点
- 可维护性指的是「半年后回来改这段代码的人,能不能快速看懂、安全修改」——那个人往往就是当初写代码的你自己
- 项目按目录做了拆分只是第一步,每个文件内部的写法、命名、组织方式不统一,代码依然难以维护
- 一致性原则是可维护性的地基:团队约定好统一的写法,代码看起来像一个人写的
- 命名规范解决「看名字就知道做什么」的问题;文件组织规范解决「想找某个东西时知道去哪找」的问题
- DRY、单一职责、开闭原则、依赖倒置——这四条原则从日常踩坑里总结出来,不是空中楼阁
- 重构不需要等到代码改不动才做;代码审查也不只是找 bug,更是维护设计一致性的手段
1. 什么是可维护性
写代码的时候,注意力通常放在「让它跑起来」上面。但代码写完之后,真正的工作才刚开始:修 bug、加参数、排查超时、拆模块——这些场景有一个共同点:改代码的人,需要先在脑子里重建整段代码的上下文。
如果这个重建过程很快,改起来就快。如果要翻五六个文件才能拼出全貌,改起来就痛苦。
可维护性衡量的是:一个新来的人(或者半年后的你自己),在只读代码、不看文档的情况下,能不能快速理解代码在做什么,并安全地修改它。
具体分成四个维度:
- 可读性:代码能不能被快速读懂
- 可定位性:想找某个逻辑时,能不能快速找到
- 可修改性:改一个功能时,会不会牵连一大片
- 可测试性:改完之后,能不能快速验证改对了
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)d 和 r 需要往下读几行才能猜到含义,matchedUsers 和 user 不需要。
几条实用建议:
- 变量用名词:
user、matchedUsers、totalAmount - 函数用动词:
getUserById、validateEmail、formatResponse - 布尔值用判断性命名:
isActive、hasPermission、canEdit - 避免单字母缩写(循环变量除外):用
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.ts、posts.ts - 中间件文件用功能描述:
auth.ts、logger.ts、rate-limiter.ts - 工具函数文件用用途描述:
format.ts、validation.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 { db } 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 或查表法简化 - 改一个功能总连带着改别的地方:模块之间耦合太紧,需要理清边界
- 不敢动某段代码:没人敢改因为「不知道改了会怎样」——至少先补测试
重构时注意两件事:
- 每次只做一种重构。不要同时重命名变量、调整函数参数、改变数据结构
- 重构和功能修改分开提交。先提交纯粹的重构(不改行为),再提交功能变更,
git log记录清晰
11. 代码审查要点
代码审查不只是找 bug。从可维护性角度,还应该关注:
命名是否清晰
- 变量名能不能一看就知道它代表什么
- 函数名是不是动词开头、描述了做什么
- 有没有缩写需要读者去猜
职责是否单一
- 一个函数是不是只做了一件事
- 有没有路由文件里混进了业务逻辑,或工具函数里混进了业务判断
是否有重复
- 相似的逻辑是否已经提取成共享函数
- 新增的代码是不是在复制已有的模式
依赖方向是否合理
- 高层模块有没有直接依赖低层模块的实现细节
- 新增的
import是否指向了正确的层级 - 有没有循环依赖(A 导入 B,B 又导入 A)
错误处理是否完整
- 异步操作有没有处理失败情况
- 返回给用户的错误信息是否安全(不泄露内部实现细节)
- 错误码和 HTTP 状态码是否匹配
总结
回顾一下这篇的要点:
- 可维护性衡量的是「新人能不能快速读懂并安全修改代码」,分成可读性、可定位性、可修改性、可测试性四个维度
- 一致性是可维护性的地基:命名规则、文件结构、代码风格统一后读者的认知成本最低
- 命名让名字承载意图——变量用名词,函数用动词,常量用大写,文件名见名知义
- 文件内部按「导入 → 类型 → 常量 → 实例 → 中间件 → 路由」的顺序组织
- 注释只解释代码没说出来的信息——为什么这样写、有什么约束、踩过什么坑
- DRY 要求同一段逻辑只出现一次,但不要为消除表面重复而强行抽象
- 单一职责让每个函数和文件只做一件事,改起来影响面小、测试容易
- 开闭原则通过接口和注册表实现「加功能不改老代码」
- 依赖倒置让路由依赖接口而不是数据库实现,降低耦合
- 重构不需要等代码烂透——函数超 50 行、改一处牵动多处、不敢动某段代码,都是可以动手的信号
- 代码审查除了找 bug,还要关注命名、职责、重复、依赖方向和错误处理
这些原则不需要一次全部落地。每次写新功能时多想一步:这段代码半年后再看,能不能快速理解?小规模、持续的维护,比集中大重构的代价低得多。