使用Kotlin编写SpringBoot搭建的SpringWebFlux和SpringSecurity项目的记录

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

最近在一个新的项目中尝试使用了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() 方式传递的当前登录用户

  1. 保存登录信息的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()
    }
}
  1. Controller代码在withContext中加上登录信息的ThreadContextElement
suspend fun a () : String = 
	withContext(Dispatchers.IO + MyCoroutineContext.setContext()) {
		service.save(...)
		"Hello!"  
	}
  1. Service代码,正常保存
suspend fun serviceA(info:Any) {
    dao.save(info)
}
  1. 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配置的代码,如果近期有时间可能就补上

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值