简介:在移动互联网时代,实时推送是增强用户粘性的重要手段。面向iOS应用开发者与后端Java工程师,这份压缩包提供了一套基于HTTP/2协议实现苹果推送服务(APNs)的完整Java示例,重点解决证书加载、安全上下文创建、HTTP/2客户端连接、通知JSON构造与响应处理中的常见问题,适合已了解JDK 11及以上版本、需要快速接入推送服务的开发者。资源包共3个文件,包含两个Java源文件和一个依赖包,整体仅286KB:ApnsSendUtil.java封装推送流程,JWT.java负责生成APNs认证令牌,commons-codec-1.14.jar提供编码工具,结构精简,便于直接放入项目使用或二次修改。资源压缩包虽小,但类职责划分清晰,适合作为项目模块直接迁移。资源还对生产环境与开发环境服务器地址差异、请求头中的通知标识与优先级、消息主题等关键参数,以及错误重试机制进行了说明,能帮助读者理解证书加载、安全连接和HTTP/2多路复用等环节,避开常见配置问题。已有2106人学习,适合有一定Java基础、希望快速完成iOS推送集成的开发者。
1. 从 Binary 协议切到 HTTP/2:这可能是你最后一次改 APNs 推送代码
iOS 推送开发里有个经常被忽略的事实:苹果在 2020 年 WWDC 上宣布废弃旧的 Binary Provider API,所有基于 socket 的二进制推送协议在 2021 年 3 月 31 日正式停止服务。也就是说,现在还维护着十年前Payload+Socket方案的工程,要么已经完成迁移,要么推送功能已经静默失效很久了。APNs 当前的唯一官方入口是 HTTP/2 接口,走 443 端口,要求 TLS 1.2 以上。这篇博文直接围绕 Java 11 环境下如何用 HTTP/2 协议把通知推到 iOS 设备展开,覆盖从 p12 证书加载、SSLContext 构建、推送请求构造到响应解析的完整链路。适合正在维护 Java 后端、需要对接 APNs 推送的工程师阅读——你不需要额外引入 Spring 体系,只要 JDK 11 和两个 jar 就够了。整个方案基于一个实际可运行的 apns.zip 资源包,里面的ApnsSendUtil封装了请求、签名、回执解析全部逻辑,文末也会给出脱离该包的独立实现思路。
2. 先理清 APNs 的 HTTP/2 接口设计:连接模型、请求头与响应语义
2.1 为什么苹果在 HTTP/2 上做推送而非普通 REST API
APNs 本质上不是一个传统意义的 REST 接口。普通 HTTP/1.1 请求每次都要建立 TCP 连接,加上 TLS 握手,一个推送请求的额外开销可能超过 200 毫秒——这对单条推送来说可以接受,但面对批量推送就非常吃亏。HTTP/2 引入的多路复用(Multiplexing)允许在同一个 TLS 连接上并发发送多个请求,配合头部压缩(HPACK)和二进制分帧,单条推送的耗时被显著压缩。苹果要求客户端必须复用连接,并且支持连接空闲超时——超过一定时间没有新请求时服务器会主动断开,客户端需要在收到 GOAWAY 帧后重新建立连接。
这里有个关键语义:APNs 的 HTTP/2 接口虽然是 POST 请求,但它不是幂等的。同一个apns-id下,如果你重发相同的请求,APNs 会返回DuplicateMessage错误(错误码 409)。所以不能用普通 REST 客户端的「超时重试」逻辑来处理推送——你需要自己维护apns-id并在重试时生成新的 UUID。
2.2 推送目标:Device Token 与会话(Session)的关系
APNs 推送的本质是「拿着设备令牌向苹果服务器证明你有权推给这个设备」。设备令牌(Device Token)由didRegisterForRemoteNotificationsWithDeviceToken回调获得,是 APNs 和你的 App 之间建立的安装标识,长度通常是 32 字节(64 个十六进制字符)。你在服务器端需要把它当成不透明字符串存储——不要解析它的内部结构,因为它可能在任何系统版本中改变编码规则。
每一个 Device Token 都与固定的 Topic 绑定。所谓 Topic 就是你的 App 的 Bundle ID(如com.example.app),在推送请求头中通过apns-topic字段指定。如果 Token 对应的 App 已经卸载,APNs 会返回Unregistered(状态码 410),响应头中的apns-unix-timestamp会给出设备最后接收推送的时间。这个信息可以用于清理无效 Token 记录。
2.3 HTTP/2 推送请求的核心字段
一个完整的 APNs 推送请求可以被拆解为三部分:请求行(POST + URL 路径)、请求头、请求体。URL 路径是/3/device/{device_token},其中{device_token}是设备的十六进制字符串。请求头里除了标准的host、content-length等字段,还需要设置以下 APNs 专用字段:
| 请求头字段 | 示例值 | 说明 |
|---|---|---|
apns-topic | com.example.app | 你的 App 的 Bundle ID,必填 |
apns-push-type | alert | 推送类型,可选值:alert、background、voip、complication、fileprovider、mdm,iOS 13 后建议显式声明 |
apns-priority | 10 | 10表示立即发送,5表示节能模式(仅限 background 推送) |
apns-id | UUID | 用于追踪和去重的唯一标识,不传则 APNs 自动生成 |
apns-expiration | 0 | 消息过期时间,Unix 时间戳;0表示立即丢弃 |
apns-collapse-id | news-2024-01 | 多条推送的合并标识,相同 ID 的新消息会覆盖旧的 |
请求体是一个 JSON 对象,最外层必须包含aps键。一个标准的 alert 推送请求体长这样:
{ "aps": { "alert": { "title": "订单状态更新", "body": "您的订单已发货" }, "sound": "default", "badge": 1, "category": "ORDER_STATUS" }, "customData": "任意自定义内容" }注意:aps外的自定义键值会原样传给 App,但大小不能超过 4KB(整个请求体)。构建推送请求时,设备令牌中的空格会被 URL 编码为%20,但实际推送时令牌不能包含空格——如果你是从数据库读出来的,建议先做trim()。
3. Java 端的证书加载与 SSLContext 构建:踩坑集中在 p12 解析
3.1 p12 文件与密钥库的内部结构
APNs 的推送证书(.p12)内部包含三样东西:证书链、私钥、以及 Apple 的中间证书信息。使用keytool可以查看基本信息:
keytool -list -v -keystore apns.p12 -storetype PKCS12 -storepass your_password输出中你会看到证书的 Owner 是Apple Push Services: com.example.app,Issuer 是Apple Worldwide Developer Relations Certification Authority。这个证书的有效期通常是一年,过期之前你需要去 Apple Developer Portal 重新生成。证书的私钥类型是 EC(椭圆曲线),256 位,对应 TLS 握手时用的签名算法是ECDSA-SHA256。
3.2 用 Java 代码加载 p12 并构建 SSLContext
ApnsSendUtil中的核心代码逻辑是从 p12 文件加载密钥库,然后构建 SSLContext。下面是完整的加载与初始化代码:
import javax.net.ssl.*; import java.io.FileInputStream; import java.security.KeyStore; import java.security.SecureRandom; public class SSLContextFactory { public static SSLContext createSSLContext(String p12Path, String password) throws Exception { // 1. 加载 p12 文件到 KeyStore KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (FileInputStream fis = new FileInputStream(p12Path)) { keyStore.load(fis, password.toCharArray()); } // 2. 使用 KeyManagerFactory 获取密钥管理器 KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); kmf.init(keyStore, password.toCharArray()); // 3. 创建 SSLContext 并初始化 SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(kmf.getKeyManagers(), null, new SecureRandom()); return sslContext; } }这段代码有三个容易被忽略的细节。第一,KeyStore.getInstance("PKCS12")不要省略,因为 JDK 8 之前默认的密钥库类型是 JKS,加载 p12 会报Invalid keystore format。第二,kmf.init的第二个参数传的是私钥密码——如果 p12 的证书密码和私钥密码不一致(一般情况一致),这里必须用私钥的密码,否则握手阶段会报SSLHandshakeException。第三,SSLContext.getInstance("TLS")会让 JVM 自动选择 TLS 版本,但在 JDK 11 下默认启用 TLS 1.3,而 APNs 服务器目前只支持到 TLS 1.2,所以需要显式指定协议版本。
修正协议版本的写法如下:
sslContext.init(kmf.getKeyManagers(), null, new SecureRandom()); // 创建 SSLSocketFactory 时指定 TLSv1.2 SSLSocketFactory factory = sslContext.getSocketFactory(); // 使用工厂创建连接时,需要强转并设置协议不过使用 Jetty 或 OkHttp 时,协议版本是在客户端配置中指定的,你不需要直接操作SSLSocketFactory。后面章节会分别给出两种客户端的配置方式。
3.3 证书加载失败的常见异常与定位方法
| 异常信息 | 原因 | 解决方法 |
|---|---|---|
IOException: keystore password was incorrect | p12 密码错误 | 确认密码是否包含特殊字符,检查是否有大小写混淆 |
IOException: DerInputStream.getLength(): lengthTag=127, too big | 文件不是 PKCS12 格式 | 可能是直接把 .cer 证书文件改名成了 .p12,需要重新导出 |
KeyStoreException: Uninitialized key store | 调用了load()但传入 null 流 | 需要先加载文件再initKeyManagerFactory |
SSLHandshakeException: Received fatal alert: handshake_failure | 服务端不认可客户端 TLS 版本或加密套件 | 手动指定 TLSv1.2,并检查 JDK 的jdk.tls.disabledAlgorithms配置 |
一个实用的验证方式:在正式写推送代码前,先通过openssl命令行验证证书和私钥匹配:
openssl pkcs12 -in apns.p12 -nodes -passin pass:your_password openssl x509 -noout -subject -dates -in cert.pem4. HTTP/2 客户端选型与推送发送:从 Jetty 到原生 HttpClient
4.1 Jetty HttpClient 的 HTTP/2 客户端配置
Jetty 的 HttpClient 对 HTTP/2 的支持非常成熟,而且可以直接复用SSLContext。引入依赖(Maven 坐标在资源包的说明文档里有),核心发送代码如下:
import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.http2.client.HTTP2Client; import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2; public class ApnsSender { public static void sendPush(String deviceToken, String payload, SSLContext sslContext) throws Exception { // 1. 创建 HTTP/2 传输层 HTTP2Client http2Client = new HTTP2Client(); HttpClientTransportOverHTTP2 transport = new HttpClientTransportOverHTTP2(http2Client); // 2. 初始化 HttpClient HttpClient client = new HttpClient(transport); client.setSslContextFactory(new SslContextFactory.Client(sslContext)); client.setConnectTimeout(5000); // 连接超时 5 秒 // 3. 启动客户端 client.start(); // 4. 构建 POST 请求 String url = "https://api.push.apple.com/3/device/" + deviceToken; ContentResponse response = client.POST(url) .accept("application/json") .header("apns-topic", "com.example.app") .header("apns-push-type", "alert") .header("apns-priority", "10") .content(new StringContentProvider(payload, "application/json; charset=utf-8")) .send(); System.out.println("HTTP 状态码: " + response.getStatus()); System.out.println("响应内容: " + response.getContentAsString()); client.stop(); } }注意这里每个方法调用都值得解释。HTTP2Client是底层 HTTP/2 协议实现,HttpClientTransportOverHTTP2是 Jetty 对 HTTP/2 传输适配的封装,它承载了连接池和帧处理。client.setSslContextFactory必须传SslContextFactory.Client实例,不能直接传SSLContext——Jetty 需要自己包装这个上下文来获取协商参数。client.start()和client.stop()成对出现,每次调用完就stop的话,等于每次都重新建立连接,这就失去了 HTTP/2 多路复用的意义。实际项目中应该把HttpClient声明为单例,只在应用关闭时停止。
4.2 使用 JDK 11 原生 HttpClient 实现
JDK 11 的java.net.http.HttpClient原生支持 HTTP/2,它的优点是零依赖。注意以下实现:
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import javax.net.ssl.SSLContext; public class NativeApnsSender { public static void main(String[] args) throws Exception { // 创建 SSLContext(复用 3.2 节的 factory 方法) SSLContext sslContext = SSLContextFactory.createSSLContext("apns.p12", "password"); // 创建 HttpClient,指定 HTTP/2 HttpClient client = HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_2) .connectTimeout(Duration.ofSeconds(5)) .build(); // 构建推送请求 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.push.apple.com/3/device/" + "DEVICE_TOKEN")) .header("apns-topic", "com.example.app") .header("apns-push-type", "alert") .header("apns-priority", "10") .timeout(Duration.ofSeconds(10)) .POST(HttpRequest.BodyPublishers.ofString("{\"aps\":{\"alert\":\"Hello\"}}")) .build(); // 发送请求 HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("状态码: " + response.statusCode()); System.out.println("响应头: " + response.headers().map()); System.out.println("响应体: " + response.body()); } }JDK 原生 HttpClient 的 API 设计更简洁,但有一个坑:它会自动协商 HTTP/2,但如果服务器不支持或协商失败,会降级到 HTTP/1.1。APNs 明确支持 HTTP/2,所以协商通常成功。不过,如果你在本地通过代理调试,代理可能不支持 HTTP/2,导致请求静默降级——APNs 服务器对 HTTP/1.1 的请求直接返回 400。判断是否真的走了 HTTP/2,可以从响应头里看,HTTP/2 响应中会包含:status伪头字段,response.version()会返回HTTP_2。
4.3 推送响应:状态码与错误码对应关系
APNs 返回的 HTTP 状态码语义和 REST API 不同。200 表示成功;400 表示请求体格式错误;403 表示证书无效或 Topic 不匹配;404 表示设备令牌无效;410 表示设备已卸载。响应体是一个 JSON 对象:
{ "reason": "BadDeviceToken", "timestamp": 1700000000 }reason字段会给出具体的错误码字符串,常见的有:
| 错误码 | 含义 | 处理建议 |
|---|---|---|
BadDeviceToken | 设备令牌格式错误或已失效 | 从数据库中删除该 Token |
DeviceTokenNotForTopic | Token 和 Topic 不匹配 | 检查是不是配置错了 Bundle ID |
Unregistered | Token 对应的 App 已卸载 | 记录时间戳,清理 Token |
PayloadTooLarge | 请求体超过 4KB | 压缩自定义字段,或改用background推送 |
TooManyRequests | 推送过于频繁 | 指数退避重试 |
处理响应时,我的建议是先判断状态码,再解析响应体,因为 200 时响应体为空,直接解析会得到空字符串。同时注意apns-id响应头会返回本次推送的唯一 ID,建议把它打印到日志里——后期排查问题时,苹果支持有据可查。
5. 实战中的批量推送与连接复用:并发控制与退避重试
5.1 复用 HttpClient 实现高吞吐批量推送
前面提到 HttpClient 应该做单例管理。批量推送的典型场景是:从数据库读出一批 Device Token,循环发送。如果每次都新建 HttpClient,性能损耗集中在 TLS 握手和 HTTP/2 连接建立上;而复用同一个 HttpClient,所有请求共享同一个连接,吞吐量可以提升一个数量级。下面给出一个线程池配合 HttpClient 的批量推送示例:
import java.util.List; import java.util.concurrent.*; import java.util.stream.Collectors; public class BatchPusher { private final HttpClient client; private final ExecutorService executor; public BatchPusher(SSLContext sslContext) { this.client = HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_2) .executor(Executors.newFixedThreadPool(8)) .build(); this.executor = Executors.newFixedThreadPool(8); } public void sendBatch(List<String> deviceTokens, String payload) { List<CompletableFuture<Void>> futures = deviceTokens.stream() .map(token -> CompletableFuture.runAsync(() -> { try { sendSingle(token, payload); } catch (Exception e) { // 记录推送失败和原始异常 System.err.println("推送失败: " + token + " - " + e.getMessage()); } }, executor)) .collect(Collectors.toList()); // 等待所有推送完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } private void sendSingle(String token, String payload) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.push.apple.com/3/device/" + token)) .header("apns-topic", "com.example.app") .header("apns-push-type", "alert") .header("apns-expiration", String.valueOf(0)) .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 410) { // 设备已卸载,标记 Token 失效 System.out.println("Token 失效: " + token); } else if (response.statusCode() != 200) { // 解析响应体中的 reason String body = response.body(); System.out.println("推送失败: " + token + " - HTTP " + response.statusCode() + " - " + body); } } }这里把ExecutorService同时传给HttpClient和CompletableFuture.runAsync是有意为之——让所有异步任务共享同一个线程池,避免额外创建线程。HttpClient内部每个连接会占用一个线程来读取服务器响应,如果你不给它指定 executor,它会使用一个默认的公共池,在大量连接下可能会与业务线程竞争。另外注意connectTimeout只限制连接建立阶段,如果你需要限制整个请求的时长,还应该在HttpRequest上设置timeout。
5.2 大规模推送时的流量控制与指数退避
APNs 虽然没有公开明确的频率限制,但从实际经验看,单个连接上并发超过 100 个请求时会出现TooManyRequests。遇到这个错误时,必须放慢速度。推荐做法是使用信号量控制并发度,同时做指数退避重试:
import java.util.concurrent.Semaphore; public class ThrottledPusher { private final Semaphore semaphore = new Semaphore(50); // 最多 50 个并发 public void sendWithThrottle(String token, String payload, int maxRetries) { for (int attempt = 1; attempt <= maxRetries; attempt++) { try { semaphore.acquire(); try { HttpResponse<String> response = doSend(token, payload); if (response.statusCode() == 429) { // 服务器限流,等待后重试 long backoffMillis = (long) Math.pow(2, attempt) * 100; System.out.println("遇到限流,等待 " + backoffMillis + "ms 后重试"); Thread.sleep(backoffMillis); continue; } return; } finally { semaphore.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } }指数退避的细节:首次重试等待 200ms(2 的 1 次方 × 100),第二次 400ms,第三次 800ms,直到达到最大重试次数。注意Math.pow(2, attempt)的底数是 2,指数是重试次数——这是常见的指数退避算法,用来平滑限流压力。另外,429是 HTTP 状态码,APNs 的响应头Retry-After会给出建议等待时间,如果存在这个头,以其值为准。
5.3 推送回执与设备令牌清理的工程链路
生产环境里,推送失败信息不能只打到日志里。我建议设计一个本地消息表,记录每次推送的状态:
| 字段 | 类型 | 说明 |
|---|---|---|
push_id | VARCHAR(64) | 对应apns-id |
device_token | VARCHAR(64) | 目标设备令牌 |
status_code | INT | HTTP 状态码 |
error_reason | VARCHAR(128) | 响应体中的 reason 字段 |
created_at | DATETIME | 推送时间 |
定时任务定期扫描状态表中status_code = 404或410的记录,把这些 Token 从设备表中删除或标记为无效。这一步是推送运营的核心——如果设备令牌长期不清理,后续推送的到达率会持续下降,还会浪费 HTTP 连接资源。
6. 进阶:从 Provider Token 到 JWT 直连,彻底摆脱 p12 证书过期问题
6.1 为什么生产环境更适合使用 JWT 认证
p12 证书认证方式有一个明显的运维缺陷:证书有效期一年,到期前必须从 Apple Developer Portal 手动下载新证书,更换到服务器上,否则推送全部失败。苹果提供了另一种认证方式——JWT(JSON Web Token)直接认证。这种方式不需要 p12 证书,只需要三样东西:Team ID(10 个字符)、Key ID(10 个字符)、以及一个.p8格式的私钥文件(ES256 算法)。
JWT 认证的好处是私钥文件长期有效(不需要每年更新),并且一个私钥可以用于多个 App——只需要在 JWT 中指定不同的iss和aud字段。缺点是 JWT 本身有有效期,需要定期刷新。APNs 要求 JWT 的有效期最长为一小时,你需要维护一个定时任务,在一个小时之内复用同一个 Token,同时异步刷新下一个。
6.2 基于 java-jwt 或手写 ES256 签名的实现
资源包里的JWT.java就是处理 JWT 签名的工具类,核心逻辑是使用私钥对 Header 和 Payload 做 ES256 签名。如果你不想引入额外的 JWT 库,可以使用 Java 标准库的Signature类实现 ES256:
import java.security.KeyFactory; import java.security.PrivateKey; import java.security.Signature; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; public class JwtGenerator { public static String generateToken(String teamId, String keyId, PrivateKey privateKey) throws Exception { // 1. JWT Header String header = "{\"alg\":\"ES256\",\"kid\":\"" + keyId + "\"}"; // 2. JWT Payload long now = System.currentTimeMillis() / 1000; long exp = now + 3600; // 1 小时有效期 String payload = "{\"iss\":\"" + teamId + "\",\"iat\":" + now + ",\"exp\":" + exp + "}"; // 3. Base64URL 编码 String encodedHeader = Base64.getUrlEncoder().withoutPadding() .encodeToString(header.getBytes("UTF-8")); String encodedPayload = Base64.getUrlEncoder().withoutPadding() .encodeToString(payload.getBytes("UTF-8")); // 4. 拼接并签名 String signingInput = encodedHeader + "." + encodedPayload; Signature signature = Signature.getInstance("SHA256withECDSA"); signature.initSign(privateKey); signature.update(signingInput.getBytes("UTF-8")); byte[] derSignature = signature.sign(); // 5. 将 DER 格式签名转换为 JWT 需要的 raw 格式 byte[] rawSignature = derToRaw(derSignature); // 6. 拼接最终 JWT return signingInput + "." + Base64.getUrlEncoder().withoutPadding() .encodeToString(rawSignature); } /** * 将 DER 格式的 ECDSA 签名转换为 R || S 的 raw 格式, * 这是 JWT 规范要求的编码方式。 */ private static byte[] derToRaw(byte[] der) { // 解析 DER:30 len 02 rlen r 02 slen s int offset = 2; int rLen = der[offset + 1] & 0xFF; int rStart = offset + 2; byte[] r = new byte[rLen]; System.arraycopy(der, rStart, r, 0, rLen); int sStart = rStart + rLen + 2; int sLen = der[rStart + rLen + 1] & 0xFF; byte[] s = new byte[sLen]; System.arraycopy(der, sStart, s, 0, sLen); // 补齐到 32 字节(不可变长度) byte[] rawR = new byte[32]; byte[] rawS = new byte[32]; System.arraycopy(r, Math.max(0, rLen - 32), rawR, Math.max(0, 32 - rLen), Math.min(32, rLen)); System.arraycopy(s, Math.max(0, sLen - 32), rawS, Math.max(0, 32 - sLen), Math.min(32, sLen)); byte[] result = new byte[64]; System.arraycopy(rawR, 0, result, 0, 32); System.arraycopy(rawS, 0, result, 32, 32); return result; } }JWT 签名中有一个最容易被坑的细节:Java 的Signature类默认输出 DER 编码格式,而 JWT 标准要求的是 raw 编码(R 和 S 两个 32 字节直接拼接,共 64 字节)。如果直接把 DER 签名塞进 JWT,APNs 服务器会返回 403。这一步转换不可省略。上面的derToRaw方法里做了非负整数补零的处理——因为 ECDSA 签名中的 R 和 S 可能不足 32 字节,需要左侧补 0,但 JWT 库(如 jjwt)会替你处理,如果手写就需要自己处理。
6.3 JWT 与 HTTP/2 客户端的整合
拿到 JWT 后,把它放到Authorization请求头中,格式是Bearer <jwt>。注意 JWT 模式不需要在请求中携带证书,所以 SSLContext 的创建只需要信任 APNs 的服务器证书,而不需要加载自己的私钥——直接使用 JVM 默认的信任库即可:
SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, null, new SecureRandom()); // 使用默认信任库 HttpClient client = HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_2) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.push.apple.com/3/device/" + deviceToken)) .header("authorization", "Bearer " + jwtToken) .header("apns-topic", "com.example.app") .header("apns-push-type", "alert") .POST(HttpRequest.BodyPublishers.ofString(payload)) .build();JWT 的刷新策略:建议用一个定时任务,每 30 分钟重新生成一次 Token,保存在内存变量中。发送请求前直接从内存取,不重复生成,避免同一秒内大量请求都用不同的 JWT 导致 APNs 端频繁验签。你可以把 Token 的生成和缓存封装成一个单例类,内部用ScheduledExecutorService做定时刷新。
6.4 两种认证方式的选型对比
| 维度 | p12 证书 | Token 认证(JWT) |
|---|---|---|
| 有效期 | 一年,过期需手动更换 | 私钥长期有效,Token 每小时刷新 |
| 多 App 支持 | 每个 App 一个证书 | 一个私钥 + Team ID 即可 |
| 配置复杂度 | 需要管理 p12 文件和密码 | 需要管理 .p8 文件和 Key ID |
| 请求头差异 | 依赖 TLS 客户端证书 | 需要Authorization: Bearer头 |
| 适合场景 | 单 App、中小规模 | 多 App、持续集成、自动化运维 |
长期维护的角度,我更推荐 JWT 模式。证书模式最烦人的不是配置,而是「你无法预知证书什么时候会出问题」——比如 Apple 后台证书更新导致原有证书链失效,这种情况虽然少见,但排查起来非常耗时。JWT 模式的所有凭据都是文件 + 字符串,放进配置中心或环境变量里,完全规避了证书链的问题。
6.5 验证推送成功的实用技巧
推送发送完成之后,怎么确认用户真的收到了?有一个很基础但经常被忽略的验证手段:APNs 的响应只能告诉你「APNs 接受了这条推送」,无法告诉你「设备已经展示」。验证链路需要拆开看:
第一,检查 APNs 响应状态码是否为 200,响应头中有apns-id就说明 APNs 已受理。第二,在 Xcode 中连接真机,打开 Console 应用,过滤apsd进程日志,能看到推送到达系统层的记录。第三,App 内的application(_:didReceiveRemoteNotification:)回调是否触发,可以在回调中打日志,然后通过远程日志平台(如统一日志系统)确认。这里还有一个技巧:在开发环境中,给请求头加上apns-push-type: alert,并确保 App 处于前台时实现了userNotificationCenter(_:willPresent:withCompletionHandler:),否则推送到达后不会有横幅展示,容易被误判为推送失败。
本文还有配套的精品资源,点击获取