Spring string placeholder parser PropertyPlaceholderHelper

Posted by developer on Thu, 14 Oct 2021 08:49:19 +0200

Spring property placeholder parser PropertyPlaceholderHelper source code reading

PropertyPlaceholderHelper is used to process the "${}" placeholder in the string, such as obtaining the property value defined in the corresponding property file through the @ Value("${}") annotation (but @ Value("#{}") cannot be processed, indicating that it is usually used to obtain the properties of bean s through spiel expressions).

This class is a simple tool class without inheritance and implementation, and is simple without dependency. It does not depend on any other classes in the Spring framework.

1, Practice

Let's see how to use this class first.

Constructor

The main constructors of this class are as follows:

private static final Map<String, String> wellKnownSimplePrefixes = new HashMap<>(4);

static {
    wellKnownSimplePrefixes.put("}", "{");
    wellKnownSimplePrefixes.put("]", "[");
    wellKnownSimplePrefixes.put(")", "(");
}

public PropertyPlaceholderHelper(String placeholderPrefix, String placeholderSuffix,
			@Nullable String valueSeparator, boolean ignoreUnresolvablePlaceholders) {

	Assert.notNull(placeholderPrefix, "'placeholderPrefix' must not be null");
	Assert.notNull(placeholderSuffix, "'placeholderSuffix' must not be null");
	this.placeholderPrefix = placeholderPrefix;
	this.placeholderSuffix = placeholderSuffix;
	String simplePrefixForSuffix = wellKnownSimplePrefixes.get(this.placeholderSuffix);
	if (simplePrefixForSuffix != null && this.placeholderPrefix.endsWith(simplePrefixForSuffix)) {
		this.simplePrefix = simplePrefixForSuffix;
	}
	else {
		this.simplePrefix = this.placeholderPrefix;
	}
	this.valueSeparator = valueSeparator;
	this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
}

The construction method contains four parameters:

  • placeholderPrefix, placeholder prefix;
  • placeholderSuffix, placeholder suffix;
  • valueSeparator, the default value separator. If the resolution fails, the default value is taken. For example, if the parameter is: for the string ${app.name:fsx} to be parsed, if ${app.name} parsing fails, the parsing result is fsx;
  • Ignoreunresolved placeholders: whether to ignore placeholders that failed to resolve;

In addition, simplePrefix is also calculated in the constructor. If the suffix is "}", "]", ")", and the prefix ends with "{", "[", "(", then simplePrefix is "{", "[", "(", otherwise it is placeholderPrefix. As for the function of simplePrefix, it will be analyzed later.

You can create a new PropertyPlaceholderHelper using the constructor above,

// The placeholder prefix is "${", the suffix is "}", and the default value separator is ":". Placeholders that fail to resolve will not be ignored, that is, an error will be reported when the resolution fails
PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":",  false);

Core method

PropertyPlaceholderHelper core method

/**
  * Replace all placeholders in the format ${name} in the string value, and the value of the placeholder is provided by placeholderResolver;
  * @param value String containing placeholder to be replaced
  * @param placeholderResolver Provide placeholder replacement values
  */
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
	Assert.notNull(value, "'value' must not be null");
	return parseStringValue(value, placeholderResolver, null);
}

PropertyPlaceholderHelper is a simple tool class and does not contain file configuration properties such as application.yml. Therefore, the replacement value of the placeholder to be replaced needs to be provided through PlaceholderResolver. See the source code of PlaceholderResolver class:

/**
 * Policy interface for resolving replacement values of placeholders in strings
 */
@FunctionalInterface
public interface PlaceholderResolver {

    /**
     * Resolves the supplied placeholder name to a replacement value
     * @param placeholderName The name of the placeholder to be resolved
     */
    @Nullable
    String resolvePlaceholder(String placeholderName);
}

It can be seen that this interface is a functional interface. It is necessary to provide a function to resolve the placeholder name into a replacement value;
For example:

PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":",  false);
Map<String, String> map = Maps.newHashMap();
map.put("app.name", "fsx");
map.put("user.home", "app.name"); 
map.put("app.key", "${user.home}"); 

// The value of the placeholder is provided by map and fsx is output
System.err.println(helper.replacePlaceholders("${app.name}", map::get));

// Output fsx, support nesting
System.err.println(helper.replacePlaceholders("${${user.home}}", map::get));

// Output fsx+app.name, which is similar to c language printf. replacePlaceholders only replaces the value of the placeholder, and the other characters are output intact
System.err.println(helper.replacePlaceholders("${app.name}+${user.home}", map::get));

// Output app.name, support recursive parsing
System.err.println(helper.replacePlaceholders("${app.key}", map::get));

// Output ${app.user}. The map does not contain the value of app.user, but ignoreunresolved placeholders is true. Placeholders that cannot be resolved are not processed
System.err.println(new PropertyPlaceholderHelper("${", "}", ":",  true).replacePlaceholders("${app.user}", map::get));

// An error is reported. The map does not contain the value of app.user and ignoreunresolved placeholders is false
System.err.println(helper.replacePlaceholders("${app.user}", map::get));
fsx
fsx
fsx+app.name
app.name
${app.user}
Exception in thread "main" java.lang.IllegalArgumentException: Could not resolve placeholder 'app.user' in value "${app.user}"
	at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:178)
	at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:124)
	at com.zte.iscp.purchasecoordination.adapter.util.SupplierUtils.main(SupplierUtils.java:212)

2, Source code reading

Let's look at the source code of the spring boss.
The replacePlaceholders function internally calls the function parseStringValue;

protected String parseStringValue(
        String value, PlaceholderResolver placeholderResolver, @Nullable Set<String> visitedPlaceholders) {

    // Find the first placeholder prefix in value
    int startIndex = value.indexOf(this.placeholderPrefix);
    // If there is no placeholder prefix, it means that the value does not contain a placeholder and does not need to be replaced. It is returned directly
    if (startIndex == -1) {
        return value;
    }

    StringBuilder result = new StringBuilder(value);
    while (startIndex != -1) {
    	// Key, startIndex represents the outermost placeholder prefix index, and endIndex represents the outermost placeholder suffix index
        int endIndex = findPlaceholderEndIndex(result, startIndex);
        if (endIndex != -1) {
        	// Placeholder represents the name of the outermost placeholder, which may be nested with placeholders
            String placeholder = result.substring(startIndex + this.placeholderPrefix.length(), endIndex);
            String originalPlaceholder = placeholder;
            if (visitedPlaceholders == null) {
                visitedPlaceholders = new HashSet<>(4);
            }
            // Prevent placeholder circular references
            if (!visitedPlaceholders.add(originalPlaceholder)) {
                throw new IllegalArgumentException(
                        "Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
            }
            // Nested placeholders should be resolved before resolving the outermost placeholders. Here, the inner nested placeholders in the outermost placeholder name are resolved recursively
            placeholder = parseStringValue(placeholder, placeholderResolver, visitedPlaceholders);
            // After the nested placeholder is resolved, the placeholder name placeholder does not contain a placeholder, and you can directly obtain the replacement value from the placeholder resolver
            String propVal = placeholderResolver.resolvePlaceholder(placeholder);
            // With default values
            if (propVal == null && this.valueSeparator != null) {
                int separatorIndex = placeholder.indexOf(this.valueSeparator);
                if (separatorIndex != -1) {
                	// If the default value is included, the placeholder contains the default value separator and the default value;
                	// For example, for ${app.name:name}, if the placeholder is app.name:name, the above code will fail to parse;
                	// The advantage of this is that if the default value contains placeholders, they belong to nested placeholders, which will be parsed together by recursive parsing above;
              
                	// Here, you need to get the actual placeholder name and resolve it again. The actual placeholder is the actual placeholder name, and the value in the example is app.name  	
                    String actualPlaceholder = placeholder.substring(0, separatorIndex);
                    // Get the default value, for example, ${app.name:name} the default value is name
                    String defaultValue = placeholder.substring(separatorIndex + this.valueSeparator.length());
                    propVal = placeholderResolver.resolvePlaceholder(actualPlaceholder);
                    // No replacement value, take the default value
                    if (propVal == null) {
                        propVal = defaultValue;
                    }
                }
            }
            if (propVal != null) {
                // The replacement value may also contain placeholders and need to be resolved again by recursive calls
                // For example, after ${app. Key} parses app.key into ${user.home}, the string still contains placeholders and needs to be parsed again
                propVal = parseStringValue(propVal, placeholderResolver, visitedPlaceholders);
                result.replace(startIndex, endIndex + this.placeholderSuffix.length(), propVal);
                if (logger.isTraceEnabled()) {
                    logger.trace("Resolved placeholder '" + placeholder + "'");
                }
                // Continue parsing the next outermost placeholder
                // For example, ${app.name}+${user.home},
                // The above program only parses ${app.name} and ${user.home} needs to continue parsing through the while loop
                startIndex = result.indexOf(this.placeholderPrefix, startIndex + propVal.length());
            }
            // Ignore the failed placeholder and continue to resolve the next placeholder
            else if (this.ignoreUnresolvablePlaceholders) {
                startIndex = result.indexOf(this.placeholderPrefix, endIndex + this.placeholderSuffix.length());
            }
            // If it cannot be ignored, an error is reported
            else {
                throw new IllegalArgumentException("Could not resolve placeholder '" +
                        placeholder + "'" + " in value \"" + value + "\"");
            }
            visitedPlaceholders.remove(originalPlaceholder);
        }
        else {
            startIndex = -1;
        }
    }
    return result.toString();
}

reading notes

I have to say that the code written by the Spring boss is concise and beautiful, which is worth learning. There are several important aspects of the above source code:

  • On the whole, the method of circular + recursive parsing placeholders is similar to depth first search. First, the string to be parsed may contain multiple placeholders to be parsed, so use while to parse each placeholder in the outermost layer; secondly, placeholders may be nested in each string, so recursively parse the inner placeholders before parsing the outer placeholders;
  • Because the placeholder replacement value may contain placeholders, if the placeholder contained is the same as the source placeholder, it will not recurse indefinitely, and the program will stackoverflow, just like the de duplication problem in depth first search. For example, the following code:
PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":",  false);
Map<String, String> map = Maps.newHashMap();
map.put("app.name", "${app.name}+fsx");
System.err.println(helper.replacePlaceholders("${app.name}", map::get));

First resolve the placeholder ${app.name}, and the resolution result is ${app.name}+fsx. If the placeholder is included, resolve ${app.name}, and then resolve ${app.name}...
PropertyPlaceholderHelper solves this problem by parsing the nested placeholder value and passing the outer placeholder to the inner layer through the parameter visitedPlaceholders. If the inner layer encounters the same placeholder, it indicates that a placeholder circular reference occurs, and an error is reported;

  • For the default value, PropertyPlaceholderHelper does not first resolve the placeholder and take the default value after the resolution fails. Instead, it first resolves the placeholder name and the default value, and then resolves the actual placeholder. If the actual placeholder resolution fails, it takes the default value. The good thing is that the placeholder name and the default value are resolved together, and the default value including nested placeholders will be resolved together The resolution is complete; therefore, the default value can include placeholders like the placeholder name, such as ${app.user:${app.name}};

In addition, another important function findPlaceholderEndIndex is not analyzed. This function is to find the suffix index matching the placeholder prefix. The source code is as follows:

/**
  * Find suffix index in character sequence buf that matches prefix whose index is startIndex
  */
private int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
    int index = startIndex + this.placeholderPrefix.length();
    // Internal nested placeholders do not match the number of prefixes
    int withinNestedPlaceholder = 0;
    while (index < buf.length()) {
    	// buf matches the character sequence starting with index with the placeholder suffix
        if (StringUtils.substringMatch(buf, index, this.placeholderSuffix)) {
        	// If the inner nested placeholder is greater than 0, the placeholder suffix belongs to the inner nested placeholder suffix
        	// For example, ${${app.name}}, when looking for the suffix matching the first ${, the first} encountered is
            if (withinNestedPlaceholder > 0) {
            	// Internal nested placeholders match successfully, and the number is reduced by 1
                withinNestedPlaceholder--;
                index = index + this.placeholderSuffix.length();
            }
            // If the internal nested placeholders have been matched, the suffix is the result
            else {
                return index;
            }
        }
        // If a placeholder prefix is encountered, the placeholder is an internally nested placeholder
        // As for why simplePrefix is used instead of placeholder prefix, the author's intention is not clear for the time being
        else if (StringUtils.substringMatch(buf, index, this.simplePrefix)) {
            withinNestedPlaceholder++;
            index = index + this.simplePrefix.length();
        }
        else {
            index++;
        }
    }
    return -1;
}

Topics: Java Spring source code