Apple Push Notification service (APNs) 採用 **HTTP/2 多工連線**,每個 App 對應一組 **Topic (Bundle ID)**。.NET 端推薦使用 **PushSharp** (或官方 ApnsHttp2) 封裝協定細節。核心流程:
App Server (PushSharp) ──HTTP/2 TLS──► APNs Gateway (api.push.apple.com:443)
│ │
│ 1. 建立連線池 (每 Topic 一條長連線) │
│ 2. 推送 Payload (JSON, ≤ 4KB) │ 3. 驗證憑證/Token、裝置 Token
│ 3. 處理回應 (Success / 410 Gone / 400) │ 4. 轉發至目標裝置
▼ ▼
Device Token 管理 (資料庫) 目標 iOS 裝置
| 模式 | 檔案 | 有效期 | 管理複雜度 | 推薦度 |
|---|---|---|---|---|
| 傳統憑證 | .p12 (含私鑰) | 1 年 | 需每年續簽、匯出、部署 | ⭐⭐ (舊專案相容) |
| Token-based (APNs Auth Key) | .p8 (私鑰) + Key ID + Team ID + Bundle ID | 永久 (除非撤銷) | 一次設定、無到期煩惱、支援多 App | ⭐⭐⭐⭐⭐ (新專案首選) |
AuthKey_XXXXXXXXXX.p8 (僅一次機會,**妥善保存**)。<PackageReference Include="PushSharp" Version="4.0.10" />
<!-- 或官方輕量庫:ApnsHttp2 -->
using PushSharp;
using PushSharp.Apple;
using System;
using System.IO;
using System.Security.Cryptography;
public sealed class ApnsPushBroker : IDisposable
{
private readonly PushBroker _broker;
private readonly string _teamId, _keyId, _bundleId;
private readonly byte[] _privateKey; // PKCS#8 DER 格式
public ApnsPushBroker(string p8Path, string teamId, string keyId, string bundleId)
{
_teamId = teamId; _keyId = keyId; _bundleId = bundleId;
_privateKey = File.ReadAllBytes(p8Path); // 或從 Key Vault 讀取
_broker = new PushBroker();
RegisterEvents();
RegisterApnsService();
}
private void RegisterApnsService()
{
var settings = new ApnsConfiguration(
ApnsConfiguration.ApnsServerEnvironment.Production, // 或 Sandbox
_privateKey, // 私鑰 byte[]
_keyId,
_teamId,
_bundleId);
// 關鍵:Token-based 不需憑證,直接用 ApnsConfiguration 多載
_broker.RegisterApnsService(settings);
}
private void RegisterEvents()
{
_broker.OnNotificationSent += (sender, notification) =>
{
var apns = (ApnsNotification)notification;
_logger.Info($"APNs Sent: Device={apns.DeviceToken}, ApsId={apns.ApnsId}");
};
_broker.OnNotificationFailed += (sender, notification, error) =>
{
var apns = (ApnsNotification)notification;
var apnsEx = error as ApnsNotificationException;
_logger.Error(error, $"APNs Failed: Device={apns.DeviceToken}, Status={apnsEx?.StatusCode}, Reason={apnsEx?.Reason}");
// 關鍵:處理 410 Gone (Token 失效) → 從 DB 標記/刪除
if (apnsEx?.StatusCode == System.Net.HttpStatusCode.Gone)
DeviceTokenRepository.MarkInvalid(apns.DeviceToken);
};
_broker.OnDeviceSubscriptionExpired += (sender, expiredToken, timestamp, notification) =>
{
_logger.Warn($"Token Expired: {expiredToken}");
DeviceTokenRepository.MarkInvalid(expiredToken);
};
_broker.OnDeviceSubscriptionChanged += (sender, oldToken, newToken, notification) =>
{
_logger.Info($"Token Updated: {oldToken} → {newToken}");
DeviceTokenRepository.UpdateToken(oldToken, newToken);
};
}
///
/// 發送推送 (非同步佇列,即時回傳)
///
public void QueueNotification(string deviceToken, string alertTitle, string alertBody, int badge = 1, string sound = "default", object customData = null)
{
var payload = new ApnsPayload
{
Alert = new ApnsAlert { Title = alertTitle, Body = alertBody },
Badge = badge,
Sound = sound,
CustomItems = customData as Dictionary
};
var notification = new ApnsNotification(deviceToken, payload);
_broker.QueueNotification(notification);
}
public void Dispose()
{
_broker.StopAllServices(true); // 等待佇列清空
_broker.Dispose();
}
}
// Startup.cs / Program.cs
services.AddSingleton(sp =>
{
var cfg = sp.GetRequiredService();
return new ApnsPushBroker(
p8Path: cfg["Apns:P8Path"],
teamId: cfg["Apns:TeamId"],
keyId: cfg["Apns:KeyId"],
bundleId: cfg["Apns:BundleId"]);
});
// 關閉時釋放
var lifetime = app.Services.GetRequiredService();
lifetime.ApplicationStopping.Register(() =>
app.Services.GetRequiredService().Dispose());
// AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// 上傳至後端 API: POST /api/device-tokens { "token": token, "platform": "ios", "appVersion": "1.2.3" }
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// 記錄失敗原因
}
public class DeviceToken
{
public string Token { get; set; } // 64 字元 hex
public string Platform { get; set; } // ios / android
public string UserId { get; set; } // 關聯使用者
public string AppVersion { get; set; }
public DateTime RegisteredAt { get; set; }
public DateTime? LastVerifiedAt { get; set; }
public bool IsValid { get; set; } = true;
public int FailureCount { get; set; } // 連續失效計數
}
// 背景服務:每日執行
public class TokenHygieneService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// 1. 刪除 90 天未活躍
await _repo.DeleteInactiveAsync(TimeSpan.FromDays(90));
// 2. 重置連續失效計數 (若使用者重新登入 App 會重新註冊)
await _repo.ResetFailureCountAsync();
await Task.Delay(TimeSpan.FromDays(1), ct);
}
}
}
{
"aps": {
"alert": {
"title": "新訂單通知",
"body": "您有新的訂單 #20240101001",
"subtitle": "金額: NT$ 1,200"
},
"badge": 5,
"sound": "default",
"thread-id": "order_20240101001", // 群組通知
"category": "NEW_ORDER", // 對應 UNNotificationCategory (動作按鈕)
"mutable-content": 1 // 啟用 Notification Service Extension (圖片/影片)
},
"orderId": "20240101001", // 自訂資料
"deepLink": "myapp://order/20240101001"
}
| 指標 | 來源 | 告警閾值 | 儀表板 |
|---|---|---|---|
| 推送成功率 | OnNotificationSent / OnNotificationFailed 計數 | < 99.5% | Grafana + Prometheus |
| 410 Gone 比率 | OnNotificationFailed StatusCode=410 | > 1% | Token 健康度 |
| 佇列堆積 | PushBroker.QueueCount (需自行暴露) | > 10,000 | 背景壓力 |
| 連線池狀態 | APNs HTTP/2 連線數、錯誤率 | 連線中斷 > 5 次/分 | 連線健康 |
| 端到端延遲 | 客戶端收到時間 - 伺服器發送時間 | P99 > 5s | 用戶體驗 |
| 錯誤現象 | 原因 | 解決 |
|---|---|---|
| 400 BadRequest: BadDeviceToken | Token 格式錯誤 (非 64 hex)、或 Sandbox/Production 環境不符 | 驗證 Token 長度、確認 ApnsConfiguration 環境參數 |
| 403 Forbidden | Topic (Bundle ID) 與 Key/憑證不符、或 Key 已撤銷 | 核對 Bundle ID、重新下載 .p8 |
| 410 Gone | Token 永久失效 (App 解除安裝、系統重置) | 從 DB 刪除/標記無效,勿重試 |
| 429 TooManyRequests | 單一連線頻率過高 | 增加連線池大小、啟用 PushSharp 內建節流 |
| 503 ServiceUnavailable | APNs 端暫時不可用 | 指數退避重試 (PushSharp 內建) |
| 推送無聲無息不送達 | Payload 過大 (> 4KB)、缺少 aps 字段、裝置關閉推送權限 | 壓縮 Payload、檢查 aps 結構、引導用戶開啟權限 |
PushSharp 同樣支援 FCM:
_broker.RegisterFcmService(new FcmConfiguration("SERVER_KEY_FROM_FIREBASE_CONSOLE"));
// 發送
_broker.QueueNotification(new FcmNotification
{
RegistrationIds = new[] { deviceToken },
Data = new Dictionary { { "title", "標題" }, { "body", "內容" } },
Notification = new FcmNotification.NotificationPayload { Title = "標題", Body = "內容" }
});
建議抽象 IPushService 介面,實作 ApnsPushService / FcmPushService,Controller 僅依賴介面。