Система уведомлений в ресурсах Laravel

от автора

В предыдущей статье по работе с API ресурсами в Laravel была затронута тема изменения бизнес-потребностей в области формирования внешнего вида объекта ответа на запрос к API приложения.

В этой мы пойдём дальше и введём новую бизнес‑потребность под названием «нотификации». Их суть в том, чтобы вместе с ответом на запрос добавлять информацию о каких‑либо действиях.

Бизнес-потребность

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

Выглядеть это будет примерно так:

{   "data": {     "id": 123,     "title": "Some title"   },   "notifications": [     {       "title": "Изменения успешно сохранены",       "text": "Запись #123 \"Some title\" успешно обновлена.",       "type": "OK"     },     {       "title": "Изменено отображение информации",       "text": "Запись #456 \"Another title\" была деактивирована.",       "type": "WARNING"     },     {       "title": "Упс! Что-то пошло не так!",       "text": "Не удалось получить информацию с сервиса \"<name>\".",       "type": "ERROR"     }   ],   "status": "OK" }

Мысли

На вид просто добавить их вывод в ресурс при помощи метода additional да и всё. Но нет. Вспоминаем что мы — ленивые) Поэтому нужно сделать так, чтобы впредь не приходилось ничего трогать руками и «оно само» работало.

Если подумать, то на ум приходит лишь два способа реализации — прямой и магический.

В «прямом» в нужных местах вместе с ответом отдаём какую-нибудь DTO где будет содержаться информация. Минус этого метода в том, что эту DTO придётся пробрасывать везде и вся, ломая логику и жёстко завязываясь на соседние DTO, вследствие чего придётся ещё и объединять результаты. Зачем? Вот и я говорю что не за чем. Поэтому пойдём простым путём ибо нам — лень (помним, да?).

Магический метод основан на использовании Singleton в Laravel и позволяет хранить состояние от и до. Минус в том, что способ не совместим с Laravel Octane и требует доработки, но так как у меня октан не используется, проблема решилась даже не начавшись ?

Архитектура

Итак, что нам нужно для реализации? Правильно! Глобальный объект, который будет храниться в фреймворке в инициализированном состоянии. При наступлении событий будем в него писать всё что нам нужно, а на выходе получать его состояние. Звучит легко, но как пойдёт на деле — разберёмся по ходу дела.

Подготовка

Сперва создадим Enum класс для хранения типа сообщения:

<?php  namespace App\Enums;  enum NotificationTypeEnum: string {     case Ok      = 'OK';     case Warning = 'WARNING';     case Error   = 'ERROR'; }

Далее создадим простейший DTO для хранения элемента:

<?php  namespace App\Data;  use App\Enums\NotificationTypeEnum;  class NotificationData {     public function __construct(         public NotificationTypeEnum $type,         public ?string $title,         public ?string $text     ) {} }

Этот объект согласует нам единый формат элементов массива. И также нам нужен будет класс API ресурса для трансформации объектов:

<?php  namespace App\Http\Resources;  use App\Dto\NotificationData; use App\Enums\NotificationTypeEnum; use App\Http\Resources\Resource; use Illuminate\Http\Request;  /** @mixin NotificationData */ class NotificationResource extends Resource {     public function toArray(Request $request): array     {         return [             'title' => $this->title(),             'text'  => $this->text,             'type'  => $this->type->value,         ];     }      protected function title(): string     {         return $this->title ?? match ($this->type) {             NotificationTypeEnum::Success => __('The changes have been successfully saved'),             NotificationTypeEnum::Warning => __('Information display has been changed'),             NotificationTypeEnum::Error   => __('Whoops! Something wrong'),         };     } } 

Идея такая, что если передан заголовок элемента, выводим его, иначе дефолтный.

С этим разобрались и теперь можно перейти к созданию управляющего класса.

Сервис

<?php  namespace App\Services;  use App\Data\NotificationData; use Illuminate\Contracts\Support\Arrayable;  class NotificationService implements Arrayable {     protected array $items = [];      public function toArray(): array     {         return $this->items;     }      protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void     {         $this->items[] = new NotificationData($type, $title, $text);     } }

Мы подготовили динамическую часть класса для работы с состоянием и, чтобы каждый раз не вызывать инициализированный класс используя хелпер app(), добавим статический метод, обращающийся к нему для добавления записи в память:

<?php  namespace App\Services;  use App\Data\NotificationData; use Illuminate\Contracts\Support\Arrayable;  class NotificationService implements Arrayable {     protected array $items = [];      public static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void     {         app(static::class)->add($type, $title, $text);     }      public function toArray(): array     {         return $this->items;     }      protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void     {         $this->items[] = new NotificationData($type, $title, $text);     } }

Вновь вспоминаем что мы ленивые и каждый раз вызывать один метод с передачей типизации, такой себе вариант. Поэтому добавляем три метода по количеству типов сообщений: success, warning и error:

<?php  namespace App\Services;  use App\Data\NotificationData; use App\Enums\NotificationTypeEnum; use Illuminate\Contracts\Support\Arrayable;  class NotificationService implements Arrayable {     protected array $items = [];      public static function success(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Success, $text, $title);     }      public static function warning(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Warning, $text, $title);     }      public static function error(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Error, $text, $title);     }      protected static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void     {         app(static::class)->add($type, $title, $text);     }      public function toArray(): array     {         return $this->items;     }      protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void     {         $this->items[] = new NotificationData($type, $title, $text);     } }

Далее добавим оставшуюся часть — вывод в ресурсы. Вспоминаем потребность что выводим список только в том случае, если есть что выводить. Поэтому необходимо использовать проверку на пустоту и, в случае обнаружения таковой, возвращать null. Это позволит проще управлять данными. Что ж, добавляем:

<?php  namespace App\Services;  use App\Data\NotificationData; use App\Enums\NotificationTypeEnum; use App\Http\Resources\NotificationResource; use Illuminate\Contracts\Support\Arrayable;  class NotificationService implements Arrayable {     protected array $items = [];      public static function success(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Success, $text, $title);     }      public static function warning(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Warning, $text, $title);     }      public static function error(?string $text = null, ?string $title = null): void     {         static::push(NotificationTypeEnum::Error, $text, $title);     }      protected static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void     {         app(static::class)->add($type, $title, $text);     }      public static function toResource(): ?Collection     {         if ($items = app(static::class)->toArray()) {             return collect($items)->mapInto(NotificationResource::class);         }          return null;     }      public function toArray(): array     {         return $this->items;     }      protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void     {         $this->items[] = new NotificationData($type, $title, $text);     } }

Также в методе toResource сразу производим проброс элементов массива в ресурсы с целью устранения дубляжа кода снаружи.

Итак, мы получили готовый класс, который в приложении можно использовать в абсолютно любом месте без какой-либо жёсткой привязки.

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

<?php  use App\Services\NotificationService;  NotificationService::success('Обновили запись №1.'); NotificationService::success('Обновили запись №2.', 'Что-то пишем');  NotificationService::warning('Не могу обновить запись №3.'); NotificationService::warning('Не могу обновить запись №4.', 'Что-то пишем');  NotificationService::error('Ошибка запроса к сервису Foo.'); NotificationService::error('Ошибка запроса к сервису Bar.', 'Ошибка получения данных');

Инициализация сервиса

НО сам по себе такой метод не будет сохранять состояние. Нужно инициализировать сервисный класс и для этого сходим в сервис-провайдер AppServiceProvider, добавив нужный вызов в его метод register:

<?php  namespace App\Providers;  use App\Services\NotificationService; use Illuminate\Support\ServiceProvider;  class AppServiceProvider extends ServiceProvider {     public function register(): void     {         $this->notifications();     }      protected function notifications(): void     {         $this->app->singleton(NotificationService::class, fn() => new NotificationService());     } } 

И последнее что нам осталось сделать, это добавить получение данных для вывода в ресурс.

API Resource

Помните как в прошлой статье формировали класс ResourceResponseService? Вернёмся к нему и добавим обращение к нашему классу:

<?php  namespace App\Services\Resources;  use App\Concerns\Resources\HasJsonDates; use App\Concerns\Resources\HasJsonOptions; use App\Services\NotificationService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Support\Responsable; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\Response;  class ResourceResponseService implements Responsable {     use HasJsonDates;     use HasJsonOptions;      protected array $with = [         'status' => 'OK',     ];      public function __construct(         protected JsonResource $resource,         protected ?string $wrap,         ?string $dateFormat     ) {         static::$dateFormat = $dateFormat;     }      public function with(array $data): static     {         $this->with = array_merge($this->with, $data);          return $this;     }      public function toResponse($request): JsonResponse     {         return tap(             response()->json(                 data: $this->wrap(                     $this->resource->resolve($request),                     $this->resource->with($request),                     $this->resource->additional                 ),                 status: $this->status(),                 options: $this->jsonOptions()             ),             function ($response) use ($request) {                 $response->original = $this->resource->resource;                  $this->resource->withResponse($request, $response);             }         );     }      protected function wrap(mixed $data, array $with, array $additional): array     {         Arr::set($result, $this->wrapper(), $this->resolveData($data));          return array_merge($result, $additional, $this->withPagination(), $this->withNotifications(), $this->with, $with);     }      protected function resolveData(mixed $data): array     {         if ($data instanceof Collection) {             $data = $data->all();         }          return $this->resolveDates($data);     }      protected function withNotifications(): array     {         if ($items = NotificationService::toResource()) {             return ['notifications' => $items];         }          return [];     }      protected function wrapper(): string     {         return 'data' . ($this->wrap ? '.' . $this->wrap : '');     }      protected function status(): int     {         if ($this->resource->resource instanceof Model && $this->resource->resource->wasRecentlyCreated) {             return Response::HTTP_CREATED;         }          return Response::HTTP_OK;     }      protected function isPagination(): bool     {         return $this->resource->resource instanceof LengthAwarePaginator;     }      protected function withPagination(): array     {         return $this->isPagination() ? $this->paginationInformation($this->resource->resource) : [];     }      protected function paginationInformation(LengthAwarePaginator $paginated): array     {         return [             'pagination' => [                 'total' => $paginated->total(),                 'perPage' => $paginated->perPage(),                 'currentPage' => $paginated->currentPage(),                 'lastPage' => $paginated->lastPage(),             ],         ];     } }

На строке 78 примера выше добавлен новый метод, получающий массив готовых ресурсов с объектами нотификаций, а на 66-й строке вызываем его при сборке данных.

В конечном итоге получим следующие виды ответа для единичной записи и для их массива:

{     "data": {         "id": 123,         "title": "Some title",         "createdAt": "2024-03-16T18:54"     },     "notifications": [         {             "title": "The changes have been successfully saved",             "text": "Обновили запись №1.",             "type": "SUCCESS"         },         {             "title": "Что-то пишем",             "text": "Обновили запись №2.",             "type": "SUCCESS"         },         {             "title": "Information display has been changed",             "text": "Не могу обновить запись №3.",             "type": "WARNING"         },         {             "title": "Что-то пишем",             "text": "Не могу обновить запись №4.",             "type": "WARNING"         },         {             "title": "Whoops! Something wrong",             "text": "Ошибка запроса к сервису Foo.",             "type": "ERROR"         },         {             "title": "Ошибка получения данных",             "text": "Ошибка запроса к сервису Bar.",             "type": "ERROR"         }     ],     "status": "OK" }
{     "data": {         "somes": [             {                 "id": 123,                 "title": "Some title",                 "createdAt": "2024-03-16T18:54",                 "category": {                     "id": 1,                     "title": "Category name"                 }             },             {                 "id": 456,                 "title": "Another title",                 "createdAt": "2024-03-16T18:55",                 "category": null             }         ]     },     "pagination": {         "total": 2,         "perPage": 2,         "currentPage": 1,         "lastPage": 2     },     "notifications": [         {             "title": "The changes have been successfully saved",             "text": "Обновили запись №1.",             "type": "SUCCESS"         },         {             "title": "Что-то пишем",             "text": "Обновили запись №2.",             "type": "SUCCESS"         },         {             "title": "Information display has been changed",             "text": "Не могу обновить запись №3.",             "type": "WARNING"         },         {             "title": "Что-то пишем",             "text": "Не могу обновить запись №4.",             "type": "WARNING"         },         {             "title": "Whoops! Something wrong",             "text": "Ошибка запроса к сервису Foo.",             "type": "ERROR"         },         {             "title": "Ошибка получения данных",             "text": "Ошибка запроса к сервису Bar.",             "type": "ERROR"         }     ],     "status": "OK" }

И пример объекта без отправки уведомлений:

{     "data": {         "id": 123,         "title": "Some title",         "createdAt": "2024-03-16T18:54"     },     "status": "OK" }

Вот и всё. При получении новых бизнес-потребностей можно будет очень легко прокидывать любые данные в любом формате в ответ на запрос и/или использовать данный принцип в других областях приложения.


ссылка на оригинал статьи https://habr.com/ru/articles/800723/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *