Doordash经过各种语言评估后决定从Python迁移到Kotlin


当DoorDash达到了我们基于Django的整体代码库所能支持的极限时,我们需要设计一个新的堆栈,这将为我们的物流服务提供坚实的基础。这个新平台将需要支持我们的未来发展,并使我们的团队能够使用更好的模式进行构建。 
在我们的旧系统下,需要更新的节点数量增加了大量发布时间。由于每个部署拥有的提交数量,分析不良部署(找出导致某个问题的提交或提交)变得越来越困难。最重要的是,我们的整体构建于旧版本的Python 2Django之上,这些版本正迅速进入生命周期以寻求安全支持。 
我们需要打破整体/单体架构,使我们的系统更好地扩展,并决定我们希望新服务的外观和行为方式。寻找一个可以支持这项工作的技术堆栈是该过程的第一步。在调查了多种不同的语言之后,我们选择Kotlin是因为其丰富的生态系统,与Java的互操作性以及对开发人员的友好性。但是,我们需要进行一些更改以应对其不断增长的痛苦。
 
为DoorDash找到合适的堆栈
构建服务器软件的可能性很多,但是由于多种原因,我们只想使用一种语言。有一种语言: 

  • 帮助集中我们的团队,并促进在整个工程组织中共享开发最佳实践。
  • 使我们能够构建适合于我们的环境的通用库,并选择默认值以在我们的规模和持续增长中发挥最佳作用。 
  • 允许工程师以最小的摩擦来更换团队,从而促进协作。 

考虑到这些特征,对我们来说,问题不是我们是否应该学习一种语言,而是我们应该学习哪种语言。 
 
选择正确的编程语言 
我们从提出编码语言选择开始,提出了关于我们希望我们的服务如何相互看起来和相互操作的要求。我们很快同意使用Apache Kafka作为消息队列,将gRPC作为同步服务到服务通信的机制。我们已经在PostgresApache Cassandra上拥有丰富的经验和专业知识,因此这些仍将保留在我们的数据存储中。这些都是相当成熟的技术,在所有现代语言中均具有广泛的支持,因此我们必须弄清楚要考虑的其他因素。
我们选择的任何技术都必须是: 
  • CPU效率高且可扩展到多个内核
  • 易于监控 
  • 在强大的库生态系统的支持下,我们可以专注于业务问题
  • 能够确保良好的开发人员生产力 
  • 规模可靠
  • 面向未来,能够支持我们的业务增长 

我们将语言与这些要求进行了比较。我们丢弃了主要的语言,包括  C ++RubyPHPScala,这些语言不支持每秒查询(QPS)和规模的增长。
尽管这些都是很好的语言,但是它们缺少我们在未来的语言堆栈中寻找的一个或多个核心原则。考虑到这些因素,这种情况仅限于KotlinJavaGoRustPython 3。在这些产品作为竞争对手的情况下,我们创建了以下图表,以帮助我们比较和对比每种选择的优势和劣势。
 
比较我们的语言选择
  • Kotlin

–提供强大的库生态系统
–为gRPC,HTTP,Kafka,Cassandr和SQL提供一流的支持
–继承Java生态系统。
–快速且可扩展
–具有并发的本地原语
–简化了Java的冗长性,并消除了对复杂的Builder / Factory模式的需求
– Java代理以很少的代码即可对组件进行强大的自动内省,自动定义并导出度量和跟踪以监控解决方案
–在服务器端不常用,这意味着我们的开发人员可以使用的示例和示例更少
–并发并不像Go那样琐碎,它在语言的基本层及其标准库中集成了gothreads的核心思想
–缺少REPL
 
  • Java

–提供强大的库生态系统
–提供对GRPC,HTTP,Kafka,Cassandra和SQL的一流支持
–快速且可扩展
– Java代理只需少量代码即可对组件进行强大的自动自检,自动定义并导出度量和跟踪以监控解决方案
–并发性比Kotlin或Go(回调地狱)更难
–可能非常冗长,使得编写干净的代码更加困难
–缺少REPL
 
  • Go

–提供强大的库生态系统
–为GRPC,HTTP,Kafka,Cassandra和SQL提供一流的支持
–是快速且可扩展的选项
–具有用于并发的本机基元,这使得编写并发代码更加简单
–许多服务器端示例和文档可用
–对于不熟悉该语言的人来说,配置数据模型可能会很困难
–没有泛型(但最终会出现!)意味着某些类库很难在Go中构建
–缺少REPL
 
  • Rust语言

–运行速度非常快
–没有垃圾收集,但仍然具有内存和并发安全性
–随着大公司开始采用该语言,大量的投资和令人振奋的发展
–强大的类型系统可以比其他语言更轻松地表达复杂的思想和模式
–相对较新,这意味着更少的样本,库或具有构建模式和调试经验的开发人员 
–当时的生态系统不像其他异步/等待系统那样强大
–缺少REPL
–内存模型需要时间来学习
 
  • Python3

–提供强大的库生态系统
–易于使用
–团队中已经有很多经验
–通常容易被雇用
–具有对GRPC,HTTP,Cassandra和SQL的一流支持
–具有REPL,便于进行测试和调试实时应用程序
-运行相比,大多数慢 ,全局解释锁使其难以有效充分地利用我们的多核机器
-没有一个强类型检查功能
-卡夫卡的支持有时可能参差不齐且有滞后的特点
 
进行此比较后,我们决定开发经过测试和扩展的Kotlin组件黄金标准,从本质上为我们提供了更好的Java版本,同时减轻了痛苦。因此,科特林是我们的选择;我们只需要解决一些成长的烦恼。
 
Kotlin相对于Java的好处
与Java相比,Kotlin的最大好处之一就是null安全。必须显式声明可为空的对象,以及迫使我们以安全的方式处理它们的语言,消除了我们可能不得不处理的许多潜在的运行时异常。我们还将获得null合并运算符:?.,该运算符允许单行安全地访问可为null的子字段。
In Java:
int subLength = 0;
if (obj != null) {
  if (obj.subObj != null) {
    subLenth = obj.subObj.length();
  }
}

Kotlin:

val subLength = obj?.subObj?.length() ?: 0

尽管上面是一个非常简单的示例,但此运算符的强大功能极大地减少了代码中条件语句的数量,并使其更易于阅读。
与Kotlin相比,使用Kotlin迁移到事件监控系统Prometheus时,使用指标对我们的服务进行测试变得更加容易。我们开发了一种注释处理器,该处理器可以自动生成按度量的功能,以正确的顺序确保正确数量的标签。 
标准的Prometheus库集成如下所示:

// to declare
val SuccessfulRequests = Counter.build( 
   
"successful_requests",
   
"successful proxying of requests",
)
.labelNames(
"handler", "method", "regex", "downstream")
.register()

// to use
SuccessfulRequests.label(
"handlerName", "GET", ".*", "www.google.com").inc()

我们可以使用以下代码将其更改为不那么容易出错的API:

// to declare
@PromMetric(
  PromMetricType.Counter, 
 
"successful_requests"
 
"successful proxying of requests"
  [
"handler", "method", "regex", "downstream"])
object SuccessfulRequests

// to use
SuccessfulRequests(
"handlerName", "GET", ".*", "www.google.com").inc()

通过这种集成,我们不需要记住指标的标签顺序或数量,因为编译器和我们的IDE确保正确的数量并让我们知道每个标签的名称。当我们采用分布式跟踪时,集成就像在运行时添加Java代理一样简单。这使我们的可观察性和基础架构团队能够快速将分布式跟踪推广到新服务,而无需拥有团队的代码更改。
协程对于我们来说也变得非常强大。这种模式使开发人员可以编写代码使其更接近他们习惯的命令式样式,而不会陷入回调地狱。协程也很容易合并,并在必要时并行运行。我们的一位Kafka消费者的例子是

val awaiting = msgs.partitions().map { topicPartition ->
   async {
       val records = msgs.records(topicPartition)
       val processor = processors[topicPartition.topic()]
       if (processor == null) {
           logger.withValues(
               Pair("topic", topicPartition.topic()),
           ).error(
"No processor configured for topic for which we have received messages")
       } else {
           try {
               processRecords(records, processor)
           } catch (e: Exception) {
               logger.withValues(
                   Pair(
"topic", topicPartition.topic()),
                   Pair(
"partition", topicPartition.partition()),
               ).error(
"Failed to process and commit a batch of records")
           }
       }
   }
}
awaiting.awaitAll()

Kotlin的协程使我们可以按分区快速拆分消息,并按分区释放协程以处理消息,而不会违反将消息插入队列时的顺序。之后,我们将在消息代理偏移灰checkpointing时,加入所有futures。
这些只是Kotlin允许我们以可靠且可扩展的方式快速移动的便捷性的一些示例。
 
Kotlin的成长之痛
为了充分利用Kotlin,我们必须克服以下问题: 

  • 教育我们的团队如何有效使用这种语言
  • 开发使用协程的最佳实践 
  • 解决Java互操作性的痛点
  • 使依赖管理更容易

我们将在以下各节中详细介绍如何处理这些问题。
  • 向我们的团队教授Kotlin

采用Kotlin的最大问题之一是确保我们可以使我们的团队加快使用它的速度。我们大多数人都具有Python的深厚背景,并且在后端团队中有一些Java和Ruby的经验。Kotlin并不经常用于后端开发,因此我们不得不想出好的指导方针来教我们的后端开发人员如何使用该语言。 
尽管可以从网上找到许多此类学习内容,但Kotlin周围的许多在线社区都是特定于Android开发的。高级工程人员撰写了“如何在Kotlin中编程”指南,其中包含建议和代码段。我们举办了午餐和学习课程,教给开发人员如何避免常见的陷阱并有效地使用IntelliJ IDE来完成工作。 
我们教了我们的工程师Kotlin的一些功能性方面的知识,以及如何使用模式匹配,并且默认情况下更喜欢不变性。我们还建立了Slack渠道,人们可以在那里提出问题并获得建议,并建立Kotlin工程指导社区。通过所有这些努力,我们能够在Kotlin建立起一支精通流利的工程师的强大基础,随着我们增加员工人数,可以帮助教授新员工,建立自我维持的周期,从而不断改善我们的组织。
  • 避免协程陷阱

gRPC是我们用于服务到服务通信的首选方法,但是当时缺乏协程,需要对其进行纠正才能充分利用Kotlin的优势。gRPC-Java是Kotlin gRPC服务的唯一选择,但是它缺乏对协程的支持,因为协程不存在于Java中。两个开源项目Kroto-plusProtokruft正在努力帮助解决这种情况。我们最终都使用了两者来设计我们的服务,并创建了一个更加原生的感觉解决方案。最近,gRPC-Kotlin变得普遍可用,我们已经在很好地进行迁移服务,以使用官方绑定获得Kotlin的最佳体验构建系统。
进行此转换的Android开发人员会熟悉其他带有协程的陷阱。不要在请求之间重用CoroutineContext。取消或异常会使CoroutineContext进入取消状态,这意味着在该上下文上启动协程的任何进一步尝试都将失败。这样,对于服务器正在处理的每个请求,都应创建一个新的CoroutineContext。不再可以依赖ThreadLocal变量,因为可以将协程交换进出,从而导致数据不正确或被覆盖。要注意的另一个陷阱是避免使用GlobalScope启动协程,因为它是不受限制的,因此可能导致资源问题。
  • 解决Java的幻影NIO问题

选择Kotlin之后,我们发现许多声称实现现代Java非阻塞I / O(NIO)标准的库(因此可以很好地与Kotlin协程进行互操作)以不可扩展的方式实现了。他们不是使用基于NIO原语的底层协议和标准,而是使用线程池来包装阻塞的I / O。 
这种策略的副作用是在协程环境中线程池很容易耗尽,由于它们的阻塞性质,这导致了很高的峰值延迟。这些幻影NIO库中的大多数将公开其线程池的调整,因此可以确保它们足够大以满足团队的要求,但是这增加了开发人员进行适当调整以节省资源的负担。使用真实的NIO或Kotlin本机库通常可以提高性能,更容易扩展和更好的开发人员工作流程。
  • 依赖管理:使用Gradle具有挑战性

对于新手和Java / JVM生态系统领域的新手来说,构建系统和依赖项管理要比Rust的Cargo或Go的模块等较新的解决方案直观得多。特别是,我们拥有的某些直接或间接依赖项对版本升级特别敏感。诸如Kafka和Scala之类的项目不遵循语义版本控制,这可能会导致编译成功的问题,但应用程序启动时会失败,并带有奇怪的,看似无关的回溯。
 随着时间的流逝,我们了解到哪些项目最容易导致这些问题,并提供了如何捕获和绕过这些问题的示例。特别是Gradle上有一些有用的页面,介绍如何查看依赖关系树,在这些情况下这总是有用的。学习多项目存储库的来龙去脉可能会花费一些时间,而且很容易遇到冲突的需求和循环依赖关系。
从长远来看,提前计划多项目存储库的布局将大大有利于项目。始终尝试使依赖关系成为一棵简单的树。具有一个不依赖于任何子项目的基础(并且永不依赖),然后以递归方式建立在其基础上,可以防止难以调试或纠缠的依赖链。DoorDash还大量使用了Artifactory,从而使我们可以轻松地跨存储库共享库。
 
DoorDash上Kotlin的未来
我们继续将Kotlin视作DoorDash服务的标准。我们的Kotlin平台团队一直在努力构建下一代服务标准(在GuiceArmeria的基础上构建),以通过预先连接工具和实用程序(包括监视,分布式跟踪,异常跟踪,与我们的运行时配置管理集成)来帮助简化开发。工具和安全性集成。
这些努力将帮助我们开发更具共享性的代码,并减轻开发人员查找可一起工作的依赖项并使它们保持最新状态的负担。建立这样一个系统的投资已经显示出在需要时我们能够以多快的速度推出新服务的好处。Kotlin使我们的开发人员可以专注于他们的业务用例,而花费更少的时间编写最终将在纯Java生态系统中使用的样板代码。总体而言,我们对选择Kotlin感到非常满意,并期待继续改进语言和生态系统。
根据我们的经验,我们强烈建议后端工程师将Kotlin作为主要语言。Kotlin作为更好的Java的想法对DoorDash来说是正确的,因为它可以提高开发人员的工作效率并减少运行时发现的错误。这些优势使我们的团队能够专注于解决业务需求,提高敏捷性和速度。我们将继续投资Kotlin作为我们的未来,并希望继续与更大的生态系统合作,为Kotlin开发更强大的案例作为服务器开发的主要语言。