Pull to refresh

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

Reading time8 min
Views30K
imageВ рамках туториала мы напишем полноценный класс для отправки сообщений на GCM сервер, который:

  • получает на вход массив данных для отправки
  • формирует пакеты для отправки размером до 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 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;
                //Здесь есть нюанс, смотри Update 1
            }
            else if ($error) {
                if ($error == self::GCM_ERROR_NOTREGISTERED) {
                    //Удаляем $regId из базы;
                }
                else {
                    //Произошла другая ошибка, логируем её
                    //Если нужно дифференцировать ошибки, то их описание можно найти здесь http://developer.android.com/google/gcm/gcm.html#error_codes
                }
                $success = false;
            }
        }
    }

    //Теперь мы знаем, доставлено ли сообщение для конкретного получателя или нет.
}



Update 1
jcrow подсказывает в комментарии о подводном камне, которой может ждать при обновлении RegistrationId. Ситуация: пользователь удалил приложение и снова установил => повторно зарегистрировался и получил новый RegistrationId. В нашей базе уже две записи для одного и того же пользователя. Причем одна запись со старым RegistrationId, а другая — с новым. Если мы отправляем сообщения на оба RegistrationId, то для старого придет новый RegistrationId. В итоге имеем две записи для одного пользователя с одинаковыми RegistrationId.

В итоге: Если по полю RegistrationId в нашей базе стоит уникальный индекс — получаем ошибку. Если индекса нет — пользователь приложения получает по два одинаковых сообщения каждый раз.

Решение: Как только в onResponse мы получили новый RegistrationId, нужно проверить на его существование в базе. И в положительном случае или удалить одну из записей или смержить обе записи и связанные с ними данные из других таблиц.

Что делать дальше?


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

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

Послесловие


Надеюсь этот туториал не просто станет для кого-нибудь отправной точкой, но и поможет сократить сроки разработки бэкэнда вашего android-приложения.
Tags:
Hubs:
Total votes 19: ↑14 and ↓5+9
Comments8

Articles