
Python操作消息推送服务:打造苹果APNs与安卓FCM的统一接口
你好,我是源码库的技术博主。在最近的一个全栈项目中,我遇到了一个经典的“甜蜜的烦恼”:我们的应用需要同时支持iOS和Android的消息推送。这意味着我不得不与苹果的APNs(Apple Push Notification service)和谷歌的FCM(Firebase Cloud Messaging)两套截然不同的SDK和API打交道。初期,我在业务逻辑里写满了 `if platform == 'ios'` 这样的判断,代码迅速变得臃肿且难以维护。痛定思痛,我决定封装一个统一的推送接口。今天,我就把这个从“踩坑”到“填坑”的实战经验分享给你。
一、为什么需要统一封装?先理清思路
在动手写代码之前,我们先明确目标。APNs和FCM的核心差异主要体现在:
- 协议与认证:APNs使用基于JWT的HTTP/2协议或传统的二进制协议,需要.p8或.p12证书;FCM使用HTTP/1.1,认证靠服务账号的JSON密钥文件。
- 消息结构:两者JSON payload的格式不同。APNs顶层键是 `aps`,而FCM的键是 `notification` 和 `data`,且结构有区别。
- 目标指定:APNs通过 `device_token` 定位设备,FCM通过 `registration_token` 或主题(topic)。
我们的封装目标很明确:对外提供一个如 `push_service.send(platform, token, title, body, data)` 这样简洁的接口,内部自动处理所有平台差异。这能极大提升代码的整洁度和可测试性。
二、搭建项目结构与基础配置
首先,我们创建一个清晰的项目结构。我习惯将核心类放在一个模块里。
mkdir unified_push && cd unified_push
touch push_service.py config.py requirements.txt
安装必要的依赖。我们将使用 `pyjwt` 处理APNs的JWT,`httpx` 作为HTTP客户端(因为它对HTTP/2支持友好),以及 `cryptography` 处理密钥。
# requirements.txt
httpx[http2]
pyjwt
cryptography
python-dotenv # 用于管理环境变量
在 `config.py` 中,我们通过环境变量管理敏感信息。这是安全实践,切记不要把密钥硬编码在代码里!
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
class PushConfig:
# APNs 配置
APNS_KEY_ID = os.getenv('APNS_KEY_ID') # .p8文件的Key ID
APNS_TEAM_ID = os.getenv('APNS_TEAM_ID')
APNS_BUNDLE_ID = os.getenv('APNS_BUNDLE_ID', 'com.yourcompany.app')
APNS_AUTH_KEY_PATH = os.getenv('APNS_AUTH_KEY_PATH') # .p8文件路径
# FCM 配置
FCM_CREDENTIALS_JSON_PATH = os.getenv('FCM_CREDENTIALS_JSON_PATH')
FCM_PROJECT_ID = os.getenv('FCM_PROJECT_ID')
# 推送环境 (APNs专用: 'development' 或 'production')
APNS_USE_SANDBOX = os.getenv('APNS_USE_SANDBOX', 'True').lower() == 'true'
三、实现APNs推送客户端
这是第一个难点。APNs的HTTP/2接口需要动态生成JWT令牌。我们创建一个 `APNsClient` 类。
# push_service.py 的一部分
import jwt
import time
import httpx
from pathlib import Path
from cryptography.hazmat.primitives import serialization
class APNsClient:
def __init__(self, config):
self.config = config
self._token = None
self._token_expiry = 0
self._client = None
self._base_url = f"https://{'api.sandbox.push.apple.com' if config.APNS_USE_SANDBOX else 'api.push.apple.com'}/3/device/"
def _get_auth_token(self):
"""生成APNs认证JWT令牌,有效期50分钟,需要复用"""
now = int(time.time())
if self._token and now < self._token_expiry:
return self._token
with open(self.config.APNS_AUTH_KEY_PATH, 'r') as f:
private_key = serialization.load_pem_private_key(f.read().encode(), password=None)
payload = {
'iss': self.config.APNS_TEAM_ID,
'iat': now
}
headers = {
'alg': 'ES256',
'kid': self.config.APNS_KEY_ID
}
self._token = jwt.encode(payload, private_key, algorithm='ES256', headers=headers)
self._token_expiry = now + 50 * 60 # 50分钟过期
return self._token
async def send(self, device_token, title, body, data=None, badge=1):
"""发送APNs推送"""
url = self._base_url + device_token
headers = {
'authorization': f'bearer {self._get_auth_token()}',
'apns-topic': self.config.APNS_BUNDLE_ID,
'apns-push-type': 'alert', # 明确指定推送类型
'apns-priority': '10'
}
# 构建APNs格式的Payload
aps_payload = {
'alert': {
'title': title,
'body': body
},
'badge': badge,
'sound': 'default'
}
payload = {'aps': aps_payload}
if data:
# 自定义数据放在aps同级
for key, value in data.items():
payload[key] = value
async with httpx.AsyncClient(http2=True) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
return True, None
except httpx.HTTPStatusError as e:
# APNs会返回具体错误原因,这是调试的关键!
error_info = e.response.json() if e.response.content else {'reason': 'Unknown'}
return False, f"APNs Error: {error_info.get('reason', str(e))}"
except Exception as e:
return False, f"APNs Connection Error: {str(e)}"
踩坑提示:APNs的JWT令牌生成是高频“坑点”。务必确保.p8文件路径正确,且Key ID、Team ID与证书匹配。错误信息通常很模糊,建议先用 `jwt.io` 调试你的令牌生成逻辑。另外,注意令牌需要复用,频繁生成会触发速率限制。
四、实现FCM推送客户端
相对于APNs,FCM的HTTP接口要简单一些。我们使用服务账号的JSON文件进行认证。
# push_service.py 继续
import json
from google.oauth2 import service_account
from google.auth.transport.requests import Request as GoogleRequest
class FCMClient:
def __init__(self, config):
self.config = config
self._credentials = None
self._access_token = None
self._token_expiry = None
self._base_url = f"https://fcm.googleapis.com/v1/projects/{config.FCM_PROJECT_ID}/messages:send"
def _get_access_token(self):
"""获取FCM OAuth2访问令牌"""
if self._credentials is None:
self._credentials = service_account.Credentials.from_service_account_file(
self.config.FCM_CREDENTIALS_JSON_PATH,
scopes=['https://www.googleapis.com/auth/cloud-platform']
)
if not self._credentials.valid or self._credentials.expired:
self._credentials.refresh(GoogleRequest())
return self._credentials.token
async def send(self, registration_token, title, body, data=None):
"""发送FCM推送"""
headers = {
'Authorization': f'Bearer {self._get_access_token()}',
'Content-Type': 'application/json'
}
# 构建FCM格式的Message
message = {
'message': {
'token': registration_token,
'notification': {
'title': title,
'body': body
}
}
}
if data:
message['message']['data'] = data
async with httpx.AsyncClient() as client:
try:
resp = await client.post(self._base_url, json=message, headers=headers)
resp.raise_for_status()
return True, None
except httpx.HTTPStatusError as e:
error_info = e.response.json() if e.response.content else {}
return False, f"FCM Error: {error_info}"
except Exception as e:
return False, f"FCM Connection Error: {str(e)}"
实战经验:FCM的 `data` 字段和 `notification` 字段行为不同。`notification` 会由系统自动显示,而 `data` 消息需要应用在前台自己处理。根据你的业务场景(例如,是否需要静默推送)决定如何使用它们。我们的封装将 `data` 参数映射到了FCM的 `data` 字段。
五、打造统一的推送门面(Facade)
现在,我们把两个客户端组装起来,提供一个干净利落的统一接口。
# push_service.py 的核心部分
class UnifiedPushService:
def __init__(self, config):
self.config = config
self.apns_client = APNsClient(config)
self.fcm_client = FCMClient(config)
async def send(self, platform, device_token, title, body, data=None, **kwargs):
"""
统一推送接口
:param platform: 'ios' 或 'android'
:param device_token: 设备令牌
:param title: 通知标题
:param body: 通知正文
:param data: 自定义数据字典
:param kwargs: 平台特定参数,如iOS的badge
:return: (success, error_message)
"""
if platform.lower() == 'ios':
badge = kwargs.get('badge', 1)
return await self.apns_client.send(device_token, title, body, data, badge)
elif platform.lower() == 'android':
return await self.fcm_client.send(device_token, title, body, data)
else:
return False, f"Unsupported platform: {platform}"
async def close(self):
"""如有需要,可在此处关闭客户端连接"""
pass # httpx的AsyncClient在with语句中自动管理,这里预留接口
六、使用示例与错误处理实践
让我们看看如何在实际业务中使用它,并做好健壮的错误处理。
# example_usage.py
import asyncio
from config import PushConfig
from push_service import UnifiedPushService
async def main():
config = PushConfig()
push_service = UnifiedPushService(config)
# 模拟推送任务
push_tasks = [
('ios', 'a1b2c3d4e5...(真实的APNs device token)', '新订单', '您有一笔新的待处理订单', {'order_id': '12345'}),
('android', 'f6g7h8i9j...(真实的FCM registration token)', '系统提醒', '您的账号在别处登录', {'event': 'logout'}),
]
for platform, token, title, body, data in push_tasks:
success, error = await push_service.send(platform, token, title, body, data)
if success:
print(f"[{platform.upper()}] 推送成功!")
else:
# **重要:在实际项目中,这里应该记录日志,并可能加入重试队列**
print(f"[{platform.upper()}] 推送失败: {error}")
# 可以根据错误类型进行特定处理,例如令牌失效
if 'InvalidToken' in error or 'UNREGISTERED' in error:
print(f" -> 设备令牌已失效,应从数据库中移除: {token}")
await push_service.close()
if __name__ == '__main__':
asyncio.run(main())
最后的忠告:推送服务是异步且依赖外部网络的,一定要做好错误处理和日志记录。特别是对于“设备令牌失效”这类错误,必须要有机制从你的用户数据库中清理无效令牌,否则会浪费推送配额并影响推送成功率。可以考虑将推送任务放入像Celery这样的异步队列,实现自动重试和死信处理。
至此,一个具备生产环境可用性的APNs与FCM统一推送接口就封装完成了。它不仅简化了业务代码,还将平台复杂性隔离在内部,使后续维护、扩展(比如增加华为、小米推送)都变得更加容易。希望这个从实战中总结的方案能帮到你。如果你在实现过程中遇到问题,欢迎在源码库社区交流讨论。 Happy Coding!

评论(0)