Loading

[01] WebSocket

1. 案例:实时推送

创建和部署 WebSocket 端点的过程如下:

  1. 创建一个端点类,实现端点的生命周期方法
  2. 将 ServerEndpointExporter 以 @Bean 的形式告知 Spring

1.1 @ServerEndpoint

在一个普通的 Java 类上添加 @ServerEndpoint 注解,即指定该类作为 WebSocket 服务器端点处理客户端的连接请求。

@ServerEndpoint("/websocket") 指定该类为 WebSocket 服务器端点,客户端可以通过 /websocket 地址连接到该端点。

生命周期方法:

  • onOpen 连接建立成功时触发的方法
  • onClose 连接关闭时触发的方法
  • onError 连接出错时触发的方法
  • onMessage 收到客户端消息时触发的方法
@Slf4j
@Component
@ServerEndpoint("/websocket")
public class WebSocket {

  /**
   * 与某个客户端的连接会话,需要通过它来给客户端发送数据
   */
  private Session session;
  
  /**
   * 客户端建立websocket连接,获取URL参数
   */
  private String param;

  @OnOpen
  public void onOpen(@PathParam("param") String param, Session session) {
    long maxIdleTimeout = session.getMaxIdleTimeout();
    int maxBinaryMessageBufferSize = session.getMaxBinaryMessageBufferSize();
    int maxTextMessageBufferSize = session.getMaxTextMessageBufferSize();
    JSONObject jsonObject = new JSONObject();
    jsonObject.put("maxIdleTimeout", maxIdleTimeout);
    jsonObject.put("maxBinaryMessageBufferSize", maxBinaryMessageBufferSize);
    jsonObject.put("maxTextMessageBufferSize", maxTextMessageBufferSize);
    logger.info("sessionId:" + session.getId() + ",session:" + jsonObject.toJSONString());
    session.setMaxIdleTimeout(1000 * 60 * 60 * 24);
    this.session = session;
    this.param = param;
    if (session.isOpen()) {
      // -------------------------------------------------------
      MessagePushService.addWebSocket(session.getId(), this);
      // -------------------------------------------------------
      addOnlineUsers();
      JSONObject sessionInfo = new JSONObject();
      sessionInfo.put("type", "session");
      sessionInfo.put("sessionid", session.getId());
      try {
        session.getBasicRemote().sendText(sessionInfo.toJSONString());
      } catch (IOException e) {
        logger.error("onOpen WebSocket Exception:", e.toString());
      }
      logger.info("[ConnCreate] sessionId is:{}, current users count is:{}", session.getId(), onlineUsers);
    }
  }

  @OnClose
  public void onClose(Session session, CloseReason reason) {
    MessagePushService.removeWebSocket(session.getId());
    MessagePushService.removeSubscriber(session.getId());
    removeOnlineUsers();
    logger.warn("[ConnClose] sessionId: {}, closeReason: {}, code: {}" + session.getId(), reason.getReasonPhrase(), reason.getCloseCode());
  }

  @OnMessage
  public void onMessage(String message, Session session) {
    logger.info("WebSocket onMessage({})", message);
    Map<String, String> map = JSON.parseObject(message, Map.class);
    map.put("sessionId", session.getId());
    try {
      RemoteEndpoint.Async async = session.getAsyncRemote();
      InetSocketAddress addr = (InetSocketAddress) WebsocketUtil.getFieldInstance(async, "base#socketWrapper#socket#sc#remoteAddress");
      String hostName = addr.getHostName();
      map.put("clientIp", hostName);
    } catch (Exception e) {
      logger.error("throw Ex: ", e);
    }
    MessagePushService.parseSubscribe(map);
  }

  @OnError
  public void onError(Session session, Throwable error) {
    MessagePushService.removeWebSocket(session.getId());
    MessagePushService.removeSubscriber(session.getId());
    removeOnlineUsers();
    logger.info("[ConnError] sessionId={}", session.getId());
  }

  public void sendMessage(String message) throws IOException {
    if (session != null) {
      session.getBasicRemote().sendText(message);
    }
  }

  public void asyncSendMessage(String message) throws IOException {
    if (session != null) {
      session.getAsyncRemote().sendText(message);
    }
  }
}

WebSocket 配置类:

@Configuration
public class WebSocketConfig {

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

    @Bean
    ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(512000);
        container.setMaxBinaryMessageBufferSize(512000);
        container.setMaxSessionIdleTimeout(15 * 6000L);
        return container;
    }
    
}

1.2 MessagePushService

自定义辅助类 MessagePushService 负责推送服务,向客户端推送订阅的消息。

@Slf4j
public final class MessagePushService {
  protected static final ConcurrentHashMap<String, WebSocket> webSocketMap = new ConcurrentHashMap<>();
  protected static final ConcurrentHashMap<String, Map<String, Subscriber>> subscriberMap = new ConcurrentHashMap<>();

  private MessagePushService() {}

  public static void addWebSocket(String sessionId, WebSocket webSocket) {
    webSocketMap.put(sessionId, webSocket);
  }

  public static void removeWebSocket(String sessionId) {
    webSocketMap.remove(sessionId);
  }

  public static List<String> getAllSessionId() {
    return new ArrayList<>(webSocketMap.keySet());
  }

  public static Map<String, Map<String, Subscriber>> getSubscriberMap() {
    return subscriberMap;
  }

  public static boolean isSubscriberPrecinct(String precinctId, String subscriberType, String sessionId) {
    Map<String, Subscriber> map = subscriberMap.get(sessionId);
    if (map == null) {
      map = new ConcurrentHashMap<>();
    }
    Subscriber subscriber = map.get(subscriberType);
    if (subscriber == null) {
      return false;
    }
    List<String> precinctIds = subscriber.getPrecinctIds();

    if (CollectionUtils.isEmpty(precinctIds)) {
      log.info("isSubscriberPrecinct: precinctId is {}, precinctIds is empty", precinctId);
      return false;
    }
    return precinctIds.contains("all") || precinctIds.contains(precinctId);
  }

  public static boolean isSubscriberDeviceId(String deviceId, String subscriberType, String sessionId) {
    if (subscriberMap.containsKey(sessionId)) {
      Map<String, Subscriber> map = subscriberMap.get(sessionId);
      Subscriber subscriber = map.get(subscriberType);
      if (Objects.isNull(subscriber)) {
        return false;
      }

      return subscriber.isSubcriberDeviceId(deviceId);
    } else {
      return false;
    }
  }

  public static void removeSubscriber(String sessionId) {
    subscriberMap.remove(sessionId);
  }

  public static void removePart(String sessionId, String subscribeType, String deviceIds, String precinctIds) {
    Map<String, Subscriber> stringSubscriberMap = subscriberMap.get(sessionId);
    if (CollectionUtils.isEmpty(stringSubscriberMap)) {
      log.info("sessionId:{} un subscriber", sessionId);
      return;
    }
    // 取消某个订阅类型
    if (!StringUtils.isEmpty(subscribeType)) {
      String[] subscribeTypes = subscribeType.split(",");
      for (String str : subscribeTypes) {
        stringSubscriberMap.remove(str);
      }
    }
    // 取消设备订阅
    if (!StringUtils.isEmpty(deviceIds)) {
      Collection<Subscriber> values = stringSubscriberMap.values();
      log.info("subscribers: {}", JSON.toJSONString(values));
      for (Subscriber subscriber : values) {
        List<String> deviceIdList = subscriber.getDeviceIds();
        if (!CollectionUtils.isEmpty(deviceIdList)) {
          String[] deviceIdArray = deviceIds.split(",");
          deviceIdList.removeAll(Arrays.asList(deviceIdArray));
        }
      }
    }
    // 取消区域订阅
    if (!StringUtils.isEmpty(precinctIds)) {
      Collection<Subscriber> values = stringSubscriberMap.values();
      for (Subscriber subscriber : values) {
        List<String> precinctIdList = subscriber.getPrecinctIds();
        String[] precinctIdArray = precinctIds.split(",");
        precinctIdList.removeAll(Arrays.asList(precinctIdArray));
      }
    }
  }

  public static void saveSubscriber(SubscribeParam subscribeParam) {
    String sessionId = subscribeParam.getSessionId();
    String subscribeType = subscribeParam.getSubscribeType();
    String precinctIds = subscribeParam.getPrecinctIds();
    String deviceIds = subscribeParam.getDeviceIds();
    String deviceKinds = subscribeParam.getDeviceKinds();
    String clientIp = subscribeParam.getClientIp();
    String userId = subscribeParam.getUserId();
    if (subscriberMap.containsKey(sessionId)) {
      Map<String, Subscriber> map = subscriberMap.get(sessionId);
      String[] subscribeTypes = subscribeType.split(",");
      for (String str : subscribeTypes) {
        Subscriber subscriber = map.get(str);
        if (Objects.nonNull(subscriber)) {
          subscriber.subscriber(str, precinctIds, deviceIds, deviceKinds);
          subscriber.setUserId(userId);
        } else {
          subscriber = new Subscriber(sessionId, clientIp, str, precinctIds, deviceIds, deviceKinds);
          subscriber.setUserId(userId);
          map.put(str, subscriber);
        }
      }
    } else {
      Map<String, Subscriber> map = new ConcurrentHashMap<>();
      String[] subscribeTypes = subscribeType.split(",");
      for (String str : subscribeTypes) {
        Subscriber subscriber = new Subscriber(sessionId, clientIp, str, precinctIds, deviceIds, deviceKinds);
        subscriber.setUserId(userId);
        map.put(str, subscriber);
      }
      subscriberMap.put(sessionId, map);
    }
  }

  public static void parseSubscribe(SubscribeParam map) {
    String isSubscribe = map.getIsSubscribe();
    if (StringUtils.isEmpty(isSubscribe)) {
      throw new BusinessException("sessionId and isSubscribe can not be null");
    }
    String sessionId = map.getSessionId();
    String subscribeType = map.getSubscribeType();
    String deviceIds = map.getDeviceIds();
    String precinctIds = map.getPrecinctIds();
    try {
      // 取消订阅
      if ("0".equals(isSubscribe)) {
        removePart(sessionId, subscribeType, deviceIds, precinctIds);
        log.info("Unsubscribe pushService, param = {}", JSON.toJSONString(map));
      } else if ("1".equals(isSubscribe)) {
        removePart(sessionId, subscribeType, null, null);
        saveSubscriber(map);
        log.info("Subscribe pushService, param = {}", JSON.toJSONString(map));
      }
    } catch (Exception e) {
      log.error("parseSubscribe: ", e);
      throw new BusinessException(e.getMessage());
    }
  }

  public static void asyncSendMessage(String sessionId, String message) throws IOException {
    log.info("Async SendMsg on SessionId: {}, Msg = {}", sessionId, message);
    webSocketMap.get(sessionId).asyncSendMessage(message);
  }
}

parseSubscribe() 对应一个接口:

/**
 * 协议消息订阅
 */
@PostMapping(value = "/subscribe")
public Result<String> subscribe(@RequestBody SubscribeParam subscribeParam) {
    String sessionId = subscribeParam.getSessionId();
    String isSubscribe = subscribeParam.getIsSubscribe();

    if (null == sessionId || null == isSubscribe) {
        throw new BusinessException("sessionId and isSubscribe can not be null");
    }

    MessagePushService.parseSubscribe(subscribeParam);

    return Result.ok();
}

1.3 KafkaConsumer#Push

消费 Kafka,将过车记录通过 websocket 推送到对应的订阅用户。

@Data
@Slf4j
@Component
public class VehicleEventConsumer {
  private static final String VEHICLE_EVENT_TYPE = "vehicleEvent";

  @Resource
  private CommonDao commonDao;

  /**
   * 过车记录消费
   */
  @KafkaListener(topics = "${spring.kafka.vehicle.bap.vehicle.event}", containerFactory = "kafkaListenerContainerFactory")
  public void vehicleTsEventListener(List<ConsumerRecord<?, ?>> records, Acknowledgment ack) {
    if (!CollectionUtils.isEmpty(records)) {
      try {
        for (ConsumerRecord<?, ?> record : records) {
          Object value = record.value();
          String vehicleEventMsg = value.toString();
          log.debug("VehicleEventMsg: {}", vehicleEventMsg);
          if (StrUtil.isNotEmpty(vehicleEventMsg)) {
            this.webSocketPush(vehicleEventMsg);
          }
        }
      } catch (Exception e) {
        log.error("VehicleEvent WebSocket Push Failed, throw Ex: ", e);
      }
    }
    // 手动确认
    ack.acknowledge();
  }

  /**
   * 车辆事件推送
   */
  private void webSocketPush(String record) {
    JSONObject dataJson = JSON.parseObject(record);
    String eventType = dataJson.getString("type");
    if (!VEHICLE_EVENT_TYPE.equalsIgnoreCase(eventType)) {
      log.info("This record isn't VehicleEvent, ignore it!");
      return;
    }
    JSONArray dataList = dataJson.getJSONArray("data");
    if (CollectionUtils.isEmpty(dataList)) {
      log.info("Invalidate vehicleEvent: {}", record);
      return;
    }
    List<String> sessionIdList = MessagePushService.getAllSessionId();
    if (CollectionUtils.isEmpty(sessionIdList)) {
      return;
    }
    List<VehicleEventDto> vehicleEventDtoList = dataList.toJavaList(VehicleEventDto.class);
    for (VehicleEventDto vehicleEventDto : vehicleEventDtoList) {
      this.pushData(vehicleEventDto, sessionIdList);
    }

  }

  /**
   * 推送WebSocket
   */
  private void pushData(VehicleEventDto vehicleEventDto, List<String> sessionIdList) {
    // 获取道闸ID
    String deviceId = vehicleEventDto.getDeviceId();
    if (StringUtils.isEmpty(deviceId)) {
      log.info("VehicleEvent push device id is empty");
      return;
    }
    DeviceDto deviceDto = commonDao.getDeviceInfo(deviceId);
    String villageId;
    if (Objects.nonNull(deviceDto)) {
      villageId = deviceDto.getPrecinctId();
    } else {
      log.info("VehicleEvent => DeviceId={} Not Exist!", deviceId);
      return;
    }
    JSONObject pushObj = new JSONObject();
    pushObj.put("data", vehicleEventDto);
    pushObj.put("eventType", EventConstants.CAR_EVENT_COMMON);
    for (String sessionId : sessionIdList) {
      boolean isSubscriber =
          MessagePushService.isSubscriberDeviceId(deviceId, EventConstants.CAR_EVENT_COMMON, sessionId)
              || MessagePushService.isSubscriberPrecinct(villageId, EventConstants.CAR_EVENT_COMMON, sessionId);
      log.info("[VehicleEvent] => sessionId:{}, isSubscriber:{}, deviceId:{}, precinctId:{}", sessionId, isSubscriber, deviceId, villageId);
      if (isSubscriber) {
        try {
          MessagePushService.asyncSendMessage(sessionId, JSON.toJSONString(pushObj));
        } catch (IOException e) {
          log.error("Send VehicleEvent to Client[SessionId={}] Failed, throw Ex: ", sessionId, e);
        }
      }
    }
  }
}

2. 集成 @ServerEndpoint

针对上面的案例,有两个疑问:

  1. 为什么要用 @Component 注解?
  2. 配置 ServerEndpointExporter 有什么用?

框选住的代码很直观了。

要求被暴露的 WebSocket 服务类必须是个 bean,所以要有 @Component 注解。ServerEndpointExporter 的作用是将所有 @ServerEndpoint 的 bean 注册进容器里。

那这就又引出一个问题,加了 @Component,WebSocket 的成员变量是线程安全的吗(案例中定义了两个成员变量)?

3. 成员变量线程安全

前端测试代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ws client</title>
</head>
<body></body>

<script>
    let ws = new WebSocket("ws://localhost:8080/ws")
    ws.onopen = function () {
        ws.send("hello")
    }

    ws.onmessage = function (message) {
        console.log(message)
    }
</script>
</html>

后端测试代码:

以上验证了 @ServerEndpoint 类的成员变量是线程安全的。

但是存在矛盾:

上面用例 @Component 和 @ServerEndpoint 共同作用于一个类上,但 Spring 容器管理 @Component 默认是单例的。

解释上文的矛盾:

@Component 用于 Bean 相关的 API 读取;@ServerEndpoint 使用的是 Servlet 的模式,与 SpringMVC 不同,它是在 Filter 里面做了 new 操作。

posted @ 2020-03-13 23:44  tree6x7  阅读(124)  评论(0编辑  收藏  举报