При развитии API, изменении форматов конфигурации, обновлении структуры документов и событий возникает проблема совместимости данных между разными версиями приложений. Библиотека Ajv предоставляет механизмы, позволяющие:
Версионирование особенно важно в системах, где:
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)
}
oneOfAjv поддерживает объединение нескольких версий в одной схеме.
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"]
}
]
}
Новая версия приложения способна читать старые данные.
Пример:
name, pricename, 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
})
Старые сервисы продолжают принимать новые данные.
Для этого используются:
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)
}
Ajv поддерживает discriminator для выбора схемы.
const schema = {
discriminator: {
propertyName: "version"
},
oneOf: [
{
properties: {
version: {
const: 1
}
}
},
{
properties: {
version: {
const: 2
}
}
}
]
}
Создание Ajv:
const ajv = new Ajv({
discriminator: true
})
Преимущества:
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"
}
}
Каждая версия события должна храниться отдельно.
Конфигурационные файлы часто меняются между релизами.
{
"version": 1,
"host": "localhost"
}
{
"version": 2,
"server": {
"host": "localhost"
}
}
Миграция:
function migrateConfigV1toV2(config) {
return {
version: 2,
server: {
host: config.host
}
}
}
Иногда поле ещё поддерживается, но считается устаревшим.
const schema = {
type: "object",
properties: {
fullName: {
type: "string",
deprecated: true
},
firstName: {
type: "string"
},
lastName: {
type: "string"
}
}
}
Ajv может использовать keyword deprecated в
пользовательских инструментах анализа схем.
Типичная стратегия:
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)
Версии схем часто оформляются в стиле SemVer:
1.0.0
1.1.0
1.2.0
2.0.0
При обновлении схем полезно автоматически анализировать изменения.
Проверяются:
Нельзя изменять старую схему после публикации.
Плохой подход:
schema.properties.name.minLength = 5
Правильный подход:
const schemaV3 = {
...
}
Иногда версия включается в $id.
$id: "https://api.example.com/schemas/v2/user.json"
$id: "user.v2"
Ajv поддерживает условную логику.
const schema = {
if: {
properties: {
version: {
const: 1
}
}
},
then: schemaV1,
else: schemaV2
}
Получатель игнорирует неизвестные поля.
additionalProperties: true
Подход снижает вероятность поломки интеграций.
Максимально жёсткая схема:
additionalProperties: false
Подход полезен:
Изменение структуры элементов массива также требует версии.
{
"items": ["apple", "banana"]
}
{
"items": [
{
"name": "apple"
}
]
}
Для каждой версии необходимы:
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.
Даже корректная схема не гарантирует правильную трансформацию данных.
ajv.addSchema(schemaV1, "v1")
ajv.addSchema(schemaV2, "v2")
const migrated = migrateV1toV2(data)
const schema = {
oneOf: [
schemaV1,
schemaV2
]
}
ajv.removeSchema("v1")