Dropbox 如何用四年完成400万行Python代码检查
作为Python的大用户之一, Dropbox公司内部聚集了数百万行Python代码,动态类型的存在让代码越来越难以理解。因此,公司开始利用mypy逐步将代码转换为静态类型。虽然效果得到了充分验证,但整个过程充满了各种错误和失败。本文,Dropbox公司完整输出了从项目研究到实践的Python静态检查全过程,以期对各位开发者有所帮助。
事实上,Python已经成为Dropbox公司使用范围最广的语言,其广泛适用于后端服务与桌面客户端应用程序等(当然,Dropbox公司也在大量使用Go 、TypeScript以及Rust等语言)。在Dropbox公司数以百万计的Python代码行中,动态类型的存在让代码越来越难以理解,并严重影响生产力水平。为了缓解这一问题,Dropbox公司一直在利用mypy逐步将代码转换为静态类型(顺带一提,mypy可能是目前Python当中最流行的独立类型检查器,属于开源项目,其核心开发团队来自于Dropbox。)。
截至目前,Dropbox已经在成千上万个项目当中使用mypy,而且效果都得到了很好地验证。但对于此次全方位检查Python代码,Dropbox仍然抱着忐忑的心情,整个过程也充满了错误与失败。在今天的文章中, Dropbox将向大家分享Python静态检查之旅——从最早的学术研究项目,到现在逐步让类型检查与类型提示成为Python社区中众多开发人员的常规操作。现在,已经有多种工具支持类型检查功能,包括各类IDE与代码分析器等。
为什么要进行类型检查?
如果开发者只使用过动态类型的Python,当然有可能对静态类型以及mypy感到陌生。甚至,不少开发者就是因为动态类型而喜欢上Python,但这事儿在逻辑上就有点莫名其妙。其关键应该在于,静态类型检查是实现规模化的前提:项目越大,需要的静态类型就越多。
一旦项目中包含成千上万行代码,而且有多位工程师在同时使用,以往开发经验告诉我们,理解代码内容就成了保障开发人员工作效率的关键所在。如果没有类型注释,基本的代码作用推理(例如找到函数的有效参数,或者可能的返回值类型)就会成为一大难题。以下是几个在缺少类型注释时,开发人员难以回答的典型问题:
· 这个函数能返回None吗?
· 这里的items参数是干什么用的?
· id属性是什么类型:到底是int、str、抑或是自定义类型?
· 这个参数需要的是一份清单、一个元组还是一个组?
只要有了类型注释,开发者能够很轻松地回答这些与代码片段相关的问题,例如:
class Resource: id: bytes ... def read_metadata(self, items: Sequence[str]) -> Dict[str, MetadataItem]: ...
· read_metadata并不会返回None,因为返回类型不是Optional[…]
· items参数代表一系列字符串,其不可能随意迭代。
· id属性为字节字符串。
在理想情况下,我们当然希望把这一切都记录在文档中,但拥有从业经验的开发者肯定知道没这么好的事儿。即使存在此类文档,我们也无法完全信任其中的内容——例如内容含糊不清或者不够准确,因此带来巨大的误解空间。对于大型团队或代码库,这类问题可能产生巨大的影响:
虽然Python在项目早期与中期阶段表现良好,但当项目发展到特定阶段后,成功的项目与使用Python语言的企业可能面临一个关键性决定:我们是否需要利用静态类型语言重写所有内容?
类似mypy这样的类型检查器主要负责提供用于类型描述的形式语言,并通过验证所获得的类型与实现(以及可能存在的可选项)间的匹配解决这一难题。更具体地讲,类型检查器专门提供经过验证的文档。
· 当然,除此之外,类型检查器还可带来其它助益:
· 类型检查器能够发现许多微妙(以及不那么微妙)的bug。其中的典型例子,就是开发者忘记处理的None值或者其它一些特殊条件。
· 重构更简单,因为类型检查器通常能够准确告诉我们需要变更的代码。我们不需要进行100%全覆盖测试,这本身也不具备可行性。另外,我们也不需要跟踪深层堆栈以了解到底出了什么问题。
· 即使是在大型项目中,mypy也能够在几分之一秒内完成完整的类型检查。运行测试通常需要几十秒或者几分钟。类型检查带来的快速反馈,能够帮助开发者更快实现迭代。这意味着不需要编写脆弱且难以维护的单元测试,用以模拟及修复现有代码以获取快速反馈。
· 以PyCharm以及Visual Studio Code为代表的IDE和编辑器可利用类型注释实现代码补全、高亮显示错误并支持更好的定义功能——这里仅列出几项典型的功能性应用。对于一部分程序员而言,这些功能直接决定着他们的生产效率。这类用例不需要独立的类型检查工具。当然,像mypy这样的独立工具仍有助于保证注释与代码之间的同步。
启动迁移:性能成为瓶颈
在Dropbox,我们成立了一个三人小队,从2015年底开始研究mypy。成员分别是Guido、Greg Price以及David Fisher。从那时起,工作开始快速推进。首先,在mypy采用面前的最大障碍就是性能。我们一直在将其运行在CPython解释器上,这对于mypy这样的工具来说速度有点不够用。(作为包含JIT编译器的Python替代性方案,PyPy在这方面也帮不上什么忙。)
幸运的是,我们实现了一系列算法层面的改进。我们采用的第一项加速措施就是增量检查。其背后的思路非常简单:如果模块的所有依赖关系都与mypy运行前的状态毫无区别,那我们完全可以使用前一次运行的缓存数据获取依赖关系,意味着只需要类型检查修改了的文件及其依赖关系。mypy则在此基础上更进一步:如果模块的外部接口没有改变,mypy甚至不需要重新检查导入该模块的其它模块。
在对现有代码进行批量注释时,增量检查确实非常有用,因为其中往往涉及mypy的大量迭代运行,用以处理陆续插入且逐渐细化的类型。最初的mypy运行仍然相当缓慢,这是因为它需要处理大量依赖项。为此,我们实现了远程缓存。如果mypy检测到本地缓存可能已经过期,mypy将从集中存储库下载整个代码库的最新缓存快照。在此之后,它会以下载到的缓存为基础执行增量构建。这又进一步提高了性能表现。
到2016年底,Dropbox公司已经有大约42万行Python完成了类型注释。很多用户都热衷于类型检查,而mypy的使用则在Dropbox各团队之间迅速传播。
情况看起来相当不错,但距离真正的成功还有很长的路。我们开始定期进行内部用户调查,借以找出痛点,并确定需要优先考虑的工作(这种习惯直到今天也一直被保持下来)。其中,有两项请求始终排名最高:更大的类型检查覆盖范围以及更快的mypy运行速度。很明显,我们的性能与采用提升工作还没有全部完成。为此,我们还得在这两项任务上再多下点力气。
性能提升方法一:使用mypy守护进程
增量构建虽然提升了mypy的速度,但仍然没有达到顶峰。大量增量运行可能需要一分钟的处理时长。对于任何面对大型Python代码库的用户来讲,其中的原因相信并不难理解:循环导入。
我们拥有数百个模块,模块相互间接导入。如果导入周期的任何文件发生变更,那么mypy就必须处理周期中的所有文件,同时还得处理在此周期内导入该模块的所有其它模块。其中最臭名昭著的循环就是“纠结(tangle)”,它给Dropbox带来了很大麻烦。其中一度包含有数百个模块,众多测试级乃至产品级功能都要或直接或间接地将其导入。
我们一直在考虑打理这种纠结无比的依赖关系,但却始终没有合适的方法着手进行。毕竟我们不熟悉的代码太多了。因此,我们想出了另一个办法——即使存在这种“纠结”,我们同样可以提升mypy速度。答案就是,使用mypy守护进程。守护进程是一项服务器进程,负责执行两项非常重要的工作。
首先,它将关于整体代码库的信息保存在内存中,这样每次mypy运行就不再需要加载数千条与所导入依赖项相对应的缓存数据。其次,它会跟踪函数与其构造之间的细粒度依赖关系。例如,如果函数foo调用函数bar,那么就存在一项从bar到foo的依赖关系。当文件发生变更时,守护程序会首先单独处理已经变更的文件;接下来,它会查找该文件中包含的外部可见变更,例如变更的函数签名。守护程序所采用的细粒度依赖项管理机制,能够确保只重新检查实际变更的那些函数——换言之,只检查极少数函数。
实现上述目标当然是个巨大的挑战,因为我们最初的mypy实现方案只适合一次处理一个文件。但在实际需求发生变化之后——例如当某个类获得一个新的基类时,我们必须重新处理大量边缘情况。经过艰苦卓绝的努力与投入,我们成功将大部分增量运行缩短至几秒钟。这是一场伟大的胜利,至少在我们当事人看来相当伟大!
性能提升方法二:将Python编译为C
配合之前提到的远程缓存,mypy守护进程几乎完全解决了增量类用例,工程师们只需要对少量文件进行迭代变更即可。但是,最差情况下的性能表现仍然远未达到最佳状态。进行一次彻底的mypy build可能需要15分钟,这样的结果当然无法令人满意。由于工程师们在不断编写新代码,并在现有代码当中添加类型注释,因此情况每周都在恶化。我们的用户渴望获得更高的性能,而我们也自然不能让大家失望。
因此,我们决定延续mypy立项之初的重要想法——将Python编译为C。遗憾的是,Cython(一款现成的Python到C编译器)并不能提供任何显著的加速效果,因此我们决定从零开始编写编译器。由于mypy代码库(使用Python编写)已经全面完成类型注释,因此利用这些注释来加快速度自然是符合逻辑的选择。我构建了一套快速概念验证原型,其在各类微基准测试中将性能提升了10倍以上。我们的想法是将Python模块编译为CPython C扩展模块,并将类型注释转换为运行时类型检查(在运行时中通常被忽略的类型注释,仅供类型检查器使用)。我们开始着手将mypy实现由Python迁移至真正的静态类型语言,这恰好与Python的迁移思路完全匹配。(这种跨语言迁移正成为新的常态,mypy最初由Alore编写,但后来则转换为Java/Python自定义语法的混合体。)
对CPython扩展API的定位,是保持项目整体可管理性的关键所在。我们不需要实现虚拟机或者mypy所需要的任何库。此外,我们仍然可以利用一切原有Python生态系统与工具(例如pytest),并能够在开发期间继续使用经过解释的Python代码,从而实现极快的编辑测试周期且不必等待编译过程。
这款被我们命名为mypyc的编译器(因为它利用mypy作为前端来执行类型分析)非常成功。总体而言,我们在不使用缓存的前提下实现了大约4倍的运行性能提升。mypyc项目的核心开发在小团队的推动之下用了大约4个月即告完成,团队成员包括Michael Sullivan、Ivan Levkivskyi、Hugh Han和我自己。很明显,这里的工作量远少于使用C++或者Go完全重写mypy,相关影响也要小得多。我们希望mypyc最终能够被交付至Dropbox的其他工程师手上,供他们编译并加速自己的更多代码。
在达成如此出色的性能提升效果的过程中,我们尝试了不少有趣的性能工程方法。编译器可以利用快速、低级C构造实现众多操作的加速。例如,对某个已编译函数的调用会被翻译成C函数调用,而后者要比调用解释函数快得多。另外,某些操作(例如字典查找)仍然会回退至常规的CPython C API调用,从而略微提升编译时的调用速度。总而言之,我们摆脱了解释带来的性能开销,从而稍稍改善了操作的速度表现。
我们还进行了一系列分析工作,希望了解“慢速操作”中的普遍共性。有了这些数据,我们尝试调整mypyc为这些操作生成速度更快的C代码,或者利用更快的操作方式重写相关Python代码(有时候确实没什么好办法,只能硬着头皮重写)。后者通常要比在编译器中自动转换容易得多,不过从长远来看,我们更倾向于实现自动化转换。但还是要具体问题具体分析,有时候为了以最低的投入获得更大的性能提升,我们也会抄近路。
实操:检查400万行代码
在完成上述工作后,还面临一个重要挑战(也是mypy用户调查中排名第二的重要要求)就是提升类型检查的覆盖范围。我们尝试了多种方法以实现这项目标:从有机增长,到专注于mypy团队的手动调整,再到静态与动态自动化类型推理等。最后,我们发现其中并不存在简单的实现策略,但我们将多种方法结合起来,从而显著提高了能够在代码库中实现的快速注释工作量。
结果就是,我们在最大的Python库(后端代码)中的注释行数在大约三年之内增长至近400万行,这些全都迁移成了静态类型代码。mypy现在支持多种覆盖报告,能够帮助我们轻松跟踪相关进度。具体来讲,我们可以报告各类不够明确的类型来源——例如在注释中使用的显式、未经检查的类型,或者未进行类型注释的已导入第三方库等。为了在Dropbox当中改善类型检查精度,我们还在中央Python类型库中为不少流行的开源库提供经过针对性改进的类型定义(即stub文件)。
我们实现了(并在后续PEP当中标准化了)新的类型系统,旨在为某些惯用的Python模式提供更精确的类型。其中一个典型例子正是TypeDict,其负责提供JSON类字典类型。字典当中包含一组固定的字符串键,各个字符串拥有不同的值类型。我们后续还将不断扩展这套类型系统,同时考虑改进对Python数字堆栈的支持能力。
以下是Dropbox在提升注释覆盖率时,设定的核心工作要点:
· 严格性。逐渐增加了对新代码的严格要求。我们先从较为简单的角度入手,要求为原有文件补充注释。现在,我们则要求在继续补充注释的同时,在新的Python文件中使用类型注释。
· 覆盖率报告。我们每周都会向各团队发送电子邮件报告,旨在统计他们的注释覆盖率,并提供关于最有必要注释的内容的相关建议。
· 外展。我们与各团队就mypy进行交流,以帮助他们快速上手这款新工具。
· 调查。我们定期进行用户调查以找到最重要的痛点,并竭尽全力解决这些问题(甚至可以发明一种新的语言来加快mypy的速度!)。
· 性能。我们通过mypy守护程序与mypyc改进了mypy性能(p75获得高达44倍的性能提升),从而减少注释流程中的阻碍,并允许用户根据需要扩展类型检查代码库的规模。
· 编辑器集成。我们为Dropbox内部流行的各款编辑器提供了mypy运行集成,具体包括PyCharm、Vim以及VS Code等。这使得注释迭代变得更轻松,也提升了大家为遗留代码做注释的热情。
· 静态分析。我们编写了一款利用静态分析来推断函数签名的工具。虽然目前它只能处理非常简单的场景,但仍然帮助我们快速提升了注释覆盖范围。
· 第三方库支持。我们的不少代码都用到了SQLAlchemy,它使用的很多动态Python函数无法由PEP 484类型进行直接建模。为此,我们制作了一个PEP 561 stub文件包及一款开源mypy插件以提供支持。
经验总结检查400万行代码绝非易事,我们在整个过程中遇到不少挑战,当然也犯过错误。下面,我想总结经验教训,希望能给大家带来启示。
文件丢失。起步之初,我们的mypy版本只需处理少量内部文件——或者说,从未接触过build之外的一切。在添加第一条注释时,文件被隐式添加到build当中。如果从build外部的模块导入任何内容,则会获得Any类型的值——而这些值根本就不会被纳入检查范围。这导致类型分析精度大打折扣,并在迁移早期给我们带来了不少麻烦。虽然现在已经解决了,而且也算是一种典型做法,但在最糟糕的情况下,如果两个孤立的类型检查机制被合并起来,而这两种机制之间又互不兼容,那么我们就必须对注释进行大量更改!回想起来,我们应该尽早将基础库模块添加到mypy build中。
注释遗留代码。在刚刚开始时,我们面对着超过400万行的现有Python代码。很明显,对如此规模的代码进行注释是项浩大的工程。我们编写了一款名为PyAnnotate的工具,它能够在运行测试的同时收集类型,并根据类型结果插入类型注释——但最终这款工具并没能得到广泛采用。理由很简单:收集类型的速度很慢,而生成的类型通常也需要大量人为调整。我们也考虑过在每一次build测试时对一小部分实时网络请求自动运行这款工具,但考虑到这两种方式都可能带来较大风险,最终只能作罢。
大多数代码都是由代码所有者手动注释。我们提供关于高价值模块与函数的报告,以帮助简化注释流程。那些在数百个位置使用的库模块,自然是注释工作中的优先考量对象;正在被替换的遗留服务同样值得关注。此外,我们还尝试利用静态分析为遗留代码生成静态注释。
导入周期。导入周期(也就是「tangle」或者说纠结周期)的存在令mypy提速变得非常困难。我们还需要努力让mypy支持来自导入周期的各种习惯。我们最近刚刚完成了一个重大项目的重新设计,最终解决了大多数导入周期问题。这些解决方案实际上源自项目早期研究中使用的Alore语言。Alore的语法使得导入周期的处理变得更轻松。当然,我们也在这种简单的实现中继承了某些限制因素(对Alore来说倒不是什么问题)。Python之所以很难搞定导入周期,是因为其语句当中可能指代多种事物。例如,赋值可能实际上定义了一个类型别名,而且mypy在大部分导入周期处理完成之后一直无法检测到该类型。Alore就不存在这种模糊性。总之,有些早期设计中不经意做出的决定,很可能成为多年之后的痛苦根源!
结束语
从早期原型设计到如今对400万行代码进行类型检查,这是一段漫长的旅程。在过程当中,我们对Python的类型提示进行了标准化,建立起围绕Python类型检查发展出的新兴生态系统、为IDE与编辑器开发出类型提示支持机制,在多种类型检查器之间进行功能权衡并实现了库支持能力。
虽然在Dropbox公司内部,类型检查已经被视为一项必要工作,但我相信就整个社区而言,对Python代码进行类型检查仍是种新生事物。当然,我也坚信这种好习惯将不断推广并给更多人带来助益。如果大家还没有在自己的大型Python项目中使用类型检查,那么现在就是最好的时机——根据我的交流整理,所有尝试类型检查的开发者都后悔没有早点参与。总而言之,类型检查正帮助Python成长为一款更适合大型项目的出色语言。