【弄nèng - Springboot】Springboot整合websocket,使用消息队列实现分布式WebSocket

本片以使用为主,概念请移动百度
不做过多解释,备注的很详细
参考:https://www.zifangsky.cn/1364.html

分布式WebSocket一般可以通过以下两种方案来实现:

  1. 将消息(<用户id,消息内容>)统一推送到一个消息队列(Redis、Kafka等)的topic,然后每个应用节点都订阅这个topic,在接收到WebSocket消息后取出这个消息的“消息接收者的用户ID/用户名”,然后再比对自身是否存在相应用户的连接,如果存在则推送消息,否则丢弃接收到的这个消息(这个消息接收者所在的应用节点会处理)
  2. 在用户建立WebSocket连接后,使用Redis缓存记录用户的WebSocket建立在哪个应用节点上,然后同样使用消息队列将消息推送到接收者所在的应用节点上面(实现上比方案一要复杂,但是网络流量会更低)

该DEMO采用第一种方案实现:
在这里插入图片描述

1. pom

		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <!--rabbitmq-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <!--优化编码-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.49</version>
        </dependency>

2. Yml

spring:
  # rabbitmq 配置
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: yang
    password: yang
    publisher-confirms: true #支持发布确认
    publisher-returns: true  #支持发布返回
    listener:
      simple:
        acknowledge-mode: manual #采用手动应答
        concurrency: 1 #指定最小的消费者数量
        max-concurrency: 3 #指定最大的消费者数量
        retry:
          enabled: true #是否支持重试

logging:
  #打印sql
  level:
    com.it.cloud.modules: debug

3. MQ配置类

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.annotation.Resource;

/**
 *  RabbitMQ配置
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:44
 * @description
 */

@Configuration
public class RabbitConfig {

    @Resource
    private RabbitTemplate rabbitTemplate;

    /**
     * 定制化amqp模版      可根据需要定制多个
     * <p>
     * <p>
     * 此处为模版类定义 Jackson消息转换器
     * ConfirmCallback接口用于实现消息发送到RabbitMQ交换器后接收ack回调   即消息发送到exchange  ack
     * ReturnCallback接口用于实现消息发送到RabbitMQ 交换器,但无相应队列与交换器绑定时的回调  即消息发送不到任何一个队列中  ack
     *
     * @return the amqp template
     */
    // @Primary
    @Bean
    public AmqpTemplate amqpTemplate() {
        Logger log = LoggerFactory.getLogger(RabbitTemplate.class);
        // 使用jackson 消息转换器, 传输对象时屏蔽掉,防止二次json转换,多了一个"
        // rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter());
        rabbitTemplate.setEncoding("UTF-8");
        // 消息发送失败返回到队列中,yml需要配置 publisher-returns: true

        // 当mandatory标志位设置为true时,如果exchange根据自身类型和消息routingKey无法找到一个合适的queue存储消息,
        // 那么broker会调用basic.return方法将消息返还给生产者;
        // 当mandatory设置为false时,出现上述情况broker会直接将消息丢弃;
        // 通俗的讲,mandatory标志告诉broker代理服务器至少将消息route到一个队列中,否则就将消息return给发送者
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            String correlationId = message.getMessageProperties().getCorrelationId();
            log.debug("消息:{} 发送失败, 应答码:{} 原因:{} 交换机: {}  路由键: {}", correlationId, replyCode, replyText, exchange, routingKey);
        });
        // 消息确认,yml需要配置 publisher-confirms: true
        // 1.消费者确认 2.exchange没有路由到queue
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (ack) {
                log.debug("消息发送到exchange成功,id: {}", correlationData.getId());
            } else {
                log.debug("消息发送到exchange失败,原因: {}", cause);
            }
        });
        return rabbitTemplate;
    }

    /* ----------------------------------------------------------------------------Direct exchange test--------------------------------------------------------------------------- */

    /**
     * 声明Direct交换机 支持持久化.
     *
     * @return the exchange
     */
    @Bean("directExchange")
    public Exchange directExchange() {
        return ExchangeBuilder.directExchange("DIRECT_EXCHANGE").durable(true).build();
    }

    /**
     * 声明一个队列 支持持久化.
     *
     * @return the queue
     */
    @Bean("directQueue")
    public Queue directQueue() {
        return QueueBuilder.durable("DIRECT_QUEUE").build();
    }

    /**
     * 通过绑定键 将指定队列绑定到一个指定的交换机 .
     *
     * @param queue    the queue
     * @param exchange the exchange
     * @return the binding
     */
    @Bean
    public Binding directBinding(@Qualifier("directQueue") Queue queue,
                                 @Qualifier("directExchange") Exchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with("DIRECT_ROUTING_KEY").noargs();
    }

    /* ----------------------------------------------------------------------------Topic exchange test--------------------------------------------------------------------------- */

    /**
     * 声明 topic 交换机.
     *
     * @return the exchange
     */
    @Bean("topicExchange")
    public TopicExchange topicExchange() {
        return (TopicExchange) ExchangeBuilder.topicExchange("TOPIC_EXCHANGE").durable(true).build();
    }

    /**
     * Fanout queue A.
     *
     * @return the queue
     */
    @Bean("topicQueueA")
    public Queue topicQueueA() {
        return QueueBuilder.durable("TOPIC_QUEUE_A").build();
    }


    /**
     * 绑定队列A 到Topic 交换机.
     *
     * @param queue          the queue
     * @param topicExchange the topic exchange
     * @return the binding
     */
    @Bean
    public Binding topicBinding(@Qualifier("topicQueueA") Queue queue,
                            @Qualifier("topicExchange") TopicExchange topicExchange) {
        return BindingBuilder.bind(queue).to(topicExchange).with("TOPIC.ROUTE.KEY.*");
    }

    /* ----------------------------------------------------------------------------Fanout exchange test--------------------------------------------------------------------------- */

    /**
     * 声明 fanout 交换机.
     *
     * @return the exchange
     */
    @Bean("fanoutExchange")
    public FanoutExchange fanoutExchange() {
        return (FanoutExchange) ExchangeBuilder.fanoutExchange("FANOUT_EXCHANGE").durable(true).build();
    }

    /**
     * Fanout queue A.
     *
     * @return the queue
     */
    @Bean("fanoutQueueA")
    public Queue fanoutQueueA() {
        return QueueBuilder.durable("FANOUT_QUEUE_A").build();
    }

    /**
     * Fanout queue B .
     *
     * @return the queue
     */
    @Bean("fanoutQueueB")
    public Queue fanoutQueueB() {
        return QueueBuilder.durable("FANOUT_QUEUE_B").build();
    }

    /**
     * 绑定队列A 到Fanout 交换机.
     *
     * @param queue          the queue
     * @param fanoutExchange the fanout exchange
     * @return the binding
     */
    @Bean
    public Binding bindingA(@Qualifier("fanoutQueueA") Queue queue,
                            @Qualifier("fanoutExchange") FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queue).to(fanoutExchange);
    }

    /**
     * 绑定队列B 到Fanout 交换机.
     *
     * @param queue          the queue
     * @param fanoutExchange the fanout exchange
     * @return the binding
     */
    @Bean
    public Binding bindingB(@Qualifier("fanoutQueueB") Queue queue,
                            @Qualifier("fanoutExchange") FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queue).to(fanoutExchange);
    }
}

4. Websocket配置类

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * 开启WebSocket支持
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:44
 * @description
 */
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

5. 服务端

WebSocketServer.java

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * websocket服务端
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:46
 */
@ServerEndpoint("/websocket/{userId}")
@Component
@Slf4j
public class WebSocketServer {

    // 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
    private static int onlineCount = 0;
    // concurrent包的线程安全Map,用来存放每个客户端对应的MyWebSocket对象。
    private static ConcurrentHashMap<String, WebSocketServer> websocketMap = new ConcurrentHashMap<>();
    // 与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session session;

    // 接收sid
    private String userId = "";

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId) {
        this.session = session;
        websocketMap.put(userId, this);
        log.info("websocketMap->" + JSON.toJSONString(websocketMap));
        // webSocketSet.add(this); //加入set中
        addOnlineCount(); // 在线数加1
        log.info("有新窗口开始监听:" + userId + ",当前在线连接数为" + getOnlineCount());
        this.userId = userId;
        try {
            sendMessage("连接成功");
        } catch (IOException e) {
            log.error("websocket IO异常");
        }
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        if (websocketMap.get(this.userId) != null) {
            websocketMap.remove(this.userId);
            // webSocketSet.remove(this); //从set中删除
            subOnlineCount(); // 在线数减1
            log.info("有一连接关闭!当前在线连接数为" + getOnlineCount());
        }
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("收到来自窗口" + userId + "的信息:" + message);
        if (StringUtils.isNotBlank(message)) {
            JSONArray list = JSONArray.parseArray(message);
            for (int i = 0; i < list.size(); i++) {
                try {
                    // 解析发送的报文
                    JSONObject object = list.getJSONObject(i);
                    String toUserId = object.getString("toUserId");
                    String contentText = object.getString("contentText");
                    object.put("fromUserId", this.userId);
                    // 传送给对应用户的websocket
                    if (StringUtils.isNotBlank(toUserId) && StringUtils.isNotBlank(contentText)) {
                        WebSocketServer socketx = websocketMap.get(toUserId);
                        // 需要进行转换,userId
                        if (socketx != null) {
                            socketx.sendMessage(JSON.toJSONString(object));
                            // 此处可以放置相关业务代码,例如存储到数据库
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("发生错误");
        error.printStackTrace();
    }

    /**
     * 实现服务器主动推送
     */
    public void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }

    /**
     * 单发自定义消息
     */
    public static void sendInfo(String message, @PathParam("userId") String userId) throws IOException {
        log.info("推送消息到窗口" + userId + ",推送内容:" + message);
        // 可以通过SpringContextUtils得到bean,进行数据库操作
        WebSocketServer webSocketServer = websocketMap.get(userId);
        if (webSocketServer != null) {
            webSocketServer.sendMessage(message);
        }
    }

    /**
     * 群发自定义消息
     */
    public static void sendInfoAll(String message) throws IOException {
        log.info("推送消息到所有窗口,推送内容:" + message);

        for (Map.Entry<String, WebSocketServer> entry : websocketMap.entrySet()) {
            WebSocketServer item = entry.getValue();
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                continue;
            }
        }
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        WebSocketServer.onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        WebSocketServer.onlineCount--;
    }

    public static ConcurrentHashMap<String, WebSocketServer> getSessionmap() {
        return WebSocketServer.websocketMap;
    }

}

6. 控制器,发送消息

SocketController.java

import cn.hutool.json.JSONUtil;
import com.it.cloud.common.base.Result;
import com.it.cloud.modules.rabbitmq.producer.MqService;
import com.it.cloud.modules.websocket.WebSocketServer;
import com.it.cloud.modules.websocket.dto.SocketMessageDTO;
import org.apache.commons.lang.SerializationUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;

/**
 * wbsocket 推送
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:44
 * @description
 */

@RestController
@RequestMapping("/socket")
public class SocketController {

    @Autowired
    private MqService mqService;

    // 跳转页面
/*    @GetMapping("/{userId}")
    public ModelAndView socket(@PathVariable String userId) {
        ModelAndView mav=new ModelAndView("/websocket");
        mav.addObject("userId", userId);
        return mav;
    }*/
    //推送数据接口
    @ResponseBody
    @RequestMapping("/push/{userId}")
    public Result pushToWeb(@PathVariable String userId, String message) {
        SocketMessageDTO dto = new SocketMessageDTO();
        dto.setUserId(userId);
        dto.setMessage(message);

        // 发送到消息队列,广播模式
        mqService.fanout(JSONUtil.toJsonStr(dto));
        /*try {
            WebSocketServer.sendInfo(message,userId);
        } catch (IOException e) {
            e.printStackTrace();
            return Result.error(userId+"#"+e.getMessage());
        }*/
        return Result.ok(userId);
    }
}

7. 消息实体类

SocketMessageDTO.java

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;

/**
 * @author yangyang7_kzx
 * @date 2019/7/29 17:35
 * @description socket消息
 */

@ApiModel(value = "Socket消息体", description = "Socket消息体")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SocketMessageDTO implements Serializable {

    @ApiModelProperty(value = "socket userId")
    private String userId;

    @ApiModelProperty(value = "消息")
    private String message;
}

8. 生产者

MqService.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.UUID;

/**
 * 消息发送服务
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:44
 * @description
 */

@Slf4j
@Service
public class MqService {

    @Resource
    private RabbitTemplate rabbitTemplate;

    /**
     * 测试广播模式.
     *
     * @param message
     * @return the response entity
     */
    public void fanout(String message) {
        CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
        rabbitTemplate.convertAndSend("FANOUT_EXCHANGE", "", message, correlationData);
    }

    /**
     * 测试Direct模式.
     *
     * @param p the p
     * @return the response entity
     */
//    public void direct(String p) {
//        CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
//        rabbitTemplate.convertAndSend("DIRECT_EXCHANGE", "DIRECT_ROUTING_KEY", p, correlationData);
//    }

}

9. 消费者

MqService.java

import cn.hutool.json.JSONUtil;
import com.it.cloud.common.base.Result;
import com.it.cloud.modules.websocket.WebSocketServer;
import com.it.cloud.modules.websocket.dto.SocketMessageDTO;
import com.rabbitmq.client.Channel;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;

/**
 * 消息监听器
 *
 * @author 司马缸砸缸了
 * @date 2019/7/29 13:44
 * @description
 */

@Component
public class MqConsumer {
    private static final Logger log = LoggerFactory.getLogger(MqConsumer.class);

    /**
     * FANOUT广播队列监听一.
     *
     * @param message the message
     * @param channel the channel
     * @throws IOException the io exception  这里异常需要处理
     */
    @RabbitListener(queues = {"FANOUT_QUEUE_A"})
    public void on(Message message, Channel channel) throws IOException {
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), true);
        String responsJson = new String(message.getBody());
        log.debug("consumer FANOUT_QUEUE_A : " + responsJson);

        //发送给浏览器
        if (StringUtils.isNotBlank(responsJson)) {
            SocketMessageDTO dto = JSONUtil.toBean(responsJson, SocketMessageDTO.class);
            String userId = dto.getUserId();
            String msg = dto.getMessage();

            if (WebSocketServer.getSessionmap().get(userId) != null) {
                try {
                	//推送消息到页面
                    WebSocketServer.sendInfo(msg, userId);
                } catch (IOException e) {
                    e.printStackTrace();
                    log.error("socket send error,userId=" + userId + "#" + e.getMessage());
                }
            }
        }
    }

    /**
     * FANOUT广播队列监听二.
     *
     * @param message the message
     * @param channel the channel
     * @throws IOException the io exception   这里异常需要处理
     */
    @RabbitListener(queues = {"FANOUT_QUEUE_B"})
    public void t(Message message, Channel channel) throws IOException {
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), true);
        log.debug("FANOUT_QUEUE_B " + new String(message.getBody()));
    }

    /**
     * DIRECT模式.
     *
     * @param message the message
     * @param channel the channel
     * @throws IOException the io exception  这里异常需要处理
     */
    @RabbitListener(queues = {"DIRECT_QUEUE"})
    public void message(Message message, Channel channel) throws IOException {
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), true);
        log.debug("DIRECT " + new String(message.getBody()));
    }
}

10. 页面

<script>
export default {
  data () {
    return {
      path: 'ws://localhost:8081/websocket/20',
      socket: ''
    }
  },
  mounted () {
    // 初始化
    this.init()
  },
  methods: {
    init: function () {
      if (typeof WebSocket === 'undefined') {
        alert('您的浏览器不支持socket')
      } else {
        // 实例化socket
        this.socket = new WebSocket(this.path)
        // 监听socket连接
        this.socket.onopen = this.open
        // 监听socket错误信息
        this.socket.onerror = this.error
        // 监听socket消息
        this.socket.onmessage = this.getMessage
      }
    },
    open: function () {
      console.log('socket连接成功')
    },
    error: function () {
      console.log('连接错误')
    },
    getMessage: function (msg) {
      console.log(msg.data)
    },
    send: function () {
      this.socket.send('哈哈')
    },
    close: function () {
      console.log('socket已经关闭')
    }
  },
  destroyed () {
    // 销毁监听
    this.socket.onclose = this.close
  }
}
</script>

11. 测试

浏览器访问:http://localhost:8081/socket/push/20?message=aaaaaaaaaaa
之后在前端控制台Console中查看消息


源码地址

稍后更新,需要请联系
开源项目,持续不断更新中,喜欢请 Star~

项目推荐

IT-CLOUD :IT服务管理平台,集成基础服务,中间件服务,监控告警服务等

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值