使用模块和层

用 ZLayer 注入 ZIO 环境

ZIO 是围绕3个参数设计的,R, E, A。R 代表运行 effect 时的要求,这意味着我们需要满足这些要求才能使 effect 可运行。我们将探讨我们可以用 R 做些什么,因为 RZIO 中起着至关重要的作用。

有关 ZIO 环境的简单案例

让我们构建一个用户管理的简单程序,该程序可以检索用户(如果存在)和创建用户。我们需要一个 DBConnection 来访问数据库,并且程序中的每个步骤都通过环境类型来表示这一点。然后,我们可以通过 flatMap 将两个(小的)步骤组合在一起,或者通过 for comprehension 更方便地进行组合。

结果是一个决于对 DBConnection 的依赖的程序。

case class User(id: UserId, name: String)
def getUser(userId: UserId): ZIO[DBConnection, Nothing, Option[User]] = UIO(???)
def createUser(user: User): URIO[DBConnection, Unit] = UIO(???)

val user: User = User(UserId(1234), "Chet")
val created: URIO[DBConnection, Boolean] = for {
  maybeUser <- getUser(user.id)
  res       <- maybeUser.fold(createUser(user).as(true))(_ => ZIO.succeed(false))
} yield res

要运行该程序,我们必须通过 provide 方法提供 DBConnection,然后再将运算提供给 ZIO 运行时。

val dbConnection: DBConnection = ???
val runnable: UIO[Boolean] = created.provide(dbConnection)

val finallyCreated  = runtime.unsafeRun(runnable)

请注意,通过 provide 为该环境提供 effect 的行为导致的结果是消除了返回的 effect 中的环境依赖,返回的 effect 中的环境类型呈现为 Any。

通常,我们不仅需要数据库连接。我们需要使我们能够执行不同操作的组件,并且需要将它们连接在一起。这也是模块化的作用。

我们的第一个 ZIO 模块

接下来,我们将看到如何定义模块,并使用它们来创建彼此依赖的不同的应用层。核心思想是,一个层依赖于紧接在下面的层,但是完全不知道其内部实现。

这种模块式的表述是 ZIO 管理应用程序组件之间的依赖关系的方式,在组合性方面提供了强大的功能,并提供了轻松更改不同实现的功能。这在测试/模拟阶段特别有用。

模块是什么?

模块是仅处理一个关注点的一组功能的集合。限制模块的范围可以提高我们理解代码的能力,因为我们一次只需要专注于一个主题,而不会在脑海中纠缠过多的概念。

ZIO 本身就通过模块来提供基本功能,比如查看 ZEnv 是如何定义的。

模块的构成

让我们按照以下简单步骤构建一个用于用户数据访问的模块:

  1. 定义一个指定模块的名称的对象,它可以是(非必须)一个包对象。
  2. 在模块对象中定义一个特质(trait)服务,该服务定义了我们模块所公开的接口,在本例中它包括两个方法:检索和创建用户。
  3. 在模块对象中,通过 ZLayer 定义该模块的不同实现(有关 ZLayer 的详细信息,请参见下文)
  4. 定义类型别名,例如 type ModuleName = Has[Service](有关以下内容的详细信息,请参见下文)
import zio.{ Has, ZLayer }

type UserRepo = Has[UserRepo.Service]

object UserRepo {
  trait Service {
    def getUser(userId: UserId): IO[DBError, Option[User]]
    def createUser(user: User): IO[DBError, Unit]
  }

  val testRepo: ZLayer[Any, Nothing, UserRepo] = ZLayer.succeed(???)
}

我们遇到了两种新的数据类型 Has 和 ZLayer,接下来让我们熟悉它们。

Has 数据类型

Has[A] 表示对A类型服务的依赖。可以通过 ++ 运算符将两个 Has[_] 水平合并,如:

val repo: Has[Repo.Service] = Has(new Repo.Service{})
val logger: Has[Logger.Service] = Has(new Logger.Service{})

val mix: Has[Repo.Service] with Has[Logger.Service] = repo ++ logger

此时您可能会问:如果结果类型只是两个 trait 的混合,那有什么用?为什么我们不仅仅依靠特质 mixins?

Has 赋予的额外能力是,通过将服务类型交叉映射到服务实现,从而得到的结果数据结构混合了每个实例,不仅可以访问/提取/修改其中的某个实例,同时也保证了彼此之间的类型安全。

// get back the logger service from the mixed value:
val log = mix.get[Logger.Service].log("Hello modules!")

根据之前的说明,为 Has[Service] 定义类型别名非常方便。通常我们不直接创建一个 Has,而是通过 ZLayer 来实现。

ZLayer 数据类型

ZLayer[-RIn, +E, +ROut <: Has[_]] 表示根据输入 RIn 来生成 ROut 类型的环境值,可能产生的错误类型用 E 来表达。

遵循环境的概念,当 RIn = Any 时表示可以不需要输入,并可将类型别名简写为 Layer

创建 ZLayer 的方法有很多,这是一个不完整的列表:

  • ZLayer.succeed 或 ZIO.asService 通过现有服务创建 Layer
  • ZLayer.succeedMany 根据一个或多个服务创建一个 Layer。
  • ZLayer.fromFunction 通过一个将请求转换成服务的函数创建 Layer。
  • ZLayer.fromEffect 将 ZIO effect 提升为一个具有效果化的环境的 Layer。
  • ZLayer.fromAcquireRelease 具有资源获取/释放能力的 Layer。这个想法与 ZManaged 相同。
  • ZLayer.fromServices 从多个服务中构建 Layer。

非常合理地,这些构建服务的方法各自还有不同的变体:具有效果化的(以后缀M来标示),资源化的(后缀Managed)或不同服务的组合(后缀为Many)。

我们可以水平地组合 layerA 和 layerB,通过 layerA ++ layerB 来组合两个层以构建出同时具有两个需求层的组合层,

我们也可以垂直地组合层,这意味着将一层的输出用作下一层的输入以构建出下一层,从而得到需要将第一层作为输入的第二层输出:layerA >>> layerB

将模块连接在一起

在这里,我们定义了一个模块来处理 User 域对象的 CRUD 操作。我们还提供模块在内存中的实现。

type UserRepo = Has[UserRepo.Service]

object UserRepo {
  trait Service {
    def getUser(userId: UserId): IO[DBError, Option[User]]
    def createUser(user: User): IO[DBError, Unit]
  }


  // This simple live version depends only on a DB Connection
  val inMemory: Layer[Nothing, UserRepo] = ZLayer.succeed(
    new Service {
      def getUser(userId: UserId): IO[DBError, Option[User]] = UIO(???)
      def createUser(user: User): IO[DBError, Unit] = UIO(???)
    }
  )

  //accessor methods
  def getUser(userId: UserId): ZIO[UserRepo, DBError, Option[User]] =
    ZIO.accessM(_.get.getUser(userId))

  def createUser(user: User): ZIO[UserRepo, DBError, Unit] =
    ZIO.accessM(_.get.createUser(user))
}

然后,我们定义另一个模块来执行一些基本的日志功能。我们提供了它的基于zioConsoleconsoleLogger 实现。

type Logging = Has[Logging.Service]

object Logging {
  trait Service {
    def info(s: String): UIO[Unit]
    def error(s: String): UIO[Unit]
  }


  import zio.console.Console
  val consoleLogger: ZLayer[Console, Nothing, Logging] = ZLayer.fromFunction( console =>
    new Service {
      def info(s: String): UIO[Unit]  = console.get.putStrLn(s"info - $s")
      def error(s: String): UIO[Unit] = console.get.putStrLn(s"error - $s")
    }
  )

  //accessor methods
  def info(s: String): URIO[Logging, Unit] =
    ZIO.accessM(_.get.info(s))

  def error(s: String): URIO[Logging, Unit] =
    ZIO.accessM(_.get.error(s))
}

访问器(accessor)方法为我们可以构建程序而无需关心所需模块的实现细节,编译器将完全推断出所有完成任务所需的模块。

val user2: User = User(UserId(123), "Tommy")
val makeUser: ZIO[Logging with UserRepo, DBError, Unit] = for {
  _ <- Logging.info(s"inserting user")  // URIO[Logging, Unit]
  _ <- UserRepo.createUser(user2)       // ZIO[UserRepo, DBError, Unit]
  _ <- Logging.info(s"user inserted")   // URIO[Logging, Unit]
} yield ()

构建所需要的层以满足程序所需的条件,:

// compose horizontally
val horizontal: ZLayer[Console, Nothing, Logging with UserRepo] = Logging.consoleLogger ++ UserRepo.inMemory

// fulfill missing deps, composing vertically
val fullLayer: Layer[Nothing, Logging with UserRepo] = Console.live >>> horizontal

// provide the layer to the program
makeUser.provideLayer(fullLayer)

提供部分环境

让我们在创建用户的程序中添加一些额外的逻辑。


val makeUser2: ZIO[Logging with UserRepo with Clock with Random, DBError, Unit] = for {
    uId       <- zio.random.nextLong.map(UserId)
    createdAt <- zio.clock.currentDateTime.orDie
    _         <- Logging.info(s"inserting user")
    _         <- UserRepo.createUser(User(uId, "Chet"))
    _         <- Logging.info(s"user inserted, created at $createdAt")
  } yield ()

现在我们程序的需求更丰富了,我们可以仅用一行代码来提供我们的自定义层以满足这些(更丰富的)需求,同时省去标准环境 ZEnv 已经覆盖的部分。

  val zEnvMakeUser: ZIO[ZEnv, DBError, Unit] = makeUser2.provideCustomLayer(fullLayer)

请注意,provideCustomLayerprovideSomeLayer 的特例。

更新本地依赖项

给定一个层,可以更新它提供的一个或多个组件。一种方法是通过一个函数将旧服务替换为新服务。

val withPostgresService = horizontal.update[UserRepo.Service]{ oldRepo  => new UserRepo.Service {
      override def getUser(userId: UserId): IO[DBError, Option[User]] = UIO(???)
      override def createUser(user: User): IO[DBError, Unit] = UIO(???)
    }
  }

另一种方法是水平组合一个具有新的服务的层(来得到更新后的层)。

val dbLayer: Layer[Nothing, UserRepo] = ZLayer.succeed(new UserRepo.Service {
    override def getUser(userId: UserId): IO[DBError, Option[User]] = ???
    override def createUser(user: User): IO[DBError, Unit] = ???
  })

val updatedHorizontal2 = horizontal ++ dbLayer

使用托管依赖

我们需要对应用程序的某些组件资源进行管理,也就是说它们在使用前会经历资源获取阶段,在使用后会经历资源释放阶段(例如,当应用程序关闭时)。 ZLayer 依赖强大的 ZManaged 数据类型,这使得此过程非常简单。

例如,要构建基于 Postgres 的数据库,我们需要在启动时打开java.sql.Connection,并在释放阶段将其关闭。

import java.sql.Connection
def makeConnection: UIO[Connection] = UIO(???)
val connectionLayer: Layer[Nothing, Has[Connection]] =
    ZLayer.fromAcquireRelease(makeConnection)(c => UIO(c.close()))
val postgresLayer: ZLayer[Has[Connection], Nothing, UserRepo] =
  ZLayer.fromFunction { hasC =>
    new UserRepo.Service {
      override def getUser(userId: UserId): IO[DBError, Option[User]] = UIO(???)
      override def createUser(user: User): IO[DBError, Unit] = UIO(???)
    }
  }

val fullRepo: Layer[Nothing, UserRepo] = connectionLayer >>> postgresLayer

在依赖关系之间共享层

ZIO层的一项重要功能是尽可能地并行获取它们并共享它们。对于依赖关系图中的每一层,只有一个实例可以在依赖它的所有层之间共享。如果您不想共享模块,请通过 ZLayer.fresh 创建一个新的,非共享的版本。

还请注意,ZLayer 机制不会建立循环依赖关系,因此构造过程使初始化呈现出线性关系。

依赖关系中的隐藏和传递Hidden Versus Passed Through Dependencies

有关如何构建依赖关系图的一项设计决策是对上游依赖服务是采用隐藏还是传递。默认采用隐藏依赖关系,但同时也很容易支持传递。

为了说明这一点,请思考上面的基于 postgres 数据库的讨论:

val connection: ZLayer[Any, Nothing, Has[Connection]] = connectionLayer
val userRepo: ZLayer[Has[Connection], Nothing, UserRepo] = postgresLayer
val layer: ZLayer[Any, Nothing, UserRepo] = connection >>> userRepo

注意在 layer 中,UserRepo 对 Connection 的依赖关系被“隐藏” 了,它没有被表达在类型签名中。从调用者的角度来看,layer 仅输出一个 UserRepo,并且不需要输入(Connection)。调用者不必关心如何构造 UserRepo 的内部实现细节。

这样就实现了服务的封装,并使重构代码变得更加容易。例如,假设我们要重构应用程序以使用内存数据库:

val updatedLayer: ZLayer[Any, Nothing, UserRepo] = dbLayer

无需更改其他代码,因为以前的实现使用了Connection 的事实对用户而言是隐藏的,因此他们无需考虑它。

但是,如果存在使用到上游依赖关系的其他服务,也可以方便地在输出层中将依赖关系“传递”下去。这可以通过 >+> 操作符来完成,该操作符将一层的输出提供给另一层,返回一个新层来输出(传递)这两层的服务。

val layer: ZLayer[Any, Nothing, Connection with UserRepo] = connection >+> userRepo

在这里,对 Connection 的依赖关系被传递给了下游,并且对所有下游服务都可用。这提供了一种组合的样式,其中 >+> 运算符用于构建逐渐扩大的服务集合,每个新服务都可以依赖之前的所有服务。

lazy val baker: ZLayer[Any, Nothing, Baker] = ???
lazy val ingredients: ZLayer[Any, Nothing, Ingredients] = ???
lazy val oven: ZLayer[Any, Nothing, Oven] = ???
lazy val dough: ZLayer[Baker with Ingredients, Nothing, Dough] = ???
lazy val cake: ZLayer[Baker with Oven with Dough, Nothing, Cake] = ???

lazy val all: ZLayer[Any, Nothing, Baker with Ingredients with Oven with Dough with Cake] =
  baker >+>       // Baker
  ingredients >+> // Baker with Ingredients
  oven >+>        // Baker with Ingredients with Oven
  dough >+>       // Baker with Ingredients with Oven with Dough
  cake            // Baker with Ingredients with Oven with Dough with Cake

在 ZLayer 中可以混合匹配这两种使用样式。如果您之前传递了依赖项,以后又想隐藏它们,则可以通过简单的类型声明来做到这一点:

lazy val hidden: ZLayer[Any, Nothing, Cake] = all

而且,如果确实更明确地构建了依赖关系图,则您可以确信,由于记忆和共享,依赖关系图中的不同部分所使用的层将仅创建一次。

Leave a Reply
Your email address will not be published.
*
*

BACK TO TOP