背景

大多数软件开发者都是学过很多编程思想和知识的人,绝对不是不知道什么代码是好的,什么样的代码是糟糕的.

然而我们在工作的过程中,为了满足和解决各种现实问题,会在代码里产生引入各种妥协和交换.最后有一天,我们发现,bug产生于一大段肮脏的业务代码,就像这样:

 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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

public class Test {
    //输出业务计算的结果
    public static void main(String[] args) {
        final Biz.Argument argument = Biz.Argument.builder()
                .a("content a")
                .b(9)
                .c(false)
                .build();

        new Biz().getBizResult(
                argument
        );
    }

    //业务的实现类
    public static class Biz {

        //通常是注入的外部服务
        private volatile IntegerCalculator integerCalculator;

        {
            integerCalculator = new IntegerCalculator();
        }

        @AllArgsConstructor
        @Builder
        @NoArgsConstructor
        public static class Argument {
            String a;
            int b;
            boolean c;
        }

        //计算业务结果的过程
        public String getBizResult(Argument argument) {
            //一段校验逻辑
            if (argument.a == null || argument.b > 10 || argument.b < 0) {
                throw new RuntimeException("argument is not valid.");
            }

            String result = "not inited.";
            Throwable throwable = null;
            try {
                if (argument.c) {
                    result = argument.a;
                    return result;
                } else {
                    result = String.valueOf(integerCalculator.calculate(argument.b));
                    return result;
                }
            } catch (Throwable t) {
                throwable = t;
                throw t;
            } finally {
                // 通常是统计和上报
                if (throwable == null) {
                    System.out.printf("result is %s", result);
                } else {
                    System.out.printf("result is %s, throwable message is %s,", result, throwable.getMessage());
                }
            }
        }
    }

    //外部服务的实现,现实生活中实现通常是远端服务,这里写出来实现只是为了跑通测试
    //我们开发者只基于接口约束开发,对于其实现有没有bug不负责
    public static class IntegerCalculator {
        @NotNull
        public Integer calculate(@Nullable Integer integer) {
            if (integer == null) {
                return 0;
            }
            return integer * integer;
        }
    }
}

getBizResult 这一段代码里混合了三种逻辑,包括了校验,业务逻辑和统计上报. 代码初期可能并不复杂,但是后续类似的逻辑越加越多,维护成本会很快升高.我称之为流水账代码.

改造方案

怎么样才能更可靠的拆解上述函数,使之更容易维护呢?我总结了一套重构方法,能够安全又快速的达到上述目标。此方法依赖于Java8 引入的一个能力: 在接口上提供default函数,也就是说,低于Java8,你就无法使用这种方法了.它一共有五步:

  1. 将流水账函数的参数访问,变为函数内的局部变量
  2. 将函数内的局部变量转为context对象的public属性
  3. 将context的public转为private
  4. 将流水账内的每一个功能转为一个private函数
  5. 将上述private函数抽象到接口的default实现里

具体操作

第一步 将流水账函数的参数访问,变为函数内的局部变量

改造的第一步是先将要治理的函数内对函数参数的访问,抽取为函数的局部变量.这一步并不是最终目标,它是为了将对函数入参的直接访问,改为对中间变量的间接访问,并且每一个参数都得是可以最大复用的对象.比如说你项目都是基于公司的同一套中间件,那么抽取后的函数变量可以是中间件提供的对象;如果不是,那最好参数是都是Java自带的类,改造后提供的函数才能最大的复用.改造后的getBizResult如下:

 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
//计算业务结果的过程
        public String getBizResult(Argument argument) {
            //一段校验逻辑
            final String a = argument.a;
            final int b = argument.b;
            if (a == null || b > 10 || b < 0) {
                throw new RuntimeException("argument is not valid.");
            }

            String result = "not inited.";
            Throwable throwable = null;
            try {
                final boolean c = argument.c;
                if (c) {
                    result = a;
                    return result;
                } else {
                    result = String.valueOf(integerCalculator.calculate(b));
                    return result;
                }
            } catch (Throwable t) {
                throwable = t;
                throw t;
            } finally {
                // 通常是统计和上报
                if (throwable == null) {
                    System.out.printf("result is %s", result);
                } else {
                    System.out.printf("result is %s, throwable message is %s,", result, throwable.getMessage());
                }
            }
        }

很明确的,抽取了a,b,c三个变量.通过idea,不论是观察高亮,还是查找argument变量的引用,都能很快确定除了初始化局部变量,已经没有对其的引用了.

第二步 将函数内的局部变量转为context对象的public属性

第二步就是将上述三个函数内的变量放到一个对象的属性里,这个对象我通常起名为xxContext,这个对象并不是为了在不同的位置复用的,它只供这个地方使用,所以名字可以跟业务非常耦合,比如这里叫做GetBizResultContext.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
        public static class GetBizResultContext{
            public final String a;
            public final int b;
            public boolean c;

            public GetBizResultContext(Argument argument) {
                a = argument.a;
                b = argument.b;
            }

            public void setC(final boolean c) {
                this.c = c;
            }
        }

读者可能很奇怪,这几个变量不是跟Argument长得一模一样吗?

没错,在这个例子里,它确实长得是一模一样,因为这个实例代码的入参恰好只有argument一个.有时候我们的参数其实是多个,如getBizResult(Argument argument,Object... otherArgs),那么,这个Context的构造函数就会是xxxContext(Argument argument,Object... otherArgs),也就是理论上跟函数的入参一致.而且它的所有参数,都是public的,这是因为改造过程中idea暂时没有抽取context的能力,为了改造getBizResult的时候更稳妥(在业务函数中访问变量的前面只要加上getBizResult.),特意如此安排的.此时getBizResult函数里是这样的:

 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
public String getBizResult(Argument argument) {
            final GetBizResultContext getBizResultContext = new GetBizResultContext(argument);
            //一段校验逻辑
            if (getBizResultContext.a == null || getBizResultContext.b > 10 || getBizResultContext.b < 0) {
                throw new RuntimeException("argument is not valid.");
            }

            String result = "not inited.";
            Throwable throwable = null;
            try {
                getBizResultContext.c = argument.c;
                if (getBizResultContext.c) {
                    result = getBizResultContext.a;
                    return result;
                } else {
                    result = String.valueOf(integerCalculator.calculate(getBizResultContext.b));
                    return result;
                }
            } catch (Throwable t) {
                throwable = t;
                throw t;
            } finally {
                // 通常是统计和上报
                if (throwable == null) {
                    System.out.printf("result is %s", result);
                } else {
                    System.out.printf("result is %s, throwable message is %s,", result, throwable.getMessage());
                }
            }
        }
    }

可能刚刚有眼尖的朋友注意到,a和b是final的,c不是.这是为啥?因为c的赋值在函数中间有一次赋值,最快速稳妥的办法,是将这里改为对context的c的赋值,而不是在构造函数里初始化.如果想将这段逻辑移入context的构造函数,必须满足以下两个条件:

  1. 在第二步抽取函数的局部变量的时候,这里是final的.也就是它后续没有编辑;
  2. c的初始化没有使用context有关的任意变量.如果它用到了,那么你必须继续保证这几个变量在此赋值前没有发生变化的可能.

第一个条件很容易满足,因为在idea重构的时候,如果c后续没有再次赋值的话,会自动在函数局部变量c的声明处添加final修饰符.但是第二个条件要想满足,需要非常小心赋值的语句里有没有其它跟argument有关的变量,以及这些变量是否有变化.假如有改造前有如下代码:

1
2
3
4
5
6
7
8
public String getBizResult(Argument argument) {
//参数规整
...
    argument.b = argument.b > 10? -10: argument.b;
...
    argument.c = argument.b > 0;
...
}

那么改造后的c是绝对不能放到context的构造函数里的,因为在这个阶段,b还没有规整过,c的处理可能是错的.因此最简单的办法,就是让c在原有抽取变量所在的位置set到context中,不用严格的验证c的初始化是否跟其它参数是正交(也就是跟其它参数没有任何关系)的了.

第三步 将context的public转为private

改造的第三步非常简单:通过idea,将context里的public属性改为private.

读者会对此很惊讶:刚刚还强调要public,现在怎么要改为private了呢?

正如我刚才括号里写的,第二步里使用public,是因为在没有idea的重构功能加持下,为了保证每一个地方都改对,特意使用public.比如原来是result = a;的地方,现在是result = getBizResultContext.a;,编辑的时候并不容易出错.而现在改成private,是为了访问的方式改为函数,当需要复用的时候,可以轻易抽取接口,正如我所说,context不是为了复用而抽取的对象.为什么context不是复用的对象呢?因为它耦合了参数校验和规整,业务逻辑和上报三段逻辑所需要的字段,并不容易复用.

修改修饰符idea会帮我们很快完成,此时业务逻辑变成了这样:

 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
        public String getBizResult(Argument argument) {
            final GetBizResultContext getBizResultContext = new GetBizResultContext(argument);
            //一段校验逻辑
            if (getBizResultContext.getA() == null || getBizResultContext.getB() > 10 || getBizResultContext.getB() < 0) {
                throw new RuntimeException("argument is not valid.");
            }

            String result = "not inited.";
            Throwable throwable = null;
            try {
                getBizResultContext.setC(argument.c);
                if (getBizResultContext.isC()) {
                    result = getBizResultContext.getA();
                    return result;
                } else {
                    result = String.valueOf(integerCalculator.calculate(getBizResultContext.getB()));
                    return result;
                }
            } catch (Throwable t) {
                throwable = t;
                throw t;
            } finally {
                // 通常是统计和上报
                if (throwable == null) {
                    System.out.printf("result is %s", result);
                } else {
                    System.out.printf("result is %s, throwable message is %s,", result, throwable.getMessage());
                }
            }
        }

第四步 将流水账内的每一个功能转为一个private函数

这步之后就非常简单了,第四步将三段不同的逻辑分别用idea的抽取函数功能,变成三段函数:

 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
//计算业务结果的过程
        public String getBizResult(Argument argument) {
            final GetBizResultContext getBizResultContext = new GetBizResultContext(argument);
            //一段校验逻辑
            doValidation(getBizResultContext);

            String result = "not inited.";
            Throwable throwable = null;
            try {
                getBizResultContext.setC(argument.c);
                result = getResult(getBizResultContext);
                return result;
            } catch (Throwable t) {
                throwable = t;
                throw t;
            } finally {
                // 通常是统计和上报
                afterBiz(result, throwable);
            }
        }

        private void afterBiz(final String result, final Throwable throwable) {
            if (throwable == null) {
                System.out.printf("result is %s", result);
            } else {
                System.out.printf("result is %s, throwable message is %s,", result, throwable.getMessage());
            }
        } 

        @org.jetbrains.annotations.NotNull
        private String getResult(final GetBizResultContext getBizResultContext) {
            if (getBizResultContext.isC()) {
                return getBizResultContext.getA();
            } else {
                return String.valueOf(integerCalculator.calculate(getBizResultContext.getB()));
            }
        }

        private void doValidation(final GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getA() == null || getBizResultContext.getB() > 10 || getBizResultContext.getB() < 0) {
                throw new RuntimeException("argument is not valid.");
            }
        }

哎,这一看,有个函数的参数还不止context,最好将其改造为只有context的方式:

 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
        //计算业务结果的过程
        public String getBizResult(Argument argument) {
            final GetBizResultContext getBizResultContext = new GetBizResultContext(argument);
            //一段校验逻辑
            doValidation(getBizResultContext);

            getBizResultContext.setResult("not inited.");
            try {
                getBizResultContext.setC(argument.c);
                getResult(getBizResultContext);
                return getBizResultContext.getResult();
            } catch (Throwable t) {
                getBizResultContext.setThrowable(t);
                throw t;
            } finally {
                // 通常是统计和上报
                afterBiz(getBizResultContext);
            }
        }

        private void afterBiz(GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getThrowable() == null) {
                System.out.printf("result is %s", getBizResultContext.getResult());
            } else {
                System.out.printf("result is %s, throwable message is %s,", getBizResultContext.getResult(), getBizResultContext.getThrowable().getMessage());
            }
        }

        private void getResult(final GetBizResultContext getBizResultContext) {
            if (getBizResultContext.isC()) {
                getBizResultContext.setResult(getBizResultContext.getA());
            } else {
                getBizResultContext.setResult(String.valueOf(integerCalculator.calculate(getBizResultContext.getB())));
            }
        }

        private void doValidation(final GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getA() == null || getBizResultContext.getB() > 10 || getBizResultContext.getB() < 0) {
                throw new RuntimeException("argument is not valid.");
            }
        }

第五步 将上述private函数抽象到接口的default实现里

接下来,也就是第五步,可以将doValidation,getResult和afterBiz单独抽取到接口的default方法里.要注意的是getResult里有一个注入的函数integerCalculator,抽取的时候需要改为接口的函数.改造后是这样的:

 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
public class Test {
    //业务的实现类
    public static class Biz implements BizAfterwards,BizProvider,BizValidator{

...

    public interface BizAfterwards{
        default void afterBiz(Biz.GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getThrowable() == null) {
                System.out.printf("result is %s", getBizResultContext.getResult());
            } else {
                System.out.printf("result is %s, throwable message is %s,", getBizResultContext.getResult(), getBizResultContext.getThrowable().getMessage());
            }
        }
    }

    public interface BizValidator{
        default void doValidation(final Biz.GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getA() == null || getBizResultContext.getB() > 10 || getBizResultContext.getB() < 0) {
                throw new RuntimeException("argument is not valid.");
            }
        }
    }

    public interface BizProvider{
        default void getResult(final Biz.GetBizResultContext getBizResultContext) {
            if (getBizResultContext.isC()) {
                getBizResultContext.setResult(getBizResultContext.getA());
            } else {
                getBizResultContext.setResult(String.valueOf(getIntegerCalculator().calculate(getBizResultContext.getB())));
            }
        }
        IntegerCalculator getIntegerCalculator();
    }

单元测试

重构结束了,是不是各个业务逻辑已经单独抽取了?如果需要写单元测试,那就非常简单了,比如针对BizProvider.getBizResult做单元测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TestTest extends Specification {

    def "test testBizResult"() {
        given:
        def provider = new Test.BizProvider() {
            @Override
            Test.IntegerCalculator getIntegerCalculator() {
                return new Test.IntegerCalculator(){
                    @Override
                    Integer calculate(final Integer integer) {
                        return 10;
                    }
                }
            }
        }
        def context = new Test.Biz.GetBizResultContext(new Test.Biz.Argument.ArgumentBuilder().a("content a").b(9).c(false).build())
        when:
            provider.getResult(context);
        then:
        context.result == "10"
    }
}

单元测试里所有的外部依赖自然不需要真正的实现,只要返回你定义的某个结果就可以了.

能力复用

看到这里,其实大家现在做重构和单元测试的目的达到了,但是当我们需要复用的时候呢?

首先先将default函数里的context要换成interface,比如getBizResult改成某interface,然后将函数里的get函数都自动生成,再将原有的context改为实现该接口,即可将该能力提供在其它地方复用了.比如:

 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
 public static class GetBizResultContext implements BizArg{
 ...
 }
 
 public interface BizProvider{
        default void getResult(final BizArg getBizResultContext) {
            if (getBizResultContext.isC()) {
                getBizResultContext.setResult(getBizResultContext.getA());
            } else {
                getBizResultContext.setResult(String.valueOf(getIntegerCalculator().calculate(getBizResultContext.getB())));
            }
        }
        IntegerCalculator getIntegerCalculator();
    }

    public interface BizArg{

        boolean isC();

        String getA();

        int getB();

        void setResult(String a);
    }

这个过程只需要:

  1. 新增BizArg接口
  2. 将BizProvider的函数参数改为BizArg接口
  3. 将GetBizResultContext实现自BizArg接口
  4. 修复BizProvider对BizArg的几个接口的实现

这样BizProvider就做好了对外的复用准备了,是不是特别简单?

完整的示例

最后这是完整的类:

  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
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168

import jetbrick.util.annotation.NotNull;
import jetbrick.util.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;

public class Test {
    //输出业务计算的结果
    public static void main(String[] args) {
        final Biz.Argument argument = Biz.Argument.builder()
                .a("content a")
                .b(9)
                .c(false)
                .build();

        new Biz().getBizResult(
                argument
        );
    }

    //业务的实现类
    public static class Biz implements BizAfterwards,BizProvider,BizValidator{

        //通常是注入的外部服务
        private volatile IntegerCalculator integerCalculator;

        {
            integerCalculator = new IntegerCalculator();
        }

        @Override
        public IntegerCalculator getIntegerCalculator() {
            return integerCalculator;
        }

        @AllArgsConstructor
        @Builder
        @NoArgsConstructor
        public static class Argument {
            String a;
            int b;
            boolean c;
        }

        public static class GetBizResultContext implements BizArg{
            private final String a;
            private final int b;
            private boolean c;
            private String result;
            private Throwable throwable;

            public GetBizResultContext(Argument argument) {
                a = argument.a;
                b = argument.b;
            }

            @Override
            public String getA() {
                return a;
            }

            @Override
            public int getB() {
                return b;
            }

            @Override
            public boolean isC() {
                return c;
            }

            public void setC(final boolean c) {
                this.c = c;
            }

            @Override
            public void setResult(final String result) {
                this.result = result;
            }

            public String getResult() {
                return result;
            }

            public void setThrowable(final Throwable throwable) {
                this.throwable = throwable;
            }

            public Throwable getThrowable() {
                return throwable;
            }
        }

        //计算业务结果的过程
        public String getBizResult(Argument argument) {
            final GetBizResultContext getBizResultContext = new GetBizResultContext(argument);
            //一段校验逻辑
            doValidation(getBizResultContext);

            getBizResultContext.setResult("not inited.");
            try {
                getBizResultContext.setC(argument.c);
                getResult(getBizResultContext);
                return getBizResultContext.getResult();
            } catch (Throwable t) {
                getBizResultContext.setThrowable(t);
                throw t;
            } finally {
                // 通常是统计和上报
                afterBiz(getBizResultContext);
            }
        }
    }

    public interface BizAfterwards {
        default void afterBiz(Biz.GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getThrowable() == null) {
                System.out.printf("result is %s", getBizResultContext.getResult());
            } else {
                System.out.printf("result is %s, throwable message is %s,", getBizResultContext.getResult(), getBizResultContext.getThrowable().getMessage());
            }
        }
    }

    public interface BizValidator{
        default void doValidation(final Biz.GetBizResultContext getBizResultContext) {
            if (getBizResultContext.getA() == null || getBizResultContext.getB() > 10 || getBizResultContext.getB() < 0) {
                throw new RuntimeException("argument is not valid.");
            }
        }
    }

    public interface BizProvider{
        default void getResult(final BizArg getBizResultContext) {
            if (getBizResultContext.isC()) {
                getBizResultContext.setResult(getBizResultContext.getA());
            } else {
                getBizResultContext.setResult(String.valueOf(getIntegerCalculator().calculate(getBizResultContext.getB())));
            }
        }
        IntegerCalculator getIntegerCalculator();
    }

    public interface BizArg{

        boolean isC();

        String getA();

        int getB();

        void setResult(String a);
    }

    //外部服务的实现,现实生活中实现通常是远端服务,这里写出来实现只是为了跑通测试
    //我们开发者只基于接口约束开发,对于其实现有没有bug不负责
    public static class IntegerCalculator {
        @NotNull
        public Integer calculate(@Nullable Integer integer) {
            if (integer == null) {
                return 0;
            }
            return integer * integer;
        }
    }
}

待续

没错,到这里,其实改造还没彻底结束.提供了能力的接口要怎么管理呢?这个得结合你项目的设计,考虑要不要把一些目的一致的能力做成命令模式,把一些几乎一致的context变成状态机.这个改造本身只是减少了耦合,提高了复用,但是怎么能让后续维护的同事易维护,还是需要花费不少心思的.

这就是我近期总结的如何对流水账代码安全地做重构和单元测试.如果大家有什么好的方法,不论是代码重构还是单元测试,都欢迎在评论区里留言.