解锁优秀源代码的基本方法与技巧
读码破万卷,敲键如有神。
概述
要成为作家, 需要阅读大量的文学作品;要成为一流的开发者,需要阅读大量的优秀代码。程序设计与开发,可视之为逻辑的武学;解锁优秀代码的能力,是开发者的内功心法之一。
优秀源代码就像一座宝库,里面藏着关于逻辑的奇珍异宝,非常值得一探哦!
- 解读设计思想,理解技术原理和实现,遇到类似问题时,能够应用到解决方案中;
- 积累好的代码,胜于重新造轮子。
怎样才算真正理解了源代码呢? 一般做到如下几点:
- 预分析与反馈总结。自己来设计,思考了哪些要素;优秀的实现,考虑了哪些要素。作个对比,能够有更多领悟和收获;
- 设计思想。蕴含了哪些设计思想,记录下来;
- 核心技术。哪些是核心技术点,是不可替换的 ?
- 逻辑路径。代码是逻辑的表达,有哪些主要逻辑路径?
- 细节与发现。有哪些值得关注的细节 ? 有什么重要的发现 ?
写一篇源码阅读笔记,记录其中的设计思想、核心技术和细节,重要或出乎意料的发现。
本文以 Guava.Cache 为例,探讨如何解读优秀源代码。
导图
流程与步骤
STEP0. 了解核心使用场景及优缺点
可以到官网上去了解库、组件或框架的核心使用场景,有哪些优势和不足。 Guava 官方文档:“Guava GitHub”
STEP1. 预分析
在阅读源码之前,切记要先弄清楚总体思路是怎样的。否则,读到的就是一堆指令,而不是一个缜密的逻辑流。
额外思考一下:如果是自己来设计,会如何来做,考虑哪些因素? 做一定的思考后,再比对别人的实现,对比中可以有更多的收获和领悟。
示例: Guava.Cache 用于创建一个可靠的本地缓存。 如果我来设计,会考虑多线程访问的并发安全性,比如使用 ConcurrentHashMap 。在 Guava 实现里,还考虑了缓存相关的统计,比如命中率,这对于衡量缓存效果是非常重要的;还记录了移除缓存的原因和监听器等。
STEP2. 了解系统的整体设计及概念
与常见业务系统冗长混乱的方法迥异, 优秀的系统通常会有一个优雅的整体设计, 将一组小而简洁的概念串联起来。代码实现也很简洁清晰。 先了解系统的整体框架和所基于的基本概念, 会对理解系统的构造与运行有一个总体的引导作用。 同时, 在设计自己的系统时, 也会从中获得很好的启发。
示例: Guava.Cache ,实现缓存功能的主要接口及核心类如下:
* KV : 缓存通常是 KV 结构,基本原理是 Hash 查找;
* Cache & LoadingCache: 缓存接口,存储一定时间期限的KV值。Cache 是一个手动添加的 Cache,需要对 key 手动指定值; LoadingCache 可以根据指定的函数对 key 计算得到一个值,然后将值填入缓存。两者都必须是线程安全的。
* AbstractCache & AbstractLoadingCache: 抽象类,Cache & LoadingCache 的骨架实现。
* ForwardingCache & ForwardingLoadingCache: 抽象类,主要是通过转发请求给一个已有的缓存来实现一个新的缓存。可以基于已有缓存更方便地建立新的缓存。
* CacheBuilderSpec & CacheSpecParser: 缓存的规格,用来设置缓存的一些基本参数,调节缓存的行为。
* CacheLoader : 从 Key 值计算出 Value 值的计算函数;
* CacheBuilder : 根据 CacheBuilderSpec 及 CacheLoader 创建具体的 LocalCache;
* ObjectPool : 对象池,缓存通常要使用到池,避免过度膨胀;
* 并发 : 可以通过 Hash 分段表来增大并发吞吐量。
STEP3. 熟悉项目代码组织结构
优秀的开源项目通常会有清晰而有层次化的项目组织结构。
拿到项目源代码, 第一件事:概览项目组织结构, 弄清楚哪些主要模块(包), 各个模块(包) 的主要作用;各个模块(包) 下面有哪些主要类, 其作用如何。 这一步可以采用大胆猜测加API文档说明的方法来完成。
通常, 最顶层包往往包含与客户端使用直接相关的类和方法, 其下层的子包完成某个子功能或特殊模块。 此外, 优秀的项目代码通常会对系统所涉及的每个点都有一个较好的抽象, 使用一个小而简洁的类或接口来表达, 而不是混杂在大的类中。 哪怕只是一些简单的颜色字符串, 也会尽力用枚举来实现它。很多 Javaweb 项目默认采用 Controller - Service - (Dao, RPC) - Model - Utils 或 API - Service - Dependency - Biz - Domain - Common - Config - Deploy 的整体组织模式, 这进一步降低了阅读 Java 项目的难度。
示例: Guava.Cache 都组织在包 com.google.guava.common.cache 下。
STEP4. 确立目标,缩小范围
在开始阅读源代码之前, 心里要定一个目标去驱动阅读。 比如说, 要探究 proxools 连接池有时获取不到数据库连接的问题, 或者弄清楚 extjs 分页控件 PagingBar & java 线程池 ThreadPoolExecutor 的内部实现。 先攻取一个小分支, 将阅读范围缩小到容易完成的程度。物理学家一上来也不是能一下子弄懂整个宇宙的。
示例:项目中用到了 Guava.Cache ,主要是想学习里面的原理和实现。
STEP5. 找到切入点
可以从外部行为切入。 阅读 API 文档, 理解其外部行为,弄清楚设计所针对的需求目标,写个 Demo 单步调试。
还可以根据实际关注点切入。 比如:使用 Proxool 连接池, 我更关心是如何获取数据库连接的, 可以从 ProxoolDataSource.getConnection 方法切入; 使用线程池, 可以从 ThreadPoolExecutor 切入; 可以使用 junit ,从 TestCase 切入。 一般来说,从所使用的客户端类切入是一个不错的选择。对于成熟框架来说,找到切入点不太容易,可以网上搜索下,入口在哪里。
示例:可以编写一个 LocalCache 的 Demo ,然后单步调试进入。
STEP6. 锁定主要和核心的类与方法
任何设计都会隐式或显式地有“关键角色” 与 “支撑角色” 的分工。阅读源代码并不是盲目漫无目的的行为, 而是要先锁定主要和核心的类与方法, 作为阅读的引路灯。在 Guava.Cache 里,核心类就是 LocalCache ,而 CacheLoader, CacheBuilderSpec, CacheBuilder 都是支撑类。
找到并理解核心数据结构和算法流程,这是理解具体实现的关键。在这个基础上,再去思考扩展性的设计。
STEP7: 标记主要流程, 绘制协作交互图
跳过各种细节, 主要集中于弄清楚主要流程, 由哪些模块、类以及哪些方法参与, 标记、绘制协作交互图。
示例: 创建 CacheBuilderSpec -> 创建 CacheLoader -> 创建 Cache 实现 -> 使用 Cache ,有用的类的 Javadoc 都会有一些例子提供。比如:
*
* CacheLoader<Key, Graph> loader = new CacheLoader<Key, Graph>() {
* public Graph load(Key key) throws AnyException {
* return createExpensiveGraph(key);
* }
* };
* LoadingCache<Key, Graph> cache = CacheBuilder.newBuilder().build(loader);
*
STEP8: 分解细节,各个击破
完成 STEP7之后, 通常对该框架已经有了一个整体的理解, 虽然还有很多细节不清楚。 没关系! 优秀源码为了追求灵活性和可扩展性,具有更强的缜密度, 相对比业务系统代码更难理解一些。 尤其是细节盘根错节,相互依赖影响。
一行行代码去读,是比较笨拙的;可以将这些细节分离和提炼出多个关注点,理解关注点是如何实现的,在关注点的导引下去阅读这些细节性的代码,会更加高效一点。将多个关注点分解成多个小任务, 各个击破。 这一步需要反复多次地进行, 可能需要查阅很多知识点和接触一些“阴暗之处”, 才能逐渐抵达系统的“真相”, 最终作出对其优缺点的合理的综合评定。
这一步比较困难,需要一些技巧和耐心。如果有一些点想不清楚,可以上网搜索,看看其他人是怎么理解的。比如 AQS 里同步队列和条件队列的联系,我就没想明白,搜到一篇文章讲得很清楚。别人的一句话,真的可以让迷惑的你拨云见日。
STEP9: 实践,再实践!
由易入难,循序渐进。从简单的类的源码,逐步深入到复杂的成熟框架或系统源码的阅读。
- 不含复杂技术点的独立类。比如 Integer , 只要编程基础就能读懂。
- 不含复杂技术点的含有少量交互的框架。比如 JDK Collection,commons-collections, 需要数据结构与算法基础。
- 不含复杂技术点的交互度适中的简易框架。比如 junit ,主要是类与交互的设计。
- 含有适量算法成分的独立类。比如 RateLimiter , 弄懂它的建模就能读懂。
- 含有并发但交互很简单的独立类。比如 AbstractQueuedSynchronizer , 并发工具的基础抽象类,需要先理解相关的技术原理和知识。
- 含有并发并只含有少量交互的类或库。比如 ThreadExecutor , Google.Cache 。
- 含有并发并含有适量交互的库。比如 Java 并发包,common-pool2。
- 含有大量交互的实际框架,比如 Spring IoC , ibatis 。
- 含有大量技术点和交互的实用性框架,比如 Spring , Struts 等。
建立一个源码阅读文件夹。阅读源码的过程中,多做笔记,记录阅读源码的方法技巧,记录从源码中学习到的思想、方法和技巧。
模式识别与积累
代码编写和程序设计都有一些模式可以遵循。 熟悉这些代码与设计模式对理解源代码也有很好的帮助。 比如, 面向对象系统中就通常有如下几种基本模式。
独立类
独立类完成其独立的功能,会引用到其它类的方法, 但交互不会复杂。 例如 java.util.Arrays , java.lang.Integer 类;
支撑类
支撑类用来表达一个小而简洁的抽象, 它通常不直接拿来用, 而是被其它类引用来构造更复杂的功能, 比如 java.util.AbstractMap$SimpleEntry;
继承型交互关系
继承型的交互关系遵循"接口-抽象类-具体类" 的模式: 接口规定行为规范, 抽象类完成用于定制的骨架实现, 具体类则实现具体完整的功能,可以直接拿来用。 例如经典“三段式” : Map -> AbstractMap -> HashMap 。
继承性交互关系,要体会接口是怎么设计的,为什么需要定义这些方法;要体会抽象类是怎么设计的,如何将通用流程和钩子方法提炼出来。
委托型交互关系
委托型交互遵循“封装或代理”模式,将一部分或全部的功能实现委托转发给其它类的实现, 可能会做一点封装或代理操作。比如 ForwardingLoadingCache , 将缓存操作全部转发给另一个缓存。
组合与混合
实际应用中, 通常是多种模式混合而成。比如 :
- class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V> 是“接口-抽象类-具体实现”的继承性交互关系;
- static class LocalLoadingCache<K, V> extends LocalManualCache<K, V> implements LoadingCache<K, V> 是“接口-实现类-子类”的继承性交互关系;
- public interface LoadingCache<K, V> extends Cache<K, V>, Function<K, V> 是 “接口-接口”的继承关系;
- LocalLoadingCache 的缓存操作实现,是委托给 LocalCache 来实现的,是“实现类-实现类”的代理关系;
- ReentrantLock 委托 Sync 实现,而 Sync 继承自 AbstractQueuedSynchronizer。
在解决具体设计问题时, 会应用到一些常见的设计模式, 比如单例模式、观察者模式、装饰器模式、代理模式、责任链模式等, 熟悉这些设计模式也是必要的, 详见《设计模式-可复用面向对象软件的基础》 一书,在 “软件设计要素初探:基础设计模式概览”一文中亦有简短的总结。
代码模式
优秀源码中,往往有一些很好的代码模式,可以直接借鉴。比如 Preconditions 类的 checkArgument , checkState, format 方法, CacheBuilderSpec 里的 VALUE_PARSERS 的初始化。
技巧与手段
-
边阅读边注释。对读过的地方做些必要的注释, 主要突出其用途,必要的实现细节; 可以边读边做些笔记。
-
搭建环境, 运行, 调试。搭建好源码环境,写个单测,运行起来, 然后设置断点调试, 观察结果, 很好的跟踪方式。
-
从简单着手, 善于分解小任务。对 Spring , Tomcat 的源代码无从下手? 从 JDK, Junit 看起; 一个庞大的类看的吃力? 不妨将其分解成多个小任务, 各个击破。
-
找点其它的事情做做。阅读源代码并不轻松。 如果起初不是很适应的话, 可以先读若干函数, 然后做点其它事, 比如活动活动, 听听歌看看视频, 然后再回来阅读, 反复如此, 来逐渐增强这种耐心和适应感。
心理锻炼
耐心与毅力
阅读源码需要很大的耐心,能很好滴锻炼耐心和毅力,而耐心和毅力均是可贵的品质。 阅读源码,起初是有些艰难,但是,一旦攻下,就为后面的前进打下很好的铺垫了。
神秘有难度
通常会认为优秀源代码很牛逼,反而敬而远之,不敢入宝山而探之。 其实,优秀源代码很平常,与日常所写的代码,是同样的原材料和材质。不同的是: 1. 组织条理性更强; 2. 逻辑更缜密。 这不正是所需要学习的地方吗?
此外,总会遇到看不懂,想不通的地方; 这时, 可能需要弥补下基础知识(数据结构与算法、设计模式、并发、网络协议、系统原理等), 也可能学习到某种高级技巧(函数式、位运算、字节码等), 一定不要放过这种学习机会。
强化练习
有两种模式:
- 初期,可以固定时段,比如每天早上或晚上半小时阅读源代码,建立反射弧;
- 中期,可以集中时段,强化阅读大量源代码。 有时候 “自虐”一下,会有更大的成长。
实际障碍
英语能力
emmm... 忘了说关键的一点了,阅读源代码需要一定的英语能力。 怎么办呢 ? 备个有道词典呗!
阅读难点
有些源码要读懂,有一些前置条件:需要先理解相关的算法、技术、网络、分布式等原理。一般来说,主要会有如下难点:
- 数据结构与算法:比如,理解 HashMap 就需要先理解哈希查找的原理,理解哈希表和红黑树的数据结构和操作;
- 并发技术: 比如,理解 AQS 需要对并发技术有相应的理解,理解 CAS 原理、多线程模型等;
- 算法与技术的组合:比如,理解 ConcurrentHashMap 就是数据结构、算法和并发技术的组合;
- 复杂交互: 比如,框架里的很多都是接口调用,弄不清具体类,这时就需要找到切入点,通过调试来弄清楚,静态分析代码不一定能找到。
没有时间
想做一定会挤出时间。欲望要足够强烈。
读不下去
可以适当边听音乐,边读代码。让音乐打通大脑的经脉,助你修炼阅码神功。
小结
就像任何一门技艺一样, 阅读源代码的技能也要从基础一点一点的训练, 直到娴熟、炉火纯青的境界。