mirror of
https://github.com/chai2010/advanced-go-programming-book.git
synced 2025-05-24 12:32:21 +00:00
半角变全角
This commit is contained in:
parent
217820254c
commit
49bca18209
@ -1,6 +1,6 @@
|
|||||||
# 5.1 Web 开发简介
|
# 5.1 Web 开发简介
|
||||||
|
|
||||||
因为Go的`net/http`包提供了基础的路由函数组合与丰富的功能函数。所以在社区里流行一种用Go编写API不需要框架的观点;在我们看来,如果你的项目的路由在个位数、URI固定且不通过URI来传递参数,那么确实使用官方库也就足够。但在复杂场景下,官方的http库还是有些力有不逮。例如下面这样的路由:
|
因为Go的`net/http`包提供了基础的路由函数组合与丰富的功能函数。所以在社区里流行一种用Go编写API不需要框架的观点,在我们看来,如果你的项目的路由在个位数、URI固定且不通过URI来传递参数,那么确实使用官方库也就足够。但在复杂场景下,官方的http库还是有些力有不逮。例如下面这样的路由:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /card/:id
|
GET /card/:id
|
||||||
@ -18,7 +18,7 @@ Go的Web框架大致可以分为这么两类:
|
|||||||
1. Router框架
|
1. Router框架
|
||||||
2. MVC类框架
|
2. MVC类框架
|
||||||
|
|
||||||
在框架的选择上,大多数情况下都是依照个人的喜好和公司的技术栈。例如公司有很多技术人员是PHP出身,那么他们一定会非常喜欢像beego这样的框架,但如果公司有很多C程序员,那么他们的想法可能是越简单越好。比如很多大厂的C程序员甚至可能都会去用 C 去写很小的CGI程序,他们可能本身并没有什么意愿去学习MVC或者更复杂的Web框架,他们需要的只是一个非常简单的路由(甚至连路由都不需要,只需要一个基础的HTTP协议处理库来帮他省掉没什么意思的体力劳动)。
|
在框架的选择上,大多数情况下都是依照个人的喜好和公司的技术栈。例如公司有很多技术人员是PHP出身,那么他们一定会非常喜欢像beego这样的框架,但如果公司有很多C程序员,那么他们的想法可能是越简单越好。比如很多大厂的C程序员甚至可能都会去用C语言去写很小的CGI程序,他们可能本身并没有什么意愿去学习MVC或者更复杂的Web框架,他们需要的只是一个非常简单的路由(甚至连路由都不需要,只需要一个基础的HTTP协议处理库来帮他省掉没什么意思的体力劳动)。
|
||||||
|
|
||||||
Go的`net/http`包提供的就是这样的基础功能,写一个简单的`http echo server`只需要30s。
|
Go的`net/http`包提供的就是这样的基础功能,写一个简单的`http echo server`只需要30s。
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ func main() {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你过了30s还没有完成这个程序,请检查一下你自己的打字速度是不是慢了(开个玩笑 :D)。这个例子是为了说明在Go中写一个HTTP协议的小程序有多么简单。如果你面临的情况比较复杂,例如几十个接口的企业级应用,直接用`net/http`库就显得不太合适了。
|
如果你过了30s还没有完成这个程序,请检查一下你自己的打字速度是不是慢了(开个玩笑 :D)。这个例子是为了说明在Go中写一个HTTP协议的小程序有多么简单。如果你面临的情况比较复杂,例如几十个接口的企业级应用,直接用`net/http`库就显得不太合适了。
|
||||||
|
|
||||||
我们来看看开源社区中一个Kafka监控项目中的做法:
|
我们来看看开源社区中一个Kafka监控项目中的做法:
|
||||||
|
|
||||||
@ -146,7 +146,7 @@ func handleKafka(app *ApplicationContext, w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
因为默认的`net/http`包中的mux不支持带参数的路由,所以Burrow这个项目使用了非常蹩脚的字符串 Split 和乱七八糟的 `switch case`来达到自己的目的,但实际上却让本来应该很集中的路由管理逻辑变得复杂,散落在系统的各处,难以维护和管理。如果读者细心地看过这些代码之后,可能会发现其它的几个handler函数逻辑上较简单,最复杂的也就是这个handleKafka。但实际上我们的系统总是从这样微不足道的混乱开始积少成多,最终变得难以收拾。
|
因为默认的`net/http`包中的`mux`不支持带参数的路由,所以Burrow这个项目使用了非常蹩脚的字符串`Split`和乱七八糟的 `switch case`来达到自己的目的,但实际上却让本来应该很集中的路由管理逻辑变得复杂,散落在系统的各处,难以维护和管理。如果读者细心地看过这些代码之后,可能会发现其它的几个`handler`函数逻辑上较简单,最复杂的也就是这个`handleKafka()`。但实际上我们的系统总是从这样微不足道的混乱开始积少成多,最终变得难以收拾。
|
||||||
|
|
||||||
根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用`net/http`中默认的路由。在Go开源界应用最广泛的router是httpRouter,很多开源的router框架都是基于httpRouter进行一定程度的改造的成果。关于httpRouter路由的原理,会在本章节的router一节中进行详细的阐释。
|
根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用`net/http`中默认的路由。在Go开源界应用最广泛的router是httpRouter,很多开源的router框架都是基于httpRouter进行一定程度的改造的成果。关于httpRouter路由的原理,会在本章节的router一节中进行详细的阐释。
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
# 5.2 router 请求路由
|
# 5.2 router 请求路由
|
||||||
|
|
||||||
在常见的Web框架中,router是必备的组件。Go语言圈子里router也时常被称为http的multiplexer。在上一节中我们通过对Burrow代码的简单学习,已经知道如何用http标准库中内置的mux来完成简单的路由功能了。如果开发Web系统对路径中带参数没什么兴趣的话,用http标准库中的mux就可以。
|
在常见的Web框架中,router是必备的组件。Go语言圈子里router也时常被称为`http`的multiplexer。在上一节中我们通过对Burrow代码的简单学习,已经知道如何用`http`标准库中内置的mux来完成简单的路由功能了。如果开发Web系统对路径中带参数没什么兴趣的话,用`http`标准库中的`mux`就可以。
|
||||||
|
|
||||||
RESTful是几年前刮起的API设计风潮,在RESTful中除了GET和POST之外,还使用了http协议定义的几种其它的标准化语义。具体包括:
|
RESTful是几年前刮起的API设计风潮,在RESTful中除了GET和POST之外,还使用了HTTP协议定义的几种其它的标准化语义。具体包括:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
const (
|
const (
|
||||||
@ -30,9 +30,9 @@ PUT /user/starred/:owner/:repo
|
|||||||
DELETE /user/starred/:owner/:repo
|
DELETE /user/starred/:owner/:repo
|
||||||
```
|
```
|
||||||
|
|
||||||
相信聪明的你已经猜出来了,这是github官方文档中挑出来的几个API设计。RESTful风格的API重度依赖请求路径。会将很多参数放在请求URI中。除此之外还会使用很多并不那么常见的HTTP状态码,不过本节只讨论路由,所以先略过不谈。
|
相信聪明的你已经猜出来了,这是Github官方文档中挑出来的几个API设计。RESTful风格的API重度依赖请求路径。会将很多参数放在请求URI中。除此之外还会使用很多并不那么常见的HTTP状态码,不过本节只讨论路由,所以先略过不谈。
|
||||||
|
|
||||||
如果我们的系统也想要这样的URI设计,使用标准库的mux显然就力不从心了。
|
如果我们的系统也想要这样的URI设计,使用标准库的`mux`显然就力不从心了。
|
||||||
|
|
||||||
## 5.2.1 httprouter
|
## 5.2.1 httprouter
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ GET /user/info/:name
|
|||||||
POST /user/:id
|
POST /user/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
简单来讲的话,如果两个路由拥有一致的http method(指 GET/POST/PUT/DELETE)和请求路径前缀,且在某个位置出现了A路由是wildcard(指 :id 这种形式)参数,B路由则是普通字符串,那么就会发生路由冲突。路由冲突会在初始化阶段直接panic:
|
简单来讲的话,如果两个路由拥有一致的http方法(指 GET/POST/PUT/DELETE)和请求路径前缀,且在某个位置出现了A路由是wildcard(指:id这种形式)参数,B路由则是普通字符串,那么就会发生路由冲突。路由冲突会在初始化阶段直接panic:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
panic: wildcard route ':id' conflicts with existing children in path '/user/:id'
|
panic: wildcard route ':id' conflicts with existing children in path '/user/:id'
|
||||||
@ -81,7 +81,7 @@ Pattern: /src/*filepath
|
|||||||
/src/subdir/somefile.go filepath = "subdir/somefile.go"
|
/src/subdir/somefile.go filepath = "subdir/somefile.go"
|
||||||
```
|
```
|
||||||
|
|
||||||
这种设计在RESTful中可能不太常见,主要是为了能够使用httprouter来做简单的http静态文件服务器。
|
这种设计在RESTful中可能不太常见,主要是为了能够使用httprouter来做简单的HTTP静态文件服务器。
|
||||||
|
|
||||||
除了正常情况下的路由支持,httprouter也支持对一些特殊情况下的回调函数进行定制,例如404的时候:
|
除了正常情况下的路由支持,httprouter也支持对一些特殊情况下的回调函数进行定制,例如404的时候:
|
||||||
|
|
||||||
@ -101,17 +101,17 @@ r.PanicHandler = func(w http.ResponseWriter, r *http.Request, c interface{}) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
目前开源界最为流行(star数最多)的Web框架[gin](https://github.com/gin-gonic/gin)使用的就是httprouter的变种。
|
目前开源界最为流行(star数最多)的Web框架[gin](https://github.com/gin-gonic/gin)使用的就是httprouter的变种。
|
||||||
|
|
||||||
## 5.2.2 原理
|
## 5.2.2 原理
|
||||||
|
|
||||||
httprouter和众多衍生router使用的数据结构被称为压缩字典树(Radix Tree)。读者可能没有接触过压缩字典树,但对字典树(Trie Tree)应该有所耳闻。*图 5-1*是一个典型的字典树结构:
|
httprouter和众多衍生router使用的数据结构被称为压缩字典树(Radix Tree)。读者可能没有接触过压缩字典树,但对字典树(Trie Tree)应该有所耳闻。*图 5-1*是一个典型的字典树结构:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*图 5-1 字典树*
|
*图 5-1 字典树*
|
||||||
|
|
||||||
字典树常用来进行字符串检索,例如用给定的字符串序列建立字典树。对于目标字符串,只要从根节点开始深度优先搜索,即可判断出该字符串是否曾经出现过,时间复杂度为O(n),n可以认为是目标字符串的长度。为什么要这样做?字符串本身不像数值类型可以进行数值比较,两个字符串对比的时间复杂度取决于字符串长度。如果不用字典树来完成上述功能,要对历史字符串进行排序,再利用二分查找之类的算法去搜索,时间复杂度只高不低。可认为字典树是一种空间换时间的典型做法。
|
字典树常用来进行字符串检索,例如用给定的字符串序列建立字典树。对于目标字符串,只要从根节点开始深度优先搜索,即可判断出该字符串是否曾经出现过,时间复杂度为`O(n)`,n可以认为是目标字符串的长度。为什么要这样做?字符串本身不像数值类型可以进行数值比较,两个字符串对比的时间复杂度取决于字符串长度。如果不用字典树来完成上述功能,要对历史字符串进行排序,再利用二分查找之类的算法去搜索,时间复杂度只高不低。可认为字典树是一种空间换时间的典型做法。
|
||||||
|
|
||||||
普通的字典树有一个比较明显的缺点,就是每个字母都需要建立一个孩子节点,这样会导致字典树的层数比较深,压缩字典树相对好地平衡了字典树的优点和缺点。是典型的压缩字典树结构:
|
普通的字典树有一个比较明显的缺点,就是每个字母都需要建立一个孩子节点,这样会导致字典树的层数比较深,压缩字典树相对好地平衡了字典树的优点和缺点。是典型的压缩字典树结构:
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ httprouter和众多衍生router使用的数据结构被称为压缩字典树(Rad
|
|||||||
|
|
||||||
*图 5-2 压缩字典树*
|
*图 5-2 压缩字典树*
|
||||||
|
|
||||||
每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。使用压缩字典树可以减少树的层数,同时因为每个节点上数据存储也比通常的字典树要多,所以程序的局部性较好(一个节点的path加载到 cache 即可进行多个字符的对比),从而对CPU缓存友好。
|
每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。使用压缩字典树可以减少树的层数,同时因为每个节点上数据存储也比通常的字典树要多,所以程序的局部性较好(一个节点的path加载到cache即可进行多个字符的对比),从而对CPU缓存友好。
|
||||||
|
|
||||||
## 5.2.3 压缩字典树创建过程
|
## 5.2.3 压缩字典树创建过程
|
||||||
|
|
||||||
@ -138,7 +138,7 @@ GET /support
|
|||||||
GET /marketplace_listing/plans/ohyes
|
GET /marketplace_listing/plans/ohyes
|
||||||
```
|
```
|
||||||
|
|
||||||
最后一条补充路由是我们臆想的,除此之外所有API路由均来自于api.github.com。
|
最后一条补充路由是我们臆想的,除此之外所有API路由均来自于`api.github.com`。
|
||||||
|
|
||||||
### 5.2.3.1 root 节点创建
|
### 5.2.3.1 root 节点创建
|
||||||
|
|
||||||
@ -153,7 +153,7 @@ type Router struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`trees`中的`key`即为http 1.1的RFC中定义的各种方法,具体有:
|
`trees`中的`key`即为HTTP 1.1的RFC中定义的各种方法,具体有:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
GET
|
GET
|
||||||
@ -193,7 +193,7 @@ nType: 当前节点类型,有四个枚举值: 分别为 static/root/param/catc
|
|||||||
param // 参数节点,例如 :id
|
param // 参数节点,例如 :id
|
||||||
catchAll // 通配符节点,例如 *anyway
|
catchAll // 通配符节点,例如 *anyway
|
||||||
|
|
||||||
indices: 子节点索引,当子节点为非参数类型,即本节点的 wildChild 为 false 时,会将每个子节点的首字母放在该索引数组。说是数组,实际上是个 string。
|
indices:子节点索引,当子节点为非参数类型,即本节点的wildChild为false时,会将每个子节点的首字母放在该索引数组。说是数组,实际上是个string。
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -227,7 +227,7 @@ indices: 子节点索引,当子节点为非参数类型,即本节点的 wild
|
|||||||
|
|
||||||
*图 5-6 插入第三个节点,导致边分裂*
|
*图 5-6 插入第三个节点,导致边分裂*
|
||||||
|
|
||||||
原有路径和新的路径在初始的`/`位置发生分裂,这样需要把原有的root节点内容下移,再将新路由 `search`同样作为子节点挂在root节点之下。这时候因为子节点出现多个,root节点的indices提供子节点索引,这时候该字段就需要派上用场了。"ms" 代表子节点的首字母分别为m(marketplace)和s(search)。
|
原有路径和新的路径在初始的`/`位置发生分裂,这样需要把原有的root节点内容下移,再将新路由 `search`同样作为子节点挂在root节点之下。这时候因为子节点出现多个,root节点的indices提供子节点索引,这时候该字段就需要派上用场了。"ms"代表子节点的首字母分别为m(marketplace)和s(search)。
|
||||||
|
|
||||||
我们一口作气,把`GET /status`和`GET /support`也插入到树中。这时候会导致在`search`节点上再次发生分裂,最终结果见*图 5-7*:
|
我们一口作气,把`GET /status`和`GET /support`也插入到树中。这时候会导致在`search`节点上再次发生分裂,最终结果见*图 5-7*:
|
||||||
|
|
||||||
@ -237,7 +237,7 @@ indices: 子节点索引,当子节点为非参数类型,即本节点的 wild
|
|||||||
|
|
||||||
### 5.2.3.4 子节点冲突处理
|
### 5.2.3.4 子节点冲突处理
|
||||||
|
|
||||||
在路由本身只有字符串的情况下,不会发生任何冲突。只有当路由中含有wildcard(类似 :id)或者catchAll的情况下才可能冲突。这一点在前面已经提到了。
|
在路由本身只有字符串的情况下,不会发生任何冲突。只有当路由中含有wildcard(类似 :id)或者catchAll的情况下才可能冲突。这一点在前面已经提到了。
|
||||||
|
|
||||||
子节点的冲突处理很简单,分几种情况:
|
子节点的冲突处理很简单,分几种情况:
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ func main() {
|
|||||||
|
|
||||||
渐渐的我们的系统增加到了30个路由和`handler`函数,每次增加新的handler,我们的第一件工作就是把之前写的所有和业务逻辑无关的周边代码先拷贝过来。
|
渐渐的我们的系统增加到了30个路由和`handler`函数,每次增加新的handler,我们的第一件工作就是把之前写的所有和业务逻辑无关的周边代码先拷贝过来。
|
||||||
|
|
||||||
接下来系统安稳地运行了一段时间,突然有一天,老板找到你,我们最近找人新开发了监控系统,为了系统运行可以更加可控,需要把每个接口运行的耗时数据主动上报到我们的监控系统里。给监控系统起个名字吧,叫metrics。现在你需要修改代码并把耗时通过HTTP Post的方式发给metrics 了。我们来修改一下`helloHandler()`:
|
接下来系统安稳地运行了一段时间,突然有一天,老板找到你,我们最近找人新开发了监控系统,为了系统运行可以更加可控,需要把每个接口运行的耗时数据主动上报到我们的监控系统里。给监控系统起个名字吧,叫metrics。现在你需要修改代码并把耗时通过HTTP Post的方式发给metrics系统了。我们来修改一下`helloHandler()`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func helloHandler(wr http.ResponseWriter, r *http.Request) {
|
func helloHandler(wr http.ResponseWriter, r *http.Request) {
|
||||||
@ -153,13 +153,13 @@ func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
|
|||||||
func (ResponseWriter, *Request)
|
func (ResponseWriter, *Request)
|
||||||
```
|
```
|
||||||
|
|
||||||
那么这个handler和`http.HandlerFunc()`就有了一致的函数签名,可以将该handler函数进行类型转换,转为`http.HandlerFunc`。而`http.HandlerFunc`实现了`http.Handler`这个接口。在http库需要调用你的handler函数来处理http请求时,会调用`HandlerFunc`的`ServeHTTP`函数,可见一个请求的基本调用链是这样的:
|
那么这个`handler`和`http.HandlerFunc()`就有了一致的函数签名,可以将该`handler()`函数进行类型转换,转为`http.HandlerFunc`。而`http.HandlerFunc`实现了`http.Handler`这个接口。在`http`库需要调用你的handler函数来处理http请求时,会调用`HandlerFunc()`的`ServeHTTP()`函数,可见一个请求的基本调用链是这样的:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
h = getHandler() => h.ServeHTTP(w, r) => h(w, r)
|
h = getHandler() => h.ServeHTTP(w, r) => h(w, r)
|
||||||
```
|
```
|
||||||
|
|
||||||
上面提到的把自定义handler转换为`http.HandlerFunc`这个过程是必须的,因为我们的handler没有直接实现`ServeHTTP`这个接口。上面的代码中我们看到的HandleFunc(注意HandlerFunc和HandleFunc的区别)里也可以看到这个强制转换过程:
|
上面提到的把自定义`handler`转换为`http.HandlerFunc()`这个过程是必须的,因为我们的`handler`没有直接实现`ServeHTTP`这个接口。上面的代码中我们看到的HandleFunc(注意HandlerFunc和HandleFunc的区别)里也可以看到这个强制转换过程:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
|
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
|
||||||
@ -284,7 +284,7 @@ throttler.go
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*图 5-9 *
|
*图 5-9 gin的中间件仓库*
|
||||||
|
|
||||||
如果读者去阅读gin的源码的话,可能会发现gin的中间件中处理的并不是`http.Handler`,而是一个叫`gin.HandlerFunc`的函数类型,和本节中讲解的`http.Handler`签名并不一样。不过实际上gin的`handler`也只是针对其框架的一种封装,中间件的原理与本节中的说明是一致的。
|
如果读者去阅读gin的源码的话,可能会发现gin的中间件中处理的并不是`http.Handler`,而是一个叫`gin.HandlerFunc`的函数类型,和本节中讲解的`http.Handler`签名并不一样。不过实际上gin的`handler`也只是针对其框架的一种封装,中间件的原理与本节中的说明是一致的。
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
*图 5-10 validator流程*
|
*图 5-10 validator流程*
|
||||||
|
|
||||||
实际上这是一个语言无关的场景,需要进行字段校验的情况有很多,Web系统的Form/JSON提交只是一个典型的例子。我们用go来写一个类似上图的校验demo。然后研究怎么一步步对其进行改进。
|
实际上这是一个语言无关的场景,需要进行字段校验的情况有很多,Web系统的Form或JSON提交只是一个典型的例子。我们用go来写一个类似上图的校验demo。然后研究怎么一步步对其进行改进。
|
||||||
|
|
||||||
## 5.4.1 重构请求校验函数
|
## 5.4.1 重构请求校验函数
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ func register(req RegisterReq) error{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
代码更清爽,看起来也不那么别扭了。这是比较通用的重构理念。虽然使用了重构方法使我们的validate过程看起来优雅了,但我们还是得为每一个http请求都去写这么一套差不多的validate函数,有没有更好的办法来帮助我们解除这项体力劳动?答案就是validator。
|
代码更清爽,看起来也不那么别扭了。这是比较通用的重构理念。虽然使用了重构方法使我们的校验过程代码看起来优雅了,但我们还是得为每一个`http`请求都去写这么一套差不多的`validate()`函数,有没有更好的办法来帮助我们解除这项体力劳动?答案就是validator。
|
||||||
|
|
||||||
## 5.4.2 用validator解放体力劳动
|
## 5.4.2 用validator解放体力劳动
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ func validate(req RegisterReq) error {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
这样就不需要在每个请求进入业务逻辑之前都写重复的validate函数了。本例中只列出了这个validator非常简单的几个功能。
|
这样就不需要在每个请求进入业务逻辑之前都写重复的`validate()`函数了。本例中只列出了这个validator非常简单的几个功能。
|
||||||
|
|
||||||
我们试着跑一下这个程序,输入参数设置为:
|
我们试着跑一下这个程序,输入参数设置为:
|
||||||
|
|
||||||
@ -235,7 +235,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
这里我们简单地对`eq=x`和`email`这两个tag进行了支持,读者可以对这个程序进行简单的修改以查看具体的validate效果。为了演示精简掉了错误处理和复杂情况的处理,例如reflect.Int8/16/32/64,reflect.Ptr等类型的处理,如果给生产环境编写validate库的话,请务必做好功能的完善和容错。
|
这里我们简单地对`eq=x`和`email`这两个tag进行了支持,读者可以对这个程序进行简单的修改以查看具体的validate效果。为了演示精简掉了错误处理和复杂情况的处理,例如`reflect.Int8/16/32/64`,`reflect.Ptr`等类型的处理,如果给生产环境编写validate库的话,请务必做好功能的完善和容错。
|
||||||
|
|
||||||
在前一小节中介绍的validator组件在功能上要远比我们这里的例子复杂的多。但原理很简单,就是用反射对结构体进行树形遍历。有心的读者这时候可能会产生一个问题,我们对结构体进行validate时大量使用了反射,而go的反射在性能上不太出众,有时甚至会影响到我们程序的性能。这样的考虑确实有一些道理,但需要对结构体进行大量校验的场景往往出现在Web服务,这里并不一定是程序的性能瓶颈所在,实际的效果还是要从pprof中做更精确的判断。
|
在前一小节中介绍的validator组件在功能上要远比我们这里的例子复杂的多。但原理很简单,就是用反射对结构体进行树形遍历。有心的读者这时候可能会产生一个问题,我们对结构体进行validate时大量使用了反射,而go的反射在性能上不太出众,有时甚至会影响到我们程序的性能。这样的考虑确实有一些道理,但需要对结构体进行大量校验的场景往往出现在Web服务,这里并不一定是程序的性能瓶颈所在,实际的效果还是要从pprof中做更精确的判断。
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
# 5.5 Database 和数据库打交道
|
# 5.5 Database 和数据库打交道
|
||||||
|
|
||||||
本节将对 db/sql 官方标准库作一些简单分析,并介绍一些应用比较广泛的开源 ORM 和 sql builder。并从企业级应用开发和公司架构的角度来分析哪种技术栈对于现代的企业级应用更为合适。
|
本节将对`db/sql`官方标准库作一些简单分析,并介绍一些应用比较广泛的开源ORM和sql builder。并从企业级应用开发和公司架构的角度来分析哪种技术栈对于现代的企业级应用更为合适。
|
||||||
|
|
||||||
## 5.5.1 从 database/sql 讲起
|
## 5.5.1 从 database/sql 讲起
|
||||||
|
|
||||||
Go官方提供了 `database/sql` 包来给用户进行和数据库打交道的工作,实际上 `database/sql` 库就只是提供了一套操作数据库的接口和规范,例如抽象好的 sql 预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。
|
Go官方提供了`database/sql`包来给用户进行和数据库打交道的工作,实际上`database/sql`库就只是提供了一套操作数据库的接口和规范,例如抽象好的SQL预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。
|
||||||
|
|
||||||
和具体的数据库,例如MySQL打交道,还需要再引入MySQL的驱动,像下面这样:
|
和具体的数据库,例如MySQL打交道,还需要再引入MySQL的驱动,像下面这样:
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ db, err := sql.Open("mysql", "user:password@/dbname")
|
|||||||
import _ "github.com/go-sql-driver/mysql"
|
import _ "github.com/go-sql-driver/mysql"
|
||||||
```
|
```
|
||||||
|
|
||||||
这一句 import,实际上是调用了 mysql 包的 init 函数,做的事情也很简单:
|
这一句import,实际上是调用了`mysql`包的`init`函数,做的事情也很简单:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func init() {
|
func init() {
|
||||||
@ -27,7 +27,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
在 sql 包的 全局 map 里把 mysql 这个名字的 driver 注册上。实际上 Driver 在 sql 包中是一个 interface:
|
在`sql`包的 全局`map`里把`mysql`这个名字的`driver`注册上。实际上`Driver`在`sql`包中是一个接口:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Driver interface {
|
type Driver interface {
|
||||||
@ -35,7 +35,7 @@ type Driver interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
调用 sql.Open() 返回的 db 对象实际上就是这里的 Conn。
|
调用`sql.Open()`返回的`db`对象实际上就是这里的`Conn`。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Conn interface {
|
type Conn interface {
|
||||||
@ -45,9 +45,9 @@ type Conn interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
也是一个接口。实际上如果你仔细地查看 database/sql/driver/driver.go 的代码会发现,这个文件里所有的成员全都是 interface,对这些类型进行操作,实际上还是会调用具体的 driver 里的方法。
|
也是一个接口。实际上如果你仔细地查看`database/sql/driver/driver.go`的代码会发现,这个文件里所有的成员全都是接口,对这些类型进行操作,实际上还是会调用具体的`driver`里的方法。
|
||||||
|
|
||||||
从用户的角度来讲,在使用 database/sql 包的过程中,你能够使用的也就是这些 interface 里提供的函数。来看一个使用 database/sql 和 go-sql-driver/mysql 的完整的例子:
|
从用户的角度来讲,在使用`database/sql`包的过程中,你能够使用的也就是这些接口里提供的函数。来看一个使用`database/sql`和`go-sql-driver/mysql`的完整的例子:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@ -96,13 +96,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
如果读者想了解官方这个 database/sql 库更加详细的用法的话,可以参考:
|
如果读者想了解官方这个`database/sql`库更加详细的用法的话,可以参考`http://go-database-sql.org/`。
|
||||||
|
|
||||||
> http://go-database-sql.org/
|
包括该库的功能介绍、用法、注意事项和反直觉的一些实现方式(例如同一个goroutine内对`sql.DB`的查询,可能在多个连接上)都有涉及,本章中不再赘述。
|
||||||
|
|
||||||
包括该库的功能介绍、用法、注意事项和反直觉的一些实现方式(例如同一个 goroutine 内对 sql.DB 的查询,可能在多个连接上)都有涉及,本章中不再赘述。
|
聪明如你的话,在上面这段简短的程序中可能已经嗅出了一些不好的味道。官方的`db`库提供的功能这么简单,我们每次去数据库里读取内容岂不是都要去写这么一套差不多的代码?或者如果我们的对象是结构体,把`sql.Rows`绑定到对象的工作就会变得更加得重复而无聊。
|
||||||
|
|
||||||
聪明如你的话,在上面这段简短的程序中可能已经嗅出了一些不好的味道。官方的 db 库提供的功能这么简单,我们每次去数据库里读取内容岂不是都要去写这么一套差不多的代码?或者如果我们的对象是 struct,把 sql.Rows 绑定到对象的工作就会变得更加得重复而无聊。
|
|
||||||
|
|
||||||
是的,所以社区才会有各种各样的sql builder和orm百花齐放。
|
是的,所以社区才会有各种各样的sql builder和orm百花齐放。
|
||||||
|
|
||||||
@ -116,7 +114,7 @@ func main() {
|
|||||||
从效果上说,它其实是创建了一个可在编程语言里使用的“虚拟对象数据库”。
|
从效果上说,它其实是创建了一个可在编程语言里使用的“虚拟对象数据库”。
|
||||||
```
|
```
|
||||||
|
|
||||||
最为常见的 ORM 实际上做的是从 db -> 程序的 class / struct 这样的映射。所以你手边的程序可能是从 mysql 的表 -> 你的程序内 class。我们可以先来看看其它的程序语言里的 ORM 写起来是怎么样的感觉:
|
最为常见的ORM实际上做的是从db到程序的类或结构体这样的映射。所以你手边的程序可能是从MySQL的表映射你的程序内的类。我们可以先来看看其它的程序语言里的ORM写起来是怎么样的感觉:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
>>> from blog.models import Blog
|
>>> from blog.models import Blog
|
||||||
@ -124,7 +122,7 @@ func main() {
|
|||||||
>>> b.save()
|
>>> b.save()
|
||||||
```
|
```
|
||||||
|
|
||||||
完全没有数据库的痕迹,没错 ORM 的目的就是屏蔽掉DB层,实际上很多语言的 ORM 只要把你的 class/struct 定义好,再用特定的语法将结构体之间的一对一或者一对多关系表达出来。那么任务就完成了。然后你就可以对这些映射好了数据库表的对象进行各种操作,例如 save,create,retrieve,delete。至于 orm在背地里做了什么阴险的勾当,你是不一定清楚的。使用 ORM 的时候,我们往往比较容易有一种忘记了数据库的直观感受。举个例子,我们有个需求:向用户展示最新的商品列表,我们再假设,商品和商家是1:1的关联关系,我们就很容易写出像下面这样的代码:
|
完全没有数据库的痕迹,没错ORM的目的就是屏蔽掉DB层,实际上很多语言的ORM只要把你的类或结构体定义好,再用特定的语法将结构体之间的一对一或者一对多关系表达出来。那么任务就完成了。然后你就可以对这些映射好了数据库表的对象进行各种操作,例如save,create,retrieve,delete。至于ORM在背地里做了什么阴险的勾当,你是不一定清楚的。使用ORM的时候,我们往往比较容易有一种忘记了数据库的直观感受。举个例子,我们有个需求:向用户展示最新的商品列表,我们再假设,商品和商家是1:1的关联关系,我们就很容易写出像下面这样的代码:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 伪代码
|
# 伪代码
|
||||||
@ -134,9 +132,9 @@ for product in productList {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
当然了,我们不能批判这样写代码的程序员是偷懒的程序员。因为ORM 一类的工具在出发点上就是屏蔽 sql,让我们对数据库的操作更接近于人类的思维方式。这样很多只接触过 orm 而且又是刚入行的程序员就很容易写出上面这样的代码。
|
当然了,我们不能批判这样写代码的程序员是偷懒的程序员。因为ORM一类的工具在出发点上就是屏蔽sql,让我们对数据库的操作更接近于人类的思维方式。这样很多只接触过ORM而且又是刚入行的程序员就很容易写出上面这样的代码。
|
||||||
|
|
||||||
这样的代码将对数据库的读请求放大了 N 倍。也就是说,如果你的商品列表有 15 个 SKU,那么每次用户打开这个页面,至少需要执行 1(查询商品列表) + 15(查询相关的商铺信息) 次查询。这里 N 是 16。如果你的列表页很大,比如说有 600 个条目,那么你就至少要执行 1 + 600 次查询。如果说你的数据库能够承受的最大的简单查询是12w QPS,而上述这样的查询正好是你最常用的查询的话,实际上你能对外提供的服务能力是多少呢?是 200 qps!互联网系统的忌讳之一,就是这种无端的读放大。
|
这样的代码将对数据库的读请求放大了N倍。也就是说,如果你的商品列表有15个SKU,那么每次用户打开这个页面,至少需要执行1(查询商品列表)+ 15(查询相关的商铺信息)次查询。这里N是16。如果你的列表页很大,比如说有600个条目,那么你就至少要执行1+600次查询。如果说你的数据库能够承受的最大的简单查询是12万QPS,而上述这样的查询正好是你最常用的查询的话,实际上你能对外提供的服务能力是多少呢?是200 qps!互联网系统的忌讳之一,就是这种无端的读放大。
|
||||||
|
|
||||||
当然,你也可以说这不是ORM的问题,如果你手写sql你还是可能会写出差不多的程序,那么再来看两个demo:
|
当然,你也可以说这不是ORM的问题,如果你手写sql你还是可能会写出差不多的程序,那么再来看两个demo:
|
||||||
|
|
||||||
@ -145,17 +143,17 @@ o := orm.NewOrm()
|
|||||||
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
|
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
|
||||||
```
|
```
|
||||||
|
|
||||||
很多 orm 都提供了这种 Filter 类型的查询方式,beego 也不例外。不过实际上在这段 orm 背后隐藏了非常难以察觉的细节,那就是生成的 sql 语句会自动 limit 1000。
|
很多ORM都提供了这种Filter类型的查询方式,不过实际上在某些ORM背后甚至隐藏了非常难以察觉的细节,比如生成的sql语句会自动 limit 1000。
|
||||||
|
|
||||||
也许喜欢 beego 的读者读到这里会反驳了,你是没有认真阅读 beego 的文档就瞎写。是的,尽管 beego 在文档里说明了 All 查询在不显式地指定 Limit 的话会自动 limit 1000,但对于很多没有阅读过文档或者看过 beego 源码的人,这依然是一个非常难以察觉的“魔鬼”细节。喜欢强类型语言的人一般都不喜欢语言隐式地去做什么事情,例如各种语言在赋值操作时进行的隐式类型转换然后又在转换中丢失了精度的勾当,一定让你非常的头疼。所以一个程序库背地里做的事情还是越少越好,如果一定要做,那也一定要在显眼的地方做。比如上面的例子,去掉这种默认的自作聪明的行为,或者要求用户强制传入 limit 参数都是更好的选择。
|
也许喜欢ORM的读者读到这里会反驳了,你是没有认真阅读文档就瞎写。是的,尽管这些ORM工具在文档里说明了All查询在不显式地指定Limit的话会自动limit 1000,但对于很多没有阅读过文档或者看过ORM源码的人,这依然是一个非常难以察觉的“魔鬼”细节。喜欢强类型语言的人一般都不喜欢语言隐式地去做什么事情,例如各种语言在赋值操作时进行的隐式类型转换然后又在转换中丢失了精度的勾当,一定让你非常的头疼。所以一个程序库背地里做的事情还是越少越好,如果一定要做,那也一定要在显眼的地方做。比如上面的例子,去掉这种默认的自作聪明的行为,或者要求用户强制传入limit参数都是更好的选择。
|
||||||
|
|
||||||
除了 limit 的问题,我们再看一遍这个 beego orm 的查询:
|
除了limit的问题,我们再看一遍这个下面的查询:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
|
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
|
||||||
```
|
```
|
||||||
|
|
||||||
你可以看得出来这个 Filter 是有表 join 的操作么?当然了,对 beego orm 有过深入使用经验的用户还是会觉得这是在吹毛求疵。但这样的分析想证明的是,orm 想从设计上隐去太多的细节。而方便的代价是其背后的运行完全失控。这样的项目在经过几任维护人员之后,将变得面目全非,难以维护。
|
你可以看得出来这个Filter是有表join的操作么?当然了,有深入使用经验的用户还是会觉得这是在吹毛求疵。但这样的分析想证明的是,ORM想从设计上隐去太多的细节。而方便的代价是其背后的运行完全失控。这样的项目在经过几任维护人员之后,将变得面目全非,难以维护。
|
||||||
|
|
||||||
当然,我们不能否认ORM的进步意义,它的设计初衷就是为了让数据的操作和存储的具体实现所剥离。但是在上了规模的公司的人们渐渐达成了一个共识,由于隐藏重要的细节,ORM可能是失败的设计。其所隐藏的重要细节对于上了规模的系统开发来说至关重要。
|
当然,我们不能否认ORM的进步意义,它的设计初衷就是为了让数据的操作和存储的具体实现所剥离。但是在上了规模的公司的人们渐渐达成了一个共识,由于隐藏重要的细节,ORM可能是失败的设计。其所隐藏的重要细节对于上了规模的系统开发来说至关重要。
|
||||||
|
|
||||||
@ -174,15 +172,15 @@ orders := orderModel.GetList(where, limit, orderBy)
|
|||||||
|
|
||||||
写sql builder的相关代码,或者读懂都不费劲。把这些代码脑内转换为sql也不会太费劲。所以通过代码就可以对这个查询是否命中数据库索引,是否走了覆盖索引,是否能够用上联合索引进行分析了。
|
写sql builder的相关代码,或者读懂都不费劲。把这些代码脑内转换为sql也不会太费劲。所以通过代码就可以对这个查询是否命中数据库索引,是否走了覆盖索引,是否能够用上联合索引进行分析了。
|
||||||
|
|
||||||
说白了 sql builder 是 sql 在代码里的一种特殊方言,如果你们没有DBA但研发有自己分析和优化 sql 的能力,或者你们公司的 dba 对于学习这样一些 sql 的方言没有异议。那么使用 sql builder 是一个比较好的选择,不会导致什么问题。
|
说白了sql builder是sql在代码里的一种特殊方言,如果你们没有DBA但研发有自己分析和优化sql的能力,或者你们公司的DBA对于学习这样一些sql的方言没有异议。那么使用sql builder是一个比较好的选择,不会导致什么问题。
|
||||||
|
|
||||||
另外在一些本来也不需要DBA介入的场景内,使用 sql builder 也是可以的,例如你要做一套运维系统,且将 mysql 当作了系统中的一个组件,系统的 QPS 不高,查询不复杂等等。
|
另外在一些本来也不需要DBA介入的场景内,使用sql builder也是可以的,例如你要做一套运维系统,且将MySQL当作了系统中的一个组件,系统的QPS不高,查询不复杂等等。
|
||||||
|
|
||||||
一旦你做的是高并发的OLTP在线系统,且想在人员充足分工明确的前提下最大程度控制系统的风险,使用sql builder就不合适了。
|
一旦你做的是高并发的OLTP在线系统,且想在人员充足分工明确的前提下最大程度控制系统的风险,使用sql builder就不合适了。
|
||||||
|
|
||||||
## 5.5.3 脆弱的 db
|
## 5.5.3 脆弱的数据库
|
||||||
|
|
||||||
无论是 ORM 还是 sql builder 都有一个致命的缺点,就是没有办法进行系统上线的事前 sql 审核。虽然很多 orm 和 sql builder 也提供了运行期打印 sql 的功能,但只在查询的时候才能进行输出。而 sql builder 和 ORM本身提供的功能太过灵活。使得你不可能通过测试枚举出所有可能在线上执行的 sql。例如你可能用 sql builder 写出下面这样的代码:
|
无论是ORM还是sql builder都有一个致命的缺点,就是没有办法进行系统上线的事前sql审核。虽然很多ORM和sql builder也提供了运行期打印sql的功能,但只在查询的时候才能进行输出。而sql builder和ORM本身提供的功能太过灵活。使得你不可能通过测试枚举出所有可能在线上执行的sql。例如你可能用sql builder写出下面这样的代码:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
where := map[string]interface{} {
|
where := map[string]interface{} {
|
||||||
@ -197,15 +195,15 @@ if order_id != 0 {
|
|||||||
res, err := historyModel.GetList(where, limit, orderBy)
|
res, err := historyModel.GetList(where, limit, orderBy)
|
||||||
```
|
```
|
||||||
|
|
||||||
你的系统里有类似上述样例的大量 if 的话,就难以通过 test case 来覆盖到所有可能的 sql 组合了。
|
你的系统里有类似上述样例的大量`if`的话,就难以通过测试用例来覆盖到所有可能的sql组合了。
|
||||||
|
|
||||||
这样的系统只要发布,就已经孕育了初期的巨大风险。
|
这样的系统只要发布,就已经孕育了初期的巨大风险。
|
||||||
|
|
||||||
对于现在 7 * 24 服务的互联网公司来说,服务不可用是非常重大的问题。存储层的技术栈虽经历了多年的发展,在整个系统中依然是最为脆弱的一环。系统宕机对于 24 小时对外提供服务的公司来说,意味着直接的经济损失。个中风险不可忽视。
|
对于现在7乘24服务的互联网公司来说,服务不可用是非常重大的问题。存储层的技术栈虽经历了多年的发展,在整个系统中依然是最为脆弱的一环。系统宕机对于24小时对外提供服务的公司来说,意味着直接的经济损失。个中风险不可忽视。
|
||||||
|
|
||||||
从行业分工的角度来讲,现今的互联网公司都有专职的 dba。大多数 dba 并不一定有写代码的能力,去阅读 sql builder 的相关“拼 sql”代码多多少少还是会有一点障碍。从 dba 角度出发,还是希望能够有专门的事前 sql 审核机制,并能让其低成本地获取到系统的所有 sql 内容,而不是去阅读业务研发编写的 sql builder 的相关代码。
|
从行业分工的角度来讲,现今的互联网公司都有专职的 DBA。大多数DBA并不一定有写代码的能力,去阅读sql builder的相关“拼sql”代码多多少少还是会有一点障碍。从DBA角度出发,还是希望能够有专门的事前sql审核机制,并能让其低成本地获取到系统的所有sql内容,而不是去阅读业务研发编写的sql builder的相关代码。
|
||||||
|
|
||||||
所以现如今,大型的互联网公司核心线上业务都会在代码中把 sql 放在显眼的位置提供给 DBA review,以此来控制系统在数据层的风险。结合Go 举一个例子:
|
所以现如今,大型的互联网公司核心线上业务都会在代码中把sql放在显眼的位置提供给DBA评审,举一个例子:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
const (
|
const (
|
||||||
@ -239,6 +237,6 @@ func GetAllByProductIDAndCustomerID(ctx context.Context, productIDs []uint64, cu
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
像这样的代码,在上线之前把DAO层的变更集的 const 部分直接拿给 dba 来进行审核,就比较方便了。代码中的 sqlutil.Named 是类似于 sqlx 中的 Named 函数,同时支持 where 表达式中的比较操作符和 in。
|
像这样的代码,在上线之前把DAO层的变更集的const部分直接拿给DBA来进行审核,就比较方便了。代码中的 sqlutil.Named 是类似于 sqlx 中的 Named 函数,同时支持 where 表达式中的比较操作符和 in。
|
||||||
|
|
||||||
这里为了说明简便,函数写得稍微复杂一些,仔细思考一下的话查询的导出函数还可以进一步进行简化。请读者朋友们自行尝试。
|
这里为了说明简便,函数写得稍微复杂一些,仔细思考一下的话查询的导出函数还可以进一步进行简化。请读者朋友们自行尝试。
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
1. 控制器(Controller)- 负责转发请求,对请求进行处理。
|
1. 控制器(Controller)- 负责转发请求,对请求进行处理。
|
||||||
2. 视图(View) - 界面设计人员进行图形界面设计。
|
2. 视图(View) - 界面设计人员进行图形界面设计。
|
||||||
3. 模型(Model) - 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)。
|
3. 模型(Model) - 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)。
|
||||||
|
|
||||||
随着时代的发展,前端也变成了越来越复杂的工程,为了更好地工程化,现在更为流行的一般是前后分离的架构。可以认为前后分离是把V层从MVC中抽离单独成为项目。这样一个后端项目一般就只剩下 M和C层了。前后端之间通过ajax来交互,有时候要解决跨域的问题,但也已经有了较为成熟的方案。*图 5-13* 是一个前后分离的系统的简易交互图。
|
随着时代的发展,前端也变成了越来越复杂的工程,为了更好地工程化,现在更为流行的一般是前后分离的架构。可以认为前后分离是把V层从MVC中抽离单独成为项目。这样一个后端项目一般就只剩下 M和C层了。前后端之间通过ajax来交互,有时候要解决跨域的问题,但也已经有了较为成熟的方案。*图 5-13* 是一个前后分离的系统的简易交互图。
|
||||||
|
|
||||||
@ -17,7 +17,7 @@
|
|||||||
这种理解显然是有问题的,业务流程也算是一种“模型”,是对真实世界用户行为或者既有流程的一种建模,并非只有按格式组织的数据才能叫模型。不过按照MVC的创始人的想法,我们如果把和数据打交道的代码还有业务流程全部塞进MVC里的M层的话,这个M层又会显得有些过于臃肿。对于复杂的项目,一个C和一个M层显然是不够用的,现在比较流行的纯后端API模块一般采用下述划分方法:
|
这种理解显然是有问题的,业务流程也算是一种“模型”,是对真实世界用户行为或者既有流程的一种建模,并非只有按格式组织的数据才能叫模型。不过按照MVC的创始人的想法,我们如果把和数据打交道的代码还有业务流程全部塞进MVC里的M层的话,这个M层又会显得有些过于臃肿。对于复杂的项目,一个C和一个M层显然是不够用的,现在比较流行的纯后端API模块一般采用下述划分方法:
|
||||||
|
|
||||||
1. Controller,与上述类似,服务入口,负责处理路由,参数校验,请求转发。
|
1. Controller,与上述类似,服务入口,负责处理路由,参数校验,请求转发。
|
||||||
2. Logic/Service,逻辑(服务)层,一般是业务逻辑的入口,可以认为从这里开始,所有的请求参数一定是合法的。业务逻辑和业务流程也都在这一层中。常见的设计中会将该层称为 Business Rules。
|
2. Logic/Service,逻辑(服务)层,一般是业务逻辑的入口,可以认为从这里开始,所有的请求参数一定是合法的。业务逻辑和业务流程也都在这一层中。常见的设计中会将该层称为 Business Rules。
|
||||||
3. DAO/Repository,这一层主要负责和数据、存储打交道。将下层存储以更简单的函数、接口形式暴露给 Logic 层来使用。负责数据的持久化工作。
|
3. DAO/Repository,这一层主要负责和数据、存储打交道。将下层存储以更简单的函数、接口形式暴露给 Logic 层来使用。负责数据的持久化工作。
|
||||||
|
|
||||||
每一层都会做好自己的工作,然后用请求当前的上下文构造下一层工作所需要的结构体或其它类型参数,然后调用下一层的函数。在工作完成之后,再把处理结果一层层地传出到入口,如*图 5-14所示*。
|
每一层都会做好自己的工作,然后用请求当前的上下文构造下一层工作所需要的结构体或其它类型参数,然后调用下一层的函数。在工作完成之后,再把处理结果一层层地传出到入口,如*图 5-14所示*。
|
||||||
@ -106,7 +106,7 @@ type CreateOrderParams struct {
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
我们需要通过一个源struct来生成我们需要的http和thrift入口代码。再观察一下上面定义的三种struct,实际上我们只要能用一个struct生成thrift的IDL,以及http服务的“IDL(实际上就是带 json/form相关tag的struct定义)” 就可以了。这个初始的struct我们可以把struct上的http的tag和thrift的tag揉在一起:
|
我们需要通过一个源struct来生成我们需要的http和thrift入口代码。再观察一下上面定义的三种struct,实际上我们只要能用一个struct生成thrift的IDL,以及http服务的“IDL(实际上就是带 json或form相关tag的struct定义)” 就可以了。这个初始的struct我们可以把struct上的http的tag和thrift的tag揉在一起:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type FeatureSetParams struct {
|
type FeatureSetParams struct {
|
||||||
@ -124,7 +124,7 @@ type FeatureSetParams struct {
|
|||||||
|
|
||||||
*图 5-16 通过Go代码定义struct生成项目入口*
|
*图 5-16 通过Go代码定义struct生成项目入口*
|
||||||
|
|
||||||
至于用什么手段来生成,你可以通过Go语言内置的Parser读取文本文件中的Go源代码,然后根据AST来生成目标代码,也可以简单地把这个源struct和generator的代码放在一起编译,让struct作为generator的输入参数(这样会更简单一些),都是可以的。
|
至于用什么手段来生成,你可以通过Go语言内置的Parser读取文本文件中的Go源代码,然后根据AST来生成目标代码,也可以简单地把这个源struct和generator的代码放在一起编译,让struct作为generator的输入参数(这样会更简单一些),都是可以的。
|
||||||
|
|
||||||
当然这种思路并不是唯一选择,我们还可以通过解析thrift的IDL,生成一套http接口的struct。如果你选择这么做,那整个流程就变成了*图 5-17*所示。
|
当然这种思路并不是唯一选择,我们还可以通过解析thrift的IDL,生成一套http接口的struct。如果你选择这么做,那整个流程就变成了*图 5-17*所示。
|
||||||
|
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
在Web项目中经常会遇到外部依赖环境的变化,比如:
|
在Web项目中经常会遇到外部依赖环境的变化,比如:
|
||||||
|
|
||||||
1. 公司的老存储系统年久失修,现在已经没有人维护了,新的系统上线也没有考虑平滑迁移,但最后通牒已下,要求N天之内迁移完毕。
|
1. 公司的老存储系统年久失修,现在已经没有人维护了,新的系统上线也没有考虑平滑迁移,但最后通牒已下,要求N天之内迁移完毕。
|
||||||
2. 平台部门的老用户系统年久失修(怎么都是年久失修,摔!),现在已经没有人维护了,真是悲伤的故事。新系统上线没有考虑兼容老接口,但最后通牒已下,要求 N 个月之内迁移完毕。
|
2. 平台部门的老用户系统年久失修,现在已经没有人维护了,真是悲伤的故事。新系统上线没有考虑兼容老接口,但最后通牒已下,要求N个月之内迁移完毕。
|
||||||
3. 公司的老消息队列人走茶凉,年久失修(汗),新来的技术精英们没有考虑向前兼容,但最后通牒已下,要求半年之内迁移完毕。
|
3. 公司的老消息队列人走茶凉,年久失修,新来的技术精英们没有考虑向前兼容,但最后通牒已下,要求半年之内迁移完毕。
|
||||||
|
|
||||||
嗯,所以你看到了,我们的外部依赖总是为了自己爽而不断地做升级,且不想做向前兼容,然后来给我们下最后通牒。如果我们的部门工作饱和,领导强势,那么有时候也可以倒逼依赖方来做兼容。但世事不一定如人愿,即使我们的领导强势,读者朋友的领导也还是可能认怂的。
|
嗯,所以你看到了,我们的外部依赖总是为了自己爽而不断地做升级,且不想做向前兼容,然后来给我们下最后通牒。如果我们的部门工作饱和,领导强势,那么有时候也可以倒逼依赖方来做兼容。但世事不一定如人愿,即使我们的领导强势,读者朋友的领导也还是可能认怂的。
|
||||||
|
|
||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
互联网公司只要可以活过三年,工程方面面临的首要问题就是代码膨胀。系统的代码膨胀之后,可以将系统中与业务本身流程无关的部分做拆解和异步化。什么算是业务无关呢,比如一些统计、反作弊、营销发券、价格计算、用户状态更新等等需求。这些需求往往依赖于主流程的数据,但又只是挂在主流程上的旁支,自成体系。
|
互联网公司只要可以活过三年,工程方面面临的首要问题就是代码膨胀。系统的代码膨胀之后,可以将系统中与业务本身流程无关的部分做拆解和异步化。什么算是业务无关呢,比如一些统计、反作弊、营销发券、价格计算、用户状态更新等等需求。这些需求往往依赖于主流程的数据,但又只是挂在主流程上的旁支,自成体系。
|
||||||
|
|
||||||
这时候我们就可以把这些旁支拆解出去,作为独立的系统来部署、开发以及维护。这些旁支流程的时延如若非常敏感,比如用户在界面上点了按钮,需要立刻返回(价格计算、支付),那么需要与主流程系统进行RPC通信,并且在通信失败时,要将结果直接返回给用户。如果时延不敏感,比如抽奖系统,结果稍后公布的这种,或者非实时的统计类系统,那么就没有必要在主流程里为每一套系统做一套RPC流程。我们只要将下游需要的数据打包成一条消息,传入消息队列,之后的事情与主流程一概无关(当然,与用户的后续交互流程还是要做的)。
|
这时候我们就可以把这些旁支拆解出去,作为独立的系统来部署、开发以及维护。这些旁支流程的时延如若非常敏感,比如用户在界面上点了按钮,需要立刻返回(价格计算、支付),那么需要与主流程系统进行RPC通信,并且在通信失败时,要将结果直接返回给用户。如果时延不敏感,比如抽奖系统,结果稍后公布的这种,或者非实时的统计类系统,那么就没有必要在主流程里为每一套系统做一套RPC流程。我们只要将下游需要的数据打包成一条消息,传入消息队列,之后的事情与主流程一概无关(当然,与用户的后续交互流程还是要做的)。
|
||||||
|
|
||||||
通过拆解和异步化虽然解决了一部分问题,但并不能解决所有问题。随着业务发展,单一职责的模块也会变得越来越复杂,这是必然的趋势。一件事情本身变的复杂的话,这时候拆解和异步化就不灵了。我们还是要对事情本身进行一定程度的封装抽象。
|
通过拆解和异步化虽然解决了一部分问题,但并不能解决所有问题。随着业务发展,单一职责的模块也会变得越来越复杂,这是必然的趋势。一件事情本身变的复杂的话,这时候拆解和异步化就不灵了。我们还是要对事情本身进行一定程度的封装抽象。
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ func BusinessProcess(ctx context.Context, params Params) (resp, error){
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
不管是多么复杂的业务,系统内的逻辑都是可以分解为step1 -> step2 -> step3 ...这样的流程的。
|
不管是多么复杂的业务,系统内的逻辑都是可以分解为`step1 -> step2 -> step3 ...`这样的流程的。
|
||||||
|
|
||||||
每一个步骤内部也会有复杂的流程,比如:
|
每一个步骤内部也会有复杂的流程,比如:
|
||||||
|
|
||||||
@ -52,13 +52,13 @@ func CreateOrder() {
|
|||||||
|
|
||||||
在阅读业务流程代码时,我们只要阅读其函数名就能知晓在该流程中完成了哪些操作,如果需要修改细节,那么就继续深入到每一个业务步骤去看具体的流程。写得稀烂的业务流程代码则会将所有过程都堆积在少数的几个函数中,从而导致几百甚至上千行的函数。这种意大利面条式的代码阅读和维护都会非常痛苦。在开发的过程中,一旦有条件应该立即进行类似上面这种方式的简单封装。
|
在阅读业务流程代码时,我们只要阅读其函数名就能知晓在该流程中完成了哪些操作,如果需要修改细节,那么就继续深入到每一个业务步骤去看具体的流程。写得稀烂的业务流程代码则会将所有过程都堆积在少数的几个函数中,从而导致几百甚至上千行的函数。这种意大利面条式的代码阅读和维护都会非常痛苦。在开发的过程中,一旦有条件应该立即进行类似上面这种方式的简单封装。
|
||||||
|
|
||||||
## 5.8.3 使用 interface 来做抽象
|
## 5.8.3 使用接口来做抽象
|
||||||
|
|
||||||
业务发展的早期,是不适宜引入interface的,很多时候业务流程变化很大,过早引入interface会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。
|
业务发展的早期,是不适宜引入接口(interface)的,很多时候业务流程变化很大,过早引入接口会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。
|
||||||
|
|
||||||
当业务发展到一定阶段,主流程稳定之后,就可以适当地使用interface来进行抽象了。这里的稳定,是指主流程的大部分业务步骤已经确定,即使再进行修改,也不会进行大规模的变动,而只是小修小补,或者只是增加或删除少量业务步骤。
|
当业务发展到一定阶段,主流程稳定之后,就可以适当地使用接口来进行抽象了。这里的稳定,是指主流程的大部分业务步骤已经确定,即使再进行修改,也不会进行大规模的变动,而只是小修小补,或者只是增加或删除少量业务步骤。
|
||||||
|
|
||||||
如果我们在开发过程中,已经对业务步骤进行了良好的封装,这时候进行interface抽象化就会变的非常容易,伪代码:
|
如果我们在开发过程中,已经对业务步骤进行了良好的封装,这时候进行接口抽象化就会变的非常容易,伪代码:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// OrderCreator 创建订单流程
|
// OrderCreator 创建订单流程
|
||||||
@ -72,9 +72,9 @@ type OrderCreator interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
我们只要把之前写过的步骤函数签名都提到一个interface中,就可以完成抽象了。
|
我们只要把之前写过的步骤函数签名都提到一个接口中,就可以完成抽象了。
|
||||||
|
|
||||||
在进行抽象之前,我们应该想明白的一点是,引入interface对我们的系统本身是否有意义,这是要按照场景去进行分析的。假如我们的系统只服务一条产品线,并且内部的代码只是针对很具体的场景进行定制化开发,那么实际上引入接口是不会带来任何收益的。至于说是否方便测试,这一点我们会在之后的章节来讲。
|
在进行抽象之前,我们应该想明白的一点是,引入接口对我们的系统本身是否有意义,这是要按照场景去进行分析的。假如我们的系统只服务一条产品线,并且内部的代码只是针对很具体的场景进行定制化开发,那么实际上引入接口是不会带来任何收益的。至于说是否方便测试,这一点我们会在之后的章节来讲。
|
||||||
|
|
||||||
如果我们正在做的是平台系统,需要由平台来定义统一的业务流程和业务规范,那么基于接口的抽象就是有意义的。举个例子:
|
如果我们正在做的是平台系统,需要由平台来定义统一的业务流程和业务规范,那么基于接口的抽象就是有意义的。举个例子:
|
||||||
|
|
||||||
@ -82,9 +82,9 @@ type OrderCreator interface {
|
|||||||
|
|
||||||
*图 5-19 实现公有的接口*
|
*图 5-19 实现公有的接口*
|
||||||
|
|
||||||
平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的interface,然后要求接入方的业务必须将这些interface都实现。如果interface中有其不需要的步骤,那么只要返回nil,或者忽略就好。
|
平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的接口,然后要求接入方的业务必须将这些接口都实现。如果接口中有其不需要的步骤,那么只要返回`nil`,或者忽略就好。
|
||||||
|
|
||||||
在业务进行迭代时,平台的代码是不用修改的,这样我们便把这些接入业务当成了平台代码的插件(plugin)引入进来了。如果没有interface的话,我们会怎么做?
|
在业务进行迭代时,平台的代码是不用修改的,这样我们便把这些接入业务当成了平台代码的插件(plugin)引入进来了。如果没有接口的话,我们会怎么做?
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
@ -120,7 +120,7 @@ switch ...
|
|||||||
switch ...
|
switch ...
|
||||||
```
|
```
|
||||||
|
|
||||||
没错,就是无穷无尽的switch,和没完没了的垃圾代码。引入了interface之后,我们的switch只需要在业务入口做一次。
|
没错,就是无穷无尽的`switch`,和没完没了的垃圾代码。引入了接口之后,我们的`switch`只需要在业务入口做一次。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type BusinessInstance interface {
|
type BusinessInstance interface {
|
||||||
@ -156,9 +156,9 @@ func BusinessProcess(bi BusinessInstance) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
面向interface编程,不用关心具体的实现。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。
|
面向接口编程,不用关心具体的实现。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。
|
||||||
|
|
||||||
## 5.8.4 interface的优缺点
|
## 5.8.4 接口的优缺点
|
||||||
|
|
||||||
Go被人称道的最多的地方是其接口设计的正交性,模块之间不需要知晓相互的存在,A模块定义接口,B模块实现这个接口就可以。如果接口中没有A模块中定义的数据类型,那B模块中甚至都不用`import A`。比如标准库中的`io.Writer`:
|
Go被人称道的最多的地方是其接口设计的正交性,模块之间不需要知晓相互的存在,A模块定义接口,B模块实现这个接口就可以。如果接口中没有A模块中定义的数据类型,那B模块中甚至都不用`import A`。比如标准库中的`io.Writer`:
|
||||||
|
|
||||||
@ -178,7 +178,7 @@ func (m MyType) Write(p []byte) (n int, err error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
那么我们就可以把我们自己的MyType传给任何使用`io.Writer`作为参数的函数来使用了,比如:
|
那么我们就可以把我们自己的`MyType`传给任何使用`io.Writer`作为参数的函数来使用了,比如:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package log
|
package log
|
||||||
@ -200,11 +200,11 @@ func init() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
在MyType定义的地方,不需要`import "io"`就可以直接实现 `io.Writer` interface,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立import产生的依赖关系。因此很多人认为Go的这种正交是一种很优秀的设计。
|
在`MyType`定义的地方,不需要`import "io"`就可以直接实现 `io.Writer`接口,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立import产生的依赖关系。因此很多人认为Go的这种正交是一种很优秀的设计。
|
||||||
|
|
||||||
但这种“正交”性也会给我们带来一些麻烦。当我们接手了一个几十万行的系统时,如果看到定义了很多interface,例如订单流程的interface,我们希望能直接找到这些interface都被哪些对象实现了。但直到现在,这个简单的需求也就只有goland实现了,并且体验尚可。Visual Studio Code则需要对项目进行全局扫描,来看到底有哪些struct实现了该interface的全部函数。那些显式实现interface的语言,对于IDE的interface查找来说就友好多了。另一方面,我们看到一个struct,也希望能够立刻知道这个struct实现了哪些interface,但也有着和前面提到的相同的问题。
|
但这种“正交”性也会给我们带来一些麻烦。当我们接手了一个几十万行的系统时,如果看到定义了很多接口,例如订单流程的接口,我们希望能直接找到这些接口都被哪些对象实现了。但直到现在,这个简单的需求也就只有Goland实现了,并且体验尚可。Visual Studio Code则需要对项目进行全局扫描,来看到底有哪些结构体实现了该接口的全部函数。那些显式实现接口的语言,对于IDE的接口查找来说就友好多了。另一方面,我们看到一个结构体,也希望能够立刻知道这个结构体实现了哪些接口,但也有着和前面提到的相同的问题。
|
||||||
|
|
||||||
虽有不便,interface带给我们的好处也是不言而喻的:一是依赖反转,这是interface在大多数语言中对软件项目所能产生的影响,在Go的正交interface的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为interface强行来使用的话:
|
虽有不便,接口带给我们的好处也是不言而喻的:一是依赖反转,这是接口在大多数语言中对软件项目所能产生的影响,在Go的正交接口的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为接口强行来使用的话:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@ -240,7 +240,7 @@ func main() {
|
|||||||
|
|
||||||
## 5.8.5 表驱动开发
|
## 5.8.5 表驱动开发
|
||||||
|
|
||||||
熟悉开源lint工具的同学应该见到过圈复杂度的说法,在函数中如果有if和switch的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有switch,还是想要干掉这个switch,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例:
|
熟悉开源lint工具的同学应该见到过圈复杂度的说法,在函数中如果有`if`和`switch`的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有`switch`,还是想要干掉这个`switch`,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func entry() {
|
func entry() {
|
||||||
@ -269,6 +269,6 @@ func entry() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
表驱动的设计方式,很多设计模式相关的书籍并没有把它作为一种设计模式来讲,但我认为这依然是一种非常重要的帮助我们来简化代码的手段。在日常的开发工作中可以多多思考,哪些不必要的switch case可以用一个字典和一行代码就可以轻松搞定。
|
表驱动的设计方式,很多设计模式相关的书籍并没有把它作为一种设计模式来讲,但我认为这依然是一种非常重要的帮助我们来简化代码的手段。在日常的开发工作中可以多多思考,哪些不必要的`switch case`可以用一个字典和一行代码就可以轻松搞定。
|
||||||
|
|
||||||
当然,表驱动也不是缺点,因为需要对输入key计算哈希,在性能敏感的场合,需要多加斟酌。
|
当然,表驱动也不是缺点,因为需要对输入`key`计算哈希,在性能敏感的场合,需要多加斟酌。
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# 5.9 灰度发布和 A/B test
|
# 5.9 灰度发布和 A/B test
|
||||||
|
|
||||||
中型的互联网公司往往有着以百万计的用户,而大型互联网公司的系统则可能要服务千万级甚至亿级的用户需求。大型系统的请求流入往往是源源不断的,任何风吹草动,都一定会有最终用户感受得到。例如你的系统在上线途中会拒绝一些上游过来的请求,而这时候依赖你的系统没有做任何容错,那么这个错误就会一直向上抛出,直到触达最终用户。形成一次对用户切切实实的伤害。这种伤害可能是在用户的 app 上弹出一个让用户摸不着头脑的诡异字符串,用户只要刷新一下页面就可以忘记这件事。但也可能会让正在心急如焚地和几万竞争对手同时抢夺秒杀商品的用户,因为代码上的小问题,丧失掉了先发优势,与自己蹲了几个月的心仪产品失之交臂。对用户的伤害有多大,取决于你的系统对于你的用户来说有多重要。
|
中型的互联网公司往往有着以百万计的用户,而大型互联网公司的系统则可能要服务千万级甚至亿级的用户需求。大型系统的请求流入往往是源源不断的,任何风吹草动,都一定会有最终用户感受得到。例如你的系统在上线途中会拒绝一些上游过来的请求,而这时候依赖你的系统没有做任何容错,那么这个错误就会一直向上抛出,直到触达最终用户。形成一次对用户切切实实的伤害。这种伤害可能是在用户的APP上弹出一个让用户摸不着头脑的诡异字符串,用户只要刷新一下页面就可以忘记这件事。但也可能会让正在心急如焚地和几万竞争对手同时抢夺秒杀商品的用户,因为代码上的小问题,丧失掉了先发优势,与自己蹲了几个月的心仪产品失之交臂。对用户的伤害有多大,取决于你的系统对于你的用户来说有多重要。
|
||||||
|
|
||||||
不管怎么说,在大型系统中容错是重要的,能够让系统按百分比,分批次到达最终用户,也是很重要的。虽然当今的互联网公司系统,名义上会说自己上线前都经过了充分慎重严格的测试,但就算它们真得做到了,代码的bug总是在所难免的。即使代码没有bug,分布式服务之间的协作也是可能出现“逻辑”上的非技术问题的。
|
不管怎么说,在大型系统中容错是重要的,能够让系统按百分比,分批次到达最终用户,也是很重要的。虽然当今的互联网公司系统,名义上会说自己上线前都经过了充分慎重严格的测试,但就算它们真得做到了,代码的bug总是在所难免的。即使代码没有bug,分布式服务之间的协作也是可能出现“逻辑”上的非技术问题的。
|
||||||
|
|
||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
## 5.9.1 通过分批次部署实现灰度发布
|
## 5.9.1 通过分批次部署实现灰度发布
|
||||||
|
|
||||||
假如服务部署在15个实例(可能是物理机,也可能是容器)上,我们把这15个实例分为四组,按照先后顺序,分别有1-2-4-8台机器,保证每次扩展时大概都是二倍的关系。
|
假如服务部署在15个实例(可能是物理机,也可能是容器)上,我们把这15个实例分为四组,按照先后顺序,分别有1-2-4-8台机器,保证每次扩展时大概都是二倍的关系。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -67,7 +67,7 @@ func isTrue() bool {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
其可以按照用户指定的概率返回 true/false,当然,true 的概率 + false 的概率 = 100%。这个函数不需要任何输入。
|
其可以按照用户指定的概率返回`true`或者`false`,当然,`true`的概率加`false`的概率应该是100%。这个函数不需要任何输入。
|
||||||
|
|
||||||
按百分比发布,是指实现下面这样的函数:
|
按百分比发布,是指实现下面这样的函数:
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ func isTrue(phone string) bool {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
这种情况可以按照指定的百分比,返回对应的true和false,和上面的单纯按照概率的区别是这里我们需要调用方提供给我们一个输入参数,我们以该输入参数作为源来计算哈希,并以哈希后的结果来求模,并返回结果。这样可以保证同一个用户的返回结果多次调用是一致的,在下面这种场景下,必须使用这种结果可预期的灰度算法,见*图 5-21*所示。
|
这种情况可以按照指定的百分比,返回对应的`true`和`false`,和上面的单纯按照概率的区别是这里我们需要调用方提供给我们一个输入参数,我们以该输入参数作为源来计算哈希,并以哈希后的结果来求模,并返回结果。这样可以保证同一个用户的返回结果多次调用是一致的,在下面这种场景下,必须使用这种结果可预期的灰度算法,见*图 5-21*所示。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -97,7 +97,7 @@ func isTrue(phone string) bool {
|
|||||||
|
|
||||||
## 5.9.3 如何实现一套灰度发布系统
|
## 5.9.3 如何实现一套灰度发布系统
|
||||||
|
|
||||||
前面也提到了,提供给用户的接口大概可以分为和业务绑定的简单灰度判断逻辑。以及输入稍微复杂一些的哈希灰度。我们来分别看看怎么实现这样的灰度系统(函数)。
|
前面也提到了,提供给用户的接口大概可以分为和业务绑定的简单灰度判断逻辑。以及输入稍微复杂一些的哈希灰度。我们来分别看看怎么实现这样的灰度系统(函数)。
|
||||||
|
|
||||||
### 5.9.3.1 业务相关的简单灰度
|
### 5.9.3.1 业务相关的简单灰度
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
有时我们需要能够生成类似MySQL自增ID这样不断增大,同时又不会重复的id。以支持业务中的高并发场景。比较典型的,电商促销时,短时间内会有大量的订单涌入到系统,比如每秒10w+。明星出轨时,会有大量热情的粉丝发微博以表心意,同样会在短时间内产生大量的消息。
|
有时我们需要能够生成类似MySQL自增ID这样不断增大,同时又不会重复的id。以支持业务中的高并发场景。比较典型的,电商促销时,短时间内会有大量的订单涌入到系统,比如每秒10w+。明星出轨时,会有大量热情的粉丝发微博以表心意,同样会在短时间内产生大量的消息。
|
||||||
|
|
||||||
在插入数据库之前,我们需要给这些消息/订单先打上一个ID,然后再插入到我们的数据库。对这个id的要求是希望其中能带有一些时间信息,这样即使我们后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序。
|
在插入数据库之前,我们需要给这些消息、订单先打上一个ID,然后再插入到我们的数据库。对这个id的要求是希望其中能带有一些时间信息,这样即使我们后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序。
|
||||||
|
|
||||||
Twitter的snowflake算法是这种场景下的一个典型解法。先来看看snowflake是怎么一回事,见*图 6-1*:
|
Twitter的snowflake算法是这种场景下的一个典型解法。先来看看snowflake是怎么一回事,见*图 6-1*:
|
||||||
|
|
||||||
@ -10,19 +10,19 @@ Twitter的snowflake算法是这种场景下的一个典型解法。先来看看s
|
|||||||
|
|
||||||
*图 6-1 snowflake中的比特位分布*
|
*图 6-1 snowflake中的比特位分布*
|
||||||
|
|
||||||
首先确定我们的数值是64 位,int64类型,被划分为四部分,不含开头的第一个bit,因为这个bit是符号位。用41位来表示收到请求时的时间戳,单位为毫秒,然后五位来表示数据中心的id,然后再五位来表示机器的实例id,最后是12位的循环自增id(到达 1111 1111 1111 后会归 0)。
|
首先确定我们的数值是64位,int64类型,被划分为四部分,不含开头的第一个bit,因为这个bit是符号位。用41位来表示收到请求时的时间戳,单位为毫秒,然后五位来表示数据中心的id,然后再五位来表示机器的实例id,最后是12位的循环自增id(到达1111,1111,1111后会归0)。
|
||||||
|
|
||||||
这样的机制可以支持我们在同一台机器上,同一毫秒内产生`2 ^ 12 = 4096`条消息。一秒共409.6万条消息。从值域上来讲完全够用了。
|
这样的机制可以支持我们在同一台机器上,同一毫秒内产生`2 ^ 12 = 4096`条消息。一秒共409.6万条消息。从值域上来讲完全够用了。
|
||||||
|
|
||||||
数据中心 + 实例id共有10位,可以支持我们每数据中心部署32台机器,所有数据中心共1024台实例。
|
数据中心加上实例id共有10位,可以支持我们每数据中心部署32台机器,所有数据中心共1024台实例。
|
||||||
|
|
||||||
表示timestamp的41位,可以支持我们使用69年。当然,我们的时间毫秒计数不会真的从1970年开始记,那样我们的系统跑到`2039/9/7 23:47:35`就不能用了,所以这里的timestamp实际上只是相对于某个时间的增量,比如我们的系统上线是2018-08-01,那么我们可以把这个timestamp当作是从`2018-08-01 00:00:00.000`的偏移量。
|
表示`timestamp`的41位,可以支持我们使用69年。当然,我们的时间毫秒计数不会真的从1970年开始记,那样我们的系统跑到`2039/9/7 23:47:35`就不能用了,所以这里的`timestamp`实际上只是相对于某个时间的增量,比如我们的系统上线是2018-08-01,那么我们可以把这个timestamp当作是从`2018-08-01 00:00:00.000`的偏移量。
|
||||||
|
|
||||||
## 6.1.1 worker_id分配
|
## 6.1.1 worker_id分配
|
||||||
|
|
||||||
timestamp,datacenter_id,worker_id和sequence_id这四个字段中,timestamp和 sequence_id是由程序在运行期生成的。但datacenter_id和worker_id需要我们在部署阶段就能够获取得到,并且一旦程序启动之后,就是不可更改的了(想想,如果可以随意更改,可能被不慎修改,造成最终生成的id有冲突)。
|
`timestamp`,`datacenter_id`,`worker_id`和`sequence_id`这四个字段中,`timestamp`和`sequence_id`是由程序在运行期生成的。但`datacenter_id`和`worker_id`需要我们在部署阶段就能够获取得到,并且一旦程序启动之后,就是不可更改的了(想想,如果可以随意更改,可能被不慎修改,造成最终生成的id有冲突)。
|
||||||
|
|
||||||
一般不同数据中心的机器,会提供对应的获取数据中心id的API,所以datacenter_id我们可以在部署阶段轻松地获取到。而worker_id是我们逻辑上给机器分配的一个id,这个要怎么办呢?比较简单的想法是由能够提供这种自增id功能的工具来支持,比如MySQL:
|
一般不同数据中心的机器,会提供对应的获取数据中心id的API,所以`datacenter_id`我们可以在部署阶段轻松地获取到。而worker_id是我们逻辑上给机器分配的一个id,这个要怎么办呢?比较简单的想法是由能够提供这种自增id功能的工具来支持,比如MySQL:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
mysql> insert into a (ip) values("10.1.2.101");
|
mysql> insert into a (ip) values("10.1.2.101");
|
||||||
@ -37,11 +37,11 @@ mysql> select last_insert_id();
|
|||||||
1 row in set (0.00 sec)
|
1 row in set (0.00 sec)
|
||||||
```
|
```
|
||||||
|
|
||||||
从MySQL中获取到worker_id之后,就把这个worker_id直接持久化到本地,以避免每次上线时都需要获取新的worker_id。让单实例的worker_id可以始终保持不变。
|
从MySQL中获取到`worker_id`之后,就把这个`worker_id`直接持久化到本地,以避免每次上线时都需要获取新的`worker_id`。让单实例的`worker_id`可以始终保持不变。
|
||||||
|
|
||||||
当然,使用MySQL相当于给我们简单的id生成服务增加了一个外部依赖。依赖越多,我们的服务的可运维性就越差。
|
当然,使用MySQL相当于给我们简单的id生成服务增加了一个外部依赖。依赖越多,我们的服务的可运维性就越差。
|
||||||
|
|
||||||
考虑到集群中即使有单个id生成服务的实例挂了,也就是损失一段时间的一部分id,所以我们也可以更简单暴力一些,把worker_id直接写在worker的配置中,上线时,由部署脚本完成worker_id字段替换。
|
考虑到集群中即使有单个id生成服务的实例挂了,也就是损失一段时间的一部分id,所以我们也可以更简单暴力一些,把`worker_id`直接写在worker的配置中,上线时,由部署脚本完成`worker_id`字段替换。
|
||||||
|
|
||||||
## 6.1.2 开源实例
|
## 6.1.2 开源实例
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ func main() {
|
|||||||
StepBits uint8 = 12
|
StepBits uint8 = 12
|
||||||
```
|
```
|
||||||
|
|
||||||
Epoch 就是本节开头讲的起始时间,NodeBits指的是机器编号的位长,StepBits指的是自增序列的位长。
|
`Epoch`就是本节开头讲的起始时间,`NodeBits`指的是机器编号的位长,`StepBits`指的是自增序列的位长。
|
||||||
|
|
||||||
### 6.1.2.2 sonyflake
|
### 6.1.2.2 sonyflake
|
||||||
|
|
||||||
@ -111,7 +111,7 @@ sonyflake是Sony公司的一个开源项目,基本思路和snowflake差不多
|
|||||||
|
|
||||||
*图 6-3 sonyflake*
|
*图 6-3 sonyflake*
|
||||||
|
|
||||||
这里的时间只用了39个bit,但时间的单位变成了10ms,所以理论上比41位表示的时间还要久(174 years)。
|
这里的时间只用了39个bit,但时间的单位变成了10ms,所以理论上比41位表示的时间还要久(174年)。
|
||||||
|
|
||||||
`Sequence ID`和之前的定义一致,`Machine ID`其实就是节点id。`sonyflake`与众不同的地方在于其在启动阶段的配置参数:
|
`Sequence ID`和之前的定义一致,`Machine ID`其实就是节点id。`sonyflake`与众不同的地方在于其在启动阶段的配置参数:
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ sonyflake是Sony公司的一个开源项目,基本思路和snowflake差不多
|
|||||||
func NewSonyflake(st Settings) *Sonyflake
|
func NewSonyflake(st Settings) *Sonyflake
|
||||||
```
|
```
|
||||||
|
|
||||||
Settings 数据结构如下:
|
`Settings`数据结构如下:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
@ -129,11 +129,11 @@ type Settings struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
StartTime选项和我们之前的Epoch差不多,如果不设置的话,默认是从`2014-09-01 00:00:00 +0000 UTC`开始。
|
`StartTime`选项和我们之前的`Epoch`差不多,如果不设置的话,默认是从`2014-09-01 00:00:00 +0000 UTC`开始。
|
||||||
|
|
||||||
MachineID可以由用户自定义的函数,如果用户不定义的话,会默认将本机IP的低16位作为`machine id`。
|
`MachineID`可以由用户自定义的函数,如果用户不定义的话,会默认将本机IP的低16位作为`machine id`。
|
||||||
|
|
||||||
CheckMachineID是由用户提供的检查MachineID是否冲突的函数。这里的设计还是比较巧妙的,如果有另外的中心化存储并支持检查重复的存储,那我们就可以按照自己的想法随意定制这个检查MachineID是否冲突的逻辑。如果公司有现成的Redis集群,那么我们可以很轻松地用Redis的set来检查冲突。
|
`CheckMachineID`是由用户提供的检查`MachineID`是否冲突的函数。这里的设计还是比较巧妙的,如果有另外的中心化存储并支持检查重复的存储,那我们就可以按照自己的想法随意定制这个检查`MachineID`是否冲突的逻辑。如果公司有现成的Redis集群,那么我们可以很轻松地用Redis的集合类型来检查冲突。
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
redis 127.0.0.1:6379> SADD base64_encoding_of_last16bits MzI0Mgo=
|
redis 127.0.0.1:6379> SADD base64_encoding_of_last16bits MzI0Mgo=
|
||||||
|
@ -40,7 +40,7 @@ func main() {
|
|||||||
|
|
||||||
## 6.2.1 进程内加锁
|
## 6.2.1 进程内加锁
|
||||||
|
|
||||||
想要得到正确的结果的话,要把对counter的操作代码部分加上锁:
|
想要得到正确的结果的话,要把对计数器(counter)的操作代码部分加上锁:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ... 省略之前部分
|
// ... 省略之前部分
|
||||||
@ -220,9 +220,9 @@ current counter is 2028
|
|||||||
unlock success!
|
unlock success!
|
||||||
```
|
```
|
||||||
|
|
||||||
通过代码和执行结果可以看到,我们远程调用setnx实际上和单机的trylock非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。
|
通过代码和执行结果可以看到,我们远程调用`setnx`实际上和单机的trylock非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。
|
||||||
|
|
||||||
setnx很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。
|
`setnx`很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。
|
||||||
|
|
||||||
所以,我们需要依赖于这些请求到达Redis节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。
|
所以,我们需要依赖于这些请求到达Redis节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。
|
||||||
|
|
||||||
@ -257,11 +257,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
基于ZooKeeper的锁与基于Redis的锁的不同之处在于Lock成功之前会一直阻塞,这与我们单机场景中的mutex.Lock很相似。
|
基于ZooKeeper的锁与基于Redis的锁的不同之处在于Lock成功之前会一直阻塞,这与我们单机场景中的`mutex.Lock`很相似。
|
||||||
|
|
||||||
其原理也是基于临时sequence节点和watch API,例如我们这里使用的是`/lock`节点。Lock会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有watch该节点的程序。这时候程序会检查当前节点下最小的子节点的id是否与自己的一致。如果一致,说明加锁成功了。
|
其原理也是基于临时sequence节点和watch API,例如我们这里使用的是`/lock`节点。Lock会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有watch该节点的程序。这时候程序会检查当前节点下最小的子节点的id是否与自己的一致。如果一致,说明加锁成功了。
|
||||||
|
|
||||||
这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照Google的chubby论文里的阐述,基于强一致协议的锁适用于`粗粒度`的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。
|
这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照Google的Chubby论文里的阐述,基于强一致协议的锁适用于`粗粒度`的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。
|
||||||
|
|
||||||
## 6.2.5 基于 etcd
|
## 6.2.5 基于 etcd
|
||||||
|
|
||||||
@ -303,7 +303,7 @@ etcd中没有像ZooKeeper那样的sequence节点。所以其锁实现和基于Zo
|
|||||||
1. 先检查`/lock`路径下是否有值,如果有值,说明锁已经被别人抢了
|
1. 先检查`/lock`路径下是否有值,如果有值,说明锁已经被别人抢了
|
||||||
2. 如果没有值,那么写入自己的值。写入成功返回,说明加锁成功。写入时如果节点被其它节点写入过了,那么会导致加锁失败,这时候到 3
|
2. 如果没有值,那么写入自己的值。写入成功返回,说明加锁成功。写入时如果节点被其它节点写入过了,那么会导致加锁失败,这时候到 3
|
||||||
3. watch `/lock`下的事件,此时陷入阻塞
|
3. watch `/lock`下的事件,此时陷入阻塞
|
||||||
4. 当`/lock`路径下发生事件时,当前进程被唤醒。检查发生的事件是否是删除事件(说明锁被持有者主动 unlock),或者过期事件(说明锁过期失效)。如果是的话,那么回到 1,走抢锁流程。
|
4. 当`/lock`路径下发生事件时,当前进程被唤醒。检查发生的事件是否是删除事件(说明锁被持有者主动unlock),或者过期事件(说明锁过期失效)。如果是的话,那么回到 1,走抢锁流程。
|
||||||
|
|
||||||
## 6.2.6 Redlock
|
## 6.2.6 Redlock
|
||||||
|
|
||||||
@ -379,6 +379,6 @@ Redlock也是一种阻塞锁,单个节点操作对应的是`set nx px`命令
|
|||||||
|
|
||||||
如果要使用Redlock,那么要考虑你们公司Redis的集群方案,是否可以直接把对应的Redis的实例的ip+port暴露给开发人员。如果不可以,那也没法用。
|
如果要使用Redlock,那么要考虑你们公司Redis的集群方案,是否可以直接把对应的Redis的实例的ip+port暴露给开发人员。如果不可以,那也没法用。
|
||||||
|
|
||||||
对锁数据的可靠性要求极高的话,那只能使用etcd或者ZooKeeper这种通过一致性协议保证数据可靠性的锁方案。但可靠的背面往往都是较低的吞吐量和较高的延迟。需要根据业务的量级对其进行压力测试,以确保分布式锁所使用的etcd/ZooKeeper集群可以承受得住实际的业务请求压力。需要注意的是,etcd和Zookeeper集群是没有办法通过增加节点来提高其性能的。要对其进行横向扩展,只能增加搭建多个集群来支持更多的请求。这会进一步提高对运维和监控的要求。多个集群可能需要引入proxy,没有proxy那就需要业务去根据某个业务id来做分片。如果业务已经上线的情况下做扩展,还要考虑数据的动态迁移。这些都不是容易的事情。
|
对锁数据的可靠性要求极高的话,那只能使用etcd或者ZooKeeper这种通过一致性协议保证数据可靠性的锁方案。但可靠的背面往往都是较低的吞吐量和较高的延迟。需要根据业务的量级对其进行压力测试,以确保分布式锁所使用的etcd或ZooKeeper集群可以承受得住实际的业务请求压力。需要注意的是,etcd和Zookeeper集群是没有办法通过增加节点来提高其性能的。要对其进行横向扩展,只能增加搭建多个集群来支持更多的请求。这会进一步提高对运维和监控的要求。多个集群可能需要引入proxy,没有proxy那就需要业务去根据某个业务id来做分片。如果业务已经上线的情况下做扩展,还要考虑数据的动态迁移。这些都不是容易的事情。
|
||||||
|
|
||||||
在选择具体的方案时,还是需要多加思考,对风险早做预估。
|
在选择具体的方案时,还是需要多加思考,对风险早做预估。
|
||||||
|
@ -23,9 +23,9 @@ timer的实现在工业界已经是有解的问题了。常见的就是时间堆
|
|||||||
|
|
||||||
*图 6-4 二叉堆结构*
|
*图 6-4 二叉堆结构*
|
||||||
|
|
||||||
小顶堆的好处是什么呢?实际上对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。所以对于定时check来说,时间复杂度是O(1)的。
|
小顶堆的好处是什么呢?实际上对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。所以对于定时check来说,时间复杂度是`O(1)`的。
|
||||||
|
|
||||||
当我们发现堆顶的元素小于当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是O(LgN)。
|
当我们发现堆顶的元素小于当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是`O(LgN)`。
|
||||||
|
|
||||||
Go自身的timer就是用时间堆来实现的,不过并没有使用二叉堆,而是使用了扁平一些的四叉堆。在最近的版本中,还加了一些优化,我们先不说优化,先来看看四叉的小顶堆长什么样:
|
Go自身的timer就是用时间堆来实现的,不过并没有使用二叉堆,而是使用了扁平一些的四叉堆。在最近的版本中,还加了一些优化,我们先不说优化,先来看看四叉的小顶堆长什么样:
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ Go自身的timer就是用时间堆来实现的,不过并没有使用二叉堆
|
|||||||
|
|
||||||
有了基本的timer实现方案,如果我们开发的是单机系统,那么就可以撸起袖子开干了,不过本章我们讨论的是分布式,距离“分布式”还稍微有一些距离。
|
有了基本的timer实现方案,如果我们开发的是单机系统,那么就可以撸起袖子开干了,不过本章我们讨论的是分布式,距离“分布式”还稍微有一些距离。
|
||||||
|
|
||||||
我们还需要把这些“定时”或是“延时”(本质也是定时)任务分发出去。下面是一种思路:
|
我们还需要把这些“定时”或是“延时”(本质也是定时)任务分发出去。下面是一种思路:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ Elasticsearch是开源分布式搜索引擎的霸主,其依赖于Lucene实现
|
|||||||
|
|
||||||
对Elasticsearch中的数据进行查询时,本质就是求多个排好序的序列求交集。非数值类型字段涉及到分词问题,大多数内部使用场景下,我们可以直接使用默认的bi-gram分词。什么是bi-gram分词呢:
|
对Elasticsearch中的数据进行查询时,本质就是求多个排好序的序列求交集。非数值类型字段涉及到分词问题,大多数内部使用场景下,我们可以直接使用默认的bi-gram分词。什么是bi-gram分词呢:
|
||||||
|
|
||||||
即将所有Ti和T(i+1)组成一个词(在Elasticsearch中叫term),然后再编排其倒排列表,这样我们的倒排列表大概就是这样的:
|
即将所有`Ti`和`T(i+1)`组成一个词(在Elasticsearch中叫term),然后再编排其倒排列表,这样我们的倒排列表大概就是这样的:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -61,11 +61,11 @@ func equal() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
多个有序列表求交集的时间复杂度是:O(N * M),N为给定列表当中元素数最小的集合,M为给定列表的个数。
|
多个有序列表求交集的时间复杂度是:`O(N * M)`,N为给定列表当中元素数最小的集合,M为给定列表的个数。
|
||||||
|
|
||||||
在整个算法中起决定作用的一是最短的倒排列表的长度,其次是词数总和,一般词数不会很大(想像一下,你会在搜索引擎里输入几百字来搜索么?),所以起决定性作用的,一般是所有倒排列表中,最短的那一个的长度。
|
在整个算法中起决定作用的一是最短的倒排列表的长度,其次是词数总和,一般词数不会很大(想像一下,你会在搜索引擎里输入几百字来搜索么?),所以起决定性作用的,一般是所有倒排列表中,最短的那一个的长度。
|
||||||
|
|
||||||
因此,文档总数很多的情况下,搜索词的倒排列表最短的那一个不长时,搜索速度也是很快的。如果用关系型数据库,那就需要按照索引(如果有的话)来慢慢扫描了。
|
因此,文档总数很多的情况下,搜索词的倒排列表最短的那一个不长时,搜索速度也是很快的。如果用关系型数据库,那就需要按照索引(如果有的话)来慢慢扫描了。
|
||||||
|
|
||||||
### 查询 DSL
|
### 查询 DSL
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ if field_1 == 1 || field_2 == 2 {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
这些Go代码里`if`后面跟着的表达式在编程语言中有专有名词来表达Boolean Expression:
|
这些Go代码里`if`后面跟着的表达式在编程语言中有专有名词来表达`Boolean Expression`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
4 > 1
|
4 > 1
|
||||||
@ -268,7 +268,7 @@ func deleteDocument(
|
|||||||
|
|
||||||
因为Lucene的性质,本质上搜索引擎内的数据是不可变的,所以如果要对文档进行更新,实际上是按照id进行完全覆盖的操作,所以与插入的情况是一样的。
|
因为Lucene的性质,本质上搜索引擎内的数据是不可变的,所以如果要对文档进行更新,实际上是按照id进行完全覆盖的操作,所以与插入的情况是一样的。
|
||||||
|
|
||||||
使用es作为数据库使用时,需要注意,因为es有索引合并的操作,所以数据插入到es中到可以查询的到需要一段时间(由es的refresh_interval决定)。所以千万不要把es当成强一致的关系型数据库来使用。
|
使用es作为数据库使用时,需要注意,因为es有索引合并的操作,所以数据插入到es中到可以查询的到需要一段时间(由es的refresh_interval决定)。所以千万不要把es当成强一致的关系型数据库来使用。
|
||||||
|
|
||||||
### 将 sql 转换为 DSL
|
### 将 sql 转换为 DSL
|
||||||
|
|
||||||
@ -343,7 +343,7 @@ select * from xxx where user_id = 1 and (
|
|||||||
|
|
||||||
es的DSL虽然很好理解,但是手写起来非常费劲。前面提供了基于SDK的方式来写,但也不足够灵活。
|
es的DSL虽然很好理解,但是手写起来非常费劲。前面提供了基于SDK的方式来写,但也不足够灵活。
|
||||||
|
|
||||||
SQL的where部分就是boolean expression。我们之前提到过,这种bool表达式在被parse之后,和es的DSL的结构长得差不多,我们能不能直接通过这种“差不多”的猜测来直接帮我们把SQL转换成DSL呢?
|
SQL的where部分就是boolean expression。我们之前提到过,这种bool表达式在被解析之后,和es的DSL的结构长得差不多,我们能不能直接通过这种“差不多”的猜测来直接帮我们把SQL转换成DSL呢?
|
||||||
|
|
||||||
当然可以,我们把SQL的where被Parse之后的结构和es的DSL的结构做个对比:
|
当然可以,我们把SQL的where被Parse之后的结构和es的DSL的结构做个对比:
|
||||||
|
|
||||||
@ -391,7 +391,7 @@ select * from wms_orders where update_time >= date_sub(
|
|||||||
|
|
||||||
*图 6-13 基于binlog的数据同步*
|
*图 6-13 基于binlog的数据同步*
|
||||||
|
|
||||||
业界使用较多的是阿里开源的Canal,来进行binlog解析与同步。canal会伪装成MySQL的从库,然后解析好行格式的binlog,再以更容易解析的格式(例如json)发送到消息队列。
|
业界使用较多的是阿里开源的Canal,来进行binlog解析与同步。canal会伪装成MySQL的从库,然后解析好行格式的binlog,再以更容易解析的格式(例如json)发送到消息队列。
|
||||||
|
|
||||||
由下游的Kafka消费者负责把上游数据表的自增主键作为es的document的id进行写入,这样可以保证每次接收到binlog时,对应id的数据都被覆盖更新为最新。MySQL的row格式的binlog会将每条记录的所有字段都提供给下游,所以实际上在向异构数据目标同步数据时,不需要考虑数据是插入还是更新,只要一律按id进行覆盖即可。
|
由下游的Kafka消费者负责把上游数据表的自增主键作为es的document的id进行写入,这样可以保证每次接收到binlog时,对应id的数据都被覆盖更新为最新。MySQL的row格式的binlog会将每条记录的所有字段都提供给下游,所以实际上在向异构数据目标同步数据时,不需要考虑数据是插入还是更新,只要一律按id进行覆盖即可。
|
||||||
|
|
||||||
|
@ -71,13 +71,13 @@ func request(params map[string]interface{}) error {
|
|||||||
|
|
||||||
真的没有问题么?实际上还是有问题的。这段简短的程序里有两个隐藏的隐患:
|
真的没有问题么?实际上还是有问题的。这段简短的程序里有两个隐藏的隐患:
|
||||||
|
|
||||||
1. 没有随机种子。在没有随机种子的情况下,rand.Intn()返回的伪随机数序列是固定的。
|
1. 没有随机种子。在没有随机种子的情况下,`rand.Intn()`返回的伪随机数序列是固定的。
|
||||||
|
|
||||||
2. 洗牌不均匀,会导致整个数组第一个节点有大概率被选中,并且多个节点的负载分布不均衡。
|
2. 洗牌不均匀,会导致整个数组第一个节点有大概率被选中,并且多个节点的负载分布不均衡。
|
||||||
|
|
||||||
第一点比较简单,应该不用在这里给出证明了。关于第二点,我们可以用概率知识来简单证明一下。假设每次挑选都是真随机,我们假设第一个位置的endpoint在len(slice)次交换中都不被选中的概率是((6/7)*(6/7))^7 ≈ 0.34。而分布均匀的情况下,我们肯定希望被第一个元素在任意位置上分布的概率均等,所以其被随机选到的概率应该约等于1/7≈0.14。
|
第一点比较简单,应该不用在这里给出证明了。关于第二点,我们可以用概率知识来简单证明一下。假设每次挑选都是真随机,我们假设第一个位置的endpoint在`len(slice)`次交换中都不被选中的概率是`((6/7)*(6/7))^7 ≈ 0.34`。而分布均匀的情况下,我们肯定希望被第一个元素在任意位置上分布的概率均等,所以其被随机选到的概率应该约等于`1/7≈0.14`。
|
||||||
|
|
||||||
显然,这里给出的洗牌算法对于任意位置的元素来说,有30%的概率不对其进行交换操作。所以所有元素都倾向于留在原来的位置。因为我们每次对shuffle数组输入的都是同一个序列,所以第一个元素有更大的概率会被选中。在负载均衡的场景下,也就意味着endpoints数组中的第一台机器负载会比其它机器高不少(这里至少是3倍以上)。
|
显然,这里给出的洗牌算法对于任意位置的元素来说,有30%的概率不对其进行交换操作。所以所有元素都倾向于留在原来的位置。因为我们每次对`shuffle`数组输入的都是同一个序列,所以第一个元素有更大的概率会被选中。在负载均衡的场景下,也就意味着endpoints数组中的第一台机器负载会比其它机器高不少(这里至少是3倍以上)。
|
||||||
|
|
||||||
### 6.5.2.2 修正洗牌算法
|
### 6.5.2.2 修正洗牌算法
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# 6.6 分布式配置管理
|
# 6.6 分布式配置管理
|
||||||
|
|
||||||
在分布式系统中,常困扰我们的还有上线问题。虽然目前有一些优雅重启方案,但实际应用中可能受限于我们系统内部的运行情况而没有办法做到真正的“优雅”。比如我们为了对去下游的流量进行限制,在内存中堆积一些数据,并对堆积设定时间/总量的阈值。在任意阈值达到之后将数据统一发送给下游,以避免频繁的请求超出下游的承载能力而将下游打垮。这种情况下重启要做到优雅就比较难了。
|
在分布式系统中,常困扰我们的还有上线问题。虽然目前有一些优雅重启方案,但实际应用中可能受限于我们系统内部的运行情况而没有办法做到真正的“优雅”。比如我们为了对去下游的流量进行限制,在内存中堆积一些数据,并对堆积设定时间或总量的阈值。在任意阈值达到之后将数据统一发送给下游,以避免频繁的请求超出下游的承载能力而将下游打垮。这种情况下重启要做到优雅就比较难了。
|
||||||
|
|
||||||
所以我们的目标还是尽量避免采用或者绕过上线的方式,对线上程序做一些修改。比较典型的修改内容就是程序的配置项。
|
所以我们的目标还是尽量避免采用或者绕过上线的方式,对线上程序做一些修改。比较典型的修改内容就是程序的配置项。
|
||||||
|
|
||||||
@ -14,11 +14,11 @@
|
|||||||
|
|
||||||
### 6.6.1.2 业务配置
|
### 6.6.1.2 业务配置
|
||||||
|
|
||||||
大公司的平台部门服务众多业务线,在平台内为各业务线分配唯一id。平台本身也由多个模块构成,这些模块需要共享相同的业务线定义(要不然就乱套了)。当公司新开产品线时,需要能够在短时间内打通所有平台系统的流程。这时候每个系统都走上线流程肯定是来不及的。另外需要对这种公共配置进行统一管理,同时对其增减逻辑也做统一管理。这些信息变更时,需要自动通知到业务方的系统,而不需要人力介入(或者只需要很简单的介入,比如点击审核通过)。
|
大公司的平台部门服务众多业务线,在平台内为各业务线分配唯一id。平台本身也由多个模块构成,这些模块需要共享相同的业务线定义(要不然就乱套了)。当公司新开产品线时,需要能够在短时间内打通所有平台系统的流程。这时候每个系统都走上线流程肯定是来不及的。另外需要对这种公共配置进行统一管理,同时对其增减逻辑也做统一管理。这些信息变更时,需要自动通知到业务方的系统,而不需要人力介入(或者只需要很简单的介入,比如点击审核通过)。
|
||||||
|
|
||||||
除业务线管理之外,很多互联网公司会按照城市来铺展自己的业务。在某个城市未开城之前,理论上所有模块都应该认为带有该城市id的数据是脏数据并自动过滤掉。而如果业务开城,在系统中就应该自己把这个新的城市id自动加入到白名单中。这样业务流程便可以自动运转。
|
除业务线管理之外,很多互联网公司会按照城市来铺展自己的业务。在某个城市未开城之前,理论上所有模块都应该认为带有该城市id的数据是脏数据并自动过滤掉。而如果业务开城,在系统中就应该自己把这个新的城市id自动加入到白名单中。这样业务流程便可以自动运转。
|
||||||
|
|
||||||
再举个例子,互联网公司的运营系统中会有各种类型的运营活动,有些运营活动推出后可能出现了超出预期的事件(比如公关危机),需要紧急将系统下线。这时候会用到一些开关来快速关闭相应的功能。或者快速将想要剔除的活动id从白名单中剔除。在Web章节中的AB测试一节中,我们也提到,有时需要有这样的系统来告诉我们当前需要放多少流量到相应的功能代码上。我们可以像那一节中,使用远程RPC来获知这些信息,但同时,也可以结合分布式配置系统,主动地拉取到这些信息。
|
再举个例子,互联网公司的运营系统中会有各种类型的运营活动,有些运营活动推出后可能出现了超出预期的事件(比如公关危机),需要紧急将系统下线。这时候会用到一些开关来快速关闭相应的功能。或者快速将想要剔除的活动id从白名单中剔除。在Web章节中的AB测试一节中,我们也提到,有时需要有这样的系统来告诉我们当前需要放多少流量到相应的功能代码上。我们可以像那一节中,使用远程RPC来获知这些信息,但同时,也可以结合分布式配置系统,主动地拉取到这些信息。
|
||||||
|
|
||||||
## 6.6.2 使用 etcd 实现配置更新
|
## 6.6.2 使用 etcd 实现配置更新
|
||||||
|
|
||||||
@ -182,7 +182,7 @@ func main() {
|
|||||||
|
|
||||||
在配置进行更新时,我们要为每份配置的新内容赋予一个版本号,并将修改前的内容和版本号记录下来,当发现新配置出问题时,能够及时地回滚回来。
|
在配置进行更新时,我们要为每份配置的新内容赋予一个版本号,并将修改前的内容和版本号记录下来,当发现新配置出问题时,能够及时地回滚回来。
|
||||||
|
|
||||||
常见的做法是,使用MySQL来存储配置文件/字符串的不同版本内容,在需要回滚时,只要进行简单的查询即可。
|
常见的做法是,使用MySQL来存储配置文件或配置字符串的不同版本内容,在需要回滚时,只要进行简单的查询即可。
|
||||||
|
|
||||||
## 6.6.5 客户端容错
|
## 6.6.5 客户端容错
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ func main() {
|
|||||||
想像一下,你们的信息分析系统运行非常之快。获取信息的速度成为了瓶颈,虽然可以用上Go语言所有优秀的并发特性,将单机的CPU和网络带宽都用满,但还是希望能够加快爬虫的爬取速度。在很多场景下,速度是有意义的:
|
想像一下,你们的信息分析系统运行非常之快。获取信息的速度成为了瓶颈,虽然可以用上Go语言所有优秀的并发特性,将单机的CPU和网络带宽都用满,但还是希望能够加快爬虫的爬取速度。在很多场景下,速度是有意义的:
|
||||||
|
|
||||||
1. 对于价格战期间的电商们来说,希望能够在对手价格变动后第一时间获取到其最新价格,再靠机器自动调整本家的商品价格。
|
1. 对于价格战期间的电商们来说,希望能够在对手价格变动后第一时间获取到其最新价格,再靠机器自动调整本家的商品价格。
|
||||||
2. 对于类似头条之类的feed流业务,信息的时效性也非常重要。如果我们慢吞吞地爬到的新闻是昨天的新闻,那对于用户来说就没有任何意义。
|
2. 对于类似头条之类的Feed流业务,信息的时效性也非常重要。如果我们慢吞吞地爬到的新闻是昨天的新闻,那对于用户来说就没有任何意义。
|
||||||
|
|
||||||
所以我们需要分布式爬虫。从本质上来讲,分布式爬虫是一套任务分发和执行系统。而常见的任务分发,因为上下游存在速度不匹配问题,必然要借助消息队列。
|
所以我们需要分布式爬虫。从本质上来讲,分布式爬虫是一套任务分发和执行系统。而常见的任务分发,因为上下游存在速度不匹配问题,必然要借助消息队列。
|
||||||
|
|
||||||
@ -80,9 +80,9 @@ func main() {
|
|||||||
|
|
||||||
*图 6-14 爬虫工作流程*
|
*图 6-14 爬虫工作流程*
|
||||||
|
|
||||||
上游的主要工作是根据预先配置好的起点来爬取所有的目标“列表页”,列表页的html内容中会包含有所有详情页的链接。详情页的数量一般是列表页的10~100倍,所以我们将这些详情页链接作为“任务”内容,通过消息队列分发出去。
|
上游的主要工作是根据预先配置好的起点来爬取所有的目标“列表页”,列表页的html内容中会包含有所有详情页的链接。详情页的数量一般是列表页的10到100倍,所以我们将这些详情页链接作为“任务”内容,通过消息队列分发出去。
|
||||||
|
|
||||||
针对页面爬取来说,在执行时是否偶尔会有重复其实不太重要,因为任务结果是幂等的(这里我们只爬页面内容,不考虑评论部分)。
|
针对页面爬取来说,在执行时是否偶尔会有重复其实不太重要,因为任务结果是幂等的(这里我们只爬页面内容,不考虑评论部分)。
|
||||||
|
|
||||||
本节我们来简单实现一个基于消息队列的爬虫,本节我们使用nats来做任务分发。实际开发中,应该针对自己的业务对消息本身的可靠性要求和公司的基础架构组件情况进行选型。
|
本节我们来简单实现一个基于消息队列的爬虫,本节我们使用nats来做任务分发。实际开发中,应该针对自己的业务对消息本身的可靠性要求和公司的基础架构组件情况进行选型。
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ nc.Flush()
|
|||||||
|
|
||||||
直接使用nats的subscribe API并不能达到任务分发的目的,因为pub sub本身是广播性质的。所有消费者都会收到完全一样的所有消息。
|
直接使用nats的subscribe API并不能达到任务分发的目的,因为pub sub本身是广播性质的。所有消费者都会收到完全一样的所有消息。
|
||||||
|
|
||||||
除了普通的subscribe之外,nats还提供了queue subscribe的功能。只要提供一个queue group名字(类似Kafka中的consumer group),即可均衡地将任务分发给消费者。
|
除了普通的subscribe之外,nats还提供了queue subscribe的功能。只要提供一个queue group名字(类似Kafka中的consumer group),即可均衡地将任务分发给消费者。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
nc, err := nats.Connect(nats.DefaultURL)
|
nc, err := nats.Connect(nats.DefaultURL)
|
||||||
@ -159,7 +159,7 @@ for {
|
|||||||
|
|
||||||
#### 结合colly的消息生产
|
#### 结合colly的消息生产
|
||||||
|
|
||||||
我们为每一个网站定制一个对应的collector,并设置相应的规则,比如v2ex,v2fx(虚构的),再用简单的工厂方法来将该collector和其host对应起来:
|
我们为每一个网站定制一个对应的collector,并设置相应的规则,比如v2ex,v2fx(虚构的),再用简单的工厂方法来将该collector和其host对应起来:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# 6.8 补充说明
|
# 6.8 补充说明
|
||||||
|
|
||||||
分布式是很大的领域,本章中的介绍只能算是对领域的管中窥豹。因为大型系统流量大,并发高,所以往往很多朴素的方案会变得难以满足需求。人们为了解决大型系统场景中的各种问题,而开发出了各式各样的分布式系统。有些系统非常简单,比如本章中介绍的分布式id生成器,而有一些系统则可能非常复杂,比如本章中的分布式搜索引擎(当然,本章中提到的es不是Go实现)。
|
分布式是很大的领域,本章中的介绍只能算是对领域的管中窥豹。因为大型系统流量大,并发高,所以往往很多朴素的方案会变得难以满足需求。人们为了解决大型系统场景中的各种问题,而开发出了各式各样的分布式系统。有些系统非常简单,比如本章中介绍的分布式id生成器,而有一些系统则可能非常复杂,比如本章中的分布式搜索引擎(当然,本章中提到的es不是Go实现)。
|
||||||
|
|
||||||
无论简单的或是复杂的系统,都会在特定的场景中体现出它们重要的价值,希望读者朋友可以多多接触开源,积累自己的工具箱,从而站在巨人们的肩膀之上。
|
无论简单的或是复杂的系统,都会在特定的场景中体现出它们重要的价值,希望读者朋友可以多多接触开源,积累自己的工具箱,从而站在巨人们的肩膀之上。
|
||||||
|
Loading…
x
Reference in New Issue
Block a user