Работа с событиями в Laravel. Рассылка push уведомлений при публикации статьи

от автора

В комментариях к одной из первых статей в моем блоге читатель посоветовал мне прикрутить push-уведомления через сервис "Onesignal" На тот момент я понятия не имел, что это за зверь и с чем его едят. Про сами уведомления я, конечно, знал, про сервис — нет.
Легко нагуглил и оказалось, что это сервис, который позволяет рассылать push уведомления абсолютно разного рода, по всем платформам и девайсам. При этом имеет удобную панель управления/отчетности, возможность отложенной отправки и тд.
На настройке самого сервиса останавливаться не буду. Есть и его российские аналоги, ссылки при необходимости легко находятся. Да и речь больше не о самом сервисе, а о правильной архитектуре приложения на Laravel.


Интеграция

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

1) Клиентская часть: размещаем javascript

2) Серверная часть: мы люди ленивые, поэтому ходить в админку Onesignal и постить каждый раз сообщения для рассылки вручную – не наш метод. Нам бы это дело доверить умным машинам! И, о чудо! Для этого у onesignal есть JSON API.

Клиентская часть

Тоже подробно расписывать не стану, тк все описано на сайте сервиса. Скажу лишь, что есть 2 пути. Простой: тупо разместить их Javascript, который генерит кнопку для подписки. И более долгий: верстать кнопку ручками, по клику вызывать их URL.

Как вы уже догадались, я выбрал простой путь )

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

<script src="https://cdn.onesignal.com/sdks/OneSignalSDK.js" async></script> <script>     var OneSignal = OneSignal || [];     OneSignal.push(["init", {         appId: "мой id приложения",         subdomainName: 'laravel-news', //мой поддомен на onesignal.com (задается при настройке приложения)         notifyButton: {             enable: true, // Set to false to hide,             size: 'large', // One of 'small', 'medium', or 'large'             theme: 'default', // One of 'default' (red-white) or 'inverse" (whi-te-red)             position: 'bottom-right', // Either 'bottom-left' or 'bottom-right'               offset: {             offset: {                 bottom: '90px',                 left: '0px', // Only applied if bottom-left                 right: '80px' // Only applied if bottom-right             },             text: {                 "tip.state.unsubscribed": "Получать уведомления о новых статьях прямо в браузере",                 "tip.state.subscribed": "Вы подписаны на уведомления",                 "tip.state.blocked": "Вы заблокировали уведомления",                 "message.prenotify": "Не забудьте подписаться на уведомления о новых статьях",                 "message.action.subscribed": "Спасибо за подписку!",                 "message.action.resubscribed": "Вы подписаны на уведомления",                 "message.action.unsubscribed": "Увы, теперь вы не сможете получать уведомления о самых интересных статьях",                 "dialog.main.title": "Настройки  уведомлений",                 "dialog.main.button.subscribe": "Подписаться",                 "dialog.main.button.unsubscribe": "Поступить опрометчиво и отписаться",                 "dialog.blocked.title": "Снова получать уведомления о самых интересных статьях",                 "dialog.blocked.message": "Следуйте этим инструкциям, чтобы разрешить уведомления:"             }         },         prenotify: true, // Show an icon with 1 unread message for first-time site visitors         showCredit: false, // Hide the OneSignal logo         welcomeNotification: {             "title": "Новости Laravel",             "message": "Спасибо за подписку!"         },         promptOptions: {             showCredit: false, // Hide Powered by OneSignal             actionMessage: "просит разрешения получать уведомления:",             exampleNotificationTitleDesktop: "Это просто тестовое сообщение",             exampleNotificationMessageDesktop: "Уведомления будут приходить на Ваш ПК",             exampleNotificationTitleMobile: " Пример уведомления",             exampleNotificationMessageMobile: "Уведомления будут приходить на Ваше устройстве",             exampleNotificationCaption: "(можно  отписаться в любое время)",             acceptButtonText: "Продолжить".toUpperCase(),             cancelButtonText: "Нет, спасибо".toUpperCase()         }      }]); </script>

На этом настройка клиентской части завершена.

Серверная часть. Архитектура.

Приступаем к самому интересному.

Задача: при размещении поста (статьи) разослать push уведомления.

Но, при этом держим в уме, что скоро при публикации статьи нам 100% понадобится выполнить еще не одно действие. Например, послать текст в «Оригинальные тексты» яндекс-вебмастера, чирикнуть в твиттер и тп.

Поэтому надо весь этот процесс как-то фэншуйненько организовать, а не пихать все в модель или, упасибох, контроллер.

Давайте порассуждаем. Сама публикация статьи — это что? Правильно – событие! Так давайте же и использовать события. Их реализация в ларе очень хороша.

Ну конечно, про события был спойлер в заголовке, поэтому все сразу догадались )

Согласно документации есть несколько способов регистрации событий и создания самих классов. Остановимся на самом удобном варианте.

Пишем код

Мы поступим так: в app/Providers/EventServiceProvider.php укажем наше событие и его слушателя. Событие назовем PostPublishedEvent, слушателя — PostActionsListener.

protected $listen = [     'App\Events\PostPublishedEvent' => [         'App\Listeners\PostActionsListener',     ], ];

Затем идем в консоль и запускаем команду

php artisan event:generate

Команда создаст классы события app/Events/PostPublishedEvent.php и его слушателя app/Listeners/PostActionsListener.php

Отредактируем сначала класс события, в него мы будем передавать экземпляр нашего блог-поста.

public $post;  /**  * PostPublishedEvent constructor.  * @param Post $post  */ public function __construct(Post $post) {     $this->post = $post; }

Здесь и далее по коду не забываем подключить классы.

use App\Models\Post;

Теперь переходим к слушателю app/Listeners/PostActionsListener.php

Я его обозвал таким образом не просто так!
Чтобы не плодить слушателей на каждый тип события (думаю их не много будут) я решил завести один.
Разруливать что именно выполнить будем исходя из того, экземпляр какого класса события пришел.

Примерно так

/**  * Handle the event.  *  * @param  Event  $event  * @return void  */ public function handle(Event $event) {     if ($event instanceof PostPublishedEvent)     {     //тут  будет магия     } }

Теперь осталось каким-то образом сделать так, чтобы наше событие PostPublishedEvent произошло. Предлагаю пока это сделать при сохранении модели.

В нашем случае статья может иметь 2 статуса (поле status) Черновик / Опубликован.

Статусы я обычно делаю константами класса. В данном случае они выглядят так:

const STATUS_DRAFT = 0; const STATUS_PUBLISHED = 1;

При смене статуса на «Опубликован» и надо разослать уведомления.

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

Добавим дополнительное поле notify_status, его значения могут такими же что и у status.
Выполним в консоли:

php artisan make:migration add_noty_status_to_post_table --table=post

Созданную миграцию отредактируем таким образом:

public function up() {     Schema::table('post', function (Blueprint $table) {         $table->tinyInteger('notify_status')->default(0);     });  }

Выполним в консоли php artisan migrate

Вызов события

Теперь все готово к тому, чтобы вызывать само событие.
Чтобы поймать процесс сохранения модели в Ларавел есть специально обученные (опять же) события.

Заведем в модели Post статичный метод boot И добавим в него слушателя на событие сохранения

public static function boot() {     static::saving(function($instance)     {         return $instance->onBeforeSave();     });     parent::boot(); }

Создадим метод onBeforeSave(), объяснения в комментариях:

protected  function onBeforeSave() {     //Мы проверяем статус статьи – если он «Опубликован», смотрим на статус оповещения, если он еще не «Опубликован»     if ($this->status == self::STATUS_PUBLISHED          && $this->notify_status < self::STATUS_PUBLISHED){          //то устанавливаемый статус оповещения в «опубикован»         $this->notify_status = self::STATUS_PUBLISHED;          //и  «выстреливаем» событие PostPublishedEvent, передавая в него собственный инстанс.         \Event::fire(new PostPublishedEvent($this));      } }

Тесты

Самое время написать первый тест!
Нам необходимо протестировать: во-первых, что нужное событие при нужных условиях происходит, и во-вторых, что событие не происходит, когда не надо (статус = черновик например)

Если вы читали статью Первое приложение на Laravel. Пошаговое руководство (Часть 1),
вы уже знаете про фабрики моделей, и как они полезны для тестирования. Создадим свою фабрику для модели Post
файл database/factories/PostFactory.php:

$factory->define(App\Models\Post::class, function (Faker\Generator $faker) {     return [         'title' => $faker->text(100),         'publish_date' => date('Y-m-d H:i'),         'short_text' => $faker->text(300),         'full_text' => $faker->realText(1000),         'slug' => str_random(50),         'status' => \App\Models\Post::STATUS_PUBLISHED,         'category_id' => 1     ]; });

И сам тест tests/PostCreateTest.php c одним пока методом:

class PostCreateTest extends TestCase {     public  function testPublishEvent()     {         //говорим, что ожидаем событие \App\Events\PostPublishedEvent         $this -> expectsEvents(\App\Events\PostPublishedEvent::class);          //Создаем экземпляр поста с записью в бд          $post  = factory(App\Models\Post::class)->create();          //и проверяем на месте ли он         $this -> seeInDatabase('post', ['title' => $post->title]);          //затем удаляем         $post -> delete();     }  }

Обратите внимани: при тестировании событий, сами события не возникают. Регистрируется только факт их возникновения или не возникновения

Запустим phpunit. Должно быть все отлично OK (1 test, 1 assertion)

Теперь добавим обратную проверку того, что событие не возникает, на черновиках например:

public  function testNoPublishEvent() {     $this->doesntExpectEvents(\App\Events\PostPublishedEvent::class);      // При создании экземпляра  статьи – переопределяем status.     $post  = factory(App\Models\Post::class)->create(                                             [                                                 'status' => App\Models\Post::STATUS_DRAFT                                              ]);      $this->seeInDatabase('post', ['title' => $post->title]);     $post->delete(); }

Прогоняем phpunit: OK (2 tests, 2 assertions)

Обработка события, отправка push уведомлений

Остались пустяки, всего лишь обработать событие и отправить пуш уведомления через сервис onesignal.com.

Идем на сайт сервиса и курим мануал по REST API.

Нас интересует процедура отправки сообщения.

Все параметры подробно описаны, пример кода есть.

Я вместо использования curl_* функций установлю знакомый мне пакет-обертку anlutro/curl.

В консоли composer require anlutro/curl

Все процедуру отправки оформим как отдельный хендлер app/Handlers/OneSignalHandler.php: Вот его код полностью. В комментариях опишу что к чему

<?php namespace App\Handlers;  use anlutro\cURL\cURL; use App\Models\Post;  class OneSignalHandler {      //признак тестовой отправки     private $test = false;      // по умолчанию отправляем "боевое сообщение"     public function __construct($test=false)     {         $this->test = $test;     }      //Метод sendNotify принимает на вход инстанс статьи.      public function sendNotify(Post $post)     {          //Про конфиг ниже         $config = \Config::get('onesignal');          //если app_id вообще задан,  то отправляем         if (!empty($config['app_id'])) {          //Cоставляет параметры согласно мануалу              $data = array(                 'app_id' => $config['app_id'],                 'contents' =>                     [                         "en" => $post->short_text                      ],                 'headings' =>                     [                         "en" => $post->title                     ],                   //(я использую только WebPush уведомления)                                     'isAnyWeb' => true,                 'chrome_web_icon' => $config['icon_url'],                 'firefox_icon' => $config['icon_url'],                 'url' => $post->link              );              //Если параметр test  ==  true То  мы  в получателя добавляем только себя,               if ($this->test)             {                 $data['include_player_ids'] = [$config['own_player_id']];             } else {                 //если нет - то  всех.                 $data['included_segments'] =  ["All"];             }              //Дата отложенной отправки! Очень круто!             if (strtotime($post->publish_date) > time()) {                 $data['send_after'] = date(DATE_RFC2822, strtotime($post->publish_date));                                 $data['delayed_option'] = 'timezone';                 $data['delivery_time_of_day'] = '10:00AM';             }              $curl = new cURL();             $req =  $curl->newJsonRequest('post',$config['url'], $data)->setHeader('Authorization', 'Basic '.$config['api_key']);             $result = $req->send();              //В случае неудачи, пишем ответ в лог.             if ($result->statusCode <> 200) {                 \Log::error('Unable to push to Onesignal', ['error' => $result->body]);                 return false;             }              $result = json_decode($result->body);             if ($result->id)             {                 //Если запрос удачен  - возвращаем кол-во получателей.                 return $result->recipients;             }          }      } } 

Настройки

Для хранения настроек onesignal я создал конфиг
config/onesignal.php

<?php  return [     'app_id' => env('ONESIGNAL_APP_ID',''),     'api_key' => env('ONESIGNAL_API_KEY',''),     'url' => env('ONESIGNAL_URL','https://onesignal.com/api/v1/notifications'),     'icon_url' => env('ONESIGNAL_ICON_URL',''),     'own_player_id' => env('ONESIGNAL_OWN_PLAYER_ID','') ];

Сами настройки в .env

ONESIGNAL_APP_ID = 256aa8d2…. ONESIGNAL_API_KEY = YWR….. ONESIGNAL_ICON_URL = http://laravel-news.ru/images/laravel_logo_80.jpg ONESIGNAL_URL = https://onesignal.com/api/v1/notifications ONESIGNAL_OWN_PLAYER_ID = 830…

В конфиге фигурирует ‘own_player_id’
Это мой ID подписчика из админки. Нужен он для тестов, чтобы отправлять уведомление только себе.

Тестирование

Отправка готова – самое время его протестировать. Сделать это очень просто, тк мы задали верную архитектуру и процесс отправки статьи по сути является изолированным.

Добавим в наш тест такой метод:

public  function  testSendOnesignal() {     //В нем мы создаем экземпляр статьи (без записи с бд)     $post  = factory(App\Models\Post::class)->make();      //Инициализируем наш обработчик  с параметром test = true         $handler = new \App\Handlers\OneSignalHandler(true);      //и делаем отправку      $result  = $handler->sendNotify($post);      //Должны получить 1, тк отправляем  уведомление только себе.     $this->assertEquals(1,$result);  }

В консоли phpunit – тест успешно проходит и выскакивает уведомление (иногда бывают задержки до нескольких минут)

Если тест не проходит, смотрим лог и исправляем то, что не нравится сервису

Финальный аккорд

Осталось только добавить вызов в слушателя

/**  * Handle the event.  *  * @param  Event  $event  * @return void  */ public function handle(Event $event) {     if ($event instanceof PostPublishedEvent)     {         (new OneSignalHandler())->sendNotify($event->post);     } } 

Заключение

На этом пока все, но наш код имеет ряд недостатков:
1) отправка у нас происходит в реальном времени при сохранении модели, если добавятся более тяжелые и медленные операции, до сохранения не дойдет и все упадет.
2) при записи статуса отправки мы не учитываем ответ сервиса, если сервис откажет в отправке, мы статью посчитаем обработанной и больше по ней пытаться отправить уведомления не будем.

Будем эти недостатки исправлять в будущих уроках.

ссылка на оригинал статьи https://habrahabr.ru/post/279385/


Комментарии

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

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