WebSocket聊天系统想法与实践1

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;
 }

后记:实在不想写开发技术文档,我想写生活的,理想的,不过没文采,大家也不会不感兴趣,希望这个文档能帮助到需要的人吧。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值