Как-то в общении с моим другом-разработчиком из одной крупной софтверной компании у нас зашёл разговор о взаимодействии распределённых команд. В его компании было множество достаточно изолированных команд, каждая из которых разрабатывала свой сервис. В ответ на мой вопрос, как команды расшаривают API, я получил ожидаемый ответ: Open API. Open API, безусловно, прекрасный инструмент, но у него есть ряд недостатков.
Меня зовут Андрей Зяблин, я главный разработчик в «Магните». Расскажу о том, как распространять API нативным для Java способом и пользоваться им в объектно-ориентированном стиле без использования генераторов кода.
Немного теории
Классическая ситуация: одна команда разрабатывает REST API (поставщик), другая его потребляет (потребитель). Чтобы распространять API, и был придуман стандарт Open API, также известный как Swagger. Он представляет собой стандарт описания и документации для RESTful — набора правил, который позволяет разработчикам описывать и документировать функциональность своего API.
OpenAPI-спецификация представлена в формате JSON или YAML и содержит подробное описание методов, параметров, структуры данных и другой информации, необходимой для взаимодействия с API.
OpenAPI — замечательный инструмент. Он позволяет распространять API между разработчиками, пишущими на различных языках и работающих в разных компаниях. Однако такое нужно далеко не всегда. Когда все команды разрабатывают на Java, возникает вопрос: так ли уж необходимо промежуточное звено в виде OpenAPI? Нельзя ли распространять API более нативным для Java способом и пользоваться им в объектно-ориентированном стиле без использования генераторов кода?
Да, существуют современные технологии, которые позволяют это сделать. Ниже я продемонстрирую подход для команд, разрабатывающих REST-сервисы на Java и Spring — это очень распространённый стек.
Как работает подход: рассмотрим на примере
Для демонстрации подхода воспроизведу ситуацию, описанную выше: когда есть поставщик API и его потребитель. Для этого потребуются два приложения:
поставщик API – producer,
потребитель API – consumer.
Producer будет распространять API через maven-репозитарий. Такой подход позволяет оперативно публиковать изменения, а также оперативно потреблять их без генерации кода.
Подход подразумевает классическую структуру API, состоящую из двух основных частей: POJO-модели и интерфейсов, предоставляющих методы работы с этими моделями.
Распространяем модели — producer
Создадим многомодульный проект со следующими настройками для простоты.
allprojects {
group = 'com.example.producer'
version = '0.0.1'
sourceCompatibility = '11'
repositories {
mavenCentral()
mavenLocal()
}
}
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
}
Добавим модуль model, который будет содержать модели. Настроим его для публикации в локальный maven-репозитарий с использованием плагина maven-publish.
apply plugin: 'maven-publish'
jar {
enabled=true
archiveClassifier.set("")
}
bootJar {
enabled = false
}
publishing {
publications {
maven(MavenPublication) {
artifact jar
}
}
repositories {
mavenLocal()
}
}
Сама модель:
package com.example.producer.model;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Builder
public class Employee {
private Long id;
private String name;
}
Теперь опубликуем модель:
gradle clean build publish
Потребляем модели — consumer
Создадим проект consumer со следующими зависимостями.
ext {
set('producerVersion', "0.0.1")
}
dependencies {
implementation "com.example.producer:model:${producerVersion}"
}
В принципе, на этом шаге модели из producer уже можно использовать, причём без всякой генерации кода.
Ну а где же API?
Конечно, распространение моделей — это хорошо, но это вещь достаточно тривиальная и древняя. А где же интерфейсы, где обещанный REST, спросите вы. Сейчас всё будет. Но для начала немного теории про технологии, которые позволяют это сделать.
Spring Cloud OpenFeign
Spring Cloud OpenFeign (далее Feign) — декларативный клиент REST для приложений Spring Boot. Он позволяет создавать веб-сервисы без написания какого-либо кода, кроме создания интерфейса. Этой его особенностью мы и воспользуется, чтобы делиться API с окружающими. Одна из замечательных возможностей Feign — поддержка аннотаций Spring MVC.
Создание REST-сервиса
Для этого воспользуемся ещё одной замечательной возможностью Spring – обнаруживать аннотации контроллеров в интерфейсах.
Модуль Common API
Модуль будет содержать интерфейс, в котором мы определим не только сигнатуры методов, но и типы веб-запросов. По сути этот интерфейс представляет собой REST-API. Почему это вынесено в отдельный модуль, станет понятно позднее.
dependencies {
implementation project(":model")
}
package com.example.producer.common.api;
import com.example.producer.model.Employee;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
public interface EmployeeApi {
String path = "/employee";
@GetMapping("findById/{id}")
Employee findById(@PathVariable Long id);
@GetMapping("findByName/{name}")
Employee findByName(@PathVariable String name);
@GetMapping("findAll")
List<Employee> findAll();
}
Модуль API
Модуль представляет собой Spring Boot приложение, содержащее реализацию интерфейса EmployeeAPI.
dependencies {
implementation project(":model")
implementation project(":common-api")
}
package com.example.producer.controller;
import com.example.producer.common.api.EmployeeApi;
import com.example.producer.model.Employee;
import java.util.List;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(EmployeeApi.path)
public class EmployeeController implements EmployeeApi {
@Override
public Employee findById(Long id) {
return Employee.builder().id(id).name("test1").build();
}
@Override
public Employee findByName(String name) {
return Employee.builder().id(1L).name(name).build();
}
@Override
public List<Employee> findAll() {
return List.of(Employee.builder().id(1L).name("test3").build());
}
}
Распространяем интерфейс
Теперь нужно опубликовать наш интерфейс в maven-репозитарии. Отсюда понятно, почему это вынесено в отдельный модуль. Добавим в Common API следующие настройки:
apply plugin: 'maven-publish'
jar {
enabled=true
archiveClassifier.set("")
}
bootJar {
enabled = false
}
publishing {
publications {
maven(MavenPublication) {
artifact jar
}
}
repositories {
mavenLocal()
}
}
И заново публикуем проект, увеличив версию.
allprojects {
group = 'com.example.producer'
version = '0.0.2'
gradle clean build publish
Перенесём приложение на другой порт.
server:
port: 8096
Полная структура приложения producer выглядит так:
Потребляем интерфейс — consumer
Сделаем из consumer Spring Boot приложение.
plugins {
id 'org.springframework.boot' version '2.6.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenLocal()
mavenCentral()
}
ext {
set('springCloudVersion', "2021.0.0")
set('producerVersion', "0.0.2")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'io.github.openfeign:feign-jackson:9.3.1'
implementation "com.example.producer:model:${producerVersion}"
implementation "com.example.producer:common-api:${producerVersion}"
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
package com.example.consumer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(ConsumerApplication.class, args);
}
}
Далее настроим Feign.
@Configuration
@RequiredArgsConstructor
public class EmployeeConfig {
@Value("${producer.api.url}")
private String baseUrl;
private final ObjectMapper mapper;
@Bean
public EmployeeApi employeeApi() {
return Feign.builder()
.contract(new SpringMvcContract())
.encoder(new JacksonEncoder(mapper))
.decoder(new JacksonDecoder(mapper))
.target(EmployeeApi.class, baseUrl.concat(EmployeeApi .path));
}
}
Cоздадим контроллер для демонстрации использования producer API.
@RestController
@RequestMapping("employee")
@RequiredArgsConstructor
public class EmployeeController {
private final EmployeeApi employeeApi;
@GetMapping("findById/{id}")
public Employee findById(@PathVariable Long id) {
return employeeApi.findById(id);
}
@GetMapping("findAll")
public List<Employee> findAll() {
return employeeApi.findAll();
}
}
В коде видна вся магия такого подхода. Мы общаемся с REST-сервис, просто вызывая методы объекта, как в классическом ООП.
Настроим producer API URL.
producer:
api:
url: http://localhost:8096
Ну и перенесём приложение на другой порт.
server:
port: 8097
Проверка результатов
Запускаем оба приложения и наслаждаемся результатом. Ниже приведён результат вызова метода findAll приложения consumer.
А что с документацией?
С документацией проблема решается при помощи старого доброго javadoc. Настроим проект producer для генерации документации. Для удобства настройки плагина публикаций перенесём в отдельный файл в корне проекта — publish.gradle.
apply plugin: 'maven-publish'
jar {
enabled=true
archiveClassifier.set("")
}
bootJar {
enabled = false
}
task packageJavadoc(type: Jar) {
from javadoc
classifier = 'javadoc'
}
publishing {
publications {
maven(MavenPublication) {
artifact jar
artifact tasks.packageJavadoc
}
}
repositories {
mavenLocal()
}
}
Здесь добавилась новая задача.
task packageJavadoc(type: Jar) {
from javadoc
classifier = 'javadoc'
}
Теперь publish.gradle можно использовать в публикуемых модулях:
apply from: '../publish.gradle'
Задокументируем интерфейс.
**
* It's a great interface
*/
public interface EmployeeApi {
Поднимем версию и опубликуем модули. Далее настроим проект consumer. Добавим плагин idea.
plugins {
id 'org.springframework.boot' version '2.6.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
id 'idea'
}
Настроим его.
idea {
module {
downloadJavadoc = true
}
}
Сделаем реимпорт проекта. Теперь можем наслаждаться чтением документации.
Безусловно, OpenAPI — очень мощный и удобный инструмент. Однако и описываемый подход имеет свои преимущества:
Нет необходимости кодогенерации: уходит зависимость от кодогенераторов.
API распротраняется в виде библиотек через репозитарии maven, поэтому для их подключения в клиенте не требуется дополнительных плагинов — это просто обычная зависимость maven.
Несколько проще поддерживать версионность по сравнению с Open API.
Комментарии (6)
leahch
28.09.2023 11:14+3Проблема в другом, что делать, если мне этот интерфейс нужно расшарить для других языков и программистов не java-вей?
aleksandy
28.09.2023 11:14Нет необходимости кодогенерации: уходит зависимость от кодогенераторов.
Кто заставляет их использовать? Пишите ручками. Тем более запускать кодогенерацию на каждый билд - это идиотизм.
API распротраняется в виде библиотек через репозитарии maven, поэтому для их подключения в клиенте не требуется дополнительных плагинов — это просто обычная зависимость maven.
При использовании моделей со стороны потребителя даже зависимости может не быть. Всё необходимое в рамках одного артефакта. Во-вторых, если мне из всего api на 100500 методов требуется только один, да и из него интересует лишь пара атрибутов, то написание своего класса с двумя полями предпочтительнее, т.к. не придётся грузить и, соответственно, держать в памяти ненужные классы.
Несколько проще поддерживать версионность по сравнению с Open API.
Ха-ха-ха.
zyablin_av Автор
28.09.2023 11:14Пишите ручками.
Статья как раз для тех, кто не хочет писать ручками, а хочет пользоваться плодами чужого труда. В программировании это благо)
запускать кодогенерацию на каждый билд
Как минимум, нужно запускать при изменении версии API. А если команд, поставляющих каждая свой API, штук десять-двадцать, то это не совсем удобно.
всего api на 100500 методов
Проектируйте API грамотно, и будет вам счастье)
держать в памяти ненужные классы.
Если таки прочитать всю статью, а не только выводы, то будет понятно, что библиотека API содержит только интерфейсы и потребителю совсем нет необходимости загружать все их реализации. Так что тут экономия на спичках. Но опять же, если команда-поставщик грамотно проектирует API.
Ха-ха-ха.
Что может быть проще для потребителя, чем поменять номер версии в gradle? При желании, чтобы получить актуальную версию API, можно даже это действие убрать.
sshmakov
Не знаток в Java, только подозреваю, что наверняка существует либа, которая по интерфейсу сформирует нотацию OpenApi, и на отдельном endpoint-е может показать ее в виде Swagger UI. Так сказать, завершить круг.
leahch
О да, существует, и не одна :)