17. 把 DDD 开源脚手架化为自己的:第四次联调(三)------一坨 MalformedInputException,最后查出来是 yml 里的中文注释
上一篇改完 nacos 配置,服务死活起不来,报的是
Failed to configure a DataSource。但真正拦住启动的,是它前面那一大坨没人愿意细看的红色报错栈:org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1。这一篇就干一件事,把这坨两百行的栈逐帧读懂 。栈不会骗人,它写得很清楚:错在 snakeyaml 解析 nacos 配置那一刻,触发点是
MalformedInputException(字节流按 UTF-8 解码失败),第二次报错的栈里还冒出了CommentEventsCollector,问题就出在注释上。最后查出来,是jet-ddd-common.yml里的中文注释编码不对。这是第四次联调的第三篇,没写新功能,就是把一个报错栈读到底。别被两百行吓住,关键帧就那么几行。
一、报错来了:一整坨 YAMLException
服务启动,日志里先甩出这么一段。原始栈很长,我只留有信息量的帧,中间 Spring 启动的样板栈折叠掉:
less
org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:214)
at org.yaml.snakeyaml.reader.StreamReader.ensureEnoughData(StreamReader.java:171)
at org.yaml.snakeyaml.reader.StreamReader.ensureEnoughData(StreamReader.java:166)
at org.yaml.snakeyaml.reader.StreamReader.peek(StreamReader.java:121)
at org.yaml.snakeyaml.scanner.ScannerImpl.scanToNextToken(ScannerImpl.java:1219)
at org.yaml.snakeyaml.scanner.ScannerImpl.fetchMoreTokens(ScannerImpl.java:335)
at org.yaml.snakeyaml.scanner.ScannerImpl.checkToken(ScannerImpl.java:239)
at org.yaml.snakeyaml.parser.ParserImpl$ParseImplicitDocumentStart.produce(ParserImpl.java:210)
at org.yaml.snakeyaml.parser.ParserImpl.peekEvent(ParserImpl.java:161)
at org.yaml.snakeyaml.parser.ParserImpl.checkEvent(ParserImpl.java:152)
at org.yaml.snakeyaml.composer.Composer.checkNode(Composer.java:119)
at org.yaml.snakeyaml.constructor.BaseConstructor.checkData(BaseConstructor.java:150)
at org.yaml.snakeyaml.Yaml$1.hasNext(Yaml.java:525)
at org.springframework.beans.factory.config.YamlProcessor.process(YamlProcessor.java:203)
at org.springframework.beans.factory.config.YamlProcessor.process(YamlProcessor.java:169)
at org.springframework.boot.env.OriginTrackedYamlLoader.load(OriginTrackedYamlLoader.java:85)
at org.springframework.boot.env.YamlPropertySourceLoader.load(YamlPropertySourceLoader.java:50)
at com.alibaba.cloud.nacos.parser.NacosDataParserHandler.parseNacosData(NacosDataParserHandler.java:92)
at com.alibaba.cloud.nacos.configdata.NacosConfigDataLoader.pullConfig(NacosConfigDataLoader.java:147)
at com.alibaba.cloud.nacos.configdata.NacosConfigDataLoader.doLoad(NacosConfigDataLoader.java:81)
at com.alibaba.cloud.nacos.configdata.NacosConfigDataLoader.load(NacosConfigDataLoader.java:68)
... (中间 ConfigDataEnvironment / ConfigDataImporter / SpringApplication 启动样板栈)
at vip.wayhua.jet.ddd.rbac.RbacApplication.main(RbacApplication.java:33)
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.base/java.nio.charset.CoderResult.throwException(CoderResult.java:274)
at java.base/sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at java.base/sun.nio.cs.StreamDecoder.read(StreamDecoder.java:188)
at java.base/java.io.InputStreamReader.read(InputStreamReader.java:177)
at org.yaml.snakeyaml.reader.UnicodeReader.read(UnicodeReader.java:118)
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:179)
... 54 common frames omitted
看三处就够了。异常类型是 YAMLException 套着 MalformedInputException: Input length = 1,这不是 YAML 语法错,是读字节时解码失败,Input length = 1 说的是解码器碰上一个按当前字符集(UTF-8)解不出来的字节。
出错位置在 NacosConfigDataLoader.pullConfig → NacosDataParserHandler.parseNacosData → OriginTrackedYamlLoader,也就是「从 nacos 拉下配置、snakeyaml 解析它」那一步,跟前面 DataSource 那堆业务配置没关系。
真正的根在 Caused by 里:sun.nio.cs.StreamDecoder 一路抛到 CoderResult.throwException 的 MalformedInputException,经 InputStreamReader.read 由 snakeyaml 的 UnicodeReader.read 触发。这条链上没有任何 YAML 语法判断,全是字节转字符的动作。
二、先搞清楚这坨栈为什么会这么长
先补点背景。Spring Boot 3/4 用 ConfigDataEnvironment 统一加载 spring.config.import 里的每一项,optional:nacos:jet-ddd-common.yml 会走 NacosConfigDataLoader,把 nacos 上那份配置拉下来,再交给 YamlPropertySourceLoader → OriginTrackedYamlLoader → snakeyaml 去解析。所以栈从 RbacApplication.main 一路往下,一直压到 snakeyaml.StreamReader.update 才崩。启动、加载 import、拉 nacos、解析 YAML、字节解码,最后那环一失败,整条启动就断在这儿。
再往细看,snakeyaml 自己就分了好几层。最底下是字节层,StreamReader.update → UnicodeReader.read → InputStreamReader.read → StreamDecoder → CoderResult.throwException,读字节、按字符集解码都在这层;往上是 Parser 和 Scanner,ParserImpl 是个状态机,管现在处在哪个语法状态,ScannerImpl 把字符流切成一个个 token,这层才真正在读 YAML 语法;再往上是 Composer 和 Constructor,Composer.checkNode 把事件流拼成节点树,BaseConstructor.checkData 把节点拼成 Java 对象;然后是 Nacos 那层 NacosConfigDataLoader.pullConfig → NacosDataParserHandler.parseNacosData;最上面才是 Spring 的 YamlProcessor.process → OriginTrackedYamlLoader.load → YamlPropertySourceLoader.load,把这份配置解析成 PropertySource。
异常是反着来的:最底下的字节层先崩,然后一层层往上包。所以栈顶写的是 YAMLException,最底下 Caused by 才是 MalformedInputException。看这种栈,先翻到最底下那个 Caused by,别被上面一长串业务名字带跑。
第一坨栈的落点也很具体。Yaml$1.hasNext → BaseConstructor.checkData → ParserImpl$ParseImplicitDocumentStart,说明解析器还停在「文档还没开始」这个状态,连第一个 document 都没开始读;ScannerImpl.scanToNextToken 是扫描器在跳过空白、找下一个 token;StreamReader.peek() 只是往前看一个字符。也就是说,在配置开头那一小段被跳过的区域里(空白或注释),往前看一个字符时,字节就解不动了。
回头再看上一篇那个 Failed to configure a DataSource: url not specified,它只是果。因是这份 common 配置根本没解析成功,jet.mysql.* 这些占位符从没被填进去。
三、第二坨同样的报错,线索落在「注释」上
重试一次,nacos 那边先打了 jet-ddd-common.yml 加载 success,紧接着又是同一段 YAMLException: MalformedInputException。但这坨栈的中间帧不一样了,多了 CommentEventsCollector:
less
2026-09-26 19:34:33.367 [main] INFO c.a.c.n.c.NacosConfigDataLoader - [Nacos Config] Load config[dataId=jet-ddd-common.yml, group=DEFAULT_GROUP] success
2026-09-26 19:34:33.368 [main] ERROR c.a.c.n.c.NacosConfigDataLoader - Error getting properties from nacos: ... dataId='jet-ddd-common.yml' ...
org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:214)
at org.yaml.snakeyaml.reader.StreamReader.ensureEnoughData(StreamReader.java:171)
at org.yaml.snakeyaml.reader.StreamReader.peek(StreamReader.java:131)
at org.yaml.snakeyaml.scanner.ScannerImpl.scanPlain(ScannerImpl.java:2059)
at org.yaml.snakeyaml.scanner.ScannerImpl.fetchPlain(ScannerImpl.java:1089)
at org.yaml.snakeyaml.scanner.ScannerImpl.fetchMoreTokens(ScannerImpl.java:447)
at org.yaml.snakeyaml.scanner.ScannerImpl.checkToken(ScannerImpl.java:239)
at org.yaml.snakeyaml.parser.ParserImpl$ParseBlockMappingKey.produce(ParserImpl.java:637)
at org.yaml.snakeyaml.parser.ParserImpl.peekEvent(ParserImpl.java:161)
at org.yaml.snakeyaml.comments.CommentEventsCollector$1.peek(CommentEventsCollector.java:57)
at org.yaml.snakeyaml.comments.CommentEventsCollector$1.peek(CommentEventsCollector.java:43)
at org.yaml.snakeyaml.comments.CommentEventsCollector.collectEvents(CommentEventsCollector.java:136)
at org.yaml.snakeyaml.comments.CommentEventsCollector.collectEvents(CommentEventsCollector.java:116)
at org.yaml.snakeyaml.composer.Composer.composeScalarNode(Composer.java:249)
at org.yaml.snakeyaml.composer.Composer.composeNode(Composer.java:214)
at org.yaml.snakeyaml.composer.Composer.composeValueNode(Composer.java:396)
at org.yaml.snakeyaml.composer.Composer.composeMappingChildren(Composer.java:361)
at org.yaml.snakeyaml.composer.Composer.composeMappingNode(Composer.java:329)
... (中间 Composer 造节点的多帧,以及 YamlProcessor 加载、SpringApplication 启动样板栈)
at vip.wayhua.jet.ddd.rbac.RbacApplication.main(RbacApplication.java:33)
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.base/java.nio.charset.CoderResult.throwException(CoderResult.java:274)
at java.base/sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at java.base/sun.nio.cs.StreamDecoder.read(StreamDecoder.java:188)
at java.base/java.io.InputStreamReader.read(InputStreamReader.java:177)
at org.yaml.snakeyaml.reader.UnicodeReader.read(UnicodeReader.java:118)
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:179)
... 72 common frames omitted
CommentEventsCollector.collectEvents 是 snakeyaml 收集注释事件时走的分支。它能出现在这个栈里,说明 Spring 解析这份 YAML 时把注释收集打开了(OriginTrackedYamlLoader 要靠注释记录配置来源),解析器在给一个标量节点收注释的时候,绕到了扫描器上。
两坨栈摆一起看,差别比表面上多。第一次停在 ParseImplicitDocumentStart,扫描器在 scanToNextToken 里跳过空白;第二次停在 ParseBlockMappingKey,已经在解析 block mapping 的 key,扫描器在 scanPlain 里扫一个普通标量。连 StreamReader.peek 的行号都不一样(121 对 131),第一次的 ensureEnoughData 压了两帧、第二次只有一帧。最大的差别还是那四帧 CommentEventsCollector,第一次根本没有。末尾省略的公共帧数也从 54 变成 72。
第一次是还没进正文就炸,第二次是已经在正文里、而且是在处理注释的过程中炸。两次都崩在 snakeyaml 的字节层,但栈中间那段完全不是一回事。
所以问题不在配置的值上,是配置里的中文注释编码不是合法的 UTF-8。
补充一个容易踩的点:MalformedInputException: Input length = 1 十有八九是 GBK / ANSI 保存的文件被当 UTF-8 读了。中文在 GBK 下是两字节、UTF-8 下是三字节,字节序列对不上,解码器就在中文那一位抛 Input length = 1。nacos 上的配置、本地 yml,只要有一个是用非 UTF-8 存了中文注释,就会在这一步炸。
至于这份带中文注释的 common 配置最终怎么改、改完服务能不能起来,还有起之后冒出来的 rbacSqlSessionFactory 依赖链,放在下一篇收尾。
四、Input length = 1 到底是什么:字节流按 UTF-8 解不动了
把 Caused by 那条链单独拎出来看,它跟 YAML 语法一点关系都没有:
bash
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.base/java.nio.charset.CoderResult.throwException(CoderResult.java:274)
at java.base/sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at java.base/sun.nio.cs.StreamDecoder.read(StreamDecoder.java:188)
at java.base/java.io.InputStreamReader.read(InputStreamReader.java:177)
at org.yaml.snakeyaml.reader.UnicodeReader.read(UnicodeReader.java:118)
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:179)
从下往上读:StreamReader.update 去 UnicodeReader.read 要字符,UnicodeReader 底下包着 InputStreamReader,按某个字符集解码;StreamDecoder.implRead 解到一半解不动,CoderResult.throwException 把 MalformedInputException 抛出来。整条链全是字节转字符的动作,没有一处在判断 YAML 语法。
Input length = 1 这个数字是解码器给的:我这儿只拿到 1 个字节,按当前字符集凑不出一个完整字符。UTF-8 里一个中文是 3 个字节,如果文件其实是 GBK 存的(中文 2 字节),解到中文那一位,剩下的字节数对不上,解码器只能停在那儿报 Input length = 1。
还有个细节。同一个 StreamReader.update,在这坨栈里出现了两个行号,栈顶是 update(214),Caused by 里是 update(179)。对着 snakeyaml 的源码看,update() 就是读一段字节、转成字符的那个方法,读字节那句在 179,214 是它的 catch (IOException e) { throw new YAMLException(e); }。所以**YAMLException 不是解析器报的语法错,它只是把底下那个 IOException 包了一层**。看到 YAMLException 别急着去查缩进,先看 Caused by 是什么。这也是我上一篇被带偏的原因,一直盯着 DataSource 和占位符,压根没往下翻。
日志里还有一条旁证。第二次报错那行 Error getting properties from nacos: 后面跟了一大串 NacosConfigProperties,里面 encode='null',nacos 客户端这边没指定编码,走的是默认 UTF-8。配置内容只要不是合法 UTF-8,这一步必炸,跟它 YAML 写得对不对没关系。
这坑不是我一个人踩。搜一下这个异常,答案高度一致:中文注释加上文件编码不是 UTF-8,处理办法也就那几种,把文件另存为 UTF-8,或者干脆去掉中文注释 (https://blog.csdn.net/230179501633/article/details/147694275)。还有一篇的栈跟我的几乎同款,'CommentEventsCollector.collectEvents→Composer.composeScalarNode→OriginTrackedYamlLoader TRAE_REF](https://blog.csdn.net/2301_79501633/article/details/147694275)。还有一篇的栈跟我的几乎同款,`CommentEventsCollector.collectEvents → Composer.composeScalarNode → OriginTrackedYamlLoader TRAEREF](https://blog.csdn.net/230179501633/article/details/147694275)。还有一篇的栈跟我的几乎同款,'CommentEventsCollector.collectEvents→Composer.composeScalarNode→OriginTrackedYamlLoaderOriginTrackingConstructor.getData一路对上,它崩在ArrayIndexOutOfBoundsException而不是MalformedInputException`,作者查下来也是读配置注释时出的问题,删掉那行注释就好了 $TRAE_REF。
真要确认编码,有个不费劲的办法:编辑器右下角都写着当前文件编码,VS Code 点一下能「Reopen with Encoding」,换个编码重开一次,中文字段显示成乱码、或者换回来就正常,那基本就坐实了。改法跟收尾一起放下一篇。
五、小结
这一篇没写新功能,就是把一坨吓人的报错栈读明白:
- 关键帧就几行。看异常类型(
YAMLException: MalformedInputException)、看 Caused by(StreamDecoder/CoderResult解码失败)、看出现场的调用者(NacosConfigDataLoader.pullConfig → parseNacosData),中间 Spring 启动的样板栈直接折叠。 - 栈是分层的,先看最底下的 Caused by。从下往上是字节层(StreamReader / UnicodeReader / InputStreamReader)、Parser 和 Scanner、Composer 和 Constructor、Nacos 取配置、Spring 的 YAML 入口。异常从最底下往上包,栈顶那个
YAMLException往往只是包装。 - 同一坨栈的差异就是线索。第一坨停在文档开始前(
ParseImplicitDocumentStart+scanToNextToken),第二坨停在 block mapping 的 key 上(ParseBlockMappingKey+scanPlain),还多了CommentEventsCollector。从「还没进正文」挪到「注释附近」,坏字节的位置基本就锁定了。 Input length = 1基本等于编码不匹配。不是 YAML 语法错,是字节流按 UTF-8 解码失败;中文在 GBK/ANSI 和 UTF-8 下字节数不同,用非 UTF-8 存中文,就会在中文那一字节抛出来。nacos 那边encode='null'走默认 UTF-8,一样躲不过。- DataSource 报错只是果,配置没解析才是因。上一篇的
url not specified,是这份 common 没被 snakeyaml 解析成功、jet.mysql.*没填进去导致的。排查要往栈最底层的 Caused by 追,别停在最上面那条业务表象。 - 第二次栈里的
CommentEventsCollector是决定性线索。解析器在给标量节点收集注释,问题就在注释文本上,中文注释编码不是 UTF-8。定位到这儿,改法(把注释改成 UTF-8,或者直接去掉中文注释)就只剩一步。
下一篇,服务起不来还剩最后一道 rbacSqlSessionFactory,以及一个更离谱的发现,project 的库和表根本没建。