
文件名校验这件事乍看就是几个if判断的小功能但真正上手做你会发现它其实是一门跨平台文件系统的行为学。我最近给团队做跨平台文件同步工具和统一上传服务后端选了 Go需要在一处集中搞定所有文件名的合法性校验。原本以为半小时能写完结果被 Windows 保留名、macOS 的 Unicode 归一化、Go 字符串的字节陷阱轮流教育了一遍。这篇博文就把我沉淀下来的 validate filenames 算法、完整源码、测试用例和实战心得一次性讲清楚想直接抄作业的拿走代码想搞懂背后原理的也能跟着思路把规则一条条捋明白。这套算法适合所有需要接收文件名的场景文件上传、数据导出、网盘同步、日志落盘、甚至 CLI 工具里的--output参数校验。无论你是在写 Go 服务端还是准备把一套文件处理逻辑从单平台搬到多平台这篇文章提供的实现都能帮你少踩几周坑。1. 为什么要专门做一套文件名校验算法1.1 先说清楚你会在哪一步遇到文件名校验文件名校验不是为了存在感它的需求几乎全部来自真实业务事故。我按自己遇到过的场景排了个优先级你对照着看就知道缺一不可。第一类是用户上传。这是最典型的场景Web 后台上传文件时multipart/form-data里的文件名完全由客户端说了算。用户可能在 Windows 上传一个report:final.txt或者从网盘下载一个测试(最终版).doc.*这些名字到了 Linux 服务器上大部分没问题但一旦需要同步回 Windows或者被其他 Windows 客户端下载就会变成打不开、存不下、甚至直接丢失的残废文件。上传服务必须在上游挡住这些名字。第二类是跨平台同步。这是我最头疼的场景。家里的 NAS 跑 Linux手机是 iOS/Android主力机是 Windows有时候还要接一台 macOS。同样的目录往几个平台同时同步任何一个平台上生成的文件名都必须满足所有平台的文件系统规则否则另一个平台要么拒绝同步要么名字被悄悄改写用户视角就是文件怎么凭空消失了。第三类是业务系统自动拼文件名。订单导出、报表生成、日志归档代码里经常写fmt.Sprintf(%s_%d.csv, orderNo, timestamp)这种逻辑。订单号本身没问题但用户填写的备注、商品名、地区名一旦拼进来就可能带出空格、斜杠、引号。这种场景下校验函数是最后一道防线能直接把脏字符串拦截在写入磁盘之前不然排查起来特别痛苦。第四类就是安全审计。文件名校验从安全角度看至少能挡掉三类问题路径穿越、空字节注入、超长文件名导致的异常行为。路径穿越靠拒绝/、\和.、..来防空字节在所有主流操作系统的文件 API 里都是非法字符但某些旧代码会直接拼接路径导致截断超长文件名则可能触发各种工具链的隐藏 bug。这些不是危言耸听我在测试环境真的见过通过文件名包含..的请求配合后端不严谨的路径拼接写出了目录外的文件。1.2 不是看着像文件名就行各文件系统的隐藏规则很多人以为文件名就是一串字符只要不是空就能存。真正干过跨平台文件处理的都知道各文件系统的规则差异大得离谱。我把最核心的几项整理成了下面这个表方便你一眼看明白。规则Windows (NTFS)Linux (ext4)macOS (APFS/HFS)非法保留字符 : / \? */和\x00保留设备名CON, PRN, AUX, NUL, COM1-9, LPT1-9无无尾部点/空格会被文件系统自动去除完全合法不推荐某些挂载方式下有问题单组件长度上限255 个 UTF-16 码元255 字节255 个 UTF-8 字符大小写敏感默认不敏感敏感默认不敏感Unicode 归一化一般存 NFC不主动处理自动转 NFD这表里最反直觉的是 Windows 的保留设备名。你在 Windows 资源管理器里创建不了CON、PRN、AUX、NUL更不要说COM1到COM9、LPT1到LPT9。而且让我踩坑的是CON.txt同样是禁止的因为它取文件名里点号之前的部分去匹配保留名点号后的扩展名救不了它。Linux 上随便你创建一个叫CON的文件但一旦同步到 Windows直接失败。规则不可怕可怕的是你不知道规则藏在哪。尾部点和空格也很隐蔽。Windows 的文件系统在创建文件时会自动去掉名字末尾的点和空格report.存进去就变成reportfoo 变成foo。这在 Windows 本地操作是用户无感知的修复但在跨平台同步时就是大坑远端 Linux 上report.和report是完完全全两个文件同步到 Windows 后其中一个会神秘覆盖另一个或者静默失败。所以凡是要做跨平台传输的名字尾部点和空格必须一刀切掉。长度问题也要单独说。Windows 的单组件上限是 255 个 UTF-16 码元Linux 的 ext4 是 255 字节macOS 的 APFS 是 255 个 UTF-8 字符。这三者算出来的同一个中文文件名的消耗量都不一样。后面我会详细说 Go 里怎么处理这个差异这里先记住结论跨平台校验时按字节数做上限最稳妥因为len()和文件系统走得最近。1.3 Go 处理文件名的三个基础认知如果你已经完成了 go 语言环境配置能正常跑go run那可以直接看下面的代码。但如果你刚开始写 Go或者已经写了一阵子却还在用 C 语言思维处理字符串下面这三个点必须先建立起来。第一Go 的string本质是字节序列不是字符数组。len(name)返回的是字节数不是字符数。一个中文字符在 UTF-8 下占 3 个字节一个 emoji 表情占 4 个字节。for i, r : range name里的r是 runeUnicode 码点i是字节下标不是字符下标。这套规则和 Python 的字符索引完全不同很多初学者在这里翻车。第二Go 标准库path/filepath是按当前运行平台的规则来处理路径的。同一个/在 Windows 和 Linux 下语义不同filepath.Separator告诉你当前平台的分隔符。但我们在做跨平台规则判断时不能依赖运行时所在平台必须让规则参数化显式指定按 Windows 规则还是 Unix 规则来校验。这也是我设计校验器时第一个想清楚的点。第三Go 没有 do-while 语法。很多从 C/Java 过来的人写循环时习惯先执行一次再判断条件我在实现尾部空格裁剪时也想过要不要手动写 do-while后来发现直接strings.TrimRight一行搞定根本不需要模拟。这也是 Go 的哲学能用标准库表达的就别在语言层面绕弯子。2. validate filenames 的规则拆解与接口设计2.1 校验规则清单与执行顺序我把规则拆成了六条严格按照下面的顺序执行先做开销小、能快速短路的基础检查再做依赖平台配置的复杂检查。顺序看着简单背后是有讲究的。空字符串拦截。空文件名没有存在价值除非业务上明确允许。.和..拦截。这两个特殊目录项一旦被当成普通文件名保存后面一定会出路径穿越问题。UTF-8 合法性检查。Linux 文件系统其实允许非 UTF-8 字节序列出现在文件名里但绝大多数应用层程序默认 UTF-8一个非法序列极可能导致后续所有字符串处理逻辑出错直接拒绝最省心。长度检查。先查字节数超过配置上限就直接返回错误。非法字符检查。控制字符0x00-0x1F、0x7F以及平台特定的保留字符逐 rune 扫描。平台专属规则。Windows 额外检查尾部点和空格、保留设备名Unix 平台跳过这一步。这个顺序的核心原则是便宜的规则在前。空判断和./ ..判断都是常数时间字符串长度是一条指令的事UTF-8 合法性是线性扫描但不需要分配内存最后才进非法字符和 Windows 专属规则。实际测试里绝大多数非法输入都在前三条被拦住根本走不到最后一步。另外说下控制字符。Linux 上你其实可以创建带换行符的文件名在终端里能把你输出搞乱很多脚本也会直接崩。Windows 上控制字符干脆就是非法。所以我把r 32以及0x7F一律拦截作为默认策略这是业务政策不是文件系统强制要求。如果你的产品确实需要在 Linux 上允许换行文件名把这条改成可配置即可但我不建议你这么干后续日志、监控、第三方程式的成本远高于你省下的那点自由度。2.2 参数化配置Options 的设计取舍既然是验证文件名算法就不能写死一套规则。同一个项目里用户上传模块想严格一点内部数据导出模块可能宽松一点一套代码同时部署在 Windows 和 Linux 上默认平台也得跟着走。我为校验器设计的配置项只有四个够用且不冗余。type Options struct { // Platform 指定按哪套文件系统规则校验。 Platform Platform // MaxLength 是单文件名组件的最大字节数0 表示使用默认值。 MaxLength int // AllowEmpty 为 true 时允许空字符串通过校验。 AllowEmpty bool // AllowSpace 为 true 时允许文件名内部包含空格默认允许。 AllowSpace bool }Platform是核心配置。默认我推荐PlatformWindows理由很简单Windows 的规则是其他平台的超集只要按 Windows 规则校验通过的文件名拿到 Linux 和 macOS 上一定没问题反过来则不成立。做跨平台同步、上传、归档这类面向多端的产品统一用 Windows 规则是最安全的决策。MaxLength为什么要暴露出来因为你可能会遇到一些特殊文件系统。比如某些云存储网关、虚拟文件系统、加密目录它们对文件名长度有额外限制比操作系统的 255 更短。这种时候配置项能让你在同一个包内适配不同后端而不是复制一支代码出来改。AllowSpace这个选项是我后来加的。大部分场景允许空格没问题但某些内部系统比如生成给对端程序批量读取的文件空格会破坏字段分隔再比如要生成 URL 友好的下载文件名空格会被浏览器转义成%20体验很差。默认允许空格需要时关掉。2.3 结构化错误类型把为什么失败变成可处理的字段早期版本我直接用errors.New(文件名不合法)返回测试和前端对接时马上后悔了。前端需要区分名字太长和包含非法字符来显示不同提示日志需要知道具体是哪个字符触发了拦截好做统计和用户体验优化测试更是需要精确断言错误类型而不是模糊的字符串相等。所以我把错误设计成了结构化类型。type ErrorKind int const ( KindEmpty ErrorKind iota KindDotName KindTooLong KindInvalidChar KindReservedName KindTrailingDotSpace KindInvalidEncoding ) type ValidationError struct { Kind ErrorKind Name string Index int // 非法字符在字符串中的字节下标 Char rune // 触发校验失败的字符 Max int // 触发长度错误时的长度上限 msg string } func (e *ValidationError) Error() string { return e.msg }ValidationError实现error接口但在断言时可以用errors.As拿到底层字段。前端接到后可以按Kind给出精准文案比如var ve *validate.ValidationError if errors.As(err, ve) { switch ve.Kind { case validate.KindReservedName: // 提示用户这个名字被系统保留换个名字 case validate.KindTooLong: // 提示用户文件名过长最多 255 字节 case validate.KindInvalidChar: // 提示用户文件名含有非法字符 %qve.Char 直接展示 } }这里我想传达一个设计理念校验函数不只是返回对/错还应该返回错在哪、错成什么样、最大允许值是多少。这三点凑齐上层逻辑就能做出非常精细的处理而不是把所有问题都归成一句文件名无效。3. 完整源码实现与逐段解读3.1 基础结构体与默认配置先看整体骨架。这个文件我命名为validate.go后续测试文件validate_test.go和它放同一个包。package validate import ( fmt strings unicode/utf8 ) type Platform int const ( PlatformWindows Platform iota PlatformUnix ) const ( DefaultMaxLenWindows 255 DefaultMaxLenUnix 255 ) type Options struct { Platform Platform MaxLength int AllowEmpty bool AllowSpace bool } type Validator struct { opts Options } func New(opts Options) *Validator { if opts.MaxLength 0 { switch opts.Platform { case PlatformUnix: opts.MaxLength DefaultMaxLenUnix default: opts.MaxLength DefaultMaxLenWindows } } return Validator{opts: opts} }这里有个细节是默认长度按平台区分。Windows、ext4、APFS 的上限都是 255但含义不同我之前表格里写过。正是因为有这个差异New初始化时统一用字节数作为衡量标准。对 ext4 来说 255 字节就是硬上限对 Windows 的 255 个 UTF-16 码元来说用字节数会稍微严格一点点这是一个可接受的保守策略后面测试部分我会专门讲。AllowEmpty默认是 false。如果传进来的 Options 里AllowEmpty是 falseValidate()就会返回KindEmpty错误如果业务上确实允许空名字比如某个批量接口允许空字段占位配置为 true 即可调用方意图一目了然。3.2 主校验函数 Validate 的完整实现核心的Validate方法这么写func (v *Validator) Validate(name string) error { if name { if v.opts.AllowEmpty { return nil } return ValidationError{Kind: KindEmpty, Name: name, msg: 文件名为空} } if name . || name .. { return ValidationError{Kind: KindDotName, Name: name, msg: 文件名不能是 . 或 ..} } if !utf8.ValidString(name) { return ValidationError{Kind: KindInvalidEncoding, Name: name, msg: 文件名包含非法 UTF-8 字节序列} } if len(name) v.opts.MaxLength { return ValidationError{ Kind: KindTooLong, Name: name, Max: v.opts.MaxLength, msg: fmt.Sprintf(文件名长度 %d 字节超过上限 %d, len(name), v.opts.MaxLength), } } for index, r : range name { if r 0x7f || r 32 { return ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: 文件名包含控制字符, } } if !v.opts.AllowSpace r { return ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: 文件名不允许包含空格, } } if isInvalidChar(r, v.opts.Platform) { return ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: 文件名包含非法字符, } } } if v.opts.Platform PlatformWindows { if err : v.checkWindowsRules(name); err ! nil { return err } } return nil } func isInvalidChar(r rune, p Platform) bool { switch p { case PlatformUnix: return r / default: switch r { case , , :, , /, \\, |, ?, *: return true } return false } }注意for index, r : range name这里index是字节下标。比如字符串测/试里/的字节下标是 3因为测占了 3 个字节。我在ValidationError.Index里存这个值是为了让上层能精确定位到底哪个位置出了问题。如果你想让 Index 变成 rune 序号可以另外计数但我在实践中发现字节下标配合日志输出反而更好用直接name[index]就能取到原字节。isInvalidChar是平台差异的集中体现。Unix 平台我只禁/因为它是路径分隔符不允许出现在单个文件名组件里。反斜杠在 Linux 和 macOS 上是完全合法的字符所以a\b.txt在 Unix 平台上能正常存在。Windows 平台则把\、、、:、、/、|、?、*全部列入黑名单。这里有一个很容易写错的地方/在 Windows 和 Unix 下都要禁不要写成只有 Windows 才禁否则 Unix 平台会放行路径分隔符保存时等于直接创建多级目录。3.3 Windows 专属规则尾部点空格与保留设备名Windows 规则我在checkWindowsRules里实现var windowsReservedNames map[string]bool{ CON: true, PRN: true, AUX: true, NUL: true, COM1: true, COM2: true, COM3: true, COM4: true, COM5: true, COM6: true, COM7: true, COM8: true, COM9: true, LPT1: true, LPT2: true, LPT3: true, LPT4: true, LPT5: true, LPT6: true, LPT7: true, LPT8: true, LPT9: true, } func (v *Validator) checkWindowsRules(name string) error { trimmed : strings.TrimRight(name, . ) if len(trimmed) ! len(name) { return ValidationError{ Kind: KindTrailingDotSpace, Name: name, msg: 文件名不能以点或空格结尾, } } base : name if idx : strings.IndexByte(base, .); idx 0 { base base[:idx] } if windowsReservedNames[strings.ToUpper(base)] { return ValidationError{ Kind: KindReservedName, Name: name, msg: fmt.Sprintf(%s 是 Windows 保留设备名, strings.ToUpper(base)), } } return nil }先解释strings.TrimRight(name, . )。C 系语言里写这个逻辑你可能会想用 do-while 循环Go 没有 do-while但标准库的TrimRight语义刚好就是从右往左去掉匹配集合里的字符。它返回的新字符串和原字符串长度不同就说明尾部存在点或空格直接返回错误。保留设备名的判断核心是取点号之前的基名转大写查表。CON.txt会先被拆成CON然后命中CON直接拦截。这是 Windows 的真实行为不是我的保守策略。con.log同理大小写不敏感所以先ToUpper。至于COM10、COM11这些经典规则只保留到COM9和LPT9因为 DOS 时代只定义到 9。我在实际项目里见过有人把COM10也列为非法其实COM10在 Windows 上是能正常创建文件的。如果你要处理的是特别老旧的网络存储协议可以额外封掉更多但至少在 NTFS 上COM10不是系统设备名。3.4 对外接口与调用示例为了让调用方写起来最舒服我暴露了两个层面一个是可复用的Validator实例适合在服务初始化时创建一次另一个是包级便捷函数Validate内置一个默认的 Windows 严格校验器适合随手调用。var DefaultValidator New(Options{Platform: PlatformWindows}) func Validate(name string) error { return DefaultValidator.Validate(name) } func ValidateFilename(name string, opts Options) error { return New(opts).Validate(name) }调用示例很简单。假设你在写一个 HTTP 上传接口func uploadHandler(w http.ResponseWriter, r *http.Request) { file, header, err : r.FormFile(file) if err ! nil { http.Error(w, 读取上传文件失败, http.StatusBadRequest) return } defer file.Close() if err : validate.Validate(header.Filename); err ! nil { var ve *validate.ValidationError if errors.As(err, ve) { http.Error(w, ve.Error(), http.StatusBadRequest) } return } // 校验通过继续保存 // dst : filepath.Join(uploadDir, header.Filename) // ... }再把几个典型输入跑一下输出非常直观$ go run example.go 正常文件.txt - OK CON - CON 是 Windows 保留设备名 a/b.txt - 文件名包含非法字符 中文 目录/子文件? - 文件名包含非法字符 report.2024. - 文件名不能以点或空格结尾这里我故意在示例里让中文 目录/子文件?这种带斜杠和问号的名字进来真实业务这么干肯定会出事。校验器在扫描到/时就返回了?根本没机会被检查到。这就是我在 2.1 节说非法字符扫描一次遍历的好处总能给出第一个出错位置避免一层层嵌套检查导致报错信息和用户看到的问题对不上。4. 实操中踩过的坑与排查实录4.1 len 统计的是字节不是字符这是 Go 新手最容易踩的坑在文件名场景里尤其危险。len(测试文件.txt)返回 16不是 9。中文每个字占 3 字节4 个汉字 12 字节再加上.txt的 4 字节。如果你按字符数上限 255 去看这个文件名完全没问题但如果你在代码里用len做长度判断一个短中文名可能比英文名多占两倍空间极限情况下 86 个汉字就能撑满 ext4 的 255 字节上限。那 Windows 的255 个 UTF-16 码元该怎么算它和 Go 的 rune 数也不是一回事。一个 rune 是 Unicode 码点对大部分汉字来说 1 个 rune 就是 1 个 UTF-16 码元但 emoji 和一些生僻字在 UTF-16 下要占用 2 个码元代理对。一个纯 emoji 组成的文件名在 Windows 上的真实长度是 rune 数的两倍。如果产品面向的用户的文件名里有大量 emoji你可以加一个辅助函数把 UTF-16 码元数算出来再和上限比较func countUTF16(s string) int { n : 0 for _, r : range s { if r 0xFFFF { n 2 } else { n } } return n }不过我的建议是默认以字节数作为唯一长度指标就够了。原因很简单ext4 只认字节数Windows 的 255 码元严格来说比字节数宽松用字节数校验是只可能更严格、不可能更宽松不会出现校验通过了但实际存不下的情况。你可以在长度上限上留一点余量比如配置成 250兼顾中文场景的兼容性。4.2 macOS 的 NFD 归一化让同名文件对不上这个坑非常隐蔽。macOS 的 APFS/HFS 在保存文件名时会自动把 Unicode 做 NFD 归一化。什么意思呢像é这个字符在内存里有两种表示一种是单个码点U00E9NFC 形式另一种是e加U0301组合重音NFD 形式。macOS 偏好 NFD会把 NFC 形式的文件名转换成 NFD 再落盘。于是问题来了你在 Windows 或 Linux 上创建了一个café.txtGo 程序里存的字符串是 NFC 形式macOS 客户端拿到这个名字去磁盘上找文件时系统自动把它转成 NFD实际落盘的名字和远端记录的名字字节不同文件匹配失败表现为文件明明在就是打不开/找不到。解决办法是在校验之前先统一归一化。Go 官方扩展包golang.org/x/text/unicode/norm提供了现成实现import golang.org/x/text/unicode/norm name norm.NFC.String(name)我建议在进入Validate之前就做归一化或者把它放进Validator的调用管道里。这样校验、存储、比较都基于同一个归一化形式跨平台问题瞬间少一大半。要注意的是归一化会改变字符串的实际字节数所以一定要在归一化之后再做长度检查否则校验的是旧长度保存的是新长度容易漏判。4.3 保留名比你想的更隐蔽Windows 保留设备名是这次开发里最让我头疼的部分。原因不只是它有名单而是它出现的位置太容易被忽略。第一大小写不敏感。Windows 文件系统默认不区分大小写所以con.log、CON.LOG、cOn.txT全都不能创建。判断时必须先ToUpper再查表我在源码里已经处理了。第二点号后的扩展名救不了你。CON.txt依旧非法因为 Windows 在解析文件名时看的是点号前的基名。源码里IndexByte取基名的逻辑就是为了复现这个行为。第三网络共享和云盘客户端可能比本地还严格。SMB 协议、某些网盘桌面端对保留名的处理比 NTFS 还保守COM10、LPT10甚至COM0都可能被当作非法名字拒绝。我们团队用的那款网盘客户端COM0.txt同步上去直接失败虽然 NTFS 本身允许。如果你的产品对接了大量第三方存储建议把保留名单做得比默认更严比如把COM0、LPT0也加上虽然牺牲一点点自由度但能减少大量工单。4.4 常见问题速查表现象根本原因处理建议上传的文件名带空格Windows 用户下载后名字变了Windows 自动去除尾部空格校验阶段拦截尾部空格云盘同步时CON目录永远失败Windows 保留设备名用保留名黑名单拦截同一个中文名macOS 显示正常但程序找不到NFC/NFD 编码不一致统一归一化为 NFC 再入库英文短名字莫名其妙太长字节数和字符数混淆统一按字节数做上限文件名里的 emoji 导致 Windows 报错UTF-16 代理对占用双倍码元需要时用countUTF16精确计算后端保存后路径多了一层目录文件名里混入了/或\校验器拒绝路径分隔符再加filepath.Base兜底最后一行我要多说一句。即使你已经做了完备的文件名校验保存文件时仍然要习惯性地调用filepath.Base再拼路径。这属于纵深防御校验器可能在某个分支被跳过但Base会把一切路径前缀剥掉无论什么情况下都不可能让用户输入变成路径穿越。两层都做才能睡得安稳。5. 单元测试与工程落地建议5.1 表驱动测试用例怎么写Go 社区最流行的测试风格就是表驱动测试把测试数据定义成结构体切片一个循环跑完所有用例。文件名校验这种输入输出都非常明确的场景简直是表驱动测试的完美样本func TestValidateTable(t *testing.T) { tests : []struct { name string opts Options fileName string wantOK bool wantKind ErrorKind }{ {空文件名校验, Options{Platform: PlatformWindows}, , false, KindEmpty}, {允许空文件名的场景, Options{Platform: PlatformWindows, AllowEmpty: true}, , true, 0}, {点号目录, Options{Platform: PlatformWindows}, ., false, KindDotName}, {点点点目录, Options{Platform: PlatformWindows}, .., false, KindDotName}, {Windows 斜杠, Options{Platform: PlatformWindows}, a/b.txt, false, KindInvalidChar}, {Windows 反斜杠, Options{Platform: PlatformWindows}, a\\b.txt, false, KindInvalidChar}, {Unix 斜杠, Options{Platform: PlatformUnix}, a/b.txt, false, KindInvalidChar}, {Unix 反斜杠合法, Options{Platform: PlatformUnix}, a\\b.txt, true, 0}, {Windows 保留名, Options{Platform: PlatformWindows}, CON, false, KindReservedName}, {保留名带扩展, Options{Platform: PlatformWindows}, con.log, false, KindReservedName}, {尾部点, Options{Platform: PlatformWindows}, report., false, KindTrailingDotSpace}, {尾部空格, Options{Platform: PlatformWindows}, foo , false, KindTrailingDotSpace}, {Unix 尾部点合法, Options{Platform: PlatformUnix}, report., true, 0}, {字节超长, Options{Platform: PlatformUnix}, strings.Repeat(a, 256), false, KindTooLong}, {中文正常名, Options{Platform: PlatformWindows}, 中文文件.txt, true, 0}, {空字节, Options{Platform: PlatformUnix}, a\x00b, false, KindInvalidChar}, } for _, tt : range tests { t.Run(tt.name, func(t *testing.T) { err : New(tt.opts).Validate(tt.fileName) if tt.wantOK { if err ! nil { t.Fatalf(期望通过实际错误: %v, err) } return } var ve *ValidationError if !errors.As(err, ve) { t.Fatalf(期望 ValidationError实际是: %v, err) } if tt.wantKind ! 0 ve.Kind ! tt.wantKind { t.Fatalf(错误类型不匹配: 期望 %v实际 %v, tt.wantKind, ve.Kind) } }) } }这个测试表里我把每个关键分支都覆盖了空串、点目录、路径分隔符双平台差异、保留名带不带扩展名、尾部点和空格、字节超长、中文正常名字、空字节。t.Run让每个用例独立显示失败时能直接跳到具体 case排查效率高。测试报告里每个测试名都对应一段业务规则后续接手的人看测试用例就能理解整个校验逻辑。源码我放在测试文件里核心思路是断言错误类型而不是断言错误字符串。字符串容易改一改就导致大量测试用例假失败错误类型是 API 的一部分稳定得多。上线前在 CI 里跑一遍go test ./...这九十个字就能保证你后续简单改规则不会把基础行为改坏。5.2 把校验器嵌入到真实业务里代码写完只是开始真正有价值的是怎么和业务结合。我总结了几条落地经验每一条都用真金白银的线上事故换来的。第一前端要校验后端更要校验。前端校验是体验后端校验是安全。用户可以在浏览器 DevTools 里轻松绕过前端 JS所以后端必须独立执行完整规则且不能相信前端传过来的已经校验过的标记。第二校验和清洗要分开。我的包里目前是只校验不修改名字过不了就直接拒绝。但有些场景下用户已经输入了my/file?.txt你直接拒绝会让用户很烦躁更好的做法是给一个清洗函数把非法字符替换成_比如my_file_.txt然后存储清洗后的名字。实现上你可以在本包基础上包一层 sanitize 逻辑替换规则自己定。两种策略并存的好处是上传场景用拒绝导出场景用清洗互不干扰。第三校验之前先归一化归一化之后重新走一次完整校验。我前文提到过 NFD/NFC 转换会改变字节长度所以流程必须是归一化、然后Validate、然后存储。不要先校验再归一化否则会因为长度变化漏掉极限情况。第四性能上这个算法可以放心用。Validate对英文文件名几乎是一次len 循环扫描O(n) 复杂度没有正则引擎的额外开销。我曾经用go test -bench跑过普通文件名单次校验在几十纳秒到一两百纳秒量级HTTP 上传场景完全不用考虑缓存和优化。与其优化这个函数不如把精力留给下游的磁盘 IO。如果后续想扩展我建议按这几个方向迭代支持更多平台策略比如 Android、iOS 的特殊限制、支持自定义非法字符集、支持文件名大小写冲突检测、把归一化步骤内置到一个SanitizeAndValidate管道函数里。我的初版只覆盖了核心规则但这套接口设计已经足够支撑这些扩展改动不会影响现有调用方。最后再分享一个小技巧虽然默认规则是 Windows 严格模式但我在服务初始化时会把Options从配置中心拉下来这样某天某块业务需要放宽尾部空格限制时改配置就能上线不用发版本。产品经理永远会有新的文件名格式需求提前留好配置口子能让你少加半年班。我个人在这套算法跑完三个月后的最大体会是文件名校验看似是字符串处理实际上做的是文件系统行为对齐。你把 Windows、Linux、macOS 各自那条只要不出事就好的潜规则摊到桌面上用一套参数化规则统一表达出来才是真正能跨平台活下来的实现。前期的坑很多但填完之后你的代码会比其他模块都稳因为你再也不用被神秘消失的文件、同步失败的 CON 目录、打开乱码的中文名追着跑了。