Как пришлось бороться с нестабильным Google C2DM

Так уж случилось, что на работе я с небольшой командой единомышленников занимаюсь написанием приложений для смартфонов, в частности iТелефон и Андроид.

Начинали мы с разработок под iPhone, где все работало гладко и как положено.
А что работало? Основная задача приложения была послать запрос «Где ты?» — ничего сложного. Но уж очень хотелось бы этот запрос доставлять адресату как можно быстрее, пока он еще актуален. Здесь, имеющий опыт в разработках под iPhone, читатель скажет, что есть APN Service, и будет абсолютно прав. Именно им мы и пользовались, и не знали горя, ибо доставлялись эти уведомления быстрее секунды.

Затем по некоторым внутренним причинам мы перешли на разработки под Android и быстренько все портировали. В частности без каких-либо задних мыслей модуль работы с APN был заменен на аналогичный с C2DM.

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

В ходе исследования этой проблемы я натолкнулся на ряд странных особенностей работы этих уведомлений от Google.


Интересующиеся реализованным низкоуровневым взаимодействием смартфона с сервером без разбора предпосылок могут эти предпосылки пропустить и перейти к разделу «4. Альтернатива Google C2DM, но не замена».

1. Схема использования уведомлений


Прежде всего схемка (обозначения выбраны просто для наглядности, а не по ГОСТу):
схема сетевых взаимодействий смартфон-сервер-C2DM
Для изучения проблемы нужно понять, как устроены соединения 1, 2 и 3.
  1. Это нами открытое соединение по HTTP, которое шлет запрос. Приложение ждет от сервера лишь 200 OK и остальное не важно. Здесь широкая часть бутылки — пользователей пока мало, и шлют запросов они немного (60-100 сообщений/с во время активной работы).
  2. Это соединение наш сервер открывает по протоколу HTTP к серверам Google. При этом приходится делать 2 последовательных соединения: сначала авторизация рекомендуемым способом — ClientLogin, затем запрос к android.clients.google.com/c2dm/send. Первым делом искать проблему начали именно здесь.
  3. Наконец, это соединение держит сам Google со смартфонами под управлением Android.


2. Ищем проблемы с запросом к C2DM


Раз уж я разрабатываю в основном серверную часть, то первый камень прилетел в меня за возможные проблемы в соединении №2. Что же было предпринято?

По моему скромному мнению лучшее объяснение, как подключить C2DM в приложении, есть в хабратопике Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM)

Само это соединение реализовано по рекомендациям этой статьи.

Иногда при подключении к серверу Google приходил Connection timed out, что натолкнуло меня на мысль об ограничении количества наших одновременных подключений. Может мысль и ошибочная, но примененное решение оказалось полезным.
Серверная часть написана на Java и запускается как отдельный JAR с встроенным в него Jetty. За настройку и запуск отвечает Spring Framework, а значит, мне довольно безболезненно удалось перенастроить взаимодействие с C2DM сервером.

Шаг 1

Добавить асинхронности в выполнение запроса.

public class C2DMServer implements IPushNotificator, IPushChecker {

	...

	@Override
	@Async // вот так добавить асинхронность
	public void sendData(String deviceId, String c2dmID, String jsonObject) {
		...
	}

}


Этот шаг дал еще одно улучшение — ответ 200 OK запрашивающему клиенту теперь приходит гораздо быстрее, так как поток не ждет ответа от сервера уведомлений Google.

Шаг 2

Настроить количество параллельных запросов к Google, чтобы как раз уложиться в лимиты.
Здесь было множество тестов и подбора коэффициентов, а результат вылился в такую настройку Spring.

<task:annotation-driven executor="asyncExecutor" />
<task:executor id="asyncExecutor" pool-size="15" queue-capacity="300" rejection-policy="CALLER_RUNS" />


Если pool-size выставлять более 15, то такое количество одновременных подключений приводит к разного рода сетевым ошибкам.

Итог

Что порадовало: больше не появлялись ошибки подключения к серверу Google.
Что расстроило: проблема скорости доставки осталась, а значит, движемся дальше.

3. Исследуем работу Google с Android


Любое приложение после установки на Android может запросить у специального сервиса идентификатор, с которым нужно отправлять уведомления.

Это делается наследованием от базового класса C2DMBaseReceiver.
Пример такого наследования можно посмотреть все в том же хабратопике Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM). Вот модифицированная реализация у меня:

import com.google.android.c2dm.C2DMBaseReceiver;

public class C2DMReceiver extends C2DMBaseReceiver {

	private static final String DATA = "data";

	public C2DMReceiver() {
		super(Settings.C2DM_ACCOUNT);
	}

	@Override
	public void onDestroy() {
		super.onDestroy();
	}

	@Override
	public void onError(Context context, String errorId) {
		Settings.Init(context, false);
		Settings.updateC2DM(null);
	}

	@Override
	protected void onMessage(Context context, Intent receiveIntent) {
		Settings.Init(context, false);
		String data = receiveIntent.getStringExtra(DATA);
		JSONUtils.processJSON(context, data);
	}

	@Override
	public void onRegistered(Context context, String registrationId) {
		Settings.Init(context, false);
		if (!registrationId.equals(Settings.getC2dm_id()))
			Settings.updateC2DM(registrationId);
	}

	@Override
	public void onUnregistered(Context context) {
	}
}

Здесь Settings — класс помощник с кучей статических полей и методов для хранения состояния приложения. JSONUtils — еще один класс помощник, разбирающий JSON и сохраняющий все данные в Settings.

Что важно понимать, так это то, что момент получения идентификатора не определен. Фактически, этим классом мы лишь вешаемся на событие получения C2DM идентификатора, и по идее при его срабатывании незамедлительно должны передать идентификатор на сервер.
Пример такого идентификатора: «APA91bF8hral5wCq_E7HPD1wq29aSIEYyY2g_P4BOue_CaBTJvTHKFPplmp2MHxFgn3c1ysNjTHyXmsp8OejRSc809ZiOYqNcXoJWiWfvarCayT6ar3RyZwRRV0CrgQNaPjLxTrYqXXcQfcxjB07xmjeNtUzc6UlGQ».

После этого любое сообщение к C2DM серверу с этим идентификатором должно быть доставлено на нужное устройство и нужному приложению.

Посмотрим как доставляются эти сообщения

схема взаимодействий C2DM-GTalk-приложение

В центре всего стоит сервис Cloud To Device Messaging.
Что интересно, на проблемных устройствах этот сервис иногда был выгружен из памяти. Это значит, что он не берет никаких блокировок ОС и вполне может выключиться, когда Android-у понадобятся ресурсы. Этот сервис в качестве ядра протокола обмена использует Google Messaging сервис, от которого также зависит GTalk. Это происходит, потому что C2DM протокол инкапсулирован в XMPP протокол, по которому обменивается GTalk. По этому каналу раз в 300 секунд C2DM сервис шлет Ping на сервера Google и ожидает Ack, подтверждающий, что соединение в порядке. Подробнее можно узнать у первоисточника в этом видео.
С сервисами все, конечно, не настолько печально. Сервис уведомлений умеет восстанавливаться при изменении условий сети и при включении экрана, хотя и не всегда.
Чтобы посмотреть состояние своего соединения, можно набрать *#*#8255#*#* и в открывшемся GTalk Service Monitor посмотреть, какое приложение какой обмен через Google Messaging проводило.

Итак, часть проблемы была идентифицирована, но решения для нее не было.
Почему именно часть? Потому что уведомления все равно не доходили даже при работающих сервисах. Иногда замечались волны уведомлений, когда через некоторое время (20-40 минут) все устройства получали уведомления одновременно, хоть и отправленные в разное время.

В итоге после размышлений, чтений документации и множества форумов и Q&A все сошлись на одном — будем делать альтернативный канал уведомлений.

4. Альтернатива Google C2DM, но не замена


Основной вопрос: как устроить стабильный канал сервер-клиент?
Побочный вопрос: как не съедать этим каналом всю батарейку пользователя?

Источником вдохновения стали примеры с ресурса http://code.google.com/p/android-random/.
В частности пример KeepAliveService.

Первая идея — лобовое решение: раз в n секунд открывать подключение к серверу и проверять нет ли уведомлений.
Вместо этого лобового решения «часто опрашивать сервер» авторы предлагают более разумный вариант, хотя и похожий на своего рода хак.

Фишки предложенного решения:
  • подключение к серверу нужно держать постоянно, а не переподключаться с интервалом;
  • раз в n секунд, где n > 60, проверять состояние подключения отправкой в него чего-либо и переподключаться только при обрыве;
  • использовать блокирующий read на подключении к серверу.


Я провел тестирование разных вариантов работы клиента с сервером уведомлений.
Было написано 2 клиента:
  1. Открывал раз в n секунд и считывал, не появилось ли чего. Именно n варьировалось в тестировании
  2. Открывал постоянное подключение и проверял его раз в 60 секунд. Варьировались устройства, чтобы узнать насколько различаются времена жизни.

Первый клиент реализовать не трудно самостоятельно. Все тонкости второго можно посмотреть в архиве. В него включены клиент Android второго типа и сервер, поддерживающий подключение, выводящий в лог все keepalive сообщения клиента, а также раз в минуту по своему случайному разумению отправляющий на клиент уведомление. Собирается все Maven-ом с подключенным android-maven-plugin.

Клиент 1 Клиент 2
Устройство Desire Desire Desire Desire Desire Wildfire S Desire S
Продолжительность теста (мин.) 540 1273 845 962 1117 1180 1121
Расход единиц заряда 82 87 31 9 39 80 49
KeepAlive в секундах 10 30 60 60 60 60 60
Подключение к Интернет 3G 3G 3G WiFi 3G 3G 3G
Вычисленная скорость разряда (е.з./ч) 10 4.28 2.22 0.56 2.22 4.28 3

разряд батареи в е.з. в час

Сразу бросается в глаза несколько результатов:
  1. Хоть переоткрывать подключение, хоть держать его открытым — это не имеет значения с точки зрения батарейки.
  2. 3G выжигает батарейку в разы быстрее, чем WiFi.
  3. Оптимальное время опроса 60 секунд.

Для нас самый важный — первый результат. Из него следует, что выбирать из двух клиентов нужно по функциональным возможностям. У переподключающегося клиента (№1) уведомления приходят лишь один раз в указанный интервал проверки. У клиента, поддерживающего подключение (№2), уведомления приходят в тот момент, когда в открытое подключение напишет сервер. Причем, забегая вперед, скажу, что даже уснувшее устройство просыпается, когда в открытое подключение приходит сообщение от сервера.

Чтобы выдержать наплыв TCP подключений я построил следующую архитектуру.
собственный сервер уведомлений

Сервер уведомлений состоит из двух компонент:
  • Маршрутизатор — регистрирует подключенное устройство и отдает ему адрес и порт сервера, с которым держать подключение. Кроме того все запросы на отправку уведомлений он маршрутизирует к нужному серверу уведомлений.
  • Сам сервер уведомлений — держит подключение, сообщает маршрутизатору об успешном получении keepalive от устройства и отправляет уведомление, если его вызвал маршрутизатор.

Все взаимодействие с клиентом идет на чистом TCP. Само уведомление может быть произвольного размера и содержания, но для уменьшения нагрузки в своем приложении я шлю ровно один байт «1».
Между компонентами сервера, используя Spring Remoting, поднимаются RMI соединения.

Теперь посмотрим логику клиентской стороны по шагам.

  1. Клиент при подключении к серверам уведомлений сообщает свой уникальный идентификатор, в моем случае это просто GUID.
  2. В ответ он получает адрес и порт, куда открывать и поддерживать подключение.
  3. После открытия socket-а клиент уходит в блокирующий read без установки timeout-а чтения.
  4. Используя AlarmManager, клиент раз в 60 секунд просыпается и отправляет в открытое подключение сообщение со своим GUID-ом. Так сервер узнает, что за клиент все еще жив.
  5. Если подключение упало, то клиент проверяет наличие какого-либо доступа в Интернет и при его наличии переподключается.
  6. Если read вернул данные, значит, пришло уведомление, о чем сообщается остальной логике приложения, а клиент опять уходит в блокирующий read.


Работа с AlarmManager-ом очень простая.
// создаем интент, которым нас известят о событии таймера
Intent i = new Intent(this, NotificationService.class).setAction(ACTION_KEEPALIVE);
PendingIntent pi = PendingIntent.getService(this, 0, i, 0);
// передаем временной интервал и интент AlarmManager-у
AlarmManager alarmMgr = (AlarmManager) getSystemService(ALARM_SERVICE);
alarmMgr.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + KEEP_ALIVE_INTERVAL, KEEP_ALIVE_INTERVAL, pi);

Подробнее в документации.

Результаты и проблемы моей реализации или почему нельзя полностью отказаться от C2DM


  • Для максимальной легковесности все сервера уведомлений не работают ни с файлами, ни с базами данных. Отсюда первое следствие: если уведомление не дошло, оно никогда уже не дойдет.
  • Сервера уведомлений ничего не знают о данных в маршрутизаторе. Второе следствие: клиент не обязан слушаться маршрутизатора и идти именно на указанный адрес и порт, а значит, клиенты могут устроить атаку на один сервер уведомлений, тогда как другие будут простаивать.
  • Маршрутизатор запоминает, когда от клиента приходил keepalive. Полезное третье следствие: маршрутизатор может сообщать эту информацию внешним системам, а эта информация по сути представляет собой записи о том, кто сейчас online.
  • Маршрутизатор запоминает, на какой сервер уведомлений клиент отправил keepalive. Четвертое следствие: даже если клиент подключается к неправильному серверу, маршрутизатор будет знать, через какой сервер уведомлений отправлять пакет, вместо рассылки этого пакета по всем серверам.


5. Заключение


«Любой уважающий себя программист для смартфонов должен написать свою реализацию сервиса уведомлений» — так в шутку охарактеризовали результат моей работы.
Но несмотря на шутку, описанный выше сервис уведомлений работает на той же скорости и почти с той же стабильностью, как у Apple, что не может не радовать, а время жизни устройства, о котором так много волнуются разработчики, сокращается совсем не на много.

6. Полезные ссылки


Размышления на тему, как реализовать хорошую доставку уведомлений
Документация Google по подключению C2DM
Хабратопик «Пишем приложение под Android с поддержкой Cloud to Device Messaging (C2DM)»
Жалобы на скорость работы C2DM и другие — надеюсь среди них однажды появится ответ «Ура! Все заработало!».
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 12

    +2
    Дважды пытался использовать code.google.com/p/chrometophone/ (использующий C2DM) и соответствующие плагины к FireFox/Chrome — сначала когда эта технология только появилась, второй раз не так давно.
    Фактически, это не работало. Т.е. вероятность что отосланные данные придут на телефон в течении, скажем, минуты — где-то 50/50. Часто ничего не приходило вообще. Очень редко — приходило в течении единиц секунд.
    Честно говоря, я думал что это кривая реализация chrometophone или моей прошивки телефона.
    Но теперь вижу что всё, видимо, хуже. Интересно, Google не смущает такая надёжность их сервиса? :)
      +1
      Прямо у Google-овцев в группах можно найти ответы вида: «а никаких гарантий о доставке и нет» и «время доставки гарантировать невозможно». В чем-то я их понимаю — у них сейчас зоопарк устройств с непонятно как измененными прошивками.
      На самом деле даже в документации Apple никаких гарантий нет, зато есть упоминание, что не доставленное уведомление через некоторый период времени может быть удалено с сервера. Но чудесным образом ни одно уведомление еще не потерялось.
        +1
        Не знаю, как у вас отношения с Apple, у меня с их уведомлениями тоже проблема: сегодня отлично доставляется, завтра нет. И на первый айпад почему-то у меня вообще не доходят. То ли косяки с устройством, то ли с эпплом, потому что один и тот же код работает на айфоне, но не работает на айпаде.
      0
      Вопрос: правильно я понимаю, что если использовать гугловский c2dm, то требуется не только 2.2 + установленный маркет(play), но и еще чтобы юзер зашел под своим аккаунтом туда?
        0
        да
        +1
        Хочу поделиться кое-чем, что мне удалось вычитать-надергать-проверить при разбирательстве, почему на андроиде ICQ-клиенты, не использующие C2DM либо какой-то свой протокол (чаще всего с поллингом, причем только при включенном экране), очень активно съедают батарейку. Пришел к такому выводу:

        По всей видимости, блокирующее чтение из сокета создает неявный wakelock, т.е. процессор устройства не уходит в сон, но при этом Timer'ы и Handler'ы, использующиеся в приложении, останавливаются и не срабатывают. Не факт конечно, что это происходит на всех устройствах, но попробовать использовать неблокирующее чтение, пробуждающееся периодически при помощи AlarmManagera, думаю, стоит.
        У вас клиент, открывающий соединение и поддерживающий показали равные результаты, по-видимому, на разных устройствах это реализовано по-разному. Либо, возможно, где-то в вашем клиенте либо в целом в процессе тестирования закрался wakelock, хотя полагаю, что вы это проверили.

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

        Подробной проверки на спектре устройств не проводил, так что на истину не претендую, так, информация к размышлению.
          0
          Сейчас тестирую на боевом приложении и никак не мог найти причину, почему некоторые устройства уходят в оффлайн минут на 20, хотя интернет у них есть. Думал, что всему виной какая-то ошибка на сервере. Посмотрю в вашем направлении про таймауты.
            0
            Кстати, а как вы именно проводили измерение расхода батареи в вашем тесте?
              0
              Клиент в каждом keepalive-е шлет состояние своей батареи.
              		private String readBattery() {
              			Intent intent = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
              			int batteryLevel = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
              			int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
              			int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
              			return "battery:" + batteryLevel + " plug:" + plugged + " status:" + status;
              		}
              


              Этот метод и его применение можно найти в архиве тестового клиента.
              Ссылку продублирую.
            0
            Тут, на хабре, была статья (перевод, кажется), в которой как раз говорилось о том, что операторы в целях собственной выгоды (для оптимизации ресурсов) используют не самые выгодные для клиента настройки сети, за что клиент расплачивается временем работы аппарата от батарейки.
            0
            Приведенный в примере клиент уведомлений сходит с ума, если устройству ограничен доступ к серверу уведомлений по какой-либо причине.
            Например, если запускаться в корпоративной среде с выходом через WiFi и прокси, то так как прокси сервер на запросы по TCP подключению в ответ шлет хоть что-то, хотя бы 400 Bad Request, то выход из блокирующего read-а происходит и считается, что пришло уведомление. Стоит это учитывать и в боевом приложении проверять, что именно вернул read.

            PS. Я не учел…
              –1
              WebSocket + небольшие модификации (keep-alive) = готовое решение проблемы.
              Приятная в использовании штука.

              Only users with full accounts can post comments. Log in, please.