Skip to content

Handle multi-JAR resources in ReloadableResourceBundleMessageSource #36292

@ZIRAKrezovic

Description

@ZIRAKrezovic

I am not sure about reasoning behind it, but I'll be happy if you could at least address the extensibility issue which I'll outline. In that case, feel free to change the title to something more appropriate.

I am trying to use ReloadableResourceBundleMessageSource to load messages defined within bundles across several JARs (internal libraries, modules).

I have defined the following.

@Bean
MessageSource internalMessageSource(ResourceLoader resourceLoader) {
        var messageSource = new ReloadableResourceBundleMessageSource();

        messageSource.setBasenames(
                "classpath:i18n/messages",
                "file:i18n/messages");

        messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
        messageSource.setResourceLoader(resourceLoader);

       return messageSource;
}

I have placed messages.properties into src/main/resources/i18n across two different JARs and used those two JARs as a dependency to my Spring Boot application.

Obtaining any message with key located within messages.properties in any of those external JARs will result in NoSuchMessageException.

While debugging from where the properties were loaded, I concluded that they are loaded from Spring Boot application class path, due to usage of ResourceLoader.loadResource(..) rather than ResourcePatternResolver.loadResources(...)

If the latter was used, I could specify my basenames as classpath*:i18n/messages and let them be merged.

I have extended the ReloadableResourceBundleMessageSource to use ResourcePatternResolver when possible, but I ran into the issue that many fields used by methods that required extending are private. Some I had to override, and others I had to use reflection to get to.

So, I ask either one, or ideally both of you

  • Would you consider supporting using Resource Patterns as basenames in ReloadableResourceBundleMessageSource?
  • Would you consider making ReloadableResourceBundleMessageSource extensible to add such functionality by adding (protected) getters to internal class fields?

I am working with Spring Framework version 6.2.15, but it would be okay if any or both of those could make it to 7.0 series if they are deemed to major for 6.2 series.

Below is my implementation that seems to work so far, but I have not yet thoroughly tested it.

import static org.springframework.util.ReflectionUtils.findField;
import static org.springframework.util.ReflectionUtils.getField;
import static org.springframework.util.ReflectionUtils.makeAccessible;

import static java.util.Objects.requireNonNullElseGet;

import org.springframework.context.ResourceLoaderAware;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertiesLoaderUtils;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.ConcurrentMap;

public class MultiResourceBundleMessageSource extends ReloadableResourceBundleMessageSource
        implements ResourceLoaderAware {
    private ResourceLoader resourceLoader = new DefaultResourceLoader();

    private List<String> fileExtensions = List.of(".properties", ".xml");

    private Properties fileEncodings;

    private final ConcurrentMap<String, PropertiesHolder> cachedProperties;

    @SuppressWarnings("unchecked")
    public MultiResourceBundleMessageSource() {
        Field field = findField(ReloadableResourceBundleMessageSource.class, "cachedProperties");
        Assert.notNull(field, "cachedProperties field must not be null");
        makeAccessible(field);
        this.cachedProperties = (ConcurrentMap<String, PropertiesHolder>) getField(field, this);
        Assert.notNull(cachedProperties, "Cached Properties map must not be null");
    }

    @Override
    public void setResourceLoader(ResourceLoader resourceLoader) {
        this.resourceLoader = requireNonNullElseGet(resourceLoader, DefaultResourceLoader::new);
        super.setResourceLoader(resourceLoader);
    }

    @Override
    public void setFileExtensions(List<String> fileExtensions) {
        Assert.isTrue(
                !CollectionUtils.isEmpty(fileExtensions),
                "At least one file extension is required");

        for (String extension : fileExtensions) {
            Assert.isTrue(
                    extension.startsWith("."),
                    () -> "File extension '" + extension + "' should start with '.'");
        }
        this.fileExtensions = Collections.unmodifiableList(fileExtensions);

        super.setFileExtensions(fileExtensions);
    }

    @Override
    public void setFileEncodings(Properties fileEncodings) {
        this.fileEncodings = fileEncodings;
        super.setFileEncodings(fileEncodings);
    }

    @Override
    protected PropertiesHolder refreshProperties(
            String filename, //
            ReloadableResourceBundleMessageSource.PropertiesHolder propHolder) {

        long refreshTimestamp = (getCacheMillis() < 0 ? -1 : System.currentTimeMillis());

        Properties properties = newProperties();

        fillProperties(properties, filename);

        PropertiesHolder updatedPropHolder = new PropertiesHolder();

        if (!properties.isEmpty()) {
            updatedPropHolder = new PropertiesHolder(properties, System.currentTimeMillis());
        }

        updatedPropHolder.setRefreshTimestamp(refreshTimestamp);
        this.cachedProperties.put(filename, updatedPropHolder);
        return updatedPropHolder;
    }

    private void fillProperties(Properties properties, String filename) {
        List<EncodedResource> resources = new ArrayList<>();
        String encoding = getFileEncoding(filename);

        for (String fileExtension : this.fileExtensions) {
            String filenameWithExtension = filename + fileExtension;

            if (resourceLoader instanceof ResourcePatternResolver rpr) {
                addResources(resources, filenameWithExtension, encoding, rpr);
            } else {
                addResources(resources, filenameWithExtension, encoding);
            }

            if (!resources.isEmpty()) {
                break;
            }
        }

        for (EncodedResource res : resources) {
            try {
                PropertiesLoaderUtils.fillProperties(properties, res);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }

    private void addResources(List<EncodedResource> resources, String filename, String encoding) {
        Resource res = resourceLoader.getResource(filename);
        if (res.exists()) {
            resources.add(new EncodedResource(res, encoding));
        }
    }

    private void addResources(
            List<EncodedResource> resources,
            String resourcePattern,
            String encoding,
            ResourcePatternResolver rpr) {

        try {
            Resource[] res = rpr.getResources(resourcePattern);

            if (ObjectUtils.isEmpty(res)) {
                return;
            }

            for (Resource r : res) {
                if (r.exists()) {
                    resources.add(new EncodedResource(r, encoding));
                }
            }

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private String getFileEncoding(String filename) {
        if (this.fileEncodings != null) {
            return this.fileEncodings.getProperty(filename);
        }

        return getDefaultEncoding();
    }
}

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)status: waiting-for-triageAn issue we've not yet triaged or decided on

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions