作者:小傅哥
博客:https://bugstack.cn
沉淀、分享、成長,讓自己和他人都能有所收獲!😄
一、前言
這知識學的,根本沒有忘的快呀?!
是不是感覺很多資料,點收藏起來爽
、看視頻時候嗨
、讀文章當時會
,只要過了那個勁,就完了,根本不記得這里面都講了啥。時間浪費了,東西還沒學到手,這是為啥?
其實因為學習也分為上策、中策和下策:
- 下策:眼睛看就行,坐着、窩着、躺着,都行,反正也不累,還能一邊回復下吹水的微信群
- 中策:看完的資料做筆記整理歸納,長期積累資料
- 上策:實踐、上手、應用、調試、歸納、整理資料,總結經驗輸出文檔
綜上,下策學起來很快感覺自己好像會了不少,中策有點要動手了懶不想動,上策就很耗時耗力了要自己對每一個知識點都能事必躬親到親力親為。就這樣你在學習的時候不自覺的就選擇了下策,因此其實並沒有學到什么。
學習能把知識學到手,講究的是實踐,在小傅哥編寫的文章中,基本都是以實踐代碼驗證結果為核心,講述文章內容。😁從小我就喜歡動手,就以一個即時通信的項目為例,已經基於不同技術方案實現了5、6次,僅為了實踐技術,截圖如下:
- 有些是剛學完Socket和Swing的時候,想動手試試這些技術能不能寫個QQ出來。
- 也有的是因為實習培訓需要完成的項目,不過在有了一些基礎后,一周時間就能寫完全部功能。
- 雖然這些項目在現在看上去還是丑丑的界面,以及代碼邏輯可能也不是那么完善。但放在學習階段的每一次實現中,都能為自己帶來很多技術上的成長。
那么,這次IM實踐的機會給你,希望你能用的上!接下來我會給你介紹一個IM的系統架構、通信協議、單聊群聊、表情發送、UI事件驅動等各項內容,以及提供全套的源碼讓你可以上手學習。
二、演示
在開始學習之前,先給大家演示下這套仿照PC端微信界面的IM系統運行效果。
聊天頁面
添加好友
視頻演示
https://www.bilibili.com/video/BV1BZ4y1W7fC
三、系統設計
在這套IM
中,服務端采用DDD
領域驅動設計模式進行搭建。將 Netty 的功能交給 SpringBoot
進行啟停控制,同時在服務端搭建控制台可以非常方便的操作通信系統,進行用戶和通信管理。在客戶端的建設上采用UI
分離的方式進行搭建,以保證業務代碼與UI
展示分離,做到非常易於擴展的控制。
另外在功能實現上包括;完美仿照微信桌面版客戶端、登錄、搜索添加好友、用戶通信、群組通信、表情發送等核心功能。如果有對於實際需要使用的功能,可以按照這套系統框架進行擴展。
- UI開發:使用
JavaFx
與Maven
搭建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
協議包
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();
}
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()));
}
}
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()));
}
}
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);
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張核心表;用戶表、群組表、用戶群組關聯表、好友表、對話表以及聊天記錄表。用戶在實際業務開發中可以自行拓展完善,目前庫表結構只以核心功能為基礎。 |
- 源碼獲取:https://github.com/fuzhengwei/NaiveChat 親,源碼給我點個Star,不要白皮襖!!!
七、總結
- 此IM系統涉及到的技術棧內容較多,Netty4.x、SpringBoot、Mybatis、Mysql、JavaFx、layui等技術棧的使用,以及整個系統框架結構采用DDD四層架構+Socket模塊的方式進行搭建,所有的UI都以前后端分離事件驅動方式進行設計,在這個過程中只要你能堅持學習下來,那么一定會收獲非常多的內容。足夠吹牛啦!🌶
- 任何一個新技術棧的學習過程都會包括這樣一條路線;運行HelloWorld、熟練使用API、項目實踐以及最后的深度源碼挖掘。 那么在聽到這樣一個需求時候,Java程序員肯定會想到一些列的技術知識點來填充我們項目中的各個模塊,例如;界面用JavaFx、Swing等,通信用Socket或者知道Netty框架、服務端控制用MVC模型加上SpringBoot等。但是怎么將這些各個技術棧合理的架設出我們的系統確是學習、實踐、成長過程中最重要的部分。