The Economist经济学人是如何使用Go语言构建内容平台微服务架构的?(4)

发布于2019-04-21 20:33:44

本文要点

我以Drupal开发人员的名义加入了The Economist工程团队。然而,我真正的任务是参与一个从根本上重塑The Economist内容交付技术的项目。在最初的几个月里,我学习Go,与一位外部顾问合作,用几个月的时间构建了一个最小可行产品,然后重新加入团队,指导他们的Go之旅。

随着新闻消费从纸媒转向数字媒体,The Economist的使命是抵达更广泛的数字受众,这是这种技术转变的推动力。The Economist需要更大的灵活性,将内容提供给日益多样化的数字渠道。为了实现这一灵活性的目标并保持高水平的性能和可靠性,平台从一个整体架构过渡到了微服务架构。用Go编写的服务是其新系统的一个关键组件,它将使The Economist能够提供可伸缩的高性能服务并快速迭代其新产品。

Go在The Economist的推广:

The Economist为什么选择Go?

为了回答这个问题,有必要重点介绍下新平台的总体架构。该平台称为内容平台,是一个基于事件的系统。它响应来自不同内容创作平台的事件,并触发在离散工作者微服务中运行的处理流。这些服务执行数据标准化、语义标签分析、ElasticSearch索引以及将内容推送到苹果新闻或Facebook等外部平台等操作。该平台还有一个RESTful API,它与GraphQL相结合,是前端客户端和产品的主要入口。

在设计整体架构时,团队研究了适合平台需求的语言。将Go与Python、Ruby、Node、PHP和Java做了比较。虽然每种语言都有其优点,但是Go最符合平台的架构。Go的并发性和API支持,以及它作为静态编译语言的设计,使得它适合实现大规模执行的分布式事件处理系统。此外,Go语法相对简单,学习以及开始编写工作代码更容易,这让一个经历了如此多技术转换的团队可以实现速赢。总之,可以确定,Go这门语言是兼顾了基于云的分布式系统的可用性和效率的最佳设计。

三年之后,Go帮助实现了这些远大的目标了吗?

Go语言可以很好地满足平台设计的几个要素。快速失败是系统的关键部分,因为它是由分布式的独立服务组成的。遵循十二要素应用原则,应用程序需要快速启动和失败。Go被设计成一种静态编译语言,启动速度快,而且编译器的性能在不断提高,对于工程或部署来说从来都不是问题。此外,Go的错误处理设计使得应用程序不仅能更快地失败,而且能更智能地失败。

错误处理

工程师很快就会注意到Go的一个不同之处,它没有异常,而是有一个Error类型。在Go中,所有错误都是值。Error类型是预先声明的,它是一个接口。Go中的接口本质上是一个命名的方法集合,任何其他自定义类型如果具有相同的方法,都可以满足该接口的要求。Error类型是一个可以用字符串描述自己的接口。

type error interface {
    Error() string
}

这为工程师提供了更好的控制和错误处理功能。在任何自定义模块中,通过添加一个返回字符串的Error方法,就可以创建自定义错误并生成它们,就像使用如下所示的来自Errors包的新函数一样。

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

这在实践中意味着什么呢?在Go中,函数可以返回多个值,因此,如果函数失败,它很可能返回一个错误值。该语言鼓励开发人员在错误出现的地方显式地检查错误(而不是抛出和捕获异常),因此,代码中通常会有一个“if err != nil”检查。这种频繁的错误处理一开始似乎是重复的。但是,Error作为一个值使开发人员能够使用该错误来简化错误处理。例如,在分布式系统中,可以通过封装错误轻松地实现重试。

网络问题是一个在系统中总会遇到的问题,无论是向其他内部服务发送数据,还是向第三方工具推送数据。下面这个来自Net包的示例重点说明了如何把错误作为一种类型用来区分临时网络错误和永久错误。当向外部API推送内容时,The Economist团队使用类似的错误封装来实现增量重试。

package net

type Error interface {
    error
    Timeout() bool   // 错误是超时吗?
    Temporary() bool // 错误是临时的吗?
}

if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
    time.Sleep(1e9)
    continue
}

if err != nil {
    log.Fatal(err)
}

Go的作者认为,并非所有例外都是例外。工程师应该聪明地从错误中恢复,而不是让应用程序失败。此外,Go的错误处理允许开发人员对错误有更多的控制,这可以改进诸如调试或错误可用性之类的东西。在内容平台中,Go的这个设计特性使得开发人员能够围绕错误做出深思熟虑的决策,从而增强了整个系统的可靠性。

一致性

一致性是内容平台中的一个关键因素。在The Economist,内容是业务的核心,而内容平台的目标就是确保内容可以一次发布随处阅读。因此,每个产品和消费者都必须与内容平台的API保持一致。其产品主要使用GraphQL来查询API,这需要一个静态模式作为消费者和平台之间的契约。平台处理的内容需要和这个模式保持一致。静态语言有助于实现这一点,并且很容易确保数据一致性。

Go的测试

另一个提高一致性的特性是Go的测试包。Go的快速编译与作为一等特性的测试相结合,使团队能够在构建管道中将强大的测试实践嵌入到工程工作流和快速失败中。Go提供的测试工具使它们非常易于设置和运行。运行“go test”就可以在当前目录中运行所有测试,测试命令有几个有用的特性标识。标识“cover”提供了关于代码覆盖率的详细报告。“bench”测试会运行基准测试,测试函数名要以单词“Bench”而不是“Test”开始。TestMain函数提供了方法,可以用于进行额外的测试设置,例如,模拟身份验证服务器。

此外,Go还能够使用匿名结构和模拟接口创建表测试,提高测试覆盖率。虽然测试并不是什么新鲜的语言特性,但是,Go使得编写健壮的测试并无缝地嵌入工作流变得很容易。从一开始,The Economist的工程师就能够把测试作为构建管道的一部分来运行,无需进行任何特殊的定制,甚至在将代码推送到Github之前,还添加了Git Hooks来运行测试。

不过,该项目在实现一致性方面并非没有困难。该平台面临的第一个主要挑战是管理来自不可预知的后端的动态内容。该平台主要是通过JSON端点使用来自源CMS系统的内容,而数据结构和类型并没有保证。也就是说,平台不能使用Go的标准编码/json包,该包支持将JSON解组成结构,但是,如果结构字段和传入数据的字段类型不匹配,就会出现莫名其妙的问题。

为了克服这一挑战,需要一个将后端映射到标准格式的自定义方法。在对该方法进行了几次迭代之后,团队实现了一个自定义的反编组过程。虽然这种方法感觉有点像重建一个标准库的包,但它让工程师可以细粒度地控制如何处理源数据。

网络支持

可伸缩性是新平台关注的焦点,而Go的网络及API标准库支持可伸缩性。在Go中,你可以在没有任何框架的情况下快速实现可伸缩的HTTP端点。下面的示例使用标准库的net/http包创建了接受请求和响应写入器参数的处理程序。在内容平台API首次实现时,它使用了一个API框架。最终,标准库取代了它,因为团队认识到,它可以满足他们的所有网络需求,而不会带来额外的代码膨胀。Go语言的HTTP处理程序是可伸缩的,因为处理程序上的每个请求都在一个轻量级线程Goroutine中并发运行,不需要自定义。

package main

import (
    "fmt"
    "log"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World!")
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

并发模型

Go的并发模型提供了多项跨平台的性能改进。处理分布式数据意味着要与承诺给消费者的保证作斗争。根据CAP定理,在以下三个保证中,要同时提供两个以上的保证是不可能的:一致性、可用性、分区容错性。在The Economist的平台上,最终一致性是可以接受的,也就是说,数据源的读取最终是一致的,在所有数据源达到一致状态的过程中,适度的延迟是可以容忍的。缩小这种延迟的其中一个方法就是利用Goroutines。

Goroutines是Go运行时管理的轻量级线程,用于防止线程耗尽。Goroutines支持跨平台优化异步任务。例如,该平台的其中一个数据存储是Elasticsearch。当系统内容更新时,Elasticsearch中指向该项内容的引用将被更新并重新索引。使用Goroutines减少了再处理时间,确保内容项更快地达到一致状态。这个例子说明了如何在Goroutine中对每个符合再处理条件的内容项进行再处理。

func reprocess(searchResult *http.Response) (int, error) {
	responses := make([]response, len(searchResult.Hits))	
	var wg sync.WaitGroup
	wg.Add(len(responses))
	
	for i, hit := range searchResult.Hits {
		wg.Add(1)
		go func(i int, item elastic.SearchHit) {
			defer wg.Done()
			code, err := reprocessItem(item)
			responses[i].code = code
			responses[i].err = err
		}(i, *hit)
	}
	wg.Wait

	return http.StatusOK, nil
}

设计系统不仅仅是简单的编程,工程师必须了解在什么地方什么时候使用什么工具。虽然Go作为一个强大的工具满足了The Economist内容平台的大部分需求,但其存在的某些局限性需要其他的解决方案。

依赖管理

Go在发布时没有依赖管理系统。社区开发了一些工具来满足这一需求。The Economist使用了Git Submodules,这在当时是有道理的,因为社区正在积极推动一个标准的依赖管理工具。到今天为止,虽然社区距离一种一致的依赖管理方法更近了,但还不够好。在The Economist,子模块方法并没有带来重大的挑战,但对于其他Go开发者来说,它一直是一个挑战,这在转换到Go时需要加以考虑。

对于这个平台,还有一些需求是Go的特性或设计不太适合的。由于平台增加了对音频处理的支持,当时用于元数据提取的Go工具非常有限,所以,团队选择了Python的Exiftool。平台服务在Docker容器中运行,它支持安装Exiftool并从Go应用程序中运行。

func runExif(args []string) ([]byte, error) {
	cmdOut, err := exec.Command("exiftool", args...).Output()
	if err != nil {
		return nil, err
	}
	return cmdOut, nil
}

在该平台中,另一个常见的场景是,从源CMS系统中获取损坏的HTML,将其解析为有效的HTML,并对HTML进行清理。这个过程最初是用Go实现的,但是,由于Go的标准HTML库需要有效的HTML输入,所以需要编写大量的自定义代码在清理之前解析HTML输入。这段代码很快就变得非常脆弱,并且遗漏了一些边缘情况,因此,团队在JavaScript中实现了一种新的解决方案。JavaScript为管理HTML验证和清理过程提供了更大的灵活性和适应性。

JavaScript也是平台中事件过滤和路由的常见选择。事件通过AWS Lambda进行过滤。AWS Lambda是仅在调用时运行的轻量级函数。一个用例是将事件过滤到不同的通道,例如快通道和慢通道。该过滤是基于事件封装器JSON对象中的单个元数据字段完成的。这种过滤实现利用JavaScript JSON指针包获取JSON对象中的元素。与Go所需的完整JSON反编组相比,这种方法要有效得多。虽然这类功能可以通过Go实现,但是对于工程师来说,使用JavaScript更容易,并且它提供了更简单的Lambda表达式。

回顾

在实现了内容平台并投入生产应用之后,如果要我对Go和内容平台做一次回顾,那么我会作出如下回复。

什么做得好?

什么还可以改进?

总的来说,这是一种积极的体验,Go是实现可扩展内容平台的其中一个关键因素。Go并不总是正确的工具,没关系。The Economist拥有一个多语言平台,可以根据需要使用不同的语言。在处理文本块和动态内容时,Go可能永远都不是第一选择,所以JavaScript会在工具集中。然而,Go的强项构成了系统扩展和发展的基础。

在考虑Go是否适合自己时,请回顾系统设计的关键问题:

如果你正在设计一个系统,旨在解决分布式数据、异步工作流、高性能和可扩展性的挑战,我建议你考虑Go,它可以大大加速系统目标的实现。

关于作者

image

Kathryn Jonas目前是Teachers Pay Teachers的一名软件工程师,此前曾在The Economist担任内容平台的技术负责人。Jonas为北京、伦敦和纽约的组织领导项目,将技术应用于各种挑战,如任务影响评估、社论透明度和信任以及在线学习和协作。她热衷于参与软件架构的讨论,并与活跃的获得授权的团队一起工作。

查看英文原文:Using Golang to Building Microservices at The Economist: A Retrospective