提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
最近在一个新的项目中尝试使用了Kotlin,搭配使用了Spring WebFlux 、 Spring Security、Spring Data JPA框架,最近项目差不多做完了,整理下来作为记录
本文不介绍具体的技术原理等深层内容,有也只会是提一下,不会说太多,仅介绍相关技术的使用
一、为什么采用一个这么拧巴的架构
最近准备将技术架构整体换为异步的,在做了一些准备后,决定在这个项目上开始尝试转变,但是由于是甲方项目,甲方使用的是Oracle数据库+Java8的环境,且Oracle的R2DBC驱动只能在Java11以上的环境中使用,修改甲方的环境太过麻烦并且不太现实,所以最后决定放弃使用R2DBC,转而在ORM框架上继续使用SpringDataJPA,调整其他的部分为异步。这中间由于有很多异步转同步的代码(例如在Service中调用JPA进行存储),而且Kotlin的协程写起来对于大多数人来说更容易理解,所以我们决定将业务部分使用Kotlin实现,仅在SpringWebFlux+SpringSecurity架构搭建时,权限配置的代码上使用Mono编写(因为SpringSecurity框架配合SpringWebFlux的那些接口是需要返回Mono的)。
二、搭建步骤
1.创建项目
使用IDEA自带的创建工具创建:
1、New
2、Project
3、Spring Initializr
4、填写项目名、选择项目文件路径、选择使用Kotlin创建项目、Type选择Gradle - Kotlin (本次使用Gradle构建项目)、填写Group、Artiface、Package Name、选择JDK、java版本、Packaging选择Jar,然后下一步
5、 SpringBoot版本修改为2.XX的最新版,选择需要的spring boot starder(这里选择了 Spring Configuration Processor、Spring Reactive Web、Spring Security、Spring Data JPA、OracleDriver或MySQLDriver),然后点创建
6、项目创建完成,修改一些项目的设置,或者添加一些自己需要的其他依赖,例如ApacheHttpClient、Netty等
2.Gradle与Maven的结构对比
Maven项目中有两个主要的部分,一个是src项目代码文件,另一个是pom.xml用来配置项目信息和依赖、编译插件等信息
Gradle项目中会有gradle文件夹用来保存Gradle版本信息以及Jar包、src文件夹是项目代码文件、build.gradle.kts(Gradle-Kotlin是这个名字,Gradle-Groovy是build.gradle)文件用来保存项目依赖、Java版本、编译插件等项目构建所使用的信息、settings.gradle.kts文件保存项目结构、任务等信息,主要是用于配置子模块
由于使用了Kotlin,在src目录下的main中,除了之前Maven项目的resources文件夹,会多出来一个 kotlin 文件夹,里边是Kotlin的项目代码,如果项目中需要写一部分Java代码的话,只需要在 kotlin 目录同级创建一个 java 文件夹(src/main/java)即可
3.代码或知识点
3.1 将一个异步代码转为协程代码
由于我们项目使用的Kotlin的协程,但是Apache的异步HttpClient是通过回调函数实现的异步,在写Kotlin协程时比较麻烦,所以需要将ApacheHttpClient的转为一个协程方法
suspend fun request(
url: String, headMap: Map<String, String>?, contentType: ContentType, body: String, method: HttpMethod,
connectionTimeOut: Int, socketTimeOut: Int
): HttpRespResult = suspendCoroutine { continuation ->
log.info("HttpClientReqUrl:$url")
log.info("HttpClientReqBody:$body")
log.info("HttpClientReqHeader:$headMap")
log.info("HttpClientReqContentType:$contentType")
val httpClient = AsynHttpClient.getHttpClient()
//设置连接超时时间
val builder = RequestConfig.custom().setConnectTimeout(connectionTimeOut).setSocketTimeout(socketTimeOut)
val req =
if (method == HttpMethod.GET) {
val reqUrl = if (StringUtils.hasText(body)) "$url?$body" else url
HttpGet(reqUrl)
} else {
val postBody = HttpPost(url)
postBody.entity = StringEntity(body, contentType);
postBody
}
req.config = builder.build();
if (!headMap.isNullOrEmpty()) {
headMap.forEach { (name: String, value: String) ->
req.setHeader(name, value)
}
}
httpClient.execute(req, object : FutureCallback<HttpResponse> {
override fun completed(result: HttpResponse) {
// 在异步请求完成时执行的逻辑
val statusCode = result.statusLine.statusCode
val httpRespResult = HttpRespResult(statusCode)
//处理Header
val allRespHeaders: Array<Header> = result.allHeaders
if (allRespHeaders.isNotEmpty()) {
httpRespResult.respHeader = allRespHeaders.associateBy({ it.name }, { it.value })
}
//接收响应信息并打包为byte[]
httpRespResult.respBody = (EntityUtils.toByteArray(result.getEntity()))
continuation.resumeWith(Result.success(httpRespResult))
}
override fun failed(ex: Exception) {
//异常时的操作
continuation.resumeWithException(java.lang.RuntimeException(ex))
}
override fun cancelled() {
//取消时的操作
continuation.resumeWithException(java.lang.Exception("cancelled"))
}
})
}
可见,ApacheHttpClient的结果是通过在执行时传入的FutureCallback对象来处理的,所以我们在转为协程的时候,也是要通过FutureCallback这个对象来处理,而Kotlin的协程的结果是用一个类型为Continuation 的 continuation 这个对象来接收的,所以我们需要在原本FutureCallback 中处理的异常或者正常业务逻辑,都调用 continuation 来返回,所以在 FutureCallback 的 completed 方法中,我们调用了 continuation 的 resumeWith 方法返回一个正常的结果,然后在 FutureCallback 的 failed 和 cancelled 方法中调用 continuation 的 resumeWithException 方法返回一个异常的结果。
3.2 将一个异步代码转为Mono返回
最开始写了上边的那个异步HttpClient转为协程的代码,随后在编写SpringWebFlux的时候,我们需要调用甲方的一些认证服务做验证,这个时候还是直接写Mono比较合适,又不想代码重复,所以又将这个http的方法修改为Mono的
fun request(
url: String, headMap: Map<String, String>?, contentType: ContentType, body: String, method: HttpMethod,
connectionTimeOut: Int, socketTimeOut: Int
): Mono<HttpRespResult> {
return Mono.create { sink: MonoSink<HttpRespResult> ->
log.info("HttpClientReqUrl:$url")
log.info("HttpClientReqBody:$body")
log.info("HttpClientReqHeader:$headMap")
log.info("HttpClientReqContentType:$contentType")
val httpClient = AsynHttpClient.getHttpClient()
//设置连接超时时间以及检查设置代理服务器
val builder = RequestConfig.custom().setConnectTimeout(connectionTimeOut).setSocketTimeout(socketTimeOut)
val req =
if (method == HttpMethod.GET) {
val reqUrl = if (StringUtils.hasText(body)) "$url?$body" else url
HttpGet(reqUrl)
} else {
val postBody = HttpPost(url)
postBody.entity = StringEntity(body, contentType);
postBody
}
req.config = builder.build();
if (!headMap.isNullOrEmpty()) {
headMap.forEach { (name: String, value: String) ->
req.setHeader(name, value)
}
}
httpClient.execute(req, object : FutureCallback<HttpResponse> {
override fun completed(result: HttpResponse) {
// 在异步请求完成时执行的逻辑
val statusCode = result.statusLine.statusCode
val httpRespResult = HttpRespResult(statusCode)
//处理Header
val allRespHeaders: Array<Header> = result.allHeaders
if (allRespHeaders.isNotEmpty()) {
httpRespResult.respHeader = allRespHeaders.associateBy({ it.name }, { it.value })
}
//接收响应信息并打包为byte[]
httpRespResult.respBody = (EntityUtils.toByteArray(result.getEntity()))
sink.success(httpRespResult)
}
override fun failed(ex: Exception) {
//异常时的操作
sink.error(java.lang.RuntimeException(ex))
}
override fun cancelled() {
//取消时的操作
sink.error(java.lang.Exception("cancelled"))
}
})
}
会发现其实这个写法与上边的转为协程方法的代码差不多,只是在最开始创建Mono时使用了 MonoSink 这个类型,当 FutureCallback 里返回时,FutureCallback 的 completed 方法会调用 MonoSink 的success 方法返回一个正常的结果,在发生异常获取取消时,FutureCallback 的 failed 或 cancelled 方法会调用 MonoSink 的 error 方法返回错误信息
3.3 将一个Mono转为协程方法
以上3.2的http方法中返回了一个Mono,那么我们还是在有些情况下会使用协程,但是又想代码不要重复,所以我们新写了一个方法,将返回 Mono 的方法改为一个协程方法
suspend fun requestWithSuspend(....) {
this.request(...).awaitFirst()
}
Kotlin在 Publisher (Mono的上级接口)上增加了一些 await 方法,比如我们现在使用的 awaitFirst 就是等待第一个返回,其余的还有 awaitFirstOrDefault 、awaitFirstOrNull 、 awaitFirstOrElse 、awaitLast 、 awaitSingle 等方法。
3.4 协程的几个调度器
协程实际还是运行在线程之上的,这个是毋庸置疑,既然是线程就还是会涉及到线程池大小等内容,而且,在Kotlin的设计里,耗时的IO任务和计算任务是需要使用不同的协程调度器的,所以简单说一下调度器,调度器在withContext( ) 方法中传入,withContext方法可以在方法上直接调用,写成
suspen fun a() = withContext(Dispatchers.Default) {
//做点什么
}
或者可以写成
suspen fun a() {
//做点a
withContext() {
//做点b
}
//做点c
}
默认的调度器有以下几种:
Dispatcher.Default : 默认调度器,最大并行数等于 CPU 核心数,但至少为两个
Dispatcher.IO : IO调度器,默认为 64 个线程或内核数(以较大者为准),同时,此调度程序及其视图与Default程序共享线程,因此在。Default调度程序上运行时使用withContext(Dispatchers.IO) { … }通常不会导致实际切换到另一个线程
Dispatcher.Unconfined : 不局限于任何特定线程的协程调度程序。它会在当前调用框架中执行协程的初始延续,会在当前线程中执行
当我们调用JDBC的这种阻塞形操作时,更适合使用Dispatcher.IO 这个调度器。
3.5 调用协程方法
如果我们使用的是SpringWebFlux框架,那么我们在Controller上直接写上协程方法即可,Spring可以正常调用一个协程的Controller方法
类似:
@RequestMapping(value = ["hello"], produces = ["application/json"])
@ResponseBody
suspend fun hello(@RequestBody reqStr:String):String {
return "Hello , Request content: $reqStr";
}
但是,我们的Service中调用了JPA的相关的代码,这种代码是阻塞的,由于默认情况下SpringWebFlux调用协程的Controller方法使用的调度器是Dispatcher.Unconfined,所以如果我们使用默认调度器调用Controller,会很容易就导致netty线程阻塞满,因此我们需要切换一个调度器,这里我们使用了withContext切换了协程的上下文:
@RequestMapping(value = ["hello"], produces = ["application/json"])
@ResponseBody
suspend fun hello(@RequestBody reqStr:String):String = withContext(Dispatchers.IO) {
service.blockingMethod()
//这里不用写return
"Hello , Request content: $reqStr";
}
3.6 在一个非协程的方法中调用一个协程方法
在另外一些情况下,则需要涉及到在一个非协程的方法中调用一个协程方法,比如,我们有一个Netty服务用来接收客户交易,然而Netty的那些Handler都没有能直接支持协程的,所以我们要在Netty的处理方法(非协程)中调用一个Kotlin协程的方法,下边的代码,是一个继承与ChannelInboundHandlerAdapter 类的一个处理器
private val scope = CoroutineScope(Dispatchers.Default)
/**
* 从 ChannelInboundHandlerAdapter 继承下来的处理方法,在消息读取时调用
*/
override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
scope.launch {
handle(ctx, msg as EappayReq)
}
}
/**
* 协程处理方法
*/
private suspend fun handle(ctx: ChannelHandlerContext, msg: Any) {
//做些什么
ctx.writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE_ON_FAILURE)
}
3.7 协程中Spring事务的问题
经过测试,在Service中使用withContext时,Spring的事务管理会失效,这一块找了一些资料,但是没有找到根本原因,也没有深究,但是目前是这么个现象,处理方法是,目前所有的协程调度器切换都在Controller中进行,避免在Service曾切换协程调度。
3.8 切换协程之后,使用ReactiveSecurityContextHolder获取当前登录用户失效
本来是想在SpringDataJpa的Auditor中通过ReactiveSecurityContextHolder获取当前登录用户,然后将保存或者修改的实体设置上创建/更新用户的,但是在写完之后发现ReactiveSecurityContextHolder获取的context是空的,查询资料之后发现,由于SpringWebFlux不再像SpringMVC一样能保证一个请求都在一个线程中执行完,所以,与SpringMVC将登录信息等存储在ThreadLocal中不同,SpringWebFlux是将登录信息保存在Mono的Context中,这样在使用Mono时是可以获取到登录信息的,但是我们在业务中换为使用Kotlin协程,经过测试,如果在不使用withContext切换协程调度器的情况下,依然可以通过ReactiveSecurityContextHolder获取当前登录用户信息,但是如果一旦使用withContext切换协程调度器,那么获取到的context将为空,原因是因为SpringWebFlux在调用协程方法时,会将Context的信息传到协程的Context中,具体的代码在 spring-core 包中的CoroutinesUtils类的invokeSuspendingFunction方法,如果在使用withContext(Dispatchers.IO)切换协程调度器时,则会丢失Context的内容。
为了解决这个问题,在切换协程调度器时,应使用
@RequestMapping(value = ["hello"], produces = ["application/json"])
@ResponseBody
suspend fun hello(@RequestBody reqStr:String):String = withContext(coroutineContext + Dispatchers.IO) {
val authentication = ReactiveSecurityContextHolder.getContext().map { it.authentication }.awaitSingleOrNull()
service.blockingMethod()
//这里不用写return
"Hello , Request content: $reqStr";
}
但是切记,如果再使用withContext()切换协程调度器,需要也将当前的 coroutineContext 向下传递
而且,coroutineContext 和 Dispatchers.IO 这两个的加减顺序不能替换,如果将 coroutineContext 写到后边,则会导致协程调度器切换失败,因为 coroutineContext 的内部实际是类似于Map这样的k=v的存储方式,CoroutineContext 的相加方法,将会替换相同key的value值,所以会使用之前的协程调度器替换到你指定的协程调度器。
3.9 使用SpringDataJpa的Auditor获取当前用户
上一步中,我们已经了解到,直接使用SpringSecurity的ReactiveSecurityContextHolder是不能获取到当前登录用户的,但是最开始的时候,我们也想当然将 coroutineContext 一层层往下传递,先在controller的withContext往下传递,在service中继续传递(但是上边已经解释过Service中不能再使用withContext切换上下文,否则会导致Spring事务管理失效,所以此处实际并没有在service中切换上下文),就能通过 ReactiveSecurityContextHolder 获取到当前登录用户,但是实际上,在service中调用jpa的相关代码的时候,其实已经脱离了Mono和协程的context,所以实际上这么操作还是获取不到需要的值,得到的Context依然是null
后来,我们采用的方式是使用ThreadLocal.asContextElement() 方式传递的当前登录用户
- 保存登录信息的ThreadLocal,使用asContextElement获取到一个ThreadContextElement
object MyCoroutineContext {
private val authenticationThreadLocal: ThreadLocal<Authentication> = ThreadLocal<Authentication>()
suspend fun setContext(): ThreadContextElement<Authentication?> {
val authentication = ReactiveSecurityContextHolder.getContext().map { it.authentication }.awaitFirstOrNull()
return this.setContext(authentication)
}
fun setContext(authentication: Authentication): ThreadContextElement<Authentication> {
return authenticationThreadLocal.asContextElement(authentication)
}
fun getContext(): Authentication {
return authenticationThreadLocal.get()
}
}
- Controller代码在withContext中加上登录信息的ThreadContextElement
suspend fun a () : String =
withContext(Dispatchers.IO + MyCoroutineContext.setContext()) {
service.save(...)
"Hello!"
}
- Service代码,正常保存
suspend fun serviceA(info:Any) {
dao.save(info)
}
- JPA获取登录信息的AuditorAware
class MyUserAuditorAware : AuditorAware<String> {
private val log = LoggerFactory.getLogger(MyUserAuditorAware::class.java)
override fun getCurrentAuditor(): Optional<String> {
val name = MyCoroutineContext.getContext().name
log.info("获取登录用户【$name】")
return if (name == null) Optional.empty() else Optional.of(name)
}
}
总结
目前来说,能想起来的要点就这些,并且过程比较紧急,整理下来的内容都比较表象,深层次的东西很多也还是没有弄清楚,如果有不清楚的或者我说的不对的,欢迎留言评论,感谢大佬的指正。
之后应该会补充一些SpringWebFlux加上SpringSecurity配置的代码,如果近期有时间可能就补上