Если вы занимались крупными Java-проектами, то вы, наверное, помните старый добрый WSDL (Web Services Description Language, язык описания веб-сервисов), за которым стоят IBM и Microsoft. WSDL — это язык описания веб-сервисов, основанный на XML. А, может, вы всё ещё пользуетесь этим языком? WSDL и его брат-близнец — язык XML Schema, относятся к тем стандартам W3C, которые являются излюбленным объектом ненависти бывалых программистов. Файлы спецификаций WSDL не особенно легко читать людям, а об удобстве их ручного составления лучше и не говорить. Но, к счастью, работать с подобными файлами вручную и не нужно. Они могут быть сгенерированы конечной точкой сервера и переданы прямо в кодогенератор для создания объектов переноса данных (DTO, Data Transfer Object) и стабов сервиса.
Цель документа, описывающего спецификации контракта, заключается в том, чтобы сообщить сведения о внешних частях вашего сервиса программистам, которые пользуются этим сервисом. Ни одно сложное приложение не обходится без подобного документа. Особенно это касается микросервисных проектов, поддерживаемых удалёнными командами разработчиков. Если подумать о том, чтобы избавиться от WSDL, и вспомнить одно известное высказывание, которое звучит как «Не стоит выплёскивать из ванны с грязной водой и самого ребёнка», то окажется, что размеры и сложность WSDL-спецификаций — это «вода», а чёткие описания сервиса — это «ребёнок». Как бы нам ни хотелось избавиться от «грязной воды», «ребёнка» мы «выплеснуть» не можем. Индустриальные стандарты, которые не должны зависеть от реализации сервисов и должны иметь широкое распространение, вышли из моды из-за их неисправимой сложности. Но нам просто необходима альтернатива таким стандартам.
Если вы, работая в одной и той же экосистеме, занимаетесь созданием и клиента, и сервера, это значит, что у вас есть роскошь обладания собственным мнением по поводу выбора протоколов и наборов инструментов. Это может сделать процесс создания API проще и удобнее с точки зрения разработчика.
Мы рассмотрим проект REST-сервиса, который имеет одну конечную точку, а так же — отдельный проект, предназначенный для тестирования этого сервиса. Мы будем применять подход, в котором можно выделить две части:
- Публикация API путём создания .jar-файла, содержащего DTO в виде простых Java-объектов (POJO, Plain Old Java Object) и описания конечных точек API в виде Java-интерфейсов.
- И REST-сервер, и проект, используемый для тестирования сервера, зависят от .jar-файла с описанием API. Пользователи сервиса используют интерфейсы, описанные в этом файле, для создания клиентских прокси-объектов с применением фреймворка Retrofit. А REST-сервер лишь использует ссылки на DTO.
Обзор проекта
Исходный код проекта можно найти в этом GitLab-репозитории. Клонировать его можно так:
git clone git@gitlab.com:jsprengers/spring-retrofit-demo.git
Это — maven-проект, в состав которого входит три подпроекта: service, api и integration. Вот основные сведения об этих проектах:
- api: содержит DTO и спецификации REST-контроллера.
- service: это — REST-сервис, созданный с использованием Spring Boot. Он, в плане DTO, зависит от подпроекта api .
- integration: включает в себя только интеграционные тесты. Он зависит от подпроекта api в плане DTO и спецификаций конечной точки REST-сервиса.
Проект service
Конечная точка возвращает и принимает DTO Person, описанные в проекте api . Тут, для имитации постоянного хранилища, в котором данные размещаются между вызовами, используется объект, данные которого хранятся в памяти. В реализации Basic Authentication различаются две роли — user и admin. Пользователь admin может выполнять запросы PUT, POST и DELETE (то есть — обладает возможностью чтения и записи данных), а пользователь user может выполнить лишь запрос GET (то есть — может лишь читать данные). Пароли хранятся в виде переменных окружения, которые загружаются при запуске проекта. Для учётных записей user и admin, по умолчанию, используются, соответственно, пароли nosecret и secret. Тут, как видите, всё очень просто. Всё же, это — учебный проект.
@RestController @RequestMapping("api/person") @RequiredArgsConstructor @Slf4j public class PersonController { @Autowired private final PersonDAO personDAO; @GetMapping List<Person> getAll(@RequestParam(value = "fields", required = false) String fields) { return personDAO.getAll(fields); } @GetMapping("/{id}") Person getPersonById(@PathVariable("id") String id, @RequestParam(value = "fields", required = false) String fields) { return personDAO.getById(id, fields).orElseThrow(() -> { throw new NotFoundException("No such ID: " + id); }); } @PostMapping void createPerson(@RequestBody Person person) { if (personDAO.getById(person.getId(), null).isPresent()) { throw new IllegalArgumentException("Person with ID already exists: " + person.getId()); } log.info("Storing person with id {}", person.getId()); personDAO.put(person); } @PutMapping void upsertPerson(@RequestBody Person person) { personDAO.put(person); } @DeleteMapping("/{id}") void deletePerson(@PathVariable("id") String id) { personDAO.deleteById(id); } }
Проект api
Проект api включает в себя DTO из предметной области приложения, в нашем случае это — Person в виде POJO. Применение фреймворка Lombok способствует минимизации объёма шаблонного кода, применяемого в проекте. Он позволяет включать в проект, в формате JSON, документацию и подсказки по сериализации (десериализации) объектов.
@Data @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) public class Person { private String id; private String name; private String address; private String email; }
Вторая часть контракта сервиса — это описание конечных точек контроллера, выполненное в виде интерфейсов Java. Они, в целом, представляют собой методы контроллера, у которых нет тел методов. Именно тут в дело вступает фреймворк Retrofit. Он позволяет пользоваться аннотациями для декорирования интерфейсов, которые будут превращены в сетевые прокси-объекты для сервиса, на работу с которым они рассчитаны. Эти аннотации очень похожи на те, которые используются в серверных контроллерах.
public interface PersonAPIClient { @GET("api/person") Call<List<Person>> getAll(@Query("fields") String fields); @GET("api/person/{id}") Call<Person> getPersonById(@Path("id") String id, @Query("fields") String fields); @POST("api/person") Call<Void> createPerson(@Body Person person); @PUT("api/person") Call<Void> upsertPerson(@Body Person person); @DELETE("api/person/{id}") Call<Void> deletePerson(@Path("id") String id); }
Обратите внимание на возвращаемые типы. Как вы увидите ниже — эти интерфейсы представляют собой шаблоны для реализаций методов, которые возвращают параметризованный объект Call, который является прокси-объектом для сетевого клиента более низкого уровня. Клиент считывает тело запроса и даёт сведения о статусе HTTP-запроса. Учитывайте, что из-за того, что везде используется стандартный возвращаемый тип Call, мы не можем позволить REST-контроллерам реализовать интерфейс API. Можно обстоятельно поговорить о том, хорошо ли, когда в проекте имеется столь тесная связь между сущностями, но это — спорный вопрос. Сейчас нам нужно поддерживать интерфейсы вручную и держать соответствующий код обособленно.
Проект integration
Этот проект демонстрирует тесты, в ходе выполнения которых отправляются запросы к REST-сервису, при этом зависимость этого проекта от общедоступного API сервиса выражается лишь в использовании соответствующего кода при создании тестов. На стадии сборки package осуществляется отправка Docker-образа с рабочим сервисом в локальный репозиторий с использованием jib-maven-plugin. При подготовке интеграционных тестов к работе осуществляется загрузка этого образа и запуск контейнера с применением фреймворка Testcontainers.
public class PersonAPIContainerizedIntegrationTest { private static AppContainer container; private static PersonAPIClient userClient; private static PersonAPIClient adminClient; @BeforeAll public static void initialize() { container = new AppContainer(); container.startAndWait(); // Сведения о порте для конечной точки localhost можно получить // посредством container.getFirstMappedPort() RetrofitClientFactory retrofitClientFactory = new RetrofitClientFactory(container.getFirstMappedPort()); userClient = retrofitClientFactory.authenticatedClient("user","nosecret"); adminClient = retrofitClientFactory.authenticatedClient("admin", "secret"); } @AfterAll public static void shutdown() { if (container != null && container.isRunning()) container.stop(); } [ ... ] }
AppContainer — это реализация GenericContainer во фреймворке TestContainer. AppContainer запускает контейнеризованный REST-сервер, контейнер которого был собран и отправлен в локальный репозиторий при сборке проекта service.
public class AppContainer extends GenericContainer<AppContainer> { public AppContainer() { // Докеризованное springboot-приложение работает на порте 8080, это - единственный порт, который образ выводит во внешний мир super(DockerImageName.parse("spring-retrofit-test-server:LATEST")); withExposedPorts(8080); } protected void startAndWait(){ this.start(); // Порт контейнера 8080 назначен свободному порту, сведения о котором получены с помощью getFirstMappedPort() // он заблокирован до того момента, когда можно будет работать с api/person. this.waitingFor(new HttpWaitStrategy() .forPath("api/person/") .forPort(getFirstMappedPort())); } }
Создание REST-клиента с помощью Retrofit
Экземпляр Retrofit представляет собой фабрику, которая создаёт REST-клиенты на основе интерфейсов. Её нужно настроить с использованием паттерна Builder. Соответствующему механизму нужно передать, как минимум, базовый URL сервиса. Мы, в роли JSON-конвертера, используем библиотеку Google GSON. Ещё нам надо настроить библиотеку OkHttpClient на использование реализованной в проекте схемы Basic Authentication. Это позволит нам протестировать сервис на предмет соблюдения ограничений доступа для ролей user и admin. Мы можем инициализировать различные клиенты для проверки различных ролей. Порт, входящий в URL, известен только после того, как будет запущен контейнер с сервисом, поэтому его мы не можем жёстко задать в коде.
Мы, для создания прокси-объекта, рассчитанного на работу с конечной точкой сервиса, используем Retrofit.create (PersonApiClient в проекте api). Удобно, хотя и необязательно, иметь по одному интерфейсу для каждого класса контроллера.
public class RetrofitClientFactory { private final int port; PersonAPIClient authenticatedClient(String username, String password) { OkHttpClient okHttpClient = new OkHttpClient.Builder().authenticator( (route, response) -> response.request().newBuilder().header("Authorization", Credentials.basic(username, password)) .build()).build(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(String.format("http://localhost:%d/", port)). addConverterFactory(GsonConverterFactory.create()) .client(okHttpClient) .build(); return retrofit.create(PersonAPIClient.class); } }
Использование клиента, созданного с помощью Retrofit
Вот код тестов:
@Test public void EntityLifeCycleHappyFlow() throws IOException { executeCall(adminClient.createPerson(Person.builder().id("42").name("Jane").build())); executeCall(adminClient.createPerson(Person.builder().id("43").name("Jack").build())); assertThat(executeCall(userClient.getPersonById("42", null)).body().getName()).isEqualTo("Jane"); assertThat(executeCall(userClient.getPersonById("43", null)).body().getName()).isEqualTo("Jack"); Response<List<Person>> response = executeCall(userClient.getAll(null)); assertThat(response.body()).hasSize(2); executeCall(adminClient.upsertPerson(Person.builder().id("42").name("Jane").address("London").build())); Person jane = executeCall(userClient.getPersonById("42", "address,dateofBirth")).body(); assertThat(jane.getAddress()).isEqualTo("London"); executeCall(adminClient.deletePerson("42")); assertThat(userClient.getAll(null).execute().body()).hasSize(1); } private <T> Response<T> executeCall(Call<T> call) throws IOException { Response<T> response = call.execute(); if (!response.isSuccessful()) { fail("response returned " + response.errorBody().string()); } return response; }
Тут можно видеть действия клиента, созданного с помощью Retrofit. Программирование с использованием интерфейса позволяет писать чистый, компактный и типобезопасный код. Не будем забывать о том, что каждый метод возвращает объект Call. Для того чтобы получить Response — нужно выполнить метод execute() этого объекта. Метод executeCall() — это удобный механизм, который позволяет предотвратить появление исключений RuntimeException, возникающих в том случае, если выполнить вызов не удалось.
Кстати говоря, это упрощает и облегчает тестирование неправильных путей. Объект Response даёт нам все необходимые данные.
// Пользователю с такой ролью не разрешено выполнение запросов POST assertThat(userClient.createPerson(Person.builder().id("42").name("Jane").build()).execute().code()).isEqualTo(403); // Уже имеется пользователь с id 42. Response<Void> personExists = adminClient.createPerson(Person.builder().id("42").name("Jane").build()).execute(); assertThat(personExists.code()).isEqualTo(400); assertThat(personExists.errorBody().string()).isEqualTo("Person with ID already exists: 42"); // Имя пользователя не может быть пустым Response<Void> incompletePost = adminClient.createPerson(Person.builder().id("44").name(null).build()).execute(); assertThat(incompletePost.code()).isEqualTo(400); assertThat(incompletePost.errorBody().string()).isEqualTo("Person name cannot be null");
Итоги
Надеюсь, что вам понравилась эта статья, и что вы нашли в ней что-то полезное. Подробнее о Retrofit можно узнать из документации к этому фреймворку. Там есть много такого, о чём я тут не рассказывал. В частности, вызовы можно выполнять в асинхронном режиме, используя коллбэки, а не пользоваться применённым здесь подходом, когда выполнение кода блокируется в ожидании ответа. Возможности Retrofit не ограничены созданием интеграционных тестов. Этот фреймворк можно использовать в продакшн-коде, в котором ведётся работа с различными сервисами.
В целом могу сказать, что фреймворк Retrofit, при подготовке с его помощью интеграционных тестов, показался мне понятным и приятным инструментом. Он гораздо дружелюбнее относится к программистам, чем, например, его соперник REST Assured.
Пользуетесь ли вы Retrofit?

ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/562730/


Добавить комментарий