做Shell脚本写了这么多年,我见过太多人在命令行里拼多行文本的时候,要么用反斜杠一行行续行,要么干脆现写个临时文件,为三五行内容搞出不必要的复杂度。其实Shell里早就内置了一个非常趁手的工具叫here document(不少人喊它heredoc),专门解决“把一段完整的多行内容喂给某个命令”这类需求。你写Nginx配置、导数据库脚本、往远端设备推送脚本、生成带格式的邮件正文,基本都能看到它的身影。这东西语法本身简单,上手几乎没门槛,但真正想把它用得利索、不踩坑,还需要理解它背后的几个关键细节。这篇内容我从最基础用法讲起,逐步深入到变量展开、定界符选择、Tab缩进、跟交互式命令配合的方式,再把我运维脚本里踩过的几个典型问题捋一遍,适合刚接触Shell的新手,也适合那些已经在用heredoc但偶尔被“坑”一下的中间用户。
1. 项目概述:一个“看起来低调,用起来真香”的Shell技巧
1.1 为什么需要here document
写脚本时最烦的事情之一,就是把一段结构化的多行内容传给另一个命令。以前很多人习惯用echo加反斜杠续行,写两三行还行,超过五行阅读感就崩塌了。比如往文件里写一条nginx配置:
bash复制echo "server {" > /etc/nginx/conf.d/demo.conf
echo " listen 80;" >> /etc/nginx/conf.d/demo.conf
echo " server_name demo.example.com;" >> /etc/nginx/conf.d/demo.conf
echo " location / {" >> /etc/nginx/conf.d/demo.conf
echo " proxy_pass http://127.0.0.1:8080;" >> /etc/nginx/conf.d/demo.conf
echo " }" >> /etc/nginx/conf.d/demo.conf
echo "}" >> /etc/nginx/conf.d/demo.conf
这段代码功能没问题,但你要改中间某一行的转义字符、特殊符号,头都大了。如果改用heredoc,同样的需求一屏搞定:
bash复制cat > /etc/nginx/conf.d/demo.conf <<EOF
server {
listen 80;
server_name demo.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
EOF
没有一排排的echo和>>,内容区域就是纯粹的文本本身,“所见即所得”,非常直觉。这是heredoc最大的价值:让脚本中的多行文本块保持原始结构,而不是被一堆引号和转义符打散。
1.2 适用场景与读者画像
我日常用heredoc的场景可以归成几类:第一类是生成配置文件,像nginx、Apache、systemd unit文件、Docker Compose片段,这类内容通常格式固定、行数多,用heredoc写进文件最舒服;第二类是喂给交互式命令,比如mysql执行一段SQL、psql执行查询、bc做批量数学运算;第三类是远端操作,通过ssh host "cat > remote.txt" <<EOF把一段内容推到远程机器上;第四类是循环读入数据,用while read配合heredoc逐行处理。还有一个高频用法是给脚本里的函数喂多行日志或参数。
读者画像大致有三类:刚上手Linux、写脚本经常靠复制粘贴拼字符串的新手,可以通过这篇把heredoc基础打牢;已经在用cat <<EOF但偶尔遇到“变量为什么不展开”“结束符怎么老报错”的中间用户,可以在这里把原理补上;还有就是写自动化部署脚本、想减少临时文件操作的开发者,能从后面的进阶用法里找到顺手的东西。不管是哪类,跟着例子实际敲一遍,比光看文字管用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心语法与原理拆解
2.1 最小可用示例:管道口与结束符
here document的本质,是把两个标识符之间的所有行当作标准输入,喂给<<前面的命令。最小形式长这样:
bash复制cat <<EOF
hello world
EOF
执行后终端打印出hello world。这里EOF是定界符,可以理解为“这块文本的边界标记”。命令启动时,Shell会持续读取输入,直到遇到一行内容恰好等于EOF为止。这里有两个必须强调的细节:结束符必须单独占一行,且前面不能有空格、后面不能带分号或注释。如果你写成EOF;或者 EOF(前面有两个空格),Shell找不到结束行,会一直等你输入,看起来就像是卡住了。
实际上很多新手容易混淆的一点是:EOF不是语法关键字,它只是一个约定俗成的名字。你完全可以换成END、DELIMITER、MY_TAG等任何你觉得顺口的标识符。选名字的唯一原则就是内容区域里不会出现一模一样的单行文本,否则Shell会提前结束,丢掉后半段内容。这一点后面会展开说。
2.2 为什么大家都写EOF而不写别的
定界符选择有个“行业默契”,几乎所有人都用EOF或EOL。原因很现实:它短、不会跟正文撞车、一看就懂是“文件结束”的意思。但如果你在脚本里嵌套多层heredoc,比如外层用EOF,内层就得换个名字,这时候常见的做法是用INNER_EOF、SQL_EOF这类带含义的名字,避免和外部定界符重复时Shell搞混层级。
有人可能会问:为什么不用EOB之类?都行,只要保持“定界符与内容不冲突”这条铁律。举个我踩过的真实例子:有次写脚本要给用户生成一封带赠言的邮件,正文里恰好有一行是“EOF”(前缀的空格被编辑器自动去掉了),结果邮件内容从那一行起全被截断,排查了十分钟才发现是定界符撞车。后来我给自己定了规矩:凡是正文可能包含EOF字样的场景,一律换成MAIL_BODY_END这种不容易出现的标识符。
定界符和命令之间的位置关系也有讲究。常见写法是cat > file <<EOF,也可以写cat <<EOF > file,两者在大多数情况下等价。但我个人建议顺序按cat > file <<EOF来写,因为读起来更像“往这个文件写入以下内容”,而且后续接管道符时不容易产生歧义。
2.3 引号、反斜杠与变量展开:三种形态彻底分清
heredoc最容易被忽视、也最容易出事的细节是引号对展开行为的影响。同样的定界符,写法差一对引号,结果天差地别。
无引号形态是默认行为:
bash复制name="monkey"
cat <<EOF
hello $name, today is $(date +%F)
EOF
执行后$name会被替换成monkey,$(date +%F)会执行并返回今天的日期。这在你“希望Shell代为解析变量和命令”的场景非常方便,但如果内容是纯文本,碰巧含有$或者反引号,就可能发生意外替换,破坏了原始内容。
单引号定界符形态是彻底禁写解析:
bash复制cat <<'EOF'
hello $name, don't run $(date)
EOF
输出就是字面上的hello $name, don't run $(date)。这里$name不展开、$(date)不执行,一切原样输出。手动挡的使用时机很明确:我要传给目标命令的文本里包含大量$、反引号、\时,最安全的方式就是单引号包住定界符,省去逐个转义的痛苦。
双引号定界符形态(<<"EOF")在Bash里的实际效果等同于单引号,也会禁止所有展开。Bash手册里写得很清楚:只要定界符有任何引号形式,正文中的参数展开、命令替换、算术展开就全部关闭。网上流传的“双引号能展开、单引号不能展开”的说法在heredoc里是不成立的,亲测过很多次。
反斜杠转义也是一种方式:<<\EOF等价于单引号定界符。如果你看到别人的脚本里写着cat <<\EOF,跟cat <<'EOF'是一回事。三种“禁展开”写法,选哪种纯看个人习惯,我偏好单引号,视觉上更清晰。
下面这张表可以直接照着用:
| 写法 | 变量展开 | 命令替换 | 典型场景 |
|---|---|---|---|
<<EOF |
会 | 会 | 生成配置时动态写入路径、IP、时间等变量 |
<<'EOF' |
不会 | 不会 | 需要原样保留$、反引号的代码模板或纯文本 |
<<\"EOF\" |
不会 | 不会 | 同单引号,但见得更少 |
<<\\EOF |
不会 | 不会 | 同单引号,脚本风格化的写法 |
到这里,你就理解了为什么有些人写heredoc总是遇到“内容被莫名其妙替换”:先检查定界符前头有没有引号,以及正文里的$到底是不是刻意用的。
2.4 特殊形态<<-和它的“Tab缩进”边界
有些脚本里会出现<<-EOF的写法,它的作用是:在正文每一行的行首,去掉所有连续的Tab字符。注意,是Tab,不是空格。这个特性就是为了让heredoc内容能够跟随函数缩进或if缩进,保持脚本整体排版美观。
bash复制if true; then
cat <<-EOF
line one
line two
EOF
fi
上面的脚本能正常输出line one和line two,因为行首的Tab被自动剥掉了。如果你用的是空格缩进,比如某个编辑器默认把Tab展开成4个空格,那<<-EOF就无能为力了——它不会剥空格。这是“看起来很简单,实际很坑”的典型细节。
我建议规范做法是:脚本文件统一用Tab缩进时配合<<-用,文件如果强制用空格缩进,就别指望<<-了,老老实实让正文顶格写,或者接受脚本里出现“局部不缩进”的观感。很多团队的代码规范里没有注意到这点,导致同一个脚本在不同编辑器里打开,heredoc执行结果不一样,排查起来浪费时间。
3. 典型实操场景全解析
3.1 批量写入配置文件:用cat加heredoc生成完整服务配置
生成多节配置时,heredoc的价值更加明显。拿一个常见的systemd服务文件为例:
bash复制cat > /etc/systemd/system/myapp.service <<EOF
[Unit]
Description=My Application Service
After=network.target
[Service]
Type=simple
User=www-data
ExecStart=/opt/myapp/bin/run.sh
Restart=on-failure
EnvironmentFile=-/etc/myapp/env.conf
[Install]
WantedBy=multi-user.target
EOF
这种一块一块的内容,用echo一行行追加简直是在折磨人。heredoc写法一次成型,段落之间空行随你怎么控制。要注意的是,一个常被忽略的习惯:写完文件别急着结束脚本,加上一个校验步骤。
bash复制systemctl daemon-reload
systemctl is-enabled myapp.service >/dev/null 2>&1 || systemctl enable myapp.service
生成文件和校验动作都应该成对出现,这算是我在自动化部署脚本里形成的肌肉记忆。同样手法也可以写Docker Compose的profile、Prometheus的rule文件,套路完全一致。
3.2 与交互式命令联动:SQL、计算器、数据库批量执行
heredoc最舒服的用法之一是喂给交互式程序。以前跑MySQL脚本,我要么mysql -e "sql"把SQL写成一长串,要么先把SQL存成.sql文件再导入。有了heredoc以后,直接在Shell脚本里内嵌一段清晰的SQL:
bash复制mysql -uroot -p"${DB_PASS}" <<SQL
USE test_db;
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (name) VALUES ('alice'), ('bob');
SELECT COUNT(*) FROM users;
SQL
执行时MySQL会逐行读取SQL内容,遇到SQL结束符才停下。好处很明显:SQL的真格式、真注释全保留,不用纠结转义问题。同样适合psql、sqlite3、bc、python这类支持从标准输入读取脚本的工具。
有一点需要注意:如果SQL文本里有!之类字符,且你当前的交互Shell开了histexpand(历史展开),可能会报“event not found”。这种情况建议先set +H关掉历史展开,或者干脆用单引号定界符,从根源上避开意外展开。
3.3 在循环中逐行处理heredoc内容
heredoc加while read的组合,可以优雅地造出“按行处理多行数据”的结构:
bash复制while IFS= read -r line; do
echo "处理行: $line"
done <<EOF
第一行
第二行 带空格
第三行
EOF
这里IFS=保留了行首行尾的空格,-r防止反斜杠被吃掉。两者都是细节中的细节:如果只写read line,行尾多余空格会被截掉,反斜杠序列也会被错误解析。
这个模式用来模拟“从一个文件里逐行读取并处理”,又无需真正创建临时文件,很适合在函数里使用。我写过不少导入脚本,数据源就是一段内嵌的多行记录,用这个结构一次搞定,干净不啰嗦。
3.4 配合tee实现“覆盖+保留现场”
很多场景是“既要写文件,又要让文件内容同步显示在终端”,这时候tee比cat更好用。做法:
bash复制tee /etc/some.conf <<EOF >/dev/null
key1=value1
key2=value2
EOF
把tee的标准输入接到heredoc上,它就把内容同时写到文件与标准输出。如果你不想在终端里刷出内容,就重定向到/dev/null,文件照常写入。
另一个高频组合是高权限目录的写入。比如写/etc/sysctl.conf,普通用户没有写权限时会习惯写成:
bash复制sudo cat > /etc/sysctl.conf <<EOF
...
EOF
这其实是错的。因为重定向是由当前Shell执行的,Shell尝试以你的身份打开/etc/sysctl.conf时就被Permission denied挡住了,sudo只对cat生效。正确写法用sudo tee:
bash复制sudo tee /etc/sysctl.conf <<EOF >/dev/null
net.ipv4.tcp_syncookies=1
net.ipv4.ip_forward=0
EOF
同理,往/etc/profile.d/下写环境变量脚本、往/usr/lib/systemd/system/下写unit文件,都优先考虑sudo tee。这个坑,新手和老手都容易忽略,值得记在脑子里。
3.5 远程主机上的“本地文本直接变远程文件”
自动化部署一个小功能时,我经常需要把本地生成的内容推到远端机器,而不是本地存个临时文件再scp。做法:
bash复制ssh app01 "cat > /opt/app/config.yaml" <<EOF
server:
port: 8080
timeout: 30s
logging:
level: info
EOF
原理是ssh命令的标准输入被重定向为heredoc内容,远端执行的cat > file从标准输入读取来自本地的所有行。这个用法在多机部署脚本里非常实用:一次性给5台机器下发同一段配置,循环里包一层ssh即可。
有两点经验分享:一是如果远端命令本身还需要一些变量,可以先把变量塞进命令字符串里,或者将内容通过$(cat <<EOF ... EOF)先本地拼接再传;二是内容较大时建议压缩后再传,否则带宽开销明显。普通配置文件规模不需要,但传输长达几百行的脚本片段时,先gzip再在远端解压,脚本执行速度会快不少。
4. 常见问题与排查技巧实录
4.1 定界符被正文“撞衫”
这是heredoc最经典的问题。定位方法很简单:输出内容后半段缺失,先怀疑结束符被正文某一行的相同文本提前触发了。
比如我在一个文档模板里吹嘘某个产品时,写了一句“EOF代表一切顺利”,结果整个heredoc在那一行就截断了。解决思路只有一个:选一个足够特殊的定界符。宁可写MY_CUSTOM_EOF_TAG也不要省那几个字符。
排查时可以用nl -ba给带期望内容的部分编个行号,再对照输出看断点在哪一行。手动推断、二分查找都行,但最直接的办法还是换名字。经验告诉我:正文里越可能出现的词,越不应该当定界符。
4.2 变量没展开、或者不该展开却展开了
变量相关的坑分两种表现。
第一种是“我明明写了变量,输出却是空的”。多半是因为你用了带引号的定界符,展开被整体禁掉了。解决:去掉定界符两边的引号。比如<<'EOF'改成<<EOF。不过改之前要想清楚:如果正文里的$是模板语法里的变量,不是你想让Shell展开的,那就别改。
第二种是“我没想让Shell展开,结果它把$变没了”。这是无引号定界符的副作用。解决:给定界符加单引号,或者对正文中每个特殊字符转义。举个例子,想往文件里写一行Elasticsearch的查询语法:
bash复制cat <<'EOF' > query.json
{
"query": {
"bool": {
"must": [
{ "term": { "user": "$user_name" } }
]
}
}
}
EOF
如果不用引号,$user_name会被当前Shell替换。你真正想要的可能是保留$user_name,作为应用层的模板变量。所以记住一条铁律:正文中如果出现$、反引号、\,默认先想一下——这里有引号吗?
4.3 结束符前面的空格和回车问题
很多脚本报warning: here-document delimited by end-of-file就是因为结束符那一行有不可见字符。
常见情况是Hexo博客、Markdown文件里复制代码块时,结束符后边被粘贴进了一个空格。肉眼看不出来,但Shell对定界符行的匹配非常严格:必须“整行只含定界符本身”。排查手段是:
bash复制cat -A script.sh
看到行尾出现的$符号表示有新行,如果有空格会显示为^I或空格符。cat -A能识别出不可见的Tab和行尾标记,定位这种问题特别快。我还遇到过Windows下编辑脚本把行尾换成CRLF,导致结束符实际是EOF\r,怎么都匹配不上,最后用sed -i 's/\r$//'清掉了。
4.4 生产脚本里那些“反直觉”的其他问题
除了定界符,生产环境里我还遇到过几个容易忽略的细节。
一个是heredoc和管道配合时的顺序问题。比如cat <<EOF | grep x | sort,顺序没问题,但如果写成cat <<EOF | sort > result.txt,内容经过管道后回写文件,操作不当可能会截断自己。更常见的错误是忘了重定向,内容直接打到终端,日志文件里没有体现。
另一个是heredoc内部出现!时,在开启历史展开的交互Shell里会报event not found。脚本文件不受影响,但你在终端手动粘贴执行时就会出问题。处理方法:set +H临时关掉历史展开再跑,或者内容里不用!。
还有一个比较冷门但真实存在:定界符和case里的EOF名字撞车。比如脚本里有一段:
bash复制case "$opt" in
start)
cat <<EOF
...
EOF
;;
esac
看到EOF下一行就是;;,理解起来让人精神紧绷,虽然语法上没错。我后来统一把这类内嵌文本的定界符改成BLOCK_START和BLOCK_END这种更具语义的名字,至少读脚本的人不会心惊肉跳。
5. 实操总结与我的建议
学heredoc真正重要的是把“定界符”和“引号展开”这两个核心机制刻进脑子里。定界符决定了文本边界,引号形式决定了文本是否被Shell解析。理解了这两个点,再配合cat、tee、ssh、管道、循环,几乎任何“多行文本输入”场景都有干净利落的解法。我的经验是先从小脚本开始,比如用heredoc写一个nginx的server块,生成成功后再尝试往数据库导入SQL、往远程主机推送配置。一步一步来,比背语法清单高效得多。
再分享一个我自己很喜欢的小技巧:在脚本里写通用的模板渲染函数时,把heredoc当作“模板字符串”使用。外层定义变量,内层用无引号定界符把变量带进去,生成不同环境的配置文件。例如:
bash复制render() {
local project=$1
local env=$2
cat <<EOF
api:
project: ${project}
env: ${env}
endpoints:
- https://${project}.internal.example.com/v1
EOF
}
这个函数可以被循环调用,一次性渲染多套配置,再配合tee写到不同目录。如果你也常写部署脚本、模板生成脚本,这个模式会让你的代码简洁很多。
最后交代一句:heredoc虽好用,但它不适合处理二进制内容、不适合超大文件,那些场景还是用专门的工具和文件操作更稳妥。在合适的地方用合适的工具,才是写脚本的长久之道。
