Не используйте одну и ту же модель для записи и чтения

Источник: «Please don’t: using the same models for write and read in PHP»
Модели — отличный инструмент для взаимодействия с хранилищем данных. Можно задать, как выглядят данные, и это гарантирует, что они совместимы с хранилищем данных, обычно базой данных. Когда есть модель, проверяющая вводимые данные и помогающая их записывать, может возникнуть соблазн использовать её и для извлечения данных. За исключением некоторых базовых CRUD-приложений, обычно это не очень хорошая идея. Давайте разберёмся, почему.

Создание рабочей модели

Давайте воспользуемся простой моделью User и интерфейсом UserRepository, подробности здесь не нужны. Но предположим, что есть некоторая библиотека утверждений, используемая для проверки валидности каждой созданной модели.

class User
{
public function __construct(
public string $email,
public string $name,
public string $password,
) {
Assert::email($email);
Assert::notEmpty($name);
Assert::password($password, strength: 3);
}
}
interface UserRepository
{
public function save(User $user): void;
}

Итак, основной сценарий использования: мы получаем данные для нового пользователя, проверяем, что имя не пустое, электронная почта — валидная, а пароль соответствует тому, что определено как уровень надёжности 3. Затем отправляем его в репозиторий и сохраняем. Работа выполнена.

$user = new User(
$request->get('email'),
$request->get('name'),
$request->get('password'),
);

$repository->save($user);

Проблема #1: Свойства модели, которые не должны быть доступны для чтения

Итак, теперь необходимо получить пользователя по электронной почте из базы данных и вернуть его JSON-представление клиенту для представления профиля пользователя. Что произойдёт, если добавить метод чтения в репозиторий, используя ту же модель?

interface UserRepository
{
public function save(User $user): void;
public function get(string $email): User;
}
// Внутри класса контроллера
return new Response(
json_encode(
$repository->get($request->get('email'))
),
);

Итак, что мы получаем?

{
"email": "peter@dailybugle.com",
"name": "Peter Parker",
"password": "$2y$10$OEaTphGkW0HQv4QNxtptQOE.BSQDnpwrB.\/VGqIgjMEhvIr22jnFK"
}

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

Даже если это, вероятно, худший из возможных случаев утечки информации, вызванной использованием модели записи в качестве модели чтения, он не единственный. Другая распространённая проблема — отправка клиенту нерелевантной информации. Например, у нас может быть логическое значение active, используемое для активации или деактивации пользователей, но бесполезное для клиента, поскольку если пользователь не активен, запрос будет отвечать 404 Not Found. Нерелевантные данные означают, что мы отправляем байты, которые никогда не будут потреблены, что снижает производительность. Это мелочь, но всё складывается, и у этой проблемы есть простое решение.

Так что же делать? Предоставить ответ с ограниченным набором данных? Это может решить эти проблемы.

class User
{
// ...

public function read(): array
{
return [
'email' => $this->email,
'name' => $this->name,
];
}
}

Но есть и другие вопросы, требующие решения, давайте посмотрим.

Проблема #2: Ненужные проверки

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

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

Теперь мы получаем запрос на список из 100 пользователей, один из которых имеет домен из чёрного списка, что произойдёт? Весь запрос считается ошибкой. И что мы отправим пользователю? Ответ 400 Bad Request, как если бы пользователь ввёл что-то неправильно? Это не вина клиента, а вина сервера. В этом случае должно быть что-то вроде 500 ошибки.

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

class User
{
public function __construct(
public string $email,
public string $name,
public string $password,
) {}

public static function create(string $email, string $name, string $password): self
{
Assert::email($email);
Assert::notEmpty($name);
Assert::password($password, strength: 3);

return new self($email, $name, $password);
}
}

Таким образом, при создании новой модели, требующей проверки, можно сделать User::new(), а при получении данных из базы использовать конструктор. Это решает некоторые проблемы, но есть и другие.

Проблема #3: Добавление дополнительных данных в модель

Другая распространённая ситуация — клиент требует дополнительных данных для представления. В нашем примере представлению может понадобиться показать количество комментариев, созданных пользователем в системе. Это не является частью модели, но выглядит расточительно не добавить это в тот же HTTP ответ и заставить клиента ждать второго ответа только потому, что данные не соответствуют модели записи.

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

return new Response(
json_encode(
array_merge(
$repository->get($request->get('email')),
['comments' => $commentRepository->count($request->get('email'))]
)

),
);

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

Проблема #4: Действительно ли вставки и обновления — это одно и то же

И последняя проблема, это не совсем модель запись vs чтение, но при обновлении модели можно ли использовать тот же класс, который использовался при её создании?

Поэтому, создавая нового пользователя с помощью этой модели, мы ожидаем ввода имени, электронной почты и пароля. Для создания пользователя это нормально, но в примере эксперт по безопасности требует, чтобы пароли обновлялись особым способом, включающим запрос пользователя на изменение пароля, отправку ему электронного письма с токеном с ограниченным сроком действия, а затем проверку токена для принятия нового пароля.

Пароль никогда не должен обновляться каким-либо другим способом, так что же делать, если использовать ту же модель, для обновления пользователя? Будет два разных места в коде, где обновляется пользователь, одно для пароля, другое для всего остального.

interface UserRepository
{
public function save(User $user): void;
public function update(User $user): void;
}
// Обновление имени
$user = new User(
$request->get('email'),
$request->get('name'),
'WHAT DO WE DO WITH PASSWORD HERE?',
);

$repository->update($user);
// Обновление пароля
$user = new User(
$request->get('email'),
'WHAT DO WE DO WITH NAME HERE?',
$request->get('password'),
);

$repository->update($user);

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

Решение: Индивидуальная модель для каждого случая

Как решить все проблемы при получении пользователя? Для этого подойдёт специализированная модель.

final readonly class UserRead
{
public function __construct(
public string $email,
public string $name,
public int $commentCount,
) {}
}

У нас может быть другой репозиторий, позволяющий получить его.

interface UserReadRepository
{
public function get(string $email): UserRead;
}

Эта реализация, предполагающая реляционную базу данных SQL, не будет получать пароль из таблицы, которой нет в модели чтения, что решает проблему номер 1. Эта модель чтения не включает валидации, что решает проблему номер 2. И в этой модели есть место для подсчёта комментариев, что может быть реализовано в новом репозитории с помощью join в одном запросе, что решает проблему номер 3.

Более того, если есть несколько представлений пользователя, то для каждого из них должна быть своя модель чтения. Например, это может быть UserWithLastCommentsRead.

А что касается проблем с обновлениями? Вы, наверное, догадались. Отдельные модели для каждого обновления.

final readonly class UserDataUpdate
{
public function __construct(
public string $email,
public string $name,
) {
Assert::notEmpty($name);
}
}
final readonly class UserPasswordUpdate
{
public function __construct(
public string $email,
public string $password,
) {
Assert::password($password, strength: 3);
}
}
interface UserRepository
{
public function save(User $user): void;
public function updateData(UserDataUpdate $userDataUpdate): void;
public function updatePassword(UserPasswordUpdate $userPasswordUpdate): void;
}

Теперь нет ошибок и лишних данных. Каждое обновление изолировано, и гораздо лучше защищено от ошибок.

Обратите внимание, что в обновлённых моделях нет валидации электронной почты. Это сделано намеренно, потому что это будет использоваться для поиска пользователя, и если бы была развитая валидация, как уже говорилось, мы бы не смогли найти старых пользователей с электронной почтой, которая уже не валидна, но всё равно находится в базе данных.

Заключение

На самом деле это не так уж и отличается от того, как мы моделируем объекты в реальном мире. Мы никогда не рассматриваем всё о реальном объекте в конкретном контексте. Например, автомобиль.

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

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

Комментарии


Дополнительные материалы

Предыдущая Статья

Новое в Symfony 7.2: Атрибут WhenNot

Следующая Статья

Новое в Symfony 7.2: Новая опция choice_lazy для ChoiceType