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

0%

序言

《编码》这本书曾经在我的豆瓣“想读”列表中躺了很久,大概在今年年初才开始看。但读着读着发现书中的电路图越来越多,而我的阅读热情也随之被慢慢浇灭。五月初的时候,终究还是把它合上,并在豆瓣上羞愧难当地将其标注为“读过”。

抛开晦涩的电路图不谈,书中有一句话吸引了我的注意力

第一次读到这里时,我想作者应当会在下一段给出具体的证明过程——结果居然没有。难道作者觉得两侧的空白太小了,不足以写下他所发现的美妙证法?

受好奇心的驱使,我便试着证明书中的这个结论。

不过正式开始前,还得明确一下命题:对于任意的正整数aba不等于b),10的a次幂和2的b次幂不相等。

先证明一条引理

为了证明上面的命题,需要先证明一条引理:对于任意的正整数a,5的a次幂是一个奇数。可以用数学归纳法来证明。

首先验证a为1时命题成立。由于5的1次幂为5,并且5是一个奇数,所以命题成立;

接着,假设ak时命题成立,将5的k次幂写成2n+1的形式,当ak+1时,

因此,5的k次幂也是一个奇数。因此,该命题对于任意的正整数a都是成立的。

同理可证:对于任意的正整数a,2的a次幂是偶数。

反证法证明原命题

假设存在正整数abb大于a),使得10的a次幂与2的b次幂相等

将10分解为2和5的积,再两边同时除以2的a次幂

等式的左边和右边分别是5的正整数次幂与2的正整数次幂。由前一节的引理可知,左边是奇数,右边是偶数,两者不可能相等,与上述等式产生矛盾。因此,原假设不成立,命题得证。

后记

我最开始的想法很复杂。虽然也是采用反证法,但我将等式做了如下变换

然后试图证明以2为底的10的对数不是有理数,和等式右边不相等。不过这个方法于我而言太难了,便没有继续尝试下去。

序言

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

来自我的小米手机的截图

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

那么这是怎么做的呢?

实现思路及代码

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

要实现一个秒表,首先要知道从开始计时至今过了多久。在*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词条也有一幅更严谨的版本。