位元詩人 [Common Lisp] 網頁程式設計教學:Hunchentoot 入門

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

消費者層級軟體 (consumer-level software) 最常見的載體是網頁程式 (web application) 和行動程式 (mobile application)。在這兩種之中,網頁程式不受到特定的軟體市集 (software market) 的控制,給予程式設計者更多的自由。

本文介紹使用 Common Lisp 撰寫網頁程式的常見方案,並以兩個簡單的實例說明 Hunchentoot 網頁程式的撰寫方式。

Common Lisp 的網頁程式設計方案

在本節中,我們介紹幾個用 Common Lisp 寫網頁程式的常見方案,讓讀者有初步的概念,減少選擇工具所繞的彎路。

撰寫 CGI 程式

CGI 程式是所有網頁程式中最簡單的形式。因為 CGI 程式的要求很低,只要該語言

  • 可實作命令列程式
  • 可接收標準輸入
  • 可接收標準輸出
  • 可接收環境變數

就可以拿來寫 CGI 程式。

由於 Common Lisp 已經有更好的方案,Common Lisp 社群不太討論 CGI 程式的寫法。筆者在先前的文章已經介紹過 CGI 程式的撰寫方式 (這裡這裡),此處不再重覆。

使用網頁程式函式庫 (Web Application Library)

在 Common Lisp 社群中,使用單一網頁伺服器軟體撰寫網頁程式是常見的選項。這裡的網頁程式並不會像無框架 Node.js 網頁程式那麼低階,而是類似於微框架 (microframework) 的網頁程式函式庫。

在所有的網頁程式函式庫中,Hunchentoot 是最常用的。因為 Hunchentoot 已經出現一陣子了,算是成熟的軟體。大部分討論用 Common Lisp 寫網頁程式的學習資料很少完全不提 Hunchentoot。

附帶一提,雖然 Hunchentoot 的官網看起來很老派,但 Hunchentoot 並不是陳舊軟體 (legacy software)。從 Hunchentoot 的 GitHub 專案活動來看,該軟體仍然持續開發著。

Woo 是一個非同步輸出入的網頁程式函式庫。Woo 主打效能,根據該軟體的官方 benchmark,甚至贏過 Node.js 和 Golang。但該軟體目前還在 beta 狀態,而且文件偏少。

Wookie 是另一個非同步輸出入的網頁程式函式庫。該軟體使用 libuv 做為事件迴圈 (event loop) 和非同步輸出入的函式庫。而 libuv 同時也是 Node.js 所使用的函式庫。

使用網頁框架 (Web Framework)

比起網頁程式函式庫,網頁框架有較完整的功能。Common Lisp 中常見的網頁框架有 ClackCaveman

但我們並未使用這些網頁框架,因為這些網頁框架基本上是網頁程式函式庫的抽象層,把網頁程式函式庫當成可抽換的的底層函式庫。但這些網頁框架要兼顧多個網頁程式函式庫的相容性,無法使用各個網頁程式函式庫所有的 API。所以,我們會使用前一小節所提到的 Hunchentoot 來寫網頁程式,而不用本節所提到的框架。

撰寫第一個 Hunchentoot 網頁程式

雖然網路上已經有 Hunchentoot 的 Hello World 程式了,但我們還是決定自己寫一個。因為有些 Hunchentoot 的範例程式無法正確運作,有些 Hunchentoot 的範例程式會在啟動網頁伺服器後進入 REPL 環境。這些情形都和程式設計者對網頁程式的期待相差過大,所以我們決定重造輪子。

請用 SBCL 並在 GNU/Linux 上編譯和執行本文所提供的範例程式,這樣可以確保程式可正確地運作。GNU/Linux 也是絕大部分非 .NET 技術的網頁程式的目標平台,使用 GNU/Linux 不算什麼額外的限制。

雖然這些程式也可以在 Windows 上編譯,但使用時多多少少會碰到一些小問題。macOS 用來當網頁伺服器太昂貴,實務上幾乎不會是選項,所以我們沒在 macOS 上測試這些程式。

一開始,先建立 Hunchentoot 網頁伺服器物件,並將其存到變數 *server* 中:

; Create a new server instance.
(defvar *server*
  (make-instance 'hunchentoot:easy-acceptor :port 8080))

注意一下我們使用 easy-acceptor。雖然我們可以用別的 Acceptor 類別,但網頁程式的寫法會改變,故我們不另行展示。

接著,撰寫存取根目錄 / 的請求處理器。為了簡化程式碼,我們只回傳純文字訊息 Hello World

; Handle route to "/".
(hunchentoot:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/plain")
  "Hello World")

由於我們只回傳純文字訊息,所以 MIME type 要設為 text/plain

建好伺服器物件後就可以啟動該伺服器:

(hunchentoot:start *server*)

當不使用伺服器時則要將其停止:

(hunchentoot:stop *server*)

但我們不是直接在程式中停掉伺服器,而是把停掉伺服器的指令包在中斷 (interrupt) 中。我們待會會解釋為什麼要這樣寫:

(handler-case
    ; Wait for all threads emitted by Hunchentoot.
    (bt:join-thread
      (find-if (lambda (th)
                 (search "hunchentoot" (bt:thread-name th)))
      (bt:all-threads)))

    ; Capture and handle interactive interrupt
    ; i.e. C-c invoked by a user.
    (#+sbcl sb-sys:interactive-interrupt
     #+ccl  ccl:interrupt-signal-condition
     #+clisp system::simple-interrupt-condition
     #+ecl ext:interactive-interrupt
     #+allegro excl:interrupt-signal
     () (progn
          (hunchentoot:stop *server*)
          (format *error-output* "~%")
          (quit-with-status)))
    (error (e) (format *error-output* "Error: ~a~%" e)))

由於 Hunchentoot 網頁程式以多執行緒來執行,要用 bordeaux-threads 等待所有的執行緒執行完畢。

網頁程式在運行時,是以伺服程式的方式不中斷運行。使用者要中斷該程式時,可用 C-c (註) 自行中斷網頁程式的運行。但 Hunchentoot 並未為程式設計者寫這項功能,所以程式設計者得自行實作。

(註) 按住 Ctrl 鍵後再按 c 鍵。

由於 Common Lisp 未統一中斷的指令,所以每個 Common Lisp 實作品使用相異的指令。程式設計者只得針對每個 Common Lisp 實作品呼叫不同的指令。

本節的範例程式的完整程式碼如下:

; Custom-made utility library.
(load "cl-yautils")
(use-package :cl-yautils)

; Hunchentoot web server.
(require "hunchentoot")

; Create a new server instance.
(defvar *server*
  (make-instance 'hunchentoot:easy-acceptor :port 8080))

; Handle route to "/".
(hunchentoot:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/plain")
  "Hello World")

(defun main ()
  (hunchentoot:start *server*)
  (format *error-output* "Start a web server at http://localhost:8080~%")
  (handler-case
    ; Wait for all threads emitted by Hunchentoot.
    (bt:join-thread
      (find-if (lambda (th)
                 (search "hunchentoot" (bt:thread-name th)))
      (bt:all-threads)))

    ; Capture and handle interactive interrupt
    ; i.e. C-c invoked by a user.
    (#+sbcl sb-sys:interactive-interrupt
     #+ccl  ccl:interrupt-signal-condition
     #+clisp system::simple-interrupt-condition
     #+ecl ext:interactive-interrupt
     #+allegro excl:interrupt-signal
     () (progn
          (hunchentoot:stop *server*)
          (format *error-output* "~%")
          (quit-with-status)))
    (error (e) (format *error-output* "Error: ~a~%" e))))

如果讀者想要實際把玩一下這個範例程式,可以到這裡看看。cl-yautils 是筆者自製的 Common Lisp 工具函式,主要是為了解決自己在寫 Common Lisp 程式時所碰到的各種議題。

部署 Hunchentoot 網頁程式

既然 SBCL 等 Common Lisp 有編譯 Common Lisp 命令稿的能力,我們不應該浪費這項特性。以執行檔 (native executable) 部署網頁程式不僅效能較好,也可以保護原始碼不外流。

以 SBCL 為例,編譯執行檔的指令如下:

sbcl --load quicklisp.lisp \
     --eval "(quicklisp-quickstart:install :path \"./quicklisp\")" \
     --eval "(ql:quickload \"hunchentoot\")" \
     --load app.lisp \
     --eval "(compile-program \"app\" #'main)"

由於這行指令有用到筆者自製的 cl-yautils 函式庫,所以有使用非標準指令。

實務上,不應讓程式使用者自行輸入這麼長的指令。應該把指令包在命令列程式腳本中,省去記憶複雜指令的負擔。

加入網頁 (Web Page) 和靜態資源 (Static Assets)

實務上,網頁程式不會輸出純文字訊息,而會輸出網頁。此外,還會利用靜態資源增色網頁,像是用 CSS 修改網頁的風格和用 JavaScript 在網頁中增加程式邏輯。本節說明在 Hunchentoot 網頁程式中增加頁面及靜態資源的方式。

網頁程式通常不處理靜態資源,而會把靜態資源原封不動地輸出到網頁客戶端上。以下程式碼片段用來托管第三方 CSS:

; Host vendor CSS sheets.
(push
  (hunchentoot:create-folder-dispatcher-and-handler
    "/vendor/css/"
    (make-pathname :directory '(:relative "vendor" "css"))
    "text/css")
  hunchentoot:*dispatch-table*)

這時候,我們要在專案中自行建立 vendor/css/ 子目錄,並將第三方 CSS 放在該目錄中。 vendor 是我們自訂的前綴,用來表示該靜態資源來自第三方。

*dispatch-table* 是一個特殊的全域變數,用來儲存 Hunchentoot 網頁程式的路徑表 (routing table)。這段程式做的事情就是把 /vendor/css/ 加入此網頁程式的路徑表中。

同樣地,也可以加入第三方 JavaScript 命令稿:

; Host vendor JavaScritp scripts.
(push
  (hunchentoot:create-folder-dispatcher-and-handler
    "/vendor/js/"
    (make-pathname :directory '(:relative "vendor" "js"))
    "application/javascript")
  hunchentoot:*dispatch-table*)

除了靜態資源外,我們還需要頁面,並在該頁面中用標籤引入靜態資源。由於生成網頁是常見的任務,Hunchentoot 的怍者做了 cl-who,這是一個用來寫網頁的小型語言。

我們直接以實例來看 cl-who 的使用方式:

; Handle route to "/".
(hunchentoot:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/html")
  (with-html-output-to-string (s nil :prologue t)
    (:html :lang "en"
      (:head :title "Hunchentoot Website with Static Assets"
        (:meta :charset "utf8")
        (:meta :name "viewport"
               :content "width=device-width, initial-scale=1, shrink-to-fit=no")
        (:link :href "/vendor/css/bootstrap.min.css")
        (:script :src "/vendor/js/polyfill.min.js"))
      (:body
        (:div :class "container"
          (:h1 "Hello Hunchentoot"))
        (:script :src "/vendor/js/bootstrap-native-v4.min.js")))))

由這段程式碼可看出, cl-who 的使用方式相當簡單。由於 Lisp 指令本身就會以中括號包住,剛好對應成對的網頁標籤。

本節的範例程式的完整程式碼如下:

; Custom-made utility library.
(load "cl-yautils")
(use-package :cl-yautils)

; Hunchentoot web server.
(require "hunchentoot")

; CL-WHO markup language for HTML.
(require "cl-who")
(use-package :cl-who)

(setf *prologue* "<!DOCTYPE html>")
(setf (html-mode) :HTML5)

; Create a new server instance.
(defvar *server*
  (make-instance 'hunchentoot:easy-acceptor :port 8080))

; Handle route to "/".
(hunchentoot:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/html")
  (with-html-output-to-string (s nil :prologue t)
    (:html :lang "en"
      (:head :title "Hunchentoot Website with Static Assets"
        (:meta :charset "utf8")
        (:meta :name "viewport"
               :content "width=device-width, initial-scale=1, shrink-to-fit=no")
        (:link :href "/vendor/css/bootstrap.min.css")
        (:script :src "/vendor/js/polyfill.min.js"))
      (:body
        (:div :class "container"
          (:h1 "Hello Hunchentoot"))
        (:script :src "/vendor/js/bootstrap-native-v4.min.js")))))

; Host vendor CSS sheets.
(push
  (hunchentoot:create-folder-dispatcher-and-handler
    "/vendor/css/"
    (make-pathname :directory '(:relative "vendor" "css"))
    "text/css")
  hunchentoot:*dispatch-table*)

; Host vendor JavaScritp scripts.
(push
  (hunchentoot:create-folder-dispatcher-and-handler
    "/vendor/js/"
    (make-pathname :directory '(:relative "vendor" "js"))
    "application/javascript")
  hunchentoot:*dispatch-table*)

(defun main ()
  (hunchentoot:start *server*)
  (format *error-output* "Start a web server at http://localhost:8080~%")
  (handler-case
    ; Wait for all threads emitted by Hunchentoot.
    (bt:join-thread
      (find-if (lambda (th)
                 (search "hunchentoot" (bt:thread-name th)))
      (bt:all-threads)))

    ; Capture and handle interactive interrupt
    ; i.e. C-c invoked by a user.
    (#+sbcl sb-sys:interactive-interrupt
     #+ccl  ccl:interrupt-signal-condition
     #+clisp system::simple-interrupt-condition
     #+ecl ext:interactive-interrupt
     #+allegro excl:interrupt-signal
     () (progn
          (hunchentoot:stop *server*)
          (format *error-output* "~%")
          (quit-with-status)))
    (error (e) (format *error-output* "Error: ~a~%" e))))

如果讀者想要把玩一下這個程式,可以到這裡看一下。

學會寫靜態網頁後,就可以實作純前端程式了。但我們不會滿足於此,因為還有許多網頁程式的功能尚未展示,像是表單 (form)、Ajax 呼叫、處理 cookie 和 session、連結資料庫等。由於篇幅的限制,我們就不在這篇文章中展示這些功能。

Common Lisp 實作品相關的議題

使用 SBCL 時,Hunchentoot 網頁程式可正確編譯和執行。

使用 Clozure CL 時,雖可編譯和執行 Hunchentoot 網頁程式,但無法順利地停止網頁程式。

由於 CLISP 的版本過舊,無法順利安裝 Hunchentoot 函式庫。理論上,如果自行編譯開發版本的 CLISP,應該可以解決這個問題。但已經有 SBCL 可用了,不想再繞遠路。

由於 ECL 編譯成執行檔的過程過於麻煩,筆者基本上已經放棄 ECL 了,只是拿來測函式庫相容性。

由於 ABCL 不支援互動性中斷,所以被排除在測試目標之外,相當可惜。

對於 Common Lisp 初學者來說,使用 SBCL 仍是最保險的選擇。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。