GOWEB
Web 开发简介
因为Go的net/http包提供了基础的路由函数组合与丰富的功能函数。所以在社区里流行一种用Go编写API不需要框架的观点,在我们看来,如果你的项目的路由在个位数、URI固定且不通过URI来传递参数,那么确实使用官方库也就足够。但在复杂场景下,官方的http库还是有些力有不逮。例如下面这样的路由:
GET /card/:id
POST /card/:id
DELTE /card/:id
GET /card/:id/name
...
GET /card/:id/relations
可见是否使用框架还是要具体问题具体分析的。
Go的Web框架大致可以分为这么两类:
1、Router框架
2、MVC类框架
在框架的选择上,大多数情况下都是依照个人的喜好和公司的技术栈。例如公司有很多技术人员是PHP出身,那么他们一定会非常喜欢像beego这样的框架,但如果公司有很多C程序员,那么他们的想法可能是越简单越好。比如很多大厂的C程序员甚至可能都会去用C语言去写很小的CGI程序,他们可能本身并没有什么意愿去学习MVC或者更复杂的Web框架,他们需要的只是一个非常简单的路由(甚至连路由都不需要,只需要一个基础的HTTP协议处理库来帮他省掉没什么意思的体力劳动)。
Go的net/http
包提供的就是这样的基础功能,写一个简单的http echo server
只需要30s。
package main
import (...)
func echo(wr http.ResponseWriter, r *http.Request) {
msg, err := ioutil.ReadAll(r.Body)
if err != nil {
wr.Write([]byte("echo error"))
return
}
writeLen, err := wr.Write(msg)
if err != nil || writeLen != len(msg) {
log.Println(err, "write len:", writeLen)
}
}
func main() {
http.HandleFunc("/", echo)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
如果你过了30s还没有完成这个程序,请检查一下你自己的打字速度是不是慢了(开个玩笑 😄)。这个例子是为了说明在Go中写一个HTTP协议的小程序有多么简单。如果你面临的情况比较复杂,例如几十个接口的企业级应用,直接用net/http
库就显得不太合适了。
我们来看看开源社区中一个Kafka监控项目中的做法:
//Burrow: http_server.go
func NewHttpServer(app *ApplicationContext) (*HttpServer, error) {
...
server.mux.HandleFunc("/", handleDefault)
server.mux.HandleFunc("/burrow/admin", handleAdmin)
server.mux.Handle("/v2/kafka", appHandler{server.app, handleClusterList})
server.mux.Handle("/v2/kafka/", appHandler{server.app, handleKafka})
server.mux.Handle("/v2/zookeeper", appHandler{server.app, handleClusterList})
...
}
上面这段代码来自大名鼎鼎的linkedin公司的Kafka监控项目Burrow,没有使用任何router框架,只使用了net/http
。只看上面这段代码似乎非常优雅,我们的项目里大概只有这五个简单的URI,所以我们提供的服务就是下面这个样子:
/
/burrow/admin
/v2/kafka
/v2/kafka/
/v2/zookeeper
如果你确实这么想的话就被骗了。我们再进handleKafka()
这个函数一探究竟:
func handleKafka(app *ApplicationContext, w http.ResponseWriter, r *http.Request) (int, string) {
pathParts := strings.Split(r.URL.Path[1:], "/")
if _, ok := app.Config.Kafka[pathParts[2]]; !ok {
return makeErrorResponse(http.StatusNotFound, "cluster not found", w, r)
}
if pathParts[2] == "" {
// Allow a trailing / on requests
return handleClusterList(app, w, r)
}
if (len(pathParts) == 3) || (pathParts[3] == "") {
return handleClusterDetail(app, w, r, pathParts[2])
}
switch pathParts[3] {
case "consumer":
switch {
case r.Method == "DELETE":
switch {
case (len(pathParts) == 5) || (pathParts[5] == ""):
return handleConsumerDrop(app, w, r, pathParts[2], pathParts[4])
default:
return makeErrorResponse(http.StatusMethodNotAllowed, "request method not supported", w, r)
}
case r.Method == "GET":
switch {
case (len(pathParts) == 4) || (pathParts[4] == ""):
return handleConsumerList(app, w, r, pathParts[2])
case (len(pathParts) == 5) || (pathParts[5] == ""):
// Consumer detail - list of consumer streams/hosts? Can be config info later
return makeErrorResponse(http.StatusNotFound, "unknown API call", w, r)
case pathParts[5] == "topic":
switch {
case (len(pathParts) == 6) || (pathParts[6] == ""):
return handleConsumerTopicList(app, w, r, pathParts[2], pathParts[4])
case (len(pathParts) == 7) || (pathParts[7] == ""):
return handleConsumerTopicDetail(app, w, r, pathParts[2], pathParts[4], pathParts[6])
}
case pathParts[5] == "status":
return handleConsumerStatus(app, w, r, pathParts[2], pathParts[4], false)
case pathParts[5] == "lag":
return handleConsumerStatus(app, w, r, pathParts[2], pathParts[4], true)
}
default:
return makeErrorResponse(http.StatusMethodNotAllowed, "request method not supported", w, r)
}
case "topic":
switch {
case r.Method != "GET":
return makeErrorResponse(http.StatusMethodNotAllowed, "request method not supported", w, r)
case (len(pathParts) == 4) || (pathParts[4] == ""):
return handleBrokerTopicList(app, w, r, pathParts[2])
case (len(pathParts) == 5) || (pathParts[5] == ""):
return handleBrokerTopicDetail(app, w, r, pathParts[2], pathParts[4])
}
case "offsets":
// Reserving this endpoint to implement later
return makeErrorResponse(http.StatusNotFound, "unknown API call", w, r)
}
// If we fell through, return a 404
return makeErrorResponse(http.StatusNotFound, "unknown API call", w, r)
}
因为默认的``net/http```包中的mux不支持带参数的路由,所以Burrow这个项目使用了非常蹩脚的字符串Split和乱七八糟的 switch case来达到自己的目的,但却让本来应该很集中的路由管理逻辑变得复杂,散落在系统的各处,难以维护和管理。如果读者细心地看过这些代码之后,可能会发现其它的几个handler函数逻辑上较简单,最复杂的也就是这个handleKafka()。而我们的系统总是从这样微不足道的混乱开始积少成多,最终变得难以收拾。
根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用net/http
中默认的路由。在Go开源界应用最广泛的router是httpRouter
,很多开源的router框架都是基于httpRouter进行一定程度的改造的成果。关于httpRouter路由的原理,会在本章节的router一节中进行详细的阐释。
再来回顾一下文章开头说的,开源界有这么几种框架,第一种是对httpRouter进行简单的封装,然后提供定制的中间件和一些简单的小工具集成比如gin,主打轻量,易学,高性能。第二种是借鉴其它语言的编程风格的一些MVC类框架,例如beego,方便从其它语言迁移过来的程序员快速上手,快速开发。还有一些框架功能更为强大,除了数据库schema设计,大部分代码直接生成,例如goa。不管哪种框架,适合开发者背景的就是最好的。
router 请求路由
在常见的Web框架中,router是必备的组件。Go语言圈子里router也时常被称为http
的multiplexer。在上一节中我们通过对Burrow代码的简单学习,已经知道如何用http
标准库中内置的mux来完成简单的路由功能了。如果开发Web系统对路径中带参数没什么兴趣的话,用http
标准库中的mux
就可以。
RESTful
是几年前刮起的API设计风潮,在RESTful
中除了GET和POST之外,还使用了HTTP协议定义的几种其它的标准化语义。具体包括:
const (
MethodGet = "GET"
MethodHead = "HEAD"
MethodPost = "POST"
MethodPut = "PUT"
MethodPatch = "PATCH" // RFC 5789
MethodDelete = "DELETE"
MethodConnect = "CONNECT"
MethodOptions = "OPTIONS"
MethodTrace = "TRACE"
)
来看看RESTful中常见的请求路径:
GET /repos/:owner/:repo/comments/:id/reactions
POST /projects/:project_id/columns
PUT /user/starred/:owner/:repo
DELETE /user/starred/:owner/:repo
相信聪明的你已经猜出来了,这是Github官方文档中挑出来的几个API设计。RESTful风格的API重度依赖请求路径。会将很多参数放在请求URI中。除此之外还会使用很多并不那么常见的HTTP状态码,不过本节只讨论路由,所以先略过不谈。
如果我们的系统也想要这样的URI设计,使用标准库的mux
显然就力不从心了。
httprouter
较流行的开源go Web框架大多使用httprouter
,或是基于httprouter
的变种对路由进行支持。前面提到的github的参数式路由在httprouter
中都是可以支持的。
因为httprouter
中使用的是显式匹配,所以在设计路由的时候需要规避一些会导致路由冲突的情况,例如:
冲突:
GET /user/info/:name
GET /user/:id
不冲突:
GET /user/info/:name
POST /user/:id
简单来讲的话,如果两个路由拥有一致的http方法(指 GET/POST/PUT/DELETE)和请求路径前缀,且在某个位置出现了A路由是wildcard(指:id这种形式)参数,B路由则是普通字符串,那么就会发生路由冲突
。路由冲突会在初始化阶段直接panic:
panic: wildcard route ':id' conflicts with existing children in path '/user/:id'
goroutine 1 [running]:
github.com/cch123/httprouter.(*node).insertChild(0xc4200801e0, 0xc42004fc01, 0x126b177, 0x3, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/tree.go:256 +0x841
github.com/cch123/httprouter.(*node).addRoute(0xc4200801e0, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/tree.go:221 +0x22a
github.com/cch123/httprouter.(*Router).Handle(0xc42004ff38, 0x126a39b, 0x3, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/router.go:262 +0xc3
github.com/cch123/httprouter.(*Router).GET(0xc42004ff38, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/router.go:193 +0x5e
main.main()
/Users/caochunhui/test/go_web/httprouter_learn2.go:18 +0xaf
exit status 2
还有一点需要注意,因为httprouter
考虑到字典树的深度,在初始化时会对参数的数量进行限制,所以在路由中的参数数目不能超过255
,否则会导致httprouter无法识别后续的参数
。不过这一点上也不用考虑太多,毕竟URI是人设计且给人来看的,相信没有长得夸张的URI能在一条路径中带有200个以上的参数。
除支持路径中的wildcard参数之外,httprouter还可以支持*
号来进行通配,不过*
号开头的参数只能放在路由的结尾,例如下面这样:
Pattern: /src/*filepath
/src/ filepath = ""
/src/somefile.go filepath = "somefile.go"
/src/subdir/somefile.go filepath = "subdir/somefile.go"
这种设计在RESTful中可能不太常见,主要是为了能够使用httprouter
来做简单的HTTP静态文件服务器。
除了正常情况下的路由支持,httprouter也支持对一些特殊情况下的回调函数进行定制
,例如404的时候:
r := httprouter.New()
// 404
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("oh no, not found"))
})
或者内部panic的时候:
r.PanicHandler = func(w http.ResponseWriter, r *http.Request, c interface{}) {
log.Printf("Recovering from panic, Reason: %#v", c.(error))
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(c.(error).Error()))
}
目前开源界最为流行(star数最多)的Web框架gin使用的就是httprouter的变种。
原理
httprouter
和众多衍生router使用的数据结构被称为压缩字典树(Radix Tree)
。读者可能没有接触过压缩字典树,但对字典树(Trie Tree)应该有所耳闻。下图是一个典型的字典树
结构:
字典树常用来进行字符串检索,例如用给定的字符串序列建立字典树。对于目标字符串,只要从根节点开始深度优先搜索,即可判断出该字符串是否曾经出现过,时间复杂度为O(n)
,n可以认为是目标字符串的长度。为什么要这样做?字符串本身不像数值类型可以进行数值比较,两个字符串对比的时间复杂度取决于字符串长度。如果不用字典树来完成上述功能,要对历史字符串进行排序,再利用二分查找之类的算法去搜索,时间复杂度只高不低。可认为字典树是一种空间换时间的典型做法。
普通的字典树有一个比较明显的缺点,就是每个字母都需要建立一个孩子节点
,这样会导致字典树的层数比较深,压缩字典树相对好地平衡了字典树的优点和缺点。是典型的压缩字典树
结构:
每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。使用压缩字典树可以减少树的层数,同时因为每个节点上数据存储也比通常的字典树要多,所以程序的局部性较好(一个节点的path加载到cache即可进行多个字符的对比),从而对CPU缓存友好。
压缩字典树创建过程
我们来跟踪一下httprouter中,一个典型的压缩字典树的创建过程,路由设定如下:
PUT /user/installations/:installation_id/repositories/:repository_id
GET /marketplace_listing/plans/
GET /marketplace_listing/plans/:id/accounts
GET /search
GET /status
GET /support
补充路由:
GET /marketplace_listing/plans/ohyes
最后一条补充路由是我们臆想的,除此之外所有API路由均来自于api.github.com。
root 节点创建
httprouter的Router结构体中存储压缩字典树使用的是下述数据结构:
// 略去了其它部分的 Router struct
type Router struct {
// ...
trees map[string]*node
// ...
}
trees
中的key
即为HTTP 1.1的RFC中定义的各种方法,具体有:
GET
HEAD
OPTIONS
POST
PUT
PATCH
DELETE
radix的节点类型为*httprouter.node,为了说明方便,我们留下了目前关心的几个字段:
path: 当前节点对应的路径中的字符串
wildChild: 子节点是否为参数节点,即 wildcard node,或者说 :id 这种类型的节点
nType: 当前节点类型,有四个枚举值: 分别为 static/root/param/catchAll。
static // 非根节点的普通字符串节点
root // 根节点
param // 参数节点,例如 :id
catchAll // 通配符节点,例如 *anyway
indices:子节点索引,当子节点为非参数类型,即本节点的wildChild为false时,会将每个子节点的首字母放在该索引数组。说是数组,实际上是个string。
每一种方法对应的都是一棵独立的压缩字典树
,这些树彼此之间不共享数据。具体到我们上面用到的路由,PUT和GET是两棵树而非一棵。
简单来讲,某个方法第一次插入的路由就会导致对应字典树的根节点被创建,我们按顺序,先是一个PUT
:
r := httprouter.New()
r.PUT("/user/installations/:installation_id/repositories/:reposit", Hello)
这样PUT
对应的根节点就会被创建出来。把这棵PUT的树画出来:
当然,PUT路由只有唯一的一条路径。接下来,我们以后续的多条GET路径为例,讲解子节点的插入过程。
子节点插入
当插入GET /marketplace_listing/plans
时,类似前面PUT的过程,GET树的结构如图
因为第一个路由没有参数,path都被存储到根节点上了。所以只有一个节点。
然后插入GET /marketplace_listing/plans/:id/accounts
,新的路径与之前的路径有共同的前缀,且可以直接在之前叶子节点后进行插入,那么结果也很简单,插入后的树结构见图
由于:id
这个节点只有一个字符串的普通子节点,所以indices
还依然不需要处理。
边分裂
接下来我们插入GET /search
,这时会导致树的边分裂,
原有路径和新的路径在初始的/位置发生分裂,这样需要把原有的root节点内容下移,再将新路由 search同样作为子节点挂在root节点之下。这时候因为子节点出现多个,root节点的indices提供子节点索引,这时候该字段就需要派上用场了。"ms"代表子节点的首字母分别为m(marketplace)和s(search)。
我们一口作气,把GET /status和GET /support
也插入到树中。这时候会导致在search节点上再次发生分裂,最终结果见图
子节点冲突处理
在路由本身只有字符串的情况下,不会发生任何冲突。只有当路由中含有wildcard(类似 :id)或者catchAll的情况下才可能冲突。这一点在前面已经提到了。
子节点的冲突处理很简单,分几种情况:
1、在插入wildcard节点时,父节点的children数组非空且wildChild被设置为false。例如:GET /user/getAll和GET /user/:id/getAddr,或者GET /user/*aaa和GET /user/:id。
2、在插入wildcard节点时,父节点的children数组非空且wildChild被设置为true,但该父节点的wildcard子节点要插入的wildcard名字不一样。例如:GET /user/:id/info和GET /user/:name/info。
3、在插入catchAll节点时,父节点的children非空。例如:GET /src/abc和GET /src/*filename,或者GET /src/:id和GET /src/*filename。
4、在插入static节点时,父节点的wildChild字段被设置为true。
5、在插入static节点时,父节点的children非空,且子节点nType为catchAll。
只要发生冲突,都会在初始化的时候panic。例如,在插入我们臆想的路由GET /marketplace_listing/plans/ohyes
时,出现第4种冲突情况:它的父节点marketplace_listing/plans/
的wildChild字段为true。
net/http下使用httprouter
package main
import (
"fmt"
"github.com/julienschmidt/httprouter"
"net/http"
)
func main() {
// 生成httprouter对象
r := httprouter.New()
r.GET("/", newRouter)
// 服务启动将httprouter对象传入listenAndServer第二个参数
http.ListenAndServe(":8080", r)
}
func newRouter(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
fmt.Fprintln(wr, "hello")
}
中间件
本章将对现在流行的Web框架中的中间件(middleware)技术原理进行分析,并介绍如何使用中间件技术将业务和非业务代码功能进行解耦。
代码泥潭
先来看一段代码:
// middleware/hello.go
package main
func hello(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("hello"))
}
func main() {
http.HandleFunc("/", hello)
err := http.ListenAndServe(":8080", nil)
...
}
这是一个典型的Web服务,挂载了一个简单的路由。我们的线上服务一般也是从这样简单的服务开始逐渐拓展开去的。
现在突然来了一个新的需求,我们想要统计之前写的hello服务的处理耗时,需求很简单,我们对上面的程序进行少量修改:
// middleware/hello_with_time_elapse.go
var logger = log.New(os.Stdout, "", 0)
func hello(wr http.ResponseWriter, r *http.Request) {
timeStart := time.Now()
wr.Write([]byte("hello"))
timeElapsed := time.Since(timeStart)
logger.Println(timeElapsed)
}
这样便可以在每次接收到http请求时,打印出当前请求所消耗的时间。
完成了这个需求之后,我们继续进行业务开发,提供的API逐渐增加,现在我们的路由看起来是这个样子:
// middleware/hello_with_more_routes.go
// 省略了一些相同的代码
package main
func helloHandler(wr http.ResponseWriter, r *http.Request) {
// ...
}
func showInfoHandler(wr http.ResponseWriter, r *http.Request) {
// ...
}
func showEmailHandler(wr http.ResponseWriter, r *http.Request) {
// ...
}
func showFriendsHandler(wr http.ResponseWriter, r *http.Request) {
timeStart := time.Now()
wr.Write([]byte("your friends is tom and alex"))
timeElapsed := time.Since(timeStart)
logger.Println(timeElapsed)
}
func main() {
http.HandleFunc("/", helloHandler)
http.HandleFunc("/info/show", showInfoHandler)
http.HandleFunc("/email/show", showEmailHandler)
http.HandleFunc("/friends/show", showFriendsHandler)
// ...
}
每一个handler里都有之前提到的记录运行时间的代码,每次增加新的路由我们也同样需要把这些看起来长得差不多的代码拷贝到我们需要的地方去。因为代码不太多,所以实施起来也没有遇到什么大问题。
渐渐的我们的系统增加到了30个路由和handler函数,每次增加新的handler,我们的第一件工作就是把之前写的所有和业务逻辑无关的周边代码先拷贝过来。
接下来系统安稳地运行了一段时间,突然有一天,老板找到你,我们最近找人新开发了监控系统,为了系统运行可以更加可控,需要把每个接口运行的耗时数据主动上报到我们的监控系统里。给监控系统起个名字吧,叫metrics。现在你需要修改代码并把耗时通过HTTP Post的方式发给metrics系统了。我们来修改一下helloHandler()
:
func helloHandler(wr http.ResponseWriter, r *http.Request) {
timeStart := time.Now()
wr.Write([]byte("hello"))
timeElapsed := time.Since(timeStart)
logger.Println(timeElapsed)
// 新增耗时上报
metrics.Upload("timeHandler", timeElapsed)
}
修改到这里,本能地发现我们的开发工作开始陷入了泥潭。无论未来对我们的这个Web系统有任何其它的非功能或统计需求,我们的修改必然牵一发而动全身。只要增加一个非常简单的非业务统计,我们就需要去几十个handler里增加这些业务无关的代码。虽然一开始我们似乎并没有做错,但是显然随着业务的发展,我们的行事方式让我们陷入了代码的泥潭。
使用中间件剥离非业务逻辑
我们来分析一下,一开始在哪里做错了呢?我们只是一步一步地满足需求,把我们需要的逻辑按照流程写下去呀?
我们犯的最大的错误,是把业务代码和非业务代码揉在了一起。对于大多数的场景来讲,非业务的需求都是在http请求处理前做一些事情,并且在响应完成之后做一些事情。我们有没有办法使用一些重构思路把这些公共的非业务功能代码剥离出去呢?回到刚开头的例子,我们需要给我们的helloHandler()
增加超时时间统计,我们可以使用一种叫function adapter
的方法来对helloHandler()
进行包装:
func hello(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("hello"))
}
func timeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(wr http.ResponseWriter, r *http.Request) {
timeStart := time.Now()
// next handler
next.ServeHTTP(wr, r)
timeElapsed := time.Since(timeStart)
logger.Println(timeElapsed)
})
}
func main() {
http.Handle("/", timeMiddleware(http.HandlerFunc(hello)))
err := http.ListenAndServe(":8080", nil)
...
}
这样就非常轻松地实现了业务与非业务之间的剥离,魔法就在于这个timeMiddleware。可以从代码中看到,我们的timeMiddleware()也是一个函数,其参数为http.Handler
,http.Handler的定义在net/http包中:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
任何方法实现了ServeHTTP
,即是一个合法的http.Handle
r,读到这里你可能会有一些混乱,我们先来梳理一下http库的Handler,HandlerFunc和ServeHTTP的关系
:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
只要你的handler函数签名是:
func (ResponseWriter, *Request)
那么这个handler
和http.HandlerFunc()
就有了一致的函数签名,可以将该handler()
函数进行类型转换,转为http.HandlerFunc
。而http.HandlerFunc
实现了http.Handler
这个接口。在http库需要调用你的handler函数来处理http
请求时,会调用HandlerFunc()的ServeHTTP()
函数,可见一个请求的基本调用链是这样的:
h = getHandler() => h.ServeHTTP(w, r) => h(w, r)
上面提到的把自定义handler
转换为http.HandlerFunc()
这个过程是必须的,因为我们的handler
没有直接实现ServeHTTP
这个接口。上面的代码中我们看到的HandleFunc(注意HandlerFunc和HandleFunc的区别)
里也可以看到这个强制转换过程:
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
// 调用
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
mux.Handle(pattern, HandlerFunc(handler))
}
知道handler是怎么一回事,我们的中间件通过包装handler,再返回一个新的handler就好理解了。
总结一下,我们的中间件要做的事情就是通过一个或多个函数对handler进行包装,返回一个包括了各个中间件逻辑的函数链。我们把上面的包装再做得复杂一些:
customizedHandler = logger(timeout(ratelimit(helloHandler)))
这个函数链在执行过程中的上下文可以用下图表示
再直白一些,这个流程在进行请求处理的时候就是不断地进行函数压栈再出栈,有一些类似于递归的执行流:
[exec of logger logic] 函数栈: []
[exec of timeout logic] 函数栈: [logger]
[exec of ratelimit logic] 函数栈: [timeout/logger]
[exec of helloHandler logic] 函数栈: [ratelimit/timeout/logger]
[exec of ratelimit logic part2] 函数栈: [timeout/logger]
[exec of timeout logic part2] 函数栈: [logger]
[exec of logger logic part2] 函数栈: []
功能实现了,但在上面的使用过程中我们也看到了,这种函数套函数的用法不是很美观,同时也不具备什么可读性。
更优雅的中间件写法
上一节中解决了业务功能代码和非业务功能代码的解耦,但也提到了,看起来并不美观,如果需要修改这些函数的顺序,或者增删中间件还是有点费劲,本节我们来进行一些“写法”上的优化。
看一个例子:
r = NewRouter()
r.Use(logger)
r.Use(timeout)
r.Use(ratelimit)
r.Add("/", helloHandler)
通过多步设置,我们拥有了和上一节差不多的执行函数链。胜在直观易懂,如果我们要增加或者删除中间件,只要简单地增加删除对应的Use()
调用就可以了。非常方便。
从框架的角度来讲,怎么实现这样的功能呢?也不复杂:
type middleware func(http.Handler) http.Handler
type Router struct {
middlewareChain [] middleware
mux map[string] http.Handler
}
func NewRouter() *Router{
return &Router{}
}
func (r *Router) Use(m middleware) {
r.middlewareChain = append(r.middlewareChain, m)
}
func (r *Router) Add(route string, h http.Handler) {
var mergedHandler = h
for i := len(r.middlewareChain) - 1; i >= 0; i-- {
mergedHandler = r.middlewareChain[i](mergedHandler)
}
r.mux[route] = mergedHandler
}
注意代码中的middleware
数组遍历顺序,和用户希望的调用顺序应该是"相反"的。应该不难理解。
哪些事情适合在中间件中做
以较流行的开源Go语言框架chi为例:
compress.go
=> 对http的响应体进行压缩处理
heartbeat.go
=> 设置一个特殊的路由,例如/ping,/healthcheck,用来给负载均衡一类的前置服务进行探活
logger.go
=> 打印请求处理处理日志,例如请求处理时间,请求路由
profiler.go
=> 挂载pprof需要的路由,如`/pprof`、`/pprof/trace`到系统中
realip.go
=> 从请求头中读取X-Forwarded-For和X-Real-IP,将http.Request中的RemoteAddr修改为得到的RealIP
requestid.go
=> 为本次请求生成单独的requestid,可一路透传,用来生成分布式调用链路,也可用于在日志中串连单次请求的所有逻辑
timeout.go
=> 用context.Timeout设置超时时间,并将其通过http.Request一路透传下去
throttler.go
=> 通过定长大小的channel存储token,并通过这些token对接口进行限流
如果读者去阅读gin的源码的话,可能会发现gin的中间件中处理的并不是http.Handler,而是一个叫gin.HandlerFunc的函数类型,和本节中讲解的http.Handler签名并不一样。不过gin的handler也只是针对其框架的一种封装,中间件的原理与本节中的说明是一致的。
validator请求校验
社区里曾经有人用下图嘲笑PHP:
这其实是一个语言无关的场景,需要进行字段校验的情况有很多,Web系统的Form或JSON提交只是一个典型的例子。我们用Go来写一个类似上图的校验示例。然后研究怎么一步步对其进行改进。
重构请求校验函数
假设我们的数据已经通过某个开源绑定库绑定到了具体的结构体上。
type RegisterReq struct {
Username string `json:"username"`
PasswordNew string `json:"password_new"`
PasswordRepeat string `json:"password_repeat"`
Email string `json:"email"`
}
func register(req RegisterReq) error{
if len(req.Username) > 0 {
if len(req.PasswordNew) > 0 && len(req.PasswordRepeat) > 0 {
if req.PasswordNew == req.PasswordRepeat {
if emailFormatValid(req.Email) {
createUser()
return nil
} else {
return errors.New("invalid email")
}
} else {
return errors.New("password and reinput must be the same")
}
} else {
return errors.New("password and password reinput must be longer than 0")
}
} else {
return errors.New("length of username cannot be 0")
}
}
我们用Go里成功写出了波动拳开路的箭头型代码。。这种代码一般怎么进行优化呢?
很简单,在《重构》一书中已经给出了方案:https://refactoring.com/catalog/replaceNestedConditionalWithGuardClauses.html
func register(req RegisterReq) error{
if len(req.Username) == 0 {
return errors.New("length of username cannot be 0")
}
if len(req.PasswordNew) == 0 || len(req.PasswordRepeat) == 0 {
return errors.New("password and password reinput must be longer than 0")
}
if req.PasswordNew != req.PasswordRepeat {
return errors.New("password and reinput must be the same")
}
if emailFormatValid(req.Email) {
return errors.New("invalid email")
}
createUser()
return nil
}
代码更清爽,看起来也不那么别扭了。这是比较通用的重构理念。虽然使用了重构方法使我们的校验过程代码看起来优雅了,但我们还是得为每一个http
请求都去写这么一套差不多的validate()
函数,有没有更好的办法来帮助我们解除这项体力劳动?答案就是validator。
用validator解放体力劳动
从设计的角度讲,我们一定会为每个请求都声明一个结构体。前文中提到的校验场景我们都可以通过validator
完成工作。还以前文中的结构体为例。为了美观起见,我们先把json tag省略掉。
这里我们引入一个新的validator库:
https://github.com/go-playground/validator
使用 go get github.com/go-playground/validator/v10 可以下载 validator 库。
import "github.com/go-playground/validator/v10"
type RegisterReq struct {
// 字符串的 gt=0 表示长度必须 > 0,gt = greater than
Username string `validate:"gt=0"`
// 同上
PasswordNew string `validate:"gt=0"`
// eqfield 跨字段相等校验
PasswordRepeat string `validate:"eqfield=PasswordNew"`
// 合法 email 格式校验
Email string `validate:"email"`
}
var validate = validator.New()
func validateFunc(req RegisterReq) error {
err := validate.Struct(req)
if err != nil {
doSomething()
return err
}
...
}
这样就不需要在每个请求进入业务逻辑之前都写重复的validate()
函数了。本例中只列出了这个校验器非常简单的几个功能。
我们试着跑一下这个程序,输入参数设置为:
//...
var req = RegisterReq {
Username : "Xargin",
PasswordNew : "ohno",
PasswordRepeat : "ohn",
Email : "alex@abc.com",
}
err := validateFunc(req)
fmt.Println(err)
// Key: 'RegisterReq.PasswordRepeat' Error:Field validation for
// 'PasswordRepeat' failed on the 'eqfield' tag
如果觉得这个validator提供的错误信息不够人性化,例如要把错误信息返回给用户,那就不应该直接显示英文了。可以针对每种tag进行错误信息定制,读者可以自行探索。
原理
从结构上来看,每一个结构体都可以看成是一棵树。假如我们有如下定义的结构体:
type Nested struct {
Email string `validate:"email"`
}
type T struct {
Age int `validate:"eq=10"`
Nested Nested
}
把这个结构体画成一棵树,见下图
从字段校验的需求来讲,无论我们采用深度优先搜索还是广度优先搜索来对这棵结构体树来进行遍历,都是可以的。
我们来写一个递归的深度优先搜索方式的遍历示例:
package main
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
)
type Nested struct {
Email string `validate:"email"`
}
type T struct {
Age int `validate:"eq=10"`
Nested Nested
}
func validateEmail(input string) bool {
if pass, _ := regexp.MatchString(
`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, input,
); pass {
return true
}
return false
}
func validate(v interface{}) (bool, string) {
validateResult := true
errmsg := "success"
vt := reflect.TypeOf(v)
vv := reflect.ValueOf(v)
for i := 0; i < vv.NumField(); i++ {
fieldVal := vv.Field(i)
tagContent := vt.Field(i).Tag.Get("validate")
k := fieldVal.Kind()
switch k {
case reflect.Int:
val := fieldVal.Int()
tagValStr := strings.Split(tagContent, "=")
tagVal, _ := strconv.ParseInt(tagValStr[1], 10, 64)
if val != tagVal {
errmsg = "validate int failed, tag is: "+ strconv.FormatInt(
tagVal, 10,
)
validateResult = false
}
case reflect.String:
val := fieldVal.String()
tagValStr := tagContent
switch tagValStr {
case "email":
nestedResult := validateEmail(val)
if nestedResult == false {
errmsg = "validate mail failed, field val is: "+ val
validateResult = false
}
}
case reflect.Struct:
// 如果有内嵌的 struct,那么深度优先遍历
// 就是一个递归过程
valInter := fieldVal.Interface()
nestedResult, msg := validate(valInter)
if nestedResult == false {
validateResult = false
errmsg = msg
}
}
}
return validateResult, errmsg
}
func main() {
var a = T{Age: 10, Nested: Nested{Email: "abc@abc.com"}}
validateResult, errmsg := validate(a)
fmt.Println(validateResult, errmsg)
}
这里我们简单地对eq=x和email这两个tag进行了支持,读者可以对这个程序进行简单的修改以查看具体的validate效果。为了演示精简掉了错误处理和复杂情况的处理,例如reflect.Int8/16/32/64,reflect.Ptr等类型的处理,如果给生产环境编写校验库的话,请务必做好功能的完善和容错。
在前一小节中介绍的开源校验组件在功能上要远比我们这里的例子复杂的多。但原理很简单,就是用反射对结构体进行树形遍历。有心的读者这时候可能会产生一个问题,我们对结构体进行校验时大量使用了反射,而Go的反射在性能上不太出众,有时甚至会影响到我们程序的性能。这样的考虑确实有一些道理,但需要对结构体进行大量校验的场景往往出现在Web服务,这里并不一定是程序的性能瓶颈所在,实际的效果还是要从pprof中做更精确的判断。
如果基于反射的校验真的成为了你服务的性能瓶颈怎么办?现在也有一种思路可以避免反射:使用Go内置的Parser对源代码进行扫描,然后根据结构体的定义生成校验代码。我们可以将所有需要校验的结构体放在单独的包内。这就交给读者自己去探索了。
Database 和数据库打交道
本节将对db/sql
官方标准库作一些简单分析,并介绍一些应用比较广泛的开源ORM和SQL Builder。并从企业级应用开发和公司架构的角度来分析哪种技术栈对于现代的企业级应用更为合适。
从 database/sql 讲起
Go官方提供了database/sql
包来给用户进行和数据库打交道的工作,database/sql
库实际只提供了一套操作数据库的接口和规范,例如抽象好的SQL预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。
和具体的数据库,例如MySQL打交道,还需要再引入MySQL的驱动,像下面这样:
import "database/sql"
import _ "github.com/go-sql-driver/mysql"
db, err := sql.Open("mysql", "user:password@/dbname")
import _ "github.com/go-sql-driver/mysql"
这条import语句会调用了mysql包的init函数,做的事情也很简单:
func init() {
sql.Register("mysql", &MySQLDriver{})
}
在sql
包的全局map
里把mysql
这个名字的driver
注册上。Driver
在sql
包中是一个接口:
type Driver interface {
Open(name string) (Conn, error)
}
调用sql.Open()
返回的db对象就是这里的Conn。
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
也是一个接口。如果你仔细地查看database/sql/driver/driver.go
的代码会发现,这个文件里所有的成员全都是接口,对这些类型进行操作,还是会调用具体的driver
里的方法。
从用户的角度来讲,在使用database/sql
包的过程中,你能够使用的也就是这些接口里提供的函数。来看一个使用database/sql
和go-sql-driver/mysql
的完整的例子:
package main
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// db 是一个 sql.DB 类型的对象
// 该对象线程安全,且内部已包含了一个连接池
// 连接池的选项可以在 sql.DB 的方法中设置,这里为了简单省略了
db, err := sql.Open("mysql",
"user:password@tcp(127.0.0.1:3306)/hello")
if err != nil {
log.Fatal(err)
}
defer db.Close()
var (
id int
name string
)
rows, err := db.Query("select id, name from users where id = ?", 1)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
// 必须要把 rows 里的内容读完,或者显式调用 Close() 方法,
// 否则在 defer 的 rows.Close() 执行之前,连接永远不会释放
for rows.Next() {
err := rows.Scan(&id, &name)
if err != nil {
log.Fatal(err)
}
log.Println(id, name)
}
err = rows.Err()
if err != nil {
log.Fatal(err)
}
}
如果读者想了解官方这个database/sql库更加详细的用法的话,可以参考http://go-database-sql.org/
。
包括该库的功能介绍、用法、注意事项和反直觉的一些实现方式(例如同一个goroutine内对sql.DB
的查询,可能在多个连接上)都有涉及,本章中不再赘述。
聪明如你的话,在上面这段简短的程序中可能已经嗅出了一些不好的味道。官方的db库提供的功能这么简单,我们每次去数据库里读取内容岂不是都要去写这么一套差不多的代码?或者如果我们的对象是结构体,把sql.Rows绑定到对象的工作就会变得更加得重复而无聊。
是的,所以社区才会有各种各样的SQL Builder和ORM百花齐放。
提高生产效率的ORM和SQL Builder
在Web开发领域常常提到的ORM是什么?我们先看看万能的维基百科:
对象关系映射(英语:Object Relational Mapping,简称ORM,或O/RM,或O/R mapping),
是一种程序设计技术,用于实现面向对象编程语言里不同类型系统的数据之间的转换。
从效果上说,它其实是创建了一个可在编程语言里使用的“虚拟对象数据库”。
最为常见的ORM做的是从db到程序的类或结构体这样的映射。所以你手边的程序可能是从MySQL的表映射你的程序内的类。我们可以先来看看其它的程序语言里的ORM写起来是怎么样的感觉:
>>> from blog.models import Blog
>>> b = Blog(name='Beatles Blog', tagline='All the latest Beatles news.')
>>> b.save()
完全没有数据库的痕迹,没错,ORM的目的就是屏蔽掉DB层,很多语言的ORM只要把你的类或结构体定义好,再用特定的语法将结构体之间的一对一或者一对多关系表达出来。那么任务就完成了。然后你就可以对这些映射好了数据库表的对象进行各种操作,例如save,create,retrieve,delete。至于ORM在背地里做了什么阴险的勾当,你是不一定清楚的。使用ORM的时候,我们往往比较容易有一种忘记了数据库的直观感受。举个例子,我们有个需求:向用户展示最新的商品列表,我们再假设,商品和商家是1:1的关联关系,我们就很容易写出像下面这样的代码:
# 伪代码
shopList := []
for product in productList {
shopList = append(shopList, product.GetShop)
}
当然了,我们不能批判这样写代码的程序员是偷懒的程序员。因为ORM一类的工具在出发点上就是屏蔽sql,让我们对数据库的操作更接近于人类的思维方式。这样很多只接触过ORM而且又是刚入行的程序员就很容易写出上面这样的代码。
这样的代码将对数据库的读请求放大了N倍。也就是说,如果你的商品列表有15个SKU,那么每次用户打开这个页面,至少需要执行1(查询商品列表)+ 15(查询相关的商铺信息)次查询。这里N是16。如果你的列表页很大,比如说有600个条目,那么你就至少要执行1+600次查询。如果说你的数据库能够承受的最大的简单查询是12万QPS,而上述这样的查询正好是你最常用的查询的话,你能对外提供的服务能力是多少呢?是200 qps!互联网系统的忌讳之一,就是这种无端的读放大。
当然,你也可以说这不是ORM的问题,如果你手写sql你还是可能会写出差不多的程序,那么再来看两个demo:
o := orm.NewOrm()
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
很多ORM都提供了这种Filter类型的查询方式,不过在某些ORM背后可能隐藏了非常难以察觉的细节,比如生成的SQL语句会自动limit 1000。
也许喜欢ORM的读者读到这里会反驳了,你是没有认真阅读文档就瞎写。是的,尽管这些ORM工具在文档里说明了All查询在不显式地指定Limit的话会自动limit 1000,但对于很多没有阅读过文档或者看过ORM源码的人,这依然是一个非常难以察觉的“魔鬼”细节。喜欢强类型语言的人一般都不喜欢语言隐式地去做什么事情,例如各种语言在赋值操作时进行的隐式类型转换然后又在转换中丢失了精度的勾当,一定让你非常的头疼。所以一个程序库背地里做的事情还是越少越好,如果一定要做,那也一定要在显眼的地方做。比如上面的例子,去掉这种默认的自作聪明的行为,或者要求用户强制传入limit参数都是更好的选择。
除了limit的问题,我们再看一遍这个下面的查询:
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)
你可以看得出来这个Filter是有表join的操作么?当然了,有深入使用经验的用户还是会觉得这是在吹毛求疵。但这样的分析想证明的是,ORM想从设计上隐去太多的细节。而方便的代价是其背后的运行完全失控。这样的项目在经过几任维护人员之后,将变得面目全非,难以维护。
当然,我们不能否认ORM的进步意义,它的设计初衷就是为了让数据的操作和存储的具体实现相剥离。但是在上了规模的公司的人们渐渐达成了一个共识,由于隐藏重要的细节,ORM可能是失败的设计。其所隐藏的重要细节对于上了规模的系统开发来说至关重要。
相比ORM来说,SQL Builder在SQL和项目可维护性之间取得了比较好的平衡。首先sql builder不像ORM那样屏蔽了过多的细节,其次从开发的角度来讲,SQL Builder进行简单封装后也可以非常高效地完成开发,举个例子:
where := map[string]interface{} {
"order_id > ?" : 0,
"customer_id != ?" : 0,
}
limit := []int{0,100}
orderBy := []string{"id asc", "create_time desc"}
orders := orderModel.GetList(where, limit, orderBy)
写SQL Builder的相关代码,或者读懂都不费劲。把这些代码脑内转换为sql也不会太费劲。所以通过代码就可以对这个查询是否命中数据库索引,是否走了覆盖索引,是否能够用上联合索引进行分析了。
说白了SQL Builder是sql在代码里的一种特殊方言,如果你们没有DBA但研发有自己分析和优化sql的能力,或者你们公司的DBA对于学习这样一些sql的方言没有异议。那么使用SQL Builder是一个比较好的选择,不会导致什么问题。
另外在一些本来也不需要DBA介入的场景内,使用SQL Builder也是可以的,例如你要做一套运维系统,且将MySQL当作了系统中的一个组件,系统的QPS不高,查询不复杂等等。
一旦你做的是高并发的OLTP在线系统,且想在人员充足分工明确的前提下最大程度控制系统的风险,使用SQL Builder就不合适了。
脆弱的数据库
无论是ORM还是SQL Builder都有一个致命的缺点,就是没有办法进行系统上线的事前sql审核。虽然很多ORM和SQL Builder也提供了运行期打印sql的功能,但只在查询的时候才能进行输出。而SQL Builder和ORM本身提供的功能太过灵活。使得你不可能通过测试枚举出所有可能在线上执行的sql。例如你可能用SQL Builder写出下面这样的代码:
where := map[string]interface{} {
"product_id = ?" : 10,
"user_id = ?" : 1232 ,
}
if order_id != 0 {
where["order_id = ?"] = order_id
}
res, err := historyModel.GetList(where, limit, orderBy)
你的系统里有大量类似上述样例的if
的话,就难以通过测试用例来覆盖到所有可能的sql组合了。
这样的系统只要发布,就已经孕育了初期的巨大风险。
对于现在7乘24服务的互联网公司来说,服务不可用是非常重大的问题。存储层的技术栈虽经历了多年的发展,在整个系统中依然是最为脆弱的一环。系统宕机对于24小时对外提供服务的公司来说,意味着直接的经济损失。个中风险不可忽视。
从行业分工的角度来讲,现今的互联网公司都有专职的DBA。大多数DBA并不一定有写代码的能力,去阅读SQL Builder的相关“拼SQL”代码多多少少还是会有一点障碍。从DBA角度出发,还是希望能够有专门的事前SQL审核机制,并能让其低成本地获取到系统的所有SQL内容,而不是去阅读业务研发编写的SQL Builder的相关代码。
所以现如今,大型的互联网公司核心线上业务都会在代码中把SQL放在显眼的位置提供给DBA评审,举一个例子:
const (
getAllByProductIDAndCustomerID = `select * from p_orders where product_id in (:product_id) and customer_id=:customer_id`
)
// GetAllByProductIDAndCustomerID
// @param driver_id
// @param rate_date
// @return []Order, error
func GetAllByProductIDAndCustomerID(ctx context.Context, productIDs []uint64, customerID uint64) ([]Order, error) {
var orderList []Order
params := map[string]interface{}{
"product_id" : productIDs,
"customer_id": customerID,
}
// getAllByProductIDAndCustomerID 是 const 类型的 sql 字符串
sql, args, err := sqlutil.Named(getAllByProductIDAndCustomerID, params)
if err != nil {
return nil, err
}
err = dao.QueryList(ctx, sqldbInstance, sql, args, &orderList)
if err != nil {
return nil, err
}
return orderList, err
}
像这样的代码,在上线之前把DAO层的变更集的const部分直接拿给DBA来进行审核,就比较方便了。代码中的 sqlutil.Named 是类似于 sqlx 中的 Named 函数,同时支持 where 表达式中的比较操作符和 in。
这里为了说明简便,函数写得稍微复杂一些,仔细思考一下的话查询的导出函数还可以进一步进行简化。请读者朋友们自行尝试。
Ratelimit 服务流量限制
计算机程序可依据其瓶颈分为磁盘IO瓶颈型,CPU计算瓶颈型,网络带宽瓶颈型,分布式场景下有时候也会外部系统而导致自身瓶颈。
Web系统打交道最多的是网络,无论是接收,解析用户请求,访问存储,还是把响应数据返回给用户,都是要走网络的。在没有epoll/kqueue
之类的系统提供的IO多路复用接口之前,多个核心的现代计算机最头痛的是C10k问题,C10k问题会导致计算机没有办法充分利用CPU来处理更多的用户连接,进而没有办法通过优化程序提升CPU利用率来处理更多的请求。
自从Linux实现了epoll
,FreeBSD实现了kqueue
,这个问题基本解决了,我们可以借助内核提供的API轻松解决当年的C10k问题,也就是说如今如果你的程序主要是和网络打交道,那么瓶颈一定在用户程序而不在操作系统内核。
随着时代的发展,编程语言对这些系统调用又进一步进行了封装,如今做应用层开发,几乎不会在程序中看到epoll
之类的字眼,大多数时候我们就只要聚焦在业务逻辑上就好。Go 的net
库针对不同平台封装了不同的syscall API,http
库又是构建在net
库之上,所以在Go语言中我们可以借助标准库,很轻松地写出高性能的http服务,下面是一个简单的hello world服务的代码:
package main
import (
"io"
"log"
"net/http"
)
func sayhello(wr http.ResponseWriter, r *http.Request) {
wr.WriteHeader(200)
io.WriteString(wr, "hello world")
}
func main() {
http.HandleFunc("/", sayhello)
err := http.ListenAndServe(":9090", nil)
if err != nil {
log.Fatal("ListenAndServe:", err)
}
}
我们需要衡量一下这个Web服务的吞吐量,再具体一些,就是接口的QPS。借助wrk
,在家用电脑 Macbook Pro上对这个 hello world 服务进行基准测试,Mac的硬件情况如下:
CPU: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
Core: 2
Threads: 4
Graphics/Displays:
Chipset Model: Intel Iris Graphics 6100
Resolution: 2560 x 1600 Retina
Memory Slots:
Size: 4 GB
Speed: 1867 MHz
Size: 4 GB
Speed: 1867 MHz
Storage:
Size: 250.14 GB (250,140,319,744 bytes)
Media Name: APPLE SSD SM0256G Media
Size: 250.14 GB (250,140,319,744 bytes)
Medium Type: SSD
测试结果:
~ ❯❯❯ wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 339.99us 1.28ms 44.43ms 98.29%
Req/Sec 4.49k 656.81 7.47k 73.36%
449588 requests in 10.10s, 54.88MB read
Requests/sec: 44513.22
Transfer/sec: 5.43MB
~ ❯❯❯ wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 334.76us 1.21ms 45.47ms 98.27%
Req/Sec 4.42k 633.62 6.90k 71.16%
443582 requests in 10.10s, 54.15MB read
Requests/sec: 43911.68
Transfer/sec: 5.36MB
~ ❯❯❯ wrk -c 10 -d 10s -t10 http://localhost:9090
Running 10s test @ http://localhost:9090
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 379.26us 1.34ms 44.28ms 97.62%
Req/Sec 4.55k 591.64 8.20k 76.37%
455710 requests in 10.10s, 55.63MB read
Requests/sec: 45118.57
Transfer/sec: 5.51MB
多次测试的结果在4万左右的QPS浮动,响应时间最多也就是40ms左右,对于一个Web程序来说,这已经是很不错的成绩了,我们只是照抄了别人的示例代码,就完成了一个高性能的hello world服务器,是不是很有成就感?
这还只是家用PC,线上服务器大多都是24核心起,32G内存+,CPU基本都是Intel i7。所以同样的程序在服务器上运行会得到更好的结果。
这里的hello world
服务没有任何业务逻辑。真实环境的程序要复杂得多,有些程序偏网络IO瓶颈,例如一些CDN服务、Proxy服务;有些程序偏CPU/GPU瓶颈,例如登陆校验服务、图像处理服务;有些程序瓶颈偏磁盘,例如专门的存储系统,数据库。不同的程序瓶颈会体现在不同的地方,这里提到的这些功能单一的服务相对来说还算容易分析。如果碰到业务逻辑复杂代码量巨大的模块,其瓶颈并不是三下五除二可以推测出来的,还是需要从压力测试中得到更为精确的结论。
对于IO/Network
瓶颈类的程序,其表现是网卡/磁盘IO会先于CPU打满,这种情况即使优化CPU的使用也不能提高整个系统的吞吐量,只能提高磁盘的读写速度,增加内存大小,提升网卡的带宽来提升整体性能。而CPU瓶颈类的程序,则是在存储和网卡未打满之前CPU占用率先到达100%,CPU忙于各种计算任务,IO设备相对则较闲。
无论哪种类型的服务,在资源使用到极限的时候都会导致请求堆积,超时,系统hang死,最终伤害到终端用户。对于分布式的Web服务来说,瓶颈还不一定总在系统内部,也有可能在外部。非计算密集型的系统往往会在关系型数据库环节失守,而这时候Web模块本身还远远未达到瓶颈。
不管我们的服务瓶颈在哪里,最终要做的事情都是一样的,那就是流量限制。
常见的流量限制手段
流量限制的手段有很多,最常见的:漏桶、令牌桶两种:
1、漏桶是指我们有一个一直装满了水的桶,每过固定的一段时间即向外漏一滴水。如果你接到了这滴水,那么你就可以继续服务请求,如果没有接到,那么就需要等待下一滴水。
2、令牌桶则是指匀速向桶中添加令牌,服务请求时需要从桶中获取令牌,令牌的数目可以按照需要消耗的资源进行相应的调整。如果没有令牌,可以选择等待,或者放弃。
这两种方法看起来很像,不过还是有区别的。漏桶流出的速率固定,而令牌桶只要在桶中有令牌,那就可以拿。也就是说令牌桶是允许一定程度的并发的,比如同一个时刻,有100个用户请求,只要令牌桶中有100个令牌,那么这100个请求全都会放过去。令牌桶在桶中没有令牌的情况下也会退化为漏桶模型。
实际应用中令牌桶应用较为广泛,开源界流行的限流器大多数都是基于令牌桶思想的。并且在此基础上进行了一定程度的扩充,比如github.com/juju/ratelimit
提供了几种不同特色的令牌桶填充方式:
func NewBucket(fillInterval time.Duration, capacity int64) *Bucket
默认的令牌桶,fillInterval指每过多长时间向桶里放一个令牌,capacity是桶的容量,超过桶容量的部分会被直接丢弃。桶初始是满的。
func NewBucketWithQuantum(fillInterval time.Duration, capacity, quantum int64) *Bucket
和普通的NewBucket()的区别是,每次向桶中放令牌时,是放quantum个令牌,而不是一个令牌。
func NewBucketWithRate(rate float64, capacity int64) *Bucket
这个就有点特殊了,会按照提供的比例,每秒钟填充令牌数。例如capacity是100,而rate是0.1,那么每秒会填充10个令牌。
从桶中获取令牌也提供了几个API:
func (tb *Bucket) Take(count int64) time.Duration {}
func (tb *Bucket) TakeAvailable(count int64) int64 {}
func (tb *Bucket) TakeMaxDuration(count int64, maxWait time.Duration) (
time.Duration, bool,
) {}
func (tb *Bucket) Wait(count int64) {}
func (tb *Bucket) WaitMaxDuration(count int64, maxWait time.Duration) bool {}
名称和功能都比较直观,这里就不再赘述了。相比于开源界更为有名的Google的Java工具库Guava中提供的ratelimiter,这个库不支持令牌桶预热,且无法修改初始的令牌容量,所以可能个别极端情况下的需求无法满足。但在明白令牌桶的基本原理之后,如果没办法满足需求,相信你也可以很快对其进行修改并支持自己的业务场景。
原理
从功能上来看,令牌桶模型就是对全局计数的加减法操作过程,但使用计数需要我们自己加读写锁,有小小的思想负担。如果我们对Go语言已经比较熟悉的话,很容易想到可以用buffered channel来完成简单的加令牌取令牌操作:
var tokenBucket = make(chan struct{}, capacity)
每过一段时间向tokenBucket
中添加token
,如果bucket
已经满了,那么直接放弃:
fillToken := func() {
ticker := time.NewTicker(fillInterval)
for {
select {
case <-ticker.C:
select {
case tokenBucket <- struct{}{}:
default:
}
fmt.Println("current token cnt:", len(tokenBucket), time.Now())
}
}
}
把代码组合起来:
package main
import (
"fmt"
"time"
)
func main() {
var fillInterval = time.Millisecond * 10
var capacity = 100
var tokenBucket = make(chan struct{}, capacity)
fillToken := func() {
ticker := time.NewTicker(fillInterval)
for {
select {
case <-ticker.C:
select {
case tokenBucket <- struct{}{}:
default:
}
fmt.Println("current token cnt:", len(tokenBucket), time.Now())
}
}
}
go fillToken()
time.Sleep(time.Hour)
}
看看运行结果:
current token cnt: 98 2018-06-16 18:17:50.234556981 +0800 CST m=+0.981524018
current token cnt: 99 2018-06-16 18:17:50.243575354 +0800 CST m=+0.990542391
current token cnt: 100 2018-06-16 18:17:50.254628067 +0800 CST m=+1.001595104
current token cnt: 100 2018-06-16 18:17:50.264537143 +0800 CST m=+1.011504180
current token cnt: 100 2018-06-16 18:17:50.273613018 +0800 CST m=+1.020580055
current token cnt: 100 2018-06-16 18:17:50.2844406 +0800 CST m=+1.031407637
current token cnt: 100 2018-06-16 18:17:50.294528695 +0800 CST m=+1.041495732
current token cnt: 100 2018-06-16 18:17:50.304550145 +0800 CST m=+1.051517182
current token cnt: 100 2018-06-16 18:17:50.313970334 +0800 CST m=+1.060937371
在1s钟的时候刚好填满100个,没有太大的偏差。不过这里可以看到,Go的定时器存在大约0.001s的误差,所以如果令牌桶大小在1000以上的填充可能会有一定的误差。对于一般的服务来说,这一点误差无关紧要。
上面的令牌桶的取令牌操作实现起来也比较简单,简化问题,我们这里只取一个令牌:
func TakeAvailable(block bool) bool{
var takenResult bool
if block {
select {
case <-tokenBucket:
takenResult = true
}
} else {
select {
case <-tokenBucket:
takenResult = true
default:
takenResult = false
}
}
return takenResult
}
一些公司自己造的限流的轮子就是用上面这种方式来实现的,不过如果开源版 ratelimit 也如此的话,那我们也没什么可说的了。现实并不是这样的。
我们来思考一下,令牌桶每隔一段固定的时间向桶中放令牌,如果我们记下上一次放令牌的时间为 t1,和当时的令牌数k1,放令牌的时间间隔为ti,每次向令牌桶中放x个令牌,令牌桶容量为cap。现在如果有人来调用TakeAvailable来取n个令牌,我们将这个时刻记为t2。在t2时刻,令牌桶中理论上应该有多少令牌呢?伪代码如下:
cur = k1 + ((t2 - t1)/ti) * x
cur = cur > cap ? cap : cur
我们用两个时间点的时间差,再结合其它的参数,理论上在取令牌之前就完全可以知道桶里有多少令牌了。那劳心费力地像本小节前面向channel里填充token的操作,理论上是没有必要的。只要在每次Take的时候,再对令牌桶中的token数进行简单计算,就可以得到正确的令牌数。是不是很像惰性求值的感觉?
在得到正确的令牌数之后,再进行实际的Take操作就好,这个Take操作只需要对令牌数进行简单的减法即可,记得加锁以保证并发安全。github.com/juju/ratelimit这个库就是这样做的。
服务瓶颈和 QoS
前面我们说了很多CPU瓶颈、IO瓶颈之类的概念,这种性能瓶颈从大多数公司都有的监控系统中可以比较快速地定位出来,如果一个系统遇到了性能问题,那监控图的反应一般都是最快的。
虽然性能指标很重要,但对用户提供服务时还应考虑服务整体的QoS。QoS全称是Quality of Service,顾名思义是服务质量。QoS包含有可用性、吞吐量、时延、时延变化和丢失等指标。一般来讲我们可以通过优化系统,来提高Web服务的CPU利用率,从而提高整个系统的吞吐量。但吞吐量提高的同时,用户体验是有可能变差的。用户角度比较敏感的除了可用性之外,还有时延。虽然你的系统吞吐量高,但半天刷不开页面,想必会造成大量的用户流失。所以在大公司的Web服务性能指标中,除了平均响应时延之外,还会把响应时间的95分位,99分位也拿出来作为性能标准。平均响应在提高CPU利用率没受到太大影响时,可能95分位、99分位的响应时间大幅度攀升了,那么这时候就要考虑提高这些CPU利用率所付出的代价是否值得了。
在线系统的机器一般都会保持CPU有一定的余裕。
layout 常见大型 Web 项目分层
流行的Web框架大多数是MVC框架,MVC这个概念最早由Trygve Reenskaug在1978年提出,为了能够对GUI类型的应用进行方便扩展,将程序划分为:
1、控制器(Controller)- 负责转发请求,对请求进行处理。
2、视图(View) - 界面设计人员进行图形界面设计。
3、模型(Model) - 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)。
随着时代的发展,前端也变成了越来越复杂的工程,为了更好地工程化,现在更为流行的一般是前后分离的架构。可以认为前后分离是把V层从MVC中抽离单独成为项目。这样一个后端项目一般就只剩下 M和C层了。前后端之间通过ajax来交互,有时候要解决跨域的问题,但也已经有了较为成熟的方案。下图 是一个前后分离的系统的简易交互图。
图里的Vue和React是现在前端界比较流行的两个框架,因为我们的重点不在这里,所以前端项目内的组织我们就不强调了。事实上,即使是简单的项目,业界也并没有完全遵守MVC框架提出者对于M和C所定义的分工。有很多公司的项目会在Controller层塞入大量的逻辑,在Model层就只管理数据的存储。这往往来源于对于model层字面含义的某种擅自引申理解。认为字面意思,这一层就是处理某种建模,而模型是什么?就是数据呗!
这种理解显然是有问题的,业务流程也算是一种“模型”,是对真实世界用户行为或者既有流程的一种建模,并非只有按格式组织的数据才能叫模型。不过按照MVC的创始人的想法,我们如果把和数据打交道的代码还有业务流程全部塞进MVC里的M层的话,这个M层又会显得有些过于臃肿。对于复杂的项目,一个C和一个M层显然是不够用的,现在比较流行的纯后端API模块一般采用下述划分方法:
1、Controller,与上述类似,服务入口,负责处理路由,参数校验,请求转发。
2、Logic/Service,逻辑(服务)层,一般是业务逻辑的入口,可以认为从这里开始,所有的请求参数一定是合法的。业务逻辑和业务流程也都在这一层中。常见的设计中会将该层称为 Business Rules。
3、DAO/Repository,这一层主要负责和数据、存储打交道。将下层存储以更简单的函数、接口形式暴露给 Logic 层来使用。负责数据的持久化工作。
每一层都会做好自己的工作,然后用请求当前的上下文构造下一层工作所需要的结构体或其它类型参数,然后调用下一层的函数。在工作完成之后,再把处理结果一层层地传出到入口,如下图所示。
划分为CLD三层之后,在C层之前我们可能还需要同时支持多种协议。本章前面讲到的thrift、gRPC和http并不是一定只选择其中一种,有时我们需要支持其中的两种,比如同一个接口,我们既需要效率较高的thrift,也需要方便debug的http入口。即除了CLD之外,还需要一个单独的protocol层,负责处理各种交互协议的细节。这样请求的流程会变成下图所示。
这样我们Controller中的入口函数就变成了下面这样:
func CreateOrder(ctx context.Context, req *CreateOrderStruct) (
*CreateOrderRespStruct, error,
) {
// ...
}
CreateOrder
有两个参数,ctx用来传入trace_id一类的需要串联请求的全局参数,req
里存储了我们创建订单所需要的所有输入信息。返回结果是一个响应结构体和错误。可以认为,我们的代码运行到Controller层之后,就没有任何与“协议”相关的代码了。在这里你找不到http.Request,也找不到http.ResponseWriter,也找不到任何与thrift或者gRPC相关的字眼。
在协议(Protocol)层,处理http协议的大概代码如下:
// defined in protocol layer
type CreateOrderRequest struct {
OrderID int64 `json:"order_id"`
// ...
}
// defined in controller
type CreateOrderParams struct {
OrderID int64
}
func HTTPCreateOrderHandler(wr http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
var params CreateOrderParams
ctx := context.TODO()
// bind data to req
bind(r, &req)
// map protocol binded to protocol-independent
map(req, params)
logicResp,err := controller.CreateOrder(ctx, ¶ms)
if err != nil {}
// ...
}
理论上我们可以用同一个请求结构体组合上不同的tag,来达到一个结构体来给不同的协议复用的目的。不过遗憾的是在thrift中,请求结构体也是通过IDL生成的,其内容在自动生成的ttypes.go文件中,我们还是需要在thrift的入口将这个自动生成的结构体映射到我们logic入口所需要的结构体上。gRPC也是类似。这部分代码还是需要的。
聪明的读者可能已经可以看出来了,协议细节处理这一层有大量重复劳动,每一个接口在协议这一层的处理,无非是把数据从协议特定的结构体(例如http.Request
,thrift的被包装过了) 读出来,再绑定到我们协议无关的结构体上,再把这个结构体映射到Controller入口的结构体上,这些代码长得都差不多。差不多的代码都遵循着某种模式,那么我们可以对这些模式进行简单的抽象,用代码生成的方式,把繁复的协议处理代码从工作内容中抽离出去。
先来看看HTTP对应的结构体、thrift对应的结构体和我们协议无关的结构体分别长什么样子:
// http 请求结构体
type CreateOrder struct {
OrderID int64 `json:"order_id" validate:"required"`
UserID int64 `json:"user_id" validate:"required"`
ProductID int `json:"prod_id" validate:"required"`
Addr string `json:"addr" validate:"required"`
}
// thrift 请求结构体
type FeatureSetParams struct {
DriverID int64 `thrift:"driverID,1,required"`
OrderID int64 `thrift:"OrderID,2,required"`
UserID int64 `thrift:"UserID,3,required"`
ProductID int `thrift:"ProductID,4,required"`
Addr string `thrift:"Addr,5,required"`
}
// controller input struct
type CreateOrderParams struct {
OrderID int64
UserID int64
ProductID int
Addr string
}
我们需要通过一个源结构体来生成我们需要的HTTP和thrift入口代码。再观察一下上面定义的三种结构体,我们只要能用一个结构体生成thrift的IDL,以及HTTP服务的“IDL(只要能包含json或form相关tag的结构体定义信息)” 就可以了。这个初始的结构体我们可以把结构体上的HTTP的tag和thrift的tag揉在一起:
type FeatureSetParams struct {
DriverID int64 `thrift:"driverID,1,required" json:"driver_id"`
OrderID int64 `thrift:"OrderID,2,required" json:"order_id"`
UserID int64 `thrift:"UserID,3,required" json:"user_id"`
ProductID int `thrift:"ProductID,4,required" json:"prod_id"`
Addr string `thrift:"Addr,5,required" json:"addr"`
}
然后通过代码生成把thrift的IDL和HTTP的请求结构体都生成出来
至于用什么手段来生成,你可以通过Go语言内置的Parser读取文本文件中的Go源代码,然后根据AST来生成目标代码,也可以简单地把这个源结构体和Generator的代码放在一起编译,让结构体作为Generator的输入参数(这样会更简单一些),都是可以的。
当然这种思路并不是唯一选择,我们还可以通过解析thrift的IDL,生成一套HTTP接口的结构体。如果你选择这么做,那整个流程就变成了
看起来比之前的图顺畅一点,不过如果你选择了这么做,你需要自行对thrift的IDL进行解析,也就是相当于可能要手写一个thrift的IDL的Parser,虽然现在有Antlr或者peg能帮你简化这些Parser的书写工作,但在“解析”的这一步我们不希望引入太多的工作量,所以量力而行即可。
既然工作流已经成型,我们可以琢磨一下怎么让整个流程对用户更加友好。
虽然我们成功地使自己的项目在入口支持了多种交互协议,但是还有一些问题没有解决。本节中所叙述的分层没有将中间件作为项目的分层考虑进去。如果我们考虑中间件的话,请求的流程是什么样的?见下图
之前我们学习的中间件是和HTTP协议强相关的,遗憾的是在thrift中看起来没有和HTTP中对等的解决这些非功能性逻辑代码重复问题的中间件。所以我们在图上写thrift stuff。这些stuff可能需要你手写去实现,然后每次增加一个新的thrift接口,就需要去写一遍这些非功能性代码。
这也是很多企业项目所面临的真实问题,遗憾的是开源界并没有这样方便的多协议中间件解决方案。当然了,前面我们也说过,很多时候我们给自己保留的HTTP接口只是用来做调试,并不会暴露给外人用。这种情况下,这些非功能性的代码只要在thrift的代码中完成即可。
接口和表驱动开发
在Web项目中经常会遇到外部依赖环境的变化,比如:
1、公司的老存储系统年久失修,现在已经没有人维护了,新的系统上线也没有考虑平滑迁移,但最后通牒已下,要求N天之内迁移完毕。
2、平台部门的老用户系统年久失修,现在已经没有人维护了,真是悲伤的故事。新系统上线没有考虑兼容老接口,但最后通牒已下,要求N个月之内迁移完毕。
3、公司的老消息队列人走茶凉,年久失修,新来的技术精英们没有考虑向前兼容,但最后通牒已下,要求半年之内迁移完毕。
嗯,所以你看到了,我们的外部依赖总是为了自己爽而不断地做升级,且不想做向前兼容,然后来给我们下最后通牒。如果我们的部门工作饱和,领导强势,那么有时候也可以倒逼依赖方来做兼容。但世事不一定如人愿,即使我们的领导强势,读者朋友的领导也还是可能认怂的。
业务系统的发展过程
互联网公司只要可以活过三年,工程方面面临的首要问题就是代码膨胀。系统的代码膨胀之后,可以将系统中与业务本身流程无关的部分做拆解和异步化。什么算是业务无关呢,比如一些统计、反作弊、营销发券、价格计算、用户状态更新等等需求。这些需求往往依赖于主流程的数据,但又只是挂在主流程上的旁支,自成体系。
这时候我们就可以把这些旁支拆解出去,作为独立的系统来部署、开发以及维护。这些旁支流程的时延如若非常敏感,比如用户在界面上点了按钮,需要立刻返回(价格计算、支付),那么需要与主流程系统进行RPC通信,并且在通信失败时,要将结果直接返回给用户。如果时延不敏感,比如抽奖系统,结果稍后公布的这种,或者非实时的统计类系统,那么就没有必要在主流程里为每一套系统做一套RPC流程。我们只要将下游需要的数据打包成一条消息,传入消息队列,之后的事情与主流程一概无关(当然,与用户的后续交互流程还是要做的)。
通过拆解和异步化虽然解决了一部分问题,但并不能解决所有问题。随着业务发展,单一职责的模块也会变得越来越复杂,这是必然的趋势。一件事情本身变的复杂的话,这时候拆解和异步化就不灵了。我们还是要对事情本身进行一定程度的封装抽象。
使用函数封装业务流程
最基本的封装过程,我们把相似的行为放在一起,然后打包成一个一个的函数,让自己杂乱无章的代码变成下面这个样子:
func BusinessProcess(ctx context.Context, params Params) (resp, error){
ValidateLogin()
ValidateParams()
AntispamCheck()
GetPrice()
CreateOrder()
UpdateUserStatus()
NotifyDownstreamSystems()
}
不管是多么复杂的业务,系统内的逻辑都是可以分解为step1 -> step2 -> step3 ...
这样的流程的。
每一个步骤内部也会有复杂的流程,比如:
func CreateOrder() {
ValidateDistrict() // 判断是否是地区限定商品
ValidateVIPProduct() // 检查是否是只提供给 vip 的商品
GetUserInfo() // 从用户系统获取更详细的用户信息
GetProductDesc() // 从商品系统中获取商品在该时间点的详细信息
DecrementStorage() // 扣减库存
CreateOrderSnapshot() // 创建订单快照
return CreateSuccess
}
在阅读业务流程代码时,我们只要阅读其函数名就能知晓在该流程中完成了哪些操作,如果需要修改细节,那么就继续深入到每一个业务步骤去看具体的流程。写得稀烂的业务流程代码则会将所有过程都堆积在少数的几个函数中,从而导致几百甚至上千行的函数。这种意大利面条式的代码阅读和维护都会非常痛苦。在开发的过程中,一旦有条件应该立即进行类似上面这种方式的简单封装。
使用接口来做抽象
业务发展的早期,是不适宜引入接口(interface)的,很多时候业务流程变化很大,过早引入接口会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。
当业务发展到一定阶段,主流程稳定之后,就可以适当地使用接口来进行抽象了。这里的稳定,是指主流程的大部分业务步骤已经确定,即使再进行修改,也不会进行大规模的变动,而只是小修小补,或者只是增加或删除少量业务步骤。
如果我们在开发过程中,已经对业务步骤进行了良好的封装,这时候进行接口抽象化就会变的非常容易,伪代码:
// OrderCreator 创建订单流程
type OrderCreator interface {
ValidateDistrict() // 判断是否是地区限定商品
ValidateVIPProduct() // 检查是否是只提供给 vip 的商品
GetUserInfo() // 从用户系统获取更详细的用户信息
GetProductDesc() // 从商品系统中获取商品在该时间点的详细信息
DecrementStorage() // 扣减库存
CreateOrderSnapshot() // 创建订单快照
}
我们只要把之前写过的步骤函数签名都提到一个接口中,就可以完成抽象了。
在进行抽象之前,我们应该想明白的一点是,引入接口对我们的系统本身是否有意义,这是要按照场景去进行分析的。假如我们的系统只服务一条产品线,并且内部的代码只是针对很具体的场景进行定制化开发,那么引入接口是不会带来任何收益的。至于说是否方便测试,这一点我们会在之后的章节来讲。
如果我们正在做的是平台系统,需要由平台来定义统一的业务流程和业务规范,那么基于接口的抽象就是有意义的。举个例子:
平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的接口,然后要求接入方的业务必须将这些接口都实现。如果接口中有其不需要的步骤,那么只要返回nil
,或者忽略就好。
在业务进行迭代时,平台的代码是不用修改的,这样我们便把这些接入业务当成了平台代码的插件(plugin)
引入进来了。如果没有接口的话,我们会怎么做?
import (
"sample.com/travelorder"
"sample.com/marketorder"
)
func CreateOrder() {
switch businessType {
case TravelBusiness:
travelorder.CreateOrder()
case MarketBusiness:
marketorder.CreateOrderForMarket()
default:
return errors.New("not supported business")
}
}
func ValidateUser() {
switch businessType {
case TravelBusiness:
travelorder.ValidateUserVIP()
case MarketBusiness:
marketorder.ValidateUserRegistered()
default:
return errors.New("not supported business")
}
}
// ...
switch ...
switch ...
switch ...
没错,就是无穷无尽的switch,和没完没了的垃圾代码。引入了接口之后,我们的switch只需要在业务入口做一次。
type BusinessInstance interface {
ValidateLogin()
ValidateParams()
AntispamCheck()
GetPrice()
CreateOrder()
UpdateUserStatus()
NotifyDownstreamSystems()
}
func entry() {
var bi BusinessInstance
switch businessType {
case TravelBusiness:
bi = travelorder.New()
case MarketBusiness:
bi = marketorder.New()
default:
return errors.New("not supported business")
}
}
func BusinessProcess(bi BusinessInstance) {
bi.ValidateLogin()
bi.ValidateParams()
bi.AntispamCheck()
bi.GetPrice()
bi.CreateOrder()
bi.UpdateUserStatus()
bi.NotifyDownstreamSystems()
}
面向接口编程,不用关心具体的实现。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。
接口的优缺点
Go被人称道的最多的地方是其接口设计的正交性,模块之间不需要知晓相互的存在,A模块定义接口,B模块实现这个接口就可以。如果接口中没有A模块中定义的数据类型,那B模块中甚至都不用import A。比如标准库中的io.Writer
:
type Writer interface {
Write(p []byte) (n int, err error)
}
我们可以在自己的模块中实现io.Writer接口:
type MyType struct {}
func (m MyType) Write(p []byte) (n int, err error) {
return 0, nil
}
那么我们就可以把我们自己的MyType传给任何使用io.Writer作为参数的函数来使用了,比如:
package log
func SetOutput(w io.Writer) {
output = w
}
然后:
package my-business
import "xy.com/log"
func init() {
log.SetOutput(MyType)
}
在MyType定义的地方,不需要import "io"就可以直接实现 io.Writer接口,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立import产生的依赖关系。因此很多人认为Go的这种正交是一种很优秀的设计。
但这种“正交”性也会给我们带来一些麻烦。当我们接手了一个几十万行的系统时,如果看到定义了很多接口,例如订单流程的接口,我们希望能直接找到这些接口都被哪些对象实现了。但直到现在,这个简单的需求也就只有Goland实现了,并且体验尚可。Visual Studio Code则需要对项目进行全局扫描,来看到底有哪些结构体实现了该接口的全部函数。那些显式实现接口的语言,对于IDE的接口查找来说就友好多了。另一方面,我们看到一个结构体,也希望能够立刻知道这个结构体实现了哪些接口,但也有着和前面提到的相同的问题。
虽有不便,接口带给我们的好处也是不言而喻的:一是依赖反转,这是接口在大多数语言中对软件项目所能产生的影响,在Go的正交接口的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为接口强行来使用的话:
package main
type OrderCreator interface {
ValidateUser()
CreateOrder()
}
type BookOrderCreator struct{}
func (boc BookOrderCreator) ValidateUser() {}
func createOrder(oc OrderCreator) {
oc.ValidateUser()
oc.CreateOrder()
}
func main() {
createOrder(BookOrderCreator{})
}
会报出下述错误。
# command-line-arguments
./a.go:18:30: cannot use BookOrderCreator literal (type BookOrderCreator) as type OrderCreator in argument to createOrder:
BookOrderCreator does not implement OrderCreator (missing CreateOrder method)
所以接口也可以认为是一种编译期进行检查的保证类型安全的手段。
表驱动开发
熟悉开源lint工具的同学应该见到过圈复杂度的说法,在函数中如果有if
和switch
的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有switch
,还是想要干掉这个switch
,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例:
func entry() {
var bi BusinessInstance
switch businessType {
case TravelBusiness:
bi = travelorder.New()
case MarketBusiness:
bi = marketorder.New()
default:
return errors.New("not supported business")
}
}
可以修改为:
var businessInstanceMap = map[int]BusinessInstance {
TravelBusiness : travelorder.New(),
MarketBusiness : marketorder.New(),
}
func entry() {
bi := businessInstanceMap[businessType]
}
表驱动的设计方式,很多设计模式相关的书籍并没有把它作为一种设计模式来讲,但我认为这依然是一种非常重要的帮助我们来简化代码的手段。在日常的开发工作中可以多多思考,哪些不必要的switch case
可以用一个字典和一行代码就可以轻松搞定。
当然,表驱动也不是没有缺点,因为需要对输入key计算哈希,在性能敏感的场合,需要多加斟酌。
灰度发布和 A/B test
中型的互联网公司往往有着以百万计的用户,而大型互联网公司的系统则可能要服务千万级甚至亿级的用户需求。大型系统的请求流入往往是源源不断的,任何风吹草动,都一定会有最终用户感受得到。例如你的系统在上线途中会拒绝一些上游过来的请求,而这时候依赖你的系统没有做任何容错,那么这个错误就会一直向上抛出,直到触达最终用户。形成一次对用户切切实实的伤害。这种伤害可能是在用户的APP上弹出一个让用户摸不着头脑的诡异字符串,用户只要刷新一下页面就可以忘记这件事。但也可能会让正在心急如焚地和几万竞争对手同时抢夺秒杀商品的用户,因为代码上的小问题,丧失掉了先发优势,与自己蹲了几个月的心仪产品失之交臂。对用户的伤害有多大,取决于你的系统对于你的用户来说有多重要。
不管怎么说,在大型系统中容错是重要的,能够让系统按百分比,分批次到达最终用户,也是很重要的。虽然当今的互联网公司系统,名义上会说自己上线前都经过了充分慎重严格的测试,但就算它们真得做到了,代码的bug总是在所难免的。即使代码没有bug,分布式服务之间的协作也是可能出现“逻辑”上的非技术问题的。
这时候,灰度发布就显得非常重要了,灰度发布也称为金丝雀发布,传说17世纪的英国矿井工人发现金丝雀对瓦斯气体非常敏感,瓦斯达到一定浓度时,金丝雀即会死亡,但金丝雀的致死量瓦斯对人并不致死,因此金丝雀被用来当成他们的瓦斯检测工具。互联网系统的灰度发布一般通过两种方式实现:
1、通过分批次部署实现灰度发布
2、通过业务规则进行灰度发布
在对系统的旧功能进行升级迭代时,第一种方式用的比较多。新功能上线时,第二种方式用的比较多。当然,对比较重要的老功能进行较大幅度的修改时,一般也会选择按业务规则来进行发布,因为直接全量开放给所有用户风险实在太大。
通过分批次部署实现灰度发布
假如服务部署在15个实例(可能是物理机,也可能是容器)上,我们把这15个实例分为四组,按照先后顺序,分别有1-2-4-8台机器,保证每次扩展时大概都是二倍的关系。
为什么要用2倍?这样能够保证我们不管有多少台机器,都不会把组划分得太多。例如1024台机器,也就只需要1-2-4-8-16-32-64-128-256-512部署十次就可以全部部署完毕。
这样我们上线最开始影响到的用户在整体用户中占的比例也不大,比如1000台机器的服务,我们上线后如果出现问题,也只影响1/1000的用户。如果10组完全平均分,那一上线立刻就会影响1/10的用户,1/10的业务出问题,那可能对于公司来说就已经是一场不可挽回的事故了。
在上线时,最有效的观察手法是查看程序的错误日志,如果较明显的逻辑错误,一般错误日志的滚动速度都会有肉眼可见的增加。这些错误也可以通过metrics一类的系统上报给公司内的监控系统,所以在上线过程中,也可以通过观察监控曲线,来判断是否有异常发生。
如果有异常情况,首先要做的自然就是回滚了。
通过业务规则进行灰度发布
常见的灰度策略有多种,较为简单的需求,例如我们的策略是要按照千分比来发布,那么我们可以用用户id、手机号、用户设备信息,等等,来生成一个简单的哈希值,然后再求模,用伪代码表示一下:
// pass 3/1000
func passed() bool {
key := hashFunctions(userID) % 1000
if key <= 2 {
return true
}
return false
}
可选规则
常见的灰度发布系统会有下列规则提供选择:
1、按城市发布
2、按概率发布
3、按百分比发布
4、按白名单发布
5、按业务线发布
6、按UA发布(APP、Web、PC)
7、按分发渠道发布
因为和公司的业务相关,所以城市、业务线、UA、分发渠道这些都可能会被直接编码在系统里,不过功能其实大同小异。
按白名单发布比较简单,功能上线时,可能我们希望只有公司内部的员工和测试人员可以访问到新功能,会直接把账号、邮箱写入到白名单,拒绝其它任何账号的访问。
按概率发布则是指实现一个简单的函数:
func isTrue() bool {
return true/false according to the rate provided by user
}
其可以按照用户指定的概率返回true或者false,当然,true的概率加false的概率应该是100%。这个函数不需要任何输入。
按百分比发布,是指实现下面这样的函数:
func isTrue(phone string) bool {
if hash of phone matches {
return true
}
return false
}
这种情况可以按照指定的百分比,返回对应的true和false
,和上面的单纯按照概率的区别是这里我们需要调用方提供给我们一个输入参数,我们以该输入参数作为源来计算哈希,并以哈希后的结果来求模,并返回结果。这样可以保证同一个用户的返回结果多次调用是一致的
,在下面这种场景下,必须使用这种结果可预期的灰度算法,见下图所示。
如果采用随机策略,可能会出现像下图这样的问题:
举个具体的例子,网站的注册环节,可能有两套API,按照用户ID进行灰度,分别是不同的存取逻辑。如果存储时使用了V1版本的API而获取时使用V2版本的API,那么就可能出现用户注册成功后反而返回注册失败消息的诡异问题。
如何实现一套灰度发布系统
前面也提到了,提供给用户的接口大概可以分为和业务绑定的简单灰度判断逻辑。以及输入稍微复杂一些的哈希灰度。我们来分别看看怎么实现这样的灰度系统(函数)。
业务相关的简单灰度
公司内一般都会有公共的城市名字和id的映射关系,如果业务只涉及中国国内,那么城市数量不会特别多,且id可能都在10000范围以内。那么我们只要开辟一个一万大小左右的bool数组,就可以满足需求了:
var cityID2Open = [12000]bool{}
func init() {
readConfig()
for i:=0;i<len(cityID2Open);i++ {
if city i is opened in configs {
cityID2Open[i] = true
}
}
}
func isPassed(cityID int) bool {
return cityID2Open[cityID]
}
如果公司给cityID赋的值比较大,那么我们可以考虑用map来存储映射关系,map的查询比数组稍慢,但扩展会灵活一些:
var cityID2Open = map[int]struct{}{}
func init() {
readConfig()
for _, city := range openCities {
cityID2Open[city] = struct{}{}
}
}
func isPassed(cityID int) bool {
if _, ok := cityID2Open[cityID]; ok {
return true
}
return false
}
按白名单、按业务线、按UA、按分发渠道发布,本质上和按城市发布是一样的,这里就不再赘述了。
按概率发布稍微特殊一些,不过不考虑输入实现起来也很简单:
func init() {
rand.Seed(time.Now().UnixNano())
}
// rate 为 0~100
func isPassed(rate int) bool {
if rate >= 100 {
return true
}
if rate > 0 && rand.Int(100) > rate {
return true
}
return false
}
注意初始化种子。
哈希算法
求哈希可用的算法非常多,比如md5,crc32,sha1
等等,但我们这里的目的只是为了给这些数据做个映射,并不想要因为计算哈希消耗过多的cpu,所以现在业界使用较多的算法是murmurhash,下面是我们对这些常见的hash算法的简单benchmark。
下面使用了标准库的md5,sha1和开源的murmur3实现来进行对比。
package main
import (
"crypto/md5"
"crypto/sha1"
"github.com/spaolacci/murmur3"
)
var str = "hello world"
func md5Hash() [16]byte {
return md5.Sum([]byte(str))
}
func sha1Hash() [20]byte {
return sha1.Sum([]byte(str))
}
func murmur32() uint32 {
return murmur3.Sum32([]byte(str))
}
func murmur64() uint64 {
return murmur3.Sum64([]byte(str))
}
为这些算法写一个基准测试:
package main
import "testing"
func BenchmarkMD5(b *testing.B) {
for i := 0; i < b.N; i++ {
md5Hash()
}
}
func BenchmarkSHA1(b *testing.B) {
for i := 0; i < b.N; i++ {
sha1Hash()
}
}
func BenchmarkMurmurHash32(b *testing.B) {
for i := 0; i < b.N; i++ {
murmur32()
}
}
func BenchmarkMurmurHash64(b *testing.B) {
for i := 0; i < b.N; i++ {
murmur64()
}
}
然后看看运行效果:
~/t/g/hash_bench git:master ❯❯❯ go test -bench=.
goos: darwin
goarch: amd64
BenchmarkMD5-4 10000000 180 ns/op
BenchmarkSHA1-4 10000000 211 ns/op
BenchmarkMurmurHash32-4 50000000 25.7 ns/op
BenchmarkMurmurHash64-4 20000000 66.2 ns/op
PASS
ok _/Users/caochunhui/test/go/hash_bench 7.050s
可见murmurhash相比其它的算法有三倍以上的性能提升。显然做负载均衡的话,用murmurhash要比md5和sha1都要好,这些年社区里还有另外一些更高效的哈希算法涌现,感兴趣的读者可以自行调研。
分布是否均匀
对于哈希算法来说,除了性能方面的问题,还要考虑哈希后的值是否分布均匀。如果哈希后的值分布不均匀,那也自然就起不到均匀灰度的效果了。
以murmur3为例,我们先以15810000000开头,造一千万个和手机号类似的数字,然后将计算后的哈希值分十个桶,并观察计数是否均匀:
package main
import (
"fmt"
"github.com/spaolacci/murmur3"
)
var bucketSize = 10
func main() {
var bucketMap = map[uint64]int{}
for i := 15000000000; i < 15000000000+10000000; i++ {
hashInt := murmur64(fmt.Sprint(i)) % uint64(bucketSize)
bucketMap[hashInt]++
}
fmt.Println(bucketMap)
}
func murmur64(p string) uint64 {
return murmur3.Sum64([]byte(p))
}
看看执行结果:
map[7:999475 5:1000359 1:999945 6:1000200 3:1000193 9:1000765 2:1000044 \
4:1000343 8:1000823 0:997853]
偏差都在1/100以内,可以接受。读者在调研其它算法,并判断是否可以用来做灰度发布时,也应该从本节中提到的性能和均衡度两方面出发,对其进行考察。
Songzhibin
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)