也许这篇文章的名字应该改成《Quartz JobFactory的使用》,因为正是使用JobFactory解决的Quartz Job类有参数构造方法的问题。同样,使用JobFactory也能解决Job已有实例重用的问题。 问题描述 问题如标题描述:就是希望为Job类的实例传递参数,结果发现找到的文档中创建Job实例的方法多是通过类反射来实现的,这显然不能满足我的需求。 也找到了一些文章建议使用jobDataMap来解决。不太喜欢这种方案,因为太难看了——只是搬个砖而已,却连底裤都漏出来了。 解决方案 翻了下官方文档,在Lesson 12: Miscellaneous Features of Quartz这一节找了关于JobFactory的描述,虽然寥寥几行,却指明了解决问题的方向。 JobFactory,根据文档描述,是Scheduler的一个配置项,主要用来完成Job实例的注入。默认使用的JobFactory只是简单地调用了Job 类的newInstance()方法来创建了实例。 要解决我的问题需要创建自定义的JobFactory实现。JobFactory的实现可以参考默认JobFactory的实现SimpleJobFactory的一些代码: 相当简单的内容,只需要在创建实例那块儿填写自定义的内容就可以。 写了一个示例程序来进行说明。下面是一些核心类的类图: 查看完整代码请移步GitHub/zhyea 介绍下类图中类的作用: AbstractJob:抽象类,实现了Job接口,并提供了部分自定义方法,以及一个MyConfig参数的构造器 MyConfig:全局配置类,提供配置信息 JobRegistry:负责创建并维护AbstractJob实例 MyJobFactory:自定义的JobFactory实现类 MyJob:AbstractJob的一个子类,实现了Job的execute()方法 如上面的解释中所说,在MyJobFactory的实现中,JobRegistry是最重要的角色,在这个类中完成了AbstractJob实现类的实例的创建、维护和获取: MyJobFactory需要提供类的实例时,可以根据类名通过JobRegistry.getInstance获取到AbstractJob子类的实例: 核心部分就是这样了。再看下AbstractJob及启动类的内容。 AbstractJob近似于是是一个抽象模板类。在这个类里完成了JobDetail和Trigger实例的创建。当然Job接口的execute方法还是需要子类来实现。 下面是启动类的内容,在这个类里通过scheduler.setJobFactory完成了MyJobFactory实例的装配: 就这样了。
[阅读更多...]-
Quartz Job类使用有参数构造方法
-
Spark数据导出任务内存优化记录
前两天又接了一个Spark任务,倒不复杂,依然是检索HDFS上的日志数据这样的事情。不过瞅着组内跑着十几二十个任务内存一共只有160来G的yarn集群,有些欲哭无泪。 事情还是要做的,反正执行时间要求不太严格,只能想办法尽量压缩内存的占用了。 先说下背景:现在使用的yarn集群由8个容器组成,每个容器的内存大概20G;工作内容是检索数据,源数据大概1T左右,取出来的目标结果数据在2~8G这样子。 最开始的时候查询任务是直接使用sparkSql来完成。随着数据量的上升很快就遇到了最经典的两个问题:StackOverflowError和OutOfMemoryError。 对于栈溢出,之前设计了几个解决方案,在历史文章里面有记录《Spark StackOverflowError》。其中我使用了任务内多批次执行的方案。现在想来,这其实并不是最好的解决方案:问题在于分批越多,每批任务中的action算子就会导致任务的执行时间越长,远不如直接增加栈空间来得简单直接。不过也算是错有错着,这反倒为后来的优化打下了基础。 至于堆内存溢出,主要发生在将每个partition的数据合并压缩的阶段:.repartition(1).saveAsTextFile(pathSave, classOf[GzipCodec])。因为这个操作可能会发生在每个Executor上,所以只好通过简单的增加Executor的内存来解决问题。因为内存总量有限,单个Executor的内存调大了,就只能将task的并发度调小。这样在更严重的问题暴露之前,一直尝试解决的问题就是如何在并发度和内存占用之间取得平衡。 更严重的问题出现在这次的需求上:很简单,要导出的结果数据集变得非常大了,一般都会大于8G,此时堆内存溢出频繁出现。应对方案如下:取消压缩操作、增大Executor执行内存,将Executor的数量调整为2,每个Executor的task数目调整为1。这样Spark任务可以正常执行了,但是因为并行度太小的缘故,执行时间巨长——动辄跑上十来个小时。优化执行速度又提到了时间表上。 是一次执行错误给了优化的方向。现在任务的执行步骤为: 某次任务执行到第4步的时候报错了,考虑到耗时的问题,就重新写了一段代码来完成4和5两步的操作。此时想到这个任务在不同的阶段对资源的需求是不一样的: 在执行1~3这几个步骤的时候对内存的需求没那么强,但是如果稍稍增加些并行度就能极大地提升任务的执行效率; 第4步则是典型的吃内存的操作,此时并行度为1,但是内存需要足够大才能保证任务顺利完成。 此时方案已经很清晰了:将一个任务拆成两个,一个负责搜集数据,一个负责合并生成的中间数据,在执行的时候按不同的策略分配资源。 至此,当前的任务优化已完成。 再扯些没用的。 最后的优化方案实际上非常简单,以至于我很奇怪为什么一开始没想到。并且这种方案是在Hadoop的计算实践中是最常用的操作。唉,也许是灯下黑吧。 也许直接使用Hadoop会是一个更好地选择。因为瓶颈主要出现在内存上,Hadoop对内存资源的占用会少很多。 如果能不走yarn,直接使用java操作,那么尽量不走yarn好了,虽然复杂度会提升很多,但是执行效率是有保证的。 另外,同事曾经问过我一个问题:如果减少了executor的数目,那么每个executor要处理的数据不就变多了,这样也会造成内存压力。听到这个问题时,我的第一印象是“对啊,之前怎么没有考虑到这一点啊”。后来仔细思索了一段时间,想明白了关键:这个问题的前半句是对的,数据总量固定,并行度降低,单个executor要处理的数据量必然会增加;但是后半句是错的,内存中的数据量取决于partition的数量,在配置中则是和task数量相关。 记录一组spark任务提交参数,留着以后参考: 就这些了。
[阅读更多...] -
linux防火墙操作命令
最近又开了一个VPS,免不了一通配置。在这里简单记录下linux防火墙相关的操作。 操作系统:centos7。 1. firewall启动、停止、重启 2. 配置/取消 firewall开机启动 3. 新增开放端口 注意事项: 执行新增/删除操作需要重启防火墙服务。 其他节点 telnet开放的端口必须保证本地 “telnet 127.0.0.1 端口号” 能通。本地不通不一定是防火墙的问题。 4. 删除开放端口 5. 查看防火墙信息 另外一些指令,或可有用:
[阅读更多...] -
Metrics学习02 – Counter
Counter是要学习的Metrics的第二个工具,顾名思义即是计数器,通常用来执行统计之类的工作。 Counter比Gauge也复杂不了多少,直接看代码好了: 这里的代码较Gauge的那段稍稍有些不同:主要是在Counter实例的创建上 —— 这里使用了MetricRegistry实例的一个工具方法counter()。 MetricRegistry的实例为各种指标工具都提供了快速创建实例的方法,通过MetricRegistry提供的方法创建完成指标实例后可以自动完成注册。所以在上面的代码中没有再显式地将counter实例注册到MetricRegistry中。 另外值得细细品一下的是Counter的实现:Counter的计数能力主要依赖LongAdder类完成。 一般执行计数统计,最先想到的是AtomicLong/AtomicInt类。AtmoicXXX使用硬件级别的指令 CAS 来更新计数器的值,这样可以避免加锁,机器直接支持的指令,效率也很高。但是AtomicXXX中的 CAS 操作在出现线程竞争时,失败的线程会白白地循环一次,在并发很大的情况下,因为每次CAS都只有一个线程能成功,竞争失败的线程会非常多。失败次数越多,循环次数就越多,很多线程的CAS操作越来越接近 自旋锁(spin lock)。计数操作本来是一个很简单的操作,实际需要耗费的cpu时间应该是越少越好,AtomicXXX在高并发计数时,大量的cpu时间都浪费会在 自旋 上了,这很浪费,也降低了实际的计数效率。 使用LongAdder计数器可以避免这个问题。LongAdder采用了锁分段的思想,每个LongAdder实例都维护了一组计数单元Cell[],并发计数时,不同的线程可以在不同的计数单元cell[threadId]上进行计数,这样减少了线程竞争,提高了并发效率。本质上是用空间换时间的思想。 不过LongAdder一开始并不会直接使用计数单元Cell[],而是先使用一个long类型的base存储,当casBase()出现失败时,则会创建计数单元Cell[]。此时,如果在单个计数单元面出现了更新冲突,那么会尝试创建新的计数单元Cell,或者将Cell[]扩容为2倍。代码如下: 在高并发的情况下,LongAdder较之AtomicXXX有着数倍的性能优势。因此,通常建议使用LongAdder替换AtomicXXX。 再看下Counter的测试结果:
[阅读更多...] -
Metrics学习01 – Gauge
最近在写一个小应用,有些计量方式觉得可以参考一下Metrics,所以打算花两天的时间学习一下这个工具。 Overview Metrics是一个java监控计量工具包。在Spark、Hadoop、Spring等软件中都可以看到它的影子。Metrics提供了多种指标工具,如Gauge、Counter、Metrer、Timer、Histogram以及HealthCheck等。 这次先看一下Gauge,其他的看时间再逐个学习。 Gauge可以说是Metrics的最简单的一个指标:它的作用就是引用一个值。 来看个例子: 代码中通过匿名类的形式创建了一个Gauge接口的实例,作用是获取当前的时间。实现得非常简单,不需要多做解释。 因为Gauge接口只有一个方法getValue,是一个函数接口,所以可以考虑用lambda表达式创建Gauge接口实例: 既然Gauge这么简单,为什么不直接使用Gauge的值,还偏要用Gauge接口封装一下?是为了能在Metrics框架中记录并表示这个值。 Metrics框架中有几个基础概念:MetricRegistry、Reporter以及Metric。Metric前面提过两句,也演示了Metric之一的Gauge的用法。接下来简单介绍下MetricRegistry和Reporter。 MetricRegistry MetricRegistry的作用从类名就可以看出来:是Metric的注册中心(或者说是Metric容器),负责管理用户创建的所有Metric实例。 MetricRegistry主要提供了几种工具方法: 指标名称创建 创建Metric实例并自动注册 增删Metric实例 对注册的Metric实例应用监听器和过滤器 Reporter接口 从接口名称看起来,Reporter的作用应该是汇总指标实例的数据并生成报表。 Reporter接口的主要子类是ScheduledReporter,其核心是ScheduledExecutorService和ScheduledFuture,用来管理报表的定时输出。ScheduledReporter的子类包括ConsoleReporter、CsvReporter和Slf4jReporter,可以以不同的形式展示报表数据。 在4.x版本以前,Reporter接口还有实现一个类JmxReporter,可以通过JMX的形式输出报表数据。 扫了几个Reporter的实现,看出Reporter确实主要用来生成报表。不过也许是Metrics框架想要提供更多的自由,Reporter接口里并没有定义任何需要实现的方法: 如果需要以自定义的形式输出报表数据,可以继承ScheduledReporter类或实现Report接口来实现自己的需求,比如将报表数据以HTTP发送给统计应用。 Other 最后,看一下示例代码的执行结果:
[阅读更多...] -
SpringBoot探索01 – @Import注解
Overview Spring中@Import注解最初主要是在配置类中使用,目的是引入其他的配置类(@Configuration)并实现自动注入。 目前Import并不只是支持引入@Configuration注解的类,也支持引入ImportSelector和ImportBeanDefinitionRegistrar接口的实现类,甚至可以引入普通的Java Bean并完成注入。 写了一个简单的应用来进行测试:spring-boot-import。 做些说明。在应用中定义了一个Worker类,应用做的事情就是结合@Import注解用不同的方式注入Worker类的多个Bean实例。 每个Worker Bean的实例通过name进行区分。 代码的一个核心是MyConfig类,代码如下: 这个类中包含@Configuration注解,说明是一个配置类,Spring会自动注入这个类的实例。此外这个类还通过@Bean注解注入了一个Worker Bean实例“tom”,又通过@Import接口引用三个其他类,目的是尝试注入其他的Worker Bean实例。最后在WorkerService中尝试获取并逐行打印注入的Worker实例: 接下来详细介绍下这个过程中是如何使用@Import接口的。 引入普通的Java Bean MyConfig类中使用@Import注解注入的MyAnotherConfig类没有继承任何超类或实现任何接口: 可以看到,如果不是内部的一个方法使用了@Bean注解,它就是一个普通的Java Bean了。也是通过这个@Bean注解,实现了另一个Worker Bean的注入。 引入ImportSelector实现类 根据Spring的文档,ImportSelector的作用是根据一些注解的属性来决定使用哪些@Configuration类。也就是配置类的选择器。通常在spring的引用包中会看到ImportSelector的实现。 因此这里定义了另一个配置类MySelectConfig,不过为了避免当前应用下Spring的自动注入,没有在这个类中添加@Configuration注解。 看起来和前面的MyAnotherConfig是一样的。不过和前例不一样的是:MySelectConfig类的注入是通过MyImportSelector来实现的。 MyImportSelector的实现如下: 这里没有基于AnnotationMetadata进行判定就直接返回了配置类的名称,在实际工作中不是一个好的实践。不过我们这里只是做一个演示,不需纠结太多。 引入ImportBeanDefinitionRegistrar实现类 ImportBeanDefinitionRegistrar与ImportSelector的作用是有着根本上的不同的:ImportSelector的作用是提供配置类;而ImportBeanDefinitionRegistrar的作用则是根据类定义完成相应Bean实例的创建。 通常ImportBeanDefinitionRegistrar多与ClassPathMapperScanner配合使用。ClassPathMapperScanner可以用来扫描指定的package,获取目标类并完成相应实例的创建。具体应用如MyBatis的@Mapper注解的解释。 看下在我们示例中的使用: 在这里通过Worker类的定义创建了一个名为“jerry”的实例。需要注意:这里虽然完成了Worker实例的创建,但是并没有配置任何属性。等在输出注入的Worker Bean的时候我们会看到这个实例的属性都是默认值。 引入Spring Component 使用@Import注解不仅可以引入普通的Java Bean,也可以引入Spring组件类,即需要使用@Component或者@Service等注解标记的类。组建类中通过@Autowired注解引用的其他组件也会被递归引用并注入。 示例应用中的WorkerService类并没有使用任何注解标记,而是在使用的时候通过@Import注解进行的引入。 这样虽然也可以使用,但并不建议这么做。 引入@Configuration注解的类 这个留到最后是因为一开始比较困惑:既然已经有@Configuration注解了,Spring就一定会自动引入这个类的,应该就没必要再使用@Import注解进行引用并注入了。 后来意识到我的想法是有漏洞的:比如一些第三方spring组件包中的配置类,既没有配置packageScan,也没有配置starter,直接使用肯定是不行的。此时使用@Import注解来导入相关的配置类及组件是一个很好地解决方案。 测试 执行WorkerControllerTest类的测试方法all(),观察测试结果,期间会输出我们创建的几个Worker Bean实例: 可以看到一个Worker实例的属性都是默认值,这个实例即是通过ImportBeanDefinitionRegistrar创建的Worker Bean “jerry”。 其他 关于@Import注解的实现原理可以参考 AbstractApplicationContext.refresh -> BeanFactoryPostProcessor -> ConfigurationClassPostProcessor -> ConfigurationClassParser.processImports()。具体就不展开了。 此外,还有另外一个注解@ImportResource主要用来引入xml或groovy配置文件。
[阅读更多...] -
CaffeineCache 慎用weakKeys
前两天在一个Spring项目里使用了Caffine缓存,在application.yml中的配置如下: 为了避免缓存占用过多内存导致频繁GC,使用了weakKeys和weakValues选项。 不过测试时发现缓存不能命中,仍然会查询数据库。 通过debug发现,caffine使用WeakKeyReference将缓存的key做了封装。WeakKeyReference的结构如下: 需要注意这里的equals()和hashCode()方法。 equals()调用的referenceEquals()方法是接口InternalReference的default方法,具体为: referenceEquals()方法中调用的get()方法在WeakKeyReference类中获取的是key的原始值。在方法中对两个key是否一致的判定使用的是==,而非是equals()。也就是说需要两个key指向同一个对象才能被认为是一致的。 hashCode()的实现也与equals()方法呼应。生成hashCode使用的是System.identityHashCode()。identityHashCode方法是jre的一个native方法,这个方法的注释如下: 注释说明这个方法对于指定的对象会返回相同的hashCode。即这个方法是针对对象进行操作的,比如两个字符串对象,即使其字符序列相同,通过identityHashCode方法生成的hashCode也不会相同。 看一个示例程序: 示例程序输出了相通字符序列“zhyea”的两个字符串对象的identityHashCode执行结果,结果为: 可以看到最终结果是不同的。 到现在缓存不能命中的原因应该是找到了:因为使用了weakKeys选项,caffine使用WeakKeyReference封装了缓存key,导致相同字符序列的不同String对象的key被视为是不同的缓存主键。 果然在去掉weakKeys和weakValues配置项后,测试发现缓存能够命中了。 后来在Caffeine的文档中找到了如下说明: Caffeine.weakKeys() stores keys using weak references. This allows entries to be garbage-collected if there are no other strong references to the keys. Since garbage collection depends only on identity equality, this causes the whole cache to use identity (==) equality to compare keys, instead of equals(). 文档中提到因为GC的限制,需要对weakKey使用“==”替换equals()。 原因算是找到了,不过回过头来想想,在Spring中Caffeine的weakKeys选项确实有些鸡肋:Spring的CacheKey生成方式导致weakKey必然指向不同的对象,结果就是缓存注定不能命中,并且每次调用都会在缓存中插入一条新的记录。这样尽管使用weakKey不会造成内存泄漏,可是也会增加GC负担。因此在SpringBoot中使用Caffeine时需要慎用weakKeys。
[阅读更多...] -
关于代码重构那些事
首先抛出观点:重构既有代码并不是一件讨好的事情,甚至是一件无功有过的事。 说个我朋友的事(我的一个朋友系列)。这个人颇有些强迫症。他在入职某家公司一段时间后,实在受不了负责模块那些祖传代码的组织方式,就用工作之余的的时间彻底重构了部分内容。也算幸运,他的重构工作没有影响生产业务。在年底进行绩效评估的时候,他将代码重构作为绩效的一部分汇报,却没有得到领导的认可。为什么呢?因为即使没有重构,以前的代码也能满足业务需求,而且重构后的代码并没有性能上的显著提升,他的工作被视为是可有可无的。 对于朋友的做法,我现在说不上不认可;对于当时他的领导的做法,我也能够理解。如果对代码进行重构的肇始是缘于洁癖或强迫症,那么最好三思三思三思而后行,至少也不要一开始就全面重构。因为既有的祖传代码虽然可能比较糟糕,但还是经过了生产业务的考验的。而修改后的代码必须得经过充分的测试才能投入生产。而这种测试我不认为是开发一个人进行自测就能充分覆盖完全的,项目组需要为这些改动多投入一些成本。 但是重构就是完全不能进行了么?我也没这么说。我只是强调了要优先保证生产环境的稳定性,并且需要顾及到团队投入的成本。重构还是要做的,但是要掌握好契机。 比如有新需求来的时候,如果继续用过往的代码适配新的业务逻辑会消耗太多的时间。这些时间甚至超过了重构的时间。那么此时优先选择重构。 再比如发现引入新的技术方案可以显著提升当前系统的性能,改善系统的短板。那么经过团队讨论后,可以在引入新方案的时候顺手实现代码的重构。 充分利用这些机会,就可以在不会过多占用团队资源的前提下,重构既有代码。 如果没有理想的机会的时候也可以小范围的进行调整,或者拉一个新的分支在不影响生产系统的前提下实现对既有代码的改造。这样在真正需要大面积改造的时候也就有了足够的基础。
[阅读更多...] -
Spring Controller层测试 – 05 SpringBootTest & WebServer
在使用@SpringBootTest测试时可以指定一个端口,如@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 或 @SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT),这样在测试时会启动Spring内嵌的Http Server。 这时就可以使用一个RestTemplate 或者 TestRestTemplate。 使用RANDOM_PORT和DEFINED_PORT的区别在于前着使用的是配置文件中的端口号(server.port,默认值为8080),后者使用的是一个随机端口号。在进行并行测试的时候可以使用随机端口号以避免端口冲突。 看下测试代码: Web Server测试 这里依然使用SpringRunner执行测试。同时使用@SpringBootTest注解的RANDOM_PORT 模式来得到一个内嵌的WebServer运行当前应用。 测试代码中使用RestTemplate来触发请求,这个过程和使用外部服务器很像。 测试中的assertion现在有了一点儿变化,因为要验证的返回值由MockHttpServletResponse变成了ResponseEntity。 TestRestTemplate 因为使用了@SpringBootTest注解,就可以使用@Autowired注解来获取TestRestTemplate的实例。这个TestRestTemplate实例的作用和常见的RestTemplate实例几乎没有任何区别,只是添加了一些额外的能力。实际上,可以将TestRestTemplate视为RestTemplate在测试环境的装饰类。 参与测试的角色关系如下图: 关于性能 也许我们会觉得第一个方案会更加有性能优势,因为它不需要加载Spring Context。事实上确实如此。但是即使加载了SpringContext,也不会造成特别恐怖的影响,因为在同一个Test Suite中,已经加载的Spring Context是可以重用的。 不过Spring Context重用也会导致一些问题,比如一些测试方法对公共的Bean做了修改就可能会影响到其他测试方法。此时可以使用@DirtiesContext注解来要求重新加载Context。 总结 到现在为止我们从轻到重共介绍了四种SpringBoot Controller测试方案。 虽然我们的目标一直都是对Controller层进行测试,但是从第一种测试方案(Standalone MockMVC)到现在,测试的角度还是有些变化的。一开始我们只是会加载测试的Controller类,却不会加载其周边的一些角色如Filter或Advice。到现在这个方案里我们启动了内嵌的WebServer,加载了整个SpringBoot Context。 目前这个方案是提到的四个测试方案里最重的一个,也是离单元测试的概念最远的一个。 下面说几个使用建议: 如果在单元测试中关注Controller的逻辑,优先选择第一种方案:Standalone MockMVC方案; 如果要测试Web层的其它角色(Filter或Advice)的行为,优先选择第四种测试方案执行集成测试; 避免将单元测试和集成测试混在一起,最好分开来写。 其他 Spring Controller测试 – 01 概述 Spring Controller测试 – 02 Standalone MockMVC Spring Controller测试 – 03 WebContext & MockMVC Spring Controller测试 – 04 SpringBootTest & MockMVC Spring Controller测试 – 05 SpringBootTest & WebServer 示例代码可在CSDN下载,地址:https://download.csdn.net/download/tianxiexingyun/11065824 参考文档:https://thepracticaldeveloper.com/2017/07/31/guide-spring-boot-controller-tests/
[阅读更多...] -
Spring Controller层测试 – 04 SpringBootTest & MockMVC
这种测试方案会加载完整的SpringContext,但我们仍然不需要Web Server,需要继续通过MockMVC来模拟请求。 在测试的时候主要用到了@SpringBootTest注解。看下代码: @SpringBootTest和@AutoConfigureMockMvc 使用@SpringBootTest注解会加载整个Context。这样我们可以自动获得所有在Context中注入的Bean,以及从application.properties中加载的配置信息。 在@SpringBootTest中声明webEnvironment为WebEnvironment.MOCK(默认值就是WebEnvironment.MOCK)后,结合@AutoConfigureMockMvc注解,在测试的时候会得到一个模拟的Web/Servlet环境。 因为没有Web Server,所以就无法使用RestTemplate,也就只能继续使用MockMVC了。这次MockMVC的实例是由@AutoConfigureMockMvc注解来完成的。这归功于SpringBoot的自动化配置。 所有参与测试的对象的关系如下图: 总结 这种测试方案更倾向于集成测试。它的关注点主要在于SpringBoot不同类之间的交互。 在这个测试方案中,请求仍然是通过MockMVC模拟的。不过因为有一个完整的Context,请求处理过程中的所有逻辑都是真实的,请求返回结果也是真实的。因此测试效果和使用Web Server几乎是差不多的了。 如果还是要只测试Controller中的逻辑,也可以继续使用 @MockBean注解来mock一个IWorkerService实例来覆盖Context中已有的实例。即使使用了真正的WebServer,也可以继续使用MockBean。同样在测试中也需要继续mock数据。 概括来说,这种测试的位置稍显尴尬:如果要执行单元测试来测试WEB层的逻辑,建议优先第二种方案;如果要执行集成测试,启动一个真正的Web Server,使用RestTemplate进行测试会更彻底也更加方便。 Spring Controller测试 – 01 概述 Spring Controller测试 – 02 Standalone MockMVC Spring Controller测试 – 03 WebContext & MockMVC Spring Controller测试 – 04 SpringBootTest & MockMVC Spring Controller测试 – 05 SpringBootTest & WebServer 其他:示例代码可在CSDN下载,地址:https://download.csdn.net/download/tianxiexingyun/11065824
[阅读更多...]