01|TDD演示(1):任务分解法与整体工作流程

TDD 学习笔记 | 极客时间 | 徐昊·TDD 项目实战 70 讲

🏆 课程原文链接 🍿 源码地址

TDD 模板

1. API 构思与组件划分

  • 首先我们需要考虑,别人将以何种方式使用这段代码,也就是这段代码的整体对外接口部分。
  • 我们可以通过写测试的方式,来感受 API 的友好程度

2. 在确定了 API 的形式之后,我们需要大致构思如何实现这个功能

3. 功能分解与任务列表 ⭐️⭐️⭐️⭐️⭐️(TDD 核心之一)

  • 在 API 与实现方式有了方向之后,我们就可以根据需求的描述对功能进行分解了。
  • 这里可以先不求全面,有个大致的范围即可
  • 三个路径
    • happy path:正常功能
    • sad path:异常判断
    • default path:默认值

4. 红 / 绿循环

  • 那么先让我们选择最简单的任务,并通过红绿循环实现它

命令行参数解析

习题源自 Robert C. Martin 的 《代码整洁之道》 第十四章的一个例子。需求描述如下:

我们中的大多数人都不得不时不时地解析一下命令行参数。如果我们没有一个方便的工具,那么我们就简单地处理一下传入 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 代表数字,”“代表字符串,[]代表列表。如果给出的参数与模式不匹配,重要的是给出一个好的错误信息,准确地解释什么是错误的。

确保你的代码是可扩展的,即如何增加新的数值类型是直接和明显的。

功能分析

简介

解析空格分割的字符串

示例

1
-l -p 8080 -d /usr/logs -g this is a list -d 1 2 -3 5

功能

  • 减号后的字符代表一个功能符号
  • l
    • 功能:日志,没有相关的值
    • 类型:布尔
    • 描述:存在=true;不存在=false
  • p
    • 功能:端口
    • 类型:整数
    • 描述:有一个整数值
  • d
    • 功能:目录
    • 类型:字符串
    • 描述有一个字符串
  • g
    • 功能:一个字符串列表
    • 类型:字符串
  • d
    • 功能:表示一个整数列表
    • 类型:整数组成的字符串
  • 如果参数中没有指定某个标志,那么解析器应该指定一个默认值
    • 布尔:false
    • 整数:0
    • 列表:[]
  • 如果给出的参数与模式不匹配,给出友好提示
    • 简要错误信息
    • 告知错误原因

实现思路

三种实现方式

  1. -l -p 8080 -d /usr/logs 一次解析整个字符串解析(难度大)
  2. [-l], [-p, 8080], [-d, /usr/logs] 按功能划分,处理特定数组(简单)💡 课程中选取了最简单的方案实现
  3. {-l:[], -p:[8080], -d:[/usr/logs]} 按功能划分,从Map中取出相应的值(比数组复杂)

方式 2 的实现

单个功能

  • 布尔类型:-l
  • 单整数类型:-p 8080
  • 单个连续字符串类型:-d /usr/logs

组合功能

-l -p 8080 -d /usr/logs

异常情况

  • 布尔类型:输入 -l a; -l 3
  • 单整数类型:输入 -p a; -p 3.14
  • 单个连续字符串类型:输入 -d a b c; -d /usr/logs /usr/logs/a.log

感悟

  • 站在使用者的角度看待自己的代码

Java 第一版

开发环境

  • JDK 17
  • Gradle 7.3.3

Option.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.wyyl1.geektimetdd.args;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Option {

    String value();
}

Args.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.wyyl1.geektimetdd.args;

import java.lang.reflect.Constructor;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.List;

public class Args {

    public static <T> T parse(Class<T> optionsClass, String... args) {
        Constructor<?> constructor = optionsClass.getDeclaredConstructors()[0];
        try {
            List<String> arguments = Arrays.stream(args).toList();
            Object[] values = Arrays.stream(constructor.getParameters())
                    .map(it -> parseOption(arguments, it))
                    .toArray();

            return (T) constructor.newInstance(values);
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }

    private static Object parseOption(List<String> arguments, Parameter parameter) {
        Option option = parameter.getAnnotation(Option.class);
        Object value = null;

        String flag = "-" + option.value();
        if (parameter.getType() == boolean.class) {
            value = arguments.contains(flag);
        }
        if (parameter.getType() == int.class) {
            int index = arguments.indexOf(flag);
            value = Integer.parseInt(arguments.get(index + 1));
        }
        if (parameter.getType() == String.class) {
            int index = arguments.indexOf(flag);
            value = arguments.get(index + 1);
        }
        return value;
    }
}

ArgsTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.wyyl1.geektimetdd.args;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ArgsTest {

    @Test
    void should_set_boolean_option_to_return_true_if_flag_present(){
        BooleanOption option = Args.parse(BooleanOption.class, "-l");

        assertTrue(option.logging);
    }

    @Test
    void should_set_boolean_option_to_return_false_if_flag_not_present(){
        BooleanOption option = Args.parse(BooleanOption.class);

        assertFalse(option.logging);
    }

    record BooleanOption(@Option("l") boolean logging) {
    }

    @Test
    void should_parse_int_as_option_value() {
        IntOption option = Args.parse(IntOption.class, "-p", "8080");
        assertEquals(8080, option.port);
    }

    record IntOption(@Option("p") int port) {
    }

    @Test
    void should_parse_string_as_option_value() {
        StringOption option = Args.parse(StringOption.class, "-d", "/usr/logs");
        assertEquals("/usr/logs", option.directory);
    }

    record StringOption(@Option("d") String directory) {

    }

    @Test
    void should_parse_multi_options(){
        MultiOptions options = Args.parse(MultiOptions.class, "-l", "-p", "8080", "-d", "/usr/logs");
        assertTrue(options.logging);
        assertEquals(8080, options.port);
        assertEquals("/usr/logs", options.directory);
    }

    record MultiOptions(@Option("l") boolean logging, @Option("p") int port, @Option("d") String directory) {
    }
}
comments powered by Disqus