• База знаний
  • /
  • Блог
  • /
  • Wiki
  • /
+380 (44) 364 05 71

Проверка данных

Представляем Вам перевод интересной статьи автора Matthias Noback на тему “Relying on the database to validate your data”. Оригинал на английском смотрите здесь.

Одна из вещей, которая входит в список моих раздражителей - использование схемы БД для проверки данных.

Вот несколько способов, как это обычно происходит:

  • Указание столбца, как обязательного, например email VARCHAR(255) NOT NULL
  • Добавления индекса с целью уникализации столбцов (e.g. CREATE UNIQUE INDEX email_idx ON users(email))
  • Добавление уникального индекса внешнего ключа, включая каскадные удаления и т. д.

Да, мне тоже нужна целостность данных. И нет, я не хочу полагаться на базу данных для этого.

Это удивительно, но несколько лет назад мы думали, что не должны записывать бизнес-логику приложения в нашу базу данных (например, хранимые процедуры), потому что:

  • Они не написаны на том же языке, что и остальная часть проекта.
  • Они не контролируются версиями (если вы, конечно, не усложните себе задачу).
  • Они не поддаются тестированию изолированно; вам нужна база данных для их запуска.
  • Они «магические», потому что запускаются безоговорочно.
  • Код специфический для каждой базы данных.

Ну, в любом случае было ясно, что они нам не нужны.

Тем не менее, многие из этих проблем относятся и к проверке на уровне базы данных. За исключением того, что с хранимыми процедурами мы фактически делегируем базе данных некоторую работу. При валидации мы, как правило, дублируем работу. Сначала мы проверяем в коде, что значение предоставлено, и показываем ошибку формы, если это не так. Затем мы используем утверждение в нашей модели объекта, чтобы убедиться, что значение не равно нулю. Затем мы сохраняем наш объект в базе данных, которая снова проверяет, что значение не равно нулю.

Зачем мы это делаем? Может потому, что хотим добиться симметрии? Свойство модели не допускает значения NULL, поэтому столбец, которому оно сопоставлено, также не должен допускать значения NULL. Может быть, потому что мы больше уверены в базе данных, чем в нашем коде? В коде проверка может быть каким-то образом пропущена, но мы всегда знаем, что будет проведена дополнительная проверка, как только данные окажутся в базе данных.

Недопустимость обнуления

Я думаю, нам не нужна ни симметрия ни дополнительные методы защиты. Вместо этого мы должны больше верить коду нашего приложения и убедиться, что в нем все обрабатывается. Нет необходимости в «двойной бухгалтерии», когда вы пытаетесь синхронизировать допустимость значений NULL свойств вашей модели с допустимостью значений NULL столбцов базы данных. По моему опыту, часто свойство модели допускает значение NULL, а столбец базы данных - нет, или наоборот. Это приводит к тому, что приложение взрывается из-за несоответствия кода и базы данных, допускающих наличие NULL. Мы можем снизить этот риск, отказавшись от двойной бухгалтерии. Вместо того, чтобы определять недопустимость обнуления для столбцов базы данных, давайте определим это только в коде. В любом случае нам всегда приходится иметь дело с недопустимостью обнуления в коде, поскольку нам нужны ошибки проверки, а не ошибки SQL. Итак, давайте просто удалим NOT NULL везде, ура!

Уникальные индексы

Другой интересный метод проверки на уровне базы данных - обеспечение уникальности, в частности в случае уникального адреса электронной почты в таблице пользователей. Видимо, здесь мы тоже не доверяем приложению и оставляем проверку важного правила базе данных (о том, насколько это важно на самом деле, поговорим позже). Учитывая, что база данных поддерживает проверку уникальности путем добавления индекса, мы добавляем его в каждый столбец, который, по нашему мнению, должен быть уникальным. Затем мы понимаем, что нам не нужны ошибки SQL, когда пользователь регистрируется с уже известным адресом электронной почты. Вместо этого нам нужны ошибки проверки формы. Именно поэтому мы вводим еще одну двойную бухгалтерию, чтобы быть удобными для пользователя и в то же время защитить целостность наших данных.

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

 

if ($this->userRepository->containsUserWithEmailAddress($emailAddress)) {
    // show form error in the response
} else {
    // the data is valid
    $user = new User($emailAddress);
    $this->userRepository->save($user);
}

 

Единственный способ получить повторяющиеся записи - это в случае если два запроса на регистрацию с одинаковым адресом электронной почты обрабатываются одновременно. В этом случае первый containsUserWithEmailAddress () может нажать false во время обработки этих запросов, а вызов save() приведет к созданию двух записей с одним и тем же адресом электронной почты. Опять же, это маловероятно, но может случиться. И что, если это произойдет? Нам просто нужно убедиться, что это не окажет существенного влияния.

Я считаю, что самый большой страх при наличии повторяющихся адресов электронной почты в базе данных заключается в том, что кто-то может войти в систему от имени другого человека. Например, вы регистрируетесь с адресом электронной почты другого человека (то есть он идентичен с вашим), и указав свой собственный пароль, войти в систему. Но, это вовсе не проблема, если использовать ID пользователя в качестве его уникального идентификатора, вместо адреса электронной почты. Вы не сможете выдать себя за другого пользователя; ведь как можно выдать себя за себя?). Тем не менее, было бы разумно всегда проверять адрес электронной почты пользователя, прежде чем он сможет войти в систему. Но это другая история, и для этого не требуется уникальность в базе данных.

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

 

$users = $this->userRepository->getAllWithEmailAddress($emailAddress);
$hashedPassword = password_hash($password);

$loggedInUser = null;

foreach ($users as $user) {
    if ($user->hashedPassword() === $hashedPassword) {
        /*
         * The password provided matches the current $user, but we always
         * set the first user in the array of users to be the logged in
         * user.
         */
        $loggedInUser = $users[array_key_first()];
    }
}

 

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

 

$loggedInUser = $this->userRepository->findOneWithEmailAndPassword(
    $emailAddress,
    password_hash($password)
);

 

В этом случае пользователь B никогда не сможет выдать себя за пользователя A. Конечно, до тех пор, пока их пароли случайно не совпадут. В таком случае, какой смысл пытаться выдать себя за пользователя A; ведь у него тоже есть ваш пароль. Вдобавок ко всему, этот код даже не будет работать, если хеширование паролей не использует строку salt.

Естественно, на одном конкретном примере, мы не можем сделать вывод, что вам никогда не понадобится ограничение уникальности в вашей базе данных. Я считаю, это обнадеживает: даже такая чувствительная к безопасности ситуация, как эта, на самом деле не требует уникальности на уровне базы данных. Однако следует признать, тот факт, что «вам никогда не нужны уникальные индексы для вашей базы данных» - и вправду действительно гипотеза. Я уверен, что мы сможем ее доработать, когда найдем еще несколько подобных случаев.

Уникальные ID

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

Ограничения внешнего ключа

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

Я не всегда создаю агрегаты, но делаю это, я придерживаюсь агрегированных правил. Первое: обращаться к другим агрегатам по их идентификатору. Второе: транзакция должна сохранять изменения только в одном агрегате (типе).

Мы никогда не переходим от агрегата к агрегату, мы никогда не сохраняем разные типы агрегатов в одной транзакции. Поэтому мы также никогда не позволяем базе данных идти дальше, чем на один агрегат. Сопоставление этих концепций со схемами базы данных (и я уверен, что вы также можете сопоставить их по-разному): таблица заказов может иметь столбец user_id, который ссылается на столбец id таблицы пользователей, но он не привязан к нему напрямую. Точно так же, как у вас может быть объект значения UserId в агрегате Order, который представляет объект значения UserId агрегата User. Но он никогда не может проверить, действительно ли он относится к самому агрегату User. Наиболее распространенное решение - установить эту ссылку службой приложений. Я обычно делаю так:

 

$user = $this->userRepository->getById(UserId::fromString($userId));

$order = OrderCreate(
    $user->userId(),
    // ...
);
$this->orderRepository->save($order);

 

И снова, как и при проверке уникального адреса электронной почты, код приложения может проверять то, что требует помощи репозитория. Конечно, репозиторий также выполняет поиск в базе данных. Но большим преимуществом здесь является то, что нам не нужно будет отлавливать ошибки SQL до того, как мы узнаем, что связанного агрегата не существует.

Еще большим преимуществом является то, что изменения, которые мы сохраняем в базе данных, не имеют другого эффекта, кроме обновления строки в одной таблице. Это само по себе приводит к шаблону, что изменения агрегата всегда автономны и делает модель отделенной от базы данных таким образом, что тип базы данных становится менее актуальным. Хотя мы привыкли использовать реляционную базу данных, мы можем также использовать базу данных документов для хранения наших агрегатов.

От каскадных удалений к явным процессам

Ограничения внешнего ключа часто используются для настройки каскадных действий внутри базы данных. Например, при удалении пользователя будут удалены и все его заказы. Что ж, этот пример не имеет смысла. Часто удаление пользователя - это не то, что нам действительно нужно. Это больше похоже на деактивацию их учетной записи, удаление их личной информации и т. д. Обычно вещи действительно не нужно удалять. Вот почему было изобретен режим “мягкого удаления”, и я думаю, что в целом он весьма полезен, в частности, в контексте реляционных баз данных и использования их для хранения данных модели. Если вы действительно хотите что-то удалить, сделайте это, используя режим “мягкого удаления”. Это защитит вас от удаления лишних данных и вы сможете прочитать в коде, что происходит после удаления пользователя. Я рекомендую сделать таким образом: моделировать данные шаги как процесс с использованием событий предметной области и подписчиков событий.

Приложение сможет реализовать всю логику валидации самостоятельно

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

  • Когда требуется значение, вы можете создать для него правило проверки формы. При этом всегда добавляйте какое-то ограничение к объекту модели (например, утверждение, которое генерирует исключение, если что-то не так).
  • Уникальность идентификаторов может уточняться при тестировании контракта для репозиториев. Т.е. вы можете убедиться, что получили ожидаемый объект из репозитория после его сохранения. При этом вы проверяете, что сохранение нового объекта с существующим идентификатором вызывает исключение.
  • Проверить наличие указанного идентификатора можно также с помощью репозитория. Загрузка объекта с данным идентификатором вызовет исключение, если идентификатор неизвестен.
  • Уникальность других значений (например, адреса электронной почты) можно обработать с помощью проверки репозитория. Уже существует объект с похожими данными? Убедитесь, что уникальность действительно требуется, и обязательно проконсультируйтесь с другими программистами, даже начинающими.

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

Дата: 03.09.2020
Автор: Евгений
Голосование

Авторам статьи важно Ваше мнение. Будем рады его обсудить с Вами:

comments powered by Disqus
Спасибо, что выбираете FREEhost.UA