Gong Yong的Blog

bash最佳实践1:操作文件

这个系列的文章是Peteris Krumins写的One-Liners Explained系列的Bash部分,这个系列讲解了一些bash的最佳实践、通用方法和小技巧,以及怎么高效使用bash的内置命令。

One-Liner的意思是简短。正如其名,这个系列的内容组织都比较简短,基本上每个用法、操作、技巧、知识点都是通过一行命令作为示例来讲解,我个人很赞赏这种组织内容的方式。

这一章是这个系列的第一部分,主要是讲解bash中跟文件相关的一些东西。

1 清空一个文件(文件大小变为0)

$ > file

">"是输出重定向符。输出重定向会打开文件并执行写操作。当文件不存在的时候会创建一个新文件,如果存在的话,则将标准输出的内容重定向到文件中。

上面的命令没有将任何东西重定向到文件中,所以最终文件的内容为空,文件大小为0,这是因为每次使用输出重定向的时候文件被打开的时就会被清空。

下面的命令会将字符串"string"写入文件,或者创建一个内容为"string"的文件:

$ echo "some string" > file

2 在文件尾部追加内容

$ echo "foo bar bas" >> file

">>"是另外一种输出重定向符,使用这个重定向符每次打开文件的时候不会清空文件,并且输出追加到文件尾部。如果文件不存在,则会新建一个。

通常新的内容将以一个新的行追加到文件尾部,你可以使用-n选项取消以新行的方式追加,而是直接追加到文件的最后一个字符后面。

3 读取文件的第一行并将其赋值给一个变量

$ read -r line < file

这个命令使用了bash的内置命令read,以及输入重定向操作符"<"。

read命令会从标准输入读取一行,并将其赋值给变量line。-r选项表示read将读取原生内容,所有字符都不会被转义,例如反斜线不会用于转义(只是反斜线)。输入重定向命令"<file"会打开文件并执行读操作,并且会将读取的内容以标准输入的形式提供给read命令。

read命令会删除特殊变量IFS所表示的字符。IFS是Internal Field Separator(内部字段分隔符)的缩写,它的值为用于分隔单词和行的字符组成的字符串。IFS的默认值为空格符、制表符和换行符组成的字符串。这意味着前导和尾随的空格符和制表符都会被删除。如果你想保留这些字符,你可以将IFS设置为空字符:

$ IFS= read -r line < file

只有在这个命令执行过程中IFS值会改变,执行完后会重设为默认值。上面的命令绝对会读取第一行的原生内容,包含所有的前导和尾随的空白符。

另外一种将文件的一行内容读取到变量中的方法是:

$ line=$(head -1 file)

这里用到了命令替换操作符"$(...)",它会执行"..."所代表的命令,并返回这个命令的输出。

head -1 file会输出文件的第一行,它会被赋值给变量line。"$(...)"跟"`...`"的用法完全一致,所以你也可以这么写:

$ line = `head -1 file`

我个人更喜欢"$(...)"这个写法,更简洁,易于嵌套。

4 逐行读文件

$ while read -r line; do
      # do something with $line
    done < file

首先请记住上面的脚本是唯一正确的逐行读文件的方法

read命令位于一个while循环的条件语句中,所以只有当read命令返回大于0的状态码(这表示读文件出错或者已到文件尾部)时循环才会终止,如果成功读取一行则会执行循环中的内容。

记住read命令会删除前导和尾随的空白符,所以如果你希望保留它们,则需要清空IFS变量的值:

$ while IFS= read -r line; do
      # do something with $line
    done < file

如果你不喜欢在done语句的后面使用"<file",你可以使用cat命令和管道命令将文件内容传输到while循环。

$ cat file | while IFS= read -r line; do
      # do something with $line
    done < file

5 随机读取一行并赋值给变量

$ read -r random_line <  <(shuf file)

如果仅仅使用bash的内置命令无法实现这个功能,必须使用外部工具。GNU核心工具包中有一个名为shuf的命令可以满足我们的要求。

上面的命令中还使用到了进程替换操作符"<(...)"。进程替换操作符会创建一个匿名管道,将这个进程的标准输出连接到这个管道。然后bash会执行这个进程,并且会将整个进程替换为匿名管道的文件名。

当bash看到"<(shuf file)"时,它会打开一个特殊文件/dev/fd/nn是一个空闲的文件描述符,当命令执行的时候shuf file的标准输出会连接到/dev/fd/n,"<(shuf file)"会被/dev/fd/n替换,于是上面的命令就变成了:

$ read -r random_line < /dev/fd/n

它会读取顺序被打乱后的文件的第一行。

还有一种方法可以实现这个功能,使用GNU的sort命令。sort命令的-R选项会将文件内容随机打乱输出。

$ read -r random_line < <(sort -R file)

还有一种方法:

$ rand_line=$(sort -R file | head -1)

这个命令先使用sort -R随机打乱文件内容,然后使用head -1获取第一行。

6 读取文件的前三列(前三个字段)并赋值给三个变量

$ while read -r field1 field2 field3 throwaway; do
    # do something with $field1, $field2, and $field3
   done < file

read命令中包含多个变量的时候,它会将读取的一行内容分割为多个字段(IFS中的字符为分隔符),然后将第一个字段赋值给第一个变量,第二个字段赋值给第二个变量...,最后将剩下的所有内容赋值给最后一个变量。这也是为什么上面的脚本有4个变量,第4个变量是throwaway,如果没有这个变量,并且如果这个文件的每行内容不止被分成3列,那么第三个变量就不只是被赋值为第三列的内容,而是第三列之后的所有内容(包括第三列)。

可以使用"_"代替throwaway变量,这会让代码看起来更简洁些:

$ while read -r field1 field2 field3 _; do
    # do something with $field1, $field2, and $field3
   done < file

如果要读取的文件刚好只有三列,那就可以只用3个变量:

$ while read -r field1 field2 field3; do
    # do something with $field1, $field2, and $field3
   done < file

再来看一个具体的例子。假设我们希望得到一个文件的行数、单词数和字节数,如果使用wc命令它会输出4个字段:

$ cat file-with-5-lines
x 1
x 2
x 3
x 4
x 5

$ wc file-with-5-lines
 5 10 20 file-with-5-lines

这个文件有5行,10个单词,20个字符,文件名为file-with-5-linei。我们可以使用read命令将这些值存到变量中:

$ read lines words chars _ < <(wc file-with-5-lines)

$ echo $lines
5
$ echo $words
10
$ echo $chars
20

你也可以使用here-strings来分割字符串并赋值给变量。假设我们有一个字符串 "20 packets in 10 seconds"保存在变量$info中,你想从中提取20和10。在不久之前我会这么做:

$ packets=$(echo $info | awk '{print $1}’)
$ time=$(echo $info | awk '{print $4}’)

但是现在我的bash知识越来越丰富,我会这么搞:

$ read packets _ _ time _ <<< "$info"

"<<<"表示here-string,它允许你直接将变量的内容传给read命令的标准输入。

7. 获取文件的大小并将其存到一个变量中

$ size=$(wc -c < file)

上面的命令使用了命令替换符号"$(...)"。这个命令中的wc -c < file 会输出文件的大小(以字节为单位),然后将它赋值给变量size。

8. 从路径中提取文件名

假设你有一个文件,它的路径为/path/to/file.txt。并且你只想提出文件名file.ext。你会怎么做?

使用参数展开机制可以很优雅地解决这个问题:

$ filename=${path##*/}

上面的命令使用了${var##pattern}参数展开。所谓参数展开就是用pattern表示的正则表达式模式从$var变量表示的字符串的开头开始匹配,如果匹配成功,就删除$var字符串中匹配到的最长的字符串,剩下的就是展开的结果。

上面的命令中的模式是*/,它会从头开始匹配字符串/path/to/file.ext,它是贪婪匹配,会一直匹配到最后一个斜线(上面的命令中会匹配/path/to/),删除$path中的/path/to/,得到的结果就是file.ext

9. 从路径中提取目录

这一节的内容跟前一节类似。假设这个路径还是/path/to/file.ext,你想提取这个路径中的目录/path/to。这依旧可以使用参数展开来搞定:

$ dirname=${path%/*}

这次使用的是${var%pattern}参数展开(上一个节中的是##,这里是%)。这种参数展开会从$var表示的字符串的结尾开始匹配。如果匹配到,就删除匹配到的最短的匹配结果,剩下的就是展开的结果。

这里例子中的模式是/*,它会从/path/to/file.ext的尾部开始匹配,它是非贪婪匹配,所以只会匹配到从第一个斜线(从右往左),也就是/file.ext,匹配到的结果会被删除,所以最终输出的就是/path/to,我们想要的文件的目录。

10 快速拷贝一个文件

如果你要将一个位于/path/to/file的文件拷贝到/path/to/file_copy,我估计你肯定会这么搞:

$ cp /path/to/file /path/to/file_copy

现在我告诉你一种更酷的方法,这种方式使用了括号展开{...}

$ cp /path/to/file{,_copy}

括号展开是一种生成不同字符串的机制。在这个示例中/path/to/file{,_copy}会生成字符串/path/to/file /path/to/file_copy,所以整个命令就变成了cp /path/to/file /path/to/file_copy

使用同样的方式你也可以快速移动文件,也可以说是重命名文件:

$ mv /path/to/file{,_old}

这个会被展开为mv /path/to/file /path/to/file_old