В предыдущих главах я говорил о двух из трех основных строительных блоков каждого приложения: DTO и действия – данные и функциональность. В этой главе мы рассмотрим последний фрагмент, который я считаю частью этого ядра: обработка данных, которые сохраняются в хранилище данных; другими словами: модели.
Так вот, модели – это сложная тема. Laravel предоставляет множество функциональных возможностей через свои Eloquent классы моделей, а это значит, что они не только предоставляют данные в хранилище данных, но и позволяют создавать запросы, загружать и сохранять данные, имеют встроенную систему событий и многое другое.
В этой главе я не буду говорить вам, чтобы вы отказались от всех функциональных возможностей модели, предоставляемых Laravel, — это действительно очень полезно. Однако я назову несколько подводных камней, с которыми вам нужно быть осторожным, и как их решить; так что даже в крупных проектах модели не будут сложны в обслуживании.
Моя точка зрения заключается в том, что мы должны принять рамки, а не пытаться бороться с ними; хотя мы должны принять их таким образом, чтобы крупные проекты оставались ремонтопригодными. Давай начнем.
Модели – это не бизнес логика.
Первая ловушка, в которую попадают многие разработчики, заключается в том, что они думают о моделях как о месте, где должна быть бизнес-логика. Я уже перечислил выше несколько обязанностей моделей, встроенных в Laravel, и я бы посоветовал быть осторожным, чтобы не добавлять больше.
Поначалу это звучит очень привлекательно-иметь возможность делать что-то вроде $invoiceLine->price_including_vat
или $invoice->total_price
. Я действительно считаю, что счета-фактуры и записи счетов-фактур должны иметь эти методы. Однако есть одно важное различие: эти методы не должны ничего вычислять. Давайте посмотрим на то, чего не следует делать:
Вот total_price
аксессор в нашей модели счета-фактуры, циклически перебирающий все записи счета-фактуры и суммирующий их цены.
class Invoice extends Model
{
public function getTotalPriceAttribute(): int
{
return $this->invoiceLines
->reduce(function (int $totalPrice, InvoiceLine $invoiceLine) {
return $totalPrice + $invoiceLine->total_price;
}, 0);
}
}
А вот как рассчитывается общая цена на запись.
class InvoiceLine extends Model
{
public function getTotalPriceAttribute(): int
{
$vatCalculator = app(VatCalculator::class);
$price = $this->item_amount * $this->item_price;
if ($this->price_excluding_vat) {
$price = $vatCalculator->totalPrice(
$price,
$this->vat_percentage
);
}
return $price;
}
}
Поскольку вы читали предыдущую главу о действиях, вы можете догадаться, что я бы сделал вместо этого: расчет общей цены счета-фактуры-это пользовательский сценарий, который должен быть представлен действием.
Модели Invoice
и InvoiceLine
могут иметь простые total_price и price_including_vat
свойства, но они сначала вычисляются действиями, а затем хранятся в базе данных. При использовании $invoice->total_price
вы просто считываете данные, которые уже были вычислены ранее.
У такого подхода есть несколько преимуществ. Во-первых, очевидное: производительность, вы делаете вычисления только один раз, а не каждый раз, когда вам нужны данные. Во-вторых, вы можете запросить вычисленные данные напрямую. И третье: вам не нужно беспокоиться о побочных эффектах.
Теперь мы могли бы начать дебаты о том, как единая ответственность помогает сделать ваши классы маленькими, лучше ремонтопригодными и легко тестируемыми; и как инъекция зависимостей превосходит сервис локатор; но я скорее констатирую очевидное, а не веду долгие теоретические дебаты, где я знаю, что есть просто две стороны, которые не согласятся.
Итак, очевидное: даже если вы хотели бы иметь возможность делать $invoice->send()
или $invoice->toPdf()
, модельный код растет и растет. Это то, что происходит со временем, поначалу это не кажется чем-то большим. $invoice->toPdf()
на самом деле это может быть только одна или две строки кода.
Однако, исходя из опыта, эти одна или две линии складываются. Одна или две строки-это не проблема, но сто раз одна или две строки-это проблема. Реальность такова, что классы моделей растут с течением времени и могут вырасти довольно большими.
Даже если вы не согласны со мной в отношении преимуществ, которые приносит одна инъекция ответственности и зависимостей, с этим трудно не согласиться: класс модели с сотнями строк кода не становятся ремонтопригодным.
Все это говорит о следующем: думайте о моделях и их назначении как о том, чтобы предоставить вам только данные, пусть что-то другое будет связано с тем, чтобы убедиться, что данные рассчитаны правильно.
Масштабирование моделей
Если наша цель состоит в том, чтобы сохранить классы моделей достаточно маленькими — достаточно маленькими, чтобы легко понимать их, просто открыв файл, — нам нужно переместить еще несколько вещей. В идеале мы хотим сохранить только геттеры и сеттеры, простые аксессоры и мутаторы, касты и отношения.
Другие обязанности должны быть перенесены на другие классы. Один из примеров – query scopes: мы могли бы легко переместить их в выделенные классы построителя запросов.
Хотите верьте, хотите нет: классы построителя запросов на самом деле являются обычным способом использования Eloquent; scope – это просто синтаксический сахар поверх них. Именно так может выглядеть класс построителя запросов.
namespace Domain\Invoices\QueryBuilders;
use Domain\Invoices\States\Paid;
use Illuminate\Database\Eloquent\Builder;
class InvoiceQueryBuilder extends Builder
{
public function wherePaid(): self
{
return $this->whereState('status', Paid::class);
}
}
Далее мы переопределяем newEloquentBuilder
в нашей модели и возвращаем наш пользовательский класс. Отныне им будет пользоваться Laravel.
namespace Domain\Invoices\Models;
use Domain\Invoices\QueryBuilders\InvoiceQueryBuilder;
class Invoice extends Model
{
public function newEloquentBuilder($query): InvoiceQueryBuilder
{
return new InvoiceQueryBuilder($query);
}
}
Вот что я имел в виду, принимая фреймворк: вам не нужно вводить новые шаблоны, такие как репозитории, вы можете опираться на то, что предоставляет Laravel. Немного подумав, мы находим идеальный баланс между использованием возможностей, предоставляемых фреймворком, и предотвращением чрезмерного роста нашего кода в определенных местах.
Используя это мышление, мы также можем предоставить собственные классы коллекций для отношений. Laravel имеет большую поддержку коллекций, хотя вы часто используете длинные цепочки функций коллекции либо в модели, либо в прикладном слое. Это опять же не идеально, и, к счастью, Laravel предоставляет нам необходимые возможности для связывания логики коллекции в выделенный класс.
Вот пример пользовательского класса коллекции, и обратите внимание, что вполне возможно объединить несколько методов в новые, избегая длинных цепочек функций в других местах.
namespace Domain\Invoices\Collections;
use Domain\Invoices\Models\InvoiceLines;
use Illuminate\Database\Eloquent\Collection;
class InvoiceLineCollection extends Collection
{
public function creditLines(): self
{
return $this->filter(function (InvoiceLine $invoiceLine) {
return $invoiceLine->isCreditLine();
});
}
}
Именно так вы связываете класс коллекции с моделью InvoiceLine
в данном случае:
namespace Domain\Invoices\Models;
use Domain\Invoices\Collection\InvoiceLineCollection;
class InvoiceLine extends Model
{
public function newCollection(array $models = []): InvoiceLineCollection
{
return new InvoiceLineCollection($models);
}
public function isCreditLine(): bool
{
return $this->price < 0.0;
}
}
Каждая модель, имеющая HasMany
отношение к InvoiceLine
, теперь будет использовать наш класс коллекции вместо этого.
$invoice
->invoiceLines
->creditLines()
->map(function (InvoiceLine $invoiceLine) {
// …
});
Старайтесь, чтобы ваши модели были чистыми и ориентированными на данные, а не предоставляли бизнес-логику. Есть места получше, чтобы справиться с этим.
Итог
Я ценю, что Тейлор Отвелл также следит за этой серией блогов. На прошлой неделе он спросил, Как избежать того, чтобы наши объекты стали не более чем пустыми классами с данными, о чем писал Мартин Фаулер.
Поскольку Тейлор нашел время спросить автора этой статьи об этом в Твиттере, я решил, что могу также включить его ответ в эту главу, где все люди могут прочитать об этом.
Ответ — мой ответ-двоякий. Прежде всего: я не думаю о моделях как о пустых классах с простыми старыми данными. Используя методы доступа, мутаторы и касты, они обеспечивают богатый слой между обычными данными в базе данных и данными, которые разработчик хочет использовать. В этой главе я доказывал, что нужно перенести несколько других обязанностей в отдельные классы, это правда, но я считаю, что модели в их “урезанной” версии все еще предлагают гораздо большую ценность, чем простые пакеты данных, благодаря всей функциональности Laravel.
Во-вторых, я думаю, что стоит упомянуть видение Алана Кея на эту тему (именно он придумал термин ООП). Он сам сказал в этом выступлении, что сожалеет о том, что назвал парадигму “объектно-ориентированной”, а не “процессно-ориентированной”. Алан утверждает, что он на самом деле сторонник разделения процесса и данных.
Согласны вы с этой точкой зрения или нет, зависит только от вас. Я действительно признаю, что на меня повлияли некоторые идеи Алана, и вы можете заметить это на протяжении всей этой серии постов. Как я уже говорил, Не думайте об этой серии как о Святом Граале разработки программного обеспечения. Моя цель-бросить вызов нынешнему способу написания кода, заставив вас задуматься, существуют ли более оптимальные способы решения некоторых ваших проблем.
Так что давайте обязательно продолжим дискуссию, вы можете написать об этом по почте или обсудить это в Твиттере автору этой серии статей.
Перевод https://stitcher.io/blog/laravel-beyond-crud-04-models