由于一些众所不知的原因,最近很忙,原本说好的这篇居然延了一周。另外,我从头对过去这一系列文章进行了复盘,采纳了一些意见并做了一些勘误。好了,下面进入正文。
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()
private val members = ConcurrentHashMap>()
private val lastMessages = LinkedList()
}
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() }
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 从入门到放弃(七) 部署到生产环境》