前言:
跨境电商平台如何将业务从 C# 转换到 Go 语言,并最终均使用 Go 来实现?怎样从零打造一整套 Go 服务体系?怎样避免遇到转型微服务的坑?本文将通过 ezbuy 的资深开发工程师陈冶在 Gopher China 2017大会上的分享做详细介绍。
正文:
我们的平台在整个 Go 语言转型过程中涉及到一些微服务的转型,牵扯到微服务,如何管理这些服务,包括环境,这次分享我会从开发环境的构建,微服务选型,分布式追踪和跨数据中心四个方面来说。
一、开发环境构建
1、规范开发环境
每个人都有自己开发的环境,公司层面上很难保证每个人的环境是一样的,这样会导致很多兼容性的问题。有很多时候在本地开发很顺利,但一部署到线上或者到其他人的电脑就出现了莫名奇妙的问题,根源就在于环境不同。另一方面,我们在用微服务以后会引入一些框架,比如 gRPC,在单纯使用 Go 的时候很简单,如果要编译一个项目,我们直接用 go install 就可以解决,但如果我们引用一些第三方的语言,会颠覆之前的习惯,我们必须编两遍。Goflow 本身会负责解决这些问题,让一个命令可以编译全部的事情,同时统一每个人的使用环境。
2、引入 Goflow
(1)Goflow 是什么
它是一个 GOPATH,它的职责是把公司代码放到它里面,它本身也是一个 git 仓库。它的作用就是把它所在程序员的机子上面的 GOPATH 改成它自己,就是这么简单。它存在的意义就是隔离个人环境,给自己创造一个相对干净的环境。图1就是一个目录图,这是一个非常简单非常典型的GOPATH 结构。比较特殊的有一个 ezbuy.sh 文件,那就是 Goflow 的核心,可以看到,他就是一个 bash 脚本,要使用他,只要在 profile 文件里面 source 一下 ezbuy.sh 就可以了。
图1
(2)Goflow的作用
Goflow 的功能之一是可以修改 PATH,将 bin 目录放进去,这样很容易把一些第三方工具也引入进来,如果在命令行内执行一个程序,如果命中 Goflow 里面自身带的就可以优先执行,这样一来,可以把用到的代码生成工具都放进去,不受宿主机原来环境影响。它会负责一些基础库的更新,工具链的编译。它甚至不用自己去安装,只要安装 Goflow 后,全部东西都统一解决。最重要的是他成为了“公司内程序规范的标杆”,不至于每个人都搞一套不同的环境。
(3)使用Goflow解决依赖管理的问题
约定:
共享依赖:对于不同的项目,我们是倾向于他们共享第三方库的代码,在有能力的情况下,会强迫不同的项目,只用同一份第三方库存。
内网缓存:不走小水管,这个是非常必要的东西。
和业务代码分开:在看代码的时候,我们一般不会看第三方代码。
“随意”修改第三方包:这个随意不是特别随意,经过公司层面商议后可以随意修改,相对随意就是可以不用经过原作者的干涉,我们可以自己修改,并且这个修改可以传达到公司内的每个人,还有在线上的服务器。
实现方案:
不让第三方代码放到主 src 目录下面,有两种方案,一种是双 GOPATH 方案,另一种是使用vendor,我们采用的是 vendor 方案。它会在对应的作用域里面,看图2,在它所在的目录以及所有子集目录里面,找一个包,这个包在这个路径里面找不到,它会尝试在 vendor 里面找,vendor 可以当做一个 src 来用。因为我们要内网管理,所以我们会新建一个仓库来存放所有的依赖包。每一个第三方包假设是一个 git 仓库,这就涉及到我们如何用一个 git 仓库来管理上百个 git 仓库的问题。谈到这个层面我们会有两种方案,一种方案会在 clone 的时候动态去获取,这种是 git submodule,还有一个是直接整合进现有的仓库,这种是 git subtree。我们最终采用了 git subtree。这个库我们定了一个名字是 vendor,刚好和 go 的 vendor 机制相吻合。这样,我们完成了使用 Goflow 管理第三包依赖的事情。
图2
图3是我们现有的使用情况,在我们整个 vendor 里面,大概有1GB 的内容,在我们内网里面,这是下载完解压以后的。实际下载可以看图4,大概有200兆左右,我们内网速度大概20兆,通过WI-FI,只要花12秒就可以下载。如果没有做这一步,一个下午都可能下载不完。
图3
图4
既然我们做了 vendor,并且我们还需要一些其他工具,比如说我们自己的 ORM 代码生成工具,那是不是可以顺便把这些代码也整合进来呢?这样顺便管理了全部的工具链。在更新的时候,自动拉取记录的分支,自动编译,写进 bin 目录(go install),完成工具链的更新。
(4)关于 Goflow 的总结
全程的自动化:对个人环境来说,它不用自己额外做一些事情,等一下我会演示什么叫自动化。
巧妙管理第三方依赖包括工具链:依赖管理这块,我们没有用到任何第三方工具,主要方案实在太多,就算自己做也可以做出一个非常简单又比较通用的方法,至少在短期的时间内不会被推翻。
自我迭代:本身 Goflow 是一个 bash 脚本,它也需要更新,我们也会对它进行迭代,它只要一个命令,就会去远程仓库拉取最新代码,自我更新。
二、微服务选型
1、gRPC
所有的接口都是使用 protobuf 来定义,包括程序与程序之间,客户端与服务端之间也是这样。gRPC 是通用的解决方案,所以它接口设计必须做得很通用化,自由度比较高。对于公司内部来说,只要找到属于自己的最佳实践就可以了,剩下的就是扩散这个最佳实践,所以非常有必要的是需要去扩展代码生成,原来可能有很多条路可以做,我们内部选了一条路,全部人只要往条走,所以生成代码,就能减少每个人写代码的负担。为了配合 gRPC,我们选择 consul 做为服务发现和负载均衡。
2、接口定义和接口扩展
首先,我会讲接口定义,这里面有三层,包、服务、方法,比较有扩展性,我们可以给每一个单独的方法设置一些单独的配置。比如说图5.1这个例子,可以通过 option 配置路径。这个功能我们内部是怎么用的?我们并不是用 gRPC 来做,我们自己扩展了一套。因为我们全部的接口都用 pb 来定义,这就涉及到有些接口内部会用,有些接口外部才会用,这些都是通过 option 来定义的。
图5.1
接下来会说一下对应的关系,图5.2我写了一个文件,它生成的代码会在右边,生成一些方法,这个pb文件对应在项目里面是哪一个目录呢?就在右下,我们是固定的,这部分由 Goflow 自己生成,它会固定生成 RPC 目录,最后是 pb,可以看出是相互照应的。对于使用端来说,他们怎么用,像左下角只要引入这个包,写一行发起一个远程调用。左边和右边我都打一个红框,要着重讲。
图5.2
3、SDK vs RPC
(1)服务提供的内容不仅限于接口
先讲 RPC,有一个有争议的地方,作为一个服务我提供一个接口还是我要提供 SDK ?这里面有什么差别?如果是 RPC,那就是一个接口出来后我什么事都不管,如果提供 SDK,我会负责我的客户端。举一个例子,现在我们想做一个汇率方案,汇率会根据不同情况选择不同汇率,汇率会有不同规则,我们的规则可能是程序所描述的。如果我们没有 SDK,我们只提供接口作为客户端,它要知道每一个商品应该选哪一个汇率,只能通过每一次做远程调用,远程调用比本地要慢很多,如果提供了SDK,服务提供方了解自己服务的接口特性,将远程调用次数降到最低,甚至将整个规则通过结构化数据一次性传递过来,剩下的都是进程内操作。在这里我们借鉴了现有对于开源程序是怎么管理自己的服务,在我们这边如果它要提供服务,可以提供 RPC接口,也可以提供SDK,SDK 代码和生成的 pb 文件是放在仓库的 RPC 目录里面,这样项目和项目之间可以相调用,这在我们这边是一个约定。
(2)使用internal来隔离资源/函数
既然项目与项目之间可以约定相互引用,这样会遇到一个问题,可能误引用,当我们使用了 goimports 这个工具,它会检测代码里面写了哪些未引用的包,并且自动添加上去,这里非常可能出现一个误引用。这时候我们需要在项目层面,把这些代码隔离。怎么隔离,这个事情在 Go 里面已经实现了,这个在1.4内部已经在使用。intrenal 是关键词的目录名,它什么作用呢?它能够限制在 intrenal 所有包,所有包里面公开的变量,只能在 intrenal 所在目录的那个包或者下面的包使用。比如图6这个例子,如果一个包在 product 包外面,就没有办法引用。假设现在定义了接口,里面有三个方法,三个方法我全部都不会公开,全部写在 intrenal 里面,这样就不会误引用。特别在我们支持项目之间相互引用机制以后,这个是一个比较安全的保障。包括还有一些微服务,微服务最讲究的是资源独立,比如说第一个model,是一个orm生成的操作数据库的代码,它生成的代码,很有可能在不小心的情况下引用到。既然我们把资源隔离出来,干脆把它加入在里面,数据库获取数据肯定要走接口,而不是直接访问。
图6
现在再看图7和图8 。这个代码我们要发起一个远程调用,最简单的写法写成什么样子?我所能想到的,如图7标记出来的那一行,首先第一个是仓库名,后面我们有一个约束,get + 服务名,再拿到的单例,拿到单例以后,直接远程调用。如果我没这么做,一般情况下应该怎么做?我要写这么多代码,见图8,这些代码可以封装起来,我会去记录当前我的服务名,调用者是谁?我要拨号,拨号失败了怎么办?使用完后这个连接我不想用了怎么办?我是不是要关闭它,我什么时候关闭比较好?我是不是要复用,是不是每一个不同的地方都要开一个连接,这个连接直接复用起来,每次写远程调用都要想这些,会特别烦。在这里,干脆一条路切,全部连接全部复用,全部用一个单例可以做,我关心就是要调用一个接口,你跟我说成功还是失败,失败是什么原因,是我们内部的原因或者网络的原因,其他我不想了解。
图7
图8
刚才在代码层面我只需要要连接的服务名,当实际上服务是以 IP 加端口的形式存在。这样做需要从服务名转化 IP 列表的过程,可以用服务发现解决。我们要了解一个服务,它肯定会先访问一下 Consul,然后会有一个 IP 地址列表,gRPC 本身提供一个负载均衡器,我们只要把他和consul对接上,其他事情都不用管了。
4、项目改造
剩下就是我们怎么改造服务,实现上很简单,请看图9,最脏的活在第二行的 mservice server里面完成,他在基础库里面,整个结构都是封装过的,它只要在 Register 里面把对应的接口信息传递到mservice.Server,它就能直接起来。
图9
5、微服务化会带来一些问题
(1)阅读体验非常差:会经常遇到这种场景,在阅读代码过程中突然发现,我想看这个代码跳过去,结果是一个远程调用的代码。实际逻辑的代码应该去哪里找?很多公司对这些没有约束,不同项目有不同的路径,导致时间都花在了找代码上面了。目前来说没有比较好的方法,这个体验确实非常差。能够做到的是,我们有一个比较规范的代码存放规则。如果我们做了非常强制性要求,一个接口肯定放在哪一个地方,因为它的代码规则是非常简单的,在每次看到这一行以后,会直接去对应代码路径里面找,这些完全可以推导。包括以后有能力可以自己写 IDE 的插件,这一层完全是程序可以介入的,不用人工去做。
(2)调用栈无法真实还原:这是 Go 本身没有做的事情,微服务化把这个加剧了,这个后面我会讲。 虽然我们可以尽力让所有的客户端和服务端都用 gRPC 来做协议,但是对于 web 前端来说比较困难。我们还是需要 jsonrpc 做支持,当然我们不会手写代码,代码会根据 pb 文件去生成。路由方面,我们还是继续沿用 Consul,Consul 有官方做的一个工具叫 Consul- template,可以自己写模板,生成nginx配置文件,这样连nginx的路由自动化也搞定了。
6、总结
(1)使用 gRPC 做为服务框架,与服务发现深度结合:我们使用 gRPC 做一个服务框架,要做的只是写一个负载均衡器。
(2)让远程调用在形式上基本等于本地调用:代价上肯定会有差别,但是写代码尽量让人很舒服。
(3)接口路径可以直接推出代码路径:包括我刚才说的阅读体验,以及在找到接口问题的时候,我们可以直接知道代码在哪个地方。
(4)通过 option 可以做接口的定义:这样就不会出现一些不该开放到外网的程序,一不小心到了外网。定义接口超时时间或者说限制只能由某些服务才能访问我的接口,这些也是完全可行的。我们的约定是每一台机器本地都要开 Consul,每一个人在自己的开发上也要安装Consul。
(5)用 Goflow 解决依赖的工具链:在前面介绍的 Goflow 里面,只要通过一个命令,Goflow 有自动命令,它会自动连接到内网的 Consul 加入进去,这样也不同自己手动安装了。
三、分布式追踪
1、调用栈
(1)sentry,调用栈展示
调用栈这里是我们现如今的成果,分布式调用栈我们是怎么做的,图10来自于 sentry。比如 Go 的服务抛出一个错误,只是一个普通的错误,我们能够得到这样一个信息,它对应的调用栈和内容是什么,它对应的哪一个接口。包括这个请求它的参数这些我们都能够记录下来。你们看到的只是单机的方案,肯定我们要先解决单机,才能解决多机的问题。
图10
(2)跨进程的错误跟踪
跨进程的错误跟踪我们是怎么做的?如图11,我们现在客户端发起了请求到API,找到服务1,服务1找到服务2,再找到服务4,这时候服务4发生了错误,它需要把错误按原路扔回去,逐层把自己的栈信息往里面填。最终给客户端的也是一个 error,我们在服务1或者 API 已经能够得到一个完整跨进程的调用栈信息,这个信息我们不会给客户,我们会扔到 sentry 里面。图12中注意标红线的地方,在我们这边属于两个不同的进程。两个不同进程,他们在同一个调用栈里面展示出来,对于排查问题最有帮助。
图11
图12
2、 Context
分布式追踪我们是怎么支持的?我们在代码做了哪些调整?第一,context 它会记录上下文的信息,context 很早就被提出来了,在Go1.7的时候加入到标准库,接着 gRPC 我们引入了context。第二,它作用的范围,比如我在函数里面定义 context,这个函数要调用子函数,这个之子函数调用的时候传递进去,context 和函数调用栈是同一个东西,它会一层层包装,在下面它可以看到顶层的所有信息。第三,我刚才说的是进程里面,出了进程以后,进程和进程之间怎么传递。第四,把我们需要传递的信息通过Header来传递,这个完全透明的。
3、调用栈的做法
相信很多人都有比较好的解决方案。首先 Go 本身没有 Exception 支持。很多常规的错误处理都是这样的。见图13.1。
图13.1
然后在被几个简单但找不到在哪里发生的几个错误骚扰后,会变成这样的代码。见图13.2。
图13.2
就是自己写一个前缀,这个前缀可能是这个函数的名字。但是这样会将错误改写,一些类似 if err == io.EOF 的判断都会失效。所以又衍生出下一步懒人的做法,它不会破坏掉原来的信息,又能够进入信息,我们把错误包了一层,Trace 这里面只写了一种情况,如果我们想判断 eof 我们可以用 Cause 的情况。见图13.3。
图13.3
刚才那一种情况非常普遍,是目前比较正常的方案。我们采用了更进一步的做法,需要一步步来说。我们引入 gRPC 以后,我们可以在代码里面加入 context。这样是不是意味着我一些变量可以通过 context 传递。比如图14这个例子,我要访问一个商品,我在第一行就记录了 ID,传递进去,同时它把 context 也传递进去,它里面怎么做我不管,只要他基于 context 打日志,就能够把 productID 也打印进来。我们用结构化日志,好处是内部抛出什么信息就是显示了什么信息。我们需要知道整个一条链条对应上下文信息是什么,而不用管破坏掉原来打的内容是什么,里面什么内容就什么内容。在它把错误抛出去以后,gRPC 的中间层会捕捉到错误,将堆栈信息封装到 metadata 里面,传递给上级。
图14
4、死循环预防方案
对于微服务我们还可能遇到一个死循环,可能两个服务不会遇到,但如果现在如果有十个服务刚好构成一个环,肉眼是完全看不出来的。怎么解决这个问题,首先服务与服务之间信息能够传递。在路由器里面有类似的场景,他怎么避免一个数据包在链路里面无限流转,靠的就是TTL,在路由器经过一跳,TTL 会减1,直到0这个包就被抛弃。这个问题编译器可能解决不了或者比较困难,我们可以借鉴路由器的这个做法,这样在 TTL 为零的时候,直接报错,就需要我们重点去观察程序逻辑是否出现非常大的漏洞。
5、总结
对于分布式追踪总结一下,完善错误调用栈和日志打印,直接聚合分布式服务跟踪。现在有很多为了达到这个事情,他们会做日志搜集、解析,去做聚合,但是这个其实是杀鸡用牛刀,这个事情我可以根本不用日志收集,日志收集更多层面是用来分析了解服务整体的运行状况。
跨数据中心
这个是最后一个议题,跨数据中心。我们会有很多数据中心,我们会做差异化的部署,可以看这样一个图。这是我们 Gateway,大家可以看图15,Gateway 与 Gateway 之间也需要相互连接,这里面把 Consul 包起来,原因就是我们对 Consul 并不是很了解,我们希望不会出现我们不可预知的情况,并没有其他特殊的原因。跨数据中心的接口通讯是通过 Gateway 来完成的。
图15
2、Gateway解决方案
这里重点说一下一个程序怎么做到这个事情?见图16。Gateway 会得到其他数据中心的服务列表,写在本地的 Consul 里面,附加一个 “gateway” 的tag,在图里面以 G 表示。
现在 Consul 里面我们有Service1、2、3、4。它读 Service2 会检查是不是本数据中心也同时部署了一份,如果已经部署了,它会优先连接内网,如果是 Service3 会直接走 gateway 出去,Service4 内网。gateway 本身是一个 proxy。这是 Gateway 的总结,它本身也是 Grpc 来做,Gateway 与 Gateway 之间可以相互感知。Consul 本身不对外暴露,确保可控。客户端对多数据中心无感知,直接从本地的 consul 读取他要的服务的信息即可。
图16
四、总结
关于跨境电商平台的 Go 转型,首先个人开发环境尤其重要,将公司内部各种特殊流程标准化自动化,其次是统一使用微服务的框架,解决服务分布式跟踪问题,解决开发效率的问题。对于多数据中心的状况,我们使用 Gateway 将其透明化。
有疑问加站长微信联系(非本文作者)