В комментариях к одной из первых статей в моем блоге читатель посоветовал мне прикрутить 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 = [
'AppEventsPostPublishedEvent' => [
'AppListenersPostActionsListener',
],
];
Затем идем в консоль и запускаем команду
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 AppModelsPost;
Теперь переходим к слушателю 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(AppModelsPost::class, function (FakerGenerator $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' => AppModelsPost::STATUS_PUBLISHED,
'category_id' => 1
];
});
И сам тест tests/PostCreateTest.php c одним пока методом:
class PostCreateTest extends TestCase
{
public function testPublishEvent()
{
//говорим, что ожидаем событие AppEventsPostPublishedEvent
$this -> expectsEvents(AppEventsPostPublishedEvent::class);
//Создаем экземпляр поста с записью в бд
$post = factory(AppModelsPost::class)->create();
//и проверяем на месте ли он
$this -> seeInDatabase('post', ['title' => $post->title]);
//затем удаляем
$post -> delete();
}
}
Обратите внимани: при тестировании событий, сами события не возникают. Регистрируется только факт их возникновения или не возникновения
Запустим phpunit. Должно быть все отлично OK (1 test, 1 assertion)
Теперь добавим обратную проверку того, что событие не возникает, на черновиках например:
public function testNoPublishEvent()
{
$this->doesntExpectEvents(AppEventsPostPublishedEvent::class);
// При создании экземпляра статьи – переопределяем status.
$post = factory(AppModelsPost::class)->create(
[
'status' => AppModelsPost::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 AppHandlers;
use anlutrocURLcURL;
use AppModelsPost;
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(AppModelsPost::class)->make();
//Инициализируем наш обработчик с параметром test = true
$handler = new AppHandlersOneSignalHandler(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) при записи статуса отправки мы не учитываем ответ сервиса, если сервис откажет в отправке, мы статью посчитаем обработанной и больше по ней пытаться отправить уведомления не будем.
Будем эти недостатки исправлять в будущих уроках.
Автор: Rencom