美国外卖平台 DoorDash 原先的代码库是基于 Django 的单体应用。之前这个平台对业务的支持能力已逼近天花板。为给送餐服务提供更坚实的基础,DoorDash 需要全新设计的技术栈。新平台应能很好地支撑企业的未来增长,并支持团队在构建中持续推陈出新,用上更好的模式。
原系统的每次发布都需更新大量的节点,这显著增加了所需的发布时间。并且每次部署中都有大量的提交,一旦部署存在问题,难以通过对分定位(Bisecting)发现具体导致问题的某次或某些提交,问题定位耗时也更长。此外,原单体应用是基于Python 2和Django构建的,而旧版本 Python 正迅速进入寿命终止期(EOL,End of Life),难以继续获得可靠的支持。
为实现具有更好可扩展性的系统,DoorDash 工程团队需要去分解单体应用,确定新服务的界面和交互行为。接下来的首要问题是如何确定支持团队工作的技术栈。通过对多种语言的调研,团队选定了具有丰富的生态系统、与 Java 良好互操作性和对开发人员友好的Kotlin。针对 Kotlin 逐渐暴露出来的痛点问题,团队做出了一些改进。
当前存在多种可用的服务器端软件构建方案。但是出于以下方面考虑因素,团队考虑只使用单一语言。
综合上述因素,对于团队而言,问题并非是否应该使用单一的开发语言,而是应该选定哪一种语言。
选择编程语言时,团队要从企业的需求着手,考虑因素包括未来服务的体验以及交互方式等。团队很快取得一致,决定对服务间的相互同步通信机制使用gRPC、消息队列使用Apache Kafka。数据存储将继续使用Postgres和Apache Cassandra,因为团队成员对此经验丰富、技能熟练,并且二者的技术成熟度也非常高,广泛支持所有的现代编程语言。下面还需要考虑其它一些因素。
无论选定何种技术,都需要满足:
团队基于上述需求考虑了各种语言,弃用了C++、Ruby、PHP和Scala等主流语言。尽管这些语言颇受欢迎,但它们难以支撑每秒查询数(QPS)和用户数的增长,不能满足团队对未来技术栈的一项或数项核心需求。基于上述需求,选择范围锁定在Kotlin、Java、Go、Rust和Python 3。为比较和对比各语言相互之间的优劣之处,团队形成了如下对比表。
优点:
不足:
优点:
不足:
优点:
不足:
优点:
不足:
优点:
不足:
根据以上对比,团队决定开发一个经过测试和扩展的 Kotlin 组件的“黄金标准”。kotlin 本质上是一种更适合团队的 Java 版本,但缓解了 Java 存在的痛点问题。由此团队选择了 Kotlin,但必须要去解决进一步发展中经历的一些问题。
相对 Java,Kotlin 的一个最大优点是“空值安全”(null safety)。Kotlin 中,开发人员必须明确定义可为空值的对象,并强制开发人员采用安全方式处理,避免了必须处理大量潜在的运行时异常的痛点。也可使用空值合并(null-coalescing)操作符“?.”,用单行代码实现对可为空值子域的安全访问。例如,Java 实现为:
int subLength = 0;if (obj != null) { if (obj.subObj != null) { subLenth = obj.subObj.length(); } }
复制代码
使用 Kotlin 实现为:
val subLength = obj?.subObj?.length() ?: 0
复制代码
尽管上面给出的例子非常简单,但已经足够体现出空值合并操作符的强大之处,即大大降低了代码中条件语句的数量,提高了代码的可读性。
相比其它语言,在实现服务度量的仪表盘监控中,使用 Kotlin 更易于迁移到Prometheus事件监测系统。团队开发了一个注解处理器(Annotation Processor),自动按度量生成相应的函数,确保正确数量的标注按正确的顺序给出。
下面例子给出了 Prometheus 软件库的标准集成代码:
// to declareval SuccessfulRequests = Counter.build( "successful_requests", "successful proxying of requests",).labelNames("handler", "method", "regex", "downstream").register()// to useSuccessfulRequests.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 useSuccessfulRequests("handlerName", "GET", ".*", "www.google.com").inc()
复制代码
采用如上的集成方式,不必再去记住某个度量所具有标注的数量和顺序,而是由编译器和所使用的 IDE 去确保标注的正确数量和名称。DoorDash 使用了分布式追踪,监控的集成可简化为类似于在运行时添加Java Agent。。对于一个新服务,不需要代码属主团队去修改代码,可观测性和架构团队就能快速地推出对应的分布式追踪。
在团队看来,Kotlin 的另一个非常强大之处是协程(Coroutines)。协程模式让开发人员无需纠结于回调这个天坑,能使用近乎命令式编程的方式去编写代码,这正是大部分开发人员更为驾轻就熟的方式。协程也非常易于组合,必要时可并行运行。下面例子给出了团队使用的某个 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 协程支持在编码中按分区快速地切分消息,并对每个分区启动一个处理消息的协程,不破坏消息插入队列时的顺序。此后,在检查偏移并返回 Broker 前,连接所有的 Future。
Kotlin 支持团队以更可靠和可扩展的方式快速推进。从上面的例子中可见一斑。
为更好地利用 Kotlin 的全部特性,团队必须要解决以下问题:
下面展开介绍团队时如何解决上述问题的
采用 Kotlin 的一个最大问题,就是如何确保提升团队的开发速度。团队中大多数人具备优秀的 Python 开发背景,后端团队具有一些 Java 和 Ruby 经验。考虑到在后端开发中很少使用 Kotlin,因此团队必须要建立指导后端开发人员使用 Kotlin 的良好指南。
尽管在线上可以找到大量的学习教程,但是大多数Kotlin线上社区主要专注于安卓开发。团队的高级开发人员编写了“如何使用 Kotlin 编程”,其中给出了编程建议和代码片段。我们团队发布了“碎片化学习教程”(Lunch and Learns session),告诉开发人员如何避免一些常见的坑,如何有效地使用 IntelliJ IDE 开展工作。
团队更多地传授开发人员 Kotlin 的函数式编程方面内容,包括如何使用模式匹配、不可变性默认优先等理念。团队建立了人人可加入、提问并获得建议的线上交流小组(Slack Channel),形成了一个 Kotlin 工程互助指导社区。通过以上工作,团队构建一个强大的、熟练掌握 Kotlin 的工程人员团队,并在团队进一步扩展时能传承知识,形成可持续发展和改进的内循环。
团队在选择 Kotlin 时,尚缺少对协程的支持(译者注:2018年10月,Kotlin 1.3推出了coroutines稳定特性)。因此团队选定 gRPC 作为服务间通信方法,为充分利用 Kotlin 需做一定改进。当时 gRPC-Java 是 Kotlin gRPC 服务的唯一选择,因为 Java 中并不存在协程,因此 gRPC-Java 也缺少对协程的支持。
两个开源项目Kroto-plus和Protokruft可以解决这个问题,团队最终分别使用了这两个解决方案的各一部分去设计服务,创建更具原生感的解决方案。最近gRPC-Kotlin发布了一般可用(GA)版。为提供更好的 Kotlin 构建系统体验,团队我们正在迁移服务以使用官方绑定版本。
对于已转向 Kotlin 的安卓开发人员,对协程中存在的其它坑应该并不陌生。例如,不要在请求中重用CoroutineContexts,因为一旦取消或出现异常,CoroutineContext 就会转入“cancelled”状态,这意味着任何进一步尝试在此 Context 中加载协程将会产生失败。
正因为此,需对服务器处理的每个请求新建一个 CoroutineContext,不能再依赖于ThreadLocal变量,因为协程可在 Context 中换入换出,导致数据不正确或被覆盖。另一个需要警惕的坑是避免使用未绑定的GlobalScope加载协程,会导致资源上的问题。
支持现代Java非阻塞IO(NIO)标准的软件库,可以很好地与 Kotlin 协程互操作。但在选定 Kotlin 后,我们发现很多宣称支持 Java NIO 的软件库的实现方式并非可扩展的。它们在底层协议和标准实现中并非基于 NIO 原语,而是使用线程池包裹阻塞 IO。我们称这种 NIO 实现策略为“虚引用(Phantom)Java NIO”。
虚引用 NIO 策略实现的副作用是线程池在协程环境中很容易耗尽,由于其本质上是阻塞 IO,会导致高峰值延迟。为确保线程池的规模能满足团队的需求,虚引用 NIO 软件库都需要对线程池做调优,恰当调优和资源维护的需求增加了开发人员工作量。因此,使用真正的 NIO 或 Kotlin 原生库,通常会提供更好的性能、更易于扩展,实现更优的开发人员工作流。
相比 Rust Cargo 和 Go module 等最新解决方案,构建系统和依赖管理无论对新手还是熟悉 Java/JVM 生态者都相当不够直观。尤其是对于团队而言,一些依赖直接或间接地对版本升级非常敏感。Kafka 和 Scala 等项目并不遵循语义化版本管理(semantic versioning),这会导致编译成功而应用却由于一些看上去毫不相干的奇怪回溯(backtrace)而启动失败的问题。
寸积铢累,团队逐渐掌握了哪些项目通常会导致此类问题,积累了一些如何捕获并过滤问题的例子。特别是,Gradle 针对如何查看依赖树提供了一些有参考的页面,非常适用于此类问题。掌握多项目代码库的进入导出情况,需假以时日,期间非常容易导致冲突需求和环形依赖。
预先规划多项目代码库的布局,对项目的长期发展是大有裨益的。尽量确保依赖树简单,避免基础代码库对任一子项目的依赖(并且永不依赖),进而在此基础上做迭代构建,防止出现难以调试或厘清的依赖链。DoorDash 主要使用了JFrog Artifactory,简化了软件库在代码库间的共享。
DoorDash 服务标准将继续完全采用 Kotlin,平台团队正基于Guice和Armeria努力构建下一代服务标准,并通过预先部署监控、分布式追踪、异常追踪、运行时配置管理工具和安全集成等工具和功能,简化团队的开发工作流。
这些工作有助于 DooDash 开发共享性更好的代码,减轻开发人员查找依赖项、协同工作和维持最新依赖的负担。构建此类系统的投资,已体现在团队具备了针对涌现的需求而快速启动新服务的能力。Kotlin 支持开发人员聚焦于业务用例,减少了编写 Java 生态中不可避免的模板代码所用的时间。总而言之,Kotlin 是很好的选择,我们期待这一语言和生态的持续改进。
基于 DoorDash 的经验,强烈推荐后端工程人员首选 Kotlin。Kotlin 是更好的 Java 语言,该理念在 DoorDash 得到了验证,带来了更大的开发人员生产率,降低了运行时发现的错误。这些优点支持团队聚焦于解决业务需求,增加敏捷性和速度。未来 DoorDash 将继续投资于 Kotlin,希望继续与更广泛的生态合作,开发以 Kotlin 为主的更强大服务器端用例。
问题:为什么没有选定 Python 3?
答:尽管 Python 3 具有更强大的生态,但整个生态系统依然不够健壮。Pip 在依赖管理上存在很多问题,而 conda、poetry、pipend、pip-tools 等工具也并未解决问题。对于构建和软件包工具存在同样问题。
使用协程时遇到的最大坑:取消或异常会导致 CoroutineContext 进入“cancelled”状态,这意味着进一步尝试在此上下文中加载协程将会失败,对于服务器处理的每个请求,需要创建一个新的 CoroutineContext。更坏情况时,新的上下文每次创建的代价很大。需要建立一类发生异常后无需取消的特殊任务类型,以及建立很好的协程异常处理。
团队使用 Kotlin 在 Apache Flink 中实现流处理。为解决虚引用 NIO 问题,团队拟出了一个符合“黄金准则”的软件库列表。其中软件库或是很好地实现了协程,或是提供预优化版本的库。
问题:非阻塞 IO 是如何实现的?DoorDash 最终使用了第三方软件库,还是推出了自己的?DoorDash 的主要 IO 是网络调用、文件系统还是消息代理?
答:DoorDash 构建了自己的软件库,针对特定服务使用 gRPC。
问题:DoorDash 在从 Python 迁移到 Kotlin 中,是如何解决 CI/CD 问题的?
答:团队采用 CI/CD 的层级和版本一直在演进,至少在今年就发生了不少变化,每次迭代都会向前推进一步。
原文链接:
https://doordash.engineering/2021/05/04/migrating-from-python-to-kotlin-for-our-backend-services/