抽象设计的基础:如何抽离共性

问题是否具有挑战性,取决于你如何去看待它。

引子

很多程序员在能够胜任一些复杂业务逻辑的开发之后,就不知道如何继续提升自己的技术水平了。其实,这时候就需要向抽象设计之路前进啦。

何为抽象设计?抽象设计的基本功,即是将业务中的共性抽离出来,用技术语言来描述,发展技术的手段去处理。这样,业务问题实际上就是技术问题的领域化描述,是技术问题加了一层业务的壳而已。

当能够看清楚业务中的技术本质时,业务问题就可以转换成原滋原味的技术问题,就再也没必要抱怨整天都是在做业务逻辑了。

举例子:

一个容器行为白名单业务。有两个事件数据源,一个事件数据源上报容器行为数据,另一个事件数据源行为上报资产相关数据。这两个事件数据源的上报是相互独立的,但这个业务需要把容器行为数据和资产数据结合起来展示。这就存在两个问题:1. 当容器行为数据上报时,对应的资产数据有可能还没有上报上来,怎么解决? 2. 资产数据是会变化的,当资产数据变化时,如何更新对应的容器行为模型?

看上去,是不是一个典型的业务问题?如果把它抽象成技术语言,怎么描述? 别急,下文将会讲到。

抽象设计思维能力是程序员应当具备的一项非常重要的思维能力,与逻辑能力是同等重要的。遗憾的是,很多程序员甚至终其一生都没意识到这一点。

本文将会探讨,如何训练抽象设计的基本功:抽离共性。

抽离共性

要想抽离共性,首先要识别共性。

识别共性有一个非常简单的方法:重复中必有共性。正是因为有共性,才会有重复。

程序员对重复代码想必是早有耳闻,深有恶觉,—— 当然,也可能早就麻木了。谁曾想,这重复中竟然孕育着抽象设计的种子呢!谁曾想,这重复中竟然孕育着技术能力进阶的机会呢!

举例一:遍历一个对象列表,取对象的 A 字段,得到一个列表; 遍历一个对象列表,取对象的 B 字段,得到一个列表。 这是不是就包含重复了? 重复模式: 遍历一个对象列表,取对象的 F 字段(F 字段可配置), 得到一个列表。 差异就是取的字段不同。大多数程序员都知道,把字段 F 的名字作为参数传入函数即可解决。


@AllArgsConstructor
@Data
class Person {
    private String name;
    private Integer age;
}

public static List getValues(List<Person> persons, String fieldName) {
    List values = new ArrayList();
    for (Person p: persons) {
        values.add(ReflectionUtil.getValue(p, fieldName));   // 这里用到了不太优雅的反射
    }
    return values;
}

List<Person> persons = Arrays.asList(new Person("qin", 32), new Person("ni", 24));
System.out.println(getValues(persons, "name"));
System.out.println(getValues(persons, "age"));

也可以这样解决(把函数作为参数传入):


public static <T> List<T> getValues(List<Person> persons, Function<Person, T> fieldFunc) {
    return persons.stream().map(fieldFunc).collect(Collectors.toList());
}

System.out.println(getValues(persons, Person::getName));
System.out.println(getValues(persons, Person::getAge));

这都是雕虫小技了。

举例二: 遍历一个对象列表,取对象的 A 字段,若满足 f(A) ,则移除,得到被移除对象之后的列表; 遍历一个对象列表,取对象的 B 字段,若满足 g(B), 则移除,得到被移除对象之后的列表。 重复模式: 遍历一个对象列表,取对象的 F 字段,满足 func(F) , 则移除,得到被移除对象的列表。差异是,所取的字段 F 不同,满足的函数 func 也不同。 这时候,很多程序员可能就不知道抽离共性了,然后就会写两段相似的重复代码。这里,很多程序员能够识别共性,但苦于缺乏相应的编程技巧,而难以抽离共性。

实际上,凡模板流程里只有少量差异的地方,都可以采用函数式编程来解决。函数式编程是抽离共性的有力的编程武器。应用函数式编程的方法是:第一步,用函数和额外参数描述差异; 第二步,在共性流程里用额外参数调用该函数。

就这个例子来说,问题就是如何描述 满足 func(F) 的对象。可以用 Function<Person, T> fieldFunc 表达如何取字段 F 的值(类型是 T), 用 Predicate<T> test 表达对字段 F 的值进行测试。 如下所示:


public static <T> List<Person> remove(List<Person> persons, Function<Person, T> fieldFunc, Predicate<T> test) {
    Iterator<Person> personIterator = persons.iterator();
    while (personIterator.hasNext()) {
        T t = fieldFunc.apply(personIterator.next());
        if (test.test(t)) {
            personIterator.remove();
        }
    }
    return persons;
}

public static void test3() {
    List<Person> persons = Lists.newArrayList(new Person("qin", 32), new Person("ni", 24));
    System.out.println(remove(persons, Person::getAge, age -> age < 30));
    System.out.println(remove(persons, Person::getName, name -> "qin".equals(name)));
}

如果不限定某个字段的话,也可以写成:


public static List<Person> remove(List<Person> persons, Predicate<Person> test) {
    Iterator<Person> personIterator = persons.iterator();
    while (personIterator.hasNext()) {
        Person p = personIterator.next();
        if (test.test(p)) {
            personIterator.remove();
        }
    }
    return persons;
}

public static void test4() {
    List<Person> persons = Lists.newArrayList(new Person("qin", 32), new Person("ni", 24));
    System.out.println(remove(persons, p -> p.getAge() < 30));
    System.out.println(remove(persons, p -> "qin".equals(p.getName())));
}

甚至,不限定具体对象类型,还可以写成更通用的形式:


public static <T> List<T> remove(List<T> objList, Predicate<T> test) {
    Iterator<T> personIterator = objList.iterator();
    while (personIterator.hasNext()) {
        T t = personIterator.next();
        if (test.test(t)) {
            personIterator.remove();
        }
    }
    return objList;
}

public static void test5() {
    List<Person> persons = Lists.newArrayList(new Person("qin", 32), new Person("ni", 24));
    System.out.println(remove(persons, p -> p.getAge() < 30));
    System.out.println(remove(persons, p -> "qin".equals(p.getName())));
}

当程序员的思维和编程技艺不再限定在具体行为和类型上,他就开始“飞翔”了。他在编程的世界里自由了。

可见,函数式编程,是思考和实现抽象设计能力的有力的法宝,不可不重视之。

举例三: CRUD 里的分页排序列表。 凡某种信息管理系统,必定有若干个分页列表,需要根据某个字段排序。 其共性是分页排序,不同之处在于展示、排序及用于筛选的字段不一样。但无论客户端分页还是服务端分页,其代码几乎是相似的。只要定义领域字段,完全可以自动生成分页排序列表,而无需反复写相似的模板代码。 5 分钟自动生成 WordPress 博客即是一例。


技能进阶

随着对共性的思考越来越多,抽离共性的技巧越来越熟练,抽离共性能力的可控范围也越来越大,抽象设计能力也在循序渐进地提升中。

举例四: 后台任务。比如导出任务,接收请求,存储导出任务信息,提交任务到线程池异步执行;比如病毒扫描任务,接收请求,存储扫描任务信息,提交任务到线程池异步执行。共性是,接收请求、存储任务信息,提交任务到线程池异步执行。所不同的是,任务信息及特征不同。

针对这种模板流程,往往可以采用模板方法模式来抽离共性。

举例五:通用导出。比如订单导出,根据某些条件查询订单列表,从各种数据源拉取订单相关的详细信息(订单信息、商品信息、支付信息、优惠信息、发货信息、退款信息、核销信息等),按照订单或商品维度组装数据,排序、过滤、汇总、格式化、生成订单商品报表、上传、更新导出任务信息;比如发货导出,根据某些条件查询发货单号列表,从各种数据源拉取发货相关的详细信息(发货单号、对应订单商品信息、对应物流信息、对应配送信息等),按照货单维度组装数据,排序、过滤、汇总、格式化、生成发货报表、上传、更新导出任务信息;比如退款导出,根据某些条件查询退款单号列表,从各种数据源拉取退款相关的详细信息(退款单号、退款的订单商品信息、退款的商品发货状态等)、按照退款单维度组装数据,排序、过滤、汇总、格式化、生成退款单报表、上传、更新导出任务信息。你能从这些导出中抽离出导出的共性吗?

很显然,仅仅模板方法模式还不够。还需要更高的设计技能。比如插件式架构设计。无论什么导出,一定包含如下流程:查询、详情获取、组装数据、排序、过滤、汇总、格式化、生成报表、上传、更新导出任务信息。无论什么查询,通常是从 DB 或 ES 或某个数据源进行分页批量筛选数据;无论详情多么复杂,总是从 API 或 HBase 或某个数据源来并发批量拉取数据;无论是什么维度,总是按照某个主维度,通过一对一或一对多的方式来拼接数据;排序必定是针对某个字段;过滤总是针对某些字段的函数布尔计算;格式化可以用字段函数计算来表达;生成报表、上传和更新导出任务信息则是典型的模板方法模式。

比较难的地方是查询、详情、组装数据、排序、过滤。可以把这些操作做成插件,插件可以从不同的数据源获取数据,指定不同的字段配置或函数计算;然后用一个通用流程把这些插件串联起来,这样,整个导出的共性就出来了。

除了设计模式,还需要熟悉和应用适宜的架构模式。架构模式可用来掌控更大范围的共性。

举例六: 事件处理流程。 资产上报事件处理流程是, 接受客户端上报的数据,然后有一系列监听器监听上报的数据,做相应的处理,然后入库;这些监听器之间的处理可能有一定的顺序依赖。入侵事件处理的通知流程是,接受已经过处理的业务事件,发送站内通知、短信,更新 Dashboard,可选地更新威胁情报。 这里的共性是什么?

组件编排。每个资产事件监听器都是一个组件,通过某种规则串联在一起,共同完成资产上报的处理流程;发送站内通知及短信、更新 Dashboard, 更新威胁情报,也都是组件,可以按照某种顺序编排成一个完整的流程。组件编排框架的作用就是,根据组件及组件的编排顺序,执行相应的组件并返回结果。

组件编排,也可以看成是插件式架构模式。

因此,抽离共性的重点在于,不是针对一个问题提出一个解决方案,而是针对一类问题提出一个解决机制。

问题是否具有挑战性,取决于你如何去看待它。

抽离技术问题

抽象设计的另一个维度,则是从业务问题中抽离出技术问题。从技术角度思考问题,再把求解还原到业务域上。

回到之前的例子。一个容器行为白名单业务。有两个事件数据源,一个事件数据源上报容器行为数据,另一个事件数据源行为上报资产相关数据。这两个事件数据源的上报是相互独立的,但这个业务需要把容器行为数据和资产数据结合起来展示。这就存在两个问题:1. 当容器行为数据上报时,对应的资产数据有可能还没有上报上来,怎么解决? 2. 资产数据是会变化的,当资产数据变化时,如何更新对应的容器行为模型?

如何抽离技术问题呢? 一个简单的办法是符号化。也就是数学采用的思维。

比如“有两个事件数据源,一个事件数据源上报容器行为数据,另一个事件数据源行为上报资产相关数据”。可以符号化为 ES1,ES2, ES1 上报的字段有 a(A1, A2, A3, A4), ES2 上报的字段有 b(B1, B2, B3),关联关系是 A3 与 B3 可以关联起来。 业务需要展示出 m(A1, A2, A3, A4, B1, B2, B3)。问题1: 当 a(A1, A2, A3, A4) 上报时,如果对应的 B3 没有上报怎么解决;问题2:当 B3 更新为 B3' 时或者被删除时,原来的 m(A1, A2, A3, A4, B1, B2, B3) 如何变化?

这样,是不是就完全跟业务无关了?ES1, ES2 可以代表任何业务数据源,而字段也可以是任何业务数据源的业务字段。一旦我们能够针对这个问题建立一个解决机制,那么这个解决机制将能解决所有符合这种特征的业务问题,而不仅仅限于这一个。这种特征的业务问题可以统称为:存在依赖变更的数据源的更新问题。而这种依赖更新问题的具体技术方案选型则需要考虑具体的业务诉求,比如实时性、依赖数据变化频度、数据量大小、数据依赖是本质依赖还是关联依赖、依赖是否需要解耦、操作的准确性要求等。

这就是抽象设计思维的能力所在。它能从表面的业务问题,看到深层的技术问题;通过技术问题的求解,来穿透解决一类的业务问题。

当然,要锻炼强大的抽象设计思维,就需要多多思考共性、从业务中抽离技术问题、思考问题的本质等。这可是烧脑细胞掉头发的事情。

小结

本文主要探讨了如何抽离业务中的共性问题以及运用函数式编程技巧来实现共性的抽离。抽离共性的重点在于,不是针对一个问题提出一个解决方案,而是针对一类问题提出一个解决机制。此外,也探讨了如何从业务问题中抽离技术问题的方法。

培养良好的抽象设计思维,对于程序员技术能力的进阶是非常重要的。它能从表面的业务问题,看到深层的技术问题;通过技术问题的求解,来穿透解决一类的业务问题。

问题是否具有挑战性,取决于你如何去看待它。

posted @ 2021-10-12 00:17  琴水玉  阅读(587)  评论(2编辑  收藏  举报