怎么为一个陌生的项目添加功能
在学习完一门编程语言的语法后,“怎么阅读项目代码” 无疑是一个经常会被问起的问题。
“明明循环分支赋值调用我都认识,怎么就是看不懂代码在说什么?”
是啊,为什么呢?
在语法和项目之间无疑缺失了一部分内容,我之前猜想可能缺失的是是软件工程,但实际上在学习软件工程这门课后我还是没有听到这个问题的答案。本文我提供一些浅薄的看法。
Building: 在阅读代码之前
首先需要注意的是,不要直接点开代码开始看,这是鲁莽的思路,一不小心你就会陷入各种调用、抽象、回调和宏定义的陷阱中。
为了迅速了解一个项目,首先需要看的是项目的文档,如果文档把项目的架构写的很清楚,那么皆大欢喜,你可以跟随文档进行学习。
常见的项目目录与意义
当然事情不会那么容易,大部分的程序员都是不会认真写文档的,如果你觉得文档不知所云,最好的办法是观察项目的结构 —— 简单来说就是文件夹的组织,典型的源码架构会有比较明确的树形文件划分。你可以通过 build/script/util/core/dependency 等目录的名字看出源码职责的宏观划分。当然也可能所有的源码都堆积在源码根目录下。
下面列举我见到的一些通用的文件夹命名。
-
src: source,指源码,通常是项目的核心部分。 -
include: 通常是类 C 式的头文件或者公共接口的声明 -
lib:library,指编译好的静态 / 动态库 -
test:单元测试、集成测试代码 -
docs:项目相关的文档 -
build:编译过程生成的中间文件 -
scripts:辅助脚本,比如构建、部署、格式化等,以.py和.sh居多 -
assets:静态资源(图片、文本等) -
util:工具函数集合,其逻辑通常独立于项目 -
dependency:处理外部依赖。
当然,这不是金科玉律,很多项目有自己的习惯。
常见的配置文件
在了解完文件夹的结构后,初次接触项目的时候,我会在根目录下看到各种莫名其妙的文件。这些有的与项目的架构有关,有的与协作开发有关,有的与自动化测试有关。此处我们将它们统称为配置文件,下面列举一些常见的配置文件。
-
README.md:项目的门面,用 Markdown 编写,通常包含项目简介,必看。 -
LICENSE:开源协议。 -
.git:Git 的本地版本数据库,存着所有提交记录、分支和标签。 -
.gitignore:列出不需要提交到仓库的文件。 -
.gitkeep:一个 "占位空文件"。其作用是让某个空文件夹保留在仓库里 -
.gitattributes:Git 的文件属性设置,通常用来规范化换行符。 -
.gitmodules:当项目依赖另一个独立的 Git 仓库时,会通过它记录依赖的远程地址和本地路径。 -
.github/:一个文件夹,存放 GitHub 专用的自动化配置。 -
workflows/*.yml:CI/CD 自动构建 / 测试。 -
package.json:Node.js 项目,声明项目依赖,从中能知道项目用的是什么框架。 -
pom.xml:Java Maven 项目,声明 Java 依赖包和插件。 -
requirements.txt:Python 项目的传统依赖列表。 -
cargo.toml:Rust 项目,声明依赖 crate、项目元数据、特性开关。 -
Makefile:快捷命令脚本,包含一系列编译、清理、安装的 Shell 命令,常用于 C/C++。 -
eslintrc:JavaScript/TypeScript 的代码检查规则。 -
.clang-format:C/C++/Objective-C 的代码格式化规则。 -
.editorconfig:跨 IDE 的基础编辑规范。 -
.vscode/:存放 VSCode 编辑器的本地工作区配置。 -
dockerfile:构建容器镜像的配置。 -
docker-compose.yml:多容器编排的启动配置。 -
.dockerignore:作用类似于.gitignore,告诉 Docker 在构建镜像时忽略哪些文件 -
vite.config.js:前端构建工具的配置文件。 -
.env.example:环境变量模板文件,通常需要将其复制一份命名为.env。 -
commitlint.config.js:限制 Git 提交信息的格式。 -
pubspec.yaml:Flutter 项目的依赖管理和资源配置。 -
CMakeLists.txt:CMake 构建脚本。用于生成 Makefile 或 IDE 工程文件。 -
configure:配置脚本,运行./configure会检查系统环境,生成 Makefile。
一时间大概能想到的是这些,看到这些文件基本就知道项目使用的编程语言与框架。可以说是相关技术的信号灯了。
试着运行项目
在完成初步探索后,需要试着先运行一次项目,切实的理解项目具体做了什么。在这个过程中,你可能需要下载相关的编程语言的编译器,运行时,插件,语法检查工具,构建工具等等。在安装前,务必在上一节的各个配置文件中找到其依赖的版本,严格按照项目指定的版本安装。如果项目依赖 MySQL 等数据库服务,你需要安装并配置好数据库服务。
在安装好语言环境后,大部分的现代语言都靠包管理器拉取依赖。Node.js 使用 npm,Python 使用 pip,Java 使用 mvn 或 gradle ,Rust 使用 cargo 等等。
依赖装好后,项目大概率会在启动时报错,这是因为没有配置项目本身的配置文件。这可能包括字面包含 config 的配置文件,.env 环境变量文件,APIKEY、DB_PASSWD 等常量值的指定。
依赖和配置都有了,但数据库可能是空的。需要执行初始化脚本建表,通常这会在script目录下,如果没有,需要阅读源码手动建表。
整理开发环境
在代码成功跑通后,可以根据自己的需要整理一下开发环境。
我个人习惯使用 VSCode,其有以下比较实用的配置,我习惯在.vscode/settings.json 中配置它们。
files.exclude:用于从文件资源管理器(Explorer)中隐藏文件。search.exclude:从全局搜索(Ctrl+Shift+F)中排除文件。files.watcherExclude:从文件监视中排除文件。
好了,我们终于可以开始读代码了!
Guessing:怎么开始读代码
一个正统的方法是用 main 函数自顶向下阅读,权衡深度和广度遍历所有调用栈。这无疑是非常扎实的方法。这确实能从根本上吃透整个项目,但是这需要难以想象的时间和精力,而且很容易陷入到与功能无关的代码中。
另一种探索源码的方法是猜测,这是一种猜测源码的实现形式,搜索定位相关部分后,阅读对应上下文和调用栈的形式。一个非常常见的定位手段是常量字符串的定位手段,即通过检索程序报错信息或者处理的常量来定位代码。(当然,AI Agent 已经可以帮我们完成这件任务了)
定位到可能的位置后,我们修改源代码,使用 print 输出一些语句,重新编译(当然现代语言也几乎都支持断点调试)。情况 1 是无法进行编译,我们就可以顺着编译器的报错信息摸清楚函数的调用栈。情况 2 是通过了编译,那么我们就可以输出一些更有用的信息来探索定位点附近的功能。Guessing 为我们节省了大量定位代码的时间,所以在接下来,我们需要对定位的功能代码进行精细的分析才能进行进一步的修改。
常见的软件模式
当然猜的准不准还是需要大量的实践积累,常见的软件模式有:
FullWeb:前后端分离,浏览器访问页面,前端调用后端 API。
C/S:客户端自带界面,通过网络与中心服务器通信。
frontend:没有后端逻辑,主要做 UI 渲染、状态管理和本地存储
GUI:没有 "网络" 和 "数据库" 的纯界面开发。
CLI:纯命令行程序。
常见的代码结构
探索软件的功能,通常 "使用了那些数据" 以及 "使用了那些数据结构处理" 是问题的关键。前者需要直觉和相关领域的算法实现,后者则与约定俗成的代码模式有关。我大概见过这些:
-
分层架构:代码被垂直划分为若干层,常见如 Controller → Service → Repository/DAO。 -
MVC:Model(数据)、View(展示)、Controller(控制逻辑)三分。 -
事件驱动:没有直接的请求 - 响应链路,代码通过发送和监听事件(Message)来触发。 -
Kernel 式的分发:存在一个无限循环,循环内部每次迭代(一帧)会执行一组固定的步骤。常见于游戏开发。 -
监听事件:程序启动时会执行一次初始渲染,通过事件回调触发状态变更,常见于前端框架。 -
状态机:系统在不同的状态(IDLE,RUNNING,ERROR)之间转移,由事件触发转换。 -
Consumer Producer:代码里有一个队列。一部分代码往里丢数据,另一部分代码从中取数据并处理。 -
响应式:通过描述数据流的转换来替代数据的更新。
尽可能提高编译效率
在使用的工具不够便捷的时候,很容易就把时间浪费到编译过程中去了,所以在第一次完成整个项目的编译运行后,可以通过编写构建脚本来提高编译运行的效率。这一过程我主要使用的有 Makefile 和.vscode/tasks.json。
Makefile 是一种能定义文件依赖关系和编译命令的脚本,在 C/C++ 项目中广泛使用,但是其本质是一个通用的任务自动化运行器。
tasks.json 是 VSCode 的任务配置文件,本质是执行预定义的 shell 命令。那么不难就可以在使用 Makefile 的基础上配置一个 make 快捷键,大大提升效率。
Feature:怎么添加功能
在实际的软件开发过程中,一旦有了原型,接下来的所有时间都在调整软件的功能,添加正确的功能才是最终要达成的目的。
定位目标代码
为了给软件添加功能,第一个要做的事情就是定位,这与第二节我们讲的部分略有相似之处。我们通过观察软件的行为,通过检索 "锚点",就可以模糊的定位到软件的函数栈附近,随后经过合适的跳转就可以定位到目标位置的上下文。
Q:什么是 "锚点",怎么检索它?
"锚点" 是一个比喻,指的是代码中具有唯一性的常量,宏定义,和我们通过观察日志猜测的代码模式。因为错误信息几乎不会改动,它们非常直白,让你能以此为锚点。
比如运行软件时,界面弹窗或终端输出:
那么,直接在代码库中搜索 "Failed to allocate memory for texture" 就可以定位到 texture Memory allocator 的相关代码,进而就可以定位到调用 allocator 前的 initial 函数等等。
模仿相似的功能
根据人类的直觉,相似的功能往往会使用相似的函数,相似的代码结构和相近的位置。所以在实现功能的时候,不妨先定位代码中相似的功能,捋清其逻辑后,再顺着其的调用栈一层层魔改。
假设我们要给一个类似 UNIX 的内核添加一个 get_current_task_name() 系统调用,用于获取当前运行进程的名字。按照 "相似功能用相似函数" 的直觉,我们先在源码里搜索 "进程名" 相关的现成函数。很快会发现现有的 getpid() 和 getppid() 系统调用。
但是在模仿的过程中有一件事要特别注意。
尽早编译成功
在修改项目代码后,一个很常见的问题是随着改动不断增加,报错也不断增加,最后在项目上捅出一个越来越大的窟窿。
有两件重要的法则:一个是尽早编译成功,一个是切割无关功能。
永远不要试图一次添加多个功能,这会混淆你对代码的掌控度。如果发现某些无关函数因为你的改动而报错,只需要通过一些简单的改动维持住它们现有的功能并标记 TODO 即可。
错误越晚发现,修复成本越高。在将代码修改一定程度后,立刻启动编译,逐次修复所有的 error 并测试新的功能。这种方法也被称之为 "步行骨架"(Walking Skeleton)。
Make it work, then make it right, then make it fast.
利用好版本管理
一个典型的错误做法是:憋一整天,改了几十个文件,最后 git add . 一把梭。(虽然但是很对不起我也这么干,vibe-coding 害了我😭)
正确的做法是在 3.3 完成一次最小的功能验证且编译通过后,立刻提交并在 commit message 中注明改动。这也被称为原子化提交(Atomic Commit),这样做的好处有很多,一个显而易见的优势是万一后续改崩了,git revert 只会丢掉最近几分钟的工作。
另一个好的做法是利用 git 的分支,通过切一个新的分支:git checkout -b feat/hatsunemiku 完成改动,这样就可以放心地在一个与主分支毫不影响的分支上工作。
目前大概能想到这些,欢迎在评论区中发表您的见解。
感谢您的观看!