Java開源-Talk:一個聊天系統
這是一個Java聊天系統,作為Java實驗課的內容,目前已基本完成,支持如下功能:
- 群聊
- 私聊
- 消息提醒
- 用戶狀態標記
- 聊天記錄保存
- 表情支持
效果如下圖:
這是私聊的界面,其中可以看到Master
,表示群聊大廳,選中可以進行群聊,而選擇其他用戶,則表示私聊。
名字后面的(*)
表示消息提醒,切換標簽即可查看,而(Offline)
則標記用戶已經離線。
同樣,可以發送表情,不那么單調。
這是服務端界面,主要是記錄用戶的登入、注銷。
下面我就來寫下我的設計思路吧。
設計思路
首先我要吐槽的是,界面真難寫,從代碼統計中可以看出,我的服務端230行左右,而客戶端達到700多行,同時客戶端也寫的一坨,純粹是面向過程的寫法了。
這里我把客戶端和服務端寫到一個項目里(有3個包,一個客戶端,一個服務端,一個公用數據),通過命令行參數來判斷是啟動客戶端,還是服務端--server
,同時互不依賴。
在寫客戶端的過程中,發現Swing
比較丑,查了下資料,最后選擇了JavaFX
來構建界面。
剛開始是用openjdk
來編寫,發現沒有內置JavaFX
庫,最后還是老老實實用了Oracle-JDK
。
期間也查了不少資料,全是關于客戶端的細節處理。
公用數據包
這里主要定義了3個類,分別介紹如下。
Talk
類就是程序的主入口了,通過判斷是否帶--server
來啟動服務端或者客戶端。
TalkUser
類,主要是為服務端使用的,標記了用戶名userName
,以及該用戶收到的消息隊列message
(為[*FROM <from>]
格式,后面會講)。同時也定義了sendMsg
(存儲用戶消息)和sendAll
(存儲群聊消息)方法,來存儲消息。可能方法名有點誤導,比如說調用usr.sendMsg(from, msg)
,其實是usr
存儲from
發來的消息,而不是發送消息= =。sendAll
類似。
TalkEmoji
類,這個類比較智障,存儲了各個Emoji
表情的Unicode
碼,這里提前說下,其實早在2010年,Unicode
編碼就已經納入了700多個Emoji
表情,所以是可以支持表情的,只要加載支持Emoji
表情的字庫即可。參考鏈接:How to support Emojis
服務端pers.netcan.talk.server
服務端接口設計,比較爛大街(傳統)的Master-Worker
模型,設置一個Master
主線程,專門用來監聽客戶端請求;當客戶端請求時,則創建一系列子線程Workers
來處理各個客戶端的請求。
需要注意的是Java很容易產生Null
指針異常操作問題,這里要仔細處理。
之后就是設計一套專用的協議,以實現服務端與客戶端的交互。
我設計的協議如下:
-
客戶端請求
[REGISTER]<username>
: 用戶注冊到服務器,服務器產生一個子線程專門來處理這個用戶的請求[GETUSRS]
: 服務端返回在線用戶列表[USERS]<uesr1>, <user2>...
,用逗號隔開[SENDTO <to>]<message>
: 用戶發送message
消息給名為to
的用戶,若發送給Master
,則[ALLFROM]
響應。[LOGOUT]
:用戶注銷
-
服務端響應
[USERS]<uesr1>, <user2>...
: 響應客戶端的[GETUSRS]
請求,返回各個在線用戶名[OK]
: 目前僅表示用戶的[REGISTER]
請求成功,即登錄成功[FAILED]
: 目前僅表示用戶的[REGISTER]
請求失敗,即登錄失敗,可能因為重名。[FROM <from>]<message>
: 表示當前用戶收到一個名為from
用戶的message
消息。[ALLFROM <from>]<message>
: 表示當前用戶收到一個名為from
用戶的群發(在Master
標簽中顯示)message
消息。
好吧,應該就那么幾條指令,這樣對于一個聊天系統來說足夠了,需要注意的是發送的時候,用flush
方法立即將發送緩沖區中的內容發送出去,而不是等到緩沖區滿了才發送,這樣就沒有交互性可言了。
忘記說一點,我是這么處理用戶的消息的,在Master
中有一個Users
全局變量(這樣所有的線程都能訪問了),它的類型為<TalkUser>
,就是前面公用數據包中提到的數據結構,每當接收到用戶的發送指令[SENDTO]
時,就調用對應用戶的sendto
方法來存儲消息到自己的消息隊列中。而每個Worker
線程,都會在300ms
內檢查各自處理用戶的消息隊列是否有消息,一有就立刻發送給對應的客戶端,讓客戶端展示出來。
之前和一個同學討論這個聊天軟件是怎么設計比較合適,他比較糾結一個問題,就是怎么調度各個用戶發送的消息,所以考慮用輪詢的做法,而我一開始就沒考慮過這個問題,因為很簡單啊,用戶A發消息給用戶B,用戶B直接展示出來就行了,反過來類似,如果同時發,怎么調度?當然是誰網速快就先處理誰的= =,同理,群聊也是,服務器先收到誰的,就立刻發送給各個用戶,先后順序完全由發送時間和網速來確定,所以不用考慮那么多的。(當然可能每個用戶的消息記錄顯示順序不一樣,這也是有可能的)。
客戶端pers.netcan.talk.client
客戶端寫的就比較凌亂了,它的職責無非就是解析服務器響應,展現給用戶,同時將用戶的操作(主要是發送命令)發送給服務器處理。
看起來比較容易,細節還是比較難處理的。客戶端開一個線程,每300ms
發送一個[GETUSRS]
報文給服務器,服務器響應報文,返回用戶列表,也就是說每300ms
刷新一下用戶列表,這里起到2個作用,一是相當于心跳包,維持TCP
長連接,二是實時獲取在線用戶,之后就是接收消息,每300ms
接收一條消息是可以接受的。
然而這個專門用來刷新消息的線程,若修改UI
會出錯,無奈查了大量資料,用Task<Void>
來處理,將修改UI
、刷新消息部分代碼放到如下代碼塊中處理。
Platform.runLater(new Runnable() {
@Override
public void run() {
...
}
});
用戶狀態標記,這里當用戶離線的時候,就加個(Offline)
標記,有新消息,就加個(*)
標記,用正則表達式"([\\w\\d]+)( \\((\\*|Offline)\\))?"
來匹配是哪種狀態,看起來夠難寫的。
發送消息,響應發送按鈕點擊事件,和回車事件,然后將發送框中的消息<msg>
,用戶列表選中的用戶<to>
,發送[SENDTO <to>]<msg>
指令給服務端。需要注意的是,為了減少特殊字符(例如換行)帶來的麻煩,將消息字符串利用base64
編碼再發送,接收的時候再base64
解碼,就不用考慮那么多文本處理細節了。
接收消息,每300ms
響應一下服務端,然后檢查是否有[*FROM]
響應,并將消息存儲至消息記錄中。客戶端展現出來。
表情支持,將一些Emoji
表情的Unicode
碼存到按鈕中,然后響應按鈕事件,點擊按鈕就把表情附加到發送框中,這里又出現一個問題,我將emojis
定義為一個按鈕數組,那么綁定事件會出現問題:
for(int i=0; i<TalkEmoji.emoji.length; ++i) { // 將表情顯示到按鈕上
emojis[i].setOnAction((event) -> {
sendMsg.appendText(emojis[i].getText());
});
}
將編譯不過去,因為event
的lambda
表達式引用了i
這個外部變量,這在Java
中是不允許的(只能將外部變量聲明為final
),this
我也想過了,不行,沒辦法,又查了大量資料,解決如下:
((Button) event.getSource()).getText()
利用event.getSource()
方法獲取是哪個對象響應的事件。
當用戶點擊退出按鈕的時候,就將內存中的聊天記錄以用戶名為文件名的方式保存到文件中,登錄的時候加載一下文件的內容到內存中即可。
還有一點要注意的是,保存/讀取文件需要指定編碼,否則在Win
平臺下運行,保存/讀取的內容將亂碼。
TODO
- 服務器接口
- 完成客戶端
- 聊天記錄保存至文件
- 增加表情支持