В комментариях к одной из первых статей в моем блоге читатель посоветовал мне прикрутить 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/
Добавить комментарий