# 给学习加点实践,开发一个分布式IM(即时通信)系统!

作者:小傅哥
博客:https://bugstack.cn (opens new window)

沉淀、分享、成长,让自己和他人都能有所收获!😄

# 一、前言

这知识学的,根本没有忘的快呀?!

是不是感觉很多资料,点收藏起来爽看视频时候嗨读文章当时会,只要过了那个劲,就完了,根本不记得这里面都讲了啥。时间浪费了,东西还没学到手,这是为啥?

其实因为学习也分为上策、中策和下策:

  • 下策:眼睛看就行,坐着、窝着、躺着,都行,反正也不累,还能一边回复下吹水的微信群
  • 中策:看完的资料做笔记整理归纳,长期积累资料
  • 上策:实践、上手、应用、调试、归纳、整理资料,总结经验输出文档

综上,下策学起来很快感觉自己好像会了不少,中策有点要动手了懒不想动,上策就很耗时耗力了要自己对每一个知识点都能事必躬亲到亲力亲为。就这样你在学习的时候不自觉的就选择了下策,因此其实并没有学到什么。

学习能把知识学到手,讲究的是实践,在小傅哥编写的文章中,基本都是以实践代码验证结果为核心,讲述文章内容。😁从小我就喜欢动手,就以一个即时通信的项目为例,已经基于不同技术方案实现了5、6次,仅为了实践技术,截图如下:

  • 有些是刚学完Socket和Swing的时候,想动手试试这些技术能不能写个QQ出来。
  • 也有的是因为实习培训需要完成的项目,不过在有了一些基础后,一周时间就能写完全部功能。
  • 虽然这些项目在现在看上去还是丑丑的界面,以及代码逻辑可能也不是那么完善。但放在学习阶段的每一次实现中,都能为自己带来很多技术上的成长。

那么,这次IM实践的机会给你,希望你能用的上!接下来我会给你介绍一个IM的系统架构、通信协议、单聊群聊、表情发送、UI事件驱动等各项内容,以及提供全套的源码让你可以上手学习。

# 二、演示

在开始学习之前,先给大家演示下这套仿照PC端微信界面的IM系统运行效果。

聊天页面

添加好友

视频演示

https://www.bilibili.com/video/BV1BZ4y1W7fC (opens new window)

# 三、系统设计

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

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

  • UI开发:使用JavaFxMaven搭建UI桌面工程,逐步讲解登录框体、聊天框体、对话框、好友栏等各项UI展示及操作事件。从而在这一章节中让Java 程序员学会开发桌面版应用。
  • 架构设计:在这一章节中我们会使用DDD领域驱动设计的四层模型结构与Netty结合使用,架构出合理的分层框架。同时还有相应库表功能的设计。相信这些内容学习后,你一定也可以假设出更好的框架。
  • 功能实现:这部分我们主要将通信中的各项功能逐步实现,包括;登录、添加好友、对话通知、消息发送、断线重连等各项功能。最终完成整个项目的开发,同时也可以让你从实践中学会技能。

# 四、UI开发

# 1. 整体结构定义、侧边栏

聊天窗体,相对于登陆窗体来说,聊天窗体的内容会比较多,同时也会相对复杂一些。因此我们会分章节的逐步来实现这些窗体以及事件和接口功能。在本篇文章中我们会主要讲解聊天框体的搭建以及侧边栏 UI 开发。

  • 首先是我们整个聊天主窗体的定义,是一块空白面板,并去掉默认的边框按钮 (最小化、退出等)
  • 之后是我们左侧边栏,我们称之为条形 Bar,功能区域的实现。
  • 最后添加窗体事件,当点击按钮时变换 内容面板 中的填充信息。

# 2. 对话聊天框

对话框选中后的内容区域展现,也就是用户之间信息发送和展现。从整体上看这是一个联动的过程,点击左侧的对话框用户,右侧就有相应内容的填充。那么右侧被填充对话列表 ListView 需要与每一个对话用户关联,点击聊天用户的时候,是通过反复切换填充的过程。

  • 点击左侧的每一个对话框体,右侧聊天框填充内容即随之变化。同时还有相应的对话名称也会也变化。
  • 对话框中左侧展示好友发送的信息,右侧展示个人发送的信息。同时消息内容会随着内容的增多而增加高度和宽度。
  • 最下面是文本输入框,在后面的实现里我们文本输入框采用公用的方式进行设计,当然你也可以设计为单独的个人使用。

# 3. 好友栏

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

  • 最上面的搜索框这部分内容不变,和前面的一样。我们目前使用的方式是 fxml 设计,例如这部分是通用功能,可以抽取出来放到代码中,设计成一个组件元素类。
  • 经过我们的分析,在使用 JavaFx 组件开发为基础下,这部分是一种嵌套 ListView,也就是最底层的面板是一个 ListView,好友和群组有各是一个 ListView,这样处理后我们会很方便的进行数据填充。
  • 另外这样的结构主要有利于在我们程序运行过程中,如果你添加了好友,那么我们需要将好友信息刷新到好友栏中,而在数据填充的时候,为了更加便捷高效,所以我们设计了嵌套的 ListView。如果还不是特别理解,可以从后续的代码中获得答案。

# 4. 事件定义

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

序号 接口名 描述
1 void doShow() 打开窗口
2 void setUserInfo(String userId, String userNickName, String userHead) 设置登陆用户 ID、昵称、头像
3 void addTalkBox(int talkIdx, Integer talkType, String talkId, String talkName, String talkHead, String talkSketch, Date talkDate, Boolean selected) 填充对话框列表
4 void addTalkMsgUserLeft(String talkId, String msg, Date msgData, Boolean idxFirst, Boolean selected, Boolean isRemind) 填充对话框消息 - 好友 (别人的消息)
  • 以上这些接口就是我们目前 UI 为外部提供的所有行为接口,这些接口的一个链路描述就是;打开窗口、搜索好友、添加好友、打开对话框、发送消息。

# 五、通信设计

# 1. 系统架构

在前面我们说到更适合的架构,才是符合你当下需要最好的架构。那么怎么设计这样架构呢,基本就是要找到符合点的目标。我们之所以这样设计是为什么,那么在这个系统里有如下几点;

  • 我们系统在服务端要有 web 页面进行管理通信用户以及服务端的控制和监控。
  • 数据库的对象类,不要被外部污染,要有隔离性。比如说;你的数据库类暴漏给外部做展示类使用了,那么现在需要增加一个字段,而这个字段又不是你数据库存在的属性。那么这个时候就已经把数据库类污染了。
  • 因为目前我们都是在 Java 语言下实现 Netty 通信,那么服务端与客户端都会需要使用到通信过程中的协议定义和解析。那么我们需要抽离这一层对外提供 Jar 包。
  • 接口、业务处理、底层服务、通信交互,要有明确的区分和实现,避免造成混乱难以维护。

结合我们上面这四点的目标,你头脑中有什么模型结构体现了呢?以及相应的技术栈选择上是否有计划了?接下来我们会介绍两种架构设计的模型,一种是你非常熟悉的 MVC,另外一种是你可能听说过的 DDD 领域驱动设计。

# 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

协议包

public abstract class Packet {

    private final static Map<Byte, Class<? extends Packet>> 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();

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 3. 添加好友

  • 从上面的流程中可以看到,这里包含了两部分内容;(1) 搜索好友,(2) 添加好友。当天就完成好友后,好友会出现到我们的好友栏中。
  • 并且这里面我们采用的是单方面同意加好友,也就是你添加一个好友的时候,对方也同样有你的好友信息。
  • 如果你的业务中是需要添加好友并同意的,那么可以在发起好友添加的时候,添加一条状态信息,请求加好友。对方同意后,两个用户才能成为好友并进行通信。

添加好友,案例代码

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 = new ArrayList<>();
        userFriendList.add(new UserFriend(msg.getUserId(), msg.getFriendId()));
        userFriendList.add(new UserFriend(msg.getFriendId(), msg.getUserId()));
        userService.addUserFriend(userFriendList);
        // 2. 推送好友添加完成 A
        UserInfo userInfo = userService.queryUserInfo(msg.getFriendId());
        channel.writeAndFlush(new AddFriendResponse(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(new AddFriendResponse(friendInfo.getUserId(), friendInfo.getUserNickName(), friendInfo.getUserHead()));
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 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(new ChatRecordInfo(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(new MsgResponse(msg.getUserId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 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(new ReconnectRequest(CacheUtil.userId));
        } catch (InterruptedException | ExecutionException e) {System.out.println("通信管道巡检:断线重连 [Error]");}
    }
}, 3, 5, TimeUnit.SECONDS);
1
2
3
4
5
6
7
8
9

# 6. 集群通信

  • 跨服务之间案例采用redis的发布和订阅进行传递消息,如果你是大型服务可以使用zookeeper
  • 用户A在发送消息给用户B时候,需要传递B的channeId,以用于服务端进行查找channeId所属是否自己的服务内
  • 单台机器也可以启动多个Netty服务,程序内会自动寻找可用端口

# 六、源码下载

本项目是作者小傅哥使用JavaFx、Netty4.x、SpringBoot、Mysql等技术栈和偏向于DDD领域驱动设计方式,搭建的仿桌面版微信实现通信核心功能。

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

序号 工程 介绍
1 itstack-naive-chat-ui 使用JavaFx开发的UI端,在我们的UI端中提供了;登录框体、聊天框体,同时在聊天框体中有大量的行为交互界面以及接口和事件。最终我的UI端使用Maven打包的方式向外提供Jar包,以此来达到UI界面与业务行为流程分离。
2 itstack-naive-chat-client 客户端是我们的通信核心工程,主要使用Netty4.x作为我们的socket框架来完成通信交互。并且在此工程中负责引入UI的Jar包,完成UI定义的事件(登录验证、搜索添加好友、对话通知、发送信息等等),以及需要使用我们在服务端工程定义的通信协议来完成信息的交互操作。
3 itstack-navie-chat-server 服务端同样使用Netty4.x作为socket的通信框架,同时在服务端使用Layui作为管理后台的页面,并且我们的服务端采用偏向于DDD领域驱动设计的方式与Netty集合,以此来达到我们的框架结构整洁干净易于扩展。
4 itstack.sql 系统工程数据库表结构以及初始化数据信息,共计6张核心表;用户表、群组表、用户群组关联表、好友表、对话表以及聊天记录表。用户在实际业务开发中可以自行拓展完善,目前库表结构只以核心功能为基础。
  • 源码获取:关注公众号:bugstack虫洞栈,回复IM 亲,源码给我点个Star,不要白皮袄!!!
  • 专栏小册:直接阅读原文即可

# 七、总结

  • 此IM系统涉及到的技术栈内容较多,Netty4.x、SpringBoot、Mybatis、Mysql、JavaFx、layui等技术栈的使用,以及整个系统框架结构采用DDD四层架构+Socket模块的方式进行搭建,所有的UI都以前后端分离事件驱动方式进行设计,在这个过程中只要你能坚持学习下来,那么一定会收获非常多的内容。足够吹牛啦!🌶
  • 任何一个新技术栈的学习过程都会包括这样一条路线;运行HelloWorld、熟练使用API、项目实践以及最后的深度源码挖掘。 那么在听到这样一个需求时候,Java程序员肯定会想到一些列的技术知识点来填充我们项目中的各个模块,例如;界面用JavaFx、Swing等,通信用Socket或者知道Netty框架、服务端控制用MVC模型加上SpringBoot等。但是怎么将这些各个技术栈合理的架设出我们的系统确是学习、实践、成长过程中最重要的部分。