STM 介绍

STM 介绍

简介 ZIO 支持软件事务性内存 (STM),它是一种模块化的可组合的并发性数据结构。它允许我们在一个原子事务中组合并执行一组内存操作。 STM是一组并发任务之间的通信的抽象。STM 的主要优点是可组合性和模块化。我们可以编写可以与同样使用 STM 构建的任何其他抽象组合的并发抽象,同时不必暴露我们的抽象是如何确保安全的细节。而锁定机制则通常不是这样的。 Transactional 操作的想法并不新鲜,它们一直是分布式系统的基础,也是那些数据库之所以能够保证我们拥有 ACID 性质。STM是纯内存操作的。所有的操作都发生在内存中,与远程系统或数据库无关。与 ACID 属性的数据库概念非常相似,但缺少持久性,因为那对内存中的操作没有意义。 在事务性内存中,我们有关于 ACID 属性的几个方面: Atomicity(原子性)——在写操作中,我们需要原子更新,这意味着更新操作要么应该立即运行,要么根本不运行。 Consistency(一致性)——在读操作中,我们希望程序状态具有一致的视图,以确保各部分都引用相同的状态,在获得状态时获得相同的值。 Isolated(隔离性)——如果我们有多个同步更新,我们需要在隔离的事务中执行这些更新。每个事务不会影响其他并发事务。无论有多少纤程在运行多少数量的事务,都不必担心其它事务中发生的事情。 ZIO STM API 的灵感来自 Haskell 的 STM库,尽管 ZIO 中的实现完全不同。 问题 让我们从一个简单的inc函数开始,它接受一个 Int 类型的可变引用 并增加其 amount: 如果只有一个纤程,那不会有问题。这个函数看上去是正确的。但是,如果在读取计数器的值和设置新值之间,另一个纤程出现并改变了计数器的值,会发生什么?在我们读取计数器之后,另一个纤程恰好更新了计数器。所以这个函数是有竞争条件的,我们可以用下面的程序来测试: 由于上述程序运行了 10 个并发纤程来增加计数器值。但是,我们不能期望这个程序总是返回结果 10。 为了解决这个问题,我们需要原子地执行 get 和 set 操作。所幸 Ref 数据类型的一些 API,比如 update,updateAndGet和modify 提供了读写同步的原子操作。 需要注意的是有关 modify 操作最重要的一点是它不使用悲观锁定。它不对临界区使用任何锁定原语。它对潜在的操作碰撞有一个乐观的假设。 该 modify 函数执行以下三个步骤: 它假设其他纤程在大多数情况下不会改变共享状态并且在多数情况下不会互相干扰。因此它在不使用任何锁定原语的情况下读取共享状态。 你应该为最坏的情况做好准备。如果另一个纤程同时接入,会发生什么?因此,当我们开始写入新值时,应该检查所有可能影响的方面。它应该确保看到全局一致的状态,如果它看到了,那么它才可以改变那个值。 如果它遇到不一致的值,则不应继续。它应该使以上假设变得无效并中止更新共享状态。然后基于被修改过的值重新尝试 modify 操作。 让我们看看在没有任何锁定机制的情况下 Ref 如何实现 modify 功能: 正如我们所看到的,modify 操作是根据 compare-and-swap 操作来实现的,它帮助我们以原子方式执行读取和更新。 让我们将 inc 函数重命名为以下的 deposit,尝试将钱从一个账户转移到另一个账户的经典问题: 增加 withdraw 函数: 看起来还不错,但是实际上我们要先检查账户中是否有足够的余额可以提现。所以让我们修改它添加一个不变量来检查: 如果在检查和更新余额之间,另一条纤程来提取账户中的资金怎么办?所以这个解决方案包含一个错误。它有可能让账户达到负平衡。 假设我们最终达成了一个解决方案来自动退出,但问题仍然存在。我们需要以原子的方式将 withdraw 和 deposit 组合在一起以创建 transfer函数: 在上面的例子中,即使我们假设 withdraw 和 deposit 各自是原子的,我们也不能组合这两个事务。它们在并发环境中会产生错误。这段代码不能给我们保证 withdraw 都 deposit 两者在同一个原子操作中执行。同时执行此 transfer 方法的其他纤程可以覆盖共享状态并引入竞争条件。 我们需要一个解决方案来原子地组合事务。这就是STM发挥作用的地方。 可组合的并发 软件事务内存为我们提供了一种组合多个事务并在一个事务中执行它们的方法。 让我们继续我们的最后努力,将我们的 withdraw 方法转换为一个原子操作。为了使用 STM 来解决问题,我们将 Ref 替换 TRef. TRef 的含义是Transactional Reference;它是 STM 世界中的可变引用。STM 是一个一元的数据结构,代表一个可以事务化执行的 effect: 同样 deposit 操作是原子的,但为了能够和 withdraw 组合,我们也需要用 TRef 将其重构并 return STM: 在 STM 的世界中,我们可以组合所有操作,并直到世界的尽头,我们才在一个操作中原子地执行所有这些操作。为了能够让 withdraw 和 deposit 组合在一起,我们需要让它们都保持在 STM 的世界中。因此,我们不对它们中的每一个单独执行STM.atomically 或 STM#commit 方法。 现在我们可以在 STM 的世界中组合这两个函数来定义 transfer,并将它们转换为单一原子的 IO 方法: 假设我们正在将资金从一个账户转移到另一个账户。如果我们取出第一个账户但没有存入第二个账户,这种中间状态对任何外部纤程都是不可见的。如果不存在任何有冲突的变更,则事务完全成功。如果存在任何冲突或冲突更改,则整个 STM 将被重试。 它是如何工作的 STM 使用了和 Ref#modify 函数相同的思想,但具有可组合性特征。STM 的主要目标是提供一种机制来组合多个事务并在一个原子操作中执行它们。 可组合部分背后的机制是显而易见的。STM 有自己的世界。它有很多有用的 combinators(组合子),例如 flatMap 和orElse 可用于组合多个 STM 并创建更优雅的结果。在我们通过 STM#commit 或 […]

Contravariant and Covariant

将一个实例赋予它的父类变量时,其赋值语言缺省具有 Covariant(协变) 语意: 协变的语意是:当一个类型A(Animal)要接纳一个子类型B(Dog)的赋值时,尝试将B变为A的子类,系标记为+A。继承的父<-子方向为 A <- +A。 协变的语意和子类型多态的变态方向是相同的。正如上所示,Animal 是 Dog 的父类,因此将 Dog cast 成 Animal 是可以接受的。 容器类不具有类型继承关系,所以以下无法通过编译检查: 因为 MyList[Dog] 不是 MyList[Animal] 的子类,但是可以通过协变来声明他们参数之间的的关系从而声明成父子: 而 Contravariant(逆变)则将父子的关系声明为反方向: 逆变的语意是:当一个类型A(Animal)向子类型B(Dog)赋值时,尝试将B变成A的父类,标记为-A。继承的父<-子方向为 -A <- A 逆变声明通常是危险的,因为赋值语句中的类型变态的方向是从右向左。而如果赋值语句的左边(被赋值端)的类型是确定的,并且是赋值端(右侧)的子类,那不能满足类型转换条件。因为 assignment 通常允许我们做减法,可以想象将一个子类,比如 Dog 赋予它的父类 Animal 时,Dog 携带的信息等于或多余 Animal 所需要的信息,也就是说父类通常是子类的子集,因此必然可以通过子类构建出父类,所以(协变)是安全的。而反之,将父类逆变为子类则可能因为信息不足而导致失败,因此通常情况下逆变不被缺省支持: 但是作为函数的参数,逆变是受欢迎的: 当以上 transfer 被调用时: 注意我们为方法 transfer 的参数类型 T 声明它支持逆变,因此编译器将T 视为 Dog 的父类,标记为-Dog,从父到子的继承关系:-Dog <- Dog,从而允许将 Dog 逆变为 -Dog。 实际上我们并没有给 T 设定具体的边界,因此 -T 实际上指向 Dog 自己(自己是自己的父类),也可以一直逆变为 Object为止,编译器只需要寻找一个能够接纳输入值的类型即可。如果要更明确地限定 T 的类型上限只能到 Animal,可以指定它的上限 -T <: Animal,并且这样可以允许接纳更多的 Animal。 显然这是我们希望看到的。为什么呢?因为当逆变作用于函数的输入参数时,它迫使赋值端的类型升级为输入值的父类,从而允许接受它的所有子类为输入条件,这让函数变得更安全。 但是,如果我们将逆变改为协变: 编译器允许 +T,也就是 +Dog,做为Dog的子类,从而形成从父到子的继承关系:Dog <- +Dog,并尝试将 Dog 赋予自己的子类型 +Dog,但是这与赋值语句的类型转变方向(从右到左)向矛盾,因此编译器拒绝向下转换。 再来看一下函数的输出值类型的转变。与输入恰好相反,因为输出的被赋值类型(左侧)是确定的,输出类型必须确定无误地是它的子类。 这满足我们对赋值的一般印象,也是前面解释的协变发生的场景。因此输出类型参数应该满足协变。 综上所述,在定义一个容器的时候,对于参数的类型通常也应该声明为(Contravariant)逆变类型,也就是允许被赋值端的参数逆变成输入值(赋值端)的父类,这样这个容器的实现条件可以更宽松(父类具有更少的属性),比如逆变成一个 Animal 类型的输入,那么允许我们在实现的时候接受 Dog 或 Cat 或其他任何它的子类型。而输出类型为协变,这样保证输出的信息足够多(子类包含的信息比父类更多),以避免无法被左侧(被赋值侧)接受:

再谈 Monad

网上有很多文章讲解 Monad,但是没有一篇让我感到满意,因为大多数文章不是从 Monad 的编程界面出发,就是从纯数学定义出发,这些都不能解决我们心中的根本问题,就是 “为什么需要 Monad?” 如果这个问题不解答,那么无论定义,还是使用,都只是照猫画虎而已。不知道为什么需要虎,那画虎就是纯粹炫技。 虽然本文试图解释为什么需要 Monad,但是不得不暂时离开 Monad 先谈一些别的。我们要先从为什么需要泛函编程讲起,其实这也是一个很难理解的问题。因为这里充满了禅宗式的崇拜,对于初学者而言,这种崇拜貌似有些虚无缥缈,所以不被接受,能打动他们的依然是炫技。这可能也反过来解释了为什么网上没有令人满意的答案的原因吧。 简而言之,泛函编程相对于面向对象的那些禅宗式的思想主要围绕两个方面展开。 安全。 数学式的严谨。 我们首先要解答关于“安全”的疑问。这个问题其实在早先的一篇文章《一个有关泛型的错误考题》中曾提到。在那篇文章中我说,通用泛型是不安全的,因为它没有为运行时提供适用类型的边界指导,所以取而代之的是以 type class 为形式的特设泛型。详细内容不复述了,大家自己去参考。回到 type class,如果我们打开 Cats 的官方文档,我们会发现 Cats 的开篇第一句话就是对 type class 的解释: 由此可见 type class 的重要性,实际上它也确实是整个 Cats 框架的实现基础。如果你对此有疑问,或者说你对特设泛型还存在疑问,那么无论你对 Cats 的 API 背诵的有多熟悉,都不能说明你真的了解 Cats 和泛函编程,因为这同时也是 Monad 的基础。 虽然我不喜欢重复《一个有关泛型的错误考题》中的观点,但是可能还是有必要再次借用文章中的例子来解释一下通用泛型的问题,对于这样一个泛型: 它好提问:请问怎么从北京到达北极?这个问题对一个孩子来说,他可能很容易回答你:一路向北就可以到达。但是对一个成年人来说几乎是无解的,因为“一路向北”意味着许许多多的不确定性。就好比这个类,你可以实现对 int 类型的 getLarger,也可以实现对 String 类型的 getLarger,但是你如何实现所有类型的 getLarger 呢?所以这个类的定义是一个非常美好的设想,就像“一路向北”一样,但是对于实现而言却很残酷。所以我们需要对向北的过程中的所有问题都有答案之后,才可以放心地让程序一路向北走下去。于是,我们就不得不为问题建立限定,让无限的问题,变成可期待的有限问题。 让我们再举一个例子:假设我们要为我们的应用的输入实现一系列的 Validator 来检查用户输入的合法性。从某个角度来看和 Comparable[T] 是很像的。从实践的角度,我们会定义一个 Validator[T] interface,然后实现一系列诸如 IntValidator,StringValidator…,然后将它们保存在一个 List[Validator] 中让它们逐个对输入进行过滤从而得到安全的输入。这是面向对象编程中非常常见的方式,但是问题同样是:你如何保证在运行时需要验证的类型恰好存在?这个问题听上去貌似有点杞人忧天,因为作为一个程序员,你可能很快回答:当我们在开发这个应用的时候当然知道需要对哪些类型进行验证,必然会提供相应类型的实现。这个回答符合实际,但是缺乏换位思考。当一个程序员在开发一个应用的时候,我们通常既是业务的开发者,也是工具的开发者。比如我们知道我们将会在业务中验证哪些内容,当我们在做此思考的时候,我们是业务开发者,而当我们去实现这些验证工具的时候,我们往往忽视了此时我们已经切换到了工具开发者的角色。因为两个角色在开发过程中总是交替并行,如果不具有架构的思想,我们很难发现这两者对待同一个问题的微妙差别。这么说吧,从架构师的角度出发,我们希望程序的业务部分是松散的,而工具是严谨的,因为业务总是在变,我们无法预估未来会产生什么新需求,(比如对新的类型进行验证。)而新的业务需求要尽可能从用今天设计的工具,所以工具需要具备相当的稳定性。更糟糕的是,相对于程序的开发周期而言,它的维护周期要长的多的多,很可能远远长于原创程序员的在职时间,甚至长于一个程序员的职业生涯。可以想象,若干年以后,当一个应用发生了改变,之前的业务逻辑已不再适用而需要重构的时候,你之前的工作留下的遗产,实际上更多的是你作为工具开发者时创造的价值,作为业务开发者留下的价值很快会被被消耗掉。为什么我们总是畏惧对前任开发者留下的代码进行修改呢?因为我们无法确定新的修改,比如新添加的类型验证是否已经被支持?我们需要实现新的 Validator 吗?是否有已经存在的接口定义?或新的 Validator 与现有的Validator 能协同工作吗?……一系列的问题困扰了后来者,这占二次开发成本很大的比重。作为一个架构师,我之前说过,每一个架构师的心中都需要具有时态感,需要非常清楚代码生效的工作时态。在此再提一条:作为一个架构师,你心中需要具有角色感。你需要非常清楚自己以什么角色在构建当前的代码,这样才能让你的代码具有保留(重用)的价值。很显然,type class 让我们在重构旧代码时有章可循。通过特设泛型,编译器可以轻易发现接口规范和对应的实现,作为继任者,我们只需要满足编译器提出的要求,就能保证新的业务程序不因为缺少匹配类型而奔溃。这就是泛函编程对 安全 最基本的要求,它实际上是基于架构思想,而不是基于一般程序员仅仅着眼于眼前的开发需求。如果仅仅局限于解决眼前的开发,头疼医头,脚疼医脚,那么还是回到“一路向北”问题上:你的路径不可复制,因为你一路向北到达北极的过程中解决的问题没有为日后可循留下足够的安全依据,或即便存在依据,也无法保证能够被后来者“因循守旧”(在此为褒义理解)。而遵循泛函编程的安全思想则可以实现这一点。 似乎有点跑题,花了很长篇幅在介绍 type class 的重要性,它对于 Monad 意味着什么?还是让我们来看一下 Cats 的官方文档是怎么介绍 Functor 的: Functor is a type class…!写的多么清楚啊!Functor 是基于 type class 的,并且它几乎是 Scala 中所有主要的数据结构,包括 List, Option, Future 等共同遵守的基础。也就是说,在泛函编程中,Monad 为 type class 提供了一个通用模版,它是当你希望利用 type class 特性的时候应该遵循的标准。当我们理解了这一点,我觉得 Monad 作为泛函编程中的一般存在的意义已经无需多言了。 当然,如果仅仅是作为模版而存在,你可能会说只要我们遵循了 type class,那么我们可以定义自己的 trait 来达到目的,为什么依然还需要 Monad 呢?确实,Monad 基于 type class,而 type class 并非基于 Monad,所以使用 type class 并非一定要遵守 Monad 制订的模版。但是 Monad 除了作为一个通用模版,满足大部分数据结构对抽象的需求外,它还提供了一些额外的特征,这些特征也是程序所必须的,对泛函编程而言尤其是第二条,对数学式的严谨的要求。 Monad 在数学方面必须遵守几条 Laws,以结合律为例: 还是以 Validator 为例,很显然,对数据总体外在呈现出来的验证结果并不因为局部验证顺序的不同而不同。这给程序的执行带来的好处显而易见,不是吗?数学的严谨为程序在业务逻辑的解耦方面的作用不必多言,也提升了 Monad 之间的并行性和可结合性,让 type class 结构不仅适用于单子层面,也合用于更大尺度的计算结构上,以实现 One law manages all 的美感。并且这种理念最终也会体现在实现低成本维护和运算上。限于篇幅,不再展开细说了。 总之:Monad 是泛函编程中对 type class 提供支持的应用模版,同时,它还提供了一些额外特征,使的程序运行和维护更加低成本且安全可靠,这就是为什么我们需要在泛函编程的学习中了解并掌握 Monad 的原因。

一个有关泛型的错误考题

这几天遇到了一个有关泛型的 Scala 试题,题目是这样的: 要求实现这两个函数,我当场被震惊到了,这也太简单粗暴了。这道题目可以作为一个有关泛型的经典的反面教才来分析一下,看看我们能从中学到哪些东西。 我的第一反应是出题者应该是一个传统的 Javaer,他应该是受到 Java 的 Comparable 的启发,那么我们就来看一下 Java 的 Comparable 是怎么定义的: 首先,Java 的 Comparable 是一个 interface 而试题是一个 class。Java interface 中的泛型参数通常用于定义通用(generic)泛型,”通用”意味着它的适用面是无限的,也就是说它不限定 Comparable 只适用于数值或少数可比较的类型。作为一个 interface,这种适用性通常是没问题的,因为它只是一个接口,只有在实现该接口时我们才需要决定它究竟要适配哪些具体类型。但是如果将类型参数作用于 class,那么就不同了,因为 class 的泛化属于子类型(subtype) 泛化,这是运行时(runtime)泛化技术。这种技术在 Java 中使用的非常普遍,也就是考题的形态。 和 interface 不同,子类型泛化无法被静态编译,编译器无法在编译期间预知它将会被作用于哪些类型,所以它是不安全的。可以想象,因为“业务”的特点决定了”通用”不会真的有无限的匹配需求,所以通常问题在开发初期被有限的需求掩盖了,造成了以后可扩展性的问题,一旦业务的比较范围扩大到了之前没能兼容的类型时,程序就出现了 bug。而在考题中出现这种定义尤其致命,因为你不可能在没有限定条件下枚举所有可能的类型逐个实现它们的比较,并且在多数情况下很可能两个 Object 根本就不能比较,那就真的成了“无限通用”了,因此这道题目脱离了上下文根本就是无解的。 我们分析了为什么这道题作为通用泛型来考虑是无解的,顺带提到了通用泛型的缺点。与之对应的是泛函编程使用了特设(ad-hoc)泛型来弥补这个缺陷。“特设(ad-hoc)泛型”顾名思义,是与特定类型对应的泛化技术,所以它具有有限的对应数量,在 Scala 中它通过 typeclass 来实现。 typeclass 的声明很简单: 这和 Comparable interface 非常类似,甚至你可以将它依旧看成是通用泛型,但是 Scala 通过隐式可以将它视为特设泛型,它是怎么做到的呢?有两种方式:隐式参数和 context bound(上下文限定)。以 context bound 为例,我们重新来声明这个 Comp 类并实现它: 你会发现这个类的定义和考题非常相似,除了类型签名略有不同外。该怎么理解 [T: Comparable]呢?这恰是特设泛型的匹配宣告,它的意识是:当该 Comp 被实例化的时候,上下文中必须存在一个特定于 Comparable[T] 的实例。注意它与 class Comp[T] 语意上的区别,后者并没有表达出对 Comparable[T] 的渴望。因为后者,也就是纯通用泛型在编译期不考虑特设匹配,因为它的适用类型是不限定的。换一句话说,这个 Comparable 就充当了限定符(bound)的作用,有了这个 bound ,我们就”有法可依”,基本上我们可以预期这个新的 Comp 需要的是 Comparable 泛型的支持,也就是将 Comparable视为特设泛型的依据,这就是 bound 的意义。接下来就是要解决 Comparable 的类型参数 T 了,这时候另一个关键特性类型推断就派上了用场,让我来生成一个 Comp 的实例: 以上代码在编译的时候,编译器会根据参数 1和 2 推断出 Comparable 的参数是 Int 类型,因此编译器推断出它的具体限定类是 Comparable[Int],这就是特设匹配的具体匹配条件,在Comp 的这个实例中,它被限定在了Comparable[Int] 这个特定的泛型上。 现在既然编译器知道了我们需要 Comparable[Int],那么接下来就好办了,它只需要在上下文中寻找是否存在这个 bound 的实例,于是我们只需要再次通过隐式(implicit)为它声明一个特设实例: implicit 关键词是必不可少的,它激活了 Scala 的依赖注入机制。由于这个 Comparable[Int] 实例是为 Int 特设的,因此它必定满足针对 Int 的 getLarger 方法。并且,编译器在编译期间就获得了全部信息并以此找到了支持代码,所以这样编译出来的代码可以放心地交由运行期去执行。假设在未来,我们在业务中提出了对 String 类型的支持,那么当我们输入以下代码时: 编译器同样会去寻找是否存在 Comparable[String] 特设实例,如果没有找到那么会拒绝编译,于是我们就会在最终交付修改前得到一个错误报告,它为我们的功能扩展提供了安全指导,相比于无法断定是否安全的 T,显然这样的代码安全了许多。 以上整个过程听起来比较抽象,让我们用一个更形象的例子来类比。假设我们生活在一个纷繁复杂的世界中,现在我们要在人群中挑选出一个最强壮者去丛林中执行一项冒险任务,那么我们怎么比较每个人的能力呢?于是我们设计出竞技场(Arena)来作为竞选的场合,在这个场合中我们通过 match (比赛)方法,制定了适合人类比赛的规则,那么当一个特定的 Arena[Human] 出现时,它代表适用于人类竞技的 bound;同时,我们还设计出适合猎狗的 Arena[Hound],它也具有 match 方法,但是具体规则和人类的不一样。以此类推,当科技发展了,有一天我们发明出了枪械,这时候我们的任务就会发出抱怨它无法找到合适的竞技场来比较新的枪械,于是我们不得不设计新的 Arena[Gun] 来满足新的需求……很显然,因为不同的 Arena 的存在,我们避免了将人类、猎狗、和未来出现的枪械同时应用于同一规则的风险,这就是特设泛型为通用泛型带来的显著改变。 好了,理解了 [T: Comparable] 和 [T] 参数的区别,我们再回来思考这道题目背后的含义就会恍然大悟,它实际上混淆了特设泛型和通用泛型,给应试者发出了完全错误的信息。因为在 Scala 泛型编程的世界中,特设泛型是标配,而通用泛型是不被推荐的,由此可见这道考题如果作为通用泛型,它是无解的(理由参考开头),而作为特设泛型,它的类型签名又是错误的,因此这是一道彻头彻尾错误的考题,不过虽然如此,通过分析它我们还是能够学到不少东西,并非所有的错误都没有营养。

摘要

Here are a few guides for common patterns with ZIO: 使用模块和层: 如何借助ZIO环境构建大型的 ZIO 程序。 Test effects: 如何使用 ZIO Test 无缝测试效果化的程序. Mock services: 如何使用模拟(mocks)来测试服务之间的交互。 Handle errors: 如何处理 ZIO 中的错误(可声明的错误与无法预见的缺陷)。 Access system information: 如何使用ZIO访问环境变量和其他系统属性。

使用模块和层

用 ZLayer 注入 ZIO 环境 ZIO 是围绕3个参数设计的,R, E, A。R 代表运行 effect 时的要求,这意味着我们需要满足这些要求才能使 effect 可运行。我们将探讨我们可以用 R 做些什么,因为 R 在 ZIO 中起着至关重要的作用。 有关 ZIO 环境的简单案例 让我们构建一个用户管理的简单程序,该程序可以检索用户(如果存在)和创建用户。我们需要一个 DBConnection 来访问数据库,并且程序中的每个步骤都通过环境类型来表示这一点。然后,我们可以通过 flatMap 将两个(小的)步骤组合在一起,或者通过 for comprehension 更方便地进行组合。 结果是一个决于对 DBConnection 的依赖的程序。 要运行该程序,我们必须通过 provide 方法提供 DBConnection,然后再将运算提供给 ZIO 运行时。 请注意,通过 provide 为该环境提供 effect 的行为导致的结果是消除了返回的 effect 中的环境依赖,返回的 effect 中的环境类型呈现为 Any。 通常,我们不仅需要数据库连接。我们需要使我们能够执行不同操作的组件,并且需要将它们连接在一起。这也是模块化的作用。 我们的第一个 ZIO 模块 接下来,我们将看到如何定义模块,并使用它们来创建彼此依赖的不同的应用层。核心思想是,一个层依赖于紧接在下面的层,但是完全不知道其内部实现。 这种模块式的表述是 ZIO 管理应用程序组件之间的依赖关系的方式,在组合性方面提供了强大的功能,并提供了轻松更改不同实现的功能。这在测试/模拟阶段特别有用。 模块是什么? 模块是仅处理一个关注点的一组功能的集合。限制模块的范围可以提高我们理解代码的能力,因为我们一次只需要专注于一个主题,而不会在脑海中纠缠过多的概念。 ZIO 本身就通过模块来提供基本功能,比如查看 ZEnv 是如何定义的。 模块的构成 让我们按照以下简单步骤构建一个用于用户数据访问的模块: 定义一个指定模块的名称的对象,它可以是(非必须)一个包对象。 在模块对象中定义一个特质(trait)服务,该服务定义了我们模块所公开的接口,在本例中它包括两个方法:检索和创建用户。 在模块对象中,通过 ZLayer 定义该模块的不同实现(有关 ZLayer 的详细信息,请参见下文) 定义类型别名,例如 type ModuleName = Has[Service](有关以下内容的详细信息,请参见下文) 我们遇到了两种新的数据类型 Has 和 ZLayer,接下来让我们熟悉它们。 Has 数据类型 Has[A] 表示对A类型服务的依赖。可以通过 ++ 运算符将两个 Has[_] 水平合并,如: 此时您可能会问:如果结果类型只是两个 trait 的混合,那有什么用?为什么我们不仅仅依靠特质 mixins? Has 赋予的额外能力是,通过将服务类型交叉映射到服务实现,从而得到的结果数据结构混合了每个实例,不仅可以访问/提取/修改其中的某个实例,同时也保证了彼此之间的类型安全。 根据之前的说明,为 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 操作。我们还提供模块在内存中的实现。 然后,我们定义另一个模块来执行一些基本的日志功能。我们提供了它的基于zioConsole 的 consoleLogger […]

反应式流

检出 interop-reactiveStreams 模块以获得对反应式流的互操作支持。 反应式流的 Producer 和 Subscriber ZIO 通过将 zio.stream.Stream 转换到 org.reactivestreams.Publisher,和将 zio.stream.Sink 转换到 org.reactivestreams.Subscriber 来集成 Reactive Streams。反之亦然。简单地引入 zio.interop.reactiveStreams._ 来让转换生效。 例子 首先,让我们导入一些内容。 我们使用以下发布者和订阅者作为示例: 将发布者作为流 将一个发布者作为流来使用时最多缓冲 qSize 个元素。如果可能的话,qSize 应该是 2 的幂以达到最佳性能。默认值为 16。 将订阅者作为接收器 当将一个订阅者连接到 Stream 时,需要一个旁支通道来处理故障。因此,toSink 返回 Promise 和 Sink 的元组。当流失败时,Promise 必须呈现失败状态,toSink 上的 type 参数代表 Stream 的错误类型。 将流作为发布者 将接收器作为订阅者 toSubscriber 返回一个 Subscriber 和一个IO,这个 IO 负责在接收器执行结束后,或当发布者产生错误时,返回结果。作为订阅者的用接收器最多缓冲 qSize 个元素。qSize 应该尽可能是 2 的幂以达到最佳性能。默认值为16。

Scalaz 7.x

ZIO 实例 如果您是 Scalaz 7.2 的忠实用户,那么 interop-scala7x 模块为它的几种类型类提供了 ZIO 支持,请查看源代码以获取更多详细信息。 例子 通过 ZIO 并行执行 Applicative 实例 由于 Applicative 和 Monad 相关的法则,ZIO 的 Applicative 实例必须通过 bind 来获得,因此组合多个 Applicative effect 时将只能串行获得。为了解除这个限制,ZIO 通过非 Monad 的标记(Tag)来并行执行 Applicative 实例。 例子

Monix

签出 interop-monix 模块以获得与 Monix 的互操作支持。 转换 Task 互操作层提供对以下转换的支持: 将 Task[A] 转为 UIO[Task[A]] 将 Task[A] 转为 Task[A] 要将 IO 转换为 Task,请使用以下方法: 要执行反方向的转换,请使用以下定义在 IO 伴随对象中的扩展方法: 请注意,为了将 Task 转换为 IO,需要使用适当的 Scheduler。 例子 转换 Coeval 要将 IO 转换为 Coeval,请使用以下方法: 要执行反方向的转换,请使用以下定义在 IO 伴随对象中的方法: 例子

BACK TO TOP