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))
