原文地址:https://spring.io/guides/tutorials/spring-boot-kotlin/
目录
创建一个新项目
使用Initializr网站
使用命令行
使用IntelliJ IDEA
支持构建方式
了解生成的应用程序
编写您的第一个Kotlin控制器
使用JUnit 5进行测试
用Kotlin编写JUnit 5测试
测试实例生命周期
创建自己的扩展
JPA的持久性
实施博客引擎
公开HTTP API
配置属性
结论
本教程向您展示如何通过结合Spring Boot和Kotlin的功能来有效地构建示例博客应用程序。
如果您是从Kotlin开始的,则可以通过阅读参考文档,遵循在线Kotlin Koans教程或仅使用Spring Framework参考文档(现在在Kotlin中提供代码示例)来学习该语言。
Spring Kotlin支持在Spring Framework和Spring Boot参考文档中有所记录。如果您需要帮助,请在StackOverflow上使用spring
和kotlin
标记进行搜索或提出问题,或者在Kotlin Slack的#spring
频道中进行讨论。
首先,我们需要创建一个Spring Boot应用程序,可以通过多种方式来完成。
访问https://start.spring.io并选择Kotlin语言。Gradle是Kotlin中最常用的构建工具,它提供了Kotlin DSL,在生成Kotlin项目时默认使用该DSL,因此这是推荐的选择。但是,如果您更喜欢Maven,也可以使用它。请注意,您可以使用https://start.spring.io/#!language=kotlin&type=gradle-project默认情况下选择Kotlin和Gradle。
1.选择“ Gradle Project”或根据您要使用的构建工具设置默认的“ Maven Project”
2.输入以下工件坐标: blog
3.添加以下依赖项:
Spring Web
Mustache
Spring Data JPA
H2 Database
Spring Boot DevTools
4.点击“生成项目”。
您可以从命令行中使用Initializr HTTP API ,例如,在类似UN * X的系统上使用curl:
$ mkdir blog && cd blog
$ curl https://start.spring.io/starter.zip -d language=kotlin -d dependencies=web,mustache,jpa,h2,devtools -d packageName=com.example.blog -d name=Blog -o blog.zip
-d type=gradle-project
如果要使用Gradle,请添加。
Spring Initializr还集成在IntelliJ IDEA Ultimate版本中,使您可以创建和导入新项目,而不必将IDE留给命令行或Web UI。
要访问该向导,请转到“文件” |“其他”。新增| 项目,然后选择Spring Initializr。
请按照向导的步骤使用以下参数:
Artifact: "blog"
Type: Maven project or Gradle Project
Language: Kotlin
Name: "Blog"
Dependencies: "Spring Web Starter", "Mustache", "Spring Data JPA", "H2 Database" and "Spring Boot DevTools"
Gradle Build
Maven Build
src/main/kotlin/com/example/blog/BlogApplication.kt
package com.example.blog
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class BlogApplication
fun main(args: Array) {
runApplication(*args)
}
与Java相比,您会注意到缺少分号,在空类上缺少括号(如果需要通过@Bean
注释声明bean,可以添加一些括号)以及使用runApplication
顶层函数。runApplication
是Kotlin的惯用替代品SpringApplication.run(BlogApplication::class.java, *args)
,可用于使用以下语法来自定义应用程序。
src/main/kotlin/com/example/blog/BlogApplication.kt
fun main(args: Array) {
runApplication(*args) {
setBannerMode(Banner.Mode.OFF)
}
}
让我们创建一个简单的控制器来显示一个简单的网页。
src/main/kotlin/com/example/blog/HtmlController.kt
package com.example.blog
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping
@Controller
class HtmlController {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
return "blog"
}
}
请注意,这里我们使用的是Kotlin扩展,该扩展允许向现有的Spring类型添加Kotlin函数或运算符。在这里,我们导入org.springframework.ui.set
扩展功能是为了能够model["title"] = "Blog"
代替编写model.addAttribute("title", "Blog")
。在Spring框架KDOC API列出了所有提供丰富的Java API的科特林扩展。
我们还需要创建关联的Mustache模板。
src/main/resources/templates/header.mustache
{{title}}
src/main/resources/templates/footer.mustache
src/main/resources/templates/blog.mustache
{{> header}}
{{title}}
{{> footer}}
通过运行的main
功能启动Web应用程序BlogApplication.kt
,然后转到http://localhost:8080/
,您应该会看到一个带有“ Blog”标题的醒目的网页。
现在在Spring Boot中默认使用的JUnit 5提供了Kotlin非常方便的各种功能,包括自动装配构造函数/方法参数,该参数允许使用不可为null的val
属性,并可以在常规的非静态方法上使用@BeforeAll
/ @AfterAll
。
为了这个示例,让我们创建一个集成测试以演示各种功能:
我们在反引号之间使用实词而不是驼峰式大小写来提供表达性的测试函数名称
JUnit 5允许注入构造函数和方法参数,这与Kotlin只读和不可为空的属性非常吻合
此代码利用getForObject
和getForEntity
Kotlin扩展(您需要导入它们)
src/test/kotlin/com/example/blog/IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@Test
fun `Assert blog page title, content and status code`() {
val entity = restTemplate.getForEntity("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("Blog
")
}
}
有时您需要在给定类的所有测试之前或之后执行一个方法。像Junit 4一样,默认情况下,JUnit 5要求这些方法是静态的(companion object
在Kotlin中转换为,这很冗长且不简单),因为每个测试都会实例化一次测试类。
但是Junit 5允许您更改此默认行为,并在每个类一次实例化测试类。这可以通过多种方式完成,这里我们将使用属性文件来更改整个项目的默认行为:
src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = per_class
通过此配置,我们现在可以在常规方法中使用@BeforeAll
和@AfterAll
注释,如IntegrationTests
上面更新版本中所示。
src/test/kotlin/com/example/blog/IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@BeforeAll
fun setup() {
println(">> Setup")
}
@Test
fun `Assert blog page title, content and status code`() {
println(">> Assert blog page title, content and status code")
val entity = restTemplate.getForEntity("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("Blog
")
}
@Test
fun `Assert article page title, content and status code`() {
println(">> TODO")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}
并非像Java中那样将util类与抽象方法一起使用,而是在Kotlin中通常通过Kotlin扩展来提供此类功能。在这里,我们将向format()
现有LocalDateTime
类型添加一个函数,以生成具有英语日期格式的文本。
src/main/kotlin/com/example/blog/Extensions.kt
fun LocalDateTime.format() = this.format(englishDateFormatter)
private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }
private val englishDateFormatter = DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd")
.appendLiteral(" ")
.appendText(ChronoField.DAY_OF_MONTH, daysLookup)
.appendLiteral(" ")
.appendPattern("yyyy")
.toFormatter(Locale.ENGLISH)
private fun getOrdinal(n: Int) = when {
n in 11..13 -> "${n}th"
n % 10 == 1 -> "${n}st"
n % 10 == 2 -> "${n}nd"
n % 10 == 3 -> "${n}rd"
else -> "${n}th"
}
fun String.toSlug() = toLowerCase()
.replace("\n", " ")
.replace("[^a-z\\d\\s]".toRegex(), " ")
.split(" ")
.joinToString("-")
.replace("-+".toRegex(), "-")
我们将在下一部分中利用这些扩展。
为了使延迟获取按预期方式工作,实体应open
如KT-28525中所述。我们将allopen
为此目的使用Kotlin插件。
使用Gradle:build.gradle.kts
plugins {
...
kotlin("plugin.allopen") version "1.3.61"
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.Embeddable")
annotation("javax.persistence.MappedSuperclass")
}
或使用Maven:pom.xml
kotlin-maven-plugin
org.jetbrains.kotlin
...
...
all-open
然后,我们使用Kotlin主要构造函数的简洁语法创建模型,该语法允许同时声明属性和构造函数参数。
src/main/kotlin/com/example/blog/Entities.kt
@Entity
class Article(
var title: String,
var headline: String,
var content: String,
@ManyToOne var author: User,
var slug: String = title.toSlug(),
var addedAt: LocalDateTime = LocalDateTime.now(),
@Id @GeneratedValue var id: Long? = null)
@Entity
class User(
var login: String,
var firstname: String,
var lastname: String,
var description: String? = null,
@Id @GeneratedValue var id: Long? = null)
注意,我们在这里使用String.toSlug()
扩展来为构造函数的slug
参数提供默认参数Article
。具有默认值的可选参数定义在最后一个位置,以便在使用位置参数时可以忽略它们(Kotlin也支持命名参数)。请注意,在Kotlin中,将简洁的类声明分组在同一文件中并不少见。
这里我们不使用带有属性的data 类,val 因为JPA并非设计用于不可变的类或由data 类自动生成的方法。如果您使用的是其他Spring Data风格,则大多数都旨在支持此类构造,因此您应使用诸如data class User(val login: String, …) 使用Spring Data MongoDB,Spring Data JDBC等之类的类。 |
虽然Spring Data JPA可以通过使用自然ID(它可能是类中的login 属性User )Persistable ,但由于KT-6653,它与Kotlin不太合适,因此建议始终在其中使用具有生成ID的实体科特林 |
我们还声明了我们的Spring Data JPA存储库,如下所示。
src/main/kotlin/com/example/blog/Repositories.kt
interface ArticleRepository : CrudRepository {
fun findBySlug(slug: String): Article?
fun findAllByOrderByAddedAtDesc(): Iterable
}
interface UserRepository : CrudRepository {
fun findByLogin(login: String): User?
}
我们编写JPA测试来检查基本用例是否按预期工作。
src/test/kotlin/com/example/blog/RepositoriesTests.kt
@DataJpaTest
class RepositoriesTests @Autowired constructor(
val entityManager: TestEntityManager,
val userRepository: UserRepository,
val articleRepository: ArticleRepository) {
@Test
fun `When findByIdOrNull then return Article`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
entityManager.persist(juergen)
val article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
entityManager.persist(article)
entityManager.flush()
val found = articleRepository.findByIdOrNull(article.id!!)
assertThat(found).isEqualTo(article)
}
@Test
fun `When findByLogin then return User`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
entityManager.persist(juergen)
entityManager.flush()
val user = userRepository.findByLogin(juergen.login)
assertThat(user).isEqualTo(juergen)
}
}
我们在这里CrudRepository.findByIdOrNull
使用Spring Data默认提供的Kotlin扩展,它是Optional
based的可空变量CrudRepository.findById
。阅读Null是您的朋友,而不是错误的博客文章,以了解更多详细信息。
我们更新了“博客” Mustache模板。
src/main/resources/templates/blog.mustache
{{> header}}
{{title}}
{{#articles}}
{{title}}
{{headline}}
{{/articles}}
{{> footer}}
我们创建了一个新的“文章”。
src/main/resources/templates/article.mustache
{{> header}}
{{article.title}}
{{article.headline}}
{{article.content}}
{{> footer}}
我们更新HtmlController
,以使用格式化的日期呈现博客和文章页面。ArticleRepository
并且MarkdownConverter
构造函数参数将自动自动关联,因为HtmlController
只有一个构造函数(隐式@Autowired
)。
src/main/kotlin/com/example/blog/HtmlController.kt
@Controller
class HtmlController(private val repository: ArticleRepository) {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog"
}
@GetMapping("/article/{slug}")
fun article(@PathVariable slug: String, model: Model): String {
val article = repository
.findBySlug(slug)
?.render()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
model["title"] = article.title
model["article"] = article
return "article"
}
fun Article.render() = RenderedArticle(
slug,
title,
headline,
content,
author,
addedAt.format()
)
data class RenderedArticle(
val slug: String,
val title: String,
val headline: String,
val content: String,
val author: User,
val addedAt: String)
}
然后,我们将数据初始化添加到新BlogConfiguration
类中。
src/main/kotlin/com/example/blog/BlogConfiguration.kt
@Configuration
class BlogConfiguration {
@Bean
fun databaseInitializer(userRepository: UserRepository,
articleRepository: ArticleRepository) = ApplicationRunner {
val smaldini = userRepository.save(User("smaldini", "Stéphane", "Maldini"))
articleRepository.save(Article(
title = "Reactor Bismuth is out",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
articleRepository.save(Article(
title = "Reactor Aluminium has landed",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
}
}
请注意使用命名参数来使代码更具可读性。 |
并且我们还将相应地更新集成测试。
src/test/kotlin/com/example/blog/IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@BeforeAll
fun setup() {
println(">> Setup")
}
@Test
fun `Assert blog page title, content and status code`() {
println(">> Assert blog page title, content and status code")
val entity = restTemplate.getForEntity("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("Blog
", "Reactor")
}
@Test
fun `Assert article page title, content and status code`() {
println(">> Assert article page title, content and status code")
val title = "Reactor Aluminium has landed"
val entity = restTemplate.getForEntity("/article/${title.toSlug()}")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains(title, "Lorem ipsum", "dolor sit amet")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}
启动(或重新启动)Web应用程序,然后转到http://localhost:8080/
,您应该看到带有可单击链接的文章列表,以查看特定文章。
现在,我们将通过带@RestController
注释的控制器来实现HTTP API 。
src/main/kotlin/com/example/blog/HttpControllers.kt
@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {
@GetMapping("/")
fun findAll() = repository.findAllByOrderByAddedAtDesc()
@GetMapping("/{slug}")
fun findOne(@PathVariable slug: String) =
repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
}
@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {
@GetMapping("/")
fun findAll() = repository.findAll()
@GetMapping("/{login}")
fun findOne(@PathVariable login: String) =
repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}
对于测试,而不是集成测试,我们将利用@WebMvcTest
和Mockk相似的Mockk,但更适合Kotlin。
由于@MockBean
和@SpyBean
注释是针对的Mockito,我们要充分利用SpringMockK提供类似@MockkBean
并@SpykBean
为Mockk注释。
使用Gradle:build.gradle.kts
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "junit")
exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:1.1.3")
或使用Maven:pom.xml
org.springframework.boot
spring-boot-starter-test
test
junit
junit
org.mockito
mockito-core
org.junit.jupiter
junit-jupiter-engine
test
com.ninja-squad
springmockk
1.1.3
test
src/test/kotlin/com/example/blog/HttpControllersTests.kt
@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {
@MockkBean
private lateinit var userRepository: UserRepository
@MockkBean
private lateinit var articleRepository: ArticleRepository
@Test
fun `List articles`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val spring5Article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
val spring43Article = Article("Spring Framework 4.3 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(spring5Article, spring43Article)
mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("\$.[0].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[0].slug").value(spring5Article.slug))
.andExpect(jsonPath("\$.[1].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[1].slug").value(spring43Article.slug))
}
@Test
fun `List users`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val smaldini = User("smaldini", "Stéphane", "Maldini")
every { userRepository.findAll() } returns listOf(juergen, smaldini)
mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("\$.[0].login").value(juergen.login))
.andExpect(jsonPath("\$.[1].login").value(smaldini.login))
}
}
$ 需要在字符串中转义,因为它用于字符串插值。 |
在Kotlin中,推荐的管理应用程序属性的方法是利用@ConfigurationProperties
,@ConstructorBinding
以便能够使用只读属性。
src/main/kotlin/com/example/blog/BlogProperties.kt
@ConstructorBinding
@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
data class Banner(val title: String? = null, val content: String)
}
然后,我们在BlogApplication
级别上启用它。
src/main/kotlin/com/example/blog/BlogApplication.kt
@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
// ...
}
要生成自己的元数据,以获得您的IDE认识到这些自定义属性,kapt应配置与spring-boot-configuration-processor
依赖如下。
build.gradle.kts
plugins {
...
kotlin("kapt") version "1.3.61"
}
dependencies {
...
kapt("org.springframework.boot:spring-boot-configuration-processor")
}
请注意,由于kapt提供的模型的限制,某些功能(例如检测默认值或不推荐使用的项目)无法正常工作。此外,由于KT-18022,Maven还不支持注释处理,有关更多详细信息,请参见initializr#438。
在IntelliJ IDEA中:
确保在菜单File | File中启用了Spring Boot插件。设置| 插件| SpringBoot
通过菜单文件|启用注释处理 设置| 构建,执行,部署| 编译器 注释处理器| 启用注释处理
由于Kapt尚未集成在IDEA中,因此您需要手动运行命令./gradlew kaptKotlin
以生成元数据
现在,在编辑application.properties
(自动完成,验证等)时,应该可以识别您的自定义属性。
src/main/resources/application.properties
blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.
相应地编辑模板和控制器。
src/main/resources/templates/blog.mustache
{{> header}}
{{#banner.title}}
{{banner.title}}
{{/banner.title}}
...
{{> footer}}
src/main/kotlin/com/example/blog/HtmlController.kt
@Controller
class HtmlController(private val repository: ArticleRepository,
private val properties: BlogProperties) {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = properties.title
model["banner"] = properties.banner
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog"
}
// ...
重新启动Web应用程序,刷新http://localhost:8080/
,您应该在博客主页上看到横幅。
现在,我们已完成构建此示例Kotlin博客应用程序。源代码可在Github上获得。如果您需要有关特定功能的更多详细信息,还可以查看Spring Framework和Spring Boot参考文档。