- 作者:老汪软件技巧
- 发表时间:2024-10-03 11:01
- 浏览量:
直入正题,Next.js 自带的 API Routes (现已改名为 Route Handlers) 异常难用,例如当你需要编写一个 RESTful API 时,尤为痛苦,就像这样
这还没完,当你需要数据验证、错误处理、中间件等等功能,又得花费不小的功夫,所以 Next.js 的 API Route 更多是为你的全栈项目编写一些简易的 API 供外部服务,这也可能是为什么 Next.js 宁可设计 Server Action 也不愿为 API Route 提供传统后端的能力。
但不乏有人会想直接使用 Next.js 来编写这些复杂服务,恰好 Hono.js 便提供相关能力。
这篇文章就带你在 Next.js 项目中要如何接入 Hono,以及开发可能遇到的一些坑点并如何优化。
Next.js 中使用 Hono
可以按照 搭建或者照 next.js 模版 /vercel/hono… 搭建,核心代码 app/api/[[...route]]/route.ts 的写法如下所示。
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
const app = new Hono().basePath('/api')
app.get('/hello', (c) => {
return c.json({
message: 'Hello Next.js!',
})
})
export const GET = handle(app)
export const POST = handle(app)
export const PUT = handle(app)
export const DELETE = handle(app)
从 hono/vercel 导入的 handle 函数会将 app 实例下的所有请求方法导出,例如 GET、POST、PUT、DELETE 等。
一开始的 User CRUD 例子,则可以将其归属到一个文件内下,这里我不建议将后端业务代码放在 app/api 下,因为 Next.js 会自动扫描 app 下的文件夹,这可能会导致不必要的热更新,并且也不易于服务相关代码的拆分。而是在根目录下创建名为 server 的目录,并将有关后端服务的工具库(如 db、redis、zod)放置该目录下以便调用。
至此 next.js 的 api 接口都将由 hono.js 来接管,接下来只需要按照 Hono 的开发形态便可。
数据效验
zod 可以说是 TS 生态下最优的数据验证器,hono 的 @hono/zod-validator 很好用,用起来也十分简单。
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
const paramSchema = z.object({
id: z.string().cuid(),
})
const jsonSchema = z.object({
status: z.boolean(),
})
const app = new Hono().put(
'/users/:id',
zValidator('param', paramSchema),
zValidator('json', jsonSchema),
(c) => {
const { id } = c.req.valid('param')
const { status } = c.req.valid('json')
// 逻辑代码...
return c.json({})
},
)
export default app
支持多种验证目标(param,query,json,header 等),以及 TS 类型完备,这都不用多说。
但此时触发数据验证失败,响应的结果令人不是很满意。下图为访问 /api/todo/xxx 的响应结果(其中 xxx 不为 cuid 格式,因此抛出数据验证异常)
所返回的响应体是完整的 zodError 内容,并且状态码为 400
:::tip
数据验证失败的状态码通常为 422
:::
因为 zod-validator 默认以 json 格式返回整个 result,代码详见
这就是坑点之一,返回给客户端的错误信息肯定不会是以这种格式。这里我将其更改为全局错误捕获,做法如下
复制 zod-validator 文件并粘贴至 server/api/validator.ts,并将 return 语句更改为 throw 语句。
if (!result.success) {
- return c.json(result, 400)
}
if (!result.success) {
+ throw result.error
}
在 server/api/error.ts 中,编写 handleError 函数用于统一处理异常。(后文前端请求也需要统一处理异常)
import { z } from 'zod'
import type { Context } from 'hono'
import { HTTPException } from 'hono/http-exception'
export function handleError(err: Error, c: Context): Response {
if (err instanceof z.ZodError) {
const firstError = err.errors[0]
return c.json(
{ code: 422, message: `\`${firstError.path}\`: ${firstError.message}` },
422,
)
}
// handle other error, e.g. ApiError
return c.json(
{
code: 500,
message: '出了点问题, 请稍后再试。',
},
{ status: 500 },
)
}
在 server/api/index.ts ,也就是 hono app 对象中绑定错误捕获。
const app = new Hono().basePath('/api')
app.onError(handleError)
更改 zValidator 导入路径。
- import { zValidator } from '@hono/zod-validator'
+ import { zValidator } from '@/server/api/validator'
这样就将错误统一处理,且后续自定义业务错误也同样如此。
:::note 顺带一提
如果需要让 zod 支持中文错误提示,可以使用 zod-i18n-map
:::
RPC
Hono 有个特性我很喜欢也很好用,可以像 TRPC 那样,导出一个 供前端直接调用,省去编写前端 api 调用代码以及对应的类型。
这里我不想在过多叙述 RPC(可见我之前所写有关 ),直接来说说有哪些注意点。
链式调用
还是以 User CRUD 的代码为例,不难发现 .get .post .put 都是以链式调用的写法来写的,一旦拆分后,此时接口还是能够调用,但这将会丢失此时路由对应的类型,导致 client 无法使用获取正常类型,使用链式调用的 app 实例化对象则正常。
替换原生 Fetch 库
hono 自带的 fetch 或者说原生的 fetch 非常难用,为了针对业务错误统一处理,因此需要选用请求库来替换,这里我的选择是 ky,因为他的写法相对原生 fetch 更友好一些,并且不会破坏 hono 原有类型推导。
在 lib/api-client.ts 编写以下代码
import { AppType } from '@/server/api'
import { hc } from 'hono/client'
import ky from 'ky'
const baseUrl =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: process.env.NEXT_PUBLIC_APP_URL!
export const fetch = ky.extend({
hooks: {
afterResponse: [
async (_, __, response: Response) => {
if (response.ok) {
return response
} else {
throw await response.json()
}
},
],
},
})
export const client = hc<AppType>(baseUrl, {
fetch: fetch,
})
这里我是根据请求状态码来判断本次请求是否为异常,因此使用 response.ok,而响应体正好有 message 字段可直接用作 Error message 提示,这样就完成了前端请求异常处理。
至于说请求前自动添加协议头、请求后的数据转换,这就属于老生常谈的东西了,这里就不多赘述,根据实际需求编写即可。
请求体与响应体的类型推导
配合 react-query 可以更好的获取类型安全。此写法与 tRPC 十分相似,相应代码 → Inferring Types
// hooks/users/use-user-create.ts
import { client } from '@/lib/api-client'
import { InferRequestType, InferResponseType } from 'hono/client'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
const $post = client.api.users.$post
type BodyType = InferRequestType<typeof $post>['json']
type ResponseType = InferResponseType<typeof $post>['data']
export const useUserCreate = () => {
return useMutation<ResponseType, Error, BodyType>({
mutationKey: ['create-user'],
mutationFn: async (json) => {
const { data } = await (await $post({ json })).json()
return data
},
onSuccess: (data) => {
toast.success('User created successfully')
},
onError: (error) => {
toast.error(error.message)
},
})
}
在 app/users/page.tsx 中的使用
'use client'
import { useUserCreate } from '@/features/users/use-user-create'
export default function UsersPage() {
const { mutate, isPending } = useUserCreate()
const handleSubmit = (e: React.FormEvent ) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const email = formData.get('email') as string
mutate({ name, email })
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='name'>Name:label>
<input type='text' id='name' name='name' />
div>
<div>
<label htmlFor='email'>Email:label>
<input type='email' id='email' name='email' />
div>
<button type='submit' disabled={isPending}>
Create User
button>
form>
)
}
OpenAPI 文档
这部分我已经弃坑了,没找到一个很好的方式为 Hono 写 OpenAPI 文档。不过对于 TS 全栈开发者,似乎也没必要编写 API 文档(接口自给自足),更何况还有 RPC 这样的黑科技,不担心接口的请求参数与响应接口。
如果你真要写,那我说说几个我遇到的坑,也是我弃坑的原因。
首先就是写法上,你需要将所有的 Hono 替换成 OpenAPIHono (来自 @hono/zod-openapi, 其中 zod 实例 z 也是)。以下是官方的示例代码,我将其整合到一个文件内
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
const app = new OpenAPIHono()
const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '123',
}),
})
const UserSchema = z
.object({
id: z.string().openapi({ example: '123' }),
name: z.string().openapi({ example: 'John Doe' }),
})
.openapi('User')
const route = createRoute({
method: 'get',
path: '/api/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Retrieve the user',
},
},
})
app.openapi(route, async (c) => {
const { id } = c.req.valid('param')
// 逻辑代码...
const user = {
id,
name: 'Ultra-man',
}
return c.json(user)
})
从上述代码的可读性来看,第一眼你很难看到清晰的看出这个接口到底是什么请求方法、请求路径,并且在写法上需要使用 .openapi 方法,传入一个由 createRoute 所创建的 router 对象。并且写法上不是在原有基础上扩展,已有的代码想要通过代码优先的方式来编写 OpenAPI 文档将要花费不小的工程,这也是我为何不推荐的原因。
定义完接口(路由)之后,只需要通过 app.doc 方法与 swaggerUI 函数,访问 /api/doc 查看 OpenAPI 的 JSON 数据,以及访问 /api/ui 查看 Swagger 界面。
import { swaggerUI } from '@hono/swagger-ui'
app.doc('/api/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Demo API',
},
})
app.get('/api/ui', swaggerUI({ url: '/api/doc' }))
从目前来看,OpenAPI 文档的生成仍面临挑战。我们期待 Hono 未来能推出一个功能,可以根据 app 下的路由自动生成接口文档(相关Issue已存在)。
仓库地址
附上本文中示例 demo 仓库链接(这个项目就不搞线上访问了)
/kuizuo/next…
后记
其实我还想写写 Auth、DB 这些服务集成的(这些都在我实际工作中实践并应用了),或许是太久未写 Blog 导致手生了不少,这篇文章也是断断续续写了好几天。后续我将会出一版完整的我个人的 Nextjs 与 Hono 的最佳实践模版。
也说说我为什么会选用 Hono.js 作为后端服务, 其实就是 Next.js 的 API Route 实在是太难用了,加之轻量化,你完全可以将整个 Nextjs + Hono 服务部署在 Vercel 上,并且还能用上 Edge Functions 的特性。(就是有点小贵)
但不过从我的 Nest.js 开发经验来看(也可能是习惯了 Spring Boot 那套三层架构开发形态),总觉得 Hono 差了点意思,说不出来的体验,可能这就是所谓的全栈框架的开发感受吧。