周五下午四点,改完最后一行配置准备发版,服务一启动就在控制台糊了你一脸:
java.lang.IllegalArgumentException: Malformed \uxxxx encoding.没有文件名,没有行号,没有堆栈指向你的配置文件。你翻遍application.properties、log4j2.properties、gradle.properties,看着每一行都挺正常——直到发现某一行写着app.log.dir=C:\update\logs。问题就出在这个看起来人畜无害的 Windows 路径上。这篇东西就是围绕这个报错展开的:它为什么会发生、怎么在五分钟内定位到具体字符、以及我实际用过的几种修法各有什么代价。不管你是刚接手一个老项目的新人,还是写了十年 Java 的老手,只要你还碰.properties文件,早晚会撞上它。
1. 先把问题看透:一行 Windows 路径是怎么把解析器逼疯的
很多人第一次遇到这个报错时,第一反应是"编码问题",然后去改 IDE 的 File Encoding、去加-Dfile.encoding=UTF-8、去重启 IDE。折腾两小时发现一点用没有。因为这个报错名字里的 "encoding" 其实是个误导,它跟字符集编码没半毛钱关系,它是转义序列解析失败。
1.1 Properties 的转义规则,比你想的窄得多
java.util.Properties这个类从 JDK 1.0 活到现在,它的解析逻辑从来没变过,核心是私有方法loadConvert。这个方法在遇到反斜杠\时,只认下面这几类后续字符:
| 反斜杠后跟的字符 | 解析结果 |
|---|---|
t | 制表符\t |
r | 回车\r |
n | 换行\n |
f | 换页符\f |
" | 双引号 |
' | 单引号 |
\ | 一个反斜杠本身 |
u+ 4 位十六进制 | 对应的 Unicode 字符,如\u4e2d是"中" |
| 其他任何字符 | 抛IllegalArgumentException: Malformed \uxxxx encoding. |
注意最后一行那个"其他任何字符"——这里的"其他"包括字母 p、字母 l、数字 8、中文、空格,什么都算。所以C:\update\logs里那个\u后面跟的是p,p不是十六进制数字,直接引爆。
关键点在于:\u这个组合是唯一会主动报错的。\U(大写)不会报错,\l不会报错,\a也不会报错——它们会被静默处理掉。这就引出了比报错更麻烦的情况。
1.2 更阴险的是"不报错但值变了"
我在一个老项目里见过这样一行配置:
app.data.path=C:\Users\name\file程序能正常启动,不抛异常,但读出来的app.data.path是:
C:Users am\file中间那个真真切切是个换行符。原因拆开看就清楚了:
\U→U不是t/r/n/f之一,走默认分支,反斜杠被丢弃,只留下U;\n→ 被识别成换行符,值里插了一个\n;\f→ 被识别成换页符(ASCII 0x0C),虽然肉眼看不见,但字符串长度确实变了。
你以为你配的是C:\Users\name\file,程序拿到的是C:Users\nam\file。这种 bug 最恶心的地方在于:日志里看不出任何异常,程序也不崩,只是文件永远找不到、路径永远拼不对。有人为此查了整整一天,最后靠System.out.println(Arrays.toString(value.toCharArray()))打印字符数组才发现里面混了个 0x0A。
还有一个容易被忽略的细节:整行注释不会被转义解析。Properties.readLine()在读到行首第一个非空白字符是#或!时,会直接把整行丢掉,根本不进loadConvert。所以你会看到有人把出问题的配置行前面加个#注释掉,程序就好了——但那只是绕过去了,问题还埋在那儿,等下一个同事取消注释时再炸一次。
注意:报错信息里没有行号,是因为异常是从
loadConvert内部直接throw的,那个位置拿不到当前行号信息。这不是你的日志配置有问题,是 JDK 就这么写的。
2. 五分钟定位:把可疑的反斜杠一次性全揪出来
知道了原理,定位就变成了纯粹的体力活。但我不想让你一行行肉眼扫——那太容易漏。下面这套流程我自己用了很多次,从看到报错到锁定字符,基本控制在五分钟内。
2.1 两分钟最小复现
先确认是不是 Properties 的问题,用一个极小的样例跑一遍:
import java.io.FileInputStream; import java.io.InputStream; import java.util.Properties; public class ReproMain { public static void main(String[] args) throws Exception { Properties props = new Properties(); try (InputStream in = new FileInputStream("app.properties")) { props.load(in); } System.out.println(props.getProperty("app.log.dir")); } }配上一个最小的app.properties:
app.name=demo app.log.dir=C:\update\logs跑起来必现。然后把app.log.dir那一行换成C:/update/logs,再跑一遍,如果好了,那基本就实锤了。这个复现过程的意义在于:把"整个项目启动失败"这个大问题,缩到"两行代码一个文件"这个小问题上。缩小范围是所有排障动作里性价比最高的一步,别急着去看 Spring 的自动配置源码。
2.2 一个能直接跑的扫描脚本
单个文件肉眼扫还行,但一个项目里几十个 properties 文件,就得靠工具。我写过一个 Python 小脚本,专门扫"可疑反斜杠",直接存成scan_escapes.py就能用:
import re import sys # 合法的转义序列:\uXXXX 或者 \t \r \n \f \" \' \\ VALID = re.compile(r'\\u[0-9a-fA-F]{4}|\\[trnf"\'\\]') def scan(path): hits = [] with open(path, encoding='utf-8', errors='replace') as f: for lineno, raw in enumerate(f, 1): line = raw.rstrip('\r\n') stripped = line.lstrip() # 整行注释不参与转义解析,跳过 if stripped.startswith('#') or stripped.startswith('!'): continue i, n = 0, len(line) while i < n: if line[i] != '\\': i += 1 continue if i == n - 1: hits.append((lineno, i + 1, '行尾反斜杠(续行符)')) break m = VALID.match(line, i) if m: i = m.end() else: hits.append((lineno, i + 1, line[i:i + 10])) i += 1 return hits if __name__ == '__main__': total = 0 for p in sys.argv[1:]: for lineno, col, snippet in scan(p): total += 1 print(f'{p}:{lineno}:{col} 可疑 -> {snippet!r}') sys.exit(1 if total else 0)用法:
find . -name "*.properties" -not -path "*/target/*" | xargs python3 scan_escapes.py输出长这样:
./src/main/resources/app.properties:2:12 可疑 -> '\\update\\lo'行号、列号、可疑片段全给你标出来了。脚本退出码非零,所以它同时也能当作 CI 的检查步骤——这一点后面第 3.5 节还会用到。
有个细节值得说明:我为什么把\uXXXX和\t\r\n\f\"\'\\放在同一个正则里?因为扫描时的核心逻辑是"逐个消费合法转义",遇到\\就整体跳过两个字符,这样才能正确区分C:\\update(合法)和C:\update(非法)。如果只匹配\u,C:\\update里的第二个反斜杠会被误报。
2.3 报错没有行号,怎么反推
如果你手上只有一个报错堆栈,没法跑脚本,还有两个土办法:
第一个是二分法。把 properties 文件对半切,上半部分留下,下半部分改个扩展名让程序读不到。还报错说明问题在上半部分,不报错说明在下半部分,继续切。十个来回能定位到具体行。这个方法笨但绝对管用,尤其是在你连文件在哪都不知道的时候——顺着Properties.load的调用栈往上找,通常能追到是哪个组件在加载哪个文件。
第二个是给类路径加日志。在 Spring Boot 项目里,-Dlogging.level.org.springframework=DEBUG会把所有加载的属性源打出来,你能看到每个 PropertySource 的名字和来源路径。老项目里经常有四五层配置叠在一起(application.properties、application-dev.properties、外部config/目录、环境变量),知道是哪一层的问题就已经赢了一半。
实操心得:我一般会在扫描脚本里加一句
errors='replace'。因为配置文件可能是 GBK 编码的,直接按 UTF-8 读会抛UnicodeDecodeError,脚本一上来就崩,反而看不到真正想看的转义问题。用 replace 容错,转义检查照样准确。
3. 六种修法逐条拆解,以及它们各自的代价
标题里说"完美解决方法",但我得先说句实话:这世上没有一种修法是没有代价的。改成正斜杠方便,但同事会问"这路径在 Windows 上能跑吗";转义反斜杠规范,但下一个人加配置时大概率又忘了。所以下面这六种我都讲清楚,包括它们的适用边界和坑在哪。
3.1 转义反斜杠:最正统,也最容易被后人改回去
最标准的做法就是把每个反斜杠写成两个:
app.log.dir=C:\\update\\logs app.data.path=C:\\Users\\dev\\data解析后拿到的就是C:\update\logs,值完全正确。这是官方文档里推荐的做法,语义上没有任何歧义。
但它的实际问题是:可读性差,而且极容易回退。下一个人接手看到C:\\update\\logs,第一反应往往是"多打了个斜杠吧",顺手删掉一个,然后线上又炸了。我在一个团队里推过这个方案,三个月内被改回去四次。
所以如果你选这条路,必须配一道防线——要么加一条代码注释说明原因(# 注意:双反斜杠是 Properties 转义要求,勿删),要么干脆在 CI 里放个检查,谁改回去谁的红灯就亮。光靠"约定"是守不住的。
3.2 路径统一用正斜杠:改动最小,收益最大
这是我最推荐的方案,没有之一:
app.log.dir=C:/update/logs app.data.path=C:/Users/dev/data因为 Java 的java.io.File和java.nio.file.Path在 Windows 上同时接受正斜杠和反斜杠。new File("C:/update/logs")和new File("C:\\update\\logs")在 Windows 上是完全等价的,能正常打开文件、创建目录、做路径拼接。
换句话说,你完全可以只在配置文件层面用正斜杠,把反斜杠转换的工作交给 JVM。这样做的好处是:
- properties 文件里再也不会出现任何反斜杠,从根本上消灭转义问题;
- 配置文件跨平台通用,Linux 和 Windows 用同一份;
- 可读性好,谁都看得懂,也不会有人手贱去改。
需要转换的地方,只在使用路径的那一刻做一次归一化:
String raw = props.getProperty("app.log.dir"); Path dir = Paths.get(raw.replace('/', File.separatorChar));老实说,连这个replace都不需要——Paths.get("C:/update/logs")在 Windows 上直接就能用,toAbsolutePath()、normalize()都正常。只有极少数跟外部程序(比如cmd /c、老版本 Office 命令行工具)交互的场景,才需要转成系统分隔符。
3.3 加载侧兜底:自定义 Properties 子类
有些场景你没法改配置文件——比如它是打包进一个第三方 jar 的,或者由运维直接下发、你碰不到源码。这时候可以换个思路:在加载的时候把孤立的反斜杠补成合法的。
写一个Properties的子类,重写load(Reader),先把文本读进来做一遍"转义修复",再交给父类解析:
import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.io.StringReader; import java.util.Properties; public class LenientProperties extends Properties { @Override public synchronized void load(Reader reader) throws IOException { StringBuilder sb = new StringBuilder(); try (BufferedReader br = new BufferedReader(reader)) { String line; while ((line = br.readLine()) != null) { sb.append(escapeLoneBackslash(line)).append('\n'); } } super.load(new StringReader(sb.toString())); } /** 把不合法的单个反斜杠补成合法的双反斜杠,合法转义原样保留 */ private static String escapeLoneBackslash(String line) { StringBuilder out = new StringBuilder(line.length() + 8); for (int i = 0; i < line.length(); i++) { char c = line.charAt(i); if (c != '\\') { out.append(c); continue; } // 行尾反斜杠是续行符,原样保留 if (i == line.length() - 1) { out.append('\\'); continue; } char next = line.charAt(i + 1); if (next == 'u' && i + 5 < line.length() && isHex4(line, i + 2)) { out.append("\\u").append(line, i + 2, i + 6); i += 5; } else if ("trnf\"'\\".indexOf(next) >= 0) { out.append('\\').append(next); i++; } else { out.append("\\\\"); } } return out.toString(); } private static boolean isHex4(String s, int from) { for (int i = from; i < from + 4; i++) { if (Character.digit(s.charAt(i), 16) < 0) { return false; } } return true; } }用的时候:
LenientProperties props = new LenientProperties(); try (Reader r = new InputStreamReader( new FileInputStream("app.properties"), java.nio.charset.StandardCharsets.UTF_8)) { props.load(r); }这段代码的逻辑很直白:逐个字符走一遍,看到反斜杠就往后看一位——如果是合法的转义组合就原样复制并跳过去,如果是孤立的就补一个反斜杠。
但这里必须泼一盆冷水:这个方案会掩盖配置文件本身的错误。原本C:\update\logs是写错的,现在它被"治好"了,文件里看起来还是错的。下一个用标准Properties.load读这个文件的组件照样会炸。所以我给它划的适用边界是:第三方产物、你确实改不了的配置文件,或者作为灰度期的过渡方案。别把它当成常规做法。
3.4 换掉配置文件格式:从根上绕开
如果你的项目还在用.properties存路径、存 SQL、存多行文本,那真的可以考虑换格式了。YAML、JSON、TOML 都没有这套反斜杠转义规则(YAML 有转义,但规则和触发条件完全不同,且路径里写成C:\update\logs也是合法标量,不会炸)。
Spring Boot 用户换起来最省事:把application.properties改名成application.yml,键名从a.b.c改成缩进层级就行。我这几年新起的项目基本直接用 YAML,路径里的反斜杠想写就写,从来没遇到过这个报错。
代价是:YAML 有它自己的坑。缩进必须用空格、冒号后面必须有空格、Tab 字符会直接报错、长文本要处理换行折叠。而且@PropertySource注解默认只支持 properties 和 XML,要用 YAML 得自己写PropertySourceFactory。所以如果你的项目里只有一两个配置文件,迁移成本不值当;如果是十几个 properties 文件互相引用,那早换早轻松。
3.5 构建期与流水线拦截
不管你选上面哪一种修法,我都强烈建议加一道自动检查。因为这类问题最典型的特征就是"改的人不知道,知道的人不改"。
最简单的方式是把 2.2 节那个脚本挂到 CI 上:
find . -name "*.properties" -not -path "*/target/*" | xargs python3 scan_escapes.py再加一个 JUnit 测试做双保险,思路是"用和生产完全一样的加载方式,把所有 properties 文件都试着读一遍":
@Test void allPropertiesFilesShouldBeLoadable() throws Exception { Path root = Paths.get("src/main/resources"); List<Path> files; try (Stream<Path> s = Files.walk(root)) { files = s.filter(p -> p.toString().endsWith(".properties")) .collect(Collectors.toList()); } for (Path p : files) { Properties props = new Properties(); try (InputStream in = Files.newInputStream(p)) { props.load(in); // 关键:和生产保持同一种加载方式 } catch (IllegalArgumentException e) { throw new AssertionError(p + " 存在非法转义: " + e.getMessage(), e); } } }这里有个容易被写错的细节:测试里的加载方式必须和生产一致。如果生产用的是load(InputStream)(ISO-8859-1),测试里就不能图省事用load(Reader)+ UTF-8,否则编码行为不一样,转义检查倒是能过,但中文乱码的问题漏掉了。
注意:如果项目用了
maven-resources-plugin的资源过滤(<filtering>true</filtering>),构建时会把${...}占位符替换掉。如果替换进来的值里带了反斜杠,就有可能在构建后才产生非法转义——源文件是干净的,产物是坏的。排查时一定要把target/classes下的产物文件也扫一遍。
3.6 什么时候你只能留着反斜杠
有一类场景你没法改成正斜杠:值要被外部程序原样消费。比如配置传给某个只认 Windows 路径的本地命令行工具,它收到正斜杠可能就歇了。或者某个老旧的批处理脚本、某段嵌入式设备配置。
这种情况我的建议是:
- 在 properties 里老老实实写双反斜杠,并在行尾加注释说明;
- 用
LenientProperties那套兜底的做法,只在最外层加载时用一次,中间层不用; - 如果这个值根本不需要被程序解析成路径,只是个字符串透传,那考虑用 Base64 或者干脆搬去 YAML。
我自己的判断标准很简单:如果这个值最终是被File、Path、Files消费的,一律改正斜杠;如果是被外部程序消费的,双反斜杠加注释。没有第三种情况需要纠结。
4. 完整实操:从复现到修复再到验证
前面讲的都是判断和选型,这一节把它串起来跑一遍。我按自己的习惯搭了一个最小工程,走完整流程,你可以直接抄。
4.1 搭最小工程
目录结构:
demo/ app.properties src/ReproMain.javaapp.properties故意写成有问题的一版:
app.name=demo app.log.dir=C:\update\logs app.data.path=C:\Users\dev\data logging.pattern=\u4e2d\u6587%d{yyyy-MM-dd HH:mm:ss} %m%n跑ReproMain,控制台输出:
Exception in thread "main" java.lang.IllegalArgumentException: Malformed \uxxxx encoding. at java.base/java.util.Properties.loadConvert(Properties.java:717) at java.base/java.util.Properties.load0(Properties.java:438) at java.base/java.util.Properties.load(Properties.java:384) at ReproMain.main(ReproMain.java:9)堆栈里能看到Properties.loadConvert那一行——这就是"案发现场"。可惜它只告诉你"有个转义不合法",不告诉你是哪一行。
把app.log.dir那行注释掉再跑:
C:Usersdev这就是 1.2 节说的静默变形。\U丢掉了反斜杠,\d丢掉了反斜杠,所以C:\Users\dev\data变成了C:Usersdev。
顺手验证一下转义的最大长度限制:\u4e2d\u6587是合法的,能正确输出"中文"。但如果你手抖写成\u4e2(只有三位),立刻又是一个Malformed \uxxxx encoding。
4.2 三种修法的代码落地
修法 A:改成正斜杠(推荐)
app.name=demo app.log.dir=C:/update/logs app.data.path=C:/Users/dev/data logging.pattern=\u4e2d\u6587%d{yyyy-MM-dd HH:mm:ss} %m%nJava 侧加一层归一化,防止代码里有人做字符串拼接把正斜杠拼乱了:
String raw = props.getProperty("app.log.dir"); Path logDir = Paths.get(raw).toAbsolutePath().normalize(); Files.createDirectories(logDir); System.out.println(logDir);在 Windows 上跑,输出C:\update\logs。注意toString()出来是反斜杠——这是Path的显示格式,正常的,内部存的是平台无关的表示。
修法 B:双反斜杠
app.log.dir=C:\\update\\logs app.data.path=C:\\Users\\dev\\data解析后props.getProperty("app.log.dir")得到C:\update\logs,和修法 A 的结果一模一样。同样的 Java 代码不用改。
修法 C:LenientProperties 兜底
直接把 3.3 节那个类复制进来,用LenientProperties替换Properties,源文件保持C:\update\logs不动,程序也能正常跑起来。但我再说一遍,这是过渡方案,别长期留着。
三种方案跑出来的props.getProperty("app.data.path")结果对比:
| 修法 | 源文件写法 | 读到的值 | 是否需要改代码 |
|---|---|---|---|
| A 正斜杠 | C:/Users/dev/data | C:/Users/dev/data | 建议加归一化 |
| B 双反斜杠 | C:\\Users\\dev\\data | C:\Users\dev\data | 不需要 |
| C 兜底子类 | C:\Users\dev\data | C:\Users\dev\data | 需要换类 |
三种都能用。选哪个看你的项目约束——如果代码里到处是字符串拼接路径,我建议直接上 A 加归一化,一次性解决问题。
4.3 顺手把中文乱码一起解决
排这个错的时候,很多人会顺便发现另一个问题:配置文件里的中文读出来是乱码。这两件事经常一起出现,但原因完全不同。
Properties.load(InputStream)从 JDK 1.0 起就固定按ISO-8859-1解码。你存成 UTF-8 的中文,按 ISO-8859-1 读就是一串拉丁字母乱码。处理方式有三种:
方式一:用load(Reader)显式指定 UTF-8(Java 6 起可用)
Properties props = new Properties(); try (Reader r = Files.newBufferedReader( Paths.get("app.properties"), StandardCharsets.UTF_8)) { props.load(r); }方式二:非 ASCII 字符全部写成\uXXXX转义
greeting=\u4f60\u597d\uXXXX在 ISO-8859-1 下也能正确解析,因为那四个十六进制字符本身就是 ASCII。这也是为什么 IDE 保存 properties 文件时经常会自动帮你转成\uXXXX。
方式三:升级到 JDK 9+,改用PropertyResourceBundle
JDK 9 起,PropertyResourceBundle的默认编码从 ISO-8859-1 改成了UTF-8。如果你用的是资源包机制读配置,升到 JDK 9 之后中文乱码会自动消失。但注意:Properties.load(InputStream)到今天(JDK 21)仍然是 ISO-8859-1,这个 API 的行为没有变,别指望升级 JDK 能顺手把这个问题也解决掉。
我自己的做法是:统一用load(Reader)+ UTF-8,文件本身存 UTF-8 无 BOM 格式。这样中文、日文、emoji 都能直接写在配置文件里,可读性最好,也不用满屏\u4e2d\u6587。
5. 常见问题速查与踩坑记录
5.1 症状-原因-对策速查表
| 症状 | 可能原因 | 对策 |
|---|---|---|
启动直接抛Malformed \uxxxx encoding | 值里有\u后跟非十六进制字符,如C:\update | 改正斜杠或写双反斜杠 |
| 不报错,但配置值明显变短/变形 | \U、\l、\a等被当成转义吃掉了 | 全文扫描孤立反斜杠 |
| 值里莫名多出换行、制表符 | 路径里出现\n、\t、\f组合 | 同上,改用正斜杠最省事 |
| 中文全部变乱码 | load(InputStream)走 ISO-8859-1 | 换load(Reader)+ UTF-8 |
| 第一个 key 永远读不到 | 文件带 UTF-8 BOM,首个 key 前面挂了\uFEFF | 存成无 BOM 的 UTF-8 |
| IDE 里跑得好好的,打包后报错 | IDE 开了 native-to-ascii 转换,源文件和产物不一致 | 统一编码策略,见 5.3 |
| 只在 CI 上失败,本地正常 | 构建时资源过滤把\\替换成了\ | 检查maven-resources-plugin的 filtering |
报错但代码里搜不到\u | 配置文件在依赖 jar 里,不在你源码里 | 顺着堆栈找,或把 classpath 上的 jar 解开扫一遍 |
5.2 我真正踩过的几个坑
坑一:BOM 头导致第一个 key 神秘消失。这个坑我踩过两次。用记事本或者某些编辑器保存 UTF-8 文件时,会在开头加上三个字节EF BB BF。Java 的 UTF-8 解码器不会自动剥掉 BOM。于是用load(InputStream)(ISO-8859-1)读,第一个 key 变成app.name;用load(Reader)+ UTF-8 读,第一个 key 变成\uFEFFapp.name。两种情况下getProperty("app.name")都返回 null。表现就是"明明写了配置,就是读不到,而且只有第一行读不到"。解决办法就是用十六进制编辑器确认文件开头没有 BOM,或者用 IDE 的"保存为无 BOM 的 UTF-8"。
坑二:以为是代码问题,其实是构建产物问题。有一次本地怎么跑都正常,部署到测试环境就报Malformed \uxxxx encoding。查了两小时才发现:项目用了 Maven 的资源过滤,CI 上的环境变量里有个路径变量带了反斜杠,${LOG_PATH}被替换进 properties 后产生了非法转义。源文件完全是干净的。从那以后我排查这类问题,第一步就是把target/classes和打包出来的 jar 解开,直接看产物里的文件长什么样,而不是盯着源码看。
坑三:报错信息不只出现在启动阶段。有一次是在运行时,某个定时任务去读一个动态生成的 properties 文件报的错。这个错误是加载时抛的,跟什么时候加载没关系。所以别限定只在启动日志里找,任何Properties.load的调用点都可能是案发现场。
坑四:行尾的孤立反斜杠。有一行配置结尾是...path=C:\,解析的时候这个反斜杠被当成续行符,试图跟下一行合并。如果下一行是以u开头,那就会拼出一个不完整的\u然后报错。这个案例之所以难查,是因为报错的行号(如果有的话)指向的是下一行,而不是真正写错的那一行。所以扫描脚本里我专门加了一条"行尾反斜杠"的检查。
5.3 一个开关:IDE 里的 native-to-ascii 转换
最后说一个很多人不知道的 IDE 设置,它能解释"为什么我本地一切正常"。
IntelliJ IDEA 对 properties 文件有个选项:Settings → Editor → File Encodings → Transparent native-to-ascii conversion(有些版本在 .properties 文件的右键菜单里)。勾上之后,IDE 在显示和编辑时给你看中文原文,但保存到磁盘时自动把非 ASCII 字符转成\uXXXX转义。
这个功能的副作用是:你在 IDE 里看到的文件内容和磁盘上真实的文件内容不是一回事。如果你在这个界面里手改一个已有转义的字符串,很容易改出一个位数不对的\u来,比如把\u4e2d删成\u4e2,然后报错。同一个项目里,有人勾了有人没勾,就会出现"我这儿好好的,你那儿就报错"的经典场面。
我的做法是团队内统一:要么都勾、要么都不勾,并且在 README 里写明。勾了的话,文件里全是转义,可读性差但编码兼容性最好,怎么传都不会乱;不勾的话,文件保持 UTF-8 明文,但必须保证所有加载点都用load(Reader)+ UTF-8。两种都行,混着来最要命。
我个人的偏好是不勾,文件里直接写中文,配合统一的load(Reader)+ UTF-8 加载工具类。这样 git diff 看得清、code review 也看得懂,出问题的概率反而更低。至于那个Malformed \uxxxx encoding,自从团队约定"配置里的路径一律用正斜杠"之后,我已经快两年没再见过了——偶尔在新同事的提交里看到反斜杠,CI 上的那个扫描脚本会替我提醒他。