Pull to refresh

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

Reading time10 min
Views20K
В комментариях к одной из первых статей в моем блоге читатель посоветовал мне прикрутить 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)
    {
       //Мы проверяем статус статьи – если он «Опубликован», смотрим на статус оповещения, если он еще не «Опубликован»
    if ($instance->status == self::STATUS_PUBLISHED 
        && $instance->notify_status < self::STATUS_PUBLISHED){

        //то устанавливаемый статус оповещения в «опубликован»
        $instance->notify_status = self::STATUS_PUBLISHED;

        //и  «выстреливаем» событие PostPublishedEvent, передавая в него собственный инстанс.
        \Event::fire(new PostPublishedEvent($instance));
    });
    parent::boot();
}

Тесты


Самое время написать первый тест!
Нам необходимо протестировать: во-первых, что нужное событие при нужных условиях происходит, и во-вторых, что событие не происходит, когда не надо (статус = черновик например)
Если вы читали статью Первое приложение на 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) при записи статуса отправки мы не учитываем ответ сервиса, если сервис откажет в отправке, мы статью посчитаем обработанной и больше по ней пытаться отправить уведомления не будем.
Поэтому я не рекомендую использовать это решение на продакшен-сервер.
Будем эти недостатки исправлять в будущих уроках. Дождитесь продолжения (спойлер в первом комментарии :)

UPD


Продолжение статьи Работа с событиями в Laravel. Асинхронная обработка очереди.
Tags:
Hubs:
+10
Comments13

Articles

Change theme settings