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;
+    }
+}

Reply via email to