2023年11月06日
使用Kotlin/JS和Spring Boot进行全栈开发
在Web开发这个充满活力的世界中,单页应用程序(SPA)和像React、Angular和Vue.js这样的框架已经成为提供无缝用户体验的首选方法。随着Kotlin语言的发展和其最近的多平台能力,出现了一些值得评估的新选择。
在本文中,我们将探讨Kotlin/JS用于创建一个与后端Spring Boot通信的Web应用程序,后端也是用Kotlin编写的。为了尽可能简单,我们不会引入任何其他框架。
Kotlin/JS在SPA开发中的优势
正如官方文档所描述的那样,Kotlin/JS提供了将Kotlin代码、Kotlin标准库和任何兼容的依赖项转译为JavaScript(ES5)的能力。使用Kotlin/JS,我们可以利用Kotlin的简洁性和表现力,结合其与JavaScript的兼容性,来操作DOM,并创建动态HTML。当然,我们也拥有非常重要的类型安全性 ,这减少了运行时错误的可能性。
这使得开发人员能够以更少的样板代码和更少的错误来编写客户端代码。此外,Kotlin/JS可以无缝集成流行的JavaScript库(和框架),从而利用现有工具和资源的广泛生态系统。最后但同样重要的是:这使得后端开发人员更容易参与前端部分,因为它看起来更加熟悉。当然,需要适度了解“原生”JavaScript、DOM和HTML;但特别是当我们处理非密集型应用程序(管理面板、后台网站等)时,可以相对顺利地参与其中。
示例项目
此演示的完整源代码可在GitHub上找到。后端利用Spring Security保护了一个简单的具有基本CRUD操作的RESTful API。我们不会在这方面展开更多,因为我们希望把重点放在前端部分,它演示了以下内容:
- 使用用户名/密码登录
- 基于Cookie的会话
- 具有多个选项卡和顶部导航栏的页面布局(基于Bootstrap)
- 客户端路由(基于Navigo)
- 具有分页、排序和过滤功能的表格,其中的数据来自后端获取(基于DataTables)
- 包括(依赖)下拉列表的基本表单(基于Bootstrap)
- 模态框和加载遮罩(基于Bootstrap和spin.js)
- 使用sessionStorage和localStorage
- 使用Ktor HttpClient向后端发起HTTP调用
下面的图表提供了架构概述:
架构概述起点
开始探索的最简单方法是从IntelliJ创建一个新的Kotlin多平台项目。项目的模板必须是“全栈Web应用程序 ”:
这将创建以下项目结构:
springMain
:包含服务器端实现的模块。springTest
:用于Spring Boot测试commonMain
:此模块包含前端和后端之间的“共享”代码,例如DTOscommonTest
:用于“共享”模块的单元测试jsMain
:这是负责我们的SPA的前端模块。jsTest
:用于Kotlin/JS测试
GitHub上的示例项目就是基于这个特定的框架。一旦克隆了项目,您可以通过执行以下命令启动后端:
$ ./gradlew bootRun
这将启动SpringBoot应用程序,监听端口:8090。为了启动前端,执行以下命令:
$ ./gradlew jsBrowserDevelopmentRun -t
这将自动打开一个浏览器窗口,导航到http://localhost:8080,并呈现用户登录页面。为了方便起见,服务器上预置了一些用户(详细信息请查看**dev.kmandalas.demo.config.SecurityConfig** )。
一旦登录,用户将看到一组选项卡,其中主选项卡呈现了一个从服务器获取的项目表格(数据网格)。用户可以与表格交互(分页、排序、过滤、数据导出),并通过点击“添加产品 ”按钮添加新项目(产品)。在这种情况下,将在模态框中呈现一个包含典型输入字段和从服务器获取的依赖下拉列表的表单。实际上,在这部分应用了一些缓存,以减少网络调用。
最后,用户可以从顶部导航栏切换主题(此设置保存在浏览器的本地存储中)并执行注销操作。在下一节中,我们将探索前端模块的一些低级细节。
jsMain模块
让我们先来看一下模块的结构:
Kotlin文件的命名应该能够让人了解每个类的职责。当然,“入口点”当然是Main.kt 类:
import home.Layout import kotlinx.browser.window import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch fun main() { MainScope().launch { window.onload = { Layout.init() val router = Router() router.start() } } }
一旦加载了“index.html ”文件,我们就初始化Layout
和我们的客户端Router
。
现在,“index.html ”导入了我们使用的JavaScript源文件(Bootstrap、Navigo、Datatables等)及其对应的CSS文件。当然,它还导入了我们的Kotlin/JS应用程序的“转译”JavaScript文件。除此之外,HTML body部分包括一些静态部分,如“顶部导航栏”,最重要的是我们的根HTML div标签。在这个标签下,我们将执行所需的DOM操作,用于我们的简单SPA。
通过在我们的Kotlin类和单例中导入kotlinx.browser
包,我们可以访问顶级对象,如document
和window
。标准库为这些对象公开的功能提供了类型安全的包装器(在可能的情况下),如浏览器和DOM API中所述。
这就是我们在模块的大部分部分所做的事情,通过编写Kotlin而不是JavaScript或使用jQuery,同时又具有类型安全性,而不使用例如TypeScript。因此,我们可以像这样创建内容:
private fun buildTable(products: List<Product>): HTMLTableElement { val table = document.createElement("table") as HTMLTableElement table.className = "table table-striped table-hover" // Header val thead = table.createTHead() val headerRow = thead.insertRow() headerRow.appendChild(document.createElement("th").apply { textContent = "ID" }) headerRow.appendChild(document.createElement("th").apply { textContent = "Name" }) headerRow.appendChild(document.createElement("th").apply { textContent = "Category" }) headerRow.appendChild(document.createElement("th").apply { textContent = "Price" }) // Body val tbody = table.createTBody() for (product in products) { val row = tbody.insertRow() row.appendChild(document.createElement("td").apply { textContent = product.id.toString() }) row.appendChild(document.createElement("td").apply { textContent = product.name }) row.appendChild(document.createElement("td").apply { textContent = product.category.name }) row.appendChild(document.createElement("td").apply { textContent = product.price.toString() }) } document.getElementById("root")?.appendChild(table) return table }
或者,我们可以使用kotlinx.html
库的类型安全HTML DSL,看起来非常酷。或者我们可以将HTML内容加载为“模板”并进一步处理它们。似乎存在许多可能性来完成这项任务。
接下来,我们可以像这样为我们的UI元素附加事件监听器,从而实现动态行为:
categoryDropdown?.addEventListener("change", { val selectedCategory = categoryDropdown.value // 基于所选类别获取子类别 mainScope.launch { populateSubCategories(selectedCategory) } })
在谈论一些“例外情况”之前,值得一提的是,我们使用Ktor HTTP客户端 (参见ProductApi )来向后端发起REST调用。我们可以使用移植的Fetch API来完成这项任务,但使用客户端看起来更好。当然,我们需要将ktor-client
添加为build.gradle.kts
文件的依赖项:
val jsMain by getting { dependsOn(commonMain) dependencies { implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-js:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") //... } }
客户端包括从服务器接收的JSESSIONID浏览器cookie,用于对HTTP请求进行成功身份验证。如果省略了这一点,我们将从服务器获得HTTP 401/403错误。这些错误也会被处理并显示在Bootstrap模态框中。此外,关于客户端-服务器通信的一个非常方便的事情是,在jsMain
和springMain
模块之间共享通用数据类(在我们的情况下是Product.kt 和Category.kt )。
例外情况1:使用npm的依赖项
对于客户端路由,我们选择了Navigo JavaScript库。这个库不是Kotlin/JS的一部分,但我们可以使用npm
函数在Gradle中导入它:
val jsMain by getting { dependsOn(commonMain) dependencies { //... implementation(npm("navigo", "8.11.1")) } }
然而,由于JavaScript模块是动态类型的,而Kotlin是静态类型的,为了从Kotlin中操作Navigo,我们必须提供一个“适配器”。这就是我们在Router.kt 类中所做的:
@JsModule("navigo") @JsNonModule external class Navigo(root: String, resolveOptions: ResolveOptions = definedExternally) { fun on(route: String, handler: () -> Unit) fun resolve() fun navigate(s: String) }
有了这个,Navigo JavaScript模块就可以像常规的Kotlin类一样使用。
例外情况2:从Kotlin中使用JavaScript代码
可以使用js()
函数从Kotlin代码中调用JavaScript函数。以下是我们示例项目中的一些示例:
// 来自ProductTable.kt: private fun initializeDataTable() { js("new DataTable('#$PRODUCTS_TABLE_ID', $DATATABLE_OPTIONS)") } // 来自ModalUtil.kt: val modalElement = document.getElementById(modal.id) as? HTMLDivElement modalElement?.let { js("new bootstrap.Modal(it).show()") }
然而,这应该谨慎使用,因为这样我们就脱离了Kotlin的类型系统。
要点
总的来说,选择最佳框架取决于几个因素,其中最重要的因素之一是“开发团队更熟悉的框架”。另一方面,根据Thoughtworks Technology radar的说法,默认使用单页应用的方法受到质疑;这意味着,即使业务需求不合理,我们也不应该盲目接受单页应用及其框架的复杂性。
在本文中,我们介绍了Kotlin多平台与Kotlin/JS,它为我们带来了新的东西。考虑到生态系统中的最新添加,即Kotlin Wasm和Compose Multiplatform,可以明显看出,这些进步不仅提供了新的视角,还为简化开发提供了强大的解决方案。