第1章:综合实战——实现一个完整的文件备份工具

说实话,做C语言开发这么多年,我遇到过不少让人头疼的场景。有一次,客户的生产服务器突然宕机,数据丢了整整三天。原因是什么?备份脚本写得不够健壮,压缩和加密的流程出了岔子。从那以后,我就养成了一个习惯:备份工具必须自己写,而且要支持增量备份、压缩、加密、日志

这一章,我们就来动手实现一个完整的文件备份工具。别小看它,麻雀虽小五脏俱全。你想想看,一个生产级的备份工具,核心就四个功能:

  • 增量备份:只备份变化的部分,节省时间和空间
  • 压缩:减少存储占用
  • 加密:保证数据安全
  • 日志:记录每一步操作,方便排查问题

我个人习惯把这类工具拆成模块来写。每个模块独立测试,最后再拼起来。这样出了问题,定位也快。

1.1 整体架构设计

我们先画一张图,看看这个工具的整体流程。嗯,这里要注意,架构设计决定了代码的扩展性。我见过太多人一上来就写main函数,写到一半发现逻辑乱成一团。

文件备份工具架构图 主控模块 增量备份模块 压缩模块 加密模块 日志模块 数据流:源文件 → 增量对比 → 压缩 → 加密 → 目标文件 日志流:每个模块操作 → 日志模块 → 日志文件 每个模块独立编译,通过接口函数与主控通信

从图上可以看得很清楚:主控模块负责调度,四个子模块各司其职。数据从源文件流入,经过增量对比、压缩、加密,最后写入目标文件。每一步的操作都会被日志模块记录下来。

1.2 增量备份的核心逻辑

增量备份说白了就是:只备份那些发生变化的文件。怎么判断文件变了?我常用的方法是记录文件的最后修改时间(mtime)和文件大小。

我曾经踩过一个坑:直接用mtime做判断,结果系统时间被修改了,导致所有文件都被重新备份了一遍。后来我加上了文件内容的哈希校验,双重保险。

增量备份的判断依据:

  • 文件不存在于备份目录 → 全量备份
  • 文件存在,但mtime或大小不同 → 增量备份
  • 文件存在,mtime和大小都相同 → 跳过

代码实现上,我们需要一个元数据文件来记录每个文件的备份状态。这个文件可以用简单的文本格式,也可以用二进制。我个人偏好二进制,解析速度快,而且不容易被误修改。

// 元数据结构体
typedef struct {
    char filename[256];      // 文件名
    time_t last_mtime;       // 最后修改时间
    off_t file_size;         // 文件大小
    unsigned char hash[16];  // MD5哈希(可选)
} FileMeta;

// 判断是否需要备份
int need_backup(const char *filepath, FileMeta *meta) {
    struct stat st;
    if (stat(filepath, &st) != 0) {
        return -1; // 文件不存在
    }
    
    // 如果元数据为空,说明是首次备份
    if (meta->last_mtime == 0) {
        return 1;
    }
    
    // 比较mtime和大小
    if (st.st_mtime != meta->last_mtime ||
        st.st_size != meta->file_size) {
        return 1; // 发生了变化
    }
    
    return 0; // 没有变化
}

小技巧:如果你要备份的文件数量很大(比如几十万个),建议用哈希表或者B树来存储元数据。线性查找会慢到你怀疑人生。我之前用线性查找处理10万个文件,花了将近3分钟。换成哈希表后,秒级完成。

1.3 压缩与加密的实现

压缩和加密这两个功能,我建议用现成的库。C语言标准库没有直接提供这些功能,但我们可以借助zlib和OpenSSL。

功能 推荐库 核心函数
压缩 zlib compress() / uncompress()
加密 OpenSSL AES_encrypt() / AES_decrypt()

压缩的流程很简单:读取源文件数据,调用compress()压缩,写入目标文件。加密则是在压缩之后进行,先压缩再加密,这样效率更高。

注意:加密密钥的管理是个大问题。千万不要把密钥硬编码在代码里。我建议从环境变量读取,或者使用密钥文件,并设置严格的权限(比如600)。我曾经见过有人把密钥写在代码里,结果代码上传到GitHub,整个公司的数据都暴露了。

// 压缩并加密一个文件
int compress_and_encrypt(const char *src, const char *dst, 
                         const unsigned char *key) {
    // 1. 读取源文件
    FILE *fp_src = fopen(src, "rb");
    if (!fp_src) return -1;
    
    // 获取文件大小
    fseek(fp_src, 0, SEEK_END);
    long src_size = ftell(fp_src);
    rewind(fp_src);
    
    // 2. 分配缓冲区
    unsigned char *src_data = malloc(src_size);
    unsigned char *compressed = malloc(src_size + 64); // 压缩后可能略大
    fread(src_data, 1, src_size, fp_src);
    fclose(fp_src);
    
    // 3. 压缩
    uLong compressed_size = src_size + 64;
    compress(compressed, &compressed_size, src_data, src_size);
    
    // 4. 加密(使用AES-256-CBC)
    unsigned char *encrypted = malloc(compressed_size + 16);
    int encrypted_size = aes_encrypt(compressed, compressed_size, 
                                     encrypted, key);
    
    // 5. 写入目标文件
    FILE *fp_dst = fopen(dst, "wb");
    fwrite(encrypted, 1, encrypted_size, fp_dst);
    fclose(fp_dst);
    
    // 6. 清理
    free(src_data);
    free(compressed);
    free(encrypted);
    
    return 0;
}

1.4 日志系统的设计

日志这东西,平时觉得没用,出问题的时候才知道它的好。我设计的日志系统包含三个级别:

  • INFO:正常操作记录,比如「文件xxx备份成功」
  • WARN:警告信息,比如「文件xxx跳过备份」
  • ERROR:错误信息,比如「文件xxx打开失败」

日志格式我建议统一为:[时间] [级别] [模块] 消息。这样用grep过滤起来特别方便。

void log_message(const char *level, const char *module, 
                 const char *format, ...) {
    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);
    
    char time_str[20];
    strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", tm_info);
    
    // 打开日志文件(追加模式)
    FILE *log_fp = fopen("backup.log", "a");
    if (!log_fp) return;
    
    // 格式化消息
    fprintf(log_fp, "[%s] [%s] [%s] ", time_str, level, module);
    
    va_list args;
    va_start(args, format);
    vfprintf(log_fp, format, args);
    va_end(args);
    
    fprintf(log_fp, "\n");
    fclose(log_fp);
}

建议:日志文件要定期轮转。我一般设置单个日志文件不超过10MB,超过就自动压缩归档。不然日志文件越来越大,最后磁盘满了,备份工具自己先挂了,那就尴尬了。

1.5 主控流程整合

好了,四个模块都准备好了。现在把它们串起来。主控流程大概是这样的:

  1. 读取配置文件,获取源目录、目标目录、密钥等信息
  2. 加载元数据文件,建立文件状态索引
  3. 遍历源目录,对每个文件调用need_backup()判断
  4. 如果需要备份,执行压缩+加密,写入目标目录
  5. 更新元数据文件
  6. 记录日志

嗯,这里要注意一点:遍历目录时要递归处理子目录。我习惯用nftw()函数,它比自己写递归要稳定得多。

int main(int argc, char *argv[]) {
    // 初始化日志
    log_message("INFO", "MAIN", "备份工具启动");
    
    // 加载配置
    Config cfg;
    if (load_config("backup.conf", &cfg) != 0) {
        log_message("ERROR", "MAIN", "加载配置文件失败");
        return 1;
    }
    
    // 加载元数据
    MetaDB *db = meta_db_open("backup.meta");
    
    // 遍历源目录
    nftw(cfg.src_dir, backup_callback, 20, FTW_PHYS);
    
    // 保存元数据
    meta_db_save(db);
    meta_db_close(db);
    
    log_message("INFO", "MAIN", "备份完成");
    return 0;
}

说实话,这个工具写完之后,我用了好几年。每次遇到新的需求,比如支持远程备份、断点续传,我就在这个框架上扩展。模块化的好处就在这里——你不需要重写整个系统,只需要新增一个模块,然后改几行主控代码。

最后提醒一句:写完一定要测试。我建议准备一个测试目录,里面放各种奇奇怪怪的文件名(带空格、带中文、带特殊符号),看看你的工具能不能扛得住。我曾经就因为没处理文件名中的空格,导致备份脚本在线上跑崩了。从那以后,我对文件名处理格外小心。


公众号:蓝海资料掘金营,微信deep3321