使用 Go 语言实现苹果推送通知服务客户端

标签:Go

注:本文拖了 10 多天了,因为被更想做的事吸引了,暂时没空补完了,所以先发个粗糙的版本吧。

因为刚学 Go 语言,自然得写点什么练练手。
之前自己用 Python 实现过比较简单的苹果推送服务(主要就是根据请求,构造并发送给 Apple Push Notification service),加上 Go 擅长服务器端编程,所以决定也写一个试试。不过整个服务依赖性比较大,这里就只做客户端的部分了。

先简单说下原理吧。
以 iOS app 为例,它可以请求一个 device token,如果用户选择允许,苹果就会生成一个发给 app。然后 app 就可以把这个 token 发给服务器,在服务器端记录下来。
服务器想给这个设备推送时,需要使用 TLS 连接到 APNs,用证书和私钥来证明和确定你需要推送的 app,然后构造一条消息,发送过去即可。

所以可以分成三步,分别来实现构造消息、连接和发送的过程。
以下代码都省略 import 了,使用的都是标准库。

  1. 消息构造
    func bwrite(w io.Writer, values ... interface{}) error {
    	for _, value := range values {
    		err := binary.Write(w, binary.BigEndian, value)
    		if err != nil {
    			return err
    		}
    	}
    	return nil
    }
    
    func buildData(id uint32) (data []byte, err error) {
    	payload := map[string]interface{}{
    		"aps": map[string]interface{}{
    			"alert": "test", // 显示的内容
    			"badge": 1, // 提示的未读数
    		}, // 其他数据就不构造了,可以参阅苹果的文档
    	}
    	payload_str, err := json.Marshal(payload)
    	if err != nil { // 还得检查 payload_str 的长度,不能超过 256 字节,这里就忽略了
    		return
    	}
    	token, _ := hex.DecodeString("abcd06019440983a88cccca257b28496598121f2b151f428d5dcf0eac092749f") // 测试用的 device token
    
    	buffer := new(bytes.Buffer)
    	err = bwrite(buffer, uint8(1), id, uint32(0), uint16(len(token)), token, uint16(len(payload_str)), payload_str)
    	if err != nil {
    		return
    	}
    
    	data = buffer.Bytes()
    	return
    }
    整体而言,除了错误处理比较麻烦,写起来还算流畅。
    不爽之处在于构造最后的 data。如果是用 C 语言,只要写一个 struct,然后转换到 char* 就行了,性能应该好很多,而且能清楚地知道每个字段是干啥的。
    感谢 reusee 指出,可以直接输出一个 struct:
    type Message struct {
    	cmd uint8
    	id uint32
    	timestamp uint32
    	tokenLength uint16
    	token []byte
    	payloadLength uint16
    	payload []byte
    }
    err = binary.Write(buffer, binary.BigEndian, Message{
    	uint8(1), uint32(1), uint32(time.Now().Unix() + 60*60), uint16(len(token)), token, uint16(len(payload_str)), payload_str})
  2. 连接 APNs
    func connect() (tlsConn *tls.Conn, err error) {
    	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem") // 证书和私钥文件
    	if err != nil {
    		return
    	}
    
    	conf := &tls.Config {
    		Certificates: []tls.Certificate{cert},
    		ServerName: "gateway.push.apple.com",
    	}
    
    	return tls.Dial("tcp", "gateway.push.apple.com:2195", conf)
    
    	/* 或者封装一下 TCPConn
    	tlsConn = tls.Client(conn, conf)
    
    	conn, err := net.Dial("tcp", "gateway.push.apple.com:2195")
    	if err != nil {
    		return
    	}
    
    	tlsConn = tls.Client(conn, conf)
    	err = tlsConn.Handshake()
    	return
    	*/
    }
    这里有几点需要注意:
    • 需要返回 tls.Conn 的指针,因为 tls.Client() 拿到的也是指针。传递大的结构体是不高效的。
    • APNs 有 2 个 server names,开发时要用 gateway.sandbox.push.apple.com。
    • 证书和私钥文件可能需要自己转换。钥匙串里导出的是一个 p12 文件,可以用 OpenSSL 命令行转换成 pem 文件(我是用 Python 的 OpenSSL 库转换的)。另外,Python 可以把证书和私钥放在同一个文件里,也就不需要额外传递 keyFile 了(Go 的文档里没说是否支持)。
    • Python 里需要创建一个 socket,然后再用 ssl.wrap_socket 或 SSLContext.wrap_socket 来封装,这点不如 Go 方便。不过 Go 用这种方式实现时,我不知道被封装的 conn 是否要在不用时关闭,还是只关闭 tlsConn 就行了。
  3. 发送数据
    func sendData(conn *tls.Conn, data []byte) (err error) {
    	_, err = conn.Write(data)
    	return err
    }
    这是最简单的函数了,甚至都没有存在的必要。
于是一个最简单的例子就完成了,还算顺利。

接下来是错误处理了。如果发的消息有任何错误,例如 token 不对、消息过长和服务器关闭等,APNs 会报错并关闭连接;否则啥也不会输出。
所以可以在发送完后,尝试读一下:
func checkError(conn *tls.Conn) {
	conn.SetReadDeadline(time.Now().Add(5 * time.Second)) // 假设 5 秒内没读到错误则算发送成功
	response := make([]byte, 6, 6)  // 错误信息长度为 6 字节
	length, err := conn.Read(response)
	if err != nil {
		if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
			// 忽略,没有错误
			return
		} else {
			// 不知道什么原因,应该需要重发
			conn.Close()
		}
	} else if length != 6 {
		// 错误信息不对,无法解析,需要重发
		conn.Close()
	} else {
		status := response[1]
		switch status {
			case 0, 10:
				// 忽略错误,不重发
				conn.Close()
				return
			case 1, 255:
				// 需要重发
				return
			case 8:
				// token 错误,不重发
				return
		}
		if status >= 2 || status <= 7 {
			// 消息错误,不重发
			return
		} else {
				// 未定义的错误,忽略
				return
		}
	}
}
那个超时的设置简直令人发指,于是改得更优雅些:
func getResponse(conn *tls.Conn) (response []byte, err error) {
	ch := make(chan []byte)
	eCh := make(chan error)

	go func(ch chan []byte, eCh chan error) {
		data := make([]byte, 6, 6)
		_, err := conn.Read(data)
		if err != nil {
			eCh <- err
			return
		}
		ch <- data
	}(ch, eCh)

	select {
		case response = <-ch:
			// 处理响应
		case err = <-eCh:
			// 处理错误
		case <-time.Tick(5 * time.Second):
			// 没有错误,忽略
	}
	return
}
这个 select 简直舒爽。

可是如果要发送多条,每条都等 5 秒,这就太没效率了。
我在做 Python 版时采用的方案是使用无阻塞的 socket,不停地发送消息,等到无法写入时,再用 select 来检查错误原因。如果 APNs 报错,则查看是什么原因,出错的是哪条,然后从那条开始继续重发即可;如果只是网络较慢,那就继续等待。
代码类似如下:
while messages:
    try:
        written_bytes = self.sock.write(messages)
        messages = messages[written_bytes:]
        if messages:  # not fully written
            input_ready, output_ready, except_ready = select(fds, fds, (), SOCKET_RW_TIMEOUT)
            if input_ready or not output_ready:  #  timeout
                self.handle_error()
    except ssl.SSLError as e:
        if e.errno == ssl.SSL_ERROR_WANT_WRITE:  # not writable
            input_ready, output_ready, except_ready = select(fds, fds, (), SOCKET_RW_TIMEOUT)
            if input_ready or not output_ready:  # timeout
                self.handle_error()
            else:
                continue  # try to write more
        self.handle_error()
        break
    except socket.error:
        self.handle_error()
        break
而用 Go 时,其实读写都能在一个 select 里搞定,只是我不确定是否会导致逻辑变得混乱。

最后,其实真正能突出 Go 的优势的地方是 goroutines,但目前的例子里没有展示出来。
在用 Python 实现时,有个很大的缺点是一旦 token 出错(大概 1% 的概率),就需要重新建立连接,这个过程需要 2 秒左右,导致发送的 QPS 会被降到几十;而如果没有出错,达到上万的 QPS 也是可能的。
Python 的解决方案是用多进程或者连接池,但都导致了复杂度的增加;而 Go 比起 Python 的多进程方案则要轻便很多,但连接池仍然存在资源的竞争,这点可能需要实际去做时再思考。
不过正如开篇时所说,我暂时没空完成这篇文章,于是先到此为止吧。

7条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?