Kotlin+Spring Boot开发REST API实战

目录

  • Kotlin简介
  • 简洁之处
    • Class
    • Data Class
    • Object
    • 默认参数和具名参数
    • 类型别名
    • 字符串模板
    • 智能类型转换
    • 控制语句
    • 集合操作
    • 作用域函数
  • 转变思想
    • 类与文件
    • 扩展函数
    • 伴生对象
    • 委托
    • 异常
  • Spring Boot实战
    • 创建工程
    • Gradle Build
    • 代码风格
    • HeroesApplication
    • 配置属性
    • Entity
    • DTO
    • Mapper
    • Repository
    • Service
    • Rest Controller
    • 异常
    • Security
    • Swagger
    • JPA测试
    • Mockk
    • 集成测试
  • 参考文档

Kotlin简介

Kotlin是面向JVM、Android、JavaScript 及原生平台的开源静态类型编程语言,支持面向对象和函数式编程。Kotlin项目开始于2010年,由 JetBrains开发,2016年2月发布第一个官方版本1.0,当前版本是 1.3.72,发布于2020年4月。

Spring Framework 5.0开始支持Kotlin,采用Kotlin的语言特性提供更简洁的API,当前官方文档提供Java和Kotlin两种语言的示例代码。Gradle 3.0开始支持Kotlin,Kotlin DSL为传统的Groovy DSL提供了替代语法,在支持的IDE中增强了编辑体验。

相对于Java,Kotlin更简洁,代码行数减少约40%。Kotlin更安全,例如对不可空类型的支持使应用程序不易发生NPE。Kotlin语法更强大,如智能类型转换、高阶函数、扩展函数等。Kotlin与Java可以100%互操作,可以与Java库交互。Kotlin/JVM 编译器会生成兼容 Java的字节码。Kotlin支持大型代码库从Java到Kotlin逐步迁移,可以用Kotlin编写新代码,同时系统中较旧部分继续用Java。

对于Java开发人员,Kotlin入门很容易。主要的Java IDE都支持 Kotlin,包括 IntelliJ IDEA、Android Studio、Eclipse和NetBeans,Kotlin 插件支持自动Java到Kotlin的转换。Kotlin官方文档全面、详尽,您还可以通过Kotlin 心印在线学习Kotlin语言的主要功能。

简洁之处

Class

在Java中定义一个POJO类需要声明属性、getters和setters等方法。在Kotlin中声明一个仅包含getters和setters方法的POJO仅需一行代码:

class Person(var firstName: String, var lastName: String, var age: Int)

编译器会自动从主构造函数中声明的所有属性导出getters和setters方法。var 声明的为可变属性,val 声明的为只读属性,只读属性仅会生成getters方法。若没有使用var或val声明,则只是普通参数,仅可用于初始化块、类体内声明的属性初始化。

class Customer(name: String) {
    val customerKey = name.toUpperCase()

    init {
        println(name)
    }
}

Kotlin不需分号,空类可以省略花括号。

下面代码在类体内声明属性,未定义主构造函数:

class Address {
    var name: String = ""
    var street: String = ""
    var city: String = ""
    var state: String? = null
    var zip: String = ""
}

编译生成的字节码仅包含getters和setters方法,没有构造方法。

声明属性的完整语法:

var [: ] [= ]
    []
    []

初始器(initializer)、getter和setter都是可选的。属性类型如果可以从初始器(或从getter返回值)中推断出来,也可以省略。仅在我们要自定义取值、赋值方法时,才需定义getter和setter,如:

var stringRepresentation: String
    get() = this.toString()
    set(value) {
        setDataFromString(value) // 解析字符串并赋值给其他属性
    }

读取、设置属性值时直接使用属性名称, 而不是使用getter和setter方法,如:

fun main() {
    var address = Address() // Kotlin 中没有“new”关键字
    address.name = "Holmes, Sherlock"
    address.street = "Baker"
    address.city = "London"
    address.zip = "123456"

    println(address.name)
}

在Java中,为了简化代码可以引入lombok,给类添加注解自动生成getter、setter等方法,而在Kotlin中这一切都不需要了。

Data Class

/*
使用一行代码创建一个包含getters、setters、`equals()`、`hashCode()`、`toString()`和`copy()` 的 POJO
*/
data class User(val name: String, val age: Int)

编译器自动从主构造函数中声明的所有属性导出getters、setters、equals()、hashCode()、toString()、componentN()(按声明顺序对应于所有属性)和copy() 函数。

生成的Java代码
下面是编译生成的Java class文件反编译后的代码:

import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

@Metadata(mv = {1, 1, 16}, bv = {1, 0, 3}, k = 1, d1 = {"\000 \n\002\030\002\n\002\020\000\n\000\n\002\020\016\n\000\n\002\020\b\n\002\b\t\n\002\020\013\n\002\b\004\b\b\030\0002\0020\001B\025\022\006\020\002\032\0020\003\022\006\020\004\032\0020\005\006\002\020\006J\t\020\013\032\0020\003H\003J\t\020\f\032\0020\005H\003J\035\020\r\032\0020\0002\b\b\002\020\002\032\0020\0032\b\b\002\020\004\032\0020\005H\001J\023\020\016\032\0020\0172\b\020\020\032\004\030\0010\001H\003J\t\020\021\032\0020\005H\001J\t\020\022\032\0020\003H\001R\021\020\004\032\0020\005\006\b\n\000\032\004\b\007\020\bR\021\020\002\032\0020\003\006\b\n\000\032\004\b\t\020\n\006\023"}, d2 = {"Lio/itrunner/heroes/dto/User;", "", "name", "", "age", "", "(Ljava/lang/String;I)V", "getAge", "()I", "getName", "()Ljava/lang/String;", "component1", "component2", "copy", "equals", "", "other", "hashCode", "toString", "heroes-kotlin"})
public final class User {
  @NotNull
  private final String name;
  
  private final int age;
  
  @NotNull
  public final String getName() {
    return this.name;
  }
  
  public final int getAge() {
    return this.age;
  }
  
  public User(@NotNull String name, int age) {
    this.name = name;
    this.age = age;
  }
  
  @NotNull
  public final String component1() {
    return this.name;
  }
  
  public final int component2() {
    return this.age;
  }
  
  @NotNull
  public final User copy(@NotNull String name, int age) {
    Intrinsics.checkParameterIsNotNull(name, "name");
    return new User(name, age);
  }
  
  @NotNull
  public String toString() {
    return "User(name=" + this.name + ", age=" + this.age + ")";
  }
  
  public int hashCode() {
    return ((this.name != null) ? this.name.hashCode() : 0) * 31 + Integer.hashCode(this.age);
  }
  
  public boolean equals(@Nullable Object paramObject) {
    if (this != paramObject) {
      if (paramObject instanceof User) {
        User user = (User)paramObject;
        if (Intrinsics.areEqual(this.name, user.name) && this.age == user.age)
          return true; 
      } 
    } else {
      return true;
    } 
    return false;
  }
}

说明:

  1. 编译后生成的每个类文件都含有@Metadata注解,具体参数的意义请查看Kotlin文档。
  2. 生成的类、属性、getters/setters方法都是final的。

解构声明
为数据类生成的Component函数可在解构声明中使用:

val user = User("Jane", 35)
val (name, age) = user
println("$name, $age years of age") // 输出 "Jane, 35 years of age"

一个解构声明同时创建多个变量。 解构声明会被编译成以下代码:

val name = user.component1()
val age = user.component2()

Object

单例模式是常用的一种设计模式,Kotlin的实现方式非常简单,仅需使用Object声明。

object DataProviderManager {
    fun registerDataProvider(provider: String) {
        // ……
    }
}

编译后生成的Java代码如下:

public final class DataProviderManager {
  public static final DataProviderManager INSTANCE;
  
  static {
    DataProviderManager dataProviderManager = new DataProviderManager();
  }
  
  public final void registerDataProvider(@NotNull String provider) {
    Intrinsics.checkParameterIsNotNull(provider, "provider");
    // ……
  }
}

默认参数和具名参数

普通函数和构造函数的参数都可以有默认值,当省略相应的参数时使用默认值,这可以减少重载数量。当一个函数有大量的参数或默认参数时,可以使用具名参数来调用,这会非常方便,代码也更具可读性。
函数

fun reformat(str: String,
             normalizeCase: Boolean = true,
             upperCaseFirstLetter: Boolean = true,
             divideByCamelHumps: Boolean = false,
             wordSeparator: Char = ' ') {
/*……*/
}

调用:

reformat(str) // 使用默认参数
reformat(str, normalizeCase = true, upperCaseFirstLetter = true, divideByCamelHumps = false, wordSeparator = '_')  // 使用具名参数
reformat(str, wordSeparator = '_') // 使用具名参数和默认参数

构造函数

class User(var name: String = "", var email: String = "", var address: String = "")

调用:

val user1 = User()
val user2 = User(name = "Jason")

如果主构造函数的所有参数都有默认值,编译器会生成一个使用默认值的无参构造函数。

类型别名

类型别名为现有类型或函数提供替代名称。 如果名称太长,可以引入较短的名称,则可使用新的名称替代原类型名。

typealias NodeSet = Set<Network.Node>
typealias FileTable<K> = MutableMap<K, MutableList<File>>

typealias MyHandler = (Int, String, Any) -> Unit
typealias Predicate<T> = (T) -> Boolean

字符串模板

字符串字面值可以包含模板表达式 ,会求值并把结果合并到字符串中。 模板表达式可以为$开头的变量,或为${}括起来的任意表达式。

val s = "abc"
println("$s.length is ${s.length}") // 输出“abc.length is 3”

智能类型转换

if (obj is String) {
    print(obj.length)
}

if (obj !is String) { // 与 !(obj is String) 相同
    print("Not a String")
}
else {
    print(obj.length)
}

在许多情况下,不需要使用显式转换操作符(as),编译器可以跟踪is检测自动智能转换。

as转换

val x: String = y as String // 不安全,y为空时会抛出异常
val x: String? = y as? String // 安全,y为空时值为空

控制语句

if、when既可用作语句,也可用作表达式。作为表达式时,每个分支可以是一个代码块,它的值是块中最后表达式的值。Kotlin没有三目操作符,可以使用if表达式替代。

// 作为语句
var max: Int
if (a > b) {
    max = a
} else {
    max = b
}
 
// 作为表达式
val max = if (a > b) a else b

Java switch语句仅支持整型、字符串或枚举,Kotlin when语句支持任何类型,分支条件可以使用任意表达式(而不只是常量),每个分支也不需要break语句。

// 多分支处理方式相同时,可以把多个分支条件放在一起,用逗号分隔
when (x) {
    0, 1 -> print("x == 0 or x == 1")
    else -> print("otherwise")
}

// 分支条件可以使用任意表达式
when (x) {
    in 1..10 -> print("x is in the range")
    in validNumbers -> print("x is valid")
    !in 10..20 -> print("x is outside the range")
    else -> print("none of the above")
}

// 单表达式函数
fun hasPrefix(x: Any) = when(x) {
    is String -> x.startsWith("prefix")
    else -> false
}

强大、简洁的for语法:

for (i in 1..100) { …… }  // 闭区间:包含 100
for (i in 1 until 100) { …… } // 半开区间:不包含 100
for (x in 2..10 step 2) { …… }
for (x in 10 downTo 1) { …… }
for ((k, v) in map) {
    println("$k -> $v")
}

集合操作

Kotlin标准库提供了集合操作的多种函数,包括简单的操作,如获取或添加元素,以及更复杂的操作,如搜索、排序、过滤、转换等。
创建集合

// 只读集合
val numbers = listOf(1, 2, 3, 4)
val numbersSet = setOf("one", "two", "three", "four")
val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key4" to 1)

// 可变集合
val numbers = mutableListOf("one", "five", "six")
val numbersSet = mutableSetOf("one", "two", "three", "four")
val numbersMap = mutableMapOf("key1" to 1, "key2" to 2, "key3" to 3, "key4" to 1)

// 空集合
val empty = emptyList<String>()
val emptySet = emptySet<String>()
val emptyMap = emptyMap<String, String>()

map转换

val numbers = setOf(1, 2, 3)
println(numbers.map { it * 3 })

双路合并

val colors = listOf("red", "brown", "grey")
val animals = listOf("fox", "bear", "wolf")
println(colors zip animals) // 输出结果为[(red, fox), (brown, bear), (grey, wolf)]

val twoAnimals = listOf("fox", "bear")
println(colors.zip(twoAnimals)) // 输出结果为[(red, fox), (brown, bear)]

关联

val numbers = listOf("one", "two", "three", "four")
println(numbers.associateWith { it.length }) // 转换为map,输出结果为{one=3, two=3, three=5, four=4}

过滤

val numbers = listOf("one", "two", "three", "four")  
val longerThan3 = numbers.filter { it.length > 3 }
println(longerThan3) // 输出结果为[three, four]

划分

val numbers = listOf("one", "two", "three", "four")
val (match, rest) = numbers.partition { it.length > 3 }

println(match) // 输出结果为[three, four]
println(rest) // 输出结果为[one, two]

plus 与 minus

val numbers = listOf("one", "two", "three", "four")

val plusList = numbers + "five"
val minusList = numbers - listOf("three", "four")
println(plusList) // 输出结果为 [one, two, three, four, five]
println(minusList) // 输出结果为 [one, two]

slice

val numbers = listOf("one", "two", "three", "four", "five", "six")    
println(numbers.slice(1..3)) // 输出结果为[two, three, four]
println(numbers.slice(0..4 step 2)) // 输出结果为[one, three, five]
println(numbers.slice(setOf(3, 5, 0))) // 输出结果为[four, six, one]

限于篇幅,在此不能一一列举所有集合操作。

作用域函数

Kotlin提供五种作用域函数:let、run、with、apply和also,在此作用域中,可以访问该对象而无需使用名称。

函数 对象引用 返回值 是否是扩展函数
let it Lambda 表达式结果
run this Lambda 表达式结果
run - Lambda 表达式结果 不是:调用无需上下文对象
with this Lambda 表达式结果 不是:把上下文对象当做参数
apply this 上下文对象
also it 上下文对象

不同函数的使用场景存在重叠,可以根据项目或团队中的约定选择函数,选择指南:

  • 对一个非空对象执行lambda表达式:let
  • 将表达式作为变量引入为局部作用域中:let
  • 对象配置:apply
  • 对象配置并且计算结果:run
  • 在需要表达式的地方运行语句:非扩展的 run
  • 附加效果:also
  • 一个对象的一组函数调用:with

let
上下文对象作为lambda表达式的参数(it)来访问,返回值是lambda表达式的结果。

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
    println(it) // 输出 [5, 4, 4]
    // 如果需要可以调用更多函数
}

若代码块仅包含以 it 作为参数的单个函数,则可以使用方法引用(::)代替 lambda 表达式:

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)

如需对非空对象执行操作,可使用安全调用操作符 ?.

val str: String? = "Hello"
val length = str?.let {
    println("let() called on $it")
    it.length
}
println(length) // 输出 5

引入作用域受限的局部变量以提高代码的可读性:

val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { firstItem ->
    println("The first item of the list is '$firstItem'")
    if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
}.toUpperCase()
println("First item after modifications: '$modifiedFirstItem'")

run
上下文对象作为接收者(this)来访问,返回值是lambda表达式结果。当lambda表达式同时包含对象初始化和计算返回值时,常用run。

data class User(var name: String, var email: String = "", var city: String = "")
...
val userInfo = User("Jason").run {
    email = "[email protected]"
    city = "Beijing"
    toString()
}

println(userInfo) // 输出 User(name=Jason, [email protected], city=Beijing)

除了在接收者对象上调用 run 之外,还可以将其用作非扩展函数。 非扩展 run 可以在需要表达式的地方执行一个由多条语句组成的块。

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"

    Regex("[$sign]?[$digits$hexDigits]+")
}

for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) {
    println(match.value)
}

输出结果为:

+1234
-FFFF
-a
be

with
非扩展函数,上下文对象作为参数传递,在lambda表达式内部,它可以作为接收者(this)使用,返回值是lambda表达式结果。

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("'with' is called with argument $this")
    println("It contains $size elements")
}

apply
上下文对象作为接收者(this)来访问,返回值是上下文对象本身,常用于配置对象。

val user = User("Jason").apply {
    email = "[email protected]"
    city = "Beijing"
}
println(user)

also
上下文对象作为lambda表达式的参数(it)来访问,返回值是上下文对象本身。对于需要引用对象而不是其属性与函数的操作,或者不想屏蔽来自外部作用域的this引用时,使用also。

val numbers = mutableListOf("one", "two", "three")
numbers.also { println("The list elements before adding new one: $it") }
    .add("four")

交换两个变量:

var a = 1
var b = 2
a = b.also { b = a }

转变思想

学习新的语言就应该用新的方式、方法进行开发,而不是挂羊头卖狗肉、新瓶装旧酒。当前趋势,前后台编程语言的语法越来越相近,都支持面向对象和函数式编程,前后台语言具有相通性,学习成本大大降低。语言不再是前后台开发的障碍,本人更习惯前后台同时开发,特别是中小项目,可以减少沟通成本,更多精力用于了解业务。

类与文件

Java编程一般一个类一个文件。Kotlin鼓励多个声明(类、顶级函数或者属性)放在同一个Kotlin源文件中, 只要这些声明在语义上彼此紧密关联并且文件保持合理大小。

data class User(var name: String, var email: String = "", var city: String = "")

const val PI: Double = 3.14159

fun area(radius: Double): Double = PI * radius * radius

fun main(args: Array<String>) {
    println(area(2.8))
    println(User("Jason", "[email protected]", "Beijing"))
}

假定上面的类、顶级函数、属性和main函数保存在名为Tests.kt的文件中,编译后将生成两个文件:User.class和TestsKt.class,其中TestsKt.class包含静态的顶级函数、属性和main函数。

通常,一个类的内容按以下顺序排列:

  • 属性声明与初始化块
  • 次构造函数
  • 方法声明
  • 伴生对象

不要按字母或者可见性对方法声明排序,也不要将常规方法与扩展方法分开。而是要把相关的东西放在一起,这样从上到下阅读类的人就能更容易理解。

优先声明带有默认参数的函数而不是声明重载函数。

// 不良
fun foo() = foo("a")
fun foo(a: String) { /*……*/ }

// 良好
fun foo(a: String = "a") { /*……*/ }

一般来说,如果Kotlin中的某种语法结构是可选的并且被IDE高亮为冗余的,那么应该在代码中省略。为了清楚起见,不要在代码中保留不必要的语法元素。

扩展函数

Kotlin能够扩展一个类的功能而无需继承该类或者使用像装饰者这样的设计模式,方法是新增一个以被扩展类型为前缀的函数。这个新增的函数称为扩展函数,与原始类已有的函数一样,可以用普通的方法调用。

例如,给MutableList类声明一个扩展函数,用来交换其中的两个值:

fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] // “this”对应该列表
    this[index1] = this[index2]
    this[index2] = tmp
}

调用扩展函数:

val list = mutableListOf(1, 2, 3)
list.swap(0, 2)

扩展不能真正的修改所扩展的类,仅仅是用该类型的变量作为函数的参数。

相对于Java中常用的工具类,扩展函数提供了更便利的方式。为了尽量减少 API 污染,定义扩展函数时尽可能地限制可见性,使用局部扩展函数、成员扩展函数或者私有的顶层扩展函数。

伴生对象

类内部的对象声明可以用 companion 关键字标记,称为伴生对象。

class MyClass {
    companion object Factory {
        fun create(): MyClass = MyClass()
    }
}

伴生对象的名称可以省略,这种情况下将使用名称Companion:

class MyClass {
    companion object {
        fun create(): MyClass = MyClass()
    }
}

无论是否具名都可以使用类名引用伴生对象,当然也可以附加伴生对象的名称,但这是不必要的,可以如下调用伴生对象的函数:

val instance = MyClass.create()
val instance = MyClass.Factory.create()
val instance = MyClass.Companion.create()

Kotlin没有静态类型,可以使用伴生对象、顶层函数、扩展函数或@JvmStatic替代。

在Java中经常使用静态工厂方法代替构造器,Kotlin则使用伴生对象工厂函数。如果一个对象有多个重载的构造函数,它们并非调用不同的超类构造函数,并且不能简化为具有默认参数值的单个构造函数,那么优先用工厂函数取代这些重载的构造函数。

另外,常用伴生对象模拟静态属性:

class MyClass {
    companion object {
        private val LOG: Logger = LoggerFactory.getLogger(MyClass::class.java)
    }

    fun test() {
        LOG.info("hello kotlin")
    }
}

委托

委托模式已被证明是实现继承的一个很好的替代方式,Kotlin提供原生支持。

interface Base {
    val message: String
    fun printMessage()
    fun printClassname()
}

class BaseImpl(private val x: Int) : Base {
    override val message = "BaseImpl: x = $x"
    override fun printMessage() {
        println(message)
    }

    override fun printClassname() {
        println(BaseImpl::class.qualifiedName)
    }
}

class Derived(b: Base) : Base by b {
    // 在 b 的 `printMessage` 实现中不会访问到这个属性
    override val message = Derived::class.qualifiedName!!
    override fun printClassname() {
        println(message)
    }
}

fun main() {
    val b = BaseImpl(10)
    val derived = Derived(b)
    derived.printMessage()
    derived.printClassname()
}

Derived类可以将其所有公有成员都委托给指定对象来实现一个接口,也可以override实现,但这种方式重写的成员不会被委托对象的成员调用 ,委托对象的成员只能访问其自身实现。

异常

Kotlin中所有异常类都是 Throwable 类的子孙类。使用throw抛出异常,使用try-catch捕获异常。

throw Exception("Hi There!")

try {
    // 一些代码
}
catch (e: SomeException) {
    // 处理程序
}
finally {
    // 可选的 finally 块
}

try是一个表达式,返回值是try块或catch块的最后一个表达式:

val a: Int? = try { parseInt(input) } catch (e: NumberFormatException) { null }

throw是一个表达式,类型是特殊类型Nothing。该类型没有值,而是用于标记永远不能达到的代码位置。

val s = person.name ?: throw IllegalArgumentException("Name required")

在Kotlin中,所有异常都是非受检的,编译器不会强迫你捕获任何异常。当你调用一个声明受检异常的Java方法时,Kotlin不会强迫你做任何事情:

fun render(list: List<*>, to: Appendable) {
    for (item in list) {
        to.append(item.toString()) // Java 会要求在这里捕获 IOException
    }
}

通过一些小程序测试得出的结论是异常规范会同时提高开发者的生产力与代码质量,但是大型软件项目的经验表明一个不同的结论——生产力降低、代码质量很少或没有提高。

然而,当使用代理对象时(比如类或方法标注了@Transactional),抛出的异常会被UndeclaredThrowableException包裹。为了获取原始异常,应使用@Throws注解显示指定抛出的异常:

@Service
@Transactional(readOnly = true)
class HeroService(private val repository: HeroRepository, private val messages: Messages) {

    @Throws(HeroNotFoundException::class)
    fun getHeroById(id: Long): HeroDto? {
        return repository.findByIdOrNull(id)?.toHeroDto() ?: throw HeroNotFoundException(messages.getMessage("hero.notFound", arrayOf(id)))
    }

}

Spring Boot实战

本部分升级Angular 9集成Spring Boot 2详解(本人博客)中的Spring Boot项目为Kotlin,不再重复介绍Spring Boot知识,更多关注Kotlin+Spring Boot开发的不同之处,同我一起学习吧。

创建工程

可以使用Spring Initializr Website、Command Line或IntelliJ IDEA创建Spring Boot工程。我们以Spring Initializr Website为例:
Kotlin+Spring Boot开发REST API实战_第1张图片

  1. 选择Gradle Project。在Kotlin中,Gradle是最常用的构建工具,推荐使用Gralde。
  2. 输入Group、Artifact、Name等Metadata。
  3. 添加依赖:Spring Web、Spring Data JPA、Spring Security、Validation、Spring Boot Actuator、H2 Database、PostgreSQL。
  4. 点击GENERATE生成zip包,其中包含了一个标准的Kotlin+Spring Boot项目。

Gradle Build

解压zip包,我们先来看一下build.gradle.kts文件的内容:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "2.3.1.RELEASE"
	id("io.spring.dependency-management") version "1.0.9.RELEASE"
	kotlin("jvm") version "1.3.72"
	kotlin("plugin.spring") version "1.3.72"
	kotlin("plugin.jpa") version "1.3.72"
}

group = "io.itrunner"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-actuator")
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.boot:spring-boot-starter-validation")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("com.h2database:h2")
	runtimeOnly("org.postgresql:postgresql")
	testImplementation("org.springframework.boot:spring-boot-starter-test") {
		exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
	}
	testImplementation("org.springframework.security:spring-security-test")
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "11"
	}
}

Plugins

  1. io.spring.dependency-management插件自动导入spring-boot-dependencies bom
  2. 面向 JVM 平台,需使用 Kotlin JVM 插件。
  3. Kotlin的类及其成员默认是 final 的,Kotlin Spring插件(allopen插件之上的一层包装)会自动open具有Spring注解的类和方法。该插件指定了以下注解: @Component、@Async、@Transactional、@Cacheable、@SpringBootTest。标注有 @Configuration、@Controller、@RestController、@Service 或者 @Repository 的类会自动打开,因为这些注解标注有元注解 @Component。
  4. Kotlin JPA插件(no-arg插件之上的一层包装)为具有@Entity、@Embeddable 与 @MappedSuperclass注解的类生成一个额外的无参构造函数。

为了使JPA延迟加载按预期工作,Entity必须是open的,我们还需使用allopen插件,在build.gradle.kts中添加以下配置:

plugins {
    ...
    kotlin("plugin.allopen") version "1.3.72"
}

allOpen {
  annotation("javax.persistence.Entity")
  annotation("javax.persistence.Embeddable")
  annotation("javax.persistence.MappedSuperclass")
}

Dependencies
Spring Boot application需要以下三个特定库,默认已配置:

  1. kotlin-stdlib-jdk8 Java 8 Kotlin标准库
  2. kotlin-reflect Kotlin反射库
  3. jackson-module-kotlin 序列化/反序列化支持库

我们再添加三个项目依赖库:

dependencies {
    ...
    implementation("com.auth0:java-jwt:3.10.2")
    implementation("io.springfox:springfox-swagger2:2.9.2")
    implementation("io.springfox:springfox-swagger-ui:2.9.2")
    ...
}

编译器选项

  • freeCompilerArgs = listOf("-Xjsr305=strict") 启用空安全编译
  • jvmTarget = “11” 生成JVM字节码的目标版本(1.6、1.8、9、10、11、12 或 13),默认为 1.6

升级Maven profiles和properties
Maven使用profile和property来支持参数化构建,Gradle有类似的属性系统,可以在build script、gradle.properties或环境变量中定义。Gradle有更强大的声明条件的方法,可以模仿profille的概念覆盖默认值,不需要对profile有正式的支持。

Spring Boot本身支持不同profile使用不同的配置文件,仅需指定要使用的profile:

$ ./gradlew bootRun --args='--spring.profiles.active=dev'

这里我们仍采用原来的profile配置方式:

spring:
  profiles:
    active: @profile@

在build.gradle.kts中添加以下配置:

import org.apache.tools.ant.filters.ReplaceTokens

val profile: String? by project // 读取project属性profile

tasks.processResources {
    filter(ReplaceTokens::class, "tokens" to mapOf("profile" to profile)) // 替换YAML配置中的@profile@
}

修改dependencies,profile为"dev"时使用h2数据库:

dependencies {
    ...
    if (profile == "dev") {
        runtimeOnly("com.h2database:h2")
    } else {
        runtimeOnly("org.postgresql:postgresql")
    }
    ...
}    

在gradle.properties中配置profile默认值为"dev":

profile=dev

配置好后就可以用project property参数启动项目了:

$ ./gradlew bootRun -Pprofile=test

代码风格

IntelliJ IDEA中内置的代码格式化工具默认采用了较早的格式,不同于现在的建议格式。可以在 Settings → Editor → Code Style → Kotlin 对话框中切换Kotlin代码风格,将Scheme切换到Project,并从下方选择 Set from… → Predefined Style → Kotlin Style Guide。然后在gradle.properties 文件中添加:

kotlin.code.style=official

HeroesApplication

package io.itrunner.heroes

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class HeroesApplication

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

生成的HeroesApplication.kt文件中包含HeroesApplication类和main函数。要使用Java库中的类,只需正常导入即可。

内联函数
runApplication()函数为Kotlin定义的内联函数,定义如下:

/**
 * Top level function acting as a Kotlin shortcut allowing to write
 * `runApplication(arg1, arg2)` instead of
 * `SpringApplication.run(FooApplication::class.java, arg1, arg2)`.
 *
 * @author Sebastien Deleuze
 * @since 2.0.0
 */
inline fun <reified T : Any> runApplication(vararg args: String): ConfigurableApplicationContext =
		SpringApplication.run(T::class.java, *args)

内联函数支持具体化的类型参数,通过泛型传递一个类型,而不是通过参数,在函数内部可以像普通类一样访问它。

为达到同样目的,普通函数是如下定义的:

fun <T> runApplication(clazz: Class<T>, vararg args: String): ConfigurableApplicationContext =
    SpringApplication.run(clazz, *args)

需如下调用:

runApplication(HeroesApplication::class.java, *args)

显然内联函数更简洁。另外,编译时将函数体直接嵌入到代码中,运行时效率更高。

Java可变参数
SpringApplication.run()方法中定义了可变参数String… args,在kotlin中调用时需要使用运算符*。

升级代码

package io.itrunner.heroes

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.data.jpa.repository.config.EnableJpaRepositories

@SpringBootApplication
@EnableJpaAuditing
@EnableJpaRepositories("io.itrunner.heroes.repository")
@EntityScan(basePackages = ["io.itrunner.heroes.entity"])
class HeroesApplication

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

配置属性

推荐运用@ConfigurationProperties和@ConstructorBinding管理应用属性以便能够使用只读属性类。
Properties.kt

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding

@ConstructorBinding
@ConfigurationProperties("springfox.documentation.swagger")
data class SwaggerProperties(
    val title: String,
    val description: String,
    val version: String,
    val basePackage: String,
    val apiPath: String,
    val contact: Contact
) {
    data class Contact(val name: String, val url: String, val email: String)
}

@ConstructorBinding
@ConfigurationProperties("security")
data class SecurityProperties(val ignorePaths: List<String>, val authPath: String, val cors: Cors, val jwt: Jwt) {
    data class Cors(
        val allowedOrigins: List<String>,
        val allowedMethods: List<String>,
        val allowedHeaders: List<String>
    )

    data class Jwt(val header: String, val secret: String, val expiration: Long, val issuer: String)
}

然后在HeroesApplication启用属性:

@EnableConfigurationProperties(SwaggerProperties::class, SecurityProperties::class)
class HeroesApplication

Configuration Metadata
Spring Boot jar 包括元数据文件,位置为META-INF/spring-configuration-metadata.json,其中提供配置属性的详细信息。IDE利用元数据文件提供上下文帮助和代码完成功能。

Spring Boot提供一个Java注解处理器(JSR 269)spring-boot-configuration-processor,利用它可以很容易的为标注@ConfigurationProperties的类或方法生成元数据文件,IDE即能识别自定义属性。在kotlin中需与kapt插件结合使用。

kapt
kapt 即 Kotlin annotation processing tool(Kotlin 注解处理工具)缩写。

在build.gradle.kts内配置kapt插件:

plugins {
    ...
    kotlin("kapt") version "1.3.72"
    ...
}

dependencies {
    ...
    kapt("org.springframework.boot:spring-boot-configuration-processor")
    ...
}

spring-boot-configuration-processor配置为kapt的依赖项,当执行build任务时会自动运行注解处理工具。IntelliJ IDEA 自身的构建系统目前还不支持 kapt。也可以手动运行命令生成元数据:

./gradlew kaptKotlin

生成的元数据文件位于build\tmp\kapt3\classes\main\META-INF目录下。

IntelliJ IDEA配置

  • 启用Spring Boo插件(默认是启用的),File -> Settings -> Plugins -> Spring Boot
  • 启用annotation processing,File -> Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors -> Enable annotation processing

Entity

我们在文件“Entities.kt”内使用主构造函数语法创建User、Authority、Hero三个Entity:
Entities.kt

import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import javax.persistence.*

@Entity
@Table(
    name = "USERS",
    uniqueConstraints = [
        (UniqueConstraint(name = "UK_USERS_USERNAME", columnNames = ["USERNAME"])),
        (UniqueConstraint(name = "UK_USERS_EMAIL", columnNames = ["EMAIL"]))
    ]
)
class User(
    @Column(name = "USERNAME", length = 50, nullable = false)
    var username: String,

    @Column(name = "PASSWORD", length = 100, nullable = false)
    var password: String,

    @Column(name = "EMAIL", length = 50, nullable = false)
    var email: String,

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "USER_AUTHORITY",
        joinColumns = [(JoinColumn(
            name = "USER_ID", referencedColumnName = "ID", foreignKey = ForeignKey(name = "FK_USER_ID")
        ))],
        inverseJoinColumns = [(JoinColumn(
            name = "AUTHORITY_ID", referencedColumnName = "ID", foreignKey = ForeignKey(name = "FK_AUTHORITY_ID")
        ))]
    )
    var authorities: MutableList<Authority> = mutableListOf(),

    @Column(name = "ENABLED")
    var enabled: Boolean = true,

    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ")
    @SequenceGenerator(name = "USER_SEQ", sequenceName = "USER_SEQ", allocationSize = 1)
    var id: Long? = null
)

@Entity
@Table(name = "AUTHORITY")
class Authority(
    @Column(name = "AUTHORITY_NAME", length = 50, nullable = false)
    @Enumerated(EnumType.STRING)
    var name: AuthorityName,

    @ManyToMany(mappedBy = "authorities", fetch = FetchType.LAZY)
    var users: MutableList<User>? = mutableListOf(),

    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "AUTHORITY_SEQ")
    @SequenceGenerator(name = "AUTHORITY_SEQ", sequenceName = "AUTHORITY_SEQ", allocationSize = 1)
    var id: Long? = null
)

enum class AuthorityName {
    ROLE_USER, ROLE_ADMIN
}

@EntityListeners(AuditingEntityListener::class)
@Entity
@Table(
    name = "HERO",
    uniqueConstraints = [(UniqueConstraint(name = "UK_HERO_NAME", columnNames = ["HERO_NAME"]))]
)
class Hero(
    @Column(name = "HERO_NAME", length = 30, nullable = false)
    var name: String,

    @Column(name = "CREATED_BY", length = 50, updatable = false, nullable = false)
    @CreatedBy
    var createdBy: String = "test",

    @Column(name = "CREATED_DATE", updatable = false, nullable = false)
    @CreatedDate
    var createdDate: LocalDateTime = LocalDateTime.now(),

    @Column(name = "LAST_MODIFIED_BY", length = 50)
    @LastModifiedBy
    var lastModifiedBy: String? = null,

    @Column(name = "LAST_MODIFIED_DATE")
    @LastModifiedDate
    var lastModifiedDate: LocalDateTime? = null,

    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "HERO_SEQ")
    @SequenceGenerator(name = "HERO_SEQ", sequenceName = "HERO_SEQ", allocationSize = 1)
    var id: Long? = null
)

带有默认值的可选参数在构造函数后面定义,这样当创建实例时可以忽略它们。JPA注解正常标注在参数前即可。JPA的设计不适用不可变类或data class自动生成的方法,因此没有使用val声明属性的data class。

DTO

使用data class在一个文件内声明以下DTO:

import io.swagger.annotations.ApiModelProperty
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class HeroDto(
    @ApiModelProperty(value = "name", example = "Jason", required = true)
    @field:[NotBlank Size(min = 3, max = 30)]
    val name: String = "",

    val id: Long? = null
)

data class AuthenticationRequest(
    @ApiModelProperty(value = "username", example = "admin", required = true)
    @field: NotBlank
    val username: String = "",

    @ApiModelProperty(value = "password", example = "admin", required = true)
    @field: NotBlank
    val password: String = ""
)

data class AuthenticationResponse(val token: String)

注意,非空属性必须定义初始值,否则当值为null时反序列化JSON会产生HttpMessageNotReadableException:

JSON parse error: Instantiation of [simple type, class io.itrunner.heroes.dto.HeroDto] value failed for JSON property name due to missing (therefore NULL) value for creator parameter name which is a non-nullable type; nested exception is com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class io.itrunner.heroes.dto.HeroDto] value failed for JSON property name due to missing (therefore NULL) value for creator parameter name which is a non-nullable type\n at [Source: (PushbackInputStream); line: 3, column: 1] (through reference chain: io.itrunner.heroes.dto.HeroDto[\"name\"])

HeroDto的name属性默认值设置为"",标注了@NotBlank和@Size注解,在RestController中启用验证,以保证其值不能为空。

注解使用处目标
当对属性或主构造函数参数进行标注时,从相应的 Kotlin 元素生成的 Java 元素会有多个,因此在生成的 Java 字节码中该注解有多个可能位置。
指定注解使用目标的语法如下:

class Example(@field:Ann val foo,    // 标注 Java 字段
              @get:Ann val bar,      // 标注 Java getter
              @param:Ann val quux)   // 标注 Java 构造函数参数

如果对同一目标有多个注解,可以将所有注解放在方括号内:

class Example {
    @field:[NotBlank Size(min = 3, max = 30)]
    var name: String = "",
}

支持的使用处目标:

  • file
  • property(具有此目标的注解对 Java 不可见)
  • field
  • get(属性 getter)
  • set(属性 setter)
  • receiver(扩展函数或属性的接收者参数)
  • param(构造函数参数)
  • setparam(属性 setter 参数)
  • delegate(为委托属性存储其委托实例的字段)

如未指定使用处目标,则根据注解的@Target来选择目标 。如有多个适用的目标,则使用以下列表中的第一个适用目标:

  • param
  • property
  • field

比如@NotBlank和@Size的@Target均为:

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })

当未指定目标时,生成的Java字节码则会标注在构造函数参数上。而我们在RestController的方法中使用的@Valid注解是不会校验构造函数参数的,需要指定使用目标才能正常校验。

Mapper

利用扩展函数、具名参数可以方便的创建mapper函数:

fun Hero.toHeroDto() = HeroDto(name, id)

fun HeroDto.toHero() = Hero(name = name, id = id)

Repository

@Repository
interface HeroRepository : JpaRepository<Hero, Long> {
    @Query("select h from Hero h where lower(h.name) like CONCAT('%', lower(:name), '%')")
    fun findByName(@Param("name") name: String): List<Hero>
}

@Repository
interface UserRepository : JpaRepository<User, Long> {
    fun findByUsername(username: String): User?

    @Modifying
    @Query("update User u set u.username = ?1 where u.email = ?2")
    fun updateUsername(username: String, email: String): Int
}

Service

HeroService

import io.itrunner.heroes.dto.HeroDto
import io.itrunner.heroes.mapper.toHero
import io.itrunner.heroes.mapper.toHeroDto
import io.itrunner.heroes.repository.HeroRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class HeroService(private val repository: HeroRepository) {
    fun getHeroById(id: Long): HeroDto? {
        return repository.findByIdOrNull(id)?.toHeroDto()
    }

    fun getAllHeroes(pageable: Pageable): Page<HeroDto> {
        return repository.findAll(pageable).map { it.toHeroDto() }
    }

    fun findHeroesByName(name: String): List<HeroDto> {
        return repository.findByName(name).map { it.toHeroDto() }
    }

    @Transactional
    fun saveHero(hero: HeroDto): HeroDto {
        return repository.save(hero.toHero()).toHeroDto()
    }

    @Transactional
    fun deleteHero(id: Long) {
        repository.deleteById(id)
    }
}

建议使用 val 只读属性的构造函数注入,单构造函数的类会自动注入,不需要显示声明@Autowired constructor。

我们知道Spring Data的findById()方法返回的类型是Optional,Kotlin是空安全的,支持可空类型,因此Optional是多余的,这里我们使用了Spring Data的Kotlin扩展函数findByIdOrNull(),当未找到entity时返回null。

JwtService

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.itrunner.heroes.config.SecurityProperties
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import java.util.*

const val CLAIM_AUTHORITIES = "authorities"

@Service
class JwtService(private val securityProperties: SecurityProperties) {

    fun generate(user: UserDetails): String {
        val algorithm = Algorithm.HMAC256(securityProperties.jwt.secret)
        return JWT.create()
            .withIssuer(securityProperties.jwt.issuer)
            .withIssuedAt(Date())
            .withExpiresAt(Date(System.currentTimeMillis() + securityProperties.jwt.expiration * 1000))
            .withSubject(user.username)
            .withArrayClaim(CLAIM_AUTHORITIES, user.authorities.map { it.authority }.toTypedArray())
            .sign(algorithm)
    }

    fun verify(token: String): UserDetails {
        val algorithm = Algorithm.HMAC256(securityProperties.jwt.secret)
        val verifier = JWT.require(algorithm).withIssuer(securityProperties.jwt.issuer).build()
        val jwt = verifier.verify(token)
        return User(jwt.subject,
            "N/A",
            jwt.getClaim(CLAIM_AUTHORITIES).asList(String::class.java).map { SimpleGrantedAuthority(it) })
    }
}

编译期常量可以使用const修饰符,但必须位于顶层或者是 object 或 companion object 的成员,必须用String或原生类型值初始化。

UserDetailsServiceImpl

import io.itrunner.heroes.repository.UserRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service

@Service
class UserDetailsServiceImpl(private val userRepository: UserRepository) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails {
        val user = userRepository.findByUsername(username)
            ?: throw UsernameNotFoundException("No user found with username '$username'")
        return User(user.username, user.password, user.authorities.map { SimpleGrantedAuthority(it.name.name) })
    }

}

Rest Controller

HeroController

@RestController
@RequestMapping(value = ["/api/heroes"], produces = [MediaType.APPLICATION_JSON_VALUE])
@Api(tags = ["Hero Controller"])
class HeroController(private val service: HeroService, private val messages: Messages) {

    @ApiOperation("Get hero by id")
    @GetMapping("/{id}")
    fun getHeroById(@ApiParam(required = true, example = "1") @PathVariable("id") id: Long) =
        service.getHeroById(id) ?: throw HeroNotFoundException(messages.getMessage("hero.notFound", arrayOf(id)))

    @ApiOperation("Get all heroes")
    @GetMapping
    fun getHeroes(
        @ApiIgnore @SortDefaults(SortDefault(sort = ["name"], direction = Sort.Direction.ASC)) pageable: Pageable
    ) = service.getAllHeroes(pageable)

    @ApiOperation("Search heroes by name")
    @GetMapping("/")
    fun searchHeroes(@ApiParam(required = true) @RequestParam("name") name: String) = service.findHeroesByName(name)

    @ApiOperation("Add new hero")
    @PostMapping
    fun addHero(@ApiParam(required = true) @Valid @RequestBody hero: HeroDto) = service.saveHero(hero)

    @ApiOperation("Update hero info")
    @PutMapping
    fun updateHero(@ApiParam(required = true) @Valid @RequestBody hero: HeroDto) = service.saveHero(hero)

    @ApiOperation("Delete hero by id")
    @DeleteMapping("/{id}")
    fun deleteHero(@ApiParam(required = true, example = "1") @PathVariable("id") id: Long) = service.deleteHero(id)
}

Controller调用了service的单一方法,因此使用了单表达式函数。

AuthenticationController

@RestController
@RequestMapping(value = ["/api/auth"], produces = [MediaType.APPLICATION_JSON_VALUE])
@Api(tags = ["Authentication Controller"])
class AuthenticationController(
    private val authenticationManager: AuthenticationManager,
    private val jwtService: JwtService
) {
    private val log = LoggerFactory.getLogger(javaClass)

    @PostMapping
    fun login(@RequestBody @Valid request: AuthenticationRequest): AuthenticationResponse {
        // Perform the security
        val authentication: Authentication =
            authenticationManager.authenticate(UsernamePasswordAuthenticationToken(request.username, request.password))
        SecurityContextHolder.getContext().authentication = authentication

        // Generate token
        val token = jwtService.generate(authentication.principal as UserDetails)

        // Return the token
        return AuthenticationResponse(token)
    }

    @ExceptionHandler(AuthenticationException::class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    fun handleAuthenticationException(e: AuthenticationException) {
        log.error(e.message, e)
    }
}

异常

自定义异常

class ErrorMessage(val error: String, val message: String?) {
    val timestamp: Date = Date()
}

@ResponseStatus(code = HttpStatus.NOT_FOUND)
class HeroNotFoundException(message: String) : Exception(message)

全局异常处理

@ControllerAdvice(basePackages = ["io.itrunner.heroes.controller"])
class RestResponseEntityExceptionHandler : ResponseEntityExceptionHandler() {

    @ExceptionHandler(
        EntityNotFoundException::class,
        DuplicateKeyException::class,
        DataAccessException::class,
        Exception::class
    )
    fun handleAllException(e: Exception): ResponseEntity<Any> {
        logger.error(e.message, e)

        return when (e) {
            is EntityNotFoundException -> notFound(e.simpleName(), e.message)
            is DuplicateKeyException -> badRequest(e.simpleName(), e.message)
            is DataAccessException -> badRequest(e.simpleName(), e.mostSpecificMessage())
            else -> badRequest(e.simpleName(), e.mostSpecificMessage())
        }
    }

    override fun handleMethodArgumentNotValid(
        ex: MethodArgumentNotValidException,
        headers: HttpHeaders,
        status: HttpStatus,
        request: WebRequest
    ): ResponseEntity<Any> {
        val messages = StringBuilder()

        ex.bindingResult.globalErrors.forEach {
            messages.append(it.defaultMessage).append(";")
        }

        ex.bindingResult.fieldErrors.forEach {
            messages.append(it.field).append(" ").append(it.defaultMessage).append(";")
        }

        return badRequest(ex.simpleName(), messages.toString())
    }

    override fun handleExceptionInternal(
        ex: java.lang.Exception,
        body: Any?,
        headers: HttpHeaders,
        status: HttpStatus,
        request: WebRequest
    ): ResponseEntity<Any> = ResponseEntity(ErrorMessage(ex.simpleName(), ex.message), headers, status)

    private fun badRequest(error: String, message: String?): ResponseEntity<Any> =
        ResponseEntity(ErrorMessage(error, message), HttpStatus.BAD_REQUEST)

    private fun notFound(error: String, message: String?): ResponseEntity<Any> =
        ResponseEntity(ErrorMessage(error, message), HttpStatus.NOT_FOUND)

    private fun Exception.simpleName(): String = javaClass.simpleName

    private fun Exception.mostSpecificMessage(): String? = NestedExceptionUtils.getMostSpecificCause(this).message
}

Security

WebSecurityConfig

@Configuration
@EnableWebSecurity
class WebSecurityConfig(
    private val unauthorizedHandler: JwtAuthenticationEntryPoint,
    private val securityProperties: SecurityProperties,
    @Qualifier("userDetailsServiceImpl") private val userDetailsService: UserDetailsService
) : WebSecurityConfigurerAdapter() {

    private val roleAdmin = "ADMIN"

    @Value("\${api.base-path}/**")
    private val apiPath: String? = null

    @Value("\${management.endpoints.web.exposure.include}")
    private val actuatorExposures: Array<String?> = arrayOfNulls(0)

    override fun configure(web: WebSecurity) {
        web.ignoring().antMatchers(*securityProperties.ignorePaths.toTypedArray())
    }

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.userDetailsService(userDetailsService)
    }

    override fun configure(http: HttpSecurity) {
        http.cors().and().csrf().disable()
            .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() // don't create session
            .authorizeRequests()
            .requestMatchers(EndpointRequest.to(*actuatorExposures))
            .permitAll()
            .antMatchers(securityProperties.authPath).permitAll()
            .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
            .antMatchers(HttpMethod.POST, apiPath).hasRole(roleAdmin)
            .antMatchers(HttpMethod.PUT, apiPath).hasRole(roleAdmin)
            .antMatchers(HttpMethod.DELETE, apiPath).hasRole(roleAdmin)
            .anyRequest().authenticated().and()
            .addFilterBefore(
                authenticationTokenFilterBean(),
                UsernamePasswordAuthenticationFilter::class.java
            ) // Custom JWT based security filter
            .headers().cacheControl() // disable page caching
    }

    @Bean
    override fun authenticationManagerBean(): AuthenticationManager = super.authenticationManagerBean()

    @Bean
    fun authenticationTokenFilterBean() = AuthenticationTokenFilter()

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val configuration = CorsConfiguration()
        val cors = securityProperties.cors

        with(cors) {
            configuration.allowedOrigins = allowedOrigins
            configuration.allowedMethods = allowedMethods
            configuration.allowedHeaders = allowedHeaders
        }

        val source = UrlBasedCorsConfigurationSource()
        source.registerCorsConfiguration("/**", configuration)
        return source
    }

}

在Java中可以使用@Value("${property}")注入配置属性,但在Kotlin中$是保留字符,需要使用@Value("\${property}")。

JwtAuthenticationEntryPoint

@Component
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
    override fun commence(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {
        // This is invoked when user tries to access a secured REST resource without supplying any credentials
        // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.reasonPhrase)
    }
}

AuthenticationTokenFilter

class AuthenticationTokenFilter : OncePerRequestFilter() {
    @Autowired
    private lateinit var jwtService: JwtService

    @Autowired
    private lateinit var securityProperties: SecurityProperties

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        var authToken = request.getHeader(securityProperties.jwt.header)

        if (authToken != null && authToken.startsWith("Bearer ")) {
            authToken = authToken.substring(7)
            try {
                val user = jwtService.verify(authToken)

                if (SecurityContextHolder.getContext().authentication == null) {
                    logger.info("checking authentication for user " + user.username)

                    val authentication = UsernamePasswordAuthenticationToken(user.username, "N/A", user.authorities)
                    authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
                    SecurityContextHolder.getContext().authentication = authentication
                }
            } catch (e: Exception) {
                logger.error(e)
            }
        }

        filterChain.doFilter(request, response)
    }
}

这里我们使用了属性注入,需要使用延迟初始化lateinit var。

Swagger

启用Swagger

@EnableSwagger2
@Configuration
class SwaggerConfig(private val properties: SwaggerProperties) {

    @Bean
    fun petApi(): Docket {
        return Docket(DocumentationType.SWAGGER_2)
            .select()
            .apis(RequestHandlerSelectors.basePackage(properties.basePackage))
            .paths(PathSelectors.any())
            .build()
            .apiInfo(apiInfo())
            .pathMapping("/")
            .directModelSubstitute(LocalDate::class.java, String::class.java)
            .genericModelSubstitutes(ResponseEntity::class.java)
            .additionalModels(TypeResolver().resolve(ErrorMessage::class.java))
            .securitySchemes(listOf(apiKey()))
            .securityContexts(listOf(securityContext()))
            .enableUrlTemplating(false)
    }

    private fun apiInfo(): ApiInfo = ApiInfoBuilder()
        .title(properties.title)
        .description(properties.description)
        .contact(Contact(properties.contact.name, properties.contact.url, properties.contact.email))
        .version(properties.version)
        .build()

    private fun apiKey(): ApiKey = ApiKey("BearerToken", "Authorization", "header")

    private fun securityContext(): SecurityContext = SecurityContext.builder()
        .securityReferences(defaultAuth())
        .forPaths(PathSelectors.regex(properties.apiPath))
        .build()

    private fun defaultAuth(): List<SecurityReference> {
        val authorizationScopes = arrayOf(AuthorizationScope("global", "accessEverything"))
        return listOf(SecurityReference("BearerToken", authorizationScopes))
    }
}

JPA测试

@DataJpaTest
class RepositoriesTests @Autowired constructor(
    val entityManager: TestEntityManager,
    val heroRepository: HeroRepository,
    val userRepository: UserRepository
) {

    @Test
    fun `when findByName then return Heroes`() {
        val jason = Hero("Jason")
        entityManager.persist(jason)
        entityManager.flush()
        val list = heroRepository.findByName("jason")
        assertThat(list.size).isEqualTo(1)
    }

    @Test
    fun `when findByUsername then return User`() {
        val lily = User("lily", "123456", "[email protected]")
        entityManager.persist(lily)
        entityManager.flush()
        val user = userRepository.findByUsername("lily")
        assertThat(user).isEqualTo(lily)
    }
}

在测试中,可以使用反引号括起来的带空格的方法名,也允许方法名使用下划线。

Mockk

Mockk与Mockito相似,但更适用于Kotlin。

在build.gradle.kts中排除Mockito,引入Mockk:

testImplementation("org.springframework.boot:spring-boot-starter-test") {
    exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
    exclude(group = "org.mockito", module = "mockito-core")
    exclude(group = "org.mockito", module = "mockito-junit-jupiter")
}
testImplementation("com.ninja-squad:springmockk:2.0.2")

HeroServiceTests

@ExtendWith(MockKExtension::class)
class HeroServiceTests {
    @MockK
    private lateinit var heroRepository: HeroRepository

    @InjectMockKs
    private lateinit var heroService: HeroService

    @BeforeEach
    fun setup() {
        val heroes: MutableList<Hero> = ArrayList<Hero>()
        heroes.add(Hero("Rogue", id = 1))
        heroes.add(Hero("Jason", id = 2))
        every { heroRepository.findByIdOrNull(1) } returns heroes[0]
        every { heroRepository.findAll(PageRequest.of(0, 10)) } returns Page.empty()
        every { heroRepository.findByName("o") } returns heroes
    }

    @Test
    fun getHeroById() {
        val hero: HeroDto? = heroService.getHeroById(1)
        assertThat(hero?.name).isEqualTo("Rogue")
    }

    @Test
    fun getAllHeroes() {
        val heroes: Page<HeroDto> = heroService.getAllHeroes(PageRequest.of(0, 10))
        assertThat(heroes.totalElements).isEqualTo(0)
    }

    @Test
    fun findHeroesByName() {
        val heroes: List<HeroDto> = heroService.findHeroesByName("o")
        assertThat(heroes.size).isEqualTo(2)
    }
}

集成测试

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class HeroesApplicationTests(@Autowired val restTemplate: TestRestTemplate) {

    @BeforeAll
    fun beforeAll() {
        val authentication = AuthenticationRequest("admin", "admin")
        val token = restTemplate.postForObject("/api/auth", authentication, AuthenticationResponse::class.java).token
        restTemplate.restTemplate.interceptors =
            listOf(ClientHttpRequestInterceptor { request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution ->
                val headers = request.headers
                headers.add("Authorization", "Bearer $token")
                headers.add("Content-Type", "application/json")
                execution.execute(request, body)
            })
    }

    @Test
    fun `login failed`() {
        val request = AuthenticationRequest("admin", "111111")
        val response = restTemplate.postForEntity("/api/auth", request, HttpEntity::class.java)
        assertThat(response.statusCode).isEqualTo(HttpStatus.UNAUTHORIZED)
    }

    @Test
    fun `crud should be executed successfully`() {
        var hero = HeroDto("Jack")

        // add hero
        hero = restTemplate.postForObject("/api/heroes", hero, HeroDto::class.java)
        assertThat(hero.id).isNotNull()

        // update hero
        val requestEntity = HttpEntity(HeroDto("Jacky"))
        hero = restTemplate.exchange("/api/heroes", HttpMethod.PUT, requestEntity, HeroDto::class.java).body!!
        assertThat(hero.name).isEqualTo("Jacky")

        // find heroes by name
        val heroes = restTemplate.getForObject("/api/heroes/?name=m", List::class.java)
        assertThat(heroes.size).isEqualTo(5)

        // get hero by id
        hero = restTemplate.getForObject("/api/heroes/${hero.id}", HeroDto::class.java)
        assertThat(hero.name).isEqualTo("Jacky")

        // delete hero successfully
        var response = restTemplate.exchange("/api/heroes/${hero.id}", HttpMethod.DELETE, null, String::class.java)
        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)

        // delete hero
        response = restTemplate.exchange("/api/heroes/9999", HttpMethod.DELETE, null, String::class.java)
        assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST)
    }

    @Test
    fun `validation failed`() {
        val responseEntity: ResponseEntity<ErrorMessage> =
            restTemplate.postForEntity("/api/heroes", HeroDto(), ErrorMessage::class.java)
        assertThat(responseEntity.statusCode).isEqualTo(HttpStatus.BAD_REQUEST)
        assertThat(responseEntity.body?.error).isEqualTo("MethodArgumentNotValidException")
    }
}

使用JUnit 5,在Kotlin测试类中可以使用@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解来启用测试类的单实例化,这允许在非静态方法上使用@BeforeAll 和 @AfterAll 注解,提高测试速度。

也可以创建属性文件src/test/resources/junit-platform.properties,改变整个项目的默认行为:

junit.jupiter.testinstance.lifecycle.default=per_class

本文内容主要源于Kotlin和Spring Boot官方文档,为本人近期学习的总结,希望有助于大家学习Kotlin。您可以从GitHub下载本文源码heroes-kotlin。

参考文档

Kotlin Reference Document
Spring Boot Kotlin support
Spring Framework Language Support
Spring Boot Gradle Plugin Reference Guide
Building web applications with Spring Boot and Kotlin
Idiomatic Logging in Kotlin
Gradle User Manual

你可能感兴趣的:(Kotlin)