• 发生时间:2023-05-17

  • 问题描述:

    • 我在产线客户端中实现了一个 check_no_less_than 模块,它的作用是通过 SSH 登录设备 bash 并执行命令,并检查返回的数据是否不小于期望值。
    • 在测试 check_no_less_than 模块时,我构造了一个测试用例:发送一条命令给设备,让设备返回的 data 为20,而我配置的期望值为 30,看此函数是否能报错。(代码如下所示)
    // check_no_less_than_test.js
    const cmd = `echo "{\"code\": 0, \"data\": 20}"`
    
    // ssh_client.js
    const hostCmd = `${this.excutor} root@${this.options.ip} '${cmd}'`
    
    • 当运行此测试用例时,会报 JSON 解析错误:
    Uncaught SyntaxError: Unexpected token c in JSON at position 1
    
  • 问题类别:软件开发

  • 原因分析:

    • 错误信息表明返回的数据不符合 JSON 格式,于是先打印返回的数据,结果为 {code: 0, data: 20},原来双引号不见了。
    • 很容易想到是转义问题,因此先在终端环境下进行测试,最终发现需要进行两次转义(不仅要转义双引号,还要把转义双引号的转义符也转义)。
    • 经过耐心查阅 bash 手册,分析出原因:这条命令实际上先被本地的 bash 解析一次,然后又被远端的 bash 再解析一次。(详见下面「bash 解析引号原理」一章)
  • 解决方案:将命令修改为 echo {\\\"code\\\": 0, \\\"data\\\": 20}

  • 实施结果:使用修改后的命令,能够正常跑通测试用例。

  • 经验总结:

    • SSH 登录远端设备执行 echo 命令返回双引号时要转义两次,因为命令实际上被(本地和远端的 bash)解析了两次。
    • bash 和 JavaScript 两门语言中单引号的用法的不同的:bash 里面单引号可以保留反斜杠的字面含义,而 JavaScript 里面单引号没有这种用法。
    • 字符串转义问题特别容易出错,建议最好提前在终端实测一下,由简到繁逐步调试。

bash 解析引号原理

首先我们需要理解单引号和双引号在 bash 里面的用法,然后再来分析一些案例。

单引号

先看 bash 手册对单引号的介绍:

Enclosing characters in single quotes (‘’’) preserves the literal value of each character within the quotes. A single quote may not occur between single quotes, even when preceded by a backslash.

意思很简单,单引号会保留被单引号所括住的任意字符的字面含义,包括反斜杠。这意味着在 bash 里面无法使用反斜杠来转义单引号。

$ echo '\'
\

$ echo '$$'
$$

$ echo '`echo hi`'
`echo hi`

$ echo '"'
"
# 单引号里面的反斜杠没有转义效果,因此这里 bash 在等待用户输出另一个单引号。
$ echo '\''
>

从上面测试可以看到,单引号可以保留反斜杠、美元符号、反引号和双引号等特殊字符的字面含义,同时无法转义自身。

双引号

再看 bash 手册对双引号的介绍(这里删掉一些对本文主题关系不大的描述,对那些内容感兴趣的同学可自行点击链接去阅读):

Enclosing characters in double quotes (‘"’) preserves the literal value of all characters within the quotes, with the exception of ‘$’, ‘’, ‘\’. The characters ‘$’ and ‘’ retain their special meaning within double quotes (see Shell Expansions). The backslash retains its special meaning only when followed by one of the following characters: ‘$’, ‘`’, ‘"’, ‘\’, or newline. Within double quotes, backslashes that are followed by one of these characters are removed. Backslashes preceding characters without a special meaning are left unmodified. A double quote may be quoted within double quotes by preceding it with a backslash.

这里可以看到 bash 里面双引号与单引号的区别:

  • 单引号会保留所有字符的字面含义,双引号也会保留大部分字符的字面含义,但有三个例外:$、反引号和反斜杠。
  • 单引号里面,反斜杠没有转义效果;双引号里面,当反斜杠后面紧跟$、单引号、双引号、反斜杠和换行符时,会有转义效果,bash 解析的时候会将反斜杠删除;否则不会删除。
# "$$" 在 bash 里面代表当前 shell 的 PID
$ echo "$$"
3735

# 反斜杠在 bash 里面代表命令替换(Command Substitution)
$ echo "`echo hi`"
hi

# 当双引号里面的反斜杠后面紧跟的字符不可转义时,会保留反斜杠
$ echo "\a"
\a

$ echo "'"
'
# 双引号里面的反斜杠把紧跟的双引号转义了,因此这里 bash 在等待用户输入另一个双引号。
$ echo "\"
>

案例分析

掌握以上基础知识后,我们便可以进行一些案例分析了。

  1. 在本地终端打印不带任何引号的 hello world

    $ echo hello world
    hello world
    
  2. 在本地终端打印被双引号括起来的 hello world

    # bash 里面双引号被用于保留字符的字面含义,因此不能直接打印,需要转义或用单引号括起来。
    $ echo "hello world"
    hello world
    
  3. 在本地终端打印被双引号括起来、且双引号被转义的 hello world

    # bash 里面反斜杠后面紧跟双引号,会保留双引号的字面含义,因此双引号能被打印。
    $ echo \"hello world\"
    "hello world"
    
  4. 在本地终端打印被双引号括起来、且双引号又被单引号括起来的 hello world

    # bash 里面单引号会保留任意字符(包括双引号)的字面含义,因此双引号能被打印。
    $ echo '"hello world"'
    "hello world"
    
  5. SSH 远端登录设备(下文的 back 是我个人的一台 Linux 主机)打印不带任何引号的 hello world

    $ ssh back echo hello world
    hello world
    
  6. SSH 远端登录设备打印带有双引号的 hello world

    # 本地的 bash 解析命令时便把双引号去掉了,因此发给远端的 bash 时就已经没有双引号了。
    $ ssh back echo "hello world"
    hello world
    
  7. SSH 远端登录设备打印带有双引号、且双引号被转义的 hello world

    # 本地的 bash 解析命令时把转义符去掉,发给远端的 bash 时是带有双引号的,但远端的 bash 解析时会把双引号去掉,最终还是没有双引号。
    $ ssh back echo \"hello world\"
    hello world
    
  8. SSH 远端登录设备打印带有双引号、且双引号被转义、且转义符也被转义的 hello world

    # 本地的 bash 解析命令时去掉一层转义符,`\\\"` 变成 `\"`,发给远端的 bash 时仍带有转义符和双引号,因此最终能打印双引号。
    $ ssh back echo \\\"hello world\\\"
    "hello world"
    
  9. SSH 远端登录设备打印带有双引号、且双引号被转义、且双引号和转义符被单引号括起来的 hello world

    # 本地的 bash 解析命令时会保留 `\"` 的字面含义,发给远端的 bash 时仍带有转义符和双引号,因此最终能打印双引号。
    $ ssh back echo '\"hello world\"'
    "hello world"
    

最后提一个问题:check_no_less_than 的测试用例里面,能否使用案例9的命令呢?这样看上去比案例8要短一点点。

答案是不能。这是因为 JavaScript 和 bash 对单引号的解析是不一样的: 前面说到,bash 里面单引号可以保留反斜杠的字面含义,但 JavaScript 里面单引号和双引号是完全相同的,不能保留反斜杠的字面含义。

以下例子可以证明这一点:

> let cmd = `'\"'`
undefined
> let utf8Encode = new TextEncoder();
undefined
> utf8Encode.encode(cmd)
Uint8Array(3) [ 39, 34, 39 ]

从 node 控制台的输出可以发现,变量 cmd 里面并不会保留转义符——也就是说,在 JavaScript 里面单引号是无法保留转义符的字面含义的。