做资源搬运这件事,我前后写过三版工具。第一版是一堆 shell 脚本加一个 CSV 对照表,第二版是 Python 写的,光启动就要等半秒,还得在每台机器上装环境,美术同事拿到手第一步永远是问“这个报错是什么”。第三版我换成了 Odin,编译出来一个几 MB 的二进制,双击就能用,规则写在文本配置文件里,改完规则点“预览”看差异,确认了再执行。标题里的“资源复制编辑器”,说的就是这第三版——它不是简单地 cp -r,而是把“哪些资源要复制、复制到哪、改成什么名字、要不要生成变体、清单怎么写”这些决策变成可编辑的数据,然后用 Odin 把它们执行掉。
先说清楚一件事:这里的 Odin 指那门系统编程语言,不是某个引擎检视面板里的插件,两者同名但完全没关系,我一开始搜资料也被绕进去过。这个工具解决的场景很具体:源资源目录里躺着大量原始素材,构建产物需要一个干净、命名规范、结构和源目录不一样的镜像目录,同时还要产出一份能被后续流程消费的清单。适合谁看?手头有大量素材需要按规则搬运和改名、又不想为这点活养一套运行时环境的人。下面我按“为什么选 Odin”“数据怎么设计”“扫描和增量怎么做”“复制阶段怎么扛住并发和半途失败”“预览怎么做”“踩了什么坑”的顺序讲,代码是主干片段,能直接照着搭。
1. 选 Odin 写这个工具:省掉运行时的那些事
1.1 从“脚本能跑就行”到“发个文件就完事”
我最早坚决站在脚本语言这边,理由很充分:改一行立刻能跑,不用编译,处理字符串和路径的库又全。真正让我换掉的是分发成本。这个工具的使用者不止我一个,还有负责打包的同事和偶尔需要重新生成中间产物的人。脚本方案意味着每台机器都要有对应的解释器版本,依赖还要装,遇到环境不一样就出五花八门的问题,而这些问题跟工具本身的功能毫无关系,纯粹是环境噪音。你花在解释环境上的时间,很快就超过写工具本身的时间。
Odin 在这里的价值非常朴素:它是一个产出静态单文件二进制的编译型语言,标准库自带文件系统、路径、字符串、线程、哈希这些我正好要用的东西,没有包管理器的运行时依赖,编译出来的东西拷到另一台同架构机器上直接跑。工具启动时间是毫秒级,批量处理几万个文件的时候,这个差别会体现得很明显——不是单次慢多少,而是你会在开发过程中反复运行它几十上百次,每次省半秒,一天下来手感完全不一样。
还有一个更隐蔽的好处:编译期检查。规则匹配、路径拼接、错误处理这些逻辑,一旦涉及分支多了,脚本语言里那种“跑到了才发现写错字段名”的体验很难受。Odin 的强类型和显式错误返回会逼你在写的时候就把“文件不存在怎么办”“目标目录创建失败怎么办”想清楚,工具上线之后的意外会少很多。
1.2 Odin 里真正帮上忙的几个语言点
真正动手之后,我发现有几个语言特性恰好卡在这个任务的痛点上,值得单独说。
切片和显式分配器。Odin 没有隐藏的内存分配,字符串拼接、切片扩容都要走context.allocator。这听起来像负担,但对批处理工具反而是好事:我可以给“每个文件的处理过程”配一个临时分配器,处理完一次性释放,几万个文件跑下来内存曲线是平的,不会出现那种跑一晚上内存涨到几个 G 的情况。这在脚本语言里要靠手动控制生命周期,很难做干净。
defer的确定性。文件句柄、临时文件清理,用defer挂在函数出口,出错提前返回也不会漏。批量复制最怕的就是半路失败留下一堆句柄和半截文件,defer把这个风险压到很低。
结构体 + 切片的组合方式很直白。我不需要为了“优雅”去设计复杂的类层次,规则就是结构体,资源条目就是结构体,计划就是切片。数据长什么样,代码就长什么样,三个月后回来看还能秒懂。
并发模型足够用。这个任务的并发需求很单纯:一堆互不依赖的文件复制任务,分给若干 worker 跑完就行。Odin 的core:thread提供线程创建和 join,配一个自己切分任务段的策略,几十行就能搞定,不需要引入任务队列框架。
编译速度。Odin 的编译很快,改一行到跑起来大概一两秒,这个反馈循环对写工具来说足够舒服,不至于让我想念解释器。
1.3 代价:你要接受的东西
不能只讲好处。换到 Odin 之后有几个明确的成本,提前知道能少走弯路。
第一个是标准库的 API 在不同版本之间有过调整,尤其是文件读写和路径处理。我从网上抄的示例代码有一半编译不过,原因是签名变了。解决办法很土但有效:直接去本地工具链的core目录里看源码,那是最准的文档。后面我给出的代码片段,如果你编译报错,第一反应应该是去core里核对一眼签名,而不是怀疑逻辑。
第二个是没有现成的配置解析库。Odin 标准库里没有 YAML/TOML 解析器,JSON 有core:encoding/json但写配置文件用手写 JSON 太反人类(不能写注释,逗号还容易漏)。我的选择是自己写一个极简的行式配置解析,格式大概是这样:
# 规则:源目录 => 目标目录 [rule] from = assets_src/characters to = build/characters ext = .png,.tga rename = {name}_{variant}解析逻辑两百行以内,够用,出错了报行号,比引入一个大依赖更符合“小工具”的定位。这种“自己写一个刚好够用的东西”的决策,在整个工具里出现了好几次,后面还会提到。
第三个是调试体验不如脚本。没有 REPL,看变量得靠打印。我在这上面花的适应时间大概两三天,之后就习惯了——反正批处理工具的调试模式基本就是“打印每个决策”,跟语言关系不大。
2. 先把“复制”这件事拆成可编辑的数据
2.1 三条独立职责:选资源、定目标、写清单
我第一版工具失败的根本原因,是把三件事揉在一个函数里:遍历源目录、判断要不要复制、拼目标路径、写文件、顺便生成清单。功能能跑,但规则一变就得改代码。所以第二版我做的第一件事不是写代码,是拆职责。
拆完之后是三条线。选择:给定源目录树,按规则挑出哪些文件进入计划。映射:对每个被选中的文件,算出目标路径和目标文件名。执行:把映射结果落盘,并同时产出清单。这三条线之间的接口就是数据结构,只要结构定下来,改规则就是改数据,不动代码。
这个拆法还有一个额外好处:预览功能几乎是免费的。因为“算出计划”和“执行计划”已经分离了,预览就是把计划打印出来而不执行。如果你把这两步揉在一起,预览就得靠一堆if dry_run分支,很快就会失控。
2.2 Asset / Rule / Plan 三张表的结构
具体的数据结构,我用的三个核心类型:
Asset :: struct { src_path: string, // 源文件绝对路径 rel_path: string, // 相对源根目录的路径,用于匹配 ext: string, // 小写扩展名,含点 size: i64, mtime: i64, } Rule :: struct { from_dir: string, // 源目录前缀,空表示任意 exts: []string, // 允许的扩展名,空表示全部 to_dir: string, // 目标目录 rename_pat: string, // 命名模板,空表示保持原名 priority: int, // 数字越大优先级越高 enabled: bool, } Task :: struct { asset: Asset, dst_path: string, action: Action, // .Copy / .Skip / .Update reason: string, // 为什么是这个动作,预览时显示 }几个设计上的取舍值得说明。
ext单独存一份小写副本,是因为源目录里.PNG和.png混着的情况太常见了,每次匹配都转一遍很浪费,而且容易在某个分支忘了转。存一份预处理结果,匹配逻辑就干净了。
rel_path而不是只有绝对路径,是因为规则匹配基本都基于相对路径(“某个目录下的所有贴图”)。绝对路径里带着机器相关的部分,拿来做前缀匹配会出各种诡异问题。
rename_pat用模板字符串而不是回调函数。我一开始设计成可以传一个函数指针,灵活性拉满,结果配置文件里根本没法表达,只能改代码。改成模板之后,{name}_{variant}这种写法美术同事自己就能改,这才是“编辑器”该有的样子。模板支持的内置变量我控制在五个以内:{name}原名(去扩展名)、{ext}扩展名、{dir}相对目录名、{variant}当前变体标识、{index}序号。变量一多,配置文件就变成编程语言了,那就失去了易用性。
action和reason是给预览用的。每个任务都带上“我打算干什么”和“为什么”,预览输出就直接读这两个字段,不需要在执行阶段重新推理一遍逻辑。这个决定的收益在后面讲预览的时候会体现得很明显。
2.3 规则匹配优先级:显式 > 目录 > 通配符
规则一多,冲突必然出现:一条说“所有 png 复制到 A”,另一条说“characters 下的复制到 B”,某个文件同时命中,听谁的?
我的方案是给每条规则一个priority,但真正落地的判断顺序不是简单比大小,而是先按特异性排序,同特异性再看优先级。特异性从高到低是:显式文件路径 > 具体目录前缀 > 通配符目录。原因是这样符合人的直觉——写得越具体,说明你越清楚自己要什么。
| 规则形式 | 特异性 | 典型用途 |
|---|---|---|
from = assets_src/ui/logo.png | 最高 | 单个特殊文件的定点处理 |
from = assets_src/characters | 中 | 按目录归类,最常用 |
from = assets_src/* | 低 | 兜底规则,处理零散文件 |
from =(空) | 最低 | 全局默认,比如“其他都不复制” |
还有一个容易被忽略的细节:兜底规则要显式存在。我踩过的坑是,某次新加了一个资源目录,没有规则命中,工具默认行为是“跳过”,结果打包出来缺了一堆贴图,但工具本身没报任何错。后来我改成:如果一个文件被扫描到但没有任何规则命中,就在预览里单独列一个“未命中”区块,用醒目的数量统计出来。宁可吵,也不能静默丢东西。
匹配的实现就是个双重循环,规则数量一般不超过几十条,性能完全不是瓶颈,不需要建索引。这里我特意没有做优化,因为可读性比那点性能重要得多——规则匹配逻辑如果写得绕,出问题的时候你根本没法定向。
3. 扫描与增量判断:别每次都全量重拷
3.1 目录遍历的写法和要提前定好的坑
Odin 的core:path/filepath提供了一个递归遍历函数,回调式设计,用法大致是这样:
import "core:os" import "core:path/filepath" collect :: proc(root: string, out: ^[dynamic]Asset) { ctx := Collect_Ctx{ root = root, out = out } filepath.walk(root, walk_proc, &ctx) } walk_proc :: proc(info: os.File_Info, in_err: os.Error, user_data: rawptr) -> (err: os.Error, skip_dir: bool) { ctx := cast(^Collect_Ctx)user_data if in_err != os.ERROR_NONE { return in_err, false } if info.is_dir { return os.ERROR_NONE, false } rel, ok := filepath.rel(ctx.root, info.fullpath) if !ok { return os.ERROR_NONE, false } append(ctx.out, Asset{ src_path = info.fullpath, rel_path = rel, ext = strings.to_lower(filepath.ext(rel)), size = info.size, mtime = info.mod_time, }) return os.ERROR_NONE, false }这里有两个必须提前想清楚的点。
一是错误处理策略。遍历过程中遇到无权限目录、被删掉的文件,回调会收到in_err。如果直接返回错误,整个遍历就中断了,剩下几万个文件一个都不处理。我的策略是:记录错误,返回os.ERROR_NONE继续走,最后统一汇总。批量工具的正确姿势是“能处理的都处理掉,处理不了的列出来”,而不是遇到第一个问题就撂挑子。
二是skip_dir的用法。这个返回值决定要不要跳过整个子目录。最常见的用途是排除版本控制目录和缓存目录。我在遍历时直接跳过.git、.cache这类名字,能在源头上省掉大量无用扫描——一个陈旧的项目里,.git下的文件数往往比真实资源多一个数量级,扫它纯属浪费时间。
顺带说一句info.is_dir,这个在不同版本的 Odin 里是字段还是方法有过变化,我现在的写法是当字段用,如果编译器报错就加个括号试试。这类小差异不影响逻辑,但会卡住新手很久,提前知道能省事。
3.2 用 size+mtime+FNV 三段式做变更判断
增量是这个工具能不能用得舒服的关键。全量复制几万个文件,就算用 SSD 也要几十秒,而且磁盘写入磨损没必要。判断一个目标文件是否需要更新,有三条路可走,我最终用了组合方案。
| 判断依据 | 速度 | 准确度 | 适用场景 |
|---|---|---|---|
| 只比文件大小 | 极快 | 低,同尺寸改动必漏 | 不推荐单独使用 |
| 比 size + mtime | 快,几乎零成本 | 中,能覆盖绝大多数真实改动 | 默认方案 |
| 比内容哈希 | 慢,要读全文件 | 高 | 强校验模式,手动开启 |
我的默认逻辑是:目标文件不存在 → 复制;存在但 size 不同 → 复制;size 相同但 mtime 比源旧 → 复制;都相同 → 跳过。这个组合在实践中漏判率极低,因为美术改图之后 mtime 一定变,而 mtime 不变但内容变的场景基本只出现在版本切换(比如从版本库里恢复旧文件,某些工具会保留 mtime)。
后者正是强校验模式的用武之地。我加了一个--verify参数,开启后对 size 和 mtime 都相同的文件再算一遍内容哈希,不匹配就覆盖。这个模式慢,但可以在关键节点手动跑一次,比如发版前。
哈希我选了 FNV-1a 64 位,自己写十行,不依赖任何库:
FNV_OFFSET :: 0xcbf29ce484222325 FNV_PRIME :: 0x100000001b3 hash_file :: proc(path: string, buf: []byte) -> (h: u64, total: i64, ok: bool) { f, open_ok := os.open(path) if !open_ok { return 0, 0, false } defer os.close(f) h = FNV_OFFSET for { n, err := os.read(f, buf) if n > 0 { for i in 0 ..< n { h ~= u64(buf[i]) h *= FNV_PRIME } total += i64(n) } if err != os.ERROR_NONE || n == 0 { break } } return h, total, true }为什么不用加密哈希?因为这个工具的哈希只用来判断“内容变没变”,不用于安全校验,FNV 的碰撞率在这个用途下完全够。选加密哈希会慢一个数量级,而收益为零——真要出问题,也是磁盘坏了而不是哈希碰撞。
buf从外面传进来,是为了整个批次复用同一块缓冲区。如果每个文件都分配一次,几万个文件的分配释放开销很可观,而且容易触发 GC 动作(虽然 Odin 默认没有 GC,但分配器本身的成本还在)。缓冲区大小我定在 256KB,这个值是我实测下来的折衷:太小系统调用次数多,太大对小文件浪费。
4. 复制阶段:并发、原子写、错误聚合
4.1 按核数切任务段而不是一文件一线程
复制任务的并发模型很简单:任务之间没有依赖,各干各的。但“简单”不等于“随便开线程”。我最初的想法是每个文件起一个线程,测下来在小文件多的场景直接崩掉——几千个线程的创建销毁开销比复制本身还大,而且句柄数会打满。
正确的做法是固定 worker 数量,把任务切片分段。worker 数我默认给 4,可以通过--jobs调整。为什么不自动探测核数?因为这活主要是 IO 密集,核数不是瓶颈,真正决定吞吐的是磁盘队列深度。我在机械盘上测过,4 线程和 16 线程的差异很小,甚至 16 线程偶尔更慢(寻道抖动)。默认 4 是个安全的起点,用户自己按磁盘类型调。
线程的骨架大致是:
Worker :: struct { tasks: []Task, from, to: int, copied: int, skipped: int, failed: int, errors: [dynamic]Copy_Error, buf: []byte, } worker_proc :: proc(w: ^Worker) { for i in w.from ..< w.to { t := &w.tasks[i] switch t.action { case .Skip: w.skipped += 1 case .Copy, .Update: if do_copy(t, w.buf) { w.copied += 1 } else { w.failed += 1 append(&w.errors, Copy_Error{ path = t.asset.src_path }) } } } }注意每个 worker 有自己的errors动态数组和buf。这是刻意的:共享一个动态数组需要加锁,而在高频追加的场景下锁竞争会吃掉并发的收益。分开收集、最后合并,代码更简单也更快。
启动和收尾:
workers := make([]Worker, jobs) per := (len(tasks) + jobs - 1) / jobs threads := make([]^thread.Thread, 0, jobs) for i in 0 ..< jobs { from := i * per to := min(from + per, len(tasks)) if from >= to { break } workers[i] = Worker{ tasks = tasks, from = from, to = to, buf = make([]byte, 256 * 1024), } t := thread.create(worker_proc) t.data = &workers[i] thread.start(t) append(&threads, t) } for t in threads { thread.join(t) thread.destroy(t) }这里有个细节值得提:from >= to的提前退出。当任务数少于 worker 数时,后面的分段是空的,起一个什么都不干的线程纯属浪费。加一行判断就能省掉,成本几乎为零。
4.2 先写 .tmp 再 rename 的原子落盘
复制过程中断(用户 Ctrl+C、磁盘满、断电)是必然会遇到的。如果直接往目标路径写,中断之后会留下一个半截文件,而它的 mtime 和 size 都可能“看起来正常”,下次增量判断就会误认为它是最新的,永久性损坏而且很难发现。这种问题最恶心的地方是它不报错,等你发现素材加载异常的时候已经过去很久了。
解决办法是标准套路:先写到同目录下的临时文件,写完整、刷盘、关闭,再原子改名到目标路径。同一文件系统内的 rename 是原子操作,要么旧文件,要么新文件,不存在“半个”。
do_copy :: proc(t: ^Task, buf: []byte) -> bool { tmp := strings.concatenate({t.dst_path, ".tmp"}, context.temp_allocator) defer delete(tmp, context.temp_allocator) if !copy_bytes(t.asset.src_path, tmp, buf) { os.remove(tmp) return false } // 目标已存在时,某些平台的 rename 会失败,先删 if os.exists(t.dst_path) { os.remove(t.dst_path) } if !os.rename(tmp, t.dst_path) { os.remove(tmp) return false } return true }copy_bytes内部用os.open打开源、os.create创建临时文件,循环读缓冲区写出去,中间任何一步失败都返回 false,由上层清理临时文件。这里我特意没有用一次性的“读整个文件再写”的写法,因为资源里偶尔有大文件(音频、视频),整读会瞬间吃掉几百 MB 内存,在 4 个 worker 并发的场景下很容易把机器压得没有响应。
缓冲区复用同一个buf,每个 worker 一块,互不干扰。256KB 在机械盘上是个比较甜的点,因为接近典型磁盘的预读块大小,顺序写的时候能吃到顺序带宽。
4.3 错误不中断,最后统一报告
前面提过遍历阶段的错误处理,复制阶段同理,而且更重要。我的原则是:单个文件失败绝不能中断整批任务,所有错误收集起来,最后按“源路径 + 失败原因”分类输出。
原因分类我控制在四类,多了反而没人看:
| 原因 | 典型场景 | 处理建议 |
|---|---|---|
| 源文件不可读 | 权限、文件被占用 | 检查权限或关闭占用程序 |
| 目标目录创建失败 | 磁盘满、路径过长 | 清理空间或缩短路径 |
| 写入中断 | 磁盘满、设备移除 | 清理后重跑,增量会补上 |
| 改名失败 | 目标被占用、跨设备 | 关闭占用,或检查挂载点 |
这个分类表不是我拍脑袋想的,是从实际报错里归纳出来的。有了它,同事看到报错就知道该干什么,而不是发一张截图过来问“这是什么意思”。
还有一个细节:失败的文件不写清单。清单必须是可信的,如果里面列了一个实际不存在的文件,下游流程会拿到一个幽灵引用,问题会推迟到更难排查的地方才爆发。
5. 干跑与差异报告:这个工具真正省事的地方
5.1 dry-run 输出的三栏对比
前面说过,因为计划和执行分离了,预览功能几乎是白送的。但“能打印”和“好用”是两回事。我第一版的预览就是把所有任务刷屏打出来,几万行滚过去,什么也看不出来。现在的输出分成三块。
第一块是汇总。四行数字:将复制 N 个、将更新 N 个、将跳过 N 个、未命中规则 N 个、预计写入 XX MB。这几个数字能让人一眼判断出这次操作是否合理。比如某次改动之后“将复制”突然从 200 跳到 20000,那八成是规则写错了,值得停下来看一眼。
第二块是变更清单,只列Copy和Update两种动作,格式是“源 → 目标”,后面括号里跟一句原因(“目标不存在” / “大小不同” / “源更新”)。跳过的不列,因为跳过是常态,列出来只会淹没真正重要的信息。
第三块是异常提示,包括未命中规则的文件、目标路径冲突(两个源文件映射到同一个目标)、目标目录在源目录内部(会导致复制自己,无限增长)。这三类问题在预览阶段暴露出来,比执行到一半出问题好太多。
目标路径冲突这个检查我特意单独抽出来,因为它是规则设计的经典错误:两条规则的模板拼出了同一个结果值。工具执行的时候就是一个覆盖另一个,谁后写谁赢,而且没有任何提示,你以为两个文件都在,实际只剩一个。加上检查之后,这个问题在预览里就是一行红字。
5.2 规则编辑器的循环:编辑、预览、执行
工具的日常使用循环很短:改配置文件、跑一次--dry-run、看输出确认、去掉参数真跑。我特意让配置文件里支持注释,因为规则里的“为什么这么写”比规则本身更重要,三个月后你只会记得“这里有个特殊处理”,但不会记得为什么。
配置文件的加载我做了两个小处理。一是出错时报“第几行第几个字符”,而不是只说“格式错误”,这个成本很低但体验差别巨大。二是加载完之后立刻做一遍自检:目标目录是否存在、源目录是否存在、模板里的变量名是否在支持列表里、两条规则的 from 是否完全重复。这些检查在启动后一秒钟内完成,能把大部分低级错误挡在执行之前。
模板变量的校验值得单独说。我最初没做这个校验,结果是模板里写错一个字母({nmae}),复制出来的文件名就是字面量{nmae}.png。因为整个流程不报错,你可能过一天才发现所有贴图名字都是错的,然后要重新跑一遍全量。加上校验之后,未知变量直接在加载阶段报错,代价是十几行代码。
6. 一路踩过来的几个坑
6.1 路径大小写与分隔符
这个问题在跨平台场景下必然遇到。源目录里同时存在Logo.PNG和logo.png的情况,在大小写敏感的平台上这是两个文件,在大小写不敏感的平台上是一个。我的处理策略是:匹配阶段全部转小写比较,映射阶段保留原始大小写。这样规则只需要写小写,行为在任何平台上都一致。
分隔符的问题更隐蔽。配置文件里手写路径,有人用/有人用\,如果直接拿来做字符串前缀匹配,assets_src/characters和assets_src\characters匹配不上,规则就像没生效一样。我的做法是加载配置时统一把所有分隔符规范成/,遍历得到的相对路径也规范成/,只在最后生成真实路径时转回平台格式。这个规范化只在一个地方做(加载入口),避免了各处零散处理。
还有末尾斜杠。assets_src/characters和assets_src/characters/做前缀匹配时结果不同,前者会把assets_src/characters2/xxx也匹配进去。这个 bug 我第一次遇到时排查了很久,因为看起来完全正常。修复很简单:规范化时统一去掉末尾斜杠再比较,匹配时额外校验下一个字符是分隔符或者字符串结束。加一段判断,十几行,但能挡掉一类很烦的问题。
6.2 临时分配器越界使用
Odin 的context.temp_allocator很方便,但它有一个必须记住的前提:它只在下一个临时作用域边界之前有效。我踩过的坑是把用临时分配器拼出来的路径存进了任务结构体,然后在几十行之后(跨越了作用域边界)才用它,结果拿到的是被复用的内存,路径变成了乱码或者别的文件名的片段。
这个问题的可怕之处在于它不是每次都崩,有时候能正常跑,取决于内存里恰好留下了什么。表现是“偶尔有几个文件复制到了奇怪的位置”。定位方法很原始:把所有路径的来源改成固定分配器,问题消失,就确认是生命周期问题。
结论很明确:跨作用域存活的数据一律用固定分配器,临时分配器只用于函数内用完即弃的中间值。我在代码里给自己定了条规矩——凡是写进结构体字段的字符串,都不用临时分配器;凡是只在当前函数里活着的拼接结果,才用临时分配器并及时delete。
6.3 符号链接与循环目录
源目录里如果有指向父目录的符号链接,朴素递归会无限下钻,最终撑爆路径长度或者内存。我在早期版本里没有处理这个,跑一次之后工具卡死,还得手动杀掉。
现在我的策略是:默认不跟随符号链接,遇到就当作普通文件跳过并在汇总里计数。理由是这个工具处理的是资源目录,符号链接基本只出现在特殊部署场景,跟随时行为不可预测,不跟随反而更安全。真要跟随,加个--follow-links开关显式打开,并且在遍历时维护一个已访问真实路径的集合,防止循环。这个集合用哈希表存,成本很低。
路径过长是另一个同源问题。深层嵌套加上长文件名,很容易超过某些平台的传统限制。我的处理是把整个路径长度纳入校验,超限的目标路径在预览阶段就报出来,而不是等到写入时失败。这样你能提前看到是哪个目录结构太深,去调整规则里的目标路径,而不是执行到一半收到一堆失败。
6.4 大文件复制的缓冲区选择
缓冲区大小这个参数看起来无关紧要,实际影响不小,而且最优点跟硬件关系很大。我在三种环境下测过同一批资源(大约 3 万个文件,总大小 6 GB,其中包含若干 200 MB 以上的音频)。
| 缓冲区 | 机械盘耗时 | SATA SSD 耗时 | NVMe 耗时 |
|---|---|---|---|
| 64 KB | 显著偏慢 | 中等 | 偏慢 |
| 256 KB | 最快 | 快 | 接近最快 |
| 1 MB | 接近最快 | 快 | 最快 |
| 4 MB | 略慢 | 略慢 | 快 |
数据说明两件事。一是 64KB 明显不够,系统调用次数太多。二是 256KB 是个很稳的默认值,在所有环境都不差。1MB 以上在 NVMe 上有收益,但在机械盘和 SATA 上开始走下坡,原因是太小的文件被浪费、缓存命中变差。
所以我最终的实现是:缓冲区默认 256KB,提供--buf参数可以调。这个参数不是所有人都用得上,但有总比没有好,遇到特殊环境能救急。另外我给小文件做了一点优化——小于 64KB 的文件直接整读整写,跳过循环,省掉几轮系统调用的开销。这个优化的收益在贴图目录(大量几十 KB 的文件)上相当明显,整体耗时能降一截。
7. 后续还能加什么,以及我会怎么加
工具做到现在这个程度,日常已经够用了。但如果继续往下做,我心里有几个明确的方向,按性价比排一下。
第一是并行扫描。目前扫描是单线程的,在几万个文件的目录上大概要一两秒,占比不算大但也不小。改成多线程遍历的价值有限,因为目录遍历本身是元数据操作,磁盘的元数据性能才是瓶颈,多线程容易互相干扰。我倾向于先做“扫描结果缓存”——把上一次的 Asset 列表存成一个文件,下次启动时用 mtime 判断根目录是否变过,没变就直接读缓存。这个收益比并行遍历大得多,实现也简单。
第二是变体批量展开。现在一个变体跑一次,多个变体要跑多次。可以在配置里写variants = hd,sd,工具内部按变体展开任务,一次跑完所有组合。实现上就是把 Task 的生成逻辑包一层循环,{variant}变量在那里替换。这个改动很小,但对多分辨率资源的项目很实用。
第三是清单的格式插件化。现在的清单是自定义的文本格式,够用但不通用。可以考虑支持输出 JSON,让下游更容易解析。不过这里要克制——每加一种格式就多一份维护成本,我倾向于只加最常用的那一两种,而不是做成“支持任意格式”的框架。
第四是把预览做成临时的交互界面。纯命令行预览在文件多的时候还是要靠眼睛扫,如果能在终端里做个简单的可折叠列表(按目录分组、回车展开),体验会好不少。但这块投入产出比不高,我暂时搁置。
讲一个我做了之后觉得最值的改动:给每个任务加reason字段。它最初只是为了预览好看,后来发现调试规则的时候极其有用——为什么这个文件被跳过了?看 reason 就知道是“大小相同且源更旧”还是“没命中规则”。没有这个字段,你就得在代码里加打印、重新编译、再跑一遍;有了它,一次输出就能定位。这类“给未来的自己留信息”的设计,成本往往只有几行代码,收益却贯穿整个工具的寿命。
最后分享一个我在这个工具上学到的判断:批量处理工具的复杂度不在核心算法,全在边界情况。复制文件本身是三行代码的事,但大小写、符号链接、路径过长、半截文件、并发错误聚合、增量误判,这些加起来占了整个代码量的八成以上。所以每次我要加新功能的时候,第一反应不是“怎么写”,而是“它能出什么幺蛾子”。这个思维习惯,比我在这门语言上学到的任何语法特性都更值钱。