基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

时间:2023-03-08 17:44:27
基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

最近兼职公司已经众筹成功的无线门铃的消息推送出现了问题,导致有些用户接收不到推送的消息,真是吓死宝宝了,毕竟自己一手包办的后台服务,影响公司信誉是多么的尴尬,容我简单介绍一下我们的需求:公司开发的是一款无线门铃系统,如果有人在门外按了门铃开关,门铃开关会发射一个信号,屋里的接收网关接收到信号会发出响声,同时也会推送一条消息到用户手机,即使这个手机是远程的,也就是主人不在家也知道有人按了家里的门铃。这里后台需要解决的问题是搭建APNS推送的Provider,因为要想把消息推送到苹果手机,按照苹果公司设计的机制,必须通过自己的服务器推送到苹果的PUSH服务器,再由它推送到手机,每个手机对应一个deviceToken,我这里介绍的重点并不是这个平台怎么搭建,这个国内网上的教程已经相当丰富了。比如你可以参考:一步一步教你做ios推送

基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

网上的教程大多是走的通的,但是他们操作的对象是一个手机,我的意思是它们是一次给一个手机终端推送消息,在我们公司设计的产品中,同一个账户可以在多个手机上登录(理论上是无数个,因为我在后台并没有限制),每个手机对应的deviceToken是不同的,另外公司的产品还设计了分享功能,也就是主用户可以把设备分享给其他用户,而其他用户也有可能在不同设备上同时登录,如果有人按了门铃要向所有已经登录的用户包括分享的用户推送消息,也就是要批量推送到很多个手机终端。当然我这里举的例子并不会有这么复杂,所有的问题抽象出来其实就是一个问题:给你一个存储deviceToken的数组,APNS如何批量推送给多个用户?

 

首先我们设计一个数据库,用来存储用户的推送令牌(deviceToken),为简单起见,这个表就两个字段。

client_id deviceToken
1 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

这里我使用的是CodeIgniter3的框架,我们新建一个Model,来管理用户deviceToken数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php
// ios推送令牌管理
class Apns_model extends CI_Model
{
    public function __construct()
    {
        $this->load->database();
    }
    /**
     * 新建推送令牌
     * create a apns如果已经存在就更新这个deviceToken
     * $data is an array organized by controller
     */
    public function create($data)
    {
        if($this->db->replace('tb_apns', $data))
        {
            return TRUE;
        }
        else
        {
            return FALSE;
        }
    }
    //删除某个用户的推送令牌
    public function delete($user_id)
    {
         
        if(isset($user_id)){
            $result=$this->db->delete('tb_apns', array('client_id' => $user_id)); 
            return TRUE;           
        }else{
            return FALSE;
        }
    }
    //根据推送令牌删除推送令牌
    public function deletebytoken($token)
    {
         
        if (isset($token)) {
            $result=$this->db->delete('tb_apns', array('deviceToken'=>$token));
            return TRUE;
        }else{
            return FALSE;
        }
    }
    //查询某个用户的iso推送令牌
    public function get($client_id)
    {
        $sql = "SELECT deviceToken FROM `tb_apns` WHERE `client_id`='$client_id'";
        $result = $this->db->query($sql);
        if ($result->num_rows()>0)
        {
            return $result->result_array();
        }
        else
        {
            return FALSE;
        }
    }
}

在我后台的第一个版本中,按照网上的教程,大多是一次给一个终端推送消息的,我稍微改了一下,将所有取得的deviceToken存在$deviceTokens数组中,参数$message是需要推送的消息,使用for循环依次从数组中取出一个deviceToken来推送,然后计数,如果所有的推送成功则返回true。这个方法看似是没有任何破绽的,而且也测试成功了,所以我就直接上线了,(主要是我也没想到公司会突然出这样一个产品,把推送功能的地位抬得很高,我一直以为是可有可无的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
function _send_apns($deviceTokens,$message)
    {
      // Put your private key's passphrase here:密语
       $passphrase = 'xxxxx';
           ////////////////////////////////////////////////////////////////////////////////
       $ctx = stream_context_create();
       stream_context_set_option($ctx, 'ssl', 'local_cert', 'xxxx.pem');
       stream_context_set_option($ctx, 'ssl', 'passphrase', $passphrase);
       // Open a connection to the APNS server
       $fp = stream_socket_client(
           'ssl://gateway.push.apple.com:2195', $err,
           $errstr, 60, STREAM_CLIENT_CONNECT|STREAM_CLIENT_PERSISTENT, $ctx);
       if (!$fp)
           exit("Failed to connect: $err $errstr" . PHP_EOL);
       echo 'Connected to APNS' . PHP_EOL;
        // Create the payload body
       $body['aps'] = array(
           'alert' => $message,
           'sound' => 'default'
           );
         // Encode the payload as JSON
       $payload = json_encode($body);
       $num=count($deviceTokens);
       $countOK=0;//统计发送成功的条数
       for($i=0;$i<$num;$i++)
       {
           $deviceToken=$deviceTokens[$i];
           $deviceToken=preg_replace("/\s/","",$deviceToken);//删除deviceToken里的空格
        // Build the binary notification
           $msg = chr(0) . pack('n', 32) . pack('H*', $deviceToken) . pack('n', strlen($payload)) . $payload;
        // Send it to the server
           $result = fwrite($fp, $msg, strlen($msg));
           if ($result)
         
           {
               $countOK++;
            
           }
       }
      // Close the connection to the server
       fclose($fp);
       if($countOK==$num)
           return TRUE;
       else
           return FALSE;
   }

就是上面的代码导致了后来推送出现了一系列问题。

第一个大问题是:这里默认了所有的推送令牌都是有效的,而实际上,如果用户直接删除了app或者app升级都有可能造成后台数据库里的deviceToken没有发生更新,从而使推送令牌失效。但是有人按了门铃,后台还是会把它当成有效的deviceToken纳入到$deviceTokens中,如何清除失效过期的deviceToken是个必须考虑的问题。

查阅相关资料发现APNS服务有一个The Feedback Service的服务,国内的博客基本上忽略了这个环节,很少有资料提及,还是谷歌找个官方网站比较靠谱。下面简要介绍一下这个服务:

在进行APNS远程推送时,如果由于用户卸载了app而导致推送失败,APNS服务器会记录下这个deviceToken,加入到一个列表中,可以通过查询这个列表,获取失效的推送令牌,从数据库中清除这些失效的令牌就可以避免下次推送时被加入到推送数组中来。连接这项服务很简单和推送工程类似,只不过地址不同,开发环境为feedback.push.apple.com ,测试环境为feedback.sandbox.push.apple.com端口都是2196。APNS服务器返回的数据格式为:

基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

Timestamp

A timestamp (as a four-byte time_t value) indicating when APNs determined that the app no longer exists on the device. This value, which is in network order, represents the seconds since 12:00 midnight on January 1, 1970 UTC.

Token length

The length of the device token as a two-byte integer value in network order.

Device token

The device token in binary format.

为了进行这项服务,我写了一个CI框架的控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Admin extends CI_Controller {
    public function __construct()
    {
        parent::__construct();
        // 加载数据库
        $this->load->database();
        $this->load->model('apns_model');
    }
    public function apnsfeedback()
    {
        $ctx = stream_context_create();
         $passphrase = 'xxxxx';
        stream_context_set_option($ctx, 'ssl', 'local_cert', 'xxxxxxx.pem');
        stream_context_set_option($ctx, 'ssl', 'passphrase', $passphrase);
        $fp = stream_socket_client('ssl://feedback.push.apple.com:2196', $error, $errorString, 60, STREAM_CLIENT_CONNECT|STREAM_CLIENT_PERSISTENT, $ctx);
        if (!$fp) {
            echo "Failed to connect feedback server: $err $errstr\n";
            return;
        }
        else {
            echo "Connection to feedback server OK\n";
            echo "<br>";
        }
        while ($devcon = fread($fp, 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 (!empty($feedbackDeviceToken)) {
                echo "Invalid token $feedbackDeviceToken\n";
                echo "<br>";
                $this->apns_model->deletebytoken($feedbackDeviceToken);
                 
            }
        }
        fclose($fp);
    }
}

通过循环读取38个字节的数据,可以查询出所有失效的令牌并在数据库中删除。
不过需要注意的是:一旦你读取了数据,APNS就会清除已有的列表,下次查询时返回的是自从上次查询后再次累积的无效的令牌。

The feedback service’s list is cleared after you read it. Each time you connect to the feedback service, the information it returns lists only the failures that have happened since you last connected.

还有一点就是你胡乱造的deviceToken是不会被服务器记录的。

第二个问题:假如$deviceTokens数组里有很多个元素,有时会发生前面几个令牌推送成功,手机收到了消息,但是后面的令牌没有推送成功,没有收到消息。

 关于这个问题,国内的博客也是很少提及,直到在官网上看到下面的几句话:

 

If you send a notification that is accepted by APNs, nothing is returned.

If you send a notification that is malformed or otherwise unintelligible, APNs returns an error-response packet and closes the connection. Any notifications that you sent after the malformed notification using the same connection are discarded, and must be resent. 

上面几句话的意思大致是:每次针对一个deviceToken推送消息时,如果推送失败,没有任何数据返回,如果apns服务器不能识别推送的令牌APNS会返回一个错误消息并关闭当前的连接,所有后续通过同一连接推送的消息都会放弃,必须重新连接跳过无效的再发送。这也解释了为什么后面的推送会失败。下图是返回的数据及对应的错误码,这里需要重点关注错误码8,其对应这无效的令牌。

基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

 Codes in error-response packet

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

128

Protocol error (APNs could not parse the notification)

255

None (unknown)

那么如何让APNS服务器返回错误消息呢,实际上我之前的第一种解决方案中,APNS并不会返回错误消息,我只是一厢情愿的在统计发送成功次数,如果需要APNS返回错误消息,需要改变发送数据的格式。发送数据的格式同样推荐参考官方文档,因为国内的博客基本上没好好严格按照文档来打包数据。下面的格式介绍来自官方文档。

Figure A-1  Notification format基于Codeigniter框架实现的APNS批量推送—叮咚,查水表

Note: All data is specified in network order, that is big endian.

The top level of the notification format is made up of the following, in order:

Table A-1  Top-level fields for remote notifications

Field name

Length

Discussion

Command

1 byte

Populate with the number 2.

Frame length

4 bytes

The size of the frame data.

Frame data

variable length

The frame contains the body, structured as a series of items.

The frame data is made up of a series of items. Each item is made up of the following, in order:

Table A-2  Fields for remote notification frames

Field name

Length

Discussion

Item ID

1 byte

The item identifier, as listed in Table A-3. For example, the item identifier of the payload is 2.

Item data length

2 bytes

The size of the item data.

Item data

variable length

The value for the item.

The items and their identifiers are as follows:

Table A-3  Item identifiers for remote notifications

Item ID

Item Name

Length

Data

1

Device token

32 bytes

The device token in binary form, as was registered by the device.

A remote notification must have exactly one device token.

2

Payload

variable length, less than or equal to 2 kilobytes

The JSON-formatted payload. A remote notification must have exactly one payload.

The payload must not be null-terminated.

3

Notification identifier

4 bytes

An arbitrary, opaque value that identifies this notification. This identifier is used for reporting errors to your server.

Although this item is not required, you should include it to allow APNs to restart the sending of notifications upon encountering an error.

4

Expiration date

4 bytes

A UNIX epoch date expressed in seconds (UTC) that identifies when the notification is no longer valid and can be discarded.

If this value is non-zero, APNs stores the notification tries to deliver the notification at least once. Specify zero to indicate that the notification expires immediately and that APNs should not store the notification at all.

5

Priority

1 byte

The notification’s priority. Provide one of the following values:

  • 10 The push message is sent immediately.

    The remote notification must trigger an alert, sound, or badge on the device. It is an error to use this priority for a push that contains only the content-available key.

  • 5 The push message is sent at a time that conserves power on the device receiving it.

    Notifications with this priority might be grouped and delivered in bursts. They are throttled, and in some cases are not delivered.

注意上面我标红的几句话,大意是说这个字段作为此次推送消息唯一的标识符,有了这个标识符,APNS就能向我们的服务器报告错误。这也解释了为什么我上面的解决方案没有返回错误信息。下面是新的推送数据打包方式。这里我直接以发送的数组下标$i作为标识符,更优化的方法是使用deviceToken在数据库中对应的id作为唯一标识符。

1
$msg = pack("C", 1) . pack("N", $i) . pack("N", $apple_expiry) . pack("n", 32) . pack('H*', str_replace(' ', '',$deviceToken)) . pack("n", strlen($payload)) . $payload;

如果批量推送中途有一个推送失败,连接会被关闭,需要重新连接推送后面的令牌,所以最好把建立连接的过程封装成一个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private function create_apns_link(){
         
         
         
        $scc = stream_context_create();
        stream_context_set_option($scc, 'ssl', 'local_cert', realpath($this->certificate));
        stream_context_set_option($scc, 'ssl', 'passphrase', $this->passphrase);
         
        $fp = stream_socket_client($this->link, $err,$errstr, 60, STREAM_CLIENT_CONNECT, $scc);
         
        $i = 0;
        while (gettype($fp) != 'resource'){//如果建立失败就每隔100ms重新建立一次,如果失败3次就放弃
            if($i < 3){
                usleep(100000);
                $fp = stream_socket_client($link, $err,$errstr, 60, STREAM_CLIENT_CONNECT, $scc);
                $i++;
            }else{
                break;
            }
        }
         
        stream_set_blocking ($fp, 0);
        if($fp){
            echo 'Connected to APNS for Push Notification';
        }
        return $fp;
    }

至此,我把前面讲到的推送过程封装到一个CodeIgniter类库中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Apns{
    private $certificate="xxxx.pem";
    private $passphrase="xxxx";
    private $link = 'ssl://gateway.push.apple.com:2195';
    protected $CI;
    public function __construct()
    {
        $this->CI=&get_instance();
        // 加载数据库
        $this->CI->load->database();
        $this->CI->load->model('apns_model');
    }
     
    public function send($deviceTokens,$data){
        $fp = $this->create_apns_link();
         
        $body['aps'] = $data;
        $apple_expiry = 0;
        $payload = json_encode($body);
        $num=count($deviceTokens);
        for ($i=0; $i <$num ; $i++) {
            $deviceToken=$deviceTokens[$i];
            $msg = pack("C", 1) . pack("N", $i) . pack("N", $apple_expiry) . pack("n", 32) . pack('H*', str_replace(' ', '',$deviceToken)) . pack("n", strlen($payload)) . $payload;
          $rtn=fwrite($fp, $msg);
          usleep(400000);//每次推送过后,因为php是同步的apns服务器不会立即返回错误消息,所以这里等待400ms
          $errorcode=$this->checkAppleErrorResponse($fp);//检查是否存在错误消息
          if($errorcode){          
            if ($errorcode=='8') {//如果令牌无效就删除
                $this->CI->apns_model->deletebytoken($deviceToken);               
            }
            if($i<$num-1){//如果还没推送完,需要重新建立连接推送后面的
                $fp = $this->create_apns_link();
            }
             
          }
        }
         
        fclose($fp);
        return true;
    }
     
     
    private function create_apns_link(){
         
         
         
        $scc = stream_context_create();
        stream_context_set_option($scc, 'ssl', 'local_cert', realpath($this->certificate));
        stream_context_set_option($scc, 'ssl', 'passphrase', $this->passphrase);
         
        $fp = stream_socket_client($this->link, $err,$errstr, 60, STREAM_CLIENT_CONNECT, $scc);
         
        $i = 0;
        while (gettype($fp) != 'resource'){//如果建立失败就每隔100ms重新建立一次,如果失败3次就放弃
            if($i < 3){
                usleep(100000);
                $fp = stream_socket_client($link, $err,$errstr, 60, STREAM_CLIENT_CONNECT, $scc);
                $i++;
            }else{
                break;
            }
        }
         
        stream_set_blocking ($fp, 0);
        if($fp){
            echo 'Connected to APNS for Push Notification';
        }
        return $fp;
    }
     
     
     private function checkAppleErrorResponse($fp) {
        //byte1=always 8, byte2=StatusCode, bytes3,4,5,6=identifier(rowID).
        // Should return nothing if OK.
         
        //NOTE: Make sure you set stream_set_blocking($fp, 0) or else fread will pause your script and wait
        // forever when there is no response to be sent.
         
        $apple_error_response = fread($fp, 6); 
        if ($apple_error_response) {
        // unpack the error response (first byte 'command" should always be 8)
            $error_response = unpack('Ccommand/Cstatus_code/Nidentifier', $apple_error_response);
         
            return $error_response['status_code'];
        }
         
        return false;
    }
     
     
}

总结:官方文档才是王道啊,英语好才是王道啊!!!!

参考

Binary Provider API

APNS SSL operation failed with code 1