Utils层设计
要点
- Utils 层存放的是纯函数工具——给定相同输入,永远返回相同输出,不依赖外部状态
- 写工具函数之前先问两个问题:这段逻辑有没有被第二个地方用到?它的输入输出能不能脱离 HTTP 上下文独立描述?
- 日期处理、字符串处理、类型转换、错误处理、响应格式化,这五类覆盖了后端项目里九成的工具函数需求
- 工具函数按功能分类放文件,比按调用方分类更稳定——日期函数不会因为用户模块重构而搬家
- 每个工具函数至少配一个正向测试和一个边界测试,纯函数的测试成本在所有类型里最低
- 只在一个地方使用的逻辑,优先留在原地,不要为了「以后可能复用」提前抽工具
1. 什么是 Utils 层
在前面的项目结构里,我们把路由、中间件、数据库操作分别放到了不同的目录。还有一类代码不属于任何一层——它不处理 HTTP 请求,不操作数据库,不校验 Token,只是在做一些通用的数据变换:
- 把一个
Date对象转成2026-06-20这样的字符串 - 把用户输入的邮箱统一转成小写并去掉前后空格
- 把一个可能是字符串的
"123"安全地转成数字 - 把一组错误信息拼成统一格式的 JSON 响应
这些函数的共同特征是:它们不关心自己被谁调用。路由处理器可以用,中间件可以用,测试脚本里也可以用。给它们同样的输入,永远返回同样的输出。
在 Hono 项目里,这类函数通常放在 src/utils/ 目录下,我们把这一层叫做 Utils 层。
src/
utils/
date.ts 日期处理
string.ts 字符串处理
type-convert.ts 类型转换
error.ts 错误处理
response.ts 响应格式化
接下来要讨论的问题是:什么样的代码该放进 utils/,什么样的不该放。
2. 为什么需要 Utils 层
也许你会想,这些函数直接写在用的地方不就行了?
项目小的时候确实可以。但如果同一个日期格式化函数出现在三个路由文件里,当你想把输出格式从 2026-06-20 改成 2026/06/20 时,你得找到这三个文件改三遍。漏改一个,线上就出现两种日期格式并存。
把这段逻辑抽到 utils/date.ts 之后,改一处就够了。除此之外,Utils 层还带来两个好处:
- 降低测试成本。纯函数不依赖数据库、不依赖请求上下文,写测试只需要传输入、断言输出,不需要 mock 任何东西。
- 让调用方代码更短。路由处理器里少了几行数据转换逻辑,业务意图更突出。
反过来,如果把所有东西都往 Utils 层塞,也会有问题。一个工具函数只在某个路由里用了一次,却要先从 src/routes/posts.ts 跳到 src/utils/slug.ts 才能看到实现,阅读路径反而变长。
3. 纯函数设计原则
放进 utils/ 的函数,有一条硬约束:必须是纯函数。
纯函数的定义只有一条——给定相同的输入,永远返回相同的输出,且不会产生副作用。具体来说:
- 不读取外部变量(除了参数和模块内的常量)
- 不修改参数本身
- 不做 I/O 操作(不发请求、不读写文件、不访问数据库)
- 不依赖随机数或当前时间(如果依赖,要把时间作为参数传入)
下面这个函数是纯函数:
// src/utils/string.ts
/**
* 将字符串转成 URL 友好的 slug 格式
* "Hello World!" -> "hello-world"
*/
export function toSlug(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '')
}下面这个不是:
// 这个函数依赖外部数据库,不是纯函数
export async function getUserCount(db: D1Database): Promise<number> {
const result = await db.select({ count: sql<number>`count(*)` }).from(users)
return result[0].count
}第二个函数应该放在数据库层或者业务逻辑层,不应该放在 utils/ 里。判断标准很简单:把这个函数单独拿出来,给一组测试数据,它能不能独立运行并返回确定结果? 能的话就是纯函数,不能的话就不属于 Utils 层。
还有一个容易混淆的情况——函数内部用了 new Date():
// 这不是纯函数,因为每次调用结果不同
export function formatNow(): string {
return new Date().toISOString().split('T')[0]
}如果确实需要处理日期,把 Date 对象作为参数传入:
// 这是纯函数,相同输入永远返回相同输出
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}调用方传入 new Date() 还是固定日期,是调用方的事。formatDate 本身保持纯粹。
4. 常用工具函数分类
日期处理
日期处理是后端项目里出现频率最高的工具函数类别之一。常见需求集中在格式化、解析和计算上。
// src/utils/date.ts
/**
* Date 对象 -> YYYY-MM-DD 格式字符串
*/
export function formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 判断一个日期字符串是否合法(YYYY-MM-DD 格式)
*/
export function isValidDate(dateString: string): boolean {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return false
// 回验:防止 new Date 自动修正不存在的日期(如 2026-02-30 -> 03-02)
return formatDate(date) === dateString
}isValidDate 不只是检查 new Date() 能不能解析——new Date('2026-02-30') 在某些运行时里会被自动修正成 2026-03-02 而不报错。多了一步回验,确保输入和解析结果完全一致。
字符串处理
字符串处理工具主要解决两类问题:格式归一化和内容提取。
// src/utils/string.ts
/**
* 转 URL 友好的 slug
* "Hello World!" -> "hello-world"
*/
export function toSlug(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* 截断字符串到指定长度,超出部分用省略号替代
*/
export function truncate(input: string, maxLength: number): string {
if (input.length <= maxLength) return input
return `${input.slice(0, maxLength)}...`
}类型转换
后端接口接收到的数据经常需要从一种类型安全地转成另一种。URL 参数全是字符串,需要转成数字或布尔值;前端可能传来空字符串,需要转成 null。
// src/utils/type-convert.ts
/**
* 安全地将字符串转为整数,转换失败返回默认值
*/
export function toInt(value: string, fallback = 0): number {
const num = Number.parseInt(value, 10)
return Number.isNaN(num) ? fallback : num
}
/**
* 将常见的「真值字符串」转为布尔值
* "true", "1", "yes" -> true
* 其他 -> false
*/
export function toBoolean(value: string): boolean {
return ['true', '1', 'yes'].includes(value.toLowerCase())
}
/**
* 空字符串转为 null,否则原样返回
* 用于处理前端传来的可选字段
*/
export function emptyToNull(value: string): string | null {
return value.trim() === '' ? null : value
}这些函数的关键设计点是永远不抛异常。toInt('abc') 返回 0(或者你指定的默认值),而不是抛出一个 NaN 错误。工具函数抛异常会把错误处理的责任推回给调用方,失去了「安全转换」的意义。
错误处理
自定义错误类和错误判断工具放在 utils/error.ts,供中间件和业务层统一使用。
// src/utils/error.ts
/**
* 应用级自定义错误
* 携带 HTTP 状态码,方便错误处理中间件统一响应
*/
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
) {
super(message)
this.name = 'AppError'
}
}
/**
* 判断一个错误是否是 AppError 实例
* 用于错误处理中间件区分业务错误和未知异常
*/
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError
}
/**
* 404 快捷构造
*/
export function notFound(resource: string): AppError {
return new AppError(404, 'NOT_FOUND', `${resource} 不存在`)
}
/**
* 400 快捷构造
*/
export function badRequest(message: string): AppError {
return new AppError(400, 'BAD_REQUEST', message)
}在路由里直接 throw notFound('文章'),错误处理中间件捕获到之后统一格式化响应——这样每个路由都不用重复写 return c.json({ error: '...' }, 404)。
响应格式化
当 API 的响应格式需要保持一致时,抽一组格式化函数统一管理。分页响应是最典型的场景:
// src/utils/response.ts
/**
* 统一的分页响应格式
*/
export function paginatedResponse<T>(
items: T[],
total: number,
page: number,
pageSize: number,
) {
return {
code: 0,
message: 'OK',
data: {
items,
pagination: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
},
}
}在路由里调用:
// src/routes/posts.ts
app.get('/posts', async (c) => {
const { items, total } = await fetchPosts(c)
const page = toInt(c.req.query('page'), 1)
const pageSize = toInt(c.req.query('pageSize'), 20)
return c.json(paginatedResponse(items, total, page, pageSize))
})路由处理器里只剩业务逻辑,响应格式交给工具函数保证一致性。同理,成功响应和错误响应也可以各写一个格式化函数,让所有接口返回统一的外层结构。
5. 工具函数的组织方式
按功能分类
每个文件放一类功能,文件名就是类别名。这是最常见的组织方式,也是多数项目采用的方式。
src/utils/
date.ts 日期处理相关
string.ts 字符串处理相关
type-convert.ts 类型转换相关
error.ts 错误处理相关
response.ts 响应格式化相关
index.ts 统一导出入口
优点是文件定位快。你知道自己需要一个日期处理函数,直接去 date.ts 找。新增一个字符串处理函数,也明确知道该放哪里。
统一导出入口
当工具函数文件多了之后,调用方的 import 路径会变得很长:
import { formatDate } from '../../utils/date'
import { toSlug } from '../../utils/string'
import { toInt } from '../../utils/type-convert'可以在 utils/index.ts 里做一次统一导出:
// src/utils/index.ts
export * from './date'
export * from './string'
export * from './type-convert'
export * from './error'
export * from './response'调用方只需要一行:
import { formatDate, toSlug, toInt } from '../../utils'需要注意的一点:如果两个文件导出了同名函数,export * 会静默覆盖,不会报错。工具函数数量较少时这个问题不容易出现,但项目变大之后,更稳妥的做法是改为具名导出,让 IDE 和 TypeScript 都能给出明确的提示。
按功能分类 vs 按调用方分类
另一种思路是「文章模块的工具函数放 routes/posts/utils.ts,用户模块的放 routes/users/utils.ts」。这种方式在初期看起来方便——工具和调用方离得近,改起来快。
但有一个代价:如果两个模块都需要 toSlug,你就得在两个地方各写一份,或者其中一个去 import 另一个模块里的工具。前者是重复,后者是跨模块耦合,都不是好的方向。
更稳妥的默认选择是按功能分类放 utils/。如果某个函数确实只有一个模块在用,先留在模块内部,等到第二个地方需要时再搬到 utils/——下一节专门讨论这个判断。
6. 工具函数的测试
纯函数是测试成本最低的代码类型。不需要 mock 数据库,不需要启动 HTTP 服务器,不需要构造请求上下文。给输入,断言输出,结束。
以 formatDate 为例:
// tests/utils/date.test.ts
import { describe, expect, it } from 'vitest'
import { formatDate, isValidDate } from '../../src/utils/date'
describe('formatDate', () => {
it('格式化为 YYYY-MM-DD', () => {
const date = new Date(2026, 5, 20) // 月份从 0 开始,5 = 六月
expect(formatDate(date)).toBe('2026-06-20')
})
it('个位数月份和日期补零', () => {
const date = new Date(2026, 0, 5)
expect(formatDate(date)).toBe('2026-01-05')
})
})
describe('isValidDate', () => {
it('接受合法日期字符串', () => {
expect(isValidDate('2026-06-20')).toBe(true)
})
it('拒绝非日期字符串', () => {
expect(isValidDate('not-a-date')).toBe(false)
})
it('拒绝被自动修正的日期', () => {
// 2026-02-30 不存在,new Date 会修正为 03-02
expect(isValidDate('2026-02-30')).toBe(false)
})
})每个工具函数建议至少覆盖三类用例:
- 正向用例 — 最常见的输入,验证正常路径
- 边界用例 — 空字符串、零值、负数、极大值
- 异常用例 — 类型不匹配、格式错误、会被误判的输入
第三类往往最有价值。上面 isValidDate 的 2026-02-30 测试,就是防止「看起来合法但不存在」的日期悄悄通过。
测试文件放在项目根目录的 tests/utils/ 下,和源码的 src/utils/ 结构一一对应。
7. 何时抽工具函数 vs 何时内联
这条边界不需要复杂的规则,两个问题就够:
问题一:这段逻辑有没有在第二个地方出现?
如果一段日期格式化代码目前只在一个路由里出现过,就让它留在那里。等第二个路由也需要同样的格式化时,再抽到 utils/date.ts。
「三次重复再抽」是一条常见的经验法则。第一次写的时候放进路由,第二次重复的时候注意到,第三次出现时提取。过早提取的风险在于——你可能提取的抽象并不稳定,第二次使用的场景和第一次有微妙差异,你为了复用硬套了一个不太合适的函数,反而增加了复杂度。
问题二:它的输入输出能不能脱离当前上下文独立描述?
如果一个函数的签名是 formatPostTitle(post: Post): string,它依赖了一个完整的 Post 对象,那它更属于文章模块的业务逻辑,不是通用工具。
但如果函数签名是 truncate(input: string, maxLength: number): string,它不关心输入来自哪张表、哪个字段,那它就是一个合格的工具函数。
具体场景对照
| 场景 | 该怎么做 | 原因 |
|---|---|---|
| 日期格式化,三个路由都在用 | 抽到 utils/date.ts | 多处复用,格式统一 |
| 把用户 ID 转成头像 URL,只在一个地方用 | 留在路由文件里 | 只有一处使用,抽出反而增加跳转 |
| 把 slug 转成标题,当前只有一处用 | 先留在原地 | 等第二处出现再抽 |
| 拼接分页 SQL,所有列表接口都要用 | 抽到 utils/query.ts | 多处复用,且与具体业务无关 |
| 根据用户角色计算权限,涉及数据库查询 | 放在业务层或中间件 | 不是纯函数,不属于 Utils 层 |
核心思路:工具函数解决的是「通用数据变换」,不是「业务逻辑复用」。业务逻辑的复用有其他的组织方式——hooks、service 层、中间件。Utils 层只管那些输入输出都明确、不依赖外部状态的部分。
总结
回顾一下这篇的要点:
- Utils 层存放纯函数,给定相同输入永远返回相同输出,不依赖外部状态
- 日期处理、字符串处理、类型转换、错误处理、响应格式化是五类最常见的工具函数
- 按功能分类组织文件,通过
index.ts统一导出,调用方只 import 一个路径 - 工具函数的测试成本最低——不需要 mock,只需要传输入、断言输出
- 抽取工具函数的时机:同一段逻辑出现两次以上,且能脱离当前上下文独立描述
- 只在一处使用的逻辑,优先留在原地,不要提前抽象
下一篇我们来看数据库层的设计,讨论如何用 Drizzle ORM 管理表结构和迁移。