Hono 前后端分离系列 03:路由、请求和响应应该怎么设计
wxk1991 Lv5

Hono 前后端分离系列 03:路由、请求和响应应该怎么设计

接口设计不是把函数暴露出去。

它是在给前端、后端和未来的自己签一份契约。

Hono 写路由很简单:

1
2
app.get('/api/users', listUsers)
app.post('/api/users', createUser)

但正因为简单,很多项目会很快写乱:

1
2
3
4
5
/getUser
/user/list
/api/user/delete
/api/users/:id/remove
/api/queryUserInfo

接口一旦乱了,前端调用会痛,后端维护也会痛。

这篇文章不只讲 Hono 的路由语法,更重要的是讲前后端分离项目里,路由、请求和响应应该怎样设计。


一、先从资源开始想

REST API 的核心不是动词,而是资源。

比如用户、文章、订单、文件:

1
2
3
4
users
posts
orders
files

不要一上来写:

1
2
3
/getUserList
/createUser
/deleteUser

更推荐:

1
2
3
4
5
GET    /api/users
POST /api/users
GET /api/users/:id
PATCH /api/users/:id
DELETE /api/users/:id

Hono 里可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Hono } from 'hono'

const users = new Hono()

users.get('/', async (c) => {
return c.json({ items: [] })
})

users.post('/', async (c) => {
return c.json({ id: 'u_1' }, 201)
})

users.get('/:id', async (c) => {
const id = c.req.param('id')
return c.json({ id })
})

users.patch('/:id', async (c) => {
const id = c.req.param('id')
return c.json({ id, updated: true })
})

users.delete('/:id', async (c) => {
return c.body(null, 204)
})

export default users

再挂到主应用:

1
app.route('/api/users', users)

这样路由会自然长成一棵树,而不是一堆散乱字符串。


二、请求参数分三类

前端调用接口时,参数一般分三类。

第一类是路径参数:

1
/api/users/:id

用来表示资源身份:

1
const id = c.req.param('id')

第二类是查询参数:

1
/api/users?page=1&pageSize=20&keyword=tom

用来表示筛选、排序、分页:

1
2
const page = c.req.query('page')
const keyword = c.req.query('keyword')

第三类是 body:

1
2
3
4
{
"name": "Tom",
"email": "[email protected]"
}

用来提交复杂数据:

1
const body = await c.req.json()

不要把所有参数都塞进 query,也不要把资源 id 放进 body。规则简单一点,前后端都省心。


三、响应结构要统一

前端最怕的响应不是错误,而是不一致。

有的接口返回:

1
{ "data": [] }

有的返回:

1
{ "list": [] }

有的报错:

1
{ "message": "failed" }

有的报错:

1
{ "error": "failed" }

这会逼前端在每个接口里写特殊处理。

我更推荐统一成两类。

成功响应:

1
2
3
4
{
"data": {},
"requestId": "req_xxx"
}

失败响应:

1
2
3
4
5
6
7
{
"error": {
"code": "USER_NOT_FOUND",
"message": "用户不存在"
},
"requestId": "req_xxx"
}

Hono handler 可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app.get('/api/users/:id', async (c) => {
const id = c.req.param('id')

if (id !== 'u_1') {
return c.json(
{
error: {
code: 'USER_NOT_FOUND',
message: '用户不存在',
},
},
404
)
}

return c.json({
data: {
id,
name: 'Tom',
},
})
})

注意状态码不要偷懒。

业务失败不应该永远返回 200。


四、状态码要有基本纪律

前后端分离项目里,常用状态码其实不多。

1
2
3
4
5
6
7
8
9
10
200 请求成功
201 创建成功
204 删除成功,无响应体
400 请求参数错误
401 未登录或 token 无效
403 已登录但无权限
404 资源不存在
409 资源冲突
422 语义校验失败
500 服务端未知错误

不要把所有错误都塞进 500。

也不要所有失败都返回 200,然后靠 code 判断。

HTTP 状态码负责第一层语义,业务 error.code 负责第二层语义。两个一起用,前端处理会清楚很多。


五、路由文件不要写业务细节

一个常见坏味道是 route 里什么都做:

1
2
3
4
5
6
7
8
app.post('/api/users', async (c) => {
const body = await c.req.json()
// 校验
// 查库
// 发邮件
// 写日志
// 拼响应
})

刚开始没问题,后面会变成 300 行 handler。

更好的方式是让 route 只做 HTTP 层:

1
2
3
4
5
6
users.post('/', async (c) => {
const input = await c.req.json()
const user = await userService.create(input)

return c.json({ data: user }, 201)
})

业务逻辑放到 service:

1
2
3
4
5
6
7
export const userService = {
async create(input: CreateUserInput) {
// 校验唯一性
// 写数据库
// 返回领域对象
},
}

前后端分离项目长期维护的关键,不是路由能不能写,而是路由能不能保持薄。


六、分页接口要提前统一

列表接口一定会越来越多。

所以分页结构最好一开始就统一:

1
GET /api/users?page=1&pageSize=20

响应:

1
2
3
4
5
6
7
8
{
"data": {
"items": [],
"page": 1,
"pageSize": 20,
"total": 0
}
}

不要一个接口叫 list,一个接口叫 rows,一个接口叫 records

前端表格组件、分页组件、请求封装都会因此变复杂。


七、最后的建议

Hono 给你的是轻量路由能力,不是接口设计能力。

接口设计要靠团队纪律。

我的基本规则是:

1
2
3
4
5
6
7
8
用资源名设计路径
用 HTTP 方法表达动作
路径参数表示资源身份
query 表示筛选分页
body 表示提交内容
状态码表达第一层结果
JSON error.code 表达业务错误
route 保持薄,业务放 service

这套规则不花哨,但很管用。

前后端分离不是前端和后端各写各的。真正的连接点就是 API。API 一乱,整个项目都会变慢。


参考资料