Common Lisp 脚本化实践

[2024/01/21] 更新:这个方法我已经弃用了。现在有了更好的解决方案,就是使用 roswell。

Common Lisp 脚本化实践

背景

Common Lisp 生成的可执行文件实际上是内存堆的映像,包含了完整的运行时,库(标准库及所有载入的第三方库),编译器,调试器等等。通常体积巨大。对于“产品”的交付和部署这倒不是什么大问题,但是对于脚本,特别是个人脚本就显得有些尴尬了。不经过的编译的 CL 脚本通常会有比较明显的启动延迟,载入的库越多延迟越久。而经过编译的每一个 CL 脚本都携带着一坨完整的Lisp环境,就好像这个世界上不存在动态链接这种技术,想像一下,每一个 C 程序都是静态编译的...

本文使用的技术来自于 busybox。busybox 将很多小的命令行程序集成进同一个可执行文件,然后创建不同名字的软链接。busybox 通过用户运行的命令名执行不同的程序逻辑。

目前 buildapp 和 cl-launch 都支持类似的技术来生成单一的堆映像,不同的是 buildapp 仅支持 ccl 和 sbcl 两个实现,所以本文选择了 cl-launch 来做实验。实验的结果证实,CL 确实可以作为一个合格的脚本语言来用。

一些约束

  1. 整个“脚本环境”都容纳在一个单独的 ASDF “系统”中;

  2. 每个脚本创建了一个独立的包(package),在命名空间上和其它脚本隔离;

  3. 每个脚本的入口命名为 main 函数,和 C 一样; 命令行传递的所有参数都以一个列表的形式传递给 main函数。

  4. 脚本名 = 包名 = 文件名

这些约束并非强制性的,仅仅是我自己的做法。不采用同样的策略当然是可行的,但是某些后面的步骤也需要相应地修改。

内容

我的系统名叫clbox,整个系统放在 ~/common-lisp/目录中,这是 ASDF 默认的源注册目录之一。

$ cd ~/common-lisp/clbox

$ ls
bar.lisp  clbox.asd  foo.lisp

$ cat bar.lisp

(defpackage :bar
  (:use :cl))

(in-package :bar)

(defun main (args)
  (declare (ignore args))
  (format t "Hello world from bar~%"))
  
$ cat foo.lisp

(defpackage :foo
  (:use :cl))

(in-package :foo)

(defun main (args)
  (declare (ignore args))
  (format t "Hello world from foo~%"))
  
$ cat clbox.asd

(asdf:defsystem #:clbox
  :description "CL scripts demo"
  :components ((:file "foo")
               (:file "bar")))

构建

$ cl-launch -o clbox \
>           -d ! \
>           -s clbox \
>           -p foo -DE foo \
>           -p bar -DE bar

$ ls
bar.lisp  clbox  clbox.asd  foo.lisp

$ ln -s clbox foo

$ ln -s clbox bar

$ ls -l
lrwxrwxrwx 1 fm fm        5 2月  19 20:29 bar -> clbox
-rw-rw-r-- 1 fm fm      135 2月  18 18:48 bar.lisp
-rwxr-xr-x 1 fm fm 42672688 2月  19 20:28 clbox
-rw-rw-r-- 1 fm fm      117 2月  18 18:47 clbox.asd
lrwxrwxrwx 1 fm fm        5 2月  19 20:29 foo -> clbox
-rw-rw-r-- 1 fm fm      135 2月  18 18:49 foo.lisp

$ ./foo
Hello world from foo

$ ./bar
Hello world from bar

自动化构建

其实作为概念演示,上面的内容就够了。下面的脚本build.lisp用于自动化构建并自动生成软链接,以降低维护工作量。

#!/usr/bin/cl -l ccl -E main

;;; Directory to save the output
;;; Don't forget the last slash, or the last part of the path will be treated
;;; as a file rather than a directory
(defparameter *bindir* "~/.local/bin/")

;;; File name of the executable heap image
(defparameter *image* "clbox")

;;; Specify the LISP implementation
(defparameter *lisp* "ccl")

;;; Names of all scripts
(defparameter *script-list* '("script-1"
                              "script-2"
                              "script-3"
                              "more-scripts-add-to-here"
                             ))

(defun make-args ()
  (apply #'append
         (mapcar #'(lambda (s)
                     (list "--package" s "-DE" s))
                 *script-list*)))

(defun symlink-exists (src target)
  (and (uiop:probe-file* target)
       (equal (uiop:probe-file* src :truename t)
              (uiop:probe-file* target :truename t))))

(defun make-symlinks ()
  (dolist (s *script-list*)
    (when (not (symlink-exists *image* s))
        (format t "~&Create symlink for ~A~%" s)
        (uiop:run-program `("ln" "-s" ,*image* ,s)
                          :output t
                          :error-output t))))

(defun make-cmd ()
  `("cl-launch" "--output" ,*image*
                "--dump" "!"
                "--lisp" ,*lisp*
                "--system" "clbox"
                ,@(make-args)))

(defun main (args)
  (declare (ignore args))
  (when *bindir*
    (let ((dir-exist? (uiop:probe-file* *bindir*)))
      (when (not dir-exist?)
        (format t "~&The specified directory ~A does not exists
try to create it~%" *bindir*)
        (let ((mkdir-status (ensure-directories-exist
                             (uiop:native-namestring *bindir*))))
          (when (not mkdir-status)
            (error "~&Directory ~A creation failed, cannot continue~%" *bindir*)
            (uiop:quit)))))
    (uiop:chdir *bindir*))
  (let ((cmdline (make-cmd)))
    (uiop:run-program cmdline :output t :error-output t)
    (format t "~&heap image ~A updated~%" *image*)
    (make-symlinks)
    (format t "~&All done~%")))

脚本将输出目录设置为 ~/.local/bin,在我的系统中,这个目录会自动添加到环境变量PATH中。

构建完毕后会自动在相同的目录里面建立软链接。不过这里有个问题,当用 sbcl 运行此脚本的时候,用于检测链接是否已经存在的判断逻辑会失效,而使用cclclisp就没有这个问题。所以,请避免使用 sbcl 来运行构建脚本。至于用于生成可执行文件的 Lisp 实现,倒是没什么问题,全局变量*lisp*可以设为任何你喜欢,而且受cl-launch支持,而且可以导出可执行映像的 CL 实现。

现在,要新加入一个脚本,其工作量除了编写脚本本身外,只剩下:

  1. 修改 clbox.asd 将新脚本列为系统组件;

  2. 将新脚本的名字添加到 *script-list* 中;

  3. 执行 ./build.lisp

  4. 收工

补充:让ASDF可以找到Quicklisp安装的库

quicklisp 是一个“重量级”的工具,在REPL中使用没什么问题,但是在脚本中就没必要了。可以将 quicklisp 的目录添加到 ASDF 的源注册目录中,在构建时 ASDF 就可以找到并 load 已经安装的第三方包。

mkdir -p ~/.config/common-lisp/source-registry.conf.d

echo '(:tree (:home "quicklisp/dists/quicklisp/software/"))' > ~/.config/common-lisp/source-registry.conf.d/include-quicklisp.conf
posted @ 2022-02-19 20:56  fmcdr  阅读(353)  评论(0编辑  收藏  举报