П’ять патернів проєктування на прикладі Laravel: Основи
Патерни проєктування — річ настільки абстрактна, що складно зрозуміти їх тільки за описом. Бувало, я перечитував опис патерну 6 разів на сайті Refactoring.Guru і не розумів, про що мова. Складно розуміти концепти, про які ти ніколи не чув, на прикладах, які не траплялися у твоїй роботі. Рішення для мене було очевидне: відкриваю laravel/framework і занотовую собі патерни проєктування, які використовував Taylor Otwell в розробці Laravel.
В цій статті я опишу 5 патернів проєктування, що зустрічаються в Laravel, та покажу, де саме вони використовуються.
💡 Помітив технічну помилку в статті? Я неправильно описав патерн чи надав неправильний приклад? Напевне, це дійсно помилка! Я вивчаю патерни й твій коментар про неточності в статті дуже допоможе мені та всім, хто навчається разом зі мною.
1. Singleton 🧍
Олександр Швець (автор Refactoring.Guru) описує Singleton як «породжувальний патерн проєктування, який гарантує, що клас має лише один екземпляр, та надає глобальну точку доступу до нього» (теорія).
Приклади
💡 Попереджаю. Більшість цієї статті я буду цитувати код із Laravel. Щоб його зрозуміти й не губитися, потрібно мати якісь знання про Laravel та Service Container. Без знання того, як працює Dependency Injection в Laravel, буде складно. Також тут буде мало теоретичного пояснення шаблонів.
Kernel
// Усе "ядро" додатку — Singleton $app->singleton( Illuminate\Contracts\Http\Kernel::class, App\Http\Kernel::class );
Cache
public function register() { // Стандартний інструмент роботи з кешом — Singleton $this->app->singleton('cache', function ($app) { return new CacheManager($app); }); /* ... КОД ... */ }
Session
protected function registerSessionManager() { // стандартний інструмент роботи з сесією — Singleton $this->app->singleton('session', function ($app) { return new SessionManager($app); }); }
І багато інших...
Перераховувати всі приклади не має сенсу, тому що їх багато. Щоб переконатися в цьому, досить пошукати слово Singleton у GitHub репозиторії фреймворку. Кожен фасад в Laravel — це Singleton, до прикладу.
Імплементація
Як вищевказане реалізоване? Якщо говорити в загальному про PHP, то зазвичай рекомендують: зробити методи __construct
та __clone
приватними та додати статичний метод, який при створенні буде контролювати, щоб екземпляр цього класу вертався один і той самий. Проте конкретно Laravel створює Singleton за допомогою Service Container, тому імплементація відрізняється.
Прив’язка в контейнері
Якщо розробник хоче створити Singleton в Laravel, він використовує функцію app()->singleton()
. Допустимо, він має клас CustomCache
, що керує кешом в якийсь особливий спосіб. Екземпляр цього класу завжди має бути один і той самий, у якому б місці розробник його не викликав, тому що від цього залежить цільність даних, що він зберігає. Для цієї задачі в Service Provider потрібно прив’язати згаданий клас до інтерфейсу: app()->singleton(CustomCacheInterface::class, CustomCache::class)
(також можна прив’язати до будь-якого string). Ланцюжок функцій, що викличеться, починається в src/Illuminate/Container/Container.php#L379:
public function singleton($abstract, $concrete = null) { $this->bind($abstract, $concrete, true); }
Розробник прив’язує конкретний клас (CustomCache::class
) до абстрактного (CustomCacheInterface::class
). Функція позначає такий зв’язок як shared
(це те, за що відповідає true
в параметрах). Метод bind
у коді зверху просто зберігає зв’язок між двома класами в асоціативному масиві (src/Illuminate/Container/Container.php#L239):
public function bind($abstract, $concrete = null, $shared = false) { /* ... КОД ... */ $this->bindings[$abstract] = compact('concrete', 'shared'); /* ... КОД ... */ }
Пошук в контейнері
А коли розробник використає клас за допомогою app()->make(CustomCacheInterface::class)
чи інших способів, викличеться функція Illuminate\Container\Container::resolve(). У цій функції буде відбуватися пошук конкретного класу (CustomCache::class
), його «побудова», а також контроль над тим, щоб розробник щоразу отримував один і той самий екземпляр:
protected function resolve($abstract, $parameters = [], $raiseEvents = true) { /* ... КОД ... */ // вертає вже сформований екземпляр, якщо такий існує в пам'яті if (isset($this->instances[$abstract]) && ! $needsContextualBuild) { return $this->instances[$abstract]; } /* ... КОД ... */ // зберігає екземпляр у пам'ять, якщо це Singleton if ($this->isShared($abstract) && ! $needsContextualBuild) { $this->instances[$abstract] = $object; } /* ... КОД ... */ }
Коротко
Singleton у Laravel імплементовано за допомогою Service Container. Для використання патерну потрібно викликати функцію app->singleton()
й потім отримувати необхідні екземпляри класу за допомогою app()->make()
чи інших способів.
2. Facade 🏛️
У Laravel є цілий список фасадів, що розробники використовують у кожному проєкті. Це — шаблон проєктування. Описують його як «структурний патерн проєктування, який надає простий інтерфейс до складної системи класів, бібліотеки або фреймворку» (теорія).
Приклади
Cache
/* Приклад того, як ти можеш використати фасад. Під капотом Laravel вибирає "драйвер" для роботи з кешом та використовує, наприклад, клас `Illuminate/Cache/DatabaseStore`. */ Illuminate\Support\Facades\Cache::put('admin', Role::admin());
Queue
/* Приклад того, як ти можеш використати фасад. Під капотом Laravel вибирає "драйвер" для роботи з чергами та використовує, наприклад, клас `Illuminate/Queue/DatabaseQueue`. */ Illuminate\Support\Facades\Queue::push(new ExampleJob());
І багато інших...
Список фасадів можна знайти в документації.
Імплементація
Багато разів я намагався розібратися, як працює конкретний фасад. Наприклад, хотів знайти, як працює метод Log::info()
. І щоразу я натикався на схожий патерн (src/Illuminate/Support/Facades/Log.php):
class Log extends Facade { protected static function getFacadeAccessor() { return 'log'; } }
Лише один метод у цілому класі. Що це означає? Де метод info()
, імплементацію якого я намагався знайти? Яким чином це працює? Декілька годин у відкритому коді Laravel — і я знайшов відповіді на ці питання.
💡 Імплементація фасаду в Laravel не є обов’язковим аспектом імплементації фасаду в іншому фреймворку чи іншому проєкті. Проте це важливо для того, щоб розуміти, як працює Laravel.
Прив’язка в контейнері
Рядок log
— це ідентифікатор класу, який відповідає за вищезгаданий метод. Laravel використовує Service Container, щоб у файлі src/Illuminate/Log/LogServiceProvider.php прив’язати строку до потрібного класу:
class LogServiceProvider extends ServiceProvider { public function register() { $this->app->singleton('log', function ($app) { return new LogManager($app); }); } }
Статичний метод
Так, розробник викликає статичний метод info()
. Проте перший метод, який викликається насправді, це магічний метод __callStatic
у файлі src/Illuminate/Support/Facades/Facade.php. Він відповідальний за те, щоб знайти екземпляр класу та делегувати йому виконання викликаного методу:
public static function __callStatic($method, $args) { // знаходження екземляра класу $instance = static::getFacadeRoot(); /* ... КОД ... */ // делегація виконання функції щойно створеному екземпляру return $instance->$method(...$args); }
Пошук в контейнері
В методі getFacadeRoot
відбувається пошук за ідентифікатором. Функція за своєю суттю дуже схожа на app()->make()
(src/Illuminate/Support/Facades/Facade.php#L186):
public static function getFacadeRoot() { return static::resolveFacadeInstance(static::getFacadeAccessor()); }
Делегація
Після того, як LogManager
був знайдений, виконується його метод (src/Illuminate/Log/LogManager.php):
public function info($message, array $context = []): void { $this->driver()->info($message, $context); }
Коротко
Facade в Laravel побудовано за допомогою Service Container та магічного методу __callStatic()
.
3. Builder 🧱
Builder — це «породжувальний патерн проєктування, що дає змогу створювати складні об’єкти крок за кроком. Будівельник дає можливість використовувати один і той самий код будівництва для отримання різних відображень об’єктів» (теорія).
Приклади
Eloquent Builder
Навіть з назви нескладно здогадатися, що це — приклад вищезгаданого шаблону проєктування. Візьмемо звичайний запит, який може написати будь-який розробник:
User::where('active', true) ->with('payments') ->get();
У коді вище розробник вказує, який конкретно запит до бази даних він хоче.
💡 Чому я говорю про Eloquent Builder, коли звертаюся до моделі User
? Тому, що Laravel, користуючись гнучкістю магічних методів __call
та __callStatic
, перенаправляє такі запити до моделі до Illuminate\Database\Eloquent\Builder. Подивитися на те, як це відбувається, можна у відкритому коді.
Кожен метод змінює запит, який розробник отримає на виході. Я наведу використані функції нижче, проте я не очікую, що ти маєш їх зрозуміти (я далеко не все зрозумів). Ці приклади важливі лише для того, щоб зрозуміти суть, чому саме виникає такий синтаксис, та як приблизно модифікується запит.
where()
public function where($column, $operator = null, $value = null, $boolean = 'and') { if ($column instanceof Closure && is_null($operator)) { $column($query = $this->model->newQueryWithoutRelationships()); $this->query->addNestedWhereQuery($query->getQuery(), $boolean); } else { $this->query->where(...func_get_args()); } return $this; }
with()
public function with($relations, $callback = null) { if ($callback instanceof Closure) { $eagerLoad = $this->parseWithRelations([$relations => $callback]); } else { $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations); } $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad); return $this; }
get()
Останній метод у ланцюжку, який вертає моделі по збудованому запиту.
public function get($columns = ['*']) { $builder = $this->applyScopes(); if (count($models = $builder->getModels($columns)) > 0) { $models = $builder->eagerLoadRelations($models); } return $builder->getModel()->newCollection($models); }
Будівельні функції (where
та with
) зберігають дані всередині класу та вертають самого себе (return $this
), щоб утворити типовий патерн виклику методів fn()->fn()->fn()
. У той час як метод get()
опрацьовує всі збережені дані та вертає потрібний результат.
Query Builder
Eloquent Builder часто звертається до Query Builder. Суть Query Builder схожа, проте цей будівельник працює не з моделями, а напряму з базою даних, коли викликається через DB::select()
, наприклад. Я не буду навантажувати деталями, його імплементацію можна знайти у відкритому коді.
4. Observer 👓
Observer — це «поведінковий патерн проєктування, який створює механізм підписки, що дає змогу одним об’єктам стежити й реагувати на події, які відбуваються в інших об’єктах» (теорія).
Приклади
Event
Registered -> Listener
SendEmailVerificationNotification
При встановленні свіжої версії Laravel, в EventServiceProvider є Event
та Listener
за замовчуванням:
class EventServiceProvider extends ServiceProvider { protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, ], ]; /* ... КОД ... */ }
Цей код відповідає за те, щоб при події Registered
відбулася команда SendEmailVerificationNotification
. Простіше кажучи, даний код надає інструкції: коли користувач зареєструється, надішли йому сповіщення про верифікацію емейлу. Проте ніщо з цього не вказує на те, коли саме виникне подія «користувач зареєструвався».
Подія може відбуватися в будь-якому контролері, сервісі, чи моделі додатка, а команда все одно спрацює (src/Illuminate/Auth/Listeners/SendEmailVerificationNotification.php):
class SendEmailVerificationNotification { public function handle(Registered $event) { if ($event->user instanceof MustVerifyEmail && ! $event->user->hasVerifiedEmail()) { $event->user->sendEmailVerificationNotification(); } } }
Коли зазвичай виникає подія Registered
? Залежить. Якщо розробник хоче написати авторизацію сам, то це його справа сповістити про подію. Якщо розробник використовує Fortify, то це роблять за нього у файлі RegisteredUserController:
public function store(Request $request, CreatesNewUsers $creator): RegisterResponse { event(new Registered($user = $creator->create($request->all()))); $this->guard->login($user); return app(RegisterResponse::class); }
Імплементація
У Laravel є цілий розділ, який описує цю систему: laravel.com/docs/9.x/events. Зараз я спробую заглибитися трішки більше, ніж розповідає документація. Спойлер: це можливо завдяки Service Container
і асоціативному масиву.
Зберігання Event
Зазвичай зберігання всіх подій відбувається в App\Providers\EventServiceProvider. Провайдер викликає метод Illuminate\Events\Dispatcher::listen() до всіх подій у списку. Саме цей метод відповідальний за збереження Event
та відповідного йому Listener
у пам’яті:
public function listen($events, $listener = null) { /* ... КОД ... */ $this->listeners[$event][] = $listener; /* ... КОД ... */ }
Запуск Listener
Коли розробник сповіщає про подію через event(new CustomEvent())
, виконується ланцюжок функцій, в кінці якого виконується метод Illuminate\Events\Dispatcher::dispatch(). Метод виконує всі слухачі цієї події, які є на цю мить у пам’яті:
public function dispatch($event, $payload = [], $halt = false) { /* ... КОД ... */ foreach ($this->getListeners($event) as $listener) { $response = $listener($event, $payload); } /* ... КОД ... */ }
💡 Що за ланцюжок функцій з event()
до Illuminate\Events\Dispatcher::dispatch()
? Викликається допоміжна функція event, яка за допомогою Service Container
знаходить клас Illuminate\Events\Dispatcher. У цьому класі вже й виконується метод dispatch().
Коротко
Шаблон Observer в Laravel зроблено за допомогою вбудованих Events
і Listeners
. Усі події та їхні слухачі зберігаються в асоціативному масиві. Синтаксис виклику й збереження можливий завдяки Service Container
.
5. Chain of Responsibility ⛓️
Chain of Responsibility — це «поведінковий патерн проєктування, що дає змогу передавати запити послідовно ланцюжком обробників. Кожен наступний обробник вирішує, чи може він обробити запит сам і чи варто передавати запит далі ланцюжком» (теорія).
Приклади
Middleware
Коли користувач надсилає запит до додатка, App\Http\Kernel його обробляє. У цьому файлі можна знайти список $middleware
:
class Kernel extends HttpKernel { protected $middleware = [ // \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ]; /* ... CODE ... */ }
Один за одним вказані Middleware обробляють запит і передають наступному в черзі. Перевіряють, чи все гаразд з CORS, забирають пробіли в параметрів, перетворюють ''
в null
і т.п.
У цій поведінці і є суть патерну «Chain of Responsibilty». Запускати окремі між собою не пов’язані Middleware. При цьому типовий Middleware буде виглядати доволі просто, як, наприклад, App\Http\Middleware\RedirectIfAuthenticated:
/** * Приклад того, як виглядає стандартний обробник запиту. * Метод handle() відподвідає за обробку. * Якщо потрібно продовжити обробку, вертається $next($request). * Якщо потрібно зупинити обробку, вертається інша відповідь. */ class RedirectIfAuthenticated { public function handle(Request $request, Closure $next, ...$guards) { $guards = empty($guards) ? [null] : $guards; foreach ($guards as $guard) { if (Auth::guard($guard)->check()) { return redirect(RouteServiceProvider::HOME); } } return $next($request); } }
Імплементація
Як саме відбувається проходження запиту ланцюжком? У сухому залишку відповідальний за це метод Illuminate\Foundation\Http\Kernel::sendRequestThroughRouter():
protected function sendRequestThroughRouter($request) { /* ... КОД ... */ return (new Pipeline($this->app)) ->send($request) ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware) ->then($this->dispatchToRouter()); }
Головний клас, що відповідальний за це: Illuminate\Pipeline\Pipeline. Так, це той самий клас, який за відчуттям займає 90% логів. Це Builder (патерн, про який я розповідав вище), суть якого в створенні «конвеєра», по якому йде якийсь об’єкт. Метод send() визначає, який об’єкт має змінюватися. through() зберігає масив класів, що будуть змінювати об’єкт.
І, нарешті, функція then() означає, що рух по «конвеєру» потрібно почати. Також там зазначають, що робити після того, як всі модифікації були задіяні. Якщо піти навіть глибше, то ось так виглядає функція, що відповідає за рух конвеєра:
public function then(Closure $destination) { $pipeline = array_reduce( array_reverse($this->pipes()), $this->carry(), $this->prepareDestination($destination) ); return $pipeline($this->passable); }
Так, розумію, функція зверху не дуже очевидна. Якщо дуже просто, метод об’єднує всі функції в одну. «Збірна» функція потім обробляє $this->passable
(у нашому випадку $request
).
Коротко
Laravel використовує клас Illuminate\Pipeline\Pipeline
, щоб імплементувати патерн Chain of Responsibility.
Наостанок
💡 Я ще не закінчив вивчати патерни проєктування. Олександр Швець описав аж 22 патерни і далеко не всі з них були зрозумілі для мене. Тому я маю намір знайти приклади до всіх. Я навіть створив англомовний репозиторій, у якому буду це робити. Якщо тобі цікаво, можеш приєднатися до мене.
Дякую, що прочитав. Сподіваюсь, стаття була корисна. Якщо є уточнення чи запитання, буду радий обговорити.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів