用 Prolog 开发 WEB 服务 序言 在绝大多数互联网行业开发者看来,Prolog 不是一门会被用在本职开发工作中的语言。更多的时候,谈论起 Prolog,人们联想到的往往是诸如“逻辑编程”、“人工智能”等词语,将它与 SQL 放在一起,视为一种 DSL,而非像 Java、Python 这样的通用编程语言。因此,我一直很好奇能否使用 Prolog 来开发一些更偏向于业务系统的程序。
答案是肯定的。基于 SWI-Prolog 这个实现和它的标准库,我开发出了一个简单的短链服务,验证了 Prolog 的确可以满足开发一个业务系统的各种功能需求。
Prolog 基础知识 由于本文的大多数读者对 Prolog 应当是比较陌生的,因此在开始讲解如何用它开发一个 WEB 应用之前,必须稍作科普,介绍一下 Prolog 的基础知识,包括但不限于:
Prolog 程序的基本结构;
运行 Prolog 脚本;
编译 Prolog 程序。
Hello World Prolog 是一门语言而不是一个具体的解释器或者编译器,为了可以运行 Prolog 脚本或编译源代码,我选择使用 SWI-Prolog。有了它,就可以运行经典的 Hello World 程序了
1 2 3 4 :- initialization(main, main). main(_ ) :- format("Hello, world!~n" ).
假设上述源代码被保存在文件hello_world.pl
中,那么执行它的命令如下
可以看到它打印出了所期望的文本
现在让我来稍微介绍一下上述代码中的细节。:- initialization(main, main).
是一个给 SWI-Prolog 的“指示”,可以理解为其声明了程序启动后的入口是一个叫做main
的函数。而
1 2 main(_ ) :- format("Hello, world!~n" ).
则是函数main
的定义,它调用内置的函数format
来打印一个字符串到标准输出。
编译 Prolog 程序 利用 SWI-Prolog 可以像运行 Python 脚本一般来运行 Prolog 程序,当然,也可以像 C 程序一样将其从文本形态的源代码编译为一个独立的可执行文件。仍然以前文的源文件hello_world.pl
为例,编译的命令如下
1 swipl --stand_alone=true -o hello_world -c hello_world.pl
效果如下图所示
在 C 语言中,被编译的程序的入口是约定俗成的,即函数main
。而由于在文件hello_world.pl
中用指令:- initialization(main, main).
Prolog 的使用 在开发 WEB 服务的过程中,还会遇到许多与 WEB 无关的、Prolog 自身在其它领域的应用知识,例如:
如何读写磁盘文件;
如何处理 JSON 格式的数据;
如何读写 MySQL;
如何读写 Redis;
因此在这一章节中,将会分别介绍在 Prolog 中如何做到上面的这些事情。
读取磁盘文件 要读取磁盘文件的全部内容,可以使用 SWI-Prolog 的库提供的函数read_file_to_string/3 。假设要读取的文件为/tmp/demo.txt
,其内容如下
1 2 3 4 5 6 7 Shopping List: - Milk - Bread - Eggs - Apples - Coffee
那么read_file_to_string/3
的用法如下
1 2 3 4 5 6 7 :- use_module(library(readutil)). :- initialization(main, main). main(_ ) :- read_file_to_string("/tmp/demo.txt" , String , []), format("file content is: ~s~n" , [String ]).
这样就可以将读到的文件内容完全打印到控制台上,如下图所示
写入磁盘文件 如果要将数据写入到磁盘文件中——例如,在每次处理完请求后记录日志,那么可以使用函数write
。以将前文中的字符串Hello, world!
写入到文件中为例,示例代码如下
1 2 3 4 5 6 7 :- initialization(main, main). main(_ ) :- LogContent = "Hello, world!" , open("/tmp/access.log" , write, Stream ), write(Stream , LogContent ), close(Stream ).
效果如下图所示
解析 JSON 格式 JSON 已经是应用最广泛的数据交互格式之一了,因此如果一门语言要能够投产于业务系统的开发,必然离不开对 JSON 数据的处理能力。假设要处理的 JSON 数据如下
1 2 3 4 5 6 7 8 9 { "mysql" : { "driver_string" : "DRIVER={MySQL ODBC 8.0 Driver};String Types=Unicode;password=1234567;port=3306;server=mysql;user=root" } , "redis" : { "hostname" : "redis" , "port" : 6379 } }
这些内容存储在文件/tmp/config.json
中,那么下列代码会取出其中的叶子节点来输出
1 2 3 4 5 6 7 8 9 10 11 12 :- use_module(library(http/json)). :- initialization(main, main). main(_ ) :- ConfigPath = "/tmp/config.json" , read_file_to_string(ConfigPath , String , []), atom_json_dict(String , JSONDict , []), format("mysql.driver_string = ~s~n" , [JSONDict .mysql.driver_string]), format("redis.hostname = ~s~n" , [JSONDict .redis.hostname]), format("redis.port = ~d~n" , [JSONDict .redis.port]).
上文中的函数atom_json_dict 将字符串类型的变量String
反序列化为变量JSONDict
。从这里 可以看到,SWI-Prolog 为字典类型提供一个中缀操作符.
,使得我们可以像在多数主流语言中引用类的成员变量一般,用简单的语法来获取字典内的字段——即JSONDict.mysql.driver_string
这样的代码。
上面的代码的运行效果如下图所示
自动导入的库 如果分别查看函数read_file_to_string
和atom_json_dict
的文档(分别在这里 和这里 ),就会发现前者的页面上写着can be autoloaded
,而后者没有
所以前文关于read_file_to_string
的例子中,即便不写上:- use_module(library(readutil)).
,也是可以正常调用的
事实与全局变量 在将磁盘上的配置文件的内容加载到内存中后,最好可以将其赋值为一个全局变量以便在所有的函数中访问到。要做到这一点,可以利用 Prolog 的一个特性:事实。
在很多 Prolog 的入门教程中,都会介绍经典的、如何用 Prolog 来回答两个人是否为某种关系的例子。例如,在这个教程 中,就给出了如何判断两个人是否为朋友的示例,如下图所示
其中,像
1 2 3 4 friend(john, julia). friend(john, jack). friend(julia, sam). friend(julia, molly).
这样的代码就是 Prolog 中的“事实”。其中,friend
在 Prolog 中被称为“谓词”,也就是前文中一直提到的函数。因此,如果想要定义一个全局变量,可以:
用dynamic/1
声明一个只有一个参数的“动态”谓词;
用asserta/1
新增一个事实;
在别的位置,用通常的归一语法就可以绑定全局的值到一个变量上了。
就像下面这样子
1 2 3 4 5 6 7 8 9 :- initialization(main, main). :- dynamic odbc_driver_string/1. main(_ ) :- asserta(odbc_driver_string("This is a global variable" )), odbc_driver_string(DriverString ), format("DriverString = ~s~n" , [DriverString ]).
效果如下
用 findall 找到所有的解 在前一小节的截图中,可以看到我们可以询问 Prolog 一个事实是否成立。其实,我们还可以让 Prolog 遍历它的“知识库”,来找出符合我们所查询的问题的“答案”。例如,我先准备好一份内容如下的文件friend.pl
1 2 3 4 friend(john, julia). friend(john, jack). friend(julia, sam). friend(julia, molly).
接着我在 Prolog 的 REPL 中加载它,如下图所示
然后我向 Prolog 提问,代码如下
1 friend(john, X ), write(X ), nl, fail.
在 Prolog 中,英文逗号是一个二元操作符,它就像是 C 语言中的&&
或 Python 中的and
,只有当操作符的左右两边都成立时,整个表达式才成立。由于上面的代码以fail
结尾,因此始终不会成立。Prolog 在遍历知识库、寻找变量X
的值的过程中,遇到表达式无法成立时就会“回溯”,继续寻找下一个可能匹配的X
。通过调用谓词write
,就可以看到 Prolog 回溯的过程了,如下图所示
可以看到 Prolog 尝试用julia
和jack
来作为变量X
的值,但终究无法让查询成立——当然了,因为最后一个值为fail
。如果我们希望将变量X
的所有值都收集到列表中,可以借助内置的谓词findall/3
,示例代码如下
1 findall(X , friend(john, X ), L ).
效果如下图所示
在 Docker 中运行 Prolog 在之后的例子中,我还会介绍如何使用 Prolog 来读写数据库。但在摸索的过程中,我发现在 macOS 上无法运行成功,只有在 Docker 内才可行,因此这一节将会先介绍如何在 Docker 中运行 Prolog。
以前文中的 Hello World 程序为例,在已经有了源文件hello_world.pl
的前提下,准备如下的hello_world.dockerfile
文件
1 2 3 4 5 6 7 8 9 10 FROM swipl:stableCOPY . /app/ WORKDIR /app RUN swipl --goal=main --stand_alone=true -o hello_world -c hello_world.pl \ && cp ./hello_world /bin/ CMD [ "hello_world" ]
然后基于这份配置来构建镜像
1 docker build -f ./hello_world.dockerfile -t hello_world .
如果无法拉取镜像docker.io/library/swipl:stable
,可以先通过 DaoCloud 下载,然后替换掉标签
1 2 docker pull docker.m.daocloud.io/library/swipl:stable docker tag b84634ddb907 docker.io/library/swipl:stable # 此处的镜像 ID b84634ddb907 是在我的机器上的效果。
然后就可以运行这个镜像了
效果如下图所示
读写数据库 如果一门语言无法读写数据库,尤其是关系型数据库,那么用它来开发业务系统必然是捉襟见肘的。这一章中,将会介绍如何用 SWI-Prolog 读写 MySQL 中的数据。
连接 MySQL 通过 SWI-Prolog 的文档 我们可以了解到,要操作关系型数据库,需要用到 ODBC,这一小节以连接 MySQL、调用函数version
为例进行讲解。首先需要有一个 DSN 字符串来指定 MySQL 的连接参数,假设:
密码为1234567
;
端口号为3306
;
主机名为mysql
;
用户名为root
;
那么这串 DSN 可以是DRIVER={MySQL ODBC 8.0 Driver};String Types=Unicode;password=1234567;port=3306;server=mysql;user=root
。将其传递给谓词odbc_driver_connect
即可连接上 MySQL。然后可以用谓词odbc_query
来提交 SQL 语句给 MySQL,并获取执行结果。完整的代码如下所示(其中数据库的密码被我替换为了星号)
1 2 3 4 5 6 7 8 9 10 11 :- use_module(library(odbc)). :- initialization(main, main). main(_ ) :- Dsn = "DRIVER={MySQL ODBC 8.0 Driver};String Types=Unicode;password=******;port=3306;server=host.docker.internal;user=root" , odbc_driver_connect(Dsn , Connection , []), Sql = "SELECT VERSION()" , odbc_query(Connection , Sql , row(Version )), odbc_disconnect(Connection ), format("Version is ~s~n" , [Version ]).
为了运行它,还得在容器中安装 MySQL 的驱动。修改后的完整 Dockerfile 文件如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 FROM swipl:stableRUN apt-get clean \ && apt-get update \ && apt-get install -y unixodbc-dev COPY . /app/ WORKDIR /app RUN tar -C /tmp/ -xzvf mysql-connector-odbc-8.4.0-linux-glibc2.28-aarch64.tar.gz \ && cd /tmp/ \ && cp -r ./mysql-connector-odbc-8.4.0-linux-glibc2.28-aarch64/bin/* /usr/local/bin \ && cp -r ./mysql-connector-odbc-8.4.0-linux-glibc2.28-aarch64/lib/* /usr/local/lib \ && myodbc-installer -a -d -n "MySQL ODBC 8.0 Driver" -t "Driver=/usr/local/lib/libmyodbc8w.so" RUN swipl --goal=main --stand_alone=true -o query_version -c query_version.pl \ && cp ./query_version /bin/ CMD [ "query_version" ]
然后构建镜像并运行即可,效果如下图所示
执行 SELECT 语句 通过查阅odbc_query/4
的文档 ,可以看到要想从表中查询出一行记录,需要在该谓词的第三个参数RowOrAffected
中传入row(Lemma)
这样的复合表达式。以查询短链的内容为例,假设存储着原始链接与短链的 ID 的关系的表的结构如下
1 2 3 4 5 6 7 8 CREATE TABLE `t_url` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `url` VARCHAR (256 ) NOT NULL COMMENT '短链对应的原始链接' , `ctime` TIMESTAMP DEFAULT CURRENT_TIMESTAMP , `mtime` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP , PRIMARY KEY (`id`), UNIQUE INDEX `ux__url` (`url`) ) AUTO_INCREMENT= 1000001 ;
往其中添加一行测试用的数据
1 INSERT INTO `t_url` SET `url` = 'https://example.com/' ;
接着便可以在 Prolog 中用下列代码将其查询出来
1 2 3 4 5 6 7 8 9 10 11 12 13 :- use_module(library(odbc)). :- initialization(main, main). main(_ ) :- Dsn = "DRIVER={MySQL ODBC 9.2 Unicode Driver};String Types=Unicode;password=1234567;port=3306;server=localhost;user=root" , odbc_driver_connect(Dsn , Connection , []), Sql = "SELECT `id`, `url` FROM `test`.`t_url` WHERE `url` = ?" , odbc_prepare(Connection , Sql , [default], Statement ), odbc_execute(Statement , ["https://example.com/" ], row(Id , Url )), odbc_disconnect(Connection ), format("Id is ~d~n" , [Id ]), format("Id is ~s~n" , [Url ]),
查询结果如下图所示
odbc_execute/3
的第三个参数与谓词odbc_query/4
是相同的,从后者的文档 可以看出,如果要接收查询结果中的多列,那么就需要相应地填上多少个变量。例如,前文中 SELECT 语句要查询的列为id
和url
,因此这里用的是row/2
,其值是两个变量Id
和Url
。以此类推,如果将列ctime
也查询出来,那么就要在row
的对应位置新增一个变量来承载这一列的值。
如果希望从数据库中查出多行记录并组织为列表的形式——这是一个很常见的需求,那么可以使用谓词odbc_prepare
的Options
参数。它等价于odbc_query
的Options
参数,因此可以传入findall(info(Id, Url), row(Id, Url))
来将查询结果中的前两列按照info(Id, Url)
的格式组成一个列表,最终赋值到一个变量中,示例代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 :- use_module(library(odbc)). :- initialization(main, main). main(_ ) :- Dsn = "DRIVER={MySQL ODBC 9.2 Unicode Driver};String Types=Unicode;password=1234567;port=3306;server=localhost;user=root" , odbc_driver_connect(Dsn , Connection , []), Sql = "SELECT `id`, `url` FROM `test`.`t_url` WHERE `id` > ?" , odbc_prepare(Connection , Sql , [default], Statement , [findall(info(Id , Url ), row(Id , Url ))]), odbc_execute(Statement , [0 ], Rows ), odbc_disconnect(Connection ), length(Rows , Length ), format("Length of Rows is ~d~n" , [Length ]), write(Rows ).
最终效果如下