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

0%

众所周知,我用Emacsledger-mode来记账(参见以前的文章《程序员的记账工具——ledger与ledger-mode》)。作为一个出色的命令行报表工具,ledger的命令balanceregister足以涵盖大部分的使用场景:

  • balance可以生成所有帐号的余额的报表,用于每天与各个账户中的真实余额进行比较;
  • register可以生成给定帐号的交易明细,用于在余额不一致时与真实账户的流水一条条核对;

美中不足的是,ledger的报表不够直观,因为它们是冷冰冰的文字信息,而不是振奋人心的统计图形。好在,正如ledger不存储数据,而只是一份份.ledger文件中的交易记录的搬运工一样,gnuplot也是这样的工具——它不存储数据,它只负责将存储在文本文件的数据以图形的形态呈现出来。

如何运用gnuplot

gnuplot是很容易使用的。以最简单的情况为例,首先将如下内容保存到文件/tmp/data.csv

1
2
3
-1 -1
0 0
1 1

然后在命令行中启动gnuplot,进入它的 REPL 中,并执行如下命令

1
plot "/tmp/data.csv"

即可得到这三组数据的展示

三组数据分别是坐标为(-1, -1)(0, 0),以及(1, 1)的点。

因此要让gnuplot绘制开销的图形,首先就是从账本中提取出要绘制的数据,再决定如何用gnuplot绘制即可。

ledger提取开销记录

尽管ledger的子命令register可以打印出给定帐号的交易明细,但此处更适合使用csv子命令。例如,下列的命令可以将最早的10条、吃的方面的支出记录,都以 CSV 格式打印出来

1
2
3
4
5
6
7
8
9
10
11
➜  Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food'
"2019/09/10","","32034acc","efe2a5b9:c720f278:58a3cd91:0dc07b7b","A","20","",""
"2019/09/11","","a61b6164","5d45e249:fe84ca06:778d1855:daf61ede","A","5","",""
"2019/09/11","","674ec19f","5d018df1:ebf020db:29d43aba:d0c84127","A","15","",""
"2019/09/11","","e55ff018","370ca545:7d3aa2d0:86f5f330:1379261b","A","20","",""
"2019/09/12","","f6aa675c","08315491:4c8f1ee7:5eeaddf3:f879914e","A","10.5","",""
"2019/09/12","","139b790f","a137e4ee:9bc8ee49:7d7ccd8b:472d6007","A","23.9","",""
"2019/09/12","","b24b716d","de348971:5364622c:b2144d94:01e74ff3","A","148","",""
"2019/09/13","","e7c066fa","b418a3b2:a3e21e87:a32ee8ac:8716a847","A","3","",""
"2019/09/13","","9eb044fe","702a13e9:3de7f1bd:9b20a278:1d20668d","A","24","",""
"2019/09/13","","ba301270","d2b7eeb3:381f9473:54f86a33:391a8662","A","36","",""

--anon选项可以将交易明细中的敏感信息(如收款方、帐号)等匿名处理。

尽管ledger打印出的内容有很多列,但只有第一列的日期,以及第六列的金额是我所需要的。同时,由于一天中可能会有多次吃的方面的开销,因此同一天的交易也会有多笔,在绘图之前,需要将同一天之中的开销累加起来,只留下一个数字。这两个需求,都可以用csvsql来满足。

csvsql聚合数据

以前文中的10条记录为例,用如下的命令可以将它们按天聚合在一起

1
ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense'

其中:

  • 选项-Hcsvsql知道从管道中输入的数据没有标题行。后续处理时,csvsql会默认使用abc等作为列名;
  • 选项--query用于提交要执行的 SQL 语句;
  • 选项--tables用于指定表的名字,这样在--query中才能用 SQL 对其进行处理;

结果如下

1
2
3
4
5
6
➜  Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense'
a,SUM(`f`)
2019-09-10,20
2019-09-11,40
2019-09-12,182.4
2019-09-13,63

gnuplot读取数据并绘图

用重定向将csvsql的输出结果保存到文件/tmp/data.csv中,然后就可以用gnuplot将它们画出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜  Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/data.csv
➜ Accounting cat /tmp/plot_expense.gplot
set format x '%y-%m-%d'
set style data boxes
set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc'
set title '吃的开销'
set output '/tmp/xyz.png'
set timefmt '%Y-%m-%d'
set xdata time
set xlabel '日期'
set xrange ['2019-09-10':'2019-09-13']
set ylabel '金额(¥)'
set yrange [0:200]
set datafile separator comma
plot '/tmp/data.csv' using 1:2
➜ Accounting gnuplot /tmp/plot_expense.gplot

生成的图片文件/tmp/xyz.png如下

在脚本文件/tmp/plot_expense.gplot中用到的命令都可以通过gnuplot在线手册查阅到:

  • set format命令用于设置坐标轴的刻度的格式。set format x "%y-%m-%d"意味着设置 X 轴的刻度为形如19-09-10的格式;
  • set style data命令设置数据的绘制风格。set style data box表示采用空心柱状图;
  • set terminal命令用于告诉gnuplot该生成什么样的输出。set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc'表示输出结果为 PNG 格式的图片,并且采用给定的字体;
  • set title命令控制输出结果顶部中间位置的标题文案;
  • set output命令用于将原本输出到屏幕上的内容重定向到文件中;
  • set timefmt命令用于指定输入的日期时间数据的格式。set timefmt '%Y-%m-%d'意味着输入的日期时间数据的为形如2019-09-10的格式;
  • set xdata命令控制gnuplot如何理解属于 X 轴的数据。set xdata time表示 X 轴上的均为时间型数据;
  • set xlabel命令控制 X 轴的含义的文案。set ylabel与其类似,只是作用在 Y 轴上;
  • set xrange命令控制gnuplot所绘制的图形中 X 轴上的展示范围;
  • set datafile separator命令控制gnuplot读取数据文件时各列间的分隔符,comma表示分隔符为逗号。

想要按周统计怎么办

假设我要查看的是2021年每一周在吃的方面的总开支,那么需要在csvsql中将数据按所处的是第几周进行聚合

1
2
3
4
5
6
7
8
9
10
11
12
➜  Accounting ledger -b '2021-01-01' -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT strftime("%W", `a`) AS `week`, SUM(`f`) FROM `expense` GROUP BY `week` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/expense_dow.csv
➜ Accounting head /tmp/expense_dow.csv
00,633.6
01,437.3
02,337.5
03,428.4
04,191.5
05,330.4
06,154.6
07,621.4
08,485.6
09,375.73

同时也需要调整gnuplot的脚本

1
2
3
4
5
6
7
8
9
set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc'
set title '吃的开销'
set output '/tmp/xyz2.png'
set xlabel '第几周'
set xrange [0:54]
set ylabel '金额(¥)'
set yrange [0:1000]
set datafile separator comma
plot '/tmp/expense_dow.csv' using 1:2 with lines

结果如下

想要同时查看两年的图形怎么办

gnuplot支持同时绘制多条曲线,只要使用数据文件中不同的列作为纵坐标即可。假设我要对比的是2020年和2021年,那么先分别统计两年的开支到不同的文件中

1
2
➜  Accounting ledger -b '2020-01-01' -e '2021-01-01' -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT strftime("%W", `a`) AS `week`, SUM(`f`) FROM `expense` GROUP BY `week` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/expense_2020.csv
➜ Accounting ledger -b '2021-01-01' -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT strftime("%W", `a`) AS `week`, SUM(`f`) FROM `expense` GROUP BY `week` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/expense_2021.csv

再将处于同一周的数据合并在一起

1
➜  Accounting csvjoin -H -c a /tmp/expense_2020.csv /tmp/expense_2021.csv | tail -n '+2' > /tmp/expense_2years.csv

最后,再让gnuplot一次性绘制两条折线

1
2
3
4
5
6
7
8
9
set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc'
set title '吃的开销'
set output '/tmp/xyz2years.png'
set xlabel '第几周'
set xrange [0:54]
set ylabel '金额(¥)'
set yrange [0:1000]
set datafile separator comma
plot '/tmp/expense_2years.csv' using 1:2 with lines title "2020", '/tmp/expense_2years.csv' using 1:3 with lines title "2021"

结果如下

后记

其实仍然是非常不直观的,因为最终生成的是一张静态的图片,并不能做到将鼠标挪到曲线上时就给出所在位置的纵坐标的效果。

序言

作为一个天天都在用的工具,各位同行想必都非常熟悉 Git 的基本用法,例如:

  • git-blame找出某一行 bug 是哪一位同事引入的,由他背锅;
  • git-merge把别人的代码合进自己完美无瑕的分支中,然后发现单元测试无法跑通;
  • git-push -f把团队里其他人的提交通通覆盖掉。

除此之外,Git 其实还是一个带版本功能的键值数据库:

  • 所有提交的内容都存储在目录.git/objects/下;
  • 有存储文件内容的blob对象、存储文件元数据的tree对象,还有存储提交记录的commit对象等;
  • Git 提供了键值风格的读写命令git-cat-filegit-hash-object

读过我以前的文章《当我们git merge的时候到底在merge什么》的朋友们应该都知道,如果一次合并不是fast-forward的,那么会产生一个新的commit类型的对象,并且它有两个父级commit对象。以知名的 Go 语言 Web 框架gin的仓库为例,它的哈希值为e38955615a14e567811e390c87afe705df957f3a的提交是一次合并产生的,这个提交的内容中有两行parent

1
2
3
4
5
6
7
8
➜  gin git:(master) git cat-file -p 'e38955615a14e567811e390c87afe705df957f3a'
tree 93e5046e502847a6355ed26223a902b4de2de7c7
parent ad087650e9881c93a19fd8db75a86968aa998cac
parent ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c
author Javier Provecho Fernandez <javiertitan@gmail.com> 1499534953 +0200
committer Javier Provecho Fernandez <javiertitan@gmail.com> 1499535020 +0200

Merge pull request #520 from 178inaba/travis-import_path

通过一个提交的parent属性,所有的提交对象组成了一个有向无环图。但聪明的你应该发现了,git-log的输出结果是线性的,所以 Git 用到了某种图的遍历算法。

查阅man git-log,可以在Commit Ordering一节中看到

By default, the commits are shown in reverse chronological order.

聪明的你想必已经知道该如何实现这个图的遍历算法了。

自己动手写一个git-log

解析commit对象

要想以正确的顺序打印commit对象的信息,得先解析它。我们不需要从零开始自己打开文件、读取字节流,以及解压文件内容,只需要像上文那样调用git-cat-file即可。git-cat-file打印的内容中,有一些是需要提取备用的:

  • parent开头的行。这一行的哈希值要用于定位到有向无环图中的一个节点;
  • committer开头的行。这一行的 UNIX 时间戳将会作为决定谁是“下一个节点”的排序依据。

可以随手写一个 Python 中的类来解析一个commit对象

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
class CommitObject:
"""一个Git中的commit类型的对象解析后的结果。"""
def __init__(self, *, commit_id: str) -> None:
self.commit_id = commit_id

file_content = self._cat_file(commit_id)
self.parents = self._parse_parents(file_content)
self.timestamp = self._parse_commit_timestamp(file_content)

def _cat_file(self, commit_id: str) -> str:
cmd = ['git', 'cat-file', '-p', commit_id]
return subprocess.check_output(cmd).decode('utf-8')

def _parse_commit_timestamp(self, file_content: str) -> int:
"""解析出提交的UNIX时间戳。"""
lines = file_content.split('\n')
for line in lines:
if line.startswith('committer '):
m = re.search('committer .+ <[^ ]+> ([0-9]+)', line.strip())
return int(m.group(1))

def _parse_parents(self, file_content: str) -> List[str]:
lines = file_content.split('\n')
parents: List[str] = []
for line in lines:
if line.startswith('parent '):
m = re.search('parent (.*)', line.strip())
parent_id = m.group(1)
parents.append(parent_id)
return parents

遍历commit组成的有向无环图——大根堆

恭喜你,你学过的数据结构可以派上用场了。

假设用上面的类CommitObject解析了gin中哈希值为e38955615a14e567811e390c87afe705df957f3a的提交,那么它的parents属性中会有两个字符串:

  • ad087650e9881c93a19fd8db75a86968aa998cac
  • ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c

其中:

  • 哈希值为ad087650e9881c93a19fd8db75a86968aa998cac的提交的时间为Sat Jul 8 12:31:44
  • 哈希值为ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c的提交时间为Jan 28 02:32:44

显然,按照反转的时间先后顺序(reverse chronological)打印日志的话,下一个打印的节点应当是是ad087650e9881c93a19fd8db75a86968aa998cac——用git-log命令可以确认这一点。

打印完ad087650e9881c93a19fd8db75a86968aa998cac之后,又要从它的父级提交和ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c中,挑选出下一个要打印的提交对象。显然,这是一个循环往复的过程:

  1. 从待打印的commit对象中,找出提交时间戳最大的一个;
  2. 打印它的消息;
  3. commit的所有父级提交加入到待打印的对象池中,回到第1个步骤;

这个过程一直持续到没有待打印的commit对象为止,而所有待打印的commit对象组成了一个优先级队列——可以用一个大根堆来实现。

然而,我并不打算在这短短的演示当中真的去实现一个堆数据结构——我用插入排序来代替它。

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
class MyGitLogPrinter():
def __init__(self, *, commit_id: str, n: int) -> None:
self.commits: List[CommitObject] = []
self.times = n

commit = CommitObject(commit_id=commit_id)
self._enqueue(commit)

def run(self):
i = 0
while len(self.commits) > 0 and i < self.times:
commit = self.commits.pop(0)

for parent_id in commit.parents:
parent = CommitObject(commit_id=parent_id)
self._enqueue(parent)

print('{} {}'.format(commit.commit_id, commit.timestamp))
i += 1

def _enqueue(self, commit: CommitObject):
for comm in self.commits:
if commit.commit_id == comm.commit_id:
return
# 插入排序,先找到一个待插入的下标,然后将从i到最后一个元素都往尾部移动,再将新节点插入下标i的位置。
i = 0
while i < len(self.commits):
if commit.timestamp > self.commits[i].timestamp:
break
i += 1
self.commits = self.commits[0:i] + [commit] + self.commits[i:]

最后再提供一个启动函数就可以体验一番了

1
2
3
4
5
6
7
8
9
@click.command()
@click.option('--commit-id', required=True)
@click.option('-n', default=20)
def cli(commit_id: str, n: int):
MyGitLogPrinter(commit_id=commit_id, n=n).run()


if __name__ == '__main__':
cli()

真假美猴王对比

为了看看上面的代码所打印出来的commit对象的顺序是否正确,我先将它的输出内容重定向到一个文件中

1
➜  gin git:(master) python3 ~/SourceCode/python/my_git_log/my_git_log.py --commit-id 'e38955615a14e567811e390c87afe705df957f3a' -n 20 > /tmp/my_git_log.txt

再用git-log以同样的格式打印出来

1
➜  gin git:(master) git log --pretty='format:%H %ct' 'e38955615a14e567811e390c87afe705df957f3a' -n 20 > /tmp/git_log.txt

最后让diff命令告诉我们这两个文件是否有差异

1
2
3
4
5
6
➜  gin git:(master) diff /tmp/git_log.txt /tmp/my_git_log.txt
20c20
< 2521d8246d9813d65700650b29e278a08823e3ae 1499266911
\ No newline at end of file
---
> 2521d8246d9813d65700650b29e278a08823e3ae 1499266911

可以说是一模一样了。

序言

众所周知,Python 支持向函数传递关键字参数。比如 Python 的内置函数max就接受名为key的关键字参数,以决定如何获取比较两个参数时的依据

1
max({'v': 1}, {'v': 3}, {'v': 2}, key=lambda o: o['v'])  # 返回值为{'v': 3}

自定义一个运用了关键字参数特性的函数当然也不在话下。例如模仿一下 Common Lisp 中的函数string-equal

1
2
3
4
5
6
7
8
9
10
def string_equal(string1, string2, *, start1=None, end1=None, start2=None, end2=None):
if not start1:
start1 = 0
if not end1:
end1 = len(string1) - 1
if not start2:
start2 = 0
if not end2:
end2 = len(string2) - 1
return string1[start1:end1 + 1] == string2[start2:end2 + 1]

再以关键字参数的形式向它传参

1
string_equal("Hello, world!", "ello", start1=1, end1=4)  # 返回值为True

秉承 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.理念, 我甚至可以花里胡哨地、用关键字参数的语法向string1string2传参

1
string_equal(string1='Goodbye, world!', string2='ello')  # 返回值为False

但瑜不掩瑕,Python 的关键字参数也有其不足。

Python 的不足

Python 的关键字参数特性的缺点在于,同一个参数无法同时以:

  1. 具有自身的参数名,以及;
  2. 可以从**kwargs中取得,

两种形态存在于参数列表中。

举个例子,我们都知道 Python 有一个知名的第三方库叫做 requests,提供了用于开发爬虫牢底坐穿的发起 HTTP 请求的功能。它的类requests.Session的实例方法request有着让人忍不住运用 Long Parameter List 对其重构的、长达 16 个参数的参数列表。(你可以移步request方法的文档观摩)

为了便于使用,requests 的作者贴心地提供了requests.request,这样只需要一次简单的函数调用即可

1
requests.request('GET', 'http://example.com')

requests.request函数支持与requests.Session#request(请允许我借用 Ruby 对于实例方法的写法)相同的参数列表,这一切都是通过在参数列表中声明**kwargs变量,并在函数体中用相同的语法向后者传参来实现的。(你可以移步request 函数的源代码观摩)

这样的缺陷在于,requests.request函数的参数列表丢失了大量的信息。要想知道使用者能往kwargs中传入什么参数,必须:

  1. 先知道requests.request是如何往requests.Session#request中传参的——将kwargs完全展开传入是最简单的情况;
  2. 再查看requests.Session#request的参数列表中排除掉methodurl的部分剩下哪些参数。

如果想在requests.request的参数列表中使用参数自身的名字(例如paramsdatajson等),那么调用requests.Session#request则变得繁琐起来,不得不写成

1
2
with sessions.Session() as session:
return session.request(method=method, url=url, params=params, data=data, json=data, **kwargs)

的形式——果然人类的本质是复读机。

一个优雅的解决方案,可以参考隔壁的 Common Lisp。

Common Lisp 的优越性

Common Lisp 第一次面世是在1984年,比 Python 的1991年要足足早了7年。但据悉,Python 的关键字参数特性借鉴自 Modula-3,而不是万物起源的 Lisp。Common Lisp 中的关键字参数特性与 Python 有诸多不同。例如,根据 Python 官方手册中的说法,**kwargs中只有多出来的关键字参数

If the form “**identifier” is present, it is initialized to a new ordered mapping receiving any excess keyword arguments

而在 Common Lisp 中,与**kwargs对应的是&rest args,它必须放置在关键字参数之前(即左边),并且根据 CLHS 中《A specifier for a rest parameter》的说法,args中含有所有未经处理的参数——也包含了位于其后的关键字参数

1
2
3
4
(defun foobar (&rest args &key k1 k2)
(list args k1 k2))

(foobar :k1 1 :k2 3) ;; 返回值为((:K1 1 :K2 3) 1 3)

如果我还有另一个函数与foobar有着相似的参数列表,那么也可以轻松将所有参数传递给它

1
2
3
4
5
6
(defun foobaz (a &rest args &key k1 k2)
(declare (ignorable k1 k2))
(cons a
(apply #'foobar args)))

(foobaz 1 :k1 2 :k2 3) ;; 返回值为(1 (:K1 2 :K2 3) 2 3)

甚至于,即使在foobaz中支持的关键字参数比foobar要多,也能轻松地处理,因为 Common Lisp 支持向被调用的函数传入一个特殊的关键字参数:allow-other-keys即可

1
2
3
4
5
6
7
(defun foobaz (a &rest args &key k1 k2 my-key)
(declare (ignorable k1 k2))
(format t "my-key is ~S~%" my-key)
(cons a
(apply #'foobar :allow-other-keys t args)))

(foobaz 1 :k1 2 :k2 3 :my-key 4) ;; 打印my-key is 4,并返回(1 (:ALLOW-OTHER-KEYS T :K1 2 :K2 3 :MY-KEY 4) 2 3)

回到 HTTP 客户端的例子。在 Common Lisp 中我一般用drakma这个第三方库来发起 HTTP 请求,它导出了一个http-request函数,用法与requests.request差不多

1
(drakma:http-request "http://example.com" :method :get)

如果我想要基于它来封装一个便捷地发出 GET 请求的函数http-get的话,可以这样写

1
2
(defun http-get (uri &rest args)
(apply #'drakma:http-request uri :method :get args))

如果我希望在http-get的参数列表中直接暴露出一部分http-request支持的关键字参数的话,可以这样写

1
2
3
(defun http-get (uri &rest args &key content)
(declare (ignorable content))
(apply #'drakma:http-request uri :method :get args))

更进一步,如果我想在http-get中支持解析Content-Typeapplication/json的响应结果的话,还可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(ql:quickload 'jonathan)
(ql:quickload 'str)
(defun http-get (uri &rest args &key content (decode-json t))
;; http-request并不支持decode-json这个参数,但依然可以将整个args传给它。
(declare (ignorable content))
(multiple-value-bind (bytes code headers)
(apply #'drakma:http-request uri
:allow-other-keys t
:method :get
args)
(declare (ignorable code))
(let ((content-type (cdr (assoc :content-type headers)))
(text (flexi-streams:octets-to-string bytes)))
(if (and decode-json
(str:starts-with-p "application/json" content-type))
(jonathan:parse text)
text))))

不愧是Dio Common Lisp,轻易就做到了我们做不到的事情。

题外话

曾几何时,Python 程序员还会津津乐道于 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.,但其实 Python 光是在定义一个函数的参数方面就有五花八门的写法了。甚至在写这篇文章的过程中,我才知道原来 Python 的参数列表中可以通过写上/来使其左侧的参数都成为 positional-only 的参数。

1
2
3
4
5
6
def foo1(a, b): pass
def foo2(a, /, b): pass


foo1(a=1, b=2)
foo2(a=1, b=2) # 会抛出异常,因为a只能按位置来传参。

序言

或许是为了显摆,也或许是虚心学习,总之我在去年年初花了大约两个月读完了《架构整洁之道》。但读过后也仅仅就是读了而已,尽管书中描绘了一个名为整洁架构的软件架构,但我并没有理解并应用到实际的开发中去。书中的诸多理念最终都蛰伏在了我的脑海深处。

今年年初的时候我换了工作。新的单位给每人都配备了办公用的电脑,从此我也不用背着2公斤重的MacBook Pro通勤了。美中不足的地方是,我和cuckoo之间的联系被斩断了,因为cuckoo是个单机程序,要在私人电脑和办公电脑上各装一份太不方便了。于是乎,我决定开两个新的项目,将cuckoo拆分为客户端和服务端两部分。

正好,这给了我在实际的项目中践行整洁架构的机会。

什么是整洁架构

不像数学领域的概念往往有一个精确的定义,书中甚至没有道出整洁架构是什么。相对的,只有一副引人入胜的架构示意图(图片摘自作者博客的这篇文章

在作者的文章中,对图中的四个层次给出了响应的解释:

  • Entities封装了企业范围内的业务规则。如果你没有经营一个企业,仅仅是开发一款应用,那么Entities就是应用的业务对象,它们封装了应用内最通用、上层的规则。
  • Use Cases包含了与应用相关的业务规则。它封装并实现了系统的所有用例。
  • 这一层负责将最方便entities和use cases的数据转换为最方便外部系统使用的格式。在这一层以内都是抽象的,对外界诸如MVC、GUI、数据库等均是无感知的。此外,这一层也负责与外部服务通信。
  • Frameworks & Drivers,顾名思义,这一层包含了与框架相关的代码,或者像C语言中的main函数这样的入口函数代码;

如何应用整洁架构

实际项目的例子

前文提到,为了满足新需求,我需要将cuckoo改造为C/S模型。但比起缓缓地将cuckoo拆解为两部分,我更乐于大刀阔斧地从头开发开发这两个程序,于是便诞生了:

  • 服务端程序为nest,负责管理任务、计划等实体对象,并提供基于HTTP协议的API;
  • 客户端程序为fledgling,负责与nest通信,并在客户机上触发通知(如macOS的右上角弹出通知)。

它们都是我依照自己对整洁架构的理解来编写的。

从架构理念到具体决策

正如REST仅仅是一种软件结构风格而不是具体的设计指南一样,整洁架构也并没有规定示意图中的分层结构该如何运用一门语言的特性来实现,这需要开发者自己去摸索。下文我给出自己在nestfledgling项目中的做法。

如何安排代码目录结构

在程序的代码结构中,最接近于架构示意图的分层架构的,当属代码仓库的目录结构了。模仿整洁架构中的四层结构,我在nest中也安排了相似的目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(venv) ➜  nest git:(master) tree -I '__pycache__' -d ./nest
./nest
├── app
│   ├── entity
│   └── use_case
├── cli
│   ├── command
│   └── config
├── infra
├── repository
│   └── DDL
└── web
├── config
├── controller
└── presenter

13 directories

nest/app/entity/目录

nest/app/entity/目录下的各个文件分别定义了系统中的各个实体类型

1
2
(venv) ➜  nest git:(master) ls nest/app/entity
__pycache__ certificate.py location.py plan.py task.py user.py

例如:

  • task.py中定义了类Task,表示一个任务;
  • plan.py中定义了类Plan,表示任务的一次触发计划,等等。

entity/目录下的各个文件中,还定义了管理各种实体对象生命期的仓库对象,例如:

  • task.py中定义了类ITaskRepository,它负责增(add方法)删(clearremove方法)查(findfind_by_id方法)改(同样是add方法)任务对象;
  • plan.py中定义了类IPlanRepository,同样能够增(add方法)删(clearremove方法)查(find_as_queuefind_by_idfind_by_task_id方法)改(同样是add方法)计划对象,等等。

实体类型都是充血模型,它们实现了系统核心的业务规则,例如:

  • Plan有方法is_repeated用于检查是否为重复性任务;
  • 有方法is_visible用于检查该计划在当前时间是否可见;
  • 有方法rebirth用于生成一个新的、下一次触发的计划,等等。

这个目录下的内容相当于整洁架构中的Entities层。

nest/app/use_case/目录

nest/app/use_case/目录下的各个文件分别定义了系统所提供的功能

1
2
3
(venv) ➜  nest git:(master) ls nest/app/use_case
__init__.py authenticate.py change_task.py create_plan.py delete_plan.py get_location.py get_task.py list_plan.py login.py registration.py
__pycache__ change_plan.py create_location.py create_task.py delete_task.py get_plan.py list_location.py list_task.py pop_plan.py

例如:

  • authenticate.py定义了系统如何认证发送当前请求的用户;
  • change_task.py定义了系统如何修改一个任务对象,等等。

每一个处于该目录下的文件,只会依赖nest/app/entity/中的代码,并且它们都是抽象的。例如,authenticate.py中的类AuthenticateUseCase的构造方法中,要求其:

  • 参数certificate_repository必须是类ICertificateRepository或其子类的实例;
  • 参数params必须是类IParams或其子类的实例。

然而ICertificateRepositoryIParams其实都是抽象基类ABC的子类,并且它们都有被装饰器abstractmethod装饰的抽象方法,因此并不能直接实例化。

该目录相当于整洁架构中的Use Cases层。

其它目录

顾名思义,cliweb目录分别是与命令行程序、基于HTTP的API相关的代码,它们实现了处理来自命令行和HTTP协议的输入,以及打印到终端和返回HTTP响应的功能。repository目录下的各个文件实现了entity目录中各个抽象的仓库类的具体子类

1
2
(venv) ➜  nest git:(master) ls nest/repository
DDL __init__.py __pycache__ certificate.py db_operation.py location.py plan.py task.py user.py

例如:

  • certificate.py中实现了entity/目录下的同名文件中的抽象类ICertificateRepository——一个基于内存的子类MemoryCertificateRepository,以及一个基于Redis的子类RedisCertificateRepository
  • location.py中实现了entity/目录下的同名文件中的抽象类ILocationRepository——基于MySQL的子类DatabaseLocationRepository,等等。

需要注意的是,除了app外的这些目录,并不能与整洁架构示意图中的外面两层严格对应起来。例如,尽管cliweb的名字一下子就让人认为它们处于Frameworks & Drivers层,但web/presenter/目录下的内容其实与框架并无联系。反倒是从命名上看处于Interface Adapters层的web/controller/目录,其中的代码依赖于Flask框架。

如何往Use Cases层传入数据

在鲍勃大叔的文章中,提到了关于如何在层之间传递数据的原则

Typically the data that crosses the boundaries is simple data structures. You can use basic structs or simple Data Transfer objects if you like. Or the data can simply be arguments in function calls. Or you can pack it into a hashmap, or construct it into an object.

nest/app/use_case/目录下的所有用例采用的都是这里提到的construct it into an object的方式。以create_task.py为例:

1
2
3
4
5
6
7
8
9
10
11
12
class IParams(ABC):
@abstractmethod
def get_brief(self) -> str:
pass

@abstractmethod
def get_keywords(self) -> List[str]:
pass

@abstractmethod
def get_user_id(self) -> int:
pass
  • 用内置模块abc中的抽象基类ABC、装饰器abstractmethod,以及类CreateTaskUseCase中的assert一起模拟类似Java中的interface的效果;
  • 用方法而不是成员变量来获取不同的输入参数:
    • get_brief获取任务的简述;
    • get_keywords获取关键字列表;
    • get_user_id获取创建该任务的用户的ID。

聪明的盲生已经发现了华点:明明只需要在类CreateTaskUseCase的构造方法中定义briefkeywords,以及user_id三个参数即可,为什么要用方法这么麻烦呢?答案是因为方法更灵活。

当你采用构造方法参数的方案时,本质上是立了一个假设:

  1. 在所有惯性系中,物理定律有相同的表达形式先完成所有参数的获取;
  2. 再执行用例中的业务逻辑。

如果是一个基于HTTP协议的API,那么这个假设是成立的——用户在客户端发送的HTTP请求到达服务端后,便无法再补充参数了。但有一种场景,用户能够在用例执行业务逻辑的过程中,持续地与应用交互,那便是命令行程序。

我在fledgling项目中给了一个用户在用例执行过程中,交互式地输入的例子。在文件fledgling/app/use_case/delete_task.py中,实现了删除指定任务的用例。它要求输入两个参数

1
2
3
4
5
6
7
8
9
class IParams(ABC):
@abstractmethod
def get_confirmation(self) -> bool:
"""获取用户是否要删除该任务的确认。"""
pass

@abstractmethod
def get_task_id(self) -> int:
pass

在文件fledgling/cli/command/delete_task.py中实现了IParams类的命令行形态。当没有从命令行参数中获取到任务的ID时,便会使用第三方库PyInquirer询问用户输入任务ID,并进一步确认

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
class Params(IParams):
def __init__(self, *, task_id: Optional[int]):
self.task_id = task_id

def get_confirmation(self) -> bool:
if self.task_id:
return True
questions = [
{
'message': '确定删除该任务',
'name': 'confirmation',
'type': 'confirm',
}
]
answers = prompt(questions)
return answers['confirmation']

def get_task_id(self) -> int:
if self.task_id:
return self.task_id
questions = [
{
'message': '输入要删除的任务的ID',
'name': 'task_id',
'type': 'input',
}
]
answers = prompt(questions)
return answers['task_id']

而这一切煮不在乎DeleteTaskUseCase并不会感知到,它独立于用户界面。

在哪一层维护业务规则

在《架构整洁之道》第20章中,鲍勃大叔给出了业务规则的定义

Strictly speaking, business rules are rules or procedures that make or save
the business money. Very strictly speaking, these rules would make or save the business money, irrespective of whether they were implemented on a computer. They would make or save money even if they were executed manually.

业务规则往往不是独立存在的,它们需要作用在一些数据上

Critical Business Rules usually require some data to work with. For example, our loan requires a loan balance, an interest rate, and a payment schedule.

而整洁架构中的实体就是包含了一部分业务规则及其操作的数据的对象。以nest中的计划实体为例,在类Plan中包含了几种业务规则——尽管这些规则不能为我赚钱或者省钱:

  • 一个计划的持续时长(如果有的话)不会是负的秒数——由duration的setter保障;
  • 周期性计划必须指定周期——由new方法维护;
  • 一个计划是重复的,当且仅当它有指定重复类型——由is_repeated方法维护;
  • 一个计划是可见的,当且仅当它:
    • 要么没有指定可见的小时,要么当且时间处于指定的小时中,并且;
    • 要么没有指定星期几可见,要么今天是指定的weekday——由is_visible方法维护。

但在整洁架构的示意图中,Use Cases层也是有维护规则的,它维护的是应用的业务规则(Application Business Rules)。与Entities层所维护的业务规则不同,Use Cases层的业务规则取决于应用提供的功能。例如,在nest项目修改一个计划的用例ChangePlanUseCase类的方法run中,会:

  1. 检查指定的计划是否存在——显然,实体没法检查自己是否存在;
  2. 检查计划是否能被修改;
  3. 检查新的地点的ID是否指向真实存在的地点对象——显然,Plan对象不会去检查Location存在与否;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 文件nest/app/use_case/change_plan.py
class ChangePlanUseCase:
# 省略__init__的定义
def run(self):
# 省略一些无关要紧的代码
params = self.params
plan_id = params.get_plan_id()
plan = self.plan_repository.find_by_id(plan_id)
if plan is None: # 上面第1点
raise PlanNotFoundError(plan_id=plan_id)
if not plan.is_changeable(): # 上面第2点
raise UnchangeableError()

found, location_id = params.get_location_id()
if found:
if location_id:
location = self.location_repository.get(id_=location_id)
if not location: # 上面第3点
raise LocationNotFoundError(location_id=location_id)
plan.location_id = location_id

聪明的你一定发现了:is_changeable为什么不作为Enterpries Business Rules,在Plan对象内自行检查呢?答案是因为这样写更简单。

试想一下,如果要让Plan自己禁止在is_changeableFalse时被修改,那么必须:

  • 先为所有可修改的属性设置setter;
  • 在每一个setter中都调用is_changeable进行检查。

之所以要这么做,是因为一个实体对象(在这里是指Plan的实例对象)是外部的时间流动是无感知的。它不知道外层(此处是Use Cases层)会调用哪一个方法,调用哪一个方法。因此,要想保持“终止状态的计划不能修改”,就必须在每一处setter都检查。

与之相反,在用例中有编排,因此它可以感知时间的流动。用例可以让Planis_changeable方法在其它任何方法之前被调用,因此免除了繁琐地在每一个setter中检查is_changeable的必要。

如何获取Use Cases层的处理结果

正如往Use Cases层中输入参数可以采用:

  1. 直接在__init__中传入对应类型的参数,或;
  2. __init__中传入一个能根据方法提取参数的对象。

两种方案一样,获取Use Cases层的计算结果同样有两种方案:

  1. 获取run方法的返回值,捕捉它的异常,或;
  2. __init__中传入一个能够接受不同结果并处理的对象。

nest这样的仅仅提供HTTP API的应用中,第1种方案便已经足够了。例如,在文件nest/web/controller/create_plan.py中,类CreatePlanUseCaserun方法的返回值为创建的计划对象,如果run调用成功,这个controller会借助于PlanPresenter,将计划对象转换为JSON对象格式的字符串,返回给调用方;如果调用失败,那么controller中也会捕捉异常(如InvalidRepeatTypeError)并以另一种格式返回给调用方。

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
def create_plan(certificate_repository, repository_factory):
# 省略了不必要的代码
params = HTTPParams()
use_case = CreatePlanUseCase(
location_repository=repository_factory.location(),
params=params,
plan_repository=repository_factory.plan(),
task_repository=repository_factory.task(),
)
try:
plan = use_case.run()
presenter = PlanPresenter(plan=plan)
return { # 成功的情形
'error': None,
'result': presenter.format(),
'status': 'success',
}, 201
except InvalidRepeatTypeError as e: # 失败的情形
return {
'error': {
'message': '不支持的重复类型:{}'.format(e.repeat_type),
},
'result': None,
'status': 'failure',
}, 422

如果想要更高的灵活性并且也有施展的空间,那么可以考虑第2种方案。例如fledgling项目中文件fledgling/app/use_case/list_plan.py中,就定义了一个接口IPresenter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class IPresenter(ABC):
@abstractmethod
def on_find_location(self):
pass

@abstractmethod
def on_find_task(self):
pass

@abstractmethod
def on_invalid_location(self, *, error: InvalidLocationError):
pass

@abstractmethod
def show_plans(self, *, count: int, plans: List[Plan]):
pass

并且在用例的执行过程中,会多次向self.presenter传递数据

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
43
44
45
46
47
48
49
50
51
class ListPlanUseCase:
# 省略__init__方法
def run(self):
location_id = None
location_name = self.params.get_location_name()
no_location = self.params.get_no_location()
if not no_location and location_name is not None:
locations = self.location_repository.find(name=location_name)
if len(locations) == 0:
self.presenter.on_invalid_location(error=InvalidLocationError(name=location_name)) # 第1次,触发无效地点的错误
return

location_id = locations[0].id

page = self.params.get_page()
per_page = self.params.get_per_page()
criteria = {
'page': page,
'per_page': per_page,
}
if location_id is not None:
criteria['location_id'] = location_id
plans, count = self.plan_repository.list(**criteria)
location_ids = [plan.location_id for plan in plans]
self.presenter.on_find_location() # 第2次交互,通知presenter开始查找地点的事件
locations = self.location_repository.find(
ids=location_ids,
page=1,
per_page=len(location_ids),
)
task_ids = [plan.task_id for plan in plans]
self.presenter.on_find_task() # 第3次交互,通知presenter开始查找任务的事件
tasks = self.task_repository.list(
page=1,
per_page=len(task_ids),
task_ids=task_ids,
)
for plan in plans:
location_id = plan.location_id
location = [location for location in locations if location.id == location_id][0]
plan.location = location
task_id = plan.task_id
task = [task for task in tasks if task.id == task_id][0]
plan.task = task

# 第4次,也是最后一次,传入用例的处理结果
self.presenter.show_plans(
count=count,
plans=plans,
)
return

在构造方法中注入presenter的缺点在于用例的run方法中需要显式地return,否则用例会继续执行下去。

Python语言特性的运用

模拟接口——abstractmethodv.s.NotImplementedError

整洁架构的每一层都只会依赖于内层,而内层又对外层一无所知,负责解耦两者的便是编程语言的接口特性。但Python并不像Java那般有interface关键字,因此我利用它的其它一系列特性来模拟出接口:

  • class代替interface,这些类继承自内置模块abc的抽象基类ABC
  • 除此之外,这些类中的方法还用同一模块中的abstractmethod装饰,使它们必须由该类的子类全部定义;
  • 在使用这个接口的位置(例如Use Cases层)用断言assert约束输入参数的类型。

nest中的大部分需要接口的位置我都是用这种手法来做的,但这种方式会给编写单元测试用例带来一些不便:

  1. 因为代码中用assert来检查参数类型,导致传入的参数只能是这个接口或其子类的实例;
  2. 因为接口类继承自ABC,所以必须定义所有被abstractmethod装饰的方法,否则在实例化时就会抛出异常。

例如,在nest项目的文件tests/use_case/task/test_list.py中,作为白盒测试的人员,我确切地知道类ListTaskUseCaserun方法只会调用它的task_repositoryfind方法,但在类MockTaskRepository中依然不得不定义基类的每一个方法——尽管它们只有一行pass语句。

如果愿意放弃一点点的严谨性,那么可以弱化一下上面的接口方案:

  1. 不使用abstractmethod,而是在本应为抽象方法的方法中只留下一句raise NotImplementedError
  2. 不使用assert检查类型,而是在参数中写上type hint。

有了第1点,那么在测试用例中就不需要为测试路径上不会调用的方法写多余的定义了。而有了第2点,也就不需要为测试路径上不会引用的属性创建对象了,大可直接传入一个None。选择哪一种都无妨,取决于开发者或团队的口味。

金坷垃整洁架构的好处都有啥

在《架构整洁之道》的第20章,作者给出了整洁架构的五种优秀特性:

  • 独立于框架。例如,我可以花不是很大的力气,将nestFlask迁移到Bottle上,尽管并不会无缘无故或频繁地这么做;
  • 容易测试。例如,在nest项目的目录tests/use_case下的测试用例不需要有任何外部系统的依赖就可以编写并运行;
  • 独立于用户界面。例如,在nest项目中同一个用例RegistrationUseCase就有HTTP API和命令行两种用户界面:
    • 在文件nest/web/controller/registration.py中是HTTP API形态;
    • 在文件nest/cli/command/register.py中则是命令行形态。
  • 独立于数据库。例如,就像更换Web框架一样,我也可以从MySQL迁移到PostgreSQL中,这对于EntitiesUse Cases层的代码而言别无二致;
  • 独立于外部系统。例如,在fledgling项目中,尽管也定义了一个接口ITaskRepository,但不同于nest中基于数据库的实现子类DatabaseTaskRepository,在fledgling中实现的是基于网络传输的类TaskRepository。但究竟是基于单机数据库,还是身处一个分布式系统(C/S模型)中,EntitiesUse Cases层对此是无感知的。

甘瓜苦蒂——整洁架构的不足

渗入内层的I/O

忆往昔峥嵘岁月稠在Python的语言标准的Comparisions章节中提到

Also unlike C, expressions like a < b < c have the interpretation that is conventional in mathematics

也就是说,在C语言中要写成a < b && b < c的表达式,在Python中可以写成a < b < c。并且,标准中还提到

Comparisons can be chained arbitrarily, e.g., x < y <= z is equivalent to x < y and y <= z, except that y is evaluated only once (but in both cases z is not evaluated at all when x < y is found to be false).

一般将这种性质成为短路。因此,像2 < 1 < (1 / 0)这样的表达式在Python中不会引发异常,而是返回False

Python的小于号能拥有短路特性,是因为它并非一个普通函数,而是有语言层面加持的操作符。而在Common Lisp(下称CL)中,小于号仅仅是一个普通函数,就像Haskell中的小于号也是一个函数一般。不同的是,CL的小于号能接受多于两个的参数

1
(< 1 2 3 -1) ; 结果为NIL

但它并没有短路特性

1
(< 1 2 3 -1 (/ 1 0)) ; 引发名为DIVISION-BY-ZERO的错误

要想模拟出具有短路特性的小于号,必须借助于宏的力量。

想生成什么样的代码

要想写出一个宏,必须先设想出它的语法,以及它会展开成什么样的代码。姑且为这个宏起名为less-than,它的语法应当为

1
2
3
(defmacro less-than (form &rest more-forms)
; TBC
)

至于它的展开结果可以有多种选择。例如,可以(less-than 2 1 (/ 1 0))展开为自身具有短路特性的and形式

1
(and (< 2 1) (< 1 (/ 1 0)))

但就像在C语言中用宏朴素地实现计算二者最大值的MAX宏一样,上面的展开方式在一些情况下会招致重复求值

1
(less-than 1 (progn (print 'hello) 2) 3)

因此,起码要展开为andlet的搭配

1
2
3
4
5
(let ((g917 1)
(g918 (progn (print 'hello) 2)))
(and (< g917 g918)
(let ((g919 3))
(< g918 g919))))

要想展开为这种结构,可以如这般实现less-than

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(defmacro less-than (form &rest more-forms)
(labels ((aux (lhs forms)
"LHS表示紧接着下一次要比较的、小于号的左操作数。"
(unless forms
(return-from aux))
(let* ((rhs (gensym))
(rv (aux rhs (rest forms))))
(if rv
`(let ((,rhs ,(first forms)))
(and (< ,lhs ,rhs)
,rv))
`(< ,lhs ,(first forms))))))
(cond ((null more-forms)
`(< ,form))
(t
(let ((lhs (gensym)))
`(let ((,lhs ,form))
,(aux lhs more-forms)))))))

用上面的输入验证一下是否会导致重复求值

1
2
3
4
5
CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3))
(LET ((#:G942 1))
(LET ((#:G943 (PROGN (PRINT 'HELLO) 2)))
(AND (< #:G942 #:G943) (< #:G943 3))))
T

优化一下

显然less-than可以优化,只需要简单地运用递归的技巧即可

1
2
3
4
5
6
7
8
9
10
(defmacro less-than (form &rest more-forms)
(cond ((<= (length more-forms) 1)
`(< ,form ,@more-forms))
(t
(let ((lhs (gensym))
(rhs (gensym)))
`(let ((,lhs ,form)
(,rhs ,(first more-forms)))
(and (< ,lhs ,rhs)
(less-than ,rhs ,@(rest more-forms))))))))

展开后的代码简短得多

1
2
3
4
CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3))
(LET ((#:G955 1) (#:G956 (PROGN (PRINT 'HELLO) 2)))
(AND (< #:G955 #:G956) (LESS-THAN #:G956 3)))
T