Версионирование данных

При развитии API, изменении форматов конфигурации, обновлении структуры документов и событий возникает проблема совместимости данных между разными версиями приложений. Библиотека Ajv предоставляет механизмы, позволяющие:

  • поддерживать несколько версий схем одновременно;
  • мигрировать данные между версиями;
  • контролировать обратную совместимость;
  • валидировать устаревшие и новые структуры;
  • постепенно выводить старые версии из эксплуатации;
  • разделять правила для разных поколений API.

Версионирование особенно важно в системах, где:

  • клиенты обновляются независимо;
  • данные сохраняются в БД длительное время;
  • используется событийная архитектура;
  • существуют внешние интеграции;
  • разные сервисы работают на разных релизах.

Основные стратегии версионирования

Версионирование через поле version

Наиболее распространённый подход — хранение версии внутри самого документа.

{
  "version": 2,
  "name": "Laptop",
  "price": 1500,
  "currency": "USD"
}

Схема первой версии:

const schemaV1 = {
  type: "object",
  properties: {
    version: {
      const: 1
    },
    name: {
      type: "string"
    },
    price: {
      type: "number"
    }
  },
  required: ["version", "name", "price"],
  additionalProperties: false
}

Схема второй версии:

const schemaV2 = {
  type: "object",
  properties: {
    version: {
      const: 2
    },
    name: {
      type: "string"
    },
    price: {
      type: "number"
    },
    currency: {
      type: "string"
    }
  },
  required: ["version", "name", "price", "currency"],
  additionalProperties: false
}

Преимущества подхода:

  • версия всегда находится рядом с данными;
  • упрощается выбор схемы;
  • легко организовать миграции;
  • удобно хранить документы в БД.

Недостатки:

  • требуется обязательное поле версии;
  • возможно дублирование логики.

Регистрация нескольких схем

Ajv позволяет хранить множество схем одновременно.

import Ajv from "ajv"

const ajv = new Ajv()

ajv.addSchema(schemaV1, "product-v1")
ajv.addSchema(schemaV2, "product-v2")

Получение валидаторов:

const validateV1 = ajv.getSchema("product-v1")
const validateV2 = ajv.getSchema("product-v2")

Проверка:

const valid = validateV2(data)

if (!valid) {
  console.log(validateV2.errors)
}

Автоматический выбор схемы по версии

Часто требуется динамически определять нужную схему.

function getValidator(data) {
  switch (data.version) {
    case 1:
      return validateV1

    case 2:
      return validateV2

    default:
      throw new Error("Unsupported version")
  }
}

Использование:

const validate = getValidator(data)

if (!validate(data)) {
  console.log(validate.errors)
}

Использование oneOf

Ajv поддерживает объединение нескольких версий в одной схеме.

const schema = {
  oneOf: [
    schemaV1,
    schemaV2
  ]
}

Проверка:

const validate = ajv.compile(schema)

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


Разделение схем через $id

Каждая версия схемы должна иметь уникальный идентификатор.

const schemaV1 = {
  $id: "https://example.com/schemas/product-v1.json",
  type: "object",
  properties: {
    version: {
      const: 1
    }
  }
}
const schemaV2 = {
  $id: "https://example.com/schemas/product-v2.json",
  type: "object",
  properties: {
    version: {
      const: 2
    }
  }
}

Это особенно важно при использовании $ref.


Наследование схем между версиями

Часто новая версия отличается минимально. Дублирование удобно устранять через $ref.

Базовая схема

const baseSchema = {
  $id: "base.json",
  type: "object",
  properties: {
    name: {
      type: "string"
    },
    price: {
      type: "number"
    }
  },
  required: ["name", "price"]
}

Первая версия

const schemaV1 = {
  $id: "v1.json",
  allOf: [
    {
      $ref: "base.json"
    },
    {
      properties: {
        version: {
          const: 1
        }
      },
      required: ["version"]
    }
  ]
}

Вторая версия

const schemaV2 = {
  $id: "v2.json",
  allOf: [
    {
      $ref: "base.json"
    },
    {
      properties: {
        version: {
          const: 2
        },
        currency: {
          type: "string"
        }
      },
      required: ["version", "currency"]
    }
  ]
}

Обратная совместимость

Backward compatibility

Новая версия приложения способна читать старые данные.

Пример:

  • v1: name, price
  • v2: name, price, currency

Если currency имеет значение по умолчанию, старые документы остаются валидными.

const schemaV2 = {
  type: "object",
  properties: {
    name: {
      type: "string"
    },
    price: {
      type: "number"
    },
    currency: {
      type: "string",
      default: "USD"
    }
  },
  required: ["name", "price"]
}

Ajv может автоматически подставлять значения.

const ajv = new Ajv({
  useDefaults: true
})

Forward compatibility

Старые сервисы продолжают принимать новые данные.

Для этого используются:

  • additionalProperties: true;
  • необязательные поля;
  • мягкие изменения структуры.

Пример:

const schema = {
  type: "object",
  properties: {
    name: {
      type: "string"
    }
  },
  required: ["name"],
  additionalProperties: true
}

Ломающие изменения

Изменения считаются breaking changes, если:

  • удаляются поля;
  • меняются типы;
  • поле становится обязательным;
  • меняется структура объекта;
  • изменяется семантика данных.

Пример несовместимого изменения

Версия 1:

{
  "price": 100
}

Версия 2:

{
  "price": {
    "amount": 100
  }
}

Старые клиенты больше не смогут работать с новой структурой.


Миграции данных

Простая миграция

function migrateV1toV2(data) {
  return {
    ...data,
    version: 2,
    currency: "USD"
  }
}

Цепочка миграций

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

const migrations = {
  1: migrateV1toV2,
  2: migrateV2toV3
}

Функция обновления:

function migrate(data, targetVersion) {
  let current = data

  while (current.version < targetVersion) {
    const migration = migrations[current.version]

    if (!migration) {
      throw new Error("Migration not found")
    }

    current = migration(current)
  }

  return current
}

Проверка после миграции

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

const migrated = migrate(data, 3)

const valid = validateV3(migrated)

if (!valid) {
  console.log(validateV3.errors)
}

Использование discriminator

Ajv поддерживает discriminator для выбора схемы.

const schema = {
  discriminator: {
    propertyName: "version"
  },
  oneOf: [
    {
      properties: {
        version: {
          const: 1
        }
      }
    },
    {
      properties: {
        version: {
          const: 2
        }
      }
    }
  ]
}

Создание Ajv:

const ajv = new Ajv({
  discriminator: true
})

Преимущества:

  • быстрый выбор схемы;
  • упрощение логики;
  • уменьшение количества ошибок.

Версионирование API

Разные схемы для разных endpoint

const userSchemaV1 = { ... }
const userSchemaV2 = { ... }
app.post("/api/v1/users", validate(userSchemaV1))
app.post("/api/v2/users", validate(userSchemaV2))

Версионирование событий

В событийных системах схема фиксируется навсегда.

Пример события:

{
  "eventType": "USER_CREATED",
  "version": 3,
  "payload": {
    "id": 10,
    "email": "admin@example.com"
  }
}

Каждая версия события должна храниться отдельно.


Версионирование конфигураций

Конфигурационные файлы часто меняются между релизами.

Конфиг v1

{
  "version": 1,
  "host": "localhost"
}

Конфиг v2

{
  "version": 2,
  "server": {
    "host": "localhost"
  }
}

Миграция:

function migrateConfigV1toV2(config) {
  return {
    version: 2,
    server: {
      host: config.host
    }
  }
}

Deprecated-поля

Иногда поле ещё поддерживается, но считается устаревшим.

const schema = {
  type: "object",
  properties: {
    fullName: {
      type: "string",
      deprecated: true
    },
    firstName: {
      type: "string"
    },
    lastName: {
      type: "string"
    }
  }
}

Ajv может использовать keyword deprecated в пользовательских инструментах анализа схем.


Постепенный отказ от старых версий

Типичная стратегия:

  1. Добавление новой версии.
  2. Поддержка двух версий одновременно.
  3. Пометка старой версии как deprecated.
  4. Сбор статистики использования.
  5. Удаление старой схемы.

Организация файлов схем

Структура проекта

schemas/
├── v1/
│   └── product.json
├── v2/
│   └── product.json
└── common/
    └── money.json

Динамическая загрузка схем

import fs from "fs"

const schema = JSON.parse(
  fs.readFileSync("./schemas/v2/product.json")
)

ajv.addSchema(schema)

Использование semantic versioning

Версии схем часто оформляются в стиле SemVer:

1.0.0
1.1.0
1.2.0
2.0.0

Правила

  • PATCH — исправления без изменения структуры;
  • MINOR — добавление совместимых полей;
  • MAJOR — breaking changes.

Проверка совместимости схем

При обновлении схем полезно автоматически анализировать изменения.

Проверяются:

  • удаление required-полей;
  • изменение типов;
  • изменение enum;
  • изменение форматов;
  • изменение вложенных объектов.

Изоляция версий

Нельзя изменять старую схему после публикации.

Плохой подход:

schema.properties.name.minLength = 5

Правильный подход:

const schemaV3 = {
  ...
}

Версионирование через URI

Иногда версия включается в $id.

$id: "https://api.example.com/schemas/v2/user.json"

Версионирование через namespace

$id: "user.v2"

Использование if/then/else

Ajv поддерживает условную логику.

const schema = {
  if: {
    properties: {
      version: {
        const: 1
      }
    }
  },
  then: schemaV1,
  else: schemaV2
}

Стратегия tolerant reader

Получатель игнорирует неизвестные поля.

additionalProperties: true

Подход снижает вероятность поломки интеграций.


Стратегия strict schema

Максимально жёсткая схема:

additionalProperties: false

Подход полезен:

  • в финансовых системах;
  • при строгих контрактах API;
  • в критически важных сервисах.

Версионирование массивов

Изменение структуры элементов массива также требует версии.

Версия 1

{
  "items": ["apple", "banana"]
}

Версия 2

{
  "items": [
    {
      "name": "apple"
    }
  ]
}

Тестирование версий

Для каждой версии необходимы:

  • positive tests;
  • negative tests;
  • migration tests;
  • compatibility tests.

Пример теста

test("v1 document is valid", () => {
  const valid = validateV1({
    version: 1,
    name: "Phone",
    price: 100
  })

  expect(valid).toBe(true)
})

Логирование ошибок версий

Полезно фиксировать:

  • неизвестные версии;
  • ошибки миграции;
  • несовместимые поля;
  • устаревшие форматы.
if (!validator) {
  logger.error("Unsupported schema version")
}

Частые ошибки

Изменение старой схемы

Старые версии должны быть неизменяемыми.


Отсутствие миграций

Без миграций невозможно обновлять сохранённые документы.


Смешивание версий

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


Слишком жёсткая совместимость

Полный запрет новых полей затрудняет развитие API.


Отсутствие тестов миграции

Даже корректная схема не гарантирует правильную трансформацию данных.


Практическая схема жизненного цикла версии

Этап 1 — создание v1

ajv.addSchema(schemaV1, "v1")

Этап 2 — выпуск v2

ajv.addSchema(schemaV2, "v2")

Этап 3 — миграция

const migrated = migrateV1toV2(data)

Этап 4 — поддержка обеих версий

const schema = {
  oneOf: [
    schemaV1,
    schemaV2
  ]
}

Этап 5 — удаление v1

ajv.removeSchema("v1")