/* * Copyright 2016 Martin Kouba * * Licensed 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.trimou.handlebars; import static org.trimou.handlebars.OptionsHashKeys.EXPIRE; import static org.trimou.handlebars.OptionsHashKeys.GUARD; import static org.trimou.handlebars.OptionsHashKeys.KEY; import static org.trimou.handlebars.OptionsHashKeys.UNIT; import java.util.Collections; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.trimou.engine.cache.ComputingCache; import org.trimou.engine.config.ConfigurationKey; import org.trimou.engine.config.SimpleConfigurationKey; import org.trimou.exception.MustacheException; import org.trimou.exception.MustacheProblem; import org.trimou.handlebars.HelperDefinition.ValuePlaceholder; import org.trimou.util.ImmutableSet; /** * Allows to cache template fragments in memory. This might be useful for * resource-intensive parts of the template that rarely change (e.g. a user menu * in a webapp). * * <code> * {{#cache}} * All the content will be cached! * {{/cache}} * </code> * * <p> * A <code>key</code> might be used to cache multiple versions of the fragment * for the same part of the template, i.e. each version depends on some context * (e.g. logged-in user): * </p> * * <code> * {{#cache key=username}} * All the content will be cached! * {{/cache}} * </code> * * <p> * The cached fragments can be automatically updated. Either set the expiration * or the guard. <code>expire</code> value must be a Long value. * <code>unit</code> must be a {@link TimeUnit} constant or its * {@link Object#toString()} ( {@link TimeUnit#MILLISECONDS} is the default). * <code>guard</code> may be any object - every time the helper is executed the * current value of the {@link Object#toString()} is compared to the * {@link Object#toString()} value of the guard referenced during the last * update of the fragment. If they're not equal the fragment is updated. * </p> * * <code> * {{#cache key=username expire=2 unit="HOURS" guard=roles}} * All the content will be cached! * {{/cache}} * </code> * * <p> * It's also possible to invalidate the cache manually (e.g. after user logout). * See also {@link #invalidateFragments()} and * {@link #invalidateFragments(String)}. * </p> * * <p> * To limit the size of the fragment cache use * {@link #FRAGMENT_CACHE_MAX_SIZE_KEY}. * </p> * * @author Martin Kouba */ public class CacheHelper extends BasicSectionHelper { /** * Limit the size of the fragment cache. */ public static final ConfigurationKey FRAGMENT_CACHE_MAX_SIZE_KEY = new SimpleConfigurationKey( CacheHelper.class.getName() + ".fragmentCacheMaxSize", 500L); private static final Logger LOGGER = LoggerFactory .getLogger(CacheHelper.class); private volatile ComputingCache<Key, Fragment> fragments; @Override public void execute(Options options) { Map<String, Object> hash = options.getHash(); Object key = hash.get(KEY); Object guard = hash.get(GUARD); Object expire = hash.get(EXPIRE); Object unit = expire != null ? hash.get(UNIT) : null; StringBuilder fragmentKey = new StringBuilder(); fragmentKey.append(options.getTagInfo().getTemplateGeneratedId()); fragmentKey.append(options.getTagInfo().getId()); if (key != null) { fragmentKey.append(key.toString()); } Fragment fragment = fragments .get(new Key(fragmentKey.toString(), options)); if (fragment.getHits() > 0 && (isExpired(fragment, expire, unit) || isGuardCompromised(fragment, guard))) { fragment.update(getContent(options), guard); } fragment.touch(); options.append(fragment.getContent()); } public void init() { super.init(); this.fragments = configuration.getComputingCacheFactory().create( CacheHelper.class.getName(), key -> { Fragment fragment = new Fragment(); fragment.update(getContent(key.getOptions()), key.getOptions().getHash().get(GUARD)); key.cleanupAfterCompute(); return fragment; }, null, configuration.getLongPropertyValue(FRAGMENT_CACHE_MAX_SIZE_KEY), null); } @Override public Set<ConfigurationKey> getConfigurationKeys() { return Collections.singleton(FRAGMENT_CACHE_MAX_SIZE_KEY); } @Override protected int numberOfRequiredParameters() { return 0; } @Override public void validate(HelperDefinition definition) { super.validate(definition); Map<String, Object> hash = definition.getHash(); if (hash.containsKey(EXPIRE)) { Object expire = hash.get(EXPIRE); if (!(expire instanceof ValuePlaceholder)) { try { Long.valueOf(expire.toString()); } catch (NumberFormatException e) { throw new MustacheException( MustacheProblem.COMPILE_HELPER_VALIDATION_FAILURE, e); } } Object unit = hash.get(UNIT); if (unit != null && !(unit instanceof TimeUnit) && !(unit instanceof ValuePlaceholder)) { try { TimeUnit.valueOf(unit.toString()); } catch (Exception e) { throw new MustacheException( MustacheProblem.COMPILE_HELPER_VALIDATION_FAILURE, e); } } } if (!hash.containsKey(EXPIRE) && !hash.containsKey(GUARD)) { LOGGER.info( "Cache fragment will not be automatically updated [helper: {}, template: {}, line: {}]", getClass().getName(), definition.getTagInfo().getTemplateName(), definition.getTagInfo().getLine()); } } /** * Invalidate all the cache fragments. */ public void invalidateFragments() { if (fragments == null) { return; } fragments.clear(); } /** * Invalidate the cache fragments whose key contains the given part of the * key. * * @param keyPart */ public void invalidateFragments(final String keyPart) { if (fragments == null || keyPart == null) { return; } fragments.invalidate(fragmentKey -> fragmentKey.getKey().contains(keyPart)); } @Override protected Set<String> getSupportedHashKeys() { return ImmutableSet.of(KEY, GUARD, EXPIRE, UNIT); } private boolean isExpired(Fragment fragment, Object expire, Object unit) { if (expire == null) { return false; } TimeUnit timeUnit; if (unit != null) { if (unit instanceof TimeUnit) { timeUnit = (TimeUnit) unit; } else { try { timeUnit = TimeUnit.valueOf(unit.toString()); } catch (Exception e) { throw new MustacheException( MustacheProblem.RENDER_HELPER_INVALID_OPTIONS, e); } } } else { timeUnit = TimeUnit.MILLISECONDS; } Long duration; if (expire instanceof Long) { duration = (Long) expire; } else if (expire instanceof Integer) { duration = ((Integer) expire).longValue(); } else { try { duration = Long.valueOf(expire.toString()); } catch (Exception e) { throw new MustacheException( MustacheProblem.RENDER_HELPER_INVALID_OPTIONS, e); } } return (System.currentTimeMillis() - fragment.getLastUsed()) > timeUnit .toMillis(duration); } private boolean isGuardCompromised(Fragment fragment, Object guard) { if (guard == null) { return false; } return !guard.toString().equals(fragment.getGuard()); } private static String getContent(Options options) { StringBuilder content = new StringBuilder(); options.fn(content); return content.toString(); } private static class Fragment { private final AtomicLong hits; private final AtomicLong lastUsed; private final AtomicReference<String> content; private final AtomicReference<String> guard; private Fragment() { this.hits = new AtomicLong(0); this.lastUsed = new AtomicLong(); this.content = new AtomicReference<>(); this.guard = new AtomicReference<>(); } /** * @return the cached content */ String getContent() { return content.get(); } void touch() { this.hits.incrementAndGet(); this.lastUsed.set(System.currentTimeMillis()); } void update(String content, Object guard) { this.content.set(content); if (guard != null) { this.guard.set(guard.toString()); } } /** * @return the lastHit */ Long getLastUsed() { return lastUsed.get(); } /** * @return the number of hits */ Long getHits() { return hits.get(); } /** * @return the guard */ String getGuard() { return guard.get(); } } private static class Key { private final String key; private Options options; private Key(String key, Options options) { this.key = key; this.options = options; } void cleanupAfterCompute() { this.options = null; } /** * @return the key */ String getKey() { return key; } /** * @return the options */ Options getOptions() { return options; } /* * (non-Javadoc) * * @see java.lang.Object#hashCode() */ @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((key == null) ? 0 : key.hashCode()); return result; } /* * (non-Javadoc) * * @see java.lang.Object#equals(java.lang.Object) */ @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } Key other = (Key) obj; if (key == null) { if (other.key != null) { return false; } } else if (!key.equals(other.key)) { return false; } return true; } } }