亿级用户日活千万的社交平台探探,如何用Go支撑后端工程实践

3,777 阅读23分钟
原文链接: mp.weixin.qq.com

作者简介

任 贺

在“探探”领导产品后端团队工作,负责处理开发 GO 到 PostrgreSQL 所有的数据库管理部分

1.探探的概述

2.Go 在探探后端的使用

3.探探工程实践

4.探探架构演变

5.分享小结

一、探探的概述

今天是第一次在Gopher大会上介绍我们在Go上的使用经验,探探算是国内比较早使用Go的公司之一,我们在做探探之前,就已经陆陆续续用Golang写了一些Web Server。



我先简单介绍一下:探探是一款陌生人交友的产品,面向的用户群主要是单身的年轻用户。探探的第一个使用场景是基于附近的人做一个推荐,你打开探探之后就可以看到附近的人,你可以选择右滑喜欢,左滑不喜欢,你喜欢的人如果也喜欢了你,你们就会形成配对的关系,或者说好友关系,好友关系表明你们可以聊天,发一些文字、图片等信息,这是探探产品的核心功能,整体来说是非常简洁的。



探探的数据使用方面,我们有一个最核心的滑动接口,当前每天有超过10亿次量级的滑动请求。

二、Go在探探后端的使用

构建探探后端目标及挑战

这一部分说一下Go在探探的使用的一些情况,以及当初所做的主要技术选型。开始做探探后端是2014年5月份的时候,我们当时有几个核心的目标

  • 作为工程师期望写比较整洁的代码

  • 希望我们的开发足够高效,能应对产品持续的需求变更

  • 架构扩展性考虑,包括水平扩展和垂直扩展,不管是应用层面还是数据层面,我们希望构建的产品有足够的扩展性

  • 高性能服务

同时也存在一些挑战

  • 第一个挑战是在业务复杂度方便来自产品的各类迭代需求

  • 第二个挑战就是如果我们在快速迭代的同时,怎样能保证软件的质量

  • 第三个挑战是我们怎样在开发和部署这一块能够更快的交付,更快地做迭代

  • 第四个挑战也是最根本的挑战是当时团队很小,只有三个研发工程师

最初三个工程师背景还挺不同的,有PHP,C#,Python,还有刚从学校毕业出来的同学会写一些C。团队成员也是讲不同的语言,有瑞典语,也有说芬兰语的同事。为什么我们选用Go呢?主要是在此之前的产品,为了满足了性能优化的需求,使用Go改写了一些后端的服务,后来做探探时就比较坚定了几个事情:第一是统一语言,第二是基于业务场景和Go语言的特性,我们认为Go能够更好地帮助我们达到研发的目标。

核心技术栈及选型

技术栈方面,在探探当前核心的开发语言是Go,存储我们使用了PostgreSQL,PostgreSQL选型最主要是因为探探作为典型的LBS社交产品,需要对用户的地理位置进行存储计算,同时也需要实现范围检索和附近人的推荐等功能,而PostGiS作为PostgreSQL比较完善的一个扩展,对于空间地理信息系统的支持是非常完备的。此外PostgreSQL本身丰富的数据类型、Partial Indexes、存储过程等特性,也是我们最终选择了PostgreSQL作为后端核心存储的主要原因。

使用的Go版本情况

其实我们2014年5月份之前Go的版本还是1.2,从整个时间线来看,Go的研发团队也兑现了每半年一更新的说法。中间比较大的改动,像Go 1.5版本,把整个的编译器使用Go重写,GC支持并发等特性。另外1.5版本之后整体性能的提升比较明显,1.6版本到现在的1.10,整体而言是比较稳步前进的状态。

代码仓库语言比例和行数

这里面简单把我们的Github仓库概况做了一个截图:



因为我们目前只有一个后端服务的仓库,Go语言比例占了75%,其它主要是JavaScript等前端的代码,这个是因为有一些内部的web系统也在同一个代码仓库中。对于代码的行数我们做了一个简单的统计,我们发现代码注释的比例目前相对还比较低,Go官方包的代码注释的整体比例占了代码行数的15%的量级。

我们在探探用Go做了哪些事情呢?

使用Go实现的后端服务



整体而言,探探对前端提供的核心功能是RESTful API,对应到后端主要是各类HTTP Web Services。其次,我们用Go处理一些图片的上传和下载相应的业务,这里面我们也做了一个单独服务去处理图片的上传、裁切和缩图等等。还有基于Go的net/rpc package做了一些RPC的server,比如内部的推送服务等。Web应用方面,我们使用了一叫Revel的web框架开发。除此以外,我们基本上也会用Go写一些小程序替换shell脚本、Cron jobs之类。

三、探探工程实践

探探工程实践分三个部分讲一下我们日常的开发,重点对于Go的代码测试做了一个汇总和总结,同时也对于我们设计和实现RESTful API分享几点心得。

3.1 日常开发

代码仓库

探探这边,目前只有一个核心的后端仓库,我们用Github做版本控制和版本管理。对于仓库的目录结构,每个公司的工程目录设置都不一样,这里面我列了我们目前在用的结构,大致的说一下:第一个是app,这个app里面有很多次级的package,这里面对应到不同服务的代码,因为整体后端是面向服务的架构风格;第二个是所有cmd的入口,包含了所有的main file和main packages;除此以外就是db,doc这些明确的目录。包管理早期也用了各种管理工具,现在基本上全部用Go官方处在试验阶段的dep来做管理。工作流程比较简单,大家目前是一个Fork,Pull Request和Code Review的流程。

Go的工具链



Go工具链还是比较完善的,比如go fmt做代码的格式化;go test 做单元测试;go doc比较方便看一些package的文档,特别是官方的文档,可以精确到包、类型和方法,其实godoc本身也是一个小的web server,可以指定端口运行,这样本地也可以访问Go官方文档,早期当Go官方网站不能访问的时候是非常有用的;此外也有go vet,可以对代码进行静态检查,报告一些潜在的、大家平时比较容易犯的问题,包括unreadable code,一些struct flag没有严格按照格式定义等。

IDE

IDE现在团队用得比较多,像Goland, VS Code等。IDE对Go相关的扩展支持特别好,如果用VS Code,像自动补全、自动的import、还有自动的fmt等支持都是非常的灵活强大的,对整体的工程效率提升帮助特别大。

Packages

除了官方的包,我们也会用比较多外部的第三方包。这边用go list工具对我们代码仓库做了一个小的统计,得到被引用包的一个排名。外部的依赖就比较多了,像路由、单元测试里面的框架等等。

Versioning



对于Go二进制的版本控制,因为目前使用Git版本管理,我们会把Git版本的信息编译到二进制中,这个是基于Go build过程中的ldflags参数,这个参数作用是Go程序在链接的过程当中,可以设置一些string value,通过这些string value可以将相应的信息编译到Go二进制里面去,早期因为每个服务只有一个二进制文件,这种版本管理方式对我们来说也是非常简洁灵活的。

把代码大致实现在这边写一下:



Profiling 



在我们日常开发过程当中,也会大量使用性能分析工具pprof,分析CPU, 内存以及goroutine的block状态。对于profiling的生成,有非常多的方式,像runtime包,net/http/pprof包也可以通过这个请求固定的方式去拿到相应的信息。同时Go test也可以获取相应性能的数据。对于性能的可视化,除了Go本身的工具开发性能,我们也应用了uber开源的go-torch用于生成火焰图。

Code Review

我们很长时间之内其实是没有设置编码规范的,这个也是使用Go语言获益最大的方面之一。我们使用go fmt等强制性要求,省去了很多编码规范的制定,不需要制定非常冗长的规则让大家遵守。但是Go官方还是有一个Go Code Review Comments,这个是我们用来参考的重要依据。

其它

其它也有一些常见的语言使用问题,第一个就是for ... range loop存在局部变量复用导致数据引用和赋值不符合预期的问题;第二个是未关闭的http response body都会经常遇到;其它细节就不在这里详细展开了。

3.2 测试Go代码

Go语言本身自带一个非常轻量级的单元测试的框架,同时还有一个testing包用于辅助测试用例编写,在Go里面写单元测试非常方便,效率也非常高,对于编写可测试代码和代码质量提升起到非常重要的作用。

关于代码质量分享一张有意思的图,主要怎么区分Good Code和Bad Code以及标准是什么, 大家可以感受一下。



可测试代码

首先为什么我们要在这里提可测试代码呢?因为从我们自己做研发的时间来看,软件开发时间和维护时间这两个没有一个非常平衡的比例,基本来说整个软件生命周期里面80%-90%的时间都是在做维护;另外可测试的代码也让会整个迭代过程更高效,特别是在早期对产品有持续不断的新需求、或者功能方面出现了一些临时bug时,可测试代码对新功能开发以及bug修复的流程提升非常有帮助,因为一个新功能需求或bug修复从实现到交付,可以做得非常非常短。

其次,在对代码质量的衡量标准中,我们认为除了代码的可读性和可维护性,代码的可测试性也是非常重要的一个标准。

最后是持续集成,如果没有完善的测试和自动化的测试,对整个持续流程来说是没有办法做到很好支持的。

Go的代码测试



测试在Go语言中是怎么样的呢?Go语言有一个内置的轻量级测试框架,由两个部分组成:第一是go test工具,第二是testing包。



testing包里有一个叫testing.TB的接口,testing.T和testing.B这两个结构体都实现这个接口。接口有一些通用的方法,像Error(...)和Errorf(...)等等,主要用来传递测试用例是成功还是失败的信号给测试框架。

如果有些单元测试需要一些多媒体或文件的数据,可以放到一个名为testdata的特殊目录中,go的相关构建等工具都会忽略这个特殊目录。

测试方法



对于Go的测试文件,有两种方式:第一种方式就是跟函数实现的代码放在一块,基本上来说,所属的包和所测试文件的包是一致的,此时测试用例实现可以访问这个包下所有的变量和方法,不管是可见还是不可见的;第二种方式是将测试代码放在与被测试文件所在不同的包中,也可以称之为黑盒测试,此时测试用例实现只能访问所测试文件导出的变量和方法。“把测试用例代码放在与被测试文件不同的包“在Go的官方包测试用例中有非常多的例子,比如:testing包需要import strings包,而strings包测试时又需要import testing包,造成了循环引用,这种情况下,给测试用例文件指定一个单独的包就成为必须的了。

范例测试

范例测试本来主要是作为生成文档中的范例使用的,但其实它也可以作为一个普通测试用例通过go test来执行,当然也是一种很好的编写测试用例的方法。

这里以官方的strings包为例,可以看到运行这些Example Tests同运行普通的测试用例方法是没有本质区别的。

基准测试

在Go里面还可以用单元测试框架去做基准测试,这里给一个strings包的基准测试例子: 具体的用法是执行go test时使用一个名为bench的flag,用于正则匹配具体的基准测试用例。

这个例子里也用了一个小技巧:在执行基准测试时,go test会默认执行所有的单元测试用例,如果你不期望这样的输出结果的话,可以通过使用run flag进行匹配要执行的单元测试用例,这里用到的正则因为不会匹配到任何一个单元测试用例,所以最终的输出结构只包含了基准测试用例的执行结果。

HTTP测试

HTTP Testing 主要是用于测试http cilent和http server的实现。 这里的requestHandleFunc函数是一个标准的HTTP Request Handle Func函数原型,我们在这个函数的测试用例里创建了一个类型为httptest.ResponseRecorder的变量,这个数据类型其实是实现了http.ResponseWriter接口,在我们编写具体测试用例的时候,可以把httptest.ResponseRecorder类型的变量作为一个http.ResponseWriter参数,这里最大的便捷之处在于你不需要真实启动一个Web Server就能完成整个测试,达到了单元测试对功能单元实现进行测试的目的。大家如果做过http test的话,都会大量用到这个包。

TestMain测试用例

很多时候,我们在批量执行一些测试用例之前或之后,会做一些额外的全局初始化和清理的工作,比如DB的连接初始化和连接关闭等,这个时候需要一些机制能够保证我们可以更好控制测试用例的执行。Go单元测试框架提供了一个叫TestMain的方法,在测试脚本执行时,会首先调用这个方法而不是每个单独的测试用例,真正的测试用例执行是通过其中的m.Run()这个方法来控制。示例代码中的setUp() 和 tearDown()函数是开发时大家自己定义实现的全局初始化和清理用的函数。

构造多测试用例场景

在编写单元测试用例时,我们有时会对同一个函数增加比较多的测试用例用于覆盖各种不同的边界条件,日常使用最多的一个实践是“表格驱动测试 (Table Driven Tests)“ ,这里可以使用匿名的结构体去创建一个slice,通过对这个slice的递归操作完成不同边界条件下的测试覆盖;第二种方法是可以去定义多个测试用例函数去满足不同边界条件的测试,这里带来的问题是会产生较多冗余的代码;第三种方法是在Go 1.7版本的时候引入的subtests,你可以对同一个函数仅创建一个单元测试函数,但可以通过t.Run()方法执行很多不同边界的测试,在执行不同的边界测试前后也可以设置一些全局的初始化和清理工作。subtests带来的最直接好处是测试用例的结构化和层次化,一方面体现在代码书写层面,另一方面也体现在测试的输出结果中。

 这个例子是官方net/http/httptest包下面的一个单元测试,测试用例叫TestServer,实现上它内部实现了很多种不同边界下的测试,这里面采用的就是subtests。subtests的实现是采用t.Run这么一个方法完成相应的调用,t.Run方法的两个函数参数分别是测试的名称和具体待执行的测试用例函数,这个函数一般通过匿名函数的方式传递进来。

 对采用了subtests实现的测试用例执行结果,可以看到执行结果不一样的地方是:未采用subtests的普通测试用例只会输出TestServer的测试结果,而采用了subtests的测试用例输出结果中可以看到非常完善的测试覆盖层级关系,每个子测试用例的测试结果和所消耗的时间也比较明确。

跳过或忽略测试用例

日常执行测试用例或者采用TDD编程实践时,经常会有需要显式跳过一些测试或忽略一些测试用例执行的场景,对于跳过测试用例,Go的测试框架也有非常多的方法能实现,典型的方式主要有四种,下面分别通过一个具体例子来说明:

第一种是通过testing.T结构体的Skip()方法去显式跳过具体的测试用例:



第二种是通过run flag匹配你所有期望执行的测试用例方法名或者函数名称,从而忽略其它不需要执行的测试用例: 

第三种是通过short flag,如果执行go test命令时使用了short flag,testing包中Short()方法会返回布尔值true,在代码中可以通过显式判断的方式决定测试用例的跳过与否:



最后一种是timeout flag方式:通过指定一个具体的超时时间来决定是否不继续执行测试。这个flag与其它flag的不同之处在于:如果测试在指定的时间之内没有完成就会panic不在继续执行,整体的测试也会失败。 

并行运行测试



如果你写了大量的单元测试且每次完整执行时非常耗时,这对软件的持续集成和部署是一个非常大的障碍。所以Go的测试框架也提供了并行执行测试用例的方法,具体实现是在每个测试用例函数中调用testing.T结构体一个名为Parallel()的方法。

通过一个具体示例对比可以看到明确的区别:



没有指定并行执行的测试用例在运行时,每一个测试用例都是顺序执行到最终返回结果,整体的测试耗时在6秒。



明确指定了并行运行的测试用例,整体的测试执行完成所需时间,取决于最耗时的测试用例时间,所以最终全部测试执行完成的耗时是3秒。对于并行执行的测试输出结果,我们也能看到相比非并行执行的测试输出结果多了PAUSE和CONTINUE两个测试用例执行状态。

测试输出结果



通过前面执行测试范例的输出结果可以看到,go test的输出结果主要有三个组成部分:第一部分是每个测试用例的执行结果,第二部分是整体的测试用例执行结果,最后是测试整体的耗时情况。

对于go test的测试输出结果,有时需要一些额外的解析和转换,比如我们使用Jenkins进行持续集成的时候,使用一个第三方包(例如https://github.com/tebeka/go2xunit)去转换测试输出结果为xunit格式。最近Go 1.10版本Go官方也是对这些细节增加了优化,引入了json flag,通过使用json flag,整体go test的测试输出结果可以转成JSON的格式,以下为一个JSON格式测试结果的输出范例:

测试覆盖率



Go对于单元测试语句测试的覆盖率也有比较明确的统计支持,执行go test命令时通过指定cover flag即可得到具体的语句覆盖率,以上为三个官方包的测试覆盖率参考。除了具体的测试覆盖率数据,我们还可以直接使用go的工具得到更为详细的可视化的测试覆盖率,比如下图通过网页形式输出的context包的测试覆盖率结果:

我们可以看到,context包的整体代码测试覆盖率达到了97%,对每一行代码的测试覆盖情况划分了三类:没有被纳入测试覆盖的代码行,测试未覆盖的代码行和测试覆盖的代码行。对测试覆盖率可视化,一些IDE比如VSCode等对语句测试覆盖率也有非常直观的代码高亮可视化效果。

集成测试

Go的测试框架主要还是做单元测试,而我们业务场景中有非常多的API需要增加集成测试,比如注册服务完成一个用户注册流程需要用到三个接口。这些接口测试过程如果能够自动化是非常有帮助的。当时选择了继续用Go的测试框架来做接口粒度的集成测试主要考虑有:首先是利用了Go测试框架的设计和测试用例的组织方式;其次是我们当时的接口测试需求比较明确,主要是做一些http请求调用,或者说模拟客户端的接口调用行为;最后是整体的测试输入输出比较简单,因为我们核心的接口是RESTful风格,且数据交互格式也是采用了JSON。

最终我们是基于Go的测试框架形成了一个后端接口的自动化集成测试框架,通过增加非常少量的几行代码即可达到增加一个测试用例的目的,这里我们也使用了一些第三方包,像testify包用于测试结果断言,gojsonschema包用于JSON返回结果的校验等等。

以下为我们日常运行集成测试的一个范例:

 这个测试文件其实跟普通单元测试文件完全一样,测试用例函数也是基于Go所有功能测试函数的定义和格式,每个测试用例函数中的主要实现是模拟客户端触发一系列的HTTP请求给服务端,不同之处是增加了一些额外flag解析支持等初始化的工作。

测试Go代码小结



除了以上分享的关于Go测试框架的细节外,还有更多的场景是go test命令和testing包可以实现的,比如对并发程序的单元测试、通过依赖注入的方式做更多复杂功能测试等等。在Go中写测试是一件非常有意思的事情,通过快速编译、快速测试达到测试覆盖率目标,这本身就是对软件质量持续迭代提升非常高效的过程。Go语言的官方包中有不少非常棒的代码测试范例和惯用的编写实践值得参考,此外目前也有很多开源的测试框架和测试库。

3.3 构建RESTful API

REST 核心概念

RESTful API 设计



JSON 数据交互格式



用Go建立RESTful API

除了可以直接用官方的net/http包去实现RESTful API外,也有不少优秀的框架可以快速构建RESTful API,比如beego和gin等。

四、探探架构演变

4.1 架构演变背景



后端架构经过四年的演变,现在面临的主要问题是:面向服务的架构是最初设计后端服务的架构风格,期间核心的RESTful API服务发展为了一个典型的单体应用,耦合的业务逻辑相对比较多,解耦成为当前架构下最核心需要解决的问题之一。

4.2 架构面临挑战



团队早已不是早期的三个工程师,现在有几十个后端研发工程师,且技术部也是超过百人的团队,团队之间的协作、业务松耦合的目标以及产品对软件持续交付效率的要求,都对当前架构提出了比较大的挑战。

4.3 未来的期望



对于未来的架构演变期望概括主要是:

  • 使用Go持续编写可测试、高质量的代码;

  • 落地微服务风格的整体架构改造,目前已经在进展中;

  • 构建高效的持续集成和持续部署,我们也期望在接下来增加更多的测试,定义明确的代码测试覆盖率,同时所有的测试能够高效的自动化执行。

    五、分享小结   

    

    在开发探探后端的这四年中,我们始终认为使用Go这门语言编程是非常有意思的事情,包括今天重点分享的go test框架使用的实践,而Go的测试框架仅仅Go工具集和语言特性里非常小的一部分,可以做的事还非常值得进一步挖掘。

    今天分享的开头并没有详细解释为什么选择Go,但基于今天的回顾:不管是在不降低软件质量仍能保持高效的工程实践,还是从我们每天都在愉快地使用Go来构建探探后端的经历来看,四年前选择Go作为我们的核心开发语言这个决定还是比较明智的。

    非常期望今天的分享能成为大家今后在做技术选型时的一个参考,最后引用 Effective Go 文章中的一句话结束今天的演讲:"Go is powerful enough to make a lot happen in a few lines"。


    2018年的 Gopher Meetup 将在深圳开启巡回第一站,这一次邀请了很多新的讲师给大家一起交流分享Go的使用经验〜

    点击阅读原文报名参加