feat(messaging/websocket): 新增消息模块 - WebSocket

This commit is contained in:
2024-06-04 22:43:27 +08:00
parent 3d2a4271d5
commit cc079e8bf4
16 changed files with 647 additions and 9 deletions

View File

@@ -114,6 +114,16 @@ public class PropertiesConstants {
*/
public static final String CAPTCHA_BEHAVIOR = CAPTCHA + StringConstants.DOT + "behavior";
/**
* 消息配置
*/
public static final String MESSAGING = CONTINEW_STARTER + StringConstants.DOT + "messaging";
/**
* WebSocket 配置
*/
public static final String MESSAGING_WEBSOCKET = MESSAGING + StringConstants.DOT + "websocket";
private PropertiesConstants() {
}
}

View File

@@ -389,6 +389,13 @@
<version>${revision}</version>
</dependency>
<!-- 消息模块 - WebSocket -->
<dependency>
<groupId>top.continew</groupId>
<artifactId>continew-starter-messaging-websocket</artifactId>
<version>${revision}</version>
</dependency>
<!-- 消息模块 - 邮件 -->
<dependency>
<groupId>top.continew</groupId>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>top.continew</groupId>
<artifactId>continew-starter-messaging</artifactId>
<version>${revision}</version>
</parent>
<artifactId>continew-starter-messaging-websocket</artifactId>
<description>ContiNew Starter 消息模块 - WebSocket</description>
<dependencies>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,101 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.autoconfigure;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.server.HandshakeInterceptor;
import top.continew.starter.core.constant.PropertiesConstants;
import top.continew.starter.messaging.websocket.core.CurrentUserProvider;
import top.continew.starter.messaging.websocket.core.WebSocketInterceptor;
import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
import top.continew.starter.messaging.websocket.dao.WebSocketSessionDaoDefaultImpl;
/**
* WebSocket 自动配置
*
* @author WeiRan
* @author Charles7c
* @since 2.1.0
*/
@AutoConfiguration
@EnableWebSocket
@EnableConfigurationProperties(WebSocketProperties.class)
@ConditionalOnProperty(prefix = PropertiesConstants.MESSAGING_WEBSOCKET, name = PropertiesConstants.ENABLED, havingValue = "true")
public class WebSocketAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(WebSocketAutoConfiguration.class);
private final WebSocketProperties properties;
public WebSocketAutoConfiguration(WebSocketProperties properties) {
this.properties = properties;
}
@Bean
public WebSocketConfigurer webSocketConfigurer(WebSocketHandler handler, HandshakeInterceptor interceptor) {
return registry -> registry.addHandler(handler, properties.getPath())
.addInterceptors(interceptor)
.setAllowedOrigins(properties.getAllowedOrigins().toArray(String[]::new));
}
@Bean
@ConditionalOnMissingBean
public WebSocketHandler webSocketHandler() {
return new top.continew.starter.messaging.websocket.core.WebSocketHandler(properties, SpringUtil
.getBean(WebSocketSessionDao.class));
}
@Bean
@ConditionalOnMissingBean
public HandshakeInterceptor handshakeInterceptor() {
return new WebSocketInterceptor(properties, SpringUtil.getBean(CurrentUserProvider.class));
}
/**
* WebSocket 会话 DAO
*/
@Bean
@ConditionalOnMissingBean
public WebSocketSessionDao webSocketSessionDao() {
return new WebSocketSessionDaoDefaultImpl();
}
/**
* 当前用户 Provider如不提供则报错
*/
@Bean
@ConditionalOnMissingBean
public CurrentUserProvider currentUserProvider() {
throw new NoSuchBeanDefinitionException(CurrentUserProvider.class);
}
@PostConstruct
public void postConstruct() {
log.debug("[ContiNew Starter] - Auto Configuration 'Messaging-WebSocket' completed initialization.");
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
import top.continew.starter.core.constant.PropertiesConstants;
import top.continew.starter.core.constant.StringConstants;
import java.awt.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* WebSocket 配置属性
*
* @author Charles7c
* @since 2.1.0
*/
@ConfigurationProperties(PropertiesConstants.MESSAGING_WEBSOCKET)
public class WebSocketProperties {
private static final List<String> ALL = Collections.singletonList(StringConstants.ASTERISK);
/**
* 是否启用 WebSocket
*/
private boolean enabled = false;
/**
* 路径
*/
private String path = StringConstants.SLASH + "websocket";
/**
* 允许跨域的域名
*/
private List<String> allowedOrigins = new ArrayList<>(ALL);
/**
* 当前登录用户 Key
*/
private String currentUserKey = "CURRENT_USER";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
public String getCurrentUserKey() {
return currentUserKey;
}
public void setCurrentUserKey(String currentUserKey) {
this.currentUserKey = currentUserKey;
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.core;
import org.springframework.http.server.ServletServerHttpRequest;
import top.continew.starter.messaging.websocket.model.CurrentUser;
/**
* 当前登录用户 Provider
*
* @author Charles7c
* @since 2.1.0
*/
public interface CurrentUserProvider {
/**
* 获取当前登录用户
*
* @param request 请求对象
* @return 当前登录用户
*/
CurrentUser getCurrentUser(ServletServerHttpRequest request);
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.core;
import cn.hutool.core.convert.Convert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
import top.continew.starter.messaging.websocket.autoconfigure.WebSocketProperties;
import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
/**
* WebSocket 处理器
*
* @author WeiRan
* @author Charles7c
* @since 2.1.0
*/
public class WebSocketHandler extends AbstractWebSocketHandler {
private static final Logger log = LoggerFactory.getLogger(WebSocketHandler.class);
private final WebSocketProperties webSocketProperties;
private final WebSocketSessionDao webSocketSessionDao;
public WebSocketHandler(WebSocketProperties webSocketProperties, WebSocketSessionDao webSocketSessionDao) {
this.webSocketProperties = webSocketProperties;
this.webSocketSessionDao = webSocketSessionDao;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("WebSocket receive message. sessionId: {}, message: {}.", session.getId(), message.getPayload());
super.handleTextMessage(session, message);
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String sessionKey = Convert.toStr(session.getAttributes().get(webSocketProperties.getCurrentUserKey()));
webSocketSessionDao.add(sessionKey, session);
log.info("WebSocket connect successfully. sessionKey: {}.", sessionKey);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String sessionKey = Convert.toStr(session.getAttributes().get(webSocketProperties.getCurrentUserKey()));
webSocketSessionDao.delete(sessionKey);
log.info("WebSocket connect closed. sessionKey: {}.", sessionKey);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
log.error("WebSocket transport error. sessionId: {}.", session.getId(), exception);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
import top.continew.starter.messaging.websocket.autoconfigure.WebSocketProperties;
import top.continew.starter.messaging.websocket.model.CurrentUser;
import java.util.Map;
/**
* WebSocket 拦截器
*
* @author WeiRan
* @author Charles7c
* @since 2.1.0
*/
public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
private static final Logger log = LoggerFactory.getLogger(WebSocketInterceptor.class);
private final WebSocketProperties webSocketProperties;
private final CurrentUserProvider currentUserProvider;
public WebSocketInterceptor(WebSocketProperties webSocketProperties, CurrentUserProvider currentUserProvider) {
this.webSocketProperties = webSocketProperties;
this.currentUserProvider = currentUserProvider;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
CurrentUser currentUser = currentUserProvider.getCurrentUser((ServletServerHttpRequest)request);
attributes.put(webSocketProperties.getCurrentUserKey(), currentUser.getUserId());
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception exception) {
super.afterHandshake(request, response, wsHandler, exception);
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.dao;
import org.springframework.web.socket.WebSocketSession;
/**
* WebSocket 会话 DAO
*
* @author Charles7c
* @since 2.1.0
*/
public interface WebSocketSessionDao {
/**
* 添加会话
*
* @param key 会话 Key
* @param session 会话信息
*/
void add(String key, WebSocketSession session);
/**
* 删除会话
*
* @param key 会话 Key
*/
void delete(String key);
/**
* 获取会话
*
* @param key 会话 Key
* @return 会话信息
*/
WebSocketSession get(String key);
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.dao;
import org.springframework.web.socket.WebSocketSession;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket 会话 DAO 默认实现
*
* @author Charles7c
* @since 2.1.0
*/
public class WebSocketSessionDaoDefaultImpl implements WebSocketSessionDao {
private static final Map<String, WebSocketSession> SESSION_MAP = new ConcurrentHashMap<>();
@Override
public void add(String key, WebSocketSession session) {
SESSION_MAP.put(key, session);
}
@Override
public void delete(String key) {
SESSION_MAP.remove(key);
}
@Override
public WebSocketSession get(String key) {
return SESSION_MAP.get(key);
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.model;
import java.io.Serial;
import java.io.Serializable;
/**
* 当前登录用户信息
*
* @author Charles7c
* @since 2.1.0
*/
public class CurrentUser implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 用户 ID
*/
private String userId;
/**
* 扩展字段
*/
private Object extend;
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public Object getExtend() {
return extend;
}
public void setExtend(Object extend) {
this.extend = extend;
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.messaging.websocket.util;
import cn.hutool.extra.spring.SpringUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
import java.io.IOException;
/**
* WebSocket 工具类
*
* @author WeiRan
* @author Charles7c
* @since 2.1.0
*/
public class WebSocketUtils {
private static final Logger log = LoggerFactory.getLogger(WebSocketUtils.class);
private static final WebSocketSessionDao SESSION_DAO = SpringUtil.getBean(WebSocketSessionDao.class);
private WebSocketUtils() {
}
/**
* 发送消息
*
* @param sessionKey 会话 Key
* @param message 消息内容
*/
public static void sendMessage(String sessionKey, String message) {
WebSocketSession session = SESSION_DAO.get(sessionKey);
sendMessage(session, message);
}
/**
* 发送消息
*
* @param session 会话
* @param message 消息内容
*/
public static void sendMessage(WebSocketSession session, String message) {
sendMessage(session, new TextMessage(message));
}
/**
* 发送消息
*
* @param session 会话
* @param message 消息内容
*/
public static void sendMessage(WebSocketSession session, WebSocketMessage<?> message) {
if (session == null || !session.isOpen()) {
log.warn("WebSocket session closed.");
return;
}
try {
session.sendMessage(message);
} catch (IOException e) {
log.error("WebSocket send message failed. sessionId: {}.", session.getId(), e);
}
}
}

View File

@@ -0,0 +1 @@
top.continew.starter.messaging.websocket.autoconfigure.WebSocketAutoConfiguration

View File

@@ -15,6 +15,7 @@
<modules>
<module>continew-starter-messaging-mail</module>
<module>continew-starter-messaging-websocket</module>
</modules>
<dependencies>

View File

@@ -30,13 +30,6 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
<!-- 移除 websocket 依赖,后续使用 websocket 可考虑由 Netty 提供。另可解决日志警告UT026010: Buffer pool was not set on WebSocketDeploymentInfo, the default pool will be used -->
<exclusions>
<exclusion>
<groupId>io.undertow</groupId>
<artifactId>undertow-websockets-jsr</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Hibernate Validator -->

View File

@@ -33,6 +33,8 @@ import java.util.List;
@ConfigurationProperties(PropertiesConstants.CORS)
public class CorsProperties {
private static final List<String> ALL = Collections.singletonList(StringConstants.ASTERISK);
/**
* 是否启用跨域配置
*/
@@ -58,8 +60,6 @@ public class CorsProperties {
*/
private List<String> exposedHeaders = new ArrayList<>();
private static final List<String> ALL = Collections.singletonList(StringConstants.ASTERISK);
public boolean isEnabled() {
return enabled;
}