λ

(conj clojurians me)

读取和写入文件

数据一般都是存储在纯文本文件当中,存储的形式多种多样。本文,我会介绍如何在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入门 - 阮一峰

究竟要怎么做,才能热衷于某件事呢?才能打心底认定非此不可,并在这条路上勇往直前呢? —— 三浦紫苑《编舟记》

西冈想不明白,我也是。但有些人没去想便做了,小说主角马缔就是其中一个。

做过报刊的编辑工作,我深知其对词汇认知敏锐度和付梓校对细致程度的要求之高,何况是小说中编纂“浩瀚的词汇海洋”般的辞典。假如书中描述是主角依着敏锐的词汇嗅觉和认真的态度不屈不挠地编纂出一部《大渡海》,这顶多被我归入成功学之列。所幸不是,推荐语“究竟要燃烧什么,才能激发出真正的热情?”是驱使我购书的诱因,也是心中一直郁结的谜团,在这种状态下,这本书激发了我思考的动机。

为什么能如此全身心地投入一件事?

这个问题放到马缔身上,可以归结成两点原因:适材适所和背负责任。除了对词汇有着敏锐认知度之外,马缔算是一无是处,如果没有被辞典编辑部发掘,估计只能碌碌无为,虚度一生。在辞典编辑部,他个人的才华连同缺点一起大放异彩,很快跻身成为中流砥柱。若说才华得以施展是他内在的驱动力,那么前辈对其的全权托付则是这外在的鞭策。一个人将毕生的事业全权托付给另一个人,这里头的分量非同小可,马缔背负这样的责任是一种幸运。这两点在我看来就是他热情的源头。

小说是一种艺术,艺术源于生活而高于生活。生活是复杂的,人也是。而我习惯囿于一种怀揣真理以正向视角去解读任何人的方式,自认为应该如此。倘若热情果真如此言简意赅,为何我心中的郁结未减半分。又或者真应了那句台词:我们听过无数的道理,却仍旧过不好这一生?

暂时抛开人生哲理不谈,本书倒是有一个实用的指导意义——辞典中每个词都是被编辑认真释义,力求正确全面的。如此不妨模仿书中的行文方式,查查辞典中对热情这个词的描述。

【热情】指人参与活动或对待别人所表现出来的热烈,积极,主动,友好的情感或态度。热情的反义词是冷漠。如:冷漠的人干活无精打彩,热情的人工作起来象个小老虎。冷漠的人待人爱理不理,热情的人主动关心,主动帮忙,如火温暖。热情是与人生观,价值观有关联的,是一个人态度、兴趣的表现。与激情相比,热情更平稳稳定一些。激情是一种强烈的情感表现形式。往往发生在强烈刺激或突如其来的变化之后。具有迅猛、激烈、难以抑制等特点。人在激情的支配下,常能调动身心的巨大潜力。

马缔编辞典的热情,固然是他性格所致、兴趣所在。但是如何解释逾十五年,几乎独力完成编纂整部辞典这一事实呢?一件事失去了新鲜感,兴趣总归是减损的,这是客观事实。工作如此,尤其是以高薪技术标榜,极客达人自居的程序员一族,视快速学习新技术为本职,这也决定我辈好奇骛新的特点,追技术也似赶潮流,热情不过三分,把玩一番就浅尝辄止,如此反复,连猎奇的心情也冷却了,凡事都提不起劲儿,以懒自居,浑浑噩噩,陷入迷惘。可见兴趣并非热情之源,一定有别种更深刻的东西在持续地,稳定地释放着热情,如源头活水才是。

我崇拜的一位我司的前辈说过:“迷惘是因为你离目标太近”。看到这句话正是我入职满一年的时候。头一年里,从职场新人转变成老人,逐渐进入舒适区。等回过神,忽然发现每天过着朝九晚六的生活,年纪轻轻,人生就这么决定了,心有不甘,又无可奈何。究其原因,大概是大学之后,再无目标。犹记得当年毕业找到工作后,写就的一篇关于《信仰》的文章,文中探讨了信仰对于一个人面临人生选择时的重要作用,同时佐引了德国大哲学家康德说过一句话:“我们信仰,是因为我们需要信仰”。那时想法单纯,对这句话也只是断章取义,不乏嘲讽。现在看来,信仰的本身就是生活的需要,如同光和水一样必不可少。很多时候,我们所认为荒谬的道理其实并不一定如此荒谬,我们过得生活会在某处经历中告诉我们:事实如此。

可是究竟要信仰什么?

在世纪的转折点上,尼采大呼“上帝死了”,人们发现自己生活在一个毫不仁慈、无法救赎的世界中。这时,叔本华用一种消极的口吻告诉人们:生命本无意义,生命本质上是悲剧性质的,人生而痛苦,个人应当自觉否定生命意志快步走向灭寂。但是尼采爱人类,他肯定了生命的悲剧性质,却不屈服否定生命意志的消极面,毕竟哲学原初的目的就是让人幸福。所以他提出“面对痛苦、险境和未知的事物,精神愈加欢欣鼓舞”的酒神精神以及不断自我超越、支配的求强力的意志。所谓求强力的意志就是生命要不断奋力向上,从高于自己的东西那里寻找自身的意义和目的;其本身就是强力,其本质上就是求强力的过程。我认可这样的生命意志,我愿意用自己蓬勃兴旺的生命力去战胜苦难,像英雄一样在残酷的悲剧中自我肯定,坚强到足以把苦难当作快乐来感受,我要把它奉为我的信仰!

回头再细细评味“我们信仰,是因为我们需要信仰”这句话,我确实埋头驻足不知所措了好长一段时间,现在抬头了,瞭望久远的未来,选择了自己的信仰——我迫切地需要一条明路。至于马缔的信仰是什么我不得而知,不过我敢肯定他至少拥有健康的生命本能和严肃的精神追求,人物本身虽然属于小说虚构,但是他活得太真实了,生命和精神必须是兼具的,不然一个人活得不会真切。此间热情的源头想必存在生命意志当中吧,于马缔而言,是生命本能和某种精神追求的合力;于我而言,是信仰。

我很早就意识到人生中的三大幸事:有事做,有人爱,有所期待。

马缔十五年如一日地编辑辞典,这是有事可做;马缔背负着前辈的托付——尽早付梓出版《大渡海》这部辞典,渴望前辈和消费者的认可,这算是有所期待;那么马缔有人爱吗?必须有,而且小说中安排了一位“辉夜公主”给他。可是视编辑辞典为生命,生活又乱糟糟的无趣之人凭什么得到人家姑娘的爱?这种不合理的展开难道是作者个人的意淫么?如是想着,模仿马缔的笨拙,自然以笨拙收场,我自嘲。我探讨不来这种话题,却由衷欣赏他们两人之间的默契,都有自己热衷的事业并沉浸其中追求极致,所以才能互相理解,互相慰藉。这是两个独立人格之间自由的结合,谁也不会为谁放弃自我,这种体现自由又能体现人格的关系不是爱情还能是什么?这三个条件满足了,在我看来,马缔就是幸福的,此生不会再迷惘和孤独。

“人的内心,有时候对自己都是一个谜”。我想活得明白点,仅此而已。

尼采 在世纪的转折点上
编舟记

什么是字符集

ASCII(American Standard Code for Information Interchange)

1
2
man ascii
0 ~ 2^7 = 128

欧洲语系 ISO8859

  • ISO8859-1英语、法语、德语;ISO8859-5 俄语

中国的GB2312和GBK,以及中国台湾的Big5

多语系 Unicode

  • 最初的目的是把世界各地的语言都映射到16位空间
  • 8位转换为16位,称为UCS-2 (2 byte Universal Character Set), 也叫做Basic Multilingual Plane (BMP)
  • 16位到21位,有效编码区间0 ~ 0x10ffff

    The Unicode standard describes how characters are represented by code point

什么是编码方式

取“鄢”这个字的Unicode编码


1
2
echo "鄢" | native2ascii -encoding utf8
;; \u9122 这个是 code point

一段查看汉字编码的Java代码

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
import java.nio.charset.Charset;
import java.util.Arrays;

class Main {
public static void main(String[] args) throws Exception {
unicode("鄢", "utf8");
// How many bytes: 3 What are they: [-23, -124, -94]

unicode("鄢", "utf16");
// How many bytes: 4 What are they: [-2, -1, -111, 34]

unicode("鄢", "utf-16BE");
// How many bytes: 2 What are they: [-111, 34]

unicode("🐶", "utf-16BE");
// How many bytes: 4 What are they: [-40, 61, -36, 54]

p("🐶".length());
// 2
}

public static void unicode(String s, String encoding){
p("How many bytes: " + s.getBytes(Charset.forName(encoding)).length);
p("What are they: " + Arrays.toString(s.getBytes(Charset.forName(encoding))));
}

public static void p(Object s) {
System.out.println(s);
}
}

UTF-8变长编码方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
\u9122
1110xxxx 10xxxxxx 10xxxxxx

9 -> 1001
1 -> 0001
2 -> 0010
2 -> 0010

10010001 00100010

原码
11101001 10000100 10100010

取反
10010110 11111011 11011101

补码
10010111 11111100 11011110
16+4+2+1=23 127-3=124 64+16+8+4+2=94
[-23, -124, -94]
  • 从程序运行的结果来看,UTF-8中,“鄢”占据了3个字节,且在计算机中的补码表示分别为-23, -124, -94
  • \u9122占据2个字节,但是带入UTF-8编码提供的1110xxxx 10xxxxxx 10xxxxxx模板中就变成了3个字节,我们是从低位带入模板的,x的个数刚好是16位、2个字节;
  • UTF-8编码除了和ASCII码兼容部分(以0开头,0xxxxxxx),其余都遵循一个简单的标准:
    • 其模板遵循110xxxxx 10xxxxxx,高位1的个数即是总的字节数,这里110xxxxx中11表示总共有两个字节;
    • 低位的10xxxxxx始终以10开头;
    • 这样的编码方式,使得程序清楚知道那个地方是字符开始的地方,所以才说UTF-8的单字节的编码方式。

但是UTF-8也有自己的缺点

  • 浪费内存,几乎所有的汉字都占三个字节
  • 随机访问,同字符串的长度成正比

Tips:

正数的补码就是其本身
负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1

大端字节序和小端字节序(网络字节序和主机字节序)

我们知道UTF-16的目的和Unicode本来的目的是一致的 —— “把世界各地的语言都映射到16位空间”。
\u9122为例,我们运行程序的结果应该是2个字节,但是事实上是4个字节(*How many bytes: 4 What are they: [-2, -1, -111, 34]*),这多出来的2个字节是怎么回事?
这里就是字节序在作祟。在内存存储时,如果最低有效位在最高有效位的前面,则称小端序;反之则称大端序。大端字节序又称网络字节序。
举个例子

1
2
3
\u9122 ;;;->鄢
91是最高有效位
22是最低有效位

我们观察一下返回的4个字节中的前两个字节-2, -1,它们其实不是字符“鄢”的一部分,它们是Unicode中的大端字节序的标识BOM(Bytes Order Marks)

1
2
3
4
5
6
7
0xfeff
11111110 11111111
反码
10000001 10000000
补码
10000010 10000001
-2 -1

Unicode在对待字节序时,采取了大端和小端序共存的方式。0xfeff这个编码在对调字节序后得到的0xfffe在Unicode不存在,Unicode借助这个特点来判断读入的字符到底是大端序还是小端序。
另外,从程序中可知,Java会默认将读入的字符串当做大端序处理;所以当我们明确指定UTF-16BE后,程序就会返回2个字节的结果了。

UTF-16不足以表示世界所有的字符

正如前面提及的UCS-2 (2 byte Universal Character Set)不足以表示世界上所有的字符,比如“🐶”这种emoji的字符需要4个字节才能表示,两个字节的UTF-16就无能为力了。
Java默认采用了UTF-16编码,结果导致"🐶".length()不是返回1,而是2。不得已,Java引入了codePoint这种处理方式,有点无奈。

UCS和CSI

我们计算机的程序会采用两种方式来处理不同的字符集

  • UCS:(Universal Character Set,泛用字符集)程序输入输出的时候,需要将文本数据变成UCS,统一处理。

  • CSI:(Character Set Independent 字符集独立),不对文字集做变换,直接处理。

结论

  • 积极采用UTF-8。

在Gaslight,我们广泛地在工作中使用Ruby和Rails,也充分地经历了从JQuery到Ember再到AngularJS及其他所有关于JavaScript的一切。不过有时候我们为了学习和成长,需要脱离舒适区。我们如何赶上未来的趋势和机会呢?

那就是绝不停止学习。在过去的几个星期里,我们的开发团队决定作为一个小组去掌握Clojure语言。我们尝试构建了关于这门技术的主要知识点,所以当我们在一个客户项目中采用Clojure的时候,对自己技术的深度和熟悉程度很有自信。

为什么我们会选择Clojure呢?

填补了Ruby和Rail没有顾及的空白

我知道我们不可能永远用Ruby编程。另一个语言和环境一定会到来并取代它。我只是希望它真的比我们现在所拥用的更好。 —— Doug Alcorn

我们依然热爱Ruby,依然认为Rails通常是构建web应用的最佳方案。但是,它和其他工具一样,有长处,也有短处。

  1. Ruby的并发并不健壮。是的,你可以使用JRuby和Celluloid,但是可变性在根本上妨碍它走向并发。同时,很多你想要使用的库都不是线程安全的。
  2. Ruby和Rails几乎总是足够快的。几乎总是,但不尽然。
  3. 对象关系映射(ORM)不总是正确的。
  4. 面向对象的语言也不总是最佳的解决方案。

函数式编程是一种不同的思考方式

函数式编程是一种编程范式,一种构建计算机程序的结构和元素,它把计算看做数学函数的求值,同时禁止改变状态和可变的数据。——Wikipedia

函数式编程语言是一种有别于我们平常在Ruby和JavaScript中编程的编程范式。学习这一范式使得我们能学习崭新且不同的技术来解决问题。这些技术可能不太容易去学习,如果我们沉浸在自己舒适的命令式和面向对象的语言范式当中。

Clojure运行在服务器端

Clojure实现它的目标,通过:拥抱一个行业标准的开放平台——Java虚拟机;现代化一门古老的语言——Lisp;树立带有不可变持久化数据结构的函数式编程;同时通过软件事务内存以及异步代理提供内建的并发支持。这一结果是健壮的,顺手的和快速的。—— Clojure.org

Clojure运行在Java虚拟机(JVM)上并且利用了其提供的优势:高质量的垃圾回收,高性能的本地线程以及可扩展的移植。Clojure又在其上增加了高效的不可变持久化数据结构。Clojure带来了有趣的并发模型Core.Async。另外,这里还有很多其他有趣的库供开发,逻辑编程,模式匹配,optional typing和好多我们想要尝试并理解的东西。

Clojure运行在浏览器端

一个具有扩展,移植和多供应商支持的开发平台,一场在各个新设备上实现精巧工具的优化军备竞赛以及一个对更丰富且更精巧应用的呐喊。开发者们还有什么想要的呢?一个不同的语言,这就是了。—— ClojureScript Wiki

ClojureScript编译成JavaScript运行于浏览器。它提供了一种全新的方式去思考编写客户端应用程序。JavaScript并不是开发者社区里广泛青睐的语言。但是Clojure是设计良好,精密且具有很好异步支持的成熟的Lisp。它也可以和其他的JavaScript类库交互。所有这些都为我们提供了一个在客户端和服务端使用相同的语言却不需要做任何妥协的机会。

管理本来就具有状态的客户端应用是件尴尬的事,不过像React和Ember这些JavaScript框架正在着手解决这个问题,我们开始猜想如果一种更加基本的改变方式值得深入研究,那么是否可能有一种更好的语言和这其中的任一已经存在的框架配合来为我们提供一种途径使得可以更好地控制复杂性呢?Clojure允许我们朝着这个问题寻找答案。

我们不相信银弹,但是我们真的喜欢这样的事实,Clojure包含了客户端的需求。

朝冰球要去的地方滑

我追逐冰球要去的地方,而不是它所在的地方。—— Wayne Gretzky

Clojure会帮助我们填补创建特定类型的应用程序的沟壑。那些需要响应或者处理大规模数据的程序。我们已经身处多核CPU的纪元,但是传统的语言还未追赶上这些计算机的架构。函数式编程提供了一种新的编程范式允许我们更简单地使用这种架构写程序。Clojure提供允许我们高效地处理大规模数据的工具。它使得传递和转换数据接近实时。这些特性会使我们比从前更高效地解决问题。

不过不是Bleeding Edge

“什么会被认为是bleeding-edge的技术,”sez Lucas。 “没被证实的用法,高风险,只有早期采用成瘾者才感到舒服的东西”—— Thomas Pynchon, Bleeding Edge

此时,Clojure被发布超过5年之久并且似乎在开发者社区里获得了不错的想法分享。在新锐技术上掉坑的采纳曲线上这不算太早了。Clojure利用了已存在的技术如已经被开发超过15以上年头的java虚拟机(JVM)。它也可以利用很多已经存在的高质量的Java类库。

一些新东西让我们保持兴奋

自从学Ruby以来,还没有学习一门新技术让我如此激动! —— Michael Guterl

在Gaslight,我们想在行业中吸引和保留最好的技术。为了做到这一点,我们需要给大家提供学习和提高其技能的机会。Clojure帮助我们提供了这样的机会。它是一个带有一系列工具的崭新范式。不过最重要的是,它允许我们处理新的且有趣的问题。还没有什么能比解决有意思的问题更让我们高兴的呢。

Monad不就是个自函子范畴上的幺半群,这有什么难理解的(A monad is just a monoid in the category of endofunctors)
—— Phillip Wadler

自函子(Endofunctor)

什么是函数(Function)?
函数表达的映射关系在类型上体现在特定类型(proper type)之间的映射。

什么是自函数(Endofunction)?

1
identity :: Number -> Number

自函数就是把类型映射到自身类型。函数identity是一个自函数的特例,它接收什么参数就返回什么参数,所以入参和返回值不仅类型一致,而且值也相同。

接下来,回答什么是自函子(Endofunctor)之前,我们先弄清什么是函子(Functor)?

函子有别于函数,函数描述的是特定类型(proper type)之间的映射,而函子描述的是范畴(category)之间的映射。

那什么是范畴(category)?

我们把范畴看做一组类型及其关系态射(morphism)的集合。包括特定类型及其态射,比如Int、String、Int -> String;高阶类型及其态射,比如List[Int]、List[String]、List[Int] -> List[String]

接下来看看函子是如何映射两个范畴的,见下图:
范畴

图中范畴C1和范畴C2之间有映射关系,C1中Int映射到C2中的List[Int],C1中String映射到C2中的List[String]。除此之外,C1中的关系态射Int -> String也映射到C2中的关系List[Int] -> List[String]态射上。

换句话说,如果一个范畴内部的所有元素可以映射为另一个范畴的元素,且元素间的关系也可以映射为另一个范畴元素间关系,则认为这两个范畴之间存在映射。所谓函子就是表示两个范畴的映射。

澄清了函子的含义,那么如何在程序中表达它?

在Haskell中,函子是在其上可以map over的东西。稍微有一点函数式编程经验,一定会想到数组(Array)或者列表(List),确实如此。不过,在我们的例子中,List并不是一个具体的类型,而是一个类型构造子。举个例子,构造List[Int],也就是把Int提升到List[Int],记作Int -> List[Int]。这表达了一个范畴的元素可以映射为另一个范畴的元素。

List具有map方法,不妨看看map的定义:

1
2
f :: A -> B
map :: f -> List[A] -> List[B]

具体到我们的例子当中,就有:

1
2
f :: Int -> String
map :: f -> List[Int] -> List[String]

展开来看:

1
map :: Int -> String -> List[Int] -> List[String]

map的定义清晰地告诉我们:Int -> String这种关系可以映射为List[Int] -> List[String]这种关系。这就表达了元素间的关系也可以映射为另一个范畴元素间关系。

所以类型构造器List[T]就是一个函子。

理解了函子的概念,接着继续探究什么是自函子。我们已经知道自函数就是把类型映射到自身类型,那么自函子就是把范畴映射到自身范畴。

自函子是如何映射范畴的,见下图:
Identity自函子范畴

图中表示的是一个将范畴映射到自身的自函子,而且还是一个特殊的Identity自函子。为什么这么说?从函子的定义出发,我们考察这个自函子,始终有List[Int] -> List[Int]以及List[Int] -> List[String] -> List[Int] -> List[String]这两种映射。
我们表述成:

1
2
类型List[Int]映射到自己
态射f :: List[Int] -> List[String]映射到自己

我们记作:

1
2
3
F(List[Int]) = List[Int]
F(f) = f
其中,F是Functor.

除了Identity的自函子,还有其它的自函子,见下图:
自函子范畴

图中的省略号代表这些范畴可以无限地延伸下去。我们在这个大范畴所做的所有映射操作都是同一范畴内的映射,自然这样的范畴就是一个自函子的范畴。

我们记作:

1
2
3
List[Int] -> List[List[Int]]
List[Int] -> List[String] -> List[List[Int]] -> List[List[String]]
...

所以List[Int]、List[List[Int]]、...、List[List[List[...]]]及其之间的态射是一个自函子的范畴。


幺半群

[幺半群][1]是一个带有二元运算 : M × M → M 的集合 M ,其符合下列公理:
结合律:对任何在 M 内的a、b、c, (a
b)c = a(bc) 。
单位元:存在一在 M 内的元素e,使得任一于 M 内的 a 都会符合 a
e = e*a = a 。

接着我们看看在自函子的范畴上,怎么结合幺半群的定义得出Monad的。

假设我们有个cube函数,它的功能就是计算每个数的3次方,函数签名如下:

1
cube :: Number -> Number

现在我们想在其返回值上添加一些调试信息,所以返回一个元组(Tuple),第二个元素代表调试信息。函数签名如下:

1
f :: Number -> (Number,String)

结合前面所讲,我们很容易知道元组构造子(Number,String)是一个自函子。Number所在的范畴并不同于元组(Number,String)所在的范畴。换句话说,f的入参和返回值属于两个范畴。那么这会产生什么影响?我们看看幺半群的定义中规定的结合律。对于函数而言,结合律就是将函数以各种结合方式嵌套起来调用。我们将常用的compose函数看作此处的二元运算。

1
2
3
4
5
6
7
var compose = function(f, g) {
return function(x) {
return f(g(x));
};
};

compose(f, f)

从函数签名可以很容易看出,右边的f运算的结果是元组,而左侧的f却是接收一个Number类型的函数,它们是彼此不兼容的。

有什么好办法能消除这种不兼容性?假如输入和输出都是元组,结果会如何呢?

1
2
3
F :: (Number,String) -> (Number,String)

compose(F, F)

这样是可行的!在验证满足结合律之前,我们引入一个bind函数来辅助将f提升成F.

1
2
3
4
5
6
7
8
9
10
11
12
13
f :: Number -> (Number,String) => F :: (Number,String) -> (Number,String)

var bind = function(f) {
return function F(tuple) {
var x = tuple[0],
s = tuple[1],
fx = f(x),
y = fx[0],
t = fx[1];

return [y, s + t];
};
};

我们来实现元组自函子范畴上的结合律:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var cube = function(x) {
return [x * x * x, 'cube was called.'];
};

var sine = function(x) {
return [Math.sin(x), 'sine was called.'];
};

var f = compose(compose(bind(sine), bind(cube)), bind(cube));
f([3, ''])

var f1 = compose(bind(sine), compose(bind(cube), bind(cube)));
f1([3,''])
>>>
[0.956, 'cube was called.cube was called.sine was called.']
[0.956, 'cube was called.cube was called.sine was called.']

这里f和f1代表的调用顺序产生同样的结果,说明元组自函子范畴满足结合律。

那如何找到这样一个e,使得a*e=e*a=a

1
2
3
4
5
6
// unit :: Number -> (Number,String)
var unit = function(x) { return [x, ''] };

var f = compose(bind(sine), bind(cube));

compose(f, bind(unit)) = compose(bind(unit), f) = f

这里的bind(unit)就是e了。

Monads for functional programming一书中介绍说monad是一个三元组(M, unit, *),对应此处就是(Tuple, unit, bind).

参考链接:

  1. Translation from Haskell to JavaScript of selected portions of the best introduction to monads I’ve ever read
  2. 我所理解的monad
  3. Monads for functional programming
0%