这里的框架指的是让软件运行起来的那种框架,因为我需要让成品不依赖NewLisp的东西,自己就能运行,也就是说编译成可执行程序。
这在主流静态语言中易如反掌的事,在Lisp系列语言中简直比登天还难,只有极少的一星半点的Lisp方言实现能支持,而且效果还不好。以至于新的Lisp方言实现干脆直接挂上JVM平台了,比如Clojure。
而NewLisp是我遇到的最强大的Lisp方言实现,因为我可以像喝水一样极其轻易的编译可执行程序,而且没有任何负面的问题存在。NewLisp编译的原理非常简单,就是复制自身,然后在后面加上代码,这样一个可执行程序就出来了。
; hello.lisp (print "hello world! ") (exit) >newlisp -x hello.lisp hello.exe >hello.exe hello world! >_
当然直接在发行版中附带newlisp.exe,然后用newlisp main.lisp一样可以,但这并不好。编译后的程序不需要额外的命令启动,而且不会被newlisp原本的参数所干扰,这是很重要的一点。
但是这有一个缺点,就是NewLisp只能编译单个lisp文件为可执行程序,至少我目前没有发现编译多个文件的方法。不过这个缺点很容易破解,而且其并不算缺点。我们只需要建立一个框架就可以了:
; main.lisp (load "a.lisp") (load "b.lisp") ... (exit (app:run (rest (main-args))))
这样就做出了一个框架,启动时加载模块,然后用app:run来开始执行,app:run定义在某个模块中,作为程序入口。这样,我们就只需要编译main.lisp:newlisp -x main.lisp demo.exe,然而将所有模块放在demo.exe的目录中,这些模块可随时修改。
但是这样就行了吗?当然不行,因为这是硬编码,所以,我们需要考虑更好的设计。我最终决定使用二级指针来完成,于是整个框架只需要两行代码:
; main.lisp (load "app.lisp") ; 加载一级指针,通过这个模块加载其他模块 (exit (app:run (rest (main-args))))
其中app.lisp就是一级指针,也可以说是模块加载器:
; app.lisp (load "a.lisp") (load "b.lisp") (load "c.lisp") ...
在app.lisp中加载需要的模块,也可以同时做一些事务,或者是定义一些东西等等。这样,除了app.lisp这个文件的文件名不能修改,其他的都可以随意修改,想加模块随意加,想修改模块随意改。
首先我对sdb的定义是一个简单的Key-Value数据库系统,而且是面向本地的。所以我决定像SQLite一样,一个文件对应一个数据库。既然是Key-Value数据库,那么就需要定义好模型。
一个数据库中,平淡的存储Pair就完事了吗,不,我需要个性一点。所以我在Pair的上面加了一层:空间。
一个数据库中可以用多个空间,Pair存储在空间之中。这看起来就像是所有表都只有2列的SQL数据库。
然后是如何操作数据库,我决定采用最简单的方法,因为这本来就只是练手之作。使用一个REPL客户端,通过执行用户的命令来处理事务。当然这个不会有SQL的那些东西,采用最简单的命令结构:
// 设想 >get a.p // 获取空间a中key为p的Pair的值 ... >set a.p 100 // 更新空间a中key为p的Pair的值或建立新的Pair ...
最核心的两个命令:get、set。作为一个Key-Value数据库来说,是核心的东西了。
这几乎就说明了需要做什么了,提供一个REPL客户端,用户通过命令来处理事务,那么设计好对这些命令的处理就差不多是做好sdb了。
以下是目前定义的所有命令:
get <spacename>.<key> // 获取值 set <spacename>.<key> <value> // 更新值或建立Pair list <spacename> // 列出一个空间中的所有Pair erase <spacename>.<key> ... // 删除所有指定的Pair eraseall <spacename> ... // 删除所有指定的空间中的所有Pair spaces // 列出所有空间 dup <source-spacename> <new-spacename> // 复制一个空间到一个新的空间 new <spacename> ... // 建立一至多个空间 delete <spacename> ... // 删除所有指定的空间 link <file> // 连接到一个数据库文件 unlink // 断开当前连接并保存更新 relink // 断开当前连接,仅重置 h or help <command> // 显示命令帮助信息 q or quit // 退出sdb,自动保存更新 q! or quit! // 仅退出sdb
然后是sdb的程序参数,目前倒没什么可定义的,所以只有两个:
link:<file> 启动时连接到一个数据库文件 help 显示使用帮助信息
例子:
sdb // 直接进入REPL sdb help // 显示使用帮助信息,虽然你已经知道了 sdb link:db.txt // 启动时连接数据库文件db.txt
sdb本身是一个Key-Value数据库系统,同时提供REPL客户端,那么我们就划分两个模块,一个是客户端模块,一个是数据库模块。客户端模块处理用户输入,调用数据库模块来处理事务并输出结果,数据库模块负责数据模型和文件存储。
于是sdb需要2个模块,总共4个代码文件:
sdb/ app.lisp ; 一级指针,模块加载器 client.lisp ; 客户端模块 db.lisp ; 数据库模块 main.lisp ; 软件框架
; main.lisp (load "app.lisp") (exit (app:run (rest (main-args)))) ; app.lisp (load "db.lisp") (load "client.lisp") ; db.lisp (context ‘db) ... (context ‘MAIN) ; client.lisp (context ‘app) ... (define run (lambda (.args) )) (context ‘MAIN)
在NewLisp中定义模块很简单,使用(context)函数就行了,(context)会建立一个模块,或者切换到已有的模块,访问一个模块的东西时,需要用<模块名>:<符号名>,就比如app:run,就是访问app模块中的run,从而调用该函数。每个模块开始和结尾都调用了一次(context)函数,这是一种模块设计方式。第一次是建立模块,第二次是切换到顶层作用域。在这里,main.lisp是框架,app.lisp是加载器,所以不需要定义成模块。
client.lisp主要负责处理用户的输入,并调用db.lisp来处理事务,然后输出结果,不断的循环,直到用户砸了机器为止。当然肯定不需要砸机器,这是一个标准的Read->Exec->Print->Loop循环,我们已经提供了quit/quit!两个命令让用户能退出。在框架中,已经预先限定了,需要app模块中有个run函数,这将作为程序的入口。简单起见,直接在run中做好REPL循环,顺带检查参数。
(define run (lambda (.args) ; 如果有参数,调用do-options解析并处理 (unless (empty? .args) (do-options .args)) ; 显示软件标题 (show-title) ; 是否要继续循环,是就继续 (while (nextrun?) ; 显示当前上下文环境 (连接的数据库) (echo-context) ; 等待用户输入然后处理 (query (read-line))) 0))
从代码已经可以清晰的看出整个流程了,但是你可能觉得这个(nextrun?)是不是多余呢,其实不多余。因为整个调用链不小,为了确保正确性,所以使用了一个全局变量。当然这是安全的,因为读写是通过专用函数操作的:
(define _isnextrun true) ; 一种命名风格,前面加_表示为模块内部符号 (define nextrun? (lambda () _isnextrun)) (define stoprun (lambda () (setf _isnextrun false)))
query函数主要负责解析命令、检查命令,执行命令:
(define query (lambda (txt) (letn ((tokens (get-tokens txt)) ; 解析命令文本,转换成单词列表 (cmd (cmd-prehead (first tokens))) ; 是什么命令 (cmdargs (rest tokens))) ; 这个命令的参数列表 (case cmd ; 由于细节较多,所以部分命令的处理过程省略 ("get" ...) ("set" ...) ("list" ...) ("erase" ...) ("eraseall" ...) ("spaces" (map println (db:list-spaces))) ("dup" ...) ("new" ...) ("delete" ...) ("link" ...) ("unlink" (cmd-request (db:unlink))) ("relink" (cmd-request (db:relink))) ("help" (cmd-help cmdargs)) ("quit" (begin (db:unlink) (stoprun))) ("quit!" (stoprun)) (true (unless (empty? cmd) (println "Unknow command, enter h or help see more Information.")))))))
(cmd-request)是对一次数据库操作的过程封装:
(define cmd-request (lambda (result) (if result (println "ok") ; 操作成功,显示ok (println (db:get-last-error))))) ; 失败,显示错误信息
(cmd-prehead)是对命令的预处理:
(define cmd-prehead (lambda (txt) (let ((cmd (lower-case txt))) ; 转换成小写 (case cmd ("h" "help") ; h替换成help ("q" "quit") ; q替换成quit ("q!" "quit!") ; q!替换成quit! (true cmd))))) ; 其他的返回自身
基本上这就是client.lisp的全部了,其他未列出来的东西无关紧要。
在db.lisp中,最核心的问题就是,如何表示数据模型,使用什么结构来构造数据,也可以说是,怎么设计get/set函数。只要get/set设计好了,其他的全部就不是问题了。
如果只是纯粹的Key-Values,那么直接用一个列表就行了,NewLisp有着原生的支持来构造Key-Value表:
> (setf x ‘()) () > (push ‘("a" 1) x) // 建立键值对 (("a" 1)) > (push ‘("b" 2) x) -1) (("a" 1) ("b" 2)) > (assoc "a" x) // 查询键值对 ("a" 1) > (setf (assoc "a" x) ‘("a" "one")) // 更新键值对 ("a" "one") > (pop-assoc "a") // 删除键值对 ("b" 2) > x (("a" "one"))
对于每一个空间,使用原生提供的push/assoc/pop-assoc就可以完成全部的键值对操作了。但是一个数据库有多个空间,那么这些空间怎么放呢?直接嵌套Key-Value表吗?
‘(("s1" // 空间s1,有a=1, b=2两个键值对 (("a" 1) ("b" 2))) ("s2" // 空间s2,有c=1, d=2两个键值对 (("c" 1) ("d" 2))) ("s3" // 空间s3,有e=1, f=2两个键值对 (("e" 1) ("f" 2))))
这样,访问一个键值对就需要如下形式:
(assoc "a" (last (assoc "s1" _spaces))) ; 访问空间s1中的a ... (map (lambda (s) (first s)) _spaces) ; 列出所有空间的名称
对我来说,这样不好,还有一个问题是,更新一个空间中的键值对时,需要(setf (assoc spacename _spaces) newspace)。这意味每一次对空间里键值对的增/删/改操作,都会生成一个新的空间。
在尝试这样设计get/set之后,我决定放弃这种结构,而使用另一种方式。
这种方式很不直接,但很有效。具体的做法是,让每一个空间都对应着一个符号,建立空间时生成一个符号和一个空间对象(Key-Value表),让这个符号指向空间对象,删除一个空间时,直接删除对应的符号就可以了。
这是什么意思呢?就是相当于在运行时定义变量,这个变量的名称都是临时生成的。经过一番设计,最终的结构如下:
(define _spaces ‘())
你没看错,就只是定义一个列表,_spaces中只存放空间的名称。那么如何建立空间呢?建立的空间要怎么访问呢?
(define new-space (lambda (name) (if (have-space? name) ; 这个空间已经存在了吗? (ERROR _SPACE_HAS_EXIST name) ; 已经有了,不能重复建立 (begin ; 如果还未存在 (set (get-space-symbol name) ‘()) ; 定义一个符号,符号的名称根据这个空间的名称来生成,指向一个空的Key-Value表 (push name _spaces))))) ; 加入这个空间的名称
是不是有点困惑,为什么这样就建立了一个空间呢?让我们再看下(get-space-symbol)
(define get-space-symbol (lambda (name) (sym (string "_space." name)))) ; 返回一个符号,这个符号的名称为_space.加上空间的名称
就比如刚才示范的三个空间,一一建立的话,就会在NewLisp的运行时环境中,定义三个新的符号,这些符号指向各自的Key-Value表。
于是,就可以在代码中使用(setf (assoc keyname (get-space-symbol spacename)) (list keyname value))之类的形式来增/删/改一个空间的键值对。
每一次建立空间都会生成一个新的符号,但我们并不能预先知道这个符号是什么名称。上面三个空间在建立时生成_space.s1、_space.s2、_space.s3三个符号,但我们无法直接使用,所以我们需要(eval)来完成对空间的访问。这样的结构,使得get/set也轻松的设计出来:
(define kvs-get (lambda (spacename keyname) (if (have-space? spacename) ; 这个空间存在吗? (let ((kv ; 如果存在 (eval ; 生成[获取键值对的函数调用] 并执行 (list ‘assoc ‘keyname (get-space-symbol spacename))))) (if kv ; 这个键值对存在吗? (last kv) ; 存在,返回值 (ERROR _PAIR_NO_IN_SPACE keyname spacename))) ; 不存在,提供错误信息 (ERROR _SPACE_NO_EXIST spacename)))) ; 这个空间不存在,提供错误信息 (define kvs-set (lambda (spacename keyname value) (if (have-space? spacename) ; 这个空间存在吗? (begin ; 如果存在 (eval (list ; 生成[删除这个键值对的函数调用] 并执行 ‘pop-assoc ‘keyname (get-space-symbol spacename))) (eval (list ; 生成[加入新键值对的函数调用] 并执行 ‘push ‘(list keyname value) (get-space-symbol spacename)))) (ERROR _SPACE_NO_EXIST spacename)))) ; 这个空间不存在,提供错误信息
为什么需要eval呢?因为我们需要访问一个运行时建立的符号,就比如:
var x = 100;
我们可以在源代码中直接对x进行任何操作,但是如果这个x是运行时定义的呢?为什么要运行时定义,因为我们不知道用户会用什么名称来建立空间,我们需要将运行时建立的符号,融合进平常的操作中:
(eval (list assoc ‘keyname (get-space-symbol spacename))) ; 如果spacename传入的是s1 ; 那么就会生成这样的函数调用: (assoc keyname _space.s1) ; 看到这里应该所有人都明白了
为什么这么麻烦?因为NewLisp没有提供指针变量的支持,如果能用指针,就能有更简单好用的设计。这是Lisp的缺点之一,主要是因为很多方言实现都避免指针导致的。就像很多Lisp中易如反掌的事,在C这些语言中需要绕出病来一样,很多在C这些语言中易如反掌的事,同样在Lisp中会绕出病。
就在这时,我发现这样的一个提示:当前已输入9840个字符, 您还可以输入160个字符。于是我只好草草结束。但还好的是,这个sdb最关键的几个地方都讲解了一遍。
当然这里讲解比较浅显,具体的东西,还是要看代码才会明白。文章写的这么多字,远不如直接看代码来的清晰。
NewLisp是一个很有价值的Lisp方言实现,虽然目前还有很多缺点。
您还可以输入3个.
使用NewLisp设计Key-Value数据库系统
标签: