Кеширование результатов валидации

Причины применения кеширования при валидации

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

Библиотека class-validator выполняет проверку декларативно, но не включает встроенного механизма кеширования результатов. Это означает, что при повторном вызове validate() или validateSync() один и тот же объект будет проходить полный цикл проверок заново, даже если его состояние не изменилось.

Кеширование валидации применяется для следующих целей:

  • снижение вычислительной нагрузки при повторных проверках одних и тех же данных
  • ускорение обработки массовых операций (batch processing)
  • уменьшение числа обращений к внешним сервисам в кастомных асинхронных валидаторах
  • оптимизация пайплайнов, где DTO повторно валидируются на разных этапах

Особенности, влияющие на кеширование

Поведение кеша в контексте class-validator ограничено несколькими факторами:

  • объекты могут быть изменяемыми (mutation-friendly), что усложняет стабильное кеширование
  • валидация часто зависит от внешнего контекста (база данных, HTTP-запрос, окружение)
  • порядок применения декораторов и вложенных структур влияет на результат
  • асинхронные валидаторы могут возвращать разные результаты во времени

Из-за этого кеширование требует строгого определения ключа и границ актуальности данных.


Модели кеширования результатов

Кеширование по ссылке объекта (WeakMap)

Наиболее безопасный вариант — использование WeakMap, где ключом выступает сам объект:

const validationCache = new WeakMap();

async function validateWithCache(dto) {
  if (validationCache.has(dto)) {
    return validationCache.get(dto);
  }

  const result = await validate(dto);
  validationCache.set(dto, result);

  return result;
}

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

  • отсутствие утечек памяти (WeakMap очищается сборщиком мусора)
  • стабильность при работе с неизменяемыми объектными ссылками
  • отсутствие необходимости сериализации

Ограничения:

  • кеш не работает при создании новых экземпляров с теми же данными
  • не подходит для распределённых систем

Кеширование по сериализованному состоянию

Если идентичность объекта не гарантируется, используется сериализация:

import stableStringify from "fast-json-stable-stringify";

const cache = new Map();

async function validateWithSerializedCache(dto) {
  const key = stableStringify(dto);

  if (cache.has(key)) {
    return cache.get(key);
  }

  const result = await validate(dto);
  cache.set(key, result);

  return result;
}

Ключевое требование — детерминированная сериализация. Обычный JSON.stringify может давать нестабильный порядок ключей, что приводит к разным кеш-ключам для одинаковых структур.

Проблемные случаи:

  • поля с undefined
  • нестабильный порядок ключей объектов
  • циклические ссылки
  • Date, Map, Set

Кеширование по хешу

Для повышения эффективности вместо полной сериализации используется хеширование:

import crypto from "crypto";

function hashDto(dto) {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(dto))
    .digest("hex");
}

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


Кеширование внутри кастомных валидаторов

class-validator позволяет создавать собственные ограничения через ValidatorConstraintInterface. Именно внутри таких валидаторов чаще всего возникает необходимость кеширования.

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
} from "class-validator";

const externalCache = new Map();

@ValidatorConstraint({ async: true })
export class IsUniqueEmailConstraint implements ValidatorConstraintInterface {
  async validate(email) {
    if (externalCache.has(email)) {
      return externalCache.get(email);
    }

    const isUnique = await checkEmailInDatabase(email);

    externalCache.set(email, isUnique);

    return isUnique;
  }

  defaultMessage() {
    return "Email уже используется";
  }
}

Особенности такого подхода:

  • кешируется результат внешнего запроса
  • валидатор остаётся чистым с точки зрения API class-validator
  • требуется отдельная стратегия инвалидирования кеша

Интеграция кеширования с асинхронной валидацией

Асинхронные проверки чаще всего обращаются к внешним системам:

  • базы данных
  • HTTP API
  • Redis
  • файловые хранилища

Без кеша такие операции становятся узким местом.

Типовая структура с кешированием:

const asyncValidationCache = new Map();

async function asyncValidate(value, validatorFn) {
  const key = `${validatorFn.name}:${value}`;

  if (asyncValidationCache.has(key)) {
    return asyncValidationCache.get(key);
  }

  const result = await validatorFn(value);
  asyncValidationCache.set(key, result);

  return result;
}

Такой слой позволяет отделить логику кеширования от самой библиотеки валидации.


Влияние class-transformer на кеширование

При использовании class-transformer объекты DTO часто пересоздаются:

plainToInstance(UserDto, payload);

Это приводит к следующему эффекту:

  • WeakMap-кеш становится неэффективным
  • сериализационные ключи могут совпадать, но ссылки — нет
  • повторная трансформация может создавать новые экземпляры с теми же данными

В таких сценариях предпочтение отдается кешированию по содержимому, а не по ссылке.


Инвалидация кеша

Ключевая проблема кеширования валидации — устаревание данных.

Причины инвалидирования:

  • изменение объекта после валидации
  • обновление внешних данных (например, запись в базе)
  • изменение правил валидации
  • смена контекста запроса

Подходы:

1. Версионирование DTO

const key = `${dto.version}:${stableStringify(dto)}`;

2. TTL-кеширование

const cache = new Map();

function setWithTTL(key, value, ttl) {
  const expires = Date.now() + ttl;

  cache.set(key, { value, expires });
}

3. Контекстное разделение

const key = `${userId}:${stableStringify(dto)}`;

Кеширование на уровне пайплайна NestJS

В архитектуре NestJS валидация часто выполняется через ValidationPipe. Он не содержит встроенного кеширования, но позволяет оборачивать процесс:

@Injectable()
export class CachedValidationPipe implements PipeTransform {
  private cache = new Map();

  async transform(value, metadata) {
    const key = JSON.stringify(value);

    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const result = await validate(value);
    this.cache.set(key, result);

    if (result.length > 0) {
      throw new BadRequestException(result);
    }

    return value;
  }
}

Такой подход изменяет семантику пайплайна: валидация становится частью кешируемого слоя обработки запроса.


Проблемы и ограничения кеширования валидации

Кеширование в контексте class-validator требует учёта ряда ограничений:

  • невозможность гарантировать неизменность входных данных
  • высокая цена ошибочного кеша (ложно положительные или отрицательные результаты)
  • сложность синхронизации при распределённой архитектуре
  • риск накопления устаревших результатов при долгоживущих процессах

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


Оптимизационные стратегии без явного кеша

В ряде случаев вместо кеширования используется снижение стоимости самой валидации:

  • группировка правил через validation groups
  • исключение необязательных проверок через skipMissingProperties
  • разделение DTO на более мелкие структуры
  • предварительная нормализация данных через class-transformer

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