使用 Go 语言实现苹果推送通知服务客户端
2015 1 14 06:40 PM 2867次查看
因为刚学 Go 语言,自然得写点什么练练手。
之前自己用 Python 实现过比较简单的苹果推送服务(主要就是根据请求,构造并发送给 Apple Push Notification service),加上 Go 擅长服务器端编程,所以决定也写一个试试。不过整个服务依赖性比较大,这里就只做客户端的部分了。
先简单说下原理吧。
以 iOS app 为例,它可以请求一个 device token,如果用户选择允许,苹果就会生成一个发给 app。然后 app 就可以把这个 token 发给服务器,在服务器端记录下来。
服务器想给这个设备推送时,需要使用 TLS 连接到 APNs,用证书和私钥来证明和确定你需要推送的 app,然后构造一条消息,发送过去即可。
所以可以分成三步,分别来实现构造消息、连接和发送的过程。
以下代码都省略 import 了,使用的都是标准库。
- 消息构造
整体而言,除了错误处理比较麻烦,写起来还算流畅。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})
- 连接 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 就行了。
- 需要返回 tls.Conn 的指针,因为 tls.Client() 拿到的也是指针。传递大的结构体是不高效的。
- 发送数据
这是最简单的函数了,甚至都没有存在的必要。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 的多进程方案则要轻便很多,但连接池仍然存在资源的竞争,这点可能需要实际去做时再思考。
不过正如开篇时所说,我暂时没空完成这篇文章,于是先到此为止吧。
向下滚动可载入更多评论,或者点这里禁止自动加载。