函数式事件驱动架构——Scala 3 函数式编程

174 阅读7分钟

现在是时候直接跳进代码啦,而 Scala 3 无疑是绝佳的切入口!这门语言的新主版本带来了许多激动人心的新特性,本章我们会结合实用的函数式库,一起做深度探索。

我们会先从领域建模开始,包括类型类派生(typeclass derivation)、新类型(newtypes)、精化类型(refinement types)以及“孤儿实例”(orphan instances)等相关内容。

接着,会简要介绍类型类、能力特质(capability traits)和 HTTP 路由的用法。最后,我们还将实践一个利用 Scala 3 全新特性的设计模式。

4.1 领域建模(Domain modeling)

这个话题在 PFPS(Practical FP in Scala)一书里已经有很全面的介绍,尤其是新类型(newtypes)和精化类型(refinement types)的结合。本节主要讲解这些内容在 Scala 3 里的用法——因为有些在 Scala 2 推荐的库在 Scala 3 已经不再受支持了。

4.1.1 类型类派生(Typeclass derivation)

在 Scala 2,我推荐用 Derevo 做类型类派生。但很可惜它依赖于实验性宏,这个特性在 Scala 3 已经被移除了。

不过,好消息是 Shapeless 3(Kittens 基于它实现)正在积极发展,能很好支持类型类派生。

下面是常见类型类的派生示例:

import cats.*
import cats.derived.*

case class Person(
    name: String,
    age: Int
) derives Eq, Order, Show

4.1.1.1 JSON 编解码器(JSON codecs)

Circe 最新版也实验性支持 Scala 3 的类型类派生:

import io.circe.Codec

case class Address(
    streetName: String,
    streetNumber: Int,
    flat: Option[String]
) derives Codec.AsObject

这会自动生成 Decoder[Address]Encoder[Address],如:

import io.circe.parser.decode
import io.circe.syntax.*

object Demo:
  @main def run =
    val address = Address("Baker", 221, Some("B"))
    val json    = address.asJson.spaces2
    assert(decode[Address](json) == Right(address))

打印出来的 JSON 是:

{
  "streetName" : "Baker",
  "streetNumber" : 221,
  "flat" : "B"
}

另一个很棒的 JSON 库是 jsoniter-scala。它需要先声明 codec 的生成方式,然后就能利用 Scala 3 的类型类派生机制:

import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*

sealed trait JsonCodec[A] extends JsonValueCodec[A]

object JsonCodec:
  inline def derived[A]: JsonCodec[A] = new:
    private val impl = JsonCodecMaker.make[A](
      CodecMakerConfig.withDiscriminatorFieldName(Some("field"))
    )
    export impl._

例如:

case class Person(
    age: Int,
    name: String
) derives JsonCodec

enum Digits derives JsonCodec:
  case One
  case Two(name: String)

验证:

val person = Person(40, "Joe")
val one = Digits.One
val two = Digits.Two("dos")

def show[A: JsonCodec](a: A): String =
  new String(writeToArray(a))

show(person) // {"age":40,"name":"Joe"}
show(one)    // {"field":"One"}
show(two)    // {"field":"Two","name":"dos"}

我们会在交易系统中用 Circe,当然你也可以用其它库。

4.1.2 新类型(Newtypes)

在 Scala 2 里我推荐 Newtype 库,可以零成本包装底层类型:

object domain {
  @newtype case class Age(value: Int)
}

可惜它也 heavily 依赖宏,Scala 3 无法使用。但 Scala 3 原生支持“opaque type”,可以用来实现 newtype。

一种灵感来自 Monix Newtypes 的写法(本书实战项目会用):

abstract class Newtype[A](using
    eqv: Eq[A],
    ord: Order[A],
    shw: Show[A],
    enc: Encoder[A],
    dec: Decoder[A]
):
  opaque type Type = A

  inline def apply(a: A): Type = a

  protected inline final def derive[F[_]](using ev: F[A]): F[Type] = ev

  extension (t: Type) inline def value: A = t

  given Wrapper[A, Type] with
    def iso: Iso[A, Type] =
      Iso[A, Type](apply(_))(_.value)

  given Eq[Type]       = eqv
  given Order[Type]    = ord
  given Show[Type]     = shw
  given Encoder[Type]  = enc
  given Decoder[Type]  = dec
  given Ordering[Type] = ord.toOrdering

用法如下:

type Name = Name.Type
object Name extends Newtype[String]

type Age = Age.Type
object Age extends Newtype[Int]

虽然没有宏注解写法简洁,但也已经很实用了。

Wrapper typeclass 提供从底层类型和新类型的等价关系(同构):

trait Wrapper[A, B]:
  def iso: Iso[A, B]

还可以实现常用的 UUID newtype:

abstract class IdNewtype extends Newtype[UUID]:
  given IsUUID[Type] = derive[IsUUID]

trait IsUUID[A]:
  def iso: Iso[UUID, A]

object IsUUID:
  def apply[A: IsUUID]: IsUUID[A] = summon
  given IsUUID[UUID] with
    def iso: Iso[UUID, UUID] =
      Iso[UUID, UUID](identity)(identity)

数字类型的 newtype 还可以加扩展方法:

abstract class NumNewtype[A](using
    eqv: Eq[A],
    ord: Order[A],
    shw: Show[A],
    enc: Encoder[A],
    dec: Decoder[A],
    num: Numeric[A]
) extends Newtype[A]:

  extension (x: Type)
    inline def -[T](using inv: T =:= Type)(y: T): Type =
      apply(num.minus(x.value, inv.apply(y).value))
    inline def +[T](using inv: T =:= Type)(y: T): Type =
      apply(num.plus(x.value, inv.apply(y).value))

4.1.2.1 新类型到底值不值?

用这种写法确实比直接 case class 多一行,比如:

type Name = Name.Type
object Name extends NewtypeWrapped[String]

对比

case class Name(value: String)

如果只考虑类型类派生,差别就更明显:

type Surname = Surname.Type
object Surname extends NewtypeWrapped[String]:
  given Eq[Surname] = derive
  given Order[Surname] = derive
  given Show[Surname] = derive

// 而 case class 一行就能搞定
case class Surname(value: String) derives Eq, Order, Show

当然,case class 不是零成本包装,但除非极端内存优化场景,性能影响其实微乎其微。

最重要的是,newtype 能避免滥用基础类型带来的混乱。开发体验最重要,可以根据实际权衡选用。

4.1.3 精化类型(Refinement types)

Scala 2 推荐 Refined 库,不过它只支持运行时校验,未来会补齐编译期校验。

在 Scala 3 下,推荐用 Iron 库,专为 Scala 3 设计。用法如:

def log(x: Double :| Greater[0.0]): Double =
  Math.log(x)

log(-1d) // 编译期报错

本书第 6 章的 Symbol 类型也用到了精化类型:

type SymbolR = DescribedAs[
  Match["^[a-zA-Z0-9]{6}$"],
  "A Symbol should be an alphanumeric of 6 digits"
]

type Symbol = Symbol.Type
object Symbol extends Newtype[String :| SymbolR]

自定义精化类型的方法:

final class Positive

given Constraint[Int, Positive] with
  override inline def test(value: Int): Boolean = value > 0
  override inline def message: String = "Should be strictly positive"

// 编译报错:Should be strictly positive
val x: Int :| Positive = 0

不过自定义 String 的约束无法做到编译期校验(除非写宏),所以只能运行时校验。详情请参考官方文档。

Iron 还支持 Cats、Circe 等库的自动推导。例:

type AgeR = DescribedAs[
  Greater[0] & Less[151],
  "Valid alien's age between 1 and 150"
]

type NameR = DescribedAs[
  Alphanumeric & MinLength[1] & MaxLength[50],
  "Valid alien's name, alphanumeric max 50 letters"
]

case class Alien(
    name: String :| NameR,
    age: Int :| AgeR
) derives Codec.AsObject, Eq, Show

val alien1 = Alien("Bob", 120)
val alien2 = Alien("Bob", 500) // 编译错误:无效年龄
val alien3 = Alien("", 50)     // 编译错误:无效姓名

对于需要运行时校验的场景,可以用 refine* 方法:

object Alien:
  def make(
      name: String,
      age: Int
  ): EitherNel[String, Alien] =
    (
      name.refineNel[NameR],
      age.refineNel[AgeR]
    ).parMapN(Alien.apply)

4.1.3.1 copy 方法陷阱

case class 自带的 copy 方法如果不配合精化类型,很容易绕过之前的校验:

case class Pet(name: String)

object Pet:
  def make(name: String): Either[String, Pet] =
    if (name != "") then Pet(name).asRight
    else "Pet name must be non-blank!".asLeft

Pet.make("Czela").map(_.copy(name = ""))

如果用精化类型则不会有此问题:

case class Pet(name: String :| Not[Blank])

若坚持用基础类型,还可以用如下两种写法:

case class Pet1 private (name: String)
object Pet1:
  def make(name: String): Either[String, Pet1] =
    Pet1(name).asRight

sealed abstract case class Pet2(name: String)
object Pet2:
  def make(name: String): Either[String, Pet2] =
    Right(new Pet2(name) {})

Pet1、Pet2 都无法自动合成 copy 方法。

def tryMe(pet: Pet2): Pet2 =
  pet.copy(name = "By-pass?") // 编译错误

不过这样写有些冗余,精化类型是更好的方案。

4.1.4 Orphan 实例(Orphan instances)

Orphan instance 指的是给第三方类型实现 typeclass 实例(比如 Instant),在 Scala 2 可以写 trait,然后在包对象或 domain 统一混入:

package object domain extends OrphanInstances

trait OrphanInstances {
  implicit val instantEq: Eq[Instant] =
    Eq.by(_.getEpochSecond)
  implicit val instantOrder: Order[Instant] =
    Order.by(_.getEpochSecond)
  implicit val instantShow: Show[Instant] =
    Show.show[Instant](_.toString)
}

Scala 3 可以直接用 export,比如:

object OrphanInstances:
  given Eq[Instant]    = Eq.by(_.getEpochSecond)
  given Order[Instant] = Order.by(_.getEpochSecond)
  given Show[Instant]  = Show.show[Instant](_.toString)

不过对象里的实例,需要时要 import 进来。Scala 3 取消了 package object,可以在 domain.scala 里顶层定义新类型,然后:

package domain

export OrphanInstances.given

type Timestamp = Timestamp.Type
object Timestamp extends Newtype[Instant]

这样,import domain.given 就能自动带上所有 Orphan 实例,非常优雅。

另外,如果你用 Ciris 配置库,所有相关实例都集中在一个文件里,这样还能保证 Scala.js 兼容性。具体细节见项目代码。

4.2 类型类(Typeclasses)

说到类型类,Scala 各版本之间其实差别不大。Scala 3 主要是语法更简洁。

下面是一个 Time effect 的例子(在 PFPS 也见过),我们后续交易系统也会用到:

trait Time[F[_]]:
  def timestamp: F[Timestamp]

object Time:
  def apply[F[_]](using ev: Time[F]): Time[F] = ev

  given forSync[F[_]](using F: Sync[F]): Time[F] with
    def timestamp: F[Timestamp] =
      F.delay(Instant.now()).map(t => Timestamp(t))

相比 Scala 2,Scala 3 语法更精炼,usinggiven 替代了原来的 implicit,定义本质不变。

Scala 3 还支持上下文约束(context bound)写法,可以重写为:

object Time:
  def apply[F[_]: Time]: Time[F] = summon

  given forSync[F[_]: Sync]: Time[F] with
    def timestamp: F[Timestamp] =
      Sync[F].delay(Instant.now()).map(t => Timestamp(t))

apply 方法(俗称 summoner)现在可以直接用 summon,完全取代了老的 implicitly

而且 given 实例可以不用命名,匿名写法更简洁:

given [F[_]: Sync]: Time[F] with
  def timestamp: F[Timestamp] =
    Sync[F].delay(Instant.now()).map(t => Timestamp(t))

我个人更喜欢这种写法,所以本书后续都会采用这种风格。

4.3 HTTP 路由

在 Scala 2 里,我习惯把 HTTP 路由定义为 final case class,主要是为了不用专门写一个 new MyRoutes 语法来实例化。例如:

final case class HealthRoutes[F[_]: Monad]() extends Http4sDsl[F] {
  val routes: HttpRoutes[F] = HttpRoutes.of {
    case GET -> Root / "health" => Ok()
  }
}

val rt: HttpRoutes[F] = HealthRoutes[F]().routes

好消息是,Scala 3 引入了“通用 apply 方法”(universal apply),我们可以把路由直接定义成普通类,写法如下:

final class HealthRoutes[F[_]: Monad] extends Http4sDsl[F]:
  val routes: HttpRoutes[F] = HttpRoutes.of {
    case GET -> Root / "health" => Ok()
  }

调用时可以直接这样实例化:

val rt: HttpRoutes[F] = HealthRoutes[F].routes

太棒了!Scala 3 让代码更简洁、易读。不管是类定义还是实例化,都不需要再写空括号了。

4.4 带副作用的上下文(Effectful context)

在中大型应用中,跨不同组件传递一堆依赖是家常便饭,依赖注入的 wiring(组装)往往很麻烦。

我一直推荐、也是自己常用的做法,是把依赖分模块管理(traits 或 class 都可以)。

还有一种常见依赖不是 typeclass,但我们依然按“能力特质”(capability trait)来传递,因为它的实例化通常涉及副作用操作。

比如 Supervisor,可以作为隐式依赖传递,这样只要少数组件用到时引用就行,无需手动穿透整个应用:

Supervisor[IO].use { implicit sp =>
  restOfTheProgram
}

如果 Supervisor 是应用中唯一需要共享的副作用依赖,那直接像 typeclass 一样穿透传递就够了:

def foo[F[_]: Supervisor]: F[Unit] = ???

def bar[F[_]](using sp: Supervisor[F]): F[Unit] = ???

但应用规模一大,副作用依赖通常不止一个,还可能有其它信息需要全局可用。这时还是推荐用模块聚合。

不过,Scala 3 新增了一个有趣特性,可以简化这种场景——上下文函数(context functions)

比如,我们想让应用具备上下文感知能力,先定义上下文类型:

final class Log(ref: Ref[IO, List[String]]):
  def add(str: => String): IO[Unit] = ref.update(_ :+ str)
  def get: IO[List[String]]         = ref.get

final case class Ctx(
    id: UUID,
    sp: Supervisor[IO],
    log: Log
)

我们有一个唯一的应用 ID、Supervisor 和 Log。为简化,全部定死 IO,你当然可以抽象成 F[_]。

传统写法,是把 context 实例作为隐式依赖传递到所有需要的地方:

def p1(using ctx: Ctx): IO[Unit] =
  IO.println("Running program 1") *> p2

def p2(using ctx: Ctx): IO[Unit] =
  IO.println("Running program #2") *>
    ctx.sp
      .supervise {
        ctx.log.add(s"Start: ${ctx.id}") >>
          IO.sleep(1.second) >>
          ctx.log.add(s"Done: ${ctx.id}")
      }
      .flatMap { fb =>
        ctx.log.add(s"Waiting: ${ctx.id}") >>
          fb.join.void
      }

def p3(using ctx: Ctx): IO[Unit] =
  IO.sleep(100.millis) *> IO.println("Running program 3") *> p4

def p4(using ctx: Ctx): IO[Unit] =
  IO.println(s"Running program 4: ${ctx.id.show}")

val mkCtx = for
  id <- Resource.eval(IO(UUID.randomUUID()))
  sp <- Supervisor[IO]
  lg <- Resource.eval(Ref.of[IO, List[String]](List.empty))
yield Ctx(id, sp, Log(lg))

val run: IO[Unit] =
  mkCtx.use { implicit ctx =>
    (p1 &> p3) *>
      ctx.log.get.flatMap(_.traverse_(IO.println))
  }

也可以用 Scala 3 的 pattern-bound given 写法:

mkCtx.use { case given Ctx => ??? }

运行后类似这样输出:

Running program #1
Running program #2
Running program #3
Running program #4: 447c6c84-6d7b-4acd-8574-95145878c820
Start: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff
Waiting: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff
Done: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff

这种写法没毛病,也兼容 Scala 2。但可以看到 p1 和 p3 虽然用不到 context,也要 everywhere 写 using ctx: Ctx。如果不是必须隐式 context,这些方法其实可以直接写成 val。

Scala 3 的**上下文函数(context function)**提供了另一种思路。你可以用箭头 ?=> 把程序声明为“需要上下文”但本身不显示依赖的函数:

val p1: Ctx ?=> IO[Unit] =
  IO.println("Running program 1") *> p2

p1 可以写成 val 而不是 def。依此模式,整个程序可以这样重写——先建个上下文初始化辅助方法:

def withCtx(f: Ctx ?=> IO[Unit]): IO[Unit] =
  mkCtx.use { ctx =>
    f(using ctx) *> ctx.log.get.flatMap(_.traverse_(IO.println))
  }

这样可以把主逻辑和前后处理解耦,比如结尾打印日志。

接下来是声明剩下的程序片段:

val p1: Ctx ?=> IO[Unit] =
  IO.println("Running program #1") *> p2

def p2(using ctx: Ctx): IO[Unit] =
  IO.println("Running program #2") *> ???

val p3: Ctx ?=> IO[Unit] =
  IO.sleep(100.millis) *> IO.println("Running program #3") *> p4

def p4(using ctx: Ctx): IO[Unit] =
  IO.println(s"Running program 4: ${ctx.id.show}")

val run: IO[Unit] =
  withCtx {
    p1 &> p3
  }

p2、p4 也可以写成 val,只要用 summon[Ctx] 取出 context:

val p2: Ctx ?=> IO[Unit] =
  val ctx = summon[Ctx]
  restOfTheProgram

也可以在 Ctx 伴生对象加个 summoner 辅助方法。

这种写法非常适合做 DSL,比如官方文档里的多层嵌套:

table {
  row {
    cell("top left")
    cell("top right")
  }
  row {
    cell("bottom left")
    cell("bottom right")
  }
}

当然,用于依赖注入场景可能有点“杀鸡用牛刀”,但这种特性值得了解和尝试!

4.5 依赖类型(Dependent types)

我们要介绍的最后一个新特性是 Match Types(模式类型):

type Elem[X] = X match
  case String      => Char
  case Array[t]    => t
  case Iterable[t] => t

Match Types 让 Scala 拥有了依赖类型(dependent typing),在第 5 章实现有限状态机(FSM)声明依赖时,我们就会用到它。

先看个例子。假设我们有一个 graph 类型,里面定义了状态 St、依赖 Dep、输入 In 和输出 Out

abstract class Graph[F[_], St, Dep, In, Out]:
  val dep: Dep

还定义了几种具体的状态类型:

type CatState = Map[String, String]
type DogState = Set[Int]
type FoxState = Array[Long]

然后可以针对每种动物建一个 graph:

val cat = new Graph[IO, CatState, List[String], String, Unit]:
  val dep = List.empty

val dog = new Graph[IO, DogState, Vector[Int], Int, Unit]:
  val dep = Vector.empty

val fox = new Graph[IO, FoxState, Set[Long], Long, Unit]:
  val dep = Set.empty

从这些例子可以看出,几种类型之间其实有隐式的“约定”:比如输入类型是 String 时,状态类型就是 CatState,依赖类型是 List[String]。不过,现在还可以随意“乱搭”:

val wrongCat = new Graph[IO, FoxState, Boolean, String, Unit]:
  val dep = List.empty

这种写法不能约束类型之间的强关联。为更清晰地表达意图,我们可以用 match types 强化这种类型对应关系——假设每个输入类型只对应一种状态和依赖类型:

type GraphSt[In] = In match
  case String => CatState
  case Int    => DogState
  case Long   => FoxState

type GraphDep[In] = In match
  case String => List[String]
  case Int    => Vector[Int]
  case Long   => Set[Long]

然后声明一个综合类型,并在 Graph 伴生对象里加智能构造器:

type Graf[In] = Graph[IO, GraphSt[In], GraphDep[In], In, Unit]

object Graph:
  def make[In](_dep: GraphDep[In]): Graf[In] = new:
    val dep = _dep

建议把抽象类的构造器设为 private。这样以后实例化时类型安全且直观:

val _cat = Graph.make[String](List.empty)
val _dog = Graph.make[Int](Vector.empty)
val _fox = Graph.make[Long](Set.empty)

如果实例类型不符,则直接编译报错:

// TypeError: Match type reduction failed...
val no1 = Graph.make[Long](List.empty)
val no2 = Graph.make[Int]("wrong")

这样还能享受类型推断,写起来非常丝滑。

Match types 本质上帮助我们实践 FP 世界那句名言:“让非法状态不可表达”(Make illegal state unrepresentable)。本书结尾章节会看到其在 tracing 状态机里的实际应用。

4.6 小结

本章我们深入探讨了领域建模,从通过 Kittens 库实现类型类派生,到用自定义编码方式实现 newtype。此外,还介绍了精化类型(refinement types)和孤儿实例(orphan instances)的用法。

我们还了解了类型类和 HTTP 路由在编码上的细微变化,最后通过带副作用的上下文(effectful context)和依赖类型(dependent types),发现了 Scala 3 的一批新特性。

需要注意的是,虽然 Scala 3 这个主版本非常令人兴奋,但它仍在不断演进中,所以本章涉及的许多特性在后续版本中可能还会发生变化。