Доброе время суток, уважаемое Хабр коммьюнити. В этой публикации я хотел бы показать несколько известных мне подходов к версионной миграции данных в контексте 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.
Было бы очень интересно узнать, как именно Ваша компания проводит миграцию данных)