引用
在某些业务场景,我们需要页面对于后台的操作进行实时的刷新,这时候就需要使用websocket。
准备
WebSoket使用场景很多,通讯、聊天、通知等…,这里我们是用来做通知使用的所以我们准备依赖的时候就需要准备两个依赖:
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>java-websocket</artifactId>
<version>1.3.3</version>
</dependency>
<!--websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
因为是做通知使用,所以我们这里准备了一个表来存储发生过的通知记录:
CREATE TABLE `t_notice_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`create_by` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'system' COMMENT '创建人工号',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'system' COMMENT '更新人工号',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_delete` int DEFAULT '0' COMMENT '逻辑删除 0正常,1删除',
`bill_no` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '单据号',
`company_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '公司标识',
`sender_no` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '发送人工号',
`sender_name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '发送人姓名',
`receiver_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '接收人工号',
`business_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '业务类型',
`message_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '消息标题',
`message_status` int DEFAULT NULL COMMENT '消息状态 0未读 1已读',
`status_time` datetime DEFAULT NULL COMMENT '状态提交时间',
`handled_name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '处理人',
`is_jump` int DEFAULT NULL COMMENT '是否跳转 1是 0否',
PRIMARY KEY (`id`),
KEY `idx_tnl_bill_no` (`bill_no`),
KEY `idx_tnl_receiver_no` (`receiver_no`)
) ENGINE=InnoDB AUTO_INCREMENT=1607187186473611267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='通知记录表';
使用
通常在后台单机的情况下没有任何的问题,如果后台经过nginx等进行负载的话,则会导致前端不能将准备的接收给到后端响应。socket属于长连接,其session只会保存在一台服务器上,其他负载及其不会持有这个session,此时,我们需要使用redis的发布订阅来实现,session的共享。
配置需要的Redis消息监听容器:
@Configuration
public class RedisConfig {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
return container;
}
}
Redis消息监听器:用于发布订阅
@Slf4j
public class SoketRedisListener implements MessageListener {
private Session session;
public Session getSession() {
return session;
}
public void setSession(Session session) {
this.session = session;
}
/**
* Callback for processing received objects through Redis.
*
* @param message message must not be {@literal null}.
* @param pattern pattern matching the channel (if specified) - can be {@literal null}.
*/
@Override
public void onMessage(Message message, byte[] pattern) {
String msg = new String(message.getBody());
if (ObjectUtil.isNotEmpty(session) && session.isOpen()) {
try {
session.getBasicRemote().sendText(msg);
log.info("The message was sent successfully!");
} catch (IOException e) {
log.info("Notification sending exception, message be :{}", msg);
throw new BizException(ErrCodeEnum.NOTICE_SEND_ERROR.getErrCode(),ErrCodeEnum.NOTICE_SEND_ERROR.getMsg());
}
}
}
}
配置Soket运行是需要的bean(扫描端点):
/**
* 开启WebSocket支持
* @author Smallink
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
到这一步,我们的配置和所需要的都已经弄好了。
下面,则是我们的核心内容了,我们需要管理用户建立的连接,并保存session到Radis
@ServerEndpoint
:监听建立连接的路径
session
:连接会话—客户端
userId
:用户—登录人
companyCode
:公司编号
@Component
@ServerEndpoint("/notice/api/{companyCode}/{userId}")
@Slf4j
public class SoketService {
/**
* 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
private String userId;
private String companyCode;
/**
* Redis消息监听器
*/
private SoketRedisListener soketRedisListener;
/**
* Redis消息监听容器
*/
private RedisMessageListenerContainer redisMessageListenerContainer = SpringUtil.getBean(RedisMessageListenerContainer.class);
private TNoticeLogMapper noticeLogMapper = SpringUtil.getBean(TNoticeLogMapper.class);
private StringRedisTemplate stringRedisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
private UserContext userContext = SpringUtil.getBean(UserContext.class);
private static ConcurrentHashMap<String, SoketService> webSocketMap = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("companyCode") String companyCode, @PathParam("userId") String userId) {
this.session = session;
this.userId = userId;
this.companyCode = companyCode;
soketRedisListener = new SoketRedisListener();
soketRedisListener.setSession(session);
//配置监听
redisMessageListenerContainer.addMessageListener(soketRedisListener, new ChannelTopic(CustInfoConstant.NOTICE_TOPIC + companyCode + "_" + userId));
if ("A001".equals(userId)) {
webSocketMap.put(userId, this);
}
log.info("User connection:{}", userId);
//消息推送
Page<TNoticeLogPO> page = new LambdaQueryChainWrapper<>(noticeLogMapper)
.eq(TNoticeLogPO::getReceiverNo, userId)
.eq(TNoticeLogPO::getMessageStatus, CustInfoConstant.UNREAD)
.eq(TNoticeLogPO::getCompanyCode, companyCode)
.orderByDesc(TNoticeLogPO::getStatusTime)
.page(new Page<>(1, 20));
PageRespVO<TNoticeLogPO> pageRespVO = new PageRespVO<>();
pageRespVO.setCurrentPage(page.getCurrent());
pageRespVO.setPageSize(page.getSize());
pageRespVO.setTotal(page.getTotal());
pageRespVO.setData(page.getRecords());
try {
this.sendMessage(JSON.toJSONString(pageRespVO));
} catch (IOException e) {
log.info("Notification sending exception, receiver be:{}", userId);
throw new BizException(ErrCodeEnum.NOTICE_SEND_ERROR.getErrCode(), ErrCodeEnum.NOTICE_SEND_ERROR.getMsg());
}
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
log.info("operator :{}, quit!", this.userId);
redisMessageListenerContainer.removeMessageListener(soketRedisListener);
}
/**
* 异常
*
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("notice error:" + error);
throw new BizException(ErrCodeEnum.NOTICE_SEND_ERROR.getErrCode(), "消息发送异常");
}
/**
* 服务器主动推送
*
* @param message 消息内容
* @param receiverNo 接收人
* @param companyCode 所属公司
* @throws IOException
*/
public void sendMessage(String message, String receiverNo, String companyCode) throws IOException {
log.info("The message sent is :{}, receiver no is :{}", message, receiverNo);
//发消息
for (String receiverNos : receiverNo.split(",")) {
stringRedisTemplate.convertAndSend(CustInfoConstant.NOTICE_TOPIC + companyCode + "_" + receiverNos, JSON.toJSONString(message));
}
}
}
简单介绍一下上面的几个方法:
onOpen:顾名思义这是我们用户第一次建立连接是走的方法,这里将用户的session放到了redis监听器里,并查询用户未读的消息发生给用户
onClose:关闭连接时调用的方法,我们则需要在关闭连接时将redis监听器中的对应记录删除掉
sendMessage:发生消息的方法(自己封装的,并不限制,按要求扩展),因为我们使用的是redis的发布订阅方式所以我们使用的是Redis的订阅来发送消息
stringRedisTemplate.convertAndSend(CustInfoConstant.NOTICE_TOPIC + companyCode + "_" + receiverNos, JSON.toJSONString(message));
特别说明:
因为,我的需求是通知,所以我们这边用到了第一个依赖来模拟服务器端用户建立连接。
下面代码配置即可,无需动代码
@Slf4j
public class SocketClient extends WebSocketClient {
//构造方法
public SocketClient(URI serverUri) {
super(serverUri);
}
//连接建立成功时调用该方法
@Override
public void onOpen(ServerHandshake serverHandshake) {
log.info("The connection is successful!");
}
/**
* Callback for string messages received from the remote host
*
* @param message The UTF-8 decoded message that was received.
**/
@Override
public void onMessage(String message) {
log.info("message :{}",message);
}
/**
* Called after the websocket connection has been closed.
*
* @param reason Additional information string
* @param remote
**/
@Override
public void onClose(int code, String reason, boolean remote) {
log.info("close connection");
}
//出现错误时调用该方法
@Override
public void onError(Exception error) {
log.error("To simulate the error:" + error);
}
}
使用:
public class ManageApplication {
private final static String url = "ws://127.0.0.1:9671/crm/manage/api/notice/api/010000/A001";
public static void main(String[] args) throws UnknownHostException {
ConfigurableApplicationContext applicationContext = SpringApplication.run(ManageApplication.class, args);
ConfigurableEnvironment env = applicationContext.getEnvironment();
String ip = InetAddress.getLocalHost().getHostAddress();
String port = env.getProperty("server.port");
String path = env.getProperty("server.servlet.context-path");
log.info("\n--mvn--------------------------------------------------------\n\t" +
"Application is running! Access URLs:\n\t" +
"Environment "+env.getActiveProfiles()[0]+"\n\t" +
"Local: \t\thttp://localhost:" + port + path + "/\n\t" +
"External: \thttp://" + ip + ":" + port + path + "/\n\t" +
"swagger-ui: \thttp://" + ip + ":" + port + path + "/swagger-ui.html\n\t" +
"----------------------------------------------------------");
try {
SocketClient client = new SocketClient(new URI(url));
client.connect();
} catch (URISyntaxException e) {
log.error("The connection fails");
}
}
}
测试
这里推荐一个测试WebSoket连接的网站,http://www.websocket-test.com/
只需要配置好服务器的监听路径然后点击连接即可当出现
时,则表示连接成功。
结尾
以上就是WebSoket使用及整合,聊天等其他方式差不多只需要变换发送人,需要考虑集群服务器下session的存放即可。
因为现在很多项目都是前后分离的,所以前端走后端基本都是通过Nginx或公共网关来统一转发请求,WebSoket使用的是长连接默认的Nginx是不支持长连接的所以需要配置,请参考【Nginx】前后分离项目,nginx转发websoket失败