乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

相信各位读者对秒表都不陌生,智能手机上通常都有这样一款软件

来自我的小米手机的截图

有一天心血来潮,便想要“复刻”一个命令行版本的秒表程序——主要是想尝试一下新学会的、“原地更新”的技能,而不是一行接一行地输出。程序的运行效果如下

那么这是怎么做的呢?

实现思路及代码

如何获取流逝的时间长度?

要实现一个秒表,首先要知道从开始计时至今过了多久。在*nix系统中,表示时刻的事实标准是Epoch Time,在shell脚本中要获取Epoch Time可以用date命令。再用首尾时刻相减便得到了期间流逝的秒数了,示例代码如下

1
2
3
4
begin_at=$(date '+%s')
# 睡个觉
end_at=$(date '+%s')
((interval=${end_at} - ${begin_at}))

双圆括号是一种在shell脚本中执行算术运算的语法,其它语法可以参见Math in Shell Scripts

如何换算为时分秒?

有了interval中存储的总秒数后,换算成时分秒便是轻而易举的事情,示例代码如下

1
2
3
((hours=${interval} / 3600))
((minutes=(${interval} % 3600) / 60))
((seconds=(${interval} % 3600) % 60))

如何输出形如hh:mm:ss的格式?

hh:mm:ss的意思是分别用两个十进制数字显示时分秒,并以冒号分隔它们。如果有任何一个单位的数值小于10,便用字符0填充左侧的空白。按这个格式,凌晨1点2分3秒便会显示为01:02:03

要在命令行中打印字符串,最容易想到的便是echo命令,只可惜它不能方便地实现填充字符0的需求。

强人所难也不是不行,示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
hours=1
minutes=2
seconds=3

if [ "${hours}" -lt '10' ];
then
echo -n "0${hours}"
else
echo -n "${hours}"
fi
echo -n ':'
if [ "${minutes}" -lt '10' ];
then
echo -n "0${minutes}"
else
echo -n "${minutes}"
fi
echo -n ':'
if [ "${seconds}" -lt '10' ];
then
echo -n "0${seconds}"
else
echo -n "${seconds}"
fi

更优雅的方法是用printf命令来自动填充左侧的字符0

1
printf "%02d:%02d:%02d" ${hours} ${minutes} ${seconds}

printf命令类似于C语言中的printf函数——它也支持打印转义的字符,下文会提到。

如何覆盖已经打印的内容?

今年以来我在断断续续地看Build Your Own Text Editor,学习如何开发文本编辑器。在这本小册子的第三章中,作者讲述了如何使用终端的转义序列(escape sequence)来控制屏幕上显示的东西——这正是秒表程序所需要的。

例如,在终端输出转义序列\x1b[2J可以清空屏幕,效果如下

为了覆盖已经打印出来的时分秒,需要:

  1. 先将光标移动到行首;
  2. 再清除从光标开始到行末的内容。

查阅《VT100 User Guide》第三章可以知道

  1. 要把光标移动到行首可以用转义序列\x1b[8D。之所以是8,是因为按照hh:mm:ss输出时分秒后光标距离行首8个身位;
  2. 要清除光标到行末内容可以用转义序列\x1b[0K(实际上,将光标移到行首只需要使用回车(carriage return)即可,但它被解释为开启新的一行了)。

更优雅的方法甚至连转义序列也不需要,只要用tput命令即可,示例代码如下

1
2
3
4
echo -n '11:22:33'
tput cr
tput el
echo '44:55:66'

关于crel,以及更多可以传给tput命令的参数,可以参见terminfoman文档。

如何每隔一秒钟输出一次?

这大概是整个程序中最简单的需求了

1
2
3
4
5
while [ 1 -eq 1 ]
do
# 此处可以为所欲为
sleep 0.5
done

完整的秒表实现

至此,完整的秒表程序就可以实现出来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
# 秒表,以hh:mm:ss的格式展示数据

begin_at=$(date '+%s')

while [ 1 -eq 1 ]
do
end_at=$(date '+%s')
# 算术运算:http://faculty.salina.k-state.edu/tim/unix_sg/bash/math.html
((interval=${end_at} - ${begin_at}))
((hours=${interval} / 3600))
((minutes=(${interval} % 3600) / 60))
((seconds=(${interval} % 3600) % 60))
tput cr
tput el
printf "%02d:%02d:%02d" ${hours} ${minutes} ${seconds}
sleep 0.5
done

运行后的效果正如本文开头的GIF所示。

全文完。

浅尝一下NOT EXISTS

最近老婆在看视频学习MySQL,然后碰到了这样一道习题:有三个表,分别记录学生、课程,以及学生选修了什么课程的信息,问如何用NOT EXISTS找出选修了所有课程的学生。

为了避免想破脑袋编造一些尴尬的学生姓名和课程名,我简化了一下习题中的表的结构,只留下它们的ID列。建表语句如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 学生表
CREATE TABLE `student` (
`id` INT NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
);

-- 课程表
CREATE TABLE `course` (
`id` INT NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
);

-- 选修关系
CREATE TABLE `elective` (
`student_id` INT NOT NULL,
`course_id` INT NOT NULL,
FOREIGN KEY (`student_id`) REFERENCES `student`(`id`),
FOREIGN KEY (`course_id`) REFERENCES `course`(`id`)
);

还需要给它们塞入一些示例数据

1
2
3
INSERT INTO `student` (`id`) VALUES (1), (2), (3), (4), (5);
INSERT INTO `course` (`id`) VALUES (1), (2);
INSERT INTO `elective` (`course_id`, `student_id`) VALUES (1, 1), (2, 1), (1, 2), (2, 3), (2, 5), (1, 5);

显然,只有id列的值为1和5的学生是选修了全部课程的。用NOT EXISTS写出来的SQL语句如下

1
2
3
4
5
6
7
8
9
10
SELECT * 
FROM `student`
WHERE NOT EXISTS (SELECT *
FROM `course`
WHERE NOT EXISTS (SELECT *
FROM `elective`
WHERE `student`.`id` =
`elective`.`student_id`
AND `course`.`id` =
`elective`.`course_id`));

DBEaver中运行后的结果为

在DBEaver中执行的结果

正确地找出了两个选修了所有课程的学生的id

如何理解双重NOT EXISTS

当第一次被请教这道习题的时候,我其实并不能理解NOT EXISTS的含义。直到后来去看EXISTS文档,才顿悟了上面的SQL。

我的理解方法是将双重NOT EXISTS转换为三层循环。以上面的SQL为例,转述为人话就是:找出student表中所有的、没有任何一门course表中的课程是没有选修的、的学生——双重的 没有

转换为三层循环大概长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (const student of students) {
// 是否存在学生未选修的课程
let existSuchCourse = false;
for (const course of courses) {
let existSuchElective = false;
for (const elective of electives) {
if (elective.student_id === student.id && elective.course_id === course.id) {
existSuchElective = true;
break;
}
}
// 如果遍历完elective表的记录后,existSuchElective仍然为false,说明的确有一门课程是没有选修记录的
// 那么便意味着“存在至少一门课程,使得当前被遍历的学生与该课程没有选修关系”。
if (!existSuchElective) {
existSuchCourse = true;
break;
}
}
// 如果遍历完一圈后确实没有找到“未选修”的课程,说明这名学生全都选修了
if (!existSuchCourse) {
console.log(student);
}
}

NOT EXISTS的本质

即使不强行理解,也可以让MySQL明确告知双重NOT EXISTS是怎么运作的。用EXPLAIN解释上面的SQL的结果如下图所示

MySQL的EXPLAIN命令的文档中说明了如何解读执行计划

EXPLAIN returns a row of information for each table used in the SELECT statement. It lists the tables in the output in the order that MySQL would read them while processing the statement. This means that MySQL reads a row from the first table, then finds a matching row in the second table, and then in the third table, and so on. When all tables are processed, MySQL outputs the selected columns and backtracks through the table list until a table is found for which there are more matching rows. The next row is read from this table and the process continues with the next table.

以上面的EXPLAIN为例,MySQL从student表中读出一行,再从course表中读取一行,最后从elective表中读取一行,然后看看WHERE子句是否能够被满足。如果可以,就输出从student表中读出来的这行数据。上图第2和第3行的select_type都是DEPENDENT SUBQUERY,表示它们依赖于“外层”的查询上下文——electiveWHERE子句依赖于studentcourse中读出来的行。

似乎和方才的三重循环有异曲同工之妙呢。

后记

NOT EXISTS这么“高阶”的功能我从未在业务代码中读过和使用过——别说NOT EXISTS,就算是EXISTS也是从未有之,甚至连子查询也极少。毕竟“正经的互联网公司”只是把MySQL当妹妹当一个具备复杂查询查询功能的key-value数据库来使用(笑

比起双重NOT EXISTS,我更可能凭直觉写出基于子查询的解决方法

1
2
3
4
5
6
SELECT * 
FROM `student`
WHERE `id` IN (SELECT `student_id`
FROM `elective`
GROUP BY `student_id`
HAVING( Count(0) ) = 2);

我甚至觉得会有人把数据库里的行读进内存然后用应用层代码来找出选修了全部课程的学生!

全文完。

API测试用例是什么?

在互联网大行其道的今天,身为一名电商平台的程序员,必定经常与HTTP API打交道,一个常见的情况便是做API测试。抛开可以用单元测试代替的,很多时候需要真地发出HTTP请求才行。这些负责发出HTTP请求的东西可能是一行curl命令,可能是一个.js文件,也可能是一个在postman中点击按钮的操作,但不管形态如何,它们便是API测试用例。尽管名字中带有“用例”二字,但很多时候是由人来校验结果的,用例更关注发出怎样的HTTP请求。

为什么要管理它们?

不同于每天在浏览器中发生成千上万次的、平凡的HTTP(或HTTPS)请求,API测试用例是值得一番精心管理的,因为:

  1. API测试用例通常会重复使用,因此必须将它们持久化保存。也许是保存成shell脚本,也许是保存成脚本语言源文件,也许是保存为某一款软件的数据文件;

  2. 需要为多个API编写测试用例,因此必须区分不同的API对应的测试用例。例如,负责管理业务资源的服务(比如一个管理商品数据的、提供RESTful API的服务),起码需要提供增删查改的功能,那么也就需要有增删查改对应的API测试用例;

  3. 需要为多个服务的API编写用例,因此必须区分不同的服务对应的测试用例集。例如,既然有商品服务,那么极可能还有订单服务、优惠券服务、物流服务,等等,每个服务又都有增删查改的功能,这些不同服务的API也需要各自的测试用例;

  4. 需要区分不同的运行环境。通常本地、开发、测试,以及生产环境是互相隔离的,一个用例中的参数往往不能照搬到另一个环境中。

API、服务,以及环境这三个维度上的区别,使得测试用例的数量显著增加,如不进行管理,当要用时,要么不得不从零开始再写一遍脚本,要么得翻查很久才能找到所需的用例。

用org-mode管理

org-mode是什么?

org-mode是一款Emacs编辑器的扩展,它让使用者能够用快速高效的纯文本方式来记笔记、维护待办事项、安排计划,以及编写文档。org-mode的精髓在于它的大纲组织能力,以及依托于Emacs的扩展能力,两者使其正好可以胜任管理API测试用例的工作。一个.org文件的示例如下图所示

org-mode官网的示例截图

如何用org-mode管理API测试用例?

尽管org-mode提供了丰富的功能,但只是管理API测试用例的话,并用不上太多花里胡哨的东西,只需要org-mode的大纲功能和org-babel特性即可。

首先用不同的.org文件区分不同的环境。

接着用不同层级的headline区分不同的服务、资源类型,以及API。

然后用org-mode代码块语法来编写HTTP请求。以请求https://httpbin.org/uuid为例

1
2
3
#+BEGIN_SRC restclient
GET https://httpbin.org/uuid
#+END_SRC

#+BEGIN_SRC#+END_SRC分别表示开启和结束代码块,restclient表示这个代码块内的代码可以用Emacs的restclient-mode来编辑。在代码块中,GET https://httpbin.org/uuid表示以GET方法请求https://httpbin.org/uuid

安装了restclient后,将光标定位在代码块上并按下ctrl-c ',可以进入一个单独的buffer编辑其中的源代码

最后,如果配置了org-babel,甚至可以直接在代码块上按下ctrl-c ctrl-c来发出HTTP请求。

可以看到,HTTP响应的内容会保留在这个`.org`文件中。

后记

以前我也用过其它的工具来管理API测试用例:

  1. 刚工作的时候用的是Postman,那时候Postman还是Chrome的一个插件;
  2. 后来出于对Firefox浏览器的喜爱,找了一个叫RESTClient的插件来代替postman,久而久之发觉两者的差距蛮大,终究无法代替;
  3. 接着遇到了Emacs中的restclient.el,于是用了好一段时间的纯restclient-mode(没有搭配org-mode);
  4. 再后来开始用Mac办公了,便开始寻找Mac下的这类工具,遇到了Insomnia。如果有人找我推荐用于HTTP API测试的GUI工具的话,我会毫无不犹豫地推荐这款。

再后来,我又回到了Emacs,并用org-mode来管理这些API测试用例。目前这是最适合我的一种方式。

全文完。

“究竟在干什么”是一系列关于软件背后运作原理的文章,每一篇文章旨在讲解一些在日常编程实践中常见但可能并不为人所熟知的技术细节,抛砖引玉,期待激发读者朋友的更多思考。

序言

每当需要ssh登录到服务器并运行一个比较花时间的脚本时(比如临时从生产环境导出数据),为了能够知道脚本是否运行结束,或者是否出错退出,我都会将脚本的输出内容重定向到文件中

1
node foobar.js > /tmp/foobar.log 2> /tmp/foobar.err

如果不在乎将正常的打印和错误混在一起,可以写成

1
node foobar.js > /tmp/foobar.log 2>&1

上面代码中的21分别是标准错误(C语言中的stderr)和标准输出(C语言中的stdout)的文件描述符,2>&1的意思便是将打印到标准错误中的内容转移到标准输出中去——这个转移在shell中的术语便叫做重定向(redirection)。

2>&1该放哪里?

bashman文档中有一个名为REDIRECTION的章节专门介绍了重定向相关的内容,其中有一段有意思的内容

ls不方便做演示,我准备了下面这一段Node.js代码

1
2
console.error('Print to standard error.');
console.log('Print to standard output.');

将代码保存到文件foobar.js中。

如果将2>&1写在后面,那么foobar.log中会包含两行

1
2
3
4
➜  /tmp node foobar.js > /tmp/foobar.log 2>&1
➜ /tmp cat /tmp/foobar.log
Print to standard error.
Print to standard output.

否则,foobar.log中只含有一行内容,另一行会出现在终端上

1
2
3
4
➜  /tmp node foobar.js 2>&1 > /tmp/foobar.log
Print to standard error.
➜ /tmp cat /tmp/foobar.log
Print to standard output.

那么为什么会这样呢?

重定向的时候,shell在做些什么?

以执行node foobar.js > /tmp/foobar.log为例,当shell发现命令中含有重定向的符号时,便开始忙碌起来。

shell首先用open函数打开文件/tmp/foobar.log,拿到一个文件描述符(一个非负整数)。Node.js的fs模块中有一个open方法,在调用成功时,也是往回调函数传入文件描述符

1
2
3
4
5
6
7
8
const fs = require('fs');

fs.open('/tmp/cuckoo.log', function (err, fd) {
console.log(`fd for cuckoo.log is ${fd}`);
fs.open('/tmp/cuckoo.err', function (err, fd) {
console.log(`fd for cuckoo.err is ${fd}`);
});
});

比较奇妙的是,多次运行时拿到的文件描述符总是相同的

1
2
3
4
5
6
7
8
➜  /tmp date; node open.test.js
Fri May 22 21:00:56 CST 2020
fd for cuckoo.log is 21
fd for cuckoo.err is 24
➜ /tmp date; node open.test.js
Fri May 22 21:00:59 CST 2020
fd for cuckoo.log is 21
fd for cuckoo.err is 24

说回重定向。shell拿到文件描述符后,便调用dup2函数。既然有dup2,那么就有dupdup接收一个文件描述符作为参数,返回一个新的文件描述符。而dup2则接收两个参数,它可以作为让第二个参数的数字成为一个新的文件描述符,指向与第一个参数相同的文件。

用图形可以更好地表达dup2的实现原理。下图是一个进程没有重定向时的状态,每个文件描述符都指向它们原本对应的文件

作为数字的文件描述符,相当于是文件描述符表的数组下标。调用dup2后,就变成了

可以将dup2理解为:把文件描述符表的一个元素(以dup2的第一个参数作为下标),按位复制到另一个元素中(以dup2的第二个参数作为下标)。

这样一来,凡是写往文件描述符1的数据,其实都写到了文件/tmp/foobar.log中。

所以,如果命令中重定向操作是2>&1 > /tmp/foobar.log,那么文件描述符表中下标1和2的元素并不会指向相同的文件

如果重定向操作是> /tmp/foobar.log 2>&1,则如下图所示

因此,此时不管是写往文件描述符1还是2,最终都重定向到了/tmp/foobar.log中。

后记

如果想要严谨地知道bash是如何处理重定向的,可以在GitHub的这个Bash源代码镜像上直接查看,找到根目录下的redir.c文件即可。

此外,对于上面的示意图,维基百科的File descriptor词条也有一幅更严谨的版本。

黑客与画家

虽然程序员多数时候都在与字符打交道,但偶尔也会像建筑或制造业的工程师一样,画一些图,比如:

  1. 为了表达多个系统如何协作以实现业务需求,会画时序图;
  2. 为了表达存储到数据库中的业务实体间的关系,会画ER图;
  3. 为了表达复杂的业务实体在整个生存期中状态的变化,会画状态图。

除此之外,还有流程图、甘特图、火焰图,等等。

尽管软件开发过程中产出的这些图不一定逼真、漂亮,或严谨,但凭着图上不同的形状、颜色,以及布局,也可以做到一图胜千言的效果。

图很有用,画图的工具也同样举足轻重。如果是本地的桌面应用,多数人可能会选择用Windows平台的Visio或macOS平台的OmniGraffle;若说到作图网站,则可能会选择ProcessOn或Draw.io。

但比起用鼠标拖拖拉拉,我更喜欢用代码来画图。

用代码画图?

用代码画图大致上可以分为两类:

  1. 用具体的编程语言控制某种绘图的API画出想要的图形,比如OpenGL、HTML5中的Canvas;
  2. 用DSL描述想要画的图,然后用程序根据DSL生成图片。

我所说的用代码画图指的是上述的第二类。

百闻不如一见,以最容易上手的DOT语言为例,将下列内容保存在名为hello.dot的文件中

1
2
3
digraph G {
Hello -> World
}

然后在shell中运行如下命令

1
dot -Tpng hello.dot -o hello.png

便得到了相应的PNG文件

更多的栗子

下面就带各位读者走马观花地看看不同的图可以用哪些工具来绘制。

流程图

说到程序员画的图,最出名的当属流程图了。依稀记得在高中的时候,某一册的数学课本中讲到了算法(也许是辗转相除法),并且给出了图示,那应当就是我第一次见到流程图。上大学后也有一段时间痴迷于寻找能够绘制流程图的DSL,不过一直未果。直到遇到Boostnote后,才知道的确有这样的DSL,那便是flowchart.js

flowchart.js是一个JS编写的、用来绘制流程图的库。比如下面这张图

便是依据下列的DSL生成的

1
2
3
4
5
6
7
8
st=>start: Start
op=>operation: Your Operation
cond=>condition: Yes or No?
e=>end

st->op->cond
cond(yes)->e
cond(no)->op

flowchart.js生成的是SVG格式的图片文件,但SVG文件不方便嵌入到Markdown或Confluence的文档中,因此我会把它转换为PNG格式。折腾了一番后,发现在Mac上最靠谱的方法,是将SVG文件嵌入一个HTML文档,再用浏览器打开这个HTML,然后复制图片到预览程序上保存下来。

遗憾的是,不管是Emacs还是VSCode,似乎都没有辅助编辑flowchart.js的DSL的插件。

有限状态机

有限状态机的示意图也是很常见的图形,尤其是在讲解编译器的书的语法分析章节中。在Graphviz项目官网的Gallery板块中,便有一个有限状态机的例子

它由如下的DOT代码描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
digraph finite_state_machine {
rankdir=LR;
size="8,5"
node [shape = doublecircle]; LR_0 LR_3 LR_4 LR_8;
node [shape = circle];
LR_0 -> LR_2 [ label = "SS(B)" ];
LR_0 -> LR_1 [ label = "SS(S)" ];
LR_1 -> LR_3 [ label = "S($end)" ];
LR_2 -> LR_6 [ label = "SS(b)" ];
LR_2 -> LR_5 [ label = "SS(a)" ];
LR_2 -> LR_4 [ label = "S(A)" ];
LR_5 -> LR_7 [ label = "S(b)" ];
LR_5 -> LR_5 [ label = "S(a)" ];
LR_6 -> LR_6 [ label = "S(b)" ];
LR_6 -> LR_5 [ label = "S(a)" ];
LR_7 -> LR_8 [ label = "S(b)" ];
LR_7 -> LR_5 [ label = "S(a)" ];
LR_8 -> LR_6 [ label = "S(b)" ];
LR_8 -> LR_5 [ label = "S(a)" ];
}

不少工具将DOT语言作为中间媒介来实现绘图的功能。

flowchart.js不同,Emacs和VSCode都可以很好地支持DOT代码的编辑和预览。Emacs上有dot-mode,VSCode则有Graphviz (dot) language support for Visual Studio Code这个插件。

时序图

我画得最多的当属时序图。在旧文《时序图绘制工具走马观花》中,提到了三个工具:

  1. WebSequenceDiagrams,一个在线绘制时序图的网站;
  2. sdedit,一个本地的命令行兼GUI绘图工具;
  3. SequenceDiagram,也是一个网站。

当时倾向于使用sdedit。时过境迁,如今的WebSequenceDiagrams变得更好看了,而我也选择了PlantUML作为绘制时序图的主力工具。下面这张图是PlantUML官网给出的例子

它依据如下的代码生成

1
2
3
4
5
6
7
8
9
10
@startuml
用户 -> 认证中心: 登录操作
认证中心 -> 缓存: 存放(key=token+ip,value=token)token

用户 <- 认证中心 : 认证成功返回token
用户 -> 认证中心: 下次访问头部携带token认证
认证中心 <- 缓存: key=token+ip获取token
其他服务 <- 认证中心: 存在且校验成功则跳转到用户请求的其他服务
其他服务 -> 用户: 信息
@enduml

Emacs的plantuml-mode,以及VSCode的PlantUML插件都可以为PlantUML的DSL提供语法高亮。

下载了PlantUMLjar包后,在Emacs中添加如下的配置,就可以不依赖远程服务器来生成PNG格式的图片了

1
2
(setq plantuml-default-exec-mode 'jar)
(setq plantuml-jar-path "/path/to/plantuml.jar")

UML用例图

在《架构整洁之道》一书中,作者提出了一个软件架构模式,其中有一层便是用例。看完这本书后,我越发地喜欢作者这一套架构模式,渐渐开始在设计文档中给出需求的典型用例——尽管是文字描述。再后来,才知道原来UML中已经有一类专门用于描述用例的图形方法——用例图。

用于画用例图的依然是PlantUML。下列这张图

便是依据如下的源代码生成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@startuml
left to right direction
actor 员工 as yg
actor 顾客 as gk
actor 餐厅员工 as ctyg
actor A2 as a2
actor 送餐员 as scy
rectangle cos {
note "没注册工资\n支付的采用\n送餐时收费" as mzc
usecase 查看菜单 as ckcd
usecase 注册 as zc
usecase 登录 as dl
usecase 订餐 as dc
usecase "预约/覆盖预约" as yy
usecase 备餐 as bc
usecase 请求送餐 as qqsc
usecase 记录送餐 as jlsc
usecase 打印送餐说明 as dyscsm
usecase 记录收费 as jlsf
zc .> dl : <<extends>>
dl .> dc : <<extends>>
}
actor A1 as a1
note bottom of a1 : 已注册工资支付

yg <|-- gk
gk <|-- ctyg
ctyg <|-- a2
ctyg <|-- scy

yg -- ckcd
yg ---- zc
yg --- dl
gk -- dc
gk ---- yy
ctyg -- bc
ctyg --- qqsc
scy -- jlsc
scy --- dyscsm
scy -- mzc
jlsf -- mzc
@enduml

比较遗憾的是,PlantUML自动排版的结果显得不那么整齐,左下角有一个明显的三角形空白区域——这也是DSL大法的一个缺点,即无法完美地控制最终的排列效果。

UML类图

最开始接触UML的时候,学习的便是类图——尽管接触得最早,画得却最少。比起类图,ER图反而画得更多一点。

如果要画类图,首选的工具是mermaid。跟PlantUML一样,mermaid也是一个大而全的东西,除了画UML类图,也可以画流程图、时序图,以及UML状态图等。下面这张图

便是mermaid-cli依据如下的源代码生成的

1
2
3
4
5
6
7
8
9
10
11
12
classDiagram
Image <|-- BMP
Image <|-- GIF
Image <|-- JPEG
Image: +setImpl()
Image: +parseFile()

ImageImpl <|-- WinImpl
ImageImpl <|-- LinuxImpl
ImageImpl: +doPaint()

Image ..> ImageImpl

Emacs用户可以安装mermaid-mode,VSCode用户则可以使用Mermaid Preview这个插件,来辅助编辑mermaid的源文件。

方才提到的mermaid-cli是一个命令行程序,用于在本地根据mermaid的源文件产生PNG格式的图片,安装也很简单

1
npm install -g mermaid.cli

总结

还有许多的图可以用DSL来绘制,感兴趣的读者可以到mermaidPlantUML的官网了解一番,这里不再一一举例。

用DSL来绘图有一些优点:

  1. 不需要借助鼠标工具,纯键盘党的福音;
  2. 源代码为纯文本,生成器跨多平台,可以在多个平台甚至网页上编辑和查看,不受单一软件厂商的约束;
  3. 方便修改,改完不需要来回调整各个图形的位置。

但也有一些缺点:

  1. 不直观,生成图片前不好猜测最终的效果;
  2. 无法准确地控制图中所有元素的排列和位置,有时候得不到想要的效果;
  3. 需要学习不同的DSL,学习成本比可视化工具要高。

就像软件开发中没有银弹一样,画图工具也没有万金油,关键还是要因地制宜地选择最合适的工具来解决眼前的问题。