后端实现
创建模块
首先,出于职责单一的考虑,为内部通知单设了一个模块,命名为platform-boot-starter-notification,依赖于通用模块,在整体架构图中位置如下:
模块内部结构如下:
除了常规的mvc架构目录外,server目录存放了基于netty实现的web socket服务端。
添加依赖
在模块的pom文件中,增加netty组件依赖,如下:
<!--netty网络通信组件-->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.58.Final</version>
</dependency>
组件配置
web socket通信需要一个独立于web系统之外的端口号,平台自定义了一个配置参数,如下:
/**
* 通知配置文件
*
* @author wqliu
* @date 2023-05-20
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "platform-config.notification")
public class NotificationConfig {
/**
* 服务端口
*/
private Integer serverPort = 9997;
}
消息实体
平台设计了实体对消息进行封装,包括标题、内容、接收人、消息类型、是否已读几个属性,如下:
package tech.abc.platform.notification.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import tech.abc.platform.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 系统消息 实体类
*
* @author wqliu
* @date 2023-05-27
*
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("ntfc_system_message")
public class SystemMessage extends BaseEntity {
/**
* 类型
*/
@TableField("type")
private String type;
/**
* 接收人
*/
@TableField("receiver")
private String receiver;
/**
* 标题
*/
@TableField("title")
private String title;
/**
* 内容
*/
@TableField("content")
private String content;
/**
* 是否已读
*/
@TableField("read_flag")
private String readFlag;
/********非库表存储属性*****/
}
消息类型
通过对消息增加类型,通过消息类型来识别和区分不同的消息,进行相应的逻辑处理。
前后端交互的消息有以下几种:
- 前端发送登录请求消息
- 前端发送心跳请求消息
- 前端发送注销请求消息
- 后端发送登录响应消息
- 后端发送心跳响应消息
- 后端发送业务消息
注:收到前端的注销请求无需再发送响应给前端,直接关闭通道即可
消息类型使用了枚举值实现,如下:
package tech.abc.platform.notification.enums;
import lombok.Getter;
/**
* 消息类型
*
* @author wqliu
*/
@Getter
public enum SystemMessageTypeEnum {
/**
* 登录请求
*/
LOGIN_REQUEST,
/**
* 登录响应
*/
LOGIN_RESPONSE,
/**
* 心跳请求
*/
HEARTBEAT_REQUEST,
/**
* 心跳响应
*/
HEARTBEAT_RESPONSE,
/**
* 业务消息
*/
BUSINESS_MESSAGE,
/**
* 注销请求
*/
LOGOUT_REQUEST,
;
}
服务实现
内部通知服务主要的几个操作,就是发送消息、获取未读消息的数量、设置单条消息已读、设置所有消息已读,如下:
/**
* 系统消息 服务接口类
*
* @author wqliu
* @date 2023-05-27
*/
public interface SystemMessageService extends BaseService<SystemMessage> {
/**
* 发送消息
*
* @param account 接收用户账号
* @param title 标题
* @param content 内容
*/
void sendMessage(String account, String title, String content);
/**
* 设置单条消息已读
*
* @param id 消息标识
* @return 消息对象
*/
SystemMessage setRead(String id);
/**
* 设置所有消息已读
*/
void setAllRead();
/**
* 获取未读消息数量
*
* @return
*/
Long getUnreadMessageCount();
}
注意,这里的发送消息,不是指服务端通过web socket发送消息给处于浏览器的客户端,而是接收产生的业务消息。
服务实现如下:
@Override
public void sendMessage(String account, String title, String content) {
SystemMessage entity = init();
entity.setReceiver(account);
entity.setTitle(title);
entity.setContent(content);
add(entity);
ClientHolder.sendMessage(entity);
}
@Override
public SystemMessage setRead(String id) {
SystemMessage entity = getEntity(id);
entity.setReadFlag(YesOrNoEnum.YES.name());
modify(entity);
return entity;
}
@Override
public void setAllRead() {
LambdaUpdateWrapper<SystemMessage> lambdaUpdateWrapper = new UpdateWrapper<SystemMessage>().lambda().set(SystemMessage::getReadFlag,
YesOrNoEnum.YES.name())
.eq(SystemMessage::getReceiver, UserUtil.getAccount())
.eq(SystemMessage::getReadFlag, YesOrNoEnum.NO.name());
this.update(lambdaUpdateWrapper);
}
@Override
public Long getUnreadMessageCount() {
return this.lambdaQuery().eq(SystemMessage::getReceiver, UserUtil.getAccount())
.eq(SystemMessage::getReadFlag, YesOrNoEnum.NO.name())
.count();
}
sendMessage方法内部再将消息发送给客户端,是通过调用方法 ClientHolder.sendMessage(entity)来实现的。
web socket服务端实现
此部分基于netty实现。
关于netty的基础知识和集成实现,详见专栏https://blog.csdn.net/seawaving/category_11610162.html消息服务部分,这里不做详细展开,仅列出关键实现。
服务端初始化源码如下:
package tech.abc.platform.notification.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import tech.abc.platform.notification.config.NotificationConfig;
/**
* netty实现的服务端
*
* @author wqliu
* @date 2021-2-5
**/
@Component
@Slf4j
public class InsideMessageServer {
@Autowired
private NotificationConfig notificationConfig;
@Autowired
private InsideMessageChannelInitializer myChannelInitializer;
/**
* 启动服务器方法
*/
public void run() {
EventLoopGroup bossGroup = new NioEventLoopGroup(2);
EventLoopGroup workerGroup = new NioEventLoopGroup(4);
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.childHandler(myChannelInitializer);
// 绑定端口,开始接收进来的连接
int port = notificationConfig.getServerPort();
ChannelFuture channelFuture = serverBootstrap.bind(port).sync();
log.info("系统通知服务启动 [port:{}]", port);
// 等待服务器socket关闭
channelFuture.channel().closeFuture().sync();
} catch (Exception e) {
log.error("系统通知服务启动异常-" + e.getMessage(), e);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
通道初始化源码如下:
package tech.abc.platform.notification.server;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.IdleStateHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import tech.abc.platform.notification.server.constant.NettyConstant;
import tech.abc.platform.notification.server.handler.HeartbeatTimeoutHandler;
import tech.abc.platform.notification.server.handler.WebSocketServerHandler;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import java.io.InputStream;
import java.security.KeyStore;
import java.util.concurrent.TimeUnit;
/**
* 初始化通道
*
* @author wqliu
* @date 2021-2-5
*/
@Slf4j
@Component
public class InsideMessageChannelInitializer extends ChannelInitializer<SocketChannel> {
@Autowired
private Environment environment;
/**
* 生产运行模式
*/
private final String PRD_MODE = "prd";
/**
* 初始化channel
*/
@Override
public void initChannel(SocketChannel socketChannel) throws Exception {
// 获取通道链路
ChannelPipeline pipeline = socketChannel.pipeline();
/**
* 仅在生产模式下加载ssl过滤器
*/
String mode = environment.getProperty("spring.profiles.active");
if (PRD_MODE.equals(mode)) {
// ssl
SSLContext sslContext = createSslContext();
SSLEngine engine = sslContext.createSSLEngine();
engine.setNeedClientAuth(false);
engine.setUseClientMode(false);
pipeline.addLast(new SslHandler(engine));
}
// HTTP 编解码
pipeline.addLast(new HttpServerCodec());
// 将一个 HttpMessage 和跟随它的多个 HttpContent 聚合
// 为单个 FullHttpRequest 或者 FullHttpResponse(取
// 决于它是被用来处理请求还是响应)。安装了这个之后,
// ChannelPipeline 中的下一个 ChannelHandler 将只会
// 收到完整的 HTTP 请求或响应
pipeline.addLast(new HttpObjectAggregator(64 * 1024));
// 处理web socket协议与握手
pipeline.addLast(new WebSocketServerProtocolHandler("/webSocket"));
// 添加读写通道空闲处理器,当超时时,会触发userEventTrigger,由下个处理器获取到
pipeline.addLast(new IdleStateHandler(NettyConstant.READ_IDLE_TIME_OUT, NettyConstant.WRITE_IDLE_TIME_OUT,
NettyConstant.ALL_IDLE_TIME_OUT, TimeUnit.SECONDS));
// 心跳机制处理
pipeline.addLast(new HeartbeatTimeoutHandler());
// 业务处理Handler
pipeline.addLast(new WebSocketServerHandler());
}
/**
* 创建ssl上下文对象
*
* @return
* @throws Exception
*/
private SSLContext createSslContext() throws Exception {
// 读取配置信息
String path = environment.getProperty("server.ssl.key-store");
log.info("证书地址:{}", path);
String password = environment.getProperty("server.ssl.key-store-password");
String type = environment.getProperty("server.ssl.key-store-type");
// 构建证书上下文对象
KeyStore ks = KeyStore.getInstance(type);
path = path.replace("classpath:", "");
log.info("处理后的证书地址:{}", path);
ClassPathResource resource = new ClassPathResource(path);
InputStream ksInputStream = resource.getInputStream();
ks.load(ksInputStream, password.toCharArray());
// KeyManagerFactory充当基于密钥内容源的密钥管理器的工厂。
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, password.toCharArray());
// SSLContext的实例表示安全套接字协议的实现,它充当用于安全套接字工厂或 SSLEngine 的工厂。
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), null, null);
return sslContext;
}
}
两个关键的处理器如下:
package tech.abc.platform.notification.server.handler;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import tech.abc.platform.common.utils.JwtUtil;
import tech.abc.platform.common.utils.SpringUtil;
import tech.abc.platform.notification.entity.SystemMessage;
import tech.abc.platform.notification.enums.SystemMessageTypeEnum;
import tech.abc.platform.notification.server.global.ClientHolder;
/**
* 业务处理
*
* @author wqliu
* @date 2021-2-5 16:25
**/
@Slf4j
public class WebSocketServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
// 获取消息字符串
String messageString = ((TextWebSocketFrame) textWebSocketFrame).text();
// 转换为对象
SystemMessage message = JSONObject.parseObject(messageString, SystemMessage.class);
// 根据消息类型分别进行处理
SystemMessageTypeEnum messageType = SystemMessageTypeEnum.valueOf(message.getType());
SystemMessage response;
switch (messageType) {
case LOGIN_REQUEST:
// 登录请求,鉴权
String token = message.getContent();
if (StringUtils.isNotBlank(token)) {
JwtUtil jwtUtil = SpringUtil.getBean(JwtUtil.class);
jwtUtil.verifyToken(token);
// 获取用户账号
String account = jwtUtil.decode(token).getSubject();
// 添加到全局
ClientHolder.addChannel(channelHandlerContext.channel(), account);
// 发送登录响应
response = new SystemMessage();
response.setType(SystemMessageTypeEnum.LOGIN_RESPONSE.name());
channelHandlerContext.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(response)));
}
break;
case HEARTBEAT_REQUEST:
// 心跳请求,发送心跳响应
response = new SystemMessage();
response.setType(SystemMessageTypeEnum.HEARTBEAT_RESPONSE.name());
channelHandlerContext.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(response)));
break;
case LOGOUT_REQUEST:
// 注销请求
channelHandlerContext.channel().close();
ClientHolder.removeChannel(channelHandlerContext.channel());
log.info("服务端收到客户端注销请求消息,关闭连接");
break;
default:
}
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("与客户端建立连接,通道开启!");
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("与客户端断开连接,通道关闭!");
ClientHolder.removeChannel(ctx.channel());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("发生异常", cause);
ctx.close();
}
}
package tech.abc.platform.notification.server.handler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;
import tech.abc.platform.notification.server.global.ClientHolder;
/**
* 心跳超时处理器
*
* @author wqliu
* @date 2021-2-6
**/
@Slf4j
public class HeartbeatTimeoutHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
// 发生读写空闲超时,说明客户端不再发送心跳,关闭该连接
ctx.channel().close();
ClientHolder.removeChannel(ctx.channel());
log.info("服务端检测到客户端不再发送心跳,主动关闭连接");
} else {
super.userEventTriggered(ctx, evt);
}
}
}
实现一个全局的工具类,缓存用户通道,用于服务端主动推送消息:
package tech.abc.platform.notification.server.global;
import com.alibaba.fastjson.JSON;
import io.netty.channel.Channel;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.AttributeKey;
import io.netty.util.concurrent.GlobalEventExecutor;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import tech.abc.platform.notification.entity.SystemMessage;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* netty客户端容器
*
* @author wqliu
* @date 2021-2-5
**/
@Slf4j
@UtilityClass
public class ClientHolder {
/**
* 全局组
*/
private static ChannelGroup globalGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 用户channel
*/
private static ConcurrentMap<String, Set<Channel>> userChannelMap = new ConcurrentHashMap();
/**
* 用户标识键值
*/
private final static String ACCOUNT = "account";
/**
* 创建通道
*
* @param channel
* @param account
*/
public static void addChannel(Channel channel, String account) {
// 全局组
globalGroup.add(channel);
// 为channel添加属性,便于移除时查找
AttributeKey<String> accountAttribute = AttributeKey.valueOf(ACCOUNT);
channel.attr(accountAttribute).set(account);
// 全局用户map
if (account != null) {
boolean containsKey = userChannelMap.containsKey(account);
if (containsKey) {
Set<Channel> channelSet = userChannelMap.get(account);
channelSet.add(channel);
} else {
HashSet<Channel> set = new HashSet<>();
set.add(channel);
userChannelMap.put(account, set);
}
}
}
/**
* 移除通道
*
* @param channel
*/
public static void removeChannel(Channel channel) {
// 全局组
globalGroup.remove(channel);
// 从channel属性中读取到用户标识
AttributeKey<String> userIdAttribute = AttributeKey.valueOf(ACCOUNT);
String userId = channel.attr(userIdAttribute).get();
if (StringUtils.isNotBlank(userId)) {
if (userChannelMap.containsKey(userId)) {
Set<Channel> set = userChannelMap.get(userId);
if (set.contains(channel)) {
set.remove(channel);
}
}
}
}
public static void sendMessage(SystemMessage entity) {
Set<Channel> channelSet = userChannelMap.get(entity.getReceiver());
if (CollectionUtils.isNotEmpty(channelSet)) {
for (Channel channel : channelSet) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(entity)));
}
}
}
}
因为内嵌于主系统中,因此采用监听器的方式,通过线程启动,避免阻塞主系统,如下:
package tech.abc.platform.notification.server.listener;
import org.springframework.beans.factory.annotation.Autowired;
import tech.abc.platform.notification.server.InsideMessageServer;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
/**
* NettyServer监听器,用于启动消息服务
*
* @author wqliu
* @date 2021-2-5
**/
@WebListener
public class InsideMessageServerListener implements ServletContextListener {
/**
* 注入NettyServer
*/
@Autowired
private InsideMessageServer nettyServer;
@SuppressWarnings("AlibabaAvoidManuallyCreateThread")
@Override
public void contextInitialized(ServletContextEvent sce) {
Thread thread = new Thread(new NettyServerThread());
// 启动netty服务
thread.start();
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
}
/**
* netty服务启动线程
*/
private class NettyServerThread implements Runnable {
@Override
public void run() {
nettyServer.run();
}
}
}
最后,在平台主程序的SpringBoot启动类中,调用监听器来启动,如下:
package tech.abc.platform;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.util.StopWatch;
import tech.abc.platform.cip.config.AppConfig;
import tech.abc.platform.cip.message.framework.MessageServer;
import tech.abc.platform.framework.config.PlatformConfig;
/**
* @author wqliu
* @date 2023-3-4
*/
@SpringBootApplication(scanBasePackages = "tech.abc")
@MapperScan("tech.abc.**.mapper")
@EnableConfigurationProperties({PlatformConfig.class, AppConfig.class})
@Slf4j
@EnableRetry
public class PlatformBootApplication implements CommandLineRunner {
@Autowired
private MessageServer messageServer;
public static void main(String[] args) {
// 添加此句,避免druid连接池报警告 discard long time none received connection
System.setProperty("druid.mysql.usePingMethod", "false");
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ConfigurableApplicationContext context = new SpringApplicationBuilder(PlatformBootApplication.class)
.logStartupInfo(false)
.run(args);
stopWatch.stop();
Integer port = context.getBean(ServerProperties.class).getPort();
log.info("服务启动完成,耗时:{}s,端口号:{}", stopWatch.getTotalTimeSeconds(), port);
}
@Override
public void run(String... args) throws Exception {
// 启动消息服务端
startMessageServer();
}
/**
* 启动消息服务端
*/
private void startMessageServer() {
Runnable runnable = new Runnable() {
@Override
public void run() {
messageServer.run();
}
};
// 此处通过单线程启动netty,是为了不堵塞主应用,不需要线程池
// noinspection AlibabaAvoidManuallyCreateThread
Thread thread = new Thread(runnable);
// 启动消息服务端
thread.start();
}
}
小结
通过上述系统设计与集成工作,一二三开发平台实现了基于web socket技术的内部通知的功能。
开源平台资料
平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:[csdn专栏]
开源地址:[Gitee]
开源协议:MIT
如果您在阅读本文时获得了帮助或受到了启发,希望您能够喜欢并收藏这篇文章,为它点赞~
请在评论区与我分享您的想法和心得,一起交流学习,不断进步,遇见更加优秀的自己!