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


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 。控制流语句如iftrywhen(类似于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,这是一个处理条件流的强大结构。它比ifswitch语句的功能要强大得多。任意逻辑都可以使用when进行条理的组织。

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

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构建

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

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

如果你使用的是Spring和JPA,你还应该添加kotlin-springkotlin-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会给你一个友好的警告。

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

你可以通过使用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特性:

属性

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

类型推断

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

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"
)

这两个属性都作为构造函数的参数传入。equalshashCodetoString是由数据类提供的。

但是,数据类不提供默认构造函数。这是对于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中执行上述操作,我们会得到一个错误:

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

类型别名

这是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

 

linuxboy的RSS地址:https://www.linuxboy.net/rssFeed.aspx

本文永久更新链接地址:https://www.linuxboy.net/Linux/2018-11/155187.htm

相关内容

    暂无相关文章