对于 UberEATS ,我们的目标是让用户从最喜欢的餐厅订购食物的过程能够无缝完成,就像通过 uberX 或 uberPOOL 约车那样简单。与任何新产品的发布一样,构建一个这样的食品交付网络在工程方面也会遇到各种“喜”和“惊”。虽然很美味,但这些新的乘客(食物!)本身也给我们带来了不小的挑战。例如,这些“乘客”无法指定自己想走的路线,也不能和司机闲聊,但是在“乘客”上下车过程中确实需要一些额外的步骤。本文我们将重点介绍其中的一个挑战:Uber 工程部门如何在原本只涉及司机和乘客的双边关系中引入第三方。
幸运的是,借助 Uber 现有的技术栈,我们可以在很短的时间里让 UberEATS 顺利上线并投入运转。只不过这次,旅途变成了送货,司机变成了“外卖小哥”,乘客变成了食客。不过餐厅在这其中的角色没有什么可供类比的,因为过去五年来,我们都已经习惯于假设一段旅途只会涉及两方人员,更不会有芝士披萨、泰式炒河粉,或者墨西哥鸡肉卷这样的乘客。
构建 Restaurant Dashboard
图 1:UberEATS 市场包含三方人员:餐厅、送货人,以及食客。这种新态势导致 Uber 传统的双边模式需要做出改变。
餐厅需要通过某种方式与送货人和食客交流。所有涉及方至少需要能传递下列信息:
- 下新订单
- 接受订单
- 送货人抵达餐厅
- 订单完成
这四个基本需求催生了 Restaurant Dashboard(餐厅仪表盘),这是一种基于 React / Flux 的单页 Web 应用程序,可通过平板设备访问。
图 2:显示有一个活跃订单的 Restaurant Dashboard。
面向未来计划中的 50 城,改造 Restaurant Dashboard
自该应用的独立版本于 2015 年 12 月在多伦多首发后,我们不断努力打造一种更简单可靠的接口,餐厅可通过该接口就送货相关的事务进行协调。多个月来,我们已经明白,为了继续改进Restaurant Dashboard,有必要进行彻底改造。
我们的Web 应用仅能通过设备提供有限的访问能力,而这是个大问题,因为这种方式限制了我们与餐厅就重要事项进行沟通的能力。例如用户必须先打开网页才能听到通知提示音。餐厅工作繁忙,因此通过声音通知餐厅员工有用户下了新订单,或送货人已经抵达准备取餐,这一点非常重要。为了解决这个问题,每次页面载入后,为了吸引用户注意,我们会显示一个模态窗口(Modal)。虽然可以通过这种方式播放声音,但会牺牲用户体验。
图3:Restaurant Dashboard 通过显示模态窗口吸引用户注意力进而播放声音。
我们还需要构建一些无法通过网页浏览器实现,或者只能以受到较大约束的方式实现的功能。例如,打印纸质小票是很多餐厅必做的,然而网页浏览器只能通过兼容 AirPrint 的打印机实现这一点。这样的局限导致餐厅和工程师们感到困惑和沮丧。我们意识到为了克服这些障碍,必须能访问硬件,使用打印机厂商提供的原生 SDK 直接与打印机通信。
评估 React Native
虽然目前来说,把 React Native 称作移动应用开发中能解决所有问题的“银子弹”还有些为时尚早,但该技术确实能很好地满足 UberEATS 的需求。由于最初设计的 Restaurant Dashboard 是面向 Web 的,我们的团队针对 React,以及面向 iOS/Android 有限的暴露已经积累了大量经验。此外大家也非常了解服务中与餐厅有关的组件是如何运作的,我们从 UberEATS 诞生那天起就在进行各种积累。多方因素考虑,使得 React Native 这一以 Web 语言进行移动开发的平台成为最理想的选择。该技术为我们提供了“烹饪”出近乎完美的应用程序所需的全部炊具。
对我们来说,多平台支持很重要。目前 Uber 正在与餐厅紧密合作,寻找平板设备并安装 Restaurant Dashboard 应用,但随着 UberEATS 的不断扩张,这种做法逐渐不那么现实了。早些年当我们过渡至 BYOD(自带设备)模式时,Uber 的司机也曾面临类似的情况。通过以不依赖具体平台的方式构建 UberEATS 应用,我们随后将能灵活地扩展至 Android,随着进一步成长为每个平台提供完善的支持。
为了顺利使用 React Native,还必须确保这技术能在我们现有的移动基础架构中流畅运行,可以支持最初促使我们转换为原生应用程序方式的各类功能。为此我们构建了一个专门用于验证各种关键功能的“演示”应用程序。借此我们可以直接用 Uber 其他团队提供的原生依赖项进行功能测试,例如崩溃报告、用户身份验证、分析。由于这些功能涵盖了原生 Objective-C 层以及解释型 JavaScript 层,借此我们还可以测试要求对两个环境进行集成的功能的交付能力。
总的来说,这个演示实现了我们的目标。诸如崩溃报告等库可以独立于应用程序的业务逻辑运作,并能实现拆箱即用。此外我们发现,通过与 JavaScript 层进行连接进而提供分析事件等功能,这个过程其实也极为简单。事后总结发现,由于不存在技术障碍,可能导致我们严重依赖原生库,而原生和 JavaScript 功能之间的矛盾后来也影响到我们很多架构方面的决策。
构建迁移路径
我们最初的目标是通过最少的工作量构建一种让 Restaurant Dashboard 能够原生运行的框架。为了实现这一点,我们创建了原生的导航和身份验证系统,并通过 WebView 将其指向现有的 Web 应用。
图 4:上图展示了 Restaurant Dashboard Flux 存储原生和 Web 组件之间的交互。
来自 WebView 的网络请求会使用 NSURLProtocol 进行修改,以便获得必要的身份验证头。窗口中还额外增加了其他钩子,这样我们就可以更新基于 Web 的 Restaurant Dashboard 的 Flux 存储,将 JavaScript 注入 WebView。这种做法在细化的功能迁移工作中为我们提供了极大的灵活性。
完成了这样一个功能基本完备的最小可行产品(MVP)后,我们可以通过现实中的餐厅快速进行测试。此外在原生功能方面也让我们获得了“快速胜出”的机会。我们集成了多个原生打印机 SDK,使得打印机方面的支持扩展到了不兼容 AirPrint 的更多型号。此外我们还禁用了睡眠模式,实现这一改动只需要一行原生代码,但以往通过 Web 是根本无法实现的。
随后需要将应用程序的其他部分逐个迁移至 React Native。在可能的情况下,我们希望让尽可能多的功能迁移后即可使用,而不是为了重写而重写。
定义架构
上文提到过,React Native 将 Web 和移动开发融合到了一起,使得我们可以自由选择使用原生或 JavaScript 的方式编写功能。与这种功能相伴的还有移动和 Web 社区的相关模式和概念。这种想法方面的融合为我们提供了更多选项,但如何选择最恰当的抽象,也为我们造成了新的挑战。
最终我们为 UberEATS 制定的架构与普通的 React/Redux Web 应用架构差不多,我们尽可能避开了 iOS 模式和模块。幸运的是,对于我们的需求和首选项来说,Web 概念和技术从整体来看可以很完美地转换为原生开发模式。
应用的路线功能是这种轻松转换的典范之一。在 Web 端,Restaurant Dashboard 使用了流行的 React-router 库,借此可用声明地方式清晰地定义线路,具体方式与 View 中的做法差不多。然而这种系统会假设 URL 的存在,但浏览器之外通常并没有 URL。React Native 提供了我们急需的导航库,该功能与 UINavigationController 提供的接口非常类似。
考虑到速度,最初 MVP 完成并开始运行时,我们让 React-router 库取代了 Routing 框架。不存在的 URL 问题也很好解决,只需要替换 JavaScript 中的 HTML5 History API 即可,毕竟它无论从目的和用途来看都只是一个栈。
当我们需要将 React-router 迁移至某个 React Native 库,例如 Navigator 或 NavigationExperimental 时,新的实现相比原本的解决方案似乎没法提供任何收益。结果我们发现这是因为无论原生或基于浏览器,Vanilla react-router 就仅仅是一个用来做 Routing 的好方法。
移植过程中学到的另一个宝贵经验是,将 iOS 与 JavaScript 之间的交互降至最低,将逻辑全部浓缩至 JavaScript 层,这种做法大有裨益,例如:
- 减少 JavaScript 与 Objective-C 之间的上下文切换次数
- 增强可移植性(因为减少了依赖特定平台的代码数量)
- 减少了 Bug 的影响范围
随着项目继续进行,我们开发了一个与原生层进行通信的简单 API。虽然希望让这一层尽可能薄,但我们也理解需要将多少代码保留在 React Native 层。诸如分析和登录等功能从本质上来看只是网络调用,可以通过 JavaScript 相对轻松地实现,然而最开始使用 Objective-C 编写的代码需要移植到 Java 才能支持 Android。然而我们更愿意借助这个机会使用 JavaScript 重写这些库,使其可以跨平台使用。
自动推送更新
React Native 应用程序可通过少量 Objective-C/Java 代码实现自举,随后即可加载 JavaScript 包(Bundle)。这些包已包含在应用程序中,和其他类型的资产一样。但有人建议我们说,如果业务逻辑依然保留在这些包中,应用程序就可以在启动时加载另一个 JavaScript 文件,借此进行更新,这个过程非常简单。在原生层,应用程序可以更改 React Native 桥所用的包,并在需要时请求进行重新载入。
为了让更新逻辑不依赖具体平台,我们选择更进一步围绕这个桥建立一个原生包装,借此让 JavaScript 包自己判断到底要加载哪个包。
图 5:Restaurant Dashboard 在任何时间最多可以存储三个不同的 JavaScript 包。
Restaurant Dashboard 会定期检查是否有新的包,并会自动下载更新。原生代码和包代码都符合语义化版本(Semantic versioning),每个新部署可分配唯一标识符,需要更改“原生 -JavaScript”通信接口的变更会被视作“破坏性”的。例如,将 Analytics 模块更名为 AnalyticsV2 会被视作一种“破坏性变更”,因为从 JavaScript 包到 Analytics 的现有调用会触发异常。
当然,就算对语义化版本给予最密切的关注,依然可能产生破坏性的更新。在 UberEATS 的环境中,破坏性的更新是指会导致包处理逻辑还来不及运行,Restaurant Dashboard 就已崩溃的包更新。这个时候出现的崩溃会导致无法通过推送新版本包而修复问题。更新造成这种不稳定的局面注定会出现,因此必须具备某种可靠的系统,能够检测到不稳定的构建版本并从中恢复。
避免部署破坏性更新的方法之一是将每个更新视作实验性的,循序渐进地推出更新,并在必要的时候进行回滚。
图 6:Restaurant Dashboard 的回滚流程判断要加载的包。
为了让回滚流程能够正常生效,Restaurant Dashboard 需要能识别哪个包是破坏性的,随后重载“安全”的包(即已知可以正常运行的包,例如应用发布时自带的包),否则将无从确定到底要将软件回滚到哪个版本。为了实现这一功能,我们设计了自动重载最初伴随应用程序一起发布的 JavaScript 包,随后从推送的两个包中择一加载:最新且安全的包,或最新的包。如果最新的包可以顺利加载,那么可以认为这个包也是安全的。如果没有找到已知安全的包,则不进行任何更新,继续使用最初的包。
相比传统的移动应用更新方式,通过这种方式对 Restaurant Dashboard 进行更新产生冲突的概率更低,因为新构建可以按需发布,借此可将新功能的发布时间由原本的数周缩短为数天。更新可在后台下载,并在下载完成后自动加载,这一过程无须用户介入。而因为无须用户交互,更新的准备过程可以变得更快,并尽可能确保更多设备始终可以使用最新版本。这样的机制还使得我们可以快速回滚有问题的构建,将软件问题对餐厅造成的影响降至最低。
虽然通过这种方式推送更新的做法还不能完全取代传统的应用发布方式(有关 iOS 或 Android 原生代码的变更依然需要通过这种方式进行),但至少降低了传统方法的使用频率。随着项目的原生层逐渐成熟,希望这样的趋势能继续保持下去。
测试和类型检查
在 Uber 工程团队内部,我们的工作进度很快,Web 项目通常会在变更推送至代码库后立刻发布,而不会等待进行构建。相比通常来说发布流程需要持续数周的移动应用程序,这样的做法产生了强烈的反差。在开发 Restaurant Dashboard 的过程中,当我们考虑到需要转向原生应用时,我们曾担心由于需要进行如此重大的转变,应用程序可能会在稳定性方面遇到问题。毕竟如果 React Native 解释器崩溃了,现实应用也会崩溃。尽管包推送的方式可以在一定程度上避免这类风险,但距离彻底避免崩溃还有很长的距离。
单元测试和浅渲染(Shallow rendering)已经诞生很久了,但最近在 JavaScript 社区有越来越多人主张通过 Flow 或 TypeScript 的方式并入静态类型检查。
因此这次更新应用时,我们决定使用 Flow 进行类型检查,这一决定使得我们可以对业务逻辑的准确性更自信。实际上,事实证明这是一种在发布至生产环境之前进行代码测试和获取错误的极为有用的工具。
通过一个简单的范例来看看 Flow 在类型检查 Reducer 函数方面的强大能力。如下例所示,Reducer 获取了正确的状态和操作作为输入,随后可以返回一个新的状态作为输出:
副作用的解决
使用 Flow 进行类型检查使得我们可以验证在该过程之后,状态依然可以维持正确结果,同时在 Flow 社区的无私奉献下,新版本可以在我们的应用程序中找到各种可能的 Bug 来源。此外由于可选类型只会造成最少量的开销,因此并不会妨碍到我们的快速迭代和开发工作。
Restaurant Dashboard 使用 Redux 管理数据的流动。Redux 为我们提供了一种简单、可预测的方法,帮助我们通过下列几个关键原则对应用程序状态进行建模:
- 所有状态位于 Store 中,而这种 Store 是一种单一不可变对象;
- View 可将 Store 视作输入,并负责渲染 React Native 组件;
- View 可以派遣 Action,而 Active 实际上是一种对 Store 进行修改的请求;
- Reducer 接受 Action 和当前状态作为输入,返回一个新的 Store。
为了响应诸如网络请求等异步操作,通常还需要修改 Store。Redux 并未提供这样做的方法,但此时比较常用的方式是使用 Thunks ,这是一种面向 Redux 的中间件,可以让操作成为一种可以返回 promise 函数,并 dispatch 其他操作。
图 7:在 Restaurant Dashboard 中,数据可通过 Redux 应用程序流动。
我们最初的做法是使用 Thunks,但随着应用程序逻辑(以及副作用)逐渐变得复杂,很快开始遇到问题。尤其是我们遇到了两种副作用模式,这两种模式无法自然地融入 Thunk 模型:
- 对应用程序状态的定期更新
- 副作用之间的协调
作为 Redux 应用一种备选的副作用模型, Sagas 可以借助 ES6( ECMAScript 6 )生成器函数提供一种不那么复杂的选项。此时不需要对操作的概念进行扩展,而是可以作为一个单独的线程进行建模,随后即可访问 Store,监听 Redux 操作,dispatch 新的操作。为了避免与 Thunk 有关的问题, UberEATS.com 最近已全盘迁移至 Sagas,因此我们可以更放心地进行缩放,并确信该技术的成熟度可以更好地满足自己的需求。(无穷无尽的 Saga!)
Sagas 最醒目的亮点之一在于对应用程序状态中定期发生的变更进行管理,例如检索活跃订单的最新列表。这一特性也可以通过 Thunks 实现,但 Sagas 的做法更优雅。(谁会喜欢用 Thunks 呢?反正我们不喜欢!)例如组件可以定期 dispatch 一个操作来获取订单,当然 Thunk 也可以递归地调用自身实现类似目标。然而抛开实现方面的问题不谈,不需要使用包含计时器逻辑的组件,也不需要用独立的 Thunk 持续触发自身,这种方式更适合 Redux 模式。
针对这种问题,Sagas 提供了简明扼要的解决方法,使得我们可以创建收寿命更长的任务,定期获取新订单并更新 Store 所需的操作。
使用寿命更长的任务,这会面临另一个问题:维持任务之间的通信,例如:
以上文获取订单的范例为例,只有在具备有效用户会话的情况下,才能获取订单并更新 Store。如果不能强制实施这一规则,可能导致一些不易察觉的错误,例如餐厅注销后订单才能更新的竞争状态。这种情况会进一步产生触发崩溃的边缘案例,或导致界面上显示一些奇怪的提示,因为有关传入订单的代码很有可能假设一个不存在的餐厅是实际存在的。
避免此类问题的做法很简单,但找出潜在的竞争状态并提供必要的检查,这是一种极为耗费时间并且容易出错的过程。更重要的是,我们的订单代码不应考虑用户会话的状态,因为这是两个不相干的问题。
Sagas 提供了一种简单的方法,供我们监听与会话有关的操作,进而启动或停止获取订单的后台任务。例如,在看到登录事件后,我们可以派生出一个定期获取订单的任务,如果发现用户注销,则取消该任务。这一切可以在 Saga 中非常简单地实现,例如:
派生的任务会成为另一个生成器,该任务会持续运行,直到该任务或其父任务终止。
实际上,我们发现这种将特定操作汇聚在一起的方式非常普遍,有些类似于组件修饰器(Decorator),我们可以将这样的逻辑放入更高层的订单生成器函数,例如:
Sagas 的本质还简化了我们的测试过程。通过使用 Sagas,针对特定功能进行单元测试的过程可以大幅简化,只须调用相关 Saga 并对结果执行深入对比即可。
这种方式需要让很多小服务通过消息传递的方式相互通信,很多后端工程师对这样的做法已经很熟悉了,但我们生成和使用的是 Redux 操作,而非 Kafka 事件。从开发者的角度来说,很高兴能看到这样的模式能够应用于客户端代码。
有关 UberEATS 旅途的反思
开发一个应用程序的完整心得体会几乎不可能用一篇文章全部概括,尤其是像 UberEATS 应用程序这样对餐厅的交互产生如此大影响的应用。希望本文能让大家更好地了解我们的团队在决定为 UberEATS 使用 React Native,以及确保为餐厅提供可靠、稳健的用户体验过程中所进行的权衡和考虑。
虽然 React Native 目前在 UberEATS 的工程生态中只占据很小的一部分,但我们使用该技术重建 Restaurant Dashboard 的过程中依然获得了大量宝贵经验。自从去年上线至今,改头换面后的 Restaurant Dashboard 已经成为几乎所有加盟 UberEATS 的餐厅不不可少的标准化工具。按照这样的发展速度,我们可以很乐观地估计该框架的能力将可以继续满足我们对规模的需求,帮助我们将这个市场扩展到更多地区。
Chris Lewis 是 Uber 的软件工程师,主要负责 UberEATS 的 Restaurant Dashboard 应用程序。工作的同时,Chris 自己也会使用 UberEATS 从他最喜欢的一家旧金山餐馆订寿司。
作者:CHRIS LEWIS,阅读英文原文: POWERING UBEREATS WITH REACT NATIVE AND UBER ENGINEERING
感谢徐川对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论