十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Hono与Zod实战:构建类型安全的TypeScript后端API

Hono与Zod实战:构建类型安全的TypeScript后端API 1. 先搞清楚 Hono 和 Zod 到底能帮你解决什么实际问题如果你正在用 TypeScript 写后端 API或者处理任何需要接收外部输入比如 HTTP 请求、表单、配置文件的场景那么 Hono 和 Zod 这两个库的组合值得你花时间了解一下。它们解决的不是“从零到一”造轮子的问题而是“如何更稳、更快、更省心地写生产级代码”的问题。Hono 是一个轻量、快速、面向边缘计算的 Web 框架。它最直接的价值是在 Node.js、Deno、Bun 甚至 Cloudflare Workers 等运行时上都能提供一致且高性能的 HTTP 服务开发体验。你不用再为不同平台写适配代码一套逻辑可以跑在多个环境。Zod 则是一个以 TypeScript 为核心的运行时数据验证库。它的核心能力是让你用一套语法同时定义数据的类型给 TypeScript 编译器看和验证规则在运行时检查数据。这解决了后端开发里一个老大难问题我定义了一个User接口但客户端传过来的数据真的符合这个接口吗Zod 让你能明确地回答“是”或“否”并给出清晰的错误信息。把这两个结合起来你得到的是一个“类型安全贯穿始终”的开发流程从定义 API 接口的输入输出类型到实际接收请求时验证数据再到业务逻辑中使用这些数据TypeScript 的智能提示和错误检查会一直陪伴你极大减少因数据格式错误导致的运行时 Bug。2. 环境准备别急着写代码先把跑道铺好在开始任何“Mini Project”之前确保你的开发环境是就绪的。这听起来像废话但我见过太多人卡在第一步不是因为工具难用而是因为版本对不上或者包管理器没选对。2.1 选择你的运行时和包管理器Hono 支持多运行时但对于学习和本地开发我建议从Node.js或Bun开始。Node.js 生态最广Bun 则启动更快、内置了测试运行器和包管理器。Node.js: 确保版本在 18.x 或以上。可以用node -v检查。Bun: 如果你追求极速的安装和启动体验可以试试 Bun。它的包管理器是内置的。包管理器: npm 或 yarn 都可以。用 Bun 的话它的bun install命令兼容 npm。2.2 初始化项目并安装核心依赖打开终端创建一个新目录并初始化项目。这里以使用 npm 和 Node.js 为例mkdir learn-hono-zod cd learn-hono-zod npm init -y接下来安装核心依赖。注意我们安装的是生产依赖因为 Hono 和 Zod 本身就是运行时需要的库。npm install hono zod同时安装 TypeScript 和相关的类型定义作为开发依赖npm install -D typescript types/node然后初始化 TypeScript 配置。使用npx来运行当前项目下的tscnpx tsc --init生成的tsconfig.json文件我建议至少修改或确认以下几项这对于现代 Node.js 开发和获得更好的类型检查体验很重要{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: node, esModuleInterop: true, strict: true, skipLibCheck: true, outDir: ./dist, rootDir: ./src }, include: [src/**/*], exclude: [node_modules] }关键点解释“target”: “ES2022”: 使用较新的 ECMAScript 标准能用到更多现代语法。“module”: “ESNext”和“moduleResolution”: “node”: 配合 Hono 的 ESM 风格导入。“strict”: true:强烈建议开启。严格的类型检查是使用 TypeScript 和 Zod 的最大意义所在。“outDir”和“rootDir”: 将源代码放在src目录编译输出到dist目录保持项目结构清晰。2.3 配置开发脚本和热重载为了方便开发在package.json中添加一些脚本。我们使用tsx或ts-node来直接运行 TypeScript 文件避免手动编译。这里推荐tsx它速度更快。npm install -D tsx然后在package.json的“scripts”部分添加{ “scripts”: { “dev”: “tsx watch src/index.ts”, “start”: “tsx src/index.ts”, “build”: “tsc” } }现在运行npm run dev就会启动一个监听模式的服务当你修改src/index.ts文件时服务会自动重启。3. 第一个 Hono 应用从 “Hello World” 到基础路由让我们先抛开 Zod感受一下 Hono 的简洁。在src目录下创建index.ts文件。3.1 创建应用实例并定义路由// src/index.ts import { Hono } from ‘hono’ // 1. 创建 Hono 应用实例 const app new Hono() // 2. 定义第一个路由GET / app.get(‘/’, (c) { // c 是 Context 对象包含了请求和响应的所有信息 return c.text(‘Hello Hono!’) }) // 3. 定义带参数的路由GET /hello/:name app.get(‘/hello/:name’, (c) { // 从路径参数中获取 name const name c.req.param(‘name’) return c.text(Hello, ${name}!) }) // 4. 定义处理 JSON 和查询参数的路由 app.get(‘/api/user’, (c) { // 从查询字符串中获取参数例如 /api/user?id123activetrue const id c.req.query(‘id’) const isActive c.req.query(‘active’) ‘true’ // 返回 JSON 响应 return c.json({ userId: id, active: isActive, message: ‘User data fetched (without validation for now)’ }) }) // 5. 启动服务器监听 3000 端口 export default { port: 3000, fetch: app.fetch } console.log(‘Server is running on http://localhost:3000’)现在运行npm run dev打开浏览器访问http://localhost:3000/- 看到 “Hello Hono!”http://localhost:3000/hello/TypeScript- 看到 “Hello, TypeScript!”http://localhost:3000/api/user?id456activetrue- 看到 JSON 响应。你已经有了一个能处理基本路由的 HTTP 服务。Hono 的 API 非常直观app.get(‘/path’, handler)。handler函数接收一个Context对象 (c)通过它你能访问请求 (c.req) 和构造响应 (c.json(),c.text())。3.2 处理 POST 请求和请求体现代 API 离不开接收数据。我们来创建一个处理用户注册的 POST 接口。// 在 src/index.ts 中继续添加 app.post(‘/api/register’, async (c) { try { // 尝试解析 JSON 格式的请求体 const body await c.req.json() // 暂时直接使用没有验证 console.log(‘Received registration data:’, body) // 模拟处理逻辑 const newUser { id: Date.now(), …body } return c.json({ success: true, user: newUser }, 201) // 201 Created } catch (error) { // 如果请求体不是合法的 JSON会在这里捕获错误 return c.json({ success: false, error: ‘Invalid JSON body’ }, 400) } })用 curl 或 Postman 测试一下curl -X POST http://localhost:3000/api/register \ -H “Content-Type: application/json” \ -d ‘{“username”: “alice”, “email”: “aliceexample.com”}’你会收到一个 201 响应。但是这里有个严重问题我们完全信任了客户端传来的数据。如果客户端传了{“username”: 123, “email”: “not-an-email”}甚至{“admin”: true}我们的代码也会照单全收。这就是运行时 Bug 的温床。接下来Zod 就该上场了。4. 引入 Zod为你的数据穿上“防弹衣”Zod 的核心是Schema模式。你可以为任何数据结构定义一个 Schema然后用它来验证未知的数据。4.1 定义第一个 Schema用户注册在src目录下创建一个schemas文件夹然后创建user.schema.ts文件。将数据验证的逻辑与路由处理分离是一个好习惯。// src/schemas/user.schema.ts import { z } from ‘zod’; // 1. 定义用户注册数据的 Schema export const registerUserSchema z.object({ // username 必须是字符串长度在3到20个字符之间 username: z.string().min(3).max(20), // email 必须是有效的邮箱格式 email: z.string().email(), // age 是可选的数字如果提供则必须在18岁以上 age: z.number().int().positive().min(18).optional(), // tags 是一个字符串数组最多10个元素 tags: z.array(z.string()).max(10).optional(), }); // 2. 从 Schema 推断出 TypeScript 类型 export type RegisterUserInput z.infertypeof registerUserSchema;看我们只用一套语法就同时完成了两件事运行时验证规则username是字符串且长度在 3-20 之间email必须符合邮箱格式等。静态类型定义RegisterUserInput这个类型可以直接用在我们的业务逻辑函数参数上享受完整的 TypeScript 提示和检查。4.2 在 Hono 路由中使用 Zod 验证现在回到src/index.ts改造我们的/api/register路由。// src/index.ts import { Hono } from ‘hono’ import { zValidator } from ‘hono/zod-validator’ // 需要安装这个中间件 import { registerUserSchema, type RegisterUserInput } from ‘./schemas/user.schema’ const app new Hono() // … 之前的其他路由 … // 安装验证中间件 npm install hono/zod-validator // 使用 zValidator 中间件处理 POST 请求 app.post( ‘/api/register’, // zValidator 中间件第一个参数 ‘json’ 表示从请求体解析 JSON // 第二个参数是我们的 Schema zValidator(‘json’, registerUserSchema), async (c) { // 如果程序执行到这里说明请求体数据已经通过了 Zod 验证 // c.req.valid(‘json’) 可以获取到类型安全且已验证的数据 const validatedData: RegisterUserInput c.req.valid(‘json’) console.log(‘Validated registration data:’, validatedData) // 现在你可以放心地使用 validatedData 了它的类型是 RegisterUserInput // TypeScript 知道 validatedData.username 是 string validatedData.age 是 number | undefined // 模拟业务逻辑 const newUser { id: Date.now(), …validatedData } return c.json({ success: true, user: newUser }, 201) } )这个流程的威力在于自动验证如果客户端发送的数据不符合 SchemazValidator中间件会自动返回一个400 Bad Request响应并附上 Zod 生成的、非常清晰的错误信息指出是哪个字段、违反了哪条规则。你不需要写任何try-catch来处理验证逻辑。类型安全在路由处理器内部c.req.valid(‘json’)返回的数据类型就是RegisterUserInput。你在写业务代码时编辑器会提供完整的自动补全并且不可能错误地访问一个不存在的属性。用错误的数据测试一下curl -X POST http://localhost:3000/api/register \ -H “Content-Type: application/json” \ -d ‘{“username”: “ab”, “email”: “invalid-email”}’ # username太短email格式错误你会得到一个结构化的错误响应类似{ “success”: false, “issues”: [ {“path”: [“username”], “message”: “String must contain at least 3 character(s)”}, {“path”: [“email”], “message”: “Invalid email”} ] }4.3 处理更复杂的嵌套数据和文件上传Zod 的能力远不止于此。假设我们有一个创建博客文章的接口包含标题、内容、标签数组和一个可选的元数据对象。// src/schemas/post.schema.ts import { z } from ‘zod’; const metaSchema z.object({ isDraft: z.boolean().default(false), visibility: z.enum([‘public’, ‘private’, ‘unlisted’]).default(‘public’), scheduledFor: z.string().datetime().optional(), // ISO 8601 日期字符串 }); export const createPostSchema z.object({ title: z.string().min(5).max(100), content: z.string().min(1), tags: z.array(z.string().max(15)).max(5), // 最多5个标签每个标签最多15字符 meta: metaSchema.optional(), // 可选的嵌套对象 }); export type CreatePostInput z.infertypeof createPostSchema;在路由中使用它// src/index.ts import { createPostSchema } from ‘./schemas/post.schema’; app.post(‘/api/posts’, zValidator(‘json’, createPostSchema), async (c) { const postData c.req.valid(‘json’); // 类型为 CreatePostInput // … 保存到数据库等逻辑 … return c.json({ success: true, postId: 123 }); } )对于文件上传Hono 也提供了方便的处理方式结合 Zod 可以验证文件类型和大小。// 假设我们只接受单张图片上传 import { z } from ‘zod’; const uploadAvatarSchema z.object({ avatar: z.instanceof(File) // 验证是 File 对象 .refine((file) file.size 5 * 1024 * 1024, ‘File size must be less than 5MB’) // 验证大小 .refine((file) [‘image/jpeg’, ‘image/png’, ‘image/gif’].includes(file.type), ‘Unsupported file type’), }); app.post(‘/api/upload-avatar’, zValidator(‘form’, uploadAvatarSchema), // 注意这里是 ‘form’ async (c) { const { avatar } c.req.valid(‘form’); // 处理 avatar 文件… return c.json({ success: true }); } )5. 构建 Mini Project一个简单的待办事项 API现在我们把所有知识串联起来构建一个具备 CRUD创建、读取、更新、删除功能的待办事项 API。这将涉及路由分组、更复杂的 Schema 验证以及状态管理为了简单我们用内存数组。5.1 定义数据模型和 Schema// src/schemas/todo.schema.ts import { z } from ‘zod’; // 创建待办事项的 Schema export const createTodoSchema z.object({ title: z.string().min(1, “Title cannot be empty”).max(200), description: z.string().max(1000).optional(), completed: z.boolean().default(false), }); // 更新待办事项的 Schema (通常所有字段都是可选的) export const updateTodoSchema createTodoSchema.partial(); // .partial() 使所有字段变为可选 // 从创建 Schema 推断类型 export type CreateTodoInput z.infertypeof createTodoSchema; export type UpdateTodoInput z.infertypeof updateTodoSchema; // 我们应用内部使用的 Todo 类型包含数据库生成的 id export interface Todo extends CreateTodoInput { id: number; createdAt: Date; updatedAt: Date; }5.2 使用 Hono 的路由分组Hono 支持路由分组让代码组织更清晰。我们为所有/api/todos相关的路由创建一个组。// src/routes/todos.ts import { Hono } from ‘hono’; import { zValidator } from ‘hono/zod-validator’; import { createTodoSchema, updateTodoSchema, type CreateTodoInput, type UpdateTodoInput, type Todo } from ‘../schemas/todo.schema’; // 创建一个专门的路由实例 const todosApp new Hono(); // 内存存储仅用于演示生产环境请用数据库 let todos: Todo[] []; let currentId 1; // 1. 获取所有待办事项 todosApp.get(‘/’, (c) { return c.json({ todos }); }); // 2. 根据ID获取单个待办事项 todosApp.get(‘/:id’, (c) { const id Number(c.req.param(‘id’)); if (isNaN(id)) { return c.json({ error: ‘Invalid ID format’ }, 400); } const todo todos.find(t t.id id); if (!todo) { return c.json({ error: ‘Todo not found’ }, 404); } return c.json({ todo }); }); // 3. 创建新的待办事项 todosApp.post(‘/’, zValidator(‘json’, createTodoSchema), (c) { const data: CreateTodoInput c.req.valid(‘json’); const newTodo: Todo { …data, id: currentId, createdAt: new Date(), updatedAt: new Date(), }; todos.push(newTodo); return c.json({ todo: newTodo }, 201); } ); // 4. 更新待办事项 todosApp.patch(‘/:id’, zValidator(‘json’, updateTodoSchema), (c) { const id Number(c.req.param(‘id’)); if (isNaN(id)) { return c.json({ error: ‘Invalid ID format’ }, 400); } const updates: UpdateTodoInput c.req.valid(‘json’); const index todos.findIndex(t t.id id); if (index -1) { return c.json({ error: ‘Todo not found’ }, 404); } // 更新找到的待办事项 todos[index] { …todos[index], …updates, updatedAt: new Date(), // 更新修改时间 }; return c.json({ todo: todos[index] }); } ); // 5. 删除待办事项 todosApp.delete(‘/:id’, (c) { const id Number(c.req.param(‘id’)); if (isNaN(id)) { return c.json({ error: ‘Invalid ID format’ }, 400); } const initialLength todos.length; todos todos.filter(t t.id ! id); if (todos.length initialLength) { return c.json({ error: ‘Todo not found’ }, 404); } return c.json({ success: true }); }); export default todosApp;5.3 在主应用中挂载路由回到src/index.ts将待办事项路由挂载到主应用上。// src/index.ts import { Hono } from ‘hono’; import { zValidator } from ‘hono/zod-validator’; import { registerUserSchema } from ‘./schemas/user.schema’; import todosApp from ‘./routes/todos’; // 导入路由 const app new Hono(); // 挂载待办事项路由所有 /api/todos 开头的请求都会由 todosApp 处理 app.route(‘/api/todos’, todosApp); // 你之前定义的其他路由… app.get(‘/’, (c) c.text(‘Todo API is running!’)); app.post(‘/api/register’, zValidator(‘json’, registerUserSchema), (c) { … }); // … 其他路由和服务器导出 export default { port: 3000, fetch: app.fetch, };现在你的 API 就拥有了完整的待办事项功能GET /api/todos– 获取列表GET /api/todos/:id– 获取单个POST /api/todos– 创建数据会被自动验证PATCH /api/todos/:id– 更新数据会被自动验证DELETE /api/todos/:id– 删除5.4 测试你的 API使用 curl、Postman 或任何 HTTP 客户端进行测试。# 1. 创建待办事项 curl -X POST http://localhost:3000/api/todos \ -H “Content-Type: application/json” \ -d ‘{“title”: “Learn Hono”, “description”: “Build a mini project”}’ # 2. 获取所有待办事项 curl http://localhost:3000/api/todos # 3. 更新待办事项 (标记为完成) curl -X PATCH http://localhost:3000/api/todos/1 \ -H “Content-Type: application/json” \ -d ‘{“completed”: true}’ # 4. 删除待办事项 curl -X DELETE http://localhost:3000/api/todos/1尝试发送错误数据看看 Zod 的验证是否生效curl -X POST http://localhost:3000/api/todos \ -H “Content-Type: application/json” \ -d ‘{“title”: “”}’ # 标题为空应该返回400错误6. 进阶与生产化考量这个 Mini Project 跑通了但离一个健壮的生产应用还有距离。下面是一些你接下来可以深入探索的方向和避坑点。6.1 错误处理中间件目前我们的错误处理分散在各个路由中。Hono 支持自定义中间件进行全局错误处理。// src/index.ts const app new Hono(); // 全局错误处理中间件 app.onError((err, c) { console.error(err); // 可以根据 err 的类型返回不同的状态码和消息 if (err.message.includes(‘Validation’)) { return c.json({ error: ‘Request validation failed’, details: err.message }, 400); } // 默认返回 500 内部服务器错误 return c.json({ error: ‘Internal server error’ }, 500); }); // 之后的路由如果抛出错误都会被这个中间件捕获 app.get(‘/error’, (c) { throw new Error(‘Something went wrong!’); });6.2 环境变量与配置永远不要将敏感信息如数据库连接字符串、API 密钥硬编码在代码中。使用dotenv和 Zod 可以很好地管理环境变量。npm install dotenv npm install -D types/node # 如果之前没装的话创建.env文件DATABASE_URL“postgresql://user:passlocalhost:5432/mydb” PORT3000 NODE_ENV“development”创建src/config.tsimport { z } from ‘zod’; import { config } from ‘dotenv’; config(); // 加载 .env 文件中的变量到 process.env const envSchema z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().int().positive().default(3000), // coerce 将字符串转为数字 NODE_ENV: z.enum([‘development’, ‘production’, ‘test’]).default(‘development’), }); // 验证并导出环境变量 const env envSchema.safeParse(process.env); if (!env.success) { console.error(‘❌ Invalid environment variables:’, env.error.format()); process.exit(1); } export const config env.data;然后在index.ts中使用import { config } from ‘./config’; export default { port: config.PORT, fetch: app.fetch, };这样做的好处是应用启动时会立即检查所有必需的环境变量是否正确而不是在运行时才崩溃。6.3 连接数据库内存数组只是演示。真实项目需要连接数据库。以 Prisma ORM 和 PostgreSQL 为例npm install prisma prisma/client npx prisma init这会创建prisma/schema.prisma文件。定义你的Todo模型然后运行npx prisma migrate dev创建数据库表。最后在你的路由中导入PrismaClient实例来操作数据库。6.4 测试为你的路由编写测试。Hono 应用本身就是一个fetch兼容的对象可以直接用测试框架调用。// test/todos.test.ts import { describe, it, expect, beforeEach } from ‘vitest’; // 或 jest import app from ‘../src/index’; describe(‘Todos API’, () { beforeEach(() { // 清空内存数据或重置测试数据库 }); it(‘should create a todo’, async () { const res await app.request(‘/api/todos’, { method: ‘POST’, headers: { ‘Content-Type’: ‘application/json’ }, body: JSON.stringify({ title: ‘Test Todo’ }), }); expect(res.status).toBe(201); const body await res.json(); expect(body.todo.title).toBe(‘Test Todo’); }); });6.5 部署Hono 应用可以轻松部署到各种平台Node.js 服务器: 使用serve或node直接运行编译后的文件。Bun: 使用bun run src/index.ts。Cloudflare Workers: 这是 Hono 的强项几乎无需修改代码。Vercel/Netlify: 作为 Serverless Function 部署。部署时确保运行npm run build生成dist目录如果配置了的话并设置好生产环境的环境变量。7. 常见问题与排查思路在实际操作中你可能会遇到以下问题TypeScript 编译错误 “找不到模块 ‘hono’ 或其相应的类型声明”检查: 确保npm install hono成功并且node_modules中存在types/honoHono 自带类型通常不需要单独安装。检查tsconfig.json中的“moduleResolution”: “node”。中间件zValidator不生效或者c.req.valid是undefined检查: 确认安装了hono/zod-validator包。确认中间件使用顺序正确zValidator必须在路由处理函数之前。检查导入路径是否正确。Zod 验证通过了但业务逻辑里类型还是any或报错检查: 确保你使用了c.req.valid(‘json’)而不是原始的c.req.json()。c.req.valid()的返回值类型是由 Schema 推断出来的。如果问题依旧检查z.infertypeof yourSchema是否正确定义和导出。应用运行正常但修改代码后热重载不生效检查: 确认package.json中的dev脚本使用的是tsx watch或nodemonts-node。检查文件是否保存在src目录下并且tsx监听的是正确的入口文件。部署到边缘环境如 Cloudflare Workers时报错检查: 边缘环境可能不支持某些 Node.js 原生模块如fs,path。确保你的代码和依赖特别是数据库驱动兼容边缘运行时。Hono 的官方文档有针对各运行时的具体指南。这个组合的核心优势在于它通过类型安全将很多运行时错误提前到了编译时和请求验证时。我建议你在自己的项目中从一个小模块开始实践比如先给一个用户注册接口加上 Zod 验证体会一下它带来的信心和开发效率的提升。当你习惯了这种“先定义契约再写逻辑”的模式后就很难再回到那种对输入数据提心吊胆的编码方式了。
返回列表