This is an automated email from the ASF dual-hosted git repository.
Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 6b31f7df4ae6 CAMEL-24684: camel-groovy - reuse the groovyXml parser
configuration and write documents once (#26305)
6b31f7df4ae6 is described below
commit 6b31f7df4ae649788909e1be593fa832bff25ca8
Author: Federico Mariani <[email protected]>
AuthorDate: Mon Sep 14 12:51:23 2026 +0200
CAMEL-24684: camel-groovy - reuse the groovyXml parser configuration and
write documents once (#26305)
* perf: camel-groovy - reuse the SAX parser configuration of groovyXml and
marshal through a Writer
unmarshal created a new XmlParser per call; the SAXParserFactory lookup and
the feature setup it does (the JDK builds a throwaway parser per setFeature)
were 61% of the cpu and 64% of the bytes of a 1 KB parse, and a cached
factory made that parse 1.8x faster. The factory is now built once per data
format with exactly the setup of the no-arg XmlParser constructor
(FactorySupport.createSaxParserFactory, namespace aware, not validating,
secure processing on, DOCTYPE disallowed), each thread keeps one SAXParser
from it, and a cheap XmlParser wraps it per call; after the parse the
parser is detached from the XmlParser so it does not retain the document.
A test asserts a DOCTYPE/external entity document fails with the same
message as new XmlParser().
marshal of a map called String.getBytes() and OutputStream.write() once per
fragment with the platform charset and went through the TypeConverter for
every value. It now writes through one buffered OutputStreamWriter using the
exchange charset (UTF-8 by default) and uses toString() directly for
String, Number and Boolean values, which is what the converter returns for
them. A test pins the previous output for a document with nested maps,
lists, attributes and non-ASCII text, and checks the exchange charset is
honoured. The Node path also writes with the exchange charset.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
(cherry picked from commit e1caea87ac280afc88b3b2a3967239524902b731)
* perf: camel-groovy - render groovyXml documents in memory and write once
A Writer over the caller's stream cost 16 KB of buffers per call, more than
a typical document
(1 KB marshal 7.4 -> 8.8 us, 25 -> 36 KB allocated); the document is now
rendered into a
StringWriter and written once with the exchange charset.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* CAMEL-24684: camel-groovy - keep namespace prefixes, pool the SAX
parsers, narrow the converter shortcut, write UTF-8
XmlParser(SAXParser) leaves namespaceAware false, unlike the no-arg
constructor, so the reused
parser dropped every namespace prefix from the QNames; it is now set
explicitly and a prefixed
round trip is byte-identical to Groovy's own output. The per-thread parser
was never released
after the data format stopped; parsers are now borrowed from and returned
to a queue that
doStop() drains. The converter shortcut only applies to String and the
final JDK number and
boolean types, so a user converter for a Number subclass is honoured again.
The output stays
UTF-8: XmlNodePrinter writes no XML declaration, so a document in another
charset would carry
no encoding information.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* CAMEL-24684: camel-groovy - exact-class check for BigDecimal/BigInteger,
drop the unused parameter, fix the test Javadoc
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
.../camel/groovy/xml/GroovyXmlDataFormat.java | 158 ++++++++++++---
.../groovy/xml/GroovyXmlDataFormatReuseTest.java | 218 +++++++++++++++++++++
2 files changed, 348 insertions(+), 28 deletions(-)
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/groovy/xml/GroovyXmlDataFormat.java
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/xml/GroovyXmlDataFormat.java
index 3f8e17f1d4c8..ea13e5cf2537 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/groovy/xml/GroovyXmlDataFormat.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/xml/GroovyXmlDataFormat.java
@@ -16,22 +16,40 @@
*/
package org.apache.camel.groovy.xml;
+import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.io.OutputStreamWriter;
import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
import groovy.util.Node;
+import groovy.xml.FactorySupport;
import groovy.xml.XmlNodePrinter;
import groovy.xml.XmlParser;
import groovy.xml.XmlUtil;
import groovy.xml.slurpersupport.GPathResult;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.spi.DataFormatName;
import org.apache.camel.spi.annotations.Dataformat;
@@ -45,8 +63,24 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
private static final int VALUE = 2;
private static final int END_TAG = 3;
+ // replaces the XmlParser of a thread as content handler of its SAX parser
after a parse, so the parsed document
+ // is not retained by the parser
+ private static final DefaultHandler DETACHED = new DefaultHandler();
+
private boolean attributeMapping = true;
+ /**
+ * Configured like the factory of {@code new XmlParser()} (secure
processing, DOCTYPE disallowed, namespace aware,
+ * not validating), created once as its lookup and feature setup dominate
the cost of parsing small documents.
+ */
+ private volatile SAXParserFactory saxParserFactory;
+
+ /**
+ * A SAX parser is not thread safe but can parse sequentially: a parser is
borrowed for one unmarshal and returned,
+ * so at most one parser per concurrent unmarshal exists, and all of them
are released when the data format stops.
+ */
+ private final ConcurrentLinkedQueue<SAXParser> parsers = new
ConcurrentLinkedQueue<>();
+
public boolean isAttributeMapping() {
return attributeMapping;
}
@@ -78,8 +112,19 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
@Override
public Object unmarshal(Exchange exchange, InputStream stream) throws
Exception {
- XmlParser parser = new XmlParser();
- return parser.parse(stream);
+ SAXParser parser = parsers.poll();
+ if (parser == null) {
+ parser = createSaxParser();
+ }
+ try {
+ XmlParser xmlParser = new XmlParser(parser);
+ // XmlParser(SAXParser) does not set this, the no-arg constructor
does: keep namespace prefixes on the QNames
+ xmlParser.setNamespaceAware(true);
+ return xmlParser.parse(stream);
+ } finally {
+ parser.getXMLReader().setContentHandler(DETACHED);
+ parsers.offer(parser);
+ }
}
@Override
@@ -87,53 +132,92 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
return "groovyXml";
}
+ @Override
+ protected void doStart() throws Exception {
+ getSaxParserFactory();
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ parsers.clear();
+ }
+
+ private SAXParserFactory getSaxParserFactory() throws
ParserConfigurationException {
+ SAXParserFactory factory = saxParserFactory;
+ if (factory == null) {
+ synchronized (this) {
+ factory = saxParserFactory;
+ if (factory == null) {
+ // the same setup as the no-arg XmlParser constructor:
secure processing and DOCTYPE disallowed
+ factory = FactorySupport.createSaxParserFactory();
+ factory.setNamespaceAware(true);
+ factory.setValidating(false);
+ XmlUtil.setFeatureQuietly(factory,
XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ XmlUtil.setFeatureQuietly(factory,
"http://apache.org/xml/features/disallow-doctype-decl", true);
+ saxParserFactory = factory;
+ }
+ }
+ }
+ return factory;
+ }
+
+ private SAXParser createSaxParser() {
+ try {
+ SAXParserFactory factory = getSaxParserFactory();
+ // a factory is not guaranteed to be thread safe, and a thread
only creates one parser
+ synchronized (factory) {
+ return factory.newSAXParser();
+ }
+ } catch (ParserConfigurationException | SAXException e) {
+ throw new RuntimeCamelException(e);
+ }
+ }
+
private void serialize(Node node, OutputStream os) {
- PrintWriter pw = new PrintWriter(os);
+ // XmlNodePrinter writes no XML declaration, so the document must be
UTF-8 to be self-describing
+ PrintWriter pw = new PrintWriter(new OutputStreamWriter(os,
StandardCharsets.UTF_8));
XmlNodePrinter nodePrinter = new XmlNodePrinter(pw);
nodePrinter.setPreserveWhitespace(true);
nodePrinter.print(node);
}
- private void printLines(List<Line> lines, OutputStream os) throws
Exception {
+ private void printLines(List<Line> lines, Writer w) throws IOException {
// add missing root end tag
lines.add(new Line(lines.get(0).key, null, END_TAG, null));
int level = 0;
for (Line line : lines) {
int kind = line.kind;
if (kind == START_TAG) {
- String pad = StringHelper.padString(level);
- os.write(pad.getBytes());
- os.write("<".getBytes());
- os.write(line.key.getBytes());
+ w.write(StringHelper.padString(level));
+ w.write('<');
+ w.write(line.key);
if (line.attrs != null) {
StringJoiner sj = new StringJoiner(" ");
for (var a : line.attrs.entrySet()) {
sj.add(a.getKey() + "=\"" + a.getValue() + "\"");
}
if (sj.length() > 0) {
- os.write(" ".getBytes());
- os.write(sj.toString().getBytes());
+ w.write(' ');
+ w.write(sj.toString());
}
}
- os.write(">\n".getBytes());
+ w.write(">\n");
level++;
} else if (kind == END_TAG) {
level--;
- String pad = StringHelper.padString(level);
- os.write(pad.getBytes());
- os.write("</".getBytes());
- os.write(line.key.getBytes());
- os.write(">\n".getBytes());
+ w.write(StringHelper.padString(level));
+ w.write("</");
+ w.write(line.key);
+ w.write(">\n");
} else {
- String pad = StringHelper.padString(level);
- os.write(pad.getBytes());
- os.write("<".getBytes());
- os.write(line.key.getBytes());
- os.write(">".getBytes());
- os.write(line.value.getBytes());
- os.write("</".getBytes());
- os.write(line.key.getBytes());
- os.write(">\n".getBytes());
+ w.write(StringHelper.padString(level));
+ w.write('<');
+ w.write(line.key);
+ w.write('>');
+ w.write(line.value);
+ w.write("</");
+ w.write(line.key);
+ w.write(">\n");
}
}
}
@@ -141,7 +225,25 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
private void serialize(Exchange exchange, Map<String, Object> map,
OutputStream os) throws Exception {
List<Line> lines = new ArrayList<>();
doSerialize(exchange.getContext(), map, lines);
- printLines(lines, os);
+ // render in memory and write once: a Writer over the stream costs 16
KB of buffers per call, which is more
+ // than a typical document, and the Marshal EIP already writes into a
memory stream
+ StringWriter w = new StringWriter(lines.size() * 32);
+ printLines(lines, w);
+ os.write(w.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String asString(CamelContext context, Object value) {
+ if (value instanceof String s) {
+ return s;
+ }
+ // final JDK types (or exactly BigDecimal/BigInteger, which are not
final) no user converter can target: the
+ // type converter would return toString() for them
+ if (value instanceof Integer || value instanceof Long || value
instanceof Double || value instanceof Boolean
+ || value instanceof Short || value instanceof Byte || value
instanceof Float
+ || value.getClass() == BigDecimal.class || value.getClass() ==
BigInteger.class) {
+ return value.toString();
+ }
+ return context.getTypeConverter().convertTo(String.class, value);
}
private void doSerialize(CamelContext context, Map<String, Object> map,
List<Line> lines) {
@@ -151,7 +253,7 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
if (attributeMapping) {
for (String key : map.keySet()) {
if (key.startsWith("_") || key.startsWith("@")) {
- String val =
context.getTypeConverter().convertTo(String.class, map.get(key));
+ String val = asString(context, map.get(key));
if (val != null) {
val = val.trim();
if (!val.isBlank()) {
@@ -193,7 +295,7 @@ public class GroovyXmlDataFormat extends ServiceSupport
implements DataFormat, D
} else if (e.getValue() instanceof List cl) {
doSerialize(context, cl, key, attrs, lines, root);
} else {
- String val =
context.getTypeConverter().convertTo(String.class, e.getValue());
+ String val = asString(context, e.getValue());
if (val != null) {
val = val.trim();
if (!val.isBlank()) {
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/groovy/xml/GroovyXmlDataFormatReuseTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/groovy/xml/GroovyXmlDataFormatReuseTest.java
new file mode 100644
index 000000000000..86314e9fa02d
--- /dev/null
+++
b/components/camel-groovy/src/test/java/org/apache/camel/groovy/xml/GroovyXmlDataFormatReuseTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.groovy.xml;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.xml.sax.SAXParseException;
+
+import groovy.namespace.QName;
+import groovy.util.Node;
+import groovy.xml.XmlNodePrinter;
+import groovy.xml.XmlParser;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The groovyXml data format reuses its SAX parser configuration and writes
UTF-8.
+ */
+public class GroovyXmlDataFormatReuseTest {
+
+ private static final String BOOKS = """
+ <library>
+ <book id="bk101"><title>No Title</title></book>
+ <book id="bk102"><title>1984</title></book>
+ </library>
+ """;
+
+ private static final String XXE = """
+ <?xml version="1.0"?>
+ <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
+ <foo>&xxe;</foo>
+ """;
+
+ // the output of the data format before the marshal path was changed to
write through a Writer
+ private static final String EXPECTED_XML = """
+ <library>
+ <book id="bk101">
+ <title>No Title</title>
+ <year>1925</year>
+ <available>true</available>
+ <note>Ünïcödé € 中</note>
+ <price>12.5</price>
+ </book>
+ <book id="bk102" lang="en">
+ <title>1984</title>
+ <tags>
+ </tags>
+ <tags>
+ </tags>
+ <name>Città</name>
+ <country>
+ <code>IT</code>
+ </country>
+ </book>
+ <name>Biblioteca</name>
+ </library>
+ """;
+
+ private CamelContext context;
+ private GroovyXmlDataFormat dataFormat;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ context = new DefaultCamelContext();
+ context.start();
+ dataFormat = new GroovyXmlDataFormat();
+ dataFormat.start();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ dataFormat.stop();
+ context.stop();
+ }
+
+ private Node unmarshal(String xml) throws Exception {
+ return (Node) dataFormat.unmarshal(new DefaultExchange(context),
+ new
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ public void testDoctypeIsRejectedLikeXmlParser() throws Exception {
+ SAXParseException expected = assertThrows(SAXParseException.class, ()
-> new XmlParser().parseText(XXE));
+ SAXParseException actual = assertThrows(SAXParseException.class, () ->
unmarshal(XXE));
+ assertEquals(expected.getMessage(), actual.getMessage());
+
+ // the parser of the thread is still usable after a failed parse
+ assertEquals(2, unmarshal(BOOKS).children().size());
+ }
+
+ @Test
+ public void testParserIsReusedSequentially() throws Exception {
+ for (int i = 0; i < 3; i++) {
+ Node library = unmarshal(BOOKS);
+ assertEquals(2, library.children().size());
+ assertEquals("bk102", ((Node)
library.children().get(1)).attribute("id"));
+ }
+ }
+
+ @Test
+ public void testUnmarshalConcurrently() throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(8);
+ try {
+ List<Future<?>> futures = new ArrayList<>();
+ for (int i = 0; i < 8; i++) {
+ futures.add(pool.submit(() -> {
+ for (int j = 0; j < 20; j++) {
+ Node library = unmarshal(BOOKS);
+ assertEquals(2, library.children().size());
+ assertEquals("No Title", ((Node)
library.children().get(0)).text());
+ }
+ return null;
+ }));
+ }
+ for (Future<?> f : futures) {
+ f.get(30, TimeUnit.SECONDS);
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testMarshalMapIsByteIdentical() throws Exception {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ dataFormat.marshal(new DefaultExchange(context), library(), bos);
+ assertArrayEquals(EXPECTED_XML.getBytes(StandardCharsets.UTF_8),
bos.toByteArray());
+ }
+
+ @Test
+ public void testNamespacePrefixesSurviveTheRoundTrip() throws Exception {
+ // XmlParser(SAXParser) leaves namespaceAware false unless it is set:
prefixes would be dropped from the QNames
+ String xml = "<ns:library
xmlns:ns=\"urn:x\"><ns:book>a</ns:book></ns:library>";
+ Exchange exchange = new DefaultExchange(context);
+ Node node = (Node) dataFormat.unmarshal(exchange, new
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+ assertEquals("ns", ((QName) node.name()).getPrefix());
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ dataFormat.marshal(exchange, node, bos);
+ String out = bos.toString(StandardCharsets.UTF_8);
+ assertEquals(new String(marshalWithGroovy(xml),
StandardCharsets.UTF_8), out);
+ assertTrue(out.contains("<ns:library xmlns:ns=\"urn:x\">"), out);
+ assertTrue(out.contains("<ns:book>a</ns:book>"), out);
+ }
+
+ private static byte[] marshalWithGroovy(String xml) throws Exception {
+ Node node = new XmlParser().parse(new
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ PrintWriter pw = new PrintWriter(new OutputStreamWriter(bos,
StandardCharsets.UTF_8));
+ XmlNodePrinter printer = new XmlNodePrinter(pw);
+ printer.setPreserveWhitespace(true);
+ printer.print(node);
+ return bos.toByteArray();
+ }
+
+ private static Map<String, Object> library() {
+ Map<String, Object> b1 = new LinkedHashMap<>();
+ b1.put("_id", "bk101");
+ b1.put("title", "No Title");
+ b1.put("year", 1925);
+ b1.put("available", true);
+ b1.put("note", "Ünïcödé € 中");
+ b1.put("price", 12.5d);
+ b1.put("empty", " ");
+ b1.put("nothing", null);
+ Map<String, Object> b2 = new LinkedHashMap<>();
+ b2.put("@id", "bk102");
+ b2.put("@lang", " en ");
+ b2.put("title", "1984");
+ b2.put("tags", new ArrayList<>(Arrays.asList("dystopia", "classic")));
+ Map<String, Object> publisher = new LinkedHashMap<>();
+ publisher.put("name", "Città");
+ publisher.put("country", List.of(Map.of("code", "IT")));
+ b2.put("publisher", publisher);
+ Map<String, Object> library = new LinkedHashMap<>();
+ library.put("book", new ArrayList<>(Arrays.asList(b1, b2)));
+ library.put("name", "Biblioteca");
+ Map<String, Object> root = new LinkedHashMap<>();
+ root.put("library", library);
+ return root;
+ }
+}