Версионирование эндпоинтов — это просто

от автора

Команда Spring АйО перевела и адаптировала доклад «Endpoint versioning made simple» Бауке Найхаус (Bouke Nijhuis) с последнего Devoxx Belgium. 

В докладе автор объясняет, зачем нужно версионировать API, и подробно сравнивает различные подходы к реализации этой задачи.


Что такое версионирование эндпоинтов?

Начать следует с объяснения того, что такое версионирование эндпоинтов. Эндпоинты могут иметь несколько версий. Другими словами, взаимодействуя с сервисом, надо указывать версию эндпоинта при каждом его вызове. Из многочисленных способов сделать это мы приведем лишь несколько примеров, отражающих наиболее распространенные варианты. Вы можете поставить версию в середине между API и ресурсом:

api/v1/resource

Вы также можете указать ее в конце как параметр запроса:

/api/resource?version=1

Другой вопрос, требующий прояснения — это зачем нам вообще нужно версионирование эндпоинтов. И ответ на него следующий: версионирование эндпоинтов обеспечивает дополнительную гибкость в контракте между фронтендом и бэкендом или потребителями и производителями (consumers/producers). Наилучший способ объяснить, почему оно нам необходимо — это объяснить, что случится, если у нас его не будет. 

Без версионирования эндпоинтов выпуск релизов становится опасным. Если вы не используете версионирование эндпоинтов, и у вас присутствуют фронтенд и бэкенд, и в программном продукте произошли радикальные изменения, относящиеся к контракту между этими двумя компонентами, вы должны запустить их точно в один и тот же момент времени. А это довольно сложно. В случае неудачного деплоймента одного из компонентов отсутствие версионирования эндпоинтов может привести к даунтайму всей системы, который продлится от нескольких минут до нескольких часов.

Поэтому мы делаем версионирование эндпоинтов.

Оно позволяет использовать так называемую “разницу на шаг” (pace difference). Это означает, что можно подготовить бэкенд, добавить новые версии к бэкенду и затем поднять этот новый бэкенд, и он будет содержать две версии: старую и новую; при этом фронтэнд все еще будет общаться со старой версией эндпоинта. В более поздний момент времени произойдет релиз нового фронтенда, и он будет общаться с новой версией эндпоинта на бэкенде. Это положит конец необходимости релизить оба компонента в одно и то же время.

Версионирование эндпоинтов также экономит множество шагов и спасает нас от многочисленных рисков. Однако оно создает и некоторые дополнительные сложности, о чем мы поговорим далее.

В чем основная сложность традиционного подхода?

Давайте посмотрим на то, что происходит при разработке нового проекта, который  использует версионированные эндпоинты.

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

У нас есть три ресурса: Customer (клиент), Product (товар) и Order (заказ).  На начальной стадии развития проекта у всех ресурсов есть по одноиу эндпоинту, и, соответственно, версия этого  эндпоинта — v1, как показано на рисунке.  Чтобы создать один order, необходимы customer и product, и в скобках написано (c1, p1), что означает, что вам нужен customer версии v1 и product версии v1, чтобы создать order версии v1. Пока все просто. Но если добавить новые версии, это будет выглядеть вот так: 

Новые версии обозначены розовым цветом, и справа под orders вы видите, что вам уже нужны orders версии v2, которые требуют customers версии v2, и затем v3 уже нуждается в двух видах входящей информации, и с добавлением большего количества версий ситуация все больше усложняется. И будет еще хуже, если добавить еще один ресурс. 

После долгих раздумий о возможных решениях данной проблемы родилась идея глобального версионирования. О ней и поговорим в данной статье далее. 

Традиционный подход к версионированию предполагает, что существует номер версии для каждого ресурса и каждого метода. Например, если вы хотите применить метод GET к ресурсу Car, вы используете версию 5, если вы хотите применить метод PUT к ресурсу Car, вам нужна версия 8, а если вы хотите применить метод POST к ресурсу Car, вы используете версию 11. При глобальном версионировании предполагается,что один номер версии будет применяться ко всему API.

Итак, каждый раз, когда вы меняете что-то в контракте где-то в вашем API, вы можете увеличить номер версии на 1. Возможно, это прозвучит как реально странная идея, поэтому мы попробуем сделать ее более понятной далее. 

Требования к предлагаемому решению

Помимо самой идеи глобального версионирования, автор доклада также формулирует несколько обязательных требований к ее практической реализации. Соответственно, описывая предложенные опции по реализации глобального версионирования. Мы каждый раз будем проверять их на выполнение приведенных требований. Естественно, в идеале все требования должны выполняться.   

  • Итак, первое требование состоит в том, что потребители должны иметь возможность использовать один и тот же номер версии для всех эндпоинтов, именно в этом состоит вся идея глобального версионирования. Заметьте, что используется формулировка “иметь возможность”, но при этом подчеркивается, что потребитель не обязан так делать. Вы все еще можете использовать специфические эндпоинты для метода со специфической версией, например, если там есть баг или у вас есть какая-то другая реальная причина. Однако, без достаточно веской причины так делать не рекомендуется, так как это подрывает базовую идею глобального версионирования.

  • Во-вторых, Минимум дополнительного кода. Конечно, добавление новой функциональности потребует несколько лишних строк кода, но важно стремиться к тому, чтобы их было как можно меньше.

  • И в-третьих, добавление любого ресурса, версии или метода должно минимально влиять на остальную кодовую базу, за пределами новой функциональности. Добавление новой функциональности неизбежно влечёт за собой написание нового кода. Однако важно, чтобы это минимально влияло на существующую кодовую базу. В идеале изменения не должны затрагивать другие части системы, но на практике добиться полного отсутствия влияния практически невозможно.

Отправная точка

Для первой практической демонстрации было использовано базовое приложение на Spring Boot, со следующим контроллером:

Примечание: код, используемый в данной статье, можно найти у автора доклада на GitHub.

package io.github.boukenijhuis.dynamicversionurl;  import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;  @RestController public class MyController {     @GetMapping("/v1/a")    public String a1() {        return "a1";    }     @GetMapping("/v1/b")    public String b1() {        return "b1";    } }

Что мы здесь видим? В приложении присутствуют два эндпоинта, и они оба используют @GetMapping. Есть ресурс a, который находится в версии 1, и он всегда возвращает a1.  Есть ресурс b1, аналогичный a1.

Каждый раз при добавлении эндпоинтов к коду будет меняться эндпоинт a, так что это будет a версии 2, который возвращает a2, затем это будет эндпоинт версии 3, который возвращает a3. Однако, мы используем глобальное версионирование. Поэтому каждый раз, когда что-то добавляется к ресурсу a, номер версии ресурса b тоже должен увеличиваться. Поэтому тут вы тоже увидите эндпоинты версии 2 и 3 для ресурса, но они будут всегда возвращать b1.

Если запустить тесты, проверяющие корректную работу эндпоинтов, то тесты для a1 и b1 пройдут, что логично, а вот тесты для a2 и b2 не пройдут, потому что эти эндпоинты пока не реализованы.

package io.github.boukenijhuis.dynamicversionurl;  import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;  @WebMvcTest(MyController.class) class MyControllerTest {     @Autowired    MockMvc mockMvc;     @Test    void a1() throws Exception {        testEndpoint("/v1/a", "a1");    }     @Test    void b1() throws Exception {        testEndpoint("/v1/b", "b1");    }     @Test    void a2() throws Exception {        testEndpoint("/v2/a", "a2");    }     @Test    void b2() throws Exception {        testEndpoint("/v2/b", "b1");    }     private void testEndpoint(String path, String response) throws Exception {        mockMvc.perform(get(path))                .andExpect(status().isOk())                .andExpect(content().string(response))                .andReturn();    } }

Теперь добавим эндпоинт второй версии. 

package io.github.boukenijhuis.dynamicversionurl;  import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;  @RestController public class MyController {     @GetMapping("/v1/a")    public String a1() {        return "a1";    }     @GetMapping("/v2/a")    public String a2() {        return "a2";    }     @GetMapping("/v1/b")    public String b1() {        return "b1";    } }

Итак, у нас теперь есть эндпоинт версии v2 для ресурса a. На этом этапе пройдут три теста, и не пройдет только тест для b2, опять таки потому что этот эндпоинт все еще не реализован.

Но мы уже можем проверить, насколько данное решение соответствует предъявляемым требованиям.

  • Единая версия для всех эндпоинтов. Сейчас это условие не выполняется. Запрос к эндпоинту v2 для ресурса b не работает, что мы видели в тестах.

  • Минимум дополнительного кода — Для реализации механизма не потребовалось писать дополнительный код. Мы еще не добавили сам механизм, поэтому этот пункт зеленый.

  • Минимальное влияние на кодовую базу. Добавление нового ресурса, версии или метода не должно сильно влиять на кодовую базу. В данном случае был добавлен метод a2 для реализации эндпоинта, но другие части кода это не затронуло. Следовательно, этот пункт тоже зеленый. 

Требования к решению

Единая версия для всех эндпоинтов.

🔴

Минимум дополнительного кода.

🟢

Минимальное влияние на кодовую базу.

🟢

На первый взгляд, получить два зеленых кружка и один красный  — это очень хорошо, но, естественно, самый важный пункт для нас — первый. Мы хотим использовать один номер версии для всех эндпоинтов, так что нам надо еще поработать над нашим кодом.

Давайте перейдем к первому возможному решению, которое приходит в голову, самому простому и очевидному. Мы вводим специальные переменные для обозначения пути к эндпоинту и оператор switch:

@RestController public class MyController {    @GetMapping("/v{version}/a")   public String a (@PathVariable("version") int version){      return switch (version) {          case 1 -> a1();          default -> a2();      };   }     public String a1() {        return "a1";    }     public String a2() {        return "a2";    } }

Итак, каждый раз, когда вы просите версию 1 ресурса a, вы получаете a1, и каждый раз, когда вы просите что-то другое, например, v2, v3, вы получаете a2. Это не совсем правильно, но в дальнейшем будут показаны более удачные решения. Нам также больше не нужен вот этот фрагмент кода: 

Что-то похожее мы можем сделать и для эндпоинтов b

@GetMapping("/v{version}/b") public String b(@PathVariable("version") int version) {    return switch (version) {        default -> b1();    }; }  public String b1() {    return "b1"; }

Здесь только b1, поэтому мы делаем его default. Если мы сейчас прогоним тесты, то все тесты станут зелеными. 

Что это означает? Мы можем вызывать версию 1 от a, получать a1. Мы можем вызывать  версию 2 от a, она возвращает a2, мы можем вызывать версии 1 и 2 от b, и они обе вернут b1

Итак, давайте проверим это решение на соответствие вышеупомянутым требованиям. 

  • Единая версия для всех эндпоинтов. Пользователи могут использовать одну версию для всех эндпоинтов, и это действительно работает. При тестировании версии v2 для эндпоинтов a и b, оба корректно функционировали. Фактически, решение поддерживает любые номера версий: 3, 4, 5 и так далее.

  • Минимум дополнительного кода. Оценим объем изменений. Для реализации механизма пришлось добавить два новых метода с операторами switch. Это ощутимое усложнение, поэтому вместо «зеленого» кружка, ставим «красный».

  • Минимальное влияние на кодовую базу. Теперь проверим, как добавление новой версии повлияет на систему. Попробуем добавить версию a3 и оценим результат. Если вы вводите новую функциональность, вам неизбежно потребуется создать этот новый метод, но теперь добавляется дополнительная работа. Надо переделать default на case 2 и добавить default для a3:

@GetMapping("/v{version}/a") public String a(@PathVariable("version") int version) {    return switch (version) {        case 1 -> a1();        case 2 -> a2();        default -> a3();    }; }

На этом этапе надо подключить тесты для v3 a и v3 b, и прогнать все шесть тестов.

package io.github.boukenijhuis.dynamicversionurl;  import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;  @WebMvcTest(MyController.class) class MyControllerTest {     @Autowired    MockMvc mockMvc;     @Test    void a1() throws Exception {        testEndpoint("/v1/a", "a1");    }     @Test    void b1() throws Exception {        testEndpoint("/v1/b", "b1");    }     @Test    void a2() throws Exception {        testEndpoint("/v2/a", "a2");    }     @Test    void b2() throws Exception {        testEndpoint("/v2/b", "b1");    }     @Test    void a3() throws Exception {        testEndpoint("/v3/a", "a3");    }     @Test    void b3() throws Exception {        testEndpoint("/v3/b", "b1");    }     private void testEndpoint(String path, String response) throws Exception {        mockMvc.perform(get(path))                .andExpect(status().isOk())                .andExpect(content().string(response))                .andReturn();    } }

Все шесть тестов будут зелеными.

Итак, мы говорим о пункте три, “Минимальное влияние на кодовую базу”. Это вроде бы верно, поскольку b-эндпоинт продолжил работать, и мы не трогали  b-эндпоинт, просто назвали его v3, потому что у нас есть switch с default для него. Будем считать, что этот пункт требований выполнен частично, и поставим напротив него оранжевый кружок.  

Требования к решению

Единая версия для всех эндпоинтов.

🔴➡️🟢

Минимум дополнительного кода.

🟢➡️🔴

Минимальное влияние на кодовую базу.

🟢➡️🟠

Итак, один зеленый, один красный, один оранжевый. Это более-менее нормально, но можно сделать намного лучше. 

Давайте перейдем к лучшему решению.

Лучшее решение

И лучшим решением будет ввести библиотеку. Бауке Найхаус, автор идеи глобального версионирования, предлагает решать эту проблему, используя библиотеку, которую он создал в процессе работы над максимально элегантным решением, при котором все три кружочка стали бы зелеными. Давайте посмотрим на пример того, как ее использование может улучшить положение вещей. 

Вернемся к первоначальной версии кода, в которой присутствовали только v1 a и v1 b

package nl.boukenijhuis.endpointversioning;  import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;  @RestController  public class Controller {   @GetMapping("/v1/a")   public String a1() {     return "al";   }      @GetMapping("/v1/b")    public String b1() {     return "b1";   } }

Итак, библиотека. Давайте перейдем к файлу pom.xml и добавим такую зависимость:

<dependency> <groupId>io.github.boukenijhuis</groupId> <artifactId>dynamic-version-url</artifactId> <version>0.0.9</version> </dependency>

Добавление библиотеки дает нам доступ к новой аннотации под названием @GetVersionMapping.

package nl.boukenijhuis.endpointversioning;  import io.github.boukenijhuis.dynamicversionurl.GetVersionMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;  @RestController  public class Controller {      @GetVersionMapping("/v1/a")   public String a1() {     return "al";   }      @GetMapping("/v1/b")    public String b1() {     return "b1";   } }

Эта аннотация предназначена для метода GET, но есть аналогичные аннотации для всех других методов. Благодаря этой аннотации вы можете отделить вашу информацию о версионировании. Вы можете написать:

@GetVersionMapping(value ="/a", version = 1)

Если вы сделаете это, вам больше не нужен кусок /v1 в строке значения пути к эндпоинту. Давайте сделаем то же самое для эндпоинта b.  По итогу должен получиться следующий код:

package nl.boukenijhuis.endpointversioning;  import io.github.boukenijhuis.dynamicversionurl.GetVersionMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;  @RestController  public class Controller {      @GetVersionMapping(value ="/a", version = 1)   public String a1() {     return "al";   }      @GetVersionMapping(value ="/b", version = 1)   public String b1() {     return "b1";   } }

Если прогнать тесты сейчас, они упадут, потому что появилась новая аннотация, но нам нужен кусочек кода, который что-то делает с этими аннотациями.

Этот код будет представлять собой Spring Boot конфигурацию, и мы поместим ее в новый класс под названием WebMvcConfig.

Требуемый код выглядит следующим образом:

package nl.boukenijhuis.endpointversioning;  import io.github.boukenijhuis.dynamicversionurl.ApiVersionRequestMappingHandLerMapping; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;  @Configuration public class WebMvcConfig extends DelegatingWebMvcConfiguration {      @0verride    public RequestMappingHandlerMapping createRequestMappingHandlerMapping() {     return new ApiVersionRequestMappingHandlerMapping("");   } }

Заметьте, что здесь создается новый класс: ApiVersionRequestMappingHandlerMapping. Какие действия здесь выполняет Spring Boot? Каждый раз, когда приходит входящий запрос, он пытается мапить его на метод. Приведенный выше код делает надстройку над этими мапингами. Заметьте, что вы можете ввести префикс, в данном случае мы используем префикс “v”. Если теперь запустить тесты, то два из четырех пройдут. 

Эндпоинты a1 и b1 работают. Других у нас пока нет.

Чтобы ввести новые эндпоинты, добавляем код для a2. Получаем:

@RestController  public class Controller {      @GetVersionMapping(value ="/a", version = 1)   public String a1() {     return "al";   }      @GetVersionMapping(value ="/a", version = 2)   public String a2() {     return "a2";   }      @GetVersionMapping(value ="/b", version = 1)   public String b1() {     return "b1";   } }

На этой стадии три из четырех тестов пройдут. Нам все еще надо реализовать версию 2 для эндпоинта b. Самое приятное здесь то, что можно использовать диапазоны, вот так: 

@GetVersionMapping(value ="/b", version = {1, 2}) public String b1() {   return "b1"; }

И теперь, если мы прогоним тесты, мы получим четыре зеленых галочки. 

Давайте вернемся обратно к требованиям. 

  • Единая версия для всех эндпоинтов. Все тесты прошли.  

  • Минимум дополнительного кода. Мы поменяли одну аннотацию на другую: @GetMapping стало @GetVersionMapping; мы также разделили информацию об имени ресурса и версии, это довольно небольшие изменения, но самым большим изменением было добавление нового класса WebMvcConfig. Впрочем, это делается только один раз. То есть, если вы однажды добавили его в проект, вам больше не надо думать об этом, следовательно, здесь можно поставить зеленый кружок.  

  • Добавление нового ресурса, версии или метода должно оказывать минимальное влияние на остальную кодовую базу. Например, мы добавили метод a2 — это неизбежно, ведь внедряется новая функциональность. Однако пришлось внести изменения и в метод b, заменив версию 1 на {1, 2}. На первый взгляд, работа кажется небольшой, но представьте, что у нас 26 ресурсов — тогда изменения понадобились бы в 25 других эндпоинтах. Такой объем работы становится значительным, что подчеркивает важность минимизации влияния. Поэтому мы всё ещё на «оранжевом уровне».

Требования к решению

Единая версия для всех эндпоинтов.

🟢

Минимум дополнительного кода.

🔴➡️🟢

Минимальное влияние на кодовую базу.

🟠

Давайте посмотрим, можно ли исправить еще и это.

Самое лучшее решение

Итак, мы переходим к самому лучшему решению, добавить константу к тому, что у нас уже есть. Добавим константу LATEST_VERSION:

private static final int LATEST_VERSION = 2

Теперь вставим ее в код мапинга для эндпоинта b

@GetVersionMapping(value ="/b", version = {1, LATEST_VERSION}) public String b1() {   return "b1"; }

Все четыре теста проходят. 

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

Добавим a3

@RestController  public class Controller {   private static final int LATEST_VERSION = 3        @GetVersionMapping(value ="/a", version = 1)   public String a1() {     return "al";   }      @GetVersionMapping(value ="/a", version = 2)   public String a2() {     return "a2";   }      @GetVersionMapping(value ="/a", version = LATEST_VERSION)   public String a3() {     return "a3";   }      @GetVersionMapping(value ="/b", version = {1, LATEST_VERSION})   public String b1() {     return "b1";   } }

LATEST_VERSION, по понятным причинам, поменялась на 3. Итак, мы добавили метод a3() и обновили переменную LATEST_VERSION.

Давайте перейдем к тестам. Надо открыть и прогнать все шесть. Если все сделано правильно, все шесть галочек будут зелеными. 

Так и есть.

Перейдем к проверке требований.

  • Единая версия для всех эндпоинтов. Этот подход подтвердился на последних двух демонстрациях: версии 1, 2 и 3 успешно использовались для ресурсов a и b. Требование считается выполненным — зеленый статус.

  • Минимум дополнительного кода. Этот пункт, как и ранее, полностью соответствует требованиям. Зеленый статус без изменений.

  • Минимальное влияние на кодовую базу. Например, для ресурса b при добавлении метода a3 единственным изменением стало увеличение значения константы LATEST_VERSION. Это изменение можно считать минимальным, что подтверждает зеленый статус и здесь.

Требования к решению

Единая версия для всех эндпоинтов.

🟢

Минимум дополнительного кода.

🟢

Минимальное влияние на кодовую базу.

🟠➡️🟢

Одно дополнительное преимущество

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

Возьмем следующий пример:

/api/v1/resource

Эта строка состоит из трех частей:

  • первая часть — это префикс (/api), который указывает на общий путь к API.

  • вторая часть (v1) обозначает версию API

  • третья часть (resource) — это имя конкретного ресурса, к которому осуществляется доступ.

В Spring Boot вы можете отделить префикс. Вы можете поместить его на более высокий уровень. Но вы не можете отделить информацию по версии и имени ресурса в таком типе URL. И это кажется странным, не так ли? Это все равно как если бы вы поместили название улицы и номер дома в одно и то же поле. Мы все знаем, что это неправильно. Это разная информация, и она должна храниться в разных переменных. Но это невозможно в Spring Boot, он не позволяет разделять информацию по версии и ресурсу, по крайней мере, когда вы используете этот тип системы версионирования. 

Библиотека делает такое разделение возможным. И, разделяя эти два типа информации, мы также можем изменить тип данных. То есть теперь имя ресурса все еще строка, но версия становится целым числом (integer). И тот факт, что версия стала integer, дает нам возможность использовать диапазоны версий. Теперь мы можем сказать, “дай мне версии от 1 до 100”, надо задать только два числа, и все, что между ними, будет заполнено автоматически. И эти диапазоны версий позволили с каждой созданной новой версией проделывать лишь очень малое количество дополнительной работы, чтобы все заработало для всех остальных эндпоинтов. Здорово, правда?

Вывод

Версионирование эндпоинтов может быть простым, когда вы используете глобальный номер версии. Решение, приведенное в конце статьи, весьма простое, и теперь потребителям проще отслеживать ваше версионирование URL. Им надо использовать только одно число. Все, что имеет этот номер версии, должно быть совместимо и работать одно с другим.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!


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


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *