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

Поэтому я не рекомендую использовать это решение на продакшен-сервер.
Будем эти недостатки исправлять в будущих уроках. Дождитесь продолжения (спойлер в первом комментарии :)

Комментарии (13)


  1. difiso
    16.03.2016 09:36
    +2

    Недостатки решаются обработкой событий в асинхронном режиме.

    Для этого надо чтобы Listener реализовывал пустой интерфейс ShouldQueue. В этом случае все события обрабатываются очередью в фоне (ну да, надо запустить php artisan queue:listen или php artisan queue:work, в доках по очередям это есть).

    Метод Post::saving выполняется ДО сохранения модели в базу. В этом методе вы не можете быть уверены в том, что модель сохранится в базу. Отправлять уведомления надо только в том случае, когда пост сохранился, т.е. использовать метод Post::saved

    И, соответственно, очень опрометчиво менять $notify_status до отправки уведомлений. В чем проблема менять, когда точно известно, что уведомление ушло — после получения ответа от сервера?


    1. Rencom
      16.03.2016 09:41
      +1

      Если Вы не заметили, я все это указал конце, очереди буду рассматривать отдельно. Вместе с расширением функционала (не только push уведомления планируются)
      Уровень статьи — для тех, кто делает первые (или вторые) шаги. Много информации сразу сбивает с толку.


      1. difiso
        16.03.2016 09:46

        Про очереди не заметил, виноват. Но логику-то зачем воротить? Сначала пометить сделанным, а потом надеяться что внешний сервер ответит успехом?


        1. Rencom
          16.03.2016 09:50
          +1

          Чтобы был лишний повод для рефакторинга. Показать, как делают обычно :)) и как это исправить


          1. Sway
            16.03.2016 15:23

            Вот не стоит так делать в этом случае. Одно дело — когда в одной статье рассматривается и проблема и ее решение, а совсем другое, когда в 1й статье умышленно делается ошибки/недочет, которые исправляются в другой статье. К моменту выхода 2й статьи уже будет поздно что-либо исправлять, а многие ее и не прочитают, особенно если найдут такие ляпы.


            1. Rencom
              16.03.2016 15:32
              +1

              В статье не просто молча допущен недочет (не смертельный кстати).
              В ней отдельным параграфом это указано. Лишний повод продумать решение самому, как это сделал difiso выше


              1. Sway
                16.03.2016 16:41

                На мой взгляд от этого легче не становится. Ошибки есть, они не исправлены. Заставлять читателя думать там, где это необязательно, не всегда уместно. Я вот зашел почитать как другой человек работает с событиями и для чего их использует.
                Кстати, не вижу особого смысла использовать для этой задачи события. Этим вы только усложняете понимание происходящего т.к. вызов события никак не передает суть того, что оно будет делать. К тому же это событие всегда вызывается из одного и того же места и имеет один обработчик (даже если их будет и несколько, то все равно смысла мало).
                Что мешает сделать вот так?

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

                И при необходимости добавлять сюда действия другого типа одной строчкой.
                Ведь подумайте — onBeforeSave() — это по сути обработчик события (префикс "on" на это толсто намекает). А вы пихаете вызов другого события внутрь обработчика события. Жуть просто.

                А теперь давайте рассмотрим почему мой вариант лучше на примере того как человек читает код.

                Ваш вариант глазами другого человека, который знает Laravel:

                1. Заходим в модель, видим onBeforeSave
                2. Смотрим что метод делает:
                  2.1. Проверка статусов поста и нотификации (А предполагается ли такая ситуация что пост был изменен после публикации и рассылки?)
                  2.2. Устанавливаем новый статус нотификации (стоп! какого? мы ж ничего не сделали!)
                  2.3. Отправляем событие PostPublishedEvent
                3. Так. А что делает это событие и где?
                  3.1. Открываем класс события (ну как обычно — ctrl+click, привыкли уже). Хм… В нем нет никакой инфы о его деятельности.
                  3.2. Ааа! Точно! У него ж должен быть listener. Таак и где среди этой сотни обработчиков нужный нам (я это к тому что с таким подходом в более-менее большом проекте обработчиков будет навалом)?
                  3.3. Нашли! Ура! Итак, наконец-то я узнаю что он делает! (new OneSignalHandler())->sendNotify($event->post);
                  3.4. Что????? И это все???? И я перелопатил хренову тучу классов/файлов ради этого????
                  Я утрирую, дальше ведь там будут еще другие действия, но все-равно путь длинноват.
                4. Так… А с чем я там вообще изначально разбирался и где? (потеря концентрации и времени)

                Мой вариант:

                1. Заходим в модель, видим onBeforeSave
                2. Смотрим что метод делает:
                  2.1. Проверка статусов поста и нотификации (А предполагается ли такая ситуация что пост был изменен после публикации и рассылки?)
                  2.2. Устанавливаем новый статус нотификации (стоп! какого? мы ж ничего не сделали!)
                  2.3. Отправляем нотификацию через OneSignalHandler (опа! да тут же косяк! статус меняется независимо от того отправилась нотификация или, да еще и отправка происходит до сохранения данных, что вообще-то совершенно неправильно — вдруг не сохраниться?)
                3. Всё понятно. Потенциальный баг найден и будет исправлен.

                Надеюсь, вы понимаете что излишнее усложнение ради мнимого удобства приводят к тому что вы не найдете очевидных багов в самых простых местах пока эти баги не вылезут во всей красе. А если проект будет поддерживать другой человек?

                События — хорошая штука, но применять их нужно не бездумно везде где захочется — будет только хуже.
                Я пока нашел всего 1 применение для очень специфического случая, где без них был бы полнейший кошмар. Больше нигде не применял — не нашлось места. Знаю только что они применяются в коде Laravel, и там это обосновано — не нужно делать 100500 методов onSomeAction(). События удобны для библиотек, когда неизвестно какие действия захочет выполнить использующий либу программист.

                Еще:

                1. Почему бы не сделать OneSignalHandler статическим классом? По сути он не хранит состояния. Его задаче — предоставить обертку для отправки пуша. Тестовую отправку можно сделать через аргумент метода sendNotify, хранить этот флаг не вижу смысла. Если же нужно глобально включать/выключать тестовую отправку — это можно организовать через поле static public $testMode или через setter для него.
                2. Разберитесь с очередью (queue) — она для данной задачи необходима намного больше чем событие т.к. значительно увеличит скорость обработки процесса публикации поста (естественно, если будет выполняться в фоне)


                1. Rencom
                  16.03.2016 17:09

                  Спасибо за такой развернутый комментарий ) Хочу напомнить, что это обучающий материал, цель его показать как можно работать с событиями, удобно это или нет, каждый решит сам.

                  Метод OnBeforeSave тут правда лишний, мой косяк, тк в оригинале я наследуюсь от своего CrudModel, в котором находится boot(), получается что дернул из разных мест: получил в итоге масло масляное

                  В следующей статье расскажу про очереди. Я это изначально планировал.


            1. Rencom
              16.03.2016 15:36

              Добавил в текст жирное предостережение.


  1. Big_Shark
    16.03.2016 18:00

    Не стоит подгружать конфиги уже в самом классе, лучше передать их в класс через конструктор. Предварительно зарегистрировав его в контейнере.


    1. Rencom
      16.03.2016 18:03

      В смысле через DI? А чем лучше?


      1. Big_Shark
        17.03.2016 11:06
        +1

        Тем что в будущем мы сможем иметь 2 класса с разными конфигами без разруливания логики загрузки конфигов в этих классах.


        1. Rencom
          17.03.2016 11:10

          Да, наверное полезно. На практике ни разу пока не приходилось такое применять, в смысле разные реализации конфига.