Go 语言编写 RESTful API 的一些思考

引言

近来实践了下 Go Web 开发,也一并做了些 Web 开发相关的效率工具,目的也是为了提升开发体验,进而改善项目质量和提高开发效率。

之前在使用 Python 做 Web 项目时,比较习惯基于类的方式为某个资源提供 RESTful 接口。和采用纯函数的方式不同,基于类的方式可以使得资源管理更清晰,当逻辑代码较多时,代码结构也不至于太混乱。

所以在使用 Go 语言实践时,我们会采用类似 ResourceHandler 的结构体的方式来替代类,并给该结构体绑定相应的 GET/POST/DELETE/PUT/PATCH 方法,用以和 HTTP 相应的请求方法匹配。

遇到的问题

我们使用的 Web 框架是基于 go-chi/chi 封装的,这个框架本身的特点就是简单,与 Go 标准库 http 无缝衔接。主要用它来做路由分发,方便编写 HTTP API。

但在实践中,路由注册那块感觉很繁琐。比如,我们先前的写法大致是下面这样的:

1
2
3
4
5
6
7
r := chi.NewRouter()

hd := NewTasksHandler()

r.Get("/tasks/{task_id:(\\d+)}", hd.Get)
r.Post("/tasks/{task_id:(\\d+)}", hd.Post)
r.Delete("/tasks/{task_id:(\\d+)", hd.Delete}

显然,上面的写法有点啰嗦,且容易出错。比如在单元测试中,我们就可能会因为疏忽大意,给 test server 绑定的路由和实际的不同(别问我怎么知道的)。所以就希望能有一种简单的方式注册路由,并且让程序自己根据请求的 Method 分发到 Handler 对应的方法上。

既然遇到问题,那就得想个优雅的方法解决,尽可能保证性能损耗不大,但又能带来较好的开发体验。于是乎,rest 包诞生了。

说说 REST 包

rest 包的实现很简单,就是对 go-chi/chi 框架做了简单的包装,实现了一个自定义的 BaseHandler 实现请求分发到所谓的「子类」结构体对应的方法进行处理。由于并不会进行类型断言或者使用反射,所以带来的性能开销不会很高。

那么,在有了 rest 包后,我们如何使用它呢?

使用示例

假如我们要编写一个任务管理服务,需要提供的接口如下:

功能 方法 URL 示例
获取任务列表 GET /tasks
新建任务 POST /tasks
获取单个任务 GET /tasks/1
删除单个任务 DELETE /tasks/1

rest 的助攻下,我们可以这样来写:

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
28
29
30
func main() {
r := rest.NewRouter()
r.MountHandler("/tasks", &TasksHandler{})
r.MountHandler("/tasks/{task_id:(\\d+)}", &TaskHandler{})
rest.Run(r, 8000)
}

type TasksHandler struct {
rest.BaseHandler
}

func (hd *TasksHandler) Get() {
hd.RenderJSON(map[string]string{"message": "Get Tasks"})
}

func (hd *TasksHandler) Post() {
hd.RenderJSON(map[string]string{"message": "Create Task"})
}

type TaskHandler struct {
rest.BaseHandler
}

func (hd *TaskHandler) Get() {
hd.RenderJSON(map[string]string{"message": "GET Task"})
}

func (hd *TaskHandler) Delete() {
hd.RenderJSON(map[string]string{"message": "Delete Task"})
}

项目结构

这个项目结构仅供参考,也没啥特别的:

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
28
29
30
.
├── Gopkg.lock (dep 包管理使用,锁定版本)
├── Gopkg.toml (dep 包管理使用)
├── Makefile (一些环境准备命令等)
├── README.md (项目简介)
├── bin
│   ├── web (Web 服务二进制程序,单个文件即可运行)
├── cmd
│   └── web (Web 服务的入口)
│   └── main.go
├── pkg
│   ├── configs (项目资源配置,通过资源发现获取)
│   ├── consts
│   │   ├── enum.go (定义一些枚举类型)
│   │   └── macro.go (定义一些常量)
│   ├── controllers (核心的业务逻辑)
│   ├── errs (业务错误定义,放在一块维护)
│   ├── middlewares (业务自定义中间件)
│   ├── models (顾名思义,ORM Model 层)
│   ├── rpcs (依赖的第三方服务)
│   ├── utils (常用的小工具)
│   └── web
│   ├── handlers (处理请求的 handlers 实现)
│ ├── schemas (定义聚合数据的 API Schema)
│   └── router.go (路由注册)
├── scripts (一些脚本)
├── testdata (放单元测试需要的 Mock 数据)
│   ├── fixtures (测试数据,用于导入测试库)
│   │   └── foo.yml
└── vendor (一些第三方依赖包,因为有墙😭,所以得存下来避免部署时因为网络等太久)

总结

怎么样,新的路由注册方式是不是更加简单了?其实这正是借鉴了 Python Tornado 的一些思想。我不认为这种做法是所谓的反模式,或者不符合 Go 的风格(欢迎来喷)。其实这压根和语言无关,为什么不愿意去实践一些能够改善开发体验的新方法呢?况且还能够减少犯错!

0%