首页 热点资讯 义务教育 高等教育 出国留学 考研考公
您的当前位置:首页正文

使用NewLisp设计Key-Value数据库系统

2023-11-09 来源:花图问答

一 建立软件框架

这里的框架指的是让软件运行起来的那种框架,因为我需要让成品不依赖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数据库系统

标签:

显示全文