本文是 教程 Go语言高级编程的 学习笔记,本部分链接
第5章 Go和Web · Go语言高级编程
1 Go的Web框架大致可以分为这么两类:
Router框架 :
对httpRouter进行简单的封装,然后提供定制的中间件和一些简单的小工具集成比如gin,主打轻量,易学,高性能
MVC类框架:
借鉴其它语言的编程风格的一些MVC类框架,例如beego
2 根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用net/http
中默认的路由。
3 RESTful风格的API重度依赖请求路径。会将很多参数放在请求URI中。除此之外还会使用很多并不那么常见的HTTP状态码。
4 支持路径中的wildcard参数之外,httprouter还可以支持*
号来进行通配,不过*
号开头的参数只能放在路由的结尾,主要是为了能够使用httprouter来做简单的HTTP静态文件服务器。
5 httprouter也支持对一些特殊情况下的回调函数进行定制,如 404或内部panic
6 httprouter和众多衍生router使用的数据结构被称为压缩字典树(Radix Tree)。字典树是一种空间换时间的典型做法。每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。
7 使用中间件可以剥离非业务逻辑,避免代码泥潭
8 中间件也是一个函数,其参数为 http.Handler
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进行包装,返回一个包括了各个中间件逻辑的函数链。
9 重构请求校验函数“:
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") } }
变成:
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 }
可以采用validator库解决这个问题
import "gopkg.in/go-playground/validator.v9" 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"` } validate := validator.New() func validate(req RegisterReq) error { err := validate.Struct(req) if err != nil { doSomething() return err } ... }
原理就是用反射对结构体进行树形遍历。对结构体进行校验时大量使用了反射,而Go的反射在性能上不太出众,有时甚至会影响到程序的性能。但对结构体进行大量校验的场景往往出现在Web服务,这里并不一定是程序的性能瓶颈所在,实际的效果还是要从pprof中做更精确的判断。
10 Go官方提供了database/sql
包来给用户进行和数据库打交道的工作,database/sql
库实际只提供了一套操作数据库的接口和规范,例如抽象好的SQL预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。操作某种数据库需要另外引入驱动。
driver.go
的代码里所有的成员全都是接口,对这些类型进行操作,还是会调用具体的driver
里的方法。
11 最为常见的ORM做的是从db到程序的类或结构体这样的映射。互联网系统的忌讳之一,就是多次重复读取数据库这种无端的读放大。的设计初衷就是为了让数据的操作和存储的具体实现相剥离。但是在上了规模的公司的人们渐渐达成了一个共识,由于隐藏重要的细节,ORM可能是失败的设计。
相比ORM来说,SQL Builder在SQL和项目可维护性之间取得了比较好的平衡。说白了SQL Builder是sql在代码里的一种特殊方言。
现如今,大型的互联网公司核心线上业务都会在代码中把SQL放在显眼的位置提供给DBA评审。在上线之前把DAO层的变更集的const部分直接拿给DBA来进行审核
12 计算机程序可依据其瓶颈分为磁盘IO瓶颈型,CPU计算瓶颈型,网络带宽瓶颈型。 wrk工具可以进行接口测试。
对于IO/Network瓶颈类的程序,其表现是网卡/磁盘IO会先于CPU打满,这种情况即使优化CPU的使用也不能提高整个系统的吞吐量,只能提高磁盘的读写速度,增加内存大小,提升网卡的带宽来提升整体性能。而CPU瓶颈类的程序,则是在存储和网卡未打满之前CPU占用率先到达100%,CPU忙于各种计算任务,IO设备相对则较闲。
13 流量限制的手段有很多,最常见的:漏桶、令牌桶两种。github.com/juju/ratelimit是开源的限流工具库。
性能指标很重要,但对用户提供服务时还应考虑服务整体的QoS。QoS全称是Quality of Service,顾名思义是服务质量。QoS包含有可用性、吞吐量、时延、时延变化和丢失等指标
14 MVC:
控制器(Controller)- 负责转发请求,对请求进行处理。
视图(View) - 界面设计人员进行图形界面设计。
模型(Model) - 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)
15 流行的前后端分离:
前后端通过通过ajax来交互,Vue和React是现在前端界比较流行的两个框架。
16 现在比较流行的纯后端API模块一般采用下述划分方法:
Controller,与上述类似,服务入口,负责处理路由,参数校验,请求转发。
Logic/Service,逻辑(服务)层,一般是业务逻辑的入口,可以认为从这里开始,所有的请求参数一定是合法的。业务逻辑和业务流程也都在这一层中。常见的设计中会将该层称为 Business Rules。
DAO/Repository,这一层主要负责和数据、存储打交道。将下层存储以更简单的函数、接口形式暴露给 Logic 层来使用。负责数据的持久化工作。
每一层都会做好自己的工作,然后用请求当前的上下文构造下一层工作所需要的结构体或其它类型参数,然后调用下一层的函数。
17 Controller中的入口函数就变成了下面这样:
func CreateOrder(ctx context.Context, req *CreateOrderStruct) ( *CreateOrderRespStruct, error, ) { // ... }
CreateOrder有两个参数,ctx用来传入trace_id一类的需要串联请求的全局参数,req里存储了我们创建订单所需要的所有输入信息。
18 代码膨胀后,可以将系统中与业务本身流程无关的部分做拆解和异步化。在阅读业务流程代码时,我们只要阅读其函数名就能知晓在该流程中完成了哪些操作。
func BusinessProcess(ctx context.Context, params Params) (resp, error){ ValidateLogin() ValidateParams() AntispamCheck() GetPrice() CreateOrder() UpdateUserStatus() NotifyDownstreamSystems() }
如在开发的过程中,一旦有条件应该立即进行类似上面这种方式的简单封装,
19 业务发展的早期,是不适宜引入接口(interface)的,很多时候业务流程变化很大,过早引入接口会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。
20 接口示例:
// OrderCreator 创建订单流程 type OrderCreator interface { ValidateDistrict() // 判断是否是地区限定商品 ValidateVIPProduct() // 检查是否是只提供给 vip 的商品 GetUserInfo() // 从用户系统获取更详细的用户信息 GetProductDesc() // 从商品系统中获取商品在该时间点的详细信息 DecrementStorage() // 扣减库存 CreateOrderSnapshot() // 创建订单快照 }
我们只要把之前写过的步骤函数签名都提到一个接口中,就可以完成抽象了。
21 平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的接口,然后要求接入方的业务必须将这些接口都实现。如果接口中有其不需要的步骤,那么只要返回nil
,或者忽略就好。
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() }
面向接口编程,不用关心具体的实现。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。
22 接口设计的正交性,模块之间不需要知晓相互的存在,A模块定义接口,B模块实现这个接口就可以。如果接口中没有A模块中定义的数据类型,那B模块中甚至都不用import A
,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立import产生的依赖关系。
23 接口带给我们的好处也是不言而喻的:一是依赖反转,这是接口在大多数语言中对软件项目所能产生的影响,在Go的正交接口的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为接口强行来使用的话
24 开源lint工具的有圈复杂度的说法,在函数中如果有if
和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] }
表驱动的设计方式,因为需要对输入key
计算哈希,在性能敏感的场合,需要多加斟酌。
25 互联网系统的灰度发布一般通过两种方式实现:
通过分批次部署实现灰度发布
通过业务规则进行灰度发布,常见的灰度发布系统会有下列规则提供选择:
按城市发布
按概率发布
按百分比发布
按白名单发布
按业务线发布
按UA发布(APP、Web、PC)
按分发渠道发布