最近在实际项目中遇到一个需求,背景是系统中有几张表使用了 longtext 类型的字段 extend_info 储存用户的一些比如电话号码、下单地址等敏感信息,出于对信息安全的考虑,该需求需要新增一个 extend_info_cipher 字段并进行加解密操作,不过由于涉及的数据量较大,目前大致可分为四个阶段进行增量覆盖,每个增量递进通过参数配置触发:

Snipaste_2020-06-13_05-54-24.png

可以发现经过从 Phase 0 到 Phase 3 这几个阶段的迭代后,原敏感数据明文列的操作逐步转移到密文列上去了。

看到这个设计图,第一时间在我脑海里闪过的是策略设计模式,之前我在中提到软件工程设计中的开闭思想,尽管只有上述这四种策略,貌似在 “开” 这一层用处不大,但是从 “闭” 原则上来看,如果能做好封装,那么可以避免在每个表涉及的 DAO 层上进行策略判断和选择,这样能避免写很多重复的代码。另一方面, 我觉得如果能做好接口抽象,尽管暂时貌似 “开” 得不多,但哪天需要扩展时,也能很舒服顺手地兼容。

直观上看来,对于上述需求,假设有 10 张表需要进行操作,那么从 Java EE 开发地角度来看,你需要在 DAO 的上一层中的每个读写表的操作类中都进行类似如下的策略的判断和选择,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public void someKindOfserviceMethod() {
    // 获取到参数配置
    ArgsConfig config = configService.getConfigCode(SECURITY_READ_WRITE_MODE);
    // 根据参数值获取到读写模式枚举
   	ReadWriteMode readWriteMode = ReadWriteMode.getReadWriteMode(config.getCode);
    // 根据读写模式枚举进行不同的操作
    switch (readWriteMode) {
        case DEFAULT:
            // ....bla bla 一连串操作
        case STAGE_ONE:
            // ....bla bla 一连串操作
        case STAGE_TWO:
            // ....bla bla 一连串操作
        case DEFAULTHREE:
            // ....bla bla 一连串操作
        default:
            // exception handding
    }
}

试想一下,这才是一张数据表的读或者写操作,如果是 10 张呢?如果是 100 张呢?显然这样搞能把人搞傻了,那么怎么办呢,有什么偷懒又优雅的解决方案吗?一开始我的答案是自定义 MyBatis 拦截器,拦截涉及的相关表的 query 和 update 方法,然后再进行进一步的密码学操作,但是与同事讨论后发现这种方案有一点不容忽视,就是性能,因为就我们所知而言,Mybatis 采用责任链设计模式实现插件式的可扩展性,而且这些插件可以出现在几个不同的特定位置,但是我们更多考虑到的是 Mybatis 对操作请求的拦截是盲目的,系统中存在几百上千张表的查询与更新请求,如果为了几张表的操作,而将所有的信号都拦截下来再进行过滤,显得有些得不偿失。

强大的 Mybatis 有没有这方面的考虑和解决方案呢?限于水平和眼界,暂时未知。但是为了不写重复代码,我决定尝试使用策略模式实现这个需求。下面通过一个简单的 Demo,记录一下自己的思考过程。

动手实践

不急,先来看看 wikipedia 中对 策略模式 的介绍:

In computer programming, the strategy pattern (also known as the policy pattern) is a behavioral software design pattern that enables selecting an algorithm at runtime. Instead of implementing a single algorithm directly, code receives run-time instructions as to which in a family of algorithms to use.

Strategy lets the algorithm vary independently from clients that use it. Strategy is one of the patterns included in the influential book Design Patterns by Gamma et al. that popularized the concept of using design patterns to describe how to design flexible and reusable object-oriented software. Deferring the decision about which algorithm to use until runtime allows the calling code to be more flexible and reusable.

大致说,策略模式是一个行为型的设计模式,通过它可以在运行时动态选定一种算法,而不是直接在代码中写死一种算法。将使用哪种算法的决定推迟到运行时,可以使调用代码更加灵活和可重用。

Snipaste_2020-08-28_00-05-51.png

在上面的 UML 类图中,Context 类并没有直接实现算法。而是调用了 Strategy 接口的 algorithm 方法,这样使 Context 类独立于算法的实现方式。Strategy1 和 Strategy2 类实现了 Strategy 接口,在里面提供具体的对于 algorithm 的实现。

在运行期,Context 对象将一个算法委托给不同的策略对象。首先,Context 对一个 Strategy1 对象调用 algorithm,该对象执行算法并将结果返回给 Context。然后,Context 更改其策略并对一个 Strategy2 对象调用 algorithm,该对象执行算法并将结果返回给 Context。

根据策略模式,类的行为不应该被继承。相反,它们应该使用接口进行封装。这符合开闭原则(open/closed) 原则(OCP),该原则建议类应该对扩展开放,但对修改关闭。

以 car 类为例。汽车的两个可能功能是刹车和加速。不同的车实现由于加速和刹车行为因车模型不同而存在差异,一种常见的方法是在子类中实现这些行为。这种方法有明显的缺点:加速和刹车行为必须在每个新车型中声明。随着模型数量的增加,管理这些行为的工作将大大增加,并且需要在模型之间复制代码。此外,如果不研究每个模型中的代码,就不容易确定每个模型的最自然而不同于其他模型的行为。

策略模式使用组合而不是继承。在策略模式中,行为被定义为单独的接口和实现这些接口的特定类。这样可以更好地解耦行为和使用行为的类。可以在不破坏使用它的类的情况下改变行为,并且类可以通过改变使用的特定实现在不需要任何显著的代码更改的情况下在行为之间切换。行为还可以在运行时和设计时更改。

好了,话不多说,我们来看看怎么使用策略模式来实现需求功能。

整体上我设计了如下图这样一个类结构,可以看到 CipherStrategy 是一个接口,定义了基础的方法,然后让子类去实现这些方法,这就是开闭原则的体现。

Snipaste_2020-08-28_00-05-51.png

对于策略接口,由于第一二阶段都是双写,所以可以使用 Java 8 的可在接口写 deault 方法的特性,提供默认的双写实现,

 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 interface CipherStrategy {
    /** 明文字段 **/
    String PLAINTEXT_FIELD = "extendInfo";
    /** 密文字段 **/
    String CIPHER_FIELD = "extendInfoCipher";

    default <D> Field getField(D data, String fieldName) {
        Field field = null;
        try {
            field = data.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
        return field;
    }

    default <D> Integer insert(Function<D, Integer> function, D data) {
        try {
            Field plaintextField = getField(data, PLAINTEXT_FIELD);
            String plaintext = (String)plaintextField.get(data);

            Field cipherField = getField(data, CIPHER_FIELD);
            cipherField.set(data, CipherUtil.encrypt(plaintext));
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return function.apply(data);
    }

    <P, G> List<G> select(Function<P, List<G>> function, P param);
}

对于阶段零采用默认的策略实现,不做额外的处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class DefaultStrategy implements CipherStrategy {
    @Override
    public <D> Integer insert(Function<D, Integer> function, D data) {
        return function.apply(data);
    }

    @Override
    public <P, G> List<G> select(Function<P, List<G>> function, P param) {
        return function.apply(param);
    }
}

对于阶段一,写数据使用接口提供的双写逻辑,读数据逻辑不做额外处理,即读明文列。

1
2
3
4
5
6
public class StageOneStrategy implements CipherStrategy {
    @Override
    public <P, G> List<G> select(Function<P, List<G>> function, P param){
        return function.apply(param);
    }
}

对于阶段二,写数据使用接口提供的双写逻辑,读数据需要读密文列进行解密设置到内存中的明文列再返回给上层调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class StageTwoStrategy implements CipherStrategy {
    @Override
    public <P, G> List<G> select(Function<P, List<G>> function, P param) {
        List<G> records = function.apply(param);
        if (records != null && records.size() > 0) {
            records.forEach(record -> {
                try {
                    Field cipherField = getField(record, CIPHER_FIELD);
                    String cipher = (String)cipherField.get(record);
                    String plaintext = CipherUtil.decrypt(cipher);
                    Field plaintextField = getField(record, PLAINTEXT_FIELD);
                    plaintextField.set(record, plaintext);
                    System.out.println("获取到密文: " + cipher + " 解密得到: " + plaintext);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            });
        }
        return function.apply(param);
    }
}

对于阶段三,写数据需要先将明文列加密结果写到密文列,再将明文列所在行置空,读数据需要读密文列进行解密设置到内存中的明文列再返回给上层调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class StageThreeStrategy implements CipherStrategy {
    @Override
    public <D> Integer insert(Function<D, Integer> function, D data) {
        try {
            Field plaintextField = getField(data, PLAINTEXT_FIELD);
            String plaintext = (String)plaintextField.get(data);
            String cipher = CipherUtil.encrypt(plaintext);

            Field cipherField = getField(data, CIPHER_FIELD);
            cipherField.set(data, cipher);
            plaintextField.set(data, "");
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public <P, G> List<G> select(Function<P, List<G>> function, P param) {
        //... 同 StageTwoStrategy
    }
}

再看 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
public class Context {
    // 如果使用 Spring,可以一次性全注入进来
    private DefaultStrategy defaultStrategy = new DefaultStrategy();
    private StageOneStrategy stageOneStrategy = new StageOneStrategy();
    private StageTwoStrategy stageTwoStrategy = new StageTwoStrategy();
    private StageThreeStrategy stageThreeStrategy = new StageThreeStrategy();

    public <P, G> List<G> doSelect(Function<P, List<G>> function, P param) {
        CipherStrategy cipherStrategy = getCipherStrategy(getConfigCode());
        return Objects.nonNull(cipherStrategy) ? cipherStrategy.select(function, param) : null;
    }

    public <D> Integer doInsert(Function<D, Integer> function, D data) {
        CipherStrategy cipherStrategy = getCipherStrategy(getConfigCode());
        return Objects.nonNull(cipherStrategy) ? cipherStrategy.insert(function, data) : 0;
    }

    private CipherStrategy getCipherStrategy(Byte code) {
        if (Objects.nonNull(code)) {
            switch (code) {
               case 0:
                   return defaultStrategy;
               case 1:
                   return stageOneStrategy;
               case 2:
                   return stageTwoStrategy;
               case 3:
                   return stageThreeStrategy;
               default:
                   throw new IllegalArgumentException("no such mode ==> code: " + code);
           }
        }
        return null;
    }
}

至于如何使用呢?如果你使用 Spring,可以将各个策略注册成 Service,然后再将 Context 组件化,然后在现需要用到的地方注入 Context 就可以了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class StrategyPatternClient {
    public static void main(String[] args) {
        Context context = new Context();
        OrderMapper orderMapper = new OrderMapperImpl();
        System.out.println(context.doSelect(orderMapper::selectById, 1));
        GoodsMapper goodsMapper = new GoodsMapperImpl();
        System.out.println(context.doSelect(goodsMapper::selectById, 2));
        System.out.println(context.doInsert(orderMapper::insert, new Order(10, "order10")));
        System.out.println(context.doSelect(orderMapper::selectById, 10));
        context.checkoutMode();
        System.out.println(context.doSelect(orderMapper::selectById, 10));
    }
}

由于这个 Demo 重点用于展示整个思路,所以代码方面没有很严谨,看一眼运行结果便是了,整个实例的完整代码可以去 Github 下载。

Snipaste_2020-08-28_00-42-11.png

总结

通过使用策略模式,我将各个阶段的不同操作与实际调用处进行解耦分离,得益于泛型和 Java 8 的函数式编程,可以实现类型泛化以及将不同的调用通过变量的形式进行传递,这大大增强了接口设计的灵活性和可扩展性。

老实说,写完这个有点开心,因为好久没有像这种思路顺畅地写一些好玩的代码了,或许是习惯寻常搬砖的代码了吧,也不知道这样下去我还能不能葆有对优雅代码的热情与追求,于我而言,现阶段的工作真的只是为了赚钱,有时候我会想,这世上有没一份我真的喜欢到爆,使我愿意为它熬夜,愿意为它主动迎接困难,而毫无怨言的工作呢?