org.jasig.resource.aggr.ResourcesAggregatorImpl.java Source code

Java tutorial

Introduction

Here is the source code for org.jasig.resource.aggr.ResourcesAggregatorImpl.java

Source

/**
 * Licensed to Jasig under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Jasig licenses this file to you 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.jasig.resource.aggr;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.BOMInputStream;
import org.apache.commons.lang.SystemUtils;
import org.apache.commons.lang.Validate;
import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jasig.resourceserver.aggr.AggregationException;
import org.jasig.resourceserver.aggr.ResourcesDao;
import org.jasig.resourceserver.aggr.ResourcesDaoImpl;
import org.jasig.resourceserver.aggr.om.BasicInclude;
import org.jasig.resourceserver.aggr.om.Css;
import org.jasig.resourceserver.aggr.om.Included;
import org.jasig.resourceserver.aggr.om.Js;
import org.jasig.resourceserver.aggr.om.Resources;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.EvaluatorException;

import com.yahoo.platform.yui.compressor.CssCompressor;
import com.yahoo.platform.yui.compressor.JavaScriptCompressor;

/**
 * {@link ResourcesAggregator} implementation.
 * 
 * @author Nicholas Blair, npblair@wisc.edu
 *
 */
public class ResourcesAggregatorImpl implements ResourcesAggregator {
    protected final Log logger;

    private final static String CSS = ".aggr.min.css";
    private final static String JS = ".aggr.min.js";

    private final ErrorReporter errorReporter;
    private final ResourcesDao resourcesDao;
    private final String encoding;

    private int cssLineBreakColumnNumber = 10000;
    private int jsLineBreakColumnNumber = 10000;

    private boolean obfuscateJs = true;
    private boolean displayJsWarnings = true;
    private boolean preserveAllSemiColons = true;
    private boolean disableJsOptimizations = false;

    private String digestAlgorithm = "MD5";

    public ResourcesAggregatorImpl(Log logger, String encoding) {
        this.logger = logger != null ? logger : LogFactory.getLog(this.getClass());
        this.encoding = encoding;
        this.resourcesDao = new ResourcesDaoImpl(this.logger, this.encoding);
        this.errorReporter = new CommonsLogErrorReporter(this.logger);
    }

    public ResourcesAggregatorImpl() {
        this(null, "UTF-8");
    }

    public String getDigestAlgorithm() {
        return this.digestAlgorithm;
    }

    /**
     * The algorithm to use when creating the hashed file name for aggregated resources
     */
    public void setDigestAlgorithm(String digestAlgorithm) {
        this.digestAlgorithm = digestAlgorithm;
    }

    public int getCssLineBreakColumnNumber() {
        return cssLineBreakColumnNumber;
    }

    /**
     * Maximum line length for minified CSS files, will wrap at this length, defaults to 10000
     * @see CssCompressor#compress(java.io.Writer, int)
     */
    public void setCssLineBreakColumnNumber(int cssLineBreakColumnNumber) {
        this.cssLineBreakColumnNumber = cssLineBreakColumnNumber;
    }

    public int getJsLineBreakColumnNumber() {
        return jsLineBreakColumnNumber;
    }

    /**
     * Maximum line length for minified JS files, will wrap at this length, defaults to 10000
     * @see JavaScriptCompressor#compress(java.io.Writer, int, boolean, boolean, boolean, boolean)
     */
    public void setJsLineBreakColumnNumber(int jsLineBreakColumnNumber) {
        this.jsLineBreakColumnNumber = jsLineBreakColumnNumber;
    }

    public boolean isObfuscateJs() {
        return obfuscateJs;
    }

    /**
     * If the JavaScript should be munged, obfuscating local symbols, defaults to true
     */
    public void setObfuscateJs(boolean obfuscateJs) {
        this.obfuscateJs = obfuscateJs;
    }

    public boolean isDisplayJsWarnings() {
        return displayJsWarnings;
    }

    /**
     * If JS syntax warnings should be displayed, defaults to true
     */
    public void setDisplayJsWarnings(boolean displayJsWarnings) {
        this.displayJsWarnings = displayJsWarnings;
    }

    public boolean isPreserveAllSemiColons() {
        return preserveAllSemiColons;
    }

    /**
     * If unnecessary semicolons should be preserved, defaults to true
     */
    public void setPreserveAllSemiColons(boolean preserveAllSemiColons) {
        this.preserveAllSemiColons = preserveAllSemiColons;
    }

    public boolean isDisableJsOptimizations() {
        return disableJsOptimizations;
    }

    /**
     * Disable micro-optimizations, defaults to false
     */
    public void setDisableJsOptimizations(boolean disableJsOptimizations) {
        this.disableJsOptimizations = disableJsOptimizations;
    }

    @Override
    public void aggregate(File resourcesXml, File outputBaseDirectory) throws IOException, AggregationException {
        this.aggregate(
                new AggregationRequest().setResourcesXml(resourcesXml).setOutputBaseDirectory(outputBaseDirectory));
    }

    @Override
    public void aggregate(File resourcesXml, File outputBaseDirectory, File sharedJavaScriptDirectory)
            throws IOException, AggregationException {
        this.aggregate(
                new AggregationRequest().setResourcesXml(resourcesXml).setOutputBaseDirectory(outputBaseDirectory)
                        .setSharedJavaScriptDirectory(sharedJavaScriptDirectory));
    }

    @Override
    public void aggregate(AggregationRequest aggregationRequest) throws IOException, AggregationException {
        final File outputBaseDirectory = aggregationRequest.getOutputBaseDirectory();
        if (outputBaseDirectory != null) {
            outputBaseDirectory.mkdirs();
        }
        if (null == outputBaseDirectory || !outputBaseDirectory.isDirectory() || !outputBaseDirectory.canWrite()) {
            throw new IllegalArgumentException("outputBaseDirectory ("
                    + (null == outputBaseDirectory ? null : outputBaseDirectory.getAbsolutePath())
                    + ") must be a directory AND writable");
        }

        final MessageDigest digest;
        try {
            digest = MessageDigest.getInstance(this.digestAlgorithm);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException(
                    "Failed to create MessageDigest for algorithm '" + this.digestAlgorithm, e);
        }

        // parse the resourcesXml input
        final File resourcesXml = aggregationRequest.getResourcesXml();
        final Resources original = this.resourcesDao.readResources(resourcesXml, Included.AGGREGATED);
        final File resourcesParentDir = resourcesXml.getParentFile();

        //Build list of source directories for resource files
        final List<File> additionalSourceDirectories = aggregationRequest.getAdditionalSourceDirectories();
        final List<File> sourceDirectories = new ArrayList<File>(1 + additionalSourceDirectories.size());
        sourceDirectories.add(resourcesParentDir);
        sourceDirectories.addAll(additionalSourceDirectories);

        // aggregate CSS elements
        final CssCallback cssCallback = new CssCallback(digest, sourceDirectories, outputBaseDirectory);
        final List<Css> cssResult = this.aggregateBasicIncludes(original.getCss(), cssCallback);

        // aggregate JS elements
        final File sharedJavaScriptDirectory = aggregationRequest.getSharedJavaScriptDirectory();
        final JsCallback jsCallback = new JsCallback(digest, sourceDirectories, outputBaseDirectory,
                sharedJavaScriptDirectory);
        final List<Js> jsResult = this.aggregateBasicIncludes(original.getJs(), jsCallback);

        // build aggregated form result
        final Resources aggregatedForm = new Resources();
        aggregatedForm.getCss().addAll(cssResult);
        aggregatedForm.getJs().addAll(jsResult);
        aggregatedForm.getParameter().addAll(original.getParameter());

        // dump aggregated form out to output directory
        final String aggregatedFormOutputFileName = this.resourcesDao.getAggregatedSkinName(resourcesXml.getName());

        final File aggregatedOutputFile = new File(outputBaseDirectory, aggregatedFormOutputFileName);
        this.resourcesDao.writeResources(aggregatedForm, aggregatedOutputFile);

        this.logger.info("Aggregated " + original.getJs().size() + " JavaScript files down to "
                + aggregatedForm.getJs().size() + " and " + original.getCss().size() + " CSS files down to "
                + aggregatedForm.getCss().size() + " for: " + resourcesXml);
    }

    /**
     * Iterate over the list of {@link BasicInclude} sub-classes using the {@link AggregatorCallback#willAggregate(BasicInclude, BasicInclude)}
     * and {@link AggregatorCallback#aggregate(Deque)} to generate an aggregated list of {@link BasicInclude} sub-classes.
     */
    protected <T extends BasicInclude> List<T> aggregateBasicIncludes(List<T> original,
            AggregatorCallback<T> callback) throws IOException {
        final List<T> result = new LinkedList<T>();
        final Deque<T> currentAggregateList = new LinkedList<T>();
        for (final T originalElement : original) {
            // handle first loop iteration
            if (currentAggregateList.isEmpty()) {
                currentAggregateList.add(originalElement);
            } else {
                // test if 'originalElement' will aggregate with head element in currentAggregate 
                final T baseElement = currentAggregateList.getFirst();
                if (callback.willAggregate(originalElement, baseElement)) {
                    // matches current criteria, add to currentAggregate
                    currentAggregateList.add(originalElement);
                } else {
                    // doesn't match criteria
                    // generate new single aggregate from currentAggregateList
                    final T aggregate = callback.aggregate(currentAggregateList);
                    if (null != aggregate) {
                        // push result
                        result.add(aggregate);
                    } else {
                        this.logger
                                .warn("Generated 0 byte aggregate from: " + generatePathList(currentAggregateList));
                    }

                    // zero out currentAggregateList
                    currentAggregateList.clear();

                    // add originalElement to empty list
                    currentAggregateList.add(originalElement);
                }
            }
        }

        // flush the currentAggregateList
        if (currentAggregateList.size() > 0) {
            final T aggregate = callback.aggregate(currentAggregateList);
            if (null != aggregate) {
                result.add(aggregate);
            } else {
                this.logger.warn("Generated 0 byte aggregate from: " + generatePathList(currentAggregateList));
            }
        }

        return result;
    }

    /**
     * Find the File for the resource file in the various source directories. 
     * 
     * @param sourceDirectories List of directories to scan
     * @param resourceFileName File name of resource file
     * @return The resolved File
     * @throws IOException If the File cannot be found
     */
    protected File findFile(final List<File> sourceDirectories, String resourceFileName) throws IOException {
        for (final File sourceDirectory : sourceDirectories) {
            final File resourceFile = new File(sourceDirectory, resourceFileName);
            if (resourceFile.exists()) {
                return resourceFile;
            }
        }

        throw new IOException("Failed to find resource " + resourceFileName + " in any of the source directories: "
                + sourceDirectories);
    }

    /**
     * Aggregate the specified Deque of elements into a single element. The provided MessageDigest is used for
     * building the file name based on the hash of the file contents. The callback is used for type specific
     * operations.
     */
    protected <T extends BasicInclude> T aggregateList(final MessageDigest digest, final Deque<T> elements,
            final List<File> skinDirectories, final File outputRoot, final File alternateOutput,
            final String extension, final AggregatorCallback<T> callback) throws IOException {

        if (null == elements || elements.size() == 0) {
            return null;
        }

        // reference to the head of the list
        final T headElement = elements.getFirst();
        if (elements.size() == 1 && this.resourcesDao.isAbsolute(headElement)) {
            return headElement;
        }

        final File tempFile = File.createTempFile("working.", extension);
        final File aggregateOutputFile;
        try {
            //Make sure we're working with a clean MessageDigest
            digest.reset();
            TrimmingWriter trimmingWriter = null;
            try {
                final BufferedOutputStream bufferedFileStream = new BufferedOutputStream(
                        new FileOutputStream(tempFile));
                final MessageDigestOutputStream digestStream = new MessageDigestOutputStream(bufferedFileStream,
                        digest);
                final OutputStreamWriter aggregateWriter = new OutputStreamWriter(digestStream, this.encoding);
                trimmingWriter = new TrimmingWriter(aggregateWriter);

                for (final T element : elements) {
                    final File resourceFile = this.findFile(skinDirectories, element.getValue());

                    FileInputStream fis = null;
                    try {
                        fis = new FileInputStream(resourceFile);
                        final BOMInputStream bomIs = new BOMInputStream(new BufferedInputStream(fis));
                        if (bomIs.hasBOM()) {
                            logger.debug("Stripping UTF-8 BOM from: " + resourceFile);
                        }
                        final Reader resourceIn = new InputStreamReader(bomIs, this.encoding);
                        if (element.isCompressed()) {
                            IOUtils.copy(resourceIn, trimmingWriter);
                        } else {
                            callback.compress(resourceIn, trimmingWriter);
                        }
                    } catch (IOException e) {
                        throw new IOException(
                                "Failed to read '" + resourceFile + "' for skin: " + skinDirectories.get(0), e);
                    } finally {
                        IOUtils.closeQuietly(fis);
                    }
                    trimmingWriter.write(SystemUtils.LINE_SEPARATOR);
                }
            } finally {
                IOUtils.closeQuietly(trimmingWriter);
            }

            if (trimmingWriter.getCharCount() == 0) {
                return null;
            }

            // temp file is created, get checksum
            final String checksum = Base64.encodeBase64URLSafeString(digest.digest());
            digest.reset();

            // create a new file name
            final String newFileName = checksum + extension;

            // Build the new file name and path
            if (alternateOutput == null) {
                final String elementRelativePath = FilenameUtils.getFullPath(headElement.getValue());
                final File directoryInOutputRoot = new File(outputRoot, elementRelativePath);
                // create the same directory structure in the output root
                directoryInOutputRoot.mkdirs();

                aggregateOutputFile = new File(directoryInOutputRoot, newFileName).getCanonicalFile();
            } else {
                aggregateOutputFile = new File(alternateOutput, newFileName).getCanonicalFile();
            }

            //Move the aggregate file into the correct location
            FileUtils.deleteQuietly(aggregateOutputFile);
            FileUtils.moveFile(tempFile, aggregateOutputFile);
        } finally {
            //Make sure the temp file gets deleted
            FileUtils.deleteQuietly(tempFile);
        }

        final String newResultValue = RelativePath.getRelativePath(outputRoot, aggregateOutputFile);

        this.logAggregation(elements, newResultValue);

        return callback.getAggregateElement(newResultValue, elements);
    }

    /**
     * Log the result of an aggregation
     */
    protected void logAggregation(final Deque<? extends BasicInclude> elements, final String fileName) {
        if (this.logger.isDebugEnabled()) {
            final StringBuilder msg = new StringBuilder("Aggregated ").append(fileName).append(" from ")
                    .append(generatePathList(elements));

            this.logger.debug(msg);
        }
    }

    /**
     * Build a string from the values of a Collection of {@link BasicInclude} elements
     */
    protected String generatePathList(final Collection<? extends BasicInclude> elements) {
        final StringBuilder msg = new StringBuilder();
        msg.append("[");
        for (final Iterator<? extends BasicInclude> elementItr = elements.iterator(); elementItr.hasNext();) {
            msg.append(elementItr.next().getValue());
            if (elementItr.hasNext()) {
                msg.append(", ");
            }
        }
        msg.append("]");

        return msg.toString();
    }

    /**
     * Similar to the {@link #equals(Object)} method, this will return
     * true if this object and the argument are "aggregatable".
     * 
     * 2 {@link Css} objects are aggregatable if and only if:
     * <ol>
     * <li>Neither object returns true for {@link #isAbsolute()}</li>
     * <li>The values of their "conditional" properties are equivalent</li>
     * <li>The values of their "media" properties are equivalent</li>
     * <li>The "paths" of their values are equivalent</li>
     * </ol>
     * 
     * The last rule mentioned above uses {@link FilenameUtils#getFullPath(String)}
     * to compare each object's value. In short, the final file name in the value's path
     * need not be equal, but the rest of the path in the value must be equal.
     * 
     * @param second
     * @return
     */
    protected boolean willAggregateWith(Css first, Css second) {
        Validate.notNull(first, "Css argument cannot be null");
        Validate.notNull(second, "Css argument cannot be null");

        // never can aggregate absolute Css values
        if (this.resourcesDao.isAbsolute(first) || this.resourcesDao.isAbsolute(second)) {
            return false;
        }

        final String firstFullPath = FilenameUtils.getFullPath(first.getValue());
        final String secondFullPath = FilenameUtils.getFullPath(second.getValue());

        return new EqualsBuilder().append(first.getConditional(), second.getConditional())
                .append(first.getMedia(), second.getMedia()).append(firstFullPath, secondFullPath).isEquals();
    }

    /**
     * Similar to the {@link #equals(Object)} method, this will return
     * true if this object and the argument are "aggregatable".
     * 
     * 2 {@link Js} objects are aggregatable if and only if:
     * <ol>
     * <li>Neither object returns true for {@link #isAbsolute()}</li>
     * <li>The values of their "conditional" properties are equivalent</li>
     * </ol>
     * 
     * The last rule mentioned above uses {@link FilenameUtils#getFullPath(String)}
     * to compare each object's value. In short, the final file name in the value's path
     * need not be equal, but the rest of the path in the value must be equal.
     * 
     * @param other
     * @return
     */
    protected boolean willAggregateWith(Js first, Js second) {
        Validate.notNull(first, "Js cannot be null");
        Validate.notNull(second, "Js cannot be null");

        // never aggregate absolutes
        if (this.resourcesDao.isAbsolute(first) || this.resourcesDao.isAbsolute(second)) {
            return false;
        }

        return new EqualsBuilder().append(first.getConditional(), second.getConditional()).isEquals();
    }

    public interface AggregatorCallback<T extends BasicInclude> {
        public void compress(Reader reader, Writer writer) throws EvaluatorException, IOException;

        public T getAggregateElement(String location, final Deque<T> elements);

        public T aggregate(Deque<T> list) throws IOException;

        public boolean willAggregate(T first, T second);
    }

    private class JsCallback implements AggregatorCallback<Js> {
        private final MessageDigest digest;
        private final List<File> sourceDirectories;
        private final File outputBaseDirectory;
        private final File sharedJavaScriptDirectory;

        public JsCallback(MessageDigest digest, List<File> sourceDirectories, File outputBaseDirectory,
                File sharedJavaScriptDirectory) {
            this.digest = digest;
            this.sourceDirectories = sourceDirectories;
            this.outputBaseDirectory = outputBaseDirectory;
            this.sharedJavaScriptDirectory = sharedJavaScriptDirectory;
        }

        @Override
        public void compress(Reader reader, Writer writer) throws EvaluatorException, IOException {
            final JavaScriptCompressor jsCompressor = new JavaScriptCompressor(reader, errorReporter);
            jsCompressor.compress(writer, jsLineBreakColumnNumber, obfuscateJs, displayJsWarnings,
                    preserveAllSemiColons, disableJsOptimizations);
        }

        @Override
        public Js getAggregateElement(String location, Deque<Js> elements) {
            final Js baseElement = elements.getFirst();

            final Js aggregate = new Js();
            aggregate.setValue(location);
            aggregate.setConditional(baseElement.getConditional());
            aggregate.setCompressed(true);

            return aggregate;
        }

        @Override
        public Js aggregate(Deque<Js> list) throws IOException {
            final File alternateOutput;
            if (sharedJavaScriptDirectory == null) {
                alternateOutput = outputBaseDirectory;
            } else {
                alternateOutput = sharedJavaScriptDirectory;
            }

            return aggregateList(digest, list, sourceDirectories, outputBaseDirectory, alternateOutput, JS, this);
        }

        @Override
        public boolean willAggregate(Js first, Js second) {
            return willAggregateWith(first, second);
        }
    }

    private class CssCallback implements AggregatorCallback<Css> {
        private final MessageDigest digest;
        private final List<File> sourceDirectories;
        private final File outputBaseDirectory;

        public CssCallback(MessageDigest digest, List<File> sourceDirectories, File outputBaseDirectory) {
            this.digest = digest;
            this.sourceDirectories = sourceDirectories;
            this.outputBaseDirectory = outputBaseDirectory;
        }

        @Override
        public void compress(Reader reader, Writer writer) throws EvaluatorException, IOException {
            final CssCompressor jsCompressor = new CssCompressor(reader);
            jsCompressor.compress(writer, cssLineBreakColumnNumber);
        }

        @Override
        public Css getAggregateElement(String location, Deque<Css> elements) {
            final Css baseElement = elements.getFirst();

            final Css aggregate = new Css();
            aggregate.setValue(location);
            aggregate.setConditional(baseElement.getConditional());
            aggregate.setMedia(baseElement.getMedia());
            aggregate.setCompressed(true);

            return aggregate;
        }

        @Override
        public Css aggregate(Deque<Css> list) throws IOException {
            return aggregateList(digest, list, sourceDirectories, outputBaseDirectory, null, CSS, this);
        }

        @Override
        public boolean willAggregate(Css first, Css second) {
            return willAggregateWith(first, second);
        }
    }
}