STAX与DOM API处理XML属性换行符的差异原因及STAX强制转义换行的方法
嗨,这个问题其实核心是XML规范的属性值规范化(AVNormalize)规则,以及STAX和DOM两个API的设计差异导致的,我给你慢慢拆解:
一、为什么两个API处理换行的行为不一样?
首先得明确XML的属性值处理规则:根据XML规范,属性值里的换行符(\n)、制表符(\t)、回车(\r)会被解析器自动规范化为空格——除非你把它们转成实体引用(比如 代表换行)。
- DOM API的行为:DOM的设计是严格遵循XML规范的,当你调用
setAttribute设置包含换行的属性值时,DOM会自动帮你把换行符转成实体 写入XML文件。这样后续不管用STAX还是DOM解析,都会把 还原成原始的换行符,所以你读出来的是foo\nbar(带换行)。 - STAX API的行为:STAX是流处理API,它的设计更偏向“所见即所得”的直接输出,默认不会自动对属性值应用AVNormalize规则的转义。当你调用
writeAttribute写入foo\nbar时,它会直接把\n作为换行符写入文件,而不是转成实体。但XML解析器读取这个属性时,会按照规范把换行符规范化为空格,所以你读出来的是foo bar(带空格)。
看你代码里的测试结果就很清楚:
- STAX生成的文件内容是
<element attribute="foo bar"/>,读取时属性值变成了空格分隔的字符串; - DOM生成的文件内容是
<element attribute="foo bar"/>,读取时能完整保留换行。
以下是你的测试代码(已修正转义问题):
import org.w3c.dom.Document; import org.w3c.dom.Element; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamReader; import javax.xml.stream.XMLStreamWriter; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; public class WriteXml { public static void main(String[] args) throws Exception { // 返回 "foo SPACE bar" 因为writeWithStax直接写入换行,解析时被规范化为空格 Path staxFile = writeWithStax("foo\nbar"); System.out.println("staxFile = " + Files.readString(staxFile)); System.out.println("attribute read with stax = " + readWithStax(staxFile)); System.out.println("attribute read with dom = " + readWithDom(staxFile)); System.out.println(); // 返回 "foo NEWLINE bar" 因为writeWithDom把换行转义为" " Path domFile = writeWithDom("foo\nbar"); System.out.println("domFile = " + Files.readString(domFile)); System.out.println("attribute read with stax = " + readWithStax(domFile)); System.out.println("attribute read with dom = " + readWithDom(domFile)); System.out.println(); } private static Path writeWithStax(String text) throws Exception { Path file = Files.createTempFile("stax", ".xml"); try (OutputStream out = Files.newOutputStream(file)) { XMLOutputFactory factory = XMLOutputFactory.newInstance(); XMLStreamWriter writer = factory.createXMLStreamWriter(out); writer.writeStartDocument(); writer.writeStartElement("element"); writer.writeAttribute("attribute", text); writer.writeEndElement(); writer.writeEndDocument(); writer.flush(); writer.close(); } return file; } private static Path writeWithDom(String text) throws Exception { Path file = Files.createTempFile("dom", ".xml"); try (OutputStream out = Files.newOutputStream(file)) { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.newDocument(); Element warningElement = document.createElement("element"); warningElement.setAttribute("attribute", text); document.appendChild(warningElement); TransformerFactory transformerFactory = TransformerFactory.newInstance(); Transformer transformer = transformerFactory.newTransformer(); transformer.transform(new DOMSource(document), new StreamResult(out)); } return file; } private static String readWithStax(Path file) throws Exception { try (InputStream in = Files.newInputStream(file)) { XMLInputFactory factory = XMLInputFactory.newInstance(); XMLStreamReader reader = factory.createXMLStreamReader(in); while (reader.hasNext()) { int event = reader.next(); if (event == XMLStreamReader.START_ELEMENT && "element".equals(reader.getLocalName())) { return reader.getAttributeValue(null, "attribute"); } } } return null; } private static String readWithDom(Path file) throws Exception { try (InputStream in = Files.newInputStream(file)) { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(in); return document.getDocumentElement().getAttribute("attribute"); } } }
二、如何让STAX强制转义换行符?
你自己写的那个workaround其实是可行的,本质上是绕过STAX默认的writeAttribute方法,手动按照XML规范处理属性值的换行。我再给你理清楚这个思路的合理性:
你的解决方案(手动拼接属性)
你通过直接操作Writer来手动输出属性的键值对,这样就能自己控制换行符的转义,确保生成合法的XML:
private static Path writeWithStax(String text) throws Exception { Path file = Files.createTempFile("stax", ".xml"); try (Writer out = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { XMLOutputFactory factory = XMLOutputFactory.newInstance(); XMLStreamWriter writer = factory.createXMLStreamWriter(out); writer.writeStartDocument(); writer.writeStartElement("element"); // 手动处理属性,确保空白字符被正确转义 writeAttributePreservingWhitespace(out, writer, "attribute", text); writer.writeEndElement(); writer.writeEndDocument(); writer.flush(); writer.close(); } return file; } private static void writeAttributePreservingWhitespace(Writer out, XMLStreamWriter writer, String name, String value) throws XMLStreamException, IOException { // 遵循XML规范处理属性值中的空白字符 if (value.contains("\t") || value.contains("\n") || value.contains("\r")) { writer.flush(); out.write(" "); // 写入属性名(确保名称合法) out.write(name); out.write("=\""); // 手动转义特殊字符 for (char c : value.toCharArray()) { switch (c) { case '\t': out.write("	"); break; case '\n': out.write(" "); break; case '\r': out.write(" "); break; case '"': out.write("""); break; case '&': out.write("&"); break; default: out.write(c); } } out.write("\""); } else { // 无特殊空白字符时,使用STAX原生方法 writer.writeAttribute(name, value); } }
为什么这个方法有效?
因为你直接绕过了STAX的writeAttribute默认逻辑,手动把换行符转成了XML实体 ,这样写入的XML文件就和DOM生成的格式一致了。后续不管用STAX还是DOM解析,都能正确还原出原始的换行符。
简化思路:提前转义属性值
如果你不想直接操作Writer,也可以在调用writeAttribute之前,手动把属性值里的特殊字符转义为XML实体:
// 手动转义属性值中的特殊字符 String escapedValue = value.replace("&", "&") .replace("\"", """) .replace("\n", " ") .replace("\r", " ") .replace("\t", "	"); writer.writeAttribute(name, escapedValue);
这种方式更简洁,但要注意转义顺序(比如先转义&,避免后续转义实体时产生新的&),否则可能生成不合法的XML。
测试验证
修改后的STAX写入方法生成的文件内容会和DOM完全一致,都是<element attribute="foo bar"/>,此时不管用STAX还是DOM读取,属性值都会还原成foo\nbar(带换行),和DOM的行为完全对齐。
内容来源于stack exchange

