
1. 项目缘起一个被重复操作折磨出来的脚本你有没有经历过这样的场景手头有一个文件夹里面散落着几十甚至上百个文本文件可能是日志、代码片段、配置文件或者是从某个系统导出的零散数据。老板或者同事突然跟你说“把这些文件里的内容按顺序合并成一个发给我。” 你打开文件夹看着密密麻麻的文件列表深吸一口气然后开始机械地操作打开第一个文件全选、复制新建一个文档粘贴再打开第二个复制切回文档粘贴…… 重复几十次后不仅手腕发酸还生怕漏掉或者重复了某个文件。几年前我就被这样一个看似简单却极其繁琐的任务折磨过。当时需要合并一批服务器日志进行分析文件夹里有超过200个按日期命名的.log文件。手动操作了十几个之后我就意识到这绝不是一个可持续的方法不仅效率低下而且极易出错。作为一个经常和命令行打交道的开发者我的第一反应就是“这事儿应该让 Shell 脚本来干。”于是一个自用的、用于合并文件夹内所有文件内容的 Shell 脚本就诞生了。它没有花哨的功能核心目标就一个给定一个文件夹路径自动将其下的所有文件内容按文件名顺序或自定义顺序拼接成一个大的输出文件。这个脚本后来成了我的“瑞士军刀”之一无论是整理代码库、合并调研资料还是预处理数据都能派上用场。今天我就把这个自用脚本的完整实现思路、代码细节以及踩过的坑毫无保留地分享出来。你会发现用 Shell 脚本自动化这类重复性文件操作不仅省时省力更是将你的工作流程推向专业化的关键一步。2. 脚本核心设计不只是cat命令的简单封装很多人听到“合并文件”第一反应就是用cat命令。没错cat file1 file2 output确实能合并文件。但当我们面对一个充满未知文件的文件夹时问题就变得复杂了。一个健壮的合并脚本需要考虑的远不止一个命令。我们需要设计一个清晰的流程来处理各种边界情况和用户需求。2.1 需求拆解与功能规划首先我们明确这个自用脚本需要满足哪些核心和进阶需求基本功能遍历指定目录读取所有普通文件的内容并按顺序写入一个新文件。顺序控制默认按文件名的字母顺序ls默认排序合并是否合理是否需要支持按文件修改时间、创建时间或者自定义列表排序文件过滤是否所有文件都需要合并通常我们只需要合并文本文件如.txt,.log,.csv,.json,.py等而应该忽略二进制文件如图片.jpg、可执行文件或隐藏文件以.开头的文件。内容格式化直接拼接可能会导致文件内容“粘”在一起缺乏分隔。是否需要在每个文件内容之间插入分隔符如换行符、一行注释、文件名标记递归处理如果文件夹下还有子文件夹是否需要递归地合并所有子文件夹中的文件输出控制输出文件名如何定义是否允许用户指定如果输出文件已存在是覆盖、跳过还是备份错误处理处理过程中如果某个文件无法读取权限不足、已被删除脚本应该报错退出还是跳过该文件继续执行交互与日志脚本运行时是否需要显示进度信息是否记录合并了哪些文件便于事后核对对于自用脚本我的原则是“够用就好但基础要牢固”。因此我决定实现一个具备良好默认行为同时通过命令行参数提供关键定制能力的脚本。核心功能聚焦于递归合并、文本文件过滤、添加分隔符、完整的错误处理与日志输出。2.2 技术选型为什么是 Bash Shell在 Windows 上有 PowerShellPython 也能轻松处理文件为什么选择 Bash Shell 脚本无处不在在 Linux、macOS 以及现代的 Windows通过 WSL、Git Bash、Cygwin上Bash 或兼容的 Shell 是标配。脚本的移植性非常好。原生文件操作优势Shell 本就是为操作文件和进程而生的。像遍历目录 (find,for)、读取文件 (cat,while read)、路径处理 (dirname,basename) 等操作在 Shell 中写起来非常简洁和高效通常是调用系统原生命令速度极快。管道与重定向Shell 的管道 (|) 和重定向 (,) 机制让数据流的处理变得直观且强大非常适合这类“读取-处理-输出”的线性任务。启动成本低无需安装额外的解释器或依赖库Python 虽常见但并非所有环境都预装了所需模块。一个脚本文件加上执行权限就能跑。当然Shell 脚本不适合处理极其复杂的逻辑或数据结构。但对于我们这个文件合并任务它的简洁和高效是无可替代的。我选择在脚本开头使用#!/bin/bash明确指定 Bash以利用其比标准sh更丰富的功能如数组和更强大的条件判断。3. 手把手实现从零构建合并脚本merge_files.sh下面我将分步拆解脚本的每一部分并解释其背后的考量和原理。你可以跟着一步步操作最终得到一个可直接使用的脚本。3.1 脚本骨架与参数解析首先创建脚本文件比如叫merge_files.sh并赋予执行权限touch merge_files.sh chmod x merge_files.sh脚本的第一行是 Shebang告诉系统用哪个解释器来执行#!/bin/bash接下来我们需要处理用户可能输入的参数。一个友好的脚本应该支持-h显示帮助并允许用户指定输入目录和输出文件。这里使用 Bash 内置的getopts来解析命令行参数它比手动解析$1,$2更健壮。# 默认值设置 INPUT_DIR. # 默认当前目录 OUTPUT_FILEmerged_output.txt # 默认输出文件名 RECURSIVEfalse # 默认不递归 SEPARATOR\n---\n # 默认分隔符两个换行加横线加换行 SHOW_HELPfalse # 使用 getopts 解析命令行参数 while getopts :d:o:rs:h opt; do case ${opt} in d ) INPUT_DIR$OPTARG ;; o ) OUTPUT_FILE$OPTARG ;; r ) RECURSIVEtrue ;; s ) SEPARATOR$OPTARG ;; h ) SHOW_HELPtrue ;; \? ) echo 无效选项: -$OPTARG 12 exit 1 ;; : ) echo 选项 -$OPTARG 需要一个参数. 12 exit 1 ;; esac done shift $((OPTIND -1)) # 显示帮助信息 if [ $SHOW_HELP true ]; then cat EOF 用法: $(basename $0) [选项] 选项: -d 目录 指定要合并的源文件目录 (默认为当前目录) -o 文件 指定合并后的输出文件名 (默认为 merged_output.txt) -r 递归处理子目录中的文件 -s 分隔符 指定文件内容之间的分隔符 (默认为 \\n---\\n) -h 显示此帮助信息 示例: $0 -d ./logs -o all_logs.txt 合并 ./logs 目录下所有文件 $0 -r -d . -o total.txt 递归合并当前目录及子目录下所有文件 $0 -s \\\n\\n\ -d src 使用自定义分隔符合并 EOF exit 0 fi关键点解析getopts :d:o:rs:h冒号:表示该选项需要一个参数。所以-d和-o后面必须跟参数-r,-h则不需要。shift $((OPTIND -1))OPTIND是getopts内部索引处理完所有选项后这条命令会移除以处理过的参数这样$里剩下的就是非选项参数本例中未使用。$(basename $0)$0是脚本名basename命令去掉路径只保留文件名使帮助信息更清晰。分隔符的注意点默认分隔符\n---\n在赋值给变量时反斜杠会被转义。在后续使用echo -e时-e选项会解释这些转义字符从而输出真正的换行。用户通过-s传入的分隔符字符串也会被同样处理。3.2 输入验证与准备工作参数解析完后不能直接开始合并必须先检查输入的合法性避免脚本在错误的状态下运行。# 1. 检查输入目录是否存在且可读 if [ ! -d $INPUT_DIR ]; then echo 错误目录 $INPUT_DIR 不存在。 12 exit 1 fi if [ ! -r $INPUT_DIR ]; then echo 错误目录 $INPUT_DIR 不可读。 12 exit 1 fi # 2. 检查输出文件路径是否可写尝试创建或追加如果文件不存在则创建 OUTPUT_DIR$(dirname $OUTPUT_FILE) if [ $OUTPUT_DIR ! . ] [ ! -d $OUTPUT_DIR ]; then echo 错误输出文件所在目录 $OUTPUT_DIR 不存在。 12 exit 1 fi # 尝试触摸一下输出文件测试是否可写。如果文件不存在则创建空文件。 if ! touch $OUTPUT_FILE 2/dev/null; then echo 错误无法创建或写入输出文件 $OUTPUT_FILE。请检查权限。 12 exit 1 fi # 3. 清空或初始化输出文件 $OUTPUT_FILE # 使用重定向清空文件比 echo -n file 更高效。 echo 开始合并文件... echo 输入目录: $(cd $INPUT_DIR pwd) echo 输出文件: $(cd $(dirname $OUTPUT_FILE) pwd)/$(basename $OUTPUT_FILE) echo 递归模式: $RECURSIVE echo 分隔符: $(echo -e $SEPARATOR | sed s/$/\\n/g | tr -d \n | head -c 50)... echo ----------------------------------------关键点解析与踩坑记录[ ! -r $INPUT_DIR ]检查目录可读性非常重要。你可能对目录有执行 (x) 权限可以进入但没有读 (r) 权限来列出文件这会导致find或ls命令失败。$(dirname $OUTPUT_FILE)处理输出文件路径时必须考虑用户可能输入了包含子目录的路径如./results/merged.txt。dirname提取目录部分basename提取文件名部分。先检查目录是否存在比直接写文件时再报错更友好。touch $OUTPUT_FILE 2/dev/null这是一个巧妙的可写性测试。touch命令如果文件不存在则创建存在则更新其时间戳。如果这个操作失败权限不足、路径只读等2/dev/null将错误信息丢弃然后通过if ! ...判断为失败脚本退出。这比先判断文件是否存在再判断是否可写更简洁。 $OUTPUT_FILE这是清空文件的标准且最高效的 Shell 方法。一个简单的重定向操作即可。打印分隔符预览echo -e解释转义字符sed将换行符替换为\n字符串以便显示tr -d \n去掉真实换行防止打印多行head -c 50只取前50个字符防止过长。这个小技巧让用户能直观看到分隔符的样子。3.3 核心文件查找与遍历逻辑这是脚本的核心。我们需要根据是否递归找到所有需要合并的文件。find命令是完成这个任务的不二之选。# 初始化文件列表和计数器 file_list() processed_count0 skipped_count0 # 构建 find 命令的基础部分 if [ $RECURSIVE true ]; then find_cmdfind \$INPUT_DIR\ -type f else find_cmdfind \$INPUT_DIR\ -maxdepth 1 -type f fi # 使用 while read 循环安全地处理 find 的输出避免文件名中的空格或换行符导致问题。 # IFS 和 -r 参数是关键。 while IFS read -r -d $\0 file; do # 排除输出文件自身防止无限循环或内容污染 if [[ $(realpath $file) $(realpath $OUTPUT_FILE) ]]; then echo 跳过输出文件自身: $file ((skipped_count)) continue fi # 简单的文件类型检查尝试读取文件前几个字节判断是否为文本文件。 # 使用 file -b --mime-type 更准确但依赖 file 命令。这里提供一个不依赖 file 的简单方法。 # 更健壮的做法是使用 file -b --mime-type $file | grep -q ^text/但考虑到兼容性我们先采用简单方法。 # 高级检查如果系统有 file 命令则使用它。 if command -v file /dev/null; then if file -b --mime-type $file | grep -q ^text/; then is_texttrue else is_textfalse fi else # 备用方案检查文件是否包含空字符NULL二进制文件通常包含。 if grep -qI . $file 2/dev/null; then # grep -I 跳过二进制文件-q 安静模式. 匹配任何非空行。如果命令成功说明可能是文本。 is_texttrue else is_textfalse fi fi if [ $is_text false ]; then echo 跳过非文本文件可能是二进制: $file ((skipped_count)) continue fi # 将文件添加到列表 file_list($file) done (eval $find_cmd -print0) # 检查是否找到了文件 if [ ${#file_list[]} -eq 0 ]; then echo 在目录 $INPUT_DIR 中未找到可合并的文本文件。 exit 0 fi echo 找到 ${#file_list[]} 个待合并的文本文件。关键点解析与深度避坑find -print0与while IFS read -r -d $\0这是处理任意文件名包含空格、换行符等特殊字符的黄金标准。-print0使用空字符NULL作为输出分隔符因为文件名中不可能包含空字符。read -d $\0同样使用空字符作为读取分隔符。IFS防止read对行进行单词分割-r防止反斜杠转义。不使用for file in $(find ...)是因为这种写法会对文件名进行分词和路径名扩展遇到空格或通配符就会出错。排除输出文件自身使用realpath获取文件的绝对路径并进行比较这比比较字符串更可靠能处理./output和/full/path/output是同一个文件的情况。这是一个非常关键的检查否则脚本可能会读取自己正在写入的文件导致内容混乱或无限增长。文本文件检测这是脚本的难点和易错点。严格来说Shell 无法 100% 准确判断一个文件是否为文本文件。我们采用两种策略首选file命令file -b --mime-type会输出文件的 MIME 类型如text/plain,application/pdf。grep -q ^text/检查是否以text/开头。这是相对最可靠的方法。备用grep方案如果系统没有file命令极少数精简环境则使用grep -I .。-I选项告诉grep直接跳过二进制文件它自己有一套简单的启发式判断。.匹配任何单个字符-q安静模式。这个检查并不完美但能过滤掉大多数典型的二进制文件。2/dev/null是为了忽略grep对某些“二进制文件匹配”的警告信息。使用数组存储文件列表将找到的合格文件存入 Bash 数组file_list而不是立即处理。这样做的好处是我们可以在合并前知道文件总数便于显示进度也方便后续实现更复杂的排序逻辑虽然当前是按find默认顺序通常是按文件系统条目顺序并非严格字母顺序。3.4 文件合并与进度展示现在我们有了一个干净的文件列表可以开始合并了。我们需要按顺序读取每个文件的内容追加到输出文件中并在每个文件之间插入分隔符。total_files${#file_list[]} current_index0 for file in ${file_list[]}; do ((current_index)) # 显示进度信息 echo -n [$current_index/$total_files] 正在处理: $(basename $file) ... # 尝试读取文件内容并追加到输出 if cat $file $OUTPUT_FILE 2/dev/null; then # 成功读取后如果不是最后一个文件则添加分隔符 if [ $current_index -lt $total_files ]; then echo -e $SEPARATOR $OUTPUT_FILE fi echo 完成。 ((processed_count)) else echo 失败文件可能无法读取或已被删除。已跳过。 ((skipped_count)) # 注意这里我们选择跳过失败的文件继续处理下一个。 # 如果希望严格一点可以在这里选择退出 exit 1。 fi done关键点解析与性能考量进度显示echo -n不换行输出在同一行更新进度体验更好。[3/50]这样的格式让用户清楚知道进度。cat命令的重定向cat $file $OUTPUT_FILE是核心操作。表示追加。这里没有使用cat file1 file2 output的语法是因为我们需要在文件间插入分隔符并且要处理可能失败的单个文件。错误处理cat命令可能因为文件权限不足、文件在遍历后被删除等原因失败。2/dev/null将错误信息如“权限被拒绝”丢弃然后通过if判断命令的退出状态码$?。如果失败我们记录并跳过而不是让整个脚本崩溃。这对于处理大量文件时偶尔出现的异常情况非常有用。分隔符的添加时机只在非最后一个文件后添加分隔符避免了输出文件末尾多出一个不必要的分隔符。逻辑清晰。性能思考对于超大文件几百MB或GB使用cat是高效的因为它是系统调用。如果需要在合并过程中进行复杂的行级处理如过滤、修改则可能需要改用while IFS read -r line循环但那样会慢很多。本脚本定位是“合并”所以cat是最佳选择。3.5 收尾工作与最终脚本合并完成后我们需要给出一个清晰的总结告诉用户发生了什么。echo ---------------------------------------- echo 合并完成 echo 成功处理文件数: $processed_count if [ $skipped_count -gt 0 ]; then echo 跳过文件数: $skipped_count (包含非文本文件、输出文件自身或读取失败的文件) fi echo 输出文件大小: $(du -h $OUTPUT_FILE | cut -f1) echo 输出文件行数: $(wc -l $OUTPUT_FILE)将以上所有代码块按顺序组合起来就是一个功能完整、健壮的自用文件合并脚本merge_files.sh。4. 进阶技巧与场景化改造基础的脚本已经很好用了但在实际使用中你可能会遇到更多定制化需求。下面分享几个我根据不同场景改造脚本的进阶技巧。4.1 实现按修改时间排序合并默认的find顺序并不总是符合预期。有时我们需要按文件修改时间从旧到新或从新到旧合并比如合并日志文件时。我们可以利用find的-printf功能和sort命令来实现。修改文件查找和排序部分# ... 参数解析和验证之后 ... file_list() # 使用 find 打印出文件的修改时间秒时间戳和路径用特殊字符分隔 while IFS read -r -d $\n line; do # 假设我们使用 作为分隔符find -printf 的输出格式为“时间戳路径” # 但更安全的方法是使用两个 find 调用或者使用 stat 命令。 # 这里展示一个使用 stat 命令GNU coreutils的版本它更通用。 : done (find $INPUT_DIR -maxdepth 1 -type f -exec stat -c %Y %n {} \; | sort -n | cut -d -f2-) # 上面的管道解释 # 1. find ... -exec stat -c %Y %n {} \; : 对每个文件执行 stat输出修改时间戳自Epoch的秒数和文件名空格分隔。 # 2. sort -n : 按数字时间戳升序排序最旧的文件在前。 # 3. cut -d -f2- : 以空格为分隔符取出第二列及之后的部分即文件名。注意如果文件名包含空格这种方法会出错 # 警告此方法对含空格的文件名不友好更健壮但复杂的方法是使用 \0 分隔符和 awk。 # 因此对于生产环境推荐以下更安全但稍复杂的方法需要 GNU find 和 sort # 将 find 结果存入临时数组然后用循环配合 stat 获取时间戳再排序。 temp_array() while IFS read -r -d $\0 file; do # 同样的排除和文本检查逻辑... if [[ $(realpath $file) $(realpath $OUTPUT_FILE) ]]; then continue fi # ... 文本检查 ... timestamp$(stat -c %Y $file 2/dev/null) # 获取时间戳 # 使用 printf 格式化确保时间戳和文件名能正确解析 temp_array($(printf %d\t%s ${timestamp:-0} $file)) done (find $INPUT_DIR -maxdepth 1 -type f -print0) # 按时间戳排序第一列 IFS$\n sorted_array($(sort -n ${temp_array[*]})) unset IFS # 提取排序后的文件名第二列制表符分隔 for item in ${sorted_array[]}; do file_list($(cut -f2- $item)) done注意按时间排序会显著增加脚本复杂度并且stat命令的选项在不同系统如 macOS 的 BSDstat和 GNUstat上可能不同。除非必要否则默认的文件系统顺序通常是可以接受的。如果需要此功能最好将其作为一个可选的命令行参数如-t来实现。4.2 添加文件名作为内容标题有时合并后的文件很难区分某段内容来自哪个源文件。一个实用的功能是在每个文件内容前插入其文件名作为标题。在合并循环中修改for file in ${file_list[]}; do ((current_index)) echo -n [$current_index/$total_files] 正在处理: $(basename $file) ... # 写入文件名标题 echo -e \n\n【文件: $(basename $file) - 路径: $file】\n $OUTPUT_FILE if cat $file $OUTPUT_FILE 2/dev/null; then if [ $current_index -lt $total_files ]; then echo -e $SEPARATOR $OUTPUT_FILE fi echo 完成。 ((processed_count)) else echo 失败已跳过。 ((skipped_count)) # 如果写入标题后读取失败最好也删除刚写入的标题。这里简化处理只是跳过。 fi done4.3 处理特定文件扩展名或排除文件你可能只想合并.log和.txt文件或者排除所有.tmp临时文件。这可以通过增强find命令的-name或-not选项来实现。在构建find_cmd时添加过滤# 假设我们只想合并 .log, .txt, .md 文件 if [ $RECURSIVE true ]; then find_cmdfind \$INPUT_DIR\ -type f \( -name \*.log\ -o -name \*.txt\ -o -name \*.md\ \) else find_cmdfind \$INPUT_DIR\ -maxdepth 1 -type f \( -name \*.log\ -o -name \*.txt\ -o -name \*.md\ \) fi # 或者排除 .tmp 和 .bak 文件 if [ $RECURSIVE true ]; then find_cmdfind \$INPUT_DIR\ -type f -not \( -name \*.tmp\ -o -name \*.bak\ \) else find_cmdfind \$INPUT_DIR\ -maxdepth 1 -type f -not \( -name \*.tmp\ -o -name \*.bak\ \) fi你可以将这些过滤模式设计成通过命令行参数传入使脚本更加灵活。5. 实战演练与排错指南让我们用一个具体的例子来跑通整个流程并看看可能会遇到哪些问题。场景合并~/project/logs目录下所有的.log文件到一个名为all_logs_$(date %Y%m%d).txt的文件中并希望在每个日志文件间插入清晰的分隔线。操作步骤保存脚本将完整的merge_files.sh脚本保存到你的个人工具目录例如~/bin/并确保该目录在PATH环境变量中。准备测试目录mkdir -p ~/test_merge cd ~/test_merge echo Log entry 1 from app A app_a_20231001.log echo Log entry 2 from app A app_a_20231002.log echo Error: something happened in app B app_b_error.log echo This is a binary file? dummy.bin执行脚本# 假设脚本已在 PATH 中 merge_files.sh -d ~/test_merge -o ./merged_$(date %Y%m%d).txt -s \n 下一个文件 \n-d指定目录。-o使用命令替换动态生成带日期的输出文件名。-s指定了更醒目的分隔符。常见问题与排查错误/bin/bash^M: 坏的解释器没有那个文件或目录原因脚本是在 Windows 系统编辑后传到 Linux/macOS 的行尾是CRLF(\r\n)而 Unix 系统期望LF(\n)。解决使用dos2unix merge_files.sh转换或用sed -i s/\r$// merge_files.sh删除\r。错误find: 路径必须在表达式之前原因目录路径包含特殊字符如空格、括号在find命令中未正确引用。解决脚本中已经用双引号包裹了$INPUT_DIR确保变量扩展后被引用。如果你在命令行手动测试也要注意引用find “/path/with spaces” ...。合并后的文件内容乱码或包含奇怪字符原因源文件编码不一致如 UTF-8, GBK, UTF-16或者包含了非文本的二进制数据。解决确保脚本的文本文件检测生效跳过了二进制文件。对于不同编码的文本文件合并到一个文件后编辑器可能无法正确识别。可以在合并前用iconv命令统一转码为 UTF-8但这会大大增加脚本复杂度。一个折中方案是在脚本中检测到非 UTF-8 文件时给出警告。可以在合并循环中加入编码检查if ! iconv -f utf-8 -t utf-8 $file /dev/null 21; then echo “警告: $file 可能不是 UTF-8 编码”; fi。脚本运行速度慢处理大量小文件时原因对每个文件都调用file或grep -I命令进行类型检查产生了大量进程开销。优化如果确信目录下全是文本文件或者愿意承担合并少量二进制文件的风险可以添加一个-fforce选项来跳过文件类型检查。在参数解析部分增加-f选项然后在查找文件循环中根据这个标志位决定是否执行检查。find: missing argument to -exec原因在-exec中使用的{}和\;格式不正确或者find命令的构建字符串在变量中时引用和扩展出现问题。解决这是 Shell 脚本中引用和变量扩展的经典难题。如果find_cmd字符串非常复杂包含-exec使用eval或数组来构建命令更安全。我们之前使用的 (eval $find_cmd -print0)中的eval需要谨慎处理。一个更安全的方法是避免在变量中存储复杂命令而是直接使用find命令。通过这个自用的 Shell 脚本项目我们不仅解决了一个具体的文件合并需求更深入实践了 Shell 脚本编程中的多个关键概念健壮的命令行参数解析、安全的文件名处理、错误处理、文件类型判断以及通过管道和命令组合构建复杂功能。将这个脚本收入你的工具箱下次再遇到合并文件的任务你只需要一行命令就能轻松搞定把时间和精力留给更有价值的工作。