在 REPL 中运行代码当然是不错的,但要简便的发行软件时,就需要构建一个可执行的软件了。

尽管各个Lisp解释器的实现不相同,但都可以创建出 可执行(self-contained executables) 程序,这是因为他们都是基于基础架构构建而成的。用户不需要安装 Lisp 解释器就可以直接执行程序。

在使用 SBCL 和 CCL 时构建时,程序几乎能够瞬间启动。

二进制文件的大小通常都会很大,这是因为里面包含了整个程序所需的依赖库、符号链接、函数的参数信息,还有编译器、调试器以及源代码的地址等详细信息。

注意,同样的也可以构建可执行的 web 应用。

构建可执行程序

SBCL

如何构建能够运行的软件取决于解释器(可以参见下面章节 Buildapp 和 Rowsell)。在 SBCL 中,如文档中提到的那样,构建方法如下:

  1. (sb-ext:save-lisp-and-die #P"path/name-of-executable" :toplevel #'my-app:main-function :executable t)

sb-ext 是 SBCL 执行其他进程的一个插件。更多关于 SBCL 插件的用法,参见SBCL extensions 文档(里面大部分插件在其他的库中都是可移植的)

:executable t 表示创建一个可执行程序,而不是创建个镜像。当然,也可以将当前的状态保存为一个 Lisp 镜像,在之后可以重新恢复过来。镜像在进行计算密集型的工作时很有用。

不要在 Slime 中执行上面的代码,不然会出现一下的错误提示:

Cannot save core with multiple threads running.

直接启动 SBCL 然后在里面运行上面的代码就好。

假如创建的项目是依赖于 Quicklisp 的,那么必须按照下面的步骤进行:

  • 确保 Quicklisp 已经安装并在启动 sbcl 时会自动加载(即完成了 Quicklisp 的安装)
  • 加载了项目的 .asd 文件
  • 安装了所有的依赖关系
  • 构建可执行

代码大致如下:

  1. (load "my-app.asd")
  2. (ql:quickload :my-app)
  3. (sb-ext:save-lisp-and-die #p"my-app-binary" :toplevel #'my-app:main :executable t)

如果是使用命令行或是 Makefile 的话,需要使用 --load--eval

  1. build:
  2. sbcl --load my-app.asd \
  3. --eval '(ql:quickload :my-app)' \
  4. --eval "(sb-ext:save-lisp-and-die #p\"my-app\" :toplevel #'my-app:main :executable t)"

ASDF

上面已经介绍了基础部分,现在来介绍更方便的方法。基于 ASDF 的版本是 3.1,因此有了更简单的方法,就是 [make](https://common-lisp.net/project/asdf/asdf.html#Convenience-Functions) 命令,运行 make 后,程序会自动从 .asd 中获取相应的参数。不过需要在 .asd 中添加以下配置:

  1. :build-operation "program-op" ;; leave as is
  2. :build-pathname "<binary-name>"
  3. :entry-point "<my-package:main-function>"

然后调用 asdf:make :my-package.

因此,其 Makefile 是这样的:

  1. LISP ?= sbcl
  2. build:
  3. $(LISP) --load my-app.asd \
  4. --eval '(ql:quickload :my-app)' \
  5. --eval '(asdf:make :my-app)' \
  6. --eval '(quit)'

Roswell or Buildapp

Roswell, 一个解释器的管理器,可以在多个解释器中使用 ros build 命令。

也可以通过 Roswell 的 ros install my-app 命令来构建个可以安装的软件。具体用法参见官方文档。

最后介绍的是 Buildapp,一个经历了时间的验证依然流行的 ”配置和保存可执行的 Common Lisp 镜像的 SBCL 或 CCL 应用程序”。

使用示例;

  1. buildapp --output myapp \
  2. --asdf-path . \
  3. --asdf-tree ~/quicklisp/dists \
  4. --load-system my-app \
  5. --entry my-app:main

很多程序都在使用 buildapp (比如说 pgloader),在 Debian 上可以通过命令 apt install buildapp 进行安装,但用了 asdf:make 或是 Roswell 的话就没必要安装 buildapp 了。

Web 应用程序

同样的,可以简单的创建可执行的 web 应用。该应用将作为一个 web 服务端,并可以使用命令行来运行:

  1. $ ./my-web-app
  2. Hunchentoot server is started.
  3. Listening on localhost:9003.

注意这是运行在 web 服务器上的,而不是开发环境中,因此可以在 VPS(Virtual Personal Server)上直接运行然后访问这台 web 服务器。

还有一个注意点是,就是找到运行中的 web 应用然后将其放到前台。在 main 函数中,可以这样做:

  1. (defun main ()
  2. (start-app :port 9003) ;; our start-app, for example clack:clack-up
  3. ;; let the webserver run.
  4. ;; warning: hardcoded "hunchentoot".
  5. (handler-case (bt:join-thread (find-if (lambda (th)
  6. (search "hunchentoot" (bt:thread-name th)))
  7. (bt:all-threads)))
  8. ;; Catch a user's C-c
  9. (#+sbcl sb-sys:interactive-interrupt
  10. #+ccl ccl:interrupt-signal-condition
  11. #+clisp system::simple-interrupt-condition
  12. #+ecl ext:interactive-interrupt
  13. #+allegro excl:interrupt-signal
  14. () (progn
  15. (format *error-output* "Aborting.~&")
  16. (clack:stop *server*)
  17. (uiop:quit)))
  18. (error (c) (format t "Woops, an unknown error occured:~&~a~&" c))))

在上面的代码中,使用了 bordeaux-threads(ql:quickload "bordeaux-threads"),命名为 bt)和 uiop,其中 uiop 是 ASDF 的一部分,默认会加载。这样就可以以一种可移植的方式退出(使用 uiop:quit 而不是 sb-ext:quit,因为 uiop:quit 有个可选的返回代码)。

解释器生成可执行文件的大小及启动时间

SBCL 并不是唯一的 Lisp 解释器。ECL,一个嵌入式 Common Lisp 解释器,可讲 Lisp 程序编译成 C 语言,这样就能生成个小一点的二进制文件。

reddit 上的贴来看,ECL 生成的二进制文件是所有解释器中大小最小的,要比 sbcl 生成的要小一个数量级,但是启动时间要更长。

用 CCL 生成的二进制文件和 SBCL 生成的启动时间要差不多,但 CCL 生成的文件大小是 SBCL 一半。

  1. | program size | implementation | CPU | startup time |
  2. |--------------+----------------+------+--------------|
  3. | 28 | /bin/true | 15% | .0004 |
  4. | 1005 | ecl | 115% | .5093 |
  5. | 48151 | sbcl | 91% | .0064 |
  6. | 27054 | ccl | 93% | .0060 |
  7. | 10162 | clisp | 96% | .0170 |
  8. | 4901 | ecl.big | 113% | .8223 |
  9. | 70413 | sbcl.big | 93% | .0073 |
  10. | 41713 | ccl.big | 95% | .0094 |
  11. | 19948 | clisp.big | 97% | .0259 |

SBCL 核心压缩(core compression)

SBCL 的核心压缩构建可以极大地减少应用程序二进制文件的大小。在示例中中,将文件大小从 120MB 缩小到 23MB,启动时间增加了 12ms,但启动时间仍然低于 50ms!

要注意的是安装的 SBCL 必须支持核心压缩,具体参见文档:http://www.sbcl.org/manual/#Saving-a-Core-Image

是这种情况吗?

  1. (find :sb-core-compression *features*)
  2. :SB-CORE-COMPRESSION

没错,这就是在 Debian 上的 SBCL 的结果。
SBCL

在 SBCL 中,需要给出 save-lisp-and-die 的参数,如 :compression

may be an integer from -1 to 9, corresponding to zlib compression levels, or t (which is equivalent to the default compression level, -1).

-1 到 9 的级别大概有 1MB 的差别。

ASDF

然而,我们更喜欢使用 ASDF(或者是说 UIOP)。在 .asd 文件中添加以下代码:

  1. #+sb-core-compression
  2. (defmethod asdf:perform ((o asdf:image-op) (c asdf:system))
  3. (uiop:dump-image (asdf:output-file o c) :executable t :compression t))

Deploy

当然,Deploy 库也能用来构建完整独立的应用。如果可以的话,deploy 也会使用压缩技术.

deploy 专门用来构建依赖外部库的程序。deeply 会收集所有的外部共享库的依赖,比如说 bin 子目录下中的 libssl.so 共享库。

解析命令行参数

SBCL 将命令行的参数保存在 sb-ext:*posix-argv* 变量中。

但不同的解释器保存参数的变量名不一样,所以就会想要使用个三方库来处理这些差异。

同时也想要解析这些参数。

快速浏览一遍 awesome-cl#scripting 然后就开始讲解 unix-opts

  1. (ql:quickload "unix-opts")

也可以通过 unix-opts 库的别名 opts 来调用。

通常分为两个阶段:

  • 定义程序的参数,可选参数以及参数的类型(字符串,整型等),或者或短的名字和必须的参数。
  • 解析(包括处理参数丢失或格式错误)。

声明参数

可以使用 opts:define-opts 来声明定义参数:

  1. (opts:define-opts
  2. (:name :help
  3. :description "print this help text"
  4. :short #\h
  5. :long "help")
  6. (:name :nb
  7. :description "here we want a number argument"
  8. :short #\n
  9. :long "nb"
  10. :arg-parser #'parse-integer) ;; <- takes an argument
  11. (:name :info
  12. :description "info"
  13. :short #\i
  14. :long "info"))

其中 parse-integer 是 CL 的内建函数。

以下是使用命令行的示例(其中帮助文档是自动生成的):

  1. $ my-app -h
  2. my-app. Usage:
  3. Available options:
  4. -h, --help print this help text
  5. -n, --nb ARG here we want a number argument
  6. -i, --info info

解析参数

通过 opts:get-opts 来获取参数,其中 opts:get-opts 会返回两个值:有效参数的列表和剩下的参数。之后就需要使用 multiple-value-bind 将这两个值绑定到变量;

  1. (multiple-value-bind (options free-args)
  2. ;; There is no error handling yet.
  3. (opts:get-opts)
  4. ...

可以给 get-opts 字符串列表来进行验证:

  1. (multiple-value-bind (options free-args)
  2. (opts:get-opts '("hello" "-h" "-n" "1"))
  3. (format t "Options: ~a~&" options)
  4. (format t "free args: ~a~&" free-args))
  5. Options: (HELP T NB-RESULTS 1)
  6. free args: (hello)
  7. NIL

当传入未知的选项时,将会进入到 debugger。关于异常处理的部分马上会介绍到。

因此,option 是个 plist。我们可以通过 getfsetf 来操作 plist,这就是我们的逻辑。下面将通过 opts:describe 输出帮助文档,通过 exit退出(一种可移植的方法)。

  1. (multiple-value-bind (options free-args)
  2. (opts:get-opts)
  3. (if (getf options :help)
  4. (progn
  5. (opts:describe
  6. :prefix "You're in my-app. Usage:"
  7. :args "[keywords]") ;; to replace "ARG" in "--nb ARG"
  8. (opts:exit))) ;; <= optional return status.
  9. (if (getf options :nb)
  10. ...)

想要查看完整的例子的话,参见 official examplecl-torrents’ tutorial

unix-opts 库中建议使用宏,因为宏的效果要好一些。下面来介绍异常处理。

格式错误及参数丢失处理

以下 4 中情况 unix-opts 无法处理,但可以抛出异常:

  • 未知参数:抛出 unknown-option 异常
  • 缺少参数:抛出 missing-arg 异常
  • 格式错误:抛出arg-parser-failed 异常。如参数是整型而传进来的是文本。
  • 缺少必需选项: 抛出 missing-required-option 异常。

因此,就需要创建一些简单的函数来处理这些异常,然后通过 handler-bind 进行解析:

  1. (multiple-value-bind (options free-args)
  2. (handler-bind ((opts:unknown-option #'unknown-option) ;; the condition / our function
  3. (opts:missing-arg #'missing-arg)
  4. (opts:arg-parser-failed #'arg-parser-failed)
  5. (opts:missing-required-option))
  6. (opts:get-opts))
  7. ;; use "options" and "free-args"

在这里,我们想要处理各种异常情况,但有个更简单点的方法,就是将异常情况作为参数:

  1. (defun handle-arg-parser-condition (condition)
  2. (format t "Problem while parsing option ~s: ~a .~%" (opts:option condition) ;; reader to get the option from the condition.
  3. condition)
  4. (opts:describe) ;; print help
  5. (opts:exit)) ;; portable exit

更多关于异常处理的,参考 第12章:异常处理

捕获终止信号(C-c)

先创建一个简单的二进制文件,运行,然后按 C-c,查看 stacktrace:

  1. $ ./my-app
  2. sleep
  3. ^C
  4. debugger invoked on a SB-SYS:INTERACTIVE-INTERRUPT in thread <== condition name
  5. #<THREAD "main thread" RUNNING {1003156A03}>:
  6. Interactive interrupt at #x7FFFF6C6C170.
  7. Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.
  8. restarts (invokable by number or by possibly-abbreviated name):
  9. 0: [CONTINUE ] Return from SB-UNIX:SIGINT. <== it was a SIGINT indeed
  10. 1: [RETRY-REQUEST] Retry the same request.

信号的命名是在实现之后:sb-sys:interactive-interrupt。只需将程序添加到 hadler-case 中就可以:

  1. (handler-case
  2. (run-my-app free-args)
  3. (sb-sys:interactive-interrupt () (progn
  4. (format *error-output* "Abort.~&")
  5. (opts:exit))))

然而,上面这段代码只能在 SBCL 中有效。之前已经介绍过 trivial-signal 但目前并不满足所需要的测试。因此可以像下面这样:

  1. (handler-case
  2. (run-my-app free-args)
  3. (#+sbcl sb-sys:interactive-interrupt
  4. #+ccl ccl:interrupt-signal-condition
  5. #+clisp system::simple-interrupt-condition
  6. #+ecl ext:interactive-interrupt
  7. #+allegro excl:interrupt-signal
  8. ()
  9. (opts:exit)))

其中, #+ 开头的代码表示在不同的解释器中执行不同的操作。当然,也有 #-#- 的作用是在 *features* 这个变量中去匹配后面的符号,也可以使用 andornot#+ 后面的符号进行组合。

二进制文件的可持续交付

在提交、发布时或者其他的操作是,也可以使用持续集成系统(Travis CI,Gitlab CI 等)。

更多参见 Continuous Integration

鸣谢