编写Shell脚本的最佳实践
前言
由于工作需要,最近重新开始拾掇shell脚本。虽然绝大部分命令自己平时也经常使用,但是在写成脚本的时候总觉得写的很难看。而且当我在看其他人写的脚本的时候,总觉得难以阅读。毕竟shell脚本这个东西不算是正经的编程语言,他更像是一个工具,用来杂糅不同的程序供我们调用。因此很多人在写的时候也是想到哪里写到哪里,基本上都像是一段超长的main函数,不忍直视。同时,由于历史原因,shell有很多不同的版本,而且也有很多有相同功能的命令需要我们进行取舍,以至于代码的规范很难统一。
考虑到上面的这些原因,我查阅了一些相关的文档,发现这些问题其实很多人都考虑过,而且也形成了一些不错的文章,但是还是有点零散。因此我就在这里把这些文章稍微整理了一下,作为以后我自己写脚本的技术规范。
代码风格规范
开头有“蛇棒”
所谓shebang其实就是在很多脚本的第一行出现的以”#!”开头的注释,他指明了当我们没有指定解释器的时候默认的解释器,一般可能是下面这样:
1 | #!/bin/bash |
当然,解释器有很多种,除了bash之外,我们可以用下面的命令查看本机支持的解释器:
1 | $ cat /etc/shells |
当我们直接使用./a.sh
来执行这个脚本的时候,如果没有shebang,那么它就会默认用$SHELL
指定的解释器,否则就会用shebang指定的解释器。
不过,上面这种写法可能不太具备适应性,一般我们会用下面的方式来指定:
1 | #!/usr/bin/env bash |
这种方式是我们推荐的使用方式。
代码有注释
注释,显然是一个常识,不过这里还是要再强调一下,这个在shell脚本里尤为重要。因为很多单行的shell命令不是那么浅显易懂,没有注释的话在维护起来会让人尤其的头大。
注释的意义不仅在于解释用途,而在于告诉我们注意事项,就像是一个README。
具体的来说,对于shell脚本,注释一般包括下面几个部分:
- shebang
- 脚本的参数
- 脚本的用途
- 脚本的注意事项
- 脚本的写作时间,作者,版权等
- 各个函数前的说明注释
- 一些较复杂的单行命令注释
参数要规范
这一点很重要,当我们的脚本需要接受参数的时候,我们一定要先判断参数是否合乎规范,并给出合适的回显,方便使用者了解参数的使用。
最少,最少,我们至少得判断下参数的个数吧:
1 | if [[ $# != 2 ]];then |
变量和魔数
一般情况下我们会将一些重要的环境变量定义在开头,确保这些变量的存在。
1 | source /etc/profile |
这种定义方式有一个很常见的用途,最典型的应用就是,当我们本地安装了很多java版本时,我们可能需要指定一个java来用。那么这时我们就会在脚本开头重新定义JAVA_HOME
以及PATH
变量来进行控制。
同时,一段好的代码通常是不会有很多硬编码在代码里的“魔数”的。如果一定要有,通常是用一个变量的形式定义在开头,然后调用的时候直接调用这个变量,这样方便日后的修改。
缩进有规矩
对于shell脚本,缩进是个大问题。因为很多需要缩进的地方(比如if,for语句)都不长,所有很多人都懒得去缩进,而且很多人不习惯用函数,导致缩进功能被弱化。
其实正确的缩进是很重要的,尤其是在写函数的时候,否则我们在阅读的时候很容易把函数体跟直接执行的命令搞混。
常见的缩进方法主要有”soft tab”和”hard tab”两种。
- 所谓soft tab就是使用n个空格进行缩进(n通常是2或4)
- 所谓hard tab当然就是指真实的”\t”字符
这里不去撕哪种方式最好,只能说各有各的优劣。反正我习惯用hard tab。
对于if和for语句之类的,我们最好不要把then,do这些关键字单独写一行,这样看上去比较丑。。。
命名有标准
所谓命名规范,基本包含下面这几点:
- 文件名规范,以.sh结尾,方便识别
- 变量名字要有含义,不要拼错
- 统一命名风格,写shell一般用小写字母加下划线
编码要统一
在写脚本的时候尽量使用UTF-8编码,能够支持中文等一些奇奇怪怪的字符。不过虽然能写中文,但是在写注释以及打log的时候还是尽量英文,毕竟很多机器还是没有直接支持中文的,打出来可能会有乱码。
这里还尤其需要注意一点,就是当我们是在windows下用utf-8编码来写shell脚本的时候,一定要注意这个utf-8是否是有BOM的。默认情况下windows判断utf-8格式是通过在文件开头加上三个EF BB BF字节来判断的,但是在Linux中默认是无BOM的。因此如果我们是在windows下写脚本的时候,一定要注意将编码改成Utf-8无BOM,一般用notepad++之类的编辑器都能改。否则,在Linux下运行的时候就会识别到开头的三个字符,从而报一些无法识别命令的错。
当然,对于跨平台写脚本还有一个比较常见的问题就是换行符不同。windows默认是\r\n而unix下是\n。不过有两个小工具可以非常方便的解决这个问题:dos2unix,unix2dos。
权限记得加
这一点虽然很小,但是我个人却经常忘记,不加执行权限会导致无法直接执行,有点讨厌。。。
日志和回显
日志的重要性不必多说,能够方便我们回头纠错,在大型的项目里是非常重要的。
如果这个脚本是供用户直接在命令行使用的,那么我们最好还要能够在执行时实时回显执行过程,方便用户掌控。
有时候为了提高用户体验,我们会在回显中添加一些特效,比如颜色啊,闪烁啊之类的,具体可以参考ANSI/VT100 Control sequences这篇文章的介绍。
密码要移除
不要把密码硬编码在脚本里,不要把密码硬编码在脚本里,不要把密码硬编码在脚本里。
重要的事情说三遍,尤其是当脚本托管在类似Github这类平台中时。。。
太长要分行
在调用某些程序的时候,参数可能会很长,这时候为了保证较好的阅读体验,我们可以用反斜杠来分行:
1 | ./configure \ |
注意在反斜杠前有个空格。
编码细节规范
代码有效率
在使用命令的时候要了解命令的具体做法,尤其当数据处理量大的时候,要时刻考虑该命令是否会影响效率。
比如下面的两个sed命令:
1 | sed -n '1p' file |
他们的作用一样,都是获取文件的第一行。但是第一条命令会读取整个文件,而第二条命令只读取第一行。当文件很大的时候,仅仅是这样一条命令不一样就会造成巨大的效率差异。
当然,这里只是为了举一个例子,这个例子真正正确的用法应该是使用head -n1 file
命令。。。
勤用双引号
几乎所有的大佬都推荐在使用”$”来获取变量的时候最好加上双引号。
不加上双引号在很多情况下都会造成很大的麻烦,为什么呢?举一个例子:
1 | #!/bin/sh |
他的运行结果如下:
1 | a.sh |
为啥会这样呢?其实可以解释为他执行了下面的命令:
1 | echo *.sh |
在很多情况下,在将变量作为参数的时候,一定要注意上面这一点,仔细体会其中的差异。上面只是一个非常小的例子,实际应用的时候由于这个细节导致的问题实在是太多了。。。
巧用main函数
我们知道,像java,C这样的编译型语言都会有一个函数入口,这种结构使得代码可读性很强,我们知道哪些直接执行,那些是函数。但是脚本不一样,脚本属于解释性语言,从第一行直接执行到最后一行,如果在这当中命令与函数糅杂在一起,那就非常难读了。
用python的朋友都知道,一个合乎标准的python脚本大体上至少是这样的:
1 | #!/usr/bin/env python |
他用一个很巧妙的方法实现了我们习惯的main函数,使得代码可读性更强。
在shell中,我们也有类似的小技巧:
1 | #!/usr/bin/env bash |
我们可以采用这种写法,同样实现类似的main函数,使得脚本的结构化程度更好。
考虑作用域
shell中默认的变量作用域都是全局的,比如下面的脚本:
1 | #!/usr/bin/env bash |
他的输出结果就是2而不是1,这样显然不符合我们的编码习惯,很容易造成一些问题。
因此,相比直接使用全局变量,我们最好使用local readonly
这类的命令,其次我们可以使用declare
来声明变量。这些方式都比使用全局方式定义要好。
函数返回值
在使用函数的时候一定要注意,shell中函数的返回值只能是整数,估计是因为一般情况下一个函数的返回值通常表示这个函数的运行状态,所以一般都是0或者是1就够了,因此就设计成了这样。不过,如果非得想传递字符串,也可以通过下面变通的方法:
1 | func(){ |
这样,通过echo或者print之类的就可以做到传一些额外参数的目的。
间接引用值
什么叫间接引用?比如下面这个场景:
1 | VAR1="2323232" |
我们有一个变量VAR1,又有一个变量VAR2,这个VAR2的值是VAR1的名字,那么我们现在想通过VAR2来获取VAR1的值,这时候应该怎么办呢?
比较土鳖的方法是这样:
1 | eval echo \$$VAR2 |
啥意思呢?其实就是构造了一个字符串”echo $XXX”,这个XXX就是$VAR2的值VAR1,然后再用eval强制解析,这样就做到了变相取值。
这个用法的确可行,但是看起来十分的不舒服,很难直观的去理解,我们并不推荐。而且事实上我们本身就不推荐使用eval这个命令。
比较舒服的写法是下面这样:
1 | echo ${!VAR1} |
通过在变量名前加一个!就可以做到简单的间接引用了。
不过需要注意的是,用上面的方法,我们只能够做到取值,而不能做到赋值。如果想要做到赋值,还要老老实实的用eval来处理:
1 | VAR1=VAR2 |
巧用heredocs
所谓heredocs,也可以算是一种多行输入的方法,即在”<<”后定一个标识符,接着我们可以输入多行内容,直到再次遇到标识符为止。
使用heredocs,我们可以非常方便的生成一些模板文件:
1 | cat>>/etc/rsyncd.conf << EOF |
学会查路径
很多情况下,我们会先获取当前脚本的路径,然后一这个路径为基准,去找其他的路径。通常我们是直接用pwd
以期获得脚本的路径。
不过其实这样是不严谨的,pwd
获得的是当前shell的执行路径,而不是当前脚本的执行路径。
正确的做法应该是下面这两种:
1 | script_dir=$(cd $(dirname $0) && pwd) |
应当先cd进当前脚本的目录然后再pwd,或者直接读取当前脚本的所在路径。
代码要简短
这里的简短不单单是指代码长度,而是只用到的命令数。原则上我们应当做到,能一条命令解决的问题绝不用两条命令解决。这不仅牵涉到代码的可读性,而且也关乎代码的执行效率。
最最经典的例子如下:
1 | cat /etc/passwd | grep root |
cat命令最为人不齿的用法就是这样,用的没有任何意义,明明一条命令可以解决,他非得加根管道。。。
其实代码简短在还能某种程度上能保证效率的提升,比如下面的例子:
1 | #method1 |
1 | #method1 |
这两种方法做的事情都一样,就是查找所有的.txt后缀的文件并做一系列替换。前者是多次执行find,后者是执行一次find,但是增加了sed的模式串。第一种更直观一点,但是当替换的量变大的时候,第二种的速度就会比第一种快很多。这里效率提升的原因,就是第二种只要执行一次命令,而第一种要执行多次。
并且,巧用xargs命令,我们还可以十分方便的进行并行化处理:
1 | find . -name '*.txt' |xargs -P $(nproc) sed -i "s/233/666/g;s/235/626/g;s/333/616/g;s/233/664/g" |
通过-P参数指定并行度,可以进一步加快执行效率。
命令并行化
当我们需要充分考虑执行效率时,我们可能需要在执行命令的时候考虑并行化。shell中最简单的并行化是通过”&”以及”wait”命令来做:
1 | func(){ |
当然,这里并行的次数不能太多,否则机器会卡死。稍微正确的做法比较复杂,以后再讨论,如果图省事可以使用parallel命令来做,或者是用上面提到的xargs来处理。
全文本检索
我们知道,当我们想在文件夹下所有的txt文件中检索某一个字符串(比如233)的时候,我们可能会用类似这样的命令:
1 | find . -name '*.txt' -type f | xargs grep 2333 |
很多情况下,这个命令会想我们所想的找到对应的匹配行,但是我们需要注意两个小问题。
find命令会符合要求的匹配文件名,但是如果文件名包含空格,这时候将文件名传给grep的时候就会有问题,这个文件就会被当成两个参数,这时候就要加一层处理,保证用空格分开的文件名不会被当成两个参数:
1 | find . -type f|xargs -i echo '"{}"'|xargs grep 2333 |
有时候,文件的字符集可能跟终端的字符集不一致,这时候就会导致grep在搜索时将文件当成二进制文件从而报binary file matches
之类的问题。这时候要么用iconv之类的字符集转换工具将字符集进行切换,要么就在不影响查找的情况下对grep加-a参数,将所有文件看成文本文件:
1 | find . -type f|xargs grep -a 2333 |
使用新写法
这里的新写法不是指有多厉害,而是指我们可能更希望使用较新引入的一些语法,更多是偏向代码风格的,比如
- 尽量使用
func(){}
来定义函数,而不是func{}
- 尽量使用
[[]]
来代替[]
- 尽量使用
$()
将命令的结果赋给变量,而不是反引号 - 在复杂的场景下尽量使用printf代替echo进行回显
事实上,这些新写法很多功能都比旧的写法要强大,用的时候就知道了。
其他小tip
考虑到还有很多零碎的点,就不一一展开了,这里简单提一提。
- 路径尽量保持绝对路径,绝多路径不容易出错,如果非要用相对路径,最好用./修饰
- 优先使用bash的变量替换代替awk sed,这样更加简短
- 简单的if尽量使用&& ||,写成单行。比如
[[ x > 2]] && echo x
- 当export变量时,尽量加上子脚本的namespace,保证变量不冲突
- 会使用trap捕获信号,并在接受到终止信号时执行一些收尾工作
- 使用mktemp生成临时文件或文件夹
- 利用/dev/null过滤不友好的输出信息
- 会利用命令的返回值判断命令的执行情况
- 使用文件前要判断文件是否存在,否则做好异常处理
- 不要处理ls后的数据(比如
ls -l | awk '{ print $8 }'
),ls的结果非常不确定,并且平台有关 - 读取文件时不要使用for loop而要使用while read
- 使用
cp -r
命令复制文件夹的时候要注意如果目的文件夹不存在则会创建,如果存在则会复制到该文件的子文件夹下
静态检查工具shellcheck
概述
为了从制度上保证脚本的质量,我们最简单的想法大概就是搞一个静态检查工具,通过引入工具来弥补开发者可能存在的知识盲点。
市面上对于shell的静态检查工具还真不多,找来找去就找到一个叫shellcheck的工具,开源在github上,有8K多的star,看上去还是十分靠谱的。我们可以去他的主页了解具体的安装和使用信息。
安装
这个工具的对不同平台的支持力度都很大,他至少支持了Debian,Arch,Gentoo,EPEL,Fedora,OS X,openSUSE等等各种的平台的主流包管理工具。安装方便。具体可以参照安装文档
集成
既然是静态检查工具,就一定可以集成在CI框架里,shellcheck可以非常方便的集成在Travis CI中,供以shell脚本为主语言的项目进行静态检查。
样例
在文档的Gallery of bad code里,也提供了非常详细的“坏代码”的标准,具有非常不错的参考价值,可以在闲下来的时候当成”Java Puzzlers“之类的书来读读还是很惬意的。
本质
不过,其实我觉得这个项目最最精华的部分都不是上面的功能,而是他提供了一个非常非常强大的wiki。在这个wiki里,我们可以找到这个工具所有判断的依据。在这里,每一个检测到的问题都可以在wiki里找到对应的问题单号,他不仅告诉我们”这样写不好”,而且告诉我们”为什么这样写不好”,”我们应当怎么写才好”,非常适合刨根问底党进一步研究。
参考资料
关于 shell 脚本编程的10 个最佳实践
shell脚本编写规范
Shellcheck Tool
Best Practices for Writing Bash Scripts
Good coding practices for bash
Design patterns or best practices for shell scripts
bashstyle(GITHUB)
BashGuide/Practices
Obsolete and deprecated syntax
ANSI/VT100 Control sequences
what-makes-grep-consider-a-file-to-be-binary
写给大忙人的JavaSE8书后习题简析-第二章
Stream API
第一题
编写一个第2.1节中的for循环的并行版本。获取处理器的数量,创造出多个独立的线程,每个都只处理列表的一个片段,然后将他们各自的结果汇总起来。(我们不希望这些线程都更新一个计数器,为什么?)
还是有点麻烦的,线程得传值,得获取运行结果,相比流式计算麻烦太多了。这里为了平均给每个线程分配任务,我们得手动将资源进行拆分,有的是将数据List平均分,我这里是通过取模的结果来进行分配。
我们当然不希望这些线程都更新一个计数器,因为累加的操作不是原子操作,我们得加锁,这样不仅麻烦容易出错,而且效率也低。
附我的代码如下:
1 | import java.io.IOException; |
第二题
请想办法验证一下,对于获得前五个最长单词的代码,一旦找到第五个最长的单词后,就不会再调用filter方法了。(一个简单的方法是记录每次的方法调用)
这题主要验证流式计算跟循环的一个很明显的区别,流式计算对于每一个流元素是直接运算到结束,而循环则是一层一层的计算。(姑且这么理解)
1 | import java.io.IOException; |
输出结果:
1 | bingo |
第三题
要统计长单词的数量,使用parallelStream与使用stream有什么区别?请具体测试一下。你可以在调用方法之前和之后调用System.nanoTime,并打印出他们之间的区别。如果你有速度较快的计算机,可以试着处理一个较大的文档(例如战争与和平的英文原著)。
验证串行流跟并行流的效率,实验证明并行流还是比串行流快好多的。我本机cpu是E5-2690v2,数据集也不算大,但是跑起来效率差别还是不小的。代码如下:
1 | import java.io.IOException; |
我的运行结果是:
1 | count1 = 48 in 93 ms. |
我这里并行流的速度接近串行流的十倍。。。
第四题
假设你有一个数组int[] values={1,4,9,16}。那么Stream.of(values)的结果是什么?你如何获得一个int类型的流。
他返回的是一个引用对象的流,这个引用对象既不是int(int不是引用),也不是Integer。。。我们只能用Stream<Object>
来接收他。。。事实上Java8提供了的IntStream来专门处理int类型的流。。。我们知道,java中泛型是只能用于对像的而不能用于基本类型的,因此我们通常会将int,double等基本类型进行”装箱”成Integer,Double这样的对象。但是这样的话计算时的时间空间消耗就很大,因此java的设计者们对这些基本类型的stream进行了特殊处理,创造了IntStream,DoubleStream这类东西。同时,这些东西还附带了一些额外的统计功能,用起来更加方便,减少了写一些无谓的统计方法。
1 | import java.io.IOException; |
第五题
使用Stream.iterate来得到一个包含随机数字的无限流-不许调用Math.Random,只能直接实现一个线性同余生成器(LCG)。在这个生成器中,你可以从$x_0=seed$开始,然后根据合适的a,c和m值产生$x_{n+1}=(ax_n+c)%m$。你应该实现一个含有参数a,c,m和seed的方法,并返回一个
Stream<Long>
对象。可以试一下a=25214903917,c=11,m=$2^{48}$
巩固Stream.iterate的用法。
1 | import java.util.stream.Stream; |
第六题
第2.3节中的characterStream方法不是很好用,他需要先填充一个数组列表,然后再转变为一个流。试着编写一行基于流的代码。一个办法是构造一个从0开始到s.length()-1的整数流,然后使用s::charAt方法引用来映射它。
考察IntSteam的用法,以及IntStream可以利用mapToObj来转换成其他类型的流。
1 | import java.util.stream.IntStream; |
输出:
1 | 1234567 |
第七题
假设你的老板让你编写一个方法
public static<T> boolean isFinite(Stream<T> stream)
。为什么这不是一个好主意?不管怎样,先试着写一写。
额,这个问题有点好玩,判断一个流是不是无限流,应该跟判断一个循环是不是死循环差不多等价啊。。。这个问题就有点复杂了,我也没时间研究。不过我也尝试了一些方法。
首先,我试了试最普通的collect,count,reduce这些聚合方法。不过显然我只能得到一个死循环。。。接着我去网上找了半天,总算找到一个看似靠谱的答案,(来自coderanch.co):
1 | public static <T> boolean isFinite(Stream<T> stream) { |
这段代码输出:
1 | 1 |
也就是说,对于有限流,他能返回流的长度,对于无限流,他能返回Long类型的MAX_VALUE。他通过spliterator的estimateSize方法,做到了能够估计一段流长度。
看似也能算是一个变通。
但是,且不说当一个有限流的长度大于MAX_VALUE会怎么样,事实上这段代码也是有硬伤的,比如下面的代码:
1 | System.out.println(Stream.of(1,2,3).limit(1).spliterator().estimateSize()); |
他们的返回结果并不是我们想象的1 1
而是3 9223372036854775807
。。。显然这个方法也不能准确的保证流的长度。。。看上去好像是因为spliterator处理的对象是没有考虑limit的。。。
从本质上讲,无限流其实就是一个迭代器,除非我们能判断迭代器是有终点的,否则我们是无法判断这个流是不是有限流。具体原因可以参照下面一题的解法。
难怪作者大佬说这不是一个Good idea。。。
第八题
编写一个方法
public static <T> Stream<T> zip(Stream<T> first,Stream<T> second)
,依次调换流first和second中元素的位置,直到其中一个流结束为止。
这道题又是翻译的锅,所谓的“调换”意思其实是交替获取。。。看来再看翻译书的时候还是要注意身边放一个原文的。。。
虽然意思清楚了,但是这道题要真正写的好还是很有难度的,主要的坑点在于如何处理无限流的情况。因此我们不能将流读取到list等容器,只能以迭代器的方式进行操作。而且我们在使用普通的转换函数的时候也是不太方便终止当前流的。所以这就需要用到一些平常比较少用的工具类了。
这种zip操作可以帮我们更好的理解流的本质。我们可以看到,将两个无限流进行zip的函数竟然是可以直接返回的,这就说明这个运算一定是lazy的,即只有当取道这个流的时候才会去进行获取下一个值,而这就是迭代器的特征。
参考了stackOverflow的代码,我的zip代码该代码如下:
1 | import java.util.Iterator; |
输出结果如下:
1 | 1 2 3 4 5 6 |
可以看到这个函数是可以支持有限流和无限流的。
第九题
将一个
Stream<ArrayList<T>>
中的全部元素连接为一个ArrayList。试着用三种不同的聚合方式来实现。
这道题就是总结reduce的三种用法,注意每种用法的用途和特点。
1 | import java.util.ArrayList; |
第十题
编写一个可以用于计算
Stream<Double>
平均值的聚合方法。为什么不能直接计算出总和再除以count()?
暂且没有找到更加方便的利用聚合函数进行计算的方法,因为不太方便处理这个count。当然,直接转化成数组来处理就不再考虑中吧。
显然不能直接计算count,因为这就会导致流的终止,无法继续下面的计算了。除非完全拷贝这个流,但是这样效率就特别低了。。。
比较中庸的办法是创建一个对象,让他记录这个count信息:
1 | import java.util.stream.Stream; |
但是这样的代码有点冗长,我们其实可以引入一个原子类来记录这个count:
1 | import java.util.concurrent.atomic.AtomicInteger; |
我们这里用了两种方法,原则上都可以,只是方法一更加适合比较长的流,数字不容易溢出,方法二速度更快一点。
第十一题
我们应该可以将流的结果并发收集到一个
ArrayList
中,而不是将多个ArrayList
合并起来。由于对集合不相交部分的并发操作是线程安全的,所以我们假设这个ArrayList
的初始大小即为流的大小。如何能做到这一点?
这题没怎么看懂,好像就是考察原子类。。。
1 | import java.io.IOException; |
第十二题
如第2.13节所示,通过更新一个
AtomicInteger
数组来计算一个并行Stream<String>
宏的所有短单词。使用原子操作方法getAndIncreament来安全的增加每个计数器的值。
简单考察原子类操作。
1 | import java.io.IOException; |
第十三题
重复上一个练习,这次使用collect方法、Collectors.groupingBy方法和Collectors.counting方法来过滤出短单词。
巩固Collectors的分组方法等。
1 | import java.io.IOException; |
参考资料
常用免费前端公共库 CDN 服务收集
Nexus私服配置以及分组的应用
前言
阴差阳错的实习部门分到了Agile Controller Campus的运维(或许吧)部门,部门主要任务大概是控制构建自动化测试的流水线,控制项目各个模块的项目进展以及维护版本一致等。本来指望能进来敲敲代码的,结果发现在这里基本不用写代码,主要是使用内部软件做好项目的配置工作,这倒是我之前一直没有接触过的工作,接触了两天,感觉还是挺有意思的,能从项目的上层了解到整个项目的架构,从整体上理解一个大型团队的工作方式,这里面的学问不比码代码少。
我接到的第一个任务就是一个实际中出现的问题。
问题
我们知道在大型的Java项目中,经常是一个小组负责一个模块,一个项目由好几个模块组成。如果各个模块之间有着某种依赖关系,那么在互相调用的时候,一定是要调用其他模块的稳定版本(即上一次测试通过的版本),在自己调试的时候,测试和发布的是自己的不稳定版本。显然,这就要求各个模块在开发的时候需要相互隔离,最简单的做法就是对每一个小组开一个nexus私服用来托管其他模块的包以及自己开发的包,每此集成测试时都从各个私服中取出最新的模块,测试通过后再同步更新给各个私服。
显然,这样的话每一个小组都得维护一个自己的私服,每次集成时也要从各个私服去取,既浪费资源,而且脚本写起来比较非常麻烦。
解决思路
事实上,Nexus作为”世界第一也是唯一的免费通用仓库管理解决方案”,对这类问题有着非常好的解决方式。当然首先,我们得理清楚maven的版本控制层次。
在maven的世界里,原则上所有的包都由他的三维唯一确定:groupId,artifactId,version。但是,其实还有另外一个维度,那就是repository。这也很好理解,毕竟并不是所有的包都发布在全球统一的仓库,还有各个组织或个人私自搭建的仓库,用来托管自己的包。只要我们指定从不同的仓库里获取包,我们就可以做到即使是相同的三维,也有可能获取到不同的代码。
在Nexus里,至少提供了四种不同类型的仓库:
宿主类型(hosted)
hosted类型表示这是我们自己搭建的仓库,我们可以向这个仓库里添加自己需要的包,就像是搭建一个属于自己的中心仓库一样。
代理类型(proxy)
proxy类型存在的意义在于,在使用自己的私服时,如果仍需要从maven中心仓库去获取公开的包的时候,我们可以不需要额外指定中心仓库,而是使用这个代理类型的仓库,并且让这个仓库去指向那个中心仓库。默认配置的中心仓库名是central,指向远程的中心仓库”https://repo1.maven.org/maven2/"。这个类型的仓库原则上不能配置自己的包,他只能作为访问远程仓库的代理来使用。
虚拟类型(virtual)
这个类型的仓库一般不怎么用到,他主要是为了保证不同类型仓库之间的兼容。比如我们可以用它将一个maven2类型的仓库转成maven1类型的仓库。
组类型(group)
这是最能体现Nexus灵活性的一个配置。上面的这些类型的仓库,无论哪一种,其实都是实实在在的仓库,他们分别都能很好的独立使用,但是如果我们需要配置一个仓库,这个仓库里包含不同类型的仓库,那么我们就需要配置一个组,用这个组来包含不同类型的仓库。而这个组类型本身保存的并不是实实在在的仓库,而是对这些仓库的引用。
需要注意的是,组类型可以嵌套,但是他们不存在层级关系。也就是说如果group1包含了repo1,repo2,group2,那么实际上group1就直接包含了repo1,repo2和group2里的所有包,而且并没有这些中间的层级。
那么我们要做的事情就很简单了,我们只要在同一个私服上,对每一个模块开发小组新建不同的组类型的仓库,每一个小组的仓库里包含的都是自己模块的不稳定版以及其他模块的稳定版本。这样当每一次集成测试时,从每个小组的仓库组里获得最新的版本作为一个新组,对他进行测试并且通过后再用这个新组里的每个模块(稳定版本)去更新旧的模块即可。
maven配置
这里顺便记分析下maven的配置。
maven的配置文件就是settings.xml了,这个文件的基础配置大概是这样的:
1 | <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" |
各个标签的具体用法可以参考官方文档,下面大体解释下一些重要配置。
localRepository
1 | <localRepository>/path/to/local/repo</localRepository> |
这个配置指明了本地maven库的包的地址。由于我们在使用maven时,可能会经常使用一些相同的包,为了提高提取效率以及减少中心库的压力,maven在第一次从远程下载某个包的时候就会将它放在本地的库中,方便以后直接取用。需要注意的是,在某些情况下,我们需要手动删除这里的包来保证我们使用的是最新版本的包。这个配置的默认值是在用户家目录下的.m2文件夹中,非常好找。
servers
1 | <servers> |
这个配置的目的在于,当我们要向私服中去deploy自己的包的时候,显然我们至少得有写入的权限,这里就定义了已知server的用户名和密码,以及这个server的唯一ID。当我们在项目的pom.xml里添加发布的配置时,只要指定正确仓库的id就可以定位到相同id的server,并从中获取用户名和密码。
1 | <distributionManagement> |
profiles
1 | <profiles> |
profiles里指定了我们的maven获取包的远程仓库的地址,每一个仓库都有一个唯一的ID以及相应的url。我们可以保存不同的profile配置,每一个配置方案也有一个唯一的ID,我们只要在后面添加下面的配置就能够选择激活的profile配置。
1 | <activeProfiles> |
同时,我们也要注意着里面的repository标签,这个标签指定的是我们需要额外查找的仓库。比如我在搭建opendaylight环境时,maven需要查找他对应的包,但是maven中心库里默认没有opendaylight的库,那么我们就需要把这个库的地址加入这个repository标签里。这样每次搜索包的时候就可以顺便搜索opendaylight自己的repository。
还需要注意一点,就是我们只能从repositories标签里下载普通的包,不能下载插件。如果我们需要从一个仓库里下载插件,我们就需要用pluginRepositories
的标签。具体用法可以参见官方文档。
mirrors
1 | <mirrors> |
mirrors配置的意义在于,我们有时候在获取中心库的包的时候,由于网络的原因会导致下载太慢,这时候我们就想使用一些网络较好的镜像来代替那个中心库。这里的mirrorOf参数配置的就是我们希望替代的repositoryId(支持多选,反选等等)。将镜像地址保存在url里。
特别配置mirror的时候尤其需要小心,那就是我们使用了mirror之后很有可能覆盖了我们不想覆盖的repository,因此我们尤其要注意这个mirrorOf标签的值。
proxies
1 | <proxies> |
proxies配置主要是用来在某些情况下通过配置用户密码,做到使用代理访问远程仓库。主要用到的场景大概就是远程仓库库被GFW墙了或者本机没有外网权限,等需要使用代理的地方。
Nexus脚本思路
有时候,我们可能需要一次行建大量的仓,或者需要重复这样的操作。每次都手动点击确认的话,不仅效率地下,而且容易出错,很不Unix。其实说白了这个Nexus的界面只是一个壳,我们点击操作的背后其实是修改Nexus的配置文件。这个配置文件通常是在$NEXUS_HOME/sonatype-work/nexus/conf/nexus.xml
这里。
大概看一看:
1 | <?xml version="1.0" encoding="UTF-8"?> |
这样一看就很清楚了,我们所有的手动操作其实都是映射到这个文件中。只是需要注意当我们手动修改了这个配置文件之后,我们得重启Nexus服务。。。
于此同时,我们也要注意到,$NEXUS_HOME/sonatype-work/nexus/storage
文件夹里存放了各个仓库的包,我们也可以将我们需要的包手动添加进来。
参考资料
继多说、网易关停之后该何去何从(网易云跟帖宣布2017年8月1日停止服务)
继多说(多说关闭想必大家都已经知道了)关闭之后,很多人包括我,都转移到了网易云跟帖,现在网易云跟帖也正式发出公告宣布于 2017 年 8 月 1 日停止服务。
写给大忙人的JavaSE8书后习题简析-第一章
lambda表达式
第一题
Arrays.sort方法中的比较器代码的线程与调用sort的线程是同一个吗?
是的,看下源码就知道了。
Arrays.sort默认采用的是TimSort的方法(即传统Mergesort的优化版本)。当用户指定了一个Comparator时,他会同步的回调这个Comparator的compare方法来作为比较的参照,因此显然这里并不存在多线程的问题。
事实上在JDK1.8以后就提供了并行的排序方法Arrays.parallelSort。
不过需要注意的是,经过测验,在小数量集上并行排序的速度反倒不如非并行的快,这主要受数量级大小以及并行的核心数的影响。在通常的4核PC机中,这个转折点大约在1e6这个数量级左右。
第二题
使用java.io.File类的listFiles(FileFilter)和isDirectory方法,编写一个返回指定目录下所有子目录的方法。使用lambda表达式来代替FileFilter对象,再将它改写为一个方法的引用。
这道题主要就是考察lambda的基本用法,以及于其他方法的对比。
1 | import java.io.File; |
第三题
使用java.io.File类的list(FilenameFilter)方法,编写一个返回指定目录下、具有指定扩展名的所有文件。使用lambda表达式(而不是FilenameFilter)来实现。他会捕获闭合作用域中的哪些变量?
这道题主要考察捕获外部变量。
1 | import java.io.File; |
捕获了suffix变量。
第四题
对于一个指定的File对象数组,首先按照路径的目录排序,然后对每组目录中的元素再按照路径名排序。请使用lambda表达式(而不是Comparator)来实现。
这道题翻译的很差劲,我特地找了下英文原版的题目,发现完全是两个问题。。。原版题目如下:
Given an array of File objects, sort it so that the directories come before the files, and within each group, elements are sorted by path name. Use a lambda expression, not a Comparator.
这样就很清楚了。
1 | import java.io.File; |
第五题
从你的项目中选取一个包含一些ActionListener、Runnable或者其他类似代码的文件。将他们替换为lambda表达式。这样能节省多少行代码?替换后的代码是否有更好的可读性?在这个过程中你使用了方法引用吗?
没有类似的项目,不过显然能节省不少的代码,可读性也会有所提高。如果使用了方法引用,那么可读性和简洁性会进一步提高。
第六题
你是否讨厌在Runnable实现中处理检查器异常?编写一个捕获所有异常的uncheck方法,再将它改造为不需要检查异常的方法。例如:
1 | new Thread(uncheck( |
主要考察@FunctionalInterface接口的用法,利用这个接口可以很方便的对函数类型的变量进行封装。
1 | public class Task7 { |
第七题
编写一个静态方法andThen,它接受两个Runnable实例作为参数,并返回一个分别运行这两个实例的Runnable对象。在main方法中,向andThen方法传递两个lambda表达式,并运行返回的实例。
巩固函数接口的使用。
1 | public class Task7 { |
第八题
当一个lambda表达式捕获了如下增强for循环中的值时,会发生什么?这样做是否合法?每个lambda表达式都捕获了一个不同的值,还是他们都获得了最终的值?如果使用传统的for循环,例如
for (int i=0;i<names.length;i++)
,又会发生什么?
1 | String[] names = { "Peter", "Paul", "Mary" }; |
考察lambda的变量捕获,这里如果采用上面的增强for循环是不会有问题的:
1 | public class Task8 { |
但是,这里是不允许使用传统for循环的,否则他会报如下错误:
1 | Error:(16, 56) java: local variables referenced from a lambda expression must be final or effectively final |
显然,lambda表达式里取到的这个i由于发生了变化,不再是一个“有效的final值”,不满足lambda“被引用变量是不能修改的”这一规范。
第九题
编写一个继承Collection接口的子接口Collection2,并添加一个默认方法
void forEachIf(Consumer<T> action, Predicate<T> filter)
,用来将action应用到所有filter返回true的元素上。你能够如何使用它?
这里主要考察一些常见的函数式接口以及default函数声明。
1 | import java.util.Collection; |
第十题
浏览Collections类中的方法。如果哪一天你可以做主,你会将每个方法放到哪个接口中?这个方法会是一个默认方法还是静态方法?
不是很清楚其中的道理,不敢瞎说。。。
第十一题
假如你有一个实现了两个接口I和J的类,这两个接口都有一个void f()方法。如果I接口中的f方法是一个抽象的、默认或者静态方法,并且J接口中的f方法是也一个抽象的、默认或者静态方法,分别会发生什么?如果这个类继承自S类并实现了接口I,并且S和I中都有一个void f()方法,又会分别发生什么?
这两个接口之间的搭配有点复杂,不过经过测试,我总结了下面的几个规则:
- 只要有一个接口中是抽象函数,那么这个类必须要重载这个函数重新实现。
- 如果一个是静态函数一个是默认函数,那么,最终显示出来的是默认函数的特性。
至于既有extends又有implements的情况。。。有点麻烦,也就不一个一个测了,到时候用到再测吧。。。
第十二题
在过去,你知道向接口中添加方法是一种不好的形式,因为他会破坏已有的代码。现在你知道了可以像接口中添加新方法,同时能够提供一个默认的实现。这样做安全程度如何?描述一个Collection接口的新stream方法会导致遗留代码编译失败的场景。二进制的兼容性如何?JAR文件中的遗留代码是否还能运行?
所谓安全问题大概就是指对于旧的版本,忽然多出一个可以执行却没有啥作用的函数,略微违背了”封装隐藏”的思想。但是在旧的版本中,他们并不知道这个函数的存在,所以我觉得一般情况下也不存在什么安全问题吧。
第二问不是很清楚。。。
参考资料
重新介绍 JavaScript(JS全面系列教程)
博客统计报告(2016.11.26-2017.07.02)
我的博客到现在快 300 天了,期间我的域名换了两次,博客程序也换了 Hexo。如今我即将使用 Google Analytics 统计访问数据,本文将公布从 2016.11.26 至 2017.07.02 的访问情况。
记在github中开发项目的正确姿势-nodejs项目为例
前言
前几天随便写了一个hexo小插件,这几天刚好考完期末考试,趁着实习前没啥事,于是又拿来看看,想想有什么可以改进改进的。为了发散思路,我就把hexo.io的插件列表里的插件基本上从头到尾看了一遍。这个不看不知道,看完之后我发现其实里面的内容质量也是参差不齐的,好一点的呢,开发、测试、集成、样例、徽章都十分齐备,文档简明扼要,一看就是专业玩家;差一点的呢,基本都没有集成,没有测试,没有徽章,文档简陋或者啰嗦,有的issue满天也没人处理,有的build failure也不解决,更有的连repository都404了。。。看上去hexo的社区似乎在走下坡路了,毕竟博客这种东西,本来能坚持下来的人就不多,用户流失日益严重,而且hexo本身学习门槛也比较高,况且像这种项目还没有金主爸爸养,坚持维护也挺不容易的。
额。。。先不议论别人,还是先想办法提高提高自己项目的逼格吧。。。
修炼
有了目标就好办了,照着目标一点点前进就好了,下面就一个一个处理喽。
代码质量审查
说实话,虽然我这学期刚刚学了软件测试的课,但是其实大学的课程还是跟实际脱节很严重,学完课程之后对很多概念也没有切身的体会,直到自己真正上手写代码的时候才发现软件测试其实是在团队项目中非常实用的。
在这里,最常见的js代码质量审查工具就是Eslint。
我们为什么要使用这个工具呢,这其实是为了更好的统一代码的编写规范。比如我们知道局部变量要用var来声明,字符串最好用单引号括,字典中最后一组后最好不要加逗号,不要写多余的分号等等很多约定俗成的最佳方案,但是如果我们不遵守这样的规定,显然也能照常解释执行,不过他的可维护性以及可读性肯定要比遵守这套最佳实践规范的代码低很多。
因此,为了整体提高代码质量,最好的方法就是用错误提示来强行要求我们采用最佳实践的规范,这就是Eslint这类工具的主要用途。
这让我想起了我在刚开始学C语言的时候,老师都建议我们,如果需要在if语句里面判断一个变量是否等于另一个数,最好采用if(0==x)
而不是if(x==0)
。说白了他的目的也是用错误提示来保证我们代码不容易出错,虽然我从来都不照他这么写。。。
当然,Eslint实际做的事情要复杂的多,他能够分析es5,es6之类的语法,在不运行代码的情况下分析出很多语法错误,可以说是非常方便了。
至于Eslint的具体用法我就不班门弄斧了,官网以及各种论坛都讲的很全,想用的时候随时学习就好了。
代码风格检查
说起代码风格,虽然看上去好像影响并不大,但是总会有些奇葩会采用非常神奇的缩进风格以及各种奇怪的对齐方案,总让人看的不舒服。不过还好,现在有很多IDE都能够自动进行代码格式化,所以从很大程度上将,代码风格应该没有太大问题。不过为了保证风格统一,减少编码人员带来的不确定性,我们一般都会做代码风格检查。
在这里,最初常见的js代码风格检查工具就是Jscs。
这个Jscs大概做啥呢,举个例子,就是他会强行要求我们正确的缩进,代码块结束必须换行,且不能多次换行,函数声明()前要有空格,键值对前后要有空格,三目运算符的每一个符号前后必须要有空格等等。几乎牵涉到代码风格的问题他都能检查出来,而且这些风格也都可以进行配置,他也提供了很多大厂的优秀配置方案供我们选择。
基本上通过Jscs审查后的代码,至少从视觉上看都还是比较赏心悦目的。
不过仔细想想,其实Jscs的很多功能跟Eslint有重复,可以说从某种程度上讲,Jscs是Eslint的子集。最近打开Jscs的官网,发现果然Jscs即将要合并到Eslint里了。
代码测试
这个代码测试就不用多说了吧,重要性不言而喻,无论是理论上还是实践上,都非常重要。
在这里,最常见的js测试工具就是Mocha了,基本上所有的Nodejs工具书里都会讲,这大概算得上是基本功吧。
虽然很多情况下,我们自己写的测试用例都比较弱,但是通过了测试的代码至少是能跑起来的,不会出现太大的问题。毕竟软件的测试用例跟各种OJ的测试用例不一样,后者基本没有迭代,而且代码量很短,因此我们对软件测试用例的要求也不用太高吧~
代码覆盖率检查
这个代码覆盖率检查大概就是帮助我们写出更好的测试用例的,基本上是跟Mocha一起使用。
通常最常见的js代码覆盖率检查工具就是istanbul。
只能说这个工具只能作为参考,覆盖率高也不一定就代表测试的质量高,覆盖率低也不一定就代表测试质量差,毕竟如果有异常捕获的情况下,让代码覆盖率上去也不是一件容易的事情。不过,作为一个完美主义者,代码覆盖率上不去总是让人有一种不舒服的感觉。。。
持续集成
说实话,持续集成这个概念我以前没怎么注意过,毕竟这个东西在简单项目里用的没啥意思(总共就commit一两次完事了有啥好集成的呢)。不过当项目需要持续维护的时候,尤其是多人维护的时候,就会发现,这类工具真特么有用。
比如,当别人pull了一个request,作为维护者得判断他的代码至少得跟当前分支兼容,并且运行起来不能报错。如果每次都下载下来手动进行测试,那得多麻烦况且每次执行的东西都一样我们完全可以写脚本来弄嘛啊写脚本也好麻烦啊~github你能不能自动帮我把每一个pull过来的request都自动跑一下测试啊啊啊。。。
我所理解的持续集成大概就是这个意思,Github也融合了持续集成的服务,这就是Travis CI。
只要将Github账号绑定Travis CI,并且勾选需要持续集成的repo,在项目中配置好配置文件,那么只要项目有了新的commit,Travis CI就会在他的虚拟机里执行测试代码,返回成功或者失败,告诉我们这一次的commit是否靠谱。
插入徽章(badge)
所谓的badge实际上就是一张与项目实时同步的图片,能够简明扼要的反映项目的各种参数特征。基本用法就是在README.md里插入类似这样的代码[![图片的alt](图片的url)](发布badge的url)
badge这种东西用好了能嗖的提升项目的逼格,用得不好就是搬起石头砸自己的脚。毕竟这个逼也不是那么好装的。下面就稍微列举下我觉得有点意义的badge。
build status
这个徽章可以从Travis CI这类的持续集成框架里搞到,每当有新的commit,他都会将值更新为最近一次的测试结果,成功则显示绿色的succcess,失败就显示红色的failure,非常醒目。
coverage
这个徽章可以从coveralls.io这类测试框架里搞到,他会显示最近一次测试中返回的代码覆盖率,并以百分比的形式显示在徽章上,覆盖率越高,颜色越绿。。。
npm package
这个徽章可以从badge.fury.io中获得,如果你的项目已经发布到了npmjs里,那么他就可以实时获得你的项目的最新版本号。
shields.io
shields.io这个网站里有非常多的徽章,可以通过这个网站找到很多你想要的徽章。我比较喜欢用npm版本徽章来显示目前项目支持的npm版本,用npm downloads之类的徽章来显示自己的包每天、每月、每年、总共的下载量等等。
Package quality
这个徽章可以在packagequality.com里获得,用来简单的评价开发者对已发布在npmjs里的项目的支持程度。他的评价标准不是代码质量,而是项目的issue处理,版本迭代次数以及下载次数等情况。具体的算法也发布在他的github主页里,可供参考。
文档质量
这个就不细说了吧,其实我也不太会写,但是我总觉的一个好的文档至少得有下面几个部分:
- 项目简介
- 安装说明
- 参数说明
- 可以有使用截图
- 可以有demo网站
- 开源证书
唔。。。我总觉的我写的README应该还能说的过去把。。
Github设置
一般情况下,我们还是认真写下显示在Github上面的项目简介,以及topic选择,这样有助于别人找到自己的项目。
如果有经历的话,还是把release版本,tag版本,wiki搞搞全。。。(不过我总觉得一般小众一点的项目还是没必要搞这些幺蛾子了)