用Node推送App消息 - APNs(Apple推送通知服务)的简单实现
提醒
如果你只是需要一个第三方包来实现APN服务的话,推荐直接使用现有的项目 node-apn
提前工作
-
确保你是 Apple Developer 会员
-
点这里生成一个 *.p8 格式的私钥,仅能下载一次,注意妥善保存。(由于我使用的是_Token-Based_的认证方式,所以需要此格式。*.p12格式的证书不适用于本文的内容)
-
获取APNsAuthKeyId(Key ID):下好的 *.p8文件中就包含有Key ID。如’AuthKey_AXN12P9G17.p8’,那么’AXN12P9G17’就是Key ID。
-
获取AppleDeveloperId(Team Id): 进此页查看
-
获取BundleId: 注册App Id时填写的名称,一般其格式为 ‘com.app-name.app’
-
获取deviceToken
deviceToken相当于用户手机的id, 用户在下载App启动后就可以获取deviceToken。开发者可以在Xcode中拿到自己手机的deviceToken。
参见:
-
基于node v12.19.0版本
使用私钥生成签名
// typescript
import * as crypto from 'crypto'
import * as fs from 'fs'
// 下面的引用请自行替换
import { APNs, RunMode, runMode } from './secret'
import { AsyncRedis } from './redis'
class APNsService {
private static APNsAuthToken = 'APNs:push:token'
private static host = {
sandbox: 'api.sandbox.push.apple.com',
product: 'api.push.apple.com',
}
private static base64Encode (data: {[x:string]: any}) {
return Buffer.from(JSON.stringify(data), 'utf-8').toString('base64')
}
private static fromBase64 (base64:string) {
return base64
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
private static getPrivateKey ():Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile('./APNs_Auth_Key.p8', 'utf8', (err, data) => {
if (err) return reject(err)
resolve(data)
})
})
}
private static async generateAuthToken () {
const header = {
alg: 'ES256',
kid: APNs.APNsAuthKeyId,
}
const payload: APNsPayload = {
iss: APNs.AppleDeveloperId,
iat: Math.floor(Date.now() / 1000)
}
const privateKey = await this.getPrivateKey()
// 虽然这里标示为"RSA-SHA"算法,但也能用于计算ECDSA类型的算法
const signer = crypto.createSign('RSA-SHA256')
const headerPayload = `${this.base64Encode(header)}.${this.base64Encode(payload)}`
let sig = (
signer.update(headerPayload),
signer.sign(privateKey, 'base64')
)
sig = this.fromBase64(sig)
return `${headerPayload}.${sig}`
}
private static tokenInvalid (token: string): boolean {
if (!token) return true
const _token = token.split('.')
if (_token.length !== 3) return true
try {
const payload: APNsPayload = JSON.parse(Buffer.from(_token[1], 'base64').toString('utf-8'))
// 计算token的有效时间,JWT签名后的有效期仅为1小时,这里提前半小时刷新
// 注意不要频繁刷新token,否则请求会被拒绝
const now = Math.floor(Date.now() / 1000)
if (now > payload.iat + 1800) return true
} catch { return true }
return false
}
static async getAuthToken () {
// 使用Redis缓存token,可自行替换其他数据库
let token = await AsyncRedis.get(this.APNsAuthToken)
if (this.tokenInvalid(token)) {
token = await this.generateAuthToken()
await AsyncRedis.set(this.APNsAuthToken, token)
}
return token
}
}
使用Http/2请求
在生成签名后,作者尝试向苹果服务器发出请求,结果遇到了报错
Parse Error: Expected HTTP/
at Socket.socketOnData (_http_client.js:456:22)
at Socket.emit (events.js:210:5)
at addChunk (_stream_readable.js:308:12)
at readableAddChunk (_stream_readable.js:289:11)
at Socket.Readable.push (_stream_readable.js:223:10)
at TCP.onStreamRead (internal/stream_base_commons.js:182:23)
作者一度怀疑是node版本的问题(甚至还升级了版本 XD)。然而待作者仔细阅读Apple的文档后发现,原来是APNs只支持http/2的协议,难怪一直跑不通呢!这里也非常佩服苹果,总是投入资源来推动新技术的普及。
不过问题找到了,其实也会很容易解决
// typescript
import * as http from 'http'
import * as https from 'https'
import * as http2 from 'http2'
import * as querystring from 'querystring'
// 下面的引用请自行替换(不关键)
import { logger } from '../base/log4j'
import { Failed } from '../errors/api-error'
import { Errors } from '../error-result-types'
export class API {
private protocol = 'https'
private debug = false
private host!: string
private path = '/'
private headers = {
'accept-encoding': 'gzip, deflate, br',
'content-type': 'application/json; charset=utf-8',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36',
}
private options: {
host: string,
path: string,
headers: {[x:string]: string},
method: 'GET' | 'POST' | 'DELETE' | 'HEAD' | 'PUT',
port?: number
} = {
host: this.host,
path: this.path,
headers: this.headers,
method: 'GET',
}
private payload = {}
private body = {}
private response?: http.IncomingMessage
private constructor (args: {
path: string,
host?: string,
port?: number
debug?: boolean,
}) {
this.options.path = this.path = args.path ? args.path : '/'
this.options.host = this.host = args.host ? args.host : 'api.evink.cn'
this.debug = args.debug ? args.debug : false
if (args.port) this.options.port = args.port
}
addHeader (headers: { [x: string]: string }): API {
this.options.headers = Object.assign(this.headers, headers)
return this
}
addPayload (payload: { [x: string]: any }): API {
this.payload = Object.assign(this.payload, payload)
return this
}
addBody (body: { [x: string]: any }): API {
this.body = Object.assign(this.body, body)
return this
}
private http2Request<T> (callback?: ((data: T) => void)): Promise<T> {
return new Promise((resolve) => {
let data = ''
try {
const client = http2.connect(`${this.protocol}://${this.host}`)
let path = this.path
if (Object.keys(this.payload).length > 0) {
path = `${this.path}?${querystring.stringify(this.payload)}`
}
const options = {
':method': this.options.method,
':path': path,
...this.headers
}
const request = client.request(options)
if (this.debug) {
logger.debug('Request options:', this.host, options)
logger.debug('Request payload:', this.payload)
logger.debug('Request body:', this.body)
}
let jsonTypeResponse = false
request.on('response', (headers, flags) => {
if (':status' in headers) logger.info('Response status code: ', headers[':status'])
if ('content-type' in headers && headers['content-type']?.includes('application/json'))
jsonTypeResponse = true
})
request.on('data', chunk => {
data += chunk
if (this.debug) logger.debug('Response chunk:', chunk)
})
request.on('end', () => {
client.close()
if (jsonTypeResponse) data = JSON.parse(data)
resolve(data as any)
if (callback) callback(data as any)
if (this.debug) logger.debug('Response data:', data)
})
request.end(JSON.stringify(this.body))
}
catch (err) {
logger.error(err)
throw new Failed(Errors.InternalError)
}
})
}
static get (args: {
path: string,
host?: string,
port?: number
debug?: boolean,
}): API {
const api = new API(args)
api.options.method = 'GET'
return api
}
static post (args: {
path: string,
host?: string,
port?: number
debug?: boolean,
}): API {
const api = new API(args)
api.options.method = 'POST'
return api
}
send (callback?: (data: string | { [x: string]: any }) => void): Promise<string | { [x: string]: any }> {
return this.http2Request(callback)
}
}
开始推送
//typescript
import { APNsService } from './apn-service'
import { API } from './request'
import { APNs, RunMode, runMode } from './secret'
export class AppPush {
static async iosPush (args: { deviceToken: string, alert: PushAlert }): Promise<void> {
const { deviceToken, alert } = { ...args }
const host = runMode === RunMode.dev? APNsService.host.sandbox : APNsService.host.product
await API.post({
host, path: `/3/device/${deviceToken}`,http2: true,
debug: true
}).addHeader({
authorization: `bearer ${await APNsService.getAuthToken()}`,
'apns-push-type': 'alert', // 'alert' | 'background'
'apns-topic': APNs.BundleId,
}).addBody({
aps: {alert},
}).send()
}
}
AppPush.iosPush({
deviceToken: 'your-device-token',
alert: {
title: '推送测试',
subtile: '',
body: '这是详细的推送消息',
}
})
请求成功后,Apple会返回对应的状态码和reason。一般出现403状态都说明生成的签名错误,请检查你是否使用错了证书(本文中的签名方法仅针对 *.p8格式的私钥)
参考:Apple官方文档
APN服务对应的状态码:
| status code | reason | 描述 |
|---|---|---|
| 200 | / | 成功 |
| 400 | BadDeviceToken | DeviceToken无效,或者环境错了。 |
| 400 | BadTopic | apns-topic值无效 |
| 400 | DeviceTokenNotForTopic | DeviceToken和Topic不匹配 |
| 400 | MissingDeviceToken | 没有DeviceToken |
| 400 | MissingTopic | 请求头缺少apns-topic |
| 403 | BadCertificate | 证书无效 |
| 403 | BadCertificateEnvironment | 证书的环境不对 |
| 403 | ExpiredProviderToken | Token过期了 |
| 403 | MissingProviderToken | 没有提供客户端认证证书,也没有设置authorization |
| 413 | PayloadTooLarge | payload太大。(不得大于4 KB) |
| 429 | TooManyProviderTokenUpdates | Token更新太过频繁(小于20分钟) |
| 429 | TooManyRequests | 同时向同一台设备发送了过多请求 |