13 ноября 2010 г.

Пишем Compressing Filter для js и css ресурсов

Задумавшись об оптимизации своих web приложений через n-ое количество времени я заинтересовался различного рода сжатием статики, о котором написано невероятно много интересных и занимательных статей. Более всего меня заинтересовало сжатие css и js. Заинтересованность эта была связана с тем, что сам разработчик правит js и css достаточно часто и постоянное ручное сжатие рано или поздно приводит либо к пересмотру всего процесса разработки и размещения, либо приводит к отказу от сжатия.

Меня это не устраивало из-за того, что процесс размещения я модифицировать не мог, а отказываться от сжатия не хотел. Поэтому я решил реализовать далеко не новую идею сжимающего фильтра, использующего какой-либо компрессор.

Такое решение подразумевает, что все css и js ресурсы отдаются сервером приложения (знаю многим разработчикам может это не понравиться) и должны пропускаться через этот сжимающий фильтр. В таком случае схема работы будет следующая:


В качестве достоинств такого подхода можно выделить следующее:

  1. Разработчик не тратит свое время на сжатие
  2. Не нужен специальный механизм размещения
  3. Для подключения сжатия к проекту будет достаточно подключить фильтр

Но у любого решения есть свои недостатки:

  1. Статика отдается сервером приложения, а не оберткой в виде, например, nginx.
  2. При простой реализации сжатия через фильтр повторное обращение к css или js будет приводить к повторному сжатию, что будет только загружать сервер приложения.
  3. Любая модификация отдаваемого контента через фильтра потенциально может привести к тому, что контент из-за некоторых сбоев в обработке может не дойти до конечного пользователя.

Исходя из этих особенностей можно выделить ряд ключевых моментов для сжимающего фильтра:

  1. Необходима проверка на соответсвие отдаваемого контента формату css или js . Это необходимо в том случае если пользователь повесит фильтр не только на js и css .
  2. При любой ошибке или даже намеке на то, что она может произойти необходимо отменить все модификации и просто пропустить несжатый контент.
  3. Чтобы избавиться от лишней нагрузки на сервер приложений необходимо реализовать кэширование с возможностью сброса или обхода.
  4. Так как фильтр может работать в цепочке фильтров необходимо предоставить response wrappers для того чтобы избежать работу нескольких фильтров с одним ответом (что будет вызывать исключение)

Инструментарий и интерфейс фильтра

В качестве компрессора я выбрал YUICompressor , ознакомиться с которым можно здесь: http://developer.yahoo.com/yui/compressor/. Конечно, можно выбрать и другой компрессор, но тогда  придется реализовать другой интерфейс к вашему компрессору.

Итак, нам необходимо скачать jar файл и поместить его в недры classpath, чтобы был доступ к следующим классам: com.yahoo.platform.yui.compressor.CssCompressor и com.yahoo.platform.yui.compressor.JavaScriptCompressor.

YUICompressor имеет не так много параметров, поэтому их легко можно перенести в конфигурацию самого фильтра. Более того не все параметры реально нужны для функционирования фильтра, поэтому вполне логично оставить следующие:

  • line-break - перенос по строкам сжатого контента
  • nomunge - минимизация без обфускации
  • preserve-semi - сохранять ли необязательные ";"
  • disable-optimizations - выключение микро-оптимизаций

Конечно, вероятно понадобиться настройка кодировки, но я предпочел зафиксировать ее со значением UTF-8. Реализовать настройку кодировки ничего не стоит, можно будет легко это сделать.

Собственно, интерфейс определен, что позволяет определить и подключение фильтра:
<filter>
        <filter-name>CompressingFilter</filter-name>
        <filter-class>com.sultry.web.filters.CompressingFilter</filter-class>
        <init-param>
                <param-name>lineBreak</param-name>
                <param-value>8000</param-value>
        </init-param>
        <init-param>
                <param-name>noMunge</param-name>
                <param-value>false</param-value>
        </init-param>
        <init-param>
                <param-name>preserveSemi</param-name>
                <param-value>false</param-value>
        </init-param>
        <init-param>
                <param-name>disableOptimizations</param-name>
                <param-value>false</param-value>
        </init-param>
</filter>

<filter-mapping>
        <filter-name>CompressingFilter</filter-name>
        <url-pattern>*.css</url-pattern>
</filter-mapping>
<filter-mapping>
        <filter-name>CompressingFilter</filter-name>
        <url-pattern>*.js</url-pattern>
</filter-mapping>
или с помощью аннотаций в самом коде
@WebFilter(filterName = "CompressingFilter",
                     initParams = {
                           @WebInitParam(name = "lineBreak", value="8000"),
                           @WebInitParam(name = "noMunge", value="false"),
                           @WebInitParam(name = "preserveSemi", value="false"),
                           @WebInitParam(name = "disableOptimizations", value="false"),
                     },
                     urlPatterns = {"*.css""*.js"})

Теперь можно приступать к реализации фильтра. Создадим новый фильтр, в котором объявим константы имен параметров и используемую кодировку:
package com.sultry.web.filters;

@WebFilter(filterName = "CompressingFilter",
                     initParams = {
                           @WebInitParam(name = "lineBreak", value="8000"),
                           @WebInitParam(name = "noMunge", value="false"),
                           @WebInitParam(name = "preserveSemi", value="false"),
                           @WebInitParam(name = "disableOptimizations", value="false"),
                     },
                     urlPatterns = {"*.css""*.js"})
public class CompressingFilter implements Filter {

    private static final String LINE_PARAM = "lineBreak";
    private static final String MUNGE_PARAM = "noMunge";
    private static final String PRESERVE_PARAM = "preserveSemi";
    private static final String OPT_PARAM = "disableOptimizations";

    private static final String CHARSET = "UTF-8";

//Filter implementation

}

Инициализация фильтра

Первое с чего необходимо начать непосредственную реализацию - это инициализация фильтра. Что необходимо сделать во время инициализации:

  • Инкапсулировать FilterConfig, передаваемый в качестве параметра метода инициализации
  • Проверить на неравенство null параметры самого фильтра
  • Привести к нижнему регистру и обрезать лишние пробелы вначале и конце значения этих параметров
  • Распарсить на числовые и логические значения
  • В случае какой-либо ошибки необходимо оставить значение по умолчанию

Ничего особенно в такой инициализации нет, поэтому достаточно быстро можно прийти к такому или аналогичному коду:
    @Override
    public void init(FilterConfig config) throws ServletException {
        this.config = config;
        String lineBreakParam = normalize(this.config.getInitParameter(LINE_PARAM));
        String noMungeParam = normalize(this.config.getInitParameter(MUNGE_PARAM));
        String preserveSemiParam = normalize(this.config.getInitParameter(PRESERVE_PARAM));
        String disableOptimizationsParam = normalize(this.config.getInitParameter(OPT_PARAM));

        this.lineBreak = isNumber(lineBreakParam) ? Integer.parseInt(lineBreakParam) : this.lineBreak;
        this.noMunge = isBoolean(noMungeParam) ? Boolean.parseBoolean(noMungeParam) : this.noMunge;
        this.preserveSemi = isBoolean(preserveSemiParam) ? Boolean.parseBoolean(preserveSemiParam) : this.preserveSemi;
        this.disableOptimizations = isBoolean(disableOptimizationsParam) ? Boolean.parseBoolean(disableOptimizationsParam) : this.disableOptimizations;
    }
И, соответственно, добавляем необходимые локальные переменные:
    private FilterConfig config;

    private int lineBreak = -1;
    private boolean noMunge = false;
    private boolean preserveSemi = false;
    private boolean disableOptimizations = false;
    private boolean useCahce = true;

При инициализации используются несколько вспомогательных функций для следующих целей:

  • Приведение к нижнему регистру и обрезание лишних пробельных символов
  • Проверка на соответствие значения параметра фильтра целому числу
  • Проверка на соответствие значения параметра фильтра логическому числу

Реализация функций очень простая, добавляем их к фильтру:
    private String normalize(String configValue) {
        if (configValue!=null)
            return configValue.trim().toLowerCase();
        return null;
    }

    private boolean isNumber(String configValue) {
        if (configValue != null && configValue.matches("[0-9]+")){
            return true;
        }
        return false;
    }

    private boolean isBoolean(String configValue){
        if (configValue != null && configValue.matches("true|false")){
            return true;
        }
        return false;
    }

Кроме этого добавим переопределение метода деструктора для избавления от ранее инкапсулированного FilterConfig:
    @Override
    public void destroy() {
        this.config = null;
    }

А что нужно?

Одной из задач, описанных ближе к началу, было определение отдаваемого контента. Зачем это нужно? Нужно это для тех ситуаций, когда фильтр настраивается на перехват не только js и css ресурсов, а некоторого множества возможных ресурсов. Ведь не всегда отдаваемый js контент храниться с расширением *.js, более того рассчитывать на расширение вообще не очень хорошо, хотя я для удобства это делаю.

Кроме расширения ресурса, которое может нас и обмануть, возможно определить тип возвращаемого контента по заголовку "Content-Type", что в большинстве случаем вполне достаточно. Но для этого необходимо определиться какие именно значения этого заголовка нас интересуют и какие расширения бывают у css и js ресурсов.

В качестве примера я выбрал следующие значения "Content-Type":

  • text/javascript
  • application/x-javascript
  • application/json
  • text/css

и следующие расширения:

  • .js
  • .json
  • .css

Получается, что для определения контента, которого необходимо сжимать, достаточно проверить на соответствие выбранным значениям заголовок "Content-Type" или расширение отдаваемого ресурса.

Определим константы необходимые нам для проверки:
    private static final String JS_EXT = ".js";
    private static final String JSON_EXT = ".json";
    private static final String CSS_EXT = ".css";

    private static final String JS_CONTENT = "text/javascript";
    private static final String JS_X_CONTENT = "application/x-javascript";
    private static final String JSON_CONTENT = "application/json";
    private static final String CSS_CONTENT = "text/css";
На основании данных констант определим функции проверки:
    private boolean isJSResource(String url) {
        if (url.endsWith(JS_EXT) || url.endsWith(JSON_EXT)) {
            return true;
        }
        return false;
    }

    private boolean isJSContentType(ServletResponse wrapper) {
        if (wrapper.getContentType() != null && (wrapper.getContentType().equals(JS_CONTENT) || wrapper.getContentType().equals(JS_X_CONTENT) || wrapper.getContentType().equals(JSON_CONTENT))) {
            return true;
        }
        return false;
    }

    private boolean isCSSResource(String url) {
        if (url.endsWith(CSS_EXT)) {
            return true;
        }
        return false;
    }

Основываясь на данной проверке возможно определить что за ресурс обрабатывается и какой компрессор нам необходим для сжатия.

Оборачиваем, упаковываем...

Так как фильтр предназначен для модификации, то нам необходимо захватить ответ и при этом находиться в потоке выполнения цепочки фильтров. Это организуется с помощью оборачивания приходящего в фильтр ответа и передачи этой обертки(wrapper) по цепочке фильтров. Такое решение позволяет и модифицировать контент и оставаться в режиме выполнения (более подробно здесь). Собственно, такое решение является примером паттерна Wrapper или Decorator.

Для определения "обертки" необходимо перекрыть метод getWriter или getOutputStream, а в случае, когда в цепочке фильтров присутствуют другие модифицирующие фильтры, то желательно переопределить оба метода, так как неизвестно с каким методом будет работать другой фильтр. Чтобы определить "обертку" для модификации ответа, необходимо расширить ServletResponseWrapper или HttpServletResponseWrapper:
class CharResponseWrapper extends HttpServletResponseWrapper {

    private CharArrayWriter output;

    @Override
    public String toString() {
       return output.toString();
    }

    public CharResponseWrapper(HttpServletResponse response){
       super(response);
       output = new CharArrayWriter();
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
     return new CharOutputStream(this.output);
    }

    @Override
    public PrintWriter getWriter(){
       return new PrintWriter(output);
    }
}

class CharOutputStream extends ServletOutputStream {

    private CharArrayWriter output;

    public CharOutputStream(CharArrayWriter output){
     this.output = output;
    }

    @Override
    public void write(int b) throws IOException {
     output.write(b);
    }

}

Для переопределения getOutputStream достаточно сделать аналогичную "обертку" расширяющую ServletOutputStream, в этом случае можно быть уверенным (ну почти...), что не возникнет конфликта с другими модифицирующими фильтрами из той же цепочки.

Наличие такой обертки позволяет приступить к реализации основного метода doFilter:
    @Override
    public void doFilter(ServletRequest sRequest, ServletResponse sResponse, FilterChain next) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest)sRequest;
        HttpServletResponse response = (HttpServletResponse)sResponse;

        String url = request.getRequestURI().toLowerCase();

        if (config != null){

                // Оборачиваем оригинальный ответ и передаем его по цепочке
                PrintWriter writer = response.getWriter();
                CharResponseWrapper wrapper = new CharResponseWrapper(response);
                next.doFilter(request, wrapper);

                // Получаем модифицированный wrapper
                StringWriter temp = new StringWriter();
                StringReader reader = new StringReader(new String(wrapper.toString().getBytes(), CHARSET));

                // Проверяем необходимость сжатия
                if (isJSResource(url) || isJSContentType(wrapper)) {
                    // Сжимаем js
                } else if (isCSSResource(url) || isCSSContentType(wrapper)) {
                    // Сжимаем css
                } else {
                    // Возвращаем без сжатия
                    temp.write(wrapper.toString());
                }

                writer.write(temp.toString());

                writer.flush();
                writer.close();
            }
        } else {
            // Что-то пошло не так... Переходим дальше по цепочке
            next.doFilter(request, response);
        }

    }

Сжимаем содержимое

YUICompressor предоставляет два необходимых нам класса, выполняющих непосредственное сжатие:

  • JavaScriptCompressor
  • СsstCompressor

Вызвав метод compress этих классов получим сжатое содержимое. Собственно, больше нам ничего и не нужно. Только нужно заметить, что процесс компрессии может выбросить исключение, которое необходимо обработать.

На основании этих классов реализуем методы, производящие компрессию:
    private void compressJSResource(String url, StringWriter temp, StringReader reader, CharResponseWrapper wrapper) {
        try {
            JavaScriptCompressor compressor =  new JavaScriptCompressor(reader,null);
            compressor.compress(temp, this.lineBreak!this.noMungefalsethis.preserveSemithis.disableOptimizations);
        } catch (Exception e) {
            temp.write(wrapper.toString());
        }
    }

    private void compressCSSResource(String url, StringWriter temp, StringReader reader, CharResponseWrapper wrapper) {
        try {
            CssCompressor compressor =  new CssCompressor(reader);
            compressor.compress(temp, this.lineBreak);
        } catch (Exception e) {
            temp.write(wrapper.toString());
        }
    }
Ничего сложного здесь нет и теперь можно дописать метод doFilter:
                if (isJSResource(url) || isJSContentType(wrapper)) {
                    compressJSResource(url,temp,reader,wrapper);
                } else if (isCSSResource(url) || isCSSContentType(wrapper)) {
                    compressCSSResource(url,temp,reader,wrapper);
                } else {
                    temp.write(wrapper.toString());
                }

Первая версия фильтра готова, ее можно опробовать, но...

Обдумываем результат

В таком виде использование фильтра чистое самоубийство, так как при каждом обращении к css- или js-ресурсу компрессия будет происходить постоянно, то есть если несколько разных пользователей запросили один и тот же статический ресурс, то сжатие этого ресурса для каждого пользователя будет повторяться. Кроме того, в случае если браузер клиента по каким-то причинам не закэшировал этот ресурс или уже сбросил свой кэш, то получается, что идентичное сжатие будут происходить по несколько раз даже для одного пользователя.

Добавляем кэширование

Исходя из имеющихся проблем необходимо добавить следующий функционал:

  • Сохранение сжатого ресурса
  • Проверка на существование уже сжатой копии по запросу
  • Получение ресурса из кэша
  • Очистка кэша
  • Механизм обхода кэша

Есть множество стратегий кэширования, для разных масштабов проектов следует использовать соответствующие стратегии. В качестве примера приведу простой вариант, рассчитанный на в целом малое количество статических ресурсов. В качестве основного хранилища будем использовать HashMap для получения ресурса по ключу - его имени:
private Map<String, String> cache = Collections.synchronizedMap(new LinkedHashMap<String, String>());
Кроме этого необходимо добавить имена параметров запроса, которые будем использовать для управлением кэшем. Нам необходимо три таких параметра:

  • Для сброса кэша
  • Для пропуска процесса кэширования
  • Для отладки, когда кэширование так же не выполняется

Естественно, в таком простом варианте поведение при втором и третьем варианте идентичное, но при усложнении фильтра в любом случае понадобиться эти ситуации разделять. Добавляем имена параметров:
    private static final String CLEAR_PARAM = "CF_CLEAR";
    private static final String SKIP_PARAM = "CF_SKIP";
    private static final String DEBUG_PARAM = "CF_DEBUG";

Первое что необходимо реализовать - это сброс кэша, так как это первое что необходимо сделать после того как мы становимся уверенными, что запрашивается ресурс, который необходимо сжимать:
...
        if (config != null && isValidResource(url)){
            tryToResetCache(request.getParameter(CLEAR_PARAM));
            ...
Сам сброс кэша выглядит следующим образом:
    private void tryToResetCache(String param) {
        if(param != null){
            this.cache.clear();
        }
    }
Таким образом, когда с клиентской стороны приходит параметр CLEAR_PARAM, то кэш будет сбрасываться.

Запись и чтение сжатого содержимого из кэша осуществляется следующим образом:
            // Получение значения по имени запрашиваемого ресурса
            String cachedValue = cache.get(url);
            // Сохранение сжатого результата с именем
            cache.put(url, temp.toString());

Последний шаг - организация логики кэширования. В случае, если ресурс уже сжимался и мы можем использовать кэш(в зависимости от параметров), то необходимо использовать уже сжатый результат, в противном случае произвести сжатие и сохранить результат:
            // Узнаем, можно ли использовать кэш
            boolean useCache = this.useCahce &&
                               request.getParameter(SKIP_PARAM) == null &&
                               request.getParameter(DEBUG_PARAM) == null;

            // Пытаемся достать сжатый результат
            String cachedValue = cache.get(url);
            // Если результат есть и можно использовать кэш
            if (useCache && cachedValue != null){
                // Отдаем кэшированное значение
                PrintWriter writer = response.getWriter();
                writer.write(cachedValue);

                writer.flush();
                writer.close();

            } else {

                // Производим сжатие и кэширование резальтата

                cache.put(url, temp.toString());

            }

Результат

package com.sultry.web.filters;

import java.io.CharArrayWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;

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

import javax.servlet.annotation.WebFilter;
import javax.servlet.annotation.WebInitParam;

/**
 * @author Oleg Tsarev<br/>
 * <b>mail:</b> tsarev.oi@mail.ru
 *
 */


/*
 *  <filter>
 *  <filter-name>CompressingFilter</filter-name>
 *  <filter-class>com.sultry.web.filters.CompressingFilter</filter-class>
 *  <init-param>
 *  <param-name>lineBreak</param-name>
 *  <param-value>8000</param-value>
 *  </init-param>
 *  <init-param>
 *  <param-name>noMunge</param-name>
 *  <param-value>false</param-value>
 *  </init-param>
 *  <init-param>
 *  <param-name>preserveSemi</param-name>
 *  <param-value>false</param-value>
 *  </init-param>
 *  <init-param>
 *  <param-name>disableOptimizations</param-name>
 *  <param-value>false</param-value>
 *  </init-param>
 *  <init-param>
 *  <param-name>useCache</param-name>
 *  <param-value>true</param-value>
 *  </init-param>
 *  </filter>
 *
 *      <filter-mapping>
 *          <filter-name>CompressingFilter</filter-name>
 *          <url-pattern>*.css</url-pattern>
 *      </filter-mapping>
 *      <filter-mapping>
 *          <filter-name>CompressingFilter</filter-name>
 *          <url-pattern>*.js</url-pattern>
 *      </filter-mapping>
 */


@WebFilter(filterName = "CompressingFilter",
           initParams = {
                @WebInitParam(name = "lineBreak", value="8000"),
                @WebInitParam(name = "noMunge", value="false"),
                @WebInitParam(name = "preserveSemi", value="false"),
                @WebInitParam(name = "disableOptimizations", value="false"),
                @WebInitParam(name = "useCache", value="true")
           },
           urlPatterns = {"*.css""*.js"})
public class CompressingFilter implements Filter {

    private static final Logger LOGGER = Logger.getLogger(CompressingFilter.class.getName());

    private static final String LINE_PARAM = "lineBreak";
    private static final String MUNGE_PARAM = "noMunge";
    private static final String PRESERVE_PARAM = "preserveSemi";
    private static final String OPT_PARAM = "disableOptimizations";
    private static final String CACHE_PARAM = "useCache";

    private static final String JS_EXT = ".js";
    private static final String JSON_EXT = ".json";
    private static final String CSS_EXT = ".css";

    private static final String JS_CONTENT = "text/javascript";
    private static final String JS_X_CONTENT = "application/x-javascript";
    private static final String JSON_CONTENT = "application/json";
    private static final String CSS_CONTENT = "text/css";

    private static final String CLEAR_PARAM = "CF_CLEAR";
    private static final String SKIP_PARAM = "CF_SKIP";
    private static final String DEBUG_PARAM = "CF_DEBUG";

    private static final String CHARSET = "UTF-8";

    private FilterConfig config;

    private int lineBreak = -1;
    private boolean noMunge = false;
    private boolean preserveSemi = false;
    private boolean disableOptimizations = false;
    private boolean useCahce = true;

    private Map<String, String> cache = Collections.synchronizedMap(new LinkedHashMap<String, String>());

    @Override
    public void init(FilterConfig config) throws ServletException {
        this.config = config;

        LOGGER.log(Level.INFO,"Init CompressingFilter");

        String lineBreakParam = normalize(this.config.getInitParameter(LINE_PARAM));
        String noMungeParam = normalize(this.config.getInitParameter(MUNGE_PARAM));
        String preserveSemiParam = normalize(this.config.getInitParameter(PRESERVE_PARAM));
        String disableOptimizationsParam = normalize(this.config.getInitParameter(OPT_PARAM));
        String useCacheParam = normalize(this.config.getInitParameter(CACHE_PARAM));

        this.lineBreak = isNumber(lineBreakParam) ? Integer.parseInt(lineBreakParam) : this.lineBreak;
        this.noMunge = isBoolean(noMungeParam) ? Boolean.parseBoolean(noMungeParam) : this.noMunge;
        this.preserveSemi = isBoolean(preserveSemiParam) ? Boolean.parseBoolean(preserveSemiParam) : this.preserveSemi;
        this.disableOptimizations = isBoolean(disableOptimizationsParam) ? Boolean.parseBoolean(disableOptimizationsParam) : this.disableOptimizations;
        this.useCahce = isBoolean(useCacheParam) ? Boolean.parseBoolean(useCacheParam) : this.useCahce;

    }

    private boolean isNumber(String configValue) {
        if (configValue != null && configValue.matches("[0-9]+")){
            return true;
        }
        return false;
    }

    private boolean isBoolean(String configValue){
        if (configValue != null && configValue.matches("true|false")){
            return true;
        }
        return false;
    }

    private String normalize(String configValue) {
        if (configValue!=null)
            return configValue.trim().toLowerCase();
        return null;
    }

    private boolean isValidResource(String url) {
        if (url.endsWith(JS_EXT) || url.endsWith(JSON_EXT) || url.endsWith(CSS_EXT)) {
            return true;
        }
        return false;
    }

    private boolean isJSResource(String url) {
        if (url.endsWith(JS_EXT) || url.endsWith(JSON_EXT)) {
            return true;
        }
        return false;
    }

    private boolean isJSContentType(ServletResponse wrapper) {
        if (wrapper.getContentType() != null && (wrapper.getContentType().equals(JS_CONTENT) || wrapper.getContentType().equals(JS_X_CONTENT) || wrapper.getContentType().equals(JSON_CONTENT))) {
            return true;
        }
        return false;
    }

    private boolean isCSSResource(String url) {
        if (url.endsWith(CSS_EXT)) {
            return true;
        }
        return false;
    }

    private boolean isCSSContentType(ServletResponse wrapper) {
        if (wrapper.getContentType() != null && (wrapper.getContentType().equals(CSS_CONTENT))) {
            return true;
        }
        return false;
    }

    @Override
    public void doFilter(ServletRequest sRequest, ServletResponse sResponse, FilterChain next) throws IOException, ServletException {

        LOGGER.log(Level.INFO,"Start CompressingFilter");

        HttpServletRequest request = (HttpServletRequest)sRequest;
        HttpServletResponse response = (HttpServletResponse)sResponse;

        String url = request.getRequestURI().toLowerCase();

        LOGGER.log(Level.INFO"CompressingFilter:{0}", url);

        if (config != null && isValidResource(url)){

            tryToResetCache(request.getParameter(CLEAR_PARAM));

            boolean useCache = this.useCahce &&
                               request.getParameter(SKIP_PARAM) == null &&
                               request.getParameter(DEBUG_PARAM) == null;

            String cachedValue = cache.get(url);
            if (useCache && cachedValue != null){

                LOGGER.log(Level.INFO"Get resource ({0}) from cache", url);

                PrintWriter writer = response.getWriter();
                writer.write(cachedValue);

                writer.flush();
                writer.close();

            } else {

                PrintWriter writer = response.getWriter();
                CharResponseWrapper wrapper = new CharResponseWrapper(response);
                next.doFilter(request, wrapper);

                StringWriter temp = new StringWriter();
                StringReader reader = new StringReader(new String(wrapper.toString().getBytes(), CHARSET));

                if (isJSResource(url) || isJSContentType(wrapper)) {
                    compressJSResource(url,temp,reader,wrapper);
                } else if (isCSSResource(url) || isCSSContentType(wrapper)) {
                    compressCSSResource(url,temp,reader,wrapper);
                } else {
                    temp.write(wrapper.toString());
                    LOGGER.log(Level.WARNING"Resource skiping: {0}", url);
                }

                cache.put(url, temp.toString());
                LOGGER.log(Level.INFO"Added to cache: {0}", url);

                writer.write(temp.toString());

                writer.flush();
                writer.close();
            }
        } else {
            next.doFilter(request, response);
        }

    }

    private void tryToResetCache(String param) {
        if(param != null){
            this.cache.clear();
        }
    }

    private void compressJSResource(String url, StringWriter temp, StringReader reader, CharResponseWrapper wrapper) {
        try {
            JavaScriptCompressor compressor =  new JavaScriptCompressor(reader,null);
            compressor.compress(temp, this.lineBreak!this.noMungefalsethis.preserveSemithis.disableOptimizations);
            LOGGER.log(Level.INFO"Compress js resource: {0}", url);
        } catch (Exception e) {
            LOGGER.log(Level.WARNING"Can not compress resource:{0}", url);
            temp.write(wrapper.toString());
        }
    }

    private void compressCSSResource(String url, StringWriter temp, StringReader reader, CharResponseWrapper wrapper) {
        try {
            CssCompressor compressor =  new CssCompressor(reader);
            compressor.compress(temp, this.lineBreak);
            LOGGER.log(Level.INFO"Compress css resource: {0}", url);
        } catch (Exception e) {
            LOGGER.log(Level.WARNING"Can not compress resource:{0}", url);
            temp.write(wrapper.toString());
        }
    }

    @Override
    public void destroy() {
        this.config = null;
    }

}

class CharResponseWrapper extends HttpServletResponseWrapper {

    private CharArrayWriter output;

    @Override
    public String toString() {
       return output.toString();
    }

    public CharResponseWrapper(HttpServletResponse response){
       super(response);
       output = new CharArrayWriter();
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
     return new CharOutputStream(this.output);
    }

    @Override
    public PrintWriter getWriter(){
       return new PrintWriter(output);
    }
}

class CharOutputStream extends ServletOutputStream {

    private CharArrayWriter output;

    public CharOutputStream(CharArrayWriter output){
     this.output = output;
    }

    @Override
    public void write(int b) throws IOException {
     output.write(b);
    }

}

Вот и все, дальше экспериментируем сами! ^_^

Progg it

4 комментария:

  1. В чем выгода от использований этого, вместо того, чтобы сделать сжатие во время сборки war-а и потом уже использовать сжатые css/js ресурсы ?

    ОтветитьУдалить
  2. Дело в том, что не везде сборка war является контролируемым процессом. Например, в моем случае сборка и деплоинг полностью автоматический процесс - напрямую из репозитория, а я как разработчик только делаю commit для своих изменений. Внедрять подобное сжатие в этот механизм для всех проектов не имеет смысла, а вот использовать такой легковесный фильтр становиться выгодным.

    ОтветитьУдалить
  3. Раз есть автоматический процесс, когда это еще легче. Нужно создать только задачу для минимизации ресурсов во время деплои и использовать их.

    ОтветитьУдалить
  4. Речь идет не о таком простом механизме, это не ant или maven. Добавление подобного функционала - хорошее решение, но не в том случае, когда над проектом работают десятки человек, каждый в произвольное время может сделать коммит и вызвать back deploing. Так как речь идет о статике, то ее нужно будет обрабатывать постоянно. Хотелось бы минимизировать нагрузку не только на систему развертывания, но и на сервер, где будет происходить частичный сброс кэша после такого деплоинга. Усовершенствованный вариант подобного легко фильтра защищает от ненужных операций, более того предотвращает отладку в случае ошибки деплоинга.

    ОтветитьУдалить