首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> .NET 網頁程式設計 >> ASP.NET 基礎 >> ASP.NET 基礎

.NET 推送 iOS 訊息完整實戰:PushSharp 架構、憑證管理、Token 更新與生產級監控

     編輯:ASP.NET 基礎

APNs 推送架構總覽

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 裝置

一、前置準備:APNs 憑證與 Key 兩種認證模式

模式檔案有效期管理複雜度推薦度
傳統憑證.p12 (含私鑰)1 年需每年續簽、匯出、部署⭐⭐ (舊專案相容)
Token-based (APNs Auth Key).p8 (私鑰) + Key ID + Team ID + Bundle ID永久 (除非撤銷)一次設定、無到期煩惱、支援多 App⭐⭐⭐⭐⭐ (新專案首選)

1.1 Token-based 設定步驟 (Apple Developer Console)

  1. Certificates, Identifiers & Profiles → Keys → + → 勾選 Apple Push Notifications service (APNs)。
  2. 下載 AuthKey_XXXXXXXXXX.p8 (僅一次機會,**妥善保存**)。
  3. 記錄:Key ID (10 碼)、Team ID (10 碼)、Bundle ID (Topic)。

二、PushSharp 核心程式碼實作 (Token-based)

2.1 依賴套件

<PackageReference Include="PushSharp" Version="4.0.10" />
<!-- 或官方輕量庫:ApnsHttp2 -->

2.2 靜態推送代理單例 (生產環境建議單例複用連線池)

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();
    }
}

2.3 依賴注入註冊

// 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());

三、Device Token 全生命週期管理

3.1 iOS 端註冊流程

// 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) {
    // 記錄失敗原因
}

3.2 後端 Token 儲存結構

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; } // 連續失效計數
}

3.3 定期清理策略

// 背景服務:每日執行
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);
        }
    }
}

四、Payload 設計最佳實踐

{
  "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: BadDeviceTokenToken 格式錯誤 (非 64 hex)、或 Sandbox/Production 環境不符驗證 Token 長度、確認 ApnsConfiguration 環境參數
403 ForbiddenTopic (Bundle ID) 與 Key/憑證不符、或 Key 已撤銷核對 Bundle ID、重新下載 .p8
410 GoneToken 永久失效 (App 解除安裝、系統重置)從 DB 刪除/標記無效,勿重試
429 TooManyRequests單一連線頻率過高增加連線池大小、啟用 PushSharp 內建節流
503 ServiceUnavailableAPNs 端暫時不可用指數退避重試 (PushSharp 內建)
推送無聲無息不送達Payload 過大 (> 4KB)、缺少 aps 字段、裝置關閉推送權限壓縮 Payload、檢查 aps 結構、引導用戶開啟權限

七、Android (FCM) 擴充提示

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 僅依賴介面。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved