Google Cloud Messaging – пишем backend на PHP

в 10:05, , рубрики: android, gcm, google cloud messaging, php, Программирование, Разработка под android, метки: , , ,

Предлагаю полноценное рабочее решение, которое

  • получает на вход массив данных для отправки
  • формирует пакеты для отправки размером до 4096кб каждый.
  • отправляет пакеты параллельными запросами.
  • Анализирует ответ и знает:
    • успешно доставлено ли сообщение
    • тип ошибки

Google Cloud Messaging – коротко и ясно

GCM – это сервис доставки мгновенных сообщений. Альтернатива стандартным polling и long polling, но не исключающая, а дополняющая их. Гарантии, что сообщение будет доставлено Гугл не дает (хотя надежность и скорость доставки стала просто космической по сравнению с предком C2DM). Если на телефоне интернет выключен, то сообщение будет храниться на GCM сервере до 4х недель. Т.е если пользователь выключил телефон, уехал в отпуск, то по приезду сообщение он может уже не получить. Поэтому GCM должен работать только вместе с надежными способами доставки такими как, например, элементарный polling – отправка http запросов на сервер каждые N минут.

Любое android приложение может зарегистрировать себя в качестве получателя сообщений от GCM. При включенном интернете регистрация происходит за считанные секунды. Как только это произошло приложение получает от GCM сервера RegistrationId, который нужно отправить на свой сервер. В итоге в базе сервера мы имеем, например, таблицу Devices, хранящую информацию об устройствах, включая их RegistrationId.

Чтобы устройства начали получать сообщения серверный код должен отправлять POST запросы на GCM сервер в формате json (можно отправлять и обычные ключ => значение, но рекомендуется именно json). Ответ сервера также содержит json, проанализировав который мы сможем понять доставлено ли сообщение, а если нет — какие ошибки произошли.

Приступим

Создадим два класса GcmPayload и GcmSender.

Листинг

class GcmPayload {
   public function __construct($regId, $jsons) {}
   public $regId;
   public $jsons;
}
class GcmSender {
   public function __construct($payloads) {}
   public function send() {}
   protected function getPackages() {}
   protected function isReadyToFlush($items, $json) {}
   public function onResponse($response, $info, RollingCurlRequest $request) {}
}

В терминологии GCM payload – это данные, которые вы хотите отправить получателю. Эти данные должны храниться в значении ключа data и имеют ограничение в 4096 байт. Подробнее про формат запроса.

GcmPayload – модель данных для одного получателя и соответственно одного RegistrationId. Поле $jsons должно быть проинициализировано массивом json’ов в виде строк, содержащих данные, которые нужно отправить этому получателю. Для упрощения туториала считаем, что это делается вне нашего класса, например, так:

Листинг

$recipients = $messagesRepository->getRecipientsWithNewMessages();
$payloads = array();
foreach ($recipients as $recipient) {
    $jsons = array();
    foreach ($recipient->messages as $message) {
        $jsons[] = json_encode($message);
    }
    $payloads[] = new GcmPayload($recipient[‘regId’], $jsons);
}

$gcm = new GcmSender();
$gcm->send($payloads);

GcmSender

Константы и члены класса

const GCM_API_KEY = 'your api key'; // Нужно получить на странице Google APIs Console
const CURL_TIMEOUT = 10; // Таймаут соединения в сервером Гугл в секундах
const GCM_MAX_DATA_SIZE = 4096; //Лимит на отправляемые данные в байтах
const GCM_SERVER_URL = 'https://android.googleapis.com/gcm/send'; //адрес GCM сервера
const GCM_MAX_CONNECTIONS = 10; // количество параллельных запросов

const KEY_REG_IDS = 'registration_ids'; //ключ получателей в json запросе
const KEY_DATA = 'data'; //ключ с данными в json запросе
const KEY_ITEMS = 'items'; //ключ в объекте data, содержащий наш массив данных
const REGID_PLACEHOLDER = '_REGID_'; //плэйсхолдер для RegistrationId в json шаблоне запроса
const ITEMS_PLACEHOLDER = '_ITEMS_'; //плэйсхолдер для массива наших данных в json шаблоне запроса

const GCM_ERROR_NOTREGISTERED = 'NotRegistered'; //константа для ошибки, если пользователь удалил приложение

protected $_template; //json шаблон запроса
protected $_baseDataSize; //изначальный размер данных, который включает ключ items, кавычки скобки и т.д.

конструктор

Конструктор создает шаблон запроса, который будет использоваться в методе getPackages. Обратите внимание, чтобы потенциально не превысить лимит в 4096 байт на данные, нужно также запомнить и учесть в дальнейшем размер изначальных данных в шаблоне: {«items»: []}

Листинг

    public function __construct() {
        $dataObj = '{"'.self::KEY_ITEMS.'": ['.self::ITEMS_PLACEHOLDER.']}';
        $this->_template = '{
            "'.self::KEY_REG_IDS.'": ["'.self::REGID_PLACEHOLDER.'"],
            "'.self::KEY_DATA.'": '.$dataObj.'
        }';
        $baseDataJson = str_replace(self::ITEMS_PLACEHOLDER, '', $dataObj);
        $this->_baseDataSize = strlen($baseDataJson);
    }

Метод send

Это паблик метод должен вызываться для непосредственной отправки данных на GCM сервер. Метод принимает данные для отправки, которые методом getPackages преобразутся в пакеты данных – подготовленные post данные в формате json (один пакет – один запрос) и гарантированно не превышающие 4096 байт. Остальная часть метода – это инициализация замечательной библиотеки RollingCurl, которая инкапсулирует в себе работу с curl_multi_exec и позволяет отправлять запросы параллельно и писать прозрачный код. RollingCurl инициализируем нашим колбэк методом onResponse, в котором будем анализировать результат отправки. Далее идет непосредственно сама отправка данных.

Листинг

    /**
     * @param GcmPayload[] $payloads
     */
    public function send($payloads) {
        $packages = self::getPackages($payloads);
        if (!$packages || count($packages) == 0) return;

        $rc = new RollingCurl(array($this, 'onResponse'));
        $headers = array('Authorization: key='.self::GCM_API_KEY, 'Content-Type: application/json');
        $rc->__set('headers', $headers);
        $rc->options = array(
            CURLOPT_SSL_VERIFYPEER => false, //отключаем проверку сертификата
            CURLOPT_RETURNTRANSFER => true, //указываем, что хотим получить ответ в виде строки
            CURLOPT_CONNECTTIMEOUT => self::CURL_TIMEOUT, // сколько секунд пытаться установить соединение
            CURLOPT_TIMEOUT => self::CURL_TIMEOUT); //сколько времени должны выполняться функции curl

        foreach ($packages as $package) {
            $rc->request(self::GCM_SERVER_URL, 'POST', $package);
        }
        
        $rc->execute(self::GCM_MAX_CONNECTIONS); 

Метод getPackages

В этом методе перебирается массив переданных классу payload’ов и постепенно наполняется шаблон, созданный в конструкторе, до тех пор пока пакет не превысит лимит в 4096 байт или данные для получателя не закончатся. Кстати, в нашем примере считаем, что один пакет – один получатель. Что это значит? Например, такая условность справедлива когда текстовое сообщение адресовано только одному человеку. Но в групповых беседах одно и тоже сообщение можно отправить несколько людям и GCM это позволяет указав в значении ключа registration_ids несколько RegistrationId. Но повторюсь, в данном примере во избежание ненужных усложнений этот случай не рассматриваем.

Вернемся к методу getPackages. На самом деле здесь интерес представляет функция isReadyToFlush, которая определяет приведет ли добавление нового json к пакету выход за рамки лимита в 4096 байт. Если да, то пакет тут же завершается и этот json добавляем уже в новый пакет.

Листинг

    /**
     * @param string $items
     * @param string $json
     * @return bool
     */
    protected function isReadyToFlush($items, $json) {
        $newPackageLen = $this->_baseDataSize + strlen($items) + strlen($json);
        return $newPackageLen > self::GCM_MAX_DATA_SIZE;
    }

    /**
     * @param GcmPayload[] $payloads
     * @return string[]
     */
    protected function getPackages($payloads) {
        $packages = array();
        foreach($payloads as $payload) {
            $template = str_replace(self::REGID_PLACEHOLDER, $payload->regId, $this->_template);
            $items = '';

            foreach($payload->jsons as $json) {
                if ($this->isReadyToFlush($items, $json)) {
                    $package = str_replace(self::ITEMS_PLACEHOLDER, $items, $template);
                    $packages[] = $package;
                    $items = '';
                }
                if ($items) $items .= ','.$json;
                else $items = $json;
            }

            if ($items) { //если есть остатки добавляем их в новый пакет
                $package = str_replace(self::ITEMS_PLACEHOLDER, $items, $template);
                $packages[] = $package;
            }
        }

        return $packages;
    }

Метод onResponse

Важно не только отправить сообщение, но и понять доставлено ли оно, а если нет то по какой причине. onResponse – это тот колбек, которым мы проинициализировали RollingCurl в методе send. Колбек принимает три параметра:

  1. $response – ответ в виде строки
  2. $info – результат функции curl_getinfo php.net/manual/en/function.curl-getinfo.php и возвращает массив с данными о передаче данных, начиная от http кода ответа и заканчивая скоростями закачки/загрузки. Но в данном туториале интересен лишь http код ответа.
  3. RollingCurlRequest $request — информация о запросе. Нас интересует $request->post_data

Комментарии в листинге функции будут красноречивее:

Листинг

    /**
     * @param string $response
     * @param array $info
     * @param RollingCurlRollingCurlRequest $request
     */
    public function onResponse($response, $info, RollingCurlRequest $request) {
       //Этот флаг показывает успешно ли отправлено сообщение
       $success = true;
//Декодирует json, который мы отправили в post
        $post = json_decode($request->post_data, true);
        if (json_last_error() != JSON_ERROR_NONE) {
            //анализируем json ошибку, возможно мы накосячили в синтаксисе.
            return;
        }
//Получаем RegistratonId и массив с данными 
        $regId = $post[self::KEY_REG_IDS][0];
        $items = $post[self::KEY_DATA][self::KEY_ITEMS];
//получаем код ответа
        $code = $info != null && isset($info['http_code']) ? $info['http_code'] : 0;
//Определяем группу кода: 2, 3, 4, 5
        $codeGroup = (int)($code / 100); 
        if ($codeGroup == 5) {
//Если код 5xx, это значит, что GCM сервер временно недоступен, сообщение не доставлено
//TODO Рекомендуется учитывать заголовок Retry-After
            $success = false;
        }
        if ($code !== 200) {
            //Ошибочный http код ответа, сообщение не доставлено
            //Если требуется более углубленный анализ кодов рекомендую прочитать описание формата ответа http://developer.android.com/google/gcm/gcm.html#response
            $success = false;
        }
        if (!$response || strlen(trim($response)) == null) {
            //пустой ответ, значит что-то пошло не так, считаем что сообщение не доставлено.
            $success = false;
        }

//анализируем ответ, см формат ответа http://developer.android.com/google/gcm/gcm.html#success
        if ($response) {
            $json = json_decode($response, true);
            if (json_last_error() != JSON_ERROR_NONE) {
                //ошибка парсинга json ответа, на всякий случай считаем что сообщение не доставлено
                $success = false;
                $json = array();
            }
        }
        else {
            $json = array();
            $success = false;
        }

// failure содержит количество недоставленных сообщений (в нашем случае получатель один, поэтому failure будет содержать либо 0 либо 1)
        $failure = isset($json['failure']) ? $json['failure'] : null;
// canonical_ids содержит количество получателей, для которых нужно обновить RegistrationId (как и в случае с failure - значение либо 0 либо 1). 
        $canonicalIds = isset($json['canonical_ids']) ? $json['canonical_ids'] : null;
//Если оба параметра равны нулю, то дальнейший анализ результата не требуется. При условии $success=true можно считать что сообщение успешно доставлено
        if ($failure || $canonicalIds) {
//results содержит массив объектов. Так как у нас получатель один, то результат тоже будет один (в случае ошибки или смены RegistrationId)
           $results = isset($json['results']) ? $json['results'] : array();
            foreach($results as $result) {
                $newRegId = isset($result['registration_id']) ? $result['registration_id'] : null;
                $error = isset($result['error']) ? $result['error'] : null;
                if ($newRegId) { 
// Заменяем $regId на $newRegId;
                }
                else if ($error) {
                    if ($error == self::GCM_ERROR_NOTREGISTERED) {
                        // Удаляем $regId из базы;
                    }
                    else {
                        //Произошла другая ошибка, логируем её
//Если нужно дифференцировать ошибки, то их описание можно найти здесь http://developer.android.com/google/gcm/gcm.html#error_codes
                    }
                    $success = false;
                }
            }
//Теперь мы знаем, доставлено ли сообщение для конкретного получателя или нет.
}

Что делать дальше? Например, можно проставить статус в своей базе, что сообщение доставлено. Но нужно помнить, что успешная отправка на GCM сервер еще не значит фактического получения сообщения смартфонов пользователя. Более того, вспомнив пример с отпуском становится понятно, что проставлять статус в onResponse нельзя. Тогда где? У меня есть только один вариант – проставлять статусы при получении данных поллингом. К сожалению, в большинстве случаев это означает, что получатель будет получать одни и те же данные два раза. На уровне приложения можно определять получены ли уже эти данные и если да – игнорировать их. Главный плюс этого подхода – надежность, данные всегда будут доставлены. Минусы – повышенный расход трафика и батареи.

Если вы еще не читали официальную документацию, рекомендую её к прочтению.

Послесловие

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

Автор: bdiang

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js