前言
在 2019年第五届 Gopher China 大会上,百度资深研发工程师陈肖楠进行了主题为《百度APP Go语言实践》的演讲,以下为演讲实录。
No.0
简介
我来自百度APP平台部,主要负责公司内规范建立和系统工具的开发。首先我将介绍一下我们公司的开发规范,然后针对这个规范,介绍我们在Go语言体系建设所做的工作;再针对这些体系建设,对其中三个项目展开详细的介绍。
这个是今天分享的大纲,主要分为以下四个部分:
Go开发规范
Go语言体系
依赖管理
代码检查
No.1
Go 开发规范
首先看一下我们公司的开发规范,上面这张图是从我们公司的一个项目中截出来的,我们公司每个项目都有这个图,我们称它为工程能力地图。这个图包含整个项目开发的所有阶段:需求、开发、代码准入、测试和上线验证五个阶段,每一个阶段下面的方框代表我们公司针对这个阶段所做的规范和标准,颜色代表这些项目在这些规范方面的得分,颜色越深代表规范遵循的越好,颜色越浅代表规范遵循的越不好。
这个规范目的是为了评价我们项目的质量,每一个规范都有对应分数和权重,我们以遵循的程度计算出一个分数,作为工程能力得分,分数越高表明项目质量越好,反之则差。那可能有人会问了,规范这么多会不会非常影响开发效率?我想这是肯定的,不过我们公司是比较注重质量的,质量跟效率之间本来有一个矛盾,但是我们会在保证质量的前提下,尽量提高开发效率,针对每一个规范我们都有对应的系统,或者是工具帮我们自动化完成一些工作。
Go语言在我们公司内相对而言是发展比较晚的,对我们来说还是一门比较新的语言。所以Go语言在这些规范过程当中有很多东西是缺失的。因此我们在做Go项目开发过程中,很多东西我们是没有办法做的。这就导致我们在做Go语言开发过程中质量和效率都没法得到很好的保证,这也是之前Go语言在我们公司内发展相对比较慢的原因。
下面我讲的就是我们团队针对这些规范所做的一些工作,也就是Go语言体系建设。下面这张图是我们目前在Go体系建设方面所做的一些工作,还包括这些工作在我们整个业务、系统当中他是处于什么样的位置。
首先看中间这一块,首先是一个接入层,然后到业务层,业务开发需要由我们公司统一的开发框架支持。而且我们的业务将会与我们公司内部的基础服务进行交互。图的右边是我们一个规范与模式。我们公司针对每一个语言都有一个语言的规范委员会,其主要的目的是来推进语言的编程规范。我们Go语言也有相应的编程委员会,给我们制定了代码的编写规范,这些规范包括一些社区的开源规范,也包括我们公司内部做得更详细、更严格的一些规范。
除了这个代码的编写规范,在实际业务开发过程中,我们还发现项目代码的组织方式和引用相对来说比较混乱,所以我们制定了代码组织和引用的指南,帮助大家规范这些代码整个开发的布局。
既然制定了规范,那我们就要遵循,否则这些规范就变成一纸空文,没有任何约束力。为了让我们规范落地,我们开发了每个语言都有的代码检查工具和工程效率工具,能够帮我们快速建立我们一个Go语言的应用,帮我们查找Bug提升开发效率;第二是依赖管理工具,解决代码相互之间的依赖,以及公司内部对于外部开源代码管理依赖问题;测试框架就是刚才Dave讲的,我们公司对于每一个项目单元测试覆盖率也要求,强制我们写测试,而且大家写单元测试的时候可能发现写业务的代码的时间还没有我写单元测试的时间长,所以单元测试本身非常耗时,于是我们就想把单元测试统一写法,或者自动生成测试,就像刚才Dave说的怎么写比较好,我们通过框架自动形成测试单元,这样既提高我们的工作效率,又使测试能够规范化。
这就是目前我们在Go语言体系建设方面做的或者正在做的工作。后面我针对这些工作当中比较重点的三个项目给大家做一下介绍,包括我们的开发框架、依赖管理以及代码检查。
No.2
Go语言体系
在讲开发框架之前,我将解释下我们为什么要做开发框架?
虽然Go语言发展时间并不长,但是已经有很多优秀的开发框架,如Beego等我们就可以直接使用,为什么还要再开发框架呢?这肯定与我们公司的实际情况是有关的,那我们实际情况是什么样呢?比如说我们的业务入口分很多种,包括PC、H5或者NA,我们公司内部有专门对外服务,这种对外服务有多种协议的,包括HTTP,一些RPC协议或者公司内部所有自定义协议,这些对外的服务与公司内部服务之间也是需要多协议的交互,一个团队可能需要对外提供不同协议的服务,又要跟公司不同内部的服务进行交互,但是这些开源框架都是针对某一个协议做,而对于我们公司项目而言,就要引入不同的框架做,对于维护整个协议成本、开发效率是有影响的,而且一些私有协议开源框架也不一定支持。因此针对公司内部的情况,我们想开发一个框架,能够同时支持不同的协议,而且使用方法又不能差太多,让大家降低这种成本,提升开发效率。
另外,我们的业务需要与公司内部系统进行紧密结合。比如说我们公司内部有他自己一套的Service的系统,还有PAAS平台,还有开源的DB和Rides都基于开源做了改动,我们服务要和公司基础架构打通,一些特殊的做法和开源协议是不一样的。开源的框架也是没有办法满足我们下面的需求。
这边是一个监控,我们公司针对每一个业务都有一套非常完善监控系统,一个监控系统监控我们的业务。大家知道这个监控很多数据都是来自于这个日志,一些开源框架都有这种自己的日志格式,公司内部对于这个日志规范有一定要求。比这些开源的框架有更加丰富的内容,包括我们的机房的信息或者是我们每一个阶段的耗时,或者是我们的traceid如果我们想把整个前端后端连接起来,就要有一个traceid进行跟踪,这也是为什么做内部框架原因。
内部框架主要是分两部分。
Server实现
第一是Server。基于TCP的协议我们实现了通用的TCP Server,在这个Server上针对不同协议进行扩展,可以有公司内部私有的协议,或者开源RPC协议。因为我们把Server和协议部分分开实现的,如果需要扩展协议是非常方便,这是我们Server设计的思路。
讲一下遇到问题。前面这个是我们一些开源的框架常规做法,每来一个请求都要给这个请求建一个Goroutine处理这个请求,处理结束就销毁。这种方式是很符合Go设计思想,实践起来比较简单。这样实现以后我们更依赖Goroutine。比如说我们一千个并发,我们是10毫秒平响,增加到一万并发时间就变成100毫秒。随着并发数增加甚至会超出Server限制,整个Server就会崩溃,这个Server的稳定性没有办法得到保证。所以我们进行了后面的改造,我们设计了goroutine的一个pool,来了请求先看看有没有可用goroutine,如果有就直接用,如果没有我们就新建一个,用完了以后再放进去,当我们发现这个池子的数量正在工作的Goroutine已经超出我们上限(这个上限是测试的时候设置的值,根据我们的平响需要多少时间进行设置)就会拒绝这个请求,从而保证整个服务的稳定性,以及性能。
控制Goroutine方法有很多,当时这么做还是想通过Goroutine减少创建和销毁,来提高整个程序的性能,但是最后我们发现这个方法对于性能的提升并不是太大,这个也从侧面说明Goroutine是非常轻量级的,如果单纯为了提升性能做这件事,不是太好。这个是我们在Server做的工作以及遇到的问题。
Client实现
很多框架大家使用的时候一般只提供Server的功能,不会提供Client的功能,当需要跟不同的协议、不同的服务进行交互的时候,我们就可以使用对应的Client交互。但我们的做法不是这样,我们一般使用一个Client跟不同的协议、不同的服务进行交互。这个思想也不是我们自己发明的,是我们公司内部已经比较成熟的框架的经验,这种方法大大提高我们开发的效率。开发人员不用过度关注细节,主要目的就是帮我们屏蔽网络请求的细节。这边是他的一个实现的原理。首先有一个配置文件,这个配置文件是真正下游服务都有一个配置,这个配置内容主要包括下游使用是哪一个Service,他的超时控制,负载均衡的策略等等这些信息,以及他是使用了什么协议这些信息。我们在程序启动的时候会把这些信息加载内部,当访问下一个服务的时候,我们首先有一个对象,看这个对象看配置里面到底用什么数据打包模式,针对这个协议进行打包,再看是使用哪一个协议,我们对他这种协议进行封装,再往下通过负载均衡模块,当选定下游之后,我们还有一个连接池的东西,当我们使用长连接的时候,我们选定一个Host,看看有没有连接,如果有就用,没有就新创建一个,通过这样提高整个程序的性能。
Client主要功能包括多种协议、多种打包格式的支持,同时他不仅是网络交互的Client,还是利用内部的Naming Service服务进行服务发现的SDK,支持公司不同的Service,所以我们这个相对于一些开源的是比较复杂。
我们在使用Client也遇到一些坑,这种坑是大家使用其他开源组件也会会遇到通用问题,我们发现过多的TIME_WAIT,使服务器连接不足而拒绝一部分请求。原因是根据TCP协议断开连接的过程,主动断开连接的一方会出现TIME_WAIT,等两个MSL,整个过程是不可用的。为什么出现这个问题?我们使用这个HTTP的时候,默认是开启Client,但是并没有复用HTTPClient,大家开发也有一个问题,来一个请求要访问下游,会给这个请求先建立一个Client访问下游,之后我退出了,这个Goroutine就销毁了。我们建立HTTPClient并没有复用,导致问题是我们告诉 Server 保持这个链接,但是没有复用这个链接,这样server超时主动关闭这个链接,出现这个TIME_WAIT。那我们怎么解决这个问题呢?
第一个方案就是我们不让Server关闭这个链接,让Client关闭这个链接,这样TIME_WAIT不会出现Server端。
刚才我们说默认是用长连接,我们把长连接关掉,最终发现关掉也不行。因为把长连接关掉就是使用短链接,这样Server在发送完了数据会主动关闭,这个TIME_WAIT还是会在Server出现。第二个方案是我们首先开启长连接,另外设置HTTPClient一个参数,MaxleConnsPerHost,这样我们的Client会主动关闭链接,不会Server让出现TIME_WAIT。
还有一个方式,我们复用我们的链接。之前我们建立一个HTTPClient,但是没有使用,我们能不能使用它降低链接的建立销毁的次数,减少TIME_WAIT出现的几率呢?首先我们针对每一个下面的Server建立一个HTTP的Client,针对下游每一个服务,因为我们不能建立一个全局的HTTPClient,因为每一个下游的Server配置不一样,需要的超时不一样,所以需要为每一个服务建立一个HTTPClient。我们请求来了之后,我们经过负载均衡策略选定下游的Host,选定以后看看有没有已经建立的连接,如果有就直接使用,如果没有就新创建一个链接,用完了之后再放里面。这样可以复用Client和Server建立的连接池,减少销毁创建的次数,性能也有所提升。大家实际使用也建议使用这种方式。
No.3
Go依赖管理
前面讲的是我们这个框架做Server和Client遇到问题和解决方案。后面讲一下我们现在怎么做公司依赖管理。
依赖管理在Go语言讨论比较多,因为之前一直没有官方的解决方案,各种解决方案五花八门。虽然现在官方已经出了解决方案,但是我们做的时候还没有。在说依赖管理的时候我们先看看百度的构建系统。我们开发环境可以在Server开发,也可以在Windows开发,我们开发结束需要统一的编译地方,我们公司为整个语言提供了一个编译集群,所有语言编译都是在云端编译集群完成的。每一个语言都有他对应的依赖管理的工具,有一些是开源的,有一些是自己开发的,比如说C++,他没有开源依赖管理工具,我们就自己开发。这些语言依赖一般是在公司私有源上,通过我们依赖管理从私有源下载依赖,在编译集群进行编译,会有编译的输出,这是其他语言的做法。
但是针对Go语言跟其他语言有不同点他的依赖源不是集中式,是分布式的源。所谓分布式的源就是我的依赖可能分布各个地方,不是集中一个地方管理的,所以导致Go语言依赖的复杂性。我们实际使用过程中,也觉得这是一个缺点,不是太好做。我们公司内部也没有统一的依赖管理工具,所以针对Go语言要做一个依赖管理工具。
在我们做这个依赖管理工具之前,先看看公司有哪一些资源可以利用,我们现状是什么样的?
首先是我们的编译集群无法访问外网。我们公司对于安全非常重视,所以开发机和编译机群不能访问外网。因为刚才说Go语言是分布式的,从外网下载是不可能的。我们只有GitHub Mirror可以利用,这个解决了不能访问外网怎么从外面下载代码的问题。我们公司内部使用Git进行管理,公司内部的依赖不符合规范的,必须要是一个内部的管理的一个域名,加上路径地址,必须完整的路径我们引用这个包。那什么叫不符合规范呢?大家在Go语言的一些入门的书上会讲我们怎么开发Go语言,比如说我们先建立一个文件夹,写一个包叫A,别人使用A的时候,就是Import A,这种方法是不规范的,因为我们单纯从Import A看,不知道这个东西到底是标准库的还是自己写的,我们没有办法判断A到底从哪儿来,如果使用开源的,就会发现一般Import都是github.com/xxx/A,我们如果自己不写就没办法区分这些东西。如果两个团队都写了一个包都叫A怎么办?同一个地方都要使用,就没有办法用了,就产生这个冲突了。
面对公司的现状我们能不能用开源的方式解决呢?我们这边调研了一些开源的解决方案,开源依赖管理工具做了对比,Godep大家知道用比较多比较早的是比较简单,但是问题Import地址与下载地址绑定无法下载。另外就是Glide,可以指定mirror,这样可以解决对外网依赖问题。但是这个Mirror是属于整个环境进行管理,不同的人环境不一样,这个Mirror不一样,所以我们没有办法做统一,也不好做个性化。另外这个Mirror一个个指定也比较复杂。比如依赖A,知道Mirror就写Mirror如果我们依赖10个包,100个包需要一个一个指定。另外我们依赖A,A依赖BCD,我们不知道,我们拿了A还要去看BC,整个过程比较繁琐。另外是GO MOD可以使用Replace,跟Mirror一个意思,这个Replace随着依赖管理文件进行维护,每个项目有自己的Replace,跟别的项目不产生冲突。Replace一个个指定比较繁琐。另外我们可以使用Go proxy,但是要使用它就要适配各种外网的不同源,有一些可以直接访问到,有一些由于政策原因没有办法访问这些网站,这也是比较繁琐的点。另外我们下载这些外网之后,还要做很多东西,我们依赖这个外网,他删除怎么办?我们内部还要维护各种缓存和版本。相对现在来说做起来比较复杂的。另外一个对于我们内网模块也不支持,我们内网地址是不支持Go Git的协议,他没有针对Go语言做优化,所以这一块没有办法使用。
开源方案不能用,我们之前怎么做的呢?首先使用Vendor,我们在Github没有问题,但是公司内部来说,我们把所有的资源放在hub对我们来说是存储资源浪费。再就是依赖打包,放到FTB服务器上,编译的时候再下下来解压编译。这种方式是绕过了我们公司的安全检查机制,我们公司对于外部开源的代码的引入也是有明确的规定,你这些代码必须在公司的监控范围之下,不能随随便便用了外部的东西,因为他不安全。这种方式公司是禁止的。还有第三种方式就是自建镜像,我们把外部放到公司内部再建一个镜像,但是缺点维护成本比较高。我们依赖外部开源的包可能升级,我们内部也要升级,可能这个包随着包越来越多,谁维护这些包?这个维护成本是非常高的。
刚才说的方案不可行,我们现在怎么做的呢?
首先不知道大家知道不知道这个Go语言的协议,我们可以遵循这个协议把版本工具写在Git工具里面,可以通过那个信息下载,不一定是在你这个地方下载,可以利用这个知道每一个外部开源依赖在什么地方。另外,有很多比较老的Go语言是不遵循规则的,或者因为网络的问题我们没有办法访问到这个东西,或者没有办法拿到这些东西,我们也是没有办法拿到这些规则,我们就通过我们手动的方式把这些规则总结一下,做一个映射,最终我们会把遵循协议的都放在Github上。我们开发编译机群不用依赖外网就可以得到。最重要的是从我们Github和内部平台下载,这样解决了我刚才说的问题。而那些实在无法通过这种方式获取的依赖,现在解决方式是在公司内部建一个镜像。
我们这种做法优点是编译环境不需要依赖外网,保证我们编译环境稳定性、安全性,也能从开源的地址下载依赖。另外提供了GoGet一样的功能,就是多级依赖都可以下载,而且会帮我们自动生成依赖文件以及和对应的映射关系。我们我们生成依赖文件之后,每一个包都有对应映射的下载地址,我们是自动生成的,如果有一些个性化需求想改可以直接改文件,他可以帮助你减少需要一个一个写的繁琐。最后,我们公司内部只有Git,所以只支持Git,并且只能在Github下载。这个就是是目前做依赖管理的做法。
No.4
Go代码检查
刚才说我们公司比较注重代码的质量,我们怎么保证代码质量?我们有这么一个流程,我们首先针对每一个语言制定公司主流的语言制定规范,这个规范可能包括一些社区的规范,或者内部的一些个性化的规范。另外,为了让规范落地,大家能够遵循这个规范,我们不用人工检查,针对每一个语言制定代码的检测工具。帮我们自动化完成整个规范的检查。另外我们通过公司的CI系统进行集成,每次代码提交都会组织一个流程帮我们做检查,如果发现代码提交不符合规范,它就会把这个错误的信息打出来,然后整个代码的核入也会Block,最后进入人工Review评审,这个代码就会被人工评测哪一个地方不合适,为什么不合适,会帮你列出来,把你这个提交打回,重新回到前面流程,再进行检测,直到你的东西都符合规范。针对Go语言我们也制定了很多语言的规范。
代码分类
包括一些命名规范、注释规范、数据规范、便生命规范、控制六规范等等,具体规范包括50条左右,我们把这些规范划分成不同的等级。
首先是我们ADVICE,这个等级表示你的写法没有问题,但是会有更好的写法告诉你。另外就是WARNING你这个写法可能存在一些问题,建议用另外一个方法代替他。ERROR的话就说明你这个写法绝对有问题,一定要把它改了,否则你的代码没有办法核入。
内部实现的原理
那么代码检查工具怎么做的呢?请看下图。
首先是有一个配置文件,配置了我们检查工具具体的规范,以及规范对应的一些参数。比如说我们检查一个函数的返回词的个数,我们就把函数返回词规范放这里面,规定个数比如说三个,不能超过三个,我们把参数放在这里面,这样我们可以在程序启动加载的时候把这些规范以及对应的参数加载内存里面。对于我们源代码进行分析变成AST的数据结构,再把AST数据结构插入具体的规范当中。包括Import的规范等等,把AST结构传到规范函数里面去。对于AST结构进行分析,看是否符合规范,如果符合规范就PASS,如果不符合就把这个AST根据不同等级,把结果进行多种格式的输出。如果想自己看,我们可以利用工具查看。这个是我们代码检查工具实现的一个方案。
这里举一个具体的例子。这个是检查我们函数的返回词的一个例子。首先我们把刚才说的AST传进来,判断是不是函数,如果是函数,我们再判断返回词个数多少个,然后再与我们公司规定的个数进行一个对比,如果比我们配置的个数大,就会有一个错误的返回,可能包括了我们具体是违反了哪一条规则,他是什么级别的,他具体注册信息是什么,以及违反这条规则对应具体规则细节是什么样,如果你不知道规则,可以通过这些东西查看,会把信息返回过来,看看违反什么规则,规则是怎么规定的,保证代码的质量。
总结
今天要分享主要内容,刚才说的三大块,基本分享结束了。下面分享公司内部Go语言的使用情况。
首先Go语言使用情况呈现几个特点,第一是业务流量比较大,比如说我们的BFE可能很多人听说过这个部门,他是百度所有的流量的入口,所以他是我们公司流量最大的一个模块。还有前一段时间比较火就是春晚抢红包的项目,所有的流量都是走BFE,BFE还自己用C++开发,现在全部用Go语言重构了,负责人说如果不是用Go语言重构,像春晚抢红包时间短、流量大用C++完成不了,所以Go语言开发效率比C++高很多,性能也非常好。
另外一个特点安全要求高的一些业务,比如说自动驾驶部门,很多业务也都在使用Go语言,还有一些迭代快的项目。比如说去年我们百度智能小程序发展特别快,迭代也是特别多的,特别频繁。百度智能小程序所有C端程序都是用Go语言进行编写,侧面反映我们Go语言的开发效率。
还有用户最多的,我们部门就是百度APP现在很多业务也都是在用这个Go语言进行重构,或者已经上线的项目。这个是目前Go语言在公司内呈现的一个特点。
Q & A
Q1:你好这边有两个问题想问一下,第一个你刚刚提到开发规范我想咨询我们有很多检查项,这些检查项权重怎么管理的?
A:这个我不是太专业了,这个工控效率做得比较多,有一个非常复杂的算法,针对每一个规范都有一个检查的权重,这个权重具体怎么算、算法怎么做,我们公司内部有一个技术分享,刚才的规范发布了企业开发规范工艺标准1.0的东西,在网络上可以搜到,可以拿到具体规范的详细的内容以及比较详细的算法。
Q2:我想咨询一下依赖管理怎么管理,刚刚提到百度依赖管理工具,我想问可能存在多个第三方或者一个版本,这个怎么做?
A:Go语言同一个项目只能存在一个版本,这个东西不变。
Q:我知道,有很多间接依赖,里面存在使用不同第三方库的版本?
A:具体版本在我们文件里面都是有明确标识,这个依赖文件会写明你依赖哪一个版本,编译的时候会下载你指定版本的的库。
Q3:间接依赖,比如说你依赖B,B里面有一个版本锁,版本锁依赖C和E,A依赖B,A也有第三方锁?
A:明白了,我们如果用这个版本,就看他如果有版本管理文件,肯定会写上自己的版本,如果他不写也不知道用哪一个版本。
Q:写了是怎么做判断?
A:使用不同依赖管理工具,如果使用自己统一依赖管理工具可能做这件事,如果使用不同依赖管理工具,现在没有做,如果要做可能我们要针对不同的依赖管理工具生成的依赖的包,具体的样式做适配,分析这个依赖文件到底是什么样,依赖版本什么样,只能这么做。