认识 Here Document

按,2024-1-5 凌晨,回顾历年来的文字时,突然想要动一动,就 review 了一下这一篇,然后对叙述上有空白的地方做了改进,然后补充了一点点 .editorconfig 配套的文字。

就是这样。

仍然是保持原貌。

基础

HereDoc 全名叫做 Here Document,中文可以称之为 嵌入文档。对它的叫法实际上很多,here文档,hereis,here-string 等等都是它。下面是关于 Linux HereDoc 或者说 Bash HereDoc 的详细介绍。

嵌入文档是 Shell I/O 重定向功能的一种替代。我们已经知道 Shell 的 I/O 重定向是一种文件句柄的传输。例如:

1
COMMAND 1>/tmp/1.lst 2>&1

将命令的标准输出为一个文件,同时也将错误输出到同一个文件中。

而下例:

1
cat /etc/passwd | grep '^admin:'

则通过管道将前一命令的输出当做后一命令的标准输入。

基本语法

Here Document 就是标准输入的一种替代品。它使得脚本开发人员可以不必使用临时文件来构建输入信息,而是直接就地生产出一个文件并用作命令的标准输入。一般来说其格式是这样的:

1
2
3
COMMAND <<IDENT
...
IDENT

在这里,<< 是引导标记,IDENT 是一个限定符,由开发人员自行选定,两个 IDENT 限定符之间的内容将被当做是一个文件并用作 COMMAND 的标准输入。例如echo大段文本时,我们可以使用 cat file 的语法:

1
2
3
4
5
cat <<EOF
SOME TEXT
HERE
!
EOF

此例中,我们使用 EOF 短语作为限定符。

Here Document 是可以嵌套的,只要双层分别使用不同的 IDENT 限定符且保证正确的嵌套关系即可:

1
2
3
4
5
6
7
ssh user@host <<EOT
ls -la --color
cat <<EOF
from a remote host
EOF
[ -f /tmp/1.tmp ] && rm -f /tmp/1.tmp
EOT

看起来有点怪?其实还好啦。

实际上,限定符可以取得非常长,只要是字母开头且只包含字母和数字(通常,下划线和短横线也是有效的,不过根据 bash 的版本不同、宿主实现的不同,可能会有一定的出入)即可。

abs 中有一个例子,节选如下:

1
2
3
4
wall <<zzz23EndOfMessagezzz23
fdjsldj
fdsjlfdsjfdls
zzz23EndOfMessagezzz23

这是正确有效的,不过这个其实更怪一些。

Here String

在 bash, ksh 和 zsh 中,还可以使用 Here String:

1
2
$ tr a-z A-Z <<<"Yes it is a string"
YES IT IS A STRING

此时也可以使用变量:

1
$ tr a-z A-Z <<<"$var"

理解 Here String:

就地提供一个字符串,将其隐式转换为一个 Unix 文件然后提供给接收者。

例如 tr a-z A-Z <<<"$var" 就是接受 var 变量中的值以文件方式从标准输入(stdin)的途径供给给 tr 使用,tr 接收到 stdin 中的字符串后完成其例行任务(在这里是从小写转换为大写字母)后打印结果到标准输出设备(这里将会是终端窗口,屏幕显示,但是你还可以将该输出管道传输给下一个接收者,或者重定向结果到一个文件中)

不常见的用法

同时重定向标准输出

有没有可能将HEREDOC存储为一个文件?显然是可以:

1
2
3
4
cat << EOF > /tmp/yourfilehere
These contents will be written to the file.
        This line is indented.
EOF

你可能也会注意到这种写法不同于经常性的写法:

1
2
3
cat >/tmp/1<<EOF
s
EOF

但两者都是对的。

1
2
3
cat <<EOF
s
EOF >/tmp/1
get root (sudo)

但当需要 root 权限时,’>’ 并不能很好地工作,此时需要 sudo tee 上场:

1
2
3
cat <<EOF | sudo tee /opt/1.log
s
EOF
child shell

标准输出的重定向,还可以通过子 shell 的方式来构造:

1
2
3
4
5
6
7
8
9
10
11
12
(echo '# BEGIN OF FILE | FROM'
 cat <<- _EOF_
        LogFile /var/log/clamd.log
        LogTime yes
        DatabaseDirectory /var/lib/clamav
        LocalSocket /tmp/clamd.socket
        TCPAddr 127.0.0.1
        SelfCheck 1020
        ScanPDF yes
        _EOF_
 echo '# END OF FILE'
) > /etc/clamd.conf

这个例子只是一个示意,因为实际上该例子用不着那么麻烦,单个 cat HEREDOC 足够达到目的了,也不需要开子 shell 那么重。

cat <<EOF 的少见的变形
1
2
3
4
5
6
7
let() {
    res=$(cat)
}

let <<'EOF'
...
EOF

元芳,你怎么看?

还可以写作这样:

1
2
3
4
5
6
7
let() {
    eval "$1"'=$(cat)'
}

let res<<'EOF'
...
EOF

当然,其实它和单行指令是等效的:

1
2
3
{ res=$(cat); } <<'EOF'
...
EOF

{} 是语句块,而不是子shell,因而更省力。根据具体情况来使用它,有时候你希望子 shell 的变量无污染的效果,或者别的期待,那你就使用 ()

在参数展开语法中使用 HEREDOC
1
2
3
4
5
6
7
variable=$(cat <<SETVAR
This variable
runs over multiple lines.
SETVAR
)

echo "$variable"

示例展示了在 $() 语法中可以随意地嵌入 HEREDOC。

如果你只是需要为变量用 HEREDOC 赋值,read var 通常是更好的主意:

1
2
3
4
read i <<!
Hi
!
echo $i  # Hi
对函数使用 HEREDOC
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
GetPersonalData () {
  read firstname
  read lastname
  read address
  read city 
  read state 
  read zipcode
} # This certainly appears to be an interactive function, but . . .


# Supply input to the above function.
GetPersonalData <<RECORD001
Bozo
Bozeman
2726 Nondescript Dr.
Bozeman
MT
21226
RECORD001


echo
echo "$firstname $lastname"
echo "$address"
echo "$city, $state $zipcode"
echo

可以看到,只要函数能够接收标准输入,那就可以将 HEREDOC 套用上去。

匿名的 HEREDOC
#!/bin/bash
# filename: aa.sh

: <<TESTVARIABLES
${UX?}, ${HOSTNAME?} | ${USER?} | ${MAIL?}  # Print error message if one of the variables not set.
TESTVARIABLES

exit $?

这个示例中,如果变量没有被设置,则会产生一条错误消息,而该 HEREDOC 的用处实际上是用来展开要确认的变量,HEREDOC产生的结果作为 : 的标准输入,实际上被忽略了,最后只有 HEREDOC 展开的状态码被返回,用以确认是不是有某个变量尚未被设置:

1
2
3
$ ./aa; echo $?
./aa: line 3: UX: parameter null or not set
1

由于 UX 变量缺失,因此调用的结果是一行错误输出,以及调用的退出码为 1,也就是 false 的意思。

:true 命令的同义词。就好像 .source 命令的同义词一样。

进一步

除了用来一次性检测一大批变量有否被赋值的效果之外,匿名的 HEREDOC 也常常被用作大段的注释。

1
2
3
4
5
6
cat >/dev/null<<COMMENT
...
COMMENT
: <<COMMENT
...
COMMENT

这些写法都可以,看你的个人喜好。Bash 程序员的一般风格是能省键盘就省键盘。但有时候他们也喜欢能炫就炫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
:<<-!
  ____                 _    ____                 _
 / ___| ___   ___   __| |  / ___| ___   ___   __| |
| |  _ / _ \ / _ \ / _` | | |  _ / _ \ / _ \ / _` |
| |_| | (_) | (_) | (_| | | |_| | (_) | (_) | (_| |
 \____|\___/ \___/ \__,_|  \____|\___/ \___/ \__,_|

 ____  _             _
/ ___|| |_ _   _  __| |_   _
\___ \| __| | | |/ _` | | | |
 ___) | |_| |_| | (_| | |_| |
|____/ \__|\__,_|\__,_|\__, |
                       |___/
!
while read

当我们需要读一个csv文件时,我们会用到 while read 结构。

将 csv 文件改为 HEREDOC:

1
2
3
4
5
6
7
8
9
while read pass port user ip files directs; do
    sshpass -p$pass scp -o 'StrictHostKeyChecking no' -P $port $files $user@$ip:$directs
done <<____HERE
    PASS    PORT    USER    IP    FILES    DIRECTS
      .      .       .       .      .         .
      .      .       .       .      .         .
      .      .       .       .      .         .
    PASS    PORT    USER    IP    FILES    DIRECTS
____HERE

由于不同格式的 CSV 的处理并非本文的主题,因此这里不再展开讨论具体情况了。

补充:循环的重定向

对于 while … done 来说,标准输入的重定向应该写在 done 之后。同样的,for … do … done 也是如此,until … done 也是如此。

while

1
2
3
4
5
6
7
while [ "$name" != Smith ]  # Why is variable $name in quotes?
do
  read name                 # Reads from $Filename, rather than stdin.
  echo $name
  let "count += 1"
done <"$Filename"           # Redirects stdin to file $Filename. 

until

1
2
3
4
5
until [ "$name" = Smith ]     # Change  !=  to =.
do
  read name                   # Reads from $Filename, rather than stdin.
  echo $name
done <"$Filename"             # Redirects stdin to file $Filename. 

for

1
2
3
4
5
6
7
8
9
10
for name in `seq $line_count`  # Recall that "seq" prints sequence of numbers.
# while [ "$name" != Smith ]   --   more complicated than a "while" loop   --
do
  read name                    # Reads from $Filename, rather than stdin.
  echo $name
  if [ "$name" = Smith ]       # Need all this extra baggage here.
  then
    break
  fi  
done <"$Filename"              # Redirects stdin to file $Filename. 

新的缩进和对齐语法

在批处理文件、bash 脚本文件中你会遇到这样的问题,在函数中(或者只要发生了缩进的情况)使用 HEREDOC 时每一行的前面的空格会引发需求之外的结果:

1
2
3
4
5
6
7
#!/usr/bin/env bash

if true ; then
    cat <<EOF > /tmp/yourfilehere
    The leading spaces are kept.
EOF
fi

在这里,“The leading spaces are kept.” 的前置字符均被保留结束标记 EOF 必须位于行首,否则语法识别会出错。

这就导致很多尴尬的问题:首先我不希望 EOF 乃至于每一行都从第一列开始排布,然后如果每一行都缩进了,前置的空格我又不想要,而且还无法解决 EOF 的缩进问题。

这就是下面的新语法被引入的原因了。

删除 TAB 缩进字符

<<-IDENT 是新的语法,市面上的 Bash 均已支持这种写法。它的特殊之处就在于 HEREDOC 正文内容中的所有前缀 TAB 字符都会被删除。

这种语法往往被用在脚本的 if 分支,case 分支或者其他的代码有缩进的场所,这样 HEREDOC 的结束标记不必非要在新的一行的开始之处不可。一方面视觉效果上 HEREDOC 跟随了所在代码块的缩进层次,可读性被提升,另一方面对于许多懒惰的编辑器来说,不会发生面对 HEREDOC 时语法分析出错、代码折叠的区块判断不正确的情况。

1
2
3
4
5
6
7
8
9
10
11
12
function a () {
    if ((DEBUG)); then
        cat <<-EOF
        French
        American
          - Uses UTF-8
        Helvetica
          - Uses RTL
          
        EOF
    fi
}

如上的脚本段落中,结束标记EOF可以不必处于行首第一个字母,只要EOF以及其上的HEREDOC正文都以TAB字符进行缩进就可以了。

注意如果TAB字符缩进在这里没有被严格遵守的话,Bash解释器可能会报出错误。

像在正文中的 - Uses UTF-8 除开行首的 TAB字符缩进之外,还包含两个空格字符,这不会受到 <<- 的影响而被删除。

因为这个新语法的原因,现在为编辑器提供 .editorconfig 文件时应该采用这样的片段:

[*]
# charset = utf-8
# end_of_line = lf
# indent_size = 4
# indent_style = space
# insert_final_newline = false
# max_line_length = 120
# tab_width = 4
trim_trailing_whitespace = false

[*.{bash,sh,zsh,fish}]
indent_size = 2
indent_style = tab
tab_width = 2

[{.zshrc,.zprofile,.zshenv,zshrc,zprofile,zshenv,.bashrc,.bash_profile,.profile}]
indent_size = 2
indent_style = tab
tab_width = 2

其中脚本文件最重要强调的是缩进自动采用 Tab 字符。

在支持 .editoconfig 的编辑器中配合这样的设置能够让你在无需额外干预的情况下连续键击和编码,而在以新语法输入 heredoc 内容时,能够自动缩进到恰当的位置,能够在存盘格式化时自动将前置的空格以最优解的方式转换为 tab 字符来保持缩进效果满足键击输入时的效果。

口头上的解说较为苍白,我也不想为此录制 gif,所以你可以在 vscode/idea/goland 等新式编辑器中自行试验。

禁止变量展开

一般情况下,HEREDOC 中的 ${VAR}$(pwd)$((1+1)) 等语句会被展开,当你想要编写 ssh 指令时,可能你希望的是不要展开 $ 标记。

这可以用 <<"EOF" 来实现。

只需要在 IDENT 标记上加上引号包围就可以达到效果,结束标记则无需引号。

1
2
3
4
5
6
7
8
cat <<"EOF"
Command is:
  $ lookup fantasy
EOF
# 如果不想展开,则你需要对 $ 字符进行转义
cat <<EOF
  \$ lookup fantasy
EOF

这个例子中,请注意单个的 $ 字符其实是不会展开也不会报错的,所以我们只是为了编写一个示例而已。

引号包围呢,单引号、双引号都可以,都会同样地生效。

甚至,你可以使用转义语法,也就是说:

1
2
3
4
cat <<\EOF
Command is:
  $ lookup fantasy
EOF

也能禁止参数展开。

同时应用上两者

上面两个新的语法特性,是可以被同时组合和运用的:

1
2
3
4
    cat <<-"EOF"
		Command is:
  		  $ lookup fantasy
    EOF

虽然你可能根本不需要遇到这样的情形。

参考

留下评论