如何阅读大型项目的代码?

大学时期算法课程的代码往往是单文件的,实践课程的代码可能包含多个文件甚至构建工具,但是总的来说所有代码还是能够被一个人或者一个小队完全掌握。同这些迷你代码迷你项目不一样的是,公司现有项目或者开源社区的项目往往包含有数以万计的行数的代码,能够完成不同方面多种多样的功能。

在我第一次阅读这样一个大型项目的时候,我是直接冲进代码的细节里面,结果当时刚接触 Java 的我看到一大堆只有定义没有实现的接口,或者短小的 Helper 类,完全不明白这些代码到底是怎么串接起来运作的。经过小一年的实践和思考,我对如何阅读大型项目的代码这件事情有了一点粗浅的经验。下面我从几个方面来分享自己的体会。

目的性

读代码的时候如果有目的性是最好的,这种情况可以简单的讨论掉。比方说你现在要给 Spark 添加一个 RDD 的缓存策略,那么你自然会有目的性地阅读相关的接口和现有实现,针对你需要的功能做出尝试。虽然为了实现目的简单粗暴地糊代码可能无法一次性地写出好代码,但是代码本身就是演化的。如果看代码时候已经知道自己要干嘛了,那么寻找相关的信息和尝试实现就是比较自然的。毕竟 DDL/KPI 是第一生产力,在这种情况下反而不太需要什么方法论来介绍如何阅读代码,我相信你会自己找到办法的(笑

时间

总体来讲,阅读大型项目的代码需要时间。软件开发说到底是一门手艺活,只要肯花时间,哪怕是像上面说的对着代码死磕,在经过足够的时间之后也总能对项目代码有一个整体的认识。下面我会从抽象的方法论和具体的操作方式介绍怎么阅读代码,但是无论如何,都需要时间的投入。因为时间投入非常的重要,所以必须单独放在前面提出。

方法论

抽象地说,阅读代码有两种方向。一种是自底向上,从具体的文件到子模块,从子模块到功能集,再到整个项目,强调的是从具体实现出发总结出一般抽象;一种是自顶向下,从项目的顶层设计到责任分发,从责任分发到功能分发,再到具体的实现代码,强调地是从抽象设计出发落实到具体实现。这两种方法没有一个胜过另一个,只是两个不同的视角。实践中经常需要结合两种方向随时切换地采用,当对大方向了解不足的时候自顶向下地看,当对实现细节不够明确的时候自底向上地看。

自底向上

如同上面概要所说,所谓自底向上地阅读代码,即从具体的文件到子模块,从子模块到功能集,再到整个项目的阅读方式。这种方式强调的是先了解具体的实现,在阅读具体实现的过程当中发现一般规律,总结出一般规律后跟本项目的一般抽象做对比,两相对应对照思考。

这种方法有点像读微积分教程的时候,解课后习题的过程中发现规律,先试着自己证明一个一般的规律,在后面看到类似的定理的时候对照自己的结论和已有的理论。

比如在 FLINK 的代码中,针对资源管理集群的访问、针对 FLINK 集群的访问和针对作业的访问是一个明显的层级结构。或许在一开始阅读代码的时候你并不能发现这一点,但是可以通过阅读不同的实现,比如针对 YARN 集群的访问(YarnClusterDescriptor)和 FLINK 集群的访问(RestClusterClient/MiniClusterClient)抽象出一个一般的流程,针对这个流程再对照地阅读抽象类 ClusterDescriptor/ClusterClient 的接口和文档,然后针对不同的实现再按照最初抽象的流程和文档中描述的流程做验证。通过这种方式,我们就有机会从单个文件跨越到一个小型功能的实现思路上。同时,对照阅读比较差异,我们可以更好地理解差异化的实现中重要的考量点。如果差异过大以至于抽象的流程都偏离了,则有可能是抽象有误或者实现有误。一旦发现错误,就有了修复错误这样明确的目的,于是我们回到了有强目的性的阅读代码的最好状态。

软件的抽象做得再完整再好,具体干实事的也是实现代码。当我们花费精力从高层次掌握整体代码的逻辑的时候,容易不小心掉入抽象的陷阱,即【我知道这个功能是做什么的】甚至【这个功能从概念上是这么实现的】,但是面对实际问题,比如遇到了异常或者一个具体的需求的时候,却不能自信地解释到底异常从何而来,需求的实现是怎么做到的。同时,实现代码是实际干活的工人。不管需要重构、添加功能还是修复 BUG,最终都会落实到实现代码的编写。经常阅读实现代码,在面对实际的需求时应对起来才能更加游刃有余,不会出现我大概知道怎么做但是到底要花多少时间怎么做却没有一个好的预估。

自顶向下

自顶向下地阅读代码到达极致可能会超出代码的层面,即我们会需要从理论入手,再到整体的框架,划分到不同的功能之后再看具体的实现代码。这里强调的是从理论和抽象层面掌握一个项目是如何有机的组成的,对整个项目或者某个模块有一个大致的印象之后逐步落实自己的猜测。

比如阅读 Apache Ratis 的代码,这是一个 Raft 协议的 Java 实现,我们可以先大体地浏览一遍 Raft 的论文。这个阶段并不要求我们对论文的细节理解通透,只需要了解到 Raft 协议有 Peer、Quorm、StateMachine、Log 这样的一些概念,以及它们分别负责什么任务就可以了。大体了解了 Raft 协议中出现的实体之后,我们就可以对照 Ratis 的项目结构一一验证。如果对 Raft 协议的实现感兴趣,就可以看到 ratis-server 和 ratis-common 模块,猜一猜有没有叫做 Peer 或者 Quorm 的类。运气好的话你会发现一个叫 RaftPeer 的类,它的注释写着这个代表着 Raft 集群的一个服务器。Got it!这个就是 Raft 协议里面参与竞选和作为副本的 Peer。显然会有大量的协议逻辑跟这个类相关。顺着它的调用点,我们梳理出 RaftServerProtocol 到 RaftServerImpl 的协议实现核心,暂时屏蔽私有方法,从公开方法中找到协议实现相关的调用链,从子模块落实到功能点,然后再看实现。沿着这样的过程下来就可以对 Raft 的工业实现有一定的了解。

软件的实现是实际干活的工人,但是上层的抽象才是可复用、可迁移的知识。如果过度执着于具体的软件实现,我们可能会觉得对系统的变化寸步难行,因为现有的一切的【道理】都深深的刻在你的脑海中;修改是【不行】的,因为会破坏现有行为。但是,自顶向下的审视一个项目和它的代码,我们会发现有些功能是基本功能,有些实现上的【缘由】仅仅是因为采取了特殊的实现才带来的限制。只要我们保持功能的提供是稳定的,我们就可以任意地修改具体的实现。

不管是重构、实现新功能甚至是新写一个项目,把握好抽象层面要做的事情总是重要的。沉浸在实现中的开发者不能很好地理解要解决的问题,往往写出来的实现也不能应对需求的变化,甚至耦合了许多不应存在的约束。例如,ZooKeeper 的代码在 zookeeper-jute 模块外仍然重度依赖了 BinaryInputArchive 这个实现类,这导致替换 ZK 的序列化框架无论替换工作量如何,首先要把这部分依赖实现的代码剥离出来。软件设计的原则常说依赖抽象而不是依赖其实现,这就是一个鲜活的反例。在需要添加一个新的功能但是现有抽象不足以支撑这个功能的时候,不是整体考虑重新设计抽象,而是直接依赖具体实现,操作细节绕过问题。这虽然在当时乃至现在支持了所要求的的新的功能,但是却使得项目不能应对轻量地替换序列化框架这个需求。这种临时绕过的方案也被称为技术债,如果缺乏自顶向下的观察力,开发者很容易就会因为从实现到实现的开发方式引入过多的技术债。如果项目的目标是一个长期支持甚至提供基础支持的目标,沉重的技术债将给未来新需求出现时的重构甚至重写工作埋下巨大的隐患。

操作方式

有一说一操作方式这一节本该是放在最前面的。虽然总体方向和方法论很重要,但是实际执行还是依赖具体的操作方式。不过我想最前和最后对于一篇文章来说都是最重要的部分,所以放在最后按照理论到实际的方式书写也还不错。不少同学问过我怎么下手读一个开源项目的代码,我想他们问我的时候更想知道的是如何立即上手。下面介绍一些切入现有代码的实用操作方式。

测试

这个事情很有意思,大型项目中的大多数代码都是不可直接运行的功能代码。回想起我们大学时期编写算法程序的时候,我们用不超过三百行的代码写完算法,从命令行或 IDE 中给输入并查看对照输出,对差异输出手动模拟输入的执行路径 DEBUG。但是在大型项目中我们很难做这件事,如果我们去跑 example,我们是以一个用户的角度在使用产品。这当然也是好的,但是却不能很好的帮助我们理解源码。

所幸,在世界范围的鼓吹测试驱动开发十几年后的今天,大多数的项目都会带有对应的测试集。如果你问我理解一个类的使用方式和接口含义的最好方式是什么,那我一定会告诉你去查看它的单元测试和相关的集成测试。单元测试通常针对类的职责和接口的语义进行验证,我们可以很好的看到类在不同的环境和输入下的表现。测试其实就是一个给定输入并查看对照输出的过程,恭喜你,你又回到了编写算法程序的舒适区当中,只不过逻辑代码可能比算法逻辑还要明确和简单。

不仅仅是查看现有的测试,你还可以手动的编写测试。随你喜欢的写!现在我们有版本管理工具和在线协作系统,不必担心你的修改会引起什么不得了的变化,即使在本地把代码搞得一团糟,我们也可以重新 checkout 一个干净的版本继续瞎搞。项目的管理者可不会合并你的随意改动(笑)所以放心的修改代码吧,修改代码也是编写的一种方式。对于编程这门手艺来说,只有不断的练习才能积累经验;对于项目来说,只有了解了代码在不同输入下的表现,对于 BUG 的排查和修改的做法才能更有信心。

重构

重构是一个大的话题,但是从快速切入项目的角度来说进行一些小的符合直觉的重构是很有帮助的。得益于软件开发者持之以恒的对重构的宣传,我们如今生活在一个对重构认可度较高的世界当中。很少再会有开发者同行会说重构是无用、多余的。

随你喜欢地重构代码。比如,将命名不合理的方法用更合理的名字取代或包裹起来,可以提高代码的可读性。同时,我们也学习到了系统是如何工作的,并且将这些理解通过提供更合理的命名融入到了新代码中。如果你幸运地在一个开源项目中学习,你还可以将你的重构改动提交成 pull request 发送给上游仓库,如果开源项目对重构的态度足够开放的话,你的改动将有可能被合入到主分支中。成为知名开源项目的贡献者相信一定能够极大的鼓励你投入更多时间到研究代码中去,而这一点可能比你想象中的要容易许多。

关于重构的代码很多,有意思的事我个人认为 Martin Fowler 的《重构》现如今已经有些过于小家子气了,适合用作手册和通俗文本。重构过程中重要的不是手法,而是驱动重构的理由或者说思想,在这一点上《设计模式》这样的书反而更有指导意义。当然,如果你有一本没看过的话,我还是推荐你都看一下。

RFC

作者通常都不能阻挡在最后的位置夹带私货的诱惑,《设计数据密集型应用》的作者在最后一章写了 Do the Right Thing 一节,《Beyond Legacy Code》的作者在最后一章写了【提升整个软件行业】一节,我也不例外。不过我不打算写行业和行为方针那样的东西,在这里我想说的是项目中 RFC 或者说设计文档的重要性。

如果说对应方法论中的分类,测试和重构都是从具体代码到一半抽象的自底向上的方式的话,RFC 或者叫设计文档则是一种自顶向下的方式。阅读一个大型项目的代码,诚然我们是以一个【外来人】的身份逐渐融入的,但是设计文档却给了我们站在项目 Owner 的角度观察项目的机会。

所谓设计文档,指的是对项目中作出的改动的描述性文件,比如 FLINK 运行时框架改造对应的 FLIP-6 和 Rust 对正则表达式的支持的 RFC-0042

设计文档的格式往往都比较统一,按照动机-提议-实现-未来工作来展开,有时还会包括同一功能的拒绝方案或者 Non Goal。设计文档本身是一个项目改动的提议,为了让更多人理解和接受这个提议,它自然地被设计成便于阅读和理解的样式。对于想要阅读大型项目代码的开发者而言,项目关键的设计、重构和优化如果有对应的设计文档,将是代码以外的对代码本身组织方式和实现方式的理解的最好助力。

前不久我看到一个实现 NLP+代码搜索的初创公司,宣称通过自然语言查找代码有助于新人程序员理解和认知现有项目。这当然是 NLP 一个好的实践方向,也是用机器或说程序取代人工的一个好的尝试。但是,在它被证明是有效的之前,或者说因为搜索代码仍然不能提供直观的人类思考的结晶,公开的持久化的设计文档确是一个值得推广的现有但不通行的协作方式。

所谓认知项目、阅读代码和持续开发,可以看做是某种意义上的记忆传承。为什么我们需要写文档,是因为有效的文档约束了接口的行为,帮助程序员在几天、几个月后仍然能够记起来当初自己为什么要设计这个接口。实现代码的文档则帮助我们理解实现细节,包括采用的 workaround 和(也许永远不会实现的)TODO。

如果软件开发是一次性的工作,编码完成之后就不再需要修改,那么文档对于这个软件本身的意义是很小的。但事实情况并非如此。软件需要持续演化以满足不同的需求,演化过程中引入的复杂性需要被很好的控制以防复杂性限制了演化的速度和独立性。软件开发的周期,小的周期以周记、以月记,大的周期以年记,不要以为你写的代码很快就会被丢弃,它可能被堆上新的功能,几年、十几年后仍然有依赖于其上的用户。在这么长的软件开发周期中,关键设计和实现的决策不仅要在项目负责人和实际编码者的脑海中留存,更要传递给新人,传递给想要了解这个项目的人。我们没有知识同步机,而 RFC/设计文档是一个极好的保留设计过程和关键决策的权衡的介质。一个项目现在的样子是它经过了无数权衡之后的结果,通过设计文档,我们会发现我们所想的重构方案、改进方法前人可能都有考虑过,分别有什么问题也都被记录了下来。重要的不是它现在是什么,而是它拒绝了什么。毕竟软件开发并不是梦中上帝告诉了你程序是怎么写的,而是在不断的试错过程中发现了一条偶然的正确的道路。

最后针对国内开发者喜欢搞源码阅读的风气说一句,代码很重要,读源码也很好,但是源码并不是真理,代码的细节也不是那么的重要。代码是演化的,源码阅读把代码演化的运动性弱化了,锁定在某个实现某个 commit 上进行讲解,容易导致以静止的观点看代码,甚至拒绝修改代码,这是不好的。另外,我们可以使用最新版本的代码仓库+设计文档理顺代码演变的流程,真的没必要从 0.x 版本一个 diff 一个 diff 读过来。不仅仅是时间的问题,更重要的是以前的 commit 中可能有错或者当时各种外部条件的 workaround,如果你对着一个这样的细节花费大量的时间,真可谓是得不偿失。

  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值