• 作者:老汪软件技巧
  • 发表时间: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 差了点意思,说不出来的体验,可能这就是所谓的全栈框架的开发感受吧。