关于Kotlin/Js和Spring 使用Kotlin同时开发前后端(一)搭建Kotlin-Multiplatform工程

前言


好久没写文章了,有好多东西想分享,但是又不知道从何写起,正好最近在学习 Kotlin,那就先从 Kotlin-Multiplatform 开始吧。

本文章介绍,如何通过Kotlin-Multiplatform实现共享逻辑代码,以及使用Kotlin/Js开发Html5工程和Kotlin/Jvm开发Web工程。

本次文章总共三篇

  • 第一篇:《搭建Kotlin-Multiplatform工程》
  • 第二篇:《通过React + Spring实现前后台简单列表》
  • 第三篇:《通过Webpack + Gradle实现环境分离》

使用工具

  • 开发工具:IntelliJ IDEA + Gradle
  • 语言:Kotlin
  • 框架:Spring + React

代码已上传至 GitHub

这是本文章的第一篇,介绍如何搭建 Kotlin-Multiplatform 前后端工程。

本文章默认你已经懂得SpringBoot相关知识、有使用Gradle搭建过工程、并且懂得Kotlin语言和部分React架构使用。

相关知识可以从以下渠道获知

正文


一、Kotlin-Multiplatform介绍

Kotlin-MultiplatformKotlin推出的一种跨平台的开发技术,通过它可以共享你的逻辑代码,以此来实现一套逻辑代码全平台通用的目的。

结构图

注意上面的图,从实际上来说这是一种不同于 Flutter 的技术,Flutter 是编写一套全部代码然后最终通过底层渲染引擎绘制,在开发 Flutter 的过程中你基本不需要关心各个平台的技术标准。

但是 Kotlin-Multiplatform 却是 KotlinCommon + KotlinNative 的模式,也就是说除了公共部分可以共享,其他部分都必须按照各个平台标准开发,只不过语言从各个平台的原生语言切换为 Kotlin 语言开发,具体图示看Kotlin-Multiplatform官方文档

这样看起来比起 Flutter 是弱爆了,而且从真正节省开发成本的角度来说它未必可以节省多少,但是从实际上来说二者其实是在不同的维度,Flutter 是一个大前端框架,它的野心是统一整个前端开发,而 Kotlin-Multiplatform 其实是想从语言的角度统一平台编程开发。

也就是说Flutter是框架而Kotlin-Multiplatform是语言,本次的文章也是通过使用Kotlin语言同时开发前后台。

其实我觉得业界最可惜的就是为什么 Kotlin 不能开发 Flutter ←_←

二、工程搭建

0.序

首先必须点名下 Kotlin-Multiplatform 的缺点:

  • 1.虽然它可以使用一套语言来开发,但是你必须遵循各个平台的技术标准,而且各个平台下 Kotlin 实现都有所不同(比如Kotlin/Js下反射就用不了),有些问题解决起来就非常麻烦。
  • 2.相关的文档和三方库都少的可怜(这里指与Kotlin-Multiplatform相关),虽然 Kotlin 官方提供了使用原生包的方式,但是你必须手动做一层转换,这也意味着你必须懂得当前平台的原生语言(假如你不是在开发全栈程序)。
  • 3.因为最终会转换为本地语言(比如Kotlin/Js最终会转换为Js运行),所以当你遇到需要调试的时候就会显得非常麻烦(Android因为原生支持Kotlin开发所以会好很多),而原生的庞大社区你只能用一半(这一半也要求你必须懂得原生语言)。

说了那么多缺点,但其实想想用同一种语言开发前后台也是一件很令人兴奋的事情。

1.目录结构

首先需要先说明下目录结构

  • common 公共模块,存放所有公共代码,使用kotlin-multiplatform插件
    Kotlin-Multiplatform 模块与其他模块最大的不同就是不再使用单一的 main 而是区分各个平台
    • commonMain 纯 Kotlin 代码,存放业务模型
    • commonTest
    • jsMain Kotlin/Js 公共代码,只会被 Kotlin/Js 模块引用
    • jsTest
    • jvmMain Kotlin/Jvm 公共代码,只会被 Kotlin/Jvm 模块引用
    • jvmTest
    • 其他所支持平台
  • dashboard 前端模块,存放所有前端逻辑代码,使用org.jetbrains.kotlin.js插件
    • main
    • test
  • server 后端模块,存放所有后端逻辑代码,使用javakotlin插件
    • main
    • test

具体如下图:

目录结构

2.插件引入

开始前需要引入 Kotlin-Multiplatform 需要的插件,在根目录的 build.gradle 添加如下代码:

这里需要说明的是,我这里使用的是 gradle 旧版本使用插件的方式,如果想要使用新的方式可以查看 org.jetbrains.kotlin.multiplatform

allprojects {
    repositories {
        mavenCentral()

        //Kotlin-Multiplatform的插件和包在这三个仓库里
        maven { url 'https://dl.bintray.com/kotlin/kotlinx' }
        maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
        maven { url 'https://kotlin.bintray.com/kotlin-js-wrappers' }
    }
}

buildscript {
    ext {
        //kotlin版本
        kotlinVersion = '1.4.10'
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}"
    }
}

subprojects {
    apply plugin: 'idea'

    version = '1.0.0'
}

3.构建common模块

接下来我们开始构建公共模块,在 common 文件夹下创建 build.gradle 文件:

apply plugin: 'kotlin-multiplatform'

kotlin {
    jvm {
        withJava()
    }
    js {
        browser {
        }
        //指示将Kotlin编译为Js代码,默认行为可以为不添加
        binaries.executable()
    }

    sourceSets {
        commonMain {
            dependencies {
                //这里引入纯Kotlin代码库
                implementation 'org.jetbrains.kotlin:kotlin-stdlib-common'
            }
        }
        jvmMain {
            dependencies {
                //这里引入Java或者Kotlin/Jvm代码库
            }
        }
        jsMain {
            dependencies {
                //这里引入Js或者Kotlin/Js代码库
            }
        }
    }
}

然后我们就可以在 common 模块下添加代码,比方说加个通用异常类

class ServerException(override val code: Int,
                      override val errorMessage: String?
) : RuntimeException() {

    constructor(error: IServerError) : this(error.code, error.errorMessage)

    constructor(error: IServerError, errorMessage: String?) : this(error.code, errorMessage)

}

这样一个通用的模块就创建好了,其他的jsMainjvmMain也是一样的用法,但是在引包的时候需要注意是否为相关平台的库。

最后别忘了在 setting.gradle 里面添加 include ':common'

4.构建dashboard模块

第二步构建 Kotlin/Js 前端模块,这个模块存放我们前端的业务和UI代码(相对的common模块的jsMain用于存放逻辑代码)。

4.1 创建模块

一样的先在 dashboard 文件夹下创建 build.gradle 文件:

apply plugin: 'org.jetbrains.kotlin.js'

dependencies {
    testImplementation "org.jetbrains.kotlin:kotlin-test-js"
    implementation 'org.jetbrains.kotlin:kotlin-stdlib-js'

    //引入common模块,commonMain和jsMain的代码会被引入
    implementation project(':common')
}

kotlin {
    js {
        //指示使用jsMain代码
        useCommonJs()
        browser {
            webpackTask {
                //当使用webpack时添加对于css的支持
                cssSupport.enabled = true
            }

            runTask {
                //当直接运行时添加对于css的支持
                cssSupport.enabled = true
            }

            testTask {
                useKarma {
                    useChromeHeadless()
                    webpackConfig.cssSupport.enabled = true
                }
            }
        }
        binaries.executable()
    }
}

4.2 引入支持库

为了能够在 Kotlin/Js 里直接开发网页,我们还需要在 build.gradle 导入几个官方库:

dependencies {
    //ktor是JetBrains推出的一个Web框架,这里使用它的Client部分
    implementation "io.ktor:ktor-client-js:${ktorVersion}"
    implementation "io.ktor:ktor-client-json-js:${ktorVersion}"
    implementation "io.ktor:ktor-client-serialization-js:${ktorVersion}"

    //官方的Kotlin/React框架库
    implementation "org.jetbrains:kotlin-react:${kotlinReactJsVersion}"
    implementation "org.jetbrains:kotlin-react-dom:${kotlinReactJsVersion}"
    implementation npm('react', reactJsVersion)
    implementation npm('react-dom', reactJsVersion)

    //官方的Kotlin/Css库
    implementation "org.jetbrains:kotlin-styled:${kotlinStyledVersion}"
    implementation npm('styled-components', styledComponentsVersion)
    implementation npm('inline-style-prefixer', stylePrefixer)
}

npm 是 Kotlin/Js 插件支持的一种导入 Js 库的方式,具体查看官方文档

同时在根目录下的 build.gradle 添加版本号:

buildscript {
    ext {
        //kotlin
        ktorVersion = '1.4.0'

        //js
        reactJsVersion = '^16.13.1'
        kotlinReactJsVersion = '16.13.1-pre.124-kotlin-1.4.10'

        reactRouterJsVersion = '5.1.2'
        kotlinReactRouterJsVersion = '5.1.2-pre.124-kotlin-1.4.10'

        styledComponentsVersion = '~5.2.0'
        stylePrefixer = '~6.0.0'
        kotlinStyledVersion = '5.2.0-pre.124-kotlin-1.4.10'
    }
}

4.3 引入资源文件

这里还有最后一个步骤,在 resources 下创建 index.html 文件,并引入我们的 dashboard.js 文件:

<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Kotlin/Js Sample Dashboard</title>
</head>
<body>
<div id="root"></div>
<script src="dashboard.js"></script>
</body>
</html>

这里需要注意dashboard.js这个文件的名称默认是与工程名相同的

完成上述操作后我们就可以开发网页了,添加一个 main.kt 文件:

fun main() {
    render(document.getElementById("root")) {
        //h1本质是kotlin的一个函数,对应html的h1标签
        h1 {
            +"Hello, React+Kotlin/JS!"
        }
    }
}

然后通过 <kbd>Gradle</kbd> > <kbd>dashboard Tasks</kbd> > <kbd>kotlin browser</kbd> > <kbd>browserDevelopmentRun</kbd> 启动前端程序:

启动dashboard

最终启动后访问 http://localhost:8080 结果如下图:

弹窗

5.构建server模块

第三步构建 Kotlin/Jvm 后端模块,这个模块存放我们后端的业务代码。

我这里使用的是 Spring + Kotlin 作为后端,详细构建可以参考这篇文章《使用SpringBoot和Kotlin构建Web程序》

1.创建模块

同样先在 server 文件夹下创建 build.gradle 文件:

apply plugin: 'java'

apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'kotlinx-serialization'

apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

compileKotlin {
    kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8
}
compileTestKotlin {
    kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8
}

test {
    useJUnitPlatform()
}

dependencies {
    //测试库
    testImplementation "org.jetbrains.kotlin:kotlin-test-junit5:${kotlinVersion}"
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    //SpringBoot和WebFlux库
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'

    //Jackson-Kotlin库,用于解决data class没有空构造函数导致无法解析的问题
    implementation "com.fasterxml.jackson.module:jackson-module-kotlin:${jacksonVersion}"

    implementation project(':common')
}

2.添加配置文件

在 resources 下添加一个 Spring 配置文件 application.yml

spring:
  application:
    name: server
  profiles:
    active: ${SMART_FOX_ACTIVE:local}

server:
  port: 9511

3.创建Application

导入库后创建一个 SampleServerApplication.kt 启动类:

@SpringBootApplication
class SampleServerApplication

fun main(args: Array<String>) {
    runApplication<SampleServerApplication>(*args)
}

启动图如下:

Spring启动

三、业务开发

在第二节我们成功搭建了一个 Kotlin-Multiplatform 项目,接下来我们尝试使用通用业务模型来实现前后台的通信,以此来感受 Kotlin-Multiplatform 的优势。

1.common部分

common 这是 Kotlin-Multiplatform 最精华的部分,通过它我们可以使用一套代码实现所有平台的逻辑,甚至可以把这部分抽离出来打成公共库。

想象一下,当整个部门不管是前端还是后端都使用同一种语言,那么我们在平时开发过程中遇到的问题,甚至一些非常好的代码都可以共享,它们可以成为整个部门的积累。

而且各个岗位之间的界线都将变得糢糊,让所有人都能更加容易的触类旁通,跨越障碍。

在这里我将通用模型放在 common 模块下,然后在 server 和 dashboard 模块下引用,如下图:

模块图

为了实现这一目的我们需要先引入 kotlinx.serialization 工程,它是 Kotlin 官方的解析库,支持JSONProtobufCBORHoconProperties格式。

在 common 下的 build.gradle 引入插件:

apply plugin: 'kotlinx-serialization'

kotlin {
    sourceSets {
        commonMain {
            dependencies {
                //忽略其他部分...
                implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:${kserializationVersion}"
            }
        }
}

然后就可以添加通用模型 ServerResult.kt,省略后的写法如下:

//解析注解
@Serializable
data class ServerResult<T>(
        val result: T? = null,
        val code: Int
) {
    companion object {
        fun <T> success(result: T): ServerResult<T> {
            return ServerResult(result, 200)
        }
    }
}

2.server部分

上面我们在 common 模块创建了一个通用模型,然后我们就可以在其他模块直接使用。

在 server 模块下创建一个 HomeController.kt 类:

@RestController
@RequestMapping("/home")
class HomeController {

    @GetMapping
    fun welcome(): Mono<ServerResult<String>> {
        return Mono.just(ServerResult.success("Welcome"))
    }
    
}

启动 Spring 后访问接口 http://localhost:9511/home 返回如下字符串:

//这里返回值已做简化
{"result":"Welcome","code":200}

最后为了能让dashboard独立运行后能访问,添加 CorsGlobalConfiguration.kt 解决跨域问题:

@Configuration
class CorsGlobalConfiguration : WebFluxConfigurer {

    override fun addCorsMappings(corsRegistry: CorsRegistry) {
        corsRegistry.addMapping("/home/**")
                .allowedOrigins("*")
                .allowedMethods("*")
    }

}

3.dashboard部分

现在我们已经完成了后端部分,下面需要开发我们的前端模块。

这里需要说明的是,在传统的类似 Thymeleaf 或者 Jsp 的模板引擎上,是通过服务器渲染完成后在返回,而我们这里开发的实际上是前后端分离的项目,所以它需要做网络请求,这也是前面引用ktor-client的用处。

3.1 封装网络请求类

为了实现网络请求,我们还需要对ktor-client做一点小小的封装,添加 Server.kt 文件:

//请求地址 如果是获取本地url则使用 window.location.origin
val endpoint = "http://localhost:9511"

//json解析器
val json = Json {
    ignoreUnknownKeys = true
}

//客户端
val client = HttpClient {
    install(JsonFeature) {
        serializer = KotlinxSerializer(json)
    }
}

//发起get请求
suspend fun <T> get(path: String,
                    serializer: KSerializer<T>,
                    block: HttpRequestBuilder.() -> Unit = {}): T {
    //这里默认解析为字符串
    val response = client.get<String>(endpoint + path, block)

    return parse(serializer, response)
}

private fun <T> parse(serializer: KSerializer<T>, response: String): T {
    //这里这样写是因为Ktor/Js还不支持泛型返回类解析,所以需要传入解析器
    val result: ServerResult<T> = json.decodeFromString(ServerResult.serializer(serializer), response)
    if (!result.code != 200) {
        throw ServerException(result)
    }

    return result.result ?: throw ServerException(500, "返回值为空")
}

Ktor 的 issues 看这里

第二步添加 HomeRepository.kt 文件,用于接口请求:

//挂起函数,协程调用
suspend inline fun welcome(): String {
    return get("/home", String.serializer())
}

3.2 装饰页面

通过welcome()方法已经可以访问接口并且获取数据,现在我们改造下 Main.kt 文件,我们为页面添加一个标题和一个按钮,然后通过点击按钮请求服务器,最后通过弹窗显示返回的结果:

fun main() {
    render(document.getElementById("root")) {
        //style...开头的是kotlin-styled包里的控件
        styledH1 {
            //直接设置css
            css {
                justifyContent = JustifyContent.center
                display = Display.flex
            }

            +"Hello Kotlin/Js"
        }

        styledDiv {
            css {
                justifyContent = JustifyContent.center
                display = Display.flex

                marginTop = 100.px
            }

            styledButton {
                css {
                    width = 120.px
                    height = 60.px
                }

                //设置属性
                attrs {
                    //注册单击函数
                    onClickFunction = {
                        //点击后通过协程发出请求
                        MainScope().launch {
                            val result = welcome()

                            //获取结果后通过弹窗显示返回值
                            window.alert("请求结果:$result")
                        }
                    }
                }

                +"发起请求"
            }
        }
    }
}

Kotlin-React/DSL 看《官方教程》或者《kotlinx.html》

启动后访问 http://localhost:8080 最终效果如下图:

[图片上传失败...(image-3cd4db-1603951807955)]

点击按钮后如下图:

弹窗

4.合并部署

通过上面的步骤我们已经成功开发出了前端和后端,但其实还差一个东西,在正常的前后端分离开发中,前端模块通常会放到 Cdn 进行分流加速,或者打包到 Native 运行。

但是如果我是一个在内部或者主功能是提供接口的服务,只需要一个仪表盘用来显示数据,那么放 Cdn 明显是不合适的。

通常对于这样的需求有2种做法,第一种一起打到 Docket 镜像中,在 Docker 启动时一起启动,或者直接将前后端打到一个 Jar 包里一起启动,这也是我这里的做法。

要实现这样的需求我们要利用 Gradle 的功能,在 server 模块下的 build.gradle 下添加如下代码:

//继承processResources任务
processResources {
    //获取dashboard模块
    def jsProject = project(':dashboard')

    //获取dashboard模块中的browserProductionWebpack任务
    def task = jsProject.tasks.getByName('browserProductionWebpack')

    //设置processResources任务的/static文件夹,来源为browserProductionWebpack任务的目标目录
    from(task.destinationDirectory) {
        into 'static'
    }

    //在processResources任务之前执行browserProductionWebpack任务
    dependsOn(task)
}

添加脚本前的 build 过程如下图:

修改配置前

在执行processResources任务前,会先执行 dashboard 模块。

当然这样带来的问题就是构建时间会大量增加,主要是因为 npm 获取包时非常慢,并且 kotlin 需要编译完整编译为 Js,这一点我们将在第二篇解决。

添加脚本后的 build 过程如下图:

修改配置后

build 过后会在 server 模块下的 build/resources 下的生成 static 文件夹,如下图:

资源目录

重新启动 Spring 访问 http://localhost:9511,点击按钮后显示如下图:

[图片上传失败...(image-c302da-1603951807955)]

结尾


第一篇到这里就结束了,这篇文章介绍了如何搭建基础工程和前后端的交互,下一篇会介绍 React 在 Kotlin/Js 中的使用。

除了像我这样实现一个前后台的开发项目,搭建 Kotlin/Native 工程的过程也是大同小异。

Kotlin-Multiplatform 是一个非常有意思的工程,而 Kotlin 现在也正在通过它将触角探入到各个平台开发中去,随着 Kotlin 版本的更新,Kotlin-Multiplatform 也将愈加完善,也许以后同一个部门,甚至一个公司都用同一种语言的事情也会发生,那会变得多么有趣。

最后,如果它有解决你的问题的话,请下点个赞,谢谢。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345