    001// Licensed under the Apache License, Version 2.0 (the "License");
    002// you may not use this file except in compliance with the License.
    003// You may obtain a copy of the License at
    004//
    005// http://www.apache.org/licenses/LICENSE-2.0
    006//
    007// Unless required by applicable law or agreed to in writing, software
    008// distributed under the License is distributed on an "AS IS" BASIS,
    009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    010// See the License for the specific language governing permissions and
    011// limitations under the License.
    012
    013package org.apache.tapestry5.internal.services;
    014
    015import java.util.Collections;
    016import java.util.Iterator;
    017import java.util.List;
    018import java.util.Map;
    019import java.util.Map.Entry;
    020import java.util.Objects;
    021import java.util.Set;
    022import java.util.stream.Collectors;
    023
    024import org.apache.tapestry5.commons.Messages;
    025import org.apache.tapestry5.commons.Resource;
    026import org.apache.tapestry5.commons.util.CaseInsensitiveMap;
    027import org.apache.tapestry5.commons.util.CollectionFactory;
    028import org.apache.tapestry5.commons.util.MultiKey;
    029import org.apache.tapestry5.func.F;
    030import org.apache.tapestry5.internal.event.InvalidationEventHubImpl;
    031import org.apache.tapestry5.ioc.internal.util.URLChangeTracker;
    032import org.apache.tapestry5.services.ComponentClassResolver;
    033import org.apache.tapestry5.services.messages.PropertiesFileParser;
    034import org.apache.tapestry5.services.pageload.ComponentResourceLocator;
    035import org.apache.tapestry5.services.pageload.ComponentResourceSelector;
    036import org.slf4j.Logger;
    037
    038/**
    039 * A utility class that encapsulates all the logic for reading properties files and assembling {@link Messages} from
    040 * them, in accordance with extension rules and locale. This represents code that was refactored out of
    041 * {@link ComponentMessagesSourceImpl}. This class can be used as a base class, though the existing code base uses it as
    042 * a utility. Composition trumps inheritance!
    043 *
    044 * The message catalog for a component is the combination of all appropriate properties files for the component, plus
    045 * any keys inherited form base components and, ultimately, the application global message catalog. At some point we
    046 * should add support for per-library message catalogs.
    047 *
    048 * Message catalogs are read using the UTF-8 character set. This is tricky in JDK 1.5; we read the file into memory then
    049 * feed that bytestream to Properties.load().
    050 */
    051public class MessagesSourceImpl extends InvalidationEventHubImpl implements MessagesSource
    052{
    053    private final URLChangeTracker<MessagesTrackingInfo> tracker;
    054
    055    private final PropertiesFileParser propertiesFileParser;
    056
    057    private final ComponentResourceLocator resourceLocator;
    058
    059    private final ComponentClassResolver componentClassResolver;
    060
    061    private final boolean multipleClassLoaders;
    062
    063    private final Logger logger;
    064
    065    /**
    066     * Keyed on bundle id and ComponentResourceSelector.
    067     */
    068    private final Map<MultiKey, Messages> messagesByBundleIdAndSelector = CollectionFactory.newConcurrentMap();
    069
    070    /**
    071     * Keyed on bundle id and ComponentResourceSelector, the cooked properties include properties inherited from less
    072     * locale-specific properties files, or inherited from parent bundles.
    073     */
    074    private final Map<MultiKey, Map<String, String>> cookedProperties = CollectionFactory.newConcurrentMap();
    075
    076    /**
    077     * Raw properties represent just the properties read from a specific properties file, in isolation.
    078     */
    079    private final Map<Resource, Map<String, String>> rawProperties = CollectionFactory.newConcurrentMap();
    080
    081    private final Map<String, String> emptyMap = Collections.emptyMap();
    082
    083    public MessagesSourceImpl(boolean productionMode, boolean multipleClassLoaders, URLChangeTracker tracker,
    084                              ComponentResourceLocator resourceLocator, PropertiesFileParser propertiesFileParser,
    085                              ComponentClassResolver componentClassResolver,
    086                              Logger logger)
    087    {
    088        super(productionMode, logger);
    089
    090        this.tracker = tracker;
    091        this.propertiesFileParser = propertiesFileParser;
    092        this.resourceLocator = resourceLocator;
    093        this.logger = logger;
    094        this.componentClassResolver = componentClassResolver;
    095        this.multipleClassLoaders = multipleClassLoaders;
    096    }
    097
    098    public void checkForUpdates()
    099    {
    100        if (tracker != null)
    101        {
    102            final Set<MessagesTrackingInfo> changedResources = tracker.getChangedResourcesInfo();
    103            if (!changedResources.isEmpty() && logger.isInfoEnabled())
    104            {
    105                logger.info("Changed message file(s): {}", changedResources.stream()
    106                        .map(MessagesTrackingInfo::getResource)
    107                        .map(Resource::toString)
    108                        .collect(Collectors.joining(", ")));
    109            }
    110
    111            boolean applicationLevelChange = false;
    112
    113            for (MessagesTrackingInfo info : changedResources)
    114            {
    115
    116                final String className = info.getClassName();
    117
    118                // An application-level file was changed, so we need to invalidate everything.
    119                if (className == null || !multipleClassLoaders)
    120                {
    121                    invalidate();
    122                    applicationLevelChange = true;
    123                    break;
    124                }
    125                else
    126                {
    127
    128                    final Iterator<Entry<MultiKey, Messages>> messagesByBundleIdAndSelectorIterator =
    129                            messagesByBundleIdAndSelector.entrySet().iterator();
    130
    131                    while (messagesByBundleIdAndSelectorIterator.hasNext())
    132                    {
    133                        final Entry<MultiKey, Messages> entry = messagesByBundleIdAndSelectorIterator.next();
    134                        if (className.equals(entry.getKey().getValues()[0]))
    135                        {
    136                            messagesByBundleIdAndSelectorIterator.remove();
    137                        }
    138                    }
    139
    140                    final Iterator<Entry<MultiKey, Map<String, String>>> cookedPropertiesIterator =
    141                            cookedProperties.entrySet().iterator();
    142
    143                    while (cookedPropertiesIterator.hasNext())
    144                    {
    145                        final Entry<MultiKey, Map<String, String>> entry = cookedPropertiesIterator.next();
    146                        if (className.equals(entry.getKey().getValues()[0]))
    147                        {
    148                            cookedPropertiesIterator.remove();
    149                        }
    150                    }
    151
    152                    final String resourceFile = info.getResource().getFile();
    153                    final Iterator<Entry<Resource, Map<String, String>>> rawPropertiesIterator = rawProperties.entrySet().iterator();
    154                    while (rawPropertiesIterator.hasNext())
    155                    {
    156                        final Entry<Resource, Map<String, String>> entry = rawPropertiesIterator.next();
    157                        if (resourceFile.equals(entry.getKey().getFile()))
    158                        {
    159                            rawPropertiesIterator.remove();
    160                        }
    161                    }
    162
    163                }
    164            }
    165
    166            if (!changedResources.isEmpty() && !applicationLevelChange)
    167            {
    168                fireInvalidationEvent(changedResources.stream()
    169                        .filter(Objects::nonNull)
    170                        .map(ClassNameHolder::getClassName)
    171                        .filter(Objects::nonNull)
    172                        .collect(Collectors.toList()));
    173            }
    174        }
    175    }
    176
    177    public void invalidate()
    178    {
    179        messagesByBundleIdAndSelector.clear();
    180        cookedProperties.clear();
    181        rawProperties.clear();
    182
    183        tracker.clear();
    184
    185        fireInvalidationEvent();
    186    }
    187
    188    public Messages getMessages(MessagesBundle bundle, ComponentResourceSelector selector)
    189    {
    190        MultiKey key = new MultiKey(bundle.getId(), selector);
    191
    192        Messages result = messagesByBundleIdAndSelector.get(key);
    193
    194        if (result == null)
    195        {
    196            result = buildMessages(bundle, selector);
    197            messagesByBundleIdAndSelector.put(key, result);
    198        }
    199
    200        return result;
    201    }
    202
    203    private Messages buildMessages(MessagesBundle bundle, ComponentResourceSelector selector)
    204    {
    205        Map<String, String> properties = findBundleProperties(bundle, selector);
    206
    207        return new MapMessages(selector.locale, properties);
    208    }
    209
    210    /**
    211     * Assembles a set of properties appropriate for the bundle in question, and the desired locale. The properties
    212     * reflect the properties of the bundles' parent (if any) for the locale, overalyed with any properties defined for
    213     * this bundle and its locale.
    214     */
    215    private Map<String, String> findBundleProperties(MessagesBundle bundle, ComponentResourceSelector selector)
    216    {
    217        if (bundle == null)
    218            return emptyMap;
    219
    220        MultiKey key = new MultiKey(bundle.getId(), selector);
    221
    222        Map<String, String> existing = cookedProperties.get(key);
    223
    224        if (existing != null)
    225            return existing;
    226
    227        // What would be cool is if we could maintain a cache of bundle id + locale -->
    228        // Resource. That would optimize quite a bit of this; may need to use an alternative to
    229        // LocalizedNameGenerator.
    230
    231        Resource propertiesResource = bundle.getBaseResource().withExtension("properties");
    232
    233        List<Resource> localizations = resourceLocator.locateMessageCatalog(propertiesResource, selector);
    234
    235        // Localizations are now in least-specific to most-specific order.
    236
    237        Map<String, String> previous = findBundleProperties(bundle.getParent(), selector);
    238
    239        for (Resource localization : F.flow(localizations).reverse())
    240        {
    241            Map<String, String> rawProperties = getRawProperties(localization, bundle);
    242
    243            // Would be nice to write into the cookedProperties cache here,
    244            // but we can't because we don't know the selector part of the MultiKey.
    245
    246            previous = extend(previous, rawProperties);
    247        }
    248
    249        cookedProperties.put(key, previous);
    250
    251        return previous;
    252    }
    253
    254    /**
    255     * Returns a new map consisting of all the properties in previous overlayed with all the properties in
    256     * rawProperties. If rawProperties is empty, returns just the base map.
    257     */
    258    private Map<String, String> extend(Map<String, String> base, Map<String, String> rawProperties)
    259    {
    260        if (rawProperties.isEmpty())
    261            return base;
    262
    263        // Make a copy of the base Map
    264
    265        Map<String, String> result = new CaseInsensitiveMap<String>(base);
    266
    267        // Add or overwrite properties to the copy
    268
    269        result.putAll(rawProperties);
    270
    271        return result;
    272    }
    273
    274    private Map<String, String> getRawProperties(Resource localization, MessagesBundle bundle)
    275    {
    276        Map<String, String> result = rawProperties.get(localization);
    277
    278        if (result == null)
    279        {
    280            result = readProperties(localization, bundle);
    281
    282            rawProperties.put(localization, result);
    283        }
    284
    285        return result;
    286    }
    287
    288    /**
    289     * Creates and returns a new map that contains properties read from the properties file.
    290     * @param bundle
    291     */
    292    private Map<String, String> readProperties(Resource resource, MessagesBundle bundle)
    293    {
    294        if (!resource.exists())
    295            return emptyMap;
    296
    297        if (tracker != null)
    298        {
    299            MessagesTrackingInfo info = new MessagesTrackingInfo(
    300                    resource, bundle != null ? bundle.getId() : bundle, getClassName(bundle));
    301            tracker.add(resource.toURL(), info);
    302        }
    303
    304        try
    305        {
    306            return propertiesFileParser.parsePropertiesFile(resource);
    307        } catch (Exception ex)
    308        {
    309            throw new RuntimeException(String.format("Unable to read message catalog from %s: %s", resource, ex), ex);
    310        }
    311    }
    312
    313    private String getClassName(MessagesBundle bundle)
    314    {
    315        String className = null;
    316        if (bundle != null && bundle.getBaseResource().getPath() != null)
    317        {
    318            final String path = bundle.getBaseResource().getPath();
    319            if (path.endsWith(".class"))
    320            {
    321                className = path.replace('/', '.').replace(".class", "");
    322                if (!componentClassResolver.isPage(className))
    323                {
    324                    className = null;
    325                }
    326            }
    327        }
    328        return className;
    329    }
    330
    331}