由于一些眾所不知的原因,最近很忙,原本說好的這篇居然延了一周。另外,我從頭對過去這一系列文章進行了復盤,采納了一些意見并做了一些勘誤。好了,下面進入正文。
WebSocket
是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。它使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API
中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,并進行雙向數據傳輸。
在 Ktor
中,可以很方便的完成 WebSocket
的操作,需要的只是一點點代碼,文本將帶你一步步實現一個在線的聊天室。
首先,我們在服務器應用中加入 WebSocket
的支持,簡單的 Gradle 引用即可:
compile "io.ktor:ktor-websockets:$ktor_version"
與之前所講述的 FreeMarkar 或 Session 一樣,WebSocket 也是使用插件形式安裝的:
fun Application.main() {
install(WebSockets) {
pingPeriod = Duration.ofMinutes(1)
}
}
此時我們的服務器就支持 WebSocket 了,可以進一步編寫代碼,現在來設計一個服務器,對于聊天室來說,只有幾個簡單的點,如用戶加入,用戶退出,收發消息,修改昵稱,服務器端廣播等,下面一個個來實現。
首先,做一些準備,在服務器類中加入一些必要的管理對象:
class ChatServer {
private val memberNames = ConcurrentHashMap<UserSession, String>()
private val members = ConcurrentHashMap<UserSession, MutableList<WebSocketSession>>()
private val lastMessages = LinkedList<String>()
}
members
用于管理用戶的連接會話,用于向指定用戶發送消息。memberNames
用于管理用戶在聊天室的昵稱,lastMessages
用于向每個用戶同步最新的消息。
此處使用 ConcurrentHashMap
,是因為 HashMap
并非線程安全,而 HashTable
效率低下,然而在協程內往往會有非常激烈的線程競爭,因此在此處選用 ConcurrentHashMap
來解決問題。
接著完成用戶的加入與離開,其實就是 Session
的加入與離開:
suspend fun memberJoin(member: UserSession, socket: WebSocketSession) {
val name = memberNames.computeIfAbsent(member) { member.nickname }
val list = members.computeIfAbsent(member) { CopyOnWriteArrayList<WebSocketSession>() }
list.add(socket)
if (list.size == 1) {
serverBroadcast("Member joined: $name.")
}
val messages = synchronized(lastMessages) { lastMessages.toList() }
for (message in messages) {
socket.send(Frame.Text(message))
}
}
suspend fun memberLeft(member: UserSession, socket: WebSocketSession) {
val connections = members[member]
connections?.remove(socket)
if (connections != null && connections.isEmpty()) {
val name = memberNames.remove(member) ?: member
serverBroadcast("Member left: $name.")
}
}
然后就是消息的收發,對于服務器來說,其實是一個消息的中轉站,它接收消息并且轉發給相應的用戶:
suspend fun receivedMessage(id: UserSession, command: String) {
server.message(id, command)
}
suspend fun message(sender: UserSession, message: String) {
val name = memberNames[sender] ?: sender.nickname
val formatted = "[$name] $message"
broadcast(sender.chatroomId, formatted)
synchronized(lastMessages) {
lastMessages.add(formatted)
if (lastMessages.size > 100) {
lastMessages.removeFirst()
}
}
}
此處有一個 chatroomId
的設定,是因為聊天室可能有很多個,而我們的消息卻不可能永遠處于全員廣播的狀態,有必要按聊天室來拆分具體的請求。此處我們將 chatroomId
放在 Session 里,然后可以方便的過濾與消息發送:
suspend fun broadcast(roomId: String, message: String) =
members.filter { it.key.chatroomId == roomId }.values.forEach {
it.send(Frame.Text(message))
}
到此,聊天服務器類已經寫好了,是不是很簡單?接下去只要讓 Ktor
服務器能夠響應 WebSocket
請求:
private val server = ChatServer()
fun Application.main() {
install(WebSockets) {
pingPeriod = Duration.ofMinutes(1)
}
routing {
webSocket("/ws") {
val ses = session
if (ses == null) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "No session"))
return@webSocket
}
server.memberJoin(ses, this)
try {
incoming.consumeEach {
if (it is Frame.Text) {
server.receivedMessage(ses, it.readText())
}
}
} finally {
server.memberLeft(ses, this)
}
}
}
}
好了,到此為止,我們在服務器端的準備已經全部做好了。下面寫一個簡單的頁面來完成消息的收發。
對于前端頁面而言,其實并不關心后端是 Ktor
或者是別的,只需要后端支持的協議是標準的 WebSocket
即可,所以對于前端來說,基礎代碼幾乎是固定的:
var socket = null;
function connect() {
socket = new WebSocket("ws://" + window.location.host + "/ws");
socket.onclose = function(e) {
setTimeout(connect, 5000);
};
socket.onmessage = function(e) {
received(e.data.toString());
};
}
function received(message) {
// TODO: received message
}
其實就是那么簡單的,通過 javascript
代碼來建立一個 WebSocket
連接即可。
來個動圖可以更好的看到效果:
好了,本篇又要結束了,似乎這篇講的東西還是比較多的,代碼片段或許不怎么容易理解,我特地寫了一個 demo 程序供各位參考,點擊訪問我的 Github
下一篇預告:《Ktor 從入門到放棄(七) 部署到生產環境》