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

от автора

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

  • получает на вход массив данных для отправки
  • формирует пакеты для отправки размером до 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 \RollingCurl\RollingCurlRequest $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-приложения.

ссылка на оригинал статьи http://habrahabr.ru/post/161305/