Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8. 工程化

8.1 简介

  对于功能复杂或持续迭代的大型项目,代码量可能达到数万、数十万,甚至数百万、数千万行。如何高效组织与管理如此海量的代码,并保证系统具备良好的可维护性和可扩展性,是软件开发过程中必须解决的问题。人类大脑擅长通过分类和分层的方式处理复杂信息,将庞大的问题拆分为多个更容易理解和管理的部分。编程语言也采用了类似的思想,通过模块、命名空间、包、类等机制组织代码。目前,主流编程语言(如 Rust、C/C++、Java、Go、Python 等)都提供了相应的代码组织机制,以便开发者构建和维护大型软件系统。

  Rust 同样基于分类和分层思想设计项目结构,通过工作区(Workspace)、包(Package)、模块(Module)等机制,将代码划分为多个职责明确、层次清晰的组织单元(见下图),提高代码的可读性、可维护性和可扩展性。在实际开发过程中,开发者可以根据项目规模和复杂程度灵活选择所需的组织层级。小型项目可能只需要使用模块管理代码,而大型项目则可以进一步结合包和工作区,将多个功能模块拆分为独立的组件进行管理。

graph TD
    L1[工作区] --> L2-1[包 bin]
    L1[工作区] --> L2-2[包 lib]
    L1[工作区] --> L2-3[包 ...]
    L2-1[包 bin] --> L3-1[模块]
    L2-2[包 bin] --> L3-2[模块]
    L2-2[包 bin] --> L3-3[模块]
    L2-2[包 lib] --> L3-4[模块...]
    L2-3[包 ...] --> L3-5[模块]

8.2 包(Crate)

  在 Rust 中,包(crate)是程序编译和分发的基本单元,分为可执行程序包和库包。可执行程序包用于生成可执行程序,库包(crate)则用于提供功能模块,供其他项目引用和复用。每个包(crate)都包含一个 Cargo.toml 配置文件,用于管理包的元数据(如名称、版本等)以及其依赖项。

8.2.1 可执行程序包

  可执行程序包必须包含 src/main.rs 文件,并在其中定义程序的入口点 main 函数。使用 cargo new 名称 命令可以快速创建一个可执行程序包,生成的项目会包含一个 Cargo.toml 配置文件和一个 src/main.rs 文件,其中包含默认的 main 函数实现。

# 创建可执行程序包,名称为 app
shell> cargo new app

# 编译运行程序
shell> cd app
shell> cargo run
Hello, world!

# 查看项目结构
shell> tree app/
app/
├── Cargo.toml           # 生成的配置文件
└── src
    └── main.rs          # 生成 main.rs 文件

# 查看生成的配置文件
shell> cat app/Cargo.toml
[package]
name = "app"             # 名称
version = "0.1.0"        # 版本号
edition = "2024"

[dependencies]           # 依赖项

# 查看默认生成的 main 函数
shell> cat app/src/main.rs
fn main() {
    println!("Hello, world!");
}

8.2.2 库包

  库包必须包含 src/lib.rs 文件,并在其中定义库的公共功能和接口。使用 cargo new --lib 名称 命令可以快速创建一个库包,生成的项目会包含一个 Cargo.toml 配置文件和一个 src/lib.rs 文件,默认会生成一个 add 示例函数。下面的示例演示了如何创建一个可执行程序包 app 和一个库包 libmath,并在 app 中引用 libmath。

#![allow(unused)]
fn main() {
创建一个可执行程序包 app 和一个库包 libmath
shell> cargo new app
shell> cargo new --lib app/libmath   # 名称前面可以加路径

查看库包的项目结构
shell> tree app/libmath
liblog/
├── Cargo.toml
└── src
    └── lib.rs                   # lib.rs 默认会生成一个 add 函数
}
在 app 中引用 libmath
shell> cat app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
libmath = { path = "./libmath" }  # 添加依赖

在 app 中使用库包
shell> cat app/src/main.rs
fn main() {
    let sum = libmath::add(1, 5);
    println!("sum = {sum}");
}
# 编译运行
shell> cargo run
sum = 6

8.2.3 使用第三方包

  除了本地库包,还可以使用第三方包,Rust 社区提供了一个名为 crates.io 的平台,用户可以在其中发布和共享包。当我们需要某个库时,可以到 crates.io 上搜索相关包,然后使用 cargo add xxx 命令添加,或者手动在 Cargo.toml 文件的 [dependencies] 部分添加,指定第三方包的名称和版本,Rust 会自动从 crates.io 上下载并构建这些依赖包。下述示例演示了如何添加并使用随机数生成库 rand。

shell> cargo new app
shell> cd app

# 添加第三方包
shell> cargo add fastrand          # 默认最新版本
shell> cargo add fastrand@2.5.0    # 指定版本,推荐使用,可以避免依赖库升级导致程序异常

# 查看添加的依赖包
shell> cat Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
fastrand = "2.5.0"

# 编译时会自动下载并构建依赖包
shell> cargo build
......

        依赖添加完成后,即可使用其提供的功能。Rust 社区中有大量成熟的第三方库,涵盖网络通信、数据处理、数据库、异步编程、Web 开发等常见应用场景,可以直接复用这些现成的功能,快速扩展项目能力。

fn main() {
    // 生成 [0, 100) 范围内的随机整数
    let range_int = fastrand::u32(0..100);
    println!("0~99 的随机整数: {}", range_int);

    // 生成 [0, u8::MAX] 范围内的随机整数
    let range_u8 = fastrand::u8(..=u8::MAX);
    println!("随机 u8: {}", range_u8);
}intln!("随机 u8: {}", range_u8);
}
# 编译运行
shell> cargo run
0~99 的随机整数: 89
随机 u8: 51

  为了避免每次使用时都书写完整的模块路径,Rust 提供了 use 关键字,可以将指定的模块、类型或函数引入当前作用域;如果需要修改引入项的名称或避免名称冲突,还可以使用 as 关键字为其设置别名。

use fastrand::u32;
use fastrand::u8 as random_u8;

fn main() {
    // 生成 [0, 100) 范围内的随机整数
    let range_int = u32(0..100);
    println!("0~99 的随机整数: {}", range_int);

    // 使用 as 为 u8 函数设置别名
    let range_u8 = random_u8(..);
    println!("随机 u8: {}", range_u8);
}
# 编译运行
shell> cargo run
0~99 的随机整数: 97
随机 u8: 116

8.3 工作区(Workspace)

  工作区(Workspace)处于项目组织的最高层级,用于将多个 Crate 聚合到一个目录下,通过顶层 Cargo.toml 文件进行集中管理。各成员 Crate 在保持独立性的同时,可以共享配置(如版本、编译目标等),并统一依赖版本与编译输出目录。下述示例演示了如何创建工作区,以及如何通过顶层 Cargo.toml 管理 app、liblog 与 libutil 等多个成员包,实现代码的模块化解耦与资源共享。

# 创建项目目录
shell> mkdir demo && cd demo

# 创建三个 crate
shell> cargo new app
shell> cargo new --lib liblog
shell> cargo new --lib libutil

# 在根目录创建一个 Cargo.toml 文件作为工作区的配置文件,并将三个 crate 都添加到工作区中
shell> cat Cargo.toml
[workspace]
resolver = "3"
members = [
    "app",
    "liblog",
    "libutil",
]

# 编译运行
shell> cargo run -p app
Hello, world!

# 最终工作区的根目录结构如下
demo/
├── Cargo.toml
├── app
│   ├── Cargo.toml
│   └── src
│       └── main.rs
├── liblog
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── libutil
    ├── Cargo.toml
    └── src
        └── lib.rs

  在工作区中,成员 Crate 之间可以相互依赖,但需避免循环依赖——即 A 依赖 B,同时 B 又依赖 A。下面为 app 添加对 libutil 的依赖,并在代码中使用 libutil 提供的功能。

shell> cat app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
libutil = { path = "../libutil" }
// app/src/main.rs
fn main() {
    // 使用 `libutil` 提供的功能
    let sum = libutil::add(1, 2);
    println!("Sum: {}", sum);
}

8.4 模块(mod)

  模块用于在 Crate 内部进一步组织和拆分代码,通过模块,可以将功能相关的代码划分到不同的命名空间中,降低代码之间的耦合,避免命名冲突,并提高项目的可读性、可维护性和可扩展性。Rust 支持在同一源文件、不同源文件以及目录中定义模块,同时支持模块嵌套,从而构建更加层次化、清晰的代码结构。

8.4.1 同一文件中定义

  在同一个文件中声明模块,可以对相关功能代码进行集中组织,将逻辑相关的内容划分到独立的命名空间中,提高代码的可读性和结构清晰性,适用于较小的模块。例如,下述示例定义了一个交通工具模块,将不同交通工具的行驶时间计算函数统一组织在同一个模块中。

// 定义一个 vehicles 模块,包含与交通工具相关的代码
mod vehicles {

    // 计算汽车的行驶时间
    pub fn travel_time_by_car(distance: f64) -> f64 {
        let speed = 100.0; // 汽车速度(km/h)
        return distance / speed;
    }

    // 计算飞机的飞行时间
    pub fn travel_time_by_plane(distance: f64) -> f64 {
        let speed = 500.0; // 飞机速度(km/h)
        return distance / speed;
    }
}

fn main() {
    // 计算不同交通工具的行驶时间
    let car_time = vehicles::travel_time_by_car(1000.0);
    let plane_time = vehicles::travel_time_by_plane(1000.0);

    // 输出各交通工具的行驶时间
    println!("汽车行驶时间: {:.2} 小时", car_time);
    println!("飞机飞行时间: {:.2} 小时", plane_time);
}
shell> cargo run
汽车行驶时间: 10.00 小时
飞机飞行时间: 2.00 小时

  此外,Rust 支持模块嵌套,即在一个模块内部定义子模块。通过嵌套结构,可以构建层次化的代码组织方式,使功能划分更清晰,便于代码管理与扩展。例如,下述示例在 vehicles 模块内定义了 transport 和 fuel 两个子模块,分别负责计算行驶时间和油耗,使各模块职责明确,同时保持代码的整洁和可维护性。

mod vehicles {
    // 计算不同交通工具的行驶时间的模块
    pub mod transport {

        // 计算汽车的行驶时间
        pub fn travel_time_by_car(distance: f64) -> f64 {
            let speed = 100.0; // 汽车速度(km/h)
            return distance / speed;
        }

        // 计算飞机的飞行时间
        pub fn travel_time_by_plane(distance: f64) -> f64 {
            let speed = 500.0; // 飞机速度(km/h)
            return distance / speed;
        }
    }

    // 计算不同交通工具油耗的模块
    pub mod fuel {

        // 计算汽车的油耗(单位:L/100km)
        pub fn consumption_by_car(distance: f64) -> f64 {
            let consumption_rate = 8.0; // 每百公里消耗8升
            return (distance / 100.0) * consumption_rate;
        }

        // 计算飞机的油耗(单位:L/1000km)
        pub fn consumption_by_plane(distance: f64) -> f64 {
            let consumption_rate = 500.0; // 每千公里消耗500升
            return (distance / 1000.0) * consumption_rate;
        }
    }
}

fn main() {
    // 计算不同交通工具的行驶时间
    let car_time = vehicles::transport::travel_time_by_car(1000.0);
    let plane_time = vehicles::transport::travel_time_by_plane(1000.0);

    // 计算不同交通工具的油耗
    let car_fuel = vehicles::fuel::consumption_by_car(1000.0);
    let plane_fuel = vehicles::fuel::consumption_by_plane(1000.0);

    println!("汽车行驶时间: {:.2} 小时, {:.2} 升", car_time, car_fuel);
    println!("飞机飞行时间: {:.2} 小时, {:.2} 升", plane_time, plane_fuel);
}
shell> cargo run
汽车行驶时间: 10.00 小时, 80.00 升
飞机飞行时间: 2.00 小时, 500.00 升

8.4.2 不同文件中定义

  在不同文件中声明模块,可以将模块代码拆分到独立文件中,避免单个文件过于庞大,使项目结构更加清晰,适用于功能较复杂或规模较大的模块。例如,可以将不同交通工具相关的实现分别放在独立文件中,通过模块系统进行统一管理。该方式需要先在 crate 根文件(main.rs 或 lib.rs)中先声明模块,才能在其他模块中通过模块路径引用并使用该模块提供的功能。下述示例中,创建了 log.rs 和 vehicles.rs 两个模块文件,并在 crate 根文件中对其进行声明。

shell> tree src/
src/
├── log.rs
├── main.rs
└── vehicles.rs
// main.rs
// 在 crate 根文件 main.rs 中声明模块,模块名为文件名
mod log;
mod vehicles;

fn main() {
    // 通过 crate::模块名::函数名 的方式使用
    crate::log::info("main called");

    // main.rs 或 lib.rs 源文件中 crate 可以省略
    log::info("main called");
    vehicles::travel_time_by_car(1000.0);
}
#![allow(unused)]
fn main() {
// log.rs
pub fn info(text: &str) {
   println!("[info]: {text}");
}
}
#![allow(unused)]
fn main() {
// vehicles.rs
pub fn travel_time_by_car(distance: f64) -> f64 {
    // 通过模块路径使用其他模块提供的功能
    crate::log::info("travel_time_by_car called");
    let speed = 100.0;
    return distance / speed;
}
}

8.4.3 在其他目录中定义

  在不同目录中声明模块,可以进一步将多个相关模块组织到同一个目录中,形成更加清晰的层次结构,适用于包含多个子模块的大型功能模块。该方式需要创建一个与目录同名的 .rs 文件,并在该文件声明和管理该目录下的子模块。下述示例中,创建了 utils 模块目录,并通过 utils.rs 对 log 等子模块进行统一管理,使模块结构更加清晰。

shell> tree src/
src/
├── main.rs
├── util
│   └── log.rs
├── util.rs        # 创建与目录同名的 .rs 文件,并在该文件管理目录下的模块
// main.rs
mod util;        // util.rs 本身也需要声明 

fn main() {

    // 使用 util 模块中的 log 模块
    util::log::info("Hello World!");
}
#![allow(unused)]
fn main() {
// util.rs
// 统一管理 util 目录下的模块
pub mod log;
}
#![allow(unused)]
fn main() {
// util/log.rs
pub fn info(text: &str) {
   println!("[info]: {text}");
}
}

  此外,Rust 早期版本通常通过在目录中创建 mod.rs 文件来声明和管理该目录下的子模块。虽然这种方式可以将相关模块组织在同一目录中,但当项目包含大量模块目录时,会产生许多同名的 mod.rs 文件,容易增加代码阅读和维护成本。从 Rust 2018 版本开始,Rust 更推荐使用与目录同名的 .rs 文件作为模块入口,以简化文件结构并提高代码组织的一致性。下述示例,演示如何使用 mod.rs 文件作为目录模块的声明入口。

shell> tree src/
src/
├── main.rs
└── util
    ├── log.rs
    └── mod.rs
// main.rs
mod util;

fn main() {
    util::log::info("hello world");
}
#![allow(unused)]
fn main() {
// util/mod.rs
// 在该文件声明和管理该目录下的子模块
pub mod log;
}
#![allow(unused)]
fn main() {
// util/log.rs
pub fn info(text: &str) {
   println!("[info]: {text}");
}
}

8.5 访问权限控制

  访问权限控制用于管理模块、函数、结构体、字段和枚举等元素的可见性,通过隐藏内部实现细节来明确代码边界。合理使用访问权限,可以降低模块之间的耦合度,避免外部代码直接依赖或修改内部实现逻辑,从而实现封装,提高系统的安全性、可维护性和稳定性。Rust 提供了以下访问权限修饰符:

访问权限说明
private默认权限,仅在定义模块内部可见。
pub完全公开,可在任何地方访问。
pub(self)与 private 相同,只是可读性更强,限定为仅在当前模块内可见。
pub(crate)仅在当前 crate 内可见。
pub(super)仅在当前模块的父模块及其所有子模块内可见。
pub(in path)限定在指定模块路径内可见。
// 定义一个模块 logger,并使用不同的访问权限
mod logger {
    // private(默认):只能在该模块内部访问
    #[allow(dead_code)]
    fn private_function() {
        println!("This is a private function.");
    }

    // pub:可以在任何地方访问
    pub fn pub_function() {
        println!("This is a pub function.");
    }

    // pub(self):与 private 相同,只是为了可读性更强,限定为仅在当前模块内可见
    #[allow(dead_code)]
    pub(self) fn pub_self_function() {
        println!("This is a pub(self) function.");
    }

    // pub(crate):只在当前 crate 内可见
    pub(crate) fn pub_crate_function() {
        println!("This is a pub(crate) function.");
    }

    // pub(super):仅在当前模块的父模块及其所有子模块内可见
    pub(super) fn pub_super_function() {
        println!("This is a pub(super) function.");
    }

    // pub(in path):限制在指定路径内可见
    #[allow(dead_code)]
    pub(in crate::logger) fn pub_in_path_function() {
        println!("This is a pub(in path) function.");
    }

}

fn main() {
    // 编译错误,private_function 函数只能在 logger 模块内部访问
    // logger::private_function();
    // 调用 pub 函数,允许在任何地方访问
    logger::pub_function();

    // 编译错误,pub(self) 函数只能在 logger 模块内部访问
    // logger::pub_self_function();

    // 调用 pub(crate) 函数,允许在当前 crate 内访问
    logger::pub_crate_function();

    // 调用 pub(super) 函数,允许在当前模块的父模块及其所有子模块内访问
    logger::pub_super_function();

    // 编译错误,pub(in crate::logger) 函数,只能在指定的 crate::logger 内部访问
    // logger::pub_in_path_function();

}
shell> cargo run
This is a pub function.
This is a pub(crate) function.
This is a pub(super) function.