大师兄

41 | 实战(一):“画图”程序后端实战

你好,我是七牛云许式伟。

到今天为止,服务端开发的基本内容已经讲完了。我们花了比较长的篇幅来介绍服务端的基础软件,包括负载均衡和各类存储中间件。然后我们上一讲介绍了服务端在业务架构上的一些通用问题。

今天我们开始进入实战。

对比服务端和桌面的内容可以看出,服务端开发和桌面端开发各自有各自的复杂性。服务端开发,难在基础软件很多,对程序员和架构师的知识面和理解深度都有较高的要求。但从业务复杂性来说,服务端的业务逻辑相对简单。而桌面端开发则相反,它的难点在于用户交互逻辑复杂,代码量大,业务架构的复杂性高。

上一章的实战篇,蛮多人反馈有点难,这某种程度来说和我们课程内容设计的规划有关。上一章我们从架构角度来说,偏重于介绍概要设计,也就是系统架构。所以我们对实现细节并没有做过多的剖析,而是把重心放在模块之间的接口耦合上。这是希望你把关注点放在全局,而不是一上来就进入局部细节。但是由于缺乏完整流程的剖析,大家没法把整个过程串起来,理解上就会打折扣。

这一章我们在架构上会偏重于详细设计。这在实战篇也会有所体现。

在上一章,我们实现了一个 mock 版本的服务端,代码如下:

接下来我们一步步把它变成一个产品级的服务端程序。

RPC 框架

第一步,我们引入 RPC 框架。

为了方便你理解,在上一章的实战中,我们的 mock 服务端程序没有引入任何非标准库的内容。代码如下:

整个 Service 大约 280 行代码。

我们改为基于七牛云开源的 restrpc 框架来实现,代码如下:

这样,整个 Service 就大约只剩下 163 行代码,只有原先的 60% 不到。

到底少写了哪些代码?我们拿创建一个新图形来看下。原先我们这样写:

func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
var aShape serviceShape
err = json.NewDecoder(req.Body).Decode(&aShape)
if err != nil {
ReplyError(w, err)
return
}
err = drawing.Add(aShape.Get())
if err != nil {
ReplyError(w, err)
return
}
ReplyCode(w, 200)
}

现在这样写:

func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
return drawing.Add(aShape.Get())
}

这个例子返回包比较简单,没有 HTTP 包的正文。

我们再来看一个返回包比较复杂的例子,取图形的内容。原先我们这样写:

func (p *Service) GetShape(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
shapeID := args[1]
shape, err := drawing.Get(shapeID)
if err != nil {
ReplyError(w, err)
return
}
Reply(w, 200, shape)
}

现在这样写:

func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
shapeID := env.Args[1]
return drawing.Get(shapeID)
}

对比这两个例子,我们可以看出:

  • 原先这两个请求 POST /drawings/<DrawingID>/shapesGET /drawings/<DrawingID>/shapes/<ShapeID> 中的 URL 参数如 DrawingID、ShapeID 的值,是通过参数 args[0]、args[1] 传入,现在通过 env.Args[0]、env.Args[1] 传入。
  • 原先我们 PostShapes 需要自己定义 Shape 实例并解析 HTTP 请求包 req.Body 的内容。现在我们只需要在参数中指定 Shape 类型,restrpc 框架就自动完成参数的解析。
  • 原先我们 GetShape 需要自己回复错误或者返回正常的 HTTP 协议包。现在我们只需要在返回值列表中返回要回复的数据,restrpc 框架自动完成返回值的序列化并回复 HTTP 请求。

通过对比两个版本的代码差异,我们大体能够猜得出来,restrpc 的 HTTP 处理函数背后都干了些啥。其核心代码如下:

值得关注的是 Env 的支持,RPC 框架并没有限定 Env 类具体是什么样子的,只是规定它需要满足以下接口:

type itfEnv interface {
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
CloseEnv()
}

在 OpenEnv 方法中,我们一般进行 Env 的初始化工作。CloseEnv 方法则反之。为什么 OpenEnv 方法中,ResponseWriter 接口是以指针方式传入?因为可能会有客户希望改写 ResponseWriter 的实现。

比如,假设我们要给 RPC 框架扩展 API 审计日志的功能。那么我们就需要接管并记录用户返回的 HTTP 包,这时我们就需要改写 ResponseWriter 以达到接管并记录的目的。

另外值得注意的是,restrpc 版本的 HTTP 请求的处理函数,看起来不再那么像 HTTP 处理函数,倒像一个普通函数。

这意味着我们可以有两种方式来测试 Service 类。除了用正常测试 HTTP Service 的方法来测试它以外,我们也可以把 Service 类当成普通类来测试,这大大降低单元测试的成本。因为我们不用再需要包装服务的 Client SDK,然后再基于 Client SDK 做单元测试。

当然,我们有这样的一种低成本测试方式,但还是会担心这种测试方法可能不能覆盖一些编码上的小意外,毕竟我们没有走 HTTP 协议,心里多多少少有些不踏实。

理解了 restrpc 的 HTTP 处理函数,剩下的就是 restrpc 的路由功能。它是由 restrpc.Router 类的 Register 函数完成的。代码如下:

它支持两种路由方式,一种是根据方法名字自动路由。比如 POST /drawings/<DrawingID>/shapes 这样的请求,要求方法名为 “PostDrawings_Shapes”。GET /drawings/<DrawingID>/shapes/<ShapeID> 这样的请求,要求方法名为 “GetDrawings_Shapes_”。

规则倒是比较简单,路径中的 “/” 由单词首字母大写来分隔,URL 参数如 DrawingID、ShapeID 这些则替换为 “_”。

当然有的人会认为这种方法名字看起来很丑。那么就可以选择手工路由的方式,传入 routeTable。它看起来是这样的:

var routeTable = [][2]string{
{"POST /drawings", "PostDrawings"},
{"GET /drawings/*", "GetDrawing"},
{"DELETE /drawings/*", "DeleteDrawing"},
{"POST /drawings/*/sync", "PostDrawingSync"},
{"POST /drawings/*/shapes", "PostShapes"},
{"GET /drawings/*/shapes/*", "GetShape"},
{"POST /drawings/*/shapes/*", "PostShape"},
{"DELETE /drawings/*/shapes/*", "DeleteShape"},
}

虽然是手工路由,但是方法名仍然有限制,要求必须是 Get、Put、Post、Delete 开头。

业务逻辑的分层

理解了 restrpc 框架,我们再看下 QPaint 服务端的业务本身。可以看出,我们的服务端业务逻辑被分为两层:一层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。代码如下:

另一层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。有了上面我们介绍的 restrpc 框架,这一层的每个方法往往都比较简单,甚至有的只是很简单的一句函数调用。比如:

func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
id := env.Args[0]
return p.doc.Delete(id)
}

完整的RESTful API 层代码如下:

这样分层的原因,是因为我们实现核心业务逻辑的时候,并不会假设一定通过 RESTful API 暴露。我们考虑这样几种可能性:

其一,有可能我们根本不需要网络调用。

做个类比,我们都知道 mysql 是通过 TCP 协议提供服务接口的,而 sqlite 是嵌入式数据库,是通过本地的函数调用提供服务接口的。这里分层就类似于我实现 mysql 的时候,先在底层实现了一个类似 sqlite 的嵌入式数据库,然后再提供基于 TCP 协议的网络接口。

其二,有可能我们需要支持很多种网络协议。

我们今天流行 RESTful API,所以我们的接口是 RESTful 风格的。如果有一天我们像 Github 一样想改用 GraphQL,那么至少底层的业务逻辑实现层是不需要改变的,我们只需要实现相对薄的 GraphQL 层就行了。

而且,往往在这种情况下 RESTful API 和 GraphQL 是需要同时支持的。毕竟我们不可能为了赶时髦,就把老用户弃之不顾了。

在需要同时支持多套网络接口的时候,这种分层的价值就体现出来了,不同网络接口的模块之间,共享了同一份 DOM 树的实例,整个体系不仅实现了多协议并存,还实现了完美的解耦,彼此之间完全独立。

单元测试

聊完了业务,我们再来看看单元测试。

之前,我们单元测试基本上没怎么做:

代码如下:

type idRet struct {
ID string `json:"id"`
}
func TestNewDrawing(t *testing.T) {
...
var ret idRet
err := Post(&ret, ts.URL + "/drawings", "")
if err != nil {
t.Fatal("Post /drawings failed:", err)
}
if ret.ID != "10001" {
t.Log("new drawing id:", ret.ID)
}
}

从这里的测试代码可以看出,我们就只是创建了一个 drawing,并且要求返回的 drawingID 为 "10001"。

从单元测试的角度,这样的测试力度当然是非常不足的。同样的测试案例,用我们上一讲介绍的 httptest 测试框架实现如下:

func TestNewDrawing(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{"id": "10001"}'
`)
}

当然,实际我们应该去测试更多的情况,比如:

func TestService(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{
"id": $(id1)
}'
match $(line1) '{
"id": "1",
"line": {
"pt1": {"x": 2.0, "y": 3.0},
"pt2": {"x": 15.0, "y": 30.0},
"style": {
"lineWidth": 3,
"lineColor": "red"
}
}
}'
post http://qpaint.com/drawings/$(id1)/shapes
json $(line1)
ret 200
get http://qpaint.com/drawings/$(id1)/shapes/1
ret 200
json $(line1)
`)
if !ctx.GetVar("id1").Equal("10001") {
t.Fatal(`$(id1) != "10001"`)
}
}

这个案例我们想演示什么?这是一个相对复杂的案例。首先我们创建了一个 drawing,并且将 drawingID 放到变量 $(id1) 中。随后,我们向该 drawing 中添加了一条直线 $(line1)。为了确认添加成功,我们取出了该图形对象,并且判断取得的图形和添加进去的 $(line1) 是否一致。

另外,它也演示了 qiniutest DSL 脚本和 Go 语言代码的互操作性。我们用 Go 代码取得变量 $(id1),并且判断它是否和 "10001" 相等。

关于 qiniutest 更多的内容,请查阅以下资料:

在我们的测试代码中,还使用了一个七牛云开源的 mockhttp 组件,它也非常有意思:

这个 mockhttp 并不真去监听端口,感兴趣的同学可以研究一下。

结语

我们总结一下今天的内容。

从今天开始我们会一步步将之前写的 mock 服务端改造为真实的服务端程序。

我们第一步改造的是 RPC 框架和单元测试。这样我们第一次开始依赖第三方的代码库,如下:

一旦有了外部依赖,我们就需要考虑依赖库的版本管理。好的一点是大多数现代语言都有很好的版本管理规范,对于 Go 语言我们用 go mod 来做版本管理。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。