资讯详情

资讯详情

Shell脚本判断文件存在与去除后缀:从test到[[ ]]的完整指南

做 Linux 运维或者天天跟服务器打交道的人应该都有过这种经历写脚本想检查一个配置文件在不在结果因为没判断好“存在”这个条件脚本在初始化的时候直接崩了或者想批量处理一批文件把.bak、.tmp这类后缀去掉折腾半天老是处理不干净。这两个操作——判断文件是否存在、去掉文件后缀——看起来是 Shell 脚本里的基础得不能再基础的功能但正是因为基础反而最容易踩坑。我见过不少写了好几年脚本的人在这两个点上依然翻车要么是[ ]和[[ ]]混用导致语法报错要么是遇到带空格的文件名直接原地爆炸要么是${file%.*}和${file%%.*}分不清结果 tar.gz 被一刀砍成 tar完全不是自己想要的结果。这篇就专门聊聊这两个点。我会从最底层的原理讲起把test、[ ]、[[ ]]、参数扩展、basename这些工具掰开揉碎再套进几个真实的脚本场景里最后把常见的坑和排查思路整理成速查表。不管是刚入门的初学者还是写了不少脚本但没系统梳理过的老手这篇应该都能帮你把这两个小知识点彻底焊死。1. 判断文件是否存在从 test 命令到 [ ] 再到 [[ ]]1.1 最常见的一行判断test 和它的“括号马甲”先看一个最朴素的写法if test -f /etc/nginx/nginx.conf; then echo 配置文件存在 fitest是 Shell 内置的一个命令后面跟参数用来做条件判断。-f这个参数的意思是“后面这个路径是不是一个普通文件regular file”。如果是test就返回 0成功if就会走 then 分支如果不是返回非 0走 else 分支。但这个写法太长了而且看起来不像“判断”更像“测试”。所以 Shell 给test提供了另一个马甲[ ]。注意这里的方括号不是语法符号它本身就是一个命令等价于test。所以下面的写法跟上面完全等价if [ -f /etc/nginx/nginx.conf ]; then echo 配置文件存在 fi这里有个细节新手特别容易忽略[后面必须跟一个空格]前面也必须跟一个空格。因为[是个命令命令跟参数之间要靠空格隔开。很多人第一次写if [-f xxx]然后报错就是因为把[当成了语法符号不知道它其实是个程序。再往后Bash 还提供了一个升级版的双中括号if [[ -f /etc/nginx/nginx.conf ]]; then echo 配置文件存在 fi[[ ]]不是外部命令它是 Bash 的关键字keyword语法解析方式和单中括号完全不同。后面我会专门讲这两者的区别。现在先记住一个结论写 Bash 脚本优先用[[ ]]具体原因见 1.3。1.2 -e、-f、-d、-s、-r、-w、-x 到底怎么选很多人判断文件存在只会一个-e。但实际写脚本的时候-e往往不是最合适的因为它太宽泛了。你以为你在判断“文件存在”实际上是在判断“路径上有没有东西”——这个路径可能是一个普通文件、一个目录、一个软链接、一个管道甚至是一个设备文件。我整理了一份常用的判断参数对照表参数含义典型使用场景-e路径存在任何文件类型都算只要路径上有东西就算数比如判断挂载点目录是否存在-f存在且是普通文件最常见判断一个普通配置文件、日志文件是否存在-d存在且是目录判断一个目录是否存在比如日志目录、部署目录-s存在且文件大小不为0判断文件是否有内容比如检查日志是否为空-r存在且当前用户可读判断有没有读权限-w存在且当前用户可写判断有没有写权限-x存在且当前用户可执行判断二进制或脚本是否可执行-L存在且是符号链接判断是不是软链接配合-e使用可避免踩软链接的坑-N存在且自上次读取后被修改过判断文件是否“新改过”适合做文件变更触发这里的核心思路是先想清楚你到底要判断什么再选参数。举个例子如果你要判断“Nginx 配置文件存在否”用-f最合适因为配置文件应该是个普通文件。如果你用-e哪怕这个路径是个目录也会返回真那后续执行source或者读文件操作的时候就会出问题。再比如写一个部署脚本要判断一个目录存不存在if [ ! -d /data/app ]; then mkdir -p /data/app fi这里用-d而不是-e能避免“路径存在但是文件”这种意外情况。-d就保证了你后面mkdir创建出来的肯定是一个目录层级。还有-s这个参数在日志场景里非常常用。比如我要检查某个日志文件是不是空的如果为空就不处理if [[ -s /var/log/app/error.log ]]; then echo error.log 非空需要处理 else echo error.log 是空的跳过 fi注意-s和-f的区别正好是一个常见误区一个脚本里用-s判断“文件存在”结果空文件被误判为不存在。其实-s的含义是“文件非空”不是“文件存在”。如果你只关心存在性用-f如果关心“有没有内容”用-s。反面用法也别忽略。判断不存在时通常在前面加感叹号if [[ ! -f /etc/app/config.yml ]]; then echo config.yml 不存在请先创建 exit 1 fi这里逻辑正好反过来了先检查依赖文件在不在不在就退出。这个模式在写脚本开头做前置校验时非常好用能防止脚本运行到一半才发现缺文件。1.3 为什么不建议裸用 [ ]聊聊 shell 对空值的处理单中括号[ ]是test命令的别名它在解析变量的时候会做词拆分word splitting和文件名展开glob expansion所以对空变量和带空格路径极其不友好。看下面的例子file if [ -f $file ]; then echo 存在 fi这段代码运行的时候Shell 先把$file展开因为file是空字符串展开后就是空相当于执行了if [ -f ]; then这里有三个参数传给了test-f、空、空。严格来说test会把这种情况当成语法错误-f后面缺少一个参数然后返回非 0所以echo不会执行。这还好但更坑的是下面这种情况filemy file.txt if [ -f $file ]; then echo 存在 fi变量展开后变成了if [ -f my file.txt ]; then这里传给了test四个参数-f、my、file.txt、空。test会先看第一个参数是不是-f然后接着判断my这个文件是否存在。如果当前目录下碰巧有两个文件my和file.txt或者根本没有my结果就完全乱套了。正确的做法是加双引号if [ -f $file ]; then加了双引号之后$file被当做一个整体哪怕值是空字符串test也能正确判断出-f 是假。而双中括号[[ ]]是 Bash 的关键字它本身就不做词拆分和文件名展开所以不写双引号也不会出上面这个问题filemy file.txt if [[ -f $file ]]; then echo 存在 fi这句虽然没加引号但[[ ]]会正确地把my file.txt当成一个整体去判断。当然为了习惯统一我还是建议就算在[[ ]]里也给变量加引号做到万无一失。还有一个容易导致脚本晦涩难懂的坑[ ]内部对空串的判断。str if [ $str abc ]; then$str展开后是空这句实际上变成了[ abc ]直接报unary operator expected错误。用[[ ]]写则没有这个问题。提示如果脚本要兼容sh比如写 /bin/sh 脚本或者在无 Bash 的容器里跑不能用[[ ]]只能用[ ]那就必须老老实实给变量加双引号。如果确定环境支持 Bash就大胆用[[ ]]能省掉很多麻烦。2. 去掉文件后缀的几种姿势参数扩展是首选2.1 ${file%.} 和 ${file%%.}一个脱一层皮一个脱到根判断文件存在只是第一步脚本里另一个高频需求是去掉文件的后缀/扩展名。比如拿到一个文件名nginx.conf想得到nginx拿到archive.tar.gz想得到archive.tar或者archive。先看最常用的参数扩展语法。${var%pattern}的意思是从变量值的末尾开始删除最短匹配pattern的部分${var%%pattern}是从末尾开始删除最长匹配的部分。注意%是盯着字符串尾部不是头部。看个例子filearchive.tar.gz echo ${file%.*} # 输出 archive.tar echo ${file%%.*} # 输出 archive第一个%.*末尾匹配最短的.加任意字符也就是最后一个.tar.gz中的.gz删掉后得到archive.tar。第二个%%.*末尾匹配最长的.加任意字符从头开始找一直匹配到最后一个点之后的所有内容整个.tar.gz都没了得到archive。我用一句口诀帮助记忆一个 % 抬一次杠两个 %% 直接抬到根。再来看搭配#的情况。${file#pattern}是从开头开始删除最短匹配${file##pattern}是从开头删除最长匹配。filearchive.tar.gz echo ${file#*.} # 输出 tar.gz echo ${file##*.} # 输出 gz这个在实际中用来“提取后缀”而不是“去掉后缀”比如想拿到文件的扩展名就用${file##*.}。如果是.tar.gz${file##*.}得到的是gz而不是tar.gz这里要特别注意。为了方便你快速查我把几个常见操作列个表表达式作用示例filearchive.tar.gz结果${file%.*}去掉最后一个后缀archive.tar${file%%.*}去掉所有后缀直到第一个点archive${file#*.}去掉最前面的前缀直到第一个点tar.gz${file##*.}去掉所有前缀直到最后一个点gz${file%??}去掉末尾两个字符archive.tar.g2.2 注意${file%.*} 并不总等于“去掉后缀”这里有一个特别容易翻车的点很多人把${file%.*}和“去掉文件后缀”划等号。实际上它只是“按最后一个点删除尾部内容”如果你遇到没有点的文件名它返回的就是原字符串本身。比如fileREADME echo ${file%.*} # 输出 README这个倒是符合预期README 没有后缀去掉后还是 README。但如果是隐藏文件呢file.gitignore echo ${file%.*} # 输出 .gitignore因为.gitignore里只有一个点在开头末尾没有点所以%.*匹配不到原样返回。这个行为有时候不是你想的。如果你想“去掉点后面的一切”对于.gitignore你期望得到空字符串还是.gitignore根据实际语义通常是后者所以这个问题不大。但下面这种情况就要小心了fileconfig.yaml.bak echo ${file%.*} # 输出 config.yaml你原本可能想得到config结果只剥掉了一层.bak。如果你要批量处理的是多级后缀记得用%%.*而不是%.*。再举个具体的对比for f in *.tar.gz; do # 客户想要解压后的目录名只留包名不要版本号也不要格式后缀 dir${f%%.*} mkdir -p $dir tar -xzf $f -C $dir done这里用%%.*才能把所有后缀一次剥干净得到app、data这样的目录名。如果你用了%.*会得到app-2.1可能就不是你要的。2.3 basename 的经典用法和局限除了参数扩展basename命令也是处理后缀的常用工具。它的原始功能是“去掉路径中的目录部分”但带第二个参数时可以同时去掉指定的后缀basename /data/logs/nginx/access.log .log # 输出 access这个命令把路径/data/logs/nginx/access.log的目录部分剥掉只留下access.log然后再把结尾的.log去掉得到access。这个方式有个好处它天然处理了带路径的情况直接给人名适合配合路径字符串使用。比如for filepath in /var/log/app/*.log; do name$(basename $filepath .log) echo 处理文件$name done但局限也很明显第二参数只能指定一个后缀没法像%%.*那样剥到底。basename archive.tar.gz .gz得到archive.tar不是archive。而且basename是外部命令每次调用都会启动一个新进程在循环上千个文件时性能明显不如 Shell 内置的参数扩展。所以我的建议是能不用 basename 做后缀处理就不用优先用${var%.*}系列。如果你只是想在拿到路径后提取文件名那用basename反而更合适filename$(basename $filepath)这个在日志处理、文件上传脚本里很常用。2.4 用 sed、awk、cut 兜底的场景参数扩展和 basename 已经能应付绝大多数场景了但偶尔会有更复杂的需求比如“去掉从第一个点开始的所有内容”或者“只保留特定后缀”。这时候可以上 sed 或 awk。比如把foo.bar.baz中第一段点之前的内容取出来echo foo.bar.baz | cut -d. -f1 # 输出 foo用 awk 也可以echo foo.bar.baz | awk -F. {print $1} # 输出 foo但这种管道处理方式有个通病循环里每处理一个文件就起两三个子进程性能很差而且代码可读性反而不如参数扩展直观。我的原则是sed/awk/cut 这类工具适合处理“一次性的批处理命令行”不适合放在循环里做高频后缀操作。还有一个特殊场景是后缀本身不确定长度也不固定但你知道它是由字母组成的。用 sed 的正则剥掉echo report-2025.04.abc | sed s/\.[a-zA-Z]*$// # 输出 report-2025.04这个命令把末尾的一个点加一串字母删掉能保证只剥离“后缀”。但话又说回来这种场景用sed虽然灵活如果后缀名里包含数字比如.tar.gz2正则又要改。实际工作里99% 的需求用参数扩展就能解决真没必要为了炫技上 sed。3. 组合实战判断存在 去后缀在真实脚本里怎么用3.1 场景一批量重命名时去掉旧后缀最常见的组合场景就是批量重命名。假设你有一堆.txt.bak文件想统一去掉.bak后缀只留.txt#!/bin/bash for file in *.txt.bak; do # 先判断是不是普通文件防止目录被带进来 if [[ -f $file ]]; then newname${file%.bak} mv $file $newname echo 重命名$file - $newname fi done这里for file in *.txt.bak的 glob 展开就已经过滤了目录但加了-f判断会更安全。注意看newname${file%.bak}它精确匹配.bak所以a.txt.bak会变成a.txt不会误伤。不过这段脚本如果真在线上跑还会踩一个坑如果目标文件已经存在mv会直接覆盖。稳妥一点应该在覆盖前判断一下if [[ -f $newname ]]; then echo 警告$newname 已存在跳过 continue fi这正好用到了前面讲的“存在性判断”说明这两个基础技能在真实脚本里是连环关联的不是孤立的。3.2 场景二日志备份与巡检按日期归档另一个非常常见的场景是日志处理。比如你要备份access.log不想覆盖原来的文件就把日期拼到文件名中间#!/bin/bash log_dir/var/log/nginx today$(date %Y%m%d) if [[ -f $log_dir/access.log ]]; then backup_name${log_dir}/access_${today}.log cp $log_dir/access.log $backup_name echo 已备份 access.log 至 $backup_name fi这里先把日期20250414拼进变量再用cp备份。你可以看到判断存在用的是-f拼接文件名用的是字符串操作跟去后缀用的是同一套思路。但更有意思的是如果你不是想加一个日期后缀而是想把文件从app.log.1这种带数字后缀的名字转成app.log那就可以这样# 找出所有类似于 app.log.1、app.log.2 的文件重命名成 app.log for file in /var/log/app/app.log.*; do if [[ -f $file ]]; then # 去掉 .数字 后缀只保留 app.log newname${file%.[0-9]*} mv $file $newname echo $file - $newname fi done这里${file%.[0-9]*}会剥掉末尾的点加数字部分。比如app.log.3变成app.logapp.log.10也会变成app.log。注意一个细节${file%.[0-9]*}的 pattern 是.[0-9]*这是从右到左匹配第一个满足“点开头后面跟数字和任意字符”的部分。如果文件名里还有别的点比如my.app.log.3那它会匹配到.3还是.log.3答案是匹配最后一个满足条件的部分也就是.3。因为%是“从末尾开始的最短匹配”它会把尾部能匹配的最小部分掐掉。3.3 场景三清理脚本里只处理“存在”的备份文件再举一个我经常写的备份清理脚本。这个脚本会扫描备份目录里的旧备份超过 7 天的删除#!/bin/bash backup_dir/data/backup expire_days7 if [[ ! -d $backup_dir ]]; then echo 错误备份目录 $backup_dir 不存在 exit 1 fi find $backup_dir -type f -name *.bak -mtime $expire_days -print | while read -r file; do echo 清理过期备份$file rm -f $file done注意这里用! -d先判断目录不存在直接退出。这是脚本主流程外最常用的哨兵检查模式能避免在备份目录缺失时脚本误以为“没有备份文件”而安静地什么都不做实际上问题根本没有暴露。如果想把文件名去掉.bak后缀做记录还可以加上base_name${file##*/} # 去掉路径只留文件名 base_no_ext${base_name%.bak} # 去掉 .bak 后缀 echo 清理了 $base_no_ext 对应的备份3.4 一个稍完整的实战脚本最后拼一个实战脚本把判断存在和去后缀集中用一遍。假设场景是每晚把 Nginx 访问日志归档到指定目录归档文件带上日期并保留一份新的空日志文件同时清理 7 天前的归档。#!/bin/bash # 日志归档脚本 nginx_log/var/log/nginx/access.log archive_dir/data/logs/archive today$(date %Y%m%d) # 1. 判断源日志是否存在且非空 if [[ ! -f $nginx_log ]]; then echo 错误$nginx_log 不存在 exit 1 fi if [[ ! -s $nginx_log ]]; then echo 日志为空无法归档 exit 1 fi # 2. 创建归档目录如果不存在 if [[ ! -d $archive_dir ]]; then mkdir -p $archive_dir fi # 3. 归档access.log - access_20250414.log archive_file${archive_dir}/$(basename ${nginx_log%.*})_${today}.log cp $nginx_log $archive_file echo 归档完成$archive_file # 4. 清空原文件 : $nginx_log # 5. 清理 7 天前归档 find $archive_dir -type f -name *.log -mtime 7 -delete这段脚本里你能看到-f、-s、-d三种存在性判断也能看到${nginx_log%.*}去掉.log后缀然后用basename配合日期的组合。跑一圈下来基本就能把前面讲的知识点串起来了。4. 常见问题与排查技巧实录4.1 变量没加双引号路径带空格直接炸这是 shell 脚本里最经典的 bug没有之一。常见场景你从配置或 find 结果里拿到的文件路径带空格比如/data/my logs/access.log。然后直接if [ -f $file ]; thenShell 会把路径拆成两个词一个叫/data/my一个叫logs/access.log判断立刻出错。除了加双引号还有一个好习惯是从 find 输出读文件路径时用 while read -r 而不是 forfind . -type f -name *.log | while read -r file; do echo $file done用read -r读一行还能保留空格。如果你用的for file in $(find ...)遇到带空格的路径一样会拆词。4.2 变量未设置或为空导致判断失效设置set -u后未定义变量直接报错#!/bin/bash set -u if [[ -f $config_file ]]; then如果config_file从没被赋值脚本会直接退出并不会走到 if 里面。这个行为其实是好事能帮你提前发现变量名拼错。相对更安全的写法是给默认值config_file${CONFIG_FILE:-/etc/app/config.yml}这个写法用了:-参数展开如果CONFIG_FILE没设置或为空就取默认路径。判断存在之前先做一次默认值兜底能避免一多半的“文件不存在”问题。4.3 [ ] 和 [[ ]] 混用导致的兼容性怪问题单中括号[ ]兼容 POSIX sh双中括号[[ ]]是 Bash 扩展。如果你的脚本开头写的是#!/bin/sh特别是 on Debian 系的/bin/sh指向 dash那[[ ]]会直接报[[ not found语法错误。反过来也一样有些脚本是在 Bash 里写的但你把脚本交给别人别人的默认 shell 是 zsh[[ ]]也能用但一些细微行为不一样。我的建议脚本开头明确写#!/bin/bash并且全程统一用[[ ]]。如果非得兼容 sh那就全程用[ ]并且所有变量都加双引号。千万别混着用查 bug 的时候能把你查哭。4.4 -f 判断“存在”但其实是软链接或目录有一种层级很深的坑路径上存在一个文件但它是软链接或者是一个目录-f返回的是 false。比如ln -s /tmp/nonexist /tmp/linkfile if [[ -f /tmp/linkfile ]]; then echo 存在 else echo 不存在 fi结果会是“不存在”因为-f在软链接上会追查到链接指向的目标而目标nonexist不存在。如果你要判断“这个链接本身是否存在”要用-e或-L。同理-d遇到软链接指向目录时也返回 true。有时候你只是想判断“当前路径上有没有这个条目别管它是啥”那就用-e。我一般这样记忆-e是“路径上有没有”-f是“是不是普通文件”-d是“是不是目录”-L是“是不是软链接”。4.5 去后缀时遇到隐藏文件、多级后缀、无后缀文件的坑这一节集中讲去后缀的细节坑。第一个隐藏文件。.gitignore用${file%.*}原样返回这通常没问题但如果你期望把.gitignore变成空字符串就会翻车。这时候用${file##*.}提取后缀结果是gitignore跟直觉也不太一样。所以处理隐藏文件时要么先判断文件名的第一个字符是不是点要么干脆不要用%系列去处理隐藏文件。第二个多级后缀。archive.tar.gz%.*只去掉gz要到archive得用%%.*。如果你写的脚本处理的是.tar.gz这类双后缀%.*会让日志输出看起来很不一致。第三个无后缀文件。README用%.*还是返回README。这本身没问题但你要做“如果文件没有后缀就跳过”的处理时需要先判断一下有没有点if [[ $file *.* ]]; then name${file%.*} else name$file fi这个处理常见于通用的文件处理框架里既能保留无后缀文件的原名又能剥掉有后缀文件的扩展名。第四个注意 glob 特殊字符。如果文件名本身包含*或?或在 pattern 里用了正则语法%系列的匹配会受 glob 规则影响。比如filedata*.csv echo ${file%.*} # 输出 data因为 * 被当成了通配符进行匹配这会导致判断结果不符合预期。解决方法是尽量不要在文件名里放特殊字符或者用 basename 加引号来规避一部分问题。4.6 问题速查表现象原因排查与解决[: too many arguments或unary operator expected[中变量未加引号变量值为空或有空格给变量加引号或改用[[ ]][[ not found脚本用 sh/dash 解析改#!/bin/bash或改用[ ]文件存在但-f返回 false路径是目录或软链接失效用-e或-L判断${file%.*}只去掉一个后缀文件有多级后缀改用${file%%.*}隐藏文件去后缀后原样返回文件名第一个字符是点特殊处理隐藏文件find 循环里文件路径带空格被拆开for 循环遇到词拆分改用while read -rset -u后未定义变量直接退出变量未初始化使用${var:-default}给默认值目标文件已存在但被 mv/cp 覆盖没有做目标存在性判断先[[ -f $newname ]]再执行5. 一些常用的小技巧与心得最后分享几个我日常工作里积累的小习惯帮你把这两个技能用得更顺手。第一在.bashrc或脚本开头加一行set -euo pipefail-e让脚本在遇到错误时立刻退出-u让未定义变量直接报错-o pipefail让管道中任何一环出错都会导致整个管道返回非 0。这三件套加下去很多潜在的“文件不存在还硬操作”的问题会在第一时间暴露而不是一路错到最后。第二判断存在后尽量不要用source或.去加载一个可能很大的文件。先检查大小if [[ -f $conf -s $conf ]]; then source $conf fi这个写法把“存在”“非空”两个条件合在一个 if 里写起来干净读起来也直观。第三如果脚本里反复用${file%.*}或-f判断可以把这些逻辑封装成函数get_base_name() { local f$1 echo ${f%.*} }这样脚本里调用name$(get_base_name $file)语义更清晰后续要改成别的逻辑也只改一个地方。第四重视 shellcheck 这个工具。几乎所有上面提到的坑shellcheck 都能提前提示。它的检查规则非常多其中 SC2086变量未加引号和 SC2001shell 脚本里用 sed 替代参数扩展是我在团队里看到最高频的两条。装上之后shellcheck myscript.sh基本能把 90% 的隐藏 bug 扫出来。很多新手觉得 shell 脚本难调其实大部分问题都出在这些细枝末节上。这些东西说白了就是在写脚本的时候多想一秒钟文件真的存在吗它存在的话就一定是我想处理的类型吗我要去掉的后缀是最后一个后缀还是所有后缀把这些“为什么”想清楚了Shell 脚本写起来会顺手特别多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →