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

Ситуация

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

Само приложение состоит всего-лишь из одного домена - Пользователь, который в свою очередь состоит из числового идентификатора и номера телефона в виде строки.

Java

DTO:

@Getter
public class UserDto {
  
  private Long id;

  @JsonProperty("phone_number")
  private String phoneNumber;
}

Контроллер:

@RestController
public class UserController {

  @GetMapping("/api/users/{user_id}")
  public UserDto getUser(@RequestParam("user_id") Long userId) {

    // Проверка существования пользователя и получение из БД
    // Вызов логики обработки
    // Конвертация модели в DTO

    return userDto;
  }
}

HTTP
GET /api/users/{user_id}
Host: api.host
Accept: application/json

{
  id: 0,
  phone_number: '+70123456789'
}

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

Конечно же, если команда:

  • состоит из волшебниковможет развернуть серверное и клиентское приложение одновременно, ничего при этом не сломав в продакшене;

  • может себе позволить содержать сервера с разными версиями серверного приложения (api.host.v1, api.host.v2) и умеет их грамотно поддерживать.

В таком случае Вы постигли дзен и все у Вас хорошо)

Цель статьи - показать как можно разворачивать новые версии серверных приложений не затрагивая при этом клиентское приложение, чтобы оно смогло в достаточной мере отладить взаимодействие с новыми полями/типами данных прежде чем было опубликовано. Ну и не положить прод, конечно :)

Миграция через добавление нового поля

В этом случае просто в существующее DTO добавляется новое поле с постфиксом версии и старое помечается как устаревшее(в кодовой базе) для дальнейшего избавления от него. Старое поле продолжается поддерживаться до момента полного отказа в его использовании на стороне клиентского приложения.

Java
@Getter
public class UserDto {
    
  private Long id;

  @Deprecated
  @JsonProperty("phone_number")
  private String deprecatedPhoneNumber;

  @JsonProperty("phone_number_v1")
  private Long phoneNumber;
}

JSON
UserDto:
{
  id: 0,
  phone_number: '+70123456789',
  phone_number_v1: 70123456789
}

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

Миграция через создание новой версии объекта

В случае когда клиенты очень долго "переезжают" на новые версии приложений (типичный пример мобильной разработки, переезд на более новую версию может идти годами), то рано или поздно DTO превратиться в сущий ад если мигрировать данные через поле. Такой код будет сложно поддерживать, так как подобных миграций в период жизни приложения может быть достаточно много.

В данном случае можно выделить новый класс DTO.

Java
@Getter
@Deprecated
public class UserDto {
    
  private Long id;

  @JsonProperty("phone_number")
  private String phoneNumber;
}

@Getter
public class UserDtoV1 {
    
  private Long id;

  @JsonProperty("phone_number")
  private Long phoneNumber;
}

JSON
UserDto:
{
  id: 0,
  phone_number: '+70123456789'
}

UserDtoV1:
{
  id: 0,
  phone_number: 70123456789
}

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

Что насчет контроллеров?

В случае с контроллером есть тоже два варианта как можно реализовать версионность.

Так же есть хорошая статья которая описывает процессы версионирования конкретно API.

Миграция через новую версию HTTP метода

Java
@RestController
public class UserController {

  @Deprecated
  @GetMapping("/api/users/{user_id}")
  public UserDto depreactedGetUser(@RequestParam("user_id") Long userId) {}

  @GetMapping("/api/users/{user_id}/v1")
  public UserDtoV1 getUser(@RequestParam("user_id") Long userId) {}
}

HTTP
GET /api/users/{user_id}
Host: api.host
Accept: application/json

{
  id: 0,
  phone_number: '+70123456789'
}

------------------------------

GET /api/users/{user_id}/v1
Host: api.host
Accept: application/json

{
  id: 0,
  phone_number: 70123456789
}

Такой подход хорош при быстрой миграции, но поддерживать кучу таких методов может рано или поздно стать головной болью разработчика так как логика конвертации модели в DTO между такими методами будет "размазана".

Миграция через заголовок запроса

В данном случае передается заголовок Accept в котором указывается конкретная версия JSON объекта которую готов принимать клиент.

Пример:

Java
@RestController
public class UserController {

  // Вместо возвращаемого типа Object можно создать UserBaseDto
  // в который можно вынести общие поля и использовать его как возвращаемый
  // тип метода
  @GetMapping("/api/users/{user_id}")
  public Object getUser(
    @RequestParam("user_id") Long userId,
    @RequestHeader("Accept") String acceptMimeType) {

    // Извлекаем версию конечного объекта из заголовка Accept
    // Формируем конечный объект
    
    return suitableUserDto;
  }
}

HTTP
GET /api/users/{user_id}
Host: api.host
Accept: application/json

Response object:
{
  id: 0,
  phone_number: '+70123456789'
}

------------------------------

GET /api/users/{user_id}
Host: api.host
Accept: application/json;v=1

Response object:
{
  id: 0,
  phone_number: 70123456789
}

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

Заключение

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

P.S.

Было бы очень интересно узнать, как именно Ваша компания проводит миграцию данных)

YouTube канал автора.

Микроблог автора.

Комментарии (9)


  1. funca
    00.00.0000 00:00
    +1

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

    Для OpenAPI у нас используется SwaggerHub и версионирование SemVer. Очередная версия API это фактически новый документ, связанный с предыдущим лишь общей историей изменений. Поэтому нет необходимости в рамках одного документа описывать разные версии.


  1. funca
    00.00.0000 00:00
    +1

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

    Для OpenAPI у нас используется SwaggerHub и версионирование SemVer. Очередная версия API это фактически новый документ, связанный с предыдущим лишь общей историей изменений. Поэтому нет необходимости в рамках одного документа описывать разные версии.


    1. MayCode Автор
      00.00.0000 00:00

      Отличный подход. У нас есть тоже своя система документирования и в какой-то момент, хотелось бы чтобы на каждую версию формировался отдельный документ и была хорошо отлеживаемая история версия) Пока это описываем ручками(декларативно в коде) и держим старые версии эндпоинтов/дто, чтобы можно было отслеживать изменения


  1. PackRuble
    00.00.0000 00:00
    +1

    У вас ссылка на микроблог сломалась ;(

    Спасибо за статью. Интересно, насколько тяжко часто делать миграцию полей в мобильных приложениях?) Есть ещё какие тактики конкретно для мобильных приложений, чтобы совладать с миграционным адом в коде?


    1. MayCode Автор
      00.00.0000 00:00
      +2

      Почему сломалась? Сейчас проверил - все окей. Ну а чтобы совладать с миграционным адом - поддерживать сервера с разными версиями, как было написано в начале статьи:

      может себе позволить содержать сервера с разными версиями серверного приложения (api.host.v1, api.host.v2) и умеет их грамотно поддерживать

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


      1. PackRuble
        00.00.0000 00:00

        Это просто brave очень бдительный товарищ

        Разрешил межсайтовые куки и всё запустилось :)

        Спасибо, понял про отдельные сервера. Накладненько так-то


        1. MayCode Автор
          00.00.0000 00:00
          +1

          При любом подходе придется чем-то жертвовать, и причем всегда финансово) Либо тратить деньги на разработку и поддержку кода, либо тратить деньги на поддержку серверов со старыми версиями)


  1. Ndochp
    00.00.0000 00:00

    Что только не придумают, лишь бы 1С не использовать ;)
    (это если что ответ на вопрос в конце статьи)


  1. upagge
    00.00.0000 00:00
    +1

    Кажется ставить версию апи в конце запроса не лучшая идея. Обычно это /api/v1/user.

    Ну и практика показывает, что эти версии редко меняются))


    1. MayCode Автор
      00.00.0000 00:00

      А если это минорная версия конкретного эндпоинта, а не мажорная всего API? Я придерживаюсь подхода установки версии после /api в случаях с мажорными апдейтами всего сервиса. В Вашем случае при мажорном переезде всего API версия /api/v1 может быть занята у эндпоинта в котором Вы делали минорный фикс. И что, в этом случае у Вас будет /api/v2 для одного эндпоинта вместо /api/v1 для всех?

      Не согласен с частотой изменения этих версий. В мобильном деве приходилось иметь вплоть до 2-3 версий (из-за необходимости поддерживать эндпоинты для старых версий приложения) и это было далеко не крупное приложение.