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

0%

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

序言

每当需要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,学习成本比可视化工具要高。

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

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

如何在shell中比较大小?

如果在搜索引擎中搜索“shell 比较”,那么得到的结果基本上都在告诉你要写[ blablabla ]这样的代码

例如,如果想知道当前的UNIX时间是否已经以16开头,可以用下列的shell代码

1
2
3
4
5
6
7
#!/bin/bash
ts=$(date '+%s')
if [ "${ts}" -gt 1600000000 -a "${ts}" -lt 1700000000 ]; then
echo '当前的UNIX时间戳已经以16开头啦。'
else
echo '当前的UNIX时间戳还没以16开头哦。'
fi

当我写这个的时候,date '+%s'的值为1587901648,所以运行后走的是else的分支。

除了用-gt表示大于之外,还有各种各样的其它比较运算符,例如下列的四个运算符

运算符 作用 示例代码
-ge 大于或等于 [ 2 -ge 1 ]
-eq 等于 [ 1 -eq 1 ]
-le 小于或等于 [ 2 -le 3 ]
-lt 小于 [ 3 -lt 4 ]

还有一些“测试”类型的运算符,例如

运算符 作用 示例代码
-b file 测试file是否存在并且是个块设备 [ -b /dev/disk0 ]
-c file 测试file是否存在并且是个字符设备 [ -c /dev/tty ]

[是shell的语法么?

大部分写shell代码的人或许会认为,[]是shell语言用于实现一系列的比较操作的特殊语法。但实际上,[]并不是一个语法——[是一个独立的命令行程序,]则什么都不是,仅仅是一个普通的字符。

在bash中使用which命令可以看到[的真面目

[是一个独立的程序,对,你没有看错(鲍尔默脸)。而且[有它自己的man文档

在man文档中出现了另外一个命令test,它和[的功能是一模一样的。或许test是一个“yet another [”?真相却更简单一点——test[是同一个东西

[源代码的二三事

可以在GitHub上找到[test源代码,代码很短,稍微读一下可以发现不少有意思的地方。

众所周知,如果在shell代码中使用[做比较运算,必须写上对应的右方括号]。但既然[是一个普通的外部程序,那么这个匹配括号的检查显然不会是shell来做的——没错,[自己会检查是否有写上相应的右方括号,这一段逻辑在源文件的main函数开始不久就出现了。

这个检查只有在程序被以[的名字启动的时候才会生效,所以test 1 -eq 1是不需要写括号的。

其实除了上文中给出的那些比较和测试运算符之外,[也支持复杂的逻辑运算表达式,比如文章开头的示例代码中的-a就是逻辑与的意思。在代码的注释中还贴心地给出了所接受的参数的BNF

而解析参数的过程则是一个手写的递归下降语法分析器,在源代码中可以找到与上面的产生式对应的多个函数:oexpraexprnexprprimary,以及binop

由于在shell语言中,0表示逻辑真,而1表示逻辑假(与C语言相反),所以在main函数中,如果发现传入的第一个参数为感叹号(!,表示逻辑取反),则将oexpr的调用结果直接返回,否则需要将结果取反后再从main函数中返回——给操作系统。

shell真的不原生支持比较?

尽管在bash中,[的确是作为一个外部程序存在的,但在zsh中却相反

而且,即使是bash也并非完全没有原生的比较操作——此处需要召唤[[[[是shell的保留字,它是一个less suprise版本的[,在Stack Overflow上有不少关于它的问答值得一看:

  1. https://stackoverflow.com/questions/3427872/whats-the-difference-between-and-in-bash
  2. https://stackoverflow.com/questions/669452/is-double-square-brackets-preferable-over-single-square-brackets-in-ba

第二个链接的回答中还给出了一个值得一看的、关于bash中的“测试”功能的指引,其中甚至提到了

It can produce surprising results, especially for people starting shell scripting that think [ ] is part of the shell syntax.

后记

不得不承认,本文标题党了一把,shell还是自身就具备比较大小这样的功能的。

“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

序言

我要编写一个Elisp函数,其核心逻辑涉及到替换字符串中一个符合某种模式的子串。举个例子,字符串为"[2020-02-15 Sat 14:19]《业务逻辑需要关心同步还是异步吗?》",需要替换的是其中的[2020-02-15 Sat 14:19]

这个由日期和时间组成的前缀在每一次我按下C-c c t的时候会自动产生,因为在org-capture-templates中就是这么设置的

1
2
3
(setq org-capture-templates
'(("t" "Todo" entry (file+headline "~/Dropbox/gtd/inbox.org" "Tasks")
"* TODO %U%?\n :PROPERTIES:\n :CREATED_AT: %U\n :ID: %(uuidgen-4)\n :END:")))

更具体一点,它们产生自其中的转义序列%U(详情可以参见org-mode的文档Template expansion)。

经过一番不是特别仔细的搜索后,我决定用string-matchreplace-match函数来完成上述替换子串的需求。

然后便闹了两个乌龙。

string-match不支持扩展的正则语法

为了匹配形如[2020-02-15 Sat 14:19]这样的字符串,我的直觉便驱使我写出了这样的正则表达式

1
"^\\[\\d+-\\d+-\\d+ \\w+ \\d+:\\d+\\]"

在Node.js或其它支持Shorthand Character Classes的语言中,上面的正则表达式是可用的。

但是Elisp偏偏不是这样的语言!(Elisp所支持的正则表达式语法可以参见这篇文档)因此,在Elisp中只好用下面这个正则表达式

1
"^\\[[0-9]+-[0-9]+-[0-9]+ [A-Za-z]+ [0-9]+:[0-9]+\\]"

虽然Elisp不支持Shorthand Character Classes,但它确实支持Character Classes,但这样写出来的正则表达式更长了

1
"^\\[[[:digit:]]+-[[:digit:]]+-[[:digit:]]+ [[:alpha:]]+ [[:digit:]]+:[[:digit:]]+\\]"

我猜你宁可写前一种对吧。

replace-match不返回新字符串

这货是用来修改一个buffer中的内容的……

一种Workaround

最后我根据需求的实际情况,综合使用string-matchsubstring,以及format实现了替换子串的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
(defun lt-org--starts-with-timestamp-p (text)
"返回T或NIL表示输入字符串是否以一个inactive timestamp开头。"
;; Emacs的正则表达式并不支持如\d和\w这样的类,所以要写成[0-9]和[A-Za-z]的形式
(string-match "^\\[[0-9]+-[0-9]+-[0-9]+ [A-Za-z]+ [0-9]+:[0-9]+\\]" text))

(defun lt-org--delay-timestamp (text new-timestamp)
"用NEW-TIMESTAMP替换TEXT中的inactive timestamp。

如果TEXT没有以inactive timestamp开头,则直接添加NEW-TIMESTAMP。"
(format "%s%s" new-timestamp
(if (lt-org--starts-with-timestamp-p text)
(substring text 22)
text)))

过了很久后,我终于发现了在Elisp中做字符串替换的正确做法……

正确答案

只要用replace-regexp-in-string函数就足够了

1
(replace-regexp-in-string "^\\[[0-9]+-[0-9]+-[0-9]+ [A-Za-z]+ [0-9]+:[0-9]+\\]" "abc" "[2020-02-15 Sat 14:19]《业务逻辑需要关心同步还是异步吗?》")

结果为"abc《业务逻辑需要关心同步还是异步吗?》",替换很成功。

后记

如何在浩如烟海的知识(搜索引擎、在线文档)中找到自己需要的东西,也是一门学问啊。

谨以本文向我脑海中那些不成熟的想法致敬。

序言

受疫情影响呆在家中的这段时间里,我收尾了《Clean Architecture》。这本书给了我许多新知识和启发,包括本文的中心论点——数据库schema不是CRUD服务的一切,也是在读书过程中想到的。在书中,作者的原话是

But the database is not the data model

它出现在书中第六部分《Details》的第一个章节中。作者认为,从架构的角度来看,数据库不是一个实体而是一个细节,不足以成为架构中的一个元素。他甚至打了个比方:数据库对于架构而言,就好像门把手对于房子一般。并且,作者进一步澄清了他的观点:他口中所说的数据库,不是指的数据模型。应用内的数据结构对架构而言至关重要,但数据库并不是数据模型。

这不禁让我回忆起了自己早期写设计文档的套路。

在我的从业生涯早期(说得好像我从业很久了一样),每当需要开发一个新的Web服务时,必须先写一份简要的设计文档,向上级清楚地表达我的实现思路,包括:

  1. 如何与其它服务协作完成产品提出的需求;
  2. 服务的接口描述;
  3. 数据的存储结构;
  4. 关键的算法等。

那时候的我会先考虑数据的存储结构,然后定义接口,最后才是与其它服务的协作。这些早期设计文档的其中一个特点是:接口的响应格式,与数据的存储结构是相同的。

比方说我要设计一个网上商城的订单服务,可能会提供如下查询特定订单的接口

1
GET /order/:id

其响应格式可能如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
order: {
id: 'F122663A-A5DC-451A-9B79-92DCE2EE41F1',
price: '100.00',
products: [
{
id: 1,
name: 'MacBook Pro',
price: '19999.00'
},
{
id: 2,
name: 'iPhone',
price: '6999.00'
}
]
}
}

为了保存这种“不平坦”的对象,将会用MongoDB作为存储——除了文档的主键_id之外,其它字段在接口和存储之间一一对应。

不仅仅是响应格式,在这个Web服务内所操作的也是同样结构的对象。用MongoDB Node.JS Driver获得的订单恰好是一个JS对象,它与collection的文档有着一模一样的结构。之后这些对象会在代码内到处流通,不加修饰地使用。定义了数据库schema(就算是用MongoDB也有一套脑内的schema)后,其它的一切也就跟着确定了。

业界甚至有工具可以直接从数据库得到API,比如postgrest

很多时候,数据库schema成了一个应用内事实上的数据模型。但是,即便它们可以偶然一样,也不要认为它们总应该一样。

适合存储,不一定适合计算

以MySQL为例,在CREATE TABLE语句中某一列的类型实际上决定的是存储时分配的空间的多少。但适合存储的类型,并不一定也适合业务逻辑的运算。

比如说,要在MySQL中存储“开关型”的数据,即诸如“是否启用”或“是否已支付”这样非此即彼的状态时,通常定义为TINYINT类型,用0表示逻辑假(“未启用”和“未支付”),1表示逻辑真(“已启用”和“已支付”)。但对代码而言,比起用数值类型,布尔类型才是更恰当的选择。尤其是当所选择的语言并没有将0与false、1与true等价起来的时候——在Common Lisp中,(if 0 1 2)的求值结果为1。

适合计算,不一定适合存储

通常数据结构在内存中比在磁盘上要容易表达得多,所以代码中使用的数据结构会比数据库中存储的要灵活不少,这同样造成了两者的不匹配。

以我自己开发的提醒工具cuckoo为例,应用内有两种对象:任务和提醒。任务描述了要做的事情,提醒描述了在什么时候该告诉用户。显然,提醒是一个依赖于任务的弱实体。在cuckoo的代码中,任务是Task类的实例对象,有一个名为remind的成员变量存储着提醒。

但这样的结构不方便存储在MySQL中。遵照关系型数据库设计的第一范式,任务和提醒分别被存储在t_taskt_remind表中,两者通过t_task.remind_id联系起来。

当然,也可以在一开始就用MongoDB来存储这些数据(甚至可以用对象数据库?不过我没玩过)。尤其是cuckoo只是一个小玩意儿,MySQL和MongoDB都足以胜任。但作为一名有理想的程序员,在做设计的时候,不应该让低层细节过分干预高层策略。(在《Clean Architecture》中,越是接近I/O的越是low-level,反之则是high-level。)

面向业务逻辑,而非存储结构

业务逻辑和规则才是一个服务的核心,应该把更多精力花在实现业务逻辑的数据结构和算法上。

以网上商城中常见的优惠券功能为例。优惠券服务所管理的优惠券往往有着各种效果、条件,以及限制。为了保持灵活性,优惠券类(下称Coupon)的实例对象中会有三种接口类型的成员变量:

  1. Effect类型的变量effect,负责实现优惠效果的计算逻辑;
  2. Condition数组类型的变量conditions,负责实现使用条件的检查逻辑;
  3. Restriction数组类型的变量restrictions,负责实现使用限制的检查逻辑。

三个接口可以有各种各样的实现——定额减免、折扣减免、某年月日前可用、不可用于电子产品,等等。如此,优惠券功能具备了极大的灵活性,业务可以随心所欲,产品可以为所欲为,老板数钱数到手软,公司业绩蒸蒸日上。

那么如何存储EffectConditionRestrictionCoupon类的实例对象呢?没有唯一的选择,既可以存储在MySQL中,也可以存储在MongoDB中,或者别的什么数据库中。不管这些数据最终如何持久化,都不会影响作为高层策略的优惠券业务逻辑。反过来,如果在代码中处理的不是类、接口,以及实例对象,而是直接从数据库中取出来的、贫血模型的行(或文档),处理起来就不是很优雅了——可以预见 ,代码中会充斥着许多的if-else判断逻辑。

数据库只是帮忙从磁盘中读取数据的软件,它的schema不应该直接成为应用的数据模型。

Interface Segregation Principle

不应该在HTTP接口的响应中直接暴露数据库的schema。

不说别的,光是数据库schema与接口规格所使用的命名规则就足以造成差异了。也许在MySQL中用snake case命名一列,却又在HTTP响应的JSON对象中用camel case命名字段。

此外,除非这些接口仅仅实现增删查改、没有任何的业务逻辑或规则,否则一个服务更应当提供与业务需求恰好契合的接口。仍然以上文的优惠券服务为例,尽管内部可能EffectConditionRestrictionCoupon等诸多概念,但煮不在乎用户不在乎,他们只想看到用人话说出来的优惠券效果以及使用规则——用户甚至不关心条件和限制有何不同。

如果优惠券服务直接将数据库中的行(或文档)序列化成JSON返回给调用者,会导致封装的泄露。每一个查询优惠券的调用方,都必须了解优惠券的内部表示形式,必须知道效果由effect描述、用券后的订单金额是多少、conditions中有关于过期与否的信息,等等。每增加一个优惠券服务的使用者,就相应地增加一套描述这些内容的代码。甚至当优惠券服务自身重构的时候,也许牵连到众多的调用方。

如果直接将存储结构暴露给调用者的话,又何必再做一个Web服务呢。

切勿矫枉过正

的确存在这样的例子,数据库schema、数据模型,以及HTTP响应结构三者相同。这是因为比起维护数据库schema与数据模型的转换规则,以及DTO与数据模型的转换规则而言,在领域代码中直接使用数据库schema来表达数据模型的成本更低一点。尽管数据库schema不是Web服务的一切,但很多时候可以因地制宜地妥协一下。