写给服务器端 Java 开发人员的 Kotlin 简介

阅读数:3181 2018 年 11 月 7 日

话题:Java语言 & 开发

Kotlin 简介

Kotlin 是 JVM 上比较新的语言之一,来自 IntelliJ 开发商 JetBrains。它是一种静态类型语言,旨在提供一种混合 OO 和 FP 的编程风格。Kotlin 编译器生成的字节码与 JVM 兼容,可以在 JVM 上运行及与现有的库互操作。2017 年,谷歌支持将其用于 Android 开发,Kotlin 获得了重大突破。

JetBrains 有一个明确的目标:让 Kotlin 成为一种多平台语言,并提供 100% 的 Java 互操作性。Kotlin 最近的成功和成熟水平为它进入服务器端提供了一个很好的机会。

选择 Kotlin 的理由

许多语言都试图成为更好的 Java。Kotlin 在语言和生态系统方面做得都很好。成为更好的 Java,同时又要保护 JVM 和巨大的库空间,这是一场姗姗来迟的进化。这种方法与来自 JetBrains 和谷歌的支持相结合,使它成为一个真正的竞争者。让我们来看看 Kotlin 带来的一些特性。

类型推断 —— 类型推断是一等特性。Kotlin 推断变量的类型,而不需要显式指定。在需要明确类型的情况下,也可以指定类型。

通过引入 var 关键字,Java 10 也在朝着类似的方向发展。虽然表面看起来类似,但它的范围仅限于局部变量,不能用于字段和方法签名。

严格空检查 —— Kotlin 将可空代码流视为编译时错误。它提供了额外的语法来处理空检查。值得注意的是,它提供了链式调用中的 NPE 保护。

与 Java 互操作 —— Kotlin 在这方面明显优于其他 JVM 语言。它可以与 Java 无缝地交互。可以在 Kotlin 中导入框架中的 Java 类并使用,反之亦然。值得注意的是,Kotlin 集合可以与 Java 集合互操作。

不变性 —— Kotlin 鼓励使用不可变的数据结构。常用的数据结构(Set/ List/ Map)是不可变的,除非显式地声明为可变的。变量也被指定为不可变(val)和可变(var)。所有这些变化对状态可管理性的影响是显而易见的。

简洁而富有表达力的语法 —— Kotlin 引入了许多改进,这些改进对代码的可读性产生了重大影响。举几个例子:

  • 分号是可选的

  • 大括号在没有用处的情况下是可选的

  • Getter/Setter 是可选的

  • 一切都是对象——如果需要,在后台自动使用原语

  • 表达式:表达式求值时返回结果

在 Kotlin 中,所有的函数都是表达式,因为它们至少返回 Unit 。控制流语句如 if、try 和 when(类似于 switch)也是表达式。例如:

复制代码
String result = null;
try {
result = callFn();
} catch (Exception ex) {
result = “”;
}
becomes:
val result = try {
callFn()
} catch (ex: Exception) {
“”
}

循环支持范围,例如:

复制代码
for (i in 1..100) { println(i) }

还有一些其他的改进,我们将继续讨论。

把 Kotlin 引入 Java 项目

循序渐进

考虑到 Java 的互操作性,建议循序渐进地将 Kotlin 添加到现有的 Java 项目中。主流产品的支持项目通常是不错的选择。一旦团队感到舒适了,他们就可以评估自己是否更喜欢完全切换。

选择哪类项目好?

所有的 Java 项目都可以从 Kotlin 中获益。但是,具有以下特征的项目可以使决策更简单。

包含大量 DTO 或模型 / 实体对象的项目 —— 这对于处理 CRUD 或数据转换的项目非常典型。此类项目往往充斥着 getter/setter。这里可以利用 Kotlin 的属性大幅简化类。

大量依赖实用工具类的项目 —— Java 中的实用工具类通常是为了弥补 Java 中顶级函数的缺乏。在许多情况下,这包括含全局无状态 public static 函数。这些可以分解成纯函数。更进一步,Kotlin 支持类似 Function 类型这样的 FP 结构和高阶函数,这可以用来使代码更易于维护和测试。

类中逻辑复杂的项目 —— 这些项目容易受到空指针异常(NPE)的影响,而这是 Kotlin 很好地解决了的其中一个问题。通过让语言分析可能导致 NPE 的代码路径为开发人员提供支持。Kotlin 的 when 结构(一个更好的 switch)在这里非常有用,可以将嵌套的逻辑树分解为可管理的函数。对变量和集合的不变性支持有助于简化逻辑,避免由于引用泄漏而导致难以查找的错误。虽然上面的一些功能可以通过 Java 实现,但 Kotlin 的优势在于升级了这些范例,并使它们保持简洁一致。

让我们在这里暂停一下,看一个典型的 Java 逻辑片段以及对应的 Kotlin 实现:

复制代码
public class Sample {
public String logic(String paramA, String paramB) {
String result = null;
try {
if (paramA.length() > 10) {
throw new InvalidArgumentException(new String[]{"Unknown"});
} else if ("AB".equals(paramA) && paramB == null) {
result = subLogicA(paramA + "A", "DEFAULT");
} else if ("XX".equals(paramA) && "YY".equals(paramB)) {
result = subLogicA(paramA + "X", paramB + "Y");
} else if (paramB != null) {
result = subLogicA(paramA, paramB);
} else {
result = subLogicA(paramA, "DEFAULT");
}
} catch (Exception ex) {
result = ex.getMessage();
}
return result;
}
private String subLogicA(String paramA, String paramB) {
return paramA + "|" + paramB;
}
}

对应的 Kotlin 实现:

复制代码
fun logic(paramA: String, paramB: String?): String {
return try {
when {
(paramA.length > 10) -> throw InvalidArgumentException(arrayOf("Unknown"))
(paramA == "AB" && paramB == null) -> subLogicA(paramA + "A")
(paramA == "XX" && paramB == "YY") -> subLogicA(paramA + "X", paramB + "X")
else -> if (paramB != null) subLogicA(paramA, paramB) else subLogicA(paramA)
}
} catch (ex: Exception) {
ex.message ?: "UNKNOWN"
}
}
private fun subLogicA(paramA: String, paramB: String = "DEFAULT"): String {
return "$paramA|$paramB"
}

虽然这些代码片段在功能上是等效的,但是它们有一些明显的区别。

logic() 函数不需要包含在类中。Kotlin 提供了顶级函数。这开辟了一个广阔的空间,鼓励我们去思考是否真的需要一个对象。单独的纯函数更容易测试。这为团队提供了采用更简洁的函数方法的选项。

Kotlin 引入了 when,这是一个处理条件流的强大结构。它比 if 或 switch 语句的功能要强大得多。任意逻辑都可以使用 when 进行条理的组织。

注意,在 Kotlin 版本中,我们从未声明返回变量。这是可能的,因为 Kotlin 允许我们使用 when 和 try 作为表达式。

在 subLogicA 函数中,我们可以在函数声明中为 paramB 指定一个默认值。

复制代码
private fun subLogicA(paramA: String, paramB: String = "DEFAULT"): String {

现在,我们可调用任何一个函数签名了:

复制代码
subLogicA(paramA, paramB)

或者

复制代码
subLogicA(paramA) \# In this case the paramB used the default value in the function declaration

现在,逻辑更容易理解了,代码行数减少了约 35%。

把 Kotlin 加入 Java 构建

Maven 和 Gradle 通过插件支持 Kotlin。Kotlin 代码被编译成 Java 类并包含在构建过程中。Kobalt等比较新的构建工具看起来也很有前景。Kobalt 受 Maven/Gradle 启发,但完全是用 Kotlin 编写的。

首先,将 Kotlin 插件依赖项添加到MavenGradle构建文件中。

如果你使用的是 Spring 和 JPA,你还应该添加 kotlin-spring 和 kotlin-jpa编译器插件。项目的编译和构建没有任何明显的差异。

如果要为 Kotlin 代码库生成 JavaDoc 则需要这个插件

有针对 IntelliJ 和 Eclipse Studio 的 IDE 插件,但正如我们所预料的那样,Kotlin 的开发和构建工具从 IntelliJ 关联中获益良多。从社区版开始,该 IDE 对 Kotlin 提供了一等支持。其中一个值得注意的特性是,它支持将现有的 Java 代码自动转换为 Kotlin。这种转换很准确,而且是一种很好的学习 Kotlin 惯用法的工具。

与流行框架集成

因为我们将 Kotlin 引入了现有的项目中,所以框架兼容性是一个问题。Kotlin 完美融入了 Java 生态系统,因为它可以编译成 Java 字节码。一些流行的框架已经宣布支持 Kotlin,包括 Spring、Vert.x、Spark 等。让我们看下 Kotlin 和 Spring 及 Hibernate 一起使用是什么样子。

Spring

Spring 是 Kotlin 的早期支持者之一,在 2016 年首次增加支持。Spring 5利用 Kotlin 提供更简洁的 DSL。你可以认为,现有的 Java Spring 代码无需任何更改就可继续运行。

Kotlin 中的 Spring 注解

Spring 注释和 AOP 都是开箱即用的。你可以像注解 Java 一样注解 Kotlin 类。考虑下面的服务声明片段。

复制代码
@Service
@CacheConfig(cacheNames = [TOKEN_CACHE_NAME], cacheResolver = "envCacheResolver")
open class TokenCache @Autowired constructor(private val repo: TokenRepository) {

这些是标准的 Spring 注解:

复制代码
@Service: org.springframework.stereotype.Service
@CacheConfig: org.springframework.cache

注意,constructor 是类声明的一部分。

@Autowired constructor(private val tokenRepo: TokenRepository)

Kotlin 将其作为主构造函数,它可以是类声明的一部分。在这个实例中,tokenRepo 是一个内联声明的属性。

编译时常量可以在注解中使用,通常,这有助于避免拼写错误。

final 类处理

Kotlin 类默认为 final 的。它提倡将继承作为一种有意识的设计选择。这在 Spring AOP 中是行不通的,但也不难弥补。我们需要将相关类标记为 open —— Kotlin 的非 final 关键字。

IntelliJ 会给你一个友好的警告。

你可以通过使用 maven 插件 all open 来解决这个问题。这个插件可以 open 带有特定注解的类。更简单的方法是将类标记为 open。

自动装配和空值检查

Kotlin 严格执行 null 检查。它要求初始化所有标记为不可空的属性。它们可以在声明时或构造函数中初始化。这与依赖注入相反——依赖注入在运行时填充属性。

lateinit 修饰符允许你指定属性将在使用之前被初始化。在下面的代码片段中,Kotlin 相信 config 对象将在首次使用之前被初始化。

复制代码
@Component
class MyService {
@Autowired
lateinit var config: SessionConfig
}

虽然 lateinit 对于自动装配很有用,但我建议谨慎地使用它。另一方面,它会关闭属性上的编译时空检查。如果在第一次使用时是 null 仍然会出现运行时错误,但是会丢失很多编译时空检查。

构造函数注入可以作为一种替代方法。这与 Spring DI 可以很好地配合,并消除了许多混乱。例如:

@Component
class MyService constructor(val config: SessionConfig)

这是 Kotlin 引导你遵循最佳实践的一个很好的例子。

Hibernate

Hibernate 和 Kotlin 可以很好地搭配使用,不需要做大的修改。一个典型的实体类如下所示:

复制代码
@Entity
@Table(name = "device_model")
class Device {
@Id
@Column(name = "deviceId")
var deviceId: String? = null
@Column(unique = true)
@Type(type = "encryptedString")
var modelNumber = "AC-100"
override fun toString(): String = "Device(id=$id, channelId=$modelNumber)"
override fun equals(other: Any?) =
other is Device
&& other.deviceId?.length == this.deviceId?.length
&& other.modelNumber == this.modelNumber
override fun hashCode(): Int {
var result = deviceId?.hashCode() ?: 0
result = 31 * result + modelNumber.hashCode()
return result
}
}

在上面的代码片段中,我们利用了几个 Kotlin 特性:

属性

通过使用属性语法,我们就不必显式地定义 getter 和 setter 了。这减少了混乱,使我们能够专注于数据模型。

类型推断

在我们可以提供初始值的情况下,我们可以跳过类型规范,因为它可以被推断出来。例如:

复制代码
var modelNumber = "AC-100"

modelNumber 属性会被推断为 String 类型。

表达式

如果我们稍微仔细地看下 toString() 方法,就会发现它有与 Java 有一些不同:

复制代码
override fun toString(): String = "Device(id=$id, channelId=$modelNumber)"

它没有返回语句。这里,我们使用了 Kotlin 表达式。对于返回单个表达式的函数,我们可以省略花括号,通过等号赋值。

字符串模板

复制代码
"Device(id=$id, channelId=$modelNumber)"

在这里,我们可以更自然地使用模板。Kotlin 允许在任何字符串中嵌入 ${表达式}。这消除了笨拙的连接或对 String.format 等外部辅助程序的依赖。

相等测试

在 equals 方法中,你可能已经注意到了这个表达式:

复制代码
other.deviceId?.length == this.deviceId?.length

它用 == 符号比较两个字符串。在 Java 中,这是一个长期存在的问题,它将字符串视为相等测试的特殊情况。Kotlin 最终修复了这个问题,始终把 == 用于结构相等测试(Java 中的 equals())。把 === 用于引用相等检查。

数据类

Kotlin 还提供一种特殊类型的类,称为数据类。当类的主要目的是保存数据时,这些类就特别适合。数据类会自动生成 equals()、hashCode() 和 toString() 方法,进一步减少了样板文件。

有了数据类,我们的最后一个示例就可以改成:

复制代码
@Entity
@Table(name = "device_model")
data class Device2(
@Id
@Column(name = "deviceId")
var deviceId: String? = null,
@Column(unique = true)
@Type(type = "encryptedString")
var modelNumber: String = "AC-100"
)

这两个属性都作为构造函数的参数传入。equals、hashCode 和 toString 是由数据类提供的。

但是,数据类不提供默认构造函数。这是对于 Hibernate 而言是个问题,它使用默认构造函数来创建实体对象。这里,我们可以利用kotlin-jpa插件,它为 JPA 实体类生成额外的零参数构造函数。

在 JVM 语言领域,Kotlin 的与众不同之处在于,它不仅关注工程的优雅性,而且解决了现实世界中的问题。

采用 Kotlin 的实际好处

减少空指针异常

解决 Java 中的 NPE 是 Kotlin 的主要目标之一。将 Kotlin 引入项目时,显式空检查是最明显的变化。

Kotlin 通过引入一些新的操作符解决了空值安全问题。Kotlin 的? 操作符就提供了空安全调用,例如:

复制代码
val model: Model? = car?.model

只有当 car 对象不为空时,才会读取 model 属性。如果 car 为空,model 计算为空。注意 model 的类型是 Model?——表示结果可以为空。此时,流分析就开始起作用了,我们可以在任何使用 model 变量的代码中进行 NPE 编译时检查。

这也可以用于链式调用:

复制代码
val year = car?.model?.year

下面是等价的 Java 代码:

复制代码
Integer year = null;
if (car != null && car.model != null) {
year = car.model.year;
}

一个大型的代码库会省掉许多这样的 null 检查。编译时安全自动地完成这些检查可以节省大量的开发时间。

在表达式求值为空的情况下,可以使用 Elvis 操作符( ?: )提供默认值:

复制代码
val year = car?.model?.year ?: 1990

在上面的代码片段中,如果 year 最终为 null,则使用值 1990。如果左边的表达式为空,则?: 操作符取右边的值。

函数式编程选项

Kotlin 以 Java 8 的功能为基础构建,并提供了一等函数。一等函数可以存储在变量 / 数据结构中并传递出去。例如,在 Java 中,我们可以返回函数:

复制代码
@FunctionalInterface
interface CalcStrategy {
Double calc(Double principal);
}
class StrategyFactory {
public static CalcStrategy getStrategy(Double taxRate) {
return (principal) -> (taxRate / 100) * principal;
}
}

Kotlin 让这个过程变得更加自然,让我们可以清晰地表达意图:

复制代码
// Function as a type
typealias CalcStrategy = (principal: Double) -> Double
fun getStrategy(taxRate: Double): CalcStrategy = { principal -> (taxRate / 100) * principal }
当我们深入使用函数时,事情就会发生变化。下面的 Kotlin 代码片段定义了一个生成另一个函数的函数:
val fn1 = { principal: Double ->
{ taxRate: Double -> (taxRate / 100) * principal }
}

我们很容易调用 fn1 及结果函数:

复制代码
fn1(1000.0) (2.5)

输出
25.0

虽然以上功能在 Java 中也可以实现,但并不直接,并且包含样板代码。

提供这些功能是为了鼓励团队尝试 FP 概念,开发出更符合要求的代码,从而得到更稳定的产品。

注意,Kotlin 和 Java 的 lambda 语法略有不同。这在早期可能会给开发人员带来烦恼。

Java 代码:

复制代码
( Integer first, Integer second ) -> first * second

等价的 Kotlin 代码:

复制代码
{ first: Int, second: Int -> first * second }

随着时间的推移,情况就变得明显了,Kotlin 支持的应用场景需要修改后的语法。

减少项目占用空间大小

Kotlin 最被低估的优点之一是它可以减少项目中的文件数量。Kotlin 文件可以包含多个 / 混合类声明、函数和枚举类等其他结构。这提供了许多 Java 没有提供的可能性。另一方面,它提供了一种新的选择——组织类和函数的正确方法是什么?

在《代码整洁之道》一书中,Robert C Martin 打了报纸的比方。好代码应该读起来和报纸一样——高级结构在文件上部,越往下面越详细。这个文件应该讲述一个紧凑的故事。Kotlin 的代码布局从这个比喻中可见一斑。

建议是——把相似的东西放在一起——放在更大的上下文里。

虽然 Kotlin 不会阻止你放弃“结构(structure)”,但这样做会使后续的代码导航变得困难。组织东西要以它们之间的关系和使用顺序为依据,例如:

复制代码
enum class Topic {
AUTHORIZE_REQUEST,
CANCEL_REQUEST,
DEREG_REQUEST,
CACHE_ENTRY_EXPIRED
}
enum class AuthTopicAttribute {APP_ID, DEVICE_ID}
enum class ExpiryTopicAttribute {APP_ID, REQ_ID}
typealias onPublish = (data: Map<String, String?>) -> Unit
interface IPubSub {
fun publish(topic: Topic, data: Map<String, String?>)
fun addSubscriber(topic: Topic, onPublish: onPublish): Long
fun unSubscribe(topic: Topic, subscriberId: Long)
}
class RedisPubSub constructor(internal val redis: RedissonClient): IPubSub {
...}

在实践中,通过减少为获得全貌而需要跳转的文件数量,可以显著减少脑力开销。

一个常见的例子是 Spring JPA 库,它使包变得混乱。可以把它们重新组织到同一个文件中:

复制代码
@Repository
@Transactional
interface DeviceRepository : CrudRepository<DeviceModel, String> {
fun findFirstByDeviceId(deviceId: String): DeviceModel?
}
@Repository
@Transactional
interface MachineRepository : CrudRepository<MachineModel, String> {
fun findFirstByMachinePK(pk: MachinePKModel): MachineModel?
}
@Repository
@Transactional
interface UserRepository : CrudRepository<UserModel, String> {
fun findFirstByUserPK(pk: UserPKModel): UserModel?
}

上述内容的最终结果是代码行数(LOC)显著减少。这直接影响了交付速度和可维护性。

我们统计了 Java 项目中移植到 Kotlin 的文件数量和代码行数。这是一个典型的 REST 服务,包含数据模型、一些逻辑和缓存。在 Kotlin 版本中,LOC 减少了大约 50%。开发人员在跨文件浏览和编写样板代码上消耗的时间明显减少。

简洁而富有表达力的代码

编写简洁的代码是一个宽泛的话题,这取决于语言、设计和技术的结合。然而,Kotlin 提供了一个良好的工具集,为团队的成功奠定了基础。下面是一些例子。

类型推断

类型推断最终会减少代码中的噪音。这有助于开发人员关注代码的目标。

类型推断可能会增加我们跟踪正在处理的对象的难度,这是一种常见的担忧。从实际经验来看,这种担忧只在少数情况下有必要,通常少于 5%。在大多数情况下,类型是显而易见的。

下面的例子:

复制代码
LocalDate date = LocalDate.now();
String text = "Banner";

变成了:

复制代码
val date = LocalDate.now()
val text = "Banner"

在 Kotlin 中,也可以指定类型:

复制代码
val date: LocalDate = LocalDate.now()
val text: String = "Banner"

值得注意的是,Kotlin 提供了一个全面的解决方案。例如,在 Kotlin 中,我们可以将函数类型定义为:

复制代码
val sq = { num: Int -> num * num }

另一方面,Java 10 通过检查右边表达式的类型推断类型。这引入了一些限制。如果我们尝试在 Java 中执行上述操作,我们会得到一个错误:

类型别名

这是 Kotlin 中一个方便的特性,它允许我们为现有类型分配别名。它不引入新类型,但允许我们使用替代名称引用现有类型,例如:

复制代码
typealias SerialNumber = String

SerialNumber 现在是 String 类型的别名,可以与 String 类型互换使用,例如:

复制代码
val serial: SerialNumber = "FC-100-AC"

和下面的代码等价:

复制代码
val serial: String = "FC-100-AC"

很多时候,typealias 可以作为一个“解释变量”,提高清晰度。考虑以下声明:

复制代码
val myMap: Map<String, String> = HashMap()

我们知道 myMap 包含字符串,但我们不知道这些字符串表示什么。我们可以通过引入 String 类型的别名来澄清这段代码:

复制代码
typealias ProductId = String
typealias SerialNumber = String

现在,上述 myMap 的声明可以改成:

复制代码
val myMap: Map<ProductId, SerialNumber> = HashMap()

上面两个 myMap 的定义是等价的,但是对于后者,我们可以很容易地判断 Map 的内容。

Kotlin 编译器用底层类型替换了类型别名。因此,myMap 的运行时行为不受影响,例如:

复制代码
myMap.put(“MyKey”, “MyValue”)

这种钙化的累积效应是减少了难以捉摸的 Bug。在大型分布式团队中,错误通常是由于未能沟通意图造成的。

早期应用

早期获得吸引力通常是引入变革的最困难的部分。从确定合适的实验项目开始。通常,有一些早期的采用者愿意尝试并编写最初的 Kotlin 代码。在接下来的几周里,更大的团队将有机会查看这些代码。人们早期的反应是避免新的和不熟悉的东西。变革需要一些时间来审视。通过提供阅读资源和技术讲座来帮助评估。在最初的几周结束时,更多的人可以决定在多大程度上采用。

对于熟悉 Java 的开发人员来说,学习曲线很短。以我的经验来看,大多数 Java 开发人员在一周内都能高效地使用 Kotlin。初级开发人员可以在没有经过特殊培训的情况下使用它。以前接触过不同语言或熟悉 FP 概念会进一步减少采用时间。

未来趋势

从 1.1 版本开始,“协同例程(Co-routine)”就可以用在 Kotlin 中了。在概念上,它们类似于 JavaScript 中的 async/await。它们允许我们在不阻塞线程的情况下挂起流,从而降低异步编程中的复杂性。

到目前为止,它们还被标记为实验性的。协同例程将在 1.3 版本中从实验状态毕业。这带来了更多令人兴奋的机会。

Kotlin 的路线图在 Kotlin Evolution and Enhancement Process(KEEP)的指导下制定。请密切关注这方面的讨论和即将发布的特性。

作者简介

Baljeet Sandhu是一名技术负责人,拥有丰富的经验,能够为从制造到金融的各个领域提供软件。他对代码整洁、安全和可扩展的分布式系统感兴趣。Baljeet 目前为HYPR工作,致力于构建非集中式的认证解决方案,以消除欺诈,提高用户体验,实现真正的无密码安全。

查看英文原文:An Introduction to Kotlin for Serverside Java Developers