从Python2到Python3:超百万行代码迁移实践
作者 | Cary Yang
译者 | BrotherZhao
出处 | AI 前线
全球有数百万用户使用 Dropbox 桌面客户端来保存其重要文件并在不同设备间同步文件。在从 Python 2 迁移到 Python 3 的过程中,我们要处理超过 100 万行 Python 代码逻辑,因此工作量巨大。在此过程中,我们明白必须不辜负用户对 Dropbox 的信任,并保证他们的信息安全。
在过去的几个月里,我们探讨了为什么要做以及如何做 Python 3 迁移,以及我们如何确保新的 Dropbox 应用是可靠的。在本文中,我们将介绍桌面客户端的 Python 3 简史,然后深入解析如何在允许持续开发的同时实现了逐步迁移。
先锋队
Dropbox 的年度黑客周(Hack Weeks)激发了许多伟大创意。将 Dropbox 桌面客户端迁移到 Python 3 也正是在此期间产生的。公司内部用来描述黑客周的词汇是,“回归本源” - 在黑客周的五天里,Dropbox 的所有人都把他们的日常工作放在一边,组建成小型的、行动迅速的团队,解决令人兴奋或有趣的问题。
黑客周 2015
一切始于此处 - 一只黑客周团队决定大胆尝试,看看用 Python 3 编写 Dropbox 桌面客户端是否可行。本着黑客周的精神,他们推出了一个桌面客户端版本,这个版本可以在运行 Python 3 时进行登录和同步文件的操作。
问题解决了吗?并非如此。很不幸的是,显然有许多功能都被 Python 升级给完全破坏掉了。一些与 Python 2 和 Python 3 兼容的更改得到合并,但是大部分努力最终都付之东流了。
黑客周 2016
在黑客周期间,一支新团队成立了,其目标是为 Python 3 版本的客户端推出更多的稳定功能。借助 Mypy(Mypy 是我们在过渡时期采用的静态类型检查工具),他们在完成 Python 3 迁移方面取得了突破进展:
- 将我们的 Python 自定义分支移植到 3.5 版本
- 将一些 Python 依赖项升级到 Python 3 兼容版本,并将一些其他版本合并(例如 babel)
- 修改了一些 Dropbox 客户端代码,使其与 Python 3 兼容
- 在我们的持续集成(CI)中设置自动化作业,以使 Python 3 解释器运行现有单元测试,并在 Python 3 模式下运行 Mypy 类型检查工具
更加重要的是,自动化测试意味着,我们可以肯定:当项目重启时,现有的为数不多的 Python 3 兼容性不会消退。
Python 3 的魅力
到 2017 年初,升级 Python 版本已经成为数个工具链升级的关键路径,这已经是显而易见的。公司为将要持续数月的 Python 3 迁移项目正式配备了团队。
先决条件
在迁移任何应用程序逻辑之前,我们必须确保,可以正常加载 Python 3 解释器并从打开程序伊始就可以运行。过去我们使用 freezer 脚本,但当时这些都不支持 Python 3,所以在 2016 年末,我们构建了一个自定义的、更原生的解决方案,内部称之为 Anti-freeze(更多内容参见最初的关于 Python 3 迁移的博客文章 )。
有了这些基础,我们就可以开始客户端本身的迁移了。
1. 逐步启用单元测试和类型检查
首先要做的是,在 Python 3 环境下启用所有单元测试和 Mypy 类型检查,以验证与 Python 3 的兼容性。
起初,在 Python 3 环境下所有单元测试全部禁用,这一步可以使用模块级的 pytest.skip 函数调用来实现。然后我们逐个检查测试文件,在 Python 3 下运行,修复应用程序逻辑本身的问题和测试中的任何问题,然后取消前面的禁用命令。
同样,我们有一个明确的文件黑名单,这些文件没有通过 Python 3 Mypy 下的单文件测试。我们在代码库中启用了 Python 3 Mypy,这样就可以利用公司范围内对 Mypy 的推动,从而可以添加更多 Mypy 类型(在此项目结束时,覆盖率已经从最初的 35% 提升到了 63%!)。我们还强制执行与 Python 2 和 Python 3 的同步兼容,防止语法混用导致消退。
特别是 Mypy 能够捕获并警告某些类型的问题,而这些问题会在 Python 3 上悄无声息地产生错误结果,例如我们最常见的问题是两个 Python 版本中 str,bytes 和 unicode 之间的行为差异。
字符串(Strings)同时有 str 、 bytes 和 unicode 三种表示,我的天啊太麻烦了。简要总结:在 Python 2 中,str 是字节(bytes)的别名,unicode 是 Unicode 字符串的类型;在 Python 3 中,str 是 Unicode 字符串的类型,bytes 是字节字符串(byte-strings),没有 unicode。
除了命名类型不同之外,Python 在不同版本中处理这些类型的方式也存在显着的语义差异(所以我们针对这个话题单独进行举例)。为简洁起见,我们不做讨论。但是我强烈建议,任何从 Python 2 迁移到 Python 3 项目的人员都彻底掌握这些差异。
我们遇到的主要问题还涉及不同的内存位置,这些位置是各种数据在内存中的序列化的展示。因为接口通常接受类字符串的对象,所以我们很乐意在字节字符串(byte-string)上调用 str ,这会在 Python 3 中产生 “b'string contents”。Mypy 类型更加强大(要明确类型何时是字节(bytes)、何时是文本(Text))和单元测试套件的失败共同促使我们发现这一问题。
关于 from future import unicode_literals 的特别说明从表面上看,这似乎很方便,因为它在 Python 2 中实现了 Python 3 字符串的行为。然而,我们发现,这仅仅在代码库的部分内容中使用起来就已经很让人困惑,并且不可能在一夜之间添加到每个文件中。
我们不可能直接在代码库中添加这个 import 命令,因为它可以改变代码运行时的行为,特别是许多 Python 标准库函数需要在 Python 2 和 Python 3 上同时传递 str 变量。
文件开头的 import 命令会导致字符串文字类型发生变化、跨文件追踪逻辑本就会令人迷惑,所以只在一些文件中包含这个 import 命令就已经容易引起曲解了。
2. 跨越 Python 2 和 Python 3
在完成单元测试和 Mypy 类型检查之后,我们就开始端到端的应用程序测试了。
我们首先在团队内部进行,解决与基本功能相关的任何明显的问题,然后组织 “bug bashes” 。后者与开发 Dropbox 客户端不同部分的团队一起进行,这样可以更详细地测试他们的功能并发现更加细微的特性回调。
然后,就是内部用户吃自己的狗粮了 - 亲自采用 Python 3 版本的客户端。在发生重大问题的情况下,为了能够安全又快速地使用户迁移回 Python 2 ,我们构建了 Hydra,在桌面客户端启动时,Hydra 允许我们选择运行 Python 2 或 Python 3 的编译器。在此期间,我们必须确保所有应用程序逻辑都使用混合的 Python 2/3 语法编写(也就是'跨越' Python 2 和 Python 3),这样我们可以向大多数用户提供 Python 2 版本的同时在内部测试 Python 3 版本。
3. 顺其自然
为确保满足我们的高质量标准,我们将桌面客户端在这种混合状态下维持了超出计划的时长 - 先是我们的内部构建,最终是对外的 Beta 版本。
在此期间,我们依靠来自改进的聚合崩溃报告管道的报告提醒我们已发的问题。这最终引申出了一些有趣的话题,例如 Python 本身的这个问题。
在此期间大约 7 个月后,我们确信,Python 3 版本客户端符合我们的质量标准,将此版本扩展到稳定版本 (Stable channel) ,并从应用程序二进制文件中移除了 Python 2 。这标志着我们的 Python 版本迁移之旅的结束!
学 习
- 单元测试和类型检查极其重要。 通过单元测试和静态 Mypy 类型检查,我们能够在早期发现大多数的兼容性问题,并且这也允许我们创建一个清晰同时可执行的问题修复列表。
- Python 中的字符串编码很难。 Python 3 在这方面明显更加高效。如果你的 Python 逻辑需要处理 Unicode 字符串,这本身就是从 Python 2 切换到 Python 3 的极佳理由。然而,Python 3 中用于修复字符串行为的重大变化意味着,在迁移过程中发现的大多数问题都与版本之间字符串处理方式的差异有关。
- 逐步迁移到 Python 3 以获取丰厚利润。 因为我们在整个项目中保留了 Python 2 兼容性,所以我们可以继续做 Python 2 版本的功能开发并发布应用程序,同时逐渐增加 Python 3 的兼容性,直到时机足够成熟可以切换。
致谢
特别感谢 Max Belanger 在本文撰写过程中提供的编辑建议,感谢 John Lai 介绍了最早在 Dropbox 进行 Python 3 尝试的历史背景,感谢为此项目做出贡献的所有人(完整列表在这篇原始的博客文章里!) 。
原文链接:
https://blogs.dropbox.com/tech/2019/02/incrementally-migrating-over-one-million-lines-of-code-from-python-2-to-python-3/