воскресенье, 13 мая 2012 г.

Использование в Wicket компонентов, написанных на JavaScript

В этой статье я постараюсь показать, каким образом можно использовать в Wicket-приложении клиентский компонент, полностью написанный на JavaScript. На практическом примере я рассмотрю, какие паттерны, на мой взгляд, были бы наиболее удобны и уместны при решении такой задачи.

Изложение не претендует на полноту и базируется на несколько надуманном примере, но, надеюсь, оно поможет вам сэкономить время, если перед вами вдруг стоит задача, аналогичная той, которую пришлось решать мне: использовать в Wicket-приложении набор JavaScript-компонентов, разработанных в компании, в которой я работаю.

Ситуация

Представим ситуацию: вам нужно написать web-приложение для очень требовательного Заказчика.

В данном случае, я надеюсь, написание имени Заказчика именно с заглавной буквы З должно дать вам представление о действительной важности этой персоны!

Требовательность Заказчика выражается в повышенных требованиях, которые он предъявляет как к функциональности, так и к интерфейсу будущего приложения. Интерфейс должен быть стильным, модным и современным. Функциональность приложения должна быть настолько очевидной и простой, насколько это возможно. В идеале вся функциональность приложения должна сводиться к одной большой красивой кнопке, нажимая на которую Заказчик будет получать необходимый ему результат здесь и сейчас. И, конечно же, никаких страниц логина! Они только вносят в приложение беспорядок и угрожают безопасности системы! :) Впрочем, об этом как-нибудь в другой раз...

Вы являетесь большим поклонником фреймворка Apache Wicket и не представляете, что такое удобное и красивое приложение можно написать на чем-то еще. И вы уже даже присмотрели компонент, при помощью которого можно реализовать стильную и элегантную Главную Кнопку приложения... Однако, вот незадача: кнопка реализована на JavaScript и работает как чисто клиентский виджет. Так что остается придумать, как сделать так, чтобы эта кнопка органично вписалась в Wicket-приложение.

Дано

JavaScript-компонент кнопки называется CoolButton, и стоит отметить следующие его отличительные черты.

При инициализации в конструктор компонента передается объект настройки, который должен содержать следующие ключевые параметры:

  • elementId - идентификатор элемента HTML-разметки страницы, который будет служить контейнером для компонента,
  • caption - надпись на кнопке,
  • width - ширина,
  • height - высота,
  • textColor - цвет надписи,
  • backColor - цвет самой кнопки.

Все параметры, кроме первого, являются также свойствами компонента кнопки, и к ним можно получить доступ при помощи соответствующих геттеров/сеттеров. Стиль вызова геттеров/сеттеров следующий:

  • property() или вызов функции без параметров - геттер для соответствующего свойства property, 
  • property(value) или вызов функции с одним параметром (обозначающим значение, которое нужно установить) - сеттер для соответствующего свойства property.

Кроме того, компонент содержит функции для установки обработчиков его событий:

  • addOnMouseOverHandler(func) для установки обработчика события прохождения указателя мыши над кнопкой, 
  • addOnMouseOutHandler(func) для установки обработчика события выхода указателя мыши за пределы кнопки,
  • addOnClickHandler(func) для установки обработчика события нажатия на кнопку.

Инициализация компонента средствами Wicket

Как показывает опыт, ключевой момент при разработке приложения на Wicket состоит в создании набора качественных компонентов, с которыми будут взаимодействовать пользователи. Если вы создали логичные, инкапсулированные, "high cohesion", "low coupling" виджеты, работающие в рамках бизнес-логики вашего приложения, можете считать, что больше половины дела уже сделано.

Следуя этой логике, можно предположить, что создание Wicket-компонента, инкапсулирующего взаимодействие с JavaScript-компонентом кнопки - это как раз то, что нам нужно!

Начнем создание компонента с того, что расширим какой-нибудь стандартный тип компонентов Wicket. Так как корневой класс Component по сути слишком низкоуровневый для наших целей, его мы использовать не будем. Если бы логика нашего компонента допускала наличие у него детей, мы могли бы выбрать класс WebMarkupContainer. Но в нашем случае это не так, поэтому остановимся на классе WebComponent:

public class CoolButton extends WebComponent {
    // ...
}

Так как, в принципе, понятно, что JavaScript-компонент кнопки будет использовать в качестве контейнера тот же самый элемент HTML, к которому будет привязан наш Wicket-компонент с помощью wicket:id, выполним еще несколько пунктов:

public class CoolButton extends WebComponent {

   public CoolButton(String id) {
       super(id);
       setOutputMarkupId(true);
   }

   @Override
   public void onComponentTagBody(MarkupStream markupStream, ComponentTag openTag) {
       replaceComponentTagBody(markupStream, openTag, "");
   }

   @Override
   protected void onComponentTag(ComponentTag tag) {
       super.onComponentTag(tag);
       tag.setName("div");
   }
}

Во-первых, в конструкторе компонента с помощью setOutputMarkupId(true) мы указываем, что в итоговой разметке для тега компонента нужно указывать уникальный атрибут id, сгенерированный Wicket.

Во-вторых, мы переопределяем метод onComponentTagBody и полностью очищаем в нем содержимое тега компонента. Это делается для того, чтобы защититься от ошибок, связанных с наличием внутри тега какого-нибудь мусора, а также для того, чтобы можно было использовать внутри тега какую-нибудь разметку в случае, если хочется предпросматривать шаблоны Wicket.

В-третьих, мы переопределяем метод onComponentTag и изменяем в нем имя тега компонента на div, так как именно с этим тегом умеет работать компонент кнопки.

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

Мы планируем управлять видом кнопки, поэтому создадим специальный класс, инкапсулирующий все настройки, которые можно указывать при создании JavaScript-компонента кнопки:

public class CoolButtonProperties implements Serializable {

    // Надпись на кнопке
    private String caption;

    // Ширина кнопки
    private int width;

    // Высота кнопки
    private int height;

    // Цвет надписи на кнопке (CSS-цвет 
    // или строка RGB-цвета с символом # в начале)
    private String textColor;

    // Цвет кнопки (CSS-цвет или строка RGB-цвета с символом # в начале)
    private String backColor;

    public CoolButtonProperties(String caption) {
        this.caption = caption;
    }

    // ...
    // Геттеры и сеттеры для каждого из свойств
    // ...

}

Значение для свойства caption передается через параметр конструктора, так как кнопка без надписи на ней имеет мало смысла (по крайней мере, для нашего Заказчика).

Если значение свойства не задано или для свойства задано некорректное значение, геттер этого свойства вернет значение по умолчанию.

Теперь объект класса CoolButtonProperties мы можем передать как параметр конструктора компонента CoolButton:

public CoolButton(String id, CoolButtonProperties properties) {
    super(id);
    setOutputMarkupId(true);

    if (properties == null) {
        throw new IllegalArgumentException("CoolButton component properties can't be null.");
    }
    this.properties = properties;
}

Как можно заметить, мы сохранили ссылку на объект настроек в приватном поле нашего компонента. Теперь дело за малым: вывести скрипт инициализации кнопки на страницу с переданными параметрами.

Для это переопределим метод renderHead:

@Override
public void renderHead(IHeaderResponse response) {
     renderReferences(response);
     renderInitJavaScript(response);
}

В методе renderReferences мы будем выводить на страницу ссылки на все ресурсы, необходимые для работы JavaScript-компонента:

private void renderReferences(IHeaderResponse response) {
    response.renderJavaScriptReference(new PackageResourceReference(
              CoolButton.class, "cool-button.js"));
}

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

Во втором методе renderInitJavaScript будем отрисовывать непосредственно JavaScript инициализации при помощи строчки кода:

response.renderOnDomReadyJavaScript(initJavaScriptStr);

Данный вызов сообщит объекту response, что скрипт, являющийся значением переменной initJavaScriptStr, нужно вывести на страницу внутри функции-обработчика onDOMReady, то есть события готовности дерева документа. Таким образом, наша кнопка будет отрисовываться уже на всем готовеньком.

Строку скрипта теоретически можно формировать каким угодно способом, например, конкатенацией строк, или же с помощью StringBuilder'а. Однако ж поступать подобным образом Создатели не завещали!.. Создатели фреймворка, я имею ввиду. Для таких вещей в Wicket есть специальный класс: org.apache.wicket.util.template.TextTemplate.

Принцип работы этого класса следующий: сначала в текстовом файле описывается шаблон будущего скрипта, в котором все места для вставки переменных (значений свойств, идентификаторов и т.д.) обозначаются при помощи специальных меток вида ${variable}, где variable - имя переменной:

// создание пространства имен компонента, если оно не существует
if (!${namespace}) {
    ${namespace} = {};
} 

// инициализация компонента CoolButton
${variableName} = new CoolButton({
    elementId: "${elementId}",
    caption: "${caption}",
    width: ${width},
    height: ${height},
    textColor: "${textColor}",
    backColor: "${backColor}"
})

Затем в коде формируется экземпляр Map с соответствующими значениями всех переменных (ключами являются имена переменных), который передается в качестве аргумента методу interpolate объекта TextTemplate. Метод interpolate собственно и производит всю работу по подстановке значений в шаблон инициализирующего JavaScript. Выглядит это следующим образом:

private void renderInitJavaScript(IHeaderResponse response) {
   // получаем шаблон из файла init.js.template, 
   // который находится в пакете класса CoolButton
   TextTemplate initJSTemplate = new PackageTextTemplate(
           CoolButton.class, "init.js.template");

   // заполняем отображение переменных шаблона
   Map<String, Object> variables = new HashMap<String, Object>();
   // идентификатор элемента HTML для отрисовки кнопки
   variables.put("elementId", this.getMarkupId());
   // пространство имен JavaScript для компонента
   variables.put("namespace", this.getJSNamespace());
   // имя переменной JavaScript для компонента
   variables.put("variableName", this.getJSVariableName());
   // параметры инициализации
   variables.put("caption", this.properties.getCaption());
   variables.put("width", this.properties.getWidth());
   variables.put("height", this.properties.getHeight());
   variables.put("textColor", this.properties.getTextColor());
   variables.put("backColor", this.properties.getBackColor());

   // момент интерполяции!
   initJSTemplate.interpolate(variables);

   // отрисовка итогового скрипта
   response.renderOnDomReadyJavaScript(initJSTemplate.asString());
}

Необходимо обратить внимание на переменные variableName и namespace. Первая переменная необходима для того, чтобы в дальнейшем мы могли получить ссылку на наш компонент (а она нам еще понадобится - даже не сомневайтесь); вторая нужна для того, чтобы не засорять именем переменной глобальное пространство имен. Методы getJSVariableName и getJSNamespace могут быть реализованы, например, следующим образом:

public String getJSVariableName() {
    // используем идентификатор разметки в качестве имени переменной, 
    // так как он является уникальным на странице
    return getJSNamespace() + "." + this.getMarkupId();
}

private String getJSNamespace() {
    return "Wicket.CoolComponents";
}

Очевидно, пространство имен Wicket.CoolComponents может быть не инициализировано до начала использования компонентов. Предупреждающей инициализацией пространства имен и заняты первые три строчки шаблона инициализации JavaScript.

После того, как код инициализации готов, можно уже даже попробовать использовать компонент в прикладном коде на странице. Инициализируем объект свойств компонента:

CoolButtonProperties theCoolestButtonProps = new CoolButtonProperties("Сделать все красиво");
theCoolestButtonProps.setWidth(280);
theCoolestButtonProps.setHeight(80);
theCoolestButtonProps.setTextColor(FONT_COLOR);
theCoolestButtonProps.setBackColor(BACK_COLOR_NORMAL);

И сам компонент:

CoolButton theCoolestButton = new CoolButton("the_coolest_button", theCoolestButtonProps);
add(theCoolestButton);

Не забываем добавить соответствующий тег <div wicket:id="the_coolest_button"><div> в разметку, и вот она - Клевая Кнопка:

Заказчику неожиданно понравилось.

Обработка событий компонента

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

Если сказано, то почему не может быть сделано? Тем более, что возможности для этого в JavaScript-компоненте есть. А именно: имеется возможность добавить обработчики событий прохождения указателя мыши над кнопкой и выхода его за пределы кнопки, а также можно изменить цвет кнопки с помощью сеттера в любой момент времени. Остается придумать, как использовать данный потенциал в Wicket.

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

В качестве родительского класса используем класс org.apache.wicket.behavior.Behavior:

public abstract class CoolButtonMouseEventsBehavior extends Behavior {

    private CoolButton coolButton;

    @Override
    public void onConfigure(Component component) {
        if (!(component instanceof CoolButton)) {
            throw new RuntimeException("Behavior should be attached to component of class "
                    + CoolButton.class.getCanonicalName());
        }
        this.coolButton = (CoolButton) component;
    }
}

В методе onConfigure поведения мы сохраняем ссылку на компонент, к которому поведение будет привязано, для того, чтобы иметь возможность обратиться к этому компоненту в методе renderHead.

В Wicket версии 1.5.xx, ссылка на целевой компонент передается непосредственно в метод renderHead, так что вполне возможно обойтись без переопределения onConfigure. Но мне кажется, что проверить тип компонента и породить исключение в случае неправильного типа будет красивее именно в методе onConfigure.

В методе renderHead поведения мы будем выводить на страницу JavaScript, сформированный из следующего шаблона:

${variableName}.addOnMouseOverHandler(function() {
    ${onMouseOverJS}
});
${variableName}.addOnMouseOutHandler(function() {
    ${onMouseOutJS}
})

Как видно, мы просто вызовем функции добавления обработчиков событий для переменной, ссылающейся на компонент кнопки.

Здесь имеет место маленькое допущение: дело в том, что по внутренней логике фреймворка метод renderHead поведения будет вызван после метода renderHead компонента, так что мы стопроцентно будем добавлять обработчики событий к уже проинициализированному компоненту. В противном случае нам бы пришлось формировать итоговый JavaScript для компонента с учетом всех добавленных к нему поведений.
Получить все поведения компонента можно с помощью component.getBehaviors().

Код для формирования JavaScript, который будет добавлять обработчики событий к компоненту:

@Override
public void renderHead(Component component, IHeaderResponse response) {
    super.renderHead(component, response);

    // формируем JavaScript добавления обработчиков событий
    TextTemplate mouseEventsJSTemplate = new PackageTextTemplate(CoolButtonMouseEventsBehavior.class, 
        "mouse-events.js.template");

    // заполняем отображение переменных шаблона
    Map<String, Object> variables = new HashMap<String, Object>();
    variables.put("variableName", coolButton.getJSVariableName());
    variables.put("onMouseOverJS", getMouseOverJavaScript(coolButton));
    variables.put("onMouseOutJS", getMouseOutJavaScript(coolButton));

    // интерполяция переменных
    mouseEventsJSTemplate.interpolate(variables);

    response.renderOnDomReadyJavaScript(mouseEventsJSTemplate.asString());
}

// Получение JavaScript для обработчика события прохождения мыши на компонентом
protected abstract String getMouseOverJavaScript(CoolButton coolButton);

// Получение JavaScript для обработчика события выхода мыши за пределы компонента
protected abstract String getMouseOutJavaScript(CoolButton coolButton);

В качестве имени переменной для компонента используем значение coolButton.getJSVariableName() (именно для этого мы в свое время сделали этот метод общедоступным). Текст JavaScript обработчиков событий будем возвращать при реализации абстрактных методов getMouseOverJavaScript и getMouseOutJavaScript в дочерних классах.

Теперь мы можем использовать поведение в прикладном коде:

theCoolestButton.add(new CoolButtonMouseEventsBehavior() {

   @Override
   protected String getMouseOverJavaScript(CoolButton coolButton) {
       // ...
   }

   @Override
   protected String getMouseOutJavaScript(CoolButton coolButton) {
       // ...
   }
});

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

Реализуем метод для генерации текста JavaScript вызова функции кнопки следующим образом:

public String getJSFunctionCall(String functionName, Object... parameters) {
    StringBuilder functionCallBuilder = new StringBuilder(getJSVariableName());
    // вызов функции...
    functionCallBuilder.append(".").append(functionName).append("(");
    // с параметрами...
    for (Object parameter : parameters) {
        if (parameter == null || parameter instanceof Number || parameter instanceof Boolean) {
            // в случае null, чисел и логических значений просто добавляем значение параметра
            functionCallBuilder.append(parameter);
        } else {
            // в противном случае, используем кавычки
            functionCallBuilder.append("\"").append(parameter).append("\"");
        }
    }
    functionCallBuilder.append(");");
    return functionCallBuilder.toString();
}

При генерации JavaScript для компонентов какой-нибудь библиотеки имеет смысл использовать для этого отдельную подсистему классов.

В итоге получаем следующий прикладной код:

theCoolestButton.add(new CoolButtonMouseEventsBehavior() {

   @Override
   protected String getMouseOverJavaScript(CoolButton coolButton) {
       return coolButton.getJSFunctionCall("backColor", BACK_COLOR_OVER);
   }

   @Override
   protected String getMouseOutJavaScript(CoolButton coolButton) {
       return coolButton.getJSFunctionCall("backColor", BACK_COLOR_NORMAL);
   }
});

Думаю, Заказчик будет рад:

Связь компонента с сервером с помощью AJAX

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

Как известно, Wicket по умолчанию предоставляет широкие возможности по работе с AJAX, давайте ими и воспользуемся.

Для добавления к кнопке обработчика, ответственного за событие нажатия на кнопку и работающего по AJAX, создадим новое поведение, которое будет расширять класс org.apache.wicket.ajax.AbstractDefaultAjaxBehavior:

public abstract class CoolButtonClickAjaxBehavior extends AbstractDefaultAjaxBehavior {
    // ...
}

Во многом новое AJAX поведение будет аналогично реализованному в предыдущем разделе поведению, ответственному за события указателя мыши. Например, оно также будет добавлять на страницу JavaScript, устанавливающий обработчик события. Шаблон JavaScript будет выглядеть следующим образом:

${variableName}.addOnClickHandler(function() {
     ${onClickJS}
})

В шаблоне у нас всего две переменные, поэтому заполненение экземпляра класса Map для интерполяции в шаблон будет очень простым:

variables.put("variableName", coolButton.getJSVariableName());
variables.put("onClickJS", getCallbackScript());

В качестве содержимого функции обработчика передается возвращаемый методом getCallbackScript() JavaScript, который будет вызывать серверный метод respond поведения. Собственно, нам остается реализовать только один этот метод:

@Override
protected void respond(AjaxRequestTarget target) {
    try {
        onClick(target);
    } catch (RuntimeException e) {
        onError(target, e);
    }
}

Метод onClick предполагается для реализации при добавлении поведения к кнопке, поэтому его мы делаем абстрактным:

protected abstract void onClick(AjaxRequestTarget target);

Метод onError, который будет обрабатывать возможные ошибки в onClick и опционален для переопределения, по умолчанию выглядит следующим образом:

protected void onError(AjaxRequestTarget target, RuntimeException e) {
    if (e != null) {
        throw e;
    }
}

Добавление поведения к компоненту кнопки на странице имеет следующий вид в коде:

theCoolestButton.add(new CoolButtonClickAjaxBehavior() {

    @Override
    protected void onClick(AjaxRequestTarget target) {
         // вызов всей необходимой функциональности приложения...
    }

    // и т.д...
}

и следующий образом выглядит в приложении:

Ну, что еще можно сказать? Profit! Мы даже полностью разработали наше маленькое, но очень могущественное приложение.

Исходный код

Исходные коды примера, рассматриваемого в данном посте, доступны здесь. Для того, чтобы использовать их в Wicket-приложении, необходимо подключить библиотеку к приложению и добавить что-то вроде строчки

mountPage("/coolbutton", CoolButtonPage.class);

в метод init класса приложения.

Библиотека для работы требует Wicket версии 1.5.xx и Java версии 1.6.

Дополнительные источники

Если вы серьезно занялись задачей, связанной с использованием в Wicket-приложении компонентов, написанных полностью на JavaScript, не лишним для вас будет изучение исходного кода, уже созданного для решения такой же задачи. В первую очередь рекомендую посмотреть на компоненты, использующиеся в рамках фреймворка. В первую очередь это компоненты из пакета org.apache.wicket. extensions. Например, компонент org.apache.wicket.extensions.ajax.markup.html.autocomplete. AutoCompleteTextField - это строка c автодополнением, прям, как у Google. А компонент org.apache.wicket.extensions.yui.calendar.DatePicker из библиотеки wicket-datetime является примером использования в Wicket компонента-календаря из JavaScript-библиотеки Yahoo UI (YUI).

Также в природе существуют две библиотеки, которые позволяют использовать в Wicket компоненты, созданные для самой популярной JavaScript-библиотеки JQuery: это JQWicket и wiQuery. Их исходные коды тоже очень интересно посмотреть.

Комментариев нет:

Отправить комментарий