使用 Node.js 进行 Shell 脚本编程
您可以购买本书的离线版本(HTML、PDF、EPUB、MOBI),并支持免费在线版本。
(广告,请不要屏蔽。)

16 使用 util.parseArgs() 解析命令行参数



在本章中,我们将探讨如何使用模块 node:util 中的 Node.js 函数 parseArgs() 来解析命令行参数。

16.1 本章隐含的导入

本章中的每个示例都隐含了以下两个导入

import * as assert from 'node:assert/strict';
import {parseArgs} from 'node:util';

第一个导入用于我们用来检查值的测试断言。第二个导入用于本章主题函数 parseArgs()

16.2 处理命令行参数所涉及的步骤

处理命令行参数涉及以下步骤

  1. 用户输入一个文本字符串。
  2. Shell 将字符串解析为一系列单词和运算符。
  3. 如果调用了一个命令,它将获得零个或多个单词作为参数。
  4. 我们的 Node.js 代码通过存储在 process.argv 中的数组接收这些单词。process 是 Node.js 上的全局变量。
  5. 我们使用 parseArgs() 将该数组转换为更易于使用的形式。

让我们使用以下带有 Node.js 代码的 Shell 脚本 args.mjs 来查看 process.argv 的外观

#!/usr/bin/env node
console.log(process.argv);

我们从一个简单的命令开始

% ./args.mjs one two
[ '/usr/bin/node', '/home/john/args.mjs', 'one', 'two' ]

如果我们在 Windows 上通过 npm 安装该命令,则相同的命令会在 Windows 命令 Shell 上产生以下结果

[
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\jane\\args.mjs',
  'one',
  'two'
]

无论我们如何调用 Shell 脚本,process.argv 始终以用于运行我们代码的 Node.js 二进制文件的路径开头。接下来是我们脚本的路径。数组以传递给脚本的实际参数结尾。换句话说:脚本的参数始终从索引 2 开始。

因此,我们更改脚本,使其如下所示

#!/usr/bin/env node
console.log(process.argv.slice(2));

让我们尝试更复杂的论点

% ./args.mjs --str abc --bool home.html main.js
[ '--str', 'abc', '--bool', 'home.html', 'main.js' ]

这些论点包括

两种使用参数的风格很常见

写成 JavaScript 函数调用,前面的示例如下所示(在 JavaScript 中,选项通常放在最后)

argsMjs('home.html', 'main.js', {str: 'abc', bool: false});

16.3 解析命令行参数

16.3.1 基础知识

如果我们想让 parseArgs() 解析带有参数的数组,我们首先需要告诉它我们的选项是如何工作的。假设我们的脚本有

我们将这些选项描述给 parseArgs(),如下所示

const options = {
  'verbose': {
    type: 'boolean',
    short: 'v',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
  'times': {
    type: 'string',
    short: 't',
  },
};

只要 options 的属性键是有效的 JavaScript 标识符,则是否对其进行引用取决于您。两者都有优缺点。在本章中,它们始终被引用。这样,具有非标识符名称的选项(例如 my-new-option)看起来与具有标识符名称的选项相同。

options 中的每个条目都可以具有以下属性(通过 TypeScript 类型定义)

type Options = {
  type: 'boolean' | 'string', // required
  short?: string, // optional
  multiple?: boolean, // optional, default `false`
};

以下代码使用 parseArgs()options 来解析带有参数的数组

assert.deepEqual(
  parseArgs({options, args: [
    '--verbose', '--color', 'green', '--times', '5'
  ]}),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'green',
      times: '5'
    },
    positionals: []
  }
);

存储在 .values 中的对象的原型为 null。这意味着我们可以使用 in 运算符来检查属性是否存在,而不必担心继承的属性(例如 .toString)。

如前所述,作为 --times 值的数字 5 被处理为字符串。

我们传递给 parseArgs() 的对象的 TypeScript 类型如下

type ParseArgsProps = {
  options?: {[key: string], Options}, // optional, default: {}
  args?: Array<string>, // optional
    // default: process.argv.slice(2)
  strict?: boolean, // optional, default `true`
  allowPositionals?: boolean, // optional, default `false`
};

这是 parseArgs() 结果的类型

type ParseArgsResult = {
  values: {[key: string]: ValuesValue}, // an object
  positionals: Array<string>, // always an Array
};
type ValuesValue = boolean | string | Array<boolean|string>;

两个连字符用于引用选项的长版本。一个连字符用于引用简短版本

assert.deepEqual(
  parseArgs({options, args: ['-v', '-c', 'green']}),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'green',
    },
    positionals: []
  }
);

请注意,.values 包含选项的长名称。

我们在本小节的结尾解析与可选参数混合的位置参数

assert.deepEqual(
  parseArgs({
    options,
    allowPositionals: true,
    args: [
      'home.html', '--verbose', 'main.js', '--color', 'red', 'post.md'
    ]
  }),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'red',
    },
    positionals: [
      'home.html', 'main.js', 'post.md'
    ]
  }
);

16.3.2 多次使用选项

如果我们多次使用一个选项,则默认情况下仅最后一次有效。它将覆盖所有先前的出现

const options = {
  'bool': {
    type: 'boolean',
  },
  'str': {
    type: 'string',
  },
};

assert.deepEqual(
  parseArgs({
    options, args: [
      '--bool', '--bool', '--str', 'yes', '--str', 'no'
    ]
  }),
  {
    values: {__proto__:null,
      bool: true,
      str: 'no'
    },
    positionals: []
  }
);

但是,如果我们在选项的定义中将 .multiple 设置为 true,则 parseArgs() 会在数组中为我们提供所有选项值

const options = {
  'bool': {
    type: 'boolean',
    multiple: true,
  },
  'str': {
    type: 'string',
    multiple: true,
  },
};

assert.deepEqual(
  parseArgs({
    options, args: [
      '--bool', '--bool', '--str', 'yes', '--str', 'no'
    ]
  }),
  {
    values: {__proto__:null,
      bool: [ true, true ],
      str: [ 'yes', 'no' ]
    },
    positionals: []
  }
);

16.3.3 使用长选项和短选项的更多方法

请考虑以下选项

const options = {
  'verbose': {
    type: 'boolean',
    short: 'v',
  },
  'silent': {
    type: 'boolean',
    short: 's',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
};

以下是一种使用多个布尔选项的紧凑方法

assert.deepEqual(
  parseArgs({options, args: ['-vs']}),
  {
    values: {__proto__:null,
      verbose: true,
      silent: true,
    },
    positionals: []
  }
);

我们可以通过等号直接附加长字符串选项的值。这称为*内联值*。

assert.deepEqual(
  parseArgs({options, args: ['--color=green']}),
  {
    values: {__proto__:null,
      color: 'green'
    },
    positionals: []
  }
);

短选项不能具有内联值。

16.3.4 引用值

到目前为止,所有选项值和位置值都是单个单词。如果我们要使用包含空格的值,则需要用双引号或单引号将其引起来。但是,并非所有 Shell 都支持后者。

16.3.4.1 Shell 如何解析带引号的值

为了检查 Shell 如何解析带引号的值,我们再次使用脚本 args.mjs

#!/usr/bin/env node
console.log(process.argv.slice(2));

在 Unix 上,这些是双引号和单引号之间的区别

以下交互演示了双引号和单引号的选项值

% ./args.mjs --str "two words" --str 'two words'
[ '--str', 'two words', '--str', 'two words' ]

% ./args.mjs --str="two words" --str='two words'
[ '--str=two words', '--str=two words' ]

% ./args.mjs -s "two words" -s 'two words'
[ '-s', 'two words', '-s', 'two words' ]

在 Windows 命令 Shell 中,单引号在任何方面都不是特殊的

>node args.mjs "say \"hi\"" "\t\n" "%USERNAME%"
[ 'say "hi"', '\\t\\n', 'jane' ]

>node args.mjs 'back slash\' '\t\n' '%USERNAME%'
[ "'back", "slash\\'", "'\\t\\n'", "'jane'" ]

Windows 命令 Shell 中带引号的选项值

>node args.mjs --str 'two words' --str "two words"
[ '--str', "'two", "words'", '--str', 'two words' ]

>node args.mjs --str='two words' --str="two words"
[ "--str='two", "words'", '--str=two words' ]

>>node args.mjs -s "two words" -s 'two words'
[ '-s', 'two words', '-s', "'two", "words'" ]

在 Windows PowerShell 中,我们可以使用单引号进行引用,变量名不会在引号内插值,并且单引号不能转义

> node args.mjs "say `"hi`"" "\t\n" "%USERNAME%"
[ 'say hi', '\\t\\n', '%USERNAME%' ]
> node args.mjs 'backtick`' '\t\n' '%USERNAME%'
[ 'backtick`', '\\t\\n', '%USERNAME%' ]
16.3.4.2 parseArgs() 如何处理带引号的值

以下是 parseArgs() 处理带引号的值的方式

const options = {
  'times': {
    type: 'string',
    short: 't',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
};

// Quoted external option values
assert.deepEqual(
  parseArgs({
    options,
    args: ['-t', '5 times', '--color', 'light green']
  }),
  {
    values: {__proto__:null,
      times: '5 times',
      color: 'light green',
    },
    positionals: []
  }
);

// Quoted inline option values
assert.deepEqual(
  parseArgs({
    options,
    args: ['--color=light green']
  }),
  {
    values: {__proto__:null,
      color: 'light green',
    },
    positionals: []
  }
);

// Quoted positional values
assert.deepEqual(
  parseArgs({
    options, allowPositionals: true,
    args: ['two words', 'more words']
  }),
  {
    values: {__proto__:null,
    },
    positionals: [ 'two words', 'more words' ]
  }
);

16.3.5 选项终止符

parseArgs() 支持所谓的*选项终止符*:如果 args 的元素之一是双连字符 (--),则其余参数都将被视为位置参数。

哪里需要选项终止符?一些可执行文件会调用其他可执行文件,例如node 可执行文件。然后可以使用选项终止符来分隔调用者的参数和被调用者的参数。

以下是 parseArgs() 处理选项终止符的方式

const options = {
  'verbose': {
    type: 'boolean',
  },
  'count': {
    type: 'string',
  },
};

assert.deepEqual(
  parseArgs({options, allowPositionals: true,
    args: [
      'how', '--verbose', 'are', '--', '--count', '5', 'you'
    ]
  }),
  {
    values: {__proto__:null,
      verbose: true
    },
    positionals: [ 'how', 'are', '--count', '5', 'you' ]
  }
);

16.3.6 严格的 parseArgs()

如果选项 .stricttrue(默认值),则如果发生以下情况之一,parseArgs() 将抛出异常

以下代码演示了每种情况

const options = {
  'str': {
    type: 'string',
  },
};

// Unknown option name
assert.throws(
  () => parseArgs({
      options,
      args: ['--unknown']
    }),
  {
    name: 'TypeError',
    message: "Unknown option '--unknown'",
  }
);

// Wrong option type (missing value)
assert.throws(
  () => parseArgs({
      options,
      args: ['--str']
    }),
  {
    name: 'TypeError',
    message: "Option '--str <value>' argument missing",
  }
);

// Unallowed positional
assert.throws(
  () => parseArgs({
      options,
      allowPositionals: false, // (the default)
      args: ['posarg']
    }),
  {
    name: 'TypeError',
    message: "Unexpected argument 'posarg'. " +
      "This command does not take positional arguments",
  }
);

16.4 parseArgs 令牌

parseArgs() 分两个阶段处理 args 数组

如果我们将 config.tokens 设置为 true,则可以访问令牌。然后,parseArgs() 返回的对象包含一个属性 .tokens,其中包含令牌。

这些是令牌的属性

type Token = OptionToken | PositionalToken | OptionTerminatorToken;

interface CommonTokenProperties {
    /** Where in `args` does the token start? */
  index: number;
}

interface OptionToken extends CommonTokenProperties {
  kind: 'option';

  /** Long name of option */
  name: string;

  /** The option name as mentioned in `args` */
  rawName: string;

  /** The option’s value. `undefined` for boolean options. */
  value: string | undefined;

  /** Is the option value specified inline (e.g. --level=5)? */
  inlineValue: boolean | undefined;
}

interface PositionalToken extends CommonTokenProperties {
  kind: 'positional';

  /** The value of the positional, args[token.index] */
  value: string;
}

interface OptionTerminatorToken extends CommonTokenProperties {
  kind: 'option-terminator';
}

16.4.1 令牌示例

例如,请考虑以下选项

const options = {
  'bool': {
    type: 'boolean',
    short: 'b',
  },
  'flag': {
    type: 'boolean',
    short: 'f',
  },
  'str': {
    type: 'string',
    short: 's',
  },
};

布尔选项的令牌如下所示

assert.deepEqual(
  parseArgs({
    options, tokens: true,
    args: [
      '--bool', '-b', '-bf',
    ]
  }),
  {
    values: {__proto__:null,
      bool: true,
      flag: true,
    },
    positionals: [],
    tokens: [
      {
        kind: 'option',
        name: 'bool',
        rawName: '--bool',
        index: 0,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 1,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'flag',
        rawName: '-f',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
    ]
  }
);

请注意,选项 bool 有三个令牌,因为它在 args 中被提到了三次。但是,由于解析的第二阶段,.values 中只有一个 bool 属性。

在下一个示例中,我们将字符串选项解析为令牌。.inlineValue 现在具有布尔值(对于布尔选项,它始终为 undefined

assert.deepEqual(
  parseArgs({
    options, tokens: true,
    args: [
      '--str', 'yes', '--str=yes', '-s', 'yes',
    ]
  }),
  {
    values: {__proto__:null,
      str: 'yes',
    },
    positionals: [],
    tokens: [
      {
        kind: 'option',
        name: 'str',
        rawName: '--str',
        index: 0,
        value: 'yes',
        inlineValue: false
      },
      {
        kind: 'option',
        name: 'str',
        rawName: '--str',
        index: 2,
        value: 'yes',
        inlineValue: true
      },
      {
        kind: 'option',
        name: 'str',
        rawName: '-s',
        index: 3,
        value: 'yes',
        inlineValue: false
      }
    ]
  }
);

最后,这是一个解析位置参数和选项终止符的示例

assert.deepEqual(
  parseArgs({
    options, allowPositionals: true, tokens: true,
    args: [
      'command', '--', '--str', 'yes', '--str=yes'
    ]
  }),
  {
    values: {__proto__:null,
    },
    positionals: [ 'command', '--str', 'yes', '--str=yes' ],
    tokens: [
      { kind: 'positional', index: 0, value: 'command' },
      { kind: 'option-terminator', index: 1 },
      { kind: 'positional', index: 2, value: '--str' },
      { kind: 'positional', index: 3, value: 'yes' },
      { kind: 'positional', index: 4, value: '--str=yes' }
    ]
  }
);

16.4.2 使用令牌实现子命令

默认情况下,parseArgs() 不支持子命令,例如 git clonenpm install。但是,通过令牌实现此功能相对容易。

这是实现

function parseSubcommand(config) {
  // The subcommand is a positional, allow them
  const {tokens} = parseArgs({
    ...config, tokens: true, allowPositionals: true
  });
  let firstPosToken = tokens.find(({kind}) => kind==='positional');
  if (!firstPosToken) {
    throw new Error('Command name is missing: ' + config.args);
  }

  //----- Command options

  const cmdArgs = config.args.slice(0, firstPosToken.index);
  // Override `config.args`
  const commandResult = parseArgs({
    ...config, args: cmdArgs, tokens: false, allowPositionals: false
  });

  //----- Subcommand

  const subcommandName = firstPosToken.value;

  const subcmdArgs = config.args.slice(firstPosToken.index+1);
  // Override `config.args`
  const subcommandResult = parseArgs({
    ...config, args: subcmdArgs, tokens: false
  });

  return {
    commandResult,
    subcommandName,
    subcommandResult,
  };
}

这是 parseSubcommand() 的实际应用

const options = {
  'log': {
    type: 'string',
  },
  color: {
    type: 'boolean',
  }
};
const args = ['--log', 'all', 'print', '--color', 'file.txt'];
const result = parseSubcommand({options, allowPositionals: true, args});

const pn = obj => Object.setPrototypeOf(obj, null);
assert.deepEqual(
  result,
  {
    commandResult: {
      values: pn({'log': 'all'}),
      positionals: []
    },
    subcommandName: 'print',
    subcommandResult: {
      values: pn({color: true}),
      positionals: ['file.txt']
    }
  }
);