概述
我们的第三个项目将文件I/O提升到一个新水平:构建一个默认安全、发出清晰诊断信息并能自我清理的小型、健壮的文件复制器。我们将第4章的defer/errdefer模式与现实世界的错误处理连接起来,同时展示标准库的原子复制助手;参见04和Dir.zig。
两种方法说明了权衡:
- 高级:对
std.fs.Dir.copyFile的单个调用执行原子复制并保留文件模式。 - 手动流式传输:使用
defer和errdefer打开、读取和写入,如果任何操作失败则删除部分输出,如#defer和errdefer和File.zig中所述。
学习目标
- 设计一个拒绝覆盖现有文件的CLI,除非明确强制,如#命令行标志中所述。
- 使用
defer/errdefer保证资源清理并在失败时删除部分文件。 - 在
Dir.copyFile的原子便利性和手动流式传输的细粒度控制之间进行选择。
正确性优先:默认安全的CLI
覆盖用户数据是不可原谅的。此工具采取保守立场:除非提供--force,否则现有目标会中止复制。我们还验证源是常规文件,并在成功时保持stdout静默,以便脚本可以将"无输出"视为良好信号,如#错误处理中所述。
在现有目标上中止
我们首先探测目标路径。如果存在且缺少--force,我们打印单行诊断信息并以非零状态退出。这反映了常见的Unix实用程序并使失败明确无误。
单次调用的原子复制
尽可能利用标准库。Dir.copyFile使用临时文件并将其重命名到位,这意味着调用者永远不会观察到部分写入的目标,即使进程在复制过程中崩溃。文件模式默认保留;时间戳由updateFile处理,如果你需要它们,我们在下面提到。
const std = @import("std");
// 第7章 - 安全文件复制器(通过 std.fs.Dir.copyFile 实现原子操作)
//
// 一个极简的命令行工具,默认安全,拒绝覆盖已存在的目标文件,
// 除非提供 --force 参数。使用 std.fs.Dir.copyFile 实现,
// 该函数先写入临时文件,然后原子性地重命名到目标位置。
//
// 用法:
// zig run safe_copy.zig -- <源文件> <目标文件>
// zig run safe_copy.zig -- --force <源文件> <目标文件>
const Cli = struct {
force: bool = false,
src: []const u8 = &[_]u8{},
dst: []const u8 = &[_]u8{},
};
fn printUsage() void {
std.debug.print("usage: safe-copy [--force] <source> <dest>\n", .{});
}
fn parseArgs(allocator: std.mem.Allocator) !Cli {
var cli: Cli = .{};
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
printUsage();
std.process.exit(0);
}
var i: usize = 1;
while (i < args.len and std.mem.startsWith(u8, args[i], "--")) : (i += 1) {
const flag = args[i];
if (std.mem.eql(u8, flag, "--force")) {
cli.force = true;
} else if (std.mem.eql(u8, flag, "--help")) {
printUsage();
std.process.exit(0);
} else {
std.debug.print("error: unknown flag '{s}'\n", .{flag});
printUsage();
std.process.exit(2);
}
}
const remaining = args.len - i;
if (remaining != 2) {
std.debug.print("error: expected <source> and <dest>\n", .{});
printUsage();
std.process.exit(2);
}
// 复制路径,确保在释放参数后仍保持有效
cli.src = try allocator.dupe(u8, args[i]);
cli.dst = try allocator.dupe(u8, args[i + 1]);
return cli;
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const cli = try parseArgs(allocator);
const cwd = std.fs.cwd();
// 验证源文件存在且为常规文件
var src_file = cwd.openFile(cli.src, .{ .mode = .read_only }) catch {
std.debug.print("error: unable to open source '{s}'\n", .{cli.src});
std.process.exit(1);
};
defer src_file.close();
const st = try src_file.stat();
if (st.kind != .file) {
std.debug.print("error: source is not a regular file\n", .{});
std.process.exit(1);
}
// 遵循"默认安全"理念:除非使用 --force,否则拒绝覆盖
const dest_exists = blk: {
_ = cwd.statFile(cli.dst) catch |err| switch (err) {
error.FileNotFound => break :blk false,
else => |e| return e,
};
break :blk true;
};
if (dest_exists and !cli.force) {
std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
std.process.exit(2);
}
// 执行原子性复制,默认保留文件权限。成功时不输出任何内容,
// 以保持管道安静并便于脚本化使用。
cwd.copyFile(cli.src, cwd, cli.dst, .{ .override_mode = null }) catch |err| {
std.debug.print("error: copy failed ({s})\n", .{@errorName(err)});
std.process.exit(1);
};
}
$ printf 'hello, copier!\n' > from.txt
$ zig run safe_copy.zig -- from.txt to.txt(无输出)copyFile覆盖现有文件。我们的包装器首先检查存在性,并要求--force才能覆盖。如果你还想保留atime/mtime,请优先使用Dir.updateFile。
有意的覆盖
当输出已存在时,演示显式覆盖:
$ printf 'v1\n' > from.txt
$ printf 'old\n' > to.txt
$ zig run safe_copy.zig -- from.txt to.txt
error: destination exists; pass --force to overwrite
$ zig run safe_copy.zig -- --force from.txt to.txterror: destination exists; pass --force to overwrite
(无输出)成功保持静默是设计使然;与echo $?结合使用以在脚本中使用状态码。
使用defer/errdefer的手动流式传输
对于细粒度控制(或作为学习练习),将Reader连接到Writer并自行流式传输字节。关键部分是errdefer,如果在创建后出现任何问题,则删除目标——这可以防止留下截断的文件。
const std = @import("std");
// 第7章 - 安全文件复制器(使用errdefer清理的手动流式传输)
//
// 演示使用defer/errdefer安全地打开、读取、写入和清理。
// 如果在创建目标文件后复制失败,我们会删除
// 部分文件,以便调用者永远不会观察到截断的产物。
//
// 用法:
// zig run copy_stream.zig -- <src> <dst>
// zig run copy_stream.zig -- --force <src> <dst>
const Cli = struct {
force: bool = false,
src: []const u8 = &[_]u8{},
dst: []const u8 = &[_]u8{},
};
fn printUsage() void {
std.debug.print("usage: copy-stream [--force] <source> <dest>\n", .{});
}
fn parseArgs(allocator: std.mem.Allocator) !Cli {
var cli: Cli = .{};
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
printUsage();
std.process.exit(0);
}
var i: usize = 1;
while (i < args.len and std.mem.startsWith(u8, args[i], "--")) : (i += 1) {
const flag = args[i];
if (std.mem.eql(u8, flag, "--force")) {
cli.force = true;
} else if (std.mem.eql(u8, flag, "--help")) {
printUsage();
std.process.exit(0);
} else {
std.debug.print("error: unknown flag '{s}'\n", .{flag});
printUsage();
std.process.exit(2);
}
}
const remaining = args.len - i;
if (remaining != 2) {
std.debug.print("error: expected <source> and <dest>\n", .{});
printUsage();
std.process.exit(2);
}
// 复制路径以便在释放args后保持有效
cli.src = try allocator.dupe(u8, args[i]);
cli.dst = try allocator.dupe(u8, args[i + 1]);
return cli;
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const cli = try parseArgs(allocator);
const cwd = std.fs.cwd();
// 打开源文件并检查其元数据
var src = cwd.openFile(cli.src, .{ .mode = .read_only }) catch {
std.debug.print("error: unable to open source '{s}'\n", .{cli.src});
std.process.exit(1);
};
defer src.close();
const st = try src.stat();
if (st.kind != .file) {
std.debug.print("error: source is not a regular file\n", .{});
std.process.exit(1);
}
// 默认安全:拒绝覆盖,除非使用--force
if (!cli.force) {
const dest_exists = blk: {
_ = cwd.statFile(cli.dst) catch |err| switch (err) {
error.FileNotFound => break :blk false,
else => |e| return e,
};
break :blk true;
};
if (dest_exists) {
std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
std.process.exit(2);
}
}
// 在不强制覆盖时以独占模式创建目标文件
var dest = cwd.createFile(cli.dst, .{
.read = false,
.truncate = cli.force,
.exclusive = !cli.force,
.mode = st.mode,
}) catch |err| switch (err) {
error.PathAlreadyExists => {
std.debug.print("error: destination exists; pass --force to overwrite\n", .{});
std.process.exit(2);
},
else => |e| {
std.debug.print("error: cannot create destination ({s})\n", .{@errorName(e)});
std.process.exit(1);
},
};
// 确保关闭和清理顺序:先关闭,错误时再删除
defer dest.close();
errdefer cwd.deleteFile(cli.dst) catch {};
// 连接Reader/Writer对并使用Writer接口复制
var reader: std.fs.File.Reader = .initSize(src, &.{}, st.size);
var write_buf: [64 * 1024]u8 = undefined; // 缓冲写入
var writer = std.fs.File.writer(dest, &write_buf);
_ = writer.interface.sendFileAll(&reader, .unlimited) catch |err| switch (err) {
error.ReadFailed => return reader.err.?,
error.WriteFailed => return writer.err.?,
};
// 刷新缓冲字节并设置最终文件长度
try writer.end();
}
$ printf 'stream me\n' > src.txt
$ zig run copy_stream.zig -- src.txt dst.txt(无输出)当使用.exclusive = true创建目标时,如果文件已存在,则打开失败。这加上errdefer deleteFile,在典型的单进程场景中提供了强大的安全保证而不会出现竞争条件。
注意事项
- 原子语义:
Dir.copyFile创建一个临时文件并将其重命名到位,避免其他进程读取部分内容。在较旧的Linux内核上,断电可能会留下临时文件;有关详细信息,请参见函数的文档注释。 - 保留时间戳:当你需要atime/mtime与源匹配时,除了内容和模式外,优先使用
Dir.updateFile。 - 性能提示:
Writer接口在可用时使用平台加速(sendfile、copy_file_range或fcopyfile),回退到缓冲循环;参见posix.zig。 - CLI生命周期:在释放
args字符串之前复制它们,以避免悬空的[]u8切片(两个示例都使用allocator.dupe);参见process.zig。 - 健全性检查:首先打开源文件,然后
stat()它并要求kind == .file以拒绝目录和特殊文件。
练习
- 添加一个
--no-clobber标志,即使--force也存在时也强制报错——然后发出有用的消息建议删除哪一个。 - 通过切换到
Dir.updateFile并验证时间戳是否匹配来实现--preserve-times。 - 教工具使用
CopyFileOptions.override_mode从数字模式覆盖(例如--mode=0644)复制文件权限。
替代方案与边缘情况:
- 在这些示例中故意拒绝复制特殊文件(目录、fifo、设备);显式处理它们或跳过。
- 跨文件系统移动:当设备不同时,复制加上
deleteFile比rename更安全;Zig的助手在给定内容复制时会做正确的事情。 - 非常大的文件:优先使用高级复制;如果不使用
Writer接口,手动循环应分块读取并仔细处理短写入。