最近,我在开源项目 iluwatar/java-design-patterns 中提交的 Thread-Specific Storage 模式已经被合并(从我有这个念头发现这个issue到被merge历时一年多了…),而我也因此被加入了项目的 contributor 列表。回头看,这并不是一次“写完代码就结束”的轻松提交,而是一段很完整的开源协作经历:从认领 issue、阅读规范、补齐 README 和 UML,到处理 AI reviewer、Spotless、SonarQube、CI 和后续 stale 状态,再到最后被 maintainer 合并并通过 all-contributors 机器人加入贡献者名单。
这篇文章想完整记录这个过程。一方面是给自己留个纪念,另一方面也想告诉后来者:一次真正的开源贡献,往往不只是“把功能写出来”这么简单。
这次贡献从哪里开始
这次对应的任务是 issue #3225,标题是 Implement Thread-Specific Storage Pattern。
issue 的描述非常明确:需要在仓库中新增一个 thread-specific-storage 模块,用 Java 的 ThreadLocal<T> 演示 Thread-Specific Storage 模式,并满足几项验收标准:
- 要有独立模块或包
- 要有清晰的线程本地数据存取示例
- 要有 README 或文档说明
- 要遵守仓库代码风格
- 要补上多线程场景下的单元测试
- 要通过 CI 检查
这类 issue 很适合认真做一次完整的开源贡献。它不是只改一两行的小修小补,而是需要从“概念解释、代码实现、测试覆盖、文档表达、工程集成”几个维度一起完成。我当时刚好也在学习 Java 并发相关内容,而javaweb也常使用ThreadLocal作为线程上下文容器来存储用户变量,所以这个题目对我来说非常有吸引力。
我实际提交了什么
我最终提交的 PR 是 #3422,标题是 Add Thread-Specific Storage design pattern。
从功能上看,这个 PR 做了几件事:
- 在父
pom.xml中注册了thread-specific-storage模块 - 新增
UserContext、UserContextProxy、RequestHandler、App等核心类 - 用
ThreadLocal保存线程隔离的用户上下文 - 在
finally中显式清理线程本地变量,避免潜在内存泄漏 - 编写了对应测试,覆盖线程隔离和清理行为
- 补充了 README、示意图和 PlantUML 文件
如果只看最终合并结果,这像是一笔很干净的提交。但真正有意思的,是中间那条并不“线性”的演进路径。
时间线:这次开源贡献是怎么一步步推进的
下面这条时间线里,GitHub 页面时间我统一按 UTC 记录;本地 git 提交时间则保留了我机器上的 +08:00 时区。
1. issue 先于实现存在了很久
issue #3225 创建于 2025-03-30 09:06:07 UTC。
它并不是我一出现就新开的任务,而是项目里已经存在了一段时间、也有多人关注过的 feature issue。在5月31日我第一个表达了想做这个的意愿。当时不懂,想的是一定要被assigned才能开做,于是就等呀等,甚至有了别人也留言要开始做。于是在9月26日,“If there’s no objection, I’ll start implementing this now and submit a PR. 🚀”。但当时又忙着上班,做完了初次提交就没有精力再做下去,直到次年的1月28日才想起来,做完交了pr。
这件事很能说明开源协作的一个现实:很多需求并不是没人想到,而是一直缺一个愿意把它真正做完的人。
2. 第一个核心提交:先把模式完整搭起来
我本地最关键的初始提交是:
23f22a37dAdd Thread-Specific Storage design pattern
本地时间:2026-01-22 23:34:31 +08:00
这一次提交直接把模块骨架、源码、测试、README 和 UML 一起带上了。也就是说,我不是先丢一个“占坑 PR”,而是尽量把一个设计模式应有的教学形态一次性做完整。
对这种 tutorial 型仓库来说,这一点很重要。因为这里不是单纯追求“代码能跑”,而是要让读者能看懂这个模式是什么、为什么这么设计、在什么场景里使用。
3. 很快发起 PR,但很快也收到了第一波反馈
PR #3422 创建于 2026-01-22 15:50:43 UTC。
PR 刚开不久,自动化 reviewer 就给出了第一批非常具体的反馈,主要集中在两点:
@Slf4j下使用了LOGGER,但 Lombok 默认生成的是log- 在子线程里直接做断言,测试失败不一定能稳定反馈到主测试线程
我觉得这两条很有代表性。第一条是典型的“代码逻辑没错,但工程细节会让它编译不过”;第二条则是并发测试里非常常见、也很容易被忽略的问题。很多时候你以为自己写了测试,其实只是“看起来有测试”。
4. 开始进入“修格式、修静态检查、修测试稳定性”的阶段
接下来几次提交,基本上都不是功能扩展,而是在把这个 PR 往“可合并状态”推进。
5d8581ac3Run 'mvn spotless:apply' to fix these violations.
时间:2026-01-22 16:42:25 UTC
这次主要是在处理格式和风格问题。很多人第一次做开源会觉得这些检查“有点烦”,但其实这正是大型仓库维持一致性的方式。不是为了为难贡献者,而是为了降低后续维护成本。
34cc03f6bfix: address Sonar security hotspots for Random usage and e.printStackTrace()
时间:2026-01-22 17:30:20 UTC
这次主要是处理 SonarQube 的关注点,比如:
- 用
SecureRandom替换Random - 把
e.printStackTrace()改成日志输出
这里其实很能体现“开源仓库”和“本地练习代码”的差别。平时自己写 demo 时,这些地方很容易随手带过;但一旦要进成熟仓库,静态分析工具会逼着你把这些细节补齐。
106f06d80Run 'mvn spotless:apply'(remove unused import)
时间:2026-01-22 17:52:19 UTC
这又是一次很典型的“修小问题但必须修”的提交。你会发现,真正进入工程化流程后,很多合并前的工作都不是“难”,而是“细”。
5. 最有价值的一次重构:不仅修 bug,还把结构做得更干净
我认为中间最关键的一次迭代是:
d3a804105Refactor Thread-Specific Storage pattern implementation to address Sonar issues...
时间:2026-01-23 03:27:24 UTC
这次不是单纯“修一个点”,而是整体上把实现打磨得更合理,包括:
- 去掉了不必要的
UserContextProxy实例化 - 给工具类补上私有构造器
- 用菱形语法简化泛型
- 改善线程中断处理
- 使用
Awaitility改进测试等待方式,让测试不再依赖脆弱的Thread.sleep - 让
RequestHandler的职责更收敛,使用静态UserContextProxy调用而不是携带冗余依赖
这次重构让我体会很深的一点是:
开源评审最有价值的地方,不是“帮你挑刺”,而是逼你把一个原本能工作的版本,继续打磨到更适合长期维护的状态。
6. 最后补 README 和 UML,让表达和实现一致
最后我又提交了:
1bc4eddd8fixed: update uml and README.md
本地时间:2026-01-23 12:20:07 +08:00
这一步很容易被低估。很多人做完代码就觉得任务结束了,但对 java-design-patterns 这种仓库来说,文档和图示并不是附属品,而是交付的一部分。
尤其是设计模式教程项目,读者通常是先看 README,再看图,再决定要不要深入读代码。所以 README 和 UML 不是“锦上添花”,而是这个模块的门面。
7. CI 还出过一次小插曲
我后来还专门在 PR 里留言说明:CI 可能因为 AI Reviewer workflow 的配置问题失败,所以我通过一次空提交触发了重新运行:
c110258184Trigger CI rerun
时间:2026-01-23 04:35:39 UTC
这件事也很真实。开源贡献里,阻碍你前进的未必总是业务代码本身,有时候是 workflow、有时候是权限、有时候是第三方机器人。解决问题的能力,不只体现在写代码上,也体现在你能不能判断出“问题到底出在我这里,还是出在流水线环境里”。
8. 所有检查通过了,但 PR 并没有马上被合并
根据 SonarQube 在 PR 下的回报,这个 PR 最终达到了:
0 New issues0 Security Hotspots86.0% Coverage on New Code0.0% Duplication on New Code
这说明从代码质量角度看,它其实已经达到了一个很健康的状态。
但现实是,通过检查不等于立刻合并。
PR 在 2026-03-30 还被 bot 标记成了 stale。对第一次认真参与开源的人来说,这种阶段挺磨心态的:你会觉得“我明明已经做完了,为什么还没有结果?”
我后来慢慢理解了,maintainer 的节奏和贡献者的节奏并不总是一致。很多仓库 issue、PR 很多,维护者未必能第一时间处理到每一个贡献。这个时候最重要的能力,反而是耐心。
9. 最终合并,以及成为 contributor
好消息是,这个 PR 最终还是被合并了。
更让我开心的是,在合并后不久,maintainer iluwatar 还在 PR 评论里触发了:
@all-contributors please add @CMD137 for code
随后机器人自动发起了 PR #3497,标题是 docs: add CMD137 as a contributor for code,并在 2026-06-01 18:13:50 UTC 被合并。
这意味着我不仅完成了这次贡献,也正式被写进了项目的 contributor 列表中。
对我来说,这个瞬间很有纪念意义。因为它意味着这次提交不再只是“我在本地完成过的一次练习”,而是真正进入了项目历史。
这次贡献里,我学到的并不只是 ThreadLocal
表面上看,这次做的是一个并发设计模式;但真正让我收获最多的,其实是开源工程层面的东西。
1. 教程型仓库的交付标准,和普通 demo 完全不同
在自己本地写一个 ThreadLocal 示例,只要能跑通就够了。
但在 java-design-patterns 这样的仓库里,你交付的是一份“别人会学习、会引用、会模仿”的材料。
所以你必须同时关心:
- 代码是否能表达模式本身
- 命名是否清晰
- 测试是否稳定
- README 是否适合教学
- UML 是否和实现一致
- 工具链是否全部通过
这是一种比“完成功能”更高一级的要求。
2. ThreadLocal 的重点不只是“存”,更是“清”
这次实现里我特别在意的一点,是在 finally 中调用清理逻辑。
很多 ThreadLocal 教程只会讲“每个线程有独立副本”,但在真实项目里,更关键的是你必须意识到线程可能被线程池复用。如果不清理,线程本地数据就可能把上一次任务的信息带到下一次任务中,甚至造成内存泄漏。
所以 Thread-Specific Storage 模式真正想传达的,不只是“线程隔离很方便”,而是:
线程隔离是有代价的,必须配套明确的生命周期管理。
3. 测试多线程代码,不能只靠“睡一会儿”
这次 PR 里,关于测试稳定性的反馈给我印象很深。
一开始很多写法其实都很常见:
Thread.sleep(...)等线程执行完- 在子线程里直接断言
这些写法不是完全不能用,但很容易变成脆弱测试。后来通过改进等待方式、让断言结果更可控,我才真正意识到:并发测试本身也是一门技术。
4. 开源贡献不是一次“提交代码”,而是一次“持续响应”
这次经历里,我并不是发完 PR 就结束了,而是持续在做:
- 读反馈
- 改实现
- 修格式
- 处理 Sonar
- 处理 CI
- 补文档
- 等待 maintainer 节奏
这让我越来越觉得,开源协作本质上是一种异步合作。
你提交的不只是代码,也是你是否愿意把一件事负责到底的态度。
如果让我总结这次经历
如果只用一句话总结,我会说:
这次贡献让我第一次真正体验到,什么叫“把一个功能做成项目愿意接收的样子”。
从 issue #3225 到 PR #3422,再到 contributor PR #3497,这条链路让我看到了一次完整开源贡献的真实形态:
- 不是只写代码
- 不是只会修 bug
- 不是检查全绿就万事大吉
- 而是从实现、表达、规范、工具链到协作节奏,全部都要跟上
现在回头看,最值得纪念的其实不只是“我被加进 contributor 列表了”,而是我开始更具体地理解了开源项目是怎么运转的,也更相信自己可以在这样的项目里持续贡献下去。
如果你也正准备做第一次像样的开源贡献,我很想说一句:
不要怕流程复杂。真正走完一遍之后,你得到的成长,通常远比那几百行代码本身更大。
参考链接
- Issue: #3225 Implement Thread-Specific Storage Pattern
- PR: #3422 Add Thread-Specific Storage design pattern
- Contributor comment: PR #3422 comment
- Contributor PR: #3497 docs: add CMD137 as a contributor for code