蘋果的推送服務的設計非常優秀和巧妙,開發者將消息發送到蘋果的APN服務器,APN服務器將消息轉發到設備上,設備與APN保持一個長連接即可,即保證了消息的實時性,又節省了系統資源,更省電。相比之下,Android這個粗放管理的,耗電大戶平台,直到2.2后才添加了類似的推送服務,而且還被牆了。
蘋果的推送模式如下圖所示:
iOS應用首先要請求用戶允許推送通知,用戶允許后,應用會獲得一個32字節的token。
應用開發者要推送通知到用戶的設備時,把消息和token一起發送給APN服務器,APN服務器根據token來將消息發送到用戶的設備上。
本文主要介紹如何通過Openssl來將推送消息發送到APN服務器,有關Apple Push Notification的更多內容可以參考官方文檔。
1. 准備工作
需要支持推送的應用必須要有一個獨立的App ID(不帶*號的)。並且要在配置里打開Push Notification這一項,配置成功后會有兩個用於發送推送通知時使用的Certificate,分別在Development和Production環境下使用,開發階段使用Development的證書,連接測試的APN服務器,兩者之間不能混用。
將兩個證書都導入鑰匙串,然后從鑰匙串中連Key和證書一起導出到p12文件。再次p12文件轉換成PEM文件。
# 將p12證書轉換為pem
> openssl pkcs12 -in development.p12 -out development.pem -nodes
Xcode中應用的Bundle Identifier必須與App ID相符,並且,還需要創建一個新的與App ID相符的Provisioning Profile,應用的Code Signing要選擇這個Profile才行。
2. 獲取設備token
// 修改AppDelegate.m - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 在這里添加以下幾行,請求允許通知 [[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound)]; return YES; } // 成功的話會在這里返回得到的token - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { NSString *str = [[[NSString stringWithFormat:@"%@", deviceToken] stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"<>"]] stringByReplacingOccurrencesOfString:@" " withString:@""]; NSLog(@"token: %@", str,); } // 失敗時會調用這里 - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { NSLog(@"register for remote notification Error: %@", error); } // 當收到推送消息時會調用這里。 - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo { }
3. 建立SSL連接,發送推送消息
3.1 初始化SSL
// 初始化ssl庫,Windows下初始化WinSock void init_openssl() { #ifdef _WIN32 WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); #endif SSL_library_init(); ERR_load_BIO_strings(); SSL_load_error_strings(); OpenSSL_add_all_algorithms(); }
3.2 連接服務器
蘋果提供了兩個服務器gateway.push.apple.com:
2195,用於正式服務,gateway.sandbox.push.apple.com:
2195用於測試服務。我們在測試的時候使用測試服務器,應用正式發布后使用正式服務器。
首先,建立TCP連接。
int tcp_connect(const char* host, int port) { struct hostent *hp; struct sockaddr_in addr; int sock = -1; // 解析域名 if (!(hp = gethostbyname(host))) { return -1; } memset(&addr, 0, sizeof(addr)); addr.sin_addr = *(struct in_addr*)hp->h_addr_list[0]; addr.sin_family = AF_INET; addr.sin_port = htons(port); if ((sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0){ return -1; } if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) { return -1; } return sock; }
第二步,要用我們的證書和Key創建SSL Context,並用SSL_connect實現與服務器的握手
// 創建SSL Context SSL_CTX* init_ssl_context( const char* clientcert, /* 客戶端的證書 */ const char* clientkey, /* 客戶端的Key */ const char* keypwd, /* 客戶端Key的密碼, 如果有的話 */ const char* cacert) /* 服務器CA證書 如果有的話 */ { // set up the ssl context SSL_CTX *ctx = SSL_CTX_new(SSLv23_client_method()); if (!ctx) { return NULL; } // certificate if (SSL_CTX_use_certificate_file(ctx, clientcert, SSL_FILETYPE_PEM) <= 0) { return NULL; } // key if (SSL_CTX_use_PrivateKey_file(ctx, clientkey, SSL_FILETYPE_PEM) <= 0) { return NULL; } // make sure the key and certificate file match if (SSL_CTX_check_private_key(ctx) == 0) { return NULL; } // load ca if exist if (cacert) { if (!SSL_CTX_load_verify_locations(ctx, cacert, NULL)) { return NULL; } } return ctx; } // 實現SSL握手,建立SSL連接 SSL* ssl_connect(SSL_CTX* ctx, int socket) { SSL *ssl = SSL_new(ctx); BIO *bio = BIO_new_socket(socket, BIO_NOCLOSE); SSL_set_bio(ssl, bio, bio); if (SSL_connect(ssl) <= 0) { return NULL; } return ssl; }
第三步,建立SSL連接成功后,說明至少我們的證書服務器認可了,我們還需要驗證一下服務器的證書是不是正確
// 驗證服務器證書 // 首先要驗證服務器的證書有效,其次要驗證服務器證書的CommonName(CN)與我們 // 實際要連接的服務器域名一致 int verify_connection(SSL* ssl, const char* peername) { int result = SSL_get_verify_result(ssl); if (result != X509_V_OK) { fprintf(stderr, "WARNING! ssl verify failed: %d", result); return -1; } X509 *peer; char peer_CN[256] = {0}; peer = SSL_get_peer_certificate(ssl); X509_NAME_get_text_by_NID(X509_get_subject_name(peer), NID_commonName, peer_CN, 255); if (strcmp(peer_CN, peername) != 0) { fprintf(stderr, "WARNING! Server Name Doesn't match, got: %s, required: %s", peer_CN, peername); } return 0; }
3.3 打包要發送的消息
發送的推送消息有兩種格式,這里做簡單介紹,具體的可見蘋果的文檔
第一種形式,比較簡單,Command占一個字節長度,必須是0,Token length是設備號的長度,現在是32,Device Token是二進制的,需要把我們前面獲得的字符串轉換成二進制,Payload length是Payload的長度,根據Payload的長度變化,Payload部分,最多256個字節,是JSON格式的內容,不能以'\0'結尾,如果有中文的話,需要是UTF-8編碼,(關於Payload可以看這里, “The Notification Payload”).
第二種形式比第一種形式增加了,Identifier和Expiry, Identifier是我們自定義的消息編號,如果我們發送的消息有錯誤,比如token無效,或者格式錯誤等,服務器會把這個Identifier返回給我們並返回錯誤碼,需要注意的是如果發送成功,服務器不會給任何回應。Expiry是一個UNIX格式的時間,用來表示消息過期的時間,比如一天的過期的時間可以寫成(time(NULL) + 24 * 3600)。
如果發送失敗,服務器會給我們回應上面格式的數據包,Identifier是我們發送的指定的編號,也就是說只有第二種形式發送的時間服務器才會給回應。
由於涉及Payload的編碼,這部分代碼較長,這里只給出部分片斷,詳細的可參閱完整代碼。
// 第一種形式的包 int build_output_packet(char* buf, int buflen, /* 輸出的緩沖區及長度 */ const char* tokenbinary, /* 二進制的Token */ const char* msg, /* 要發送的消息 */ int badage, /* 應用圖標上顯示的數字 */ const char * sound) /* 設備收到時播放的聲音,可以為空 */ { assert(buflen >= 1 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE); char * pdata = buf; // command *pdata = 0; // token length pdata++; *(uint16_t*)pdata = htons(TOKEN_SIZE); // token binary pdata += 2; memcpy(pdata, tokenbinary, TOKEN_SIZE); pdata += TOKEN_SIZE; int payloadlen = MAX_PAYLOAD_SIZE; if (build_payload(pdata + 2, payloadlen, msg, badage, sound) < 0) { std::string strmsg(msg); strmsg.erase(strmsg.length() - (payloadlen - MAX_PAYLOAD_SIZE)); payloadlen = MAX_PAYLOAD_SIZE; if (build_payload(pdata + 2, payloadlen, msg, badage, sound) <= 0) { return -1; } } *(uint16_t*)pdata = htons(payloadlen); return 1 + 2 + TOKEN_SIZE + 2 + payloadlen; } // 第二種形式的包 int build_output_packet_2(char* buf, int buflen, /* 緩沖區及長度 */ uint32_t messageid, /* 消息編號 */ uint32_t expiry, /* 過期時間 */ const char* tokenbinary, /* 二進制Token */ const char* msg, /* message */ int badage, /* badage */ const char * sound) /* sound */ { assert(buflen >= 1 + 2 + 4 + 4 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE); char * pdata = buf; // command *pdata = 1; // messageid pdata++; *(uint32_t*)pdata = messageid; // expiry time pdata += 4; *(uint32_t*)pdata = htonl(expiry); // token length pdata += 4; *(uint16_t*)pdata = htons(TOKEN_SIZE); // token binary pdata += 2; memcpy(pdata, tokenbinary, TOKEN_SIZE); pdata += TOKEN_SIZE; int payloadlen = MAX_PAYLOAD_SIZE; if (build_payload(pdata + 2, payloadlen, msg, badage, sound) < 0) { std::string strmsg(msg); strmsg.erase(strmsg.length() - (payloadlen - MAX_PAYLOAD_SIZE)); payloadlen = MAX_PAYLOAD_SIZE; if (build_payload(pdata + 2, payloadlen, msg, badage, sound) <= 0) { return -1; } } *(uint16_t*)pdata = htons(payloadlen); return 1 + 4 + 4 + 2 + TOKEN_SIZE + 2 + payloadlen; }
發送消息
int send_message(SSL *ssl, const char* token, const char* msg, int badage, const char* sound) { char buf[1 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE]; int buflen = sizeof(buf); buflen = build_output_packet(buf, buflen, (const char*)DeviceToken2Binary(token).binary(), msg, badage, sound); if (buflen <= 0) { return -1; } return SSL_write(ssl, buf, buflen); } int send_message_2(SSL *ssl, const char* token, uint32_t id, uint32_t expire, const char* msg, int badage, const char* sound) { char buf[1 + 4 + 4 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE]; int buflen = sizeof(buf); buflen = build_output_packet_2(buf, buflen, id, expire, (const char*)DeviceToken2Binary(token).binary(), msg, badage, sound); if (buflen <= 0) { return -1; } return SSL_write(ssl, buf, buflen); }
3.4 完整使用
init_openssl(); // 初始化Context // develop.pem是我們的證書和Key,為了方便使用,我們把證書和Key寫到同一個文件中 // 並取水了Key的密碼保護 // entrust_2048_ca.pem是蘋果證書的CA,它並不在Openssl的根證書中,所以需要我們手動指定,不然會無法驗證 // 詳細:http://www.entrust.net/developer/index.cfm SSL_CTX *ctx = init_ssl_context("develop.pem", "develop.pem", NULL, "entrust_2048_ca.pem"); if (!ctx) { fprintf(stderr, "init ssl context failed: %s\n", ERR_reason_error_string(ERR_get_error())); return -1; } // 連接到測試服務器 const char* host = "gateway.sandbox.push.apple.com"; const int port = 2195; int socket = tcp_connect(host, port); if (socket < 0) { fprintf(stderr, "failed to connect to host %s\n", strerror(errno)); return -1; } // SSL連接 SSL *ssl = ssl_connect(ctx, socket); if (!ssl) { fprintf(stderr, "ssl connect failed: %s\n", ERR_reason_error_string(ERR_get_error())); Closesocket(socket); return -1; } // 驗證服務器證書 if (verify_connection(ssl, host) != 0) { fprintf(stderr, "verify failed\n"); Closesocket(socket); return 1; } uint32_t msgid = 1; uint32_t expire = time(NULL) + 24 * 3600; // expire 1 day // 發送一條消息 const char* token = "0a8b9e7cbe68616cd5470e4c8abb4c1a3f4ba2bee4ca113ff02ae2c325948b8a"; if (send_message_2(ssl, token, msgid++, expire, "Hello, This is a push message", 1, "default") <= 0) { fprintf(stderr, "send failed: %s\n", ERR_reason_error_string(ERR_get_error())); } // 關閉連接 SSL_shutdown(ssl); Closesocket(socket);
4. 總結
本例中,只演示了發送消息,而沒有實現服務器返回數據的接收,實際應用中如果服務器返回錯誤后,連接會斷開,這時候需要重新連接來發送其它的消息,另外,還應該注意,盡量將多個消息放到一個連接里發送,與服務器保持長連接,不能發一個消息連接一次,連接過於頻繁,服務器可能會把你的IP暫時禁掉。
在設備上獲得Token,開發環境下和從App Store下載正式的應用,獲得的Token是一樣的,而且這個Token一旦在測試環境下使用了,就無法再從正式服務器上推送消息了,會返回Token無效,除非把手機還原了,不然無法從正式服務器上推送消息。
本例的代碼放在這里。https://github.com/e7868a/apple-push-notify src/push.cpp
編譯:gcc src/push.cpp -o push -lssl -lcrypto -lstdc++