Робота з подіями в 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 logo OneSignal
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 (test 1, 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'])) {

//Становить параметри згідно мануали 
$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) при запису статусу відправки ми не враховуємо відповідь сервісу, якщо сервіс відмовить у відправці, ми статтю порахуємо обробленої і більше по ній намагатися відправити повідомлення не будемо.

Будемо виправляти ці недоліки в майбутніх уроках.

Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.