WebSocket聊天系统想法与实践1
1.简介
上面需求做一套客服系统,能跟用户进行交互,实现即时聊天的功能,想想都很麻烦,但是很不幸任务落到我头上,简单来说就是相当于微信加京东客服的简化版本,把每个用户的咨询当作一个订单,客服方面可以进行抢单,应答,处理不了时转接给别的客服,处理完成可以结束订单,这个客服系统连页面都是仿照微信的聊天界面设计的,很坑啊有没有,我没做过这种聊天系统,收到需求时先是兴奋,因为做一个没做过的系统很有吸引力、挑战性,几乎所有程序猿都是这样吧。但随着各种坑接踵而至,整个人都不高兴了,产品提的需求也是一点点的堆上的,不是一开始就是完整的
2.WebSocket介绍
网上查了些资料,都是很简单的例子,首先WebSocket是一个持久化Tcp协议,双工通信啊,握手是通过Http协议完成的,但通过查看websocket请求头信息发现了与http不同的地方,多了几个参数,发起的方式也是Get,那传参岂不是很麻烦,通过查资料发现了他的核心参数在于Connection和Upgrade,这两个参数告诉服务器自己是websocket请求,跟http是不同的,要保持连接不断开的,而且后面配置的Nginx代理服务器的时候也要加上这个,不加的话nginx就把websocket请求转发成了普通http请求
- Connection: Upgrade
- Upgrade: websocket
- Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
- Sec-WebSocket-Key: NJFEq3ruKfdasRgeQt3Gw==
- Sec-WebSocket-Protocol: WeChat,tset
- Sec-WebSocket-Version: 13
3.服务端设计
服务器端是不能主动去连接WebSocket的,只能由客户端发起,就算是因为各种问题断了连接,这个也只能由客户端发起连接再次建立的请求,网上很多人吐槽,不过其实蛮合理的,要不你的个人设备被其他服务器连接也是很恐怖的。这里我写的后端是用@ServerEndpoint注解方式开发的,因为简单,但个人觉得不甚灵活
做聊天系统很重要的一点是数据存储记录,数据结构设计,建议用非结构数据库存贮
这里用的是mongoDB,嵌套子查询用起来还不错,都是新接触的,直接存简单对象数组都可以
sessionid:会话编号
userId:客户ID
customerId:客服Id
createDate:创建时间
userType:客户类型 这里可以区分下客户来源比如男女、年龄段之类的
transferId:转接人ID
artificialType:人工服务状态 用它来区分各种时段:开启、服务中、转接发起、转接中、发起结束、结束等等
User userInfo:用户信息
Customer customerInfo:客服信息
messageid:消息编号
message:消息体
sessionId:会话Id,与上面的数据结构关联,多对一的关系
senderId:消息发送人ID
msgType:消息类型 文本、语音、图片等
senderType:发送人类型 客户、客服、系统其他
userReade咨询人读消息
String[] customerReadeId:客服读消息 这里建议做数组,多个客服同时为一个用户服务时可做区分
createDate:创建时间
instructions:操作指令,这个用处很大,可以由他来区分websocket消息的命令是什么,比如是发普通消息还是心跳确认或者读消息等等
代码是简化过的,业务不能贴,大家懂得
@ServerEndpoint(value = "/websocket/{sessionId}/{connectionId}", configurator= InterceptorSocket.class)
@Component
public class MessageWebSocket {
private static final Logger log = LoggerFactory.getLogger(MessageWebSocket.class);
//客服应该不会太多,就这样吧
protected static ConcurrentHashMap<String, Session> webSocketCustomerMap = new ConcurrentHashMap<>(100);
protected static ConcurrentHashMap<String, Session> webSocketUserMap = new ConcurrentHashMap<>(1000);
//对象不能直接引入,通过静态注入
protected static SocketMessageTask socketMessageTask;
@Autowired
public void setSocketMessageTaskBean(SocketMessageTask socketMessageTask){
this.socketMessageTask = socketMessageTask;
}
/**
* @Return void
* @Description 建立连接
*/
@OnOpen
public void onOpen(Session session,
@PathParam("sessionId") String sessionId,
@PathParam("connectionId") String connectionId,) {
log.info("StartTime = " + (new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date())));
String param = session.getQueryString();
if(StringUtil.isBlank(param)){
throw new NullPointerException("发送者类型不能为空");
}
try {
Integer senderType = Integer.valueOf(param);
if(senderType.equals(CustomerEnum.USER)){
webSocketUserMap.put(connectionId, session); //存储用户socketSessio
}else if(senderType.equals(CustomerEnum.CUSTOMER)){
webSocketCustomerMap.put(connectionId, session); //存储客服的socketSession
}else{
throw new ParameterFailException("类型错误");
}
} catch (Exception e) {
log.error("WebSocket 异常");
e.printStackTrace();
}
}
/**
* @Return void
* @Description 连接关闭,清理Session
*/
@OnClose
public void onClose(Session session) {
System.out.println("CloseTime = " + (new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date())));
Map<String, String> pathParameters = session.getPathParameters();
String connectionId = pathParameters.get("connectionId");
String param = session.getQueryString();
if(StringUtil.isBlank(param)){
throw new NullPointerException("发送者类型不能为空");
}
Integer senderType = Integer.valueOf(param);
if(senderType.equals(CustomerEnum.USER)){
webSocketUserMap.remove(connectionId); //存储用户socketSessio
}else if(senderType.equals(CustomerEnum.CUSTOMER)){
webSocketCustomerMap.remove(connectionId); //存储客服的socketSession
}else{
throw new ParameterFailException("类型错误");
}
}
@OnMessage
public void onMessage(String message, Session session) throws IOException {
log.info("message = {}" + message);
if(StringUtil.isBlank(message)){
throw new NullPointerException("message is null");
}
socketMessageTask.execute(message); //通过业务方法处理发送过来的消息
}
@OnError
public void onError(Session session, Throwable error) {
System.out.println("ErrorTime = " + (new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date())));
Map<String, String> pathParameters = session.getPathParameters();
String connectionId = pathParameters.get("connectionId");
String param = session.getQueryString();
if(StringUtil.isBlank(param)){
throw new NullPointerException("发送者类型不能为空");
}
Integer senderType = Integer.valueOf(param);
if(senderType.equals(CustomerEnum.USER)){
webSocketUserMap.remove(connectionId); //存储用户socketSessio
}else if(senderType.equals(CustomerEnum.CUSTOMER)){
webSocketCustomerMap.remove(connectionId); //存储客服的socketSession
}else{
throw new ParameterFailException("类型错误");
}
}
}
后端服务完成后需要做一步校验,控制能连接websocket的人,也就是token校验,经过多方面调查,发现websocket是不走http拦截器的,之前系统写的拦截器就没用了,只能做一个其他的校验方式,因为是用注解的方式开发的websocket,所以HttpSessionHandshakeInterceptor拦截器也是无效的,网上找了个例子亲测无效,所以只能modifyHandshake这种方式实现一下
//无效拦截器
public class HandshakeInterceptor extends HttpSessionHandshakeInterceptor {
public static final String HTTP_SESSION_KEY_MMY = "HTTP_SESSION_KEY_MMY";
// 握手前
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
System.out.println("++++++++++++++++ HandshakeInterceptor: beforeHandshake ++++++++++++++"+attributes);
return super.beforeHandshake(request, response, wsHandler, attributes);
}
// 握手后
@Override
public void afterHandshake(ServerHttpRequest request,
ServerHttpResponse response, WebSocketHandler wsHandler,
Exception ex) {
System.out.println("++++++++++++++++ HandshakeInterceptor: afterHandshake ++++++++++++++");
super.afterHandshake(request, response, wsHandler, ex);
}
}
连接载入Handshake时进行token校验
@Component
public class InterceptorSocket extends ServerEndpointConfig.Configurator implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
context = applicationContext;
}
public static <T> T getBean(Class<T> tClass){
if(null==context){
return null;
}
return context.getBean(tClass);
}
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
log.info("check socket token start");
List<String> headerParam = request.getHeaders().get(request.SEC_WEBSOCKET_PROTOCOL);
if (headerParam == null || headerParam.isEmpty()){
throw new CheckParamException("socket token is null");
}
if(headerParam.size() == 1){
String token = headerParam.get(0);
log.info("token = {}" + token);
String redisToken = getBean(RedisStringUtil.class).get(token).block();
String param = request.getQueryString();
if (StringUtil.isBlank(redisToken)) {
log.info("param = {} , token = {}",param,token);
throw new CheckParamException("socket token expired");
}
if(StringUtil.isBlank(param)){
throw new NullPointerException("sender type is null");
}
Integer senderType = Integer.valueOf(param);
boolean identityCheck = getIdentityCheck(redisToken, senderType);
if(!identityCheck){
throw new UnsupportedOperationException("socket token verification failed");
}
}else{
throw new CheckParamException("check param error,It`s too long");
}
response.getHeaders().put(request.SEC_WEBSOCKET_PROTOCOL, headerParam);
}
private boolean getIdentityCheck(String id, Integer type){
try {
if (type.equals(CustomerEnum.SenderType.CUSTOMER.type())) {
Customer customer = getBean(Customer.class).findOne(id);
if (customer == null)
return false;
} else if (Integer.valueOf(type%2).equals(CustomerEnum.SenderType.CONSULTANT.type())) {
User user = getBean(User.class).finByOne(id);
if (user == null)
return false;
}else{
return false;
}
}catch (Exception e){
return false;
}
return true;
}
}
至此,一套简单的聊天系统架子出现,现在就可以进行业务逻辑、边角等开始编写,还有一点就是某天产品突然提出了我们要角标消息,就是微信的那个聊天列表有人给你发消息显示的红色圆点,上面显示有几条未读的,那之前预留的userReade、customerReadeId就能派上用场了,通过这两个分别记录用户未读数和客服未读数量
Criteria criteria = new Criteria();
Criteria joinCriteria = new Criteria();
joinCriteria.and("customerSession.artificialType").in(new Integer[]{
CustomerEnum.ArtificialType.START_SERVER.type(),
CustomerEnum.ArtificialType.IN_SERVER.type(),
CustomerEnum.ArtificialType.TRANSFER_SERVER.type()});
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.lookup(
"SessionTable",
"sessionId",
"_id",
"sessionInfo"),
Aggregation.match(criteria),
Aggregation.match(joinCriteria),
Aggregation.group("sessionId").count().as("msgCount")
.sum("userReade").as("userReadeCount")
);
List<Message> messageCount = mongoUtil.getTemplate().aggregate(aggregation, "Message", Message.class);
4:服务器Nginx配置
websocket请求经过nginx会被转发,如果不进行配置的话会转发成http请求,所以需要我们自己手动配置一下转发的请求头信息
location /websocket/ {
proxy_pass http://foreign_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 7200s;
}
后记:实在不想写开发技术文档,我想写生活的,理想的,不过没文采,大家也不会不感兴趣,希望这个文档能帮助到需要的人吧。