open atlas
↑ К треку
NestJS с нуля до senior NEST · 02 · 05

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

Кастомные constraint: класс @ValidatorConstraint или фабрика registerDecorator. Асинхронные валидаторы в БД прячут ловушки: забыл useContainer — инжектированный repo undefined; а гонка read-then-write значит, что уникальность гарантирует только UNIQUE-индекс БД, а не валидатор.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Форма регистрации выглядела герметичной. Кто-то написал чистый декоратор @IsEmailUnique(): он запрашивал таблицу пользователей внутри валидатора, возвращал дружелюбный 400 "email already taken", и демо было безупречным. Поехало в прод. Через две недели поддержка вытащила два аккаунта с ровно одинаковым email, а третий запрос упал с Postgres-500. Ничто в коде не выглядело неправильным — валидатор был прямо вот тут. Но валидатор лгал о том, что он гарантирует: он прочитал таблицу, нашёл email отсутствующим и пропустил запрос дальше — а соседний запрос, на полмиллисекунды позже, сделал ровно то же чтение и получил то же «отсутствует». Оба прошли. Оба вставили. Этот урок — про кастомные и асинхронные валидаторы: как их строить, почему инжектированный repository иногда undefined и почему валидатор никогда не может быть тем, что держит две строки порознь.

Два способа написать кастомный constraint

class-validator даёт две формы для кастомного правила. Первая — класс constraint: класс, помеченный @ValidatorConstraint, реализующий ValidatorConstraintInterface. Ты пишешь validate() (возвращает boolean или Promise<boolean>) и defaultMessage(), затем прикрепляешь его к свойству через @Validate(MyConstraint).

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
  Validate,
} from 'class-validator';

// A synchronous rule — no I/O, just logic over the value
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
  validate(value: string): boolean {
    return typeof value === 'string'
      && value.length >= 12
      && /[a-z]/.test(value)
      && /[A-Z]/.test(value)
      && /[0-9]/.test(value);
  }
  defaultMessage(args: ValidationArguments): string {
    return `${args.property} must be ≥12 chars with upper, lower, and a digit`;
  }
}

export class SignupDto {
  @Validate(IsStrongPasswordConstraint)
  password: string;
}

Вторая форма — фабрика декоратора на базе registerDecorator. За ней тянешься, когда хочешь переиспользуемый @IsEmailUnique(), который можно ронять на любое свойство — она оборачивает тот же интерфейс constraint в функцию, регистрирующую его против target и property:

import { registerDecorator, ValidationOptions } from 'class-validator';

// Переиспользуемый декоратор: @IsEmailUnique() — оборачивает constraint через registerDecorator
export function IsEmailUnique(options?: ValidationOptions) {
  return function (target: object, propertyName: string) {
    registerDecorator({
      name: 'isEmailUnique',
      target: target.constructor,
      propertyName,
      options,
      validator: IsEmailUniqueConstraint, // the @Injectable() class below
    });
  };
}

В любом случае правило живёт в классе constraint. Фабрика декоратора — лишь эргономичная упаковка, чтобы место вызова читалось как @IsEmailUnique(), а не @Validate(IsEmailUniqueConstraint).

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

Проверка уникальности обязана говорить с базой, так что её validate() возвращает Promise<boolean>, а constraint помечен async: true. Чтобы запросить, ему нужен repository пользователей — поэтому ты инжектишь его, как любой другой сервис Nest:

@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private readonly users: UsersRepository) {} // injected dep

  async validate(email: string): Promise<boolean> {
    // true = valid = email is NOT taken
    return !(await this.users.exists({ email }));
  }
  defaultMessage(): string {
    return 'email already taken';
  }
}

Это компилируется, а затем падает в рантайме: Cannot read properties of undefined (reading 'exists'). Причина — та самая ловушка. class-validator инстанцирует классы constraint сам, вне DI-контейнера Nest — он просто делает new IsEmailUniqueConstraint(), не имея понятия, что твой конструктор хотел repository. Так что this.usersundefined. В зависимости от того, как ты написал защиту, это либо краш, либо — хуже — валидатор, который молча всегда проходит, потому что await на undefined выбрасывает, а исключение проглатывается.

Фикс в одну строку живёт в main.ts. useContainer (экспортируется из class-validator) говорит ему резолвить constraint через контейнер Nest вместо того, чтобы new-ить их самому:

import { useContainer } from 'class-validator';
import { AppModule } from './app.module';

const app = await NestFactory.create(AppModule);
// Инстанцирование constraint через DI Nest, чтобы @Injectable() зависимости резолвились
useContainer(app.select(AppModule), { fallbackOnErrors: true });

Теперь IsEmailUniqueConstraint строится Nest, получает свой UsersRepository, и this.users реален. fallbackOnErrors: true важен: встроенные constraint class-validator (@IsEmail, @IsString, …) — не провайдеры Nest, так что Nest не может их зарезолвить — fallbackOnErrors позволяет class-validator откатиться на свой собственный new для них вместо того, чтобы выбросить. Без него каждый встроенный валидатор станет ошибкой «provider not found».

Почему это работает

Почему проходящий асинхронный валидатор уникальности недостаточен, чтобы гарантировать уникальность? Потому что это проверка read-then-write с зазором посередине. Валидатор выполняет SELECT … WHERE email = ? и не видит строки. Но «нет строки прямо сейчас» — не «нет строки, когда я вставлю». Две конкурентные регистрации с одинаковым email обе выполняют свой SELECT до того, как любая сделала INSERT — так что обе видят «отсутствует», обе проходят валидацию и обе идут вставлять. Ни один валидатор не неправ насчёт того, что увидел; они просто оба увидели мир, который перестал быть истинным мгновением позже. Только база может закрыть этот зазор, потому что только база может сериализовать две вставки против общего UNIQUE-индекса — она пропускает первый commit и отвергает второй. Валидатор снижает трение (чистый 400 для общего случая); индекс обеспечивает истину.

Три ловушки, и почему индекс — настоящий фикс

Размещение чтения из БД в слое валидации покупает тебе три проблемы, по возрастанию серьёзности. Когда видишь их вместе, понимаешь, почему валидатор и индекс — это не альтернативы: каждый закрывает дыру, которую другой закрыть не может.

(1) DI — та, что выше: забыл useContainer — и инжектированный repository undefined. Краш в день деплоя, легко найти, как только знаешь, что он существует.

(2) Производительность и enumeration — валидация теперь выполняет запрос на каждый запрос к этому эндпоинту, до хендлера, включая некорректные и злонамеренные. Это DB round trip на запрос, и хуже того, отличимое сообщение "email already taken" превращает эндпоинт в оракул user-enumeration: атакующий скриптует его, чтобы узнать, у каких email есть аккаунты. Митигируй rate limiting и сообщением, которое не подтверждает существование.

(3) Гонка TOCTOU — настоящий урок. Это и есть прод-инцидент. Проверка уникальности — это read-then-write, так что при конкуренции две регистрации обе проходят валидатор и обе вставляют. Исход — либо две строки с одинаковым email (нет constraint), либо сырое нарушение уникальности Postgres, всплывающее как 500 (constraint есть, не обработан). Фикс — перестать притворяться, что валидация — это constraint:

// 1) The DATABASE is the source of truth — a UNIQUE index serializes inserts
//    e.g. CREATE UNIQUE INDEX users_email_key ON users (lower(email));

// 2) Catch the unique-violation and map it to a clean 409 — Postgres code 23505
try {
  return await this.users.insert(dto);
} catch (e) {
  if (e.code === '23505') {           // unique_violation
    throw new ConflictException('email already taken'); // 409, not 500
  }
  throw e;
}

Валидация — это не constraint. Оставь асинхронный @IsEmailUnique() — он даёт общему случаю дружелюбный 400 без stack trace. Но UNIQUE-индекс — источник истины: он единственное, что сериализует две конкурентные вставки, и отлов 23505 превращает проигравшего в этой гонке в чистый 409 вместо утёкшего 500. Валидатор — это UX; индекс — это гарантия.

Выбери лучший вариант

Ты должен гарантировать, что никакие два пользователя никогда не делят email, даже при конкурентных регистрациях. Какой подход реально даёт гарантию?

Викторина

Твой асинхронный IsEmailUniqueConstraint инжектит UsersRepository, но в рантайме this.users — undefined, и валидация падает. Чего не хватает?

Викторина

Две конкурентные регистрации с одинаковым email обе проходят асинхронный валидатор @IsEmailUnique. Что реально гарантирует, что только одна из них преуспеет?

Вспомните перед уходом
  1. 01
    Опиши два способа написать кастомный constraint class-validator и объясни ловушку DI, из-за которой инжектированный repository undefined в асинхронном валидаторе, плюс её фикс.
  2. 02
    Почему асинхронный валидатор уникальности никогда не может гарантировать уникальность и какова правильная стратегия уникальности в Nest + Postgres?
Итог

Кастомные constraint class-validator бывают двух форм: класс, помеченный @ValidatorConstraint, реализующий ValidatorConstraintInterface (validate + defaultMessage, прикрепляется через @Validate), либо переиспользуемая фабрика декоратора на базе registerDecorator, упаковывающая тот же constraint как @IsEmailUnique(). Асинхронная проверка в БД помечает constraint async: true и возвращает Promise<boolean>, и обязана инжектить свой repository — но class-validator инстанцирует constraint сам обычным new, вне DI Nest, так что инжектированный repo undefined, и валидация падает. Фикс в одну строку — useContainer(app.select(AppModule), { fallbackOnErrors: true }) в main.ts, которая направляет инстанцирование через контейнер Nest, чтобы @Injectable()-зависимости зарезолвились; fallbackOnErrors позволяет встроенным валидаторам откатиться на собственный new class-validator вместо ошибки. Асинхронная валидация в БД затем несёт три ловушки: забытый useContainer (undefined repo), DB round trip на каждый запрос плюс оракул user-enumeration из отличимого сообщения «email taken» (митигируй rate limiting и расплывчатым сообщением) и настоящую — TOCTOU (Time-of-Check/Time-of-Use — гонка между чтением и записью). Поскольку проверка — это read-then-write, две конкурентные регистрации обе делают SELECT «отсутствует» до любого INSERT, обе проходят и обе вставляют, давая дубликаты или необработанный 500. Валидация — это не constraint: UNIQUE-индекс БД — источник истины, который сериализует вставки, и ты ловишь нарушение уникальности (Postgres 23505) и мапишь в чистый 409. Оставь валидатор ради дружелюбного сообщения, но индекс — это гарантия. Теперь, когда видишь проверку уникальности в валидаторе, твой первый вопрос — есть ли UNIQUE-индекс и ловит ли сервис 23505, — потому что валидатор в одиночку — это обещание, не доказательство.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.