📌 序言:前阵子遇到新需求:“给‘xx事件’接上 iOS 的 APNs 远程推送通知。”接到任务的那一刻,我心里是一片空白的,我此前并没有接触过 ios的生态,也不知道这个APNs是什么东西,面对这个全然陌生的技术领域,我决定从产品入手,先明白项目为什么要进行APNs开发,他是干什么用的?
首先从用户视角和业务视角来看,APNs本质上就是一个“手机消息通知”的功能。就是你每天早上打开手机看到的一个一个消息弹窗,APNs的全称就叫做Apple Push Notification service。
从技术上来看不做 APNs,iOS App 就没有远程推送能力。
在搞清楚这个之后,我就知道我要做的其实就是一个消息推送功能,这个依赖苹果的Apple Push Notification service
简单来说就是xx事件触发后端 后端把推送消息发给苹果 APNs 服务器,由苹果统一下发到用户 iPhone,弹出消息提醒。
第一阶段:登记收件地址(App 刚安装时)
至此,准备工作完成,后端手里有了给该用户寄信的“门牌号”。
200 OK。(到这一步,后端的任务已经彻底结束了)完全由 后端业务 决定!苹果和系统只负责提供“信封”。
苹果极其死板,它为了保证所有 App 的通知在 iPhone 上看起来整齐划一,提供了一个固定格式的 JSON 结构(相当于官方信封)。但信封里面写什么字,100% 由我们后端说了算。
比如,只要我们在代码里组装出下面这块数据发送给苹果:
{
"aps": {
"alert": {
"title": "订单发货提醒📦",
"body": "您购买的潮流球鞋已安排发货,快递单号:SF10086,请注意查收~"
},
"sound": "default",
"badge": 1
}
}用户的手机屏幕上就会一字不差地显示这个标题和内容。苹果和 iOS 系统就像顺丰快递员,他们不关心信件里写了什么(是情书还是账单),他们只负责安全、准时地把它送到目的地并展示出来
在知道什么是APNs,知道他是如何工作的时候我就要开始开发的逻辑,那我首先就要知道 什么时候发送通知,也就是在什么事件条件下通知苹果 让他发送通知给用户,以及如何通知苹果。
这个问题可以拆成业务上什么时候用户应该接收到通知,技术上何时通知苹果两层
技术服务于业务。第一步和产品经理(PM)在白纸上明确,用户在什么场景下必须收到弹窗。
明确了业务场景后,我们就要回到后端的代码世界。我们需要找到对应业务逻辑的终点线,在那里埋下“触发推送”的钩子(Hook)。
简单来说,当数据库里的核心状态发生改变,或者某个关键接口成功返回时,就是技术上通知苹果(APNs)的时刻。在具体的架构实现上,我们通常有以下两种最经典的触发方式:
—— 适合:系统简单、对推送实时性要求极高的场景。
当业务逻辑执行完毕后,主线程直接在代码里调用推送方法(或者开一个线程异步调用),向苹果的 APNs 服务器发送请求。
—— 适合:高并发、架构复杂、对稳定性要求高的中大型系统。
这是目前业界最主流、也最优雅的处理方式。核心业务代码只负责干一件事:把事情做好,然后“发个广播”。
消息队列虽然好,但是太重量级了!
不想引入复杂的 RabbitMQ / Kafka,但又觉得直接用异步线程(方式 A)不够安全、怕丢数据时 还有一个选择, 就是把 DB(数据库)当成一个“轻量级的消息队列”。
本质就是在数据库创建一个表,当作队列用,后端定期扫描,处理in process状态的task。它能够发挥消息队列解耦削峰的优势,也不需要部署和维护额外的消息队列集群,减少了运维成本。在小项目中或一些特定的业务场景下,它的缺点,比如 时效性较差 分布式锁问题 数据库性能压力 可以忽略不记。
触发方式 | 实时性 | 数据可靠性 | 系统复杂度 | 适用场景 |
|---|---|---|---|---|
A. 直接调用 (同步/异步) | 极高 (立刻发送) | 低 (内存丢了或宕机就没了) | 极低 (几行代码搞定) | 个人项目、MVP 快速上线、对丢失不敏感的通知。 |
B. 消息队列 (MQ解耦) | 高 (毫秒级延迟) | 高 (MQ 支持持久化) | 高 (需要运维 MQ 中间件) | 用户量大、高并发、核心业务。 |
C. 数据库轮询 (DB暂存) | 较低 (受限于扫描间隔) | 极高 (强事务保证) | 中等 (需处理重试) | 并发不高、但绝对不能漏发的通知 |
在最底层的网络视角看,APNs 的本质是一个基于长连接的高性能双向通信网关。
苹果为了让全世界所有的 iOS 设备都能高效接收通知,规定了两个死理:
按照官方文档,要发起一次成功的投递,后端必须在本地配置好以下四样东西。
🔑 鉴权凭证(.p12证书 或 .p8令牌) ──> 证明“我是正版后端”
🏷️ apns-topic(App 的 Bundle ID) ──> 证明“我要发给哪款App”
📍 Device Token(64位十六进制字符串) ──> 证明“我要发给哪台具体的手机”.p12 证书:内含数字证书,直接绑定在底层的 TLS 握手阶段(双向 TLS 验证)。.p8 令牌:一个纯文本密钥,后端在发送请求时,需要动态用算法将其算成一串 JWT(JSON Web Token),塞进 HTTP 请求头里。apns-topic(Bundle ID): 比如 com.jianding.app。因为一个开发者账号下可能有多款 App,你必须明确告诉苹果你想把消息塞进哪一个。Device Token: 目标 iPhone 真机在注册通知时向苹果申请的唯一软硬件标识(类似 00fc13ad...)。它就是你要投递的精准门牌号。当你的后端要触发一个“鉴定事件通知”时,三方之间的接力赛是这样运行的:
后端发起(Create a POST Request):你的业务满足触发条件(如报告生成),后端捞出该用户的 Device Token,组装好一个标准的 HTTP/2 POST 请求。
网关校验(APNs Validate):请求送达苹果的 APNs 服务器。苹果立刻拆开信封:
apns-topic 是否属于你这个账号。响应(APNs Response):校验通过,苹果立刻给你的后端返回一个 200 OK。
系统投递(Device Delivery):苹果 APNs 网关在后台通过它跟那台 iPhone 之间长年维持的系统级高开销通道,把消息拍向用户的手机。
系统弹窗(iOS Render):用户的 iOS 操作系统(注意:不是你们的 App 进程)收到数据,硬生生在锁屏或屏幕顶部砸出一个横幅弹窗。
为了让你等会儿写代码时知道每一行 Headers 是在干什么,对照官方文档,看看发送时的 HTTP 报文长什么样:
// 1. 伪首部(HTTP/2 Pseudo-Header Fields)
:method = POST
:scheme = https
:path = /3/device/00fc13adff785122b4ad28809... // 路径里直接拼上 Device Token
// 2. 苹果要求的核心头部(Headers)
host = api.sandbox.push.apple.com // 开发测试网关(线上用 api.push.apple.com)
apns-topic = com.jianding.app // 必填:你的 App 包名
apns-push-type = alert // 必填:告诉苹果这是会引起亮屏、声音的用户弹窗
apns-expiration = 0 // 选填:0 代表手机离线就不缓存,立刻丢弃
apns-priority = 10 // 选填:10 为最高优先级,代表立刻投递,不合并成批次
// 3. 消息体(DATA Frame / JSON Payload)
{
"aps" : {
"alert" : {
"title" : "商品已发货 🚨",
"body" : "你购买的xx球鞋已发货"
},
"sound" : "default",
"badge" : 1
}
}aps 外壳:里面的键值对(alert, sound, badge)是苹果死死定义好的,iOS 系统只认这几个词来渲染弹窗。如果你传 "badge": 1,用户手机屏幕上的 App 图标右上角就会出现数字 1。注意苹果的 APNs 非常死板,它不会帮你做数学加法。 也就是说,如果用户本来就有 2 条未读消息(红点显示 2),此时你后端又触发了一次发货通知,如果你依然在 JSON 里传
"badge": 1,用户的红点不仅不会变成 3,反而会变成 1。在真实的商业项目里,后端数据库通常要有一张表去记录每个用户当前的“未读消息总数”。每次准备发 APNs 推送前,先去数据库里把未读数 $+1$,然后把计算好的最终结果(比如3)塞进 JSON 里的"badge": 3发给苹果。
aps 的同级外层塞入你自己的键值对,例如 "report_id": 9527。.p8 令牌方式这是目前工业界最主流、最推荐的搭配。.p8 文件永远不会过期,且一个文件可以给公司旗下所有 App 发通知。我们使用苹果生态里公认最好用的 pushy 库来搞定。
XML
<dependency>
<groupId>com.eatthepath</groupId>
<groupId>pushy</groupId>
<version>0.15.4</version>
</dependency>Java
import com.eatthepath.pushy.apns.ApnsClient;
import com.eatthepath.pushy.apns.ApnsClientBuilder;
import com.eatthepath.pushy.apns.PushNotificationResponse;
import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification;
import com.eatthepath.pushy.apns.util.TokenUtil;
import java.io.File;
import java.security.interfaces.ECPrivateKey;
import java.util.concurrent.CompletableFuture;
public class ApnsWithPushyP8 {
public static void main(String[] args) {
// 1. 准备向 iOS 同学索要的【.p8 原材料】
String teamId = "ABC123XYZ7"; // 苹果开发者团队 ID
String keyId = "KEY9527ABC"; // .p8 密钥文件自己的 ID
String p8FilePath = "/path/to/AuthKey_KEY9527ABC.p8"; // .p8 文件的绝对路径
String bundleId = "com.jianding.app"; // App 的唯一包名 (apns-topic)
String deviceToken = "00fc13adff785122b4ad..."; // 目标测试手机的 Token
ApnsClient apnsClient = null;
try {
// 2. 库在底层会自动读取 .p8 并用算法计算出 JWT 加密签名
ECPrivateKey privateKey = TokenUtil.loadPrivateKeyFromP8File(new File(p8FilePath));
apnsClient = new ApnsClientBuilder()
.setSigningKey(privateKey, teamId, keyId) // 注入密钥三件套
.setApnsServer(ApnsClientBuilder.DEVELOPMENT_SERVER) // 指定沙盒测试环境
.build();
// 3. 组装标准 JSON 内容
String payload = "{\"aps\":{\"alert\":{\"title\":\"发货通知\",\"body\":\"使用Pushy + .p8 发射成功!\"},\"sound\":\"default\"}}";
// 4. 打包发送对象
SimpleApnsPushNotification notification = new SimpleApnsPushNotification(deviceToken, bundleId, payload);
// 5. 异步发送并等待结果
CompletableFuture<PushNotificationResponse<SimpleApnsPushNotification>> future = apnsClient.sendNotification(notification);
PushNotificationResponse<SimpleApnsPushNotification> response = future.get();
if (response.isAccepted()) {
System.out.println("【成功】通过 .p8 成功送达苹果网关!");
} else {
System.err.println("【失败】原因: " + response.getRejectionReason());
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (apnsClient != null) { apnsClient.close(); }
}
}
}.p12 证书方式这是无需任何外部依赖(零 Maven 配置)的最小实现。我们将 .p12 证书直接绑定到 Java 原生的 TLS 握手层,通过原生的 HTTP/2 客户端裸发请求。
无! 只需要确保你的本地 JDK 版本 $\ge$ 11。
Java
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.FileInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyStore;
import java.time.Duration;
public class RawApnsJavaP12 {
public static void main(String[] args) {
// 1. 准备向 iOS 同学索要的【.p12 原材料】
String p12FilePath = "/path/to/aps_development.p12"; // .p12 证书路径
String p12Password = "123456"; // 证书密码
String bundleId = "com.jianding.app"; // App 的唯一包名
String deviceToken = "00fc13adff785122b4ad..."; // 目标测试手机的 Token
// 苹果测试环境 URL 路径
String apnsUrl = "https://api.sandbox.push.apple.com/3/device/" + deviceToken;
try {
// 2. 安全层:将 .p12 证书直接加载进 Java 底层的 TLS 握手上下文中
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(p12FilePath)) {
keyStore.load(fis, p12Password.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, p12Password.toCharArray());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), null, null);
// 3. 协议层:创建强制支持 HTTP/2 的 Java 原生 HttpClient
HttpClient client = HttpClient.newBuilder()
.sslContext(sslContext) // 注入绑定了证书的安全上下文
.version(HttpClient.Version.HTTP_2) // 强制指定 HTTP/2 协议
.connectTimeout(Duration.ofSeconds(10))
.build();
// 4. 数据层与请求构建
String jsonPayload = "{\"aps\":{\"alert\":{\"title\":\"发货通知\",\"body\":\"纯原生 Java + .p12 发射成功!\"},\"sound\":\"default\"}}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apnsUrl))
.timeout(Duration.ofSeconds(5))
.header("apns-topic", bundleId) // 必填:告诉苹果发给哪个 App
.header("apns-push-type", "alert") // 必填:弹窗类型
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
// 5. 发送并处理状态码
System.out.println("原生 HTTP/2 投递中...");
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println("【大获成功】苹果响应 200 OK,手机已弹窗!");
} else {
System.err.println("【投递失败】错误详情: " + response.body());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}📊 全方位对比
对比维度 | 方案一:第三方库 (Pushy) + .p8 | 方案二:纯原生 Java + .p12 |
|---|---|---|
外部依赖 | 需要引入 pushy 及其附带的 netty 依赖(打包体积稍大) | 零依赖,全部采用 JDK 11+ 内置类(最干净) |
凭证类型 | .p8 令牌(Token-based) | .p12 证书(Certificate-based) |
证书时效性 | 永久有效,一劳永逸 | 只有 1 年有效期,到期不更换线上直接瘫痪 |
复用灵活性 | 极高。一个文件能给公司所有 App 发推送 | 极低。一个证书只能绑定一个指定的 App |
环境区分 | 开发、生产环境通用这一个文件 | 开发、生产环境必须各自生成独立的 .p12 文件 |
鉴权发生阶段 | 应用层 HTTP 请求头。 库在底层把 .p8 算成 JWT 加密字符串,塞进 Authorization 的 Header 里。 | 传输层 TLS 握手阶段。 Java 建立网络连接的瞬间,通过客户端证书(双向 TLS)直接向苹果自证清白。 |
连接管理能力 | 极强。库内置了连接池、多路复用、掉线自动重连。 | 需要开发者自己利用单例模式去小心维护连接复用。 |
.p8? 因为如果用原生 Java 强行去啃 .p8,我们就得用纯原生代码手写一整套 JWT 签名加密算法。为了证明‘我是我’,我们需要写上百行枯燥的签名、时间戳、椭圆曲线加密代码。而 .p12 把身份证明直接融合进了网络长连接的握手阶段,让我们的 HTTP 请求头变得极其干净。既然 iOS 系统有苹果官方统一管理的 APNs 统一代收点,那么作为死对头的安卓(Android),当然也有它对应的“APNs”。
不过在安卓的世界里,因为历史原因和生态环境,这件事情变得稍微有点“割裂”。
在遵循谷歌原生生态的国外,安卓的“APNs”叫做 FCM(旧称 GCM)。
但是,由于国内的安卓手机无法使用谷歌服务(FCM 连不上),国内的安卓推送生态演变成了一场“军阀混战”。
国内的安卓手机没有统一的“一个”APNs,而是每一个手机厂商,都自己做了一个自己的“APNs”:
如果你是一个国内的后端开发,当产品经理和你说:“给我们的安卓版 App 也加上像 iOS 那样的离线系统弹窗吧!”
你不能像 iOS 那样只对接一个苹果就行了,你需要在后端写五六套完全不同的代码:
每一个厂商的 JSON 格式、Header 规范、鉴权方式都完全不一样。
看到这里,你可能已经开始头皮发麻了。为了解决国内安卓开发这种“要给六七个厂商分别写六七套代码”的绝望境地,第三方推送中台(如:极光推送 JPush、个推) 应运而生。
这些中台干了什么事呢?它们在自己的服务器上,把苹果 APNs、谷歌 FCM、华为、小米、OPPO、VIVO 的所有接口全都帮我们封装集成好了。
无论是苹果的 APNs、谷歌的 FCM、华为鸿蒙的 HMPS,还是国内小米、OPPO、VIVO 各自搞的厂商推送通道,它们在底层的“工作原理”上,相似度高达 99%。所有的不同,仅仅是域名不同、鉴权证书的格式不同、以及 JSON 豆腐块里的 key 改了个名字而已。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。