目录
1.确定适用的技术栈
2.选择适合的编程语言
Kotlin优点:
软件库生态系统强大
对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
继承 Java 的生态系统,速度快,可扩展。
原生支持并发原语,避免了 Java 的繁琐,去除了对复杂的 Builder/Factory 模式的依赖。
Java Agent 提供强大的组件内省(introspection),仅需少量编码,即可自动定义并导出度量和追踪,以实现监控。
不足:
通常很少用于服务器端,开发人员缺少可供参考的示例代码。
相比 Go 而言,并发实现相对繁琐。Go 在语言基础层和标准软件库中集成了 goroutine(译者注:原文是“gothreads”)这一核心理念。
Java优点:
不足:
Go优点:
不足:
Rust优点:
不足:
Python 3优点:
不足:
3. 相对 Java,Kotlin 的优点
4.解决推广 Kotlin 中遇到的问题
5. 解决虚引用 Java NIO 问题
6 .依赖项管理:使用 Gradle 颇具挑战
7 . Kotlin 在 DoorDash 的未来发展
8. 答疑
DoorDash 是美国版的饿了么或美团外卖。基于 Python 2 和 Django 的单体应用无法持续,DoorDash 于是拆分单体应用,在对比 Kotlin、Java、Go、Rust、Python 3 后,他们确定用 Kotlin 写后端服务。
美国外卖平台 DoorDash 原先的代码库是基于 Django 的单体应用。之前,这个平台对业务的支持能力已逼近天花板。为给送餐服务提供更坚实的基础,DoorDash 需要全新设计的技术栈。新平台应能很好地支撑企业的未来增长,并支持团队在构建中持续推陈出新,用上更好的模式。
原系统的每次发布都需更新大量的节点,这显著增加了所需的发布时间。并且,每次部署中都有大量的提交,一旦部署存在问题,难以通过对分定位(Bisecting)发现具体导致问题的某次或某些提交,问题定位耗时也更长。此外,原单体应用是基于 Python 2 和 Django 构建的,而旧版本 Python 正迅速进入寿命终止期(EOL,End of Life),难以继续获得可靠的支持。为实现具有更好可扩展性的系统,DoorDash 工程团队需要去分解单体应用,确定新服务的界面和交互行为。接下来的首要问题是如何确定支持团队工作的技术栈。通过对多种语言的调研,团队选定了具有丰富的生态系统、与 Java 良好互操作性和对开发人员友好的 Kotlin。针对 Kotlin 逐渐暴露出来的痛点问题,团队做出了一些改进。
1.确定适用的技术栈
当前,存在多种可用的服务器端软件构建方案。但是出于以下方面考虑因素,团队考虑只使用单一语言。
有助于团队聚力,推动最佳开发实践在整个工程组织内的共享。
支持针对企业场景构建优化的通用软件库,很好地适应企业规模和持续增长。
极大降低工程人员在团队间转岗的摩擦,推动相互合作。
综合上述因素,对于团队而言,问题并非是否应该使用单一的开发语言,而是应该选定哪一种语言。
2.选择适合的编程语言
选择编程语言时,团队要从企业的需求着手,考虑因素包括未来服务的体验以及交互方式等。团队很快取得一致,决定对服务间的相互同步通信机制使用 gRPC、消息队列使用 Apache Kafka。数据存储将继续使用 Postgres 和 Apache Cassandra,因为团队成员对此经验丰富、技能熟练,并且二者的技术成熟度也非常高,广泛支持所有的现代编程语言。下面还需要考虑其它一些因素。
无论选定何种技术,都需要满足:
高效利用 CPU,可扩展到多核。
易于监控
具有强大的软件库生态支持,使团队可聚焦于业务问题本身。
确保提供良好的开发人员生产率
可靠的扩展性
面向未来,为企业业务增长提供良好支撑。
团队基于上述需求考虑了各种语言,弃用了 C++、Ruby、PHP 和 Scala 等主流语言。尽管这些语言颇受欢迎,但它们难以支撑每秒查询数(QPS)和用户数的增长,不能满足团队对未来技术栈的一项或数项核心需求。基于上述需求,选择范围锁定在 Kotlin、Java、Go、Rust 和 Python 3。为比较和对比各语言相互之间的优劣之处,团队形成了如下对比。
Kotlin优点:
软件库生态系统强大
对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
继承 Java 的生态系统,速度快,可扩展。
原生支持并发原语,避免了 Java 的繁琐,去除了对复杂的 Builder/Factory 模式的依赖。
Java Agent 提供强大的组件内省(introspection),仅需少量编码,即可自动定义并导出度量和追踪,以实现监控。
不足:
通常很少用于服务器端,开发人员缺少可供参考的示例代码。
相比 Go 而言,并发实现相对繁琐。Go 在语言基础层和标准软件库中集成了 goroutine(译者注:原文是“gothreads”)这一核心理念。
Java优点:
具有强大的软件库生态系统
对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
速度快、可扩展
Java Agent 提供强大的组件内省(introspection),仅需少量编码,即可自动定义并导出度量和追踪,以实现监控。
不足:
相对 Kotlin 和 Go 而言,并发实现相对繁琐。
编码存在极端冗长问题,难以编写整洁的代码。
Go优点:
具有强大的软件库生态系统
对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
速度快、可扩展
原生支持并发原语,简化了并发代码的编写。
具有大量可用的服务器端例子程序和文档。
不足:
对于不熟悉 Go 语言的开发人员,配置数据模型并非易事。
尽管 Go 最终会支持泛型(generics),但当前尚不支持。这意味着一些软件库中的类相对难以在 Go 中构建。
Rust优点:
运行速度非常快
没有垃圾回收机制,依然内存和并发安全。
一些大型企业开始采用该语言,因此具有大量投资及很好的发展。
相比其它语言,其提供强大的类型系统,更易于表达复杂理念和模式。
不足:
语言较新,这意味着例子代码、软件库以及具有模式构建和调试经验的开发人员相对不足。
相比其它语言,生态系统不算强大。
当前 async/await 尚未实现标准化。
要掌握内存模型,需要一定的学习时间。
Python 3优点:
提供强大的软件库生态系统
易用
团队已经具有丰富的经验
易于招聘开发人员
对 GRPC、HTTP、Cassandra 和 SQL 提供一等支持
提供 REPL,便于 App 运行时的测试和调试。
不足:
相比其它语言,运行速度较慢。
解释器全局锁(GIL)难以完全高效利用团队的多核机器。
缺少强大的类型检查特性。
当前对 Kafka 支持不够,特性发布存在延迟。
根据以上对比,团队决定开发一个经过测试和扩展的 Kotlin 组件的“黄金标准”。kotlin 本质上是一种更适合团队的 Java 版本,但缓解了 Java 存在的痛点问题。由此团队选择了 Kotlin,但必须要去解决进一步发展中经历的一些问题。
3. 相对 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 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 去确保标注的正确数量和名称。DoorDash 使用了分布式追踪,监控的集成可简化为类似于在运行时添加 Java Agent。对于一个新服务,不需要代码属主团队去修改代码,可观测性和架构团队就能快速地推出对应的分布式追踪。
https://github.com/open-telemetry/opentelemetry-java-instrumentation#getting-started
在团队看来,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 支持团队以更可靠和可扩展的方式快速推进。从上面的例子中可见一斑。
4.解决推广 Kotlin 中遇到的问题
为更好地利用 Kotlin 的全部特性,团队必须要解决以下问题:
如何培训团队更高效地使用 Kotlin
建立使用协程的最佳实践
解决与 Java 互操作上的痛点
进一步简化依赖管理
下面展开介绍团队时如何解决上述问题的
培训团队使用 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 加载协程,会导致资源上的问题。
5. 解决虚引用 Java NIO 问题
支持现代 Java 非阻塞 IO(NIO)标准的软件库,可以很好地与 Kotlin 协程互操作。但在选定 Kotlin 后,我们发现很多宣称支持 Java NIO 的软件库的实现方式并非可扩展的。它们在底层协议和标准实现中并非基于 NIO 原语,而是使用线程池包裹阻塞 IO。我们称这种 NIO 实现策略为“虚引用(Phantom)Java NIO”。
https://en.wikipedia.org/wiki/Non-blocking_I/O_(Java)
虚引用 NIO 策略实现的副作用是线程池在协程环境中很容易耗尽,由于其本质上是阻塞 IO,会导致高峰值延迟。为确保线程池的规模能满足团队的需求,虚引用 NIO 软件库都需要对线程池做调优,恰当调优和资源维护的需求增加了开发人员工作量。
因此,使用真正的 NIO 或 Kotlin 原生库,通常会提供更好的性能、更易于扩展,实现更优的开发人员工作流。
6 .依赖项管理:使用 Gradle 颇具挑战
相比 Rust Cargo 和 Go module 等最新解决方案,构建系统和依赖管理无论对新手还是熟悉 Java/JVM 生态者都相当不够直观。尤其是对于团队而言,一些依赖直接或间接地对版本升级非常敏感。Kafka 和 Scala 等项目并不遵循语义化版本管理(semantic versioning),这会导致编译成功而应用却由于一些看上去毫不相干的奇怪回溯(backtrace)而启动失败的问题。
寸积铢累,团队逐渐掌握了哪些项目通常会导致此类问题,积累了一些如何捕获并过滤问题的例子。特别是,Gradle 针对如何查看依赖树提供了一些有参考的页面,非常适用于此类问题。掌握多项目代码库的进入导出情况,需假以时日,期间非常容易导致冲突需求和环形依赖。
预先规划多项目代码库的布局,对项目的长期发展是大有裨益的。尽量确保依赖树简单,避免基础代码库对任一子项目的依赖(并且永不依赖),进而在此基础上做迭代构建,防止出现难以调试或厘清的依赖链。DoorDash 主要使用了 JFrog Artifactory,简化了软件库在代码库间的共享。
https://jfrog.com/artifactory/
7 . Kotlin 在 DoorDash 的未来发展
DoorDash 服务标准将继续完全采用 Kotlin,平台团队正基于 Guice 和 Armeria 努力构建下一代服务标准,并通过预先部署监控、分布式追踪、异常追踪、运行时配置管理工具和安全集成等工具和功能,简化团队的开发工作流。
这些工作有助于 DooDash 开发共享性更好的代码,减轻开发人员查找依赖项、协同工作和维持最新依赖的负担。构建此类系统的投资,已体现在团队具备了针对涌现的需求而快速启动新服务的能力。Kotlin 支持开发人员聚焦于业务用例,减少了编写 Java 生态中不可避免的模板代码所用的时间。总而言之,Kotlin 是很好的选择,我们期待这一语言和生态的持续改进。
https://en.wikipedia.org/wiki/Boilerplate_code
基于 DoorDash 的经验,强烈推荐后端工程人员首选 Kotlin。Kotlin 是更好的 Java 语言,该理念在 DoorDash 得到了验证,带来了更大的开发人员生产率,降低了运行时发现的错误。这些优点支持团队聚焦于解决业务需求,增加敏捷性和速度。未来 DoorDash 将继续投资于 Kotlin,希望继续与更广泛的生态合作,开发以 Kotlin 为主的更强大服务器端用例。
8. 答疑
问题 1:为什么没有选定 Python 3?
答:尽管 Python 3 具有更强大的生态,但整个生态系统依然不够健壮。Pip 在依赖管理上存在很多问题,而 conda、poetry、pipend、pip-tools 等工具也并未解决问题。对于构建和软件包工具存在同样问题。
使用协程时遇到的最大坑:取消或异常会导致 CoroutineContext 进入“cancelled”状态,这意味着进一步尝试在此上下文中加载协程将会失败,对于服务器处理的每个请求,需要创建一个新的 CoroutineContext。更坏情况时,新的上下文每次创建的代价很大。需要建立一类发生异常后无需取消的特殊任务类型,以及建立很好的协程异常处理。
团队使用 Kotlin 在 Apache Flink 中实现流处理。为解决虚引用 NIO 问题,团队拟出了一个符合“黄金准则”的软件库列表。其中软件库或是很好地实现了协程,或是提供预优化版本的库。
问题 2:非阻塞 IO 是如何实现的?DoorDash 最终使用了第三方软件库,还是推出了自己的?DoorDash 的主要 IO 是网络调用、文件系统还是消息代理?
答:DoorDash 构建了自己的软件库,针对特定服务使用 gRPC。
问题 3:DoorDash 在从 Python 迁移到 Kotlin 中,是如何解决 CI/CD 问题的?
答:团队采用 CI/CD 的层级和版本一直在演进,至少在今年就发生了不少变化,每次迭代都会向前推进一步。
链接:
https://doordash.engineering/2021/05/04/migrating-from-python-to-kotlin-for-our-backend-services/