把Scala代码写简单一点

337 阅读8分钟

在写代码的时候,什么事情会让我们感到兴奋呢?重构了一段代码? 尝试了一项新技术?或者突然想到一个优雅的方案来解决让我们头疼很久的问题?

这些事情都是有难度的,但正是因为有难度才能充分证明我们的能力。那这些事情只跟我们自己相关么?并不是,整个团队都需要对它们负责,团队所有人都要有能力去理解它们,维护它们。

如果我们的团队很稳定,每个成员都很资深,那没有什么可担心的,任何事情都难不倒我们。

但如果不是这样呢,我们怎么保证团队能够理解和维护当前的系统?

很不幸,我的团队就是这种情况,每隔一段时间都会有新老成员的交替。我们团队已经使用Scala 5年了,是重度的函数式编程使用者,在不断的探索和尝试新技术。但是随着新技术越来越多,我们发现系统越来越难于维护,甚至影响到了交付,最后我们不得不决定让我们的Scala代码尽量简单一点。

在这篇文章里,我会向大家阐述做出这个决定的原因以及如何才能写出简单的Scala代码。

为什么要写简单的代码?

招聘

我们尝试过招聘Scala开发,要求他们掌握Scala函数式编程,Cats, Fs2, http4s, circe, doobie, eff 等,但这比我们想象的难多了。

在过去5年里,我们总共有18个新成员,其中只有两个有Spark的使用经验。大部分新成员是来自其他技术栈的,例如Java, JavaScript或者Ruby。所以我们得组织大量的培训来提升他们的技能。

培训

鉴于大部分新成员没有Scala经验,我们培训的目的就是帮助他们成为一个Scala高级开发。

为什么要是高级开发呢?因为我们使用了很多需要高阶知识的技术,例如eff, Fs2。 同时我们又在不同系统中使用了很多不同的架构,例如 Cake pattern, ReaderT pattern, Tagless Final等。这些都要求我们的新成员具备高级开发能力。

培训需要多长时间呢?通常情况下,新成员应该在经过几周的培训后就可以开始写代码了。但你可以在几周内从一个毕业生成长为高级开发么?答案显然是否定的,不管培训课程有多好,老师教的有多投入,我们的大脑是需要时间来整理和消化新知识的,这就是问题所在。

我们为新成员准备了将近20个小时的培训和工作坊,覆盖了从Scala函数式编程到各种库的用法。

根据反馈,我们把这些知识点分成了两部分

  1. 初级知识点,一般可以在几周内掌握.

    • Scala 基本语法
    • 函数式编程基本概念
    • 简单的Monad, 例如 Option, Either, IO
    • 简单的高阶函数, 例如 map, flatMap, filter, reduce, foldLeft
    • 用法简单的库, 例如 Cats, http4s, circe, doobie
  2. 高级知识点,通常需要几个月来理解和掌握.

    • Implicit
    • Free Monad
    • 复杂的 Monad, 例如 Reader, State
    • Monad Transformer, 例如 ReaderT, WriterT, StateT
    • 复杂的高阶函数, 例如 traverse, sequence
    • 用法复杂的库, 例如 eff, Fs2
    • 架构, 例如 Cake pattern, ReaderT pattern, Tagless Final

我们在系统中使用了很多的高级知识点,这就意味着新成员没有办法在数周内掌握它们,也就无法在短时间内开发和维护现有的系统。

解决方案

我们大体上有三种解决方案

  1. 我们可以祈祷市场上突然有了足够的Scala开发,那么我们就不需要再为业务的可持续性而担心。但这是不可能的,尤其是在我们什么都不做的时候。

  2. 我们可以切换到其他技术栈,例如TypeScript或者Java。但是我们有将近50个系统是用Scala写的,而且大多数高级开发都很喜欢Scala函数式编程。如果切换技术栈,这意味着我们需要花费大量的时间来重写这些系统,并且要面临高级开发大量流失的风险。这是我们不能承受的。

  3. 把代码写简单一点,少用高级知识点。这样对新成员和现有成员都好,我们不但能够充分利用函数式编程的优点,也可以节省大量的培训时间。

方案3是我们在追求技术卓越和业务可持续性上的一个很好的平衡点。

怎么把Scala代码写简单一点?

避免使用 implicit

implicit 是一个非常强大的特性,但也极易被滥用.

大多数情况下,正是因为使用了implicit才导致代码的可读性和可维护性变差。它也是新成员学习Scala最大的拦路虎。

看下面的例子

import IntInstances._

val a:Int = "1"
if(a.isGreaterThan(0)){
  println("The number is greater than 0")
}

你能读懂这段代码么?为什么一个Int变量可以接受一个String值?isGreaterThan是从哪里来的?

再看看下面的例子

def toInt(String str):Int = a.toInt
def isGreaterThan(Int x, Int y):Boolean = x > y

val a:Int = toInt("1")
if(isGreaterThan(a, 0)){
  println("The number is greater than 0")
}

这个例子没有上一个简洁,但大多数程序员都能够读懂它。

implicit 并不是写出好Scala代码的必要特性。除了有库需要我们提供implicit实例,或者我们确切的知道为什么使用它,其他情况我们要尽量避免使用implicit

统一的Monadic返回类型

函数的Monadic返回类型在函数式编程中是非常重要的,它需要能够表示函数产生的所有side-effects

例如,如果一个函数可能返回null,那么我们可以使用Option。如果一个函数可能返回错误,我们可以使用Either。如果一个函数需要调用API或数据库,我们可以使用IO或者Task。

如果两个函数返回的Monadic类型不一样怎么办?例如

def last(list: List[String]):Option[String] = ???
def toInt(x:String):Either[String, Int] = ???

如果要把这两个函数组合起来,我们需要把Option转换为Either

for{
  x <- last(List("1")).toRight("The list is empty")
  value <- toInt(x)
} yield value

为了避免不同Monadic类型之间的转换,我们可以约定在整个工程里所有函数都只返回一种Monadic类型,那么这些函数就可以被直接组合起来了。

但是我们需要仔细筛选Monadic类型

  1. 它需要足够强大,能够表示整个工程中可能出现的side-effects。例如,如果我们需要调用API或数据库,我们就不能选Either作为返回类型,因为它无法表示外部调用的side-effect,IO或者Task是更好的选择。
  2. 它的使用方法要足够简单。例如,Free Monad 和 ReaderT就太复杂了,我们需要很多时间来理解和掌握它们。

面向对象架构 + 纯函数

我们尝试过很多种架构

刚开始我们使用的是 Cake pattern,

trait Application  { self: Service1 with Service2 with Service3 => 
  def run = ???
} 

val application = new Application with Service1Implementation with Service2Implementation with Service3Implementation {}

application.run

后来使用了eff,它可以支持多个Monad的组合,


trait ApplicationEffect[A]

case object Start extends ApplicationEffect[Nothing]

type Stack = Fx.fx5[ApplicationEffect, Service1Effect, Service2Effect, Service3Effect, IO]

implicit class Service1InterpretationOps[R, A](eff: Eff[R, A]) {
  def runService1[U: Member.Aux[Service1Effect, R, ?]: HasIO, A](): Eff[U, A] = ???
}

implicit class Service2InterpretationOps[R, A](eff: Eff[R, A]) {
  def runService2[U: Member.Aux[Service2Effect, R, ?]: HasIO, A](): Eff[U, A] = ???
}

implicit class Service3InterpretationOps[R, A](eff: Eff[R, A]) {
  def runService3[U: Member.Aux[Service3Effect, R, ?]: HasIO, A](): Eff[U, A] = ???
}

implicit class ApplicationInterpretationOps[R, A](eff: Eff[R, A]) {
  def runApplication[U: Member.Aux[ApplicationEffect, R, ?]: HasService1Effect: HasService2Effect: HasService3Effect, A](): Eff[U, A] = ???
}

val application = Eff.send[Stack](Start).runApplication().runService1().runService2().runService3()

application.unsafeRunSync()

使用了eff两年后,我们迎来了 Tagless Final,

trait Application[M[_]] {
  def run:M[Unit] = ???
}

class ApplicationImplementation[M[_]:Monad](service1: Service1[M], service2: Service2[M], service3: Service3[M]){
  def run:M[Unit] = ???
}

val application = new ApplicationImplementation[IO](new Service1Implementation[IO](), new Service2Implementation[IO](), new Service3Implementation[IO]())

application.run.unsafeRunSync()

最后我们重新拾起了面向对象架构,但仍然使用纯函数。


trait Application {
  def run:IO[Unit] = ???
}

class ApplicationImplementation(service1: Service1, service2: Service2, service3: Service3){
  def run:IO[Unit] = ???
}

val application = new ApplicationImplementation(new Service1Implementation(), new Service2Implementation(), new Service3Implementation())

application.run.unsafeRunSync()

对于非Scala开发来说,最后一个架构非常易于理解,唯一需要学习的是IO的用法。

eff架构是最难于理解的,在一些高级开发离开之后,只有几个人可以维护它。我们甚至花了3个月的时间来把它从当前系统中给剥离出去。

对于大多数开发来说,面向对象架构是很容易理解的,他们只需要把精力集中在如何写出纯函数上。需要强调的是,我们只是使用面向对象架构来聚合纯函数,所有数据都是不可变的。

通过这种方式,我们不仅可以继续使用函数式编程,也可以让新成员更快的上手。

用开源库来隔离复杂代码

我们还是要写复杂代码的,因为总是会有困难的问题存在,例如NewRelic集成,多线程日志,全局依赖等。有的时候我们找不到成熟的库来解决这些问题,那么我们就需要创建自己的库了。

在创建自己的库时,唯一的准则就是使用方法要简单。除了这条,我们可以在库中使用任何我们喜欢的技术,甚至引入一些side-effect。

例如,如果我们要打印多线程日志,我们可能需要用到MDC技术来存储同一个业务的日志标识,这个显然是一个side-effect。但是如果没有更好的解决方案,而且对库的测试又很完备的话,我们可以接受这样的side-effect。

我们最好开源我们所做的工作,因为同样的问题可能也正在其他团队内发生。

通过这种方式,我们可以持续对Scala社区做出贡献,让我们的高级开发有施展拳脚的空间。

总结

这就是我们总结出的经验,把代码写简单一点。其实在Haskell社区也发生过同样的事情。我们需要在业务可持续性和技术卓越之间寻找平衡点,这样我们才能取得真正的成功。