基于自定义token消息通知系统的设计与实现
2020-08-26李毓丽陈泰宏
李毓丽 陈泰宏
摘要:为了提供给Web应用以及Web开发者一种即时消息通知的解决方案,该设计主要以当下流行的vuejs+Laravel+Mysql的技术组合方式,将前后端完全分离,降低开发的耦合性,有利于多阶段的部署。并在此基础上,白定义了一套令牌校验机制以及利用swoole下的websocket接口实现消息通知功能,并且使用redis提高性能。
关键词:redis;websocket;自定义token;消息通知
中图分类号:TP311 文献标识码:A
文章编号:1009-3044(2020)20-0084-03
1背景
一个成熟的Web应用必定少不了消息通知。例如商城中的到货提醒功能,又如社交类型的网站,当用户A关注了用户B,或者用户C收藏了用户D的话题,系统会定时发送更新的消息,去提醒对应的用户,所以,消息通知在当下的WEB应用中是非常重要的组成部分。
2消息通知系统的设计
消息通知系统是以用户为单位来进行实现,主要的技术栈为前端采用vue2.x,利用vue-cli创建项目,是一个单页Web应用,也就是我们说的SPA应用。SPA应用有许多的优点,良好的交互体验,用户不需要频繁的刷新页面,通过Ajax从后台渎取数据,利用vuex对数据进行管理。良好的前后端分离开发方式,让单页Web应用可以和RESTful规约一起使用,通过RESTAPI提供接口数据,并使用Ajax获取数据,这样有助于分离客户端和服务器端工作。在客户端也可以分解为静态页面和页面数据响应两个部分。最重要的是可以减轻服务器压力,服务器只需要传输数据以及对一些存在安全隐患的数据进行验证,不用管展示逻辑和页面合成,吞吐能力会提高几倍。但SPA单页应用也有一个比较大的缺点,就是SEO难度较高,所以在优化期间会将页面修改成服务器渲染的方式。
后端采用的是PHP的框架Laravel,Laravel框架提供了许多软件包,Laravel有一个好处就是仓库非常多,加上有compos-er这一包管理器,使得其拓展性非常大。包括前端的一些组件包以及后台的一些插件。由于我们采用的是前后端完全分离这一模式,会存在跨域问题,因此使用了laravel-cors这一插件包,解决跨域问题。
根据需求,主要分为三部分,第一部分是用户的登录机制的实现。登录采用前后分离的方式,这就需要令牌token的验证,利用自定义实现的令牌机制,在登录注册这一功能上,根据OAUTH2.0协议,对用户的登录进行加密传递,通过用户提供的用户名密码认证成功后,传递回来的access_token去获取用户数据。同时,还集成了短信登录(阿里云短信接口)和微博登录(微博开放平台),方便用户登录,更好的拓展用户量。
第二部分是基于用户登录完成后的消息通知系统,当用户登录成功后,系统会自动连接上消息系统,本部分的功能基于PHP异步框架swoole的实现,通过swoole底层的封装,达成对websocket协议的支持,从而使得用户能够作为长链接稳定的接受系统通知消息。
第三部分是基于Redis的以用户为单位的域分离队列消息推送。消息通知系统中少不了订阅推送的功能,当系统中的管理员,想给所有的用户推送一条消息,假设平台有10万个用户,如果按照原来的思路来实现,那么就意味着每向所有用户推送一条消息,则会通过消息表,向消息表中增加10万条数据,这样做无疑会对数据库的性能造成非常大的影响,会对服务器造成很大的压力。而利用redis,则可以实现只插入一条数据,通知所有用户,并对用户的其他消息不造成影响。
3基于JWT标准与HMACSHA256+base64加密规则实现的自定义token机制
Laravel框架白带了一个实现OAUTH2.0协议和JWT标准的扩展包,名为Laravel-passport,该扩展包将token和Refresh-Token存于数据库。并且安全性等各方面也已经做得比较完善,但是缺点也很明显,复杂的实现逻辑,繁重的trait,还有麻烦的将token记录到数据库(这样无异于session)这些特点,都使得这个扩展包性能变得非常缓慢,并且很明显,扩展性以及对外友好性不强,耦合性太高(只适用于Laravel框架)。因此实现一个自定义的token机制。
3.1基于JWT标准的token定义
根据JWT标准,token被拆分为三部分:头部、载荷、签名。头部中存着实现签名的算法规则,以及实现方式,如下面代码所示:
private statiC $header= array(
'alg'=>'HS256',//生成signature的算法
'typ'=>'JWT'//类型
);
payload主要存着我们需要的数据内容,叫作jwt载荷,需要几个基本的字段,用于在后期的解析中使用,其中包括用户idtoken的颁发时间和过期时间,以及jti(該token的唯一标识)等。payload是token中最主要的部分。下面是定义access_token和refresh_token的payload的实现。
accesstoken:
$data= array(
'iss'=>self::loadConf($iss)['secretuser'],//该JWT的签发者
'iat'=> time(),//签发时间
'exp'=> time()+ $exp,//过期时间
'nbf'=> time0+ 10,//该时间之前不接收处理该Token
'uid'=>$uid.
'type'=>'access',
'refresh_id'=>$refresh_id,
'jti'=> $jti//該token的id);
refresh_token:
$data= array(
'iss'=>self::loadConf($iss)['secret_user'],//该JWT的签发者
'iat'=> time(),//签发时间
'exp'=> time()+ $exp,//过期时间
'nbf'=> time()+ 60,//该时间之前不接收处理该Token
'uid'=>$uid.
'type'=>'refresh',
'jti'=>$jti);
第三部分是签名部分,签名部分是由一个白定义的随机字符串组成的key,由这个key,对前面两部分进行base64编码和HMACSHA256加密形成签名。这一部分是确保token安全性的必须内容。生成token代码:
public static function getToken(array $payload, string $key)
{if(is_array($payload)){
$base64header=self:: base64UrIEncode(json_encode(self::$header, JSON_UNESCAPED_UNICODE));
$base64payload=self:: base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE));
retum $base64header.'.'.$base64payload.'.'.self::signature($base64header.'.'.$base64payload, $key, self'::$header['alg']);
}else{
return false;
}}
当生成token之后,再通过指定的key,将token解析,当校对无误后,解析出完整的载荷内容,当解析正确,中间件将认为这是一个允许访问的请求,则可以进入控制器,执行相应的功能逻辑。为了给不同的用户定制不同的配置,例如管理员to-ken和用户token的secret key肯定不能相同,或者过期时间等配置,所以会独立出来,以使用者为单位进行配置,配置文件如下所示:
/*用户*/
'user'=>[
'secret_key'=>'vhjcUExrBL5q6kWW"',//Str:: random()生成
'secret user'=>'Jasper_User',
'access_token_expire_time'=>7200.//access_token过期时间
'ref'resh_token_expire_time'=>86400.//refresh_token过期时间
],
/*管理员*/
'admin'=>[
'seCret_key'=>'IlzzMTZAX6tycbkM',//Str::random()生成
'seCret user'=>'Jasper_Admin',
'accesstoken_expiretime'=>84600,//access_token过期时间
'refresh_token_expire_time'=>86400.//refresh_token过期时间
],
对前台用户的中间件拦截请求如下面代码所示,对于管理员的拦截也是类似的写法,开发者只需要简单的修改一下使用者的身份并且在配置文件配置好,则可以实现不同的秘钥。这样很好的解决了耦合性的问题,使开发者很方便的就能够使用不同的机制开发其他不同类型的功能。
$token= SerAuth::getFinalToken($Auth);
if (!$token) return response(retumAPI(6000));
$result=SerAuth::verifyToken($token);
if(! $result['token'])
retum resp.nse(returnAPl($result['code']));
$info= $result['payload']['uid'];
$request->payload= $info;
return $next($request);
4基于websocket协议和swoole的消息通知系统实现
系统的实现较为复杂,采用点对多的方式。管理员给用户发送一条消息,消息将会通过某个控制器的方法,调用封装好的发送消息的service方法,将消息发送websocket服务器。该ServiCe作为一个短暂的客户端,将信息转发给webs。cket服务器之后,立即断开,因为该连接不需要进行长连接占用资源,它只负责讲消息发送到服务器。再由已经连接上websocket的前台客户端,接收服务器所发送的消息。
获取信息的内容,并将信息入库。然后利用composer中的websocket建立一个短链接客户端,链接到服务器,将消息通知给对应的用户。当管理员发送消息请求时候,需要将消息的主体入库,以此来达到同步数据的目的。同步数据是为了准确的提高用户了解未读信息的数量。当管理员发起一条消息,消息表入库,未读消息则加l,在客户端的store仓库中,会将用户显示的未读消息数量增1,所以当用户刷新或者退出登录状态时,即使收不到及时信息,但是消息表中依然有记录,等待用户登录的时候,依旧能够看到所有的信息。这就完成了一整套的消息通知流程。具体如下图1所示。
5基于Redis缓存的数据读取,减少直接读取数据库以及对数据库的操作
本系统是基于websocket下的swoole框架来完成业务,配合Mysql数据库的消息表进行消息的存取。其中的一个业务是管理员需要向平台的所有用户推送某一消息,例如商品上线通知等系统消息。那么,如果平台有十万甚至百万个用户,推送的数据量存入消息表就非常大。如果每发送一条消息,消息表都需要增加10万条数据,那么这样对数据库性能和服务器要求无疑是非常大的。因此,我们可以通过缓存来实现全部推送。管理员向所有用户推送信息,只需要在数据库中加入一个字段“is_all",来判断该消息是否是所有用户的消息即可。在查询某个用户的消息时,会有两部分,一是拥有自己的消息的数据,二是带有“is_all”字段的消息。当用户已读消息时,正常消息会修改“is_read”字段标识为已读,而对于“is_all,类型的消息,为了避免其他用户的消息受到影响,则会需要用缓存将每个用户的已读消息存人,在数据查询时忽略这些内容,即可完成已读未读区分。删除功能也是如此。
经过多次的测试发现,这种消息通知入库的方式可行,并可以极大程度的避免了数据库中数据的冗余性,有利于系统高效的运行,减少了频繁入库的操作。
6结束语
论文主要论述了基于白定义token以用户为单位的消息通知系统的设计与实现。重点阐述自定义token机制的实现,包括头部、载荷、签名。实现基于websocket协议和swoole的消息通知流程,以及消息通知入库的方式,提高系统的性能和运行效率,
參考文献:
[1] Vue CLI[EB/OLl.[2019-12-20l.https://cli.vuejs.org/zh/guitle/.
[2] larave16.0中文文档[EB/OL].[2019-12-20].https://leamku.com/docs/laraveU6.0.
[3] Vanessa Wang.HTML5 WebSocket指南2018[M].北京:机械工业出版社,2014: 21-250.
【通联编辑:谢媛媛】
收稿日期:2020-05-08
作者简介:李毓丽( 1980-),女,广东普宁人,副教授,硕士,主要从事网络技术、网络编程方向的教学研究。