原创

看起来相同的文件名,为什么在 Java 索引里成了两个键

两个文件名在界面上都显示 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调用写得多简洁。

正文到此结束
评论插件初始化中...
Loading...
本文目录