Laravel: Применяем принципы SOLID

Источник: «SOLID Principles with Laravel»
SOLID. Звучит по-научному, не так ли? Но это просто маркетинг. На самом деле это самая простая вещь во вселенной. Набор принципов популяризованных Робертом С. Мартином.

Во-первых, давайте обсудим, что означает SOLID.

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, мы плачем от боли. Конечно, этот ресурс намного лучше, но на самом деле у него схожие проблемы:

В конце концов, мы смешиваем множество вещей всего в 20 строках кода.

Хорошо, но это всё теория. Что в этом такого особенного? Вот некоторые проблемы, которые могут возникнуть:

К счастью, исправить это довольно просто:

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();
}
}

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

Теперь у нас есть два чётко определённых класса:

Несколько типичных признаков нарушения SPR:

Этот классЗависит от этих
ModelHTTP, Job, Command, Auth
JobHTTP
CommandHTTP
Mail/NotificationHTTP, Job, Command
ServiceHTTP
RepositoryHTTP, Job, Command

Конечно, это просто чрезмерно обобщённые примеры. Обычно это зависит от вашего конкретного проекта/класса.

Open-Closed Principle / Принцип Открытости/Закрытости

Класс должен быть открыт для расширения, но закрыт для модификации.

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

Допустим мы работаем над социальным приложением. У нас есть пользователи, публикации, комментарии и лайки. Пользователи могут публиковать публикации, поэтому вы реализуете эту функции в Модели Post. Легко. Но теперь пользователи хотят лайкать комментарии. Есть два варианта:

Конечно, нам нужен второй вариант. Это выглядит примерно так:

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:

idnamepriceprice_type
1Куриный суп7has_batches
2Пицца Маргарита15has_topping
3Чизбургер12standard

product_batches: эта таблица содержит изменения цен для разны размеров порций.

idproduct_idnameprice
11Большая12

Итак, маленькая порция куриного супа стоит 7$, а большая — 12$ из-за записи product_batches.price.

Когда клиенты заказывают еду, нам нужно создать Order и OrderItems на эти продукты:

orders: эта таблица не так важна для нашей цели, оставим её довольно простой.

idtotal_pricecreated-at
1382023-01-08 14:42

order_items:

idorder_idproduct_idproduct_batch_idprice
11119
21217
31312

toppings:

idnameprice
1Сыр1
2Грибы1

order_item_topping : это сводная таблица связывающая продукт из order_item и toppings

idorder_item_idtopping_id
121
222

Мы можем определить цены используя различные вычисления:

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

Теперь давайте посмотрим как можно рассчитать цены:

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);
}
}

Это не так уж и плохо, но есть два существенных недостатка:

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

Давайте проведём рефакторинг с использованием 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 из всего приложения и заменили их на отдельные классы и простую фабрику. Что происходит, когда появляется новый тип продукта?

Или другими словами: вместо того, чтобы всё менять, мы можем расширить существующие классы новой функциональностью.

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

Ну и, конечно же, мы крутые ребята, так что давайте модернизируем фабрику:

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
) {}

Звучит очевидно, однако есть важные, которые необходимо выполнить:

Как видите принцип довольно прост, но легко ошибиться.

Interface Segregation Principle / Принцип разделения интерфейса

У вас должно быть много маленьких интерфейсов вместо нескольких огромных.

Оригинальны принцип звучит так: ни один код не должен зависеть от неиспользуемых методов, но практическое значение — это определение, которое я вам дал. Честно говоря, это самый простой принцип. В примере с DashDoor (см. главу б открытости/закрытости) продукты имеют тип, например:

У нас был отдельный класс для расчёта цен для этих типов. В реальном мире цена — не единственное, что зависит от типа. Есть и другие вещи, такие как:

В оригинальном примере у были следующие классы:

Каждый из них обрабатывает расчёт цены для определённого типа продукта. Представьте, что мы пишем некий общий класс ProductType, например:

И мы пытаемся обрабатывать всё в этих классах. Итак, у них есть такие функции:

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, а не от конкретной реализации.

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

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

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

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

Laravel Pint: Настройка базовой конфигурации

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

Laravel: Как начать тестировать приложение