你好,我是徐昊。今天我们来继续进行命令行参数解析的TDD演示。

首先让我们回顾一下题目与需求与代码进度。如前所述,题目源自Bob大叔的 Clean Code 第十四章:

我们中的大多数人都不得不时不时地解析一下命令行参数。如果我们没有一个方便的工具,那么我们就简单地处理一下传入main函数的字符串数组。有很多开源工具可以完成这个任务,但它们可能并不能完全满足我们的要求。所以我们再写一个吧。
 
传递给程序的参数由标志和值组成。标志应该是一个字符,前面有一个减号。每个标志都应该有零个或多个与之相关的值。例如:
 
-l -p 8080 -d /usr/logs
 
“l”(日志)没有相关的值,它是一个布尔标志,如果存在则为true,不存在则为false。“p”(端口)有一个整数值,“d”(目录)有一个字符串值。标志后面如果存在多个值,则该标志表示一个列表:
 
-g this is a list -d 1 2 -3 5
 
"g"表示一个字符串列表[“this”, “is”, “a”, “list”],“d"标志表示一个整数列表[1, 2, -3, 5]。
 
如果参数中没有指定某个标志,那么解析器应该指定一个默认值。例如,false代表布尔值,0代表数字,”"代表字符串,[]代表列表。如果给出的参数与模式不匹配,重要的是给出一个好的错误信息,准确地解释什么是错误的。
 
确保你的代码是可扩展的,即如何增加新的数值类型是直接和明显的。

截至目前,我们的代码支持三种类型的参数解析,分别是布尔型、整型和字符串类型。接下来,我们来实现对于列表参数的支持。在开始之前,我们首先要看一看是否存在坏味道,是否需要重构。

不易察觉的坏味道

对我来说呢,在当前的代码中存在一个不易察觉的坏味道,意图也不直观,主要存在于SingleValuedOptionParser类的parse方法中:

if (index + 1 == arguments.size() ||
    arguments.get(index + 1).startsWith("-")) throw new InsufficientArgumentException(option.value());
if (index + 2 < arguments.size() && 
    !arguments.get(index + 2).startsWith("-")) throw new TooManyArgumentsException(option.value());

如果我们静下心来仔细推敲,不难发现,第一个if语句表示的是参数不足的情况,分别为:当前参数到达列表末尾(-p的情况);紧紧跟随另一个参数(-p -l的情况)。

第二个if语句则表示,当前参数后至少还存在两个数值,且第二个不是另一个参数(-p 8080 8081,而不是-p 8080 -l的情况),那么参数给多了。

一般这种情况下,我们可能会选择添加代码注释的方式。不过更推荐的方式是,通过抽取方法,让方法名成为注释。或者,换一种更容易理解的方法来实现同样的功能:

重构后的代码让我们的意图变得非常直观,获取当前参数的值,而且我们明确希望它的长度为1:

List<String> values = valuesFrom(arguments, index);
if (values.size() > 1) throw new InsufficientArgumentException(option.value());
if (values.size() < 1) throw new TooManyArgumentsException
(option.value());

完成重构之后,我们又会发现另一个坏味道,BooleanOptionParser和SingleValuedOptionParser之间存在隐含的重复的代码:

在消除了重复之后,让我们重新整理一下代码结构:

好,现在让我们正式进入列表参数解析的开发。

列表参数解析

现在我们对于如何实现参数解析已经有了非常清晰的认识。在目前的代码结构中,如果需要增加不同类型的单值型参数,那么我们只需要修改Args类中的类型注册表,提供默认值以及解析函数即可:

private static Map<Class<?>, OptionParser> PARSERS = Map.of(
        boolean.class, bool(),
        int.class, unary(0, Integer::parseInt),
        String.class, unary("", String::valueOf));

而如果需要支持除布尔或者单值型参数,则需要实现OptionParser接口:

interface OptionParser<T> {
  T parse(List<String> arguments, Option option);
}

更具体来说,在实现OptionParser接口时,可以利用OptionParsers类中提供的支撑方法(values、parseValue等)。最后,在OptionParsers上增加工厂方法。

在回顾了这些信息之后,我们可以对列表参数进行任务分解了。从题目要求的功能上看,我们需要实现:

ArgsTest:
//TODO: -g this is a list -d 1 2 -3 5

然后,我们需要将其分解成一组更小的任务:

//TODO: -g "this" "is" {"this", is"}
//TODO: default value []
//TODO: -d a throw exception

好,现在让我们进入红/绿/重构循环:

以及最后对于代码的清理与重构:

小结

至此,我们使用TDD的方法完成了参数解析的功能。我觉得你至少应该感受到了TDD这三个特点。

第一是,将要完成的功能分解成一系列任务,再将任务转化为测试,以测试体现研发进度,将整个开发过程变成有序的流程,以减少无效劳动。

第二是,在修改代码的时候,随时执行测试以验证功能,及时发现错误,降低发现、定位错误的成本,降低修改错误的难度。

第三是,时刻感受到认知的提升,增强自信降低恐惧。在针对列表参数使用任务分解法时,你明显可以感觉到,我们无论是对需求的把握性,还是对最终实现的可预见性,都有了大幅度的提升。甚至,如果更进一步要求,我们可以较有把握地评估(误差在15%以内)实现列表参数解析需要多长时间。这就是我们认知提升的具体体现。

我将这样的工作状态称为“职业程序工作状态”:有序、可控、自信。

很多同学可能是第一次目睹TDD在实战中是如何工作的,心中肯定充满了疑问。而另一些有过TDD实践的同学,也可能会发现我所采用的方法和步骤与你的方式有很大不同,这也是很正常的。在接下来的六节课中,我将带你复盘整个流程,并对其中涉及到的技巧和方法进行深入讨论。下节课,让我们正式开始学习TDD吧!

思考题

请自己尝试使用TDD从头实现命令行参数解析的功能。

如果你在学习过程中还有什么问题或想法,欢迎加入读者交流群。最后,也欢迎把你学习这节课的代码与体会分享在留言区,我们下节课再见!