在外部看来,trivago 似乎是提供我们流行的酒店元搜索的单一软件产品。然而,在幕后,它拥有数十个项目和工具来支持它。鼓励团队选择最能完成工作的编程语言和框架。在这些决策中,对团队的限制很少,主要是长期可维护性。因此,trivago 拥有一个多语言代码库,可以培养创造力和多元化思维。它使我们能够根据实际需求而不是遗留代码或过时的项目做出明智的决定。
几个月前,一个新项目的工作机会出现了。为了改善跨多个会话的用户体验,启动了“最近搜索”项目。任务是开发一个gRPC 服务来处理来自前端的请求,以存储、检索和汇总登录用户的最近搜索。部分任务是在 Kubernetes 中运行服务,并针对我们的 trivago OAuth2 身份验证服务器对传入请求进行身份验证。我们的团队已经在使用 Java 或 Kotlin 等 JVM 语言的类似环境中进行过类似项目的丰富经验。然而,这一次,我们选择了 Go。这就是为什么。
竞争检测器
在 trivago 运行面向用户的服务意味着同时处理潜在的数千个传入请求。此外,暴露于开放的互联网绝对需要对超时 和共享资源进行适当的管理。对于这两点,我们非常确定我们可以依赖 Go 对并发的出色内置支持:
由于并发请求和对共享资源的访问成为常态,因此可能会发生错误。在之前的一个类似项目中,在我们决定并行运行它们之后,我们的集成测试随机开始失败并出现不同的结果。错误模式指向可能的竞争条件,经过检查,我们很快就能找到它。当您将此类视为单例 或请求之间的共享资源时,您能否在以下简化版本的源代码中发现潜在问题?
public class Service {
private String value;
public handle(Request req) {
this.value = req.stringField;
System.out.println(this.value);
}
}
该handle
方法将单个请求的数据存储在类实例的value
字段中,并在以后再次使用它。如果在此期间有另一个请求进入,则会发生数据竞争 ,并且行为未定义。幸运的是,我们在用户之前发现了这个问题,但我们不想让这个问题发生。让我们看一下 Go 中的相同示例:
type Service struct {
value string
}
func (s *Service) handle(req Request) {
s.value = req.stringField
fmt.Println(s.value)
}
Go 代码表现出与 Java 代码相同的行为,因此容易发生数据竞争。这就是 2013 年推出的 Go 竞争检测器发挥作用的地方。一个新的普通构建标志-race
现在允许启用数据竞争检测:
编译器使用记录访问内存的时间和方式的代码检测所有内存访问,而运行时库监视对共享变量的非同步访问。
在使用该标志编译上述示例时,处理两个并发请求会导致打印以下警告,并直接将我们指向有问题的代码:
==================
WARNING: DATA RACE
Write at 0x00c0000901e0 by goroutine 7:
main.(*Service).handle()
<module-path>@/cmd/test/main.go:16 +0x3e
Previous write at 0x00c0000901e0 by main goroutine:
main.(*Service).handle()
<module-path>@/cmd/test/main.go:16 +0x3e
main.main()
<module-path>@/cmd/test/main.go:8 +0xc3
Goroutine 7 (running) created at:
main.main()
<module-path>@/cmd/test/main.go:7 +0xa0
==================
Found 1 data race(s)
静态链接的二进制文件
默认情况下,
gc
工具链中的链接器会创建静态链接的二进制文件。因此,所有 Go 二进制文件都包含 Go 运行时,以及支持动态类型检查、反射甚至恐慌时间堆栈跟踪所需的运行时类型信息。(这里
gc
指的是 Go 编译器,而不是垃圾收集器)
与 Python 或 Java 等语言相比,运行使用 Go 编译的二进制文件不需要匹配版本的解释器或虚拟机。通过另外禁止包调用 C 代码 ( cgo
),我们可以创建没有任何运行时依赖的静态链接二进制文件。这让我们有机会将我们的 Docker 构建过程更进一步。 与其使用像 debian:stable-slim
或 alpine
这样的父映像,我们可以直接从零
开始创建我们的映像.
# This is a minimal example to demonstrate the 'FROM scratch' usage.
# It does not include steps to create and use a non-privileged user,
# add root certificates and time zone data, or perform other checks.
FROM golang:1.13.8 as build
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build ./cmd/my-tool
FROM scratch
COPY --from=build /build/my-tool /entrypoint
ENTRYPOINT ["/entrypoint"]
这几乎将 Docker 映像的大小 (20 MB) 缩小到我们的应用程序的大小 (18 MB)。相比之下,用于Java的最小Docker映像,如openjdk:8-jre-alpine
或gcr.io/distroless/java:8
本身的重量已经在85 MB到125 MB之间。不需要解释器或虚拟机也意味着镜像基本上没有启动 时间。鉴于我们在 Kubernetes 中运行服务的要求,小镜像和低启动时间是非常可取的,因为它们允许我们快速部署和自动扩展。
go fmt
间距和支撑位置可以说是围绕软件工程的辩论中最具争议的两个话题。除非您使用“语义依赖于不可见字符”的语言,否则它们本质上受个人风格的影响,对代码的正确性或性能没有影响。Go 附带了一个非常自以为是的源代码格式化程序。这是一个例子:
if err!=nil {return err}
$ go fmt
example.go
if err != nil {
return err
}
几乎每种语言都存在自动格式化源代码的工具,那么为什么要提到它作为选择 Go 而不是其他任何语言的理由呢?此外,“如果我更喜欢第一个版本怎么办?确定必须有一个设置来配置行为?”。我将尝试使用 Rob Pike在 Gopherfest 上 关于Go Proverbs的演讲中的一句话来回答这两个问题。
很多人,尤其是初学者,会说“我想移动大括号”,或者“为什么使用制表符而不是空格”之类的。谁在乎,闭嘴! Gofmt的风格没有人喜欢。它的格式化方式甚至不是 Robert Griesemer 喜欢的代码外观,而是他编写了程序。
– Go Proverbs – Rob Pike – Gopherfest – 2015 年 11 月 18 日
关键是,您可以花费数天、数周甚至数月的时间,试图找到一种所有人都同意的编码风格,但仍然失败。将代码格式化程序集成到工具链中而不是外部工具链中,可以防止大量无意义的自行车脱落。这就是为什么我们喜欢go fmt
我们的代码 并专注于功能。
结论
Go 已被证明非常适合我们的微服务,但它并不是唯一的。Rust 是另一种支持静态链接二进制文件的现代语言,并且“通过 Rust 的所有权系统主要防止了数据竞争”。这意味着它们将在编译时被捕获,而不仅仅是在运行时。然而,Go 的简单性和复杂的工具让我们不仅可以扩展我们的服务,更重要的是,可以扩展软件工程本身的过程。减少入职和培训人员的摩擦对公司的生产力有重大影响,在像 trivago 这样不断变化的环境中更是如此。
这就是我们选择 Go 的原因。