web-push 国内实现浏览器的消息推送

一次业务需要我们用户部提出一个web push的需求,说来惭愧虽然在国外web push已经算主流的用户召唤手段了但是国内因为某种大家都知道的原因它被大多厂家放弃。下面我会把本站的实现web push的一些方法在下面写出来,欢迎指正。

web push 是什么

浏览器授权后可以实时消息通知对方,类似于APP中的消息通知。但是整体支持并没全部覆盖,支持web push的浏览器,内核有限,加上国内用户有功夫网的保护,无法授权并且通知到用户,所以这门在国外如火中天的用户召回技术在国内很少有人提及

web-push能干什么

  • 召回用户,增加用户粘性
  • 消息通知
  • 减少活动中的短信运营成本

web-push 通知如下图

web-push通知框

本次 web push 实现用到的知识

订阅

  • Notifications API (浏览器通知接口)
  • Service Workers(长驻浏览器中的脚本)

推送

  • JWT鉴权
  • shadowsocks
  • web-push

web-push实现原理

注册服务(订阅)

Notifications API

Notifications API 允许网页控制向最终用户显示系统通知 —这些都在顶级浏览上下文视口之外,因此即使用户已经切换标签页或移动到不同的应用程序,也可以显示。该API被设计成与不同平台上的现有通知系统兼容。

下面给出的是权限注册代码,可以在浏览器的Console面板贴上它,浏览器会弹出询问授权信息选择是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//这段代码讲向用户请求通知的权限,返回3种状态
//granted(被授予) denied(被拒绝) default(默认)

Notification.requestPermission().then(function(result) {
if (result === 'denied') {
console.log('拒绝');
return;
}
if (result === 'default') {
console.log('默认');
return;
}
if (result === 'granted') {
console.log('允许');
return;
}
});

如下图所示

Notification注册询问

下面我们调用Notification接口占时一个浏览器的推送通知继续Console面板贴上它

1
2
3
4
5
var options = {
body: '你好呀,这是一个测试',
icon: ''
}
var n = new Notification('张俊杰的博客测试dome',options);

允许结果如下图

web-push通知效果

总结下上面的两个小dome其实是让用户允许询问通知获得浏览器授权的一个过程,然后通知一下用户的例子。但是实际使用中

Service Worker

Service Worker是浏览器为web提供的一个功能让web端有常驻浏览器线程的能力,需要我们自己注册脚本到Service Worker中在下图中就可以看见我们注册的一个sw.js脚本。

浏览器Service Worker展示

下面这个脚本会检测浏览器是否支持服务工作线程和推送消息,并且注册服务工作线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if ('serviceWorker' in navigator && 'PushManager' in window) {
console.log('Service Worker and Push is supported');

navigator.serviceWorker.register('https://www.phpzjj.com/js/app/sw.js')
.then(function(swReg) {
console.log('Service Worker is registered', swReg);

swRegistration = swReg;
})
.catch(function(error) {
console.error('Service Worker Error', error);
});
} else {
console.warn('Push messaging is not supported');
pushButton.textContent = 'Push Not Supported';
}

如果注册成功会如下图显示

serviceWorker的注册

下面的代码既是我们注册的常驻文件icon可以引用自定义的网络图片,这边为了省事就用的本地静态图了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
'use strict';
var url = ''
self.addEventListener('push', function(event) {


const data = event.data.json()
const title = data['title'];
url = data['url']
const options = {
body: data['body'],
icon: 'images/icon.png',
badge: 'images/badge.png'
};

event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener('notificationclick', function(event) {

event.notification.close();

event.waitUntil(
clients.openWindow(url)
);
});

整个注册脚本的封装(JS功底的确有限代码有点丑陋)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/**
* 2019年02月16日18:33:22
* 基于Notification的web推送
*/
var config = new function (){
return {
debug:true, //打开错误提示
publicKey:'BLMtwQjfLMjmcGjcoX9AhSnEd6yl5JAAcyG9UkyNB0RL_AWuN8bLGf0RE2JWV6LBEsApgXtCdWiHn74jrE4dv_s'
}
}

let swRegistration = null;

/**
* 申请注册Notification权限
*
* no:浏览器不支持
* agree:用户同意过
* success:用户第一次同意
* fail:用户拒绝
*
*/

function register(callback) {
var state = ''
// 浏览器是否支持
if (!("Notification" in window)) {

if(config['debug']){
console.log('浏览器不支持');
}

callback('no')
}

// 检查用户是否同意接受通知
else if (Notification.permission === "granted") {
callback('agree')
}
// 否则我们需要向用户获取权限
else if (Notification.permission !== 'denied') {

Notification.requestPermission(function (permission) {
// 申请权限

if (permission === "granted") {
callback('success')
}else{
callback('fail')
}

});
}

}


function subscribeUser(callback) {


const applicationServerKey = urlB64ToUint8Array(config["publicKey"]);

return swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(subscription) {
callback(Base64.encode(JSON.stringify(subscription)))
})
.catch(function(err) {
console.log('Failed to subscribe the user: ', err);
});
}



/**
* 接口加密
* @param {*} base64String
*/
function urlB64ToUint8Array(base64String) {

const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');

const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);

for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}




//注册
if ('serviceWorker' in navigator && 'PushManager' in window) {
console.log('Service Worker and Push is supported');

navigator.serviceWorker.register('/js/app/sw.js')
.then(function(swReg) {
swRegistration = swReg;
})
.catch(function(error) {
console.error('Service Worker Error', error);
});
} else {
console.warn('Push messaging is not supported');
}


var Base64 = {

// private property
_keyStr: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",

// public method for encoding
encode: function(input) {
var output = "";
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
var i = 0;

input = Base64._utf8_encode(input);

while (i < input.length) {

chr1 = input.charCodeAt(i++);
chr2 = input.charCodeAt(i++);
chr3 = input.charCodeAt(i++);

enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;

if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}

output = output + this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) + this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);

}

return output;
},

// public method for decoding
decode: function(input) {
var output = "";
var chr1, chr2, chr3;
var enc1, enc2, enc3, enc4;
var i = 0;

input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");

while (i < input.length) {

enc1 = this._keyStr.indexOf(input.charAt(i++));
enc2 = this._keyStr.indexOf(input.charAt(i++));
enc3 = this._keyStr.indexOf(input.charAt(i++));
enc4 = this._keyStr.indexOf(input.charAt(i++));

chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;

output = output + String.fromCharCode(chr1);

if (enc3 != 64) {
output = output + String.fromCharCode(chr2);
}
if (enc4 != 64) {
output = output + String.fromCharCode(chr3);
}

}

output = Base64._utf8_decode(output);

return output;

},

// private method for UTF-8 encoding
_utf8_encode: function(string) {
string = string.replace(/\r\n/g, "\n");
var utftext = "";

for (var n = 0; n < string.length; n++) {

var c = string.charCodeAt(n);

if (c < 128) {
utftext += String.fromCharCode(c);
} else if ((c > 127) && (c < 2048)) {
utftext += String.fromCharCode((c >> 6) | 192);
utftext += String.fromCharCode((c & 63) | 128);
} else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}

}

return utftext;
},

// private method for UTF-8 decoding
_utf8_decode: function(utftext) {
var string = "";
var i = 0;
var c = c1 = c2 = 0;

while (i < utftext.length) {

c = utftext.charCodeAt(i);

if (c < 128) {
string += String.fromCharCode(c);
i++;
} else if ((c > 191) && (c < 224)) {
c2 = utftext.charCodeAt(i + 1);
string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
i += 2;
} else {
c2 = utftext.charCodeAt(i + 1);
c3 = utftext.charCodeAt(i + 2);
string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
i += 3;
}

}

return string;
}

}

//为了满足日后的一些统计要求这边加上了回调,本来想封装成插件用配置操作,但是我懒。。。而且功能也实现了,上面代码无需多关心有兴趣可以看看如果是实际使用替换 publicKey 即可

var register = register(function(e){
if(config['debug']){
console.log(e);
}

if(e ==="success"){

subscribeUser(function(e){
$.post("/api/push", {token: e});
})

}

})

把整个代码跑起来实际流程是这样的

授权:询问用户索取权限->生成公钥后验签的用户唯一标识信息->base64转码->ajax给后台数据库保存

1
2
eyJlbmRwb2ludCI6Imh0dHBzOi8vdXBkYXRlcy5wdXNoLnNlcnZpY2VzLm1vemlsbGEuY29tL3dwdXNoL3YyL2dBQUFBQUJjZXJ4dWdvanhOTHdhalN6cXdlc3JwdVQyTTJYUjFJdjV2aXZVc081V0hrLUQ3dkxhZ3dZd2ZERkN6UmhkbmItMHBHR19jNGZLSzNGS0FRR1dwdUp2OXVQdTJHbC0zZElmRzRGNXZfUUtqWFpsMkRSczNiNkRBVTRyY3hlQlVXTURKRDBWMzRNVGtzcXhtSGV6WWY4X2pRcjhfeTBCckY3enRaeTJYcGVmNXpEYzdBMCIsImtleXMiOnsiYXV0aCI6InFwLXg5U01qQ1JhSzlhLWVMR1JSV3ciLCJwMjU2ZGgiOiJCRjdqYnVTZlYzWlNlV0VsRXZSMkhrRFlnSHg5cWxHLUVFdHVuOWVGTkpqRURpVVJ0VlN1MVppMTVyb3VydWNrUDNkb0w4UURmS2h0UjBxV3dwZjFuR1kifX0=

1
{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABcerxugojxNLwajSzqwesrpuT2M2XR1Iv5vivUsO5WHk-D7vLagwYwfDFCzRhdnb-0pGG_c4fKK3FKAQGWpuJv9uPu2Gl-3dIfG4F5v_QKjXZl2DRs3b6DAU4rcxeBUWMDJD0V34MTksqxmHezYf8_jQr8_y0BrF7ztZy2Xpef5zDc7A0","keys":{"auth":"qp-x9SMjCRaK9a-eLGRRWw","p256dh":"BF7jbuSfV3ZSeWElEvR2HkDYgHx9qlG-EEtun9eFNJjEDiURtVSu1Zi15rouruckP3doL8QDfKhtR0qWwpf1nGY"}}

ps:如果你没科学上网,请拿火狐浏览器测试。

publicKey 的公私钥一定要配对否则后续的通知用户是接收不到的,浏览厂商为了保证用户不被普天满地的广告覆盖也加强了推送时对身份的验证。这里就需要引入applicationServerKey的概念,它又被称作VAPID

两种生成公私钥的方法
1:传送门
2:一些轮子里支持生成

推送消息

Linux下Shadowsocks的http协议服务

这里记录一些在国内无法推送谷歌浏览器的一些方法,更多关于推送的消息方法见谷歌他们的轮子上面讲的比较全了,有文档案例我就又懒一下。这边我用的是php源码,在翻了他们的源码后发现底层用的GuzzleHttp(有良好的异步支持)并不需要用我一开始想的swoole或者另外一些多进程的方法,并且在new WebPush()时候暴露出了配置参数,也就是说支持代理访问。大体如下就好

1
2
new WebPush($auth,[],10,['proxy' => 'udp://192.168.1.5:1087'] );

升级Python

我用的CentOs版本比较老,Python还是2.7的所以先升到3.6

算了尝试了下我放弃了yum还要安装一堆依赖,以后还是用Centos 7 年轻不懂事选了一个6.5哎。

安装 Shadowsocks 服务端

一.尝试在梯子上安装

1
2
sudo yum install python-setuptools && easy_install pip

二.编辑配置文件

1
2
3
4
5
6
7
8
9
10
11
sudo vi /etc/shadowsocks.json

{
"server":"my_server_ip",
"local_address": "127.0.0.1",
"local_port":1080,
"server_port":my_server_port,
"password":"my_password",
"timeout":300,
"method":"aes-256-cfb"
}

三.启动

1
2
3
4
//正常后台启动
sslocal -c /etc/shadowsocks.json -d start
//因为服务端和用户端在一台,会有点冲突
nohup sslocal -c /etc/shadowsocks.json &

四.测试

1
curl --socks5 127.0.0.1:5343 https://www.phpzjj.com/

如果有正常返回证明socks5的Linux代理完成,在实例化的时候带上代理~

new WebPush($auth,[],10,['proxy' => 'socks5://192.168.1.5:1087'] );

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

请我喝杯咖啡吧~