Объектно-ориентированный подход к созданию REST-клиентов, или возможна ли жизнь без Open API

—

от автора

Как-то в общении с моим другом-разработчиком из одной крупной софтверной компании у нас зашёл разговор о взаимодействии распределённых команд. В его компании было множество достаточно изолированных команд, каждая из которых разрабатывала свой сервис. В ответ на мой вопрос, как команды расшаривают 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.  


ссылка на оригинал статьи https://habr.com/ru/articles/763952/