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


  1. sshmakov
    28.09.2023 11:14
    +1

    Не знаток в Java, только подозреваю, что наверняка существует либа, которая по интерфейсу сформирует нотацию OpenApi, и на отдельном endpoint-е может показать ее в виде Swagger UI. Так сказать, завершить круг.


    1. leahch
      28.09.2023 11:14

      О да, существует, и не одна :)


  1. leahch
    28.09.2023 11:14
    +3

    Проблема в другом, что делать, если мне этот интерфейс нужно расшарить для других языков и программистов не java-вей?



  1. aleksandy
    28.09.2023 11:14

    Нет необходимости кодогенерации: уходит зависимость от кодогенераторов.

    Кто заставляет их использовать? Пишите ручками. Тем более запускать кодогенерацию на каждый билд - это идиотизм.

    API распротраняется в виде библиотек через репозитарии maven, поэтому для их подключения в клиенте не требуется дополнительных плагинов — это просто обычная зависимость maven.

    При использовании моделей со стороны потребителя даже зависимости может не быть. Всё необходимое в рамках одного артефакта. Во-вторых, если мне из всего api на 100500 методов требуется только один, да и из него интересует лишь пара атрибутов, то написание своего класса с двумя полями предпочтительнее, т.к. не придётся грузить и, соответственно, держать в памяти ненужные классы.

    Несколько проще поддерживать версионность по сравнению с Open API.

    Ха-ха-ха.


    1. zyablin_av Автор
      28.09.2023 11:14

      Пишите ручками.

      Статья как раз для тех, кто не хочет писать ручками, а хочет пользоваться плодами чужого труда. В программировании это благо)

      запускать кодогенерацию на каждый билд

      Как минимум, нужно запускать при изменении версии API. А если команд, поставляющих каждая свой API, штук десять-двадцать, то это не совсем удобно.

      всего api на 100500 методов

      Проектируйте API грамотно, и будет вам счастье)

      держать в памяти ненужные классы.

      Если таки прочитать всю статью, а не только выводы, то будет понятно, что библиотека API содержит только интерфейсы и потребителю совсем нет необходимости загружать все их реализации. Так что тут экономия на спичках. Но опять же, если команда-поставщик грамотно проектирует API.

      Ха-ха-ха.

      Что может быть проще для потребителя, чем поменять номер версии в gradle? При желании, чтобы получить актуальную версию API, можно даже это действие убрать.