极客时间已完结课程限时免费阅读

42 | 实战(二):“画图”程序后端实战

42 | 实战(二):“画图”程序后端实战

42 | 实战(二):“画图”程序后端实战

讲述:丁伟

时长09:50大小9.02M

你好,我是七牛云许式伟。
在上一章,我们实现了一个 mock 版本的服务端,代码如下:
接下来我们将一步步迭代,把它变成一个产品级的服务端程序。
我们之前已经提到,服务端程序的业务逻辑被分为两层:底层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。上层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。
上一讲我们关注的是 RESTful API 层。我们为了实现它,引入了 RPC 框架 restrpc 和单元测试框架 qiniutest
这一讲我们关注的是底层的业务逻辑实现层。

使用界面(接口)

我们先看下这一层的使用界面(接口)。从 DOM 树的角度来说,在这一讲之前,它的逻辑结构如下:
<Drawing1>
<Shape11>
...
<Shape1M>
...
<DrawingN>
从大的层次结构来说只有三层:
Document => Drawing => Shape
那么,在引入多租户(即多用户,每个用户有自己的 uid)之后的 DOM 树,会发生什么样的变化?
比如我们是否应该把它变成四层:
Document => User => Drawing => Shape
<User1>
<Drawing11>
<Shape111>
...
<Shape11M>
...
<Drawing1N>
...
<UserK>
我的答案是:多租户不应该影响 DOM 树的结构。所以正确的设计应该是:
<Drawing1>, 隶属于某个<uid>
<Shape11>
...
<Shape1M>
...
<DrawingN>, 隶属于某个<uid>
也就是说,多租户只会导致 DOM 树多了一些额外的约定,通常我们应该把它看作某种程度的安全约定,避免访问到没有权限访问到的资源。
所以多租户不会导致 DOM 层级变化,但是它会导致接口方法的变化。比如我们看 Document 类的方法。之前,Document 类接口看起来是这样的:
func (p *Document) Add() (drawing *Drawing, err error)
func (p *Document) Get(dgid string) (drawing *Drawing, err error)
func (p *Document) Delete(dgid string) (err error)
现在它变成了:
// Add 创建新drawing。
func (p *Document) Add(uid UserID) (drawing *Drawing, err error)
// Get 获取drawing。
// 我们会检查要获取的drawing是否为该uid所拥有,如果不属于则获取会失败。
func (p *Document) Get(uid UserID, dgid string) (drawing *Drawing, err error)
// Delete 删除drawing。
// 我们会检查要删除的drawing是否为该uid所拥有,如果不属于删除会失败。
func (p *Document) Delete(uid UserID, dgid string) (err error)
正如注释中说的那样,传入 uid 是一种约束,我们无论是获取还是删除 drawing ,都会看这个 drawing 是不是隶属于该用户。
对于 QPaint 程序来说,Document 类之外其他类的接口倒是没有发生变化。比如 Drawing 类的接口如下:
func (p *Drawing) GetID() string
func (p *Drawing) Add(shape Shape) (err error)
func (p *Drawing) List() (shapes []Shape, err error)
func (p *Drawing) Get(id ShapeID) (shape Shape, err error)
func (p *Drawing) Set(id ShapeID, shape Shape) (err error)
func (p *Drawing) SetZorder(id ShapeID, zorder string) (err error)
func (p *Drawing) Delete(id ShapeID) (err error)
func (p *Drawing) Sync(shapes []ShapeID, changes []Shape) (err error)
但是这只是因为 QPaint 程序的业务逻辑比较简单。虽然我们需要极力避免接口因为多租户而产生变化,但是这种影响有时候却是不可避免的。
另外,在描述类的使用界面时,我们不能只描述语言层面的约定。比如上面的 Drawing 类,我们引用图形(Shape)对象时,用的是 Go 语言的 interface。如下:
type ShapeID = string
type Shape interface {
GetID() ShapeID
}
但是,是不是这一接口就是图形(Shape)的全部约束?
答案显然不是。
我们先看一个最基本的约束:考虑到 Drawing 类的 List 和 Get 返回的 Shape 实例,会被直接作为 RESTful API 的结果返回。所以,Shape 已知的一大约束是,其 json.Marshal 结果必须符合 API 层的预期。
至于在“实战二”的代码实现下,我们对 Shape 完整的约束是什么样的,欢迎你留言讨论。

数据结构

明确了使用界面,下一步就要考虑实现相关的内容。可能大家都听过这样一个说法:
程序 = 数据结构 + 算法
它是一个很好的指导思想。所以当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
我们先看数据结构。
对于服务端程序,数据结构不完全是我们自己能够做主的。在 “36 | 业务状态与存储中间件”这一讲中我们说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。
对于 QPaint 的服务端程序来说,我们选择了 mongodb。
为何是 mongodb,而不是某种关系型数据库?
最重要的理由,是因为图形(Shape)对象的开放性。因为图形的种类很多,它的 Schema 不是我们今天所能够提前预期的。故此,文档型数据库更为合适。
确定了基于 mongodb 这个存储中间件,我们下一步就是定义表结构。当然表(Table)是在关系型数据库中的说法,在 mongodb 中我们叫集合(Collection)。但是出于惯例,我们很多时候还是以 “定义表结构” 一词来表达我们想干什么。
我们定义了两个表(Collection):drawing 和 shape。其中,drawing 表记录所有的 drawing,而 shape 表记录所有的 shape。具体如下:
我们重点关注索引的设计。
在 drawing 表中,我们为 uid 建立了索引。这个比较容易理解:虽然目前我们没有提供 List 某个用户所有 drawing 的方法,但这是迟早的事情。
在 shape 表中,我们为 (dgid, spid) 建立了联合唯一索引。这是因为 spid 作为 ShapeID ,是 drawing 内部唯一的,而不是全局唯一的。所以,它需要联合 dgid 作为唯一索引。

算法

谈清楚了数据结构,我们接着聊算法。
在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?
在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。
所以算法,最直白的含义,指的是用户故事背后的实现机制。
数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。以下是一些典型的用户故事:
创建新 drawing (uid):
dgid = newObjectId()
db.drawing.insert({_id: dgid, uid: uid, shapes:[]})
return dgid
取得 drawing 的内容 (uid, dgid):
doc = db.drawing.findOne({_id: dgid, uid: uid})
shapes = []
foreach spid in doc.shapes {
o = db.shape.findOne({dgid: dgid, spid: spid})
shapes.push(o.shape)
}
return shapes
删除 drawing (uid, dgid):
if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
db.shape.remove({dgid: dgid})
}
创建新 shape (uid, dgid, shape):
if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
db.shape.insert({dgid: dgid, spid: shape.id, shape: shape})
db.drawing.update({$push: {shapes: shape.id}})
}
删除 shape (uid, dgid, spid):
if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
if db.drawing.update({$pull: {shapes: spid}}) {
db.shape.remove({dgid: dgid, spid: spid})
}
}
这些算法的表达整体是一种伪代码。但它也不完全是伪代码。如果大家用过 mongo 的 shell 的话,其实能够知道这里面的每一条 mongo 数据库操作的代码都是真实有效的。
另外,从严谨的角度来说,以上算法中凡是涉及到多次修改操作的,都应该以事务形式来做。比如删除 drawing 的代码:
if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
db.shape.remove({dgid: dgid})
}
假如第一句 drawing 表的 remove 操作执行成功,但是在此时发生了故障停机事件导致 shape 表的 remove 没有完成,那么从用户的业务逻辑角度来说一切都正常,但是从系统维护的角度来说,系统残留了一些孤立的 shape 对象,永远都没有机会被清除。

网络协议

考虑到底层的业务逻辑实现层已经支持多租户,我们网络协议也需要做出相应的修改。这一讲我们只做最简单的调整,引入一个 mock 的授权机制。如下:
Authorization QPaintStub <uid>
既然有了 Authorization,那么我们就不能继续用 restrpc.Env 作为 RPC 请求的环境了。我们自己实现一个 Env,如下:
type Env struct {
restrpc.Env
UID UserID
}
func (p *Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
auth := req.Header.Get("Authorization")
pos := strings.Index(auth, " ")
if pos < 0 || auth[:pos] != "QPaintStub" {
return errBadToken
}
uid, err := strconv.Atoi(auth[pos+1:])
if err != nil {
return errBadToken
}
p.UID = UserID(uid)
return p.Env.OpenEnv(rcvr, w, req)
}
把所有的 restrpc.Env 替换为我们自己的 Env,再对代码进行一些微调(Document 类的调用增加 env.UID 参数),我们就完成了基本的多租户改造。
改造后完整的 RESTful API 层代码如下:

结语

总结一下今天的内容。
今天我们主要改造的是底层的业务逻辑实现层。
一方面,我们对使用界面(接口)作了多租户的改造。多租户改造从网络协议角度来说,主要是增加授权(Authorization)。从底层的 DOM 接口角度来说,主要是 Document 类增加 uid 参数。
另一方面,我们基于 mongodb 完成了新的实现。我们对数据结构和算法作了详细的描述。要更完整了解实现细节,请重点阅读以下两个文件:
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 6

提建议

上一篇
41 | 实战(一):“画图”程序后端实战
下一篇
43 | 实战(三):“画图”程序后端实战
unpreview
 写留言

精选留言(10)

  • spark
    2019-09-17
    这个专栏看到现在的体会: 1.作者是大神。神一样的思考和行为。 2.体会了看懂后的快乐,无价之宝。 3.必须磕头才能表达敬意。
    共 1 条评论
    19
  • zhj
    2020-03-15
    shape为什么不直接设计在drawing的层级结构中,现在用法还是偏关系型,本身monggo使用也是推崇这种整体嵌套,而且可以利用行的原子性来规避mongo不支持事务的软肋,目前这样设计初衷只是为了享有mongo的schema free,

    作者回复: shape直接在drawing里面就是一个画图docunent作为一个整体放到数据库,这固然有优点,但是当document是一个复杂文档时就会有很低的编辑性能,数据库的io性能也会受到影响(原因是小修改变成了大io)。

    共 2 条评论
    4
  • Bachue Zhou
    2019-10-23
    从工程的角度看,不推荐用这种难以理解的缩写,dgid 乍看之下非常难以让人联想到 drawingId

    作者回复: 这种简写一般在团队内有统一的缩写表,否则建议全称

    2
  • Geek_88604f
    2019-09-22
    多租户共用一颗 DOM 树,会不会存在性能瓶颈?在后续用户数增多,高并发的情况下无法满足要求,存在拆分的诉求?

    作者回复: 不是共享dom树,这里和共享没什么关系。你可以认为uid和任何普通的变量一样,只是一个普通数据而已。

    1
  • Aaron Cheung
    2019-09-17
    读了好几遍文章 很有收获 打卡
    2
  • 不温暖啊不纯良
    2021-04-27
    使用界面那里,单租户和多租户的区别是,当变成多租户的时候,图形界面便加入了用户概念,让每个用户下面都拥有一个画图对象和图形对象。那如果不加入用户的概念呢?那当我们对外提供服务的时候,所有的用户都在使用同一个画图对象和图形对象,比如用户a创建了一个圆型,用户B的电脑屏幕上同时也会出现一个圆型,这就像是一个马桶一样,要想让100个人同时解决问题,那你就必须提供100个马桶,而在使用界面中加入用户概念就是解决了这个问题,也就是多租户,每个人都有自己的独立空间。 关于用户和角色的时候。一个用户可以是多个角色。那一个角色也可以有多个用户。一个人可以是银行经理,也可以是孩子的父母,也可以是父母的孩子。而银行经理,父母这两个角色的职能可以赋给很多人。 用户故事。嗯,我理解的是用户和系统交互的行为,我把它叫做用户行为,业务系统将一个个用户的行为抽象成接口,用算法来实现或模拟用户行为。
    展开
    1
  • 你为啥那么牛
    2019-12-16
    看到现在,很多概念的理解进一步升华。
  • 程序员小跃
    2019-11-20
    看后端实战,没有动GO语言,还得回去稍微了解下GO
  • Geek_88604f
    2019-09-22
    关于Sharp的约束我试着理解一下,语言方面的约束老师已经说了两点:一是Drawing类通过接口来引用Sharp类以增加通用性;二是考虑到 Drawing 类的 List 和 Get 返回的 Shape 实例,会被直接作为 RESTful API 的结果返回。所以,Shape 的 json.Marshal 结果必须符合 API 层的预期。 除了语言方面的约束外还存在语意方面的约束,一方面RESTful要求对资源的操作只能是POST、GET、PUT、DELETE;另一方面考虑到网络可能出现的故障,接口实现要幂等,支持错误或超时的重试。
    展开

    作者回复: Shape对象的约束和网络并没有关系

  • 2019-09-17
    有深度