Table of Contents:

开篇词|为什么我们要从零开发框架?

只要你开始动手做一个框架,你就能站在框架作者的角度,遇到作者开发时遇到的问题,思考作者开发时选择的方案,从本质上理解清楚这些框架都在做些什么、为什么这么设计,之后在工作中遇到类似问题的时候,也会清楚这个问题为什么会出现,解决也就不在话下了。

流行的go web框架汇总
本教程的github

01|net/http:使用标准库搭建Server并不是那么简单

Web Server 的本质,实际上就是接收、解析 HTTP 请求传输的文本字符,理解这些文本字符的指令,然后进行计算,再将返回值组织成 HTTP 响应的文本字符,通过 TCP 网络传输回去。

有个开源库,叫 FastHTTP,它就是抛弃标准库 net/http 来实现的。作者为了追求极高的 HTTP 性能,自己封装了网络事件驱动,解析了 HTTP 协议。
但是现在绝大部分的 Web 框架,都是基于 net/http 标准库的。
原因主要有两点:
第一是相信官方开源的力量。自己实现 HTTP 协议的解析,不一定会比标准库实现得更好,即使当前标准库有一些不足之处,我们也都相信,随着开源贡献者越来越多,标准库也会最终达到完美。
第二是 Web 服务架构的变化。随着容器化、Kubernetes 等技术的兴起,业界逐渐达成共识,单机并发性能并不是评判 Web 服务优劣的唯一标准了,易用性、扩展性也是底层库需要考量的。

net/http 标准库怎么学
这里我教给你一个快速掌握代码库的技巧:库函数 > 结构定义 > 结构函数。
简单来说,就是当你在阅读一个代码库的时候,不应该从上到下阅读整个代码文档,而应该先阅读整个代码库提供的对外库函数(function),再读这个库提供的结构(struct/class),最后再阅读每个结构函数(method)。
为什么要这么学呢?因为这种阅读思路和代码库作者的思路是一致的。
首先搞清楚这个库要提供什么功能(提供什么样的对外函数),然后为了提供这些功能,我要把整个库分为几个核心模块(结构),最后每个核心模块,我应该提供什么样的能力(具体的结构函数)来满足我的需求。

在windows系统中,go的网络库最终会调用到windows提供的系统调用,而系统调用是通过读取调用windows的各种dll进行调用的。
go的实现源码路径:C:\Go\src\syscall\zsyscall_windows.go

HTTP 库服务端代码分析:
第一层,标准库创建 HTTP 服务是通过创建一个 Server 数据结构完成的;
第二层,Server 数据结构在 for 循环中不断监听每一个连接;
第三层,每个连接默认开启一个 Goroutine 为其服务;
第四、五层,serverHandler 结构代表请求对应的处理逻辑,并且通过这个结构进行具体业务逻辑处理;
第六层,Server 数据结构如果没有设置处理函数 Handler,默认使用 DefaultServerMux 处理请求;
第七层,DefaultServerMux 是使用 map 结构来存储和查找路由规则。

// 方式一:
// 使用http的默认路由,DefaultServeMux,Mux是multiplexor的缩写,DefaultServeMux只支持静态路由规则,使用http.HandleFunc是往DefaultServeMux的map中添加规则。若要支持动态路由就得自己实现HandlerFunc
func hello(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "hello world\n")
}


func main() {
    http.HandleFunc("/hello", hello)       
    http.ListenAndServe(":8080", nil)
}


// 方式二:
// 使用自定义的Handler,http.HandlerFunc(greeting)会把greeting函数转换成自定义的Handler
func greeting(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome, Gopher!\n")
}                    
func main() {
    //HandlerFunc 的底层类型是func(ResponseWriter, *Request),与 greeting 函数的类型是一致的,可以转换
    http.ListenAndServe(":8080", http.HandlerFunc(greeting))        // 最终是要用HandlerFunc的ServeHTTP的接口
}


// 方式三:
// http.Handle, func Handle(pattern string, handler Handler) { DefaultServeMux.Handle(pattern, handler) }
// 也是往默认的路由实现类中添加
type helloHandler struct {}


func (h *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    io.WriteString(w, "hello world\n")
}


func main() {
    http.Handle("/hello", &helloHandler{})
    http.ListenAndServe(":8080", nil)
}

02|Context:请求控制器,让每个请求都在掌控之中

HTTP 服务会为每个请求创建一个 Goroutine 进行服务处理。在服务处理的过程中,有可能就在本地执行业务逻辑,也有可能再去下游服务获取数据。如下图,本地处理逻辑 A/B、下游服务 a/b/c,会形成一个标准的树形逻辑链条。

如何控制超时,官方是有提供 context 标准库作为解决方案的,但是由于标准库的功能并不够完善,一会我们会基于标准库,来根据需求自定义框架的 Context。所以理解其背后的设计思路就可以了。
为了防止雪崩,context 标准库的解决思路是:在整个树形逻辑链条中,用上下文控制器 Context,实现每个节点的信息传递和共享。

Context 的结构定义和函数句柄

type Context interface {
    // 当 Context 被取消或者到了 deadline,返回一个被关闭的 channel
    Done() <-chan struct{}
    ...
}
//函数句柄
type CancelFunc func() 

在树形逻辑链条上,一个节点其实有两个角色:一是下游树的管理者;二是上游树的被管理者,那么就对应需要有两个能力:
* 一个是能让整个下游树结束的能力,也就是函数句柄 CancelFunc;
* 另外一个是在上游树结束的时候被通知的能力,也就是 Done() 方法。同时因为通知是需要不断监听的,所以 Done() 方法需要通过 channel 作为返回值让使用方进行监听。

代码示例:

const shortDuration = 1 * time.Millisecond
func main() {
    // 创建截止时间
  d := time.Now().Add(shortDuration)
    // 创建有截止时间的 Context
  ctx, cancel := context.WithDeadline(context.Background(), d)
  defer cancel()
    // 使用 select 监听 1s 和有截止时间的 Context 哪个先结束
  select {
  case <-time.After(1 * time.Second):
    fmt.Println("overslept")
  case <-ctx.Done():
    fmt.Println(ctx.Err())
  }
}

主线程创建了一个 1 毫秒结束的定时器 Context,在定时器结束的时候,主线程会通过 Done() 函数收到事件结束通知,然后主动调用函数句柄 cancelFunc 来通知所有子 Context 结束(这个例子比较简单没有子 Context)。

03|路由:如何让请求更快寻找到目标函数?

路由规则的需求
* HTTP 方法匹配
get,post等
* 静态路由匹配
静态路由匹配是一个路由的基本功能,指的是路由规则中没有可变参数,即路由规则地址是固定的,与 Request-URI 完全匹配
* 批量通用前缀
因为业务模块的划分,我们会同时为某个业务模块注册一批带有相同前缀的路由,比如user/add,user/list, user/edit
* 动态路由匹配
URL 中某个字段或者某些字段并不是固定的,是按照一定规则(比如是数字)变化的。比如:user/detail/1,user/detail/2
一旦引入了动态路由匹配的规则,之前使用的哈希规则就无法使用了。因为有通配符,在匹配 Request-URI 的时候,请求 URI 的某个字符或者某些字符是动态变化的,无法使用 URI 做为 key 来匹配。那么,我们就需要其他的算法来支持路由匹配。
如果你对算法比较熟悉,会联想到这个问题本质是一个字符串匹配,而字符串匹配,比较通用的高效方法就是字典树,也叫 trie 树。这里,我们先简单梳理下 trie 树的数据结构。trie 树不同于二叉树,它是多叉的树形结构,根节点一般是空字符串,而叶子节点保存的通常是字符串,一个节点的所有子孙节点都有相同的字符串前缀。

04|中间件:如何提高框架的可拓展性?

中间件机制的本质就是装饰器模型,对核心的逻辑函数进行装饰、封装,所以一开始我们就使用函数嵌套的方式实现了中间件机制。
但是实现之后,我们发现函数嵌套的弊端:一是不优雅,二是无法批量设置中间件。所以我们引入了 pipeline 的思想,将所有中间件做成一个链条,通过这个链条的调用,来实现中间件机制。
中间件机制是我们必须要掌握的机制,很多 Web 框架中都有这个逻辑。在架构层面,中间件机制就相当于,在每个请求的横切面统一注入了一个逻辑。这种统一处理的逻辑是非常有用的,比如统一打印日志、统一打点到统计系统、统一做权限登录验证等。

05|封装:如何让你的框架更好用?

如果说一定要记住一句话,希望你能记住,在实现“读取请求数据”和“封装返回数据”的过程中,采用“先系统设计,再定义接口,最后具体实现”的系统思考方法。如果你一接到要开发某些功能模块需求,不先想清楚就立刻上手,最终实现的代码,细节会非常混乱,复用性极差。
请记住,设计永远优于实现!

06|重启:如何进行优雅关闭?

优雅关闭服务,其实说的就是,关闭进程的时候,不能暴力关闭进程,而是要等进程中的所有请求都逻辑处理结束后,才关闭进程。按照这个思路,需要研究两个问题“如何控制关闭进程的操作” 和 “如何等待所有逻辑都处理结束”。

如何控制关闭进程的操作

哪个 Goroutine 用于监听信号呢?
答案是 main 函数所在的当前 Goroutine。因为使用 Ctrl 或者 kill 命令,它们发送的信号是进入 main 函数的,即只有 main 函数所在的 Goroutine 会接收到,所以必须在 main 函数所在的 Goroutine 监听信号。

如何等待所有逻辑都处理结束

而在 1.8 版本之后,net/http 引入了 server.Shutdown 来进行优雅重启。
server.Shutdown 方法是个阻塞方法,一旦执行之后,它会阻塞当前 Goroutine,并且在所有连接请求都结束之后,才继续往后执行。

07|目标:站在巨人肩膀,你的理想框架到底长什么样?

对第一个分问题如何比较开源框架,我们提出了五个维度,按照优先级顺序依次为:核心模块、功能完备性、框架扩展性、框架性能、文档完备度及社区活跃度。然后从这五个维度,简要分析了现在最流行的三个开源框架 Beego、Gin 和 Echo。
最后我们回到终极问题,探讨我们理想中的框架应该是什么样子的?总结一句话就是,在搞清楚真正的业务需求后,选最合适的框架就可以了。把握好这一点,你今后在遇到框架选择问题的时候,就不会太迷茫。

08|自研or借力,集成Gin替换已有核心(上)

今天通过对比 Gin 框架和我们之前设计的框架间的细节,展示了一个成熟的生产级别的框架与一个示例级别框架在细节上的距离。
现代框架的理念不在于实现,而更多在于组合。基于某些基础组件或者基础实现,不断按照自己或者公司的需求,进行二次改造和二次开发,从而打造出适合需求的形态。
比如 PHP 领域的 Laravel 框架,就是将各种底层组件、Symfony、Eloquent ORM、Monolog 等进行组装,而框架自身提供统一的组合调度方式;比如 Ruby 领域的 Rails 框架,整合了 Ruby 领域的各种优秀开源库,而框架的重点在于如何整理组件库、如何提供更便捷的机制,让程序员迅速解决问题。
所以,接下来我们设计框架的思路,就要从之前的从零开始造轮子,转换为站在巨人的肩膀上了,会借用 Gin 框架来继续实现。准备好,下一讲我们就要开始动手改造了。

09|自研or借力:集成Gin替换已有核心(下)

最主流的开源许可证有 6 种:Apache、BSD、GPL、LGPL、MIT、Mozillia。

Gin 框架使用的 MIT 开源许可证 ,这个许可证内容非常简单,对使用者的要求也最低。
允许被许可人使用、复制、修改、合并、出版发行、散布、再许可、售卖软件及软件副本。
唯一条件是在软件和软件副本中必须包含著作权声明和本许可声明。
所以如果软件用的是 MIT 许可证,不管你开发的项目是开源的,还是商业闭源的,都可以放心使用这个软件,只需要在软件中包含著作权声明和许可协议声明就行,且不要求新的文件必须使用 MIT 协议。

如何将 Gin 迁移进入我们的框架

在 Golang 中,要在一个项目中引入另外一个项目,一般有两种做法,一种做法是把要引用的项目通过 go mod 引入到目标库中,而另外一种做法则费劲的多,使用复制源码的方式引入要引用的项目。
go mod 的方式提倡的是“使用第三方库”而不是“定制第三方库”。所以对于很强的定制第三方库的需求,我们只能选择复制源码的方式。

10|面向接口编程:一切皆服务,服务基于协议(上)

面向接口编程

面向接口 / 对象 / 过程的区别

这三个名词描述的都是思维方式,就是我们在抽象业务的时候如何思考问题。
“面向过程编程”是指进行业务抽象的时候,我们定义一个一个的过程方法,通过这些过程方法的串联完成具体的业务。
“面向对象编程”表示的是在业务抽象的时候,我们先定义业务中的对象,通过这些对象之间的关联来表示整个业务。
“面向接口编程”表示的是面对业务,我们并不先定义具体的对象、思考对象有哪些属性,而是先思考如何抽象接口,把接口的定义放在第一步,然后多个模块之间梳理如何通过接口进行交互,最后才是实现具体的模块。

接口服务的理论基础

在框架中会包含很多模块,这些模块会和框架主体交互,也会互相交互,所以如果从功能的交互上看,整体会是一个非常复杂的网状结构。

如果改变一下思路,按照面向接口编程的理念,将每个模块看成是一个服务,服务的具体实现我们其实并不关心,我们关心的是服务提供的能力,即接口协议。那么框架主体真正要做的事情是什么呢?其实是:定义好每个模块服务的接口协议,规范服务与服务之间的调用,并且管理每个服务的具体实现。
所有的服务都去框架主体中注册自身的模块接口协议,其他的服务调用功能模块的时候,并不是直接去这个服务获取实例,而是从框架主体中获取有这个接口协议的服务实例。
这样,所有的模块服务都不和具体的服务进行交互,而是和框架主体进行交互,所有的接口也都注册在框架主体中,非常方便管理。


每个模块服务都做两件事情:一是它和自己提供的接口协议做绑定,这样当其他人要使用这个接口协议时能找到自己;二是它使用到其他接口协议的时候,去框架主体中寻找。
所以,这个时候,每个模块服务都是一个“服务提供者”(service provider),而我们主体框架需要承担起来的角色叫做“服务容器”(service container),服务容器中绑定了多个接口协议,每个接口协议都由一个服务提供者提供服务。
在框架初始化启动的时候,我们可以选择在服务容器中绑定多个服务提供者,每个服务提供者对应一个凭证。当要使用到某个服务的时候,再根据这个凭证去服务容器中,获取这个服务提供者提供的服务。这样就能很方便地获取服务了。
我们的设计是将每个服务,不管是配置、还是日志、还是缓存,都看成是一个服务。
这个服务,通过提供一个服务提供者注册到服务容器中。服务提供者提供的是“创建服务实例的方法”,服务容器提供的是“实例化服务的方法”。

结合面向接口编程的理念,我们希望设计出的框架是一个服务容器,也就是说,并不是在它的内部实现各种各样的功能模块,而是在框架中,定义好每个模块服务的接口,规范服务与服务之间的调用,并且管理每个服务的具体实现。
具体功能模块的实现由绑定的服务提供者进行,我们只需要规范服务提供者的能力,就能获取到具体的服务实例了。

11|面向接口编程:一切皆服务,服务基于协议(下)

我们在主体框架中实现了服务容器、服务提供者的逻辑,现在,hade 框架就包含一个服务容器,所有的服务都会在服务容器中注册。当业务需要获取某个服务实例的时候,也就是从服务容器中获取服务。
在这节课中,你是不是能感知到服务容器的方便之处了。只需要往服务容器中注册服务提供者,之后不管任何时候,想要获取某个服务,都能很方便地从服务容器中,获取到符合服务接口的实例,而不需要考虑到服务的具体实现。这种设计的拓展性非常好,之后在实际业务中我们只要保证服务协议不变,而不用担心具体的某个服务实现进行了变化。
后续开发的所有服务模块,比如日志、配置等我们都会以服务的形式进行开发,先定义好服务的接口,后定义服务的服务提供者,最后再定义服务的具体实例化方法。

12|结构:如何系统设计框架的整体目录?

如何设计

在制定具体的目录结构之前,我们要明确一点:业务的目录结构也是一个服务,是一个应用目录服务。在这个服务中,我们制定框架要求的最小化的工程化规范,即框架要求业务至少有哪些目录结构。
而在其他服务中,一旦需要用到某个目录,我们能从目录结构服务中,查找出对应的结构。比如后续要创建配置服务,它需要去某个配置目录文件中读取配置,而去哪个配置目录呢,只需要去服务容器中获取这个应用目录服务就能知道了。
所以按照面向接口编程的思想,先为“应用目录服务”定义一下服务接口协议,也就是应用要提供哪些接口。

在今后的章节中,我们会定义许多框架级别的基础服务,这些服务我们都存放在框架目录中。服务的接口协议,统一放在框架目录下的 framework/contract 中,而对应的服务提供者和服务实现,统一存放在框架目录下的 framework/provider 中。

定义默认目录结构

定义好了应用目录服务接口,我们再来思考它的实现,也就是设计使用这个 hade 框架的应用的默认目录。

app
    console
    http
    provider
framework
    command
    contract
    middleware
    provider
    util

config
storage
test

总结
今天我们其实就讨论了一个核心问题:如何从框架层来规范业务的目录结构。是不是有点惊讶,只是一个目录结构的设计,居然也有如此多的门道。框架设计就是这样,好的框架之所以能让所有人都喜欢,就是因为具备非常优秀的设计感,在每个模块和每个细节点上,都包含作者的思考。
比如我们的目录结构,不仅仅是一种分目录的设计,还贯彻了面向接口的思想,将目录作为一个服务提供在服务容器中,后续的所有服务在使用到业务目录的时候,可以直接通过这个目录服务,获取具体的目录路径,是非常方便的。

13|交互:可以执行命令行的框架才是好框架

14|定时任务:如何让框架支持分布式定时脚本?

如何实现分布式定时器

现在的定时器还是单机版本的定时器,容灾性很低,如果有很多定时任务都挂载在一个进程中,一旦这个进程或者这个机器出现灾难性不可恢复,那么定时任务就直接无法运行了。
容灾性更高的是分布式定时器。也就是很多机器都同时挂载定时任务,在同一时间都启动任务,只有一台机器能抢占到这个定时任务并且执行,其他机器由于抢占不到定时任务,不执行任何操作。

15|配置和环境:配置服务中的设计思路(上)

当你看到获取配置项这个需求,第一个反应是不是要创建一个读取配置文件的服务。但是,一个程序获取配置项只有读取配置文件这个方法么?其实不是的,获取配置项的方法有很多,读取本地配置文件、读取远端配置服务、获取环境变量,都是获取配置项的方法。

环境变量获取配置思路分析

在现在服务越来越容器化的时代,环境变量越来越重要。因为一个服务一旦被封装为 Docker 镜像,镜像就会被部署在不同的环境中。如何区分不同的环境呢?在容器内部已经把程序、配置文件都进行了打包,唯一能在不同环境变化的就是环境变量了。
为一个程序设置环境变量的方式是多种多样的。如果是容器化的进程,可以在创建镜像的时候设置,也可以在容器启动的时候设置,在 Linux 系统中,我们也可以通过在启动进程的时候,通过前面加上“KEY=VALUE”的方式,为单个进程设置环境变量,这种方式非常方便于测试,比如:

FOO_ENV=bar ./hade foo

环境变量可能会有很多。但是我们每次部署一个环境的时候,设置的环境变量可能就只有一两个,那其他的环境变量就需要有一个“默认值”。这个默认值我们一般使用一个以 dot 点号开头的文件.env 来进行设置。
其实使用.env 文件来设置默认环境变量,在运行的时候再使用真实的环境变量替换部分默认值,这种做法,在业界已经是一种非常普遍的加载环境变量的方式了。

小结

今天我们围绕获取配置这一个功能点,设计了环境变量服务和配置文件服务。
环境变量服务按照先读取本地默认.env 文件,再读取运行环境变量的方式来实现,并且为其设置了最关键的环境变量 APP_ENV 来表示这个应用当前运行的环境。后续我们根据这个 APP_ENV 来获取具体环境的本地配置文件。
有的人可能会觉得要获取一个变量,直接使用配置文件就行啊,为什么要绕这么一圈?但是,环境变量是一个应用运行环境的一些参数,在现在的容器化流行的架构设计中,一般都会选择使用环境变量来区别不同的环境和配置。所以我们为框架提供获取环境变量的方式,在实际架构,特别是微服务相关的架构中,是非常有用的。

16|配置和环境:配置服务中的设计思路(下)

17|日志:如何设计多输出的日志服务?

日志接口协议
说到日志服务,最先冒出来的一定是三个问题:什么样的日志需要输出?日志输出哪些内容?日志输出到哪里?

日志级别

什么样的日志需要输出,这是个关于日志级别的问题。
这里我们主要参考 log4j 的日志级别方法,并做了一些小调整,归并为下列七种日志级别:
* panic,表示会导致整个程序出现崩溃的日志信息
* fatal,表示会导致当前这个请求出现提前终止的错误信息
* error,表示出现错误,但是不一定影响后续请求逻辑的错误信息
* warn,表示出现错误,但是一定不影响后续请求逻辑的报警信息
* info,表示正常的日志信息输出
* debug,表示在调试状态下打印出来的日志信息
* trace,表示最详细的信息,一般信息量比较大,可能包含调用堆栈等信息

日志格式

默认提供两种输出格式,一种是文本输出形式,一种是 JSON

[Info]  2021-09-22T00:04:21+08:00       "demo test error"       map[api:demo/demo cspan_id: parent_id: span_id:c55051d94815vbl56i2g trace_id:c55051d94815vbl56i20 user:jianfengye]
{"api":"demo/demo","cspan_id":"","level":5,"msg":"demo1","parent_id":"","span_id":"c54v0tt9481537jasreg","timestamp":"2021-09-21T22:47:19+08:00","trace_id":"c54v0tt9481537jasre0","user":"jianfengye"}

这两种输出除了格式不同,其中的内容应该是相同的。具体使用起来,文本输出更便于我们阅读,而 JSON 输出更便于机器或者程序阅读。

日志输出

对于日志服务,按照我们平时的使用情况,可以分为四类:
* 控制台输出
* 本地单个日志文件输出
* 本地单个日志文件,自动进行切割输出
* 自定义输出

加餐|国庆特别放送:什么是业务架构,什么是基础架构?

架构和开发

所谓架构,和一线开发最大的区别就在于是否有系统设计工作。
一线开发的工作内容是在获取到一个细分需求后,思考如何用代码实现这个细分需求。如果是初级开发工程师,拿到手的甚至可能是一个已经定义好接口和交互的技术方案,要做的事情就是往这个技术方案中填充内容,让一个功能可以如期运行。
如果职别高一点,是高级开发工程师,工作要求会比简单实现功能更复杂,因为拿到手的是一个功能需求,往往需要进行技术方案设计,梳理拆分成一个个小的功能需求点,然后将功能需求实现出来。不过仍然是以编码实现为主。
而架构师的工作价值已经不是体现在编码实现上,而更多是体现在设计上。

架构师面对的是系统,这个系统或大或小,可能是一个复杂功能模块、一个复杂业务,也可能是一个公司级别的基础服务,但都有一个特点,就是比较庞大和复杂。如何将这个庞大又复杂的系统清晰地分层、如何设计流程、如何拆分子系统、每个子系统负责什么、难点系统的技术应该选择什么技术,这就是架构师最大的价值体现。

基础架构和业务架构

基础架构师,主要负责的是对基础服务的架构设计。所谓基础服务,就是和业务无关,基本上所有业务都会使用到的服务,比如数据库、缓存、对列等。
这些基础服务的重要性毋庸置疑,它的稳定性往往决定着整个公司的业务稳定性。试想一下,如果你的公司数据库出现不可用,是不是所有的业务线都会受到影响呢?而基础架构做的事情就是设计出合理的一套技术方案,来保证这些基础服务的可用性和稳定性。

业务架构师,主要负责的是业务服务的技术方案设计。你应该听说过这么一句话,技术是为业务服务的。是的,技术最终的价值就体现在业务实现上,而业务架构师,核心作用就是让技术更好地服务业务。
作为业务架构师,我觉得首先要清楚的是,你的技术是服务什么业务的。每个业务都带有行业属性,所以业务架构师的一个必要条件是了解你当前负责业务的行业。这里面不限于行业的技术发展趋势、竞品对手的动向,以及自己产品的后续发展方向。

最后我觉得非常重要的一点,业务架构师一定不能脱离一线。
如果不是在一线长期摸爬滚打过来,很难有接地气的设计。而在实现阶段,如果不时刻关注代码的质量,进行足够的代码检查,实现是有极大可能偏离设计的。

我可以分享一个我自己的习惯。不管你能力有多强,接手或者到了新的一个业务中,前面 3 个月尽量不要做大的架构级别的修改,因为不深度了解业务,没有足够时间了解一线的代码逻辑,是不可能做出好的架构调整的。

职业发展方向

不管你愿意从事基础架构还是业务架构,两者都是有淘汰周期的,都需要进行技术更新。这个是首先要说清楚的。所以如果你抱着哪个岗位更稳定的想法做选择,就不太靠谱了。
基础架构虽然听名字是底层,仍然有可能被淘汰。比如云和微服务的出现,对于之前的服务器运维方向的基础架构工作是一个很大的打击,现在的基础运维很多都新加了服务编排等工作。
而业务架构的更新淘汰更是常事,或许你在某个行业深耕多年,但由于各种政策原因,大行业都变化了,想不被淘汰,你的行业知识就必须重新补充了。

基础架构的同学更大可能是往技术专家方向发展。他们对技术的成就感更多来源于为某个软件或者某种语言增加了特性,比如会追求成为 Apache PMC、微软的 MVP 等,他们的研究是有可能改变某个技术行业的。所以如果你想走这个方向,必须有热衷于某个技术行业的觉悟。
而业务架构的同学更多可能是往业务管理方向发展,他们对技术的成就感更多来源于创造出某个比较流行的产品。比如有的业务架构同学就希望在教育行业有所发展,能设计并实现出改变教育行业的产品。
两种职业发展方向并没有优劣之分,而且不管哪个方向做到顶尖的人都是市场上非常稀缺的人。你在做职业选择的时候,更多的是要看清楚自己的兴趣所在,只有把自己的兴趣和工作相匹配,你的职业生涯才比较快乐。

加餐|阶段答疑:这些代码里的小知识点你都知道吗?

http.Server 源码为什么是两层循环?

go c.serve(connCtx) 里面为什么还有一个循环?c 指的是一个 connection,我理解不是每个连接处理一次就好了吗,为啥还有一个 for 循环呢?

HTTP 层有个 keep-alive,它主要是用于客户端告诉服务端,这个连接我还会继续使用,在使用完之后不要关闭。这个设置会影响 Web 服务的哪几个方面呢?
* 性能
这个设置首先会在性能上对客户端和服务器端性能上有一定的提升。很好理解的是,少了 TCP 的三次握手和四次挥手,第二次传递数据就可以通过前一个连接,直接进行数据交互了。当然会提升服务性能了。
* 服务器 TIME_WAIT 的时间
由于 HTTP 服务的发起方一般都是浏览器,即客户端,但是先执行完逻辑,传输完数据的一定是服务端。那么一旦没有 keep-alive 机制,服务端在传送完数据之后,会率先发起连接断开的操作。
由于 TCP 的四次挥手机制,先发起连接断开的一方,会在连接断开之后进入到 TIME_WAIT 的状态,达到 2MSL 之久。
设想,如果没有开启 HTTP 的 keep-alive,那么这个 TIME_WAIT 就会留在服务端,由于服务端资源是非常有限的,我们当然倾向于服务端不会同一时间 hold 住过多的连接,这种 TIME_WAIT 的状态应该尽量在客户端保持。那么这个 HTTP 的 keep-alive 机制就起到非常重要的作用了。

所以基于这两个原因,现在的浏览器发起 Web 请求的时候,都会带上 connection:keep-alive 的头了。
而我们的 Go 服务器,使用 net/http 在启动服务的时候,则会按照当前主流浏览器的设置,默认开启 keep-alive 机制。服务端的意思就是,只要浏览器端发送的请求头里,要求我开启 keep-alive,我就可以支持。

Q5、为什么 context 作为函数的第一个参数?

context 作为第一个参数在实际工作中是非常有用的一个实践。不管是设计一个函数,还是设计一个结构体的方法或者服务,我们一旦养成了将第一个参数作为 context 的习惯,那么这个 context 在相互调用的时候,就会传递下去。这里会带来两大好处:

Q5、服务雪崩 case 有哪些?

在第二节课中我们完成了添加 Context 为请求设置超时时间,提到超时很有可能造成雪崩,有同学问到相关问题,引发了我对服务雪崩场景的思考,这里我也简单总结一下。
雪崩的顾名思义,一个服务中断导致其他服务也中断,进而导致大片服务都中断。这里我们最常见的雪崩原因有下列几个:
* 超时设置不合理
服务雪崩最常见的就是下游服务没设置超时,导致上游服务不可用,也是我们设置 Context 的原因。
比如像上图的,A->B->C, C 的超时不合理,导致 B 请求不中止,而进而堆积,B 服务逐渐不可用,同理导致 A 服务也不可用。而在微服务盛行的链式结构中这种影响面会更大。
* 重试加大流量

我们在下游调用的时候,经常会使用重试机制来防止网络抖动问题,但是重试机制一旦使用不合理,也有可能导致下游服务的不可用。
理论上,越下层的服务可承受的 QPS 应该越高。在微服务链路中,有某个下游服务的 QPS,比如上图中 C 的 QPS 没有预估正确,当正常请求量上来,C 先扛不住,而扛不住返回的错误码又会让上游服务不断增加重试机制,进一步加剧了下游服务的不可用,进而整个系统雪崩。
* 缓存雪崩
缓存雪崩顾名思义就是,原本应该打在缓存中的请求全部绕开缓存,打到了 DB,从而导致 DB 不可用,而 DB 作为一个下游服务节点,不可用会导致上游都出现雪崩效应(这里的 DB 也有可能是各种数据或者业务服务)。

为什么会出现缓存雪崩呢,我列了一下工作中经常遇到的缓存导致雪崩的原因,有如下三种:
1. 被攻击
在平时写代码中我们日常使用这样的逻辑:“根据请求中的某个值建立 key 去缓存中获取,获取不到就去数据库中获取”。但是这种逻辑其实很容易被攻击者利用,攻击者只需要建立大量非合理的 key,就可以打穿缓存进入数据库进行请求。请求量只要足够大,就可以导致绕过缓存,让数据库不可用。
2. 缓存瞬时失效
“通过第一个请求建立缓存,建立之后一段时间后失效”。这也是一个经常出现瞬时缓存雪崩的原因。因为有可能在第一次批量建立了缓存后,进行业务逻辑,而后续并没有更新缓存时长,那就可能导致批量在统一时间内缓存失效。缓存失效后大批量的请求会涌入后端数据库,导致数据库不可用。
3. 缓存热 key
还有一种情况是缓存中的某个 key 突然有大批量的请求涌入,而缓存的分布式一般是按照 key 进行节点分布的。这样会导致某个缓存服务节点流量过于集中,不可用。而缓存节点不可用又会导致大批量的请求穿透缓存进入数据库,导致数据库不可用。

18|一体化:前端和后端一定要项目分开吗?

小结
我们将前端的 Vue 集成进到 hade 框架中,并且增加了 static 中间件,让框架具有同时提供前后端服务的功能,即可以充当一个静态文件服务器。最后在改造过程中,发现频繁用到 go build、npm build 等命令做前后端编译,不太方便,所以我们改造了 build 命令行工具为统一编译前后端命令,提升了编译效率。
目前的框架,确实很少有支持前后端一体化的,但是我个人的工作经验来说,如果你开发的是一个运营后台系统,很多时候前端和后端都是一个人开发的,那么,前后端一体化的功能就是非常实用的,能大大加快我们的开发效率。

19|提效:实现调试模式加速开发效率(上)

所谓反向代理,就是能将一个请求按照条件分发到不同的服务中去。在 Golang 中的 net/http/httputil 包中提供了 ReverseProxy 这么一个数据结构,它是实现整个反向代理的关键。

今天这节课最关键的点就在于 ReverseProxy 的运用。ReverseProxy 是 Golang 标准库提供的反向代理的实现方式。而反向代理,在实际业务开发过程中实际上是非常好用的。
比如我们在业务开发过程中很有可能会需要自研网关,来全局代理和监控所有的后端接口;又或者在拆分微服务的时候,需要有一个统一路由层来引导流量。这个 ReverseProxy 结构的熟练使用就是这些功能的核心关键。

20|提效:实现调试模式加速开发效率(下)

21|自动化:DRY,如何自动化一切重复性劳动?(上)

小结
今天增加的命令不少,自动化创建服务工具、命令行工具,以及中间件迁移工具,这些命令都为我们后续开发应用提供了不少便利。
其实每个自动化命令行工具实现的思路都是差不多的,先思考清楚对于这个工具我们要自动化生成什么,然后使用代码和对应的模版生成对应的文件,并且替换其中特有的单词。原理不复杂,但是对于实际的工作,是非常有帮助的。

22|自动化:DRY,如何自动化一切重复性劳动?(下)

小结
今天我们增加了一个新的命令,自动化初始化脚手架的命令设计,让 hade 框架也可以像 Vue 框架一样,直接使用一个二进制命令 ./hade new 创建一个脚手架。我们把框架和脚手架示例代码同时放在 github.com/gohade/hade 仓库中,实现了框架和脚手架示例代码版本的关联。
在创建脚手架的时候,我们是基于这个仓库的某个 tag 版本做减法,而不是费劲地做加法来进行创建。
同时在每次更新框架的时候,我们也会自然而然更新这个示例代码,框架和示例代码永远是一一对应的,而下载的时候会保留这种一一对应的关系。这种设计让 hade 版本的框架设计更为方便了。

23|管理接口:如何集成swagger自动生成文件?

24|管理进程:如何设计完善的运行命令?

今天我们完成了运行 app 相关的命令,包括 app 一级命令和四个二级命令,启动 app 服务、停止 app 服务、重启 app 服务、查询 app 服务。基本上已经把一个 app 服务启动的状态变更都包含了。有了这些命令,我们对 app 的控制就方便很多了。特别是 daemon 运行模式,为线上运行提供了不少方便。
在实现这四个命令的过程中,我们使用了不少第三方库,gspt、go-daemon,这些库的使用你要能熟练掌握,特别是 go-daemon 库,我们已经不止一次使用到它了。确认一个进程是否已经结束,我们使用每秒做一次轮询的 CheckProcessExist 方法实现了检查机制,并仔细考虑了轮训的次数和效果,你可以多多体会这么设计的好处。

25|GORM:数据库的使用必不可少(上)

ORM

ORM 并不等同于数据库操作,M,maping,这个映射是双向的映射。
数据库操作,本质上是使用 SQL 语句对数据库发送命令来操作数据。而 ORM 是一种将数据库中的数据映射到代码中对象的技术,这个技术的需求出发点就是,代码中有类,数据库中有数据表,我们可以将类和数据表进行映射,从而使得在代码中操作类就等同于操作数据库中的数据表了。

Gorm

一个 ORM 库,最核心要了解两个部分。一个部分是数据库连接,它是怎么和数据库建立连接的,第二部分是数据库操作,即它是怎么操作数据库的。

DSN

DSN 全称叫 Data Source Name,数据库的源名称。
DSN 定义了一个数据库的连接方式及信息,包含用户名、密码、数据库 IP、数据库端口、数据库字符集、数据库时区等信息。可以说一个 DSN 就是一个数据源的描述。但是 DSN 并没有明确的官方文档要求其格式,每个语言、每个平台都可以自己定义 DSN 格式,只要定义和解析能对得上就行。

26|GORM:数据库的使用必不可少(下)

27|缓存服务:如何基于Redis实现封装?

28|SSH:如何生成发布系统让框架发布自动化?

这里介绍一个小知识,你可以看下这个 ssh 库的 git:golang.org/x/crypto/ssh。它是在官网 golang.org 下的,但是又不是官方的标准库,因为子目录是 x。
这种库其实也是经过官方认证的,属于实验性的库,我们可以这么理解:以 golang.org/x/ 开头的库,都是官方认为这些库后续有可能成为标准库的一部份,但是由于种种原因,现在还没有计划放进标准库中,需要更多时间打磨。但是这种库的维护者和开发者一般已经是 Golang 官方组的人员了。比如现在今年讨论热度很大的 Golang 泛型,据说也会先以实验库的形式出现。

今天我们实现了将代码自动化部署到 Web 服务器的机制。为了实现这个自动化部署,先实现了一个 SSH 服务,然后定制了一套自动化部署命令,包括部署前端、部署后端、部署全部和部署回滚。
虽然说这个由框架负责的自动化部署机制在大项目中可能用不上,毕竟现在大项目都采用 Docker 化和 k8s 部署了。不过对于小型项目,这种部署机制还是有其便利性的。所以我们的 hade 框架还是决定提供这个机制。
在实现这个机制的过程中,要做到熟练掌握 Golang 对于 SSH、SFTP 等库的操作。基本上这两个库的操作你熟悉了,就能在一个程序中同时自动化操作多个服务器了。在实际工作中,如果遇到类似的需求,可以按照这节课所展示的技术来自动化你的需求。

29|周边:框架发布和维护也是重要的一环

今天我们为框架的升级和维护设计了一套完整的方案。hade 框架的发布和文档维护都有自己的独特设计,比如框架的发布,直接和 hade 框架的命令行工具进行了关联,只要发布了一个新版本,使用者就能直接用命令行工具使用这个新版本创建一个脚手架。而文档维护,是通过编写 markdown 以及 hade 框架已经集成的 Vue,来自动生成网站 HTML。

30|设计先于实战:需求设计和框架搭建

31|通用模块(上):用户模块开发

首先不管分层逻辑是什么样,我们在实现一个业务模块的时候一般会有三种模型的需求。

第一种模型是数据库模型,就是说这个这个模型对应数据库中的某个数据,这个也就是我们在第 25 章提到的 ORM 的概念,代码中的数据模型和数据表一一对应,这样操作这个模型就相当于能操作数据表。这种模型我们也称之为持久化对象 PO(Persistent Object),表示对象与数据库这种持久化层一一映射。

但是数据库模型和数据表关联太紧密了,一旦数据表修改,我们的数据库模型也需要对应修改,所以就会需要第二种模型,领域模型,DO。就是我们在业务中对某个事物的理解和抽象。

DO 模型不一定会依赖数据表的字段,而是依赖于我们对于要建立的业务的抽象。有了这个模型,我们在不同服务、不同模块之间的调用,都直接基于这个领域模型,就能保持各个模块对同一个事物的理解是一致的。

但是领域模型如果直接输出给用户,比如 Web 前端用户,有一些是需要进行加工的,比如一些涉及安全的字段需要隐藏、一些字段类型需要转换等。所以输出给前端的是第三种模型,输出模型,我们经常叫它 DTO,表示最终在网络上传输的数据对象。

这三种模型从底层到上层依次为 PO、DO、DTO。不管我们的业务是如何分层,基本上绕不开这三种模型的设计。

估计从这段分析中,你也能想到这三种模型之间是存在转换关系的,它们的转换也是一个比较繁琐的过程,比如 DO 转换为 DTO,记得吗我们还专门设计了一个映射层,就是 app/http/module/user/mapper.go 中定义的各种映射方法。

另外坚持将 UserDTO 单独分开,这也是我的经验之谈。越接近前端,需求变化越频繁,修改 / 增加 / 删除某个前端展示字段的需求在实际工作中比比皆是。所以这个输出层模型我一般习惯单独写出来。

小结
分析用户模块的注册和登录两个部分后,我们开始开发后端了,但并不是一上手就开始接口逻辑编写,也不是一开始就考虑我应该用数据库还是缓存实现用户存储。而是依照四个步骤:
1. 接口 swagger 化
2. 定义用户服务协议
3. 开发模块接口
4. 实现用户服务协议

hade 框架的整体都是不断在强调“协议优于实现”,接口是后端和前端定义的协议,用户服务是后端模块与模块之间定义的协议。对于一个业务,最重要的是定义好、说明清楚这些协议,然后才是实现好这些协议。希望 hade 框架带给你的不仅仅是工具和功能上的便利,更多是开发思维和流程的转变。

32|通用模块(下):用户模块开发

用户模块接口实现
设计了用户服务的协议,下一步我们也不是急于实现它,需要先验证下这些服务协议是否能满足我们的“需求”。如何验证呢?可以直接开发用户接口,确认是否有未满足的需求。

小结
这节课我们就完完整整做好了用户模块的开发。还是再啰嗦强调一下,后端开发的四个步骤:先将接口 swaggger 化、再定义用户服务协议、接着开发模块接口、最后实现用户服务协议。服务模块的协议设计不一定能一次性抽象好,可以从服务需要提供哪些对外能力”的角度来思考,从需求出发,遇到新的需求,不断迭代你的设计就可以。

33|业务开发(上):问答业务开发

34|业务开发(下):问答业务开发