[Перевод] Retrofit: удобные разработка и тестирование API
Если вы занимались крупными 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 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> getAll(@Query("fields") String fields);
@GET("api/person/{id}")
Call getPersonById(@Path("id") String id, @Query("fields") String fields);
@POST("api/person")
Call createPerson(@Body Person person);
@PUT("api/person")
Call upsertPerson(@Body Person person);
@DELETE("api/person/{id}")
Call 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 {
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> 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 Response executeCall(Call call) throws IOException {
Response 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 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 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?