源码阅读之我见

2023-02-28 15:03:21 浏览数 (2)

【概述】


在很多技术交流群里,都看到过同样一个问题:如何阅读源码?

很多情况下,我们对一些开源的组件会用、或者通过官方文档、实际部署测试对其原理有一定程度的理解就可以了,不一定需要进行源码的阅读。因为阅读源码确实是一件非常耗精力的事情。

然而阅读源码,尤其是优秀的源码,可以从中学到很多,不仅仅是可以深入掌握组件的原理、也可以从源码中学习优秀的设计、以及一些好代码的写法,甚至可以从中抽取部分好的代码进行复用,避免重复造轮子。

笔者从2012年开始,陆续研究过memcached、redis、nginx、ejabberd、rabbitmq、还有一些不大的模块,例如libevent、tidb中的sql解析模块、以及最近两年一直在研究的hadoop、ranger、kafka等,在源码阅读上也算是有一定的积累,这里就来谈谈自己阅读源码的一些方式方法和技巧。

本文主要提到的方法如下图所示:

【按业务流程】


一种阅读源码的方式是完全按业务流程来,比如阅读消息队列服务(rocketmq、kafka、rabbitmq等)的源码,一个通用的流程是服务端如何接收生产者发送的消息并持久化存储的,那么这里可以拆分为服务端是如何接收客户端的连接的;客户端的连接建立后,如何处理客户端发送消息的请求的;消息是如何写入文件的几个简单的步骤来阅读对应的源码。

另一通用的流程是服务端如何将文件中的消息发送给消费者,同样可以拆分成如何从文件读取消息、消息读取后如何发送给消费者、如何处理消费者的确认消息等几个子流程来阅读对应的源码。

首次阅读时,你可能只需要关注主流程就可以了,所有异常的流程一概忽略,这样会省去很多分支代码的阅读(代码里的28原则,80%的代码属于异常处理,20%的代码为正常逻辑流程,然而80%的时间跑的是正常逻辑的代码,20%的时间跑的异常逻辑的代码),从而快速的缕清楚前后逻辑流程。

对主流程的相关源码有一定了解后,可以进行一些扩展和加入一些异常因素继续阅读相关的源码。比如,接收到生产者发送的消息后,同时发现有消费者在线并等待消费消息,那么此时的处理逻辑是和前面梳理的逻辑保持一致,还是说中间会有一个分支处理流程,直接将消息投递给消费者;比如,生产者发送的消息接收了一半,生产者异常了(连接断开了),此时会进行怎样的处理;同样,如果在将生产者发送的消息持久化到文件时,写失败了,又是如何处理的?

这样经过几轮反复阅读后,想必对正常、异常的处理逻辑已经掌握得八九不离十了。

【按模块】


另一种阅读源码的方式是按照模块来,有这么几种场景会涉及先对一整个模块的代码进行阅读以达到熟悉的程度

  • 公共的工具类模块

一个项目中,通常会有不少工具类、配置类等公共的模块,在串业务流程中有时候会反复遇到的公共类,可以考虑优先对这个模块进行走读,梳理一些公共类中常见的字段的含义、函数的作用等。这样在按业务流程走读源码时不必每次都进入到该类中理解其作用。

  • 前面按业务流程拆分后的模块

另外一种场景,则是明确知道一个流程会拆分成几个模块,这样可以先专注对这几个模块进行走读。

还是以上面消息队列服务的代码为例,肯定会涉及这么几个类型的模块。

最常见的莫过于RPC模块:负责进行指定端口的监听,以及接收客户端的连接,并处理客户端连接发送过来的请求。不管是怎样的实现方式,例如C/C 中的libevent,libuv、rabbitmq中使用的ranch、java中的netty等RPC模块(框架),通常会有一些固定的套路。比如IO模型使用的是reactor还是proactor,数据收发处理的方式以及线程模型,是一个线程负责处理一个或多个客户端的请求,还是有独立线程(池)负责数据的收发,独立的线程(池)负责数据的处理,如果是独立线程收发与数据处理,那么又会牵扯出线程的通信方式,比如中间加个队列进行消息的缓存,自然也就涉及对这个队列的同步操作,以及后续性能调优时,队列长度的问题。同时对应的异常情况也就随之需要考虑:即如果队列满了,对于写入的线程是如何处理,同时会对客户端有怎样的影响等等。

另外,rpc中还有一些经典的问题,由于通常是建立一个长连接进行交互,那么必然要考虑到(空闲时、发送请求、响应时)如果连接断开会怎样,即各种网络不可达的异常场景。然后就是连接之间是否有心跳保活,对应的机制是怎样的,因为一旦没有心跳保活,那么对于tcp半打开问题则会束手无策。

其他常见的模块,例如:写持久化文件的模块,包括相关文件格式的定义(数据文件、索引文件)、文件的读写、以及文件格式到消息体(消息类)的转换等;消息的同步模块:有的服务会复用消费者的逻辑、有的则是独立编写一套逻辑;状态机模块以及事件分发模块,负责不同事件触发的有限状态机流转及对应处理等。

【按线程】


大多数的开源组件,通常是一个服务,服务内部都会有不同的线程、线程池来提供不同的服务。计算机运行时也是以线程为执行单元运行处理的。因此可以以各个线程运行的流程来进行源码的走读,然后配合泳道图、时序图,这样可以梳理出每个线程的处理逻辑,以及可能的线程之间的交互逻辑。

很多情况下,多个线程会调用到同一个类中的方法,因此可以将泳道图与模块相结合,形成纵横交错的方式。纵列表示一个线程的处理(循环)流程,横列表示各个模块类在不同线程中被调用的情况。这样可以从代码的设计、计算机运行方式两种角度来加深对源码的理解。

【从树木到森林】


前面都是按一个一个功能流程、或者小的模块来进行源码走读的,随着多个功能流程,越来越多的小模块逐步熟悉后,需要开始建立一些全局的视野,或者进行一些逻辑层次的划分与抽象,以此来梳理整个组件的架构设计,不同模块之间的划分,调用关系,以及与周边配套组件之间的交互等。这不仅仅有助于提升自己的系统架构设计能力,也能从更高的角度来理解源码,可能之前有疑惑的一些代码,此时会豁然开朗。

【测试用例】


有时候,一个模块,或者一个功能觉得无从下手时,可以考虑先去看看自带的单元测试用例,梳理这些测试用例,也就掌握了某个模块的使用方式、数据输入输出流,这样可以根据结果倒推出实现的逻辑,以及源码中的具体实现。

【日志分析】


以解决问题的形式出发,或者是对某个功能验证其逻辑,对照日志(或手动在关键位置增加打印)找到对应的源码位置,能快速掌握源码之间的调用流程。

【单步调试】


就个人而言,单步调试是我阅读源码的最后采用的方式,这通常是出现问题时,纠结具体细节无法确认的情况下才采用的方式。平常的话, 不太推荐使用该方式,一方面是多线程的服务,单步调试无法兼顾每个线程的处理流程,另一方面是多层级的函数跳转,很容易迷失方向,从而不知道自己应该专注哪一块了。

【辅助方法】


阅读源码过程中,最常用也是最好用的辅助方式那就是画图了,所谓一图胜千言,能用一张或多张图将流程梳理清楚,比大段文字加代码更直观,更清晰。

常画的图包括

  • 流程图:可以直观地看出业务的逻辑流程
  • 时序图:通常是多个组件之间rpc交互逻辑,或者多个线程之间的交互逻辑,这对于分析一些异常场景
  • 类关系图:包括类的继承关系、接口实现逻辑、类与类之间的关联等等。

除了上面提到的几个类型的图,还包括用于描述协议二进制、文件结构的图、描述业务场景的脑图等等。

另外,除了画图之外,通过tcpdump(wireshark)抓包、查看文件的二进制内容等方式,也能帮助准确的了解源码的相关内容。

【总结】


以上,就是个人阅读源码时所使用的方式方法,通常是几种方法组合使用,然后配合画图,做到快速准确地理解源码的逻辑。当然,我的方法不一定都对,也不一定适用于每个人,或许你有自己的一套方法和理论,但不管采用怎样的方式方法,能从源码中探索真相,学习优秀的代码,架构设计才是最重要的事。

0 人点赞