Common Lisp 生态系统有几种构建 WebSocket 服务的方法。首先,比较出色的就是 Hunchensocket,一个 Hunchentoot 拓展插件,经典的 Common Lisp web 服务。

然而,现在的话,可以使用同样出色的 websocket-driverClack 构建WebSocket 服务。Common Lisp web 开发社区表达了对 Clack 生态的偏爱,因为 Clack 为各种后端(包括 Hunchentoot)提供了统一的接口。也就是说,使用 Clack,可以选择自己喜欢的后端。

接下来,将会展示如何构建一个简单的聊天服务,并且可以通过浏览器连接。本章的教程是可以直在 REPL 直接运行,但如果漏掉一些步骤,完整的代码在本章最后。

首先,需要用 quicklisp 安装加载一些依赖库:

  1. (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 连接映射到昵称。

  1. ;; make a hash table to map connections to nicknames
  2. (defvar *connections* (make-hash-table))
  3. ;; and assign a random nickname to a user upon connection
  4. (defun handle-new-connection (con)
  5. (setf (gethash con *connections*)
  6. (format nil "user-~a" (random 100000))))

接下来,当用户在聊天室中发消息时,应该通知聊天时中的其他用户。服务器接收到的消息是用发送消息用户的昵称作为前缀。

  1. (defun broadcast-to-room (connection message)
  2. (let ((message (format nil "~a: ~a"
  3. (gethash connection *connections*)
  4. message)))
  5. (loop :for con :being :the :hash-key :of *connections* :do
  6. (websocket-driver:send con message))))

最后,当用户离开频道时(关闭浏览器选项卡或导航),应该通知聊天时这个变化,同时该用户的连接要从*connections* 表中删除。

  1. (defun handle-close-connection (connection)
  2. (let ((message (format nil " .... ~a has left."
  3. (gethash connection *connections*))))
  4. (remhash connection *connections*)
  5. (loop :for con :being :the :hash-key :of *connections* :do
  6. (websocket-driver:send con message))))

定义服务

在 Clack 中,可以通过将函数传给 clack:clackup 来启动服务。接下来将定义一个 chat-server 的函数,然后通过 (calck:clackup #'chat-server :port12345) 启动。

Clack 服务端函数的参数是个 plist 类型的列表。该列表包含了由客户端系统提供的请求信息。本章中的聊天服务不会使用这些信息,但如果你想进一步了解的话,可以去阅读 Clack 的文档。

当浏览器连接到服务器后,会将 websocket 实例化,同时在实例上定义一系列需要的事件处理函数。WebSocket ”握手“包会返回给客户端的浏览器,表示已建立连接了。下面是其工作原理:

  1. (defun chat-server (env)
  2. (let ((ws (websocket-driver:make-server env)))
  3. (websocket-driver:on :open ws
  4. (lambda () (handle-new-connection ws)))
  5. (websocket-driver:on :message ws
  6. (lambda (msg) (broadcast-to-room ws msg)))
  7. (websocket-driver:on :close ws
  8. (lambda (&key code reason)
  9. (declare (ignore code reason))
  10. (handle-close-connection ws)))
  11. (lambda (responder)
  12. (declare (ignore responder))
  13. (websocket-driver:start-connection ws)))) ; send the handshake

现在可以启动服务了,服务的监听端口为 12345

  1. ;; keep the handler around so that you can stop your server later on
  2. (defvar *chat-handler* (clack:clackup #'chat-server :port 12345))

简单的网页聊天客户端

现在,需要一种和服务端通信的方式了。使用 Clack 定义一个简单的应用,这个应用是在 web 页面上显示和发送消息的。首先是网页:

  1. (defvar *html*
  2. "<!doctype html>
  3. <html lang=\"en\">
  4. <head>
  5. <meta charset=\"utf-8\">
  6. <title>LISP-CHAT</title>
  7. </head>
  8. <body>
  9. <ul id=\"chat-echo-area\">
  10. </ul>
  11. <div style=\"position:fixed; bottom:0;\">
  12. <input id=\"chat-input\" placeholder=\"say something\" >
  13. </div>
  14. <script>
  15. window.onload = function () {
  16. const inputField = document.getElementById(\"chat-input\");
  17. function receivedMessage(msg) {
  18. let li = document.createElement(\"li\");
  19. li.textContent = msg.data;
  20. document.getElementById(\"chat-echo-area\").appendChild(li);
  21. }
  22. const ws = new WebSocket(\"ws://localhost:12345/chat\");
  23. ws.addEventListener('message', receivedMessage);
  24. inputField.addEventListener(\"keyup\", (evt) => {
  25. if (evt.key === \"Enter\") {
  26. ws.send(evt.target.value);
  27. evt.target.value = \"\";
  28. }
  29. });
  30. };
  31. </script>
  32. </body>
  33. </html>
  34. ")
  35. (defun client-server (env)
  36. (declare (ignore env))
  37. `(200 (:content-type "text/html")
  38. (,*html*)))

你可能会倾向将 HTML 单独的保存为一个文件,因为转译符号很麻烦。但在本章的教程中将网页的内容放在 defvar 中会跟简单些。

这样,就可以看到 client-server 函数只是提供 HTML 的内容。直接启动,这次端口是 8080

  1. (defvar *client-handler* (clack:clackup #'client-server :port 8080))

验证

现在,打开浏览器然后在地址栏输入 http://localhost:8080 后就可以看到聊天应用程序了。

30. Websockets - 图1

源代码

  1. (ql:quickload '(clack websocket-driver alexandria))
  2. (defvar *connections* (make-hash-table))
  3. (defun handle-new-connection (con)
  4. (setf (gethash con *connections*)
  5. (format nil "user-~a" (random 100000))))
  6. (defun broadcast-to-room (connection message)
  7. (let ((message (format nil "~a: ~a"
  8. (gethash connection *connections*)
  9. message)))
  10. (loop :for con :being :the :hash-key :of *connections* :do
  11. (websocket-driver:send con message))))
  12. (defun handle-close-connection (connection)
  13. (let ((message (format nil " .... ~a has left."
  14. (gethash connection *connections*))))
  15. (remhash connection *connections*)
  16. (loop :for con :being :the :hash-key :of *connections* :do
  17. (websocket-driver:send con message))))
  18. (defun chat-server (env)
  19. (let ((ws (websocket-driver:make-server env)))
  20. (websocket-driver:on :open ws
  21. (lambda () (handle-new-connection ws)))
  22. (websocket-driver:on :message ws
  23. (lambda (msg) (broadcast-to-room ws msg)))
  24. (websocket-driver:on :close ws
  25. (lambda (&key code reason)
  26. (declare (ignore code reason))
  27. (handle-close-connection ws)))
  28. (lambda (responder)
  29. (declare (ignore responder))
  30. (websocket-driver:start-connection ws))))
  31. (defvar *html*
  32. "<!doctype html>
  33. <html lang=\"en\">
  34. <head>
  35. <meta charset=\"utf-8\">
  36. <title>LISP-CHAT</title>
  37. </head>
  38. <body>
  39. <ul id=\"chat-echo-area\">
  40. </ul>
  41. <div style=\"position:fixed; bottom:0;\">
  42. <input id=\"chat-input\" placeholder=\"say something\" >
  43. </div>
  44. <script>
  45. window.onload = function () {
  46. const inputField = document.getElementById(\"chat-input\");
  47. function receivedMessage(msg) {
  48. let li = document.createElement(\"li\");
  49. li.textContent = msg.data;
  50. document.getElementById(\"chat-echo-area\").appendChild(li);
  51. }
  52. const ws = new WebSocket(\"ws://localhost:12345/\");
  53. ws.addEventListener('message', receivedMessage);
  54. inputField.addEventListener(\"keyup\", (evt) => {
  55. if (evt.key === \"Enter\") {
  56. ws.send(evt.target.value);
  57. evt.target.value = \"\";
  58. }
  59. });
  60. };
  61. </script>
  62. </body>
  63. </html>
  64. ")
  65. (defun client-server (env)
  66. (declare (ignore env))
  67. `(200 (:content-type "text/html")
  68. (,*html*)))
  69. (defvar *chat-handler* (clack:clackup #'chat-server :port 12345))
  70. (defvar *client-handler* (clack:clackup #'client-server :port 8080))