В рамках туториала мы напишем полноценный класс для отправки сообщений на GCM сервер, который:
GCM – это сервис доставки мгновенных сообщений. Альтернатива стандартным polling и long polling, но не исключающая, а дополняющая их. Гарантии, что сообщение будет доставлено Гугл не дает (хотя надежность и скорость доставки стала просто космической по сравнению с предком C2DM). Если на телефоне интернет выключен, то сообщение будет храниться на GCM сервере до 4х недель. Т.е если пользователь выключил телефон, уехал в отпуск, то по приезду сообщение он может уже не получить. Поэтому GCM должен работать только вместе с надежными способами доставки такими как, например, элементарный polling – отправка http запросов на сервер каждые N минут.
Любое android приложение может зарегистрировать себя в качестве получателя сообщений от GCM. При включенном интернете регистрация происходит за считанные секунды. Как только это произошло приложение получает от GCM сервера RegistrationId, который нужно отправить на свой сервер. В итоге в базе сервера мы имеем, например, таблицу Devices, хранящую информацию об устройствах, включая их RegistrationId.
Чтобы устройства начали получать сообщения серверный код должен отправлять POST запросы на GCM сервер в формате json (можно отправлять и обычные ключ => значение, но рекомендуется именно json). Ответ сервера также содержит json, проанализировав который мы сможем понять доставлено ли сообщение, а если нет — какие ошибки произошли.
Создадим два класса GcmPayload и GcmSender.
В терминологии GCM payload – это данные, которые вы хотите отправить получателю. Эти данные должны храниться в значении ключа data и имеют ограничение в 4096 байт. Подробнее про формат запроса.
GcmPayload – модель данных для одного получателя и соответственно одного RegistrationId. Поле $jsons должно быть проинициализировано массивом json’ов в виде строк, содержащих данные, которые нужно отправить этому получателю. Для упрощения туториала считаем, что это делается вне нашего класса, например, так:
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»: []}
Это паблик метод должен вызываться для непосредственной отправки данных на GCM сервер. Метод принимает данные для отправки, которые методом getPackages преобразутся в пакеты данных – подготовленные post данные в формате json (один пакет – один запрос) и гарантированно не превышающие 4096 байт. Остальная часть метода – это инициализация замечательной библиотеки RollingCurl, которая инкапсулирует в себе работу с curl_multi_exec и позволяет отправлять запросы параллельно и писать прозрачный код. RollingCurl инициализируем нашим колбэк методом onResponse, в котором будем анализировать результат отправки. Далее идет непосредственно сама отправка данных.
В этом методе перебирается массив переданных классу payload’ов и постепенно наполняется шаблон, созданный в конструкторе, до тех пор пока пакет не превысит лимит в 4096 байт или данные для получателя не закончатся. Кстати, в нашем примере считаем, что один пакет – один получатель. Что это значит? Например, такая условность справедлива когда текстовое сообщение адресовано только одному человеку. Но в групповых беседах одно и тоже сообщение можно отправить несколько людям и GCM это позволяет указав в значении ключа registration_ids несколько RegistrationId. Но повторюсь, в данном примере во избежание ненужных усложнений этот случай не рассматриваем.
Вернемся к методу getPackages. На самом деле здесь интерес представляет функция isReadyToFlush, которая определяет приведет ли добавление нового json к пакету выход за рамки лимита в 4096 байт. Если да, то пакет тут же завершается и этот json добавляем уже в новый пакет.
Важно не только отправить сообщение, но и понять доставлено ли оно, а если нет то по какой причине. onResponse – это тот колбек, которым мы проинициализировали RollingCurl в методе send. Колбек принимает три параметра:
Комментарии в листинге функции будут красноречивее:
Update 1
jcrow подсказывает в комментарии о подводном камне, которой может ждать при обновлении RegistrationId. Ситуация: пользователь удалил приложение и снова установил => повторно зарегистрировался и получил новый RegistrationId. В нашей базе уже две записи для одного и того же пользователя. Причем одна запись со старым RegistrationId, а другая — с новым. Если мы отправляем сообщения на оба RegistrationId, то для старого придет новый RegistrationId. В итоге имеем две записи для одного пользователя с одинаковыми RegistrationId.
В итоге: Если по полю RegistrationId в нашей базе стоит уникальный индекс — получаем ошибку. Если индекса нет — пользователь приложения получает по два одинаковых сообщения каждый раз.
Решение: Как только в onResponse мы получили новый RegistrationId, нужно проверить на его существование в базе. И в положительном случае или удалить одну из записей или смержить обе записи и связанные с ними данные из других таблиц.
Например, можно проставить статус в своей базе, что сообщение доставлено. Но нужно помнить, что успешная отправка на GCM сервер еще не значит фактического получения сообщения смартфоном пользователя. Более того, вспомнив пример с отпуском становится понятно, что проставлять статус в onResponse нельзя. Тогда где? У меня есть только один вариант – проставлять статусы при получении данных поллингом. К сожалению, в большинстве случаев это означает, что получатель будет получать одни и те же данные два раза. На уровне приложения можно определять получены ли уже эти данные и если да – игнорировать их. Главный плюс этого подхода – надежность, данные всегда будут доставлены. Минусы – повышенный расход трафика и батареи.
Если вы еще не читали официальную документацию, рекомендую её к прочтению.
Надеюсь этот туториал не просто станет для кого-нибудь отправной точкой, но и поможет сократить сроки разработки бэкэнда вашего 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 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. Колбек принимает три параметра:
- $response – ответ в виде строки
- $info – результат функции curl_getinfo php.net/manual/en/function.curl-getinfo.php и возвращает массив с данными о передаче данных, начиная от http кода ответа и заканчивая скоростями закачки/загрузки. Но в данном туториале интересен лишь http код ответа.
- 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-приложения.