Sylvain Leroux 发布的文章

我们都知道如何从键盘输入文字,不是吗?

那么,请允许我挑战你在你最爱的文本编辑器中输入这段文字:

«Ayumi moved to Tokyo in 1993 to pursue her career» said Dmitrii

这段文字难以被输入因为它包含着:

  • 键盘上没有的印刷符号,
  • 平假名日文字符,
  • 为符合平文式罗马字标准,日本首都的名字中的两个字母 “o” 头顶带有长音符号,
  • 以及最后,用西里尔字母拼写的名字德米特里。

毫无疑问,想要在早期的电脑中输入这样的句子是不可能的。这是因为早期电脑所使用的字符集有限,无法兼容多种书写系统。而如今类似的限制已不复存在,马上我们就能在文中看到。

电脑是如何储存文字的?

计算机将字符作为数字储存。它们再通过表格将这些数字与含有意义的字形一一对应。

在很长一段时间里,计算机将每个字符作为 0 到 255 之间的数字储存(这正好是一个字节的长度)。但这用来代表人类书写所用到的全部字符是远远不够的。而解决这个问题的诀窍在于,取决于你住在地球上的哪一块区域,系统会分别使用不同的对照表。

这里有一张在法国常被广泛使用的对照表 ISO 8859-15

The ISO 8859-15 encoding

如果你住在俄罗斯,你的电脑大概会使用 KOI8-R 或是 Windows-1251 来进行编码。现在让我们假设我们在使用后者:

The Windows-1251 encoding is a popular choice to store text written using the Cyrillic alphabets

对于 128 之前的数字,两张表格是一样的。这个范围与 US-ASCII 相对应,这是不同字符表格之间的最低兼容性。而对于 128 之后的数字,这两张表格则完全不同了。

比如,依据 Windows-1251,字符串 “said Дмитрий” 会被储存为:

115 97 105 100 32 196 236 232 242 240 232 233

按照计算机科学的常规方法,这十二个数字可被写成更加紧凑的十六进制:

73 61 69 64 20 c4 ec e8 f2 f0 e8 e9

如果德米特里发给我这份文件,我在打开后可能会看到:

said Äìèòðèé

这份文件 看起来 被损坏了,实则不然。这些储存在文件里的数据,即数字,并没有发生改变。被显示出的字符与 另一张表格 中的数据相对应,而非文字最初被写出来时所用的编码表。

让我们来举一个例子,就以字符 “Д” 为例。按照 Windows-1251,“Д” 的数字编码为 196(c4)。储存在文件里的只有数字 196。而正是这同样的数字在 ISO8859-15 中与 “Ä” 相对应。这就是为什么我的电脑错误地认为字形 “Ä” 就是应该被显示的字形。

When the same text file is written then read again but using a different encoding

多提一句,你依然可以时不时地看到一些错误配置的网站展示,或由 用户邮箱代理 发出的对收件人电脑所使用的字符编码做出错误假设的邮件。这样的故障有时被称为乱码(LCTT 译注:原文用词为 mojibake, 源自日语 文字化け)。好在这种情况在今天已经越来越少见了。

Example of Mojibake on the website of a French movie distributor. The website name has been changed to preserve the innocent.

Unicode 拯救了世界

我解释了不同国家间交换文件时会遇到的编码问题。但事情还能更糟,同一个国家的不同生产商未必会使用相同的编码。如果你在 80 年代用 Mac 和 PC 互传过文件你就懂我是什么意思了。

也不知道是不是巧合,Unicode 项目始于 1987 年,主导者来自 施乐 Xerox 和…… 苹果 Apple

这个项目的目标是定义一套通用字符集来允许同一段文字中 同时 出现人类书写会用到的任何文字。最初的 Unicode 项目被限制在 65536 个不同字符(每个字符用 16 位表示,即每个字符两字节)。这个数字已被证实是远远不够的。

于是,在 1996 年 Unicode 被扩展以支持高达 100 万不同的 代码点 code point 。粗略来说,一个“代码点”可被用来识别字符表中的一个条目。Unicode 项目的一个核心工作就是将世界上正在被使用(或曾被使用)的字母、符号、标点符号以及其他文字仓管起来,并给每一项条目分配一个代码点用以准确分辨对应的字符。

这是一个庞大的项目:为了让你有个大致了解,发布于 2017 年的 Unicode 版本 10 定义了超过 136,000 个字符,覆盖了 139 种现代和历史上的语言文字。

随着如此庞大数量的可能性,一个基本的编码会需要每个字符 32 位(即 4 字节)。但对于主要使用 US-ASCII 范围内字符的文字,每个字符 4 字节意味着 4 倍多的储存需求以及 4 倍多的带宽用以传输这些文字。

Encoding text as UTF-32 requires 4 bytes per character

所以除了 UTF-32,Unicode 联盟还定义了更加节约空间的 UTF-16UTF-8 编码,分别使用了 16 位和 8 位。但只有 8 位该如何储存超过 100,000 个不同的值呢?事实是,你不能。但这其中窍门在于用一个代码值(UTF-8 中的 8 位以及 UTF-16 中的 16 位)来储存最常用的一些字符。再用几个代码值储存最不常用的一些字符。所以说 UTF-8 和 UTF-16 是 可变长度 编码。尽管这样也有缺陷,但 UTF-8 是空间与时间效率之间一个不错的折中。更不用提 UTF-8 可以向后兼容大部分 Unicode 之前的 1 字节编码,因为 UTF-8 经过了特别设计,任何有效的 US-ASCII 文件都是有效的 UTF-8 文件。你也可以说,UTF-8 是 US-ASCII 的超集。而在今天已经找不到不用 UTF-8 编码的理由了。当然除非你书写主要用的语言需要多字节编码,或是你不得不与一些残留的老旧系统打交道。

在下面两张图中,你可以亲自比较一下同一字符串的 UTF-16 和 UTF-8 编码。特别注意 UTF-8 使用了一字节来储存拉丁字母表中的字符,但它使用了两字节来存储西里尔字母表中的字符。这是 Windows-1251 西里尔编码储存同样字符所需空间的两倍。

UTF-16 is a variable length encoding requiring 2 bytes to encode most characters. Some character still requires 4 bytes though (for example

UTF-8 is a variable length encoding requiring 1, 2, 3 or 4 bytes per character

而这些对于打字有什么用呢?

啊……知道一些你的电脑的能力与局限以及其底层机制也不是什么坏事嘛。特别是我们马上就要说到 Unicode 和十六进制。现在……让我们再聊点历史。真的就一点,我保证……

……就说从 80 年代起,电脑键盘曾经有过 Compose(有时候也被标为 Multi 键)就在 Shift 键的下边。当按下这个键时,你会进入 “ 组合 Compose ” 模式。一旦在这个模式下,你便可以通过输入助记符来输入你键盘上没有的字符。比如说,在组合模式下,输入 RO 便可生成字符 ®(当作是 O 里面有一个 R 就能很容易记住)。

Compose key on lk201 keyboard

现在很难在现代键盘上看到 Compose 键了。这大概是因为占据主导地位的 PC 不再用它了。但是在 Linux 上(可能还有其他系统)你可以模拟 Compose 键。这项设置可以通过 GUI 开启,在大多数桌面环境下调用“键盘”控制面板:但具体的步骤取决于你的桌面环境以及版本。如果你成功启用了那项设置,不要犹豫,在评论区分享你在你电脑上所采取的具体步骤。

(LCTT 译注:如果有读者想要尝试,建议将 Compose 键设为大写锁定键,或是别的不常用的键,CtrlAlt 会被大部分 GUI 程序优先识别为功能键。还有一些我自己试验时遇到过的问题,在开启 Compose 键前要确认大写锁定是关闭的,输入法要切换成英文,组合模式下输入大小写敏感。我试验的系统是 Ubuntu 22.04 LTS。)

至于我自己嘛,我现在先假设你用的就是默认的 Shift+AltGr 组合来模拟 Compose 键。(LCTT 校注:AltGr 在欧洲键盘上是指右侧的 Alt 键,在国际键盘上等价于 Ctrl+Alt 组合键。)

那么,作为一个实际例子,尝试输入 “LEFT-POINTING DOUBLE ANGLE QUOTATION MARK(左双角引号)”(LCTT 译注:Guillemet,是法语和一些欧洲语言中的引号,与中文的书名号不同),你可以输入 Shift+AltGr <<(你在敲助记符时不需要一直按着 Shift+AltGr)。如果你成功输入了这个符号,你自己应该也能猜到要怎么输入 “RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK(右双角引号)” 了。

来看看另一个例子,试试 Shift+AltGr --- 来生成一个 “EM DASH(长破折号)”(LCTT 译注:中文输入法的长破折号由两个 “EM DASH” 组成)。要做到这个,你需要按下主键盘上的的 连字符减号 键而非数字键盘上的那个。

值得注意的是 Compose 键在非 GUI 环境下也能工作。但是取决于你使用的是 X11 控制台还是只显示文字的控制台,它们所支持的组合按键顺序并不相同。

在控制台上,你可以通过命令 dumpkeys 来查看支持的组合按键列表(LCTT 译注:可能需要 root 权限):

dumpkeys --compose-only

在 GUI 下,组合键是在 Gtk/X11 层被实现的。想要知道 Gtk 所支持的助记符,可以查看页面:https://help.ubuntu.com/community/GtkComposeTable

我们可以避免对 Gtk 字符组合的依赖吗?

或许我是个纯粹主义者,但是我为 Gtk 这种对 Compose 键进行硬编码的方式感到悲哀。毕竟,不是所有 GUI 应用都会使用 Gtk 库。而且我如果想要添加我自己的助记符的话就只能重新编译 Gtk 了。

幸好在 X11 层也有对字符组合的支持。在以前则是通过令人尊敬的 X 输入法(XIM)

这个方法在比起基于 Gtk 的字符组合能够在更加底层的地方工作,同时具备优秀的灵活性并兼容很多 X11 应用。

比如说,假设我只是想要添加 --> 组合来输入字符 (U+2192,RIGHTWARDS ARROW(朝右箭头)),我只需要新建 ~/.XCompose 文件并写入以下代码:

cat > ~/.XCompose << EOT
# Load default compose table for the current local
include "%L"

# Custom definitions
<Multi_key> <minus> <minus> <greater> : U2192 # RIGHTWARDS ARROW
EOT

然后你就可以启动一个新的 X11 应用,强制函数库使用 XIM 作为输入法,并开始测试:

GTK_IM_MODULE="xim" QT_IM_MODULE="xim" xterm

新的组合排序应该可以在你刚启动的应用里被输入了。我鼓励你通过 man 5 compose 来进一步学习组合文件格式。

在你的 ~/.profile 中加入以下两行来将 XIM 设为你所有应用的默认输入法。这些改动会在下一次你登录电脑时生效:

export GTK_IM_MODULE="xim"
export QT_IM_MODULE="xim"

这挺酷的,不是吗?这样你就可以随意的加入你想要的组合排序。而且在默认的 XIM 设置中已经有几个有意思的组合了。试一下输入组合键 LLAP

但我不得不提到两个缺陷。XIM 已经比较老了,而且只适合我们这些不太需要多字节输入法的人。其次,当你用 XIM 作为输入法的时候,你就不能利用 Ctrl+Shift+u 加上代码点来输入 Unicode 字符了。什么?等一下?我还没聊过那个?让我们现在来聊一下吧:

如果我需要的字符没有对应的组合键排序该怎么办?

组合键是一个不错的工具,它可以用来输入一些键盘上没有的字符。但默认的组合集有限,而切换 XIM 并为一个你一生仅用一次的字符来定义一个新的组合排序十分麻烦。

但这能阻止你在同一段文字里混用日语、拉丁语,还有西里尔字符吗?显然不能,这多亏了 Unicode。比如说,名字 “あゆみ” 由三个字母组成:

我在上文提及了 Unicode 字符的正式名称,并遵循了全部用大写拼写的规范。在它们的名字后面,你可以找到它们的 Unicode 代码点,位于括号之间并写作 16 位的十六进制数字。这让你想到什么了吗?

不管怎样,一旦你知道了的一个字符的代码点,你就可以按照以下组合输入:

  • Ctrl+Shift+u,然后是 XXXX(你想要的字符的 十六进制 代码点)然后回车。

作为一种简写方式,如果你在输入代码点时不松开 Ctrl+Shift,你就不用敲回车。

不幸的是,这项功能的实现是在软件库层而非 X11 层,所以对其支持在不同应用间并不统一。以 LibreOffice 为例,你必须使用主键盘来输入代码点。而在基于 Gtk 的应用则接受来自数字键盘的输入。

最后,当我和我的 Debian 系统上的控制台打交道时,我发现了一个类似的功能,但它需要你按下 Alt+XXXXXXXXXX 是你想要的字符的 十进制 的代码点。我很好奇这究竟是 Debian 独有的功能,还是因为我使用的语言环境(Locale) 是 en_US.UTF-8。如果你对此有更多信息,我会很愿意在评论区读到它们的!

GUI控制台字符
Ctrl+Shift+u 3042 EnterAlt+12354
Ctrl+Shift+u 3086 EnterAlt+12422
Ctrl+Shift+u 307F EnterAlt+12415

死键

最后值得一提的是,想要不(必须)依赖 Compose 键来输入键组合还有一个更简单的方法。

你的键盘上的某些键是专门用来创造字符组合的。这些键叫做 死键 Dead Key 。这是因为当你按下它们一次,看起来什么都没有发生,但它们会悄悄地改变你下一次按键所产生的字符。这个行为的灵感来自于机械打字机:在使用机械打字机时,按下一个死键会印下一个字符,但不会移动字盘。于是下一次按键则会在同一个地方印下另一个字符。视觉效果就是两次按键的组合。

我们在法语里经常用到这个。举例来说,想要输入字母 ë 我必须按下死键 ¨ 然后再按下 e 键。同样地,西班牙人的键盘上有着死键 ~。而在北欧语系下的键盘布局,你可以找到 ° 键。我可以念很久这份清单。

hungary dead keys

显然,不是所有键盘都有所有死键。实际上,你的键盘上是找不到大部分死键的。比如说,我猜在你们当中只有小部分人——如果真的有的话——有死键 ¯ 来输入 Tōkyō 所需要的长音符号(“平变音符”)。

对于那些你键盘上没有的死键,你需要寻找别的解决方案。好消息是,我们已经用过那些技术了。但这一次我们要用它们来模拟死键,而非“普通”键。

那么,我们的第一个选择是利用 Compose - 来生成长音符号(你键盘上有的连字符减号)。按下时屏幕上什么都不会出现,但当你接着按下 o 键你就能看到 ō

Gtk 在组合模式下可以生成的一系列死键都能在 这里 找到。

另一个解决方法则是利用 Unicode 字符 “COMBINING MACRON(组合长音符号)”(U+0304),然后字母 o。我把细节都留给你。但如果你好奇的话,你会发现你打出的结果有着微妙的不同,你并没有真地打出 “LATIN SMALL LETTER O WITH MACRON(小写拉丁字母 O 带长音符号)”。我在上一句话的结尾用了大写拼写,这就是一个提示,引导你寻找通过 Unicode 组合字符按更少的键输入 ō 的方法……现在我将这些留给你的聪明才智去解决了。

轮到你来练习了!

所以,你都学会了吗?这些在你的电脑上工作吗?现在轮到你来尝试了:根据上面提出的线索,加上一点练习,现在你可以完成文章开头给出的挑战了。挑战一下吧,然后把成果复制到评论区作为你成功的证明。

赢了也没有奖励,或许来自同伴的惊叹能够满足你!


via: https://itsfoss.com/unicode-linux/

作者:Sylvain Leroux 选题:lkxed 译者:yzuowei 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在前面的文章中,我展示了 Sed 命令的基本用法, Sed 是一个实用的流编辑器。今天,我们准备去了解关于 Sed 更多的知识,深入了解 Sed 的运行模式。这将是你全面了解 Sed 命令的一个机会,深入挖掘它的运行细节和精妙之处。因此,如果你已经做好了准备,那就打开终端吧,下载测试文件 然后坐在电脑前:开始我们的探索之旅吧!

关于 Sed 的一点点理论知识

首先我们看一下 sed 的运行模式

要准确理解 Sed 命令,你必须先了解工具的运行模式。

当处理数据时,Sed 从输入源一次读入一行,并将它保存到所谓的 模式空间 pattern space 中。所有 Sed 的变换都发生在模式空间。变换都是由命令行上或外部 Sed 脚本文件提供的单字母命令来描述的。大多数 Sed 命令都可以由一个地址或一个地址范围作为前导来限制它们的作用范围。

默认情况下,Sed 在结束每个处理循环后输出模式空间中的内容,也就是说,输出发生在输入的下一个行覆盖模式空间之前。我们可以将这种运行模式总结如下:

  1. 尝试将下一个行读入到模式空间中
  2. 如果读取成功:

    1. 按脚本中的顺序将所有命令应用到与那个地址匹配的当前输入行上
    2. 如果 sed 没有以静默模式(-n)运行,那么将输出模式空间中的所有内容(可能会是修改过的)。
    3. 重新回到 1。

因此,在每个行被处理完毕之后,模式空间中的内容将被丢弃,它并不适合长时间保存内容。基于这种目的,Sed 有第二个缓冲区: 保持空间 hold space 。除非你显式地要求它将数据置入到保持空间、或从保持空间中取得数据,否则 Sed 从不清除保持空间的内容。在我们后面学习到 exchangegethold 命令时将深入研究它。

Sed 的抽象机制

你将在许多的 Sed 教程中都会看到上面解释的模式。的确,这是充分正确理解大多数基本 Sed 程序所必需的。但是当你深入研究更多的高级命令时,你将会发现,仅这些知识还是不够的。因此,我们现在尝试去了解更深入的一些知识。

的确,Sed 可以被视为是抽象机制的实现,它的状态)由三个缓冲区 、两个寄存器和两个标志来定义的:

  • 三个缓冲区用于去保存任意长度的文本。是的,是三个!在前面的基本运行模式中我们谈到了两个:模式空间和保持空间,但是 Sed 还有第三个缓冲区: 追加队列 append queue 。从 Sed 脚本的角度来看,它是一个只写缓冲区,Sed 将在它运行时的预定义阶段来自动刷新它(一般是在从输入源读入一个新行之前,或仅在它退出运行之前)。
  • Sed 也维护两个寄存器 行计数器 line counter (LC)用于保存从输入源读取的行数,而 程序计数器 program counter (PC)总是用来保存下一个将要运行的命令的索引(就是脚本中的位置),Sed 将它作为它的主循环的一部分来自动增加 PC。但在使用特定的命令时,脚本也可以直接修改 PC 去跳过或重复执行程序的一部分。这就像使用 Sed 实现的一个循环或条件语句。更多内容将在下面的专用分支一节中描述。
  • 最后,两个标志可以修改某些 Sed 命令的行为: 自动输出 auto-print (AP)标志和<ruby替换 substitution(SF)标志。当自动输出标志 AP 被设置时,Sed 将在模式空间的内容被覆盖前自动输出(尤其是,包括但不限于,在从输入源读入一个新行之前)。当自动输出标志被清除时(即:没有设置),Sed 在脚本中没有显式命令的情况下,将不会输出模式空间中的内容。你可以通过在“静默模式”(使用命令行选项 -n 或者在第一行或脚本中使用特殊注释 #n)运行 Sed 命令来清除自动输出标志。当它的地址和查找模式与模式空间中的内容都匹配时,替换标志 SF 将被替换命令(s 命令)设置。替换标志在每个新的循环开始时、或当从输入源读入一个新行时、或获得条件分支之后将被清除。我们将在分支一节中详细研究这一话题。

另外,Sed 维护一个进入到它的地址范围(关于地址范围的更多知识将在地址范围一节详细描述)的命令列表,以及用于读取和写入数据的两个文件句柄(你将在读取和写入命令的描述中获得更多有关文件句柄的内容)。

一个更精确的 Sed 运行模式

一图胜千言,所以我画了一个流程图去描述 Sed 的运行模式。我将两个东西放在了旁边,像处理多个输入文件或错误处理,但是我认为这足够你去理解任何 Sed 程序的行为了,并且可以避免你在编写你自己的 Sed 脚本时浪费在摸索上的时间。

The Sed execution model

你可能已经注意到,在上面的流程图上我并没有描述特定的命令动作。对于命令,我们将逐个详细讲解。因此,不用着急,我们马上开始!

打印命令

打印命令(p)是用于输出在它运行那一刻模式空间中的内容。它并不会以任何方式改变 Sed 抽象机制中的状态。

The Sed <code>print</code> command

示例:

sed -e 'p' inputfile

上面的命令将输出输入文件中每一行的内容……两次,因为你一旦显式地要求使用 p 命令时,将会在每个处理循环结束时再隐式地输出一次(因为在这里我们不是在“静默模式”中运行 Sed)。

如果我们不想每个行看到两次,我们可以用两种方式去解决它:

sed -n -e 'p' inputfile # 在静默模式中显式输出
sed -e '' inputfile # 空的“什么都不做的”程序,隐式输出

注意:-e 选项是引入一个 Sed 命令。它被用于区分命令和文件名。由于一个 Sed 表达式必须包含至少一个命令,所以对于第一个命令,-e 标志不是必需的。但是,由于我个人使用习惯问题,为了与在这里的大多数的一个命令行上给出多个 Sed 表达式的更复杂的案例保持一致性,我添加了它。你自己去判断这是一个好习惯还是坏习惯,并且在本文的后面部分还将延用这一习惯。

地址

显而易见,print 命令本身并没有太多的用处。但是,如果你在它之前添加一个地址,这样它就只输出输入文件的一些行,这样它就突然变得能够从一个输入文件中过滤一些不希望的行。那么 Sed 的地址又是什么呢?它是如何来辨别输入文件的“行”呢?

行号

Sed 的地址既可以是一个行号($ 表示“最后一行”)也可以是一个正则表达式。在使用行号时,你需要记住 Sed 中的行数是从 1 开始的 —— 并且需要注意的是,它不是从 0 行开始的。

sed -n -e '1p' inputfile # 仅输出文件的第一行
sed -n -e '5p' inputfile # 仅输出第 5 行
sed -n -e '$p' inputfile # 输出文件的最后一行
sed -n -e '0p' inputfile # 结果将是报错,因为 0 不是有效的行号

根据 POSIX 规范,如果你指定了几个输出文件,那么它的行号是累加的。换句话说,当 Sed 打开一个新输入文件时,它的行计数器是不会被重置的。因此,以下的两个命令所做的事情是一样的。仅输出一行文本:

sed -n -e '1p' inputfile1 inputfile2 inputfile3
cat inputfile1 inputfile2 inputfile3 | sed -n -e '1p'

实际上,确实在 POSIX 中规定了多个文件是如何处理的:

如果指定了多个文件,将按指定的文件命名顺序进行读取并被串联编辑。

但是,一些 Sed 的实现提供了命令行选项去改变这种行为,比如, GNU Sed 的 -s 标志(在使用 GNU Sed -i 标志时,它也被隐式地应用):

sed -sn -e '1p' inputfile1 inputfile2 inputfile3

如果你的 Sed 实现支持这种非标准选项,那么关于它的具体细节请查看 man 手册页。

正则表达式

我前面说过,Sed 地址既可以是行号也可以是正则表达式。那么正则表达式是什么呢?

正如它的名字,一个正则表达式是描述一个字符串集合的方法。如果一个指定的字符串符合一个正则表达式所描述的集合,那么我们就认为这个字符串与正则表达式匹配。

正则表达式可以包含必须完全匹配的文本字符。例如,所有的字母和数字,以及大部分可以打印的字符。但是,一些符号有特定意义:

  • 它们相当于锚,像 ^$ 它们分别表示一个行的开始和结束;
  • 能够做为整个字符集的占位符的其它符号(比如圆点 . 可以匹配任意单个字符,或者方括号 [] 用于定义一个自定义的字符集);
  • 另外的是表示重复出现的数量(像 克莱尼星号(* 表示前面的模式出现 0、1 或多次);

这篇文章的目的不是给大家讲正则表达式。因此,我只粘几个示例。但是,你可以在网络上随便找到很多关于正则表达式的教程,正则表达式的功能非常强大,它可用于许多标准的 Unix 命令和编程语言中,并且是每个 Unix 用户应该掌握的技能。

下面是使用 Sed 地址的几个示例:

sed -n -e '/systemd/p' inputfile # 仅输出包含字符串“systemd”的行
sed -n -e '/nologin$/p' inputfile # 仅输出以“nologin”结尾的行
sed -n -e '/^bin/p' inputfile # 仅输出以“bin”开头的行
sed -n -e '/^$/p' inputfile # 仅输出空行(即:开始和结束之间什么都没有的行)
sed -n -e '/./p' inputfile # 仅输出包含字符的行(即:非空行)
sed -n -e '/^.$/p' inputfile # 仅输出只包含一个字符的行
sed -n -e '/admin.*false/p' inputfile # 仅输出包含字符串“admin”后面有字符串“false”的行(在它们之间有任意数量的任意字符)
sed -n -e '/1[0,3]/p' inputfile # 仅输出包含一个“1”并且后面是一个“0”或“3”的行
sed -n -e '/1[0-2]/p' inputfile # 仅输出包含一个“1”并且后面是一个“0”、“1”、“2”或“3”的行
sed -n -e '/1.*2/p' inputfile # 仅输出包含字符“1”后面是一个“2”(在它们之间有任意数量的字符)的行
sed -n -e '/1[0-9]*2/p' inputfile # 仅输出包含字符“1”后面跟着“0”、“1”、或更多数字,最后面是一个“2”的行

如果你想在正则表达式(包括正则表达式分隔符)中去除字符的特殊意义,你可以在它前面使用一个反斜杠:

# 输出所有包含字符串“/usr/sbin/nologin”的行
sed -ne '/\/usr\/sbin\/nologin/p' inputfile

并不限制你只能使用斜杠作为地址中正则表达式的分隔符。你可以通过在第一个分隔符前面加上反斜杠(\)的方式,来使用任何你认为适合你需要和偏好的其它字符作为正则表达式的分隔符。当你用地址与带文件路径的字符一起来匹配的时,是非常有用的:

# 以下两个命令是完全相同的
sed -ne '/\/usr\/sbin\/nologin/p' inputfile
sed -ne '\=/usr/sbin/nologin=p' inputfile

扩展的正则表达式

默认情况下,Sed 的正则表达式引擎仅理解 POSIX 基本正则表达式 的语法。如果你需要用到 扩展正则表达式,你必须在 Sed 命令上添加 -E 标志。扩展正则表达式在基本正则表达式基础上增加了一组额外的特性,并且很多都是很重要的,它们所要求的反斜杠要少很多。我们来比较一下:

sed -n -e '/\(www\)\|\(mail\)/p' inputfile
sed -En -e '/(www)|(mail)/p' inputfile

花括号量词

正则表达式之所以强大的一个原因是范围量词 {,}。事实上,当你写一个不太精确匹配的正则表达式时,量词 * 就是一个非常完美的符号。但是,(用花括号量词)你可以显式在它边上添加一个下限和上限,这样就有了很好的灵活性。当量词范围的下限省略时,下限被假定为 0。当上限被省略时,上限被假定为无限大:

括号速记词解释
{,}*前面的规则出现 0、1、或许多遍
{,1}?前面的规则出现 0 或 1 遍
{1,}+前面的规则出现 1 或许多遍
{n,n}{n}前面的规则精确地出现 n 遍

花括号在基本正则表达式中也是可以使用的,但是它要求使用反斜杠。根据 POSIX 规范,在基本正则表达式中可以使用的量词仅有星号(*)和花括号(使用反斜杠,如 \{m,n\})。许多正则表达式引擎都扩展支持 \?\+。但是,为什么魔鬼如此有诱惑力呢?因为,如果你需要这些量词,使用扩展正则表达式将不但易于写而且可移植性更好。

为什么我要花点时间去讨论关于正则表达式的花括号量词,这是因为在 Sed 脚本中经常用这个特性去计数字符。

sed -En -e '/^.{35}$/p' inputfile # 输出精确包含 35 个字符的行
sed -En -e '/^.{0,35}$/p' inputfile # 输出包含 35 个字符或更少字符的行
sed -En -e '/^.{,35}$/p' inputfile # 输出包含 35 个字符或更少字符的行
sed -En -e '/^.{35,}$/p' inputfile # 输出包含 35 个字符或更多字符的行
sed -En -e '/.{35}/p' inputfile # 你自己指出它的输出内容(这是留给你的测试题)

地址范围

到目前为止,我们使用的所有地址都是唯一地址。在我们使用一个唯一地址时,命令是应用在与那个地址匹配的行上。但是,Sed 也支持地址范围。Sed 命令可以应用到那个地址范围中从开始到结束的所有地址中的所有行上:

sed -n -e '1,5p' inputfile # 仅输出 1 到 5 行
sed -n -e '5,$p' inputfile # 从第 5 行输出到文件结尾
sed -n -e '/www/,/systemd/p' inputfile # 输出与正则表达式 /www/ 匹配的第一行到与接下来匹配正则表达式 /systemd/ 的行为止

(LCTT 译注:下面用的一个生成的列表例子,如下供参考:)

printf "%s\n" {a,b,c}{d,e,f} | cat -n
     1  ad
     2  ae
     3  af
     4  bd
     5  be
     6  bf
     7  cd
     8  ce
     9  cf

如果在开始和结束地址上使用了同一个行号,那么范围就缩小为那个行。事实上,如果第二个地址的数字小于或等于地址范围中选定的第一个行的数字,那么仅有一个行被选定:

printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '4,4p'
     4 bd
printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '4,3p'
     4 bd

下面有点难了,但是在前面的段落中给出的规则也适用于起始地址是正则表达式的情况。在那种情况下,Sed 将对正则表达式匹配的第一个行的行号和给定的作为结束地址的显式的行号进行比较。再强调一次,如果结束行号小于或等于起始行号,那么这个范围将缩小为一行:

(LCTT 译注:此处作者陈述有误,Sed 会在处理以正则表达式表示的开始行时,并不会同时测试结束表达式:从匹配开始行的正则表达式开始,直到不匹配时,才会测试结束行的表达式——无论是否是正则表达式——并在结束的表达式测试不通过时停止,并循环此测试。)

# 这个 /b/,4 地址将匹配三个单行
# 因为每个匹配的行有一个行号 >= 4
#(LCTT 译注:结果正确,但是说明不正确。4、5、6 行都会因为匹配开始正则表达式而通过,第 7 行因为不匹配开始正则表达式,所以开始比较行数: 7 > 4,遂停止。)
printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '/b/,4p'
     4  bd
     5  be
     6  bf

# 你自己指出匹配的范围是多少
# 第二个例子:
printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '/d/,4p'
     1  ad
     2  ae
     3  af
     4  bd
     7  cd

但是,当结束地址是一个正则表达式时,Sed 的行为将不一样。在那种情况下,地址范围的第一行将不会与结束地址进行检查,因此地址范围将至少包含两行(当然,如果输入数据不足的情况除外):

(LCTT 译注:如上译注,当满足开始的正则表达式时,并不会测试结束的表达式;仅当不满足开始的表达式时,才会测试结束表达式。)

printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '/b/,/d/p'
 4 bd
 5 be
 6 bf
 7 cd

printf "%s\n" {a,b,c}{d,e,f} | cat -n | sed -ne '4,/d/p'
 4 bd
 5 be
 6 bf
 7 cd

(LCTT 译注:对地址范围的总结,当满足开始的条件时,从该行开始,并不测试该行是否满足结束的条件;从下一行开始测试结束条件,并在结束条件不满足时结束;然后对剩余的行,再从开始条件开始匹配,以此循环——也就是说,匹配结果可以是非连续的单/多行。大家可以调整上述命令行的条件以理解。)

补集

在一个地址选择行后面添加一个感叹号(!)表示不匹配那个地址。例如:

sed -n -e '5!p' inputfile # 输出除了第 5 行外的所有行
sed -n -e '5,10!p' inputfile # 输出除了第 5 到 10 之间的所有行
sed -n -e '/sys/!p' inputfile # 输出除了包含字符串“sys”的所有行

交集

(LCTT 译注:原文标题为“合集”,应为“交集”)

Sed 允许在一个块中使用花括号 {…} 组合命令。你可以利用这个特性去组合几个地址的交集。例如,我们来比较下面两个命令的输出:

sed -n -e '/usb/{
  /daemon/p
}' inputfile

sed -n -e '/usb.*daemon/p' inputfile

通过在一个块中嵌套命令,我们将在任意顺序中选择包含字符串 “usb” 和 “daemon” 的行。而正则表达式 “usb.*daemon” 将仅匹配在字符串 “daemon” 前面包含 “usb” 字符串的行。

离题太长时间后,我们现在重新回去学习各种 Sed 命令。

退出命令

退出命令(q)是指在当前的迭代循环处理结束之后停止 Sed。

The Sed quit command

q 命令是在到达输入文件的尾部之前停止处理输入的方法。为什么会有人想去那样做呢?

很好的问题,如果你还记得,我们可以使用下面的命令来输出文件中第 1 到第 5 的行:

sed -n -e '1,5p' inputfile

对于大多数 Sed 的实现方式,工具将循环读取输入文件的所有行,那怕是你只处理结果中的前 5 行。如果你的输入文件包含了几百万行(或者更糟糕的情况是,你从一个无限的数据流,比如像 /dev/urandom 中读取)将有重大影响。

使用退出命令,相同的程序可以被修改的更高效:

sed -e '5q' inputfile

由于我在这里并不使用 -n 选项,Sed 将在每个循环结束后隐式输出模式空间的内容。但是在你处理完第 5 行后,它将退出,并且因此不会去读取更多的数据。

我们能够使用一个类似的技巧只输出文件中一个特定的行。这也是从命令行中提供多个 Sed 表达式的几种方法。下面的三个变体都可以从 Sed 中接受几个命令,要么是不同的 -e 选项,要么是在相同的表达式中新起一行,或用分号(;)隔开:

sed -n -e '5p' -e '5q' inputfile

sed -n -e '
  5p
  5q
' inputfile

sed -n -e '5p;5q' inputfile

如果你还记得,我们在前面看到过能够使用花括号将命令组合起来,在这里我们使用它来防止相同的地址重复两次:

# 组合命令
sed -e '5{
  p
  q
}' inputfile

# 可以简写为:
sed '5{p;q;}' inputfile

# 作为 POSIX 扩展,有些实现方式可以省略闭花括号之前的分号:
sed '5{p;q}' inputfile

替换命令

你可以将替换命令(s)想像为 Sed 的“查找替换”功能,这个功能在大多数的“所见即所得”的编辑器上都能找到。Sed 的替换命令与之类似,但比它们更强大。替换命令是 Sed 中最著名的命令之一,在网上有大量的关于这个命令的文档。

The Sed <code>substitution</code> command

在前一篇文章中我们已经讲过它了,因此,在这里就不再重复了。但是,如果你对它的使用不是很熟悉,那么你需要记住下面的这些关键点:

  • 替换命令有两个参数:查找模式和替换字符串:sed s/:/-----/ inputfile
  • s 命令和它的参数是用任意一个字符来分隔的。这主要看你的习惯,在 99% 的时间中我都使用斜杠,但也会用其它的字符:sed s%:%-----% inputfilesed sX:X-----X inputfile 或者甚至是 sed 's : ----- ' inputfile
  • 默认情况下,替换命令仅被应用到模式空间中匹配到的第一个字符串上。你可以通过在命令之后指定一个匹配指数作为标志来改变这种情况:sed 's/:/-----/1' inputfilesed 's/:/-----/2' inputfilesed 's/:/-----/3' inputfile、…
  • 如果你想执行一个全局替换(即:在模式空间上的每个非重叠匹配上进行),你需要增加 g 标志:sed 's/:/-----/g' inputfile
  • 在字符串替换中,出现的任何一个 & 符号都将被与查找模式匹配的子字符串替换:sed 's/:/-&&&-/g' inputfilesed 's/.../& /g' inputfile
  • 圆括号(在扩展的正则表达式中的 (...) ,或者基本的正则表达式中的 \(...\))被当做 捕获组 capturing group 。那是匹配字符串的一部分,可以在替换字符串中被引用。\1 是第一个捕获组的内容,\2 是第二个捕获组的内容,依次类推:sed -E 's/(.)(.)/\2\1/g' inputfilesed -E 's/(.):x:(.):(.*)/\1:\3/' inputfile(后者之所能正常工作是因为 正则表达式中的量词星号表示尽可能多的匹配,直到不匹配为止,并且它可以匹配许多个字符)
  • 在查找模式或替换字符串时,你可以通过使用一个反斜杠来去除任何字符的特殊意义:sed 's/:/--\&--/g' inputfilesed 's/\//\\/g' inputfile

所有的这些看起来有点抽象,下面是一些示例。首先,我想去显示我的测试输入文件的第一个字段并给它在右侧附加 20 个空格字符,我可以这样写:

sed < inputfile -E -e '
 s/:/ /             # 用 20 个空格替换第一个字段的分隔符
 s/(.{20}).*/\1/    # 只保留一行的前 20 个字符
 s/.*/| & |/        # 为了输出好看添加竖条
'

第二个示例是,如果我想将用户 sonia 的 UID/GID 修改为 1100,我可以这样写:

sed -En -e '
  /sonia/{
    s/[0-9]+/1100/g
    p
 }' inputfile

注意在替换命令结束部分的 g 选项。这个选项改变了它的行为,因此它将查找全部的模式空间并替换,如果没有那个选项,它只替换查找到的第一个。

顺便说一下,这也是使用前面讲过的输出(p)命令的好机会,可以在命令运行时输出修改前后的模式空间的内容。因此,为了获得替换前后的内容,我可以这样写:

sed -En -e '
  /sonia/{
     p
     s/[0-9]+/1100/g
     p
 }' inputfile

事实上,替换后输出一个行是很常见的用法,因此,替换命令也接受 p 选项:

sed -En -e '/sonia/s/[0-9]+/1100/gp' inputfile

最后,我就不详细讲替换命令的 w 选项了,我们将在稍后的学习中详细介绍。

删除命令

删除命令(d)用于清除模式空间的内容,然后立即开始下一个处理循环。这样它将会跳过隐式输出模式空间内容的行为,即便是你设置了自动输出标志(AP)也不会输出。

The Sed <code>delete</code> command

只输出一个文件前五行的一个很低效率的方法将是:

sed -e '6,$d' inputfile

你猜猜看,我为什么说它很低效率?如果你猜不到,建议你再次去阅读前面的关于退出命令的章节,答案就在那里!

当你组合使用正则表达式和地址,从输出中删除匹配的行时,删除命令将非常有用:

sed -e '/systemd/d' inputfile

次行命令

如果 Sed 命令没有运行在静默模式中,这个命令(n)将输出当前模式空间的内容,然后,在任何情况下它将读取下一个输入行到模式空间中,并使用新的模式空间中的内容来运行当前循环中剩余的命令。

The Sed next command

用次行命令去跳过行的一个常见示例:

cat -n inputfile | sed -n -e 'n;n;p'

在上面的例子中,Sed 将隐式地读取输入文件的第一行。但是次行命令将丢弃对模式空间中的内容的输出(不输出是因为使用了 -n 选项),并从输入文件中读取下一行来替换模式空间中的内容。而第二个次行命令做的事情和前一个是一模一样的,这就实现了跳过输入文件 2 行的目的。最后,这个脚本显式地输出包含在模式空间中的输入文件的第三行的内容。然后,Sed 将启动一个新的循环,由于次行命令,它会隐式地读取第 4 行的内容,然后跳过它,同样地也跳过第 5 行,并输出第 6 行。如此循环,直到文件结束。总体来看,这个脚本就是读取输入文件然后每三行输出一行。

使用次行命令,我们也可以找到一些显示输入文件的前五行的几种方法:

cat -n inputfile | sed -n -e '1{p;n;p;n;p;n;p;n;p}'
cat -n inputfile | sed -n -e 'p;n;p;n;p;n;p;n;p;q'
cat -n inputfile | sed -e 'n;n;n;n;q'

更有趣的是,如果你需要根据一些地址来处理行时,次行命令也非常有用:

cat -n inputfile | sed -n '/pulse/p' # 输出包含 “pulse” 的行
cat -n inputfile | sed -n '/pulse/{n;p}' # 输出包含 “pulse” 之后的行
cat -n inputfile | sed -n '/pulse/{n;n;p}'  # 输出包含 “pulse” 的行的下一行的下一行

使用保持空间

到目前为止,我们所看到的命令都是仅使用了模式空间。但是,我们在文章的开始部分已经提到过,还有第二个缓冲区:保持空间,它完全由用户管理。它就是我们在第二节中描述的目标。

交换命令

正如它的名字所表示的,交换命令(x)将交换保持空间和模式空间的内容。记住,你只要没有把任何东西放入到保持空间中,那么保持空间就是空的。

The Sed exchange command

作为第一个示例,我们可使用交换命令去反序输出一个输入文件的前两行:

cat -n inputfile | sed -n -e 'x;n;p;x;p;q'

当然,在你设置保持空间之后你并没有立即使用它的内容,因为只要你没有显式地去修改它,保持空间中的内容就保持不变。在下面的例子中,我在输入一个文件的前五行后,使用它去删除第一行:

cat -n inputfile | sed -n -e '
 1{x;n} # 交换保持和模式空间
        # 保存第 1 行到保持空间中
        # 然后读取第 2 行
 5{
   p    # 输出第 5 行
   x    # 交换保持和模式空间
        # 去取得第 1 行的内容放回到模式空间
 }

 1,5p   # 输出第 2 到第 5 行
        # (并没有输错!尝试找出这个规则
        # 没有在第 1 行上运行的原因 ;)
'

保持命令

保持命令(h)是用于将模式空间中的内容保存到保持空间中。但是,与交换命令不同的是,模式空间中的内容不会被改变。保持命令有两种用法:

  • h 将复制模式空间中的内容到保持空间中,覆盖保持空间中任何已经存在的内容。
  • H 将模式空间中的内容追加到保持空间中,使用一个新行作为分隔符。

The Sed hold command

上面使用交换命令的例子可以使用保持命令重写如下:

cat -n inputfile | sed -n -e '
 1{h;n} # 保存第 1 行的内容到保持缓冲区并继续
 5{     # 在第 5 行
   x    # 交换模式和保持空间
        # (现在模式空间包含了第 1 行)
   H    # 在保持空间的第 5 行后追加第 1 行
   x    # 再次交换第 5 行和第 1 行,第 5 行回到模式空间
 }

 1,5p   # 输出第 2 行到第 5 行
        # (没有输错!尝试去找到为什么这个规则
        # 不在第 1 行上运行 ;)
'

获取命令

获取命令(g)与保持命令恰好相反:它从保持空间中取得内容并将它置入到模式空间中。同样它也有两种方式:

  • g 将复制保持空间中的内容并将其放入到模式空间,覆盖模式空间中已存在的任何内容
  • G 将保持空间中的内容追加到模式空间中,并使用一个新行作为分隔符

The Sed get command

将保持命令和获取命令一起使用,可以允许你去存储并调回数据。作为一个小挑战,我让你重写前一节中的示例,将输入文件的第 1 行放置在第 5 行之后,但是这次必须使用获取和保持命令(使用大写或小写命令的版本)而不能使用交换命令。带点小运气,可以更简单!

同时,我可以给你展示另一个示例,它能给你一些灵感。目标是将拥有登录 shell 权限的用户与其它用户分开:

cat -n inputfile | sed -En -e '
 \=(/usr/sbin/nologin|/bin/false)$= { H;d; }
            # 追回匹配的行到保持空间
            # 然后继续下一个循环
 p          # 输出其它行
 $ { g;p }  # 在最后一行上
            # 获取并打印保持空间中的内容
'

复习打印、删除和次行命令

现在你已经更熟悉使用保持空间了,我们回到打印、删除和次行命令。我们已经讨论了小写的 pdn 命令了。而它们也有大写的版本。因为每个命令都有大小写版本,似乎是 Sed 的习惯,这些命令的大写版本将与多行缓冲区有关:

  • P 将模式空间中第一个新行之前的内容输出
  • D 删除模式空间中第一个新行之前的内容(包含新行),然后不读取任何新的输入而是使用剩余的文本去重启一个循环
  • N 读取输入并追加一个新行到模式空间,用一个新行作为新旧数据的分隔符。继续运行当前的循环。

The Sed uppercase <code>Delete</code> command

The Sed uppercase <code>Next</code> command

这些命令的使用场景主要用于实现队列(FIFO 列表))。从一个输入文件中删除最后 5 行就是一个很权威的例子:

cat -n inputfile | sed -En -e '
  1 { N;N;N;N } # 确保模式空间中包含 5 行

  N             # 追加第 6 行到队列中
  P             # 输出队列的第 1 行
  D             # 删除队列的第 1 行
'

作为第二个示例,我们可以在两个列上显示输入数据:

# 输出两列
sed < inputfile -En -e '
 $!N    # 追加一个新行到模式空间
        # 除了输入文件的最后一行
        # 当在输入文件的最后一行使用 N 命令时
        # GNU Sed 和 POSIX Sed 的行为是有差异的
        # 需要使用一个技巧去处理这种情况
        # https://www.gnu.org/software/sed/manual/sed.html#N_005fcommand_005flast_005fline

        # 用空间填充第 1 行的第 1 个字段
        # 并丢弃其余行
 s/:.*\n/                    \n/
 s/:.*//            # 除了第 2 行上的第 1 个字段外,丢弃其余的行
 s/(.{20}).*\n/\1/  # 修剪并连接行
 p                  # 输出结果
'

分支

我们刚才已经看到,Sed 因为有保持空间所以有了缓存的功能。其实它还有测试和分支的指令。因为有这些特性使得 Sed 是一个图灵完备的语言。虽然它可能看起来很傻,但意味着你可以使用 Sed 写任何程序。你可以实现任何你的目的,但并不意味着实现起来会很容易,而且结果也不一定会很高效。

不过不用担心。在本文中,我们将使用能够展示测试和分支功能的最简单的例子。虽然这些功能乍一看似乎很有限,但请记住,有些人用 Sed 写了 http://www.catonmat.net/ftp/sed/dc.sed[计算器]、http://www.catonmat.net/ftp/sed/sedtris.sed [俄罗斯方块] 或许多其它类型的应用程序!

标签和分支

从某些方面,你可以将 Sed 看到是一个功能有限的汇编语言。因此,你不会找到在高级语言中常见的 “for” 或 “while” 循环,或者 “if … else” 语句,但是你可以使用分支来实现同样的功能。

The Sed branch command

如果你在本文开始部分看到了用流程图描述的 Sed 运行模型,那么你应该知道 Sed 会自动增加程序计数器(PC)的值,命令是按程序的指令顺序来运行的。但是,使用分支(b)指令,你可以通过选择执行程序中的任意命令来改变顺序运行的程序。跳转目的地是使用一个标签(:)来显式定义的。

The Sed label command

这是一个这样的示例:

echo hello | sed -ne '
  :start    # 在程序的该行上放置一个 “start” 标签
  p         # 输出模式空间内容
  b start   # 继续在 :start 标签上运行
' | less

那个 Sed 程序的行为非常类似于 yes 命令:它获取一个字符串并产生一个包含那个字符串的无限流。

切换到一个标签就像我们绕开了 Sed 的自动化特性一样:它既不读取任何输入,也不输出任何内容,更不更新任何缓冲区。它只是跳转到源程序指令顺序中下一条的另外一个指令。

值得一提的是,如果在分支命令(b)上没有指定一个标签作为它的参数,那么分支将直接切换到程序结束的地方。因此,Sed 将启动一个新的循环。这个特性可以用于去跳过一些指令并且因此可以用于作为“块”的替代者:

cat -n inputfile | sed -ne '
/usb/!b
/daemon/!b
p
'

条件分支

到目前为止,我们已经看到了无条件分支,这个术语可能有点误导嫌疑,因为 Sed 命令总是基于它们的可选地址来作为条件的。

但是,在传统意义上,一个无条件分支也是一个分支,当它运行时,将跳转到特定的目的地,而条件分支既有可能也或许不可能跳转到特定的指令,这取决于系统的当前状态。

Sed 只有一个条件指令,就是测试(t)命令。只有在当前循环的开始或因为前一个条件分支运行了替换,它才跳转到不同的指令。更多的情况是,只有替换标志被设置时,测试命令才会切换分支。

The Sed <code>test</code> command

使用测试指令,你可以在一个 Sed 程序中很轻松地执行一个循环。作为一个特定的示例,你可以用它将一个行填充到某个长度(这是使用正则表达式无法实现的):

# 居中文本
cut -d: -f1 inputfile | sed -Ee '
  :start
  s/^(.{,19})$/ \1 /    # 用一个空格填充少于 20 个字符的行的开始处
                        # 并在结束处添加另一个空格
  t start               # 如果我们已经添加了一个空格,则返回到 :start 标签
  s/(.{20}).*/| \1 |/   # 只保留一个行的前 20 个字符
                        # 以修复由于奇数行引起的差一错误
'

如果你仔细读前面的示例,你可能注意到,在将要把数据“喂”给 Sed 之前,我通过 cut 命令做了一点小修正去预处理数据。

不过,我们也可以只使用 Sed 对程序做一些小修改来执行相同的任务:

cat inputfile | sed -Ee '
  s/:.*//               # 除第 1 个字段外删除剩余字段
  t start
  :start
  s/^(.{,19})$/ \1 /    # 用一个空格填充少于 20 个字符的行的开始处
                        # 并在结束处添加另一个空格
  t start               # 如果我们已经添加了一个空格,则返回到 :start 标签
  s/(.{20}).*/| \1 |/   # 仅保留一个行的前 20 个字符
                        # 以修复由于奇数行引起的差一错误
'

在上面的示例中,你或许对下列的结构感到惊奇:

t start
:start

乍一看,在这里的分支并没有用,因为它只是跳转到将要运行的指令处。但是,如果你仔细阅读了测试命令的定义,你将会看到,如果在当前循环的开始或者前一个测试命令运行后发生了一个替换,分支才会起作用。换句话说就是,测试指令有清除替换标志的副作用。这也正是上面的代码片段的真实目的。这是一个在包含条件分支的 Sed 程序中经常看到的技巧,用于在使用多个替换命令时避免出现 误报 false positive 的情况。

通过它并不能绝对强制地清除替换标志,我同意这一说法。因为在将字符串填充到正确的长度时我使用的特定的替换命令是 幂等 idempotent 的。因此,一个多余的迭代并不会改变结果。不过,我们可以现在再次看一下第二个示例:

# 基于它们的登录程序来分类用户帐户
cat inputfile | sed -Ene '
  s/^/login=/
  /nologin/s/^/type=SERV /
  /false/s/^/type=SERV /
  t print
  s/^/type=USER /
  :print
  s/:.*//p
'

我希望在这里根据用户默认配置的登录程序,为用户帐户打上 “SERV” 或 “USER” 的标签。如果你运行它,预计你将看到 “SERV” 标签。然而,并没有在输出中跟踪到 “USER” 标签。为什么呢?因为 t print 指令不论行的内容是什么,它总是切换,替换标志总是由程序的第一个替换命令来设置。一旦替换标志设置完成后,在下一个行被读取或直到下一个测试命令之前,这个标志将保持不变。下面我们给出修复这个程序的解决方案:

# 基于用户登录程序来分类用户帐户
cat inputfile | sed -Ene '
  s/^/login=/

  t classify # clear the "substitution flag"
  :classify

  /nologin/s/^/type=SERV /
  /false/s/^/type=SERV /
  t print
  s/^/type=USER /
  :print
  s/:.*//p
'

精确地处理文本

Sed 是一个非交互式文本编辑器。虽然是非交互式的,但仍然是文本编辑器。而如果没有在输出中插入一些东西的功能,那它就不算一个完整的文本编辑器。我不是很喜欢它的文本编辑的特性,因为我发现它的语法太难用了(即便是以 Sed 的标准而言),但有时你难免会用到它。

采用严格的 POSIX 语法的只有三个命令:改变(c)、插入(i)或追加(a)一些文字文本到输出,都遵循相同的特定语法:命令字母后面跟着一个反斜杠,并且文本从脚本的下一行上开始插入:

head -5 inputfile | sed '
1i\
# List of user accounts
$a\
# end
'

插入多行文本,你必须每一行结束的位置使用一个反斜杠:

head -5 inputfile | sed '
1i\
# List of user accounts\
# (users 1 through 5)
$a\
# end
'

一些 Sed 实现,比如 GNU Sed,在初始的反斜杠后面的换行符是可选的,即便是在 --posix 模式下仍然如此。我在标准中并没有找到任何关于该替代语法的说明(如果是因为我没有在标准中找到那个特性,请在评论区留言告诉我!)。因此,如果对可移植性要求很高,请注意使用它的风险:

# 非 POSIX 语法:
head -5 inputfile | sed -e '
1i\# List of user accounts
$a\# end
'

也有一些 Sed 的实现,让初始的反斜杠完全是可选的。因此毫无疑问,它是一个厂商对 POSIX 标准进行扩展的特定版本,它是否支持那个语法,你需要去查看那个 Sed 版本的手册。

在简单概述之后,我们现在来回顾一下这些命令的更多细节,从我还没有介绍的改变命令开始。

改变命令

改变命令(c\)就像 d 命令一样删除模式空间的内容并开始一个新的循环。唯一的不同在于,当命令运行之后,用户提供的文本是写往输出的。

The Sed change command

cat -n inputfile | sed -e '
/systemd/c\
# :REMOVED:
s/:.*// # This will NOT be applied to the "changed" text
'

如果改变命令与一个地址范围关联,当到达范围的最后一行时,这个文本将仅输出一次。这在某种程度上成为 Sed 命令将被重复应用在地址范围内所有行这一惯例的一个例外情况:

cat -n inputfile | sed -e '
19,22c\
# :REMOVED:
s/:.*// # This will NOT be applied to the "changed" text
'

因此,如果你希望将改变命令重复应用到地址范围内的所有行上,除了将它封装到一个块中之外,你将没有其它的选择:

cat -n inputfile | sed -e '
19,22{c\
# :REMOVED:
}
s/:.*// # This will NOT be applied to the "changed" text
'

插入命令

插入命令(i\)将立即在输出中给出用户提供的文本。它并不以任何方式修改程序流或缓冲区的内容。

The Sed insert command

# display the first five user names with a title on the first row
sed < inputfile -e '
1i\
USER NAME
s/:.*//
5q
'

追加命令

当输入的下一行被读取时,追加命令(a\)将一些文本追加到显示队列。文本在当前循环的结束部分(包含程序结束的情况)或当使用 nN 命令从输入中读取一个新行时被输出。

The Sed append command

与上面相同的一个示例,但这次是插入到底部而不是顶部:

sed < inputfile -e '
5a\
USER NAME
s/:.*//
5q
'

读取命令

这是插入一些文本内容到输出流的第四个命令:读取命令(r)。它的工作方式与追加命令完全一样,但不同的,它不从 Sed 脚本中取得硬编码到脚本中的文本,而是把一个文件的内容写入到一个输出上。

读取命令只调度要读取的文件。当清理追加队列时,后者才被高效地读取,而不是在读取命令运行时。如果这时候对这个文件有并发的访问读取,或那个文件不是一个普通的文件(比如,它是一个字符设备或命名管道),或文件在读取期间被修改,这时可能会产生严重的后果。

作为一个例证,如果你使用我们将在下一节详细讲述的写入命令,它与读取命令共同配合从一个临时文件中写入并重新读取,你可能会获得一些创造性的结果(使用法语版的 Shiritori 游戏作为一个例证):

printf "%s\n" "Trois p'tits chats" "Chapeau d' paille" "Paillasson" |
sed -ne '
  r temp
  a\
  ----
  w temp
'

现在,在流输出中专门用于插入一些文本的 Sed 命令清单结束了。我的最后一个示例纯属好玩,但是由于我前面提到过有一个写入命令,这个示例将我们完美地带到下一节,在下一节我们将看到在 Sed 中如何将数据写入到一个外部文件。

替代的输出

Sed 的设计思想是,所有的文本转换都将写入到进程的标准输出上。但是,Sed 也有一些特性支持将数据发送到替代的目的地。你有两种方式去实现上述的输出目标替换:使用专门的写入命令(w),或者在一个替换命令(s)上添加一个写入标志。

写入命令

写入命令(w)会追加模式空间的内容到给定的目标文件中。POSIX 要求在 Sed 处理任何数据之前,目标文件能够被 Sed 所创建。如果给定的目标文件已经存在,它将被覆写。

The Sed write command

因此,即便是你从未真的写入到该文件中,但该文件仍然会被创建。例如,下列的 Sed 程序将创建/覆写这个 output 文件,那怕是这个写入命令从未被运行过:

echo | sed -ne '
  q # 立刻退出
  w output # 这个命令从未被运行
'

你可以将几个写入命令指向到同一个目标文件。指向同一个目标文件的所有写入命令将追加那个文件的内容(工作方式几乎与 shell 的重定向符 >> 相同):

sed < inputfile -ne '
  /:\/bin\/false$/w server
  /:\/usr\/sbin\/nologin$/w server
  w output
'
cat server

替换命令的写入标志

在前面,我们已经学习了替换命令(s),它有一个 p 选项用于在替换之后输出模式空间的内容。同样它也提供一个类似功能的 w 选项,用于在替换之后将模式空间的内容输出到一个文件中:

sed < inputfile -ne '
  s/:.*\/nologin$//w server
  s/:.*\/false$//w server
'
cat server

注释

我无数次使用过它们,但我从未花时间正式介绍过它们,因此,我决定现在来正式地介绍它们:就像大多数编程语言一样,注释是添加软件不去解析的自由格式文本的一种方法。Sed 的语法很晦涩,我不得不强调在脚本中需要的地方添加足够的注释。否则,除了作者外其他人将几乎无法理解它。

The Sed comment command

不过,和 Sed 的其它部分一样,注释也有它自己的微妙之处。首先并且是最重要的,注释并不是语法结构,但它是真正意义的 Sed 命令。注释虽然是一个“什么也不做”的命令,但它仍然是一个命令。至少,它是在 POSIX 中定义了的。因此,严格地说,它们只允许使用在其它命令允许使用的地方。

大多数 Sed 实现都通过允许行内命令来放松了那种要求,就像在那个文章中我到处都使用的那样。

结束那个主题之前,需要说一下 #n 注释(# 后面紧跟一个字母 n,中间没有空格)的特殊情况。如果在脚本的第一行找到这个精确注释,Sed 将切换到静默模式(即:清除自动输出标志),就像在命令行上指定了 -n 选项一样。

很少用得到的命令

现在,我们已经学习的命令能让你写出你所用到的 99.99% 的脚本。但是,如果我没有提到剩余的 Sed 命令,那么本教程就不能称为完全指南。我把它们留到最后是因为我们很少用到它。但或许你有实际使用案例,那么你就会发现它们很有用。如果是那样,请不要犹豫,在下面的评论区中把它分享给我们吧。

行数命令

这个 = 命令将向标准输出上显示当前 Sed 正在读取的行数,这个行数就是行计数器(LC)的内容。没有任何方式从任何一个 Sed 缓冲区中捕获那个数字,也不能对它进行输出格式化。由于这两个限制使得这个命令的可用性大大降低。

The Sed line number command

请记住,在严格的 POSIX 兼容模式中,当在命令行上给定几个输入文件时,Sed 并不重置那个计数器,而是连续地增长它,就像所有的输入文件是连接在一起的一样。一些 Sed 实现,像 GNU Sed,它就有一个选项可以在每个输入文件读取结束后去重置计数器。

明确打印命令

这个 l(小写的字母 l)作用类似于打印命令(p),但它是以精确的格式去输出模式空间的内容。以下引用自 POSIX 标准

在 XBD 转义序列中列出的字符和相关的动作(\\\a\b\f\r\t\v)将被写为相应的转义序列;在那个表中的 \n 是不适用的。不在那个表中的不可打印字符将被写为一个三位八进制数字(在前面使用一个反斜杠 \),表示字符中的每个字节(最重要的字节在前面)。长行应该被换行,通过写一个反斜杠后跟一个换行符来表示换行位置;发生换行时的长度是不确定的,但应该适合输出设备的具体情况。每个行应该以一个 $ 标记结束。

The Sed unambiguous print command

我怀疑这个命令是在非 8 位规则化信道 上交换数据的。就我本人而言,除了调试用途以外,也从未使用过它。

移译命令

移译 transliterate y)命令允许从一个源集到一个目标集映射模式空间的字符。它非常类似于 tr 命令,但是限制更多。

The Sed transliterate command

# The `y` c0mm4nd 1s for h4x0rz only
sed < inputfile -e '
 s/:.*//
 y/abcegio/48<3610/
'

虽然移译命令语法与替换命令的语法有一些相似之处,但它在替换字符串之后不接受任何选项。这个移译总是全局的。

请注意,移译命令要求源集和目标集之间要一一对应地转换。这意味着下面的 Sed 程序可能所做的事情并不是你乍一看所想的那样:

# 注意:这可能并不如你想的那样工作!
sed < inputfile -e '
  s/:.*//
  y/[a-z]/[A-Z]/
'

写在最后的话

# 它要做什么?
# 提示:答案就在不远处...
sed -E '
  s/.*\W(.*)/\1/
  h
  ${ x; p; }
  d' < inputfile

我们已经学习了所有的 Sed 命令,真不敢相信我们已经做到了!如果你也读到这里了,应该恭喜你,尤其是如果你花费了一些时间,在你的系统上尝试了所有的不同示例!

正如你所见,Sed 是非常复杂的,不仅因为它的语法比较零乱,也因为许多极端案例或命令行为之间的细微差别。毫无疑问,我们可以将这些归结于历史的原因。尽管它有这么多缺点,但是 Sed 仍然是一个非常强大的工具,甚至到现在,它仍然是 Unix 工具箱中为数不多的大量使用的命令之一。是时候总结一下这篇文章了,没有你们的支持我将无法做到:请节选你对喜欢的或最具创意的 Sed 脚本,并共享给我们。如果我收集到的你们共享出的脚本足够多了,我将会把这些 Sed 脚本结集发布!


via: https://linuxhandbook.com/sed-reference-guide/

作者:Sylvain Leroux 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

cut 命令是用来从文本文件中移除“某些列”的经典工具。在本文中的“一列”可以被定义为按照一行中位置区分的一系列字符串或者字节,或者是以某个分隔符为间隔的某些域。

先前我已经介绍了如何使用 AWK 命令。在本文中,我将解释 linux 下 cut 命令的 4 个本质且实用的例子,有时这些例子将帮你节省很多时间。

Cut Linux 命令示例

Linux 下 cut 命令的 4 个实用示例

假如你想,你可以观看下面的视频,视频中解释了本文中我列举的 cut 命令的使用例子。

1、 作用在一系列字符上

当启用 -c 命令行选项时,cut 命令将移除一系列字符。

和其他的过滤器类似, cut 命令不会直接改变输入的文件,它将复制已修改的数据到它的标准输出里去。你可以通过重定向命令的结果到一个文件中来保存修改后的结果,或者使用管道将结果送到另一个命令的输入中,这些都由你来负责。

假如你已经下载了上面视频中的示例测试文件,你将看到一个名为 BALANCE.txt 的数据文件,这些数据是直接从我妻子在她工作中使用的某款会计软件中导出的:

sh$ head BALANCE.txt
ACCDOC    ACCDOCDATE    ACCOUNTNUM ACCOUNTLIB              ACCDOCLIB                        DEBIT          CREDIT
4         1012017       623477     TIDE SCHEDULE           ALNEENRE-4701-LOC                00000001615,00
4         1012017       445452     VAT BS/ENC              ALNEENRE-4701-LOC                00000000323,00
4         1012017       4356       PAYABLES                ALNEENRE-4701-LOC                               00000001938,00
5         1012017       623372     ACCOMODATION GUIDE      ALNEENRE-4771-LOC                00000001333,00
5         1012017       445452     VAT BS/ENC              ALNEENRE-4771-LOC                00000000266,60
5         1012017       4356       PAYABLES                ALNEENRE-4771-LOC                               00000001599,60
6         1012017       4356       PAYABLES                FACT FA00006253 - BIT QUIROBEN                  00000001837,20
6         1012017       445452     VAT BS/ENC              FACT FA00006253 - BIT QUIROBEN   00000000306,20
6         1012017       623795     TOURIST GUIDE BOOK      FACT FA00006253 - BIT QUIROBEN   00000001531,00

上述文件是一个固定宽度的文本文件,因为对于每一项数据,都使用了不定长的空格做填充,使得它看起来是一个对齐的列表。

这样一来,每一列数据开始和结束的位置都是一致的。从 cut 命令的字面意思去理解会给我们带来一个小陷阱:cut 命令实际上需要你指出你想保留的数据范围,而不是你想移除的范围。所以,假如我需要上面文件中的 ACCOUNTNUMACCOUNTLIB 列,我需要这么做:

sh$ cut -c 25-59 BALANCE.txt | head
ACCOUNTNUM ACCOUNTLIB
623477     TIDE SCHEDULE
445452     VAT BS/ENC
4356       /accountPAYABLES
623372     ACCOMODATION GUIDE
445452     VAT BS/ENC
4356       PAYABLES
4356       PAYABLES
445452     VAT BS/ENC
623795     TOURIST GUIDE BOOK

范围如何定义?

正如我们上面看到的那样, cut 命令需要我们特别指定需要保留的数据的范围。所以,下面我将更正式地介绍如何定义范围:对于 cut 命令来说,范围是由连字符(-)分隔的起始和结束位置组成,范围是基于 1 计数的,即每行的第一项是从 1 开始计数的,而不是从 0 开始。范围是一个闭区间,开始和结束位置都将包含在结果之中,正如它们之间的所有字符那样。如果范围中的结束位置比起始位置小,则这种表达式是错误的。作为快捷方式,你可以省略起始结束值,正如下面的表格所示:

范围含义
a-ba 和 b 之间的范围(闭区间)
a与范围 a-a 等价
-b与范围 1-a 等价
b-与范围 b-∞ 等价

cut 命令允许你通过逗号分隔多个范围,下面是一些示例:

# 保留 1 到 24 之间(闭区间)的字符
cut -c -24 BALANCE.txt

# 保留 1 到 24(闭区间)以及 36 到 59(闭区间)之间的字符
cut -c -24,36-59 BALANCE.txt

# 保留 1 到 24(闭区间)、36 到 59(闭区间)和 93 到该行末尾之间的字符
cut -c -24,36-59,93- BALANCE.txt

cut 命令的一个限制(或者是特性,取决于你如何看待它)是它将 不会对数据进行重排。所以下面的命令和先前的命令将产生相同的结果,尽管范围的顺序做了改变:

cut -c 93-,-24,36-59 BALANCE.txt

你可以轻易地使用 diff 命令来验证:

diff -s <(cut -c -24,36-59,93- BALANCE.txt) \
              <(cut -c 93-,-24,36-59 BALANCE.txt)
Files /dev/fd/63 and /dev/fd/62 are identical

类似的,cut 命令 不会重复数据

# 某人或许期待这可以第一列三次,但并不会……
cut -c -10,-10,-10 BALANCE.txt | head -5
ACCDOC
4
4
4
5

值得提及的是,曾经有一个提议,建议使用 -o 选项来去除上面提到的两个限制,使得 cut 工具可以重排或者重复数据。但这个提议被 POSIX 委员会拒绝了“因为这类增强不属于 IEEE P1003.2b 草案标准的范围”

据我所知,我还没有见过哪个版本的 cut 程序实现了上面的提议,以此来作为扩展,假如你知道某些例外,请使用下面的评论框分享给大家!

2、 作用在一系列字节上

当使用 -b 命令行选项时,cut 命令将移除字节范围。

咋一看,使用字符范围和使用字节没有什么明显的不同:

sh$ diff -s <(cut -b -24,36-59,93- BALANCE.txt) \
              <(cut -c -24,36-59,93- BALANCE.txt)
Files /dev/fd/63 and /dev/fd/62 are identical

这是因为我们的示例数据文件使用的是 US-ASCII 编码(字符集),使用 file -i 便可以正确地猜出来:

sh$ file -i BALANCE.txt
BALANCE.txt: text/plain; charset=us-ascii

在 US-ASCII 编码中,字符和字节是一一对应的。理论上,你只需要使用一个字节就可以表示 256 个不同的字符(数字、字母、标点符号和某些符号等)。实际上,你能表达的字符数比 256 要更少一些,因为字符编码中为某些特定值做了规定(例如 32 或 65 就是控制字符)。即便我们能够使用上述所有的字节范围,但对于存储种类繁多的人类手写符号来说,256 是远远不够的。所以如今字符和字节间的一一对应更像是某种例外,并且几乎总是被无处不在的 UTF-8 多字节编码所取代。下面让我们看看如何来处理多字节编码的情形。

作用在多字节编码的字符上

正如我前面提到的那样,示例数据文件来源于我妻子使用的某款会计软件。最近好像她升级了那个软件,然后呢,导出的文本就完全不同了,你可以试试和上面的数据文件相比,找找它们之间的区别:

sh$ head BALANCE-V2.txt
ACCDOC    ACCDOCDATE    ACCOUNTNUM ACCOUNTLIB              ACCDOCLIB                        DEBIT          CREDIT
4         1012017       623477     TIDE SCHEDULE           ALNÉENRE-4701-LOC                00000001615,00
4         1012017       445452     VAT BS/ENC              ALNÉENRE-4701-LOC                00000000323,00
4         1012017       4356       PAYABLES                ALNÉENRE-4701-LOC                               00000001938,00
5         1012017       623372     ACCOMODATION GUIDE      ALNÉENRE-4771-LOC                00000001333,00
5         1012017       445452     VAT BS/ENC              ALNÉENRE-4771-LOC                00000000266,60
5         1012017       4356       PAYABLES                ALNÉENRE-4771-LOC                               00000001599,60
6         1012017       4356       PAYABLES                FACT FA00006253 - BIT QUIROBEN                  00000001837,20
6         1012017       445452     VAT BS/ENC              FACT FA00006253 - BIT QUIROBEN   00000000306,20
6         1012017       623795     TOURIST GUIDE BOOK      FACT FA00006253 - BIT QUIROBEN   00000001531,00

上面的标题栏或许能够帮助你找到什么被改变了,但无论你找到与否,现在让我们看看上面的更改过后的结果:

sh$ cut -c 93-,-24,36-59 BALANCE-V2.txt
ACCDOC    ACCDOCDATE    ACCOUNTLIB              DEBIT          CREDIT
4         1012017       TIDE SCHEDULE            00000001615,00
4         1012017       VAT BS/ENC               00000000323,00
4         1012017       PAYABLES                                00000001938,00
5         1012017       ACCOMODATION GUIDE       00000001333,00
5         1012017       VAT BS/ENC               00000000266,60
5         1012017       PAYABLES                                00000001599,60
6         1012017       PAYABLES                               00000001837,20
6         1012017       VAT BS/ENC              00000000306,20
6         1012017       TOURIST GUIDE BOOK      00000001531,00
19        1012017       SEMINAR FEES            00000000080,00
19        1012017       PAYABLES                               00000000080,00
28        1012017       MAINTENANCE             00000000746,58
28        1012017       VAT BS/ENC              00000000149,32
28        1012017       PAYABLES                               00000000895,90
31        1012017       PAYABLES                                00000000240,00
31        1012017       VAT BS/DEBIT             00000000040,00
31        1012017       ADVERTISEMENTS           00000000200,00
32        1012017       WATER                   00000000202,20
32        1012017       VAT BS/DEBIT            00000000020,22
32        1012017       WATER                   00000000170,24
32        1012017       VAT BS/DEBIT            00000000009,37
32        1012017       PAYABLES                               00000000402,03
34        1012017       RENTAL COSTS            00000000018,00
34        1012017       PAYABLES                               00000000018,00
35        1012017       MISCELLANEOUS CHARGES   00000000015,00
35        1012017       VAT BS/DEBIT            00000000003,00
35        1012017       PAYABLES                               00000000018,00
36        1012017       LANDLINE TELEPHONE        00000000069,14
36        1012017       VAT BS/ENC                00000000013,83

毫无删减地复制了上面命令的输出。所以可以很明显地看出列对齐那里有些问题。

对此我的解释是原来的数据文件只包含 US-ASCII 编码的字符(符号、标点符号、数字和没有发音符号的拉丁字母)。

但假如你仔细地查看经软件升级后产生的文件,你可以看到新导出的数据文件保留了带发音符号的字母。例如现在合理地记录了名为 “ALNÉENRE” 的公司,而不是先前的 “ALNEENRE”(没有发音符号)。

file -i 正确地识别出了改变,因为它报告道现在这个文件是 UTF-8 编码 的。

sh$ file -i BALANCE-V2.txt
BALANCE-V2.txt: text/plain; charset=utf-8

如果想看看 UTF-8 文件中那些带发音符号的字母是如何编码的,我们可以使用 [hexdump][12],它可以让我们直接以字节形式查看文件:

# 为了减少输出,让我们只关注文件的第 2 行
sh$ sed '2!d' BALANCE-V2.txt
4         1012017       623477     TIDE SCHEDULE           ALNÉENRE-4701-LOC                00000001615,00
sh$ sed '2!d' BALANCE-V2.txt  | hexdump -C
00000000  34 20 20 20 20 20 20 20  20 20 31 30 31 32 30 31  |4         101201|
00000010  37 20 20 20 20 20 20 20  36 32 33 34 37 37 20 20  |7       623477  |
00000020  20 20 20 54 49 44 45 20  53 43 48 45 44 55 4c 45  |   TIDE SCHEDULE|
00000030  20 20 20 20 20 20 20 20  20 20 20 41 4c 4e c3 89  |           ALN..|
00000040  45 4e 52 45 2d 34 37 30  31 2d 4c 4f 43 20 20 20  |ENRE-4701-LOC   |
00000050  20 20 20 20 20 20 20 20  20 20 20 20 20 30 30 30  |             000|
00000060  30 30 30 30 31 36 31 35  2c 30 30 20 20 20 20 20  |00001615,00     |
00000070  20 20 20 20 20 20 20 20  20 20 20 0a              |           .|
0000007c

hexdump 输出的 00000030 那行,在一系列的空格(字节 20)之后,你可以看到:

  • 字母 A 被编码为 41
  • 字母 L 被编码为 4c
  • 字母 N 被编码为 4e

但对于大写的带有注音的拉丁大写字母 E (这是它在 Unicode 标准中字母 É 的官方名称),则是使用 2 个字节 c3 89 来编码的。

这样便出现问题了:对于使用固定宽度编码的文件, 使用字节位置来表示范围的 cut 命令工作良好,但这并不适用于使用变长编码的 UTF-8 或者 Shift JIS 编码。这种情况在下面的 POSIX 标准的非规范性摘录 中被明确地解释过:

先前版本的 cut 程序将字节和字符视作等同的环境下运作(正如在某些实现下对退格键 <backspace> 和制表键 <tab> 的处理)。在针对多字节字符的情况下,特别增加了 -b 选项。

嘿,等一下!我并没有在上面“有错误”的例子中使用 '-b' 选项,而是 -c 选项呀!所以,难道不应该能够成功处理了吗!?

是的,确实应该:但是很不幸,即便我们现在已身处 2018 年,GNU Coreutils 的版本为 8.30 了,cut 程序的 GNU 版本实现仍然不能很好地处理多字节字符。引用 GNU 文档 的话说,-c 选项“现在和 -b 选项是相同的,但对于国际化的情形将有所不同[...]”。需要提及的是,这个问题距今已有 10 年之久了!

另一方面,OpenBSD 的实现版本和 POSIX 相吻合,这将归功于当前的本地化(locale)设定来合理地处理多字节字符:

# 确保随后的命令知晓我们现在处理的是 UTF-8 编码的文本文件
openbsd-6.3$ export LC_CTYPE=en_US.UTF-8

# 使用 `-c` 选项, `cut` 能够合理地处理多字节字符
openbsd-6.3$ cut -c -24,36-59,93- BALANCE-V2.txt
ACCDOC    ACCDOCDATE    ACCOUNTLIB              DEBIT          CREDIT
4         1012017       TIDE SCHEDULE           00000001615,00
4         1012017       VAT BS/ENC              00000000323,00
4         1012017       PAYABLES                               00000001938,00
5         1012017       ACCOMODATION GUIDE      00000001333,00
5         1012017       VAT BS/ENC              00000000266,60
5         1012017       PAYABLES                               00000001599,60
6         1012017       PAYABLES                               00000001837,20
6         1012017       VAT BS/ENC              00000000306,20
6         1012017       TOURIST GUIDE BOOK      00000001531,00
19        1012017       SEMINAR FEES            00000000080,00
19        1012017       PAYABLES                               00000000080,00
28        1012017       MAINTENANCE             00000000746,58
28        1012017       VAT BS/ENC              00000000149,32
28        1012017       PAYABLES                               00000000895,90
31        1012017       PAYABLES                               00000000240,00
31        1012017       VAT BS/DEBIT            00000000040,00
31        1012017       ADVERTISEMENTS          00000000200,00
32        1012017       WATER                   00000000202,20
32        1012017       VAT BS/DEBIT            00000000020,22
32        1012017       WATER                   00000000170,24
32        1012017       VAT BS/DEBIT            00000000009,37
32        1012017       PAYABLES                               00000000402,03
34        1012017       RENTAL COSTS            00000000018,00
34        1012017       PAYABLES                               00000000018,00
35        1012017       MISCELLANEOUS CHARGES   00000000015,00
35        1012017       VAT BS/DEBIT            00000000003,00
35        1012017       PAYABLES                               00000000018,00
36        1012017       LANDLINE TELEPHONE      00000000069,14
36        1012017       VAT BS/ENC              00000000013,83

正如期望的那样,当使用 -b 选项而不是 -c 选项后, OpenBSD 版本的 cut 实现和传统的 cut 表现是类似的:

openbsd-6.3$ cut -b -24,36-59,93- BALANCE-V2.txt
ACCDOC    ACCDOCDATE    ACCOUNTLIB              DEBIT          CREDIT
4         1012017       TIDE SCHEDULE            00000001615,00
4         1012017       VAT BS/ENC               00000000323,00
4         1012017       PAYABLES                                00000001938,00
5         1012017       ACCOMODATION GUIDE       00000001333,00
5         1012017       VAT BS/ENC               00000000266,60
5         1012017       PAYABLES                                00000001599,60
6         1012017       PAYABLES                               00000001837,20
6         1012017       VAT BS/ENC              00000000306,20
6         1012017       TOURIST GUIDE BOOK      00000001531,00
19        1012017       SEMINAR FEES            00000000080,00
19        1012017       PAYABLES                               00000000080,00
28        1012017       MAINTENANCE             00000000746,58
28        1012017       VAT BS/ENC              00000000149,32
28        1012017       PAYABLES                               00000000895,90
31        1012017       PAYABLES                                00000000240,00
31        1012017       VAT BS/DEBIT             00000000040,00
31        1012017       ADVERTISEMENTS           00000000200,00
32        1012017       WATER                   00000000202,20
32        1012017       VAT BS/DEBIT            00000000020,22
32        1012017       WATER                   00000000170,24
32        1012017       VAT BS/DEBIT            00000000009,37
32        1012017       PAYABLES                               00000000402,03
34        1012017       RENTAL COSTS            00000000018,00
34        1012017       PAYABLES                               00000000018,00
35        1012017       MISCELLANEOUS CHARGES   00000000015,00
35        1012017       VAT BS/DEBIT            00000000003,00
35        1012017       PAYABLES                               00000000018,00
36        1012017       LANDLINE TELEPHONE        00000000069,14
36        1012017       VAT BS/ENC                00000000013,83

3、 作用在域上

从某种意义上说,使用 cut 来处理用特定分隔符隔开的文本文件要更加容易一些,因为只需要确定好每行中域之间的分隔符,然后复制域的内容到输出就可以了,而不需要烦恼任何与编码相关的问题。

下面是一个用分隔符隔开的示例文本文件:

sh$ head BALANCE.csv
ACCDOC;ACCDOCDATE;ACCOUNTNUM;ACCOUNTLIB;ACCDOCLIB;DEBIT;CREDIT
4;1012017;623477;TIDE SCHEDULE;ALNEENRE-4701-LOC;00000001615,00;
4;1012017;445452;VAT BS/ENC;ALNEENRE-4701-LOC;00000000323,00;
4;1012017;4356;PAYABLES;ALNEENRE-4701-LOC;;00000001938,00
5;1012017;623372;ACCOMODATION GUIDE;ALNEENRE-4771-LOC;00000001333,00;
5;1012017;445452;VAT BS/ENC;ALNEENRE-4771-LOC;00000000266,60;
5;1012017;4356;PAYABLES;ALNEENRE-4771-LOC;;00000001599,60
6;1012017;4356;PAYABLES;FACT FA00006253 - BIT QUIROBEN;;00000001837,20
6;1012017;445452;VAT BS/ENC;FACT FA00006253 - BIT QUIROBEN;00000000306,20;
6;1012017;623795;TOURIST GUIDE BOOK;FACT FA00006253 - BIT QUIROBEN;00000001531,00;

你可能知道上面文件是一个 CSV 格式的文件(它以逗号来分隔),即便有时候域分隔符不是逗号。例如分号(;)也常被用来作为分隔符,并且对于那些总使用逗号作为 十进制分隔符的国家(例如法国,所以上面我的示例文件中选用了他们国家的字符),当导出数据为 “CSV” 格式时,默认将使用分号来分隔数据。另一种常见的情况是使用 tab 键 来作为分隔符,从而生成叫做 tab 分隔的值 的文件。最后,在 Unix 和 Linux 领域,冒号 (:) 是另一种你能找到的常见分隔符号,例如在标准的 /etc/passwd/etc/group 这两个文件里。

当处理使用分隔符隔开的文本文件格式时,你可以向带有 -f 选项的 cut 命令提供需要保留的域的范围,并且你也可以使用 -d 选项来指定分隔符(当没有使用 -d 选项时,默认以 tab 字符来作为分隔符):

sh$ cut -f 5- -d';' BALANCE.csv | head
ACCDOCLIB;DEBIT;CREDIT
ALNEENRE-4701-LOC;00000001615,00;
ALNEENRE-4701-LOC;00000000323,00;
ALNEENRE-4701-LOC;;00000001938,00
ALNEENRE-4771-LOC;00000001333,00;
ALNEENRE-4771-LOC;00000000266,60;
ALNEENRE-4771-LOC;;00000001599,60
FACT FA00006253 - BIT QUIROBEN;;00000001837,20
FACT FA00006253 - BIT QUIROBEN;00000000306,20;
FACT FA00006253 - BIT QUIROBEN;00000001531,00;

处理不包含分隔符的行

但要是输入文件中的某些行没有分隔符又该怎么办呢?很容易地认为可以将这样的行视为只包含第一个域。但 cut 程序并 不是 这样做的。

默认情况下,当使用 -f 选项时,cut 将总是原样输出不包含分隔符的那一行(可能假设它是非数据行,就像表头或注释等):

sh$ (echo "# 2018-03 BALANCE"; cat BALANCE.csv) > BALANCE-WITH-HEADER.csv

sh$ cut -f 6,7 -d';' BALANCE-WITH-HEADER.csv | head -5
# 2018-03 BALANCE
DEBIT;CREDIT
00000001615,00;
00000000323,00;
;00000001938,00

使用 -s 选项,你可以做出相反的行为,这样 cut 将总是忽略这些行:

sh$ cut -s -f 6,7 -d';' BALANCE-WITH-HEADER.csv | head -5
DEBIT;CREDIT
00000001615,00;
00000000323,00;
;00000001938,00
00000001333,00;

假如你好奇心强,你还可以探索这种特性,来作为一种相对隐晦的方式去保留那些只包含给定字符的行:

# 保留含有一个 `e` 的行
sh$ printf "%s\n" {mighty,bold,great}-{condor,monkey,bear} | cut -s -f 1- -d'e'

改变输出的分隔符

作为一种扩展, GNU 版本实现的 cut 允许通过使用 --output-delimiter 选项来为结果指定一个不同的域分隔符:

sh$ cut -f 5,6- -d';' --output-delimiter="*" BALANCE.csv | head
ACCDOCLIB*DEBIT*CREDIT
ALNEENRE-4701-LOC*00000001615,00*
ALNEENRE-4701-LOC*00000000323,00*
ALNEENRE-4701-LOC**00000001938,00
ALNEENRE-4771-LOC*00000001333,00*
ALNEENRE-4771-LOC*00000000266,60*
ALNEENRE-4771-LOC**00000001599,60
FACT FA00006253 - BIT QUIROBEN**00000001837,20
FACT FA00006253 - BIT QUIROBEN*00000000306,20*
FACT FA00006253 - BIT QUIROBEN*00000001531,00*

需要注意的是,在上面这个例子中,所有出现域分隔符的地方都被替换掉了,而不仅仅是那些在命令行中指定的作为域范围边界的分隔符。

4、 非 POSIX GNU 扩展

说到非 POSIX GNU 扩展,它们中的某些特别有用。特别需要提及的是下面的扩展也同样对字节、字符或者域范围工作良好(相对于当前的 GNU 实现来说)。

--complement

想想在 sed 地址中的感叹符号(!),使用它,cut 将只保存没有被匹配到的范围:

# 只保留第 5 个域
sh$ cut -f 5 -d';' BALANCE.csv |head -3
ACCDOCLIB
ALNEENRE-4701-LOC
ALNEENRE-4701-LOC

# 保留除了第 5 个域之外的内容
sh$ cut --complement -f 5 -d';' BALANCE.csv |head -3
ACCDOC;ACCDOCDATE;ACCOUNTNUM;ACCOUNTLIB;DEBIT;CREDIT
4;1012017;623477;TIDE SCHEDULE;00000001615,00;
4;1012017;445452;VAT BS/ENC;00000000323,00;

--zero-terminated (-z)

使用 NUL 字符 来作为行终止符,而不是 新行 newline 字符。当你的数据包含 新行字符时, -z 选项就特别有用了,例如当处理文件名的时候(因为在文件名中新行字符是可以使用的,而 NUL 则不可以)。

为了展示 -z 选项,让我们先做一点实验。首先,我们将创建一个文件名中包含换行符的文件:

bash$ touch $'EMPTY\nFILE\nWITH FUNKY\nNAME'.txt
bash$ ls -1 *.txt
BALANCE.txt
BALANCE-V2.txt
EMPTY?FILE?WITH FUNKY?NAME.txt

现在假设我想展示每个 *.txt 文件的前 5 个字符。一个想当然的解决方法将会失败:

sh$ ls -1 *.txt | cut -c 1-5
BALAN
BALAN
EMPTY
FILE
WITH
NAME.

你可以已经知道 ls 是为了方便人类使用而特别设计的,并且在一个命令管道中使用它是一个反模式(确实是这样的)。所以让我们用 find 来替换它:

sh$ find . -name '*.txt' -printf "%f\n" | cut -c 1-5
BALAN
EMPTY
FILE
WITH
NAME.
BALAN

上面的命令基本上产生了与先前类似的结果(尽管以不同的次序,因为 ls 会隐式地对文件名做排序,而 find 则不会)。

在上面的两个例子中,都有一个相同的问题,cut 命令不能区分 新行 字符是数据域的一部分(即文件名),还是作为最后标记的 新行 记号。但使用 NUL 字节(\0)来作为行终止符就将排除掉这种混淆的情况,使得我们最后可以得到期望的结果:

# 我被告知在某些旧版的 `tr` 程序中需要使用 `\000` 而不是 `\0` 来代表 NUL 字符(假如你需要这种改变请让我知晓!)
sh$ find . -name '*.txt' -printf "%f\0" | cut -z -c 1-5| tr '\0' '\n'
BALAN
EMPTY
BALAN

通过上面最后的例子,我们就达到了本文的最后部分了,所以我将让你自己试试 -printf 后面那个有趣的 "%f\0" 参数或者理解为什么我在管道的最后使用了 tr 命令。

使用 cut 命令可以实现更多功能

我只是列举了 cut 命令的最常见且在我眼中最基础的使用方式。你甚至可以将它以更加实用的方式加以运用,这取决于你的逻辑和想象。

不要再犹豫了,请使用下面的评论框贴出你的发现。最后一如既往的,假如你喜欢这篇文章,请不要忘记将它分享到你最喜爱网站和社交媒体中!


via: https://linuxhandbook.com/cut-command/

作者:Sylvain Leroux 译者:FSSlc 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

How to install software from source code

简介:这篇文章详细介绍了在 Linux 中怎么用源代码安装程序,以及怎么去卸载用源代码安装的程序。

Linux 发行版的一个最大的优点就是它的包管理器和相关的软件库。通过它们提供的资源和工具,你才能够以完全自动化的方式在你的计算机上下载和安装软件。

但是,尽管付出了很多的努力,包维护者仍然没法照顾好每种情况,也不可能将所有的可用软件都打包进去。因此,仍然存在需要你自已去编译和安装一个新软件的情形。对于我来说,到目前为止,最主要的原因是,我编译一些软件是我需要去运行一个特定的版本。或者是我想去修改源代码或使用一些想要的编译选项。

如果你也属于后一种情况,那你已经知道你应该怎么做了。但是,对于绝大多数的 Linux 用户来说,第一次从源代码中编译和安装一个软件看上去像是一个入门仪式:它让很多人感到恐惧;但是,如果你能克服困难,你将可能进入一个全新的世界,并且,如果你做到了,那么你将成为社区中享有特权的一部分人。

A. 在 Linux 中从源代码开始安装软件

这正是我们要做的。因为这篇文章的需要,我要在我的系统上安装 NodeJS 8.1.1。它是个完全真实的版本。这个版本在 Debian 仓库中没有:

sh$ apt-cache madison nodejs | grep amd64
    nodejs | 6.11.1~dfsg-1 | http://deb.debian.org/debian experimental/main amd64 Packages
    nodejs | 4.8.2~dfsg-1 | http://ftp.fr.debian.org/debian stretch/main amd64 Packages
    nodejs | 4.8.2~dfsg-1~bpo8+1 | http://ftp.fr.debian.org/debian jessie-backports/main amd64 Packages
    nodejs | 0.10.29~dfsg-2 | http://ftp.fr.debian.org/debian jessie/main amd64 Packages
    nodejs | 0.10.29~dfsg-1~bpo70+1 | http://ftp.fr.debian.org/debian wheezy-backports/main amd64 Packages

第 1 步:从 GitHub 上获取源代码

像大多数开源项目一样,NodeJS 的源代码可以在 GitHub:https://github.com/nodejs/node 上找到。

所以,我们直接开始吧。

The NodeJS official GitHub repository

如果你不熟悉 GitHubgit 或者提到的其它 版本管理系统包含了这个软件的源代码,以及多年来对该软件的所有修改的历史。甚至可以回溯到该软件的最早版本。对于开发者来说,保留它的历史版本有很多好处。如今对我来说,其中一个好处是可以得到任何一个给定时间点的项目源代码。更准确地说,我可以得到我所要的 8.1.1 发布时的源代码。即便从那之后他们有了很多的修改。

Choose the v8.1.1 tag in the NodeJS GitHub repository

在 GitHub 上,你可以使用 “branch” (分支)按钮导航到这个软件的不同版本。“分支” 和 “标签” 是 Git 中一些相关的概念。总的来说,开发者创建 “分支” 和 “标签” 来在项目历史中对重要事件保持跟踪,比如当他们启用一个新特性或者发布一个新版本时。在这里先不详细介绍了,你现在只需要知道我在找被标记为 “v8.1.1” 的版本。

The NodeJS GitHub repository as it was at the time the v8.1.1 tag was created

在选择了 “v8.1.1” 标签后,页面被刷新,最显著的变化是标签现在作为 URL 的一部分出现。另外,你可能会注意到文件改变日期也有所不同。你现在看到的源代码树是创建了 v8.1.1 标签时的代码。在某种意义上,你也可以认为像 git 这样的版本管理工具是一个时光穿梭机,允许你在项目历史中来回穿梭。

NodeJS GitHub repository download as a ZIP button

此时,我们可以下载 NodeJS 8.1.1 的源代码。你不要忘记去点那个建议的大的蓝色按钮来下载一个项目的 ZIP 压缩包。对于我来说,为讲解的目的,我从命令行中下载并解压这个 ZIP 压缩包。但是,如果你更喜欢使用一个 GUI 工具,不用担心,你可以取代下面的命令方式:

wget https://github.com/nodejs/node/archive/v8.1.1.zip
unzip v8.1.1.zip
cd node-8.1.1/

下载一个 ZIP 包就可以,但是如果你希望“像个专家一样”,我建议你直接使用 git 工具去下载源代码。它一点也不复杂 — 并且如果你是第一次使用该工具,它将是一个很好的开端,你以后将经常用到它:

# first ensure git is installed on your system
sh$ sudo apt-get install git
# Make a shallow clone the NodeJS repository at v8.1.1
sh$ git clone --depth 1 \
              --branch v8.1.1 \
              https://github.com/nodejs/node
sh$ cd node/

顺便说一下,如果你有任何问题,这篇文章的第一部分只是做一个总体介绍而已。后面,为了帮你排除常见问题,我们将基于 Debian 和基于 RedHat 的发行版更详细地解释。

不管怎样,在你使用 git 或者作为一个 ZIP 压缩包下载了源代码后,在当前目录下就有了同样的源代码文件:

sh$ ls
android-configure  BUILDING.md            common.gypi      doc            Makefile   src
AUTHORS            CHANGELOG.md           configure        GOVERNANCE.md  node.gyp   test
benchmark          CODE_OF_CONDUCT.md     CONTRIBUTING.md  lib            node.gypi  tools
BSDmakefile        COLLABORATOR_GUIDE.md  deps             LICENSE        README.md  vcbuild.bat

第 2 步:理解程序的构建系统

构建系统就是我们通常所说的“编译源代码”,其实,编译只是从源代码中生成一个可使用的软件的其中一个阶段。构建系统是一套工具,用于自动处置不同的任务,以便可以仅通过几个命令就能构建整个软件。

虽然概念很简单,实际上编译做了很多事情。因为不同的项目或者编程语言也许有不同的要求,或者因为编程者的好恶,或者因为支持的平台、或者因为历史的原因,等等等等 … 选择或创建另外一个构建系统的原因几乎数不清。这方面有许多种不同的解决方案。

NodeJS 使用一种 GNU 风格的构建系统。这在开源社区中这是一个很流行的选择。由此开始,你将进入一段精彩的旅程。

写出和调优一个构建系统是一个非常复杂的任务。但是,作为 “终端用户” 来说,GNU 风格的构建系统使用两个工具让他们免于此难:configuremake

configure 文件是个项目专用的脚本,它将检查目标系统的配置和可用功能,以确保该项目可以被构建,并最终吻合当前平台的特性。

一个典型的 configure 任务的重要部分是去构建 Makefile。这个文件包含了有效构建项目所需的指令。

另一方面,make 工具,这是一个可用于任何类 Unix 系统的 POSIX 工具。它将读取项目专用的 Makefile 然后执行所需的操作去构建和安装你的程序。

但是,在 Linux 的世界中,你仍然有一些定制你自己专用的构建的理由。

./configure --help

configure -help 命令将展示你可用的所有配置选项。再强调一下,这是非常的项目专用。说实话,有时候,在你完全理解每个配置选项的作用之前,你需要深入到项目中去好好研究。

不过,这里至少有一个标准的 GNU 自动化工具选项是你该知道的,它就是众所周知的 --prefix 选项。它与文件系统的层次结构有关,它是你软件要安装的位置。

第 3 步:文件系统层次化标准(FHS)

大部分典型的 Linux 发行版的文件系统层次结构都遵从 文件系统层次化标准(FHS)

这个标准说明了你的系统中各种目录的用途,比如,/usr/tmp/var 等等。

当使用 GNU 自动化工具 和大多数其它的构建系统 时,它会把新软件默认安装在你的系统的 /usr/local 目录中。这是依据 FHS 中 /usr/local 层级是为系统管理员本地安装软件时使用的,它在系统软件更新覆盖时是安全的。它也可以用于存放在一组主机中共享,但又没有放到 /usr 中的程序和数据”,因此,它是一个非常好的选择。

/usr/local 层级以某种方式复制了根目录,你可以在 /usr/local/bin 这里找到可执行程序,在 /usr/local/lib 中找到库,在 /usr/local/share 中找到架构无关的文件,等等。

使用 /usr/local 树作为你定制安装的软件位置的唯一问题是,你的软件的文件将在这里混杂在一起。尤其是你安装了多个软件之后,将很难去准确地跟踪 /usr/local/bin/usr/local/lib 中的哪个文件到底属于哪个软件。它虽然不会导致系统的问题。毕竟,/usr/bin 也是一样混乱的。但是,有一天你想去卸载一个手工安装的软件时它会将成为一个问题。

要解决这个问题,我通常喜欢安装定制的软件到 /opt 子目录下。再次引用 FHS:

/opt 是为安装附加的应用程序软件包而保留的。

包安装在 /opt 下的软件包必须将它的静态文件放在单独的 /opt/<package> 或者 /opt/<provider> 目录中,此处 <package> 是所说的那个软件名的名字,而 <provider> 处是提供者的 LANANA 注册名字。”(LCTT 译注:LANANA 是指 The Linux Assigned Names And Numbers Authority。 )

因此,我们将在 /opt 下创建一个子目录,用于我们定制的 NodeJS 安装。并且,如果有一天我想去卸载它,我只是很简单地去删除那个目录:

sh$ sudo mkdir /opt/node-v8.1.1
sh$ sudo ln -sT node-v8.1.1 /opt/node
# What is the purpose of the symbolic link above?
# Read the article till the end--then try to answer that
# question in the comment section!

sh$ ./configure --prefix=/opt/node-v8.1.1
sh$ make -j9 && echo ok
# -j9 means run up to 9 parallel tasks to build the software.
# As a rule of thumb, use -j(N+1) where N is the number of cores
# of your system. That will maximize the CPU usage (one task per
# CPU thread/core + a provision of one extra task when a process
# is blocked by an I/O operation.

在你运行完成 make 命令之后,如果有任何的除了 “ok” 以外的信息,将意味着在构建过程中有错误。当我们使用一个 -j 选项去运行并行构建时,在构建系统的大量输出过程中,检索错误信息并不是件很容易的事。

在这种情况下,只能是重新开始 make,并且不要使用 -j 选项。这样错误将会出现在输出信息的最后面:

sh$ make

最终,编译结束后,你可以运行这个命令去安装你的软件:

sh$ sudo make install

然后测试它:

sh$ /opt/node/bin/node --version
v8.1.1

B. 如果在源代码安装的过程中出现错误怎么办?

我上面介绍的大多是你能在文档完备的项目的“构建指令”页面上看到。但是,本文的目标是让你从源代码开始去编译你的第一个软件,它可能要花一些时间去研究一些常见的问题。因此,我将再次重新开始一遍整个过程,但是,这次是在一个最新的、最小化安装的 Debian 9.0 和 CentOS 7.0 系统上。因此,你可能看到我遇到的错误以及我怎么去解决它。

从 Debian 9.0 中 “Stretch” 开始

itsfoss@debian:~$ git clone --depth 1 \
                             --branch v8.1.1 \
                             https://github.com/nodejs/node
-bash: git: command not found

这个问题非常容易去诊断和解决。去安装这个 git 包即可:

itsfoss@debian:~$ sudo apt-get install git
itsfoss@debian:~$ git clone --depth 1 \
                             --branch v8.1.1 \
                             https://github.com/nodejs/node && echo ok
[...]
ok
itsfoss@debian:~/node$ sudo mkdir /opt/node-v8.1.1
itsfoss@debian:~/node$ sudo ln -sT node-v8.1.1 /opt/node

现在没有问题了。

itsfoss@debian:~/node$ ./configure --prefix=/opt/node-v8.1.1/
WARNING: failed to autodetect C++ compiler version (CXX=g++)
WARNING: failed to autodetect C compiler version (CC=gcc)
Node.js configure error: No acceptable C compiler found!
        Please make sure you have a C compiler installed on your system and/or
        consider adjusting the CC environment variable if you installed
        it in a non-standard prefix.

很显然,编译一个项目,你需要一个编译器。NodeJS 是使用 C++ 语言 写的,我们需要一个 C++ 编译器。在这里我将安装 g++,它就是为这个目的写的 GNU C++ 编译器:

itsfoss@debian:~/node$ sudo apt-get install g++
itsfoss@debian:~/node$ ./configure --prefix=/opt/node-v8.1.1/ && echo ok
[...]
ok
itsfoss@debian:~/node$ make -j9 && echo ok
-bash: make: command not found

还差一个其它工具。同样的症状。同样的解决方案:

itsfoss@debian:~/node$ sudo apt-get install make
itsfoss@debian:~/node$ make -j9 && echo ok
[...]
ok
itsfoss@debian:~/node$ sudo make install
[...]
itsfoss@debian:~/node$ /opt/node/bin/node --version
v8.1.1

成功!

请注意:我将一次又一次地安装各种工具去展示怎么去诊断编译问题,以及展示怎么去解决这些问题。但是,如果你搜索关于这个主题的更多文档,或者读其它的教程,你将发现,很多发行版有一个 “meta-packages”,它包罗了安装一些或者全部的用于编译软件的常用工具。在基于 Debian 的系统上,你或许遇到过 build-essentials 包,它就是这种用作。在基于 Red Hat 的发行版中,它将是 “Development Tools” 组。

在 CentOS 7.0 上

[itsfoss@centos ~]$ git clone --depth 1 \
                               --branch v8.1.1 \
                               https://github.com/nodejs/node
-bash: git: command not found

命令没有找到?可以用 yum 包管理器去安装它:

[itsfoss@centos ~]$ sudo yum install git
[itsfoss@centos ~]$ git clone --depth 1 \
                               --branch v8.1.1 \
                               https://github.com/nodejs/node && echo ok
[...]
ok
[itsfoss@centos ~]$ sudo mkdir /opt/node-v8.1.1
[itsfoss@centos ~]$ sudo ln -sT node-v8.1.1 /opt/node
[itsfoss@centos ~]$ cd node
[itsfoss@centos node]$ ./configure --prefix=/opt/node-v8.1.1/
WARNING: failed to autodetect C++ compiler version (CXX=g++)
WARNING: failed to autodetect C compiler version (CC=gcc)
Node.js configure error: No acceptable C compiler found!

        Please make sure you have a C compiler installed on your system and/or
        consider adjusting the CC environment variable if you installed
        it in a non-standard prefix.

你知道的:NodeJS 是使用 C++ 语言写的,但是,我的系统缺少合适的编译器。Yum 可以帮到你。因为,我不是一个合格的 CentOS 用户,我实际上是在互联网上搜索到包含 g++ 编译器的包的确切名字的。这个页面指导了我:https://superuser.com/questions/590808/yum-install-gcc-g-doesnt-work-anymore-in-centos-6-4

[itsfoss@centos node]$ sudo yum install gcc-c++
[itsfoss@centos node]$ ./configure --prefix=/opt/node-v8.1.1/ && echo ok
[...]
ok
[itsfoss@centos node]$ make -j9 && echo ok
[...]
ok
[itsfoss@centos node]$ sudo make install && echo ok
[...]
ok
[itsfoss@centos node]$ /opt/node/bin/node --version
v8.1.1

再次成功!

C. 从源代码中对要安装的软件做一些改变

从源代码中安装一个软件,可能是因为你的分发仓库中没有一个可用的特定版本。或者因为你想去 修改 那个程序。也可能是修复一个 bug 或者增加一个特性。毕竟,开源软件这些都可以做到。因此,我将抓住这个机会,让你亲自体验怎么去编译你自己的软件。

在这里,我将在 NodeJS 源代码上做一个微小改变。然后,我们将看到我们的改变将被纳入到软件的编译版本中:

用你喜欢的 文本编辑器(如,vim、nano、gedit、 … )打开文件 node/src/node.cc。然后,尝试找到如下的代码片段:

   if (debug_options.ParseOption(argv[0], arg)) {
      // Done, consumed by DebugOptions::ParseOption().
    } else if (strcmp(arg, "--version") == 0 || strcmp(arg, "-v") == 0) {
      printf("%s\n", NODE_VERSION);
      exit(0);
    } else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
      PrintHelp();
      exit(0);
    }

它在 文件的 3830 行 附近。然后,修改包含 printf 的行,将它替换成如下内容:

      printf("%s (compiled by myself)\n", NODE_VERSION);

然后,返回到你的终端。在继续之前,为了对强大的 Git 支持有更多的了解,你可以去检查一下,你修改是文件是否正确:

diff --git a/src/node.cc b/src/node.cc
index bbce1022..a5618b57 100644
--- a/src/node.cc
+++ b/src/node.cc
@@ -3828,7 +3828,7 @@ static void ParseArgs(int* argc,
     if (debug_options.ParseOption(argv[0], arg)) {
       // Done, consumed by DebugOptions::ParseOption().
     } else if (strcmp(arg, "--version") == 0 || strcmp(arg, "-v") == 0) {
-      printf("%s\n", NODE_VERSION);
+      printf("%s (compiled by myself)\n", NODE_VERSION);
       exit(0);
     } else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
       PrintHelp();

在你前面改变的那行之前,你将看到一个 “-” (减号标志)。而在改变之后的行前面有一个 “+” (加号标志)。

现在可以去重新编译并重新安装你的软件了:

make -j9 && sudo make install && echo ok
[...]
ok

这个时候,可能失败的唯一原因就是你改变代码时的输入错误。如果就是这种情况,在文本编辑器中重新打开 node/src/node.cc 文件并修复错误。

一旦你完成了新修改版本的 NodeJS 的编译和安装,就可以去检查你的修改是否包含到软件中:

itsfoss@debian:~/node$ /opt/node/bin/node --version
v8.1.1 (compiled by myself)

恭喜你!你对开源程序做出了你的第一个改变!

D. 让 shell 找到我们定制构建的软件

到目前为止,你可能注意到,我通常启动我新编译的 NodeJS 软件是通过指定到该二进制文件的绝对路径。

/opt/node/bin/node

这是可以正常工作的。但是,这样太麻烦。实际上有两种办法可以去解决这个问题。但是,去理解它们,你必须首先明白,你的 shell 定位可执行文件是通过在环境变量 PATH 中指定的目录里面查找的。

itsfoss@debian:~/node$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

在这个 Debian 系统上,如果你不指定一个精确的目录做为命令名字的一部分,shell 将首先在 /usr/local/bin 中查找可执行程序;如果没有找到,然后进入 /usr/bin 中查找;如果没有找到,然后进入 /bin查找;如果没有找到,然后进入 /usr/local/games 查找;如果没有找到,然后进入 /usr/games 查找;如果没有找到,那么,shell 将报告一个错误,“command not found”

由此,我们可以知道有两种方法去确保命令可以被 shell 访问到:将它(该二进制程序)增加到已经配置好的 PATH 目录中,或者将包含可执行程序的目录添加到 PATH 中。

从 /usr/local/bin 中添加一个链接

只是从 /opt/node/bin拷贝 NodeJS 二进制可执行文件到 /usr/local/bin 是一个错误的做法。因为,如果这么做,该可执行程序将无法定位到在 /opt/node/ 中的需要的其它组件。(软件以它自己的位置去定位它所需要的资源文件是常见的做法)

因此,传统的做法是去使用一个符号链接:

itsfoss@debian:~/node$ sudo ln -sT /opt/node/bin/node /usr/local/bin/node
itsfoss@debian:~/node$ which -a node || echo not found
/usr/local/bin/node
itsfoss@debian:~/node$ node --version
v8.1.1 (compiled by myself)

这一个简单而有效的解决办法,尤其是,如果一个软件包是由好几个众所周知的可执行程序组成的,因为,你将为每个用户调用的命令创建一个符号链接。例如,如果你熟悉 NodeJS,你知道应用的 npm 组件,也应该从 /usr/local/bin 做个符号链接。我把这个留给你做练习。

修改 PATH

首先,如果你尝试过前面的解决方案,请先移除前面创建的节点符号链接,去从一个干净的状态开始:

itsfoss@debian:~/node$ sudo rm /usr/local/bin/node
itsfoss@debian:~/node$ which -a node || echo not found
not found

现在,这里有一个改变你的 PATH 的魔法命令:

itsfoss@debian:~/node$ export PATH="/opt/node/bin:${PATH}"
itsfoss@debian:~/node$ echo $PATH
/opt/node/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

简单说就是,我用环境变量 PATH 之前的内容前缀了一个 /opt/node/bin 替换了其原先的内容。因此,你可以想像一下,shell 将先进入到 /opt/node/bin 目录中查找可执行程序。我们也可以使用 which 命令去确认一下:

itsfoss@debian:~/node$ which -a node || echo not found
/opt/node/bin/node
itsfoss@debian:~/node$ node --version
v8.1.1 (compiled by myself)

鉴于 “符号链接” 解决方案是永久的,只要创建到 /usr/local/bin 的符号链接就行了,而对 PATH 的改变仅影响到当前的 shell。你可以自己做一些研究,如何做到对 PATH 的永久改变。给你一个提示,可以将它写到你的 “profile” 中。如果你找到这个解决方案,不要犹豫,通过下面的评论区共享给其它的读者!

E. 怎么去卸载刚才从源代码中安装的软件

因为我们定制编译的 NodeJS 软件全部在 /opt/node-v8.1.1 目录中,卸载它不需要做太多的工作,仅使用 rm 命令去删除那个目录即可:

sudo rm -rf /opt/node-v8.1.1

注意:sudorm -rf 是 “非常危险的鸡尾酒”!一定要在按下回车键之前多检查几次你的命令。你不会得到任何的确认信息,并且如果你删除了错误的目录它是不可恢复的 …

然后,如果你修改了你的 PATH,你可以去恢复这些改变。它一点也不复杂。

如果你从 /usr/local/bin 创建了一个符号链接,你应该去删除它们:

itsfoss@debian:~/node$ sudo find /usr/local/bin \
                                 -type l \
                                 -ilname "/opt/node/*" \
                                 -print -delete
/usr/local/bin/node

等等? 依赖地狱在哪里?

作为最终的讨论,如果你读过有关的编译定制软件的文档,你可能听到关于 依赖地狱 dependency hell 的说法。那是在你能够成功编译一个软件之前,对那种烦人情况的一个别名,你必须首先编译一个前提条件所需要的库,它又可能要求其它的库,而这些库有可能与你的系统上已经安装的其它软件不兼容。

发行版的软件包维护者的部分工作,就是实际去地解决那些依赖地狱,确保你的系统上的各种软件都使用了兼容的库,并且按正确的顺序去安装。

在这篇文章中,我特意选择了 NodeJS 去安装,是因为它几乎没有依赖。我说 “几乎” 是因为,实际上,它 依赖。但是,这些源代码的依赖已经预置到项目的源仓库中(在 node/deps 子目录下),因此,在你动手编译之前,你不用手动去下载和安装它们。

如果你有兴趣了解更多关于那个问题的知识和学习怎么去处理它。请在下面的评论区告诉我,它将是更高级别的文章的好主题!


作者简介:

充满激情的工程师,职业是教师,我的目标是:热心分享我所教的内容,并让我的学生自己培养它们的技能。你也可以在我的网站上联系到我。


via: https://itsfoss.com/install-software-from-source-code/

作者:Sylvain Leroux 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出