ZIO 与外部 Java 代码具有完全的互操作性。让我向您展示它的工作原理,然后讲解第一个案例,明天您就可以在工作中使用纯函数式 Java 了。 From Java CompletionStage and back CompletionStage 是(Java 提供的)最便捷的用于模拟函数式异步效果的 API(例如 ZIO)的接口,因此我们从它开始。轻而易举地: 您甚至可以将其变成纤程! 该 API 创建了一个不绑定具体对象的纤程。 此外,您如果希望将 ZIO 值转换为 CompletionStage 也易如反掌: 正如您所看到,它返回 CompletionStage 接口的具体类,即CompletableFuture。需要指出的是,只要可以将类型 E 的值转换为Throwable,那么任何 IO[E, A] 都可以变成 CompletableFuture: Java 的 Future 类 您可以通过 ZIO.fromFutureJava 将任何 java.util.concurrent.Future 嵌入 ZIO 计算中。一个简单的 Apache Async HTTP 客户端的例子看起来如下: 就这么简单。请注意,从对产出值的签名中可以看出,ZIO 在内部使用了阻塞Future#get 调用。显然,它被运行在阻塞线程池上,我想您应该清楚地知道。如果可能的话,请如上所述使用 ZIO.fromCompletionStage。 如果您需要,也可以使用 Fiber.fromFutureJava 将它转换为纤程。类似又有差别: NIO 完成具柄 通过提供完成处理具柄,Java 对使用 NIO API 的通讯通道执行异步处理,它通过将完成处理具柄挂接到可中断 I/O 中来实现。例如,读取文件的内容: 如您所见,ZIO 在此处提供了 CPS 样式的 API,与上面的两个例子有所不同,但是仍然非常优雅。
Scala Future ZIO 现在提供了与 Scala 的 Future 的基本互操作性,并且不需要额外的模块提供支持。 转换自 Future 可以使用 ZIO.fromFuture 将 Scala 的 Future 转换为 ZIO effect: Scala 的 Future 也可以使用 Fiber.fromFuture 转换为 Fiber: 这是一个纯操作,因此于任何纤程执行公平性而言都是一个明智的注脚。 转换到 Future ZIO Task effect 可以通过 ZIO#toFuture 转换为 Future: 因为将 Task 转换为(即时的)Future 是效果化的,所以 ZIO#toFuture 的返回值是一个 effect。要真正开始执行 Future 并访问其中,必须使用 runtime 来执行 effect。 ZIO Fiber 可以通过 Fiber#toFuture 转换为 Future: 执行 Future Runtime 类型具有方法 unsafeRunToFuture,该方法可以异步执行 ZIO effect,并在 effect 执行完成时返回 Future。
interop-cats 模块提供了与 Cats Effect 生态系统之间的互操作性。 要使用此模块,请将以下内容添加到 build.sbt 中: 大多数互操作功能包含在以下程序包中: Cats Effect 实例 ZIO 通过提供 Cats Effect 类型类的实例与 Typelevel 库集成。fs2,doobie 和http4s 以及函数式 Scala 生态系统中的各种其他库都使用这些类型类。 由于 Cats Effect 的限制,ZIO 无法提供任意错误类型的实例。相反,您只能获得它的错误类型扩展自 Throwable 的错误类型的 effect 的实例。 为了方便起见,ZIO 提供了 Task 和 RIO 类型别名,这些别名将错误类型固定为Throwable,对于与 Cats Effect 互操作可能有用: 为了对这些类型使用Cats Effect实例,您的 effect 的环境类型的作用域中应具有隐式的 Runtime[R]。以下代码片段为 ZIO 中内置的所有模块创建了一个隐式的Runtime: 如果将 RIO 用于自定义环境 R,则必须创建自己的 Runtime[R],并确保它隐式地出现在需要 Cats Effect 实例的任何地方。 Cats App 为方便起见,您的应用程序可以扩展自 CatsApp,这会自动将隐式 Runtime[Environment] 引入作用域。 Timer 为了获得 cats.effect.Timer[Task] 的实例,我们需要额外导入: 默认的“互操作”不提供“Timer[Task]”的导入的原因是,这使得对需要计时功能的程序的测试变得非常困难。额外的导入(仅在需要的时候)让与计时相关的 effect (的测试)变得更加简单合理。 例子 以下示例显示如何将 ZIO 与 Doobie(用于 JDBC 访问的库)和 FS2(流式库)一起使用,它们都依赖于Cats Effect实例:
ZIO 提供与广泛的生态系统的其他部分进行互操作的能力,它们包含以下: Future — ZIO 提供内建的在 ZIO 数据类型(例如 ZIO 和 Fiber)和 Scala 并发数据类型(例如 Future)之间进行转换的能力。 Java — ZIO 提供了内建的具在 ZIO 数据类型(例如 ZIO 和 Fiber)和 Java 并发数据类型(例如 CompletionStage, Future 和 CompletionHandler)之间进行转换的能力。 JavaScript — ZIO 对 Scala.js 具有一流的支持。 Scalaz 8 — Scalaz 8 基于 ZIO,其库中包含所有 ZIO 数据类型的实例。再无需其他模块提供额外支持。 interop-cats 提供了 Cats, Cats MTL 和 Cats Effect 库的实例,这些实例使您可以将 ZIO 与依赖这些库的其它库一起使用,例如 Doobie, Http4s, FS2 和 Circe interop-reactive-streams for Reactive Streams, 提供了将 ZIO Stream和 Sink 转换为反应式 Stream 的生产者和消费者的转换。 interop-scalaz for ScalaZ 7, 为 ScalaZ 7 提供了对 ZIO 数据类型(提供支持)的 Monad 和其他类型类的实例。 interop-twitter for Twitter’s com.twitter.util.Future interop-monix for Monix’s monix.eval.Task and monix.eval.Coeval interop-guava for Guava’s com.google.common.util.concurrent.ListenableFuture 以上部分让您了解将 ZIO 与您正在使用的任何库或平台集成起来有多么容易。
TReentrantLock(可重入锁)允许效果性地、安全地并发访问某些可变状态,从而允多个纤程读取其中的状态(因为并发读取是安全的),但是只有一个纤程可以修改状态(以防止数据损坏)。此外,TReentrantLock 是使用 STM 实现的,读写操作都可以当作事务进行提交,因此可以将其用作纯 ZIO effect 解决方案的基石,并在内部允许以简单且可组合的方式锁定多个状态(感谢 STM)。 TReentrantLock 是可重入的读/写锁。可重入锁是一个纤程可以多次取得而不会对其自身造成阻塞的锁。在难以跟踪您是否已经获得锁的情况下,此功能很有用。如果锁是不可重入的,则在你获得该锁后再次去获得它时会被阻塞,从而实际上导致了死锁。 语义 该锁对读取方和写入方都提供了重新获得读取或写入锁的重入保证。在释放由写纤程持有的所有写锁之前,不允许读纤程进入。除非没有其他纤程持有写入锁,或者想要写入的纤程已经持有读取锁且没有其他纤程也持有读取锁,否则不允许写纤程进入。 此锁还允许从读锁升级到写锁(自动),以及从写锁降级到读锁(前提是您从读锁升级到写锁是自动的)。 创建一个可重入锁 获取一个读锁 获取一个写锁 多个纤程可同时持有读锁 锁升级和降级 如果您的纤程已经持有读锁,则可以将其升级为写锁,前提是(除了当前纤程外)没有其他读取纤程持有该锁。 在有争议的情况下获取写锁定 只有满足以下条件之一,才能立即获取写锁定: 没有其他的(写入)锁持有人。 当前的纤程已经持有读取锁,且没有其他方持有读取锁。 如果以上两种情况都不满足,则尝试获取写锁定将在语义上阻塞当前纤程。这是一个示例,说明仅当所有其他读取器(尝试获取写锁的纤程除外)释放对(读或写)锁的锁定后,该纤程才能获得写锁。 此例中纤程 f1 获得读取锁定,并在释放它之前休眠 5 秒钟。同时纤程 f2 尝试获取读锁定,并立即尝试获取写锁定。但是,f2 必须在语义上阻塞大约5 秒钟才能获得写锁,因为 f1 到时才将释放其对锁的保留,然后 f2 才能获取写锁定。 更安全的方法(readLock and writeLock) 对于一些简单的使用案例,应避免使用 acquireRead,acquireWrite,releaseRead 和 releaseWrite ,而应该使用诸如 readLock 和 writeLock 之类的方法。借助 Managed 构造,readLock 和 writeLock 可以自动获取并释放锁。下面的程序是一个比上面的例子更安全的版本,它确保一旦通过可重入锁完成操作,我们就不会占用(会释放)任何资源。
TSemaphore 是具有事务语义的信号量,可用于控制对公共资源的访问。它拥有一定数量的许可证,并且可以获取或释放许可证。 创建一个 TSemaphore 创建一个具有 10 个许可证的 TSemaphore: 获取一个许可证 一旦外部程序获得许可证,这会减少 TSemaphore 包含的剩余许可证数量。当用户想要访问受限共享资源时,就需要获取可: 请注意,如果在信号量中没有剩余的许可证时,尝试获取许可证则具有阻塞的语意,直到有许可证为止。请注意,阻塞语义不会阻塞线程,只当有许可证被释放时系统才会尝试重试 STM 事务。 释放一个许可证 访问完共享资源后,必须释放许可证,以便其它第三方可以访问共享资源: 查询可用的许可证 您可以使用 available 查询在 TSemaphore 中的剩余许可数量: 上面的代码创建一个具有两个许可证的 TSemaphore,然后立刻获得但不释放一个许可。然后,“available” 将会报告仅剩一个许可证。 执行带有自动获取和释放功能的 STM 操作 您可以将任意在 TSemaphore 上 acquire 和 release 许可证的 STM 操作,作为一个事务的一部分。与其: 不如: 最佳实践是使用 withPermit 而不是直接使用 acquire 和 release,除非更复杂的,比如涉及多个 STM 动作,并且它们不以 acquire 作为事务的起点,也不以 release 作为终点的情况。 获取和释放多个许可证 使用 acquireN 和 releaseN 一次可以获取和释放若干个许可证:
最近应“IT资深专业协会”的邀请做了一场有关设计原则和函数式编程的科普演讲,案例有些复杂,在这里将内容和代码简单重复一下,以便没有听明白的协友可以复习。 需求分析 案例是一个假设的电商系统的用户信息管理接口,需求大致为以下三点: 设计一个电商系统的用户信息管理子模块(ProfileService),满足新用户注册、登陆、和个人信息管理,包括头像、密码、住址等; 用户信息管理模块必须能够支持来自订单系统(OrderService)的调用,提供必要的相关功能子集。 用户信息管理模块必须能够支持来自客服系统(HelpDeskService)的调用,提供必要的相关功能子集。 先大致梳理一下订单系统到用户系统的 use case: 用户可以直接注册/登陆/或取回密码,也可以先下单(order),然后在提交订单的时候再注册/登陆,并且可以添加/修改寄送地址,因此 OrderService 需要能够访问用户的 Profile。基于这样的需求,我们得到以上 use case,并根据 use case 的功能,得到以下 ProfileService 及其接口的类图设计: 接口隔离 我们得到了一个初步的版本,但是这个版本是有缺陷的。很显然它没有考虑接口的应用。 对于订单模块 OrderService 和负责登陆认证的 AuthService 模块,ProfileService 应该提供不同的功能集合,因此我们需要将大而全的接口分割成多个不同的小接口及其组合,以便为不同的业务模块提供不同的外观,因此我们需要重新考虑接口的设计以满足对接口隔离的要求: 我们从 IProfileService 中分离出两个小的接口:ICustomerService 和 ISecurityService,让它们分别作为 OrderService 和 AuthService 的依赖项,同时 ICustomerService 和 ISecurityService 又共享了注册/登陆功能,这些更基础的功能项被放在更基础的 IRegisterService 中。自此,我们为项目成功提供了隔离接口,让每个模块彼此保持了低耦合性。目前为止来说,传统面向对象架构都还能够很好地胜任。 项目演进 现在让我们来添加第三个 HelpDeskService 模块,先来看一下这个模块的 use case: Help desk 模块相比 AuthService 而言,出于安全考虑,我们不打算让它能替用户开户,更不能替用户登陆。但是它可以在用户忘记密码的时候协助用户发送密码重设邮件,因此它可以访问 ResetPassword 功能,而我们假设这个能力是 OrderService 没有的。同时它又比 AuthService 有更丰富的用户信息管理能力,比如添加或修改用户的寄送地址等,这部分功能则与 OrderService 共享。因此它看上去更像是 AuthService 和 OrdereService 的部分子功能的集合。也就是说我们需要从 ICustomerService 和 ISecurityService 中屏蔽掉(来自最基本的 IRegisterService 的)部分功能,然后再分离出一些更小的元素重新组合出新的集合,假设这个集合叫 IHelpService,于是我们再次重构接口得到以下类图: 重新设计的接口组合,我们首先从最基本的层面拆解了 IRegisterService,得到了四个更小粒度的接口,分别是 IPasswordReset、IProfileReadOnly、IRegisterService 和 IAddressService 四个基本接口,然后在这四个接口的基础上,我们从新组合得到 IHelpService、ICustomerService 和 ISecurityService 这三个分别对应 Help desk、订单和认证的功能接口,以及基于以上接口的最上一层 IProfileService 模块的总接口。 以上设计基本上达到了接口设计的目的,它保持了接口隔离。ICustomerService 和 ISecurityService 从客户端的角度看完全没有发生变化,但是我们成功新增了 IHelpService 接口,其中包含了分离出来的部分函数,隔离了不需要的函数。 虽然我们达到了设计目标,但是感觉似乎有一些不对劲。仔细品味一下,我们在添加 Help desk 接口的时候,明显比前两个接口要费劲的多,我们几乎重构了之前的所有接口,而结果也让类图中的元素看上去增加了许多,这会不会是个问题呢? 问题及思考 经过重新的梳理,我们发现以上这些接口虽然能够让我们完整地遵守接口隔离原则,只暴露必要的功能给相应的客户端模块。但是很显然相比在没有加入 Help desk 之前的设计,接口逻辑的复杂度已经提升了很多。这是因为当三个或三个以上的模块彼此产生联系的时候,它们之间的关系不是以线性的方式,而是以曲线方式递增的。我们每增加一个新的模块,它都有可能和已经存在的所有模块发生交集,并且在最坏的情况下可能导致重构所有有关联的接口模块的继承关系。由此可见,接口隔离原则虽然很重要,但是它给工程带来的设计成本的增加,在随着项目规模的升级,会越来越可观,很快就会达到难以承受的高度。成本是我们在实际的工程中必须要解决的首要问题,否则它将严重影响质量。 另一个方面,我们之前的分析一直聚焦在接口上,暂时地忽略了实现层面的问题。从第一个版本开始,我们只是将所有的功能实现都集中在 ProfileService 这一个大类(class)中,它实现了 IProfileService 接口,是所有功能最终的提供者。虽然从接口层面我们不必关心这一点,但是在实际项目中,我们几乎一定会考虑代码解藕,而不仅仅是接口的解藕。因此,我们有必要思考一下 ProfileService 的实现应该怎样适应接口的变化。 如何实现功能 class 这个问题其实和第一个问题有一定的关联。我们设想一下,带来第一个问题的根本是因为接口隔离原则着眼的是功能的组合,而不是功能本身,因此如果我们在最极端的情况下,将每单个功能都以一个单一接口来表达,然后基于一个一个零散的实现来组合隔离接口。这好比,与其靠从其它车辆上拆卸零件来组装新的汽车,为何不一开始就提供最原子化的零件来组装汽车呢?也就是说,如果我们抛弃“继承”,直接以单个函数为对象来实现我们的类(特质),然后再通过“混入”(mix in),来实现不同的接口组合,那么就能够实现模块的数量和复杂度的线性关系。因此从对功能的实现上引入“蛋糕模型”,而不是接口继承模型来设计我们的功能组合将明显有利于成本控制。 那么蛋糕模型对我们的传统设计是否构成挑战呢?首先,当我们为每个方法都设立一个对应的接口时,编码量会明显地增加,只是相比不可控地增加有所减小,至少它是线性的,你可以基于此做出预算了。其次,让我们以这样一个例子来看一下具体的实现:假设我们将实现 addAddress(Int profileId, Address address) 这个函数,它的代码大致如下: 在添加新的地址之前,我们需要先根据 profile id 来获得相应的 profile 引用。传统的继承模型中,我们可以通过父类提供的 getProfile 方法来获得 profile 的引用,但是现在我们使用的是蛋糕模型,很显然当前上下文中没有 getProfile 方法可供调用。它来自蛋糕的另一块切片,两个切片之间没有继承关系!如果我们使用 new 在这里加入一个实体变量来指向 getProfile,那么我们可以想象在整个工程中,类似的操作将累积起惊人的内存消耗和无端的运行开销,并且它也不是代码管理的最佳实践,因此这条路显然也走不通。 我们似乎陷入了另一个迷宫:虽然借助蛋糕模型暂时控制了成本不成比例上涨的问题,但是照成了另一个更难解决的技术难题。如何让不同的,没有继承关系的类彼此发生调用关系呢?这成为了我们要解决的第二个难题。当然,这也是传统架构遗留下来的问题,因为传统架构没有为我们提供突破此障碍的解决方案。自此,我们不得不重新审视我所选择的开发工具,采用更全面的函数式编程来解决以上工程和技术难题。 函数式解决方案 我将采用 Scala 来重构以上设计。首先要声明,我们将在不牺牲接口隔离原则的前提下,以可控的成本来解决技术上的挑战。以 HelpService 接口为例: 打完收工…… 是的,结束了,以上就是实现 HelpService 接口的全部(如果神奇的 “???” 也算的话)代码。让我来解释一下: trait(特质)是一个融合了 Interface 和 Abstract class 的,函数式风格的“接口”,通过 trait,我们避免了为无数的函数提供同样多数量的 Interface,这极大地节约了代码量。并且它的编写成本和单个函数相差无几,这是我们保证工程成本的关键,基于此我们可以得到更可承受的成本预算。同时,它提供了比函数,甚至比接口和抽象基类更强大的能力,它是函数式语言实现蛋糕模型的核心。在 Scala 中,我们甚至可以在 trait 中通过“自类型”来让没有关系的两个 trait […]
TSet[A] 是一个可以参与 STM 中事务可变集合。 创建一个 TSet 创建一个空的 TSet: 或创建具有指定值的 TSet: 或者,您可以通过具有值的集合来创建 TSet: 如果提供了重复项,则取最后一个。 将一个值加入 TSet 可以通过以下方式将新元素添加到集合中: 如果集合已经包含元素,则不会进行任何修改。 从 TSet 中删除元素 从 TSet 中删除元素的最简单方法是使用 delete 函数: 同样,可以删除满足所提供函数参数的每个元素: 或者,您可以保留所有与函数参数相匹配的元素: 请注意,retainIf 和 removeIf 与 filter 和 filterNot 具有相同的目的。分别命名它们的原因是要强调其本质上的区别。也就是说,retainIf 和 removeIf 都是破坏性的,调用它们会修改集合本身。 TSet 的并集 Set A 和 Set B 的并集表示属于 Set A 或 Set B 或两者都属于的元素集。使用 A union B 函数来修改 A 集合(取得两者的并集)。 TSet 的交集 Set A 和 Set B 的交集指的是同时属于 A 和 B 的元素集。使用 A intersect B 函数修改 A 集合(来取得交集)。 TSet 的差集 Set A 和 Set B 的差集是包含于集合 A但,集合 B 中没有元素。使用 A diff B 函数来获得。The difference between sets A and B is the set containing elements of set A but not in B. Using A diff B method modifies set A. 转换 TSet 的元素 transform(A => A) 函数允许基于集合中的每个元素计算一个新值: 以下可以压缩 TSet: 上面示例得到的结果集只有一个元素。 请注意,transform 的作用与 map 相同。对其进行不同命名的原因是要强调其本质上的区别。也就是说,transform 是破坏性的,调用它会修改集合本身。 可以通过 transformM 效果化地映射元素: fold 可以使用指定的二元运算折叠 TSet 的元素: 元素可以通过 foldM 有效折叠:The elements can be folded effectfully via foldM: 对 TSet 中的元素执行side-effect foreach 用于对集合中的每个元素执行副作用: 检查 TSet 的成员 检查元素是否存在于 TSet 中: 将 TSet 转换为 List 可以按以下方式获取集合元素的列表: TSet 的大小 集的大小可以通过以下方式获得:
TRef[A] 是可以参与 STM 事务的对不可变值的可变引用。其可变引用可以在事务内检索和设置,并被强制保证原子性,一致性并与其他事务的隔离。 TRef 在 STM 内存中发生改变时,使用了低级机制来创建事务。 创建一个 TRef 在事务内部创建TRef: 或者在事务内创建一个TRef,然后立即提交该事务,这使您可以存储并传递一个值引用。 从 TRef 中取出值 从一次事务中取回结果值: 或多次事务提交后取回值: 给 TRef 设置一个值 设置该值将覆盖现的引用内容。 在单个交易中设置值: 或(在)多次交易(中多次设置): 更新 TRef 中的值 update(A => A) 函数允许根据 TRef 中旧的值计算新的值。 在单次事务中更新值: 或(在)多次事务(中多次更新): 修改 TRef 中的值 modify(A => (B, A)): B 函数和 update 相似,但是它允许返回一些(B 类型的)信息。 在单个事务中修改值: 或(在)多次事务(中多次修改): 使用例子 这是使用 TRef 在两个纤程之间传递值的例子: 在此示例中,我们为发送者和接收者创建并提交两个事务引用,以便能够提取它们的值。在接下来的步骤中,我们创建一个原子事务,仅在发送者帐户中有足够的可用余额时才更新两个帐户。然后,我们(fork)以异步运行它。在接下去的纤程中,我们暂停它的执行直到发件人余额发生变化(在这种情况下达到零为止)。 最后,我们提取两个帐户的新值并将其合并为一个结果。 ZTRef 和 Ref[A] 类似,TRef[A] 实际上是 ZTRef[+EA, +EB, -A, +B] 类型的别名, ZTRef[+EA, +EB, -A, +B] 是一个多态的事务性引用,支持 ZRef 所提供的所有运算。有关多态引用的更多讨论,请参见 ZRef.