跟着源码学IM(九):基于Netty实现一套分布式IM系统

1、本文引言

计算机编程的学习,能不能把知识学到手,讲究的是动手实践。在我编写的文章中,基本都是以实践代码验证结果为核心来讲述文章内容。

从小我就喜欢动手,就以一个即时通信的项目为例,已经基于不同技术方案实现了5、6次,仅仅为了实践技术,截图如下。

正如上图这样:

1)有些是刚学完Socket和Swing的时候,想动手试试这些技术能不能写个QQ出来;

2)也有的是因为实习培训需要完成的项目,不过在有了一些基础后,一周时间就能写完全部功能;

3)虽然这些项目在现在看上去还是丑丑的界面,以及代码逻辑可能也不是那么完善。但放在学习阶段的每一次实现中,都能为自己带来很多技术上的成长。

那么,这次借本文的机会,将IM实践的机会留给你,希望你能用的上。

接下来的内容,我会为你介绍如何开发一个IM的方方面面,包括系统架构、通信协议、单聊群聊、表情发送、UI事件驱动等,以及全套的实践源码让你可以上手学习。

2、知识准备

* 重要提示:本文不是一篇即时通讯理论文章,文章内容全部由实战代码组织而成,如果你对即时通讯(IM)技术理论了解的太少,建议先详细阅读:《新手入门一篇就够:从零开发移动端IM》。

可能有人不知道 Netty 是什么,这里简单介绍下:

Netty 是一个 Java 开源框架。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。

也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。

Netty 相当简化和流线化了网络应用的编程开发过程,例如,TCP 和 UDP 的 Socket 服务开发。

3、运行效果

聊天页面:

添加好友:

消息提醒:

4、本文源码

本文完整代码附件下载:

源码的目录结构,如下图所示:

这套 IM 代码分为了三组模块:UI、客户端、服务端。

之所以这样拆分,是为了将UI展示与业务逻辑隔离,使用事件和接口进行驱动,让代码层次更加干净整洁易于扩展和维护。

各模块的作用,具体解释如下:

5、系统设计

在这套IM中,服务端采用DDD领域驱动设计模式进行搭建。将 Netty 的功能交给 SpringBoot 进行启停控制,同时在服务端搭建控制台可以非常方便的操作通信系统,进行用户和通信管理。在客户端的建设上采用UI分离的方式进行搭建,以保证业务代码与UI展示分离,做到非常易于扩展的控制。

另外,在功能实现上包括:完美仿照微信桌面版客户端、登录、搜索添加好友、用户通信、群组通信、表情发送等核心功能。如果有对于实际需要使用的功能,可以按照这套系统框架进行扩展。

解释一下:

1)UI开发:使用JavaFx与Maven搭建UI桌面工程,逐步讲解登录框体、聊天框体、对话框、好友栏等各项UI展示及操作事件;

2)架构设计:使用DDD领域驱动设计的四层模型结构与Netty结合使用,架构出合理的分层框架(相应库表功能的设计);

3)功能实现:包括;登录、添加好友、对话通知、消息发送、断线重连等各项功能。

6、UI开发

6.1 功能划分

聊天窗体,相对于登陆窗体来说,聊天窗体的内容会比较多,同时也会相对复杂一些。

下图是聊天窗体的功能定义草图:

如上图所示:

1)首先是我们整个聊天主窗体的定义,是一块空白面板,并去掉默认的边框按钮 (最小化、退出等);

2)之后是我们左侧边栏,我们称之为条形 Bar,功能区域的实现;

3)最后添加窗体事件,当点击按钮时变换 内容面板 中的填充信息。

6.2 聊天界面

对话框选中后的内容区域展现,也就是用户之间信息发送和展现。

从整体上看这是一个联动的过程,点击左侧的对话框用户,右侧就有相应内容的填充。那么右侧被填充对话列表 ListView 需要与每一个对话用户关联,点击聊天用户的时候,是通过反复切换填充的过程。效果如下图所示。

参见上图,我解释一下:

1)点击左侧的每一个对话框体,右侧聊天框填充内容即随之变化(同时还有相应的对话名称也会也变化);

2)对话框中左侧展示好友发送的信息,右侧展示个人发送的信息(同时消息内容会随着内容的增多而增加高度和宽度);

3)最下面是文本输入框,在后面的实现里我们文本输入框采用公用的方式进行设计,当然你也可以设计为单独的个人使用。

6.3 好友列表

大家都经常使用 PC 端的微信,可以知道在好友栏里是分了几段内容的,其中包含:新的朋友、公众号、群组和最下面的好友(功能划分如下图)。

参见上图,我解释一下:

1)最上面的搜索框这部分内容不变,和前面的一样。我们目前使用的方式是 fxml 设计,例如这部分是通用功能,可以抽取出来放到代码中,设计成一个组件元素类;

2)经过我们的分析,在使用 JavaFx 组件开发为基础下,这部分是一种嵌套 ListView,也就是最底层的面板是一个 ListView,好友和群组有各是一个 ListView,这样处理后我们会很方便的进行数据填充;

3)另外这样的结构主要有利于在我们程序运行过程中,如果你添加了好友,那么我们需要将好友信息刷新到好友栏中,而在数据填充的时候,为了更加便捷高效,所以我们设计了嵌套的 ListView(如果还不是特别理解,可以从后续的代码中获得答案)。

6.4 事件定义

在桌面版 UI 开发中,为了能使 UI 与业务逻辑隔离,需要在我们把 UI 打包后提供出操作界面的展示效果的接口以及界面操作事件抽象类。

那么可以按照下图理解:

以上这些接口就是我们目前 UI 为外部提供的所有行为接口,这些接口的一个链路描述就是:打开窗口、搜索好友、添加好友、打开对话框、发送消息。

7、通信设计

7.1 系统架构

在前面我说到更适合的架构,才是符合你当下需要最好的架构。

那么怎么设计需要的架构呢?

之所以这样设计,在这个系统里有如下几点前提:

1)系统在服务端要有 web 页面进行管理通信用户以及服务端的控制和监控;

2)数据库的对象类,不要被外部污染,要有隔离性(比如:你的数据库类暴漏给外部做展示类使用了,那么现在需要增加一个字段,而这个字段又不是你数据库存在的属性。那么这个时候就已经把数据库类污染了)。

3)因为目前都是在 Java 语言下实现 Netty 通信,那么服务端与客户端都会需要使用到通信过程中的协议定义和解析。那么我们需要抽离这一层对外提供 Jar 包(利于重用,不然客户端和服务端复制同样的代码维护,就太恶心了);

4)接口、业务处理、底层服务、通信交互,要有明确的区分和实现,避免造成混乱难以维护。

结合我们上面这四点的前提,你头脑中有什么模型结构体现了?以及相应的技术栈选择上是否有计划了?

接下来我会介绍两种架构设计的模型,一种是你非常熟悉的 MVC,另外一种是你可能听说过的 DDD 领域驱动设计。

7.2 通信协议

从图稿上来看,我们在传输对象的时候需要在传输包中添加一个“帧标识”以此来判断当前的业务对象是哪个对象,也就可以让我们的业务更加清晰,避免使用大量的 if 语句判断。

协议框架:

agreement

└── src

    ├── main

    │   ├── java

    │   │   └── org.itstack.naive.chat

    │   │       ├── codec

    │   │       │    ├── ObjDecoder.java

    │   │       │    └── ObjEncoder.java

    │   │       ├── protocol

    │   │       │    ├── demo

    │   │       │    ├── Command.java

    │   │       │    └── Packet.java

    │   │       └── util

    │   │             └── SerializationUtil.java

    │   ├── resources   

    │   │   └── application.yml

    │   └── webapp

    │       └── chat

    │       └── res

    │       └── index.html

    └── test

         └── java

             └── org.itstack.demo.test

                 └── ApiTest.java

协议包:

public abstract class Packet {

    private final static Map<Byte, Class<? extendsPacket>> packetType = new ConcurrentHashMap<>();

    static{

        packetType.put(Command.LoginRequest, LoginRequest.class);

        packetType.put(Command.LoginResponse, LoginResponse.class);

        packetType.put(Command.MsgRequest, MsgRequest.class);

        packetType.put(Command.MsgResponse, MsgResponse.class);

        packetType.put(Command.TalkNoticeRequest, TalkNoticeRequest.class);

        packetType.put(Command.TalkNoticeResponse, TalkNoticeResponse.class);

        packetType.put(Command.SearchFriendRequest, SearchFriendRequest.class);

        packetType.put(Command.SearchFriendResponse, SearchFriendResponse.class);

        packetType.put(Command.AddFriendRequest, AddFriendRequest.class);

        packetType.put(Command.AddFriendResponse, AddFriendResponse.class);

        packetType.put(Command.DelTalkRequest, DelTalkRequest.class);

        packetType.put(Command.MsgGroupRequest, MsgGroupRequest.class);

        packetType.put(Command.MsgGroupResponse, MsgGroupResponse.class);

        packetType.put(Command.ReconnectRequest, ReconnectRequest.class);

    }

    public static Class<? extends Packet> get(Byte command) {

        return packetType.get(command);

    }


    /**

     * 获取协议指令

     *

     * @return 返回指令值

     */

    public abstract Byte getCommand();

}

7.3 添加好友

从上面的流程图中可以看到,这里包含了两部分内容:搜索好友和添加好友。

当添加完成好友后,好友会出现到我们的好友栏中。

并且这里面我们采用的是单方面同意加好友,也就是你添加一个好友的时候,对方也同样有你的好友信息。

如果你的业务中是需要添加好友并同意的,那么可以在发起好友添加的时候,添加一条状态信息,请求加好友。对方同意后,两个用户才能成为好友并进行通信。

添加好友的样例代码:

public class AddFriendHandler extends MyBizHandler<AddFriendRequest> {

    public AddFriendHandler(UserService userService) {

        super(userService);

    }

    @Override

    public void channelRead(Channel channel, AddFriendRequest msg) {

        // 1. 添加好友到数据库中[A->B B->A]

        List<UserFriend> userFriendList = newArrayList<>();

        userFriendList.add(newUserFriend(msg.getUserId(), msg.getFriendId()));

        userFriendList.add(newUserFriend(msg.getFriendId(), msg.getUserId()));

        userService.addUserFriend(userFriendList);

        // 2. 推送好友添加完成 A

        UserInfo userInfo = userService.queryUserInfo(msg.getFriendId());

        channel.writeAndFlush(newAddFriendResponse(userInfo.getUserId(), userInfo.getUserNickName(), userInfo.getUserHead()));

        // 3. 推送好友添加完成 B

        Channel friendChannel = SocketChannelUtil.getChannel(msg.getFriendId());

        if(null== friendChannel) return;

        UserInfo friendInfo = userService.queryUserInfo(msg.getUserId());

        friendChannel.writeAndFlush(newAddFriendResponse(friendInfo.getUserId(), friendInfo.getUserNickName(), friendInfo.getUserHead()));

    }

}

7.4 消息应答

从整体的流程可以看到:在用户发起好友、群组通信的时候,会触发一个事件行为,接下来客户端向服务端发送与好友的对话请求。

服务端收到对话请求后:如果是好友对话,那么需要保存与好友的通信信息到对话框中。同时通知好友,我与你要通信了。你在自己的对话框列表中,把我加进去。

如果是群组通信:是可以不用这样通知的,因为不可能把还没有在线的所有群组用户全部通知(人家还没登录呢),所以这部分只需要在用户上线收到信息后,创建出对话框到列表中即可。可以仔细理解下,同时也可以想想其他实现的方式。

消息应答样例代码:

public class MsgHandler extends MyBizHandler<MsgRequest> {

    public MsgHandler(UserService userService) {

        super(userService);

    }

    @Override

    public void channelRead(Channel channel, MsgRequest msg) {

        logger.info("消息信息处理:{}", JSON.toJSONString(msg));

        // 异步写库

        userService.asyncAppendChatRecord(newChatRecordInfo(msg.getUserId(), msg.getFriendId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));

        // 添加对话框[如果对方没有你的对话框则添加]

        userService.addTalkBoxInfo(msg.getFriendId(), msg.getUserId(), Constants.TalkType.Friend.getCode());

        // 获取好友通信管道

        Channel friendChannel = SocketChannelUtil.getChannel(msg.getFriendId());

        if(null== friendChannel) {

            logger.info("用户id:{}未登录!", msg.getFriendId());

            return;

        }

        // 发送消息

        friendChannel.writeAndFlush(newMsgResponse(msg.getUserId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));

    }

}

7.5 断线重连

从上述流程中我们看到:当网络连接断开以后,会像服务端发送重新链接的请求。 那么在这个发起链接的过程,和系统的最开始链接有所区别。断线重连是需要将用户的 ID 信息一同发送给服务端,好让服务端可以去更新用户与通信管道 Channel 的绑定关系。

同时还需要更新群组内的重连信息,把用户的重连加入群组映射中。此时就可以恢复用户与好友和群组的通信功能。

消息应答样例代码:

// Channel 状态定时巡检;3 秒后每 5 秒执行一次

scheduledExecutorService.scheduleAtFixedRate(() -> {while(!nettyClient.isActive()) {System.out.println("通信管道巡检:通信管道状态"+ nettyClient.isActive());

        try{System.out.println("通信管道巡检:断线重连 [Begin]");

            Channel freshChannel = executorService.submit(nettyClient).get();

            if(null== CacheUtil.userId) continue;

            freshChannel.writeAndFlush(newReconnectRequest(CacheUtil.userId));

        } catch(InterruptedException | ExecutionException e) {System.out.println("通信管道巡检:断线重连 [Error]");}

    }

}, 3, 5, TimeUnit.SECONDS);

相关文章学习:

为何基于TCP协议的移动端IM仍然需要心跳保活机制?

一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等

7.6 集群通信

如上图所示,我是这样实现IM集群通信的:

1)跨服务之间案例采用redis的发布和订阅进行传递消息,如果你是大型服务可以使用zookeeper;

2)用户A在发送消息给用户B时候,需要传递B的channeId,以用于服务端进行查找channeId所属是否自己的服务内;

3)单台机器也可以启动多个Netty服务,程序内会自动寻找可用端口。

8、本文小结

此IM系统涉及到的技术栈内容较多:Netty4.x、SpringBoot、Mybatis、Mysql、JavaFx、layui等技术栈的使用,以及整个系统框架结构采用DDD四层架构+Socket模块的方式进行搭建,所有的UI都以前后端分离事件驱动方式进行设计。在这个过程中只要你能坚持学习下来,那么一定会收获非常多的内容。足够吹牛啦!

任何一个新技术栈的学习过程都会包括这样一条路线:运行HelloWorld、熟练使用API、项目实践以及最后的深度源码挖掘。 那么在听到这样一个需求时候,Java程序员肯定会想到一些列的技术知识点来填充我们项目中的各个模块(例如:界面用JavaFx、Swing等,通信用Socket或者知道Netty框架、服务端控制用MVC模型加上SpringBoot等)。但是怎么将这些各个技术栈合理的架设出我们的系统确是学习、实践、成长过程中最重要的部分。

好了,IM开发实际上涉及的知识维度非常多,限于篇幅就不在这里啰嗦更多,各位读者务必对着源码同步进行学习,这样效果会更好。

9、参考资料

[4] Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!

[5] 史上最通俗Netty框架入门长文:基本介绍、环境搭建、动手实战

[6] 理论联系实际:一套典型的IM通信协议设计详解

[7] 浅谈IM系统的架构设计

[8] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容