WebSocket+Spring boot 构建一个完整消息服务

1、添加依赖

compile project(":faas-spring-boot-starter-data-websocket")

2、定义WebSocketHandler Socket 服务入口(Header接收 jwt-token 同应用登录的Token(直接解决鉴权问题),然后定义请求的自定义参数,方便后续消息推送、支持群发、私发、模糊匹配)


@Component
@WebSocketMapping("/server")
@AllArgsConstructor
public class ServerHandler implements WebSocketHandler {
    private final static Logger LOG = LoggerFactory.getLogger(ServerHandler.class);
    private final ConcurrentHashMap<String, WebSocketSender> senders;
    private final WebsocketResourceProperties properties;
    private final WebsocketMessageApiService messageApiService;
    private final UserApiService userApiService;

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        HandshakeInfo info = session.getHandshakeInfo();
        String token = Validator.getToken(info);
        Terminal terminal = getTerminal(info);
        try {
            Terminal terminalByToken = Validator.verify(token, properties.getClients());
            String userId = terminalByToken.getUserId();
            //openId 转 userId
            UserInfoResp infoResp = userApiService.get(UserGetReq.newBuilder().setLoginUserId(userId).build());
            userId = infoResp.getUserId();
            terminal.setUserId(userId);
            messageApiService.addOnlineUsers(userId);
        } catch (FibException e) {
            LOG.error("会话创建: code={}, message={}", e.getRespCode(), e.getRespMessage());
            session.close();
            return Mono.empty();
        }
        String userId = terminal.getUserId();
        String id = WebSocketSender.getSenderId(userId, terminal.getAppType(), terminal.getAppSn(), terminal.getTableCode());
        session.receive().doFinally(sig -> {
            LOG.info("会话终止: id={},name={}", id, sig.name());
            session.close();
            senders.remove(id);
            messageApiService.removeOnlineUsers(userId);
        }).subscribe(inMsg -> {
            LOG.info("接收消息: id={}, message={}", id, inMsg.getPayloadAsText());
            process(inMsg.getPayloadAsText());
        });
        return session.send(Flux.create(sink -> {
            senders.put(id, new WebSocketSender(session, sink));
            LOG.info("会话创建: id={},count={},senders={}", id, senders.size(), JSON.toJSONString(senders.keys()));
        }));
    }


    /**
     * 处理客户端消息
     *
     * @param text
     */
    private void process(String text) {

    }

    /**
     * 应用终端信息
     *
     * @param info
     * @return
     */
    private static Terminal getTerminal(HandshakeInfo info) {
        Terminal terminal = new Terminal();
        HttpHeaders headers = info.getHeaders();
        Map<String, String> queryParams = getQueryParams(info.getUri().getQuery());
        String appSn = headers.getFirst("appSn");
        String appType = headers.getFirst("appType");
        String tableCode = headers.getFirst("tableCode");
        if (StringUtils.isEmpty(appSn)) {
            appSn = queryParams.get("appSn");
        }
        if (StringUtils.isEmpty(tableCode)) {
            tableCode = queryParams.get("tableCode");
        }
        if (StringUtils.isEmpty(appType)) {
            appType = queryParams.get("appType");
        }
        terminal.setAppSn(appSn);
        terminal.setAppType(appType);
        terminal.setTableCode(tableCode);
        return terminal;
    }

    /**
     * 获取请求参数
     *
     * @param queryStr
     * @return
     */
    private static Map<String, String> getQueryParams(String queryStr) {
        Map<String, String> queryMap = new HashMap<>();
        if (!StringUtils.isEmpty(queryStr)) {
            String[] queryParam = queryStr.split("&");
            Arrays.stream(queryParam).forEach(s -> {
                String[] kv = s.split("=", 2);
                String value = kv.length == 2 ? kv[1] : "";
                queryMap.put(kv[0], value);
            });
        }
        return queryMap;
    }
}

3、Validator  辅助类(解析 Header中的token)

public class Validator {
    private static final Pattern authorizationPattern = Pattern.compile("^Bearer (?<token>[a-zA-Z0-9-._~+/]+=*)$", 2);

    private static String getTokenFromHeader(HttpHeaders headers) {
        String authorization = headers.getFirst("Authorization");
        if (StringUtils.isEmpty(authorization)) {
            return null;
        }
        if (StringUtils.startsWithIgnoreCase(authorization, "bearer")) {
            Matcher matcher = authorizationPattern.matcher(authorization);
            if (!matcher.matches()) {
                throw FibException.ofServerError("无效令牌[Authorization]");
            } else {
                return matcher.group("token");
            }
        } else {
            return null;
        }
    }

    public static String getToken(HandshakeInfo handshakeInfo) {
        String token = getTokenFromHeader(handshakeInfo.getHeaders());
        if (StringUtils.isEmpty(token)) {
            Map<String, String> queryParams = getQueryParams(handshakeInfo.getUri().getQuery());
            return queryParams.get("token");
        }
        return token;
    }

    /**
     * 验证token有效性并返回id
     *
     * @param token
     * @return
     * @throws ParseException
     */
    public static Terminal verify(String token, List<String> clients) {
        try {
            SignedJWT signedJWT = SignedJWT.parse(token);
            JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
            String sub = claims.getStringClaim("sub");
            if (StringUtils.isEmpty(sub)) {
                throw FibException.ofServerError("无效令牌[Illegal sub]");
            }
            List<String> aud = claims.getStringListClaim("aud");
            if (aud == null || aud.isEmpty()) {
                throw FibException.ofServerError("无效令牌[Illegal aud]");
            }
            if (clients != null && !clients.isEmpty()) {
                Object audStr = aud.get(0);
                if (!clients.contains(audStr)) {
                    throw FibException.ofServerError("无效令牌[Illegal aud]");
                }
            }
            String iss = claims.getStringClaim("iss");
            if (StringUtils.isEmpty(iss)) {
                throw FibException.ofServerError("无效令牌[Missing issuer]");
            }
            Date exp = claims.getDateClaim("exp");
            long now = System.currentTimeMillis();
            if (exp == null || now > exp.getTime()) {
                throw FibException.ofServerError("无效令牌[Expired]");
            }
            Terminal terminal = new Terminal();
            terminal.setUserId(sub);
            return terminal;
        } catch (ParseException e) {
            throw FibException.ofServerError("无效令牌[Unexpected token]");
        }
    }

    /**
     * 获取请求参数
     *
     * @param queryStr
     * @return
     */
    private static Map<String, String> getQueryParams(String queryStr) {
        Map<String, String> queryMap = new HashMap<>();
        if (!StringUtils.isEmpty(queryStr)) {
            String[] queryParam = queryStr.split("&");
            Arrays.stream(queryParam).forEach(s -> {
                String[] kv = s.split("=", 2);
                String value = kv.length == 2 ? kv[1] : "";
                queryMap.put(kv[0], value);
            });
        }
        return queryMap;
    }
}

4、WebSocketConfiguration


@Configuration
public class WebSocketConfiguration {
    @Bean
    public HandlerMapping webSocketMapping() {
        return new WebSocketMappingHandlerMapping();
    }

    @Bean
    public WebSocketHandlerAdapter handlerAdapter() {
        return new WebSocketHandlerAdapter();
    }

    @Bean
    public ConcurrentHashMap<String, WebSocketSender> senders() {
        return new ConcurrentHashMap<String, WebSocketSender>();
    }

}

5、WebSocketMappingHandlerMapping

/**
 * Register WebSocket handlers annotated by @WebSocketMapping
 */
public class WebSocketMappingHandlerMapping extends SimpleUrlHandlerMapping {
    private final Map<String, WebSocketHandler> handlers = new LinkedHashMap<>();

    @Override
    public void initApplicationContext() throws BeansException {
        Map<String, Object> beanMap = obtainApplicationContext().getBeansWithAnnotation(WebSocketMapping.class);
        beanMap.values().forEach(bean -> {
            if (!(bean instanceof WebSocketHandler)) {
                throw new RuntimeException(String.format("Controller [%s] doesn't implement WebSocketHandler interface.", bean.getClass().getName()));
            }
            WebSocketMapping annotation = AnnotationUtils.getAnnotation(bean.getClass(), WebSocketMapping.class);
            handlers.put(Objects.requireNonNull(annotation).value(), (WebSocketHandler) bean);
        });
        super.setOrder(Ordered.HIGHEST_PRECEDENCE);
        super.setUrlMap(handlers);
        super.initApplicationContext();
    }
}

6、ServerJob、离线后重新登录,可以重新推送消息

@Service
@AllArgsConstructor
@Lazy(false)
public class ServerJob extends AbstractDistributedSchedule {
    private final Logger logger = LoggerFactory.getLogger(getClass());
    private final WebsocketMessageApiService messageApiService;
    private final ConcurrentHashMap<String, WebSocketSender> senders;
    private final UserApiService userApiService;

    @Override
    @Scheduled(cron = "0 0/1 * * * ?")
    public void execute() {
        try {
            logger.info(String.format("消息推送异步处理任务,当前在线用户数[%s]", senders.size()));
            boolean lock = lock(0, 1000);
            if (!lock) {
                logger.info("get lock fail");
                return;
            }
            fib.core.Pageable<cn.finopen.faas.api.websocket.dto.WebsocketMessageFindResp> pageable = messageApiService.find(WebsocketMessageFindReq.newBuilder().setStatus(MessageStatus.WAITING.ordinal()).setPage(1).setPageSize(100).build());
            if (pageable.getTotal() <= 0) {
                return;
            }
            Collection<WebsocketMessageFindResp> items = pageable.getItems();
            items.forEach(req -> ThreadPoolUtils.execute(() -> {
                String userId = req.getUserId();
                UserInfoResp infoResp = userApiService.get(UserGetReq.newBuilder().setLoginUserId(userId).build());
                String appSn = req.getAppSn();
                String appType = req.getAppType();
                String tableCode = req.getTableCode();
                boolean flag = WebSocketSender.send(senders, infoResp.getUserId(), appType, appSn, tableCode, req.getData());
                if (flag) {
                    messageApiService.update(WebsocketMessageUpdateReq.newBuilder().setId(req.getId()).setStatus(MessageStatus.SENT.ordinal()).build());
                }
            }));
        } catch (Exception e) {
            logger.error("消息推送异步处理任务", e);
        }
    }
}

7、消息推送API 集成到具体的业务服务(支持在线和离线)

@AllArgsConstructor
@Service
public class WebsocketMessageApiServiceImpl implements WebsocketMessageApiService {
    private final ConcurrentHashMap<String, WebSocketSender> senders;
    private final WebsocketMessageRepository repository;
    private final RedisService redisService;
    private final static String WEBSOCKET_ONLINE_USERS = "WEBSOCKET_ONLINE_USERS";
    private final UserApiService userApiService;

    @Override
    public WebsocketMessageCreateResp create(WebsocketMessageCreateReq req) {
        WebsocketMessageData data = req.getData();
        if (data.getType() == null) {
            throw FibException.ofBadRequest("消息类型不能为空");
        }
        String content = trim(data.getContent());
        if (StringUtils.isEmpty(content)) {
            throw FibException.ofBadRequest("消息内容不能为空");
        }
        List<String> userIdList = req.getUserId();
        if (userIdList == null || userIdList.isEmpty()) {
            throw FibException.ofBadRequest("用户id不能不能为空");
        }
        data.setTime(System.currentTimeMillis());
        String text = data.toString();
        String appSn = req.getAppSn();
        String appType = req.getAppType();
        String tableCode = req.getTableCode();
        String ignoreUserId = req.getIgnoreUserId();
        if (StringUtils.isNotEmpty(ignoreUserId)) {
            UserInfoResp infoResp = userApiService.get(UserGetReq.newBuilder().setLoginUserId(ignoreUserId).build());
            ignoreUserId = infoResp.getUserId();
        }
        List<WebsocketMessage> messages = new ArrayList<>();
        boolean checkOnline = req.getCheckOnline() != null && req.getCheckOnline();
        for (String userId : userIdList) {
            int status = MessageStatus.SENT.ordinal();
            UserInfoResp infoResp = userApiService.get(UserGetReq.newBuilder().setLoginUserId(userId).build());
            boolean flag = WebSocketSender.send(senders, infoResp.getUserId(), appType, appSn, tableCode, text, ignoreUserId);
            if (!flag) {
                if (checkOnline) {
                    status = MessageStatus.FAILED.ordinal();
                } else {
                    status = MessageStatus.WAITING.ordinal();
                }
            }
            messages.add(WebsocketMessage.newBuilder()
                    .setUserId(userId)
                    .setCheckOnline(checkOnline)
                    .setData(text)
                    .setAppSn(appSn)
                    .setAppType(appType)
                    .setTableCode(tableCode)
                    .setCreateTime(LocalDateTime.now())
                    .setStatus(status)
                    .build());
        }
        if (!messages.isEmpty() && content.length() < 1024) {
            repository.saveAll(messages);
        }
        return WebsocketMessageCreateResp.newBuilder().build();
    }


    @Override
    public WebsocketMessageGetResp get(WebsocketMessageGetReq req) {
        Long id = req.getId();
        Optional<WebsocketMessage> messageOptional = repository.findOne(Example.of(WebsocketMessage.newBuilder().setId(id).build()));
        if (messageOptional.isEmpty()) {
            throw FibException.ofNotFound("消息不存在");
        }
        WebsocketMessage entity = messageOptional.get();
        return WebsocketMessageGetResp.newBuilder()
                .setId(entity.getId())
                .setUserId(entity.getUserId())
                .setData(entity.getData())
                .setCheckOnline(entity.getCheckOnline())
                .setCreateTime(entity.getCreateTime()).build();
    }

    @Override
    public WebsocketMessageUpdateResp update(WebsocketMessageUpdateReq req) {
        Long id = req.getId();
        Optional<WebsocketMessage> messageOptional = repository.findOne(Example.of(WebsocketMessage.newBuilder().setId(id).build()));
        if (messageOptional.isEmpty()) {
            throw FibException.ofNotFound("消息不存在");
        }
        WebsocketMessage entity = messageOptional.get();
        entity.setStatus(req.getStatus());
        entity.setUpdateTime(LocalDateTime.now());
        repository.save(entity);
        return WebsocketMessageUpdateResp.newBuilder().build();
    }

    @Override
    public Pageable<WebsocketMessageFindResp> find(WebsocketMessageFindReq req) {
        QWebsocketMessage qEntity = QWebsocketMessage.websocketMessage;
        BooleanExpression expression = qEntity.isNotNull();
        if (req.getStatus() != null) {
            expression = expression.and(qEntity.status.eq(req.getStatus()));
        }
        if (StringUtils.isNotEmpty(req.getUserId())) {
            expression = expression.and(qEntity.userId.eq(req.getUserId()));
        }
        List<String> createTimes = req.getCreateTime();
        if (createTimes != null && !createTimes.isEmpty()) {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            if (createTimes.size() == 1) {
                expression = expression.and(qEntity.createTime.goe(LocalDateTime.parse(createTimes.get(0), formatter)));
            } else {
                expression = expression.and(qEntity.createTime.goe(LocalDateTime.parse(createTimes.get(0), formatter))).and(qEntity.createTime.lt(LocalDateTime.parse(createTimes.get(1), formatter)));
            }
        }
        PageRequest pageRequest = PageRequest.of(req.getPage() - 1, req.getPageSize(), Sort.by(Sort.Order.desc("createTime")));
        Page<WebsocketMessage> data = repository.findAll(expression, pageRequest);
        return PageImpl.of(req.getPage(), req.getPageSize(), data.getTotalElements(), parse(data.getContent()));
    }

    @Override
    public Set<String> getOnlineUsers(WebsocketMessageGetOnlineUserReq req) {
        Set<String> data = redisService.get(WEBSOCKET_ONLINE_USERS, Set.class);
        if (data == null) {
            data = new HashSet<>();
        }
        return data;
    }

    @Override
    public Boolean addOnlineUsers(String userId) {
        Set data = redisService.get(WEBSOCKET_ONLINE_USERS, Set.class);
        if (data == null) {
            data = new HashSet<>();
        }
        data.add(userId);
        redisService.set(WEBSOCKET_ONLINE_USERS, JSON.toJSONString(data), 24 * 3600);
        return true;
    }

    @Override
    public Boolean removeOnlineUsers(String userId) {
        Set data = redisService.get(WEBSOCKET_ONLINE_USERS, Set.class);
        if (data != null) {
            data.remove(userId);
            redisService.set(WEBSOCKET_ONLINE_USERS, JSON.toJSONString(data), 24 * 3600);
        }
        return true;
    }

    private List<WebsocketMessageFindResp> parse(List<WebsocketMessage> content) {
        List<WebsocketMessageFindResp> result = new ArrayList<>();
        for (WebsocketMessage item : content) {
            WebsocketMessageFindResp temp = WebsocketMessageFindResp.newBuilder().build();
            AvroUtils.copy(item, temp);
            result.add(temp);
        }
        return result;
    }

    /**
     * 如果内容为json/jsonArray 剔除null属性,防止内容过长
     *
     * @param src
     * @return
     */
    private String trim(String src) {
        if (src == null || src.isEmpty()) {
            return src;
        }
        try {
            if (src.startsWith("{") && src.endsWith("}")) {
                return JSONObject.toJSONString(JSONObject.parseObject(src), SerializerFeature.WriteNullListAsEmpty);
            } else if (src.startsWith("[") && src.endsWith("]")) {
                return JSONObject.toJSONString(JSONArray.parseArray(src), SerializerFeature.WriteNullListAsEmpty);
            }
        } catch (Exception ignore) {
        }
        return src;
    }
}

8、Web在线测试工具

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>websocket在线测试工具</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="websocket_tool.min.css" rel="stylesheet">
    <style>
    </style>
</head>
<body>
<div class="well socketBody">
    <div class="socketTop">
        <div class="socketTopColLeft">
            <div class="btn-group socketSelect">
                <button type="button" class="btn btn-default dropdown-toggle socketSelectBtn" data-toggle="dropdown"
                        aria-expanded="false">
                    <span class="showHeadWS">WS</span>
                    <span class="caret"> </span>
                </button>
                <ul class="dropdown-menu socketSelectshadow">
                    <li><a onclick="showWS('WS')">WS</a></li>
                    <li><a onclick="showWS('WSS')">WSS</a></li>
                </ul>
            </div>
        </div>
        <div class="socketTopColRight">
            <input type="text" list="typelist" class="form-control urlInput"
                   placeholder="请输入连接地址~  如: 127.0.0.1:8000/ws"
                   oninput="inputChange()">
            <datalist id="typelist" class="inputDatalist">
                <option>
                    <!-- 这里放默认ws地址 -->
                </option>
            </datalist>
        </div>
    </div>
    <div class="socketBG well" id="main"></div>
    <div class="socketBottom row">
        <div class="col-xs-8 socketTextareaBody">
            <input class="form-control socketTextarea" placeholder="请输入发送信息~"></input>
            <input id="token" class="form-control " placeholder="Sec-WebSocket-Protocol~"></input>
            <input id="sid" class="form-control " placeholder="sid~"></input>
            <input id="rid" class="form-control " placeholder="rid~"></input>
        </div>
        <div class="col-xs-2 socketBtnSendBody">
            <button type="button" class="btn btn-success socketBtnSend" onclick="sendBtn()">发送</button>
        </div>
        <div class="col-xs-2 socketBtnBody">
            <button type="button" class="btn btn-primary socketBtn" onclick="connectBtn()">连接</button>
            <button type="button" class="btn btn-info socketBtn" onclick="emptyBtn()">清屏</button>
            <button type="button" class="btn btn-warning socketBtn" onclick="closeBtn()">断开</button>
        </div>
    </div>
    <div class="alert alert-danger socketInfoTips" role="alert">...</div>


</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@1.12.4/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"></script>
<script src="websocket_tool.min.js"></script>

</body>
</html>

*****  另外客户端在使用过,为了避免掉线情况的发生,可以采取以下几种策略:

  • 心跳机制

实现心跳包机制来维持连接。客户端定时向服务器发送心跳包,服务器收到后回应,以此来确认连接是否仍然活跃。如果在一定时间内没有收到来自对方的心跳响应,则认为连接已断开,并进行相应的处理。

  • 重连机制

当检测到 WebSocket 连接断开时,自动尝试重新建立连接。可以通过设置递增的重试间隔来避免过于频繁地尝试连接,例如每次重试之间等待的时间逐渐增加。
优化网络环境适应性
根据不同的网络环境调整策略,比如在弱网环境下加大心跳包的间隔,减少数据传输频率等。

  • 错误处理

对 WebSocket 的各种事件(如 onopen, onmessage, onerror, onclose)进行适当的监听和处理,确保能够及时发现并处理连接问题。

  • 多实例管理

如果应用程序需要维护多个 WebSocket 连接,确保每个连接独立管理,避免相互之间的干扰导致掉线。​​​​​​​

实例:

let socket = null;
const HEARTBEAT_INTERVAL = 30000; // 心跳间隔时间
const PING = 'ping'; // 心跳包标识符
const PONG = 'pong';

function connect() {
    socket = uni.connectSocket({
        url: 'wss://example.com/socket',
        success: function (res) {
            console.log('WebSocket连接成功');
            startHeartbeat();
        },
        fail: function (err) {
            console.error('WebSocket连接失败', err);
            reconnect();
        }
    });
}

function startHeartbeat() {
    const heartbeat = setInterval(() => {
        socket.send({
            data: JSON.stringify({ type: PING })
        });
    }, HEARTBEAT_INTERVAL);

    socket.onMessage(res => {
        if (res.data === PONG) {
            // 收到服务器的心跳响应
        } else {
            // 处理其他消息
        }
    });

    socket.onClose(() => {
        clearInterval(heartbeat);
        reconnect();
    });
}

function reconnect() {
    // 重连逻辑
    setTimeout(() => {
        connect();
    }, 5000); // 每次重连等待5秒
}

// 初始化连接
connect();

ping 和 pong 分别表示客户端和服务器之间的心跳信号:
Ping:客户端发送给服务器的心跳信号,用于检测连接状态。
Pong:服务器响应客户端的心跳信号,表示服务器已经收到并处理了客户端的心跳请求。
具体来说:
Ping:
客户端定期向服务器发送一个心跳包,这个心跳包的内容通常是简单的字符串 "ping"。
目的是让服务器知道客户端仍然在线并且连接正常。
Pong:
服务器收到客户端发送的 "ping" 后,会回应一个 "pong"。
客户端收到 "pong" 后,就知道连接仍然是活跃的。
这种机制可以用来检测连接的状态,确保双方都在正常工作。如果客户端在一段时间内没有收到服务器的 "pong" 响应,就可以认为连接已经断开,从而触发重连机制

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值