鄢倩

(conj clojurians me)

2016年11月份的技术雷达中给出了一个简明的定义:流水线即代码 (Pipeline as Code) 通过编码而非配置持续集成/持续交付 (CI/CD) 运行工具的方式定义部署流水线。
其实早在2015年11月份的技术雷达当中就已经有了类似的概念:

The way to avoid programming in your CI/CD tool is to extract the complexities of the build process from the guts of the tool and into a simple script which can be invoked by a single command. This script can then be executed on any developer workstation and therefore eliminates the privileged/singular status of the build environment
大意是将复杂的构建流程纳入一个简单的脚本文件,然后用一条命令调用。这样,任意的开发者都能在自己的工作区中执行脚本重建一套一模一样的构建环境,从而消除 CI/CD 环境由于散乱配置腐化而成的特异性。这么做的原因很好理解,使用 CI/CD 工具是为了暴露产品代码中的问题的,如果它们自身已经复杂到不稳定的地步,我们还使用它就是自找麻烦。

从某种程度上看,实施流水线即代码是不证自明的。在 CI/CD 的时间过程中,凡是可以被编码的东西都已经被代码化了,比如:构建、测试、数据库迁移、部署和基础设施/环境配置 (Infrastruture as Code) 等。说得烂俗点,流水线已经是 CI/CD 实践过程中的“最后一公里”,让流水线变成软件开发中的“一等公民”(即代码)是大势所趋、民心所向。不过,这种论断毕竟欠缺说服力,我们接着从实践的痛点出发总结当前流水线遇到的问题。

实践中的痛点

我给客户搭建和配置过不少 CI/CD 流水线(被同事戏谑地称为“CI/CD搭建兽”),最大的痛苦莫过于每次都得从头来过,即便大部分情况下所用的工具和配置都大同小异。其次是手工操作产生的配置漂移 (configuration drift) 。以 Jenkins 为例,先不谈 1.0 版本不支持流水线这一概念的问题,我们为了解决遇到的构建、测试和部署等问题,一般会在多个文本框中粘贴大量 shell/batch 脚本;甚至会通过这些文本框安装各种插件或者依赖包、设置环境变量等等。久而久之(实际上不需要多久),这台 Jenkins 服务器就变得不可替代(特异化)了,因为没人清楚到底对它做了哪些更改以及这些更改对承载它的系统产生哪些影响,这时 Jenkins 服务器俨然腐化成了老马所说的雪花服务器 (snowflake server)。雪花服务器有两点显著的特征:

  1. 特别难以复现
  2. 几乎无法理解

第一点是由于以往所做的更改并没有被记录下来,所以做过的操作都是七零八落的,没有办法复现同样的操作,也无法复制一个同样的系统。
第二点则是由于绝大部分情况下散乱的配置是没有文档描述的,哪部分是重要的已经无从知晓,改动的风险很大。

这些问题会在流水线的演化过程中恶化得越来越严重。一般来讲,除非不再使用,否则流水线不会保持一成不变。具体实施过程中,考虑到项目,尤其是遗留项目当前的特点和团队成员的“产能”,我们会先将构建和部署自动化;部署节奏稳定后,开始将单元测试和代码分析自动化;接着可以指导测试人员将验收测试自动化;然后尝试将发布自动化。在这之后,就要开始持续优化流水线,包括 CI 的速度和稳定性等。换句话说,流水线的演化其实是和项目的当前进展密切相关的,保证这样的对应关系有时是有必要的,比如:在版本控制下,多发布分支所需流水线和主干分支会存在不同。发布分支是主干分支某个时刻分出去的,它需要在那时的流水线上才能正常工作。由于前面所说雪花服务器的特征,重建这样一条流水线并不是一件容易的事情。

演进式的持续集成

如何解决

其实,流水线即代码本身已经回答这个问题了。当前实现了这一概念的工具大体遵循了两种模式:

  1. 版本控制
  2. DSL(领域特定语言)

对于特别难以复现、没有保证对应关系的痛点,我们就把流水线写成代码放到版本控制工具中管理起来。这样一来,每一次更改都能被记录下来,而且它会始终和此时的项目进展保持同步。

对于几乎无法理解、没有文档支持的痛点,我们就选用领域特定语言描述整条流水线。举个 Jenkins 2.0 例子,它允许我们在项目的特定目录下放置一个 Jenkinsfile 的文件,内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
node('master') {
stage('Checkout') {…}
stage('Code Analysis') {…}
stage('Unit Test') {…}
stage('Packing') {…}
stage('Archive') {…}
stage('DEV') {…}
}
stage('SIT') {
timeout(time:4, unit:'HOURS') {
input "Deploy to SIT?"
}
node('master') {…}
}
stage('Acceptance Test') {
node('slave') {…}
}

Jenkins 2.0 使用Groovy实现了一套描述流水线的DSL,即便不了解Groovy语言,只要对流水线稍微熟悉,就能按照例子和文档编写出符合要求的代码。

类似的工具还有Concourse.ci、λCD (LambdaCD) 等。
Concourse.ci 使用了 yaml 实现了DSL,独立抽象出Resource(外部依赖,如:git repo)、Job(函数, get 和 put Resource )和 Task(纯函数,必须明确定义 Input 和 Output )模型。

Concousre.ci

而 λCD 则使用 Clojure 语言实现了 DSL,抽象出 Pipeline 和 Step 模型,使用了Lisp特有的宏 (macro) 和普通函数,编写起来简单明了。

λCD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(def pipeline-def
`(
(either
manualtrigger/wait-for-manual-trigger
wait-for-repo)

(with-workspace
clone
(in-parallel
run-some-tests
run-smokeing-tests)

run-package
deploy)))

上述的pipeline-def就是这条流水线的定义,极为优雅得是,它的代码和UI事实上构成了一一映射的关系,简单到极致。

值得一提的是,λCD 有别于其它同类型的工具,它本身就是一份用 Clojure 写就的微服务。换句话说,其它的工具可能需要借助基础设施即代码完成自身的安装,但λCD不用,它完全可以采用其它微服务的部署方式,比如用 λCD 部署它自己,类似于编译器的自举 (bootstraping)。这个时候,我们就需要两套 λCD 服务,一套用于部署自身,另一套部署开发中的工程。

流水线自举

小结

流水线即代码是个新概念,也就意味着我们还需要花时间去探索与之相关的实践,比如,调试和测试(既然是代码就需要测试)。一旦有了这些实践,我们就可以把流水线本身作为产品放到流水线上运作起来,那时将会看到一种很好玩的现象——旧的流水线会构建并部署新流水线,完成流水线的自举 (pipeline bootstrap) 。此外,当流水线成为代码,它在最终的交付物中必然占据一席之地,其潜在的价值还等待我们挖掘,至少从精益的角度,流水线能做的事情还有很多。

俗话说,工欲善其事必先利其器,完善开发工具与我而言是一件快乐的事情,分享也是一件令人愉悦的事情,所以我想把学习过程中的点滴记录下,留作备忘。本文不会介绍太多花式或有深度的emacs配置,更多是摸索学习的过程,其中充满了乐趣。

原因

网络上的*.emacs.d/init.el配置数不胜数,各路lisp大神的dot file都已经放在github上了,而且前有牛人撰文推荐学习emacs配置的详实方法,看似确实没有什么必要自己折腾一份配置。这个说法对,也不对。我在转向emacs之前,是一名忠实的vim党,从大学开始就不断折腾vim的配置,还花过一段时间专门学习了vimscript,曾经惊叹于vimscript的动态函数式风格的优美和强大。类似地,.vimrc*配置文件在网络上也多如牛毛,华丽和酷炫的插件极大地提升了vim的操作性。尽管如此,我还是乐于一砖一瓦地打造自己的vim环境,竭力演化它变成我心目中的“编辑器之神”。这个过程一般会充满修改然后重启的重复性机械劳作,偶尔会遭遇无论怎么修改就是不生效、甚至遍寻google也一无所获的挫折,但是我就是无法厌倦它,人天生好奇,探索未知事物本身就充满了乐趣,而且一旦配置奏效,便能获得满满的成就感。新事物对程序员具有极大的吸引力,但是程序员不会止步于使用新事物,而且会在惊奇之余,渴望控制那股背后主导它的力量本身,行使“上帝之力”。

话说回来,为什么我会从vim党摇身一变成为emacs党呢?这就不得不提起Clojure这门lisp方言,出于对lisp和函数式编程的痴迷,我选择了基于JVM的Clojure作为自己的偏好语言,而emacs天生为lisp而生。有了这个充足的理由,我开始收集emacs的cheatsheet并打印出来,天天放在手边翻阅,甚至买了一本英文版的Learning GNU Emacs书籍,只要有机会就打开emacs开始刷4clojure上的编程题。由于emacs对lisp的亲和性,我几乎没花多少时间就掌握住了常用的操作技巧。不过,emacs最负盛名的学习曲线确实让学习者绕过圈子,只要一段时间不用,就会忘记很多基本操作。另外,为了更好地在emacs中编写Clojure,还需要cider-mode和clojure-mode的支持,这时候就不得不编辑init.el文件,本着KISS (keep it simple, stupid)原则,我照着各种插件的说明文档中,把配置项复制粘贴到init.el文件当中,运行起来没有问题就好。随着自定义的内容变多,init.el文件也急剧膨胀起来。膨胀本来算不上问题,但我是个比较有操守的程序员,臃肿的代码是我极力避免的坏味道(bad smell)。所以胸臆之中涌动一股浩然之气,决心学起emacs lisp,把emacs的配置从头来过。

从『头』开始

init.el文件位于*~/.emacs.d目录之下,如果没有,自行创建一份即可。
首先,我们需要用到emacs的包管理工具
package.el*,因为emacs 24及其以上的版本都已经内置,所以无需下载到本地,直接通过require加载到emacs的运行时。

1
2
3
4
5
6
7
(require 'package)
(setq package-archives '(("melpa" . "http://melpa.org/packages/")
("melpa-stable" . "https://stable.melpa.org/packages/")
("marmalade" . "http://marmalade-repo.org/packages/")
("elpy" . "http://jorgenschaefer.github.io/packages/")
("gnu" . "http://elpa.gnu.org/packages/"))
package-enable-at-startup nil)

上面的代码涉及到setq(变量赋值)的操作,package-archives,顾名思义,多个包的下载源,我给package-archives设置了5个包源,它们之间服从顺序的优先级,即先从第一个源中下载包,如果没有,到第二个原种寻找,以此类推。此外,这里("melpa" . "http://melpa.org/packages/")中的点号(dot)表示法也比较奇怪,其实这是lisp中的Dotted pair表示法,用法和普通的列表类似,但因为是pair的缘故,你可以使用(car )获取"melpa"(cdr )获取到的却不再是一个列表,而是"http://melpa.org/packages/" 这个值本身。

emacs lisp不熟悉不要紧,先找个教程练习一下它的用法,比如learnxinyminutes就非常不错。完成这个教程,大体不会对elisp犯怵了。接下来,只需要使用c-h vc-h f查看elisp中定义的变量函数就能很快上手自行配置。
来个实际的例子,在大牛的配置文件中,经常能看到如下成对的配置:

1
2
(setq package-enable-at-startup nil)
(package-initialize)

开始我觉得这是一对矛盾的配置,package-enable-at-startup设置为nil,暗示emacs启动时不会启用package,而package-initialize明显表明在做package的初始化工作。这种时候,我心中就蹦跶出一句话“世界上本没有矛盾,如果出现了,检查你都有哪些前提条件,就会发现其中一个是错的”。这种非异常的知识点很难通过搜索引擎找到满意的答案,而阅读文档恰恰是最合适的解决方式。emacs对elisp文档的支持非常全面,只需将鼠标移到package-enable-at-startup变量上,按下c-h v (control + h, v) 组合键,就能在其它窗口(window) 看到文档描述:

Whether to activate installed packages when Emacs starts.
If non-nil, packages are activated after reading the init file
and before after-init-hook'. Activation is not done if user-init-file’ is nil (e.g. Emacs was started with “-q”).

意思是在读入init.el之后,这个变量才会生效。换句话说,在读取init.el的过程中,该变量不论是nil或是non-nil都不会影响package的加载和初始化。所以,这两者之间并没有矛盾。当然,此时你可能会想把package-enable-at-startup设置为nil意欲何为?官方文档中有如下的解释:

This will automatically set package-enable-at-startup to nil, to avoid loading the packages again after processing the init file.

简单点说,就是防止在package-initialize 之后重复加载包,因为可能会影响性能。

模块化

如果把什么东西都揉到init.el文件中,这个文件一定会很快变得臃肿不堪。为了解决这个问题,需要引入模块化的思想——把特定功能的配置放到独立的文件中,然后require进来。按照惯例,我在*~/.emacs.d目录下建立一个lisp目录用于存放所有自定义的模块文件,随后在init.el中加入下面这句代码,意在把lisp*目录加到emacs的加载路径列表里。

1
(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory))

看似,接下来就可以在每个独立的模块文件中编写各种功能的配置。但是由于package.el功能的局限,我们很快就会遇到包重复安装和配置漂移(configuration drift)的麻烦。package.el提供了package-install-p(p是predicate的意思)和package-install两个配套使用的函数,也就是说一般得先判断包在不在,才决定安不安装。幸运的是,有人已经很好地解决了这部分问题,use-package就是非常好用的包,它将包的配置和包的定义聚合到了一块,并且保证包一定会安装在你的系统当中。
在使用use-package之前,我们需要先安装它,如下:

1
2
3
4
5
6
(unless (package-installed-p 'use-package)
(package-refresh-contents)
(package-install 'use-package))

(eval-when-compile
(require 'use-package))

由于use-package本身就是一个包,所以可以使用package-install安装到本地,然后require到emacs的运行时,值得一提的是这个eval-when-compile函数,使用c-h f查看它的定义:

Like ‘progn’, but evaluates the body at compile time if you’re compiling.
Thus, the result of the body appears to the compiler as a quoted constant.
In interpreted code, this is entirely equivalent to `progn’.

初次看到compile time,心中难免会有疑问:lisp不是动态语言吗,怎么还需要编译?这种时候,我们就要求助于elisp的文档了。在emacs中按下c-h i获取主话题(topic)的菜单,然后点击Elisp进入它的操作指南。重点查看EvaluationByte Compilation两个章节。不难发现lisp的解析器可以读取解析两种类型的lisp代码,一种是适合人类阅读的代码,以el作为后缀;另一种是编译字节码,以elc作为后缀。编译字节码运行速度优于前一种代码,我们可以通过byte-compile-file把前一种代码的文件编译成字节码文件。有趣的是,如果我们使用package来安装包,对应包的目录下都存在配套的elelc两类文件。
Byte Compilation条目下,有eval-when-compile的完整描述:

If you’re using another package, but only need macros from it (the byte compiler will expand those), then ‘eval-when-compile’ can be used to load it for compiling, but not executing. For example,

1
2
(eval-when-compile
(require 'my-macro-package))

这里头有三个关键字loadcompilingexecuting值得留意一下。为了弄懂它们的含义,我们需要了解lisp解析器基本的工作原理:code text -[characters]-> load -[lisp object]-> evaluation/compiling -[bytecode]-> lisp interpretor。换句话说,除非你想编译包含上述代码的文件,否则它的作用和progn一模一样,顺序地求值包含其中的表达式。当你正在编译文件的时候,包中宏就会原地展开,然后被eval-when-compile宏加载进内存并被编译成字节码,供后续解析器执行。

Clojure相关

载入use-package之后,我需要开始配置自己强大的Clojure开发环境了。首先,引入几个包:

1
2
3
4
5
6
7
8
(use-package rainbow-delimiters
:ensure t)
(use-package clj-refactor
:ensure t)
(use-package company
:ensure t
:defer t
:config (global-company-mode))

rainbow-delimiters能够让括号变得如同彩虹一样绚丽(主要是易于区分forms),clj-refactor是重构Clojure程序的神器,company提供了强大的命令补全提示功能。

clojure mode

接下来,我们在*~/.emacs.d/lisp目录下新建一个init-clojure.el*文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(require 'clj-refactor)
(require 'rainbow-delimiters)

(use-package midje-mode
:ensure t)

(defun my-clj-refactor-mode-hook ()
(clj-refactor-mode 1)
(yas-minor-mode 1) ; for adding require/use/import
(cljr-add-keybindings-with-prefix "C-c C-m"))

(use-package clojure-mode
:ensure t
:config
(add-hook 'clojure-mode-hook #'rainbow-delimiters-mode)
(add-hook 'clojure-mode-hook #'subword-mode)
(add-hook 'clojure-mode-hook #'midje-mode)
(add-hook 'clojure-mode-hook #'my-clj-refactor-mode-hook)
(add-hook 'clojure-mode-hook #'enable-paredit-mode))

(provide 'init-clojure)

这里就能看出use-package的好处来了,针对clojure-mode的配置项都统一放到:config中管理起来。配置完毕后,使用(provide 'init-clojure)将模块以这样的名字暴露给其它客户端调用。

CIDER mode

有了clojure-mode之后,我们还需要一个Clojure可交互式的开发工具,CIDER便是这么一款工具。同样地,我们在lisp目录下新建一个名为init-clojure-cider.el,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(require 'init-clojure)
(require 'company)

(use-package cider
:ensure t
:config
(setq nrepl-popup-stacktraces nil)
(add-hook 'cider-mode-hook 'eldoc-mode)
(add-hook 'cider-mode-hook #'rainbow-delimiters-mode)
;; Replace return key with newline-and-indent when in cider mode.
(add-hook 'cider-mode-hook '(lambda () (local-set-key (kbd "RET") 'newline-and-indent)))
(add-hook 'cider-mode-hook #'company-mode)
(add-hook 'cider-repl-mode-hook 'subword-mode)
(add-hook 'cider-repl-mode-hook 'paredit-mode)
(add-hook 'cider-repl-mode-hook #'company-mode)
(add-hook 'cider-repl-mode-hook #'rainbow-delimiters-mode))

(provide 'init-clojure-cider)

配置的首部,我使用(require 'init-clojure)先加载init-clojure,然后对CIDER本身进行一系列的配置。配置的详细信息可以通过CIDER github主页获取到,这里我就不再赘述。

最后,需要在init.el文件中添加入这么一句(require 'init-clojure-cider),重新启动emacs,找到一个Clojure项目,按下C-c M-j (hack-jack-in),就能获得一个Clojure的交互式开发环境。

小结

当然,我的emacs配置绝对不止这些,但是其余的过程大体类似。由于emacs速来有伪装成编辑器的操作系统的称号,所以我的探索是无止境的。如果大家对我的配置感兴趣,可以直接去我github上dotfiles上查看。

参考链接
[1] sriramkswamy dotemacs
[2] purcell emacs.d

我们为什么要写单元测试?

满足需求是所有软件存在的必要条件,单元测试一定是为它服务的。从这一点出发,我们可以总结出写单元测试的两个动机:驱动(如:TDD)和验证功能实现。另外,软件需求易变的特征决定修改代码成为必然,在这种情况下,单元测试能保护已有的功能不被破坏。

基于以上两点共识,我们看看传统的单元测试有什么特征?

基于用例的测试(By Example)

单元测试最常见的套路就是Given、When、Then三部曲。

  • Given:初始状态或前置条件
  • When:行为发生
  • Then:断言结果

编写时,我们会精心准备(Given)一组输入数据,然后在调用行为后,断言返回的结果与预期相符。这种基于用例的测试方式在开发(包括TDD)过程中十分好用。因为它清晰地定义了输入输出,而且大部分情况下体量都很小、容易理解。

但这样的测试方式也有坏处。

第一点在于测试的意图。用例太过具体,我们就很容易忽略自己的测试意图。比如我曾经看过有人在写计算器kata程序的时候,将其中的一个测试命名为return 3 when add 1 and 2,这样的命名其实掩盖了测试用例背后的真实意图——传入两个整型参数,调用add方法之后得到的结果应该是参数之和。我们常说测试即文档,既然是文档就应该明确描述待测方法的行为,而不是陈述一个例子。

第二点在于测试完备性。因为省事省心并且回报率高,我们更乐于写happy path的代码。尽管出于职业道德,我们也会找一个明显的异常路径进行测试,不过这还远远不够。

为了辅助单元测试改善这两点。我这里介绍另一种测试方式——生成式测试(Generative Testing,也称Property-Based Testing)。这种测试方式会基于输入假设输出,并且生成许多可能的数据验证假设的正确性。

生成式测试

对于第一个问题,我们换种思路思考一下。假设我们不需要写具体的测试用例,那么掩盖意图的可能性也就没有了。想法很美好,但如何实践Given、When、Then呢?答案是让程序生成并自动验证它们。这也就引出生成式测试的概念——我们先声明传入数据可能的情况,然后使用生成器生成符合入参情况的数据,调用待测方法,最后才进行验证。

Given阶段

Clojure 1.9(Alpha)新内置的clojure.spec可以很轻松地做到这点:

1
2
3
4
5
6
7
;; 定义输入参数的可能情况:两个整型参数
(s/def ::add-operators (s/cat :a int? :b int?))
;; 尝试生成数据
(gen/generate (s/gen ::add-operators))

;; 生成的数据
-> (1 -122)

首先,我们尝试声明两个参数可能出现的情况或者称为规格(specification),即参数a和b都是整数。然后调用生成器产生一对整数。整个分析和构造的过程中,都没有涉及具体的数据,这样会强制我们揣摩输入数据可能的模样,而且也能避免测试意图被掩盖掉——正如前面所说,return 3 when add 1 and 2并不代表什么,return the sum of two integers才具有普遍意义。

Then阶段

数据是生成了,待测方法也可以调用,但是Then这个断言阶段又让人头疼了,因为我们根本没法预知生成的数据,也就无法知道正确的结果,怎么断言?

拿我们定义的加法运算为例:

1
2
(defn add [a b]
(+ a b))

我们尝试把断言改成一个全称命题的格式:

任取两个整数a, b,a和b加起来的结果总是a, b之和。

借助test.check,我们在Clojure可以这样表达

1
2
3
4
(def test-add
(prop/for-all [a (gen/int)
b (gen/int)]
(= (add a b) (+ a b))))

等等,我们把add方法的实现(+ a b)写到了断言里,这几乎丧失了单元测试的基本意义。换一种断言方式,我们使用加法的逆运算进行描述:

任取两个整数,把a和b加起来的结果减去a总会得到b。

1
2
3
4
(def test-add
(prop/for-all [a (gen/int)
b (gen/int)]
(= (- (add a b) a) b))))

我们通过程序陈述了一个已知的真命题。变换以后,就可以使用quick-check对多组生成的整数进行测试。

1
2
3
4
5
;; 随机生成100组数据测试add方法
(tc/quick-check 100 test-add)

;; 测试结果
-> {:result true, :num-tests 100, :seed 1477285296502}

测试结果表明,刚才运行了100组测试,并且都通过了。理论上,程序可以生成无数的测试数据来验证add方法的正确性。即便不能穷尽,我们也获得一组统计上的数字,而不仅仅是几个纯手工挑选的用例。

至于第二个问题,首先得明确测试是无法做到完备的。很多指导方法保证使用较少的用例做到有效覆盖,比如:等价类、边界值、判定表、因果图、pairwise等等。但是在实际使用过程当中,依然存在问题。举个等价类的例子,假如我们有一个接受自然数并直接返回入参的方法identity-nat,那么对于输入参数而言,全体自然数都互为等价类,其中的一个有效等价类可以是自然数1。如果入参被假定在整数的范围,我们很容易找到一个无效等价类,比如-1。

用Clojure测试代码表现出来:

1
2
3
4
5
(deftest test-with-identity-nat
(testing "identity of natural integers"
(is (= 1 (identity-nat 1))))
(testing "throw exception for non-natural integers"
(is (thrown? RuntimeException (identity-nat -1)))))

不过如果有人修改了方法identity-nat的实现,单独处理入参为0的情况,这个测试还是能够照常通过。也就是说,实现发生改变,基于等价类的测试有可能起不到防护作用的。当然你完全可以反驳:规则改变,等价类也得重新定义。道理确实如此,但是反过来想想,我们写测试的目的不正是构建一张安全网吗?我们信任测试能在代码变动时给予警告,但此处它失信了,这就尴尬了。

如果使用生成式测试,我们规定:

任取一个自然数a,在其上调用f的结果总是a。

1
2
3
4
5
6
7
8
9
10
11
(def test-identity-nat
(prop/for-all [a (s/gen nat-int?)]
(= a (identity-nat a))))


(tc/quick-check 100 test-identity-nat)

-> {:result false, :seed 1477362396044, :failing-size 0, :num-tests 1, :fail [0], :shrunk {:total-nodes-visited 0,
:depth 0,
:result false,
:smallest [0]}}

这个测试尝试对100组生成的自然数(nat-int?)进行测试,但首次运行就发现代码发生过变动。失败的数据是0,而且还给出了最小失败集[0]。拿着这个最小失败集,我们就可以快速地重现失败用例,从而修正。

当然也有可能在一次运行中,我们的测试无法发现失败的用例。但是,如果100个测试用例都通过了,至少表明我们程序对于100个随机的自然数都是正确的。和基于用例的测试相比,这就如同编织出一道更加紧密的安全网。网孔越小,漏掉的情况也越少。

Clojure语言之父Rich Hickey推崇*Simple Made Easy*哲学,所以生成式测试在Clojure.spec中有更为简约的表达。以上述为例:

1
2
3
4
5
6
(s/fdef identity-nat
:args (s/cat :a nat-int?) ;输入参数的规格
:ret nat-int? ;返回结果的规格
:fn #(= (:ret %) (-> % :args :a))) ;入参和出参之间的约束

(stest/check `identity-nat)

fdef宏定义了方法identity-nat的规格,默认情况下会基于参数的规格生成1000组数据进行生成式测试。除了这一好处,它还提供部分类型检查的功能。

再谈TDD

TDD(测试驱动开发)是一种驱动代码实现和设计的过程。我们说要先有测试,再去实现;保证实现功能的前提下,重构代码以达到较好的设计。整个过程就好比演绎推理,测试就是其中的证明步骤,而最终实现的功能则是证明的结果。

对于开发人员而言,基于用例的测试方式是友好的,因为它能简单直接地表达实现的功能并保证其正确性。一旦进入红、绿、重构的节(guai)奏(quan),开发人员根本停不下来,进入一种心流状态。只不过问题是,基于用例驱动出来的实现可能并不是恰好通过的。我们常常会发现,在写完上组测试用例的实现之后,无需任何改动,下组测试照常能运行通过。换句话说,实现代码可能做了多余的事情而我们却浑然不知。在这种情况下,我们可以利用生成式测试准备大量符合规格的数据探测程序,以此检查程序的健壮性,让缺陷无处遁形。

凡是想到的情况都能测试,但是想不到情况也需要测试。这才是生成式测试的价值所在。有人把TDD概念化为“展示你的功能”(Show your work),而把生成式测试概念化为“检查你的功能“(Check your work),我深以为然。

小结

回到我们写单元测试的动机上:

  1. 保证或验证实现功能;
  2. 保护已经实现的功能不被破坏。
    基于用例的单元测试和生成式测试在这两点上是相辅相成的。我们可以借助它们尽可能早地找出缺陷,避免缺陷逃逸到生产环境。

ThoughtWorks 2016年11月份的技术雷达把Clojure.spec移到了工具象限的评估环中,这表明这个工具值得作一番探究。当然,除了Clojure,其它语言都有相应的生成式测试的框架,你不妨在自己的项目中试一试。

起源

TDD讨论组里的申导最近在B站直播了Martin Fowler的经典文章*Refactoring with Loops and Collection Pipelines中谈到的利用集合管道对循环进行函数式重构。视频地址在这里,申导的翻译在这里。组织者小波(Seaborn Lee)趁机出了一道关于集合管道函数题目。我就想啊,论函数式编程,舍Clojure其谁?而且我在Clojure*很少能写出loop... recur这样偏底层的循环代码。话不多说,撸起袖子开工。

题目

一家澡堂有 m 个房间,每个房间有 n 个时段,现在要给用户推荐「最早的可以预约的时段」。

例子:

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
rooms: [
{
room_id: 1,
periods: [
{
time: '17:00-18:00',
status: 'available'
},
{
time: '18:00-19:00',
status: 'occupied'
}
]
}, {
room_id: 2,
periods: [
{
time: '17:00-18:00',
status: 'occupied'
},
{
time: '18:00-19:00',
status: 'available'
}
]
}
]

期望返回:

1
2
3
4
{
room_id: 1,
time: '17:00-18:00'
}

解析

题目很简单,基本思路:首先过滤出每个房间periodsstatusavailable的时间段,然后取第一个也就是最早的时间段(默认为递增排序的),接着将room_id和这个时间段以期望返回的形式合并。再然后对所有合并的结果依据时间段进行一次排序(sort),最后取第一个结果即可。

1. Clojure 解法

转换数据格式

原题中给的是json的格式,不适合在Clojure中处理,所以我们手工转换成需要的形式,如下:
清单1-1 数据定义

1
2
3
4
5
6
7
8
9
10
11
(def rooms
[{:room-id 1
:periods [{:time "17:00-18:00"
:status :available}
{:time "18:00-19:00"
:status :occupied}]}
{:room-id 2
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}]}])

代码

清单1-2 房间最早可预约时间段

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-room [rooms]
(->> rooms
(map
(juxt first (fn [{:keys [periods]}]
(->> periods
(filter #(= (:status %) :available))
(ffirst)))))
(map #(into {} %))
(sort-by :time)
(first)))

(the-earliest-available-room rooms)
-> {:room-id 1, :time "17:00-18:00"}

这段代码和上面的解析是一一对应的关系。为了让程序清晰,符合管道的用法,这里使用了thread last宏(->>),它的作用是把前面一个form作为后一个form的最后一个参数。与之呼应的是thread first宏(->),它的作用类似,不过会传成第一个参数。

我们先看(map (juxt ...) ...)这一段代码。juxt是一个非常有意思的函数,而且超级实用。它的文档描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
(doc juxt)
->
clojure.core/juxt
[f]
[f g]
[f g h]
[f g h & fs]
Added in 1.1
Takes a set of functions and returns a fn that is the juxtaposition
of those fns. The returned fn takes a variable number of args, and
returns a vector containing the result of applying each fn to the
args (left-to-right).
((juxt a b c) x) => [(a x) (b x) (c x)]

它的神奇之处在于可以对同一个参数应用不同的函数,而且还能将应用的结果全部收集起来。想想题目的解析中提及的以期望返回的形式合并,如果我们应用juxt函数,就能得到[(:room-id 1) (:time "17:00-18:00")]这样的中间结果。

(juxt first (fn ...))first用于提取:room-id,而后面的lambda表达式则用于提取:time。解法很直观,筛选出:status:available的时间段,然后使用(ffirst)取第一个map的首个entry。如:{:time "17:00-18:00" :status :available},那么应用(ffirst)的结果就是[:time "17:00-18:00"]

接下来,又进行了一次map操作,这次的目的是把元组的entries,转换为map。举个例子:[[:room-id 1] [:time "17:00-18:00"]] => {:room-id 1 :time "17:00-18:00"}。转换成map之后,方便以:time对结果进行排序(sort-by :time),最后取出第一个元素(first),即我们期望的返回。

写完之后,我很想再写个TDD版本的。话不多说,继续撸袖子。

2. Clojure TDD 解法

环境准备

  • 生成工程

进入命令行,输入lein new midje the-earliest-available-period-of-bathroom,leiningen会生成基于midje这个测试框架的工程。

  • Git

    1
    2
    3
    4
    5
    6
    7
    8
    git init
    > .gitignore
    .lein*
    .nrep*
    target/
    这里ctrl-c退出
    git add .
    git commit --message "init commit"

    我使用了zshoh-my-zsh,自带了很多git操作的alias,可以通过alias |grep git查看。后续的git操作都会使用alias。

  • 自动测试

输入lein repl,然后(use 'midje.repl),最后输入(autotest)。这样一旦文件修改保存,测试就会自动触发。

  • Emacs

用来写代码的。

Tasking(任务拆分)

先不急着敲代码,我们先从测试的角度看看完成这个需求需要哪几步?

  • 单间澡堂有一个可用时间段
  • 单间澡堂有多个可用时间段
  • 所有澡堂(包含输入为空)没有可用时间段
  • 多间澡堂都有可用时间段
  • 多间澡堂中有的有可用时间段,有的没有可用时间段

第1个任务

  • 单间澡堂有一个可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(def room-1 {:room-id 1
:periods [{:time "17:00-18:00"
:status :available}
{:time "18:00-19:00"
:status :occupied}]})

(def room-2 {:room-id 2
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}]})

(facts "about `the-earliest-avaible-period-of-bathroom`"
(fact "should recommand if there is only one room with available period"
;; 1号
(the-earliest-available-recommand [room-1]) => {:room-id 1 :time "17:00-18:00"})
;; 2号
(the-earliest-available-recommand [room-2]) => {:room-id 2 :time "18:00-19:00"}))

2. 写实现

1
2
(defn the-earliest-available-recommand [rooms]
{:room-id 1 :time "17:00-18:00"})

针对1号测试,这个实现有点“荒诞”,术语hard code说的就是这个,但是眼下足够了。不过此时,应该再写一个类似的测试来去除hard code,即2号测试。
相应地,我们要修改实现。

1
2
3
4
5
defn the-earliest-available-recommand [rooms]
(let [{:keys [room-id periods]} (first rooms)
available-periods (filter #(#{:available} (:status %)) periods)]
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))))

3. 关闭并提交

  • 单间澡堂有一个可用时间段
1
2
ga .
gcmsg "one available room"

第2个任务

  • 单间澡堂有多个可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
(def room-3 {:room-id 3
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}
{:time "19:00-20:00"
:status :available}]})
...
(fact "should recommand the earliest one if there is only one room with multiple available periods"
(the-earliest-available-recommand [room-3]) => {:room-id 3 :time "18:00-19:00"})

保存,发现测试还是跑过了。原因在于我们默认了period是递增排序的。我们看看有没有重构点?实现太简单了暂时找不到,那就欢欢喜喜地跳过实现步骤。

2. 关闭并提交

  • 单间澡堂有多个可用时间段
    1
    2
    ga .
    gcmsg "one room with multiple available periods"

第3个任务

  • 所有澡堂(包含输入为空)没有可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
11
(def non-available-room {:room-id 4
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :occupied}
{:time "19:00-20:00"
:status :occupied}]})

(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room]) => :no-available-room))

这回肯定挂掉。

2. 写实现

1
2
3
4
5
6
7
(defn the-earliest-available-recommand [rooms]
(let [{:keys [room-id periods]} (first rooms)
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room)))

这里使用了Clojure中判断集合是否为空较为常用的手法(seq ),如果集合非空,那么返回集合本身;反之,返回nil,nil在逻辑上是false。测试通过。

3. 关闭并提交

  • 所有澡堂(包含输入为空)没有可用时间段
1
2
ga .
gcmsg "no available room"

第4个任务

  • 多间澡堂都有可用时间段

1. 写测试

1
2
3
4
5
(fact "should recommand the earliest if there has more than one room and each has available periods"
(the-earliest-available-recommand [room-1 room-2]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-1]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-3]) => {:room-id 2 :time "18:00-19:00"}
(the-earliest-available-recommand [room-1 room-2 room-3]) => {:room-id 1 :time "17:00-18:00"})

2. 写实现

1
2
3
4
5
6
7
8
9
10
11
12
(defn the-earliest-available-recommand [rooms]
(if (seq rooms)
(first (sort-by :time
(map (fn [room]
(let [{:keys [room-id periods]} room
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room)))
rooms)))
:no-available-room))

到这里,我们开始使用(map )函数处理多个房间的内容。注意,当输入房间是空集合的时候,这里需要相应地做(seq rooms)判空处理,否则会返回nil,而不是我们想要的:no-available-room

3. 关闭并提交

  • 多间澡堂都有可用时间段
1
2
ga .
gcmsg "more than one room"

4. 重构

代码写到这里,再不重构就说不过去了。另外,管道没看到,倒是看到一堆括号。
我们使用thread last(->> )做一次重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(if (seq rooms)
(->> rooms
(map (fn [room]
(let [{:keys [room-id periods]} room
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room))))
(sort-by :time)
first)
:no-available-room))

还行,至少没那么多嵌套了。提交一次。

1
2
ga .
gcmsg "[refactor] use macro thread-last ->> to pipe"

继续重构,使用我们的juxt函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(->> periods
(filter #(#{:available} (:status %)))
ffirst
(#(or % [:time ::non-available]))))]
(->> rooms
(map (fn [room]
(apply conj {} ((juxt first period) room))))
(remove #(#{::non-available} (:time %)))
(sort-by :time)
first
(#(or % :no-available-room)))))

看上去还行,不过不爽的是(#(or % [:time ::non-available]))。为了迎合(->> )宏,我们给(or )包了一层。原因是(->> )会让前面的结果出现在最后一个参数的位置,而我们需要将结果放到(or )的第一个参数的位置。有没有什么好看的解决方法呢?当然有!我们可以使用(-> )来做到这点。

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter #(#{:available} (:status %)))
ffirst)
(or [:time ::non-available])))]
(-> rooms
(->> (map (fn [room]
(apply conj {} ((juxt first period) room))))
(remove #(#{::non-available} (:time %)))
(sort-by :time)
first)
(or :no-available-room))))

顿时觉得世界干净了不少。再提交一次。

1
2
ga .
gcmsg "[refactor] use juxt to extract needed fields"

第5个任务

  • 多间澡堂中有的有可用时间段,有的没有可用时间段

1. 写测试

1
2
3
(fact "should recommand the earliest available room even if there has non available room"
(the-earliest-available-recommand [room-1 non-available-room]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 non-available-room]) => {:room-id 2 :time "18:00-19:00"})

测试直接通过,又可以跳过实现代码了。不过,这也预示着我们的测试是有覆盖的,也需要花时间整理这些测试用例。在那之前,先提交一下。

2. 关闭并提交

  • 多间澡堂中有的有可用时间段,有的没有可用时间段
1
2
ga .
gcmsg "mixed non-available and available rooms"

为第3个任务补上测试用例

  • 所有(包含多个)澡堂(包含输入为空)没有可用时间段
1
2
3
4
(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room]) => :no-available-room
(the-earliest-available-recommand [non-available-room non-available-room]) => :no-available-room))

这里的第3个用例包含第2个用例,我们待会整理掉。不过现在先提交一下。

1
2
ga .
gcmsg "multiple non-available rooms"

整理测试

在前面进行的任务当中,我们发现有两次没有写实现测试就通过的情况。这说明测试用例是有覆盖的。

  • 第2个任务的测试用例其实覆盖了第1个任务的测试用例,所以可以直接删去后者;
  • 第5个任务的测试用例覆盖了第4个任务的部分测试用例,所以可以合并到一起。

整理下来,最终的测试变成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(facts "about `the-earliest-avaible-period-of-bathroom`"
(fact "should recommand the earliest one if there is only one room with multiple available periods"
(the-earliest-available-recommand [room-3]) => {:room-id 3 :time "18:00-19:00"})

(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room non-available-room]) => :no-available-room)

(fact "should recommand the earliest if there has more than one room and each may have available periods"
(the-earliest-available-recommand [room-1 room-2]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-1]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-3]) => {:room-id 2 :time "18:00-19:00"}
(the-earliest-available-recommand [room-1 room-2 room-3]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-1 non-available-room]) => {:room-id 1 :time "17:00-18:00"}))

文档

The final goal of any engineering activity is some type of documentation.

更新README.md文件,其中描述程序解决的问题以及运行步骤,当然包含设计思路那更好了。提交一下。

1
2
ga .
gcmsg "update readme"

美化代码

代码是诗行 - by lambeta

什么是好看的代码?除了清晰明了,格式也必须产生美感。
1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter #(#{:available} (:status %))))
(ffirst) ; 统一套上括号
(or [:time ::non-available])))]
(-> rooms
(->> (map (juxt first period))
(map #(into {} %)) ; 合并单独提出来
(remove #(#{::non-available} (:time %)))
(sort-by :time)
(first)) ; 统一套上括号
(or :no-available-room))))

顺眼不少,最后提交一下。
1
2
ga .
gcmsg "[refactor] beautify pipe format"

这篇文章发出来一天,TDD讨论群的一位麦姓朋友@我道:

core=> (first {:a 1 :b 2})
[:a 1]
core=> (first {:b 2 :a 1})
[:b 2]
@lambeta map的元素应该是无序的,用first来获得key value pair是不可靠的。

看到这个建议的时候,我心里一阵欣喜——又有一员Clojurians,可以切磋技艺了!冷静下来,发现自己确实忽略了map中的entries可能是无序的。所以我做了如下的验证:

1
2
(type {})
-> clojure.lang.PersistentArrayMap

看到PersistentArrayMap的时候,我明白这些entries是保持插入顺序的,也就是说,(first {:a 1 :b 2})的求值结果一定是[:a 1]。照这个思路,在我的程序当中使用(first )取map的第一个元素并不会出错。不过,本着谨慎的心态,我查了一下clojure的array-map,发现一个有趣的例子:

1
2
3
4
5
(defn make-map [count] (zipmap (range count) (range count)))
(type (make-map 9))
;; => clojure.lang.PersistentArrayMap
(type (make-map 10))
;; => clojure.lang.PersistentHashMap

这表明当map中的entries数量超过一定数量(不一定是9,例外见:PersistentArrayMap’s assoc doesn’t respect HASHTABLE_THRESHOLD)时,PersistentArrayMap就变成了PersistentHashMap,那也就意味着,(first )取出来的值可能是随机的。举个例子:

1
2
(first {7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> [0 0]

返回的结果并不是相当然的[7 7],而是[0 0]。那么(first )到底干了些什么呢?Cognitect公司的alexmiller回答我说:(first )会把它的参数强制转换(coerce)成了一个序列,然后取第一个值。我们试着用(seq )转换一下:

1
2
3
4
(type { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> clojure.lang.PersistentHashMap
(seq { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> ([0 0] [7 7] [1 1] [4 4] [6 6] [3 3] [2 2] [9 9] [8 8])

果然,[0 0]出现在序列的首位。至于为什么是这样的顺序,需要深入Clojure的hash算法和数据结构当中,有时间另起一篇博客解释。我们再试试PersistentArrayMap的情况:

1
2
3
4
(type { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0})
-> clojure.lang.PersistentArrayMap
(seq { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0})
-> ([7 7] [1 1] [4 4] [6 6] [3 3] [2 2] [9 9] [0 0])

顺序确实和原来的一致。

我们的程序当中是不应该假设map是有序的,所以需要修改实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter (comp #{:available} :status)))
(first)
(find :time)))
(room-id [room]
(find room :room-id))]
(-> rooms
(->> (map (comp (partial into {}) (juxt room-id period)))
(filter :time)
(sort-by :time)
(first))
(or :no-available-room))))

(find )函数,用于从map中获取包含该键值的entry,如果找不到,返回nil。这样就避免了潜在无序的entries对程序的干扰。另外,(partial into {})Currying很像,它通过接收into函数及其首个参数,构造出一个接收后续参数的函数。当然也可以直接使用#(into {} %)这样的形式。

下面是麦姓朋友的另一种解法,和我的解法思路不完全一样,值得学习借鉴。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn the-earliest-available-recommand [rooms]
(letfn [(earliest-available-time [periods]
(->> periods
(filter (comp #{:available} :status))
(map :time)
(sort)
(first)))]
(rename-keys
(->> rooms
(map #(update-in % [:periods] earliest-available-time))
(filter :periods)
(sort-by :periods)
(first))
{:periods :time})))

真诚欢迎大家继续点评。


本文样例

读取和写入文件

数据一般都是存储在纯文本文件当中,存储的形式多种多样。本文,我会介绍如何在Clojure中读取和写入这些数据。

1. 打开文件

新建文件hello.txt,放到resources目录,内容如下:

1
2
3
hello world!
hello lambeta!
hello life!

新建4io.clj,输入程序:

1
2
3
4
5
6
(ns the-way-to-clojure.4io
(:require [clojure.java.io :as io]
[clojure.string :as str]))

(def data-file (io/resource "hello.txt"))
(slurp data-file)

运行程序,输出如下:

1
"hello world! \nhello lambeta!\nhello life!\n"

读取所有行

1
2
(line-seq (io/reader data-file))
;;=> ("hello world!" "hello lambeta!" "hello life!")

with-open宏

with-open宏用于自动关闭打开的文件。

1.1 读取一行,如下:

1
2
3
(with-open [rdr (io/reader data-file)]
(when-let [line (.readLine rdr)]
(println line)))

1.2 读取多行,如下:

1
2
3
4
5
6
;;; read multiple lines
(with-open [rdr (io/reader data-file)]
(loop [line (.readLine rdr)]
(when line
(println line)
(recur (.readLine rdr)))))

2. 读取文件的技巧

想想读取文件可能有哪些场景?

  • 读取整个文本
1
(slurp data-file)
  • 读取一行
1
2
3
4
5
6
(with-open [rdr (io/reader data-file)]
(first (line-seq rdr)))
;; 或者
(with-open [rdr (io/reader data-file)]
(take 1 (line-seq rdr)))
-> "hello world!"
  • 读取前n行
1
2
3
(with-open [rdr (io/reader data-file)]
(doall (take 2 (line-seq rdr))))
-> ("hello world!" "hello lambeta!")

这里使用了(doall )方法,如果不用这个方法,在repl中求值的时候会表达式导致抛出Unhandled java.io.IOException Stream closed异常。究其缘由是(take 2 )返回了一个惰性序列,详细解释参见文末备注。

  • 读取前n个字符
1
2
3
4
5
6
7
8
9
with-open [rdr (io/reader data-file)]
(loop [ch (.read rdr) len 20]
(when-not (or (= -1 ch) (zero? len))
(println (char ch))
(recur (.read rdr) (dec len)))))
| h
| e
| ...
-> nil
  • 跳过特定的行

resources目录下,新建records.txt,内容即代码注释所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defn read-records [input-file]
"Coloured fox fur production, HOPEDALE, Labrador, 1834-1842
#Source: C. Elton (1942) \"Voles, Mice and Lemmings\", Oxford Univ. Press
#Table 17, p.265--266
22
29
2
16
12
35
8
83
166"
(letfn [(skip [lines]
(next lines))]
(with-open [rdr ((comp io/reader io/resource) input-file)]
(->>
(for [line (skip (line-seq rdr))
:when (not (.startsWith line "#"))]
(read-string line))
(apply +)))))

(read-records "records.txt")
-> 373

我们在read-records内部新建一个skip方法,顾名思义,跳过第一个元素,然后返回后面的列表。这里旨在跳过文本的声明头。:when (not ...)过滤了文本的注释部分(以#开头的行),并使用read-string转换字符串到数字类型,(for )求值完成后返回只包含数字的列表。最后,我们对列表做了一次累加操作。

我们试试非过滤而是跳过(删除)以”#”开头行的方式获取数字列表,这样更符合要求。重写with-open部分,如下:

1
2
3
4
5
6
(with-open [rdr ((comp io/reader io/resource) input-file)]
(apply +
(let [lines (skip (line-seq rdr))]
(->> lines
(remove (set (for [line lines :while (.startsWith line "#")] line)))
(map read-string)))))

或者

1
2
3
4
5
6
(with-open [rdr ((comp io/reader io/resource) input-file)]
(apply +
(let [lines (skip (line-seq rdr))]
(->> lines
(drop (count (for [line lines :while (.startsWith line "#")] line)))
(map read-string)))))

3. 读取网络文件

通过slurp读取字符串

1
2
3
(slurp "http://robjhyndman.com/tsdldata/ecology1/hopedale.dat" :encoding "utf-8")

-> "Coloured fox fur production, HOPEDALE, Labrador,, 1834-1925\n#Source: C. Elton (1942) \"Voles, Mice and Lemmings\", Oxford Univ. Press\n#Table 17, p.265--266\n 22 \n...

注意,这个网页上的数据是用UTF-8编码的,所以解码读取时,也应该使用UTF-8。

4. 写入文件

  • 使用spit方法
1
(spit "world.txt" "Hello, lambeta!" :append true)

运行程序之后,项目的根目录下会生成world.txt文件,内容是Hello, lambeta。spit方法其实就是向Java的BufferedWriter中写入内容。

  • 使用clojure.java.io/writer

我们在项目的根目录新建numbers.txt,内容是多行的数字对,如下:

1
2
3
1.3 2.7
10000 1
-1 1

我们需要把每行两个数字,和它们相加的结果写入到sum-of_numbers.txt文件中。也就是注释中的描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(defn sum-number-pairs [input-file output-file]
"Read the data from input-file, which contains two floats per line
separated by a space. Open file named output-file and, for each line in
input-file, write a line to the output file that contains the two floats
from the corresponding line of input-file plus a space and the sum of the
two floats."
(with-open [rdr (io/reader input-file) wtr (io/writer output-file :append true)]
(loop [line (.readLine rdr)]
(when line
(let [pair (map read-string
(str/split line #"\s"))
first (first pair)
second (second pair)
sum (+ first second)]
(.write wtr (str first " " second " " sum "\n")))
(recur (.readLine rdr))))))

(sum-number-pairs "numbers.txt" "sum-of-numbers.txt")

with-open同时打开了一个用于读取、名为input-file的文件以及一个用于写入、名为output-file的文件,写入方式是追加:append true。随后循环读取input-file中的每行内容。若line不是nil(即存在),那么用空格分隔这行内容,得到一个数组,如:”1.3 2.7” -> [“1.3” “2.7”]。此时数组的元素类型还不是数字(Number),我们使用(map read-string )将元素转换为对应的数字类型,如:[“1.3” “2.7”] -> [1.3 2.7]。之后,分别提取数组的第一、二个元素以及两者的和。最后,写入到wtr中。


注意:程序中的str/split是通过(:require [clojure.string :as str])方式引入str命名空间的。


运行程序之后,sum-of-numbers.txt中的内容如下:

1
2
3
1.3 2.7 4.0
10000 1 10001
-1 1 0

5. 多行记录

5.1 有结束标识

有时候,记录并不是以一行一行的方式存储在文件当中的,而是以多行数据描述一条记录。比如下面的蛋白质数据:
清单 5.1 multimol.pdb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
COMPND      AMMONIA
ATOM 1 N 0.257 -0.363 0.000
ATOM 2 H 0.257 0.727 0.000
ATOM 3 H 0.771 -0.727 0.890
ATOM 4 H 0.771 -0.727 -0.890
END
COMPND METHANOL
ATOM 1 C -0.748 -0.015 0.024
ATOM 2 O 0.558 0.420 -0.278
ATOM 3 H -1.293 -0.202 -0.901
ATOM 4 H -1.263 0.754 0.600
ATOM 5 H -0.699 -0.934 0.609
ATOM 6 H 0.716 1.404 0.137
END

第一行描述的是分子的名字,接下来到END为止的每行代表原子的ID、类型以及在分子中分布的[x y z]坐标。
我们需要一个函数,将数据读取出来并且以规定的格式输出,格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
(("AMMONIA" 
("N" "0.257" "-0.363" "0.000")
("H" "0.257" "0.727" "0.000")
("H" "0.771" "-0.727" "0.890")
("H" "0.771" "-0.727" "-0.890"))
("METHANOL"
("C" "-0.748" "-0.015" "0.024")
("O" "0.558" "0.420" "-0.278")
("H" "-1.293" "-0.202" "-0.901")
("H" "-1.263" "0.754" "0.600")
("H" "-0.699" "-0.934" "0.609")
("H" "0.716" "1.404" "0.137")))

也就是说,我们需要把每条记录读入单个列表中,每个列表由分子的名称和多个(Type X Y Z)的原子列表组成。

1
2
3
4
5
6
7
8
9
10
11
(defn read-all-molecules [input-file]
(map (fn [molecules]
(let [[_ name] (str/split (first molecules) #"\s+")
atoms (map (comp #(drop 2 %) #(str/split % #"\s+"))
(rest molecules))]
(concat [name] atoms)))
;; 分割成多条记录
(remove #(= % ["END"])
(partition-by #(= % "END") (line-seq (io/reader input-file))))))

(read-all-molecules "multimol.pdb")

(remove #(= % ["END"]) (partition-by #(= % "END") (line-seq (io/reader input-file))))这行代码做的事情就是把文件读取出来变成一个lazy-seq,然后使用parttition-byEND进行分组,最后使用remove方法剔除掉*[“END”]*这样的分组,得到如下中间结果:

1
2
3
4
5
6
7
8
9
10
11
12
(("COMPND      AMMONIA" 
"ATOM 1 N 0.257 -0.363 0.000"
"ATOM 2 H 0.257 0.727 0.000"
"ATOM 3 H 0.771 -0.727 0.890"
"ATOM 4 H 0.771 -0.727 -0.890")
("COMPND METHANOL"
"ATOM 1 C -0.748 -0.015 0.024"
"ATOM 2 O 0.558 0.420 -0.278"
"ATOM 3 H -1.293 -0.202 -0.901"
"ATOM 4 H -1.263 0.754 0.600"
"ATOM 5 H -0.699 -0.934 0.609"
"ATOM 6 H 0.716 1.404 0.137"))

这样离我们的目标已经很近了。观察上述结果,不难发现分子的名称处于列表的第一个(first ),而原子列表可以使用(rest )获取。然后,借助(map )函数遍历所有的记录。

(let )中的第一个binding是[_ name] (str/split (first molecules) #"\s+"),首先用(split )函数分割,再使用了解构提取出分子的名称;第二个binding是原子列表的提取,我们在(split )的基础之上,使用(drop 2 )函数剔除了不用的字段,如:ATOM和1。最后使用(concat )函数将名称和原子列表的列表拼接到一起。

5.2 无结束标识

5.1中的记录项通过END标识分隔,但是事实上这是一个多余的字段,记录项可以更简练,如下:
清单 5.2 multimol-without-end-marker.pdb

1
2
3
4
5
6
7
8
9
10
11
12
COMPND      AMMONIA
ATOM 1 N 0.257 -0.363 0.000
ATOM 2 H 0.257 0.727 0.000
ATOM 3 H 0.771 -0.727 0.890
ATOM 4 H 0.771 -0.727 -0.890
COMPND METHANOL
ATOM 1 C -0.748 -0.015 0.024
ATOM 2 O 0.558 0.420 -0.278
ATOM 3 H -1.293 -0.202 -0.901
ATOM 4 H -1.263 0.754 0.600
ATOM 5 H -0.699 -0.934 0.609
ATOM 6 H 0.716 1.404 0.137

现在的问题变成了没有END标识符,如何进行分组?观察不难发现以COMPND开头的数据行可以作为记录的分隔符。
使用(partition-by #(.startsWith % "COMPND") (line-seq (io/reader input-file)))进行分组,得到的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
(("COMPND      AMMONIA") 
("ATOM 1 N 0.257 -0.363 0.000"
"ATOM 2 H 0.257 0.727 0.000"
"ATOM 3 H 0.771 -0.727 0.890"
"ATOM 4 H 0.771 -0.727 -0.890")
("COMPND METHANOL")
("ATOM 1 C -0.748 -0.015 0.024"
"ATOM 2 O 0.558 0.420 -0.278"
"ATOM 3 H -1.293 -0.202 -0.901"
"ATOM 4 H -1.263 0.754 0.600"
"ATOM 5 H -0.699 -0.934 0.609"
"ATOM 6 H 0.716 1.404 0.137"))

此时,我们对比5.1中中间结果,会发现它们极为相似。也就是说,我们稍加转换就能让两者一致,而一致的好处就是可以复用原来(map )中的逻辑。

稍稍修改原来的分组逻辑,如下:

1
2
3
4
5
(map (fn [[name atoms]] (concat name atoms))
(partition 2
(partition-by
#(.startsWith % "COMPND")
(line-seq (io/reader input-file)))))

我们先使用(partition 2 )将第一步得到的列表每隔两个元素划为一组,如下:

1
2
3
4
5
6
7
8
9
10
11
12
((("COMPND      AMMONIA") 
("ATOM 1 N 0.257 -0.363 0.000"
"ATOM 2 H 0.257 0.727 0.000"
"ATOM 3 H 0.771 -0.727 0.890"
"ATOM 4 H 0.771 -0.727 -0.890")) ; 多出一对括号
(("COMPND METHANOL")
("ATOM 1 C -0.748 -0.015 0.024"
"ATOM 2 O 0.558 0.420 -0.278"
"ATOM 3 H -1.293 -0.202 -0.901"
"ATOM 4 H -1.263 0.754 0.600"
"ATOM 5 H -0.699 -0.934 0.609"
"ATOM 6 H 0.716 1.404 0.137")))

然后使用(map (fn [[name atoms]] ...)将每组里面的两个列表合成为一个列表,这样就得到和原来5.1一模一样的中间结果。

接下来,我们把转换的逻辑从(read-all-molecules )中提取出来,以便复用。改造如下:

1
2
3
4
5
6
7
(defn read-all-molecules [f input-file]
(let [data (f input-file)]
(map (fn [molecules]
(let [[_ name] (str/split (first molecules) #"\s+")
atoms (map (comp #(drop 2 %) #(str/split % #"\s+"))
(rest molecules))]
(concat [name] atoms))) data)))

定义转换逻辑,如下:

1
2
3
4
5
6
(defn file-without-markers->multi-records [input-file]
(map (fn [[name atoms]] (concat name atoms))
(partition 2
(partition-by
#(.startsWith % "COMPND")
(line-seq (io/reader input-file))))))

最后,我们来调用的改造之后的方法:

1
2
(read-all-molecules 
file-without-markers->multi-records "multimol-without-end-marker.pdb")

此时,5.1中的转换逻辑也可以提取出一个函数:

1
2
3
(defn file->multi-records
(remove #(= % ["END"])
(partition-by #(= % "END") (line-seq (io/reader input-file)))))

原来的程序就重构成了如下的模样:

1
(read-all-molecules file->multi-records "multimol.pdb")

备注

为了清楚定位这个问题,我们需要提前了解两个知识点

  1. 什么是惰性序列?
  2. 惰性序列在repl中什么时候变现(realizes)?

惰性序列是用(lazy-seq [& body] )宏创建出来的。lazy-seq仅在需要的时候才会去调用它的body。
当repl尝试pretty-print惰性序列的结果时,才会进行变现操作。

有了上面的知识点,我们来考察with-open(take 2 (line-seq ))的关系。with-open是宏,我们使用clojure.walk/macroexpand-all展开下:

1
2
3
4
5
6
7
8
(clojure.walk/macroexpand-all 
'(with-open [rdr (io/reader data-file)]
(take 2 (line-seq rdr))))

-> (let* [rdr (io/reader data-file)]
(try (do
(take 2 (line-seq rdr)))
(finally (. rdr clojure.core/close))))

使用(doc line-seq)查看文档,得到

1
2
3
4
5
clojure.core/line-seq
[rdr]
Added in 1.0
Returns the lines of text from rdr as a lazy sequence of strings.
rdr must implement java.io.BufferedReader.

可以确认line-seq返回一个惰性的字符串序列。
再看看(doc take)的文档,得到

1
2
3
4
5
6
7
clojure.core/take
[n]
[n coll]
Added in 1.0
Returns a lazy sequence of the first n items in coll, or all items if
there are fewer than n. Returns a stateful transducer when
no collection is provided.

所以take返回的也是一个惰性序列,那么(do (take 2 (line-seq rdr)))(等价于(take 2 (line-seq rdr)))整个返回的就是一个惰性序列。

当我们通过repl求值with-open时,它并没有真的变现(take 2 (line-seq rdr)),而是在运行完try...finally之后,直接返回这个惰性序列作为结果。此时,repl开始尝试pretty-print (take 2 (line-seq rdr)),变现发生,但是rdr已经被关闭了,所以抛出Stream closed异常。

到这里,解决了一大半问题,但是还有一个逻辑上解释不过去的点,就是

1
2
(with-open [rdr (io/reader data-file)]
(take 1 (line-seq rdr)))

当我们尝试(take 1 )时并不会抛出异常!也就是说(take 1 )(take 2 )的行为不同,但是(take )明明都是返回惰性序列啊?

带着这个疑惑,看看line-seq的源代码

1
2
(when-let [line (.readLine rdr)]
(cons line (lazy-seq (line-seq rdr)))))

是不是有种豁然开朗的感觉?没有也没关系,我来解释一下。
line-seqwhen-let语句并没有包在(lazy-seq )(这点可以和take的源码比较)中,这说明[line (.readline rdr)]是需要立即求值的。也就是说,我们在求值with-open时,rdr中第一行的内容会被(line-seq )给抓住了。那么当try...finally运行结束之后,pretty-print变现惰性序列时,发现第一行根本不需要从rdr中读,当然就不会抛出异常了。

明确这几点之后,我们看看(doall )为何能解决惰性序列延迟求值的问题?(doall )其实强制变现了整个惰性序列(不断调用序列的next方法),所以并不会等到with-open求值完成之后才求值。

换个角度,我们知道之所以抛出异常,是因为repl对返回的惰性序列求值了。那么如果我们不在repl中求值,程序还会抛出异常吗?

1
2
3
4
5
6
7
(ns the-way-to-clojure.core
(:require [clojure.java.io :as io])
(:gen-class))

(defn -main [& args]
(with-open [rdr (io/reader "hello.txt")]
(take 100 (line-seq rdr))))

接着,我们使用lein run来运行main方法。程序运行良好,因为根本没有人用到返回的惰性序列。

如果我们加一句打印语句如下:

1
2
3
4
(defn -main [& args]
(println ; 变现
(with-open [rdr (io/reader "hello.txt")]
(take 100 (line-seq rdr)))))

再用lein run跑一个main方法,异常又不期而遇了。因为此处的println等价于replpretty print

插件不好写?!

插件确实不好写,因为插件是插入庞大的系统当中工作的,那也就意味着写插件需要具备一定的领域知识,包括系统架构、扩展点、业务共性及差异、API及其业务模型对应、安装和测试。而对于开发者而言,学习这些知识的代价绝对是昂贵的。
在《函数式编程思想》一书中,作者Neal Ford提到开发过程当中的两种抽象方式——composable and contextual abstract. 谈及contextual抽象的时候,他把插件系统列为这一抽象中最经典的例子。

Plugin-based architectures are excellent examples of the contextual abstraction. The plug-in API provides a plethora of data structures and other useful context that developers inherit from or summon via already existing methods. But to use the API, a developer must understand what that context provides, and that understanding is sometimes expensive.

大意是开发者能够借助已存在的方法来使用Plugin API中提供的大量数据结构和有用的上下文信息。但是,理解起这些上下文信息有时是很昂贵的。

基于一个共识:开发者的时间都是宝贵的。知道插件难写之后,我的这篇文章才有价值。

理解领域模型

一说写插件,估计大家都会上官网寻找开发指南或者google大量博客来快速完成开发任务。这里不是说这种方式不好,其实一开始我也是这么做的,但是着手开发以后,很快就遭遇处处掣肘。比如:开发sonar plugin,会用到Profile、Rule、LanguageRepository等概念。单从代码层面上看,我们很难理清这些概念所代表的模型和它们之间的关系。所以需要从用户的视角来感受这些领域知识。

而用户视角大部分情况下就是UI界面。

规则(Rules)

我们先看看Rules导航栏,左边的单选框是这些规则的过滤条件。
说明规则包含或者被包含这些属性之下:

Rules

  • Language:规则对应的某种编程语言。
  • Type:规则的类型,比如:缺陷(Bug)、代码坏味道(Code Smell)、易受攻击(Vulnerability)。
  • Tag:规则设置的标签,易于检索。
  • Repository:承载特定语言下各种规则的容器;通过它可以通过规则的键值(ruleKey)检索。
  • Default Severity:触犯规则的严重程度。
    • Blocker:最高等级,阻碍的
    • Critical:高等级,极为严重的
    • Major:较高等级,主要的;默认级别。
    • Minor:较低等级
    • Info:低等级
  • Status:规则现在的状态,可用、废弃还是实验版(Beta)。
  • Avaiable Since:什么时候开始可用。
  • Template:规则模板:比如某些参数可以运行时传入。
  • Quality Profile:挑选特定语言下各种规则组成的配置;其中可以启用或禁用一部分规则。

质量Profile(Quality Profile)

再看看Quality Profiles导航栏,左侧栏显示的是某种语言包含的所有Profiles.

Profiles

从关系型数据库的角度,Language和Profile是1对多(one-to-many)关系,但是从领域建模的角度,Profile其实和Language是1对1的关系。所以可以是Profile包含Language属性。利用领域建模的思考方式,可以联想到Repository和Rules是1对多的关系,所以Repository包含一个Rules的集合。Repository和Language是1对1的关系,Repository包含Language属性。那么Rules和Profiles的对应关系呢?多对多。但是我们更关心Profile到Rules这一层的关系,所以选择Profile包含一个Rules的集合。

我整理出这样一份对应关系图:

1
2
3
4
5
6
profile
- language
- [rules]
respository
- lanuage
- [rules]

现在,缺少Profile和Repository的关系。不过既然有了Rule这一层联系,那么就可以这样考虑,Rule和Repository是1对1的关系(为什么呢?因为每个Rule显然只能存在于一个特定的Repository当中)。所以原图可以修改为:

1
2
3
4
5
6
7
8
profile
- language
- [rules]
- rule
- respository
respository
- language
- [rules]

好了。梳理完这些领域知识,我们可以开始依照官方的教程Developing a Plugin.

扫描特定领域语言(DSL)的SonarQube插件

SonarQube 5.6现在只支持Java 8、Maven 3.1以上。当然也支持Gradle。

第一步 创建一个Maven工程

这里有两种方式。第一种方式就是从头开始写起,包括创建工程;另一种就是拷贝官方的样例程序。我自然是推荐第二种做法,不过这里我从零开始开发。

1
$ mvn archetype:create -DgroupId=com.lambeta -DartifactId=sonar-lambeta -DarchetypeArtifactId=maven-archetype-quickstart

依照官方文档将pom.xml修改如下:

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
52
53
54
55
56
57
58
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.lambeta</groupId>
<artifactId>sonar-custom</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>sonar-plugin</packaging>

<name>sonar-custom</name>
<url>https://www.lambeta.com</url>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.sonarsource.sonarqube</groupId>
<artifactId>sonar-plugin-api</artifactId>
<!-- minimal version of SonarQube to support. Note that the groupId was "org.codehaus.sonar" before version 5.2 -->
<version>5.6</version>
<!-- mandatory scope -->
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-xml</artifactId>
<version>5.4.2</version>
</dependency>
<dependency>
<groupId>dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>1.6.1</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.sonarsource.sonar-packaging-maven-plugin</groupId>
<artifactId>sonar-packaging-maven-plugin</artifactId>
<version>1.16</version>
<extensions>true</extensions>
<configuration>
<pluginClass>com.lambeta.CustomPlugin</pluginClass>
<pluginDescription>how to write sonar plugin</pluginDescription>
</configuration>
</plugin>
</plugins>
</build>
</project>

注意: pmd-xml、dom4j会在后面的编程当中使用到。

依据标准的代码结构,新建CustomPlugin.java文件。

1
2
3
4
5
6
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │   └── lambeta
│   │   │   ├── CustomPlugin.java

第二步 识别扩展点

此时,该去查看API Basics了。不过在写代码之前,还得先了解所谓的扩展点(Extension Points)。

Scanner, which runs the source code analysis
Compute Engine, which consolidates the output of scanners, for example by
computing 2nd-level measures such as ratings
aggregating measures (for example number of lines of code of project = sum of lines of code of all files)
assigning new issues to developers
persisting everything in data stores
Web application

翻译如下

  • 扫描器:分析源代码
  • 计算引擎:聚合扫描器的输出。举例:计算第二轮measures,如打分;聚合measures(举例:工程中所有代码的行数 = 所有文件的代码行的综合);给开发者安排新的问题;持久化。
  • Web应用程序。
    翻译还不如不翻译!一言不合,去看例子程序…的注释

这三个扩展点,其实对应于API中的三个接口。

1
2
3
扫描器 -> Sensor
计算引擎 -> MeasureComputer
Web应用程序 -> Widget

第三步 定义Sensor(Scanner)

基于扫描DSL源码的需求,我们需要扩展Sensor这个接口。新建CustomSensor.java如下:

1
2
3
4
5
6
public class CustomSensor implements Sensor

public void describe(SensorDescriptor descriptor)
...
public void execute(SensorContext context)
...

接下来,我们需要定义这门DSL语言的某些属性,以便于识别以及扫描时过滤相关的源文件(通过文件的后缀)。

第四步 定义语言(Language)

新建CustomLanguage如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.lambeta;
import org.sonar.api.resources.AbstractLanguage;

public class CustomLanguage extends AbstractLanguage {
public static final String KEY = "custom-key";
public static final String NAME = "custom-name";

public CustomLanguage() {
super(KEY, NAME);
}

public String[] getFileSuffixes() {
return new String[] {"csm.xml"}; //custom这门基于xml的内部DSL的文件后缀
}
}

我定义了一门基于xml语法的内部DSL,其文件的后缀是csm.xml。比如:right-syntax.csm.xml

Language定义出来了,我们还得定义rule、profile和repository. 回到上文提及的language、rule、profile以及repository的关系图:

1
2
3
4
5
6
7
8
profile
- language
- [rules]
- rule
- respository
respository
- language
- [rules]

第五步 定义规则(Rule)

1
2
3
respository
- language
- [rules]

我们需要实现接口RulesDefinition

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
package com.lambeta;
import org.apache.commons.io.Charsets;
import org.apache.commons.io.IOUtils;
import org.sonar.api.server.rule.RulesDefinition;
import org.sonar.api.server.rule.RulesDefinitionXmlLoader;

import java.io.InputStream;

public class CustomRulesDefinition implements RulesDefinition {

public static final String REPOSITORY_KEY = "custom-repo";
private final RulesDefinitionXmlLoader xmlLoader;

public CustomRulesDefinition(RulesDefinitionXmlLoader xmlLoader) {
this.xmlLoader = xmlLoader;
}

public void define(Context context) {

final InputStream stream = getClass().getResourceAsStream("/rules.xml");
final NewRepository repository = context.createRepository(REPOSITORY_KEY, CustomLanguage.KEY);

try {
if (stream != null) {
xmlLoader.load(repository, stream, Charsets.UTF_8);
}
repository.done();
} finally {
IOUtils.closeQuietly(stream);
}
}
}

我们通过context新建出一个repository。respository需要一个唯一key作为其标识(可以通过setName方法设置名称)以及一个language key来关联(从UI上可以看出来)。然后,通过DI进来的RulesDefinitionXmlLoaderrules.xml中定义的rules加载进repository中。最后,调用*reposiotory.done()*宣告加载完成。

定义的rules.xml内容如下:

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
<?xml version="1.0" encoding="UTF-8" ?>
<rules>
<rule>
<key>ComponentsMustNotBeFollowedByComponentsRule</key>

<name>Components标签后不能跟随Components标签规则</name>
<description>
<![CDATA[
Components标签后不能跟随Components标签
]]>
</description>
<severity>MINOR</severity>
<cardinality>SINGLE</cardinality>
<status>READY</status>
<tag>custom</tag>
<example>
<![CDATA[
<components>
<!-- Error, components must be here! -->
<components/>
</components>
]]>
</example>
</rule>
</rules>

包含了rule的key和其他相关的属性。它们最终显示在UI上,会是这样:

Rule

第六步 定义Profile

1
2
3
4
5
profile
- language
- [rules]
- rule
- respository

我们需要实现接口ProfileDefinition.

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
package com.lambeta;
import org.apache.commons.io.IOUtils;
import org.sonar.api.profiles.ProfileDefinition;
import org.sonar.api.profiles.RulesProfile;
import org.sonar.api.profiles.XMLProfileParser;
import org.sonar.api.utils.ValidationMessages;

import java.io.InputStreamReader;

public class CustomProfileDefinition extends ProfileDefinition {
private final XMLProfileParser xmlProfileParser;

public CustomProfileDefinition(XMLProfileParser xmlProfileParser) {
this.xmlProfileParser = xmlProfileParser;
}

@Override
public RulesProfile createProfile(ValidationMessages validation) {
final InputStreamReader reader = new InputStreamReader(getClass().getResourceAsStream("/profile.xml"));

try {
return xmlProfileParser.parse(reader, validation);
} finally {
IOUtils.closeQuietly(reader);
}
}
}

使用DI注入的XMLProfileParser解析profile.xml文件,并生成RulesProfile对象。我们来看看profile.xml的内容:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8" ?>
<profile>
<language>custom-key</language>
<name>Custom Quality</name>
<rules>
<rule>
<repositoryKey>custom-repo</repositoryKey>
<key>ComponentsMustNotBeFollowedByComponentsRule</key>
<priority>MAJOR</priority>
</rule>
</rules>
</profile>

这里定义一个名为Custom Quality的profile,它关联CustomLanguage的键值:custom-key. 同时包含了多条rules,每条rule拥有自己的标识key以及其所在的repository(事实上,profile会在repository中通过ruleKey来查找rule)。

写到这里,一个DSL的SonarQube Plugin已经几近完善。但是,我们还缺少至关重要的一环——规则的执行!

第七步 运行PMD扫描代码

PMD简介

我们需要一个静态扫描工具来扫描源代码,发现这些代码存在的缺陷和坏味道。PMD就是这么一款好用的工具。

PMD is a source code analyzer. It finds common programming flaws like unused variables, empty catch blocks, unnecessary object creation, and so forth. It supports Java, JavaScript, PLSQL, Apache Velocity, XML, XSL.

翻译:
PMD是一款源码分析工具。它会发现编程中的普遍缺陷,如未使用的变量、空的catch块、不必要的对象创建等等。它支持分析Java、Javascript、PLSQL、Apache Velocity、XML、XSL语言。

前面提到我定义的是一门基于XML的DSL,那么理所当然,可以借助PMD,扩展XML的扫描规则来满足自己的需求。

PMD在命令行中执行的方式如下:

1
pmd -d src/ -f xml -R myrule.xml -r dest/report.xml
  • -d 代表要扫描的源码目录
  • -f 代表报告输出的格式
  • -R 代表采用哪些规则来扫描源代码
  • -r 代表报告的输出路径

注意:这里PMD的规则和SonarQube中的规则其实没有太大关系,属于两种事物。不过,为方便后续提取PMD输出的报告,需要将PMD规则的名字和Sonar规则的键值保持一致。

我们定义PMD需要使用到的规则集custom-pmd-rules.xml

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
<?xml version="1.0"?>
<ruleset name="ExamplePmdRuleset"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.net/ruleset_2_0_0.xsd">

<description>
Example set of configured PMD rules
</description>

<rule name="ComponentsMustNotBeFollowedByComponentsRule"
message="Components tags followed by components tag found!"
language="xml"
class="net.sourceforge.pmd.lang.rule.XPathRule">

<description>
Tag components must not be followed by components tag.
</description>

<priority>1</priority>

<properties>
<property name="xpath">
<value>//components/components</value>
</property>
</properties>

<example>
<![CDATA[
<components>
<components>
</components>
]]>
</example>
</rule>
</ruleset>

这里的类net.sourceforge.pmd.lang.rule.XPathRule来自于我们先前在pom.xml中声明的pmd-xml这个依赖包。它可以让我们通过设置xpath这一属性的值来构建各种不同规则。扫描中XML文件一旦匹配这些xpath规则,就会输出错误报告。

ComponentsMustNotBeFollowedByComponentsRule这个自定义的规则为例。顾名思义,Components元素下不能再跟着Components元素。它在PMD扫描过程中如果被匹配上,会输出这样的报告:

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
<?xml version="1.0" encoding="UTF-8"?>
<pmd version="5.4.2" timestamp="2016-06-23T23:06:04.120">
<file name="/Users/qianyan/github/sonar/sonar-custom/src/test/resources/wrong-syntax-but-not-csm.xml">
<violation beginline="4"
endline="4"
begincolumn="5"
endcolumn="17"
rule="ComponentsMustNotBeFollowedByComponentsRule"
ruleset="ExamplePmdRuleset"
priority="1">
Components tags followed by components tag found!
</violation>
</file>
<file name="/Users/qianyan/github/sonar/sonar-custom/src/test/resources/wrong-syntax.csm.xml">
<violation beginline="4"
endline="4"
begincolumn="5"
endcolumn="17"
rule="ComponentsMustNotBeFollowedByComponentsRule"
ruleset="ExamplePmdRuleset"
priority="1">
Components tags followed by components tag found!
</violation>
</file>
</pmd>

PMD报告转化为Sonar的Issue

由于PMD是由Java编写的,所以我们可以在代码中调用PMD这个类net.sourceforge.pmd.PMD根据我们写好的PMD规则,来扫描Sonar指定的目录及其文件。最后,将PMD输出的XML格式的报告转化成Sonar能够理解的Issue。

代码如下:

1
2
3
4
5
public void execute(SensorContext context) {
File reportFile = new File(context.fileSystem().workDir(), "report.xml"); // 1
runPMD(context, reportFile); // 2
convertToIssues(context, doc(reportFile)); // 3
}
  1. 指定PMD输出文件的路径;
  2. 运行PMD,输出XML格式的报告到1指定的文件当中;
  3. 解析报告,并转化为Issue。

下面我们一步步来解释对应的代码:

  • runPMD
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    private void runPMD(SensorContext context, File reportFile) {
    final String dir = context.settings().getString("sonar.sources");
    final File file = new File(dir);
    String[] pmdArgs = {
    "-f", "xml",
    "-R", "custom-pmd-rules.xml",
    "-d", dir,
    "-r", reportFile.getAbsolutePath(),
    "-e", context.settings().getString("sonar.sourceEncoding"),
    "-language", "xml",
    "-version", "1.0"
    };
    final ClassLoader loader = Thread.currentThread().getContextClassLoader();
    try {
    Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
    PMD.run(pmdArgs);
    } finally {
    Thread.currentThread().setContextClassLoader(loader);
    }
    }
    我们通过PMD这个类运行pmdArgs。这里值得注意的是自SonarQube 5.6之后,我们可以通过context.settings()来获取工程的配置了,而不像以前那样依赖注入Settings对象了。

至于 Thread.currentThread().setContextClassLoader(getClass().getClassLoader());这步操作和Sonar使用独立的classLoader加载自己的类有关。

  • convertToIssues
    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
    private void convertToIssues(SensorContext context, Document doc) {
    final Element root = doc.getRootElement();
    final List<Element> files = root.elements("file");
    for (Element file : files) {

    final List<Element> violations = file.elements("violation");
    final String filePath = file.attributeValue("name");
    final FileSystem fs = context.fileSystem();
    final InputFile inputFile = fs.inputFile(fs.predicates().hasAbsolutePath(filePath));
    if (inputFile == null) {
    LOG.info("fs predicates that there is no {}", filePath);
    continue;
    }
    for (Element violation : violations) {
    final String rule = violation.attributeValue("rule");
    final int beginLine = Integer.parseInt(violation.attributeValue("beginline"));
    final int endLine = Integer.parseInt(violation.attributeValue("endline"));
    final int beginColumn = Integer.parseInt(violation.attributeValue("begincolumn"));
    final int endColumn = Integer.parseInt(violation.attributeValue("endcolumn"));
    final NewIssue newIssue = context.newIssue()
    .forRule(RuleKey.of(CustomRulesDefinition.REPOSITORY_KEY, rule));
    final NewIssueLocation newIssueLocation = newIssue
    .newLocation()
    .on(inputFile)
    .at(inputFile.newRange(beginLine, beginColumn, endLine, endColumn))
    .message(violation.getText());
    newIssue.at(newIssueLocation).save();
    }
    }
    }
    这里主要是对PMD生成XML报告的解析和转换。比较需要关注是这块代码:
    1
    2
    3
    4
    5
    final InputFile inputFile = fs.inputFile(fs.predicates().hasAbsolutePath(filePath));
    if (inputFile == null) {
    LOG.info("fs predicates that there is no {}", filePath);
    continue;
    }
    InputFile这是Sonar定义的合法的待扫描文件。举个例子:我们定义了一门基于XML的DSL,其文件的后缀是csm.xml,那么合法的待扫描文件就只能是这个后缀的文件了。像上述PMD输出的那份报告中出现的
    1
    <file name="/Users/qianyan/github/sonar/sonar-custom/src/test/resources/wrong-syntax-but-not-csm.xml">
    就是不合法的。这个文件是以xml作为后缀的,PMD肯定可以扫描它,但是对于Sonar而言,它并不是InputFile(如果不作处理,就会返回null),所以我们需要在转换为Issue之前剔除掉。

最后,不要忘记保存,newIssue.at(newIssueLocation).save();

Issue呈现在UI上,是这样的:

Issue

第八步 注册所有组件

现在所有的组件已经就绪,是时候将这些组件注册进插件当中了。还记得第一步我们创建的CustomPlugin.java? 所有上述组件,包括Language、Rules、Profiles以及Sensor都得在这个类中进行注册。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
package com.lambeta;

import org.sonar.api.Plugin;

public class CustomPlugin implements Plugin {
public void define(Context context) {
context.addExtension(CustomLanguage.class)
.addExtension(CustomRulesDefinition.class)
.addExtension(CustomProfileDefinition.class)
.addExtension(CustomSensor.class);
}
}

到此,这个插件算是写完了。那么接下来的问题就是如何运行它?

使用插件扫描工程

下载sonarqube docker镜像

最易于调试的地方莫过于本地了。如果机器是Mac,建议使用Kitematic这个Docker的客户端下载sonarqube的官方镜像,同时将映射的Port定在9000端口上,启动该镜像的容器实例。

sonarqube docker

构建和Copy插件包

在插件的工程根目录下,运行

1
mvn clean package

然后执行

1
cp target/sonar-custom-1.0-SNAPSHOT.jar /Users/your-name/Documents/Kitematic/sonarqube/opt/sonarqube/extensions/plugins

如果plugins目录不存在,可以手动创建。执行完命令之后,重启容器。

安装Maven的sonar插件

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
<!-- settings.xml -->
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
https://maven.apache.org/xsd/settings-1.0.0.xsd">
<localRepository/>
<interactiveMode/>
<usePluginRegistry/>
<offline/>
<pluginGroups>
<pluginGroup>org.sonarsource.scanner.maven</pluginGroup>
</pluginGroups>
<profiles>
<profile>
<id>sonar</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- Optional URL to server. Default value is http://localhost:9000 -->
<sonar.host.url>
http://192.168.99.100:9000
</sonar.host.url>
</properties>
</profile>
</profiles>
<servers/>
<mirrors/>
<proxies/>
<activeProfiles/>
</settings>

将这个settings.xml的文件放到~/.m2下。

运行Maven sonar:sonar

1
mvn sonar:sonar -Dsonar.sources=src/test/resources/ -Dsonar.language=custom-key -X

src/test/resources目录展开如下:

1
2
3
4
src/test/resources
├── right-syntax.csm.xml
├── wrong-syntax-but-not-csm.xml
└── wrong-syntax.csm.xml

然后,根据输出提示,访问
http://192.168.99.100:9000/dashboard/index/com.lambeta:sonar-custom

总结

Sonar Plugin

Plugin implements details

[1] 官方教程
[2] 博客
[3] 官方样例
[4] 本文样例

记忆用的Mindmap

leingingen profiles

标准的leiningen的工程目录结构

当我们使用lein new your-project-name之后,工程目录结构如下:

阅读全文 »

问题

本地使用maven编译和运行时一切都正常,但是通过ci的方式,编译、打包、发布到部署环境,运行时抛出了一条一眼便知是关于JDK版本的错误。

错误是这个样子:

1
2
java.lang.NoSuchMethodError: java.util.concurrent.ConcurrentHashMap.keySet() 
Ljava/util/concurrent/ConcurrentHashMap$KeySetView;

报的是的NoSuchMethodError的错误,且是关于java.util.concurrent.ConcurrentHashMap的。所以不难排查出原因是ci使用了JDK 8来进行编译,导致生成的字节码包含了JDK 8更改的新方法keySet——ConcurrentHashMap$KeySetView这个新增内部类作为其返回值的类型。

为了进一步验证部署服务器上的class文件都是JDK 8编译的,我使用javap这个JDK自带的工具做了如下的验证:

1
javap -v a.class |grep major

返回的结果是

1
major version: 51

问题初露端倪,51对应的JDK版本号应该是1.7(或者7),52才是JDK 8的major版本。这里出现了两个疑惑:

  • 为什么ci使用JDK 8编译的class会是JDK 7的编译结果?
  • 既然是JDK 7编译的class文件,那为何会出现JDK 8才有的内部类?

先看第一个疑惑。之前说到ci也是通过maven compiler plugin进行编译的,pom.xml中可以配置language level如下:

1
2
3
4
5
6
7
8
9
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>

这实际对应于javac的-source和-target参数,那么这两个参数具体代表什么呢?

1
2
3
$ javac -help
-source <release> Provide source compatibility with specified release
-target <release> Generate class files for specific VM version

source参数指的是源代码级别的语法兼容,而target参数指的是生成release版本的兼容性的class文件,不过只确保目标VM能够加载class文件,却无法保证运行时的正确性。接下来,我们尝试使用javac加上这些参数来编译源码。

首先我们写一段程序,如下:

1
2
3
4
5
6
7
8
9
10
// App.java
package com.lambeta;
import java.util.concurrent.ConcurrentHashMap;

public class App {
public static void main(String[] args) {
ConcurrentHashMap map = new ConcurrentHashMap();
map.keySet();
}
}

我本机的java版本是1.8,直接使用javac来编译App.java,结果如下

1
2
3
$ javac App.java
$ javap -v App.class |grep major
major version: 52

如果指定source和target参数,再用javac编译App.java

1
2
3
4
5
6
7
8
$ java -version
java version "1.8.0_45"
...
$ javac -source 7 -target 7 App.java
warning: [options] bootstrap class path not set in conjunction with -source 1.7
1 warning
$ ls
App.class App.java

这里有个警告,我们暂时不看。先使用javap反编译App.class,观察major version以及keySet()这个方法的返回值。

1
2
3
4
5
6
7
8
$ javap -v App.class
...
major version: 51
...
9: invokevirtual #4
// Method java/util/concurrent/ConcurrentHashMap.keySet:()
Ljava/util/concurrent/ConcurrentHashMap$KeySetView;
...

这样,第二个疑惑也解开了。可以初步得出一个结论。

小结

在javac指定了这些参数,降低版本号来编译,会导致生成class文件被标识为较低版本以供指定的JVM加载。但是,基于JDK 8的bootstrap class编译而成的keySet()方法,其返回值依旧是JDK 8中ConcurrentHashMap$KeySetView这个新增内部类。运行时,1.7的JVM尝试加载这个class文件,一定找不到KeySetView作为返回值的keySet()方法,出错。

解决方式

既然知道错在那里,就比较容易寻找到解决方案了。

  • 编译期间,替换掉bootstrap class
  • 使用父类/接口替换子类,即ConcurrentMap替换ConcurrentHashMap声明

编译期间,替换掉bootstrap clas

javac编译时,可以指定bootclasspath,来替换默认的加载路径,如下:

1
2
3
4
5
javac -bootclasspath /Library/Java/JavaVirtualMachines/jdk1.7.0_60.jdk/Contents/Home/jre/lib/rt.jar \
-source 7 -target 7 App.java
// or
javac -Xbootclasspath:/Library/Java/JavaVirtualMachines/jdk1.7.0_60.jdk/Contents/Home/jre/lib/rt.jar \
-source 7 -target 7 App.java

这时候,再去看看反编译的结果,就会是这样:

1
2
3
4
5
...
major version: 51
...
9: invokevirtual #4
// Method java/util/concurrent/ConcurrentHashMap.keySet:()Ljava/util/Set;

此时major是51(JDK 7),而keySet()的返回值也是JDK 7中的java.util.Set类型了。

使用父类/接口替换子类,即ConcurrentMap替换ConcurrentHashMap声明

上一种方案虽然可行,但是却不实用——因为不能要求ci服务器上有两个不同版本的JDK,也不能要求在maven构建时传递与安装路径如此紧耦合的值作为bootclasspath的参数值。所以可以采取将具体实现类的声明替换成为其接口的方式,如下:

1
2
3
4
5
6
7
8
9
10
package com.lambeta;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class App {
public static void main(String[] args) {
ConcurrentMap map = new ConcurrentHashMap();
map.keySet();
}
}

这样编译好的字节码中就不会有ConcurrentHashMap$KeySetView这样的返回值类型了。在JDK 7上运行时,JVM动态调用的一定是ConcurrentHashMap的keySet():java.util.Set方法了。


结论

  • 保证编译、打包环境和最终部署环境JDK版本的一致性
  • 如果无法保证,就尽量面向接口编程,尤其是JDK中提供的类。原因是接口不易改变,而实现类遵循“宽收严发”原则,方法的入参和出参都是易变的。

参考链接
[1] Using Java 7 to target much older JVMs

问题

最近遇到一个问题,使用Java写某个DSL标记语言X的parser(解析器)Maven插件的时候,对外暴露一个名为Callback的接口和一个待实现的方法*getHTML()*——基于调用处传入的文件名srcX构造出HTML文件的输出路径(其实此处的Callback就是一个闭包,文件名是一个自由变量)。大致代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
parser.parse(srcX, new Callback() {
@Override
public FileWriter getHTML() {
return new FileWriter(outputPath(suffix(srcX, "html")));
}
});

private String suffix(String filename, String suffix) {
return Joiner.on(".").join(filename, suffix);
}

//这里假设输入和输出根路径地址已知
private File outputPath(String file) {
return new File(
file.replace(srcDir.getAbsolutePath(), //srcDir: File
outputDir.getAbsolutePath())); //outputDir: File
}

目前为止还没有任何问题。但若是运行时,这段程序很可能抛出异常java.io.FileNotFoundException: your-file-name (No such file or directory)。原因在于file的路径当中可能存在多级父级目录,例如:outputDir/p1/p2/srcX.html,那么当FileWriter尝试创建srcX.html就会失败。此时最简单的方法就是提前创建好所有的父级目录,于是*outputPath()*方法会变成下面这样:

1
2
3
4
5
6
7
8
private File outputPath(String file) {
File outputFile = new File(
file.replace(srcDir.getAbsolutePath(),
outputDir.getAbsolutePath()));
outputFile.getParentFile().mkdirs(); //创建可能不存在的父级目录

return outputFile;
}

似乎这段程序可以正常工作了,但是创建文件夹这样的操作是可能失败的。所以我们需要关注是否创建成功,若失败,则写入Log文件当中。修改程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
private File outputPath(String file) {
File outputFile = new File(
file.replace(srcDir.getAbsolutePath(),
outputDir.getAbsolutePath()));
final File parentDirs = outputFile.getParentFile();
if (!parentDirs.exists()) {
if (!parentDirs.mkdirs()) {//创建可能不存在的父级目录
getLog().error("Cannot create parent dirs for {}", outputFile);
}
}
return outputFile;
}

注意
这里我们需要先判断父级目录是否存在,即parentDirs.exists()?可是*parentDirs.mkdirs()不是直接返回boolean值来表示是否创建成功吗?是这样么?这儿有mkdirs()*方法的说明:

public boolean mkdirs()
Creates the directory named by this abstract pathname, including any necessary but nonexistent parent directories. Note that if this operation fails it may have succeeded in creating some of the necessary parent directories.
Returns:
true if and only if the directory was created, along with all necessary parent directories; false otherwise

也就是说只有当这个目录及其所有的父级目录都被创建时,才返回true,反之返回false。照这个推论,如果所有目录事先已经存在了,这个方法应该也会返回true,毕竟都被创建过了嘛。但是只要稍微看一眼源码,你就会发现事实并非如此:

1
2
3
4
//mkdirs源码
if (exists()) {
return false;
}

所以这里需要特别强调was created是一种操作,如果没有进行这个操作,那就不能算这个方法成功。

前面已经提到过,我需要写一个maven的插件,所以最好在这种导致程序崩溃的地方抛出一个maven中通用的异常MojoExecutionException。这样,更改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
private File outputPath(String file) {
File outputFile = new File(
file.replace(srcDir.getAbsolutePath(),
outputDir.getAbsolutePath()));
final File parentDirs = outputFile.getParentFile();
if (!parentDirs.exists()) {
if (!parentDirs.mkdirs()) {//创建可能不存在的父级目录
getLog().error("Cannot create parent dirs for {}", outputFile);
throw new MojoExecutionException("Cannot create parent dirs");
}
}
return outputFile;
}

此时,问题才显出端倪——异常MojoExecutionException是一个受检的异常(checked Exception),它间接继承自java.lang.Exception。可是我们的getHTML()方法并没有在签名中抛出任何异常,编译无法通过。那唯一的办法就是try…catch了,但是我不应该捕获自己刚刚抛出来的异常,否则抛出受检异常的意义何在?

这时,自然而然会想到,将方法签名改成getHTML() throws MojoExecutionException。确实可行,但是并不合适,因为MojoExecutionException只是Maven插件规定的异常,而getHTML()则是一个对外暴露的API,不应该依赖于某个具体的异常。所以我将异常扩大化:getHTML() throws Exception,这样做的好处很明显,坏处也很显眼。

好处

  1. 牢记《Unix编程艺术》中的“宽收严发”原则。即子类实现父类、接口的方法,入参可以扩大,出参可以缩小。举个例子:父类、接口有个方法
1
public Object something(HashMap map) throws Exception

那么子类实现这个方法可以这样写

1
2
public String something(Map map)
throws ExecutionException, NoSuchMethodException

这里,入参是HashMap,出参是Object和Exception。入参扩大,所以子类出现了Map;出参缩小,所以子类出现了String和ExecutionException和NoSuchMethodException。同理,此处getHTML() throws Exception由子类实现的形式可以是getHTML() throws MojoExecutionException

坏处

  1. 不管getHTML()是否需要抛出异常,你都得在实现代码中抛出异常;
  2. 由于对外表现的是抛出较宽泛的Exception,所以丧失了对于具体受检 (checked exception)异常进行检查的好处。

这里有个JDK中比较类似的例子,就是关于RunnableCallable接口的设计问题:

1
2
3
4
5
6
7
public interface Runnable {
public void run();
}

public interface Callable<V> {
V call() throws Exception;
}

它们就是两个极端,Runnable必须将受检的异常转换成非受检(unchecked exception)或者发明一种方式来将异常暴露给调用者;Callable就是无论如何都得抛出异常,而且迫使用户去捕获一个较宽泛的异常。

解决方式

这个时候,泛型就派上用场了。

1
2
3
4
5
6
interface Callback<E extends Exception> {
FileWriter getHTML() throws E;
}

//interface parser
public <E extends Exception> void parse(String srcX, Callback<E> cb) throws E;

通过这种方式,我们可以捕获具体的异常:

1
2
3
4
5
6
7
8
9
10
try {
parser.parse(srcX, new Callback<MojoExecutionException>() {
@Override
public FileWriter getHTML() throws MojoExecutionException {
return new FileWriter(outputPath(suffix(srcX, "html")));
}
});
} catch (MojoExecutionException e) {
getLog().error("Failed to execute. {}", e);
}

使用lambda表达式可以简化成下面的模样:

1
2
3
4
5
try {
parser.parse(srcX, (Callback<MojoExecutionException>) () -> new FileWriter(outputPath(suffix(srcX, "html"))));
} catch (MojoExecutionException e) {
getLog().error("Failed to execute. {}", e);
}

我们解决了迫使用户去捕获一个较宽泛的异常的问题,但是无论如何都得抛出异常这个问题还是没有得到解决。或许我们需要一个像是throws Nothing一样的语法,表示什么也没有抛出来。我们知道RuntimeException是非受检的异常(unchecked exception),所以throws RuntimeException就表明这个异常跟没有抛出异常一样,不需要捕获。如下:

1
2
3
4
5
6
7
8
parser.parse(srcX, new Callback<Nothing>() {
@Override
public FileWriter getHTML() throws Nothing {
return new FileWriter(outputPath(suffix(srcX, "html")));
}
});

public abstract class Nothing extends RuntimeException {}

走到这一步,我们算是较为完全地解决了匿名内部类的异常处理问题。

异常透明化

With the throws type parameter on the Block interface, we can now accurately generify over the set of exceptions thrown by the Block; with the generic forEach method, we can mirror the exception behavior of the block in forEach(). This is called exception transparency because now the exception behavior of forEach can match the exception behavior of its block argument. Exception transparency simplifies the construction of library classes that implement idioms like internal iteration of data structures, because it is common that methods that accept function-valued arguments will invoke those functions, meaning that the library method will throw a superset of the exceptions thrown by its function-valued arguments.

1
2
3
4
5
6
7
interface Block<T, throws E> {
public void invoke(T element) throws E;
}

interface NewCollection<T> {
public<throws E> forEach(Block<T, throws E> block) throws E;
}

异常透明化,简单来讲,就是调用者的签名中的异常完全由它的函数值(function-valued)的参数决定,所有这些调用者最终的异常都会是该函数值所注异常的超集。

异常透明化就是用来解决我们常用的通过内部类模拟闭包调用时异常处理的手法了。


闭包的定义
一个包含了自由变量的开发表达式,和该自由变量的约束环境组合之后,产生了一种封闭的状态。


参考链接
[1] Exception Transparency
[2] Throwing Checked Exceptions from Anonymous Inner Classes

搭建 Node.js 开发环境

NVM安装

1. Download nvm-noinstall.zip
2. Update the system environment variables:
NVM_HOME, NVM_SYMLINK (C:\Users\Program Files\nodejs This directory should not exist in previously.)
3. Create settings.txt file

root: C:\Users\qinayan\bin\nvm
path: C:\Program Files\nodejs
arch: 64
proxy: none

详情请参考 如何安装nvm-windows
另外别忘了在NVM_HOME目录中运行install命令

node.js安装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
安装特定版本的nodejs
> nvm install 0.12.10
安装最新版本的nodejs
> nvm install latest
查看本地安装了哪些nodejs
> nvm ls
* 5.7.1 (Currently using 64-bit executable)
0.12.10
验证安装完成
> node -v
v5.7.1
> node
...> console.log("hello world");
hello world

模块

每个文件就是一个模块,文件的路径名就是模块的名字

require

类似于Java中的import关键字,导入不同的包。

1
var express = require('express');

exports

导出模块的公有方法和属性。可以理解为Java中的public方法和属性。

1
2
3
4
5
6
7
8
// util.js
exports.greeting = function(name) {
return "hello, " + name;
}
// index.js
var greeting = require('./util').greeting;
console.log(greeting("lambeta"))
> hello, lambeta

module

包含当前模块的一些信息,常用的做法是替换当前模块的导出对象。

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
// util.js
console.log(module)
>>
Module {
id: '.',
exports: { greeting: [Function] },
parent: null,
filename: 'C:\\Users\\qianyan\\Projects\\lesson2\\util.js',
loaded: false,
children: [],
paths:
[ 'C:\\Users\\qianyan\\Projects\\lesson2\\node_modules',
'C:\\Users\\qianyan\\Projects\\node_modules',
'C:\\Users\\qianyan\\node_modules',
'C:\\Users\\node_modules',
'C:\\node_modules' ] }

// replace with obj
module.exports = {greeting: {}};
>>
Module {
id: '.',
exports: { greeting: {} },
parent: null,
filename: 'C:\\Users\\qianyan\\Projects\\lesson2\\util.js',
loaded: false,
children: [],
paths:
[ 'C:\\Users\\qianyan\\Projects\\lesson2\\node_modules',
'C:\\Users\\qianyan\\Projects\\node_modules',
'C:\\Users\\qianyan\\node_modules',
'C:\\Users\\node_modules',
'C:\\node_modules' ] }

module initialize发生的时机

模块中的代码只会在首次被使用的时候才会执行一次,同时初始化该模块的导出对象,之后导出对象会被缓存到内存当中,供任意使用。

小结

  • NVM是Node Version Manager,管理node的版本的工具。使用NVM,可以保证同一个操作系统下,多个不同版本的node得以共存。
  • node作为javascript的解析器,可以在终端下进入交互式模式(repl read-eval-print-loop),很方便快速地反馈我们程序的结果。
  • nodeJS的模块系统实现了CMD标准,即CommonJS Module Definition标准;而对于运行在浏览器上的javascript的模块化,因为需要异步加载js文件,所以由require.js实现了AMD (Asynchronous Module Definition)标准
1
2
3
4
5
6
7
8
9
10
11
12
CMD
require('express')

AMD
define("alpha", ["require", "exports", "beta"],
function (require, exports, beta) {
exports.verb = function() {
return beta.verb();
//Or:
return require("beta").verb();
}
});

问题

  • 是否可以使用require('./data.json')将json文件引入到我们的程序当中呢?
  • 有两个js文件同时引入了data.json,先执行a.js,后执行b.js。下面的程序会输出什么?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //data.json
    {"hello": "world"}

    //a.js
    var data = require('./data.json');
    data = {};

    //b.js
    var data = require('./data.json');
    console.log(data)

代码组织

模块解析路径

node_modules

不想直接require文件路径名,因为这样一旦所依赖的文件路径发生变化,牵扯的文件会很多。所以我们需要一个约定的根目录。这个根目录就是node_modules
以这个文件的路径为例:C:\\Users\\qianyan\\Projects\\lesson2\\util.js,node搜索的路径如下。

1
2
3
4
5
6
paths:
[ 'C:\\Users\\qianyan\\Projects\\lesson2\\node_modules',
'C:\\Users\\qianyan\\Projects\\node_modules',
'C:\\Users\\qianyan\\node_modules',
'C:\\Users\\node_modules',
'C:\\node_modules' ]

NODE_PATH

我们知道java中依赖包的搜索路径是通过classpath这个JVM的参数控制的。其实node也有这样的变量提供支持。这个变量就是NODE_PATH
windows下

1
2
3
4
cmd
set NODE_PATH="your_path"
powershell
env:NODE_PATH="your_path"

NODE_PATH中的路径被遍历是发生在从项目的根位置递归搜寻 node_modules 目录,直到文件系统根目录的node_modules,如果还没有查找到指定模块的话,就会去 NODE_PATH中注册的路径中查找。

内置模块

fs, http等,不做路径解析就直接使用其导出对象require('fs'), require('http')

Package(包)

包就是封装多个子模块,同时提供入口的大模块。这个大模块的功能是内聚的。举个例子:

1
2
3
4
5
6
C:\USERS\QIANYAN\PROJECTS\LESSON2
└───plane
body.js
engine.js
main.js
wing.js

其中plane目录定义了一个包,其中包含了4个子模块。main.js作为入口模块,如下:

main.js
1
2
3
4
5
6
7
8
9
10
var engine = require('./engine');
var wing = require('./wing');
var body = require('./body');

exports.plane = {
type: "747",
engine: engine,
wing: wing,
body: body
}

其他模块需要使用plane这个包时,得使用require('./plane/main')才行。不过,这里有两种方法可以省去写文件名main

1. index.js

这里有个约定,如果将main.js重命名成index.js,那么就不需要写出文件名字,直接require('./plane')就可以了。

这样模块显得更内聚,和Clojure中的(use namespace)的用法类似。以下两条语句等价。

1
2
require('./plane/main')
require('./plane')

2. package.json

如果想自定义入口模块的文件名和存放位置,就需要在包目录下包含一个package.json文件,并在其中指定入口模块的路径。上例中的模块可以重构如下。

1
2
3
4
5
6
7
8
9
10
11
C:\USERS\QIANYAN\PROJECTS\LESSON2\PLANE
│ package.json //包描述文件

├───doc //文档
├───lib //API文件
│ body.js
│ engine.js
│ main.js
│ wing.js

└───tests //测试文件

其中package.json内容如下。

1
2
3
4
{
"name": "plane",
"main": "./lib/main.js" //这里入口文件的名字可以按自己的喜好更改
}

如此一来,就同样可以使用require(‘./lib/plane’)的方式加载模块。NodeJS会根据包目录下的package.json找到入口模块所在位置。


命令行程序

node.js的程序是跑在命令行之中的,命令行程序长得类似cmd --name=value这样的形式。

创建目录

在windows下创建一个greeting程序的目录,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
C:\USERS\QIANYAN\PROJECTS\LESSON2\GREETING
│ package.json
│ README.md

├───bin
│ greeting.cmd

├───doc
├───lib
│ index.js

├───node_modules
└───tests

package.json的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "greeting",
"version": "1.0.0",
"description": "say hi to everyone",
"main": "lib/index.js",
"directories": {
"doc": "doc",
"test": "tests"
},
"bin": {
"greeting": "bin/greeting.cmd"
},
"scripts": {
"test": "node test"
},
"author": "lambeta",
"license": "MIT"
}

在windows下,如果我们想要实现cmd –name=value的效果,就必须使用cmd后缀的文件,如下:

1
2
//greeting.cmd
@node "lib/index.js" %*

我们实现一个接受人的名字作为参数的命令行程序lib/index.js,如下:

1
console.log('hello,', process.argv[2]);

到这里,可以直接这样运行./bin/greeting.cmd lambeta,输出hello, lambeta。不过,还是没有预期的样子。

我们再运行一条npm的命令

1
2
3
> npm link
C:\Program Files\nodejs\greeting -> C:\Program Files\nodejs\node_modules\greeting\bin\greeting.cmd
C:\Program Files\nodejs\node_modules\greeting -> C:\Users\qianyan\Projects\lesson2\greeting

这条命令帮助我们设置两个软链接。第一个链接使得我们可以直接运行greeting lambeta;第二个则在全局范围内,其他的模块得以引入greeting这个包。
此时,我们可以直接使用greeting lambeta来运行程序了

依赖第三方库

为了实现真正的cmd –name=value,我们使用一个第三方库yargs

  1. 安装yargs: npm install yargs –save
  2. 修改index.js文件如下
    1
    2
    var argv = require('yargs').argv;
    console.log('hello,', argv.name);
  3. 运行
    1
    2
    > greeting --name=lambeta
    hello, lambeta

最后再来看看一个完整的node.js的整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
C:\USERS\QIANYAN\PROJECTS\LESSON2\GREETING
│ package.json //包描述文件
│ README.md //说明文件

├───bin
│ greeting.cmd //命令行相关代码

├───doc //文档
├───lib //API代码
│ index.js

├───node_modules //第三方依赖
└───tests //测试

小结

  • 按照标准目录结构
  • 分模块管理项目
  • 使用NPM管理第三方模块和命令行程序
  • 使用package.json描述项目信息和依赖

问题

  • 下载一个第三方命令行程序到本地npm install es-checker,不要使用-g参数,如何运行起来这个程序?
  • 了解一下npm scripts,在上题的基础上,添加包含下面的内容的package.json,运行npm test。思考这样做是否可行?
    1
    2
    3
    4
    5
    6
    //package.json
    {
    "scripts": {
    "test": "es-checker"
    }
    }

文件操作

前置条件:安装Windows上的离线文档工具

文件操作相关的API

buffer对象(数据块)

Javascript语言本身只支持字符串操作,没有提供针对二进制数据流的操作。NodeJS提供了一个与String对等的全局对象Buffer. Buffer和整数的数组很类似,但是它是固定长度,一旦创建就不能修改。

1
2
3
4
5
6
7
8
var bin = new Buffer('hello', 'utf8');// <Buffer 68 65 6c 6c 6f>
bin.toString(); //'hello'
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
bin.toString(); //'hello'

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var dump = new Buffer(bin.length);
bin.copy(dump);

stream模块(数据流)

Stream是一个抽象的接口,所有的stream都是EventEmitter的实例。
当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作。

1
2
3
4
5
6
7
8
9
10
var fs = require('fs');
var readStream = fs.createReadStream('README.md'); //readStream是EventEmitter的实例。

readStream.on('data', function(chunk) {
console.log(chunk.toString());
});

readStream.on('end', function() {
console.log('end.');
});

fs模块

NodeJS通过fs内置模块提供对文件的操作。fs模块提供的API基本可以分为以下三类:

  • 文件属性读写。
    其中常用的有fs.stat、fs.chmod、fs.chown等等。

  • 文件内容读写。
    其中常用的有fs.readFile、fs.readdir、fs.writeFile、fs.mkdir等等。

  • 底层文件操作。
    其中常用的有fs.open、fs.read、fs.write、fs.close等等。

我们可以通过require('fs')来引用这个模块,而且该模块下的每个方法都有同步和异步的形式。

1
2
3
4
5
6
7
8
9
10
11
12
// read sync
var file = fs.readFileSync(process.cwd() + '/README.md', 'utf8');
console.log(file);

// read async
fs.readFile(process.cwd() + '/README.md', 'utf8', function (err, data) {
if(err) {
console.log(err);
return;
}
console.log(data)
});

一段遍历当前目录的程序

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
var fs = require('fs');
var p = require('path');

function recursiveRead(path) {
if(fs.statSync(path).isFile()) {
console.log("File:", path);
} else {
fs.readdir(path, function(err, files) {
if(err) {
console.log(err);
return;
}

files.forEach(function(file) {
var absPath = p.join(path, file);
if(fs.statSync(absPath).isFile()) {
console.log("File:", absPath);
} else {
console.log("Directory:", absPath);
recursiveRead(absPath);
}
});
});
}
}
recursiveRead(__dirname); //全局的对象__dirname,当前脚本执行的目录。

path模块

java类似,NodeJS提供path来简化对文件路径的操作。

  • path.normalize
    1
    2
    var path = require('path');
    path.normalize('foo/bar/..'); // 'foo'
  • path.join & path.sep
    1
    2
    path.join('foo', '/bar/', '/baz', 'par/') // 'foo\\bar\\baz\\par\\'
    path.sep // '\\'
  • path.extname
    1
    path.extname('node.js') //'.js'

小结

  • Buffer提供了NodeJS操作二进制的机制;
  • Stream是一个抽象的接口,每种stream都是EventEmitter的实例。当我们在读取大文件时,可以使用数据流一边读取,一边处理;
  • fs提供了文件属性读写,内容读写以及底层文件操作。
  • 不要使用字符串拼接,使用path简化操作

问题

  • 使用fs的API创建一个copy的函数;
  • NodeJS对文本编码的处理;
  • 使用第三方包findit重写遍历当前目录的程序。

网络操作

简单的HTTP服务器

使用http实现一个简单的HTTP服务器。

1
2
3
4
5
var http = require('http');
http.createServer(function(req, res) {
res.writeHead(200, {'Content-type': 'text/plain'});
res.end('Hello Node.js');
}).listen(12306);

http模块

http模块提供两种使用方式:

  • 作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应;
  • 作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

先创建一个HTTP服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//http-server.js
var http = require('http');

http.createServer(function(req, res) {
var body = [];
res.writeHead(200, {'Content-type': 'text/plain'});

req.on('data', function(chunk) {
res.write(chunk);
body.push(chunk);
});

req.on('end', function() {
body = Buffer.concat(body);
console.log(body.toString());
res.end();
})
}).listen(12306);

再创建一个HTTP客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//http-client.js
var http = require('http');
var options = {hostname: 'localhost',
port: 12306,
method: 'POST',
headers: {
'Content-type': 'text/plain'
}};
var req = http.request(options, function(res) {
res.on('data', function(chunk) {
console.log('res:', chunk.toString());
});
});

req.write('hello world');
req.end();

url模块

  • parse
    使用url解析成URL对象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    > url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash
    Url {
    protocol: 'http:',
    slashes: true,
    auth: 'user:pass',
    host: 'host.com:8080',
    port: '8080',
    hostname: 'host.com',
    hash: '#hash',
    search: '?query=string',
    query: 'query=string',
    pathname: '/p/a/t/h',
    path: '/p/a/t/h?query=string',
    href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' }
  • format
    format方法允许将一个URL对象转换为URL字符串
    1
    2
    3
    4
    5
    6
    7
    > url.format({
    protocol: 'http:',
    host: 'www.example.com',
    pathname: '/p/a/t/h',
    search: 'query=string'
    });
    //'http://www.example.com/p/a/t/h?query=string'
  • resolve
    resolve方法拼接两个URL
    1
    2
    3
    4
    > url.resolve('http://www.baid.com/path', '../www.google.com')
    'http://www.baid.com/www.google.com'
    > url.resolve('http://example.com/one', '/two')
    'http://example.com/two'

querystring

1
2
> querystring.parse('foo=bar&baz=qux&baz=quux&corge');
{ foo: 'bar', baz: [ 'qux', 'quux' ], corge: '' }

小结

  • http模块支持服务端模式和客户端模式两种使用方式;
  • request和response对象除了用于读写头数据外,可以当作数据流来操作;
  • url.parse方法加上request.url属性是处理HTTP请求时的固定搭配。

问题

  • http模块和https模块的区别?
  • 如何创建一个https服务器?

进程操作

API一览

Process

process是一个全局的对象,可以在node环境中随处访问。并且它是EventEmitter的实例。
一个进程对象里头到底包含些什么属性?

1
2
3
4
pid
stdio
argv
env

只在POSIX平台支持的函数

1
2
3
4
getuid
getgid
geteuid
getegid

进程ID、标准输入输出以及错误流、启动进程的参数、运行环境、运行时权限。

应用场景
  • 获取命令行参数

    1
    2
    3
    4
    5
    6
    7
    8
    // index.js
    console.log(process.argv);

    > node index.js hello
    [ 'C:\\Program Files\\nodejs\\node.exe', //node的执行路径
    'C:\\Users\\qianyan\\Projects\\lesson5\\index.js', //文件路径
    'hello' ] //参数

    一般获取参数的写法

    1
    process.argv.splice(2)
  • 退出程序
    类似Java中的System.exit(1),当我们捕获一个异常,同时觉得程序需要立即停止时,就执行process.exit(1)来表示非正常退出。

  • 控制输入和输出
    stdin是只读流,而stdoutstderr都是只写流。console.log等价于

    1
    2
    3
    console.log = (msg) => {
    process.stdout.write(`${msg}\n`);
    };

Child Process

child_process是一个内置模块,可以创建和控制子进程。该模块的主要功能都是child_process.spawn()函数提供的。其余诸如exec, fork, execFile等都是对spawn()进行的封装。

应用场景
  • 创建子进程
1
2
3
4
5
6
7
//(command[, args][, options])
const spawn = require('child_process').spawn;
const echo = spawn('cmd', ['/c', 'env'], {env: process.env});//尝试设置{env: {}},观察结果。

echo.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
})

第一参数是可执行文件的路径,第二参数是数组对应可执行文件接收的参数,第三参数用于配置子进程运行的环境和行为。

  • 进程间通信
    如果父子进程都是Node.js的进程,那么就可以通过IPC通道通信。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//parent.js
const spawn = require('child_process').spawn;
const child = spawn('node', ['child.js'], {
stdio: [process.stdin, process.stdout, process.stderr, 'ipc']
});

child.on('message', (msg) => {
console.log('parent:', msg);
});

child.send({hello: 'world'});

//child.js
process.on('message', (msg) => {
console.log('child:', msg);
msg.hello = msg.hello.toUpperCase();
process.send(msg);
})
=>
child: { hello: 'world' }
parent: { hello: 'WORLD' }

父进程在创建子进程的时候,使用了options.stdioipc额外开辟了一条通道,之后开始监听子进程的message事件来接收子进程的消息,同时通过send方法给子进程发送消息。子进程则通过process对象监听来自父进程的消息,并通过process.send方法向父进程发送消息。

Cluster

单个实例的Node.js运行在单独的进程当中。但是我们有时候可能需要利用多核处理器的优势,在每个单独的核上跑一个Node.js的进程。
Cluster就是创造出来简化多进程服务程序开发的,让每一个核上面运行一个工作进程,并统一通过主进程监听端口和分发请求。

应用场景
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
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
// Fork workers.
for (var i = 0; i < numCPUs; i++) {
cluster.fork();
}

cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
}
=>
NODE_DEBUG=cluster node server.js
isMaster
worker
worker
worker
worker
worker
worker
worker
worker

该模块很简单地创建多个共享一个服务端口的子进程,而这些子进程是通过IPC和Master,也即父进程进行信息交互的,可应用于负载均衡。

小结

  • 使用process对象管理进程
  • 使用child_process对象管理子进程,其最主要的方法就是spawn

问题


异步编程

NodeJS最大的卖点——事件机制和异步IO,开发者需要按照异步的方式去组织代码。

回调

异步编程的直接体现就是回调函数,但是不是有回调函数,就是异步编程呢?

1
2
3
4
5
6
7
8
9
10
11
12
function sum(arr, callback) {
var sum = 0;
for(var i=0; i<arr.length; i++) {
sum = sum + arr[i];
}

callback(sum);
}

sum([1,2,3,4,5], console.log);
=>
15

显然,这个callback还是顺序(同步)执行的。我们知道,JS本身是单线程的,所以不具备多线程并发执行的特点,那么异步从何体现呢?
我们再看一段程序:

1
2
3
4
5
6
7
8
setTimeout(function() {
console.log("world")
}, 1000);

console.log("hello");
=>
hello
world

上面的例子先打印出“hello”,然后打印出“world”。看上去好像是setTimeout()另外启动了一个“平行线程”,等待了1秒钟之后,调用回调函数打印“world”。
JS中提供了两大类异步函数,一种是计时函数,如:setTimeoutsetInterval。另外一类是I/O异步函数,如:fs.readFile

但是JS是单线程的。也就是说如果“主”线程一直处于忙碌状态,即使“平行”线程完成工作,通知“主”线程调用它的回调函数,也会等到“主”线程空闲了才能真正去调用。

1
2
3
4
5
6
7
8
var t = new Date();
setTimeout(function () {
console.log("waiting time: ", new Date() - t);
}, 1000);

while(new Date() - start < 1000);
=>
waiting time: 1094 //大于我们设置的1000毫秒

返回值

我们分别使用同步和异步实现一个函数,判断当前目录下的文件是否都是File,最终程序返回一个布尔值的数组,如:[true, false]
当前目录文件结构如下:

1
2
3
|_async.js
|_sync.js
|_ dir/

比较中学习

  • 同步方式下
1
2
3
4
5
6
7
8
9
10
const fs = require('fs');
const path = require('path');

const dirs = fs.readdirSync('.');
const areFiles = dirs.map((dirName) => {
return fs.statSync(path.join('.', dirName)).isFile();
});

console.log(areFiles);
=> [true, false, true]
  • 异步方式下
    失败的尝试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fs = require('fs');
const path = require('path');

fs.readdir('.', (err, dirs) => {
const areFiles = [];

dirs.forEach((dirName) => {
fs.stat(path.join('.', dirName), (err, stat) => {
areFiles.push(stat.isFile());
})
});

console.log(areFiles);
});
=> [] //思考为何是空数组?

成功的尝试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fs = require('fs');
const path = require('path');

fs.readdir('.', (err, dirs) => {
const areFiles = [];

dirs.forEach((dirName) => {
fs.stat(path.join('.', dirName), (err, stat) => {
areFiles.push(stat.isFile());

if(areFiles.length == dirs.length) { //使用标志来位判断所有的回调都已经调用完毕
console.log(areFiles);
}
})
});
});
=>[ true, true, false ] or [true, false, true]

总结

  1. 同步方法顺序取返回值,而异步方法总是在回调函数的取返回值
  2. 循环遍历中调用同步方法很容易,但是同样地在异步方法中,需要使用标志位来判断是否所有回调函数都已经调用完毕
  3. 异步函数的执行回调是无序的

数组的串行处理

我们看到上个例子里的异步的写法,最后的返回结果其实是无序的。使用标志位只能保证数组中的所有数据对应的回调函数都得以执行,但不能保证哪个回调函数先返回。要想顺序执行,那么必须是一个回调函数中包含另一个回调函数。拿上面的例子尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fs = require('fs');
const path = require('path');

fs.readdir('.', (err, dirs) => {
(function iterate(index, areFiles, callback) {
if(index < dirs.length) {
fs.stat(path.join('.', dirs[index]), (err, stat) => {
areFiles.push(stat.isFile());
iterate(index + 1, areFiles, callback);
});
} else {
callback(areFiles);
}
}(0, [], (result) => {
console.log(result);
}));
});

在场景中学习

假如我们有这样一个场景:有一系列的HTTP请求的URL构成的数组和一个初始值。这些HTTP请求是有依赖的,后一个的执行必须依赖前一个HTTP请求的响应。如果只是两个请求,我们可以很轻松地写出这样的代码:

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
const http = require('http');
const urls = ['localhost', 'www.baidu.com']; //多个urls的数组

const req = http.request(optionsWithHostname(urls[0]), (res) => { //第一次请求
res.on('data', (chunk) => {
const req2 = http.request(optionsWithHostname(urls[1]), (res2) => { //第二次请求
res2.on('data', (chunk2) => {
//dosometing here...
});
});
req2.write(chunk.toString() + 'agian');
req2.end();
});
});

req.write('hello world');
req.end();

function optionsWithHostname(hostname) {
return {
hostname: hostname,
port: 12306,
method: 'POST',
headers: {
'Content-type': 'text/plain'
}};
}

但如果是十个或者更多,这样的写法就不好使了。

我们知道异步函数必须在回调中才能使用其返回值,这样会很容易写出类似于>形状的回调套回调的写法。而递归的写法也正好符合这样的形状,所以尝试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const urls = ['localhost', 'www.baidu.com']; //多个urls的数组

(function next(i, len, initValue, callback) {
if (i < len) {
// 将http请求过程简化成了async
async(urls[i], (value) => {
console.log(value);
next(i + 1, len, value, callback);
});
} else {
callback();
}
}(0, urls.length, 'hello world', () => {
//dosomething here...
}));

总结

  • 在异步函数想要保证执行的顺序,就必须一个回调套一个回调
  • 可以利用递归的写法,在保证执行顺序的同时,处理系列或者不定长度的数据

异常处理

在比较中学习

  • 同步方式下
1
2
3
4
5
6
7
8
//try ... catch ...

try {
x.func();
} catch (err) {
console.log("I catch you ", err);
}
=> I catch you [ReferenceError: x is not defined]
  • 异步方式下
1
2
3
4
5
6
7
8
9
10
11
12
13
try {
setTimeout(() => {
x.func();
}, 0);
} catch (err) {
console.log("I catch you ", err);
}
=> C:\Users\qianyan\Projects\lesson6\exception\async.js:5
x.func();
^

ReferenceError: x is not defined
at null._onTimeout (C:\Users\qianyan\Projects\lesson6\exception\async.js:5:9)

可以看到,同步方式下异常会沿着代码执行路径一直冒泡,直到遇到第一个try语句时被捕获住。但由于异步函数会打断代码执行路径,异步函数执行过程中以及执行之后产生的异常冒泡到执行路径被打断的位置时,如果一直没有遇到try语句,就作为一个全局异常抛出。

解决方式就是在异常被作为全局异常抛出之前,try-catch住,如下:

1
2
3
4
5
6
7
8
setTimeout(() => {
try {
x.func();
} catch (err) {
console.log("I catch you ", err);
}
}, 0);
=> I catch you [ReferenceError: x is not defined]

这样异常又被捕获了。不妨,对setTimetout做一次封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function wrapSetTimeout(fn, callback) {
setTimeout(() => {
try {
callback(null, fn());
} catch (err) {
callback(err);
}
}, 0);
}

wrapSetTimeout(() => {x.func()}, (err, data) => {
if(err) console.log("I catch you again", err);
})
=>I catch you again [ReferenceError: x is not defined

Node.js的整个异步函数的异常设计都是如此,callback的首个参数都是err。

总结

  • try-catch在同步方式下很有效,但在异步方式下做不到
  • callback首个参数是err,是因为大多数API都遵循了一致的风格

小结

  • 不掌握异步编程就不算学会NodeJS
  • 异步编程依托于回调来实现,而使用回调不一定就是异步编程
  • 异步编程下的函数间数据传递、数组遍历和异常处理与同步编程有很大差别

参考链接
[1] 七天学会NodeJS
[2] ECMAScript 6入门 - 阮一峰

0%