找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4395

积分

0

好友

575

主题
发表于 1 小时前 | 查看: 4| 回复: 0

本篇内容带大家动手实践一个 Shell 脚本,实现“文件检查与拷贝”功能。在这个过程中,咱们能顺便摸清不少 Shell 脚本的常用语法。

一个动态变化的品牌视觉组合,包含蓝色立体立方体LOGO和搜索框样式横幅

如果你习惯了手动 ctrl+cctrl+v 来备份文件,不妨看看脚本自动化能带来的效率提升。在 云栈社区 上,经常能看到大家分享各种运维提效的实战经验,这种文件检查与同步的脚本就是一个很典型的例子。

1 功能说明

在 Linux 系统中,要实现这样一个 Shell 脚本:把一个源目录中的所有文件(包含子目录下的),同步到另一个指定目录。具体要求如下:

  • 拷贝前,先检测两个目录中同名文件的 MD5 值,仅拷贝 MD5 不一致的文件。
  • 如果需要拷贝,先汇总并打印待拷贝的文件数量、可能缺失的子目录数量,并询问用户是否执行。
  • 用户确认后,再执行拷贝,并输出详细的拷贝操作信息。

2 脚本实现

下面我们分模块来拆解这个脚本。

2.1 源目录与目标目录

SRC_DIR="./curl-8.15.0"  # 源目录路径
DEST_DIR="./curl-8.15.0-test2"  # 目标目录路径

# 确保目录路径不以斜杠结尾,避免路径处理问题
SRC_DIR=${SRC_DIR%/}
DEST_DIR=${DEST_DIR%/}

# 检查源目录是否存在
if [ ! -d "$SRC_DIR" ]; then
    echo "错误:源目录 $SRC_DIR 不存在!"
    exit 1
fi

# 检查目标目录,不存在则创建
if [ ! -d "$DEST_DIR" ]; then
    echo "目标目录 $DEST_DIR 不存在,正在创建..."
    mkdir -p "$DEST_DIR" || { echo "创建目标目录失败!"; exit 1; }
fi

2.2 关键语法点拆解

2.2.1 确保目录路径不以斜杠结尾

移除变量 SRC_DIR 末尾可能存在的斜杠 /,避免后续拼接路径时出现重复的斜杠。

SRC_DIR=${SRC_DIR%/}

其语法为:

${变量%模式}

这会从变量值的末尾开始匹配“模式”,并删除最短的匹配部分(这里用到的模式就是那个斜杠)。

  • 如果 SRC_DIR 的值本身不带斜杠(如 src),则该操作不改变原变量。
  • 如果 SRC_DIR 的值带斜杠(如 src/./src/),则会删除末尾的斜杠,变成 src./src

2.2.2 检查目录是否存在

在进行文件检查前,需要先检查对应的目录是否存在。

if [ ! -d "$SRC_DIR" ]; then
#...
fi

在 Shell 脚本中,-d 是一个文件测试运算符,用于检查指定路径是否为一个目录(directory)。类似的常用文件测试运算符还有:

  • -f:检查是否为普通文件(不是目录)。
  • -e:检查路径是否存在(无论文件还是目录)。
  • -r:检查是否有读权限。
  • -w:检查是否有写权限。

再来看下 if [ ! -d "$SRC_DIR" ] 的语法:

  • [ ] 是 Shell 的条件判断语法。
  • ! 表示取反。
  • -d "$SRC_DIR" 检查变量 $SRC_DIR 对应的路径是否为一个存在的目录。

2.3 统计源目录的总文件数和总目录数

这里使用 Shell 脚本中的命令替换(Command Substitution)语法,来计算指定目录下的普通文件总数。

# 统计源目录的总文件数和总目录数
TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
TOTAL_DIRS=$(find "$SRC_DIR" -type d | wc -l)
# 减去源目录本身
TOTAL_DIRS=$((TOTAL_DIRS - 1))

echo "源目录总文件数: $TOTAL_FILES"
echo "源目录总目录数: $TOTAL_DIRS"

具体解释下这句核心命令:

TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
  • find 是用于查找文件和目录的命令。
  • "$SRC_DIR" 是查找的起始目录(使用变量引用,双引号处理路径中的空格)。
  • -type ffind 的参数,指定只查找普通文件(排除目录、链接等)。除了 f 参数,还可以有:
    • d:目录(directory)。
    • l:符号链接(symbolic link)。
    • b:块设备文件(block special file),通常指存储设备(如硬盘分区)。
    • c:字符设备文件(character special file),通常指输入输出设备(如键盘、终端)。
    • p:管道文件(named pipe/FIFO),用于进程间通信的特殊文件。
    • s:套接字文件(socket),用于网络或进程间通信的特殊文件。
  • | 管道符,将前一个命令(find)的输出作为后一个命令(wc -l)的输入。
  • wc 是用于统计的命令(Word Count),后面的参数可以为:
    • -l 参数表示统计行数(每行对应一个文件路径)。
    • -w 单词数。
    • -c 字节数。
    • -m 字符数。
  • $(...) 是命令替换语法,将括号内命令的输出结果作为字符串返回,并赋值给前面的 TOTAL_FILES 变量。

2.4 通过 MD5 对比文件是否一样

# 创建临时文件存储需要处理的项目
TMP_FILE=$(mktemp)

# 递归遍历源目录下所有文件,使用临时文件解决子shell变量问题
find "$SRC_DIR" -type f | while read -r src_file; do
    # 计算相对路径
    rel_path="${src_file#$SRC_DIR/}"
    dest_file="$DEST_DIR/$rel_path"
    dest_dir=$(dirname "$dest_file")

    # 计算源文件MD5
    src_md5=$(md5sum "$src_file" | awk '{print $1}')

    # 计算目标文件MD5(如果存在)
    if [ -f "$dest_file" ]; then
        dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
    else
        dest_md5="不存在"
    fi

    # 比较MD5,不同则记录到临时文件
    if [ "$src_md5" != "$dest_md5" ]; then
        echo "MD5不同: $rel_path"
        echo "  源文件: $src_md5"
        echo "  目标文件: $dest_md5"
        echo "----------------------------------"

        # 只记录不存在的目录
        if [ ! -d "$dest_dir" ]; then
            echo "DIR:$dest_dir" >> "$TMP_FILE"
        fi

        # 记录需要复制的文件
        echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
    fi
done

# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))

# 删除临时文件
rm -f "$TMP_FILE"

2.4.1 创建临时文件

TMP_FILE=$(mktemp)
  • mktemp:一个专门用于创建临时文件的命令,它会在系统的临时目录(通常是 /tmp)中生成一个唯一的临时文件,并返回该文件的完整路径。
  • $(...):命令替换语法,将 mktemp 命令的输出结果,即临时文件的路径,赋值给 TMP_FILE 变量。

2.4.2 遍历每个文件

find "$SRC_DIR" -type f | while read -r src_file; do
#...
done
  • find "$SRC_DIR" -type f 用于查找 $SRC_DIR 目录下所有的普通文件(-type f),并输出每个文件的完整路径(一行一个路径)。
  • while 循环:持续读取输入内容,直到没有更多内容为止。
  • read -r src_file:从输入中读取一行内容,并将其保存到变量 src_file 中。-r 选项用于防止 read 命令对输入中的反斜杠 \ 进行转义,保证路径的完整性。

2.4.3 计算路径

从源目录文件的完整路径中提取出相对路径,然后根据目标位置,组成目标文件的路径。

rel_path="${src_file#$SRC_DIR/}"
dest_file="$DEST_DIR/$rel_path"
dest_dir=$(dirname "$dest_file")
  • ${变量#模式},从变量值的开头开始匹配“模式”,并删除最短的匹配部分。
    • src_file,存储着文件的完整路径,例如 /home/user/src/docs/readme.txt
    • $SRC_DIR/,作为匹配的前缀模式,例如 /home/user/src/
    • rel_path,最终得到 docs/readme.txt
  • $DEST_DIR/$rel_path,拼接成目标文件的路径,例如 /home/user/src2/docs/readme.txt
  • dirname,从文件路径中提取其所在的目录部分,例如 /home/user/src2/docs

2.4.4 计算 MD5

# 计算源文件MD5
src_md5=$(md5sum "$src_file" | awk '{print $1}')

# 计算目标文件MD5(如果存在)
if [ -f "$dest_file" ]; then
    dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
else
    dest_md5="不存在"
fi

解释一下这句:

md5sum "$src_file" | awk '{print $1}'
  • md5sum 是一个计算文件 MD5 哈希值的命令,能生成一个唯一标识文件内容的 32 位十六进制字符串。
    • "$src_file" 是要计算哈希值的文件路径。
    • 该命令的输出格式通常是:[MD5值] [文件名]
  • awk 是文本处理工具,$1 表示取行中的第一个字段,即 MD5 的值。

2.4.5 比较 MD5

if [ "$src_md5" != "$dest_md5" ]; then
    echo "MD5不同: $rel_path"
    echo "  源文件: $src_md5"
    echo "  目标文件: $dest_md5"
    echo "----------------------------------"

    # 只记录不存在的目录
    if [ ! -d "$dest_dir" ]; then
        echo "DIR:$dest_dir" >> "$TMP_FILE"
    fi

    # 记录需要复制的文件
    echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
fi

解释下这句:

echo "DIR:$dest_dir" >> "$TMP_FILE"
  • echo 用于输出字符串。
  • "DIR:$dest_dir" 是要输出的内容,$dest_dir 是目标目录路径。
  • >> 追加重定向运算符,用于将前面命令的输出追加到指定文件的末尾。
  • "$TMP_FILE" 被写入的文件路径,之前用 mktemp 创建的临时文件。

最终写入 TMP_FILE 中的需要复制的目录的信息如:DIR:./curl-8.15.0-test2/include/curl

类似的,最终写入 TMP_FILE 中的需要复制的文件的信息如:FILE:./curl-8.15.0/CMakeLists.txt:./curl-8.15.0-test2/CMakeLists.txt:CMakeLists.txt

2.4.6 从临时文件读取数据

# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))

# 删除临时文件
rm -f "$TMP_FILE"

解释下第一句管道命令链:

  • grep "^DIR:" "$TMP_FILE":筛选出类似 DIR:/home/user/output/utils 这样的行。
    • grep 用于从文件中筛选匹配特定模式的行。
    • ^DIR: 是匹配模式,^ 表示行首,即筛选所有以 DIR: 开头的行。
  • sort -u:对筛选出的行进行排序,同时去重(unique),确保相同的目录路径只保留一次。
  • cut -d: -f2-:比如将 DIR:/home/user/output/utils 处理为 /home/user/output/utils
    • cut 用于从行中提取指定部分。
    • -d: 指定分隔符(delimiter)为冒号 :
    • -f2- 表示提取从第 2 个字段(field)开始到行尾的内容,从而去掉前面的 DIR: 前缀。

2.5 确认是否复制

# 检查是否有需要处理的内容
if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ] && [ ${#NEED_COPY_FILES[@]} -eq 0 ]; then
    echo "=== 所有文件MD5一致,无需复制 ==="
    exit 0
fi

# 显示统计信息并确认,附加源目录总数量
echo -e "\n=== 准备操作汇总 ==="
echo "需要创建的目录数: ${#NEED_CREATE_DIRS[@]} (共 $TOTAL_DIRS 个目录)"
echo "需要复制的文件数: ${#NEED_COPY_FILES[@]} (共 $TOTAL_FILES 个文件)"
read -p "是否继续执行操作?(y/n):" confirm

if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
    echo "操作已取消"
    exit 0
fi
  • if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ]:判断 NEED_CREATE_DIRS 数组的长度是否为 0。${#数组名[@]} 表示返回数组中元素的数量,-eq 是 Shell 中的比较运算符,表示等于(equal)。
  • read -p "是否继续执行操作?(y/n):" confirm:用于获取用户输入的交互命令。
    • read:Shell 中用于从标准输入读取用户输入的命令。
    • -p:指定一个提示信息(prompt)。
    • confirm:变量名,用于存储用户输入的内容。

2.6 复制有变化的文件

# 创建目录
for dir in "${NEED_CREATE_DIRS[@]}"; do
    echo "创建目录: $dir"
    mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done

# 复制文件
total=${#NEED_COPY_FILES[@]}
current=0

for item in "${NEED_COPY_FILES[@]}"; do
    current=$((current + 1))
    # 解析数据
    src_file=$(echo "$item" | cut -d: -f2)
    dest_file=$(echo "$item" | cut -d: -f3)
    rel_path=$(echo "$item" | cut -d: -f4)

    # 显示进度和文件名
    echo "[$current/$total] 复制: $rel_path"

    # 执行复制
    cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done

2.6.1 创建目录

for dir in "${NEED_CREATE_DIRS[@]}"; do
    echo "创建目录: $dir"
    mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done
  • for dir in "${NEED_CREATE_DIRS[@]}"for 循环会遍历 NEED_CREATE_DIRS 数组中的每个元素,并赋值给 dir
  • mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
    • mkdir 是创建目录的命令。
    • -p 选项表示递归创建,如果父目录不存在会自动创建。
    • || 是逻辑“或”运算符,左侧命令执行失败时,才会执行右侧命令。

2.6.2 需要复制的数量

total=${#NEED_COPY_FILES[@]}

${#数组名[@]} 是 Shell 中获取数组长度的语法。

2.6.3 复制文件

current=0

for item in "${NEED_COPY_FILES[@]}"; do
    current=$((current + 1))
    # 解析数据
    src_file=$(echo "$item" | cut -d: -f2)
    dest_file=$(echo "$item" | cut -d: -f3)
    rel_path=$(echo "$item" | cut -d: -f4)

    # 显示进度和文件名
    echo "[$current/$total] 复制: $rel_path"

    # 执行复制
    cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done
  • src_file=$(echo "$item" | cut -d: -f2) 从字符串中以冒号 : 为分隔符,提取第 2 个字段。
  • cp -pv "$src_file" "$dest_file"
    • cp:用于复制文件或目录。
    • -p 选项,复制文件时保留源文件的元数据信息,包括文件的权限模式、修改时间等。
    • -v 选项,显示详细的复制过程信息,输出类似 '源文件' -> '目标文件' 的日志。

3 完整的脚本

#!/bin/bash

SRC_DIR="./curl-8.15.0"  # 源目录路径
DEST_DIR="./curl-8.15.0-test2"  # 目标目录路径

# 确保目录路径不以斜杠结尾,避免路径处理问题
SRC_DIR=${SRC_DIR%/}
DEST_DIR=${DEST_DIR%/}

# 检查源目录是否存在
if [ ! -d "$SRC_DIR" ]; then
    echo "错误:源目录 $SRC_DIR 不存在!"
    exit 1
fi

# 检查目标目录,不存在则创建
if [ ! -d "$DEST_DIR" ]; then
    echo "目标目录 $DEST_DIR 不存在,正在创建..."
    mkdir -p "$DEST_DIR" || { echo "创建目标目录失败!"; exit 1; }
fi

echo "源目录: $SRC_DIR"
echo "目标目录: $DEST_DIR"

# 统计源目录的总文件数和总目录数
TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
TOTAL_DIRS=$(find "$SRC_DIR" -type d | wc -l)
# 减去源目录本身
TOTAL_DIRS=$((TOTAL_DIRS - 1))

echo "源目录总文件数: $TOTAL_FILES"
echo "源目录总目录数: $TOTAL_DIRS"

# 创建临时文件存储需要处理的项目
TMP_FILE=$(mktemp)

echo "=== 开始MD5校验(包括子目录) ==="

# 递归遍历源目录下所有文件,使用临时文件解决子shell变量问题
find "$SRC_DIR" -type f | while read -r src_file; do
    # 计算相对路径
    rel_path="${src_file#$SRC_DIR/}"
    dest_file="$DEST_DIR/$rel_path"
    dest_dir=$(dirname "$dest_file")

    # 计算源文件MD5
    src_md5=$(md5sum "$src_file" | awk '{print $1}')

    # 计算目标文件MD5(如果存在)
    if [ -f "$dest_file" ]; then
        dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
    else
        dest_md5="不存在"
    fi

    # 比较MD5,不同则记录到临时文件
    if [ "$src_md5" != "$dest_md5" ]; then
        echo "MD5不同: $rel_path"
        echo "  源文件: $src_md5"
        echo "  目标文件: $dest_md5"
        echo "----------------------------------"

        # 只记录不存在的目录
        if [ ! -d "$dest_dir" ]; then
            echo "DIR:$dest_dir" >> "$TMP_FILE"
        fi

        # 记录需要复制的文件
        echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
    fi
done

# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))

# 删除临时文件
rm -f "$TMP_FILE"

# 检查是否有需要处理的内容
if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ] && [ ${#NEED_COPY_FILES[@]} -eq 0 ]; then
    echo "=== 所有文件MD5一致,无需复制 ==="
    exit 0
fi

# 显示统计信息并确认,附加源目录总数量
echo -e "\n=== 准备操作汇总 ==="
echo "需要创建的目录数: ${#NEED_CREATE_DIRS[@]} (共 $TOTAL_DIRS 个目录)"
echo "需要复制的文件数: ${#NEED_COPY_FILES[@]} (共 $TOTAL_FILES 个文件)"
read -p "是否继续执行操作?(y/n):" confirm

if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
    echo "操作已取消"
    exit 0
fi

# 创建目录
echo -e "\n=== 开始创建目录 ==="
for dir in "${NEED_CREATE_DIRS[@]}"; do
    echo "创建目录: $dir"
    mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done

# 复制文件
echo -e "\n=== 开始复制文件 ==="
total=${#NEED_COPY_FILES[@]}
current=0

for item in "${NEED_COPY_FILES[@]}"; do
    current=$((current + 1))
    # 解析数据
    src_file=$(echo "$item" | cut -d: -f2)
    dest_file=$(echo "$item" | cut -d: -f3)
    rel_path=$(echo "$item" | cut -d: -f4)

    # 显示进度和文件名
    echo "[$current/$total] 复制: $rel_path"

    # 执行复制
    cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done

echo -e "\n=== 操作完成 ==="

4 测试结果

这里用 curl 的源码目录进行测试。拷贝一份到 curl-8.15.0-test2 目录,然后删除其中一些文件,模拟目录不一致的场景。

终端截图显示执行脚本后检测到MD5不同的文件并提示是否需要复制

可以看到,脚本检测到了两个目录里存在不一致的文件,在用户确认执行后,顺利地完成了文件拷贝。

再次执行相同的脚本,验证同步效果。

终端截图显示再次执行脚本后,提示所有文件MD5一致,无需复制

再次运行的结果显示,所有文件都已完全一致。

5 总结

本篇通过“文件检查与拷贝”这个实例,不仅串联了 findmd5sumgrepcut 等多个 Shell 命令,还实践了条件判断、循环、数组以及临时文件处理等核心编程语法。这种将基础命令组合成强大自动化工具的能力,正是 Shell 脚本的精髓所在。




上一篇:C语言libcurl编程实战:从单线程到多线程分段下载文件
下一篇:Shell脚本实战:高效文件检查与增量拷贝(cmp对比法)
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-3 04:04 , Processed in 1.158078 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表