diff --git a/ch5-web/ch5-08-interface-and-web.md b/ch5-web/ch5-08-interface-and-web.md index 5ab7979..9a90400 100644 --- a/ch5-web/ch5-08-interface-and-web.md +++ b/ch5-web/ch5-08-interface-and-web.md @@ -1,6 +1,6 @@ # 5.8 接口和表驱动开发 -在 Web 项目中经常会遇到外部依赖环境的变化,比如: +在Web项目中经常会遇到外部依赖环境的变化,比如: 1. 公司的老存储系统年久失修,现在已经没有人维护了,新的系统上线也没有考虑平滑迁移,但最后通牒已下,要求 N 天之内迁移完毕。 2. 平台部门的老用户系统年久失修(怎么都是年久失修,摔!),现在已经没有人维护了,真是悲伤的故事。新系统上线没有考虑兼容老接口,但最后通牒已下,要求 N 个月之内迁移完毕。 @@ -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 ...这样的流程的。 每一个步骤内部也会有复杂的流程,比如: @@ -54,11 +54,11 @@ func CreateOrder() { ## 5.8.3 使用 interface 来做抽象 -业务发展的早期,是不适宜引入 interface 的,很多时候业务流程变化很大,过早引入 interface 会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。 +业务发展的早期,是不适宜引入interface的,很多时候业务流程变化很大,过早引入interface会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。 -当业务发展到一定阶段,主流程稳定之后,就可以适当地使用 interface 来进行抽象了。这里的稳定,是指主流程的大部分业务步骤已经确定,即使再进行修改,也不会进行大规模的变动,而只是小修小补,或者只是增加或删除少量业务步骤。 +当业务发展到一定阶段,主流程稳定之后,就可以适当地使用interface来进行抽象了。这里的稳定,是指主流程的大部分业务步骤已经确定,即使再进行修改,也不会进行大规模的变动,而只是小修小补,或者只是增加或删除少量业务步骤。 -如果我们在开发过程中,已经对业务步骤进行了良好的封装,这时候进行 interface 抽象化就会变的非常容易,伪代码: +如果我们在开发过程中,已经对业务步骤进行了良好的封装,这时候进行interface抽象化就会变的非常容易,伪代码: ```go // OrderCreator 创建订单流程 @@ -72,17 +72,17 @@ type OrderCreator interface { } ``` -我们只要把之前写过的步骤函数签名都提到一个 interface 中,就可以完成抽象了。 +我们只要把之前写过的步骤函数签名都提到一个interface中,就可以完成抽象了。 -在进行抽象之前,我们应该想明白的一点是,引入 interface 对我们的系统本身是否有意义,这是要按照场景去进行分析的。假如我们的系统只服务一条产品线,并且内部的代码只是针对很具体的场景进行定制化开发,那么实际上引入 interface 是不会带来任何收益的。至于说是否方便测试,这一点我们会在之后的章节来讲。 +在进行抽象之前,我们应该想明白的一点是,引入interface对我们的系统本身是否有意义,这是要按照场景去进行分析的。假如我们的系统只服务一条产品线,并且内部的代码只是针对很具体的场景进行定制化开发,那么实际上引入interface是不会带来任何收益的。至于说是否方便测试,这一点我们会在之后的章节来讲。 -如果我们正在做的是平台系统,需要由平台来定义统一的业务流程和业务规范,那么基于 interface 的抽象就是有意义的。举个例子: +如果我们正在做的是平台系统,需要由平台来定义统一的业务流程和业务规范,那么基于interface的抽象就是有意义的。举个例子: ![interface-impl](../images/ch6-interface-impl.uml.png) -平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的 interface,然后要求接入方的业务必须将这些 interface 都实现。如果 interface 中有其不需要的步骤,那么只要返回 nil,或者忽略就好。 +平台需要服务多条业务线,但数据定义需要统一,所以希望都能走平台定义的流程。作为平台方,我们可以定义一套类似上文的interface,然后要求接入方的业务必须将这些interface都实现。如果interface中有其不需要的步骤,那么只要返回nil,或者忽略就好。 -在业务进行迭代时,平台的代码是不用修改的,这样我们便把这些接入业务当成了平台代码的插件(plugin)引入进来了。如果没有 interface 的话,我们会怎么做? +在业务进行迭代时,平台的代码是不用修改的,这样我们便把这些接入业务当成了平台代码的插件(plugin)引入进来了。如果没有interface的话,我们会怎么做? ```go import ( @@ -118,7 +118,7 @@ switch ... switch ... ``` -没错,就是无穷无尽的 switch,和没完没了的垃圾代码。引入了 interface 之后,我们的 switch 只需要在业务入口做一次。 +没错,就是无穷无尽的switch,和没完没了的垃圾代码。引入了interface之后,我们的switch只需要在业务入口做一次。 ```go type BusinessInstance interface { @@ -154,11 +154,11 @@ func BusinessProcess(bi BusinessInstance) { } ``` -直接面向 interface 编程,而不用关心具体的实现了。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。 +面向interface编程,不用关心具体的实现。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。 -## 5.8.4 interface 的优缺点 +## 5.8.4 interface的优缺点 -Go 被人称道的最多的地方是其 interface 设计的正交性,模块之间不需要知晓相互的存在,A 模块定义 interface,B 模块实现这个 interface 就可以。如果 interface 中没有 A 模块中定义的数据类型,那 B 模块中甚至都不用 import A。比如标准库中的 `io.Writer`: +Go被人称道的最多的地方是其interface设计的正交性,模块之间不需要知晓相互的存在,A模块定义interface,B模块实现这个interface就可以。如果interface中没有A模块中定义的数据类型,那B模块中甚至都不用import A。比如标准库中的`io.Writer`: ```go type Writer interface { @@ -166,7 +166,7 @@ type Writer interface { } ``` -我们可以在自己的模块中实现 `io.Writer` 接口: +我们可以在自己的模块中实现`io.Writer`接口: ```go type MyType struct {} @@ -176,7 +176,7 @@ func (m MyType) Write(p []byte) (n int, err error) { } ``` -那么我们就可以把我们自己的 MyType 传给任何使用 `io.Writer` 作为参数的函数来使用了,比如: +那么我们就可以把我们自己的MyType传给任何使用`io.Writer`作为参数的函数来使用了,比如: ```go package log @@ -198,11 +198,11 @@ func init() { } ``` -在 MyType 定义的地方,不需要 `import "io"` 就可以直接实现 `io.Writer` interface,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立 import 产生的依赖关系。因此很多人认为 Go 的这种正交是一种很优秀的设计。 +在MyType定义的地方,不需要`import "io"`就可以直接实现 `io.Writer` interface,我们还可以随意地组合很多函数,以实现各种类型的接口,同时接口实现方和接口定义方都不用建立import产生的依赖关系。因此很多人认为Go的这种正交是一种很优秀的设计。 -但这种“正交”性也会给我们带来一些麻烦。当我们接手了一个几十万行的系统时,如果看到定义了很多 interface,例如订单流程的 interface,我们希望能直接找到这些 interface 都被哪些对象实现了。但直到现在,这个简单的需求也就只有 goland 实现了,并且体验尚可。Visual Studio Code 则需要对项目进行全局扫描,来看到底有哪些 struct 实现了该 interface 的全部函数。那些显式实现 interface 的语言,对于 IDE 的 interface 查找来说就友好多了。另一方面,我们看到一个 struct,也希望能够立刻知道这个 struct 实现了哪些 interface,但也有着和前面提到的相同的问题。 +但这种“正交”性也会给我们带来一些麻烦。当我们接手了一个几十万行的系统时,如果看到定义了很多interface,例如订单流程的interface,我们希望能直接找到这些interface都被哪些对象实现了。但直到现在,这个简单的需求也就只有goland实现了,并且体验尚可。Visual Studio Code则需要对项目进行全局扫描,来看到底有哪些struct实现了该interface的全部函数。那些显式实现interface的语言,对于IDE的interface查找来说就友好多了。另一方面,我们看到一个struct,也希望能够立刻知道这个struct实现了哪些interface,但也有着和前面提到的相同的问题。 -虽有不便,interface 带给我们的好处也是不言而喻的:一是依赖反转,这是 interface 在大多数语言中对软件项目所能产生的影响,在 Go 的正交 interface 的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为 interface 强行来使用的话: +虽有不便,interface带给我们的好处也是不言而喻的:一是依赖反转,这是interface在大多数语言中对软件项目所能产生的影响,在Go的正交interface的设计场景下甚至可以去除依赖;二是由编译器来帮助我们在编译期就能检查到类似“未完全实现接口”这样的错误,如果业务未实现某个流程,但又将其实例作为interface强行来使用的话: ```go package main @@ -234,11 +234,11 @@ func main() { BookOrderCreator does not implement OrderCreator (missing CreateOrder method) ``` -所以 interface 也可以认为是一种编译期进行检查的保证类型安全的手段。 +所以interface也可以认为是一种编译期进行检查的保证类型安全的手段。 -## 5.8.5 table-driven 开发 +## 5.8.5 表驱动开发 -熟悉开源 lint 工具的同学应该见到过圈复杂度的说法,在函数中如果有 if 和 switch 的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有 switch,还是想要干掉这个 switch,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例: +熟悉开源lint工具的同学应该见到过圈复杂度的说法,在函数中如果有if和switch的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有switch,还是想要干掉这个switch,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例: ```go func entry() { @@ -267,4 +267,6 @@ func entry() { } ``` -table driven 的设计方式,很多设计模式相关的书籍并没有把它作为一种设计模式来讲,但我认为这依然是一种非常重要的帮助我们来简化代码的手段。在日常的开发工作中可以多多思考,哪些不必要的 switch case 可以用一个字典和一行代码就可以轻松搞定。 +表驱动的设计方式,很多设计模式相关的书籍并没有把它作为一种设计模式来讲,但我认为这依然是一种非常重要的帮助我们来简化代码的手段。在日常的开发工作中可以多多思考,哪些不必要的switch case可以用一个字典和一行代码就可以轻松搞定。 + +当然,表驱动也不是缺点,因为需要对输入key计算哈希,在性能敏感的场合,需要多加斟酌。 diff --git a/ch5-web/ch5-09-gated-launch.md b/ch5-web/ch5-09-gated-launch.md index 3233ccc..14cf987 100644 --- a/ch5-web/ch5-09-gated-launch.md +++ b/ch5-web/ch5-09-gated-launch.md @@ -2,9 +2,9 @@ 中型的互联网公司往往有着以百万计的用户,而大型互联网公司的系统则可能要服务千万级甚至亿级的用户需求。大型系统的请求流入往往是源源不断的,任何风吹草动,都一定会有最终用户感受得到。例如你的系统在上线途中会拒绝一些上游过来的请求,而这时候依赖你的系统没有做任何容错,那么这个错误就会一直向上抛出,直到触达最终用户。形成一次对用户切切实实的伤害。这种伤害可能是在用户的 app 上弹出一个让用户摸不着头脑的诡异字符串,用户只要刷新一下页面就可以忘记这件事。但也可能会让正在心急如焚地和几万竞争对手同时抢夺秒杀商品的用户,因为代码上的小问题,丧失掉了先发优势,与自己蹲了几个月的心仪产品失之交臂。对用户的伤害有多大,取决于你的系统对于你的用户来说有多重要。 -不管怎么说,在大型系统中容错是重要的,能够让系统按百分比,分批次到达最终用户,也是很重要的。虽然当今的互联网公司系统,名义上会说自己上线前都经过了充分慎重严格的测试,但就算它们真得做到了,代码的 bug 总是在所难免的。即使代码没有 bug,分布式服务之间的协作也是可能出现“逻辑”上的非技术问题的。 +不管怎么说,在大型系统中容错是重要的,能够让系统按百分比,分批次到达最终用户,也是很重要的。虽然当今的互联网公司系统,名义上会说自己上线前都经过了充分慎重严格的测试,但就算它们真得做到了,代码的bug总是在所难免的。即使代码没有bug,分布式服务之间的协作也是可能出现“逻辑”上的非技术问题的。 -这时候,灰度发布就显得非常重要了,灰度发布也称为金丝雀发布,传说 17 世纪的英国矿井工人发现金丝雀对瓦斯气体非常敏感,瓦斯达到一定浓度时,金丝雀即会死亡,但金丝雀的致死量瓦斯对人并不致死,因此金丝雀被用来当成他们的瓦斯检测工具。互联网系统的灰度发布一般通过两种方式实现: +这时候,灰度发布就显得非常重要了,灰度发布也称为金丝雀发布,传说17世纪的英国矿井工人发现金丝雀对瓦斯气体非常敏感,瓦斯达到一定浓度时,金丝雀即会死亡,但金丝雀的致死量瓦斯对人并不致死,因此金丝雀被用来当成他们的瓦斯检测工具。互联网系统的灰度发布一般通过两种方式实现: 1. 通过分批次部署实现灰度发布 2. 通过业务规则进行灰度发布 @@ -13,21 +13,21 @@ ## 5.9.1 通过分批次部署实现灰度发布 -假如服务部署在 15 个实例(可能是物理机,也可能是容器)上,我们把这 15 个实例分为四组,按照先后顺序,分别有 1-2-4-8 台机器,保证每次扩展时大概都是二倍的关系。 +假如服务部署在15个实例(可能是物理机,也可能是容器)上,我们把这15个实例分为四组,按照先后顺序,分别有1-2-4-8台机器,保证每次扩展时大概都是二倍的关系。 ![online group](../images/ch5-online-group.png) -为什么要用 2 倍?这样能够保证我们不管有多少台机器,都不会把组划分得太多。例如 1024 台机器,实际上也就只需要 1-2-4-8-16-32-64-128-256-512 部署十次就可以全部部署完毕。 +为什么要用2倍?这样能够保证我们不管有多少台机器,都不会把组划分得太多。例如1024台机器,实际上也就只需要1-2-4-8-16-32-64-128-256-512部署十次就可以全部部署完毕。 -这样我们上线最开始影响到的用户在整体用户中占的比例也不大,比如 1000 台机器的服务,我们上线后如果出现问题,也只影响 1/1000 的用户。如果 10 组完全平均分,那一上线立刻就会影响 1/10 的用户,1/10 的业务出问题,那可能对于公司来说就已经是一场不可挽回的事故了。 +这样我们上线最开始影响到的用户在整体用户中占的比例也不大,比如 1000 台机器的服务,我们上线后如果出现问题,也只影响1/1000的用户。如果10组完全平均分,那一上线立刻就会影响1/10的用户,1/10的业务出问题,那可能对于公司来说就已经是一场不可挽回的事故了。 -在上线时,最有效的观察手法是查看程序的错误日志,如果较明显的逻辑错误,一般错误日志的滚动速度都会有肉眼可见的增加。这些错误也可以通过 metrics 一类的系统上报给公司内的监控系统,所以在上线过程中,也可以通过观察监控曲线,来判断是否有异常发生。 +在上线时,最有效的观察手法是查看程序的错误日志,如果较明显的逻辑错误,一般错误日志的滚动速度都会有肉眼可见的增加。这些错误也可以通过metrics一类的系统上报给公司内的监控系统,所以在上线过程中,也可以通过观察监控曲线,来判断是否有异常发生。 如果有异常情况,首先要做的自然就是回滚了。 ## 5.9.2 通过业务规则进行灰度发布 -常见的灰度策略有多种,较为简单的需求,例如我们的策略是要按照千分比来发布,那么我们可以用用户 id、手机号、用户设备信息,等等,来生成一个简单的哈希值,然后再求模,用伪代码表示一下: +常见的灰度策略有多种,较为简单的需求,例如我们的策略是要按照千分比来发布,那么我们可以用用户id、手机号、用户设备信息,等等,来生成一个简单的哈希值,然后再求模,用伪代码表示一下: ```go // pass 3/1000 @@ -79,7 +79,7 @@ func isTrue(phone string) bool { } ``` -这种情况可以按照指定的百分比,返回对应的 true 和 false,和上面的单纯按照概率的区别是这里我们需要调用方提供给我们一个输入参数,我们以该输入参数作为源来计算哈希,并以哈希后的结果来求模,并返回结果。这样可以保证同一个用户的返回结果多次调用是一致的,在下面这种场景下,必须使用这种结果可预期的灰度算法: +这种情况可以按照指定的百分比,返回对应的true和false,和上面的单纯按照概率的区别是这里我们需要调用方提供给我们一个输入参数,我们以该输入参数作为源来计算哈希,并以哈希后的结果来求模,并返回结果。这样可以保证同一个用户的返回结果多次调用是一致的,在下面这种场景下,必须使用这种结果可预期的灰度算法: ![set 和 get 流程不应该因为灰度走到不同版本的 API](../images/ch5-set-time-line.png) @@ -95,7 +95,7 @@ func isTrue(phone string) bool { ### 5.9.3.1 业务相关的简单灰度 -公司内一般都会有公共的城市名字和 id 的映射关系,如果业务只涉及中国国内,那么城市数量不会特别多,且 id 可能都在 10000 范围以内。那么我们只要开辟一个一万大小左右的 bool 数组,就可以满足需求了: +公司内一般都会有公共的城市名字和id的映射关系,如果业务只涉及中国国内,那么城市数量不会特别多,且id可能都在10000范围以内。那么我们只要开辟一个一万大小左右的bool数组,就可以满足需求了: ```go var cityID2Open = [12000]bool{}