一二三应用开发平台——能力扩展:内部通知、站内信后端实现

后端实现

创建模块

首先,出于职责单一的考虑,为内部通知单设了一个模块,命名为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
如果您在阅读本文时获得了帮助或受到了启发,希望您能够喜欢并收藏这篇文章,为它点赞~
请在评论区与我分享您的想法和心得,一起交流学习,不断进步,遇见更加优秀的自己!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行者无疆1982

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值