ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java批量重命名命令行工具:正则匹配与安全校验实战

Java批量重命名命令行工具:正则匹配与安全校验实战

简介:针对Java文件批量重命名的源码包,面向日常开发中需要整理大量文件的Java程序员,提供一套简单直接的解决方案;整个资源为一个压缩包,体积仅1KB,内含1个Java源文件,代码结构紧凑,适合快速查看和复用。源码围绕IO和NIO两个核心文件操作包展开,实例演示了如何使用File类的listFiles()遍历目标目录及子目录,借助Files.move()与StandardCopyOption安全地重命名文件,同时加入异常处理机制以应对权限不足、目标已存在等常见失败场景;命名规则部分留有清晰注释,可根据实际需求方便地改造为添加序号、替换字符串或调整扩展名等批量策略。该源码尤其适合整理下载文件夹、日志文件或批量转换扩展名的场景,稍作改动即可嵌入日常工具;已有574人学习下载,无论是刚接触文件操作的初学者,还是想寻找现成工具快速完成任务的老手,都能从中获得有效参考。

1. 文件批量重命名:一个Java命令行工具,让整理文件不再重复劳动

给一批图片素材统一命名规范、给日志文件按日期补序号、把PascalCase文件名批量转成下划线风格——这些看似只需要“改个名字”的小事,一旦文件数量上了三位数,手工操作就很容易翻车。这个Java项目就是专门解决这个问题的:一个基于命令行和配置文件的批量重命名工具,支持正则匹配、占位符序号填充、扩展名过滤、干运行预览与冲突检测,用最小的代码量把“批量重命名”这个场景做完整了。

项目本身不依赖任何重量级框架,纯JDK实现,JDK 8以上直接编译运行。既适合需要用Java做文件处理工具箱的后端开发同学,也适合需要批量整理测试资源、日志样本的自动化测试同学。源码结构清晰,规则引擎与文件IO分离,拿来即用,也方便按自己的需求改造成独立服务或GUI工具。

2. 设计思路与技术选型:如何用纯JDK把规则引擎做薄

做批量重命名工具,第一个问题往往不是“怎么写”,而是“做成什么样”。是带界面的桌面程序,还是控制台命令?是用Spring Boot起一个常驻服务,还是main方法跑完就退出?这个项目给出的答案是:纯JDK、零第三方依赖、命令行入口、配置文件驱动。批量重命名本质上是批处理任务,不需要常驻进程,不需要Web容器,一次运行把活干完、干净退出,这才是这类工具该有的样子。

2.1 需求边界与功能清单

先梳理清楚工具处理的核心对象。输入是一个目标目录、一组文件匹配条件和一条重命名规则;输出是改名后的文件,以及一份可回溯的操作日志。围绕这个边界,功能清单落在五个方面。

规则匹配支持四种模式:前缀匹配、后缀匹配、包含匹配、正则匹配。前缀后缀适合处理固定格式的文件,比如所有IMG_开头的照片;正则匹配适合处理无规律但可按模式归类的文件,比如把20230601_001.jpg这类时间戳命名的汇总文件按新规则统一改名。命中的文件进入重命名队列,未命中的保持原样。

重命名规则支持四类占位符:原文件名占位符${name}、序号占位符${seq:3}、正则捕获组占位符${group:1}、以及固定字符串直接拼接。比如把test_1.jpg改成photo_test_001.jpg,规则模板就是photo_${name}_${seq:3}.jpg。${name}引用的是去除扩展名之后的文件名主干,扩展名由工具自动保留。

安全校验分三层。第一层是目标文件名合法性校验,剔除 Windows 下的非法字符\ / : * ? " < > |,换成下划线。第二层是冲突检测,防止两个源文件重命名之后指向同一个目标名。第三层是只读检查,源文件不满足Files.isWritable时跳过并记录日志,避免执行到一半才发现没权限。

预览与执行分离是这个工具最重要的交互设计。--dry-run参数下,引擎完整跑一遍匹配、解析、冲突检测流程,但最后一步Files.move不执行,只把所有即将发生的改动打印出来。这样先看清楚规则是否生效,再实跑,避免改了一批文件之后才发现规则写错,连个后悔药都没有。

最后是操作日志。每次执行生成一个带时间戳的.log文件,逐行记录旧名、新名、执行状态。工具提供了一个--reverse参数,读取上一次的操作日志执行反向重命名,把文件批量恢复原名。这个功能平时用不上,一旦规则写错就非常救命。

2.2 技术选型和零依赖策略

选型权衡集中在两个问题:要不要引正则库、要不要用 JSON 或 YAML 做配置。

正则表达式,JDK 自带的java.util.regex已经完全够用。Pattern的捕获组可以直接实现“从旧文件名提取一段,拼到新文件名里”的典型需求。第三方正则库在这个场景下没有额外收益,反而增加依赖传播的风险。工具类项目最怕的就是依赖出问题,编译环境一换就构建失败。

配置文件选择了.properties而非 JSON 或 YAML,原因很直白:Properties是 JDK 原生支持,加载一行代码,属性文件的键值结构天然对应“匹配条件=重命名规则”这种清单。如果未来要改成 Web 服务,把规则源从 properties 换成 request body 即可,核心逻辑完全不受影响。

文件操作也坚定使用java.nio.file而不是老的java.io.File。Files.move、Path.resolveSibling、Files.walk这几个 API 组合起来,比File.renameTo的返回布尔值可靠得多。File.renameTo在跨目录移动时经常静默返回 false,没有任何异常信息,出了问题只能干瞪眼;Files.move则会在失败时抛出明确异常,便于引擎统一记录失败原因。

第三方依赖是零,这一点值得多说一句。pom.xml里只有编译器和打包配置,没有spring-boot-starter、没有commons-io、没有 lombok。带来的直接好处是任何装有 JDK 8 以上的机器,把源码拷过去mvn package即可构建,没有版本冲突、没有传递依赖的坑。代价是文件遍历、日志写入这些公共操作要手写,但这种重复是有价值的,它让核心逻辑完全透明,也让阅读源码的人更快理解文件 IO 的本质。

2.3 项目核心目录结构与类职责

源码是标准 Maven 结构。核心目录如下:

file-renamer/ ├── pom.xml ├── src/main/java/com/filetool/renamer/ │ ├── App.java # 入口,解析命令行参数 │ ├── config/ │ │ ├── RenameConfig.java # 配置模型 │ │ └── ConfigLoader.java # 配置加载与校验 │ ├── core/ │ │ ├── FileMatcher.java # 四种匹配模式实现 │ │ ├── RenameEngine.java # 引擎调度 │ │ ├── RuleParser.java # 规则模板解析 │ │ └── SequenceGenerator.java # 序号生成 │ ├── validate/ │ │ ├── NameValidator.java # 非法字符校验 │ │ └── ConflictDetector.java # 冲突检测 │ └── util/ │ ├── FileWalker.java # 递归遍历 │ └── LogWriter.java # 执行日志 ├── src/main/resources/ │ └── rename.properties # 默认规则配置 └── src/test/java/ └── RenameEngineTest.java # 核心引擎测试

类之间的依赖方向是单向的:App 解析参数 → ConfigLoader 加载配置 → RenameEngine 接收配置并调度 → FileMatcher 选出目标文件 → RuleParser 解析命名规则 → NameValidator 和 ConflictDetector 校验 → 最后 Files.move 执行。每个类职责很窄,engine 里看不到文件遍历代码,matcher 里看不到命名规则代码,后续改规则解析不会波及到遍历逻辑。这种模块划分是这个项目里最值得借鉴的一点,刚接触 Java 文件处理的人照着这个结构写自己的工具,能少走很多弯路。

3. 核心代码拆解:一条重命名从进入到落盘的完整链路

这一章分三段看核心实现:规则模板如何被解析成可执行的 Token 链、引擎如何调度匹配与重命名、冲突检测如何夹在中间防止覆盖。

3.1 规则解析:把${seq:3}变成可执行的 Token 链

重命名规则的核心不是字符串拼接,而是模板解析。RuleParser的职责是把photo_${name}_${seq:3}.jpg这种模板解析成一组有序的 Token,每个 Token 知道自己输出什么。

public class RuleParser { private static final Pattern TOKEN_PATTERN = Pattern.compile( "\\$\\{(\\w+)(?::(\\d+))?\\}" ); public List<RuleToken> parse(String template) { List<RuleToken> tokens = new ArrayList<>(); Matcher m = TOKEN_PATTERN.matcher(template); int last = 0; while (m.find()) { if (m.start() > last) { tokens.add(new LiteralToken(template.substring(last, m.start()))); } String name = m.group(1); String arg = m.group(2); if ("name".equals(name)) { tokens.add(new NameToken()); } else if ("seq".equals(name)) { int width = arg == null ? 1 : Integer.parseInt(arg); tokens.add(new SequenceToken(width)); } else if ("group".equals(name)) { tokens.add(new GroupToken(Integer.parseInt(arg))); } else { throw new IllegalArgumentException("未知占位符: " + name); } last = m.end(); } if (last < template.length()) { tokens.add(new LiteralToken(template.substring(last))); } return tokens; } }

正则\$\{(\w+)(?::(\d+))?\}中,外层\$\{...\}匹配占位符语法,\w+捕获占位符名称,(?::(\d+))?捕获可选的参数。${seq:3}里的 3 就是在这个分组里拿到的。核心逻辑是每次m.find()命中一个占位符时,先记录占位符之前的普通文本作为LiteralToken,再根据占位符名称创建对应 Token 对象,最后更新last位置。循环结束后,把模板剩余部分补一个LiteralToken。

每个 Token 是一个接口的实现:

public interface RuleToken { String render(RenameContext context); }

RenameContext里带着当前源文件的主文件名、序号计数器和正则匹配结果。NameToken.render返回context.getName();SequenceToken.render返回String.format("%0" + width + "d", context.getSeq());GroupToken.render返回context.getMatcher().group(groupIndex)。三个实现加起来不到 40 行,但已经覆盖了最常见的需求。

这里有一个在拆代码时容易忽略的细节:数字宽度的语义。String.format("%03d", 1234)输出的是1234而不是999,格式串的宽度只是最小宽度,不是最大宽度。当文件数量超过 999 时,三位零填充不会截断,序号会悄悄变成四位数。所以在SequenceToken里要考虑溢出策略,否则大规模批次下格式会错乱。这种边角问题,正是实际跑批量任务时最容易翻车的地方。

3.2 引擎调度:遍历、匹配、解析、校验、执行的五步流程

RenameEngine是整个工具的中枢,execute方法按五步走。

public class RenameEngine { private final FileWalker fileWalker; private final FileMatcher fileMatcher; private final RuleParser ruleParser; private final ConflictDetector conflictDetector; private final NameValidator nameValidator; public RenameResult execute(RenameConfig config, boolean dryRun) { List<Path> files = fileWalker.walk(config.getTargetDir(), config.isRecursive()); List<RenameItem> pending = new ArrayList<>(); List<RuleToken> tokens = ruleParser.parse(config.getRenameTemplate()); int seq = 1; for (Path file : files) { String fileName = file.getFileName().toString(); Optional<FileMatch> match = fileMatcher.matches(fileName, config); if (!match.isPresent()) { continue; } RenameContext ctx = new RenameContext( stripExt(fileName), seq, match.get().matcher()); String newBase = tokens.stream() .map(t -> t.render(ctx)) .collect(Collectors.joining()); String newName = newBase + extOf(fileName); if (nameValidator.isValid(newName) && !conflictDetector.hasConflict(file, file.resolveSibling(newName))) { pending.add(new RenameItem(file, file.resolveSibling(newName))); seq++; } } if (dryRun) { pending.forEach(item -> log.info("RENAME {} -> {}", item.source(), item.target())); return new RenameResult(pending, Collections.emptyList()); } List<Path> done = new ArrayList<>(); for (RenameItem item : pending) { try { Files.move(item.source(), item.target()); done.add(item.target()); } catch (IOException e) { log.severe("MOVE FAILED: " + item.source() + ", " + e.getMessage()); } } return new RenameResult(pending, done); } }

五步分别是:遍历收集所有候选文件 → 用FileMatcher过滤出命中的文件 → 用解析好的 Token 列表逐 Token 渲染出新文件名 → 用NameValidator和ConflictDetector双重校验 → 执行Files.move落盘。seq计数器只在文件被加入 pending 队列时递增,保证了序号连续,不会因为某个文件被冲突检测跳过而出现空洞。

resolveSibling(newName)是java.nio.file.Path的常用 API,拿到当前文件同目录下的一个新路径,相当于手工拼接父目录和新文件名。它比Paths.get(parent, newName)更直观,也不容易踩分隔符的坑。

注意Files.move这里没有传REPLACE_EXISTING,目标文件存在时会抛FileAlreadyExistsException,engine 捕获后记一条失败日志继续执行下一个文件,不会中断整个批次。这比默认覆盖安全得多。如果用户显式传入--overwrite参数,这里才加上StandardCopyOption.REPLACE_EXISTING。覆盖策略被单独交给参数控制,而不是写死在引擎里,是我比较认可的一个设计决定。

3.3 冲突检测与扩展名保护:防止覆盖和改名丢失

public class ConflictDetector { private final Set<String> occupied = new HashSet<>(); public boolean hasConflict(Path source, Path target) { if (target.equals(source)) { return true; } String key = target.toString().toLowerCase(Locale.ROOT); if (occupied.contains(key) || Files.exists(target)) { return true; } occupied.add(key); return false; } }

hasConflict返回 true 表示冲突,调用方跳过该文件。判断依据有三个:目标路径和源路径相同(自己改成自己,属于无效操作);当前批次内已经有一个文件占用了这个目标名;磁盘上已经存在同名文件。三个条件任何一个命中即冲突。

这里有个容易被误解的行为:target.equals(source)在 Windows 和 macOS 这类大小写不敏感的文件系统上,abc.jpg改成ABC.jpg时可能返回 true,引擎会把这次操作判为无效而跳过。用户觉得“我只是改了大写”,系统却认为“路径没有变化”。解决办法是给引擎加一个--force-case-change容错参数,遇到这种名字时直接用Files.move尝试执行,让底层文件系统去决定合法性。

扩展名保护逻辑是一段独立的小方法:

private String stripExt(String fileName) { int dot = fileName.lastIndexOf('.'); if (dot <= 0) { return fileName; } return fileName.substring(0, dot); }

lastIndexOf('.')找最后一个点,去掉它后面部分作为主文件名。dot <= 0的判断很关键:如果点是第一个字符(比如.gitignore),整个文件会被当作主文件名,扩展名为空。如果不判边界,隐藏文件会被切成空字符串,重命名之后直接丢名字。这种小坑在真实项目里非常常见,写文件处理工具时一定要记住。

4. 运行配置与避坑排查:从命令行启动到五条血泪经验

4.1 环境准备与三种启动方式

先把环境准备好。项目要求 JDK 8 以上,Maven 3.6 以上。不需要联网拉依赖,因为 pom 里没有任何第三方库,唯一的风险是本地 Maven 仓库缺基础插件,用阿里云镜像源或默认中央仓库都能解决。构建命令很简单:

mvn clean package -DskipTests

不想用 Maven 也可以用 javac 直接编译:

javac -encoding UTF-8 -d out $(find src/main/java -name "*.java")

Windows 下没有 find 命令,直接用 Maven 就好,别在编译环境上浪费时间。编译产物在target/目录下,运行方式有三种。

干运行预览模式,最推荐先跑这一步:

java -jar target/file-renamer-1.0.jar \ --config src/main/resources/rename.properties \ --dir /tmp/photo_test \ --dry-run

--dry-run让引擎跑完整个流程但不对文件做任何修改,输出格式为RENAME /old/path -> /new/path。实际执行时去掉--dry-run即可。第三种是反向恢复,读取上次操作日志恢复原文件名:

java -jar target/file-renamer-1.0.jar \ --reverse --log logs/rename-20250101-153022.log

这相当于给批量重命名加了后悔药,规则写错了也能按日志一键还原。

4.2 配置文件字段说明

rename.properties是默认配置,字段含义如下:

配置项示例值说明
dir.target/data/photos目标目录,命令行 --dir 可覆盖
dir.recursivetrue是否递归子目录
match.moderegex匹配模式:prefix/suffix/contains/regex
match.patternIMG_\d{4}匹配模式对应的表达式
match.caseSensitivefalse匹配时是否区分大小写
rename.templatephoto_${seq:3}.jpg重命名模板
rename.extKeeptrue是否保留源扩展名
log.output./logs操作日志输出目录
advance.forceCaseChangefalse是否允许仅大小写不同的重命名

这里最需要注意的是match.pattern在 properties 文件里的转义。properties 文件加载时,\d会被 Java 的Properties当成转义字符消费掉,所以正则里的\d在配置里必须写成\\d。如果忘记双写,加载后正则表达式就只剩d,匹配结果全错。我第一次跑这个工具时就栽在这里,日志显示 0 个文件被匹配,排查了半天才意识到是配置文件转义的问题。

4.3 常见问题排查:五条血泪经验

第一个问题:任务执行完,日志显示 0 个文件被命中,但目录下明明有符合预期的文件。

现象是批量重命名任务跑完,日志里一个 RENAME 记录都没有。原因是配置文件里正则表达式的反斜杠被Properties加载时转义掉了,\d{4}变成d{4}。解决方法是正则里所有反斜杠写成双反斜杠\\d,或者在ConfigLoader里对加载后的字符串做一次反转义。我一般会在加载之后把 pattern 值打印出来确认一遍,先确定加载阶段的值没问题,再排查匹配逻辑。

第二个问题:重命名执行后,某些文件不见了。

现象是执行完重命名,发现目录里少了几个文件,日志里也没有报错。原因有两个:一是目标目录里已存在同名文件,冲突检测把源文件跳过,但用户以为改名成功;二是Files.move带了REPLACE_EXISTING,直接把磁盘上的同名旧文件覆盖了。解决方法是默认不启用覆盖选项,只有用户显式传入--overwrite才开启。我在改造时把覆盖策略抽成了枚举传进引擎,避免在 engine 里写死覆盖参数。

第三个问题:序号生成结果位数不对。

现象是${seq:3}生成的序号超过 999 之后变成 4 位数,格式错乱。原因是String.format("%03d", 1000)输出的是1000,格式串的宽度参数只是最小宽度,不是截断长度。解决方法是给SequenceToken增加溢出策略,要么自动扩位,要么循环封顶。实际处理几万张图片时这个问题很典型,3 位宽度在 1024 个文件时就不够用了,靠肉眼检查很难发现。

第四个问题:中文文件名乱码。

现象是日志里中文名显示成?,重命名后原名中的中文变成乱码。原因是 Windows 控制台默认编码是 GBK,Java 字符串内部是 UTF-16,日志输出走了系统默认编码,中文就被转坏了。解决方法是启动 JVM 时加-Dfile.encoding=UTF-8,同时LogWriter里用Files.newBufferedWriter(path, StandardCharsets.UTF_8)写文件,不依赖PrintWriter的默认编码。这个问题根源不在重命名引擎,是 IO 编码,但绝大多数第一次碰到的人都会以为是引擎的 bug。

第五个问题:递归遍历太慢,大目录下执行耗时达到分钟级。

现象是在数万文件的目录下,工具执行耗时超过两分钟。原因有两个:一是FileWalker用了Files.walk但没利用 Stream 的并行能力;二是每个文件都调用Files.exists做磁盘检查,文件数量大时元数据查询次数成倍增加。解决方法是给Files.walk的 Stream 加.parallel(),同时在ConflictDetector里把已占用文件名缓存到内存Set,先查内存再查磁盘。我在处理一份 8 万文件的日志目录时,这两个改动把耗时从 4 分钟压到了 20 秒左右。

5. 二次开发:把命令行工具改造成通用重命名服务

5.1 暴露 HTTP 接口,让发布系统直接调用

这个工具的模块划分使扩展成本很低。最值得做的一个改造是把命令行入口换成 HTTP 接口,复用RenameConfig模型,对外提供POST /rename/preview和POST /rename/execute两个端点。第一个返回将要执行的改名清单,第二个执行并返回日志文件名。如果不想引入 Spring,JDK 自带com.sun.net.httpserver就够用;想进现有服务体系就包一层 Spring Boot Controller,内部直接调RenameEngine.execute,核心代码一行不用改。

5.2 扩展序号策略,支持幂等重命名

当前SequenceGenerator只有零填充序号一种策略。我一般会加三类策略进去:时间戳序号、UUID 短序号、文件名哈希序号。哈希序号最实用,它让“同一批文件不管跑几遍,生成的名称都一样”,这在增量同步和幂等校验场景下特别关键。实现上只需要改SequenceToken.render(),新增策略在渲染时读取文件内容哈希即可,完全不用动引擎。

5.3 增加规则链,做多轮变换

把RenameEngine.execute里的模板渲染循环抽出来,形成applyRule(String currentName, RenameRule rule),让配置支持多轮规则依次作用。注意每轮之间要重新分离扩展名,序号只在首轮计算,避免多轮引用同一个序号变量导致结果不一致。完整的源码工程和配置文件已经在下载资源里打包好了,适合直接下载到本地跑通再动手改。我自己的习惯是无论改哪个模块,先跑一遍全部已有单元测试,再为新行为补测试,最后一定跑一遍--dry-run确认改动没有影响已有批次。从那以后,每次改RenameEngine我都会强制走一遍干运行,成本为零,但能挡住大部分低级错误。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进