Простое RESTful приложение с помощью JAX-RS

суббота, 30 августа 2014 г.

RESTful - что может быть проще? Только RESTful реализованный с помощью Java EE!

Конечно, многие с этим не согласятся и приведут кучу весомых аргументов против, и будут по своему правы. Но поверьте, в Java мире проделана огромная работа, чтобы упростить воплощение ваших REST идей в жизнь. И в этой статье мне бы хотелось наглядно это продемонстрировать.

Скажу сразу, статья не затрагивает теорию Rest-приложений, ее вы и так можете самостоятельно почерпнуть из множества статей на просторах интернета. Приведу лишь ссылку на цикл статей от Mugunth Kumar, достойных внимания: RESTful API Server – Doing it the right way (перевод на хабре: RESTful API для сервера – делаем правильно) и статью Как мы делали API для социальной сети (REST API для WEB) подробно освещающую основные аспекты идеологии REST.

Итак, самый простой способ объяснить что-то - показать это "что-то" в деле. Для примера, будет показан процесс реализации REST API для простого планировщика повседневных задач - так называемого TODO листа.

Что из себя представляет приложение TODO?

Демонстрационное приложение TODO будет позволять вести список задач. Каждая задача будет состоять из уникального идентификатора, имени, описания и статуса выполнена/невыполнена:
class Task { uuid: UUID name: String description: String completed: boolean }
Задачи будут представлены с помощью json. Пример Json представления задачи:
{
    "uuid":"550e8400-e29b-41d4-a716-446655440000",
    "name":"написать RESTful приложение",
    "description":"чтобы полученные знания принесли пользу и не были забыты, ими необходимо воспользоваться в течении первых 72 часов после получения",
    "completed":false
}
В качестве значения completed допускается true или false.

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

usecase GET as "Получить список всех заданий -- GET: /tasks" usecase GET_ACTIVE as "Получить список выполненных/невыполненных заданий -- GET: /tasks/find?completed=false{true}" usecase GET_BY_ID as "Получить конкретное задание -- GET: /tasks/{id}" usecase POST as "Добавить задание -- POST: /tasks" usecase PUT as "Создание нового задания/изменение существующего -- PUT: /tasks/{id}" usecase PATCH as "Редактировать отдельные параметры заданий -- PATCH: /tasks/{id}" usecase DELETE as "Удалить задание -- DELETE: /tasks/{id}" User -u-> (GET) User -u-> (GET_ACTIVE) User -l-> (GET_BY_ID) User -r-> (PUT) User --> (POST) User --> (PATCH) User --> (DELETE)
  • Получение списка всех заданий
  • Получение списка выполненых/невыполненных заданий
  • Получение конкретного задания
  • Добавление задания
  • Создание нового задания/изменение существующего
  • Редактирование отдельных параметров задания
  • Удаление задания


Пример специально максимально упрощен, чтобы не отвлекать читателя от 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);
    }
}
Не смотря на то, что для написания тестов использовалась библиотека JUnit, это совсем не модульные тесты. Поэтому не стоит их складывать в каталог test, а лучше создать для этих целей отдельную директорию.

Введение в 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) {
      }
    }
просто, не так ли? На самом деле не совсем. Чудес не бывает, и никто не сможет за вас решить, в каком формате передавать ваши данные в ответ на GET запрос и откуда брать параметры для POST запроса. Все это необходимо реализовывать самостоятельно. Хорошая новость в том, что за вас о многом уже позаботились.

Так,из коробки, вы получаете возможность сериализации/десериализации данных в популярные форматы, такие как JSON и XML с помощью аннотации
@Produces({"application/xml", "application/json"})
Аннотация может быть применена как к каждому конкретному методу в отдельности, так и ко всему сервисному классу в целом. Конкретный тип представления данных выбирается исходя из указанного в запросе MIME типа, или первый, если в запросе также перечислены оба. Полный перечень типов вы можете найти на страницах спецификации: http://jsr311.java.net/nonav/releases/1.0/javax/ws/rs/core/MediaType.html

Что касается вопроса, откуда брать аргументы метода, то тут дефицита в вариантах нет. Возможными источниками могут выступать: параметры http запроса, значения веб-форм, часть пути url, значения куки, заголовки запроса и матрицы. Чтобы указать конкретный источник, вы должны пометить аргумент одной из аннотаций:
  • @FormParam
  • @PathParam
  • @QueryParam
  • @MatrixParam
  • @HeaderParam
  • @CookieParam
В этой статье я затрону только первые три из них. Более подробную информацию вы всегда можете получить со страниц сайта Oracle: http://docs.oracle.com/javaee/6/tutorial/doc/gilik.html.

@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

Привет мир!

И так, общее представление о JAX-RS уже должно было сложиться. Осталось опробывать его в деле. Как и у любой спецификации в мире java, у JAX-RS есть несколько реализаций:
По ряду исключительно субъективных причин, я остановлю свой выбор на RESTeasy от Red Hat - реализации используемой в JBoss. Соответственно в качестве сервера приложения будет использоваться JBoss версии 8, носящий гордое имя Wildfly.

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

Создадим пустой maven проект:
mvn archetype:generate -DgroupId=ru.dokwork -DartifactId=todo -DinteractiveMode=false
Удалим лишние классы, которые были созданы автоматически, и настроим скрипт:
Освежим версию java:
    <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
            <source>1.7</source>
            <target>1.7</target>
        </configuration>
    </plugin>
Настроим плагин для сборки war архивов:
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>2.4</version>
    </plugin>
Упростим себе жизнь, настроив процесс локального разворачивания приложения прямо из скрипта (при работе из IDE это может упростить жизнь, но я не настаиваю):
    <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
Думаю имена команд говорят сами за себя и разъяснений не требуют. Более подробную информацию вы можете найти здесь:  https://docs.jboss.org/wildfly/plugins/maven/latest/usage.html

Теперь добавим зависимость RESTeasy:
    <dependency>
        <groupId>org.jboss.resteasy</groupId>
        <artifactId>resteasy-jaxrs</artifactId>
        <version>3.0.8.Final</version>
        <scope>provided</scope>
    </dependency>
И чтобы получить возможность преобразовывать возвращаемые объекты в json добавим реализацию соответствующего провайдера:
    <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>
pom.xml

Там где сервлеты, там и 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

Для включения библиотеки 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>
Если url-pattern отличается от /*, вы должны явно указать используемый resteasy.servlet.mapping.prefix:

    <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 класса:
interface Task { getByUUID(uuid: UUID): Task save(task: Task) remove(task: Task) findByStatus(isCompleted: boolean) Collection<Task> getAll(): Collection<Task> }
И воспользуюсь простейшей его реализацией:
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 приложениях. Надеюсь я найду время чтобы изучить и осветить этот вопрос в ближайших статьях.

5 комментариев :

  1. java2ee? :D

    j2ee либо Java EE

    ОтветитьУдалить
    Ответы
    1. Второй момент, PUT в роут /tasks/{id}, какой id будет у нового таска? Делать put получается в /tasks/ просто?

      Удалить
    2. Я к тому что есть вероятность затереть данные, случайно сгенерировав существующий uuid. Или нету? Может все-таки база должна его генерить.

      Удалить
    3. UUID и генерируется базой. Но только в случае использования метода POST.

      Что касается возможности перезатереть данные методом PUT, то тут скорее вопрос к протоколу:

      PUT
      Применяется для загрузки содержимого запроса на указанный в запросе URI. Если по заданному URI не существовало ресурса, то сервер создаёт его и возвращает статус 201 (Created). Если же был изменён ресурс, то сервер возвращает 200 (Ok) или 204 (No Content).

      На практике вы либо знаете uuid таска которого собираетесь изменить, либо генерируете новый uuid на свой страх и риск. Это может показаться странным, но представьте себе сервис с коллекцией книг, где идентификатором книги является ее имя. Предположим в какой то момент вы нашли редкое издание "Войны и мир" и хотите добавить его в коллекцию. Вам не важно, была ли раньше у Вас такая книга или нет, главное чтобы теперь она появилась в коллекции. Метод PUT позволяет добавить ее в одно действие.

      Удалить

Ваше мнение мне искренне интересно. Смелее!

Технологии Blogger.