APNS導致消息丟失和發送效率原因

探索

---談APNS(Apple PushNotification Service)

大咔!大咔!!

作爲一個移動視頻社交應用,大咔歷經無數風雨,而蘋果的消息推送(APNS)更是問題不斷。歷經一年多的探索我發現了一些APNS需要注意的地方,當然這些東東也是大咔消息推送的糾結之處,本文將討論這些問題。

APNS原理

什麼是APNS

APNS(Apple PushNotification Service)蘋果推送通知服務。該技術由蘋果公司提供的APNS服務。

APNS工作原理

首先,APNS會對用戶進行物理連接認證,和設備令牌認證(簡言之就是蘋果的服務器檢查設備裏的證書已確定其爲蘋果設備)。

然後,將服務器的信息接收並且保存在APNS當中,APNS從其中註冊的列表中查找該設備(設備可以爲iPhone、iPad、iTouch)並將信息發送到該設備。

最後,設備接收到數據信息給相應的APP,並按照設定彈出Push信息。


APNS發送簡述

APNS其實可以看做向一個蘋果提供的SSL地址去發送一個固定格式的JSON(實際發送出去的不是一個JSON,消息前面會跟上DeviceToken)。

建立SSL連接需要一個SSL證書。SSL證書是由IOS工程師導出來的,弄成一個“.pem”(“.p12”文件也行自己可以做成“.pem”文件)文件。

失敗之旅

服務器端

無法連接蘋果服務器

影響:程序報錯(無法連接建立連接)

說明:這個問題有可能是IOS工程師導出證書有誤或者推送地址選擇不正確導致。推送服務分爲正式和測試兩個環境。例如:如果用測試的SSL證書連接正式的SSL地址就會報無法連接的錯誤。

解決:先確定SSL地址是否正確(是正式還是測試),如果確定無誤那麼找蘋果開發工程師吧,讓他重新給你所需要的證書(注意是正式還是測試的)。一般一個證書導出來的“.pem”文件大小有8k左右(“.p12”文件有6k左右)如果大小隻3k-4k的話肯定就是錯的了,他們只導出了一半。

SSL寫數據成功任然收不到消息

這個纔是本文的重頭戲,各種收不到也都在這裏了。因爲跟蘋果建立連接以後寫成功了以後蘋果不會返回任何此消息是否能發送的提示,只有程序返回的true or false,所以這樣的錯誤很難調試。所以在開發的時候有可以從以下幾點入手調試:

一、badge字段蘋果只接受int類型

推送到蘋果的JSON中badge字段後面的值必須是int類型,也就是說你把JSON打印出來的badge的值是不帶引號的(正確:”badge”:1 錯誤:”badge:”1”)

二、發送的json是否查過長

蘋果對於消息的最大長度是255個字符。如果你超過了這個限制,是程序不會有任何提示,只是蘋果會把這些消息過濾掉不推送,導致消息推送失敗。

這255個字符指的是你發送到蘋果服務器整個JSON的長度(不包括Device Token的長度),這裏值得注意的一點的是,如果用PHP中的json_encode方法會把漢字變成“\ua38f”這樣的形式,也就是說一個漢字是6個字符(全角標點也會被轉成這樣的,半角標點、數字、英文不會)。

技巧
如果你需要推送的數據比較多,例如需要攜帶以下用戶信息的。那麼你可以把一條消息拆分成兩條消息發送。

第一條發送一個消息不帶蘋果默認的參數(alert、badge、sound)只攜帶自定義的參數,這樣的消息蘋果手機不會有任何反應(提示和數字),但是程序是能捕獲這個消息的。第二條消息則只包含蘋果所需的信息,不帶自定義的參數,這樣就會有一個消息提醒了。其實你是發送了兩條但是用戶會認爲只有一個。

如果用這種方式發送的話一定要先推送自定義參數(第一條)的消息,再推送蘋果默認參數的消息。否則會導致沒有提示音或者提示音剛開始就沒了。

三、正式和測試DeviceToken

正式環境,對於同一個設備,你同時存儲了測試的Token和正式的Token,這個是個超級糾結的問題了。這個問題經常會出現在正式和測試服務器來回切換的機器上。

因爲開發的時候都是是IOS程序員給機器裝的應用,這個時候拿到的Device Token是測試的Device Token,此時如果你使用蘋果的沙盒地址(測試服務器地址)那麼消息是可以正常收到的。而你從AppStore下載的應用這個時候得到的Device Token則是正式的,此時你需要通過蘋果的正式服務器發送消息就能正常收到消息。這兩個都是Device Token 但是完全不同。

而將測試Device Token在正式的蘋果服務器發送則會導致收不到消息的問題。你在正式服務器不慎同時存儲了正式和測試的token,並且之後的推,送順序是先推送測試token然後緊接着推送正式token的,即:

測試->正式->正式1

那麼你的設備將收不到任何消息。如果推送順序爲:

正式->測試->正式1

此時第一個的可以收到消息,後面的失敗,即第一條消息發送成功最後兩條均失敗。而:

正式->正式1->測試

則可以第一二條推送成功。

所以務必不要將測試的DeviceToken和正式的Device Token存儲混雜在一起。

客戶端

設備和網絡

由設備或者網絡導致收不到消息的情況很少出現。導致收不到消息的原因一般有以下幾點:

1、手機非正規途徑激活

說明:如果手機在激活的時候不是通過ITunes激活的設備。這樣的設備由於在激活的時候沒有連接蘋果服務器,所以收到推送通知的必備證書在設備中是沒有的。這個證書是收費的,在激活的時候由蘋果公司提供,並且下載到對應的手機(每個手機唯一)。

解決:無解(貌似有軟件可以做一個假的證書,沒試過)。

2、複雜的網絡原因

說明:這樣的情況在目前公司網絡偶爾能見。產生的原因是因爲手機沒有連上蘋果的推送服務器。蘋果手機在網絡狀態改變(例:3g切到wifi,無網絡到有網絡)的時候會去主動連接蘋果推送服務器。但是由於複雜的網絡情況(例如:多重路由、代理上網等等)或者網絡信號不好的情況下可能導致無法連接上蘋果推送服務器,這個時候就導致無法收到消息了。

解決:可以先切一下飛機模式,然後切回來。

首先說明一下,本文只是介紹一些容易被開發者忽視,而導致性能低下問題。並不是介紹如何向蘋果設備成功發送一條消息,這裏假設所有閱讀者已經能夠向蘋果服務器發送消息,並且成功接收,只是發送效率比較低,並且丟失率很高。如果你不是此類情況,那麼繞道吧。PS:伸手黨可以直接看標紅部分(結論)

    最近參與並且完成了公司1000W級的消息推送服務平臺重建。此次重構級別解決了消息丟失,並且大幅度提升了推送效率。有些東西我想很多開發者也會碰到,並且難以被開發者所意識到。

    先先掃下盲哈。如果你發送消息是一次連接發送一條,那麼請你先改成長連接發送--一次連接發送多條數據。粘下PHP代碼吧:)

  1. $pass = ''// $pass是你在建立證書的時候輸入的密碼  
  2. $ctx = stream_context_create();  
  3. // apns.pem就是你的證書的路徑了,最好寫絕對路徑  
  4. stream_context_set_option($ctx'ssl''local_cert''apns.pem');  
  5. stream_context_set_option($ctx'ssl''passphrase'$pass);  
  6. $fp = stream_socket_client('ssl://gateway.sandbox.push.apple.com:2195'$err$errstr, 60, STREAM_CLIENT_CONNECT, $ctx);  
  7. if(!$fp) {  
  8.     print "Failed to connect $err $errstr";  
  9.     exit();  
  10. else {  
  11.     print "Connection OK\n";  
  12. }  
  13. $body = array('aps' => array('badge' => 1));  
  14. for($i = 0; $i <= 10000; $i++) {  
  15.     $deviceToken = md5(time() . rand(0, 9999999)) . md5(time() . rand(0, 9999999)); // 模擬一個Device Token  
  16.     $body['aps']['alert'] = md5(time() . rand(0, 9999999)); // 隨便模擬點數據  
  17.     $payload = json_encode($body);  
  18.     // 這裏是簡單的消息結構,如果想多發幾個但是不要返回錯誤,可以用這個  
  19.     /* 
  20.     $msg = chr(0) . pack("n", 32) 
  21.         . pack('H*', str_replace(' ', '', $deviceToken)) 
  22.         . pack("n", strlen($payload)) . $payload; 
  23.     */  
  24.     // 這個是增強型消息格式,$i就是Identifier,864000就是Expiry了  
  25.     $msg = pack('CNNnH*', self::COMMAND_PUSH, $i, 864000, 32, $deviceToken)  
  26.         . pack('n'strlen($payload))  
  27.         . $payload;  
  28.     print "sending message :" . $payload . "\n";  
  29.     fwrite($fp$msg);  
  30.     // 這裏是讀取錯誤信息,不要沒發一條就讀取一次,這樣蘋果會認爲攻擊而終止連接  
  31.     //fread($fp, 6);  
  32. }  

    要往下面說我先解釋一下這個東東--Broken Pipe,如果你有過大量的數據推送,並且看下你的錯誤日誌那麼Writen Broken Pipe你一定不陌生。這個錯誤產生的原因通常是當管道讀端沒有在讀,而管道的寫端繼續有線程在寫,就會造成管道中斷。可以簡單的理解爲你在向一個已經關閉的連接寫數據就會拋出這個錯誤。

    由於Broken Pipe的關係,我們不得不重新和蘋果服務器建立連接,這個連接耗時在國內.....(你們懂的3sec+),這個應該是我們推送速度最大的瓶頸了。有很多開發者也許會認爲這個是由於國內的網絡環境導致,因爲他們習慣的“traceroute gateway.push.apple.com”一下,然後發現30+的路由跳轉然後就會說這個斷開是無法避免的。如果你這麼想那麼你就錯了

    我們用大量(10W左右)能保證基本正確的Device Token來做測試,平均一次連接的能寫入3W左右的數據,好的情況下能一次寫完這10W數據!!!這個測試也就證明平凡出現Broken Pipe不是由於網絡原因。既然不是由於網絡原因,那麼我做個大膽的假設:這個連接是由APNs主動斷開的

    那麼假設這個猜想是正確的,那蘋果什麼時候會斷開連接了?解釋這個問題,我們又做了一個測試:往這10W的Device Token裏面均勻插入1000個錯誤的Device Token。神奇的事情發生了,發送期間平均斷開連接900次+。這個實驗正好驗證了我之前的猜測:產生Broken Pipe是因爲APNs服務器主動斷開了連接,並且是由於錯誤的Device Token引起的(或者其他的錯誤)。蘋果的錯誤類型和代碼編號:

Status code

Description

0

No errors encountered

1

Processing error

2

Missing device token

3

Missing topic

4

Missing payload

5

Invalid token size

6

Invalid topic size

7

Invalid payload size

8

Invalid token

10

Shutdown

255

None (unknown)

    我們進一步跟進測試,我們發現一個奇怪的現象,斷開連接的時的前一個Token並不是我們所特意設置的錯誤Token。同時我們也發現消息送達率也變得非常的低(偶爾有設備能收到)。這個很好解釋,之前我就有文章提到過(官方也有相應說明)當一次連接先發送一個錯誤的Token,之後的有效Token的消息是無法送達的(http://blog.csdn.net/hjq_tlq/article/details/8131115),這就導致了錯誤的Token後面的正確的Token全部沒有收到,從而送達率也就明顯下降了。

    經過上面的測試,當APNs接收到錯誤的Token的時候會主動斷開連接,但是斷開連接之前會有1sec左右的延遲。那麼你可以有下面這個例子理解:

        你要發送1000條數據並且第20個Token是錯誤的

        當此次連接發到第20個Token的時候蘋果認爲此次連接終止(但是連接並沒有斷開,只是APNs將拋棄之後的內容),並且不處理此次連接之後的消息

        1sec左右的時間之後蘋果主動斷開SSL連接,如果你繼續忘此連接寫數據,你將可以捕捉到Broken Pipe錯誤

        此時由於1sec左右的延遲,你已經發送到了第123個消息

        此時從20以後直至123的消息將全部沒有送達

    太可怕了.....你竟然不知道是從哪一個錯了!!!蘋果是SB啊!先不要做這樣的結論,我們先看一下蘋果官方文檔所給出的東東:

Figure 5-1  Notification format

The first byte in the notification format is a command value of 1. The remaining fields are as follows:

  • Identifier—An arbitrary value that identifies this notification. This same identifier is returned in a error-response packet if APNs cannot interpret a notification.

  • Expiry—A fixed UNIX epoch date expressed in seconds (UTC) that identifies when the notification is no longer valid and can be discarded. The expiry value uses network byte order (big endian). If the expiry value is positive, APNs tries to deliver the notification at least once. Specify zero (or a value less than zero) to request that APNs not store the notification at all.

  • Token length—The length of the device token in network order (that is, big endian)

  • Device token—The device token in binary form.

  • Payload length—The length of the payload in network order (that is, big endian). The payload must not exceed 256 bytes and must not be null-terminated.

  • Payload—The notification payload.

    PS:這裏蘋果到是做了件好事,這個消息結構在早些的文檔中顯示的是5-2 Enhanced Notification Format,而之前的5-1是Notification Format。區別在於之前的5-1中的消息結構爲簡單消息結構,沒有Identifier和Expiry字段並且Command爲0。現在直接把簡單的消息體結構給去掉了,這樣可以強制開發者加上Identifier,從而得到返回值。

    爲了方便我直接把官方文檔粘過來了哈:)我們需要注意的是Identifier這個東東。沒錯,這個就是蘋果用來提供的給第三方的4唯一標示,如果鳥語不是很好的話他後面的那個註釋大致就是說:一個消息的唯一標識。如果蘋果服務器不能解釋這個消息,那麼將在錯誤中返回這個唯一標示。

    可惡的蘋果並沒有說明這個會有延遲,以及怎麼確保我們能收到這個錯誤。我們現在採用的是每發送100條消息,就檢查一下(read)是否有失敗的。如果你抓到這個錯誤,那麼果斷斷開連接,並且重新發送這條錯誤以後的Token,這樣就能保證消息基本能送達。

    哦,順便說一下如何得到錯誤反饋,如果你發送的時候加上了Identifier,那麼此時你一定有一個和APNs的連接吧(廢話,沒連接怎麼write),那麼你只要read就好了,如果有就能讀到一個二進制數據:)

    有一個也需要提一下,就是APNs的FeedBack功能也一定要用上,這個能幫助你更好的剔除錯誤的Token。

    當你的Token基本爲正確的時候,如果還有大量的Broken Pipe出現,你可以給我留言,我們一起研究到底哪裏出問題了:)

    附錄:蘋果推送官方文檔


 





這段時間一直在總結IOS消息推送的東西,前面也寫了些自己在處理這個需求時遇到的問題已經解決方法。

查看《通過php對IOS設備進行消息推送流程》、《IOS消息推送

下面貼上自己的代碼:

<?php
error_reporting(0);
set_time_limit(0);
 
class push
{
	private $message = '';
	private $article_id = '';
 
	private $group_size = 15;
 
	//證書
	private $certificate = '';
 
	//密碼
	private $passphrase = '';
 
	//PUSH地址
	//private $push_url = 'ssl://gateway.push.apple.com:2195';
	private $push_url = 'ssl://gateway.sandbox.push.apple.com:2195';
 
	//feedback地址
	//private $feedback_url = 'ssl://feedback.push.apple.com:2196';
	private $feedback_url = 'ssl://feedback.sandbox.push.apple.com:2196';
 
	private $push_ssl = null;
	private $feedback_ssl = null;
 
	//是否獲取feedback信息
	private $get_feedback_info = null;
 
	public function __construct($message, $article_id)
	{
		$this->message = $message;
		$this->article_id = $article_id;
 
		$day = date('d', time());
		if($day % 9 == 0)
		{
			$this->get_feedback_info = true;
		}
		else
		{
			$this->get_feedback_info = false;
		}
	}
 
	public function push_message($tokens)
	{
		$this->open_push_ssl();
 
		$payload = $this->create_payload();
 
		//對device tokens信息進行分組
		$group_tokens = array_chunk($tokens, $this->group_size, true);
		$group_num = count($group_tokens);
		$mark = 0;
 
		$success_tokens = array();
		$feedback_tokens = array();
 
		foreach($group_tokens as $token)
		{
			$mark++;
			foreach($token as $value)
			{
				$msg = chr(0) . pack('n', 32) . pack('H*', $value['device_token']) . pack('n', strlen($payload)) . $payload;
				$result = fwrite($this->push_ssl, $msg, strlen($msg));
 
				if(!$result)
				{
					$this->close_push_ssl();
					sleep(1);
					$this->open_push_ssl();
				}
				else
				{
					$success_tokens[] = $value['device_token'];
 
					if($this->get_feedback_info)
					{
						if($this->feedback_info())
						{
							$feedback_tokens[] = $this->feedback_info();
						}
					}
				}
			}
 
			if($mark < $group_num)
			{
				$this->close_push_ssl();
				sleep(5);
				$this->open_push_ssl();
			}
		}
 
		$this->close_feedback_ssl();
		$this->close_push_ssl();
	}
 
	//鏈接push ssl
	private function open_push_ssl()
	{
		$ctx = stream_context_create();
		stream_context_set_option($ctx, 'ssl', 'allow_self_signed', true);
		stream_context_set_option($ctx, 'ssl', 'verify_peer', false);
		stream_context_set_option($ctx, 'ssl', 'local_cert', $this->certificate);
		stream_context_set_option($ctx, 'ssl', 'passphrase', $this->passphrase);
 
		$this->push_ssl = stream_socket_client($this->push_url, $err, $errstr, 60, STREAM_CLIENT_CONNECT, $ctx);
 
		if(!$this->push_ssl)
		{
			echo "Failed to connect Apple Push Server {$err} {$errstr}! Please try again later.<br/>";
			exit();
		}
	}
 
	private function close_push_ssl()
	{
		fclose($this->push_ssl);
	}
 
	//根據實際情況,生成相應的推送信息,這裏需要注意一下每條信息的長度最大爲256字節
	private function create_payload($message, $article_id)
	{
		$body = array();
		$body['aps'] = array(
							'alert' => $this->message,
							'badge' => 1,
							'sound' => 'default',
							'activityId' => $this->article_id
		);
		return json_encode($body);
	}
 
	private function open_feedback_ssl()
	{
		$ctx = stream_context_create();
		stream_context_set_option($ctx, 'ssl', 'allow_self_signed', true);
		stream_context_set_option($ctx, 'ssl', 'verify_peer', false);
		stream_context_set_option($ctx, 'ssl', 'local_cert', $this->certificate);
		stream_context_set_option($ctx, 'ssl', 'passphrase', $this->passphrase);
 
		$this->feedback_ssl = stream_socket_client($this->feedback_url, $err, $errstr, 60, STREAM_CLIENT_CONNECT, $ctx);
 
		if(!$this->feedback_ssl)
		{
			echo "Failed to connect Apple Feedback Server {$err} {$errstr}! Please try again later.<br/>";
			exit();
		}
	}
 
	private function close_feedback_ssl()
	{
		fclose($this->feedback_ssl);
	}
 
	private function feedback_info()
	{
		$this->open_feedback_ssl();
 
		while($devcon = fread($this->feedback_ssl, 38))
		{
			$arr = unpack("H*", $devcon);
			$rawhex = trim(implode("", $arr));
			$feedbackTime = hexdec(substr($rawhex, 0, 8));
			$feedbackDate = date('Y-m-d H:i', $feedbackTime);
			$feedbackLen = hexdec(substr($rawhex, 8, 4));
			$feedbackDeviceToken = substr($rawhex, 12, 64);
		}
 
		if(is_null($feedbackDeviceToken))
		{
			return $feedbackDeviceToken;
		}
		else
		{
			return false;
		}
	}
}

需要注意的幾點:

1、推送消息的長度限制:每次發送消息又必須少於256 字節,蘋果服務器接收推送消息一次只可以接收7000 字節。這裏需要對推送的消息做些長度限制。

2、獲取本地數據庫device tokens信息:這裏是一次性獲取所有的device tokens信息,然後再進行分組推送,如果數據量大的話不建議這樣做,建議直接分批次從數據庫中獲取數據。其實這個獲取device tokens信息的方式,不知道是不是可以通過redis隊列來完成,最近在看redis方面的東西,對redis還不是很瞭解。

3、feedback服務:要獲取feedback信息,必須先進行push一次,纔可以獲取該設備的相關信息,如果用戶已經刪除APP運用,則返回設備token信息,否則爲空。獲取這些無用的device tokens信息後進行對本地數據庫中的tokens進行更新,保證數據庫存儲的都是有用的數據。上面代碼沒有體現這個操作。

發佈了25 篇原創文章 · 獲贊 6 · 訪問量 22萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章