查看原文
其他

英雄联盟的大厂开发商是如何玩转 Go 的?

苦瓜​先生 Go开发大全 2021-01-31
英文:RiotGames,翻译:Go开发大全 - 苦瓜先生

【导读】:《英雄联盟》是美国游戏开发公司拳头游戏(Riot Games)的代表作他家程序员在本文分享了 Go 语言在游戏开发和游戏运维领域的经验。





大家好!我叫 Aaron Torres,是 Riot 开发者体验团队的项目经理。我们部门的职责是辅助 Riot 游戏团队进行快速的游戏开发、并且协助进行后台服务的部署和运维。游戏团队所开发的微服务需要在全球范围内进行部署和运维。我在公司工作的 3 年多时间里一直在写 Go 代码。

在加入 Riot 之前,我写了一本关于 Go 语言的书,在 Riot 时,我把它更新到了第二版。在学习 Go 的过程中,我也深度参与了将大量单体应用向微服务架构的迁移的项目中,我们广泛使用 Docker 容器、Kubernetes 等容器编排工具,以及当需要在全球范围内做服务集群部署时所需要的其他相关工具。

我在 Riot 管理着两个团队,分别是服务生命周期管理团队和云服务集成团队,它们都隶属于 Riot 开发者体验团队(RDX)。RDX 除了负责管理公司的裸机数据中心和云厂商提供的云资源等基础设施之外还负责管理平台应用与平台服务之间的软件中间层。这些平台服务中的大部分,包括涉及到的第三方软件(如 Docker),都是用Go语言编写的。

我的团队管理着多项软件服务,具体有:全球化部署工具和在基于 SDN 软件定义网络之上的用于管理服务之间网络连接的访问控制管理层。此外,还需要负责启动和管理 AWS 资源,来供平台服务所有者来使用这些云基础设施资源。

当需要进行软件和程序语言选型的时候,我们鼓励整个 Riot 的技术团队为自己的产品挑选最佳的技术选项。在这篇文章中,我们将特别关注 Go 语言在几个不同团队中的使用情况。我将介绍两位技术专家,来自 RDX Operability 的 Chad Wyszynski 和来自 VALORANT 的 Justin O'Brien,他们会分享下如何在项目中使用 Go 语言。

Go语言背景资料

在 Riot,我们主要采用 Java 和 Go 来进行软件服务的开发。由于我们使用容器化的应用部署方式,而且这两种语言彼此之间可以进行互操作引用,此外对 java 应用和 go 应用来说,打包和部署都相对容易,因此技术支持团队将这两种语言都视为头等公民,并且给予优先的技术支持。

在Riot,我们喜欢 Go 的原因有以下几点:

  • Go 应用程序的分发和部署就像下载和运行二进制文件一样简单。这种语言特性对于构建CLI工具来说是非常友好的;

  • Go 语法简单,语言规范简洁清晰,可以让有其他语言背景的工程师快速上手开发;

  • Go 代码构建速度很快,快到你的编辑器可以在保存时进行重新编译构建来检查代码中是否存在错误;

  • Go 标准库功能强大,标准库中甚至包含一个生产级别可用的 web-server 实现;

  • Go 具有原生的并发能力和以goroutinego channel为核心的并发原语支持;

  • Go 对语言的最佳实践有自己的规范,gofmt命令让每个人的代码看起来都差不多;

  • Go 很少破坏向后的兼容性,当需要进行兼容性调整时,通常是利用依赖管理模块来调整语言库来进行处理而不是以破坏语言本身特性的形式;

  • Go 相对比较流行,这意味着有很好的第三方支持,其他软件厂商常常会开发出包含go客户端的第三方软件

科技领域中对 Go 语言的研究在最近也取得了不少进展,尤其是在微服务方面的进展引起了开发者的浓厚兴趣和广泛关注。在系统领域,它也变得越来越流行,比如 etcd、Docker、Kubernetes、Prometheus 等开源软件库。在结构化日志、共识算法、websockets等方面也有优秀的库。此外,标准库还包含了对 TLS 和 SQL 支持的内容。所以使用 go 语言可以快速提升生产力。

使用案例:RIOT部署工具

服务生命周期团队的主工作是开发和维护Riot部署工具。团队利用部署工具在docker运行时基础上进行服务部署,并且通过部署工具来管理服务的整个生命周期。如果你读过我们之前的 "运行在线服务"系列,你会对我们所面临的需求场景有更好的了解。我们的部署工具是用Go编写的,因为使用Go能让我们进行快速版本迭代,也可以让团队新加入的工程师快速的学习和熟悉我们的技术栈,从而快速实现从早期开发版本到生产版本的迭代。部署工具的底层由MySQL数据库支撑,并且部署工具的单实例可在多个数据中心进行部署和使用。面对上述的需求场景,go的语言特性能够让我们很好的应对这些挑战,诸如:

  • JSON/YAML 支持

  • HTTP客户端

  • 网络连接

  • API 集成

JSON/YAML 支持

我们的部署工具需要解析和运行一个描述应用运行时所需资源的自定义的YAML语法模版。有几个第三方的Go库为我们实现了 JSONschema 的解析。与第三方对YAML的支持类似,Go还提供了将Go结构Marshaling和Unmarshaling成JSON的原生支持。

schema_version: 1.0.0
application:
  nameapp.server
  version: 1.0.0
  description: "A micro-service that does import things"
  ownersmartperson@riotgames.com
  pack:
    count: 1
    containers:
      -name: platform.core
      image: platform/core
      version: 1.0.0
      resources:
        cpu: 2
        memory: 8192
部署工具需要使用的结构化YAML配置文件

HTTP 客户端

我们的运维工具需要与其他一些微服务如服务发现、日志、告警、配置管理、调配数据库等进行网络连接,连接的主要的方式是建立HTTP请求。这意味着我们要常常考虑请求的生命周期、互联网突发事件、超时等问题。幸运的是,Go提供了一个非常可靠的HTTP客户端,当然,在使用时HTTP客户端的某些默认配置需要调整。例如,客户端默认情况下永远不会超时。

res,err := http.Get("http://www.riotgames.com")
if err!= nil{
    log.Fatal(err)
}
results,err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err!= nil{
    log.Fatal(err)
}
fmt.Printf("%s",results)
发起Http Request请求并且打印出Http Response Body

网络连接

通常情况下,当数据中心与相邻数据中心进行网络交互时可以利用额外的网络安全层进行网络隔离,在这种场景下,我们在多个项目中使用的Go的一个特性是 Go httputil 反向代理。反向代理可以快速代理网络请求,并且可以使客户端无感的情况下以中间件的形式,以注入额外的验证信息或头文件信息,来为请求的生命周期添加中间件。

API 库

在Riot公司的服务开发过程中,我们必须与各种第三方服务进行交互,包括Hashicorp Vault、DCOS、AWS 和 Kubernetes 等。这些解决方案大多提供Go语言版本的 API 客户端库。有时,我们也会根据自己的需要使用或 fork 第三方库。几乎所有情况下,我们都能找到足够的支持来满足我们的需求。

此外,在开发过程中,重新编译并运行本地版本的部署工具的很容易的,通过使用本地版本可以进行快速的测试或调试,而且通过对API库的封装和分发可以使我们轻松地进行代码共享。

现在我们已经了解了我的团队是如何使用Go进行项目部署的,让我们来看看另外两个例子。

案例1:运维监控

大家好,我是来自 RDX 运维团队的 Chad Wyszynski,下面我为大家介绍一下我的团队是如何在我们的运维监控 PipeLine 中使用 Go 来减少请求延迟的。

Riot 的大部分日志和指标数据都要流经我团队的监控服务。这部分网络流量很大,而且持续很久,当有服务出现问题时,流量会激增,所以监控服务必须满足高吞吐量和低延迟的要求。没有人会愿意等待几秒钟来记录一个错误信息,而利用go语言中的Go channel特性使我们我们的代码实现很好的满足了这些需求。

运维监控服务存在的目的只有一个,那就是将监控日志和指标数据转发到后端观测平台,比如New Relic。该服务首先将请求数据格式化为后端平台所期望的格式,然后它将转化后的数据转发给后台。格式转换和数据转发都很耗时。服务也不应该强迫客户端进行等待,我们的处理逻辑是是将请求数据放到一个Bouned channel中,由另一个Goroutine处理。通过这种处理使得服务对客户端的响应几乎可以做到实时响应。

但是,当bounded channel满了会发生什么情况?默认情况下,Goroutine会一直阻塞直到通道可以接受数据。我们使用Go的time.After来对等待时间进行设置。如果通道在超时之前不能接受请求数据,服务就会返回503错误。客户端接收到 503错误后利用指数退避算法进行请求重试,

select {
    case workQueue <- requestData:
     return nil
    case <-time.After(timeout):
     return errors.New("queue is full")     
}

基于go-channel设计的另一个优势在于当需要从一个可观测后端迁移到另一个后端时。Riot最近将所有的指标和日志从手动管道转移到 New Relic。运维监控服务必须将数据转发到两个后端,同时技术团队需要在新平台上配置显示仪表盘和报警规则。得益于Go语言的管道特性,双向channel的数据发送基本上没有给客户请求增加任何延迟。我们的服务代码只需将请求数据添加到另一个bouned-channel中。那么,最大的服务器响应时间是基于Goroutine将数据转发到目标管道上所等待的时间,而不是目标服务器的响应时间。

var wg sync.WaitGroup
wg.Add(len(destinations))

for _,destination := range destinations{
    // enqueue data in parallel
    go func() {
        defer wg.Done()
        enqueue(requestData) //will block until data is queued or times out
    }()
}
//wait until data is enqueued or timed out for each destination
wg.Wait()

当我刚加入Riot公司时,我还是一个 Go 语言新手,所以当我看到 go channel 和 go Goroutines 的实际使用案例时我非常高兴。我的同事Ayse Gokmen设计了最初的工作流程;我很高兴能分享我们的工作。

案例2:VALORANT 游戏(无畏契约)

Justin O'Brien 来自 Valorant 的竞争团队!我的团队和在 Valorant 的其他所有团队一样都使用 Go来开发和实现所有的后端服务。我们的整个后端微服务架构都是用Golang构建的。这意味着,从服务初始化到管理游戏服务进程再到游戏中购买虚拟物品,所有的事情都是用Go代码来实现。尽管使用Golang为我们的所有服务带来了很多好处,但我将谈论三个特定的语言特性:并发原语、隐式接口和模块化包管理。

并发原语

当系统开始慢下来的时候可以使用golang的并发原语来提升后端负载,具体实现的方式是将应用后台进程中的独立操作并行化。其中一个例子是,在游戏进行玩家匹配的过程中,需要为每个玩家做游戏设置,例如在开始游戏时为每个玩家加载皮肤数据。我们对一个共享函数的要求是,一旦所有的子进程执行完毕就立即返回,并且返回子进程执行错误的列表。

func Execute(funcList []func() error) []error

我们通过使用两个channel和一个waitgroup来实现。另外创建一个通道用来在每个thunk执行时捕获错误,而另一个管道是一个接收子进程是否完成的管道,当waitgroup完成时,一个Goroutine就会发送上去。go语言的语言特性原生支持这种模式的实现。

隐式接口

另一个我们广泛使用的Go语言特性是隐式接口。通过使用隐式接口可以快速又轻便的来测试我们的代码,隐式接口也用来很方便的实现模块化代码。例如,我们很早就开始在所有的服务中使用一个通用的数据存储接口。,我们的每一个服务都可以通过这个接口来与数据源进行交互。

type Datastore interface {
    Fetch(ctx context.Context,key string)(interface{},error)
    Update(ctx context.Context,key string,f func(current interface{})(interface{},error))(interface{},error)
}

通过简单的接口定义的使用,可以在面对不同的需求时让我们可以实现接口来开发出不同的后端代码。在大部分测试场景下数据存储接口使用内存实现,接口方法的定义可以使得代码实现非常的轻量,并且可以在测试文件中对访问次数等特殊情况进行内联实现,或者对错误处理逻辑也进行测试。我们还为我们的服务混合使用了SQL和Redis,并为这两种数据库服务都进行了接口代码实现。对新服务中新增数据存储的操作变得特别容易,同时也可以很容易的扩展新特性新,比如由redis支持的内存缓存中的写入操作变得相对容易实现。

模块化包管理

最后,我想指出一些语言特性之外的东西,那就是可广泛选择使用的第三方包,并且这些第三方包通常可以与内置包互换使用。由于golang包的模块化特性,可以使得我们在进行项目重构时只需要做很小的改动就可以完成重构工作。例如,我们的一些服务在序列化和反序列化JSON时消耗了大量的CPU时间。原因在于我们在第一次编写服务时,使用了Golang的开箱即用的 json 包。

当然这个包可以适用于95%的用户使用情况,通常json序列化不会在火焰图上显示出来(现在想来golang内置的性能剖析工具也是很棒的)。有几个案例专门围绕golang中大对象的序列化,通过分析,我们发现服务的大量时间都花在了json序列化上。我们开始进行服务优化,在优化过程中发现有很多可替代的第三方json包与内置包是相互兼容的。这样一来,代码重构优化就只需要修改下面的这个一行代码。

import "json"
调整为:
import "github.com/custom-json-library/json"

代码调整之后,对JSON库的任何调用都会转向调用第三方库,这种调整过程也使得对不同包的性能剖析和测试变得十分容易。

GOPHER 社区实践

刚刚我们向大家介绍了在Riot公司中使用golang的相关案例,现在我想继续分享的是riot在技术选型和团队协作方面情况。在Rioter技术专家之间相互协作的基础上,技术团队在进行技术栈选型时能够拥有相当的灵活性。

Riot游戏是一家非常社会化的公司,我们的技术部门鼓励Riot公司员工积极参与社区活动并且在社区中学习。例如,我们的各种实践社区使得有共同兴趣的Rioter团队能够定期聚集在一起学习和分享。其中最活跃的技术社区是我目前负责的Go社区。我们也创建了一个专门Slack频道来讨论新的提案,我们每月都有一次见面会,社区成员们会在会上会针对他们目前所学习的主题或对了解到的其他比如用Golang编写的其他Riot项目等信息进行分享。

我们也非常期待Riot之外的社区参与到开源库维护中来。当开源库有变动时且变动会影响到多个团队时,我们可以在COP中进行讨论和协调。比如可以在COP中讨论模块镜像启动时的相关安全问题。此外,我们也会讨论诸如在构建容器时可能会遇到的问题等等,当然也可以针对某个方法、软件工具、代码库等一般性问题向领域专家提出疑问从而来寻求解答。

就我个人而言,我很期待能够拥有一个由不同团队和不同学科背景的Go爱好者组成的交流频道,希望能够在这个频道中交流彼此想法,讨论语言的变化,分享我们遇到的代码库。当我们从旧的Go依赖管理方案过渡到Go Module包管理方案时,我们就用这个频道进行了大量热切的讨论,并且也在这个频道中认识了很多对golang语言充满热情的程序工程师。


The Go CoP’s flier

总结

在 Riot 公司,有很多团队都在维护和使用基于 Go 语言开发的服务和工具。而 Go 本身提供的强大的标准库和完备的第三方社区支持能够很好满足我们的开发需求。

对 Riot 的开发者来说,我们的实践社区是一个能够用来分享其所学和所用的很好的渠道。通过在整个公司层面保持灵活性和高效沟通的基础上,我们对 Go 在 Riot 公司的未来的应用场景感到兴奋和期待。

感谢您的阅读!

参考资料: 
[1].https://technology.riotgames.com/news/leveraging-golang-game-development-and-operations
[2].https://medium.com/capital-one-tech/building-an-unbounded-channel-in-go-789e175cd2cd


 - EOF -





如果觉得本文不错,欢迎转发推荐给更多人。



分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存