探索
---谈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代码吧:)
- $pass = ''; // $pass是你在建立证书的时候输入的密码
- $ctx = stream_context_create();
- // apns.pem就是你的证书的路径了,最好写绝对路径
- stream_context_set_option($ctx, 'ssl', 'local_cert', 'apns.pem');
- stream_context_set_option($ctx, 'ssl', 'passphrase', $pass);
- $fp = stream_socket_client('ssl://gateway.sandbox.push.apple.com:2195', $err, $errstr, 60, STREAM_CLIENT_CONNECT, $ctx);
- if(!$fp) {
- print "Failed to connect $err $errstr";
- exit();
- } else {
- print "Connection OK\n";
- }
- $body = array('aps' => array('badge' => 1));
- for($i = 0; $i <= 10000; $i++) {
- $deviceToken = md5(time() . rand(0, 9999999)) . md5(time() . rand(0, 9999999)); // 模拟一个Device Token
- $body['aps']['alert'] = md5(time() . rand(0, 9999999)); // 随便模拟点数据
- $payload = json_encode($body);
- // 这里是简单的消息结构,如果想多发几个但是不要返回错误,可以用这个
- /*
- $msg = chr(0) . pack("n", 32)
- . pack('H*', str_replace(' ', '', $deviceToken))
- . pack("n", strlen($payload)) . $payload;
- */
- // 这个是增强型消息格式,$i就是Identifier,864000就是Expiry了
- $msg = pack('CNNnH*', self::COMMAND_PUSH, $i, 864000, 32, $deviceToken)
- . pack('n', strlen($payload))
- . $payload;
- print "sending message :" . $payload . "\n";
- fwrite($fp, $msg);
- // 这里是读取错误信息,不要没发一条就读取一次,这样苹果会认为攻击而终止连接
- //fread($fp, 6);
- }
要往下面说我先解释一下这个东东--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.
为了方便我直接把官方文档粘过来了哈:)我们需要注意的是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进行更新,保证数据库存储的都是有用的数据。上面代码没有体现这个操作。