看起来相同的文件名,为什么在 Java 索引里成了两个键
- 发布时间:2026-10-23 21:25:12
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: Java Unicode Normalizer
- 全文共1字,阅读约需1分钟
两个文件名在界面上都显示 café,复制进 Java 却不相等。一份使用单个字符 é,另一份使用 e 加组合重音。屏幕展示接近,并不意味着字符串的码元序列一致。
当系统同时接收网页输入、压缩包文件名和不同操作系统生成的清单时,这类差异可能进入索引。若业务约定它们属于同一个逻辑名称,就要明确比较用的规范形式;不能期待 String.equals 自动理解视觉等价,也不应假设所有文件系统采用同一种存储形式。
这次实现一个内存中的文件名登记簿:比较键采用 NFC,展示名保留首次输入;规范化后冲突就拒绝,不覆盖旧记录。它不在真实磁盘上创建同名文件,避免把文件系统的行为混进字符串实验。
先把原始字符串和比较键分开
使用 JDK 17,将代码保存为 NormalizedNames.java,执行 java NormalizedNames.java。登记簿只演示单线程调用,不冒充并发数据库唯一约束。
import java.text.Normalizer;
import java.util.LinkedHashMap;
import java.util.Map;
public class NormalizedNames {
record Entry(long id, String displayName) { }
static final class Names {
private final Map<String, Entry> entries = new LinkedHashMap<>();
private String key(String name) {
if (name == null || name.isEmpty() || name.contains("/") || name.contains("\\"))
throw new IllegalArgumentException("single non-empty name required");
return Normalizer.normalize(name, Normalizer.Form.NFC);
}
void add(long id, String name) {
if (entries.putIfAbsent(key(name), new Entry(id, name)) != null)
throw new IllegalArgumentException("normalized name exists");
}
Entry find(String name) { return entries.get(key(name)); }
}
public static void main(String[] args) {
String composed = "caf\u00e9.txt";
String decomposed = "cafe\u0301.txt";
System.out.println("raw_equal=" + composed.equals(decomposed));
System.out.println("nfc_equal=" + Normalizer.normalize(composed, Normalizer.Form.NFC)
.equals(Normalizer.normalize(decomposed, Normalizer.Form.NFC)));
Names names = new Names(); names.add(10, composed);
try { names.add(11, decomposed); throw new AssertionError(); }
catch (IllegalArgumentException expected) { System.out.println("second=CONFLICT"); }
System.out.println("found=" + names.find(decomposed).id());
System.out.println("nfkc=" + Normalizer.normalize("A", Normalizer.Form.NFKC));
}
}
得到:
raw_equal=false
nfc_equal=true
second=CONFLICT
found=10
nfkc=A
原始两个键不相等,NFC键相等,第二次登记被拒绝。查询使用分解形式仍能找到首次保存的记录,展示值则没有被替换。这个结果体现了一条业务协议:用户输入可以保留,身份比较必须稳定。
把规范化只放在写入端不够,查询端也需要相同规则;只放在查询端同样不够,旧索引可能已经保存两份互相冲突的记录。实际迁移应先离线计算新比较键,列出冲突,再决定哪条记录保留或怎样改名。直接给全表批量normalize后覆盖,可能在没有人工判断的情况下合并两个对象。
为什么这里选择 NFC,而不是把所有相似字符都压成一样
NFC 处理规范等价的组合形式。NFKC 还会进行兼容分解与重组,实验中的全角 A 会被转换成普通 A。这对某些搜索输入有价值,却可能不符合文档名称、账号或展示文本的保留要求。
因此不应写一个到处调用的“清洗字符串”函数,顺手把归一化、去空白、大小写转换和标点删除都塞进去。每加一条规则,都会扩大被视为同一个身份的输入集合。规则应随字段用途确定,并且有版本与迁移办法。
NFC 也没有承诺把大小写相同化,更不会把外形接近的不同文字合为一个字符。视觉混淆、保留词、长度限制与控制字符,是后续不同的校验问题。规范形式一致,只回答了这一层比较要求。
它叫文件名登记簿,但不负责路径权限
本例拒绝空名称以及斜杠、反斜杠,目的是让样本的名称保持单段形态。不要把这两条判断复制到上传接口,就宣称路径穿越已经处理完。真实文件系统还有保留名称、不同路径分隔语义、符号链接、挂载关系以及检查后被替换的竞态。
更稳妥的上传存储通常用服务端生成的内部编号作为实际文件路径,把用户原始名称仅当元数据。若必须保留文件名,需要按目标文件系统的规则设计目录隔离和打开方式,不能由Unicode规范化承担权限职责。
长度也要说明单位。界面上的一个可见字符可能由多个码点组成,数据库字段长度、UTF-8字节限额和Java的UTF-16长度不是同一指标。如果存储限制针对规范化后的比较键,就要在规范化后重新检查;原始显示名的限额也应单独验证。否则输入阶段能通过,建索引时却可能溢出。
已有数据接入时,先记录冲突而不是抹平它
数据库要保证唯一性,比较键必须落到受唯一约束保护的列中;应用里的先查再写不能防止两个并发请求同时登记。此时还要核对数据库排序规则,因为它可能另外忽略大小写或重音,使数据库认为冲突的范围大于Java NFC键。
对本例而言,拒绝冲突比选择“后写覆盖”更诚实。冲突提示可以带上旧记录编号,由调用方决定改名、关联到已有对象或取消操作。没有明确业务决定时,不应因为两个名字显示接近就删除其中一份内容。
这套规则的核心很小:保留展示值,固定比较规则,对碰撞给出可见结果。真正难的是让所有入口、查询和持久层执行同一份身份协议,而不是把Normalizer调用写得多简洁。