0%

对比

如果只是简单地用在接口返回,List 与 Stream 并没有差异
例如以下两段代码,返回的结果是一样的

@GetMapping("list")
public Object listAll() {
    return repository.findAll();
}
@GetMapping("stream")
public Object streamAll() {
    return repository.streamAllBy();
}

但如果你需要在业务逻辑中处理大量的数据,List 和 Stream 的差异就体现出来了。

如果不用 Stream,在处理海量数据的时候,为了避免一次性将全部数据 Load 到内存导致内存溢出,一般我们会进行分页处理。但 skip & limit 跳页会导致较低的的查询性能,因此一般我们会采用 lastId 配合索引的方式来进行分页,如下所示

final int PAGE_SIZE = 5000;
String lastId = '000000000000000000000000';

List<MyDoc> rows = repo.findAllByGreaterThanId(lastId, PageRequest.of(0, PAGE_SIZE));
while (rows.size() > 0) {
    for (row in rows) {
        // do something for current row
    }
    // read next page
    lastId = lastOne(rows).getId();
    rows = repo.findAllByGreaterThanId(lastId, PageRequest.of(0, PAGE_SIZE));
}

但这种方式仍然存在一些问题:

  1. 仍然会占用一些内存(取决于你设定的 PAGE SIZE),当然这不是什么大问题
  2. 在等待传输完当前页数据的 I/O 期间,应用程序什么也干不了
  3. 如果哪天要修改成多线程版本以提升处理效率,会有比较大的改动
  4. 代码复杂度稍高

相比之下,如果我们用 Stream 来处理,代码就简单多了

repo.streamAllBy().forEach(doc -> {
    // do something for cuurent row
});

如果要修改为并行版本也非常简单

repository.streamAllBy().parallel().forEach(doc -> {
    // do something for cuurent row
});

Stream 的缺点:

  1. 批处理的场景下没有分页直观(例如滑动窗口),这点主要是 JDK8 缺乏支持,其它类似的框架如 RxJava 或 ProjectReactor 都是支持的,Spring Data Reactive 也有相关的支持(当然,学习成本也是很高。。)
  2. 【实际与我猜想的不一样,见实测章节】Stream 处理任务的期间会持续占用一个连接,不利于资源的复用。相比之下 List 只有每次拉取页的 I/O 期间才占用连接(假如不加事务的话)。如果连接资源很紧张,使用 Stream 可能会出较大的问题

性能实测

环境

  • 单 collection 约 130w 数据
  • 客户端:Java + MacOS

List

list performance

idea list breakpoint

可以看到,调用 List 的过程,JVM 内存只增不减,且 GC 频率越来越高。整个过程花了接近 15min 时间。

list gc

而在执行完后,触发一次 GC,直接内存占用就清零了。

原因显而易见,List 操作需要在 JVM 内存中构建 ArrayList 对象,加上数据量过于庞大,会导致不断地进行扩容,因此性能极差。同时由于所有数据均被一个 ArrayList 对象持有,导致内存占用只升不降(无法被 GC 回收)

Stream

stream performance

handle count: 1391665. time elapsed: 11500ms

首先性能上远远高于 List(没有扩容和 GC,只花了 11s 左右)

由于不需要通过 ArrayList 去保存数据,内存利用率会迅速增加(约 700MB),后面有一段维持直线的,猜测是因为一直没有触发 GC。

将代码稍微改动下,在 stream 的处理期间手动触发一些 GC

repository.streamAllBy()
        .forEach(doc -> {
            String itemInMemory = doc.getContent();
            if (c.get() % 100000 == 0) {
                System.gc();
            }
            c.getAndIncrement();
        });

stream performance

handle count: 1391665. time elapsed: 15226ms

可以看到相比于 List,Stream 的处理期间是可以释放被占用的内存的。另外由于多了取余和 GC 的操作,整个时间花费也由 11s 上升到 15s,CPU 也有所上升。

缺点 2 实测

为了验证上述的缺点 2,我准备了一个简单的服务以及两个接口

@GetMapping("list")
public Object listAll() {
    return repository.findAll();
}

@GetMapping("stream")
public Object streamAll() {
    repository.streamAllBy().parallel().forEach(doc -> {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    return "ok";
}

其中,/stream接口中针对每条数据会 sleep 200ms 的时间以模拟慢操作(数据库中预先准备了 100 条数据,因此该接口需要执行约 20s 的时间),/list接口则只是简单的返回所有数据。我们通过交叉用这两个接口来观察应用中的连接使用情况

  1. 我们先重启应用,确保连接池为空
  2. 先调用/list接口,观察日志会发现 Spring Data Mongo 创建了一个连接
2023-01-20 17:09:19.576  INFO 77750 --- [nio-8080-exec-1] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:2, serverValue:1859}] to 192.168.11.180:32017

如果此时进入断点查看,会发现连接池数量为 1
pool size 1
继续调用/list接口(非并发场景)会发现 Mongo Client 将一直复用此连接,不会创建新的连接。这符合连接池的设计机制

  1. 我们先调用/stream接口,在其处理期间再调用/list接口,如果如我们所猜想的一样 Stream 会长时间占用一个连接的话,那么我们在调用/list接口的时候 Mongo Client 应该会再创建一个连接用于处理查询才对

实际情况是:在我们/stream接口执行期间,调用/list接口并没有使得 Mongo Client 创建新的连接。打断点观察ServerSessionPool的可用连接数也会发现其仍然为 1。显然 Mongo Client 及 JDK Stream 底层是针对这种情况做过优化的,猜想被推翻

  1. 为了证明在资源不够用的时候 Mongo Client 确实是会自动创建新的连接的,我们也用 ab 来做一个简单的压测

压测命令:ab -n 100 -c 5 'localhost:8080/mongo/list'
可以看到控制台输出了 4 个连接创建的事件

2023-01-20 17:19:42.172  INFO 77750 --- [nio-8080-exec-3] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:6, serverValue:1865}] to 192.168.11.180:32017
2023-01-20 17:19:42.172  INFO 77750 --- [nio-8080-exec-4] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:5, serverValue:1863}] to 192.168.11.180:32017
2023-01-20 17:19:42.172  INFO 77750 --- [nio-8080-exec-2] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:4, serverValue:1864}] to 192.168.11.180:32017
2023-01-20 17:19:42.172  INFO 77750 --- [io-8080-exec-10] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:3, serverValue:1866}] to 192.168.11.180:32017

断点观察连接池可用连接数也变成了 5
pool size 5

结论

除了如批处理之类的少数场景下,Stream 几乎总是优于分页 List(更不用说全量 List),因此在单次要处理的数据量达到一定量级时(比如超过 1000),应该优先考虑使用 Stream。

参考资料

Redis 有自己的一套传输协议(RESP),因此想通过 Wireshark 抓包 Redis 得先安装插件,具体步骤如下:

  1. 找到你的 Wireshark 安装目录下的 init.lua 所在的文件夹

例如我的是 MacOS,通过 DMG 安装的,是在 /Applications/Wireshark.app/Contents/Resources/share/wireshark 目录下

  1. redis-wireshark.conf 整个文件 copy 放在上述目录下

  2. 编辑 init.lua 添加以下内容,加载第 2 步放进去的 lua 脚本

-- 其它内容...
if not running_superuser or run_user_scripts_when_superuser then
    dofile(DATA_DIR.."console.lua")
    dofile(DATA_DIR.."redis-wireshark.lua")     -- 这行是你要新加的,其它都是原有的
end
-- 其它内容...
  1. 重启 Wireshark 或使用 UI 上的 Analyse -> Reload Lua Plugins 功能(CMD + SHIFT + L)重新加载插件

  2. 启动抓包,用 redis-cli 连接你的 Redis,随便输入一些命令(我这里示例是 get tac),在 Wireshark filter 框框中输入 redistcp.port == 6379 过滤协议,看到类似以下的数据就说明成功了

redis-wireshark-example

背景

最近心血来潮买了个米家门窗传感器,打算用在浴室实现一种人来灯亮,人走灯灭的效果,但买回来后却发现实际场景比我想象中要复杂得多。为啥呢?门窗传感器最大的问题在于只能感知开/合两种状态,所以无法得知是否有人在浴室内。如果你仅靠闭门来触发关灯的话,就会有一个大问题:当你触发开门的时候灯亮了,然后你走进浴室,这时关门,pa 的一下,灯灭了。。尴尬。。

方案

如果要解决这个问题,首先想到的是再买个人体红外感应器配合来用。但一来吧,总觉得为了这一个小自动化功能买两个传感器不经济;二来红外感应器在浴室的表现并不好(覆盖角度有限制而且有些浴室设计还是分区的,加上浴室水蒸气有时会干扰红外检)因此不是个很好的方案。

但东西都买回来了,总不能丢一边吃灰吧。好在办法总比困难多。门窗传感器本身虽然不足以检测到人,但米家给我们提供了其它逻辑工具啊~

来看看这个问题,其实可以抽象成如何设计一个有限状态机(Finite Automate)以保证浴室自动化符合我们的要求的问题,其中:

要处理的浴室状态有:

  • close_out: 室内无人且门属于关闭状态(此状态时灯与排气扇关闭)
  • open_wait: 室内无人且门属于打开等待进入状态(此状态时灯与排气扇打开)
  • close_in: 室内有人且门属于关闭状态(此状态时灯与排气扇保持打开)
  • open_in: 室内有人且门属于打开状态(此状态时灯与排气扇区保持打开)

tips: 因为还要考虑人是否在内,所以要考虑的状态有四个而非简单的开和闭两个。

影响状态变换的条件有:

  • open: 浴室门打开
  • close: 浴室门关闭

但实际上还有个隐藏条件可以利用:

  • time_elaspe: 时间流逝 ns(比如我们假定现实中 90% 的场景下人进入浴室,都会在 15s 内关上门)

如果把人工干预也算上,那还可以加上:

  • manual: 检测到手动操作(例如,手动关灯)

最终设计出来的状态变换图如下

toilet fa

其中 close_out 即是起始状态也是最终状态。

对应状态转换表如下

open close time elaspe manual
close_out open_wait - - -
open_wait - close_out open_in -
close_in close_out - - close_out
open_in - close_out - -

横杠 - 表示该状态下不接受此输入的意思

验证

接下来我们把要处理的场景跑一遍,看上述 FA 能否满足我们的要求。

为了简化表述,我们把状态和条件均用数字代替:

  • state set: close_out(1), open_wait(2), close_in(3), open_in(4)
  • criteria: open(1), close(2), time_elaspe(3)

toilet fa with numbers

场景一:长时间事务,会关门进行(如洗澡,蹲坑)

输入:1, 2, 1, 2
状态变换过程:1->2->3->4->1

toilet fa scene 1

最终状态为 close_out,可接受,符合预期

子场景 1.1:人在里边临时开门(不超时)

输入:1, 2, 1, 2
状态变换过程:1->2->3->4->1
最终状态为为 close_out,可接受,但不符合预期(预期状态是保持 close_in),需要将输入调整为 1, 2, 1, 2, 1, 2(也就是临时开门后再执行一次开关门,中间会经历一次灯断电,体验稍差)
状态变换过程:1->2->3->4->1->2->3,符合预期

toilet fa scene 1.1

场景二:短时间事务,不关门(如洗手,时间足以触发条件 3)

输入:1, 3, 2
状态变换过程:1->2->4->1

toilet fa scene 2

最终状态为 close_out,可接受,符合预期

场景三:误操作,开门后立马关门(时间不足以触发条件 3)

输入:1, 2
状态变换过程:1->2->3
最终状态为 close_in,非可接受状态,因此要人工介入,使得

输入变为: 1, 2, 4
状态变换过程:1->2->3->1

toilet fa scene 3

最终状态回到 close_out,可接受,符合预期

总结

可以看到在场景 1.1 & 3 下若没有人工介入依然是无法很好地处理的。好在 1&2 占了我们日常场景的 90% 以上,总的来说此方案还是利大于弊。

实现

由于笔者使用的是米家 APP,因此这里仅展示基于米家『场景』模块的实现(理论上只要支持上述触发条件的平台都能实现)

这里的场景指的是米家 APP 上的『场景』模块,与我们文章上述提到的场景并非同一概念
米家场景分为自动(又称作智能)和手动两类,手类执行的场景除了手动操作外,还可以通过自动场景来触发

我们把浴室的状态与米家的场景对应起来(有些状态可能会对应多个场景),得到以下场景

close_out

  • 触发条件:门窗传感器开
  • 执行动作:
    1. 开浴室灯 & 排气扇(这块可以根据实际需求自行调整)
    2. 关闭智能 close_out
    3. 开启智能 open_wait
    4. 关闭智能 open_in
    5. 执行手动场景 open_wait.time_elaspe

tips: 其中命名 close_out 代表该米家场景打开时我们的浴室是处在状态 close_out 的,一旦检测到门窗传感器打开将执行相应动作。动作 2&3 组合起来实现了状态切换的效果(关闭当前智能,打开目标状态对应的智能,也就是 open_in);动作 5 是模拟以时间条件为输入的效果,详见 open_wait.time_elaspe 的配置
此外,这里还出现了一个看似没啥用的动作 关闭 open_in,将在后面作解答

open_wait.time_elaspe

  • 触发条件:手动触发
  • 执行动作:
    1. 延时 15s(时间可以自行调整成符合家庭习惯的数值)
    2. 开启 open_in

open_wait

  • 触发条件:门窗传感器关
  • 执行动作:
    1. 关闭智能 open_wait
    2. 开启智能 close_in
    3. 开启智能 close_in.manual
    4. 关闭智能 open_in

tips: 这里的状态切换是由动作 1&2&3 共同组成的,这是因为在我们的状态转换表里面 close_in 状态是可以被条件 open / manual 任其一触发转换到其它状态,所以状态 close_in 其实是对应了两个米家智能,这两个智能只能是同时开启同时关闭,才能保证自动化不会出现混乱

close_in

  • 触发条件:门窗传感器开
  • 执行动作:
    1. 关闭智能 close_in
    2. 关闭智能 close_in.manual
    3. 开启智能 open_in

close_in.manual

  • 触发条件:浴室灯 or 排气扇任一关闭(这块可以根据实际需求自行调整)
  • 执行动作:
    1. 关浴室灯 & 排气扇(这块可以根据实际需求自行调整)
    2. 关闭智能 close_in
    3. 关闭智能 close_in.manual
    4. 开启智能 close_out
    5. 关闭智能 open_in

open_in

  • 触发条件:门窗传感器关
  • 执行动作:
    1. 关浴室灯 & 排气扇(这块可以根据实际需求自行调整)
    2. 关闭智能 open_in
    3. 开启智能 close_out

最后,还记得上面提到的 关闭 open_in 这个看似没啥用的动作吗,其实不仅在 close_out 状态中,在除 open_in 外的每一个状态动作执行时都要加上这个动作。这是因为利用米家 APP 的延时模拟时间条件的输入这个是无法取消的,即是说一旦执行了 open_wait.time_elaspe,15s 后必定会打开 open_in 场景,而这时我们的浴室可能处在任何一个状态。为了避免因此导致的混乱,我们才需要在每个状态的动作加入此动作

tips: 超时效果也可以通过门窗传感器自带的超时未关通知能力来实现,这样就相当于是可中断的,不需要上述的的补偿操作了。但有个问题是,目前我这款传感器好像只支持配置分钟时间粒度

手机截图如下

toilet fa scene 1

结语

以上配置对于普通用户(尤其是没有编程基础的)来说还是有些过于复杂了,建议厂家可以将状态机直接集成到门窗传感器中的,用户只要决定用或不用即可。

另外,米家平台提供的基础能力也比较有限(可能是基于易用性及安全性的考虑?),导致很多效果实现起来还是比较困难。不管怎样,希望希望米家能加入一些变量能力,可以通过动作来设置变量值,也可以根据变量值的不同来触发不同的智能,这样会有更高的可玩性。

出于 Pigeon 项目的需要,我需要把通过插件支持的扩展信息在 swagger api 文档中动态展示出来。

项目使用的是 springfox 框架生成的 swagger 文档,众所周知 springfox 是注解型的文档框架,而且文档内容是直接写死的,不支持动态化,估摸着是需要自己扩展了。

按照惯例,先了解代码架构(由于以前都是使用为主,没有深入了解,也不知道有哪些拓展点)。发现 springfox 其实是在应用启动时 scan 带有 swagger 注解的类和字段,生成一个叫 Documentation 的类,然后通过 mapper 转换成相应的 swagger model(v1.2 or v2)。

可见,我们要分析的 code 可以缩小到 Docuementation 生成的过程,主要关注 @ApiParam, @ApiPropertyModel 这几个注解的解析步骤。

继续深入,发现 Documentation 的生成是通过 DocumentationPlugin 来做的,而调度 plugins 则是在 DocumentationPluginsBootstrapper 中。DocumentationPluginsBootstrapper 又是从哪里被调用的呢?没错,是通过 SpringfoxWebMvcConfiguration scan package springfox.documentation.spring.web.plugins 时被加载到 spring context 中的,由于这是一个 SmartLifecycle,因此会自动执行 #start 方法。在这个方法中会 foreach plugins,通过 plugin 得到的文档 context(context 中包含了 api 信息,而这些信息其实正是 spring mvc 维护着的 request handlers),再经由 scanner scan 整个 context 生成 Documentation 后加入到 DocumentationCache 中,后续通过 /api-v2/docs 接口访问时就是直接从 cache 中去获取到 Documentation 的。

上面提到的 plugins 又是什么呢?这个概念大家可能比较陌生,但只要是用过 springfox 的小伙伴,对 Docket 这个类肯定不陌生。没错,Docket 其实也是实现了 DocumentationPlugin 的一个类,根据官方的描述

Docket is a builder which is intended to be the primary interface into the Springfox framework.
Provides sensible defaults and convenience methods for configuration.

回归主题,DocumentationPluginsBootstrapper 使用的 scanner 是 ApiDocumentationScanner。追踪源码发现,这个 scanner 中又细分为 ApiListingReferenceScanner(扫描引用 model 的)ApiListingScanner (扫描 apis 的),后者正是关键类。继续深入,在 ApiListingScanner -> ApiDescriptionReader -> ApiOperationReader 中发现,Operation(即维护 api 的路径、参数等具体描述的类)正是在其中通过 20+ OperationBuilderPlugin 组成的 filter 器链共同构建而成。根据名称大概筛选了一下,最终锁定了 OperationParameterReader 这个类。该类在处理参数时又会分成需要 expand 的(@RequestBody 之流)和不需要 expand 的(@ApiParam 直接标注的参数)。

先来看不需要 expand 的,最终会交由 ParameterBuilderPlugin 链处理,因此我简单写了一个扩展

@Component
public class PigeonExtendParamBuilder implements ParameterBuilderPlugin {
    @Override
    public void apply(ParameterContext context) {
        if (context.resolvedMethodParameter().getParameterType().isInstanceOf(String.class)) {
            context.parameterBuilder()
                    .allowableValues(new AllowableListValues(Lists.newArrayList("MAIL", "SMS"), "String"));
        }
    }

    @Override
    public boolean supports(DocumentationType documentationType) {
        return true;
    }
}

跑下程序,果然所有 @ApiParam 标注的 string 类型参数都被加上了 Available values : MAIL, SMS 限制。但在 body 定义里面的参数没有效果,一开始推测是跟上述的 expand 概念有关,但后来发现不是,而是这类参数最终会指向一个 ModelRef,因此应该是与 Model 的解析有关。那 Model 的解析是在什么时候呢,同样是在 ApiListingScanner#scan 方法中,交由 ApiModelReader 完成。 ApiModelReader 会通过由 Spring MVC 维护的 RequestMapping 信息提取 model 信息(例如 @RequestBody, @Requestpart 之流),然后交给 ModelProvied#modelFor 转换成 springfox 的 Model 对象,此 Model 对象即是 ModelRef 引用的 Model。

#modelFor 中,会通过反射解析出 Model 对应的 class 中的所有字段信息(称为 ModelProperty),而这个过程则是交给 ModelPropertiesProvider#propertiesFor 完成的,其中细节比较多,但总的来说,最终都是交由 ModelPropertyBuilderPlugin 链来处理的,这个类也正是我们要找的扩展点。后续简单验证了一下,确实是有效的。

最终效果:TODO

团队协作最大的成本是沟通成本。如何降低沟通成本,是管理者需要持续思考的问题。

日常沟通的本质,究其根本其实是由一系列的逻辑推理构成。而所谓的达成一致意见,即是我们对同一个命题推导出了同样的结论

对于这一点,可能有些同学并不认同。这是因为我们的交流往往不会以十分正式的逻辑推理过程进行,而是更多的口语化进行。

比方说:今天应该会下雨。这样简单的一句话就包括了逻辑推理,只是大前提小前提均没有明确而以,需要我们根据当时聊天的语境来确定。假如你是因为昨天看了天气预报,那你的大前提是:按以往经验,如果天气预报预测说会下雨,那大概率会下雨;小前提是:昨天的天气预报说今天会下雨;结论就是:今天应该会下雨。如果换一种场景,是因为你看到今天的天空,那你的大前提是:阴云密布的天气大概念会下雨;小前提是:今天是阴天;结论就是:今天应该会下雨。

可见,简单的一句话,也是包含了完整的逻辑推理在内的。只不过出于方便,我们在交流时往往会简化许多步骤,并通过我们的大脑运算去补全其它的信息。否则,我们在沟通时的对话便会冗长而啰嗦,有效信息比低,因而沟通成本高。

但这样做也有缺点,就是我们有可能意会错别人的意思,这时就出现了所谓的误解

假设甲对乙说今天应该会下雨,实际上是因为甲昨天看了天气预报。但乙并没有看,而且乙今天出门时看到天气晴朗,因此并不同意甲的说法。此时要保证沟通顺畅,需要甲补充自己的大前提。如果甲也并没有意识到这个问题,不去向乙解析清楚,那么两个人之间就产生了分岐,产生的原因是沟通中信息的缺失。

除了信息缺失外,信息畸变(想要传递的信息,因为表述不当,导致别人理解错误)也会带来误解,这可能是有意的(偷换概念),也可能是无意的(混淆概念),但导致的结果一样。像同样是偷,偷窃、偷笑就完全不是同一个概念。

上面讨论的是在信息传递本身,但如果沟通本身缺少主题(即没有要讨论的命题),自然也谈不上得出结论,就是在白白浪费时间而以。

知道了原因,我们便可以确定下来沟通中应尽力遵守的一些原则

  • 讨论前确定命题(有时比较迷茫的时候,想借讨论过程本身找到问题其实也是一个命题)
  • 尽可能使用术语(术语即是某个领域中的专用概念,保证双方都理解术语的前提下,使用术语可以很有效地降低沟通成本)
  • 尽可能复用现有术语(少用一些不常见或自创的概念)
  • 有意识地创造一些概念,但需要做到
    • 尽力保证其定义准确
    • 做好概念普及工作,让团队能在一个上下文沟通
    • 一经确定便不要再修改,这容易带来混乱
  • 避免大量创造不准确、难以理解的概念(这点在编码中的类、方法、变量命名尤为常见)

用人过程中常见的雷区

专业基础太差

专业基础太差的另一种解释是:对这个专业领域的概念理解得太少

这会导致你在解释一些问题给他听的时候,需要费非常大的工夫。因为逻辑推理是一层嵌一层的结果,一个上层概念往往是由无数下层概念支撑起来的,如果他对下层概念的理解不够,他是很难去理解一个上层概念的。

我听到很多朋友问过我一个问题:什么是 Java?内行人士可能完全能理解什么是 Java,但是如果让你向外行解释清楚,你会发现这其实是很困难的一件事。

这是为什么?因为我们要真正理解一个概念,不仅要知道其定义,更要理解其内涵

Java 是一个上层概念,它是一门静态的、面向对象的编程语言。但如果你照搬此定义向你的朋友解释,他听完肯定依然一脸懵圈。要对这个概念有深刻的理解,你首先要理解更下一层的概念:语法、框架、虚拟机、IDE 等等。而要理解下一层的概念又要理解更下一层的概念,比如:

  • 语法的下一层概念有:修饰符、关键字、变量、常量、类、方法等等
  • 虚拟机的下一层概念有:字节码、堆、栈、class 文件、垃圾回收、操作系统、等等

如此循环往复,每个上层概念都会牵扯出一堆组成树状结构的概念,在不理解底层概念的情况下试图直接去理解上层概念,那必然会非常困难而且理解得也不深刻。

这些下层的概念其实都已经内化在内行人士心中了,所以跟他们交流时无需过多地解释。但对于外行则完全不一样,这就导致很多时候我们面对这种问题非常无奈。

理解能力太差

专业基础差还有一种情况,就是许多概念都知道一点,但不深入,或者理解有错误。这种情况其实比上面更可怕,不懂的人会直接表现出来,而理解有偏差的人,不仅不会表现出来,他们往往还会表现得自己是正确的一样,进而把不明就理的人带歪。

这也是为什么有人主张在面试的时候,遇到不懂的问题,直接说不懂也好过硬答。

学习能力太差

这是个信息爆炸的时代,每天都有新的知识产生、旧的知识消失,学习能力差的人终究会逐渐无法跟上时代的脚步。

但这个问题会在相对而言的中长期才体现出来,应该视团队当前情况决定是否要与此类人共事。

三观不合

所谓三观不合,其实是在一些基础认知上存在分岐。比如甲是一个追求完美的人,而乙是一个得过且过的人,那他们在同一个问题上就很可能出现意见不合。而所谓江山易改、本性难移,基础认知是没那么容易改变的。多数人在某一个团队工作短则几月长也就几年,这点时间你是很难去改变一个人的。

最好的方法是不要与三观不合的人一起共事,这并不一定因为他的认知是错的,只是你们不适合。

前言

DDD 全称领域驱动设计,最初由 Eric Evans 提出。此文章仅探讨 DDD 在 Java 服务端的落地可行性简要方案,以期不需要太过复杂的前期准备也能够快速在项目中运用 DDD 编写代码

注意,此文章:

  • 假定读者有一定的 DDD 理论基础
  • 假定读者有 Java 服务端开发经验,并掌握主流的开发框架(如 Spring),本文的许多章节将会结合这些框架进行实现
  • 以经典的 RBAC 权限模型为示例进行 DDD 建模

DDD 编写的代码所属层次

我们把 DDD 设计的相关代码放到 Domain 层,这一层是介于经典三层架构中 Service 与 DAO 层之间的特殊的一层,但严格意义上来说还是属于 Service 层(处理业务逻辑),可以想象成在原先的 Service 层上又划分了一层出来。

如下图所示

TODO::

示例
下面是我们在 JAVA 工程中采用的一个 DDD 包结构规范

TODO::

如果是现有项目,可以在与 controller, service 平级的 package 中再创建一个 domain,这样做的好处是不需要改动现有代码,domain 与 service 可以共存

TODO::

也可以设计得更复杂,但成本较高,一般建议在新的、核心的项目中使用

TODO::

核心概念的落地

实体

以标识作为其基本定义的对象称为实体 - Eric Evans

实体至少有两个字段:唯一标识 id 和 负责该实体持久化工作的 DAO

服务端比较流行 ORM 框架(如 MyBatis),这些框架操作的数据对象(DO)往往是一些纯数据模型,不适合将实体与 DO 混用,最好是以实体依赖 DO 的方式设计,将 DO 定位为存储实体属性的承载者

最终得到一个最小的实体定义如下:

public class User {
    private Long id;
    private UserDAO dao;

    public User(Long id, UserDAO dao) {
        this.id = id;
        this.dao = dao;
    }

    public UserDO data() {
        return this.dao.selectById(this.id);
    }

    public String sayhello() {
        return this.data().getName() + " say: hello.";
    }
}

使用时的代码如下

User user = new User(1L);
user.data();
user.sayhello();

实际项目中,实体往往还有很多其它依赖,而且随着业务的发展依赖还会不断增多。依赖多了以后,实体的构造就成了一个大问题,不可能再以 new 的方式去创建实体。因此,需要引入工厂的概念,这将在下文讨论

实体与数据对象的关系

初学者的经常存在一个误区是,喜欢把实体与数据对象(或者说数据库表设计)一一对应起来。这种观念一定要纠正过来,在这里给出的建议是:实体的设计要更多从业务概念的角度去考虑(比如从技术的角度可能会细分出 user_info, user_ext, user_login_history 等等数据对象,但从业务的角度其实都应该是对应到用户这一个实体)。

常见的有以下情况

  • 一个实体的属性拆分为多张表存储
  • 实体之间的关联关系信息
public class UserDAO {
    private UserInfoMapper userInfoMapper;
    private UserExtMapper userExtMapper;
    private UserLoginHistoryMapper userLoginHistoryMapper;

    public UserDO selectById(Long id) {
        build(
            userInfoMapper.selectById(id),
            userExtMapper.selectById(id),
            userLoginHistoryMapper.selectById(id),
        )
    }
}

多封装一层 DAO 可以屏蔽持久化的底层实现,方便后续替换(比如某天要对现有服务进行拆分,只需将 mapper 替换成 feign client 即可),但同时也会增加工作量。

一种更加简便的方式是直接在实体中引用 mapper 进行操作。

public class User {
    private Long id;
    private UserInfoMapper userInfoMapper;
    private UserExtMapper userExtMapper;
    private UserLoginHistoryMapper userLoginHistoryMapper;

    public UserDO data() {
        ...
    }
}

此外,出于高内聚考虑,实体不应该直接操作不属于自己管辖的数据对象。如,User 不应该通过 RoleMapper 直接操作 RoleDO

引用

考虑到在服务端开发中,有状态的对象朝生夕灭的情况非常常见(服务端要管理的对象非常多,不可能将所有实体都存在内存中,一般一个请求过来时会创建对象,请求结束后在下一次 GC 这个对象就会被销毁),而实体之间的关联可能是非常复杂的,每次使用时都构建一个完整的聚合非常不划算,比较建议实体间的聚合采用软关联的方式

可以看到以下两种方式的区别:

硬关联
public class User {
    private Long id;
    private List<Role> roles;

    public User(Long id, List<Role> roles) {
        this.id = id;
        this.roles = roles;
    }

    public List<Role> listAllRoles() {
        return this.roles;
    }
}
软关联
public class User {
    private Long id;
    private RoleRepository roleRepo;

    public User(Long id, RoleRepository roleRepo) {
        this.id = id;
        this.roleRepo = roleRepo;
    }

    public List<Role> listAllRoles() {
        return this.roleRepo.listAllByUserId(this.getId());
    }
}

两者在使用方式并没有区别

for (Role role : user.listAllRoles()) {
    role.dosomething()
}

工厂

虽然在上面我们采用了软关联的方式建立实体之间的引用关系,但这并不代表要构建一个实体就非常简单了,原因是我们的实体除了依赖其它实体外,往往还需要依赖许多其它对象(如领域服务、仓储、DAO 等),并且随着业务的变化,实体的依赖往往还会随之发生变化,如果还是通过传统的 new 方式去创建一个实体,会产生一些灾难性的问题:

  • 使用者必须清楚实体的创建细节,这会大大增加代码的复杂度
  • 每当实体的构造方式发生变化时,不得不调整所有创建实体的代码逻辑以解决代码编译问题

这个时候就需要引入工厂(Factory)的概念了,一个通用 Factory 的实现示例如下

@Component
public abstract class Factory {
    @Autowired
    private static UserRepository userRepo;

    public User getUser(Long id) {
        return new User(id, userRepo);
    }
}

结合 Spring,可以把实体的依赖注入做得更简单,并且在实体 User 的依赖变化后不需要做任何代码变更

public abstract class Factory {
    public User getUser(Long id) {
        User user = new User(id);
        SpringUtils.inject(user);       // 结合 AOP 可以进一步简化装配逻辑
        return user;
    }
}

实体仓储(Repository) TODO::

仓储可以为使用者提供实体的创建、删除及条件查询操作。在 C/S 中,仓储还要负责内存中的实体的更新(保证数据一致性)及缓存管理(防止频繁地重复创建,影响性能)

在 B/S 架构中,我们将实体与数据对象分离,因此数据一致性问题交给 ORM 去控制即可,仓储

仓储往往依赖 DAO(查询持久化数据)及工厂(创建实体),并且应可以发布领域事件。


领域服务

领域服务用于处理一些在概念上不属于实体的操作,这些操作本质上往往是一些活动或行为,并且是无状态的。对于这类操作,将其强制进行归类会显得非常别扭,于是便引入了领域服务这一概念。

需要明确的是,其与三层架构的 Service 层(业务逻辑层)并不是一个概念。另外与 Evans 在书中提及的示例不同,为了避免混乱,一般不建议为领域服务的类命名加上 Service 后缀。
可以简单理解为,领域中没有任何实体适合承载该职责,因此我们创造了一个『实体』来承担,只不过这个特殊的『实体』是一个无状态的单例而以。

示例

在某个应用中,由于用户量较大,用户登录历史可能会逐渐变得非常庞大,因此需要需要定时查找超过 15 天的记录并将其清理。

显然,我们需要完成的这个操作无法归类到任何一个实体中,因此我们需要一个名为 LoginHistoryClearer 的领域服务来承接此职责

@Compoment
public class LoginHistoryClearer {
    private UserRepository userRepo;

    public void clearOutDated(Integer interval) {
        for (User user : userRepo.listAll()) {
            user.removeOutDatedLoginHistory(interval);
        }
    }
}

在其它地方,我们可以直接注入该领域服务,并使用

@Slf4j
@Component
public class UserScheduledTask {
    @Autowired
    private LoginHistoryClearer clearer;

    @Value("${exec.output.interval.days:15}")
    private Integer intervalDays;

    @Scheduled(cron = "0 0 0 * * ?")
    public void deleteExecData() {
        log.info("starting clear out dated data, intervalDays=>{}", intervalDays);
        clearer.clearOutDated(intervalDays);
        log.info("clear out dated data end");
    }
}

领域事件

在我们的领域活动(实体、仓储等操作)中会出现一系列的重要的事件,而这些事件的订阅者,往往需要对这些事件作出响应(例如,新增用户后,可能会触发一系列动作:发送欢迎信息、发放优惠券等等)。领域事件可以简单地理解为是发布订阅模式在 DDD 中的一种运用。

在我们的实践中,一般采用事件总线来快速地发布一个领域事件。

事件总线的接口定义一般如下

public interface EventBus {
    void post(Event event);
}

通过调用 EventBus.post() 方法,我们可以快速发布一个事件。

同时我们还会提供一个抽象类 AbstractEventPublisher

public class AbstractEventPublisher implements EventPublisher {
    private EventBus eventBus;

    public void setEventBus(EventBus eventBus) {
        this.eventBus = eventBus;
    }

    @Override
    public void publish(Event event) {
        if (eventBus != null) {
            eventBus.post(event);
        } else {
            log.warn("event bus is null. event " + event.getClass() + " will not be published!");
        }
    }
}
public interface EventPublisher {
    void publish(Event event);
}

这样我们可以让实体或 Manager 继承自 AbstractEventPublisher,其便有了发布事件的能力。至于如何订阅并处理这些事件,取决于 EventBus 的实现方式。举个例子,我们一般使用 Guava 的 EventBus,定义相关的 handler 并注册到 EventBus 中便可方便地处理这些事件

@Component
public class DomainEventBus extends EventBus implements InitializingBean {
    @Autowired
    private FooEventHandler fooEventHandler;

    @Override
    public void afterPropertiesSet() {
        this.register(fooEventHandler);
    }
}

@Component
@Slf4j
public class FooEventHandler implements DomainEventHandler {
    @Override
    @Subscribe
    public void listen(ProjectCreatEvent e) {
        // do something here...
    }
}

其它
TODO::
● ddd-support

DDD 设计

理解了 DDD 中的全部概念,也并不意味着就能做出一个好的设计了。

DDD 的设计最重要的是做好以下几点:

  1. 准确地定义实体
  2. 准确地定义实体应该有哪些方法
  3. 确立实体与实体之间的关系

实体的设计其实是一个建模的过程。面向对象的设计方法本质就是将现实世界的对象关系以简化的形式提炼为模型。关于这一块,已经是一个更大的话题了,不在这里讨论。

案例工程
TODO::

其它

与现有三层架构是否冲突

并不冲突, 甚至是可以混用的

事务问题如何解决

实体操作是可以兼容 JDBC 事务,在编排实体的应用层中加上 Spring 事务注解 @Transaction 即可

重复查询的性能问题

如下,会执行三次数据库查询

log.debug(user.data());
log.info(user.data());
log.warn(user.data());

实际项目中,应使用带缓存的 ORM 框架(如 MyBatis),这样便可避免同一事务中重复查询带来的性能问题。否则应注意优化编码方式,如下

UserDO data = user.data();
log.debug(data);
log.info(data);
log.warn(data);

复杂查询场景下的性能问题

DDD 要求将数据对象转换为内存中的实体对象后再进行业务操作,这注定了 DDD 不擅长批量查询以及联表查询等复杂的查询场景。业界采用的方案是 CQRS 模式,将查询操作从领域模型中分离出去。

要实现也很简单,有查询业务时,直接定义一个相应的 Service,在里面操作数据库完成查询即可。

更复杂一点,可以专门为查询操作开一个微服务,实现物理上的分离。

示例如下

不分离,会存在对 dto, vo 等对象的直接引用,导致领域模型被污染

public class User {
    public List<UserLoginHistoryVO> queryLoginHistory(QueryDTO dto) {
        return dao.queryLoginHistory(...)
    }
}

分离后,查询操作转移到专门的查询 Service 中

public class UserQueryService {
    public List<UserLoginHistoryVO> queryLoginHistory(QueryDTO dto) {
        return dao.queryLoginHistory(...)
    }
}

经常听到一些程序猿加班是常态的言论,在这里我想尝试下进行反驳。

先来分析一下这个命题:

程序猿加班是常态 => 程序猿的工作量巨大 => 软件系统过于复杂,难以处理。

可见,反驳这个命题的关键就在于回答『如何治理一个复杂的软件系统』这个问题。

接下来深入看下这个问题。软件系统是什么?我认为软件系统无非是一个巨大的逻辑体。你的任何操作(如点击 web 的一个按钮),其实背后都是一层又一层的逻辑堆砌的结果。

以浏览器渲染一个按钮(<button>click</button>)为例,这其实是浏览器解析 HTML 代码的结果。HTML 本身是有逻辑可循的,你必须要按照他的规则编写才能正确地展示 UI,而 HTML 本身又需要浏览器进行解析,这个过程需要用到许多更低一层的逻辑(比如语法解释器,比如图形渲染的接口),这些被依赖的逻辑本身又会依赖更底层的逻辑(比如显卡接口)。 这些逻辑一层嵌一层的,直至最底层的 CPU 指令集。

因此任何一个复杂系统,本质上其实就是一个庞大的逻辑体。而这个逻辑体,必然是分层的(你见过谁直接用 GPU 的接口来渲染网站吗),否则描述它的复杂度就是指数级增长了,以人脑的计算能力很快就无法处理这么复杂的系统了(这就好比在概率论里面,让你连续抛掷10次硬币,再用文字表示其样本空间,你要是用枚举法来表示,可能得写到手软,但要是用笛卡尔积来表示,只需要一行即可)。

整个网站开发,其实也是分层的,其中最经典的莫过于三层架构了(视图层、业务逻辑层、持久层),演化到今天,视图层独立出去成了前端领域,业务逻辑与持久层则归为后端领域(当然,还有更底层的东西,比如说数据库,操作系统,只是大多数开发仔都不需要参与这一块的开发)。

看起来很完美,前人已经把分层都设计好了,并且那些高难度的底层开发工作往往也有开源组织在承担,稳定性都是经过验证的。我们做应用开发的,只要搞定视图层(前端)跟业务逻辑层(后端)不就好了吗?这点事情都搞不定吗?问题究竟出在哪呢?

其一是,单独某一层内的复杂度,其实很多时候是超乎我们想象的,因此需要进一步分层

以业务逻辑层为例,一个简单的用户注册,可能就包括了验证码校验,密码复杂度检测,手机绑定,注册成功欢迎邮件发送,发放优惠券等等业务逻辑,以及缓存、冗余之类的非业务逻辑。这些被依赖的逻辑其实也是同属于业务逻辑层的,并且也会被其他业务逻辑依赖,一旦没有做好划分,那又是重复造轮子,复杂度和工作量都会指数级增长!关于这一块,不同领域的解决方案不同,前端是组件化,而后端是面向对象(虽然是很标准的答案,但我认为真正掌握这两技能的研发同学其实很少,最典型的莫过于拿着面向对象语言,写着面向过程代码),非要抽象出一个更通用的方案的话,我认为可以称之为『建模』。前端通过建模,将视图层进一步分拆出组件层;后端通过建模,将业务逻辑层进一步分拆出领域模型层(在微服务架构中亦称之为业务中台)。

其二则是工程问题了,即沟通成本的问题。举个例子,HTML 语言提供了 button 标签,但这个标签除了渲染一个按钮外,还会同时渲染一个文本输入框给我们。这显然不是我们需要的,好在这个标签同时提供了一个配置给我们,可能显式地指定避免渲染文本框(额外的工作是你需要指定该配置 <button disable-input='true'>click</button>)。这样子虽然最终实现了想要的效果,但却让代码变得不优雅,不仅你花费了不必要的时间,还导致后来的维护者看到会迷惑,沟通成本由此产生。 尝试把这个现象无限放大,如果你在代码中看到的每一个概念定义都是不可信的,都是与现实世界有偏差的,那么当你要解决一个问题或开发一个新功能的时候,你工作中花在的这些无谓的沟通中的时间就会成倍地提升(尤其是当没有人或文档能解答你的问题的时候),最终远远大于你的编码时间。

问题分析到这,答案也就出来了。如何治理一个复杂的软件系统?私以为关键在于做好模型设计,以及维护好概念一致性。前者可以通过组合逻辑空间的不同维度,使得复杂度的表示难度指数下降,最终达到人脑可以处理的水平。后者则保证模型中的概念跟现实世界是一致的,易于理解,可减少不必要的沟通成本。两者任一没有处理好,均会导致复杂度指数上升,进而导致工作量增大,因此程序猿加班也就成了常态了。经常加班的猿们,都应该先反问下自己,这两点是不是都做得足够好了?如果是,再来问是不是人手不够之类的问题。

当然现实往往更复杂,很多同学都会以业务方不给时间,上级不支持为由,写出一堆烂代码来。确实这也是一些阻力,但并不能成为人云亦云的借口。有这种想法的同学,要么是选择躺平了,不想去改变现状;要么是缺乏对事物发展规律的认识的,推荐学习一下教员的《矛盾论》。

Spring Boot Starter Swagger

简介

Swagger与Spring Boot现在在Java Web开发领域是再常用不过的两个框架了。集成这两者的Starter现在在Github上也存在很多(基本都是非官方的,官方好像没有提供Starter),但是大多数或多或少都存在以下问题:

  • 整合程度低,许多Springfox-Swagger2提供的功能无法通过Spring Boot的方式进行配置或者配置方式复杂
  • 项目疏于维护,许多Issue没人去解决
  • 扩展性不足,当用户出现一些需求时只能祈求项目更新或者自行修改源码
  • 无法同时兼容Spring Boot1.x和Spring Boot2.x

我曾在Github上找了许久都没找到,索性自行开发了一个。

这个Starter具有以下特点:

  • 完美适配springfox-swagger2,几乎支持通过yaml文件进行所有配置
  • 提供拦截器,允许用户自行扩展自定义配置
  • 同时支持spring boot1和spring boot2
  • 扩展了一些小功能,如展示当前hostname等

项目地址

如何使用

引入依赖

pom.xml

<!-- spring boot1用户 -->
<dependency>
    <groupId>com.github.taccisum</groupId>
    <artifactId>swagger-spring-boot1-starter</artifactId>
    <version>{lastest.version}</version>
</dependency>

<!-- spring boot2用户 -->
<dependency>
    <groupId>com.github.taccisum</groupId>
    <artifactId>swagger-spring-boot2-starter</artifactId>
    <version>{lastest.version}</version>
</dependency>

application.yml

swagger:
  base-package: com.github.taccisum.controller

启动项目,打开 http://localhost:8080/swagger-ui.html 即可查看API文档。

更多功能可以到 https://github.com/taccisum/spring-boot-starter-swagger 了解。

简介

flowable engine一共有5种:App, CMMN, DMN, Form, Process(即BPMN)。

flowable-spring-boot-starter提供了零配置集成flowable的功能,主要包括各类型engine的自动配置、流程/表单定义自动部署,其次还有rest-api,spring boot aucuator集成等。

这篇文章主要是解析flowable在spring boot环境下的启动流程,不涉及flowable内部原理。

flowable相关的AutoConfiguration

# flowable-spring-boot-autoconfigure: spring.factories

org.springframework.boot.env.EnvironmentPostProcessor=\
  org.flowable.spring.boot.environment.FlowableDefaultPropertiesEnvironmentPostProcessor,\
  org.flowable.spring.boot.environment.FlowableLiquibaseEnvironmentPostProcessor

# Flowable auto-configurations

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    org.flowable.spring.boot.actuate.info.FlowableInfoAutoConfiguration,\
    org.flowable.spring.boot.EndpointAutoConfiguration,\
    org.flowable.spring.boot.RestApiAutoConfiguration,\
    org.flowable.spring.boot.app.AppEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.app.AppEngineAutoConfiguration,\
    org.flowable.spring.boot.ProcessEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.ProcessEngineAutoConfiguration,\
    org.flowable.spring.boot.FlowableJpaAutoConfiguration,\
    org.flowable.spring.boot.form.FormEngineAutoConfiguration,\
    org.flowable.spring.boot.form.FormEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.content.ContentEngineAutoConfiguration,\
    org.flowable.spring.boot.content.ContentEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.dmn.DmnEngineAutoConfiguration,\
    org.flowable.spring.boot.dmn.DmnEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.idm.IdmEngineAutoConfiguration,\
    org.flowable.spring.boot.idm.IdmEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.cmmn.CmmnEngineAutoConfiguration,\
    org.flowable.spring.boot.cmmn.CmmnEngineServicesAutoConfiguration,\
    org.flowable.spring.boot.ldap.FlowableLdapAutoConfiguration,\
    org.flowable.spring.boot.FlowableSecurityAutoConfiguration

看起来比较多,不过大多数AutoConfiguration逻辑还是比较简单的。

engine自动配置

各engine的配置方式大同小异,以ProcessEngine为例,其涉及到的AutoConfiguration主要是

  • ProcessEngineAutoConfiguration
  • ProcessEngineServicesAutoConfiguration

ProcessEngineAutoConfiguration

ProcessEngineAutoConfiguration类图

ProcessEngineAutoConfiguration

从红框部分可以看到,在ProcessEngineAutoConfiguration中配置了一个类型为SpringProcessEngineConfiguration的bean,其类图如下

SpringProcessEngineConfiguration

可知,SpringProcessEngineConfiguration是ProcessEngineConfiguration的子类,而ProcessEngineConfiguration正是用于创建ProcessEngine的类。

不过这里只是注册了一个bean,并没有调用其buildProcessEngine()方法来创建ProcessEngine。ProcessEngine实例是在ProcessEngineFactoryBean中创建的。

ProcessEngineServicesAutoConfiguration

这个AutoConfiguration主要负责配置ProcessEngineFactoryBean及各个Service(RuntimeService, RepositoryService, TaskService等)。

各Service的配置比较简单,主要来看看ProcessEngineFactoryBean

ProcessEngineFactoryBean

需要注意的是ProcessEngineFactoryBean是在内部类ProcessEngineServicesAutoConfiguration#StandaloneEngineConfiguration中注册的。

这是一个FactoryBean,我们知道Spring通过FactoryBean的getObject()方法来创建bean,来看看其代码

// ProcessEngineFactoryBean.java
public class ProcessEngineFactoryBean implements FactoryBean<ProcessEngine>, DisposableBean, ApplicationContextAware {
    protected ProcessEngineConfigurationImpl processEngineConfiguration;
    @Override
    public ProcessEngine getObject() throws Exception {
        // 省略无关代码...
        this.processEngine = processEngineConfiguration.buildProcessEngine();
        return this.processEngine;
    }
}

可以看到,ProcessEngineFactoryBean通过调用processEngineConfiguration.buildProcessEngine()创建了ProcessEngine的实例。

对于processEngineConfiguration这个对象的构建,可以参考ProcessEngineAutoConfiguration

自动部署

flowable spring boot starter能够自动将classpath的相关目录(如processes, forms)下的资源自动部署。

不同的engine逻辑大同小异,以ProcessEngine为例,其核心在于SpringProcessEngineConfiguration这个类。

SpringProcessEngineConfiguration实现了spring的SmartLifecycle接口,相关代码如下

// SpringProcessEngineConfiguration.java
@Override
public void start() {
    synchronized (lifeCycleMonitor) {
        if (!isRunning()) {
            // 遍历engines实例进行部署
            enginesBuild.forEach(name -> autoDeployResources(ProcessEngines.getProcessEngine(name)));
            running = true;
        }
    }
}

@Override
public void stop() {
    synchronized (lifeCycleMonitor) {
        running = false;
    }
}

@Override
public boolean isRunning() {
    return running;
}

其中start方法正是对process engines所需要的资源进行自动部署,会在spring应用完成初始化后进行回调。

来看看autoDeployResources方法

// SpringProcessEngineConfiguration.java
protected Resource[] deploymentResources = new Resource[0];

protected void autoDeployResources(ProcessEngine processEngine) {
    if (deploymentResources != null && deploymentResources.length > 0) {
        final AutoDeploymentStrategy strategy = getAutoDeploymentStrategy(deploymentMode);      // 选择部署策略
        strategy.deployResources(deploymentName, deploymentResources, processEngine.getRepositoryService());
    }
}

字段deploymentResources的值是关键,通过调试,发现该字段是在ProcessEngineAutoConfiguration中进行赋值的

// ProcessEngineAutoConfiguration
@Bean
@ConditionalOnMissingBean
public SpringProcessEngineConfiguration springProcessEngineConfiguration(DataSource dataSource, PlatformTransactionManager platformTransactionManager,
        @Process ObjectProvider<IdGenerator> processIdGenerator,
        ObjectProvider<IdGenerator> globalIdGenerator,
        @ProcessAsync ObjectProvider<AsyncExecutor> asyncExecutorProvider,
        @ProcessAsyncHistory ObjectProvider<AsyncExecutor> asyncHistoryExecutorProvider) throws IOException {

    SpringProcessEngineConfiguration conf = new SpringProcessEngineConfiguration();

    // 根据配置的规则找到相关的资源
    List<Resource> resources = this.discoverDeploymentResources(
        flowableProperties.getProcessDefinitionLocationPrefix(),
        flowableProperties.getProcessDefinitionLocationSuffixes(),
        flowableProperties.isCheckProcessDefinitions()
    );

    if (resources != null && !resources.isEmpty()) {
        conf.setDeploymentResources(resources.toArray(new Resource[0]));
        conf.setDeploymentName(flowableProperties.getDeploymentName());
    }

    // ...省略无关代码

    return conf;
}

TODO LIST

  • List<EngineConfigurationConfigurer>

介绍

zuul支持用两种语言编写的过滤器,分别是Groovy和Java。不过只有Groovy编写的过滤器才支持动态加载。

所谓动态加载,即是可以在应用的运行时对filter进行CRUD的操作,以达到动态调整zuul行为的目的。

以下我们看看zuul是如何实现这一点的。

FilterLoader

// FilterLoader.java
    /**
     * 获取所有指定类型的filter并排序,通过ZuulFilter.filterOrder()方法
     * Returns a list of filters by the filterType specified
     */
    public List<ZuulFilter> getFiltersByType(String filterType) {
        // 尝试从缓存中获取,如果存在缓存,则直接返回
        List<ZuulFilter> list = hashFiltersByType.get(filterType);
        if (list != null) return list;

        list = new ArrayList<ZuulFilter>();

        // 从registry获取所有filter,从中找出需要的filter,最终进行排序
        Collection<ZuulFilter> filters = filterRegistry.getAllFilters();
        for (Iterator<ZuulFilter> iterator = filters.iterator(); iterator.hasNext(); ) {
            ZuulFilter filter = iterator.next();
            if (filter.filterType().equals(filterType)) {
                list.add(filter);
            }
        }
        Collections.sort(list); // sort by priority

        // 将结果缓存起来
        hashFiltersByType.putIfAbsent(filterType, list);
        return list;
    }

从上面代码可以看到,getFiltersByType()依赖于对象filterRegistry,zuul正是通过对该registry的CRUD实现动态加载filter的功能。

filterRegistry是FilterRegistry的一个唯一实例(单例模式)。FilterRegistry的代码很简单,就是简单地维护了一个用于存放filter的ConcurrentHashMap而以。关键需要找出zuul是如何维护这份registry的。

FilterScriptManagerServlet

需要在运行时维护registry,必然需要有一个入口。这个入口就是FilterScriptManagerServlet,这是一个HttpServlet,提供了以下HTTP资源:

路径 请求方式 Servlet对应的处理方法 描述
LIST GET handleListAction 获取filter脚本
DOWNLOAD GET handleDownloadAction 下载脚本
UPLOAD PUT/POST handleUploadAction 上传脚本
ACTIVATE PUT/POST handleActivateAction 启用脚本
CANARY PUT/POST handleCanaryAction TODO::还不知道干啥用的
DEACTIVATE PUT/POST handledeActivateAction 禁用脚本