ORM и слой персистентности
Слой персистентности на Eloquent и Doctrine — Active Record против Data Mapper, миграции, проблема N+1 и её обнаружение в продакшене, ленивая и жадная загрузка, массовое присваивание, сидеры и фабрики, а также вопрос о том, оправдан ли репозиторий поверх ORM.
10 вопросов
JuniorТеорияОчень частоЧто означает, что ORM Laravel Eloquent реализует Active Record?
Что означает, что ORM Laravel Eloquent реализует Active Record?
Active Record означает, что класс модели и есть строка таблицы: User отображается на таблицу users, экземпляр хранит одну строку, и этот же объект сам себя сохраняет — User::find(1), $user->save(), $user->delete(). Данные и доступ к БД живут в одном классе.
Типичные ошибки
- ✗Путать
Active RecordсData Mapper, где обычный объект сохраняет отдельный менеджер - ✗Думать, что модель лишь описывает таблицу, а сохраняет её что-то другое
- ✗Считать, что
Active Record-модель можно чисто протестировать без базы данных
Уточняющие вопросы
- →Чем оборачивается связь модели с персистентностью при юнит-тестах бизнес-правил?
- →Как удержать доменную логику вне
Eloquent-модели, которая уже несёт персистентность?
JuniorТеорияОчень частоЧем ленивая загрузка связи в ORM отличается от жадной?
Чем ленивая загрузка связи в ORM отличается от жадной?
Ленивая загрузка отправляет запрос в тот момент, когда вы впервые трогаете связь: чтение $post->comments идёт в базу прямо там. Жадная загрузка тянет связь заранее вместе с родителем — Post::with('comments')->get() стоит всего двух запросов, сколько бы постов ни вернулось.
Типичные ошибки
- ✗Считать, что жадная загрузка всегда добавляет
JOIN, а не второй запросWHERE … IN (…) - ✗Думать, что ленивая загрузка бесплатна, раз запрос не виден до чтения свойства
- ✗Жадно грузить все связи подряд, вытягивая мегабайты, которые страница не рисует
Уточняющие вопросы
- →Что меняет
Model::preventLazyLoading()и когда его стоит включить? - →Когда ленивая загрузка — верный выбор и чем в этом случае обходится жадная?
JuniorТеорияЧастоЧто такое миграция базы данных и зачем держать изменения схемы в репозитории?
Что такое миграция базы данных и зачем держать изменения схемы в репозитории?
Миграция — это версионированный класс, изменяющий схему: up() применяет изменение, down() откатывает его. Фреймворк записывает уже выполненные файлы в таблицу migrations, поэтому php artisan migrate проигрывает только новые и приводит любую базу к той форме, которую ждёт выкатываемый код.
Типичные ошибки
- ✗Думать, что ORM сравнивает модели с живой схемой, а не проигрывает упорядоченные файлы
- ✗Забывать, что именно таблица
migrationsзаставляет повторный запуск пропустить уже применённое - ✗Писать
down(), который на деле не откатываетup(), из-за чего откат ломает схему
Уточняющие вопросы
- →Почему миграция не должна импортировать класс модели приложения для чтения или записи данных?
- →Что ломается, когда двое разработчиков сливают миграции с переплетёнными таймстампами?
JuniorТеорияЧастоЧем сидер отличается от фабрики модели в Laravel?
Чем сидер отличается от фабрики модели в Laravel?
Фабрика — это чертёж для одной модели, собирающий экземпляр с правдоподобными фейковыми полями: User::factory()->count(10)->create(). Сидер — запускаемый класс, наполняющий базу, обычно как раз через фабрики. Фабрика — рецепт одной модели; сидер решает, что и в каком объёме вставить.
Типичные ошибки
- ✗Менять роли местами — называть чертёж фейковых полей сидером
- ✗Не знать, что
make()строит несохранённый экземпляр, аcreate()пишет его в базу - ✗Наполнять продакшен справочными данными через фабрику со случайными фейковыми значениями
Уточняющие вопросы
- →Когда справочные данные стоит засеять реальными, а не сгенерировать фабрикой?
- →Как состояния фабрики позволяют одному чертежу дать и админа, и заблокированного пользователя?
MiddleТеорияЧастоЧем ORM-подход Data Mapper в Doctrine отличается от Active Record в Eloquent?
Чем ORM-подход Data Mapper в Doctrine отличается от Active Record в Eloquent?
Data Mapper отделяет сущность от персистентности: сущность Doctrine — обычный PHP-объект без кода работы с базой, а отображает его в таблицу отдельный EntityManager при persist() и flush(). Eloquent эти две роли сливает. Размен — скорость написания против доменной модели, тестируемой без базы.
Типичные ошибки
- ✗Думать, что сущность
Doctrineноситsave(), то есть персистентность всё равно на объекте - ✗Сводить различие к синтаксису, упуская, что
Data Mapperпокупает тестируемость домена - ✗Считать
Data Mapperстрого лучшим, игнорируя, насколько больше он требует церемоний
Уточняющие вопросы
- →Что делает unit-of-work в
Doctrineмеждуpersist()иflush()? - →На CRUD-ориентированной админке чем ORM-подход
Data Mapperобходится без всякой выгоды?
MiddleТеорияЧастоПочему Eloquent требует $fillable или $guarded перед Model::create($request->all())?
Почему Eloquent требует $fillable или $guarded перед Model::create($request->all())?
Потому что create($request->all()) запишет любые ключи, которые окажутся в запросе. Без белого списка атакующий пошлёт лишний is_admin=1 и массово присвоит колонку, которую вы не открывали, — это over-posting, настоящая эскалация привилегий. $fillable задаёт разрешённые колонки, $guarded — запрещённые.
Типичные ошибки
- ✗Считать
$fillableдеталью отображения или валидации, а не границей безопасности - ✗Ставить
$guarded = [], чтобы заглушить исключение, тем самым открывая все колонки - ✗Полагать, что колонку, которой нет в форме, нельзя прислать — тело запроса задаёт клиент
Уточняющие вопросы
- →Чем
$guardedрискованнее$fillable, когда кто-то позже добавит новую колонку? - →Как form request или явный DTO снимает необходимость доверять
$request->all()?
MiddleТеорияЧастоВ чём состоит проблема запросов N+1 и как жадная загрузка её устраняет?
В чём состоит проблема запросов N+1 и как жадная загрузка её устраняет?
Один запрос грузит N родителей; затем обращение к ленивой связи внутри цикла шлёт ещё по запросу на каждого — итого 1 + N походов в базу. Жадная загрузка это схлопывает: Post::with('comments')->get() даёт один запрос за постами и один WHERE post_id IN (…) за всеми комментариями — цена остаётся два.
Типичные ошибки
- ✗Винить базу или пул соединений вместо ленивой загрузки внутри цикла
- ✗Думать, что
with()добавляетJOIN, а не шлёт второй запросWHERE … IN (…) - ✗Чинить одну связь через
with(), пока вложенная связь продолжает стрелять на каждой строке
Уточняющие вопросы
- →Как жадно загрузить вложенную связь и во что обходится
with('comments.author')? - →Когда проекция через
select()— лучшее решение, чем жадная загрузка всей связи?
MiddleПроизводительностьИногдаСтраница со списком тормозит по мере роста. Как доказать, что причина — проблема лишних запросов N+1?
Страница со списком тормозит по мере роста. Как доказать, что причина — проблема лишних запросов N+1?
Нужно измерять, а не гадать. Включите лог запросов — Laravel Debugbar, Telescope или DB::listen() — и посчитайте запросы на страницу: N+1 виден как один и тот же SELECT, повторённый с меняющимся только id, и число повторов растёт с числом строк. Фикс подтверждайте повторным замером числа запросов, а не временем.
Типичные ошибки
- ✗Рассуждать по одному лишь код-ревью вместо подсчёта запросов, которые шлёт реальный запрос
- ✗Искать в логе медленных запросов, где N быстрых запросов поодиночке не перешагивают порог
- ✗Объявлять победу по времени на dev-датасете, слишком маленьком, чтобы N что-то значил
Уточняющие вопросы
- →Как ловить
N+1в CI до выката, а не уже в продакшене? - →Почему число запросов — лучший сигнал регрессии здесь, чем время обработки по часам?
MiddleДизайнИногдаУ вашей команды Laravel-приложение, где контроллеры и джобы обращаются к моделям Eloquent напрямую — User::where(...)->get() встречается в десятках мест. Коллега предлагает спрятать каждую модель за абстракцию доступа к данным Repository: интерфейс вида UserRepositoryInterface с методами findActive(), привязанный в сервис-провайдере к EloquentUserRepository. Заявленные цели — тестируемость и возможность позже заменить Eloquent на Doctrine. Аргументируйте за или против внедрения этого слоя. Разберите, что он реально даёт, держится ли довод о замене ORM для кодовой базы на Active Record, чего он стоит в лишней косвенности и потерянной выразительности построителя запросов и при каких условиях вы бы его всё-таки ввели.
У вашей команды Laravel-приложение, где контроллеры и джобы обращаются к моделям Eloquent напрямую — User::where(...)->get() встречается в десятках мест. Коллега предлагает спрятать каждую модель за абстракцию доступа к данным Repository: интерфейс вида UserRepositoryInterface с методами findActive(), привязанный в сервис-провайдере к EloquentUserRepository. Заявленные цели — тестируемость и возможность позже заменить Eloquent на Doctrine. Аргументируйте за или против внедрения этого слоя. Разберите, что он реально даёт, держится ли довод о замене ORM для кодовой базы на Active Record, чего он стоит в лишней косвенности и потерянной выразительности построителя запросов и при каких условиях вы бы его всё-таки ввели.
Довод о замене ORM почти фиктивен: модели Eloquent протекают через возвращаемые типы, и абстракция никогда не выходит чистой. Что репозиторий реально даёт — именованный тестируемый шов: намерение запроса живёт в одном месте и подменяется в тестах. Честное правило: вводить там, где логика запросов правда переиспользуется или сложна, а не сплошным слоем.
Типичные ошибки
- ✗Продавать слой гипотетической сменой ORM, которую модель на
Active Recordвсё равно делает невозможной - ✗Писать репозитории-прокладки, повторяющие модель метод в метод и не дающие ничего
- ✗Заявлять, что
Eloquentнетестируем — тест поверх базы или фейк в памяти работают и так
Уточняющие вопросы
- →Если репозиторий возвращает модели
Eloquent, в каком смысле ORM вообще абстрагирована? - →Как подход с query-объектом или read-моделью решит ту же задачу с меньшей косвенностью?
SeniorДизайнИногдаИз таблицы users на 40 миллионов строк нужно убрать колонку full_name и добавить first_name и last_name. Приложение крутится на нескольких серверах за балансировщиком, деплой катится постепенно, а в таблицу непрерывно пишут. Одна миграция с переименованием и бэкфиллом залочит таблицу и заодно сломает те серверы, где ещё работает старый код. Спроектируйте последовательность миграции, которая ни разу не уронит сервис. Разберите порядок выкатки схемы и кода, как старый и новый код уживаются на одной схеме, как забэкфиллить 40 миллионов строк без блокировки и как в итоге безопасно убрать старую колонку — включая то, что вы сделаете, если бэкфилл придётся прервать на середине.
Из таблицы users на 40 миллионов строк нужно убрать колонку full_name и добавить first_name и last_name. Приложение крутится на нескольких серверах за балансировщиком, деплой катится постепенно, а в таблицу непрерывно пишут. Одна миграция с переименованием и бэкфиллом залочит таблицу и заодно сломает те серверы, где ещё работает старый код. Спроектируйте последовательность миграции, которая ни разу не уронит сервис. Разберите порядок выкатки схемы и кода, как старый и новый код уживаются на одной схеме, как забэкфиллить 40 миллионов строк без блокировки и как в итоге безопасно убрать старую колонку — включая то, что вы сделаете, если бэкфилл придётся прервать на середине.
Сначала расширение, потом сжатие. Первой катится аддитивная миграция: добавить новые nullable-колонки и больше ничего. Затем код, который пишет в обе формы, а читает старую, — любой сервер жив на любой схеме. Бэкфилл идёт батчами с троттлингом, чтение переключается на новые колонки, и только затем катится сжимающая миграция, удаляющая full_name.
Типичные ошибки
- ✗Катить схему и код одним шагом, из-за чего часть серверов работает с несовпадающей схемой
- ✗Бэкфиллить 40 миллионов строк одним
UPDATE, который лочит таблицу на минуты - ✗Удалять старую колонку тем же релизом, что перестал в неё писать, не оставив пути отката
Уточняющие вопросы
- →Почему бэкфилл должен идти джобой или командой, а не внутри самого файла миграции?
- →Как сделать батчевый бэкфилл возобновляемым после прерывания на середине?