良好的CLI设计与实施
#c #cpp

介绍

图形用户界面(GUI)或REST API引起了所有注意,但是,对于命令行工具,命令行界面(CLIS)同样重要,但经常被忽略。因此,本文将展示如何在c。

中设计和实施一个好的CLI

通常最低限度,CLI必须:

  1. 解析和验证命令行选项及其参数(如果有)。
  2. 解析和验证命令行参数(如果有)。
  3. 提供良好的错误消息。
  4. 提供很好的帮助。

实际上有several styles of command-line options。其中,由于GNU命令行工具的无处不在,我个人建议使用GNU standard。要解析GNU风格的命令行选项,请使用getopt.h中声明的koude0。不幸的是,getopt_long()有许多怪癖。我会提到这些,并展示如何在它们周围工作。

命令行选项

getopt_long()功能需要长和短选项的列表。例如:

static struct option const OPTIONS_LONG[] = {
  { "help",    no_argument,       NULL, 'h' },
  { "output",  required_argument, NULL, 'o' },
  { "version", no_argument,       NULL, 'v' },
  { NULL,      0,                 NULL, 0   }
};
static char const OPTIONS_SHORT[] = ":ho:v";

对于OPTIONS_LONG,我找到了flag(第三)字段遗迹,因为它仅对int(或int-as-bool)选项值有用,因此我建议始终将其设置为NULL。在这种情况下,当遇到给定的长选项时,val(第四)字段是getopt_long()返回的。最直接的是使val成为短选项的同义词,因为每个长选项都应有一个简短的选项同义词。

在极少数情况下,当特定长选项不可用的短短选项同义词(因为所需的短选项已经用作其他长选项的同义词),然后您可以指定一个非null flag知道知道当遇到特定的长期选择时。在这种情况下,getopt_long()返回0

根据GNU标准,所有程序均应接受--help--version选项。

OPTIONS_SHORT以与posix koude19兼容的方式指定了短选项。注意:

  • 丢失了必需的选项参数时,:使getopt_long()返回:
  • :的期权信件意味着该选项需要参数。 (::表示该选项允许可选参数。)

但是,如果每个长选项都有一个简短的选项同义词,则必须单独指定OPTIONS_SHORT都是多余的(因为所有选项信息包含在OPTIONS_LONG中)和容易出错(因为您可能会更新OPTIONS_LONG,但忘记将OPTIONS_SHORT更新为匹配)。要解决这两个问题,我们可以编写一个函数来创建一个从长选项数组中创建一个简短的选项字符串:

static char const* make_short_opts( struct option const opts[static const 2] ) {
  // pre-flight to calculate string length
  size_t len = 1;       // for leading ':'
  for ( struct option const *opt = opts; opt->name != NULL; ++opt )
    len += 1 + (unsigned)opt->has_arg;

  char *const short_opts = malloc( len + 1/*\0*/ );
  char *s = short_opts;

  *s++ = ':';           // return missing argument as ':'
  for ( struct option const *opt = opts; opt->name != NULL; ++opt ) {
    *s++ = (char)opt->val;
    switch ( opt->has_arg ) {
      case optional_argument:
        *s++ = ':';
        // no break;
      case required_argument:
        *s++ = ':';
    } // switch
  } // for
  *s = '\0';

  return short_opts;
}

如果您不知道opts参数声明中的static const 2是什么意思,请阅读this

对于这个简单的示例,我们只需要声明一个output选项的一个全局变量:

char const *opt_output = "-";

(可以在内部处理--help--version选项。)

解析命令行选项

这是parse_options()函数的开始:

static void parse_options( int *pargc, char const **pargv[const] ) {
  opterr = 0;  // suppress default error message

  int  opt;
  bool opt_help = false;
  bool opt_version = false;
  char const *const options_short = make_short_opts( OPTIONS_LONG );
  // ...

该函数将指针带到argcargv,因为我们希望将argc调整为非选项命令行参数的数量,并调整argv,以便在第一个非选项参数中以argv[0]点(如果有)。 P>

首先,我们设置了全局opterr = 0来抑制getopt_long()给出的默认错误消息,因此我们可以完全以所需的格式打印错误消息。

接下来,我们声明了一些变量,包括opt_helpopt_version。这些选项变量在本地声明,因为我们可以在parse_options()中完全处理这些变量,因此无需使它们全局。

接下来,我们在循环中调用getopt_long(),直到返回-1之一(不再是选项),:(对于缺少所需的参数)或?(对于无效的选项):

  // ...
  for (;;) {
    opt = getopt_long(
      pargc, pargv, options_short, OPTIONS_LONG, /*longindex=*/NULL
    );
    if ( opt == -1 )
      break;
    switch ( opt ) {
      case 'h':
        opt_help = true;
        break;
      case 'o':
        if ( SKIP_WS( optarg )[0] == '\0' )
          goto missing_arg;
        opt_output = optarg;
        break;
      case 'v':
        opt_version = true;
        break;
      case ':':
        goto missing_arg;
      case '?':
        goto invalid_opt;
    } // switch
  } // for
  // ...

对于获取参数的选项,getopt_long()中存储在全局变量optarg中的参数值。您必须必须复制(浅层很好),因为它的选项变量将在每个循环迭代中都会更改。但是,getopt_long()考虑了以下选项:

example --output=     # optarg will be "" (empty string)
example --output=" "

有一个现在的空白或全天候论点。在大多数情况下,我们希望将其视为缺失的论点。 SKIP_WS()是一个宏,它跳过字符串中的任何领先的空格:

#define SKIP_WS(S)  ((S) += strspn( (S), " \n\t\r\f\v" ))

一旦跳过,就可以检查(更新)optarg的第一个字符:如果是无效的字符,则有效丢失了该参数。

请注意,我们可以在其各自的case中处理--help--version options。但是,我们不会因为首先解析所有选项,然后处理。如果处理此类选项,那么我们不会捕获使用错误:

example --version arg

--help--version选项只能由自己提供。我们稍后检查一下。)

在解析了所有选项之后,我们可以通过optind进行free options_short和调整argcargvoptind(由getopts_long()维护的全局变量包含分析的选项数量):

  // ...
  free( (void*)options_short );
  *pargc -= optind;
  *pargv += optind;
  // ...

--help--version选项

接下来,我们处理--help--version选项:

  // ...
  if ( opt_help )
    usage( *pargc > 0 ? EX_USAGE : EX_OK );
  if ( opt_version ) {
    if ( *pargc > 0 )
      usage( EX_USAGE );
    version();
  }
  return;
  // ...

EX_符号是sysexits.h中声明的退出状态代码。您应该尽可能使用这些代码。)

打印用法消息

usage()函数打印命令行的用法消息:

_Noreturn static void usage( int status ) {
  fprintf( status == EX_OK ? stdout : stderr,
    "usage: %s [options] ...\n"
    "options:\n"
    "  --help        (-h) Print this help and exit.\n"
    "  --output=FILE (-o) Write to FILE [default: stdout].\n"
    "  --version     (-v) Print version and exit.\n",
    prog_name
  );
  exit( status );
}

(全局变量prog_name包含我们在main()中设置的程序名称。)

usage()功能获得退出状态的原因有两个:

  1. 如果通过--help请求打印使用消息,则应将其打印为标准输出(因为没有发生错误)。但是,如果由于使用错误而被打印,则应将其打印为标准错误。
  2. 因此,它可以以该状态调用exit()(我们也可以这样做,因为它是出于第一个原因而通过的)。

呼叫:

    usage( *pargc > 0 ? EX_USAGE : EX_OK );

检查是否有任何命令行参数:如果是的,则是使用错误,因为--help选项只能由本身给出。

version()函数只需打印程序名称和版本,然后退出:

_Noreturn static void version( void ) {
  puts( PACKAGE_NAME " " PACKAGE_VERSION );
  exit( EX_OK );
}

在其他地方定义了PACKAGE_NAMEPACKAGE_VERSION,类似于:

#define PACKAGE_NAME     "example"
#define PACKAGE_VERSION  "1.0"

但是,在我们致电version()之前,我们进行相同的检查以查看是否有任何命令行参数:

    if ( *pargc > 0 )
      usage( EX_USAGE );
    version();

如果是这样,则是使用错误。

无效的选项

对于无效的选项:

  // ...
invalid_opt:
  (void)0;  // needed before C23
  char const *invalid_opt = (*pargv)[ optind - 1 ];
  if ( invalid_opt != NULL && strncmp( invalid_opt, "--", 2 ) == 0 )
    fprintf( stderr, "\"%s\": invalid option", invalid_opt + 2 );
  else
    fprintf( stderr, "'%c' invalid option", (char)optopt );
  fputs( "; use --help or -h for help\n", stderr );

不幸的是,getopt_long()的错误处理很差。当getopt_long()返回?表示一个无效的选项时,我们必须确定它是无效的短还是长选项:

  • 如果它是无效的简短选项,getopt_long()将将全局变量optopt设置为
  • 但是,如果这是一个无效的长期选择,那么getopt_long()不是直接告诉您那个长期的选择。

我们必须检查当时正在处理的命令行参数(*pargv)[optind-1]:如果它以--开头,那是无效的长期选项;否则optopt是无效的简短选项。

缺少所需的参数

对于所需但缺少参数的选项,我们打印了一个错误消息:

missing_arg:
  fatal_error( EX_USAGE,
    "\"%s\" requires an argument\n",
    opt_format( (char)(opt == ':' ? optopt : opt) )
  );
} // end of parse_options()

但是,此代码在两种情况下执行:

  1. getopt_long()返回:,以表示缺少必需的论点。在这种情况下,optopt包含缺少其参数的选项。
  2. getopt_long()返回了该选项及其参数,但是,在进一步检查后,我们发现该论点是空字符串或所有空格。在这种情况下,opt包含以上说明的选项。

函数fatal_error()是一个便利函数,打印和错误消息(在程序名称之前),并带有给定状态代码:

_Noreturn void fatal_error( int status, char const *format, ... ) {
  fprintf( stderr, "%s: ", prog_name );
  va_list args;
  va_start( args, format );
  vfprintf( stderr, format, args );
  va_end( args );
  exit( status );
}

函数opt_format()格式化了其长度(如果存在)和简短形式的选项,例如--help/-h,用于错误消息:

#define OPT_BUF_SIZE  32  /* enough for longest long option */

char const* opt_format( char short_opt ) {
  static char bufs[ 2 ][ OPT_BUF_SIZE ];
  static unsigned buf_index;
  char *const buf = bufs[ buf_index++ % 2 ];

  char const *const long_opt = opt_get_long( short_opt );
  snprintf(
    buf, OPT_BUF_SIZE, "%s%s%s-%c",
    long_opt[0] != '\0' ? "--" : "", long_opt,
    long_opt[0] != '\0' ? "/"  : "", short_opt
  );
  return buf;
}

该函数使用两个内部缓冲区,以便在同一printf()中可以两次调用opt_format()。 (以后会变得方便。)

函数opt_get_long()给定一个简短的选项,可获得相应的长选项,如果有的话:

static char const* opt_get_long( char short_opt ) {
  for ( struct option const *opt = OPTIONS_LONG; opt->name != NULL; ++opt ) {
    if ( opt->val == short_opt )
      return opt->name;
  } // for
  return "";
}

致电parse_options()

最后,这就是parse_options()的方式:

char const *prog_name;

int main( int argc, char const *argv[] ) {
  prog_name = argv[0];
  parse_options( &argc, &argv );
  // ...
}

parse_options()返回后,argc将包含剩余的非选项参数的数量,如果有的话,argv[0]将是第一个这样的选择。 (请注意,这与最初是可执行路径的argv[0]的规范值不同。)

选项排他性

到目前为止提出的代码无法处理某些选项只能自己给出某些选项的情况(例如,您不应该给--help--version提供任何其他选项)。可以通过添加全局数组来跟踪提供哪些选项来实现:

static _Bool opts_given[128];  // options that were given

getopt_long()返回的每个选项设置它:

      // ...
      case '?':
        goto invalid_opt;
    } // switch
    opts_given[ opt ] = true;  // <-- new line
  } // for

编写一个函数以检查排他性:

static void opt_check_exclusive( char opt ) {
  if ( !opts_given[ (unsigned)opt ] )
    return;
  for ( size_t i = '0'; i < ARRAY_SIZE( opts_given ); ++i ) {
    char const curr_opt = (char)i;
    if ( curr_opt == opt )
      continue;
    if ( opts_given[ (unsigned)curr_opt ] ) {
      fatal_error( EX_USAGE,
        "%s can be given only by itself\n",
        opt_format( opt )
      );
    }
  } // for
}

处理所有选项后调用功能:

  // ...
  *pargc -= optind;
  *pargv += optind;

  opt_check_exclusive( 'h' );
  opt_check_exclusive( 'v' );
  // ...

选项相互排他性

在许多程序中,其他选项可能不会给出一些选项。例如,如果程序具有Options --json/-j--xml/-x来指定输出格式,则可以同时给出这些选项。最好检查此类情况,而不是让最后的选项指定为获胜。

static void opt_check_mutually_exclusive( char const *opts1,
                                          char const *opts2 ) {
  unsigned gave_count = 0;
  char const *opt = opts1;
  char gave_opt1 = '\0';

  for ( unsigned i = 0; i < 2; ++i ) {
    for ( ; *opt != '\0'; ++opt ) {
      if ( opts_given[ (unsigned)*opt ] ) {
        if ( ++gave_count > 1 ) {
          char const gave_opt2 = *opt;
          fatal_error( EX_USAGE,
            "%s and %s are mutually exclusive\n",
            opt_format( gave_opt1 ),
            opt_format( gave_opt2 )
          );
        }
        gave_opt1 = *opt;
        break;
      }
    } // for
    if ( gave_count == 0 )
      break;
    opt = opts2;
  } // for
}

其中opts1是一组简短的选项,可以彼此给出,但在opts2集中没有任何一个。 (这是上述情况时,当opt_format()使用两个内部缓冲区变得方便时,它可以在与此处的同一语句中两次称为。)

调用该函数就像:

  opt_check_mutually_exclusive( "j", "x" );

其他选项检查

当然,某些程序可能具有更复杂的选项关系,例如,如果给出了-x,那么-y也必须是。如果您的程序有这样的关系,则应检查它们。使用opts_given编写这样的功能非常简单,但作为读者的练习。

消除短期选择冗余

必须四次指定每个简短选项:

  1. struct option阵列的val字段中。
  2. case中。
  3. 在致电opt_check_exclusive()opt_check_mutually_exclusive()的电话中。
  4. 在用法消息中。

如果您决定更改一个简短的选项,则必须在四个地方进行更新。最好一次定义每个简短选项,然后在任何地方使用该定义。对于此示例程序,我们可以做:

#define OPT_HELP     h
#define OPT_JSON     j
#define OPT_OUTPUT   o
#define OPT_VERSION  v
#define OPT_XML      x

但是,为了使用这些定义,必须根据用途进行串行或伪造。弦乐化更容易,因为C预处理器直接支持它:

#define STRINGIFY_HELPER(X)  #X
#define STRINGIFY(X)         STRINGIFY_HELPER(X)

#define SOPT(X)              STRINGIFY(OPT_##X)

SOPT(FOO)的意思是``字符串finde the FOO选项。例如,SOPT(HELP)将扩展到"h"。这可以在呼叫opt_check_mutually_exclusive()中使用:

  opt_check_mutually_exclusive( SOPT(JSON), SOPT(XML) );

我们可以定义一个用于用法消息中的宏:

#define UOPT(X)              " (-" SOPT(X) ") "

,然后在用法消息本身中:

    // ...
    "  --help        " UOPT(HELP)    "Print this help and exit.\n"
    "  --output=FILE " UOPT(OUTPUT)  "Write to FILE [default: stdout].\n"
    "  --version     " UOPT(VERSION) "Print version and exit.\n",
    // ...

但是,对于val字段和case,我们都需要简短的选项作为字符。不幸的是,C预处理人并不支持任何直接的伪装。只有在标识符中只有有效的字符才能构造出来的警告,即[A-Za-z_0-9]。首先,定义每个标识符字符的宏:

#define CHARIFY_0 '0'
#define CHARIFY_1 '1'
#define CHARIFY_2 '2'
// ...
#define CHARIFY_A 'A'
#define CHARIFY_B 'B'
#define CHARIFY_C 'C'
// ...
#define CHARIFY__ '_'
#define CHARIFY_a 'a'
#define CHARIFY_b 'b'
#define CHARIFY_c 'c'
// ...
#define CHARIFY_z 'z'

然后:

#define NAME2_HELPER(A,B)    A##B
#define NAME2(A,B)           NAME2_HELPER(A,B)

#define CHARIFY(X)           NAME2(CHARIFY_,X)

#define COPT(X)              CHARIFY(OPT_##X)

COPT(FOO)的意思是charify the FOO选项。例如,COPT(HELP)将扩展到'h'。然后可以在选项阵列中使用:

  // ...
  { "help",    no_argument,       NULL, COPT(HELP)    },
  { "output",  required_argument, NULL, COPT(OUTPUT)  },
  { "version", no_argument,       NULL, COPT(VERSION) },
  // ...

cases:

      // ...
      case COPT(HELP):
        opt_help = true;
        break;
      // ...

并在致电opt_check_exclusive()中:

  opt_check_exclusive( COPT(HELP) );
  opt_check_exclusive( COPT(VERSION) );

作为奖励,它使代码更具可读性。

在这一点上,您可能会问,长期选择的冗余呢?只有两次(在struct option数组的name字段中和用法消息中),而不是四次,并且在可执行代码中使用的Arent,因此错误看起来可能错误,但不会影响逻辑。因此,我不值得消除长期选择的冗余。

结论

CLIS应该像REST API一样强大。最终,良好的CLI可以提供更好的用户体验,并可以防止可能导致错误的意外选项组合。