В этой статье мы расскажем, как написать микросервис Spring Boot на основе Spring Jax-Rs Jersey, HATEOAS API и интеграции с JerseyTest. Мы собираемся взять материал из нашей предыдущей статьи Spring Boot — Microservice — Spring Data REST и HATEOAS Integration и перепишем его для нового использования Spring Jax-Rs Jersey.
Вам также может понравиться: REST API — Что такое HATEOAS?
Обе статьи основаны на примере проекта, написанного Грегом Тернквистом, одним из авторов Spring HATEOAS — Справочная документация . Если вы уже знакомы с этим проектом и его проблемной областью, не стесняйтесь пропустить его описание. В противном случае, мы рекомендуем вам продолжать читать.
Maven Project Зависимости
Проект имеет следующие зависимости Maven.
Ниже приведен соответствующий фрагмент файла pom проекта.
XML
xxxxxxxxxx
1
<dependencies>
2
<dependency>
3
<groupId>org.springframework.boot</groupId>
4
<artifactId>spring-boot-starter-data-jpa</artifactId>
5
</dependency>
6
<dependency>
7
<groupId>org.springframework.boot</groupId>
8
<artifactId>spring-boot-starter-hateoas</artifactId>
9
</dependency>
10
<dependency>
11
<groupId>org.springframework.boot</groupId>
12
<artifactId>spring-boot-starter-jersey</artifactId>
13
</dependency>
14
<dependency>
16
<groupId>com.h2database</groupId>
17
<artifactId>h2</artifactId>
18
<scope>runtime</scope>
19
</dependency>
20
<dependency>
21
<groupId>org.springframework.boot</groupId>
22
<artifactId>spring-boot-starter-test</artifactId>
23
<scope>test</scope>
24
<exclusions>
25
<exclusion>
26
<groupId>org.junit.vintage</groupId>
27
<artifactId>junit-vintage-engine</artifactId>
28
</exclusion>
29
</exclusions>
30
</dependency>
31
<dependency>
33
<groupId>org.glassfish.jersey.test-framework</groupId>
34
<artifactId>jersey-test-framework-core</artifactId>
35
<scope>test</scope>
36
</dependency>
37
<dependency>
39
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
40
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
41
<scope>test</scope>
42
</dependency>
43
</dependencies>
Структура кода проекта
Этот проект имеет следующую структуру.
KievSpringJaxrsJerseyAndHateoasApplication.java - класс приложения Spring Boot, созданный Spring Boot. Вы уже видели подобные коды много раз. Добавление выделенного статического свойства mapperString необходимо для интеграции с платформой JerseyTest и будет подробно обсуждаться позже.
JerseyConfig.java - простой файл конфигурации Jersey 2.x. В этом нет ничего особенного. Как мы видим, здесь зарегистрирована одна конечная точка.
CustomOrderHateoasController.java, OrdersModel.java и HalConfig.java - пользовательский контроллер, известный как конечная точка, и его классы поддержки и конфигурации соответственно. Именно здесь происходит интеграция Spring Jax-Rs Jersey и HATEOAS API.
KievSpringJaxrsJerseyAndHateoasApplcationTests.java, MockMvc.java и MockMvcConfig.java - Классы, отвечающие за интеграцию инфраструктуры JerseyTest с приложением Spring Boot. И это то, что делает их довольно интересными.
Мы готовы начать анализ проекта. Но прежде чем мы это сделаем, давайте освежим нашу память, каковы цели проекта. Ниже приведены соответствующие выдержки из документа README.adoc .
Определение проблемы
ПРОБЛЕМА : Вы хотите реализовать концепцию заказов. Эти заказы имеют определенные коды состояния, которые определяют, какие переходы может предпринять система, например, заказ не может быть выполнен до тех пор, пока он не оплачен, и выполненный заказ не может быть отменен.
РЕШЕНИЕ . Вы должны кодировать набор OrderStatus
кодов и применять их с помощью специального контроллера Spring Web MVC. Этот контроллер должен иметь маршруты, которые отображаются рядом с маршрутами, предоставленными Spring Data REST .
Опишем основы системы заказов.
Это начинается с объекта домена:
Джава
xxxxxxxxxx
1
2
name = "ORDERS") // (1) (
3
class Order {
4
6
private Long id; // (2)
7
private OrderStatus orderStatus; // (3)
9
private String description; // (4)
11
private Order() {
13
this.id = null;
14
this.orderStatus = OrderStatus.BEING_CREATED;
15
this.description = "";
16
}
17
public Order(String description) {
19
this();
20
this.description = description;
21
}
22
...
24
}
Следующим шагом в определении вашего домена является определение OrderStatus
. Предполагая, что мы хотим общий поток Create an order
⇒ Pay for an order
⇒ Fulfill an order
, с возможностью отмены, только если вы еще не заплатили за это, это будет хорошо:
Джава
xxxxxxxxxx
1
public enum OrderStatus {
2
BEING_CREATED, PAID_FOR, FULFILLED, CANCELLED;
4
/**
6
* Verify the transition between {@link OrderStatus} is valid.
7
*
8
* NOTE: This is where any/all rules for state transitions should be kept and enforced.
9
*/
10
static boolean valid(OrderStatus currentStatus, OrderStatus newStatus) {
11
if (currentStatus == BEING_CREATED) {
13
return newStatus == PAID_FOR || newStatus == CANCELLED;
14
} else if (currentStatus == PAID_FOR) {
15
return newStatus == FULFILLED;
16
} else if (currentStatus == FULFILLED) {
17
return false;
18
} else if (currentStatus == CANCELLED) {
19
return false;
20
} else {
21
throw new RuntimeException("Unrecognized situation.");
22
}
23
}
24
}
Вверху находятся актуальные состояния. Внизу статический метод проверки. Именно здесь определяются правила перехода состояний и где остальная часть системы должна искать, чтобы определить, является ли переход действительным.
Последний шаг, который нужно предпринять, - это определение репозитория Spring Data:
Джава
xxxxxxxxxx
1
public interface OrderRepository extends CrudRepository<Order, Long> {
2
}
Этот репозиторий расширяет CrudRepository Spring Data Commons, заполняя домен и типы ключей (Order и Long).
Следующий класс предварительно загружает некоторые данные тестирования. Мы считаем, что аннотацию @Configuration следует использовать вместо @Component, но мы стараемся максимально использовать код нашего фонда, и именно поэтому у нас есть аннотация @Component.
Джава
xxxxxxxxxx
1
2
public class DatabaseLoader {
3
5
CommandLineRunner init(OrderRepository repository) { // (1)
6
return args -> { // (2)
8
repository.save(new Order("grande mocha")); // (3)
9
repository.save(new Order("venti hazelnut machiatto"));
10
};
11
}
12
}
Следует отметить, что прямо здесь вы можете запустить свое приложение. Spring Boot запустит веб-контейнер, предварительно загрузит данные, а затем переведет Spring Data REST в режим онлайн. Spring Data REST со всеми его предварительно созданными маршрутами на основе гипермедиа будет отвечать на вызовы для создания, замены, обновления и удаления Order
объектов.
Spring Data REST ничего не будет знать о допустимых и недопустимых переходах состояний. Его предварительно созданные ссылки помогут вам перейти от /api
сводного корня ко всем заказам, к отдельным записям и обратно. Но не будет концепции оплаты, выполнения или отмены заказов. По крайней мере, не встраивается в гипермедиа. Единственные подсказки, которые могут иметь конечные пользователи, - это полезные данные существующих заказов.
Это не эффективно.
Нет, лучше создать несколько дополнительных операций и затем обслуживать их ссылки, когда это необходимо .
На этом мы завершаем описание наших предварительных шагов. На данный момент вы должны знать цели проекта. Мы знаем, что нужно сделать, поэтому давайте посмотрим, как это можно сделать. Давайте поговорим об интеграции Spring Jax-Rs Jersey и HATEOAS.
Spring Jax-Rs Джерси и HATEOAS Интеграция
Через минуту мы поговорим о конечной точке CustomOrderHateoasController и ее файлах поддержки и конфигурации. Во-первых, давайте дадим кристально ясный смысл всей этой интеграции с HATEOAS. Так в чем же дело?
Как мы знаем, основной принцип HATEOAS заключается в том, что ресурсы должны быть обнаружены путем публикации ссылок, указывающих на доступные ресурсы. Например, если мы выдадим HTTP GET для корневого URL нашего проекта, мы должны увидеть следующий вывод.
Первая ссылка должна направить нас к заказам ресурсов , а последняя ссылка должна направить нас к документу ALPS . У нас нет документа ALPS для этой демонстрации, поэтому давайте выполним HTTP GET для первой ссылки, то есть заказов. Мы должны увидеть следующий результат, и теперь мы можем видеть, что есть два заказа, и мы можем оплатить или отменить их соответственно.
Остановимся здесь на секунду и рассмотрим, в чем разница между первым корневым ресурсом и ресурсом второго порядка. Хорошо, одна вещь, которая приходит на ум, это наблюдение, что ресурс orders имеет дело с данными из хранилища, а корневой ресурс - нет.
Это означает, что второй ресурс будет использовать EntityModel из Spring Data JPA, и именно здесь мы должны начать делать некоторую интеграцию с HATEOAS API, предоставляемым Spring. Давайте посмотрим на соответствующие методы из CustomOrderHateoasController.java .
Вот где на помощь приходит класс поддержки OrderModel.java .
Этот статический класс отвечает за описание HAL корневого ресурса. Из-за того, что он не использует EntityModel, мы можем иметь свойство с именем _links , и Джерси будет генерировать JSON в соответствии с нашими ожиданиями. Ведь мы контролируем, мы хозяева своего домена! Мы хотели бы отметить, что это представление написано вручную, но оно должно быть приемлемым.
Ведь речь идет о микросервисах, а это подразумевает, что корневой API не слишком велик. Иначе это был бы не микросервис, а что-то еще. В любом случае, давайте посмотрим, что происходит, когда нам нужно сгенерировать представление HAL для заказов ресурсов .
Здесь мы можем увидеть свойство с именем _embedded, чтобы Джерси генерировал его соответствующим образом. Но затем мы можем видеть, что мы используем создание экземпляра интерфейса EntityModel , и это создание вне нашей досягаемости. У него нет свойства _links , но есть ссылки .
Так что же это значит и почему нас это волнует? Мы заботимся, потому что Джерси будет генерировать следующее представление.
Выделенные поля показывают, где проблема. Здесь мы можем ясно видеть, что Джерси генерировал ссылки, а не те ссылки, которые мы хотели бы иметь. Это имеет смысл после того, как все ссылки - это то, что мы имеем в EntityModel . Это означает, что мы готовы начать интеграцию с Spring HATEOAS, что приведет нас к формату HAL. Давайте рассмотрим и поприветствуем HalConfig.java , класс конфигурации, который делает все возможное.
Теперь вы можете запустить приложение весенней загрузки и выполнить действия, описанные в документе README.adoc .
Джава
xxxxxxxxxx
1
$ curl localhost:8080/api/orders/1 { "orderStatus" : "BEING_CREATED", "description" : "grande mocha", "_links" : {
2
"self" : {
3
"href" : "http://localhost:8080/api/orders/1"
4
},
5
"order" : {
6
"href" : "http://localhost:8080/api/orders/1"
7
},
8
"payment" : {
9
"href" : "http://localhost:8080/api/orders/1/pay"
10
},
11
"cancel" : {
12
"href" : "http://localhost:8080/api/orders/1/cancel"
13
}
14
}
15
====
16
Apply the payment link:
17
====
18
$ curl -X POST localhost:8080/api/orders/1/pay { "id" : 1, "orderStatus" : "PAID_FOR", "description" : "grande mocha" }
19
$ curl localhost:8080/api/orders/1 { "orderStatus" : "PAID_FOR", "description" : "grande mocha", "_links" : {
20
"self" : {
21
"href" : "http://localhost:8080/api/orders/1"
22
},
23
"order" : {
24
"href" : "http://localhost:8080/api/orders/1"
25
},
26
"fulfill" : {
27
"href" : "http://localhost:8080/api/orders/1/fulfill"
28
}
29
}
30
====
31
The `pay` and `cancel` links have disappeared, replaced with a `fulfill` link.
32
Fulfill the order and see the final state:
33
====
34
$ curl -X POST localhost:8080/api/orders/1/fulfill { "id" : 1, "orderStatus" : "FULFILLED", "description" : "grande mocha" }
35
$ curl localhost:8080/api/orders/1 { "orderStatus" : "FULFILLED", "description" : "grande mocha", "_links" : {
36
"self" : {
37
"href" : "http://localhost:8080/api/orders/1"
38
},
39
"order" : {
40
"href" : "http://localhost:8080/api/orders/1"
41
}
42
}
43
Все работает как положено. Это означает, что наша интеграция Jax-Rs Jersey с HATEOAS API прошла очень успешно. Но ждать! О чем эта выделенная строка? Что это за mapperString ? Да, в самом деле! Что это?
Интеграция Spring Boot с JerseyTest Framework
Мы используем его для интеграции приложения Spring Boot и инфраструктуры JerseyTest. В конце концов, мы живем в материальном мире, и они требуют единичных тестов здесь для хлеба. Поэтому мы не можем остановиться, у нас нет другого выбора, кроме как продолжать идти. Как вы, наверное, знаете, Jax-Rs Jersey - это собственный мир, и в этом мире им нравится структура JerseyTest .
Мы должны принять это как факт и жить с этим. Им также нравится тестовый контейнер Grizzly, и с нами это тоже нормально. Итак, что мы имеем в конце? У нас есть среда JerseyTest и тестовый контейнер Grizzly для использования. Помните наш файл pom выше, последние две зависимости? Если ответ «Нет», прокрутите текст назад и посмотрите.
Итак, теперь вы знаете, что мы должны делать. Мы должны запустить тестовые случаи, написанные Грегом Тернквистом OrderIntegrationTest.java . Это не должно быть большой проблемой, верно? Ну, ответ да и нет. Правда в том, что мы не можем использовать это так, как есть. Причина довольно проста. Это было написано Весенними людьми для Весенней толпы. Другими словами, он основан на тестовой среде Spring MockMvc. И наше приложение JerseyTest не является MVC. Значит ли это, что мы должны переписать контрольные примеры, повторно использовать логику, а не уже написанный код?
Мы можем это сделать. Но что делать, если у вас есть тонна уже написанных тестовых примеров JUnit для Spring MVC, и теперь они говорят вам, что вы мигрируете в мир Джерси. Да, весь путь! Собираетесь ли вы сделать переписать? Вдохни и не паникуй. По крайней мере, пока не паникуйте.
Мы рады сообщить вам, что вам не нужно. Хотя вы не можете повторно использовать весь написанный код Spring MockMVC, вы можете повторно использовать его, и самое главное, вы сможете выполнить все уже написанные тестовые примеры JUnit без каких-либо изменений, но с небольшой помощью, конечно. Теперь (барабанные удары) Дамы и господа, давайте познакомим вас с интеграцией Spring Boot - JerseyTest!
С чего начать? Начнем с существующего кода. Посмотрим, что у нас есть.
Первое, на что нужно обратить внимание, это _ ссылки, выделенные красной рамкой. Также существуют такие методы , например , как выполнить () , andDo () , andExpect () . Эти методы связаны между собой. Другими словами, их возвращаемое значение - это сам экземпляр ( this.mvc ). Мы также можем сделать вывод, что эти аргументы методов экземпляра являются статическими вызовами методов. Давайте перечислим некоторые из них, чтобы прояснить ситуацию: get () , post () , jsonPath () и ext.
Давайте начнем говорить об этих вещах один за другим. Давайте начнем говорить о _links . На этом этапе нашего обсуждения мы должны быть знакомы с его происхождением. Это представление ссылок HAL . Поскольку наше приложение JerseyTest еще не интегрировано с HATEOAS, и мы можем предположить, что оно потерпит неудачу здесь. И это так. Рассмотрим KievSpringJaxrsJerseyAndHateoasApplcationTests.java .
Приложение JerseyTest имеет собственный контекст приложения. По умолчанию он пытается использовать конфигурацию на основе XML-схемы. Это было бы нашей первой неудачей. Ведь созданное нами весеннее загрузочное приложение основано на конфигурации на основе аннотаций. Таким образом, первая выделенная строка заботится об этом.
Мы видим, что мы пытаемся обработать наше приложение Spring Boot, и мы ожидаем, что все будет загружено. Да, но это не то же самое. Одна вещь, которую вы заметите, что хранилище потеряло тестовые загруженные данные. Это легко исправить, и мы можем перезагрузить тестовые данные. Ничего страшного, мы можем сказать себе. В конце концов, мы проводим тесты, и имеет смысл загрузить некоторые данные для тестов. Так что мы хороши в этом.
Однако, к нашему удивлению, мы обнаружили, что загруженная интеграция HATEOAS, средство отображения объектов, не доставляет. Картограф будет в контексте JerseyTest, но сгенерированное представление будет ссылками , а не ссылками , ожидаемыми в письменных тестах. Очевидно, что для исправления ситуации необходимо предпринять некоторые хитрости.
Помните, mapperString статическое свойство класса приложения Spring Boot? Вы уверены, что делаете. Так что же мы знаем? Мы знаем, что объект сопоставления из контекста приложения Spring Boot обеспечивает. Таким образом, мы можем попытаться получить этот экземпляр и передать его в контекст JerseyTest. Для этого и используется свойство mapperSpring . И подход работает!
Так что же это за объект MVC, который мы получаем из контекста приложения JerseyTest (строка 55)? Одно можно сказать наверняка, это не может быть оригинальный MockMvc из среды тестирования Spring Boot. Если вы догадались, вы были правы! Не то! Это наш адаптер, который помещает все части головоломки на место. Вот как это работает. Но прежде чем мы перейдем к его обсуждению, давайте посмотрим на его конфигурацию. Итак, мы знаем, как оно попадает в контекст приложения JerseyTest.
Так что это экземпляр MockMvc.java адаптера, который мы написали, чтобы можно было повторно использовать существующие тестовые примеры Junit. С помощью класса мы можем выполнить тест, написанный для тестовой среды Spring MockMvc в наборе JerseyTest. Это просто, это довольно грубо, но это делает работу! Не стесняйтесь исследовать это, конечно. Прежде чем закончить статью и поблагодарить вас за чтение, мы бы хотели выделить некоторые ее функции, такие как вызовы статических методов и методов экземпляра. То, что мы считаем, информативно.
Давайте рассмотрим утверждение this.mvc.perform(get("/api"))
xxxxxxxxxx
1
public static Pair<String, String> get(String path) {
2
return Pair.of(HttpMethod.GET, path);
3
}
4
public static Pair<String, String> post(String path) {
6
return Pair.of(HttpMethod.POST, path);
7
}
8
...
9
public MockMvc perform(Pair<String, String> methodPath) {
10
String httpMethod = methodPath.getFirst();
11
String path = methodPath.getSecond();
12
if (HttpMethod.GET.equals(httpMethod)) {
13
Response response = client
14
.target(String.format("%s://%s%s", baseUri.getScheme(), baseUri.getAuthority(), path)).request()
15
.get();
16
setResponse(response);
17
} else if (HttpMethod.POST.equals(httpMethod)) {
18
Response response = client
19
.target(String.format("%s://%s%s", baseUri.getScheme(), baseUri.getAuthority(), path)).request()
20
.post(Entity.json(null));
21
setResponse(response);
22
}
23
return this;
24
}
Как видите, мы вызываем нашу конечную точку, развернутую в тестовом контейнере Grizzly с помощью инфраструктуры JerseyTest.
Другое интересное утверждение для рассмотрения .andExpect(jsonPath("$._embedded.orders[0].orderStatus", is("BEING_CREATED")))
Джава
xxxxxxxxxx
1
public static COMMAND jsonPath(String expression, String expected) {
2
COMMAND.JSON_PATH.setExpression(expression);
3
COMMAND.JSON_PATH.setExpected(expected);
4
return COMMAND.JSON_PATH;
5
}
6
public static String is(String expected) {
8
return expected;
9
}
10
...
11
public MockMvc andExpect(MockMvc.COMMAND command) throws Exception {
12
switch (command) {
13
case PRINT:
14
break;
15
case STATUS_OK: {
16
if (!Response.Status.OK.equals(Response.Status.fromStatusCode(response.getStatus())))
17
throw new Exception();
18
break;
19
}
20
case CONTENT_HAL_JSON: {
21
String contentType = response.getHeaderString("Content-Type");
22
if (!(MediaTypes.HAL_JSON).equals(MediaType.valueOf(contentType)))
23
throw new Exception();
24
break;
25
}
26
case JSON_PATH: {
27
String expression = COMMAND.JSON_PATH.getExpression();
28
String expected = COMMAND.JSON_PATH.expected;
29
String json = getJson();
30
Object[] args = {};
31
JsonPathExpectationsHelper helper = new JsonPathExpectationsHelper(expression, args);
32
Object rawActual = helper.evaluateJsonPath(json);
34
boolean isNumeric = expected.chars().allMatch(Character::isDigit);
35
if (isNumeric) {
36
Integer actualValue = (Integer) rawActual;
37
rawActual = String.format("%d", actualValue);
38
}
39
String actualUri = (String) rawActual;
40
String expectedUri = expected;
41
if (PORT_HANDLING.SET_EXPECTED_PORT_FROM_ACTUAL.equals(portHandling) && expectedUri.startsWith("http://")) {
42
// very naive approach, but it is good enough for demo
43
URL originalURL = new URL(expected);
44
// add port
45
URL portURL = new URL(originalURL.getProtocol(), originalURL.getHost(), baseUri.getPort(),
46
originalURL.getFile());
47
expectedUri = portURL.toString();
48
}
49
if (!expectedUri.equals(actualUri))
50
throw new Exception();
51
break;
53
}
54
case IS4XXCLIENTERROR: {
55
if (!Response.Status.Family.CLIENT_ERROR.equals(Response.Status.Family.familyOf(response.getStatus())))
56
throw new Exception();
57
break;
58
}
59
case CONTENT_APPLICATION_JSON: {
60
String contentType = response.getHeaderString("Content-Type");
61
if (!(MediaType.APPLICATION_JSON).equals(MediaType.valueOf(contentType)))
62
throw new Exception();
63
break;
64
}
65
case CONTENT_STRING: {
66
String expected = COMMAND.CONTENT_STRING.getExpected();
67
String json = getJson();
68
if (!json.equals(expected))
69
throw new Exception();
70
break;
71
}
72
default:
73
throw new Exception();
74
}
75
return this;
77
}
Стоит упомянуть класс J sonPathExpectationsHelper.java (строка 121). Он является частью среды тестирования Spring MockMvc и является реальным базовым компонентом, который обрабатывает ожидания и фактические выражения - такие как «$ ._ embedded.orders [0] .orderStatus» и ext.
Последняя часть, о которой стоит упомянуть, это клиент JerseyTest, который MockMvc использует для вызова конечной точки, развернутой в контейнере Grizzly ( execute () ). Настройка происходит перед каждым тестом. Учтите следующее.
По умолчанию JerseyTest сбрасывает тестовый контейнер перед каждым тестом. В результате мы должны сбросить клиента в нашем адаптере MVC.
Выводы
Вот и все. Не стесняйтесь взять код из GitHub и опробовать его. Не стесняйтесь расширять это и использовать это, если это действительно имеет смысл. В конце концов, они говорят, что лучше один раз увидеть, чем тысячу раз услышать. Поговорка - азиатская пословица. Один из авторов живет и работает там. Поэтому, если вы окажетесь в Сеуле, Республика Корея, зайдите и скажите «Привет Богдану». Что касается меня, я немного устал. На самом деле, я очень устал в последнее время. Пора спать. Покойся с миром Константин. 🙂
Дальнейшее чтение
Веб-сервисы RESTful с Spring Boot, Gradle, HATEOAS и Swagger