    001// Copyright 2006, 2007, 2008, 2010 The Apache Software Foundation
    002//
    003// Licensed under the Apache License, Version 2.0 (the "License");
    004// you may not use this file except in compliance with the License.
    005// You may obtain a copy of the License at
    006//
    007// http://www.apache.org/licenses/LICENSE-2.0
    008//
    009// Unless required by applicable law or agreed to in writing, software
    010// distributed under the License is distributed on an "AS IS" BASIS,
    011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    012// See the License for the specific language governing permissions and
    013// limitations under the License.
    014
    015package org.apache.tapestry5.ioc.internal.util;
    016
    017import java.io.File;
    018import java.io.IOException;
    019import java.net.URISyntaxException;
    020import java.net.URL;
    021import java.util.HashSet;
    022import java.util.Map;
    023import java.util.Set;
    024
    025import org.apache.tapestry5.commons.util.CollectionFactory;
    026import org.apache.tapestry5.ioc.internal.services.ClasspathURLConverterImpl;
    027import org.apache.tapestry5.ioc.services.ClasspathURLConverter;
    028
    029/**
    030 * Given a (growing) set of URLs, can periodically check to see if any of the underlying resources has changed. This
    031 * class is capable of using either millisecond-level granularity or second-level granularity. Millisecond-level
    032 * granularity is used by default. Second-level granularity is provided for compatibility with browsers vis-a-vis
    033 * resource caching -- that's how granular they get with their "If-Modified-Since", "Last-Modified" and "Expires"
    034 * headers.
    035 *
    036 * @param <T> The type of the optional information about the tracked resource. This type should
    037 * implement <code>equals()</code> and <code>hashCode()</code>.
    038 */
    039public class URLChangeTracker<T>
    040{
    041    private static final long FILE_DOES_NOT_EXIST_TIMESTAMP = -1L;
    042
    043    private final Map<File, TrackingInfo> fileToTimestamp = CollectionFactory.newConcurrentMap();
    044
    045    private final boolean granularitySeconds;
    046
    047    private final boolean trackFolderChanges;
    048
    049    private final ClasspathURLConverter classpathURLConverter;
    050
    051    public static final ClasspathURLConverter DEFAULT_CONVERTER = new ClasspathURLConverterImpl();
    052
    053    /**
    054     * Creates a tracker using the default (does nothing) URL converter, with default (millisecond)
    055     * granularity and folder tracking disabled.
    056     *
    057     * @since 5.2.1
    058     */
    059    public URLChangeTracker()
    060    {
    061        this(DEFAULT_CONVERTER, false, false);
    062    }
    063
    064    /**
    065     * Creates a new URL change tracker with millisecond-level granularity and folder checking enabled.
    066     *
    067     * @param classpathURLConverter
    068     *            used to convert URLs from one protocol to another
    069     */
    070    public URLChangeTracker(ClasspathURLConverter classpathURLConverter)
    071    {
    072        this(classpathURLConverter, false);
    073
    074    }
    075
    076    /**
    077     * Creates a new URL change tracker, using either millisecond-level granularity or second-level granularity and
    078     * folder checking enabled.
    079     *
    080     * @param classpathURLConverter
    081     *            used to convert URLs from one protocol to another
    082     * @param granularitySeconds
    083     *            whether or not to use second granularity (as opposed to millisecond granularity)
    084     */
    085    public URLChangeTracker(ClasspathURLConverter classpathURLConverter, boolean granularitySeconds)
    086    {
    087        this(classpathURLConverter, granularitySeconds, true);
    088    }
    089
    090    /**
    091     * Creates a new URL change tracker, using either millisecond-level granularity or second-level granularity.
    092     *
    093     * @param classpathURLConverter
    094     *            used to convert URLs from one protocol to another
    095     * @param granularitySeconds
    096     *            whether or not to use second granularity (as opposed to millisecond granularity)
    097     * @param trackFolderChanges
    098     *            if true, then adding a file URL will also track the folder containing the file (this
    099     *            is useful when concerned about additions to a folder)
    100     * @since 5.2.1
    101     */
    102    public URLChangeTracker(ClasspathURLConverter classpathURLConverter, boolean granularitySeconds,
    103            boolean trackFolderChanges)
    104    {
    105        this.granularitySeconds = granularitySeconds;
    106        this.classpathURLConverter = classpathURLConverter;
    107        this.trackFolderChanges = trackFolderChanges;
    108    }
    109
    110    /**
    111     * Converts a URL with protocol "file" to a File instance.
    112     *
    113     * @since 5.2.0
    114     */
    115    public static File toFileFromFileProtocolURL(URL url)
    116    {
    117        assert url != null;
    118
    119        if (!url.getProtocol().equals("file"))
    120            throw new IllegalArgumentException(String.format("URL %s does not use the 'file' protocol.", url));
    121
    122        // http://weblogs.java.net/blog/kohsuke/archive/2007/04/how_to_convert.html
    123
    124        try
    125        {
    126            return new File(url.toURI());
    127        } catch (URISyntaxException ex)
    128        {
    129            return new File(url.getPath());
    130        }
    131    }
    132
    133    /**
    134     * Stores a new URL into the tracker, or returns the previous time stamp for a previously added URL. Filters out all
    135     * non-file URLs.
    136     *
    137     * @param url
    138     *            of the resource to add, or null if not known
    139     * @return the current timestamp for the URL (possibly rounded off for granularity reasons), or 0 if the URL is
    140     *         null
    141     */
    142    public long add(URL url)
    143    {
    144        return add(url, null);
    145    }
    146    /**
    147     * Stores a new URL and associated memo (most probably a related class name)
    148     * into the tracker, or returns the previous time stamp for a previously added URL. Filters out all
    149     * non-file URLs.
    150     *
    151     * @param url
    152     *            of the resource to add, or null if not known
    153     * @param resourceInfo
    154     *            an optional object containing information about the tracked URL. It's
    155     *            returned in the {@link #getChangedResourcesInfo()} method.
    156     * @return the current timestamp for the URL (possibly rounded off for granularity reasons), or 0 if the URL is
    157     *         null
    158     * @since 5.8.3
    159     */
    160    public long add(URL url, T resourceInfo)
    161    {
    162        if (url == null)
    163            return 0;
    164
    165        URL converted = classpathURLConverter.convert(url);
    166
    167        if (!converted.getProtocol().equals("file"))
    168            return timestampForNonFileURL(converted);
    169
    170        File resourceFile = toFileFromFileProtocolURL(converted);
    171
    172        if (fileToTimestamp.containsKey(resourceFile))
    173            return fileToTimestamp.get(resourceFile).timestamp;
    174
    175        long timestamp = readTimestamp(resourceFile);
    176
    177        // A quick and imperfect fix for TAPESTRY-1918. When a file
    178        // is added, add the directory containing the file as well.
    179
    180        fileToTimestamp.put(resourceFile, new TrackingInfo(timestamp, resourceInfo));
    181
    182        if (trackFolderChanges)
    183        {
    184            File dir = resourceFile.getParentFile();
    185
    186            if (!fileToTimestamp.containsKey(dir))
    187            {
    188                long dirTimestamp = readTimestamp(dir);
    189                fileToTimestamp.put(dir, new TrackingInfo(dirTimestamp, null));
    190            }
    191        }
    192
    193        return timestamp;
    194    }
    195
    196    private long timestampForNonFileURL(URL url)
    197    {
    198        long timestamp;
    199
    200        try
    201        {
    202            timestamp = url.openConnection().getLastModified();
    203        }
    204        catch (IOException ex)
    205        {
    206            throw new RuntimeException(ex);
    207        }
    208
    209        return applyGranularity(timestamp);
    210    }
    211
    212    /**
    213     * Clears all URL and timestamp data stored in the tracker.
    214     */
    215    public void clear()
    216    {
    217        fileToTimestamp.clear();
    218    }
    219
    220    /**
    221     * Re-acquires the last updated timestamp for each URL and returns true if any timestamp has changed.
    222     */
    223    public boolean containsChanges()
    224    {
    225        boolean result = false;
    226
    227        // This code would be highly suspect if this method was expected to be invoked
    228        // concurrently, but CheckForUpdatesFilter ensures that it will be invoked
    229        // synchronously.
    230
    231        for (Map.Entry<File, TrackingInfo> entry : fileToTimestamp.entrySet())
    232        {
    233            long newTimestamp = readTimestamp(entry.getKey());
    234            long current = entry.getValue().timestamp;
    235
    236            if (current == newTimestamp)
    237                continue;
    238
    239            result = true;
    240            entry.getValue().timestamp = newTimestamp;
    241        }
    242
    243        return result;
    244    }
    245
    246    /**
    247     * Re-acquires the last updated timestamp for each URL and returns the non-null resource information for all files with a changed timestamp.
    248     */
    249    public Set<T> getChangedResourcesInfo()
    250    {
    251
    252        Set<T> changedResourcesInfo = new HashSet<>();
    253
    254        for (Map.Entry<File, TrackingInfo> entry : fileToTimestamp.entrySet())
    255        {
    256            long newTimestamp = readTimestamp(entry.getKey());
    257            final TrackingInfo value = entry.getValue();
    258            long current = value.timestamp;
    259
    260            if (current != newTimestamp)
    261            {
    262                if (value.resourceInfo != null)
    263                {
    264                    changedResourcesInfo.add(value.resourceInfo);
    265                }
    266                value.timestamp = newTimestamp;
    267            }
    268        }
    269
    270        return changedResourcesInfo;
    271    }
    272
    273
    274    /**
    275     * Returns the time that the specified file was last modified, possibly rounded down to the nearest second.
    276     */
    277    private long readTimestamp(File file)
    278    {
    279        if (!file.exists())
    280            return FILE_DOES_NOT_EXIST_TIMESTAMP;
    281
    282        return applyGranularity(file.lastModified());
    283    }
    284
    285    private long applyGranularity(long timestamp)
    286    {
    287        // For coarse granularity (accurate only to the last second), remove the milliseconds since
    288        // the last full second. This is for compatibility with client HTTP requests, which
    289        // are only accurate to one second. The extra level of detail creates false positives
    290        // for changes, and undermines HTTP response caching in the client.
    291
    292        if (granularitySeconds)
    293            return timestamp - (timestamp % 1000);
    294
    295        return timestamp;
    296    }
    297
    298    /**
    299     * Needed for testing; changes file timestamps so that a change will be detected by {@link #containsChanges()}.
    300     */
    301    public void forceChange()
    302    {
    303        for (Map.Entry<File, TrackingInfo> e : fileToTimestamp.entrySet())
    304        {
    305            e.getValue().timestamp = 0l;
    306        }
    307    }
    308
    309    /**
    310     * Needed for testing.
    311     */
    312    int trackedFileCount()
    313    {
    314        return fileToTimestamp.size();
    315    }
    316
    317    private final class TrackingInfo
    318    {
    319
    320        private long timestamp;
    321        private T resourceInfo;
    322
    323        public TrackingInfo(long timestamp, T resourceInfo)
    324        {
    325            this.timestamp = timestamp;
    326            this.resourceInfo = resourceInfo;
    327        }
    328
    329        @Override
    330        public String toString()
    331        {
    332            return "Info [timestamp=" + timestamp + ", resourceInfo=" + resourceInfo + "]";
    333        }
    334
    335    }
    336
    337}