|
RESTful - что может быть проще? Только RESTful реализованный с помощью Java EE! Конечно, многие с этим не согласятся и приведут кучу весомых аргументов против, и будут по своему правы. Но поверьте, в Java мире проделана огромная работа, чтобы упростить воплощение ваших REST идей в жизнь. И в этой статье мне бы хотелось наглядно это продемонстрировать. |
Итак, самый простой способ объяснить что-то - показать это "что-то" в деле. Для примера, будет показан процесс реализации REST API для простого планировщика повседневных задач - так называемого TODO листа.
Что из себя представляет приложение TODO?
Демонстрационное приложение TODO будет позволять вести список задач. Каждая задача будет состоять из уникального
идентификатора, имени, описания и статуса выполнена/невыполнена:
Задачи будут представлены с помощью json. Пример Json представления задачи:
{ "uuid":"550e8400-e29b-41d4-a716-446655440000", "name":"написать RESTful приложение", "description":"чтобы полученные знания принесли пользу и не были забыты, ими необходимо воспользоваться в течении первых 72 часов после получения", "completed":false }
API приложения будет предусматривать следующие действия:
- Получение списка всех заданий
- Получение списка выполненых/невыполненных заданий
- Получение конкретного задания
- Добавление задания
- Создание нового задания/изменение существующего
- Редактирование отдельных параметров задания
- Удаление задания
Пример специально максимально упрощен, чтобы не отвлекать читателя от API предоставляемой J2EE и быстро создать общую картину процесса реализации RESTful приложений на java.
Функциональные тесты
Статья не затрагивает тему написания клиента для разрабатываемого api. Вместо этого я предлагаю решение в виде функциональных тестов, написанное с помощью JUnit, Apache HttpComponents и Google gson.package ru.dokwork.todo;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.http.HttpResponse;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import java.util.UUID;
import static ru.dokwork.todo.ResponseChecker.assertThat;
public class TaskServiceFT {
private static final String URL = "http://localhost:8080/todo/tasks/";
static final JsonObject jtask1;
static final JsonObject jtask2;
JsonParser parser;
TaskServiceClient todo;
static {
jtask1 = new JsonObject();
jtask1.addProperty("name", "First task");
jtask1.addProperty("description", "First task for test.");
jtask1.addProperty("completed", false);
jtask2 = new JsonObject();
jtask2.addProperty("name", "Second task");
jtask2.addProperty("description", "Second task for test.");
jtask2.addProperty("completed", false);
}
@Before
public void setup() throws Exception {
parser = new JsonParser();
todo = new TaskServiceClient(URL);
todo.removeAllExistingTasks();
}
@Test
public void testGetTask() throws Exception {
// Add task
HttpResponse response = todo.addNewTask(jtask1);
// Get uuid
String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
// Get task
response = todo.getTask(UUID.fromString(uuid));
// Create expected json
JsonObject expectedObject = createJsonTask(uuid, jtask1);
// Check answer
assertThat(response).haveCode(200).haveContent(expectedObject);
}
@Test
public void testGetAllTasks() throws Exception {
// Add first task
HttpResponse response = todo.addNewTask(jtask1);
String uuid1 = response.getFirstHeader("location").getValue().substring(URL.length());
// Add second task
response = todo.addNewTask(jtask2);
String uuid2 = response.getFirstHeader("location").getValue().substring(URL.length());
// Get all tasks
response = todo.getAll();
// Create expected array
JsonArray array = new JsonArray();
array.add(createJsonTask(uuid1, jtask1));
array.add(createJsonTask(uuid2, jtask2));
// Check answer
assertThat(response).haveCode(200).haveContent(array);
}
@Test
public void testAddTask() throws Exception {
// Add task
HttpResponse response = todo.addNewTask(jtask1);
// Check answer
assertThat(response).haveCode(201).haveHeaders("location", 1);
// Get uuid
String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
// Check new task
response = todo.getTask(UUID.fromString(uuid));
JsonObject addedTask = createJsonTask(uuid, jtask1);
assertThat(response).haveContent(addedTask);
}
@Test
public void testPutNewTask() throws Exception {
// Put new task
UUID uuid = UUID.randomUUID();
HttpResponse response = todo.putTask(uuid, jtask1);
// Check answer
assertThat(response).haveCode(201).haveHeaders("location", 1);
response.getFirstHeader("location").getValue().endsWith(uuid.toString());
// Check new task
response = todo.getTask(uuid);
JsonObject addedTask = createJsonTask(uuid.toString(), jtask1);
assertThat(response).haveContent(addedTask);
}
@Test
public void testPutExistingTask() throws Exception {
// Add task
HttpResponse response = todo.addNewTask(jtask1);
// Get uuid
String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
// Change task
JsonObject editedTask = createJsonTask(uuid, jtask1);
editedTask.addProperty("name", "Edited task");
// Put edited task
response = todo.putTask(UUID.fromString(uuid), editedTask);
// Check answer
assertThat(response).haveCode(204).haveHeaders("location", 1);
response.getFirstHeader("location").getValue().endsWith(uuid.toString());
// Check edited task
response = todo.getTask(UUID.fromString(uuid));
assertThat(response).haveContent(editedTask);
}
@Test
public void testPatchTask() throws Exception {
// Add task
HttpResponse response = todo.addNewTask(jtask1);
// Get uuid
String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
// Create patch
JsonObject patch = new JsonObject();
patch.addProperty("completed", true);
// Patch state for task
response = todo.patch(UUID.fromString(uuid), patch);
// Check answer
assertThat(response).haveCode(204).haveHeaders("location", 1);
response.getFirstHeader("location").getValue().endsWith(uuid);
// Check patched task
response = todo.getTask(UUID.fromString(uuid));
// Create expected json
JsonObject expectedObject = createJsonTask(uuid, jtask1);
expectedObject.addProperty("completed", true);
// Check task
assertThat(response).haveContent(expectedObject);
}
@Test
public void testFindTasks() throws Exception {
// Add first task
HttpResponse response = todo.addNewTask(jtask1);
// Get uuid for first task
String uuid1 = response.getFirstHeader("location").getValue().substring(URL.length());
// Get uuid for second task
String uuid2 = UUID.randomUUID().toString();
// Add second task
JsonObject jsonTask2 = createJsonTask(uuid2, jtask2);
jsonTask2.addProperty("completed", true);
todo.putTask(UUID.fromString(uuid2), jsonTask2);
// Get only completed tasks
response = todo.find(true);
// Create expected content
JsonArray expectedContent = new JsonArray();
expectedContent.add(createJsonTask(uuid2, jsonTask2));
// Check answer
assertThat(response).haveCode(200).haveContent(expectedContent);
}
@Test
public void testDeleteTask() throws Exception {
// Add task
HttpResponse response = todo.addNewTask(jtask1);
// Get uuid
String uuid = response.getFirstHeader("location").getValue().substring(URL.length());
// Delete task
int countBefore = todo.getTasksCount();
response = todo.delete(UUID.fromString(uuid));
// Check answer
assertThat(response).haveCode(204);
int countAfter = todo.getTasksCount();
Assert.assertEquals(1, countBefore - countAfter);
}
private JsonObject createJsonTask(String uuid, JsonObject pattern) {
JsonObject expectedObject = new JsonObject();
expectedObject.add("uuid", parser.parse(uuid));
expectedObject.add("name", pattern.get("name"));
expectedObject.add("description", pattern.get("description"));
expectedObject.add("completed", pattern.get("completed"));
return expectedObject;
}
}
Классы ResponseChecker и TaskServiceClient призваны сделать код функциональных тестов более наглядным.
ResponseChecker
TaskServiceClient
Обратите внимание на метод PATCH. Он не реализован (на момент написания статьи) в библиотеке Apache HttpComponents и его реализацией необходимо заняться самостоятельно. К счастью это совершенно не трудно:
public class HttpPatch extends HttpPost {
@Override
public String getMethod() {
return "PATCH";
}
public HttpPatch() {
super();
}
public HttpPatch(URI uri) {
super(uri);
}
public HttpPatch(String url) {
super(url);
}
}
Введение в JAX-RS API
Каждому ресурсу в rest-приложениях соответствует некоторый URI, а жизненный цикл ресурса управляется с помощью методов GET, POST, PUT и DELETE http протокола. JAX-RS предлагает очень прозрачную схему реализации такой идеи. Вы просто заводите сервисный класс, отвечающий за некоторую сущность и указываете базовый URL для этой сущности:
@Path(value="/tasks")
class TaskService {
}
Затем реализуете методы для управления этими сущностями и связываете их с методами http протокола:
@Path(value="/tasks")
class TaskService {
@GET
public List<Task> getTasks() {
}
@POST
public void addTask(Task task) {
}
}
Так,из коробки, вы получаете возможность сериализации/десериализации данных в популярные форматы, такие как JSON и XML с помощью аннотации
@Produces({"application/xml", "application/json"})
Что касается вопроса, откуда брать аргументы метода, то тут дефицита в вариантах нет. Возможными источниками могут выступать: параметры http запроса, значения веб-форм, часть пути url, значения куки, заголовки запроса и матрицы. Чтобы указать конкретный источник, вы должны пометить аргумент одной из аннотаций:
- @FormParam
- @PathParam
- @QueryParam
- @MatrixParam
- @HeaderParam
- @CookieParam
@FormParam привязывает значения параметров формы к значениям параметров метода:
@POST
@Path("/")
public Task addTask(@FormParam("name") String name,
@FormParam("description") String description) {
}
При большом количестве аргументов конструкция метода выглядит устрашающе. К счастью есть возможность этого избежать. Для этого вы можете повесить аннотации @FormParam прямо на поля класса и сделать этот класс аргументом метода:
class Task {
@FormParam("name")
String name;
@FormParam("description")
String description;
}
@Path(value="/tasks")
class TaskService {
@POST
@Path("/")
public Task addTask(@Form Task task) {
}
}
Важный нюанс такого решения: класс, с аннотированными полями, должен содержать конструктор поумолчанию!
@PathParam позволяет использовать в качестве аргументов часть пути:
@GET
@Path("/{id}")
public Task getTask(@PathParam("id") String id) {
}
@QueryParam позволяет использовать в качестве аргументов параметры HTTP запроса:
@GET
@Path("/task?id=123")
public Task getTask(@QueryParam("id") String id) {
}
URL для вызова такого метода может выглядеть так: http://todo.com/tasks/task?id=123
Чтобы избежать NullPointerException в случае, если в запросе будут пропущенные параметры, можно указать для аргумента значение поумолчанию:
@GET
@Path("/task")
public Task getTask(@DefaultValue("123") @QueryParam("id") String id) {
}
В качестве типов аргументов помеченных как @QueryParam или @PathParam могут выступать:
- Все примитивные типы кроме char
- Все классы-оболочки кроме Character
- Любой класс с конструктором, который принимает единственный аргумент типа String
- Любой класс со статическим методом valueOf(String)
- List<T>, Set<T>, или SortedSet<T>, где T - тип удовлетворяющий условиям выше
Но примитивными типами аргументы методов не ограничиваются. Выше уже было показано как с помощью параметров формы
принимать в методах целые классы. Или как легко превращать возвращаемые методами объекты в json или xml
представление. Справедливо было бы ожидать возможность так же легко и получать объекты, созданные из json или xml
запроса. И такая возможность есть! Для этого достаточно пометить метод аннотацией @Consumes и указать принимаемый
медиа тип:
@POST
@Consumes(MediaType.APPLICATION_JSON)
public void updateTask(Task task) {
}
POST /contacts HTTP/1.1
Content-Type: application/json
Content-Length: 32
Content-Type: application/json
Content-Length: 32
Привет мир!
И так, общее представление о JAX-RS уже должно было сложиться. Осталось опробывать его в деле. Как и у любой
спецификации в мире java, у JAX-RS есть несколько реализаций:
- Apache CXF
- Jersey
- RESTeasy
- Restlet
- Apache Wink
- WebSphere Application Server:Version 7.0, Version 8.0
- WebLogic Application Server
- Apache Tuscany
- Cuubez framework
О процессе инсталяции сервера я рассказывать не стану и буду ожидать, что с этим вы справитесь самостоятельно. Поэтому сразу перейду к разработке.
Создадим пустой maven проект:
mvn archetype:generate -DgroupId=ru.dokwork -DartifactId=todo -DinteractiveMode=false
Удалим лишние классы, которые были созданы автоматически, и настроим скрипт:
Освежим версию java:
Настроим плагин для сборки war архивов:
Упростим себе жизнь, настроив процесс локального разворачивания приложения прямо из скрипта (при работе из IDE это
может упростить жизнь, но я не настаиваю):
Пользоваться плагином достаточно просто:
Думаю имена команд говорят сами за себя и разъяснений не требуют. Более подробную информацию вы можете найти здесь:
https://docs.jboss.org/wildfly/plugins/maven/latest/usage.html
Теперь добавим зависимость RESTeasy:
И чтобы получить возможность преобразовывать возвращаемые объекты в json добавим реализацию соответствующего
провайдера:
Ну и наконец, добавим библиотеки для тестирования:
pom.xml
Там где сервлеты, там и web.xml. Согласно принятой в maven структуре каталогов, этот файл должен лежать в директории src/main/webapp/WEB-INF/web.xml:
Для включения библиотеки resteasy в web.xml указывается слушатель и специальный сервлет:
Далее указывается какие адреса должен обрабатывать сервлет:
Если url-pattern отличается от /*, вы должны явно указать используемый resteasy.servlet.mapping.prefix:
Все сервисные классы должны быть явно перечислены в этом конфигурационном файле. Но к счастью есть возможность
избежать этого с помощью автоматического сканирования:
web.xml
Приложение для хранения списка задач так и напрашивается на использование базы данных, но использование баз данных всегда сопрежено с мелкими нюансами, отвлекаться на которые в этой статье совершенно не хочется. Поэтому я отгорожусь от задачи хранения заданий с помощью интерфейса некоторого DAO класса:
И воспользуюсь простейшей его реализацией:
MockTaskDao
Само же приложение будет состоять из класса описывающего задачу и сервиса, эти задачи обслуживающего:
Task
TaskService
Здесь вы снова не найдете поддержки метода PATCH из коробки, и снова добавить ее самостоятельно не составляет труда:
Полный код рассмотренного в статье примера вы можете посмотреть здесь: http://bitbucket.org/dok/todo/
Освежим версию java:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>2.4</version>
</plugin>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<version>1.0.2.Final</version>
<configuration>
<hostname>127.0.0.1</hostname>
<port>9990</port>
<username>admin</username>
<password>password</password>
</configuration>
</plugin>
mvn wildfly:deploy mvn wildfly:redeploy mvn wildfly:undeploy
Теперь добавим зависимость RESTeasy:
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxrs</artifactId>
<version>3.0.8.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson-provider</artifactId>
<version>3.0.8.Final</version>
<scope>provided</scope>
</dependency>
Ну и наконец, добавим библиотеки для тестирования:
- Библиотека для модульного тестирования
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.10</version>
<scope>test</scope>
</dependency>
- Библиотеки для работы с http:
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>fluent-hc</artifactId>
<version>4.3.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.3.5</version>
<scope>test</scope>
</dependency>
- И библиотека для преобразования объектов из json:
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.2.4</version>
<scope>test</scope>
</dependency>
Там где сервлеты, там и web.xml. Согласно принятой в maven структуре каталогов, этот файл должен лежать в директории src/main/webapp/WEB-INF/web.xml:
todo
├── pom.xml
└── src
├── main
│ ├── java
│ │ └── ru
│ │ └── dokwork
│ │ └── todo
│ └── webapp
│ └── WEB-INF
│ └── web.xml
└── test
└── java
├── pom.xml
└── src
├── main
│ ├── java
│ │ └── ru
│ │ └── dokwork
│ │ └── todo
│ └── webapp
│ └── WEB-INF
│ └── web.xml
└── test
└── java
Для включения библиотеки resteasy в web.xml указывается слушатель и специальный сервлет:
<listener>
<listener-class>
org.jboss.resteasy.plugins.server.servlet.ResteasyBootstrap
</listener-class>
</listener>
<servlet>
<servlet-name>resteasy-servlet</servlet-name>
<servlet-class>
org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher
</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>resteasy-servlet</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>resteasy-servlet</servlet-name>
<url-pattern>/resteasy/*</url-pattern>
</servlet-mapping>
<context-param>
<param-name>resteasy.servlet.mapping.prefix</param-name>
<param-value>/resteasy</param-value>
</context-param>
<context-param>
<param-name>resteasy.scan</param-name>
<param-value>true</param-value>
</context-param>
web.xml
Приложение для хранения списка задач так и напрашивается на использование базы данных, но использование баз данных всегда сопрежено с мелкими нюансами, отвлекаться на которые в этой статье совершенно не хочется. Поэтому я отгорожусь от задачи хранения заданий с помощью интерфейса некоторого DAO класса:
И воспользуюсь простейшей его реализацией:
MockTaskDao
Само же приложение будет состоять из класса описывающего задачу и сервиса, эти задачи обслуживающего:
Task
TaskService
Здесь вы снова не найдете поддержки метода PATCH из коробки, и снова добавить ее самостоятельно не составляет труда:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@HttpMethod("PATCH")
public @interface PATCH {
}
Полный код рассмотренного в статье примера вы можете посмотреть здесь: http://bitbucket.org/dok/todo/
Заключение
Обзор получился несколько скомканным, но если отвлечься от примеров кода (недаром они спрятаны поумолчанию), то понять общую концепцию реализации RESTful приложений на Java должно быть не трудно. Благодаря JAX-RS вам не придется заботиться о нудной работе с сервлетами, вы даже можете не замечать того, что пишете веб приложение. Всего десяток аннотаций помогут превратить ваши POJO классы в полноценные REST сервисы!
За рамками данной статьи осталась еще одна очень важная тема: реализация аутентификации и авторизации в REST приложениях. Надеюсь я найду время чтобы изучить и осветить этот вопрос в ближайших статьях.
За рамками данной статьи осталась еще одна очень важная тема: реализация аутентификации и авторизации в REST приложениях. Надеюсь я найду время чтобы изучить и осветить этот вопрос в ближайших статьях.
5 комментариев:
java2ee? :D
j2ee либо Java EE
поправлено, спасибо :)
Второй момент, PUT в роут /tasks/{id}, какой id будет у нового таска? Делать put получается в /tasks/ просто?
Я к тому что есть вероятность затереть данные, случайно сгенерировав существующий uuid. Или нету? Может все-таки база должна его генерить.
UUID и генерируется базой. Но только в случае использования метода POST.
Что касается возможности перезатереть данные методом PUT, то тут скорее вопрос к протоколу:
PUT
Применяется для загрузки содержимого запроса на указанный в запросе URI. Если по заданному URI не существовало ресурса, то сервер создаёт его и возвращает статус 201 (Created). Если же был изменён ресурс, то сервер возвращает 200 (Ok) или 204 (No Content).
На практике вы либо знаете uuid таска которого собираетесь изменить, либо генерируете новый uuid на свой страх и риск. Это может показаться странным, но представьте себе сервис с коллекцией книг, где идентификатором книги является ее имя. Предположим в какой то момент вы нашли редкое издание "Войны и мир" и хотите добавить его в коллекцию. Вам не важно, была ли раньше у Вас такая книга или нет, главное чтобы теперь она появилась в коллекции. Метод PUT позволяет добавить ее в одно действие.
Отправить комментарий