2023年3月

你好呀,我是歪歪。

是的,正如标题描述的这样,我试图通过这篇文章,教会你如何阅读源码。

事情大概是这样的,前段时间,我收到了一个读者发来的类似于这样的示例代码:

他说他知道这三个案例的回滚情况是这样的:

  • insertTestNoRollbackFor:不会回滚
  • insertTestRollback:会回滚
  • insertTest:会回滚

他说在没有执行代码之前,他也知道前两个为什么一个不会回滚,一个会回滚。因为抛出的异常和 @Transactional 里面的注解呼应上了。

但是第三个到底会不会回滚,没有执行之前,他不知道为什么会回滚。执行之后,回滚了,他也不知道为什么回滚了。

我告诉他:源码之下无秘密。

让他去看看这部分源码,理解它的原理,不然这个地方抛出一个其他的异常,又不知道会不会回滚了。

但是他说他完全不会看源码,找不到下手的角度。

所以,就这个问题,我打算写这样的一篇文章,试图教会你一种阅读源码的方式。让你找到一个好的切入点,或者说突破口。

但是需要事先说明的是,阅读源码的方式非常的多,这篇文章只是站在我个人的角度介绍阅读源码的众多方式中的一种,沧海一粟,就像是一片树林里面的一棵树的树干上的一叶叶片的叶脉中的一个小分叉而已。

对于啃源码这件事儿,没有一个所谓的“一招吃遍天下”的秘诀,如果你非要让我给出一个秘诀的话,那么就只有一句话:

啃源码的过程,一定是非常枯燥的,特别是啃自己接触不多的框架源码的时候,千头万绪,也得下手去捋,所以一定要耐得住寂寞才行。

然后,如果你非得让我再补充一句的话,那么就是:

调试源码,一定要亲!自!动!手!只是去看相关的文章,而没有自己一步步的去调试源码,那你相当于看了个寂寞。

亲自动手的第一步就是搞个 Demo 出来。用“黑话”来说,这个 Demo 就是你的抓手,有了抓手你才能打出一套理论结合实际的组合拳。抓手多了,就能沉淀出可复用的方法论,最终为自己赋能。


搭建 Demo

所以,第一步肯定是先把 Demo 给搭建起来,项目结构非常的简单,标准的三层结构:

主要是一个 Controller,一个 Service,然后搞个本地数据库给接上,就完全够够的了:

Student 对象是从表里面映射过来的,随便弄了两个字段,主要是演示用:

就这么一点代码,给你十分钟,你是不是就能搭建好了?中间甚至还能摸几分钟鱼。

要是只有这么一点东西的、极其简单的 Demo 你都不想自己亲自动手搭一下,然后自己去调试的话,仅仅是通过阅读文章来肉眼调试,那么我只能说:

在正式开始调试代码之前,我们还得明确一下调试的目的:想要知道 Spring 的 @Transactional 注解对于异常是否应该回滚的判断逻辑具体是怎么样的。

带着问题去调试源码,是最容易有收获的,而且你的问题越具体,收获越快。你的问题越笼统,就越容易在源码里面迷失。


方法论之关注调用栈

自己 Debug 的过程就是不断的打断点的过程。

我再说一次:自己 Debug 的过程就是不断的打断点的过程。

打断点大家都会打,断点打在哪些地方,这个玩意就很讲究了。

在我们的这个 Demo 下,第一个断点的位置非常好判断,就打在事务方法的入口处:

一般来说,大家调试业务代码的时候,都是顺着断点往下调试。但是当你去阅读框架代码的时候,你得往回看。

什么是“往回看”呢?

当你的程序在断点处停下的时候,你会发现 IDEA 里面有这样的一个部分:

这个调用栈是你在调试的过程中,一个非常非常非常重要的部分。

它表示的是以当前断点位置为终点的程序调用链路。

为了让你彻底的明白这句话,我给你看一张图:

我在 test6 方法中打上断点,调用栈里面就是以 test6 方法为终点到 main 方法为起点的程序调用链接。

当你去点击这个调用栈的时候,你会发现程序也会跟着动:

“跟着动”的这个动作,你可以理解为你站着断点处“往回看”的过程。

当你理解了调用栈是干啥的了之后,我们再具体看看在当前的 Demo 下,这个调用栈里面都有写啥:

标号为 ① 的地方,是 TestController 方法,也就是程序的入口。

标号为 ② 的地方,从包名称可以看出是 String AOP 相关的方法。

标号为 ③ 的地方,就可以看到是事务相关的逻辑了。

标号为 ④ 的地方,是当前断点处。

好,到这里,我想让你简单的回顾一下你来调试代码的目的是什么?

是不是想要知道 Spring 的 @Transactional 注解对于异常是否应该回滚的判断逻辑具体是怎么样的。

那么,我们是不是应该主要把关注的重点放在标号为 ③ 的地方?

也就是对应到这一行:

这个地方我一定要特别的强调一下:要保持目标清晰,很多人在源码里面迷失的原因就是不知不觉间被源码牵着走远了。

比如,有人看到标号为 ② 的部分,也就是 AOP 的部分,一想着这玩意我眼熟啊,书上写过 Spring 的事务是基于 AOP 实现的,我去看看这部分代码吧。

当你走到 AOP 里面去的时候,路就开始有点走偏了。你明白我意思吧?

即使在这个过程中,你翻阅了这部分的源码,确实了解到了更多的关于 AOP 和事务之间的关系,但是这个部分并不解决你“关于回滚的判断”这个问题。

然而更多更真实的情况可能是这样的,当你点到 AOP 这部分的时候,你一看这个类名称是 CglibAopProxy:

你一细嗦,Cglib 你也熟悉啊,它和 JDK 动态代理是一对好兄弟,都是老八股了。

然后你可能又会点击到 AopProxy 这个接口,找到 JdkDynamicAopProxy:

接着你恍然大悟:哦,我在什么都没有配置的情况下,当前版本的 SpringBoot 默认使用的是 Cglib 作为动态代理的实现啊。

诶,我怎么记得我背的八股文默认是使用 JDK 呢?

网上查一下,查一下。

哦,原来是这么一回事儿啊:

  • SpringBoot 1.x,默认使用的是 JDK 动态代理。
  • SpringBoot 2.x 开始,为了解决使用 JDK 动态代理可能导致的类型转化异常而默认使用 CGLIB。
  • 在 SpringBoot 2.x 中,如果需要默认使用 JDK 动态代理可以通过配置项spring.aop.proxy-target-class=false来进行修改,proxyTargetClass配置已无效。

刚刚提到了一个 spring.aop.proxy-target-class 配置,这是个啥,咋配置啊?

查一下,查一下...

喂,醒一醒啊,朋友,走远了啊。还记得你调试源码的目的吗?

如果你对于 AOP 这个部分感兴趣,可以先进行简单的记录,但是不要去深入的追踪。

不要觉得自己只是随便看看,不要紧。反正正是因为这些“随便看看”导致你在源码里面忙了半天感觉这波学到了,但是停下来一想:我 TM 刚刚看了些啥来着?我的问题怎么还没解决?

我为什么要把这部分非常详尽,甚至于接近啰嗦的写一遍,就是因为这个就是初看源码的朋友最容易犯的错误。

特别强调一下:抓住主要矛盾,解决主要问题。

好,回到我们通过调用栈找到的这个和事务相关的方法中:

org.springframework.transaction.interceptor.TransactionInterceptor#invoke

这个方法,就是我们要打第二个断点,或者说这才是真正的第一个断点的地方。

然后,重启项目,重新发起请求,从这个地方就可以进行正向的调试,也就是从框架代码一步步的往业务代码执行。

比如这个方法接着往下 Debug,就来到了这个地方:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

找到了这个地方,你就算是无限的接近于问题的真相了。

这个部分我肯定会讲的,但是在这里先按下不表,毕竟这并不是本文最重要的东西。

本文最重要的是,我再次重申一遍:我试图想要教会你一种阅读源码的方式,让你找到一个好的切入点,或者说突破口。

由于这个案例比较简单,所以很容易找到真正的第一个利于调试的断点。

如果遇到一些复杂的场景、响应式的编程、异步的调用等等,可能会循环往复的执行上面的动作。

分析调用栈,打断点,重启。

再分析调用栈,再打断点,再重启。


方法论之死盯日志

其实我发现很少有人会去注意框架打印的日志,就像是很少有人会去仔细阅读源码上的 Javadoc 一样。

但是其实通过观察日志输出,也是一个很好的寻找阅读源码突破口的方式。

我们要做的,就是保证 Demo 尽量的单纯,不要有太多的和本次排查无关的代码和依赖引入。

然后把日志级别修改为 debug:

logging.level.root=debug

接着,就是发起一次调用,然后耐着性子去看日志。

还是我们的这个 Demo,发起一次调用之后,控制台输出了很多的日志,我给你搞个缩略图看看:

我们已知的是这里面大概率是有线索的,有没有什么方法尽量快的找出来呢?

有,但是通用性不强。所以如果经验不够丰富的话,那么最好的方法就是一行行的去找。

前面我也说过了:啃源码的过程,一定是非常枯燥的。

所以你一定会找到这样的日志输出:

Acquired Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] to manual commit
...
==>  Preparing: insert into student ( name, home ) values ( ?, ? ) 
HikariPool-1 - Pool stats (total=1, active=1, idle=0, waiting=0)
==> Parameters: why(String), 草市街199号-insertTestNoRollbackFor(String)
<==    Updates: 1
...
Committing JDBC transaction on Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c]
Releasing JDBC Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] after transaction

这几行日志,不就是正对应着 Spring 事务的开启和提交吗?

有了日志,我们完全可以基于日志去找对应的日志输出的地方,比如我们现在要找这一行日志输出对应的代码:

o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] for JDBC transaction

首先,我们可以根据日志知道对应输出的类是 DataSourceTransactionManager 这个类。

然后找到这个类,按照关键词搜索:

不就找到这一行代码了吗?

或者我们直接秉承大力出奇迹的真理,来一个暴力的全局搜索,也是能搜到这一行代码的:

再或者修改一下日志输出格式,把行号也搞出来嘛。

当我们把日志格式修改为这样之后:

logging.pattern.console=%d{dd-MM-yyyy HH:mm:ss.SSS} %magenta([%thread]) %highlight(%-5level) %logger.%M:%L - %msg%n

控制台的日志就变成了这样:

org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin:263 - Acquired Connection [HikariProxyConnection@1569067488 wrapping com.mysql.cj.jdbc.ConnectionImpl@19a49539] for JDBC transaction

很直观的就看出来了,这行日志是 DataSourceTransactionManager 类的 doBegin 方法,在 263 行输出的。

然后你找过去,发现没有任何毛病,这就是案发现场:

我前面给你说这么多,就是为了让你找到这一行日志输出的地方。

现在,找到了,然后呢?

然后肯定就是在这里打断点,然后重启程序,重新发起调用了啊。

这样,你又能得到一个调用栈:

然后,你会从调用栈中看到一个我们熟悉的东西:

朋友,这不就和前面写的“方法论之关注调用栈”呼应起来了吗?

这不就是一套组合拳吗,不就是沉淀出的可复用的方法论吗?

黑话,咱们也是可以整两句的。


方法论之查看被调用的地方

除了前面两种方法之外,我有时候也会直接看我要阅读部分的方法,在框架中被哪些地方调用了。

比如在我们的 Demo 中,我们要阅读的代码非常的明确,就是 @Transactional 注解。

于是直接看一下这个注解在哪些地方用到了:

有的时候调用的地方会非常的少,甚至只有一两处,那么直接在调用的地方打上断点就对了。

虽然 @Transactional 注解一眼望去也是有很多的调用,但是仔细一看大多是测试类。排除测试类、JavaDoc 里面的备注和自己项目中的使用之后,只剩下很明显的这三处:

看起来很接近真相,但是很遗憾,这里只是在项目启动的时候解析注解而已。和我们要调研的地方,差的还有点远。

这个时候就需要一点经验了,一看苗头不对,立马转换思路。

什么是苗头不对呢?

你在这几个地方打上断点了,只是在项目启动的过程中断点起作用了,发起调用的时候并没有在断点处停下,说明发起调用的时候并不会触发这部分逻辑,苗头不对。

顺着这个思路想,在我的 Demo 中抛出了异常,那么 rollbackFor 和 noRollbackFor 这两个参数大概率是会在调用的时候被用到,对吧?

所以当你去看 rollbackFor 被调用的时候只有我们自己写的业务代码在调用:

怎么办呢?

这个时候就要靠一点运气了。

是的,靠运气。

你都点到 rollbackFor 这个方法来了,你也看了它被调用的地方,在这个过程中你大概率会瞟到几眼它对应的 JavaDoc:

org.springframework.transaction.annotation.Transactional#rollbackFor

然后你会发现在 JavaDoc 里面提到了 rollbackOn 这个方法:

org.springframework.transaction.interceptor.DefaultTransactionAttribute.rollbackOn(Throwable)

到这里一看,你发现这是一个接口,它有好多个实现类:

怎么办呢?

早期的时候,由于不知道具体的实现类是哪个,我是在每个实现类的入口处都打上断点,虽然是笨办法,但是总是能起作用的。

后来我才发现,原来可以直接在接口上打断点:

然后,重启项目,发起调用,第一次会停在我们方法的入口:

F9,跳过当前断点之后,来到了这个地方:

这里就是我前面在接口上打的方法断点,走到了这个实现类中:

org.springframework.transaction.interceptor.DelegatingTransactionAttribute

然后,关键的就来了,我们又有一个调用栈了,又从调用栈中看到一个我们熟悉的东西:

朋友,组合拳这不又打起来了?突破口不就又找到了?

关于“瞟到几眼对应的 JavaDoc ,然后就可能找到突破口”的这个现象,早期对我来说确实是运气,但是现在已经是一个习惯了。一些知名框架的 JavaDoc 真的写的很清楚的,里面隐藏了很多关键信息,而且是最权威的正确信息,读官网文档,比读技术博客稳当的多。


探索答案

前面我介绍的都是找到代码调试突破口的方法。

现在突破口也有了,接下来应该怎么办呢?

很简单,调试,反复的调试。从这个方法开始,一步一步的调试:

org.springframework.transaction.interceptor.TransactionInterceptor#invoke

如果你真的想要有所收获的话,这是一个需要你亲自去动手的步骤,必须要有逐行阅读的一个过程,然后才能知道大概的处理流程。

我就不进行详细解读了,只是把重点给大家画一下:

框起来的部分,就是去执行业务逻辑,然后基于业务逻辑的处理结果,去走不同的逻辑。

抛异常了,走这个方法:completeTransactionAfterThrowing

正常执行完毕了,走这个方法:commitTransactionAfterReturning

所以,我们问题的答案就藏在 completeTransactionAfterThrowing 里面。

继续调试,进入这个方法之后,可以看到它拿到了事务和当前异常相关的信息:

在这个方法里面,大体的逻辑是当标号为 ① 的地方为 true 的时候,就在标号为 ② 的地方回滚事务,否则就在标号为 ③ 的地方提交事务:

因此,标号为 ① 的部分就很重要了,这里面就藏着我们问题的答案。

另外,在这里多说一句,在我们的案例中,这个方法,也就是当前调试的方法是不会回滚的:

而这个方法是会回滚的:

也就是这两个方法在这个地方会走不同的逻辑,所以你在调试的时候遇到 if-else 就需要注意,去构建不同的案例,以覆盖尽量多的代码逻辑。

继续往下调试,会进入到标号为 ① 的 rollbackOn 方法里面,来到这个方法:

org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn

这里,就藏着问题的终极答案,而且这里面的代码逻辑相对比较的绕。

核心逻辑就是通过循环 rollbackRules,这里面装的是我们在代码中配置的回滚规则,在循环体中拿 ex,也就是我们程序抛出的异常,去匹配规则,最后选择一个 winner:

如果 winner 为空,则走默认逻辑。如果是 RuntimeException 或者是 Error 的子类,就要进行回滚:

如果有 winner,判断 winner 是否是不用回滚的配置,如果是,则取反,返回 false,表示不进行回滚:

那么问题的冠军就在于:winner 怎么来的?

答案就藏着这个递归调用中:

一句话描述就是:看当前抛出的异常和配置的规则中的 rollbackFor 和 noRollbackFor 谁距离更近。这里的距离指的是父类和子类之间的关系。

比如,还是这个案例:

我们抛出的是 RuntimeException,它距离 noRollbackFor=RuntimeException.class 为 0。RuntimeException 是 Exception 的子类,所以距离 rollbackFor = Exception.class 为 1。

所以,winner 是 noRollbackFor,能明白吧?

然后,我们再看一下这个案例:

根据前面的“距离”的分析,NullPointerException 是 RuntimeException 的子类,它们之间的距离是 1。而 NullPointerException 到 Exception 的距离是 2:

所以,rollbackFor=RuntimeException.class 这个的距离更短,所以 winner 是 rollbackFor。

而把 winner 放到这个判断中,返回是 true:

return !(winner instanceof NoRollbackRuleAttribute);

所以,这就是它为什么会回滚的原因:

好了,到这里你有可能是晕的,晕就对了,去调试这部分代码,亲自摸一遍,你就搞的明明白白了。

最后,再给“死盯日志”的方法论打个补丁吧。

前面我说了,日志级别调整到 Debug 也许会有意外发现。现在,我要再给你说一句,如果 Debug 没有查到信息,可以试着调整到 trace:

logging.level.root=trace

比如,当我们调整到 trace 之后,就可以看到“ winner 到底是谁”这样的信息了:

当然了,trace 级别下日志更多了。

所以,来,再跟我大声的读一遍:

啃源码的过程,一定是非常枯燥的,特别是啃自己接触不多的框架源码的时候,千头万绪,也得下手去捋,所以一定要耐得住寂寞才行。


作业

我前面主要是试图教你一种阅读源码时,寻找突破点的技能。这个突破点,说白了就是第一个有效的断点到底应该打在哪里。

你用前面我教的方法,也能把 @Cacheable 和 @Async 都玩明白。因为它们的底层逻辑和 @Transactional 是一样的。

所以,现在布置两个作业。

拿着这套组合拳,去上手玩一玩 @Cacheable 和 @Async 吧,沉淀出属于自己的方法论。

@Cacheable:

@Async:

最后,再附上几个我之前写过的文章,里面也用到了前面提到的几个方法定位源码,老舒服了。有兴趣可以看看:

《我是真没想到,这个面试题居然从11年前就开始讨论了,而官方今年才表态。》

《确实很优雅,所以我要扯下这个注解的神秘面纱。》

《关于Request复用的那点破事儿。研究明白了,给你汇报一下。》

《千万千万不要在方法上打断点!太坑了!》

好了,本文就到这里啦。如果你觉得对你有一丝丝帮助的话,求个免费的赞,不过分吧?

大家好,我是三友~~

今天来讲一个可能看似没有用但是实际又有点用的一个小东西,那就是@Autowired支持注入哪些Bean的类型。

为啥要讲这个呢?

故事说起来可能就比较长了。

不过长话可以短说,仅仅就是突然想起来之前有一个妹子问过我这个问题!

微信公众号:三友的java日记


1、普通对象

这没什么好说的,大家都这么用的,比如需要用到UserService,直接@Autowired就可以了。

@Autowired
private UserService userService;


2、Collection及其子接口

除了支持注入一个单一的对象之外,@Autowired还支持注入一个Collection对象。

比如说,现在有个消息通知的接口
MessageNotifier

这种接口一般都会有不同的实现,比如说通过邮件通知,或者app,短信等等,所以就有多种实现,此时如果需要注入
MessageNotifier
,就可以使用注入Collection的方式,比如

@Autowired
private List<MessageNotifier> messageNotifiers;

不过这种方式有个规定,那就是注入的类型必须是Collection及其子接口,如果你直接注入一个
ArrayList
,那么此时是不支持的。


3、数组

同理,@Autowired可实现了注入一个数组的功能。

@Autowired
private MessageNotifier[] messageNotifiers;

代码如下:


4、Map

同样的,@Autowired还可以注入一个Map。

@Autowired
private Map<String, MessageNotifier> messageNotifierMap;

此时注入的map,key的类型就是bean的名称,这种方式可以配合策略模式使用。

不过,这种方式只支持注入的是Map接口,不支持子类型接口,代码如下。


5、@Lazy

当一个注入的字段加了@Lazy注解之后,那么此时就代表这个字段是延迟注入。

@Autowired
@Lazy
private MessageNotifier messageNotifier;

延迟注入并不是不注入,而是注入目标对象类型的代理对象,真正的目标是当需要用到的时候在创建。

如图所示,当注入的
MessageNotifier
时加了@Lazy注解,那么此时注入的其实是
MessageNotifier
的代理对象,而真正的
MessageNotifier
对象并没有创建,图中代理对象我称为
MessageNotifierProxy

由于注入的是对象是代理对象
MessageNotifierProxy
,那么真正被使用的就是
MessageNotifierProxy
,一旦调用了
MessageNotifierProxy
的方法,此时
MessageNotifierProxy
会去Spring容器中查找真正的
MessageNotifier
对象,然后再调用
MessageNotifier
对象的方法。

代码如下:

这就是@Lazy延迟注入的原理。并不是不注入,而是注入一个代理对象,可以理解为一个占位符,一个空壳子,先占着位置,等用到这个壳子的时候,这个壳子会去查找到真正的对象,调用真正对象的方法。

@Lazy的一个使用场景就是用来解决Spring无法处理的循环依赖场景,比如使用了@Async注解的循环依赖的场景,不了解的小伙伴可以看一下
@Async注解的坑,小心
这篇文章


6、Optional

Optional是JDK1.8提供的一个api,可以优雅的解决判空的问题。

@Autowired也支持了注入Optional类型。

@Autowired
private Optional<MessageNotifier> messageNotifier;

代码如下:

注入Optional这种方式可以解决注入的对象不存在的导致异常问题,也就是安全注入。

比如说,
MessageNotifier
这个对象Spring容器中并没有,如果直接注入,此时会抛
NoSuchBeanDefinitionException
异常

而直接通过注入Optional的方式就可以解决这个问题。

除了通过Optional的方式之外,也可以直接把@Autowired的
required
的属性设置为false来解决注入对象不存在的问题。

那Optional存在的作用是啥?

其实Optional的作用仅仅是不用写为空的判断,这也是Optional这个类的作用作用,除了这个,跟直接@Autowired对象并没有其它区别。

注入Optional这种方式其实用的不多,在我的映像中,我在源码中几乎没有看见这种注入方式。


7、ObjectFactory和ObjectProvider

ObjectFactory和ObjectProvider是Spring提供的两接口

ObjectFactory
ObjectFactory

ObjectProvider继承了ObjectFactory

ObjectProvider
ObjectProvider

@Autowired也可以直接注入这两个接口。

@Autowired
private ObjectFactory<MessageNotifier> messageNotifierObjectFactory;

@Autowired
private ObjectProvider<MessageNotifier> messageNotifierObjectProvider;

代码如下:

从这段代码也可以看出,最终注入的其实是
DependencyObjectProvider
实现。

ObjectFactory也是用来做延迟注入的操作,跟@Lazy作用差不多,但是实现原理不一样。

用上面的例子来说,注入ObjectFactory的时候并有创建MessageNotifier对象。

当需要使用MessageNotifier的时候需要通过ObjectFactory的getObject方法获取,此时才会真正创建MessageNotifier对象。

MessageNotifier messageNotifier = messageNotifierObjectFactory.getObject();
getObject
getObject

所以@Async注解导致的循环依赖异常不仅可以通过@Lazy注解解决,也可以通过注入ObjectFactory的方式解决。

同理,ObjectProvider也有延迟加载的功能,但是除了延迟加载之外,ObjectProvider额外提供了跟Optional安全注入的功能,这个功能ObjectFactory是没有的。

上面的例子中,当使用ObjectFactory的getObject方法时,如果Spring容器中不存在MessageNotifier对象,此时也会抛
NoSuchBeanDefinitionException
异常。

但是ObjectProvider额外提供的getIfAvailable方法就支持获取不存在的对象的功能,当通过getIfAvailable获取的对象不存在时,只会返回null,并不会出抛异常。

ObjectFactory和ObjectProvider在框架内部中使用的还是比较多的。

就比如说,在MybatisPlus自动装配的时候就大量使用ObjectProvider

并且泛型类型就是数组或者是集合,跟前面说的都对应上了。

通过这种方式就可以安全的注入,当Spring容器有这些对象的时候MybatisPlus就使用这些,没有也不会报错。


8、JSR-330 Provider

首先,来讲一下什么是JSR-330。

JSR是Java Specification Requests的缩写,是一种Java标准规范。

而330算是一个版本,除了330,听到的比较多的还有250。

这个规范定义了一些IOC的注解,我们熟知的比如@Resource、@PostConstruct、@PreDestroy注解都是JSR-250中提出的。

一些IOC的框架会基于这个标准来实现这些接口的功能,比如Spring、Dagger2等IOC框架都实现了这些注解的功能。

所以,如果你不使用Spring框架,使用其它的IOC框架,那么@Resource、@PostConstruct、@PreDestroy注解都是可以生效的。

在JSR-330中,提出了
javax.inject.Provider
这个接口

不过,想使用JSR-330这个接口,需要引入依赖

<dependency>
    <groupId>javax.inject</groupId>
    <artifactId>javax.inject</artifactId>
    <version>1</version>
</dependency>

Spring也支持注入这个类型的接口

这个接口的功能跟前面提到的ObjectFactory功能是一样的,也支持延迟注入的功能。


总结

到这Spring能够注入的Bean的8种类型就讲完了,其实这8种类型可以分为以下几种功能:

  • 单一注入,就是注入一个单一的对象
  • 集合注入,可以注入数组或者集合
  • 延迟注入,比如@Lazy、ObjectFactory、ObjectProvider、JSR-330 Provider
  • 安全注入,不存在不会抛异常,比如Optional、ObjectProvider

这几种方式并不是互斥的,比如说延迟注入也可以注入的是一个集合,前面举的MyBaisPlus自动装配时ObjectProvider的使用就是很好的例子。

同时虽然本文举例的是@Autowird注解和字段注入的方式,但上面提到的注入的Bean类型跟使用注解和注入方式没什么关系,@Resource注解,构造器注入,setter注入都是一样的。


往期热门文章推荐

为什么RocketMQ会重复消费,我扒出了7种原因

如何去阅读源码,我总结了18条心法

如何实现延迟任务,我总结了11种方法

如何写出漂亮代码,我总结了45个小技巧

三万字盘点Spring/Boot的那些常用扩展点

两万字盘点那些被玩烂了的设计模式

扫码或者搜索关注公众号
三友的java日记
,及时干货不错过,公众号致力于通过画图加上通俗易懂的语言讲解技术,让技术更加容易学习,回复 面试 即可获得一套面试真题。

本文介绍在
ArcMap
软件中,基于
模型构建器

ModelBuilder
)完成
模型建立

使用
的具体方法。

首先,在
ArcMap
软件中打开“
ModelBuilder
”。

建立一个模型,我们一般需要两种素材,一是该模型所需的初始数据,二是该模型具体的操作工具;而二者都可以通过插入的方法导入模型。在这里,我们首先导入一个矢量图层作为初始数据。

在“
Model
”界面上方,选择“
Insert
”→“
Add Data or Tool...
”。

在弹出的窗口中,找到我们需要导入的数据;确认后可以看到“
Model
”界面已经有了刚刚我们导入的矢量数据。

接下来,我们导入前文提及的第二种素材——模型具体的操作工具。导入工具有两种方法:如果要导入的工具是
ArcGIS
自身已有的工具,那么我们可以直接将其从“
Catalog
”窗口中拖动到“
Model
”窗口中;而如果该工具是类似于文章
ArcMap将Python写的代码转为工具箱与自定义工具
中提及的自定义工具,那么我们可以通过前文提到的“
Insert
”→“
Add Data or Tool...
”方法来导入。

这里我们就选择第一种导入方法,直接将“
Project
”工具拖动到“
Model
”中。

其中,“
Project
”代表该工具的具体操作,其右侧的椭圆则表示该工具的输出结果数据,这二者是同时生成、相互关联的。

此时可以看到,初始数据与工具之间并没有建立连接;因此我们选择“
Connect
”功能,用鼠标将二者连接起来。

因为这里我们的矢量数据是该模型的初始数据,即对于模型而言其是一个输入数据,因此在二者连接后弹出的窗口中选择第一项即可。

建立起数据与工具之间的联系后,往往还需要对工具加以进一步的设定,才可以让模型正确、完整运行。

在“
Project
”工具上方右键,选择“
Open
”。

可以看到,这里其实就是我们一般用
ArcGIS
下属各类工具时的参数窗口;而由于我们已经给模型设定了初始数据,因此其第一项参数是默认的。我们对输出数据路径及投影坐标系加以选择即可。

随后可以看到,“
Model
”中工具及其输出结果数据已经由之前的黑、白色变为彩色填充的样式,这说明工具已经可以运行。

此时,点击“
Run
”,即可运行该模型。

如下所示,说明模型运行成功。

此时可以看到,“
Model
”中工具及其输出结果数据出现了阴影,表示该工具已经执行过,且得到了结果数据。

在输出结果数据上右键,选择“
Add To Display
”,即可将该数据图层显示在
ArcMap
中。但这个功能似乎不太稳定,我的电脑上点这个按钮,不知道为什么图层并不会显示。

查看输出结果数据的属性,可以看到其名称、投影坐标系都与我们所设定的一致。

完成模型的配置后,即可将模型保存。选择“
Model
”→“
Save
”。

在弹出的窗口中选择模型保存路径与名称。

此外,我们还可以在“
Model
”→“
Model Properties...
”中配置模型的相关属性。

相关属性包括模型名称、标签、描述文本等。

此外,我们还可以通过“
Export
”→“
To Python Script
”将模型导出,从而形成一个
Python
脚本。

打开新生成的
Python
脚本,可以看到其具体代码。

至此,大功告成。

更多技术交流、求职机会,欢迎关注
字节跳动数据平台微信公众号,回复【1】进入官方交流群

前言

社区版 ClickHouse 推出了
MaterializedMySQL数据库引擎
,用于将 MySQL 中的表映射到 ClickHouse 中。ClickHouse 服务作为 MySQL 副本,读取 Binlog 并执行 DDL 和 DML 请求,实现了基于 MySQL Binlog 机制的业务数据库实时同步功能。

这样不依赖其他数据同步工具,就能将 MySQL 整库数据实时同步到 ClickHouse,从而能基于 ClickHouse 构建实时数据仓库。

ByteHouse 是基于 ClickHouse 增强自研的云原生数据仓库,在社区版 ClickHouse 的 MaterializedMySQL 之上进行了功能增强,让数据同步更稳定,支持便捷地处理同步异常问题。

社区版 MaterializedMySQL 简介

ClickHouse 社区版通过 DDL 语句在 ClickHouse 上创建一个 database,并将 MySQL 中的指定的一个 database 的全量数据迁移至 ClickHouse,并实时读取 MySQL 的 binlog 日志,将 MySQL 中的增量数据实时同步至 ClickHouse 中。

详细介绍:
[experimental] MaterializedMySQL | ClickHouse Docs

同步示例

同步一个 MySQL 库至 ClickHouse 的示例创建语句如下:

CREATE DATABASE db_name ENGINE =MaterializedMySQL(...)
SETTINGS materialized_mysql_tables_list
='user_table,catalog_sales'TABLE OVERRIDE user_table(
COLUMNS (
userid UUID,
category LowCardinality(String),
timestamp DateTime CODEC(Delta, Default)
)
PARTITION BY toYear(timestamp)
),
TABLE OVERRIDE catalog_sales(
COLUMNS (
client_ip String TTL created
+ INTERVAL 72HOUR
)
SAMPLE BY ip_hash
)

功能优势

MaterializedMySQL 数据同步方案的优势有:

  • 简单易用:使用一个 DDL 语句就能创建整库同步任务,能将数百数千张表一键同步至 ClickHouse,操作简单。

  • 架构简单:使用 ClickHouse 本身的计算资源进行数据增量同步,无需搭建其他的数据同步工具,数据架构简单。

  • 时效性好:支持实时同步源端数据,ClickHouse 端几乎是毫秒和秒级延迟,时效体验非常好。

ByteHouse 功能增强

社区版 MaterializedMySQL 很大程度了解决了 MySQL 库到 ClickHouse 之间的数据实时同步问题,但也存在不少问题导致其很难应用到生产应用中,主要问题如下:

  • 配置选项少

社区版 MaterializedMySQL 不支持同步到分布式表,不支持跳过不兼容 DDL 等功能,缺乏这些功能很难将 MaterializedMySQL 用于实际应用中。

  • 运维困难

社区版 MaterializedMySQL 不支持同步异常重新同步命令,没有同步状态和日志信息,同步任务失败后很难短时间定位问题和恢复同步。

ByteHouse 的 MaterializedMySQL 功能针对使用过程中的问题和困难,做了多处增强,提高了易用性,降低了运维成本。

数据去重

通过 MaterializedMySQL 同步到 ByteHouse 的表默认采用 HaUniqueMergeTree 表引擎,该表引擎支持配置 UNIQUE KEY 唯一键,提供 upsert 更新写语义,源端数据的更新操作在目标端可以实时去重更新。不需要依赖_version、_sign 虚拟列来标记删除更新,简化了业务逻辑,提高了易用性。

同步范围

通过 SETTINGS 参数中配置 include_tables 和 exclude_tables 列表,指定该数据库下需要同步的表清单或者不需要同步的表清单,否则同步该库所有的表。

在实际应用中,一个数据库通常有数百乃至数千张表,其中有些表无需同步、或者数据可能存在异常,可以将这些表加入 exclude_tables 清单,不影响其他表的数据同步。

异常处理

数据同步链路无法避免发生异常情况导致同步中断,ByteHouse 提高了多个功能来简化异常问题处理。

跳过不支持的语句

MySQL 支持的 DDL 语句非常丰富,有很多语法与 clickhouse 不兼容,在 ClickHouse 端执行会报错中断同步任务。

可以通过设置 skip_ddl_patterns 参数,用 1 个或多个正则表达式将匹配的 DDL 语句过滤掉,从而避免了报错和中断同步任务。

系统日志表

ByteHouse 提供两个系统表:system.materialize_mysql_status,system.materialize_mysql_log,分别记录了每个同步任务的状态,参数设置和运行日志。便于实时查看同步状态和排查异常问题。

出错后运维

当同步任务出现了同步异常后,通过查看运行日志系统表定为问题。

针对性处理了异常问题后,通过 resync 命令重启同步任务。

分布式模式

社区版 MaterializedMySQL 的每个同步任务会将源端的一个库同步至 ClickHouse 的某个节点,不支持按分片逻辑将数据分布到所有节点,无法利用 ClickHouse 集群的分布式计算存储能力;如果在集群中每个节点都建一个同步库,则源端一份数据会被同步一份全量至每个 ClickHouse 节点,既浪费了存储空间,降低了查询性能,又会对源端产生巨大的压力。

ByteHouse 支持构建分布式模式的 MaterializedMySQL 库,将每个表都对应同步至 ByteHouse 的一个分布式表,数据不重复存储,能充分利用分布式集群的计算能力,又降低了对源端的同步压力。

可视化运维

ByteHouse 同时提供了可视化运维模块,能实时查看同步状态,暴露同步异常,支持在线修复同步异常问题和重启同步任务。

最佳实践

下午将演示将 MySQL 库中的若干张表同步至 ByteHouse 的全过程。

源端配置

在 MySQL 数据库端需要配置的参数如下。

开启 Binlog

设置默认的认证插件

开启 GTID 模式

  • 用户权限  MaterializeMySQL 表引擎用户必须具备 MySQL 库的 RELOAD、REPLICATION SLAVE、REPLICATION CLIENT 以及 SELECT PRIVILEGE 权限  支持的 MySQL 版本 5.65.78.0

源端数据准备

在 MySQL 数据库里面创建一个 database,创建两张表,并插入若干数据。

Show databases;--【MySQL】Mysql中创建库
create database db;
use db;
--【MySQL】Mysql中创建表
CREATE TABLE `user` (
`id` bigint(
20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(
64) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE
=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='user info';

CREATE TABLE `data` (
`id` bigint(
20) unsigned NOT NULL,
`date_time` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE
=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='time';--【MySQL】Mysql中插入数据
INSERT INTO yangxi.user(id,name) VALUES (
666666,'step1-6666661'),(222,'step1-2222');
INSERT INTO yangxi.data(id,date_time) VALUES (
666666,now()),(222,now()),(333,now());

创建 MaterializeMySQL

在 ByteHouse 的控制台数据查询窗口,创建 MaterializeMySQL 库。


#【worksheet】创建物化Mysql库,--【Clickhouse】在集群名称是 bytehouse的集群上创建物化库,集群名称是个变量
CREATE DATABASE shard_mode_true_mysql_sync on cluster bytehouse
ENGINE
= MaterializeMySQL('10.137.xx.xx:3309', 'db', 'username', 'password')
settings shard_mode
=true,allows_query_when_mysql_lost=1,include_tables='user,data'TABLE OVERRIDE data( PARTITION BY toYYYYMMDD(date_time)) ;

参数解释:

  • shard_mode:true 表示是同步至分布式表。

  • allows_query_when_mysql_lost:1 表示同步中断的时候也允许查询数据。

  • include_tables:同步源端 db 库中 user 和 data 两张表,其他表跳过不同步。

  • OVERRIDE :ByteHouse 中的 data 表按照 date_time 字段分区。

查看同步状态

切换到 ByteHouse 数据管理模块,搜索
shard_mode_true_mysql_sync 库,并查看库同步状态

同步任务管理

库-停止同步/开始同步

  • 创建库后默认是同步状态

  • 可以手动停止同步

  • 停止中的库可以手动开始同步

库-重置同步

选择一个同步库,点击“重置同步”可以从头开始重新同步整库

表-重置同步

选择一个同步中的表 A,点击“重置同步”按钮,系统将执行以下行为:

  1. 关闭同步任务

  2. 从源端全量拉取该表的数据至临时表(A_CHTMP,表名后缀会加上_CHTMP)

  3. 删除目标端原有表 A(如果存在)

  4. 将临时表 A_CHTMP RENAME 为 A

  5. 开始增量同步

删除库

删除 ByteHouse 中的库以及同步关系。

异常处理

系统运维表

在 ByteHouse 管理控制台,通过下列语句查看任务同步状态和错误信息。

select * from cluster('bytehouse','system.materialize_mysql_status',(1,2))select * from cluster('bytehouse','system.materialize_mysql_log',(1,2))

单表异常恢复

在源端执行下列 Alter table 语句以后,库同步会失败

--修改字段类型
mysql
>ALTER TABLE db.test ADD COLUMN h tinyint;
mysql
> ALTER TABLE db.test MODIFY h int default 0;
mysql
>ALTER TABLE db.test MODIFY h tinyint default 0;

恢复办法:

在 ByteHouse 界面上进入表详情,点击重新同步按钮。

进入库详情页面,点击开始同步按钮,即可恢复同步。

在 ByteHouse 中执行下列语句,也可以恢复数据同步

--通过下述命令,或者可视化界面,可以重启同步--shard_mode=true情况
:) system resync materialize mysql table on cluster bytehouse shard_mode_true_mysql_sync.test;
:) system restart sync materialize mysql on cluster bytehouse shard_mode_true_mysql_sync;

其他操作

设置跳过 DDL

:) CREATE DATABASE db_mysql_sync_skip on cluster bytehouse  
ENGINE
= MaterializeMySQL('10.xx.xx.xx:3309', 'db', 'username', 'password')
SETTINGS include_tables
='user,date_time',skip_ddl_patterns='.*add column.*,.*MODIFY.*'TABLE OVERRIDE data( PARTITION BY toYYYYMMDD(date_time)) ;

修改 include 和 exclude

通过下列语句修改 include 和 exclude 参数,来修改同步表范围。

:) alter database shard_mode_true_mysql_sync on cluster bytehouse   modify setting include_tables='user,data,date_time,test';
:) alter database shard_mode_true_mysql_sync on cluster bytehouse
modify setting include_tables
='',exclude_tables='test3';

异常报警

ByteHouse 提供监控报警功能,在库同步异常停止或单表同步失败的时候,可以向管理员发送报警信息。

点击跳转
ByteHouse云原生数据仓库
了解更多

正文:

现在需要你写一个程序,从 3 开始依次向下,当到 0 时打印 「GO!」 并退出,要求每次打印从新的一行开始且打印间隔一秒的停顿。

3
2
1
Go!
我们将通过编写一个
Countdown
函数来处理这个问题,然后放入
main
程序,所以它看起来这样:

packagemainfuncmain() {
Countdown()
}
虽然这是一个非常简单的程序,但要完全测试它,我们需要像往常一样采用迭代的、测试驱动的方法。
所谓迭代是指:确保我们采取最小的步骤让软件可用。
我们不想花太多时间写那些在被攻击后理论上还能运行的代码,因为这经常导致开发人员陷入开发的无底深渊。
尽你所能拆分需求是一项很重要的技能,这样你就能拥有可以工作的软件
下面是我们如何划分工作和迭代的方法:
  • 打印 3
  • 打印 3 到 Go!
  • 在每行中间等待一秒

先写测试

我们的软件需要将结果打印到标准输出界面。在 DI(依赖注入) 的部分,我们已经看到如何使用 DI 进行方便的测试。

func TestCountdown(t *testing.T) {
buffer :
= &bytes.Buffer{}

Countdown(buffer)

got :
=buffer.String()
want :
= "3" if got !=want {
t.Errorf(
"got '%s' want '%s'", got, want)
}
}
如果你对
buffer
不熟悉,请重新阅读前面的部分。
我们清楚,我们的目的是让
Countdown
函数将数据写到某处,
io.writer 就是作为 Go 的一个接口来抓取数据的一种方式。

  • main
    中,我们将信息发送到
    os.Stdout
    ,所以用户可以看到
    Countdown
    的结果打印到终端
  • 在测试中,我们将发送到
    bytes.Buffer
    ,所以我们的测试能够抓取到正在生成的数据

尝试并运行测试

./countdown_test.go:11:2: undefined: Countdown

为测试的运行编写最少量的代码,并检查失败测试的输出

定义
Countdown
函数

func Countdown() {}

再次尝试运行

./countdown_test.go:11:11: too many arguments in call to Countdown
have (*bytes.Buffer)
want ()

编译器正在告诉你函数的问题,所以更正它

func Countdown(out *bytes.Buffer) {}

countdown_test.go:17: got '' want '3'

这样结果就完美了!

编写足够的代码使程序通过

func Countdown(out *bytes.Buffer) {
fmt.Fprint(out,
"3")
}

我们正在使用
fmt.Fprint
传入一个
io.Writer
(例如
*bytes.Buffer
)并发送一个
string
。这个测试应该可以通过。

重构代码

虽然我们都知道
*bytes.Buffer
可以运行,但最好使用通用接口代替。

funcCountdown(out io.Writer) {
fmt.Fprint(out,
"3")
}
重新运行测试他们应该就可以通过了。
为了完成任务,现在让我们将函数应用到
main 中。这样的话,我们就有了一些可工作的软件来确保我们的工作正在取得进展。

packagemainimport("fmt"
    "io"
    "os")funcCountdown(out io.Writer) {
fmt.Fprint(out,
"3")
}
funcmain() {
Countdown(os.Stdout)
}
尝试运行程序,这些成果会让你感到神奇。
当然,这仍然看起来很简单,但是我建议任何项目都使用这种方法。
在测试的支持下,将功能切分成小的功能点,并使其首尾相连顺利的运行。
接下来我们可以让它打印 2,1 然后输出「Go!」。

先写测试

通过花费一些时间让整个流程正确执行,我们就可以安全且轻松的迭代我们的解决方案。我们将不再需要停止并重新运行程序,要对它的工作充满信心因为所有的逻辑都被测试过了。

func TestCountdown(t *testing.T) {
buffer :
= &bytes.Buffer{}

Countdown(buffer)

got :
=buffer.String()
want :
= `3 2 1Go!`if got !=want {
t.Errorf(
"got '%s' want '%s'", got, want)
}
}

反引号语法是创建
string
的另一种方式,但是允许你放置东西例如放到新的一行,对我们的测试来说是完美的。

尝试并运行测试

countdown_test.go:21: got '3' want '3
2
1
Go!'

写足够的代码令测试通过

funcCountdown(out io.Writer) {for i := 3; i > 0; i--{
fmt.Fprintln(out, i)
}
fmt.Fprint(out,
"Go!")
}
使用
for
循环与
i--
反向计数,并且用
fmt.println
打印我们的数字到
out
,后面跟着一个换行符。最后用
fmt.Fprint
发送 「Go!」。

重构代码

这里已经没有什么可以重构的了,只需要将变量重构为命名常量

const finalWord = "Go!"
const countdownStart = 3

funcCountdown(out io.Writer) {for i := countdownStart; i > 0; i--{
fmt.Fprintln(out, i)
}
fmt.Fprint(out, finalWord)
}

如果你现在运行程序,你应该可以获得想要的输出,但是向下计数的输出没有 1 秒的暂停。

Go 可以通过
time.Sleep
实现这个功能。尝试将其添加到我们的代码中。

funcCountdown(out io.Writer) {for i := countdownStart; i > 0; i--{
time.Sleep(
1 *time.Second)
fmt.Fprintln(out, i)
}

time.Sleep(
1 *time.Second)
fmt.Fprint(out, finalWord)
}

如果你运行程序,它会以我们期望的方式工作。

Mocking

测试可以通过,软件按预期的工作。但是我们有一些问题:

  • 我们的测试花费了 4 秒的时间运行
  • 每一个关于软件开发的前沿思考性文章,都强调快速反馈循环的重要性。
  • 缓慢的测试会破坏开发人员的生产力。
  • 想象一下,如果需求变得更复杂,将会有更多的测试。对于每一次新的
    Countdown
    测试,我们是否会对被添加到测试运行中 4 秒钟感到满意呢?
  • 我们还没有测试这个函数的一个重要属性。
我们有个
Sleep
ing 的注入,需要抽离出来然后我们才可以在测试中控制它。
如果我们能够
mock
time.Sleep
,我们可以用
依赖注入
的方式去来代替「真正的」
time.Sleep
,然后我们可以使用断言
监视调用

先写测试

让我们将依赖关系定义为一个接口。这样我们就可以在
main
使用
真实的
Sleeper
,并且在我们的测试中使用
spy sleeper
。通过使用接口,我们的
Countdown
函数忽略了这一点,并为调用者增加了一些灵活性。

type Sleeper interface{
Sleep()
}
我做了一个设计的决定,我们的
Countdown
函数将不会负责
sleep
的时间长度。 这至少简化了我们的代码,也就是说,我们函数的使用者可以根据喜好配置休眠的时长。
现在我们需要为我们使用的测试生成它的
mock

type SpySleeper struct{
Calls
int}func (s *SpySleeper) Sleep() {
s.Calls
++}
监视器(spies)
是一种
mock
,它可以记录依赖关系是怎样被使用的。它们可以记录被传入来的参数,多少次等等。在我们的例子中,我们跟踪记录了
Sleep()
被调用了多少次,这样我们就可以在测试中检查它。
更新测试以注入对我们监视器的依赖,并断言
sleep

被调用了 4 次。

func TestCountdown(t *testing.T) {
buffer :
= &bytes.Buffer{}
spySleeper :
= &SpySleeper{}

Countdown(buffer, spySleeper)

got :
=buffer.String()
want :
= `3 2 1Go!`if got !=want {
t.Errorf(
"got '%s' want '%s'", got, want)
}
if spySleeper.Calls != 4{
t.Errorf(
"not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
}
}

尝试并运行测试

too many arguments in call to Countdown
have (*bytes.Buffer, Sleeper)
want (io.Writer)

为测试的运行编写最少量的代码,并检查失败测试的输出

我们需要更新
Countdow
来接受我们的
Sleeper

funcCountdown(out io.Writer, sleeper Sleeper) {for i := countdownStart; i > 0; i--{
time.Sleep(
1 *time.Second)
fmt.Fprintln(out, i)
}

time.Sleep(
1 *time.Second)
fmt.Fprint(out, finalWord)
}

如果您再次尝试,你的
main
将不会出现相同编译错误的原因

./main.go:26:11: not enough arguments in call to Countdown
have (*os.File)
want (io.Writer, Sleeper)

让我们创建一个
真正的
sleeper 来实现我们需要的接口

type ConfigurableSleeper struct{
duration time.Duration
}
func (o *ConfigurableSleeper) Sleep() {
time.Sleep(o.duration)
}
我决定做点额外的努力,让它成为我们真正的可配置的 sleeper。但你也可以在 1 秒内毫不费力地编写它。
我们可以在实际应用中使用它,就像这样:
funcmain() {
sleeper :
= &ConfigurableSleeper{1 *time.Second}
Countdown(os.Stdout, sleeper)
}

足够的代码令测试通过

现在测试正在编译但是没有通过,因为我们仍然在调用
time.Sleep
而不是依赖注入。让我们解决这个问题。

funcCountdown(out io.Writer, sleeper Sleeper) {for i := countdownStart; i > 0; i--{
sleeper.Sleep()
fmt.Fprintln(out, i)
}

sleeper.Sleep()
fmt.Fprint(out, finalWord)
}

测试应该可以该通过,并且不再需要 4 秒。

仍然还有一些问题

还有一个重要的特性,我们还没有测试过。
Countdown
应该在第一个打印之前 sleep,然后是直到最后一个前的每一个,例如:
  • Sleep
  • Print N
  • Sleep
  • Print N-1
  • Sleep
我们最新的修改只断言它已经
sleep
了 4 次,但是那些
sleeps
可能没按顺序发生。
当你在写测试的时候,如果你没有信心,你的测试将给你足够的信心,尽管推翻它!(不过首先要确定你已经将你的更改提交给了源代码控制)。将代码更改为以下内容。
funcCountdown(out io.Writer, sleeper Sleeper) {for i := countdownStart; i > 0; i--{
sleeper.Sleep()
}
for i := countdownStart; i > 0; i--{
fmt.Fprintln(out, i)
}

sleeper.Sleep()
fmt.Fprint(out, finalWord)
}
如果你运行测试,它们仍然应该通过,即使实现是错误的。
让我们再用一种新的测试来检查操作的顺序是否正确。
我们有两个不同的依赖项,我们希望将它们的所有操作记录到一个列表中。所以我们会为它们俩创建
同一个监视器

type CountdownOperationsSpy struct{
Calls []
string}func (s *CountdownOperationsSpy) Sleep() {
s.Calls
= append(s.Calls, sleep)
}
func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
s.Calls
= append(s.Calls, write)return}const write = "write" const sleep = "sleep"
我们的
CountdownOperationsSpy
同时实现了
io.writer

Sleeper
,把每一次调用记录到
slice
。在这个测试中,我们只关心操作的顺序,所以只需要记录操作的代名词组成的列表就足够了。
现在我们可以在测试套件中添加一个子测试。

t.Run("sleep after every print", func(t *testing.T) {
spySleepPrinter :
= &CountdownOperationsSpy{}
Countdown(spySleepPrinter, spySleepPrinter)

want :
= []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf(
"wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
现在这个测试应该会失败。恢复原状新测试应该又可以通过。
我们现在在
Sleeper
上有两个测试监视器,所以我们现在可以重构我们的测试,一个测试被打印的内容,另一个是确保我们在打印时间
sleep。 最后我们可以删除第一个监视器,因为它已经不需要了。

func TestCountdown(t *testing.T) {

t.Run(
"prints 3 to Go!", func(t *testing.T) {
buffer :
= &bytes.Buffer{}
Countdown(buffer,
&CountdownOperationsSpy{})

got :
=buffer.String()
want :
= `3 2 1Go!`if got !=want {
t.Errorf(
"got '%s' want '%s'", got, want)
}
})

t.Run(
"sleep after every print", func(t *testing.T) {
spySleepPrinter :
= &CountdownOperationsSpy{}
Countdown(spySleepPrinter, spySleepPrinter)

want :
= []string{
sleep,
write,
sleep,
write,
sleep,
write,
sleep,
write,
}
if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
t.Errorf(
"wanted calls %v got %v", want, spySleepPrinter.Calls)
}
})
}

我们现在有了自己的函数,并且它的两个重要的属性已经通过合理的测试。

难道 mocking 不是在作恶(evil)吗?

你可能听过 mocking 是在作恶。就像软件开发中的任何东西一样,它可以被用来作恶,就像 DRY(Don't repeat yourself) 一样。
当人们
不听从他们的测试
并且
不尊重重构阶段时
,他们通常会陷入糟糕的境地。
如果你的模拟代码变得很复杂,或者你需要模拟很多东西来测试一些东西,那么你应该
倾听

那种糟糕的感觉,并考虑你的代码。通常这是一个征兆:
  • 你正在进行的测试需要做太多的事情
  • 把模块分开就会减少测试内容
  • 它的依赖关系太细致
  • 考虑如何将这些依赖项合并到一个有意义的模块中
  • 你的测试过于关注实现细节
  • 最好测试预期的行为,而不是功能的实现
通常,在你的代码中有大量的 mocking 指向
错误的抽象
人们在这里看到的是测试驱动开发的弱点,但它实际上是一种力量
通常情况下,糟糕的测试代码是糟糕设计的结果,而设计良好的代码很容易测试。

但是模拟和测试仍然让我举步维艰!

曾经遇到过这种情况吗?
  • 你想做一些重构
  • 为了做到这一点,你最终会改变很多测试
  • 你对测试驱动开发提出质疑,并在媒体上发表一篇文章,标题为「Mocking 是有害的」

这通常是您测试太多
实现细节
的标志。尽力克服这个问题,所以你的测试将测试
有用的行为
,除非这个实现对于系统运行非常重要。

有时候很难知道到底要测试到
什么级别
,但是这里有一些我试图遵循的思维过程和规则。

  • 重构的定义是代码更改,但行为保持不变。
    如果您已经决定在理论上进行一些重构,那么你应该能够在没有任何测试更改的情况下进行提交。所以,在写测试的时候问问自己。
  • 我是在测试我想要的行为还是实现细节?
  • 如果我要重构这段代码,我需要对测试做很多修改吗?
  • 虽然 Go 允许你测试私有函数,但我将避免它作为私有函数与实现有关。
  • 我觉得如果一个测试
    超过 3 个模拟,那么它就是警告
    —— 是时候重新考虑设计。
  • 小心使用监视器。监视器让你看到你正在编写的算法的内部细节,这是非常有用的,但是这意味着你的测试代码和实现之间的耦合更紧密。
    如果你要监视这些细节,请确保你真的在乎这些细节。
和往常一样,软件开发中的规则并不是真正的规则,也有例外。Uncle Bob 的文章
「When to mock」
有一些很好的指南。

总结

更多关于测试驱动开发的方法:
  • 当面对不太简单的例子,把问题分解成「简单的模块」。试着让你的工作软件尽快得到测试的支持,以避免掉进兔子洞(rabbit holes,意指未知的领域)和采取「最终测试(Big bang)」的方法。
  • 一旦你有一些正在工作的软件,
    小步迭代
Mocking:

一旦开发人员学会了 mocking,就很容易对系统的每一个方面进行过度测试,按照
它工作的方式
而不是
它做了什么
。始终要注意
测试的价值
,以及它们在将来的重构中会产生什么样的影响。
在这篇关于 mocking 的文章中,我们只提到了
监视器(Spies)
,他们是一种 mock。也有不同类型的 mocks
。Uncle Bob 的一篇极易阅读的文章中解释了这些类型
。在后面的章节中,我们将需要编写依赖于其他数据的代码,届时我们将展示
Stubs 行为