From 449b85f446b390983931fe816bb33138f3ad146a Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 10 Oct 2025 17:03:34 +0200 Subject: [PATCH] Avoid overhead for parsing plain values and simple placeholders Closes gh-35594 --- .../util/PlaceholderParser.java | 195 ++++++++---------- .../util/PropertyPlaceholderHelper.java | 5 +- .../util/PlaceholderParserTests.java | 23 +-- 3 files changed, 98 insertions(+), 125 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java index d6cd4356401..d50625012eb 100644 --- a/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java +++ b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java @@ -18,7 +18,6 @@ package org.springframework.util; import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; @@ -36,10 +35,10 @@ import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver; * that can be resolved using a {@link PlaceholderResolver PlaceholderResolver}, * ${ the prefix, and } the suffix. * - *

A placeholder can also have a default value if its key does not represent a - * known property. The default value is separated from the key using a - * {@code separator}. For instance {@code ${name:John}} resolves to {@code John} if - * the placeholder resolver does not provide a value for the {@code name} + *

A placeholder can also have a default value if its key does not represent + * a known property. The default value is separated from the key using a + * {@code separator}. For instance {@code ${name:John}} resolves to {@code John} + * if the placeholder resolver does not provide a value for the {@code name} * property. * *

Placeholders can also have a more complex structure, and the resolution of @@ -50,13 +49,14 @@ import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver; * must be rendered as is, the placeholder can be escaped using an {@code escape} * character. For instance {@code \${name}} resolves as {@code ${name}}. * - *

The prefix, suffix, separator, and escape characters are configurable. Only - * the prefix and suffix are mandatory, and the support for default values or - * escaping is conditional on providing non-null values for them. + *

The prefix, suffix, separator, and escape characters are configurable. + * Only the prefix and suffix are mandatory, and the support for default values + * or escaping is conditional on providing non-null values for them. * *

This parser makes sure to resolves placeholders as lazily as possible. * * @author Stephane Nicoll + * @author Juergen Hoeller * @since 6.2 */ final class PlaceholderParser { @@ -120,51 +120,47 @@ final class PlaceholderParser { * @return the supplied value with placeholders replaced inline */ public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) { - Assert.notNull(value, "'value' must not be null"); - ParsedValue parsedValue = parse(value); + List parts = parse(value, false); + if (parts == null) { + return value; + } + ParsedValue parsedValue = new ParsedValue(value, parts); PartResolutionContext resolutionContext = new PartResolutionContext(placeholderResolver, this.prefix, this.suffix, this.ignoreUnresolvablePlaceholders, candidate -> parse(candidate, false)); return parsedValue.resolve(resolutionContext); } - /** - * Parse the specified value. - * @param value the value containing the placeholders to be replaced - * @return the different parts that have been identified - */ - ParsedValue parse(String value) { - List parts = parse(value, false); - return new ParsedValue(value, parts); - } - - private List parse(String value, boolean inPlaceholder) { - LinkedList parts = new LinkedList<>(); + private @Nullable List parse(String value, boolean inPlaceholder) { int startIndex = nextStartPrefix(value, 0); if (startIndex == -1) { - Part part = (inPlaceholder ? createSimplePlaceholderPart(value) : new TextPart(value)); - parts.add(part); - return parts; + return null; } + List parts = new ArrayList<>(4); int position = 0; while (startIndex != -1) { int endIndex = nextValidEndPrefix(value, startIndex); - if (endIndex == -1) { // Not a valid placeholder, consume the prefix and continue + if (endIndex == -1) { // Not a valid placeholder, consume the prefix and continue addText(value, position, startIndex + this.prefix.length(), parts); position = startIndex + this.prefix.length(); startIndex = nextStartPrefix(value, position); } - else if (isEscaped(value, startIndex)) { // Not a valid index, accumulate and skip the escape character + else if (isEscaped(value, startIndex)) { // Not a valid index, accumulate and skip the escape character addText(value, position, startIndex - 1, parts); addText(value, startIndex, startIndex + this.prefix.length(), parts); position = startIndex + this.prefix.length(); startIndex = nextStartPrefix(value, position); } - else { // Found valid placeholder, recursive parsing + else { // Found valid placeholder, recursive parsing addText(value, position, startIndex, parts); String placeholder = value.substring(startIndex + this.prefix.length(), endIndex); List placeholderParts = parse(placeholder, true); - parts.addAll(placeholderParts); + if (placeholderParts == null) { + parts.add(createSimplePlaceholderPart(placeholder)); + } + else { + parts.addAll(placeholderParts); + } startIndex = nextStartPrefix(value, endIndex + this.suffix.length()); position = endIndex + this.suffix.length(); } @@ -241,29 +237,6 @@ final class PlaceholderParser { return new ParsedSection(buffer.toString(), null); } - private static void addText(String value, int start, int end, LinkedList parts) { - if (start > end) { - return; - } - String text = value.substring(start, end); - if (!text.isEmpty()) { - if (!parts.isEmpty()) { - Part current = parts.removeLast(); - if (current instanceof TextPart textPart) { - parts.add(new TextPart(textPart.text() + text)); - } - else { - parts.add(current); - parts.add(new TextPart(text)); - } - } - else { - parts.add(new TextPart(text)); - } - } - } - - private int nextStartPrefix(String value, int index) { return value.indexOf(this.prefix, index); } @@ -296,15 +269,46 @@ final class PlaceholderParser { return (this.escape != null && index > 0 && value.charAt(index - 1) == this.escape); } - record ParsedSection(String key, @Nullable String fallback) { + private static void addText(String value, int start, int end, List parts) { + if (start >= end) { + return; + } + String text = value.substring(start, end); + if (!parts.isEmpty() && parts.get(parts.size() - 1) instanceof TextPart textPart) { + parts.set(parts.size() - 1, new TextPart(textPart.text() + text)); + } + else { + parts.add(new TextPart(text)); + } + } + + + /** + * A representation of the parsing of an input string. + * @param text the raw input string + * @param parts the parts that appear in the string, in order + */ + private record ParsedValue(String text, List parts) { + public String resolve(PartResolutionContext resolutionContext) { + try { + return Part.resolveAll(this.parts, resolutionContext); + } + catch (PlaceholderResolutionException ex) { + throw ex.withValue(this.text); + } + } + } + + + private record ParsedSection(String key, @Nullable String fallback) { } /** * Provide the necessary context to handle and resolve underlying placeholders. */ - static class PartResolutionContext implements PlaceholderResolver { + private static class PartResolutionContext implements PlaceholderResolver { private final String prefix; @@ -319,7 +323,6 @@ final class PlaceholderParser { @Nullable private Set visitedPlaceholders; - PartResolutionContext(PlaceholderResolver resolver, String prefix, String suffix, boolean ignoreUnresolvablePlaceholders, Function> parser) { this.prefix = prefix; @@ -352,7 +355,7 @@ final class PlaceholderParser { return this.prefix + text + this.suffix; } - public List parse(String text) { + public @Nullable List parse(String text) { return this.parser.apply(text); } @@ -367,17 +370,17 @@ final class PlaceholderParser { } public void removePlaceholder(String placeholder) { - Assert.state(this.visitedPlaceholders != null, "Visited placeholders must not be null"); - this.visitedPlaceholders.remove(placeholder); + if (this.visitedPlaceholders != null) { + this.visitedPlaceholders.remove(placeholder); + } } - } /** * A part is a section of a String containing placeholders to replace. */ - interface Part { + private interface Part { /** * Resolve this part using the specified {@link PartResolutionContext}. @@ -408,30 +411,12 @@ final class PlaceholderParser { } - /** - * A representation of the parsing of an input string. - * @param text the raw input string - * @param parts the parts that appear in the string, in order - */ - record ParsedValue(String text, List parts) { - - public String resolve(PartResolutionContext resolutionContext) { - try { - return Part.resolveAll(this.parts, resolutionContext); - } - catch (PlaceholderResolutionException ex) { - throw ex.withValue(this.text); - } - } - } - - /** * A base {@link Part} implementation. */ - abstract static class AbstractPart implements Part { + private abstract static class AbstractPart implements Part { - private final String text; + final String text; protected AbstractPart(String text) { this.text = text; @@ -454,29 +439,19 @@ final class PlaceholderParser { @Nullable protected String resolveRecursively(PartResolutionContext resolutionContext, String key) { String resolvedValue = resolutionContext.resolvePlaceholder(key); - if (resolvedValue != null) { - resolutionContext.flagPlaceholderAsVisited(key); - // Let's check if we need to recursively resolve that value - List nestedParts = resolutionContext.parse(resolvedValue); - String value = toText(nestedParts); - if (!isTextOnly(nestedParts)) { - value = new ParsedValue(resolvedValue, nestedParts).resolve(resolutionContext); - } - resolutionContext.removePlaceholder(key); - return value; + if (resolvedValue == null) { + // Not found + return null; } - // Not found - return null; - } - - private boolean isTextOnly(List parts) { - return parts.stream().allMatch(TextPart.class::isInstance); - } - - private String toText(List parts) { - StringBuilder sb = new StringBuilder(); - parts.forEach(part -> sb.append(part.text())); - return sb.toString(); + // Let's check if we need to recursively resolve that value + List nestedParts = resolutionContext.parse(resolvedValue); + if (nestedParts == null) { + return resolvedValue; + } + resolutionContext.flagPlaceholderAsVisited(key); + String value = new ParsedValue(resolvedValue, nestedParts).resolve(resolutionContext); + resolutionContext.removePlaceholder(key); + return value; } } @@ -484,7 +459,7 @@ final class PlaceholderParser { /** * A {@link Part} implementation that does not contain a valid placeholder. */ - static class TextPart extends AbstractPart { + private static class TextPart extends AbstractPart { /** * Create a new instance. @@ -496,7 +471,7 @@ final class PlaceholderParser { @Override public String resolve(PartResolutionContext resolutionContext) { - return text(); + return this.text; } } @@ -505,7 +480,7 @@ final class PlaceholderParser { * A {@link Part} implementation that represents a single placeholder with * a hard-coded fallback. */ - static class SimplePlaceholderPart extends AbstractPart { + private static class SimplePlaceholderPart extends AbstractPart { private final String key; @@ -533,13 +508,13 @@ final class PlaceholderParser { else if (this.fallback != null) { return this.fallback; } - return resolutionContext.handleUnresolvablePlaceholder(this.key, text()); + return resolutionContext.handleUnresolvablePlaceholder(this.key, this.text); } @Nullable private String resolveRecursively(PartResolutionContext resolutionContext) { - if (!this.text().equals(this.key)) { - String value = resolveRecursively(resolutionContext, this.text()); + if (!this.text.equals(this.key)) { + String value = resolveRecursively(resolutionContext, this.text); if (value != null) { return value; } @@ -553,7 +528,7 @@ final class PlaceholderParser { * A {@link Part} implementation that represents a single placeholder * containing nested placeholders. */ - static class NestedPlaceholderPart extends AbstractPart { + private static class NestedPlaceholderPart extends AbstractPart { private final List keyParts; @@ -582,7 +557,7 @@ final class PlaceholderParser { else if (this.defaultParts != null) { return Part.resolveAll(this.defaultParts, resolutionContext); } - return resolutionContext.handleUnresolvablePlaceholder(resolvedKey, text()); + return resolutionContext.handleUnresolvablePlaceholder(resolvedKey, this.text); } } diff --git a/spring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java b/spring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java index f262435aba4..856d691fa2c 100644 --- a/spring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java +++ b/spring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java @@ -97,7 +97,7 @@ public class PropertyPlaceholderHelper { * @param properties the {@code Properties} to use for replacement * @return the supplied value with placeholders replaced inline */ - public String replacePlaceholders(String value, final Properties properties) { + public String replacePlaceholders(String value, Properties properties) { Assert.notNull(properties, "'properties' must not be null"); return replacePlaceholders(value, properties::getProperty); } @@ -111,9 +111,10 @@ public class PropertyPlaceholderHelper { */ public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) { Assert.notNull(value, "'value' must not be null"); - return parseStringValue(value, placeholderResolver); + return this.parser.replacePlaceholders(value, placeholderResolver); } + @Deprecated(since = "6.2.12", forRemoval = true) protected String parseStringValue(String value, PlaceholderResolver placeholderResolver) { return this.parser.replacePlaceholders(value, placeholderResolver); } diff --git a/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java index a882a6c56ce..4d7073b7636 100644 --- a/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java +++ b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java @@ -26,8 +26,6 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InOrder; -import org.springframework.util.PlaceholderParser.ParsedValue; -import org.springframework.util.PlaceholderParser.TextPart; import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver; import static org.assertj.core.api.Assertions.assertThat; @@ -43,10 +41,11 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; * * @author Stephane Nicoll * @author Sam Brannen + * @author Juergen Hoeller */ class PlaceholderParserTests { - @Nested // Tests with only the basic placeholder feature enabled + @Nested // Tests with only the basic placeholder feature enabled class OnlyPlaceholderTests { private final PlaceholderParser parser = new PlaceholderParser("${", "}", null, null, true); @@ -82,7 +81,7 @@ class PlaceholderParserTests { Map properties = Map.of( "p1", "v1", "p2", "v2", - "p3", "${p1}:${p2}", // nested placeholders + "p3", "${p1}:${p2}", // nested placeholders "p4", "${p3}", // deeply nested placeholders "p5", "${p1}:${p2}:${bogus}"); // unresolvable placeholder assertThat(this.parser.replacePlaceholders(text, properties::get)).isEqualTo(expected); @@ -154,14 +153,13 @@ class PlaceholderParserTests { @Test void textWithInvalidPlaceholderSyntaxIsMerged() { String text = "test${of${with${and${"; - ParsedValue parsedValue = this.parser.parse(text); - assertThat(parsedValue.parts()).singleElement().isInstanceOfSatisfying(TextPart.class, - textPart -> assertThat(textPart.text()).isEqualTo(text)); + assertThat(this.parser.replacePlaceholders(text, + placeholder -> {throw new UnsupportedOperationException();})).isEqualTo(text); } - } - @Nested // Tests with the use of a separator + + @Nested // Tests with the use of a separator class DefaultValueTests { private final PlaceholderParser parser = new PlaceholderParser("${", "}", ":", null, true); @@ -195,7 +193,7 @@ class PlaceholderParserTests { Map properties = Map.of( "p1", "v1", "p2", "v2", - "p3", "${p1}:${p2}", // nested placeholders + "p3", "${p1}:${p2}", // nested placeholders "p4", "${p3}", // deeply nested placeholders "p5", "${p1}:${p2}:${bogus}", // unresolvable placeholder "p6", "${p1}:${p2}:${bogus:def}"); // unresolvable w/ default @@ -259,9 +257,9 @@ class PlaceholderParserTests { assertThat(this.parser.replacePlaceholders("${invalid:${firstName}}", resolver)).isEqualTo("John"); verifyPlaceholderResolutions(resolver, "invalid", "firstName"); } - } + /** * Tests that use the escape character. */ @@ -341,9 +339,9 @@ class PlaceholderParserTests { Arguments.of("${service/host/${app.environment}/name:\\value}", "https://example.com/qa/name"), Arguments.of("${service/host/${name\\:value}/}", "${service/host/${name:value}/}")); } - } + @Nested class ExceptionTests { @@ -378,7 +376,6 @@ class PlaceholderParserTests { .withMessage("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\" <-- \"${p3}\"") .withNoCause(); } - }