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 层还带来两个好处:

  1. 降低测试成本。纯函数不依赖数据库、不依赖请求上下文,写测试只需要传输入、断言输出,不需要 mock 任何东西。
  2. 让调用方代码更短。路由处理器里少了几行数据转换逻辑,业务意图更突出。

反过来,如果把所有东西都往 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(&#123; error: '...' &#125;, 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)
  })
})

每个工具函数建议至少覆盖三类用例:

  1. 正向用例 — 最常见的输入,验证正常路径
  2. 边界用例 — 空字符串、零值、负数、极大值
  3. 异常用例 — 类型不匹配、格式错误、会被误判的输入

第三类往往最有价值。上面 isValidDate2026-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 管理表结构和迁移。