【问题标题】:In awk, how can I use a file containing multiple format strings with printf?在 awk 中,如何将包含多个格式字符串的文件与 printf 一起使用?
【发布时间】:2014-07-04 13:59:58
【问题描述】:

我有一个案例,我想使用来自文件的输入作为 awk 中printf() 的格式。当我在代码中的字符串中设置它时,我的格式有效,但当我从输入加载它时它不起作用。

这是问题的一个小例子:

$ # putting the format in a variable works just fine:
$ echo "" | awk -vs="hello:\t%s\n\tfoo" '{printf(s "bar\n", "world");}'
hello:  world
        foobar
$ # But getting the format from an input file does not.
$ echo "hello:\t%s\n\tfoo" | awk '{s=$0; printf(s "bar\n", "world");}'
hello:\tworld\n\tfoobar
$ 

所以...格式替换有效(“%s”),但不是制表符和换行符等特殊字符。知道为什么会这样吗?有没有办法“做某事”来输入数据以使其可用作格式字符串?

更新 #1:

作为进一步的示例,使用 bash heretext 考虑以下内容:

[me@here ~]$ awk -vs="hello: %s\nworld: %s\n" '{printf(s, "foo", "bar");}' <<<""
hello: foo
world: bar
[me@here ~]$ awk '{s=$0; printf(s, "foo", "bar");}' <<<"hello: %s\nworld: %s\n"
hello: foo\nworld: bar\n[me@here ~]$

据我所知,同样的事情发生在多个不同的 awk 解释器上,我无法找到任何解释原因的文档。

更新 #2:

我试图替换的代码目前看起来像这样,在 shell 中有嵌套循环。目前,awk 用于其printf,并且可以替换为基于shell的printf

#!/bin/sh

while read -r fmtid fmt; do
  while read cid name addy; do
    awk -vfmt="$fmt" -vcid="$cid" -vname="$name" -vaddy="$addy" \
      'BEGIN{printf(fmt,cid,name,addy)}' > /path/$fmtid/$cid
  done < /path/to/sampledata
done < /path/to/fmtstrings

示例输入是:

## fmtstrings:
1 ID:%04d Name:%s\nAddress: %s\n\n
2 CustomerID:\t%-4d\t\tName: %s\n\t\t\t\tAddress: %s\n
3 Customer: %d / %s (%s)\n

## sampledata:
5 Companyname 123 Somewhere Street
12 Othercompany 234 Elsewhere

我希望我能够构建这样的东西,通过一次调用 awk 来完成整个事情,而不是在 shell 中嵌套循环:

awk '

  NR==FNR { fmts[$1]=$2; next; }

  {
    for(fmtid in fmts) {
      outputfile=sprintf("/path/%d/%d", fmtid, custid);
      printf(fmts[fmtid], $1, $2) > outputfile;
    }
  }

' /path/to/fmtstrings /path/to/sampledata

显然,这不起作用,既是因为这个问题的实际主题,也是因为我还没有弄清楚如何优雅地将 awk 加入 $2..$n 到单个变量中。 (但这是一个可能的未来问题的主题。)

FWIW,我正在使用内置的 FreeBSD 9.2,但如果可以找到解决方案,我愿意使用 gawk。

【问题讨论】:

  • 在激发问题的更大背景下,您是否将有多个输入行表示多个格式字符串(以便printf 将被多次调用,使用相同的数据但不同的格式字符串),还是您打算在整个awk 程序中使用单个格式字符串(恰好是从文件中读取的)?为第一个场景设计一个合理的用途有点困难,但测试不同的格式选项可能是一个有效的用例。这很重要,因为评估一次可以通过多种方式完成;将多个输入分别评估为格式字符串更加困难。
  • @JonathanLeffler - 添加了更新#2,它更好地展示了我目前正在做什么以及我实际上希望看到的结果。找到足够多的例子来证明问题,同时保留足够的细节以免无用地加重问题的负担,这是一种平衡行为。
  • @Graham 我刚刚更新了我的答案,以包含一种相当简洁的方法来在单个 awk 命令中扩展转义字符。

标签: awk printf


【解决方案1】:

为什么要举一个如此冗长而复杂的例子?这说明了问题:

$ echo "" | awk '{s="a\t%s"; printf s"\n","b"}'
a       b

$ echo "a\t%s" | awk '{s=$0; printf s"\n","b"}'
a\tb

在第一种情况下,字符串 "a\t%s" 是一个字符串文字,因此被解释了两次 - 一次是在 awk 读取脚本时,另一次是在执行时,所以 \t 被扩展在第一遍,然后在执行时,awk 在格式化字符串中有一个文字制表符。

在第二种情况下,awk 在格式化字符串中仍然有字符反斜杠和 t - 因此行为不同。

您需要一些东西来解释这些转义字符,一种方法是调用 shell 的 printf 并读取结果(根据@EtanReiser 的出色观察进行更正,即我在应该有单引号的地方使用双引号,在此处实现通过 \047,以避免外壳扩展):

$ echo 'a\t%s' | awk '{"printf \047" $0 "\047 " "b" | getline s; print s}'
a       b

如果您不需要变量中的结果,您可以调用system()

如果您只是想扩展转义字符,因此您不需要在 shell printf 调用中提供 %s 参数,您只需要转义所有 %s(注意已经-转义%s)。

如果您愿意,可以调用 awk 而不是 shell printf

请注意,这种方法虽然笨拙,但比调用eval 安全得多,后者可能只执行rm -rf /*.* 之类的输入行!

在 Arnold Robbins(gawk 的创建者)和 Manuel Collado(另一位著名的 awk 专家)的帮助下,下面是一个扩展单字符转义序列的脚本:

$ cat tst2.awk
function expandEscapes(old,     segs, segNr, escs, idx, new) {
    split(old,segs,/\\./,escs)
    for (segNr=1; segNr in segs; segNr++) {
        if ( idx = index( "abfnrtv", substr(escs[segNr],2,1) ) )
            escs[segNr] = substr("\a\b\f\n\r\t\v", idx, 1)
        new = new segs[segNr] escs[segNr]
    }
    return new
}

{
    s = expandEscapes($0)
    printf s, "foo", "bar"
}

.

$ awk -f tst2.awk <<<"hello: %s\nworld: %s\n"
hello: foo
world: bar

或者,这应该在功能上等效但不是 gawk 特定的:

function expandEscapes(tail,   head, esc, idx) {
    head = ""
    while ( match(tail, /\\./) ) {
        esc  = substr( tail, RSTART + 1, 1 )
        head = head substr( tail, 1, RSTART-1 )
        tail = substr( tail, RSTART + 2 )
        idx  = index( "abfnrtv", esc )
        if ( idx )
             esc = substr( "\a\b\f\n\r\t\v", idx, 1 )
        head = head esc
    }

    return (head tail)
} 

如果您愿意,可以通过将 split() RE 更改为

,将概念扩展到八进制和十六进制转义序列
/\\(x[0-9a-fA-F]*|[0-7]{1,3}|.)/

\\ 后面的十六进制值:

c = sprintf("%c", strtonum("0x" rest_of_str))

对于八进制值:

c = sprintf("%c", strtonum("0" rest_of_str))

【讨论】:

  • 一种比推荐的更可怕的可能性是编写一个awk 函数,如function map_escapes(s, t) { t = s; gsub(/\\n/, "\n", t); gsub(/\\t/, "\t", t); …; return t; } 并使用它来操作从文件中读取的格式字符串。您可以根据需要对其进行扩展以处理其他转义序列。
  • @mklement0,实际上 FreeBSD 的 awk 在-v 之后不需要空格,至少在我这里有的 9.2 和 10.0 中。你使用的是什么版本的 FreeBSD?
  • @JonathanLeffler,比起在子shell 中生成/bin/printf,我更喜欢这样! :) 但是……为什么这样行? \t\t 有什么区别?!?
  • @EdMorton 你认为你的代码比 eval 更安全再想一想:echo '$(rm -rf /)' | awk '{"printf \"" $0 "\" " "b" | getline s; print s}' 请不要在你的系统上这样做:)
  • 好的,顺便说一句,问题不是printf 问题是| 甚至system() 他们不能盲目做。
【解决方案2】:

由于该问题明确要求 awk 解决方案,因此这里有一个适用于我所知道的所有 awk 的解决方案。这是一个概念验证;错误处理很糟糕。我已经尝试指出可以改进的地方。

正如许多评论员所指出的那样,关键是 awk 的 printf —— 就像它所基于的 C 标准函数一样—— 不会解释格式字符串中的反斜杠转义。但是,awk 会在命令行赋值参数中解释它们。

awk 'BEGIN  {if(ARGC!=3)exit(1);
             fn=ARGV[2];ARGC=2}
     NR==FNR{ARGV[ARGC++]="fmt="substr($0,length($1)+2);
             ARGV[ARGC++]="fmtid="$1;
             ARGV[ARGC++]=fn;
             next}
     {match($0,/^ *[^ ]+[ ]+[^ ]+[ ]+/);
      printf fmt,$1,$2,substr($0,RLENGTH+1) > ("data/"fmtid"/"$1)
     }' fmtfile sampledata

( 这里发生的是 'FNR==NR' 子句(仅在第一个文件上执行)从第一个文件的每一行添加值(fmtidfmt)作为命令行分配,然后插入数据文件名作为命令行参数。在awk 中,作为命令行参数的赋值被简单地执行,就好像它们是来自带有隐式引号的字符串常量的赋值,包括反斜杠转义处理(除非参数中的最后一个字符是反斜杠,它不会转义隐式结束双引号)。这种行为是 Posix 强制要求的,处理参数的顺序也是如此,这样就可以随时添加参数。

在编写时,必须为脚本提供恰好两个参数:格式和数据(按此顺序)。显然,还有一些改进的余地。

sn-p 还显示了连接尾随字段的两种方式。

在格式文件中,我假设这些行表现良好(没有前导空格;格式 id 后面正好有一个空格)。有了这些限制,substr($0, length($1)+2) 恰好是第一个字段和一个空格之后的行部分。

处理数据文件时,可能需要以较少的约束来执行此操作。首先,使用正则表达式 /^ *[^ ]+[ ]+[^ ]+[ ]+/ 调用内置的 match 函数,该表达式匹配前导空格(如果有)和两个空格分隔的字段,以及以下空格。 (最好也允许制表符。)一旦正则表达式匹配(并且不应该假设匹配,所以还有另一件事要修复),变量RSTARTRLENGTH被设置,所以substr($0, RLENGTH+1)从第三个字段开始拾取所有内容。 (同样,这都是 Posix 标准的行为。)

老实说,我会使用 shell printf 来解决这个问题,我不明白你为什么觉得这个解决方案不是最优的。 shell printf 以格式解释反斜杠转义,shell read -r 将按照您想要的方式分割行。因此,据我所知,根本没有理由使用 awk。

【讨论】:

  • +1 表示这个解决方案,但我坚信你最后写的内容老实说,我会使用 shell printf 来解决这个问题 为什么要强制 awk它无法处理。直接在 shell 中执行此操作非常简单。
  • +1 获得巧妙的纯 awk 解决方案;但是,substr($3,RLENGTH+1) 中的$3 应该是$0,并且"data/"fmtid"/"$1 需要在它周围加上括号才能使OS X 10.9.4 上的FreeBSD awk 满意。至于为什么要使用 awk: 性能(在纯 shell 代码中循环行很慢)。老实说,在基于awk 的答案中,我自己的答案仍然是最简单的,同时同样强大。
  • @mklement0:感谢鹰眼。修复了这两个错误。确实,您的解决方案有其优点;只读取一次数据文件会更好,因为一旦将输出定向到单个文件,内/外循环的顺序就无关紧要了。如果我在噪音中看到它,我可能不会写这个,但是这个解决方案确实展示了如何进行行拆分。
  • 谢谢,@rici。除了演示行拆分之外,您的解决方案还值得注意的是 ARGV 的动态重写(我不知道可以这样做),虽然在 this 情况下不是最有效的方法,但可能会出现在其他情况下派上用场。
【解决方案3】:

Ed Morton 清楚地显示了问题(编辑:and it's now complete, so just go accept it):awk 的字符串文字处理处理了转义,文件 I/O 代码不是词法分析器。

这是一个简单的解决方法:决定你想要支持的逃逸,并支持他们。如果您正在做不需要处理转义的反斜杠的特殊用途的工作,这是一个单行表格

awk '{ gsub(/\\n/,"\n"); gsub(/\\t/,"\t"); printf($0 "bar\n", "world"); }' <<\EOD
hello:\t%s\n\tfoo
EOD

但为了省心,只需使用链接答案中的完整形式。

【讨论】:

    【解决方案4】:

    @Ed Morton's answer很好地解释了问题。

    一个简单的解决方法是:

    • 通过awk 变量传递格式字符串文件内容,使用命令替换,
    • 假设文件不是太大而无法全部读入内存。

    使用 GNU awkmawk

    awk -v formats="$(tr '\n' '\3' <fmtStrings)" '
         # Initialize: Split the formats into array elements.
        BEGIN {n=split(formats, aFormats, "\3")}
         # For each data line, loop over all formats and print.
        { for(i=1;i<n;++i) {printf aFormats[i] "\n", $1, $2, $3} }
        ' sampleData
    

    注意:

    • 此解决方案的优势在于它可以通用 - 您无需预期特定的转义序列并专门处理它们。
    • 在 FreeBSD awk 上,这几乎 可以工作,但是 - 遗憾的是 - split() 仍然被换行符分割,尽管有明确的分隔符 - 这闻起来像一个错误。在 20070501 (OS X 10.9.4) 和 20121220 (FreeBSD 10.0) 版本上观察到。
    • 以上解决了核心问题(为简洁起见,它省略了从格式字符串前面剥离ID,并省略了输出文件创建逻辑)。

    解释:

    • tr '\n' '\3' &lt;fmtStrings 将格式字符串文件中的 actual 换行符替换为 \3 (0x3) 字符,以便以后能够将它们与嵌入在行中的 \n 转义序列区分开来,当分配给变量formats(根据需要)时,awk 会变成实际的换行符。
      \3 (0x3) - ASCII 文本结尾字符。 - 被任意选择作为辅助分隔符,假定输入文件中不存在该分隔符。
      请注意,不能使用\0 (NUL),因为awk 将其解释为 字符串,导致split() 将字符串拆分为单个字符。
    • awk 脚本的BEGIN 块内,split(formats, aFormats, "\3") 然后将组合的格式字符串拆分回单独的格式字符串。

    【讨论】:

      【解决方案5】:

      我必须创建另一个答案才能开始干净,我相信我已经找到了一个很好的解决方案,再次使用 perl:

       echo '%10s\t:\t%10s\r\n' | perl -lne 's/((?:\\[a-zA-Z\\])+)/qq[qq[$1]]/eeg; printf "$_","hi","hello"'  
              hi  :        hello
      

      那个坏小子s/((?:\\[a-zA-Z\\])+)/qq[qq[$1]]/eeg会翻译我能想到的任何元字符,让我们和cat -A一起看看:

      echo '%10s\t:\t%10s\r\n' | perl -lne 's/((?:\\[a-zA-Z\\])+)/qq[qq[$1]]/eeg; printf "$_","hi","hello"'   | cat -A
              hi^I:^I     hello^M$
      

      PS。我没有创建那个正则表达式,我用谷歌搜索了 unquote meta 并找到了here

      【讨论】:

        【解决方案6】:

        您正在尝试做的事情称为模板。我建议 shell 工具不是这项工作的最佳工具。一个安全的方法是使用模板库,例如用于 Perl 的 Template Toolkit 或用于 Python 的 Jinja2

        【讨论】:

        • +1 了解全局。 Template Toolkit自带CLI,而Jinja2没有,但是有第三方包;例如:github.com/kolypto/j2cli(使用pip,通过[sudo] pip install j2cli 安装)。
        【解决方案7】:

        问题在于echo 没有解释特殊字符\t\n:它确保它们被理解为原样字符串,而不是列表和换行符。此行为可以通过您提供给 echo 的 -e 标志来控制,而根本不需要更改您的 awk 脚本:

        echo -e "hello:\t%s\n\tfoo" | awk '{s=$0; printf(s "bar\n", "world");}'
        

        多多!! :)

        编辑: 好的,所以在 Chrono 正确提出的观点之后,我们可以设计与原始请求相对应的另一个答案,以从文件中读取模式:

        echo "hello:\t%s\n\tfoo" > myfile
        awk 'BEGIN {s="'$(cat myfile)'" ; printf(s "bar\n", "world")}'
        

        当然,在上面我们必须小心引用,因为 $(cat myfile) 不会被 awk 看到,而是被 shell 解释。

        【讨论】:

        • 一旦你以这种方式使用它,\n 就不再是格式的一部分了;它成为记录分隔符。
        • 你是绝对正确的,Chrono,然后使用 keep on ussign echo 传递模式变得非常棘手......
        • 感谢您的回复,但正如 Chrono 指出的那样,这不是正确的解决方案,因为 (1) 它没有解决 awk 对字符串的解释(考虑到输入实际上是来自一个文件,而不是像小例子中的echo),并且(2)它将格式字符串分成多行。例如,hello: %s\nworld: %s\n 格式字符串将不起作用,因为第二个 %s 不会成为格式的一部分。
        • 好的,所以根据您的编辑,假设myfile 有多行,每行都是需要由 awk 解释的格式字符串。现在会发生什么?
        【解决方案8】:

        这看起来非常难看,但它适用于这个特殊问题:

        s=$0;
        gsub(/'/, "'\\''", s);
        gsub(/\\n/, "\\\\\\\\n", s);
        "printf '%b' '" s "'" | getline s;
        gsub(/\\\\n/, "\n", s);
        gsub(/\\n/, "\n", s);
        printf(s " bar\n", "world");
        
        1. 用外壳转义的单引号 ('\'') 替换所有单引号。
        2. 将所有正常显示为\n 的转义换行符序列替换为显示为\\\\n 的序列。使用 \\\\n 作为实际的替换字符串就足够了(意思是如果你打印了 \\n 就会打印出来),但是我在 POSIX 模式下的 gawk 版本搞砸了。
        3. 调用 shell 以执行 printf '%b' 'escape'\''d format' 并使用 awk 的 getline 语句检索该行。
        4. 取消转义\\n 以产生换行符。如果 POSIX 模式下的 gawk 运行良好,则无需执行此步骤。
        5. 取消转义 \n 以产生换行符。

        否则你只能为每个可能的转义序列调用 gsub 函数,这对于\001\002 等来说是很糟糕的。

        【讨论】:

          【解决方案9】:

          格雷厄姆,

          Ed Morton 的解决方案是最好的(也许是唯一的)一个。

          我包含这个答案是为了更好地解释为什么你会看到你所看到的。

          字符串就是字符串。这里令人困惑的部分是 WHERE awk 将\t 转换为制表符,将\n 转换为换行符等。反斜杠和tprintf 中使用时似乎并非如此格式。相反,翻译发生在 assignment,因此 awk 将选项卡存储为格式的一部分,而不是在运行 printf 时进行翻译。

          这就是 Ed 的功能起作用的原因。从标准输入或文件中读取时,不会执行将实现特殊字符翻译的 assignment。在 awk 中运行命令 s="a\tb"; 后,您将获得一个不包含反斜杠或 t 的三字符字符串。

          证据:

          $ echo "a\tb\n" | awk '{ s=$0; for (i=1;i<=length(s);i++) {printf("%d\t%c\n",i,substr(s,i,1));} }'
          1       a
          2       \
          3       t
          4       b
          5       \
          6       n
          

          $ awk 'BEGIN{s="a\tb\n"; for (i=1;i<=length(s);i++) {printf("%d\t%c\n",i,substr(s,i,1));} }'
          1       a
          2               
          3       b
          4       
          

          你去吧。

          正如我所说,Ed 的回答为您提供了出色的功能。但是如果你能预测你的输入会是什么样子,你可能会得到一个更简单的解决方案。知道这些东西是如何被解析的,如果你有一组有限的字符需要翻译,你也许可以用一些简单的东西来生存:

          s=$0;
          gsub(/\\t/,"\t",s);
          gsub(/\\n/,"\n",s);
          

          【讨论】:

            【解决方案10】:

            这是一个很酷的问题,我不知道 awk 中的答案,但在 perl 中你可以使用 eval

            echo '%10s\t:\t%-10s\n' |  perl -ne ' chomp; eval "printf (\"$_\", \"hi\", \"hello\")"'
                    hi  :   hello  
            

            PS。当你在任何语言中使用eval 时要注意代码注入的危险,不只是 eval 任何系统调用都不能盲目地进行。

            Awk 中的示例:

            echo '$(whoami)' | awk '{"printf \"" $0 "\" " "b" | getline s; print s}'
            tiago
            

            如果输入是$(rm -rf /) 怎么办?你可以猜到会发生什么:)


            ikegami补充道:

            为什么还要考虑使用eval\n 转换为换行符并将\t 转换为制表符?

            echo '%10s\t:\t%-10s\n' | perl -e'
               my %repl = (
                  n => "\n",
                  t => "\t",
               );
            
               while (<>) {
                  chomp;
                  s{\\(?:(\w)|(\W))}{
                     if (defined($2)) {
                        $2
                     }
                     elsif (exists($repl{$1})) {
                        $repl{$1}
                     }
                     else {
                        warn("Unrecognized escape \\$1.\n");
                        $1
                     }
                  }eg;
            
                  printf($_, "hi", "hello");
               }
            '
            

            短版:

            echo '%10s\t:\t%-10s\n' | perl -nle'
               s/\\(?:(n)|(t)|(.))/$1?"\n":$2?"\t":$3/seg;
               printf($_, "hi", "hello");
            '
            

            【讨论】:

            • 这正是需要的,而这正是 awk 所缺乏的。
            • 这是一个代码注入攻击,只是乞求发生。
            • 期待第一个包含rm -rf /*.*的输入文件:-)
            • 当然eval在任何语言中都是危险的,但是有一些方法可以避免代码注入,但这会使答案太冗长
            猜你喜欢
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2023-03-10
            • 2021-10-06
            • 2017-08-04
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多