通过nodejs搭建客服系统还有闲谈

  张一帆   01/25/17


本文所述的游云客服系统,网址为cs.51kupai.com
前端程序代码customService
后端程序代码customWeb

  从12月开始,公司要我们为宝库搭建一套客服系统。本来是做通讯的我们,因为自身业务单一,技术经验少,推广比不过竞品,再加上本身通讯行业的技术壁垒较高,导致我们的产品战略从年初计划的对外产品慢慢转向了对内的支持服务。后来,公司专一重点项目后,我们也即将成为一个转型部门。在这一节骨眼上我们渴望获得一次机会进行证明,同时也期望能够在2016年底给我们自己做一个收官。经过看起来不是很顺利的沟通后,宝库同意将对客服的需求交托于我们部门做,所以这也是我们能够称的上是救命稻草的一线希望。

  在年中我们在运营着自己的sdk,这个sdk是给开发人员对接移动端push需求而开发的。除了push功能以外,我们也开发着voip以及聊天室等项目。游云网站是我们呈现给开发者的接入网站,开发者在接入的时候可以在这个网站中看到各种定制化的推送服务。游云网站是由我一人开发起来的,虽然与竞品网站比起来有些小巫见大巫,但是也是充满着对通讯行业的一些窥视。后因工作需要,我们需要给游云接入一个web版的demo,望借以此呈现个开发者用户一个更加直观的体验,所以后来就有了web游云这个功能,这个网站是利用cometd技术做的,因为公司前身是当年在新浪红极一时的项目-微米。到现在我还记得,当年在新浪的时候上级压下来的任务就是每天都要登录微米发信息,而且那年年会上还专门有个项目奖颁发给了微米,可谁又能够想到微米团队内部出现分歧,技术团队叛变,造成彭极其尴尬,最终死的很惨。话说回来,自从工作以来已经两次接触了cometd这个玩意了。

  记得第一次接触这玩意的时候自己还是个初出茅庐的小伙子,那个时候正好要对看游戏进行大改版。后来,研究了半天最终也没搞清楚个大概,只能沿着老人们的代码一点一点调试,现在回忆起来那个时候的调试方法极其笨拙,再加上自己对项目的理解很差,所以虽然顺利改完版了,但是对自己的成长真的没有太多,只能说自己知道这个是什么玩意了。现在时间已经过去了4年了,再来看cometd多少还是有些抵触的。可是,项目压下来自己也没有办法,而且这次后端的网关还是用java写的,这让我又整了套java环境,两套环境在同一台电脑上跑,真是太费劲了。最终,还是搭起来了这个网站,也能够实现一些基本的简单功能了,可是后来由于本部门的java团队忙于应付宝库的需求,最终不能提供给我及时的响应,所以也就暂时搁浅了。

  随着公司大战略的转移,慢慢的我们通讯部门就慢慢的变的越来越不重要了。再加上一年来项目给外面项目对接的缓慢并且总是不能正常使用让决策层变得越发的艰难了。最终,公司要求我们部门从产品线转化为支持性部门,将功能给上海总公司提供支持。也就是说领导们不期望这个项目可以去赚钱了,但是又不干做了那么久了,那么就公司内部的产品谁需要通信功能,那么我们部门就跟他们进行对接。可想而知,这样做的时间越长项目的立锥之地就越少。由于我是部门的一个开发工程师,抱着不在其位不谋其政的态度静观其变。后来,大概是16年的11月份,部门争取到了宝库的客服需求。我们就做起客服系统来了。

  起先,我们人员安排上比较薄弱。几个java负责服务端,想要将原来cometd转变成websocket。另外,我们部门的C老大也一直在帮助java们整理开发服务功能,所以必定会偏向java。而作为客服是需要一套前端框架的,这个框架是需要js的,而我从事于php,对js当然要比他们多,索性就安排我来开发js了。

  起初,在选择方案上我就非常头疼。js的页面级应用对我来说并不是很难,我一个人完全应付的来。但是,对已一套客服系统来说,我开始是不知所措的。于是,我就一边去研究websocket功能的同时一边去github上找客服功能的源代码。在github上更多的是聊天室功能,我费了半天劲将github上的一个个开源程序clone到本机并运行起来却发现功能要不是很简单,要不就是从需求上就不能满足。这两我很头疼,但是最终我还是找到了一个带着聊天框且支持多聊天窗口的开源程序。搭建起来是这个样子的:

chatPi

  通过阅读作者的代码发现这个程序是通过redis来进行聊天内容的同步的。也就是redis为每一个接入的用户增加一个id为键的堆栈,内容就是用户的聊天内容,说话的对象以及时间。页面渲染的数据是通过nodejs服务端的逻辑发送到websocket上,再由页面的js渲染出来。这样其实已经满足了我的基本功能了,只是实现方式不太一样。我需要做的就是将redis的功能去除,而发送消息的地方改成向nodejs中的socket.io发送即可。至于后续逻辑就在nodejs服务端中去补充即可。

  但是,后来java也直接找到了一个开源的websocket框架并且搭起来了,与其让我一个人写前端+后端还不如我把后端撇给java做,毕竟java是两个人负责。于是,我这里只需要做前端的html+js即可。虽然我只负责了html+js,但是我还是留着nodejs服务端的功能,运行这个项目还是需要启动nodejs服务的,原因当然是为了今后的数据我自己也可以处理,这样更加安全且方便。

  慢慢地,产品设计的需求也一步一步的满足。比如,上传图片、获取历史消息等。然而,页面还停留在这种乱乱的界面呢。为了达到更进一步,我利用bootstrap重新做了一套页面,套上以后确实感觉很nice。

chatafte

  好了,基础的页面也有了,剩下的就是完成各种需求了。websocket有几种基础行为:onopen,onclose,onmessage,onerror。当websocket连接成功的时候会进入onopen,断开进入onclose,有消息发送过来进入onmessage。具体收到消息是什么消息以及之后的逻辑是什么都需要在onmessage中完成。我和java约定数据格式大概是这样子的:

{
    "type": "action", //可触发事件的,param中参数原样返回
    "content": "是什么材质的?",
    "params": {
        "to": "robot"
        "id": 1,
        "groupId": 1,
        "prio": 1, // 排序 priority
        "level": 1,
        "createTime": 1480931117000
    }
}

  我们可以通过type来区分服务端发过来的是何种消息,也就可以实现比如聊天消息,通知消息,心跳消息等的区分了。至于onmessage中是怎么写完全可以凭自己公司的需求去写了。

  onmessage中我是通过obj(是java传给我的数据)中的type值(需要json解析出数据)来区分消息的类型,比如用户进入(entercs)、用户推出(leavecs)、消息(message,其中具体是图片消息还是文字消息之类的可以通过content中的type字段来区分)、剔除用户(kill_user,这个就是我们产品要求的逻辑了,也是个性化的消息类型了)等等。可以看到我在接收到message的时候调用了directive.receive(data)方法,这个方法最终的执行逻辑是这样的:

  首先,响提示音。然后需要判断当前发送过来消息的人是不是我打开当前窗口的人,如果不是就需要将用户列表增加一条未读消息的提示数字,之后不论是否都将信息添加到发送人的窗口中。

  那什么叫做发送人的窗口呢?我们可以这样理解,每个发送人都要有个存储聊天内容的页面,因为你现在聊天中的用户时可以直接看到他的消息。然而,那些已经接入了但是又不是你当前聊天的对象的信息也需要记录下来的,当我们切换聊天窗口时我们是需要看到他之前留下的信息的。这个功能起初git上的开源程序没有考虑到,所以我需要增加这块功能。

  大体思想是这样的,我用html写一个div的聊天窗口框架出来,每当一个用户接入的时候,我都将这个div里面的内容clone给一个变量,然后再声明一个MAP(其实就是在内存中开辟一个空间用来做hash存储),用这个用户的id为键,clone的变量作为值保存在其中。这样我就能够随时通过map键的方法将用户聊天窗口存起来了,当用户有信息过来的时候我将这个变量取出来,再往html中的最底部的dom标签前增加信息内容,最后再存入map中。这样我们就可以实现页面级的切换窗口查看消息内容的需求。

  那怎么记录当前聊天的用户id呢?当然,是声明个全局的js变量喽,每次切换聊天的用户id时,就将这个id替换到这个变量中。除了这个当前聊天用户的id的变量以外,还需要两个全局变量:一个是用来存储我们登录的客服id的变量,一个是用来存储已经接入用户的id的变量,这个变量可以作为记录未读数,当你切换到这个用户聊天时将未读数清零。

QQ20170126-130958@2x

  当用户接入进来的时候,页面上就应该有响应的动作,那就是用户列表中就会增加这个新接入用户的提示,就如同上图中的用户2,用户3所示。在js上我们可以采取用jquery来写的方法,比如在发现有用户接入时我们append到用户1的html下方。但是,这样比较麻烦,因为我们需要去专门写这个方法。就因为我觉得麻烦所以我想找一个能不能有个能够相对动态的方法呢?

  最终我找到angularJs。angularJS是一个能够进行双向绑定的js框架,只要你在js中声明了哪个html需要进行监听,angularJS就会自动的帮你完成双向绑定功能,也即是说他会自动的添加新用户接入的提示。下面这段代码就是将用户列表这一块的html作为angularJs的侦听对象,之后再绑定一个click的方法,用来更新js中变量记录的数值以及将用户对应的聊天框显示出来。

  在上面的代码中还有一些更新localstorage的代码,这块代码的用途我也需要说明一下啊。由于业务产品的需求,当用户退出时不会自动在客服系统中退出,只有客服人员能够关闭客户的接入(现在的客服产品,商家都是要求客服不能主动停止会话的,但是我们的产品说是因为我们的需求方的特殊需求,所以只能这样了)。

  那么问题来了,js是页面的语言,一旦刷新以前存在页面js中的信息皆会不复存在,那么我们如何存储曾经接入又没有被客服关闭的会话呢?因为html5出现以后,现在的浏览器也都支持了本地存储功能,也就是localstorage。localstorage是根据域名来保存的键值对,value只支持字符串。这正好可以帮我们实现数据的存储,于是我将用户的信息都通过key来存储到了这里。只是,在保存和查询的动作上需要进行json字符串的字符化和对象化。

  很多功能我们不光需要自己写,更多的可以去git上去搜索已经很有价值的插件。比如上传文件的组件,查看图片的缩略图的功能。我就是用的https://github.com/jfeldstein/jQuery.AjaxFileUpload.js用来做文件上传,viewer用来做图片的缩略图。后来,我们又添加了获取历史记录的功能,我们想要实现qq这样的当滚动条到达最顶部的时候会拉出历史记录并且滚动条还原到按照增加的记录长度的比例的位置。我也在git上找了一些这样的下拉触发事件的插件,但是没有满意的,于是我就自己写了这段代码。

  当然,我们的系统设计的还不是很好。随着代码越写越多,我越发的觉得当时设计的架构非常差,我要是想改一地方,有很多地方都会牵扯到,所以就不是改一个地方这么简单了。我现在需要的是提高自己的js能力,去学习js的框架知识,应该马上对这个系统进行代码重构。

  另外,我刚开始说过,我采用的是nodejs,但是服务端我并没有用。这就造成了一个问题,当我需要调用java接口的时候,由于java接口的域名和客服系统的域名不一样,这样就遇到了跨域问题。

  跨域(CORS)是现在的浏览器都会禁止的操作,所以我需要java在出接口的时候,返回给我的json串外还需要帮我增加一个嵌套,如jsoncallback[‘java返回的json’]。这个设计是不好的,虽然能够暂时符合目前的需求,但是终归还是需要经过nodejs的服务层一趟的,因为安全以及敏捷。在后来的工作中发现了一个比这个方法更加优秀的方案,那就是让服务端的nginx配置add_header模块即可允许信任的域名跨域请求。这样一来就不需要java在每个接口中增加jsoncallback了。

  最后,我还要说一下。我这个项目用到了2个方便开发和管理的工具。一个是pm2,一个是webpack。具体两个是干吗用的,我大概说一下。

  pm2是一个可视化的nodejs进程管理工具,他可以帮助你实时的更新应用服务,这就给你省去了每次更改nodejs代码时总需要频繁的重启node服务(–watch)。

  由于我们的项目是运行在线上的,所以环境也有几套。当我更改代码的时候我关心的是代码如何书写,并不关心环境。所以,我还需要动态的去区分代码所处的服务器环境,哪个是测试环境,哪个是生产环境。那么,我通过官网的手册,找到了pm2是可以通过参数来区分服务器环境的。我们只需要声明一个配置文件ecosystem.config.js。在这里面定义参数所对应的环境,那么在我们启动pm2的时候我们只需要将代表当前环境的参数输入即可,这些小工具能够帮助自己更加方便的进行开发以及维护。

  webpack是一个js代码整合器,它主要的功能就是通过js的互相引用,他可以智能的侦测到所用到的js并且可以将所有的js整合到一个文件中,便于开发者管理。