使用Pushy進(jìn)行APNs消息推送

APNs

最近對項(xiàng)目組的老的蘋果IOS推送進(jìn)行了升級修改∽暮В看了看蘋果的接口文檔突委,感覺自己直接來寫一個保證穩(wěn)定和高效的接口還是有點(diǎn)難度,同時為了避免重復(fù)造輪子(懶)省容,囧....調(diào)研了一些開源常用的庫之后抖拴,選擇了Turo團(tuán)隊開發(fā)和維護(hù)的pushy

APNs和Pushy

蘋果設(shè)備的消息推送是依靠蘋果的APNs(Apple Push Notification service)服務(wù)的腥椒,APNs的官方簡介如下:

Apple Push Notification service (APNs) is the centerpiece of the remote notifications feature. It is a robust, secure, and highly efficient service for app developers to propagate information to iOS (and, indirectly, watchOS), tvOS, and macOS devices.

IOS設(shè)備(tvOS阿宅、macOS)上的所有消息推送都需要經(jīng)過APNs,APNs服務(wù)確實(shí)非常厲害笼蛛,每天需要推送上百億的消息洒放,可靠、安全滨砍、高效往湿。就算是微信和QQ這種用戶級別的即時通訊app在程序沒有啟動或者后臺運(yùn)行過程中也是需要使用APNs的(當(dāng)程序啟動時,使用自己建立的長連接)惋戏,只不過騰訊優(yōu)化了整條從他們服務(wù)器到蘋果服務(wù)器的線路而已煌茴,所以覺得推送要快(參考知乎)。

項(xiàng)目組老的蘋果推送服務(wù)使用的是蘋果以前的基于二進(jìn)制socket的APNs日川,同時使用的是一個javapns的開源庫蔓腐,這個javapns貌似效果不是很好,在網(wǎng)上也有人有過討論龄句。javapns現(xiàn)在也停止維護(hù)DEPRECATED掉了回论。作者建議轉(zhuǎn)向基于蘋果新APNs服務(wù)的庫。

蘋果新APNs基于HTTP/2分歇,通過連接復(fù)用傀蓉,更加高效,當(dāng)然還有其它方面的優(yōu)化和改善职抡,可以參考APNs的一篇介紹葬燎,講解的比較清楚。

再說一下我們使用的Pushy,官方簡介如下:

Pushy is a Java library for sending APNs (iOS, macOS, and Safari) push notifications. It is written and maintained by the engineers at Turo......We believe that Pushy is already the best tool for sending APNs push notifications from Java applications, and we hope you'll help us make it even better via bug reports and pull requests.

Pushy的文檔和說明很全谱净,討論也很活躍窑邦,作者基本有問必答,大部分疑問都可以找到答案壕探,使用難度也不大冈钦。

使用Pushy進(jìn)行APNs消息推送

首先加入包

<dependency>
    <groupId>com.turo</groupId>
    <artifactId>pushy</artifactId>
    <version>0.11.1</version>
</dependency>

身份認(rèn)證

蘋果APNs提供了兩種認(rèn)證的方式:基于JWT的身份信息token認(rèn)證和基于證書的身份認(rèn)證。Pushy也同樣支持這兩種認(rèn)證方式李请,這里我們使用證書認(rèn)證方式瞧筛,關(guān)于token認(rèn)證方式可以查看Pushy的文檔。

如何獲取蘋果APNs身份認(rèn)證證書可以查考官方文檔导盅。

Pushy使用

ApnsClient apnsClient = new ApnsClientBuilder()
    .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
    .build();

ps. 這里的setClientCredentials函數(shù)也可以支持傳入一個InputStream和證書密碼较幌。

同時也可以通過setApnsServer函數(shù)來指定是開發(fā)環(huán)境還是生產(chǎn)環(huán)境:

ApnsClient apnsClient = new ApnsClientBuilder().setApnsServer(ApnsClientBuilder.DEVELOPMENT_APNS_HOST)
    .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
    .build();

Pushy是基于Netty的,通過ApnsClientBuilder我們可以根據(jù)需要來修改ApnsClient的連接數(shù)和EventLoopGroups的線程數(shù)白翻。

EventLoopGroup eventLoopGroup = new NioEventLoopGroup(4);
ApnsClient apnsClient = new ApnsClientBuilder()
        .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
        .setConcurrentConnections(4).setEventLoopGroup(eventLoopGroup).build();

關(guān)于連接數(shù)和EventLoopGroup線程數(shù)官網(wǎng)有如下的說明乍炉,簡單來說,不要配置EventLoopGroups的線程數(shù)超過APNs連接數(shù)嘁字。

Because connections are bound to a single event loop (which is bound to a single thread), it never makes sense to give an ApnsClient more threads in an event loop than concurrent connections. A client with an eight-thread EventLoopGroup that is configured to maintain only one connection will use one thread from the group, but the other seven will remain idle. Opening a large number of connections on a small number of threads will likely reduce overall efficiency by increasing competition for CPU time.

關(guān)于消息的推送,注意一定要使用異步操作杉畜,Pushy發(fā)送消息會返回一個Netty Future對象纪蜒,通過它可以拿到消息發(fā)送的情況。

for (final ApnsPushNotification pushNotification : collectionOfPushNotifications) {
    final Future sendNotificationFuture = apnsClient.sendNotification(pushNotification);

    sendNotificationFuture.addListener(new GenericFutureListener<Future<PushNotificationResponse>>() {
        
        @Override
        public void operationComplete(final Future<PushNotificationResponse> future) throws Exception {
            // This will get called when the sever has replied and returns immediately
            final PushNotificationResponse response = future.getNow();
        }
    });
}

APNs服務(wù)器可以保證同時發(fā)送1500條消息此叠,當(dāng)超過這個限制時纯续,Pushy會緩存消息,所以我們不必?fù)?dān)心異步操作發(fā)送的消息過多(當(dāng)我們的消息非常多灭袁,達(dá)到上億時猬错,我們也得做一些控制,避免緩存過大茸歧,內(nèi)存不足倦炒,Pushy給出了使用Semaphore的解決方法)。

The APNs server allows for (at the time of this writing) 1,500 notifications in flight at any time. If we hit that limit, Pushy will buffer notifications automatically behind the scenes and send them to the server as in-flight notifications are resolved.

In short, asynchronous operation allows Pushy to make the most of local resources (especially CPU time) by sending notifications as quickly as possible.

以上僅是Pushy的基本用法软瞎,在我們的生產(chǎn)環(huán)境中情況可能會更加復(fù)雜逢唤,我們可能需要知道什么時候所有推送都完成了,可能需要對推送成功消息進(jìn)行計數(shù)
涤浇,可能需要防止內(nèi)存不足鳖藕,也可能需要對不同的發(fā)送結(jié)果進(jìn)行不同處理....不多說,上代碼只锭。

最佳實(shí)踐

參考Pushy的官方最佳實(shí)踐著恩,我們加入了如下操作:

  • 通過Semaphore來進(jìn)行流控,防止緩存過大,內(nèi)存不足
  • 通過CountDownLatch來標(biāo)記消息是否發(fā)送完成
  • 使用AtomicLong完成匿名內(nèi)部類operationComplete方法中的計數(shù)
  • 使用Netty的Future對象進(jìn)行消息推送結(jié)果的判斷

具體用法參考如下代碼:

public class IOSPush {

    private static final Logger logger = LoggerFactory.getLogger(IOSPush.class);

    private static final ApnsClient apnsClient = null;

    private static final Semaphore semaphore = new Semaphore(10000);

    public void push(final List<String> deviceTokens, String alertTitle, String alertBody) {

        long startTime = System.currentTimeMillis();

        if (apnsClient == null) {
            try {
                EventLoopGroup eventLoopGroup = new NioEventLoopGroup(4);
                apnsClient = new ApnsClientBuilder().setApnsServer(ApnsClientBuilder.DEVELOPMENT_APNS_HOST)
                        .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
                        .setConcurrentConnections(4).setEventLoopGroup(eventLoopGroup).build();
            } catch (Exception e) {
                logger.error("ios get pushy apns client failed!");
                e.printStackTrace();
            }
        }

        long total = deviceTokens.size();

        final CountDownLatch latch = new CountDownLatch(deviceTokens.size());

        final AtomicLong successCnt = new AtomicLong(0);

        long startPushTime =  System.currentTimeMillis();

        for (String deviceToken : deviceTokens) {
            ApnsPayloadBuilder payloadBuilder = new ApnsPayloadBuilder();
            payloadBuilder.setAlertBody(alertBody);
            payloadBuilder.setAlertTitle(alertTitle);
            
            String payload = payloadBuilder.buildWithDefaultMaximumLength();
            final String token = TokenUtil.sanitizeTokenString(deviceToken);
            SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(token, "com.example.myApp", payload);

            try {
                semaphore.acquire();
            } catch (InterruptedException e) {
                logger.error("ios push get semaphore failed, deviceToken:{}", deviceToken);
                e.printStackTrace();
            }
            final Future<PushNotificationResponse<SimpleApnsPushNotification>> future = apnsClient.sendNotification(pushNotification);

            future.addListener(new GenericFutureListener<Future<PushNotificationResponse>>() {
                @Override
                public void operationComplete(Future<PushNotificationResponse> pushNotificationResponseFuture) throws Exception {
                    if (future.isSuccess()) {
                        final PushNotificationResponse<SimpleApnsPushNotification> response = future.getNow();
                        if (response.isAccepted()) {
                            successCnt.incrementAndGet();
                        } else {
                            Date invalidTime = response.getTokenInvalidationTimestamp();
                            logger.error("Notification rejected by the APNs gateway: " + response.getRejectionReason());
                            if (invalidTime != null) {
                                logger.error("\t…and the token is invalid as of " + response.getTokenInvalidationTimestamp());
                            }
                        }
                    } else {
                        logger.error("send notification device token={} is failed {} ", token, future.cause().getMessage());
                    }
                    latch.countDown();
                    semaphore.release();
                }
            });
        }

        try {
            latch.await(20, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            logger.error("ios push latch await failed!");
            e.printStackTrace();
        }

        long endPushTime = System.currentTimeMillis();

        logger.info("test pushMessage success. [共推送" + total + "個][成功" + (successCnt.get()) + "個], 
            totalcost= " + (endPushTime - startTime) + ", pushCost=" + (endPushTime - startPushTime));
    }
}

  • 關(guān)于多線程調(diào)用client

Pushy ApnsClient是線程安全的喉誊,可以使用多線程來調(diào)用

  • 關(guān)于創(chuàng)建多個client

創(chuàng)建多個client是可以加快發(fā)送速度的邀摆,但是提升并不大,作者建議:

ApnsClient instances are designed to stick around for a long time. They're thread-safe and can be shared between many threads in a large application. We recommend creating a single client (per APNs certificate/key), then keeping that client around for the lifetime of your application.

  • 關(guān)于APNs響應(yīng)信息(錯誤信息)

可以查看官網(wǎng)的error code表格(鏈接)裹驰,了解出錯情況隧熙,及時調(diào)整。

Pushy性能

作者在Google討論組中說Pushy推送可以單核單線程達(dá)到10k/s-20k/s幻林,如下圖所示:

pushy-discuss

作者關(guān)于創(chuàng)建多client的建議及Pushy性能描述

但是可能是網(wǎng)絡(luò)或其他原因贞盯,我的測試結(jié)果沒有這么好,把測試結(jié)果貼出來沪饺,僅供參考(時間ms):

ps. 由于是測試躏敢,沒有大量的設(shè)備可以用于群發(fā)推送測試,所以以往一個設(shè)備發(fā)送多條推送替代整葡。這里短時間往一個設(shè)備發(fā)送大量的推送件余,APNs會報TooManyRequests錯誤,Too many requests were made consecutively to the same device token遭居。所以會有少量消息無法發(fā)出啼器。

ps. 這里的推送時間,沒有加上client初始化的時間俱萍。

ps. 消息推送時間與被推消息的大小有關(guān)系端壳,這里我在測試時沒有控制消息變量(都是我瞎填的,都是很短的消息)所以數(shù)據(jù)僅供參考枪蘑。

  • ConcurrentConnections: 1, EventLoopGroup Thread: 1
推送1個設(shè)備 推送13個設(shè)備 同一設(shè)備推100條 同一設(shè)備推1000條
平均推送成功(個) 1 13 100 998
平均推送耗時(ms) 222 500 654 3200
  • ConcurrentConnections: 5, EventLoopGroup Thread: 1
推送1個設(shè)備 推送13個設(shè)備 同一設(shè)備推100條 同一設(shè)備推1000條
平均推送成功(個) 1 13 100 999
平均推送耗時(ms) 310 330 1600 1200
  • ConcurrentConnections: 4, EventLoopGroup Thread: 4
推送1個設(shè)備 推送13個設(shè)備 同一設(shè)備推100條 同一設(shè)備推1000條
平均推送成功(個) 1 13 100 999
平均推送耗時(ms) 250 343 700 1700

關(guān)于性能優(yōu)化也可以看看官網(wǎng)作者的建議:Threads, concurrent connections, and performance

大家有測試的數(shù)據(jù)也可以分享出來一起討論一下损谦。

今天(12.11)又測了一下,推送給3個設(shè)備岳颇,每個重復(fù)推送1000條照捡,共3000條,結(jié)果如下(時間為ms):

thread/connection No.1 No.2 No.3 No.4 No.5 No.6 No.7 No.8 No.9 No.10 Avg
1/1 12903 12782 10181 10393 11292 13608 - - - - 11859.8
4/4 2861 3289 6258 5488 6649 6113 7042 5393 4591 7269 5495.3
20/20 1575 1456 1640 2761 2321 2154 1796 1634 2440 2114 1989.1
40/40 1535 2134 3312 2311 1553 2088 1734 1834 1530 1724 1975.5

同時測了一下话侧,給這3個設(shè)備重復(fù)推送100000條消息栗精,共300000條的時間,結(jié)果如下(時間為ms):

thread/connection No.1
20/20 43547

思考

蘋果APNs一直在更新優(yōu)化瞻鹏,一致在擁抱新技術(shù)(HTTP/2术羔,JWT等),是一個非常了不起的服務(wù)乙漓。

自己來直接調(diào)用APNs服務(wù)來達(dá)到生成環(huán)境要求還是有點(diǎn)困難级历。Turo給我們提供了一個很好的Java庫:Pushy。Pushy還有一些其他的功能與用法(Metrics叭披、proxy寥殖、Logging...)玩讳,總體來說還是非常不錯的。

同時感覺我們使用Pushy還可以調(diào)優(yōu)...


2017/12/07 done

此文章也同步至個人Github博客

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末嚼贡,一起剝皮案震驚了整個濱河市熏纯,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌粤策,老刑警劉巖樟澜,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異叮盘,居然都是意外死亡秩贰,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門柔吼,熙熙樓的掌柜王于貴愁眉苦臉地迎上來毒费,“玉大人,你說我怎么就攤上這事愈魏∶俨#” “怎么了?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵培漏,是天一觀的道長溪厘。 經(jīng)常有香客問我,道長牌柄,這世上最難降的妖魔是什么畸悬? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮友鼻,結(jié)果婚禮上傻昙,老公的妹妹穿的比我還像新娘闺骚。我一直安慰自己彩扔,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布僻爽。 她就那樣靜靜地躺著虫碉,像睡著了一般。 火紅的嫁衣襯著肌膚如雪胸梆。 梳的紋絲不亂的頭發(fā)上敦捧,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天,我揣著相機(jī)與錄音碰镜,去河邊找鬼兢卵。 笑死,一個胖子當(dāng)著我的面吹牛绪颖,可吹牛的內(nèi)容都是我干的秽荤。 我是一名探鬼主播,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼窃款!你這毒婦竟也來了课兄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤晨继,失蹤者是張志新(化名)和其女友劉穎烟阐,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體紊扬,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡蜒茄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了珠月。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片扩淀。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖啤挎,靈堂內(nèi)的尸體忽然破棺而出驻谆,到底是詐尸還是另有隱情,我是刑警寧澤庆聘,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布胜臊,位于F島的核電站,受9級特大地震影響伙判,放射性物質(zhì)發(fā)生泄漏象对。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一宴抚、第九天 我趴在偏房一處隱蔽的房頂上張望勒魔。 院中可真熱鬧,春花似錦菇曲、人聲如沸冠绢。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽弟胀。三九已至,卻和暖如春喊式,著一層夾襖步出監(jiān)牢的瞬間孵户,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工岔留, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留夏哭,地道東北人。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓献联,卻偏偏與公主長得像竖配,于是被迫代替她去往敵國和親厕吉。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容