3. 项目结构设计:目录组织规范、模块划分原则、接口与实现分离

好,咱们进入第三章。说实话,项目结构这事儿,看着不起眼,但往往是决定一个构建系统能活多久的关键。我见过太多项目,一开始随便扔几个文件夹,三个月后连自己都找不到头绪。嗯,咱们今天就把这事儿聊透。

3.1 目录组织规范:别让文件「流浪」

先说说目录怎么摆。我个人习惯是「按功能分层,按模块聚合」。什么意思呢?就是顶层目录按构建流程的职责来分,每个目录内部再按模块组织。

我早期做过一个项目,把所有 .c 文件都塞在 src 下面,结果编译脚本写了一堆绝对路径,维护起来简直噩梦。后来我总结了一套结构,分享给你:

build-system/
├── src/                    # 核心源码
│   ├── core/              # 构建引擎核心
│   ├── parser/            # 配置文件解析
│   ├── executor/          # 任务执行器
│   └── utils/             # 工具函数
├── include/               # 公共头文件
│   └── build/             # 对外暴露的 API
├── plugins/               # 插件系统
│   ├── cmake/             # CMake 集成
│   └── make/              # Makefile 集成
├── tests/                 # 测试代码
│   ├── unit/              # 单元测试
│   └── integration/       # 集成测试
├── docs/                  # 文档
├── examples/              # 示例项目
└── scripts/               # 构建辅助脚本

你看,每个目录的职责非常清晰。src 只放源码,include 只放头文件,谁也不越界。我曾经见过有人把测试代码和源码混在一起,结果发布时忘了排除测试文件,用户编译报错——这种坑,咱们不踩。

小技巧: 目录命名统一用小写加连字符(kebab-case),比如 build-system 而不是 buildSystem 或 build_system。这样在命令行和脚本里操作最顺手,不会因为大小写问题出幺蛾子。

3.2 模块划分原则:高内聚,低耦合

模块划分这事儿,说白了就是「把相关的放一起,不相关的隔开」。我常用的原则就三条:

  • 单一职责: 一个模块只做一件事。比如 parser 模块只负责解析配置文件,别让它去管文件读写。
  • 依赖方向: 高层模块依赖低层模块,不要循环依赖。我见过一个项目,A 模块调用 B,B 又回调 A,最后编译都过不了。
  • 接口稳定: 模块对外暴露的接口要尽量少,尽量稳定。改接口就像改地基,牵一发动全身。

举个例子,咱们的构建系统里,core 模块是核心引擎,它依赖 parser 和 executor。但 parser 和 executor 之间不应该有直接依赖。为什么?因为如果 parser 改了,executor 也要跟着改,那维护成本就上去了。

核心原则: 模块之间的通信只通过接口,不直接访问内部数据。这样即使内部实现重写,只要接口不变,其他模块就不受影响。

3.3 接口与实现分离:让代码「可替换」

这是我最想强调的一点。接口与实现分离,说白了就是「声明和定义分开」。在 C/C++ 里就是 .h 和 .c 文件分开,在 Java 里就是 interface 和 class 分开。

为什么要这么做?我举个例子。之前我做一个构建系统,需要支持多种编译器。如果我把编译器调用逻辑直接写在 core 模块里,那每加一个编译器就要改 core 的代码,风险极高。正确的做法是:

// include/build/compiler.h
typedef struct {
    int (*compile)(const char* source, const char* output);
    int (*link)(const char** objects, int count, const char* output);
} CompilerInterface;

// src/core/compiler_gcc.c
#include "build/compiler.h"
static int gcc_compile(const char* source, const char* output) {
    // GCC 具体实现
    return 0;
}
static int gcc_link(const char** objects, int count, const char* output) {
    // GCC 链接实现
    return 0;
}
CompilerInterface gcc_compiler = {
    .compile = gcc_compile,
    .link = gcc_link
};

你看,core 模块只依赖 compiler.h 这个接口,具体是 GCC 还是 Clang,通过注册不同的实现来切换。这样加新编译器时,只需要新增一个 .c 文件,完全不用动 core。

注意: 接口文件要放在独立的 include 目录,不要和实现混在一起。我曾经犯过这个错——把接口定义放在 src 里,结果其他模块引用时路径乱七八糟,最后不得不重构。

3.4 实际案例:一个模块的完整结构

咱们拿 parser 模块举个例子,看看完整的目录和文件怎么组织:

src/parser/
├── parser.h              # 内部头文件(模块内使用)
├── parser_internal.h     # 内部数据结构
├── lexer.c               # 词法分析
├── parser.c              # 语法分析
├── ast.c                 # 抽象语法树
└── CMakeLists.txt        # 模块构建配置

include/build/
└── parser.h              # 对外接口(只暴露必要函数)

这里有个细节:src/parser/parser.h 和 include/build/parser.h 是两个文件。前者是模块内部使用的,包含一些内部函数;后者是对外暴露的,只包含稳定接口。这样外部模块只能通过 #include "build/parser.h" 来使用,看不到内部实现。

嗯,这种设计在大型项目里特别有用。我记得有一次重构 parser 模块,内部实现几乎重写了一遍,但因为对外接口没变,其他模块一行代码都没改——那种感觉,真的很爽。

3.5 避坑指南:我踩过的几个坑

  • 坑一: 我曾经把所有头文件都放在一个目录下,结果不同模块的同名文件冲突了。后来我强制要求每个模块的头文件放在 include/模块名/ 下,再也没出过问题。
  • 坑二: 接口设计得太细,导致每次加功能都要改接口。后来我学会「接口要粗粒度,实现要细粒度」——对外接口尽量少,内部函数可以随意拆分。
  • 坑三: 模块之间直接引用全局变量。这简直是灾难,因为一旦变量名冲突或者初始化顺序不对,整个系统就崩了。我现在强制要求模块间只能通过函数调用通信。

3.6 总结一下

项目结构设计,说白了就是「定规矩」。目录怎么摆、模块怎么分、接口怎么暴露,这些规矩定好了,后面写代码就是按部就班的事。你想想看,如果每个模块都规规矩矩的,调试、测试、重构都会轻松很多。

下一章咱们会聊构建配置怎么写,到时候你会看到,好的项目结构能让配置工作事半功倍。今天就到这儿,去动手整理一下你的项目目录吧。