返回

AWK技巧:如何按空格分割但不拆分引号内的字段?

Linux

用 AWK 处理带引号的字段:空格分割,但引号内除外

处理文本数据时,经常会遇到需要按特定分隔符(比如空格)拆分字段的情况。但如果文本里包含了带引号的字符串,而引号内的内容也包含分隔符,事情就变得有点棘手了。咱不希望引号里的内容被错误地拆开。

问题来了:空格分割遇上引号

假设有这样一个配置文件 argstext

Pipelines:
YOLO-Goby

Pipeline Container Arguments:
Bubblerargs:
python Collect_Unpack.py --primary_images --processes 1 --every_nth 25 --output_folder

YOLOargs:
python 03_YOLO_infer_no_labels.py --img_list_txt --output_name inference_output_test --confidence 0.1 --weights "/mnt/c/Users/jmilitello/OneDrive - DOI/ARIS_MLM_Files/Literature/Literature for Pete/Code/best.pt"

目标是提取 YOLOargs: 下面那行的参数,放到 Bash 数组里。参数之间用空格隔开,但那个带引号的文件路径 "/mnt/c/Users/.../best.pt" 应该被看作一个整体 ,数组的一个元素,而不是被空格拆成好几块。

如果直接用 awk 按空格分割,就像这样:

# 提取 YOLOargs 下一行的内容
line_content=$(awk '/YOLOargs:/{getline; print}' argstext)
# 尝试直接放入数组
YOLOcontainerargs=($line_content)

# 查看数组内容
declare -p YOLOcontainerargs

你会发现数组 YOLOcontainerargs 里面的文件路径被空格无情地分割了:

declare -a YOLOcontainerargs=([0]="python" [1]="03_YOLO_infer_no_labels.py" [2]="--img_list_txt" [3]="--output_name" [4]="inference_output_test" [5]="--confidence" [6]="0.1" [7]="--weights" [8]="\"/mnt/c/Users/jmilitello/OneDrive" [9]="-" [10]="DOI/ARIS_MLM_Files/Literature/Literature" [11]="for" [12]="Pete/Code/best.pt\"")
# 注意看元素 812,路径被拆开了

这显然不是咱们想要的结果。

为啥会这样?AWK 的默认分割逻辑

awk 工作的时候,默认会把空格、制表符这类空白字符当作字段分隔符(FS, Field Separator)。它一行一行地读入数据,然后根据 FS 把行切成一个个字段,存到 $1, $2, $3 这些变量里。

awk 遇到 ... --weights "/mnt/c/.../best.pt" 这一部分时,它眼里的分隔符就是空格。所以 "/mnt/c/Users/jmilitello/OneDrive-DOI/ARIS_MLM_Files/Literature/Literature 等等都被认为是独立的字段。awk 默认情况下并不理解引号的特殊含义,不会把引号括起来的内容当作一个整体来保护。

怎么办?几种 AWK 搞定方法

要让 awk 聪明地处理引号,我们需要换个思路,不能光靠默认的 FS

方法一:GNU AWK 的 FPAT 大显身手 (推荐)

如果你用的是 GNU Awk (通常就是 Linux 发行版和 WSL 里的 awk,可以通过 awk --version 查看,带有 GNU Awk 字样),那就有个很方便的特性:FPAT (Field Pattern)。

FS 定义的是 分隔符 是什么样子,而 FPAT 定义的是 字段本身 是什么样子。这就好办了!我们可以定义字段要么是“一串不包含空格和双引号的字符”,要么是“一个由双引号包围的字符串”。

原理:

FPAT 接受一个正则表达式。awk 会用这个表达式去匹配输入行,每次成功匹配到的内容就作为一个字段。

  • 匹配不含空格或引号的字段: [^[:space:]"]+
    • [^...]: 匹配不在括号内的任意字符。
    • [:space:]: 代表所有空白字符(空格、tab 等)。
    • ": 就是双引号字符。
    • +: 表示匹配前面这个规则一次或多次。
    • 合起来就是:匹配一个或多个既不是空白字符也不是双引号的字符。
  • 匹配双引号包围的字段: "[^"]*"
    • ": 匹配开头的双引号。
    • [^"]: 匹配任意不是双引号的字符。
    • *: 匹配前面这个规则零次或多次(允许空字符串 "")。
    • ": 匹配结尾的双引号。
    • 合起来就是:匹配一个由双引号包围,内部不含双引号的字符串。

把这两种模式用 | (或) 连接起来,就是 FPAT 的值:FPAT = "([^[:space:]\"]+)|(\"[^\"]*\")"

代码/步骤:

  1. 找到目标行: 仍然可以用之前的 /YOLOargs:/ 模式来定位。
  2. 设置 FPAT 并处理: 在找到目标行后,用 getline 读取下一行,然后设置 FPAT 并遍历所有字段 ($1$NF)。
# fpat_parser.awk
/YOLOargs:/ {
  # 读下一行
  getline
  # 设置 FPAT 规则
  FPAT = "([^[:space:]\"]+)|(\"[^\"]*\")"
  # 遍历所有通过 FPAT 匹配到的字段
  for (i = 1; i <= NF; i++) {
    # 打印每个字段,用一个特殊的分隔符(比如换行符或 \0)
    # 以便 Bash 能准确地读入数组,这里用换行符演示
    print $i
  }
  exit # 处理完就退出,避免处理其他行
}

在 Bash 中使用:

# 先用 awk 提取并按正确方式分割字段,每行一个字段
# 注意:这里需要 gawk (GNU Awk)
fields_output=$(gawk -f fpat_parser.awk "${argstext}")

# 使用 mapfile (或 readarray) 将 awk 的输出读入 Bash 数组
# 这是最安全的方式,能正确处理包含空格的元素
mapfile -t YOLOcontainerargs < <(printf '%s\n' "$fields_output")

# 查看结果
declare -p YOLOcontainerargs

执行后,数组内容就是期望的样子了:

declare -a YOLOcontainerargs=([0]="python" [1]="03_YOLO_infer_no_labels.py" [2]="--img_list_txt" [3]="--output_name" [4]="inference_output_test" [5]="--confidence" [6]="0.1" [7]="--weights" [8]="\"/mnt/c/Users/jmilitello/OneDrive - DOI/ARIS_MLM_Files/Literature/Literature for Pete/Code/best.pt\"")
# 注意元素 8,带引号的路径现在是一个完整的元素了!

安全建议:

  • FPAT 的正则表达式对于嵌套引号或包含转义引号(\")的场景处理起来会更复杂,上面的模式只处理了简单情况。如果输入源不可控,可能包含复杂的引用结构,这种简单正则可能会失效或出错。
  • 始终考虑输入数据的来源和格式。如果可能,对输入进行预处理或校验,确保其符合预期的模式。

进阶使用技巧:

  • FPAT 可以写得更健壮,比如处理单引号 '...' 或处理转义字符 \。例如,一个更复杂的 FPAT 可能需要处理 \\"\\'
  • 如果性能是关键,对于超大文件,FPAT 相较于某些手动解析方法通常效率更高,因为它利用了 awk 底层的优化 regex 引擎。
  • 需要注意 FPAT 是 GNU Awk 特有的,如果你的脚本需要在只安装了 POSIX awk (比如 macOS 或某些老旧 Unix 系统上的默认 awk) 的环境运行,这个方法就不行了。

方法二:手动解析循环和 match 函数 (更通用,稍复杂)

如果不能用 FPAT (比如环境限制),或者想了解更底层的处理方式,可以手动编写 awk 代码来解析。基本思路是遍历字符串,判断当前是在引号内还是引号外,然后据此决定如何分割。

原理:

  1. 找到 YOLOargs: 的下一行。
  2. 使用循环和 awk 的字符串函数(如 match, substr)来扫描这一行。
  3. 维护一个状态变量,标记当前是否在引号内部。
  4. 遇到非引号、非空格字符时,如果不在引号内,开始积累一个字段;如果遇到空格且不在引号内,标志一个字段结束。
  5. 遇到引号时,切换状态,并将引号内的所有内容(直到下一个匹配的引号)当作一个字段。

这种方法逻辑比较绕,容易出错。用 match 函数寻找下一个引号或下一个空白,然后用 substr 提取片段,会稍微简洁一点。

代码/步骤:

下面是一个使用 matchsubstr 的简化版手动解析逻辑:

# manual_parser.awk
function parse_quoted_line(line, parts,      # 输出数组
                           pos, len, rstart, rlength, field, in_quotes) {
    pos = 1 # 当前处理位置
    len = length(line)
    delete parts # 清空输出数组

    while (pos <= len) {
        # 跳过开头的空格
        while (substr(line, pos, 1) == " ") {
            pos++
        }
        if (pos > len) break # 到行尾了

        in_quotes = 0
        if (substr(line, pos, 1) == "\"") { # 引号开头
            in_quotes = 1
            pos++ # 跳过开头的引号
            # 寻找下一个不在转义符后的引号
            # 简单版本:直接找下一个引号
            if (match(substr(line, pos), /[^"]*/)) {
                 field = "\"" substr(line, pos, RLENGTH) "\"" # 包含引号
                 pos += RLENGTH + 1 # 跳过内容和结尾引号
            } else { # 没找到结尾引号,视为错误或取到行尾
                 field = "\"" substr(line, pos) # 包含开头引号,直到行尾
                 pos = len + 1
            }
        } else { # 非引号开头
            # 寻找下一个空格或引号
            if (match(substr(line, pos), /[^ "]+/)) {
                 field = substr(line, pos, RLENGTH)
                 pos += RLENGTH
            } else { # 应该不会到这里,除非行尾有空格?
                 break
            }
        }
        parts[length(parts) + 1] = field # 添加到数组
    }
}

/YOLOargs:/ {
    getline current_line
    parse_quoted_line(current_line, fields_array)
    for (i = 1; i <= length(fields_array); i++) {
        print fields_array[i] # 同样,每行打印一个字段
    }
    exit
}

在 Bash 中使用(与方法一类似):

# 使用 awk 执行手动解析脚本
fields_output=$(awk -f manual_parser.awk "${argstext}")

# 同样使用 mapfile 读入数组
mapfile -t YOLOcontainerargs < <(printf '%s\n' "$fields_output")

# 查看结果
declare -p YOLOcontainerargs

输出结果应该与使用 FPAT 的方法相同。

安全建议:

  • 手动解析代码比 FPAT 更容易出 bug,尤其是在处理边缘情况时(如空字符串、连续空格、转义引号、未闭合的引号等)。上面示例代码是简化的,并没有处理所有复杂情况。
  • 务必充分测试手动解析的脚本,覆盖各种可能的输入。

进阶使用技巧:

  • 可以增强 parse_quoted_line 函数来处理转义引号 \"。这通常需要更细致地检查引号前面的字符是不是反斜杠。
  • 性能方面,纯 awk 脚本实现的循环解析通常比 C 语言实现的 FPAT 慢,但对于不是特别长的行或不是海量数据,差异可能不明显。
  • 这种手动方法的好处是它不依赖 GNU Awk 特性,在更多版本的 awk 上也能运行。

整合到 Bash 脚本

前面两个方法的核心都是让 awk 正确地输出字段,每个字段占一行。然后 Bash 这边使用 mapfile (或者别名 readarray) 把这些行读入数组。

#!/bin/bash

argstext="argstext" # 假设配置文件名叫这个

# 创建 argstext 文件用于测试
cat > "${argstext}" << EOF
Pipelines:
YOLO-Goby

Pipeline Container Arguments:
Bubblerargs:
python Collect_Unpack.py --primary_images --processes 1 --every_nth 25 --output_folder

YOLOargs:
python 03_YOLO_infer_no_labels.py --img_list_txt --output_name inference_output_test --confidence 0.1 --weights "/mnt/c/Users/jmilitello/OneDrive - DOI/ARIS_MLM_Files/Literature/Literature for Pete/Code/best.pt"
EOF

# --- 使用 FPAT (需要 gawk) ---
gawk_script='
/YOLOargs:/ {
  getline
  FPAT = "([^[:space:]\"]+)|(\"[^\"]*\")"
  for (i = 1; i <= NF; i++) {
    # 为确保 mapfile 正确处理,可以移除字段两端的引号(如果需要裸值)
    # current_field = $i
    # gsub(/^"|"$/, "", current_field) # 取消注释此行以移除引号
    # print current_field
    print $i # 保持原样,带引号
  }
  exit
}'

# 执行 gawk 并用 mapfile 读取
mapfile -t YOLOcontainerargs_gawk < <(gawk "$gawk_script" "${argstext}")

echo "使用 FPAT 的结果:"
declare -p YOLOcontainerargs_gawk

# --- 使用手动解析 (通用 awk) ---
manual_awk_script='
function parse_quoted_line(line, parts, pos, len, rstart, rlength, field, in_quotes) {
    pos = 1; len = length(line); delete parts
    while (pos <= len) {
        while (substr(line, pos, 1) == " ") { pos++ }
        if (pos > len) break
        if (substr(line, pos, 1) == "\"") {
            in_quotes = 1; pos++
            # 简化版找下一个引号
            match_len = match(substr(line, pos), /[^"]*/)
            field = "\"" substr(line, pos, match_len) "\""
            pos += match_len + 1
        } else {
            match_len = match(substr(line, pos), /[^ "]+/)
            field = substr(line, pos, match_len)
            pos += match_len
        }
        parts[length(parts) + 1] = field
    }
    # 将结果填充回关联数组(或者直接用函数参数传递)
    for(i=1; i<=length(parts); ++i) _global_parts[i] = parts[i]; _global_parts_len = length(parts);
}
/YOLOargs:/ {
    getline current_line
    # 调用函数,结果存放在 _global_parts
    parse_quoted_line(current_line, _global_parts)
    for (i = 1; i <= _global_parts_len; i++) {
         # gsub(/^"|"$/, "", _global_parts[i]) # 可选:移除引号
        print _global_parts[i]
    }
    exit
}'


# 执行通用 awk 并用 mapfile 读取
mapfile -t YOLOcontainerargs_manual < <(awk "$manual_awk_script" "${argstext}")

echo "使用手动解析的结果:"
declare -p YOLOcontainerargs_manual

# 清理测试文件
# rm "${argstext}"

这个脚本演示了如何将 awk 代码嵌入 Bash,并通过 mapfile 安全地创建数组。你可以根据需要选择是否移除提取出字段两端的引号。对于那个文件路径,保留引号通常更安全,避免 Bash 后续处理时因路径中的空格再次产生问题。

相关资源