Laravel: Применяем принципы SOLID
Во-первых, давайте обсудим, что означает SOLID.
- Single-responsibility principle — Принцип единой ответственности
- Open-closed principle — Принцип Открытости/Закрытости
- Liskov substitution principle — Принцип подстановки Барбары Лисков
- Interface segregation principle — Принцип разделения интерфейса
- Dependency inversion principle — Принцип инверсии зависимости
Single-Responsibility Principle / Принцип единой ответственности
У каждого класса должна быть только одна причина для изменения.
Трудно определить, что такое причина
, и это вызывает некоторую путаницу, но обычно это связано с ролями. У пользователей разные роли. Предположим, мы работаем над приложением используемым финансовыми экспертами. Пользователям нужны отчёты. Очевидно, что бухгалтер хочет видеть совсем другие отчёты и графики, чем финансовый директор. Они оба отчёты, но используются в разных ролях. Вероятно, не стоит смешивать код в одном классе Report
. Этот класс может измениться по разным причинам.
Другой, может быть более очевидный пример — данные и представление. Обычно, они меняются по разным причинам. Следовательно, будет безопаснее отделить уровень запроса от уровня представления. Что в настоящее время является отраслевым стандартом де-факто и одной из причин популярности API+SPA. Но так было не всегда. Но, мы можем сделать шаг назад, потому что до сих пор есть бесчисленной множество примеров, когда разработчики смешивают эти две вещи в Laravel.
Рассмотрим этот гипотетический (и упрощённый) пример:
class UserResource extends JsonResource
{
public function toArray($request)
{
$mostPopularPosts = $user->posts()
->where('like_count', '>', 50)
->where('share_count', '>', 25)
->orderByDesc('visits')
->take(10)
->get();
return [
'id' => $this->id,
'full_name' => $this->full_name,
'most_popular_posts' => $mostPopularPosts,
];
}
}
Не могу сосчитать, сколько раз видел что-то подобное. Это современный пример смешивания слоя данных и слоя представления в одном классе. Когда видим легаси проект состоящий из одного PHP файла с HTML, PHP и MySQL, мы плачем от боли. Конечно, этот ресурс намного лучше, но на самом деле у него схожие проблемы:
- Массив похож на HTML. Это представление данных.
- Eloquent запрос похож на MySQL. Это слой запроса.
- И весь класс на PHP.
В конце концов, мы смешиваем множество вещей всего в 20 строках кода.
Хорошо, но это всё теория. Что в этом такого особенного?
Вот некоторые проблемы, которые могут возникнуть:
- PM скажет
Можем ли мы изменить определение «самых популярных сообщений»?
После такого запроса я бы не подумал, что мне нужно изменять классUserResource
. Это не логично. Этот класс не должен изменяться из-за этого запроса. - Вам понадобится этот ресурс во многих местах приложения. Но такая информация, как самые популярные сообщения, обычно отображается всего на нескольких страницах. Это расточительно.
- Этот простой запрос может быть источником N+1 запросов и других проблем с производительностью.
К счастью, исправить это довольно просто:
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'full_name' => $this->full_name,
'most_popular_posts' => $this->when(
$request->most_popular_posts,
$this->mostPopularPosts,
),
];
}
}
class User extends Posts
{
/**
* @return Collection<Post>
*/
public function mostPopularPosts(): Collection
{
return $this->posts()
->where('like_count', '>', 50)
->where('share_count', '>', 25)
->orderByDesc('visits')
->take(10)
->get();
}
}
Конечно, в этом примере я ничего не предполагаю об общей архитектуре проекта. Вы можете писать сервисы, action, Query Builder, scope, репозитории или что угодно. Вы можете возразить, что этот запрос должен быть в модели Post
.
Мы можем сдать что-то вроде:
Post::mostPopularBy($user);
Или использовать scope:
$user->posts()->mostPopularOnes();
Я также думаю, что это лучшее решение. Если PM говорит: Можем ли мы изменить определение «самых популярных сообщений»?
Я знаю, что мне нужно посмотреть модель Post
. И, конечно же, обычно User
первым переходит в легаси
режим. Через шесть месяцев. Итак, вы видите, что такой простой запрос может вызвать некоторую путаницу и споры. Или даже (часто) религиозные войны.
Вот почему, я предпочитаю использовать одноразовые
action. И я думаю, именно поэтому они становятся всё более и более популярными в Laravel сообществе. Это выглядит так:
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'full_name' => $this->full_name,
'most_popular_posts' => $this->when(
$request->most_popular_posts,
GetMostPopularPosts::execute($user),
),
];
}
}
class GetMostPopularPosts
{
/**
* @return Collection<Post>
*/
public static function execute(User $user): Collection
{
return $user->posts()
->where('like_count', '>', 50)
->where('share_count', '>', 25)
->orderByDesc('visits')
->take(10)
->get();
}
}
Вы можете использовать нестатические функции или вызываемые классы, выбор за вами. Все они великолепны и пригодны для тестирования, поэтому я думаю, что это вопрос предпочтений. Но видите ли вы, насколько мы улучшили архитектуру
с точки зрения единой ответственности?
Теперь у нас есть два чётко определённых класса:
UserResource
отвечает только за представление и у него есть одна причина для изменения.GetMostPopularPosts
отвечает только за запрос и у него есть одна причина для изменения.
Несколько типичных признаков нарушения SPR:
- Запрос к базе данных в простых
данных
или классах представлений, таких как запросы, ответы, DTO, объекты значений, письма, и уведомления. Каждый раз, когда вы записываете бизнес-логику в эти классы, это может быть плохой практикой. - Отправка Заданий или Команд от Моделей. Обычно вы не хотите совмещать эти вещи вместе. Лучше использовать Событие или отправить задание из Контроллера, или использовать Action. Модели не должны классами оркестрации запускающие длительные процессы.
- Неправильные зависимости. Это нарушает множество других принципов, но обычно является тревожным звоночком с точки зрения SRP. Под неправильной зависимостью я подразумеваю Модель использующую HTTP запрос или ответ. В этом случае связывается транспортный уровень (HTTP) с уровнем данных (Модель). Вот несколько примеров, которые я считаю
неправильными
зависимостями:
Этот класс | Зависит от этих |
---|---|
Model | HTTP, Job, Command, Auth |
Job | HTTP |
Command | HTTP |
Mail/Notification | HTTP, Job, Command |
Service | HTTP |
Repository | HTTP, Job, Command |
Конечно, это просто чрезмерно обобщённые примеры. Обычно это зависит от вашего конкретного проекта/класса.
Open-Closed Principle / Принцип Открытости/Закрытости
Класс должен быть открыт для расширения, но закрыт для модификации.
Я знаю, звучит странно. Пожалуйста, не смотрите страницу в Википедии, потому что это становится ещё более странным. Итак, позвольте мне показать пример.
Допустим мы работаем над социальным приложением. У нас есть пользователи, публикации, комментарии и лайки. Пользователи могут публиковать публикации, поэтому вы реализуете эту функции в Модели Post
. Легко. Но теперь пользователи хотят лайкать комментарии. Есть два варианта:
- Скопировать ориентированный на лайки функционал в Модель
Comment
. - Реализовать общий трейт, который можно использовать в любой модели.
Конечно, нам нужен второй вариант. Это выглядит примерно так:
trait Likeable
{
public function like(): Like
{
// ...
}
public function dislike(): void
{
// ...
}
public function likes(): MorphMany
{
// ...
}
public function likeCount(): int
{
return $this->likes()->count();
}
}
class Post extends Model
{
use Likeable;
}
class Comment extends Model
{
use Likeable;
}
Теперь, допустим, нам нужно добавить в приложение чат, и, конечно, пользователи хотят лайкать сообщения. Итак, мы делаем это:
class ChatMessage extends Model
{
use Likeable;
}
Выглядит довольно стандартно, верно? Но подумайте, что здесь произошло. Мы просто добавили новую функциональность в несколько классов не меняя их! Мы расширили наши классы вместо модификации. И это огромная победа в долгосрочной перспективе. Вот почему трейты и полиморфизм в целом — замечательные инструменты.
Давайте рассмотрим другой пример использующий полиморфизм и интерфейсы. Допустим, мы работаем над приложением похожим на DoorDash. В нём есть разные продукты и вариации. Например, пользователи могут заказать небольшую порцию или большую порции куриного супа. Обе имеют разную цену. Они также могут заказать пиццу с различным топпингом, которые влияют на цену. Но они могут заказать стандартную еду, такую как чизбургер без каких-либо модификаций.
Вот упрощённая структура базы данных:
products:
id | name | price | price_type |
---|---|---|---|
1 | Куриный суп | 7 | has_batches |
2 | Пицца Маргарита | 15 | has_topping |
3 | Чизбургер | 12 | standard |
product_batches: эта таблица содержит изменения цен для разны размеров порций.
id | product_id | name | price |
---|---|---|---|
1 | 1 | Большая | 12 |
Итак, маленькая порция куриного супа стоит 7$, а большая — 12$ из-за записи product_batches.price
.
Когда клиенты заказывают еду, нам нужно создать Order
и OrderItems
на эти продукты:
orders: эта таблица не так важна для нашей цели, оставим её довольно простой.
id | total_price | created-at |
---|---|---|
1 | 38 | 2023-01-08 14:42 |
order_items:
id | order_id | product_id | product_batch_id | price |
---|---|---|---|---|
1 | 1 | 1 | 1 | 9 |
2 | 1 | 2 | 17 | |
3 | 1 | 3 | 12 |
toppings:
id | name | price |
---|---|---|
1 | Сыр | 1 |
2 | Грибы | 1 |
order_item_topping : это сводная таблица связывающая продукт из order_item
и toppings
id | order_item_id | topping_id |
---|---|---|
1 | 2 | 1 |
2 | 2 | 2 |
Мы можем определить цены используя различные вычисления:
- Куриный суп:
products.price
+product_batches.extra_price
- Пицца
Маргарита
:products.price
+ сумма цен топпинга на основе таблицыorder_item_topping
- Чизбургер:
products.price
Конечно, это всего лишь гипотетический и упрощённый пример, но технические детали пока не так важны, для наших целей он подходит.
Теперь давайте посмотрим как можно рассчитать цены:
class PriceCalculatorService
{
public function calculatePrice(Order $order): float
{
return $order->items()
->reduce((float $sum, OrderItem $item) {
switch ($item->product->type) {
case 'standard':
return $item->product->price;
case 'has_batches':
return $item->product->price +
$item->product_batch->price;
case 'has_toppings':
$toppingsSum = $item->toppings
->reduce(function ($sum, Topping $topping) {
return $sum + $topping->price;
}, 0);
return $item->product->price + $toppingsSum;
}
}, 0);
}
}
Это не так уж и плохо, но есть два существенных недостатка:
- Если вы создаёте приложение, подобное DoorDash, только представьте, сколько раз нужно повторить оператор
switch
. Если есть другие вещи зависящие от типа продукта (а их с десяток!) становится ещё хуже. - Что произойдёт когда, будет нужно обрабатывать новый тип продукта? Нужно будет изменить все эти операторы
switch
. И это минимум, который нужно будет сделать. Обычно это гораздо большая проблема в проекте, который не полагается на полиморфизм и принцип открытости/закрытости.
Другими словами: эта архитектура нарушает принцип открытости/закрытости. Она абсолютно не расширяема, но требует изменений существующих (и, вероятно, неприятных) классов каждый раз, когда появляется новое требование.
Давайте проведём рефакторинг с использованием OCP и полиморфизма. Во-первых, нам нужна иерархия классов, представляющая различные типы цен:
abstract class PriceType
{
public function __construct(
protected readonly OrderItem $orderItem
) {}
abstract public function calculatePrice(): float;
}
class StandardPriceType extends PriceType
{
public function calculatePrice(): float
{
return $this->orderItem->product->price;
}
}
class HasBatchesPriceType extends PriceType
{
public function calculatePrice(): float
{
return $this->orderItem->product->price +
$this->orderItem->product_batch->price;
}
}
class HasToppingsPriceType extends PriceType
{
public function calculatePrice(): float
{
$toppingsSum = $this->orderItem->toppings
->reduce(function (float $sum, Topping $topping) {
return $sum + $topping->price;
}, 0);
return $this->orderItem->product->price + $toppingsSum;
}
}
Эти классы могут вычислять цену одного OrderItem
, у которого есть Product
. Нам нужен лёгкий способ создавать эти классы. Здесь может быть полезен шаблон проектирования
Фабрика:
class PriceTypeFactory
{
public function create(OrderItem $orderItem): PriceType
{
switch ($orderItem->product->type)
{
case 'standard':
return new StandardPriceType($orderItem->product);
case 'has_batches':
return new HasBatchesPriceType($orderItem->product);
case 'has_toppings':
return new HasToppingsPriceType($orderItem->product);
}
}
}
А теперь нам нужен способ создать эти классы в Модели. Аксессор атрибутов — отличный выбор:
class OrderItem extends Model
{
public function priceType(): Attribute
{
return new Attribute(
get: fn () => (new PriceTypeFactory())
->create($this),
);
}
}
И, наконец, мы можем переписать класс PriceCalculator
:
class PriceCalculatorService
{
public function calculatePrice(Order $order): float
{
return $order->items
->reduce(function (float $sum, OrderItem $item) {
return $sum + $item->price_type->calculatePrice();
}, 0);
}
}
Видите, что мы сделали? Мы просто исключили каждый оператор switch
из всего приложения и заменили их на отдельные классы и простую фабрику. Что происходит, когда появляется новый тип продукта?
- Нужно добавить новый класс, расширяющий абстрактный класс
PriceType
- нужно добавить новый класс в
PriceTypeFactory
Или другими словами: вместо того, чтобы всё менять, мы можем расширить существующие классы новой функциональностью.
Всё что было нужно — фабрика, несколько классов стратегии и немного полиморфизма. Конечно, сейчас мы говорим только о ценах зависящих от типа товара, но обычно есть и другие вещи. Всё, что нужно сделать, это повторить
этот процесс и ввести другую иерархию классов.
Ну и, конечно же, мы крутые ребята, так что давайте модернизируем фабрику:
class PriceTypeFactory
{
public function create(OrderItem $item): PriceType
{
return match ($item->product->type) {
'standard' => new StandardPriceType($item->product),
'has_batches' => new HasBatchesPriceType($item->product),
'has_toppings' => new HasToppingsPriceType($item->product),
};
}
}
Или, что ещё лучше, мы можем избавиться от всего этого с помощью магических строк и использовать перечисление, которое может вести себя как фабрика:
enum PriceTypes: string
{
case Standard = 'standard';
case HasBatches = 'has_batches';
case HasToppings = 'has_toppings';
public function create(OrderItem $item): PriceType
{
return match ($this) {
self::Standard => new StandardPriceType($item),
self::HasBatches => new HasBatchesPriceType($item),
self::HasToppings => new HasToppingsPriceType($item),
};
}
}
Аксессор атрибутов должен выглядеть так:
class OrderItem extends Model
{
public function priceType(): Attribute
{
return new Attribute(
get: fn () => PriceTypes::from(
$this->product->price_type
)->create($this),
);
}
}
Liskov Substitution Principle / Принцип подстановки Барбары Лисков
Каждый базовый класс может быть заменён его подклассами.
Звучит очевидно, и я думаю, что это самый простой принцип для соблюдения. Однако есть несколько важных вещей.
Принцип гласит, что если у вас есть базовый класс и несколько подклассов, вы сможете без проблем заменить базовый класс подклассами в любом месте вашего приложения.
Рассмотрим этот сценарий:
abstract class EmailProvider
{
abstract public function addSubscriber(User $user): array;
/**
* @throws Exception
*/
abstract public function sendEmail(User $user): void;
}
class MailChimp extends EmailProvider
{
public function addSubscriber(User $user): array
{
// Using MailChimp API
}
public function sendEmail(User $user): void
{
// Using MailChimp API
}
}
class ConvertKit extends EmailProvider
{
public function addSubscriber(User $user): array
{
// Using ConvertKit API
}
public function sendEmail(User $user): void
{
// Using ConvertKit API
}
}
У нас есть абстрактный EmailProvider
и по какой-то причине мы используем и MailChimp
и ConvertKit
. Эти классы должны вести себя одинаково, несмотря ни на что.
Итак, если есть контроллер добавляющий нового подписчика:
class AuthController
{
public function register(
RegisterRequest $request,
EmailProvider $emailProvider
) {
$user = User::create($request->validated());
$subscriber = $emailProvider->addSubscriber($user);
}
}
Должна быть возможность использовать любой из этих классов без каких-либо проблем. Не имеет значения, является ли текущий EmailProvider
MailChimp
или ConvertKit
. Так же должна быть возможность переключать аргумент:
public function register(
RegisterRequest $request,
ConvertKit $emailProvider
) {}
Звучит очевидно, однако есть важные, которые необходимо выполнить:
- Сигнатуры одного и того же метода. В PHP мы не обязаны использовать типы, поэтому может случиться так, что метода
addSubscriber
разные типы вMailChimp
иConvertKit
. - Это верно и для возвращаемых типов. Конечно, мы можем ввести их, но как насчёт массива или Коллекции Laravel. Нет гарантии, что массив содержит одни и те же типы в нескольких классах, верно? Как видите, метод
addSubscriber
возвращает массив, содержащий данные о подписчике полученные от API. ИMailChimp
иConvertKit
возвращают разные данные. Да, это массивы, но это совершенно разные структуры данных. Я не могу быть на 100% уверенным, чтоRegisterController
правильно работает с любой реализацией провайдера электронной почты. Вот почему рекомендуется использовать DTO при работе со сторонними данными. - Одинаковые исключения должны быть выброшены из каждого метода. Поскольку исключения не могут быть указаны в сигнатуре типа, это тоже является источником различий этих классов.
Как видите принцип довольно прост, но легко ошибиться.
Interface Segregation Principle / Принцип разделения интерфейса
У вас должно быть много маленьких интерфейсов вместо нескольких огромных.
Оригинальны принцип звучит так: ни один код не должен зависеть от неиспользуемых методов, но практическое значение — это определение, которое я вам дал. Честно говоря, это самый простой принцип. В примере с DashDoor (см. главу б открытости/закрытости) продукты имеют тип, например:
- Standard
- Has Batches
- Has topping
- и т.д.
У нас был отдельный класс для расчёта цен для этих типов. В реальном мире цена — не единственное, что зависит от типа. Есть и другие вещи, такие как:
- Отчёты
- Представление данных
- Управление запасами
- Расчёт налогов и НДС
- Сведения о квитанции
В оригинальном примере у были следующие классы:
ProductPriceType
StandardProductPrice
HasBatchesProductPrice
HasToppingsProductPrice
Каждый из них обрабатывает расчёт цены для определённого типа продукта. Представьте, что мы пишем некий общий класс ProductType
, например:
ProductType
StandardProduct
HasBatchesProduct
HasToppingsProductPrice
И мы пытаемся обрабатывать всё в этих классах. Итак, у них есть такие функции:
interface ProductType
{
public function calculatePrice(Product $product): float;
public function decreaseInventory(Product $product): void;
public function calculateTaxes(Product $product): TaxData;
// ...
}
Думаю, вы видите, в чём проблема. Этот интерфейс слишком большой. Он обрабатывает слишком много вещей. Вещей, которые не зависят друг от друга. Поэтому вместо одного огромного интерфейса для обработки всего, разделим эти обязанности на более мелкие:
interface ProductPriceType
{
public function calculatePrice(Product $product): float;
}
interface ProductInventoryHandler
{
public function decreaseInventory(Product $product): void;
}
interface ProductTaxType
{
public function calculateTaxes(Product $product): TaxData;
}
Другим отличным примером это являются PHP трейты и то как фреймворки, сторонние пакеты и сообщество используют их:
class Broadcast extends Model implements Sendable
{
use WithData;
use HasUser;
use HasAudience;
use HasPerformance;
}
Каждый из этих трейтов имеет довольно небольшой и чётко определённый интерфейс, и добавляет классу небольшую часть функциональности. То же самое касается интерфейса Sendable
.
Dependency Inversion Principle / Принцип инверсии зависимости
Зависьте от абстракций, а не от конкретики.
Всякий раз, когда у вас есть родительский класс и один или несколько подклассов, нужно использовать родительский класс в качестве зависимости. Например:
abstract class MarketDataProvider
{
abstract public function getPrice(string $ticker): float;
}
class IexCloud extends MarketDataProvider
{
abstract public function getPrice(string $ticker): float
{
// Using IEX API
}
}
class Finnhub extends MarketDataProvider
{
abstract public function getPrice(string $ticker): float
{
// Using Finnhub API
}
}
На данный момент должно быть довольно ясно, что мы хотим сделать что-то вроде этого:
class CompanyController
{
public function show(
Company $company,
MarketDataProvider $marketDataProvider
) {
$price = $marketDataProvider->getPrice();
return view('company.show', compact('company', 'price'));
}
}
Таким образом, каждый класс должен зависеть от абстрактного MarketDataProvider
, а не от конкретной реализации.
По моему мнению, важно иметь эти абстракции, даже если у вас есть только одна реализация, когда речь идёт о сторонних провайдерах. Причина в том, что эти сервисы и провайдеры меняются, и вы не знаете, что произойдёт в будущем. Просто расскажу несколько примеров:
- Давным-давно я использовал облако IEX в финансовом приложении. Я думал, что IEX API — единственная постоянная вещь в этом проекте. Это было здорово, это было стабильно и т.д. Пока они не перешли с ежемесячной подписки на оплату по факту использования (если я правильно помню). По сути, они в три раза повышают наши расходы. В проекте, у которого ещё не было дохода. Поэтому мы перешли на Finnhub. Но, конечно, у нас не было правильных абстракций, так что это была та ещё заноза в заднице.
- Я использую Gumroad с тех пор, как начал публиковать контент. Я думал, что никогда не буду использовать какую-либо другую платформу. До этой книги. Она работает на Paddle. Gumroad двукратно увеличили цены, а Paddle на 100% лучше с точки зрения бухгалтерского учёта.
- Я всегда считал Stripe лучшим платёжным провайдером во вселенной. Пока не попробовал Paddle. Теперь это моё любимое решение, и я использую его в нескольких проектах.
- Давным-давно MailChimp был для меня стандартным поставщиком почтовых услуг. Теперь я почти исключительно использую ConvertKit.
- Мы использовали Azure на моём нынешнем рабочем месте, пока не получили бесплатные кредиты от Google, которые покроют наши расходы в течении следующих двух лет.
Услуги и поставщики меняются, и вы должны быть в состоянии справиться с этими изменениями с минимальными усилиями. Минимум усилий означает абстракцию над конкретными классами, которые можно переключать без изменения кода.