利用 Rust 构建一个面向 Kotlin Monorepo 的高性能增量式构建系统


团队的 Monorepo 越来越臃肿,CI 时间从最初的 5 分钟飙升到了 45 分钟,本地构建一杯咖啡的时间都不够。问题根源很清晰:一个混合了 Kotlin/JVM 微服务、一个共享的 Kotlin Multiplatform 模块,以及一个核心高性能计算库(用 Rust 编写)的复杂项目,被一套脆弱的 Gradle 脚本和 make 文件粘合在一起。每次修改一个底层库,Gradle 守护进程似乎总有理由重新计算所有任务图,而 make 的时间戳依赖在分支切换时频繁出错。这不仅是成本问题,更是对开发者心流的持续破坏。

我们受够了 JVM 启动的开销、Groovy 脚本的动态性调试地狱,以及跨语言依赖管理的混乱。初步构想是,用一种原生、高性能的语言编写一个专用的构建协调器,它必须足够快,足够智能,能理解我们整个技术栈的依赖关系。Rust 成了不二之选。它的性能、内存安全、强大的生态系统(特别是 clap, serde, rayon, petgraph)使其成为构建这类系统级工具的理想选择。

我们的目标不是要重新发明 Bazel 或 Buck,而是要为我们特定的技术栈打造一个“恰到好处”的解决方案,核心要求有三:

  1. 声明式配置:用简单的 TOML 文件描述所有模块及其依赖关系。
  2. 内容寻址缓存:实现可靠的增量构建,只有当文件内容或依赖项的产物发生变化时才重新执行任务。
  3. 原生跨语言支持:无缝处理 Kotlin/JVM、Kotlin/Native 和 Rust 模块之间的依赖,尤其是 JNI/FFI 的链接。

第一步:定义项目结构与构建蓝图

我们的第一步是设计一个 polyglot.toml 配置文件,它将成为整个构建系统的唯一事实来源。这个文件需要清晰地表达模块、任务和它们之间的关系。

一个典型的项目结构可能如下:

monorepo/
├── polyglot.toml
├── libs/
│   ├── native-core/ (Rust)
│   │   ├── src/lib.rs
│   │   └── Cargo.toml
│   └── shared-logic/ (Kotlin Multiplatform)
│       └── src/commonMain/kotlin/common.kt
└── services/
    └── user-service/ (Kotlin/JVM)
        └── src/main/kotlin/main.kt

对应的 polyglot.toml 设计如下。这里的关键是定义 [[module]] 数组,每个模块都有一个唯一的 name,一个 type (用于分派不同的构建逻辑),以及其 dependencies

# polyglot.toml

[workspace]
name = "polyglot-monorepo"
build_dir = "build/polyglot"
cache_dir = ".polyglot_cache"

[[module]]
name = "native-core"
path = "libs/native-core"
type = "rust.library"

[[module]]
name = "shared-logic"
path = "libs/shared-logic"
type = "kotlin.mpp"
dependencies = ["native-core"] # Kotlin 模块依赖 Rust 模块

[[module]]
name = "user-service"
path = "services/user-service"
type = "kotlin.jvm.app"
dependencies = ["shared-logic"]

为了在 Rust 中解析这个配置,我们借助 serdetoml crate。数据结构的设计直接反映了配置的层级关系。

// src/config.rs

use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Deserialize)]
pub struct WorkspaceConfig {
    pub name: String,
    pub build_dir: PathBuf,
    pub cache_dir: PathBuf,
}

#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum ModuleType {
    #[serde(rename = "rust.library")]
    RustLibrary,
    #[serde(rename = "kotlin.mpp")]
    KotlinMpp,
    #[serde(rename = "kotlin.jvm.app")]
    KotlinJvmApp,
}

#[derive(Debug, Deserialize, Clone)]
pub struct ModuleConfig {
    pub name: String,
    pub path: PathBuf,
    #[serde(rename = "type")]
    pub module_type: ModuleType,
    #[serde(default)]
    pub dependencies: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct ProjectConfig {
    pub workspace: WorkspaceConfig,
    #[serde(rename = "module")]
    pub modules: Vec<ModuleConfig>,
}

impl ProjectConfig {
    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, anyhow::Error> {
        let content = std::fs::read_to_string(path)?;
        let config: Self = toml::from_str(&content)?;
        Ok(config)
    }

    pub fn get_module_map(&self) -> HashMap<String, &ModuleConfig> {
        self.modules.iter().map(|m| (m.name.clone(), m)).collect()
    }
}

这个结构为后续构建任务图提供了坚实的基础。

第二步:构建任务依赖图 (DAG)

有了模块定义和依赖关系,下一步就是将其转化为一个有向无环图(DAG)。petgraph 是处理这类问题的绝佳工具。我们将每个模块视为图中的一个节点,依赖关系为边。

// src/graph.rs

use crate::config::{ModuleConfig, ProjectConfig};
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::Topo;
use std::collections::HashMap;
use anyhow::Result;

pub struct BuildGraph<'a> {
    pub graph: DiGraph<&'a ModuleConfig, ()>,
    pub node_map: HashMap<String, NodeIndex>,
}

impl<'a> BuildGraph<'a> {
    pub fn new(config: &'a ProjectConfig) -> Result<Self> {
        let mut graph = DiGraph::new();
        let mut node_map = HashMap::new();
        let module_map = config.get_module_map();

        // 1. 添加所有模块作为节点
        for module in &config.modules {
            let idx = graph.add_node(module);
            node_map.insert(module.name.clone(), idx);
        }

        // 2. 根据依赖关系添加边
        for module in &config.modules {
            let to_idx = *node_map.get(&module.name).unwrap();
            for dep_name in &module.dependencies {
                let from_idx = node_map.get(dep_name)
                    .ok_or_else(|| anyhow::anyhow!("Module '{}' depends on non-existent module '{}'", module.name, dep_name))?;
                graph.add_edge(*from_idx, to_idx, ());
            }
        }
        
        // 3. 检查循环依赖
        if petgraph::algo::is_cyclic_directed(&graph) {
            return Err(anyhow::anyhow!("Circular dependency detected in project configuration."));
        }

        Ok(Self { graph, node_map })
    }

    pub fn build_order(&self) -> Vec<&'a ModuleConfig> {
        let mut topo = Topo::new(&self.graph);
        let mut order = Vec::new();
        while let Some(nx) = topo.next(&self.graph) {
            order.push(self.graph[nx]);
        }
        order
    }
}

通过拓扑排序(Topo::next),我们能得到一个正确的构建顺序,确保在构建一个模块之前,它的所有依赖项都已被构建。这是并行和增量构建的前提。

第三步:核心驱动 - 基于内容哈希的增量构建

这部分是整个系统的灵魂。传统的基于时间戳的增量构建在分布式环境和版本控制系统中表现不佳。我们将实现一个基于内容哈希的缓存系统。

核心逻辑

  1. 对于每个构建任务,定义其所有“输入”:源文件、配置文件、依赖模块的构建产物、编译器版本等。
  2. 将所有输入的哈希值组合成一个唯一的“输入哈希”。
  3. 在缓存目录中,以这个“输入哈希”作为键,存储构建任务的产物(如 .jar 文件、.so 文件)和构建日志。
  4. 在执行任务前,计算其“输入哈希”。如果缓存中已存在该哈希,则直接从缓存中恢复产物,跳过实际的编译过程。

我们将使用 blake3 crate,因为它速度极快。

// src/cache.rs

use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

// 计算单个文件的 BLAKE3 哈希
fn hash_file(path: &Path) -> Result<blake3::Hash> {
    let mut file = fs::File::open(path)?;
    let mut hasher = blake3::Hasher::new();
    std::io::copy(&mut file, &mut hasher)?;
    Ok(hasher.finalize())
}

// 计算目录中所有文件的哈希,并组合成一个总哈希
pub fn hash_directory_contents(path: &Path) -> Result<blake3::Hash> {
    let mut dir_hasher = blake3::Hasher::new();
    let mut files = WalkDir::new(path)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
        .map(|e| e.path().to_path_buf())
        .collect::<Vec<_>>();
    
    // 排序以保证哈希的确定性
    files.sort();

    for file_path in files {
        let file_hash = hash_file(&file_path)?;
        dir_hasher.update(file_hash.as_bytes());
    }

    Ok(dir_hasher.finalize())
}

// 定义一个任务的完整输入指纹
pub struct TaskInputs {
    // 任务自身的唯一标识符,如 "native-core:build"
    pub task_id: String, 
    // 源文件目录的哈希
    pub sources_hash: blake3::Hash,
    // 依赖项的输出哈希列表,顺序必须固定
    pub dependency_hashes: Vec<blake3::Hash>,
}

impl TaskInputs {
    // 计算最终的输入哈希
    pub fn calculate_final_hash(&self) -> blake3::Hash {
        let mut hasher = blake3::Hasher::new();
        hasher.update(self.task_id.as_bytes());
        hasher.update(self.sources_hash.as_bytes());
        for dep_hash in &self.dependency_hashes {
            hasher.update(dep_hash.as_bytes());
        }
        hasher.finalize()
    }
}

TaskInputs 结构是关键。它确保了任何可能影响构建结果的因素都被纳入了哈希计算。一个常见的错误是忘记包含依赖项的哈希,这会导致上游模块更新后,下游模块没有被正确地重新构建。

第四步:任务执行与工具链集成

现在我们需要实际调用 rustckotlinc。Rust 的 std::process::Command 非常适合这个任务。我们需要一个健壮的执行器,能够捕获输出、处理错误,并与我们的日志系统集成。

// src/runner.rs

use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use tracing::{error, info, instrument};

#[instrument(skip_all, fields(command = %format!("{:?}", command.get_program())))]
pub fn run_command(mut command: Command) -> Result<()> {
    info!("Executing command: {:?}", command);

    let status = command
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .status()
        .with_context(|| format!("Failed to execute command: {:?}", command.get_program()))?;

    if !status.success() {
        // 在真实项目中,这里会更详细地捕获和记录 stdout/stderr
        error!("Command failed with status: {}", status);
        return Err(anyhow::anyhow!("Command execution failed for {:?}", command.get_program()));
    }

    info!("Command executed successfully.");
    Ok(())
}

// 示例:构建一个 Rust 模块
pub fn build_rust_library(module_path: &Path) -> Result<()> {
    let mut command = Command::new("cargo");
    command.arg("build")
           .arg("--release")
           .current_dir(module_path);
    run_command(command)
}

// 示例:构建一个 Kotlin JVM 应用
pub fn build_kotlin_jvm(
    module_path: &Path,
    source_paths: &[PathBuf],
    classpath: &str,
    output_jar: &Path,
) -> Result<()> {
    let mut command = Command::new("kotlinc");
    command.arg("-d").arg(output_jar)
           .arg("-cp").arg(classpath);

    for src in source_paths {
        command.arg(src);
    }
    command.current_dir(module_path);
    run_command(command)
}

tracing crate在这里非常有用,它提供了结构化的日志,#[instrument]宏可以自动为函数调用添加上下文信息,这在调试复杂的并行构建时至关重要。

第五步:粘合一切 - 主构建循环与跨语言链接

现在我们把所有部分组合起来。主构建循环会遍历拓扑排序后的模块列表,为每个模块计算输入哈希,检查缓存,然后决定是执行构建还是从缓存恢复。

最棘手的部分是处理 shared-logic (Kotlin) 对 native-core (Rust) 的依赖。

  1. Rust 侧:我们需要在 native-coreCargo.toml 中配置 crate-type = ["cdylib"] 以生成动态链接库。Rust 代码需要使用 JNI crate 并暴露 #[no_mangle] 的 C 风格函数。

    // libs/native-core/src/lib.rs
    
    use jni::JNIEnv;
    use jni::objects::{JClass, JString};
    use jni::sys::jstring;
    
    #[no_mangle]
    pub extern "system" fn Java_com_example_shared_NativeBridge_processData<'local>(
        mut env: JNIEnv<'local>,
        _class: JClass<'local>,
        input: JString<'local>,
    ) -> jstring {
        let input_str: String = env.get_string(&input).expect("Couldn't get java string!").into();
        let output_str = format!("Hello from Rust: {}", input_str);
        let output = env.new_string(output_str).expect("Couldn't create java string!");
        output.into_raw()
    }
  2. Kotlin 侧:需要一个 external 函数来声明这个 JNI 调用。

    // libs/shared-logic/src/commonMain/kotlin/NativeBridge.kt
    
    package com.example.shared
    
    object NativeBridge {
        external fun processData(input: String): String
    
        init {
            // 在实际项目中,加载逻辑会更复杂
            // System.loadLibrary("native_core")
        }
    }
  3. 构建工具侧:我们的构建协调器必须理解这种依赖关系。

    • 当构建 native-core 时,它会产生一个 libnative_core.so (或 .dll/.dylib)。这个文件的哈希就是该模块的“输出哈希”。
    • 当构建 shared-logic 时,它会将 native-core 的输出哈希作为其输入哈希的一部分。
    • 更重要的是,在运行 user-service(最终的应用)时,构建工具需要确保 JVM 启动时,java.library.path 系统属性指向了包含 libnative_core.so 的目录。

下面是构建循环的简化实现,展示了这一流程:

```rust
// src/main.rs (simplified)

use std::collections::HashMap;
use anyhow::Result;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;

// … other imports

fn main() -> Result<()> {
// 设置日志
let subscriber = FmtSubscriber::builder().with_max_level(Level::INFO).finish();
tracing::subscriber::set_global_default(subscriber)?;

let config = config::ProjectConfig::from_path("polyglot.toml")?;
let build_graph = graph::BuildGraph::new(&config)?;
let build_order = build_graph.build_order();

// 存储每个模块构建产物的哈希,供下游模块使用
let mut built_artifacts_hashes: HashMap<String, blake3::Hash> = HashMap::new();

for module in build_order {
    info!("Processing module: {}", module.name);

    // 1. 收集依赖项的输出哈希
    let dependency_hashes = module.dependencies.iter()
        .map(|dep_name| *built_artifacts_hashes.get(dep_name).unwrap())
        .collect::<Vec<_>>();

    // 2. 计算源文件哈希
    let sources_hash = cache::hash_directory_contents(&module.path)?;

    // 3. 计算最终输入哈希
    let inputs = cache::TaskInputs {
        task_id: format!("{}:build", module.name),
        sources_hash,
        dependency_hashes,
    };
    let final_hash = inputs.calculate_final_hash();
    info!("Module '{}' input hash: {}", module.name, final_hash);
    
    // 4. TODO: 检查缓存。如果命中,则跳过并从缓存恢复 artifact hash。
    // if cache.exists(&final_hash) { ... }
    
    // 5. 执行构建 (此处为简化逻辑)
    match module.module_type {
        config::ModuleType::RustLibrary => {
            runner::build_rust_library(&module.path)?;
            // 实际项目中,我们会定位构建产物并计算其哈希
            let artifact_hash = blake3::hash(b"fake_rust_artifact_hash");
            built_artifacts_hashes.insert(module.name.clone(), artifact_hash);
        },
        config::ModuleType::KotlinJvmApp => {
            // 实际项目中,需要构建 classpath 字符串
            let classpath = "."; 
            let output_jar = config.workspace.build_dir.join(format!("{}.jar", module.name));
            std::fs::create_dir_all(output_jar.parent().unwrap())?;
            let sources = vec

  目录