Common Lisp 生态系统有几种构建 WebSocket 服务的方法。首先,比较出色的就是 Hunchensocket,一个 Hunchentoot 拓展插件,经典的 Common Lisp web 服务。
然而,现在的话,可以使用同样出色的 websocket-driver 和 Clack 构建WebSocket 服务。Common Lisp web 开发社区表达了对 Clack 生态的偏爱,因为 Clack 为各种后端(包括 Hunchentoot)提供了统一的接口。也就是说,使用 Clack,可以选择自己喜欢的后端。
接下来,将会展示如何构建一个简单的聊天服务,并且可以通过浏览器连接。本章的教程是可以直在 REPL 直接运行,但如果漏掉一些步骤,完整的代码在本章最后。
首先,需要用 quicklisp 安装加载一些依赖库:
(ql:quickload '(clack websocket-driver alexandria))
websocket-driver 概念
some-message-handler
whenever a new message arrives.
在 websocket-driver 中,WebSocket 连接是 ws
类的实例,类 ws
中有个公开的事件驱动 API。通过将 WebSocket 实例作为 on
方法的第二个参数注册为事件处理。例如,每当有新消息到达时,调用 (on :message my-websocket #'some-message-handler)
就会调用 some-message-handler
。
websocket-driver
API 有以下事件的处理函数(handler):
:open
:当建立连接时。处理函数不需要参数。:message
:当有信息到达时。处理函数需要处理接受到的消息。:close
:断开连接时。处理函数接受两个关键词参数,断开连接的“代码”和“原因”。:error
:协议层发生错误时。处理函数需要处理错误信息。
基于聊天服务的需求,需要处理三种情况:当新用户加入频道,当用户在频道中发消息,当用户退出。
定义聊天服务端逻辑的处理函数
在本节中,将定义事件处理程序最终调用的函数。这些函数是管理聊天服务器逻辑的辅助函数。WebSocket 服务将在下一节中定义。
首先,当用户连接到服务器时,需要给该用户提供一个昵称,以便其他用户知道是在和谁聊天。还需要个数据结构来将单个 WebSocket 连接映射到昵称。
;; make a hash table to map connections to nicknames
(defvar *connections* (make-hash-table))
;; and assign a random nickname to a user upon connection
(defun handle-new-connection (con)
(setf (gethash con *connections*)
(format nil "user-~a" (random 100000))))
接下来,当用户在聊天室中发消息时,应该通知聊天时中的其他用户。服务器接收到的消息是用发送消息用户的昵称作为前缀。
(defun broadcast-to-room (connection message)
(let ((message (format nil "~a: ~a"
(gethash connection *connections*)
message)))
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
最后,当用户离开频道时(关闭浏览器选项卡或导航),应该通知聊天时这个变化,同时该用户的连接要从*connections*
表中删除。
(defun handle-close-connection (connection)
(let ((message (format nil " .... ~a has left."
(gethash connection *connections*))))
(remhash connection *connections*)
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
定义服务
在 Clack 中,可以通过将函数传给 clack:clackup
来启动服务。接下来将定义一个 chat-server
的函数,然后通过 (calck:clackup #'chat-server :port12345)
启动。
Clack 服务端函数的参数是个 plist 类型的列表。该列表包含了由客户端系统提供的请求信息。本章中的聊天服务不会使用这些信息,但如果你想进一步了解的话,可以去阅读 Clack 的文档。
当浏览器连接到服务器后,会将 websocket 实例化,同时在实例上定义一系列需要的事件处理函数。WebSocket ”握手“包会返回给客户端的浏览器,表示已建立连接了。下面是其工作原理:
(defun chat-server (env)
(let ((ws (websocket-driver:make-server env)))
(websocket-driver:on :open ws
(lambda () (handle-new-connection ws)))
(websocket-driver:on :message ws
(lambda (msg) (broadcast-to-room ws msg)))
(websocket-driver:on :close ws
(lambda (&key code reason)
(declare (ignore code reason))
(handle-close-connection ws)))
(lambda (responder)
(declare (ignore responder))
(websocket-driver:start-connection ws)))) ; send the handshake
现在可以启动服务了,服务的监听端口为 12345
:
;; keep the handler around so that you can stop your server later on
(defvar *chat-handler* (clack:clackup #'chat-server :port 12345))
简单的网页聊天客户端
现在,需要一种和服务端通信的方式了。使用 Clack 定义一个简单的应用,这个应用是在 web 页面上显示和发送消息的。首先是网页:
(defvar *html*
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<title>LISP-CHAT</title>
</head>
<body>
<ul id=\"chat-echo-area\">
</ul>
<div style=\"position:fixed; bottom:0;\">
<input id=\"chat-input\" placeholder=\"say something\" >
</div>
<script>
window.onload = function () {
const inputField = document.getElementById(\"chat-input\");
function receivedMessage(msg) {
let li = document.createElement(\"li\");
li.textContent = msg.data;
document.getElementById(\"chat-echo-area\").appendChild(li);
}
const ws = new WebSocket(\"ws://localhost:12345/chat\");
ws.addEventListener('message', receivedMessage);
inputField.addEventListener(\"keyup\", (evt) => {
if (evt.key === \"Enter\") {
ws.send(evt.target.value);
evt.target.value = \"\";
}
});
};
</script>
</body>
</html>
")
(defun client-server (env)
(declare (ignore env))
`(200 (:content-type "text/html")
(,*html*)))
你可能会倾向将 HTML 单独的保存为一个文件,因为转译符号很麻烦。但在本章的教程中将网页的内容放在 defvar
中会跟简单些。
这样,就可以看到 client-server
函数只是提供 HTML 的内容。直接启动,这次端口是 8080
。
(defvar *client-handler* (clack:clackup #'client-server :port 8080))
验证
现在,打开浏览器然后在地址栏输入 http://localhost:8080
后就可以看到聊天应用程序了。
源代码
(ql:quickload '(clack websocket-driver alexandria))
(defvar *connections* (make-hash-table))
(defun handle-new-connection (con)
(setf (gethash con *connections*)
(format nil "user-~a" (random 100000))))
(defun broadcast-to-room (connection message)
(let ((message (format nil "~a: ~a"
(gethash connection *connections*)
message)))
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
(defun handle-close-connection (connection)
(let ((message (format nil " .... ~a has left."
(gethash connection *connections*))))
(remhash connection *connections*)
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
(defun chat-server (env)
(let ((ws (websocket-driver:make-server env)))
(websocket-driver:on :open ws
(lambda () (handle-new-connection ws)))
(websocket-driver:on :message ws
(lambda (msg) (broadcast-to-room ws msg)))
(websocket-driver:on :close ws
(lambda (&key code reason)
(declare (ignore code reason))
(handle-close-connection ws)))
(lambda (responder)
(declare (ignore responder))
(websocket-driver:start-connection ws))))
(defvar *html*
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<title>LISP-CHAT</title>
</head>
<body>
<ul id=\"chat-echo-area\">
</ul>
<div style=\"position:fixed; bottom:0;\">
<input id=\"chat-input\" placeholder=\"say something\" >
</div>
<script>
window.onload = function () {
const inputField = document.getElementById(\"chat-input\");
function receivedMessage(msg) {
let li = document.createElement(\"li\");
li.textContent = msg.data;
document.getElementById(\"chat-echo-area\").appendChild(li);
}
const ws = new WebSocket(\"ws://localhost:12345/\");
ws.addEventListener('message', receivedMessage);
inputField.addEventListener(\"keyup\", (evt) => {
if (evt.key === \"Enter\") {
ws.send(evt.target.value);
evt.target.value = \"\";
}
});
};
</script>
</body>
</html>
")
(defun client-server (env)
(declare (ignore env))
`(200 (:content-type "text/html")
(,*html*)))
(defvar *chat-handler* (clack:clackup #'chat-server :port 12345))
(defvar *client-handler* (clack:clackup #'client-server :port 8080))