netty+springBoot+webSocket+redis实现各个客户端之间通信、及时聊天等功能

说明:本文主要写的是适用于中小型企业的消息中心(主要是即时聊天功能)的一些设计思路,文末有完整项目地址,仅有后端代码。本人经验尚浅,有错误、不妥之处望各位大神与本人联系,不胜感激。文章中有些许借用的地方,如侵权,请及时联系本人,立马删除。
邮箱:[email protected]

本项目用到的技术

netty框架,为什么要使用netty框架呢?netty框架是基于Nio的,那和传统的Bio有什么区别呢?

Bio

BIO 有的称之为 basic(基本) IO,有的称之为 block(阻塞) IO,主要应用于文件 IO 和网络 IO, 这里不再说文件 IO, 在 JDK1.4 之前,我们建立网络连接的时候只能采用 BIO,需要先在服务端启动一个 ServerSocket,然后在客户端启动 Socket 来对服务端进行通信,默认情况下服务端需要对每个请求建立一个线程等待请求,而客户端发送请求后,先咨询服务端是否有线程响应,如果没有则会一直等待或者遭到拒绝,如果有的话,客户端线程会等待请求结束后才继续执行, 这就是阻塞式 IO

 //BIO 服务器端程序
    public class TCPServer {
        public static void main(String[] args) throws Exception {
            //1.创建 ServerSocket 对象
            ServerSocket ss = new ServerSocket(9999);
            while (true) {
                //2.监听客户端
                Socket s = ss.accept(); //阻塞
                // 3.从连接中取出输入流来接收消息
                InputStream is = s.getInputStream();
                //阻塞
                byte[] b = new byte[10];
                is.read(b);
                String clientIP = s.getInetAddress().getHostAddress();
                System.out.println(clientIP + "说:" + new String(b).trim());
                //4.从连接中取出输出流并回话
                OutputStream os = s.getOutputStream();
                os.write("hello".getBytes());
                //5.关闭
                s.close();
            }
        }
    }

上述代码编写了一个服务器端程序,绑定端口号 9999,accept 方法用来监听客户端连接, 如果没有客户端连接,就一直等待,程序会阻塞到这里。

 //BIO 客户端程序
    public class TCPClient {
        public static void main(String[] args) throws Exception {
            while (true) {
                //1.创建 Socket 对象
                Socket s = new Socket("127.0.0.1", 9999);
                //2.从连接中取出输出流并发消息 
                OutputStream os = s.getOutputStream();
                System.out.println("请输入:");
                Scanner sc = new Scanner(System.in);
                String msg = sc.nextLine();
                os.write(msg.getBytes());
                //3.从连接中取出输入流并接收回话 
                InputStream is = s.getInputStream();
                //阻塞 
                byte[] b = new byte[20];
                is.read(b);
                System.out.println("说:" + new String(b).trim());
                //4.关闭
                s.close();
            }
        }
    }

上述代码编写了一个客户端程序,通过 9999 端口连接服务器端,getInputStream 方法用来 等待服务器端返回数据,如果没有返回,就一直等待,程序会阻塞到这里。
结果请拷贝到自己idea自行查看

这个仅仅只是简单的演示代码,如果真正的使用怎么做呢?加线程呗,一个用户过来就新建一个线程,然后每个线程里面一个while循环阻塞在里面,这是很恐怖的一件事,这些就会带来下面三个问题:

1、资源受限,大量的线程阻塞在那里,对于服务器来说是很浪费资源的一件事。
2、线程切换频繁,我们知道java线程如果优先级相同是抢占式的也就是随机的,线程数量过多,对于单核cpu切换来说是很影响性能的。
3、上面例子可以看到Bio是以byte为单位的。

Nio

Jdk1.4之后提出了Nio,NIO 和 BIO 有着相同的目的和作用,但是它们的实现方式完全不同,BIO 以流的方式处理数据,**而 NIO 以块的方式处理数据,**块 I/O 的效率比流 I/O 高很多。另外,NIO 是非阻塞式的, 这一点跟 BIO 也很不相同,使用它可以提供非阻塞式的高伸缩性网络。 NIO 主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)。传统的 BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通 道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。

这里借大神“闪电侠”举的例子说明一下:
(强烈推荐Netty入门学习小册https://juejin.im/book/5b4bc28bf265da0f60130116
在一家幼儿园里,小朋友有上厕所的需求,小朋友都太小以至于你要问他要不要上厕所,他才会告诉你。幼儿园一共有 100 个小朋友,有两种方案可以解决小朋友上厕所的问题:

每个小朋友配一个老师。每个老师隔段时间询问小朋友是否要上厕所,如果要上,就领他去厕所,100 个小朋友就需要 100 个老师来询问,并且每个小朋友上厕所的时候都需要一个老师领着他去上,这就是IO模型,一个连接对应一个线程。
所有的小朋友都配同一个老师。这个老师隔段时间询问所有的小朋友是否有人要上厕所,然后每一时刻把所有要上厕所的小朋友批量领到厕所,这就是 NIO 模型,所有小朋友都注册到同一个老师,对应的就是所有的连接都注册到一个线程,然后批量轮询。

这就是 NIO 模型解决线程资源受限的方案,实际开发过程中,我们会开多个线程,每个线程都管理着一批连接,相对于 IO 模型中一个线程管理一条连接,消耗的线程资源大幅减少。

但是传统的Nio网络编程是很麻烦的一件事,想去了解的自行百度,这里不过多赘述。

那netty到底是什么?官方解释:Netty 是一个异步事件驱动的网络应用框架,用于快速开发可维护的高性能服务器和客户端。netty就是一个对Jdk的Nio进行封装的一个框架,和其他框架一样,目的就是让你用得爽。

webSocket

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
(注意:如果对于协议很了解的话,这里也可以使用自己写的协议,但是对于中小型企业或者非专门做即时聊天的企业来说,这是得不偿失的)

这里主要注意几个大坑,包括后面也会说这个问题

1、返回对象的包裹

//第一个坑,这里整合了webSocket对象后一定要用TextWebSocketFrame或者其他WebSocketFrame对象进行包裹
  public void sendWebSocket(Channel channel, String res) {
        if (channel != null && channel.isActive()) {
            channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(res)));
        }
    }

2、跨域问题的处理,如果跨域没有处理,一切等于0,这个坑一定要注意

 /**
     * Http返回
     *
     * @param ctx
     * @param request
     * @param response
     */
    private static void sendHttpResponse(ChannelHandlerContext ctx, FullHttpRequest request, FullHttpResponse response) {
        // 返回应答给客户端
        if (response.status().code() != 200) {
            ByteBuf buf = Unpooled.copiedBuffer(response.status().toString(), CharsetUtil.UTF_8);
            response.content().writeBytes(buf);
            buf.release();
            //允许跨域访问 设置头部信息
            response.headers().set(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
            response.headers().set(ACCESS_CONTROL_ALLOW_METHODS, "GET,POST,PUT,DELETE");
            response.headers().set(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
            response.headers().set(ACCESS_CONTROL_ALLOW_HEADERS, "Origin, X-Requested-With, Content-Type, Accept");
            HttpHeaders.setContentLength(response, response.content().readableBytes());
        }

        // 如果是非Keep-Alive,关闭连接 保持Keep-Alive
        ChannelFuture f = ctx.channel().writeAndFlush(response);
        if (!HttpHeaders.isKeepAlive(request) || response.status().code() != 200) {
            f.addListener(ChannelFutureListener.CLOSE);
        }
    }

数据库设计

主要技术就是Netty框架整合WebSocket,现在来看看我们的需求。即时聊天、系统消息推送、各个客户端之间的通信。功能要做到消息一对多、多对多,并且保证即时性,那用户看不到的消息就存储在数据库即可,也可以用其他的存储方法,比如mongodb、redis等。
数据库表结构
数据库设计是基于核心表chat实现聊天的,简单说明一下:A和B需要聊天,那么他们必须有一个会话Chat才能聊天,这个会话可以是A建立也可以是B建立,但是必须有且只有一个这是一对一聊天的设计。chat_user表则是记录这个会话关联的人员有那些人。同理如果是群组聊天则是和chat_group表有关系,而chat_group_user则是记录这个群组有那些人,注意看chat_group表中有个属性很重要的字段cgro_chat_uuid,也就是说群组依然是基于chat表的。这样做的好处是,我们做业务处理时不用管过多的属性,只需要关注chat_uuid即可,不用去管到底前端给我们的到底是群组聊天还是一对一聊天,包括对于聊天记录的处理也会很方便。
如果你要使用这个项目,那么只需要在这个数据库设计基础上修改成你自己需要的业务逻辑即可。

好了,技术确定了,数据库设计也ok了,接下来直接看看核心代码。本项目我们最需要去关注的是netty中的Channel对象和用户的映射关系

public class ChannelUtil {

    //公司层封装
    private Map<Long, ConcurrentHashMap<Long, Channel>> echaMap = null;

    private ChannelUtil() {
        this.echaMap = new HashMap<>();
    }

    private static volatile ChannelUtil instance = null;

    public static ChannelUtil getInstance() {
        if (instance == null) {
            synchronized (ChannelUtil.class) {
                instance = new ChannelUtil();
                return instance;
            }
        }
        return instance;
    }

    //建立会话,保存连接映射
    public void bindChannel(Long enteUuid, Long suseUuid, Channel channel) {
        //如果当前公司层级没有
        if (this.echaMap.get(enteUuid) == null) {
            ConcurrentHashMap<Long, Channel> chalMap = new ConcurrentHashMap<>();
            chalMap.put(suseUuid, channel);
            echaMap.put(enteUuid, chalMap);
            channel.attr(Attributes.SESSION).set(suseUuid);
            channel.attr(Attributes.ENTE).set(enteUuid);

        } else if (suseUuid != null && channel != null) { //如果公司层级有
            echaMap.get(enteUuid).put(suseUuid, channel);
            channel.attr(Attributes.SESSION).set(suseUuid);
            channel.attr(Attributes.ENTE).set(enteUuid);
        }
    }



    //移除管道
    public void unBindChannel(Channel channel, Long suseUuid) {
        Long enteUuid = channel.attr(Attributes.ENTE).get();
        this.echaMap.get(enteUuid).remove(suseUuid);
    }

    //是否登錄
    public boolean hasLogin(Channel channel) {
        return channel.attr(Attributes.SESSION).get() == null;
    }

    //用戶Uuid
    public Long getSuseUuid(Channel channel) {
        return channel.attr(Attributes.SESSION).get();
    }

    public Long getEnteUuid(Channel channel) {
        return channel.attr(Attributes.ENTE).get();
    }

    //获取管道
    public Channel getChannel(Channel channel, Long suseUuid) {
        Long enteUuid = channel.attr(Attributes.ENTE).get();
        return this.echaMap.get(enteUuid).get(suseUuid);
    }

    public Channel getChannel(Long suseUuid) {
        for (ConcurrentHashMap<Long, Channel> chalMap : echaMap.values()) {
            for (Channel item : chalMap.values()) {
                return chalMap.get(suseUuid);
            }
        }
        return null;
    }

    //获取所有管道组
    public ChannelGroup getChannelGroup(ChannelHandlerContext ctx) {
        ChannelGroup channelGroup = new DefaultChannelGroup(ctx.executor());
        for (ConcurrentHashMap<Long, Channel> chalMap : echaMap.values()) {
            for (Channel item : chalMap.values()) {
                channelGroup.add(item);
            }
        }
        return channelGroup;
    }

    //获取公司下的所有管道映射
    public ConcurrentHashMap<Long, Channel> getChalMap(Long enteUuid) {
        return this.echaMap.get(enteUuid);
    }
    
    //获取所有管道,主要用于系统消息
    public List<Channel> getAllChannel() {
        List<Channel> channelList = new ArrayList<Channel>();
        for (ConcurrentHashMap<Long, Channel> chalMap : echaMap.values()) {
            for (Channel item : chalMap.values()) {
                if (item != null && item.isActive()) {
                    channelList.add(item);
                }
            }
        }
        return channelList;
    }
}

因为考虑到本项目可能会用于多个公司,也就是说A公司用户和B公司用户是不能进行通信的,所以用了两个map进行管理,这里的公司echaMap使用ConcurrentHashMap或许会更好,具体根据实际情况来看。echaMap则是存储的每个公司的uuid和chalMap对象,chalMap存储的就是每个用户的UUid和Channel对象。这里说一下channel.attr(Attributes.SESSION)是什么东西

public interface Attributes {
    AttributeKey<Boolean> LOGIN = AttributeKey.valueOf("LOGIN");
    AttributeKey<Long> SESSION = AttributeKey.valueOf("SESSION");
    AttributeKey<Long> ENTE = AttributeKey.valueOf("ENTE");
}

其实这个东西就是channel对象的属性而已可以把他看成一个Map,可以方便的通过channel.attr(Attributes.SESSION).get()方法拿到当前channel的key为SESSION的值

好了,存储结构确定了,现在来看看一些具体的代码优化问题(这里的优化笔者通过学习上面推荐的小册而使用的,效果非常好https://juejin.im/book/5b4bc28bf265da0f60130116

那么前端和后端怎么确定进行什么操作呢?比如:新建群组、修改群组名、A发消息给B、A查看在线好友、A拉取最近聊天。。。。。。
我们最先想到的是加状态
假设新建群组 状态 “openGroup”
修改群组名 状态 “editGroupUser”
往后依次。。。。
那么我们的代码会变成这个样子

            if ("chat".equals(reqOb.getAction())) {
                chat(reqOb.getParams(), ctx.channel());
            } else if ("chatOpen".equals(reqOb.getAction())) {//开启会话
                openChat(reqOb.getParams(), ctx.channel());
            } else if ("chatList".equals(reqOb.getAction())) {//获取最近会话
                chatList(reqOb.getParams(), ctx.channel());
            } else if ("listFriend".equals(reqOb.getAction())) {//获取好友
                listFriend(reqOb.getParams(), ctx.channel());
            } else if ("openGroup2".equals(reqOb.getAction())) {//创建一个群
                openGroup2(reqOb.getParams(), ctx.channel());
            } else if ("listGroup".equals(reqOb.getAction())) {//获取群
                listGroup(reqOb.getParams(), ctx.channel());
            } else if ("outGroup".equals(reqOb.getAction())) {//退出群
                outGroup(reqOb.getParams(), ctx.channel());
            } else if ("addGroup".equals(reqOb.getAction())) {//增加群成员
               addGroup(reqOb.getParams(), ctx.channel());
            } else if ("delGroup".equals(reqOb.getAction())) {//删除群成员
                delGroup(reqOb.getParams(), ctx.channel());
            } else if ("editGroup".equals(reqOb.getAction())) {//修改群
                editGroup(reqOb.getParams(), ctx.channel());
            } else if ("editGroupUser".equals(reqOb.getAction())) {//修改群名片
                editGroupUser(reqOb.getParams(), ctx.channel());
            } else if ("listMsg".equals(reqOb.getAction())) {//获取会话消息列表
                listMsg(reqOb.getParams(), ctx.channel());
            } else if ("updateGroup".equals(reqOb.getAction())) {//修改群成员
                editUpdateGroupUser(reqOb.getParams(), ctx.channel());
            } else if ("schat".equals(reqOb.getAction())) {//系统消息
                schat(reqOb.getParams(), ctx);
            }

大量的if-else对于程序来说是很耗费性能的,假设发送的系统消息,那么要一直跑完所有if-else语句,再说这个看到是很让人难受。当然会有人杠用switch…case来解决,这也没什么问题,但是有一种更好的处理方式,netty中有种Handler的方式,我们完全可以根据不同的业务逻辑实现不同的Handler,然后建一个Handel分发类,只需要根据前端过来的状态分发给不同的handler即可

public class SpliterHandler extends SimpleChannelInboundHandler<Request> {

    private static Map<String, SimpleChannelInboundHandler> handlerMap = null;

    //解码映射表
    Map<String, Class> codecMap = null;
    private static volatile SpliterHandler instance = null;

    public static SpliterHandler getInstance() {
        if (instance == null) {
            synchronized (SpliterHandler.class) {
                instance = new SpliterHandler();
                return instance;
            }
        }
        return instance;
    }

    private SpliterHandler() {
        handlerMap = new HashMap<>();

        handlerMap.put("chat", new ChatHandler());
        handlerMap.put("chatOpen", new ChatOpenHandler());
        handlerMap.put("chatList", new ChatListHandler());
        handlerMap.put("listFriend", new ListFriendHandler());
        handlerMap.put("openGroup2", new OpenGroup2Handler());
        handlerMap.put("listGroup", new ListGroupHandler());
        handlerMap.put("outGroup", new OutGroupHandler());
        handlerMap.put("updateGroup",new EditUpdateGroupUserHandler());
        handlerMap.put("editGroup", new EditGroupHandler());
        handlerMap.put("editGroupUser", new EditGroupUserHandler());
        handlerMap.put("listMsg", new ListMsgHandler());
        handlerMap.put("schat", new SchatHandler());

        codecMap = new HashMap<String, Class>();

        codecMap.put("chat", MessageParams.class);
        codecMap.put("chatOpen", OpenParams.class);
        codecMap.put("listFriend", Long.class);
        codecMap.put("chatList", Long.class);
        codecMap.put("openGroup2", OpenGroupParams2.class);
        codecMap.put("listGroup", Long.class);
        codecMap.put("outGroup", OutGroupParams.class);
        codecMap.put("editGroup", EditChatGroupParams.class);
        codecMap.put("editGroupUser", EditChatGroupUserParams.class);
        codecMap.put("listMsg", SearchMessageParams.class);
        codecMap.put("updateGroup", EditUpdateChatGroupParams.class);
        codecMap.put("schat", SChatParams.class);
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Request reOb) throws Exception {
        Object msg = JSON.parseObject(reOb.getParams(), codecMap.get(reOb.getAction()));
        handlerMap.get(reOb.getAction()).channelRead(ctx, msg);
    }

}

这样我们只需要从map中取出对应的handler即可,速度远比大量if-else快。单例模式保证了不会出现大量的实例占用内存资源。

redis实现各个客户端的通信

客户端的通信主要是使用redis实现的,假设客户端A要发送一个系统消息到B、C、D3个客户端,最简单的方法就是使用redis进行通信了。这里主要用到了生产者/消费者模式对于的是jedis的Publish/psubscribe方法。Jedis有两种订阅模式:subsribe(一般模式设置频道)和psubsribe(使用模式匹配来设置频道)

下面是核心代码:

 /**
     * 发布消息
     * @param channel
     * @param message
     */
    public void publish(String channel,String message){
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            jedis.publish(channel,message);
        }catch (Exception e){
            LOGGER.error(e.getMessage(),e.getStackTrace());
        }finally {
            if (jedis != null){
                jedis.close();
            }
        }
    }
 /**
     * 订阅消息
     * @param channel
     */
    public void psubscribe(String channel,CustomPubSub pubSub){
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            jedis.psubscribe(pubSub, channel);
        }catch (Exception e){
            LOGGER.error(e.getMessage(),e.getStackTrace());
        }finally {
            if (jedis != null){
                jedis.close();
            }
        }
    }

    /**
     * redis消息监听
     */
    @Async("taskExecutor")
    public void msgMonitor() {
        redisUtil.psubscribe("topic", new CustomPubSub() {
            @Override
            public void onPMessage(String pattern, String channel, String message) {
                super.onPMessage(pattern, channel, message);
                Topic topic = JSON.parseObject(message, Topic.class);
                if (Topic.TOPIC_WS.equals(topic.getAction())) {
                    topicService.msgToWebScoket(topic);
                } else if (Topic.TOPIC_WX.equals(topic.getAction())) { //调用微信服务等 其他业务
//                    topicService.MsgToWX(topic);
                }
            }
        });
    }
}

这样就可以使用redis实现各个客户端之间的通信然后根据不同的message实现不同的业务逻辑,是不是很简单

到此,本文就简单的说到这里,具体的一些代码由于篇幅有限请到gitHub查看https://github.com/dengyu123456/messagecenter.git

原创文章 3 获赞 7 访问量 1058

猜你喜欢

转载自blog.csdn.net/qq_30765051/article/details/101815475