Современные системы сборки позволяют полностью автоматизировать процесс компиляции и запуска приложения из исходников. На целевой машине необходим лишь JDK, все остальное включая и сам сборщик загрузится налету. Надо лишь правильно построить процесс сборки и по запуску двух команд получить, например, следующее: запуск базы данных, выполнение SQL скриптов, компиляцию Java, Javascript и CSS файлов, запуск контейнера сервлетов. Реализуется это с помощью Gradle, HSQLDB, Liquibase, Google closure compile и Gretty. Подробнее в статье.
Содержание
- Введение
- Первый запуск проекта
- Каркас приложения (Spring MVC)
- Настройка параметров сервера (Gretty)
- Логирование (Logback)
- Запуск базы данных (HSQL)
- Создание таблиц (liquibase)
- Заполнение базы данными (liquibase)
- Авторизация пользователей (Spring security)
- Изменение страницы входа
- Работа с javascript
- Работа с css
- ORM (JPA/Hibernate)
- REST сервис
Введение
Попытавшись в недалеком прошлом разобраться с перспективным сборщиком Gradle, я обнаружил неприятную особенность. Много где пишут, о том, как замечательно, просто и быстро можно сделать отдельные вещи. Но цельную картину приходиться собирать самому. Попытаюсь устранить данный недостаток в материалах по Gradle, на примере небольшого приложения. В самом приложении я постараюсь рассмотреть, актуальный на данный момент базис Java EE.
Внимание! Данная статья написана новичком для новичков. Не исключено наличие неточностей или ошибок.
Перед тем как начать писать и запускать приложение определимся с требованиями. Они будут небольшими — три странички: одна корневая, доступная всем, другая доступная только авторизованному пользователю. Последняя странница входа, она же страница создания нового пользователя. Ничего сверх сложного и не очевидного. Типичный «Hello, world!». Но даже такого небольшого примера хватит, чтобы показать всю мощь и чудовищность Java EE. При типичной разработке, число конфигурационных файлов необходимых для запуска проекта окажется пугающее велико.
К счастью, данная проблема возникает лишь на старте. И дело не только в выборе Java в качестве языка сервера. Само по себе серверное приложение не полноценно. Ему необходимы внешние ресурсы: база данных или хотя бы контейнер сервлетов. Базу данных необходимо предварительно подготовить, создать таблицы и заполнить данными. Последнее необходимо даже в случае использования нереляционных баз данных с отсутствием жестких схем.
Настройка политики логирования, работа с базой данных, автоматизация обработки JavaScript и CSS. Есть множество вещей, которые необходимо настроить перед тем, как использовать приложение. Постараемся это сделать.
Используемые приложения, плагины и библиотеки
- JDK/JRE — виртуальная машина для java.
- Idea — среда разработки.
- Gradle — система сборки
- Spring — каркас приложения, по факту стандартный фреймворк для Java EE приложений
- Hibernate — реализация JPA
- hsqldb — база данных
- Jetty — контейнер сервлетов
- Gretty — Gradle плагин для быстрого и удобного запуска контейнеров
- Liquibase — библиотека для работы с sql скриптов.
- Gradle JS Plugin — слияние и сжатие javasript файлов
- Gradle CSS Plugin — слияние и сжатие css файлов
Список большой, но для успешного запуска и модификации приложения необходим лишь первый пункт и доступ к интернету. Дополнительно рекомендую поставить все-таки среду разработки, но в принципе можно обойтись и без нее, запуская сборку через консоль.
Первый запуск проекта
Создадим новый проект, собирающийся с помощью Gradle. Структура созданного проекта.
Как видно, даже новый проект содержит в себе немало файлов. Условно они делятся на две группы. Одни должны храниться в системе управления версиями (VCS), другие нет.
Следующие файлы должны храниться в VCS.
- build.gradle — файл описывающий как собирать проект.
- settings.gradle — общие настройки для сборки проекта.
- папка src — содержит в себе исходный код и ресурсы проекта.
- LICENSE.txt — лицензия по которой распространяется проект (не создается по умолчанию ).
- gradlew, gradlew.bat, gradle-wrapper.jar и gradle-wrapper.properties — служебные файлы для gradle-wrapper. Wrapper это легковесная оболочка над gradle, скачивающая при необходимости конкретную (указанную в gradle-wrapper.properties) версию сборщика. Соответственно предварительная установка gradle для сборки проекта не требуется.
Следующие файлы имеются в составе проекта, но не должны храниться в VCS.
- gradle.properties — файл с локальными настройками gradle (не создается по умолчанию ).
- tit.iml — файл проекта idea
- Служебные папки .gradle, build, out
У каждой системы версий есть свой файл, который описывает какие папки/файлы должны храниться в VCS. Он сам тоже должен храниться в VCS, чтобы соблюдалось единство у всех разработчиков. В случае использования git, содержимое .gitignore примерно следующее.
.gitignore
*.iml
.idea/
/out/
/gradle.properties
Для запуска нового проекта воспользуемся плагином Gretty для Gradle, умеющим скачивать и разворачивать контейнер сервлетов. Подключается плагин в файле build.gradle.
plugins {
id "org.akhikhl.gretty" version "1.2.4"
}
С использованием плагина скачивание, установка и запуск сервера сокращается до одной команды.
gradlew jettyStart
Так же запускать можно прямо из Idea, выбрав 'jettyStart'. В списке заданий для gradle. По этой команде будет скачена и автоматически развернута 9 версия jetty. Проект будет доступен по адресу localhost:8080/gull/. Для остановки сервера сервера используется 'jettyStop'.
Не используйте jettyRun, так как он не корректно работает при запуске из Idea (завершается сразу после запуска).
Для запуска сервера в режиме отладки используется 'jettyStartDebug'
Подробней
При запуске через команду 'jettyStartDebug' сервер будет ждать пока к нему не подключиться отладчик. Чтобы это сделать в Idea необходимо создать новую конфигурацию через меню run->Edit Configuration
Добавляем новую конфигурацию 'remote'. Настройки по умолчанию не меняем.
Теперь можно выбрать новую конфигурацию и присоединиться к серверу.
Добавляем новую конфигурацию 'remote'. Настройки по умолчанию не меняем.
Теперь можно выбрать новую конфигурацию и присоединиться к серверу.
Spring MVC
По умолчанию проект умеет отображать только одну 'корневую' страницу. Для расширения функциональности воспользуемся Spring MVC, реализующим паттерн модель-представление-контроллер. В нашем случае представлением будет является JSP страница, а контроллером Java класс заполняющий модель данных.
Подключение библиотек.
build.gradle.
settings.gradle
dependencies {
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
}
settings.gradle
gradle.ext.springVersion = '4.2.2.RELEASE'
Подключение к проекту библиотеки spring-webmvc, неявно добавляет и ядро Spring, так как сама библиотека от него зависит. При желании ядро можно добавить и явно, указав следующие библиотеки.
- spring-core
- spring-beans
- spring-context
Можно сказать, что именно ядро Spring собирает разрозненные классы и файлы в единое приложение, через внедрение зависимостей.
В современных версиях Spring допустимо указывать всю конфигурацию прямо в коде. Мне данный подход кажется чересчур радикальным, все-таки конфигурация подключения к базе данных уместней смотрится в виде xml файла, а не кода. Поэтому я, использую смешанный подход часть через xml, часть через аннотации.
Описание о том как развертывать приложение храниться в web.xml. Там содержится три элемента.
- listener — просматривает конфигурационные файлы ( по умолчанию applicationContext.xml)
- servlet — Класс обрабатывающий запросы
- servlet-mapping- описывает за какие запросы, отвечает указанный сервлет. Например при указании '/' будут перехвачены все запросы, при указании '/admin/' только начинающиеся с 'admin'. В случае, если на один путь указывает несколько маппингов то используется самый точный из них.
web.xml
<?xml version=«1.0» encoding=«UTF-8»?>
<web-app xmlns:xsi=«www.w3.org/2001/XMLSchema-instance» version=«2.4»
xmlns=«java.sun.com/xml/ns/j2ee»
xsi:schemaLocation=«java.sun.com/xml/ns/j2ee java.sun.com/xml/ns/j2ee/web-app_2_4.xsd»>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</web-app>
<web-app xmlns:xsi=«www.w3.org/2001/XMLSchema-instance» version=«2.4»
xmlns=«java.sun.com/xml/ns/j2ee»
xsi:schemaLocation=«java.sun.com/xml/ns/j2ee java.sun.com/xml/ns/j2ee/web-app_2_4.xsd»>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</web-app>
applicationContext.xml — описывает бины общие для всего приложения.
applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
</beans>
dispatcher-servlet.xml — содержат бины для конкретного сервлета. Замечание файл конфигурации для сервлета по умолчанию называется так же как и сервлет с добавлением '-servlet.xml '. Для поддержания иерархичности в имени сервлета можно использовать ‘/’. Например сервлету ‘admin/dispatcher’ соответствует файл ‘src/main/webapp/WEBINF/admin/dispatcher-servlet.xml’
dispatcher-servlet.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:context="http://www.springframework.org/schema/context"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<!-- бин указывающий где лежат jsp для view -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:view-controller path="/" view-name="index"/>
<!--Включаем аннотации mvc -->
<mvc:annotation-driven/>
<!--Указываем в каких пакетах производить поиск -->
<context:component-scan base-package="com.intetm.web"/>
</beans>
В dispatcher-servlet.xml определен бин, который указывает где лежат JSP для view. Указана корневая ("/") страница и так же добавлена строчка <mvc:annotation-driven/>" включающая аннотации Spring-mvc. По умолчанию Spring будет искать данные аннотации только в бинах, которые описаны в данном контексте, поэтому укажем ссылку на пакет, который необходимо проверить.
Spring ищет все классы помеченные аннотацией Controller, а внутри них методы с аннотацией @RequestMapping. В качестве входного параметра метод принимает модель, которую необходимо заполнить данными. В качестве выходного параметра указывается имя view. Параметр аннотации value обрабатываемый адрес.
package com.intetm.web.login;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class LoginController {
private static final String HELLO_VIEW = "hello";
@RequestMapping(value = "/hello", method = RequestMethod.GET)
public String hello(Model model) {
model.addAttribute("subject", "world");
return HELLO_VIEW;
}
}
Доступ к переменным модели внутри view, производиться через конструкцию "${parametr_name}". Пример использования в hello.jsp.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
Hello, ${subject}!
</body>
</html>
На странице по умолчанию указываем ссылку, на странице приветствия.
index.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<a href="hello">Hello</a>
</body>
</html>
Результатом добавления и редактирования файлов, станет двух страничное приложение. Запускаем проект и смотрим полученный результат.
Настройка параметров сервера
Хотя настройки сервера по умолчанию могут подходить при запуске простейшего приложения, в дальнейшем их придется изменить. Посмотрим, как можно настраивать порты, контекст сервера. Добавим файл в classpath.
Для настройки порта достаточно указать gretty использовать соответствующий порт в build.gradle.
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
gretty {
httpPort = serverHttpPort
}
и порт по умолчанию в settings.gradle
//default config
gradle.ext.serverHttpPort = 8080
Теперь если в gradle.properties будет найдена переменная 'serverHttpPort' то будет использоваться она. В противном случае использоваться значение по умолчанию из settings.gradle. Так как settings.gradle находиться в git, а gradle.properties исключена из него это позволяет с одной стороны централизованно обновлять значение по умолчанию, и настраивать значение локально без конфликтов с git.
Для контекста сервера также предпочтительно иметь файл по умолчанию хранящийся в VCS и локальную свободно изменяемую копию. Переключение между локальным и общей копией реализуем через указание переменной serverContextFile. По умолчанию же использоваться копия из VCS. Дополнительно для тренировки сделаем задачу в Gradle, создающую локальную копию.
build.gradle.
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
}
task copyEnvironment(type: Copy) {
from 'src/test/resources/environment'
into serverResourcesPath
}
Значения по умолчанию settings.gradle
gradle.ext.serverResourcesPath = "dev/resources"
gradle.ext.serverContextFile = "src/test/resources/environment/jetty-context.xml"
Пустой файл конфигурации для jetty
src/test/resources/environment/jetty-context.xml
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="ExampleServer" class="org.eclipse.jetty.server.Server">
</Configure>
Копия файла создается в папке dev/resources. В дальнейшем папка dev также будет использоваться для хранения логов и базы данных. Для исключения случайных коммитов, исключим всю папку dev из VCS.
Аналогично можно настраивать classpath сервера. Например добавить файл 'logback.xml' с настройками логов.
build.gradle.
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
gretty {
…
classPath = serverClassPath
}
settings.gradle
gradle.ext.serverClassPath = gradle.serverResourcesPath + "/classpath"
Логирование
История систем логирования в java довольно запутанная и печальная. В проекте используется более-менее актуальная связка из slf4j и logback. Для этого в build.gradle добавлены две зависимости.
dependencies {
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
…
}
Используемые версии в settings.gradle
gradle.ext.slf4jVersion = '1.7.13'
gradle.ext.logbackVersion = '1.1.3'
Для logback необходим файл logback.xml который будет описывать как именно записывать логи. Типичный файл с настройками содержит следующие компоненты
- Appender - канал(консоль, файл или что-то другое) для логирования. Задает формат записи сообщения, путь к файлу, политику ротации файлов.
- Root - перехватывает все сообщения, с уровнем не ниже указанного и переправляет их в указанные appender.
- Logger — в отличие от root перехватывает только сообщения от классов входящих внутрь указанного пакета. (Подразумевается, что классы логируют сообщения стандартным образом, указывая в качестве имени логгера полное имя класса)
Пример файла logback.xml
<!--suppress XmlUnboundNsPrefix -->
<configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://www.padual.com/java/logback.xsd"
scan="true" scanPeriod="10 seconds">
<!-- Вывод напрямую в консоль -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- Минимальный необходимый уровень error. Debug, info и прочие более низкие уровни логироваться не будут. -->
<level>ERROR</level>
</filter>
<encoder>
<pattern>
<!--Указываем формат записи логов. Сначала дата, потом уровень ошибки, название логгера (чаще всего название класса) и сообщение-->
%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{128} - %msg%n
</pattern>
</encoder>
</appender>
<!-- Вывод в файл, поддерживается ротация логов -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- Название файла куда будут писаться логи-->
<file>dev/logs/error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- Минимальный необходимый уровень error. Debug, info и прочие более низкие уровни логироваться не будут. -->
<level>ERROR</level>
</filter>
<!-- Политика ротации логов. Указывает как часто логи будут выделяться в отдельный файл-->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>dev/logs/old/%d{yyyy-MM-dd}.error.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>
<!--Указываем формат записи логов. Сначала дата, потом уровень ошибки, название логгера (чаще всего название класса) и сообщение-->
%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{128} - %msg%n
</pattern>
</encoder>
</appender>
<!-- Вывод в файл, поддерживается ротация логов -->
<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- Название файла куда будут писаться логи-->
<file>dev/logs/debug.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
<!-- Политика ротации логов. Указывает как часто логи будут выделяться в отдельный файл-->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>dev/logs/old/%d{yyyy-MM-dd}.debug.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>
<!--Указываем формат записи логов. Сначала дата, потом уровень ошибки, название логгера (чаще всего название класса) и сообщение-->
%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{128} - %msg%n
</pattern>
</encoder>
</appender>
<!-- Вывод в файл, НЕ поддерживается ротация логов -->
<appender name="SQL_FILE" class="ch.qos.logback.core.FileAppender">
<!-- Название файла куда будут писаться логи-->
<file>dev/logs/sql.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>dev/logs/sql.lg</level>
</filter>
<!-- Очищает файл при запуске-->
<append>false</append>
<encoder>
<pattern>
<!--Указываем формат записи логов. Сначала дата, потом уровень ошибки, название логгера (чаще всего название класса) и сообщение-->
%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{128} - %msg%n
</pattern>
</encoder>
</appender>
<!-- Указываем, что все ошибки уровня DEBUG или выше, если выброшены классом входящим внутрь пакета com.intetm будут записаны в DEBUG_FILE -->
<logger name="com.intetm" level="DEBUG">
<appender-ref ref="DEBUG_FILE"/>
</logger>
<!-- Логируем запросы hibernate-->
<logger name="org.hibernate.type" level="ALL">
<appender-ref ref="SQL_FILE"/>
</logger>
<logger name="org.hibernate" level="DEBUG">
<appender-ref ref="SQL_FILE"/>
</logger>
<!-- Все ошибки независимо от пакета будут записаны в консоль и ERROR_FILE-->
<root level="ERROR">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</configuration>
Добавляем строчку с записью логов в LoginController.
logger.debug("hello page");
Полный файл
package com.intetm.web.login;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class LoginController {
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
private static final String HELLO_VIEW = "hello";
@RequestMapping(value = "/hello", method = RequestMethod.GET)
public String hello(Model model) {
logger.debug("hello page");
model.addAttribute("subject", "world");
return HELLO_VIEW;
}
}
Запускаем проект. Убеждаемся, что при заходе localhost/gull/hello в файле dev\log\debug.log появляется запись.
Запуск базы данных
Для хранения информации о пользователях, паролях, ролях потребуется база данных. Теоретически возможно встроить базу данных прямо в само приложение, но возникнут проблемы с масштабированием, подключением и внешним редактором и прочим. Поэтому база данных будет внешней по отношению к приложению.
Oracle и прочие 'тяжелые' базы данных требуют предварительной установки. Это их плата за возможность масштабирования и огромную производительность. Подобные базы данных хорошо подходят для боевой эксплуатации, с нагрузкой в десятки тысяч пользователей. Во время разработки подобных нагрузок не предвидеться, поэтому воспользуемся небольшой базой данных HSQL.
HSQL не требует установки, запускается напрямую из jar файла или при использовании небольшой обертки и напрямую из Gradle. К сожалению, я не нашел стандартного способа запускать HSQL из Gradle в серверном режиме, поэтому написал небольшой велосипед и вынес в отдельный файл.
database.gradle
apply plugin: 'java'
task startDatabase() {
group = 'develop'
outputs.upToDateWhen {
return !available()
}
doLast {
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbFile = project.properties['dbFile'] ?: gradle.dbFile
def dbName = project.properties['dbName'] ?: gradle.dbName
def className = "org.hsqldb.server.Server";
def filePath = "file:${projectDir}/${dbFile};user=${dbUser};password=${dbPassword}";
def process = buildProcess(className, filePath, dbName)
wait(process)
}
}
def buildProcess(className, filePath, dbName) {
def javaHome = System.getProperty("java.home");
def javaBin = javaHome + File.separator + "bin" + File.separator + "java";
def classpath = project.buildscript.configurations.classpath.asPath;
def builder = new ProcessBuilder(javaBin, "-cp", classpath, className, "-database.0", filePath, "-dbname.0", dbName);
builder.redirectErrorStream(true)
builder.directory(projectDir)
def process = builder.start()
process
}
def wait(Process process) {
def ready = "From command line, use [Ctrl]+[C] to abort abruptly"
def reader = new BufferedReader(new InputStreamReader(process.getInputStream()))
def line;
while ((line = reader.readLine()) != null) {
logger.quiet line
if (line.contains(ready)) {
break;
}
}
}
import groovy.sql.Sql
task stopDatabase() {
group = 'develop'
outputs.upToDateWhen {
return available()
}
doLast {
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
def dbDrive = project.properties['dbDrive'] ?: gradle.dbDrive
ClassLoader loader = Sql.class.classLoader
project.buildscript.configurations.classpath.each { File file ->
loader.addURL(file.toURI().toURL())
}
//noinspection GroovyAssignabilityCheck
Sql sql = Sql.newInstance(dbUrl, dbUser, dbPassword, dbDrive) as Sql
sql.execute('SHUTDOWN;')
sql.close()
}
}
boolean available() {
try {
int dbPort = project.properties['dbPort'] ?: gradle.dbPort as int
String dbHost = project.properties['dbHost'] ?: gradle.dbHost
Socket ignored = new Socket(dbHost, dbPort);
ignored.close();
return false;
}
catch (IOException ignored) {
return true;
}
}
В build.gradle необходимо лишь подключить файл и указать на использования hsql.
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
…
apply from: 'database.gradle'
settings.gradle
//lib version
gradle.ext.hsqldbVersion = '2.3.2'
//default database config
gradle.ext.dbName = "xdb"
gradle.ext.dbFile = "dev/database/devDB"
gradle.ext.dbUser = "SA"
gradle.ext.dbPassword = "password"
gradle.ext.dbPort = 9001
gradle.ext.dbHost = "localhost"
gradle.ext.dbUrl = "jdbc:hsqldb:hsql://${gradle.dbHost}:${gradle.dbPort}/${gradle.dbName}"
gradle.ext.dbDrive = "org.hsqldb.jdbc.JDBCDriver"
Запускать базу данных возможно через консоль или через меню задач Gradle в Idea
gradlew startDatabase
После выполнения команды к базе данных можно подключиться через любой внешний редактор в том числе и через Idea. Пользователь/пароль по умолчанию «SA»/«password». Адрес — jdbc:hsqldb:hsql://localhost:9001/xdb
Выключение базы данных производиться аналогично.
gradlew stopDatabase
Создание таблиц
Перед тем как начать использовать реляционную базу данных в приложении, необходимо создать таблицы, индексы и прочие. Нереляционную базу данных в случае отсутствия жесткой схемы можно заполнять данными сразу. Но само заполнение данными придется производить и в том и в другом случае.
Для упорядочивания выполнения SQL скриптов будет использоваться Liquibase. Liquibase умеет выполнять скрипты в заданном порядке, следит за тем чтобы одни и те же скрипты не выполнялись дважды. Предупреждает об опасной ситуации, когда меняются файлы с уже исполненными скриптами. Поддерживает откаты на заданную точку или период. При использовании Liquibase сильно уменьшается число ошибок при работе с несколькими базами данных: боевой, тестовой и т.д.
Подключаем liquibase, описываем откуда брать файлы, куда проливать и создаем задачу.
build.gradle
plugins {
id 'org.liquibase.gradle' version '1.1.1'
}
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
Осталось только наполнить файл changelog.xml содержимым.
По совету из статьи будет использоваться следующая структура скриптов
/src
/sql
/main
/changelog.xml
/v-1.0
/2015.11.28_01_Create_User_table.sql
...
/changelog-v.1.0-cumulative.xml
/v-2.0
...
/changelog-v.2.0-cumulative.xml
В главный файл changelog.xml включаются только кумулятивные скрипты от каждой версии.
changelog.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<include file="src/sql/main/V-1.0/changelog-v.1.0-cumulative.xml"/>
</databaseChangeLog>
В файл changelog-v.1.0-cumulative.xml включаются все скрипты для 1 версии приложения.
changelog-v.1.0-cumulative.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet id="Version 1 tag" author="Sivodedov Dmitry">
<tagDatabase tag="Version 1"/>
</changeSet>
<include file="src/sql/main/V-1.0/2015.11.28_01_Create_User_table.sql"/>
</databaseChangeLog>
Конкретные же списки изменений хранятся только на самом низком уровне.
2015.11.28_01_Create_User_table.sql
--liquibase formatted sql
--changeset Sivodedov Dmitry:CREATE_TABLE_Users
CREATE TABLE Users (
id BINARY(16) NOT NULL PRIMARY KEY,
username VARCHAR_IGNORECASE(50) NOT NULL,
password VARCHAR(60) NOT NULL,
enabled BOOLEAN NOT NULL
);
--rollback drop table Users;
--changeset Sivodedov Dmitry:CREATE_TRIGGER_TRIG_BI_DM_USERS splitStatements:false
CREATE TRIGGER TRIG_BI_DM_USERS BEFORE INSERT ON Users
REFERENCING NEW AS NEW
FOR EACH ROW
BEGIN ATOMIC
IF NEW.id IS NULL
THEN
-- noinspection SqlResolve
SET NEW.id = UUID();
END IF;
END;
--rollback drop TRIGGER TRIG_BI_DM_USERS on Users;
--changeset Sivodedov Dmitry:CREATE_TABLE_Authorities
CREATE TABLE Authorities (
id BIGINT IDENTITY NOT NULL PRIMARY KEY,
userId BINARY(16) NOT NULL,
authority VARCHAR_IGNORECASE(50) NOT NULL,
CONSTRAINT fk_authorities_users FOREIGN KEY (userId) REFERENCES users (id)
);
--rollback drop table Authorities;
--changeset Sivodedov Dmitry:CREATE_INDEX_ix_auth_username
CREATE UNIQUE INDEX ix_auth_username ON Authorities (userId, authority);
--rollback drop INDEX ix_auth_username on Authorities;
Запускаем задачу 'updateDbMain', она автоматически при необходимости запустит базу данных. Результатом станут две таблицы.
Заполнение базы данными
Дополнительно создадим еще одну задачу, прогоняющую на базе данных скрипты только для разработки. Это бывает полезно, так как тестировать на пустой базе данных затруднительно, а наполнение тестовыми данными через SQL разумный способов.
Новая задача и ее параметры в build.gradle
liquibase {
activities {
…
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
Главный список
changelog.xml
src\sql\dev\changelog.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<include file="src/sql/dev/V-1.0/2015.11.28_01_Create_User.sql"/>
</databaseChangeLog>
Список изменений первой версии.
changelog-v.1.0-cumulative.xml
src\sql\dev\V-1.0\changelog-v.1.0-cumulative.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<include file="src/sql/dev/V-1.0/2015.11.28_01_Create_User.sql"/>
</databaseChangeLog>
Непосредственное добавление пользователя.
2015.11.28_01_Create_User.sql
sql\dev\V-1.0\2015.11.28_01_Create_User.sql
--liquibase formatted sql
--changeset Sivodedov Dmitry:Create_User
INSERT INTO USERS VALUES
('8a59d9547e5b4d9ca0a30804e8a33a94', 'admin', '$2a$10$GZtUdy1Z7Hpk0lYYG92CQeiW1f2c4e3XgA8wunVTDFyQJ2DAmH.x.', TRUE);
INSERT INTO AUTHORITIES VALUES (1, '8a59d9547e5b4d9ca0a30804e8a33a94', 'ROLE_ADMIN');
INSERT INTO AUTHORITIES VALUES (2, '8a59d9547e5b4d9ca0a30804e8a33a94', 'ROLE_USER');
--rollback delete from AUTHORITIES where userId = '8a59d9547e5b4d9ca0a30804e8a33a94';
--rollback delete from USERS where id = '8a59d9547e5b4d9ca0a30804e8a33a94';
Авторизация пользователей
Теперь после настройки и заполнения базы данных, можно использовать ее в качестве источника данных для авторизации пользователей. Воспользуемся одним из компонентов Spring — Spring Security.
Подключаем Spring Security
build.gradle
settings.gradle
dependencies {
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
runtime group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
// Драйверы для подключения к базе данных, при запуске сервера через gretty.
// Если будет использоваться сторонний сервер, то добавьте драйвер в classpath сервера.
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
runtime group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
settings.gradle
gradle.ext.springSecurityVersion = '4.0.2.RELEASE'
В файле с настройками spring security создаем authentication-manager. Для хеширования для паролей будет использоваться относительно стойкий BCrypt. Доступ к базе данных пока сделаем через два простых SQL запроса, со следующими контрактами.
- users-by-username-query — По заданному идентификатору пользователя возвращает только одну строчку (имя, пароль, блокировка), если пользователь найден. Иначе ничего.
- authorities-by-username-query — По заданному идентификатору пользователя список пар (имя пользователя, роль).
Неавторизованные пользователи, будут иметь роль 'ANONYMOUS'.
К странице hello получат доступ только пользователи с ролью USER. Внимание! В базе данных роль пользователя пишется, как ROLE_USER, но в конфигурации указывается именно 'USER'.
security.xml
<beans:beans xmlns:beans="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security-4.0.xsd">
<http>
<intercept-url pattern="/hello**" access="hasRole('USER')"/>
<form-login default-target-url="/"/>
<logout logout-url="/logout" logout-success-url="/"/>
<anonymous username="guest" granted-authority="ANONYMOUS"/>
<http-basic/>
<remember-me/>
</http>
<authentication-manager>
<authentication-provider>
<password-encoder ref="encoder"/>
<jdbc-user-service data-source-ref="dbDataSource"
users-by-username-query="SELECT username, password, enabled FROM Users WHERE username= ?"
authorities-by-username-query="SELECT u1.username, u2.authority FROM Users u1, Authorities u2 WHERE u1.id = u2.userId AND u1.UserName = ?"/>
</authentication-provider>
</authentication-manager>
<beans:bean id="encoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder">
<beans:constructor-arg name="strength" value="10"/>
</beans:bean>
</beans:beans>
Для того, что spring смог подключиться к базе данных требуется сделать еще несколько действий.
Определить бин для доступа к базе данных.
dbContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:jee="http://www.springframework.org/schema/jee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee.xsd">
<jee:jndi-lookup id="dbDataSource"
jndi-name="jdbc/Database"
expected-type="javax.sql.DataSource"/>
</beans>
Подключить файлы с настройками spring securuty и настройками базы данных в главный контекст приложения.
applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<import resource="security.xml"/>
<import resource="dbContext.xml"/>
</beans>
Пробросить коннект к базе данных из приложения в контейнер и создать фильтр. Фильтр будет перехватывать все подключения и обрабатывать их в соответствии с настройками безопасности еще до того, как запрос попадет в соответствующие сервлеты.
web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.4"
xmlns="http://java.sun.com/xml/ns/j2ee"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app.xsd">
<!--Security-->
<!-- Создаем фильтр springSecurityFilterChain который будет перехватывать подключения-->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<!-- Указываем что фильтр с названием springSecurityFilterChain будет перехватывать все подключения, попадающие под маску /*-->
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!--Security-->
<!--DispatcherServlet-->
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!--DispatcherServlet-->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!--Указываем на внешний ресурс-->
<resource-ref>
<!--Текстовое описание -->
<description>Datasource</description>
<!-- путь по которому доступен ресурс-->
<res-ref-name>jdbc/Database</res-ref-name>
<!-- Тип ресурса-->
<res-type>javax.sql.DataSource</res-type>
<!-- Авторизация производиться на стороне контейнера, а не приложения-->
<res-auth>Container</res-auth>
</resource-ref>
</web-app>
Добавляем саму ссылку на базу данных в контекст сервера.
jetty-context.xml
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="ExampleServer" class="org.eclipse.jetty.server.Server">
<New id="DS" class="org.eclipse.jetty.plus.jndi.Resource">
<Arg>
<Ref refid="wac"/>
</Arg>
<Arg>jdbc/Database</Arg>
<Arg>
<New class="org.hsqldb.jdbc.JDBCDataSource">
<Set name="DatabaseName">jdbc:hsqldb:hsql://localhost:9001/xdb</Set>
<Set name="User">SA</Set>
<Set name="Password">password</Set>
</New>
</Arg>
</New>
</Configure>
Теперь можно запустить сервер перейти на страницу localhost:8080/gull/hello и убедиться, что перед входом на страницу потребуется ввести пользователя и пароль. Такой пользователь (admin/password) был создан и добавлен в базу данных на прошлом этапе.
Изменение страницы входа
Сейчас на страницу могут войти только те пользователи, которые были добавлены в базу данных сторонним инструментом. Сделаем возможным создание пользователей напрямую из самого приложения. Для простоты и удобства добавим эту возможность прямо на страницу входа. Поэтому сначала поменяем стандартную страницу входа на свою.
Указываем, что будем использовать свою страницу входа в security.xml
<form-login login-page="/login" default-target-url="/"/>
Полный файл
<beans:beans xmlns:beans="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security-4.0.xsd">
<http>
<intercept-url pattern="/hello**" access="hasRole('USER')"/>
<form-login login-page="/login" default-target-url="/"/>
<logout logout-url="/logout" logout-success-url="/"/>
<anonymous username="guest" granted-authority="ANONYMOUS"/>
<http-basic/>
<remember-me/>
</http>
<authentication-manager>
<authentication-provider>
<password-encoder ref="encoder"/>
<jdbc-user-service data-source-ref="dbDataSource"
users-by-username-query="SELECT username, password, enabled FROM Users WHERE username= ?"
authorities-by-username-query="SELECT u1.username, u2.authority FROM Users u1, Authorities u2 WHERE u1.id = u2.userId AND u1.UserName = ?"/>
</authentication-provider>
</authentication-manager>
<beans:bean id="encoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder">
<beans:constructor-arg name="strength" value="10"/>
</beans:bean>
</beans:beans>
Добавляем ссылку на view в dispatcher-servlet.xml
<mvc:view-controller path="/login" view-name="login"/>
Полный файл
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:context="http://www.springframework.org/schema/context"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<!-- бин указывающий где лежат jsp для view -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:view-controller path="/" view-name="index"/>
<mvc:view-controller path="/login" view-name="login"/>
<!--Включаем аннотации mvc -->
<mvc:annotation-driven/>
<!--Указываем в каких пакетах производить поиск -->
<context:component-scan base-package="com.intetm.web"/>
</beans>
Создаем страницу входа.
login.jsp
<%@ page contentType="text/html" pageEncoding="UTF-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!--suppress HtmlFormInputWithoutLabel -->
<html>
<head>
<title>Login Page</title>
</head>
<body>
<div align="center">
<h3>Login with Username and Password</h3>
<form:form id='formLogin' action='./login' method='POST'>
<table>
<tr>
<td>username:</td>
<td><input type='text' name='username' value='' autofocus></td>
</tr>
<tr>
<td>password:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td><input type='checkbox' name='remember-me'/></td>
<td>remember-me</td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="submit"/></td>
</tr>
</table>
</form:form>
</div>
</body>
</html>
Добавляем на странницу hello возможность выхода.
hello.jsp
<%--suppress ELValidationInJSP --%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<!--suppress XmlPathReference -->
<c:url value="/logout" var="logoutUrl"/>
<!-- csrt support -->
<form action="${logoutUrl}" method="post" id="logoutForm">
<input type="hidden"
name="${_csrf.parameterName}"
value="${_csrf.token}"/>
</form>
<script>
function formSubmit() {
document.getElementById("logoutForm").submit();
}
</script>
<c:if test="${pageContext.request.userPrincipal.name != null}">
<h2>
Welcome : ${pageContext.request.userPrincipal.name} | <a
href="javascript:formSubmit()"> Logout</a>
</h2>
</c:if>
Hello, ${subject}!
</body>
</html>
Чтобы использовать условные операторы в JSP, необходимо дополнительно подключить библиотеку.
Подключаем библиотеки jstl
build.gradle
settings.gradle
dependencies {
runtime group: 'org.apache.taglibs', name: 'taglibs-standard-impl', version: gradle.jstlVersion
}
settings.gradle
gradle.ext.jstlVersion = '1.2.5'
Осталось запустить и проверить.
Работа с javascript
Без JavaScript не обходиться практически не один сайт. Исполнение кода на стороне клиента позволяет изменять страницу, верифицировать вводимые данные без перезагрузки и даже создавать полноценные аналоги ‘десктопных’ приложений, загружающиеся на лету. А чтобы загрузка не была долгой, необходимо позаботиться об оптимизации.
Оптимизацию JavaScript будем производить в два этапа. Во-первых, соединим разрозненные файлы в один, что позволит уменьшить число подзапросов и положительно скажется на скорости загрузки. После этого из полученного файла удаляется все лишние — пробелы, комментарии, сокращается название переменных и прочее. Конечно оптимизация производиться не вручную, а отдается на откуп компилятору. Я воспользуюсь Google Closure Compiler в обертке плагина к Gradle.
Внимание! Не стоит объединять свой код и стандартные библиотеки. Последние лучше подключать с серверов gooqle. С большой вероятностью они уже будут у клиента в кеше и их не потребуется загружать.
Подключаем плагин к gradle.
build.gradle
plugins {
id "com.eriwen.gradle.js" version "1.12.1"
}
Создадим файл который будем минифицировать.
login.js
$(function () {
$("#tabs").tabs();
});
Вносим изменения в login.jsp. Подключаем будущий минифицированный файл, а так же библиотеку jquery с сервера google.
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/themes/smoothness/jquery-ui.css">
<script src="js/login.js"></script>
Полный файл
<%@ page contentType="text/html" pageEncoding="UTF-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!--suppress HtmlFormInputWithoutLabel -->
<html>
<head>
<title>Login Page</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/themes/smoothness/jquery-ui.css">
<!--suppress HtmlUnknownTarget -->
<script src="js/login.js"></script>
</head>
<body>
<div align="center">
<div id="tabs">
<ul>
<li><a href="#tabs-1">Sign in</a></li>
<li><a href="#tabs-2">Create user</a></li>
</ul>
<div id="tabs-1">
<h3>Login with Username and Password</h3>
<form:form id='formLogin' action='./login' method='POST'>
<table>
<tr>
<td>username:</td>
<td><input type='text' name='username' value='' autofocus></td>
</tr>
<tr>
<td>password:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td><input type='checkbox' name='remember-me'/></td>
<td>remember-me</td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="submit"/></td>
</tr>
</table>
</form:form>
</div>
<div id="tabs-2">
<h3>Create user</h3>
<form:form id='formCreate' action='./createUser' method='POST'>
<table>
<tr>
<td>username:</td>
<td><input type='text' name='username' value=''></td>
</tr>
<tr>
<td>password:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="submit"/></td>
</tr>
</table>
</form:form>
</div>
</div>
</div>
</body>
</html>
В dispatcher-servlet.xml указываем папку, где будет храниться готовый файл.
<mvc:resources mapping="/js/**" location="/js/"/>
Полный файл
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:context="http://www.springframework.org/schema/context"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<!-- бин указывающий где лежат jsp для view -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:view-controller path="/" view-name="index"/>
<mvc:view-controller path="/login" view-name="login"/>
<mvc:resources mapping="/js/**" location="/js/"/>
<!--Включаем аннотации mvc -->
<mvc:annotation-driven/>
<!--Указываем в каких пакетах производить поиск -->
<context:component-scan base-package="com.intetm.web"/>
</beans>
Осталось только добавить в build.gradle задачи, которые соберут и минифицируют javascript файлы. Google Closure Compiler и даже умеет создавать sourcemap, что теоретически позволяет сразу подключать минифицированный файл. На практике во время отладки, переменные носят сокращенные наименования, что затрудняет отладку. Поэтому поступим проще — вовремя разработки обойдемся без минифицирования и подключим ее только в итоговую сборку.
javascript.source {
login {
js {
srcDir "src/main/js/login"
include "*.js"
}
}
}
combineJs {
source = javascript.source.login.js.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/combine/login.js")
}
minifyJs {
source = combineJs
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/min/login.js")
//sourceMap = file("${buildDir}/js/min/login.sourcemap.json")
closure {
warningLevel = 'QUIET'
}
}
def dev = true;
task copyJs(type: Copy) {
group = 'develop'
from(dev ? combineJs : minifyJs) as String
into "src/main/webapp/js"
}
compileJava.dependsOn.add(copyJs)
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
id "com.eriwen.gradle.js" version "1.12.1"
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
runtime group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
runtime group: 'org.apache.taglibs', name: 'taglibs-standard-impl', version: gradle.jstlVersion
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
inplaceMode = "hard"
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
javascript.source {
login {
js {
srcDir "src/main/js/login"
include "*.js"
}
}
}
combineJs {
source = javascript.source.login.js.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/combine/login.js")
}
minifyJs {
source = combineJs
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/min/login.js")
//sourceMap = file("${buildDir}/js/min/login.sourcemap.json")
closure {
warningLevel = 'QUIET'
}
}
def dev = true;
task copyJs(type: Copy) {
group = 'develop'
from(dev ? combineJs : minifyJs) as String
into "src/main/webapp/js"
}
compileJava.dependsOn.add(copyJs)
Как видно процедура copyJs, подключает в зависимости от режима сжатую или не сжатую версию. Чтобы не было необходимости вызывать процедуру вручную, я добавил зависимость на компилирование Java файлов, теперь все файлы будут компилироваться одновременно.
Работа с css
Работа с CSS ничем не отличается, от JavaScript. Файлы так же сливаются в один сжимается. Название плагина и функций отличается, только буквами CSS вместо JavaScript. Добавляем плагин и задачи в build.gradle.
plugins {
id "com.eriwen.gradle.css" version "1.11.1"
}
css.source {
login {
css {
srcDir "src/main/css/login"
include "*.css"
}
}
}
combineCss {
source = css.source.login.css.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/css/combine/login.css")
}
minifyCss {
source = combineCss
dest = file("${buildDir}/css/min/login.css")
yuicompressor { // Optional
lineBreakPos = -1
}
}
task copyCss(type: Copy) {
group = 'develop'
from(dev ? combineCss : minifyCss) as String
into "src/main/webapp/css"
}
compileJava.dependsOn.add(copyCss)
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
id "com.eriwen.gradle.js" version "1.12.1"
id "com.eriwen.gradle.css" version "1.11.1"
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
runtime group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
runtime group: 'org.apache.taglibs', name: 'taglibs-standard-impl', version: gradle.jstlVersion
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
inplaceMode = "hard"
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
javascript.source {
login {
js {
srcDir "src/main/js/login"
include "*.js"
}
}
}
combineJs {
source = javascript.source.login.js.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/combine/login.js")
}
minifyJs {
source = combineJs
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/min/login.js")
//sourceMap = file("${buildDir}/js/min/login.sourcemap.json")
closure {
warningLevel = 'QUIET'
}
}
css.source {
login {
css {
srcDir "src/main/css/login"
include "*.css"
}
}
}
combineCss {
source = css.source.login.css.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/css/combine/login.css")
}
minifyCss {
source = combineCss
dest = file("${buildDir}/css/min/login.css")
yuicompressor { // Optional
lineBreakPos = -1
}
}
def dev = true;
task copyJs(type: Copy) {
group = 'develop'
from(dev ? combineJs : minifyJs) as String
into "src/main/webapp/js"
}
task copyCss(type: Copy) {
group = 'develop'
from(dev ? combineCss : minifyCss) as String
into "src/main/webapp/css"
}
compileJava.dependsOn.add(copyJs)
compileJava.dependsOn.add(copyCss)
Создаем login.css
login.css
.tab-centered {
width: 500px;
}
.tab-centered .ui-tabs-nav {
height: 2.35em;
text-align: center;
}
.tab-centered .ui-tabs-nav li {
display: inline-block;
float: none;
margin: 0;
}
Добавляем CSS на страницу.
<link rel="stylesheet" href="css/login.css">
...
<div id="tabs" class="tab-centered">
Полный файл
<%@ page contentType="text/html" pageEncoding="UTF-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!--suppress HtmlFormInputWithoutLabel -->
<html>
<head>
<title>Login Page</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/themes/smoothness/jquery-ui.css">
<!--suppress HtmlUnknownTarget -->
<script src="js/login.js"></script>
<!--suppress HtmlUnknownTarget -->
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div align="center">
<div id="tabs" class="tab-centered">
<ul>
<li><a href="#tabs-1">Sign in</a></li>
<li><a href="#tabs-2">Create user</a></li>
</ul>
<div id="tabs-1">
<h3>Login with Username and Password</h3>
<form:form id='formLogin' action='./login' method='POST'>
<table>
<tr>
<td>username:</td>
<td><input type='text' name='username' value='' autofocus></td>
</tr>
<tr>
<td>password:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td><input type='checkbox' name='remember-me'/></td>
<td>remember-me</td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="submit"/></td>
</tr>
</table>
</form:form>
</div>
<div id="tabs-2">
<h3>Create user</h3>
<form:form id='formCreate' action='./createUser' method='POST'>
<table>
<tr>
<td>username:</td>
<td><input type='text' name='username' value=''></td>
</tr>
<tr>
<td>password:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="submit"/></td>
</tr>
</table>
</form:form>
</div>
</div>
</div>
</body>
</html>
Указываем spring-mvc откуда брать css файлы.
<mvc:resources mapping="/css/**" location="/css/"/>
dispatcher-servlet.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:context="http://www.springframework.org/schema/context"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<!-- бин указывающий где лежат jsp для view -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:view-controller path="/" view-name="index"/>
<mvc:view-controller path="/login" view-name="login"/>
<mvc:resources mapping="/js/**" location="/js/"/>
<mvc:resources mapping="/css/**" location="/css/"/>
<!--Включаем аннотации mvc -->
<mvc:annotation-driven/>
<!--Указываем в каких пакетах производить поиск -->
<context:component-scan base-package="com.intetm.web"/>
</beans>
Теперь при компилировании одновременно будут компилироваться и Java и JavaScript и CSS файлы. Результат доступен на странице localhost:8080/gull/login
ORM
Перед созданием rest сервиса, добавим прослойку при работе с базой данных в виде ORM. Чтобы не зависеть от конкретной реализации ORM, будем использовать общий интерфейс JPA, входящей в состав библиотеки javaee-api. Конкретная реализация (hibernate) подключиться только во время запуска.
build.gradle
compile group: 'javax', name: 'javaee-api', version: gradle.javaxVersion
compile group: 'org.springframework', name: 'spring-orm', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-tx', version: gradle.springVersion
runtime group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.1-api', version: gradle.hibernateJpaVersion
runtime group: 'org.hibernate', name: 'hibernate-core', version: gradle.hibernateVersion
runtime group: 'org.hibernate', name: 'hibernate-entitymanager', version: gradle.hibernateVersion
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
id "com.eriwen.gradle.js" version "1.12.1"
id "com.eriwen.gradle.css" version "1.11.1"
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'javax', name: 'javaee-api', version: gradle.javaxVersion
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-orm', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-tx', version: gradle.springVersion
runtime group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
runtime group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.1-api', version: gradle.hibernateJpaVersion
runtime group: 'org.hibernate', name: 'hibernate-core', version: gradle.hibernateVersion
runtime group: 'org.hibernate', name: 'hibernate-entitymanager', version: gradle.hibernateVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
runtime group: 'org.apache.taglibs', name: 'taglibs-standard-impl', version: gradle.jstlVersion
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
inplaceMode = "hard"
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
javascript.source {
login {
js {
srcDir "src/main/js/login"
include "*.js"
}
}
}
combineJs {
source = javascript.source.login.js.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/combine/login.js")
}
minifyJs {
source = combineJs
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/min/login.js")
//sourceMap = file("${buildDir}/js/min/login.sourcemap.json")
closure {
warningLevel = 'QUIET'
}
}
css.source {
login {
css {
srcDir "src/main/css/login"
include "*.css"
}
}
}
combineCss {
source = css.source.login.css.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/css/combine/login.css")
}
minifyCss {
source = combineCss
dest = file("${buildDir}/css/min/login.css")
yuicompressor { // Optional
lineBreakPos = -1
}
}
def dev = true;
task copyJs(type: Copy) {
group = 'develop'
from(dev ? combineJs : minifyJs) as String
into "src/main/webapp/js"
}
task copyCss(type: Copy) {
group = 'develop'
from(dev ? combineCss : minifyCss) as String
into "src/main/webapp/css"
}
compileJava.dependsOn.add(copyJs)
compileJava.dependsOn.add(copyCss)
settings.gradle.
gradle.ext.javaxVersion = '7.0'
gradle.ext.hibernateVersion = '5.0.2.Final'
gradle.ext.hibernateJpaVersion = '1.0.0.Final'
Полный файл
rootProject.name = 'gull'
//lib version
gradle.ext.springVersion = '4.2.2.RELEASE'
gradle.ext.springSecurityVersion = '4.0.2.RELEASE'
gradle.ext.javaxVersion = '7.0'
gradle.ext.hibernateVersion = '5.0.2.Final'
gradle.ext.hibernateJpaVersion = '1.0.0.Final'
gradle.ext.slf4jVersion = '1.7.13'
gradle.ext.logbackVersion = '1.1.3'
gradle.ext.hsqldbVersion = '2.3.2'
gradle.ext.jstlVersion = '1.2.5'
//default server config
gradle.ext.serverHttpPort = 8080
gradle.ext.serverResourcesPath = "dev/resources"
gradle.ext.serverContextFile = "src/test/resources/environment/jetty-context.xml"
gradle.ext.serverClassPath = "src/test/resources/environment/classpath"
//default database config
gradle.ext.dbName = "xdb"
gradle.ext.dbFile = "dev/database/devDB"
gradle.ext.dbUser = "SA"
gradle.ext.dbPassword = "password"
gradle.ext.dbPort = 9001
gradle.ext.dbHost = "localhost"
gradle.ext.dbUrl = "jdbc:hsqldb:hsql://${gradle.dbHost}:${gradle.dbPort}/${gradle.dbName}"
gradle.ext.dbDrive = "org.hsqldb.jdbc.JDBCDriver"
Для работы JPA требуется создать два бина.
- EntityManagerFactory — главный бин, служит мостиком между базой данных, ORM и кодом.
- TransactionManager — управляет транзакциями.
dbContext.xml
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
<property name="persistenceXmlLocation" value="classpath:persistence.xml"/>
<property name="persistenceUnitName" value="defaultUnit"/>
<property name="dataSource" ref="dbDataSource"/>
<property name="jpaVendorAdapter" ref="jpaVendorAdapter"/>
<property name="jpaDialect" ref="jpaDialect"/>
</bean>
<bean id="jpaVendorAdapter" class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter"/>
<bean id="jpaDialect" class="org.springframework.orm.jpa.vendor.HibernateJpaDialect"/>
<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
<property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<context:component-scan base-package="com.intetm.db.dao"/>
Полный файл
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:jee="http://www.springframework.org/schema/jee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<jee:jndi-lookup id="dbDataSource"
jndi-name="jdbc/Database"
expected-type="javax.sql.DataSource"/>
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
<property name="persistenceXmlLocation" value="classpath:persistence.xml"/>
<property name="persistenceUnitName" value="defaultUnit"/>
<property name="dataSource" ref="dbDataSource"/>
<property name="jpaVendorAdapter" ref="jpaVendorAdapter"/>
<property name="jpaDialect" ref="jpaDialect"/>
</bean>
<bean id="jpaVendorAdapter" class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter"/>
<bean id="jpaDialect" class="org.springframework.orm.jpa.vendor.HibernateJpaDialect"/>
<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
<property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<context:component-scan base-package="com.intetm.db.dao"/>
</beans>
Уже отдельно для hibernate, необходимо создать файл с настройками — persistence.xml. Он содержит в себе политику логирования, тип базы данных, политику обновления структуры базы данных и прочие специфичные для конкретного сервера данные. Выносим файл из проекта и только подключаем в classPath сервера.
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://xmlns.jcp.org/xml/ns/persistence"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence.xsd"
version="2.1">
<persistence-unit name="defaultUnit" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
<jta-data-source>jdbc/Database</jta-data-source>
<class>com.intetm.db.entity.User</class>
<!-- Hibernate properties -->
<properties>
<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect"/>
<property name="hibernate.ejb.naming_strategy" value="org.hibernate.cfg.ImprovedNamingStrategy"/>
<property name="hibernate.connection.charSet" value="UTF-8"/>
<property name="hibernate.validator.apply_to_ddl" value="false"/>
<property name="hibernate.validator.autoregister_listeners" value="false"/>
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.hbm2ddl.auto" value="validate"/>
</properties>
</persistence-unit>
</persistence>
Отмечу, что если в параметре «hibernate.hbm2ddl.auto» указать «update» вместо «validate», то hibernate добавит недостающие столбцы и таблицы. Это кажется более легким способом, чем написание sql скриптов, но стоит учитывать как минимум два факта
а) hibernate производит только не деструктивные изменения базы данных, то есть столбцы в таблицах некогда не удаляются.
б) Всю миграцию данных придется производить сторонними механизмами.
По мере роста проекта вероятность столкнуться с данными ограничениями быстро возрастает.
Осталось только создать обертки и DAO. Что-чем является указываем с помощью аннотаций.
User.java- обертка над таблицей. Сам класс помечается аннотацией Entity. Название таблицы указывается через Table. Класс должен иметь конструктор без параметров.
@Entity
@Table(name = User.TABLE)
public class User { … }
Отображающиеся на столбцы таблицы, поля класса помечаются аннотацией @Column с указанием имени столбца. Для данных полей обязательно должны существовать стандартные get и set методы.
@Column(name = COLUMN_USER_NAME)
private String userName;
User.java
package com.intetm.db.entity;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = User.TABLE)
public class User {
public static final String TABLE = "Users";
public static final String COLUMN_ID = "id";
public static final String COLUMN_USER_NAME = "userName";
public static final String COLUMN_PASSWORD = "password";
public static final String COLUMN_ENABLED = "enabled";
@Id
@Column(name = COLUMN_ID, columnDefinition = "BINARY(16)")
private UUID id;
@Column(name = COLUMN_USER_NAME)
private String userName;
@Column(name = COLUMN_PASSWORD)
private String password;
@Column(name = COLUMN_ENABLED)
private boolean enabled;
@ElementCollection(targetClass = Authority.class)
@Enumerated(EnumType.STRING)
@CollectionTable(name = Authority.TABLE, joinColumns = @JoinColumn(name = Authority.COLUMN_USERID, referencedColumnName = COLUMN_ID))
@Column(name = Authority.COLUMN_AUTHORITY)
private List<Authority> authorities;
public User() {
}
public User(String userName, String password, Authority authority) {
this.userName = userName;
this.password = password;
this.enabled = true;
this.authorities = new ArrayList<>();
this.authorities.add(authority);
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public List<Authority> getAuthorities() {
return authorities;
}
public void setAuthorities(List<Authority> authorities) {
this.authorities = authorities;
}
}
Authority.java — Простой enum, без аннотаций. Все аннотаций указывающие на связь с таблицей указаны в User.java. В данный класс вынесены лишь название таблиц и столбцов.
Authority.java
package com.intetm.db.entity;
public enum Authority {
ROLE_ADMIN,
ROLE_USER,
ROLE_ANONYMOUS;
public static final String TABLE = "authorities";
public static final String COLUMN_USERID = "userid";
public static final String COLUMN_AUTHORITY = "authority";
}
AbstractDao — обобщенный предок для всех дао. Содержит две аннотации @PersistenceContext и @PersistenceUnit, по которым Spring определит куда подставлять entityManager и entityManagerFactory. Так же есть общие методы для загрузки, сохранения и поиска объектов в базе данных.
@PersistenceContext
private EntityManager entityManager;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory;
AbstractDao.java
package com.intetm.db.dao;
import javax.persistence.*;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Path;
import javax.persistence.criteria.Root;
import java.util.List;
import java.util.Map;
public abstract class AbstractDao<Entity, ID> {
private final Class entryClass;
@PersistenceContext
private EntityManager entityManager;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory;
public AbstractDao(Class entryClass) {
this.entryClass = entryClass;
}
public void persist(Entity entity) {
entityManager.persist(entity);
}
public void merge(Entity entity) {
entityManager.merge(entity);
}
public void delete(Entity entity) {
entityManager.remove(entity);
}
@SuppressWarnings("unchecked")
public CriteriaQuery<Entity> createCriteriaQuery() {
return this.getCriteriaBuilder().createQuery(entryClass);
}
@SuppressWarnings("unchecked")
public Entity find(ID id) {
return (Entity) entityManager.find(entryClass, id);
}
public List<Entity> find(CriteriaQuery<Entity> criteriaQuery) {
TypedQuery<Entity> query = entityManager.createQuery(criteriaQuery);
return query.getResultList();
}
public List<Entity> find(Object... keysAndValues) {
CriteriaBuilder criteriaBuilder = this.getCriteriaBuilder();
CriteriaQuery<Entity> criteriaQuery = this.createCriteriaQuery();
Root root = criteriaQuery.from(entryClass);
fillQuery(criteriaQuery, keysAndValues, root, criteriaBuilder);
return find(criteriaQuery);
}
public List<Entity> find(Map<String, Object> parameters) {
Object[] array = toArray(parameters);
return find(array);
}
@SuppressWarnings("unchecked")
public long count(Object... keysAndValues) {
CriteriaBuilder criteriaBuilder = this.getCriteriaBuilder();
CriteriaQuery<Long> criteriaQuery = criteriaBuilder.createQuery(Long.class);
Root<Entity> root = criteriaQuery.from(entryClass);
criteriaQuery.select(criteriaBuilder.count(root));
fillQuery(criteriaQuery, keysAndValues, root, criteriaBuilder);
return getEntityManager().createQuery(criteriaQuery).getSingleResult();
}
public long count(Map<String, Object> parameters) {
Object[] array = toArray(parameters);
return count(array);
}
private void fillQuery(CriteriaQuery criteriaQuery, Object[] keysAndValues, Root root, CriteriaBuilder criteriaBuilder) {
if (keysAndValues.length % 2 != 0) {
throw new IllegalArgumentException("Expected even count argument, receive odd");
}
for (int i = 0; i < keysAndValues.length; i += 2) {
Path parameterPath = root.get((String) keysAndValues[i]);
Object parameterValue = keysAndValues[i + 1];
criteriaQuery.where(criteriaBuilder.equal(parameterPath, parameterValue));
}
}
private Object[] toArray(Map<String, Object> parameters) {
Object[] array = new Object[parameters.size() * 2];
int i = 0;
for (Map.Entry<String, Object> parameter : parameters.entrySet()) {
array[i] = parameter.getKey();
i++;
array[i] = parameter.getValue();
i++;
}
return array;
}
public List<Entity> selectAll() {
CriteriaQuery<Entity> criteriaQuery = createCriteriaQuery();
criteriaQuery.from(entryClass);
return find(criteriaQuery);
}
public EntityManager getEntityManager() {
return entityManager;
}
public void setEntityManager(EntityManager entityManager) {
this.entityManager = entityManager;
}
public Object getEntityManagerFactory() {
return entityManagerFactory;
}
public void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
}
public CriteriaBuilder getCriteriaBuilder() {
return this.entityManager.getCriteriaBuilder();
}
}
UserDao — конкретная реализация Dao для таблицы User. Помечается аннотацией @ Repository, указывающий Spring на необходимость специального бина. Spring будет искать поля класса с аннотациями @PersistenceContext и @PersistenceUnit, найдет их в базовом классе AbstractDao и заполнит.
@Repository("userDao")
public class UserDao extends AbstractDao<User, UUID>
UserDao.java
package com.intetm.db.dao;
import com.intetm.db.entity.User;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository("userDao")
public class UserDao extends AbstractDao<User, UUID> {
public UserDao() {
super(User.class);
}
@Override
public void persist(User user) {
if (user.getId() == null) {
user.setId(UUID.randomUUID());
}
super.persist(user);
}
public boolean isUserExsist(String userName) {
return count(User.COLUMN_USER_NAME, userName) != 0;
}
}
REST сервис
Rest сервис является точкой входа и выхода в приложение, со всеми вытекающими последствиями. Входной поток данных не контролируется приложением, и может содержать заведомо некорректные данные. Подобное накладывает на сервис дополнительные требования к обработке ошибок. При ошибке вовремя обработке запроса он не должен неконтролируемо изменять базу данных и отдавать клиенту stacktrace. Правильное поведение при ошибке — это откат всех примененных изменений, логирование ошибки и отдача корректного сообщения клиенту.
Попытаемся реализовать данное поведение. Сначала создадим сервис, создающий пользователя. Вначале он проверяет, не существуют ли пользователь. Если да, то бросает ошибку. В другом случае хеширует пароль и сохраняет пользователя в базу данных.
LoginService
package com.intetm.service.login;
import com.intetm.db.dao.UserDao;
import com.intetm.db.entity.Authority;
import com.intetm.db.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
public class LoginService {
@Autowired
private UserDao userDao;
@Autowired
private PasswordEncoder encoder;
@Transactional
public User createUser(String userName, String password, Authority authority) throws UserExistsException {
if (userDao.isUserExsist(userName)) {
throw new UserExistsException(userName);
}
String hash = encoder.encode(password);
User user = new User(userName, hash, authority);
userDao.persist(user);
return user;
}
public UserDao getUserDao() {
return userDao;
}
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public PasswordEncoder getEncoder() {
return encoder;
}
public void setEncoder(PasswordEncoder encoder) {
this.encoder = encoder;
}
}
Выбрасываемое им исключение.
UserExistsException
package com.intetm.service.login;
public class UserExistsException extends Exception {
public UserExistsException(String userName) {
super("User " + userName + " already exists!");
}
}
В LoginController добавляем метод обрабатывающий запрос на создание. Он принимает два параметра (пользователь, пароль) и возвращает краткое описание пользователя или бросает ошибку. Stacktrace мы при этом не сохраняем, так как это не ошибка приложения, а всего лишь неверные данные и нет необходимости в логировании.
@RequestMapping(value = "/createUser", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.POST)
@ResponseBody
public UserDetails createUser(@RequestParam String username, @RequestParam String password) throws ServiceException {
try {
User user = loginService.createUser(username, password, ROLE_USER);
return new UserDetails(user);
} catch (UserExistsException exception) {
throw new ServiceException(exception.getMessage());
}
}
LoginController
package com.intetm.web.login;
import com.intetm.db.entity.User;
import com.intetm.service.login.LoginService;
import com.intetm.service.login.UserExistsException;
import com.intetm.web.exception.ServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import static com.intetm.db.entity.Authority.ROLE_USER;
@Controller
public class LoginController {
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
private static final String HELLO_VIEW = "hello";
@Autowired
private LoginService loginService;
@RequestMapping(value = "/hello", method = RequestMethod.GET)
public String hello(Model model) {
logger.debug("hello page");
model.addAttribute("subject", "world");
return HELLO_VIEW;
}
@RequestMapping(value = "/createUser", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.POST)
@ResponseBody
public UserDetails createUser(@RequestParam String username, @RequestParam String password) throws ServiceException {
try {
User user = loginService.createUser(username, password, ROLE_USER);
return new UserDetails(user);
} catch (UserExistsException exception) {
throw new ServiceException(exception.getMessage());
}
}
}
Возвращаемый объект, который будет преобразован в json.
UserDetails
package com.intetm.web.login;
import com.intetm.db.entity.Authority;
import com.intetm.db.entity.User;
import java.util.List;
class UserDetails {
private String userName;
private List<Authority> authorities;
public UserDetails(User user) {
this.userName = user.getUserName();
this.authorities = user.getAuthorities();
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public List<Authority> getAuthorities() {
return authorities;
}
public void setAuthorities(List<Authority> authorities) {
this.authorities = authorities;
}
}
Осталось только поймать ошибки и правильно их обработать. Для этого создадим класс помощник. Он помечается аннотацией @ControllerAdvice
. Если в ходе обработки запроса возникнут ошибки, то Spring поищет методы в оригинальном контролере и помощника с аннотацией ExceptionHandler. Если класса ошибки совпадет, то будет вызван этот метод и клиенту будет возращен результат метода. Это позволяет вернуть осмысленный результат даже в случае ошибок и правильно производить логирование.
ExceptionController
package com.intetm.web.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
public class ExceptionController extends ResponseEntityExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);
@ExceptionHandler(ServiceException.class)
@ResponseBody
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public String handleServiceException(ServiceException ex) {
if (ex.isNeedLogging()) {
logger.error(ex.getMessage(), ex);
}
return ex.getMessage();
}
@ExceptionHandler(RuntimeException.class)
@ResponseBody
@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public String handleException(RuntimeException ex) {
logger.error(ex.getMessage(), ex);
return ex.getMessage();
}
}
Бросаемое контролером исключение. По умолчанию считается, что его логировать не надо, так как это ошибка данных.
ServiceException
package com.intetm.web.exception;
public class ServiceException extends Exception {
private boolean needLogging = false;
public ServiceException() {
super();
}
public ServiceException(String message) {
super(message);
}
public ServiceException(boolean needLogging) {
super();
this.needLogging = needLogging;
}
public ServiceException(String message, boolean needLogging) {
super(message);
this.needLogging = needLogging;
}
public boolean isNeedLogging() {
return needLogging;
}
public void setNeedLogging(boolean needLogging) {
this.needLogging = needLogging;
}
}
В javascript добавляем код отправки.
login.js
$(function () {
$("#tabs").tabs();
});
$(document).ready(function () {
var frm = $("#formCreate")
frm.submit(function (event) {
event.preventDefault();
$.ajax({
type: frm.attr('method'),
url: frm.attr('action'),
data: frm.serialize(),
success: function (data) {
alert('Username:' + data.userName + "\nrole:" + data.authorities[0]);
},
error: function (xhr, str) {
alert('User exist!');
}
});
return false;
});
});
Осталось сделать несколько мелочей. Добавить бин преобразующий Java объекты в json.
<bean id="jacksonMessageConverter"
class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/>
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
<property name="messageConverters">
<list>
<ref bean="jacksonMessageConverter"/>
</list>
</property>
</bean>
Подключение библиотеки
В build.gradle добавляем библиотеку по преобразующую java объекты в json.
Указать версию settings.gradle
compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: gradle.jacksonVersion
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: gradle.jacksonVersion
Полный файл
buildscript {
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
classpath group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
}
plugins {
id "org.akhikhl.gretty" version "1.2.4"
id 'org.liquibase.gradle' version '1.1.1'
id "com.eriwen.gradle.js" version "1.12.1"
id "com.eriwen.gradle.css" version "1.11.1"
}
group 'com.intetm'
version '0.1'
apply plugin: 'java'
apply plugin: 'war'
apply from: 'database.gradle'
//noinspection GroovyUnusedAssignment
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'javax', name: 'javaee-api', version: gradle.javaxVersion
runtime group: 'org.springframework', name: 'spring-jdbc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-webmvc', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-orm', version: gradle.springVersion
compile group: 'org.springframework', name: 'spring-tx', version: gradle.springVersion
compile group: 'org.springframework.security', name: 'spring-security-web', version: gradle.springSecurityVersion
runtime group: 'org.springframework.security', name: 'spring-security-config', version: gradle.springSecurityVersion
runtime group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.1-api', version: gradle.hibernateJpaVersion
runtime group: 'org.hibernate', name: 'hibernate-core', version: gradle.hibernateVersion
runtime group: 'org.hibernate', name: 'hibernate-entitymanager', version: gradle.hibernateVersion
compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: gradle.jacksonVersion
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: gradle.jacksonVersion
compile group: 'org.slf4j', name: 'slf4j-api', version: gradle.slf4jVersion
runtime group: 'ch.qos.logback', name: 'logback-classic', version: gradle.logbackVersion
runtime group: 'org.apache.taglibs', name: 'taglibs-standard-impl', version: gradle.jstlVersion
gretty group: 'org.hsqldb', name: 'hsqldb', version: gradle.hsqldbVersion
}
def serverHttpPort = project.properties['serverHttpPort'] ?: gradle.serverHttpPort
def serverResourcesPath = project.properties['serverResourcesPath'] ?: gradle.serverResourcesPath
def serverContextFile = project.properties['serverContextFile'] ?: gradle.serverContextFile
def serverClassPath = [project.properties['serverClassPath'] ?: gradle.serverClassPath] as Set
def dbUser = project.properties['dbUser'] ?: gradle.dbUser
def dbPassword = project.properties['dbPassword'] ?: gradle.dbPassword
def dbUrl = project.properties['dbUrl'] ?: gradle.dbUrl
gretty {
httpPort = serverHttpPort
serverConfigFile = serverContextFile
classPath = serverClassPath
inplaceMode = "hard"
}
task copyEnvironment(type: Copy) {
group = 'develop'
from 'src/test/resources/environment'
into serverResourcesPath
}
liquibase {
activities {
//noinspection GroovyAssignabilityCheck
main {
changeLogFile 'src/sql/main/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
dev {
changeLogFile 'src/sql/dev/changelog.xml'
url dbUrl
username dbUser
password dbPassword
}
}
}
task updateDbMain(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main'
tasks.update.execute()
}
}
task updateDbDev(dependsOn: startDatabase) {
group = 'develop'
doLast {
liquibase.runList = 'main, dev'
tasks.update.execute()
}
}
javascript.source {
login {
js {
srcDir "src/main/js/login"
include "*.js"
}
}
}
combineJs {
source = javascript.source.login.js.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/combine/login.js")
}
minifyJs {
source = combineJs
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/js/min/login.js")
//sourceMap = file("${buildDir}/js/min/login.sourcemap.json")
closure {
warningLevel = 'QUIET'
}
}
css.source {
login {
css {
srcDir "src/main/css/login"
include "*.css"
}
}
}
combineCss {
source = css.source.login.css.files
//noinspection GrReassignedInClosureLocalVar
dest = file("${buildDir}/css/combine/login.css")
}
minifyCss {
source = combineCss
dest = file("${buildDir}/css/min/login.css")
yuicompressor { // Optional
lineBreakPos = -1
}
}
def dev = true;
task copyJs(type: Copy) {
group = 'develop'
from(dev ? combineJs : minifyJs) as String
into "src/main/webapp/js"
}
task copyCss(type: Copy) {
group = 'develop'
from(dev ? combineCss : minifyCss) as String
into "src/main/webapp/css"
}
compileJava.dependsOn.add(copyJs)
compileJava.dependsOn.add(copyCss)
Указать версию settings.gradle
gradle.ext.jacksonVersion = '2.3.0'
Полный файл
rootProject.name = 'gull'
//lib version
gradle.ext.springVersion = '4.2.2.RELEASE'
gradle.ext.springSecurityVersion = '4.0.2.RELEASE'
gradle.ext.javaxVersion = '7.0'
gradle.ext.hibernateVersion = '5.0.2.Final'
gradle.ext.hibernateJpaVersion = '1.0.0.Final'
gradle.ext.slf4jVersion = '1.7.13'
gradle.ext.logbackVersion = '1.1.3'
gradle.ext.hsqldbVersion = '2.3.2'
gradle.ext.jacksonVersion = '2.3.0'
gradle.ext.jstlVersion = '1.2.5'
//default server config
gradle.ext.serverHttpPort = 8080
gradle.ext.serverResourcesPath = "dev/resources"
gradle.ext.serverContextFile = "src/test/resources/environment/jetty-context.xml"
gradle.ext.serverClassPath = "src/test/resources/environment/classpath"
//default database config
gradle.ext.dbName = "xdb"
gradle.ext.dbFile = "dev/database/devDB"
gradle.ext.dbUser = "SA"
gradle.ext.dbPassword = "password"
gradle.ext.dbPort = 9001
gradle.ext.dbHost = "localhost"
gradle.ext.dbUrl = "jdbc:hsqldb:hsql://${gradle.dbHost}:${gradle.dbPort}/${gradle.dbName}"
gradle.ext.dbDrive = "org.hsqldb.jdbc.JDBCDriver"
Включить аннотацию транзакций.
<tx:annotation-driven transaction-manager="transactionManager"/>
dispatcher-servlet.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd">
<!-- бин указывающий где лежат jsp для view -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:view-controller path="/" view-name="index"/>
<mvc:view-controller path="/login" view-name="login"/>
<mvc:resources mapping="/js/**" location="/js/"/>
<mvc:resources mapping="/css/**" location="/css/"/>
<!--Включаем аннотации mvc -->
<mvc:annotation-driven/>
<!--Указываем в каких пакетах производить поиск -->
<context:component-scan base-package="com.intetm.web"/>
<tx:annotation-driven transaction-manager="transactionManager"/>
<bean id="loginService" class="com.intetm.service.login.LoginService"/>
<bean id="jacksonMessageConverter"
class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/>
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
<property name="messageConverters">
<list>
<ref bean="jacksonMessageConverter"/>
</list>
</property>
</bean>
</beans>
Можно заметить, что создано немало классов. Это попытка разделить обязанности и ответственность классов. Данное разделение не идеально, и приведено лишь пример. Подразумевающийся при этом контракт классов следующий.
1) Dao — относительно низкоуровневая работа с базой данных. Сохранение, загрузка, обновление данных. Во время выполнения может выбросить Runtime Exception, полностью прерывая обработку запроса.
2) Service — основная логика работы. Публичные методы, являющиеся точками входа, помечается транзакционным. Во время обработки запроса может неоднократно вызывать методы Dao, но вызовы происходят в рамках одной транзакции. Единая транзакция на всю обработку запроса, позволит без проблем откатить все сделанные изменения при возникновении ошибки.
Для лучшего понимания результата работы аннотации, надо знать принцип работы. Ничего на самом деле сложного в нем не скрывается. Увидев данную transactional Spring оборачивает упаковывает бин в прокси. Код прокси схематично можно представить следующим образом
public void someMethod(){
transaction.open();
try{
subject.someMethod();
}
catch(Exception exception){
transaction.setRollbackOnly(true);
}
finally {
transaction.close();
}
}
То есть при вызове метода, прокси сначала управление попадает в прокси. Прокси отрывает транзакцию, вызывает исходный метод, в случае возникновения ошибки помечает, что транзакцию надо откатить и перед возврата управления вызывающему классу закрывает транзакцию. Понимая примерный способ работы можно сделать два важных вывода.
По завершению исполнения помеченного аннотацией метода, транзакция автоматически закрывается и откатить изменения становиться невозможно. Поэтому именно методы Service, должны открывать и закрывать транзакцию. В некоторых обучающих примерах, транзакции открывают на уровне Dao, что в корне неправильно. Если внутри метода Service сделать два последовательных вызова Dao, то в случае ошибки во втором вызове результат первого останется в базе данных! Ведь к моменту второго вызова первая транзакция уже успешно закрылась!
Второй вывод более тривиален — так как для открытия транзакции используется прокси, то вызов должен произойти из другого бина. Прокси встраивается в вызовы между разными бинами и не имеет возможности перехватить вызовы методов внутри класса.
Если же вернуться к обязанностям класса по обработке ошибок, то поведение Service я делаю следующим. Если все завершилось без ошибок, то класс просто отдает результат. Если входе вызова методов Dao произошла ошибка, то она пробрасывается наверх и производиться откат изменений.
Дополнительно к ошибкам рантайма, Service может выбросить проверяемое исключение. Они выбрасываются в случае если запрос клиента некорректен, и мы знаем причину. Например, попытка создать пользователя с совпадающим именем. Подобное не является ошибкой приложения, и должно обрабатываться по-особому. Подобные запросы даже могут изменять базу данных, в зависимости от логики процесса.
Внимание! По умолчанию откат производиться только в случаях Runtime Exception. Для того, чтобы происходил откат изменений и для проверяемых исключений, их необходимо дополнительно указать, через параметр rollbackFor. Пример кода:
@Transactional(rollbackFor = Exception.class)
public void someMethod() throws Exception {
...
}
Тогда откат изменений будет производиться при любом исключении.
3) Controller — Принимает запрос. Минимальная его обрабатывает и передает в Service. В случае получение успешного ответа из сервиса — формирует ответ. Проверяемые ошибки переупаковывает при необходимости в подходящий контейнер, остальные пробрасывает на следующий уровень. Хотя его роль кажется незначительной, не стоит пытаться слить его с Service. На начальном этапе точка входа будет одна, но потом их число может вырасти. Например, добавиться мобильные клиенты со своим API.
4) Controller advice — дополнительный класс к контролеру, помогает работать с ошибками. Перехватывает все Runtime Exception, обязательно делает запись в лог и отдает клиенту лишь необходимый минимум информации. Для проверяемых исключений ситуация другая. Они тоже все перехватываются, но в лог попадают только помеченные специальным флагом ошибки. Подобная разница связана с природой ошибок. Первые действительно являются ошибками приложения, и обязаны быть залогированны. Контролируемые исключения лишь отображают некорректность введённых пользователем данных и не требуют упоминания в логе.
Заключение.
Хотя получившейся пример не очень-то и похож на небольшой ‘Hello, world!’, он содержит лишь базовые компоненты и то не все. За бортом остались такие архиважные вещи, как тестирование и локализация. Без них нельзя считать приложение завершенным.
Исходный код приложения доступен на github. Запуск производиться в две строки.
gradlew updateDbDev
gradlew jettyStart
Предварительно требуется лишь установка JDK. Все остальные зависимости загрузиться налету. Но при этом, приложение не является монолитным. При желании возможно локально заменить базу данных на внешнюю, использовать внешний контейнер сервлетов, настроить политику логирования без конфликтов с VCS.
vayho
Есть же Spring Boot где это все уже есть. И и с xml пора бы уже мигрировать.
vayho
Поясню немного подробнее, конфигурация которую вы предлагаете развернуть как мне кажется немного устарела. Есть Spring Boot с плагином для Maven и Gradle которые позволяет развернуть приложение за 15 минут. И там уже все будет и БД и локализация и JPA и Security.
Очевидно что количество программистов которых раздражает xml > количества программистов которых раздражает Java API. Поэтому без видимых преимуществ xml лучше сменить на Java, благо Spring на текущий момент предлагает красивое Java API. Тут и CDI аннотации и Spring аннотации и красивые билдеры.
intet
Я бы поспорил с развертыванием приложения за 15 минут. Проблемы возникнут уже на стадии подготовки базы данных, простого решения разделить sql скриптов на две группы (одна для всех, другая только для разработки) я не вижу. С локализацией тоже есть вопросы — не генировать же страницы из jsp каждый раз при запросе? Компиляцию javascript/css тоже придется прикручивать. Но возможно стоило все-же использовать Spring Boot.
По поводу xml — это вопрос предпочтений. Мне лично кажутся немного странными классы в которых почти нет логики, зато куча параметров. И xml и java код не являются в данном случае идеальными.
vayho
Для решения таких проблем Spring предлагает профили.
Например вот тут https://docs.spring.io/spring-boot/docs/current/reference/html/howto-database-initialization.html внизу описан процесс интеграции с liquabase. С помощью профилей можно переопределить параметры liquabase и грузить разные ченджлоги для разных режимов работы.
Вот пример(application.yml):
По поводу локализации, компиляции js/css. В режиме разработки мы просто отключаем кеширование файлов с локализацией(обычно messages/messages.properties) и соответственно каждый раз данные берутся заново. Так же мы не компилируем js/css в режиме разработки. А вот уже в проде мы все компилируем сжимаем оптимизируем. Но эта задача лежит на Maven и Teamcity.
Кстати почему JSP? Есть же Thymeleaf(мы его не любим) и куча других шаблонизаторов, мы используем простеньки Pebble Engine. Но он расширяем и покрывает весь спектр наших задач.
intet
В приведенном приложении так и сделано Js/css компилируется и сжимается только в случае боевого использования. Во время разработки файлы только линкуются, чтобы не было проблем с подключением страницы.
И как раз-таки настройка Teamcity, добавление профилей и прочие мелочи не позволят уложиться в 15 минут на запуск.
Не хватило времени разобраться в разных шаблонизаторах. Хотелось один раз сгенирировать страницы, сложить в статичные ресурсы, а дальше лишь обрабатывать данные.