# Effect 中文社区 · 全量中文内容 > 非官方社区站;内容为 Effect 官方文档(Effect-TS/website,MIT)的中文译文与社区原创。 > 站点:https://effect-ts.cn/ · 官方站点:https://effect.website/ > 生成时间:2026-09-18T14:54:10.242Z --- # 从 ZIO 转向 Effect > Effect 与 ZIO 之间的关键差异。 如果你是从 ZIO 转向 Effect 的开发者,有一些差异需要留意。 ## 环境 在 Effect 中,我们把运行一段 effect 工作流所需的环境表示为服务的**并集(union)**: **示例**(使用服务的并集定义环境) ```ts import { Effect } from "effect" interface IOError { readonly _tag: "IOError" } interface HttpError { readonly _tag: "HttpError" } interface Console { readonly log: (msg: string) => void } interface Logger { readonly log: (msg: string) => void } type Response = Record // `R` is a union of `Console` and `Logger` type Http = Effect.Effect ``` 对于从 ZIO 过来的人,这可能有些令人困惑:在 ZIO 中,环境被表示为服务的**交集(intersection)**: ```scala type Http = ZIO[Console with Logger, IOError, Response] ``` ## 设计缘由 使用并集来表示 `Effect` 工作流所需环境,其缘由归根结底在于我们希望去掉作为环境中服务包装器的 `Has`(这与 ZIO 2.0 中达成的目标类似)。 为了能从 Effect 中去掉 `Has`,考虑到 TypeScript 是结构化类型系统,我们必须更多地从结构的角度来思考。在 TypeScript 中,如果你有一个类型 `A & B`,而 `A` 与 `B` 之间存在结构冲突,那么类型 `A & B` 就会归约为 `never`。 **示例**(交叉类型的冲突) ```ts interface A { readonly prop: string } interface B { readonly prop: number } // @errors: 2322 const ab: A & B = { prop: "", } ``` 在早期的 Effect 版本中,人们用交叉类型来表示包含多个服务的环境。使用交叉类型(即 `A & B`)的问题在于:环境中可能存在多个服务,它们拥有同名的方法和属性。为了解决这个问题,我们把服务包装进 `Has` 类型(类似于 ZIO 1.0),于是你的环境中就会出现 `Has & Has`。 在 ZIO 2.0 中,`ZIO` 类型(表示环境)的_逆变(contravariant)_ `R` 类型参数变成了完全幻影(phantom)的,从而可以移除 `Has` 类型。这显著提升了类型签名的清晰度,也为新用户消除了又一个「绊脚石」。 为了在 Effect 中顺利移除 `Has`,我们必须考虑环境中的类型如何组合。按照组合规则,就可赋值性而言,以交叉类型(即 `&`)组合起来的逆变参数,等价于以并集(即 `|`)组合起来的协变参数。基于这一点,我们决定与 ZIO 分道扬镳,把 `R` 类型参数设为_协变(covariant)_的,因为当 `A` 与 `B` 存在冲突时,`A | B` 不会归约为 `never`。 沿用上面的例子: ```ts interface A { readonly prop: string } interface B { readonly prop: number } // ok const ab: A | B = { prop: "", } ``` 把 `R` 表示为一个协变类型参数,其中承载 `Effect` 工作流所需的服务的并集,这使我们得以去掉对 `Has` 的要求。 ## 类型别名 在 Effect 中,不像 ZIO 那样有 `UIO`、`URIO`、`RIO`、`Task`、`IO` 这类预定义的类型别名。 原因在于,类型别名一旦被组合就会丢失,除非你为**每一个**函数都维护**多套**签名,否则它们多少有些无用。在 Effect 中,我们选择不走这条路。相反,我们用 `never` 类型来表示未被使用的类型。 值得一提的是,「类型别名更容易理解」这种感觉往往只是一种错觉。在 Effect 中,显式的写法 `Effect` 清楚地表明只使用了类型 `A`。而使用 `RIO` 这样的类型别名时,就会冒出关于类型 `E` 的疑问:它是 `unknown` 吗?还是 `never`?要记住这类细节并不容易。 --- # Effect 与 fp-ts 对比 > 对比 Effect 与 fp-ts,涵盖类型化服务、资源管理、并发与 Stream 处理等特性。 ## 关键进展 - **项目合并**:fp-ts 项目已正式并入 Effect-TS 生态。fp-ts 的作者 Giulio Canti 受邀加入了 Effect 组织。更多细节参见[这则公告](https://dev.to/effect/a-bright-future-for-effect-455m)。 - **延续与演进**:Effect 可以看作 fp-ts v2 的继任者,实际上等同于 fp-ts v3,标志着该库能力的一次重大演进。 ## 常见问题 ### Effect 与 fp-ts 的打包体积对比 **问:我用 Effect 和 fp-ts 分别写了两个简单的程序并比较它们的打包体积,为什么 Effect 的打包体积更大?** 答:观察到打包体积不同是很自然的,因为 Effect 和 fp-ts 是两套为不同目的而设计的系统。 Effect 的打包体积更大,是因为它内置了 Fiber 运行时,而这对它的功能至关重要。 初始打包体积看起来可能偏大,但随着你不断使用 Effect,这部分开销会被逐渐摊薄。 **问:在 Effect 和 fp-ts 之间做选择时,我需要担心打包体积的差异吗?** 答:不一定。你应该结合项目的具体需求,以及这两个库各自能带来的收益来判断。 Effect 中的 **Micro** 模块被设计成标准 `Effect` 模块的轻量级替代品,专门面向对减小打包体积有严格要求的场景。 该模块是自包含的,不包含 `Layer`、`Ref`、`Queue`、`Deferred` 这类更复杂的功能。 一旦用到任何主要的 Effect 模块(`Option`、`Either`、`Array` 这类基础数据模块之外),effect 运行时就会被加入你的打包产物,Micro 带来的好处也随之消失。 因此,对于希望在尽量不影响打包体积的前提下实现 Effect 能力的库来说,Micro 是理想选择,尤其是那些计划对外暴露基于 `Promise` 的 API 的库。 它还支持这样的场景:客户端使用 Micro,而服务端使用 Effect 的完整功能集,从而在同一应用的不同部分之间保持兼容并共享逻辑。 ## 对比表 下表对比了 Effect 与 [fp-ts](https://github.com/gcanti/fp-ts) 两个库的特性。 | 特性 | fp-ts | Effect | | ------------------------- | ----- | ------ | | 类型化服务 | ❌ | ✅ | | 内置服务 | ❌ | ✅ | | 类型化错误 | ✅ | ✅ | | 可管道化的 API | ✅ | ✅ | | Dual API | ❌ | ✅ | | 可测试性 | ❌ | ✅ | | 资源管理 | ❌ | ✅ | | 中断 | ❌ | ✅ | | Defect | ❌ | ✅ | | 基于 Fiber 的并发 | ❌ | ✅ | | Fiber 监督 | ❌ | ✅ | | 重试与重试策略 | ❌ | ✅ | | 内置日志 | ❌ | ✅ | | 内置调度 | ❌ | ✅ | | 内置缓存 | ❌ | ✅ | | 内置批处理 | ❌ | ✅ | | 指标 | ❌ | ✅ | | 追踪 | ❌ | ✅ | | 配置 | ❌ | ✅ | | 不可变数据结构 | ❌ | ✅ | | Stream 处理 | ❌ | ✅ | 下面逐一解释每项特性: ### 类型化服务 fp-ts 和 Effect 都能在类型层面跟踪需求,让你可以定义并使用具有特定类型的服务。在 fp-ts 中,你可以使用 `ReaderTaskEither` 类型;而在 Effect 中,则有 `Effect` 类型可用。需要注意的是,在 fp-ts 中 `R` 类型参数是逆变的,这意味着无法保证避免冲突,而且该库只提供了用于依赖管理的基础工具。 另一方面,在 Effect 中 `R` 类型参数是协变的,当涉及多个 effect 时,所有 API 都能在类型层面合并依赖。Effect 还提供了一系列专门设计的工具来简化依赖管理,包括 `Tag`、`Context` 和 `Layer`。这些工具让你处理代码中的依赖时更轻松、更灵活,也更容易组合和管理复杂应用。 ### 内置服务 Effect 内置了 `Clock`、`Random` 和 `Tracer` 这类服务,而 fp-ts 不提供任何默认服务。 ### 类型化错误 两个库都支持类型化错误,让你可以定义并处理具有特定类型的错误。不过,在 Effect 中,当涉及多个 effect 时,所有 API 都能在类型层面合并错误,而且每个 effect 都可能以不同类型的错误失败。 这意味着,组合多个可能失败的 effect 时,最终的错误类型会是各个错误类型的并集。Effect 提供了各种工具与类型层面的操作,来高效地处理和管理这些合并后的错误类型。 ### 可管道化的 API fp-ts 和 Effect 都提供可管道化的 API,让你能借助 `pipe` 函数以函数式且可读的方式组合和串联操作。不过 Effect 更进一步,为每种数据类型都提供了 `.pipe()` 方法,这样处理管道时更方便,不必每次都显式导入 `pipe` 函数。 ### Dual API Effect 提供 dual API,让你可以用不同的方式使用同一个 API(例如 "data-last" 与 "data-first" 两种变体)。 ### 可测试性 fp-ts 的函数式风格总体上有利于写出易于测试的代码,但该库本身并没有提供专门为测试阶段设计的工具。而 Effect 在可测试性上更进一步,提供了额外的、专门用于简化测试流程的工具。 Effect 提供了一系列提升可测试性的工具。例如,它提供了 `TestClock`,让你可以在测试中控制时间的流逝,这对测试依赖时间的代码很有用。此外,Effect 还提供了 `TestRandom`,让你能对涉及随机性的代码进行完全确定性的测试,从而保证测试结果一致且可预测。另一个有用的工具是 `ConfigProvider.fromMap`,它让你在测试期间可以轻松地为应用定义 mock 配置。 ### 资源管理 Effect 内置了资源管理能力,而 fp-ts 在这方面的功能很有限(主要是 `bracket`),并且不够成熟完善。 在 Effect 中,资源管理指的是以安全且受控的方式获取和释放资源(例如数据库连接、文件句柄或网络套接字)的能力。该库提供了全面而精细的机制来处理资源的获取与释放,确保正确清理并防止资源泄漏。 ### 中断 Effect 支持中断,也就是说,你可以在需要时中断并取消正在进行的计算。这一特性让你对代码的执行有更强的控制力,也能应对希望在计算完成前就停止它的场景。 在 Effect 中,当你需要处理用户取消、超时,或者其他要求停止正在进行的计算的外部事件时,中断就非常有用。你可以显式地请求中断,库会安全而高效地停止该计算的执行。 另一方面,fp-ts 没有对中断的内置支持。在 fp-ts 中,一个计算一旦开始,就会一直运行到完成或遇到错误,无法在中途被中断。 ### Defect Effect 提供了处理 defect、管理**非预期**失败的机制。在 Effect 中,defect 指的是程序执行期间可能出现的非预期错误或失败。 借助 Effect,你可以用内置的工具与实用函数以结构化且可靠的方式处理 defect。它提供的错误处理能力让你能够捕获并处理异常、从失败中恢复,并优雅地应对非预期场景。 另一方面,fp-ts 没有专门用于管理 defect 的内置支持。虽然你可以用标准的函数式编程技术在 fp-ts 中处理错误,但 Effect 在处理 defect 方面提供了更全面、更精简的方式。 ### 基于 Fiber 的并发 Effect 利用基于 Fiber 的并发,从而实现轻量而高效的并发计算。简单来说,基于 Fiber 的并发允许多个任务同时运行,让你的代码响应更快、效率更高。 借助基于 Fiber 的并发,Effect 能以轻量且不阻塞其他任务执行的方式处理并发操作。这意味着你可以同时运行多个计算,充分利用可用资源并最大化性能。 另一方面,fp-ts 没有对基于 Fiber 的并发的内置支持。虽然 fp-ts 提供了丰富的函数式编程特性,但它在并发计算方面的支持程度不及 Effect。 ### Fiber 监督 Effect 提供了用于管理和监控 Fiber 的监督策略。fp-ts 没有对 Fiber 监督的内置支持。 ### 重试与重试策略 Effect 内置了按可自定义的重试策略重试计算的支持。fp-ts 开箱即用并不提供这一功能,你需要依赖外部库才能实现类似能力。不过要注意,外部库在精细程度和可调优程度上,可能比不上 Effect 内置的重试能力。 重试功能让你可以在计算或操作失败时,依据一组预定义的规则或策略自动重试。当你面对不可靠或不可预测的资源(例如网络请求或外部服务)时,这尤其有用。 Effect 提供了一整套全面的重试策略,你可以按自己的具体需求进行定制。这些策略定义了重试计算的条件,例如重试次数、重试之间的延迟,以及判断是否应当尝试重试的标准。 借助 Effect 内置的重试功能,你可以更稳健、更有弹性地处理瞬时错误或临时性失败。这有助于提升应用整体的可靠性与稳定性,特别是在需要与外部系统或服务交互的场景中。 相比之下,fp-ts 不提供重试计算的内置支持。如果你需要在 fp-ts 中使用重试功能,就得依赖外部库,而这些库可能无法提供与 Effect 相同水平的精细度与灵活性。 值得一提的是,Effect 内置的重试能力被设计为能与其错误处理、资源管理等其他特性无缝协作。这种集成让处理计算中的失败与重试有了更统一、更全面的方式。 ### 内置日志 Effect 自带日志能力。这意味着你可以轻松地把日志集成到应用中,而不需要额外的库或依赖。此外,Effect 提供的默认 logger 可以替换为自定义 logger,以满足你特定的日志需求。 日志是软件开发中不可或缺的一环,它让你能在代码执行期间记录并跟踪重要信息,帮助你监控应用行为、调试问题,并收集可供分析的洞察。 借助 Effect 内置的日志能力,你可以轻松地在代码的各个位置记录消息、警告、错误或其他相关信息。这对于跟踪执行流程、发现潜在问题,或捕获应用运行期间的重要事件尤其有用。 另一方面,fp-ts 不提供内置的日志能力。如果你需要在 fp-ts 中使用日志功能,就得依赖外部库,或者从零实现自己的日志方案,这会给代码库带来额外的复杂度和依赖。 ### 内置调度 Effect 提供内置的调度能力,让你可以按时间管理计算的执行。fp-ts 不具备这一特性。 在许多应用中,常常会有需要按特定间隔执行、或安排在未来执行的任务或计算。例如,你可能希望定期更新数据、触发通知,或在特定时间运行后台进程。内置调度在这些场景下就派上了用场。 另一方面,fp-ts 没有内置的调度能力。如果你需要在 fp-ts 中安排任务或管理定时计算,就必须依赖外部库,或者自己实现调度机制,这会增加代码库的复杂度。 ### 内置缓存 Effect 提供内置的缓存机制,让你可以缓存计算结果以提升性能。fp-ts 不具备这一特性。 在许多应用中,计算可能很耗时或耗费资源,尤其是在处理复杂操作或访问远程资源时。缓存是一种存储计算结果的技术,这样就能快速取回结果,而不必每次都重新计算。 借助 Effect 内置的缓存能力,你可以轻松缓存计算结果并在需要时复用。通过避免重复计算、减轻对外部资源的压力,这能显著提升应用的性能。 ### 内置批处理 Effect 提供内置的批处理能力,让你可以把多个计算合并为一次批量计算。fp-ts 不具备这一特性。 在许多场景中,你可能需要执行多个共享相似输入或依赖的计算。逐个执行这些计算会造成效率低下和额外开销。批处理是一种把这些计算分组、以单个批次执行的技术,能提升性能并减少不必要的处理。 ### 指标 Effect 内置了对收集和上报指标的支持,这些指标与计算及系统行为相关。它特别支持 [OpenTelemetry 指标](https://opentelemetry.io/docs/specs/otel/metrics/)。fp-ts 不具备这一特性。 指标在理解和监控应用的性能与行为方面起着关键作用。它们能就响应时间、资源利用率、错误率等诸多方面提供有价值的洞察。通过收集和分析指标,你可以定位性能瓶颈、优化代码,并做出有依据的决策来提升应用的整体质量。 ### 追踪 Effect 内置了追踪能力,让你可以追踪和调试代码的执行,并跟踪一个请求在应用中的流转路径。此外,Effect 还提供了专门的 [OpenTelemetry exporter](https://opentelemetry.io/docs/instrumentation/js/exporters/),用于与 OpenTelemetry 可观测性框架集成。相比之下,fp-ts 没有提供类似的追踪工具来增强代码执行的可见性。 ### 配置 Effect 内置了对在计算中管理和访问配置值的支持。fp-ts 不具备这一特性。 配置值是软件开发中不可或缺的一部分。它们让你无需修改代码就能定制应用的行为。配置值的例子包括数据库连接字符串、API 端点、功能开关,以及各种可能随环境或部署而变化的设置。 借助 Effect 内置的配置支持,你可以轻松地在计算中管理和访问这些值。它提供了便捷的工具和抽象来加载、校验和访问配置值,确保应用拥有正常运行所需的设置。 利用 Effect 内置的配置支持,你可以: - 从各种来源加载配置值,例如环境变量、配置文件或远程配置提供方。 - 校验并确保加载的配置值符合预期的格式与结构。 - 在计算中访问配置值,从而在需要的任何地方使用它们。 ### 不可变数据结构 Effect 内置了对 `Chunk`、`HashSet`、`HashMap` 这类不可变数据结构的支持。这些数据结构保证一旦创建,其值就无法被修改,有助于写出更安全、更可预测的代码。相比之下,fp-ts 没有对这类数据结构的内置支持,只提供了为 `Set`、`Map` 等标准数据类型补充 API 的模块。这些模块虽然有用,但它们并不具备 Effect 内置的不可变数据结构所提供的同等性能优化与专用操作。 不可变数据结构带来多项好处,包括: - 不可变性:不可变数据结构在创建之后无法被修改。这一性质消除了意外修改的风险,也让并发编程更安全。 - 可预测性:使用不可变数据结构时,你可以确信它们的值不会意外改变。这种可预测性简化了对代码行为的推理,也减少了由可变状态引发的 bug。 - 共享与复用:不可变数据结构可以在程序的不同部分之间安全共享。由于它们无法被修改,你不需要创建防御性副本,从而带来更高效的内存使用和更好的性能。 ### Stream 处理 Effect 生态内置了对 Stream 处理的支持,让你可以处理数据流。Stream 处理是一个强大的概念,让你能以响应式、异步的方式高效地处理和转换持续不断的数据流。不过,fp-ts 并没有内置这一特性,它依赖 RxJS 这类外部库来处理 Stream 处理。 --- # Effect 与 neverthrow 对比 > Effect 与 neverthrow 的对比,涵盖类型安全与错误处理等特性。 在 TypeScript 中处理错误时,[neverthrow](https://github.com/supermacro/neverthrow) 与 Effect 都提供了有用的抽象, 用来在不使用异常的情况下对成功与失败建模。二者共享许多概念,例如把计算包装进一个安全的容器、 用 `map` 转换值、用 `mapErr`/`mapLeft` 处理错误,以及提供组合或解包结果的工具函数。 本页针对常见用例,对 neverthrow 与 Effect 的 API 做了并排对比。 如果你已经熟悉 neverthrow,这些示例会帮助你理解如何用 Effect 实现同样的模式。 如果你是初次接触,这份对比会突出二者的相似与不同之处,帮助你判断哪个库更适合你的项目。 neverthrow 暴露的是**实例方法**(例如 `result.map(...)`)。 Effect 暴露的是 `Either` 上的**函数**(例如 `Either.map(result, ...)`),并支持 `pipe` 风格,以获得更好的可读性和更好的 tree shaking。 ## 同步 API ### ok **示例**(创建成功结果) ```ts import { ok } from "neverthrow" const result = ok({ myData: "test" }) result.isOk() // true result.isErr() // false ``` ```ts import * as Either from "effect/Either" const result = Either.right({ myData: "test" }) Either.isRight(result) // true Either.isLeft(result) // false ``` ### err **示例**(创建失败结果) ```ts import { err } from "neverthrow" const result = err("Oh no") result.isOk() // false result.isErr() // true ``` ```ts import * as Either from "effect/Either" const result = Either.left("Oh no") Either.isRight(result) // false Either.isLeft(result) // true ``` ### map **示例**(转换成功值) ```ts import { Result } from "neverthrow" declare function getLines(s: string): Result, Error> const result = getLines("1\n2\n3\n4\n") // this Result now has a Array inside it const newResult = result.map((arr) => arr.map(parseInt)) newResult.isOk() // true ``` ```ts import * as Either from "effect/Either" declare function getLines(s: string): Either.Either, Error> const result = getLines("1\n2\n3\n4\n") // this Either now has a Array inside it const newResult = result.pipe(Either.map((arr) => arr.map(parseInt))) Either.isRight(newResult) // true ``` ### mapErr **示例**(转换错误值) ```ts import { Result } from "neverthrow" declare function parseHeaders( raw: string, ): Result, string> const rawHeaders = "nonsensical gibberish and badly formatted stuff" const result = parseHeaders(rawHeaders) // const newResult: Result, Error> const newResult = result.mapErr((err) => new Error(err)) ``` ```ts import * as Either from "effect/Either" declare function parseHeaders( raw: string, ): Either.Either, string> const rawHeaders = "nonsensical gibberish and badly formatted stuff" const result = parseHeaders(rawHeaders) // const newResult: Either, Error> const newResult = result.pipe(Either.mapLeft((err) => new Error(err))) ``` ### unwrapOr **示例**(提供默认值) ```ts import { err } from "neverthrow" const result = err("Oh no") const multiply = (value: number): number => value * 2 const unwrapped = result.map(multiply).unwrapOr(10) ``` ```ts import * as Either from "effect/Either" const result = Either.left("Oh no") const multiply = (value: number): number => value * 2 const unwrapped = result.pipe( Either.map(multiply), Either.getOrElse(() => 10), ) ``` ### andThen **示例**(串联可能失败的计算) ```ts import { ok, Result, err } from "neverthrow" const sqrt = (n: number): Result => n > 0 ? ok(Math.sqrt(n)) : err("n must be positive") ok(16).andThen(sqrt).andThen(sqrt) // Ok(2) ``` ```ts import * as Either from "effect/Either" const sqrt = (n: number): Either.Either => n > 0 ? Either.right(Math.sqrt(n)) : Either.left("n must be positive") Either.right(16).pipe(Either.andThen(sqrt), Either.andThen(sqrt)) // Right(2) ``` ### asyncAndThen **示例**(串联可能失败的异步计算) ```ts import { ok, okAsync } from "neverthrow" // const result: ResultAsync const result = ok(1).asyncAndThen((n) => okAsync(n + 1)) ``` ```ts import * as Either from "effect/Either" import * as Effect from "effect/Effect" // const result: Effect const result = Either.right(1).pipe( Effect.andThen((n) => Effect.succeed(n + 1)), ) ``` ### orElse **示例**(在失败时提供备选方案) ```ts import { Result, err, ok } from "neverthrow" enum DatabaseError { PoolExhausted = "PoolExhausted", NotFound = "NotFound", } const dbQueryResult: Result = err(DatabaseError.NotFound) const updatedQueryResult = dbQueryResult.orElse((dbError) => dbError === DatabaseError.NotFound ? ok("User does not exist") : err(500), ) ``` ```ts import * as Either from "effect/Either" enum DatabaseError { PoolExhausted = "PoolExhausted", NotFound = "NotFound", } const dbQueryResult: Either.Either = Either.left( DatabaseError.NotFound, ) const updatedQueryResult = dbQueryResult.pipe( Either.orElse((dbError) => dbError === DatabaseError.NotFound ? Either.right("User does not exist") : Either.left(500), ), ) ``` ### match **示例**(对成功或失败进行模式匹配) ```ts import { Result } from "neverthrow" declare const myResult: Result myResult.match( (value) => `The value is ${value}`, (error) => `The error is ${error}`, ) ``` ```ts import * as Either from "effect/Either" declare const myResult: Either.Either myResult.pipe( Either.match({ onLeft: (error) => `The error is ${error}`, onRight: (value) => `The value is ${value}`, }), ) ``` ### asyncMap **示例**(解析请求头并查找用户) ```ts import { Result } from "neverthrow" interface User {} declare function parseHeaders( raw: string, ): Result, string> declare function findUserInDatabase( authorization: string, ): Promise const rawHeader = "Authorization: Bearer 1234567890" // const asyncResult: ResultAsync const asyncResult = parseHeaders(rawHeader) .map((kvMap) => kvMap["Authorization"]) .asyncMap((authorization) => authorization === undefined ? Promise.resolve(undefined) : findUserInDatabase(authorization), ) ``` ```ts import * as Either from "effect/Either" import * as Effect from "effect/Effect" interface User {} declare function parseHeaders( raw: string, ): Either.Either, string> declare function findUserInDatabase( authorization: string, ): Promise const rawHeader = "Authorization: Bearer 1234567890" // const asyncResult: Effect const asyncResult = parseHeaders(rawHeader).pipe( Either.map((kvMap) => kvMap["Authorization"]), Effect.andThen((authorization) => authorization === undefined ? Promise.resolve(undefined) : findUserInDatabase(authorization), ), ) ``` **注意**。在 neverthrow 中,`asyncMap` 直接与 Promise 配合工作。 在 Effect 中,把 Promise 传给 `Effect.andThen` 这类组合子时,它会自动被提升为一个 `Effect`。 如果这个 Promise 被拒绝,该拒绝会被转换为一个 `UnknownException`,这就是错误类型被拓宽为 `string | UnknownException` 的原因。 ### combine **示例**(合并多个结果) ```ts import { Result, ok } from "neverthrow" const results: Result[] = [ok(1), ok(2)] // const combined: Result const combined = Result.combine(results) ``` ```ts import * as Either from "effect/Either" const results: Either.Either[] = [ Either.right(1), Either.right(2), ] // const combined: Either const combined = Either.all(results) ``` ### combineWithAllErrors **示例**(收集所有错误与成功值) ```ts import { Result, ok, err } from "neverthrow" const results: Result[] = [ ok(123), err("boooom!"), ok(456), err("ahhhhh!"), ] const result = Result.combineWithAllErrors(results) // result is Err(['boooom!', 'ahhhhh!']) ``` ```ts import * as Either from "effect/Either" import * as Array from "effect/Array" const results: Either.Either[] = [ Either.right(123), Either.left("boooom!"), Either.right(456), Either.left("ahhhhh!"), ] const errors = Array.getLefts(results) // errors is ['boooom!', 'ahhhhh!'] const successes = Array.getRights(results) // successes is [123, 456] ``` **注意**。Effect 中没有与 `Result.combineWithAllErrors` 完全对应的函数。 可以用 `Array.getLefts` 收集所有错误,用 `Array.getRights` 收集所有成功值。 ## 异步 API 下面的示例中,我们用 `Effect.runPromise` 运行一个 effect 并返回 `Promise`。 你也可以使用其他 API,例如 `Effect.runPromiseExit`,它能额外捕获 defect(运行时错误)和中断等情况。 ### okAsync **示例**(创建一个成功的异步结果) ```ts import { okAsync } from "neverthrow" const myResultAsync = okAsync({ myData: "test" }) const result = await myResultAsync result.isOk() // true result.isErr() // false ``` ```ts import * as Either from "effect/Either" import * as Effect from "effect/Effect" const myResultAsync = Effect.succeed({ myData: "test" }) const result = await Effect.runPromise(Effect.either(myResultAsync)) Either.isRight(result) // true Either.isLeft(result) // false ``` ### errAsync **示例**(创建一个失败的异步结果) ```ts import { errAsync } from "neverthrow" const myResultAsync = errAsync("Oh no") const myResult = await myResultAsync myResult.isOk() // false myResult.isErr() // true ``` ```ts import * as Either from "effect/Either" import * as Effect from "effect/Effect" const myResultAsync = Effect.fail("Oh no") const result = await Effect.runPromise(Effect.either(myResultAsync)) Either.isRight(result) // false Either.isLeft(result) // true ``` ### fromThrowable **示例**(包装一个可能抛错、返回 Promise 的函数) ```ts import { ResultAsync } from "neverthrow" interface User {} declare function insertIntoDb(user: User): Promise // (user: User) => ResultAsync const insertUser = ResultAsync.fromThrowable( insertIntoDb, () => new Error("Database error"), ) ``` ```ts import * as Effect from "effect/Effect" interface User {} declare function insertIntoDb(user: User): Promise // (user: User) => Effect const insertUser = (user: User) => Effect.tryPromise({ try: () => insertIntoDb(user), catch: () => new Error("Database error"), }) ``` ### map **示例**(转换成功值) ```ts import { Result, ResultAsync } from "neverthrow" interface User { readonly name: string } declare function findUsersIn(country: string): ResultAsync, Error> const usersInCanada = findUsersIn("Canada") const namesInCanada = usersInCanada.map((users: Array) => users.map((user) => user.name), ) // We can extract the Result using .then() or await namesInCanada.then((namesResult: Result, Error>) => { if (namesResult.isErr()) { console.log("Couldn't get the users from the database", namesResult.error) } else { console.log("Users in Canada are named: " + namesResult.value.join(",")) } }) ``` ```ts import * as Effect from "effect/Effect" import * as Either from "effect/Either" interface User { readonly name: string } declare function findUsersIn(country: string): Effect.Effect, Error> const usersInCanada = findUsersIn("Canada") const namesInCanada = usersInCanada.pipe( Effect.map((users: Array) => users.map((user) => user.name)), ) // We can extract the Either using Effect.either Effect.runPromise(Effect.either(namesInCanada)).then( (namesResult: Either.Either, Error>) => { if (Either.isLeft(namesResult)) { console.log("Couldn't get the users from the database", namesResult.left) } else { console.log("Users in Canada are named: " + namesResult.right.join(",")) } }, ) ``` ### mapErr **示例**(转换错误值) ```ts import { Result, ResultAsync } from "neverthrow" interface User { readonly name: string } declare function findUsersIn(country: string): ResultAsync, Error> const usersInCanada = findUsersIn("Canada").mapErr((error: Error) => { // The only error we want to pass to the user is "Unknown country" if (error.message === "Unknown country") { return error.message } // All other errors will be labelled as a system error return "System error, please contact an administrator." }) usersInCanada.then((usersResult: Result, string>) => { if (usersResult.isErr()) { console.log("Couldn't get the users from the database", usersResult.error) } else { console.log("Users in Canada are: " + usersResult.value.join(",")) } }) ``` ```ts import * as Effect from "effect/Effect" import * as Either from "effect/Either" interface User { readonly name: string } declare function findUsersIn(country: string): Effect.Effect, Error> const usersInCanada = findUsersIn("Canada").pipe( Effect.mapError((error: Error) => { // The only error we want to pass to the user is "Unknown country" if (error.message === "Unknown country") { return error.message } // All other errors will be labelled as a system error return "System error, please contact an administrator." }), ) Effect.runPromise(Effect.either(usersInCanada)).then( (usersResult: Either.Either, string>) => { if (Either.isLeft(usersResult)) { console.log("Couldn't get the users from the database", usersResult.left) } else { console.log("Users in Canada are: " + usersResult.right.join(",")) } }, ) ``` ### unwrapOr **示例**(异步失败时提供默认值) ```ts import { errAsync } from "neverthrow" const unwrapped = await errAsync(0).unwrapOr(10) // unwrapped = 10 ``` ```ts import * as Effect from "effect/Effect" const unwrapped = await Effect.runPromise( Effect.fail(0).pipe(Effect.orElseSucceed(() => 10)), ) // unwrapped = 10 ``` ### andThen **示例**(串联多个异步计算) ```ts import { Result, ResultAsync } from "neverthrow" interface User {} declare function validateUser(user: User): ResultAsync declare function insertUser(user: User): ResultAsync declare function sendNotification(user: User): ResultAsync const user: User = {} const resAsync = validateUser(user) .andThen(insertUser) .andThen(sendNotification) resAsync.then((res: Result) => { if (res.isErr()) { console.log("Oops, at least one step failed", res.error) } else { console.log("User has been validated, inserted and notified successfully.") } }) ``` ```ts import * as Effect from "effect/Effect" import * as Either from "effect/Either" interface User {} declare function validateUser(user: User): Effect.Effect declare function insertUser(user: User): Effect.Effect declare function sendNotification(user: User): Effect.Effect const user: User = {} const resAsync = validateUser(user).pipe( Effect.andThen(insertUser), Effect.andThen(sendNotification), ) Effect.runPromise(Effect.either(resAsync)).then( (res: Either.Either) => { if (Either.isLeft(res)) { console.log("Oops, at least one step failed", res.left) } else { console.log( "User has been validated, inserted and notified successfully.", ) } }, ) ``` ### orElse **示例**(异步操作失败时回退) ```ts import { ResultAsync, ok } from "neverthrow" interface User {} declare function fetchUserData(id: string): ResultAsync declare function getDefaultUser(): User const userId = "123" // Try to fetch user data, but provide a default if it fails const userResult = fetchUserData(userId).orElse(() => ok(getDefaultUser())) userResult.then((result) => { if (result.isOk()) { console.log("User data:", result.value) } }) ``` ```ts import * as Effect from "effect/Effect" import * as Either from "effect/Either" interface User {} declare function fetchUserData(id: string): Effect.Effect declare function getDefaultUser(): User const userId = "123" // Try to fetch user data, but provide a default if it fails const userResult = fetchUserData(userId).pipe( Effect.orElse(() => Effect.succeed(getDefaultUser())), ) Effect.runPromise(Effect.either(userResult)).then((result) => { if (Either.isRight(result)) { console.log("User data:", result.right) } }) ``` ### match **示例**(在链的末尾处理成功与失败) ```ts import { ResultAsync } from "neverthrow" interface User { readonly name: string } declare function validateUser(user: User): ResultAsync declare function insertUser(user: User): ResultAsync const user: User = { name: "John" } // Handle both cases at the end of the chain using match const resultMessage = await validateUser(user) .andThen(insertUser) .match( (user: User) => `User ${user.name} has been successfully created`, (error: Error) => `User could not be created because ${error.message}`, ) ``` ```ts import * as Effect from "effect/Effect" interface User { readonly name: string } declare function validateUser(user: User): Effect.Effect declare function insertUser(user: User): Effect.Effect const user: User = { name: "John" } // Handle both cases at the end of the chain using match const resultMessage = await Effect.runPromise( validateUser(user).pipe( Effect.andThen(insertUser), Effect.match({ onSuccess: (user) => `User ${user.name} has been successfully created`, onFailure: (error) => `User could not be created because ${error.message}`, }), ), ) ``` ### combine **示例**(组合多个异步结果) ```ts import { ResultAsync, okAsync } from "neverthrow" const resultList: ResultAsync[] = [okAsync(1), okAsync(2)] // const combinedList: ResultAsync const combinedList = ResultAsync.combine(resultList) ``` ```ts import * as Effect from "effect/Effect" const resultList: Effect.Effect[] = [ Effect.succeed(1), Effect.succeed(2), ] // const combinedList: Effect const combinedList = Effect.all(resultList) ``` ### combineWithAllErrors **示例**(收集所有错误,而不是快速失败) ```ts import { ResultAsync, okAsync, errAsync } from "neverthrow" const resultList: ResultAsync[] = [ okAsync(123), errAsync("boooom!"), okAsync(456), errAsync("ahhhhh!"), ] const result = await ResultAsync.combineWithAllErrors(resultList) // result is Err(['boooom!', 'ahhhhh!']) ``` ```ts import { Effect, identity } from "effect" const resultList: Effect.Effect[] = [ Effect.succeed(123), Effect.fail("boooom!"), Effect.succeed(456), Effect.fail("ahhhhh!"), ] const result = await Effect.runPromise( Effect.either(Effect.validateAll(resultList, identity)), ) // result is left(['boooom!', 'ahhhhh!']) ``` ## 实用工具 ### fromThrowable **示例**(安全地包装一个会抛出异常的函数) ```ts import { Result } from "neverthrow" type ParseError = { message: string } const toParseError = (): ParseError => ({ message: "Parse Error" }) const safeJsonParse = Result.fromThrowable(JSON.parse, toParseError) // the function can now be used safely, // if the function throws, the result will be an Err const result = safeJsonParse("{") ``` ```ts import * as Either from "effect/Either" type ParseError = { message: string } const toParseError = (): ParseError => ({ message: "Parse Error" }) const safeJsonParse = (s: string) => Either.try({ try: () => JSON.parse(s), catch: toParseError }) // the function can now be used safely, // if the function throws, the result will be an Either const result = safeJsonParse("{") ``` ### safeTry **示例**(用生成器简化错误处理) ```ts import { Result, ok, safeTry } from "neverthrow" declare function mayFail1(): Result declare function mayFail2(): Result function myFunc(): Result { return safeTry(function* () { return ok( (yield* mayFail1().mapErr( (e) => `aborted by an error from 1st function, ${e}`, )) + (yield* mayFail2().mapErr( (e) => `aborted by an error from 2nd function, ${e}`, )), ) }) } ``` ```ts import * as Either from "effect/Either" declare function mayFail1(): Either.Either declare function mayFail2(): Either.Either function myFunc(): Either.Either { return Either.gen(function* () { return ( (yield* mayFail1().pipe( Either.mapLeft((e) => `aborted by an error from 1st function, ${e}`), )) + (yield* mayFail2().pipe( Either.mapLeft((e) => `aborted by an error from 2nd function, ${e}`), )) ) }) } ``` **注意**:使用 `Either.gen` 时,你不需要用 `Either.right` 包装最终值。生成器的返回值会成为 `Right`。 你也可以用异步生成器函数配合 `safeTry` 来表示一个异步代码块。 在 Effect 这一侧,同样的模式改用 `Effect.gen` 而不是 `Either.gen` 来书写。 **示例**(用异步生成器处理多个失败) ```ts import { ResultAsync, safeTry, ok } from "neverthrow" declare function mayFail1(): ResultAsync declare function mayFail2(): ResultAsync function myFunc(): ResultAsync { return safeTry(async function* () { return ok( (yield* mayFail1().mapErr( (e) => `aborted by an error from 1st function, ${e}`, )) + (yield* mayFail2().mapErr( (e) => `aborted by an error from 2nd function, ${e}`, )), ) }) } ``` ```ts import { Effect } from "effect" declare function mayFail1(): Effect.Effect declare function mayFail2(): Effect.Effect function myFunc(): Effect.Effect { return Effect.gen(function* () { return ( (yield* mayFail1().pipe( Effect.mapError((e) => `aborted by an error from 1st function, ${e}`), )) + (yield* mayFail2().pipe( Effect.mapError((e) => `aborted by an error from 2nd function, ${e}`), )) ) }) } ``` **注意**:使用 `Effect.gen` 时,你不需要用 `Effect.succeed` 包装最终值。生成器的返回值会成为 `Success`。 --- # Effect 与 Promise 对比 > 对比 Effect 与 Promise,涵盖类型安全、并发和错误处理等特性。 本指南将探讨 `Promise` 与 `Effect` 的差异 —— 它们是 TypeScript 中处理异步操作的两种方式。我们会讨论它们在类型安全、创建、链式调用和并发方面的区别,并给出示例,帮助你理解各自的用法。 ## Effect 与 Promise 的关键区别 - **求值策略:** Promise 是立即求值的,而 effect 是惰性求值的。 - **执行模式:** Promise 是一次性的,只会执行一次;而 effect 可以多次执行,是可重复的。 - **中断处理与自动传播:** Promise 没有内置的中断处理机制,这让管理中断颇具挑战;它也不会自动传播中断,需要手动管理 abort controller。相比之下,effect 具备中断处理能力,并能自动组合中断,从而把对中断的管理局部化到较小的计算上,无需高层次的编排。 - **结构化并发:** Effect 内置了结构化并发,而用 Promise 很难做到这一点。 - **错误报告(类型安全):** Promise 本身并不在类型层面提供详细的错误报告,而 effect 可以,它能以类型安全的方式让你洞察各种错误情形。 - **运行时行为:** Effect 运行时会尽可能保持同步,只有在出于计算需要或主线程饥饿而不得不异步时,才切换到异步模式。 ## 类型安全 我们先来比较 `Promise` 与 `Effect` 的类型。类型参数 `A` 表示该操作解析后的值: ```ts Promise ``` ```ts Effect ``` `Effect` 的独特之处在于: - 它允许你通过类型参数 `Error` 静态追踪错误的类型。关于 `Effect` 中错误管理的更多信息,参见[预期错误](/docs/v3/error-management/expected-errors/)。 - 它允许你通过类型参数 `Context` 静态追踪所需依赖的类型。关于 `Effect` 中上下文管理的更多信息,参见[管理服务](/docs/v3/requirements-management/services/)。 ## 创建 ### 成功 我们来比较用 `Promise` 和 `Effect` 创建一个成功操作的方式: ```ts const success = Promise.resolve(2) ``` ```ts import { Effect } from "effect" const success = Effect.succeed(2) ``` ### 失败 接下来看看如何用 `Promise` 和 `Effect` 处理失败: ```ts const failure = Promise.reject("Uh oh!") ``` ```ts import { Effect } from "effect" const failure = Effect.fail("Uh oh!") ``` ### 构造函数 用自定义逻辑创建操作: ```ts const task = new Promise((resolve, reject) => { setTimeout(() => { Math.random() > 0.5 ? resolve(2) : reject("Uh oh!") }, 300) }) ``` ```ts import { Effect } from "effect" const task = Effect.gen(function* () { yield* Effect.sleep("300 millis") return Math.random() > 0.5 ? 2 : yield* Effect.fail("Uh oh!") }) ``` ## Thenable 映射操作的结果: ### map ```ts const mapped = Promise.resolve("Hello").then((s) => s.length) ``` ```ts import { Effect } from "effect" const mapped = Effect.succeed("Hello").pipe( Effect.map((s) => s.length), // or Effect.andThen((s) => s.length) ) ``` ### flatMap 串联多个操作: ```ts const flatMapped = Promise.resolve("Hello").then((s) => Promise.resolve(s.length), ) ``` ```ts import { Effect } from "effect" const flatMapped = Effect.succeed("Hello").pipe( Effect.flatMap((s) => Effect.succeed(s.length)), // or Effect.andThen((s) => Effect.succeed(s.length)) ) ``` ## 比较 Effect.gen 与 async/await 如果你熟悉 `async`/`await`,可能会注意到两者的代码书写流程很相似。 我们来比较这两种方式: ```ts const increment = (x: number) => x + 1 const divide = (a: number, b: number): Promise => b === 0 ? Promise.reject(new Error("Cannot divide by zero")) : Promise.resolve(a / b) const task1 = Promise.resolve(10) const task2 = Promise.resolve(2) const program = async function () { const a = await task1 const b = await task2 const n1 = await divide(a, b) const n2 = increment(n1) return `Result is: ${n2}` } program().then(console.log) // Output: "Result is: 6" ``` ```ts import { Effect } from "effect" const increment = (x: number) => x + 1 const divide = (a: number, b: number): Effect.Effect => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) const task1 = Effect.promise(() => Promise.resolve(10)) const task2 = Effect.promise(() => Promise.resolve(2)) const program = Effect.gen(function* () { const a = yield* task1 const b = yield* task2 const n1 = yield* divide(a, b) const n2 = increment(n1) return `Result is: ${n2}` }) Effect.runPromise(program).then(console.log) // Output: "Result is: 6" ``` 需要注意的是,尽管这些代码看起来相似,但这两个程序并不完全相同。把它们并排比较,只是为了突出它们在写法上的相似之处。 ## 并发 ### Promise.all() ```ts const task1 = new Promise((resolve, reject) => { console.log("Executing task1...") setTimeout(() => { console.log("task1 done") resolve(1) }, 100) }) const task2 = new Promise((resolve, reject) => { console.log("Executing task2...") setTimeout(() => { console.log("task2 done") reject("Uh oh!") }, 200) }) const task3 = new Promise((resolve, reject) => { console.log("Executing task3...") setTimeout(() => { console.log("task3 done") resolve(3) }, 300) }) const program = Promise.all([task1, task2, task3]) program.then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done Uh oh! task3 done */ ``` ```ts import { Effect } from "effect" const task1 = Effect.gen(function* () { console.log("Executing task1...") yield* Effect.sleep("100 millis") console.log("task1 done") return 1 }) const task2 = Effect.gen(function* () { console.log("Executing task2...") yield* Effect.sleep("200 millis") console.log("task2 done") return yield* Effect.fail("Uh oh!") }) const task3 = Effect.gen(function* () { console.log("Executing task3...") yield* Effect.sleep("300 millis") console.log("task3 done") return 3 }) const program = Effect.all([task1, task2, task3], { concurrency: "unbounded", }) Effect.runPromise(program).then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done (FiberFailure) Error: Uh oh! */ ``` ### Promise.allSettled() ```ts const task1 = new Promise((resolve, reject) => { console.log("Executing task1...") setTimeout(() => { console.log("task1 done") resolve(1) }, 100) }) const task2 = new Promise((resolve, reject) => { console.log("Executing task2...") setTimeout(() => { console.log("task2 done") reject("Uh oh!") }, 200) }) const task3 = new Promise((resolve, reject) => { console.log("Executing task3...") setTimeout(() => { console.log("task3 done") resolve(3) }, 300) }) const program = Promise.allSettled([task1, task2, task3]) program.then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done task3 done [ { status: 'fulfilled', value: 1 }, { status: 'rejected', reason: 'Uh oh!' }, { status: 'fulfilled', value: 3 } ] */ ``` ```ts import { Effect } from "effect" const task1 = Effect.gen(function* () { console.log("Executing task1...") yield* Effect.sleep("100 millis") console.log("task1 done") return 1 }) const task2 = Effect.gen(function* () { console.log("Executing task2...") yield* Effect.sleep("200 millis") console.log("task2 done") return yield* Effect.fail("Uh oh!") }) const task3 = Effect.gen(function* () { console.log("Executing task3...") yield* Effect.sleep("300 millis") console.log("task3 done") return 3 }) const program = Effect.forEach( [task1, task2, task3], (task) => Effect.either(task), // or Effect.exit { concurrency: "unbounded", }, ) Effect.runPromise(program).then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done task3 done [ { _id: "Either", _tag: "Right", right: 1 }, { _id: "Either", _tag: "Left", left: "Uh oh!" }, { _id: "Either", _tag: "Right", right: 3 } ] */ ``` ### Promise.any() ```ts const task1 = new Promise((resolve, reject) => { console.log("Executing task1...") setTimeout(() => { console.log("task1 done") reject("Something went wrong!") }, 100) }) const task2 = new Promise((resolve, reject) => { console.log("Executing task2...") setTimeout(() => { console.log("task2 done") resolve(2) }, 200) }) const task3 = new Promise((resolve, reject) => { console.log("Executing task3...") setTimeout(() => { console.log("task3 done") reject("Uh oh!") }, 300) }) const program = Promise.any([task1, task2, task3]) program.then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done 2 task3 done */ ``` ```ts import { Effect } from "effect" const task1 = Effect.gen(function* () { console.log("Executing task1...") yield* Effect.sleep("100 millis") console.log("task1 done") return yield* Effect.fail("Something went wrong!") }) const task2 = Effect.gen(function* () { console.log("Executing task2...") yield* Effect.sleep("200 millis") console.log("task2 done") return 2 }) const task3 = Effect.gen(function* () { console.log("Executing task3...") yield* Effect.sleep("300 millis") console.log("task3 done") return yield* Effect.fail("Uh oh!") }) const program = Effect.raceAll([task1, task2, task3]) Effect.runPromise(program).then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done task2 done 2 */ ``` ### Promise.race() ```ts const task1 = new Promise((resolve, reject) => { console.log("Executing task1...") setTimeout(() => { console.log("task1 done") reject("Something went wrong!") }, 100) }) const task2 = new Promise((resolve, reject) => { console.log("Executing task2...") setTimeout(() => { console.log("task2 done") reject("Uh oh!") }, 200) }) const task3 = new Promise((resolve, reject) => { console.log("Executing task3...") setTimeout(() => { console.log("task3 done") resolve(3) }, 300) }) const program = Promise.race([task1, task2, task3]) program.then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done Something went wrong! task2 done task3 done */ ``` ```ts import { Effect } from "effect" const task1 = Effect.gen(function* () { console.log("Executing task1...") yield* Effect.sleep("100 millis") console.log("task1 done") return yield* Effect.fail("Something went wrong!") }) const task2 = Effect.gen(function* () { console.log("Executing task2...") yield* Effect.sleep("200 millis") console.log("task2 done") return yield* Effect.fail("Uh oh!") }) const task3 = Effect.gen(function* () { console.log("Executing task3...") yield* Effect.sleep("300 millis") console.log("task3 done") return 3 }) const program = Effect.raceAll([task1, task2, task3].map(Effect.either)) // or Effect.exit Effect.runPromise(program).then(console.log, console.error) /* Output: Executing task1... Executing task2... Executing task3... task1 done { _id: "Either", _tag: "Left", left: "Something went wrong!" } */ ``` ## 常见问题 **问题**。在 Effect 中,启动一个 promise 而不立即等待它,等价的做法是什么? ```ts const task = (delay: number, name: string) => new Promise((resolve) => setTimeout(() => { console.log(`${name} done`) return resolve(name) }, delay), ) export async function program() { const r0 = task(2_000, "long running task") const r1 = await task(200, "task 2") const r2 = await task(100, "task 3") return { r1, r2, r0: await r0, } } program().then(console.log) /* Output: task 2 done task 3 done long running task done { r1: 'task 2', r2: 'task 3', r0: 'long running promise' } */ ``` **答案:** 你可以使用 `Effect.fork` 和 `Fiber.join` 来实现。 ```ts import { Effect, Fiber } from "effect" const task = (delay: number, name: string) => Effect.gen(function* () { yield* Effect.sleep(delay) console.log(`${name} done`) return name }) const program = Effect.gen(function* () { const r0 = yield* Effect.fork(task(2_000, "long running task")) const r1 = yield* task(200, "task 2") const r2 = yield* task(100, "task 3") return { r1, r2, r0: yield* Fiber.join(r0), } }) Effect.runPromise(program).then(console.log) /* Output: task 2 done task 3 done long running task done { r1: 'task 2', r2: 'task 3', r0: 'long running promise' } */ ``` --- # 关于 Effect 的常见误解 > 澄清关于 Effect 在性能、复杂度和适用场景方面流传甚广的误解。 ## Effect 严重依赖生成器,而生成器很慢! Effect 的内部实现并不是建立在生成器之上的,我们只是用生成器来提供一个与 async-await 高度相似的 API。在底层,async-await 使用的机制与生成器完全相同,两者的性能也相当。所以,如果你对 async-await 没有意见,那么对 Effect 的生成器也不会有意见。 生成器和可迭代对象真正慢到无法接受的地方,是转换数据集合;在这类场景中,请尽量使用普通数组。 ## Effect 会让你的代码慢 500 倍! 如果你拿下面两段代码作比较,Effect 确实会慢 500 倍: ```ts const result = 1 + 1 ``` 以及: ```ts import { Effect } from "effect" const result = Effect.runSync( Effect.zipWith(Effect.succeed(1), Effect.succeed(1), (a, b) => a + b), ) ``` 原因在于,其中一个操作会被 JIT 编译器优化成一条直接的 CPU 指令,而另一个不会。 现实中你永远不会在这种场景下使用 Effect。Effect 是一个应用级库,用来驯服并发、错误处理以及更多难题! 你应该用 Effect 来协调自己编写的各个代码 thunk,而这些 thunk 可以按你认为性能最优的方式实现,同时仍然通过 Effect 来控制执行过程。 ## Effect 的性能开销巨大! 这取决于你说的「性能」指什么。很多时候,JS 中的性能瓶颈源于对并发的糟糕管理。 得益于结构化并发与可观测性,发现并优化这些问题变得容易得多。 有些前端应用以 120fps 运行,同时密集使用 Effect,所以 Effect 多半不会成为你的性能问题。 在内存方面,它占用的内存并不会比普通程序多多少。相比非 Effect 代码,它会多一些内存分配,但当非 Effect 代码做的是与 Effect 代码相同的事情时,通常这种差异就不存在了。 建议是:先用起来,并监控你的代码;只按需优化,而不是凭想象优化 —— 过早优化是软件设计中万恶之源。 ## 打包体积巨大! Effect 的最低成本大约是 25k 的 gzip 压缩代码,这部分包含了 Effect Runtime,并且已经涵盖普通应用代码场景下你几乎会用到的所有函数。 在此基础之上,Effect 对 tree-shaking 友好,所以你只会打包自己用到的部分。 此外,使用 Effect 时你自己的代码会变得更短、更紧凑,因此整体成本会随着使用而被摊薄。我们有些应用在大部分代码库中采用 Effect 之后,最终打包体积反而变小了。 ## Effect 根本不可能学会,函数和模块太多了! 确实,整个 Effect 生态相当庞大,有些模块包含上千个函数。但事实是,你不需要全部掌握才能开始高效工作:只要了解 10 到 20 个函数,你就可以放心地开始使用 Effect,然后再逐步探索其余部分 —— 就像你可以开始使用 TypeScript,而不必先了解每一个 NPM 包一样。 以下是一份适合入门的常用函数简表: - [Effect.succeed](/docs/v3/getting-started/creating-effects/#succeed) - [Effect.fail](/docs/v3/getting-started/creating-effects/#fail) - [Effect.sync](/docs/v3/getting-started/creating-effects/#sync) - [Effect.tryPromise](/docs/v3/getting-started/creating-effects/#trypromise) - [Effect.gen](/docs/v3/getting-started/using-generators/) - [Effect.runPromise](/docs/v3/getting-started/running-effects/#runpromise) - [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag) - [Effect.catchAll](/docs/v3/error-management/expected-errors/#catchall) - [Effect.acquireRelease](/docs/v3/resource-management/scope/#acquirerelease) - [Effect.acquireUseRelease](/docs/v3/resource-management/introduction/#acquireuserelease) - [Effect.provide](/docs/v3/requirements-management/layers/#providing-a-layer-to-an-effect) - [Effect.provideService](/docs/v3/requirements-management/services/#providing-a-service-implementation) - [Effect.andThen](/docs/v3/getting-started/building-pipelines/#andthen) - [Effect.map](/docs/v3/getting-started/building-pipelines/#map) - [Effect.tap](/docs/v3/getting-started/building-pipelines/#tap) 以下是一份常用模块简表: - [Effect](https://effect.website/docs/v3/api/effect/Effect) - [Context](/docs/v3/requirements-management/services/#creating-a-service) - [Layer](/docs/v3/requirements-management/layers/) - [Option](/docs/v3/data-types/option/) - [Either](/docs/v3/data-types/either/) - [Array](https://effect.website/docs/v3/api/effect/Array) - [Match](/docs/v3/code-style/pattern-matching/) ## Effect 和 RxJS 一样,也有同样的问题 这是个敏感话题。先说清楚:RxJS 是一个了不起的项目,它帮助了数百万开发者写出可靠的软件,我们都应当感谢那些为这样一个出色项目做出贡献的开发者。 从项目定位来看,RxJS 的目标是让使用 Observable 变得简单,并为 JS 提供响应式扩展;而 Effect 想要的是让编写生产级 TypeScript 变得简单。尽管两者的交集并非空集,但它们的根本目标和策略截然不同。 有时人们会用负面的眼光看待 RxJS,但原因并不在 RxJS 本身,而在于把 RxJS 用在了它原本并未被设想使用的领域。 也就是说,「一切皆 stream」这一想法在理论上是成立的,但它会对开发者体验造成根本性的限制。首要问题在于:stream 是 multi-shot 的(可能发射多个元素,也可能一个都不发射),而可变的分隔延续(delimited continuation,即 JS 生成器)众所周知只适合表示 single-shot 的 effect(即只发射单个值)。 简而言之,这意味着用 stream 这类原语几乎不可能写出命令式风格的代码(想想 async/await)。(说「几乎」是因为你总可以选择在每个元素、每个步骤上重放生成器,但这往往效率低下,语义也反直觉,并且只有在整个函数体都没有副作用的前提下才成立。)这迫使开发者改用 `pipe` 这类声明式方法来表达全部代码。 Effect 有一个 Stream 模块(它是拉取式而非推送式的,以保持内存占用恒定),但基础的 Effect 类型是 single-shot 的,并且被优化成一个聪明而惰性的 Promise,从而支持命令式编程。所以使用 Effect 时,你并不被迫对所有东西都采用声明式风格,可以用一种类似 async-await 的模型来编程。 另一个重大区别是:RxJS 只关心类型显式的顺利路径(happy path),它不提供为错误和依赖标注类型的方式;而 Effect 把错误和依赖都视为需要显式标注类型的东西,并以完全类型安全的方式围绕它们提供控制流。 简而言之:如果你需要围绕 Observable 的响应式编程,就用 RxJS;如果你需要编写生产级 TypeScript,并且默认就带有原生遥测、错误处理、依赖注入等能力,就用 Effect。 ## Effect 应该做成一种语言,或者改用另一种语言 这两者都解决不了用 TypeScript 编写生产级软件的问题。 TypeScript 是一门出色的语言,适合编写全栈代码,它深深扎根于 JS 生态,工具的兼容性也很广;它是一门工业级语言,被许多大型公司采用。 像 Effect 这样的东西能够在这门语言内实现,而且这门语言支持生成器之类的特性,从而允许用 Effect 这样的自定义类型进行命令式编程 —— 这些事实让 TypeScript 成为一门独一无二的语言。 事实上,即使在 Scala 这样的函数式语言中,与 effect 系统的互操作也不如 TypeScript 中那样理想,以至于 effect 系统的作者们都曾表示,希望自己的语言能提供像 TypeScript 一样多的支持。 --- # 快速上手 > 了解如何使用 Effect 的 AI 集成包来定义 LLM 交互 在本快速上手指南中,我们将演示如何使用 Effect 的 AI 集成包,借助某个 LLM 提供商(OpenAi)生成一段简单的文本补全。 我们将依次介绍: - 编写与提供商无关的逻辑来与 LLM 交互 - 声明本次交互要使用的具体 LLM 模型 - 使用提供商集成让程序变得可执行 ## 安装 首先,我们需要安装基础包 `@effect/ai`,以便使用核心的 AI 抽象。此外,我们还需要至少安装一个提供商集成包(这里以 `@effect/ai-openai` 为例): ```sh # Install the base package for the core abstractions (always required) npm install @effect/ai # Install one (or more) provider integrations npm install @effect/ai-openai # Also add the core Effect package (if not already installed) npm install effect ``` ```sh # Install the base package for the core abstractions (always required) pnpm add @effect/ai # Install one (or more) provider integrations pnpm add @effect/ai-openai # Also add the core Effect package (if not already installed) pnpm add effect ``` ```sh # Install the base package for the core abstractions (always required) yarn add @effect/ai # Install one (or more) provider integrations yarn add @effect/ai-openai # Also add the core Effect package (if not already installed) yarn add effect ``` ```sh # Install the base package for the core abstractions (always required) bun add @effect/ai # Install one (or more) provider integrations bun add @effect/ai-openai # Also add the core Effect package (if not already installed) bun add effect ``` ## 定义与大语言模型的交互 首先,让我们定义一个与大语言模型(LLM)的简单交互: **示例**(使用 `LanguageModel` 服务生成一个冷笑话) ```ts import { LanguageModel } from "@effect/ai" import { Effect } from "effect" // Using `LanguageModel` will add it to your program's requirements // // ┌─── Effect, AiError, LanguageModel> // ▼ const generateDadJoke = Effect.gen(function* () { // Use the `LanguageModel` to generate some text const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) // Log the generated text to the console console.log(response.text) // Return the response return response }) ``` ## 选择提供商 接下来,我们需要选择想要使用的模型提供商: **示例**(使用模型提供商来满足 `LanguageModel` 需求) ```ts import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Effect } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) // Create a `Model` which provides a concrete implementation of // `LanguageModel` and requires an `OpenAiClient` // // ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient> // ▼ const Gpt4o = OpenAiLanguageModel.model("gpt-4o") // Provide the `Model` to the program // // ┌─── Effect, AiError, OpenAiClient> // ▼ const main = generateDadJoke.pipe(Effect.provide(Gpt4o)) ``` 在继续之前,重要的是先理解 `Model` 数据类型的用途。 ## 理解 `Model` `Model` 数据类型表示一个或多个服务(例如 `LanguageModel` 或 `EmbeddingsModel`)的**提供商专属实现**。它是把真实的大语言模型接入你的程序的主要方式。 ```ts export interface Model {} ``` `Model` 有三个泛型类型参数: - **ProviderName** —— 将要使用的大语言模型提供商的名称 - **Provides** —— 该 `Model` 构建后会提供的服务 - **Requires** —— 构建该 `Model` 所需要的服务 这样 Effect 就能追踪 `Model` 需要哪些服务,以及 `Model` 会提供哪些服务。 ### 创建 `Model` 要创建一个 `Model`,你可以使用 Effect 某个提供商集成包中针对具体模型的工厂函数。 **示例**(定义一个与 OpenAI 交互的 `Model`) ```ts import { OpenAiLanguageModel } from "@effect/ai-openai" // ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient> // ▼ const Gpt4o = OpenAiLanguageModel.model("gpt-4o") ``` 这会创建一个 `Model`,它: - **Provides** `ProviderName` 服务,从而可以内省程序当前正在使用的提供商 - **Provides** 使用 `"gpt-4o"` 的 OpenAI 专属 `LanguageModel` 服务实现 - **Requires** 一个 `OpenAiClient` 才能构建 ### 提供 `Model` 一旦创建好 `Model`,你就可以像提供任何其他服务一样,直接把它 `Effect.provide` 给你的 Effect 程序: ```ts import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" // ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient> // ▼ const Gpt4o = OpenAiLanguageModel.model("gpt-4o") // ┌─── Effect, AiError, OpenAiClient> // ▼ const program = LanguageModel.generateText({ prompt: "Generate a dad joke", }).pipe(Effect.provide(Gpt4o)) ``` ### `Model` 的优势 这种做法有几个好处: **可复用性** 你可以把同一个 `Model` 提供给任意多个程序。 **示例**(把 `Model` 提供给多个程序) ```ts import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Effect } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const main = Effect.gen(function* () { // You can provide the `Model` individually to each // program, or to all of them at once (as we do here) const res1 = yield* generateDadJoke const res2 = yield* generateDadJoke const res3 = yield* generateDadJoke }).pipe(Effect.provide(Gpt4o)) ``` **灵活性** 如果我们知道某个模型或提供商在特定任务上的表现优于另一个,就可以自由地混用和搭配不同的模型与提供商。 例如,如果我们知道 Anthropic 的 Claude 能生成非常棒的冷笑话,只需几行代码就能把它混入现有的程序: **示例**(混用多个提供商与模型) ```ts import { AnthropicLanguageModel } from "@effect/ai-anthropic" import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Effect } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const Claude37 = AnthropicLanguageModel.model("claude-3-7-sonnet-latest") // ┌─── Effect // ▼ const main = Effect.gen(function* () { const res1 = yield* generateDadJoke const res2 = yield* generateDadJoke const res3 = yield* Effect.provide(generateDadJoke, Claude37) }).pipe(Effect.provide(Gpt4o)) ``` 由于 Effect 会在类型层面进行依赖追踪,我们可以看到,现在还需要一个 `AnthropicClient` 才能让程序运行起来。 **可抽象性** `Model` 还可以被 `yield*`,从而把它的依赖提升到调用它的 Effect 中。这在创建依赖 AI 交互的服务时尤其有用:你会希望避免把服务层面的依赖泄漏到服务接口中。 例如,在下面的代码中,`main` 程序只依赖 `DadJokes` 服务。所有 AI 相关的需求都被抽象进了 `Layer` 的组合之中。 **示例**(把 LLM 交互抽象为服务) ```ts import { AnthropicLanguageModel } from "@effect/ai-anthropic" import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Effect } from "effect" const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const Claude37 = AnthropicLanguageModel.model("claude-3-7-sonnet-latest") class DadJokes extends Effect.Service()("app/DadJokes", { effect: Effect.gen(function* () { // Yielding the model will return a layer with no requirements // // ┌─── Layer // ▼ const gpt = yield* Gpt4o const claude = yield* Claude37 const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) return { generateDadJoke: Effect.provide(generateDadJoke, gpt), generateBetterDadJoke: Effect.provide(generateDadJoke, claude), } }), }) {} // Programs which utilize the `DadJokes` service have no knowledge of // any AI requirements // // ┌─── Effect // ▼ const main = Effect.gen(function* () { const dadJokes = yield* DadJokes const res1 = yield* dadJokes.generateDadJoke const res2 = yield* dadJokes.generateBetterDadJoke }) // The AI requirements are abstracted away into `Layer` composition // // ┌─── Layer // ▼ DadJokes.Default ``` ## 创建提供商客户端 要让代码变得可执行,我们还必须满足程序剩余的需求。 让我们再看一眼之前的程序: ```ts import { OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Effect } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") // ┌─── Effect, AiError, OpenAiClient> // ▼ const main = generateDadJoke.pipe(Effect.provide(Gpt4o)) ``` 可以看到,我们的 `main` 程序仍然需要我们提供一个 `OpenAiClient`。 我们的每个提供商集成包都会导出一个客户端模块,可用于为该提供商构建客户端。 **示例**(为模型提供商创建客户端 Layer) ```ts import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { Config, Effect } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const main = generateDadJoke.pipe(Effect.provide(Gpt4o)) // Create a `Layer` which produces an `OpenAiClient` and requires // an `HttpClient` // // ┌─── Layer // ▼ const OpenAi = OpenAiClient.layerConfig({ apiKey: Config.redacted("OPENAI_API_KEY"), }) ``` 在上面的代码中,我们使用 `OpenAiClient` 模块的 `layerConfig` 构造函数创建了一个会产出 `OpenAiClient` 的 `Layer`。`layerConfig` 构造函数让我们可以使用 Effect 的[配置系统](/docs/v3/configuration/)读取配置变量。 提供商客户端还依赖一个 `HttpClient` 实现,以避免任何平台依赖。这样,你就可以根据代码所运行的平台,提供最合适的 `HttpClient` 实现。 例如,如果我们知道这段代码将在 NodeJS 中运行,就可以利用 `@effect/platform-node` 的 `NodeHttpClient` 模块来提供一个 `HttpClient` 实现: ```ts import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { NodeHttpClient } from "@effect/platform-node" import { Config, Effect, Layer } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const main = generateDadJoke.pipe(Effect.provide(Gpt4o)) // Create a `Layer` which produces an `OpenAiClient` and requires // an `HttpClient` // // ┌─── Layer // ▼ const OpenAi = OpenAiClient.layerConfig({ apiKey: Config.redacted("OPENAI_API_KEY"), }) // Provide a platform-specific implementation of `HttpClient` to our // OpenAi layer // // ┌─── Layer // ▼ const OpenAiWithHttp = Layer.provide(OpenAi, NodeHttpClient.layerUndici) ``` ## 运行程序 现在我们有了一个能提供 `OpenAiClient` 的 `Layer`,可以让 `main` 程序运行起来了。 最终的程序如下: ```ts import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { LanguageModel } from "@effect/ai" import { NodeHttpClient } from "@effect/platform-node" import { Config, Effect, Layer } from "effect" const generateDadJoke = Effect.gen(function* () { const response = yield* LanguageModel.generateText({ prompt: "Generate a dad joke", }) console.log(response.text) return response }) const Gpt4o = OpenAiLanguageModel.model("gpt-4o") const main = generateDadJoke.pipe(Effect.provide(Gpt4o)) const OpenAi = OpenAiClient.layerConfig({ apiKey: Config.redacted("OPENAI_API_KEY"), }) const OpenAiWithHttp = Layer.provide(OpenAi, NodeHttpClient.layerUndici) main.pipe(Effect.provide(OpenAiWithHttp), Effect.runPromise) ``` --- # Effect AI 简介 > 介绍 Effect 的 AI 集成 —— 一组用于与大语言模型交互的包 欢迎阅读 Effect 的 AI 集成包文档 —— 这是一组库,旨在让你与大语言模型(LLM)的协作变得无缝、灵活且与提供商无关。 这些包让你编写的程序只需描述你*想*用 LLM 做什么 —— 生成补全、处理对话交互、执行函数调用 —— 而不必事先确定这些操作*如何*执行、在*哪里*执行。 核心包 [`@effect/ai`](https://www.npmjs.com/package/@effect/ai) 提供了一个高层、统一的接口,用于对 LLM 交互建模,且不依赖任何特定提供商。当你准备好运行程序时,只需从我们的 LLM 提供商集成包中,插入程序所需的服务即可。 这种关注点分离让你能够: - 编写简洁、声明式的业务逻辑,无需担心特定提供商的怪癖 - 在运行时或测试期间轻松切换或组合不同的提供商 - 在构建 AI 驱动的工作流时,充分利用 Effect 的各项特性 无论你是在构建智能 agent、交互式聊天应用,还是利用 LLM 处理后台任务的系统,Effect 的 AI 包都能提供你所需的灵活性与控制力! 让我们开始吧! ## 为什么选择 Effect 构建 AI 应用? 集成 LLM 并不只是发送 API 请求 —— 还要处理流式输出、重试、速率限制、超时以及用户驱动的副作用,同时保持系统稳定与响应迅速。Effect 提供了简单、可组合的构建块,让你以**安全**、**声明式**且**可组合**的方式为这些工作流建模。 在 LLM 交互中使用 Effect,你将获得以下好处: - 🧩 **与提供商无关的架构** 业务逻辑只需编写一次,而底层提供商(OpenAI、Anthropic、本地模型、mock 等)的选择可以推迟到运行时 - 🧪 **完全可测试** 由于 LLM 交互是通过 Effect 服务建模的,你只需提供一个替代实现,就能 mock、模拟响应或对响应做快照 - 🧵 **结构化并发** 并发执行 LLM 调用、取消过期的请求、流式返回部分结果,或让多个提供商竞速 —— 这些都由 Effect 的结构化并发模型安全地管理 - 🔍 **可观测性** 利用 Effect 内置的追踪、日志与指标为你的 LLM 交互埋点,从而深入了解生产环境中的性能瓶颈或故障 ……以及更多! ## 核心概念 Effect 的 AI 集成围绕**与提供商无关的编程**这一理念构建。你无需把对某个特定 LLM 提供商 API 的调用硬编码在代码里,而是使用基础包 `@effect/ai` 提供的服务来描述你的交互。 这些服务提供的能力包括: - **生成文本** – 单次文本生成 - **生成嵌入向量** – 用于搜索或检索的文本向量表示 - **工具调用** – 结构化输出与工具使用 - **流式输出** – 增量输出,兼顾内存效率与响应速度 这些服务中的每一个都被定义为一个 *Effect 服务* —— 这意味着它们可以像 Effect 生态中任何其他依赖一样被注入、组合和测试。 这种解耦让你把 AI 代码写成对「你想要发生什么」的纯粹描述,稍后再解决它*如何*发生 —— 无论是接入 OpenAI、Anthropic、用于测试的 mock 服务,还是你自己定制的 LLM 后端。 --- ## 包 Effect 的 AI 生态由若干专注特定职责的包组成: ### `@effect/ai` 定义了与 LLM 提供商服务交互的核心抽象。该包定义了以与提供商无关的方式构建 AI 应用所需的通用服务与辅助工具。 使用这个包可以: - 定义你的应用与 LLM 的交互方式 - 使用 Effect 组织对话或补全流程 - 构建类型安全、声明式的 AI 逻辑 详细的 API 文档请参见 [API 参考](https://effect.website/docs/v3/api/ai)。 ### `@effect/ai-openai` 由 [OpenAI API](https://platform.openai.com) 支撑的 `@effect/ai` 服务的具体实现。 支持的服务包括: - `LanguageModel`(通过 OpenAI 的 [Chat Completions API](https://platform.openai.com/docs/api-reference/chat)) - `EmbeddingsModel`(通过 OpenAI 的 [Embeddings API](https://platform.openai.com/docs/api-reference/embeddings)) 详细的 API 文档请参见 [API 参考](https://effect.website/docs/v3/api/ai-openai)。 ### `@effect/ai-anthropic` 由 [Anthropic API](https://docs.anthropic.com/en/api/getting-started) 支撑的 `@effect/ai` 服务的具体实现。 支持的服务包括: - `LanguageModel`(通过 Anthropic 的 [Messages API](https://docs.anthropic.com/en/api/messages)) 详细的 API 文档请参见 [API 参考](https://effect.website/docs/v3/api/ai-anthropic)。 ### `@effect/ai-amazon-bedrock` 由 [Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html) 支撑的 `@effect/ai` 服务的具体实现。 支持的服务包括: - `LanguageModel`(通过 Amazon Bedrock 的 [Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html)) 详细的 API 文档请参见 [API 参考](https://effect.website/docs/v3/api/ai-amazon-bedrock)。 ### `@effect/ai-google` 由 [Google Generative AI](https://ai.google.dev/gemini-api/docs) 支撑的 `@effect/ai` 服务的具体实现。 支持的服务包括: - `LanguageModel`(通过 Google 的 [Gemini API](https://ai.google.dev/api)) 详细的 API 文档请参见 [API 参考](https://effect.website/docs/v3/api/ai-google)。 --- # 执行计划 > 学习如何为你的 LLM 交互创建结构化的执行计划 设想我们已经重构了[快速上手](/docs/v3/ai/getting-started/)指南中的 `generateDadJoke` 程序。现在,代码不再在内部处理所有错误,而是可能**因领域特定的问题而失败**,例如网络中断或提供商服务中断: ```ts import type { LanguageModel } from "@effect/ai" import { OpenAiLanguageModel } from "@effect/ai-openai" import { Data, Effect } from "effect" class NetworkError extends Data.TaggedError("NetworkError") {} class ProviderOutage extends Data.TaggedError("ProviderOutage") {} declare const generateDadJoke: Effect.Effect< LanguageModel.GenerateTextResponse<{}>, NetworkError | ProviderOutage, LanguageModel.LanguageModel > const main = Effect.gen(function* () { const response = yield* generateDadJoke console.log(response.text) }).pipe(Effect.provide(OpenAiLanguageModel.model("gpt-4o"))) ``` 这样写没问题,但如果我们想要: - 在遇到 `NetworkError` 时,将程序重试固定的次数 - 在两次重试之间加入一些退避延迟 - 如果 OpenAi 不可用,则回退到另一个模型提供商 我们该如何实现这样的逻辑? ## 规划 LLM 交互 Effect 提供的 `ExecutionPlan` 模块,为你的 Effect 程序提供了一种创建**结构化执行计划**的可靠方法。与其只发起一次模型调用、然后指望它成功,你可以用 `ExecutionPlan` 以清晰、声明式的方式描述如何处理错误、重试与回退。 这在以下场景中尤其有用: - 当主模型不可用时,你希望回退到备用模型 - 当遇到临时性错误(例如网络故障)时,你希望进行重试 - 你希望控制各次重试之间的时间间隔 ## 创建执行计划 要创建 `ExecutionPlan`,我们可以使用 `ExecutionPlan.make` 构造函数。 **示例**(为 LLM 交互创建 `ExecutionPlan`) ```ts import type { LanguageModel } from "@effect/ai" import { OpenAiLanguageModel } from "@effect/ai-openai" import { Data, Effect, ExecutionPlan, Schedule } from "effect" class NetworkError extends Data.TaggedError("NetworkError") {} class ProviderOutage extends Data.TaggedError("ProviderOutage") {} declare const generateDadJoke: Effect.Effect< LanguageModel.GenerateTextResponse<{}>, NetworkError | ProviderOutage, LanguageModel.LanguageModel > const DadJokePlan = ExecutionPlan.make({ provide: OpenAiLanguageModel.model("gpt-4o"), attempts: 3, schedule: Schedule.exponential("100 millis", 1.5), while: (error: NetworkError | ProviderOutage) => error._tag === "NetworkError", }) // ┌─── Effect // ▼ const main = Effect.gen(function* () { const response = yield* generateDadJoke console.log(response.text) }).pipe(Effect.withExecutionPlan(DadJokePlan)) ``` 这个计划只包含一个步骤,它会: - 将 OpenAi 的 `"gpt-4o"` 模型作为 `LanguageModel` 提供给程序 - 最多尝试调用 OpenAi 3 次 - 在两次尝试之间按指数退避等待(从 `100ms` 开始) - 仅当错误为 `NetworkError` 时才重新尝试调用 OpenAi ## 添加回退模型 为了让与大语言模型的交互能够从容应对提供商服务中断,你可以定义一个要使用的**回退**模型。这样,当执行计划中的前一个步骤失败时,计划就会自动回退到另一个模型。 在以下场景中使用它: - 你希望让自己的模型交互能够从容应对提供商服务中断 - 你可能希望拥有多个回退模型 **示例**(从 OpenAi 回退到 Anthropic) ```ts import type { LanguageModel } from "@effect/ai" import { AnthropicLanguageModel } from "@effect/ai-anthropic" import { OpenAiLanguageModel } from "@effect/ai-openai" import { Data, Effect, ExecutionPlan, Schedule } from "effect" class NetworkError extends Data.TaggedError("NetworkError") {} class ProviderOutage extends Data.TaggedError("ProviderOutage") {} declare const generateDadJoke: Effect.Effect< LanguageModel.GenerateTextResponse<{}>, NetworkError | ProviderOutage, LanguageModel.LanguageModel > const DadJokePlan = ExecutionPlan.make( { provide: OpenAiLanguageModel.model("gpt-4o"), attempts: 3, schedule: Schedule.exponential("100 millis", 1.5), while: (error: NetworkError | ProviderOutage) => error._tag === "NetworkError", }, { provide: AnthropicLanguageModel.model("claude-4-sonnet-20250514"), attempts: 2, schedule: Schedule.exponential("100 millis", 1.5), while: (error: NetworkError | ProviderOutage) => error._tag === "ProviderOutage", }, ) // ┌─── Effect<..., ..., AnthropicClient | OpenAiClient> // ▼ const main = Effect.gen(function* () { const response = yield* generateDadJoke console.log(response.text) }).pipe(Effect.withExecutionPlan(DadJokePlan)) ``` 这个计划包含两个步骤。 **第 1 步** 第一个步骤会: - 将 OpenAi 的 `"gpt-4o"` 模型作为 `LanguageModel` 提供给程序 - 最多尝试调用 OpenAi 3 次 - 在两次尝试之间按指数退避等待(从 `100ms` 开始) - 仅当错误为 `NetworkError` 时才尝试调用 OpenAi 如果以上所有逻辑都未能让程序成功运行,计划会尝试使用第二个步骤来运行程序。 **第 2 步** 第二个步骤会: - 将 Anthropic 的 `"claude-4-sonnet-20250514"` 模型作为 `LanguageModel` 提供给程序 - 最多尝试调用 Anthropic 2 次 - 在两次尝试之间按指数退避等待(从 `100ms` 开始) - 仅当错误为 `ProviderOutage` 时才尝试回退 ## 端到端用法 下面是完整实现了所需执行计划的完整程序: ```ts import type { LanguageModel } from "@effect/ai" import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic" import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { NodeHttpClient } from "@effect/platform-node" import { Config, Data, Effect, ExecutionPlan, Layer, Schedule } from "effect" class NetworkError extends Data.TaggedError("NetworkError") {} class ProviderOutage extends Data.TaggedError("ProviderOutage") {} declare const generateDadJoke: Effect.Effect< LanguageModel.GenerateTextResponse<{}>, NetworkError | ProviderOutage, LanguageModel.LanguageModel > const DadJokePlan = ExecutionPlan.make( { provide: OpenAiLanguageModel.model("gpt-4o"), attempts: 3, schedule: Schedule.exponential("100 millis", 1.5), while: (error: NetworkError | ProviderOutage) => error._tag === "NetworkError", }, { provide: AnthropicLanguageModel.model("claude-4-sonnet-20250514"), attempts: 2, schedule: Schedule.exponential("100 millis", 1.5), while: (error: NetworkError | ProviderOutage) => error._tag === "ProviderOutage", }, ) const main = Effect.gen(function* () { const response = yield* generateDadJoke console.log(response.text) }).pipe(Effect.withExecutionPlan(DadJokePlan)) const Anthropic = AnthropicClient.layerConfig({ apiKey: Config.redacted("ANTHROPIC_API_KEY"), }).pipe(Layer.provide(NodeHttpClient.layerUndici)) const OpenAi = OpenAiClient.layerConfig({ apiKey: Config.redacted("OPENAI_API_KEY"), }).pipe(Layer.provide(NodeHttpClient.layerUndici)) main.pipe(Effect.provide([Anthropic, OpenAi]), Effect.runPromise) ``` --- # 工具使用 > 让你的 LLM 交互具备使用工具执行特定操作的能力 语言模型很擅长生成文本,但我们往往需要它们采取**真实世界的行动**,例如查询 API、访问数据库或调用某个服务。大多数 LLM 提供商通过**工具使用**(tool use,也称为 _函数调用_)来支持这一点:你在应用中暴露特定的操作,供模型调用。 根据收到的输入,模型可能会选择**调用(invoke)**一个或多个工具,来增强自己的响应。随后,你的应用会使用模型提供的参数运行该工具对应的逻辑。然后你把结果返回给模型,让它能够把这个输出纳入最终响应。 `Toolkit` 通过提供一种结构化、类型安全的方式来定义工具,从而简化了工具的集成。它负责处理模型与你的应用之间的全部接线工作——你要做的只是定义工具并实现其行为。 ## 定义工具 下面我们通过一个完整的示例,演示如何定义、实现并使用一个从 [icanhazdadjoke.com](https://icanhazdadjoke.com) API 获取冷笑话(dad joke)的工具。 ### 1. 定义工具 我们首先使用 `Tool.make` 构造函数定义一个语言模型可以访问的工具。 该构造函数接受若干参数,让我们能够向语言模型完整地描述这个工具: - `description`:可选,提供该工具的描述 - `success`:工具成功执行时返回值的类型 - `failure`:工具执行失败时返回值的类型 - `parameters`:调用该工具时应传入的参数 **示例**(定义一个工具) ```ts import { Tool } from "@effect/ai" import { Schema } from "effect" const GetDadJoke = Tool.make("GetDadJoke", { description: "Get a hilarious dad joke from the ICanHazDadJoke API", success: Schema.String, failure: Schema.Never, parameters: { searchTerm: Schema.String.annotations({ description: "The search term to use to find dad jokes", }), }, }) ``` 基于上述定义,一次调用 `GetDadJoke` 工具的请求会: - 接受一个 `searchTerm` 参数 - 在成功时返回一个字符串(也就是那个笑话) - 没有任何预期的失败场景 ### 2. 创建 Toolkit 一旦定义好工具请求,我们就可以创建一个 `Toolkit`,它是模型可以访问的一组工具的集合。 **示例**(创建一个 `Toolkit`) ```ts import { Tool, Toolkit } from "@effect/ai" import { Schema } from "effect" const GetDadJoke = Tool.make("GetDadJoke", { description: "Get a hilarious dad joke from the ICanHazDadJoke API", success: Schema.String, failure: Schema.Never, parameters: { searchTerm: Schema.String.annotations({ description: "The search term to use to find dad jokes", }), }, }) const DadJokeTools = Toolkit.make(GetDadJoke) ``` ### 3. 实现逻辑 `Toolkit` 上的 `.toLayer(...)` 方法允许你为该工具包中的每个工具定义处理函数。由于 `.toLayer(...)` 接受一个 `Effect`,我们可以访问应用中的服务来实现工具调用的处理函数。 **示例**(实现一个 `Toolkit`) ```ts import { Tool, Toolkit } from "@effect/ai" import { HttpClient, HttpClientRequest, HttpClientResponse, } from "@effect/platform" import { NodeHttpClient } from "@effect/platform-node" import { Array, Effect, Schema } from "effect" class DadJoke extends Schema.Class("DadJoke")({ id: Schema.String, joke: Schema.String, }) {} class SearchResponse extends Schema.Class("SearchResponse")({ results: Schema.Array(DadJoke), }) {} class ICanHazDadJoke extends Effect.Service()( "ICanHazDadJoke", { dependencies: [NodeHttpClient.layerUndici], effect: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient const httpClientOk = httpClient.pipe( HttpClient.filterStatusOk, HttpClient.mapRequest( HttpClientRequest.prependUrl("https://icanhazdadjoke.com"), ), ) const search = Effect.fn("ICanHazDadJoke.search")(function* ( searchTerm: string, ) { return yield* httpClientOk .get("/search", { acceptJson: true, urlParams: { searchTerm }, }) .pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)), Effect.flatMap(({ results }) => Array.head(results)), Effect.map((joke) => joke.joke), Effect.orDie, ) }) return { search, } as const }), }, ) {} const GetDadJoke = Tool.make("GetDadJoke", { description: "Get a hilarious dad joke from the ICanHazDadJoke API", success: Schema.String, failure: Schema.Never, parameters: { searchTerm: Schema.String.annotations({ description: "The search term to use to find dad jokes", }), }, }) const DadJokeTools = Toolkit.make(GetDadJoke) const DadJokeToolHandlers = DadJokeTools.toLayer( Effect.gen(function* () { // Access the `ICanHazDadJoke` service const icanhazdadjoke = yield* ICanHazDadJoke return { // Implement the handler for the `GetDadJoke` tool call request GetDadJoke: ({ searchTerm }) => icanhazdadjoke.search(searchTerm), } }), ) ``` 在上面的代码中: - 我们从应用中访问 `ICanHazDadJoke` 服务 - 使用 `.handle("GetDadJoke", ...)` 为 `GetDadJoke` 工具注册一个处理函数 - 使用 `ICanHazDadJoke` 服务上的 `.search` 方法,根据工具调用参数搜索一个冷笑话 在 `Toolkit` 上调用 `.toLayer` 的结果是一个 `Layer`,它包含我们工具包中所有工具的处理函数。 因此,测试一个 `Toolkit` 非常简单:使用 `.toLayer` 专门为测试创建一个单独的 `Layer`。 ### 4. 把工具交给模型 工具定义并实现完成后,你可以在发起请求时把它们传给模型。在幕后,模型会收到每个工具的结构化描述,并可以在响应输入时选择调用其中一个或多个工具。 **示例**(使用一个 `Toolkit`) ```ts import { LanguageModel, Tool, Toolkit } from "@effect/ai" import { Effect, Schema } from "effect" const GetDadJoke = Tool.make("GetDadJoke", { description: "Get a hilarious dad joke from the ICanHazDadJoke API", success: Schema.String, failure: Schema.Never, parameters: { searchTerm: Schema.String.annotations({ description: "The search term to use to find dad jokes", }), }, }) const DadJokeTools = Toolkit.make(GetDadJoke) const generateDadJoke = LanguageModel.generateText({ prompt: "Generate a dad joke about pirates", toolkit: DadJokeTools, }) ``` ### 5. 整合起来 为了让程序可以执行,我们必须提供工具调用处理函数的实现: **示例**(为程序提供工具调用处理函数) ```ts import { LanguageModel, Tool, Toolkit } from "@effect/ai" import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { HttpClient, HttpClientRequest, HttpClientResponse, } from "@effect/platform" import { NodeHttpClient } from "@effect/platform-node" import { Array, Config, Console, Effect, Layer, Schema } from "effect" class DadJoke extends Schema.Class("DadJoke")({ id: Schema.String, joke: Schema.String, }) {} class SearchResponse extends Schema.Class("SearchResponse")({ results: Schema.Array(DadJoke), }) {} class ICanHazDadJoke extends Effect.Service()( "ICanHazDadJoke", { dependencies: [NodeHttpClient.layerUndici], effect: Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient const httpClientOk = httpClient.pipe( HttpClient.filterStatusOk, HttpClient.mapRequest( HttpClientRequest.prependUrl("https://icanhazdadjoke.com"), ), ) const search = Effect.fn("ICanHazDadJoke.search")(function* ( searchTerm: string, ) { return yield* httpClientOk .get("/search", { acceptJson: true, urlParams: { searchTerm }, }) .pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)), Effect.flatMap(({ results }) => Array.head(results)), Effect.map((joke) => joke.joke), Effect.scoped, Effect.orDie, ) }) return { search, } as const }), }, ) {} const GetDadJoke = Tool.make("GetDadJoke", { description: "Get a hilarious dad joke from the ICanHazDadJoke API", success: Schema.String, failure: Schema.Never, parameters: { searchTerm: Schema.String.annotations({ description: "The search term to use to find dad jokes", }), }, }) const DadJokeTools = Toolkit.make(GetDadJoke) const DadJokeToolHandlers = DadJokeTools.toLayer( Effect.gen(function* () { const icanhazdadjoke = yield* ICanHazDadJoke return { GetDadJoke: ({ searchTerm }) => icanhazdadjoke.search(searchTerm), } }), ).pipe(Layer.provide(ICanHazDadJoke.Default)) const program = LanguageModel.generateText({ prompt: "Generate a dad joke about pirates", toolkit: DadJokeTools, }).pipe( Effect.flatMap((response) => Console.log(response.text)), Effect.provide(OpenAiLanguageModel.model("gpt-4o")), ) const OpenAi = OpenAiClient.layerConfig({ apiKey: Config.redacted("OPENAI_API_KEY"), }).pipe(Layer.provide(NodeHttpClient.layerUndici)) program.pipe(Effect.provide([OpenAi, DadJokeToolHandlers]), Effect.runPromise) ``` ## 优势 **类型安全** 每个工具都使用 Effect 的 `Schema` 完整描述,包括输入、输出和描述。 **Effect 原生** 工具调用的行为使用 Effect 定义,因此它们可以发挥 Effect 的全部能力。当你需要访问其他服务来支撑工具调用处理函数的实现时,这一点尤其有用。 **可注入** 因为实现一个 `Toolkit` 的处理函数会得到一个 `Layer`,所以在不同环境中提供工具调用处理函数的替代实现,就像为程序提供另一个 `Layer` 一样简单。 **关注点分离** 工具调用请求的定义,与工具行为的实现、以及调用模型的业务逻辑,都干净地分离开来。 --- # 批处理 > 通过批处理请求并减少冗余 API 调用优化性能,提升数据获取与处理的效率。 在典型的应用开发中,当我们需要与外部 API、数据库或其他数据源交互时,常常会定义一些函数来发起请求,并相应地处理它们的结果或失败。 ### 简单的模型搭建 下面是一个基础模型,它勾勒出我们的数据结构以及可能出现的错误: ```ts import { Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} ``` ### 定义 API 函数 接下来我们定义一些与外部 API 交互的函数,处理诸如获取 Todo 列表、查询用户详情和发送邮件这样的常见操作。 ```ts import { Effect, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // API // ------------------------------ // Fetches a list of todos from an external API const getTodos = Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }) // Retrieves a user by their ID from an external API const getUserById = (id: number) => Effect.tryPromise({ try: () => fetch(`https://api.example.demo/getUserById?id=${id}`).then( (res) => res.json() as Promise, ), catch: () => new GetUserError(), }) // Sends an email via an external API const sendEmail = (address: string, text: string) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmail", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ address, text }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }) // Sends an email to a user by fetching their details first const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Notifies the owner of a todo by sending them an email const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) ``` 虽然这种做法直观易读,但未必最高效。重复的 API 调用,尤其是当多个 Todo 属于同一个所有者时,会显著增加网络开销,拖慢应用。 ### 使用这些 API 函数 这些函数清晰易懂,但使用它们的方式未必最高效。例如,通知 Todo 所有者会涉及重复的 API 调用,而这部分是可以优化的。 ```ts import { Effect, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // API // ------------------------------ // Fetches a list of todos from an external API const getTodos = Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }) // Retrieves a user by their ID from an external API const getUserById = (id: number) => Effect.tryPromise({ try: () => fetch(`https://api.example.demo/getUserById?id=${id}`).then( (res) => res.json() as Promise, ), catch: () => new GetUserError(), }) // Sends an email via an external API const sendEmail = (address: string, text: string) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmail", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ address, text }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }) // Sends an email to a user by fetching their details first const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Notifies the owner of a todo by sending them an email const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) // Orchestrates operations on todos, notifying their owners const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }) ``` 这个实现会为每个 Todo 分别执行一次 API 调用,以获取所有者详情并发送邮件。如果多个 Todo 属于同一个所有者,就会产生冗余的 API 调用。 ## 批处理 假设 `getUserById` 和 `sendEmail` 可以批量执行。这意味着我们能在一次 HTTP 调用中发送多个请求,从而减少 API 请求数量并提升性能。 **批处理的分步指南** 1. **声明请求:** 我们首先把请求转换成结构化的数据模型。这需要详细描述输入参数、预期输出以及可能出现的错误。以这种方式组织请求,不仅有助于高效地管理数据,还能比较不同的请求,判断它们是否引用了相同的输入参数。 2. **声明 Resolver:** Resolver 旨在同时处理多个请求。借助比较请求的能力(确保它们引用相同的输入参数),Resolver 可以一次性执行多个请求,从而最大限度地发挥批处理的价值。 3. **定义查询:** 最后,我们定义一些查询,利用这些批量 Resolver 来执行操作。这一步把结构化的请求及其对应的 Resolver 组合成应用中可用的组成部分。 ### 声明请求 我们将借助 `Request` 这一概念,设计一个数据源可能支持的模型: ```ts Request ``` `Request` 是一种构造,表示对类型为 `Value` 的值的请求,它可能以类型为 `Error` 的错误失败。 我们先为数据源能够处理的各类请求定义一个结构化模型。 ```ts import { Request, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") ``` 每个请求都用一个具体的数据结构来定义,它继承自通用的 `Request` 类型,从而确保每个请求都携带自己特有的数据需求以及特定的错误类型。 通过使用 `Request.tagged` 这类带标签的构造器,我们可以轻松创建请求对象,使它们在整个应用中都能被识别和管理。 ### 声明 Resolver 定义好请求之后,下一步是配置 Effect 如何使用 `RequestResolver` 解析这些请求: ```ts RequestResolver ``` `RequestResolver` 需要一个环境 `R`,并且能够执行类型为 `A` 的请求。 本节中,我们会为每种请求分别创建独立的 Resolver。Resolver 的粒度可以不同,但通常按照对应 API 调用是否支持批量处理来划分。 ```ts import { Effect, Request, RequestResolver, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers // ------------------------------ // Assuming GetTodos cannot be batched, we create a standard resolver const GetTodosResolver = RequestResolver.fromEffect( (_: GetTodos): Effect.Effect => Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }), ) // Assuming GetUserById can be batched, we create a batched resolver const GetUserByIdResolver = RequestResolver.makeBatched( (requests: ReadonlyArray) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/getUserByIdBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ users: requests.map(({ id }) => ({ id })), }), }).then((res) => res.json()) as Promise>, catch: () => new GetUserError(), }).pipe( Effect.andThen((users) => Effect.forEach(requests, (request, index) => Request.completeEffect(request, Effect.succeed(users[index]!)), ), ), Effect.catchAll((error) => Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.fail(error)), ), ), ), ) // Assuming SendEmail can be batched, we create a batched resolver const SendEmailResolver = RequestResolver.makeBatched( (requests: ReadonlyArray) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmailBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ emails: requests.map(({ address, text }) => ({ address, text, })), }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }).pipe( Effect.andThen( Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.void), ), ), Effect.catchAll((error) => Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.fail(error)), ), ), ), ) ``` 在这个配置中: - **GetTodosResolver** 负责获取多个 `Todo` 项。因为我们假设它不能批量处理,所以把它配置为普通 Resolver。 - **GetUserByIdResolver** 和 **SendEmailResolver** 被配置为批量 Resolver。这样设置的前提是这些请求可以按批处理,从而提升性能并减少 API 调用次数。 ### 定义查询 现在解析器已经就绪,我们可以把所有部分串联起来定义查询了。这一步让我们能够在应用中高效地执行数据操作。 ```ts import { Effect, Request, RequestResolver, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers // ------------------------------ // Assuming GetTodos cannot be batched, we create a standard resolver const GetTodosResolver = RequestResolver.fromEffect( (_: GetTodos): Effect.Effect => Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }), ) // Assuming GetUserById can be batched, we create a batched resolver const GetUserByIdResolver = RequestResolver.makeBatched( (requests: ReadonlyArray) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/getUserByIdBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ users: requests.map(({ id }) => ({ id })), }), }).then((res) => res.json()) as Promise>, catch: () => new GetUserError(), }).pipe( Effect.andThen((users) => Effect.forEach(requests, (request, index) => Request.completeEffect(request, Effect.succeed(users[index]!)), ), ), Effect.catchAll((error) => Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.fail(error)), ), ), ), ) // Assuming SendEmail can be batched, we create a batched resolver const SendEmailResolver = RequestResolver.makeBatched( (requests: ReadonlyArray) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmailBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ emails: requests.map(({ address, text }) => ({ address, text, })), }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }).pipe( Effect.andThen( Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.void), ), ), Effect.catchAll((error) => Effect.forEach(requests, (request) => Request.completeEffect(request, Effect.fail(error)), ), ), ), ) // ------------------------------ // Queries // ------------------------------ // Defines a query to fetch all Todo items const getTodos: Effect.Effect, GetTodosError> = Effect.request( GetTodos({}), GetTodosResolver, ) // Defines a query to fetch a user by their ID const getUserById = (id: number) => Effect.request(GetUserById({ id }), GetUserByIdResolver) // Defines a query to send an email to a specific address const sendEmail = (address: string, text: string) => Effect.request(SendEmail({ address, text }), SendEmailResolver) // Composes getUserById and sendEmail to send an email to a specific user const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Uses getUserById to fetch the owner of a Todo and then sends them an email notification const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) ``` 通过使用 `Effect.request` 函数,我们让解析器与请求模型有效地结合在一起。这种方式确保每个查询都能由恰当的解析器以最优方式完成。 尽管代码结构与前面的示例看起来相似,但使用解析器能显著提升效率:它优化了请求的处理方式,并减少了不必要的 API 调用。 ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { batching: true, }) }) ``` 在最终的配置下,无论有多少个 todo,这个程序都只会向 API 执行 **3** 次查询。这与传统方式形成鲜明对比:后者可能执行 **1 + 2n** 次查询,其中 **n** 是 todo 的数量。这是效率上的显著提升,尤其是对于数据交互量很大的应用而言。 ### 禁用批处理 可以使用 `Effect.withRequestBatching` 工具在局部禁用批处理,方式如下: ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }).pipe(Effect.withRequestBatching(false)) ``` ### 带上下文的解析器 在复杂的应用中,解析器通常需要访问共享服务或配置,才能有效地处理请求。然而,在提供必要上下文的同时保持请求批处理能力,可能颇具挑战。这里我们将探讨如何在解析器中管理上下文,以确保批处理能力不受影响。 在创建请求解析器时,谨慎管理上下文至关重要。为解析器提供过多的上下文,或给不同的解析器提供不同的服务,都会使它们无法兼容批处理。为避免这类问题,传给 `Effect.request` 的解析器,其上下文被显式设为 `never`。这迫使开发者明确界定上下文在解析器内部是如何被访问和使用的。 考虑下面的例子,我们搭建了一个 HTTP 服务,供解析器用来执行 API 调用: ```ts import { Effect, Context, RequestResolver, Request, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers With Context // ------------------------------ class HttpService extends Context.Tag("HttpService")< HttpService, { fetch: typeof fetch } >() {} const GetTodosResolver = // we create a normal resolver like we did before RequestResolver.fromEffect((_: GetTodos) => Effect.andThen(HttpService, (http) => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json() as Promise>), catch: () => new GetTodosError(), }), ), ).pipe( // we list the tags that the resolver can access RequestResolver.contextFromServices(HttpService), ) ``` 现在可以看到,`GetTodosResolver` 的类型不再是 `RequestResolver`,而是: ```ts const GetTodosResolver: Effect< RequestResolver, never, HttpService > ``` 这是一个 effect,它访问 `HttpService`,并返回一个已经组装好、具备最小可用上下文的解析器。 有了这样一个 effect,我们就可以直接在查询定义中使用它: ```ts const getTodos: Effect.Effect = Effect.request(GetTodos({}), GetTodosResolver) ``` 可以看到,这个 Effect 正确地要求提供 `HttpService`。 另一种做法是,把 `RequestResolver` 作为 `Layer` 的一部分来创建,在构造时直接访问上下文,或通过闭包捕获上下文。 **示例** ```ts import { Effect, Context, RequestResolver, Request, Layer, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers With Context // ------------------------------ class HttpService extends Context.Tag("HttpService")< HttpService, { fetch: typeof fetch } >() {} const GetTodosResolver = // we create a normal resolver like we did before RequestResolver.fromEffect((_: GetTodos) => Effect.andThen(HttpService, (http) => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json() as Promise>), catch: () => new GetTodosError(), }), ), ).pipe( // we list the tags that the resolver can access RequestResolver.contextFromServices(HttpService), ) // ------------------------------ // Layers // ------------------------------ class TodosService extends Context.Tag("TodosService")< TodosService, { getTodos: Effect.Effect, GetTodosError> } >() {} const TodosServiceLive = Layer.effect( TodosService, Effect.gen(function* () { const http = yield* HttpService const resolver = RequestResolver.fromEffect((_: GetTodos) => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json()), catch: () => new GetTodosError(), }), ) return { getTodos: Effect.request(GetTodos({}), resolver), } }), ) const getTodos: Effect.Effect< Array, GetTodosError, TodosService > = Effect.andThen(TodosService, (service) => service.getTodos) ``` 鉴于 `Layer` 是把服务装配到一起的自然原语,对大多数场景而言,这种方式很可能也是最好的。 ## 缓存 虽然我们已经大幅优化了请求批处理,但还有一个领域可以进一步提升应用的效率:缓存。没有缓存时,即使批处理已经过优化,相同的请求仍可能被执行多次,导致不必要的数据获取。 在 Effect 库中,缓存通过内置工具来实现:这些工具让请求可以被临时存储,从而无需重新获取那些并未发生变化的数据。这一特性对于减轻服务器与网络的负载至关重要,尤其是在频繁发出相似请求的应用中。 下面是为 `getUserById` 查询实现缓存的方式: ```ts const getUserById = (id: number) => Effect.request(GetUserById({ id }), GetUserByIdResolver).pipe( Effect.withRequestCaching(true), ) ``` ## 最终程序 假设你已经把所有部分正确串联起来: ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }).pipe(Effect.repeat(Schedule.fixed("10 seconds"))) ``` 在这个程序中,`getTodos` 操作会获取每个用户的 todo。随后,`Effect.forEach` 函数用于并发地通知每个 todo 的所有者,而无需等待这些通知完成。 `repeat` 函数被应用于整条操作链,它使用固定调度(fixed schedule)确保程序每 10 秒重复一次。这意味着整个流程——包括获取 todo 和发送通知——都会以 10 秒为间隔反复执行。 该程序内置了缓存机制,可防止同一个 `GetUserById` 操作在 1 分钟内被执行多次。这种默认的缓存行为有助于优化程序的执行,并减少获取用户数据的不必要请求。 此外,该程序设计为批量发送邮件,从而实现高效处理并更好地利用资源。 ## 自定义请求缓存 在真实应用中,有效的缓存策略可以显著提升性能,因为它减少了冗余的数据获取。Effect 库提供了灵活的缓存机制,既可以针对应用的特定部分进行定制,也可以全局应用。 在某些场景下,应用的不同部分可能有各自的缓存需求——有些部分可能适合局部缓存,而另一些部分可能需要全局缓存配置。下面我们来探讨如何配置自定义缓存,以满足这些不同的需求。 ### 创建自定义缓存 下面演示如何创建自定义缓存,并将其应用到应用的一部分。这个示例设置了一个每 10 秒重复执行任务的缓存,并按照容量(capacity)和 TTL(time-to-live,存活时间)等特定参数来缓存请求。 ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }).pipe( Effect.repeat(Schedule.fixed("10 seconds")), Effect.provide( Layer.setRequestCache( Request.makeCache({ capacity: 256, timeToLive: "60 minutes" }), ), ), ) ``` ### 直接应用缓存 你也可以使用 `Request.makeCache` 构建缓存,并通过 `Effect.withRequestCache` 将其直接应用到特定的程序。只要启用了缓存,这种方式就能确保源自该程序的所有请求都由这个自定义缓存管理。 --- # Equivalence > 为 TypeScript 值定义并自定义等价关系。 `Equivalence` 模块提供了一种在 TypeScript 中定义值之间等价关系的方式。等价关系是一种自反、对称且传递的二元关系,它为“两个值何时应被视为等价”建立了形式化的定义。 ## 什么是 Equivalence? 一个 `Equivalence` 表示一个函数,它比较两个类型为 `A` 的值并判断它们是否等价。与使用 `===` 的简单相等性检查相比,这种方式更灵活、也更可定制。 `Equivalence` 的结构如下: ```ts interface Equivalence { (self: A, that: A): boolean } ``` ## 使用内置的 Equivalence 该模块为常见数据类型提供了若干内置的等价关系: | Equivalence | 说明 | | ----------- | ------------------------------- | | `string` | 对字符串使用严格相等(`===`) | | `number` | 对数字使用严格相等(`===`) | | `boolean` | 对布尔值使用严格相等(`===`) | | `symbol` | 对 symbol 使用严格相等(`===`) | | `bigint` | 对 bigint 使用严格相等(`===`) | | `Date` | 按时间戳比较 `Date` 对象 | **示例**(使用内置的 Equivalence) ```ts import { Equivalence } from "effect" console.log(Equivalence.string("apple", "apple")) // Output: true console.log(Equivalence.string("apple", "orange")) // Output: false console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 1, 1))) // Output: true console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 10, 1))) // Output: false ``` ## 派生 Equivalence 对于更复杂的数据结构,你可能需要自定义的等价关系。`Equivalence` 模块允许你通过 `Equivalence.mapInput` 函数,从已有的 `Equivalence` 实例派生出新的实例。 **示例**(为对象创建自定义的 Equivalence) ```ts import { Equivalence } from "effect" interface User { readonly id: number readonly name: string } // Create an equivalence that compares User objects based only on the id const equivalence = Equivalence.mapInput( Equivalence.number, // Base equivalence for comparing numbers (user: User) => user.id, // Function to extract the id from a User ) // Compare two User objects: they are equivalent if their ids are the same console.log(equivalence({ id: 1, name: "Alice" }, { id: 1, name: "Al" })) // Output: true ``` `Equivalence.mapInput` 函数接收两个参数: 1. 你想用作基础的现有 `Equivalence`(这里是 `Equivalence.number`,用于比较数字)。 2. 一个从你的数据结构中提取值的函数,该值用于等价性检查(这里是 `(user: User) => user.id`)。 --- # Order > 使用可定制的工具在 TypeScript 中比较、排序和管理值的顺序。 `Order` 模块提供了一种比较值并确定其顺序的方式。它定义了一个接口 `Order`,表示用于比较两个类型为 `A` 的值的单个函数。该函数返回 `-1`、`0` 或 `1`,分别表示第一个值小于、等于还是大于第二个值。 `Order` 的基本结构如下: ```ts interface Order { (first: A, second: A): -1 | 0 | 1 } ``` ## 使用内置的 Order `Order` 模块为常见数据类型内置了若干比较器: | Order | 说明 | | -------- | -------------------------------- | | `string` | 用于比较字符串。 | | `number` | 用于比较数字。 | | `bigint` | 用于比较大整数。 | | `Date` | 用于比较 `Date` 对象。 | **示例**(使用内置比较器) ```ts import { Order } from "effect" console.log(Order.string("apple", "banana")) // Output: -1, as "apple" < "banana" console.log(Order.number(1, 1)) // Output: 0, as 1 = 1 console.log(Order.bigint(2n, 1n)) // Output: 1, as 2n > 1n ``` ## 排序数组 你可以使用这些比较器对数组排序。`Array` 模块提供了 `sort` 函数,它在不修改原数组的前提下对数组排序。 **示例**(使用 `Order` 排序数组) ```ts import { Order, Array } from "effect" const strings = ["b", "a", "d", "c"] const result = Array.sort(strings, Order.string) console.log(strings) // Original array remains unchanged // Output: [ 'b', 'a', 'd', 'c' ] console.log(result) // Sorted array // Output: [ 'a', 'b', 'c', 'd' ] ``` 你也可以把 `Order` 用作 JavaScript 原生 `Array.sort` 方法的比较器,但要注意这会修改原数组。 **示例**(将 `Order` 与原生 `Array.prototype.sort` 一起使用) ```ts import { Order } from "effect" const strings = ["b", "a", "d", "c"] strings.sort(Order.string) // Modifies the original array console.log(strings) // Output: [ 'a', 'b', 'c', 'd' ] ``` ## 派生 Order 对于更复杂的数据结构,你可能需要自定义排序规则。`Order` 模块允许你通过 `Order.mapInput` 函数,从已有的 `Order` 实例派生出新的实例。 **示例**(为对象创建自定义 Order) 假设你有一个 `Person` 对象列表,想按名字升序排序。为此,你可以创建一个自定义的 `Order`。 ```ts import { Order } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create a custom order to sort Person objects by name in ascending order // // ┌─── Order // ▼ const byName = Order.mapInput(Order.string, (person: Person) => person.name) ``` `Order.mapInput` 函数接受两个参数: 1. 你想用作基准的现有 `Order`(此处是 `Order.string`,用于比较字符串)。 2. 一个从数据结构中提取排序所用值的函数(此处是 `(person: Person) => person.name`)。 定义好自定义 `Order` 之后,就可以用它来排序 `Person` 对象数组了: **示例**(使用自定义 Order 排序对象) ```ts import { Order, Array } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create a custom order to sort Person objects by name in ascending order const byName = Order.mapInput(Order.string, (person: Person) => person.name) const persons: ReadonlyArray = [ { name: "Charlie", age: 22 }, { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, ] // Sort persons array using the custom order const sortedPersons = Array.sort(persons, byName) console.log(sortedPersons) /* Output: [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }, { name: 'Charlie', age: 22 } ] */ ``` ## 组合 Order `Order` 模块允许你组合多个 `Order` 实例,从而构造出复杂的排序规则。当需要按多个属性排序时,这非常有用。 **示例**(按多个条件排序) 假设你有一个人员列表,每个人用带有 `name` 和 `age` 的对象表示。你想先按名字排序,然后再对名字相同的人按年龄排序。 ```ts import { Order, Array } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create an Order to sort people by their names in ascending order const byName = Order.mapInput(Order.string, (person: Person) => person.name) // Create an Order to sort people by their ages in ascending order const byAge = Order.mapInput(Order.number, (person: Person) => person.age) // Combine orders to sort by name, then by age const byNameAge = Order.combine(byName, byAge) const result = Array.sort( [ { name: "Bob", age: 20 }, { name: "Alice", age: 18 }, { name: "Bob", age: 18 }, ], byNameAge, ) console.log(result) /* Output: [ { name: 'Alice', age: 18 }, // Sorted by name { name: 'Bob', age: 18 }, // Sorted by age within the same name { name: 'Bob', age: 20 } ] */ ``` ## 其他实用函数 `Order` 模块还提供了用于常见比较操作的额外函数,让处理有序值更加容易。 ### 反转顺序 `Order.reverse` 会反转比较的顺序。如果你有一个用于升序的 `Order`,反转它就得到降序。 **示例**(反转 Order) ```ts import { Order } from "effect" const ascendingOrder = Order.number const descendingOrder = Order.reverse(ascendingOrder) console.log(ascendingOrder(1, 3)) // Output: -1 (1 < 3 in ascending order) console.log(descendingOrder(1, 3)) // Output: 1 (1 > 3 in descending order) ``` ### 比较值 这些函数让你可以在值之间执行简单的比较: | API | 说明 | | ---------------------- | ------------------------------------------- | | `lessThan` | 检查一个值是否严格小于另一个值。 | | `greaterThan` | 检查一个值是否严格大于另一个值。 | | `lessThanOrEqualTo` | 检查一个值是否小于或等于另一个值。 | | `greaterThanOrEqualTo` | 检查一个值是否大于或等于另一个值。 | **示例**(使用比较函数) ```ts import { Order } from "effect" console.log(Order.lessThan(Order.number)(1, 2)) // Output: true (1 < 2) console.log(Order.greaterThan(Order.number)(5, 3)) // Output: true (5 > 3) console.log(Order.lessThanOrEqualTo(Order.number)(2, 2)) // Output: true (2 <= 2) console.log(Order.greaterThanOrEqualTo(Order.number)(4, 4)) // Output: true (4 >= 4) ``` ### 求最小值和最大值 `Order.min` 和 `Order.max` 函数会根据给定的顺序,返回两个值中的最小值或最大值。 **示例**(求数字的最小值和最大值) ```ts import { Order } from "effect" console.log(Order.min(Order.number)(3, 1)) // Output: 1 (1 is the minimum) console.log(Order.max(Order.number)(5, 8)) // Output: 8 (8 is the maximum) ``` ### 将值限制在区间内 `Order.clamp` 会把一个值限制在给定区间内。如果该值超出区间,就会被调整到最近的边界。 **示例**(把数字限制在区间内) ```ts import { Order } from "effect" // Define a function to clamp numbers between 20 and 30 const clampNumbers = Order.clamp(Order.number)({ minimum: 20, maximum: 30, }) // Value 26 is within the range [20, 30], so it remains unchanged console.log(clampNumbers(26)) // Output: 26 // Value 10 is below the minimum bound, so it is clamped to 20 console.log(clampNumbers(10)) // Output: 20 // Value 40 is above the maximum bound, so it is clamped to 30 console.log(clampNumbers(40)) // Output: 30 ``` ### 检查值的区间 `Order.between` 会检查一个值是否落在指定的闭区间内。 **示例**(检查数字是否落在区间内) ```ts import { Order } from "effect" // Create a function to check if numbers are between 20 and 30 const betweenNumbers = Order.between(Order.number)({ minimum: 20, maximum: 30, }) // Value 26 falls within the range [20, 30], so it returns true console.log(betweenNumbers(26)) // Output: true // Value 10 is below the minimum bound, so it returns false console.log(betweenNumbers(10)) // Output: false // Value 40 is above the maximum bound, so it returns false console.log(betweenNumbers(40)) // Output: false ``` --- # Cache > 借助缓存优化性能,实现并发、可组合且高效的值获取。 在许多应用中,处理相互重叠的工作是很常见的。例如,在处理传入请求的服务中,避免诸如多次处理同一请求之类的重复工作就非常重要。Cache 模块通过防止重复工作来帮助提升性能。 Cache 的主要特性: | 特性 | 说明 | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **可组合性** | 允许应用的不同部分之间存在重叠工作,同时保持组合式编程。 | | **统一的同步与异步缓存** | 通过一个统一的查找函数整合同步缓存与异步缓存,该函数能以任一方式计算值。 | | **Effect 集成** | 与 Effect 库原生协作,支持并发查找、失败处理与中断。 | | **Cache 指标** | 跟踪条目数、命中数与未命中等关键指标,为性能优化提供洞察。 | ## 创建 Cache 缓存由一个查找函数定义:当某个键对应的值尚未被缓存时,该函数负责计算它: ```ts type Lookup = ( key: Key, ) => Effect ``` 查找函数接收一个 `Key` 并返回一个 `Effect`,这个 `Effect` 描述了如何计算对应的值(`Value`)。该 `Effect` 可能依赖某个环境(`Requirements`),可能以 `Error` 失败,也可能以 `Value` 成功。由于它返回的是 `Effect`,因此既能处理同步流程,也能处理异步流程。 创建缓存时,需要提供一个查找函数,以及缓存值的最大容量和生存时间(TTL)。 ```ts declare const make: (options: { readonly capacity: number readonly timeToLive: Duration.DurationInput readonly lookup: Lookup }) => Effect, never, Requirements> ``` 缓存创建之后,最符合惯用法的方式是使用 `get` 方法。 如果缓存中已存在对应的值,`get` 方法会返回该值;否则它会计算一个新值,将其放入缓存并返回。 如果多个并发进程请求同一个值,该值只会被计算一次。其他所有进程会在该值可用时立即收到它。这一过程由 Effect 基于 Fiber 的并发模型管理,不会阻塞底层线程。 **示例**(并发的缓存查找) 在这个示例中,我们用同一个键并发地调用 `timeConsumingEffect` 三次。 缓存只会执行这个 effect 一次,因此并发的查找会一直等待,直到该值可用: ```ts import { Effect, Cache, Duration } from "effect" // Simulating an expensive lookup with a delay const expensiveLookup = (key: string) => Effect.sleep("2 seconds").pipe(Effect.as(key.length)) const program = Effect.gen(function* () { // Create a cache with a capacity of 100 and an infinite TTL const cache = yield* Cache.make({ capacity: 100, timeToLive: Duration.infinity, lookup: expensiveLookup, }) // Perform concurrent lookups using the same key const result = yield* Effect.all( [cache.get("key1"), cache.get("key1"), cache.get("key1")], { concurrency: "unbounded", }, ) console.log( "Result of parallel execution of three effects" + `with the same key: ${result}`, ) // Fetch and display cache stats const hits = yield* cache.cacheStats.pipe(Effect.map((stats) => stats.hits)) console.log(`Number of cache hits: ${hits}`) const misses = yield* cache.cacheStats.pipe( Effect.map((stats) => stats.misses), ) console.log(`Number of cache misses: ${misses}`) }) Effect.runPromise(program) /* Output: Result of parallel execution of three effects with the same key: 4,4,4 Number of cache hits: 2 Number of cache misses: 1 */ ``` ## 并发访问 缓存被设计为对并发访问安全,并且在并发场景下保持高效。如果两个并发进程请求同一个值,而该值不在缓存中,那么它只会被计算一次,并在可用时立即提供给这两个进程。并发进程会等待该值,而不会阻塞底层线程。 如果查找函数失败或被中断,错误会被传播给所有正在等待该值的并发进程。失败的结果会被缓存,以避免对同一个失败的值反复计算。如果被中断,对应的键会从缓存中移除,因此后续调用会再次尝试计算该值。 ## 容量 创建缓存时需要指定一个容量。当缓存达到容量上限时,最近最少被访问的值会最先被移除。在两次操作之间,缓存的大小可能会略微超过指定的容量。 ## 生存时间(TTL) 缓存还可以指定生存时间(TTL)。超过 TTL 的值不会被返回。值的存活时长从它被载入缓存时开始计算。 ## 方法 除了 `get` 之外,缓存还提供了若干其他方法: | 方法 | 说明 | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `refresh` | 触发对某个键对应值的重新计算,但不会移除旧值,从而允许继续访问。 | | `size` | 返回缓存当前的大小。在并发场景下,该大小是近似值。 | | `contains` | 检查缓存中是否存在与指定键关联的值。在并发访问下,该结果仅在检查的那一刻有效,之后可能立即发生变化。 | | `invalidate` | 逐出与特定键关联的值。 | | `invalidateAll` | 逐出缓存中的所有值。 | --- # 缓存 Effect > 使用可复用的工具高效管理 Effect 的缓存与记忆化。 本节介绍库中若干用于在应用中管理缓存与记忆化的函数。 ## cachedFunction 对一个带 effect 的函数进行记忆化,为相同的输入缓存结果,以避免重复计算。 **示例**(记忆化一个随机数生成器) ```ts import { Effect, Random } from "effect" const program = Effect.gen(function* () { const randomNumber = (n: number) => Random.nextIntBetween(1, n) console.log("non-memoized version:") console.log(yield* randomNumber(10)) // Generates a new random number console.log(yield* randomNumber(10)) // Generates a different number console.log("memoized version:") const memoized = yield* Effect.cachedFunction(randomNumber) console.log(yield* memoized(10)) // Generates and caches the result console.log(yield* memoized(10)) // Reuses the cached result }) Effect.runFork(program) /* Example Output: non-memoized version: 2 8 memoized version: 5 5 */ ``` ## once 确保一个 effect 只执行一次,即使它被多次调用也是如此。 **示例**(Effect 的单次执行) ```ts import { Effect, Console } from "effect" const program = Effect.gen(function* () { const task1 = Console.log("task1") // Repeats task1 three times yield* Effect.repeatN(task1, 2) // Ensures task2 is executed only once const task2 = yield* Effect.once(Console.log("task2")) // Attempts to repeat task2, but it will only execute once yield* Effect.repeatN(task2, 2) }) Effect.runFork(program) /* Output: task1 task1 task1 task2 */ ``` ## cached 返回一个 effect,它会惰性地计算出一个结果并缓存该结果。之后再次对这个 effect 求值时,会直接返回缓存的结果,而不会重新执行其中的逻辑。 **示例**(惰性缓存一个开销较大的任务) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Without caching, the task is executed each time console.log("-- non-cached version:") yield* expensiveTask.pipe(Effect.andThen(Console.log)) yield* expensiveTask.pipe(Effect.andThen(Console.log)) // With caching, the result is reused after the first run console.log("-- cached version:") const cached = yield* Effect.cached(expensiveTask) yield* cached.pipe(Effect.andThen(Console.log)) yield* cached.pipe(Effect.andThen(Console.log)) }) Effect.runFork(program) /* Output: -- non-cached version: expensive task... result 1 expensive task... result 2 -- cached version: expensive task... result 3 result 3 */ ``` ## cachedWithTTL 返回一个 effect,它会把结果缓存指定的时长,这个时长称为 `timeToLive`。当缓存在这个时长之后过期时,该 effect 会在下一次求值时重新计算。 **示例**(带存活时间的缓存) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Caches the result for 150 milliseconds const cached = yield* Effect.cachedWithTTL(expensiveTask, "150 millis") // First evaluation triggers the task yield* cached.pipe(Effect.andThen(Console.log)) // Second evaluation returns the cached result yield* cached.pipe(Effect.andThen(Console.log)) // Wait for 100 milliseconds, ensuring the cache expires yield* Effect.sleep("100 millis") // Recomputes the task after cache expiration yield* cached.pipe(Effect.andThen(Console.log)) }) Effect.runFork(program) /* Output: expensive task... result 1 result 1 expensive task... result 2 */ ``` ## cachedInvalidateWithTTL 与 `Effect.cachedWithTTL` 类似,这个函数会把一个 effect 的结果缓存指定的时长。它还额外提供一个 effect,用于在缓存自然过期之前手动使其失效。 **示例**(手动使缓存失效) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Caches the result for 150 milliseconds const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( expensiveTask, "150 millis", ) // First evaluation triggers the task yield* cached.pipe(Effect.andThen(Console.log)) // Second evaluation returns the cached result yield* cached.pipe(Effect.andThen(Console.log)) // Invalidate the cache before it naturally expires yield* invalidate // Third evaluation triggers the task again // since the cache was invalidated yield* cached.pipe(Effect.andThen(Console.log)) }) Effect.runFork(program) /* Output: expensive task... result 1 result 1 expensive task... result 2 */ ``` --- # 品牌类型 > 使用品牌类型在 TypeScript 中强化类型安全并细化数据。 在本指南中,我们将探讨 TypeScript 中的**品牌类型**(branded types)概念,并学习如何使用 Brand 模块创建和使用它们。 品牌类型是带有额外类型标记(type tag)的 TypeScript 类型,有助于防止在错误的上下文中意外使用某个值。 它们允许我们基于已有的底层类型创建彼此不同的类型,从而实现类型安全和更好的代码组织。 ## TypeScript 结构类型系统的问题 TypeScript 的类型系统是结构化类型(structurally typed)的,这意味着只要两个类型的成员兼容,它们就被视为兼容。 这可能导致这样的情况:底层类型相同的值被互换使用,即使它们代表不同的概念或具有不同的含义。 考虑以下类型: ```ts type UserId = number type ProductId = number ``` 在这里,`UserId` 和 `ProductId` 在结构上完全相同,因为它们都基于 `number`。 TypeScript 会把二者视为可互换的,如果它们在应用中被混用,就可能引发 bug。 **示例**(意外的类型兼容) ```ts type UserId = number type ProductId = number const getUserById = (id: UserId) => { // Logic to retrieve user } const getProductById = (id: ProductId) => { // Logic to retrieve product } const id: UserId = 1 getProductById(id) // No type error, but incorrect usage ``` 在上面的例子中,把 `UserId` 传给 `getProductById` 不会产生类型错误,尽管这在逻辑上是不正确的。出现这种情况是因为这两个类型被视为可互换。 ## 品牌类型如何解决问题 品牌类型允许你通过添加唯一的类型标记,从相同的底层类型创建出彼此不同的类型,从而在编译期强制正确的用法。 品牌化(branding)是通过添加一个符号标识符来实现的,它在类型层面把一个类型与另一个类型区分开。 这种方法确保类型保持彼此不同,同时不改变它们的运行时特征。 让我们先引入 `BrandTypeId` 符号: ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" // unique identifier for ProductId } } ``` 这种方法把一个唯一标识符作为品牌赋予 `number` 类型,从而有效地将 `ProductId` 与其他数值类型区分开。 使用符号可以确保品牌字段不会与 `number` 类型的任何现有属性冲突。 现在,尝试用 `UserId` 替代 `ProductId` 会导致错误: **示例**(用品牌类型强制类型安全) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" } } const getProductById = (id: ProductId) => { // Logic to retrieve product } type UserId = number const id: UserId = 1 // @errors: 2345 getProductById(id) ``` 错误信息清楚地表明,`number` 不能用来替代 `ProductId`。 TypeScript 不会允许我们把 `number` 的实例传给接受 `ProductId` 的函数,因为它缺少品牌字段。 让我们也为 `UserId` 添加品牌: **示例**(为 UserId 和 ProductId 添加品牌) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" // unique identifier for ProductId } } const getProductById = (id: ProductId) => { // Logic to retrieve product } type UserId = number & { readonly [BrandTypeId]: { readonly UserId: "UserId" // unique identifier for UserId } } declare const id: UserId // @errors: 2345 getProductById(id) ``` 这个错误表明,虽然两个类型都使用了品牌,但品牌字段关联的唯一值(`"ProductId"` 和 `"UserId"`)确保它们保持彼此不同、不可互换。 ## 泛化品牌类型 为了增强品牌类型的通用性和可复用性,可以用一种标准化的方式对它们进行泛化: ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") // Create a generic Brand interface using a unique identifier interface Brand { readonly [BrandTypeId]: { readonly [id in ID]: ID } } // Define a ProductId type branded with a unique identifier type ProductId = number & Brand<"ProductId"> // Define a UserId type branded similarly type UserId = number & Brand<"UserId"> ``` 这种设计允许用唯一标识符(字符串或符号)为任意类型添加品牌。 下面展示如何使用 `Brand` 接口——它由 Brand 模块直接提供,因此无需自己编写实现: **示例**(使用 Brand 模块中的 Brand 接口) ```ts import { Brand } from "effect" // Define a ProductId type branded with a unique identifier type ProductId = number & Brand.Brand<"ProductId"> // Define a UserId type branded similarly type UserId = number & Brand.Brand<"UserId"> ``` 然而,直接创建这些类型的实例会导致错误,因为类型系统期望的是品牌结构: **示例**(直接赋值错误) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") interface Brand { readonly [BrandTypeId]: { readonly [k in K]: K } } type ProductId = number & Brand<"ProductId"> // @errors: 2322 const id: ProductId = 1 ``` 你不能直接把 `number` 赋值给 `ProductId`。Brand 模块提供了用于正确构造品牌类型值的工具。 ## 构造品牌类型 Brand 模块提供了两个用于创建品牌类型的主要函数:`nominal` 和 `refined`。 ### nominal `Brand.nominal` 函数用于定义不需要运行时校验的品牌类型。 它只是给底层类型添加一个类型标记,让我们能够区分同一类型但含义不同的值。 当我们只是为了代码清晰和代码组织而想创建彼此不同的类型时,名义品牌类型(nominal branded types)就很有用。 **示例**(用名义品牌定义不同的标识符) ```ts import { Brand } from "effect" // Define UserId as a branded number type UserId = number & Brand.Brand<"UserId"> // Constructor for UserId const UserId = Brand.nominal() const getUserById = (id: UserId) => { // Logic to retrieve user } // Define ProductId as a branded number type ProductId = number & Brand.Brand<"ProductId"> // Constructor for ProductId const ProductId = Brand.nominal() const getProductById = (id: ProductId) => { // Logic to retrieve product } ``` 尝试赋值一个非 `ProductId` 的值会导致编译期错误: **示例**(品牌标识符带来的类型安全) ```ts import { Brand } from "effect" type UserId = number & Brand.Brand<"UserId"> const UserId = Brand.nominal() const getUserById = (id: UserId) => { // Logic to retrieve user } type ProductId = number & Brand.Brand<"ProductId"> const ProductId = Brand.nominal() const getProductById = (id: ProductId) => { // Logic to retrieve product } // Correct usage getProductById(ProductId(1)) // Incorrect, will result in an error // @errors: 2345 getProductById(1) // Also incorrect, will result in an error // @errors: 2345 getProductById(UserId(1)) ``` ### refined `Brand.refined` 函数用于创建包含数据校验的品牌类型。它需要一个精化谓词(refinement predicate),用于根据特定条件检查输入数据是否有效。 当输入数据不符合条件时,该函数使用 `Brand.error` 生成一个 `BrandErrors` 数据类型。这会提供关于校验为何失败的详细信息。 **示例**(创建带校验的品牌类型) ```ts import { Brand } from "effect" // Define a branded type 'Int' to represent integer values type Int = number & Brand.Brand<"Int"> // Define the constructor using 'refined' to enforce integer values const Int = Brand.refined( // Validation to ensure the value is an integer (n) => Number.isInteger(n), // Provide an error if validation fails (n) => Brand.error(`Expected ${n} to be an integer`), ) ``` **示例**(使用 `Int` 构造器) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.refined( // Check if the value is an integer (n) => Number.isInteger(n), // Error message if the value is not an integer (n) => Brand.error(`Expected ${n} to be an integer`), ) // Create a valid Int value const x: Int = Int(3) console.log(x) // Output: 3 // Attempt to create an Int with an invalid value const y: Int = Int(3.14) // throws [ { message: 'Expected 3.14 to be an integer' } ] ``` 尝试赋值一个非 `Int` 的值会导致编译期错误: **示例**(错误赋值的编译期错误) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.refined( (n) => Number.isInteger(n), (n) => Brand.error(`Expected ${n} to be an integer`), ) // Correct usage const good: Int = Int(3) // Incorrect, will result in an error // @errors: 2322 const bad1: Int = 3 // Also incorrect, will result in an error // @errors: 2322 const bad2: Int = 3.14 ``` ## 组合品牌类型 在某些情况下,你可能需要组合多个品牌类型。为此,Brand 模块提供了 `Brand.all` API: **示例**(组合多个品牌类型) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.refined( (n) => Number.isInteger(n), (n) => Brand.error(`Expected ${n} to be an integer`), ) type Positive = number & Brand.Brand<"Positive"> const Positive = Brand.refined( (n) => n > 0, (n) => Brand.error(`Expected ${n} to be positive`), ) // Combine the Int and Positive constructors // into a new branded constructor PositiveInt const PositiveInt = Brand.all(Int, Positive) // Extract the branded type from the PositiveInt constructor type PositiveInt = Brand.Brand.FromConstructor // Usage example // Valid positive integer const good: PositiveInt = PositiveInt(10) // throws [ { message: 'Expected -5 to be positive' } ] const bad1: PositiveInt = PositiveInt(-5) // throws [ { message: 'Expected 3.14 to be an integer' } ] const bad2: PositiveInt = PositiveInt(3.14) ``` --- # 控制流操作符 > 学习用 Effect 提供的高级结构控制程序执行流:条件分支、循环,以及把多个 effect 组合到一起。 尽管 JavaScript 已经内置了控制流结构,Effect 仍额外提供了一些在 Effect 应用中很有用的控制流函数。本节介绍控制执行流的几种不同方式。 ## if 表达式 处理 Effect 值时,我们可以使用标准的 JavaScript `if-then-else` 语句: **示例**(对非法体重返回 None) 这里我们用 [Option](/docs/v3/data-types/option/) 数据类型来表示"没有有效值"。 ```ts import { Effect, Option } from "effect" // Function to validate weight and return an Option const validateWeightOption = ( weight: number, ): Effect.Effect> => { if (weight >= 0) { // Return Some if the weight is valid return Effect.succeed(Option.some(weight)) } else { // Return None if the weight is invalid return Effect.succeed(Option.none()) } } ``` **示例**(对非法体重返回错误) 也可以用错误通道来处理非法输入:输入非法时返回一个错误。 ```ts import { Effect } from "effect" // Function to validate weight or fail with an error const validateWeightOrFail = ( weight: number, ): Effect.Effect => { if (weight >= 0) { // Return the weight if valid return Effect.succeed(weight) } else { // Fail with an error if invalid return Effect.fail(`negative input: ${weight}`) } } ``` ## 条件操作符 ### if 根据由返回 effect 的谓词求值出的条件,执行两个 effect 中的一个。 当需要根据谓词 effect 求值为 `true` 还是 `false` 来决定运行两个 effect 中的哪一个时,使用 `Effect.if`。 若谓词为 `true`,则执行 `onTrue` effect;若为 `false`,则改为执行 `onFalse` effect。 **示例**(模拟抛硬币) 在这个示例中,我们用 `Random.nextBoolean` 生成一个随机布尔值来模拟虚拟抛硬币。如果值为 `true`,`onTrue` effect 会记录 "Head";如果值为 `false`,`onFalse` effect 会记录 "Tail"。 ```ts import { Effect, Random, Console } from "effect" const flipTheCoin = Effect.if(Random.nextBoolean, { onTrue: () => Console.log("Head"), // Runs if the predicate is true onFalse: () => Console.log("Tail"), // Runs if the predicate is false }) Effect.runFork(flipTheCoin) ``` ### when 根据布尔条件,有条件地执行某个 effect。 `Effect.when` 让你可以有条件地执行一个 effect,它类似于使用 `if (condition)` 表达式, 额外的好处是能够处理 effect。若条件为 `true`,则执行该 effect;否则什么也不做。 effect 的结果会被包在 `Option` 里,用来表示这个 effect 是否被执行过: 条件为 `true` 时,结果被包在 `Some` 里;条件为 `false` 时结果是 `None`, 表示这个 effect 被跳过了。 **示例**(有条件地执行 effect) ```ts import { Effect, Option } from "effect" const validateWeightOption = ( weight: number, ): Effect.Effect> => // Conditionally execute the effect if the weight is non-negative Effect.succeed(weight).pipe(Effect.when(() => weight >= 0)) // Run with a valid weight Effect.runPromise(validateWeightOption(100)).then(console.log) /* Output: { _id: "Option", _tag: "Some", value: 100 } */ // Run with an invalid weight Effect.runPromise(validateWeightOption(-5)).then(console.log) /* Output: { _id: "Option", _tag: "None" } */ ``` 在这个示例中,[Option](/docs/v3/data-types/option/) 数据类型用于表示有效值是否存在。如果条件求值为 `true`(在这个例子里就是体重非负),则执行该 effect 并包在 `Some` 里;否则结果是 `None`。 ### whenEffect 根据另一个 effect 的结果,有条件地执行某个 effect。 当"要不要执行"这个条件本身取决于另一个产出布尔值的 effect 的结果时,使用 `Effect.whenEffect`。 若条件 effect 求值为 `true`,则执行指定的 effect;若求值为 `false`,则不执行任何 effect。 effect 的结果会被包在 `Option` 里,用来表示这个 effect 是否被执行过: 条件为 `true` 时,结果被包在 `Some` 里;条件为 `false` 时结果是 `None`, 表示这个 effect 被跳过了。 **示例**(用 effect 作为条件) 下面的函数会产生一个随机整数,但仅当随机生成的布尔值为 `true` 时才产生。 ```ts import { Effect, Random } from "effect" const randomIntOption = Random.nextInt.pipe( Effect.whenEffect(Random.nextBoolean), ) console.log(Effect.runSync(randomIntOption)) /* Example Output: { _id: 'Option', _tag: 'Some', value: 8609104974198840 } */ ``` ### unless / unlessEffect `Effect.unless` 和 `Effect.unlessEffect` 函数与 `when*` 系列函数类似,但它们等价于 `if (!condition) expression` 构造。 ## 组合(Zipping) ### zip 把两个 effect 合并成一个 effect,产出一个包含两者结果的元组。 `Effect.zip` 先执行第一个 effect(左),再执行第二个 effect(右)。 两者都成功之后,它们的结果被组合成一个元组。 **示例**(顺序组合两个 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // Combine the two effects together // // ┌─── Effect<[number, string], never, never> // ▼ const program = Effect.zip(task1, task2) Effect.runPromise(program).then(console.log) /* Output: timestamp=... level=INFO fiber=#0 message="task1 done" timestamp=... level=INFO fiber=#0 message="task2 done" [ 1, 'hello' ] */ ``` 默认情况下两个 effect 是顺序执行的。要并发执行,请使用 `{ concurrent: true }` 选项。 **示例**(并发组合两个 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // Run both effects concurrently using the concurrent option const program = Effect.zip(task1, task2, { concurrent: true }) Effect.runPromise(program).then(console.log) /* Output: timestamp=... level=INFO fiber=#3 message="task2 done" timestamp=... level=INFO fiber=#2 message="task1 done" [ 1, 'hello' ] */ ``` 在这个并发版本里,两个 effect 并行运行。`task2` 先完成,但两个任务都会在完成的当下被记录和处理。 ### zipWith 顺序组合两个 effect,并对它们的结果套用一个函数,产出单一的值。 `Effect.zipWith` 与 [Effect.zip](#zip) 类似,区别在于它不返回结果的元组, 而是把给定的函数作用在两者的结果上,合并成单一的值。 默认情况下两个 effect 顺序执行。要并发执行,请使用 `{ concurrent: true }` 选项。 **示例**(用自定义函数组合 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // ┌─── Effect // ▼ const task3 = Effect.zipWith( task1, task2, // Combines results into a single value (number, string) => number + string.length, ) Effect.runPromise(task3).then(console.log) /* Output: timestamp=... level=INFO fiber=#3 message="task1 done" timestamp=... level=INFO fiber=#2 message="task2 done" 6 */ ``` ## 循环 ### loop `Effect.loop` 让你用一个 `step` 函数反复更新状态,直到 `while` 函数定义的条件变为 `false`。 它会把中间的每一个状态收集进数组,作为最终结果返回。 **语法** ```ts Effect.loop(initial, { while: (state) => boolean, step: (state) => state, body: (state) => Effect, }) ``` 这个函数类似 JavaScript 里的 `while` 循环,只是循环中可以有带副作用的计算: ```ts let state = initial const result = [] while (options.while(state)) { result.push(options.body(state)) // Perform the effectful operation state = options.step(state) // Update the state } return result ``` **示例**(循环并收集结果) ```ts import { Effect } from "effect" // A loop that runs 5 times, collecting each iteration's result const result = Effect.loop( // Initial state 1, { // Condition to continue looping while: (state) => state <= 5, // State update function step: (state) => state + 1, // Effect to be performed on each iteration body: (state) => Effect.succeed(state), }, ) Effect.runPromise(result).then(console.log) // Output: [1, 2, 3, 4, 5] ``` 在这个例子里,循环从状态 `1` 开始,一直持续到状态超过 `5`。每次状态加 `1` 并被收集进数组,该数组就是最终结果。 #### 丢弃中间结果 把 `discard` 选项设为 `true` 会丢弃每次带副作用操作的结果,返回 `void` 而不是数组。 **示例**(丢弃结果的循环) ```ts import { Effect, Console } from "effect" const result = Effect.loop( // Initial state 1, { // Condition to continue looping while: (state) => state <= 5, // State update function step: (state) => state + 1, // Effect to be performed on each iteration body: (state) => Console.log(`Currently at state ${state}`), // Discard intermediate results discard: true, }, ) Effect.runPromise(result).then(console.log) /* Output: Currently at state 1 Currently at state 2 Currently at state 3 Currently at state 4 Currently at state 5 undefined */ ``` 在这个例子里,循环每次迭代都会产生一个打印当前下标的副作用,但所有中间结果都被丢弃,最终结果是 `undefined`。 ### iterate `Effect.iterate` 让你通过一个带副作用的操作反复更新状态。它在每次迭代中运行 `body` effect 来更新状态, 只要 `while` 条件求值为 `true` 就继续下去。 **语法** ```ts Effect.iterate(initial, { while: (result) => boolean, body: (result) => Effect, }) ``` 这个函数类似 JavaScript 里的 `while` 循环,只是循环中可以有带副作用的计算: ```ts let result = initial while (options.while(result)) { result = options.body(result) } return result ``` **示例**(带副作用的迭代) ```ts import { Effect } from "effect" const result = Effect.iterate( // Initial result 1, { // Condition to continue iterating while: (result) => result <= 5, // Operation to change the result body: (result) => Effect.succeed(result + 1), }, ) Effect.runPromise(result).then(console.log) // Output: 6 ``` ### forEach 对 `Iterable` 中的每个元素执行一次带副作用的操作。 `Effect.forEach` 把给定的操作作用在可迭代对象的每个元素上,产出一个**返回结果数组**的新 effect。 如果任何一个 effect 失败,迭代会立即停止(短路),错误被向外传播。 `concurrency` 选项控制有多少个操作并发执行。默认情况下操作是顺序执行的。 **示例**(对可迭代对象的元素施加 effect) ```ts import { Effect, Console } from "effect" const result = Effect.forEach([1, 2, 3, 4, 5], (n, index) => Console.log(`Currently at index ${index}`).pipe(Effect.as(n * 2)), ) Effect.runPromise(result).then(console.log) /* Output: Currently at index 0 Currently at index 1 Currently at index 2 Currently at index 3 Currently at index 4 [ 2, 4, 6, 8, 10 ] */ ``` 在这个例子里,我们遍历数组 `[1, 2, 3, 4, 5]`,对每个元素施加一个打印当前下标的 effect。`Effect.as(n * 2)` 把每个值转换掉,最终得到数组 `[2, 4, 6, 8, 10]`。最终输出就是所有转换后的值被收集起来的结果。 #### 丢弃结果 把 `discard` 选项设为 `true` 会丢弃每次带副作用操作的结果,返回 `void` 而不是数组。 **示例**(用 `discard` 忽略结果) ```ts import { Effect, Console } from "effect" // Apply effects but discard the results const result = Effect.forEach( [1, 2, 3, 4, 5], (n, index) => Console.log(`Currently at index ${index}`).pipe(Effect.as(n * 2)), { discard: true }, ) Effect.runPromise(result).then(console.log) /* Output: Currently at index 0 Currently at index 1 Currently at index 2 Currently at index 3 Currently at index 4 undefined */ ``` 这种情况下,每个元素上的 effect 照常执行,但结果被丢弃,所以最终输出是 `undefined`。 ## 收集 ### all 将多个 effect 合并为一个,并根据输入结构返回结果。 当你需要运行多个 effect 并将它们的结果合并为单个输出时,请使用 `Effect.all`。它支持元组、可迭代对象、Struct 和 Record,因此能灵活适配不同的输入类型。 如果任一 effect 失败,它会停止执行(短路),并传播错误。要改变这一行为,你可以使用 [`mode`](#the-mode-option) 选项,它允许所有 effect 都继续运行,并以 [Either](/docs/v3/data-types/either/) 或 [Option](/docs/v3/data-types/option/) 的形式收集结果。 你可以通过[并发选项](/docs/v3/concurrency/basic-concurrency/#concurrency-options)来控制执行顺序(例如串行还是并发)。 例如,如果输入是一个元组: ```ts // ┌─── a tuple of effects // ▼ Effect.all([effect1, effect2, ...]) ``` 这些 effect 会按顺序执行,其结果是一个包含这些结果(以元组形式)的新 effect。元组中结果的顺序与传给 `Effect.all` 的 effect 顺序一致。 下面我们来看针对不同类型结构的示例:元组、可迭代对象、对象和 Record。 **示例**(在元组中合并 Effect) ```ts import { Effect, Console } from "effect" const tupleOfEffects = [ Effect.succeed(42).pipe(Effect.tap(Console.log)), Effect.succeed("Hello").pipe(Effect.tap(Console.log)), ] as const // ┌─── Effect<[number, string], never, never> // ▼ const resultsAsTuple = Effect.all(tupleOfEffects) Effect.runPromise(resultsAsTuple).then(console.log) /* Output: 42 Hello [ 42, 'Hello' ] */ ``` **示例**(在可迭代对象中合并 Effect) ```ts import { Effect, Console } from "effect" const iterableOfEffects: Iterable> = [1, 2, 3].map((n) => Effect.succeed(n).pipe(Effect.tap(Console.log)), ) // ┌─── Effect // ▼ const resultsAsArray = Effect.all(iterableOfEffects) Effect.runPromise(resultsAsArray).then(console.log) /* Output: 1 2 3 [ 1, 2, 3 ] */ ``` **示例**(在 Struct 中合并 Effect) ```ts import { Effect, Console } from "effect" const structOfEffects = { a: Effect.succeed(42).pipe(Effect.tap(Console.log)), b: Effect.succeed("Hello").pipe(Effect.tap(Console.log)), } // ┌─── Effect<{ a: number; b: string; }, never, never> // ▼ const resultsAsStruct = Effect.all(structOfEffects) Effect.runPromise(resultsAsStruct).then(console.log) /* Output: 42 Hello { a: 42, b: 'Hello' } */ ``` **示例**(在 Record 中合并 Effect) ```ts import { Effect, Console } from "effect" const recordOfEffects: Record> = { key1: Effect.succeed(1).pipe(Effect.tap(Console.log)), key2: Effect.succeed(2).pipe(Effect.tap(Console.log)), } // ┌─── Effect<{ [x: string]: number; }, never, never> // ▼ const resultsAsRecord = Effect.all(recordOfEffects) Effect.runPromise(resultsAsRecord).then(console.log) /* Output: 1 2 { key1: 1, key2: 2 } */ ``` #### 短路行为 `Effect.all` 函数在遇到第一个错误时就会停止执行,这被称为“短路”。 如果集合中的任一 effect 失败,其余 effect 将不会运行,错误也会被传播。 **示例**(首次失败即退出) ```ts import { Effect, Console } from "effect" const program = Effect.all([ Effect.succeed("Task1").pipe(Effect.tap(Console.log)), Effect.fail("Task2: Oh no!").pipe(Effect.tap(Console.log)), // Won't execute due to earlier failure Effect.succeed("Task3").pipe(Effect.tap(Console.log)), ]) Effect.runPromiseExit(program).then(console.log) /* Output: Task1 { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Task2: Oh no!' } } */ ``` 你可以通过 `mode` 选项覆盖这一行为。 #### `mode` 选项 `{ mode: "either" }` 选项会改变 `Effect.all` 的行为,确保所有 effect 都运行,即使其中一些失败。该模式不会在首次失败时停止,而是同时收集成功与失败的结果,返回一个 `Either` 实例数组,其中每个结果要么是 `Right`(成功),要么是 `Left`(失败)。 **示例**(用 `mode: "either"` 收集结果) ```ts import { Effect, Console } from "effect" const effects = [ Effect.succeed("Task1").pipe(Effect.tap(Console.log)), Effect.fail("Task2: Oh no!").pipe(Effect.tap(Console.log)), Effect.succeed("Task3").pipe(Effect.tap(Console.log)), ] const program = Effect.all(effects, { mode: "either" }) Effect.runPromiseExit(program).then(console.log) /* Output: Task1 Task3 { _id: 'Exit', _tag: 'Success', value: [ { _id: 'Either', _tag: 'Right', right: 'Task1' }, { _id: 'Either', _tag: 'Left', left: 'Task2: Oh no!' }, { _id: 'Either', _tag: 'Right', right: 'Task3' } ] } */ ``` 类似地,`{ mode: "validate" }` 选项使用 `Option` 来表示成功或失败。每个 effect 成功时返回 `None`,失败时返回带错误的 `Some`。 **示例**(用 `mode: "validate"` 收集结果) ```ts import { Effect, Console } from "effect" const effects = [ Effect.succeed("Task1").pipe(Effect.tap(Console.log)), Effect.fail("Task2: Oh no!").pipe(Effect.tap(Console.log)), Effect.succeed("Task3").pipe(Effect.tap(Console.log)), ] const program = Effect.all(effects, { mode: "validate" }) Effect.runPromiseExit(program).then((result) => console.log("%o", result)) /* Output: Task1 Task3 { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: [ { _id: 'Option', _tag: 'None' }, { _id: 'Option', _tag: 'Some', value: 'Task2: Oh no!' }, { _id: 'Option', _tag: 'None' } ] } } */ ``` --- # 简化过度嵌套 > 使用 Do 模拟与 generator 简化嵌套代码。 假设你想创建一个自定义函数 `elapsed`,用来打印某个 effect 执行所耗费的时间。 ## 使用普通的 pipe 最初,你可能会写出使用标准 `pipe` [方法](/docs/v3/getting-started/building-pipelines/#the-pipe-method)的代码,但这种方式会导致过度嵌套,让代码变得冗长且难以阅读: **示例**(使用 `pipe` 测量耗时) ```ts import { Effect, Console } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) // Prints the elapsed time occurred to `self` to execute const elapsed = ( self: Effect.Effect, ): Effect.Effect => now.pipe( Effect.andThen((startMillis) => self.pipe( Effect.andThen((result) => now.pipe( Effect.andThen((endMillis) => { // Calculate the elapsed time in milliseconds const elapsed = endMillis - startMillis // Log the elapsed time return Console.log(`Elapsed: ${elapsed}`).pipe( Effect.map(() => result), ) }), ), ), ), ), ) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) Effect.runPromise(program).then(console.log) /* Output: Elapsed: 204 some task */ ``` 为了解决这个问题并让代码更易于维护,有一个方案:「do 模拟」(do simulation)。 ## 使用「do 模拟」 Effect 中的「do 模拟」让你能以更声明式的风格编写代码,类似于其他编程语言中的「do notation」。它提供了一种定义变量、并通过 `Effect.bind`、`Effect.let` 这类函数对它们执行操作的方式。 do 模拟的工作方式如下: 1. 使用 `Effect.Do` 值启动 do 模拟: ```ts const program = Effect.Do.pipe(/* ... rest of the code */) ``` 2. 在 do 模拟的作用域内,你可以使用 `Effect.bind` 函数定义变量,并把它绑定到 `Effect` 值: ```ts Effect.bind("variableName", (scope) => effectValue) ``` - `variableName` 是你为要定义的变量选择的名字。它在作用域内必须唯一。 - `effectValue` 是你想绑定到该变量的 `Effect` 值。它可以是函数调用的结果,也可以是任何其他合法的 `Effect` 值。 3. 你可以累积多个 `Effect.bind` 语句,在作用域内定义多个变量: ```ts Effect.bind("variable1", () => effectValue1), Effect.bind("variable2", ({ variable1 }) => effectValue2), // ... additional bind statements ``` 4. 在 do 模拟作用域内,你还可以使用 `Effect.let` 函数定义变量,并把它绑定到简单值: ```ts Effect.let("variableName", (scope) => simpleValue) ``` - `variableName` 是你给变量起的名字。和之前一样,它在作用域内必须唯一。 - `simpleValue` 是你想赋给该变量的值。它可以是 `number`、`string` 或 `boolean` 这样的简单值。 5. 在 do 模拟中仍然可以使用 `Effect.andThen`、`Effect.flatMap`、`Effect.tap` 和 `Effect.map` 这类常规 Effect 函数。在作用域内,这些函数会把累积的变量作为参数接收: ```ts Effect.andThen(({ variable1, variable2 }) => { // Perform operations using variable1 and variable2 // Return an `Effect` value as the result }) ``` 借助 do 模拟,你可以像这样重写 `elapsed` 函数: **示例**(使用 do 模拟测量耗时) ```ts import { Effect, Console } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) const elapsed = ( self: Effect.Effect, ): Effect.Effect => Effect.Do.pipe( Effect.bind("startMillis", () => now), Effect.bind("result", () => self), Effect.bind("endMillis", () => now), Effect.let( "elapsed", // Calculate the elapsed time in milliseconds ({ startMillis, endMillis }) => endMillis - startMillis, ), // Log the elapsed time Effect.tap(({ elapsed }) => Console.log(`Elapsed: ${elapsed}`)), Effect.map(({ result }) => result), ) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) Effect.runPromise(program).then(console.log) /* Output: Elapsed: 204 some task */ ``` ## 使用 Effect.gen 最简洁、最方便的解决方案是使用 [Effect.gen](/docs/v3/getting-started/using-generators/),它让你在处理 effect 时可以使用 [generator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator)。这种方式利用了 generator 语法提供的原生作用域,避免了过度嵌套,从而让代码更简洁。 **示例**(使用 Effect.gen 测量耗时) ```ts import { Effect } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) // Prints the elapsed time occurred to `self` to execute const elapsed = ( self: Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const startMillis = yield* now const result = yield* self const endMillis = yield* now // Calculate the elapsed time in milliseconds const elapsed = endMillis - startMillis // Log the elapsed time console.log(`Elapsed: ${elapsed}`) return result }) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) Effect.runPromise(program).then(console.log) /* Output: Elapsed: 204 some task */ ``` 在 generator 内部,我们使用 `yield*` 调用 effect,并把它们的结果绑定到变量。这消除了嵌套,提供了更易读、更顺序化的代码结构。 Effect 中的 generator 风格采用更加线性、顺序化的执行流程,类似于传统的命令式编程语言。这让代码更易读、更易理解,尤其是对更熟悉命令式编程范式的开发者而言。 --- # Dual API > 探索 Effect 生态系统中 dual API 的 data-first 与 data-last 两种变体。 在 Effect 生态系统中使用 API 时,你可能会遇到同一个 API 有两种不同的用法。 这两种用法分别称为 "data-last" 变体和 "data-first" 变体。 当一个 API 同时支持这两种变体时,我们称它为 "dual" API。 下面用 `Effect.map` 来展示这两种变体。 ## Effect.map 作为 dual API `Effect.map` 函数由两个 TypeScript 重载定义。"data-last" 和 "data-first" 这两个说法,指的是 `self` 参数(也称为 "data")在两个重载签名中的位置: ```ts declare const map: { // ┌─── data-last // ▼ (f: (a: A) => B): (self: Effect) => Effect // ┌─── data-first // ▼ (self: Effect, f: (a: A) => B): Effect } ``` ### data-last 在第一个重载中,`self` 参数位于**最后**: ```ts declare const map: ( f: (a: A) => B, ) => (self: Effect) => Effect ``` 这种写法通常与 `pipe` 函数配合使用。你先把 `Effect` 作为初始参数传给 `pipe`,然后再链式调用 `Effect.map` 之类的变换: **示例**(使用 data-last 配合 `pipe`) ```ts const mappedEffect = pipe(effect, Effect.map(func)) ``` 当你需要串联多个变换时,这种风格很有帮助,能让代码以管道的形式更易读懂: ```ts pipe(effect, Effect.map(func1), Effect.map(func2), ...) ``` ### data-first 在第二个重载中,`self` 参数位于**最前**: ```ts declare const map: ( self: Effect, f: (a: A) => B, ) => Effect ``` 这种形式不需要 `pipe`。你可以直接把 `Effect` 作为第一个参数传入: **示例**(不使用 `pipe` 的 data-first 写法) ```ts const mappedEffect = Effect.map(effect, func) ``` 当你只需要对 `Effect` 执行单个操作时,这种写法很合适。 --- # 指南 > 运行 Effect 应用并确保代码风格安全、显式的最佳实践。 ## 使用 runMain 在 Effect 中,`runMain` 是在 Node.js 上执行 Effect 应用的主要入口点。 **示例**(以优雅收尾的方式运行 Effect 应用) ```ts import { Effect, Console, Schedule, pipe } from "effect" import { NodeRuntime } from "@effect/platform-node" const program = pipe( Effect.addFinalizer(() => Console.log("Application is about to exit!")), Effect.andThen(Console.log("Application started!")), Effect.andThen( Effect.repeat(Console.log("still alive..."), { schedule: Schedule.spaced("1 second"), }), ), Effect.scoped, ) // No graceful teardown on CTRL+C // Effect.runPromise(program) // Use NodeRuntime.runMain for graceful teardown on CTRL+C NodeRuntime.runMain(program) /* Output: Application started! still alive... still alive... still alive... still alive... ^C <-- CTRL+C Application is about to exit! */ ``` `runMain` 函数负责查找并中断所有 Fiber。它在内部观察 Fiber 并监听 `sigint` 信号,确保应用被中断时(例如按下 CTRL+C)能够优雅关闭。 ### 不同平台的版本 Effect 为不同平台提供了各自的 `runMain` 版本: | 平台 | 运行时版本 | 导入路径 | | -------- | ------------------------ | -------------------------- | | Node.js | `NodeRuntime.runMain` | `@effect/platform-node` | | Bun | `BunRuntime.runMain` | `@effect/platform-bun` | | Browser | `BrowserRuntime.runMain` | `@effect/platform-browser` | ## 避免隐式用法 避免使用隐式(无点)函数调用,例如 `Effect.map(fn)`,也不要使用 `effect/Function` 模块中的 `flow`。 在 Effect 中,显式地编写函数通常更安全: ```ts Effect.map((x) => fn(x)) ``` 而不是写成无点风格: ```ts Effect.map(fn) ``` 隐式函数虽然简洁,看起来很有吸引力,但它们可能带来一系列问题: - 使用隐式函数,尤其是在处理可选参数时,可能并不安全。例如,如果某个函数有重载,用隐式风格来写它可能会抹掉所有泛型,从而导致 bug。更多细节请参阅这个 X 帖子:[link to thread](https://twitter.com/MichaelArnaldi/status/1670715270845935616)。 - 隐式用法还可能妨碍 TypeScript 的类型推断能力,进而引发意料之外的错误。这不仅仅是风格问题,更是避免类型推断问题所导致的细微错误的一种方式。 - 此外,使用隐式用法时,堆栈跟踪可能不够清晰。 避免隐式用法是一个简单的预防措施,能让你的代码更加可靠。 --- # 模式匹配 > 使用 Match 模块进行模式匹配,简化复杂的分支逻辑。 模式匹配是一种让开发者能够在单个简洁表达式中处理复杂条件的方法。它简化了代码,使其更简洁、更容易理解。此外,它还包含一个称为穷尽性检查(exhaustiveness checking)的过程,用于帮助确保没有任何可能的情况被遗漏。 模式匹配源自函数式编程语言,是代码分支处理的一项强大技术。与 if/else 或 switch 语句这类命令式替代方案相比,它通常能提供更强大、更简洁的解决方案,尤其是在处理复杂条件时。 尽管模式匹配还不是 JavaScript 的原生特性,但目前有一个处于早期阶段的 [tc39 提案](https://github.com/tc39/proposal-pattern-matching),旨在把模式匹配引入 JavaScript。不过,该提案仍处于第 1 阶段,可能还需要数年才能落地。即便如此,开发者依然可以在自己的代码库中实现模式匹配。`effect/Match` 模块提供了一套可靠且类型安全的模式匹配实现,可立即使用。 **示例**(用模式匹配处理不同的数据类型) ```ts import { Match } from "effect" // Simulated dynamic input that can be a string or a number const input: string | number = "some input" // ┌─── string // ▼ const result = Match.value(input).pipe( // Match if the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Match if the value is a string Match.when(Match.string, (s) => `string: ${s}`), // Ensure all possible cases are covered Match.exhaustive, ) console.log(result) // Output: "string: some input" ``` ## 模式匹配的工作原理 模式匹配遵循一个结构化的流程: 1. **创建匹配器**。 定义一个 `Matcher`,让它作用于某个特定的[类型](#matching-by-type)或[值](#matching-by-value)。 2. **定义模式**。 使用 `Match.when`、`Match.not` 和 `Match.tag` 这类组合子来指定匹配条件。 3. **完成匹配**。 应用 `Match.exhaustive`、`Match.orElse` 或 `Match.option` 这样的终结器,来决定未匹配的情况应如何处理。 ## 创建匹配器 你可以通过以下任意一种方式创建 `Matcher`: - `Match.type()`:针对特定的类型进行匹配。 - `Match.value(value)`:针对特定的值进行匹配。 ### 按类型匹配 `Match.type` 构造函数会定义一个作用于特定类型的 `Matcher`。创建之后,你就可以使用 `Match.when` 这类模式来定义处理不同情况的条件。 **示例**(匹配数字和字符串) ```ts import { Match } from "effect" // Create a matcher for values that are either strings or numbers // // ┌─── (u: string | number) => string // ▼ const match = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Match when the value is a string Match.when(Match.string, (s) => `string: ${s}`), // Ensure all possible cases are handled Match.exhaustive, ) console.log(match(0)) // Output: "number: 0" console.log(match("hello")) // Output: "string: hello" ``` ### 按值匹配 除了为类型创建匹配器,你也可以使用 `Match.value` 直接基于某个具体的值来定义匹配器。 **示例**(按属性匹配对象) ```ts import { Match } from "effect" const input = { name: "John", age: 30 } // Create a matcher for the specific object const result = Match.value(input).pipe( // Match when the 'name' property is "John" Match.when( { name: "John" }, (user) => `${user.name} is ${user.age} years old`, ), // Provide a fallback if no match is found Match.orElse(() => "Oh, not John"), ) console.log(result) // Output: "John is 30 years old" ``` ### 强制返回类型 你可以使用 `Match.withReturnType()` 来确保所有分支都返回特定的类型。 **示例**(校验返回类型的一致性) 这个示例强制要求每个匹配分支都返回 `string`。 ```ts import { Match } from "effect" const match = Match.type<{ a: number } | { b: string }>().pipe( // Ensure all branches return a string Match.withReturnType(), // ❌ Type error: returns a number // @errors: 2322 Match.when({ a: Match.number }, (_) => _.a), // ✅ Correct: returns a string Match.when({ b: Match.string }, (_) => _.b), Match.exhaustive, ) ``` ## 定义模式 ### when `Match.when` 函数允许你定义用于匹配值的条件。它同时支持直接的值比较和谓词函数。 **示例**(用值和谓词进行匹配) ```ts import { Match } from "effect" // Create a matcher for objects with an "age" property const match = Match.type<{ age: number }>().pipe( // Match when age is greater than 18 Match.when({ age: (age) => age > 18 }, (user) => `Age: ${user.age}`), // Match when age is exactly 18 Match.when({ age: 18 }, () => "You can vote"), // Fallback case for all other ages Match.orElse((user) => `${user.age} is too young`), ) console.log(match({ age: 20 })) // Output: "Age: 20" console.log(match({ age: 18 })) // Output: "You can vote" console.log(match({ age: 4 })) // Output: "4 is too young" ``` ### not `Match.not` 函数允许你排除特定的值,同时匹配其余所有值。 **示例**(忽略某个特定的值) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match any value except "hi", returning "ok" Match.not("hi", () => "ok"), // Fallback case for when the value is "hi" Match.orElse(() => "fallback"), ) console.log(match("hello")) // Output: "ok" console.log(match("hi")) // Output: "fallback" ``` ### tag `Match.tag` 函数允许基于[可辨识联合](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions)中的 `_tag` 字段进行模式匹配。你可以在单个模式中指定多个要匹配的 tag。 **示例**(按 tag 匹配可辨识联合) ```ts import { Match } from "effect" type Event = | { readonly _tag: "fetch" } | { readonly _tag: "success"; readonly data: string } | { readonly _tag: "error"; readonly error: Error } | { readonly _tag: "cancel" } // Create a Matcher for Either const match = Match.type().pipe( // Match either "fetch" or "success" Match.tag("fetch", "success", () => `Ok!`), // Match "error" and extract the error message Match.tag("error", (event) => `Error: ${event.error.message}`), // Match "cancel" Match.tag("cancel", () => "Cancelled"), Match.exhaustive, ) console.log(match({ _tag: "success", data: "Hello" })) // Output: "Ok!" console.log(match({ _tag: "error", error: new Error("Oops!") })) // Output: "Error: Oops!" ``` ### 内置谓词 `Match` 模块为常见类型提供了内置谓词,例如 `Match.number`、`Match.string` 和 `Match.boolean`。这些谓词简化了针对原始类型的匹配过程。 **示例**(对属性键使用内置谓词) ```ts import { Match } from "effect" const matchPropertyKey = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `Key is a number: ${n}`), // Match when the value is a string Match.when(Match.string, (s) => `Key is a string: ${s}`), // Match when the value is a symbol Match.when(Match.symbol, (s) => `Key is a symbol: ${String(s)}`), // Ensure all possible cases are handled Match.exhaustive, ) console.log(matchPropertyKey(42)) // Output: "Key is a number: 42" console.log(matchPropertyKey("username")) // Output: "Key is a string: username" console.log(matchPropertyKey(Symbol("id"))) // Output: "Key is a symbol: Symbol(id)" ``` | 谓词 | 说明 | | ------------------------- | ----------------------------------------------------------------------------- | | `Match.string` | 匹配 `string` 类型的值。 | | `Match.nonEmptyString` | 匹配非空字符串。 | | `Match.number` | 匹配 `number` 类型的值。 | | `Match.boolean` | 匹配 `boolean` 类型的值。 | | `Match.bigint` | 匹配 `bigint` 类型的值。 | | `Match.symbol` | 匹配 `symbol` 类型的值。 | | `Match.date` | 匹配 `Date` 的实例值。 | | `Match.record` | 匹配键为 `string` 或 `symbol`、值为 `unknown` 的对象。 | | `Match.null` | 匹配值 `null`。 | | `Match.undefined` | 匹配值 `undefined`。 | | `Match.defined` | 匹配任何已定义(非 null 且非 undefined)的值。 | | `Match.any` | 匹配任意值,不做限制。 | | `Match.is(...values)` | 匹配一组特定的字面量值(例如 `Match.is("a", 42, true)`)。 | | `Match.instanceOf(Class)` | 匹配给定类的实例。 | ## 完成匹配 ### exhaustive `Match.exhaustive` 方法通过确保所有可能的情况都已被覆盖,来终结模式匹配过程。如果有任何情况缺失,TypeScript 会产生类型错误。这在处理联合类型时特别有用,因为它有助于避免模式匹配中出现意外的遗漏。 **示例**(确保覆盖所有情况) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Mark the match as exhaustive, ensuring all cases are handled // TypeScript will throw an error if any case is missing // @errors: 2345 Match.exhaustive, ) ``` ### orElse `Match.orElse` 方法定义当其他模式都不匹配时返回的 fallback 值。这确保匹配器始终能产出一个有效结果。 **示例**(在没有模式匹配时提供默认值) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match when the value is "a" Match.when("a", () => "ok"), // Fallback when no patterns match Match.orElse(() => "fallback"), ) console.log(match("a")) // Output: "ok" console.log(match("b")) // Output: "fallback" ``` ### option `Match.option` 会把匹配结果包装进一个 [Option](/docs/v3/data-types/option/)。如果找到匹配,它会返回 `Some(value)`;否则返回 `None`。 **示例**(用 Option 提取用户角色) ```ts import { Match } from "effect" type User = { readonly role: "admin" | "editor" | "viewer" } // Create a matcher to extract user roles const getRole = Match.type().pipe( Match.when({ role: "admin" }, () => "Has full access"), Match.when({ role: "editor" }, () => "Can edit content"), Match.option, // Wrap the result in an Option ) console.log(getRole({ role: "admin" })) // Output: { _id: 'Option', _tag: 'Some', value: 'Has full access' } console.log(getRole({ role: "viewer" })) // Output: { _id: 'Option', _tag: 'None' } ``` ### either `Match.either` 方法会把结果包装进一个 [Either](/docs/v3/data-types/either/),提供一种结构化的方式来区分匹配与未匹配的情况。如果找到匹配,它会返回 `Right(value)`;否则返回 `Left(no match)`。 **示例**(用 Either 提取用户角色) ```ts import { Match } from "effect" type User = { readonly role: "admin" | "editor" | "viewer" } // Create a matcher to extract user roles const getRole = Match.type().pipe( Match.when({ role: "admin" }, () => "Has full access"), Match.when({ role: "editor" }, () => "Can edit content"), Match.either, // Wrap the result in an Either ) console.log(getRole({ role: "admin" })) // Output: { _id: 'Either', _tag: 'Right', right: 'Has full access' } console.log(getRole({ role: "viewer" })) // Output: { _id: 'Either', _tag: 'Left', left: { role: 'viewer' } } ``` --- # 基础并发 > 通过并发、中断与竞速来管理和控制 effect 的执行。 ## 并发选项 Effect 提供了一些选项来管理 effect 的执行方式,尤其侧重控制有多少 effect 并发运行。 ```ts type Options = { readonly concurrency?: Concurrency } ``` `concurrency` 选项用于确定并发级别,取值如下: ```ts type Concurrency = number | "unbounded" | "inherit" ``` 下面我们详细探讨每一种配置。 ### 顺序执行(默认) 默认情况下,如果你不指定任何并发选项,effect 会顺序执行,一个接一个。这意味着每个 effect 只会在前一个 effect 完成之后才开始。 **示例**(顺序执行) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.DurationInput) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const sequential = Effect.all([task1, task2]) Effect.runPromise(sequential) /* Output: start task1 task1 done start task2 <-- task2 starts only after task1 completes task2 done */ ``` ### 数字并发 你可以通过为 `concurrency` 设置一个 `number` 来控制有多少 effect 并发运行。例如,`concurrency: 2` 允许最多两个 effect 同时运行。 **示例**(限制为 2 个并发任务) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.DurationInput) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") const numbered = Effect.all([task1, task2, task3, task4, task5], { concurrency: 2, }) Effect.runPromise(numbered) /* Output: start task1 start task2 <-- active tasks: task1, task2 task2 done start task3 <-- active tasks: task1, task3 task1 done start task4 <-- active tasks: task3, task4 task4 done start task5 <-- active tasks: task3, task5 task3 done task5 done */ ``` ### 无界并发 当使用 `concurrency: "unbounded"` 时,并发运行的 effect 数量没有上限。 **示例**(无界并发) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.DurationInput) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") const unbounded = Effect.all([task1, task2, task3, task4, task5], { concurrency: "unbounded", }) Effect.runPromise(unbounded) /* Output: start task1 start task2 start task3 start task4 start task5 task2 done task4 done task5 done task1 done task3 done */ ``` ### 继承并发 当使用 `concurrency: "inherit"` 时,并发级别会从周围的上下文中继承。这个上下文可以用 `Effect.withConcurrency(number | "unbounded")` 设置。如果没有提供上下文,默认值为 `"unbounded"`。 **示例**(从上下文继承并发) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.DurationInput) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") // Running all tasks with concurrency: "inherit", // which defaults to "unbounded" const inherit = Effect.all([task1, task2, task3, task4, task5], { concurrency: "inherit", }) Effect.runPromise(inherit) /* Output: start task1 start task2 start task3 start task4 start task5 task2 done task4 done task5 done task1 done task3 done */ ``` 如果你使用 `Effect.withConcurrency`,并发配置就会调整为指定的选项。 **示例**(设置并发选项) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.DurationInput) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") // Running tasks with concurrency: "inherit", // which will inherit the surrounding context const inherit = Effect.all([task1, task2, task3, task4, task5], { concurrency: "inherit", }) // Setting a concurrency limit of 2 const withConcurrency = inherit.pipe(Effect.withConcurrency(2)) Effect.runPromise(withConcurrency) /* Output: start task1 start task2 <-- active tasks: task1, task2 task2 done start task3 <-- active tasks: task1, task3 task1 done start task4 <-- active tasks: task3, task4 task4 done start task5 <-- active tasks: task3, task5 task3 done task5 done */ ``` ## 中断 Effect 中的所有 effect 都由 [Fiber](/docs/v3/concurrency/fibers/) 执行。如果你没有自己创建 Fiber,那么它要么是由你正在使用的某个操作创建的(如果该操作是并发的),要么是由 Effect [运行时](/docs/v3/runtime/) 系统创建的。 每当一个 effect 被运行时,都会创建一个 Fiber。并发运行 effect 时,会为每个并发 effect 创建一个 Fiber。 总结如下: - `Effect` 是更高层的概念,用于描述一段带副作用的计算。它是惰性且不可变的,这意味着它表示一段可能产生值、也可能失败的计算,但并不会立即执行。 - 而 Fiber 表示 `Effect` 正在运行的执行过程。它可以被中断,也可以被等待以获取其结果。可以把它看作一种控制和交互正在进行的计算的方式。 Fiber 可以通过多种方式被中断。下面我们来探讨其中一些场景,并看看在 Effect 中如何中断 Fiber 的示例。 ### interrupt 可以使用 `Effect.interrupt` effect 来中断指定的 Fiber。 这个 effect 模拟它所在的 Fiber 被显式中断的行为。 执行时,它会让该 Fiber 立即停止运行,并捕获中断的详细信息,例如该 Fiber 的 ID 和它的启动时间。 如果使用 [runPromiseExit](/docs/v3/getting-started/running-effects/#runpromiseexit) 这类函数运行 effect,就可以在 [Exit](/docs/v3/data-types/exit/) 类型中观察到由此产生的中断。 **示例**(无中断) 在这个例子中,程序在没有任何中断的情况下运行,记录了任务的开始与完成。 ```ts import { Effect } from "effect" const program = Effect.gen(function* () { console.log("start") yield* Effect.sleep("2 seconds") console.log("done") return "some result" }) Effect.runPromiseExit(program).then(console.log) /* Output: start done { _id: 'Exit', _tag: 'Success', value: 'some result' } */ ``` **示例**(发生中断) 这里,Fiber 在打印日志 `"start"` 之后、打印 `"done"` 之前被中断。`Effect.interrupt` 会停止该 Fiber,因此它永远不会走到最后那行日志。 ```ts import { Effect } from "effect" const program = Effect.gen(function* () { console.log("start") yield* Effect.sleep("2 seconds") yield* Effect.interrupt console.log("done") return "some result" }) Effect.runPromiseExit(program).then(console.log) /* Output: start { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ ``` ### onInterrupt 注册一个清理 effect,在某个 effect 被中断时运行。 这个函数允许你指定一个 effect,在 Fiber 被中断时运行。该 effect 会在 Fiber 被中断时执行, 让你可以执行清理或其他操作。 **示例**(在中断时运行清理操作) 在这个示例中,我们设置了一个处理器,每当 Fiber 被中断时就记录 "Cleanup completed"。然后展示了三种情况:成功的 effect、失败的 effect 以及被中断的 effect,以此说明处理器如何根据 effect 结束方式的不同而被触发。 ```ts import { Console, Effect } from "effect" // This handler is executed when the fiber is interrupted const handler = Effect.onInterrupt((_fibers) => Console.log("Cleanup completed"), ) const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed */ const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed */ const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed */ ``` ### 并发 effect 的中断 当并发运行多个 effect 时(例如使用 `Effect.forEach`),如果其中一个 effect 被中断,就会导致所有并发 effect 也一并被中断。 由此得到的 [cause](/docs/v3/data-types/cause/) 会包含哪些 Fiber 被中断的信息。 **示例**(中断并发 effect) ```ts import { Effect, Console } from "effect" const program = Effect.forEach( [1, 2, 3], (n) => Effect.gen(function* () { console.log(`start #${n}`) yield* Effect.sleep(`${n} seconds`) if (n > 1) { yield* Effect.interrupt } console.log(`done #${n}`) }).pipe(Effect.onInterrupt(() => Console.log(`interrupted #${n}`))), { concurrency: "unbounded" }, ) Effect.runPromiseExit(program).then((exit) => console.log(JSON.stringify(exit, null, 2)), ) /* Output: start #1 start #2 start #3 done #1 interrupted #2 interrupted #3 { "_id": "Exit", "_tag": "Failure", "cause": { "_id": "Cause", "_tag": "Parallel", "left": { "_id": "Cause", "_tag": "Interrupt", "fiberId": { "_id": "FiberId", "_tag": "Runtime", "id": 3, "startTimeMillis": ... } }, "right": { "_id": "Cause", "_tag": "Sequential", "left": { "_id": "Cause", "_tag": "Empty" }, "right": { "_id": "Cause", "_tag": "Interrupt", "fiberId": { "_id": "FiberId", "_tag": "Runtime", "id": 0, "startTimeMillis": ... } } } } } */ ``` ## 竞速 ### race 这个函数接收两个 effect 并并发运行它们。第一个成功完成的 effect 将决定这次竞速的结果,而另一个 effect 会被中断。 如果两个 effect 都没有成功,该函数会以一个包含所有错误的 [cause](/docs/v3/data-types/cause/) 失败。 当你希望并发运行两个 effect、但只关心第一个成功的那个时,这很有用。它常用于超时、重试等场景,或者当你希望优化为更快得到响应、而不必顾虑另一个 effect 时。 **示例**(两个任务都成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) Effect.runFork(program) /* Output: task2 done task1 interrupted */ ``` **示例**(一个任务失败,一个任务成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) Effect.runFork(program) /* Output: task2 done */ ``` **示例**(两个任务都失败) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.fail("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Parallel', left: { _id: 'Cause', _tag: 'Fail', failure: 'task1' }, right: { _id: 'Cause', _tag: 'Fail', failure: 'task2' } } } */ ``` 如果你想处理最先完成的任务的结果,无论它成功还是失败,都可以使用 `Effect.either` 函数。这个函数会把结果包装为 [Either](/docs/v3/data-types/either/) 类型,让你可以看出结果是成功(`Right`)还是失败(`Left`): **示例**(用 Either 处理成功或失败) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) // Run both tasks concurrently, wrapping the result // in Either to capture success or failure const program = Effect.race(Effect.either(task1), Effect.either(task2)) Effect.runPromise(program).then(console.log) /* Output: task2 interrupted { _id: 'Either', _tag: 'Left', left: 'task1' } */ ``` ### raceAll 该函数会并发运行多个 effect,并返回第一个成功的 effect 的结果。一旦某个 effect 成功,其余的都会被中断。 如果所有 effect 都没有成功,该函数会以最后遇到的错误失败。 当你想要让多个 effect 竞速、但只关心第一个成功的那个时,这很有用。 它常用于超时、重试之类的场景, 或者当你想优化出更快的响应、 而不必关心其余 effect 的时候。 **示例**(所有任务都成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.succeed("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) Effect.runFork(program) /* Output: task1 done task2 interrupted task3 interrupted */ ``` **示例**(一个任务失败,两个任务成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.succeed("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) Effect.runFork(program) /* Output: task3 done task2 interrupted */ ``` **示例**(所有任务都失败) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.fail("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.fail("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'task2' } } */ ``` ### raceFirst 这个函数接收两个 effect 并并发运行它们,返回最先完成的那个的结果,无论它是成功还是失败。 当你想让两个操作竞速,并且希望无论哪一个先完成(成功也好、失败也好)都继续往下走时,这个函数很有用。 **示例**(两个任务都成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceFirst(task1, task2).pipe( Effect.tap(Console.log("more work...")), ) Effect.runPromiseExit(program).then(console.log) /* Output: task1 done task2 interrupted more work... { _id: 'Exit', _tag: 'Success', value: 'task1' } */ ``` **示例**(一个任务失败,一个任务成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceFirst(task1, task2).pipe( Effect.tap(Console.log("more work...")), ) Effect.runPromiseExit(program).then(console.log) /* Output: task2 interrupted { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'task1' } } */ ``` #### 断开 effect `Effect.raceFirst` 函数会在另一方完成后安全地中断“输掉”的那个 effect,但直到输掉的一方被干净地终止之前,它都不会返回。 如果你希望更快地返回,可以为两个 effect 断开中断信号。与其像这样调用: ```ts Effect.raceFirst(task1, task2) ``` 可以改用: ```ts Effect.raceFirst(Effect.disconnect(task1), Effect.disconnect(task2)) ``` 这样两个 effect 就能各自独立地完成,同时输掉的那个 effect 仍会在后台被终止。 **示例**(用 `Effect.disconnect` 更快返回) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) // Race the two tasks with disconnect to allow quicker return const program = Effect.raceFirst( Effect.disconnect(task1), Effect.disconnect(task2), ).pipe(Effect.tap(Console.log("more work..."))) Effect.runPromiseExit(program).then(console.log) /* Output: task1 done more work... { _id: 'Exit', _tag: 'Success', value: 'task1' } task2 interrupted */ ``` ### raceWith 这个函数会并发运行两个 effect,并在其中某个 effect 完成时调用指定的“finisher”(收尾)函数,无论它是成功还是失败。 每个 effect 各自的 finisher 函数让你可以在它们一完成时就去处理各自的结果。 该函数接收两个 finisher 回调,每个 effect 一个,让你可以自行指定如何处理这次竞速的结果。 当你需要对任一 effect 的完成做出反应、而不必等两者都结束时,这个函数很有用。任何时候,只要你想基于最先拿到的结果采取行动,都可以用它。 **示例**(处理并发任务的结果) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceWith(task1, task2, { onSelfDone: (exit) => Console.log(`task1 exited with ${exit}`), onOtherDone: (exit) => Console.log(`task2 exited with ${exit}`), }) Effect.runFork(program) /* Output: task1 done task1 exited with { "_id": "Exit", "_tag": "Success", "value": "task1" } task2 interrupted */ ``` --- # Deferred > 掌握 Deferred 的异步协调能力 —— 一个用于管理 effect 同步与通信的一次性变量。 `Deferred` 是 `Effect` 的一个特殊子类型,它的行为类似于一个一次性变量,只不过带有一些独特之处。它只能被完成一次,因此是管理异步操作以及程序不同部分之间同步的有用工具。 deferred 本质上是一种同步原语,它表示一个可能不会立即可用的值。当你创建一个 deferred 时,它初始为空。之后,它可以用成功值 `Success` 或错误值 `Error` 来完成: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ▼ ▼ Deferred ``` 一旦完成,它就不能再被更改。 当某个 Fiber 调用 `Deferred.await` 时,它会暂停,直到该 deferred 被完成。在 Fiber 等待期间,它不会阻塞线程,只在语义上阻塞。这意味着其他 Fiber 仍然可以运行,从而保证高效的并发。 deferred 在概念上类似于 JavaScript 的 `Promise`。 关键区别在于它同时支持成功类型和错误类型,从而提供了更强的类型安全性。 ## 创建 Deferred 可以使用 `Deferred.make` 构造器创建 deferred。它返回一个表示 deferred 创建过程的 effect。由于创建 deferred 涉及内存分配,因此必须在 effect 内部完成,以确保资源得到安全管理。 **示例**(创建 Deferred) ```ts import { Deferred } from "effect" // ┌─── Effect> // ▼ const deferred = Deferred.make() ``` ## 等待 要从 deferred 中获取值,可以使用 `Deferred.await`。该操作会挂起调用它的 Fiber,直到该 deferred 以一个值或一个错误被完成。 ```ts import { Effect, Deferred } from "effect" // ┌─── Effect, never, never> // ▼ const deferred = Deferred.make() // ┌─── Effect // ▼ const value = deferred.pipe(Effect.andThen(Deferred.await)) ``` ## 完成 你可以通过多种方式完成一个 deferred,具体取决于你想让它成功、失败,还是中断正在等待的 Fiber: | API | 说明 | | ----------------------- | --------------------------------------------------------------------------------------------------------------- | | `Deferred.succeed` | 用一个值成功地完成 deferred。 | | `Deferred.done` | 用一个 [Exit](/docs/v3/data-types/exit/) 值完成 deferred。 | | `Deferred.complete` | 用一个 effect 的结果完成 deferred。 | | `Deferred.completeWith` | 用一个 effect 完成 deferred。该 effect 会被每个正在等待的 Fiber 执行,因此请谨慎使用。 | | `Deferred.fail` | 用一个错误使 deferred 失败。 | | `Deferred.die` | 用一个用户自定义的错误使 deferred 产生 defect。 | | `Deferred.failCause` | 用一个 [Cause](/docs/v3/data-types/cause/) 使 deferred 失败或产生 defect。 | | `Deferred.interrupt` | 中断 deferred,强制停止或中断正在等待的 Fiber。 | **示例**(用成功值完成 Deferred) ```ts import { Effect, Deferred } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Complete the Deferred successfully yield* Deferred.succeed(deferred, 1) // Awaiting the Deferred to get its value const value = yield* Deferred.await(deferred) console.log(value) }) Effect.runFork(program) // Output: 1 ``` 完成一个 deferred 会产生一个 `Effect`。如果该 deferred 被成功完成,这个 effect 返回 `true`;如果它之前已经被完成过,则返回 `false`。这对于跟踪 deferred 的状态很有用。 **示例**(检查完成状态) ```ts import { Effect, Deferred } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Attempt to fail the Deferred const firstAttempt = yield* Deferred.fail(deferred, "oh no!") // Attempt to succeed after it has already been completed const secondAttempt = yield* Deferred.succeed(deferred, 1) console.log([firstAttempt, secondAttempt]) }) Effect.runFork(program) // Output: [ true, false ] ``` ## 检查完成状态 有时,你可能需要在不挂起 Fiber 的情况下检查一个 deferred 是否已经完成。这可以通过 `Deferred.poll` 方法实现。它的工作方式如下: - `Deferred.poll` 返回一个 `Option>`: - 如果 `Deferred` 尚未完成,它返回 `None`。 - 如果 `Deferred` 已完成,它返回 `Some`,其中包含结果或错误。 此外,你还可以使用 `Deferred.isDone` 函数来检查一个 deferred 是否已经完成。该方法返回一个 `Effect`,如果 `Deferred` 已完成则求值为 `true`,让你可以快速检查它的状态。 **示例**(轮询并检查完成状态) ```ts import { Effect, Deferred } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Polling the Deferred to check if it's completed const done1 = yield* Deferred.poll(deferred) // Checking if the Deferred has been completed const done2 = yield* Deferred.isDone(deferred) console.log([done1, done2]) }) Effect.runFork(program) /* Output: [ { _id: 'Option', _tag: 'None' }, false ] */ ``` ## 常见用例 当你需要等待程序中某件特定的事情发生时,`Deferred` 就变得很有用。 它非常适合这样的场景:你希望代码的某一部分在准备好时通知另一部分。 以下是一些常见用例: | **使用场景** | **说明** | | -------------- | --------------------------------------------------------------------------------------------------------------------------- | | **协调 Fiber** | 当你有多个并发任务并需要协调它们的行动时,`Deferred` 可以帮助一个 Fiber 在完成任务后向另一个 Fiber 发出信号。 | | **同步** | 每当你需要确保一段代码在另一段代码完成其工作之前不继续执行时,`Deferred` 都能提供你所需要的同步。 | | **移交工作** | 你可以用 `Deferred` 把工作从一个 Fiber 移交给另一个 Fiber。例如,一个 Fiber 可以准备一些数据,然后第二个 Fiber 继续处理它。 | | **挂起执行** | 当你希望某个 Fiber 暂停执行直到某个条件满足时,可以用 `Deferred` 阻塞它,直到该条件被满足。 | **示例**(使用 Deferred 协调两个 Fiber) 在这个示例中,我们用一个 deferred 在两个 Fiber 之间传递一个值。 通过并发运行这两个 Fiber,并把该 deferred 用作同步点,我们可以确保 `fiberB` 只有在 `fiberA` 完成其任务之后才继续执行。 ```ts import { Effect, Deferred, Fiber } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Completes the Deferred with a value after a delay const taskA = Effect.gen(function* () { console.log("Starting task to complete the Deferred") yield* Effect.sleep("1 second") console.log("Completing the Deferred") return yield* Deferred.succeed(deferred, "hello world") }) // Waits for the Deferred and prints the value const taskB = Effect.gen(function* () { console.log("Starting task to get the value from the Deferred") const value = yield* Deferred.await(deferred) console.log("Got the value from the Deferred") return value }) // Run both fibers concurrently const fiberA = yield* Effect.fork(taskA) const fiberB = yield* Effect.fork(taskB) // Wait for both fibers to complete const both = yield* Fiber.join(Fiber.zip(fiberA, fiberB)) console.log(both) }) Effect.runFork(program) /* Starting task to complete the Deferred Starting task to get the value from the Deferred Completing the Deferred Got the value from the Deferred [ true, 'hello world' ] */ ``` --- # Fiber > 了解 Effect 中的 Fiber——轻量级虚拟线程,带来强大并发、结构化生命周期与高效的资源管理,让应用保持响应。 Effect 是一个由 Fiber 驱动的高并发框架。Fiber 是轻量级虚拟线程,具备资源安全的取消能力,为 Effect 中的诸多特性提供了支撑。 在本节中,你将学习 Fiber 的基础知识,并熟悉一些利用 Fiber 的强大底层操作符。 ## 什么是虚拟线程? JavaScript 本质上是单线程的,也就是说它按单一指令序列执行代码。不过,现代 JavaScript 环境使用事件循环来管理异步操作,从而营造出多任务并行的假象。在这种语境下,虚拟线程(也就是 Fiber)是由 Effect 运行时模拟出来的逻辑线程。它们允许并发执行,而无需依赖 JavaScript 原生并不支持的真多线程。 ## Fiber 如何工作 Effect 中的所有 effect 都由 Fiber 执行。如果你没有自己创建 Fiber,那么它要么是由你正在使用的某个操作创建的(如果该操作是并发的),要么是由 Effect 运行时系统创建的。 每当一个 effect 被运行时,就会创建一个 Fiber。当并发运行多个 effect 时,会为每个并发 effect 创建一个 Fiber。 即使你编写的是没有任何并发操作的“单线程”代码,也总会至少存在一个 Fiber:执行你的 effect 的那个“主” Fiber。 Effect 的 Fiber 具有定义良好的生命周期,该生命周期基于它所执行的那个 effect。 每个 Fiber 的退出方式要么是失败,要么是成功,取决于它所执行的 effect 是失败还是成功。 Effect 的 Fiber 具有唯一的标识、局部状态以及状态(例如 done、running 或 suspended)。 总结如下: - `Effect` 是更高层的概念,用于描述一段带副作用的计算。它是惰性且不可变的,这意味着它表示一段可能产生值、也可能失败的计算,但并不会立即执行。 - 而 Fiber 表示 `Effect` 正在运行的执行过程。它可以被中断,也可以被等待以获取其结果。可以把它看作一种控制和交互正在进行的计算的方式。 ## Fiber 数据类型 Effect 中的 `Fiber` 数据类型表示对某个 effect 执行的“句柄”。 以下是 `Fiber` 的一般形式: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ▼ ▼ Fiber ``` 这个类型表明一个 Fiber: - 成功并返回类型为 `Success` 的值 - 失败并带有类型为 `Error` 的错误 Fiber 没有 `Requirements` 类型参数,因为它们只执行那些依赖需求已经被提供好的 effect。 ## Fork Effect 你可以通过 **fork** 一个 effect 来创建新的 Fiber。这会在一个新的 Fiber 中启动该 effect,而你会收到指向该 Fiber 的引用。 **示例**(Fork 一个 Fiber) 在这个示例中,斐波那契计算被 fork 到它自己的 Fiber 中,使它能够独立于主 Fiber 运行。之后可以使用 `fib10Fiber` 的引用去 join 或中断该 Fiber。 ```ts import { Effect } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.fork(fib(10)) ``` ## Join Fiber 对 Fiber 最常见的操作之一是 **join**。使用 `Fiber.join` 函数,你可以等待某个 Fiber 完成并获取它的结果。被 join 的 Fiber 要么成功、要么失败,而 `join` 返回的 `Effect` 反映了该 Fiber 的结果。 **示例**(Join 一个 Fiber) ```ts import { Effect, Fiber } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.fork(fib(10)) const program = Effect.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Join the fiber and get the result const n = yield* Fiber.join(fiber) console.log(n) }) Effect.runFork(program) // Output: 55 ``` ## Await Fiber 在处理 Fiber 时,`Fiber.await` 函数是一个很有用的工具。它允许你等待某个 Fiber 完成,并获取关于它是如何结束的详细信息。结果被封装在一个 [Exit](/docs/v3/data-types/exit/) 值中,让你了解该 Fiber 是成功、失败还是被中断。 **示例**(等待 Fiber 完成) ```ts import { Effect, Fiber } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.fork(fib(10)) const program = Effect.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Await its completion and get the Exit result const exit = yield* Fiber.await(fiber) console.log(exit) }) Effect.runFork(program) /* Output: { _id: 'Exit', _tag: 'Success', value: 55 } */ ``` ## 中断模型 在开发并发应用时,有几种情况需要我们中断其他 Fiber 的执行,例如: 1. 父 Fiber 可能启动了一些子 Fiber 来执行某项任务,之后父 Fiber 可能认定它不再需要其中某些或全部子 Fiber 的结果。 2. 两个或多个 Fiber 相互竞争。结果最先计算出来的 Fiber 胜出,而其他所有 Fiber 都不再需要,应当被中断。 3. 在交互式应用中,用户可能希望停止某些已经在运行的任务,例如点击“停止”按钮以阻止继续下载文件。 4. 运行时间超出预期的计算,应当通过超时操作予以中止。 5. 当我们的应用根据用户输入执行计算密集型任务时,如果用户更改了输入,我们就应当取消当前任务并执行另一个任务。 ### 轮询 vs. 异步中断 在中断 Fiber 方面,一种朴素的做法是允许一个 Fiber 强制终止另一个 Fiber。然而这种做法并不理想,因为如果目标 Fiber 正在修改共享状态,强制终止就可能使该状态处于不一致、不可靠的状态。因此,它无法保证共享可变状态的内部一致性。 相反,有两种流行且有效的方案可以解决这个问题: 1. **半异步中断(轮询式中断)**:命令式语言通常采用轮询作为一种半异步信号机制,例如 Java。在这种模型中,一个 Fiber 向另一个 Fiber 发送中断请求。目标 Fiber 持续轮询中断状态,检查自己是否收到了来自其他 Fiber 的中断请求。如果检测到中断请求,目标 Fiber 会尽快终止自身。 采用这种方案时,临界区由 Fiber 自身处理。因此,如果某个 Fiber 正处于临界区中并收到中断请求,它会忽略该中断,并把对中断的处理推迟到临界区之后。 然而这种做法的一个缺点是:如果程序员忘记定期轮询,目标 Fiber 就可能变得无响应,从而导致死锁。此外,轮询一个全局标志与 Effect 所遵循的函数式范式并不契合。 2. **异步式中断**:在异步式中断中,允许一个 Fiber 终止另一个 Fiber。目标 Fiber 并不负责轮询中断状态。取而代之的是,在临界区中,目标 Fiber 会禁用这些区域的可中断性。这是一种纯函数式方案,不需要轮询全局状态。Effect 的中断模型采用了这一方案,它是一种完全异步的信号机制。 这种机制克服了忘记定期轮询的缺点。它也与函数式范式完全兼容,因为在纯函数式计算中,我们可以在任意时刻中止计算,除非处于那些禁用了中断的临界区。 ### 中断 Fiber 如果 Fiber 的结果不再被需要,就可以中断它。该操作会立即停止该 Fiber,并安全地运行所有终结器以释放资源。 与 `Fiber.await` 一样,`Fiber.interrupt` 函数返回一个 [Exit](/docs/v3/data-types/exit/) 值,其中提供了关于该 Fiber 如何结束的详细信息。 **示例**(中断一个 Fiber) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork a fiber that runs indefinitely, printing "Hi!" const fiber = yield* Effect.fork( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") // Interrupt the fiber and get an Exit value detailing how it finished const exit = yield* Fiber.interrupt(fiber) console.log(exit) }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ ``` 默认情况下,`Fiber.interrupt` 返回的 effect 会等待该 Fiber 完全终止后才恢复。这确保了在前一批 Fiber 完成之前不会启动新的 Fiber,这种行为被称为“背压”(back-pressuring)。 如果你不需要这种等待行为,可以把这个中断操作本身 fork 出去,让主程序不必等待该 Fiber 终止就能继续执行: **示例**(Fork 一个中断操作) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { const fiber = yield* Effect.fork( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") const _ = yield* Effect.fork(Fiber.interrupt(fiber)) console.log("Do something else...") }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! Do something else... */ ``` 对于后台中断,还有一个简写:`Fiber.interruptFork`。 ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { const fiber = yield* Effect.fork( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") // const _ = yield* Effect.fork(Fiber.interrupt(fiber)) const _ = yield* Fiber.interruptFork(fiber) console.log("Do something else...") }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! Do something else... */ ``` ## 组合 Fiber `Fiber.zip` 和 `Fiber.zipWith` 函数允许你把两个 Fiber 组合成一个。组合后的 Fiber 会产生两个输入 Fiber 的结果。如果任一 Fiber 失败,组合后的 Fiber 也会失败。 **示例**(用 `Fiber.zip` 组合 Fiber) 在这个示例中,两个 Fiber 并发运行,它们的结果被组合成一个元组。 ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork two fibers that each produce a string const fiber1 = yield* Effect.fork(Effect.succeed("Hi!")) const fiber2 = yield* Effect.fork(Effect.succeed("Bye!")) // Combine the two fibers using Fiber.zip const fiber = Fiber.zip(fiber1, fiber2) // Join the combined fiber and get the result as a tuple const tuple = yield* Fiber.join(fiber) console.log(tuple) }) Effect.runFork(program) /* Output: [ 'Hi!', 'Bye!' ] */ ``` 另一种组合 Fiber 的方式是使用 `Fiber.orElse`。这个函数允许你提供一个备用 Fiber,当第一个 Fiber 失败时就会执行它。如果第一个 Fiber 成功,就返回它的结果;如果它失败,则改为运行第二个 Fiber,并且无论其结果如何都会被返回。 **示例**(用 `Fiber.orElse` 提供回退 Fiber) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork a fiber that will fail const fiber1 = yield* Effect.fork(Effect.fail("Uh oh!")) // Fork another fiber that will succeed const fiber2 = yield* Effect.fork(Effect.succeed("Hurray!")) // If fiber1 fails, fiber2 will be used as a fallback const fiber = Fiber.orElse(fiber1, fiber2) const message = yield* Fiber.join(fiber) console.log(message) }) Effect.runFork(program) /* Output: Hurray! */ ``` ## 子 Fiber 的生命周期 当我们 fork Fiber 时,根据 fork 方式的不同,子 Fiber 可以有四种不同的生命周期策略: 1. **带自动监督的 Fork**。如果我们使用普通的 `Effect.fork` 操作,子 Fiber 将由父 Fiber 自动监督。子 Fiber 的生命周期与父 Fiber 的生命周期绑定。这意味着这些 Fiber 要么在自然结束时终止,要么在父 Fiber 被终止时终止。 2. **在全局作用域中 Fork(Daemon)**。有时我们想运行长时间运行的后台 Fiber,它们不依附于父 Fiber,而且我们希望在全局作用域中 fork 它们。任何在全局作用域中 fork 的 Fiber 都会成为 daemon Fiber。这可以通过 `Effect.forkDaemon` 操作实现。由于这些 Fiber 没有父 Fiber,它们不受监督;它们会在自然结束时终止,或在我们的应用被终止时终止。 3. **在局部作用域中 Fork**。有时,我们想运行一个不依附于父 Fiber 的后台 Fiber,但我们希望该 Fiber 生活在局部作用域中。我们可以使用 `Effect.forkScoped` 在局部作用域中 fork Fiber。这类 Fiber 可以比父 Fiber 存活得更久(因此不受父 Fiber 监督),它们会在自身生命结束时或局部作用域被关闭时终止。 4. **在指定作用域中 Fork**。这与上一种策略类似,但通过在指定作用域中 fork 子 Fiber,我们可以对子 Fiber 的生命周期进行更细粒度的控制。我们可以使用 `Effect.forkIn` 操作做到这一点。 ### 带自动监督的 Fork Effect 遵循**结构化并发**模型,其中子 Fiber 的生命周期与父 Fiber 绑定。简单来说,一个 Fiber 的寿命取决于其父 Fiber 的寿命。 **示例**(自动监督的子 Fiber) 在这个场景中,`parent` Fiber 启动了一个 `child` Fiber,后者每秒重复打印一条消息。 当 `parent` Fiber 完成时,`child` Fiber 将被终止。 ```ts import { Effect, Console, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Child fiber is supervised by the parent yield* Effect.fork(child) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) Effect.runFork(parent) /* Output: parent: started! child: still running! child: still running! child: still running! parent: finished! */ ``` 这一行为可以扩展到任意层级的嵌套 Fiber,确保 Fiber 的生命周期可预测且受控。 ### 在全局作用域中 Fork(Daemon) 你可以使用 `Effect.forkDaemon` 创建一个长时间运行的后台 Fiber。这类 Fiber 被称为 daemon Fiber,它不依附于父 Fiber 的生命周期,其寿命与全局作用域相关联。即使父 Fiber 被终止,daemon Fiber 仍会继续运行,只有当全局作用域被关闭或该 Fiber 自然完成时才会停止。 **示例**(创建一个 Daemon Fiber) 这个示例展示了 daemon Fiber 如何在父 Fiber 结束后仍然继续在后台运行。 ```ts import { Effect, Console, Schedule } from "effect" // Daemon fiber that logs a message repeatedly every second const daemon = Effect.repeat( Console.log("daemon: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Daemon fiber running independently yield* Effect.forkDaemon(daemon) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) Effect.runFork(parent) /* Output: parent: started! daemon: still running! daemon: still running! daemon: still running! parent: finished! daemon: still running! daemon: still running! daemon: still running! daemon: still running! daemon: still running! ...etc... */ ``` 即使父 Fiber 被中断,daemon Fiber 也会继续独立运行。 **示例**(中断父 Fiber) 在这个示例中,中断父 Fiber 不会影响 daemon Fiber,它会继续在后台运行。 ```ts import { Effect, Console, Schedule, Fiber } from "effect" // Daemon fiber that logs a message repeatedly every second const daemon = Effect.repeat( Console.log("daemon: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Daemon fiber running independently yield* Effect.forkDaemon(daemon) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }).pipe(Effect.onInterrupt(() => Console.log("parent: interrupted!"))) // Program that interrupts the parent fiber after 2 seconds const program = Effect.gen(function* () { const fiber = yield* Effect.fork(parent) yield* Effect.sleep("2 seconds") yield* Fiber.interrupt(fiber) // Interrupt the parent fiber }) Effect.runFork(program) /* Output: parent: started! daemon: still running! daemon: still running! parent: interrupted! daemon: still running! daemon: still running! daemon: still running! daemon: still running! daemon: still running! ...etc... */ ``` ### 在局部作用域中 Fork 有时我们想创建一个与局部 [scope](/docs/v3/resource-management/scope/) 绑定的 Fiber,也就是说它的生命周期不依赖于其父 Fiber,而是绑定到它被 fork 时所处的局部作用域。这可以使用 `Effect.forkScoped` 操作来完成。 使用 `Effect.forkScoped` 创建的 Fiber 可以比其父 Fiber 存活得更久,只有当局部作用域本身被关闭时才会被终止。 **示例**(在局部作用域中 Fork 一个 Fiber) 在这个示例中,`child` Fiber 在 `parent` Fiber 的生命周期结束之后仍继续运行。`child` Fiber 与局部作用域绑定,只有当作用域结束时才会被终止。 ```ts import { Effect, Console, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) // ┌─── Effect // ▼ const parent = Effect.gen(function* () { console.log("parent: started!") // Child fiber attached to local scope yield* Effect.forkScoped(child) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) // Program runs within a local scope const program = Effect.scoped( Effect.gen(function* () { console.log("Local scope started!") yield* Effect.fork(parent) // Scope lasts for 5 seconds yield* Effect.sleep("5 seconds") console.log("Leaving the local scope!") }), ) Effect.runFork(program) /* Output: Local scope started! parent: started! child: still running! child: still running! child: still running! parent: finished! child: still running! child: still running! Leaving the local scope! */ ``` ### 在指定作用域中 Fork 有些情况下我们需要更细粒度的控制,因此我们想在一个指定作用域中 fork 一个 Fiber。 我们可以使用 `Effect.forkIn` 操作,它接收目标作用域作为参数。 **示例**(在指定作用域中 Fork 一个 Fiber) 在这个示例中,`child` Fiber 被 fork 到 `outerScope` 中,这使它能够比内部作用域存活得更久,但在 `outerScope` 被关闭时仍会被终止。 ```ts import { Console, Effect, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) const program = Effect.scoped( Effect.gen(function* () { yield* Effect.addFinalizer(() => Console.log("The outer scope is about to be closed!"), ) // Capture the outer scope const outerScope = yield* Effect.scope // Create an inner scope yield* Effect.scoped( Effect.gen(function* () { yield* Effect.addFinalizer(() => Console.log("The inner scope is about to be closed!"), ) // Fork the child fiber in the outer scope yield* Effect.forkIn(child, outerScope) yield* Effect.sleep("3 seconds") }), ) yield* Effect.sleep("5 seconds") }), ) Effect.runFork(program) /* Output: child: still running! child: still running! child: still running! The inner scope is about to be closed! child: still running! child: still running! child: still running! child: still running! child: still running! child: still running! The outer scope is about to be closed! */ ``` ## Fiber 何时运行? 被 fork 的 Fiber 会在当前 Fiber 完成或让出之后开始执行。 **示例**(Fiber 启动过晚,只捕获到一个值) 在下面的示例中,`changes` Stream 只捕获到一个值 `2`。 这是因为由 `Effect.fork` 创建的 Fiber 在该值被更新**之后**才启动。 ```ts import { Effect, SubscriptionRef, Stream, Console } from "effect" const program = Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) yield* ref.changes.pipe( // Log each change in SubscriptionRef Stream.tap((n) => Console.log(`SubscriptionRef changed to ${n}`)), Stream.runDrain, // Fork a fiber to run the stream Effect.fork, ) yield* SubscriptionRef.set(ref, 1) yield* SubscriptionRef.set(ref, 2) }) Effect.runFork(program) /* Output: SubscriptionRef changed to 2 */ ``` 如果你使用 `Effect.sleep()` 添加一个短暂延迟,或者调用 `Effect.yieldNow()`,就能让当前 Fiber 让出执行权。这样,被 fork 的 Fiber 就有足够的时间在值被更新之前启动并收集到所有值。 **示例**(延迟让 Fiber 捕获所有值) ```ts import { Effect, SubscriptionRef, Stream, Console } from "effect" const program = Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) yield* ref.changes.pipe( // Log each change in SubscriptionRef Stream.tap((n) => Console.log(`SubscriptionRef changed to ${n}`)), Stream.runDrain, // Fork a fiber to run the stream Effect.fork, ) // Allow the fiber a chance to start yield* Effect.sleep("100 millis") yield* SubscriptionRef.set(ref, 1) yield* SubscriptionRef.set(ref, 2) }) Effect.runFork(program) /* Output: SubscriptionRef changed to 0 SubscriptionRef changed to 1 SubscriptionRef changed to 2 */ ``` --- # Latch > Latch 通过让 Fiber 等待某个特定事件发生来同步它们,并根据其打开或关闭的状态控制访问。 Latch 是一种同步工具,其行为如同一道闸门:它让 Fiber 先等待,直到 Latch 被打开之后才继续执行。Latch 可以处于打开或关闭两种状态: - 关闭时,到达 Latch 的 Fiber 会一直等待,直到它被打开。 - 打开时,Fiber 会立即通过。 一旦被打开,Latch 通常会保持打开状态,不过如有需要,你也可以再次将它关闭。 设想有一个应用,它只有在完成初始化设置(例如加载配置数据或建立数据库连接)之后才处理请求。 你可以在设置进行期间创建一个处于关闭状态的 Latch。 任何到达的请求(以 Fiber 表示)都会在 Latch 处等待,直到它被打开。 一旦设置完成,你调用 `latch.open`,请求便得以继续。 ## Latch 接口 `Latch` 包含若干操作,让你能够控制并观察它的状态: | 操作 | 说明 | | ---------- | -------------------------------------------------------------------------------------------------------- | | `whenOpen` | 仅当 Latch 处于打开状态时才运行给定的 effect;否则,等待直到它被打开。 | | `open` | 打开 Latch,让所有正在等待的 Fiber 得以继续。 | | `close` | 关闭 Latch,使 Fiber 在之后到达该 Latch 时进行等待。 | | `await` | 挂起当前 Fiber,直到 Latch 被打开。如果 Latch 已经处于打开状态,则立即返回。 | | `release` | 让正在等待的 Fiber 继续执行,但不会永久打开 Latch。 | ## 创建 Latch 使用 `Effect.makeLatch` 函数并传入一个布尔值,即可创建一个处于打开或关闭状态的 Latch。默认值为 `false`,也就是说它初始处于关闭状态。 **示例**(创建并使用 Latch) 在这个示例中,Latch 初始处于关闭状态。一个 Fiber 仅在 Latch 打开时才输出 “open sesame” 日志。等待一秒之后,Latch 被打开,该 Fiber 随之被释放: ```ts import { Console, Effect } from "effect" // A generator function that demonstrates latch usage const program = Effect.gen(function* () { // Create a latch, starting in the closed state const latch = yield* Effect.makeLatch() // Fork a fiber that logs "open sesame" only when the latch is open const fiber = yield* Console.log("open sesame").pipe( latch.whenOpen, // Waits for the latch to open Effect.fork, // Fork the effect into a new fiber ) // Wait for 1 second yield* Effect.sleep("1 second") // Open the latch, releasing the fiber yield* latch.open // Wait for the forked fiber to finish yield* fiber.await }) Effect.runFork(program) // Output: open sesame (after 1 second) ``` ## Latch 与 Semaphore 的对比 当你有一个一次性的事件或条件来决定 Fiber 能否继续执行时,Latch 是合适的选择。例如,你可以用 Latch 阻塞所有 Fiber,直到某个设置步骤完成,然后再打开 Latch,让所有 Fiber 继续执行。 而带一个锁的 [semaphore](/docs/v3/concurrency/semaphore/)(通常称为 binary semaphore 或 mutex)通常用于互斥:它确保同一时刻只有一个 Fiber 访问共享资源或代码段。一旦某个 Fiber 获取了锁,在锁被释放之前,其他 Fiber 都无法进入受保护的区域。 简而言之: - 如果你要用某个特定事件来闸控一组 Fiber(“在这里等待,直到条件成立”),请使用 **Latch**。 - 如果你需要确保同一时刻只有一个 Fiber 处于临界区或使用共享资源,请使用 **Semaphore(仅带一个锁)**。 --- # PubSub > 在 Effect 中使用 PubSub,轻松实现消息广播与异步通信。 `PubSub` 是一个异步消息中枢,发布者发送的消息可以被当前所有订阅者接收。 与 [Queue](/docs/v3/concurrency/queue/) 不同——在 Queue 中每个值只会投递给一个消费者——`PubSub` 会把每条已发布的消息广播给所有订阅者。因此,在需要消息广播而非负载分发的场景中,`PubSub` 是理想之选。 ## 基本操作 `PubSub` 存储类型为 `A` 的消息,并提供两个基础操作: | API | 说明 | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PubSub.publish` | 向 `PubSub` 发送一条类型为 `A` 的消息,返回一个 effect,指示该消息是否发布成功。 | | `PubSub.subscribe` | 创建一个 scoped effect,用于订阅该 `PubSub`,并在作用域结束时自动取消订阅。订阅者通过 [Dequeue](/docs/v3/concurrency/queue/#dequeue) 接收消息,Dequeue 中保存着已发布的消息。 | **示例**(向多个订阅者发布消息) ```ts import { Effect, PubSub, Queue } from "effect" const program = Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) // Two subscribers const dequeue1 = yield* PubSub.subscribe(pubsub) const dequeue2 = yield* PubSub.subscribe(pubsub) // Publish a message to the pubsub yield* PubSub.publish(pubsub, "Hello from a PubSub!") // Each subscriber receives the message console.log("Subscriber 1: " + (yield* Queue.take(dequeue1))) console.log("Subscriber 2: " + (yield* Queue.take(dequeue2))) }), ) Effect.runFork(program) /* Output: Subscriber 1: Hello from a PubSub! Subscriber 2: Hello from a PubSub! */ ``` ## 创建 PubSub ### 有界 PubSub 有界 `PubSub` 在达到容量上限时会对发布者施加背压(back pressure),暂停后续发布,直到有可用空间为止。 背压能确保所有订阅者在订阅期间都能收到全部消息。不过,如果某个订阅者速度较慢,消息投递也会随之变慢。 **示例**(创建有界 PubSub) ```ts import { PubSub } from "effect" // Creates a bounded PubSub with a capacity of 2 const boundedPubSub = PubSub.bounded(2) ``` ### 丢弃式 PubSub 丢弃式 `PubSub` 在容量已满时会丢弃新值。如果消息被丢弃,`PubSub.publish` 操作会返回 `false`。 在丢弃式 pubsub 中,发布者可以继续发布新值,但不保证订阅者能收到所有消息。 **示例**(创建丢弃式 PubSub) ```ts import { PubSub } from "effect" // Creates a dropping PubSub with a capacity of 2 const droppingPubSub = PubSub.dropping(2) ``` ### 滑动式 PubSub 滑动式 `PubSub` 会移除最早的消息,为新消息腾出空间,从而确保发布永不阻塞。 滑动式 pubsub 能避免慢订阅者影响消息投递速率。不过,慢订阅者仍有漏掉部分消息的风险。 **示例**(创建滑动式 PubSub) ```ts import { PubSub } from "effect" // Creates a sliding PubSub with a capacity of 2 const slidingPubSub = PubSub.sliding(2) ``` ### 无界 PubSub 无界 `PubSub` 没有容量限制,因此发布总是立即成功。 无界 pubsub 保证所有订阅者都能收到全部消息,且不会拖慢消息投递。不过,如果消息的发布速度快于消费速度,它可以无限增长。 一般来说,除非你有特定的使用场景需要无界 pubsub,否则建议使用有界、丢弃式或滑动式 pubsub。 **示例** ```ts import { PubSub } from "effect" // Creates an unbounded PubSub with unlimited capacity const unboundedPubSub = PubSub.unbounded() ``` ## PubSub 上的操作符 ### publishAll `PubSub.publishAll` 函数让你可以一次性向 pubsub 发布多个值。 **示例**(发布多条消息) ```ts import { Effect, PubSub, Queue } from "effect" const program = Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) const dequeue = yield* PubSub.subscribe(pubsub) yield* PubSub.publishAll(pubsub, ["Message 1", "Message 2"]) console.log(yield* Queue.takeAll(dequeue)) }), ) Effect.runFork(program) /* Output: { _id: 'Chunk', values: [ 'Message 1', 'Message 2' ] } */ ``` ### capacity / size 你可以分别用 `PubSub.capacity` 和 `PubSub.size` 查看 pubsub 的容量与当前大小。 注意,`PubSub.capacity` 返回一个 `number`,因为容量在 pubsub 创建时就已设定,之后不会再改变。 相比之下,由于 pubsub 中消息的数量会随时间变化,`PubSub.size` 返回一个 effect,用于获取 pubsub 的当前大小。 **示例**(获取 PubSub 的容量与大小) ```ts import { Effect, PubSub } from "effect" const program = Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) console.log(`capacity: ${PubSub.capacity(pubsub)}`) console.log(`size: ${yield* PubSub.size(pubsub)}`) }) Effect.runFork(program) /* Output: capacity: 2 size: 0 */ ``` ### 关闭 PubSub 要关闭 pubsub,请使用 `PubSub.shutdown`。你也可以用 `PubSub.isShutdown` 检查它是否已关闭,或用 `PubSub.awaitShutdown` 等待关闭完成。关闭 pubsub 还会终止所有关联的队列,确保关闭信号被有效传达。 ## PubSub 作为 Enqueue `PubSub` 的操作符与 [Queue](/docs/v3/concurrency/queue/) 类似,主要区别在于用 `PubSub.publish` 和 `PubSub.subscribe` 代替了 `Queue.offer` 和 `Queue.take`。如果你已经熟悉 `Queue` 的用法,那么 `PubSub` 对你来说会很容易上手。 本质上,`PubSub` 可以被看作一个只允许写入的 `Enqueue`: ```ts import type { Queue } from "effect" interface PubSub extends Queue.Enqueue {} ``` 这里的 `Enqueue` 类型指的是只接受入队操作(enqueue,即写入)的队列。任何在这里入队的值都会被发布到 pubsub,而 shutdown 之类的操作也会影响该 pubsub。 这种设计让 `PubSub` 非常灵活,你可以在任何需要一个只接受已发布值的 `Enqueue` 的地方使用它。 --- # Queue > 了解如何使用 Effect 的 Queue,以内置背压实现轻量、类型安全且异步的工作流。 `Queue` 是一个轻量级的内存队列,内置背压(back pressure),能够以异步、纯函数式且类型安全的方式处理数据。 ## 基本操作 `Queue` 存储类型为 `A` 的值,并提供两个基础操作: | API | 说明 | | ------------- | --------------------------------------- | | `Queue.offer` | 向队列中添加一个类型为 `A` 的值。 | | `Queue.take` | 移除并返回队列中最旧的值。 | **示例**(添加并取出一个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { // Creates a bounded queue with capacity 100 const queue = yield* Queue.bounded(100) // Adds 1 to the queue yield* Queue.offer(queue, 1) // Retrieves and removes the oldest value const value = yield* Queue.take(queue) return value }) Effect.runPromise(program).then(console.log) // Output: 1 ``` ## 创建 Queue Queue 可以是**有界的**(带有指定容量),也可以是**无界的**(没有上限)。不同类型的队列在达到容量上限时,对新值的处理方式各不相同。 ### 有界 Queue 有界队列在已满时会施加背压,也就是说,任何 `Queue.offer` 操作都会挂起,直到有可用空间为止。 **示例**(创建有界 Queue) ```ts import { Queue } from "effect" // Creating a bounded queue with a capacity of 100 const boundedQueue = Queue.bounded(100) ``` ### 丢弃式 Queue 丢弃式队列在队列已满时会丢弃新值。 **示例**(创建丢弃式 Queue) ```ts import { Queue } from "effect" // Creating a dropping queue with a capacity of 100 const droppingQueue = Queue.dropping(100) ``` ### 滑动式 Queue 滑动式队列在达到容量上限时会移除旧值,为新值腾出空间。 **示例**(创建滑动式 Queue) ```ts import { Queue } from "effect" // Creating a sliding queue with a capacity of 100 const slidingQueue = Queue.sliding(100) ``` ### 无界 Queue 无界队列没有容量限制,因此可以不受约束地添加新值。 **示例**(创建无界 Queue) ```ts import { Queue } from "effect" // Creates an unbounded queue without a capacity limit const unboundedQueue = Queue.unbounded() ``` ## 向 Queue 添加元素 ### offer 使用 `Queue.offer` 向队列中添加值。 **示例**(添加单个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds 1 to the queue yield* Queue.offer(queue, 1) }) ``` 使用带背压的队列时,如果队列已满,`Queue.offer` 会挂起。为了避免阻塞主 Fiber,你可以把 `Queue.offer` 操作 fork 出去。 **示例**(用 `Effect.fork` 处理已满的队列) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(1) // Fill the queue with one item yield* Queue.offer(queue, 1) // Attempting to add a second item will suspend as the queue is full const fiber = yield* Effect.fork(Queue.offer(queue, 2)) // Empties the queue to make space yield* Queue.take(queue) // Joins the fiber, completing the suspended offer yield* Fiber.join(fiber) // Returns the size of the queue after additions return yield* Queue.size(queue) }) Effect.runPromise(program).then(console.log) // Output: 1 ``` ### offerAll 你也可以用 `Queue.offerAll` 一次性添加多个元素。 **示例**(添加多个元素) ```ts import { Effect, Queue, Array } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) const items = Array.range(1, 10) // Adds all items to the queue at once yield* Queue.offerAll(queue, items) // Returns the size of the queue after additions return yield* Queue.size(queue) }) Effect.runPromise(program).then(console.log) // Output: 10 ``` ## 从 Queue 消费元素 ### take `Queue.take` 操作会从队列中移除并返回最旧的元素。如果队列为空,`Queue.take` 会挂起,直到有元素被添加时才恢复。为避免阻塞,你可以把 `Queue.take` 操作 fork 到一个新的 Fiber 中。 **示例**(在 Fiber 中等待一个元素) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // This take operation will suspend because the queue is empty const fiber = yield* Effect.fork(Queue.take(queue)) // Adds an item to the queue yield* Queue.offer(queue, "something") // Joins the fiber to get the result of the take operation const value = yield* Fiber.join(fiber) return value }) Effect.runPromise(program).then(console.log) // Output: something ``` ### poll 若想在不挂起的情况下取出队列的第一个元素,请使用 `Queue.poll`。如果队列为空,`Queue.poll` 返回 `None`;如果队列中有元素,它会将该元素包装在 `Some` 中。 **示例**(轮询一个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 10) yield* Queue.offer(queue, 20) // Retrieves the first item if available const head = yield* Queue.poll(queue) return head }) Effect.runPromise(program).then(console.log) /* Output: { _id: "Option", _tag: "Some", value: 10 } */ ``` ### takeUpTo 要取出多个元素,请使用 `Queue.takeUpTo`,它会返回最多达到指定数量的元素。 如果元素数量不足,它会返回所有当前可用的元素,而不会继续等待。 当不需要精确数量的元素时,这个函数对批处理特别有用。它能确保程序利用当前可用的数据继续工作。 如果你需要等待精确数量的元素再继续,可以考虑使用 [takeN](#taken)。 **示例**(最多取出 N 个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) yield* Queue.offer(queue, 3) // Retrieves up to 2 items const chunk = yield* Queue.takeUpTo(queue, 2) console.log(chunk) return "some result" }) Effect.runPromise(program).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2 ] } some result */ ``` ### takeN 从队列中取出指定数量的元素。如果队列中的元素不足,该操作会挂起,直到所需数量的元素可用为止。 在每次处理都需要精确数量元素的场景中,这个函数很有用:它能确保在该批次凑齐之前,操作不会继续。 **示例**(取出固定数量的元素) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { // Create a queue that can hold up to 100 elements const queue = yield* Queue.bounded(100) // Fork a fiber that attempts to take 3 items from the queue const fiber = yield* Effect.fork( Effect.gen(function* () { console.log("Attempting to take 3 items from the queue...") const chunk = yield* Queue.takeN(queue, 3) console.log(`Successfully took 3 items: ${chunk}`) }), ) // Offer only 2 items initially yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) console.log("Offered 2 items. The fiber is now waiting for the 3rd item...") // Simulate some delay yield* Effect.sleep("2 seconds") // Offer the 3rd item, which will unblock the takeN call yield* Queue.offer(queue, 3) console.log("Offered the 3rd item, which should unblock the fiber.") // Wait for the fiber to finish yield* Fiber.join(fiber) return "some result" }) Effect.runPromise(program).then(console.log) /* Output: Offered 2 items. The fiber is now waiting for the 3rd item... Attempting to take 3 items from the queue... Offered the 3rd item, which should unblock the fiber. Successfully took 3 items: { "_id": "Chunk", "values": [ 1, 2, 3 ] } some result */ ``` ### takeAll 要一次性取出队列中的所有元素,请使用 `Queue.takeAll`。该操作会立即完成:如果队列为空,则返回空集合。 **示例**(取出所有元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 10) yield* Queue.offer(queue, 20) yield* Queue.offer(queue, 30) // Retrieves all items from the queue const chunk = yield* Queue.takeAll(queue) return chunk }) Effect.runPromise(program).then(console.log) /* Output: { _id: "Chunk", values: [ 10, 20, 30 ] } */ ``` ## 关闭 Queue ### shutdown `Queue.shutdown` 操作允许你中断当前所有挂起在 `offer*` 或 `take*` 操作上的 Fiber。该操作还会清空队列,并使之后任何 `offer*` 与 `take*` 调用立即终止。 **示例**(关闭 Queue 时中断 Fiber) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(3) // Forks a fiber that waits to take an item from the queue const fiber = yield* Effect.fork(Queue.take(queue)) // Shuts down the queue, interrupting the fiber yield* Queue.shutdown(queue) // Joins the interrupted fiber yield* Fiber.join(fiber) }) ``` ### awaitShutdown `Queue.awaitShutdown` 操作可用于在队列关闭时运行一个 effect。它会等待直到队列被关闭;如果队列已经关闭,则会立即恢复。 **示例**(等待 Queue 关闭) ```ts import { Effect, Queue, Fiber, Console } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(3) // Forks a fiber to await queue shutdown and log a message const fiber = yield* Effect.fork( Queue.awaitShutdown(queue).pipe( Effect.andThen(Console.log("shutting down")), ), ) // Shuts down the queue, triggering the await in the fiber yield* Queue.shutdown(queue) yield* Fiber.join(fiber) }) Effect.runPromise(program) // Output: shutting down ``` ## 只允许 offer / 只允许 take 的 Queue 有时,你可能希望代码的某些部分只能向队列添加值(`Enqueue`),或者只能从队列取出值(`Dequeue`)。Effect 提供了接口来强制约束这些特定的能力。 ### Enqueue 所有向队列添加值的方法都由 `Enqueue` 接口定义。这样就把队列限制为只能执行 offer 操作。 **示例**(把 Queue 限制为只能执行 offer 操作) ```ts import { Queue } from "effect" const send = (offerOnlyQueue: Queue.Enqueue, value: number) => { // This queue is restricted to offer operations only // Error: cannot use take on an offer-only queue // @errors: 2345 Queue.take(offerOnlyQueue) // Valid offer operation return Queue.offer(offerOnlyQueue, value) } ``` ### Dequeue 类似地,所有从队列取出值的方法都由 `Dequeue` 接口定义,它把队列限制为只能执行 take 操作。 **示例**(把 Queue 限制为只能执行 take 操作) ```ts import { Queue } from "effect" const receive = (takeOnlyQueue: Queue.Dequeue) => { // This queue is restricted to take operations only // Error: cannot use offer on a take-only queue // @errors: 2345 Queue.offer(takeOnlyQueue, 1) // Valid take operation return Queue.take(takeOnlyQueue) } ``` `Queue` 类型同时组合了 `Enqueue` 和 `Dequeue`,因此你可以轻松地把它传给代码的不同部分,并按需只暴露 `Enqueue` 或 `Dequeue` 的行为。 **示例**(同时使用只 offer 和只 take 的 Queue) ```ts import { Effect, Queue } from "effect" const send = (offerOnlyQueue: Queue.Enqueue, value: number) => { return Queue.offer(offerOnlyQueue, value) } const receive = (takeOnlyQueue: Queue.Dequeue) => { return Queue.take(takeOnlyQueue) } const program = Effect.gen(function* () { const queue = yield* Queue.unbounded() // Add values to the queue yield* send(queue, 1) yield* send(queue, 2) // Retrieve values from the queue console.log(yield* receive(queue)) console.log(yield* receive(queue)) }) Effect.runFork(program) /* Output: 1 2 */ ``` --- # Semaphore > 学习在 Effect 中使用 semaphore,精确控制并发、管理资源访问,并高效协调异步任务。 semaphore 是一种同步机制,用于管理对共享资源的访问。在 Effect 中,semaphore 可以帮助控制资源访问,或在异步、并发操作中协调任务。 semaphore 就像一种通用化的互斥锁(mutex),它允许一定数量的 **permit**(许可)被并发地获取和释放。permit 就像票据,让任务或 Fiber 以受控的方式访问共享资源。当没有可用 permit 时,试图获取 permit 的任务会一直等待,直到有 permit 被释放。 ## 创建 Semaphore `Effect.makeSemaphore` 函数会用指定数量的 permit 初始化一个 semaphore。 每个 permit 允许一个任务并发地访问资源或执行操作,而多个 permit 则可以实现可配置的并发级别。 **示例**(创建一个带 3 个 permit 的 Semaphore) ```ts import { Effect } from "effect" // Create a semaphore with 3 permits const mutex = Effect.makeSemaphore(3) ``` ## withPermits `withPermits` 方法允许你指定运行某个 effect 所需的 permit 数量。一旦所需的 permit 可用,它就会运行该 effect,并在任务完成时自动释放这些 permit。 **示例**(用一个 permit 的 Semaphore 强制任务串行执行) 在这个示例中,三个任务被并发启动,但它们会串行执行,因为只有一个 permit 的 semaphore 一次只允许一个任务继续执行。 ```ts import { Effect } from "effect" const task = Effect.gen(function* () { yield* Effect.log("start") yield* Effect.sleep("2 seconds") yield* Effect.log("end") }) const program = Effect.gen(function* () { const mutex = yield* Effect.makeSemaphore(1) // Wrap the task to require one permit, forcing sequential execution const semTask = mutex.withPermits(1)(task).pipe(Effect.withLogSpan("elapsed")) // Run 3 tasks concurrently, but they execute sequentially // due to the one-permit semaphore yield* Effect.all([semTask, semTask, semTask], { concurrency: "unbounded", }) }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message=start elapsed=3ms timestamp=... level=INFO fiber=#1 message=end elapsed=2010ms timestamp=... level=INFO fiber=#2 message=start elapsed=2012ms timestamp=... level=INFO fiber=#2 message=end elapsed=4017ms timestamp=... level=INFO fiber=#3 message=start elapsed=4018ms timestamp=... level=INFO fiber=#3 message=end elapsed=6026ms */ ``` **示例**(使用多个 permit 控制并发任务的执行) 在这个示例中,我们创建一个带五个 permit 的 semaphore,并使用 `withPermits(n)` 为每个任务分配不同数量的 permit: ```ts import { Effect } from "effect" const program = Effect.gen(function* () { const mutex = yield* Effect.makeSemaphore(5) const tasks = [1, 2, 3, 4, 5].map((n) => mutex .withPermits(n)(Effect.delay(Effect.log(`process: ${n}`), "2 seconds")) .pipe(Effect.withLogSpan("elapsed")), ) yield* Effect.all(tasks, { concurrency: "unbounded" }) }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message="process: 1" elapsed=2011ms timestamp=... level=INFO fiber=#2 message="process: 2" elapsed=2017ms timestamp=... level=INFO fiber=#3 message="process: 3" elapsed=4020ms timestamp=... level=INFO fiber=#4 message="process: 4" elapsed=6025ms timestamp=... level=INFO fiber=#5 message="process: 5" elapsed=8034ms */ ``` --- # 配置 > 使用内置类型、灵活的提供者,以及默认值、校验与脱敏等高级特性,高效管理应用配置。 配置是任何云原生应用都不可或缺的一环。Effect 为配置提供者提供了便捷的接口,从而简化了配置管理的过程。 Effect 中的配置前端让生态库和应用能够以声明式的方式描述自己的配置需求。它把复杂的任务交给 `ConfigProvider` 处理,而 `ConfigProvider` 可以由第三方库提供。 Effect 自带一个简单直接的默认 `ConfigProvider`,它从环境变量中读取配置数据。这个默认 provider 可以在开发阶段使用,也可以作为迁移到更高级的配置提供者之前的起点。 要让应用变得可配置,我们需要理解三个基本要素: - **Config 描述**:我们使用 `Config` 的实例来描述配置数据。如果配置数据很简单,例如 `string`、`number` 或 `boolean`,可以使用 `Config` 模块提供的内置函数。对于 [HostPort](#custom-configuration-types) 这类更复杂的数据类型,我们可以组合基础 config 来创建自定义的配置描述。 - **Config 前端**:我们利用 `Config` 的实例来加载该实例所描述的配置数据(`Config` 本身就是一个 effect)。这个过程会借助当前的 `ConfigProvider` 来读取配置。 - **Config 后端**:`ConfigProvider` 是管理配置加载过程的底层引擎。Effect 自带一个默认的 config provider,作为其默认服务的一部分。这个默认 provider 从环境变量中读取配置数据。如果想使用自定义的 config provider,可以利用 `Effect.withConfigProvider` API 来相应地配置 Effect 运行时。 ## 基础配置类型 Effect 为配置值提供了若干内置类型,开箱即用: | 类型 | 说明 | | ---------- | ------------------------------------------------------------------- | | `string` | 将配置值读取为字符串。 | | `number` | 将值读取为浮点数。 | | `boolean` | 将值读取为布尔值(`true` 或 `false`)。 | | `integer` | 将值读取为整数。 | | `date` | 将值解析为 `Date` 对象。 | | `literal` | 读取一个固定的字面量(\*)。 | | `logLevel` | 将值读取为 [LogLevel](/docs/v3/observability/logging/#log-levels)。 | | `duration` | 将值解析为时间长度。 | | `redacted` | 读取**敏感值**,确保它在被记录到日志时受到保护。 | | `url` | 将值解析为合法的 URL。 | (\*) `string | number | boolean | null | bigint` **示例**(加载环境变量) 下面是一个示例,用环境变量 `HOST` 和 `PORT` 来加载基础配置: ```ts import { Effect, Config } from "effect" // Define a program that loads HOST and PORT configuration const program = Effect.gen(function* () { const host = yield* Config.string("HOST") // Read as a string const port = yield* Config.number("PORT") // Read as a number console.log(`Application started: ${host}:${port}`) }) Effect.runPromise(program) ``` 如果在没有设置所需环境变量的情况下运行: ```sh npx tsx primitives.ts ``` 你会看到一个提示配置缺失的错误: ```ansi [Error: (Missing data at HOST: "Expected HOST to exist in the process context")] { name: '(FiberFailure) Error', [Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure), [Symbol(effect/Runtime/FiberFailure/Cause)]: { _tag: 'Fail', error: { _op: 'MissingData', path: [ 'HOST' ], message: 'Expected HOST to exist in the process context' } } } ``` 要想成功运行该程序,请按下面所示设置环境变量: ```sh HOST=localhost PORT=8080 npx tsx primitives.ts ``` 输出: ```ansi Application started: localhost:8080 ``` ## 将 Config 与 Schema 一起使用 你可以使用 schema 来定义并解码配置值。 **示例**(解码配置值) ```ts import { Effect, Schema } from "effect" // Define a config that expects a string with at least 4 characters const myConfig = Schema.Config("Foo", Schema.String.pipe(Schema.minLength(4))) ``` 更多信息参见 [Schema.Config](/docs/v3/schema/effect-data-types/#config) 文档。 ## 提供默认值 有时你会遇到环境变量缺失、导致配置不完整的情况。为此,Effect 提供了 `Config.withDefault` 函数,允许你指定一个默认值。这种回退机制能确保即使必需的环境变量没有设置,应用也能继续运行。 **示例**(使用默认值) ```ts import { Effect, Config } from "effect" const program = Effect.gen(function* () { const host = yield* Config.string("HOST") // Use default 8080 if PORT is not set const port = yield* Config.number("PORT").pipe(Config.withDefault(8080)) console.log(`Application started: ${host}:${port}`) }) Effect.runPromise(program) ``` 只设置 `HOST` 环境变量来运行这个程序: ```sh HOST=localhost npx tsx defaults.ts ``` 得到如下输出: ```ansi Application started: localhost:8080 ``` 在这个例子中,尽管没有设置 `PORT` 环境变量,程序仍然会继续运行,并让端口使用默认值 `8080`。这确保了应用无需显式提供每一项配置也能正常工作。 ## 处理敏感值 有些配置值,比如 API 密钥,不应该被打印到日志中。 `Config.redacted` 函数用于安全地处理敏感信息。 它会解析配置值,并将其包装成 `Redacted` —— 一种专门用于保护机密信息的[数据类型](/docs/v3/data-types/redacted/)。 当你使用 `console.log` 打印 `Redacted` 值时,实际内容会保持隐藏,从而多了一层安全保障。要访问真实的值,必须显式使用 `Redacted.value`。 **示例**(保护敏感数据) ```ts import { Effect, Config, Redacted } from "effect" const program = Effect.gen(function* () { // ┌─── Redacted // ▼ const redacted = yield* Config.redacted("API_KEY") // Log the redacted value, which won't reveal the actual secret console.log(`Console output: ${redacted}`) // Access the real value using Redacted.value and log it console.log(`Actual value: ${Redacted.value(redacted)}`) }) Effect.runPromise(program) ``` 执行这个程序时: ```sh API_KEY=my-api-key tsx redacted.ts ``` 输出会是这样: ```ansi Console output: Actual value: my-api-key ``` 如你所见,使用 `console.log` 打印 `Redacted` 值时,输出是 ``,从而确保敏感数据始终被隐藏。不过,通过 `Redacted.value` 可以访问并显示真实的值(`"my-api-key"`),从而对机密信息实现受控访问。 ### 用 Redacted 包装 Config 默认情况下,当你向 `Config.redacted` 传入一个字符串时,它返回 `Redacted`。你也可以传入一个 `Config`(例如 `Config.number`),以确保只接受经过校验的值。这通过确保敏感数据在被脱敏之前先得到正确校验,多加了一层安全保障。 **示例**(脱敏并校验数值) ```ts import { Effect, Config, Redacted } from "effect" const program = Effect.gen(function* () { // Wrap the validated number configuration with redaction // // ┌─── Redacted // ▼ const redacted = yield* Config.redacted(Config.number("SECRET")) console.log(`Console output: ${redacted}`) console.log(`Actual value: ${Redacted.value(redacted)}`) }) Effect.runPromise(program) ``` ## 组合配置 Effect 提供了若干内置组合子,让你可以定义和操作配置。 这些组合子接受一个 `Config` 作为输入并产出另一个 `Config`,从而支持更复杂的配置结构。 | 组合子 | 说明 | | --------- | ------------------------------------------------------------------------------------------------------------------- | | `array` | 构造一个用于数组值的配置。 | | `chunk` | 构造一个用于值序列的配置。 | | `option` | 返回一个可选配置。如果数据缺失,结果将是 `None`;否则将是 `Some`。 | | `repeat` | 描述一个值序列,其中每个值都遵循给定 config 的结构。 | | `hashSet` | 构造一个用于值集合的配置。 | | `hashMap` | 构造一个用于键值映射的配置。 | 此外,还有三个用于特定场景的特殊组合子: | 组合子 | 说明 | | --------- | ----------------------------------------------------------------- | | `succeed` | 构造一个包含预定义值的 config。 | | `fail` | 构造一个以指定错误消息失败的 config。 | | `all` | 将多个配置组合成元组、结构体或参数列表。 | **示例**(使用 `array` 组合子) 下面的示例演示如何使用 `Config.array` 构造器把一个环境变量加载为字符串数组。 ```ts import { Config, Effect } from "effect" const program = Effect.gen(function* () { const config = yield* Config.array(Config.string(), "MYARRAY") console.log(config) }) Effect.runPromise(program) // Run: // MYARRAY=a,b,c,a npx tsx index.ts // Output: // [ 'a', 'b', 'c', 'a' ] ``` **示例**(使用 `hashSet` 组合子) ```ts import { Config, Effect } from "effect" const program = Effect.gen(function* () { const config = yield* Config.hashSet(Config.string(), "MYSET") console.log(config) }) Effect.runPromise(program) // Run: // MYSET=a,"b c",d,a npx tsx index.ts // Output: // { _id: 'HashSet', values: [ 'd', 'a', 'b c' ] } ``` **示例**(使用 `hashMap` 组合子) ```ts import { Config, Effect } from "effect" const program = Effect.gen(function* () { const config = yield* Config.hashMap(Config.string(), "MYMAP") console.log(config) }) Effect.runPromise(program) // Run: // MYMAP_A=a MYMAP_B=b npx tsx index.ts // Output: // { _id: 'HashMap', values: [ [ 'A', 'a' ], [ 'B', 'b' ] ] } ``` ## 操作符 Effect 提供了若干内置操作符来处理配置,让你可以根据需要操作和转换它们。 ### 转换操作符 这些操作符让你可以修改配置或校验其值: | 操作符 | 说明 | | ------------ | --------------------------------------------------------------------------------------------------------- | | `validate` | 确保配置满足特定条件,若不满足则返回校验错误。 | | `map` | 使用提供的函数转换配置的值。 | | `mapAttempt` | 与 `map` 类似,但会捕获函数抛出的任何错误并将其转换为校验错误。 | | `mapOrFail` | 类似 `map`,但函数可以失败。如果失败,结果就是一个校验错误。 | **示例**(使用 `validate` 操作符) ```ts import { Effect, Config } from "effect" const program = Effect.gen(function* () { // Load the NAME environment variable and validate its length const config = yield* Config.string("NAME").pipe( Config.validate({ message: "Expected a string at least 4 characters long", validation: (s) => s.length >= 4, }), ) console.log(config) }) Effect.runPromise(program) ``` 如果用一个无效的 `NAME` 值运行这个程序: ```sh NAME=foo npx tsx validate.ts ``` 输出将会是: ```ansi [Error: (Invalid data at NAME: "Expected a string at least 4 characters long")] { name: '(FiberFailure) Error', [Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure), [Symbol(effect/Runtime/FiberFailure/Cause)]: { _tag: 'Fail', error: { _op: 'InvalidData', path: [ 'NAME' ], message: 'Expected a string at least 4 characters long' } } } ``` ### 回退操作符 当你希望在出现错误或数据缺失时提供备选配置,回退操作符会很有用。这些操作符确保即使某些配置值不可用,程序仍然能够运行。 | 操作符 | 说明 | | ---------- | ----------------------------------------------------------------------------------------------------- | | `orElse` | 首先尝试使用主 config。如果它失败或缺失,就回退到另一个 config。 | | `orElseIf` | 与 `orElse` 类似,但只有当错误满足某个条件时才会切换到回退 config。 | **示例**(使用 `orElse` 进行回退) 在这个示例中,程序需要两个配置值:`A` 和 `B`。我们设置了两个配置提供者,每个只包含其中一个所需的值。使用 `orElse` 操作符,我们把这两个提供者组合起来,使程序能够同时取得 `A` 和 `B`。 ```ts import { Config, ConfigProvider, Effect } from "effect" // A program that requires two configurations: A and B const program = Effect.gen(function* () { const A = yield* Config.string("A") // Retrieve config A const B = yield* Config.string("B") // Retrieve config B console.log(`A: ${A}, B: ${B}`) }) // First provider has A but is missing B const provider1 = ConfigProvider.fromMap(new Map([["A", "A"]])) // Second provider has B but is missing A const provider2 = ConfigProvider.fromMap(new Map([["B", "B"]])) // Use `orElse` to fall back from provider1 to provider2 const provider = provider1.pipe(ConfigProvider.orElse(() => provider2)) Effect.runPromise(Effect.withConfigProvider(program, provider)) ``` 如果我们运行这个程序: ```sh npx tsx orElse.ts ``` 输出将会是: ```ansi A: A, B: B ``` ## 自定义配置类型 Effect 允许你使用[组合子](#combining-configurations)和[操作符](#operators)组合基础配置,从而为自定义类型定义配置。 例如,我们创建一个 `HostPort` 类,它有两个字段:`host` 和 `port`。 ```ts class HostPort { constructor( readonly host: string, readonly port: number, ) {} get url() { return `${this.host}:${this.port}` } } ``` 要为这个自定义类型定义配置,我们可以组合 `string` 和 `number` 的基础 config: **示例**(定义自定义配置) ```ts import { Config } from "effect" class HostPort { constructor( readonly host: string, readonly port: number, ) {} get url() { return `${this.host}:${this.port}` } } // Combine the configuration for 'HOST' and 'PORT' const both = Config.all([Config.string("HOST"), Config.number("PORT")]) // Map the configuration values into a HostPort instance const config = Config.map(both, ([host, port]) => new HostPort(host, port)) ``` 在这个示例中,`Config.all(configs)` 把两个基础配置 `Config` 和 `Config` 组合成一个 `Config<[string, number]>`。随后使用 `Config.map` 操作符把这些值转换成一个 `HostPort` 类的实例。 **示例**(使用自定义配置) ```ts import { Effect, Config } from "effect" class HostPort { constructor( readonly host: string, readonly port: number, ) {} get url() { return `${this.host}:${this.port}` } } // Combine the configuration for 'HOST' and 'PORT' const both = Config.all([Config.string("HOST"), Config.number("PORT")]) // Map the configuration values into a HostPort instance const config = Config.map(both, ([host, port]) => new HostPort(host, port)) // Main program that reads configuration and starts the application const program = Effect.gen(function* () { const hostPort = yield* config console.log(`Application started: ${hostPort.url}`) }) Effect.runPromise(program) ``` 运行这个程序时,它会尝试从环境变量中读取 `HOST` 和 `PORT` 的值: ```sh HOST=localhost PORT=8080 npx tsx App.ts ``` 如果成功,它会打印: ```ansi Application started: localhost:8080 ``` ## 嵌套配置 我们已经看到如何在顶层定义配置,无论是基本类型还是自定义类型。不过在有些情况下,你可能希望以更嵌套的方式组织配置,把它们按共同的命名空间归类,从而更清晰、更易管理。 例如,考虑下面这个 `ServiceConfig` 类型: ```ts class ServiceConfig { constructor( readonly host: string, readonly port: number, readonly timeout: number, ) {} get url() { return `${this.host}:${this.port}` } } ``` 如果在应用中使用这个配置,它会期望在顶层提供 `HOST`、`PORT` 和 `TIMEOUT` 这几个环境变量。但在许多情况下,你可能希望把配置组织到某个共享的命名空间下——例如把 `HOST` 和 `PORT` 归入 `SERVER` 命名空间,同时让 `TIMEOUT` 留在根层级。 为此,你可以使用 `Config.nested` 操作符,它允许你把配置值嵌套到指定的命名空间下。我们来修改前面的示例以体现这一点: ```ts import { Config } from "effect" class ServiceConfig { constructor( readonly host: string, readonly port: number, readonly timeout: number, ) {} get url() { return `${this.host}:${this.port}` } } const serverConfig = Config.all([Config.string("HOST"), Config.number("PORT")]) const serviceConfig = Config.map( Config.all([ // Read 'HOST' and 'PORT' from 'SERVER' namespace Config.nested(serverConfig, "SERVER"), // Read 'TIMEOUT' from the root namespace Config.number("TIMEOUT"), ]), ([[host, port], timeout]) => new ServiceConfig(host, port, timeout), ) ``` 现在,如果用这套配置运行应用,它会查找以下环境变量: - host 值使用 `SERVER_HOST` - port 值使用 `SERVER_PORT` - timeout 值使用 `TIMEOUT` 这种结构化的方式能让配置更有条理,尤其是在处理多个 service 或复杂应用时。 ## 在测试中模拟配置 在测试 service 时,有时需要为测试提供特定的配置。为了模拟这种情况,可以模拟读取这些值的配置后端。 你可以使用 `ConfigProvider.fromMap` 构造器做到这一点。该方法允许你从一个 `Map` 创建配置 provider,其中这个 map 表示配置数据。之后你只需调用 `Effect.withConfigProvider`,就可以用这个模拟 provider 替代默认 provider。 **示例**(为测试模拟 Config Provider) ```ts import { Config, ConfigProvider, Effect } from "effect" class HostPort { constructor( readonly host: string, readonly port: number, ) {} get url() { return `${this.host}:${this.port}` } } const config = Config.map( Config.all([Config.string("HOST"), Config.number("PORT")]), ([host, port]) => new HostPort(host, port), ) const program = Effect.gen(function* () { const hostPort = yield* config console.log(`Application started: ${hostPort.url}`) }) // Create a mock config provider using a map with test data const mockConfigProvider = ConfigProvider.fromMap( new Map([ ["HOST", "localhost"], ["PORT", "8080"], ]), ) // Run the program using the mock config provider Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider)) // Output: Application started: localhost:8080 ``` 这种方式有助于编写不依赖外部环境变量的隔离测试,确保测试在模拟配置下始终一致地运行。 ### 处理嵌套配置值 对于更复杂的配置,键常常是嵌套的。默认情况下,`ConfigProvider.fromMap` 使用 `.` 作为嵌套键的分隔符。 **示例**(提供嵌套配置值) ```ts import { Config, ConfigProvider, Effect } from "effect" const config = Config.nested(Config.number("PORT"), "SERVER") const program = Effect.gen(function* () { const port = yield* config console.log(`Server is running on port ${port}`) }) // Mock configuration using '.' as the separator for nested keys const mockConfigProvider = ConfigProvider.fromMap( new Map([["SERVER.PORT", "8080"]]), ) Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider)) // Output: Server is running on port 8080 ``` ### 自定义路径分隔符 如果你的配置数据使用了别的分隔符(例如 `_`),可以通过 `ConfigProvider.fromMap` 的 `pathDelim` 选项更改分隔符。 **示例**(使用自定义路径分隔符) ```ts import { Config, ConfigProvider, Effect } from "effect" const config = Config.nested(Config.number("PORT"), "SERVER") const program = Effect.gen(function* () { const port = yield* config console.log(`Server is running on port ${port}`) }) // Mock configuration using '_' as the separator const mockConfigProvider = ConfigProvider.fromMap( new Map([["SERVER_PORT", "8080"]]), { pathDelim: "_", }, ) Effect.runPromise(Effect.withConfigProvider(program, mockConfigProvider)) // Output: Server is running on port 8080 ``` ## ConfigProvider Effect 中的 `ConfigProvider` 模块允许应用从不同的来源加载配置值。默认 provider 从环境变量读取,但你可以在需要时自定义其行为。 ### 从环境变量加载配置 `ConfigProvider.fromEnv` 函数会创建一个 `ConfigProvider`,它从环境变量加载值。除非另外指定,否则它就是 Effect 使用的默认 provider。 如果你的应用需要为嵌套配置键使用自定义分隔符,可以相应地配置 `ConfigProvider.fromEnv`。 **示例**(更改路径分隔符) 下面这个示例修改了环境变量的路径分隔符(`"__"`)和序列分隔符(`"|"`)。 ```ts import { Config, ConfigProvider, Effect } from "effect" const program = Effect.gen(function* () { // Read SERVER_HOST and SERVER_PORT as nested configuration values const port = yield* Config.nested(Config.number("PORT"), "SERVER") const host = yield* Config.nested(Config.string("HOST"), "SERVER") console.log(`Application started: ${host}:${port}`) }) Effect.runPromise( Effect.withConfigProvider( program, // Custom delimiters ConfigProvider.fromEnv({ pathDelim: "__", seqDelim: "|" }), ), ) ``` 为匹配自定义分隔符(`"__"`),请像这样设置环境变量: ```sh SERVER__HOST=localhost SERVER__PORT=8080 npx tsx index.ts ``` 输出: ```ansi Application started: localhost:8080 ``` ### 从 JSON 加载配置 `ConfigProvider.fromJson` 函数会创建一个 `ConfigProvider`,它从 JSON 对象加载值。 **示例**(从 JSON 读取嵌套配置) ```ts import { Config, ConfigProvider, Effect } from "effect" const program = Effect.gen(function* () { // Read SERVER_HOST and SERVER_PORT as nested configuration values const port = yield* Config.nested(Config.number("PORT"), "SERVER") const host = yield* Config.nested(Config.string("HOST"), "SERVER") console.log(`Application started: ${host}:${port}`) }) Effect.runPromise( Effect.withConfigProvider( program, ConfigProvider.fromJson( JSON.parse(`{"SERVER":{"PORT":8080,"HOST":"localhost"}}`), ), ), ) // Output: Application started: localhost:8080 ``` ### 使用嵌套配置命名空间 `ConfigProvider.nested` 函数允许把**配置值分组**到某个命名空间下。当需要按逻辑组织各项设置时,这很有帮助,比如把与 `SERVER` 相关的值归到一组。 **示例**(使用嵌套命名空间) ```ts import { Config, ConfigProvider, Effect } from "effect" const program = Effect.gen(function* () { const port = yield* Config.number("PORT") // Reads SERVER_PORT const host = yield* Config.string("HOST") // Reads SERVER_HOST console.log(`Application started: ${host}:${port}`) }) Effect.runPromise( Effect.withConfigProvider( program, ConfigProvider.fromEnv().pipe( // Uses SERVER as a namespace ConfigProvider.nested("SERVER"), ), ), ) ``` 由于我们把 `"SERVER"` 定义为命名空间,环境变量必须遵循这种形式: ```sh SERVER_HOST=localhost SERVER_PORT=8080 npx tsx index.ts ``` 输出: ```ansi Application started: localhost:8080 ``` ### 把配置键转换为常量命名(constant case) `ConfigProvider.constantCase` 函数会把所有配置键转换为常量命名(大写,用下划线连接)。当需要让环境变量适配不同的命名约定时,这很有用。 **示例**(对环境变量使用 `constantCase`) ```ts import { Config, ConfigProvider, Effect } from "effect" const program = Effect.gen(function* () { const port = yield* Config.number("Port") // Reads PORT const host = yield* Config.string("Host") // Reads HOST console.log(`Application started: ${host}:${port}`) }) Effect.runPromise( Effect.withConfigProvider( program, // Convert keys to constant case ConfigProvider.fromEnv().pipe(ConfigProvider.constantCase), ), ) ``` 由于 `constantCase` 会把 `"Port"` 转换为 `"PORT"`、把 `"Host"` 转换为 `"HOST"`,因此环境变量必须按下面这样设置: ```sh HOST=localhost PORT=8080 npx tsx index.ts ``` 输出: ```ansi Application started: localhost:8080 ``` ## 废弃项 ### Secret _自 3.3.0 版本起废弃:今后处理敏感信息请使用 [Config.redacted](#handling-sensitive-values)。_ `Config.secret` 函数过去用于保护敏感信息,方式与 `Config.redacted` 类似。它把配置值包装成 `Secret` 类型,这种类型在记录日志时同样会隐藏细节,但允许通过 `Secret.value` 访问真实值。 **示例**(使用已废弃的 `Config.secret`) ```ts import { Effect, Config, Secret } from "effect" const program = Effect.gen(function* () { const secret = yield* Config.secret("API_KEY") // Log the secret value, which won't reveal the actual secret console.log(`Console output: ${secret}`) // Access the real value using Secret.value and log it console.log(`Actual value: ${Secret.value(secret)}`) }) Effect.runPromise(program) ``` 执行这个程序时: ```sh API_KEY=my-api-key tsx secret.ts ``` 输出结果如下: ```ansi Console output: Secret() Actual value: my-api-key ``` --- # BigDecimal > BigDecimal 数据类型用于表示任意精度的十进制数。 在 JavaScript 中,数字通常以 64 位浮点数的形式存储。浮点数虽然快速且通用,但会引入细小的舍入误差。这些误差在日常使用中往往难以察觉,但在金融或统计等领域却可能成为问题:细小的不精确随着时间累积,可能导致越来越大的偏差。 通过使用 BigDecimal 模块,你可以避免这些问题,并以更高的精度进行计算。 `BigDecimal` 数据类型可以表示小数位数很多的实数,从而避免浮点运算中常见的错误(例如 0.1 + 0.2 ≠ 0.3)。 ## BigDecimal 的工作原理 `BigDecimal` 用两个组成部分来表示一个数字: 1. `value`:一个 `BigInt`,存储数字的各位数字。 2. `scale`:一个 64 位整数,决定小数点的位置。 `BigDecimal` 所表示的数值按如下公式计算:value × 10-scale。 - 如果 `scale` 为零或正数,它表示小数点右侧的位数。 - 如果 `scale` 为负数,则将 `value` 乘以 10 的 `scale` 相反数次幂。 例如: - `value = 12345n`、`scale = 2` 的 `BigDecimal` 表示 `123.45`。 - `value = 12345n`、`scale = -2` 的 `BigDecimal` 表示 `1234500`。 最大精度很大,但并非无限,限制为 263 位小数。 ## 创建一个 BigDecimal ### make `make` 函数通过指定一个 `BigInt` 数值和一个 scale 来创建 `BigDecimal`。`scale` 决定小数点右侧的位数。 **示例**(使用指定的 scale 创建 BigDecimal) ```ts import { BigDecimal } from "effect" // Create a BigDecimal from a BigInt (1n) with a scale of 2 const decimal = BigDecimal.make(1n, 2) console.log(decimal) // Output: { _id: 'BigDecimal', value: '1', scale: 2 } // Convert the BigDecimal to a string console.log(String(decimal)) // Output: BigDecimal(0.01) // Format the BigDecimal as a standard decimal string console.log(BigDecimal.format(decimal)) // Output: 0.01 // Convert the BigDecimal to exponential notation console.log(BigDecimal.toExponential(decimal)) // Output: 1e-2 ``` ### fromBigInt `fromBigInt` 函数根据 `bigint` 创建 `BigDecimal`。`scale` 默认为 `0`,表示该数字没有小数部分。 **示例**(从 BigInt 创建 BigDecimal) ```ts import { BigDecimal } from "effect" const decimal = BigDecimal.fromBigInt(10n) console.log(decimal) // Output: { _id: 'BigDecimal', value: '10', scale: 0 } ``` ### fromString 将数字字符串解析为 `BigDecimal`。返回 `Option`: - 字符串合法时返回 `Some(BigDecimal)`。 - 字符串非法时返回 `None`。 **示例**(将字符串解析为 BigDecimal) ```ts import { BigDecimal } from "effect" const decimal = BigDecimal.fromString("0.02") console.log(decimal) /* Output: { _id: 'Option', _tag: 'Some', value: { _id: 'BigDecimal', value: '2', scale: 2 } } */ ``` ### unsafeFromString `unsafeFromString` 函数是 `fromString` 的变体,当输入字符串非法时会抛出错误。仅当你确信输入始终合法时才使用它。 **示例**(不安全的字符串解析) ```ts import { BigDecimal } from "effect" const decimal = BigDecimal.unsafeFromString("0.02") console.log(decimal) // Output: { _id: 'BigDecimal', value: '2', scale: 2 } ``` ### unsafeFromNumber 根据 JavaScript 的 `number` 创建 `BigDecimal`。对于非有限数(`NaN`、`+Infinity` 或 `-Infinity`),会抛出 `RangeError`。 **示例**(不安全的数字解析) ```ts import { BigDecimal } from "effect" console.log(BigDecimal.unsafeFromNumber(123.456)) // Output: { _id: 'BigDecimal', value: '123456', scale: 3 } ``` ## 基本算术运算 BigDecimal 模块支持多种算术运算,它们能够保证精度,并避免标准 JavaScript 算术中常见的舍入误差。以下是受支持运算的列表: | 函数 | 说明 | | ----------------- | --------------------------------------------------------------------------------------------------------------- | | `sum` | 将两个 `BigDecimal` 值相加。 | | `subtract` | 从一个 `BigDecimal` 值中减去另一个。 | | `multiply` | 将两个 `BigDecimal` 值相乘。 | | `divide` | 将一个 `BigDecimal` 值除以另一个,返回 `Option`。 | | `unsafeDivide` | 将一个 `BigDecimal` 值除以另一个;若除数为零则抛出错误。 | | `negate` | 对 `BigDecimal` 值取负(即改变其符号)。 | | `remainder` | 返回一个 `BigDecimal` 值除以另一个的余数,结果为 `Option`。 | | `unsafeRemainder` | 返回一个 `BigDecimal` 值除以另一个的余数;若除数为零则抛出错误。 | | `sign` | 返回 `BigDecimal` 值的符号(`-1`、`0` 或 `1`)。 | | `abs` | 返回 `BigDecimal` 的绝对值。 | **示例**(使用 BigDecimal 执行基本算术运算) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.unsafeFromString("1.05") const dec2 = BigDecimal.unsafeFromString("2.10") // Addition console.log(String(BigDecimal.sum(dec1, dec2))) // Output: BigDecimal(3.15) // Multiplication console.log(String(BigDecimal.multiply(dec1, dec2))) // Output: BigDecimal(2.205) // Subtraction console.log(String(BigDecimal.subtract(dec2, dec1))) // Output: BigDecimal(1.05) // Division (safe, returns Option) console.log(BigDecimal.divide(dec2, dec1)) /* Output: { _id: 'Option', _tag: 'Some', value: { _id: 'BigDecimal', value: '2', scale: 0 } } */ // Division (unsafe, throws if divisor is zero) console.log(String(BigDecimal.unsafeDivide(dec2, dec1))) // Output: BigDecimal(2) // Negation console.log(String(BigDecimal.negate(dec1))) // Output: BigDecimal(-1.05) // Modulus (unsafe, throws if divisor is zero) console.log( String(BigDecimal.unsafeRemainder(dec2, BigDecimal.unsafeFromString("0.6"))), ) // Output: BigDecimal(0.3) ``` 使用 `BigDecimal` 进行算术运算有助于避免 JavaScript 中浮点数常见的精度问题。例如: **示例**(避免浮点误差) ```ts const dec1 = 1.05 const dec2 = 2.1 console.log(String(dec1 + dec2)) // Output: 3.1500000000000004 ``` ## 比较运算 `BigDecimal` 模块提供了多个用于比较小数值的函数。借助它们,你可以确定两个值的相对顺序、求最小值或最大值,并检查是否为正值、是否为整数等特定属性。 ### 比较函数 | 函数 | 说明 | | ------------------------ | ------------------------------------------------------------- | | `lessThan` | 检查第一个 `BigDecimal` 是否小于第二个。 | | `lessThanOrEqualTo` | 检查第一个 `BigDecimal` 是否小于或等于第二个。 | | `greaterThan` | 检查第一个 `BigDecimal` 是否大于第二个。 | | `greaterThanOrEqualTo` | 检查第一个 `BigDecimal` 是否大于或等于第二个。 | | `min` | 返回两个 `BigDecimal` 值中较小的那个。 | | `max` | 返回两个 `BigDecimal` 值中较大的那个。 | **示例**(比较两个 BigDecimal 值) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.unsafeFromString("1.05") const dec2 = BigDecimal.unsafeFromString("2.10") console.log(BigDecimal.lessThan(dec1, dec2)) // Output: true console.log(BigDecimal.lessThanOrEqualTo(dec1, dec2)) // Output: true console.log(BigDecimal.greaterThan(dec1, dec2)) // Output: false console.log(BigDecimal.greaterThanOrEqualTo(dec1, dec2)) // Output: false console.log(BigDecimal.min(dec1, dec2)) // Output: { _id: 'BigDecimal', value: '105', scale: 2 } console.log(BigDecimal.max(dec1, dec2)) // Output: { _id: 'BigDecimal', value: '210', scale: 2 } ``` ### 用于比较的谓词 该模块还包含用于检查 `BigDecimal` 特定属性的谓词: | 谓词 | 说明 | | ------------ | ------------------------------------------------ | | `isZero` | 检查该值是否恰好为零。 | | `isPositive` | 检查该值是否为正数。 | | `isNegative` | 检查该值是否为负数。 | | `between` | 检查该值是否落在指定范围内(含边界)。 | | `isInteger` | 检查该值是否为整数(即没有小数部分)。 | **示例**(检查 BigDecimal 值的符号与属性) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.unsafeFromString("1.05") const dec2 = BigDecimal.unsafeFromString("-2.10") console.log(BigDecimal.isZero(BigDecimal.unsafeFromString("0"))) // Output: true console.log(BigDecimal.isPositive(dec1)) // Output: true console.log(BigDecimal.isNegative(dec2)) // Output: true console.log( BigDecimal.between({ minimum: BigDecimal.unsafeFromString("1"), maximum: BigDecimal.unsafeFromString("2"), })(dec1), ) // Output: true console.log( BigDecimal.isInteger(dec2), BigDecimal.isInteger(BigDecimal.fromBigInt(3n)), ) // Output: false true ``` ## 规范化与相等性 在某些情况下,两个 `BigDecimal` 值可能具有不同的内部表示,却仍然表示同一个数字。 例如,`1.05` 在内部可以用不同的 scale 表示,比如: - `105n`,scale 为 `2` - `1050n`,scale 为 `3` 为了保证一致性,你可以对 `BigDecimal` 进行规范化,以调整 scale 并去除末尾的零。 ### 规范化 `BigDecimal.normalize` 函数会调整 `BigDecimal` 的 scale,并消除其内部表示中不必要的末尾零。 **示例**(规范化 BigDecimal) ```ts import { BigDecimal } from "effect" const dec = BigDecimal.make(1050n, 3) console.log(BigDecimal.normalize(dec)) // Output: { _id: 'BigDecimal', value: '105', scale: 2 } ``` ### 相等性 若要检查两个 `BigDecimal` 值在数值上是否相等(无论其内部表示如何),请使用 `BigDecimal.equals` 函数。 **示例**(检查相等性) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.make(105n, 2) const dec2 = BigDecimal.make(1050n, 3) console.log(BigDecimal.equals(dec1, dec2)) // Output: true ``` --- # Cause > 使用 Effect 中的 Cause 进行全面的错误分析 —— 精确追踪失败、defect 与中断的细节。 [`Effect`](/docs/v3/getting-started/the-effect-type/) 类型在错误类型 `E` 上是多态的,这让处理任意期望的错误类型都很灵活。然而,关于失败往往还有更多信息,单靠错误类型 `E` 是捕获不到的。 为了解决这个问题,Effect 使用 `Cause` 数据类型来存储各种细节,例如: - 非预期的错误或 defect - 堆栈与执行轨迹 - Fiber 被中断的原因 Effect 严格保留所有与失败相关的信息,在 `Cause` 类型中存储错误上下文的完整图景。这种全面的做法让失败能够被精确地分析和处理,确保不丢失任何数据。 虽然 `Cause` 值通常不会被直接操作,但它们是 Effect 工作流中错误的底层表示,既能提供并发的错误细节,也能提供顺序的错误细节。需要时,这让你可以对错误做彻底的分析。 ## 创建 Cause 你可以使用 `Effect.failCause` 有意创建一个带有特定 cause 的 effect。 **示例**(定义带有不同 Cause 的 Effect) ```ts import { Effect, Cause } from "effect" // Define an effect that dies with an unexpected error // // ┌─── Effect // ▼ const die = Effect.failCause(Cause.die("Boom!")) // Define an effect that fails with an expected error // // ┌─── Effect // ▼ const fail = Effect.failCause(Cause.fail("Oh no!")) ``` 有些 cause 不会影响 effect 的错误类型,因此错误通道中会是 `never`: ```text ┌─── no error information ▼ Effect ``` 例如,`Cause.die` 不会为 effect 指定错误类型,而 `Cause.fail` 会,并据此设置错误通道的类型。 ## Cause 的变体 针对各种错误,存在若干种 cause。本节将逐一介绍这些 cause。 ### Empty `Empty` cause 表示没有任何错误。 ### Fail `Fail` cause 表示由类型为 `E` 的预期错误导致的失败。 ### Die `Die` cause 表示由 defect(即非预期或意料之外的错误)导致的失败。 ### Interrupt `Interrupt` cause 表示由 `Fiber` 中断导致的失败,并包含被中断的 `Fiber` 的 `FiberId`。 ### Sequential `Sequential` cause 把先后发生的两个 cause 组合在一起。 例如,在 `Effect.ensuring` 操作(类似于 `try-finally`)中,如果 `try` 和 `finally` 两段都失败,这两个错误会由一个 `Sequential` cause 按顺序表示出来。 **示例**(用 `Sequential` Cause 捕获顺序失败) ```ts import { Effect, Cause } from "effect" const program = Effect.failCause(Cause.fail("Oh no!")).pipe( Effect.ensuring(Effect.failCause(Cause.die("Boom!"))), ) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Sequential', left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' }, right: { _id: 'Cause', _tag: 'Die', defect: 'Boom!' } } } */ ``` ### Parallel `Parallel` cause 把并发发生的两个 cause 组合在一起。 在 Effect 程序中,两个操作可能并行运行,从而可能导致多个失败。当两个计算同时失败时,`Parallel` cause 就表示 effect 工作流中并发发生的错误。 **示例**(用 `Parallel` Cause 捕获并发失败) ```ts import { Effect, Cause } from "effect" const program = Effect.all( [ Effect.failCause(Cause.fail("Oh no!")), Effect.failCause(Cause.die("Boom!")), ], { concurrency: 2 }, ) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Parallel', left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' }, right: { _id: 'Cause', _tag: 'Die', defect: 'Boom!' } } } */ ``` ## 获取 Effect 的 Cause 要获取一个失败 effect 的 cause,请使用 `Effect.cause`。这让你可以检查或处理失败背后的确切原因。 **示例**(获取并检查失败的 Cause) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { const cause = yield* Effect.cause(Effect.fail("Oh no!")) console.log(cause) }) Effect.runPromise(program) /* Output: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' } */ ``` ## 类型守卫 要判断一个 `Cause` 的具体类型,可以使用 Cause 模块提供的 guard: - `Cause.isEmpty`:检查 cause 是否为空,即不存在任何错误。 - `Cause.isFailType`:识别表示预期失败的 cause。 - `Cause.isDie`:识别表示非预期 defect 的 cause。 - `Cause.isInterruptType`:识别与 Fiber 中断相关的 cause。 - `Cause.isSequentialType`:检查 cause 是否由顺序发生的错误组成。 - `Cause.isParallelType`:检查 cause 是否包含并发发生的错误。 **示例**(使用 Guard 识别 Cause 的类型) ```ts import { Cause } from "effect" const cause = Cause.fail(new Error("my message")) if (Cause.isFailType(cause)) { console.log(cause.error.message) // Output: my message } ``` 这些 guard 让你能准确识别 `Cause` 的类型,从而更容易在代码中处理各种错误情况。无论是应对预期失败、非预期 defect、中断还是复合错误,这些 guard 都提供了一种清晰的方法来评估和管理错误场景。 ## 模式匹配 `Cause.match` 函数提供了一种直接的方式来处理 `Cause` 的每一种情况。通过为每种可能的 cause 类型定义回调,你可以针对具体的错误场景做出自定义的响应。 **示例**(对不同 Cause 进行模式匹配) ```ts import { Cause } from "effect" const cause = Cause.parallel( Cause.fail(new Error("my fail message")), Cause.die("my die message"), ) console.log( Cause.match(cause, { onEmpty: "(empty)", onFail: (error) => `(error: ${error.message})`, onDie: (defect) => `(defect: ${defect})`, onInterrupt: (fiberId) => `(fiberId: ${fiberId})`, onSequential: (left, right) => `(onSequential (left: ${left}) (right: ${right}))`, onParallel: (left, right) => `(onParallel (left: ${left}) (right: ${right})`, }), ) /* Output: (onParallel (left: (error: my fail message)) (right: (defect: my die message)) */ ``` ## 美化输出 清晰易读的错误信息是高效调试的关键。`Cause.pretty` 函数以结构化的方式格式化错误信息,让你更容易理解失败的细节。 **示例**(使用 `Cause.pretty` 获得易读的错误信息) ```ts import { Cause, FiberId } from "effect" console.log(Cause.pretty(Cause.empty)) /* Output: All fibers interrupted without errors. */ console.log(Cause.pretty(Cause.fail(new Error("my fail message")))) /* Output: Error: my fail message ...stack trace... */ console.log(Cause.pretty(Cause.die("my die message"))) /* Output: Error: my die message */ console.log(Cause.pretty(Cause.interrupt(FiberId.make(1, 0)))) /* Output: All fibers interrupted without errors. */ console.log( Cause.pretty(Cause.sequential(Cause.fail("fail1"), Cause.fail("fail2"))), ) /* Output: Error: fail1 Error: fail2 */ ``` ## 提取失败与 Defect 要从 `Cause` 中专门收集失败或 defect,可以使用 `Cause.failures` 和 `Cause.defects`。这些函数让你只检查发生的预期错误或非预期 defect。 **示例**(从 Cause 中提取失败与 Defect) ```ts import { Effect, Cause } from "effect" const program = Effect.gen(function* () { const cause = yield* Effect.cause( Effect.all([ Effect.fail("error 1"), Effect.die("defect"), Effect.fail("error 2"), ]), ) console.log(Cause.failures(cause)) console.log(Cause.defects(cause)) }) Effect.runPromise(program) /* Output: { _id: 'Chunk', values: [ 'error 1' ] } { _id: 'Chunk', values: [] } */ ``` --- # Chunk > 了解 Chunk —— Effect 中高性能的不可变数据结构,提供拼接、切片与转换等高效操作。 `Chunk` 表示一个有序、不可变的值集合,其元素类型为 `A`。虽然它与数组类似,但 `Chunk` 提供了函数式的接口,并对某些用普通数组实现时开销很大的操作(例如反复拼接)做了优化。 ## 为什么使用 Chunk? - **不可变性**:普通 JavaScript 数组是可变的,而 `Chunk` 不同,它提供真正不可变的集合,防止数据在创建后被修改。这在并发编程场景中尤其有用,因为不可变性可以提升数据一致性。 - **高性能**:`Chunk` 为高效操作数组提供了专门的方法,例如追加单个元素或拼接多个 Chunk,使这些操作比普通 JavaScript 数组上的等价操作更快。 ## 创建 Chunk ### empty 使用 `Chunk.empty` 创建一个空的 `Chunk`。 **示例**(创建一个空 Chunk) ```ts import { Chunk } from "effect" // ┌─── Chunk // ▼ const chunk = Chunk.empty() ``` ### make 要创建包含特定值的 `Chunk`,请使用 `Chunk.make(...values)`。注意,得到的 chunk 在类型上被标记为非空。 **示例**(创建一个非空 Chunk) ```ts import { Chunk } from "effect" // ┌─── NonEmptyChunk // ▼ const chunk = Chunk.make(1, 2, 3) ``` ### fromIterable 你可以通过提供一个集合来创建 `Chunk`,既可以来自可迭代对象,也可以直接来自数组。 **示例**(从可迭代对象创建 Chunk) ```ts import { Chunk, List } from "effect" const fromArray = Chunk.fromIterable([1, 2, 3]) const fromList = Chunk.fromIterable(List.make(1, 2, 3)) ``` ### unsafeFromArray `Chunk.unsafeFromArray` 会直接基于数组创建 `Chunk`,且不进行克隆。这种方式通过避免复制数据的开销来提升性能,但需要谨慎使用,因为它绕过了通常的不可变性保证。 **示例**(直接从数组创建 Chunk) ```ts import { Chunk } from "effect" const chunk = Chunk.unsafeFromArray([1, 2, 3]) ``` ## 拼接 要将两个 `Chunk` 实例合并为一个,请使用 `Chunk.appendAll`。 **示例**(将两个 Chunk 合并为一个) ```ts import { Chunk } from "effect" // Concatenate two chunks with different types of elements // // ┌─── NonEmptyChunk // ▼ const chunk = Chunk.appendAll(Chunk.make(1, 2), Chunk.make("a", "b")) console.log(chunk) /* Output: { _id: 'Chunk', values: [ 1, 2, 'a', 'b' ] } */ ``` ## 丢弃 要从 `Chunk` 的开头移除元素,请使用 `Chunk.drop`,并指定要丢弃的元素数量。 **示例**(从开头丢弃元素) ```ts import { Chunk } from "effect" // Drops the first 2 elements from the Chunk const chunk = Chunk.drop(Chunk.make(1, 2, 3, 4), 2) ``` ## 比较 要检查两个 `Chunk` 实例是否相等,请使用 [`Equal.equals`](/docs/v3/trait/equal/)。该函数会逐个比较每个 `Chunk` 的内容,判断结构上是否相等。 **示例**(比较两个 Chunk) ```ts import { Chunk, Equal } from "effect" const chunk1 = Chunk.make(1, 2) const chunk2 = Chunk.make(1, 2, 3) console.log(Equal.equals(chunk1, chunk1)) // Output: true console.log(Equal.equals(chunk1, chunk2)) // Output: false console.log(Equal.equals(chunk1, Chunk.make(1, 2))) // Output: true ``` ## 转换 使用 `Chunk.toReadonlyArray` 可以把 `Chunk` 转换为 `ReadonlyArray`。得到的类型会随 `Chunk` 内容的不同而变化,用以区分空 chunk、非空 chunk 以及一般 chunk。 **示例**(将 Chunk 转换为 ReadonlyArray) ```ts import { Chunk } from "effect" // ┌─── readonly [number, ...number[]] // ▼ const nonEmptyArray = Chunk.toReadonlyArray(Chunk.make(1, 2, 3)) // ┌─── readonly never[] // ▼ const emptyArray = Chunk.toReadonlyArray(Chunk.empty()) declare const chunk: Chunk.Chunk // ┌─── readonly number[] // ▼ const array = Chunk.toReadonlyArray(chunk) ``` --- # Data > 使用 Effect 的 Data 模块定义不可变数据结构、确保相等性,并无缝管理错误。 Data 模块简化了在 TypeScript 中创建和处理数据结构的过程。它提供了用于**定义数据类型**、确保对象之间的**相等性**,以及对数据进行**哈希**以实现高效比较的工具。 ## 值相等性 Data 模块提供了用于创建数据类型的构造器,这些构造器内置了对相等性和哈希的支持,因此无需自定义实现。 这意味着,使用这些构造器创建的两个值,只要结构和值相同,就被视为相等。 ### struct 在普通 JavaScript 中,只有当两个对象引用的是完全相同的实例时,它们才被视为相等。 **示例**(用普通 JavaScript 比较两个对象) ```ts const alice = { name: "Alice", age: 30 } // This comparison is false because they are different instances // @errors: 2839 console.log(alice === { name: "Alice", age: 30 }) // Output: false ``` 不过,`Data.struct` 构造器允许你根据结构和内容来比较值。 **示例**(创建 struct 并检查相等性) ```ts import { Data, Equal } from "effect" // ┌─── { readonly name: string; readonly age: number; } // ▼ const alice = Data.struct({ name: "Alice", age: 30 }) // Check if Alice is equal to a new object // with the same structure and values console.log(Equal.equals(alice, Data.struct({ name: "Alice", age: 30 }))) // Output: true // Check if Alice is equal to a plain JavaScript object // with the same content console.log(Equal.equals(alice, { name: "Alice", age: 30 })) // Output: false ``` `Equal.equals` 执行的比较是**浅比较**,也就是说,除非嵌套对象也是用 `Data.struct` 创建的,否则它们不会被递归比较。 **示例**(嵌套对象的浅比较) ```ts import { Data, Equal } from "effect" const nested = Data.struct({ name: "Alice", nested_field: { value: 42 } }) // This will be false because the nested objects are compared by reference console.log( Equal.equals( nested, Data.struct({ name: "Alice", nested_field: { value: 42 } }), ), ) // Output: false ``` 要确保嵌套对象按结构比较,请对它们也使用 `Data.struct`。 **示例**(正确比较嵌套对象) ```ts import { Data, Equal } from "effect" const nested = Data.struct({ name: "Alice", nested_field: Data.struct({ value: 42 }), }) // Now, the comparison returns true console.log( Equal.equals( nested, Data.struct({ name: "Alice", nested_field: Data.struct({ value: 42 }), }), ), ) // Output: true ``` ### tuple 要使用元组来表示数据,可以使用 `Data.tuple` 构造器。它能确保你的元组可以按结构进行比较。 **示例**(创建元组并检查相等性) ```ts import { Data, Equal } from "effect" // ┌─── readonly [string, number] // ▼ const alice = Data.tuple("Alice", 30) // Check if Alice is equal to a new tuple // with the same structure and values console.log(Equal.equals(alice, Data.tuple("Alice", 30))) // Output: true // Check if Alice is equal to a plain JavaScript tuple // with the same content console.log(Equal.equals(alice, ["Alice", 30])) // Output: false ``` ### array 你可以使用 `Data.array` 创建一个支持结构相等性的类数组数据结构。 **示例**(创建数组并检查相等性) ```ts import { Data, Equal } from "effect" // ┌─── readonly number[] // ▼ const numbers = Data.array([1, 2, 3, 4, 5]) // Check if the array is equal to a new array // with the same values console.log(Equal.equals(numbers, Data.array([1, 2, 3, 4, 5]))) // Output: true // Check if the array is equal to a plain JavaScript array // with the same content console.log(Equal.equals(numbers, [1, 2, 3, 4, 5])) // Output: false ``` ## 构造器 该模块引入了一个称为 "Case classes" 的概念,它在定义数据类型时自动完成各种必要的操作。 这些操作包括生成**构造函数**、处理**相等性**检查以及管理**哈希**。 Case classes 主要有两种定义方式: - 作为普通对象,使用 `case` 或 `tagged` - 使用 `Class` 或 `TaggedClass` 定义为 TypeScript 类 ### case `Data.case` 辅助函数会为你的数据类型生成构造函数,并内置对相等性检查和哈希的支持。 **示例**(定义 Case Class 并检查相等性) 在这个示例中,`Data.case` 用于为 `Person` 创建构造函数。得到的实例内置了相等性检查支持,你可以直接用 `Equal.equals` 比较它们。 ```ts import { Data, Equal } from "effect" interface Person { readonly name: string } // Create a constructor for `Person` // // ┌─── (args: { readonly name: string; }) => Person // ▼ const make = Data.case() const alice = make({ name: "Alice" }) console.log(Equal.equals(alice, make({ name: "Alice" }))) // Output: true console.log(Equal.equals(alice, make({ name: "John" }))) // Output: false ``` **示例**(定义并比较嵌套的 Case Class) 这个示例演示了如何使用 `Data.case` 创建嵌套数据结构,例如包含 `Address` 的 `Person` 类型。`Person` 和 `Address` 的构造函数都支持相等性检查。 ```ts import { Data, Equal } from "effect" interface Address { readonly street: string readonly city: string } // Create a constructor for `Address` const Address = Data.case
() interface Person { readonly name: string readonly address: Address } // Create a constructor for `Person` const Person = Data.case() const alice = Person({ name: "Alice", address: Address({ street: "123 Main St", city: "Wonderland" }), }) const anotherAlice = Person({ name: "Alice", address: Address({ street: "123 Main St", city: "Wonderland" }), }) console.log(Equal.equals(alice, anotherAlice)) // Output: true ``` 另外,你也可以使用 `Data.struct` 创建嵌套数据结构,而无需单独定义 `Address` 构造函数。 **示例**(使用 `Data.struct` 处理嵌套对象) ```ts import { Data, Equal } from "effect" interface Person { readonly name: string readonly address: { readonly street: string readonly city: string } } // Create a constructor for `Person` const Person = Data.case() const alice = Person({ name: "Alice", address: Data.struct({ street: "123 Main St", city: "Wonderland" }), }) const anotherAlice = Person({ name: "Alice", address: Data.struct({ street: "123 Main St", city: "Wonderland" }), }) console.log(Equal.equals(alice, anotherAlice)) // Output: true ``` **示例**(定义并比较递归的 Case Class) 这个示例演示了使用 `Data.case` 定义的递归结构 —— 一棵二叉树,其中每个节点都可以包含其他节点。 ```ts import { Data, Equal } from "effect" interface BinaryTree { readonly value: T readonly left: BinaryTree | null readonly right: BinaryTree | null } // Create a constructor for `BinaryTree` const BinaryTree = Data.case>() const tree1 = BinaryTree({ value: 0, left: BinaryTree({ value: 1, left: null, right: null }), right: null, }) const tree2 = BinaryTree({ value: 0, left: BinaryTree({ value: 1, left: null, right: null }), right: null, }) console.log(Equal.equals(tree1, tree2)) // Output: true ``` ### tagged 当你处理的数据类型包含 tag 字段时(例如在不交并集类型中),为每个实例手动定义 tag 会变得很重复。使用 `case` 方式需要你每次都指定 tag 字段,这可能很繁琐。 **示例**(手动定义带标签的 Case Class) 这里,我们使用 `Data.case` 创建一个带有 `_tag` 字段的 `Person` 类型。注意,每创建一个新实例都需要指定 `_tag`。 ```ts import { Data } from "effect" interface Person { readonly _tag: "Person" // the tag readonly name: string } const Person = Data.case() // Repeating `_tag: 'Person'` for each instance const alice = Person({ _tag: "Person", name: "Alice" }) const bob = Person({ _tag: "Person", name: "Bob" }) ``` 为了简化这一过程,`Data.tagged` 辅助函数会自动添加 tag。它遵循 Effect 生态中把 tag 字段命名为 `"_tag"` 的约定。 **示例**(使用 Data.tagged 简化标签的添加) `Data.tagged` 辅助函数让你只需定义一次 tag,从而使实例的创建更简单。 ```ts import { Data } from "effect" interface Person { readonly _tag: "Person" // the tag readonly name: string } const Person = Data.tagged("Person") // The `_tag` field is automatically added const alice = Person({ name: "Alice" }) const bob = Person({ name: "Bob" }) console.log(alice) // Output: { name: 'Alice', _tag: 'Person' } ``` ### Class 如果你更喜欢使用类而不是普通对象,可以用 `Data.Class` 作为 `Data.case` 的替代方案。在你希望获得带有方法和自定义逻辑的、面向类的结构时,这种方式可能更自然。 **示例**(使用 Data.Class 创建面向类的结构) 下面演示如何使用 `Data.Class` 定义 `Person` 类: ```ts import { Data, Equal } from "effect" // Define a Person class extending Data.Class class Person extends Data.Class<{ name: string }> {} // Create an instance of Person const alice = new Person({ name: "Alice" }) // Check for equality between two instances console.log(Equal.equals(alice, new Person({ name: "Alice" }))) // Output: true ``` 使用类的好处之一是,你可以轻松添加自定义方法和 getter。这让你能够扩展数据类型的功能。 **示例**(为类添加自定义 getter) 在这个示例中,我们为 `Person` 类添加一个 `upperName` getter,用于以大写形式返回名字: ```ts import { Data } from "effect" // Extend Person class with a custom getter class Person extends Data.Class<{ name: string }> { get upperName() { return this.name.toUpperCase() } } // Create an instance and use the custom getter const alice = new Person({ name: "Alice" }) console.log(alice.upperName) // Output: ALICE ``` ### TaggedClass 如果你更喜欢基于类的方式,同时又想获得不交并集标签带来的好处,`Data.TaggedClass` 会是个有用的选择。它的工作方式与 `tagged` 类似,但专为类定义而设计。 **示例**(定义内置标签的 Tagged Class) 下面演示如何使用 `Data.TaggedClass` 定义 `Person` 类。注意,tag `"Person"` 会被自动添加: ```ts import { Data, Equal } from "effect" // Define a tagged class Person with the _tag "Person" class Person extends Data.TaggedClass("Person")<{ name: string }> {} // Create an instance of Person const alice = new Person({ name: "Alice" }) console.log(alice) // Output: Person { name: 'Alice', _tag: 'Person' } // Check equality between two instances console.log(Equal.equals(alice, new Person({ name: "Alice" }))) // Output: true ``` 使用 Tagged Class 的一个好处是,可以轻松添加自定义方法和 getter,按需扩展类的功能。 **示例**(为 Tagged Class 添加自定义 getter) 在这个示例中,我们为 `Person` 类添加一个 `upperName` getter,它会以大写形式返回名字: ```ts import { Data } from "effect" // Extend the Person class with a custom getter class Person extends Data.TaggedClass("Person")<{ name: string }> { get upperName() { return this.name.toUpperCase() } } // Create an instance and use the custom getter const alice = new Person({ name: "Alice" }) console.log(alice.upperName) // Output: ALICE ``` ## 带标签 struct 的联合 要创建带标签 struct 的不交并集,可以使用 `Data.TaggedEnum` 和 `Data.taggedEnum`。这些工具让定义和操作普通对象的联合变得简单直接。 ### 定义 传给 `Data.TaggedEnum` 的类型必须是一个对象,其中键表示各个 tag,值则定义对应数据类型的结构。 **示例**(定义带标签联合并检查相等性) ```ts import { Data, Equal } from "effect" // Define a union type using TaggedEnum type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { readonly data: string } Failure: { readonly reason: string } }> // Create constructors for each case in the union const { Loading, Success, Failure } = Data.taggedEnum() // Instantiate different states const state1 = Loading() const state2 = Success({ data: "test" }) const state3 = Success({ data: "test" }) const state4 = Failure({ reason: "not found" }) // Check equality between states console.log(Equal.equals(state2, state3)) // Output: true console.log(Equal.equals(state2, state4)) // Output: false // Display the states console.log(state1) // Output: { _tag: 'Loading' } console.log(state2) // Output: { data: 'test', _tag: 'Success' } console.log(state4) // Output: { reason: 'not found', _tag: 'Failure' } ``` ### $is 与 $match `Data.taggedEnum` 提供了 `$is` 和 `$match` 函数,方便进行类型守卫和模式匹配。 **示例**(使用类型守卫与模式匹配) ```ts import { Data } from "effect" type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { readonly data: string } Failure: { readonly reason: string } }> const { $is, $match, Loading, Success } = Data.taggedEnum() // Use `$is` to create a type guard for "Loading" const isLoading = $is("Loading") console.log(isLoading(Loading())) // Output: true console.log(isLoading(Success({ data: "test" }))) // Output: false // Use `$match` for pattern matching const matcher = $match({ Loading: () => "this is a Loading", Success: ({ data }) => `this is a Success: ${data}`, Failure: ({ reason }) => `this is a Failure: ${reason}`, }) console.log(matcher(Success({ data: "test" }))) // Output: "this is a Success: test" ``` ### 添加泛型 使用 `TaggedEnum.WithGenerics` 可以创建更灵活、更可复用的带标签联合。这种方式让你能够定义可以动态处理不同类型的带标签联合。 **示例**(在 TaggedEnum 中使用泛型) ```ts import { Data } from "effect" // Define a generic TaggedEnum for RemoteData type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { data: Success } Failure: { reason: Failure } }> // Extend TaggedEnum.WithGenerics to add generics interface RemoteDataDefinition extends Data.TaggedEnum.WithGenerics<2> { readonly taggedEnum: RemoteData } // Create constructors for the generic RemoteData const { Loading, Failure, Success } = Data.taggedEnum() // Instantiate each case with specific types const loading = Loading() const failure = Failure({ reason: "not found" }) const success = Success({ data: 1 }) ``` ## 错误 在 Effect 中,使用专门的构造函数可以简化错误处理: - `Error` - `TaggedError` 这些构造函数让定义自定义错误类型变得简单直接,同时还提供了诸如相等性检查、结构化错误处理等实用集成。 ### Error `Data.Error` 让你可以创建一种 `Error` 类型,在常规的 `message` 属性之外还能包含额外字段。 **示例**(创建带额外字段的自定义错误) ```ts import { Data } from "effect" // Define a custom error with additional fields class NotFound extends Data.Error<{ message: string; file: string }> {} // Create an instance of the custom error const err = new NotFound({ message: "Cannot find this file", file: "foo.txt", }) console.log(err instanceof Error) // Output: true console.log(err.file) // Output: foo.txt console.log(err) /* Output: NotFound [Error]: Cannot find this file file: 'foo.txt' ... stack trace ... */ ``` 你可以直接在 [Effect.gen](/docs/v3/getting-started/using-generators/) 中 yield 一个 `NotFound` 实例,而无需使用 `Effect.fail`。 **示例**(在 `Effect.gen` 中 yield 自定义错误) ```ts import { Data, Effect } from "effect" class NotFound extends Data.Error<{ message: string; file: string }> {} const program = Effect.gen(function* () { yield* new NotFound({ message: "Cannot find this file", file: "foo.txt", }) }) Effect.runPromise(program) /* throws: Error: Cannot find this file at ... { name: '(FiberFailure) Error', [Symbol(effect/Runtime/FiberFailure/Cause)]: { _tag: 'Fail', error: NotFound [Error]: Cannot find this file at ...stack trace... file: 'foo.txt' } } } */ ``` ### TaggedError Effect 提供了 `TaggedError` API,用于自动为自定义错误添加 `_tag` 字段。配合 [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag) 或 [Effect.catchTags](/docs/v3/error-management/expected-errors/#catchtags) 这类 API,错误处理会变得更简单。 ```ts import { Data, Effect, Console } from "effect" // Define a custom tagged error class NotFound extends Data.TaggedError("NotFound")<{ message: string file: string }> {} const program = Effect.gen(function* () { yield* new NotFound({ message: "Cannot find this file", file: "foo.txt", }) }).pipe( // Catch and handle the tagged error Effect.catchTag("NotFound", (err) => Console.error(`${err.message} (${err.file})`), ), ) Effect.runPromise(program) // Output: Cannot find this file (foo.txt) ``` ### 原生 cause 支持 使用 `Data.Error` 或 `Data.TaggedError` 创建的错误可以包含 `cause` 属性,与 JavaScript `Error` 的原生 `cause` 功能集成,从而实现更详细的错误追踪。 **示例**(使用 `cause` 属性) ```ts import { Data, Effect } from "effect" // Define an error with a cause property class MyError extends Data.Error<{ cause: Error }> {} const program = Effect.gen(function* () { yield* new MyError({ cause: new Error("Something went wrong"), }) }) Effect.runPromise(program) /* throws: Error: An error has occurred at ... { name: '(FiberFailure) Error', [Symbol(effect/Runtime/FiberFailure/Cause)]: { _tag: 'Fail', error: MyError at ... [cause]: Error: Something went wrong at ... */ ``` --- # DateTime > 使用 Effect 的 DateTime 处理精确的时间点,支持创建、比较和算术运算,从而高效地处理时间。 在 JavaScript 中处理日期和时间可能颇具挑战。内置的 `Date` 对象会修改自身的内部状态,时区处理也可能令人困惑。这些设计选择会在开发依赖日期时间准确性的应用时引入错误,例如调度系统、时间戳服务或日志工具。 DateTime 模块旨在通过提供以下特性来解决这些局限: - **不可变数据**:每个 `DateTime` 都是不可变结构,可减少与就地修改相关的错误。 - **时区支持**:`DateTime` 为时区提供了完善的支持,包括自动处理夏令时调整。 - **算术运算**:你可以对 `DateTime` 实例执行算术运算,例如加上或减去一个时长(duration)。 ## DateTime 类型 `DateTime` 表示时间中的一个时刻。它既可以存储为简单的 UTC 值,也可以存储为带有关联时区的值。以这种方式存储时间,有助于你同时管理精确的时间戳,以及该时间应如何显示或解释的上下文。 `DateTime` 有两种主要变体: 1. **Utc**:一种不可变结构,使用 `epochMillis`(自 Unix 纪元以来的毫秒数)表示协调世界时(UTC)中的一个时间点。 2. **Zoned**:包含 `epochMillis` 以及一个 `TimeZone`,让你可以为时间戳附加偏移量或命名区域(如 "America/New_York")。 ### 为什么有两种变体? - 如果你只需要一个通用参照,而不依赖本地时区,**Utc** 就很直接。 - 当你需要跟踪时区信息时,**Zoned** 会很有帮助,例如转换为本地时间或针对夏令时进行调整。 ### TimeZone 变体 `TimeZone` 可以是以下两种之一: - **Offset**:表示相对 UTC 的固定偏移量(例如 UTC+2 或 UTC-5)。 - **Named**:使用命名区域(如 "Europe/London" 或 "America/New_York"),它会自动考虑特定区域的规则,例如夏令时变更。 ### TypeScript 定义 下面是 `DateTime` 类型的 TypeScript 定义: ```ts type DateTime = Utc | Zoned interface Utc { readonly _tag: "Utc" readonly epochMillis: number } interface Zoned { readonly _tag: "Zoned" readonly epochMillis: number readonly zone: TimeZone } type TimeZone = TimeZone.Offset | TimeZone.Named declare namespace TimeZone { interface Offset { readonly _tag: "Offset" readonly offset: number } interface Named { readonly _tag: "Named" readonly id: string } } ``` ## DateTime.Parts 类型 `DateTime.Parts` 类型定义了日期的主要组成部分,例如年、月、日、时、分、秒。 ```ts namespace DateTime { interface Parts { readonly millis: number readonly seconds: number readonly minutes: number readonly hours: number readonly day: number readonly month: number readonly year: number } interface PartsWithWeekday extends Parts { readonly weekDay: number } } ``` ## DateTime.Input 类型 `DateTime.Input` 类型是一种灵活的输入类型,可用于创建 `DateTime` 实例。它可以是以下之一: - 一个 `DateTime` 实例 - 一个 JavaScript `Date` 对象 - 一个表示自 Unix 纪元以来毫秒数的数值 - 一个带有部分日期 [parts](#the-datetimeparts-type) 的对象(例如 `{ year: 2024, month: 1, day: 1 }`) - 一个可由 JavaScript 的 [Date.parse](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse) 解析的字符串 ```ts namespace DateTime { type Input = DateTime | Partial | Date | number | string } ``` ## Utc 构造器 `Utc` 是一种不可变结构,使用 `epochMillis`(自 Unix 纪元以来的毫秒数)表示协调世界时(UTC)中的一个时间点。 ### unsafeFromDate 从 JavaScript `Date` 创建一个 `Utc`。 如果提供的 `Date` 无效,则抛出 `IllegalArgumentException`。 当传入一个 `Date` 对象时,它会被转换为 `Utc` 实例。该时间会被解释为执行代码的系统的本地时间,然后再调整为 UTC。这确保了日期和时间具有一致且与时区无关的表示。 **示例**(在意大利将本地时间转换为 UTC) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // Create a Utc instance from a local JavaScript Date // // ┌─── Utc // ▼ const utc = DateTime.unsafeFromDate(new Date("2025-01-01 04:00:00")) console.log(utc) // Output: DateTime.Utc(2025-01-01T03:00:00.000Z) console.log(utc.epochMillis) // Output: 1735700400000 ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 - `epochMillis` 以自 Unix 纪元以来的毫秒数提供同一时间,确保 UTC 时间戳具有精确的数值表示。 ### unsafeMake 从 [DateTime.Input](#the-datetimeinput-type) 创建一个 `Utc`。 **示例**(使用 unsafeMake 创建 DateTime) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // From a JavaScript Date const utc1 = DateTime.unsafeMake(new Date("2025-01-01 04:00:00")) console.log(utc1) // Output: DateTime.Utc(2025-01-01T03:00:00.000Z) // From partial date parts const utc2 = DateTime.unsafeMake({ year: 2025 }) console.log(utc2) // Output: DateTime.Utc(2025-01-01T00:00:00.000Z) // From a string const utc3 = DateTime.unsafeMake("2025-01-01") console.log(utc3) // Output: DateTime.Utc(2025-01-01T00:00:00.000Z) ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 ### make 与 [unsafeMake](#unsafemake) 类似,但如果输入无效,它会返回一个 [Option](/docs/v3/data-types/option/) 而不是抛出错误。 如果输入无效,则返回 `None`。如果有效,则返回包含 `Utc` 的 `Some`。 **示例**(安全地创建 DateTime) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // From a JavaScript Date const maybeUtc1 = DateTime.make(new Date("2025-01-01 04:00:00")) console.log(maybeUtc1) /* Output: { _id: 'Option', _tag: 'Some', value: '2025-01-01T03:00:00.000Z' } */ // From partial date parts const maybeUtc2 = DateTime.make({ year: 2025 }) console.log(maybeUtc2) /* Output: { _id: 'Option', _tag: 'Some', value: '2025-01-01T00:00:00.000Z' } */ // From a string const maybeUtc3 = DateTime.make("2025-01-01") console.log(maybeUtc3) /* Output: { _id: 'Option', _tag: 'Some', value: '2025-01-01T00:00:00.000Z' } */ ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 ## Zoned 构造器 `Zoned` 包含 `epochMillis` 以及一个 `TimeZone`,让你可以为时间戳附加偏移量或命名区域(如 "America/New_York")。 ### unsafeMakeZoned 通过将一个 [DateTime.Input](#the-datetimeinput-type) 与一个可选的 `TimeZone` 组合来创建 `Zoned`。 这让你能够表示一个带有相关联时区的特定时间点。 时区可以通过以下几种方式提供: - 作为一个 `TimeZone` 对象 - 一个字符串标识符(例如 `"Europe/London"`) - 一个以毫秒为单位的数值偏移量 如果输入或时区无效,则抛出 `IllegalArgumentException`。 **示例**(在不指定时区的情况下创建 Zoned DateTime) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // Create a Zoned DateTime based on the system's local time zone const zoned = DateTime.unsafeMakeZoned(new Date("2025-01-01 04:00:00")) console.log(zoned) // Output: DateTime.Zoned(2025-01-01T04:00:00.000+01:00) console.log(zoned.zone) // Output: TimeZone.Offset(+01:00) ``` 这里使用了系统的时区(CET,1 月为 UTC+1)来创建 `Zoned` 实例。 **示例**(指定命名时区) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // Create a Zoned DateTime with a specified named time zone const zoned = DateTime.unsafeMakeZoned(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", }) console.log(zoned) // Output: DateTime.Zoned(2025-01-01T04:00:00.000+01:00[Europe/Rome]) console.log(zoned.zone) // Output: TimeZone.Named(Europe/Rome) ``` 在本例中,显式提供了 `"Europe/Rome"` 时区,使得 `Zoned` 实例与这个命名时区绑定。 默认情况下,输入的日期会被当作 UTC 值处理,然后再针对指定的时区进行调整。若要将输入的日期解释为处于指定的时区,可以使用 `adjustForTimeZone` 选项。 **示例**(调整时区解释方式) 下面的示例假设代码在一个位于意大利(CET 时区)的系统上执行: ```ts import { DateTime } from "effect" // Interpret the input date as being in the specified time zone const zoned = DateTime.unsafeMakeZoned(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", adjustForTimeZone: true, }) console.log(zoned) // Output: DateTime.Zoned(2025-01-01T03:00:00.000+01:00[Europe/Rome]) console.log(zoned.zone) // Output: TimeZone.Named(Europe/Rome) ``` **解释** - **不使用 `adjustForTimeZone`**:输入的日期会被解释为 UTC,然后再调整为指定的时区。例如,UTC 中的 `2025-01-01 04:00:00` 在 CET(UTC+1)中变为 `2025-01-01T04:00:00.000+01:00`。 - **使用 `adjustForTimeZone: true`**:输入的日期会被解释为处于指定的时区。例如,"Europe/Rome"(CET)中的 `2025-01-01 04:00:00` 会被调整为其对应的 UTC 时间,结果为 `2025-01-01T03:00:00.000+01:00`。 ### makeZoned `makeZoned` 函数的行为与 [unsafeMakeZoned](#unsafemakezoned) 类似,但提供了一种更安全的方式。当输入无效时,它不会抛出错误,而是返回 `Option`。 如果输入无效,则返回 `None`。如果有效,则返回包含 `Zoned` 的 `Some`。 **示例**(安全地创建 Zoned DateTime) ```ts import { DateTime, Option } from "effect" // ┌─── Option // ▼ const zoned = DateTime.makeZoned(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", }) if (Option.isSome(zoned)) { console.log("The DateTime is valid") } ``` ### makeZonedFromString 通过解析格式为 `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[IANA timezone identifier]` 的字符串来创建 `Zoned`。 如果输入字符串有效,该函数会返回包含 `Zoned` 的 `Some`。如果输入无效,则返回 `None`。 **示例**(从字符串解析 Zoned DateTime) ```ts import { DateTime, Option } from "effect" // ┌─── Option // ▼ const zoned = DateTime.makeZonedFromString( "2025-01-01T03:00:00.000+01:00[Europe/Rome]", ) if (Option.isSome(zoned)) { console.log("The DateTime is valid") } ``` ## 当前时间 ### now 以 `Effect` 的形式提供当前 UTC 时间,使用的是 [Clock](/docs/v3/requirements-management/default-services/) 服务。 **示例**(获取当前 UTC 时间) ```ts import { DateTime, Effect } from "effect" const program = Effect.gen(function* () { // ┌─── Utc // ▼ const currentTime = yield* DateTime.now }) ``` ### unsafeNow 使用 `Date.now()` 立即获取当前 UTC 时间,而不使用 [Clock](/docs/v3/requirements-management/default-services/) 服务。 **示例**(立即获取当前 UTC 时间) ```ts import { DateTime } from "effect" // ┌─── Utc // ▼ const currentTime = DateTime.unsafeNow() ``` ## 类型守卫 | 操作 | 说明 | | ------------------ | --------------------------------------------- | | `isDateTime` | 检查一个值是否为 `DateTime`。 | | `isTimeZone` | 检查一个值是否为 `TimeZone`。 | | `isTimeZoneOffset` | 检查一个值是否为 `TimeZone.Offset`。 | | `isTimeZoneNamed` | 检查一个值是否为 `TimeZone.Named`。 | | `isUtc` | 检查一个 `DateTime` 是否为 `Utc` 变体。 | | `isZoned` | 检查一个 `DateTime` 是否为 `Zoned` 变体。 | **示例**(校验一个 DateTime) ```ts import { DateTime } from "effect" function printDateTimeInfo(x: unknown) { if (DateTime.isDateTime(x)) { console.log("This is a valid DateTime") } else { console.log("Not a DateTime") } } ``` ## 时区管理 | 操作 | 说明 | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `setZone` | 通过应用给定的 `TimeZone`,从 `DateTime` 创建一个 `Zoned`。 | | `setZoneOffset` | 使用一个固定的偏移量(毫秒)从 `DateTime` 创建一个 `Zoned`。 | | `setZoneNamed` | 使用一个 IANA 时区标识符从 `DateTime` 创建一个 `Zoned`,如果无效则返回 `None`。 | | `unsafeSetZoneNamed` | 使用一个 IANA 时区标识符从 `DateTime` 创建一个 `Zoned`,如果无效则抛出异常。 | | `zoneUnsafeMakeNamed` | 使用一个 IANA 时区标识符创建一个 `TimeZone.Named`,如果标识符无效则抛出异常。 | | `zoneMakeNamed` | 使用一个 IANA 时区标识符创建一个 `TimeZone.Named`,如果无效则返回 `None`。 | | `zoneMakeNamedEffect` | 使用一个 IANA 时区标识符创建一个 `Effect`,如果无效则以 `IllegalArgumentException` 失败 | | `zoneMakeOffset` | 使用一个以毫秒为单位的数值偏移量创建一个 `TimeZone.Offset`。 | | `zoneMakeLocal` | 从系统的本地时区创建一个 `TimeZone.Named`。 | | `zoneFromString` | 尝试从字符串解析时区,如果无效则返回 `None`。 | | `zoneToString` | 返回 `TimeZone` 的字符串表示。 | **示例**(将时区应用到一个 DateTime) ```ts import { DateTime } from "effect" // Create a UTC DateTime // // ┌─── Utc // ▼ const utc = DateTime.unsafeMake("2024-01-01") // Create a named time zone for New York // // ┌─── TimeZone.Named // ▼ const zoneNY = DateTime.zoneUnsafeMakeNamed("America/New_York") // Apply it to the DateTime // // ┌─── Zoned // ▼ const zoned = DateTime.setZone(utc, zoneNY) console.log(zoned) // Output: DateTime.Zoned(2023-12-31T19:00:00.000-05:00[America/New_York]) ``` ### zoneFromString 解析字符串以创建 `DateTime.TimeZone`。 该函数会尝试将输入字符串解释为以下两种之一: - 一个数值时区偏移量(例如 "GMT"、"+01:00") - 一个 IANA 时区标识符(例如 "Europe/London") 如果字符串匹配偏移量格式,它会被转换为 `TimeZone.Offset`。 否则,它会尝试用该输入创建一个 `TimeZone.Named`。 如果输入字符串无效,则返回 `Option.none()`。 **示例**(从字符串解析时区) ```ts import { DateTime, Option } from "effect" // Attempt to parse a numeric offset const offsetZone = DateTime.zoneFromString("+01:00") console.log(Option.isSome(offsetZone)) // Output: true // Attempt to parse an IANA time zone const namedZone = DateTime.zoneFromString("Europe/London") console.log(Option.isSome(namedZone)) // Output: true // Invalid input const invalidZone = DateTime.zoneFromString("Invalid/Zone") console.log(Option.isSome(invalidZone)) // Output: false ``` ## 比较 | 操作 | 说明 | | -------------------------------------------- | ------------------------------------------------------------ | | `distance` | 返回两个 `DateTime` 之间的差值(以毫秒为单位)。 | | `distanceDurationEither` | 根据先后顺序返回一个 `Left` 或 `Right` `Duration`。 | | `distanceDuration` | 返回一个 `Duration`,表示两个时间相距多远。 | | `min` | 返回两个 `DateTime` 值中较早的那个。 | | `max` | 返回两个 `DateTime` 值中较晚的那个。 | | `greaterThan`, `greaterThanOrEqualTo`, etc. | 检查两个 `DateTime` 值之间的顺序。 | | `between` | 检查一个 `DateTime` 是否落在给定的边界内。 | | `isFuture`, `isPast`, `unsafeIsFuture`, etc. | 检查一个 `DateTime` 是在未来还是过去。 | **示例**(求两个 DateTime 之间的距离) ```ts import { DateTime } from "effect" const utc1 = DateTime.unsafeMake("2025-01-01T00:00:00Z") const utc2 = DateTime.add(utc1, { days: 1 }) console.log(DateTime.distance(utc1, utc2)) // Output: 86400000 (one day) console.log(DateTime.distanceDurationEither(utc1, utc2)) /* Output: { _id: 'Either', _tag: 'Right', right: { _id: 'Duration', _tag: 'Millis', millis: 86400000 } } */ console.log(DateTime.distanceDuration(utc1, utc2)) // Output: { _id: 'Duration', _tag: 'Millis', millis: 86400000 } ``` ## 转换 | 操作 | 说明 | | ---------------- | ----------------------------------------------------------------------- | | `toDateUtc` | 返回一个采用 UTC 的 JavaScript `Date`。 | | `toDate` | 应用时区(如果存在)并转换为一个 JavaScript `Date`。 | | `zonedOffset` | 对于 `Zoned` DateTime,返回以毫秒为单位的时区偏移量。 | | `zonedOffsetIso` | 对于 `Zoned` DateTime,返回形如 "+01:00" 的 ISO 偏移量字符串。 | | `toEpochMillis` | 返回以毫秒为单位的 Unix 纪元时间。 | | `removeTime` | 返回一个清除了时间部分的 `Utc`(只保留日期)。 | ## 组成部分 | 操作 | 说明 | | -------------------------- | -------------------------------------------------------------------------- | | `toParts` | 返回按时间调整后的日期组成部分(包括星期几)。 | | `toPartsUtc` | 返回 UTC 的日期组成部分(包括星期几)。 | | `getPart` / `getPartUtc` | 从日期中获取指定的组成部分(例如 `"year"` 或 `"month"`)。 | | `setParts` / `setPartsUtc` | 更新日期的某些组成部分,同时保留或忽略时区。 | **示例**(从 DateTime 中提取组成部分) ```ts import { DateTime } from "effect" const zoned = DateTime.setZone( DateTime.unsafeMake("2024-01-01"), DateTime.zoneUnsafeMakeNamed("Europe/Rome"), ) console.log(DateTime.getPart(zoned, "month")) // Output: 1 ``` ## 数学运算 | 操作 | 说明 | | ------------------ | ------------------------------------------------------------------------------------------ | | `addDuration` | 将给定的 `Duration` 加到 `DateTime` 上。 | | `subtractDuration` | 从 `DateTime` 中减去给定的 `Duration`。 | | `add` | 将数值组成部分(例如 `{ hours: 2 }`)加到 `DateTime` 上。 | | `subtract` | 减去数值组成部分。 | | `startOf` | 将 `DateTime` 移动到给定单位的起点(例如一天或一个月的开始)。 | | `endOf` | 将 `DateTime` 移动到给定单位的终点。 | | `nearest` | 将 `DateTime` 舍入到最近的指定单位。 | ## 格式化 | 操作 | 说明 | | ------------------ | ------------------------------------------------------------------------- | | `format` | 使用 `DateTimeFormat` API 将 `DateTime` 格式化为字符串。 | | `formatLocal` | 使用系统的本地时区和区域设置进行格式化。 | | `formatUtc` | 强制使用 UTC 格式化。 | | `formatIntl` | 使用传入的 `Intl.DateTimeFormat`。 | | `formatIso` | 返回采用 UTC 的 ISO 8601 字符串。 | | `formatIsoDate` | 返回一个 ISO 日期字符串,并针对时区进行调整。 | | `formatIsoDateUtc` | 返回一个采用 UTC 的 ISO 日期字符串。 | | `formatIsoOffset` | 将 `Zoned` 格式化为带有形如 "+01:00" 偏移量的字符串。 | | `formatIsoZoned` | 以 `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[Zone]` 的形式格式化 `Zoned`。 | ## 当前时区的 Layer | 操作 | 说明 | | ------------------------ | -------------------------------------------------------------------- | | `CurrentTimeZone` | 当前时区的服务标签。 | | `setZoneCurrent` | 将 `DateTime` 设置为使用当前时区。 | | `withCurrentZone` | 为 effect 提供指定的时区。 | | `withCurrentZoneLocal` | 为 effect 使用系统的本地时区。 | | `withCurrentZoneOffset` | 为 effect 使用一个固定的偏移量(毫秒)。 | | `withCurrentZoneNamed` | 使用一个命名时区标识符(例如 "Europe/London")。 | | `nowInCurrentZone` | 以 `Zoned` 的形式获取所配置时区中的当前时间。 | | `layerCurrentZone` | 创建一个提供 `CurrentTimeZone` 服务的 Layer。 | | `layerCurrentZoneOffset` | 从一个固定偏移量创建一个 Layer。 | | `layerCurrentZoneNamed` | 从一个命名时区创建一个 Layer,如果无效则失败。 | | `layerCurrentZoneLocal` | 从系统的本地时区创建一个 Layer。 | **示例**(在 Effect 中使用当前时区) ```ts import { DateTime, Effect } from "effect" // Retrieve the current time in the "Europe/London" time zone const program = Effect.gen(function* () { const zonedNow = yield* DateTime.nowInCurrentZone console.log(zonedNow) }).pipe(DateTime.withCurrentZoneNamed("Europe/London")) Effect.runFork(program) /* Example Output: DateTime.Zoned(2025-01-06T18:36:38.573+00:00[Europe/London]) */ ``` --- # Duration > 使用 Effect 的 Duration 处理精确的时间跨度,支持创建、比较与算术运算,从而高效地处理时间。 `Duration` 数据类型用于表示特定的非负时间跨度。它常用于在各种操作中表示时间间隔或持续时间,例如超时、延迟或调度。`Duration` 类型提供了一种便捷的方式来处理时间单位,并对时长进行计算。 ## 创建 Duration `Duration` 模块提供了若干构造函数,用于以不同的单位创建时长。 **示例**(以各种单位创建时长) ```ts import { Duration } from "effect" // Create a duration of 100 milliseconds const duration1 = Duration.millis(100) // Create a duration of 2 seconds const duration2 = Duration.seconds(2) // Create a duration of 5 minutes const duration3 = Duration.minutes(5) ``` 你可以使用纳秒、微秒、毫秒、秒、分钟、小时、天和周等单位来创建时长。 若要创建无限的时长,请使用 `Duration.infinity`。 **示例**(创建无限时长) ```ts import { Duration } from "effect" console.log(String(Duration.infinity)) /* Output: Duration(Infinity) */ ``` 另一种创建时长的方式是使用 `Duration.decode` 辅助函数: - `number` 值会被视为毫秒。 - `bigint` 值会被视为纳秒。 - 字符串必须遵循 `"${number} ${unit}"` 格式。 **示例**(把值解码为时长) ```ts import { Duration } from "effect" Duration.decode(10n) // same as Duration.nanos(10) Duration.decode(100) // same as Duration.millis(100) Duration.decode(Infinity) // same as Duration.infinity Duration.decode("10 nanos") // same as Duration.nanos(10) Duration.decode("20 micros") // same as Duration.micros(20) Duration.decode("100 millis") // same as Duration.millis(100) Duration.decode("2 seconds") // same as Duration.seconds(2) Duration.decode("5 minutes") // same as Duration.minutes(5) Duration.decode("7 hours") // same as Duration.hours(7) Duration.decode("3 weeks") // same as Duration.weeks(3) ``` ## 获取 Duration 的值 你可以使用 `Duration.toMillis` 以毫秒为单位获取时长的值。 **示例**(以毫秒获取时长) ```ts import { Duration } from "effect" console.log(Duration.toMillis(Duration.seconds(30))) // Output: 30000 ``` 若要以纳秒为单位获取时长的值,请使用 `Duration.toNanos`。注意,`toNanos` 返回 `Option`,因为时长可能是无限的。 **示例**(以纳秒获取时长) ```ts import { Duration } from "effect" console.log(Duration.toNanos(Duration.millis(100))) /* Output: { _id: 'Option', _tag: 'Some', value: 100000000n } */ ``` 若要直接得到 `bigint` 值而不经过 `Option`,请使用 `Duration.unsafeToNanos`。不过,对于无限时长它会抛出错误。 **示例**(不安全的纳秒获取方式) ```ts import { Duration } from "effect" console.log(Duration.unsafeToNanos(Duration.millis(100))) // Output: 100000000n console.log(Duration.unsafeToNanos(Duration.infinity)) /* throws: Error: Cannot convert infinite duration to nanos ...stack trace... */ ``` ## 比较 Duration 使用以下函数来比较两个时长: | API | 说明 | | ---------------------- | ---------------------------------------------------------------------------- | | `lessThan` | 如果第一个时长小于第二个,则返回 `true`。 | | `lessThanOrEqualTo` | 如果第一个时长小于或等于第二个,则返回 `true`。 | | `greaterThan` | 如果第一个时长大于第二个,则返回 `true`。 | | `greaterThanOrEqualTo` | 如果第一个时长大于或等于第二个,则返回 `true`。 | **示例**(比较两个时长) ```ts import { Duration } from "effect" const duration1 = Duration.seconds(30) const duration2 = Duration.minutes(1) console.log(Duration.lessThan(duration1, duration2)) // Output: true console.log(Duration.lessThanOrEqualTo(duration1, duration2)) // Output: true console.log(Duration.greaterThan(duration1, duration2)) // Output: false console.log(Duration.greaterThanOrEqualTo(duration1, duration2)) // Output: false ``` ## 执行算术运算 你可以对时长执行算术运算,例如加法和乘法。 **示例**(时长相加与相乘) ```ts import { Duration } from "effect" const duration1 = Duration.seconds(30) const duration2 = Duration.minutes(1) // Add two durations console.log(String(Duration.sum(duration1, duration2))) /* Output: Duration(1m 30s) */ // Multiply a duration by a factor console.log(String(Duration.times(duration1, 2))) /* Output: Duration(1m) */ ``` ## 转换 把 `Duration` 转换为人类可读的字符串。 **示例** ```ts import { Duration } from "effect" Duration.format(Duration.millis(1000)) // "1s" Duration.format(Duration.millis(1001)) // "1s 1ms" ``` --- # Either > 用 Either 数据类型把互斥的值表示为 Left 或 Right,从而在计算中实现精确的控制流。 `Either` 数据类型表示两个互斥的值:一个 `Either` 要么是 `Right` 值,要么是 `Left` 值,其中 `R` 是 `Right` 值的类型,`L` 是 `Left` 值的类型。 ## 理解 Either 与 Exit Either 主要用作一个**简单的可辨识联合(discriminated union)**,不推荐把它作为需要详细错误信息的操作的主要结果类型。 [Exit](/docs/v3/data-types/exit/) 是 Effect 中首选的**结果类型**,用于捕获关于失败的详尽细节。 它封装了带 effect 的计算的结果,区分成功与各种失败模式,例如错误、defect 和中断。 ## 创建 Either 你可以使用 `Either.right` 和 `Either.left` 构造器来创建 `Either`。 使用 `Either.right` 创建一个类型为 `R` 的 `Right` 值。 **示例**(创建 Right 值) ```ts import { Either } from "effect" const rightValue = Either.right(42) console.log(rightValue) /* Output: { _id: 'Either', _tag: 'Right', right: 42 } */ ``` 使用 `Either.left` 创建一个类型为 `L` 的 `Left` 值。 **示例**(创建 Left 值) ```ts import { Either } from "effect" const leftValue = Either.left("not a number") console.log(leftValue) /* Output: { _id: 'Either', _tag: 'Left', left: 'not a number' } */ ``` ## 类型守卫 使用 `Either.isLeft` 和 `Either.isRight` 检查一个 `Either` 是 `Left` 值还是 `Right` 值。 **示例**(使用类型守卫检查 Either 的类型) ```ts import { Either } from "effect" const foo = Either.right(42) if (Either.isLeft(foo)) { console.log(`The left value is: ${foo.left}`) } else { console.log(`The Right value is: ${foo.right}`) } // Output: "The Right value is: 42" ``` ## 模式匹配 使用 `Either.match` 处理 `Either` 的两种情况:分别为 `Left` 和 `Right` 指定各自的回调。 **示例**(对 Either 进行模式匹配) ```ts import { Either } from "effect" const foo = Either.right(42) const message = Either.match(foo, { onLeft: (left) => `The left value is: ${left}`, onRight: (right) => `The Right value is: ${right}`, }) console.log(message) // Output: "The Right value is: 42" ``` ## 映射 ### 映射 Right 值 使用 `Either.map` 转换一个 `Either` 的 `Right` 值。你提供的函数只会作用于 `Right` 值,`Left` 值保持不变。 **示例**(转换 Right 值) ```ts import { Either } from "effect" // Transform the Right value by adding 1 const rightResult = Either.map(Either.right(1), (n) => n + 1) console.log(rightResult) /* Output: { _id: 'Either', _tag: 'Right', right: 2 } */ // The transformation is ignored for Left values const leftResult = Either.map(Either.left("not a number"), (n) => n + 1) console.log(leftResult) /* Output: { _id: 'Either', _tag: 'Left', left: 'not a number' } */ ``` ### 映射 Left 值 使用 `Either.mapLeft` 转换一个 `Either` 的 `Left` 值。所提供的函数只会作用于 `Left` 值,`Right` 值保持不变。 **示例**(转换 Left 值) ```ts import { Either } from "effect" // The transformation is ignored for Right values const rightResult = Either.mapLeft(Either.right(1), (s) => s + "!") console.log(rightResult) /* Output: { _id: 'Either', _tag: 'Right', right: 1 } */ // Transform the Left value by appending "!" const leftResult = Either.mapLeft(Either.left("not a number"), (s) => s + "!") console.log(leftResult) /* Output: { _id: 'Either', _tag: 'Left', left: 'not a number!' } */ ``` ### 同时映射两个值 使用 `Either.mapBoth` 同时转换一个 `Either` 的 `Left` 值和 `Right` 值。这个函数接收两个独立的转换函数:一个用于 `Left` 值,另一个用于 `Right` 值。 **示例**(同时转换 Left 与 Right 值) ```ts import { Either } from "effect" const transformedRight = Either.mapBoth(Either.right(1), { onLeft: (s) => s + "!", onRight: (n) => n + 1, }) console.log(transformedRight) /* Output: { _id: 'Either', _tag: 'Right', right: 2 } */ const transformedLeft = Either.mapBoth(Either.left("not a number"), { onLeft: (s) => s + "!", onRight: (n) => n + 1, }) console.log(transformedLeft) /* Output: { _id: 'Either', _tag: 'Left', left: 'not a number!' } */ ``` ## 与 Effect 互操作 `Either` 类型可以作为 `Effect` 类型的子类型使用,因此你可以把它与 `Effect` 模块中的函数一起使用。虽然这些函数是为处理 `Effect` 值而构建的,但它们同样能正确处理 `Either` 值。 ### Either 如何映射到 Effect | Either 变体 | 映射为 Effect | 说明 | | ----------- | ------------------ | -------- | | `Left` | `Effect` | 表示失败 | | `Right` | `Effect` | 表示成功 | **示例**(将 `Either` 与 `Effect` 结合使用) ```ts import { Effect, Either } from "effect" // Function to get the head of an array, returning Either const head = (array: ReadonlyArray): Either.Either => array.length > 0 ? Either.right(array[0]) : Either.left("empty array") // Simulated fetch function that returns Effect const fetchData = (): Effect.Effect => { const success = Math.random() > 0.5 return success ? Effect.succeed("some data") : Effect.fail("Failed to fetch data") } // Mixing Either and Effect const program = Effect.all([head([1, 2, 3]), fetchData()]) Effect.runPromise(program).then(console.log) /* Example Output: [ 1, 'some data' ] */ ``` ## 组合两个或多个 Either ### zipWith `Either.zipWith` 函数让你用一个提供的函数来组合两个 `Either` 值。它会创建一个新的 `Either`,其中保存着两个原始 `Either` 值组合后的结果。 **示例**(将两个 Either 组合成一个对象) ```ts import { Either } from "effect" const maybeName: Either.Either = Either.right("John") const maybeAge: Either.Either = Either.right(25) // Combine the name and age into a person object const person = Either.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) /* Output: { _id: 'Either', _tag: 'Right', right: { name: 'JOHN', age: 25 } } */ ``` 如果两个 `Either` 值中有任意一个是 `Left`,结果就会是 `Left`,并保存最先遇到的那个 `Left` 值: **示例**(组合时包含 Left 值) ```ts import { Either } from "effect" const maybeName: Either.Either = Either.right("John") const maybeAge: Either.Either = Either.left("Oh no!") // Since maybeAge is a Left, the result will also be Left const person = Either.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) /* Output: { _id: 'Either', _tag: 'Left', left: 'Oh no!' } */ ``` ### all 如果想在不变换内容的情况下组合多个 `Either` 值,可以使用 `Either.all`。这个函数返回一个结构与输入相匹配的 `Either`: - 如果传入元组(tuple),结果就是长度相同的元组。 - 如果传入结构体(struct),结果就是键相同的结构体。 - 如果传入 `Iterable`,结果就是一个数组。 **示例**(将多个 Either 组合成元组和结构体) ```ts import { Either } from "effect" const maybeName: Either.Either = Either.right("John") const maybeAge: Either.Either = Either.right(25) // ┌─── Either<[string, number], string> // ▼ const tuple = Either.all([maybeName, maybeAge]) console.log(tuple) /* Output: { _id: 'Either', _tag: 'Right', right: [ 'John', 25 ] } */ // ┌─── Either<{ name: string; age: number; }, string> // ▼ const struct = Either.all({ name: maybeName, age: maybeAge }) console.log(struct) /* Output: { _id: 'Either', _tag: 'Right', right: { name: 'John', age: 25 } } */ ``` 如果有一个或多个 `Either` 值是 `Left`,则返回最先遇到的 `Left`: **示例**(处理多个 Left 值) ```ts import { Either } from "effect" const maybeName: Either.Either = Either.left("name not found") const maybeAge: Either.Either = Either.left("age not found") // The first Left value will be returned console.log(Either.all([maybeName, maybeAge])) /* Output: { _id: 'Either', _tag: 'Left', left: 'name not found' } */ ``` ## gen 与 [Effect.gen](/docs/v3/getting-started/using-generators/) 类似,`Either.gen` 提供了更易读的、基于生成器的语法来处理 `Either` 值,让涉及 `Either` 的代码更易编写和理解。这种方式类似于使用 `async/await`,但专为 `Either` 量身定制。 **示例**(使用 `Either.gen` 创建组合值) ```ts import { Either } from "effect" const maybeName: Either.Either = Either.right("John") const maybeAge: Either.Either = Either.right(25) const program = Either.gen(function* () { const name = (yield* maybeName).toUpperCase() const age = yield* maybeAge return { name, age } }) console.log(program) /* Output: { _id: 'Either', _tag: 'Right', right: { name: 'JOHN', age: 25 } } */ ``` 当序列中任意一个 `Either` 值是 `Left` 时,生成器会立即返回该 `Left` 值,跳过后续操作: **示例**(用 `Either.gen` 处理 `Left` 值) 在这个示例中,`Either.gen` 一遇到 `Left` 值就停止执行,从而在不进行后续操作的情况下有效地传播错误。 ```ts import { Either } from "effect" const maybeName: Either.Either = Either.left("Oh no!") const maybeAge: Either.Either = Either.right(25) const program = Either.gen(function* () { console.log("Retrieving name...") const name = (yield* maybeName).toUpperCase() console.log("Retrieving age...") const age = yield* maybeAge return { name, age } }) console.log(program) /* Output: Retrieving name... { _id: 'Either', _tag: 'Left', left: 'Oh no!' } */ ``` 这些示例中使用 `console.log` 仅用于演示。在使用 `Either.gen` 时,请避免在生成器函数中引入副作用,因为 `Either` 应当保持为一种纯数据结构。 --- # Exit > 用 Exit 表示 Effect 工作流的结果,捕获成功值或失败原因。 `Exit` 描述运行一个 `Effect` 工作流所得到的结果。 `Exit` 有两种可能的状态: - `Exit.Success`:包含类型为 `A` 的成功值。 - `Exit.Failure`:包含类型为 `E` 的失败 [Cause](/docs/v3/data-types/cause/)。 ## 创建 Exit Exit 模块提供了两个用于构造 Exit 值的主要函数:`Exit.succeed` 和 `Exit.failCause`。 这两个函数用成功或失败来描述一个带 effect 的计算的结果。 ### succeed `Exit.succeed` 会创建一个表示成功结果的 `Exit` 值。 当你想要表明某个计算已成功完成,并给出其结果值时,就使用这个函数。 **示例**(创建一个成功的 Exit) ```ts import { Exit } from "effect" // Create an Exit representing a successful outcome with the value 42 // // ┌─── Exit // ▼ const successExit = Exit.succeed(42) console.log(successExit) // Output: { _id: 'Exit', _tag: 'Success', value: 42 } ``` ### failCause `Exit.failCause` 会创建一个表示失败的 `Exit` 值。 这个失败通过一个 [Cause](/docs/v3/data-types/cause/) 对象来描述,该对象可以封装预期错误、defect、中断,甚至复合错误。 **示例**(创建一个失败的 Exit) ```ts import { Exit, Cause } from "effect" // Create an Exit representing a failure with an error message // // ┌─── Exit // ▼ const failureExit = Exit.failCause(Cause.fail("Something went wrong")) console.log(failureExit) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Something went wrong' } } */ ``` ## 模式匹配 你可以使用 `Exit.match` 函数来处理 `Exit` 的不同结果。 这个函数让你可以提供两个独立的回调,分别处理 `Effect` 执行的成功与失败两种情况。 **示例**(匹配成功与失败状态) ```ts import { Effect, Exit, Cause } from "effect" // ┌─── Exit // ▼ const simulatedSuccess = Effect.runSyncExit(Effect.succeed(1)) console.log( Exit.match(simulatedSuccess, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }), ) // Output: "Exited with success value: 1" // ┌─── Exit // ▼ const simulatedFailure = Effect.runSyncExit( Effect.failCause(Cause.fail("error")), ) console.log( Exit.match(simulatedFailure, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }), ) // Output: "Exited with failure state: Error: error" ``` ## Exit 与 Either 从概念上讲,`Exit` 可以看作 `Either>`。不过,[Cause](/docs/v3/data-types/cause/) 类型所表示的不只是类型为 `E` 的预期错误,它还包括: - 中断原因 - defect(意外错误) - 多个 Cause 的组合 与简单的 `Either` 相比,这让 `Cause` 能够捕获更丰富、更复杂的错误状态。 ## Exit 与 Effect `Exit` 实际上是 `Effect` 的子类型。这意味着 `Exit` 值也可以被视为 `Effect` 值。 - 从本质上说,一个 `Exit` 就是一个“常量计算”。 - `Effect.succeed` 本质上与 `Exit.succeed` 相同。 - `Effect.failCause` 与 `Exit.failCause` 相同。 --- # HashSet > 了解 HashSet 数据结构 —— 既有不可变版本,也有可变版本。 HashSet 表示一个由**唯一**值组成的**无序**集合,并支持高效的查找、插入与删除操作。 Effect 库为该结构提供了两个版本: - [HashSet](/docs/v3/data-types/hash-set/#hashset) —— 不可变版本 - [MutableHashSet](/docs/v3/data-types/hash-set/#mutablehashset) —— 可变版本 两个版本的平均操作复杂度都是常数级。主要区别在于它们如何处理变更:一个返回新的集合,另一个则直接修改原集合。 ### 为什么使用 HashSet? HashSet 解决的是这样一个问题:维护一个**值不重复的无序集合**,并提供快速的成员检查与值的添加/删除操作。 一些常见的使用场景包括: - 跟踪唯一元素(例如已完成某个操作的用户) - 高效地判断某个值是否属于集合 - 执行并集、交集、差集等集合运算 - 从集合中消除重复项 ### 何时用 HashSet 替代其他集合 在以下情况下,应选择 HashSet(任一版本)而不是其他集合: - 你需要确保元素唯一 - 你经常需要检查某个元素是否存在于集合中 - 你需要执行并集、交集、差集等集合运算 - 元素的顺序对你的用例并不重要 在以下情况下,应选择其他集合: - 你需要保持插入顺序(使用 `List` 或 `Array`) - 你需要键值关联(使用 `HashMap` 或 `MutableHashMap`) - 你需要频繁按下标访问元素(使用 `Array`) ### 在不可变与可变版本之间做选择 Effect 同时提供不可变和可变的版本,以支持不同的编码风格与性能需求。 **HashSet** 该版本从不修改原集合,而是为每次变更返回一个新集合。 特点: - 操作返回新实例,而不是修改原集合 - 保留之前的状态 - 设计上线程安全 - 适合函数式编程模式 - 适合在应用的不同部分之间共享 **MutableHashSet** 该版本允许直接更新:添加和删除值会就地修改集合。 特点: - 操作直接修改原集合 - 在增量构建集合时更高效 - 需要小心处理,以避免意外的副作用 - 在修改频繁的场景中性能更好 - 适合局部使用,即修改不会影响其他位置 ### 何时使用哪个版本 在以下情况下使用 **HashSet**: - 你需要可预测且无副作用的行为 - 你希望保留数据的之前状态 - 你要在应用的不同部分之间共享集合 - 你偏好函数式编程模式 - 你需要在并发环境中保证 Fiber 安全 在以下情况下使用 **MutableHashSet**: - 性能至关重要,且你需要避免创建新实例 - 你正在以大量添加/删除的方式增量构建集合 - 你在一个可以安全修改的受控作用域中工作 - 你需要在性能关键的代码中优化内存占用 ### 混合使用 你可以借助 `HashSet.mutate`,在一个临时的可变上下文中对 `HashSet` 施加多次更新。这样就能一次性完成多项变更,而不会修改原集合。 **示例**(批量修改而不改动原集合) ```ts import { HashSet } from "effect" // Create an immutable HashSet const original = HashSet.make(1, 2, 3) // Apply several updates inside a temporary mutable draft const modified = HashSet.mutate(original, (draft) => { HashSet.add(draft, 4) HashSet.add(draft, 5) HashSet.remove(draft, 1) }) console.log(HashSet.toValues(original)) // Output: [1, 2, 3] - original remains unchanged console.log(HashSet.toValues(modified)) // Output: [2, 3, 4, 5] - changes applied to a new version ``` ## 性能特征 `HashSet` 与 `MutableHashSet` 在核心操作上提供相近的平均时间复杂度: | 操作 | HashSet | MutableHashSet | 说明 | | -------------- | ------------ | -------------- | ------------------------------- | | 查找 | O(1) 平均 | O(1) 平均 | 检查某个值是否存在 | | 插入 | O(1) 平均 | O(1) 平均 | 添加一个值 | | 删除 | O(1) 平均 | O(1) 平均 | 删除一个值 | | 迭代 | O(n) | O(n) | 遍历所有值 | | 集合运算 | O(n) | O(n) | 并集、交集、差集 | 主要区别在于更新是如何处理的: - **HashSet** 每次变更都返回一个新集合。如果连续进行大量变更,这可能较慢。 - **MutableHashSet** 就地更新同一个集合。在进行大量变更时,这通常更快。 ## 相等性与唯一性 `HashSet` 与 `MutableHashSet` 都使用 Effect 的 [`Equal`](/docs/v3/trait/equal/) trait 来判断两个元素是否相同。这确保了每个值在集合中只出现一次。 - **原始值**(如数字或字符串)按值比较,类似于 `===` 运算符。 - **对象与自定义类型**必须实现 `Equal` 接口,以定义两个实例在什么意义上相等。如果没有提供实现,相等性判断会回退到引用比较。 **示例**(使用自定义的相等性与哈希) ```ts import { Equal, Hash, HashSet } from "effect" // Define a custom class that implements the Equal interface class Person implements Equal.Equal { constructor( readonly id: number, readonly name: string, readonly age: number, ) {} // Two Person instances are equal if their id, name, and age match [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Hash code is based on the id (must match the equality logic) [Hash.symbol](): number { return Hash.hash(this.id) } } // Add two different instances with the same content const set = HashSet.empty().pipe( HashSet.add(new Person(1, "Alice", 30)), HashSet.add(new Person(1, "Alice", 30)), ) // Only one instance is kept console.log(HashSet.size(set)) // Output: 1 ``` ### 用 Data 和 Schema 简化相等性 Effect 的 [`Data`](/docs/v3/data-types/data/) 与 [`Schema.Data`](/docs/v3/schema/effect-data-types/#interop-with-data) 模块会基于结构相等性,自动为你实现 `Equal`。 **示例**(使用 `Data.struct`) ```ts import { Data, Equal, HashSet, pipe } from "effect" // Define two records with the same content const person1 = Data.struct({ id: 1, name: "Alice", age: 30 }) const person2 = Data.struct({ id: 1, name: "Alice", age: 30 }) // They are different object references console.log(Object.is(person1, person2)) // Output: false // But they are equal in value (based on content) console.log(Equal.equals(person1, person2)) // Output: true // Add both to a HashSet — only one will be stored const set = pipe(HashSet.empty(), HashSet.add(person1), HashSet.add(person2)) console.log(HashSet.size(set)) // Output: 1 ``` **示例**(使用 `Schema.Data`) ```ts import { Equal, MutableHashSet, Schema } from "effect" // Define a schema that describes the structure of a Person const PersonSchema = Schema.Data( Schema.Struct({ id: Schema.Number, name: Schema.String, age: Schema.Number, }), ) // Decode values from plain objects const Person = Schema.decodeSync(PersonSchema) const person1 = Person({ id: 1, name: "Alice", age: 30 }) const person2 = Person({ id: 1, name: "Alice", age: 30 }) // person1 and person2 are different instances but equal in value console.log(Equal.equals(person1, person2)) // Output: true // Add both to a MutableHashSet — only one will be stored const set = MutableHashSet.empty().pipe( MutableHashSet.add(person1), MutableHashSet.add(person2), ) console.log(MutableHashSet.size(set)) // Output: 1 ``` ## HashSet `HashSet` 是一个**不可变**、**无序**且值**唯一**的集合。 它保证每个值只出现一次,并支持查找、插入、删除等快速操作。 任何会修改集合的操作(例如添加或删除值)都会返回一个新的 `HashSet`,而原集合保持不变。 ### 操作 | 分类 | 操作 | 说明 | 时间复杂度 | | ------------ | -------------------------------------------------------- | ------------------------------------------- | --------------- | | 构造器 | [empty](https://effect.website/docs/v3/api/effect/HashSet#empty) | 创建一个空 HashSet | O(1) | | 构造器 | [fromIterable](https://effect.website/docs/v3/api/effect/HashSet#fromIterable) | 从可迭代对象创建 HashSet | O(n) | | 构造器 | [make](https://effect.website/docs/v3/api/effect/HashSet#make) | 从多个值创建 HashSet | O(n) | | 元素 | [has](https://effect.website/docs/v3/api/effect/HashSet#has) | 检查某个值是否存在于集合中 | O(1) 平均 | | 元素 | [some](https://effect.website/docs/v3/api/effect/HashSet#some) | 检查是否有任一元素满足谓词 | O(n) | | 元素 | [every](https://effect.website/docs/v3/api/effect/HashSet#every) | 检查是否所有元素都满足谓词 | O(n) | | 元素 | [isSubset](https://effect.website/docs/v3/api/effect/HashSet#isSubset) | 检查一个集合是否为另一个集合的子集 | O(n) | | 读取器 | [values](https://effect.website/docs/v3/api/effect/HashSet#values) | 获取所有值的 `Iterator` | O(1) | | 读取器 | [toValues](https://effect.website/docs/v3/api/effect/HashSet#toValues) | 获取所有值的 `Array` | O(n) | | 读取器 | [size](https://effect.website/docs/v3/api/effect/HashSet#size) | 获取元素数量 | O(1) | | 变更 | [add](https://effect.website/docs/v3/api/effect/HashSet#add) | 向集合中添加一个值 | O(1) 平均 | | 变更 | [remove](https://effect.website/docs/v3/api/effect/HashSet#remove) | 从集合中删除一个值 | O(1) 平均 | | 变更 | [toggle](https://effect.website/docs/v3/api/effect/HashSet#toggle) | 切换某个值的存在状态 | O(1) 平均 | | 运算 | [difference](https://effect.website/docs/v3/api/effect/HashSet#difference) | 计算集合差集(A - B) | O(n) | | 运算 | [intersection](https://effect.website/docs/v3/api/effect/HashSet#intersection) | 计算集合交集(A ∩ B) | O(n) | | 运算 | [union](https://effect.website/docs/v3/api/effect/HashSet#union) | 计算集合并集(A ∪ B) | O(n) | | 映射 | [map](https://effect.website/docs/v3/api/effect/HashSet#map) | 转换每个元素 | O(n) | | 序列操作 | [flatMap](https://effect.website/docs/v3/api/effect/HashSet#flatMap) | 转换并展平元素 | O(n) | | 遍历 | [forEach](https://effect.website/docs/v3/api/effect/HashSet#forEach) | 对每个元素应用一个函数 | O(n) | | 折叠 | [reduce](https://effect.website/docs/v3/api/effect/HashSet#reduce) | 将集合归约为单个值 | O(n) | | 过滤 | [filter](https://effect.website/docs/v3/api/effect/HashSet#filter) | 保留满足谓词的元素 | O(n) | | 分区 | [partition](https://effect.website/docs/v3/api/effect/HashSet#partition) | 按谓词拆分为两个集合 | O(n) | **示例**(基本的创建与操作) ```ts import { HashSet } from "effect" // Create an initial set with 3 values const set1 = HashSet.make(1, 2, 3) // Add a value (returns a new set) const set2 = HashSet.add(set1, 4) // The original set is unchanged console.log(HashSet.toValues(set1)) // Output: [1, 2, 3] console.log(HashSet.toValues(set2)) // Output: [1, 2, 3, 4] // Perform set operations with another set const set3 = HashSet.make(3, 4, 5) // Combine both sets const union = HashSet.union(set2, set3) console.log(HashSet.toValues(union)) // Output: [1, 2, 3, 4, 5] // Shared values const intersection = HashSet.intersection(set2, set3) console.log(HashSet.toValues(intersection)) // Output: [3, 4] // Values only in set2 const difference = HashSet.difference(set2, set3) console.log(HashSet.toValues(difference)) // Output: [1, 2] ``` **示例**(用 `pipe` 串联操作) ```ts import { HashSet, pipe } from "effect" const result = pipe( // Duplicates are ignored HashSet.make(1, 2, 2, 3, 4, 5, 5), // Keep even numbers HashSet.filter((n) => n % 2 === 0), // Double each value HashSet.map((n) => n * 2), // Convert to array HashSet.toValues, ) console.log(result) // Output: [4, 8] ``` ## MutableHashSet `MutableHashSet` 是一个**可变**、**无序**且值**唯一**的集合。 与 `HashSet` 不同,它允许直接修改:`add`、`remove`、`clear` 等操作会更新原集合,而不是返回一个新集合。 在你需要反复构建或更新集合时(尤其是在局部或隔离的作用域内),这种可变性可以提升性能。 ### 操作 | 分类 | 操作 | 说明 | 复杂度 | | ------------ | --------------------------------------------------------------- | ----------------------------------- | ---------- | | 构造器 | [empty](https://effect.website/docs/v3/api/effect/MutableHashSet#empty) | 创建一个空 MutableHashSet | O(1) | | 构造器 | [fromIterable](https://effect.website/docs/v3/api/effect/MutableHashSet#fromIterable) | 从可迭代对象创建集合 | O(n) | | 构造器 | [make](https://effect.website/docs/v3/api/effect/MutableHashSet#make) | 从多个值创建集合 | O(n) | | 元素 | [has](https://effect.website/docs/v3/api/effect/MutableHashSet#has) | 检查某个值是否存在于集合中 | O(1) 平均 | | 元素 | [add](https://effect.website/docs/v3/api/effect/MutableHashSet#add) | 向集合中添加一个值 | O(1) 平均 | | 元素 | [remove](https://effect.website/docs/v3/api/effect/MutableHashSet#remove) | 从集合中删除一个值 | O(1) 平均 | | 读取器 | [size](https://effect.website/docs/v3/api/effect/MutableHashSet#size) | 获取元素数量 | O(1) | | 变更 | [clear](https://effect.website/docs/v3/api/effect/MutableHashSet#clear) | 删除集合中的所有值 | O(1) | **示例**(使用可变集合) ```ts import { MutableHashSet } from "effect" // Create a mutable set with initial values const set = MutableHashSet.make(1, 2, 3) // Add a new element (updates the set in place) MutableHashSet.add(set, 4) // Check current contents console.log([...set]) // Output: [1, 2, 3, 4] // Remove an element (modifies in place) MutableHashSet.remove(set, 1) console.log([...set]) // Output: [2, 3, 4] // Clear the set entirely MutableHashSet.clear(set) console.log(MutableHashSet.size(set)) // Output: 0 ``` ## 与 JavaScript 的互操作性 `HashSet` 与 `MutableHashSet` 都实现了 `Iterable` 接口,因此可以将它们用于 JavaScript 的以下特性: - 展开运算符(`...`) - `for...of` 循环 - `Array.from` 你也可以用 `.toValues` 把其中的值提取成数组。 **示例**(以 JS 原生方式使用 HashSet 的值) ```ts import { HashSet, MutableHashSet } from "effect" // Immutable HashSet const hashSet = HashSet.make(1, 2, 3) // Mutable variant const mutableSet = MutableHashSet.make(4, 5, 6) // Convert HashSet to an iterator // // ┌─── IterableIterator // ▼ const iterable = HashSet.values(hashSet) // Spread into console.log console.log(...iterable) // Output: 1 2 3 // Use in a for...of loop for (const value of mutableSet) { console.log(value) } // Output: 4 5 6 // Convert to array with Array.from console.log(Array.from(mutableSet)) // Output: [ 4, 5, 6 ] // Convert immutable HashSet to array using toValues // // ┌─── Array // ▼ const array = HashSet.toValues(hashSet) console.log(array) // Output: [ 1, 2, 3 ] ``` --- # Option > 用 Option 表示可选值,既可以是存在(Some),也可以是缺失(None),并支持映射、组合与模式匹配等无缝操作。 `Option` 数据类型表示可选值。一个 `Option` 要么是 `Some`,包含一个类型为 `A` 的值;要么是 `None`,表示值的缺失。 你可以在以下场景中使用 `Option`: - 用作初始值 - 从并非对所有可能输入都有定义的函数(即「偏函数」,partial function)中返回值 - 管理数据结构中的可选字段 - 处理可选的函数参数 ## 创建 Option ### some 使用 `Option.some` 构造器创建一个持有类型 `A` 值的 `Option`。 **示例**(创建一个带值的 Option) ```ts import { Option } from "effect" // An Option holding the number 1 const value = Option.some(1) console.log(value) // Output: { _id: 'Option', _tag: 'Some', value: 1 } ``` ### none 使用 `Option.none` 构造器创建一个表示值缺失的 `Option`。 **示例**(创建一个没有值的 Option) ```ts import { Option } from "effect" // An Option holding no value const noValue = Option.none() console.log(noValue) // Output: { _id: 'Option', _tag: 'None' } ``` ### liftPredicate 你可以基于谓词创建 `Option`,例如检查一个值是否为正数。 **示例**(显式创建 Option) 下面展示如何用 `Option.none` 和 `Option.some` 实现这一点: ```ts import { Option } from "effect" const isPositive = (n: number) => n > 0 const parsePositive = (n: number): Option.Option => isPositive(n) ? Option.some(n) : Option.none() ``` **示例**(用 `Option.liftPredicate` 让代码更简洁) 或者,你可以用 `Option.liftPredicate` 简化上面的逻辑: ```ts import { Option } from "effect" const isPositive = (n: number) => n > 0 // ┌─── (b: number) => Option // ▼ const parsePositive = Option.liftPredicate(isPositive) ``` ## 为可选属性建模 考虑一个 `User` 模型,其中 `"email"` 属性是可选的,可以保存 `string` 值。我们用 `Option` 类型来表示这个可选属性: ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option } ``` 下面的示例展示了如何创建带 email 和不带 email 的 `User` 实例: **示例**(创建带 email 和不带 email 的 User) ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option } const withEmail: User = { id: 1, username: "john_doe", email: Option.some("john.doe@example.com"), } const withoutEmail: User = { id: 2, username: "jane_doe", email: Option.none(), } ``` ## 类型守卫 你可以使用 `Option.isSome` 和 `Option.isNone` 这两个守卫检查一个 `Option` 是 `Some` 还是 `None`。 **示例**(用守卫检查 Option 的值) ```ts import { Option } from "effect" const foo = Option.some(1) console.log(Option.isSome(foo)) // Output: true if (Option.isNone(foo)) { console.log("Option is empty") } else { console.log(`Option has a value: ${foo.value}`) } // Output: "Option has a value: 1" ``` ## 模式匹配 使用 `Option.match` 处理 `Option` 的两种情况:分别为 `None` 和 `Some` 指定独立的回调。 **示例**(对 Option 进行模式匹配) ```ts import { Option } from "effect" const foo = Option.some(1) const message = Option.match(foo, { onNone: () => "Option is empty", onSome: (value) => `Option has a value: ${value}`, }) console.log(message) // Output: "Option has a value: 1" ``` ## 使用 Option ### map `Option.map` 函数让你无需手动解包再重新包装,就能转换 `Option` 内部的值。如果 `Option` 持有值(`Some`),就应用该转换函数。如果 `Option` 是 `None`,则忽略该函数,`Option` 保持不变。 **示例**(映射 Some 中的值) ```ts import { Option } from "effect" // Transform the value inside Some console.log(Option.map(Option.some(1), (n) => n + 1)) // Output: { _id: 'Option', _tag: 'Some', value: 2 } ``` 处理 `None` 时,映射函数不会执行,`Option` 仍然是 `None`: **示例**(对 None 进行映射) ```ts import { Option } from "effect" // Mapping over None results in None console.log(Option.map(Option.none(), (n) => n + 1)) // Output: { _id: 'Option', _tag: 'None' } ``` ### flatMap `Option.flatMap` 函数与 `Option.map` 类似,但它用于处理转换可能返回另一个 `Option` 的情况。这让我们能够串联那些依赖于 `Option` 中是否存在值的计算。 考虑一个 `User` 模型,它包含嵌套的可选 `Address`,而 `Address` 自身又包含可选的 `street` 属性: ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option readonly address: Option.Option
} interface Address { readonly city: string readonly street: Option.Option } ``` 在这个模型中,`address` 字段是 `Option
`,而 `Address` 中的 `street` 字段是 `Option`。 我们可以用 `Option.flatMap` 从 `address` 中提取 `street` 属性: **示例**(提取嵌套的可选属性) ```ts import { Option } from "effect" interface Address { readonly city: string readonly street: Option.Option } interface User { readonly id: number readonly username: string readonly email: Option.Option readonly address: Option.Option
} const user: User = { id: 1, username: "john_doe", email: Option.some("john.doe@example.com"), address: Option.some({ city: "New York", street: Option.some("123 Main St"), }), } // Use flatMap to extract the street value const street = user.address.pipe(Option.flatMap((address) => address.street)) console.log(street) // Output: { _id: 'Option', _tag: 'Some', value: '123 Main St' } ``` 如果 `user.address` 是 `Some`,`Option.flatMap` 会应用函数 `(address) => address.street` 来取出 `street` 值。 如果 `user.address` 是 `None`,该函数不会执行,`street` 保持为 `None`。 这种方式让我们能够简洁地处理嵌套的可选值,避免手动检查,使代码更干净、更易读。 ### filter `Option.filter` 函数允许你根据给定的谓词过滤 `Option`。如果谓词不满足,或者 `Option` 是 `None`,结果将是 `None`。 **示例**(过滤 Option 的值) 下面展示如何用 `Option.filter` 简化一些代码,写出更符合习惯的写法: 原始代码 ```ts import { Option } from "effect" // Function to remove empty strings from an Option const removeEmptyString = (input: Option.Option) => { if (Option.isSome(input) && input.value === "") { return Option.none() // Return None if the value is an empty string } return input // Otherwise, return the original Option } console.log(removeEmptyString(Option.none())) // Output: { _id: 'Option', _tag: 'None' } console.log(removeEmptyString(Option.some(""))) // Output: { _id: 'Option', _tag: 'None' } console.log(removeEmptyString(Option.some("a"))) // Output: { _id: 'Option', _tag: 'Some', value: 'a' } ``` 重构后的习惯写法 使用 `Option.filter`,我们可以更简洁地写出同样的逻辑: ```ts import { Option } from "effect" const removeEmptyString = (input: Option.Option) => Option.filter(input, (value) => value !== "") console.log(removeEmptyString(Option.none())) // Output: { _id: 'Option', _tag: 'None' } console.log(removeEmptyString(Option.some(""))) // Output: { _id: 'Option', _tag: 'None' } console.log(removeEmptyString(Option.some("a"))) // Output: { _id: 'Option', _tag: 'Some', value: 'a' } ``` ## 从 Option 中取值 要从 `Option` 内部取出存储的值,你可以使用 `Option` 模块提供的几个辅助函数。下面是可用方法的概览: ### getOrThrow 该函数从 `Some` 中提取值。如果 `Option` 是 `None`,它会抛出错误。 **示例**(取出值或抛出错误) ```ts import { Option } from "effect" console.log(Option.getOrThrow(Option.some(10))) // Output: 10 console.log(Option.getOrThrow(Option.none())) // throws: Error: getOrThrow called on a None ``` ### getOrNull / getOrUndefined 这些函数把 `None` 转换为 `null` 或 `undefined`,在与非 `Option` 风格的代码交互时很有用。 **示例**(把 `None` 转换为 `null` 或 `undefined`) ```ts import { Option } from "effect" console.log(Option.getOrNull(Option.some(5))) // Output: 5 console.log(Option.getOrNull(Option.none())) // Output: null console.log(Option.getOrUndefined(Option.some(5))) // Output: 5 console.log(Option.getOrUndefined(Option.none())) // Output: undefined ``` ### getOrElse 该函数允许你指定当 `Option` 为 `None` 时返回的默认值。 **示例**(当 `None` 时提供默认值) ```ts import { Option } from "effect" console.log(Option.getOrElse(Option.some(5), () => 0)) // Output: 5 console.log(Option.getOrElse(Option.none(), () => 0)) // Output: 0 ``` ## 回退 ### orElse 当一次计算返回 `None` 时,你可能想尝试另一个会产生 `Option` 的计算。`Option.orElse` 函数在这种情况下很有用。它让你能够串联多个计算:如果当前计算得到 `None`,就继续尝试下一个。这种方式常用于重试逻辑,不断尝试计算,直到有一个成功或所有可能性都用尽。 **示例**(尝试备选计算) ```ts import { Option } from "effect" // Simulating a computation that may or may not produce a result const computation = (): Option.Option => Math.random() < 0.5 ? Option.some(10) : Option.none() // Simulates an alternative computation const alternativeComputation = (): Option.Option => Math.random() < 0.5 ? Option.some(20) : Option.none() // Attempt the first computation, then try an alternative if needed const program = computation().pipe( Option.orElse(() => alternativeComputation()), ) const result = Option.match(program, { onNone: () => "Both computations resulted in None", // At least one computation succeeded onSome: (value) => `Computed value: ${value}`, }) console.log(result) // Output: Computed value: 10 ``` ### firstSomeOf 你也可以用 `Option.firstSomeOf` 从一组 `Option` 值(可迭代对象)中取出第一个 `Some` 值: **示例**(取出第一个 `Some` 值) ```ts import { Option } from "effect" const first = Option.firstSomeOf([ Option.none(), Option.some(2), Option.none(), Option.some(3), ]) console.log(first) // Output: { _id: 'Option', _tag: 'Some', value: 2 } ``` ## 与可空类型互操作 处理 `Option` 数据类型时,你可能会遇到用 `undefined` 或 `null` 表示可选值的代码。`Option` 模块提供了若干 API,让与这些可空类型的交互变得简单直接。 ### fromNullable `Option.fromNullable` 把一个可空值(`null` 或 `undefined`)转换为 `Option`。如果值是 `null` 或 `undefined`,它返回 `Option.none()`。否则,它把值包装进 `Option.some()`。 **示例**(从可空值创建 Option) ```ts import { Option } from "effect" console.log(Option.fromNullable(null)) // Output: { _id: 'Option', _tag: 'None' } console.log(Option.fromNullable(undefined)) // Output: { _id: 'Option', _tag: 'None' } console.log(Option.fromNullable(1)) // Output: { _id: 'Option', _tag: 'Some', value: 1 } ``` 如果你需要把 `Option` 转换回可空值,有两个辅助方法: - `Option.getOrNull`:把 `None` 转换为 `null`。 - `Option.getOrUndefined`:把 `None` 转换为 `undefined`。 ## 与 Effect 互操作 `Option` 类型可以作为 `Effect` 类型的子类型使用,因此你可以把它与 `Effect` 模块中的函数一起使用。这些函数本是为处理 `Effect` 值而构建的,但它们同样能正确处理 `Option` 值。 ### Option 如何映射到 Effect | Option 变体 | 映射到的 Effect | 说明 | | --- | --- | --- | | `None` | `Effect` | 表示值缺失 | | `Some` | `Effect` | 表示值存在 | **示例**(把 `Option` 与 `Effect` 结合使用) ```ts import { Effect, Option } from "effect" // Function to get the head of an array, returning Option const head = (array: ReadonlyArray): Option.Option => array.length > 0 ? Option.some(array[0]) : Option.none() // Simulated fetch function that returns Effect const fetchData = (): Effect.Effect => { const success = Math.random() > 0.5 return success ? Effect.succeed("some data") : Effect.fail("Failed to fetch data") } // Mixing Either and Effect const program = Effect.all([head([1, 2, 3]), fetchData()]) Effect.runPromise(program).then(console.log) /* Example Output: [ 1, 'some data' ] */ ``` ## 组合两个或多个 Option ### zipWith `Option.zipWith` 函数让你用一个给定的函数组合两个 `Option` 值。它会创建一个新的 `Option`,其中保存两个原始 `Option` 值的组合结果。 **示例**(把两个 Option 组合成一个对象) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) // Combine the name and age into a person object const person = Option.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) /* Output: { _id: 'Option', _tag: 'Some', value: { name: 'JOHN', age: 25 } } */ ``` 如果其中任意一个 `Option` 值是 `None`,结果就会是 `None`: **示例**(处理 None 值) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.none() // Since maybeAge is a None, the result will also be None const person = Option.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) // Output: { _id: 'Option', _tag: 'None' } ``` ### all 如果你想在不转换内容的情况下组合多个 `Option` 值,可以使用 `Option.all`。这个函数返回的 `Option` 具有与输入相匹配的结构: - 如果传入元组,结果就是长度相同的元组。 - 如果传入 struct,结果就是包含相同键的 struct。 - 如果传入 `Iterable`,结果就是数组。 **示例**(把多个 Option 组合成元组与 struct) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) // ┌─── Option<[string, number]> // ▼ const tuple = Option.all([maybeName, maybeAge]) console.log(tuple) /* Output: { _id: 'Option', _tag: 'Some', value: [ 'John', 25 ] } */ // ┌─── Option<{ name: string; age: number; }> // ▼ const struct = Option.all({ name: maybeName, age: maybeAge }) console.log(struct) /* Output: { _id: 'Option', _tag: 'Some', value: { name: 'John', age: 25 } } */ ``` 如果其中任意一个 `Option` 值是 `None`,结果就会是 `None`: **示例** ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.none() console.log(Option.all([maybeName, maybeAge])) // Output: { _id: 'Option', _tag: 'None' } ``` ## gen 与 [Effect.gen](/docs/v3/getting-started/using-generators/) 类似,`Option.gen` 提供了一种更具可读性的、基于生成器的语法来处理 `Option` 值,让涉及 `Option` 的代码更易编写和理解。这种方式与使用 `async/await` 类似,但专为 `Option` 量身定制。 **示例**(使用 `Option.gen` 创建一个组合值) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) const person = Option.gen(function* () { const name = (yield* maybeName).toUpperCase() const age = yield* maybeAge return { name, age } }) console.log(person) /* Output: { _id: 'Option', _tag: 'Some', value: { name: 'JOHN', age: 25 } } */ ``` 当序列中任意一个 `Option` 值是 `None` 时,生成器会立即返回该 `None` 值,并跳过后续操作: **示例**(用 `Option.gen` 处理 `None` 值) 在这个示例中,`Option.gen` 一遇到 `None` 值就停止执行,从而在不执行后续操作的情况下把缺失值传播出去。 ```ts import { Option } from "effect" const maybeName: Option.Option = Option.none() const maybeAge: Option.Option = Option.some(25) const program = Option.gen(function* () { console.log("Retrieving name...") const name = (yield* maybeName).toUpperCase() console.log("Retrieving age...") const age = yield* maybeAge return { name, age } }) console.log(program) /* Output: Retrieving name... { _id: 'Option', _tag: 'None' } */ ``` 这些示例中使用 `console.log` 仅用于演示目的。使用 `Option.gen` 时,请避免在生成器函数中引入副作用,因为 `Option` 应当保持为纯数据结构。 ## Equivalence 你可以使用 `Option.getEquivalence` 函数比较 `Option` 值。该函数允许你为 `Option` 可能包含的值的类型提供一个 [Equivalence](/docs/v3/behaviour/equivalence/),以此指定如何比较 `Option` 类型的内容。 **示例**(比较可选数值是否等价) 假设你有一些可选数值,想检查它们是否等价。可以这样使用 `Option.getEquivalence`: ```ts import { Option, Equivalence } from "effect" const myEquivalence = Option.getEquivalence(Equivalence.number) console.log(myEquivalence(Option.some(1), Option.some(1))) // Output: true, both options contain the number 1 console.log(myEquivalence(Option.some(1), Option.some(2))) // Output: false, the numbers are different console.log(myEquivalence(Option.some(1), Option.none())) // Output: false, one is a number and the other is empty ``` ## 排序 你可以使用 `Option.getOrder` 函数对一组 `Option` 值进行排序。该函数有助于为 `Option` 中包含的值的类型指定自定义排序规则。 **示例**(对可选数值排序) 假设你有一个可选数值的列表,想按升序排序,并把空值(`Option.none()`)视为最小值: ```ts import { Option, Array, Order } from "effect" const items = [Option.some(1), Option.none(), Option.some(2)] // Create an order for sorting Option values containing numbers const myOrder = Option.getOrder(Order.number) console.log(Array.sort(myOrder)(items)) /* Output: [ { _id: 'Option', _tag: 'None' }, // None appears first because it's considered the lowest { _id: 'Option', _tag: 'Some', value: 1 }, // Sorted in ascending order { _id: 'Option', _tag: 'Some', value: 2 } ] */ ``` **示例**(按倒序对可选日期排序) 考虑一个更复杂的情形:你有一个包含可选日期的对象列表,想按降序排序,并把 `Option.none()` 值放在末尾: ```ts import { Option, Array, Order } from "effect" const items = [ { data: Option.some(new Date(10)) }, { data: Option.some(new Date(20)) }, { data: Option.none() }, ] // Define the order to sort dates within Option values in reverse const sorted = Array.sortWith( items, (item) => item.data, Order.reverse(Option.getOrder(Order.Date)), ) console.log(sorted) /* Output: [ { data: { _id: 'Option', _tag: 'Some', value: '1970-01-01T00:00:00.020Z' } }, { data: { _id: 'Option', _tag: 'Some', value: '1970-01-01T00:00:00.010Z' } }, { data: { _id: 'Option', _tag: 'None' } } // None placed last ] */ ``` --- # Redacted > 借助 Redacted 模块安全地处理敏感数据,防止其在日志中意外泄露,同时支持安全地取值与比较。 `Redacted` 模块提供了在应用程序中安全处理敏感信息的能力。通过使用 `Redacted` 数据类型,你可以确保敏感值不会意外暴露在日志或错误消息中。 ## make `Redacted.make` 函数会基于给定的值 `A` 创建一个 `Redacted` 实例,确保其内容被安全地隐藏。 **示例**(隐藏日志中的敏感信息) 使用 `Redacted.make` 有助于防止 API 密钥之类的敏感信息意外暴露在日志或错误消息中。 ```ts import { Redacted, Effect } from "effect" // Create a redacted API key const API_KEY = Redacted.make("1234567890") console.log(API_KEY) // Output: {} console.log(String(API_KEY)) // Output: Effect.runSync(Effect.log(API_KEY)) // Output: timestamp=... level=INFO fiber=#0 message="\"\"" ``` ## value `Redacted.value` 函数会从 `Redacted` 实例中取出原始值。请谨慎使用该函数,因为它会暴露敏感数据,可能使其出现在日志中,或以意料之外的方式被访问。 **示例**(访问底层的敏感值) ```ts import { Redacted } from "effect" const API_KEY = Redacted.make("1234567890") // Expose the redacted value console.log(Redacted.value(API_KEY)) // Output: "1234567890" ``` ## unsafeWipe `Redacted.unsafeWipe` 函数会擦除 `Redacted` 实例的底层值,使其无法再被访问。这有助于确保敏感数据不会在内存中保留超过必要的时间。 **示例**(从内存中擦除敏感数据) ```ts import { Redacted } from "effect" const API_KEY = Redacted.make("1234567890") console.log(Redacted.value(API_KEY)) // Output: "1234567890" Redacted.unsafeWipe(API_KEY) console.log(Redacted.value(API_KEY)) /* throws: Error: Unable to get redacted value */ ``` ## getEquivalence `Redacted.getEquivalence` 函数会基于类型 `A` 底层值的 Equivalence,为 `Redacted` 值生成一个 [Equivalence](/docs/v3/behaviour/equivalence/)。这让你可以在不泄露内容的前提下安全地比较 `Redacted` 值。 **示例**(比较 Redacted 值) ```ts import { Redacted, Equivalence } from "effect" const API_KEY1 = Redacted.make("1234567890") const API_KEY2 = Redacted.make("1-34567890") const API_KEY3 = Redacted.make("1234567890") const equivalence = Redacted.getEquivalence(Equivalence.string) console.log(equivalence(API_KEY1, API_KEY2)) // Output: false console.log(equivalence(API_KEY1, API_KEY3)) // Output: true ``` --- # 错误累积 > 学习在 Effect 工作流中有效地管理错误:掌握顺序执行、错误累积与结果划分的工具。 诸如 [Effect.zip](/docs/v3/code-style/control-flow/#zip)、[Effect.all](/docs/v3/code-style/control-flow/#all) 和 [Effect.forEach](/docs/v3/code-style/control-flow/#foreach) 这类顺序组合子,在错误管理上采用「快速失败」(fail fast)策略。这意味着它们一旦遇到第一个错误,就会立即停止并返回。 下面是一个使用 `Effect.zip` 的示例,它会在第一个失败处停止,并且只显示第一个错误: **示例**(使用 `Effect.zip` 快速失败) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1").pipe(Effect.as(1)) const task2 = Effect.fail("Oh uh!").pipe(Effect.as(2)) const task3 = Console.log("task2").pipe(Effect.as(3)) const task4 = Effect.fail("Oh no!").pipe(Effect.as(4)) const program = task1.pipe( Effect.zip(task2), Effect.zip(task3), Effect.zip(task4), ) Effect.runPromise(program).then(console.log, console.error) /* Output: task1 (FiberFailure) Error: Oh uh! */ ``` `Effect.forEach` 函数的行为与之类似。它会把一个产生 effect 的操作应用到集合中的每个元素,但遇到第一个错误时就会停止: **示例**(使用 `Effect.forEach` 快速失败) ```ts import { Effect, Console } from "effect" const program = Effect.forEach([1, 2, 3, 4, 5], (n) => { if (n < 4) { return Console.log(`item ${n}`).pipe(Effect.as(n)) } else { return Effect.fail(`${n} is not less that 4`) } }) Effect.runPromise(program).then(console.log, console.error) /* Output: item 1 item 2 item 3 (FiberFailure) Error: 4 is not less that 4 */ ``` 不过,有些场景下你可能希望收集所有错误,而不是快速失败。在这些情况下,你可以使用那些同时累积成功与错误的函数。 ## validate `Effect.validate` 函数与 `Effect.zip` 类似,但即使遇到错误,它也会继续组合各个 effect,从而同时累积成功与失败。 **示例**(校验并收集错误) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1").pipe(Effect.as(1)) const task2 = Effect.fail("Oh uh!").pipe(Effect.as(2)) const task3 = Console.log("task2").pipe(Effect.as(3)) const task4 = Effect.fail("Oh no!").pipe(Effect.as(4)) const program = task1.pipe( Effect.validate(task2), Effect.validate(task3), Effect.validate(task4), ) Effect.runPromiseExit(program).then(console.log) /* Output: task1 task2 { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Sequential', left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh uh!' }, right: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' } } } */ ``` ## validateAll `Effect.validateAll` 函数与 `Effect.forEach` 函数类似。它使用所提供的、会产生 effect 的操作来转换集合中的所有元素,但它会把所有错误收集到错误通道中,同时把成功值收集到成功通道中。 ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.validateAll([1, 2, 3, 4, 5], (n) => { if (n < 4) { return Console.log(`item ${n}`).pipe(Effect.as(n)) } else { return Effect.fail(`${n} is not less that 4`) } }) Effect.runPromiseExit(program).then(console.log) /* Output: item 1 item 2 item 3 { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: [ '4 is not less that 4', '5 is not less that 4' ] } } */ ``` ## validateFirst `Effect.validateFirst` 函数与 `Effect.validateAll` 类似,但它会返回第一个成功的结果;如果没有任何元素成功,则返回所有错误。 **示例**(返回第一个成功结果) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.validateFirst([1, 2, 3, 4, 5], (n) => { if (n < 4) { return Effect.fail(`${n} is not less that 4`) } else { return Console.log(`item ${n}`).pipe(Effect.as(n)) } }) Effect.runPromise(program).then(console.log, console.error) /* Output: item 4 4 */ ``` 注意,`Effect.validateFirst` 返回的成功类型是单个 `number`,而不像 `Effect.validateAll` 那样返回结果数组。 ## partition `Effect.partition` 函数会处理一个可迭代对象,并把一个会产生 effect 的函数应用到每个元素上。它返回一个元组,其中第一部分包含所有失败,第二部分包含所有成功。 **示例**(划分成功与失败) ```ts import { Effect } from "effect" // ┌─── Effect<[string[], number[]], never, never> // ▼ const program = Effect.partition([0, 1, 2, 3, 4], (n) => { if (n % 2 === 0) { return Effect.succeed(n) } else { return Effect.fail(`${n} is not even`) } }) Effect.runPromise(program).then(console.log, console.error) /* Output: [ [ '1 is not even', '3 is not even' ], [ 0, 2, 4 ] ] */ ``` 这个操作符是一个不会失败的 effect(unexceptional effect),也就是说其错误通道类型为 `never`。失败会被收集而不会中断该 effect,因此整个操作会执行完成,并同时返回错误与成功。 --- # 错误通道操作 > 探索 Effect 中错误通道上的各种操作,包括错误映射、过滤、观察、合并与翻转通道。 在 Effect 中,你可以对 effect 的错误通道执行各种操作。这些操作让你能够以不同方式转换、观察并处理错误。下面我们来探索其中的一些操作。 ## 映射操作 ### mapError 当你需要转换或修改某个 effect 产生的错误、同时不影响成功值时,可以使用 `Effect.mapError` 函数。当你想为错误补充额外信息或改变它的类型时,这会很有帮助。 **示例**(映射一个错误) 这里,错误类型从 `string` 变为 `Error`。 ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const simulatedTask = Effect.fail("Oh no!").pipe(Effect.as(1)) // ┌─── Effect // ▼ const mapped = Effect.mapError(simulatedTask, (message) => new Error(message)) ``` ### mapBoth `Effect.mapBoth` 函数允许你同时转换 effect 的两个通道:错误通道和成功通道。它接收两个映射函数作为参数:一个用于错误通道,另一个用于成功通道。 **示例**(同时映射成功与错误) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const simulatedTask = Effect.fail("Oh no!").pipe(Effect.as(1)) // ┌─── Effect // ▼ const modified = Effect.mapBoth(simulatedTask, { onFailure: (message) => new Error(message), onSuccess: (n) => n > 0, }) ``` ## 过滤成功通道 Effect 库提供了若干操作符,用于根据给定的谓词过滤成功通道上的值。 这些操作符为谓词不成立的情况提供了不同的处理策略: | API | 说明 | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filterOrFail` | 该操作符根据谓词过滤成功通道上的值。如果谓词对某个值不成立,原 effect 会以错误失败。 | | `filterOrDie` / `filterOrDieMessage` | 这些操作符同样根据谓词过滤成功通道上的值。如果谓词对某个值不成立,原 effect 会突然终止。`filterOrDieMessage` 变体允许你提供自定义的错误消息。 | | `filterOrElse` | 该操作符根据谓词过滤成功通道上的值。如果谓词对某个值不成立,则会改为执行一个替代 effect。 | **示例**(过滤成功值) ```ts import { Effect, Random, Cause } from "effect" // Fail with a custom error if predicate is false const task1 = Effect.filterOrFail( Random.nextRange(-1, 1), (n) => n >= 0, () => "random number is negative", ) // Die with a custom exception if predicate is false const task2 = Effect.filterOrDie( Random.nextRange(-1, 1), (n) => n >= 0, () => new Cause.IllegalArgumentException("random number is negative"), ) // Die with a custom error message if predicate is false const task3 = Effect.filterOrDieMessage( Random.nextRange(-1, 1), (n) => n >= 0, "random number is negative", ) // Run an alternative effect if predicate is false const task4 = Effect.filterOrElse( Random.nextRange(-1, 1), (n) => n >= 0, () => task3, ) ``` 需要注意的是,取决于所使用的具体过滤操作符,当谓词不成立时,effect 可能失败、突然终止,或执行一个替代 effect。请根据你期望的错误处理策略和程序逻辑选择合适的操作符。 这些过滤 API 还可以与[用户定义的类型守卫](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)结合使用,以提高类型安全性与代码清晰度。这确保只有有效的类型能够通过。 **示例**(使用类型守卫) ```ts import { Effect, pipe } from "effect" // Define a user interface interface User { readonly name: string } // Simulate an asynchronous authentication function declare const auth: () => Promise const program = pipe( Effect.promise(() => auth()), // Use filterOrFail with a custom type guard to ensure user is not null Effect.filterOrFail( (user): user is User => user !== null, // Type guard () => new Error("Unauthorized"), ), // 'user' now has the type `User` (not `User | null`) Effect.andThen((user) => user.name), ) ``` 在上面的示例中,`filterOrFail` API 内部使用了一个守卫,以确保 `user` 的类型是 `User` 而不是 `User | null`。 如果你愿意,也可以使用 [Predicate.isNotNull](https://effect.website/docs/v3/api/effect/Predicate#isNotNull) 这类现成的守卫,以获得简洁性和一致性。 ## 观察错误 与针对成功值的 [tapping](/docs/v3/getting-started/building-pipelines/#tap) 类似,Effect 提供了若干用于观察错误值的操作符。 这些操作符让开发者能够观察失败或底层问题,而无需修改最终结果。 ### tapError 执行一个带 effect 的操作来观察 effect 的失败,而不改变它。 **示例**(观察错误) ```ts import { Effect, Console } from "effect" // Simulate a task that fails with an error const task: Effect.Effect = Effect.fail("NetworkError") // Use tapError to log the error message when the task fails const tapping = Effect.tapError(task, (error) => Console.log(`expected error: ${error}`), ) Effect.runFork(tapping) /* Output: expected error: NetworkError */ ``` ### tapErrorTag 该函数允许你观察与特定 tag 匹配的错误,帮助你更精确地处理不同的错误类型。 **示例**(观察带标签的错误) ```ts import { Effect, Console, Data } from "effect" class NetworkError extends Data.TaggedError("NetworkError")<{ readonly statusCode: number }> {} class ValidationError extends Data.TaggedError("ValidationError")<{ readonly field: string }> {} // Create a task that fails with a NetworkError const task: Effect.Effect = Effect.fail( new NetworkError({ statusCode: 504 }), ) // Use tapErrorTag to inspect only NetworkError types // and log the status code const tapping = Effect.tapErrorTag(task, "NetworkError", (error) => Console.log(`expected error: ${error.statusCode}`), ) Effect.runFork(tapping) /* Output: expected error: 504 */ ``` ### tapErrorCause 该函数观察错误的完整 cause,包括失败与 defect。 **示例**(观察错误的 cause) ```ts import { Effect, Console } from "effect" // Create a task that fails with a NetworkError const task1: Effect.Effect = Effect.fail("NetworkError") const tapping1 = Effect.tapErrorCause(task1, (cause) => Console.log(`error cause: ${cause}`), ) Effect.runFork(tapping1) /* Output: error cause: Error: NetworkError */ // Simulate a severe failure in the system const task2: Effect.Effect = Effect.dieMessage( "Something went wrong", ) const tapping2 = Effect.tapErrorCause(task2, (cause) => Console.log(`error cause: ${cause}`), ) Effect.runFork(tapping2) /* Output: error cause: RuntimeException: Something went wrong ... stack trace ... */ ``` ### tapDefect 专门观察 effect 中不可恢复的失败或 defect(即一个或多个 [Die](/docs/v3/data-types/cause/#die) cause)。 **示例**(观察 defect) ```ts import { Effect, Console } from "effect" // Simulate a task that fails with a recoverable error const task1: Effect.Effect = Effect.fail("NetworkError") // tapDefect won't log anything because NetworkError is not a defect const tapping1 = Effect.tapDefect(task1, (cause) => Console.log(`defect: ${cause}`), ) Effect.runFork(tapping1) /* No Output */ // Simulate a severe failure in the system const task2: Effect.Effect = Effect.dieMessage( "Something went wrong", ) // Log the defect using tapDefect const tapping2 = Effect.tapDefect(task2, (cause) => Console.log(`defect: ${cause}`), ) Effect.runFork(tapping2) /* Output: defect: RuntimeException: Something went wrong ... stack trace ... */ ``` ### tapBoth 同时观察 effect 的成功与失败结果,并根据结果执行不同的操作。 **示例**(同时观察成功与失败) ```ts import { Effect, Random, Console } from "effect" // Simulate a task that might fail const task = Effect.filterOrFail( Random.nextRange(-1, 1), (n) => n >= 0, () => "random number is negative", ) // Use tapBoth to log both success and failure outcomes const tapping = Effect.tapBoth(task, { onFailure: (error) => Console.log(`failure: ${error}`), onSuccess: (randomNumber) => Console.log(`random number: ${randomNumber}`), }) Effect.runFork(tapping) /* Example Output: failure: random number is negative */ ``` ## 在成功通道中暴露错误 `Effect.either` 函数会把 `Effect` 转换为一个 effect,该 effect 将潜在的失败与成功都封装在 [Either](/docs/v3/data-types/either/) 数据类型之中: ```ts Effect -> Effect, never, R> ``` 这意味着,如果你有一个具有以下类型的 effect: ```ts Effect ``` 并对其调用 `Effect.either`,类型就会变成: ```ts Effect, never, never> ``` 所生成的 effect 不会失败,因为潜在的失败现在由 `Either` 的 `Left` 类型表示。 返回的 `Effect` 的错误类型被指定为 `never`,确认该 effect 被构造为不会失败。 在使用 [Effect.gen](/docs/v3/getting-started/using-generators/#understanding-effectgen) 时,这个函数在从可能失败的 effect 中恢复时特别有用: **示例**(用 `Effect.either` 处理错误) ```ts import { Effect, Either, Console } from "effect" // Simulate a task that fails // // ┌─── Either // ▼ const program = Effect.fail("Oh uh!").pipe(Effect.as(2)) // ┌─── Either // ▼ const recovered = Effect.gen(function* () { // ┌─── Either // ▼ const failureOrSuccess = yield* Effect.either(program) if (Either.isLeft(failureOrSuccess)) { const error = failureOrSuccess.left yield* Console.log(`failure: ${error}`) return 0 } else { const value = failureOrSuccess.right yield* Console.log(`success: ${value}`) return value } }) Effect.runPromise(recovered).then(console.log) /* Output: failure: Oh uh! 0 */ ``` ## 在成功通道中暴露 Cause 你可以使用 `Effect.cause` 函数来暴露 effect 的 cause,它是失败的更详细表示,包含错误消息与 defect。 **示例**(记录失败的 cause) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.fail("Oh uh!").pipe(Effect.as(2)) // ┌─── Effect // ▼ const recovered = Effect.gen(function* () { const cause = yield* Effect.cause(program) yield* Console.log(cause) }) ``` ## 把错误通道合并进成功通道 `Effect.merge` 函数允许你把错误通道与成功通道合并。这样得到的 effect 永远不会失败;相反,成功与错误都会作为成功通道中的值来处理。 **示例**(合并错误通道与成功通道) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const program = Effect.fail("Oh uh!").pipe(Effect.as(2)) // ┌─── Effect // ▼ const recovered = Effect.merge(program) ``` ## 翻转错误通道与成功通道 `Effect.flip` 函数允许你交换 effect 的错误通道与成功通道。这意味着原本的成功会变成错误,反之亦然。 **示例**(交换错误通道与成功通道) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const program = Effect.fail("Oh uh!").pipe(Effect.as(2)) // ┌─── Effect // ▼ const flipped = Effect.flip(program) ``` --- # 预期错误 > 了解 Effect 如何通过精确的错误追踪、短路以及强大的恢复技术来管理预期错误。 预期错误由 [Effect 数据类型](/docs/v3/getting-started/the-effect-type/) 在「错误通道」中于类型层面进行追踪: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Effect ``` 这意味着 `Effect` 类型不仅会捕获程序成功时返回的内容,还会捕获它可能产生何种错误。 **示例**(创建一个可能失败的 Effect) 在这个示例中,我们定义了一个可能随机以 `HttpError` 失败的程序。 ```ts import { Effect, Random, Data } from "effect" // Define a custom error type using Data.TaggedError class HttpError extends Data.TaggedError("HttpError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { // Generate a random number between 0 and 1 const n = yield* Random.next // Simulate an HTTP error if (n < 0.5) { return yield* Effect.fail(new HttpError()) } return "some result" }) ``` `program` 的类型告诉我们,它要么返回一个 `string`,要么以 `HttpError` 失败: ```ts const program: Effect ``` 在这里,我们使用一个类来表示 `HttpError` 类型,这样既能定义错误类型,也能定义构造函数。 使用 `Data.TaggedError` 时,会自动向该类添加一个 `_tag` 字段 ```ts // This field serves as a discriminant for the error console.log(new HttpError()._tag) // Output: "HttpError" ``` 当我们讨论 [Effect.catchTag](#catchtag) 这类用于处理特定错误类型的 API 时,这个判别字段会很有用。 ## 错误追踪 在 Effect 中,如果一个程序可能以多种类型的错误失败,这些错误类型会自动被追踪为它们的并集。 这让你能够确切知道执行期间可能发生哪些错误,从而使错误处理更加精确、更可预测。 下面的示例展示了错误是如何被自动追踪的。 **示例**(自动追踪错误) ```ts import { Effect, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { // Generate two random numbers between 0 and 1 const n1 = yield* Random.next const n2 = yield* Random.next // Simulate an HTTP error if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } // Simulate a validation error if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) ``` Effect 会自动将程序执行期间可能发生的错误追踪为一个并集: ```ts const program: Effect ``` 表明它可能以 `HttpError` 或 `ValidationError` 失败。 ## 短路 在使用 [Effect.gen](/docs/v3/getting-started/using-generators/#understanding-effectgen)、[Effect.map](/docs/v3/getting-started/building-pipelines/#map)、[Effect.flatMap](/docs/v3/getting-started/building-pipelines/#flatmap) 和 [Effect.andThen](/docs/v3/getting-started/building-pipelines/#andthen) 这类 API 时,理解它们如何处理错误很重要。 这些 API 被设计为在遇到**第一个错误**时**短路执行**。 这对作为开发者的你意味着什么?假设你有一串操作,或者一组要按顺序执行的 effect。如果其中某个 effect 在执行期间发生任何错误,剩余的计算都会被跳过,错误会被传播到最终结果。 更简单地说,短路行为确保:如果程序在任何一步出了差错,它不会浪费时间执行不必要的计算;相反,它会立即停止并返回错误,让你知道出了问题。 **示例**(短路行为) ```ts import { Effect, Console } from "effect" // Define three effects representing different tasks. const task1 = Console.log("Executing task1...") const task2 = Effect.fail("Something went wrong!") const task3 = Console.log("Executing task3...") // Compose the three tasks to run them in sequence. // If one of the tasks fails, the subsequent tasks won't be executed. const program = Effect.gen(function* () { yield* task1 // After task1, task2 is executed, but it fails with an error yield* task2 // This computation won't be executed because the previous one fails yield* task3 }) Effect.runPromiseExit(program).then(console.log) /* Output: Executing task1... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Something went wrong!' } } */ ``` 这段代码片段演示了发生错误时的短路行为。 每个操作都依赖于前一个操作的成功执行。 如果发生任何错误,执行就会短路,错误会被传播。 在这个具体示例中,由于 `task2` 中发生错误,`task3` 从未被执行。 ## 捕获所有错误 ### either `Effect.either` 函数会将 `Effect` 转换为一个 effect,它把潜在的失败和成功都封装在 [Either](/docs/v3/data-types/either/) 数据类型中: ```ts Effect -> Effect, never, R> ``` 这意味着,如果你有一个如下类型的 effect: ```ts Effect ``` 然后对它调用 `Effect.either`,类型就变成: ```ts Effect, never, never> ``` 得到的 effect 不会失败,因为潜在的失败现在由 `Either` 的 `Left` 类型来表示。 返回的 `Effect` 的错误类型被指定为 `never`,确认该 effect 在结构上不会失败。 通过 yield 一个 `Either`,我们就能对这个类型进行「模式匹配」,从而在生成器函数内部同时处理失败和成功两种情况。 **示例**(使用 `Effect.either` 处理错误) ```ts import { Effect, Either, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = Effect.gen(function* () { // ┌─── Either // ▼ const failureOrSuccess = yield* Effect.either(program) if (Either.isLeft(failureOrSuccess)) { // Failure case: you can extract the error from the `left` property const error = failureOrSuccess.left return `Recovering from ${error._tag}` } else { // Success case: you can extract the value from the `right` property return failureOrSuccess.right } }) ``` 可以看到,由于所有错误都被处理了,最终得到的 effect `recovered` 的错误类型是 `never`: ```ts const recovered: Effect ``` 我们可以使用 `Either.match` 函数让代码更简洁,它直接接受两个回调函数,分别用于处理错误和成功值: **示例**(用 `Either.match` 简化) ```ts import { Effect, Either, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = Effect.gen(function* () { // ┌─── Either // ▼ const failureOrSuccess = yield* Effect.either(program) return Either.match(failureOrSuccess, { onLeft: (error) => `Recovering from ${error._tag}`, onRight: (value) => value, // Do nothing in case of success }) }) ``` ### option 使用 [Option](/docs/v3/data-types/option/) 数据类型把 effect 转换为同时封装失败和成功的类型。 `Effect.option` 函数会把 effect 的成功或失败包装在 `Option` 类型中,使两种情况都显式化。如果原始 effect 成功, 其值会被包装为 `Option.some`。如果失败,该失败会被映射为 `Option.none`。 得到的 effect 不会直接失败,因为错误类型被设为 `never`。不过,像 defect 这样的致命错误不会被封装。 **示例**(使用 `Effect.option` 处理错误) ```ts import { Effect } from "effect" const maybe1 = Effect.option(Effect.succeed(1)) Effect.runPromiseExit(maybe1).then(console.log) /* Output: { _id: 'Exit', _tag: 'Success', value: { _id: 'Option', _tag: 'Some', value: 1 } } */ const maybe2 = Effect.option(Effect.fail("Uh oh!")) Effect.runPromiseExit(maybe2).then(console.log) /* Output: { _id: 'Exit', _tag: 'Success', value: { _id: 'Option', _tag: 'None' } } */ const maybe3 = Effect.option(Effect.die("Boom!")) Effect.runPromiseExit(maybe3).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: 'Boom!' } } */ ``` ### catchAll 通过提供一个回退 effect 来处理 effect 中的所有错误。 `Effect.catchAll` 函数会捕获 effect 执行期间可能发生的任何错误,并允许你通过指定一个回退 effect 来处理它们。这确保程序能借助所提供的回退逻辑从错误中恢复, 从而继续运行而不失败。 **示例**(为可恢复错误提供恢复逻辑) ```ts import { Effect, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( Effect.catchAll((error) => Effect.succeed(`Recovering from ${error._tag}`)), ) ``` 我们可以看到,程序错误通道中的类型已经变为 `never`: ```ts const recovered: Effect ``` 表明所有错误都已被处理。 ### catchAllCause 通过提供一个恢复 effect 来处理可恢复和不可恢复的错误。 `Effect.catchAllCause` 函数允许你通过提供一个恢复 effect 来处理所有错误, 包括不可恢复的 defect。恢复逻辑基于错误的 `Cause`,它提供了关于 该失败的详细信息。 **示例**(从所有错误中恢复) ```ts import { Cause, Effect } from "effect" // Define an effect that may fail with a recoverable or unrecoverable error const program = Effect.fail("Something went wrong!") // Recover from all errors by examining the cause const recovered = program.pipe( Effect.catchAllCause((cause) => Cause.isFailType(cause) ? Effect.succeed("Recovered from a regular error") : Effect.succeed("Recovered from a defect"), ), ) Effect.runPromise(recovered).then(console.log) // Output: "Recovered from a regular error" ``` ## 捕获部分错误 ### either 前面作为捕获所有错误的方式展示过的 [`Effect.either`](#either) 函数,也可以用来捕获特定的错误。 通过 yield 一个 `Either`,我们就能对这个类型进行「模式匹配」,从而在生成器函数内部同时处理失败和成功两种情况。 **示例**(使用 `Effect.either` 处理特定错误) ```ts import { Effect, Random, Either, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = Effect.gen(function* () { const failureOrSuccess = yield* Effect.either(program) if (Either.isLeft(failureOrSuccess)) { const error = failureOrSuccess.left // Only handle HttpError errors if (error._tag === "HttpError") { return "Recovering from HttpError" } else { // Rethrow ValidationError return yield* Effect.fail(error) } } else { return failureOrSuccess.right } }) ``` 我们可以看到,程序错误通道中的类型已经变为只显示 `ValidationError`: ```ts const recovered: Effect ``` 表明 `HttpError` 已被处理。 如果我们还想处理 `ValidationError`,可以很容易地在代码中再加一个分支: ```ts import { Effect, Random, Either, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = Effect.gen(function* () { const failureOrSuccess = yield* Effect.either(program) if (Either.isLeft(failureOrSuccess)) { const error = failureOrSuccess.left // Handle both HttpError and ValidationError if (error._tag === "HttpError") { return "Recovering from HttpError" } else { return "Recovering from ValidationError" } } else { return failureOrSuccess.right } }) ``` 我们可以看到,错误通道中的类型已经变为 `never`: ```ts const recovered: Effect ``` 表明所有错误都已被处理。 ### catchSome 捕获并恢复特定类型的错误,让你只针对某些错误尝试恢复。 `Effect.catchSome` 让你通过为特定错误提供恢复 effect,有选择地捕获并处理某些类型的错误。如果错误满足某个条件,就会尝试恢复;如果不满足,则不会影响程序。该函数不会改变错误类型,也就是说错误类型与原始 effect 保持一致。 **示例**(使用 `Effect.catchSome` 处理特定错误) ```ts import { Effect, Random, Option, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( Effect.catchSome((error) => { // Only handle HttpError errors if (error._tag === "HttpError") { return Option.some(Effect.succeed("Recovering from HttpError")) } else { return Option.none() } }), ) ``` 在上面的代码中,`Effect.catchSome` 接收一个函数,它检查错误并决定是否尝试恢复。如果错误满足特定条件,可以通过返回 `Option.some(effect)` 来尝试恢复。如果无法恢复,只需返回 `Option.none()` 即可。 需要注意的是,虽然 `Effect.catchSome` 让你捕获特定错误,但它并不会改变错误类型本身。 因此,得到的 effect 仍然与原始 effect 具有相同的错误类型: ```ts const recovered: Effect ``` ### catchIf 基于谓词从特定错误中恢复。 `Effect.catchIf` 的工作方式与 [`Effect.catchSome`](#catchsome) 类似,但它允许你通过提供谓词函数来从错误中恢复。如果谓词与错误匹配,就会应用恢复 effect。该函数不会改变错误类型,因此除非使用用户定义的类型守卫来收窄类型,否则得到的 effect 仍然携带原始的错误类型。 **示例**(使用谓词捕获特定错误) ```ts import { Data, Effect, Random } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( Effect.catchIf( // Only handle HttpError errors (error) => error._tag === "HttpError", () => Effect.succeed("Recovering from HttpError"), ), ) ``` 需要注意的是,当 TypeScript 版本低于 5.5 时,虽然 `Effect.catchIf` 让你捕获特定错误,但它**不会改变错误类型**本身。 因此,得到的 effect 仍然与原始 effect 具有相同的错误类型: ```ts const recovered: Effect ``` 在 TypeScript 5.5 及更高版本中,改进的类型收窄会让得到的错误类型被推断为 `ValidationError`。 #### TypeScript 版本低于 5.5 时的变通方案 如果你提供的是[用户定义的类型守卫](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)而不是谓词,那么得到的错误类型会被裁剪,返回 `Effect`: ```ts import { Data, Effect, Random } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( Effect.catchIf( // User-defined type guard (error): error is HttpError => error._tag === "HttpError", () => Effect.succeed("Recovering from HttpError"), ), ) ``` ### catchTag 通过 `_tag` 字段(用作判别式)捕获并处理特定错误。 当你的错误带有一个标识错误类型的 `_tag` 字段时,`Effect.catchTag` 会很有用。你可以用这个函数通过匹配 `_tag` 值来处理特定的错误类型。这样可以实现精确的错误处理,确保只捕获并处理特定的错误。 要使用 `Effect.catchTag`,错误类型必须带有 `_tag` 字段。该字段 用于标识和匹配错误。 **示例**(按 Tag 处理错误) ```ts import { Effect, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( // Only handle HttpError errors Effect.catchTag("HttpError", (_HttpError) => Effect.succeed("Recovering from HttpError"), ), ) ``` 在上面的示例中,`Effect.catchTag` 函数让我们能够专门处理 `HttpError`。 如果程序执行期间发生 `HttpError`,所提供的错误处理函数就会被调用, 程序随后会按处理函数中指定的恢复逻辑继续执行。 可以看到,程序错误通道中的类型已经变成只显示 `ValidationError`: ```ts const recovered: Effect ``` 这表明 `HttpError` 已被处理。 如果我们还想处理 `ValidationError`,只需再添加一个 `catchTag` 即可: **示例**(使用 `catchTag` 处理多种错误类型) ```ts import { Effect, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( // Handle both HttpError and ValidationError Effect.catchTag("HttpError", (_HttpError) => Effect.succeed("Recovering from HttpError"), ), Effect.catchTag("ValidationError", (_ValidationError) => Effect.succeed("Recovering from ValidationError"), ), ) ``` 可以看到,程序错误通道中的类型已经变成 `never`: ```ts const recovered: Effect ``` 这表明所有错误都已被处理。 ### catchTags 使用多个错误的 `_tag` 字段,在单个代码块中处理它们。 `Effect.catchTags` 是一次处理多种错误类型的便捷方式。与多次使用 [`Effect.catchTag`](#catchtag) 不同,你可以传入一个对象,其中每个键是某个错误类型的 `_tag`,值则是针对该特定错误的处理函数。这样你就能在一次调用中捕获并恢复多种错误类型。 **示例**(一次处理多个带标签的错误类型) ```ts import { Effect, Random, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const n1 = yield* Random.next const n2 = yield* Random.next if (n1 < 0.5) { return yield* Effect.fail(new HttpError()) } if (n2 < 0.5) { return yield* Effect.fail(new ValidationError()) } return "some result" }) // ┌─── Effect // ▼ const recovered = program.pipe( Effect.catchTags({ HttpError: (_HttpError) => Effect.succeed(`Recovering from HttpError`), ValidationError: (_ValidationError) => Effect.succeed(`Recovering from ValidationError`), }), ) ``` 该函数接收一个对象,其中每个属性代表一个特定的错误 `_tag`(本例中是 `"HttpError"` 和 `"ValidationError"`), 而对应的值则是当该特定错误发生时要执行的错误处理函数。 ## Effect.fn `Effect.fn` 函数让你可以创建返回 effect 的追踪函数。它提供两个关键特性: - **带位置详情的堆栈追踪**(stack traces),在发生错误时可用。 - 在提供 span 名称时,为[追踪](/docs/v3/observability/tracing/)**自动创建 span**。 如果把 span 名称作为第一个参数传入,函数的执行就会以该名称被追踪。 如果没有提供名称,堆栈追踪仍然有效,但不会创建 span。 函数可以用以下两种方式之一定义: - 生成器函数,从而可以使用 `yield*` 来组合 effect。 - 返回 `Effect` 的普通函数。 **示例**(创建带 Span 名称的追踪函数) ```ts import { Effect } from "effect" const myfunc = Effect.fn("myspan")(function* (n: N) { yield* Effect.annotateCurrentSpan("n", n) // Attach metadata to the span console.log(`got: ${n}`) yield* Effect.fail(new Error("Boom!")) // Simulate failure }) Effect.runFork(myfunc(100).pipe(Effect.catchAllCause(Effect.logError))) /* Output: got: 100 timestamp=... level=ERROR fiber=#0 cause="Error: Boom! at (/.../index.ts:6:22) <= Raise location at myspan (/.../index.ts:3:23) <= Definition location at myspan (/.../index.ts:9:16)" <= Call location */ ``` ### 导出 Span 用于追踪 `Effect.fn` 会自动创建 [span](/docs/v3/observability/tracing/)。这些 span 会捕获函数执行的相关信息,包括元数据与错误详情。 **示例**(将 Span 导出到控制台) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const myfunc = Effect.fn("myspan")(function* (n: N) { yield* Effect.annotateCurrentSpan("n", n) console.log(`got: ${n}`) yield* Effect.fail(new Error("Boom!")) }) const program = myfunc(100) const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, // Export span data to the console spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) Effect.runFork(program.pipe(Effect.provide(NodeSdkLive))) /* Output: got: 100 { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.30.1' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: '22801570119e57a6e2aacda3dec9665b', parentId: undefined, traceState: undefined, name: 'myspan', id: '7af530c1e01bc0cb', kind: 0, timestamp: 1741182277518402.2, duration: 4300.416, attributes: { n: 100, 'code.stacktrace': 'at (/.../index.ts:8:23)\n' + 'at (/.../index.ts:14:17)' }, status: { code: 2, message: 'Boom!' }, events: [ { name: 'exception', attributes: { 'exception.type': 'Error', 'exception.message': 'Boom!', 'exception.stacktrace': 'Error: Boom!\n' + ' at (/.../index.ts:11:22)\n' + ' at myspan (/.../index.ts:8:23)\n' + ' at myspan (/.../index.ts:14:17)' }, time: [ 1741182277, 522702583 ], droppedAttributesCount: 0 } ], links: [] } */ ``` ### 将 Effect.fn 用作 pipe 函数 `Effect.fn` 也可以充当 pipe 函数,让你在函数定义之后创建管道,并以生成器函数返回的 effect 作为管道的起始值。 **示例**(创建带延迟的追踪函数) ```ts import { Effect } from "effect" const myfunc = Effect.fn( function* (n: number) { console.log(`got: ${n}`) yield* Effect.fail(new Error("Boom!")) }, // You can access both the created effect and the original arguments (effect, n) => Effect.delay(effect, `${n / 100} seconds`), ) Effect.runFork(myfunc(100).pipe(Effect.catchAllCause(Effect.logError))) /* Output: got: 100 timestamp=... level=ERROR fiber=#0 cause="Error: Boom! (<= after 1 second) */ ``` --- # 回退 > 了解在 Effect 程序中处理失败并实现回退机制的各种技术。 本页讲解 Effect 库中处理失败、构建回退机制的各种技术。 ## orElse `Effect.orElse` 允许你先尝试运行一个 effect;如果它失败了,你可以改为提供一个回退 effect 来运行。 当你为第一个 effect 定义了备选方案、以便在它出错时优雅地处理失败时,这一点很有用。 **示例**(用 `Effect.orElse` 处理回退) ```ts import { Effect } from "effect" const success = Effect.succeed("success") const failure = Effect.fail("failure") const fallback = Effect.succeed("fallback") // Try the success effect first, fallback is not used const program1 = Effect.orElse(success, () => fallback) console.log(Effect.runSync(program1)) // Output: "success" // Try the failure effect first, fallback is used const program2 = Effect.orElse(failure, () => fallback) console.log(Effect.runSync(program2)) // Output: "fallback" ``` ## orElseFail `Effect.orElseFail` 允许你用一个自定义的失败值替换某个 effect 的失败。如果该 effect 失败了,你可以提供一个新的失败,用它替代原本的失败返回。 这个函数只对失败的 effect 生效。如果该 effect 成功了,它不会受到任何影响。 **示例**(用 `Effect.orElseFail` 替换失败) ```ts import { Effect } from "effect" const validate = (age: number): Effect.Effect => { if (age < 0) { return Effect.fail("NegativeAgeError") } else if (age < 18) { return Effect.fail("IllegalAgeError") } else { return Effect.succeed(age) } } const program = Effect.orElseFail(validate(-1), () => "invalid age") console.log(Effect.runSyncExit(program)) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'invalid age' } } */ ``` ## orElseSucceed `Effect.orElseSucceed` 允许你用成功值替换某个 effect 的失败。如果该 effect 失败了,它会改为以提供的值成功,从而确保该 effect 总是成功完成。 当你想保证无论原本的 effect 是否失败都能得到一个成功结果时,这一点很有用。 该函数确保任何失败都被有效地「吞掉」,并由一个成功值替代;在失败时提供默认值的场景下,这会很有帮助。 这个函数只对失败的 effect 生效。如果该 effect 已经成功,它将保持不变。 **示例**(用 `Effect.orElseSucceed` 把失败替换为成功) ```ts import { Effect } from "effect" const validate = (age: number): Effect.Effect => { if (age < 0) { return Effect.fail("NegativeAgeError") } else if (age < 18) { return Effect.fail("IllegalAgeError") } else { return Effect.succeed(age) } } const program = Effect.orElseSucceed(validate(-1), () => 18) console.log(Effect.runSyncExit(program)) /* Output: { _id: 'Exit', _tag: 'Success', value: 18 } */ ``` ## firstSuccessOf `Effect.firstSuccessOf` 允许你按顺序依次尝试多个 effect,其中任何一个一旦成功,就会返回该结果。如果所有 effect 都失败,则返回列表中最后一个 effect 的错误。 当你有若干备选方案,并想使用第一个可行的方案时,这一点很有用。 这个函数是顺序执行的:可迭代对象中的各个 `Effect` 值会依次执行,第一个成功的 `Effect` 值将决定最终 `Effect` 值的结果。 **示例**(用多级回退查找配置) 在这个示例中,我们尝试从不同的节点获取配置。如果主节点失败,就回退到其他节点,直到找到一份成功的配置。 ```ts import { Effect, Console } from "effect" interface Config { host: string port: number apiKey: string } // Create a configuration object with sample values const makeConfig = (name: string): Config => ({ host: `${name}.example.com`, port: 8080, apiKey: "12345-abcde", }) // Simulate retrieving configuration from a remote node const remoteConfig = (name: string): Effect.Effect => Effect.gen(function* () { // Simulate node3 being the only one with available config if (name === "node3") { yield* Console.log(`Config for ${name} found`) return makeConfig(name) } else { yield* Console.log(`Unavailable config for ${name}`) return yield* Effect.fail(new Error(`Config not found for ${name}`)) } }) // Define the master configuration and potential fallback nodes const masterConfig = remoteConfig("master") const nodeConfigs = ["node1", "node2", "node3", "node4"].map(remoteConfig) // Attempt to find a working configuration, // starting with the master and then falling back to other nodes const config = Effect.firstSuccessOf([masterConfig, ...nodeConfigs]) // Run the effect to retrieve the configuration const result = Effect.runSync(config) console.log(result) /* Output: Unavailable config for master Unavailable config for node1 Unavailable config for node2 Config for node3 found { host: 'node3.example.com', port: 8080, apiKey: '12345-abcde' } */ ``` --- # 匹配 > 学习如何在 Effect 程序中处理成功与失败的情况,包括模式匹配、忽略值、副作用以及精确的失败分析等工具。 在 Effect 模块中,与其他模块(例如 [Option](/docs/v3/data-types/option/#pattern-matching) 和 [Exit](/docs/v3/data-types/exit/#pattern-matching))类似,我们有一个 `Effect.match` 函数,可以同时处理不同的情况。此外,Effect 还提供了多种函数来管理带 effect 的程序中的成功与失败场景。 ## match `Effect.match` 允许你为成功和失败两种场景分别定义自定义的处理函数。你为每种情况提供一个单独的函数:当该 effect 成功时处理其结果,当它失败时处理其错误。 当你希望代码对成功或失败作出不同响应、又不触发副作用时,这很有用。 **示例**(同时处理成功与失败的情况) ```ts import { Effect } from "effect" const success: Effect.Effect = Effect.succeed(42) const program1 = Effect.match(success, { onFailure: (error) => `failure: ${error.message}`, onSuccess: (value) => `success: ${value}`, }) // Run and log the result of the successful effect Effect.runPromise(program1).then(console.log) // Output: "success: 42" const failure: Effect.Effect = Effect.fail(new Error("Uh oh!")) const program2 = Effect.match(failure, { onFailure: (error) => `failure: ${error.message}`, onSuccess: (value) => `success: ${value}`, }) // Run and log the result of the failed effect Effect.runPromise(program2).then(console.log) // Output: "failure: Uh oh!" ``` ## ignore `Effect.ignore` 允许你运行一个 effect,而不关心它的结果——无论它成功还是失败。 当你只关心该 effect 的副作用、不需要处理或加工它的结果时,这很有用。 **示例**(使用 `Effect.ignore` 丢弃值) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const task = Effect.fail("Uh oh!").pipe(Effect.as(5)) // ┌─── Effect // ▼ const program = Effect.ignore(task) ``` ## matchEffect `Effect.matchEffect` 函数与 [Effect.match](#match) 类似,但它允许你在处理成功和失败结果的处理函数中执行副作用。 当你需要根据 effect 成功还是失败来执行额外的操作(例如记录日志或通知用户)时,这很有用。 **示例**(带副作用地处理成功与失败) ```ts import { Effect } from "effect" const success: Effect.Effect = Effect.succeed(42) const failure: Effect.Effect = Effect.fail(new Error("Uh oh!")) const program1 = Effect.matchEffect(success, { onFailure: (error) => Effect.succeed(`failure: ${error.message}`).pipe(Effect.tap(Effect.log)), onSuccess: (value) => Effect.succeed(`success: ${value}`).pipe(Effect.tap(Effect.log)), }) console.log(Effect.runSync(program1)) /* Output: timestamp=... level=INFO fiber=#0 message="success: 42" success: 42 */ const program2 = Effect.matchEffect(failure, { onFailure: (error) => Effect.succeed(`failure: ${error.message}`).pipe(Effect.tap(Effect.log)), onSuccess: (value) => Effect.succeed(`success: ${value}`).pipe(Effect.tap(Effect.log)), }) console.log(Effect.runSync(program2)) /* Output: timestamp=... level=INFO fiber=#1 message="failure: Uh oh!" failure: Uh oh! */ ``` ## matchCause `Effect.matchCause` 函数允许你在处理失败时访问某个 Fiber 内失败的完整 [cause](/docs/v3/data-types/cause/)。 当你需要区分不同类型的错误(例如常规失败、defect 或中断)时,这很有用。你可以基于 cause 为每种失败类型提供特定的处理逻辑。 **示例**(处理不同的失败 cause) ```ts import { Effect } from "effect" const task: Effect.Effect = Effect.die("Uh oh!") const program = Effect.matchCause(task, { onFailure: (cause) => { switch (cause._tag) { case "Fail": // Handle standard failure return `Fail: ${cause.error.message}` case "Die": // Handle defects (unexpected errors) return `Die: ${cause.defect}` case "Interrupt": // Handle interruption return `${cause.fiberId} interrupted!` } // Fallback for other causes return "failed due to other causes" }, onSuccess: (value) => // task completes successfully `succeeded with ${value} value`, }) Effect.runPromise(program).then(console.log) // Output: "Die: Uh oh!" ``` ## matchCauseEffect `Effect.matchCauseEffect` 函数的工作方式与 [Effect.matchCause](#matchcause) 类似,但它还允许你基于失败 cause 执行额外的副作用。 该函数提供对失败完整 [cause](/docs/v3/data-types/cause/) 的访问,从而可以区分各种失败类型,并让你在执行副作用(例如记录日志或其他操作)的同时作出相应的响应。 **示例**(带副作用地处理不同的失败 cause) ```ts import { Effect, Console } from "effect" const task: Effect.Effect = Effect.die("Uh oh!") const program = Effect.matchCauseEffect(task, { onFailure: (cause) => { switch (cause._tag) { case "Fail": // Handle standard failure with a logged message return Console.log(`Fail: ${cause.error.message}`) case "Die": // Handle defects (unexpected errors) by logging the defect return Console.log(`Die: ${cause.defect}`) case "Interrupt": // Handle interruption and log the fiberId that was interrupted return Console.log(`${cause.fiberId} interrupted!`) } // Fallback for other causes return Console.log("failed due to other causes") }, onSuccess: (value) => // Log success if the task completes successfully Console.log(`succeeded with ${value} value`), }) Effect.runPromise(program) // Output: "Die: Uh oh!" ``` --- # 并行与顺序错误 > 在 Effect 程序中处理并发与顺序错误,捕获多个失败,并在并发与顺序工作流中实现稳健的错误管理。 在使用 Effect 时,如果发生错误,默认行为是以遇到的第一个错误失败。 **示例**(在第一个错误上失败) 这里,程序以它遇到的第一个错误 `"Oh uh!"` 失败。 ```ts import { Effect } from "effect" const fail = Effect.fail("Oh uh!") const die = Effect.dieMessage("Boom!") // Run both effects sequentially const program = Effect.all([fail, die]) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Oh uh!' } } */ ``` ## 并行错误 在某些情况下,你可能会遇到多个错误,尤其是在并发计算期间。当多个任务并发运行时,多个错误可能同时发生。 **示例**(处理并发计算中的多个错误) 在这个示例中,`fail` 和 `die` 这两个 effect 是并发执行的。由于两者都失败,程序会在输出中报告多个错误。 ```ts import { Effect } from "effect" const fail = Effect.fail("Oh uh!") const die = Effect.dieMessage("Boom!") // Run both effects concurrently const program = Effect.all([fail, die], { concurrency: "unbounded", }).pipe(Effect.asVoid) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Parallel', left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh uh!' }, right: { _id: 'Cause', _tag: 'Die', defect: [Object] } } } */ ``` ### parallelErrors Effect 提供了一个名为 `Effect.parallelErrors` 的函数,它会把并发操作产生的所有失败错误捕获到错误通道中。 **示例**(捕获多个并发失败) 在这个示例中,`Effect.parallelErrors` 把 `fail1` 和 `fail2` 的错误合并为单个错误。 ```ts import { Effect } from "effect" const fail1 = Effect.fail("Oh uh!") const fail2 = Effect.fail("Oh no!") const die = Effect.dieMessage("Boom!") // Run all effects concurrently and capture all errors const program = Effect.all([fail1, fail2, die], { concurrency: "unbounded", }).pipe(Effect.asVoid, Effect.parallelErrors) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: [ 'Oh uh!', 'Oh no!' ] } } */ ``` ## 顺序错误 在使用 `Effect.ensuring` 之类的资源安全操作符时,你可能会遇到多个顺序错误。 这是因为无论原始 effect 是否有错误,终结器(finalizer)都是不可中断的,并且总会运行。 **示例**(处理多个顺序错误) 在这个示例中,`fail` 和终结器 `die` 都会导致顺序错误,并且两者都会被捕获。 ```ts import { Effect } from "effect" // Simulate an effect that fails const fail = Effect.fail("Oh uh!") // Simulate a finalizer that causes a defect const die = Effect.dieMessage("Boom!") // The finalizer 'die' will always run, even if 'fail' fails const program = fail.pipe(Effect.ensuring(die)) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Sequential', left: { _id: 'Cause', _tag: 'Fail', failure: 'Oh uh!' }, right: { _id: 'Cause', _tag: 'Die', defect: [Object] } } } */ ``` --- # 重试 > 借助 Effect 的重试策略增强韧性,用可自定义的重试策略与回退机制稳健地处理临时性失败。 在软件开发中,经常会遇到某次操作因网络问题、资源不可用或外部依赖等各种因素而临时失败的情况。此时,通常希望自动重试该操作,让它最终能够成功。 重试是处理临时性失败、确保关键操作成功执行的强大机制。在 Effect 中,借助内置函数与调度策略,重试变得简单而灵活。 在本指南中,我们将探讨 Effect 中重试的概念,并学习如何使用 `retry` 和 `retryOrElse` 函数来处理失败场景。我们还会看到如何使用 Schedule 定义重试策略,由它决定何时重试以及重试多少次。 无论你处理的是网络请求、数据库交互,还是其他任何容易出错的操作,掌握 Effect 的重试能力都能显著提升应用程序的韧性与可靠性。 ## retry `Effect.retry` 函数接收一个 effect 和一个 [Schedule](/docs/v3/scheduling/introduction/) 策略,并在该 effect 失败时按照策略的规则自动重试。 如果该 effect 最终成功,就会返回其结果。 如果重试次数用尽而该 effect 仍然失败,则会把该失败传播出去。 这在处理间歇性失败时很有用,例如网络问题或临时的资源不可用。通过定义重试策略,你可以控制重试次数、重试之间的延迟,以及何时停止重试。 **示例**(以固定延迟重试) ```ts import { Effect, Schedule } from "effect" let count = 0 // Simulates an effect with possible failures const task = Effect.async((resume) => { if (count <= 2) { count++ console.log("failure") resume(Effect.fail(new Error())) } else { console.log("success") resume(Effect.succeed("yay!")) } }) // Define a repetition policy using a fixed delay between retries const policy = Schedule.fixed("100 millis") const repeated = Effect.retry(task, policy) Effect.runPromise(repeated).then(console.log) /* Output: failure failure failure success yay! */ ``` ### 立即重试 n 次 你也可以用一个更简单的、立即重试的策略,让失败的 effect 重试固定次数: **示例**(最多重试任务 5 次) ```ts import { Effect } from "effect" let count = 0 // Simulates an effect with possible failures const task = Effect.async((resume) => { if (count <= 2) { count++ console.log("failure") resume(Effect.fail(new Error())) } else { console.log("success") resume(Effect.succeed("yay!")) } }) // Retry the task up to 5 times Effect.runPromise(Effect.retry(task, { times: 5 })) /* Output: failure failure failure success */ ``` ### 基于条件重试 你可以通过指定条件来自定义重试的管理方式。使用 `until` 或 `while` 选项来控制何时停止重试。 **示例**(重试直到满足特定条件) ```ts import { Effect } from "effect" let count = 0 // Define an effect that simulates varying error on each invocation const action = Effect.failSync(() => { console.log(`Action called ${++count} time(s)`) return `Error ${count}` }) // Retry the action until a specific condition is met const program = Effect.retry(action, { until: (err) => err === "Error 3", }) Effect.runPromiseExit(program).then(console.log) /* Output: Action called 1 time(s) Action called 2 time(s) Action called 3 time(s) { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Error 3' } } */ ``` ## retryOrElse `Effect.retryOrElse` 函数会按照一个确定的 [Schedule](/docs/v3/scheduling/introduction/) 策略,尝试对失败的 effect 重试多次。 如果重试次数用尽而该 effect 仍然失败,它就会改为运行一个回退 effect。 当你想在反复失败之后指定一个替代操作,从而优雅地处理失败时,这个函数很有用。 **示例**(带回退的重试) ```ts import { Effect, Schedule, Console } from "effect" let count = 0 // Simulates an effect with possible failures const task = Effect.async((resume) => { if (count <= 2) { count++ console.log("failure") resume(Effect.fail(new Error())) } else { console.log("success") resume(Effect.succeed("yay!")) } }) // Retry the task with a delay between retries and a maximum of 2 retries const policy = Schedule.addDelay(Schedule.recurs(2), () => "100 millis") // If all retries fail, run the fallback effect const repeated = Effect.retryOrElse( task, policy, // fallback () => Console.log("orElse").pipe(Effect.as("default value")), ) Effect.runPromise(repeated).then(console.log) /* Output: failure failure failure orElse default value */ ``` --- # 沙箱化 > 掌握 Effect 中的沙箱化错误处理,从而详细检查并恢复失败、defect 与中断。 错误是编程中不可避免的一部分,它们可能来自各种来源,例如失败(failure)、defect、Fiber 中断,或者这些情况的组合。本指南讲解如何使用 `Effect.sandbox` 函数来隔离并理解基于 Effect 的代码中错误的成因。 ## sandbox / unsandbox `Effect.sandbox` 函数允许你把一个 effect 中所有可能的错误成因封装起来。它会暴露一个 effect 的完整 cause,无论其成因是失败、defect、Fiber 中断,还是这些因素的组合。 简单来说,它接收一个 effect `Effect`,并将其转换为一个 effect `Effect, R>`,此时错误通道中包含着错误的详细成因(cause)。 **语法** ```ts Effect -> Effect, R> ``` 通过使用 `Effect.sandbox` 函数,你就能访问异常 effect 的底层成因。这些成因以 `Cause` 类型表示,并且可以在 `Effect` 数据类型的错误通道中获取。 一旦暴露了这些成因,你就可以利用标准的错误处理操作符,例如 [Effect.catchAll](/docs/v3/error-management/expected-errors/#catchall) 和 [Effect.catchTags](/docs/v3/error-management/expected-errors/#catchtags),来更有效地处理错误。这些操作符让你能够针对特定的错误条件做出响应。 如果需要,我们可以用 `Effect.unsandbox` 撤销沙箱化操作。 **示例**(处理不同的错误成因) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const task = Effect.fail(new Error("Oh uh!")).pipe(Effect.as("primary result")) // ┌─── Effect, never> // ▼ const sandboxed = Effect.sandbox(task) const program = Effect.catchTags(sandboxed, { Die: (cause) => Console.log(`Caught a defect: ${cause.defect}`).pipe( Effect.as("fallback result on defect"), ), Interrupt: (cause) => Console.log(`Caught a defect: ${cause.fiberId}`).pipe( Effect.as("fallback result on fiber interruption"), ), Fail: (cause) => Console.log(`Caught a defect: ${cause.error}`).pipe( Effect.as("fallback result on failure"), ), }) // Restore the original error handling with unsandbox const main = Effect.unsandbox(program) Effect.runPromise(main).then(console.log) /* Output: Caught a defect: Oh uh! fallback result on failure */ ``` --- # 超时 > 用 Effect 为操作设置时间限制,确保任务在指定时长内完成,并自定义超时时的行为。 在编程中,经常会遇到需要花一些时间才能完成的任务。我们往往希望给这些任务施加一个愿意等待的时长上限。`Effect.timeout` 函数可以为某个操作加上时间约束,确保它不会无限期地运行下去。 ## 基本用法 ### timeout `Effect.timeout` 函数接收一个 [Duration](/docs/v3/data-types/duration/) 参数,用来为某个操作设定时间限制。如果该操作超出了这个限制,就会触发 `TimeoutException`,表示发生了超时。 **示例**(设置超时) 这里,任务在超时时长内完成,因此结果被成功返回。 ```ts import { Effect } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) // Sets a 3-second timeout for the task const timedEffect = task.pipe(Effect.timeout("3 seconds")) // Output will show that the task completes successfully // as it falls within the timeout duration Effect.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... Processing complete. { _id: 'Exit', _tag: 'Success', value: 'Result' } */ ``` 如果操作超出了指定的时长,就会抛出 `TimeoutException`: ```ts import { Effect } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) // Output will show a TimeoutException as the task takes longer // than the specified timeout duration const timedEffect = task.pipe(Effect.timeout("1 second")) Effect.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _tag: 'TimeoutException' } } } */ ``` ### timeoutOption 如果你希望更优雅地处理超时,可以考虑使用 `Effect.timeoutOption`。这个函数把超时当作普通结果来处理,并将结果包装进一个 [Option](/docs/v3/data-types/option/) 中。 **示例**(把超时当作 Option 处理) 在这个示例中,第一个任务成功完成,而第二个任务超时了。超时任务的结果在 `Option` 类型中表示为 `None`。 ```ts import { Effect } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const timedOutEffect = Effect.all([ task.pipe(Effect.timeoutOption("3 seconds")), task.pipe(Effect.timeoutOption("1 second")), ]) Effect.runPromise(timedOutEffect).then(console.log) /* Output: Start processing... Processing complete. Start processing... [ { _id: 'Option', _tag: 'Some', value: 'Result' }, { _id: 'Option', _tag: 'None' } ] */ ``` ## 处理超时 当某个操作没有在指定的时长内结束,`Effect.timeout` 的行为取决于该操作是否「不可中断」(uninterruptible)。 1. **可中断的操作**:如果操作可以被中断,那么一旦达到超时阈值,它就会立即被终止,并产生一个 `TimeoutException`。 ```ts import { Effect } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const timedEffect = task.pipe(Effect.timeout("1 second")) Effect.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _tag: 'TimeoutException' } } } */ ``` 2. **不可中断的操作**:如果操作不可中断,它会一直继续到完成为止,然后才会判定 `TimeoutException`。 ```ts import { Effect } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const timedEffect = task.pipe( Effect.uninterruptible, Effect.timeout("1 second"), ) // Outputs a TimeoutException after the task completes, // because the task is uninterruptible Effect.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... Processing complete. { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _tag: 'TimeoutException' } } } */ ``` ## 超时时断开连接 `Effect.disconnect` 函数提供了一种更灵活的方式,用来处理不可中断 effect 中的超时。它允许不可中断的 effect 在后台继续完成,而主控制流则像已经发生超时一样继续向下执行。 两者的区别如下: **不使用** `Effect.disconnect` 时: - 不可中断的 effect 会忽略超时,继续执行直到完成,之后才会判定超时错误。 - 这可能导致超时条件的识别被延迟,因为系统必须等待该 effect 完成。 **使用** `Effect.disconnect` 时: - 允许不可中断的 effect 在后台继续运行,独立于主控制流。 - 主控制流会立即识别出超时,并带着超时错误或替代逻辑继续执行,而不必等待该 effect 完成。 - 当该 effect 中的操作虽然被标记为不可中断,却不需要阻塞程序继续执行时,这种方式尤其有用。 **示例**(运行不可中断任务并设置超时,同时在后台完成) 考虑这样一个场景:启动了一个长时间运行的数据处理任务,而你希望即使数据处理耗时过长,系统也能保持响应: ```ts import { Effect } from "effect" const longRunningTask = Effect.gen(function* () { console.log("Start heavy processing...") yield* Effect.sleep("5 seconds") // Simulate a long process console.log("Heavy processing done.") return "Data processed" }) const timedEffect = longRunningTask.pipe( Effect.uninterruptible, // Allows the task to finish in the background if it times out Effect.disconnect, Effect.timeout("1 second"), ) Effect.runPromiseExit(timedEffect).then(console.log) /* Output: Start heavy processing... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _tag: 'TimeoutException' } } } Heavy processing done. */ ``` 在这个示例中,系统在一秒后检测到了超时,但长时间运行的任务会在后台继续执行并完成,不会阻塞程序的流程。 ## 自定义超时行为 除了基础的 `Effect.timeout` 函数之外,还有若干变体可供使用,让你能够自定义发生超时时的行为。 ### timeoutFail `Effect.timeoutFail` 函数允许你在发生超时时产生一个特定的错误。 **示例**(自定义超时错误) ```ts import { Effect, Data } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) class MyTimeoutError extends Data.TaggedError("MyTimeoutError")<{}> {} const program = task.pipe( Effect.timeoutFail({ duration: "1 second", onTimeout: () => new MyTimeoutError(), // Custom timeout error }), ) Effect.runPromiseExit(program).then(console.log) /* Output: Start processing... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: MyTimeoutError { _tag: 'MyTimeoutError' } } } */ ``` ### timeoutFailCause `Effect.timeoutFailCause` 让你可以定义一个在发生超时时抛出的特定 defect。当你希望把超时当作代码中的异常情况来处理时,这会很有帮助。 **示例**(超时时抛出自定义 defect) ```ts import { Effect, Cause } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const program = task.pipe( Effect.timeoutFailCause({ duration: "1 second", onTimeout: () => Cause.die("Timed out!"), // Custom defect for timeout }), ) Effect.runPromiseExit(program).then(console.log) /* Output: Start processing... { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: 'Timed out!' } } */ ``` ### timeoutTo 与 `Effect.timeout` 相比,`Effect.timeoutTo` 提供了更大的灵活性,允许你分别为成功和超时的操作定义不同的结果。当你希望根据操作是否按时完成来自定义结果时,这会很有用。 **示例**(用 [Either](/docs/v3/data-types/either/) 处理成功与超时) ```ts import { Effect, Either } from "effect" const task = Effect.gen(function* () { console.log("Start processing...") yield* Effect.sleep("2 seconds") // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const program = task.pipe( Effect.timeoutTo({ duration: "1 second", onSuccess: (result): Either.Either => Either.right(result), onTimeout: (): Either.Either => Either.left("Timed out!"), }), ) Effect.runPromise(program).then(console.log) /* Output: Start processing... { _id: "Either", _tag: "Left", left: "Timed out!" } */ ``` --- # 两类错误 > 了解 Effect 如何区分预期错误与意外错误,以便更好地追踪和恢复错误。 与其他任何程序一样,Effect 程序也可能因为预期之内或预期之外的原因而失败。 非 Effect 程序与 Effect 程序的区别,在于程序失败时你能获得多少细节。 Effect 会尽可能保留导致程序失败的原因的相关信息,从而给出详细、全面且人类可读的失败信息。 在 Effect 程序中,程序失败有两种可能的方式: - **预期错误(Expected Errors)**:这类错误是开发者在程序正常执行过程中预料到并预期会发生的错误。 - **意外错误(Unexpected Errors)**:这类错误意外发生,不属于程序预期的流程。 ## 预期错误(Expected Errors) 这类错误也称为**失败(failure)**、**类型化错误(typed error)**或**可恢复错误(recoverable error)**,它们是开发者在程序正常执行过程中预料到的错误。 它们的作用类似于受检异常(checked exception),并参与界定程序的领域与控制流。 预期错误会由 `Effect` 数据类型在「Error」通道中于类型层面**追踪**: ```ts const program: Effect ``` 从这个类型可以清楚地看出,该程序可能以 `HttpError` 类型的错误失败。 ## 意外错误(Unexpected Errors) 意外错误也称为 **defect**、**未类型化错误(untyped error)**或**不可恢复错误(unrecoverable error)**,它们是开发者在程序正常执行过程中没有预料到会发生的错误。 与被视为程序领域与控制流一部分的预期错误不同,意外错误类似于未受检异常(unchecked exception),位于程序的预期行为之外。 由于这些错误并不在预期之内,Effect **不会在类型层面追踪**它们。 不过,Effect 运行时仍会记录这些错误,并提供若干方法,帮助你从意外错误中恢复。 --- # 意外错误 > 了解 Effect 如何处理意外错误,以及用于管理 defect、终止执行并有选择地从严重失败中恢复的工具。 有时你会遇到意外错误,需要决定如何处理它们。Effect 提供了若干函数来帮助你应对这类场景,让你在 effect 执行期间发生错误时能够采取恰当的措施。 ## 创建不可恢复的错误 正如可以利用 [Effect.fail](/docs/v3/getting-started/creating-effects/#fail) 这类组合子来创建 `Effect` 类型的值一样,Effect 库也提供了创建 defect 的工具。 从业务逻辑的角度看,当错误无法恢复时,创建 defect 往往是必要的,例如尝试建立连接却在多次重试后被拒绝。 在这些情况下,最好的解决方案或许是终止 effect 的执行,并转入上报流程,例如通过 stdout 之类的输出或某个外部监控服务。 下面这些函数与组合子可以终止 effect,它们常被用来把 `Effect` 类型的值转换为 `Effect` 类型的值,从而给程序员一个逃生出口,不必去处理并恢复那些没有合理恢复方式的错误。 ### die 创建一个以指定错误终止 Fiber 的 effect。 当你在代码中遇到本不该按常规错误处理、而应视为不可恢复 defect 的意外情况时,请使用 `Effect.die`。 `Effect.die` 函数用于发出 defect 信号,defect 代表代码中严重且意外的错误。调用它会产生一个不处理该错误、而是直接终止 Fiber 的 effect。 所生成 effect 的错误通道类型为 `never`,表示它无法从这次失败中恢复。 **示例**(以指定错误终止除零操作) ```ts import { Effect } from "effect" const divide = (a: number, b: number) => b === 0 ? Effect.die(new Error("Cannot divide by zero")) : Effect.succeed(a / b) // ┌─── Effect // ▼ const program = divide(1, 0) Effect.runPromise(program).catch(console.error) /* Output: (FiberFailure) Error: Cannot divide by zero ...stack trace... */ ``` ### dieMessage 创建一个以携带指定消息的 `RuntimeException` 终止 Fiber 的 effect。 当你希望因不可恢复的 defect 而终止 Fiber、并在消息中包含清晰说明时,请使用 `Effect.dieMessage`。 `Effect.dieMessage` 函数用于发出 defect 信号,它代表代码中严重且意外的错误。调用它会产生一个 effect,该 effect 会以携带给定消息的 `RuntimeException` 终止 Fiber。 所生成的 effect 错误通道类型为 `never`,表示它不处理也不恢复该错误。 **示例**(以指定消息终止除零操作) ```ts import { Effect } from "effect" const divide = (a: number, b: number) => b === 0 ? Effect.dieMessage("Cannot divide by zero") : Effect.succeed(a / b) // ┌─── Effect // ▼ const program = divide(1, 0) Effect.runPromise(program).catch(console.error) /* Output: (FiberFailure) RuntimeException: Cannot divide by zero ...stack trace... */ ``` ## 把失败转换为 defect ### orDie 把 effect 的失败转换为 Fiber 的终止,并从 effect 的类型中移除该错误。 当失败应当被视为不可恢复的 defect、且不需要任何错误处理时,请使用 `Effect.orDie`。 当你遇到不想处理或不想恢复的错误时,可以使用 `Effect.orDie` 函数。 它会从 effect 中移除错误类型,并确保任何失败都会终止 Fiber。 这对于把失败作为 defect 传播很有用,表明这些失败不应在该 effect 内部被处理。 **示例**(把错误作为 defect 传播) ```ts import { Effect } from "effect" const divide = (a: number, b: number) => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) // ┌─── Effect // ▼ const program = Effect.orDie(divide(1, 0)) Effect.runPromise(program).catch(console.error) /* Output: (FiberFailure) Error: Cannot divide by zero ...stack trace... */ ``` ### orDieWith 把 effect 的失败转换为带有自定义错误的 Fiber 终止。 当失败应当作为 defect 终止 Fiber、而你希望为了清晰或调试目的自定义错误时,请使用 `Effect.orDieWith`。 `Effect.orDieWith` 函数的行为与 [Effect.orDie](#ordie) 类似,但它允许你提供一个映射函数,在终止 Fiber 之前转换该错误。当失败作为 defect 传播、而你希望包含更详细或对用户更友好的错误时,这一特性很有用。 **示例**(自定义 defect) ```ts import { Effect } from "effect" const divide = (a: number, b: number) => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) // ┌─── Effect // ▼ const program = Effect.orDieWith( divide(1, 0), (error) => new Error(`defect: ${error.message}`), ) Effect.runPromise(program).catch(console.error) /* Output: (FiberFailure) Error: defect: Cannot divide by zero ...stack trace... */ ``` ## 捕获所有 defect 没有任何合理的办法可以从 defect 中恢复。我们接下来要讨论的函数只应在 Effect 与外部系统之间的边界处使用,用于传递 defect 的相关信息,以便诊断或解释。 ### exit `Effect.exit` 函数会把 `Effect` 转换为一个 effect,该 effect 将潜在的失败与成功都封装在 [Exit](/docs/v3/data-types/exit/) 数据类型之中: ```ts Effect -> Effect, never, R> ``` 这意味着,如果你有一个具有以下类型的 effect: ```ts Effect ``` 并对其调用 `Effect.exit`,类型就会变成: ```ts Effect, never, never> ``` 所生成的 effect 不会失败,因为潜在的失败现在由 `Exit` 的 `Failure` 类型表示。 返回的 effect 的错误类型被指定为 `never`,确认该 effect 被构造为不会失败。 通过 yield 一个 `Exit`,我们就获得了对这种类型进行「模式匹配」的能力,从而在生成器函数内部同时处理失败与成功两种情况。 **示例**(用 `Effect.exit` 捕获 defect) ```ts import { Effect, Cause, Console, Exit } from "effect" // Simulating a runtime error const task = Effect.dieMessage("Boom!") const program = Effect.gen(function* () { const exit = yield* Effect.exit(task) if (Exit.isFailure(exit)) { const cause = exit.cause if (Cause.isDieType(cause) && Cause.isRuntimeException(cause.defect)) { yield* Console.log( `RuntimeException defect caught: ${cause.defect.message}`, ) } else { yield* Console.log("Unknown failure caught.") } } }) // We get an Exit.Success because we caught all failures Effect.runPromiseExit(program).then(console.log) /* Output: RuntimeException defect caught: Boom! { _id: "Exit", _tag: "Success", value: undefined } */ ``` ### catchAllDefect 使用提供的恢复函数从所有 defect 中恢复。 `Effect.catchAllDefect` 允许你处理 defect,也就是那些通常会导致程序终止的意外错误。该函数让你可以通过提供一个处理错误的函数,从这些 defect 中恢复。 不过,它不处理预期错误(例如来自 [Effect.fail](/docs/v3/getting-started/creating-effects/#fail) 的错误)或执行中断(例如来自 [Effect.interrupt](/docs/v3/concurrency/basic-concurrency/#interrupt) 的中断)。 **示例**(处理所有 defect) ```ts import { Effect, Cause, Console } from "effect" // Simulating a runtime error const task = Effect.dieMessage("Boom!") const program = Effect.catchAllDefect(task, (defect) => { if (Cause.isRuntimeException(defect)) { return Console.log(`RuntimeException defect caught: ${defect.message}`) } return Console.log("Unknown defect caught.") }) // We get an Exit.Success because we caught all defects Effect.runPromiseExit(program).then(console.log) /* Output: RuntimeException defect caught: Boom! { _id: "Exit", _tag: "Success", value: undefined } */ ``` ## 捕获部分 defect ### catchSomeDefect 使用提供的偏函数从特定 defect 中恢复。 `Effect.catchSomeDefect` 允许你处理特定的 defect,也就是那些可能导致程序停止的意外错误。它使用偏函数只捕获某些 defect,而忽略其他 defect。 不过,它不处理预期错误(例如来自 [Effect.fail](/docs/v3/getting-started/creating-effects/#fail) 的错误)或执行中断(例如来自 [Effect.interrupt](/docs/v3/concurrency/basic-concurrency/#interrupt) 的中断)。 提供给 `Effect.catchSomeDefect` 的函数同时充当 defect 的过滤器与处理器: - 它接收 defect 作为输入。 - 如果该 defect 匹配某个特定条件(例如某种错误类型),函数会返回一个包含恢复逻辑的 `Option.some`。 - 如果该 defect 不匹配,函数会返回 `Option.none`,让该 defect 继续传播。 **示例**(处理特定的 defect) ```ts import { Effect, Cause, Option, Console } from "effect" // Simulating a runtime error const task = Effect.dieMessage("Boom!") const program = Effect.catchSomeDefect(task, (defect) => { if (Cause.isIllegalArgumentException(defect)) { return Option.some( Console.log( `Caught an IllegalArgumentException defect: ${defect.message}`, ), ) } return Option.none() }) // Since we are only catching IllegalArgumentException // we will get an Exit.Failure because we simulated a runtime error. Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: { _tag: 'RuntimeException' } } } */ ``` --- # 可 yield 的错误 > 探索 Effect 编程中的可 yield 错误,了解如何在生成器函数中使用自定义与 tagged error 构造函数实现无缝的错误处理。 可 yield 的错误(Yieldable Errors)是一类特殊的错误,可以直接在生成器函数中通过 [Effect.gen](/docs/v3/getting-started/using-generators/) yield 出来。 这类错误让你能够以直观的方式处理它们,而不必显式调用 [Effect.fail](/docs/v3/getting-started/creating-effects/#fail)。这简化了你在代码中管理自定义错误的方式。 ## Data.Error `Data.Error` 构造函数提供了一种为可 yield 的错误定义基类的方式。 **示例**(创建并 yield 一个自定义错误) ```ts import { Effect, Data } from "effect" // Define a custom error class extending Data.Error class MyError extends Data.Error<{ message: string }> {} export const program = Effect.gen(function* () { // Yield a custom error (equivalent to failing with MyError) yield* new MyError({ message: "Oh no!" }) }) Effect.runPromiseExit(program).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { message: 'Oh no!' } } } */ ``` ## Data.TaggedError `Data.TaggedError` 构造函数让你可以定义带有唯一标签的自定义可 yield 错误。每个错误都有一个 `_tag` 属性,让你可以轻松区分不同的错误类型。因此,使用 [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag) 或 [Effect.catchTags](/docs/v3/error-management/expected-errors/#catchtags) 这类函数来处理特定的 tagged error 会很方便。 **示例**(处理多个 tagged error) ```ts import { Effect, Data, Random } from "effect" // An error with _tag: "Foo" class FooError extends Data.TaggedError("Foo")<{ message: string }> {} // An error with _tag: "Bar" class BarError extends Data.TaggedError("Bar")<{ randomNumber: number }> {} const program = Effect.gen(function* () { const n = yield* Random.next return n > 0.5 ? "yay!" : n < 0.2 ? yield* new FooError({ message: "Oh no!" }) : yield* new BarError({ randomNumber: n }) }).pipe( // Handle different tagged errors using catchTags Effect.catchTags({ Foo: (error) => Effect.succeed(`Foo error: ${error.message}`), Bar: (error) => Effect.succeed(`Bar error: ${error.randomNumber}`), }), ) Effect.runPromise(program).then(console.log, console.error) /* Example Output (n < 0.2): Foo error: Oh no! */ ``` --- # 入门 > Effect 文档导览,以及应该从哪里开始。 欢迎来到 Effect 指南。如果你是 Effect 新手,请先从 [入门引导](/docs/v3/onboarding) 开始,该章节会按顺序介绍核心概念。这些指南会更详细地 讲解每个主题,你也可以在动手构建时把它们当作参考资料使用。 ## 如何使用这些文档 文档按顺序组织,从基础开始,逐步推进到更进阶的主题。这样你就可以在构建 Effect 应用时 一步步跟着学。不过你也可以按任意顺序阅读,或者直接跳到与你具体使用场景相关的页面。 你可以使用页面顶部的版本选择器在 Effect 的不同版本之间切换。 为方便在页面内导航,屏幕右侧提供了目录。你可以借此轻松跳转到页面的不同小节。 ## 指南栏目 这些指南按领域分组: - [错误管理](/docs/v3/error-management/two-error-types) 指南涵盖带类型的错误、回退与重试。 - [并发](/docs/v3/concurrency/basic-concurrency) 指南涵盖 Fiber、有界并行与竞速。 - [资源管理](/docs/v3/resource-management/introduction) 指南讲解如何安全地获取与释放资源。 - [Schema](/docs/v3/schema/introduction) 指南涵盖数据的解析、校验与转换。 - [可观测性](/docs/v3/observability/logging) 指南涵盖日志、指标与追踪。 - [流式处理](/docs/v3/stream/introduction) 指南展示如何构建带背压的数据管道。 要查阅每个模块的完整参考,请见 [API 参考](https://effect.website/docs/v3/api)。 ## 使用 LLM 编写代码 下面这篇文章介绍了如何将 Effect 与 LLM 结合使用:[https://effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive/](https://effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive/) 使用 LLM 时,将反馈回路优化到尽可能紧凑同样非常重要,其中可以包括编写契合你风格偏好与 模式的自定义 lint 规则。一个为 agentic coding 优化过的仓库示例见:[https://github.com/mikearnaldi/accountability](https://github.com/mikearnaldi/accountability) 优化反馈回路(以及总体上使用 Effect 时的开发者体验)的一个关键点是使用 Effect LSP 插件, 我们建议使用它最新的 "tsgo" 实现,见:[https://github.com/Effect-TS/tsgo](https://github.com/Effect-TS/tsgo) ## 加入我们的社区 如果你对任何与 Effect 相关的问题有疑问,欢迎加入[中文社区微信群](/community/)直接提问, 也可以在官方的 [GitHub 仓库](https://github.com/Effect-TS) 上参与讨论。 --- # 构建管道 > 学习如何在 Effect 中构建模块化、可读的管道,组合并串联操作,实现清晰高效的数据转换。 Effect 管道可以组合并串联对值的操作,让你以简洁、模块化的方式转换和处理数据。 ## 为什么管道有利于组织应用结构 管道是组织应用结构、以简洁且模块化的方式处理数据转换的绝佳方式。它带来了以下几方面好处: 1. **可读性**:管道让你以可读的、顺序化的方式组合函数。你可以清楚地看到数据的流动以及所施加的操作,从而更容易理解和维护代码。 2. **代码组织**:借助管道,你可以把复杂操作拆解为更小、更易管理的函数。每个函数只负责一项具体任务,让代码更加模块化,也更容易推理。 3. **可复用性**:管道促进函数的复用。把操作拆分为更小的函数后,你可以在不同的管道或场景中复用它们,从而提升代码复用率并减少重复。 4. **类型安全**:借助类型系统,管道有助于在编译期捕获错误。管道中的函数具有明确的输入和输出类型,确保数据正确地流经管道,并尽可能减少运行时错误。 ## 函数与方法 在 Effect 生态的库中使用函数,对于实现**可摇树优化(tree shakeability)**和确保**可扩展性(extensibility)**非常重要。函数能够通过剔除未使用的代码来实现高效打包,同时也为扩展库的功能提供了灵活、模块化的方式。 ### 可摇树优化 可摇树优化指的是构建系统在打包过程中剔除未使用代码的能力。函数是可摇树优化的,而方法不是。 在 Effect 生态中使用函数时,只有实际被导入并在应用中使用的函数才会包含在最终打包的代码里。未使用的函数会被自动移除,从而得到更小的包体积和更好的性能。 相反,方法挂载在对象或原型上,无法被轻易地摇树剔除。即使你只用到其中一部分方法,与该对象或原型关联的所有方法都会被打包进去,导致不必要的代码膨胀。 ### 可扩展性 在 Effect 生态中使用函数的另一个重要优势是易于扩展。如果使用方法,扩展已有 API 的功能通常需要修改对象的原型,这可能既复杂又容易出错。 相比之下,使用函数时扩展功能要简单得多。你可以把自定义的“扩展方法”定义为普通函数,而无需修改对象的原型。这有助于写出更清晰、更模块化的代码,也能更好地与其他库和模块兼容。 ## pipe `pipe` 是一个工具函数,让我们能够以可读、顺序化的方式组合函数。它把某个函数的输出作为输入传给管道中的下一个函数。这样我们就能通过串联多个函数来构建复杂的转换。 **语法** ```ts import { pipe } from "effect" const result = pipe(input, func1, func2, ..., funcN) ``` 在这个语法中,`input` 是初始值,`func1`、`func2`、…、`funcN` 是按顺序应用的函数。每个函数的结果会成为下一个函数的输入,最终返回最后的结果。 下面用图示说明 `pipe` 是如何工作的: ```text ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐ │ input │───►│ func1 │───►│ func2 │───►│ ... │───►│ funcN │───►│ result │ └───────┘ └───────┘ └───────┘ └───────┘ └───────┘ └────────┘ ``` 需要注意的是,传给 `pipe` 的函数必须是**单参数**的,因为它们只会以单个参数被调用。 下面通过一个例子更好地理解 `pipe` 是如何工作的: **示例**(串联算术运算) ```ts import { pipe } from "effect" // Define simple arithmetic operations const increment = (x: number) => x + 1 const double = (x: number) => x * 2 const subtractTen = (x: number) => x - 10 // Sequentially apply these operations using `pipe` const result = pipe(5, increment, double, subtractTen) console.log(result) // Output: 2 ``` 在上面的例子中,我们从输入值 `5` 开始。`increment` 函数给初始值加 `1`,得到 `6`。接着 `double` 函数把值翻倍,得到 `12`。最后 `subtractTen` 函数从 `12` 中减去 `10`,最终输出 `2`。 这个结果等价于 `subtractTen(double(increment(5)))`,但使用 `pipe` 让代码更易读,因为操作是从左到右顺序书写的,而不是由内向外层层嵌套。 ## map 对 effect 内部的值应用一个函数进行转换。 **语法** ```ts const mappedEffect = pipe(myEffect, Effect.map(transformation)) // or const mappedEffect = Effect.map(myEffect, transformation) // or const mappedEffect = myEffect.pipe(Effect.map(transformation)) ``` `Effect.map` 接收一个函数,并将它应用到 effect 中包含的值上,从而创建一个带有转换后值的新 effect。 **示例**(添加服务费) 下面是一个实际例子:给交易金额加上一笔服务费。 ```ts import { pipe, Effect } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Apply service charge to the transaction amount const finalAmount = pipe(fetchTransactionAmount, Effect.map(addServiceCharge)) Effect.runPromise(finalAmount).then(console.log) // Output: 101 ``` ## as 用一个常量值替换 effect 内部的值。 `Effect.as` 让你可以忽略 effect 内部的原始值,并用一个新的常量值替换它。 **示例**(替换一个值) ```ts import { pipe, Effect } from "effect" // Replace the value 5 with the constant "new value" const program = pipe(Effect.succeed(5), Effect.as("new value")) Effect.runPromise(program).then(console.log) // Output: "new value" ``` ## flatMap 串联 effect 以产生新的 `Effect` 实例,适合组合那些依赖前一步结果的操作。 **语法** ```ts const flatMappedEffect = pipe(myEffect, Effect.flatMap(transformation)) // or const flatMappedEffect = Effect.flatMap(myEffect, transformation) // or const flatMappedEffect = myEffect.pipe(Effect.flatMap(transformation)) ``` 在上面的代码中,`transformation` 是接收一个值并返回 `Effect` 的函数,`myEffect` 是被转换的初始 `Effect`。 当你需要串联多个 effect 时,可以使用 `Effect.flatMap`,它确保每一步都产生一个新的 `Effect`,同时把可能出现的嵌套 effect 展平。 它类似于数组上使用的 `flatMap`,但专门作用于 `Effect` 实例,让你可以避免出现深层嵌套的 effect 结构。 **示例**(应用折扣) ```ts import { pipe, Effect } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Chaining the fetch and discount application using `flatMap` const finalAmount = pipe( fetchTransactionAmount, Effect.flatMap((amount) => applyDiscount(amount, 5)), ) Effect.runPromise(finalAmount).then(console.log) // Output: 95 ``` ### 确保所有 effect 都被考虑在内 请确保 `Effect.flatMap` 中的所有 effect 都对最终计算有所贡献。如果忽略某个 effect,可能会导致意料之外的行为: ```ts Effect.flatMap((amount) => { // This effect will be ignored Effect.sync(() => console.log(`Apply a discount to: ${amount}`)) return applyDiscount(amount, 5) }) ``` 在这个例子中,`Effect.sync` 调用被忽略了,不会影响 `applyDiscount(amount, 5)` 的结果。要正确处理 effect,请务必使用 `Effect.map`、`Effect.flatMap`、`Effect.andThen` 或 `Effect.tap` 这类函数显式地串联它们。 ## andThen 串联两个操作,其中第二个操作可以依赖第一个操作的结果。 **语法** ```ts const transformedEffect = pipe(myEffect, Effect.andThen(anotherEffect)) // or const transformedEffect = Effect.andThen(myEffect, anotherEffect) // or const transformedEffect = myEffect.pipe(Effect.andThen(anotherEffect)) ``` 当你需要按顺序运行多个操作,且第二个操作依赖第一个操作的结果时,可以使用 `andThen`。这对于组合 effect 或处理必须按顺序发生的计算很有用。 第二个操作可以是: 1. 一个值(类似于 `Effect.as`) 2. 一个返回值的函数(类似于 `Effect.map`) 3. 一个 `Promise` 4. 一个返回 `Promise` 的函数 5. 一个 `Effect` 6. 一个返回 `Effect` 的函数(类似于 `Effect.flatMap`) **示例**(基于获取到的金额应用折扣) 下面这个例子对比了 `Effect.andThen` 与 `Effect.map`、`Effect.flatMap` 的用法: ```ts import { pipe, Effect } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Using Effect.map and Effect.flatMap const result1 = pipe( fetchTransactionAmount, Effect.map((amount) => amount * 2), Effect.flatMap((amount) => applyDiscount(amount, 5)), ) Effect.runPromise(result1).then(console.log) // Output: 190 // Using Effect.andThen const result2 = pipe( fetchTransactionAmount, Effect.andThen((amount) => amount * 2), Effect.andThen((amount) => applyDiscount(amount, 5)), ) Effect.runPromise(result2).then(console.log) // Output: 190 ``` ### Option 与 Either 搭配 andThen [Option](/docs/v3/data-types/option/#interop-with-effect) 和 [Either](/docs/v3/data-types/either/#interop-with-effect) 常用于处理可选值、缺失值或简单的错误场景。这两种类型与 `Effect.andThen` 配合得很好。在与 `Effect.andThen` 一起使用时,这些操作属于前面讨论过的第 5 种和第 6 种情形,因为在这里 `Option` 和 `Either` 都被当作 effect 处理。 **示例**(使用 Option) ```ts import { pipe, Effect, Option } from "effect" // Simulated asynchronous task fetching a number from a database const fetchNumberValue = Effect.tryPromise(() => Promise.resolve(42)) // ┌─── Effect // ▼ const program = pipe( fetchNumberValue, Effect.andThen((x) => (x > 0 ? Option.some(x) : Option.none())), ) ``` 你可能以为 `program` 的类型是 `Effect, UnknownException, never>`,但实际上它是 `Effect`。 这是因为 `Option` 被当作类型为 `Effect` 的 effect 处理,因此可能出现的错误会被合并为联合类型。 **示例**(使用 Either) ```ts import { pipe, Effect, Either } from "effect" // Function to parse an integer from a string that can fail const parseInteger = (input: string): Either.Either => isNaN(parseInt(input)) ? Either.left("Invalid integer") : Either.right(parseInt(input)) // Simulated asynchronous task fetching a string from database const fetchStringValue = Effect.tryPromise(() => Promise.resolve("42")) // ┌─── Effect // ▼ const program = pipe( fetchStringValue, Effect.andThen((str) => parseInteger(str)), ) ``` 尽管你可能期望 `program` 的类型是 `Effect, UnknownException, never>`,但它实际上是 `Effect`。 这是因为 `Either` 被当作类型为 `Effect` 的 effect 处理,也就是说错误会被合并为联合类型。 ## tap 执行一个使用 effect 结果的副作用,同时不改变原始值。 当你需要执行日志记录或追踪之类的副作用,又不修改主值时,可以使用 `Effect.tap`。这在需要观察或记录某个动作,同时希望把原始值继续传给下一步时很有用。 `Effect.tap` 的工作方式与 `Effect.flatMap` 类似,但它会忽略传给它的函数的结果。前一个 effect 的值仍然可供链中的下一步使用。注意,如果这个副作用失败,整条链也会失败。 **示例**(在管道中记录一个步骤) ```ts import { pipe, Effect, Console } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const finalAmount = pipe( fetchTransactionAmount, // Log the fetched transaction amount Effect.tap((amount) => Console.log(`Apply a discount to: ${amount}`)), // `amount` is still available! Effect.flatMap((amount) => applyDiscount(amount, 5)), ) Effect.runPromise(finalAmount).then(console.log) /* Output: Apply a discount to: 100 95 */ ``` 在这个例子中,`Effect.tap` 用于在应用折扣前记录交易金额,而不会修改值本身。原始值(`amount`)仍然可供下一个操作(`applyDiscount`)使用。 使用 `Effect.tap` 可以让我们在计算过程中执行副作用而不改变结果。这对于日志记录、执行额外动作,或在不干扰主计算流程的前提下观察中间值都很有用。 ## all 把多个 effect 合并成一个,并根据输入结构返回结果。 当你需要运行多个 effect 并把它们的结果合并为单个输出时,可以使用 `Effect.all`。它支持元组、可迭代对象、结构体(struct)和记录(record),因此能灵活适配不同的输入类型。 例如,如果输入是一个元组: ```ts // ┌─── a tuple of effects // ▼ Effect.all([effect1, effect2, ...]) ``` 这些 effect 会按顺序执行,结果是一个包含各项结果的新 effect(以元组形式)。元组中结果的顺序与传给 `Effect.all` 的 effect 顺序一致。 默认情况下,`Effect.all` 会顺序运行 effect,并产生一个包含结果的元组或对象。如果其中任何 effect 失败,它会停止执行(短路)并传播错误。 关于 `Effect.all` 的更多用法,请参见 [Collecting](/docs/v3/code-style/control-flow/#all)。 **示例**(合并配置检查与数据库检查) ```ts import { Effect } from "effect" // Simulated function to read configuration from a file const webConfig = Effect.promise(() => Promise.resolve({ dbConnection: "localhost", port: 8080 }), ) // Simulated function to test database connectivity const checkDatabaseConnectivity = Effect.promise(() => Promise.resolve("Connected to Database"), ) // Combine both effects to perform startup checks const startupChecks = Effect.all([webConfig, checkDatabaseConnectivity]) Effect.runPromise(startupChecks).then(([config, dbStatus]) => { console.log( `Configuration: ${JSON.stringify(config)}\nDB Status: ${dbStatus}`, ) }) /* Output: Configuration: {"dbConnection":"localhost","port":8080} DB Status: Connected to Database */ ``` ## 构建你的第一个管道 现在让我们把 `pipe` 函数、`Effect.all` 和 `Effect.andThen` 组合起来,创建一个执行一系列转换的管道。 **示例**(构建一个交易管道) ```ts import { Effect, pipe } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Simulated asynchronous task to fetch a discount rate // from a configuration file const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) // Assembling the program using a pipeline of effects const program = pipe( // Combine both fetch effects to get the transaction amount // and discount rate Effect.all([fetchTransactionAmount, fetchDiscountRate]), // Apply the discount to the transaction amount Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate), ), // Add the service charge to the discounted amount Effect.andThen(addServiceCharge), // Format the final result for display Effect.andThen((finalAmount) => `Final amount to charge: ${finalAmount}`), ) // Execute the program and log the result Effect.runPromise(program).then(console.log) // Output: "Final amount to charge: 96" ``` 这个管道展示了如何通过把不同的 effect 组合成清晰、可读的流程来组织代码。 ## pipe 方法 Effect 提供了一个 `pipe` 方法,它的工作方式类似于 [rxjs](https://rxjs.dev/api/index/function/pipe) 中的 `pipe` 方法。这个方法让你可以把多个操作串联起来,使代码更简洁、更易读。 **语法** ```ts const result = effect.pipe(func1, func2, ..., funcN) ``` 它等价于这样使用 `pipe` **函数**: ```ts const result = pipe(effect, func1, func2, ..., funcN) ``` `pipe` 方法可用于所有 effect 以及许多其他数据类型,这样就不需要导入 `pipe` 函数,也能少敲一些代码。 **示例**(使用 `pipe` 方法) 这一次,我们用 `pipe` 方法来重写[前面的例子](#build-your-first-pipeline)。 ```ts import { Effect } from "effect" const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) const program = Effect.all([fetchTransactionAmount, fetchDiscountRate]).pipe( Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate), ), Effect.andThen(addServiceCharge), Effect.andThen((finalAmount) => `Final amount to charge: ${finalAmount}`), ) ``` ## 速查表 下面总结一下我们目前见到的转换函数: | API | Input | Output | | --------- | ----------------------------------------- | --------------------------- | | `map` | `Effect`, `A => B` | `Effect` | | `flatMap` | `Effect`, `A => Effect` | `Effect` | | `andThen` | `Effect`, \* | `Effect` | | `tap` | `Effect`, `A => Effect` | `Effect` | | `all` | `[Effect, Effect, ...]` | `Effect<[A, B, ...], E, R>` | --- # 创建 Effect > 学习如何创建和管理 effect,以结构化的方式处理同步与异步工作流中的成功、失败与副作用。 Effect 提供了多种创建 effect 的方式,effect 是封装副作用的计算单元。 在本指南中,我们将介绍一些常见的创建 effect 的方法。 ## 为什么不抛出错误? 在传统编程中,当错误发生时,通常通过抛出异常来处理: ```ts // Type signature doesn't show possible exceptions const divide = (a: number, b: number): number => { if (b === 0) { throw new Error("Cannot divide by zero") } return a / b } ``` 然而,抛出错误可能会带来问题。函数的类型签名并不会表明它可能抛出异常,这让推断潜在错误变得困难。 为了解决这个问题,Effect 引入了专门的构造函数来创建同时表示成功与失败的 effect:`Effect.succeed` 和 `Effect.fail`。这些构造函数让你可以显式地处理成功与失败的情况,同时**利用类型系统追踪错误**。 ### succeed 创建一个总是以给定值成功的 `Effect`。 当你需要一个以特定值成功完成、并且没有任何错误或外部依赖的 effect 时, 就使用这个函数。 **示例**(创建一个成功的 Effect) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const success = Effect.succeed(42) ``` `success` 的类型是 `Effect`,这意味着: - 它产生一个 `number` 类型的值。 - 它不产生任何错误(`never` 表示没有错误)。 - 它不需要任何额外的数据或依赖(`never` 表示没有需求)。 ```text ┌─── Produces a value of type number │ ┌─── Does not generate any errors │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` ### fail 创建一个表示可以被恢复的错误的 `Effect`。 使用这个函数可以在 `Effect` 中显式地发出错误信号。除非被处理,否则该错误 会持续传播。你可以使用 [Effect.catchAll](/docs/v3/error-management/expected-errors/#catchall) 或 [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag) 这类函数来处理错误。 **示例**(创建一个失败的 Effect) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const failure = Effect.fail(new Error("Operation failed due to network error")) ``` `failure` 的类型是 `Effect`,这意味着: - 它从不产生值(`never` 表示不会产生任何成功结果)。 - 它会以一个错误失败,具体来说是一个 `Error`。 - 它不需要任何额外的数据或依赖(`never` 表示没有需求)。 ```text ┌─── Never produces a value │ ┌─── Fails with an Error │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` 虽然你可以在 `Effect.fail` 中使用 `Error` 对象,但也可以根据你的错误管理策略传递字符串、数字或更复杂的对象。 使用「带标签的」(tagged)错误(含有 `_tag` 字段的对象)有助于识别错误类型,并且能与标准的 Effect 函数(例如 [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag))很好地配合。 **示例**(使用带标签的错误) ```ts import { Effect, Data } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} // ┌─── Effect // ▼ const program = Effect.fail(new HttpError()) ``` ## 错误追踪 借助 `Effect.succeed` 和 `Effect.fail`,你可以显式地处理成功与失败的情况,类型系统会确保错误被追踪并得到处理。 **示例**(重写一个除法函数) 下面展示了如何用 Effect 重写 [`divide`](#why-not-throw-errors) 函数,让错误处理变得显式。 ```ts import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) ``` 在这个例子中,`divide` 函数在其返回类型 `Effect` 中表明:该操作既可能以 `number` 成功,也可能以 `Error` 失败。 ```text ┌─── Produces a value of type number │ ┌─── Fails with an Error ▼ ▼ Effect ``` 这种清晰的类型签名有助于确保错误得到妥善处理,也让每个调用该函数的人都清楚可能的结果。 **示例**(模拟一次用户查询操作) 再设想另一个场景:我们用 `Effect.succeed` 和 `Effect.fail` 为一个简单的用户查询操作建模,其中的用户数据是硬编码的,这在测试场景或需要模拟数据时会很有用: ```ts import { Effect } from "effect" // Define a User type interface User { readonly id: number readonly name: string } // A mocked function to simulate fetching a user from a database const getUser = (userId: number): Effect.Effect => { // Normally, you would access a database or API here, but we'll mock it const userDatabase: Record = { 1: { id: 1, name: "John Doe" }, 2: { id: 2, name: "Jane Smith" }, } // Check if the user exists in our "database" and return appropriately const user = userDatabase[userId] if (user) { return Effect.succeed(user) } else { return Effect.fail(new Error("User not found")) } } // When executed, this will successfully return the user with id 1 const exampleUserEffect = getUser(1) ``` 在这个例子中,`exampleUserEffect` 的类型是 `Effect`,它会根据模拟数据库中是否存在该用户,产生一个 `User` 对象或者一个 `Error`。 如果想更深入地了解如何在应用中管理错误,请参阅[错误管理指南](/docs/v3/error-management/expected-errors/)。 ## 为同步 Effect 建模 在 JavaScript 中,你可以使用「thunk」来延迟同步计算的执行。 Thunk 对于把值的计算推迟到真正需要它的时候很有用。 为了给同步副作用建模,Effect 提供了 `Effect.sync` 和 `Effect.try` 构造函数,它们都接受一个 thunk。 ### sync 创建一个表示同步且带副作用的计算的 `Effect`。 当你确信操作不会失败时,使用 `Effect.sync`。 提供的函数(`thunk`)不得抛出错误;如果它抛出了错误,该错误会被视为[“defect”](/docs/v3/error-management/unexpected-errors/)。 这个 defect 并不是普通的错误,而是表明本应无错的逻辑中存在缺陷。 你可以把它类比为程序中意料之外的崩溃,可以用 [Effect.catchAllDefect](/docs/v3/error-management/unexpected-errors/#catchalldefect) 这类工具进一步管理或记录它。 这一特性确保即使应用中出现了意料之外的失败也不会丢失,而是能够得到妥善处理。 **示例**(记录一条消息) 在下面的例子中,`Effect.sync` 被用来延迟向控制台写入这一副作用。 ```ts import { Effect } from "effect" const log = (message: string) => Effect.sync(() => { console.log(message) // side effect }) // ┌─── Effect // ▼ const program = log("Hello, World!") ``` 封装在 `program` 中的副作用(向控制台记录日志)只有在 effect 被显式运行后才会发生(更多细节参见[运行 Effect](/docs/v3/getting-started/running-effects/)一节)。这让你可以在代码的某一处定义副作用,并掌控它们何时被激活,从而提升大型应用中副作用的可管理性与可预测性。 ### try 创建一个表示可能失败的同步计算的 `Effect`。 当你需要执行可能失败的同步操作(例如解析 JSON)时,可以使用 `Effect.try` 构造函数。 这个构造函数专为处理可能抛出异常的操作而设计:它会捕获这些异常,并把它们转换成可管理的错误。 **示例**(安全的 JSON 解析) 假设你有一个尝试解析 JSON 字符串的函数。如果输入的字符串不是正确的 JSON 格式,这个操作就可能失败并抛出错误: ```ts import { Effect } from "effect" const parse = (input: string) => // This might throw an error if input is not valid JSON Effect.try(() => JSON.parse(input)) // ┌─── Effect // ▼ const program = parse("") ``` 在这个例子中: - `parse` 是一个函数,它创建了一个封装 JSON 解析操作的 effect。 - 如果 `JSON.parse(input)` 因输入非法而抛出错误,`Effect.try` 会捕获这个错误,`program` 所表示的 effect 将以 `UnknownException` 失败。这确保错误不会被悄无声息地忽略,而是在结构化的 effect 流程中得到处理。 #### 自定义错误处理 你可能想把捕获到的异常转换成一个更具体的错误,或者在捕获错误时执行额外的操作。`Effect.try` 支持一个重载,允许你指定捕获到的异常应如何转换: **示例**(自定义错误处理) ```ts import { Effect } from "effect" const parse = (input: string) => Effect.try({ // JSON.parse may throw for bad input try: () => JSON.parse(input), // remap the error catch: (unknown) => new Error(`something went wrong ${unknown}`), }) // ┌─── Effect // ▼ const program = parse("") ``` 你可以把它看作与 JavaScript 中传统的 try-catch 代码块类似的一种模式: ```ts try { return JSON.parse(input) } catch (unknown) { throw new Error(`something went wrong ${unknown}`) } ``` ## 为异步 Effect 建模 在传统编程中,我们经常使用 `Promise` 来处理异步计算。然而,处理 Promise 中的错误可能会很麻烦。默认情况下,`Promise` 只为已解析的值提供类型 `Value`,这意味着错误不会反映在类型系统中。这限制了表达力,也让有效处理和追踪错误变得困难。 为了克服这些限制,Effect 引入了专门的构造函数来创建在异步上下文中同时表示成功与失败的 effect:`Effect.promise` 和 `Effect.tryPromise`。这些构造函数让你可以显式地处理成功与失败的情况,同时**利用类型系统追踪错误**。 ### promise 创建一个表示保证成功的异步计算的 `Effect`。 当你确信操作不会 reject 时,使用 `Effect.promise`。 提供的函数(`thunk`)返回一个绝不应 reject 的 `Promise`;如果它 reject 了,该错误会被视为[“defect”](/docs/v3/error-management/unexpected-errors/)。 这个 defect 并不是普通的错误,而是表明本应无错的逻辑中存在缺陷。 你可以把它类比为程序中意料之外的崩溃,可以用 [Effect.catchAllDefect](/docs/v3/error-management/unexpected-errors/#catchalldefect) 这类工具进一步管理或记录它。 这一特性确保即使应用中出现了意料之外的失败也不会丢失,而是能够得到妥善处理。 **示例**(延迟消息) ```ts import { Effect } from "effect" const delay = (message: string) => Effect.promise( () => new Promise((resolve) => { setTimeout(() => { resolve(message) }, 2000) }), ) // ┌─── Effect // ▼ const program = delay("Async operation completed successfully!") ``` `program` 值的类型是 `Effect`,可以把它理解为一个满足以下条件的 effect: - 以 `string` 类型的值成功 - 不产生任何预期错误(`never`) - 不需要任何上下文(`never`) ### tryPromise 创建一个表示可能失败的异步计算的 `Effect`。 与 `Effect.promise` 不同,当底层的 `Promise` 可能 reject 时,适合使用这个构造函数。 它提供了一种捕获错误并妥善处理的方式。 默认情况下,如果发生错误,它会被捕获并作为 `UnknownException` 传播到错误通道。 **示例**(获取一条 TODO 待办项) ```ts import { Effect } from "effect" const getTodo = (id: number) => // Will catch any errors and propagate them as UnknownException Effect.tryPromise(() => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`), ) // ┌─── Effect // ▼ const program = getTodo(1) ``` `program` 值的类型是 `Effect`,可以把它理解为一个满足以下条件的 effect: - 以 `Response` 类型的值成功 - 可能产生错误(`UnknownException`) - 不需要任何上下文(`never`) #### 自定义错误处理 如果你想更好地控制哪些内容会被传播到错误通道,可以使用 `Effect.tryPromise` 的一个接受重映射函数的重载: **示例**(自定义错误处理) ```ts import { Effect } from "effect" const getTodo = (id: number) => Effect.tryPromise({ try: () => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`), // remap the error catch: (unknown) => new Error(`something went wrong ${unknown}`), }) // ┌─── Effect // ▼ const program = getTodo(1) ``` ## 从回调函数创建 从基于回调的异步函数创建一个 `Effect`。 有时你必须使用那些不支持 `async/await` 或 `Promise`、而是采用回调风格的 API。 为了处理基于回调的 API,Effect 提供了 `Effect.async` 构造函数。 **示例**(包装一个回调式 API) 下面把 Node.js `fs` 模块中的 `readFile` 函数包装成基于 Effect 的 API(请确保已安装 `@types/node`): ```ts import { Effect } from "effect" import * as NodeFS from "node:fs" const readFile = (filename: string) => Effect.async((resume) => { NodeFS.readFile(filename, (error, data) => { if (error) { // Resume with a failed Effect if an error occurs resume(Effect.fail(error)) } else { // Resume with a succeeded Effect if successful resume(Effect.succeed(data)) } }) }) // ┌─── Effect // ▼ const program = readFile("example.txt") ``` 在上面的例子中,我们在调用 `Effect.async` 时手动标注了类型: ```ts Effect.async((resume) => { // ... }) ``` 因为 TypeScript 无法根据回调体内的返回值推断出回调的类型参数。标注类型可以确保传给 `resume` 的值与期望的类型一致。 `Effect.async` 中的 `resume` 函数应当恰好被调用一次。如果调用多次,多余的调用会被忽略。 **示例**(忽略后续的 `resume` 调用) ```ts import { Effect } from "effect" const program = Effect.async((resume) => { resume(Effect.succeed(1)) resume(Effect.succeed(2)) // This line will be ignored }) // Run the program Effect.runPromise(program).then(console.log) // Output: 1 ``` ### 进阶用法 对于更进阶的用法,传给 Effect.async 的回调可以返回一个 Effect:当运行这个 effect 的 Fiber 被中断时,返回的 Effect 就会被执行。你可以用它来在操作被取消时执行清理。 **示例**(通过清理处理中断) 在这个例子中: - `writeFileWithCleanup` 函数把数据写入一个文件。 - 如果运行这个 effect 的 Fiber 被中断,清理 effect(删除该文件)就会被执行。 - 这确保在操作被取消时,已打开的文件句柄这类资源会被妥善清理。 ```ts import { Effect, Fiber } from "effect" import * as NodeFS from "node:fs" // Simulates a long-running operation to write to a file const writeFileWithCleanup = (filename: string, data: string) => Effect.async((resume) => { const writeStream = NodeFS.createWriteStream(filename) // Start writing data to the file writeStream.write(data) // When the stream is finished, resume with success writeStream.on("finish", () => resume(Effect.void)) // In case of an error during writing, resume with failure writeStream.on("error", (err) => resume(Effect.fail(err))) // Handle interruption by returning a cleanup effect return Effect.sync(() => { console.log(`Cleaning up ${filename}`) NodeFS.unlinkSync(filename) }) }) const program = Effect.gen(function* () { const fiber = yield* Effect.fork( writeFileWithCleanup("example.txt", "Some long data..."), ) // Simulate interrupting the fiber after 1 second yield* Effect.sleep("1 second") yield* Fiber.interrupt(fiber) // This will trigger the cleanup }) // Run the program Effect.runPromise(program) /* Output: Cleaning up example.txt */ ``` 如果你包装的操作支持中断,`resume` 函数可以接收一个 `AbortSignal`,从而直接处理中断请求。 **示例**(使用 `AbortSignal` 处理中断) ```ts import { Effect, Fiber } from "effect" // A task that supports interruption using AbortSignal const interruptibleTask = Effect.async((resume, signal) => { // Simulate a long-running task const timeoutId = setTimeout(() => { console.log("Operation completed") resume(Effect.void) }, 2000) // Handle interruption signal.addEventListener("abort", () => { console.log("Abort signal received") clearTimeout(timeoutId) }) }) const program = Effect.gen(function* () { const fiber = yield* Effect.fork(interruptibleTask) // Simulate interrupting the fiber after 1 second yield* Effect.sleep("1 second") yield* Fiber.interrupt(fiber) }) // Run the program Effect.runPromise(program) /* Output: Abort signal received */ ``` ## 挂起的 Effect `Effect.suspend` 用于延迟一个 effect 的创建。 它让你可以把 effect 的求值推迟到真正需要它的时候。 `Effect.suspend` 函数接收一个表示该 effect 的 thunk,并把它包装成一个挂起的 effect。 **语法** ```ts const suspendedEffect = Effect.suspend(() => effect) ``` 下面来看看 `Effect.suspend` 特别有用的一些常见场景。 ### 惰性求值 当你想把 effect 的求值推迟到需要它的时候。这对于优化 effect 的执行很有用,尤其是当它们并不总是被用到、或者计算开销很大时。 另外,当创建带有副作用或作用域捕获的 effect 时,可以使用 `Effect.suspend` 让它在每次调用时重新执行。 **示例**(带副作用的惰性求值) ```ts import { Effect } from "effect" let i = 0 const bad = Effect.succeed(i++) const good = Effect.suspend(() => Effect.succeed(i++)) console.log(Effect.runSync(bad)) // Output: 0 console.log(Effect.runSync(bad)) // Output: 0 console.log(Effect.runSync(good)) // Output: 1 console.log(Effect.runSync(good)) // Output: 2 ``` 在这个示例中,`bad` 是调用一次 `Effect.succeed(i++)` 的结果,它会让作用域变量自增,但[返回的是它原来的值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Increment#postfix_increment)。`Effect.runSync(bad)` 不会带来任何新的计算,因为 `Effect.succeed(i++)` 已经被调用过了。另一方面,每次调用 `Effect.runSync(good)` 时,传给 `Effect.suspend()` 的 thunk 都会被执行,输出作用域变量最新的值。 ### 处理循环依赖 `Effect.suspend` 有助于管理 effect 之间的循环依赖,即一个 effect 依赖另一个 effect,反之亦然。 例如,在递归函数中使用 `Effect.suspend` 来避免一次急切调用(eager call)是相当常见的做法。 **示例**(递归斐波那契) ```ts import { Effect } from "effect" const blowsUp = (n: number): Effect.Effect => n < 2 ? Effect.succeed(1) : Effect.zipWith(blowsUp(n - 1), blowsUp(n - 2), (a, b) => a + b) // console.log(Effect.runSync(blowsUp(32))) // crash: JavaScript heap out of memory const allGood = (n: number): Effect.Effect => n < 2 ? Effect.succeed(1) : Effect.zipWith( Effect.suspend(() => allGood(n - 1)), Effect.suspend(() => allGood(n - 2)), (a, b) => a + b, ) console.log(Effect.runSync(allGood(32))) // Output: 3524578 ``` `blowsUp` 函数在没有延迟执行的情况下创建了一个递归的斐波那契数列。每次调用 `blowsUp` 都会立即触发更多递归调用,迅速增大 JavaScript 调用栈的规模。 相反,`allGood` 通过使用 `Effect.suspend` 延迟递归调用来避免栈溢出。这个机制不会立即执行递归的 effect,而是把它们安排到稍后运行,从而让调用栈保持较浅,避免崩溃。 ### 统一返回类型 在 TypeScript 难以统一返回的 effect 类型的情况下,可以使用 `Effect.suspend` 来解决这个问题。 **示例**(借助 `Effect.suspend` 帮助 TypeScript 推断类型) ```ts import { Effect } from "effect" /* Without suspend, TypeScript may struggle with type inference. Inferred type: (a: number, b: number) => Effect | Effect */ const withoutSuspend = (a: number, b: number) => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) /* Using suspend to unify return types. Inferred type: (a: number, b: number) => Effect */ const withSuspend = (a: number, b: number) => Effect.suspend(() => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b), ) ``` ## 速查表 下表汇总了可用的构造函数及其输入与输出类型,帮助你根据自身需求选择合适的函数。 | API | 给定 | 结果 | | ----------------------- | ---------------------------------- | ----------------------------- | | `succeed` | `A` | `Effect` | | `fail` | `E` | `Effect` | | `sync` | `() => A` | `Effect` | | `try` | `() => A` | `Effect` | | `try` (overload) | `() => A`, `unknown => E` | `Effect` | | `promise` | `() => Promise` | `Effect` | | `tryPromise` | `() => Promise` | `Effect` | | `tryPromise` (overload) | `() => Promise`, `unknown => E` | `Effect` | | `async` | `(Effect => void) => void` | `Effect` | | `suspend` | `() => Effect` | `Effect` | 构造器的完整列表请见 [Effect 构造函数文档](https://effect.website/docs/v3/api/effect/Effect#category-creating-effects)。 --- # 开发者工具 > 借助 Effect Language Service 与官方的 VS Code/Cursor 扩展,获得高级诊断、重构和智能代码补全,全面提升你的 Effect 开发体验。 Effect 提供了一系列强大的开发工具,用来提升你的编码体验,帮助你编写更安全、更易维护的代码。这些工具会直接集成到你的编辑器中,提供实时反馈、智能重构以及有用的诊断信息。 ## Effect LSP Effect LSP 用 Effect 专属功能扩展了你的编辑器。它会分析你的 Effect 代码,并通过诊断、快速信息、代码补全和自动重构来提供智能辅助。 它可以在支持标准 TypeScript LSP 的编辑器中工作,例如 Code、Cursor、Zed、NVim 等。 ### 安装 要在你的项目中安装 Effect Language Service: 1. 将包安装为开发依赖: 对于 monorepo,我们建议在根目录层级安装该语言服务。对于单包项目,请在包目录中安装。 ```sh npm install @effect/language-service --save-dev ``` ```sh pnpm add -D @effect/language-service ``` ```sh yarn add --dev @effect/language-service ``` ```sh bun add --dev @effect/language-service ``` 2. 将插件添加到你的 `tsconfig.json`: ```json { "compilerOptions": { "plugins": [ { "name": "@effect/language-service" } ] } } ``` 3. 确保你的编辑器使用工作区的 TypeScript 版本: 这一步对语言服务能否正常工作至关重要。插件必须运行在你项目所安装的 TypeScript 版本上,而不是编辑器内置的版本。 4. 现在你可以开始上手了! 在你的项目中新建一个 file.ts 并写入以下代码,应该会出现一条错误诊断,提示 Effect 必须被 yield 或赋值给变量: ```ts import { Effect } from "effect" Effect.log("Hello world!") // ^- should be run or assigned to a variable! ``` ### 功能特性 Effect Language Service 提供了一整套完善的功能,用来提升你的开发工作流: #### 智能快速信息 将鼠标悬停在 Effect 值上,即可查看扩展的类型信息和详细的洞察: - **Effect 类型**:查看 Effect 值的完整类型信息 - **Generator 参数**:在 `Effect.gen` 中悬停在 `yield*` 上时,查看所 yield 值的详细信息 - **Layer 组合**:借助交互式图表可视化 Layer 依赖,展示各个 Layer 是如何组合在一起的 - **Service 依赖**:一眼看清服务的需求以及它们之间的依赖关系 #### 实时诊断 在编写代码时及时发现常见错误和潜在问题: - **游离的 Effect**:检测未被赋值或未 yield 的 Effect 值,避免出现静默 bug - **Layer 问题**:在运行前捕获 Layer 需求泄漏和作用域违规 - **不必要的代码**:识别多余的 `Effect.gen` 或 `pipe()` 调用 - **错误处理**:检测在不会失败的 Effect 上误用 catch 函数的情况 - **版本冲突**:检测项目中是否存在多个 Effect 版本 #### 智能补全 借助上下文感知的建议加快编码速度: - **Generator 样板代码**:快速生成 `Effect.gen` 函数骨架 - **脚手架**:用于 `Effect.Service`、`Data.TaggedError` 以及类似结构。 - **Self 参数**:为服务声明中的 `Self` 参数提供自动补全 #### 强大的重构 通过智能自动重构来改造你的代码: - **Async 转 Effect**:使用 `gen` 或 `fn` 语法将 async 函数转换为 Effect - **错误生成**:从基于 Promise 的代码生成 tagged error - **Service 访问器**:自动实现服务访问器函数 - **Pipe 转换**:将函数调用转换为 pipe 语法 - **Pipe 风格**:在不同的 pipe 风格格式之间切换 - **Layer 魔法**:自动按正确的依赖关系组合 Layer ### 配置 Effect LSP 还提供了大量配置选项,例如修改严重级别或禁用某些诊断消息。 要查看完整的选项与功能列表,请访问 [LSP 仓库的 README](https://github.com/Effect-TS/language-service)。 ### 构建时诊断 LSP 只在编辑会话期间生效,而你可能希望在构建过程中也能捕获诊断信息。 通常这是通过 lint 规则实现的,但由于几乎所有 Effect 诊断都依赖于类型,这就意味着要启用类型感知的 lint,也就是要对项目文件再次进行类型检查。 为了解决这个问题,Effect Language Service 允许你为本地安装的 TypeScript 打补丁,从而在进行类型检查时一并给出诊断信息。 要启用它,请运行以下命令来修改你本地安装的 TypeScript: ```sh effect-language-service patch ``` 要让所有开发者都自动完成这一步,请把它加到你的 `package.json` 中: ```json { "scripts": { "prepare": "effect-language-service patch" } } ``` 这样可以确保语言服务在使用标准 `tsc` 命令编译时也会运行。 ## VS Code / Cursor 扩展 编辑器扩展提供了一些实用工具,帮助你调试 Effect 应用。 目前只支持 Code 以及像 Cursor 这样的 Code 分支。 ### 安装 你可以在编辑器的扩展页面中直接搜索安装该扩展,也可以从 [Code Marketplace](https://marketplace.visualstudio.com/items?itemName=effectful-tech.effect-vscode) 或 [Open VSX Marketplace](https://open-vsx.org/extension/effectful-tech/effect-vscode) 安装。 ### 调试器功能 使用 Effect 扩展后,你会在编辑器的 Debug 区域中看到几个新的小节,当你暂停执行时,它们会显示相关信息。 - **Context**:允许你查看当前暂停的 Effect Fiber 的上下文。 - **Span Stack**:显示引导你进入当前暂停的 Effect 执行的遥测 span 堆栈。 - **Fibers**:列出应用中正在运行的所有 Effect Fiber,允许你查看诸如可中断性等信息,并允许请求中断它们。 - **Breakpoints**:启用 “pause on defect”,让调试器在某个 Effect Fiber 因 defect 而失败时暂停。 ### 内置 Tracer 与 Metrics 内置的 tracer 与 metrics 视图可以让你无需启动整套遥测服务,就能快速查看应用中的 Effect Span 与 Metric。 要启用它,你需要在项目中安装以下依赖: ```sh npm install @effect/experimental ``` ```sh pnpm install @effect/experimental ``` ```sh yarn add @effect/experimental ``` ```sh bun add @effect/experimental ``` 然后你就可以在 Effect 应用中导入并使用 DevTools 模块: ```ts import { DevTools } from "@effect/experimental" import { NodeRuntime, NodeSocket } from "@effect/platform-node" import { Effect, Layer } from "effect" const program = Effect.log("Hello!").pipe( Effect.delay(2000), Effect.withSpan("Hi", { attributes: { foo: "bar" } }), Effect.forever, ) const DevToolsLive = DevTools.layer() program.pipe(Effect.provide(DevToolsLive), NodeRuntime.runMain) ``` 如果你的项目中使用了 `@effect/opentelemetry`,那么务必在你的 tracing layer 之前提供 DevTools layer,这样 tracer 才能被正确地打补丁。 现在同时启动你的编辑器和应用。在 Effect 面板的 clients 区域中,你会看到一个新连接的客户端。 在编辑器底部、终端附近,还会出现一个新的 “Effect Tracer” 标签页,以可视化的方式实时展示你的 span。 --- # 导入 Effect > 通过安装 effect 包并导入必要的模块与函数,开始用 Effect 构建类型安全、模块化的应用。 如果你刚刚入门,可能会被 Effect 提供的众多模块和函数弄得不知所措。 不过请放心,你并不需要马上就把它们全部搞懂。 本页会简单介绍如何导入模块与函数,并说明:开始使用时,通常只需要安装 `effect` 包就够了。 ## 安装 Effect 如果你还没有安装 `effect` 包,可以在终端中运行下面的命令来安装: ```sh npm install effect ``` ```sh pnpm add effect ``` ```sh yarn add effect ``` ```sh bun add effect ``` ```sh deno add npm:effect ``` 安装这个包之后,你就可以使用 Effect 的核心功能。 如果你想了解 Deno、Bun 等平台的详细安装说明,请参阅 [安装](/docs/v3/getting-started/installation/) 指南,其中提供了逐步的操作指引。 ## 导入模块与函数 安装好 `effect` 包之后,你就可以在项目中使用它的模块和函数了。 导入模块与函数非常直接,遵循标准的 JavaScript/TypeScript 导入语法。 要从 `effect` 包中导入某个模块或函数,只需在文件顶部使用 `import` 语句。下面演示如何导入 `Effect` 模块: ```ts import { Effect } from "effect" ``` 现在你就可以使用 Effect 模块了,它是 Effect 库的核心。它提供了各种函数,用于创建、组合和操作带 effect 的计算。 ## 命名空间导入 除了像前面那样用命名导入的方式导入 `Effect` 模块之外: ```ts import { Effect } from "effect" ``` 你还可以用命名空间导入的方式这样写: ```ts import * as Effect from "effect/Effect" ``` 这两种导入方式都能让你使用 `Effect` 模块提供的功能。 不过有一个重要的考量:**tree shaking**(摇树优化),它指的是在打包应用的过程中剔除未使用代码的过程。 当打包工具不支持深层作用域分析时,命名导入可能会带来 tree shaking 问题。 以下这些打包工具支持深层作用域分析,因此命名导入不会有问题: - Rolldown - Rollup - Webpack 5+ ## 函数与方法 在 Effect 生态中,库通常暴露的是函数而不是方法。这一设计选择之所以重要,有两个关键原因:tree shaking 的友好性与可扩展性。 ### Tree shaking 友好性 Tree shaking 友好性是指构建系统在打包过程中剔除未使用代码的能力。函数可以被 tree shaking 剔除,而方法不行。 在 Effect 生态中使用函数时,只有那些真正被导入并在应用中用到的函数才会被包含进最终打包的代码里。未使用的函数会被自动移除,从而减小打包体积、提升性能。 另一方面,方法依附在对象或原型上,很难被 tree shaking 剔除。即使你只用到其中一部分方法,与该对象或原型关联的所有方法都会被放进打包结果,造成不必要的代码膨胀。 ### 可扩展性 在 Effect 生态中使用函数还有一个重要优势:易于扩展。如果使用方法,想扩展某个现有 API 的功能往往需要修改对象的原型,这既复杂又容易出错。 相比之下,使用函数时扩展功能要简单得多。你可以把自己的「扩展方法」定义为普通的函数,而无需修改对象的原型。这有助于写出更清晰、更模块化的代码,也能更好地与其他库和模块兼容。 ## 常用函数 当你开始 Effect 之旅时,并不需要立刻钻研 `effect` 包里的每一个函数。相反,先专注于一些常用函数,它们会为你的 Effect 世界之旅打下坚实的基础。 在接下来的指南中,我们会探讨其中一些必不可少的函数,特别是用于创建和运行 `Effect` 以及构建管道的那些函数。 但在深入这些内容之前,让我们从 Effect 的核心开始:理解 `Effect` 类型。这将为你理解 Effect 如何把可组合性、类型安全和错误处理带进你的应用打下基础。 那么,让我们迈出第一步,探索 [Effect 类型](/docs/v3/getting-started/the-effect-type/) 的基础概念。 --- # 安装 > 一步步教你如何在 Node.js、Deno、Bun 以及 Vite + React 等不同平台上搭建新的 Effect 项目。 环境要求: - TypeScript 5.4 或更高版本。 - 支持 Node.js 22.18 或更高版本、Deno 以及 Bun。 ## 手动安装 ### JavaScript 运行时 按照以下步骤为 [Node.js](https://nodejs.org/)、[Bun](https://bun.sh/) 或 [Deno](https://deno.com/) 创建一个新的 Effect 项目: 1. 创建一个项目目录并进入其中: ```sh mkdir hello-effect cd hello-effect ``` 2. 初始化一个 TypeScript 项目: ```sh npm init -y npm install --save-dev typescript ``` ```sh pnpm init pnpm add --save-dev typescript ``` ```sh yarn init -y yarn add --dev typescript ``` ```sh bun init ``` ```sh deno init ``` 这会创建一个 `package.json` 文件,为你的 TypeScript 项目提供初始配置。对于 Bun,这还会生成一个 `tsconfig.json` 文件;对于 Deno,则会生成一个 `deno.json` 文件。 请确保 `package.json` 文件中包含 `"type": "module"` 字段,这样 Node.js 才会把你的源文件当作 ES 模块处理(`bun init` 会自动添加该字段): ```json { "type": "module" } ``` 3. 初始化 TypeScript: ```sh npx tsc --init ``` ```sh pnpm tsc --init ``` ```sh yarn tsc --init ``` `bun init` 已经生成了一个 `tsconfig.json` 文件。 Deno 开箱即用地运行 TypeScript,并且 `deno init` 已经生成了一个 `deno.json` 文件,其中默认启用了 `strict` 模式。无需再做其他配置。 运行该命令后,会生成一个 `tsconfig.json` 文件,其中包含 TypeScript 的配置选项。最需要重视的选项之一是 `strict` 标志。 请务必打开 `tsconfig.json` 文件,确认 `strict` 选项的值被设置为 `true`。 ```json { "compilerOptions": { "strict": true } } ``` 4. 把所需的包安装为依赖: ```sh npm install effect ``` ```sh pnpm add effect ``` ```sh yarn add effect ``` ```sh bun add effect ``` ```sh deno add npm:effect ``` 这个包会为你的 Effect 项目提供基础功能。 让我们编写并运行一个简单的程序,确认一切都已经正确配置。 在终端中执行以下命令: ```sh mkdir src touch src/index.ts ``` 打开 `src/index.ts` 文件并添加以下代码: ```ts import { Effect, Console } from "effect" const program = Console.log("Hello, World!") Effect.runSync(program) ``` 运行 `src/index.ts` 文件。Node.js 22.18 或更高版本、Bun 与 Deno 都可以直接运行 TypeScript 文件,因此不需要额外的工具链: ```sh node src/index.ts ``` ```sh node src/index.ts ``` ```sh node src/index.ts ``` ```sh bun src/index.ts ``` ```sh deno run src/index.ts ``` 如果你使用的是较旧版本的 Node.js,可以改用 [tsx](https://github.com/privatenumber/tsx) 运行该文件:`npx tsx src/index.ts`。 你应该会看到打印出 `"Hello, World!"` 消息。这说明程序运行正常。 ### Vite + React 按照以下步骤为 [Vite](https://vitejs.dev/guide/) + [React](https://react.dev/) 创建一个新的 Effect 项目: 1. 搭建 Vite 项目脚手架:打开终端并运行以下命令: ```sh # npm 6.x npm create vite@latest hello-effect --template react-ts # npm 7+, extra double-dash is needed npm create vite@latest hello-effect -- --template react-ts ``` ```sh pnpm create vite@latest hello-effect -- --template react-ts ``` ```sh yarn create vite@latest hello-effect -- --template react-ts ``` ```sh bun create vite@latest hello-effect -- --template react-ts ``` ```sh deno init --npm vite@latest hello-effect -- --template react-ts ``` 该命令会创建一个使用 React 与 TypeScript 模板的新 Vite 项目。 2. 进入新建的项目目录并安装所需的包: ```sh cd hello-effect npm install ``` ```sh cd hello-effect pnpm install ``` ```sh cd hello-effect yarn install ``` ```sh cd hello-effect bun install ``` ```sh cd hello-effect deno install ``` 安装完成后,打开 `tsconfig.json` 文件,确认 `strict` 选项的值被设置为 true。 ```json { "compilerOptions": { "strict": true } } ``` 3. 把所需的包安装为依赖: ```sh npm install effect ``` ```sh pnpm add effect ``` ```sh yarn add effect ``` ```sh bun add effect ``` ```sh deno add npm:effect ``` 这个包会为你的 Effect 项目提供基础功能。 现在,让我们编写并运行一个简单的程序,确认一切都已经正确配置。 打开 `src/App.tsx` 文件,并用以下代码替换其内容: ```diff +import { useState, useMemo, useCallback } from "react" import reactLogo from "./assets/react.svg" import viteLogo from "/vite.svg" import "./App.css" +import { Effect } from "effect" function App() { const [count, setCount] = useState(0) + const task = useMemo( + () => Effect.sync(() => setCount((current) => current + 1)), + [setCount] + ) + + const increment = useCallback(() => Effect.runSync(task), [task]) return ( <>

Vite + React

+

Edit src/App.tsx and save to test HMR

Click on the Vite and React logos to learn more

) } export default App ``` 完成这些修改后,运行以下命令启动开发服务器: ```sh npm run dev ``` ```sh pnpm run dev ``` ```sh yarn run dev ``` ```sh bun run dev ``` ```sh deno run dev ``` 然后按 **o** 在浏览器中打开应用。 点击按钮时,你应该会看到计数器递增。这说明程序运行正常。 --- # 运行 Effect > 了解如何在 Effect 中使用各种用于同步与异步执行的函数来运行 effect,包括处理结果以及管理错误结果。 要执行一个 effect,你可以使用 `Effect` 模块提供的众多 `run` 函数之一。 ## runSync 同步地执行一个 effect:立即运行它并返回结果。 **示例**(同步日志记录) ```ts import { Effect } from "effect" const program = Effect.sync(() => { console.log("Hello, World!") return 1 }) const result = Effect.runSync(program) // Output: Hello, World! console.log(result) // Output: 1 ``` 使用 `Effect.runSync` 来运行一个不会失败、也不包含任何异步操作的 effect。如果该 effect 失败或涉及异步工作,它会抛出错误,执行会在失败或异步操作发生的位置停止。 **示例**(失败或异步 effect 的错误用法) ```ts import { Effect } from "effect" try { // Attempt to run an effect that fails Effect.runSync(Effect.fail("my error")) } catch (e) { console.error(e) } /* Output: (FiberFailure) Error: my error */ try { // Attempt to run an effect that involves async work Effect.runSync(Effect.promise(() => Promise.resolve(1))) } catch (e) { console.error(e) } /* Output: (FiberFailure) AsyncFiberException: Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work */ ``` ## runSyncExit 同步地运行一个 effect,并把结果作为 [Exit](/docs/v3/data-types/exit/) 类型返回,该类型表示 effect 的结果(成功或失败)。 使用 `Effect.runSyncExit` 可以在不处理异步操作的情况下,判断一个 effect 是成功还是失败,包括其中出现的任何 defect。 `Exit` 类型表示 effect 的结果: - 如果 effect 成功,结果会被包装在一个 `Success` 中。 - 如果失败,失败信息会以一个 `Failure` 的形式给出,其中包含一个 [Cause](/docs/v3/data-types/cause/) 类型。 **示例**(以 Exit 的形式处理结果) ```ts import { Effect } from "effect" console.log(Effect.runSyncExit(Effect.succeed(1))) /* Output: { _id: "Exit", _tag: "Success", value: 1 } */ console.log(Effect.runSyncExit(Effect.fail("my error"))) /* Output: { _id: "Exit", _tag: "Failure", cause: { _id: "Cause", _tag: "Fail", failure: "my error" } } */ ``` 如果 effect 中包含异步操作,`Effect.runSyncExit` 会返回一个带 `Die` cause 的 `Failure`,表示该 effect 无法被同步地解析。 **示例**(异步操作导致 Die) ```ts import { Effect } from "effect" console.log(Effect.runSyncExit(Effect.promise(() => Promise.resolve(1)))) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: [Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work] { fiber: [FiberRuntime], _tag: 'AsyncFiberException', name: 'AsyncFiberException' } } } */ ``` ## runPromise 执行一个 effect,并把结果作为 `Promise` 返回。 当你需要执行一个 effect,并使用 `Promise` 语法来处理结果时,请使用 `Effect.runPromise`,这通常是为了与其他基于 promise 的代码兼容。 **示例**(把成功的 effect 作为 Promise 运行) ```ts import { Effect } from "effect" Effect.runPromise(Effect.succeed(1)).then(console.log) // Output: 1 ``` 如果 effect 成功,promise 会以该结果兑现(resolve)。如果 effect 失败,promise 会以错误拒绝(reject)。 **示例**(把失败的 effect 作为被拒绝的 Promise 处理) ```ts import { Effect } from "effect" Effect.runPromise(Effect.fail("my error")).catch(console.error) /* Output: (FiberFailure) Error: my error */ ``` ## runPromiseExit 运行一个 effect,并返回一个兑现(resolve)为 [Exit](/docs/v3/data-types/exit/) 的 `Promise`,它表示该 effect 的结果(成功或失败)。 当你需要判断一个 effect 是成功还是失败(包括其中出现的任何 defect),并且希望使用 `Promise` 时,请使用 `Effect.runPromiseExit`。 `Exit` 类型表示 effect 的结果: - 如果 effect 成功,结果会被包装在一个 `Success` 中。 - 如果失败,失败信息会以一个 `Failure` 的形式给出,其中包含一个 [Cause](/docs/v3/data-types/cause/) 类型。 **示例**(以 Exit 的形式处理结果) ```ts import { Effect } from "effect" Effect.runPromiseExit(Effect.succeed(1)).then(console.log) /* Output: { _id: "Exit", _tag: "Success", value: 1 } */ Effect.runPromiseExit(Effect.fail("my error")).then(console.log) /* Output: { _id: "Exit", _tag: "Failure", cause: { _id: "Cause", _tag: "Fail", failure: "my error" } } */ ``` ## runFork 运行 effect 的基础函数,会返回一个可被观测或中断的「fiber」。 `Effect.runFork` 通过创建 fiber 来在后台运行一个 effect。它是所有其他 run 函数的基础函数。它会启动一个可被观测或中断的 fiber。 **示例**(在后台运行一个 effect) ```ts import { Effect, Console, Schedule, Fiber } from "effect" // ┌─── Effect // ▼ const program = Effect.repeat( Console.log("running..."), Schedule.spaced("200 millis"), ) // ┌─── RuntimeFiber // ▼ const fiber = Effect.runFork(program) setTimeout(() => { Effect.runFork(Fiber.interrupt(fiber)) }, 500) ``` 在这个示例中,`program` 会持续打印 "running...",每次重复之间相隔 200 毫秒。你可以在[调度简介](/docs/v3/scheduling/introduction/)指南中进一步了解重复与调度。 要停止程序的执行,我们对 `Effect.runFork` 返回的 fiber 调用 `Fiber.interrupt`。这让你可以控制执行流程,并在需要时将其终止。 如果想更深入地理解 fiber 的工作方式以及如何处理中断,请参阅 [Fiber](/docs/v3/concurrency/fibers/) 与[中断](/docs/v3/concurrency/basic-concurrency/#interruptions)这两篇指南。 ## 同步 Effect 与异步 Effect 在 Effect 库中,没有内建的方法可以预先判断一个 effect 会同步执行还是异步执行。虽然在早期版本的 Effect 中考虑过这个想法,但最终出于几个重要原因没有实现: 1. **复杂性:** 引入这一特性来在类型系统中跟踪同步/异步行为,会让 Effect 更难使用,并限制它的可组合性。 2. **安全性顾虑:** 我们尝试过用不同的方式来跟踪异步 Effect,但它们都导致开发者体验变差,却没有显著提升安全性。即使有了完全同步的类型,我们仍然需要支持一个 `fromCallback` 组合子,以便与使用延续传递风格(Continuation-Passing Style,CPS)的 API 协作。然而在类型层面,无法保证这样的函数总是被立即调用,而不是被推迟执行。 ### 运行 Effect 的最佳实践 大多数情况下,effect 都是在应用的最外层运行的。通常,围绕 Effect 构建的应用只会涉及一次对主 effect 的调用。下面是你应该如何处理 effect 的执行: - **使用 `runPromise` 或 `runFork`:** 大多数情况下,异步执行都应该是默认选择。这些方法提供了处理基于 Effect 的工作流的最佳方式。 - **只在必要时使用 `runSync`:** 同步执行应被视为一种边缘情况,只在无法进行异步执行的场景中使用。例如,当你确定该 effect 完全是同步的、并且需要立即拿到结果时。 ## 速查表 下表汇总了可用的 `run*` 函数及其输入与输出类型,帮助你根据自身需求选择合适的函数。 | API | Given | Result | | ---------------- | -------------- | --------------------- | | `runSync` | `Effect` | `A` | | `runSyncExit` | `Effect` | `Exit` | | `runPromise` | `Effect` | `Promise` | | `runPromiseExit` | `Effect` | `Promise>` | | `runFork` | `Effect` | `RuntimeFiber` | 你可以在[这里](https://effect.website/docs/v3/api/effect/Effect#category-running-effects)找到 `run*` 函数的完整列表。 --- # Effect 类型 > 理解 Effect 生态中的 Effect 类型:它为带 effect 的计算提供类型安全的成功值、错误与需求处理,并用于建模不可变、惰性的工作流。 `Effect` 类型是对**惰性**执行的工作流或操作的描述。也就是说,当你创建一个 `Effect` 时,它并不会立即运行,而是定义了一个可能成功、失败,或者需要一些额外上下文才能完成的程序。 下面是 `Effect` 的一般形式: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Effect ``` 这个类型表明一个 effect 会: - 成功并返回一个 `Success` 类型的值 - 以 `Error` 类型的错误失败 - 执行时可能需要 `Requirements` 类型的特定上下文依赖 从概念上讲,你可以把 `Effect` 看作下面这种函数类型的「带 effect」版本: ```ts type Effect = ( context: Context, ) => Error | Success ``` 不过,effect 实际上并不是函数。它们可以建模同步、异步、并发以及需要管理资源的计算。 **不可变性**。`Effect` 值是不可变的,Effect 库中的每个函数都会产生一个新的 `Effect` 值。 **对交互建模**。这些值本身不执行任何动作,它们只是建模或描述带 effect 的交互。 **执行**。`Effect` 可以由 [Effect 运行时系统](/docs/v3/runtime/) 执行,后者会把它解释为与外部世界的实际交互。 理想情况下,这次执行发生在你应用中的单一入口点,例如发起各种 effect 操作的 main 函数。 ## 类型参数 `Effect` 类型有三个类型参数,含义如下: | 参数 | 说明 | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Success** | 表示 effect 执行成功时所产生的值的类型。如果该类型参数是 `void`,说明这个 effect 不产生任何有用的信息;如果它是 `never`,说明这个 effect 会永远运行下去(或者直到失败)。 | | **Error** | 表示执行 effect 时可能发生的预期错误。如果该类型参数是 `never`,说明这个 effect 不会失败,因为不存在 `never` 类型的值。 | | **Requirements** | 表示 effect 执行所需的上下文数据。这些数据存储在一个名为 `Context` 的集合中。如果该类型参数是 `never`,说明这个 effect 没有任何需求,`Context` 集合是空的。 | ## 提取推断出的类型 借助工具类型 `Effect.Success`、`Effect.Error` 和 `Effect.Context`,你可以从一个 effect 中提取出相应的类型。 **示例**(提取 Success、Error 与 Context 类型) ```ts import { Effect, Context } from "effect" class SomeContext extends Context.Tag("SomeContext")() {} // Assume we have an effect that succeeds with a number, // fails with an Error, and requires SomeContext declare const program: Effect.Effect // Extract the success type, which is number type A = Effect.Effect.Success // Extract the error type, which is Error type E = Effect.Effect.Error // Extract the context type, which is SomeContext type R = Effect.Effect.Context ``` --- # 使用 Generator > 学习如何在 Effect 中使用 Generator 编写带副作用的代码,改善控制流、处理错误,并借助类似 async/await 的语法简化异步操作。 Effect 提供了一种便捷的语法,它类似于 `async`/`await`,让你可以使用 [generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) 编写带副作用的代码。 ## 理解 Effect.gen `Effect.gen` 工具借助 JavaScript 的 generator 函数,简化了编写带副作用代码的工作。这种方式能让你的代码在外观和行为上更接近传统的同步代码,从而提升可读性并改善错误管理。 **示例**(执行带折扣的交易) 让我们来看一个实用的程序,它执行一系列在应用逻辑中常见的转换操作: ```ts import { Effect } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from a // database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Simulated asynchronous task to fetch a discount rate from a // configuration file const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) // Assembling the program using a generator function const program = Effect.gen(function* () { // Retrieve the transaction amount const transactionAmount = yield* fetchTransactionAmount // Retrieve the discount rate const discountRate = yield* fetchDiscountRate // Calculate discounted amount const discountedAmount = yield* applyDiscount(transactionAmount, discountRate) // Apply service charge const finalAmount = addServiceCharge(discountedAmount) // Return the total amount after applying the charge return `Final amount to charge: ${finalAmount}` }) // Execute the program and log the result Effect.runPromise(program).then(console.log) // Output: Final amount to charge: 96 ``` 使用 `Effect.gen` 时需要遵循的关键步骤: - 把逻辑包裹在 `Effect.gen` 中 - 使用 `yield*` 处理 effect - 返回最终结果 如果你在 generator 中通过 `yield*` 处理的任何一个 effect 失败了,那么 generator 会停止执行,并以该失败退出。 ## 比较 Effect.gen 与 async/await 如果你熟悉 `async`/`await`,可能会注意到两者的代码编写流程很相似。 让我们比较一下这两种方式: ```ts import { Effect } from "effect" const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) export const program = Effect.gen(function* () { const transactionAmount = yield* fetchTransactionAmount const discountRate = yield* fetchDiscountRate const discountedAmount = yield* applyDiscount(transactionAmount, discountRate) const finalAmount = addServiceCharge(discountedAmount) return `Final amount to charge: ${finalAmount}` }) ``` ```ts const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = (total: number, discountRate: number): Promise => discountRate === 0 ? Promise.reject(new Error("Discount rate cannot be zero")) : Promise.resolve(total - (total * discountRate) / 100) const fetchTransactionAmount = Promise.resolve(100) const fetchDiscountRate = Promise.resolve(5) export const program = async function () { const transactionAmount = await fetchTransactionAmount const discountRate = await fetchDiscountRate const discountedAmount = await applyDiscount(transactionAmount, discountRate) const finalAmount = addServiceCharge(discountedAmount) return `Final amount to charge: ${finalAmount}` } ``` 需要注意的是,尽管代码看起来相似,但这两个程序并不完全相同。把它们并排比较,只是为了突出它们在写法上的相似之处。 ## 拥抱控制流 在配合 generator 使用 `Effect.gen` 时,一个显著优势是它能够在 generator 函数内部使用标准的控制流结构。这些结构包括 `if`/`else`、`for`、`while` 以及其他分支和循环机制,从而增强你在代码中表达复杂控制流逻辑的能力。 **示例**(使用控制流) ```ts import { Effect } from "effect" const calculateTax = ( amount: number, taxRate: number, ): Effect.Effect => taxRate > 0 ? Effect.succeed((amount * taxRate) / 100) : Effect.fail(new Error("Invalid tax rate")) const program = Effect.gen(function* () { let i = 1 while (true) { if (i === 10) { break // Break the loop when counter reaches 10 } else { if (i % 2 === 0) { // Calculate tax for even numbers console.log(yield* calculateTax(100, i)) } i++ continue } } }) Effect.runPromise(program) /* Output: 2 4 6 8 */ ``` ## 如何抛出错误 `Effect.gen` API 让你可以通过 yield 一个失败的 effect,把错误处理直接整合进工作流中。 你可以像下面这个示例一样,用 `Effect.fail` 引入错误。 **示例**(向流程中引入错误) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1...") const task2 = Console.log("task2...") const program = Effect.gen(function* () { // Perform some tasks yield* task1 yield* task2 // Introduce an error yield* Effect.fail("Something went wrong!") }) Effect.runPromise(program).then(console.log, console.error) /* Output: task1... task2... (FiberFailure) Error: Something went wrong! */ ``` ## 短路的作用 在使用 `Effect.gen` 时,理解它如何处理错误很重要。 这个 API 会在遇到**第一个错误**时停止执行,并返回该错误。 这对你的代码有什么影响?如果你有一系列顺序执行的操作,那么其中任何一个失败后,其余操作都不会运行,并且该错误会被返回。 简单来说,如果某个环节出了问题,程序会立刻停在那里,并把错误交给你。 如果你不想在出错时停止,可以使用 `Effect.either` 方法把错误封装进 [Either](/docs/v3/data-types/either/) 数据类型:请参阅[管理预期错误的示例](/docs/v3/error-management/expected-errors/#either)。 **示例**(在第一个错误处停止执行) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1...") const task2 = Console.log("task2...") const failure = Effect.fail("Something went wrong!") const task4 = Console.log("task4...") const program = Effect.gen(function* () { yield* task1 yield* task2 // The program stops here due to the error yield* failure // The following lines never run yield* task4 return "some result" }) Effect.runPromise(program).then(console.log, console.error) /* Output: task1... task2... (FiberFailure) Error: Something went wrong! */ ``` 尽管执行永远不会到达失败之后的代码,但除非你在失败后显式 return,否则 TypeScript 仍可能认为错误下方的代码是可到达的。 例如,考虑下面这个场景,你希望收窄某个变量的类型: **示例**(没有显式 return 时的类型收窄) ```ts import { Effect } from "effect" type User = { readonly name: string } // Imagine this function checks a database or an external service declare function getUserById(id: string): Effect.Effect function greetUser(id: string) { return Effect.gen(function* () { const user = yield* getUserById(id) if (user === undefined) { // Even though we fail here, TypeScript still thinks // 'user' might be undefined later yield* Effect.fail(`User with id ${id} not found`) } // @errors: 18048 return `Hello, ${user.name}!` }) } ``` 在这个示例中,TypeScript 仍然认为 `user` 可能是 `undefined`,因为失败之后没有显式 return。 要解决这个问题,请在调用 `Effect.fail` 之后立即显式 return: **示例**(有显式 return 时的类型收窄) ```ts import { Effect } from "effect" type User = { readonly name: string } declare function getUserById(id: string): Effect.Effect function greetUser(id: string) { return Effect.gen(function* () { const user = yield* getUserById(id) if (user === undefined) { // Explicitly return after failing return yield* Effect.fail(`User with id ${id} not found`) } // Now TypeScript knows that 'user' is not undefined return `Hello, ${user.name}!` }) } ``` ## 传递 `this` 在某些情况下,你可能需要把当前对象(`this`)的引用传入 generator 函数体。 你可以借助一个把该引用作为第一个参数接收的重载来实现: **示例**(向 Generator 传递 `this`) ```ts import { Effect } from "effect" class MyClass { readonly local = 1 compute = Effect.gen(this, function* () { const n = this.local + 1 yield* Effect.log(`Computed value: ${n}`) return n }) } Effect.runPromise(new MyClass().compute).then(console.log) /* Output: timestamp=... level=INFO fiber=#0 message="Computed value: 2" 2 */ ``` ## 适配器 你可能仍会遇到一些使用适配器的代码片段,它们通常以 `_` 或 `$` 符号来标识。 在更早的 TypeScript 版本中,generator「适配器」函数是必需的,用来确保 generator 内部能进行正确的类型推断。这个适配器用于促进 TypeScript 类型系统与 generator 函数之间的交互。 **示例**(旧代码中的适配器) ```ts import { Effect } from "effect" const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Older usage with an adapter for proper type inference const programWithAdapter = Effect.gen(function* ($) { const transactionAmount = yield* $(fetchTransactionAmount) }) // Current usage without an adapter const program = Effect.gen(function* () { const transactionAmount = yield* fetchTransactionAmount }) ``` 随着 TypeScript(v5.5+)的进步,适配器对于类型推断来说已不再必要。虽然为了向后兼容它仍保留在代码库中,但预计会在 Effect 的下一个主要版本中移除。 --- # 为什么选择 Effect? > 了解 Effect 如何利用类型系统跟踪错误、上下文与成功值,从而改变 TypeScript 编程方式,为构建可靠、易维护的应用提供切实可行的方案。 编程本身充满挑战。在构建库和应用时,我们会借助各种工具来应对复杂性,让日常工作更可控。Effect 为 TypeScript 编程带来了一种全新的思考方式。 Effect 是一个工具生态,帮助你构建更好的应用与库。与此同时,你也会更深入地理解 TypeScript 这门语言,学会利用类型系统让你的程序更可靠、更易维护。 在不使用 Effect 的「典型」TypeScript 代码中,我们写下的函数要么成功返回,要么抛出异常。例如: ```ts const divide = (a: number, b: number): number => { if (b === 0) { throw new Error("Cannot divide by zero") } return a / b } ``` 仅从类型上,我们完全看不出这个函数可能抛出异常,只能通过阅读代码来发现。当代码库里只有一个函数时,这似乎算不上什么大问题;但当你面对成百上千个函数时,这种代价就会不断累积。我们很容易忘记某个函数会抛异常,也很容易忘记去处理它。 通常,我们会选择「最省事」的做法:把函数包进 `try/catch` 块里。这是防止程序崩溃的良好第一步,但它并没有让你更容易管理或理解复杂的应用与库。我们可以做得更好。 TypeScript 中最重要的工具之一就是编译器。它是抵御 bug、领域错误(domain error)以及整体复杂性的第一道防线。 ## Effect 模式 Effect 是一个包含众多工具的庞大生态,但如果必须把它浓缩成唯一的核心思想,那就是下面这句话: Effect 最独特的关键洞见在于:我们可以用类型系统来跟踪 **errors** 和 **context**,而不仅仅是像上面的 divide 示例那样只跟踪 **success** 值。 下面是上面那个 divide 函数改用 Effect 模式后的写法: ```ts import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) ``` 采用这种方式后,函数不再抛出异常。错误被当作值来处理,可以像成功值一样被传递下去。类型签名也清晰地说明了: - 函数返回的成功值是什么(`number`)。 - 可能发生什么错误(`Error`)。 - 需要哪些额外的上下文或依赖(`never` 表示没有)。 ```text ┌─── Produces a value of type number │ ┌─── Fails with an Error │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` 此外,跟踪上下文让你无需把所有东西都当作参数传入,就能为函数提供额外信息。例如,你可以在测试中用 mock 替换线上外部服务的实现,而无需改动任何核心业务逻辑。 ## 不要重复造轮子 TypeScript 的应用代码常常在反复解决同样的问题。与外部服务、文件系统、数据库等打交道,是所有应用开发者都会遇到的常见问题。Effect 提供了丰富的库生态,为其中许多问题给出了标准化的解决方案。你既可以用这些库来构建自己的应用,也可以用它们来构建你自己的库。 错误处理、调试、tracing、async/Promise、重试、流式处理(streaming)、并发、缓存、资源管理等等挑战,在 Effect 中都变得可管理。你不必重新发明这些问题的解决方案,也不必安装成堆的依赖。Effect 在一个统一的体系下,解决了那些通常需要安装许多不同依赖、使用不同 API 才能解决的问题。 ## 解决实际问题 Effect 深受 Scala、Haskell 等其他语言中优秀成果的启发。但同样重要的是要理解:Effect 的目标是成为一个实用的工具箱,它不遗余力地解决开发者用 TypeScript 构建应用与库时每天都会遇到的真实问题。 ## 享受构建与学习 学习 Effect 是一件很有乐趣的事。Effect 生态中的许多开发者既用 Effect 解决日常工作中的真实问题,也在试验各种前沿想法,推动 TypeScript 成为它所能成为的最实用的语言。 你不需要一次性用上 Effect 的所有方面,可以先从生态中最契合你当前问题的部分入手。Effect 是一个工具箱,你可以按需挑选最适合自己场景的部分。不过,随着代码库中越来越多的部分用上 Effect,你大概会发现自己想要用上生态里更多的东西! Effect 的概念对你来说可能是全新的,一开始未必能完全理解,这完全正常。慢慢阅读文档,努力理解核心概念——当你之后接触 Effect 生态中更高级的工具时,这些投入会得到丰厚的回报。Effect 社区始终乐于帮助大家学习与成长。欢迎加入[中文社区微信群](/community/),或在官方的 [GitHub 仓库](https://github.com/Effect-TS) 上参与讨论!我们欢迎反馈与贡献,也一直在寻找改进 Effect 的方法。 --- # 面向 Effect 用户的 Micro > 了解 Micro 模块:它是 Effect 的轻量替代品,能在保持兼容性与功能的前提下为 TypeScript 应用减小打包体积。 Micro 模块被设计为标准 Effect 模块的轻量替代品,专为减小打包体积会带来好处的场景而生。 该模块是独立的,不包含 [Layer](/docs/v3/requirements-management/layers/)、[Ref](/docs/v3/state-management/ref/)、[Queue](/docs/v3/concurrency/queue/) 和 [Deferred](/docs/v3/concurrency/deferred/) 等更复杂的功能。这样的功能取舍使 Micro 尤其适合那些希望使用 Effect 功能、同时把打包体积保持在最低限度的库,特别是想要提供基于 `Promise` 的 API 的库。 Micro 也支持这样的用法:客户端应用使用 Micro,而服务端使用 Effect 的完整功能集,从而在各类应用组件之间同时保持兼容性与逻辑一致性。 集成 Micro 只会给你的打包产物增加极小的体积,最小为 **5kb gzipped**,具体可能随你使用的功能而增加。 ## 导入 Micro Micro 是 Effect 库的一部分,可以像任何其他模块一样导入: ```ts import { Micro } from "effect" ``` 你也可以像这样使用命名空间导入: ```ts import * as Micro from "effect/Micro" ``` 这两种导入方式都能让你使用 `Micro` 模块提供的功能。 不过有一个重要的考量是 **tree shaking**(摇树优化),它指的是在打包应用时剔除未使用代码的过程。 当打包器不支持深度作用域分析时,具名导入可能会引发 tree shaking 问题。 以下是一些支持深度作用域分析的打包器,因此它们使用具名导入不会有问题: - Rolldown - Rollup - Webpack 5+ ## 主要类型 ### Micro `Micro` 类型使用三个类型参数: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Micro ``` 这三个参数与 `Effect` 类型的类型参数一一对应。 ### MicroExit `MicroExit` 类型是 [Exit](/docs/v3/data-types/exit/) 类型的精简版,用于捕获 `Micro` 计算的结果。 它要么成功,包含一个类型为 `A` 的值;要么失败,包含一个被 `MicroCause` 包装的、类型为 `E` 的错误。 ```ts type MicroExit = MicroExit.Success | MicroExit.Failure ``` ### MicroCause `MicroCause` 类型是 [Cause](/docs/v3/data-types/cause/) 类型的精简版。 与 `Cause` 是多个类型的联合类似,`MicroCause` 有三种形式: ```ts type MicroCause = Die | Fail | Interrupt ``` | 变体 | 说明 | | ----------- | ------------------------------------------------------------------------------------------- | | `Die` | 表示系统逻辑中未曾预料到的一种无法预见的 defect。 | | `Fail` | 涵盖应用能够识别、并且通常会处理的预期错误。 | | `Interrupt` | 表示一个被有意停止的操作。 | ### MicroSchedule `MicroSchedule` 类型是 [Schedule](/docs/v3/scheduling/introduction/) 类型的精简版。 ```ts type MicroSchedule = (attempt: number, elapsed: number) => Option ``` 它表示一个可用于计算重复之间延迟的函数。 该函数接收当前尝试次数和自首次尝试以来经过的时间,并返回下一次尝试的延迟。 如果函数返回 `None`,重复就会停止。 ## 如何使用本指南 下面你会看到 `Effect` 与 `Micro` 功能之间的一系列对比。每个表格都列出一项 `Effect` 功能及其在 `Micro` 中的对应项。所用图标含义如下: - ⚠️:该功能在 `Micro` 中可用,但与 `Effect` 存在一些差异。 - ❌:该功能在 `Effect` 中不可用。 ## 创建 Effect | Effect | Micro | ⚠️ | | ---------------------- | -------------------- | ------------------------------------ | | `Effect.try` | `Micro.try` | 需要一个 `try` 块 | | `Effect.tryPromise` | `Micro.tryPromise` | 需要一个 `try` 块 | | `Effect.sleep` | `Micro.sleep` | 只处理毫秒 | | `Effect.failCause` | `Micro.failWith` | 使用 `MicroCause` 而不是 `Cause` | | `Effect.failCauseSync` | `Micro.failWithSync` | 使用 `MicroCause` 而不是 `Cause` | | ❌ | `Micro.make` | | | ❌ | `Micro.fromOption` | | | ❌ | `Micro.fromEither` | | ## 运行 Effect | Effect | Micro | ⚠️ | | ----------------------- | ---------------------- | -------------------------------------------------- | | `Effect.runSyncExit` | `Micro.runSyncExit` | 返回 `MicroExit` 而不是 `Exit` | | `Effect.runPromiseExit` | `Micro.runPromiseExit` | 返回 `MicroExit` 而不是 `Exit` | | `Effect.runFork` | `Micro.runFork` | 返回 `MicroFiber` 而不是 `RuntimeFiber` | ### runSyncExit `Micro.runSyncExit` 函数用于同步执行一个 Effect,也就是说它会立即运行,并以 [MicroExit](#microexit) 的形式返回结果。 **示例**(以 MicroExit 形式处理结果) ```ts import { Micro } from "effect" const result1 = Micro.runSyncExit(Micro.succeed(1)) console.log(result1) /* Output: { "_id": "MicroExit", "_tag": "Success", "value": 1 } */ const result2 = Micro.runSyncExit(Micro.fail("my error")) console.log(result2) /* Output: { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "MicroCause.Fail", "error": "my error" } } */ ``` ### runPromiseExit `Micro.runPromiseExit` 函数用于执行一个 Effect,并以 `Promise` 的形式获取结果,该 Promise 会解析为一个 [MicroExit](#microexit)。 **示例**(以 MicroExit 形式处理结果) ```ts import { Micro } from "effect" Micro.runPromiseExit(Micro.succeed(1)).then(console.log) /* Output: { "_id": "MicroExit", "_tag": "Success", "value": 1 } */ Micro.runPromiseExit(Micro.fail("my error")).then(console.log) /* Output: { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "MicroCause.Fail", "error": "my error" } } */ ``` ### runFork `Micro.runFork` 函数执行该 effect,并返回一个 `MicroFiber`,它可以被 await、join 或 abort。 你可以使用 `addObserver` 方法添加一个观察者来监听结果。 **示例**(观察一个异步 Effect) ```ts import { Micro } from "effect" // ┌─── MicroFiber // ▼ const fiber = Micro.succeed(42).pipe(Micro.delay(1000), Micro.runFork) // Attach an observer to log the result when the effect completes fiber.addObserver((result) => { console.log(result) }) console.log("observing...") /* Output: observing... { "_id": "MicroExit", "_tag": "Success", "value": 42 } */ ``` ## 构建管道 | Effect | Micro | ⚠️ | | ------------------ | ----------------- | ------------------------------------------------------- | | `Effect.andThen` | `Micro.andThen` | 不接受 `Promise` 或 `() => Promise` 作为参数 | | `Effect.tap` | `Micro.tap` | 不接受 `() => Promise` 作为参数 | | `Effect.all` | `Micro.all` | 没有 `batching` 和 `mode` 选项 | | `Effect.forEach` | `Micro.forEach` | 没有 `batching` 选项 | | `Effect.filter` | `Micro.filter` | 没有 `batching` 选项 | | `Effect.filterMap` | `Micro.filterMap` | 该过滤器本身是 effectful 的 | ## 预期错误 | Effect | Micro | ⚠️ | | ------------- | ------------ | ------------------------------------------ | | `Effect.exit` | `Micro.exit` | 返回 `MicroExit` 而不是 `Exit` | ## 意外错误 | Effect | Micro | | | ------ | -------------------- | --- | | ❌ | `Micro.catchCauseIf` | | ## 超时 | Effect | Micro | | | ------ | --------------------- | --- | | ❌ | `Micro.timeoutOrElse` | | ## 依赖管理 在使用 `Micro.gen` 时若要访问某个服务,你需要用 `Micro.service` 函数包装服务标签: **示例**(在 `Micro.gen` 中访问服务) ```ts import { Micro, Context } from "effect" class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Micro.Micro } >() {} const program = Micro.gen(function* () { // const random = yield* Random // this doesn't work const random = yield* Micro.service(Random) const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) const runnable = Micro.provideService(program, Random, { next: Micro.sync(() => Math.random()), }) Micro.runPromise(runnable) /* Example Output: random number: 0.8241872233134417 */ ``` ## Scope | Effect | Micro | ⚠️ | | ------------ | ----------------- | ------------------------------------------- | | `Scope` | `MicroScope` | 返回 `MicroScope` 而不是 `Scope` | | `Scope.make` | `Micro.scopeMake` | 返回 `MicroScope` 而不是 `Scope` | ## 重试 | Effect | Micro | ⚠️ | | -------------- | ------------- | ------------------- | | `Effect.retry` | `Micro.retry` | `options` 不同 | ## 重复执行 | Effect | Micro | ⚠️ | | --------------- | ------------------ | ------------------- | | `Effect.repeat` | `Micro.repeat` | `options` 不同 | | ❌ | `Micro.repeatExit` | | ## 超时 | Effect | Micro | | | ------ | --------------------- | --- | | ❌ | `Micro.timeoutOrElse` | | ## 沙箱化 | Effect | Micro | ⚠️ | | ---------------- | --------------- | ----------------------------------------------- | | `Effect.sandbox` | `Micro.sandbox` | 返回 `MicroCause` 而不是 `Cause` | ## 错误通道操作 | Effect | Micro | ⚠️ | | ---------------------- | ------------------------ | ------------------------------------- | | ❌ | `Micro.filterOrFailWith` | | | `Effect.tapErrorCause` | `Micro.tapErrorCause` | `MicroCause` 而不是 `Cause` | | ❌ | `Micro.tapCauseIf` | | | `Effect.tapDefect` | `Micro.tapDefect` | `unknown` 而不是 `Cause` | ## 依赖管理 | Effect | Micro | ⚠️ | | ---------------- | ---------------------- | ---------------------- | | `Effect.provide` | `Micro.provideContext` | 只处理 `Context` | | ❌ | `Micro.provideScope` | | | ❌ | `Micro.service` | | ## 作用域、资源与终结 | Effect | Micro | ⚠️ | | -------------------------- | ------------------------- | ---------------------------------------- | | `Effect.addFinalizer` | `Micro.addFinalizer` | `MicroExit` 而不是 `Exit`,且没有 `R` | | `Effect.acquireRelease` | `Micro.acquireRelease` | `MicroExit` 而不是 `Exit` | | `Effect.acquireUseRelease` | `Micro.acquireUseRelease` | `MicroExit` 而不是 `Exit` | | `Effect.onExit` | `Micro.onExit` | `MicroExit` 而不是 `Exit` | | `Effect.onError` | `Micro.onError` | 使用 `MicroCause` 而不是 `Cause` | | ❌ | `Micro.onExitIf` | | ## 并发 | Effect | Micro | ⚠️ | | ------------------- | ------------------ | -------------------------------------- | | `Effect.fork` | `Micro.fork` | `MicroFiber` 而不是 `RuntimeFiber` | | `Effect.forkDaemon` | `Micro.forkDaemon` | `MicroFiber` 而不是 `RuntimeFiber` | | `Effect.forkIn` | `Micro.forkIn` | `MicroFiber` 而不是 `RuntimeFiber` | | `Effect.forkScoped` | `Micro.forkScoped` | `MicroFiber` 而不是 `RuntimeFiber` | --- # Micro 入门 > 了解如何开始使用 Micro 模块——它是 Effect 的轻量级替代方案,可在保持 TypeScript 应用核心功能的同时减小打包体积。 Micro 模块被设计为标准 Effect 模块的轻量级替代方案,适用于那些减小打包体积能带来好处的场景。 该模块是独立的,不包含 [Layer](/docs/v3/requirements-management/layers/)、[Ref](/docs/v3/state-management/ref/)、[Queue](/docs/v3/concurrency/queue/) 和 [Deferred](/docs/v3/concurrency/deferred/) 等更复杂的功能。这样的功能集合使 Micro 特别适合那些希望利用 Effect 功能、同时把打包体积保持在最小的库,尤其是那些想提供基于 `Promise` 的 API 的库。 Micro 也支持这样的使用场景:客户端应用使用 Micro,而服务端采用完整的 Effect 功能集,从而在各个应用组件之间同时保持兼容性与逻辑一致性。 集成 Micro 只会给你的打包产物增加极小的体积,起始为 **5kb(gzip 压缩后)**,具体体积可能会随你使用的功能而增加。 ## 导入 Micro 在开始之前,请确保你已经完成以下设置: 在你的项目中安装 `effect` 库。如果尚未安装,可以用 npm 通过以下命令添加: ```sh npm install effect ``` ```sh pnpm add effect ``` ```sh yarn add effect ``` ```sh bun add effect ``` ```sh deno add npm:effect ``` Micro 是 Effect 库的一部分,可以像任何其他模块一样导入: ```ts import { Micro } from "effect" ``` 你也可以像这样用命名空间导入: ```ts import * as Micro from "effect/Micro" ``` 这两种导入形式都能让你访问 `Micro` 模块提供的功能。 不过有一个重要的考量是 **tree shaking**(摇树优化),它指的是在应用打包过程中剔除未使用代码的过程。 当打包工具不支持深层作用域分析时,具名导入可能会引发 tree shaking 问题。 以下是一些支持深层作用域分析、因而不会因具名导入而出问题的打包工具: - Rolldown - Rollup - Webpack 5+ ## Micro 类型 下面是 `Micro` 的一般形式: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Micro ``` 它与 `Effect` 类型的参数一一对应: | 参数 | 说明 | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Success** | 表示 effect 执行成功时可能得到的值的类型。如果该类型参数是 `void`,说明 effect 不会产生有用的信息;如果它是 `never`,说明 effect 会一直运行(或直到失败)。 | | **Error** | 表示执行 effect 时可能发生的预期错误。如果该类型参数是 `never`,说明 effect 不会失败,因为不存在 `never` 类型的值。 | | **Requirements** | 表示 effect 执行时所需的上下文数据。这些数据保存在名为 `Context` 的集合中。如果该类型参数是 `never`,说明 effect 没有任何需求,`Context` 集合为空。 | ## MicroExit 类型 `MicroExit` 类型用于表示一次 `Micro` 计算的结果。 它要么成功,包含一个 `A` 类型的值;要么失败,包含一个被包裹在 `MicroCause` 中的 `E` 类型错误。 ```ts type MicroExit = MicroExit.Success | MicroExit.Failure ``` ## MicroCause 类型 `MicroCause` 类型描述了 effect 可能失败的各种原因。 `MicroCause` 有三种形式: ```ts type MicroCause = Die | Fail | Interrupt ``` | 变体 | 说明 | | ----------- | ------------------------------------------------------------------------------------------- | | `Die` | 表示一个未被预见的 defect,它不在系统逻辑的计划之内。 | | `Fail` | 涵盖已被识别、并且通常在应用内处理的预期错误。 | | `Interrupt` | 表示一个被有意停止的操作。 | ## 用 Micro 包装基于 Promise 的 API 本指南展示如何使用 Effect 中的 `Micro` 库包装一个基于 `Promise` 的 API。我们将创建一个与假想的天气预报 API 交互的简单示例,用 Micro 来处理结构化的错误处理和执行流程。 1. **创建一个基于 Promise 的 API 函数** 首先定义一个基本的基于 Promise 的函数,用来模拟从外部服务获取天气数据。 ```ts // Simulate fetching weather data function fetchWeather(city: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (city === "London") { resolve("Sunny") } else { reject(new Error("Weather data not found for this location")) } }, 1_000) }) } ``` 2. **用 Micro 包装这个 Promise** 现在,用 Micro 包装 `fetchWeather` 函数,把这个 `Promise` 转换为一个 Micro effect,以便同时管理成功与失败的情形。 ```ts import { Micro } from "effect" // Simulate fetching weather data function fetchWeather(city: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (city === "London") { resolve("Sunny") } else { reject(new Error("Weather data not found for this location")) } }, 1_000) }) } function getWeather(city: string) { return Micro.promise(() => fetchWeather(city)) } ``` 在这里,`Micro.promise` 把 `fetchWeather` 返回的 `Promise` 转换为一个 `Micro` effect。 3. **运行 Micro Effect** 函数包装完成后,执行这个 Micro effect 并处理结果。 **示例**(执行 Micro Effect) ```ts import { Micro } from "effect" // Simulate fetching weather data function fetchWeather(city: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (city === "London") { resolve("Sunny") } else { reject(new Error("Weather data not found for this location")) } }, 1_000) }) } function getWeather(city: string) { return Micro.promise(() => fetchWeather(city)) } // ┌─── Micro // ▼ const weatherEffect = getWeather("London") Micro.runPromise(weatherEffect) .then((data) => console.log(`The weather in London is: ${data}`)) .catch((error) => console.error(`Failed to fetch weather data: ${error.message}`), ) /* Output: The weather in London is: Sunny */ ``` 在上面的示例中,`Micro.runPromise` 用于执行 `weatherEffect`,把它转换回 `Promise`,从而可以用熟悉的异步处理方式来管理。 如果需要关于 effect 退出状态的更详细信息,可以使用 `Micro.runPromiseExit`: **示例**(检查退出状态) ```ts import { Micro } from "effect" // Simulate fetching weather data function fetchWeather(city: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (city === "London") { resolve("Sunny") } else { reject(new Error("Weather data not found for this location")) } }, 1_000) }) } function getWeather(city: string) { return Micro.promise(() => fetchWeather(city)) } // ┌─── Micro // ▼ const weatherEffect = getWeather("London") Micro.runPromiseExit(weatherEffect).then( // ┌─── MicroExit // ▼ (exit) => console.log(exit), ) /* Output: { "_id": "MicroExit", "_tag": "Success", "value": "Sunny" } */ ``` 4. **添加错误处理** 为了进一步增强这个函数,你可能想以不同方式处理特定的错误。 Micro 提供了 `Micro.tryPromise` 这类函数,以便优雅地处理预期内的错误。 **示例**(处理特定错误) ```ts import { Micro } from "effect" // Simulate fetching weather data function fetchWeather(city: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (city === "London") { resolve("Sunny") } else { reject(new Error("Weather data not found for this location")) } }, 1_000) }) } class WeatherError { readonly _tag = "WeatherError" constructor(readonly message: string) {} } function getWeather(city: string) { return Micro.tryPromise({ try: () => fetchWeather(city), // remap the error catch: (error) => new WeatherError(String(error)), }) } // ┌─── Micro // ▼ const weatherEffect = getWeather("Paris") Micro.runPromise(weatherEffect) .then((data) => console.log(`The weather in London is: ${data}`)) .catch((error) => console.error(`Failed to fetch weather data: ${error}`)) /* Output: Failed to fetch weather data: MicroCause.Fail: {"_tag":"WeatherError","message":"Error: Weather data not found for this location"} */ ``` ## 预期错误 这类错误也被称为*失败*(failure)、*类型化错误*(typed error)或*可恢复错误*(recoverable error),它们是开发者在正常程序执行中预期会发生的错误。 它们的作用类似于受检异常(checked exception),并在定义程序的领域与控制流方面发挥作用。 预期错误会被 `Micro` 数据类型在「Error」通道中**于类型层面追踪**: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Micro ``` ### either `Micro.either` 函数会把一个 `Micro` 转换为一个 effect,它把潜在的失败和成功都封装在 [Either](/docs/v3/data-types/either/) 数据类型中: ```ts Micro -> Micro, never, R> ``` 这意味着,如果你有一个如下类型的 effect: ```ts Micro ``` 然后对它调用 `Micro.either`,类型就变成: ```ts Micro, never, never> ``` 得到的 effect 不会失败,因为潜在的失败现在由 `Either` 的 `Left` 类型来表示。 返回的 `Micro` 的错误类型被指定为 `never`,确认该 effect 在结构上不会失败。 通过 yield 一个 `Either`,我们就能对这个类型进行「模式匹配」,从而在生成器函数内部同时处理失败和成功两种情况。 **示例**(使用 `Micro.either` 处理错误) ```ts import { Micro, Either } from "effect" class HttpError { readonly _tag = "HttpError" } class ValidationError { readonly _tag = "ValidationError" } // ┌─── Micro // ▼ const program = Micro.gen(function* () { // Simulate http and validation errors if (Math.random() > 0.5) yield* Micro.fail(new HttpError()) if (Math.random() > 0.5) yield* Micro.fail(new ValidationError()) return "some result" }) // ┌─── Micro // ▼ const recovered = Micro.gen(function* () { // ┌─── Either // ▼ const failureOrSuccess = yield* Micro.either(program) return Either.match(failureOrSuccess, { // Failure case onLeft: (error) => `Recovering from ${error._tag}`, // Success case onRight: (value) => `Result is: ${value}`, }) }) Micro.runPromiseExit(recovered).then(console.log) /* Example Output: { "_id": "MicroExit", "_tag": "Success", "value": "Recovering from ValidationError" } */ ``` 可以看到,由于所有错误都被处理了,最终得到的 effect `recovered` 的错误类型是 `never`: ```ts const recovered: Micro ``` ### catchAll `Micro.catchAll` 函数允许你捕获程序中发生的任何错误,并提供一个回退。 **示例**(用 `Micro.catchAll` 捕获所有错误) ```ts import { Micro } from "effect" class HttpError { readonly _tag = "HttpError" } class ValidationError { readonly _tag = "ValidationError" } // ┌─── Micro // ▼ const program = Micro.gen(function* () { // Simulate http and validation errors if (Math.random() > 0.5) yield* Micro.fail(new HttpError()) if (Math.random() > 0.5) yield* Micro.fail(new ValidationError()) return "some result" }) // ┌─── Micro // ▼ const recovered = program.pipe( Micro.catchAll((error) => Micro.succeed(`Recovering from ${error._tag}`)), ) Micro.runPromiseExit(recovered).then(console.log) /* Example Output: { "_id": "MicroExit", "_tag": "Success", "value": "Recovering from HttpError" } */ ``` 我们可以看到,程序错误通道中的类型已经变为 `never`: ```ts const recovered: Micro ``` 表明所有错误都已被处理。 ### catchTag 如果程序中的错误**全都带有标签**——也就是用一个充当判别式的 `_tag` 字段来区分——那么就可以使用 `Effect.catchTag` 函数来精确地捕获并处理特定错误。 **示例**(使用 `Micro.catchTag` 按标签处理错误) ```ts import { Micro } from "effect" class HttpError { readonly _tag = "HttpError" } class ValidationError { readonly _tag = "ValidationError" } // ┌─── Micro // ▼ const program = Micro.gen(function* () { // Simulate http and validation errors if (Math.random() > 0.5) yield* Micro.fail(new HttpError()) if (Math.random() > 0.5) yield* Micro.fail(new ValidationError()) return "Success" }) // ┌─── Micro // ▼ const recovered = program.pipe( Micro.catchTag("HttpError", (_HttpError) => Micro.succeed("Recovering from HttpError"), ), ) Micro.runPromiseExit(recovered).then(console.log) /* Example Output: { "_id": "MicroExit", "_tag": "Success", "value": "Recovering from HttpError" } */ ``` 在上面的示例中,`Micro.catchTag` 函数让我们可以专门处理 `HttpError`。 如果程序执行期间出现 `HttpError`,就会调用所提供的错误处理函数, 随后程序会按照该处理函数中指定的恢复逻辑继续执行。 可以看到,我们这个程序错误通道中的类型已经变为只显示 `ValidationError`: ```ts const recovered: Micro ``` 这表明 `HttpError` 已经被处理了。 ## 意外错误 意外错误(unexpected errors)也被称为 _defect_、_无类型错误_(untyped errors)或 _不可恢复错误_(unrecoverable errors), 它们是开发者在程序正常执行期间不预期会发生的错误。 预期错误被视为程序领域模型与控制流的一部分,与它们不同, 意外错误更像是未受检查的异常(unchecked exceptions),落在程序预期行为之外。 由于这些错误是意料之外的,Effect **不会在类型层面跟踪**它们。 不过 Effect 运行时确实会跟踪这些错误,并提供了若干方法来帮助从意外错误中恢复。 ### die `Micro.die` 函数返回一个会抛出指定错误的 effect。当代码中检测到 defect(一种严重且意外的错误)时,该函数可用于终止程序。 **示例**(使用 `Effect.die` 在除零时终止程序) ```ts import { Micro } from "effect" const divide = (a: number, b: number): Micro.Micro => b === 0 ? Micro.die(new Error("Cannot divide by zero")) : Micro.succeed(a / b) Micro.runPromise(divide(1, 0)) /* throws: Die [(MicroCause.Die) Error]: Cannot divide by zero ...stack trace... */ ``` ### orDie `Micro.orDie` 函数会把 effect 的失败转换为程序终止,同时从 effect 的类型中移除该错误。当你遇到不打算处理或恢复的失败时,这个函数很有用。 **示例**(使用 `Micro.orDie` 将失败转换为 defect) ```ts import { Micro } from "effect" const divide = (a: number, b: number): Micro.Micro => b === 0 ? Micro.fail(new Error("Cannot divide by zero")) : Micro.succeed(a / b) // ┌─── Micro // ▼ const program = Micro.orDie(divide(1, 0)) Micro.runPromise(program) /* throws: Die [(MicroCause.Die) Error]: Cannot divide by zero ...stack trace... */ ``` ### catchAllDefect `Micro.catchAllDefect` 函数允许你借助提供的函数从所有 defect 中恢复。 **示例**(使用 `Micro.catchAllDefect` 处理所有 defect) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // Simulating a runtime error const task = Micro.die("Boom!") const program = Micro.catchAllDefect(task, (defect) => log(`Unknown defect caught: ${defect}`), ) // We get a Right because we caught all defects Micro.runPromiseExit(program).then((exit) => console.log(exit)) /* Output: Unknown defect caught: Boom! { "_id": "MicroExit", "_tag": "Success" } */ ``` 需要理解的是,`Micro.catchAllDefect` 只能处理 defect,不能处理预期错误(例如由 `Micro.fail` 引起的错误)或执行中断(例如使用 `Micro.interrupt` 时)。 defect 指的是无法事先预料的错误,也没有可靠的方式来应对它。一般来说,建议让 defect 直接导致应用崩溃,因为它们通常表明存在需要解决的严重问题。 不过,在某些特定场景下,比如处理动态加载的插件时,可能有必要采用受控的恢复方式。例如,如果我们的应用支持在运行时加载插件,而某个插件内部出现了 defect,我们可以选择记录该 defect,然后只重新加载受影响的插件,而不是让整个应用崩溃。这样可以让应用运行得更稳健、更不中断。 ## 回退 ### orElseSucceed `Effect.orElseSucceed` 函数会用成功值替换原来的失败,确保该 effect 不会失败: **示例**(使用 `Micro.orElseSucceed` 用成功值替换失败) ```ts import { Micro } from "effect" const validate = (age: number): Micro.Micro => { if (age < 0) { return Micro.fail("NegativeAgeError") } else if (age < 18) { return Micro.fail("IllegalAgeError") } else { return Micro.succeed(age) } } const program = Micro.orElseSucceed(validate(-1), () => 18) console.log(Micro.runSyncExit(program)) /* Output: { "_id": "MicroExit", "_tag": "Success", "value": 18 } */ ``` ## 匹配 ### match `Micro.match` 函数让你在不产生副作用的情况下同时处理成功和失败两种情况。你只需为每种情况提供一个处理函数。 **示例**(同时处理成功与失败两种情况) ```ts import { Micro } from "effect" const success: Micro.Micro = Micro.succeed(42) const program1 = Micro.match(success, { onFailure: (error) => `failure: ${error.message}`, onSuccess: (value) => `success: ${value}`, }) // Run and log the result of the successful effect Micro.runPromise(program1).then(console.log) // Output: "success: 42" const failure: Micro.Micro = Micro.fail(new Error("Uh oh!")) const program2 = Micro.match(failure, { onFailure: (error) => `failure: ${error.message}`, onSuccess: (value) => `success: ${value}`, }) // Run and log the result of the failed effect Micro.runPromise(program2).then(console.log) // Output: "failure: Uh oh!" ``` ### matchEffect `Micro.matchEffect` 函数与 `Micro.match` 类似,同样允许你处理成功和失败两种情况,但它还允许你在这些处理函数中执行额外的副作用。 **示例**(带副作用地处理成功与失败) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) const success: Micro.Micro = Micro.succeed(42) const failure: Micro.Micro = Micro.fail(new Error("Uh oh!")) const program1 = Micro.matchEffect(success, { onFailure: (error) => Micro.succeed(`failure: ${error.message}`).pipe(Micro.tap(log)), onSuccess: (value) => Micro.succeed(`success: ${value}`).pipe(Micro.tap(log)), }) Micro.runSync(program1) /* Output: success: 42 */ const program2 = Micro.matchEffect(failure, { onFailure: (error) => Micro.succeed(`failure: ${error.message}`).pipe(Micro.tap(log)), onSuccess: (value) => Micro.succeed(`success: ${value}`).pipe(Micro.tap(log)), }) Micro.runSync(program2) /* Output: failure: Uh oh! */ ``` ### matchCause / matchCauseEffect `Micro.matchCause` 和 `Micro.matchCauseEffect` 函数让你可以访问 fiber 内完整的失败原因(cause),从而更精确地处理失败。这样就可以区分各种失败类型并做出相应的响应。 **示例**(使用 `Micro.matchCauseEffect` 处理不同的失败原因) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) const task: Micro.Micro = Micro.die("Uh oh!") const program = Micro.matchCauseEffect(task, { onFailure: (cause) => { switch (cause._tag) { case "Fail": // Handle standard failure with a logged message return log(`Fail: ${cause.error.message}`) case "Die": // Handle defects (unexpected errors) by logging the defect return log(`Die: ${cause.defect}`) case "Interrupt": // Handle interruption return log("Interrupt") } }, onSuccess: (value) => // Log success if the task completes successfully log(`succeeded with ${value} value`), }) Micro.runSync(program) // Output: "Die: Uh oh!" ``` ## 重试 ### retry `Micro.retry` 函数允许你按照定义好的策略重试失败的 effect。 **示例**(使用固定延迟重试) ```ts import { Micro } from "effect" let count = 0 // Simulates an effect with possible failures const effect = Micro.async((resume) => { if (count <= 2) { count++ console.log("failure") resume(Micro.fail(new Error())) } else { console.log("success") resume(Micro.succeed("yay!")) } }) // Define a repetition policy using a spaced delay between retries const policy = Micro.scheduleSpaced(100) const repeated = Micro.retry(effect, { schedule: policy }) Micro.runPromise(repeated).then(console.log) /* Output: failure failure failure success yay! */ ``` ## 超时 当某个操作未能在指定时长内完成时,`Micro.timeout` 的行为取决于该操作是否「不可中断」。 1. **可中断的操作**:如果该操作可以被中断,那么在达到超时阈值时会立即终止它,并产生 `TimeoutException`。 ```ts import { Micro } from "effect" const task = Micro.gen(function* () { console.log("Start processing...") yield* Micro.sleep(2_000) // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const timedEffect = task.pipe(Micro.timeout(1_000)) Micro.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "(MicroCause.Fail) TimeoutException", "error": { "_tag": "TimeoutException" } } } */ ``` 2. **不可中断的操作**:如果该操作不可中断,它会继续执行直至完成,之后才会判定 `TimeoutException`。 ```ts import { Micro } from "effect" const task = Micro.gen(function* () { console.log("Start processing...") yield* Micro.sleep(2_000) // Simulates a delay in processing console.log("Processing complete.") return "Result" }) const timedEffect = task.pipe(Micro.uninterruptible, Micro.timeout(1_000)) // Outputs a TimeoutException after the task completes, // because the task is uninterruptible Micro.runPromiseExit(timedEffect).then(console.log) /* Output: Start processing... Processing complete. { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "(MicroCause.Fail) TimeoutException", "error": { "_tag": "TimeoutException" } } } */ ``` ## 沙箱 `Micro.sandbox` 函数允许你把 effect 中所有潜在的错误原因都封装起来。它会暴露 effect 的完整 cause,无论该 cause 来自失败、defect 还是中断。 简单来说,它接收一个 effect `Micro`,并将其转换为 effect `Micro, R>`,其中错误通道现在包含该错误的详细原因。 ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // ┌─── Micro // ▼ const task = Micro.fail(new Error("Oh uh!")).pipe(Micro.as("primary result")) // ┌─── Effect, never> // ▼ const sandboxed = Micro.sandbox(task) const program = sandboxed.pipe( Micro.catchTag("Fail", (cause) => log(`Caught a defect: ${cause.error}`).pipe( Micro.as("fallback result on expected error"), ), ), Micro.catchTag("Interrupt", () => log(`Caught a defect`).pipe( Micro.as("fallback result on fiber interruption"), ), ), Micro.catchTag("Die", (cause) => log(`Caught a defect: ${cause.defect}`).pipe( Micro.as("fallback result on unexpected error"), ), ), ) Micro.runPromise(program).then(console.log) /* Output: Caught a defect: Error: Oh uh! fallback result on expected error */ ``` ## 检查错误 ### tapError 执行一个带 effect 的操作,以检查某个 effect 的失败,而不改变该 effect。 **示例**(检查错误) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // Simulate a task that fails with an error const task: Micro.Micro = Micro.fail("NetworkError") // Use tapError to log the error message when the task fails const tapping = Micro.tapError(task, (error) => log(`expected error: ${error}`)) Micro.runFork(tapping) /* Output: expected error: NetworkError */ ``` ### tapErrorCause 该函数会检查错误的完整 `Cause`,包括失败与 defect。 **示例**(检查错误 Cause) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // Create a task that fails with a NetworkError const task1: Micro.Micro = Micro.fail("NetworkError") const tapping1 = Micro.tapErrorCause(task1, (cause) => log(`error cause: ${cause}`), ) Micro.runFork(tapping1) /* Output: error cause: MicroCause.Fail: NetworkError */ // Simulate a severe failure in the system const task2: Micro.Micro = Micro.die("Something went wrong") const tapping2 = Micro.tapErrorCause(task2, (cause) => log(`error cause: ${cause}`), ) Micro.runFork(tapping2) /* Output: error cause: MicroCause.Die: Something went wrong */ ``` ### tapDefect 专门检查 effect 中不可恢复的失败或 defect(即一个或多个 [Die](/docs/v3/data-types/cause/#die) 原因)。 **示例**(检查 Defect) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // Simulate a task that fails with a recoverable error const task1: Micro.Micro = Micro.fail("NetworkError") // tapDefect won't log anything because NetworkError is not a defect const tapping1 = Micro.tapDefect(task1, (cause) => log(`defect: ${cause}`)) Micro.runFork(tapping1) /* No Output */ // Simulate a severe failure in the system const task2: Micro.Micro = Micro.die("Something went wrong") // Log the defect using tapDefect const tapping2 = Micro.tapDefect(task2, (cause) => log(`defect: ${cause}`)) Micro.runFork(tapping2) /* Output: defect: Something went wrong */ ``` ## 可 yield 的错误 可 yield 的错误是一类特殊的错误,它们可以在使用 `Micro.gen` 的 generator 函数中直接 yield。 这类错误让你可以直观地处理它们,无需显式调用 `Micro.fail`。这简化了你在代码中管理自定义错误的方式。 ### Error `Error` 构造函数提供了一种为可 yield 的错误定义基类的方式。 **示例**(创建并 yield 一个自定义错误) ```ts import { Micro } from "effect" // Define a custom error class extending Error class MyError extends Micro.Error<{ message: string }> {} export const program = Micro.gen(function* () { // Yield a custom error (equivalent to failing with MyError) yield* new MyError({ message: "Oh no!" }) }) Micro.runPromiseExit(program).then(console.log) /* Output: { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "(MicroCause.Fail) Error", "error": { "message": "Oh no!" } } } */ ``` ### TaggedError `TaggedError` 构造函数让你可以定义带唯一标签的自定义可 yield 错误。每个错误都有一个 `_tag` 属性,让你可以轻松区分不同的错误类型。这也让使用 `Micro.catchTag` 这类函数处理特定的 tagged error 变得很方便。 **示例**(处理多个 tagged error) ```ts import { Micro } from "effect" // An error with _tag: "Foo" class FooError extends Micro.TaggedError("Foo")<{ message: string }> {} // An error with _tag: "Bar" class BarError extends Micro.TaggedError("Bar")<{ randomNumber: number }> {} export const program = Micro.gen(function* () { const n = Math.random() return n > 0.5 ? "yay!" : n < 0.2 ? yield* new FooError({ message: "Oh no!" }) : yield* new BarError({ randomNumber: n }) }).pipe( // Handle different tagged errors using catchTag Micro.catchTag("Foo", (error) => Micro.succeed(`Foo error: ${error.message}`), ), Micro.catchTag("Bar", (error) => Micro.succeed(`Bar error: ${error.randomNumber}`), ), ) Micro.runPromise(program).then(console.log, console.error) /* Example Output (n < 0.2): Foo error: Oh no! */ ``` ## 需求管理 在编程语境中,**服务(service)** 指的是可复用的组件或功能,可以被应用程序的不同部分使用。 服务旨在提供特定的能力,并可以在多个模块或组件之间共享。 服务通常封装了应用程序不同部分都需要的常见任务或操作。 它们可以处理复杂的操作、与外部系统或 API 交互、管理数据,或执行其他专门的任务。 服务通常被设计成模块化的,并与应用程序的其余部分解耦。 这让它们易于维护、测试和替换,而不会影响应用程序的整体功能。 要创建一个新服务,你需要两样东西: - 一个唯一的标识符。 - 一个描述该服务可执行操作的类型。 ```ts import { Micro, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Micro.Micro } >() {} ``` 现在我们已经定义好了服务标签,接下来通过构建一个简单的程序,看看如何使用它。 **示例**(在程序中使用自定义服务) ```ts import * as Context from "effect/Context" import { Micro } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Micro.Micro } >() {} // Using the service // // ┌─── Micro // ▼ const program = Micro.gen(function* () { // Access the Random service const random = yield* Micro.service(Random) // Retrieve a random number from the service const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) ``` 值得注意的是,`program` 变量的类型在 `Requirements` 类型参数中包含了 `Random`: ```ts const program: Micro ``` 这表明我们的程序需要提供 `Random` 服务才能成功执行。 要成功执行该程序,我们需要提供一个 `Random` 服务的实际实现。 **示例**(提供并使用服务) ```ts import { Micro, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Micro.Micro } >() {} // Using the service const program = Micro.gen(function* () { // Access the Random service const random = yield* Micro.service(Random) // Retrieve a random number from the service const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // Providing the implementation // // ┌─── Micro // ▼ const runnable = Micro.provideService(program, Random, { next: Micro.sync(() => Math.random()), }) Micro.runPromise(runnable) /* Example Output: random number: 0.8241872233134417 */ ``` ## 资源管理 ### MicroScope 简单来说,`MicroScope` 代表一个或多个资源的生命周期。当 scope 被关闭时,与它关联的资源保证会被释放。 借助 `MicroScope` 数据类型,你可以: - **添加 finalizer**:finalizer 指定资源的清理逻辑。 - **关闭 scope**:当 scope 被关闭时,所有资源都会被释放,且 finalizer 会被执行。 **示例**(管理 Scope) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) const program = // create a new scope Micro.scopeMake.pipe( // add finalizer 1 Micro.tap((scope) => scope.addFinalizer(() => log("finalizer 1"))), // add finalizer 2 Micro.tap((scope) => scope.addFinalizer(() => log("finalizer 2"))), // close the scope Micro.andThen((scope) => scope.close(Micro.exitSucceed("scope closed successfully")), ), ) Micro.runPromise(program) /* Output: finalizer 2 <-- finalizers are closed in reverse order finalizer 1 */ ``` 在上面的示例中,finalizer 被添加到 scope 中;当 scope 被关闭时,这些 finalizer 会**以相反的顺序执行**。 这种相反的顺序很重要,因为它能确保资源按正确的次序释放。 例如,如果你先获取一个网络连接,然后访问远程服务器上的文件,那么必须先关闭文件再关闭网络连接,以避免出错。 ### addFinalizer `Micro.addFinalizer` 函数是一个高层 API,它允许你把 finalizer 添加到某个 effect 的 scope 中。finalizer 是一段保证会在关联 scope 关闭时运行的代码。finalizer 的行为会根据 `MicroExit` 值而变化,该值表示 scope 是以何种方式关闭的——是成功还是出错。 **示例**(在成功时添加 finalizer) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) // ┌─── Micro // ▼ const program = Micro.gen(function* () { yield* Micro.addFinalizer((exit) => log(`finalizer after ${exit._tag}`)) return "some result" }) // ┌─── Micro // ▼ const runnable = Micro.scoped(program) Micro.runPromise(runnable).then(console.log, console.error) /* Output: finalizer after Success some result */ ``` 接下来,让我们看看发生失败时的行为: **示例**(在失败时添加 finalizer) ```ts import { Micro } from "effect" // Helper function to log a message const log = (message: string) => Micro.sync(() => console.log(message)) const program = Micro.gen(function* () { yield* Micro.addFinalizer((exit) => log(`finalizer after ${exit._tag}`)) return yield* Micro.fail("Uh oh!") }) const runnable = Micro.scoped(program) Micro.runPromiseExit(runnable).then(console.log) /* Output: finalizer after Failure { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "MicroCause.Fail", "error": "Uh oh!" } } */ ``` ### 定义资源 我们可以使用 `Micro.acquireRelease(acquire, release)` 这样的操作符来定义资源,它允许我们基于 `acquire` 与 `release` 工作流创建一个带 scope 的值。 每次 acquire release 都需要三个动作: - **获取资源**:一个描述如何获取资源的 effect。例如,打开一个文件。 - **使用资源**:一个描述产生结果的实际过程的 effect。例如,统计文件的行数。 - **释放资源**:一个描述释放或清理资源的最后步骤的 effect。例如,关闭文件。 `Micro.acquireRelease` 操作符会**不可中断地**执行 `acquire` 工作流。 这一点很重要,因为如果我们允许在资源获取期间被中断,就可能在资源只被获取了一部分时被中断。 `Micro.acquireRelease` 操作符的保证是:如果 `acquire` 工作流成功完成执行,那么当 `Scope` 被关闭时,`release` 工作流保证会运行。 **示例**(定义一个简单的资源) ```ts import { Micro } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Micro.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Micro.promise(() => res.close()) // Create the resource management workflow // // ┌─── Micro // ▼ const resource = Micro.acquireRelease(acquire, release) // ┌─── Micro // ▼ const program = Micro.scoped( Micro.gen(function* () { const res = yield* resource console.log(`content is ${res.contents}`) }), ) Micro.runPromise(program) /* Resource acquired content is lorem ipsum Resource released */ ``` `Micro.scoped` 操作符会把 `MicroScope` 从上下文中移除,表明该工作流不再使用任何需要 scope 的资源。 ### acquireUseRelease `Micro.acquireUseRelease(acquire, use, release)` 函数是 `Micro.acquireRelease` 函数的一个特化版本,它通过自动处理资源的作用域来简化资源管理。 主要区别在于,`acquireUseRelease` 省去了手动调用 `Micro.scoped` 来管理资源作用域的需要。它额外知道你在什么时候已经用完由 `acquire` 步骤创建的资源。这是通过提供 `use` 参数实现的,它表示对已获取资源进行操作的函数。因此,`acquireUseRelease` 能够自动判断何时应该执行 release 步骤。 **示例**(自动管理资源生命周期) ```ts import { Micro } from "effect" // Define the interface for the resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate getting the resource const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define the acquisition of the resource with error handling const acquire = Micro.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define the release of the resource const release = (res: MyResource) => Micro.promise(() => res.close()) const use = (res: MyResource) => Micro.sync(() => console.log(`content is ${res.contents}`)) // ┌─── Micro // ▼ const program = Micro.acquireUseRelease(acquire, use, release) Micro.runPromise(program) /* Resource acquired content is lorem ipsum Resource released */ ``` ## 调度 ### MicroSchedule `MicroSchedule` 类型表示一个可用于计算两次重复之间延迟的函数。 ```ts type MicroSchedule = (attempt: number, elapsed: number) => Option ``` 该函数接收当前的尝试次数以及自第一次尝试以来经过的时间,并返回下一次尝试的延迟。如果该函数返回 `None`,重复就会停止。 ### repeat `Micro.repeat` 函数返回一个新的 effect,它会按照指定的调度重复给定的 effect,或者重复到第一次失败为止。 **示例**(重复一个成功的 effect) ```ts import { Micro } from "effect" // Define an effect that logs a message to the console const action = Micro.sync(() => console.log("success")) // Define a schedule that repeats the action 2 more times with a delay const policy = Micro.scheduleAddDelay(Micro.scheduleRecurs(2), () => 100) // Repeat the action according to the schedule const program = Micro.repeat(action, { schedule: policy }) Micro.runPromise(program) /* Output: success success success */ ``` **示例**(处理重复中的失败) ```ts import { Micro } from "effect" let count = 0 // Define an async effect that simulates an action with potential failure const action = Micro.async((resume) => { if (count > 1) { console.log("failure") resume(Micro.fail("Uh oh!")) } else { count++ console.log("success") resume(Micro.succeed("yay!")) } }) // Define a schedule that repeats the action 2 more times with a delay const policy = Micro.scheduleAddDelay(Micro.scheduleRecurs(2), () => 100) // Repeat the action according to the schedule const program = Micro.repeat(action, { schedule: policy }) // Run the program and observe the result on failure Micro.runPromiseExit(program).then(console.log) /* Output: success success failure { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Fail", "traces": [], "name": "MicroCause.Fail", "error": "Uh oh!" } } */ ``` ### 模拟调度行为 这个辅助函数 `dryRun` 展示了不同的调度策略如何在没有实际执行 effect 的情况下控制重复的时机。它返回一个延迟间隔数组,从而可视化一个调度会如何安排各次重复之间的间隔。 ```ts import { Option, Micro } from "effect" // Helper function to simulate and visualize a schedule's behavior const dryRun = ( schedule: Micro.MicroSchedule, // The scheduling policy to simulate maxAttempt: number = 7, // Maximum number of repetitions to simulate ): Array => { let attempt = 1 // Track the current attempt number let elapsed = 0 // Track the total elapsed time const out: Array = [] // Array to store each delay duration let duration = schedule(attempt, elapsed) // Continue until the schedule returns no delay or maxAttempt is reached while (Option.isSome(duration) && attempt <= maxAttempt) { const value = duration.value out.push(value) attempt++ elapsed += value // Get the next duration based on the current attempt // and total elapsed time duration = schedule(attempt, elapsed) } return out } ``` ### scheduleSpaced 一个无限重复的调度,每次重复与上一次运行之间相隔指定的时长。 **示例**(执行之间带延迟地重复) ```ts import { Micro } from "effect" import * as Option from "effect/Option" // Helper function to simulate and visualize a schedule's behavior const dryRun = ( schedule: Micro.MicroSchedule, maxAttempt: number = 7, ): Array => { let attempt = 1 let elapsed = 0 const out: Array = [] let duration = schedule(attempt, elapsed) while (Option.isSome(duration) && attempt <= maxAttempt) { const value = duration.value attempt++ elapsed += value out.push(value) duration = schedule(attempt, elapsed) } return out } const policy = Micro.scheduleSpaced(10) console.log(dryRun(policy)) /* Output: [ 10, 10, 10, 10, 10, 10, 10 ] */ ``` ### scheduleExponential 一个使用指数退避重复的调度,每次延迟按指数增长。 **示例**(指数退避调度) ```ts import { Micro } from "effect" import * as Option from "effect/Option" // Helper function to simulate and visualize a schedule's behavior const dryRun = ( schedule: Micro.MicroSchedule, maxAttempt: number = 7, ): Array => { let attempt = 1 let elapsed = 0 const out: Array = [] let duration = schedule(attempt, elapsed) while (Option.isSome(duration) && attempt <= maxAttempt) { const value = duration.value attempt++ elapsed += value out.push(value) duration = schedule(attempt, elapsed) } return out } const policy = Micro.scheduleExponential(10) console.log(dryRun(policy)) /* Output: [ 20, 40, 80, 160, 320, 640, 1280 ] */ ``` ### scheduleUnion 使用并集(Union)组合两个调度。只要其中一个调度还想继续,该调度就会重复,并取两次重复之间的最小延迟。 **示例**(指数调度与定间隔调度的并集) ```ts import { Micro } from "effect" import * as Option from "effect/Option" // Helper function to simulate and visualize a schedule's behavior const dryRun = ( schedule: Micro.MicroSchedule, maxAttempt: number = 7, ): Array => { let attempt = 1 let elapsed = 0 const out: Array = [] let duration = schedule(attempt, elapsed) while (Option.isSome(duration) && attempt <= maxAttempt) { const value = duration.value attempt++ elapsed += value out.push(value) duration = schedule(attempt, elapsed) } return out } const policy = Micro.scheduleUnion( Micro.scheduleExponential(10), Micro.scheduleSpaced(300), ) console.log(dryRun(policy)) /* Output: [ 20, < exponential 40, 80, 160, 300, < spaced 300, 300 ] */ ``` ### scheduleIntersect 使用交集(Intersection)组合两个调度。只有当两个调度都还想继续时,该调度才会重复,并取两者之间的最大延迟。 **示例**(指数调度与 Recurs 调度的交集) ```ts import { Micro } from "effect" import * as Option from "effect/Option" // Helper function to simulate and visualize a schedule's behavior const dryRun = ( schedule: Micro.MicroSchedule, maxAttempt: number = 7, ): Array => { let attempt = 1 let elapsed = 0 const out: Array = [] let duration = schedule(attempt, elapsed) while (Option.isSome(duration) && attempt <= maxAttempt) { const value = duration.value attempt++ elapsed += value out.push(value) duration = schedule(attempt, elapsed) } return out } const policy = Micro.scheduleIntersect( Micro.scheduleExponential(10), Micro.scheduleSpaced(300), ) console.log(dryRun(policy)) /* Output: [ 300, < spaced 300, 300, 300, 320, < exponential 640, 1280 ] */ ``` ## 并发 ### Fork Effect 创建 Fiber 的基本方式之一就是 fork 一个已有的 effect。当你 fork 一个 effect 时,它会在一个新的 Fiber 上开始执行该 effect,并返回一个指向这个新建 Fiber 的引用。 下面的代码演示了如何使用 `Micro.fork` 函数创建一个 Fiber。这个 Fiber 会独立于主 Fiber 执行函数 `fib(100)`: **示例**(Fork 一个 Fiber) ```ts import { Micro } from "effect" const fib = (n: number): Micro.Micro => n < 2 ? Micro.succeed(n) : Micro.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Micro, never, never> // ▼ const fib10Fiber = Micro.fork(fib(10)) ``` ### Join Fiber 对 Fiber 的一个常见操作是使用 `Micro.fiberJoin` 函数 join 它们。该函数返回一个 `Micro`,它会根据所 join 的 Fiber 的结果而成功或失败: **示例**(Join 一个 Fiber) ```ts import { Micro } from "effect" const fib = (n: number): Micro.Micro => n < 2 ? Micro.succeed(n) : Micro.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Micro, never, never> // ▼ const fib10Fiber = Micro.fork(fib(10)) const program = Micro.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Join the fiber and get the result const n = yield* Micro.fiberJoin(fiber) console.log(n) }) Micro.runPromise(program) // Output: 55 ``` ### Await Fiber 另一个对 Fiber 很有用的函数是 `Micro.fiberAwait`。该函数返回一个包含 `MicroExit` 值的 effect,它提供了关于该 Fiber 如何结束的详细信息。 **示例**(等待 Fiber 完成) ```ts import { Micro } from "effect" const fib = (n: number): Micro.Micro => n < 2 ? Micro.succeed(n) : Micro.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Micro, never, never> // ▼ const fib10Fiber = Micro.fork(fib(10)) const program = Micro.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Await its completion and get the MicroExit result const exit = yield* Micro.fiberAwait(fiber) console.log(exit) }) Micro.runPromise(program) /* Output: { "_id": "MicroExit", "_tag": "Success", "value": 55 } */ ``` ## 中断 Effect 中的所有 effect 都由 Fiber 执行。如果你没有自己创建 Fiber,那么它要么是由你正在使用的某个操作创建的(如果该操作是并发的),要么是由 Effect 运行时系统创建的。 每当一个 effect 被运行时,就会创建一个 Fiber。当并发运行多个 effect 时,会为每个并发 effect 创建一个 Fiber。 总结如下: - `Micro` 是更高层的概念,用于描述一段带副作用的计算。它是惰性且不可变的,这意味着它表示一段可能产生值、也可能失败的计算,但并不会立即执行。 - 而 Fiber 表示 `Micro` 正在运行的执行过程。它可以被中断,也可以被等待以获取其结果。可以把它看作一种控制和交互正在进行的计算的方式。 Fiber 可以通过多种方式被中断。下面我们来探讨其中一些场景,看看在 Effect 中中断 Fiber 的示例。 ### 中断 Fiber 如果 Fiber 的结果不再被需要,就可以中断它,这会立即终止该 Fiber,并通过运行所有终结器来安全地释放所有资源。 与 `.await` 类似,`.interrupt` 返回一个 `MicroExit` 值,用来描述该 Fiber 是如何完成的。 **示例**(中断一个 Fiber) ```ts import { Micro } from "effect" const program = Micro.gen(function* () { // Fork a fiber that runs indefinitely, printing "Hi!" const fiber = yield* Micro.fork( Micro.forever(Micro.sync(() => console.log("Hi!")).pipe(Micro.delay(10))), ) yield* Micro.sleep(30) // Interrupt the fiber yield* Micro.fiberInterrupt(fiber) }) Micro.runPromise(program) /* Output: Hi! Hi! */ ``` ### Micro.interrupt 可以针对特定的 Fiber 使用 `Micro.interrupt` 这个 effect 来中断它。 **示例**(不进行中断) 在这个例子中,程序不会发生任何中断,只会记录任务的开始与完成。 ```ts import { Micro } from "effect" const program = Micro.gen(function* () { console.log("start") yield* Micro.sleep(2_000) console.log("done") }) Micro.runPromiseExit(program).then(console.log) /* Output: start done { "_id": "MicroExit", "_tag": "Success" } */ ``` **示例**(进行中断) 这里,Fiber 在打印 `"start"` 之后、打印 `"done"` 之前被中断。`Effect.interrupt` 会停止该 Fiber,因此它永远执行不到最后一行日志。 ```ts import { Micro } from "effect" const program = Micro.gen(function* () { console.log("start") yield* Micro.sleep(2_000) yield* Micro.interrupt console.log("done") }) Micro.runPromiseExit(program).then(console.log) /* Output: start { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Interrupt", "traces": [], "name": "MicroCause.Interrupt" } } */ ``` 当 Fiber 被中断时,中断的原因会被捕获,其中包含 Fiber 的 ID、启动时间等细节。 ### 并发 effect 的中断 当并发运行多个 effect 时(例如使用 `Micro.forEach`),如果其中一个 effect 被中断,那么所有并发运行的 effect 也会随之被中断。 **示例**(中断并发 effect) ```ts import { Micro } from "effect" const program = Micro.forEach( [1, 2, 3], (n) => Micro.gen(function* () { console.log(`start #${n}`) yield* Micro.sleep(2 * 1_000) if (n > 1) { yield* Micro.interrupt } console.log(`done #${n}`) }), { concurrency: "unbounded" }, ) Micro.runPromiseExit(program).then((exit) => console.log(JSON.stringify(exit, null, 2)), ) /* Output: start #1 start #2 start #3 done #1 { "_id": "MicroExit", "_tag": "Failure", "cause": { "_tag": "Interrupt", "traces": [], "name": "MicroCause.Interrupt" } } */ ``` ## 竞速 `Effect.race` 函数允许你并发运行多个 effect,并返回第一个成功完成的 effect 的结果。 **示例**(effect 之间的基本竞速) ```ts import { Micro } from "effect" const task1 = Micro.delay(Micro.fail("task1"), 1_000) const task2 = Micro.delay(Micro.succeed("task2"), 2_000) // Run both tasks concurrently and return // the result of the first to complete const program = Micro.race(task1, task2) Micro.runPromise(program).then(console.log) /* Output: task2 */ ``` 如果你想处理最先完成的任务的结果——无论它是成功还是失败——可以使用 `Micro.either` 函数。该函数会把结果包装成 [Either](/docs/v3/data-types/either/) 类型,让你可以判断结果是成功(`Right`)还是失败(`Left`): **示例**(用 Either 处理成功或失败) ```ts import { Micro } from "effect" const task1 = Micro.delay(Micro.fail("task1"), 1_000) const task2 = Micro.delay(Micro.succeed("task2"), 2_000) // Run both tasks concurrently, wrapping the result // in Either to capture success or failure const program = Micro.race(Micro.either(task1), Micro.either(task2)) Micro.runPromise(program).then(console.log) /* Output: { _id: 'Either', _tag: 'Left', left: 'task1' } */ ``` --- # 日志 > 了解 Effect 的日志工具,包括动态日志级别、自定义输出以及对日志的细粒度控制。 日志是软件开发中的一个重要方面,尤其是在调试和监控应用程序行为时。在本节中,我们将探索 Effect 的日志工具,并看看它们与传统日志记录方法有何不同。 ## 相比传统日志记录的优势 相比传统的日志记录方式,Effect 的日志工具带来了几项优势: 1. **动态日志级别控制**:借助 Effect 的日志功能,你可以动态更改日志级别。这意味着你能根据严重程度控制哪些日志消息会被展示。例如,你可以把应用配置为只记录警告或错误,这在生产环境中对降低噪音非常有帮助。 2. **自定义日志输出**:Effect 的日志工具允许你改变日志的处理方式。借助[自定义 logger](#custom-loggers),你可以把日志消息导向各种目的地,例如某个服务或某个文件。这种灵活性确保日志的存储与处理方式最贴合你的应用需求。 3. **细粒度日志**:Effect 支持按程序的各个部分对日志进行细粒度控制。你可以为应用的不同部分设置不同的日志级别,从而为每个具体组件定制详细程度。这在调试和排查问题时非常有价值,因为你可以专注于最重要的信息。 4. **基于环境的日志**:Effect 的日志工具可以与部署环境结合,实现精细的日志策略。例如,在开发期间,你可能会选择以 trace 级别及以上记录所有内容,以便详细调试。相比之下,生产版本可以配置为只记录错误或严重问题,从而把对性能的影响以及生产日志中的噪音降到最低。 5. **其他特性**:Effect 的日志工具还带有其他特性,例如测量时间跨度、按 effect 调整日志级别,以及集成 span 用于性能监控。 ## log `Effect.log` 函数允许你以默认的 `INFO` 级别记录一条消息。 **示例**(记录一条简单消息) ```ts import { Effect } from "effect" const program = Effect.log("Application started") Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started" */ ``` Effect 中的默认 logger 会为每条日志条目添加若干有用的细节: | 注解 | 说明 | | --- | --- | | `timestamp` | 日志消息生成时的时间戳。 | | `level` | 记录该消息时使用的日志级别(例如 `INFO`、`ERROR`)。 | | `fiber` | 执行该程序的 [fiber](/docs/v3/concurrency/fibers/) 的标识符。 | | `message` | 日志消息的内容,可以包含多个字符串或值。 | | `span` | (可选)span 的持续时间,单位为毫秒,可帮助你了解各项操作的耗时。 | 你也可以一次记录多条消息。 **示例**(记录多条消息) ```ts import { Effect } from "effect" const program = Effect.log("message1", "message2", "message3") Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 message=message2 message=message3 */ ``` 为了提供更多上下文,你还可以在日志中包含一个或多个 [Cause](/docs/v3/data-types/cause/) 实例, 它们会在额外的 `cause` 注解下提供详细的错误信息: **示例**(记录带 cause 的日志) ```ts import { Effect, Cause } from "effect" const program = Effect.log( "message1", "message2", Cause.die("Oh no!"), Cause.die("Oh uh!"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 message=message2 cause="Error: Oh no! Error: Oh uh!" */ ``` ## 日志级别 ### logDebug 默认情况下,`DEBUG` 消息**不会被展示**。要启用 `DEBUG` 日志,你可以用 `Logger.withMinimumLogLevel` 调整日志配置,把最低级别设为 `LogLevel.Debug`。 **示例**(启用调试日志) ```ts import { Effect, Logger, LogLevel } from "effect" const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") // Log a debug message }).pipe(Logger.withMinimumLogLevel(LogLevel.Debug)) // Enable DEBUG level const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") // This message won't be logged }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) Effect.runFork(program) /* Output: timestamp=... level=INFO message=start timestamp=... level=DEBUG message="task1 done" <-- 2 seconds later timestamp=... level=INFO message=done <-- 1 second later */ ``` ### logInfo `INFO` 日志级别默认会被展示。该级别通常用于一般性的应用事件或进度更新。 **示例**(以 INFO 级别记录日志) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { yield* Effect.logInfo("start") yield* Effect.sleep("2 seconds") yield* Effect.sleep("1 second") yield* Effect.logInfo("done") }) Effect.runFork(program) /* Output: timestamp=... level=INFO message=start timestamp=... level=INFO message=done <-- 3 seconds later */ ``` ### logWarning `WARN` 日志级别默认会被展示。该级别用于那些不会立即打断程序流程、但应当被关注的潜在问题或警告。 **示例**(以 WARN 级别记录日志) ```ts import { Effect, Either } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.either(task) if (Either.isLeft(failureOrSuccess)) { yield* Effect.logWarning(failureOrSuccess.left) return 0 } else { return failureOrSuccess.right } }) Effect.runFork(program) /* Output: timestamp=... level=WARN fiber=#0 message="Oh uh!" */ ``` ### logError `ERROR` 日志级别默认会被展示。这些消息表示需要处理的问题。 **示例**(以 ERROR 级别记录日志) ```ts import { Effect, Either } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.either(task) if (Either.isLeft(failureOrSuccess)) { yield* Effect.logError(failureOrSuccess.left) return 0 } else { return failureOrSuccess.right } }) Effect.runFork(program) /* Output: timestamp=... level=ERROR fiber=#0 message="Oh uh!" */ ``` ### logFatal `FATAL` 日志级别默认会被展示。该日志级别通常保留给不可恢复的错误。 **示例**(以 FATAL 级别记录日志) ```ts import { Effect, Either } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.either(task) if (Either.isLeft(failureOrSuccess)) { yield* Effect.logFatal(failureOrSuccess.left) return 0 } else { return failureOrSuccess.right } }) Effect.runFork(program) /* Output: timestamp=... level=FATAL fiber=#0 message="Oh uh!" */ ``` ## 自定义注解 你可以使用 `Effect.annotateLogs` 函数添加自定义注解,从而增强日志输出。 这样可以让你为每条日志条目附加额外的元数据,提升可追溯性并提供更多上下文。 ### 添加单个注解 你可以以键/值对的形式,把单个注解应用到某个 effect 内的所有日志条目上。 **示例**(单个键/值注解) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") }).pipe( // Annotation as key/value pair Effect.annotateLogs("key", "value"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value */ ``` 在这个例子中,`program` 内生成的所有日志都会包含注解 `key=value`。 ### 嵌套 effect 中的注解 注解会传播到嵌套 effect 或下游 effect 中生成的所有日志,从而确保任何子 effect 的日志都继承父 effect 的注解。 **示例**(把注解传播到嵌套 effect) 在这个例子中,注解 `key=value` 会出现在所有日志中,甚至包括来自嵌套 `anotherProgram` effect 的日志。 ```ts import { Effect } from "effect" // Define a child program that logs an error const anotherProgram = Effect.gen(function* () { yield* Effect.logError("error1") }) // Define the main program const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") yield* anotherProgram // Call the nested program }).pipe( // Attach an annotation to all logs in the scope Effect.annotateLogs("key", "value"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value timestamp=... level=ERROR fiber=#0 message=error1 key=value */ ``` ### 添加多个注解 你也可以通过传入一个包含键/值对的对象,一次应用多个注解。每一对键/值都会被添加到该 effect 内的每一条日志记录中。 **示例**(多个注解) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") }).pipe( // Add multiple annotations Effect.annotateLogs({ key1: "value1", key2: "value2" }), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key2=value2 key1=value1 timestamp=... level=INFO fiber=#0 message=message2 key2=value2 key1=value1 */ ``` 在这种情况下,每条日志都会同时包含 `key1=value1` 和 `key2=value2`。 ### 作用域内的注解 如果你希望限制注解的作用范围,使它们只对特定的日志记录生效,可以使用 `Effect.annotateLogsScoped`。这个函数会把注解限制在特定作用域内产生的日志上。 **示例**(作用域内的注解) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { yield* Effect.log("no annotations") // No annotations yield* Effect.annotateLogsScoped({ key: "value" }) // Scoped annotation yield* Effect.log("message1") // Annotation applied yield* Effect.log("message2") // Annotation applied }).pipe( Effect.scoped, // Outside scope, no annotations Effect.andThen(Effect.log("no annotations again")), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="no annotations" timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value timestamp=... level=INFO fiber=#0 message="no annotations again" */ ``` ## 日志 Span Effect 内置支持日志 span(log span),它可以让你测量并记录特定任务或代码片段的耗时。这个特性有助于追踪某些操作耗费了多长时间,让你对应用的性能有更深入的了解。 **示例**(用日志 Span 测量任务耗时) ```ts import { Effect } from "effect" const program = Effect.gen(function* () { // Simulate a delay to represent a task taking time yield* Effect.sleep("1 second") // Log a message indicating the job is done yield* Effect.log("The job is finished!") }).pipe( // Apply a log span labeled "myspan" to measure // the duration of this operation Effect.withLogSpan("myspan"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="The job is finished!" myspan=1011ms */ ``` ## 禁用默认日志 有时,比如在测试执行期间,你可能希望禁用应用中的默认日志。Effect 提供了几种在需要时关闭日志的方式。本节中,我们来看看在 Effect 框架中禁用日志的不同方法。 **示例**(使用 `Logger.withMinimumLogLevel`) 禁用日志的一种便捷方式是使用 `Logger.withMinimumLogLevel` 函数。它允许你把最低日志级别设为 `None`,从而彻底关闭所有日志输出。 ```ts import { Effect, Logger, LogLevel } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Default behavior: logging enabled Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Executing task..." task done */ // Disable logging by setting minimum log level to 'None' Effect.runFork(program.pipe(Logger.withMinimumLogLevel(LogLevel.None))) /* Output: task done */ ``` **示例**(使用 Layer) 另一种禁用日志的方式是创建一个把最低日志级别设为 `LogLevel.None` 的 Layer,从而彻底关闭所有日志输出。 ```ts import { Effect, Logger, LogLevel } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Create a layer that disables logging const layer = Logger.minimumLogLevel(LogLevel.None) // Apply the layer to disable logging Effect.runFork(program.pipe(Effect.provide(layer))) /* Output: task done */ ``` **示例**(使用自定义 Runtime) 你也可以通过创建一个包含关闭日志配置的自定义 Runtime 来禁用日志: ```ts import { Effect, Logger, LogLevel, ManagedRuntime } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Create a custom runtime that disables logging const customRuntime = ManagedRuntime.make(Logger.minimumLogLevel(LogLevel.None)) // Run the program using the custom runtime customRuntime.runFork(program) /* Output: task done */ ``` ## 从配置中加载日志级别 若要从[配置](/docs/v3/configuration/)中动态加载日志级别并应用到你的程序,可以使用 `Logger.minimumLogLevel` layer。这让你应用可以根据外部配置调整其日志行为。 **示例**(从配置中加载日志级别) ```ts import { Effect, Config, Logger, Layer, ConfigProvider, LogLevel } from "effect" // Simulate a program with logs const program = Effect.gen(function* () { yield* Effect.logError("ERROR!") yield* Effect.logWarning("WARNING!") yield* Effect.logInfo("INFO!") yield* Effect.logDebug("DEBUG!") }) // Load the log level from the configuration and apply it as a layer const LogLevelLive = Config.logLevel("LOG_LEVEL").pipe( Effect.andThen((level) => // Set the minimum log level Logger.minimumLogLevel(level), ), Layer.unwrapEffect, // Convert the effect into a layer ) // Provide the loaded log level to the program const configured = Effect.provide(program, LogLevelLive) // Test the program using a mock configuration provider const test = Effect.provide( configured, Layer.setConfigProvider( ConfigProvider.fromMap(new Map([["LOG_LEVEL", LogLevel.Warning.label]])), ), ) Effect.runFork(test) /* Output: ... level=ERROR fiber=#0 message=ERROR! ... level=WARN fiber=#0 message=WARNING! */ ``` ## 自定义 Logger 本节中,你将学习如何定义自定义 logger,并把它设为应用中的默认 logger。自定义 logger 让你可以控制日志消息的处理方式,例如把它们路由到外部服务、写入文件,或以特定方式格式化日志。 ### 定义自定义 Logger 你可以使用 `Logger.make` 函数定义自己的 logger。这个函数允许你指定日志消息应当如何处理。 **示例**(定义一个简单的自定义 Logger) ```ts import { Logger } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.label}] ${message}`) }) ``` 在这个例子中,自定义 logger 把日志输出到控制台,格式为 `[LogLevel] Message`,其中包含日志级别和消息。 ### 在程序中使用自定义 Logger 假设你已有下面这些任务,以及一个记录若干消息的程序: ```ts import { Effect, Logger } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.label}] ${message}`) }) const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") }) const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) ``` 要用自定义 logger 替换默认 logger,可以使用 `Logger.replace` 函数。在创建一个替换默认 logger 的 layer 之后,用 `Effect.provide` 把它提供给你的程序。 **示例**(用自定义 Logger 替换默认 Logger) ```ts import { Effect, Logger, LogLevel } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.label}] ${message}`) }) const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") }) const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) // Replace the default logger with the custom logger const layer = Logger.replace(Logger.defaultLogger, logger) Effect.runFork( program.pipe( Logger.withMinimumLogLevel(LogLevel.Debug), Effect.provide(layer), ), ) ``` 运行上面的程序时,控制台会打印如下日志消息: ```ansi [INFO] start [DEBUG] task1 done [DEBUG] task2 done [INFO] done ``` ## 内置 Logger Effect 提供了若干内置 logger,你可以根据自己的日志记录需求选用。这些 logger 提供不同的格式,各自适用于不同的环境或用途,例如开发、生产,或与外部日志服务集成。 每个 logger 都以两种形式提供:logger 本身,以及一个使用该 logger 并把输出发送到 `Console` [默认服务](/docs/v3/requirements-management/default-services/) 的 layer。例如,`structuredLogger` logger 以详细的对象格式生成日志,而 `structured` layer 使用同一个 logger,并把输出写入 `Console` 服务。 ### stringLogger(默认) `stringLogger` logger 以人类可读的键值风格生成日志。这种格式在开发和生产中都很常用,因为它简单,并且易于在控制台中阅读。 由于它是默认 logger,因此这个 logger 没有对应的 layer。 ```ts import { Effect } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program) ``` 输出: ```ansi timestamp=2024-12-28T10:44:31.281Z level=INFO fiber=#0 message=msg1 message=msg2 message="[ \"msg3\", \"msg4\" ]" myspan=102ms key2=value2 key1=value1 ``` ### logfmtLogger `logfmtLogger` logger 以人类可读的键值格式生成日志,与 [stringLogger](#stringlogger-default) logger 类似。主要区别在于,`logfmtLogger` 会移除多余的空格,让日志更紧凑。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program.pipe(Effect.provide(Logger.logFmt))) ``` 输出: ```ansi timestamp=2024-12-28T10:44:31.281Z level=INFO fiber=#0 message=msg1 message=msg2 message="[\"msg3\",\"msg4\"]" myspan=102ms key2=value2 key1=value1 ``` ### prettyLogger `prettyLogger` logger 通过颜色和缩进来增强日志输出,以获得更好的可读性,因此在开发阶段需要目视浏览控制台日志时尤其有用。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program.pipe(Effect.provide(Logger.pretty))) ``` 输出: ```ansi [11:37:14.265] INFO (#0) myspan=101ms: msg1 msg2 [ 'msg3', 'msg4' ] key2: value2 key1: value1 ``` ### structuredLogger `structuredLogger` logger 以详细的对象格式生成日志。当你需要更可追溯的日志时,这种格式很有帮助,特别是当其他系统要分析这些日志、或将其存储起来以便日后查看时。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program.pipe(Effect.provide(Logger.structured))) ``` 输出: ```ansi { message: [ 'msg1', 'msg2', [ 'msg3', 'msg4' ] ], logLevel: 'INFO', timestamp: '2024-12-28T10:44:31.281Z', cause: undefined, annotations: { key2: 'value2', key1: 'value1' }, spans: { myspan: 102 }, fiberId: '#0' } ``` | 字段 | 说明 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `message` | 经过处理的单个值,或经过处理的值组成的数组,取决于记录了多少条消息。 | | `logLevel` | 表示日志级别标签的字符串(例如 "INFO" 或 "DEBUG")。 | | `timestamp` | 日志生成时刻的 ISO 8601 时间戳(例如 "2024-01-01T00:00:00.000Z")。 | | `cause` | 展示详细错误信息的字符串;如果未提供 cause,则为 `undefined`。 | | `annotations` | 一个对象,其中每个键是一个注解标签,对应的值会被解析为结构化格式(例如 `{"key": "value"}`)。 | | `spans` | 一个对象,把每个 span 标签映射到它的毫秒级时长,该时长从 span 开始计时算起,到调用 logger 的那一刻为止(例如 `{"myspan": 102}`)。 | | `fiberId` | 生成这条日志的 fiber 的标识符(例如 "#0")。 | ### jsonLogger `jsonLogger` logger 以 JSON 格式生成日志。对于需要解析并存储 JSON 日志的工具或服务来说,这很有用。 它会对 [structuredLogger](#structuredlogger) logger 创建的对象调用 `JSON.stringify`。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program.pipe(Effect.provide(Logger.json))) ``` 输出: ```ansi {"message":["msg1","msg2",["msg3","msg4"]],"logLevel":"INFO","timestamp":"2024-12-28T10:44:31.281Z","annotations":{"key2":"value2","key1":"value1"},"spans":{"myspan":102},"fiberId":"#0"} ``` ## 组合多个 Logger ### zip `Logger.zip` 函数把两个 logger 组合成一个新的 logger。这个新 logger 会把日志消息转发给原来的两个 logger。 **示例**(组合两个 Logger) ```ts import { Effect, Logger } from "effect" // Define a custom logger that logs to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.label}] ${message}`) }) // Combine the default logger and the custom logger // // ┌─── Logger // ▼ const combined = Logger.zip(Logger.defaultLogger, logger) const program = Effect.log("something") Effect.runFork( program.pipe( // Replace the default logger with the combined logger Effect.provide(Logger.replace(Logger.defaultLogger, combined)), ), ) /* Output: timestamp=2025-01-09T13:50:58.655Z level=INFO fiber=#0 message=something [INFO] something */ ``` --- # Effect 中的 Metric > Effect Metrics 提供了强大的监控工具,包括 Counter、Gauge、Histogram、Summary 和 Frequency,用于跟踪应用的性能与行为。 在复杂且高度并发的应用中,管理各种相互关联的组件可能相当棘手。确保一切平稳运行、避免应用停机,在这类场景中变得至关重要。 现在,设想我们拥有一套复杂的基础设施,其中包含众多服务。这些服务被复制并分布到多台服务器上。然而,我们往往无法了解这些服务中正在发生什么,包括错误率、响应时间和服务正常运行时间。这种可见性的缺失会让我们难以有效地发现和解决问题。这正是 Effect Metrics 发挥作用的地方:它让我们能够捕获并分析各种 metric,为后续排查提供有价值的数据。 Effect Metrics 支持五种不同类型的 metric: | Metric | 说明 | | --- | --- | | **Counter** | Counter 用于跟踪随时间增长的数值,例如请求次数。它帮助我们掌握某个特定事件或动作已经发生了多少次。 | | **Gauge** | Gauge 表示一个会随时间上下波动的单一数值。它常用于监控内存使用量这类会持续变化的 metric。 | | **Histogram** | Histogram 适合跟踪观测值在不同 bucket 之间的分布。它常用于请求延迟这类 metric,让我们能够了解响应时间的分布情况。 | | **Summary** | Summary 提供对时间序列滑动窗口的洞察,并给出该时间序列特定百分位的 metric,这些百分位通常被称为分位数(quantile)。这对于理解与延迟相关的 metric(例如请求响应时间)特别有帮助。 | | **Frequency** | Frequency metric 统计不同字符串值出现的次数。当你想要跟踪应用中不同事件或条件的发生频率时,它非常有用。 | ## Counter 在 metric 的世界里,Counter 是一种表示单一数值的 metric,这个数值可以随时间递增,也可以随时间递减。可以把它想象成一个记录变化次数的计数器,例如你的应用收到的某类请求的数量,无论它是在增加还是减少。 与其他类型的 metric 不同(比如 [Gauge](#gauge)),我们关注的是某个特定时刻的值;而对于 Counter,我们关心的是随时间累积的值。也就是说,它提供的是变化的累计总量,这个总量可升可降,反映出某些 metric 的动态特性。 Counter 的一些典型使用场景包括: - **请求计数**:监控发往服务器的传入请求数量。 - **已完成任务**:跟踪有多少任务或流程已成功完成。 - **错误计数**:统计应用中错误出现的次数。 ### 如何创建 Counter 要创建 Counter,可以使用 `Metric.counter` 构造器。 **示例**(创建 Counter) ```ts import { Metric, Effect } from "effect" const requestCount = Metric.counter("request_count", { // Optional description: "A counter for tracking requests", }) ``` 创建之后,Counter 可以接收一个返回 `number` 的 effect,这个值会让 Counter 递增或递减。 **示例**(使用 Counter) ```ts import { Metric, Effect } from "effect" const requestCount = Metric.counter("request_count") const program = Effect.gen(function* () { // Increment the counter by 1 const a = yield* requestCount(Effect.succeed(1)) // Increment the counter by 2 const b = yield* requestCount(Effect.succeed(2)) // Decrement the counter by 4 const c = yield* requestCount(Effect.succeed(-4)) // Get the current state of the counter const state = yield* Metric.value(requestCount) console.log(state) return a * b * c }) Effect.runPromise(program).then(console.log) /* Output: CounterState { count: -1, ... } -8 */ ``` ### Counter 类型 你可以指定 Counter 跟踪的是 `number` 还是 `bigint`。 ```ts import { Metric } from "effect" const numberCounter = Metric.counter("request_count", { description: "A counter for tracking requests", // bigint: false // default }) const bigintCounter = Metric.counter("error_count", { description: "A counter for tracking errors", bigint: true, }) ``` ### 仅递增的 Counter 如果你需要一个只递增的 Counter,可以使用 `incremental: true` 选项。 **示例**(使用仅递增的 Counter) ```ts import { Metric, Effect } from "effect" const incrementalCounter = Metric.counter("count", { description: "a counter that only increases its value", incremental: true, }) const program = Effect.gen(function* () { const a = yield* incrementalCounter(Effect.succeed(1)) const b = yield* incrementalCounter(Effect.succeed(2)) // This will have no effect on the counter const c = yield* incrementalCounter(Effect.succeed(-4)) const state = yield* Metric.value(incrementalCounter) console.log(state) return a * b * c }) Effect.runPromise(program).then(console.log) /* Output: CounterState { count: 3, ... } -8 */ ``` 在这种配置下,Counter 只接受正值。任何递减的尝试都不会生效,从而确保 Counter 严格向上计数。 ### 带常量输入的 Counter 你可以把 Counter 配置为每次被调用时都按固定值递增。 **示例**(常量输入) ```ts import { Metric, Effect } from "effect" const taskCount = Metric.counter("task_count").pipe( Metric.withConstantInput(1), // Automatically increments by 1 ) const task1 = Effect.succeed(1).pipe(Effect.delay("100 millis")) const task2 = Effect.succeed(2).pipe(Effect.delay("200 millis")) const task3 = Effect.succeed(-4).pipe(Effect.delay("300 millis")) const program = Effect.gen(function* () { const a = yield* taskCount(task1) const b = yield* taskCount(task2) const c = yield* taskCount(task3) const state = yield* Metric.value(taskCount) console.log(state) return a * b * c }) Effect.runPromise(program).then(console.log) /* Output: CounterState { count: 3, ... } -8 */ ``` ## Gauge 在 metric 的世界里,Gauge 是一种表示单一数值的 metric,这个数值可以被设置或调整。可以把它想象成一个会随时间变化的动态变量。Gauge 的一个常见用途是监控应用的当前内存使用量这类指标。 与 Counter 不同(我们关心的是随时间累积的值),对于 Gauge,我们关注的是某个特定时间点上的当前值。 当你想要监控既可增大也可减小、并且不关心其变化速率的数值时,Gauge 是最佳选择。换句话说,Gauge 帮助我们度量在某个特定时刻具有特定值的量。 Gauge 的一些典型使用场景包括: - **内存使用量**:留意应用当前正在使用多少内存。 - **队列大小**:监控等待处理任务的队列的当前大小。 - **进行中的请求数**:跟踪服务器当前正在处理的请求数量。 - **温度**:测量当前温度,它会上下波动。 ### 如何创建 Gauge 要创建 Gauge,可以使用 `Metric.gauge` 构造器。 **示例**(创建 Gauge) ```ts import { Metric } from "effect" const memory = Metric.gauge("memory_usage", { // Optional description: "A gauge for memory usage", }) ``` 创建之后,可以通过传入一个产生目标值的 effect 来更新 Gauge,该值就是你想为 Gauge 设置的值。 **示例**(使用 Gauge) ```ts import { Metric, Effect, Random } from "effect" // Create a gauge to track temperature const temperature = Metric.gauge("temperature") // Simulate fetching a random temperature const getTemperature = Effect.gen(function* () { // Get a random temperature between -10 and 10 const t = yield* Random.nextIntBetween(-10, 10) console.log(`new temperature: ${t}`) return t }) // Program that updates the gauge multiple times const program = Effect.gen(function* () { const series: Array = [] // Update the gauge with new temperature readings series.push(yield* temperature(getTemperature)) series.push(yield* temperature(getTemperature)) series.push(yield* temperature(getTemperature)) // Retrieve the current state of the gauge const state = yield* Metric.value(temperature) console.log(state) return series }) Effect.runPromise(program).then(console.log) /* Example Output: new temperature: 9 new temperature: -9 new temperature: 2 GaugeState { value: 2, // the most recent value set in the gauge ... } [ 9, -9, 2 ] */ ``` ### Gauge 类型 你可以指定 Gauge 跟踪的是 `number` 还是 `bigint`。 ```ts import { Metric } from "effect" const numberGauge = Metric.gauge("memory_usage", { description: "A gauge for memory usage", // bigint: false // default }) const bigintGauge = Metric.gauge("cpu_load", { description: "A gauge for CPU load", bigint: true, }) ``` ## Histogram Histogram 是一种用于分析数值如何随时间分布的 metric。它并不关注单个数据点,而是把值归入预先定义的范围(称为 **bucket**),并跟踪每个范围内落入多少个值。 当一个值被记录时,它会根据自己的大小被分配到 Histogram 的某个 bucket 中。每个 bucket 都有一个上边界,如果该值小于或等于这个边界,该 bucket 的计数就会增加。一旦记录完成,单个值就被丢弃,关注点转移到每个 bucket 中落入了多少个值。 Histogram 还会跟踪: - **总计数**:已观测到的值的数量。 - **总和**:所有已观测值的总和。 - **最小值**:最小的观测值。 - **最大值**:最大的观测值。 Histogram 对于计算百分位数特别有用,它通过分析每个 bucket 中有多少个值,帮助你估计数据集中的特定位置。 这个概念受到 [Prometheus](https://prometheus.io/docs/concepts/metric_types#histogram) 的启发,它是一个广为人知的监控与告警工具包。 Histogram 在性能分析和系统监控中特别有用。通过考察响应时间、延迟或其他 metric 如何分布,你可以深入了解系统的行为。这些数据有助于你发现异常值、性能瓶颈,或可能需要优化的趋势。 Histogram 的常见使用场景包括: - **百分位估计**:Histogram 让你可以近似计算观测值的百分位数,例如响应时间的第 95 百分位。 - **已知范围**:如果你能提前估计值的范围,Histogram 可以把数据组织到预先定义的 bucket 中,以便更好地分析。 - **性能指标**:使用 Histogram 跟踪请求延迟、内存使用量或吞吐量随时间的变化。 - **聚合**:Histogram 可以跨多个实例聚合,这使它非常适合需要从不同来源收集数据的分布式系统。 **示例**(使用线性 bucket 的 Histogram) 在这个示例中,我们定义了一个使用线性 bucket 的 Histogram,其值的范围从 `0` 到 `100`,步长为 `10`。此外,我们还添加了最后一个用于大于 `100` 的值的 bucket,称为 "Infinity" bucket。这种配置适合在特定范围内跟踪数值,例如请求延迟。 该程序生成 `1` 到 `120` 之间的随机数,把它们记录到 Histogram 中,然后打印 Histogram 的状态,展示落入每个 bucket 的值的数量。 ```ts import { Effect, Metric, MetricBoundaries, Random } from "effect" // Define a histogram to track request latencies, with linear buckets const latency = Metric.histogram( "request_latency", // Buckets from 0-100, with an extra Infinity bucket MetricBoundaries.linear({ start: 0, width: 10, count: 11 }), // Optional "Measures the distribution of request latency.", ) const program = Effect.gen(function* () { // Generate 100 random values and record them in the histogram yield* latency(Random.nextIntBetween(1, 120)).pipe(Effect.repeatN(99)) // Fetch and display the histogram's state const state = yield* Metric.value(latency) console.log(state) }) Effect.runPromise(program) /* Example Output: HistogramState { buckets: [ [ 0, 0 ], // 0 values <= 0 [ 10, 7 ], // 7 values <= 10 (all of them between 1 and 10) [ 20, 11 ], // 11 values <= 20 (4 values between 11 and 20) [ 30, 20 ], // 20 values <= 30 (9 values between 21 and 30) [ 40, 27 ], // and so on... [ 50, 38 ], [ 60, 53 ], [ 70, 64 ], [ 80, 73 ], [ 90, 84 ], [ Infinity, 100 ] // All 100 values have been recorded ], count: 100, // Total count of observed values min: 1, // Smallest observed value max: 119, // Largest observed value sum: 5980, // Sum of all observed values ... } */ ``` ### Timer Metric 在这个示例中,我们演示如何使用 timer metric 跟踪特定工作流的耗时。Timer 会记录某些任务执行了多长时间,并把这些信息存入 Histogram,从而让你了解这些耗时的分布情况。 我们生成随机值来模拟不同的等待时间,把耗时记录到 timer 中,然后打印出 Histogram 的状态。 **示例**(使用 Timer Metric 跟踪工作流耗时) ```ts import { Metric, Array, Random, Effect } from "effect" // Create a timer metric with predefined boundaries from 1 to 10 const timer = Metric.timerWithBoundaries("timer", Array.range(1, 10)) // Define a task that simulates random wait times const task = Effect.gen(function* () { // Generate a random value between 1 and 10 const n = yield* Random.nextIntBetween(1, 10) // Simulate a delay based on the random value yield* Effect.sleep(`${n} millis`) }) const program = Effect.gen(function* () { // Track the duration of the task and repeat it 100 times yield* Metric.trackDuration(task, timer).pipe(Effect.repeatN(99)) // Retrieve and print the current state of the timer histogram const state = yield* Metric.value(timer) console.log(state) }) Effect.runPromise(program) /* Example Output: HistogramState { buckets: [ [ 1, 3 ], // 3 tasks completed in <= 1 ms [ 2, 13 ], // 13 tasks completed in <= 2 ms (10 tasks between 1 and 2 ms) [ 3, 17 ], // and so on... [ 4, 26 ], [ 5, 35 ], [ 6, 43 ], [ 7, 53 ], [ 8, 56 ], [ 9, 65 ], [ 10, 72 ], [ Infinity, 100 ] // All 100 tasks have completed ], count: 100, // Total number of tasks observed min: 0.25797, // Shortest task duration in milliseconds max: 12.25421, // Longest task duration in milliseconds sum: 683.0266810000002, // Total time spent across all tasks ... } */ ``` ## Summary Summary 是一种通过计算特定百分位数来洞察一系列数据点的 metric。百分位数有助于我们理解数据的分布。例如,如果你在跟踪过去一小时内请求的响应时间,可能会想查看第 50、90、95 或 99 百分位数这类关键百分位数,以更好地了解系统的性能。 Summary 与 Histogram 类似,都是观察 `number` 值,但采取的方式不同。Summary 不会立即把值分到各个 bucket 中并丢弃它们,而是把观察到的值保留在内存里。不过,为了避免存储过多数据,Summary 使用两个参数: - **maxAge**:值在被丢弃之前可以存在的最大时长。 - **maxSize**:Summary 中存储的值的最大数量。 这样就形成了一个由近期值组成的滑动窗口,因此 Summary 始终表示固定数量的最近观测值。 Summary 通常用于在这个滑动窗口上计算 **分位数(quantile)**。**分位数**是 `0` 到 `1` 之间的一个数,表示小于或等于某个阈值的值所占的百分比。例如,分位数 `0.5`(即第 50 百分位数)是**中位数**,而 `0.95`(即第 95 百分位数)则表示有 95% 的观测数据落在其之下的那个值。 分位数有助于监控延迟等重要性能指标,也有助于确保系统满足性能目标(例如服务级别协议,即 SLA)。 Effect Metrics API 还允许你为 Summary 配置**误差范围(error margin)**。这个范围会为分位数引入一个可接受值的区间,从而提高结果的准确性。 Summary 在以下情况下特别有用: - 你观察的值的范围事先未知,也无法预估,这使得 Histogram 不太实用。 - 你不需要跨多个实例聚合数据,也不需要平均结果。Summary 在应用侧计算结果,这意味着它们只关注自身被使用的那个具体实例。 **示例**(创建并使用 Summary) 在这个示例中,我们将创建一个 Summary 来跟踪响应时间。这个 Summary 将: - 最多保留 `100` 个样本。 - 丢弃早于 `1 day` 的样本。 - 在计算分位数时具有 `3%` 的误差范围。 - 报告 `10%`、`50%` 和 `90%` 分位数,它们有助于跟踪响应时间的分布。 我们会把这个 Summary 应用到一个生成随机整数、用以模拟响应时间的 effect 上。 ```ts import { Metric, Random, Effect } from "effect" // Define the summary for response times const responseTimeSummary = Metric.summary({ name: "response_time_summary", // Name of the summary metric maxAge: "1 day", // Maximum sample age maxSize: 100, // Maximum number of samples to retain error: 0.03, // Error margin for quantile calculation quantiles: [0.1, 0.5, 0.9], // Quantiles to observe (10%, 50%, 90%) // Optional description: "Measures the distribution of response times", }) const program = Effect.gen(function* () { // Record 100 random response times between 1 and 120 ms yield* responseTimeSummary(Random.nextIntBetween(1, 120)).pipe( Effect.repeatN(99), ) // Retrieve and log the current state of the summary const state = yield* Metric.value(responseTimeSummary) console.log("%o", state) }) Effect.runPromise(program) /* Example Output: SummaryState { error: 0.03, // Error margin used for quantile calculation quantiles: [ [ 0.1, { _id: 'Option', _tag: 'Some', value: 17 } ], // 10th percentile: 17 ms [ 0.5, { _id: 'Option', _tag: 'Some', value: 62 } ], // 50th percentile (median): 62 ms [ 0.9, { _id: 'Option', _tag: 'Some', value: 109 } ] // 90th percentile: 109 ms ], count: 100, // Total number of samples recorded min: 4, // Minimum observed value max: 119, // Maximum observed value sum: 6058, // Sum of all recorded values ... } */ ``` ## Frequency Frequency 是一种帮助统计特定值出现次数的 metric。可以把它们看作一组 Counter,每个 Counter 关联一个唯一的值。当观察到新值时,Frequency metric 会自动为这些值创建新的 Counter。 对于跟踪不同字符串值出现的频率,Frequency 特别有用。一些示例用例包括: - 统计应用中每个服务的调用次数,其中每个服务都有一个逻辑名称。 - 监控不同类型的失败发生的频率。 **示例**(跟踪错误出现次数) 在这个示例中,我们将创建一个 `Frequency` 来观察不同错误码出现的频率。它可以应用于返回 `string` 值的 effect: ```ts import { Metric, Random, Effect } from "effect" // Define a frequency metric to track errors const errorFrequency = Metric.frequency("error_frequency", { // Optional description: "Counts the occurrences of errors.", }) const task = Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) return `Error-${n}` }) // Program that simulates random errors and tracks their occurrences const program = Effect.gen(function* () { yield* errorFrequency(task).pipe(Effect.repeatN(99)) // Retrieve and log the current state of the summary const state = yield* Metric.value(errorFrequency) console.log("%o", state) }) Effect.runPromise(program) /* Example Output: FrequencyState { occurrences: Map(9) { 'Error-7' => 12, 'Error-2' => 12, 'Error-4' => 14, 'Error-1' => 14, 'Error-9' => 8, 'Error-6' => 11, 'Error-5' => 9, 'Error-3' => 14, 'Error-8' => 6 }, ... } */ ``` ## 为 Metric 打标签 标签(tag)是你添加到 metric 上的键值对,用于提供额外的上下文。它们有助于对 metric 进行分类和过滤,让你更容易分析应用性能或行为的特定方面。 在创建 metric 时,你可以为它们添加标签。标签是提供额外上下文的键值对,有助于对 metric 进行分类和过滤。这让你更容易分析和监控应用中的特定方面。 ### 为单个 Metric 打标签 你可以使用 `Metric.tagged` 函数为单个 metric 打标签。 这让你可以为单个 metric 添加特定的标签,提供详细的上下文,而无需全局应用标签。 **示例**(为单个 Metric 打标签) ```ts import { Metric } from "effect" // Create a counter metric for request count // and tag it with "environment: production" const counter = Metric.counter("request_count").pipe( Metric.tagged("environment", "production"), ) ``` 这里,`request_count` metric 带有标签 `"environment": "production"`,让你之后可以按这个标签来过滤或分析 metric。 ### 为多个 Metric 打标签 你可以使用 `Effect.tagMetrics` 把标签应用到同一上下文中的所有 metric。当你想跨多个 metric 应用通用标签(例如环境,如 "production" 或 "development")时,这很有用。 **示例**(为多个 Metric 打标签) ```ts import { Metric, Effect } from "effect" // Create two separate counters const counter1 = Metric.counter("counter1") const counter2 = Metric.counter("counter2") // Define a task that simulates some work with a slight delay const task = Effect.succeed(1).pipe(Effect.delay("100 millis")) // Apply the environment tag to both counters in the same context Effect.gen(function* () { yield* counter1(task) yield* counter2(task) }).pipe(Effect.tagMetrics("environment", "production")) ``` 如果你只想在特定的 [scope](/docs/v3/resource-management/scope/) 内应用标签,可以使用 `Effect.tagMetricsScoped`。这会把标签的应用限制在该 scope 内的 metric 上,从而实现更精确的标签控制。 --- # Supervisor > Effect 的 Supervisor 负责管理 Fiber 的生命周期,让你能够跟踪、监控并控制应用内 Fiber 的行为。 `Supervisor` 是 Effect 中用于管理 Fiber 的工具,它让你能够跟踪 Fiber 的生命周期(创建与终止),并产出一个类型为 `A` 的值来反映这种监督。当你需要洞察或控制应用中 Fiber 的行为时,Supervisor 会很有用。 要创建一个 supervisor,可以使用 `Supervisor.track` 函数。它会生成一个新的 supervisor,用来跟踪其子 Fiber,并把它们维护在一个集合中。这样你就可以在执行过程中观察和监控它们的状态。 你可以使用 `Effect.supervised` 函数来监督一个 effect。该函数接收一个 supervisor 作为参数,并返回一个 effect,其中在该 effect 内 fork 出来的所有子 Fiber 都由所提供的 supervisor 监督。由此,你可以通过 supervisor 捕获这些子 Fiber 的详细信息,例如它们的状态。 **示例**(监控 Fiber 数量) 在这个示例中,我们将使用一个 supervisor 定期监控应用中正在运行的 Fiber 数量。程序会计算一个斐波那契数,在此过程中生成多个 Fiber,同时另有一个监控器跟踪 Fiber 的数量。 ```ts import { Effect, Supervisor, Schedule, Fiber, FiberStatus } from "effect" // Main program that monitors fibers while calculating a Fibonacci number const program = Effect.gen(function* () { // Create a supervisor to track child fibers const supervisor = yield* Supervisor.track // Start a Fibonacci calculation, supervised by the supervisor const fibFiber = yield* fib(20).pipe( Effect.supervised(supervisor), // Fork the Fibonacci effect into a fiber Effect.fork, ) // Define a schedule to periodically monitor the fiber count every 500ms const policy = Schedule.spaced("500 millis").pipe( Schedule.whileInputEffect((_) => Fiber.status(fibFiber).pipe( // Continue while the Fibonacci fiber is not done Effect.andThen((status) => status !== FiberStatus.done), ), ), ) // Start monitoring the fibers, using the supervisor to track the count const monitorFiber = yield* monitorFibers(supervisor).pipe( // Repeat the monitoring according to the schedule Effect.repeat(policy), // Fork the monitoring into its own fiber Effect.fork, ) // Join the monitor and Fibonacci fibers to ensure they complete yield* Fiber.join(monitorFiber) const result = yield* Fiber.join(fibFiber) console.log(`fibonacci result: ${result}`) }) // Function to monitor and log the number of active fibers const monitorFibers = ( supervisor: Supervisor.Supervisor>>, ): Effect.Effect => Effect.gen(function* () { const fibers = yield* supervisor.value // Get the current set of fibers console.log(`number of fibers: ${fibers.length}`) }) // Recursive Fibonacci calculation, spawning fibers for each recursive step const fib = (n: number): Effect.Effect => Effect.gen(function* () { if (n <= 1) { return 1 } yield* Effect.sleep("500 millis") // Simulate work by delaying // Fork two fibers for the recursive Fibonacci calls const fiber1 = yield* Effect.fork(fib(n - 2)) const fiber2 = yield* Effect.fork(fib(n - 1)) // Join the fibers to retrieve their results const v1 = yield* Fiber.join(fiber1) const v2 = yield* Fiber.join(fiber2) return v1 + v2 // Combine the results }) Effect.runPromise(program) /* Output: number of fibers: 0 number of fibers: 2 number of fibers: 6 number of fibers: 14 number of fibers: 30 number of fibers: 62 number of fibers: 126 number of fibers: 254 number of fibers: 510 number of fibers: 1022 number of fibers: 2034 number of fibers: 3795 number of fibers: 5810 number of fibers: 6474 number of fibers: 4942 number of fibers: 2515 number of fibers: 832 number of fibers: 170 number of fibers: 18 number of fibers: 0 fibonacci result: 10946 */ ``` --- # Effect 中的追踪 > 探索分布式系统中的追踪,使用 span 和 trace 跨服务追踪请求的生命周期,以便进行调试和性能优化。 尽管日志和指标有助于理解单个服务的行为,但它们不足以完整呈现分布式系统中一个请求的生命周期。 在分布式系统中,一个请求可能跨越多个服务,而每个服务为了完成该请求也可能向其他服务发起多次请求。在这种情况下,我们需要一种方法来追踪请求在多个服务之间的生命周期,从而诊断哪些服务是瓶颈,以及请求把大部分时间花在了哪里。 ## Span **span** 表示一个请求中的单个工作单元或操作。它详细呈现了该特定操作执行期间发生了什么。 每个 span 通常包含以下信息: | Span 组件 | 说明 | | ---------------- | ------------------------------------------------------------------ | | **Name** | 描述正在追踪的具体操作。 | | **Timing Data** | 指示操作开始时间的时间戳及其持续时间。 | | **Log Messages** | 捕获操作期间重要事件的结构化日志。 | | **Attributes** | 提供该操作附加上下文的元数据。 | span 是追踪中的关键构建块,帮助你可视化和理解请求在各种服务之间的流转。 ## Trace Trace 记录请求(由应用程序或最终用户发起)在微服务、无服务器应用等多服务架构中传播时所经过的路径。 如果没有追踪,就很难在分布式系统中定位性能问题的根因。 Trace 由一个或多个 span 组成。第一个 span 表示根 span。每个根 span 都表示一个从开始到结束的完整请求。父 span 之下的各个 span 提供了更深入的上下文,说明请求期间发生了什么(或者说一个请求由哪些步骤构成)。 许多可观测性后端会把 trace 可视化为瀑布图,大致如下所示: ![Trace 瀑布图](../_assets/waterfall-trace.svg "一张以瀑布图形式展示应用 trace 的图片") 瀑布图展示了根 span 与其子 span 之间的父子关系。当一个 span 包裹另一个 span 时,这也表示一种嵌套关系。 ## 创建 Span 你可以使用 `Effect.withSpan` API 创建一个 span,从而为 effect 添加追踪能力。这有助于你追踪 effect 中的特定操作。 **示例**(为 Effect 添加 Span) ```ts import { Effect } from "effect" // Define an effect that delays for 100 milliseconds const program = Effect.void.pipe(Effect.delay("100 millis")) // Instrument the effect with a span for tracing const instrumented = program.pipe(Effect.withSpan("myspan")) ``` 用 span 对 effect 进行插桩不会改变其类型。如果你传入的是 `Effect`,结果仍然是 `Effect`。 ## 打印 Span 为了调试或分析而打印 span,你需要安装所需的追踪工具。以下是为你的项目配置它们的方法。 ### 安装依赖 选择你的包管理器并安装所需的库: ```sh # Install the main library for integrating OpenTelemetry with Effect npm install @effect/opentelemetry # Install the required OpenTelemetry SDKs for tracing and metrics npm install @opentelemetry/sdk-trace-base npm install @opentelemetry/sdk-trace-node npm install @opentelemetry/sdk-trace-web npm install @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect pnpm add @effect/opentelemetry # Install the required OpenTelemetry SDKs for tracing and metrics pnpm add @opentelemetry/sdk-trace-base pnpm add @opentelemetry/sdk-trace-node pnpm add @opentelemetry/sdk-trace-web pnpm add @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect yarn add @effect/opentelemetry # Install the required OpenTelemetry SDKs for tracing and metrics yarn add @opentelemetry/sdk-trace-base yarn add @opentelemetry/sdk-trace-node yarn add @opentelemetry/sdk-trace-web yarn add @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect bun add @effect/opentelemetry # Install the required OpenTelemetry SDKs for tracing and metrics bun add @opentelemetry/sdk-trace-base bun add @opentelemetry/sdk-trace-node bun add @opentelemetry/sdk-trace-web bun add @opentelemetry/sdk-metrics ``` ### 将 Span 打印到控制台 依赖安装完成后,就可以使用 OpenTelemetry 配置 span 打印。下面的示例展示了如何为 effect 打印 span。 **示例**(设置并打印 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" // Define an effect that delays for 100 milliseconds const program = Effect.void.pipe(Effect.delay("100 millis")) // Instrument the effect with a span for tracing const instrumented = program.pipe(Effect.withSpan("myspan")) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, // Export span data to the console spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(instrumented.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: '673c06608bd815f7a75bf897ef87e186', parentId: undefined, traceState: undefined, name: 'myspan', id: '401b2846170cd17b', kind: 0, timestamp: 1733220735529855.5, duration: 102079.958, attributes: {}, status: { code: 1 }, events: [], links: [] } */ ``` ### 理解 Span 输出 输出中提供了关于该 span 的详细信息: | 字段 | 说明 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `traceId` | 整个 trace 的唯一标识符,帮助在请求或操作流经应用时对其进行追踪。 | | `parentId` | 标识当前 span 的父 span;当没有父 span 时,输出中会标记为 `undefined`,从而说明它是一个根 span。 | | `name` | 描述 span 的名称,指示正在追踪的操作(例如 “myspan”)。 | | `id` | 当前 span 的唯一标识符,用于将其与同一 trace 中的其他 span 区分开。 | | `timestamp` | 表示 span 开始时间的时间戳,以自 Unix 纪元以来的微秒数计量。 | | `duration` | 指定 span 的持续时间,表示完成该操作所花费的时间(例如 `2895.769` 微秒)。 | | `attributes` | span 可以包含 attributes,它们是提供操作附加上下文或信息的键值对。在此输出中,它是一个空对象,表示这个 span 没有任何特定的 attributes。 | | `status` | status 字段提供 span 状态的信息。在此例中,它的 code 为 1,通常表示 OK 状态(而 code 为 2 表示 ERROR 状态)。 | | `events` | span 可以包含 events,它们是 span 生命周期中特定时刻的记录。在此输出中,它是一个空数组,表示没有记录任何特定事件。 | | `links` | links 可用于将这个 span 与其他 trace 中的 span 关联起来。在输出中,它是一个空数组,表示这个 span 没有特定的 links。 | ### Span 捕获错误 下面是 effect 遇到错误时 span 呈现的样子: **示例**(失败 Effect 的 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const program = Effect.fail("Oh no!").pipe( Effect.delay("100 millis"), Effect.withSpan("myspan"), ) const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) Effect.runPromiseExit(program.pipe(Effect.provide(NodeSdkLive))).then( console.log, ) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'eee9619866179f209b7aae277283e71f', parentId: undefined, traceState: undefined, name: 'myspan', id: '3a5725c91884c9e1', kind: 0, timestamp: 1733220830575626, duration: 106578.042, attributes: { 'code.stacktrace': 'at (/Users/giuliocanti/Documents/GitHub/website/content/dev/index.ts:10:10)' }, status: { code: 2, message: 'Oh no!' }, events: [ { name: 'exception', attributes: { 'exception.type': 'Error', 'exception.message': 'Oh no!', 'exception.stacktrace': 'Error: Oh no!' }, time: [ 1733220830, 682204083 ], droppedAttributesCount: 0 } ], links: [] } { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' } } */ ``` 在这个示例中,span 的 status code 为 `2`,表示发生了错误。status 中的 message 提供了关于该失败的更多细节。 ## 添加注解 你可以使用 `Effect.annotateCurrentSpan` 函数为 span 提供额外信息。 该函数允许你附加键值对,为 span 的执行提供更多上下文。 **示例**(为 Span 添加注解) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const program = Effect.void.pipe( Effect.delay("100 millis"), // Annotate the span with a key-value pair Effect.tap(() => Effect.annotateCurrentSpan("key", "value")), // Wrap the effect in a span named 'myspan' Effect.withSpan("myspan"), ) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(program.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'c8120e01c0f1ea83ccc1d388e5cdebd3', parentId: undefined, traceState: undefined, name: 'myspan', id: '81c430ba4979f1db', kind: 0, timestamp: 1733220874356084, duration: 102821.417, attributes: { key: 'value' }, status: { code: 1 }, events: [], links: [] } */ ``` ## 日志即事件 在追踪的语境中,日志会被转换为 “Span Events”。这些事件以结构化方式揭示应用的活动,并提供特定操作发生时间的时间线。 ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" // Define a program that logs a message and delays for 100 milliseconds const program = Effect.log("Hello").pipe( Effect.delay("100 millis"), Effect.withSpan("myspan"), ) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(program.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'b0f4f012b5b13c0a040f7002a1d7b020', parentId: undefined, traceState: undefined, name: 'myspan', id: 'b9ba8472002715a8', kind: 0, timestamp: 1733220905504162.2, duration: 103790, attributes: {}, status: { code: 1 }, events: [ { name: 'Hello', attributes: { 'effect.fiberId': '#0', 'effect.logLevel': 'INFO' }, // Log attributes time: [ 1733220905, 607761042 ], // Event timestamp droppedAttributesCount: 0 } ], links: [] } */ ``` 每个 span 都可以包含 events,它们捕获 span 执行过程中的特定时刻。在这个示例中,一条日志消息 `"Hello"` 被记录为该 span 内的一个事件。该事件的关键细节包括: | 字段 | 说明 | | ------------------------ | ------------------------------------------------------------------------------------------------- | | `name` | 事件的名称,与所记录的日志消息对应(例如 `'Hello'`)。 | | `attributes` | 提供事件附加上下文的键值对,例如 `fiberId` 和日志级别。 | | `time` | 事件发生的时间戳,以高精度格式显示。 | | `droppedAttributesCount` | 表示有多少 attributes 被丢弃(如果有的话)。在此例中,没有 attributes 被丢弃。 | ## 嵌套 Span span 可以嵌套,以表示操作的层次结构。这让你能够追踪应用的不同部分在执行期间如何相互关联。下面的示例演示了如何创建和管理嵌套 span。 **示例**(在 Trace 中嵌套 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const child = Effect.void.pipe( Effect.delay("100 millis"), Effect.withSpan("child"), ) const parent = Effect.gen(function* () { yield* Effect.sleep("20 millis") yield* child yield* Effect.sleep("10 millis") }).pipe(Effect.withSpan("parent")) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(parent.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'a9cd69ad70698a0c7b7b774597c77d39', parentId: 'a09e5c3fdfdbbc1d', // This indicates the span is a child of 'parent' traceState: undefined, name: 'child', id: '210d2f9b648389a4', // Unique ID for the child span kind: 0, timestamp: 1733220970590126.2, duration: 101579.875, attributes: {}, status: { code: 1 }, events: [], links: [] } { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'a9cd69ad70698a0c7b7b774597c77d39', parentId: undefined, // Indicates this is the root span traceState: undefined, name: 'parent', id: 'a09e5c3fdfdbbc1d', // Unique ID for the parent span kind: 0, timestamp: 1733220970569015.2, duration: 132612.208, attributes: {}, status: { code: 1 }, events: [], links: [] } */ ``` 父子关系在 span 输出中清晰可见:`child` span 的 `parentId` 与 `parent` span 的 `id` 相匹配。这种结构有助于追踪单个 trace 内各操作之间的关联关系。 ## 教程:可视化 Trace 在本教程中,我们将带你一步步可视化一个示例 Effect 应用生成的 Trace。这个示例应用还被配置为通过 HTTP 以 [OTLP 格式](https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md)导出 Trace 和/或指标。 为了可视化应用导出的 Trace,我们将使用一个 Docker 镜像,其中包含一套预配置的 OpenTelemetry 后端,它基于 [OpenTelemetry Collector](https://opentelemetry.io/docs/collector)、[Prometheus](https://github.com/prometheus/prometheus)、[Loki](https://github.com/grafana/loki)、[Tempo](https://github.com/grafana/tempo) 和 [Grafana](https://github.com/grafana/grafana)。 ### 工具说明 让我们用通俗的语言来理解将要使用的这些工具: - **Docker**:Docker 让我们可以在容器中运行应用。可以把容器看作一个轻量且隔离的环境,无论宿主机系统是什么,你的应用都能在其中一致地运行。它有点像虚拟机,但更高效。 - **Prometheus**:Prometheus 是一个监控与告警工具包。它会收集应用的指标与数据并存储起来,以便进一步分析。这有助于发现性能问题、理解应用的行为。 - **Loki**:Loki 是一个受 Prometheus 启发的日志聚合系统。它不会为日志内容建立索引,而是为每个日志流的一组标签建立索引。 - **Grafana**:Grafana 是一个可视化与分析平台。它有助于创建美观且可交互的仪表盘,用来可视化应用的数据。你可以用它以图形方式展示 Prometheus 收集的指标。 - **Tempo**:Tempo 是一个分布式追踪系统,让你能够追踪一个请求在应用中流转的全过程。它提供关于请求如何被处理的洞察,并帮助你调试和优化应用。 ### 获取 Docker 要获取 Docker,请按以下步骤操作: 1. 访问 Docker 网站 [https://www.docker.com/](https://www.docker.com/)。 2. 下载适用于你的操作系统(Windows 或 macOS)的 Docker Desktop 并安装。 3. 安装完成后,打开 Docker Desktop,它会在后台运行。 ### 模拟 Trace 1. **启动 OpenTelemetry 后端** 在开始从示例应用生成并导出 Trace 之前,我们需要先在 Docker 中把 OpenTelemetry 后端运行起来。 可以用下面的命令完成: ```sh docker run -p 3000:3000 -p 4317:4317 -p 4318:4318 --rm -it docker.io/grafana/otel-lgtm ``` 2. **安装依赖** 我们还需要安装一些额外的依赖,以及最新版本的 `effect`: ```sh # If not already installed npm install effect # Required to integrate Effect with OpenTelemetry npm install @effect/opentelemetry # Required to export traces over HTTP in OTLP format npm install @opentelemetry/exporter-trace-otlp-http # Required by all applications npm install @opentelemetry/sdk-trace-base # For NodeJS applications npm install @opentelemetry/sdk-trace-node # For browser applications npm install @opentelemetry/sdk-trace-web # If you also need to export metrics npm install @opentelemetry/sdk-metrics ``` ```sh # If not already installed pnpm add effect # Required to integrate Effect with OpenTelemetry pnpm add @effect/opentelemetry # Required to export traces over HTTP in OTLP format pnpm add @opentelemetry/exporter-trace-otlp-http # Required by all applications pnpm add @opentelemetry/sdk-trace-base # For NodeJS applications pnpm add @opentelemetry/sdk-trace-node # For browser applications pnpm add @opentelemetry/sdk-trace-web # If you also need to export metrics pnpm add @opentelemetry/sdk-metrics ``` ```sh # If not already installed yarn add effect # Required to integrate Effect with OpenTelemetry yarn add @effect/opentelemetry # Required to export traces over HTTP in OTLP format yarn add @opentelemetry/exporter-trace-otlp-http # Required by all applications yarn add @opentelemetry/sdk-trace-base # For NodeJS applications yarn add @opentelemetry/sdk-trace-node # For browser applications yarn add @opentelemetry/sdk-trace-web # If you also need to export metrics yarn add @opentelemetry/sdk-metrics ``` ```sh # If not already installed bun add effect # Required to integrate Effect with OpenTelemetry bun add @effect/opentelemetry # Required to export traces over HTTP in OTLP format bun add @opentelemetry/exporter-trace-otlp-http # Required by all applications bun add @opentelemetry/sdk-trace-base # For NodeJS applications bun add @opentelemetry/sdk-trace-node # For browser applications bun add @opentelemetry/sdk-trace-web # If you also need to export metrics bun add @opentelemetry/sdk-metrics ``` 3. **模拟 Trace** 现在,让我们用一个示例 Node.js 应用来模拟 Trace。 下面的代码模拟了一组任务,并为每个任务生成 Trace。它还设置了一个 `Layer`,用于通过 HTTP 以 OTLP 格式把应用中的 Trace 导出到我们的 OpenTelemetry 后端。 ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" // Function to simulate a task with possible subtasks const task = ( name: string, delay: number, children: ReadonlyArray> = [], ) => Effect.gen(function* () { yield* Effect.log(name) yield* Effect.sleep(`${delay} millis`) for (const child of children) { yield* child } yield* Effect.sleep(`${delay} millis`) }).pipe(Effect.withSpan(name)) const poll = task("/poll", 1) // Create a program with tasks and subtasks const program = task("client", 2, [ task("/api", 3, [ task("/authN", 4, [task("/authZ", 5)]), task("/payment Gateway", 6, [task("DB", 7), task("Ext. Merchant", 8)]), task("/dispatch", 9, [ task("/dispatch/search", 10), Effect.all([poll, poll, poll], { concurrency: "inherit" }), task("/pollDriver/{id}", 11), ]), ]), ]) const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter()), })) Effect.runPromise( program.pipe( Effect.provide(NodeSdkLive), Effect.catchAllCause(Effect.logError), ), ) /* Output: timestamp=... level=INFO fiber=#0 message=client timestamp=... level=INFO fiber=#0 message=/api timestamp=... level=INFO fiber=#0 message=/authN timestamp=... level=INFO fiber=#0 message=/authZ timestamp=... level=INFO fiber=#0 message="/payment Gateway" timestamp=... level=INFO fiber=#0 message=DB timestamp=... level=INFO fiber=#0 message="Ext. Merchant" timestamp=... level=INFO fiber=#0 message=/dispatch timestamp=... level=INFO fiber=#0 message=/dispatch/search timestamp=... level=INFO fiber=#3 message=/poll timestamp=... level=INFO fiber=#4 message=/poll timestamp=... level=INFO fiber=#5 message=/poll timestamp=... level=INFO fiber=#0 message=/pollDriver/{id} */ ``` 4. **可视化 Trace** 打开浏览器并访问 `http://localhost:3000/explore`。你应该会看到 Grafana Tempo 的 TraceQL 界面。 ![Tempo TraceQL 界面](../_assets/tempo-traceql-interface.png "未指定 TraceQL 查询时的 Grafana Tempo TraceQL 界面") 要获取所有可用 Trace 的列表,我们可以选择 `"Search"` 查询类型,从而得到所有可用 Trace 的列表。 ![Tempo 搜索选择器](../_assets/tempo-trace-list.png "Grafana Tempo TraceQL 界面,其中 Search 选择器用红框标出") 点击生成的 Trace ID,就可以查看该 Trace 的详细信息。 ![Grafana Tempo 中的 Trace](../_assets/trace.png "以瀑布图形式在 Grafana Tempo 中可视化的 Effect 应用 Trace 详情") ## 集成 ### Sentry 要把 Span 数据直接发送到 Sentry 进行分析,请把默认的 span processor 替换为 Sentry 的实现。这样你就可以把 Sentry 用作追踪与调试的后端。 **示例**(为追踪配置 Sentry) ```ts import { NodeSdk } from "@effect/opentelemetry" import { SentrySpanProcessor } from "@sentry/opentelemetry" const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new SentrySpanProcessor(), })) ``` --- # 欢迎来到 Effect > Effect 是一个用于构建生产级软件的 TypeScript 库——类型化错误处理、结构化并发、资源安全与可观测性,全都来自同一个可组合的核心。 Effect 是一个用于构建生产级软件的 TypeScript 库:类型化错误处理、结构化并发、资源安全与可观测性,全都来自同一个可组合的核心。 ## 为什么选择 Effect? ### 数据有类型,程序无类型 TypeScript 非常擅长描述你的数据,但它对你的程序几乎只字不提:一个函数的签名不会告诉你它可能出什么错、需要哪些依赖,也无法说明它能否被安全地重试、超时或中断。随着应用不断增长,团队最终只能用临时拼凑的 try/catch、缺乏结构的 Promise 以及彼此无法组合的库,手工去实现这些保障。 ### 程序即值 Effect 用一个构建单元填补了这一空白:`Effect` 类型,它是一个完整描述程序的值,包括它的成功值、可能出现的错误,以及运行它所需的依赖。正因为程序是值,它们才能够组合:重试、超时、并发、资源管理和链路追踪都是你可以施加的运算符,而不是需要反复重建的架构。
开箱即用
类型化错误
失败会体现在签名中,并且可以像数据一样被处理。
重试与调度
用可组合的退避策略,取代你自己编写的循环。
结构化并发
有界的并行任务,并且会自行善后。
资源安全
资源的获取与释放都有保证,即使出现失败也不例外。
依赖注入
服务通过类型系统串联起来,在测试中替换它们轻而易举。
可观测性
运行时内置链路追踪、指标和结构化日志。
流式处理
支持背压的 Stream 与其他一切共享同一套运算符。
Schema 校验
用与现实相符的类型来解析和转换数据。
配置
从环境中读取的类型化配置,在启动时完成校验,敏感信息会被 脱敏。
一致的生态系统
HTTP、SQL、CLI、AI 和平台相关的包都构建在同一个核心之上。
### 为 AI 时代而生 AI 时代让这一切变得更为关键。当编码智能体编写的软件在你项目中的占比越来越大时,瓶颈就从写代码转移到了信任代码。Effect 让程序的失败模式、依赖关系和生命周期对编译器可见,把运行时的意外转化为智能体可以据以行动的精确反馈。而当你正在构建的东西本身就是 AI 应用时,不稳定的服务提供方、重试、流式传输和速率限制,正是 Effect 开箱即用地解决的问题。 ## 你的学习路径 Effect 值得按顺序学习:每一步都建立在前一步之上。沿着这条主线走下来,大多数开发者只需专注投入几天;其余内容都从这里分叉出去。
  1. 理解核心思想 Effect 是一个描述程序的值:它产出什么、可能如何失败、运行需要 什么。其他一切都建立在这一个类型之上。
  2. 搭建你的项目 安装这个库并配置 TypeScript。Effect 只是一个依赖,无需任何 额外工具。
  3. 编写你的第一个程序 创建 Effect,用生成器把它们组合起来,并在应用的边界处运行 它们。
  4. 用 Effect 的方式处理错误 错误是有类型的值,而不是意外。了解预期失败与非预期失败、 回退方案以及重试。
  5. 进入并发世界 以有界并发并行运行 Effect,让它们相互竞速,并交给结构化并发 为你善后。
下面就是你在第一个小时内会写出的那种程序: ```ts import { Effect } from "effect" const program = Effect.gen(function* () { const name = yield* Effect.succeed("world") yield* Effect.log("Hello, " + name + "!") }) Effect.runPromise(program) ``` ## 继续前进 ## 加入我们的社区 Effect 社区非常活跃:核心团队和经验丰富的用户每天都在那里,任何问题都不会被忽视。中文读者可以加入[中文社区微信群](/community/)直接提问,也可以在官方的 [GitHub 仓库](https://github.com/Effect-TS) 上参与讨论。 --- # Command > 了解如何在 Effect 中创建、运行和管理命令,包括自定义参数、环境变量以及输入/输出的处理。 `@effect/platform/Command` 模块提供了一种创建并运行命令的方式,你可以在其中指定进程名以及一个可选的参数列表。 ## 创建命令 `Command.make` 函数会生成一个命令对象,其中包含进程名、参数以及环境等细节。 **示例**(为目录列表定义一个命令) ```ts import { Command } from "@effect/platform" const command = Command.make("ls", "-al") console.log(command) /* { _id: '@effect/platform/Command', _tag: 'StandardCommand', command: 'ls', args: [ '-al' ], env: {}, cwd: { _id: 'Option', _tag: 'None' }, shell: false, gid: { _id: 'Option', _tag: 'None' }, uid: { _id: 'Option', _tag: 'None' } } */ ``` 该命令对象在被执行器(executor)运行之前不会真正执行。 ## 运行命令 运行命令需要一个 `CommandExecutor`,它能够以字符串、行或流等多种格式捕获输出。 **示例**(运行命令并打印输出) ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const command = Command.make("ls", "-al") // The program depends on a CommandExecutor const program = Effect.gen(function* () { // Runs the command returning the output as a string const output = yield* Command.string(command) console.log(output) }) // Provide the necessary CommandExecutor NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) ``` ### 输出格式 你可以选择不同的方法来处理命令的输出: | 方法 | 说明 | | ------------- | ---------------------------------------------------------------------------------------- | | `string` | 运行命令,并以字符串形式返回输出(使用指定的编码) | | `lines` | 运行命令,并以行的数组形式返回输出(使用指定的编码) | | `stream` | 运行命令,并以 `Uint8Array` 数据块组成的流形式返回输出 | | `streamLines` | 运行命令,并以行的流形式返回输出(使用指定的编码) | ### exitCode 如果你只需要命令的退出码,请使用 `Command.exitCode`。 **示例**(获取退出码) ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const command = Command.make("ls", "-al") const program = Effect.gen(function* () { const exitCode = yield* Command.exitCode(command) console.log(exitCode) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) // Output: 0 ``` ## 自定义环境变量 你可以使用 `Command.env` 自定义命令的环境变量。当你需要为命令的执行指定特定的变量时,这很有用。 **示例**(设置环境变量) 在这个例子中,命令在一个 shell 中运行,以确保环境变量被正确处理。 ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const command = Command.make("echo", "-n", "$MY_CUSTOM_VAR").pipe( Command.env({ MY_CUSTOM_VAR: "Hello, this is a custom environment variable!", }), // Use shell to interpret variables correctly // on Windows and Unix-like systems Command.runInShell(true), ) const program = Effect.gen(function* () { const output = yield* Command.string(command) console.log(output) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) // Output: Hello, this is a custom environment variable! ``` ## 向命令提供输入 你可以使用 `Command.feed` 函数把输入直接发送到命令的标准输入。 **示例**(将输入发送到命令的标准输入) ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const command = Command.make("cat").pipe(Command.feed("Hello")) const program = Effect.gen(function* () { console.log(yield* Command.string(command)) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) // Output: Hello ``` ## 获取进程详情 你可以访问正在运行的进程的详细信息,例如 `exitCode`、`stdout` 和 `stderr`。 **示例**(访问正在运行进程的退出码与流) ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect, Stream, String, pipe } from "effect" // Helper function to collect stream output as a string const runString = ( stream: Stream.Stream, ): Effect.Effect => stream.pipe(Stream.decodeText(), Stream.runFold(String.empty, String.concat)) const program = Effect.gen(function* () { const command = Command.make("ls") const [exitCode, stdout, stderr] = yield* pipe( // Start running the command and return a handle to the running process Command.start(command), Effect.flatMap((process) => Effect.all( [ // Waits for the process to exit and returns // the ExitCode of the command that was run process.exitCode, // The standard output stream of the process runString(process.stdout), // The standard error stream of the process runString(process.stderr), ], { concurrency: 3 }, ), ), ) console.log({ exitCode, stdout, stderr }) }) NodeRuntime.runMain( Effect.scoped(program).pipe(Effect.provide(NodeContext.layer)), ) ``` ## 将 stdout 流式传输到 process.stdout 要把命令的 `stdout` 直接流式传输到 `process.stdout`,可以采用下面的做法: **示例**(将命令输出直接流式传输到标准输出) ```ts import { Command } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" // Create a command to run `cat` on a file and inherit stdout const program = Command.make("cat", "./some-file.txt").pipe( Command.stdout("inherit"), // Stream stdout to process.stdout Command.exitCode, // Get the exit code ) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) ``` --- # FileSystem > 探索 Effect 中用于读取、写入和管理文件与目录的文件系统操作。 `@effect/platform/FileSystem` 模块提供了一组用于从文件系统读取以及向文件系统写入的操作。 ## 基本用法 该模块只提供一个 `FileSystem` [tag](/docs/v3/requirements-management/services/),它是与文件系统交互的入口。 **示例**(访问文件系统操作) ```ts import { FileSystem } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem // Use `fs` to perform file system operations }) ``` `FileSystem` 接口包含以下操作: | 操作 | 说明 | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **access** | 检查文件是否可以被访问。你可以选择性地指定要检查的访问级别。 | | **copy** | 将文件或目录从 `fromPath` 复制到 `toPath`。等价于 `cp -r`。 | | **copyFile** | 将文件从 `fromPath` 复制到 `toPath`。 | | **chmod** | 更改文件的权限。 | | **chown** | 更改文件的所有者和所属组。 | | **exists** | 检查某个路径是否存在。 | | **link** | 从 `fromPath` 到 `toPath` 创建硬链接。 | | **makeDirectory** | 在 `path` 处创建目录。你可以选择性地指定权限模式以及是否递归创建嵌套目录。 | | **makeTempDirectory** | 创建一个临时目录。默认情况下,该目录会创建在系统的默认临时目录中。 | | **makeTempDirectoryScoped** | 在 scope 内创建一个临时目录。功能上等价于 `makeTempDirectory`,但当 scope 关闭时该目录会被自动删除。 | | **makeTempFile** | 创建一个临时文件。其目录创建方式在功能上等价于 `makeTempDirectory`。文件名将是一个随机生成的字符串。 | | **makeTempFileScoped** | 在 scope 内创建一个临时文件。功能上等价于 `makeTempFile`,但当 scope 关闭时该文件会被自动删除。 | | **open** | 以指定的 `options` 打开 `path` 处的文件。当 scope 关闭时,文件句柄会被自动关闭。 | | **readDirectory** | 列出目录的内容。你可以通过设置 `recursive` 选项来递归列出嵌套目录的内容。 | | **readFile** | 读取文件的内容。 | | **readFileString** | 以字符串形式读取文件的内容。 | | **readLink** | 读取符号链接的目标。 | | **realPath** | 将路径解析为规范化的绝对路径名。 | | **remove** | 删除文件或目录。通过将 `recursive` 选项设为 `true`,你可以递归删除嵌套目录。 | | **rename** | 重命名文件或目录。 | | **sink** | 为指定的 `path` 创建一个可写的 `Sink`。 | | **stat** | 获取 `path` 处文件的信息。 | | **stream** | 为指定的 `path` 创建一个可读的 `Stream`。 | | **symlink** | 从 `fromPath` 到 `toPath` 创建符号链接。 | | **truncate** | 将文件截断到指定长度。如果未指定 `length`,文件将被截断为长度 `0`。 | | **utimes** | 更改 `path` 处文件的文件系统时间戳。 | | **watch** | 监视目录或文件的变化。 | | **writeFile** | 将数据写入 `path` 处的文件。 | | **writeFileString** | 将字符串写入 `path` 处的文件。 | **示例**(以字符串形式读取文件) ```ts import { FileSystem } from "@effect/platform" import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem // Reading the content of the same file where this code is written const content = yield* fs.readFileString("./index.ts", "utf8") console.log(content) }) // Provide the necessary context and run the program NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) ``` ## 模拟文件系统 在测试环境中,你可能希望模拟(mock)文件系统,以避免执行真实的磁盘操作。`FileSystem.layerNoop` 提供了 `FileSystem` 服务的空操作实现。 `FileSystem.layerNoop` 中的大多数操作会返回 **failure**(例如对缺失文件返回 `Effect.fail`)或 **defect**(例如对未实现的功能返回 `Effect.die`)。 不过,你可以通过向 `FileSystem.layerNoop` 传入一个对象,为选定的方法定义自定义返回值,从而覆盖特定的行为。 **示例**(以自定义行为模拟文件系统) ```ts import { FileSystem } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const exists = yield* fs.exists("/some/path") console.log(exists) const content = yield* fs.readFileString("/some/path") console.log(content) }) // ┌─── Layer // ▼ const customMock = FileSystem.layerNoop({ readFileString: () => Effect.succeed("mocked content"), exists: (path) => Effect.succeed(path === "/some/path"), }) // Provide the customized FileSystem mock implementation Effect.runPromise(program.pipe(Effect.provide(customMock))) /* Output: true mocked content */ ``` --- # Effect Platform 简介 > 使用 @effect/platform 的统一抽象,为 Node.js、Deno、Bun 和浏览器构建跨平台应用。 `@effect/platform` 是一个用于在 Node.js、Deno、Bun 和浏览器等环境中构建与平台无关的抽象的库。 借助 `@effect/platform`,你可以把 [FileSystem](/docs/v3/platform/file-system/) 或 [Terminal](/docs/v3/platform/terminal/) 这类抽象服务集成到你的程序中。 在组装最终应用时,你可以使用对应的包,为目标平台提供具体的 [layers](/docs/v3/requirements-management/layers/): - `@effect/platform-node`,用于 Node.js 或 Deno - `@effect/platform-bun`,用于 Bun - `@effect/platform-browser`,用于浏览器 ### 稳定模块 以下模块已经稳定,它们的文档可以在本站查阅: | 模块 | 说明 | 状态 | | --------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------- | | [Command](/docs/v3/platform/command/) | 提供与命令行交互的方式。 | | | [FileSystem](/docs/v3/platform/file-system/) | 一套用于文件系统操作的模块。 | | | [KeyValueStore](/docs/v3/platform/key-value-store/) | 管理用于数据存储的键值对。 | | | [Path](/docs/v3/platform/path/) | 处理文件路径的工具。 | | | [PlatformLogger](/docs/v3/platform/platformlogger/) | 使用 FileSystem API 把日志消息写入文件。 | | | [Runtime](/docs/v3/platform/runtime/) | 以内置的错误处理与日志功能运行你的程序。 | | | [Terminal](/docs/v3/platform/terminal/) | 用于终端交互的工具。 | | ### 不稳定模块 `@effect/platform` 中还有一些模块仍在开发中,或被标记为实验性。 这些特性可能会发生变化。 | 模块 | 说明 | 状态 | | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------- | | [Http API](https://github.com/Effect-TS/effect/blob/v3/packages/platform/README.md#http-api) | 以声明式的方式定义 HTTP API。 | | | [Http Client](https://github.com/Effect-TS/effect/blob/v3/packages/platform/README.md#http-client) | 用于发起 HTTP 请求的客户端。 | | | [Http Server](https://github.com/Effect-TS/effect/blob/v3/packages/platform/README.md#http-server) | 用于处理 HTTP 请求的服务端。 | | | [Socket](https://effect.website/docs/v3/api/platform/Socket) | 一套基于 socket 进行通信的模块。 | | | [Worker](https://effect.website/docs/v3/api/platform/Worker) | 用于在独立 worker 中运行任务的模块。 | | 有关最新文档与详细信息,请参阅该包官方的 [README](https://github.com/Effect-TS/effect/blob/v3/packages/platform/README.md)。 ## 安装 安装 **beta** 版本: ```sh npm install @effect/platform ``` ```sh pnpm add @effect/platform ``` ```sh yarn add @effect/platform ``` ```sh bun add @effect/platform ``` ```sh deno add npm:@effect/platform ``` ## 跨平台编程入门 下面是一个基础示例,使用 `Path` 模块创建一个文件路径,它可以在不同环境中运行: **示例**(跨平台路径处理) ```ts import { Path } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) ``` ### 在 Node.js 或 Deno 中运行程序 首先,安装 Node.js 专用的包: ```sh npm install @effect/platform-node ``` ```sh pnpm add @effect/platform-node ``` ```sh yarn add @effect/platform-node ``` ```sh deno add npm:@effect/platform-node ``` 更新程序,让它加载 Node.js 专用的 context: **示例**(提供 Node.js context) ```ts import { Path } from "@effect/platform" import { Effect } from "effect" import { NodeContext, NodeRuntime } from "@effect/platform-node" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) ``` 最后,使用 `tsx` 在 Node.js 中运行程序,或直接在 Deno 中运行: ```sh npx tsx index.ts # Output: tmp/file.txt ``` ```sh pnpm dlx tsx index.ts # Output: tmp/file.txt ``` ```sh yarn dlx tsx index.ts # Output: tmp/file.txt ``` ```sh deno run index.ts # Output: tmp/file.txt # or deno run -RE index.ts # Output: tmp/file.txt # (granting required Read and Environment permissions without being prompted) ``` ### 在 Bun 中运行程序 要在 Bun 中运行同一个程序,首先安装 Bun 专用的包: ```sh bun add @effect/platform-bun ``` 更新程序,让它使用 Bun 专用的 context: **示例**(提供 Bun context) ```ts import { Path } from "@effect/platform" import { Effect } from "effect" import { BunContext, BunRuntime } from "@effect/platform-bun" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) BunRuntime.runMain(program.pipe(Effect.provide(BunContext.layer))) ``` 在 Bun 中运行程序: ```sh bun index.ts tmp/file.txt ``` --- # KeyValueStore > 以异步且一致的方式管理键值对存储,支持内存、文件系统与基于 schema 的实现。 `@effect/platform/KeyValueStore` 模块提供了一套健壮且具备 effect 语义的接口,用于管理键值对。 它支持异步操作,能够保证数据完整性与一致性,并内置了内存存储、基于文件系统的存储以及经 schema 校验的存储等实现。 ## 基本用法 该模块只暴露一个 [service](/docs/v3/requirements-management/services/),即 `KeyValueStore`,它是与该存储交互的入口。 **示例**(访问 KeyValueStore 服务) ```ts import { KeyValueStore } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { const kv = yield* KeyValueStore.KeyValueStore // Use `kv` to perform operations on the store }) ``` `KeyValueStore` 接口包含以下操作: | 操作 | 说明 | | -------------------- | -------------------------------------------------------------------- | | **get** | 如果指定的键存在,则以 `string` 返回其对应的值。 | | **getUint8Array** | 如果指定的键存在,则以 `Uint8Array` 返回其对应的值。 | | **set** | 设置指定键的值。 | | **remove** | 移除指定的键。 | | **clear** | 移除所有条目。 | | **size** | 返回条目数量。 | | **modify** | 如果指定的键存在,则更新其对应的值。 | | **modifyUint8Array** | 如果指定的键存在,则更新其对应的值。 | | **has** | 检查某个键是否存在。 | | **isEmpty** | 检查该存储是否为空。 | | **forSchema** | 为指定的 schema 创建一个 `SchemaStore`。 | **示例**(键值存储的基本操作) ```ts import { KeyValueStore, layerMemory } from "@effect/platform/KeyValueStore" import { Effect } from "effect" const program = Effect.gen(function* () { const kv = yield* KeyValueStore // Store is initially empty console.log(yield* kv.size) // Set a key-value pair yield* kv.set("key", "value") console.log(yield* kv.size) // Retrieve the value const value = yield* kv.get("key") console.log(value) // Remove the key yield* kv.remove("key") console.log(yield* kv.size) }) // Run the program using the in-memory store implementation Effect.runPromise(program.pipe(Effect.provide(layerMemory))) /* Output: 0 1 { _id: 'Option', _tag: 'Some', value: 'value' } 0 */ ``` ## 内置实现 该模块内置了 `KeyValueStore` 接口的两种实现。二者都以 [Layer](/docs/v3/requirements-management/layers/) 的形式提供,你可以把它们注入到自己的 effect 程序中。 | 实现 | 说明 | | --------------------- | ------------------------------------------------------------------------------------------------------- | | **In-Memory Store** | `layerMemory` 提供一个简单的内存键值存储,适合轻量级场景或测试场景。 | | **File System Store** | `layerFileSystem` 提供一个基于文件的存储,适用于需要持久化的场景。 | ## 处理非字符串值 默认情况下,`KeyValueStore` 只处理 `string` 和 `Uint8Array` 类型的值。若要存储对象、数字、布尔值等其他类型,请使用 `forSchema` 方法创建一个 `SchemaStore`。 `SchemaStore` 会使用 [schema](/docs/v3/schema/introduction/) 来校验并转换值。在内部,它用 `JSON.stringify` 序列化数据,并用 `JSON.parse` 反序列化数据。 **示例**(使用 schema 存储有类型的对象) ```ts import { KeyValueStore, layerMemory } from "@effect/platform/KeyValueStore" import { Effect, Schema } from "effect" // Define a JSON-compatible schema const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const program = Effect.gen(function* () { // Create a typed store based on the schema const kv = (yield* KeyValueStore).forSchema(Person) // Store a typed value const value = { name: "Alice", age: 30 } yield* kv.set("user1", value) console.log(yield* kv.size) // Retrieve the value console.log(yield* kv.get("user1")) }) // Use the in-memory store for this example Effect.runPromise(program.pipe(Effect.provide(layerMemory))) /* Output: 1 { _id: 'Option', _tag: 'Some', value: { name: 'Alice', age: 30 } } */ ``` --- # Path > 跨平台执行文件路径操作,例如拼接、解析和规范化。 `@effect/platform/Path` 模块提供了一组用于处理文件路径的操作。 ## 基本用法 该模块只提供一个 `Path` [tag](/docs/v3/requirements-management/services/),它是与路径交互的入口。 **示例**(访问 Path 服务) ```ts import { Path } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { const path = yield* Path.Path // Use `path` to perform various path operations }) ``` `Path` 接口包含以下操作: | 操作 | 说明 | | -------------------- | ------------------------------------------------------------------- | | **basename** | 返回路径的最后一部分,可选地移除给定的后缀。 | | **dirname** | 返回路径的目录部分。 | | **extname** | 返回路径中的文件扩展名。 | | **format** | 将路径对象格式化为路径字符串。 | | **fromFileUrl** | 将文件 URL 转换为路径。 | | **isAbsolute** | 检查路径是否为绝对路径。 | | **join** | 将多个路径片段拼接成一个。 | | **normalize** | 通过解析 `.` 和 `..` 片段来规范化路径。 | | **parse** | 将路径字符串解析为包含各片段的对象。 | | **relative** | 计算从一个路径到另一个路径的相对路径。 | | **resolve** | 将一组路径解析为绝对路径。 | | **sep** | 返回平台特定的路径片段分隔符(例如 POSIX 上的 `/`)。 | | **toFileUrl** | 将路径转换为文件 URL。 | | **toNamespacedPath** | 将路径转换为带命名空间的路径(Windows 特有)。 | **示例**(拼接路径片段) ```ts import { Path } from "@effect/platform" import { Effect } from "effect" import { NodeContext, NodeRuntime } from "@effect/platform-node" const program = Effect.gen(function* () { const path = yield* Path.Path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) // Output: "tmp/file.txt" ``` --- # PlatformLogger > 使用 FileSystem API 将日志消息写入文件。 Effect 的日志系统默认通常会把消息写入控制台。不过,你可能更希望把日志存到文件里,以便调试或归档。`PlatformLogger.toFile` 函数会创建一个 logger,把日志消息发送到磁盘上的文件。 ### toFile 基于已有的字符串 logger 创建一个新的 logger,并把它的输出写入指定文件。 如果在调用 `toFile` 时传入一个 `batchWindow` 时长,日志会先在该时间窗口内批量累积,然后再写入。当你的应用产生大量日志条目时,这可以降低开销。若不设置 `batchWindow`,日志会在到达时立即写入。 请注意,`toFile` 返回一个 `Effect`,如果文件无法打开或写入,它可能以 `PlatformError` 失败。如果你需要对文件 I/O 问题作出反应,请务必处理这种可能性。 **示例**(将日志写入文件) 这个 logger 需要一个 `FileSystem` 实现来打开并写入文件。在 Node.js 上,你可以使用 `NodeFileSystem.layer`。 ```ts import { PlatformLogger } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect, Layer, Logger } from "effect" // Create a string-based logger (logfmtLogger in this case) const myStringLogger = Logger.logfmtLogger // Apply toFile to write logs to "/tmp/log.txt" const fileLogger = myStringLogger.pipe(PlatformLogger.toFile("/tmp/log.txt")) // Replace the default logger, providing NodeFileSystem // to access the file system const LoggerLive = Logger.replaceScoped(Logger.defaultLogger, fileLogger).pipe( Layer.provide(NodeFileSystem.layer), ) const program = Effect.log("Hello") // Run the program, writing logs to /tmp/log.txt Effect.runFork(program.pipe(Effect.provide(LoggerLive))) /* Logs will be written to "/tmp/log.txt" in the logfmt format, and won't appear on the console. */ ``` 在下面的示例中,日志会同时写入控制台和文件。控制台使用 pretty logger,而文件使用 logfmt 格式。 **示例**(同时将日志写入文件和控制台) ```ts import { PlatformLogger } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect, Layer, Logger } from "effect" const fileLogger = Logger.logfmtLogger.pipe( PlatformLogger.toFile("/tmp/log.txt"), ) // Combine the pretty logger for console output with the file logger const bothLoggers = Effect.map(fileLogger, (fileLogger) => Logger.zip(Logger.prettyLoggerDefault, fileLogger), ) const LoggerLive = Logger.replaceScoped(Logger.defaultLogger, bothLoggers).pipe( Layer.provide(NodeFileSystem.layer), ) const program = Effect.log("Hello") // Run the program, writing logs to both the console (pretty format) // and "/tmp/log.txt" (logfmt) Effect.runFork(program.pipe(Effect.provide(LoggerLive))) ``` --- # Runtime > 使用内置的错误处理与日志功能来运行你的程序。 ## 使用 runMain 运行主程序 `runMain` 可以帮助你执行主 effect,并内置了错误处理、日志记录和信号管理。你可以专注于自己的 effect,而由 `runMain` 负责收尾资源、记录错误并设置退出码。 - **退出码(Exit Codes)** 如果你的 effect 失败或被中断,`runMain` 会指定一个合适的退出码(例如,出错时用 `1`,成功时用 `0`)。 - **日志(Logs)** 默认情况下,它会记录错误。如有需要,可以将其关闭。 - **美化日志(Pretty Logging)** 默认情况下,错误消息会以 “pretty” 格式记录。如有需要,可以将其关闭。 - **中断处理(Interrupt Handling)** 如果应用程序收到 `SIGINT`(Ctrl+C)或类似的信号,`runMain` 会中断该 effect,并且仍然执行必要的清理步骤。 - **收尾逻辑(Teardown Logic)** 你可以依赖默认的收尾逻辑,也可以定义自己的逻辑。默认逻辑会为非中断型失败设置退出码 `1`。 ### 用法选项 调用 `runMain` 时,传入一个包含以下字段的配置对象(所有字段都是可选的): - `disableErrorReporting`:如果为 `true`,错误不会被自动记录到日志中。 - `disablePrettyLogger`:如果为 `true`,就不会添加 "pretty" logger。 - `teardown`:提供一个用于结束程序的自定义函数。如果未提供,默认逻辑会为非中断型失败设置退出码 `1`。 **示例**(运行一个成功的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const success = Effect.succeed("Hello, World!") NodeRuntime.runMain(success) // No Output ``` **示例**(运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure) /* Output: [12:43:07.186] ERROR (#0): Error: Uh oh! */ ``` **示例**(在不使用 pretty logger 的情况下运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure, { disablePrettyLogger: true }) /* Output: timestamp=2025-01-14T11:43:46.276Z level=ERROR fiber=#0 cause="Error: Uh oh!" */ ``` **示例**(在关闭错误报告的情况下运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure, { disableErrorReporting: true }) // No Output ``` **示例**(使用自定义收尾逻辑运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure, { teardown: function customTeardown(exit, onExit) { if (exit._tag === "Failure") { console.error("Program ended with an error.") onExit(1) } else { console.log("Program finished successfully.") onExit(0) } }, }) /* Output: [12:46:39.871] ERROR (#0): Error: Uh oh! Program ended with an error. */ ``` --- # Terminal > 与标准输入和标准输出交互,读取用户输入并在终端上显示消息。 `@effect/platform/Terminal` 模块提供了一层抽象,用于与标准输入和标准输出交互,包括读取用户输入以及在终端上显示消息。 ## 基本用法 该模块只提供一个 `Terminal` [tag](/docs/v3/requirements-management/services/),它是从标准输入读取、向标准输出写入的入口。 **示例**(使用 Terminal 服务) ```ts import { Terminal } from "@effect/platform" import { Effect } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal // Use `terminal` to interact with standard input and output }) ``` ## 写入标准输出 **示例**(在终端上显示一条消息) ```ts import { Terminal } from "@effect/platform" import { NodeRuntime, NodeTerminal } from "@effect/platform-node" import { Effect } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display("a message\n") }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeTerminal.layer))) // Output: "a message" ``` ## 从标准输入读取 **示例**(从标准输入读取一行) ```ts import { Terminal } from "@effect/platform" import { NodeRuntime, NodeTerminal } from "@effect/platform-node" import { Effect } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal const input = yield* terminal.readLine console.log(`input: ${input}`) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeTerminal.layer))) // Input: "hello" // Output: "input: hello" ``` ## 示例:猜数字游戏 这个示例演示了如何通过读取终端输入并向用户提供反馈,来创建一个完整的猜数字游戏。游戏会一直持续,直到用户猜中正确的数字。 **示例**(交互式猜数字游戏) ```ts import { Terminal } from "@effect/platform" import type { PlatformError } from "@effect/platform/Error" import { Effect, Option, Random } from "effect" import { NodeRuntime, NodeTerminal } from "@effect/platform-node" // Generate a secret random number between 1 and 100 const secret = Random.nextIntBetween(1, 100) // Parse the user's input into a valid number const parseGuess = (input: string) => { const n = parseInt(input, 10) return isNaN(n) || n < 1 || n > 100 ? Option.none() : Option.some(n) } // Display a message on the terminal const display = (message: string) => Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display(`${message}\n`) }) // Prompt the user for a guess const prompt = Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display("Enter a guess: ") return yield* terminal.readLine }) // Get the user's guess, validating it as an integer between 1 and 100 const answer: Effect.Effect< number, Terminal.QuitException | PlatformError, Terminal.Terminal > = Effect.gen(function* () { const input = yield* prompt const guess = parseGuess(input) if (Option.isNone(guess)) { yield* display("You must enter an integer from 1 to 100") return yield* answer } return guess.value }) // Check if the guess is too high, too low, or correct const check = ( secret: number, guess: number, ok: Effect.Effect, ko: Effect.Effect, ) => Effect.gen(function* () { if (guess > secret) { yield* display("Too high") return yield* ko } else if (guess < secret) { yield* display("Too low") return yield* ko } else { return yield* ok } }) // End the game with a success message const end = display("You guessed it!") // Main game loop const loop = ( secret: number, ): Effect.Effect< void, Terminal.QuitException | PlatformError, Terminal.Terminal > => Effect.gen(function* () { const guess = yield* answer return yield* check( secret, guess, end, Effect.suspend(() => loop(secret)), ) }) // Full game setup and execution const game = Effect.gen(function* () { yield* display( `We have selected a random number between 1 and 100. See if you can guess it in 10 turns or fewer. We'll tell you if your guess was too high or too low.`, ) yield* loop(yield* secret) }) // Run the game NodeRuntime.runMain(game.pipe(Effect.provide(NodeTerminal.layer))) ``` --- # 默认服务 > 了解 Effect 中的默认服务,包括 Clock、Console、Random、ConfigProvider 和 Tracer,以及它们如何被自动提供给我们的程序。 Effect 自带五种预置服务: ```ts type DefaultServices = Clock | ConfigProvider | Console | Random | Tracer ``` 当我们使用这些服务时,无需显式提供它们的实现。Effect 会自动把它们的 live 版本提供给我们的 effect,让我们省去手动配置的麻烦。 **示例**(使用 Clock 和 Console) ```ts import { Effect, Clock, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis yield* Console.log(`Application started at ${new Date(now)}`) }) Effect.runFork(program) // Output: Application started at ``` 可以看到,即使我们的程序同时使用了 `Clock` 和 `Console`,代表该 effect 执行所需服务的 `Requirements` 参数依然保持为 `never`。 Effect 会替我们无缝地处理这些服务。 ## 覆盖默认服务 有时你可能需要用自定义实现来替换默认服务。Effect 提供了内置工具,可用 `Effect.with` 和 `Effect.withScoped` 覆盖这些服务。 - `Effect.with`:在 effect 的持续期间内覆盖某个服务。 - `Effect.withScoped`:在某个 scope 内覆盖服务,并在之后恢复原来的服务。 | 函数 | 说明 | | --------------------------------- | ------------------------------------------------------------------------------ | | `Effect.withClock` | 使用指定的 `Clock` 服务执行 effect。 | | `Effect.withClockScoped` | 临时覆盖 `Clock` 服务,并在 scope 结束时恢复它。 | | `Effect.withConfigProvider` | 使用指定的 `ConfigProvider` 服务执行 effect。 | | `Effect.withConfigProviderScoped` | 在某个 scope 内临时覆盖 `ConfigProvider` 服务。 | | `Effect.withConsole` | 使用指定的 `Console` 服务执行 effect。 | | `Effect.withConsoleScoped` | 在某个 scope 内临时覆盖 `Console` 服务。 | | `Effect.withRandom` | 使用指定的 `Random` 服务执行 effect。 | | `Effect.withRandomScoped` | 在某个 scope 内临时覆盖 `Random` 服务。 | | `Effect.withTracer` | 使用指定的 `Tracer` 服务执行 effect。 | | `Effect.withTracerScoped` | 在某个 scope 内临时覆盖 `Tracer` 服务。 | **示例**(覆盖 Random 服务) ```ts import { Effect, Random } from "effect" // A program that logs a random number const program = Effect.gen(function* () { console.log(yield* Random.next) }) Effect.runSync(program) // Example Output: 0.23208633934454326 (varies each run) // Override the Random service with a seeded generator const override = program.pipe(Effect.withRandom(Random.make("myseed"))) Effect.runSync(override) // Output: 0.6862142528438508 (consistent output with the seed) ``` --- # Layer 的记忆化 > 学习 Layer 的记忆化如何通过复用 Layer 并控制其实例化来优化 Effect 应用中的性能。 Layer 的记忆化允许一个 Layer 只创建一次,并在依赖图中被多次使用。如果我们两次使用同一个 Layer: ```ts Layer.merge(Layer.provide(L2, L1), Layer.provide(L3, L1)) ``` 那么 `L1` 这个 Layer 只会被分配一次。 ## 全局提供时的记忆化 Effect 应用的一个重要特性是:Layer 默认会被共享。这意味着,如果同一个 Layer 被使用了两次,并且我们以全局方式提供它,那么这个 Layer 只会被分配一次。对于依赖图中的每个 Layer,都只有一个实例,在所有依赖它的 Layer 之间共享。 **示例** 例如,假设我们有三个服务 `A`、`B` 和 `C`。`B` 和 `C` 的实现都依赖 `A` 这个服务: ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Tag("A")() {} class B extends Context.Tag("B")() {} class C extends Context.Tag("C")() {} const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe(Effect.tap(() => Effect.log("initialized"))), ) const BLive = Layer.effect( B, Effect.gen(function* () { const { a } = yield* A return { b: String(a) } }), ) const CLive = Layer.effect( C, Effect.gen(function* () { const { a } = yield* A return { c: a > 0 } }), ) const program = Effect.gen(function* () { yield* B yield* C }) const runnable = Effect.provide( program, Layer.merge(Layer.provide(BLive, ALive), Layer.provide(CLive, ALive)), ) Effect.runPromise(runnable) /* Output: timestamp=... level=INFO fiber=#2 message=initialized */ ``` 尽管 `BLive` 和 `CLive` 这两个 Layer 都需要 `ALive`,但 `ALive` 只会被实例化一次。它被 `BLive` 和 `CLive` 共享。 ## 获取全新版本 如果我们不想共享某个模块,就应该通过 `Layer.fresh` 为它创建一个全新的、不共享的版本。 **示例** ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Tag("A")() {} class B extends Context.Tag("B")() {} class C extends Context.Tag("C")() {} const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe(Effect.tap(() => Effect.log("initialized"))), ) const BLive = Layer.effect( B, Effect.gen(function* () { const { a } = yield* A return { b: String(a) } }), ) const CLive = Layer.effect( C, Effect.gen(function* () { const { a } = yield* A return { c: a > 0 } }), ) const program = Effect.gen(function* () { yield* B yield* C }) const runnable = Effect.provide( program, Layer.merge( Layer.provide(BLive, Layer.fresh(ALive)), Layer.provide(CLive, Layer.fresh(ALive)), ), ) Effect.runPromise(runnable) /* Output: timestamp=... level=INFO fiber=#2 message=initialized timestamp=... level=INFO fiber=#3 message=initialized */ ``` ## 局部提供时不进行记忆化 如果我们不以全局方式提供 Layer,而是在局部提供它们,那么该 Layer 默认不支持记忆化。 **示例** 在下面的例子中,我们在局部两次提供了 `ALive` Layer,Effect 不会对 `ALive` 的构造进行记忆化。 因此,它会被初始化两次: ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Tag("A")() {} const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe(Effect.tap(() => Effect.log("initialized"))), ) const program = Effect.gen(function* () { yield* Effect.provide(A, ALive) yield* Effect.provide(A, ALive) }) Effect.runPromise(program) /* Output: timestamp=... level=INFO fiber=#0 message=initialized timestamp=... level=INFO fiber=#0 message=initialized */ ``` ## 手动记忆化 我们可以使用 `Layer.memoize` 函数手动对一个 Layer 进行记忆化。 它会返回一个 scoped effect:一旦被求值,就会返回该 Layer 延迟计算的结果。 **示例** ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Tag("A")() {} const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe(Effect.tap(() => Effect.log("initialized"))), ) const program = Effect.scoped( Layer.memoize(ALive).pipe( Effect.andThen((memoized) => Effect.gen(function* () { yield* Effect.provide(A, memoized) yield* Effect.provide(A, memoized) }), ), ), ) Effect.runPromise(program) /* Output: timestamp=... level=INFO fiber=#0 message=initialized */ ``` --- # 管理 Layer > 学习如何在 Effect 中使用 Layer 管理服务依赖,为应用构建高效、清晰的依赖图。 在[管理服务](/docs/v3/requirements-management/services/)页面中,你学习了如何创建依赖某个服务才能执行的 effect,以及如何为该 effect 提供这个服务。 然而,如果 effect 程序中的某个服务在构建时依赖其他服务,该怎么办?我们希望避免把这些实现细节泄漏到服务接口中。 为了表示程序的“依赖图”并更有效地管理这些依赖,我们可以使用一个强大的抽象,称为 “Layer”。 Layer 充当**创建服务的构造器**,让我们能够在构造期间而非服务层面管理依赖。这种方式有助于保持服务接口的简洁与专注。 在深入细节之前,让我们先回顾一些关键概念: | 概念 | 说明 | | ----------- | ------------------------------------------------------------------------------------------ | | **服务** | 可复用的组件,提供特定功能,在应用的不同部分被使用。 | | **tag** | 代表某个**服务**的唯一标识符,让 Effect 能够定位并使用它。 | | **context** | 存储服务的集合,作用类似于一个以 **tag** 为键、以**服务**为值的 map。 | | **layer** | 用于构造**服务**的抽象,在构造期间而非服务层面管理依赖。 | ## 设计依赖图 假设我们正在构建一个 Web 应用。可以想象,对于需要管理配置、日志和数据库访问的应用,其依赖图大致如下: - `Config` 服务提供应用配置。 - `Logger` 服务依赖 `Config` 服务。 - `Database` 服务同时依赖 `Config` 和 `Logger` 服务。 我们的目标是构建 `Database` 服务及其直接与间接依赖。这意味着需要确保 `Config` 服务对 `Logger` 和 `Database` 都可用,然后把这些依赖提供给 `Database` 服务。 ## 避免需求泄漏 在构造 `Database` 服务时,重要的是避免在 `Database` 接口中暴露对 `Config` 和 `Logger` 的依赖。 你可能会想按如下方式定义 `Database` 服务: **示例**(在服务接口中泄漏依赖) ```ts import { Effect, Context } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")() {} // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")() {} // Declaring a tag for the Database service class Database extends Context.Tag("Database")< Database, { // ❌ Avoid exposing Config and Logger as a requirement readonly query: ( sql: string, ) => Effect.Effect } >() {} ``` 这里,`Database` 服务的 `query` 函数同时需要 `Config` 和 `Logger`。这种设计泄漏了实现细节,使 `Database` 服务意识到自己的依赖,从而让测试变得复杂、难以 mock。 为演示这一问题,我们来创建一个 `Database` 服务的测试实例: **示例**(创建带有泄漏依赖的测试实例) ```ts import { Effect, Context } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")() {} // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")() {} // Declaring a tag for the Database service class Database extends Context.Tag("Database")< Database, { readonly query: ( sql: string, ) => Effect.Effect } >() {} // Declaring a test instance of the Database service const DatabaseTest = Database.of({ // Simulating a simple response query: (sql: string) => Effect.succeed([]), }) import * as assert from "node:assert" // A test that uses the Database service const test = Effect.gen(function* () { const database = yield* Database const result = yield* database.query("SELECT * FROM users") assert.deepStrictEqual(result, []) }) // ┌─── Effect // ▼ const incompleteTestSetup = test.pipe( // Attempt to provide only the Database service without Config and Logger Effect.provideService(Database, DatabaseTest), ) ``` 由于 `Database` 服务接口直接包含对 `Config` 和 `Logger` 的依赖,任何测试准备工作都被迫包含这些服务,即使它们与测试无关。这带来了不必要的复杂度,也让编写简单、隔离的单元测试变得困难。 与其把依赖直接绑定到 `Database` 服务接口上,不如在构造阶段管理依赖。 我们可以使用 **Layer** 来正确构造 `Database` 服务并管理其依赖,而不会把细节泄漏到接口中。 ## 创建 Layer `Layer` 类型的结构如下: ```text ┌─── The service to be created │ ┌─── The possible error │ │ ┌─── The required dependencies ▼ ▼ ▼ Layer ``` `Layer` 表示构造 `RequirementsOut`(即服务)的蓝图。它以 `RequirementsIn`(依赖)作为输入,并可能在构造过程中产生 `Error` 类型的错误。 | 参数 | 说明 | | ----------------- | ---------------------------------------------------------------- | | `RequirementsOut` | 要创建的服务或资源。 | | `Error` | 构造服务时可能发生的错误类型。 | | `RequirementsIn` | 构造服务所需的依赖。 | 通过使用 Layer,你可以更好地组织服务,确保其依赖被清晰定义,并与实现细节分离。 为简单起见,我们假设在值构造过程中不会遇到任何错误(即 `Error = never`)。 现在,让我们确定实现依赖图需要多少个 Layer: | Layer | 依赖 | 类型 | | -------------- | ------------------------------------------------ | ------------------------------------------ | | `ConfigLive` | `Config` 服务不依赖任何其他服务 | `Layer` | | `LoggerLive` | `Logger` 服务依赖 `Config` 服务 | `Layer` | | `DatabaseLive` | `Database` 服务依赖 `Config` 和 `Logger` | `Layer` | 当一个服务有多个依赖时,它们表示为**联合类型**。在我们的例子中,`Database` 服务同时依赖 `Config` 和 `Logger` 服务。因此,`DatabaseLive` Layer 的类型为: ```ts Layer ``` ### Config `Config` 服务不依赖任何其他服务,因此 `ConfigLive` 是最容易实现的 Layer。正如[管理服务](/docs/v3/requirements-management/services/)页面中那样,我们必须为该服务创建一个 tag。由于该服务没有依赖,我们可以直接使用 `Layer.succeed` 构造器创建 Layer: ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed( Config, Config.of({ getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }), ) ``` 观察 `ConfigLive` 的类型,我们可以发现: - `RequirementsOut` 是 `Config`,表明构造该 Layer 将产出 `Config` 服务 - `Error` 是 `never`,表明 Layer 构造不会失败 - `RequirementsIn` 是 `never`,表明该 Layer 没有依赖 注意,为了构造 `ConfigLive`,我们使用了 `Config.of` 构造器。然而,这只是一个用于确保实现具有正确类型推断的辅助方法。 也可以跳过这个辅助方法,直接把实现构造成一个简单对象: ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) ``` ### Logger 现在我们继续实现 `Logger` 服务,它依赖 `Config` 服务来获取一些配置。 正如我们在[管理服务](/docs/v3/requirements-management/services/#using-the-service)页面中所做的那样,我们可以 yield `Config` tag,从 Context 中“提取”该服务。 由于使用 `Config` tag 是一个会产生 effect 的操作,我们使用 `Layer.effect` 从得到的 effect 创建 Layer。 ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) ``` 观察 `LoggerLive` 的类型: ```ts Layer ``` 我们可以发现: - `RequirementsOut` 是 `Logger` - `Error` 是 `never`,表明 Layer 构造不会失败 - `RequirementsIn` 是 `Config`,表明该 Layer 有一个需求 ### Database 最后,我们可以使用 `Config` 和 `Logger` 服务来实现 `Database` 服务。 ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a tag for the Database service class Database extends Context.Tag("Database")< Database, { readonly query: (sql: string) => Effect.Effect } >() {} // Layer const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) ``` 观察 `DatabaseLive` 的类型: ```ts Layer ``` 我们可以发现 `RequirementsIn` 类型是 `Config | Logger`,也就是说 `Database` 服务同时需要 `Config` 和 `Logger` 服务。 ## 组合 Layer Layer 可以通过两种主要方式组合:**合并(merging)**与**组合(composing)**。 ### 合并 Layer Layer 可以通过 `Layer.merge` 函数进行合并: ```ts import { Layer } from "effect" declare const layer1: Layer.Layer<"Out1", never, "In1"> declare const layer2: Layer.Layer<"Out2", never, "In2"> // Layer<"Out1" | "Out2", never, "In1" | "In2"> const merging = Layer.merge(layer1, layer2) ``` 当我们合并两个 Layer 时,得到的 Layer: - 需要它们两者所需的所有服务(`"In1" | "In2"`)。 - 产出它们两者产出的所有服务(`"Out1" | "Out2"`)。 例如,在上面的 Web 应用中,我们可以把 `ConfigLive` 和 `LoggerLive` 合并成单个 `AppConfigLive` Layer,它保留两个 Layer 的需求(`never | Config = Config`)以及两个 Layer 的输出(`Config | Logger`): ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) ``` ### 组合 Layer Layer 可以使用 `Layer.provide` 函数进行组合: ```ts import { Layer } from "effect" declare const inner: Layer.Layer<"OutInner", never, "InInner"> declare const outer: Layer.Layer<"InInner", never, "InOuter"> // Layer<"OutInner", never, "InOuter"> const composition = Layer.provide(inner, outer) ``` Layer 的顺序组合意味着一个 Layer 的输出被作为内层 Layer 的输入提供,结果得到单个 Layer:它拥有外层 Layer 的需求和内层 Layer 的输出。 现在我们可以把 `AppConfigLive` Layer 与 `DatabaseLive` Layer 组合起来: ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a tag for the Database service class Database extends Context.Tag("Database")< Database, { readonly query: (sql: string) => Effect.Effect } >() {} // Layer const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) // Layer const MainLive = DatabaseLive.pipe( // provides the config and logger to the database Layer.provide(AppConfigLive), // provides the config to AppConfigLive Layer.provide(ConfigLive), ) ``` 我们得到了一个 `MainLive` Layer,它产出 `Database` service: ```ts Layer ``` 该 Layer 是我们应用完全解析后的 Layer。 ### 合并与组合 Layer 假设我们希望 `MainLive` Layer 同时返回 `Config` 和 `Database` 这两个 service。可以通过 `Layer.provideMerge` 实现: ```ts import { Effect, Context, Layer } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a tag for the Logger service class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a tag for the Database service class Database extends Context.Tag("Database")< Database, { readonly query: (sql: string) => Effect.Effect } >() {} const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) // Layer const MainLive = DatabaseLive.pipe( Layer.provide(AppConfigLive), Layer.provideMerge(ConfigLive), ) ``` ## 为 Effect 提供 Layer 现在我们已经为应用组装好了完全解析的 `MainLive`,可以使用 `Effect.provide` 将它提供给程序,以满足程序的需求: ```ts import { Effect, Context, Layer } from "effect" class Config extends Context.Tag("Config")< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >() {} const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) class Logger extends Context.Tag("Logger")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) class Database extends Context.Tag("Database")< Database, { readonly query: (sql: string) => Effect.Effect } >() {} const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) const MainLive = DatabaseLive.pipe( Layer.provide(AppConfigLive), Layer.provide(ConfigLive), ) // ┌─── Effect // ▼ const program = Effect.gen(function* () { const database = yield* Database const result = yield* database.query("SELECT * FROM users") return result }) // ┌─── Effect // ▼ const runnable = Effect.provide(program, MainLive) Effect.runPromise(runnable).then(console.log) /* Output: [INFO] Executing query: SELECT * FROM users { result: 'Results from mysql://username:password@hostname:port/database_name' } */ ``` 注意 `runnable` 的需求类型是 `never`,表明该程序运行时不需要任何额外的 service。 ## 把 Layer 转换为 Effect 有时你的整个应用可能就是一个 Layer,例如一个 HTTP server。你可以用 `Layer.launch` 把该 Layer 转换为 Effect。它会构造 Layer 并使其保持存活,直到被中断。 **示例**(启动一个 HTTP Server Layer) ```ts import { Console, Context, Effect, Layer } from "effect" class HTTPServer extends Context.Tag("HTTPServer")() {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, // Log a message to simulate a server starting Console.log("Listening on http://localhost:3000"), ) // Converts the layer to an effect and runs it Effect.runFork(Layer.launch(server)) /* Output: Listening on http://localhost:3000 ... */ ``` ## Tap 操作 `Layer.tap` 和 `Layer.tapError` 函数允许你根据 Layer 的成功或失败执行额外的 effect。这些操作不会修改 Layer 的签名,但在 Layer 构造期间用于日志记录或执行副作用时非常有用。 - `Layer.tap`:当 Layer 成功获取时执行指定的 effect。 - `Layer.tapError`:当 Layer 获取失败时执行指定的 effect。 **示例**(记录 Layer 获取过程中的成功与失败) ```ts import { Config, Context, Effect, Layer, Console } from "effect" class HTTPServer extends Context.Tag("HTTPServer")() {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, Effect.gen(function* () { const host = yield* Config.string("HOST") console.log(`Listening on http://localhost:${host}`) }), ).pipe( // Log a message if the layer acquisition succeeds Layer.tap((ctx) => Console.log(`layer acquisition succeeded with:\n${ctx}`)), // Log a message if the layer acquisition fails Layer.tapError((err) => Console.log(`layer acquisition failed with:\n${err}`), ), ) Effect.runFork(Layer.launch(server)) /* Output: layer acquisition failed with: (Missing data at HOST: "Expected HOST to exist in the process context") */ ``` ## 错误处理 在构造 Layer 时,处理潜在错误很重要。Effect 库提供了 `Layer.catchAll` 和 `Layer.orElse` 等工具来管理错误,并定义失败时的回退 Layer。 ### catchAll `Layer.catchAll` 函数允许你通过指定回退 Layer 从 Layer 构造期间的错误中恢复。这对于处理特定错误情况、确保应用能以替代方案继续运行很有用。 **示例**(从 Layer 构造期间的错误中恢复) ```ts import { Config, Context, Effect, Layer } from "effect" class HTTPServer extends Context.Tag("HTTPServer")() {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, Effect.gen(function* () { const host = yield* Config.string("HOST") console.log(`Listening on http://localhost:${host}`) }), ).pipe( // Recover from errors during layer construction Layer.catchAll((configError) => Layer.effect( HTTPServer, Effect.gen(function* () { console.log(`Recovering from error:\n${configError}`) console.log(`Listening on http://localhost:3000`) }), ), ), ) Effect.runFork(Layer.launch(server)) /* Output: Recovering from error: (Missing data at HOST: "Expected HOST to exist in the process context") Listening on http://localhost:3000 ... */ ``` ### orElse `Layer.orElse` 函数提供了一种更简单的方式:当初始 Layer 失败时回退到替代 Layer。与 `Layer.catchAll` 不同,它不会把错误作为输入接收。当你只需要提供一个默认 Layer、而无需针对特定错误作出反应时,可以使用它。 **示例**(回退到替代 Layer) ```ts import { Config, Context, Effect, Layer } from "effect" class Database extends Context.Tag("Database")() {} // Simulating a database connection const postgresDatabaseLayer = Layer.effect( Database, Effect.gen(function* () { const databaseConnectionString = yield* Config.string("CONNECTION_STRING") console.log(`Connecting to database with: ${databaseConnectionString}`) }), ) // Simulating an in-memory database connection const inMemoryDatabaseLayer = Layer.effect( Database, Effect.gen(function* () { console.log(`Connecting to in-memory database`) }), ) // Fallback to in-memory database if PostgreSQL connection fails const database = postgresDatabaseLayer.pipe( Layer.orElse(() => inMemoryDatabaseLayer), ) Effect.runFork(Layer.launch(database)) /* Output: Connecting to in-memory database ... */ ``` ## 使用 Effect.Service 简化 Service 定义 `Effect.Service` API 提供了一种一步定义 service 的方式,包括它的 tag 和 Layer。它还允许预先声明依赖,使 service 的构造更加直接。 ### 定义带依赖的 Service 下面的例子定义了一个依赖文件系统的 `Cache` service。 **示例**(定义 Cache Service) ```ts import { FileSystem } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect } from "effect" // Define a Cache service class Cache extends Effect.Service()("app/Cache", { // Define how to create the service effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), // Specify dependencies dependencies: [NodeFileSystem.layer], }) {} ``` ### 使用生成的 Layer `Effect.Service` API 会自动为 service 生成 Layer。 | Layer | 说明 | | ---------------------------------- | ------------------------------------------ | | `Cache.Default` | 提供 `Cache` service,并已包含其依赖。 | | `Cache.DefaultWithoutDependencies` | 提供 `Cache` service,但需要单独提供依赖。 | ```ts import { FileSystem } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect } from "effect" // Define a Cache service class Cache extends Effect.Service()("app/Cache", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), dependencies: [NodeFileSystem.layer], }) {} // Layer that includes all required dependencies // // ┌─── Layer // ▼ const layer = Cache.Default // Layer without dependencies, requiring them to be provided externally // // ┌─── Layer.Layer // ▼ const layerNoDeps = Cache.DefaultWithoutDependencies ``` ### 访问 Service 使用 `Effect.Service` 创建的 service 可以像其他任何 Effect service 一样被访问。 **示例**(访问 Cache Service) ```ts import { FileSystem } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect, Console } from "effect" // Define a Cache service class Cache extends Effect.Service()("app/Cache", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), dependencies: [NodeFileSystem.layer], }) {} // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchAllCause((cause) => Console.log(cause))) const runnable = program.pipe(Effect.provide(Cache.Default)) Effect.runFork(runnable) /* { _id: 'Cause', _tag: 'Fail', failure: { _tag: 'SystemError', reason: 'NotFound', module: 'FileSystem', method: 'readFile', pathOrDescriptor: 'cache/my-key', syscall: 'open', message: "ENOENT: no such file or directory, open 'cache/my-key'", [Symbol(@effect/platform/Error/PlatformErrorTypeId)]: Symbol(@effect/platform/Error/PlatformErrorTypeId) } } */ ``` 由于该示例使用了 `Cache.Default`,它会与真实文件系统交互。如果文件不存在,就会产生错误。 ### 注入测试依赖 为了在不依赖真实文件系统的情况下测试程序,我们可以使用 `Cache.DefaultWithoutDependencies` Layer 注入一个测试文件系统。 **示例**(使用测试文件系统) ```ts import { FileSystem } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect, Console } from "effect" // Define a Cache service class Cache extends Effect.Service()("app/Cache", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), dependencies: [NodeFileSystem.layer], }) {} // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchAllCause((cause) => Console.log(cause))) // Create a test file system that always returns a fixed value const FileSystemTest = FileSystem.layerNoop({ readFileString: () => Effect.succeed("File Content..."), }) const runnable = program.pipe( Effect.provide(Cache.DefaultWithoutDependencies), // Provide the mock file system Effect.provide(FileSystemTest), ) Effect.runFork(runnable) // Output: File Content... ``` ### 直接 Mock Service 另一种方式是不替换依赖,而是直接 mock `Cache` service 本身。 **示例**(Mock Cache Service) ```ts import { FileSystem } from "@effect/platform" import { NodeFileSystem } from "@effect/platform-node" import { Effect, Console } from "effect" // Define a Cache service class Cache extends Effect.Service()("app/Cache", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), dependencies: [NodeFileSystem.layer], }) {} // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchAllCause((cause) => Console.log(cause))) // Create a mock implementation of Cache const cache = new Cache({ lookup: () => Effect.succeed("Cache Content..."), }) // Provide the mock Cache service const runnable = program.pipe(Effect.provideService(Cache, cache)) Effect.runFork(runnable) // Output: Cache Content... ``` ### 定义 Service 的其他方式 `Effect.Service` API 支持多种定义 service 的方式: | 方法 | 说明 | | --------- | ------------------------------------ | | `succeed` | 提供服务的一个静态实现。 | | `sync` | 使用同步构造器定义 service。 | | `effect` | 使用带 effect 的构造器定义 service。 | | `scoped` | 创建带生命周期管理的 service。 | **示例**(定义具有静态实现的 Service) 这是定义 service 最简单的方式。当你希望为 service 提供一个常量值时,它很有用。 ```ts import { Effect } from "effect" class MagicNumber extends Effect.Service()("MagicNumber", { succeed: { value: 42 }, }) {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const magicNumber = yield* MagicNumber console.log(`The magic number is ${magicNumber.value}`) }) Effect.runPromise(program.pipe(Effect.provide(MagicNumber.Default))) // The magic number is 42 ``` **示例**(定义具有同步构造器的 Service) ```ts import { Effect, Random } from "effect" class Sync extends Effect.Service()("Sync", { sync: () => ({ next: Random.nextInt, }), }) {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const sync = yield* Sync const n = yield* sync.next console.log(`The number is ${n}`) }) Effect.runPromise(program.pipe(Effect.provide(Sync.Default))) // Example Output: The number is 3858843290019673 ``` **示例**(定义具有生命周期控制的 Service) ```ts import { Effect, Console } from "effect" class Scoped extends Effect.Service()("Scoped", { scoped: Effect.gen(function* () { // Acquire the resource and ensure it is properly released const resource = yield* Effect.acquireRelease( Console.log("Acquiring...").pipe(Effect.as("foo")), () => Console.log("Releasing..."), ) // Register a finalizer to run when the effect is completed yield* Effect.addFinalizer(() => Console.log("Shutting down")) return { resource } }), }) {} // ┌─── Effect // ▼ const program = Effect.gen(function* () { const resource = (yield* Scoped).resource console.log(`The resource is ${resource}`) }) Effect.runPromise( program.pipe( Effect.provide( // ┌─── Layer // ▼ Scoped.Default, ), ), ) /* Acquiring... The resource is foo Shutting down Releasing... */ ``` `Scoped.Default` Layer 不需要 `Scope` 作为依赖,因为 `Scoped` 自身管理其生命周期。 ### 启用直接方法访问 通过设置 `accessors: true`,你可以直接用 service tag 调用 service 的方法,而不必先取出 service。 **示例**(定义支持直接方法访问的 Service) ```ts import { Effect, Random } from "effect" class Sync extends Effect.Service()("Sync", { sync: () => ({ next: Random.nextInt, }), accessors: true, // Enables direct method access via the tag }) {} const program = Effect.gen(function* () { // const sync = yield* Sync // const n = yield* sync.next const n = yield* Sync.next // No need to extract the service first console.log(`The number is ${n}`) }) Effect.runPromise(program.pipe(Effect.provide(Sync.Default))) // Example Output: The number is 3858843290019673 ``` ### Effect.Service vs Context.Tag `Effect.Service` 和 `Context.Tag` 都是在 Effect 生态中建模 service 的方式。它们用途相似,但面向不同的使用场景。 | 特性 | Effect.Service | Context.Tag | | ----------------------------- | ------------------------------------------------- | ------------------------ | | tag 的创建 | 自动为你生成(类名充当 tag) | 你需要手动声明 tag | | 默认实现 | **必需** —— 以内联方式提供(`effect`、`sync` 等) | **可选** —— 可以稍后提供 | | 现成的 Layer(`.Default` 等) | 自动生成 | 由你自己构建 Layer | | 最适合用于 | 具有明确运行时实现的应用代码 | 库代码或动态作用域的值 | | 当不存在合理的默认值时 | 并不理想;你仍然得自己编一个 | 更受推荐 | **要点** - **更少的样板代码:** `Effect.Service` 是 `Context.Tag` 加上配套 Layer 与辅助函数的语法糖。 - **必须提供默认实现:** 继承 `Effect.Service` 的类必须声明内置构造器中的**一个**(`effect`、`sync`、`succeed` 或 `scoped`)。这个基线实现会成为 `MyService.Default` 的一部分,因此任何导入该 service 的代码都无需额外提供 Layer 即可运行。 对于存在合理运行时实现的应用级 service(日志、HTTP 客户端、真实数据库等)来说,这很方便。 如果你的 service 本质上依赖上下文(例如每个请求各自的数据库句柄),或者你正在编写一个不应假定具体实现的库,那么请优先使用 `Context.Tag`:你只发布 tag,而由调用方提供适合其环境的 Layer。 - **类 _就是_ tag:** 当你用 `extends Effect.Service` 创建类时,类构造函数本身就充当 tag。在组装 Layer 时,你可以为该类提供一个值,从而提供替代实现: ```ts const mock = new MyService({/* mocked methods */}) program.pipe(Effect.provideService(MyService, mock)) ``` --- # 管理服务 > 了解如何在 Effect 中管理可复用的服务、高效地处理依赖,并在应用中保持整洁、解耦的架构。 在编程的语境中,**服务**(service)指的是可复用的组件或功能,应用的各个不同部分都可以使用它。 服务被设计用来提供特定的能力,并且可以跨多个模块或组件共享。 服务通常封装了应用不同部分都需要的常见任务或操作。 它们可以处理复杂的操作、与外部系统或 API 交互、管理数据,或执行其他专门的任务。 服务通常被设计为模块化,并且与应用的其他部分解耦。 这使得它们易于维护、测试和替换,而不会影响应用的整体功能。 在深入探讨服务以及它们在应用开发中的集成时,从函数管理和依赖处理的基本原则出发、而不依赖高级构造,会很有帮助。想象一下,你不得不手动把一个服务传递到每一个需要它的函数中: ```ts const processData = (data: Data, databaseService: DatabaseService) => { // Operations using the database service } ``` 随着应用的增长,服务需要在多层函数之间传递,这种方式会变得笨重且难以管理。 为了简化这一点,你可能会考虑使用一个环境(environment)对象来打包各种服务: ```ts type Context = { databaseService: DatabaseService loggingService: LoggingService } const processData = (data: Data, context: Context) => { // Using multiple services from the context } ``` 然而,这又引入了一个新的复杂性问题:你必须确保在使用环境之前,已经用所有必要的服务正确地配置好它,这会导致代码紧密耦合,并使函数式组合和测试变得更加困难。 ## 使用 Effect 管理服务 Effect 库通过利用类型系统简化了这些依赖的管理。 你无需手动传递服务或环境对象,Effect 允许你使用 `Effect` 类型中的 `Requirements` 参数,直接在函数的类型签名中声明服务依赖: ```ts ┌─── Represents required dependencies ▼ Effect ``` 在实际使用 Effect 时,它的工作方式如下: **依赖声明**:你直接在类型中指明某个函数需要哪些服务,从而把依赖管理的复杂性推给类型系统。 **服务提供**:使用 `Effect.provideService` 让需要服务的函数获得某个服务的实现。在一开始就提供服务,可以确保应用的所有部分都能一致地访问所需的服务,从而保持整洁、解耦的架构。 这种方式把手工处理服务的过程抽象掉了,让开发者可以专注于业务逻辑,而由编译器来确保所有依赖都被正确处理。它也让代码更易于维护和扩展。 让我们一步一步地了解如何在 Effect 中管理服务: 1. **创建服务**:定义具有独特功能和接口的服务。 2. **使用服务**:在应用的函数中访问并使用该服务。 3. **提供服务实现**:提供服务的实际实现,以满足所声明的需求。 ## 工作原理 到目前为止,我们使用 Effect 框架的示例处理的都是独立于外部服务运行的 effect。 这意味着我们 `Effect` 类型签名中的 `Requirements` 参数一直被设为 `never`,表示没有依赖。 然而,真实世界中的应用常常需要依赖特定服务才能正常运行的 effect。这些服务通过一个名为 `Context` 的构造来管理和访问。 `Context` 充当 effect 可能需要的所有服务的仓库或容器。 它就像一个保存这些服务的存储,让应用的各个部分都能按需访问和使用它们。 存储在 `Context` 中的服务会直接反映到 `Effect` 类型的 `Requirements` 参数中。 `Context` 中的每个服务都由一个唯一的 “tag”(标签)标识,tag 本质上就是该服务的唯一标识符。 当某个 effect 需要使用特定服务时,该服务的 tag 会被包含在 `Requirements` 类型参数中。 ## 创建服务 要创建一个新服务,你需要两样东西: 1. 一个唯一的**标识符**。 2. 一个描述该服务可能操作的**类型**。 **示例**(定义一个随机数生成服务) 让我们创建一个用于生成随机数的服务。 1. **标识符**。我们将使用字符串 `"MyRandomService"` 作为唯一标识符。 2. **类型**。该服务类型将包含一个名为 `next` 的单一操作,它返回一个随机数。 ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} ``` 导出的 `Random` 值在 Effect 中被称为 **tag**。它代表该服务,让 Effect 能够在运行时定位并使用这个服务。 该服务将被存储在一个名为 `Context` 的集合中,你可以把它看作一个 `Map`,其中键是 tag,值是服务: ```ts type Context = Map ``` 让我们总结一下目前涉及的概念: | 概念 | 说明 | | ------------- | --------------------------------------------------------------------------------- | | **服务** | 提供特定功能、在应用的不同部分之间复用的可复用组件。 | | **tag** | 代表某个服务的唯一标识符,让 Effect 能够定位并使用它。 | | **context** | 存储服务的集合,其作用类似于一个以 **tag** 为键、以服务为值的 map。 | ## 使用服务 现在我们已经定义好了服务的 tag,接下来通过构建一个简单的程序来看看如何使用它。 **示例**(使用 Random 服务) ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Using the service // // ┌─── Effect // ▼ const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) ``` 在上面的代码中,我们可以看到,我们能够像 yield 一个 effect 一样 yield `Random` tag。 这让我们能够访问该服务的 `next` 操作。 ```ts import { Effect, Context, Console } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Using the service // // ┌─── Effect // ▼ const program = Random.pipe( Effect.andThen((random) => random.next), Effect.andThen((randomNumber) => Console.log(`random number: ${randomNumber}`), ), ) ``` 在上面的代码中,我们可以看到,我们能够像对 effect 本身一样对 `Random` tag 进行 flat-map。 这让我们能够在 `Effect.andThen` 回调中访问该服务的 `next` 操作。 值得注意的是,`program` 变量的类型在 `Requirements` 类型参数中包含了 `Random`: ```ts const program: Effect ``` 这表明我们的程序需要提供 `Random` 服务才能成功执行。 如果我们尝试在没有提供必要服务的情况下执行该 effect,就会遇到类型检查错误: **示例**(未提供服务时的类型错误) ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Using the service const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // @errors: 2379 Effect.runSync(program) ``` 要解决这个错误并成功执行程序,我们需要为 `Random` 服务提供一个实际的实现。 在下一节中,我们将探讨如何实现 `Random` 服务并将其提供给我们的程序,从而让它成功运行。 ## 提供服务实现 为了给 `Random` 服务提供一个实际的实现,我们可以使用 `Effect.provideService` 函数。 **示例**(提供一个随机数实现) ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Using the service const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // Providing the implementation // // ┌─── Effect // ▼ const runnable = Effect.provideService(program, Random, { next: Effect.sync(() => Math.random()), }) // Run successfully Effect.runPromise(runnable) /* Example Output: random number: 0.8241872233134417 */ ``` 在上面的代码中,我们为之前定义的 `program` 提供了 `Random` 服务的一个实现。 我们使用 `Effect.provideService` 函数把 `Random` tag 与它的实现关联起来,该实现是一个带有 `next` 操作、用于生成随机数的对象。 注意,现在 `runnable` effect 的 `Requirements` 类型参数是 `never`。这表明该 effect 不再需要提供任何服务。 有了 `Random` 服务的实现,我们就能够在没有任何额外需求的情况下运行这个程序了。 ## 提取服务类型 要从 tag 中取出服务类型,请使用 `Context.Tag.Service` 工具类型。 **示例**(提取服务类型) ```ts import { Effect, Context } from "effect" // Declaring a tag class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Extracting the type type RandomShape = Context.Tag.Service /* This is equivalent to: type RandomShape = { readonly next: Effect.Effect; } */ ``` ## 使用多个服务 当我们需要使用多个服务时,流程与我们在定义服务时学到的基本相同,只需对每个所需的服务重复一遍即可。 **示例**(同时使用 Random 和 Logger 服务) 让我们看一个需要两个服务的例子,即 `Random` 和 `Logger`: ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Declaring a tag for the logging service class Logger extends Context.Tag("MyLoggerService")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} const program = Effect.gen(function* () { // Acquire instances of the 'Random' and 'Logger' services const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next yield* logger.log(String(randomNumber)) }) ``` 现在 `program` effect 的 `Requirements` 类型参数是 `Random | Logger`: ```ts const program: Effect ``` 这表明它需要同时提供 `Random` 和 `Logger` 这两个服务。 要执行 `program`,我们需要为这两个服务都提供实现: **示例**(提供多个服务) ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Declaring a tag for the logging service class Logger extends Context.Tag("MyLoggerService")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} const program = Effect.gen(function* () { const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next return yield* logger.log(String(randomNumber)) }) // Provide service implementations for 'Random' and 'Logger' const runnable = program.pipe( Effect.provideService(Random, { next: Effect.sync(() => Math.random()), }), Effect.provideService(Logger, { log: (message) => Effect.sync(() => console.log(message)), }), ) ``` 或者,我们不必多次调用 `provideService`,而是可以把这些服务实现合并到一个单独的 `Context` 中,然后使用 `Effect.provide` 函数提供整个 context: **示例**(合并服务实现) ```ts import { Effect, Context } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} // Declaring a tag for the logging service class Logger extends Context.Tag("MyLoggerService")< Logger, { readonly log: (message: string) => Effect.Effect } >() {} const program = Effect.gen(function* () { const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next return yield* logger.log(String(randomNumber)) }) // Combine service implementations into a single 'Context' const context = Context.empty().pipe( Context.add(Random, { next: Effect.sync(() => Math.random()) }), Context.add(Logger, { log: (message) => Effect.sync(() => console.log(message)), }), ) // Provide the entire context const runnable = Effect.provide(program, context) ``` ## 可选服务 有些情况下,我们可能只想在服务实现可用时才访问它。 在这种情况下,我们可以使用 `Effect.serviceOption` 函数来处理这种场景。 `Effect.serviceOption` 函数返回一个实现,只有当它确实在执行该 effect 之前被提供时,它才是可用的。 为了表示这种可选性,它返回该实现的一个 [Option](/docs/v3/data-types/option/)。 **示例**(处理可选服务) 为了决定采取什么操作,我们可以使用 Option 模块提供的 `Option.isNone` 函数。当服务不可用时,该函数会返回 `true`,借此我们可以检查服务是否可用。 ```ts import { Effect, Context, Option } from "effect" // Declaring a tag for a service that generates random numbers class Random extends Context.Tag("MyRandomService")< Random, { readonly next: Effect.Effect } >() {} const program = Effect.gen(function* () { const maybeRandom = yield* Effect.serviceOption(Random) const randomNumber = Option.isNone(maybeRandom) ? // the service is not available, return a default value -1 : // the service is available yield* maybeRandom.value.next console.log(randomNumber) }) ``` 在上面的代码中,我们可以看到,即使我们正在使用一个服务,`program` effect 的 `Requirements` 类型参数仍然是 `never`。这让我们只有在服务确实于执行该 effect 之前被提供时,才能从 context 中访问到东西。 当我们在不提供 `Random` 服务的情况下运行 `program` effect 时: ```ts Effect.runPromise(program).then(console.log) // Output: -1 ``` 我们看到日志消息中包含 `-1`,这正是我们在服务不可用时提供的默认值。 然而,如果我们提供 `Random` 服务的实现: ```ts Effect.runPromise( Effect.provideService(program, Random, { next: Effect.sync(() => Math.random()), }), ).then(console.log) // Example Output: 0.9957979486841035 ``` 我们可以看到,日志消息现在包含一个由 `Random` 服务的 `next` 操作生成的随机数。 ## 处理带依赖的服务 有时应用中的某个服务可能依赖其他服务。为了保持整洁的架构,重要的是在不把这些依赖暴露到服务接口中的情况下管理它们。相反,你可以在服务构建阶段使用 **Layer** 来处理这些依赖。 **示例**(定义一个带配置依赖的 Logger 服务) 考虑一个多个服务相互依赖的场景。在这个例子中,`Logger` 服务需要访问一个配置服务(`Config`)。 ```ts import { Effect, Context } from "effect" // Declaring a tag for the Config service class Config extends Context.Tag("Config")() {} // Declaring a tag for the logging service class Logger extends Context.Tag("MyLoggerService")< Logger, { // ❌ Avoid exposing Config as a requirement readonly log: (message: string) => Effect.Effect } >() {} ``` 为了以结构化的方式处理这些依赖,并防止它们泄漏到服务接口中,你可以使用 `Layer` 抽象。关于使用 Layer 管理依赖的更多细节,请参阅 [管理 Layer](/docs/v3/requirements-management/layers/) 页面。 --- # 简介 > 安全资源管理的常见模式 在长时间运行的应用程序中,高效地管理资源至关重要,尤其是在构建大规模系统时。如果 socket 连接、数据库连接或文件描述符这类资源没有得到妥善管理,就可能导致资源泄漏,进而降低应用程序的性能与可靠性。Effect 提供了一些构造,帮助确保资源被妥善管理与释放,即使发生异常也是如此。 通过确保每次获取资源时都有对应的释放机制,Effect 简化了应用程序中资源管理的过程。 ## 终结处理 在许多编程语言中,`try` / `finally` 构造确保清理代码无论操作成功还是失败都会运行。Effect 通过 `Effect.ensuring`、`Effect.onExit` 和 `Effect.onError` 提供了类似的功能。 ### ensuring `Effect.ensuring` 函数保证终结器 effect 无论主 effect 成功、失败还是被中断都会运行。 这适用于执行清理操作,例如关闭文件句柄、记录日志消息或释放锁。 如果你需要访问 effect 的结果,请考虑使用 [onExit](#onexit)。 **示例**(在所有结果下运行终结器) ```ts import { Console, Effect } from "effect" // Define a cleanup effect const handler = Effect.ensuring(Console.log("Cleanup completed")) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed Cleanup completed */ // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed */ // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed */ ``` ### onExit `Effect.onExit` 允许你在主 effect 完成后运行一个清理 effect,并接收一个描述执行结果的 [Exit](/docs/v3/data-types/exit/) 值。 - 如果 effect 成功,`Exit` 持有成功值。 - 如果 effect 失败,`Exit` 包含错误或失败原因。 - 如果 effect 被中断,`Exit` 会反映该中断。 清理步骤本身是不可中断的,这有助于在复杂或高并发的情况下管理资源。 **示例**(使用 effect 的结果运行清理函数) ```ts import { Console, Effect, Exit } from "effect" // Define a cleanup effect that logs the result const handler = Effect.onExit((exit) => Console.log(`Cleanup completed: ${Exit.getOrElse(exit, String)}`), ) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed Cleanup completed: some result */ // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed: Error: some error */ // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed: All fibers interrupted without errors. */ ``` ### onError 这个函数让你可以附加一个清理 effect,只要调用它的 effect 失败就会运行,并把失败的原因传递给该清理 effect。 你可以用它来执行诸如记录日志、释放资源或应用额外恢复步骤之类的操作。 如果失败是由中断引起的,清理 effect 也会运行;而且它是不可中断的,因此一旦开始就总会执行完成。 **示例**(仅在失败时运行清理) ```ts import { Console, Effect } from "effect" // This handler logs the failure cause when the effect fails const handler = Effect.onError((cause) => Console.log(`Cleanup completed: ${cause}`), ) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed */ // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed: Error: some error */ // Define a failing effect const defect = Console.log("Task failed with defect").pipe( Effect.andThen(Effect.die("Boom!")), handler, ) Effect.runFork(defect) /* Output: Task failed with defect Cleanup completed: Error: Boom! */ // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed: All fibers interrupted without errors. */ ``` ## acquireUseRelease 许多真实场景中的操作都涉及使用那些不再需要时必须释放的资源,例如: - 数据库连接 - 文件句柄 - 网络请求 Effect 提供了 `Effect.acquireUseRelease`,它确保资源能够: 1. 被正确地**获取**(Acquired)。 2. 被用于其预期用途(**Used**)。 3. 即使发生错误也能被**释放**(Released)。 **语法** ```ts Effect.acquireUseRelease(acquire, use, release) ``` **示例**(自动管理资源生命周期) ```ts import { Effect, Console } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) const use = (res: MyResource) => Console.log(`content is ${res.contents}`) // ┌─── Effect // ▼ const program = Effect.acquireUseRelease(acquire, use, release) Effect.runPromise(program) /* Output: Resource acquired content is lorem ipsum Resource released */ ``` --- # Scope > 了解 Effect 如何借助 Scope 简化资源管理,确保在长时间运行的应用程序中高效清理并安全地处理资源。 `Scope` 数据类型是 Effect 中的核心构件,用于以安全且可组合的方式管理资源。 一个 scope 代表一个或多个资源的生命周期。当 scope 被关闭时,其中的所有资源都会被释放,从而确保不会有资源泄漏。Scope 还允许添加 **finalizer**,由它来定义如何释放资源。 借助 `Scope` 数据类型,你可以: - **添加 finalizer**:finalizer 指定资源的清理逻辑。 - **关闭 scope**:当 scope 被关闭时,所有资源都会被释放,且 finalizer 会被执行。 **示例**(管理 Scope) ```ts import { Scope, Effect, Console, Exit } from "effect" const program = // create a new scope Scope.make().pipe( // add finalizer 1 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("finalizer 1")), ), // add finalizer 2 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("finalizer 2")), ), // close the scope Effect.andThen((scope) => Scope.close(scope, Exit.succeed("scope closed successfully")), ), ) Effect.runPromise(program) /* Output: finalizer 2 <-- finalizers are closed in reverse order finalizer 1 */ ``` 在上面的示例中,finalizer 被添加到 scope 中;当 scope 被关闭时,这些 finalizer 会**以相反的顺序执行**。 这种相反的顺序很重要,因为它能确保资源按正确的次序释放。 例如,如果你先获取一个网络连接,然后访问远程服务器上的文件,那么必须先关闭文件再关闭网络连接,以避免出错。 ## addFinalizer `Effect.addFinalizer` 函数是一个高层 API,它允许你把 finalizer 添加到某个 effect 的 scope 中。finalizer 是一段保证会在关联 scope 关闭时运行的代码。finalizer 的行为会根据 [Exit](/docs/v3/data-types/exit/) 值而变化,该值表示 scope 是以何种方式关闭的:是成功还是出错。 **示例**(在成功时添加 finalizer) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return "some result" }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Success { _id: 'Exit', _tag: 'Success', value: 'some result' } */ ``` ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.succeed("some result"))) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Success { _id: 'Exit', _tag: 'Success', value: 'some result' } */ ``` 在这个示例中,我们使用 `Effect.addFinalizer` 添加一个 finalizer,它会在 scope 关闭后记录 exit 状态。该 finalizer 会在 effect 结束时执行,并记录 effect 是成功完成还是失败。 类型签名如下: ```ts const program: Effect ``` 这表明该工作流需要 `Scope` 才能运行。你可以使用 `Effect.scoped` 函数来提供这个 `Scope`:它会创建一个新的 scope,在其中运行该 effect,并确保 scope 关闭时执行这些 finalizer。 **示例**(在失败时添加 finalizer) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return yield* Effect.fail("Uh oh!") }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Uh oh!' } } */ ``` ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.fail("Uh oh!"))) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Uh oh!' } } */ ``` 在这种情况下,即使 effect 失败,finalizer 也会执行。日志输出表明 finalizer 在失败之后运行,并记录了失败的详细信息。 **示例**(在[中断](/docs/v3/concurrency/basic-concurrency/#interruptions)时添加 finalizer) ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return yield* Effect.interrupt }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ ``` ```ts import { Effect, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.interrupt)) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ ``` 这个示例展示了 effect 被中断时 finalizer 的行为。finalizer 会在中断之后运行,而 exit 状态也反映出该 effect 是在执行途中被停止的。 ## 手动创建和关闭 Scope 当你在单个操作中处理多个受 scope 管理的资源时,理解这些 scope 之间如何交互很重要。 默认情况下,这些 scope 会合并成一个,但你可以手动创建和关闭 scope,从而更精细地控制每个 scope 的关闭时机。 我们先来看看默认情况下 scope 是如何合并的: **示例**(合并 scope) ```ts import { Effect, Console } from "effect" const task1 = Effect.gen(function* () { console.log("task 1") yield* Effect.addFinalizer(() => Console.log("finalizer after task 1")) }) const task2 = Effect.gen(function* () { console.log("task 2") yield* Effect.addFinalizer(() => Console.log("finalizer after task 2")) }) const program = Effect.gen(function* () { // The scopes of both tasks are merged into one yield* task1 yield* task2 }) Effect.runPromise(Effect.scoped(program)) /* Output: task 1 task 2 finalizer after task 2 finalizer after task 1 */ ``` 在这里,`task1` 和 `task2` 的 scope 被合并成单个 scope;运行该程序时,它会以特定顺序输出这些任务及其 finalizer。 如果你想更精细地控制每个 scope 的关闭时机,可以手动创建和关闭它们: **示例**(手动创建和关闭 scope) ```ts import { Console, Effect, Exit, Scope } from "effect" const task1 = Effect.gen(function* () { console.log("task 1") yield* Effect.addFinalizer(() => Console.log("finalizer after task 1")) }) const task2 = Effect.gen(function* () { console.log("task 2") yield* Effect.addFinalizer(() => Console.log("finalizer after task 2")) }) const program = Effect.gen(function* () { const scope1 = yield* Scope.make() const scope2 = yield* Scope.make() // Extend the scope of task1 into scope1 yield* task1.pipe(Scope.extend(scope1)) // Extend the scope of task2 into scope2 yield* task2.pipe(Scope.extend(scope2)) // Manually close scope1 and scope2 yield* Scope.close(scope1, Exit.void) yield* Console.log("doing something else") yield* Scope.close(scope2, Exit.void) }) Effect.runPromise(program) /* Output: task 1 task 2 finalizer after task 1 doing something else finalizer after task 2 */ ``` 在这个示例中,我们创建了两个独立的 scope:`scope1` 和 `scope2`,并把每个任务的 scope 扩展进各自的 scope。运行该程序时,它输出的任务及其 finalizer 顺序有所不同。 你可能会好奇:如果 scope 已经关闭,但该 scope 中的某个任务尚未完成,会发生什么? 关键在于,关闭 scope 并不会强制中断该任务。 **示例**(在存在未完成任务时关闭 scope) ```ts import { Console, Effect, Exit, Scope } from "effect" const task = Effect.gen(function* () { yield* Effect.sleep("1 second") console.log("Executed") yield* Effect.addFinalizer(() => Console.log("Task Finalizer")) }) const program = Effect.gen(function* () { const scope = yield* Scope.make() // Close the scope immediately yield* Scope.close(scope, Exit.void) console.log("Scope closed") // This task will be executed even if the scope is closed yield* task.pipe(Scope.extend(scope)) }) Effect.runPromise(program) /* Output: Scope closed Executed <-- after 1 second Task Finalizer */ ``` ## 定义资源 ### acquireRelease `Effect.acquireRelease(acquire, release)` 函数允许你定义这样的资源:它们会被获取,并在不再需要时被安全地释放。这对于管理文件句柄、数据库连接或网络套接字之类的资源很有用。 要使用 `Effect.acquireRelease`,你需要定义两个动作: 1. **获取资源**:描述获取该资源的 effect,例如打开文件或建立数据库连接。 2. **释放资源**:确保资源被正确释放的清理 effect,例如关闭文件或连接。 获取过程是**不可中断**的,以确保部分获取资源不会让系统处于不一致的状态。 `Effect.acquireRelease` 函数保证:一旦资源被成功获取,当 `Scope` 关闭时,它的释放步骤总是会被执行。 **示例**(定义一个简单资源) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow // // ┌─── Effect // ▼ const resource = Effect.acquireRelease(acquire, release) ``` 在上面的代码中,`Effect.acquireRelease` 函数创建了一个需要 `Scope` 的资源工作流: ```ts const resource: Effect ``` 这意味着该工作流需要一个 `Scope` 才能运行,并且当 scope 关闭时,资源会被自动释放。 现在,你可以使用 `Effect.andThen` 或类似函数来串联操作,从而使用这个资源。 借助 `Effect.andThen` 或其他 Effect 操作符,我们可以按自己的需要长时间继续使用该资源。例如,下面展示了如何读取其中的内容: **示例**(使用该资源) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow const resource = Effect.acquireRelease(acquire, release) // ┌─── Effect // ▼ const program = Effect.gen(function* () { const res = yield* resource console.log(`content is ${res.contents}`) }) ``` 为确保资源得到妥善管理,当你用完资源后应当关闭 `Scope`。`Effect.scoped` 函数会替你处理这件事:它创建一个 `Scope`,运行该 effect,并在 effect 结束时关闭这个 `Scope`。 **示例**(使用 `Effect.scoped` 提供 `Scope`) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow const resource = Effect.acquireRelease(acquire, release) // ┌─── Effect // ▼ const program = Effect.scoped( Effect.gen(function* () { const res = yield* resource console.log(`content is ${res.contents}`) }), ) // We now have a workflow that is ready to run Effect.runPromise(program) /* Resource acquired content is lorem ipsum Resource released */ ``` ### 示例模式:顺序执行操作 在某些场景中,你可能需要执行一连串链式操作,其中每个操作能否成功都取决于前一个操作。但是,只要其中有任何操作失败,你就希望撤销此前所有成功操作产生的影响。当你需要确保要么所有操作都成功、要么它们都不产生任何影响时,这个模式就很有价值。 让我们看一个实现该模式的示例。假设我们要在应用中创建一个“Workspace”,这涉及创建一个 S3 存储桶、一个 ElasticSearch 索引,以及一条依赖前两者的 Database 记录。 首先,我们为所需的[服务](/docs/v3/requirements-management/services/)定义领域模型: - `S3` - `ElasticSearch` - `Database` ```ts import { Effect, Context, Data } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Tag("S3")< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >() {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Tag("ElasticSearch")< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >() {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Tag("Database")< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >() {} ``` 接下来,我们定义三个 create 操作,以及 Workspace 的总体事务(`make`)。 ```ts import { Effect, Context, Exit, Data } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Tag("S3")< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >() {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Tag("ElasticSearch")< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >() {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Tag("Database")< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >() {} // Create a bucket, and define the release function that deletes the // bucket if the operation fails. const createBucket = Effect.gen(function* () { const { createBucket, deleteBucket } = yield* S3 return yield* Effect.acquireRelease(createBucket, (bucket, exit) => // The release function for the Effect.acquireRelease operation is // responsible for handling the acquired resource (bucket) after the // main effect has completed. It is called regardless of whether the // main effect succeeded or failed. If the main effect failed, // Exit.isFailure(exit) will be true, and the function will perform // a rollback by calling deleteBucket(bucket). If the main effect // succeeded, Exit.isFailure(exit) will be false, and the function // will return Effect.void, representing a successful, but // do-nothing effect. Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void, ) }) // Create an index, and define the release function that deletes the // index if the operation fails. const createIndex = Effect.gen(function* () { const { createIndex, deleteIndex } = yield* ElasticSearch return yield* Effect.acquireRelease(createIndex, (index, exit) => Exit.isFailure(exit) ? deleteIndex(index) : Effect.void, ) }) // Create an entry in the database, and define the release function that // deletes the entry if the operation fails. const createEntry = (bucket: Bucket, index: Index) => Effect.gen(function* () { const { createEntry, deleteEntry } = yield* Database return yield* Effect.acquireRelease( createEntry(bucket, index), (entry, exit) => Exit.isFailure(exit) ? deleteEntry(entry) : Effect.void, ) }) const make = Effect.scoped( Effect.gen(function* () { const bucket = yield* createBucket const index = yield* createIndex return yield* createEntry(bucket, index) }), ) ``` 随后我们创建一些简单的 service 实现,用来测试 Workspace 代码的行为。为此,我们会利用 [layers](/docs/v3/requirements-management/layers/) 来构造测试。这些 layer 能够处理各种场景(包括错误),我们可以通过 `FailureCase` 类型来控制它们。 ```ts import { Effect, Context, Exit, Data, Layer, Console } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Tag("S3")< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >() {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Tag("ElasticSearch")< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >() {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Tag("Database")< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >() {} // Create a bucket, and define the release function that deletes the // bucket if the operation fails. const createBucket = Effect.gen(function* () { const { createBucket, deleteBucket } = yield* S3 return yield* Effect.acquireRelease(createBucket, (bucket, exit) => // The release function for the Effect.acquireRelease operation is // responsible for handling the acquired resource (bucket) after the // main effect has completed. It is called regardless of whether the // main effect succeeded or failed. If the main effect failed, // Exit.isFailure(exit) will be true, and the function will perform // a rollback by calling deleteBucket(bucket). If the main effect // succeeded, Exit.isFailure(exit) will be false, and the function // will return Effect.void, representing a successful, but // do-nothing effect. Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void, ) }) // Create an index, and define the release function that deletes the // index if the operation fails. const createIndex = Effect.gen(function* () { const { createIndex, deleteIndex } = yield* ElasticSearch return yield* Effect.acquireRelease(createIndex, (index, exit) => Exit.isFailure(exit) ? deleteIndex(index) : Effect.void, ) }) // Create an entry in the database, and define the release function that // deletes the entry if the operation fails. const createEntry = (bucket: Bucket, index: Index) => Effect.gen(function* () { const { createEntry, deleteEntry } = yield* Database return yield* Effect.acquireRelease( createEntry(bucket, index), (entry, exit) => Exit.isFailure(exit) ? deleteEntry(entry) : Effect.void, ) }) const make = Effect.scoped( Effect.gen(function* () { const bucket = yield* createBucket const index = yield* createIndex return yield* createEntry(bucket, index) }), ) // The `FailureCaseLiterals` type allows us to provide different error // scenarios while testing our // // For example, by providing the value "S3", we can simulate an error // scenario specific to the S3 service. This helps us ensure that our // program handles errors correctly and behaves as expected in various // situations. // // Similarly, we can provide other values like "ElasticSearch" or // "Database" to simulate error scenarios for those In cases // where we want to test the absence of errors, we can provide // `undefined`. By using this parameter, we can thoroughly test our // services and verify their behavior under different error conditions. type FailureCaseLiterals = "S3" | "ElasticSearch" | "Database" | undefined class FailureCase extends Context.Tag("FailureCase")< FailureCase, FailureCaseLiterals >() {} // Create a test layer for the S3 service const S3Test = Layer.effect( S3, Effect.gen(function* () { const failureCase = yield* FailureCase return { createBucket: Effect.gen(function* () { console.log("[S3] creating bucket") if (failureCase === "S3") { return yield* Effect.fail(new S3Error()) } else { return { name: "" } } }), deleteBucket: (bucket) => Console.log(`[S3] delete bucket ${bucket.name}`), } }), ) // Create a test layer for the ElasticSearch service const ElasticSearchTest = Layer.effect( ElasticSearch, Effect.gen(function* () { const failureCase = yield* FailureCase return { createIndex: Effect.gen(function* () { console.log("[ElasticSearch] creating index") if (failureCase === "ElasticSearch") { return yield* Effect.fail(new ElasticSearchError()) } else { return { id: "" } } }), deleteIndex: (index) => Console.log(`[ElasticSearch] delete index ${index.id}`), } }), ) // Create a test layer for the Database service const DatabaseTest = Layer.effect( Database, Effect.gen(function* () { const failureCase = yield* FailureCase return { createEntry: (bucket, index) => Effect.gen(function* () { console.log( "[Database] creating entry for bucket" + `${bucket.name} and index ${index.id}`, ) if (failureCase === "Database") { return yield* Effect.fail(new DatabaseError()) } else { return { id: "" } } }), deleteEntry: (entry) => Console.log(`[Database] delete entry ${entry.id}`), } }), ) // Merge all the test layers for S3, ElasticSearch, and Database // services into a single layer const layer = Layer.mergeAll(S3Test, ElasticSearchTest, DatabaseTest) // Create a runnable effect to test the Workspace code. The effect is // provided with the test layer and a FailureCase service with undefined // value (no failure case). const runnable = make.pipe( Effect.provide(layer), Effect.provideService(FailureCase, undefined), ) Effect.runPromise(Effect.either(runnable)).then(console.log) ``` 我们来看看 `FailureCase` 被设为 `undefined`(正常路径)时的测试结果: ```ansi [S3] creating bucket [ElasticSearch] creating index [Database] creating entry for bucket and index { _id: 'Either', _tag: 'Right', right: { id: '' } } ``` 在这个例子中,所有操作都成功,我们看到了一个成功的结果 `right({ id: '' })`。 现在,让我们模拟一次 `Database` 失败: ```ts const runnable = make.pipe( Effect.provide(layer), Effect.provideService(FailureCase, "Database"), ) ``` 控制台输出将是: ```ansi [S3] creating bucket [ElasticSearch] creating index [Database] creating entry for bucket and index [ElasticSearch] delete index [S3] delete bucket { _id: 'Either', _tag: 'Left', left: { _tag: 'DatabaseError' } } ``` 你可以看到,一旦发生 `Database` 错误,就会有一次完整的回滚:先删除 `ElasticSearch` 索引,再删除关联的 `S3` 存储桶。结果是一个包含 `DatabaseError` 的失败,即 `left(new DatabaseError())`。 现在,让我们改为让索引创建失败: ```ts const runnable = make.pipe( Effect.provide(layer), Effect.provideService(FailureCase, "ElasticSearch"), ) ``` 在这种情况下,控制台输出将是: ```ansi [S3] creating bucket [ElasticSearch] creating index [S3] delete bucket { _id: 'Either', _tag: 'Left', left: { _tag: 'ElasticSearchError' } } ``` 如预期的那样,一旦 `ElasticSearch` 索引创建失败,就会发生一次回滚,删除 `S3` 存储桶。结果是一个包含 `ElasticSearchError` 的失败,即 `left(new ElasticSearchError())`。 --- # Runtime 入门 > 了解 Effect 的运行时系统如何以灵活高效的方式执行并发程序、管理资源并处理配置。 `Runtime` 数据类型表示一个能够**执行 effect** 的运行时系统。要运行一个 effect `Effect`,我们需要一个 `Runtime`,它包含由 `R` 类型参数所表示的必需资源。 `Runtime` 由三个主要部分组成: - 一个 `Context` 类型的值 - 一个 `FiberRefs` 类型的值 - 一个 `RuntimeFlags` 类型的值 ## 什么是运行时系统? 当我们编写 Effect 程序时,我们会使用各种构造器和组合子来构造一个 `Effect`。本质上,我们是在创建一份程序的蓝图。`Effect` 只是一个描述并发程序执行过程的数据结构。它表现为一种树状结构,把各种原语组合在一起,定义该 effect 应该做什么。 然而,这个数据结构本身不会执行任何动作,它仅仅是对一个并发程序的描述。 要执行这个程序,就需要 Effect 运行时系统登场。`Runtime.run*` 系列函数(例如 `Runtime.runPromise`、`Runtime.runFork`)负责接收这份蓝图并执行它。 当运行时系统运行一个 effect 时,它会创建一个根 Fiber,并用以下内容初始化它: - 初始 [context](/docs/v3/requirements-management/services/#how-it-works) - 初始的 `FiberRefs` - 初始 effect 然后它启动一个循环,逐步执行 `Effect` 所描述的指令。 你可以把运行时看作这样一个系统:它接收一个 [`Effect`](/docs/v3/getting-started/the-effect-type/) 及其关联的 context `Context`,并产出 [`Exit`](/docs/v3/data-types/exit/) 结果。 ```text ┌────────────────────────────────┐ │ Context + Effect │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ Effect Runtime System │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ Exit │ └────────────────────────────────┘ ``` 运行时系统肩负着许多职责: | 职责 | 说明 | | --- | --- | | **执行程序** | 运行时必须循环执行 effect 的每一个步骤,直到程序完成。 | | **处理错误** | 它同时处理执行过程中出现的预期错误与意外错误。 | | **管理并发** | 当调用 `Effect.fork` 时,运行时会生成新的 Fiber 来处理并发操作。 | | **协作式让出** | 它确保 Fiber 不会独占资源,并在必要时让出控制权。 | | **确保资源清理** | 运行时保证终结器正确运行,以便在需要时清理资源。 | | **处理异步回调** | 运行时透明地处理异步操作,让你可以用统一的方式编写异步与同步代码。 | ## 默认运行时 当我们使用[运行 effect 的函数](/docs/v3/getting-started/running-effects/)(如 `Effect.runPromise` 或 `Effect.runFork`)时,我们实际上在不知不觉中使用了**默认运行时**。这些函数是为了方便我们用默认运行时执行 effect 而设计的快捷方式。 每个 `Effect.run*` 函数在内部都会调用对应的 `Runtime.run*` 函数,并把默认运行时传进去。例如,`Effect.runPromise` 只是 `Runtime.runPromise(defaultRuntime)` 的别名。 下面两种执行方式在功能上完全等价: **示例**(使用默认运行时运行 effect) ```ts import { Effect, Runtime } from "effect" const program = Effect.log("Application started!") Effect.runPromise(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" */ Runtime.runPromise(Runtime.defaultRuntime)(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" */ ``` 两种情况下,程序都使用默认运行时运行,并产生相同的输出。 默认运行时包括: - 一个空的 [context](/docs/v3/requirements-management/services/#how-it-works) - 一组包含[默认 services](/docs/v3/requirements-management/default-services/) 的 `FiberRefs` - 一份默认的 `RuntimeFlags` 配置,其中启用了 `Interruption` 和 `CooperativeYielding` 在大多数场景下,使用默认运行时已足以执行 effect。不过,有些情况下创建一个自定义 runtime 会很有帮助,尤其是当你需要复用特定的配置或 context 时。 例如,在 React 应用里,或者在服务器上响应 API 请求执行操作时,你可能会通过初始化一个 [layer](/docs/v3/requirements-management/layers/) `Layer` 来创建 `Runtime`。这样你就能在不同的执行边界之间保持一致 context。 ## 局部作用域的运行时配置 在 Effect 中,运行时配置通常从父级工作流**继承**。这意味着,当我们在某个工作流内部访问运行时配置或获取一个 runtime 时,实际上使用的就是父级工作流的配置。 不过,有时我们想临时**覆盖代码中某个特定部分的运行时配置**。这个概念称为局部作用域的运行时配置。一旦该代码区域的执行结束,运行时配置就会**恢复**为原来的设置。 为此,我们使用 `Effect.provide` 函数,它允许我们把新的运行时配置提供给代码的某个特定区段。 **示例**(覆盖 Logger 配置) 在这个示例中,我们用 `Logger.replace` 创建一个简单的 logger,它用一个记录消息时不带时间戳和级别的自定义 logger 替换默认 logger。然后我们用 `Effect.provide` 把这个自定义 logger 应用到程序上。 ```ts import { Logger, Effect } from "effect" const addSimpleLogger = Logger.replace( Logger.defaultLogger, // Custom logger implementation Logger.make(({ message }) => console.log(message)), ) const program = Effect.gen(function* () { yield* Effect.log("Application started!") yield* Effect.log("Application is about to exit!") }) // Running with the default logger Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" timestamp=... level=INFO fiber=#0 message="Application is about to exit!" */ // Overriding the default logger with a custom one Effect.runFork(program.pipe(Effect.provide(addSimpleLogger))) /* Output: [ 'Application started!' ] [ 'Application is about to exit!' ] */ ``` 为了确保运行时配置只应用于 Effect 应用的某个特定部分,我们应该只把配置 layer 提供给那一个部分。 **示例**(把配置 layer 提供给嵌套的工作流) 在这个示例中,我们演示如何只把自定义 logger 配置应用到程序的某个特定区段。程序的大部分都使用默认 logger,但当我们应用 `Effect.provide(addSimpleLogger)` 调用时,它会覆盖那个特定嵌套块内部的 logger。之后,配置会恢复为原来的状态。 ```ts import { Logger, Effect } from "effect" const addSimpleLogger = Logger.replace( Logger.defaultLogger, // Custom logger implementation Logger.make(({ message }) => console.log(message)), ) const removeDefaultLogger = Logger.remove(Logger.defaultLogger) const program = Effect.gen(function* () { // Logs with default logger yield* Effect.log("Application started!") yield* Effect.gen(function* () { // This log is suppressed yield* Effect.log("I'm not going to be logged!") // Custom logger applied here yield* Effect.log("I will be logged by the simple logger.").pipe( Effect.provide(addSimpleLogger), ) // This log is suppressed yield* Effect.log( "Reset back to the previous configuration, so I won't be logged.", ) }).pipe( // Remove the default logger temporarily Effect.provide(removeDefaultLogger), ) // Logs with default logger again yield* Effect.log("Application is about to exit!") }) Effect.runSync(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" [ 'I will be logged by the simple logger.' ] timestamp=... level=INFO fiber=#0 message="Application is about to exit!" */ ``` ## ManagedRuntime 在开发 Effect 应用并使用 `Effect.run*` 函数执行它时,应用会在幕后自动使用默认运行时运行。虽然可以通过 `Effect.provide` 提供局部作用域的配置 layer 来调整应用的特定部分,但有些场景下你可能想**从顶层为整个应用自定义运行时配置**。 在这些情况下,你可以使用 `ManagedRuntime.make` 构造器把一个配置 layer 转换成 runtime,从而创建顶层 runtime。 **示例**(创建并使用自定义 ManagedRuntime) 在这个示例中,我们首先创建一个名为 `appLayer` 的自定义配置 layer,它用一个会把消息输出到控制台的简单 logger 替换默认 logger。接着,我们用 `ManagedRuntime.make` 把这个配置 layer 变成 runtime。 ```ts import { Effect, ManagedRuntime, Logger } from "effect" // Define a configuration layer that replaces the default logger const appLayer = Logger.replace( Logger.defaultLogger, // Custom logger implementation Logger.make(({ message }) => console.log(message)), ) // Create a custom runtime from the configuration layer const runtime = ManagedRuntime.make(appLayer) const program = Effect.log("Application started!") // Execute the program using the custom runtime runtime.runSync(program) // Clean up resources associated with the custom runtime Effect.runFork(runtime.disposeEffect) /* Output: [ 'Application started!' ] */ ``` ### Effect.Tag 在与需要四处传递的 runtime 打交道时,`Effect.Tag` 可以帮助简化对 service 的访问。它让你可以定义一个新的 tag,并把 service 的形状直接嵌入到该 tag 类的静态属性中。 **示例**(为通知定义一个 Tag) ```ts import { Effect } from "effect" class Notifications extends Effect.Tag("Notifications")< Notifications, { readonly notify: (message: string) => Effect.Effect } >() {} ``` 在这个设置中,service 的各个字段(这里指 `notify` 方法)会变成 `Notifications` 类的静态属性,从而更容易访问它们。 这让你可以直接与该 service 交互: **示例**(使用 Notifications Tag) ```ts import { Effect } from "effect" class Notifications extends Effect.Tag("Notifications")< Notifications, { readonly notify: (message: string) => Effect.Effect } >() {} // Create an effect that depends on the Notifications service // // ┌─── Effect // ▼ const action = Notifications.notify("Hello, world!") ``` 在这个示例中,`action` effect 依赖于 `Notifications` service。这种方式让你无需手动传递就能引用 service。之后,你可以创建一个提供 `Notifications` service 的 `Layer`,并用该 layer 构建 `ManagedRuntime`,以确保该 service 在需要的地方可用。 ### 集成 `ManagedRuntime` 简化了 service 与 layer 同其他框架或工具的集成,尤其是在 Effect 并非主要框架、且对主入口点的访问受到限制的环境中。 例如,在 React 这类框架或环境中,你对应用主入口点的控制有限,`ManagedRuntime` 有助于管理 service 的生命周期。 下面介绍如何在外部框架中管理 service 的生命周期: **示例**(在外部框架中使用 `ManagedRuntime`) ```ts import { Effect, ManagedRuntime, Layer, Console } from "effect" // Define the Notifications service using Effect.Tag class Notifications extends Effect.Tag("Notifications")< Notifications, { readonly notify: (message: string) => Effect.Effect } >() { // Provide a live implementation of the Notifications service static Live = Layer.succeed(this, { notify: (message) => Console.log(message), }) } // Example entry point for an external framework async function main() { // Create a custom runtime using the Notifications layer const runtime = ManagedRuntime.make(Notifications.Live) // Run the effect await runtime.runPromise(Notifications.notify("Hello, world!")) // Dispose of the runtime, cleaning up resources await runtime.dispose() } ``` --- # 内置调度方案 > 探索 Effect 中的内置调度模式,实现高效的时间重复与延迟控制。 为了演示不同调度方案的功能,我们将使用下面这个辅助函数,它会记录每一次重复以及以毫秒为单位的对应延迟,格式如下: ```text #: ``` **辅助函数**(记录执行延迟) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 // Limit the number of executions const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." // Indicate truncation if there are more executions : i === delays.length - 1 ? "(end)" // Mark the last execution : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } ``` ## 无限重复与固定重复 ### forever 一种无限重复的调度方案,每次运行时都会产生重复次数。 **示例**(无限重复的调度方案) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.forever log(schedule) /* Output: #1: 0ms < forever #2: 0ms #3: 0ms #4: 0ms #5: 0ms #6: 0ms #7: 0ms #8: 0ms #9: 0ms #10: 0ms ... */ ``` ### once 只重复一次的调度方案。 **示例**(仅重复一次的调度方案) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.once log(schedule) /* Output: #1: 0ms < once (end) */ ``` ### recurs 按指定次数重复的调度方案,每次运行时都会产生重复次数。 **示例**(固定重复次数) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.recurs(5) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms #4: 0ms #5: 0ms (end) */ ``` ## 按特定间隔重复 你可以定义控制各次执行之间时间间隔的调度方案。`spaced` 与 `fixed` 调度方案的差别在于间隔的度量方式: - `spaced` 从上一次执行的**结束**时刻起延迟每一次重复。 - `fixed` 确保重复以**固定间隔**发生,与执行耗时无关。 ### spaced 一种无限重复的调度方案,每次重复与上一次运行之间间隔指定的时长。它每次运行时都会返回重复次数。 **示例**(各次执行之间带延迟的重复) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.spaced("200 millis") // ┌─── Simulating an effect that takes // │ 100 milliseconds to complete // ▼ log(schedule, "100 millis") /* Output: #1: 300ms < spaced #2: 300ms #3: 300ms #4: 300ms #5: 300ms #6: 300ms #7: 300ms #8: 300ms #9: 300ms #10: 300ms ... */ ``` 第一次延迟大约为 100 毫秒,因为初次执行不受调度方案影响。后续延迟之间大约相隔 200 毫秒,体现了 `spaced` 调度方案的效果。 ### fixed 按固定间隔重复的调度方案。它每次运行时都会返回重复次数。如果每次更新之间运行的操作耗时超过该间隔,那么该操作会立即运行,但重复运行不会“堆积”。 ```text |-----interval-----|-----interval-----|-----interval-----| |---------action--------|action-------|action------------| ``` **示例**(固定间隔重复) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.fixed("200 millis") // ┌─── Simulating an effect that takes // │ 100 milliseconds to complete // ▼ log(schedule, "100 millis") /* Output: #1: 300ms < fixed #2: 200ms #3: 200ms #4: 200ms #5: 200ms #6: 200ms #7: 200ms #8: 200ms #9: 200ms #10: 200ms ... */ ``` ## 递增各次执行之间的延迟 ### exponential 使用指数退避重复的调度方案,每次延迟按指数增长。返回相邻两次重复之间的当前时长。 **示例**(指数退避调度方案) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.exponential("10 millis") log(schedule) /* Output: #1: 10ms < exponential #2: 20ms #3: 40ms #4: 80ms #5: 160ms #6: 320ms #7: 640ms #8: 1280ms #9: 2560ms #10: 5120ms ... */ ``` ### fibonacci 一种始终重复的调度方案,通过把前两次延迟相加来递增延迟(类似斐波那契数列)。返回相邻两次重复之间的当前时长。 **示例**(斐波那契式延迟调度方案) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.fibonacci("10 millis") log(schedule) /* Output: #1: 10ms < fibonacci #2: 10ms #3: 20ms #4: 30ms #5: 50ms #6: 80ms #7: 130ms #8: 210ms #9: 340ms #10: 550ms ... */ ``` --- # Cron > 探索 Effect 中的 cron 调度:在特定时间与间隔执行操作。 `Cron` 模块让你可以用类似 [UNIX cron 表达式](https://en.wikipedia.org/wiki/Cron) 的风格定义调度。 它还支持部分约束(例如特定的月份或星期几)、通过 [DateTime](/docs/v3/data-types/datetime/) 模块实现的时区感知,以及健壮的错误处理。 这个模块可以帮助你: - **创建(Create)**:由各个单独的字段构造一个 `Cron` 实例。 - **解析并校验(Parse and validate)**:解析 cron 表达式并校验其有效性。 - **匹配(Match)**:检查已有日期是否满足给定的 cron 调度。 - **查找(Find)**:找出给定日期之后该调度的下一次触发时间。 - **迭代(Iterate)**:遍历符合某个调度的未来日期。 - **转换(Convert)**:把 `Cron` 实例转换为 `Schedule`,以便在 effectful 程序中使用。 ## 创建 Cron 你可以通过为秒、分、时、日、月、星期几指定数值约束来定义 cron 调度。`make` 函数要求你定义表示该调度约束的所有字段。 **示例**(创建 Cron) ```ts import { Cron, DateTime } from "effect" // Build a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.make({ seconds: [0], // Trigger at the start of a minute minutes: [0], // Trigger at the start of an hour hours: [4], // Trigger at 4:00 AM days: [8, 9, 10, 11, 12, 13, 14], // Specific days of the month months: [], // No restrictions on the month weekdays: [], // No restrictions on the weekday tz: DateTime.zoneUnsafeMakeNamed("Europe/Rome"), // Optional time zone }) ``` - `seconds`、`minutes` 和 `hours`:定义一天中的时间。 - `days` 和 `months`:指定哪些日历日和月份是有效的。 - `weekdays`:把调度限制在一周中的特定几天。 - `tz`:可选地为该调度指定时区。 如果某个字段留空(例如 `months`),它会被视为「无约束」,该日期部分可以取任意有效值。 ## 解析 cron 表达式 除了手动构造 `Cron`,你也可以使用类 UNIX 的 cron 字符串,并用 `parse` 或 `unsafeParse` 解析它们。 ### parse `parse(cronExpression, tz?)` 函数会安全地把 cron 字符串解析为 `Cron` 实例。它返回一个 [Either](/docs/v3/data-types/either/),其中要么是解析得到的 `Cron`,要么是一个解析错误。 **示例**(安全地解析 cron 表达式) ```ts import { Either, Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const expression = "0 0 4 8-14 * *" // Parse the cron expression const eitherCron = Cron.parse(expression) if (Either.isRight(eitherCron)) { // Successfully parsed console.log("Parsed cron:", eitherCron.right) } else { // Parsing failed console.error("Failed to parse cron:", eitherCron.left.message) } ``` ### unsafeParse `unsafeParse(cronExpression, tz?)` 函数的工作方式与 [parse](#parse) 类似,但当输入无效时它会抛出异常,而不是返回 [Either](/docs/v3/data-types/either/)。 **示例**(解析 cron 表达式) ```ts import { Cron } from "effect" // Parse a cron expression for 4:00 AM // on the 8th to the 14th of every month // Throws if the expression is invalid const cron = Cron.unsafeParse("0 0 4 8-14 * *") ``` ## 用 match 检查日期 `match` 函数让你可以判断给定的 `Date`(或任意 [DateTime.Input](/docs/v3/data-types/datetime/#the-datetimeinput-type))是否满足某个 cron 调度的约束。 如果该日期满足调度的条件,`match` 返回 `true`;否则返回 `false`。 **示例**(检查日期是否匹配 cron 调度) ```ts import { Cron } from "effect" // Suppose we have a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.unsafeParse("0 0 4 8-14 * *") const checkDate = new Date("2025-01-08 04:00:00") console.log(Cron.match(cron, checkDate)) // Output: true ``` ## 查找下一次运行时间 `next` 函数从指定日期开始,找出满足给定 cron 调度的下一个日期。如果没有提供起始日期,则以当前时间作为起点。 如果 `next` 在预定义的迭代次数内找不到匹配的日期,它会抛出错误,以避免无限循环。 **示例**(确定下一个匹配的日期) ```ts import { Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const cron = Cron.unsafeParse("0 0 4 8-14 * *", "UTC") // Specify the starting point for the search const after = new Date("2025-01-08") // Find the next matching date const nextDate = Cron.next(cron, after) console.log(nextDate) // Output: 2025-01-08T04:00:00.000Z ``` ## 迭代未来的日期 要生成多个符合某个 cron 调度的未来日期,可以使用 `sequence` 函数。该函数会从指定日期开始,提供一个匹配日期的无限迭代器。 **示例**(用迭代器生成未来的日期) ```ts import { Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const cron = Cron.unsafeParse("0 0 4 8-14 * *", "UTC") // Specify the starting date const start = new Date("2021-01-08") // Create an iterator for the schedule const iterator = Cron.sequence(cron, start) // Get the first matching date after the start date console.log(iterator.next().value) // Output: 2021-01-08T04:00:00.000Z // Get the second matching date after the start date console.log(iterator.next().value) // Output: 2021-01-09T04:00:00.000Z ``` ## 转换为 Schedule `Schedule` 模块让你可以定义重复发生的行为,例如重试或周期性事件。`cron` 函数在 `Cron` 模块与 `Schedule` 模块之间架起桥梁,让你能够基于 cron 表达式或 `Cron` 实例创建调度。 ### cron `Schedule.cron` 函数会生成一个 [Schedule](/docs/v3/scheduling/introduction/),它在给定的 cron 表达式或 `Cron` 实例所定义的每个区间开始时触发。触发时,该调度会产出一个元组 `[start, end]`,表示该 cron 区间窗口的时间戳(以毫秒为单位)。 **示例**(由 Cron 创建 Schedule) ```ts import { Effect, Schedule, TestClock, Fiber, TestContext, Cron, Console, } from "effect" // A helper function to log output at each interval of the schedule const log = ( action: Effect.Effect, schedule: Schedule.Schedule<[number, number], void>, ): void => { let i = 0 Effect.gen(function* () { const fiber: Fiber.RuntimeFiber<[[number, number], number]> = yield* Effect.gen(function* () { yield* action i++ }).pipe( Effect.repeat( schedule.pipe( // Limit the number of iterations for the example Schedule.intersect(Schedule.recurs(10)), Schedule.tapOutput(([Out]) => Console.log( i === 11 ? "..." : [new Date(Out[0]), new Date(Out[1])], ), ), ), ), Effect.fork, ) yield* TestClock.adjust(Infinity) yield* Fiber.join(fiber) }).pipe(Effect.provide(TestContext.TestContext), Effect.runPromise) } // Build a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.unsafeParse("0 0 4 8-14 * *", "UTC") // Convert the Cron into a Schedule const schedule = Schedule.cron(cron) // Define a dummy action to repeat const action = Effect.void // Log the schedule intervals log(action, schedule) /* Output: [ 1970-01-08T04:00:00.000Z, 1970-01-08T04:00:01.000Z ] [ 1970-01-09T04:00:00.000Z, 1970-01-09T04:00:01.000Z ] [ 1970-01-10T04:00:00.000Z, 1970-01-10T04:00:01.000Z ] [ 1970-01-11T04:00:00.000Z, 1970-01-11T04:00:01.000Z ] [ 1970-01-12T04:00:00.000Z, 1970-01-12T04:00:01.000Z ] [ 1970-01-13T04:00:00.000Z, 1970-01-13T04:00:01.000Z ] [ 1970-01-14T04:00:00.000Z, 1970-01-14T04:00:01.000Z ] [ 1970-02-08T04:00:00.000Z, 1970-02-08T04:00:01.000Z ] [ 1970-02-09T04:00:00.000Z, 1970-02-09T04:00:01.000Z ] [ 1970-02-10T04:00:00.000Z, 1970-02-10T04:00:01.000Z ] ... */ ``` --- # 示例 > 探索在 Effect 中处理调度、重试、超时以及周期性任务执行的实用示例。 以下示例展示了使用 Effect 处理超时、重试和周期性执行的不同方式。每个场景都确保应用保持响应能力、能从失败中恢复,同时动态适应各种情况。 ## 处理 API 调用的超时与重试 在调用第三方 API 时,通常需要设置超时并实现重试机制,以应对临时性失败。在本例中,API 调用在失败时最多重试两次;如果耗时超过 4 秒,就会被中断。 **示例**(带超时的 API 调用重试) ```ts import { Console, Effect } from "effect" // Function to make the API call const getJson = (url: string) => Effect.tryPromise(() => fetch(url).then((res) => { if (!res.ok) { console.log("error") throw new Error(res.statusText) } console.log("ok") return res.json() as unknown }), ) // Program that retries the API call twice, times out after 4 seconds, // and logs errors const program = (url: string) => getJson(url).pipe( Effect.retry({ times: 2 }), Effect.timeout("4 seconds"), Effect.catchAll(Console.error), ) // Test case: successful API response Effect.runFork(program("https://dummyjson.com/products/1?delay=1000")) /* Output: ok */ // Test case: API call exceeding timeout limit Effect.runFork(program("https://dummyjson.com/products/1?delay=5000")) /* Output: TimeoutException: Operation timed out before the specified duration of '4s' elapsed */ // Test case: API returning an error response Effect.runFork(program("https://dummyjson.com/auth/products/1?delay=500")) /* Output: error error error UnknownException: An unknown error occurred */ ``` ## 根据特定错误重试 API 调用 有时,只应针对某些错误情况进行重试。例如,如果 API 调用以 `401 Unauthorized` 响应失败,重试可能是合理的;而 `404 Not Found` 错误则不应触发重试。 **示例**(仅针对特定错误码重试) ```ts import { Console, Effect, Data } from "effect" // Custom error class for handling status codes class Err extends Data.TaggedError("Err")<{ readonly message: string readonly status: number }> {} // Function to make the API call const getJson = (url: string) => Effect.tryPromise({ try: () => fetch(url).then((res) => { if (!res.ok) { console.log(res.status) throw new Err({ message: res.statusText, status: res.status }) } return res.json() as unknown }), catch: (e) => e as Err, }) // Program that retries only when the error status is 401 (Unauthorized) const program = (url: string) => getJson(url).pipe( Effect.retry({ while: (err) => err.status === 401 }), Effect.catchAll(Console.error), ) // Test case: API returns 401 (triggers multiple retries) Effect.runFork(program("https://dummyjson.com/auth/products/1?delay=1000")) /* Output: 401 401 401 401 ... */ // Test case: API returns 404 (no retries) Effect.runFork(program("https://dummyjson.com/-")) /* Output: 404 Err [Error]: Not Found */ ``` ## 根据错误信息动态调整重试延迟 某些 API 错误(例如 `429 Too Many Requests`)会带有 `Retry-After` 响应头,其中指定了重试前需要等待的时长。我们可以根据该值动态调整重试间隔,而不是使用固定延迟。 **示例**(使用 `Retry-After` 响应头决定重试延迟) 这种做法让重试延迟能够根据服务器的响应动态调整,在遵循所提供的 `Retry-After` 值的同时避免不必要的重试。 ```ts import { Duration, Effect, Schedule, Data } from "effect" // Custom error class representing a "Too Many Requests" response class TooManyRequestsError extends Data.TaggedError("TooManyRequestsError")<{ readonly retryAfter: number }> {} let n = 1 const request = Effect.gen(function* () { // Simulate failing a particular number of times if (n < 3) { const retryAfter = n * 500 console.log(`Attempt #${n++}, retry after ${retryAfter} millis...`) // Simulate retrieving the retry-after header return yield* Effect.fail(new TooManyRequestsError({ retryAfter })) } console.log("Done") return "some result" }) // Retry policy that extracts the retry delay from the error const policy = Schedule.identity().pipe( Schedule.addDelay((error) => error._tag === "TooManyRequestsError" ? // Wait for the specified retry-after duration Duration.millis(error.retryAfter) : Duration.zero, ), // Limit retries to 5 attempts Schedule.intersect(Schedule.recurs(5)), ) const program = request.pipe(Effect.retry(policy)) Effect.runFork(program) /* Output: Attempt #1, retry after 500 millis... Attempt #2, retry after 1000 millis... Done */ ``` ## 运行周期性任务直到另一个任务完成 有些情况下,我们需要按固定间隔重复执行某个动作,直到另一个耗时更长的任务完成。这种模式常见于轮询机制或周期性日志记录。 **示例**(运行定时任务直到完成) ```ts import { Effect, Console, Schedule } from "effect" // Define a long-running effect // (e.g., a task that takes 5 seconds to complete) const longRunningEffect = Console.log("done").pipe(Effect.delay("5 seconds")) // Define an action to run periodically const action = Console.log("action...") // Define a fixed interval schedule const schedule = Schedule.fixed("1.5 seconds") // Run the action repeatedly until the long-running task completes const program = Effect.race(Effect.repeat(action, schedule), longRunningEffect) Effect.runPromise(program) /* Output: action... action... action... action... done */ ``` --- # 简介 > 学习 Effect 中调度的基础知识,包括可组合的重复模式,以及如何处理重试与重复。 # 调度 调度是 Effect 中的一个重要概念,它让你能够定义按计划重复执行的 effect 操作。这需要用到 `Schedule` 类型,它是一个不可变的值,用于描述执行 effect 的调度模式。 `Schedule` 类型的结构如下: ```text ┌─── The type of output produced by the schedule │ ┌─── The type of input consumed by the schedule │ │ ┌─── Additional requirements for the schedule ▼ ▼ ▼ Schedule ``` 一个 Schedule 通过消费 `In` 类型的值(例如 `retry` 情况下的错误,或 `repeat` 情况下的值)并产出 `Out` 类型的值来运作。它根据输入值及其内部状态,决定何时停止或继续执行。 引入 `Requirements` 参数,使 Schedule 能够按需使用额外的服务或资源。 Schedule 被定义为一组在时间上分散的区间。每个区间代表一个时间窗口,在此期间 effect 有可能重复发生。 ## 重试与重复 在调度领域中有两个相关概念:[重试](/docs/v3/error-management/retrying/) 和 [重复](/docs/v3/scheduling/repetition/)。它们共享同一个基本思想,但侧重点不同。重试旨在通过再次执行 effect 来处理失败,而重复则侧重于反复执行 effect 以达成期望的结果。 在使用 Schedule 进行重试或重复时,每个区间的起始边界决定了 effect 何时会被再次执行。例如在重试中,如果发生错误,Schedule 便定义了该 effect 应在何时重试。 ## Schedule 的可组合性 Schedule 是可组合的,这意味着你可以将简单的 Schedule 组合起来,构建出更复杂的重复模式。像 `Schedule.union` 或 `Schedule.intersect` 这样的操作符,允许你通过组合和修改已有的 Schedule 来构建精巧的调度方案。这种灵活性让你能够量身定制调度行为,以满足特定需求。 --- # 重复执行 > 探索 Effect 中的重复执行:多次执行同一个动作,并控制重试、失败与条件。 在软件开发中,重复执行 Effect 是一项常见需求。它允许我们按照特定的重复策略多次执行同一个 Effect。 ## repeat `Effect.repeat` 函数返回一个新的 Effect,它会按照指定的 Schedule 重复给定的 Effect,或者重复到第一次失败为止。 **示例**(重复一个成功的 Effect) ```ts import { Effect, Schedule, Console } from "effect" // Define an effect that logs a message to the console const action = Console.log("success") // Define a schedule that repeats the action 2 more times with a delay const policy = Schedule.addDelay(Schedule.recurs(2), () => "100 millis") // Repeat the action according to the schedule const program = Effect.repeat(action, policy) // Run the program and log the number of repetitions Effect.runPromise(program).then((n) => console.log(`repetitions: ${n}`)) /* Output: success success success repetitions: 2 */ ``` **示例**(处理重复中的失败) ```ts import { Effect, Schedule } from "effect" let count = 0 // Define an async effect that simulates an action with potential failure const action = Effect.async((resume) => { if (count > 1) { console.log("failure") resume(Effect.fail("Uh oh!")) } else { count++ console.log("success") resume(Effect.succeed("yay!")) } }) // Define a schedule that repeats the action 2 more times with a delay const policy = Schedule.addDelay(Schedule.recurs(2), () => "100 millis") // Repeat the action according to the schedule const program = Effect.repeat(action, policy) // Run the program and observe the result on failure Effect.runPromiseExit(program).then(console.log) /* Output: success success failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Uh oh!' } } */ ``` ### 跳过首次执行 如果你想避免首次执行,只按照 Schedule 运行该动作,可以使用 `Effect.schedule`。这会让 Effect 跳过最初的运行,直接遵循定义好的重复策略。 **示例**(跳过首次执行) ```ts import { Effect, Schedule, Console } from "effect" const action = Console.log("success") const policy = Schedule.addDelay(Schedule.recurs(2), () => "100 millis") const program = Effect.schedule(action, policy) Effect.runPromise(program).then((n) => console.log(`repetitions: ${n}`)) /* Output: success success repetitions: 2 */ ``` ## repeatN `repeatN` 函数返回一个新的 Effect,它会将指定的 Effect 重复给定的次数,或者重复到第一次失败为止。这些重复次数是在初次执行之外额外增加的,因此 `Effect.repeatN(action, 1)` 会先执行一次 `action`,如果成功,再额外重复一次。 **示例**(多次重复一个动作) ```ts import { Effect, Console } from "effect" const action = Console.log("success") // Repeat the action 2 additional times after the first execution const program = Effect.repeatN(action, 2) Effect.runPromise(program) /* Output: success success success */ ``` ## repeatOrElse `repeatOrElse` 函数返回一个新的 Effect,它会按照给定的 Schedule 重复指定的 Effect,或者重复到第一次失败为止。 发生失败时,失败值和 Schedule 的输出会被传给指定的处理函数。 计划中的重复次数是在初次执行之外额外增加的,因此 `Effect.repeat(action, Schedule.once)` 会先执行一次 `action`,如果成功,再额外重复一次。 **示例**(处理重复过程中的失败) ```ts import { Effect, Schedule } from "effect" let count = 0 // Define an async effect that simulates an action with possible failures const action = Effect.async((resume) => { if (count > 1) { console.log("failure") resume(Effect.fail("Uh oh!")) } else { count++ console.log("success") resume(Effect.succeed("yay!")) } }) // Define a schedule that repeats up to 2 times // with a 100ms delay between attempts const policy = Schedule.addDelay(Schedule.recurs(2), () => "100 millis") // Provide a handler to run when failure occurs after the retries const program = Effect.repeatOrElse(action, policy, () => Effect.sync(() => { console.log("orElse") return count - 1 }), ) Effect.runPromise(program).then((n) => console.log(`repetitions: ${n}`)) /* Output: success success failure orElse repetitions: 1 */ ``` ## 基于条件重复 你可以使用 `while` 或 `until` 选项,通过条件来控制一个 Effect 的重复,从而根据运行时的结果进行动态控制。 **示例**(重复直到满足某个条件) ```ts import { Effect } from "effect" let count = 0 // Define an effect that simulates varying outcomes on each invocation const action = Effect.sync(() => { console.log(`Action called ${++count} time(s)`) return count }) // Repeat the action until the count reaches 3 const program = Effect.repeat(action, { until: (n) => n === 3 }) Effect.runFork(program) /* Output: Action called 1 time(s) Action called 2 time(s) Action called 3 time(s) */ ``` --- # Schedule 组合子 > 学习如何在 Effect 中组合与定制 Schedule,构造复杂的重复模式,包括并集、交集、顺序执行等。 Schedule 定义的是有状态、且可能带 effect 的事件重复计划,它们能以多种方式组合。组合子(combinator)让我们可以把多个 Schedule 组合到一起,从而得到新的 Schedule。 为了演示不同 Schedule 的功能,我们将使用下面这个辅助函数,它会记录每一次重复以及对应的延迟毫秒数,格式为: ```text #: ``` **辅助函数**(记录执行延迟) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 // Limit the number of executions const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." // Indicate truncation if there are more executions : i === delays.length - 1 ? "(end)" // Mark the last execution : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } ``` ## 组合方式 Schedule 可以通过不同的方式组合: | 模式 | 说明 | | ------------ | ------------------------------------------------------------------------ | | **并集** | 组合两个 Schedule,只要任一 Schedule 想继续就继续,并使用较短的延迟。 | | **交集** | 组合两个 Schedule,仅当两个 Schedule 都想继续时才继续,并使用较长的延迟。 | | **顺序执行** | 组合两个 Schedule,先完整运行第一个,然后切换到第二个。 | ### 并集 组合两个 Schedule,只要任一 Schedule 想继续就继续,并使用较短的延迟。 **示例**(组合指数退避与固定间隔) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.union( Schedule.exponential("100 millis"), Schedule.spaced("1 second"), ) log(schedule) /* Output: #1: 100ms < exponential #2: 200ms #3: 400ms #4: 800ms #5: 1000ms < spaced #6: 1000ms #7: 1000ms #8: 1000ms #9: 1000ms #10: 1000ms ... */ ``` `Schedule.union` 操作符在每一步都挑选最短的延迟,因此在把指数退避的 Schedule 与固定间隔组合时,最初的几次重复会遵循指数退避,而当延迟超过该值后,就会稳定在固定间隔上。 ### 交集 组合两个 Schedule,仅当两个 Schedule 都想继续时才继续,并使用较长的延迟。 **示例**(用固定重试次数限制指数退避) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.intersect( Schedule.exponential("10 millis"), Schedule.recurs(5), ) log(schedule) /* Output: #1: 10ms < exponential #2: 20ms #3: 40ms #4: 80ms #5: 160ms (end) < recurs */ ``` `Schedule.intersect` 操作符会同时施加两个 Schedule 的约束。在这个示例中,Schedule 遵循指数退避,但由于 `Schedule.recurs(5)` 的限制,它在 5 次重复之后就会停止。 ### 顺序执行 组合两个 Schedule,先完整运行第一个,然后切换到第二个。 **示例**(从固定重试次数切换到周期性执行) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.andThen( Schedule.recurs(5), Schedule.spaced("1 second"), ) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms #4: 0ms #5: 0ms #6: 1000ms < spaced #7: 1000ms #8: 1000ms #9: 1000ms #10: 1000ms ... */ ``` 第一个 Schedule 会一直运行到结束,之后由第二个 Schedule 接管。在这个示例中,effect 最初会毫无延迟地执行 5 次,然后每隔 1 秒继续执行。 ## 为重试延迟加入随机性 `Schedule.jittered` 组合子会通过在指定范围内施加随机延迟来修改一个 Schedule。 当资源因过载或争用而不可用时,重试与退避并不能帮到我们。如果所有失败的 API 调用都在同一时间点退避,它们会造成新一轮的过载或争用。抖动(jitter)为 Schedule 的延迟加入了一定程度的随机性,这有助于避免意外地让它们同步在一起,从而意外拖垮服务。 [研究](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)表明,`Schedule.jittered(0.0, 1.0)` 是在重试中引入随机性的一种有效方式。 **示例**(加入抖动的指数退避) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.jittered(Schedule.exponential("10 millis")) log(schedule) /* Output: #1: 10.448486ms #2: 21.134521ms #3: 47.245117ms #4: 88.263184ms #5: 163.651367ms #6: 335.818848ms #7: 719.126709ms #8: 1266.18457ms #9: 2931.252441ms #10: 6121.593018ms ... */ ``` `Schedule.jittered` 组合子在某个范围内为延迟引入随机性。例如,对指数退避施加抖动可以确保每次重试都发生在略微不同的时间点,从而降低压垮系统的风险。 ## 用过滤器控制重复次数 你可以使用 `Schedule.whileInput` 或 `Schedule.whileOutput`,根据施加在 Schedule 输入或输出上的条件来限制它持续多久。 **示例**(根据输出停止) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.whileOutput(Schedule.recurs(5), (n) => n <= 2) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms (end) < whileOutput */ ``` `Schedule.whileOutput` 会根据 Schedule 的输出来过滤重复次数。在这个示例中,一旦输出超过 `2`,Schedule 就会停止,尽管 `Schedule.recurs(5)` 允许最多 5 次重复。 ## 根据输出调整延迟 `Schedule.modifyDelay` 组合子允许你根据重复次数或其他输出条件,动态地改变 Schedule 的延迟。 **示例**(在若干次重复之后缩短延迟) ```ts import { Array, Chunk, Duration, Effect, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.modifyDelay( Schedule.spaced("1 second"), (out, duration) => (out > 2 ? "100 millis" : duration), ) log(schedule) /* Output: #1: 1000ms #2: 1000ms #3: 1000ms #4: 100ms < modifyDelay #5: 100ms #6: 100ms #7: 100ms #8: 100ms #9: 100ms #10: 100ms ... */ ``` 延迟的修改会在执行过程中动态生效。在这个示例中,前 3 次重复遵循原本的 `1 秒` 间隔;此后延迟降到 `100 毫秒`,使得后续的重复更加频繁。 ## 探查 `Schedule.tapInput` 和 `Schedule.tapOutput` 允许你在不改变 Schedule 行为的前提下,对它的输入或输出执行额外的带 effect 操作。 **示例**(记录 Schedule 的输出) ```ts import { Array, Chunk, Duration, Effect, Schedule, Console } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.DurationInput = 0, ): void => { const maxRecurs = 10 const delays = Chunk.toArray( Effect.runSync( Schedule.run( Schedule.delays(Schedule.addDelay(schedule, () => delay)), Date.now(), Array.range(0, maxRecurs), ), ), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } const schedule = Schedule.tapOutput(Schedule.recurs(2), (n) => Console.log(`Schedule Output: ${n}`), ) log(schedule) /* Output: Schedule Output: 0 Schedule Output: 1 Schedule Output: 2 #1: 0ms #2: 0ms (end) */ ``` `Schedule.tapOutput` 会在每次重复之前运行一个 effect,并以 Schedule 当前的输出作为输入。这对于日志记录、调试或触发副作用都很有用。 --- # 高级用法 > 了解定义和扩展数据 schema 的高级技巧,包括递归与互递归类型、可选字段、品牌类型以及 schema 转换。 ## 声明新的数据类型 ### 原始数据类型 要为 `File` 这样的原始数据类型声明 schema,你可以把 `Schema.declare` 函数与类型守卫配合使用。 **示例**(为 `File` 声明 Schema) ```ts import { Schema } from "effect" // Declare a schema for the File type using a type guard const FileFromSelf = Schema.declare( (input: unknown): input is File => input instanceof File, ) const decode = Schema.decodeUnknownSync(FileFromSelf) // Decoding a valid File object console.log(decode(new File([], ""))) /* Output: File { size: 0, type: '', name: '', lastModified: 1724774163056 } */ // Decoding an invalid input decode(null) /* throws ParseError: Expected , actual null */ ``` 为了改进默认的报错信息,你可以添加注解,特别是 `identifier`、`title` 和 `description` 这几个注解(这些注解都不是必需的,但出于良好实践推荐添加,它们能让你的 schema 具备自解释性)。消息系统会利用这些注解返回更有意义的提示信息。 - **Identifier**:schema 的唯一名称 - **Title**:简短、描述性的标题 - **Description**:对 schema 用途的详细说明 **示例**(声明带注解的 Schema) ```ts import { Schema } from "effect" // Declare a schema for the File type with additional annotations const FileFromSelf = Schema.declare( (input: unknown): input is File => input instanceof File, { // A unique identifier for the schema identifier: "FileFromSelf", // Detailed description of the schema description: "The `File` type in JavaScript", }, ) const decode = Schema.decodeUnknownSync(FileFromSelf) // Decoding a valid File object console.log(decode(new File([], ""))) /* Output: File { size: 0, type: '', name: '', lastModified: 1724774163056 } */ // Decoding an invalid input decode(null) /* throws ParseError: Expected FileFromSelf, actual null */ ``` ### 类型构造器 类型构造器是接收一个或多个类型作为参数、并返回一个新类型的泛型类型。要为类型构造器定义 schema,可以使用 `Schema.declare` 函数。 **示例**(为 `ReadonlySet` 声明 Schema) ```ts import { ParseResult, Schema } from "effect" export const MyReadonlySet = ( // Schema for the elements of the Set item: Schema.Schema, ): Schema.Schema, ReadonlySet, R> => Schema.declare( // Store the schema for the Set's elements [item], { // Decoding function decode: (item) => (input, parseOptions, ast) => { if (input instanceof Set) { // Decode each element in the Set const elements = ParseResult.decodeUnknown(Schema.Array(item))( Array.from(input.values()), parseOptions, ) // Return a ReadonlySet containing the decoded elements return ParseResult.map(elements, (as): ReadonlySet => new Set(as)) } // Handle invalid input return ParseResult.fail(new ParseResult.Type(ast, input)) }, // Encoding function encode: (item) => (input, parseOptions, ast) => { if (input instanceof Set) { // Encode each element in the Set const elements = ParseResult.encodeUnknown(Schema.Array(item))( Array.from(input.values()), parseOptions, ) // Return a ReadonlySet containing the encoded elements return ParseResult.map(elements, (is): ReadonlySet => new Set(is)) } // Handle invalid input return ParseResult.fail(new ParseResult.Type(ast, input)) }, }, { description: `ReadonlySet<${Schema.format(item)}>`, }, ) // Define a schema for a ReadonlySet of numbers const setOfNumbers = MyReadonlySet(Schema.NumberFromString) const decode = Schema.decodeUnknownSync(setOfNumbers) console.log(decode(new Set(["1", "2", "3"]))) // Set(3) { 1, 2, 3 } // Decode an invalid input decode(null) /* throws ParseError: Expected ReadonlySet, actual null */ // Decode a Set with an invalid element decode(new Set(["1", null, "3"])) /* throws ParseError: ReadonlyArray └─ [1] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual null */ ``` ### 添加编译器注解 定义新的数据类型时,像 [Arbitrary](/docs/v3/schema/arbitrary/) 或 [Pretty](/docs/v3/schema/pretty/) 这样的编译器可能不知道如何处理这个新类型。 这会导致错误,因为编译器可能缺少生成实例或产出可读输出所需的信息: **示例**(在没有必需注解的情况下尝试生成 Arbitrary 值) ```ts import { Arbitrary, Schema } from "effect" // Define a schema for the File type const FileFromSelf = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "FileFromSelf", }, ) // Try creating an Arbitrary instance for the schema const arb = Arbitrary.make(FileFromSelf) /* throws: Error: Missing annotation details: Generating an Arbitrary for this schema requires an "arbitrary" annotation schema (Declaration): FileFromSelf */ ``` 在上面的示例中,为 `FileFromSelf` schema 生成 arbitrary 值会失败,因为编译器缺少必需的注解。要解决这个问题,你需要提供用于生成 arbitrary 数据的注解: **示例**(为自定义的 `File` Schema 添加 Arbitrary 注解) ```ts import { Arbitrary, FastCheck, Pretty, Schema } from "effect" const FileFromSelf = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "FileFromSelf", // Provide a function to generate random File instances arbitrary: () => (fc) => fc .tuple(fc.string(), fc.string()) .map(([content, path]) => new File([content], path)), }, ) // Create an Arbitrary instance for the schema const arb = Arbitrary.make(FileFromSelf) // Generate sample files using the Arbitrary instance const files = FastCheck.sample(arb, 2) console.log(files) /* Example Output: [ File { size: 5, type: '', name: 'C', lastModified: 1706435571176 }, File { size: 1, type: '', name: '98Ggmc', lastModified: 1706435571176 } ] */ ``` 关于如何为 Arbitrary 编译器添加注解的更多细节,请参阅 [Arbitrary](/docs/v3/schema/arbitrary/) 文档。 ## 品牌类型 TypeScript 的类型系统是结构化的,这意味着任何两个在结构上等价的类型都会被视为同一个类型。 当语义上不同的类型被当作同一个类型处理时,这就会带来问题。 **示例**(结构化类型带来的问题) ```ts type UserId = string type Username = string declare const getUser: (id: UserId) => object const myUsername: Username = "gcanti" getUser(myUsername) // This erroneously works ``` 在上面的示例中,`UserId` 和 `Username` 都是同一个类型 `string` 的别名。这意味着 `getUser` 函数会误把一个 `Username` 当作合法的 `UserId` 接受,从而带来 bug 和错误。 为了避免这种情况,Effect 引入了**品牌类型**(branded types)。这类类型会给一个类型附加一个唯一标识(也就是 "brand"),让你能够区分结构相似但语义不同的类型。 **示例**(定义品牌类型) ```ts import { Brand } from "effect" type UserId = string & Brand.Brand<"UserId"> type Username = string declare const getUser: (id: UserId) => object const myUsername: Username = "gcanti" // @errors: 2345 getUser(myUsername) ``` 通过把 `UserId` 定义为品牌类型,`getUser` 函数就只能接受 `UserId` 类型的值,而不能接受普通字符串或其他与字符串兼容的类型。这有助于避免因误把错误类型的值传给函数而引发的 bug。 为品牌类型定义 schema 有两种方式,取决于你是: - 想从零开始定义 schema - 已经通过 [`effect/Brand`](/docs/v3/code-style/branded-types/) 定义了品牌类型,想复用它来定义 schema ### 从零定义品牌 schema 要从零为品牌类型定义 schema,请使用 `Schema.brand` 函数。 **示例**(为品牌类型创建 schema) ```ts import { Schema } from "effect" const UserId = Schema.String.pipe(Schema.brand("UserId")) // string & Brand<"UserId"> type UserId = typeof UserId.Type ``` 注意,你可以使用 `unique symbol` 作为 brand,以确保在模块 / 包之间保持唯一性。 **示例**(使用 unique symbol 作为 Brand) ```ts import { Schema } from "effect" const UserIdBrand: unique symbol = Symbol.for("UserId") const UserId = Schema.String.pipe(Schema.brand(UserIdBrand)) // string & Brand type UserId = typeof UserId.Type ``` ### 复用已有的品牌构造器 如果你已经使用 [`effect/Brand`](/docs/v3/code-style/branded-types/) 模块定义过品牌类型,就可以通过 `Schema.fromBrand` 函数复用它来定义 schema。 **示例**(复用已有的品牌类型) ```ts import { Schema } from "effect" import { Brand } from "effect" // the existing branded type type UserId = string & Brand.Brand<"UserId"> const UserId = Brand.nominal() // Define a schema for the branded type const UserIdSchema = Schema.String.pipe(Schema.fromBrand(UserId)) ``` ### 使用默认构造器 `Schema.brand` 函数包含一个默认构造器,便于创建品牌类型的值。 ```ts import { Schema } from "effect" const UserId = Schema.String.pipe(Schema.brand("UserId")) const userId = UserId.make("123") // Creates a branded UserId ``` ## 属性签名 `PropertySignature` 表示从 "From" 字段到 "To" 字段的一次转换。它让你能够定义传入的数据字段与你内部模型之间的映射。 ### 基本用法 属性签名可以带注解来定义,从而为字段提供额外的上下文。 **示例**(为属性签名添加注解) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.propertySignature(Schema.NumberFromString).annotations({ title: "Age", // Annotation to label the age field }), }) ``` `PropertySignature` 类型包含若干参数,每个参数都描述了源字段(From)与目标字段(To)之间转换的细节。下面来看看每个参数分别代表什么: ```ts age: PropertySignature< ToToken, ToType, FromKey, FromToken, FromType, HasDefault, Context > ``` | 参数 | 说明 | | ------------ | ------------------------------------------------------------------------------------------------------------------- | | `age` | "To" 字段的键 | | `ToToken` | 表示字段是否必需:`"?:"` 表示可选,`":"` 表示必需 | | `ToType` | "To" 字段的类型 | | `FromKey` | (可选,默认值为 `never`)表示源字段的键;除非特别指定,通常与 "To" 字段的键相同 | | `FromToken` | 表示源字段是否必需:`"?:"` 表示可选,`":"` 表示必需 | | `FromType` | "From" 字段的类型 | | `HasDefault` | 表示是否存在构造器默认值(布尔值) | 在上面的示例中,`age` 对应的 `PropertySignature` 类型是: ```ts PropertySignature<":", number, never, ":", string, false, never> ``` 这意味着: | 参数 | 说明 | | ------------ | -------------------------------------------------------------------------- | | `age` | "To" 字段的键 | | `ToToken` | `":"` 表示 `age` 字段是必需的 | | `ToType` | `age` 字段的类型是 `number` | | `FromKey` | `never` 表示从同名的 `age` 字段进行解码 | | `FromToken` | `":"` 表示从一个必需的 `age` 字段进行解码 | | `FromType` | "From" 字段的类型是 `string` | | `HasDefault` | `false`:表示没有默认值 | 有时,源字段("From" 字段)的名称可能与内部模型中的字段不同。你可以使用 `Schema.fromKey` 函数在这些字段之间进行映射。 **示例**(从不同的键映射) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.propertySignature(Schema.NumberFromString).pipe( Schema.fromKey("AGE"), // Maps from "AGE" to "age" ), }) console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" })) // Output: { name: 'name', age: 18 } ``` 当你从 `"AGE"` 映射到 `"age"` 时,`PropertySignature` 类型会变成: ```ts PropertySignature<":", number, never, ":", string, false, never> PropertySignature<":", number, "AGE", ":", string, false, never> ``` ### 可选字段 #### 基本的可选属性 语法如下: ```ts Schema.optional(schema: Schema) ``` 它会在 schema 中创建一个可选属性,允许该字段被省略或设为 `undefined`。 ##### 解码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `i: I` | transforms to `a: A` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `a: A` | transforms back to `i: I` | **示例**(定义可选数字字段) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.NumberFromString), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number | undefined; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} console.log(Schema.encodeSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } ``` ##### 暴露的值 你可以通过 `from` 属性访问原始的 schema 类型(即它被标记为可选之前的类型)。 **示例**(访问原始的 Schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.NumberFromString), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 带可空性的可选属性 语法如下: ```ts Schema.optionalWith(schema: Schema, { nullable: true }) ``` 它会在 schema 中创建一个可选属性,并把 `null` 值视为缺失值。 ##### 解码 | Input | Output | | ----------------- | ------------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `null` | transforms to `` | | `i: I` | transforms to `a: A` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `a: A` | transforms back to `i: I` | **示例**(把 Null 作为缺失值处理) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { nullable: true, }), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number | undefined; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: {} // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} console.log(Schema.encodeSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } ``` ##### 暴露的值 你可以通过 `from` 属性访问原始的 schema 类型(即它被标记为可选之前的类型)。 **示例**(访问原始的 Schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { nullable: true, }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 带精确性的可选属性 语法如下: ```ts Schema.optionalWith(schema: Schema, { exact: true }) ``` 它会在创建可选属性的同时强制严格类型。这意味着只接受指定的类型(不包括 `undefined`)。任何尝试解码 `undefined` 的操作都会导致错误。 ##### 解码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | `ParseError` | | `i: I` | transforms to `a: A` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `a: A` | transforms back to `i: I` | **示例**(对可选字段使用精确性) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true }), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: { readonly quantity?: NumberFromString } └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined */ // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} ``` ##### 暴露的值 你可以通过 `from` 属性访问原始的 schema 类型(即它被标记为可选之前的类型)。 **示例**(访问原始的 Schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 组合可空性与精确性 语法如下: ```ts Schema.optionalWith(schema: Schema, { exact: true, nullable: true }) ``` 它让你可以定义一个可选属性,既强制严格类型(只允许精确的类型),又把 `null` 视为等价于缺失值。 ##### 解码 | Input | Output | | ----------------- | ------------------------------- | | `` | remains `` | | `null` | transforms to `` | | `undefined` | `ParseError` | | `i: I` | transforms to `a: A` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `a: A` | transforms back to `i: I` | **示例**(对可选字段使用精确性并把 Null 作为缺失值处理) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true, nullable: true, }), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: (Struct (Encoded side) <-> Struct (Type side)) └─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString | null ├─ NumberFromString │ └─ Encoded side transformation failure │ └─ Expected string, actual undefined └─ Expected null, actual undefined */ console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: {} // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} ``` ##### 暴露的值 你可以通过 `from` 属性访问原始的 schema 类型(即它被标记为可选之前的类型)。 **示例**(访问原始的 Schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { exact: true, nullable: true, }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` ### 使用 never 类型表示可选字段 当你创建一个 schema 来复刻某个包含 `never` 类型可选字段的 TypeScript 类型时,例如: ```ts type MyType = { readonly quantity?: never } ``` 这些字段的处理方式取决于 `tsconfig.json` 中的 `exactOptionalPropertyTypes` 设置。 该设置会影响 schema 应当把可选的 `never` 类型字段视为单纯不存在,还是允许把 `undefined` 作为它的值。 **示例**(`exactOptionalPropertyTypes: false`) 当该特性关闭时,你可以使用 `Schema.optional` 函数。这种方式允许该字段隐式接受 `undefined` 作为值。 ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.Never), }) // ┌─── { readonly quantity?: undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: undefined; } // ▼ type Type = typeof Product.Type ``` **示例**(`exactOptionalPropertyTypes: true`) 当该特性开启时,推荐使用 `Schema.optionalWith` 函数。 它能确保更严格地强制该字段缺失。 ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.Never, { exact: true }), }) // ┌─── { readonly quantity?: never; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: never; } // ▼ type Type = typeof Product.Type ``` ### 默认值 `Schema.optionalWith` 中的 `default` 选项允许你设置默认值,这些默认值会在解码和对象构造两个阶段被应用。 这一特性确保即使使用者没有提供某些属性,系统也会自动使用指定的默认值。 `Schema.optionalWith` 函数提供了多种方式来控制默认值在解码与编码期间的应用方式。你可以精细调整默认值是仅在输入完全缺失时应用,还是在提供了 `null` 或 `undefined` 值时也应用。 #### 基本默认值 这是最简单的用例。如果输入缺失或为 `undefined`,就会应用默认值。 **语法** ```ts Schema.optionalWith(schema: Schema, { default: () => A }) ``` | 操作 | 行为 | | -------- | ----------------------------------------------- | | **解码** | 如果输入缺失或为 `undefined`,则应用默认值 | | **编码** | 把输入 `a: A` 转换回 `i: I` | **示例**(当字段缺失或为 `undefined` 时应用默认值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity }), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type // Decoding examples with default applied console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } // Object construction examples with default applied console.log(Product.make({})) // Output: { quantity: 1 } console.log(Product.make({ quantity: 2 })) // Output: { quantity: 2 } ``` ##### 暴露的值 你可以使用 `from` 属性访问原始 schema 类型(即在被标记为可选之前的类型)。 **示例**(访问原始 schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 带精确性的默认值 当你希望默认值仅在字段完全缺失时才应用(而不是在它为 `undefined` 时应用),可以使用 `exact` 选项。 **语法** ```ts Schema.optionalWith(schema: Schema, { default: () => A, exact: true }) ``` | 操作 | 行为 | | -------- | -------------------------------- | | **解码** | 仅当输入缺失时应用默认值 | | **编码** | 把输入 `a: A` 转换回 `i: I` | **示例**(仅在字段缺失时应用默认值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity exact: true, // Only apply default if quantity is not provided }), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: (Struct (Encoded side) <-> Struct (Type side)) └─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined */ ``` #### 带可空性的默认值 当你希望 `null` 值触发默认行为时,可以使用 `nullable` 选项。这确保如果字段被设为 `null`,它会被默认值替换。 **语法** ```ts Schema.optionalWith(schema: Schema, { default: () => A, nullable: true }) ``` | 操作 | 行为 | | -------- | ------------------------------------------------------ | | **解码** | 如果输入缺失,或为 `undefined` 或 `null`,则应用默认值 | | **编码** | 把输入 `a: A` 转换回 `i: I` | **示例**(当字段缺失,或为 `undefined` 或 `null` 时应用默认值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity nullable: true, // Apply default if quantity is null }), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } ``` #### 组合精确性与可空性 为了更严格的处理方式,你可以同时组合 `exact` 和 `nullable` 选项。这样,默认值只在字段为 `null` 或缺失时应用,而在字段被显式设为 `undefined` 时不应用。 **语法** ```ts Schema.optionalWith(schema: Schema, { default: () => A, exact: true, nullable: true }) ``` | 操作 | 行为 | | -------- | ------------------------------------------ | | **解码** | 如果输入缺失或为 `null`,则应用默认值 | | **编码** | 把输入 `a: A` 转换回 `i: I` | **示例**(仅在字段缺失或为 `null` 时应用默认值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { default: () => 1, // Default value for quantity exact: true, // Only apply default if quantity is not provided nullable: true, // Apply default if quantity is null }), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: (Struct (Encoded side) <-> Struct (Type side)) └─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined */ ``` ### 作为 Option 的可选字段 处理可选字段时,你可能希望把它们当作 [Option](/docs/v3/data-types/option/) 类型来处理。这种方式让你能够显式地管理字段的存在或缺失,而不必依赖 `undefined` 或 `null`。 #### 使用 Option 类型的基本可选字段 你可以把 schema 配置为将可选字段视为 `Option` 类型:缺失或为 `undefined` 的值会被转换为 `Option.none()`,而已存在的值会被包装为 `Option.some()`。 **语法** ```ts optionalWith(schema: Schema, { as: "Option" }) ``` ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | transforms to `Option.none()` | | `i: I` | transforms to `Option.some(a: A)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(a: A)` | transforms back to `i: I` | **示例**(把可选字段作为 Option 处理) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option" }), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } ``` ##### 暴露的值 你可以使用 `from` 属性访问原始 schema 类型(即在被标记为可选之前的类型)。 **示例**(访问原始 schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option" }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 带精确性的可选字段 `exact` 选项确保可选字段的默认行为仅在字段完全缺失时生效,而不是在它为 `undefined` 时生效。 **语法** ```ts optionalWith(schema: Schema, { as: "Option", exact: true }) ``` ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | `ParseError` | | `i: I` | transforms to `Option.some(a: A)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(a: A)` | transforms back to `i: I` | **示例**(在可选字段作为 Option 时使用精确性) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, }), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: (Struct (Encoded side) <-> Struct (Type side)) └─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined */ ``` ##### 暴露的值 你可以使用 `from` 属性访问原始 schema 类型(即在被标记为可选之前的类型)。 **示例**(访问原始 schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 带可空性的可选字段 `nullable` 选项把默认行为扩展为:除了缺失或 `undefined` 值之外,还把 `null` 视为等价于 `Option.none()`。 **语法** ```ts optionalWith(schema: Schema, { as: "Option", nullable: true }) ``` ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | transforms to `Option.none()` | | `null` | transforms to `Option.none()` | | `i: I` | transforms to `Option.some(a: A)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(a: A)` | transforms back to `i: I` | **示例**(在可选字段作为 Option 时把 null 视为缺失值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", nullable: true, }), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } ``` ##### 暴露的值 你可以使用 `from` 属性访问原始 schema 类型(即在被标记为可选之前的类型)。 **示例**(访问原始 schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", nullable: true, }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` #### 组合精确性与可空性 当 `exact` 和 `nullable` 选项一起使用时,只有 `null` 和缺失的字段会被视为 `Option.none()`,而 `undefined` 会被视为无效值。 **语法** ```ts optionalWith(schema: Schema, { as: "Option", exact: true, nullable: true }) ``` ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | `ParseError` | | `null` | transforms to `Option.none()` | | `i: I` | transforms to `Option.some(a: A)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(a: A)` | transforms back to `i: I` | **示例**(在可选字段作为 Option 时使用精确性并把 null 视为缺失值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, nullable: true, }), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: ParseError: (Struct (Encoded side) <-> Struct (Type side)) └─ Encoded side transformation failure └─ Struct (Encoded side) └─ ["quantity"] └─ NumberFromString └─ Encoded side transformation failure └─ Expected string, actual undefined */ ``` ##### 暴露的值 你可以使用 `from` 属性访问原始 schema 类型(即在被标记为可选之前的类型)。 **示例**(访问原始 schema) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalWith(Schema.NumberFromString, { as: "Option", exact: true, nullable: true, }), }) // ┌─── typeof Schema.NumberFromString // ▼ const from = Product.fields.quantity.from ``` ## 可选字段原语 ### optionalToOptional `Schema.optionalToOptional` API 让你可以管理从输入中的可选字段到输出中的可选字段的变换。当需要根据特定条件同时控制输出类型以及字段是存在还是缺失时,这会很有用。 `optionalToOptional` 的一个常见用例是处理这样的字段:某个特定的输入值(例如空字符串)在输出中应被视为字段缺失。 **语法** ```ts const optionalToOptional = ( from: Schema, to: Schema, options: { readonly decode: (o: Option.Option) => Option.Option, readonly encode: (o: Option.Option) => Option.Option } ): PropertySignature<"?:", TA, never, "?:", FI, false, FR | TR> ``` 在这个函数中: - `from` 参数指定输入 schema,`to` 指定输出 schema。 - `decode` 与 `encode` 函数定义了该字段在两端应如何被解释: - 以 `Option.none()` 作为输入参数,表示输入中缺失该字段。 - 从任一函数返回 `Option.none()` 会在输出中省略该字段。 **示例**(从输出中省略空字符串) 考虑一个类型为 `string` 的可选字段,输入中的空字符串应从输出中移除。 ```ts import { Option, Schema } from "effect" const schema = Schema.Struct({ nonEmpty: Schema.optionalToOptional(Schema.String, Schema.String, { // ┌─── Option // ▼ decode: (maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return Option.none() to omit it in the output. return Option.none() } // Extract the value from the `Some` instance const value = maybeString.value if (value === "") { // Treat empty strings as missing in the output // by returning Option.none(). return Option.none() } // Include non-empty strings in the output. return Option.some(value) }, // In the encoding phase, you can decide to process the field // similarly to the decoding phase or use a different logic. // Here, the logic is left unchanged. // // ┌─── Option // ▼ encode: (maybeString) => maybeString, }), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({})) // Output: {} console.log(decode({ nonEmpty: "" })) // Output: {} console.log(decode({ nonEmpty: "a non-empty string" })) // Output: { nonEmpty: 'a non-empty string' } // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({})) // Output: {} console.log(encode({ nonEmpty: "" })) // Output: { nonEmpty: '' } console.log(encode({ nonEmpty: "a non-empty string" })) // Output: { nonEmpty: 'a non-empty string' } ``` 你可以用 `Option.filter` 简化解码逻辑,它能以简洁的方式过滤掉不需要的值。 **示例**(使用 `Option.filter` 进行解码) ```ts import { identity, Option, Schema } from "effect" const schema = Schema.Struct({ nonEmpty: Schema.optionalToOptional(Schema.String, Schema.String, { decode: Option.filter((s) => s !== ""), encode: identity, }), }) ``` ### optionalToRequired `Schema.optionalToRequired` API 让你可以把可选字段转换为必需字段,并用自定义逻辑处理输入中该字段缺失的情况。 **语法** ```ts const optionalToRequired = ( from: Schema, to: Schema, options: { readonly decode: (o: Option.Option) => TI, readonly encode: (ti: TI) => Option.Option } ): PropertySignature<":", TA, never, "?:", FI, false, FR | TR> ``` 在这个函数中: - `from` 指定输入 schema,而 `to` 指定输出 schema。 - `decode` 与 `encode` 函数定义了变换行为: - 向 `decode` 传入 `Option.none()` 意味着输入中缺失该字段。此时函数可以为输出返回一个默认值。 - 在 `encode` 中返回 `Option.none()` 会在输出中省略该字段。 **示例**(把 `null` 设为缺失字段的默认值) 这个示例演示了当输入中缺少 `nullable` 字段时,如何使用 `optionalToRequired` 提供一个 `null` 默认值。在编码阶段,值为 `null` 的字段会从输出中被省略。 ```ts import { Option, Schema } from "effect" const schema = Schema.Struct({ nullable: Schema.optionalToRequired( // Input schema for an optional string Schema.String, // Output schema allowing null or string Schema.NullOr(Schema.String), { // ┌─── Option // ▼ decode: (maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return `null` as the default value for the output. return null } // Extract the value from the `Some` instance // and use it as the output. return maybeString.value }, // During encoding, treat `null` as an absent field // // ┌─── string | null // ▼ encode: (stringOrNull) => stringOrNull === null ? // Omit the field by returning `None` Option.none() : // Include the field by returning `Some` Option.some(stringOrNull), }, ), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({})) // Output: { nullable: null } console.log(decode({ nullable: "a value" })) // Output: { nullable: 'a value' } // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({ nullable: "a value" })) // Output: { nullable: 'a value' } console.log(encode({ nullable: null })) // Output: {} ``` 你可以用 `Option.getOrElse` 与 `Option.liftPredicate` 精简解码与编码逻辑,让变换既简洁又可读。 **示例**(使用 `Option.getOrElse` 与 `Option.liftPredicate`) ```ts import { Option, Schema } from "effect" const schema = Schema.Struct({ nullable: Schema.optionalToRequired( Schema.String, Schema.NullOr(Schema.String), { decode: Option.getOrElse(() => null), encode: Option.liftPredicate((value) => value !== null), }, ), }) ``` ### requiredToOptional `requiredToOptional` API 让你可以把必需字段转换为可选字段,并应用自定义逻辑来决定何时可以省略该字段。 **语法** ```ts const requiredToOptional = ( from: Schema, to: Schema, options: { readonly decode: (fa: FA) => Option.Option readonly encode: (o: Option.Option) => FA } ): PropertySignature<"?:", TA, never, ":", FI, false, FR | TR> ``` 借助 `decode` 与 `encode` 函数,你可以控制字段的存在或缺失: - 在 `decode` 中,以 `Option.none()` 作为参数意味着输入中缺失该字段。 - 在 `encode` 中,以 `Option.none()` 作为返回值意味着输出中将省略该字段。 **示例**(把空字符串视为缺失值) 在这个示例中,`name` 字段是必需的,但如果它是空字符串则被视为可选。解码时,`name` 中的空字符串被视为缺失;而编码时会保证有一个值(如果 `name` 缺失,就用空字符串作为默认值)。 ```ts import { Option, Schema } from "effect" const schema = Schema.Struct({ name: Schema.requiredToOptional(Schema.String, Schema.String, { // ┌─── string // ▼ decode: (string) => { // Treat empty string as a missing value if (string === "") { // Omit the field by returning `None` return Option.none() } // Otherwise, return the string as is return Option.some(string) }, // ┌─── Option // ▼ encode: (maybeString) => { // Check if the field is missing if (Option.isNone(maybeString)) { // Provide an empty string as default return "" } // Otherwise, return the string as is return maybeString.value }, }), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({ name: "John" })) // Output: { name: 'John' } console.log(decode({ name: "" })) // Output: {} // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({ name: "John" })) // Output: { name: 'John' } console.log(encode({})) // Output: { name: '' } ``` 你可以用 `Option.liftPredicate` 与 `Option.getOrElse` 精简解码与编码逻辑,让变换既简洁又可读。 **示例**(使用 `Option.liftPredicate` 与 `Option.getOrElse`) ```ts import { Option, Schema } from "effect" const schema = Schema.Struct({ name: Schema.requiredToOptional(Schema.String, Schema.String, { decode: Option.liftPredicate((s) => s !== ""), encode: Option.getOrElse(() => ""), }), }) ``` ## 扩展 Schema `effect` 中的 schema 可以通过多种方式扩展,从而把现有类型与其他字段或功能组合、增强。一种常见做法是使用 `Struct` schema 上提供的 `fields` 属性。这个属性提供了一种便捷方式,可以在保留原始 `Struct` 类型的同时添加字段,或合并来自不同 struct 的字段。这种做法也让字段的访问与修改更加容易。 对于更复杂的情况,例如用一个联合来扩展某个 struct,你可能需要使用 `Schema.extend` 函数;在直接展开字段不够用的场景下,它提供了更大的灵活性。 ### 展开 Struct 字段 Struct 通过 `fields` 属性暴露其字段,这让你可以通过添加额外字段,或合并来自多个 struct 的字段,来扩展一个现有的 struct。 **示例**(添加新字段) ```ts import { Schema } from "effect" const Original = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Extended = Schema.Struct({ ...Original.fields, // Adding new fields c: Schema.String, d: Schema.String, }) // ┌─── { // | readonly a: string; // | readonly b: string; // | readonly c: string; // | readonly d: string; // | } // ▼ type Type = typeof Extended.Type ``` **示例**(添加额外的索引签名) ```ts import { Schema } from "effect" const Original = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Extended = Schema.Struct( Original.fields, // Adding an index signature Schema.Record({ key: Schema.String, value: Schema.String }), ) // ┌─── { // │ readonly [x: string]: string; // | readonly a: string; // | readonly b: string; // | } // ▼ type Type = typeof Extended.Type ``` **示例**(合并来自多个 struct 的字段) ```ts import { Schema } from "effect" const Struct1 = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Struct2 = Schema.Struct({ c: Schema.String, d: Schema.String, }) const Extended = Schema.Struct({ ...Struct1.fields, ...Struct2.fields, }) // ┌─── { // | readonly a: string; // | readonly b: string; // | readonly c: string; // | readonly d: string; // | } // ▼ type Type = typeof Extended.Type ``` ### extend 函数 `Schema.extend` 函数提供了一种结构化方式来扩展 schema,尤其适用于直接[展开字段](#spreading-struct-fields)不够用的场景 —— 例如当你需要用其他 struct 的联合来扩展某个 struct 时。 受支持的扩展包括: - `Schema.String` 与另一个 `Schema.String` 精化或一个字符串字面量 - `Schema.Number` 与另一个 `Schema.Number` 精化或一个数字字面量 - `Schema.Boolean` 与另一个 `Schema.Boolean` 精化或一个布尔字面量 - 一个 struct 与另一个 struct,且重叠的字段支持扩展 - 一个 struct 与一个索引签名 - 一个 struct 与受支持 schema 的联合 - 一个 struct 的精化与一个受支持的 schema - 一个 struct 的 `suspend` 与一个受支持的 schema - struct 之间的变换,其中 "from" 与 "to" 两侧与目标 struct 没有重叠字段 **示例**(用一个 struct 的联合扩展一个 struct) ```ts import { Schema } from "effect" const Struct = Schema.Struct({ a: Schema.String, }) const UnionOfStructs = Schema.Union( Schema.Struct({ b: Schema.String }), Schema.Struct({ c: Schema.String }), ) const Extended = Schema.extend(Struct, UnionOfStructs) // ┌─── { // | readonly a: string; // | } & ({ // | readonly b: string; // | } | { // | readonly c: string; // | }) // ▼ type Type = typeof Extended.Type ``` **示例**(尝试用冲突字段扩展 struct) 这个示例演示了尝试用一个包含重叠字段名的 struct 去扩展另一个 struct,由于类型冲突而导致错误。 ```ts import { Schema } from "effect" const Struct = Schema.Struct({ a: Schema.String, }) const OverlappingUnion = Schema.Union( Schema.Struct({ a: Schema.Number }), // conflicting type for key "a" Schema.Struct({ d: Schema.String }), ) const Extended = Schema.extend(Struct, OverlappingUnion) /* throws: Error: Unsupported schema or overlapping types at path: ["a"] details: cannot extend string with number */ ``` **示例**(用一个精化扩展另一个精化) 在这个示例中,我们扩展了两个精化 —— `Integer` 与 `Positive` —— 得到一个同时强制整数与正数约束的 schema。 ```ts import { Schema } from "effect" const Integer = Schema.Int.pipe(Schema.brand("Int")) const Positive = Schema.Positive.pipe(Schema.brand("Positive")) // ┌─── Schema & Brand<"Int">, number, never> // ▼ const PositiveInteger = Schema.asSchema(Schema.extend(Positive, Integer)) Schema.decodeUnknownSync(PositiveInteger)(-1) /* throws ParseError: positive & Brand<"Positive"> & int & Brand<"Int"> └─ From side refinement failure └─ positive & Brand<"Positive"> └─ Predicate refinement failure └─ Expected a positive number, actual -1 */ Schema.decodeUnknownSync(PositiveInteger)(1.1) /* throws ParseError: positive & Brand<"Positive"> & int & Brand<"Int"> └─ Predicate refinement failure └─ Expected an integer, actual 1.1 */ ``` ## 重命名属性 ### 在定义时重命名属性 要在创建 schema 时直接重命名属性,可以使用 `Schema.fromKey` 函数。 **示例**(重命名必需属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.propertySignature(Schema.String).pipe(Schema.fromKey("c")), b: Schema.Number, }) // ┌─── { readonly c: string; readonly b: number; } // ▼ type Encoded = typeof schema.Encoded // ┌─── { readonly a: string; readonly b: number; } // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } ``` **示例**(重命名可选属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.optional(Schema.String).pipe(Schema.fromKey("c")), b: Schema.Number, }) // ┌─── { readonly b: number; readonly c?: string | undefined; } // ▼ type Encoded = typeof schema.Encoded // ┌─── { readonly a?: string | undefined; readonly b: number; } // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 })) // Output: { a: 'c', b: 1 } console.log(Schema.decodeUnknownSync(schema)({ b: 1 })) // Output: { b: 1 } ``` 使用 `Schema.optional` 会自动返回一个 `PropertySignature`,因此在重命名必需字段时,无需像上一个示例那样显式使用 `Schema.propertySignature`。 ### 重命名已有 schema 的属性 对于已有的 schema,`Schema.rename` API 提供了一种在整个 schema 中系统地更改属性名的方法,即使在 union 这样的复杂结构中也能做到,不过对于 struct,你会丢失原始的字段类型。 **示例**(重命名 struct schema 中的属性) ```ts import { Schema } from "effect" const Original = Schema.Struct({ c: Schema.String, b: Schema.Number, }) // Renaming the "c" property to "a" // // // ┌─── SchemaClass<{ // | readonly a: string; // | readonly b: number; // | }> // ▼ const Renamed = Schema.rename(Original, { c: "a" }) console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } ``` **示例**(重命名 union schema 中的属性) ```ts import { Schema } from "effect" const Original = Schema.Union( Schema.Struct({ c: Schema.String, b: Schema.Number, }), Schema.Struct({ c: Schema.String, d: Schema.Boolean, }), ) // Renaming the "c" property to "a" for all members // // ┌─── SchemaClass<{ // | readonly a: string; // | readonly b: number; // | } | { // | readonly a: string; // | readonly d: number; // | }> // ▼ const Renamed = Schema.rename(Original, { c: "a" }) console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", d: false })) // Output: { a: 'c', d: false } ``` ## 递归 schema `Schema.suspend` 函数用于定义引用自身的 schema,例如递归数据结构中的 schema。 **示例**(自引用 schema) 在这个例子中,`Category` schema 通过 `subcategories` 字段引用自身,该字段是一个由 `Category` 对象组成的数组。 ```ts import { Schema } from "effect" interface Category { readonly name: string readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Schema => Category), ), }) ``` **示例**(类型推断错误) ```ts import { Schema } from "effect" // @errors: 7022 const Category = Schema.Struct({ name: Schema.String, // @errors: 7022 7024 subcategories: Schema.Array(Schema.suspend(() => Category)), }) ``` ### 简化 schema 定义的实用模式 正如我们所见,为了能够定义递归 schema,必须为 schema 的 `Type` 定义一个 interface, 这会让事情变得复杂,而且相当繁琐。 缓解这一问题的一种模式是,把**负责递归的字段**与所有其他字段分离开来。 **示例**(分离递归字段) ```ts import { Schema } from "effect" const fields = { name: Schema.String, // ...other fields as needed } // Define an interface for the Category schema, // extending the Type of the defined fields interface Category extends Schema.Struct.Type { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, // Spread in the base fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Schema => Category), ), }) ``` ### 相互递归的 schema 你也可以使用 `Schema.suspend` 创建相互递归的 schema,即两个 schema 互相引用。在下面的例子中,`Expression` 和 `Operation` 通过相互引用构成一棵简单的算术表达式树。 **示例**(定义相互递归的 schema) ```ts import { Schema } from "effect" interface Expression { readonly type: "expression" readonly value: number | Operation } interface Operation { readonly type: "operation" readonly operator: "+" | "-" readonly left: Expression readonly right: Expression } const Expression = Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union( Schema.Number, Schema.suspend((): Schema.Schema => Operation), ), }) const Operation = Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literal("+", "-"), left: Expression, right: Expression, }) ``` ### Encoded 与 Type 不同的递归类型 定义 `Encoded` 类型与 `Type` 类型不同的递归 schema 会再增加一层复杂度。在这种情况下,我们需要定义两个 interface:一个用于 `Type` 类型(如前所见),另一个用于 `Encoded` 类型。 **示例**(Encoded 与 Type 定义不同的递归 schema) 让我们考虑一个例子:假设我们想给 `Category` schema 添加一个 `id` 字段,其中 `id` 的 schema 是 `NumberFromString`。 需要注意的是,`NumberFromString` 是一个把字符串转换为数字的 schema,因此 `NumberFromString` 的 `Type` 与 `Encoded` 类型不同,分别是 `number` 和 `string`。 当我们把这个字段添加到 `Category` schema 时,TypeScript 会报错: ```ts import { Schema } from "effect" const fields = { id: Schema.NumberFromString, name: Schema.String, } interface Category extends Schema.Struct.Type { readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Schema => Category), ), }) ``` 出现这个错误是因为显式注解 `Schema.Schema` 已不再足够,需要通过显式添加 `Encoded` 类型来调整: ```ts import { Schema } from "effect" const fields = { id: Schema.NumberFromString, name: Schema.String, } interface Category extends Schema.Struct.Type { readonly subcategories: ReadonlyArray } interface CategoryEncoded extends Schema.Struct.Encoded { readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( Schema.suspend((): Schema.Schema => Category), ), }) ``` --- # Schema 注解 > 了解如何用注解增强 schema,以便在基于 Effect 的应用中更好地自定义、处理错误、编写文档并控制并发。 Schema 设计的关键特性之一,就是它的灵活性以及可自定义的能力。这一点是通过“注解(annotation)”实现的。schema 的 `ast` 字段中的每个节点都有一个 `annotations: Record` 字段,让你可以为 schema 附加额外信息。你可以使用 `annotations` 方法或 `Schema.annotations` API 来管理这些注解。 **示例**(用注解自定义 Schema) ```ts import { Schema } from "effect" // Define a Password schema, starting with a string type const Password = Schema.String // Add a custom error message for non-string values .annotations({ message: () => "not a string" }) .pipe( // Enforce non-empty strings and provide a custom error message Schema.nonEmptyString({ message: () => "required" }), // Restrict the string length to 10 characters or fewer // with a custom error message for exceeding length Schema.maxLength(10, { message: (issue) => `${issue.actual} is too long`, }), ) .annotations({ // Add a unique identifier for the schema identifier: "Password", // Provide a title for the schema title: "password", // Include a description explaining what this schema represents description: "A password is a secret string used to authenticate a user", // Add examples for better clarity examples: ["1Ki77y", "jelly22fi$h"], // Include any additional documentation documentation: `...technical information on Password schema...`, }) ``` ## 内置注解 下表概述了常见的内置注解及其用途: | 注解 | 说明 | | --- | --- | | `identifier` | 为 schema 分配唯一标识符,非常适合 TypeScript 标识符与代码生成场景。像 [TreeFormatter](/docs/v3/schema/error-formatters/#customizing-the-output) 这样的工具常用它来让输出更清晰。例如 `"Person"`、`"Product"`。 | | `title` | 为 schema 设置简短的描述性标题,类似 JSON Schema 的 title。适用于文档或 UI 标题。[TreeFormatter](/docs/v3/schema/error-formatters/#customizing-the-output) 也会用它来提升错误消息的可读性。 | | `description` | 详细说明 schema 的用途,类似 JSON Schema 的 description。[TreeFormatter](/docs/v3/schema/error-formatters/#customizing-the-output) 会用它提供更详细的错误消息。 | | `documentation` | 为 schema 补充详细文档,对开发者或自动化文档生成很有帮助。 | | `examples` | 列出合法 schema 值的示例,类似 JSON Schema 的 examples 属性,对文档与校验测试都很有用。 | | `default` | 为 schema 定义默认值,类似 JSON Schema 的 default 属性,以便在适用时预先填充 schema。 | | `message` | 自定义校验失败时的错误消息,让 [TreeFormatter](/docs/v3/schema/error-formatters/#customizing-the-output) 与 [ArrayFormatter](/docs/v3/schema/error-formatters/#arrayformatter) 这类工具在解码或校验出错时输出得更清晰。 | | `jsonSchema` | 指定会影响 [JSON Schema](/docs/v3/schema/json-schema/) 文档生成的注解,从而自定义 schema 的表示方式。 | | `arbitrary` | 配置 [Arbitrary](/docs/v3/schema/arbitrary/) 测试数据的生成设置。 | | `pretty` | 配置 [Pretty](/docs/v3/schema/pretty/) 输出的生成设置。 | | `equivalence` | 配置数据 [Equivalence](/docs/v3/schema/equivalence/) 的判定设置。 | | `concurrency` | 控制并发行为,确保 schema 在并发操作下表现最佳。详细用法请参阅[并发注解](#concurrency-annotation)。 | | `batching` | 管理批处理操作的设置,在操作可以分组时提升性能。 | | `parseIssueTitle` | 为解析 issue 提供自定义标题,增强 [TreeFormatter](/docs/v3/schema/error-formatters/#treeformatter-default) 输出中的错误描述。更多信息请参阅 [ParseIssueTitle 注解](/docs/v3/schema/error-formatters/#parseissuetitle-annotation)。 | | `parseOptions` | 允许在 schema 层级覆盖解析选项,从而对解析行为提供细粒度控制。应用细节请参阅[在 Schema 层级自定义解析行为](/docs/v3/schema/getting-started/#customizing-parsing-behavior-at-the-schema-level)。 | | `decodingFallback` | 提供一种方式,用于定义解码操作失败时触发的自定义回退行为。详细用法请参阅[用回退处理解码错误](#handling-decoding-errors-with-fallbacks)。 | ## 并发注解 对于 `Struct`、`Array` 或 `Union` 这类包含多个嵌套 schema 的复杂 schema,`concurrency` 注解提供了一种控制校验如何并发执行的方式。 ```ts type ConcurrencyAnnotation = number | "unbounded" | "inherit" | undefined ``` 下面用表格给出更简短的版本: | 值 | 说明 | | --- | --- | | `number` | 限制并发任务的最大数量。 | | `"unbounded"` | 所有任务并发运行,没有数量限制。 | | `"inherit"` | 从父级上下文继承 concurrency 设置。 | | `undefined` | 任务一个接一个地顺序运行(默认行为)。 | **示例**(顺序执行) 在这个示例中,我们定义了三个任务,模拟耗时不同的异步操作。由于没有指定 concurrency,这些任务会一个接一个地顺序执行。 ```ts import { Schema } from "effect" import type { Duration } from "effect" import { Effect } from "effect" // Simulates an async task const item = (id: number, duration: Duration.DurationInput) => Schema.String.pipe( Schema.filterEffect(() => Effect.gen(function* () { yield* Effect.sleep(duration) console.log(`Task ${id} done`) return true }), ), ) const Sequential = Schema.Tuple( item(1, "30 millis"), item(2, "10 millis"), item(3, "20 millis"), ) Effect.runPromise(Schema.decode(Sequential)(["a", "b", "c"])) /* Output: Task 1 done Task 2 done Task 3 done */ ``` **示例**(并发执行) 通过添加一个设置为 `"unbounded"` 的 `concurrency` 注解,这些任务现在可以并发运行,也就是说它们不必等待彼此完成后才开始。当涉及多个任务时,这能带来更快的执行速度。 ```ts import { Schema } from "effect" import type { Duration } from "effect" import { Effect } from "effect" // Simulates an async task const item = (id: number, duration: Duration.DurationInput) => Schema.String.pipe( Schema.filterEffect(() => Effect.gen(function* () { yield* Effect.sleep(duration) console.log(`Task ${id} done`) return true }), ), ) const Concurrent = Schema.Tuple( item(1, "30 millis"), item(2, "10 millis"), item(3, "20 millis"), ).annotations({ concurrency: "unbounded" }) Effect.runPromise(Schema.decode(Concurrent)(["a", "b", "c"])) /* Output: Task 2 done Task 3 done Task 1 done */ ``` ## 用回退处理解码错误 `DecodingFallbackAnnotation` 让你可以通过提供自定义的回退逻辑来处理解码错误。 ```ts type DecodingFallbackAnnotation = ( issue: ParseIssue, ) => Effect ``` 这个注解让你可以在解码失败时指定回退行为,从而优雅地从错误中恢复。 **示例**(基本回退) 在这个基本示例中,当解码失败时(例如输入为 `null`),会返回回退值而不是报错。 ```ts import { Schema } from "effect" import { Either } from "effect" // Schema with a fallback value const schema = Schema.String.annotations({ decodingFallback: () => Either.right(""), }) console.log(Schema.decodeUnknownSync(schema)("valid input")) // Output: valid input console.log(Schema.decodeUnknownSync(schema)(null)) // Output: ``` **示例**(带日志的进阶回退) 在这个进阶示例中,当发生解码错误时,schema 会记录该 issue,然后返回一个回退值。这展示了如何在错误处理过程中加入日志和其他副作用。 ```ts import { Schema } from "effect" import { Effect } from "effect" // Schema with logging and fallback const schemaWithLog = Schema.String.annotations({ decodingFallback: (issue) => Effect.gen(function* () { // Log the error issue yield* Effect.log(issue._tag) // Simulate a delay yield* Effect.sleep(10) // Return a fallback value return yield* Effect.succeed("") }), }) // Run the effectful fallback logic Effect.runPromise(Schema.decodeUnknown(schemaWithLog)(null)).then(console.log) /* Output: timestamp=2024-07-25T13:22:37.706Z level=INFO fiber=#0 message=Type */ ``` ## 自定义注解 除了内置注解之外,你还可以定义自定义注解来满足特定需求。例如,下面演示如何创建一个 `deprecated` 注解: **示例**(定义一个自定义注解) ```ts import { Schema } from "effect" // Define a unique identifier for your custom annotation const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) // Apply the custom annotation to the schema const MyString = Schema.String.annotations({ [DeprecatedId]: true }) console.log(MyString) /* Output: [class SchemaClass] { ast: StringKeyword { annotations: { [Symbol(@effect/docs/schema/annotation/Title)]: 'string', [Symbol(@effect/docs/schema/annotation/Description)]: 'a string', [Symbol(some/unique/identifier/for/your/custom/annotation)]: true }, _tag: 'StringKeyword' }, ... } */ ``` 为了让新的自定义注解具备类型安全,你可以使用 module augmentation。在下一个示例中,我们希望自定义注解是一个 boolean。 **示例**(为自定义注解添加类型安全) ```ts import { Schema } from "effect" const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) // Module augmentation declare module "effect/Schema" { namespace Annotations { interface GenericSchema extends Schema { [DeprecatedId]?: boolean } } } const MyString = Schema.String.annotations({ // @errors: 2418 [DeprecatedId]: "bad value", }) ``` 你可以使用 `SchemaAST.getAnnotation` 辅助函数读取自定义注解。 **示例**(读取一个自定义注解) ```ts import { SchemaAST, Schema } from "effect" import { Option } from "effect" const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) declare module "effect/Schema" { namespace Annotations { interface GenericSchema extends Schema { [DeprecatedId]?: boolean } } } const MyString = Schema.String.annotations({ [DeprecatedId]: true }) // Helper function to check if a schema is marked as deprecated const isDeprecated = (schema: Schema.Schema): boolean => SchemaAST.getAnnotation(DeprecatedId)(schema.ast).pipe( Option.getOrElse(() => false), ) console.log(isDeprecated(Schema.String)) // Output: false console.log(isDeprecated(MyString)) // Output: true ``` --- # 从 Schema 到 Arbitrary > 使用 Arbitrary 生成符合 schema 约束的随机测试数据,并支持转换、过滤器与自定义生成等选项。 `Arbitrary.make` 函数用于创建与特定 `Schema` 相符的随机值。 该函数会返回 [fast-check](https://github.com/dubzzz/fast-check) 库中的一个 `Arbitrary`, 它特别适合用来生成符合所定义 schema 约束的随机测试数据。 **示例**(为 Schema 生成 Arbitrary 数据) ```ts import { Arbitrary, FastCheck, Schema } from "effect" // Define a Person schema with constraints const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Int.pipe(Schema.between(1, 80)), }) // Create an Arbitrary based on the schema const arb = Arbitrary.make(Person) // Generate random samples from the Arbitrary console.log(FastCheck.sample(arb, 2)) /* Example Output: [ { name: 'q r', age: 3 }, { name: '&|', age: 6 } ] */ ``` 想让输出更真实,请参阅[自定义 Arbitrary 数据生成](#customizing-arbitrary-data-generation)一节。 ## 过滤器 生成随机值时,`Arbitrary` 会尽量遵循 schema 的约束。它会选用最合适的 `fast-check` primitive(原语),并在该 primitive 支持约束时应用这些约束。 例如,如果你把 `age` 属性定义为: ```ts Schema.Int.pipe(Schema.between(1, 80)) ``` 那么 Arbitrary 生成时会使用: ```ts FastCheck.integer({ min: 1, max: 80 }) ``` 来在该范围内生成值。 ### 模式 要为必须匹配某个模式的字符串生成高效的 arbitrary,请使用 `Schema.pattern` 过滤器,而不是自己编写自定义过滤器: **示例**(使用 `Schema.pattern` 处理模式约束) ```ts import { Schema } from "effect" // ❌ Without using Schema.pattern (less efficient) const Bad = Schema.String.pipe(Schema.filter((s) => /^[a-z]+$/.test(s))) // ✅ Using Schema.pattern (more efficient) const Good = Schema.String.pipe(Schema.pattern(/^[a-z]+$/)) ``` 使用 `Schema.pattern` 后,arbitrary 生成会依赖 `FastCheck.stringMatching(regexp)`,这更高效,也与所定义的模式直接对应。 当使用多个模式时,它们会被合并成一个 union。例如: ```ts (?:${pattern1})|(?:${pattern2}) ``` 这种做法确保在使用 `FastCheck.stringMatching` 时,所有模式都有相同的机会生成值。 ## 转换与 Arbitrary 生成 生成 Arbitrary 数据时,理解 schema 内部如何处理转换和过滤器很重要: **示例**(过滤器与转换) ```ts import { Arbitrary, FastCheck, Schema } from "effect" // Schema with filters before the transformation const schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe( Schema.maxLength(500), ) // May produce empty strings due to ignored NonEmpty filter console.log(FastCheck.sample(Arbitrary.make(schema1), 2)) /* Example Output: [ '', '"Ry' ] */ // Schema with filters applied after transformations const schema2 = Schema.Trim.pipe(Schema.nonEmptyString(), Schema.maxLength(500)) // Adheres to all filters, avoiding empty strings console.log(FastCheck.sample(Arbitrary.make(schema2), 2)) /* Example Output: [ ']H+MPXgZKz', 'SNS|waP~\\' ] */ ``` **解释:** - `schema1`:会考虑 `Schema.maxLength(500)`,因为它应用在 `Schema.Trim` 转换之后;但会忽略 `Schema.NonEmptyString`,因为它位于转换之前。 - `schema2`:完全遵循所有过滤器,因为它们被正确地排在转换之后,从而避免生成不期望的数据。 ### 最佳实践 为确保一致且有效的 Arbitrary 数据生成,请遵循以下准则: 1. **先应用过滤器**:为初始类型(`I`)定义过滤器。 2. **应用转换**:添加转换来转换数据。 3. **应用最后的过滤器**:为转换后的类型(`A`)使用过滤器。 这样的设置能确保数据处理的每个阶段都精确且定义清晰。 **示例**(避免混用过滤器与转换) 避免随意组合转换和过滤器: ```ts import { Schema } from "effect" // Less optimal approach: Mixing transformations and filters const problematic = Schema.compose(Schema.Lowercase, Schema.Trim) ``` 更推荐结构化的做法:把转换步骤与过滤器应用分开: **示例**(更推荐的结构化做法) ```ts import { Schema } from "effect" // Recommended: Separate transformations and filters const improved = Schema.transform( Schema.String, Schema.String.pipe(Schema.trimmed(), Schema.lowercased()), { strict: true, decode: (s) => s.trim().toLowerCase(), encode: (s) => s, }, ) ``` ## 自定义 Arbitrary 数据生成 你可以使用 schema 定义中的 `arbitrary` 注解来自定义 Arbitrary 数据的生成方式。 **示例**(自定义 Arbitrary 生成器) ```ts import { Arbitrary, FastCheck, Schema } from "effect" const Name = Schema.NonEmptyString.annotations({ arbitrary: () => (fc) => fc.constantFrom("Alice Johnson", "Dante Howell", "Marta Reyes"), }) const Age = Schema.Int.pipe(Schema.between(1, 80)) const Person = Schema.Struct({ name: Name, age: Age, }) const arb = Arbitrary.make(Person) console.log(FastCheck.sample(arb, 2)) /* Example Output: [ { name: 'Dante Howell', age: 6 }, { name: 'Marta Reyes', age: 53 } ] */ ``` 该注解可以访问 fast-check 库的完整导出(`fc`)。 这样你就能返回一个 `Arbitrary`,精确生成你想要的数据类型。 ### 与假数据生成器集成 在使用 [@faker-js/faker](https://www.npmjs.com/package/@faker-js/faker) 这类 mocking 库时, 你可以把它们与 `fast-check` 结合,为测试生成逼真的数据。 **示例**(与 Faker 集成) ```ts import { Arbitrary, FastCheck, Schema } from "effect" import { faker } from "@faker-js/faker" const Name = Schema.NonEmptyString.annotations({ arbitrary: () => (fc) => fc.constant(null).map(() => { // Each time the arbitrary is sampled, faker generates a new name return faker.person.fullName() }), }) const Age = Schema.Int.pipe(Schema.between(1, 80)) const Person = Schema.Struct({ name: Name, age: Age, }) const arb = Arbitrary.make(Person) console.log(FastCheck.sample(arb, 2)) /* Example Output: [ { name: 'Henry Dietrich', age: 68 }, { name: 'Lucas Haag', age: 52 } ] */ ``` --- # 基本用法 > 学习定义和使用基本 schema,包括基本类型、字面量、联合与结构体,以进行高效的数据校验与转换。 ## 基本类型 Schema 模块为常见的基本类型提供了内置 schema。 | Schema | 等价的 TypeScript 类型 | | ----------------------- | -------------------------- | | `Schema.String` | `string` | | `Schema.Number` | `number` | | `Schema.Boolean` | `boolean` | | `Schema.BigIntFromSelf` | `BigInt` | | `Schema.SymbolFromSelf` | `symbol` | | `Schema.Object` | `object` | | `Schema.Undefined` | `undefined` | | `Schema.Void` | `void` | | `Schema.Any` | `any` | | `Schema.Unknown` | `unknown` | | `Schema.Never` | `never` | **示例**(使用基本类型 schema) ```ts import { Schema } from "effect" const schema = Schema.String // Infers the type as string // // ┌─── string // ▼ type Type = typeof schema.Type // Attempt to decode a null value, which will throw a parse error Schema.decodeUnknownSync(schema)(null) /* throws: ParseError: Expected string, actual null */ ``` ## asSchema 为了方便使用 schema,内置 schema 在可能的情况下会以更简短的不透明类型(opaque type)暴露出来。 `Schema.asSchema` 函数让你可以把任意 schema 视为 `Schema`。 **示例**(用 `asSchema` 展开一个 schema) 例如,`Schema.String` 被定义为一个类型为 `typeof Schema.String` 的类,而使用 `Schema.asSchema` 则可以以扩展形式 `Schema` 得到该 schema。 ```ts import { Schema } from "effect" // ┌─── typeof Schema.String // ▼ const schema = Schema.String // ┌─── Schema // ▼ const nomalized = Schema.asSchema(schema) ``` ## 唯一符号 你可以使用 `Schema.UniqueSymbolFromSelf` 为唯一符号创建 schema。 **示例**(为唯一符号创建 schema) ```ts import { Schema } from "effect" const mySymbol = Symbol.for("mySymbol") const schema = Schema.UniqueSymbolFromSelf(mySymbol) // ┌─── typeof mySymbol // ▼ type Type = typeof schema.Type Schema.decodeUnknownSync(schema)(null) /* throws: ParseError: Expected Symbol(mySymbol), actual null */ ``` ## 字面量 字面量 schema 表示[字面量类型](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types)。 你可以用它们来指定类型必须具有的精确值。 字面量可以是以下几种类型: - `string` - `number` - `boolean` - `null` - `bigint` **示例**(定义字面量 schema) ```ts import { Schema } from "effect" // Define various literal schemas Schema.Null // Same as S.Literal(null) Schema.Literal("a") // string literal Schema.Literal(1) // number literal Schema.Literal(true) // boolean literal Schema.Literal(2n) // BigInt literal ``` **示例**(为 `"a"` 定义字面量 schema) ```ts import { Schema } from "effect" // ┌─── Literal<["a"]> // ▼ const schema = Schema.Literal("a") // ┌─── "a" // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)("a")) // Output: "a" console.log(Schema.decodeUnknownSync(schema)("b")) /* throws: ParseError: Expected "a", actual "b" */ ``` ### 字面量的联合 你可以把多个字面量作为参数传给 `Schema.Literal` 构造函数,从而创建它们的联合: **示例**(定义字面量的联合) ```ts import { Schema } from "effect" // ┌─── Literal<["a", "b", "c"]> // ▼ const schema = Schema.Literal("a", "b", "c") // ┌─── "a" | "b" | "c" // ▼ type Type = typeof schema.Type Schema.decodeUnknownSync(schema)(null) /* throws: ParseError: "a" | "b" | "c" ├─ Expected "a", actual null ├─ Expected "b", actual null └─ Expected "c", actual null */ ``` 如果你想为整个字面量联合设置自定义错误消息,可以使用 `override: true` 选项(更多细节见[自定义错误消息](/docs/v3/schema/error-messages/#custom-error-messages))来指定一条统一的消息。 **示例**(为字面量的联合添加自定义消息) ```ts import { Schema } from "effect" // Schema with individual messages for each literal const individualMessages = Schema.Literal("a", "b", "c") console.log(Schema.decodeUnknownSync(individualMessages)(null)) /* throws: ParseError: "a" | "b" | "c" ├─ Expected "a", actual null ├─ Expected "b", actual null └─ Expected "c", actual null */ // Schema with a unified custom message for all literals const unifiedMessage = Schema.Literal("a", "b", "c").annotations({ message: () => ({ message: "Not a valid code", override: true }), }) console.log(Schema.decodeUnknownSync(unifiedMessage)(null)) /* throws: ParseError: Not a valid code */ ``` ### 暴露的值 你可以通过 `literals` 属性访问字面量 schema 中定义的字面量: ```ts import { Schema } from "effect" const schema = Schema.Literal("a", "b", "c") // ┌─── readonly ["a", "b", "c"] // ▼ const literals = schema.literals ``` ### pickLiteral 工具 你可以把 `Schema.pickLiteral` 用于字面量 schema,以缩小其可能的取值范围。 **示例**(用 `pickLiteral` 收窄取值) ```ts import { Schema } from "effect" // Create a schema for a subset of literals ("a" and "b") from a larger set // // ┌─── Literal<["a", "b"]> // ▼ const schema = Schema.Literal("a", "b", "c").pipe(Schema.pickLiteral("a", "b")) ``` 有时你可能需要在代码的其他部分复用一个字面量 schema。下面的示例演示了如何做到这一点: **示例**(从字面量 schema 创建子类型) ```ts import { Schema } from "effect" // Define the base set of fruit categories const FruitCategory = Schema.Literal("sweet", "citrus", "tropical") // Define a general Fruit schema with the base category set const Fruit = Schema.Struct({ id: Schema.Number, category: FruitCategory, }) // Define a specific Fruit schema for only "sweet" and "citrus" categories const SweetAndCitrusFruit = Schema.Struct({ id: Schema.Number, category: FruitCategory.pipe(Schema.pickLiteral("sweet", "citrus")), }) ``` 在这个示例中,`FruitCategory` 是各类水果分类的事实来源。 我们复用它创建了 `Fruit` 的一个子类型 `SweetAndCitrusFruit`,确保只允许指定的分类(`"sweet"` 和 `"citrus"`)。 这种做法有助于在整个代码中保持一致,并在分类定义发生变化时提供类型安全。 ## 模板字面量 在 TypeScript 中,[模板字面量类型](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)允许你在字符串字面量中嵌入表达式。 `Schema.TemplateLiteral` 构造函数让你可以为这些模板字面量类型创建 schema。 **示例**(定义模板字面量) ```ts import { Schema } from "effect" // This creates a schema for: `a${string}` // // ┌─── TemplateLiteral<`a${string}`> // ▼ const schema1 = Schema.TemplateLiteral("a", Schema.String) // This creates a schema for: // `https://${string}.com` | `https://${string}.net` const schema2 = Schema.TemplateLiteral( "https://", Schema.String, ".", Schema.Literal("com", "net"), ) ``` **示例**(来自[模板字面量类型](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)文档) 再来看一个更复杂的例子。假设你有两组用于邮件和页脚的 locale ID。 你可以使用 `Schema.TemplateLiteral` 构造函数创建一个合并这些 ID 的 schema: ```ts import { Schema } from "effect" const EmailLocaleIDs = Schema.Literal("welcome_email", "email_heading") const FooterLocaleIDs = Schema.Literal("footer_title", "footer_sendoff") // This creates a schema for: // "welcome_email_id" | "email_heading_id" | // "footer_title_id" | "footer_sendoff_id" const schema = Schema.TemplateLiteral( Schema.Union(EmailLocaleIDs, FooterLocaleIDs), "_id", ) ``` ### 支持的 span 类型 `Schema.TemplateLiteral` 构造函数支持以下 span 类型: - `Schema.String` - `Schema.Number` - 字面量:`string | number | boolean | null | bigint`。它们既可以由 `Schema.Literal` 包装,也可以直接使用 - 上述类型的联合 - 上述类型的 Brand **示例**(在模板字面量中使用带品牌的字符串) ```ts import { Schema } from "effect" // Create a branded string schema for an authorization token const AuthorizationToken = Schema.String.pipe( Schema.brand("AuthorizationToken"), ) // This creates a schema for: // `Bearer ${string & Brand<"AuthorizationToken">}` const schema = Schema.TemplateLiteral("Bearer ", AuthorizationToken) ``` ### TemplateLiteralParser `Schema.TemplateLiteral` 构造函数作为简单的校验器很有用,但它只是把模板字面量定义转换成正则表达式,从而验证输入是否符合特定的字符串模式。类似地,[`Schema.pattern`](/docs/v3/schema/filters/#string-filters)直接使用正则表达式来达到同样的目的。在校验之后,这两种方法都需要额外的手工解析,才能把通过校验的字符串转换成可用的数据格式。 为了解决这些限制、省去校验后的手工解析,我们开发了 `Schema.TemplateLiteralParser` API。它不仅校验输入格式,还会自动把它解析成结构更清晰、类型更安全的输出,具体来说就是**元组**格式。 `Schema.TemplateLiteralParser` 构造函数支持与 `Schema.TemplateLiteral` 相同的 [span 类型](#supported-span-types)。 **示例**(使用 TemplateLiteralParser 进行解析与编码) ```ts import { Schema } from "effect" // ┌─── Schema // ▼ const schema = Schema.TemplateLiteralParser( Schema.NumberFromString, "a", Schema.NonEmptyString, ) console.log(Schema.decodeSync(schema)("100afoo")) // Output: [ 100, 'a', 'foo' ] console.log(Schema.encodeSync(schema)([100, "a", "foo"])) // Output: '100afoo' ``` ## 原生枚举 Schema 模块支持 TypeScript 的原生枚举。你可以使用 `Schema.Enums` 为枚举定义 schema,从而校验属于该枚举的值。 **示例**(为枚举定义 schema) ```ts import { Schema } from "effect" enum Fruits { Apple, Banana, } // ┌─── Enums // ▼ const schema = Schema.Enums(Fruits) // // ┌─── Fruits // ▼ type Type = typeof schema.Type ``` ### 暴露的值 枚举可以通过 schema 的 `enums` 属性访问。你可以用这个属性获取单个成员或整个枚举值集合。 ```ts import { Schema } from "effect" enum Fruits { Apple, Banana, } const schema = Schema.Enums(Fruits) schema.enums // Returns all enum members schema.enums.Apple // Access the Apple member schema.enums.Banana // Access the Banana member ``` ## 联合 Schema 模块内置了 `Schema.Union` 构造函数,用于创建“或”类型,让你可以定义能够表示多种类型的 schema。 **示例**(定义联合 schema) ```ts import { Schema } from "effect" // ┌─── Union<[typeof Schema.String, typeof Schema.Number]> // ▼ const schema = Schema.Union(Schema.String, Schema.Number) // ┌─── string | number // ▼ type Type = typeof schema.Type ``` ### 联合成员的求值顺序 解码时,联合成员按它们定义的顺序依次求值。如果某个值与第一个成员匹配,就会用那个 schema 解码。如果不匹配,解码过程会继续尝试下一个成员。 如果多个 schema 都能解码同一个值,顺序就很关键。把更通用的 schema 放在更具体的 schema 前面,可能会导致属性丢失,因为会使用第一个匹配的 schema。 **示例**(处理联合中相互重叠的 schema) ```ts import { Schema } from "effect" // Define two overlapping schemas const Member1 = Schema.Struct({ a: Schema.String, }) const Member2 = Schema.Struct({ a: Schema.String, b: Schema.Number, }) // ❌ Define a union where Member1 appears first const Bad = Schema.Union(Member1, Member2) console.log(Schema.decodeUnknownSync(Bad)({ a: "a", b: 12 })) // Output: { a: 'a' } (Member1 matched first, so `b` was ignored) // ✅ Define a union where Member2 appears first const Good = Schema.Union(Member2, Member1) console.log(Schema.decodeUnknownSync(Good)({ a: "a", b: 12 })) // Output: { a: 'a', b: 12 } (Member2 matched first, so `b` was included) ``` ### 字面量联合 你固然可以通过组合各个字面量 schema 来创建字面量联合: **示例**(使用各个字面量 schema) ```ts import { Schema } from "effect" // ┌─── Union<[Schema.Literal<["a"]>, Schema.Literal<["b"]>, Schema.Literal<["c"]>]> // ▼ const schema = Schema.Union( Schema.Literal("a"), Schema.Literal("b"), Schema.Literal("c"), ) ``` 你可以把多个字面量直接传给 `Schema.Literal` 构造器,从而简化这一过程: **示例**(定义字面量联合) ```ts import { Schema } from "effect" // ┌─── Literal<["a", "b", "c"]> // ▼ const schema = Schema.Literal("a", "b", "c") // ┌─── "a" | "b" | "c" // ▼ type Type = typeof schema.Type ``` 如果你想为整个字面量联合设置自定义错误信息,可以使用 `override: true` 选项(更多细节见[自定义错误信息](/docs/v3/schema/error-messages/#custom-error-messages))来指定一条统一的信息。 **示例**(为字面量联合添加自定义信息) ```ts import { Schema } from "effect" // Schema with individual messages for each literal const individualMessages = Schema.Literal("a", "b", "c") console.log(Schema.decodeUnknownSync(individualMessages)(null)) /* throws: ParseError: "a" | "b" | "c" ├─ Expected "a", actual null ├─ Expected "b", actual null └─ Expected "c", actual null */ // Schema with a unified custom message for all literals const unifiedMessage = Schema.Literal("a", "b", "c").annotations({ message: () => ({ message: "Not a valid code", override: true }), }) console.log(Schema.decodeUnknownSync(unifiedMessage)(null)) /* throws: ParseError: Not a valid code */ ``` ### 可空类型 Schema 模块提供了一些工具函数,用于定义允许可空类型的 schema,帮助你处理可能是 `null`、`undefined` 或两者兼有的值。 **示例**(创建可空 Schema) ```ts import { Schema } from "effect" // Represents a schema for a string or null value Schema.NullOr(Schema.String) // Represents a schema for a string, null, or undefined value Schema.NullishOr(Schema.String) // Represents a schema for a string or undefined value Schema.UndefinedOr(Schema.String) ``` ### 可辨识联合 TypeScript 中的[可辨识联合](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions)是一种对复杂数据结构建模的方式,这类结构可能根据一组特定的条件或属性呈现不同的形态。它允许你定义一个表示多个相关形状的类型,其中每个形状都由一个共享的判别属性唯一标识。 在可辨识联合中,联合的每个变体都有一个公共属性,称为判别属性(discriminant)。判别属性是字面量类型,这意味着它只能取有限的一组可能值。TypeScript 可以根据判别属性的值推断出当前使用的是联合中的哪个变体。 **示例**(在 TypeScript 中定义可辨识联合) ```ts type Circle = { readonly kind: "circle" readonly radius: number } type Square = { readonly kind: "square" readonly sideLength: number } type Shape = Circle | Square ``` 在 `Schema` 模块中,你可以为每个类型指定一个字面量字段作为判别属性,从而以类似的方式定义可辨识联合。 **示例**(使用 Schema 定义可辨识联合) ```ts import { Schema } from "effect" const Circle = Schema.Struct({ kind: Schema.Literal("circle"), radius: Schema.Number, }) const Square = Schema.Struct({ kind: Schema.Literal("square"), sideLength: Schema.Number, }) const Shape = Schema.Union(Circle, Square) ``` 在这个例子中,`Schema.Literal` 构造器把 `kind` 属性设置为 `Circle` 和 `Square` 两个 schema 共同的判别属性。随后 `Shape` schema 表示这两个类型的联合,让 TypeScript 能够根据 `kind` 的值推断出具体的形状。 ### 把简单联合转换为可辨识联合 如果你从一个简单联合开始,并想把它转换为可辨识联合,可以为每个成员添加一个特殊属性。这样 TypeScript 就能根据判别属性的值自动推断出正确的类型。 **示例**(最初的简单联合) 例如,假设你定义了一个由 `Circle` 和 `Square` 组合而成、不带任何特殊属性的 `Shape` 联合: ```ts import { Schema } from "effect" const Circle = Schema.Struct({ radius: Schema.Number, }) const Square = Schema.Struct({ sideLength: Schema.Number, }) const Shape = Schema.Union(Circle, Square) ``` 为了让代码更易于管理,你可能想把简单联合转换为可辨识联合。这样,TypeScript 就能根据某个特定属性的值自动判断你正在处理联合中的哪个成员。 为此,你可以为联合的每个成员添加一个特殊属性,让 TypeScript 在运行时知道它面对的是哪个类型。 下面演示如何把 `Shape` schema [转换](/docs/v3/schema/transformations/#transform)为另一个表示可辨识联合的 schema: **示例**(添加判别属性) ```ts import { Schema } from "effect" const Circle = Schema.Struct({ radius: Schema.Number, }) const Square = Schema.Struct({ sideLength: Schema.Number, }) const DiscriminatedShape = Schema.Union( Schema.transform( Circle, // Add a "kind" property with the literal value "circle" to Circle Schema.Struct({ ...Circle.fields, kind: Schema.Literal("circle") }), { strict: true, // Add the discriminant property to Circle decode: (circle) => ({ ...circle, kind: "circle" as const }), // Remove the discriminant property encode: ({ kind: _kind, ...rest }) => rest, }, ), Schema.transform( Square, // Add a "kind" property with the literal value "square" to Square Schema.Struct({ ...Square.fields, kind: Schema.Literal("square") }), { strict: true, // Add the discriminant property to Square decode: (square) => ({ ...square, kind: "square" as const }), // Remove the discriminant property encode: ({ kind: _kind, ...rest }) => rest, }, ), ) console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 })) // Output: { radius: 10, kind: 'circle' } console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ sideLength: 10 })) // Output: { sideLength: 10, kind: 'square' } ``` 前面这个方案完全可行,也展示了我们可以随意为 schema 添加属性,让结果更容易在领域模型中使用。 不过,它需要大量样板代码。所幸有一个专门为此场景设计的 API —— `Schema.attachPropertySignature`,它让我们用少得多的代码实现同样的效果: **示例**(使用 `Schema.attachPropertySignature` 减少代码量) ```ts import { Schema } from "effect" const Circle = Schema.Struct({ radius: Schema.Number, }) const Square = Schema.Struct({ sideLength: Schema.Number, }) const DiscriminatedShape = Schema.Union( Circle.pipe(Schema.attachPropertySignature("kind", "circle")), Square.pipe(Schema.attachPropertySignature("kind", "square")), ) // decoding console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 })) // Output: { radius: 10, kind: 'circle' } // encoding console.log( Schema.encodeSync(DiscriminatedShape)({ kind: "circle", radius: 10, }), ) // Output: { radius: 10 } ``` ### 暴露的值 你可以访问以元组形式表示的联合 schema 中的各个成员: ```ts import { Schema } from "effect" const schema = Schema.Union(Schema.String, Schema.Number) // Accesses the members of the union const members = schema.members // ┌─── typeof Schema.String // ▼ const firstMember = members[0] // ┌─── typeof Schema.Number // ▼ const secondMember = members[1] ``` ## 元组 Schema 模块允许你定义元组,即元素类型可以不同的有序集合。 你可以定义包含必需元素、可选元素或剩余元素的元组。 ### 必需元素 要定义包含必需元素的元组,可以使用 `Schema.Tuple` 构造器,按顺序列出各个元素 schema 即可: **示例**(定义包含必需元素的元组) ```ts import { Schema } from "effect" // Define a tuple with a string and a number as required elements // // ┌─── Tuple<[typeof Schema.String, typeof Schema.Number]> // ▼ const schema = Schema.Tuple(Schema.String, Schema.Number) // ┌─── readonly [string, number] // ▼ type Type = typeof schema.Type ``` ### 追加必需元素 你可以使用展开运算符,向已有元组追加额外的必需元素: **示例**(向已有元组添加元素) ```ts import { Schema } from "effect" const tuple1 = Schema.Tuple(Schema.String, Schema.Number) // Append a boolean to the existing tuple const tuple2 = Schema.Tuple(...tuple1.elements, Schema.Boolean) // ┌─── readonly [string, number, boolean] // ▼ type Type = typeof tuple2.Type ``` ### 可选元素 要定义可选元素,请使用 `Schema.optionalElement` 构造器。 **示例**(定义包含可选元素的元组) ```ts import { Schema } from "effect" // Define a tuple with a required string and an optional number const schema = Schema.Tuple( Schema.String, // required element Schema.optionalElement(Schema.Number), // optional element ) // ┌─── readonly [string, number?] // ▼ type Type = typeof schema.Type ``` ### 剩余元素 要定义剩余元素,请把它添加在必需元素或可选元素列表之后。 剩余元素让元组可以接受特定类型的额外元素。 **示例**(使用剩余元素) ```ts import { Schema } from "effect" // Define a tuple with required elements and a rest element of type boolean const schema = Schema.Tuple( [Schema.String, Schema.optionalElement(Schema.Number)], // elements Schema.Boolean, // rest element ) // ┌─── readonly [string, number?, ...boolean[]] // ▼ type Type = typeof schema.Type ``` 你还可以在剩余元素之后包含其他元素: **示例**(在剩余元素之后包含额外元素) ```ts import { Schema } from "effect" // Define a tuple with required elements, a rest element, // and an additional element const schema = Schema.Tuple( [Schema.String, Schema.optionalElement(Schema.Number)], // elements Schema.Boolean, // rest element Schema.String, // additional element ) // ┌─── readonly [string, number | undefined, ...boolean[], string] // ▼ type Type = typeof schema.Type ``` ### 元素注解 注解(annotation)可用于为元组元素添加元数据,从而更容易描述它们的用途或要求。 这在生成文档或 JSON schema 时尤其有用。 **示例**(为元组元素添加注解) ```ts import { JSONSchema, Schema } from "effect" // Define a tuple representing a point with annotations for each coordinate const Point = Schema.Tuple( Schema.element(Schema.Number).annotations({ title: "X", description: "X coordinate", }), Schema.optionalElement(Schema.Number).annotations({ title: "Y", description: "optional Y coordinate", }), ) // Generate a JSON Schema from the tuple console.log(JSONSchema.make(Point)) /* Output: { '$schema': 'http://json-schema.org/draft-07/schema#', type: 'array', minItems: 1, items: [ { type: 'number', description: 'X coordinate', title: 'X' }, { type: 'number', description: 'optional Y coordinate', title: 'Y' } ], additionalItems: false } */ ``` ### 暴露的值 你可以使用 `elements` 和 `rest` 属性访问元组 schema 的元素与剩余元素: **示例**(访问元组 schema 的元素与剩余元素) ```ts import { Schema } from "effect" // Define a tuple with required, optional, and rest elements const schema = Schema.Tuple( [Schema.String, Schema.optionalElement(Schema.Number)], // elements Schema.Boolean, // rest element Schema.String, // additional element ) // Access the required and optional elements of the tuple // // ┌─── readonly [typeof Schema.String, Schema.Element] // ▼ const tupleElements = schema.elements // Access the rest element of the tuple // // ┌─── readonly [typeof Schema.Boolean, typeof Schema.String] // ▼ const restElement = schema.rest ``` ## 数组 Schema 模块允许你为数组定义 schema,从而轻松校验由特定类型的元素组成的集合。 **示例**(定义数组 Schema) ```ts import { Schema } from "effect" // Define a schema for an array of numbers // // ┌─── Array$ // ▼ const schema = Schema.Array(Schema.Number) // ┌─── readonly number[] // ▼ type Type = typeof schema.Type ``` ### 可变数组 默认情况下,`Schema.Array` 生成的类型被标记为 `readonly`。 要为可变数组创建 schema,可以使用 `Schema.mutable` 函数,它以**浅层**方式让数组类型变为可变。 **示例**(创建可变数组 Schema) ```ts import { Schema } from "effect" // Define a schema for a mutable array of numbers // // ┌─── mutable> // ▼ const schema = Schema.mutable(Schema.Array(Schema.Number)) // ┌─── number[] // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `value` 属性访问数组 schema 的值类型: **示例**(访问数组 Schema 的值类型) ```ts import { Schema } from "effect" const schema = Schema.Array(Schema.Number) // Access the value type of the array schema // // ┌─── typeof Schema.Number // ▼ const value = schema.value ``` ## 非空数组 Schema 模块还提供了为非空数组定义 schema 的方式,确保数组始终至少包含一个元素。 **示例**(定义非空数组 Schema) ```ts import { Schema } from "effect" // Define a schema for a non-empty array of numbers // // ┌─── NonEmptyArray // ▼ const schema = Schema.NonEmptyArray(Schema.Number) // ┌─── readonly [number, ...number[]] // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `value` 属性访问非空数组 schema 的值类型: **示例**(访问非空数组 Schema 的值类型) ```ts import { Schema } from "effect" // Define a schema for a non-empty array of numbers const schema = Schema.NonEmptyArray(Schema.Number) // Access the value type of the non-empty array schema // // ┌─── typeof Schema.Number // ▼ const value = schema.value ``` ## Record Schema 模块提供了定义 record 类型的支持:record 是键值对的集合,其中的键可以是字符串、symbol 或其他类型,而值则具有一个已定义的 schema。 ### 字符串键 你可以定义键为字符串、并为其值指定类型的 record。 **示例**(字符串键与数字值) ```ts import { Schema } from "effect" // Define a record schema with string keys and number values // // ┌─── Record$ // ▼ const schema = Schema.Record({ key: Schema.String, value: Schema.Number }) // ┌─── { readonly [x: string]: number; } // ▼ type Type = typeof schema.Type ``` ### Symbol 键 Record 也可以使用 symbol 作为键。 **示例**(Symbol 键与数字值) ```ts import { Schema } from "effect" // Define a record schema with symbol keys and number values const schema = Schema.Record({ key: Schema.SymbolFromSelf, value: Schema.Number, }) // ┌─── { readonly [x: symbol]: number; } // ▼ type Type = typeof schema.Type ``` ### 字面量键的联合 使用字面量的联合可以把键限制在一组特定的值上。 **示例**(用字符串字面量作为键) ```ts import { Schema } from "effect" // Define a record schema where keys are limited // to specific string literals ("a" or "b") const schema = Schema.Record({ key: Schema.Union(Schema.Literal("a"), Schema.Literal("b")), value: Schema.Number, }) // ┌─── { readonly a: number; readonly b: number; } // ▼ type Type = typeof schema.Type ``` ### 模板字面量键 Record 可以使用模板字面量作为键,从而支持更复杂的键模式。 **示例**(模板字面量键与数字值) ```ts import { Schema } from "effect" // Define a record schema with keys that match // the template literal pattern "a${string}" const schema = Schema.Record({ key: Schema.TemplateLiteral(Schema.Literal("a"), Schema.String), value: Schema.Number, }) // ┌─── { readonly [x: `a${string}`]: number; } // ▼ type Type = typeof schema.Type ``` ### 细化后的键 你可以用额外的约束来细化键的类型。 **示例**(按最小长度过滤键) ```ts import { Schema } from "effect" // Define a record schema where keys are strings with a minimum length of 2 const schema = Schema.Record({ key: Schema.String.pipe(Schema.minLength(2)), value: Schema.Number, }) // ┌─── { readonly [x: string]: number; } // ▼ type Type = typeof schema.Type ``` 对键的细化起的是过滤作用,而不会导致解码失败。 如果某个键不满足约束(例如模式或最小长度检查),它会被从解码输出中移除,而不是触发错误。 **示例**(不满足约束的键会被移除) ```ts import { Schema } from "effect" const schema = Schema.Record({ key: Schema.String.pipe(Schema.minLength(2)), value: Schema.Number, }) console.log(Schema.decodeUnknownSync(schema)({ a: 1, bb: 2 })) // Output: { bb: 2 } ("a" is removed because it is too short) ``` 如果你希望在键不满足约束时让解码失败,可以把 [`onExcessProperty`](/docs/v3/schema/getting-started/#managing-excess-properties) 设为 `"error"`。 **示例**(对无效键强制报错) ```ts import { Schema } from "effect" const schema = Schema.Record({ key: Schema.String.pipe(Schema.minLength(2)), value: Schema.Number, }) console.log( Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })({ a: 1, bb: 2, }), ) /* throws: ParseError: { readonly [x: minLength(2)]: number } └─ ["a"] └─ is unexpected, expected: minLength(2) */ ``` ### 转换键 `Schema.Record` API 不支持对键 schema 做转换。 尝试对键应用转换会得到 `Unsupported key schema` 错误: **示例**(尝试转换键) ```ts import { Schema } from "effect" const schema = Schema.Record({ key: Schema.Trim, value: Schema.NumberFromString, }) /* throws: Error: Unsupported key schema schema (Transformation): Trim */ ``` 要修改 record 的键,你必须在 `Schema.Record` 之外应用转换。 一种常见做法是用 [Schema.transform](/docs/v3/schema/transformations/#transform) 在解码过程中调整键。 **示例**(解码时修剪键) ```ts import { Schema, Record, identity } from "effect" const schema = Schema.transform( // Define the input schema with unprocessed keys Schema.Record({ key: Schema.String, value: Schema.NumberFromString, }), // Define the output schema with transformed keys Schema.Record({ key: Schema.Trimmed, value: Schema.Number, }), { strict: true, // Trim keys during decoding decode: (record) => Record.mapKeys(record, (key) => key.trim()), encode: identity, }, ) console.log(Schema.decodeUnknownSync(schema)({ " key1 ": "1", key2: "2" })) // Output: { key1: 1, key2: 2 } ``` ### 可变 Record 默认情况下,`Schema.Record` 生成的类型被标记为 `readonly`。 要创建可变 record 的 schema,可以使用 `Schema.mutable` 函数,它以**浅层**(shallow)方式让 record 类型可变。 **示例**(创建可变 Record 的 Schema) ```ts import { Schema } from "effect" // Create a schema for a mutable record with string keys and number values const schema = Schema.mutable( Schema.Record({ key: Schema.String, value: Schema.Number }), ) // ┌─── { [x: string]: number; } // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `key` 和 `value` 属性访问 record schema 的 `key` 和 `value` 类型: **示例**(访问键与值的类型) ```ts import { Schema } from "effect" const schema = Schema.Record({ key: Schema.String, value: Schema.Number }) // Accesses the key // // ┌─── typeof Schema.String // ▼ const key = schema.key // Accesses the value // // ┌─── typeof Schema.Number // ▼ const value = schema.value ``` ## Struct ### 属性签名 `Schema.Struct` 构造器为具有特定属性的对象定义 schema。 **示例**(定义 Struct Schema) 这个示例为一个对象定义了 struct schema,该对象具有以下属性: - `name`:字符串 - `age`:数字 ```ts import { Schema } from "effect" // ┌─── Schema.Struct<{ // │ name: typeof Schema.String; // │ age: typeof Schema.Number; // │ }> // ▼ const schema = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // The inferred TypeScript type from the schema // // ┌─── { // │ readonly name: string; // │ readonly age: number; // │ } // ▼ type Type = typeof schema.Type ``` ### 索引签名 `Schema.Struct` 构造器还可以可选地接受一组表示索引签名的键/值对,允许你定义额外的动态属性。 ```ts declare const Struct: (props, ...indexSignatures) => Struct<...> ``` **示例**(添加索引签名) ```ts import { Schema } from "effect" // Define a struct with a specific property "a" // and an index signature allowing additional properties const schema = Schema.Struct( // Defined properties { a: Schema.Number }, // Index signature: allows additional string keys with number values { key: Schema.String, value: Schema.Number }, ) // The inferred TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly a: number; // │ } // ▼ type Type = typeof schema.Type ``` **示例**(使用 `Schema.Record`) 你也可以用 `Schema.Record` 达到同样的效果: ```ts import { Schema } from "effect" // Define a struct with a fixed property "a" // and a dynamic index signature using Schema.Record const schema = Schema.Struct( { a: Schema.Number }, Schema.Record({ key: Schema.String, value: Schema.Number }), ) // The inferred TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly a: number; // │ } // ▼ type Type = typeof schema.Type ``` ### 多个索引签名 每种键类型(`string` 或 `symbol`)只能定义**一个**索引签名。不允许定义多个同类型的索引签名。 **示例**(合法的多个索引签名) ```ts import { Schema } from "effect" // Define a struct with a fixed property "a" // and valid index signatures for both strings and symbols const schema = Schema.Struct( { a: Schema.Number }, // String index signature { key: Schema.String, value: Schema.Number }, // Symbol index signature { key: Schema.SymbolFromSelf, value: Schema.Number }, ) // The inferred TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly [x: symbol]: number; // │ readonly a: number; // │ } // ▼ type Type = typeof schema.Type ``` 定义多个同一种键类型(`string` 或 `symbol`)的索引签名会导致错误。 **示例**(非法的多个索引签名) ```ts import { Schema } from "effect" Schema.Struct( { a: Schema.Number }, // Attempting to define multiple string index signatures { key: Schema.String, value: Schema.Number }, { key: Schema.String, value: Schema.Boolean }, ) /* throws: Error: Duplicate index signature details: string index signature */ ``` ### 冲突的索引签名 在使用索引签名定义 schema 时,如果某个固定属性的类型与索引签名所允许的值类型不同,就会产生冲突。 这可能导致 TypeScript 出现意料之外的行为。 **示例**(冲突的索引签名) ```ts import { Schema } from "effect" // Attempting to define a struct with a conflicting index signature // - The fixed property "a" is a string // - The index signature requires all values to be numbers const schema = Schema.Struct( { a: Schema.String }, { key: Schema.String, value: Schema.Number }, ) // ❌ Incorrect TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly a: string; // │ } // ▼ type Type = typeof schema.Type ``` 当你手动定义该类型时,TypeScript 编译器会把它标记为一个错误: ```ts // @errors: 2411 // This type is invalid because the index signature // conflicts with the fixed property `a` type Test = { readonly a: string readonly [x: string]: number } ``` 这是因为 TypeScript 不允许索引签名与固定属性相矛盾。 #### 冲突索引签名的变通方案 在使用 schema 时,如果某个固定属性的类型与索引签名所允许的值类型不同,就可能发生冲突。这种情况常常出现在与不遵循严格 TypeScript 约定的外部 API 打交道时。 为避免冲突,你可以把固定属性与索引属性分开,把它们当作 schema 中两个独立的部分来处理。 **示例**(提取固定属性与索引属性) 考虑这样一个对象: - `"a"` 是类型为 `string` 的固定属性。 - 所有其他键都存储数字,这与 `"a"` 冲突。 ```ts // @errors: 2411 // This type is invalid because the index signature // conflicts with the fixed property `a` type Test = { a: string [x: string]: number } ``` 为避免这个问题,我们可以把这些属性拆分成两个不同的类型: ```ts // Fixed properties schema type FixedProperties = { readonly a: string } // Index signature properties schema type IndexSignatureProperties = { readonly [x: string]: number } // The final output groups both properties in a tuple type OutputData = readonly [FixedProperties, IndexSignatureProperties] ``` 通过使用 [Schema.transform](/docs/v3/schema/transformations/#transform) 和 [Schema.compose](/docs/v3/schema/transformations/#composition),你可以在校验之前预处理输入数据。这种方式能确保固定属性与索引签名属性被独立处理。 ```ts import { Schema } from "effect" // Define a schema for the fixed property "a" const FixedProperties = Schema.Struct({ a: Schema.String, }) // Define a schema for index signature properties const IndexSignatureProperties = Schema.Record({ // Exclude keys that are already present in FixedProperties key: Schema.String.pipe( Schema.filter((key) => !Object.keys(FixedProperties.fields).includes(key)), ), value: Schema.Number, }) // Create a schema that duplicates an object into two parts const Duplicate = Schema.transform( Schema.Object, Schema.Tuple(Schema.Object, Schema.Object), { strict: true, // Create a tuple containing the input twice decode: (a) => [a, a] as const, // Merge both parts back when encoding encode: ([a, b]) => ({ ...a, ...b }), }, ) // ┌─── Schema // ▼ const Result = Schema.compose( Duplicate, Schema.Tuple(FixedProperties, IndexSignatureProperties).annotations({ parseOptions: { onExcessProperty: "ignore" }, }), ) // Decoding: Separates fixed and indexed properties console.log(Schema.decodeUnknownSync(Result)({ a: "a", b: 1, c: 2 })) // Output: [ { a: 'a' }, { b: 1, c: 2 } ] // Encoding: Combines them back into an object console.log(Schema.encodeSync(Result)([{ a: "a" }, { b: 1, c: 2 }])) // Output: { a: 'a', b: 1, c: 2 } ``` ### 暴露的值 你可以使用 `fields` 和 `records` 属性访问 struct schema 的字段与 record: **示例**(访问字段与 record) ```ts import { Schema } from "effect" const schema = Schema.Struct( { a: Schema.Number }, Schema.Record({ key: Schema.String, value: Schema.Number }), ) // Accesses the fields // // ┌─── { readonly a: typeof Schema.Number; } // ▼ const fields = schema.fields // Accesses the records // // ┌─── readonly [Schema.Record$] // ▼ const records = schema.records ``` ### 可变 Struct 默认情况下,`Schema.Struct` 生成的类型中,属性被标记为 `readonly`。 要为 struct 创建可变版本,可以使用 `Schema.mutable` 函数,它以**浅层**方式让属性变为可变。 **示例**(创建可变 Struct Schema) ```ts import { Schema } from "effect" const schema = Schema.mutable( Schema.Struct({ a: Schema.String, b: Schema.Number }), ) // ┌─── { a: string; b: number; } // ▼ type Type = typeof schema.Type ``` ## 带标签的结构体 在 TypeScript 中,标签有助于增强类型判别与模式匹配,它提供了一种简单而强大的方式来定义和识别不同的数据类型。 ### 什么是标签? 标签是添加到数据结构上的一个字面量值,常用于 struct 中,用来区分带标签联合里的各种对象类型或变体。这个字面量充当判别属性,让人能更轻松、更高效地正确处理不同类型的数据。 ### 使用 tag 构造器 `Schema.tag` 构造器专门用于创建一个持有特定字面量值的属性签名,作为对象类型的判别属性。 **示例**(定义带标签的结构体) ```ts import { Schema } from "effect" const User = Schema.Struct({ _tag: Schema.tag("User"), name: Schema.String, age: Schema.Number, }) // ┌─── { readonly _tag: "User"; readonly name: string; readonly age: number; } // ▼ type Type = typeof User.Type console.log(User.make({ name: "John", age: 44 })) /* Output: { _tag: 'User', name: 'John', age: 44 } */ ``` 在上面的例子中,`Schema.tag("User")` 为 `User` struct schema 附加了一个 `_tag` 属性,从而把该 struct 类型的对象标记为 "User"。 当使用 `make` 方法创建新实例时,这个标签会被自动应用,从而简化对象创建并保证标签一致。 ### 用 TaggedStruct 简化带标签的结构体 `Schema.TaggedStruct` 构造器把标签直接集成到 struct 定义中,从而简化了创建带标签 struct 的过程。这种方式为构建带内嵌判别属性的数据结构提供了更清晰、更具声明性的写法。 **示例**(使用 `TaggedStruct` 简化带标签的结构体) ```ts import { Schema } from "effect" const User = Schema.TaggedStruct("User", { name: Schema.String, age: Schema.Number, }) // `_tag` is automatically applied when constructing an instance console.log(User.make({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } // `_tag` is required when decoding from an unknown source console.log(Schema.decodeUnknownSync(User)({ name: "John", age: 44 })) /* throws: ParseError: { readonly _tag: "User"; readonly name: string; readonly age: number } └─ ["_tag"] └─ is missing */ ``` 在这个例子中: - 使用 `make` 构造实例时,`_tag` 属性是可选的,因为 schema 会自动应用它。 - 在解码未知数据时,`_tag` 是必需的,以确保正确的类型识别。这种在实例构造与解码之间的区别很有用:它既保留了标签作为类型判别属性的作用,又简化了实例创建。 如果你希望 `_tag` 在解码期间也能自动应用,可以创建 `Schema.TaggedStruct` 的定制版本: **示例**(定制 `TaggedStruct`,在解码时应用 `_tag`) ```ts import type { SchemaAST } from "effect" import { Schema } from "effect" const TaggedStruct = < Tag extends SchemaAST.LiteralValue, Fields extends Schema.Struct.Fields, >( tag: Tag, fields: Fields, ) => Schema.Struct({ _tag: Schema.Literal(tag).pipe( Schema.optional, Schema.withDefaults({ constructor: () => tag, // Apply _tag during instance construction decoding: () => tag, // Apply _tag during decoding }), ), ...fields, }) const User = TaggedStruct("User", { name: Schema.String, age: Schema.Number, }) console.log(User.make({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } console.log(Schema.decodeUnknownSync(User)({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } ``` ### 多个标签 虽然通常一个主标签就足够了,但 TypeScript 允许你定义多个标签,以满足更复杂的数据结构需求。下面是一个在单个 struct 中使用多个标签的示例: **示例**(为一个 struct 添加多个标签) 这个示例定义了一个产品 schema,它带有一个主标签(`"Product"`)以及一个额外的分类标签(`"Electronics"`),为数据结构增添了更多特异性。 ```ts import { Schema } from "effect" const Product = Schema.TaggedStruct("Product", { category: Schema.tag("Electronics"), name: Schema.String, price: Schema.Number, }) // `_tag` and `category` are optional when creating an instance console.log(Product.make({ name: "Smartphone", price: 999 })) /* Output: { _tag: 'Product', category: 'Electronics', name: 'Smartphone', price: 999 } */ ``` ## instanceOf 当你需要为通过 `class` 定义的自定义数据类型定义 schema 时,最方便快捷的方式就是使用 `Schema.instanceOf` 构造器。 **示例**(使用 `instanceOf` 定义 schema) ```ts import { Schema } from "effect" // Define a custom class class MyData { constructor(readonly name: string) {} } // Create a schema for the class const MyDataSchema = Schema.instanceOf(MyData) // ┌─── MyData // ▼ type Type = typeof MyDataSchema.Type console.log(Schema.decodeUnknownSync(MyDataSchema)(new MyData("name"))) // Output: MyData { name: 'name' } console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" })) /* throws: ParseError: Expected MyData, actual {"name":"name"} */ ``` `Schema.instanceOf` 构造器只是 [Schema.declare](/docs/v3/schema/advanced-usage/#declaring-new-data-types) API 的一个轻量封装,而后者是 `effect/Schema` 中用于声明新自定义数据类型的原语。 ### 私有构造器 注意,`Schema.instanceOf` 只能用于暴露了**公开构造器**的类。 如果你尝试把它用于出于某种原因把构造器标记为 `private` 的类,就会收到一个 TypeScript 错误: **示例**(私有构造器导致的错误) ```ts import { Schema } from "effect" class MyData { static make = (name: string) => new MyData(name) private constructor(readonly name: string) {} } // @errors: 2345 const MyDataSchema = Schema.instanceOf(MyData) ``` 在这种情况下,你不能使用 `Schema.instanceOf`,而必须像这样依赖 [Schema.declare](/docs/v3/schema/advanced-usage/#declaring-new-data-types): **示例**(对私有构造器使用 `Schema.declare`) ```ts import { Schema } from "effect" class MyData { static make = (name: string) => new MyData(name) private constructor(readonly name: string) {} } const MyDataSchema = Schema.declare( (input: unknown): input is MyData => input instanceof MyData, ).annotations({ identifier: "MyData" }) console.log(Schema.decodeUnknownSync(MyDataSchema)(MyData.make("name"))) // Output: MyData { name: 'name' } console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" })) /* throws: ParseError: Expected MyData, actual {"name":"name"} */ ``` ### 校验实例的字段 要校验类实例的字段,你可以使用[过滤器(filter)](/docs/v3/schema/filters/)。这种方式把实例校验与对实例字段的额外检查结合起来。 **示例**(为实例 schema 添加字段校验) ```ts import { Either, ParseResult, Schema } from "effect" class MyData { constructor(readonly name: string) {} } const MyDataFields = Schema.Struct({ name: Schema.NonEmptyString, }) // Define a schema for the class instance with additional field validation const MyDataSchema = Schema.instanceOf(MyData).pipe( Schema.filter((a, options) => // Validate the fields of the instance ParseResult.validateEither(MyDataFields)(a, options).pipe( // Invert success and failure for filtering Either.flip, // Return undefined if validation succeeds, or an error if it fails Either.getOrUndefined, ), ), ) // Example: Valid instance console.log(Schema.validateSync(MyDataSchema)(new MyData("John"))) // Output: MyData { name: 'John' } // Example: Invalid instance (empty name) console.log(Schema.validateSync(MyDataSchema)(new MyData(""))) /* throws: ParseError: { MyData | filter } └─ Predicate refinement failure └─ { readonly name: NonEmptyString } └─ ["name"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected a non empty string, actual "" */ ``` ## 挑选 每个 struct schema 上都可用的 `pick` 静态函数,可以通过从已有 `Struct` 中选取特定属性来创建一个新的 `Struct`。 **示例**(从 struct 中挑选属性) ```ts import { Schema } from "effect" // Define a struct schema with properties "a", "b", and "c" const MyStruct = Schema.Struct({ a: Schema.String, b: Schema.Number, c: Schema.Boolean, }) // Create a new schema that picks properties "a" and "c" // // ┌─── Struct<{ // | a: typeof Schema.String; // | c: typeof Schema.Boolean; // | }> // ▼ const PickedSchema = MyStruct.pick("a", "c") ``` `Schema.pick` 函数的适用范围不只局限于 `Struct` 类型,例如也可以用于 schema 的联合。 不过它返回的是一个通用的 `SchemaClass`。 **示例**(从联合中挑选属性) ```ts import { Schema } from "effect" // Define a union of two struct schemas const MyUnion = Schema.Union( Schema.Struct({ a: Schema.String, b: Schema.String, c: Schema.String }), Schema.Struct({ a: Schema.Number, b: Schema.Number, d: Schema.Number }), ) // Create a new schema that picks properties "a" and "b" // // ┌─── SchemaClass<{ // | readonly a: string | number; // | readonly b: string | number; // | }> // ▼ const PickedSchema = MyUnion.pipe(Schema.pick("a", "b")) ``` ## 省略 每个 struct schema 上都提供了 `omit` 静态函数,可用于从已有 `Struct` 中排除特定属性,从而创建新的 `Struct`。 **示例**(从 struct 中省略属性) ```ts import { Schema } from "effect" // Define a struct schema with properties "a", "b", and "c" const MyStruct = Schema.Struct({ a: Schema.String, b: Schema.Number, c: Schema.Boolean, }) // Create a new schema that omits property "b" // // ┌─── Schema.Struct<{ // | a: typeof Schema.String; // | c: typeof Schema.Boolean; // | }> // ▼ const PickedSchema = MyStruct.omit("b") ``` `Schema.omit` 函数的适用范围并不局限于 `Struct` 类型,还可以用于 schema 的 union 等场景。 不过它返回的是泛化的 `Schema`。 **示例**(从 union 中省略属性) ```ts import { Schema } from "effect" // Define a union of two struct schemas const MyUnion = Schema.Union( Schema.Struct({ a: Schema.String, b: Schema.String, c: Schema.String }), Schema.Struct({ a: Schema.Number, b: Schema.Number, d: Schema.Number }), ) // Create a new schema that omits property "b" // // ┌─── SchemaClass<{ // | readonly a: string | number; // | }> // ▼ const PickedSchema = MyUnion.pipe(Schema.omit("b")) ``` ## partial `Schema.partial` 函数会让 schema 中的所有属性都变为可选。 **示例**(让所有属性都可选) ```ts import { Schema } from "effect" // Create a schema with an optional property "a" const schema = Schema.partial(Schema.Struct({ a: Schema.String })) // ┌─── { readonly a?: string | undefined; } // ▼ type Type = typeof schema.Type ``` 默认情况下,`Schema.partial` 操作会为每个属性的类型加上 `undefined`。如果不想如此,可以使用 `Schema.partialWith`,并把 `{ exact: true }` 作为参数传入。 **示例**(定义精确的 partial schema) ```ts import { Schema } from "effect" // Create a schema with an optional property "a" without allowing undefined const schema = Schema.partialWith( Schema.Struct({ a: Schema.String, }), { exact: true }, ) // ┌─── { readonly a?: string; } // ▼ type Type = typeof schema.Type ``` ## required `Schema.required` 函数会确保 schema 中的所有属性都是必需的。 **示例**(让所有属性都必需) ```ts import { Schema } from "effect" // Create a schema and make all properties required const schema = Schema.required( Schema.Struct({ a: Schema.optionalWith(Schema.String, { exact: true }), b: Schema.optionalWith(Schema.Number, { exact: true }), }), ) // ┌─── { readonly a: string; readonly b: number; } // ▼ type Type = typeof schema.Type ``` 在这个示例中,尽管 `a` 和 `b` 最初都被定义为可选,但它们最终都被设为必需。 ## keyof `Schema.keyof` 操作会创建一个 schema,用来表示给定对象 schema 的键。 **示例**(从对象 schema 中提取键) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.String, b: Schema.Number, }) const keys = Schema.keyof(schema) // ┌─── "a" | "b" // ▼ type Type = typeof keys.Type ``` --- # Class API > 学习使用类来定义和扩展 schema,涵盖校验、自定义逻辑,以及相等性检查与变换等高级特性。 在处理 schema 时,除了 [Schema.Struct](/docs/v3/schema/basic-usage/#structs) 构造函数之外,你还有另一种选择。 你可以通过 `Schema.Class` 工具来利用类的能力,它自带一套针对常见用例量身定制的优势: 类提供了若干能够简化 schema 创建过程的特性: - **一体化定义**:借助类,你可以同时定义一个 schema 和一个不透明类型(opaque type)。 - **共享功能**:你可以通过类的方法或 getter 加入共享功能。 - **值的哈希与相等性**:利用内置能力来检查值相等性并应用哈希(这得益于 `Class` 实现了 [Data.Class](/docs/v3/data-types/data/#class))。 ## 定义 要用 `Schema.Class` 定义一个类,你需要指定: - 所创建类的**类型**。 - 该类的唯一**标识符**。 - 你想要的**字段**。 **示例**(定义一个 Schema 类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} ``` 在这个示例中,`Person` 既是一个 schema,也是一个 TypeScript 类。`Person` 的实例通过所定义的 schema 创建,从而确保它们符合指定的字段。 **示例**(创建实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} console.log(new Person({ id: 1, name: "John" })) /* Output: Person { id: 1, name: 'John' } */ // Using the factory function console.log(Person.make({ id: 1, name: "John" })) /* Output: Person { id: 1, name: 'John' } */ ``` ### 类 Schema 是变换 类 schema 会把一个 struct schema [变换](/docs/v3/schema/transformations/) 成一个代表类类型的[声明(declaration)](/docs/v3/schema/advanced-usage/#declaring-new-data-types) schema。 - 解码时,普通对象会被转换成该类的一个实例。 - 编码时,类实例会被转换回普通对象。 **示例**(解码与编码一个类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} const person = Person.make({ id: 1, name: "John" }) // Decode from a plain object into a class instance const decoded = Schema.decodeUnknownSync(Person)({ id: 1, name: "John" }) console.log(decoded) // Output: Person { id: 1, name: 'John' } // Encode a class instance back into a plain object const encoded = Schema.encodeUnknownSync(Person)(person) console.log(encoded) // Output: { id: 1, name: 'John' } ``` ### 定义不含字段的类 当你的 schema 不需要任何字段时,可以定义一个使用空对象的类。 **示例**(定义并使用一个不带参数的类) ```ts import { Schema } from "effect" // Define a class with no fields class NoArgs extends Schema.Class("NoArgs")({}) {} // Create an instance using the default constructor const noargs1 = new NoArgs() // Alternatively, create an instance by explicitly passing an empty object const noargs2 = new NoArgs({}) ``` ### 定义带过滤器的类 过滤器让你能够在解码、编码或创建实例时校验输入。你可以传入一个应用了过滤器的 `Schema.Struct`,而不是直接指定原始字段。 **示例**(为 Schema 类应用过滤器) ```ts import { Schema } from "effect" class WithFilter extends Schema.Class("WithFilter")( Schema.Struct({ a: Schema.NumberFromString, b: Schema.NumberFromString, }).pipe(Schema.filter(({ a, b }) => a >= b || "a must be greater than b")), ) {} // Constructor console.log(new WithFilter({ a: 1, b: 2 })) /* throws: ParseError: WithFilter (Constructor) └─ Predicate refinement failure └─ a must be greater than b */ // Decoding console.log(Schema.decodeUnknownSync(WithFilter)({ a: "1", b: "2" })) /* throws: ParseError: (WithFilter (Encoded side) <-> WithFilter) └─ Encoded side transformation failure └─ WithFilter (Encoded side) └─ Predicate refinement failure └─ a must be greater than b */ ``` ## 通过类构造函数验证属性 当你使用 `Schema.Class` 定义一个类时,构造函数会自动检查所提供的属性是否符合 schema 的规则。 ### 定义并实例化一个有效的类实例 构造函数确保每个属性(例如 `id` 和 `name`)都符合 schema。例如,`id` 必须是数字,`name` 必须是非空字符串。 **示例**(创建一个有效实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} // Create an instance with valid properties const john = new Person({ id: 1, name: "John" }) ``` ### 处理无效属性 如果在实例化时提供了无效的属性,构造函数会抛出错误,并说明验证失败的原因。 **示例**(用无效属性创建一个实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} // Attempt to create an instance with an invalid `name` new Person({ id: 1, name: "" }) /* throws: ParseError: Person (Constructor) └─ ["name"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected NonEmptyString, actual "" */ ``` 该错误清晰地指出,`name` 字段未能满足 `NonEmptyString` 的要求。 ### 绕过验证 在某些场景下,你可能希望绕过验证逻辑。虽然通常不建议这样做,但库提供了一个选项来实现它。 **示例**(绕过验证) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} // Bypass validation during instantiation const john = new Person({ id: 1, name: "" }, true) // Or use the `disableValidation` option explicitly new Person({ id: 1, name: "" }, { disableValidation: true }) ``` ## 类中的自动哈希与相等性 使用 `Schema.Class` 创建的类的实例通过集成 [Data.Class](/docs/v3/data-types/data/#class) 来支持 [Equal](/docs/v3/trait/equal/) trait。这让按值比较变得简单直接,即使是在不同的实例之间。 ### 基本的相等性检查 如果两个类实例的属性值完全相同,它们就被认为是相等的。 **示例**(比较属性相等的实例) ```ts import { Schema } from "effect" import { Equal } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} const john1 = new Person({ id: 1, name: "John" }) const john2 = new Person({ id: 1, name: "John" }) // Compare instances console.log(Equal.equals(john1, john2)) // Output: true ``` ### 嵌套或复杂属性 `Equal` trait 只在第一层进行比较。如果某个属性是更复杂的结构(例如数组),那么即使这些数组本身具有完全相同的值,实例也可能不会被认为是相等的。 **示例**(数组的浅层相等性) ```ts import { Schema } from "effect" import { Equal } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, hobbies: Schema.Array(Schema.String), // Standard array schema }) {} const john1 = new Person({ id: 1, name: "John", hobbies: ["reading", "coding"], }) const john2 = new Person({ id: 1, name: "John", hobbies: ["reading", "coding"], }) // Equality fails because `hobbies` are not deeply compared console.log(Equal.equals(john1, john2)) // Output: false ``` 要让数组这类嵌套结构实现深层相等性,可以结合 `Data.array` 使用 `Schema.Data`。这样库就会比较数组的每个元素,而不是把整个数组当作一个整体。 **示例**(使用 `Schema.Data` 实现深层相等性) ```ts import { Schema } from "effect" import { Data, Equal } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, hobbies: Schema.Data(Schema.Array(Schema.String)), // Enable deep equality }) {} const john1 = new Person({ id: 1, name: "John", hobbies: Data.array(["reading", "coding"]), }) const john2 = new Person({ id: 1, name: "John", hobbies: Data.array(["reading", "coding"]), }) // Equality succeeds because `hobbies` are deeply compared console.log(Equal.equals(john1, john2)) // Output: true ``` ## 用自定义逻辑扩展类 Schema 类提供了灵活性,允许你加入自定义的 getter 和方法,从而把功能扩展到已定义的字段之外。 ### 添加自定义 getter getter 可以用来从类的字段中派生出计算值。例如,`Person` 类可以包含一个 getter,用于返回大写形式的 `name` 属性。 **示例**(添加一个返回大写名字的 getter) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) { // Custom getter to return the name in uppercase get upperName() { return this.name.toUpperCase() } } const john = new Person({ id: 1, name: "John" }) // Use the custom getter console.log(john.upperName) // Output: "JOHN" ``` ### 添加自定义方法 除了 getter,你还可以定义方法来封装更复杂的逻辑或涉及类字段的操作。 **示例**(添加一个方法) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) { // Custom method to return a greeting greet() { return `Hello, my name is ${this.name}.` } } const john = new Person({ id: 1, name: "John" }) // Use the custom method console.log(john.greet()) // Output: "Hello, my name is John." ``` ## 将类用作 Schema 定义 当你用 `Schema.Class` 定义一个类时,它既充当 schema,也充当类。这种双重功能让该类可以在任何需要 schema 的地方使用。 **示例**(在数组 schema 中使用一个类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} // Use the Person class in an array schema const Persons = Schema.Array(Person) // ┌─── readonly Person[] // ▼ type Type = typeof Persons.Type ``` ### 暴露的值 该类还包含一个 `fields` 静态属性,它列出了在创建类时所定义的字段。 **示例**(访问 `fields` 属性) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} // ┌─── { // | readonly id: typeof Schema.Number; // | readonly name: typeof Schema.NonEmptyString; // | } // ▼ Person.fields ``` ## 添加注解 用 `Schema.Class` 定义一个类,类似于创建一个[变换](/docs/v3/schema/transformations/) schema,它会把一个 struct schema 转换成一个代表该类类型的[声明(declaration)](/docs/v3/schema/advanced-usage/#declaring-new-data-types) schema。 例如,考虑下面这个类定义: ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} ``` 在底层,这个定义会创建一个变换 schema,它把: ```ts Schema.Struct({ id: Schema.Number, name: Schema.NonEmptyString, }) ``` 映射为代表 `Person` 类的 schema: ```ts Schema.declare((input) => input instanceof Person) ``` 因此,用 `Schema.Class` 定义一个 schema 会涉及三个 schema: - "from" schema(即 struct) - "to" schema(即类) - "transformation" schema(struct -> class) 你可以通过向 `Schema.Class` API 传入一个元组作为第二个参数,来分别为这三个 schema 添加注解。 **示例**(为类 schema 的不同部分添加注解) ```ts import { Schema, SchemaAST } from "effect" class Person extends Schema.Class("Person")( { id: Schema.Number, name: Schema.NonEmptyString, }, [ // Annotations for the "to" schema { description: `"to" description` }, // Annotations for the "transformation schema { description: `"transformation" description` }, // Annotations for the "from" schema { description: `"from" description` }, ], ) {} console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to)) // Output: { _id: 'Option', _tag: 'Some', value: '"to" description' } console.log(SchemaAST.getDescriptionAnnotation(Person.ast)) // Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' } console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from)) // Output: { _id: 'Option', _tag: 'Some', value: '"from" description' } ``` 如果你不想为全部三个 schema 都添加注解,可以对想要跳过的那些传入 `undefined`。 **示例**(跳过部分注解) ```ts import { Schema, SchemaAST } from "effect" class Person extends Schema.Class("Person")( { id: Schema.Number, name: Schema.NonEmptyString, }, [ // No annotations for the "to" schema undefined, // Annotations for the "transformation schema { description: `"transformation" description` }, ], ) {} console.log(SchemaAST.getDescriptionAnnotation(Person.ast.to)) // Output: { _id: 'Option', _tag: 'None' } console.log(SchemaAST.getDescriptionAnnotation(Person.ast)) // Output: { _id: 'Option', _tag: 'Some', value: '"transformation" description' } console.log(SchemaAST.getDescriptionAnnotation(Person.ast.from)) // Output: { _id: 'Option', _tag: 'None' } ``` 默认情况下,用于定义该类的唯一标识符也会被应用为 Class Schema 的默认 `identifier` 注解。 **示例**(默认的标识符注解) ```ts import { Schema, SchemaAST } from "effect" // Used as default identifier annotation ────┐ // | // ▼ class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) {} console.log(SchemaAST.getIdentifierAnnotation(Person.ast.to)) // Output: { _id: 'Option', _tag: 'Some', value: 'Person' } ``` ## 递归 schema 当你需要定义一个依赖自身的 schema 时,比如在处理递归数据结构时,`Schema.suspend` 组合子就会很有用。 在这个例子中,`Category` schema 依赖自身,因为它有一个 `subcategories` 字段,该字段是 `Category` 对象的数组。 **示例**(自引用 schema) ```ts import { Schema } from "effect" // Define a Category schema with a recursive subcategories field class Category extends Schema.Class("Category")({ name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Schema => Category), ), }) {} ``` **示例**(缺少类型注解的报错) ```ts import { Schema } from "effect" // @errors: 2506 7024 class Category extends Schema.Class("Category")({ name: Schema.String, subcategories: Schema.Array(Schema.suspend(() => Category)), }) {} ``` ### 互递归 schema 有时,schema 之间会以互递归的方式相互依赖。例如,一个算术表达式树可能包含 `Expression` 节点,这些节点要么是数字,要么是 `Operation` 节点,而 `Operation` 节点又会反过来引用 `Expression` 节点。 **示例**(算术表达式树) ```ts import { Schema } from "effect" class Expression extends Schema.Class("Expression")({ type: Schema.Literal("expression"), value: Schema.Union( Schema.Number, Schema.suspend((): Schema.Schema => Operation), ), }) {} class Operation extends Schema.Class("Operation")({ type: Schema.Literal("operation"), operator: Schema.Literal("+", "-"), left: Expression, right: Expression, }) {} ``` ### Encoded 与 Type 不同的递归类型 定义 `Encoded` 类型与 `Type` 类型不一致的递归 schema 会引入额外的复杂性。例如,如果 schema 中包含会转换数据的字段(比如 `NumberFromString`),`Encoded` 与 `Type` 类型就可能对不上。 在这种情况下,我们需要为 `Encoded` 类型定义一个接口。 我们来看一个例子:假设我们想给 `Category` schema 添加一个 `id` 字段,其中 `id` 的 schema 是 `NumberFromString`。 需要注意,`NumberFromString` 是一个把字符串转换成数字的 schema,因此 `NumberFromString` 的 `Type` 和 `Encoded` 类型并不相同,分别是 `number` 和 `string`。 当我们把这个字段添加到 `Category` schema 时,TypeScript 会报错: ```ts import { Schema } from "effect" class Category extends Schema.Class("Category")({ id: Schema.NumberFromString, name: Schema.String, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Schema => Category), ), }) {} ``` 这个错误之所以出现,是因为显式注解 `S.suspend((): S.Schema => Category` 已经不够用了,需要通过显式添加 `Encoded` 类型来调整: **示例**(用显式的 `Encoded` 类型调整 schema) ```ts import { Schema } from "effect" interface CategoryEncoded { readonly id: string readonly name: string readonly subcategories: ReadonlyArray } class Category extends Schema.Class("Category")({ id: Schema.NumberFromString, name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Schema => Category), ), }) {} ``` 正如我们所见,为了让递归 schema 的定义成为可能,必须为 schema 的 `Encoded` 定义一个接口,这会让事情变得复杂,而且相当繁琐。 缓解这一问题的一种模式是,把**负责递归的字段**与其它所有字段分离开来。 **示例**(分离递归字段) ```ts import { Schema } from "effect" const fields = { id: Schema.NumberFromString, name: Schema.String, // ...possibly other fields } interface CategoryEncoded extends Schema.Struct.Encoded { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray } class Category extends Schema.Class("Category")({ ...fields, // Include the fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Schema => Category), ), }) {} ``` ## Tagged Class 变体 你也可以创建继承自 `effect/Data` 模块中 [TaggedClass](/docs/v3/data-types/data/#taggedclass) 和 [TaggedError](/docs/v3/data-types/data/#taggederror) 的类。 **示例**(创建 Tagged Class 与 Tagged Error) ```ts import { Schema } from "effect" // Define a tagged class with a "name" field class TaggedPerson extends Schema.TaggedClass()("TaggedPerson", { name: Schema.String, }) {} // Define a tagged error with a "status" field class HttpError extends Schema.TaggedError()("HttpError", { status: Schema.Number, }) {} const joe = new TaggedPerson({ name: "Joe" }) console.log(joe._tag) // Output: "TaggedPerson" const error = new HttpError({ status: 404 }) console.log(error._tag) // Output: "HttpError" console.log(error.stack) // access the stack trace ``` ## 扩展现有类 `extend` 静态工具允许你通过添加**额外的**字段和功能来增强已有的 schema 类。这种方式有助于在现有 schema 的基础上继续构建,而不必从头重新定义它们。 **示例**(扩展一个 schema 类) ```ts import { Schema } from "effect" // Define the base class class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) { // A custom getter that converts the name to uppercase get upperName() { return this.name.toUpperCase() } } // Extend the base class to include an "age" field class PersonWithAge extends Person.extend("PersonWithAge")({ age: Schema.Number, }) { // A custom getter to check if the person is an adult get isAdult() { return this.age >= 18 } } // Usage const john = new PersonWithAge({ id: 1, name: "John", age: 25 }) console.log(john.upperName) // Output: "JOHN" console.log(john.isAdult) // Output: true ``` 注意,扩展类时只能添加额外的字段。 **示例**(尝试覆盖已有字段) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.NonEmptyString, }) { get upperName() { return this.name.toUpperCase() } } class BadExtension extends Person.extend("BadExtension")({ name: Schema.Number, }) {} /* throws: Error: Duplicate property signature details: Duplicate key "name" */ ``` 这个错误之所以出现,是因为允许字段被覆盖并不安全。它可能会干扰类上任何依赖原始定义的 getter 或方法。例如在这个例子中,如果 `name` 字段被改成数字,`upperName` getter 就会失效。 ## 变换 你可以为 schema 类添加带 effect 的变换,从而丰富或校验实体,尤其是在处理来自数据库或 API 等外部系统的数据时。 **示例**(带 effect 的变换) 下面的示例演示了如何给 `Person` 类添加一个 `age` 字段。`age` 的值会根据 `id` 字段异步推导出来。 ```ts import { Effect, Option, Schema, ParseResult } from "effect" // Base class definition class Person extends Schema.Class("Person")({ id: Schema.Number, name: Schema.String, }) {} console.log(Schema.decodeUnknownSync(Person)({ id: 1, name: "name" })) /* Output: Person { id: 1, name: 'name' } */ // Simulate fetching age asynchronously based on id function getAge(id: number): Effect.Effect { return Effect.succeed(id + 2) } // Extended class with a transformation class PersonWithTransform extends Person.transformOrFail( "PersonWithTransform", )( { age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }), }, { // Decoding logic for the new field decode: (input) => Effect.mapBoth(getAge(input.id), { onFailure: (e) => new ParseResult.Type(Schema.String.ast, input.id, e.message), // Must return { age: Option } onSuccess: (age) => ({ ...input, age: Option.some(age) }), }), encode: ParseResult.succeed, }, ) {} Schema.decodeUnknownPromise(PersonWithTransform)({ id: 1, name: "name", }).then(console.log) /* Output: PersonWithTransform { id: 1, name: 'name', age: { _id: 'Option', _tag: 'Some', value: 3 } } */ // Extended class with a conditional Transformation class PersonWithTransformFrom extends Person.transformOrFailFrom( "PersonWithTransformFrom", )( { age: Schema.optionalWith(Schema.Number, { exact: true, as: "Option" }), }, { decode: (input) => Effect.mapBoth(getAge(input.id), { onFailure: (e) => new ParseResult.Type(Schema.String.ast, input, e.message), // Must return { age?: number } onSuccess: (age) => (age > 18 ? { ...input, age } : { ...input }), }), encode: ParseResult.succeed, }, ) {} Schema.decodeUnknownPromise(PersonWithTransformFrom)({ id: 1, name: "name", }).then(console.log) /* Output: PersonWithTransformFrom { id: 1, name: 'name', age: { _id: 'Option', _tag: 'None' } } */ ``` 究竟该使用哪个 API —— `transformOrFail` 还是 `transformOrFailFrom` —— 取决于你希望何时执行变换: 1. 使用 `transformOrFail`: - 变换发生在整个流程的末尾。 - 它期望你提供一个类型为 `{ age: Option }` 的值。 - 处理完初始输入后,新的变换才会生效,你需要确保最终输出符合指定的结构。 2. 使用 `transformOrFailFrom`: - 新的变换会在初始输入被处理时立即开始。 - 你应当提供一个 `{ age?: number }` 值。 - 基于这个新的输入,后续的变换 `Schema.optionalWith(Schema.Number, { exact: true, as: "Option" })` 会被执行。 - 这种方式允许立即处理输入,并可能影响后续的变换。 --- # 默认构造器 > 借助针对 Struct、Record、Filter 与品牌类型的默认构造器,轻松创建符合 schema 的值,并支持校验、默认值与惰性求值等选项。 在处理数据结构时,能够以最小的代价创建符合某个 schema 的值往往很有帮助。 为此,Schema 模块为多种 schema 类型提供了默认构造器,涵盖 `Structs`、`Records`、`filters` 与 `brands`。 默认构造器是**不安全的**,这意味着当输入不符合 schema 时,它们会**抛出错误**。 如果你需要一个更安全的替代方案,可以考虑使用 [Schema.validateEither](#error-handling-in-constructors),它返回一个表示成功或失败的结果,而不是抛出错误。 **示例**(使用 Refinement 的默认构造器) ```ts import { Schema } from "effect" const schema = Schema.NumberFromString.pipe(Schema.between(1, 10)) // The constructor only accepts numbers console.log(schema.make(5)) // Output: 5 // This will throw an error because the number is outside the valid range console.log(schema.make(20)) /* throws: ParseError: between(1, 10) └─ Predicate refinement failure └─ Expected a number between 1 and 10, actual 20 */ ``` ## Structs Struct schema 允许你定义具有特定字段与约束的对象。`make` 函数可用于创建某个 struct schema 的实例。 **示例**(创建 Struct 实例) ```ts import { Schema } from "effect" const Struct = Schema.Struct({ name: Schema.NonEmptyString, }) // Successful creation Struct.make({ name: "a" }) // This will throw an error because the name is empty Struct.make({ name: "" }) /* throws ParseError: { readonly name: NonEmptyString } └─ ["name"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected NonEmptyString, actual "" */ ``` 在某些情况下,你可能需要绕过校验。虽然在大多数场景下并不推荐,但 `make` 提供了一个禁用校验的选项。 **示例**(绕过校验) ```ts import { Schema } from "effect" const Struct = Schema.Struct({ name: Schema.NonEmptyString, }) // Bypass validation during instantiation Struct.make({ name: "" }, true) // Or use the `disableValidation` option explicitly Struct.make({ name: "" }, { disableValidation: true }) ``` ## Records Record schema 允许你定义键值映射,其中的键与值都必须满足特定条件。 **示例**(创建 Record 实例) ```ts import { Schema } from "effect" const Record = Schema.Record({ key: Schema.String, value: Schema.NonEmptyString, }) // Successful creation Record.make({ a: "a", b: "b" }) // This will throw an error because 'b' is empty Record.make({ a: "a", b: "" }) /* throws ParseError: { readonly [x: string]: NonEmptyString } └─ ["b"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected NonEmptyString, actual "" */ // Bypasses validation Record.make({ a: "a", b: "" }, { disableValidation: true }) ``` ## Filters Filter 允许你为单个值定义约束。 **示例**(使用 Filter 强制取值范围) ```ts import { Schema } from "effect" const MyNumber = Schema.Number.pipe(Schema.between(1, 10)) // Successful creation const n = MyNumber.make(5) // This will throw an error because the number is outside the valid range MyNumber.make(20) /* throws ParseError: a number between 1 and 10 └─ Predicate refinement failure └─ Expected a number between 1 and 10, actual 20 */ // Bypasses validation MyNumber.make(20, { disableValidation: true }) ``` ## Branded Types Branded schema 会为值添加元数据,从而赋予它更具体的类型,同时仍保留其原始类型。 **示例**(创建 Branded 值) ```ts import { Schema } from "effect" const BrandedNumberSchema = Schema.Number.pipe( Schema.between(1, 10), Schema.brand("MyNumber"), ) // Successful creation const n = BrandedNumberSchema.make(5) // This will throw an error because the number is outside the valid range BrandedNumberSchema.make(20) /* throws ParseError: a number between 1 and 10 & Brand<"MyNumber"> └─ Predicate refinement failure └─ Expected a number between 1 and 10 & Brand<"MyNumber">, actual 20 */ // Bypasses validation BrandedNumberSchema.make(20, { disableValidation: true }) ``` 在使用默认构造器时,理解它们产出的值的类型会很有帮助。 例如,在 `BrandedNumberSchema` 示例中,构造器的返回类型是 `number & Brand<"MyNumber">`。这表明得到的值是一个带有额外 branding 信息 `"MyNumber"` 的 `number`。 这种行为与 Filter 示例形成对比:后者的返回类型就是 `number`。Branding 会增加一层额外的类型信息,有助于更有效地识别和处理你的数据。 ## Error Handling in Constructors 默认构造器被认为"不安全",因为当输入不符合 schema 时它们会抛出错误。该错误包含对出错原因的详细描述。默认构造器的用意在于提供一种直接创建合法值的方式,例如用于测试或配置——在这些场景中,无效输入本就被视为异常情况。 如果你需要一个不抛出错误、而是返回表示成功或失败的结果的"安全"构造器,可以使用 `Schema.validateEither`。 **示例**(使用 `Schema.validateEither` 进行安全校验) ```ts import { Schema } from "effect" const schema = Schema.NumberFromString.pipe(Schema.between(1, 10)) // Create a safe constructor that validates an unknown input const safeMake = Schema.validateEither(schema) // Valid input returns a Right value console.log(safeMake(5)) /* Output: { _id: 'Either', _tag: 'Right', right: 5 } */ // Invalid input returns a Left value with detailed error information console.log(safeMake(20)) /* Output: { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: 'between(1, 10)\n' + '└─ Predicate refinement failure\n' + ' └─ Expected a number between 1 and 10, actual 20' } } */ // This will throw an error because it's unsafe schema.make(20) /* throws: ParseError: between(1, 10) └─ Predicate refinement failure └─ Expected a number between 1 and 10, actual 20 */ ``` ## Setting Default Values 在创建对象时,你可能希望为某些字段指定默认值,以简化对象的构造。`Schema.withConstructorDefault` 函数让你可以处理默认值,从而使这些字段在默认构造器中变为可选。 **示例**(包含必填字段的 Struct) 在这个示例中,创建新实例时所有字段都是必填的。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number, }) // Both name and age must be provided console.log(Person.make({ name: "John", age: 30 })) /* Output: { name: 'John', age: 30 } */ ``` **示例**(带默认值的 Struct) 这里,`age` 字段是可选的,因为它有默认值 `0`。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => 0), ), }) // The age field is optional and defaults to 0 console.log(Person.make({ name: "John" })) /* Output: { name: 'John', age: 0 } */ console.log(Person.make({ name: "John", age: 30 })) /* Output: { name: 'John', age: 30 } */ ``` ### Nested Structs and Shallow Defaults schema 中的默认值是浅层的,这意味着嵌套 struct 中定义的默认值不会自动传播到顶层的构造器。 **示例**(嵌套 Struct 中的浅层默认值) ```ts import { Schema } from "effect" const Config = Schema.Struct({ // Define a nested struct with a default value web: Schema.Struct({ application_url: Schema.String.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => "http://localhost"), ), application_port: Schema.Number, }), }) // This will cause a type error because `application_url` // is missing in the nested struct // @errors: 2741 Config.make({ web: { application_port: 3000 } }) ``` 之所以会出现这种行为,是因为 `Schema` 接口并不包含用于从嵌套 struct 中携带默认构造器类型的类型参数。 要绕过这一限制,可以把嵌套 struct 的构造器提取出来,并直接应用在它的字段上。这样就能确保嵌套的默认值得到遵守。 **示例**(使用嵌套 Struct 的构造器) ```ts import { Schema } from "effect" const Config = Schema.Struct({ web: Schema.Struct({ application_url: Schema.String.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => "http://localhost"), ), application_port: Schema.Number, }), }) // Extract the nested struct constructor const { web: Web } = Config.fields // Use the constructor for the nested struct console.log(Config.make({ web: Web.make({ application_port: 3000 }) })) /* Output: { web: { application_url: 'http://localhost', application_port: 3000 } } */ ``` ### Lazy Evaluation of Defaults 默认值是惰性求值的:每次调用构造器时,都会生成一个新的默认值实例: **示例**(默认值的惰性求值) 在这个示例中,`timestamp` 字段会为每个实例生成一个新值。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => 0), ), timestamp: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => new Date().getTime()), ), }) console.log(Person.make({ name: "name1" })) /* Example Output: { age: 0, timestamp: 1714232909221, name: 'name1' } */ console.log(Person.make({ name: "name2" })) /* Example Output: { age: 0, timestamp: 1714232909227, name: 'name2' } */ ``` ### Reusing Defaults Across Schemas 默认值还是"可移植的":如果你在另一个 schema 中复用同一个属性签名,该默认值会被一并带过去: **示例**(在另一个 Schema 中复用默认值) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => 0), ), timestamp: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => new Date().getTime()), ), }) const AnotherSchema = Schema.Struct({ foo: Schema.String, age: Person.fields.age, }) console.log(AnotherSchema.make({ foo: "bar" })) /* Output: { foo: 'bar', age: 0 } */ ``` ### Using Defaults in Classes 在使用 `Class` API 时也可以应用默认值,从而确保基于 Class 的 schema 之间保持一致。 **示例**(Class 中的默认值) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ name: Schema.NonEmptyString, age: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => 0), ), timestamp: Schema.Number.pipe( Schema.propertySignature, Schema.withConstructorDefault(() => new Date().getTime()), ), }) {} console.log(new Person({ name: "name1" })) /* Example Output: Person { age: 0, timestamp: 1714400867208, name: 'name1' } */ console.log(new Person({ name: "name2" })) /* Example Output: Person { age: 0, timestamp: 1714400867215, name: 'name2' } */ ``` --- # Effect 数据类型 > 使用 schema 转换并管理各类数据类型,以获得更好的 JSON 序列化支持,涵盖 Option、Either、Set、Map、Duration 以及敏感的 Redacted 数据。 ## 与 Data 互操作 Effect 生态中的 [Data](/docs/v3/data-types/data/) 模块会自动实现 [Equal](/docs/v3/trait/equal/) 与 [Hash](/docs/v3/trait/hash/) 两个 trait,从而简化值比较。这样一来就无需手动实现,相等性检查也变得直接明了。 **示例**(用 Data 比较 Struct) ```ts import { Data, Equal } from "effect" const person1 = Data.struct({ name: "Alice", age: 30 }) const person2 = Data.struct({ name: "Alice", age: 30 }) console.log(Equal.equals(person1, person2)) // Output: true ``` 默认情况下,`Schema.Struct` 这类 schema 并不会实现 `Equal` 与 `Hash` 这两个 trait。这意味着两个值完全相同的已解码对象不会被视为相等。 **示例**(不使用 `Equal` 与 `Hash` 时的默认行为) ```ts import { Schema } from "effect" import { Equal } from "effect" const schema = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decode(schema) const person1 = decode({ name: "Alice", age: 30 }) const person2 = decode({ name: "Alice", age: 30 }) console.log(Equal.equals(person1, person2)) // Output: false ``` `Schema.Data` 函数可以用来增强一个 schema,让它带上 `Equal` 与 `Hash` 这两个 trait。这样得到的对象就支持基于值的相等性。 **示例**(使用 `Schema.Data` 添加相等性) ```ts import { Schema } from "effect" import { Equal } from "effect" const schema = Schema.Data( Schema.Struct({ name: Schema.String, age: Schema.Number, }), ) const decode = Schema.decode(schema) const person1 = decode({ name: "Alice", age: 30 }) const person2 = decode({ name: "Alice", age: 30 }) console.log(Equal.equals(person1, person2)) // Output: true ``` ## Config `Schema.Config` 函数允许你使用结构化的 schema 来解码并管理应用的配置项。 它保证配置数据的一致性,并为解码错误提供详细的反馈。 **语法** ```ts Config: (name: string, schema: Schema) => Config ``` 该函数接收两个参数: - `name`:配置项的标识符。 - `schema`:描述期望的数据类型与结构的 schema。 它返回一个 [Config](/docs/v3/configuration/) 对象,可与你的应用配置系统集成。 Encoded 类型 `I` 必须扩展自 `string`,因此该 schema 必须能够从字符串解码,这包括 `Schema.String`、`Schema.Literal("...")` 或 `Schema.NumberFromString` 这类 schema,并且它们还可以附加 refinement。 在幕后,`Schema.Config` 会执行以下步骤: 1. **获取值**:使用提供的名称(例如从环境变量中获取)。 2. **解码值**:使用给定的 schema。如果值无效,解码就会失败。 3. **格式化错误**:使用 [TreeFormatter.formatErrorSync](/docs/v3/schema/error-formatters/#treeformatter-default),它有助于产出可读且详细的错误消息。 **示例**(解码一个配置值) ```ts import { Effect, Schema } from "effect" // Define a config that expects a string with at least 4 characters const myConfig = Schema.Config("Foo", Schema.String.pipe(Schema.minLength(4))) const program = Effect.gen(function* () { const foo = yield* myConfig console.log(`ok: ${foo}`) }) Effect.runSync(program) ``` 要测试这份配置,请执行以下命令: **测试**(配置数据缺失) ```sh npx tsx config.ts # Output: # [(Missing data at Foo: "Expected Foo to exist in the process context")] ``` **测试**(数据无效) ```sh Foo=bar npx tsx config.ts # Output: # [(Invalid data at Foo: "a string at least 4 character(s) long # └─ Predicate refinement failure # └─ Expected a string at least 4 character(s) long, actual "bar"")] ``` **测试**(数据有效) ```sh Foo=foobar npx tsx config.ts # Output: # ok: foobar ``` ## Option ### Option `Schema.Option` 函数可用于把 `Option` 转换成可 JSON 序列化的格式。 **语法** ```ts Schema.Option(schema: Schema) ``` ##### Decoding | Input | Output | | ---------------------------- | ----------------------------------------------------------------------------------- | | `{ _tag: "None" }` | 转换为 `Option.none()` | | `{ _tag: "Some", value: I }` | 转换为 `Option.some(a)`:其中 `I` 用内部 schema 解码为 `A` | ##### Encoding | Input | Output | | ---------------- | ----------------------------------------------------------------------------------------------- | | `Option.none()` | 转换为 `{ _tag: "None" }` | | `Option.some(A)` | 转换为 `{ _tag: "Some", value: I }`:其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" import { Option } from "effect" const schema = Schema.Option(Schema.NumberFromString) // ┌─── OptionEncoded // ▼ type Encoded = typeof schema.Encoded // ┌─── Option // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode({ _tag: "None" })) // Output: { _id: 'Option', _tag: 'None' } console.log(decode({ _tag: "Some", value: "1" })) // Output: { _id: 'Option', _tag: 'Some', value: 1 } // Encoding examples console.log(encode(Option.none())) // Output: { _tag: 'None' } console.log(encode(Option.some(1))) // Output: { _tag: 'Some', value: '1' } ``` ### OptionFromSelf `Schema.OptionFromSelf` 函数面向这样的场景:`Option` 值已经处于 `Option` 格式,需要在其内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.OptionFromSelf(schema: Schema) ``` #### Decoding | Input | Output | | ---------------- | ----------------------------------------------------------------------------------- | | `Option.none()` | 保持为 `Option.none()` | | `Option.some(I)` | 转换为 `Option.some(A)`:其中 `I` 用内部 schema 解码为 `A` | #### Encoding | Input | Output | | ---------------- | ----------------------------------------------------------------------------------- | | `Option.none()` | 保持为 `Option.none()` | | `Option.some(A)` | 转换为 `Option.some(I)`:其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" import { Option } from "effect" const schema = Schema.OptionFromSelf(Schema.NumberFromString) // ┌─── Option // ▼ type Encoded = typeof schema.Encoded // ┌─── Option // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(Option.none())) // Output: { _id: 'Option', _tag: 'None' } console.log(decode(Option.some("1"))) // Output: { _id: 'Option', _tag: 'Some', value: 1 } // Encoding examples console.log(encode(Option.none())) // Output: { _id: 'Option', _tag: 'None' } console.log(encode(Option.some(1))) // Output: { _id: 'Option', _tag: 'Some', value: '1' } ``` ### OptionFromUndefinedOr `Schema.OptionFromUndefinedOr` 函数处理这样的场景:`undefined` 被视为 `Option.none()`,而根据所提供的 schema,其他所有值都被解释为 `Option.some()`。 **语法** ```ts Schema.OptionFromUndefinedOr(schema: Schema) ``` #### Decoding | Input | Output | | ----------- | ----------------------------------------------------------------------------------- | | `undefined` | 转换为 `Option.none()` | | `I` | 转换为 `Option.some(A)`:其中 `I` 用内部 schema 解码为 `A` | #### Encoding | Input | Output | | ---------------- | ---------------------------------------------------------------------- | | `Option.none()` | 转换为 `undefined` | | `Option.some(A)` | 转换为 `I`:其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" import { Option } from "effect" const schema = Schema.OptionFromUndefinedOr(Schema.NumberFromString) // ┌─── string | undefined // ▼ type Encoded = typeof schema.Encoded // ┌─── Option // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(undefined)) // Output: { _id: 'Option', _tag: 'None' } console.log(decode("1")) // Output: { _id: 'Option', _tag: 'Some', value: 1 } // Encoding examples console.log(encode(Option.none())) // Output: undefined console.log(encode(Option.some(1))) // Output: "1" ``` ### OptionFromNullOr `Schema.OptionFromUndefinedOr` 函数处理这样的场景:`null` 被视为 `Option.none()`,而根据所提供的 schema,其他所有值都被解释为 `Option.some()`。 **语法** ```ts Schema.OptionFromNullOr(schema: Schema) ``` #### Decoding | Input | Output | | ------ | ----------------------------------------------------------------------------------- | | `null` | 转换为 `Option.none()` | | `I` | 转换为 `Option.some(A)`:其中 `I` 用内部 schema 解码为 `A` | #### Encoding | Input | Output | | ---------------- | ---------------------------------------------------------------------- | | `Option.none()` | 转换为 `null` | | `Option.some(A)` | 转换为 `I`:其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" import { Option } from "effect" const schema = Schema.OptionFromNullOr(Schema.NumberFromString) // ┌─── string | null // ▼ type Encoded = typeof schema.Encoded // ┌─── Option // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(null)) // Output: { _id: 'Option', _tag: 'None' } console.log(decode("1")) // Output: { _id: 'Option', _tag: 'Some', value: 1 } // Encoding examples console.log(encode(Option.none())) // Output: null console.log(encode(Option.some(1))) // Output: "1" ``` ### OptionFromNullishOr `Schema.OptionFromNullishOr` 函数处理这样的场景:`null` 或 `undefined` 被视为 `Option.none()`,而根据所提供的 schema,其他所有值都被解释为 `Option.some()`。此外,它还允许自定义 `Option.none()` 的编码方式(`null` 或 `undefined`)。 **语法** ```ts Schema.OptionFromNullishOr( schema: Schema, onNoneEncoding: null | undefined ) ``` #### Decoding | Input | Output | | ----------- | ----------------------------------------------------------------------------------- | | `undefined` | 转换为 `Option.none()` | | `null` | 转换为 `Option.none()` | | `I` | 转换为 `Option.some(A)`:其中 `I` 用内部 schema 解码为 `A` | #### Encoding | Input | Output | | ---------------- | -------------------------------------------------------------------------- | | `Option.none()` | 根据用户的选择(`onNoneEncoding`)转换为 `undefined` 或 `null` | | `Option.some(A)` | 转换为 `I`:其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" import { Option } from "effect" const schema = Schema.OptionFromNullishOr( Schema.NumberFromString, undefined, // Encode Option.none() as undefined ) // ┌─── string | null | undefined // ▼ type Encoded = typeof schema.Encoded // ┌─── Option // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(null)) // Output: { _id: 'Option', _tag: 'None' } console.log(decode(undefined)) // Output: { _id: 'Option', _tag: 'None' } console.log(decode("1")) // Output: { _id: 'Option', _tag: 'Some', value: 1 } // Encoding examples console.log(encode(Option.none())) // Output: undefined console.log(encode(Option.some(1))) // Output: "1" ``` ### OptionFromNonEmptyTrimmedString `Schema.OptionFromNonEmptyTrimmedString` schema 用于处理这样的字符串:去掉首尾空白后为空字符串的,会被当作 `Option.none()`;所有其他字符串则会被转换成 `Option.some()`。 #### 解码 | Input | Output | | ----------- | ------------------------------------------------------- | | `s: string` | 如果 `s.trim().length > 0`,则转换为 `Option.some(s)` | | | 否则转换为 `Option.none()` | #### 编码 | Input | Output | | ------------------------ | ----------------- | | `Option.none()` | 转换为 `""` | | `Option.some(s: string)` | 转换为 `s` | **示例** ```ts import { Schema, Option } from "effect" // ┌─── string // ▼ type Encoded = typeof Schema.OptionFromNonEmptyTrimmedString // ┌─── Option // ▼ type Type = typeof Schema.OptionFromNonEmptyTrimmedString const decode = Schema.decodeUnknownSync(Schema.OptionFromNonEmptyTrimmedString) const encode = Schema.encodeSync(Schema.OptionFromNonEmptyTrimmedString) // Decoding examples console.log(decode("")) // Output: { _id: 'Option', _tag: 'None' } console.log(decode(" a ")) // Output: { _id: 'Option', _tag: 'Some', value: 'a' } console.log(decode("a")) // Output: { _id: 'Option', _tag: 'Some', value: 'a' } // Encoding examples console.log(encode(Option.none())) // Output: "" console.log(encode(Option.some("example"))) // Output: "example" ``` ## Either ### Either `Schema.Either` 函数可用于把 `Either` 转换成可 JSON 序列化的格式。 **语法** ```ts Schema.Either(options: { left: Schema, right: Schema }) ``` ##### 解码 | Input | Output | | ------------------------------ | ----------------------------------------------------------------------------------------------- | | `{ _tag: "Left", left: LI }` | 转换为 `Either.left(LA)`:其中 `LI` 用内部 `left` schema 解码为 `LA` | | `{ _tag: "Right", right: RI }` | 转换为 `Either.right(RA)`:其中 `RI` 用内部 `right` schema 解码为 `RA` | ##### 编码 | Input | Output | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | `Either.left(LA)` | 转换为 `{ _tag: "Left", left: LI }`:其中 `LA` 用内部 `left` schema 编码为 `LI` | | `Either.right(RA)` | 转换为 `{ _tag: "Right", right: RI }`:其中 `RA` 用内部 `right` schema 编码为 `RI` | **示例** ```ts import { Schema, Either } from "effect" const schema = Schema.Either({ left: Schema.Trim, right: Schema.NumberFromString, }) // ┌─── EitherEncoded // ▼ type Encoded = typeof schema.Encoded // ┌─── Either // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode({ _tag: "Left", left: " a " })) // Output: { _id: 'Either', _tag: 'Left', left: 'a' } console.log(decode({ _tag: "Right", right: "1" })) // Output: { _id: 'Either', _tag: 'Right', right: 1 } // Encoding examples console.log(encode(Either.left("a"))) // Output: { _tag: 'Left', left: 'a' } console.log(encode(Either.right(1))) // Output: { _tag: 'Right', right: '1' } ``` ### EitherFromSelf `Schema.EitherFromSelf` 函数面向这样的场景:`Either` 值已经处于 `Either` 格式,需要在内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.EitherFromSelf(options: { left: Schema, right: Schema }) ``` ##### 解码 | Input | Output | | ------------------ | ----------------------------------------------------------------------------------------------- | | `Either.left(LI)` | 转换为 `Either.left(LA)`:其中 `LI` 用内部 `left` schema 解码为 `LA` | | `Either.right(RI)` | 转换为 `Either.right(RA)`:其中 `RI` 用内部 `right` schema 解码为 `RA` | ##### 编码 | Input | Output | | ------------------ | ----------------------------------------------------------------------------------------------- | | `Either.left(LA)` | 转换为 `Either.left(LI)`:其中 `LA` 用内部 `left` schema 编码为 `LI` | | `Either.right(RA)` | 转换为 `Either.right(RI)`:其中 `RA` 用内部 `right` schema 编码为 `RI` | **示例** ```ts import { Schema, Either } from "effect" const schema = Schema.EitherFromSelf({ left: Schema.Trim, right: Schema.NumberFromString, }) // ┌─── Either // ▼ type Encoded = typeof schema.Encoded // ┌─── Either // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(Either.left(" a "))) // Output: { _id: 'Either', _tag: 'Left', left: 'a' } console.log(decode(Either.right("1"))) // Output: { _id: 'Either', _tag: 'Right', right: 1 } // Encoding examples console.log(encode(Either.left("a"))) // Output: { _id: 'Either', _tag: 'Left', left: 'a' } console.log(encode(Either.right(1))) // Output: { _id: 'Either', _tag: 'Right', right: '1' } ``` ### EitherFromUnion `Schema.EitherFromUnion` 函数用于解码和编码这样的 `Either` 值:其 `left` 与 `right` 两侧被表示为彼此不同的类型。这个 schema 支持在原始联合类型与结构化的 `Either` 类型之间进行转换。 **语法** ```ts Schema.EitherFromUnion(options: { left: Schema, right: Schema }) ``` ##### 解码 | Input | Output | | ----- | ----------------------------------------------------------------------------------------------- | | `LI` | 转换为 `Either.left(LA)`:其中 `LI` 用内部 `left` schema 解码为 `LA` | | `RI` | 转换为 `Either.right(RA)`:其中 `RI` 用内部 `right` schema 解码为 `RA` | ##### 编码 | Input | Output | | ------------------ | --------------------------------------------------------------------------------- | | `Either.left(LA)` | 转换为 `LI`:其中 `LA` 用内部 `left` schema 编码为 `LI` | | `Either.right(RA)` | 转换为 `RI`:其中 `RA` 用内部 `right` schema 编码为 `RI` | **示例** ```ts import { Schema, Either } from "effect" const schema = Schema.EitherFromUnion({ left: Schema.Boolean, right: Schema.NumberFromString, }) // ┌─── string | boolean // ▼ type Encoded = typeof schema.Encoded // ┌─── Either // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(true)) // Output: { _id: 'Either', _tag: 'Left', left: true } console.log(decode("1")) // Output: { _id: 'Either', _tag: 'Right', right: 1 } // Encoding examples console.log(encode(Either.left(true))) // Output: true console.log(encode(Either.right(1))) // Output: "1" ``` ## Exit ### Exit `Schema.Exit` 函数可用于把 `Exit` 转换成可 JSON 序列化的格式。 **语法** ```ts Schema.Exit(options: { failure: Schema, success: Schema, defect: Schema }) ``` ##### 解码 | Input | Output | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `{ _tag: "Failure", cause: CauseEncoded }` | 转换为 `Exit.failCause(Cause)`:其中 `CauseEncoded` 用内部 `failure` 与 `defect` schema 解码为 `Cause` | | `{ _tag: "Success", value: SI }` | 转换为 `Exit.succeed(SA)`:其中 `SI` 用内部 `success` schema 解码为 `SA` | ##### 编码 | Input | Output | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Exit.failCause(Cause)` | 转换为 `{ _tag: "Failure", cause: CauseEncoded }`:其中 `Cause` 用内部 `failure` 与 `defect` schema 编码为 `CauseEncoded` | | `Exit.succeed(SA)` | 转换为 `{ _tag: "Success", value: SI }`:其中 `SA` 用内部 `success` schema 编码为 `SI` | **示例** ```ts import { Schema, Exit } from "effect" const schema = Schema.Exit({ failure: Schema.String, success: Schema.NumberFromString, defect: Schema.String, }) // ┌─── ExitEncoded // ▼ type Encoded = typeof schema.Encoded // ┌─── Exit // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode({ _tag: "Failure", cause: { _tag: "Fail", error: "a" } })) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'a' } } */ console.log(decode({ _tag: "Success", value: "1" })) /* Output: { _id: 'Exit', _tag: 'Success', value: 1 } */ // Encoding examples console.log(encode(Exit.fail("a"))) /* Output: { _tag: 'Failure', cause: { _tag: 'Fail', error: 'a' } } */ console.log(encode(Exit.succeed(1))) /* Output: { _tag: 'Success', value: '1' } */ ``` ### 处理序列化中的 defect Effect 内置了 `Defect` schema,用于处理 JavaScript 错误(`Error` 实例)以及其他类型的不可恢复的 defect。 - 解码时,如果输入包含 `message`,并且可选地包含 `name` 和 `stack`,它就会重建出 `Error` 实例。 - 编码时,它会把 `Error` 实例转换成仅保留必要属性的普通对象。 当需要在网络请求或日志系统中传递错误,而 `Error` 对象默认不会被序列化时,这一点非常有用。 **示例**(编码与解码 defect) ```ts import { Schema, Exit } from "effect" const schema = Schema.Exit({ failure: Schema.String, success: Schema.NumberFromString, defect: Schema.Defect, }) const decode = Schema.decodeSync(schema) const encode = Schema.encodeSync(schema) console.log(encode(Exit.die(new Error("Message")))) /* Output: { _tag: 'Failure', cause: { _tag: 'Die', defect: { name: 'Error', message: 'Message' } } } */ console.log(encode(Exit.fail("a"))) console.log( decode({ _tag: "Failure", cause: { _tag: "Die", defect: { name: "Error", message: "Message" } }, }), ) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: [Error: Message] { [cause]: [Object] } } } */ ``` ### ExitFromSelf `Schema.ExitFromSelf` 函数面向这样的场景:`Exit` 值已经处于 `Exit` 格式,需要在内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.ExitFromSelf(options: { failure: Schema, success: Schema, defect: Schema }) ``` ##### 解码 | Input | Output | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `Exit.failCause(Cause)` | 转换为 `Exit.failCause(Cause)`:其中 `Cause` 用内部 `failure` 与 `defect` schema 解码为 `Cause` | | `Exit.succeed(SI)` | 转换为 `Exit.succeed(SA)`:其中 `SI` 用内部 `success` schema 解码为 `SA` | ##### 编码 | Input | Output | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `Exit.failCause(Cause)` | 转换为 `Exit.failCause(Cause)`:其中 `Cause` 用内部 `failure` 与 `defect` schema 解码为 `Cause` | | `Exit.succeed(SA)` | 转换为 `Exit.succeed(SI)`:其中 `SA` 用内部 `success` schema 编码为 `SI` | **示例** ```ts import { Schema, Exit } from "effect" const schema = Schema.ExitFromSelf({ failure: Schema.String, success: Schema.NumberFromString, defect: Schema.String, }) // ┌─── Exit // ▼ type Encoded = typeof schema.Encoded // ┌─── Exit // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(Exit.fail("a"))) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'a' } } */ console.log(decode(Exit.succeed("1"))) /* Output: { _id: 'Exit', _tag: 'Success', value: 1 } */ // Encoding examples console.log(encode(Exit.fail("a"))) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'a' } } */ console.log(encode(Exit.succeed(1))) /* Output: { _id: 'Exit', _tag: 'Success', value: '1' } */ ``` ## ReadonlySet ### ReadonlySet `Schema.ReadonlySet` 函数可用于把 `ReadonlySet` 转换成可 JSON 序列化的格式。 **语法** ```ts Schema.ReadonlySet(schema: Schema) ``` ##### 解码 | Input | Output | | ------------------ | ----------------------------------------------------------------------------------- | | `ReadonlyArray` | 转换为 `ReadonlySet`:其中 `I` 用内部 schema 解码为 `A` | ##### 编码 | Input | Output | | ---------------- | ------------------------------------------------------------------------ | | `ReadonlySet` | `ReadonlyArray`,其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" const schema = Schema.ReadonlySet(Schema.NumberFromString) // ┌─── readonly string[] // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlySet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(["1", "2", "3"])) // Output: Set(3) { 1, 2, 3 } // Encoding examples console.log(encode(new Set([1, 2, 3]))) // Output: [ '1', '2', '3' ] ``` ### ReadonlySetFromSelf `Schema.ReadonlySetFromSelf` 函数面向这样的场景:`ReadonlySet` 值已经处于 `ReadonlySet` 格式,需要在内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.ReadonlySetFromSelf(schema: Schema) ``` ##### 解码 | Input | Output | | ---------------- | ----------------------------------------------------------------------------------- | | `ReadonlySet` | 转换为 `ReadonlySet`:其中 `I` 用内部 schema 解码为 `A` | ##### 编码 | Input | Output | | ---------------- | ---------------------------------------------------------------------- | | `ReadonlySet` | `ReadonlySet`,其中 `A` 用内部 schema 编码为 `I` | **示例** ```ts import { Schema } from "effect" const schema = Schema.ReadonlySetFromSelf(Schema.NumberFromString) // ┌─── ReadonlySet // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlySet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(new Set(["1", "2", "3"]))) // Output: Set(3) { 1, 2, 3 } // Encoding examples console.log(encode(new Set([1, 2, 3]))) // Output: Set(3) { '1', '2', '3' } ``` ## ReadonlyMap `Schema.ReadonlyMap` 函数可用于把 `ReadonlyMap` 转换成可 JSON 序列化的格式。 ### ReadonlyMap **语法** ```ts Schema.ReadonlyMap(options: { key: Schema, value: Schema }) ``` ##### 解码 | Input | Output | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReadonlyArray` | 转换为 `ReadonlyMap`:其中 `KI` 用内部 `key` schema 解码为 `KA`,`VI` 用内部 `value` schema 解码为 `VA` | ##### 编码 | Input | Output | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReadonlyMap` | 转换为 `ReadonlyArray`:其中 `KA` 用内部 `key` schema 解码为 `KI`,`VA` 用内部 `value` schema 解码为 `VI` | **示例** ```ts import { Schema } from "effect" const schema = Schema.ReadonlyMap({ key: Schema.String, value: Schema.NumberFromString, }) // ┌─── readonly (readonly [string, string])[] // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlyMap // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log( decode([ ["a", "2"], ["b", "2"], ["c", "3"], ]), ) // Output: Map(3) { 'a' => 2, 'b' => 2, 'c' => 3 } // Encoding examples console.log( encode( new Map([ ["a", 1], ["b", 2], ["c", 3], ]), ), ) // Output: [ [ 'a', '1' ], [ 'b', '2' ], [ 'c', '3' ] ] ``` ### ReadonlyMapFromSelf `Schema.ReadonlyMapFromSelf` 函数面向这样的场景:`ReadonlyMap` 值已经处于 `ReadonlyMap` 格式,需要在内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.ReadonlyMapFromSelf(options: { key: Schema, value: Schema }) ``` ##### 解码 | Input | Output | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReadonlyMap` | 转换为 `ReadonlyMap`:其中 `KI` 用内部 `key` schema 解码为 `KA`,`VI` 用内部 `value` schema 解码为 `VA` | ##### 编码 | Input | Output | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReadonlyMap` | 转换为 `ReadonlyMap`:其中 `KA` 用内部 `key` schema 解码为 `KI`,`VA` 用内部 `value` schema 解码为 `VI` | **示例** ```ts import { Schema } from "effect" const schema = Schema.ReadonlyMapFromSelf({ key: Schema.String, value: Schema.NumberFromString, }) // ┌─── ReadonlyMap // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlyMap // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log( decode( new Map([ ["a", "2"], ["b", "2"], ["c", "3"], ]), ), ) // Output: Map(3) { 'a' => 2, 'b' => 2, 'c' => 3 } // Encoding examples console.log( encode( new Map([ ["a", 1], ["b", 2], ["c", 3], ]), ), ) // Output: Map(3) { 'a' => '1', 'b' => '2', 'c' => '3' } ``` ### ReadonlyMapFromRecord `Schema.ReadonlyMapFromRecord` 函数是一个工具,用于把 `ReadonlyMap` 转换成对象格式(键为字符串、值为可序列化),反之亦然。 **语法** ```ts Schema.ReadonlyMapFromRecord({ key: Schema, value: Schema, }) ``` #### 解码 | Input | Output | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | | `{ readonly [x: string]: VI }` | 转换为 `ReadonlyMap`:其中 `x` 用 `key` schema 解码为 `KA`,`VI` 用 `value` schema 解码为 `VA` | #### 编码 | Input | Output | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `ReadonlyMap` | 转换为 `{ readonly [x: string]: VI }`:其中 `KA` 用 `key` schema 编码为 `x`,`VA` 用 `value` schema 编码为 `VI` | **示例** ```ts import { Schema } from "effect" const schema = Schema.ReadonlyMapFromRecord({ key: Schema.NumberFromString, value: Schema.NumberFromString, }) // ┌─── { readonly [x: string]: string; } // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlyMap // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log( decode({ "1": "4", "2": "5", "3": "6", }), ) // Output: Map(3) { 1 => 4, 2 => 5, 3 => 6 } // Encoding examples console.log( encode( new Map([ [1, 4], [2, 5], [3, 6], ]), ), ) // Output: { '1': '4', '2': '5', '3': '6' } ``` ## HashSet ### HashSet `Schema.HashSet` 函数提供了一种在 `HashSet` 与数组表示之间互相映射的方式,从而支持 JSON 序列化与反序列化。 **语法** ```ts Schema.HashSet(schema: Schema) ``` #### 解码 | Input | Output | | ------------------ | --------------------------------------------------------------------------------------------------- | | `ReadonlyArray` | 转换为 `HashSet`:使用该 schema 把数组中的每个元素解码为类型 `A` | #### 编码 | Input | Output | | ------------ | ------------------------------------------------------------------------------------------------------------- | | `HashSet` | 转换为 `ReadonlyArray`:使用该 schema 把 `HashSet` 中的每个元素编码为类型 `I` | **示例** ```ts import { Schema } from "effect" import { HashSet } from "effect" const schema = Schema.HashSet(Schema.NumberFromString) // ┌─── readonly string[] // ▼ type Encoded = typeof schema.Encoded // ┌─── HashSet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(["1", "2", "3"])) // Output: { _id: 'HashSet', values: [ 1, 2, 3 ] } // Encoding examples console.log(encode(HashSet.fromIterable([1, 2, 3]))) // Output: [ '1', '2', '3' ] ``` ### HashSetFromSelf `Schema.HashSetFromSelf` 函数面向这样的场景:`HashSet` 值已经处于 `HashSet` 格式,需要在内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.HashSetFromSelf(schema: Schema) ``` #### 解码 | Input | Output | | ------------ | ------------------------------------------------------------------------------------------ | | `HashSet` | 转换为 `HashSet`:使用该 schema 把每个元素从类型 `I` 解码为类型 `A` | #### 编码 | Input | Output | | ------------ | ------------------------------------------------------------------------------------------ | | `HashSet` | 转换为 `HashSet`:使用该 schema 把每个元素从类型 `A` 编码为类型 `I` | **示例** ```ts import { Schema } from "effect" import { HashSet } from "effect" const schema = Schema.HashSetFromSelf(Schema.NumberFromString) // ┌─── HashSet // ▼ type Encoded = typeof schema.Encoded // ┌─── HashSet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(HashSet.fromIterable(["1", "2", "3"]))) // Output: { _id: 'HashSet', values: [ 1, 2, 3 ] } // Encoding examples console.log(encode(HashSet.fromIterable([1, 2, 3]))) // Output: { _id: 'HashSet', values: [ '1', '3', '2' ] } ``` ## HashMap ### HashMap `Schema.HashMap` 函数可用于把 `HashMap` 转换成可 JSON 序列化的格式。 **语法** ```ts Schema.HashMap(options: { key: Schema, value: Schema }) ``` | Input | Output | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `ReadonlyArray` | 转换为 `HashMap`:其中 `KI` 用指定的 schema 解码为 `KA`,`VI` 用指定的 schema 解码为 `VA` | #### 编码 | Input | Output | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `HashMap` | 转换为 `ReadonlyArray`:其中 `KA` 用指定的 schema 编码为 `KI`,`VA` 用指定的 schema 编码为 `VI` | **示例** ```ts import { Schema } from "effect" import { HashMap } from "effect" const schema = Schema.HashMap({ key: Schema.String, value: Schema.NumberFromString, }) // ┌─── readonly (readonly [string, string])[] // ▼ type Encoded = typeof schema.Encoded // ┌─── HashMap // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log( decode([ ["a", "2"], ["b", "2"], ["c", "3"], ]), ) // Output: { _id: 'HashMap', values: [ [ 'a', 2 ], [ 'c', 3 ], [ 'b', 2 ] ] } // Encoding examples console.log( encode( HashMap.fromIterable([ ["a", 1], ["b", 2], ["c", 3], ]), ), ) // Output: [ [ 'a', '1' ], [ 'c', '3' ], [ 'b', '2' ] ] ``` ### HashMapFromSelf `Schema.HashMapFromSelf` 函数面向这样的场景:`HashMap` 值已经处于 `HashMap` 格式,需要在其内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.HashMapFromSelf(options: { key: Schema, value: Schema }) ``` #### 解码 | Input | Output | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | `HashMap` | 转换为 `HashMap`:其中 `KI` 用指定的 schema 解码为 `KA`,`VI` 用指定的 schema 解码为 `VA` | #### 编码 | Input | Output | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | `HashMap` | 转换为 `HashMap`:其中 `KA` 用指定的 schema 编码为 `KI`,`VA` 用指定的 schema 编码为 `VI` | **示例** ```ts import { Schema } from "effect" import { HashMap } from "effect" const schema = Schema.HashMapFromSelf({ key: Schema.String, value: Schema.NumberFromString, }) // ┌─── HashMap // ▼ type Encoded = typeof schema.Encoded // ┌─── HashMap // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log( decode( HashMap.fromIterable([ ["a", "2"], ["b", "2"], ["c", "3"], ]), ), ) // Output: { _id: 'HashMap', values: [ [ 'a', 2 ], [ 'c', 3 ], [ 'b', 2 ] ] } // Encoding examples console.log( encode( HashMap.fromIterable([ ["a", 1], ["b", 2], ["c", 3], ]), ), ) // Output: { _id: 'HashMap', values: [ [ 'a', '1' ], [ 'c', '3' ], [ 'b', '2' ] ] } ``` ## SortedSet ### SortedSet `Schema.SortedSet` 函数提供了在 `SortedSet` 与数组表示之间相互映射的方式,从而支持 JSON 序列化与反序列化。 **语法** ```ts Schema.SortedSet(schema: Schema, order: Order) ``` #### 解码 | Input | Output | | ------------------ | ----------------------------------------------------------------------------------------------------- | | `ReadonlyArray` | 转换为 `SortedSet`:使用该 schema 把数组中的每个元素解码为类型 `A` | #### 编码 | Input | Output | | -------------- | --------------------------------------------------------------------------------------------------------------- | | `SortedSet` | 转换为 `ReadonlyArray`:使用该 schema 把 `SortedSet` 中的每个元素编码为类型 `I` | **示例** ```ts import { Schema } from "effect" import { Number, SortedSet } from "effect" const schema = Schema.SortedSet(Schema.NumberFromString, Number.Order) // ┌─── readonly string[] // ▼ type Encoded = typeof schema.Encoded // ┌─── SortedSet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(["1", "2", "3"])) // Output: { _id: 'SortedSet', values: [ 1, 2, 3 ] } // Encoding examples console.log(encode(SortedSet.fromIterable(Number.Order)([1, 2, 3]))) // Output: [ '1', '2', '3' ] ``` ### SortedSetFromSelf `Schema.SortedSetFromSelf` 函数面向这样的场景:`SortedSet` 值已经处于 `SortedSet` 格式,需要在其内部值按照所提供的 schema 进行转换的同时完成解码或编码。 **语法** ```ts Schema.SortedSetFromSelf( schema: Schema, decodeOrder: Order, encodeOrder: Order ) ``` #### 解码 | Input | Output | | -------------- | -------------------------------------------------------------------------------------------- | | `SortedSet` | 转换为 `SortedSet`:使用该 schema 把每个元素从类型 `I` 解码为类型 `A` | #### 编码 | Input | Output | | -------------- | -------------------------------------------------------------------------------------------- | | `SortedSet` | 转换为 `SortedSet`:使用该 schema 把每个元素从类型 `A` 编码为类型 `I` | **示例** ```ts import { Schema } from "effect" import { Number, SortedSet, String } from "effect" const schema = Schema.SortedSetFromSelf( Schema.NumberFromString, Number.Order, String.Order, ) // ┌─── SortedSet // ▼ type Encoded = typeof schema.Encoded // ┌─── SortedSet // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) const encode = Schema.encodeSync(schema) // Decoding examples console.log(decode(SortedSet.fromIterable(String.Order)(["1", "2", "3"]))) // Output: { _id: 'SortedSet', values: [ 1, 2, 3 ] } // Encoding examples console.log(encode(SortedSet.fromIterable(Number.Order)([1, 2, 3]))) // Output: { _id: 'SortedSet', values: [ '1', '2', '3' ] } ``` ## Duration `Duration` schema 家族支持对各种格式的时长值进行转换与校验,包括 `hrtime`、毫秒与纳秒。 ### Duration 把 hrtime(即 `[seconds: number, nanos: number]`)转换为 `Duration`。 **示例** ```ts import { Schema } from "effect" const schema = Schema.Duration // ┌─── readonly [seconds: number, nanos: number] // ▼ type Encoded = typeof schema.Encoded // ┌─── Duration // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode([0, 0])) // Output: { _id: 'Duration', _tag: 'Millis', millis: 0 } console.log(decode([5000, 0])) // Output: { _id: 'Duration', _tag: 'Nanos', hrtime: [ 5000, 0 ] } ``` ### DurationFromSelf `DurationFromSelf` schema 用于校验给定值是否符合 `Duration` 类型。 **示例** ```ts import { Schema, Duration } from "effect" const schema = Schema.DurationFromSelf // ┌─── Duration // ▼ type Encoded = typeof schema.Encoded // ┌─── Duration // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode(Duration.seconds(2))) // Output: { _id: 'Duration', _tag: 'Millis', millis: 2000 } console.log(decode(null)) /* throws: ParseError: Expected DurationFromSelf, actual null */ ``` ### DurationFromMillis 把 `number` 转换为 `Duration`,其中的数字表示毫秒数。 **示例** ```ts import { Schema } from "effect" const schema = Schema.DurationFromMillis // ┌─── number // ▼ type Encoded = typeof schema.Encoded // ┌─── Duration // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode(0)) // Output: { _id: 'Duration', _tag: 'Millis', millis: 0 } console.log(decode(5000)) // Output: { _id: 'Duration', _tag: 'Millis', millis: 5000 } ``` ### DurationFromNanos 把 `BigInt` 转换为 `Duration`,其中的数字表示纳秒数。 **示例** ```ts import { Schema } from "effect" const schema = Schema.DurationFromNanos // ┌─── bigint // ▼ type Encoded = typeof schema.Encoded // ┌─── Duration // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode(0n)) // Output: { _id: 'Duration', _tag: 'Millis', millis: 0 } console.log(decode(5000000000n)) // Output: { _id: 'Duration', _tag: 'Nanos', hrtime: [ 5, 0 ] } ``` ### clampDuration 把 `Duration` 限制在最小值与最大值之间。 **示例** ```ts import { Schema, Duration } from "effect" const schema = Schema.DurationFromSelf.pipe( Schema.clampDuration("5 seconds", "10 seconds"), ) // ┌─── Duration // ▼ type Encoded = typeof schema.Encoded // ┌─── Duration // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode(Duration.decode("2 seconds"))) // Output: { _id: 'Duration', _tag: 'Millis', millis: 5000 } console.log(decode(Duration.decode("6 seconds"))) // Output: { _id: 'Duration', _tag: 'Millis', millis: 6000 } console.log(decode(Duration.decode("11 seconds"))) // Output: { _id: 'Duration', _tag: 'Millis', millis: 10000 } ``` ## Redacted ### Redacted `Schema.Redacted` 函数专门用于处理敏感信息,它把 `string` 转换为 [Redacted](/docs/v3/data-types/redacted/) 对象。 这种转换能确保敏感数据不会暴露在应用的输出中。 **示例**(基础的 Redacted schema) ```ts import { Schema } from "effect" const schema = Schema.Redacted(Schema.String) // ┌─── string // ▼ type Encoded = typeof schema.Encoded // ┌─── Redacted // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode("keep it secret, keep it safe")) // Output: ``` 需要注意的是,成功解码 `Redacted` 时,输出会被有意遮蔽为 ``,以免真正的机密内容泄露到日志或控制台输出中。 **示例**(错误期间的暴露风险) 在下面的示例中,如果输入字符串不符合条件(例如包含空格),生成的错误消息可能会无意中暴露输入中包含的敏感信息。 ```ts import { Schema } from "effect" import { Redacted } from "effect" const schema = Schema.Trimmed.pipe( Schema.compose(Schema.Redacted(Schema.String)), ) console.log(Schema.decodeUnknownEither(schema)(" SECRET")) /* { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: '(Trimmed <-> (string <-> Redacted()))\n' + '└─ Encoded side transformation failure\n' + ' └─ Trimmed\n' + ' └─ Predicate refinement failure\n' + ' └─ Expected Trimmed (a string with no leading or trailing whitespace), actual " SECRET"' } } */ console.log(Schema.encodeEither(schema)(Redacted.make(" SECRET"))) /* { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: '(Trimmed <-> (string <-> Redacted()))\n' + '└─ Encoded side transformation failure\n' + ' └─ Trimmed\n' + ' └─ Predicate refinement failure\n' + ' └─ Expected Trimmed (a string with no leading or trailing whitespace), actual " SECRET"' } } */ ``` #### 缓解暴露风险 为了降低错误消息中敏感信息泄露的风险,你可以自定义错误消息,以遮蔽敏感细节: **示例**(自定义错误消息) ```ts import { Schema } from "effect" import { Redacted } from "effect" const schema = Schema.Trimmed.annotations({ message: () => "Expected Trimmed, actual ", }).pipe(Schema.compose(Schema.Redacted(Schema.String))) console.log(Schema.decodeUnknownEither(schema)(" SECRET")) /* { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: '(Trimmed <-> (string <-> Redacted()))\n' + '└─ Encoded side transformation failure\n' + ' └─ Expected Trimmed, actual ' } } */ console.log(Schema.encodeEither(schema)(Redacted.make(" SECRET"))) /* { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: '(Trimmed <-> (string <-> Redacted()))\n' + '└─ Encoded side transformation failure\n' + ' └─ Expected Trimmed, actual ' } } */ ``` ### RedactedFromSelf `Schema.RedactedFromSelf` schema 用于校验给定值是否符合 `effect` 库中的 `Redacted` 类型。 **示例** ```ts import { Schema } from "effect" import { Redacted } from "effect" const schema = Schema.RedactedFromSelf(Schema.String) // ┌─── Redacted // ▼ type Encoded = typeof schema.Encoded // ┌─── Redacted // ▼ type Type = typeof schema.Type const decode = Schema.decodeUnknownSync(schema) // Decoding examples console.log(decode(Redacted.make("mysecret"))) // Output: console.log(decode(null)) /* throws: ParseError: Expected Redacted(), actual null */ ``` 需要注意的是,成功解码一个 `Redacted` 时,输出会被有意遮蔽为(``),以防止真正的机密内容在日志或控制台输出中暴露出来。 --- # 从 Schema 派生 Equivalence > 基于 schema 定义,为数据结构生成并自定义等价性检查。 `Schema.equivalence` 函数允许你基于一份 schema 定义生成一个 [Equivalence](/docs/v3/schema/equivalence/)。 该函数用于按照 schema 中定义的规则比较数据结构是否等价。 **示例**(比较 Struct 是否等价) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Generate an equivalence function based on the schema const PersonEquivalence = Schema.equivalence(Person) const john = { name: "John", age: 23 } const alice = { name: "Alice", age: 30 } // Use the equivalence function to compare objects console.log(PersonEquivalence(john, { name: "John", age: 23 })) // Output: true console.log(PersonEquivalence(john, alice)) // Output: false ``` ## Any、Unknown 和 Object 的 Equivalence 在处理以下 schema 时: - `Schema.Any` - `Schema.Unknown` - `Schema.Object` - `Schema.Struct({})`(表示宽泛的 `{}` TypeScript 类型) 最合理的等价性形式是使用 [Equal](/docs/v3/trait/equal/) 模块中的 `Equal.equals`,它默认采用引用相等(`===`)。 这是因为这些类型几乎可以承载任意类型的值。 **示例**(使用引用相等比较空对象) ```ts import { Schema } from "effect" const schema = Schema.Struct({}) const input1 = {} const input2 = {} console.log(Schema.equivalence(schema)(input1, input2)) // Output: false (because they are different references) ``` ## 自定义 Equivalence 的生成 你可以通过在 schema 定义中提供一个 `equivalence` 注解来定制等价性逻辑。 `equivalence` 注解会接收所提供的全部类型参数(`typeParameters`)以及两个用于比较的值,并根据期望的等价条件返回一个布尔值。 **示例**(为字符串定制 Equivalence) ```ts import { Schema } from "effect" // Define a schema with a custom equivalence annotation const schema = Schema.String.annotations({ equivalence: (/**typeParameters**/) => (s1, s2) => // Custom rule: Compare only the first character of the strings s1.charAt(0) === s2.charAt(0), }) // Generate the equivalence function const customEquivalence = Schema.equivalence(schema) // Use the custom equivalence function console.log(customEquivalence("aaa", "abb")) // Output: true (both start with 'a') console.log(customEquivalence("aaa", "bba")) // Output: false (strings start with different characters) ``` --- # 错误 Formatter > 在 schema 解码与编码期间,使用 TreeFormatter 或 ArrayFormatter 格式化并自定义错误消息。 使用 Effect Schema 时,解码或编码操作中遇到的错误可以通过两个内置方法来格式化:`TreeFormatter` 和 `ArrayFormatter`。这两个 Formatter 有助于把错误组织成易读且可操作的形式。 ## TreeFormatter(默认) `TreeFormatter` 是默认的错误格式化方法。它把错误组织成树状结构,清晰地呈现问题之间的层级关系。 **示例**(解码时缺少属性) ```ts import { Either, Schema, ParseResult } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decodeUnknownEither(Person) const result = decode({}) if (Either.isLeft(result)) { console.error("Decoding failed:") console.error(ParseResult.TreeFormatter.formatErrorSync(result.left)) } /* Decoding failed: { readonly name: string; readonly age: number } └─ ["name"] └─ is missing */ ``` 在这个示例中: - `{ readonly name: string; readonly age: number }` 描述了 schema 期望的结构。 - `["name"]` 指出导致错误的具体字段。 - `is missing` 说明了 `"name"` 字段的问题。 ### 自定义输出 你可以通过给 schema 添加 `identifier`、`title` 或 `description` 这类注解(annotation),让错误输出更简洁、更有意义。这些注解会替换错误消息中默认的类似 TypeScript 的表示。 **示例**(使用 `title` 注解提升可读性) 添加 `title` 注解会用更易读的 `Person` 替换错误消息中的 schema 结构,使其更容易理解。 ```ts import { Either, Schema, ParseResult } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }).annotations({ title: "Person" }) // Add a title annotation const result = Schema.decodeUnknownEither(Person)({}) if (Either.isLeft(result)) { console.error(ParseResult.TreeFormatter.formatErrorSync(result.left)) } /* Person └─ ["name"] └─ is missing */ ``` ### 处理多个错误 默认情况下,`Schema.decodeUnknownEither` 这类解码函数只报告第一个错误。要列出所有错误,请使用 `{ errors: "all" }` 选项。 **示例**(列出所有错误) ```ts import { Either, Schema, ParseResult } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decodeUnknownEither(Person, { errors: "all" }) const result = decode({}) if (Either.isLeft(result)) { console.error("Decoding failed:") console.error(ParseResult.TreeFormatter.formatErrorSync(result.left)) } /* Decoding failed: { readonly name: string; readonly age: number } ├─ ["name"] │ └─ is missing └─ ["age"] └─ is missing */ ``` ### ParseIssueTitle 注解 `parseIssueTitle` 注解让你可以基于被校验的值动态生成标题,从而为错误消息补充上下文。例如,它可以带上被校验对象里的 ID,让你更容易在复杂或嵌套的数据结构中定位具体问题。 **注解类型** ```ts export type ParseIssueTitleAnnotation = ( issue: ParseIssue, ) => string | undefined ``` **返回值**: - 如果函数返回 `string`,`TreeFormatter` 会把它用作标题,除非存在 `message` 注解(其优先级更高)。 - 如果函数返回 `undefined`,`TreeFormatter` 会按以下优先级确定标题: 1. `identifier` 注解 2. `title` 注解 3. `description` 注解 4. 默认的类似 TypeScript 的 schema 表示 **示例**(使用 `parseIssueTitle` 生成动态标题) ```ts import type { ParseResult } from "effect" import { Schema } from "effect" // Function to generate titles for OrderItem issues const getOrderItemId = ({ actual }: ParseResult.ParseIssue) => { if (Schema.is(Schema.Struct({ id: Schema.String }))(actual)) { return `OrderItem with id: ${actual.id}` } } const OrderItem = Schema.Struct({ id: Schema.String, name: Schema.String, price: Schema.Number, }).annotations({ identifier: "OrderItem", parseIssueTitle: getOrderItemId, }) // Function to generate titles for Order issues const getOrderId = ({ actual }: ParseResult.ParseIssue) => { if (Schema.is(Schema.Struct({ id: Schema.Number }))(actual)) { return `Order with id: ${actual.id}` } } const Order = Schema.Struct({ id: Schema.Number, name: Schema.String, items: Schema.Array(OrderItem), }).annotations({ identifier: "Order", parseIssueTitle: getOrderId, }) const decode = Schema.decodeUnknownSync(Order, { errors: "all" }) // Case 1: No id available, uses the `identifier` annotation decode({}) /* throws ParseError: Order ├─ ["id"] │ └─ is missing ├─ ["name"] │ └─ is missing └─ ["items"] └─ is missing */ // Case 2: ID present, uses the dynamic `parseIssueTitle` annotation decode({ id: 1 }) /* throws ParseError: Order with id: 1 ├─ ["name"] │ └─ is missing └─ ["items"] └─ is missing */ // Case 3: Nested issues with IDs for both Order and OrderItem decode({ id: 1, items: [{ id: "22b", price: "100" }] }) /* throws ParseError: Order with id: 1 ├─ ["name"] │ └─ is missing └─ ["items"] └─ ReadonlyArray └─ [0] └─ OrderItem with id: 22b ├─ ["name"] │ └─ is missing └─ ["price"] └─ Expected a number, actual "100" */ ``` ## ArrayFormatter `ArrayFormatter` 提供了一种结构化、基于数组的错误格式化方式。它把每个错误表示为一个对象,让你在数据解码或编码时更容易分析和处理多个问题。为清晰起见,每个错误对象都包含 `_tag`、`path` 和 `message` 等属性。 **示例**(以数组格式表示单个错误) ```ts import { Either, Schema, ParseResult } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decodeUnknownEither(Person) const result = decode({}) if (Either.isLeft(result)) { console.error("Decoding failed:") console.error(ParseResult.ArrayFormatter.formatErrorSync(result.left)) } /* Decoding failed: [ { _tag: 'Missing', path: [ 'name' ], message: 'is missing' } ] */ ``` 在这个示例中: - `_tag`:指出错误的类型(`Missing`)。 - `path`:指定错误在数据中的位置(`['name']`)。 - `message`:描述该问题(`'is missing'`)。 ### 处理多个错误 默认情况下,`Schema.decodeUnknownEither` 这类解码函数只报告第一个错误。要列出所有错误,请使用 `{ errors: "all" }` 选项。 **示例**(列出所有错误) ```ts import { Either, Schema, ParseResult } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decodeUnknownEither(Person, { errors: "all" }) const result = decode({}) if (Either.isLeft(result)) { console.error("Decoding failed:") console.error(ParseResult.ArrayFormatter.formatErrorSync(result.left)) } /* Decoding failed: [ { _tag: 'Missing', path: [ 'name' ], message: 'is missing' }, { _tag: 'Missing', path: [ 'age' ], message: 'is missing' } ] */ ``` ## React Hook Form 如果你在使用 React,并且需要表单校验,`@hookform/resolvers` 为 `effect/Schema` 提供了一个适配器,可以集成到 React Hook Form 中以增强表单校验流程。这一集成让你可以在 React 应用中利用 `effect/Schema` 的强大能力。 关于如何使用 `@hookform/resolvers` 把 `effect/Schema` 集成到 React Hook Form 的详细说明与示例,请访问官方 npm 包页面: [React Hook Form Resolvers](https://www.npmjs.com/package/@hookform/resolvers#effect-ts) --- # 错误消息 > 定制并强化 schema 解码的错误消息:默认消息、细化消息与自定义消息。 ## 默认错误消息 默认情况下,当解析出错时,系统会根据 schema 的结构和错误的性质自动生成一条信息丰富的消息(更多信息见 [TreeFormatter](/docs/v3/schema/error-formatters/#treeformatter-default))。 例如,当必需的属性缺失、或数据类型不匹配时,错误消息会清楚地说明期望值与实际输入之间的差异。 **示例**(类型不匹配) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) Schema.decodeUnknownSync(Person)(null) // Output: ParseError: Expected { readonly name: string; readonly age: number }, actual null ``` **示例**(缺少属性) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) Schema.decodeUnknownSync(Person)({}, { errors: "all" }) /* throws: ParseError: { readonly name: string; readonly age: number } ├─ ["name"] │ └─ is missing └─ ["age"] └─ is missing */ ``` **示例**(属性类型不正确) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) Schema.decodeUnknownSync(Person)({ name: null, age: "age" }, { errors: "all" }) /* throws: ParseError: { readonly name: string; readonly age: number } ├─ ["name"] │ └─ Expected string, actual null └─ ["age"] └─ Expected number, actual "age" */ ``` ### 用标识符让错误消息更清楚 当 schema 有多个字段或嵌套结构时,默认的错误消息可能变得过于复杂和冗长。 为此,你可以利用 `identifier`、`title`、`description` 这类注解,让这些消息更清晰、更简洁。 **示例**(用标识符提升可读性) ```ts import { Schema } from "effect" const Name = Schema.String.annotations({ identifier: "Name" }) const Age = Schema.Number.annotations({ identifier: "Age" }) const Person = Schema.Struct({ name: Name, age: Age, }).annotations({ identifier: "Person" }) Schema.decodeUnknownSync(Person)(null) /* throws: ParseError: Expected Person, actual null */ Schema.decodeUnknownSync(Person)({}, { errors: "all" }) /* throws: ParseError: Person ├─ ["name"] │ └─ is missing └─ ["age"] └─ is missing */ Schema.decodeUnknownSync(Person)({ name: null, age: null }, { errors: "all" }) /* throws: ParseError: Person ├─ ["name"] │ └─ Expected Name, actual null └─ ["age"] └─ Expected Age, actual null */ ``` ### 细化(Refinement) 当细化失败时,默认的错误消息会指出失败发生在 “from” 部分,还是发生在定义该细化的谓词内部: **示例**(细化错误) ```ts import { Schema } from "effect" const Name = Schema.NonEmptyString.annotations({ identifier: "Name" }) const Age = Schema.Positive.pipe(Schema.int({ identifier: "Age" })) const Person = Schema.Struct({ name: Name, age: Age, }).annotations({ identifier: "Person" }) // From side failure Schema.decodeUnknownSync(Person)({ name: null, age: 18 }) /* throws: ParseError: Person └─ ["name"] └─ Name └─ From side refinement failure └─ Expected string, actual null */ // Predicate refinement failure Schema.decodeUnknownSync(Person)({ name: "", age: 18 }) /* throws: ParseError: Person └─ ["name"] └─ Name └─ Predicate refinement failure └─ Expected a non empty string, actual "" */ ``` 在第一个示例中,错误消息指出 `name` 属性发生了 “from 侧” 细化失败,并说明期望 `string` 却收到了 `null`。 在第二个示例中,报告的是 “谓词” 细化失败,说明 `name` 期望非空字符串,但提供的却是空字符串。 ### 变换(Transformation) 在不同类型或格式之间做变换时偶尔也会产生错误。 系统会提供结构化的错误消息来指明错误发生的位置: - **编码侧失败(Encoded Side Failure):** 这一侧的错误通常表示变换的输入不符合期望的初始类型或格式。例如期望 `string` 却收到 `null`。 - **变换过程失败(Transformation Process Failure):** 这类错误在变换逻辑本身失败时出现,例如输入不满足变换函数中指定的条件。 - **类型侧失败(Type Side Failure):** 当变换的输出不满足解码侧 schema 的要求时出现。如果变换后的值未通过后续校验或条件,就会发生这种情况。 **示例**(变换错误) ```ts import { ParseResult, Schema } from "effect" const schema = Schema.transformOrFail( Schema.String, Schema.String.pipe(Schema.minLength(2)), { strict: true, decode: (s, _, ast) => s.length > 0 ? ParseResult.succeed(s) : ParseResult.fail(new ParseResult.Type(ast, s)), encode: ParseResult.succeed, }, ) // Encoded side failure Schema.decodeUnknownSync(schema)(null) /* throws: ParseError: (string <-> minLength(2)) └─ Encoded side transformation failure └─ Expected string, actual null */ // transformation failure Schema.decodeUnknownSync(schema)("") /* throws: ParseError: (string <-> minLength(2)) └─ Transformation process failure └─ Expected (string <-> minLength(2)), actual "" */ // Type side failure Schema.decodeUnknownSync(schema)("a") /* throws: ParseError: (string <-> minLength(2)) └─ Type side transformation failure └─ minLength(2) └─ Predicate refinement failure └─ Expected a string at least 2 character(s) long, actual "a" */ ``` ## 自定义错误消息 你可以使用 `message` 注解,为 schema 的不同部分量身定制专门的自定义错误消息。 这让开发者能够提供更贴合具体上下文的反馈,从而改进调试与校验过程。 下面概述了 `MessageAnnotation` 类型,你可以用它来构造这些消息: ```ts type MessageAnnotation = (issue: ParseIssue) => | string | Effect | { readonly message: string | Effect readonly override: boolean } ``` | 返回类型 | 说明 | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `string` | 提供一条静态消息,直接描述该错误。 | | `Effect` | 使用动态消息,可以结合**同步**过程的结果,或依赖**可选**的依赖项。 | | Object(带 `message` 与 `override`) | 允许你定义一条特定的错误消息,并配上一个布尔标志(`override`)。该标志决定这条自定义消息是否应取代任何默认消息或嵌套的自定义消息,从而让展示给用户的错误输出得到精确控制。 | **示例**(给 string schema 添加自定义错误消息) ```ts import { Schema } from "effect" // Define a string schema without a custom message const MyString = Schema.String // Attempt to decode `null`, resulting in a default error message Schema.decodeUnknownSync(MyString)(null) /* throws: ParseError: Expected string, actual null */ // Define a string schema with a custom error message const MyStringWithMessage = Schema.String.annotations({ message: () => "not a string", }) // Decode with the custom schema, showing the new error message Schema.decodeUnknownSync(MyStringWithMessage)(null) /* throws: ParseError: not a string */ ``` **示例**(带 override 选项的联合 schema 自定义错误消息) ```ts import { Schema } from "effect" // Define a union schema without a custom message const MyUnion = Schema.Union(Schema.String, Schema.Number) // Decode `null`, resulting in default union error messages Schema.decodeUnknownSync(MyUnion)(null) /* throws: ParseError: string | number ├─ Expected string, actual null └─ Expected number, actual null */ // Define a union schema with a custom message and override flag const MyUnionWithMessage = Schema.Union( Schema.String, Schema.Number, ).annotations({ message: () => ({ message: "Please provide a string or a number", // Ensures this message replaces all nested messages override: true, }), }) // Decode with the custom schema, showing the new error message Schema.decodeUnknownSync(MyUnionWithMessage)(null) /* throws: ParseError: Please provide a string or a number */ ``` ### 消息的通用准则 确定消息时遵循的一般逻辑如下: 1. 如果没有设置任何自定义消息,则使用与操作(即解码或编码)失败所在的最内层 schema 相关的默认消息。 2. 如果设置了自定义消息,则从最内层 schema 到最外层,使用对应**第一个**失败 schema 的消息。不过,如果失败的 schema 没有自定义消息,那么**将使用默认消息**。 3. 作为一项可选启用的特性,你可以通过把 `override` 标志设为 `true` 来**覆盖准则 2**。这会让该自定义消息优先于来自内层 schema 的所有其他自定义消息。这样做是为了应对这样的场景:用户想定义一条单一的、累积性的自定义消息,用来描述一个有效值必须具有哪些属性,并且不希望看到默认消息。 下面来看一些实际示例。 ### 标量 schema **示例**(标量 schema 的简单自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String.annotations({ message: () => "my custom message", }) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) // "my custom message" } ``` ### 细化(Refinement) 这个示例演示了如何在细化链的最后一个细化上设置自定义消息。可以看到,只有当与 `maxLength` 相关的细化失败时才会使用这条自定义消息;否则会使用默认消息。 **示例**(给链中最后一个细化设置自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String.pipe( Schema.minLength(1), Schema.maxLength(2), ).annotations({ // This message is displayed only if the last filter (`maxLength`) fails message: () => "my custom message", }) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) /* minLength(1) & maxLength(2) └─ From side refinement failure └─ minLength(1) └─ From side refinement failure └─ Expected string, actual null */ } try { decode("") } catch (e: any) { console.log(e.message) /* minLength(1) & maxLength(2) └─ From side refinement failure └─ minLength(1) └─ Predicate refinement failure └─ Expected a string at least 1 character(s) long, actual "" */ } try { decode("abc") } catch (e: any) { console.log(e.message) // "my custom message" } ``` 当设置了多条自定义消息时,从最内层细化到最外层,使用对应**第一个**失败谓词的那条消息: **示例**(多个细化的自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String // This message is displayed only if a non-String is passed as input .annotations({ message: () => "String custom message" }) .pipe( // This message is displayed only if the filter `minLength` fails Schema.minLength(1, { message: () => "minLength custom message" }), // This message is displayed only if the filter `maxLength` fails Schema.maxLength(2, { message: () => "maxLength custom message" }), ) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) // String custom message } try { decode("") } catch (e: any) { console.log(e.message) // minLength custom message } try { decode("abc") } catch (e: any) { console.log(e.message) // maxLength custom message } ``` 你也可以通过把 `override` 标志设为 `true` 来改变默认行为。当你想要创建一条单一而全面的自定义消息,用来描述一个有效值必须具备的属性,并且不希望显示默认消息时,这很有用。 **示例**(覆盖默认消息) ```ts import { Schema } from "effect" const MyString = Schema.String.pipe( Schema.minLength(1), Schema.maxLength(2), ).annotations({ // By setting the `override` flag to `true`, this message will always be shown for any error message: () => ({ message: "my custom message", override: true }), }) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) // my custom message } try { decode("") } catch (e: any) { console.log(e.message) // my custom message } try { decode("abc") } catch (e: any) { console.log(e.message) // my custom message } ``` ### 变换 在下面的例子里,`IntFromString` 是一个把字符串转成整数的变换 schema。它会根据不同的场景应用特定的校验消息。 **示例**(字符串转整数的自定义错误消息) ```ts import { ParseResult, Schema } from "effect" const IntFromString = Schema.transformOrFail( // This message is displayed only if the input is not a string Schema.String.annotations({ message: () => "please enter a string" }), // This message is displayed only if the input can be converted // to a number but it's not an integer Schema.Int.annotations({ message: () => "please enter an integer" }), { strict: true, decode: (s, _, ast) => { const n = Number(s) return Number.isNaN(n) ? ParseResult.fail(new ParseResult.Type(ast, s)) : ParseResult.succeed(n) }, encode: (n) => ParseResult.succeed(String(n)), }, ) // This message is displayed only if the input // cannot be converted to a number .annotations({ message: () => "please enter a parseable string" }) const decode = Schema.decodeUnknownSync(IntFromString) try { decode(null) } catch (e: any) { console.log(e.message) // please enter a string } try { decode("1.2") } catch (e: any) { console.log(e.message) // please enter an integer } try { decode("not a number") } catch (e: any) { console.log(e.message) // please enter a parseable string } ``` ### 复合 schema 与 `string`、`number` 这类简单的标量值不同,自定义消息系统在处理复杂 schema 时格外好用。例如,设想一个由嵌套结构组成的 schema:一个 struct 里包含一个由其它 struct 组成的数组。下面我们通过一个例子来看看,在处理这类嵌套结构中的解码错误时,默认消息能带来什么优势: **示例**(嵌套 schema 中的自定义错误消息) ```ts import { Schema, pipe } from "effect" const schema = Schema.Struct({ outcomes: pipe( Schema.Array( Schema.Struct({ id: Schema.String, text: pipe( Schema.String.annotations({ message: () => "error_invalid_outcome_type", }), Schema.minLength(1, { message: () => "error_required_field" }), Schema.maxLength(50, { message: () => "error_max_length_field", }), ), }), ), Schema.minItems(1, { message: () => "error_min_length_field" }), ), }) Schema.decodeUnknownSync(schema, { errors: "all" })({ outcomes: [], }) /* throws ParseError: { readonly outcomes: minItems(1) } └─ ["outcomes"] └─ error_min_length_field */ Schema.decodeUnknownSync(schema, { errors: "all" })({ outcomes: [ { id: "1", text: "" }, { id: "2", text: "this one is valid" }, { id: "3", text: "1234567890".repeat(6) }, ], }) /* throws ParseError: { readonly outcomes: minItems(1) } └─ ["outcomes"] └─ minItems(1) └─ From side refinement failure └─ ReadonlyArray<{ readonly id: string; readonly text: minLength(1) & maxLength(50) }> ├─ [0] │ └─ { readonly id: string; readonly text: minLength(1) & maxLength(50) } │ └─ ["text"] │ └─ error_required_field └─ [2] └─ { readonly id: string; readonly text: minLength(1) & maxLength(50) } └─ ["text"] └─ error_max_length_field */ ``` ### 基于 Effect 的消息 错误消息并不局限于简单的字符串:通过返回一个 `Effect`,它们可以访问依赖,例如一个国际化服务。这种方式让消息能够根据外部上下文或服务动态调整。下面这个例子演示了如何创建基于 effect 的消息。 **示例**(基于 Effect 的消息,配合国际化服务) ```ts import { Context, Effect, Either, Option, Schema, ParseResult } from "effect" // Define an internationalization service for custom messages class Messages extends Context.Tag("Messages")< Messages, { NonEmpty: string } >() {} // Define a schema with an effect-based message // that depends on the Messages service const Name = Schema.NonEmptyString.annotations({ message: () => Effect.gen(function* () { // Attempt to retrieve the Messages service const service = yield* Effect.serviceOption(Messages) // Use a fallback message if the service is not available return Option.match(service, { onNone: () => "Invalid string", onSome: (messages) => messages.NonEmpty, }) }), }) // Attempt to decode an empty string without providing the Messages service Schema.decodeUnknownEither(Name)("").pipe( Either.mapLeft((error) => ParseResult.TreeFormatter.formatError(error).pipe( Effect.runSync, console.log, ), ), ) // Output: Invalid string // Provide the Messages service to customize the error message Schema.decodeUnknownEither(Name)("").pipe( Either.mapLeft((error) => ParseResult.TreeFormatter.formatError(error).pipe( Effect.provideService(Messages, { NonEmpty: "should be non empty", }), Effect.runSync, console.log, ), ), ) // Output: should be non empty ``` ### 缺失字段的消息 借助 `missingMessage` 注解,你可以为缺失的字段或元组元素提供自定义消息。 **示例**(缺失属性的自定义消息) 下面这个例子为 `Person` schema 中缺失的 `name` 属性定义了自定义消息。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.propertySignature(Schema.String).annotations({ // Custom message if "name" is missing missingMessage: () => "Name is required", }), }) Schema.decodeUnknownSync(Person)({}) /* throws: ParseError: { readonly name: string } └─ ["name"] └─ Name is required */ ``` **示例**(缺失元组元素的自定义消息) 这里,`Point` 元组 schema 中的每个元素在缺失时都有各自的自定义消息。 ```ts import { Schema } from "effect" const Point = Schema.Tuple( Schema.element(Schema.Number).annotations({ // Message if X is missing missingMessage: () => "X coordinate is required", }), Schema.element(Schema.Number).annotations({ // Message if Y is missing missingMessage: () => "Y coordinate is required", }), ) Schema.decodeUnknownSync(Point)([], { errors: "all" }) /* throws: ParseError: readonly [number, number] ├─ [0] │ └─ X coordinate is required └─ [1] └─ Y coordinate is required */ ``` --- # 过滤器 > 定义自定义校验逻辑,用过滤器在基础类型检查之外增强数据校验。 开发者可以定义超越基础类型检查的自定义校验逻辑,从而更好地控制数据的校验方式。 ## 声明过滤器 过滤器通过 `Schema.filter` 函数声明。该函数需要两个参数:要校验的 schema 与一个谓词函数。谓词函数由用户自定义,用于判断数据是否满足条件。如果数据未通过校验,可以提供一条错误消息。 **示例**(定义最小字符串长度过滤器) ```ts import { Schema } from "effect" // Define a string schema with a filter to ensure the string // is at least 10 characters long const LongString = Schema.String.pipe( Schema.filter( // Custom error message for strings shorter than 10 characters (s) => s.length >= 10 || "a string at least 10 characters long", ), ) // ┌─── string // ▼ type Type = typeof LongString.Type console.log(Schema.decodeUnknownSync(LongString)("a")) /* throws: ParseError: { string | filter } └─ Predicate refinement failure └─ a string at least 10 characters long */ ``` 注意,过滤器不会改变 schema 的 `Type`: ```ts // ┌─── string // ▼ type Type = typeof LongString.Type ``` 过滤器会添加额外的校验约束,但不会修改 schema 的底层类型。 ## 谓词函数 过滤器中的谓词函数遵循以下结构: ```ts type Predicate = ( a: A, options: ParseOptions, self: AST.Refinement, ) => FilterReturnType ``` 其中 ```ts interface FilterIssue { readonly path: ReadonlyArray readonly issue: string | ParseResult.ParseIssue } type FilterOutput = undefined | boolean | string | ParseResult.ParseIssue | FilterIssue type FilterReturnType = FilterOutput | ReadonlyArray ``` 过滤器的谓词可以返回多种类型的值,每种类型对校验的影响各不相同: | 返回类型 | 行为 | | --- | --- | | `true` 或 `undefined` | 数据满足过滤器的条件,通过校验。 | | `false` | 数据不满足条件,且没有提供具体的错误消息。 | | `string` | 校验失败,所提供的字符串会作为错误消息。 | | `ParseResult.ParseIssue` | 校验失败,并给出详细的错误结构,指明失败的位置与原因。 | | `FilterIssue` | 允许提供带具体路径的更详细错误消息,从而增强错误报告。 | | `ReadonlyArray` | 当需要报告多个校验错误时,可以返回一个 issue 数组。 | ## 添加注解 在 schema 中嵌入元数据(例如标识符、JSON schema 规范与描述)有助于更好地理解和分析 schema 的约束与用途。 **示例**(用注解添加元数据) ```ts import { Schema, JSONSchema } from "effect" const LongString = Schema.String.pipe( Schema.filter( (s) => s.length >= 10 ? undefined : "a string at least 10 characters long", { identifier: "LongString", jsonSchema: { minLength: 10 }, description: "Lorem ipsum dolor sit amet, ...", }, ), ) console.log(Schema.decodeUnknownSync(LongString)("a")) /* throws: ParseError: LongString └─ Predicate refinement failure └─ a string at least 10 characters long */ console.log(JSON.stringify(JSONSchema.make(LongString), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$defs": { "LongString": { "type": "string", "description": "Lorem ipsum dolor sit amet, ...", "minLength": 10 } }, "$ref": "#/$defs/LongString" } */ ``` ## 指定错误路径 在校验表单或结构化数据时,可以把具体的错误消息关联到特定的字段或路径上。这能增强错误报告,在与 [react-hook-form](https://react-hook-form.com/) 这类库集成时尤其有用。 **示例**(校验密码是否一致) ```ts import { Either, Schema, ParseResult } from "effect" const Password = Schema.Trim.pipe(Schema.minLength(2)) const MyForm = Schema.Struct({ password: Password, confirm_password: Password, }).pipe( // Add a filter to ensure that passwords match Schema.filter((input) => { if (input.password !== input.confirm_password) { // Return an error message associated // with the "confirm_password" field return { path: ["confirm_password"], message: "Passwords do not match", } } }), ) console.log( JSON.stringify( Schema.decodeUnknownEither(MyForm)({ password: "abc", confirm_password: "abd", // Confirm password does not match }).pipe( Either.mapLeft((error) => ParseResult.ArrayFormatter.formatErrorSync(error), ), ), null, 2, ), ) /* "_id": "Either", "_tag": "Left", "left": [ { "_tag": "Type", "path": [ "confirm_password" ], "message": "Passwords do not match" } ] } */ ``` 在这个示例中,我们定义了一个包含两个密码字段(`password` 与 `confirm_password`)的 `MyForm` schema。我们用 `Schema.filter` 检查两次密码是否一致。如果不一致,就会返回一条错误消息,并专门关联到 `confirm_password` 字段。这样更容易精确定位校验失败的确切位置。 错误会由 `ArrayFormatter` 格式化为结构化的形式,便于后续处理以及与表单库集成。 ## 报告多个错误 `Schema.filter` API 支持一次报告多个校验 issue,这在表单校验这类可能同时有多个检查失败的场景中尤其有用。 **示例**(报告多个校验错误) ```ts import { Either, Schema, ParseResult } from "effect" const Password = Schema.Trim.pipe(Schema.minLength(2)) const OptionalString = Schema.optional(Schema.String) const MyForm = Schema.Struct({ password: Password, confirm_password: Password, name: OptionalString, surname: OptionalString, }).pipe( Schema.filter((input) => { const issues: Array = [] // Check if passwords match if (input.password !== input.confirm_password) { issues.push({ path: ["confirm_password"], message: "Passwords do not match", }) } // Ensure either name or surname is present if (!input.name && !input.surname) { issues.push({ path: ["surname"], message: "Surname must be present if name is not present", }) } return issues }), ) console.log( JSON.stringify( Schema.decodeUnknownEither(MyForm)({ password: "abc", confirm_password: "abd", // Confirm password does not match }).pipe( Either.mapLeft((error) => ParseResult.ArrayFormatter.formatErrorSync(error), ), ), null, 2, ), ) /* { "_id": "Either", "_tag": "Left", "left": [ { "_tag": "Type", "path": [ "confirm_password" ], "message": "Passwords do not match" }, { "_tag": "Type", "path": [ "surname" ], "message": "Surname must be present if name is not present" } ] } */ ``` 在这个示例中,我们定义了一个 `MyForm` schema,其中包含用于密码校验的字段以及可选的 name/surname 字段。`Schema.filter` 函数会检查两次密码是否一致,并确保 name 或 surname 至少提供一个。任意一项校验失败时,对应的错误消息都会关联到相关字段,两条错误会以结构化的格式一起返回。 ## 暴露的值 对于带过滤器的 schema,你可以通过 `from` 属性访问基础 schema(即应用过滤器之前的那个 schema): ```ts import { Schema } from "effect" const LongString = Schema.String.pipe(Schema.filter((s) => s.length >= 10)) // Access the base schema, which is the string schema // before the filter was applied // // ┌─── typeof Schema.String // ▼ const From = LongString.from ``` ## 内置过滤器 ### 字符串过滤器 下面是 Schema 模块提供的一些实用字符串过滤器: ```ts import { Schema } from "effect" // Specifies maximum length of a string Schema.String.pipe(Schema.maxLength(5)) // Specifies minimum length of a string Schema.String.pipe(Schema.minLength(5)) // Equivalent to minLength(1) Schema.String.pipe(Schema.nonEmptyString()) // or Schema.NonEmptyString // Specifies exact length of a string Schema.String.pipe(Schema.length(5)) // Specifies a range for the length of a string Schema.String.pipe(Schema.length({ min: 2, max: 4 })) // Matches a string against a regular expression pattern Schema.String.pipe(Schema.pattern(/^[a-z]+$/)) // Ensures a string starts with a specific substring Schema.String.pipe(Schema.startsWith("prefix")) // Ensures a string ends with a specific substring Schema.String.pipe(Schema.endsWith("suffix")) // Checks if a string includes a specific substring Schema.String.pipe(Schema.includes("substring")) // Validates that a string has no leading or trailing whitespaces Schema.String.pipe(Schema.trimmed()) // Validates that a string is entirely in lowercase Schema.String.pipe(Schema.lowercased()) // Validates that a string is entirely in uppercase Schema.String.pipe(Schema.uppercased()) // Validates that a string is capitalized Schema.String.pipe(Schema.capitalized()) // Validates that a string is uncapitalized Schema.String.pipe(Schema.uncapitalized()) ``` ### 数字过滤器 下面是 Schema 模块提供的一些实用数字过滤器: ```ts import { Schema } from "effect" // Specifies a number greater than 5 Schema.Number.pipe(Schema.greaterThan(5)) // Specifies a number greater than or equal to 5 Schema.Number.pipe(Schema.greaterThanOrEqualTo(5)) // Specifies a number less than 5 Schema.Number.pipe(Schema.lessThan(5)) // Specifies a number less than or equal to 5 Schema.Number.pipe(Schema.lessThanOrEqualTo(5)) // Specifies a number between -2 and 2, inclusive Schema.Number.pipe(Schema.between(-2, 2)) // Specifies that the value must be an integer Schema.Number.pipe(Schema.int()) // or Schema.Int // Ensures the value is not NaN Schema.Number.pipe(Schema.nonNaN()) // or Schema.NonNaN // Ensures that the provided value is a finite number // (excluding NaN, +Infinity, and -Infinity) Schema.Number.pipe(Schema.finite()) // or Schema.Finite // Specifies a positive number (> 0) Schema.Number.pipe(Schema.positive()) // or Schema.Positive // Specifies a non-negative number (>= 0) Schema.Number.pipe(Schema.nonNegative()) // or Schema.NonNegative // A non-negative integer Schema.NonNegativeInt // Specifies a negative number (< 0) Schema.Number.pipe(Schema.negative()) // or Schema.Negative // Specifies a non-positive number (<= 0) Schema.Number.pipe(Schema.nonPositive()) // or Schema.NonPositive // Specifies a number that is evenly divisible by 5 Schema.Number.pipe(Schema.multipleOf(5)) // A 8-bit unsigned integer (0 to 255) Schema.Uint8 ``` ### ReadonlyArray 过滤器 下面是 Schema 模块提供的一些实用数组过滤器: ```ts import { Schema } from "effect" // Specifies the maximum number of items in the array Schema.Array(Schema.Number).pipe(Schema.maxItems(2)) // Specifies the minimum number of items in the array Schema.Array(Schema.Number).pipe(Schema.minItems(2)) // Specifies the exact number of items in the array Schema.Array(Schema.Number).pipe(Schema.itemsCount(2)) ``` ### 日期过滤器 ```ts import { Schema } from "effect" // Specifies a valid date (rejects values like `new Date("Invalid Date")`) Schema.DateFromSelf.pipe(Schema.validDate()) // or Schema.ValidDateFromSelf // Specifies a date greater than the current date Schema.Date.pipe(Schema.greaterThanDate(new Date())) // Specifies a date greater than or equal to the current date Schema.Date.pipe(Schema.greaterThanOrEqualToDate(new Date())) // Specifies a date less than the current date Schema.Date.pipe(Schema.lessThanDate(new Date())) // Specifies a date less than or equal to the current date Schema.Date.pipe(Schema.lessThanOrEqualToDate(new Date())) // Specifies a date between two dates Schema.Date.pipe(Schema.betweenDate(new Date(0), new Date())) ``` ### BigInt 过滤器 下面是 Schema 模块提供的一些实用 `BigInt` 过滤器: ```ts import { Schema } from "effect" // Specifies a BigInt greater than 5 Schema.BigInt.pipe(Schema.greaterThanBigInt(5n)) // Specifies a BigInt greater than or equal to 5 Schema.BigInt.pipe(Schema.greaterThanOrEqualToBigInt(5n)) // Specifies a BigInt less than 5 Schema.BigInt.pipe(Schema.lessThanBigInt(5n)) // Specifies a BigInt less than or equal to 5 Schema.BigInt.pipe(Schema.lessThanOrEqualToBigInt(5n)) // Specifies a BigInt between -2n and 2n, inclusive Schema.BigInt.pipe(Schema.betweenBigInt(-2n, 2n)) // Specifies a positive BigInt (> 0n) Schema.BigInt.pipe(Schema.positiveBigInt()) // or Schema.PositiveBigIntFromSelf // Specifies a non-negative BigInt (>= 0n) Schema.BigInt.pipe(Schema.nonNegativeBigInt()) // or Schema.NonNegativeBigIntFromSelf // Specifies a negative BigInt (< 0n) Schema.BigInt.pipe(Schema.negativeBigInt()) // or Schema.NegativeBigIntFromSelf // Specifies a non-positive BigInt (<= 0n) Schema.BigInt.pipe(Schema.nonPositiveBigInt()) // or Schema.NonPositiveBigIntFromSelf ``` ### BigDecimal 过滤器 下面是 Schema 模块提供的一些实用 `BigDecimal` 过滤器: ```ts import { Schema, BigDecimal } from "effect" // Specifies a BigDecimal greater than 5 Schema.BigDecimal.pipe( Schema.greaterThanBigDecimal(BigDecimal.unsafeFromNumber(5)), ) // Specifies a BigDecimal greater than or equal to 5 Schema.BigDecimal.pipe( Schema.greaterThanOrEqualToBigDecimal(BigDecimal.unsafeFromNumber(5)), ) // Specifies a BigDecimal less than 5 Schema.BigDecimal.pipe( Schema.lessThanBigDecimal(BigDecimal.unsafeFromNumber(5)), ) // Specifies a BigDecimal less than or equal to 5 Schema.BigDecimal.pipe( Schema.lessThanOrEqualToBigDecimal(BigDecimal.unsafeFromNumber(5)), ) // Specifies a BigDecimal between -2 and 2, inclusive Schema.BigDecimal.pipe( Schema.betweenBigDecimal( BigDecimal.unsafeFromNumber(-2), BigDecimal.unsafeFromNumber(2), ), ) // Specifies a positive BigDecimal (> 0) Schema.BigDecimal.pipe(Schema.positiveBigDecimal()) // Specifies a non-negative BigDecimal (>= 0) Schema.BigDecimal.pipe(Schema.nonNegativeBigDecimal()) // Specifies a negative BigDecimal (< 0) Schema.BigDecimal.pipe(Schema.negativeBigDecimal()) // Specifies a non-positive BigDecimal (<= 0) Schema.BigDecimal.pipe(Schema.nonPositiveBigDecimal()) ``` ### Duration 过滤器 下面是 Schema 模块提供的一些实用 [Duration](/docs/v3/data-types/duration/) 过滤器: ```ts import { Schema } from "effect" // Specifies a duration greater than 5 seconds Schema.Duration.pipe(Schema.greaterThanDuration("5 seconds")) // Specifies a duration greater than or equal to 5 seconds Schema.Duration.pipe(Schema.greaterThanOrEqualToDuration("5 seconds")) // Specifies a duration less than 5 seconds Schema.Duration.pipe(Schema.lessThanDuration("5 seconds")) // Specifies a duration less than or equal to 5 seconds Schema.Duration.pipe(Schema.lessThanOrEqualToDuration("5 seconds")) // Specifies a duration between 5 seconds and 10 seconds, inclusive Schema.Duration.pipe(Schema.betweenDuration("5 seconds", "10 seconds")) ``` --- # Schema 入门 > 了解如何定义 schema、提取类型,以及处理解码与编码。 你可以从 `effect/Schema` 模块导入所需的类型与函数: **示例**(命名空间导入) ```ts import * as Schema from "effect/Schema" ``` **示例**(具名导入) ```ts import { Schema } from "effect" ``` ## 定义 Schema 定义 `Schema` 的一种常见方式就是使用 `Struct` 构造器。这个构造器让你可以创建一个新的 schema,用来描述一个具有特定属性的对象。 对象中的每个属性都由它自己的 schema 定义,该 schema 规定了数据类型以及任何校验规则。 **示例**(定义简单的对象 Schema) 这个 `Person` schema 描述了一个带有 `name`(string)和 `age`(number)属性的对象: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) ``` ## 提取推导出的类型 ### Type 定义 schema(`Schema`)之后,你可以通过两种方式提取它推导出的类型 `Type`: 1. 使用 `Schema.Type` 工具类型 2. 直接在 schema 上访问 `Type` 字段 **示例**(提取推导出的类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // 1. Using the Schema.Type utility type Person = Schema.Schema.Type // 2. Accessing the Type field directly type Person2 = typeof Person.Type ``` 得到的类型如下所示: ```ts type Person = { readonly name: string readonly age: number } ``` 另一种方式是使用 `interface` 关键字提取 `Person` 类型,在某些情况下这可以提升可读性与性能。 **示例**(用 interface 提取类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) interface Person extends Schema.Schema.Type {} ``` 两种方式得到的结果相同,但使用 interface 有性能优势和更好的可读性等好处。 ### Encoded 在 `Schema` 中,`Encoded` 类型可以与 `Type` 类型不同,它表示数据被编码时所采用的格式。你可以通过两种方式提取 `Encoded` 类型: 1. 使用 `Schema.Encoded` 工具类型 2. 直接在 schema 上访问 `Encoded` 字段 **示例**(提取编码类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.NumberFromString, }) // 1. Using the Schema.Encoded utility type PersonEncoded = Schema.Schema.Encoded // 2. Accessing the Encoded field directly type PersonEncoded2 = typeof Person.Encoded ``` 得到的类型是: ```ts type PersonEncoded = { readonly name: string readonly age: string } ``` 注意,`age` 在 schema 的 `Encoded` 类型中是 `string` 类型,而在 schema 的 `Type` 类型中是 `number` 类型。 另一种方式是使用 `interface` 关键字定义 `PersonEncoded` 类型,这可以提升可读性与性能。 **示例**(用 interface 提取编码类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.NumberFromString, }) interface PersonEncoded extends Schema.Schema.Encoded {} ``` 两种方式得到的结果相同,但使用 interface 有性能优势和更好的可读性等好处。 ### Context 在 `Schema` 中,`Context` 类型表示 schema 执行编码或解码时所需的外部数据或依赖。你可以通过两种方式提取推导出的 `Context` 类型: 1. 使用 `Schema.Context` 工具类型。 2. 在 schema 上访问 `Context` 字段。 **示例**(提取 Context 类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // 1. Using the Schema.Context utility type PersonContext = Schema.Schema.Context // 2. Accessing the Context field directly type PersonContext2 = typeof Person.Context ``` ### 不透明类型的 Schema 在定义 schema 时,你可能想创建一个具有不透明类型的 schema。当你希望隐藏 schema 的内部结构、只暴露该 schema 的类型时,这很有用。 **示例**(创建不透明 Schema) 要创建具有不透明类型的 schema,可以使用下面这种重新声明 schema 的技巧: ```ts import { Schema } from "effect" // Define the schema structure const _Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Declare the type interface to make it opaque interface Person extends Schema.Schema.Type {} // Re-declare the schema as opaque const Person: Schema.Schema = _Person ``` 另一种方式是使用 Class API(更多细节见 [Class API](/docs/v3/schema/classes/) 一节)。 注意,当 schema 的 `Type` 与 `Encoded` 不同时,上面这个技巧会变得更复杂。 **示例**(Type 与 Encoded 不同的不透明 Schema) ```ts import { Schema } from "effect" // Define the schema structure, with a field that // decodes a string to a number const _Person = Schema.Struct({ name: Schema.String, age: Schema.NumberFromString, }) // Create the `Type` interface for an opaque schema interface Person extends Schema.Schema.Type {} // Create the `Encoded` interface for an opaque schema interface PersonEncoded extends Schema.Schema.Encoded {} // Re-declare the schema with opaque Type and Encoded const Person: Schema.Schema = _Person ``` 在这个例子中,字段 `"age"` 在 schema 的 `Encoded` 类型中是 `string` 类型,而在 schema 的 `Type` 类型中是 `number` 类型。因此,我们需要定义**两个** interface(`PersonEncoded` 与 `Person`),并用它们一起重新声明最终的 schema `Person`。 ## 默认的 Readonly 类型 需要注意的是,默认情况下,`effect/Schema` 导出的多数构造器都会返回 `readonly` 类型。 **示例**(Schema 中的 Readonly 类型) 例如,在下面的 `Person` schema 中: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) ``` 推导出的 `Type` 是: ```ts { readonly name: string; readonly age: number; } ``` ## 解码 在 TypeScript 中处理未知数据类型时,把未知值解码成已知结构可能很有挑战。好在 `effect/Schema` 提供了若干函数来帮助完成这一过程。下面来看看如何使用这些函数解码未知值。 | API | 说明 | | ---------------------- | ---------------------------------------------------------------------------------- | | `decodeUnknownSync` | 同步解码一个值,解析失败时抛出错误。 | | `decodeUnknownOption` | 解码一个值并返回一个 [Option](/docs/v3/data-types/option/) 类型。 | | `decodeUnknownEither` | 解码一个值并返回一个 [Either](/docs/v3/data-types/either/) 类型。 | | `decodeUnknownPromise` | 解码一个值并返回一个 `Promise`。 | | `decodeUnknown` | 解码一个值并返回一个 [Effect](/docs/v3/getting-started/the-effect-type/)。 | ### decodeUnknownSync 当你想要解析一个值,并在解析失败时立即抛出错误时,`Schema.decodeUnknownSync` 函数很有用。 **示例**(使用 `decodeUnknownSync` 立即解码) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Simulate an unknown input const input: unknown = { name: "Alice", age: 30 } // Example of valid input matching the schema console.log(Schema.decodeUnknownSync(Person)(input)) // Output: { name: 'Alice', age: 30 } // Example of invalid input that does not match the schema console.log(Schema.decodeUnknownSync(Person)(null)) /* throws: ParseError: Expected { readonly name: string; readonly age: number }, actual null */ ``` ### decodeUnknownEither `Schema.decodeUnknownEither` 函数让你可以解析一个值,并以 [Either](/docs/v3/data-types/either/) 的形式获得结果,它表示成功(`Right`)或失败(`Left`)。这种方式让你能够更优雅地处理解析错误,而不必抛出异常。 **示例**(用 `Schema.decodeUnknownEither` 处理错误) ```ts import { Schema } from "effect" import { Either } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const decode = Schema.decodeUnknownEither(Person) // Simulate an unknown input const input: unknown = { name: "Alice", age: 30 } // Attempt decoding a valid input const result1 = decode(input) if (Either.isRight(result1)) { console.log(result1.right) /* Output: { name: "Alice", age: 30 } */ } // Simulate decoding an invalid input const result2 = decode(null) if (Either.isLeft(result2)) { console.log(result2.left) /* Output: { _id: 'ParseError', message: 'Expected { readonly name: string; readonly age: number }, actual null' } */ } ``` ### decodeUnknown 如果你的 schema 涉及异步转换,那么 `Schema.decodeUnknownSync` 和 `Schema.decodeUnknownEither` 函数就不适用了。 在这种情况下,你应该使用 `Schema.decodeUnknown` 函数,它返回一个 [Effect](/docs/v3/getting-started/the-effect-type/)。 **示例**(处理异步解码) ```ts import { Schema } from "effect" import { Effect } from "effect" const PersonId = Schema.Number const Person = Schema.Struct({ id: PersonId, name: Schema.String, age: Schema.Number, }) const asyncSchema = Schema.transformOrFail(PersonId, Person, { strict: true, // Decode with simulated async transformation decode: (id) => Effect.succeed({ id, name: "name", age: 18 }).pipe( Effect.delay("10 millis"), ), encode: (person) => Effect.succeed(person.id).pipe(Effect.delay("10 millis")), }) // Attempting to use a synchronous decoder on an async schema console.log(Schema.decodeUnknownEither(asyncSchema)(1)) /* Output: { _id: 'Either', _tag: 'Left', left: { _id: 'ParseError', message: '(number <-> { readonly id: number; readonly name: string; readonly age: number })\n' + '└─ cannot be be resolved synchronously, this is caused by using runSync on an effect that performs async work' } } */ // Decoding asynchronously with `Schema.decodeUnknown` Effect.runPromise(Schema.decodeUnknown(asyncSchema)(1)).then(console.log) /* Output: { id: 1, name: 'name', age: 18 } */ ``` 在上面的代码中,第一种使用 `Schema.decodeUnknownEither` 的方式会产生错误,表明该转换无法同步完成。 这是因为 `Schema.decodeUnknownEither` 并不是为异步操作设计的。 第二种方式使用 `Schema.decodeUnknown`,它可以正常工作,让你能够处理异步转换并返回预期的结果。 ## 编码 `Schema` 模块提供了若干 `encode*` 函数,用于按照 schema 编码数据: | API | 说明 | | --------------- | ------------------------------------------------------------------------------------------------------- | | `encodeSync` | 同步编码数据,编码失败时抛出错误。 | | `encodeOption` | 编码数据并返回一个 [Option](/docs/v3/data-types/option/) 类型。 | | `encodeEither` | 编码数据并返回表示成功或失败的 [Either](/docs/v3/data-types/either/) 类型。 | | `encodePromise` | 编码数据并返回一个 `Promise`。 | | `encode` | 编码数据并返回一个 [Effect](/docs/v3/getting-started/the-effect-type/)。 | **示例**(使用 `Schema.encodeSync` 立即编码) ```ts import { Schema } from "effect" const Person = Schema.Struct({ // Ensure name is a non-empty string name: Schema.NonEmptyString, // Allow age to be decoded from a string and encoded to a string age: Schema.NumberFromString, }) // Valid input: encoding succeeds and returns expected types console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 })) // Output: { name: 'Alice', age: '30' } // Invalid input: encoding fails due to empty name string console.log(Schema.encodeSync(Person)({ name: "", age: 30 })) /* throws: ParseError: { readonly name: NonEmptyString; readonly age: NumberFromString } └─ ["name"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected a non empty string, actual "" */ ``` 注意,在编码过程中,数字值 `30` 被转换成了字符串 `"30"`。 ### 处理不支持的编码 在某些情况下,为某个 schema 支持编码可能并不可行。虽然通常建议把 schema 定义为同时支持解码与编码,但有时对某种特定类型进行编码既不受支持、也没有必要。在这些情况下,可以用 `Forbidden` issue 来表明某些值无法进行编码。 **示例**(用 `Forbidden` 表示不支持的编码) 下面是一个在解码过程中永不失败的转换示例。它返回一个 [Either](/docs/v3/data-types/either/),其中包含的要么是解码后的值,要么是原始输入。对于编码而言,不支持它是合理的,并用 `Forbidden` 作为结果。 ```ts import { Either, ParseResult, Schema } from "effect" // Define a schema that safely decodes to Either type export const SafeDecode = (self: Schema.Schema) => { const decodeUnknownEither = Schema.decodeUnknownEither(self) return Schema.transformOrFail( Schema.Unknown, Schema.EitherFromSelf({ left: Schema.Unknown, right: Schema.typeSchema(self), }), { strict: true, // Decode: map a failed result to the input as Left, // successful result as Right decode: (input) => ParseResult.succeed( Either.mapLeft(decodeUnknownEither(input), () => input), ), // Encode: only support encoding Right values, // Left values raise Forbidden error encode: (actual, _, ast) => Either.match(actual, { onLeft: () => ParseResult.fail( new ParseResult.Forbidden(ast, actual, "cannot encode a Left"), ), // Successfully encode a Right value onRight: ParseResult.succeed, }), }, ) } ``` **说明** - **解码**:`SafeDecode` 函数确保解码永不失败。它把解码后的值包装进一个 [Either](/docs/v3/data-types/either/):解码成功得到 `Right`,解码失败则得到包含原始输入的 `Left`。 - **编码**:编码过程使用 `Forbidden` 错误来表明不支持对 `Left` 值进行编码。只有 `Right` 值能够被成功编码。 ## ParseError `Schema.decodeUnknownEither` 和 `Schema.encodeEither` 函数会返回一个 [Either](/docs/v3/data-types/either/): ```ts Either ``` 其中 `ParseError` 的定义如下(简化版): ```ts interface ParseError { readonly _tag: "ParseError" readonly issue: ParseIssue } ``` 在这个结构中,`ParseIssue` 表示解析过程中可能出现的错误。它被包装成一个带标签的错误(tagged error),以便使用 [Effect.catchTag](/docs/v3/error-management/expected-errors/#catchtag) 更轻松地捕获错误。结果 `Either` 包含了 schema 所描述的推断数据类型(`Type`)。解析成功会得到一个带有已解析数据 `Type` 的 `Right` 值,而解析失败则会得到一个包含 `ParseError` 的 `Left` 值。 ## 解析选项 下面这些选项可以同时控制解码和编码的行为。 ### 管理多余属性 默认情况下,解析一个值时,schema 中未定义的任何属性都会从输出中移除。这能确保解析出的数据严格符合预期的结构。 如果你想检测并处理意料之外的属性,可以使用 `onExcessProperty` 选项(默认值为 `"ignore"`),它允许你针对多余属性抛出错误。当你需要校验并捕获未预料到的属性时,这会很有帮助。 **示例**(把 `onExcessProperty` 设为 `"error"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Excess properties are ignored by default console.log( Schema.decodeUnknownSync(Person)({ name: "Bob", age: 40, email: "bob@example.com", // Ignored }), ) /* Output: { name: 'Bob', age: 40 } */ // With `onExcessProperty` set to "error", // an error is thrown for excess properties Schema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", // Will raise an error }, { onExcessProperty: "error" }, ) /* throws ParseError: { readonly name: string; readonly age: number } └─ ["email"] └─ is unexpected, expected: "name" | "age" */ ``` 如果想保留额外的属性,请把 `onExcessProperty` 设为 `"preserve"`。 **示例**(把 `onExcessProperty` 设为 `"preserve"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Excess properties are preserved in the output console.log( Schema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", }, { onExcessProperty: "preserve" }, ), ) /* { email: 'bob@example.com', name: 'Bob', age: 40 } */ ``` ### 接收全部错误 `errors` 选项让你能够获取解析过程中遇到的所有错误。默认只返回第一个错误。把 `errors` 设为 `"all"` 会提供完整的错误反馈,这在调试或给出详细的校验反馈时很有用。 **示例**(把 `errors` 设为 `"all"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Attempt to parse with multiple issues in the input data Schema.decodeUnknownSync(Person)( { name: "Bob", age: "abc", email: "bob@example.com", }, { errors: "all", onExcessProperty: "error" }, ) /* throws ParseError: { readonly name: string; readonly age: number } ├─ ["email"] │ └─ is unexpected, expected: "name" | "age" └─ ["age"] └─ Expected number, actual "abc" */ ``` ### 管理属性顺序 `propertyOrder` 选项可以控制输出中对象字段的顺序。当键的顺序对下游消费流程很重要,或者保持输入顺序能提升可读性和易用性时,这个特性尤其有用。 默认情况下,`propertyOrder` 选项被设为 `"none"`。这意味着由内部系统决定键的顺序,以优化解析速度。在此模式下,键的顺序不应被视为稳定的,建议不要依赖键的顺序,因为它可能在未来更新中发生变化。 把 `propertyOrder` 设为 `"original"` 可以确保在解码/编码过程中,键按照它们在输入中出现的顺序排列。 **示例**(同步解码) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.Number, b: Schema.Literal("b"), c: Schema.Number, }) // Default decoding, where property order is system-defined console.log(Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 })) // Output may vary: { a: 1, b: 'b', c: 2 } // Decoding while preserving input order console.log( Schema.decodeUnknownSync(schema)( { b: "b", c: 2, a: 1 }, { propertyOrder: "original" }, ), ) // Output preserves input order: { b: 'b', c: 2, a: 1 } ``` **示例**(异步解码) ```ts import type { Duration } from "effect" import { Effect, ParseResult, Schema } from "effect" // Helper function to simulate an async operation in schema const effectify = (duration: Duration.DurationInput) => Schema.Number.pipe( Schema.transformOrFail(Schema.Number, { strict: true, decode: (x) => Effect.sleep(duration).pipe(Effect.andThen(ParseResult.succeed(x))), encode: ParseResult.succeed, }), ) // Define a structure with asynchronous behavior in each field const schema = Schema.Struct({ a: effectify("200 millis"), b: effectify("300 millis"), c: effectify("100 millis"), }).annotations({ concurrency: 3 }) // Default decoding, where property order is system-defined Schema.decode(schema)({ a: 1, b: 2, c: 3 }) .pipe(Effect.runPromise) .then(console.log) // Output decided internally: { c: 3, a: 1, b: 2 } // Decoding while preserving input order Schema.decode(schema)({ a: 1, b: 2, c: 3 }, { propertyOrder: "original" }) .pipe(Effect.runPromise) .then(console.log) // Output preserving input order: { a: 1, b: 2, c: 3 } ``` ### 在 schema 层级自定义解析行为 `parseOptions` 注解(annotation)允许你在不同的 schema 层级自定义解析行为,让你能够把独特的解析设置应用到结构体中的嵌套 schema。在某个 schema 内部定义的选项会覆盖父层级的设置,并应用到所有嵌套的 schema。 **示例**(用 `parseOptions` 自定义错误处理) ```ts import { Schema } from "effect" import { Either } from "effect" const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.String, c: Schema.String, }).annotations({ title: "first error only", // Limit errors to the first in this sub-schema parseOptions: { errors: "first" }, }), d: Schema.String, }).annotations({ title: "all errors", // Capture all errors for the main schema parseOptions: { errors: "all" }, }) // Decode input with custom error-handling behavior const result = Schema.decodeUnknownEither(schema)( { a: {} }, { errors: "first" }, ) if (Either.isLeft(result)) { console.log(result.left.message) } /* all errors ├─ ["a"] │ └─ first error only │ └─ ["b"] │ └─ is missing └─ ["d"] └─ is missing */ ``` **输出详解:** 在这个例子中: - 主 schema 被配置为显示所有错误。因此,你会看到与 `d` 字段相关的错误(因为它缺失),以及来自 `a` 子 schema 的错误。 - 子 schema(`a`)被设置为只显示第一个错误。尽管 `b` 和 `c` 字段都缺失,但只会报告第一个缺失的字段(`b`)。 ## 类型守卫 `Schema.is` 函数提供了一种验证某个值是否符合给定 schema 的方式。它充当一个[类型守卫(type guard)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates):接收一个 `unknown` 类型的值,并判断它是否匹配 schema 中定义的结构和类型约束。 `Schema.is` 函数的工作方式如下: 1. **Schema 定义**:定义一个 schema 来描述你期望的数据类型的结构和约束。例如 `Schema`,其中 `Type` 是你要校验的目标类型。 2. **创建类型守卫**:使用该 schema 创建一个用户定义的类型守卫 `(u: unknown) => u is Type`。这个函数可以在运行时用来检查某个值是否满足 schema 的要求。 **示例**(创建并使用类型守卫) ```ts import { Schema } from "effect" // Define a schema for a Person object const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Generate a type guard from the schema const isPerson = Schema.is(Person) // Test the type guard with various inputs console.log(isPerson({ name: "Alice", age: 30 })) // Output: true console.log(isPerson(null)) // Output: false console.log(isPerson({})) // Output: false ``` 生成的 `isPerson` 函数具有以下签名: ```ts const isPerson: ( u: unknown, overrideOptions?: number | ParseOptions, ) => u is { readonly name: string readonly age: number } ``` ## 断言 类型守卫验证的是某个值是否符合特定类型,而 `Schema.asserts` 函数则更进一步:它断言输入匹配 schema 类型 `Type`(来自 `Schema`)。如果输入与 schema 不匹配,它会抛出一个详细的错误,因此很适合用于运行时校验。 **示例**(创建并使用断言) ```ts import { Schema } from "effect" // Define a schema for a Person object const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Generate an assertion function from the schema const assertsPerson: Schema.Schema.ToAsserts = Schema.asserts(Person) try { // Attempt to assert that the input matches the Person schema assertsPerson({ name: "Alice", age: "30" }) } catch (e) { console.error("The input does not match the schema:") console.error(e) } /* throws: The input does not match the schema: { _id: 'ParseError', message: '{ readonly name: string; readonly age: number }\n' + '└─ ["age"]\n' + ' └─ Expected number, actual "30"' } */ // This input matches the schema and will not throw an error assertsPerson({ name: "Alice", age: 30 }) ``` 由 schema 生成的 `assertsPerson` 函数具有以下签名: ```ts const assertsPerson: ( input: unknown, overrideOptions?: number | ParseOptions, ) => asserts input is { readonly name: string readonly age: number } ``` ## 管理缺失属性 解码时,理解缺失属性是如何被处理的很重要。默认情况下,如果输入中不存在某个属性,它会被视为以 `undefined` 值存在。 **示例**(缺失属性的默认行为) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.Unknown }) const input = {} console.log(Schema.decodeUnknownSync(schema)(input)) // Output: { a: undefined } ``` 在这个例子中,尽管键 `"a"` 不存在于输入中,默认情况下它会被当作 `{ a: undefined }`。 如果你需要校验逻辑区分真正缺失的属性和显式设为 `undefined` 的属性,可以启用 `exact` 选项。 **示例**(设置 `exact: true` 来区分缺失属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.Unknown }) const input = {} console.log(Schema.decodeUnknownSync(schema)(input, { exact: true })) /* throws ParseError: { readonly a: unknown } └─ ["a"] └─ is missing */ ``` 不过,对于 `Schema.is` 和 `Schema.asserts` 这两个 API,默认行为是严格对待缺失属性,也就是 `exact` 默认为 `true`: **示例**(用 `Schema.is` 和 `Schema.asserts` 严格处理缺失属性) ```ts import type { SchemaAST } from "effect" import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.Unknown }) const input = {} console.log(Schema.is(schema)(input)) // Output: false console.log(Schema.is(schema)(input, { exact: false })) // Output: true const asserts: ( u: unknown, overrideOptions?: SchemaAST.ParseOptions, ) => asserts u is { readonly a: unknown } = Schema.asserts(schema) try { asserts(input) console.log("asserts passed") } catch (e: any) { console.error("asserts failed") console.error(e.message) } /* Output: asserts failed { readonly a: unknown } └─ ["a"] └─ is missing */ try { asserts(input, { exact: false }) console.log("asserts passed") } catch (e: any) { console.error("asserts failed") console.error(e.message) } // Output: asserts passed ``` ## 命名约定 `effect/Schema` 中的命名约定力求直白、合乎逻辑,**首要考虑的是与 JSON 序列化的兼容性**。这种做法简化了对 schema 的理解与使用,尤其对那些正在集成 Web 技术的开发者而言更是如此——在 Web 技术中,JSON 是标准的数据交换格式。 ### 命名策略概览 **与 JSON 兼容的类型** 那些天然就能序列化为 JSON 兼容格式的 schema,会直接以其数据类型来命名。 例如: - `Schema.Date`:把 JavaScript 的 Date 对象序列化为 ISO 格式的字符串,这是 JSON 中表示日期的典型做法。 - `Schema.Number`:直接使用,因为它与 JSON 的 number 类型精确对应,无需任何特殊转换即可保持 JSON 兼容。 **与 JSON 不兼容的类型** 当处理的类型在 JSON 中没有直接对应的表示时,命名策略会加入额外的细节来指明所需的转换。这有助于对 schema 的行为建立清晰的预期: 例如: - `Schema.DateFromSelf`:表明该 schema 处理的是 `Date` 对象,而这类对象本身并不能被 JSON 直接序列化。 - `Schema.NumberFromString`:这一命名暗示该 schema 处理的是最初以字符串形式表示的数字,强调在解码时从字符串到数字的转换。 这些 schema 的首要目标是确保领域对象能够方便地序列化("encoded")与反序列化("decoded"),以便通过网络连接传输,从而便于它们在同一应用的不同部分之间、或在不同应用之间传递。 ### 理由 尽管 JSON 的普遍性使其成为命名时的首要考量,这些约定同样兼顾了其他传输类型的序列化需求。例如,把 `Date` 转换为字符串对各种通信协议都普遍有用,并非只对 JSON 如此。因此,所选的命名约定充当了一套合理的默认值,优先考虑清晰性与易用性,从而便于在多样化的技术环境中进行序列化与反序列化。 --- # Effect Schema 简介 > `effect/Schema` 简介:一个用于定义、校验和转换数据 schema 的模块。 欢迎阅读 `effect/Schema` 的文档。这是一个用于在 TypeScript 中定义并使用 schema 来校验和转换数据的模块。 `effect/Schema` 模块让你能够定义 `Schema`,它为描述数据的结构与数据类型提供了一份蓝图。定义好之后,你就可以借助这个 schema 执行一系列操作,包括: | 操作 | 说明 | | --------------- | ------------------------------------------------------------------------------------ | | Decoding | 把数据从输入类型 `Encoded` 转换为输出类型 `Type`。 | | Encoding | 把数据从输出类型 `Type` 转换回输入类型 `Encoded`。 | | Asserting | 校验某个值是否符合 schema 的输出类型 `Type`。 | | Standard Schema | 生成一个 [Standard Schema V1](https://standardschema.dev/)。 | | Arbitraries | 为 [fast-check](https://github.com/dubzzz/fast-check) 测试生成 arbitrary。 | | JSON Schemas | 基于已定义的 schema 创建 JSON Schema。 | | Equivalence | 基于已定义的 schema 创建 [Equivalence](/docs/v3/schema/equivalence/)。 | | Pretty printing | 支持对数据结构进行美化打印(pretty printing)。 | ## 环境要求 - TypeScript 5.4 或更高版本。 - 在 `tsconfig.json` 文件中启用 `strict` 标志。 - (可选)在 `tsconfig.json` 文件中启用 `exactOptionalPropertyTypes` 标志。 ```json { "compilerOptions": { "strict": true, "exactOptionalPropertyTypes": true // optional } } ``` ### exactOptionalPropertyTypes 选项 `effect/Schema` 模块会利用 `tsconfig.json` 的 `exactOptionalPropertyTypes` 选项。这个选项会影响可选属性的类型标注方式(想进一步了解这个选项,可以参考官方的 [TypeScript 文档](https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes))。 **示例**(启用 `exactOptionalPropertyTypes`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.optionalWith(Schema.NonEmptyString, { exact: true }), }) type Type = Schema.Schema.Type /* type Type = { readonly name?: string; } */ // @errors: 2379 Schema.decodeSync(Person)({ name: undefined }) ``` 这里请注意,`name` 的类型是「精确的」(`string`),这意味着类型检查器会捕获任何试图赋予非法值(比如 `undefined`)的操作。 **示例**(禁用 `exactOptionalPropertyTypes`) 如果由于某些原因(比如与其他第三方库存在冲突)你无法启用 `exactOptionalPropertyTypes` 选项,你仍然可以使用 `effect/Schema`。不过,类型与运行时行为之间会出现不一致: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.optionalWith(Schema.NonEmptyString, { exact: true }), }) type Type = Schema.Schema.Type /* type Type = { readonly name?: string | undefined; } */ // No type error, but a decoding failure occurs Schema.decodeSync(Person)({ name: undefined }) /* throws: ParseError: { readonly name?: NonEmptyString } └─ ["name"] └─ NonEmptyString └─ From side refinement failure └─ Expected string, actual undefined */ ``` 在这种情况下,`name` 的类型会被放宽为 `string | undefined`,这意味着类型检查器不会捕获这个非法值(`undefined`)。但在解码过程中,你会遇到一个错误,表明 `undefined` 是不被允许的。 ## Schema 类型 schema 是一个不可变的值,用于描述数据的结构,它由 `Schema` 类型表示。 下面是 `Schema` 的一般形式: ```text ┌─── Type of the decoded value │ ┌─── Encoded type (input/output) │ │ ┌─── Requirements (context) ▼ ▼ ▼ Schema ``` `Schema` 类型有三个类型参数,它们的含义如下: | 参数 | 说明 | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Type** | 表示 schema 在解码时能够成功得到的值的类型。 | | **Encoded** | 表示 schema 在编码时能够成功得到的值的类型。如果没有显式提供,默认等于 `Type`。 | | **Requirements** | 与 [`Effect`](/docs/v3/getting-started/the-effect-type) 类型类似,它表示 schema 执行解码和编码所需的上下文数据。如果该类型参数为 `never`(未显式提供时的默认值),则表示该 schema 没有任何要求。 | **示例** - `Schema`(默认为 `Schema`)表示一个解码为 `string`、编码为 `string`,且没有任何要求的 schema。 - `Schema`(默认为 `Schema`)表示一个从 `string` 解码为 `number`、把 `number` 编码为 `string`,且没有任何要求的 schema。 ## 理解 Schema 值 **不可变性(Immutability)**。`Schema` 值是不可变的,`effect/Schema` 模块中的每个函数都会产生一个新的 `Schema` 值。 **数据结构的建模(Modeling Data Structure)**。这些值本身不执行任何操作,它们只是对数据的结构进行建模或描述。 **由编译器解释(Interpretation by Compilers)**。一个 `Schema` 可以被各种「编译器」解释为具体的操作,具体取决于编译器的类型(解码、编码、美化打印、arbitrary 等……)。 ## 理解解码与编码 在 TypeScript 中处理数据时,你经常需要处理来自外部系统或要发送给外部系统的数据。这些数据未必总是符合你预期的格式或类型,尤其是在处理用户输入、来自 API 的数据,或存储为不同格式的数据时。为了处理这些差异,我们使用**解码(decoding)**与**编码(encoding)**。 | 术语 | 说明 | | ------------ | ------------------------------------------------------------------------------------------------------------ | | **Decoding** | 用于解析来自外部来源的数据,而这些数据的格式并不受你控制。 | | **Encoding** | 用于把数据发送到外部来源时,将其转换为这些来源所期望的格式。 | 例如,在前端处理表单时,你收到的往往是以字符串形式出现的无类型数据。这些数据可能被篡改,并且原生不支持数组或布尔值。解码可以帮助你校验这些数据,并将其解析为更有用的类型,比如数字、日期和数组。编码则允许你把这些类型转换回表单所期望的字符串格式。 下面的图示通过 `Schema` 展示了编码与解码之间的关系: ```text ┌─────────┐ ┌───┐ ┌───┐ ┌─────────┐ | unknown | | A | | I | | unknown | └─────────┘ └───┘ └───┘ └─────────┘ | | | | | validate | | | |─────────────►│ | | | | | | | is | | | |─────────────►│ | | | | | | | asserts | | | |─────────────►│ | | | | | | | encodeUnknown| | | |─────────────────────────►| | | | | | encode | | |──────────►│ | | | | | decode | | | ◄─────────| | | | | | | decodeUnknown| | ◄────────────────────────| ``` 我们将通过一个 `Schema` 的例子来拆解这些概念。这个 schema 是一个把 `string` 转换为 `Date`、也能反向转换的工具。 ### 编码 当我们谈到「编码」时,指的是把 `Date` 转换为 `string` 的过程。简单来说,就是把数据从一种格式转换为另一种格式。 ### 解码 反过来,「解码」则是把 `string` 转换回 `Date`。它本质上是编码的逆操作,让数据恢复为其原本的形式。 ### 从 Unknown 解码 从 `unknown` 解码包含两个关键步骤: 1. **检查(Checking):** 首先,我们验证输入数据(其类型为 `unknown`)是否符合预期的结构。在我们这个具体场景中,这意味着确保输入确实是一个 `string`。 2. **解码(Decoding):** 检查通过之后,我们继续把 `string` 转换为 `Date`。这一过程完成了整个解码操作,数据在此过程中既被校验也被转换。 ### 从 Unknown 编码 从 `unknown` 编码包含两个关键步骤: 1. **检查(Checking):** 首先,我们验证输入数据(其类型为 `unknown`)是否符合预期的结构。在我们这个具体场景中,这意味着确保输入确实是一个 `Date`。 2. **编码(Encoding):** 检查通过之后,我们继续把 `Date` 转换为 `string`。这一过程完成了整个编码操作,数据在此过程中既被校验也被转换。 ## Schema 的规则 使用 schema 时,有一条重要的规则需要牢记:你的 schema 应当被设计成在执行编码和解码操作之后,最终得到的是原始值。 更简单地说,如果你先编码一个值、然后立即解码它,结果应当与你最初的那个值一致。这条规则确保你的数据在整个编码和解码过程中保持一致、可靠。 --- # 从 Schema 到 JSON Schema > 把 schema 定义转换为 JSON Schema,用于数据校验与互操作。 `JSONSchema.make` 函数允许你从一个 schema 生成 JSON Schema。 **示例**(为一个 Struct 创建 JSON Schema) 下面的示例定义了一个 `Person` schema,它具有 `name`(字符串)和 `age`(数值)两个属性,随后生成对应的 JSON Schema。 ```ts import { JSONSchema, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) const jsonSchema = JSONSchema.make(Person) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "name", "age" ], "properties": { "name": { "type": "string" }, "age": { "type": "number" } }, "additionalProperties": false } */ ``` `JSONSchema.make` 函数的目标是生成一份最优的 JSON Schema,用于表示解码阶段的输入部分。 它的做法是:从嵌套最深的组件开始遍历 schema,把每一处 refinement 都纳入其中,并在**遇到第一个 transformation 时停止**。 **示例**(在 JSON Schema 中排除 transformation) 试着把 `age` 字段改成同时包含一个 refinement 和一个 transformation。此时只有 refinement 会体现在 JSON Schema 中。 ```ts import { JSONSchema, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number.pipe( // Refinement included in the JSON Schema Schema.int(), // Transformation excluded from the JSON Schema Schema.clamp(1, 10), ), }) const jsonSchema = JSONSchema.make(Person) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "name", "age" ], "properties": { "name": { "type": "string" }, "age": { "type": "integer", "description": "an integer", "title": "integer" } }, "additionalProperties": false } */ ``` 在这个例子中,JSON Schema 体现了整数的 refinement,但没有包含那个对取值进行 clamp 的 transformation。 ## 指定 JSON Schema 版本 默认情况下,`JSONSchema.make` 生成与 **Draft 07** 兼容的 JSON Schema。你可以通过传入带 `target` 属性的选项对象来更改目标 schema 版本。支持的 target 有: - `"jsonSchema7"`(默认)- JSON Schema Draft 07 - `"jsonSchema2019-09"` - JSON Schema Draft 2019-09 - `"jsonSchema2020-12"` - JSON Schema Draft 2020-12 - `"openApi3.1"` - OpenAPI 3.1 更改 target 会影响生成的输出。例如,元组 schema 在 Draft 07 中使用 `items` 和 `additionalItems`,而 Draft 2020-12 使用 `prefixItems` 和 `items`。 **示例**(为元组使用 JSON Schema 2020-12) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Tuple(Schema.String, Schema.Number) const jsonSchema = JSONSchema.make(schema, { target: "jsonSchema2020-12", }) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "array", "minItems": 2, "prefixItems": [ { "type": "string" }, { "type": "number" } ], "items": false } */ ``` ## 各类 Schema 的具体输出 ### 字面量 字面量在 JSON Schema 中会被转换为 `enum` 类型。 **示例**(单个字面量) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Literal("a") console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string", "enum": [ "a" ] } */ ``` **示例**(字面量的联合) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Literal("a", "b") console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string", "enum": [ "a", "b" ] } */ ``` ### Void ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Void console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/void", "title": "void" } */ ``` ### Any ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Any console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/any", "title": "any" } */ ``` ### Unknown ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Unknown console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/unknown", "title": "unknown" } */ ``` ### Object ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Object console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/object", "anyOf": [ { "type": "object" }, { "type": "array" } ], "description": "an object in the TypeScript meaning, i.e. the `object` type", "title": "object" } */ ``` ### String ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.String console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string" } */ ``` ### Number ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Number console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "number" } */ ``` ### Boolean ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Boolean console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "boolean" } */ ``` ### 元组 ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Tuple(Schema.String, Schema.Number) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "array", "minItems": 2, "items": [ { "type": "string" }, { "type": "number" } ], "additionalItems": false } */ ``` ### 数组 ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Array(Schema.String) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "array", "items": { "type": "string" } } */ ``` ### 非空数组 表示至少包含一个元素的数组。 **示例** ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.NonEmptyArray(Schema.String) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "array", "minItems": 1, "items": { "type": "string" } } */ ``` ### 结构体 ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Struct({ name: Schema.String, age: Schema.Number, }) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "name", "age" ], "properties": { "name": { "type": "string" }, "age": { "type": "number" } }, "additionalProperties": false } */ ``` ### 记录 ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Record({ key: Schema.String, value: Schema.Number, }) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [], "properties": {}, "patternProperties": { "": { "type": "number" } } } */ ``` ### 混合结构体与记录 把结构体中的固定属性与记录中的动态属性组合起来。 **示例** ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Struct( { name: Schema.String, age: Schema.Number, }, Schema.Record({ key: Schema.String, value: Schema.Union(Schema.String, Schema.Number), }), ) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "name", "age" ], "properties": { "name": { "type": "string" }, "age": { "type": "number" } }, "patternProperties": { "": { "anyOf": [ { "type": "string" }, { "type": "number" } ] } } } */ ``` ### 枚举 ```ts import { JSONSchema, Schema } from "effect" enum Fruits { Apple, Banana, } const schema = Schema.Enums(Fruits) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$comment": "/schemas/enums", "anyOf": [ { "type": "number", "title": "Apple", "enum": [ 0 ] }, { "type": "number", "title": "Banana", "enum": [ 1 ] } ] } */ ``` ### 模板字面量 ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.TemplateLiteral(Schema.Literal("a"), Schema.Number) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string", "title": "`a${number}`", "description": "a template literal", "pattern": "^a[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" } */ ``` ### 联合类型 联合类型会根据所涉及的类型,用 `anyOf` 或 `enum` 来表示: **示例**(通用联合类型) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Union(Schema.String, Schema.Number) console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "anyOf": [ { "type": "string" }, { "type": "number" } ] } */ ``` **示例**(字面量联合类型) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Literal("a", "b") console.log(JSON.stringify(JSONSchema.make(schema), null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string", "enum": [ "a", "b" ] } */ ``` ## 标识符注解 你可以为 schema 添加 `identifier` 注解,以改善结构并提升可维护性。带注解的 schema 会被放进 JSON Schema 根部的 `$defs` 对象,并从那里被引用。 **示例**(使用标识符注解) ```ts import { JSONSchema, Schema } from "effect" const Name = Schema.String.annotations({ identifier: "Name" }) const Age = Schema.Number.annotations({ identifier: "Age" }) const Person = Schema.Struct({ name: Name, age: Age, }) const jsonSchema = JSONSchema.make(Person) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$defs": { "Name": { "type": "string", "description": "a string", "title": "string" }, "Age": { "type": "number", "description": "a number", "title": "number" } }, "type": "object", "required": [ "name", "age" ], "properties": { "name": { "$ref": "#/$defs/Name" }, "age": { "$ref": "#/$defs/Age" } }, "additionalProperties": false } */ ``` 借助标识符注解,schema 可以更容易地被复用和引用,在复杂的 JSON Schema 中尤其如此。 ## 标准 JSON Schema 注解 `title`、`description`、`default`、`examples` 等标准 JSON Schema 注解都受支持。 这些注解让你可以为 schema 补充元数据,从而提升可读性,并提供关于数据结构的更多信息。 **示例**(使用注解提供元数据) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.String.annotations({ description: "my custom description", title: "my custom title", default: "", examples: ["a", "b"], }) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "string", "description": "my custom description", "title": "my custom title", "examples": [ "a", "b" ], "default": "" } */ ``` ### 为 Struct 属性添加注解 为了让 JSON schema 更清晰,建议把注解直接添加到属性签名(property signature)上,而不是添加到类型本身上。 这种做法在语义上更合适,因为它把描述性标题和其他元数据与它们所描述的具体属性关联起来,而不是与泛型类型关联。 **示例**(带注解的 Struct 属性) ```ts import { JSONSchema, Schema } from "effect" const Person = Schema.Struct({ firstName: Schema.propertySignature(Schema.String).annotations({ title: "First name", }), lastName: Schema.propertySignature(Schema.String).annotations({ title: "Last Name", }), }) const jsonSchema = JSONSchema.make(Person) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "firstName", "lastName" ], "properties": { "firstName": { "type": "string", "title": "First name" }, "lastName": { "type": "string", "title": "Last Name" } }, "additionalProperties": false } */ ``` ## 递归与互递归 schema 递归与互递归 schema 都受支持,不过对这类 schema 而言,**必须**使用 `identifier` 注解,以确保生成的 JSON Schema 中的引用和定义正确无误。 **示例**(带标识符注解的递归 schema) 在这个例子中,`Category` schema 引用自身,因此必须使用 `identifier` 注解来支持这种引用。 ```ts import { JSONSchema, Schema } from "effect" // Define the interface representing a category structure interface Category { readonly name: string readonly categories: ReadonlyArray } // Define a recursive schema with a required identifier annotation const Category = Schema.Struct({ name: Schema.String, categories: Schema.Array( // Recursive reference to the Category schema Schema.suspend((): Schema.Schema => Category), ), }).annotations({ identifier: "Category" }) const jsonSchema = JSONSchema.make(Category) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "$defs": { "Category": { "type": "object", "required": [ "name", "categories" ], "properties": { "name": { "type": "string" }, "categories": { "type": "array", "items": { "$ref": "#/$defs/Category" } } }, "additionalProperties": false } }, "$ref": "#/$defs/Category" } */ ``` ## 自定义 JSON Schema 生成 在处理 JSON Schema 时,某些数据类型(例如 `bigint`)没有直接的表示,因为 JSON Schema 原生并不支持它们。 这种缺失通常会导致在生成 schema 时报错。 **示例**(因缺少注解而报错) 尝试为 `bigint` 这类不支持的类型生成 JSON Schema,会得到一条缺少注解的错误: ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Struct({ a_bigint_field: Schema.BigIntFromSelf, }) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* throws: Error: Missing annotation at path: ["a_bigint_field"] details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation schema (BigIntKeyword): bigint */ ``` 为解决这个问题,你可以为 schema 添加自定义的 `jsonSchema` 注解,定义你打算如何在 JSON Schema 中表示这类类型: **示例**(为不支持的类型使用自定义注解) ```ts import { JSONSchema, Schema } from "effect" const schema = Schema.Struct({ // Adding a custom JSON Schema annotation for the `bigint` type a_bigint_field: Schema.BigIntFromSelf.annotations({ jsonSchema: { type: "some custom way to represent a bigint in JSON Schema", }, }), }) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "a_bigint_field" ], "properties": { "a_bigint_field": { "type": "some custom way to represent a bigint in JSON Schema" } }, "additionalProperties": false } */ ``` ### 细化 在定义细化(refinement)时(例如通过 `Schema.filter` 函数),你可以加一个 JSON Schema 注解来描述该细化。这个注解会作为一个「片段」(fragment)加入生成的 JSON Schema。如果一个 schema 包含多个细化,它们各自的注解会合并到输出中。 **示例**(使用合并注解的细化) ```ts import { JSONSchema, Schema } from "effect" // Define a schema with a refinement for positive numbers const Positive = Schema.Number.pipe( Schema.filter((n) => n > 0, { jsonSchema: { minimum: 0 }, }), ) // Add an upper bound refinement to the schema const schema = Positive.pipe( Schema.filter((n) => n <= 10, { jsonSchema: { maximum: 10 }, }), ) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "number", "minimum": 0, "maximum": 10 } */ ``` `jsonSchema` 注解被定义为一个泛型对象,因此可以表示非标准扩展。这种灵活性把强制类型约束的责任留给了使用者。 如果你更希望有严格的类型约束,或者需要支持非标准扩展,可以为对象字面量引入一个 `satisfies` 约束。这个约束应与你所选的类型库配合使用。 **示例**(确保类型正确) 在下面的例子中,我们使用 `@types/json-schema` 包为 JSON Schema 提供 TypeScript 定义。这种做法不仅能确保类型正确,还能在 IDE 中获得自动补全提示。 ```ts import { JSONSchema, Schema } from "effect" import type { JSONSchema7 } from "json-schema" const Positive = Schema.Number.pipe( Schema.filter((n) => n > 0, { jsonSchema: { minimum: 0 }, // Generic object, no type enforcement }), ) const schema = Positive.pipe( Schema.filter((n) => n <= 10, { jsonSchema: { maximum: 10 } satisfies JSONSchema7, // Enforces type constraints }), ) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "number", "minimum": 0, "maximum": 10 } */ ``` 对于细化之外的其他 schema 类型,你可以通过提供自定义的 `jsonSchema` 注解来覆盖默认生成的 JSON Schema。该注解的内容会替换系统生成的 schema。 **示例**(为 Struct 使用自定义注解) ```ts import { JSONSchema, Schema } from "effect" // Define a struct with a custom JSON Schema annotation const schema = Schema.Struct({ foo: Schema.String }).annotations({ jsonSchema: { type: "object" }, }) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" } the default would be: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "foo" ], "properties": { "foo": { "type": "string" } }, "additionalProperties": false } */ ``` ## 使用 Schema.parseJson 生成专用的 JSON Schema `Schema.parseJson` 函数为 JSON Schema 生成提供了一种独特的做法。它不会默认使用表示转换“来源”一侧的普通字符串 schema,而是根据参数中提供的结构来生成 schema。 这种行为确保生成的 JSON Schema 反映的是解析后数据的目标结构,而不是原始的 JSON 输入。 **示例**(为解析后的对象生成 JSON Schema) ```ts import { JSONSchema, Schema } from "effect" // Define a schema that parses a JSON string into a structured object const schema = Schema.parseJson( Schema.Struct({ // Nested parsing: JSON string to a number a: Schema.parseJson(Schema.NumberFromString), }), ) const jsonSchema = JSONSchema.make(schema) console.log(JSON.stringify(jsonSchema, null, 2)) /* Output: { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": [ "a" ], "properties": { "a": { "type": "string", "contentMediaType": "application/json" } }, "additionalProperties": false } */ ``` --- # 从 Schema 到 Pretty Printer > 根据 Schema 生成值的格式化字符串表示。 `Pretty.make` 函数用于创建 pretty printer,它根据某个 Schema 生成值的格式化字符串表示。 **示例**(为 Struct Schema 生成 Pretty Printer) ```ts import { Pretty, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, }) // Create a pretty printer for the schema const PersonPretty = Pretty.make(Person) // Format and print a Person object console.log(PersonPretty({ name: "Alice", age: 30 })) /* Output: '{ "name": "Alice", "age": 30 }' */ ``` ## 自定义 Pretty Printer 的生成 你可以在 Schema 定义中使用 `pretty` 注解,来自定义 pretty printer 格式化输出的方式。 `pretty` 注解会接收所提供的任意类型参数(`typeParameters`),并把值格式化为字符串。 **示例**(为数字自定义 Pretty Printer) ```ts import { Pretty, Schema } from "effect" // Define a schema with a custom pretty annotation const schema = Schema.Number.annotations({ pretty: (/**typeParameters**/) => (value) => `my format: ${value}`, }) // Create the pretty printer const customPrettyPrinter = Pretty.make(schema) // Format and print a value console.log(customPrettyPrinter(1)) // Output: "my format: 1" ``` --- # Schema 投影 > 通过提取并定制已有 schema 的 Type 或 Encoded 组成部分来创建新 schema。 有时,你可能想基于已有的 schema 创建一个新 schema,并专门关注它的 `Type` 或 `Encoded` 其中一面。Schema 模块提供了若干函数来实现这一点。 ## typeSchema `Schema.typeSchema` 函数用于提取一个 schema 的 `Type` 部分,得到一个新 schema,它只保留原始 schema 中与类型相关的属性。这会排除应用于原始 schema 的任何初始编码或变换逻辑。 **函数签名** ```ts declare const typeSchema: (schema: Schema) => Schema ``` **示例**(只提取 Type 侧特有的属性) ```ts import { Schema } from "effect" const Original = Schema.Struct({ quantity: Schema.NumberFromString.pipe(Schema.greaterThanOrEqualTo(2)), }) // This creates a schema where 'quantity' is defined as a number // that must be greater than or equal to 2. const TypeSchema = Schema.typeSchema(Original) // TypeSchema is equivalent to: const TypeSchema2 = Schema.Struct({ quantity: Schema.Number.pipe(Schema.greaterThanOrEqualTo(2)), }) ``` ## encodedSchema `Schema.encodedSchema` 函数让你能够提取一个 schema 的 `Encoded` 部分,创建一个新 schema,它与原始属性相匹配,但**会省略应用于该 schema 的任何 refinement 或变换**。 **函数签名** ```ts declare const encodedSchema: (schema: Schema) => Schema ``` **示例**(只提取 Encoded 属性) ```ts import { Schema } from "effect" const Original = Schema.Struct({ quantity: Schema.String.pipe(Schema.minLength(3)), }) // This creates a schema where 'quantity' is just a string, // disregarding the minLength refinement. const Encoded = Schema.encodedSchema(Original) // Encoded is equivalent to: const Encoded2 = Schema.Struct({ quantity: Schema.String, }) ``` ## encodedBoundSchema `Schema.encodedBoundSchema` 函数与 `Schema.encodedSchema` 类似,但会保留原始 schema 中直到第一个变换点为止的 refinement。 **函数签名** ```ts declare const encodedBoundSchema: ( schema: Schema, ) => Schema ``` 这里的 “bound” 一词指的是在提取 schema 的编码形式时保留 refinement 的边界。它本质上标记了一个限度:在应用任何变换之前,最初的校验与结构会被保持到该限度为止。 **示例**(只保留最初的 refinement) ```ts import { Schema } from "effect" const Original = Schema.Struct({ foo: Schema.String.pipe(Schema.minLength(3), Schema.compose(Schema.Trim)), }) // The EncodedBoundSchema schema preserves the minLength(3) refinement, // ensuring the string length condition is enforced // but omits the Schema.Trim transformation. const EncodedBoundSchema = Schema.encodedBoundSchema(Original) // EncodedBoundSchema is equivalent to: const EncodedBoundSchema2 = Schema.Struct({ foo: Schema.String.pipe(Schema.minLength(3)), }) ``` --- # 从 Schema 到 Standard Schema > 生成 Standard Schema V1。 `Schema.standardSchemaV1` API 允许你从一个 Effect `Schema` 生成 [Standard Schema v1](https://standardschema.dev/) 对象。 **示例**(生成 Standard Schema V1) ```ts import { Schema } from "effect" const schema = Schema.Struct({ name: Schema.String, }) // Convert an Effect schema into a Standard Schema V1 object // // ┌─── StandardSchemaV1<{ readonly name: string; }> // ▼ const standardSchema = Schema.standardSchemaV1(schema) ``` ## 同步校验与异步校验 `Schema.standardSchemaV1` API 创建的 schema,其 `validate` 方法会尝试同步解码并校验传入的输入。如果底层 `Schema` 包含任何异步组件(例如异步的 message resolution 或 check),那么校验必然改为返回一个 `Promise`。 **示例**(处理同步与异步校验) ```ts import { Effect, Schema } from "effect" // Utility function to display sync and async results const print = (t: T) => t instanceof Promise ? t.then((x) => console.log("Promise", JSON.stringify(x, null, 2))) : console.log("Value", JSON.stringify(t, null, 2)) // Define a synchronous schema const sync = Schema.Struct({ name: Schema.String, }) // Generate a Standard Schema V1 object const syncStandardSchema = Schema.standardSchemaV1(sync) // Validate synchronously print(syncStandardSchema["~standard"].validate({ name: null })) /* Output: { "issues": [ { "path": [ "name" ], "message": "Expected string, actual null" } ] } */ // Define an asynchronous schema with a transformation const async = Schema.transformOrFail( sync, Schema.Struct({ name: Schema.NonEmptyString, }), { // Simulate an asynchronous validation delay decode: (x) => Effect.sleep("100 millis").pipe(Effect.as(x)), encode: Effect.succeed, }, ) // Generate a Standard Schema V1 object const asyncStandardSchema = Schema.standardSchemaV1(async) // Validate asynchronously print(asyncStandardSchema["~standard"].validate({ name: "" })) /* Output: Promise { "issues": [ { "path": [ "name" ], "message": "Expected a non empty string, actual \"\"" } ] } */ ``` ## Defect 如果校验期间出现意外的 defect,它会被报告为单个不带 `path` 的 issue。这样可以确保意外的错误不会中断 schema 校验,同时仍会被捕获并报告。 **示例**(处理 Defect) ```ts import { Effect, Schema } from "effect" // Define a schema with a defect in the decode function const defect = Schema.transformOrFail(Schema.String, Schema.String, { // Simulate an internal failure decode: () => Effect.die("Boom!"), encode: Effect.succeed, }) // Generate a Standard Schema V1 object const defectStandardSchema = Schema.standardSchemaV1(defect) // Validate input, triggering a defect console.log(defectStandardSchema["~standard"].validate("a")) /* Output: { issues: [ { message: 'Error: Boom!' } ] } */ ``` --- # Schema 变换 > 使用基于 schema 的变换来转换和处理数据,包括类型转换、校验以及自定义处理。 在处理 schema 时,变换非常重要。它让你可以把数据从一种类型转换为另一种类型。例如,你可以把字符串解析为数字,或者把日期字符串转换为 `Date` 对象。 [Schema.transform](#transform) 与 [Schema.transformOrFail](#transformorfail) 这两个函数帮助你连接两个 schema,从而在它们之间转换数据。 ## transform `Schema.transform` 会取一个 schema(「源」)的输出,把它作为另一个 schema(「目标」)的输入,从而创建一个新的 schema。当你确信该变换总会成功时使用它;如果它可能失败,请改用 [Schema.transformOrFail](#transformorfail)。 ### 理解输入与输出 “输出”与“输入”取决于你正在做什么(解码还是编码): **解码时:** - 源 schema `Schema` 产出 `SourceType`。 - 目标 schema `Schema` 期望得到 `TargetEncoded`。 - 解码路径如下:`SourceEncoded` → `TargetType`。 如果 `SourceType` 与 `TargetEncoded` 不同,你可以提供一个 `decode` 函数,把源 schema 的输出转换为目标 schema 的输入。 **编码时:** - 目标 schema `Schema` 产出 `TargetEncoded`。 - 源 schema `Schema` 期望得到 `SourceType`。 - 编码路径如下:`TargetType` → `SourceEncoded`。 如果 `TargetEncoded` 与 `SourceType` 不同,你可以提供一个 `encode` 函数,把目标 schema 的输出转换为源 schema 的输入。 ### 组合两个原始 schema 在这个示例中,我们从一个接受 `"on"` 或 `"off"` 的 schema 出发,把它转换为一个布尔 schema。`decode` 函数把 `"on"` 变为 `true`、把 `"off"` 变为 `false`,`encode` 函数则执行相反的操作。这样我们就得到了一个 `Schema`。 **示例**(把字符串转换为布尔值) ```ts import { Schema } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.transform( // Source schema: "on" or "off" Schema.Literal("on", "off"), // Target schema: boolean Schema.Boolean, { // optional but you get better error messages from TypeScript strict: true, // Transformation to convert the output of the // source schema ("on" | "off") into the input of the // target schema (boolean) decode: (literal) => literal === "on", // Always succeeds here // Reverse transformation encode: (bool) => (bool ? "on" : "off"), }, ) // ┌─── "on" | "off" // ▼ type Encoded = typeof BooleanFromString.Encoded // ┌─── boolean // ▼ type Type = typeof BooleanFromString.Type console.log(Schema.decodeUnknownSync(BooleanFromString)("on")) // Output: true ``` 上面的 `decode` 函数本身永远不会失败。不过,如果输入不符合源 schema,整个解码过程仍然可能失败。例如,如果你提供的是 `"wrong"` 而不是 `"on"` 或 `"off"`,源 schema 会在调用 `decode` 之前就失败。 **示例**(处理无效输入) ```ts import { Schema } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.transform( Schema.Literal("on", "off"), Schema.Boolean, { strict: true, decode: (s) => s === "on", encode: (bool) => (bool ? "on" : "off"), }, ) // Providing input not allowed by the source schema Schema.decodeUnknownSync(BooleanFromString)("wrong") /* throws: ParseError: ("on" | "off" <-> boolean) └─ Encoded side transformation failure └─ "on" | "off" ├─ Expected "on", actual "wrong" └─ Expected "off", actual "wrong" */ ``` ### 组合两个变换 schema 下面这个示例中,源 schema 与目标 schema 都会对各自的数据做变换: - 源 schema 是 `Schema.NumberFromString`,即 `Schema`。 - 目标 schema 是 `BooleanFromString`(上面已定义),即 `Schema`。 这个示例涉及四种类型,需要进行两次转换: - 解码时,把 `number` 转换为 `"on" | "off"`。例如,把任何正数都视为 `"on"`。 - 编码时,把 `"on" | "off"` 转换回 `number`。例如,把 `"on"` 视为 `1`,把 `"off"` 视为 `-1`。 通过组合这些变换,我们得到一个 schema:它能把字符串解码为布尔值,也能把布尔值编码回字符串。得到的 schema 是 `Schema`。 **示例**(组合两个变换 schema) ```ts import { Schema } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.transform( Schema.Literal("on", "off"), Schema.Boolean, { strict: true, decode: (s) => s === "on", encode: (bool) => (bool ? "on" : "off"), }, ) const BooleanFromNumericString = Schema.transform( // Source schema: Convert string -> number Schema.NumberFromString, // Target schema: Convert "on"/"off" -> boolean BooleanFromString, { strict: true, // If number is positive, use "on", otherwise "off" decode: (n) => (n > 0 ? "on" : "off"), // If boolean is "on", use 1, otherwise -1 encode: (bool) => (bool === "on" ? 1 : -1), }, ) // ┌─── string // ▼ type Encoded = typeof BooleanFromNumericString.Encoded // ┌─── boolean // ▼ type Type = typeof BooleanFromNumericString.Type console.log(Schema.decodeUnknownSync(BooleanFromNumericString)("100")) // Output: true ``` **示例**(把数组转换为 ReadonlySet) 在这个示例中,我们把一个数组转换为 `ReadonlySet`。`decode` 函数接收一个数组并创建一个新的 `ReadonlySet`,`encode` 函数则把 set 转换回数组。我们还提供了数组元素的 schema,以便它们得到正确的校验。 ```ts import { Schema } from "effect" // This function builds a schema that converts between a readonly array // and a readonly set of items const ReadonlySetFromArray = ( itemSchema: Schema.Schema, ): Schema.Schema, ReadonlyArray, R> => Schema.transform( // Source schema: array of items Schema.Array(itemSchema), // Target schema: readonly set of items // **IMPORTANT** We use `Schema.typeSchema` here to obtain the schema // of the items to avoid decoding the elements twice Schema.ReadonlySetFromSelf(Schema.typeSchema(itemSchema)), { strict: true, decode: (items) => new Set(items), encode: (set) => Array.from(set.values()), }, ) const schema = ReadonlySetFromArray(Schema.String) // ┌─── readonly string[] // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlySet // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)(["a", "b", "c"])) // Output: Set(3) { 'a', 'b', 'c' } console.log(Schema.encodeSync(schema)(new Set(["a", "b", "c"]))) // Output: [ 'a', 'b', 'c' ] ``` ### 非严格选项 在某些情况下,严格的类型检查会在数据变换期间引发问题,尤其是当类型在某些特定变换中略有差异时。为应对这类情形,`Schema.transform` 提供了 `strict: false` 选项,它会放宽类型约束,允许更灵活的变换。 **示例**(创建一个限制范围的构造器) 让我们来看这样一个场景:你需要定义一个构造器 `clamp`,用来确保数字落在特定范围内。该函数返回一个 schema,它会把数字限制在指定的最小值和最大值范围内: ```ts import { Schema, Number } from "effect" const clamp = (minimum: number, maximum: number) => (self: Schema.Schema) => Schema.transform( // Source schema self, // Target schema: filter based on min/max range self.pipe( Schema.typeSchema, Schema.filter((a) => a <= minimum || a >= maximum), ), // @errors: 2345 { strict: true, // Clamp the number within the specified range decode: (a) => Number.clamp(a, { minimum, maximum }), encode: (a) => a, }, ) ``` 在这个示例中,`Number.clamp` 返回的 `number` 可能不会被识别为具体的 `A` 类型,这在严格检查下会导致类型不匹配。 有两种方式可以解决这个问题: 1. **使用类型断言**: 添加类型转换可以强制把返回类型当作类型 `A` 处理: ```ts decode: (a) => Number.clamp(a, { minimum, maximum }) as A ``` 2. **使用非严格选项**: 在变换选项中设置 `strict: false`,可以让 schema 绕过 TypeScript 的部分类型检查规则,从而容纳这种类型差异: ```ts import { Schema, Number } from "effect" const clamp = (minimum: number, maximum: number) => (self: Schema.Schema) => Schema.transform( self, self.pipe( Schema.typeSchema, Schema.filter((a) => a >= minimum && a <= maximum), ), { strict: false, decode: (a) => Number.clamp(a, { minimum, maximum }), encode: (a) => a, }, ) ``` ## transformOrFail [Schema.transform](#transform) 函数适用于不会出错的变换, 而 `Schema.transformOrFail` 函数则面向更复杂的场景:在这些场景中,**变换 可能在解码或编码阶段失败**。 这个函数让解码/编码函数既可以返回成功结果,也可以返回错误, 因此在校验和处理那些未必总是符合预期格式的数据时特别有用。 ### 错误处理 `Schema.transformOrFail` 函数借助 ParseResult 模块来管理可能出现的错误: | 构造器 | 说明 | | --- | --- | | `ParseResult.succeed` | 表示变换成功,未发生任何错误。 | | `ParseResult.fail` | 表示变换失败,并根据所提供的 `ParseIssue` 创建一个新的 `ParseError`。 | 此外,ParseResult 模块还提供了用于处理各种解析问题类型的构造器,例如: | 解析问题类型 | 说明 | | --- | --- | | `Type` | 表示类型不匹配错误。 | | `Missing` | 在缺少必填字段时使用。 | | `Unexpected` | 用于 schema 中不允许出现的意外字段。 | | `Forbidden` | 标记解码或编码操作被 schema 禁止。 | | `Pointer` | 指向数据中发生问题的具体位置。 | | `Refinement` | 在值不满足特定 refinement 或约束时使用。 | | `Transformation` | 标记从一种类型变换为另一种类型时出现的问题。 | | `Composite` | 表示复合错误,把多个问题合并为一个,便于对错误分组。 | 这些工具支持细致而具体的错误处理,从而提升了数据处理操作的可靠性。 **示例**(把字符串转换为数字) `Schema.transformOrFail` 的一个常见用例是把数字的字符串表示转换为真正的数值类型。在处理用户输入或来自外部来源的数据时,这种场景很典型。 ```ts import { ParseResult, Schema } from "effect" export const NumberFromString = Schema.transformOrFail( // Source schema: accepts any string Schema.String, // Target schema: expects a number Schema.Number, { // optional but you get better error messages from TypeScript strict: true, decode: (input, options, ast) => { const parsed = parseFloat(input) // If parsing fails (NaN), return a ParseError with a custom error if (isNaN(parsed)) { return ParseResult.fail( // Create a Type Mismatch error new ParseResult.Type( // Provide the schema's abstract syntax tree for context ast, // Include the problematic input input, // Optional custom error message "Failed to convert string to number", ), ) } return ParseResult.succeed(parsed) }, encode: (input, options, ast) => ParseResult.succeed(input.toString()), }, ) // ┌─── string // ▼ type Encoded = typeof NumberFromString.Encoded // ┌─── number // ▼ type Type = typeof NumberFromString.Type console.log(Schema.decodeUnknownSync(NumberFromString)("123")) // Output: 123 console.log(Schema.decodeUnknownSync(NumberFromString)("-")) /* throws: ParseError: (string <-> number) └─ Transformation process failure └─ Failed to convert string to number */ ``` `decode` 与 `encode` 函数不仅会接收要变换的值(`input`),还会接收用户在使用所得 schema 时设置的 [parse 选项](/docs/v3/schema/getting-started/#parse-options),以及 `ast` —— 它表示你正在变换的 schema 的底层定义。 ### 异步变换 在现代应用中,尤其是那些需要与外部 API 交互的应用,你可能需要异步地转换数据。`Schema.transformOrFail` 通过允许你返回一个 `Effect` 来支持异步变换。 **示例**(通过 API 调用校验数据) 假设你需要通过发起 API 调用来校验一个人的 ID,可以这样实现: ```ts import { Effect, Schema, ParseResult } from "effect" // Define a function to make API requests const get = (url: string): Effect.Effect => Effect.tryPromise({ try: () => fetch(url).then((res) => { if (res.ok) { return res.json() as Promise } throw new Error(String(res.status)) }), catch: (e) => new Error(String(e)), }) // Create a branded schema for a person's ID const PeopleId = Schema.String.pipe(Schema.brand("PeopleId")) // Define a schema with async transformation const PeopleIdFromString = Schema.transformOrFail(Schema.String, PeopleId, { strict: true, decode: (s, _, ast) => // Make an API call to validate the ID Effect.mapBoth(get(`https://swapi.dev/api/people/${s}`), { // Error handling for failed API call onFailure: (e) => new ParseResult.Type(ast, s, e.message), // Return the ID if the API call succeeds onSuccess: () => s, }), encode: ParseResult.succeed, }) // ┌─── string // ▼ type Encoded = typeof PeopleIdFromString.Encoded // ┌─── string & Brand<"PeopleId"> // ▼ type Type = typeof PeopleIdFromString.Type // ┌─── never // ▼ type Context = typeof PeopleIdFromString.Context // Run a successful decode operation Effect.runPromiseExit(Schema.decodeUnknown(PeopleIdFromString)("1")).then( console.log, ) /* Output: { _id: 'Exit', _tag: 'Success', value: '1' } */ // Run a decode operation that will fail Effect.runPromiseExit(Schema.decodeUnknown(PeopleIdFromString)("fail")).then( console.log, ) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _id: 'ParseError', message: '(string <-> string & Brand<"PeopleId">)\n' + '└─ Transformation process failure\n' + ' └─ Error: 404' } } } */ ``` ### 声明依赖 当你的变换依赖外部 service 时,可以在 `decode` 或 `encode` 函数中注入这些 service。这些依赖随后会被记录在 schema 的 `Requirements` 通道中: ```text Schema ``` **示例**(用 service 校验数据) ```ts import { Context, Effect, Schema, ParseResult, Layer } from "effect" // Define a Validation service for dependency injection class Validation extends Context.Tag("Validation")< Validation, { readonly validatePeopleid: (s: string) => Effect.Effect } >() {} // Create a branded schema for a person's ID const PeopleId = Schema.String.pipe(Schema.brand("PeopleId")) // Transform a string into a validated PeopleId, // using an external validation service const PeopleIdFromString = Schema.transformOrFail(Schema.String, PeopleId, { strict: true, decode: (s, _, ast) => // Asynchronously validate the ID using the injected service Effect.gen(function* () { // Access the validation service const validator = yield* Validation // Use service to validate ID yield* validator.validatePeopleid(s) return s }).pipe(Effect.mapError((e) => new ParseResult.Type(ast, s, e.message))), encode: ParseResult.succeed, // Encode by simply returning the string }) // ┌─── string // ▼ type Encoded = typeof PeopleIdFromString.Encoded // ┌─── string & Brand<"PeopleId"> // ▼ type Type = typeof PeopleIdFromString.Type // ┌─── Validation // ▼ type Context = typeof PeopleIdFromString.Context // Layer to provide a successful validation service const SuccessTest = Layer.succeed(Validation, { validatePeopleid: (_) => Effect.void, }) // Run a successful decode operation Effect.runPromiseExit( Schema.decodeUnknown(PeopleIdFromString)("1").pipe( Effect.provide(SuccessTest), ), ).then(console.log) /* Output: { _id: 'Exit', _tag: 'Success', value: '1' } */ // Layer to provide a failing validation service const FailureTest = Layer.succeed(Validation, { validatePeopleid: (_) => Effect.fail(new Error("404")), }) // Run a decode operation that will fail Effect.runPromiseExit( Schema.decodeUnknown(PeopleIdFromString)("fail").pipe( Effect.provide(FailureTest), ), ).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: { _id: 'ParseError', message: '(string <-> string & Brand<"PeopleId">)\n' + '└─ Transformation process failure\n' + ' └─ Error: 404' } } } */ ``` ## 禁止编码的单向变换 在某些情况下,把值编码回它原本的形式可能没有意义,也可能并不希望如此。你可以用 `Schema.transformOrFail` 定义一个单向变换,并在编码过程中显式返回 `Forbidden` 解析错误。这样就保证了:一旦某个值被变换,就无法再还原为它原本的形式。 **示例**(带禁止编码的密码哈希) 设想这样一个场景:你需要对用户的明文密码做哈希,以便安全存储。关键在于,哈希后的密码不能被逆向还原为明文。通过 `Schema.transformOrFail`,你可以强制实施这一限制,从而确保从明文到哈希密码的单向变换。 ```ts import { Schema, ParseResult, Redacted } from "effect" import { createHash } from "node:crypto" // Define a schema for plain text passwords // with a minimum length requirement const PlainPassword = Schema.String.pipe( Schema.minLength(6), Schema.brand("PlainPassword", { identifier: "PlainPassword" }), ) // Define a schema for hashed passwords as a separate branded type const HashedPassword = Schema.String.pipe( Schema.brand("HashedPassword", { identifier: "HashedPassword" }), ) // Define a one-way transformation from plain passwords to hashed passwords export const PasswordHashing = Schema.transformOrFail( PlainPassword, // Wrap the output in Redacted for added safety Schema.RedactedFromSelf(HashedPassword), { strict: true, // Decode: Transform a plain password into a hashed password decode: (plainPassword) => { const hash = createHash("sha256").update(plainPassword).digest("hex") // Wrap the hash in Redacted return ParseResult.succeed(Redacted.make(hash)) }, // Encode: Forbid reversing the hashed password back to plain text encode: (hashedPassword, _, ast) => ParseResult.fail( new ParseResult.Forbidden( ast, hashedPassword, "Encoding hashed passwords back to plain text is forbidden.", ), ), }, ) // ┌─── string // ▼ type Encoded = typeof PasswordHashing.Encoded // ┌─── Redacted> // ▼ type Type = typeof PasswordHashing.Type // Example: Decoding a plain password into a hashed password console.log(Schema.decodeUnknownSync(PasswordHashing)("myPlainPassword123")) // Output: // Example: Attempting to encode a hashed password back to plain text console.log( Schema.encodeUnknownSync(PasswordHashing)(Redacted.make("2ef2b7...")), ) /* throws: ParseError: (PlainPassword <-> Redacted()) └─ Transformation process failure └─ (PlainPassword <-> Redacted()) └─ Encoding hashed passwords back to plain text is forbidden. */ ``` ## 组合 在复杂应用中,经常需要组合并复用 schema,而 `Schema.compose` 组合子提供了一种高效的做法。借助 `Schema.compose`,你可以把两个 schema —— `Schema` 与 `Schema` —— 串联成单个 schema `Schema`: **示例**(组合 schema,把带分隔符的字符串解析为数字) ```ts import { Schema } from "effect" // Schema to split a string by commas into an array of strings // // ┌─── Schema // ▼ const schema1 = Schema.asSchema(Schema.split(",")) // Schema to convert an array of strings to an array of numbers // // ┌─── Schema // ▼ const schema2 = Schema.asSchema(Schema.Array(Schema.NumberFromString)) // Composed schema that takes a string, splits it by commas, // and converts the result into an array of numbers // // ┌─── Schema // ▼ const ComposedSchema = Schema.asSchema(Schema.compose(schema1, schema2)) ``` ### 非严格选项 在组合 schema 时,你可能会遇到某个 schema 的输出与下一个 schema 的输入并不完全匹配的情况。例如,你有 `Schema` 与 `Schema`,而 `C` 与 `B` 不同。要处理这类情况,可以用 `{ strict: false }` 选项放宽类型约束。 **示例**(在组合中使用非严格选项) ```ts import { Schema } from "effect" // Without the `strict: false` option, // this composition raises a TypeScript error Schema.compose( // @errors: 2769 Schema.Union(Schema.Null, Schema.Literal("0")), Schema.NumberFromString, ) // Use `strict: false` to allow type flexibility Schema.compose( Schema.Union(Schema.Null, Schema.Literal("0")), Schema.NumberFromString, { strict: false, }, ) ``` ## 带副作用的过滤器 `Schema.filterEffect` 函数支持那些需要异步或动态场景的校验,因此适用于校验过程中带有副作用的情况,例如网络请求或数据库查询。对于简单的同步校验,请参阅 [`Schema.filter`](/docs/v3/schema/filters/#declaring-filters)。 **示例**(异步校验用户名) ```ts import { Effect, Schema } from "effect" // Mock async function to validate a username async function validateUsername(username: string) { return Promise.resolve(username === "gcanti") } // Define a schema with an effectful filter const ValidUsername = Schema.String.pipe( Schema.filterEffect((username) => Effect.promise(() => // Validate the username asynchronously, // returning an error message if invalid validateUsername(username).then((valid) => valid || "Invalid username"), ), ), ).annotations({ identifier: "ValidUsername" }) Effect.runPromise(Schema.decodeUnknown(ValidUsername)("xxx")).then(console.log) /* ParseError: ValidUsername └─ Transformation process failure └─ Invalid username */ ``` ## 字符串转换 ### split 按指定的分隔符把字符串拆分为子字符串数组。 **示例**(按逗号拆分字符串) ```ts import { Schema } from "effect" const schema = Schema.split(",") const decode = Schema.decodeUnknownSync(schema) console.log(decode("")) // [""] console.log(decode(",")) // ["", ""] console.log(decode("a,")) // ["a", ""] console.log(decode("a,b")) // ["a", "b"] ``` ### Trim 去掉字符串开头和结尾的空白字符。 **示例**(去除空白字符) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Trim) console.log(decode("a")) // "a" console.log(decode(" a")) // "a" console.log(decode("a ")) // "a" console.log(decode(" a ")) // "a" ``` ### Lowercase 把字符串转换为小写。 **示例**(转换为小写) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Lowercase) console.log(decode("A")) // "a" console.log(decode(" AB")) // " ab" console.log(decode("Ab ")) // "ab " console.log(decode(" ABc ")) // " abc " ``` ### Uppercase 把字符串转换为大写。 **示例**(转换为大写) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Uppercase) console.log(decode("a")) // "A" console.log(decode(" ab")) // " AB" console.log(decode("aB ")) // "AB " console.log(decode(" abC ")) // " ABC " ``` ### Capitalize 把字符串的第一个字符转换为大写。 **示例**(把字符串首字母大写) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Capitalize) console.log(decode("aa")) // "Aa" console.log(decode(" ab")) // " ab" console.log(decode("aB ")) // "AB " console.log(decode(" abC ")) // " abC " ``` ### Uncapitalize 把字符串的第一个字符转换为小写。 **示例**(把字符串首字母小写) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Uncapitalize) console.log(decode("AA")) // "aA" console.log(decode(" AB")) // " AB" console.log(decode("Ab ")) // "ab " console.log(decode(" AbC ")) // " AbC " ``` ### parseJson `Schema.parseJson` 构造函数提供了一种方法:借助 `JSON.parse` 的底层能力把 JSON 字符串转换为 `unknown` 类型。它在编码时还会使用 `JSON.stringify`。 **示例**(解析 JSON 字符串) ```ts import { Schema } from "effect" const schema = Schema.parseJson() const decode = Schema.decodeUnknownSync(schema) // Parse valid JSON strings console.log(decode("{}")) // Output: {} console.log(decode(`{"a":"b"}`)) // Output: { a: "b" } // Attempting to decode an empty string results in an error decode("") /* throws: ParseError: (JsonString <-> unknown) └─ Transformation process failure └─ Unexpected end of JSON input */ ``` 若要进一步约束 JSON 解析的结果,你可以给 `Schema.parseJson` 构造函数传入一个 schema。这个 schema 会校验解析出的 JSON 是否符合特定结构。 **示例**(带结构校验的 JSON 解析) 在这个例子中,`Schema.parseJson` 使用一个 struct schema 来确保解析出的 JSON 是一个带有数值属性 `a` 的对象。这为解析出的数据加上了校验,确认它符合预期的结构。 ```ts import { Schema } from "effect" // ┌─── SchemaClass<{ readonly a: number; }, string, never> // ▼ const schema = Schema.parseJson(Schema.Struct({ a: Schema.Number })) ``` ### StringFromBase64 把 base64(RFC4648)编码的字符串解码为 UTF-8 字符串。 **示例**(解码 Base64) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromBase64) console.log(decode("Zm9vYmFy")) // Output: "foobar" ``` ### StringFromBase64Url 把 base64(URL)编码的字符串解码为 UTF-8 字符串。 **示例**(解码 Base64 URL) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromBase64Url) console.log(decode("Zm9vYmFy")) // Output: "foobar" ``` ### StringFromHex 把十六进制编码的字符串解码为 UTF-8 字符串。 **示例**(解码十六进制字符串) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromHex) console.log(new TextEncoder().encode(decode("0001020304050607"))) /* Output: Uint8Array(8) [ 0, 1, 2, 3, 4, 5, 6, 7 ] */ ``` ### StringFromUriComponent 把 URI 编码的字符串解码为 UTF-8 字符串。它适合在 URL 中编码与解码数据。 **示例**(解码 URI 组件) ```ts import { Schema } from "effect" const PaginationSchema = Schema.Struct({ maxItemPerPage: Schema.Number, page: Schema.Number, }) const UrlSchema = Schema.compose( Schema.StringFromUriComponent, Schema.parseJson(PaginationSchema), ) console.log(Schema.encodeSync(UrlSchema)({ maxItemPerPage: 10, page: 1 })) // Output: %7B%22maxItemPerPage%22%3A10%2C%22page%22%3A1%7D ``` ## 数字转换 ### NumberFromString 使用 `effect/Number` 模块的 `parse` 函数解析字符串,从而把字符串变换为数字。 如果值无法转换(例如提供了非数字字符),它会返回错误。 支持以下特殊字符串值:"NaN"、"Infinity"、"-Infinity"。 **示例**(从字符串解析数字) ```ts import { Schema } from "effect" const schema = Schema.NumberFromString const decode = Schema.decodeUnknownSync(schema) // success cases console.log(decode("1")) // 1 console.log(decode("-1")) // -1 console.log(decode("1.5")) // 1.5 console.log(decode("NaN")) // NaN console.log(decode("Infinity")) // Infinity console.log(decode("-Infinity")) // -Infinity // failure cases decode("a") /* throws: ParseError: NumberFromString └─ Transformation process failure └─ Expected NumberFromString, actual "a" */ ``` ### clamp 把数字限制在指定范围内。 **示例**(限制数字范围) ```ts import { Schema } from "effect" // clamps the input to -1 <= x <= 1 const schema = Schema.Number.pipe(Schema.clamp(-1, 1)) const decode = Schema.decodeUnknownSync(schema) console.log(decode(-3)) // -1 console.log(decode(0)) // 0 console.log(decode(3)) // 1 ``` ### parseNumber 使用 `effect/Number` 模块的 `parse` 函数解析字符串,从而把字符串变换为数字。 如果值无法转换(例如提供了非数字字符),它会返回错误。 支持以下特殊字符串值:"NaN"、"Infinity"、"-Infinity"。 **示例**(解析并校验数字) ```ts import { Schema } from "effect" const schema = Schema.String.pipe(Schema.parseNumber) const decode = Schema.decodeUnknownSync(schema) console.log(decode("1")) // 1 console.log(decode("Infinity")) // Infinity console.log(decode("NaN")) // NaN console.log(decode("-")) /* throws ParseError: (string <-> number) └─ Transformation process failure └─ Expected (string <-> number), actual "-" */ ``` ## 布尔转换 ### Not 对布尔值取反。 **示例**(对布尔值取反) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Not) console.log(decode(true)) // false console.log(decode(false)) // true ``` ## Symbol 转换 ### Symbol 使用 `Symbol.for` 把字符串转换为 symbol。 **示例**(从字符串创建 symbol) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Symbol) console.log(decode("a")) // Symbol(a) ``` ## BigInt 转换 ### BigInt 使用 `BigInt` 构造器把字符串转换为 `BigInt`。 **示例**(从字符串解析 BigInt) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigInt) // success cases console.log(decode("1")) // 1n console.log(decode("-1")) // -1n // failure cases decode("a") /* throws: ParseError: bigint └─ Transformation process failure └─ Expected bigint, actual "a" */ decode("1.5") // throws decode("NaN") // throws decode("Infinity") // throws decode("-Infinity") // throws ``` ### BigIntFromNumber 使用 `BigInt` 构造器把数字转换为 `BigInt`。 **示例**(从数字解析 BigInt) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigIntFromNumber) const encode = Schema.encodeSync(Schema.BigIntFromNumber) // success cases console.log(decode(1)) // 1n console.log(decode(-1)) // -1n console.log(encode(1n)) // 1 console.log(encode(-1n)) // -1 // failure cases decode(1.5) /* throws: ParseError: BigintFromNumber └─ Transformation process failure └─ Expected BigintFromNumber, actual 1.5 */ decode(NaN) // throws decode(Infinity) // throws decode(-Infinity) // throws encode(BigInt(Number.MAX_SAFE_INTEGER) + 1n) // throws encode(BigInt(Number.MIN_SAFE_INTEGER) - 1n) // throws ``` ### clampBigInt 把 `BigInt` 限制在指定范围内。 **示例**(限制 BigInt 范围) ```ts import { Schema } from "effect" // clamps the input to -1n <= x <= 1n const schema = Schema.BigIntFromSelf.pipe(Schema.clampBigInt(-1n, 1n)) const decode = Schema.decodeUnknownSync(schema) console.log(decode(-3n)) // Output: -1n console.log(decode(0n)) // Output: 0n console.log(decode(3n)) // Output: 1n ``` ## Date 转换 ### Date 把字符串转换为**合法的** `Date`,确保 `new Date("Invalid Date")` 这类非法日期会被拒绝。 **示例**(解析并校验日期) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Date) console.log(decode("1970-01-01T00:00:00.000Z")) // Output: 1970-01-01T00:00:00.000Z decode("a") /* throws: ParseError: Date └─ Predicate refinement failure └─ Expected Date, actual Invalid Date */ const validate = Schema.validateSync(Schema.Date) console.log(validate(new Date(0))) // Output: 1970-01-01T00:00:00.000Z console.log(validate(new Date("Invalid Date"))) /* throws: ParseError: Date └─ Predicate refinement failure └─ Expected Date, actual Invalid Date */ ``` ## BigDecimal 转换 ### BigDecimal 把字符串转换为 `BigDecimal`。 **示例**(从字符串解析 BigDecimal) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigDecimal) console.log(decode(".124")) // Output: { _id: 'BigDecimal', value: '124', scale: 3 } ``` ### BigDecimalFromNumber 把数字转换为 `BigDecimal`。 **示例**(从数字解析 BigDecimal) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigDecimalFromNumber) console.log(decode(0.111)) // Output: { _id: 'BigDecimal', value: '111', scale: 3 } ``` ### clampBigDecimal 把 `BigDecimal` 限制在指定范围内。 **示例**(限制 BigDecimal 范围) ```ts import { Schema } from "effect" import { BigDecimal } from "effect" const schema = Schema.BigDecimal.pipe( Schema.clampBigDecimal(BigDecimal.fromNumber(-1), BigDecimal.fromNumber(1)), ) const decode = Schema.decodeUnknownSync(schema) console.log(decode("-2")) // Output: { _id: 'BigDecimal', value: '-1', scale: 0 } console.log(decode("0")) // Output: { _id: 'BigDecimal', value: '0', scale: 0 } console.log(decode("3")) // Output: { _id: 'BigDecimal', value: '1', scale: 0 } ``` --- # Sink 并发 > 了解如何通过并发的 sink 操作提升性能,例如合并结果,或竞速以捕获最先完成者。 本节介绍并发操作,它们允许多个 sink 同时运行。当你确实需要并发执行时,这些操作对于提升任务性能很有价值。 ## 通过并发 zip 合并结果 要并发运行两个 sink 并合并它们的结果,可以使用 `Sink.zip`。该操作会并发执行两个 sink,并把它们的结果合并成一个元组。 **示例**(并发运行两个 Sink 并合并结果) ```ts import { Sink, Console, Stream, Schedule, Effect } from "effect" const stream = Stream.make("1", "2", "3", "4", "5").pipe( Stream.schedule(Schedule.spaced("10 millis")), ) const sink1 = Sink.forEach((s: string) => Console.log(`sink 1: ${s}`)).pipe( Sink.as(1), ) const sink2 = Sink.forEach((s: string) => Console.log(`sink 2: ${s}`)).pipe( Sink.as(2), ) // Combine the two sinks to run concurrently and collect results in a tuple const sink = Sink.zip(sink1, sink2, { concurrent: true }) Effect.runPromise(Stream.run(stream, sink)).then(console.log) /* Output: sink 1: 1 sink 2: 1 sink 1: 2 sink 2: 2 sink 1: 3 sink 2: 3 sink 1: 4 sink 2: 4 sink 1: 5 sink 2: 5 [ 1, 2 ] */ ``` ## 竞速 Sink:最先完成者胜出 `Sink.race` 操作允许多个 sink 竞争完成。最先完成的那个 sink 提供结果。 **示例**(让两个 Sink 竞速以捕获最先产生的结果) ```ts import { Sink, Console, Stream, Schedule, Effect } from "effect" const stream = Stream.make("1", "2", "3", "4", "5").pipe( Stream.schedule(Schedule.spaced("10 millis")), ) const sink1 = Sink.forEach((s: string) => Console.log(`sink 1: ${s}`)).pipe( Sink.as(1), ) const sink2 = Sink.forEach((s: string) => Console.log(`sink 2: ${s}`)).pipe( Sink.as(2), ) // Race the two sinks, the result will be from the first to complete const sink = Sink.race(sink1, sink2) Effect.runPromise(Stream.run(stream, sink)).then(console.log) /* Output: sink 1: 1 sink 2: 1 sink 1: 2 sink 2: 2 sink 1: 3 sink 2: 3 sink 1: 4 sink 2: 4 sink 1: 5 sink 2: 5 1 */ ``` --- # 创建 Sink > 了解如何创建和使用各种用于处理 Stream 的 Sink,包括计数、求和、收集、折叠,以及处理成功与失败。 在 Stream 处理中,`Sink` 用于消费和处理来自 stream 的元素。本节将探索各种 Sink 构造函数,它们让你可以为特定任务创建 `Sink`。 ## 常用构造函数 ### head `Sink.head` 只取 stream 的第一个元素,并用 `Some` 包装它。如果 stream 没有任何元素,则返回 `None`。 **示例**(获取第一个元素) ```ts import { Stream, Sink, Effect } from "effect" const nonEmptyStream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(nonEmptyStream, Sink.head())).then(console.log) /* Output: { _id: 'Option', _tag: 'Some', value: 1 } */ const emptyStream = Stream.empty Effect.runPromise(Stream.run(emptyStream, Sink.head())).then(console.log) /* Output: { _id: 'Option', _tag: 'None' } */ ``` ### last `Sink.last` 只取 stream 的最后一个元素,并用 `Some` 包装它。如果 stream 没有任何元素,则返回 `None`。 **示例**(获取最后一个元素) ```ts import { Stream, Sink, Effect } from "effect" const nonEmptyStream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(nonEmptyStream, Sink.last())).then(console.log) /* Output: { _id: 'Option', _tag: 'Some', value: 4 } */ const emptyStream = Stream.empty Effect.runPromise(Stream.run(emptyStream, Sink.last())).then(console.log) /* Output: { _id: 'Option', _tag: 'None' } */ ``` ### count `Sink.count` 会消费 stream 的所有元素,并统计传给它的元素数量。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.count)).then(console.log) // Output: 4 ``` ### sum `Sink.sum` 会消费 stream 的所有元素,并对传入的数值求和。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.sum)).then(console.log) // Output: 10 ``` ### take `Sink.take` 会从 stream 中取出指定数量的值,结果是一个 [Chunk](/docs/v3/data-types/chunk/) 数据类型。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.take(3))).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3 ] } */ ``` ### drain `Sink.drain` 会忽略它的输入,实际上就是把它们丢弃。 ```ts import { Stream, Console, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4).pipe(Stream.tap(Console.log)) Effect.runPromise(Stream.run(stream, Sink.drain)).then(console.log) /* Output: 1 2 3 4 undefined */ ``` ### timed `Sink.timed` 会执行 stream 并测量其执行时间,返回一个 [Duration](/docs/v3/data-types/duration/)。 ```ts import { Stream, Schedule, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) Effect.runPromise(Stream.run(stream, Sink.timed)).then(console.log) /* Output: { _id: 'Duration', _tag: 'Millis', millis: 408 } */ ``` ### forEach `Sink.forEach` 会针对传给它的每个元素执行所提供的 effect 函数。 ```ts import { Stream, Console, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.forEach(Console.log))).then( console.log, ) /* Output: 1 2 3 4 undefined */ ``` ## 从成功与失败创建 Sink 正如你可以定义 stream 来保存或操作数据,你也可以使用 `Sink.fail` 和 `Sink.succeed` 函数创建具有特定成功或失败结果的 `Sink`。 ### 成功的 Sink 下面的示例创建了一个 `Sink`:它不消费上游源中的任何元素,而是立即以一个指定的数值成功结束: **示例**(总是以某个值成功的 Sink) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.succeed(0))).then(console.log) // Output: 0 ``` ### 失败的 Sink 在这个示例中,这个 `Sink` 同样不消费上游源中的任何元素。相反,它以 `string` 类型的指定错误消息失败: **示例**(总是以错误消息失败的 Sink) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromiseExit(Stream.run(stream, Sink.fail("fail!"))).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'fail!' } } */ ``` ## 收集 ### 收集所有元素 要把数据流中的所有元素汇总到一个 [Chunk](/docs/v3/data-types/chunk/) 中,可以使用 `Sink.collectAll`。 最终输出是一个 Chunk,按元素被发出的顺序包含 stream 中的所有元素。 **示例**(收集 Stream 中的所有元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise(Stream.run(stream, Sink.collectAll())).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3, 4 ] } */ ``` ### 收集指定数量 要把 stream 中固定数量的元素收集到一个 [Chunk](/docs/v3/data-types/chunk/) 中,可以使用 `Sink.collectAllN`。这个 Sink 在达到指定上限后就停止收集。 **示例**(收集有限数量的元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) Effect.runPromise( Stream.run( stream, // Collect the first 3 elements into a Chunk Sink.collectAllN(3), ), ).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3 ] } */ ``` ### 在满足条件时收集 要在元素满足特定条件时从 stream 中收集它们,可以使用 `Sink.collectAllWhile`。这个 Sink 会持续收集元素,直到给定的谓词返回 `false`。 **示例**(收集元素直到条件不再满足) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 0, 4, 0, 6, 7) Effect.runPromise( Stream.run( stream, // Collect elements while they are not equal to 0 Sink.collectAllWhile((n) => n !== 0), ), ).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2 ] } */ ``` ### 收集到 HashSet 要把 stream 的元素累积到一个 `HashSet` 中,可以使用 `Sink.collectAllToSet()`。这样可以确保每个元素在最终集合中只出现一次。 **示例**(把去重后的元素收集到 HashSet) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 2, 3, 4, 4) Effect.runPromise(Stream.run(stream, Sink.collectAllToSet())).then(console.log) /* Output: { _id: 'HashSet', values: [ 1, 2, 3, 4 ] } */ ``` ### 收集到指定大小的 HashSet 如果需要以受控方式把元素收集到有指定最大大小的 `HashSet` 中,可以使用 `Sink.collectAllToSetN`。这个 Sink 会收集去重后的元素,直到达到给定的上限。 **示例**(在限制集合大小的情况下收集去重元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 2, 3, 4, 4) Effect.runPromise( Stream.run( stream, // Collect unique elements, limiting the set size to 3 Sink.collectAllToSetN(3), ), ).then(console.log) /* Output: { _id: 'HashSet', values: [ 1, 2, 3 ] } */ ``` ### 收集到 HashMap 对于更复杂的收集场景,`Sink.collectAllToMap` 让你可以把元素收集到一个 `HashMap` 中,并指定 key 策略与合并策略。这个 Sink 既需要一个 key 函数来定义每个元素的分组,也需要一个合并函数来合并共享同一个 key 的值。 **示例**(在 HashMap 中分组并合并 Stream 元素) 在这个示例中,我们用 `(n) => n % 3` 确定 map 的 key,用 `(a, b) => a + b` 合并具有相同 key 的元素: ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 3, 2, 3, 1, 5, 1) Effect.runPromise( Stream.run( stream, Sink.collectAllToMap( (n) => n % 3, // Key function to group by element value (a, b) => a + b, // Merge function to sum values with the same key ), ), ).then(console.log) /* Output: { _id: 'HashMap', values: [ [ 0, 6 ], [ 1, 3 ], [ 2, 7 ] ] } */ ``` ### 收集到 key 数量受限的 HashMap 要把元素累积到一个 key 数量有上限的 `HashMap` 中,可以使用 `Sink.collectAllToMapN`。这个 Sink 会一直收集元素,直到达到指定的 key 上限;它需要一个 key 函数来定义每个元素的分组,以及一个合并函数来合并具有相同 key 的值。 **示例**(限制 HashMap 中收集的 key 数量) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 3, 2, 3, 1, 5, 1) Effect.runPromise( Stream.run( stream, Sink.collectAllToMapN( 3, // Maximum of 3 keys (n) => n, // Key function to group by element value (a, b) => a + b, // Merge function to sum values with the same key ), ), ).then(console.log) /* Output: { _id: 'HashMap', values: [ [ 1, 2 ], [ 2, 2 ], [ 3, 6 ] ] } */ ``` ## 折叠 ### 左折叠 如果你想按顺序对每个元素应用一个操作,把 stream 归约为单个累积值,可以使用 `Sink.foldLeft` 函数。 **示例**(用 foldLeft 对 Stream 中的元素求和) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) Effect.runPromise( Stream.run( stream, // Use foldLeft to sequentially add each element, starting with 0 Sink.foldLeft(0, (a, b) => a + b), ), ).then(console.log) // Output: 10 ``` ### 带终止条件的折叠 有时,你可能想折叠 stream 中的元素,但在满足某个特定条件时就停止这一过程。这被称为“短路”(short-circuiting)。你可以用 `Sink.fold` 函数做到这一点,它允许你定义终止条件。 **示例**(带提前停止条件的折叠) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.iterate(0, (n) => n + 1) Effect.runPromise( Stream.run( stream, Sink.fold( 0, // Initial value (sum) => sum <= 10, // Termination condition (a, b) => a + b, // Folding operation ), ), ).then(console.log) // Output: 15 ``` ### 折叠到某个上限 要累积元素直到达到特定数量,可以使用 `Sink.foldUntil`。这个 Sink 会一直折叠元素,直到达到指定上限,然后停止。 **示例**(累积固定数量的元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) Effect.runPromise( Stream.run( stream, // Fold elements, stopping after accumulating 3 values Sink.foldUntil(0, 3, (a, b) => a + b), ), ).then(console.log) // Output: 6 ``` ### 带权重元素的折叠 在某些场景中,你可能希望按定义好的“权重”(weight)或“代价”(cost)来折叠元素,累积元素直到达到指定的最大代价。你可以用 `Sink.foldWeighted` 做到这一点。 **示例**(按权重累积元素) 在下面的示例中,每个元素的权重都是 `1`,当累积权重达到 `3` 时折叠就会重新开始。 ```ts import { Stream, Sink, Chunk, Effect } from "effect" const stream = Stream.make(3, 2, 4, 1, 5, 6, 2, 1, 3, 5, 6).pipe( Stream.transduce( Sink.foldWeighted({ initial: Chunk.empty(), // Initial empty Chunk maxCost: 3, // Maximum accumulated cost cost: () => 1, // Each element has a weight of 1 body: (acc, el) => Chunk.append(acc, el), // Append element to the Chunk }), ), ) Effect.runPromise(Stream.runCollect(stream)).then((chunk) => console.log("%o", chunk), ) /* Output: { _id: 'Chunk', values: [ { _id: 'Chunk', values: [ 3, 2, 4, [length]: 3 ] }, { _id: 'Chunk', values: [ 1, 5, 6, [length]: 3 ] }, { _id: 'Chunk', values: [ 2, 1, 3, [length]: 3 ] }, { _id: 'Chunk', values: [ 5, 6, [length]: 2 ] }, [length]: 4 ] } */ ``` --- # 简介 > 了解 Sink 在 Stream 处理中的角色:处理元素的消费、错误管理、结果的产出以及剩余元素。 在 Stream 处理中,`Sink` 是一种用于消费 `Stream` 所生成元素的结构。 ```text ┌─── Type of the result produced by the Sink | ┌─── Type of elements consumed by the Sink | | ┌─── Type of any leftover elements │ | | ┌─── Type of possible errors │ │ | | ┌─── Type of required dependencies ▼ ▼ ▼ ▼ ▼ Sink ``` 下面是 `Sink` 所做事情的总览: - 它会消费数量不定的 `In` 元素,这个数量可以是零个、一个或多个。 - 它在处理过程中可能遇到 `E` 类型的错误。 - 它在处理完成后会产出一个 `A` 类型的结果。 - 它还可能返回 `L` 类型的剩余部分,表示任何未被消费的元素。 要使用 `Sink` 处理一个 stream,你可以把它直接传给 `Stream.run` 函数: **示例**(使用 Sink 收集 Stream 元素) ```ts import { Stream, Sink, Effect } from "effect" // ┌─── Stream // ▼ const stream = Stream.make(1, 2, 3) // Create a sink to take the first 2 elements of the stream // // ┌─── Sink, number, number, never, never> // ▼ const sink = Sink.take(2) // Run the stream through the sink to collect the elements // // ┌─── Effect // ▼ const sum = Stream.run(stream, sink) Effect.runPromise(sum).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2 ] } */ ``` `sink` 的类型如下: ```text ┌─── result | ┌─── consumed elements | | ┌─── leftover elements │ | | ┌─── no errors │ │ | | ┌─── no dependencies ▼ ▼ ▼ ▼ ▼ Sink, number, number, never, never> ``` 下面逐项说明: - `Chunk`:Sink 处理完元素后产出的最终结果(在本例中,是一个由数字组成的 [Chunk](/docs/v3/data-types/chunk/))。 - `number`(第一次出现):Sink 将从 stream 中消费的元素类型。 - `number`(第二次出现):未被消费的剩余元素(如果有的话)的类型。 - `never`(第一次出现):表示这个 Sink 不会产生任何错误。 - `never`(第二次出现):表示运行这个 Sink 不需要任何依赖。 --- # 剩余元素 > 学习如何处理 Stream 中未被消费的元素:收集或忽略剩余元素,从而实现高效的数据处理。 在本节中,我们将探讨如何处理 Sink 未消费、被留下的元素。Sink 可能只处理上游源中的一部分元素,而把其余元素留作「剩余元素」(leftovers)。下面介绍如何收集或忽略这些剩余元素。 ## 收集剩余元素 如果 Sink 没有消费上游源中的所有元素,那么剩下的元素就称为剩余元素(leftovers)。若要捕获这些剩余元素,可以使用 `Sink.collectLeftover`,它返回一个元组,其中包含 Sink 操作的结果以及所有未消费的元素。 **示例**(收集剩余元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) // Take the first 3 elements and collect any leftovers const sink1 = Sink.take(3).pipe(Sink.collectLeftover) Effect.runPromise(Stream.run(stream, sink1)).then(console.log) /* Output: [ { _id: 'Chunk', values: [ 1, 2, 3 ] }, { _id: 'Chunk', values: [ 4, 5 ] } ] */ // Take only the first element and collect the rest as leftovers const sink2 = Sink.head().pipe(Sink.collectLeftover) Effect.runPromise(Stream.run(stream, sink2)).then(console.log) /* Output: [ { _id: 'Option', _tag: 'Some', value: 1 }, { _id: 'Chunk', values: [ 2, 3, 4, 5 ] } ] */ ``` ## 忽略剩余元素 如果不需要这些剩余元素,可以用 `Sink.ignoreLeftover` 忽略它们。这种做法会丢弃所有未消费的元素,让 Sink 操作只关注它需要的元素。 **示例**(忽略剩余元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) // Take the first 3 elements and ignore any remaining elements const sink = Sink.take(3).pipe( Sink.ignoreLeftover, Sink.collectLeftover, ) Effect.runPromise(Stream.run(stream, sink)).then(console.log) /* Output: [ { _id: 'Chunk', values: [ 1, 2, 3 ] }, { _id: 'Chunk', values: [] } ] */ ``` --- # Sink 操作 > 探索用于变换、过滤和适配 Sink 的操作,从而在 Stream 处理中实现自定义的输入输出处理与元素过滤。 在前面几节中,我们学习了如何创建和使用 Sink。现在,让我们来探索一些可以变换或过滤 Sink 行为的操作。 ## 适配 Sink 的输入 有时,你的 Sink 处理的是一种输入类型,而当前的 stream 使用的是另一种类型。`Sink.mapInput` 函数通过变换输入值,帮助你让 Sink 适配新的输入类型。`Sink.map` 改变的是 Sink 的输出,而 `Sink.mapInput` 改变的是它接受的输入。 **示例**(将字符串输入转换为数值以便求和) 假设你有一个用于计算数字之和的 `Sink.sum`。如果你的 stream 中包含的是字符串而不是数字,那么 `Sink.mapInput` 可以把这些字符串转换为数字,从而让 `Sink.sum` 能与你的 stream 配合工作: ```ts import { Stream, Sink, Effect } from "effect" // A stream of numeric strings const stream = Stream.make("1", "2", "3", "4", "5") // Define a sink for summing numeric values const numericSum = Sink.sum // Use mapInput to adapt the sink, converting strings to numbers const stringSum = numericSum.pipe( Sink.mapInput((s: string) => Number.parseFloat(s)), ) Effect.runPromise(Stream.run(stream, stringSum)).then(console.log) // Output: 15 ``` ## 同时变换输入与输出 当你需要同时变换 Sink 的输入和输出时,`Sink.dimap` 提供了一个灵活的解决方案。它扩展了 `mapInput`:允许你先变换输入类型、执行操作,再把输出变换为新类型。这在需要在输入类型和输出类型之间做完整转换时很有用。 **示例**(将输入转换为整数、求和,再把输出转换为字符串) ```ts import { Stream, Sink, Effect } from "effect" // A stream of numeric strings const stream = Stream.make("1", "2", "3", "4", "5") // Convert string inputs to numbers, sum them, // then convert the result to a string const sumSink = Sink.dimap(Sink.sum, { // Transform input: string to number onInput: (s: string) => Number.parseFloat(s), // Transform output: number to string onDone: (n) => String(n), }) Effect.runPromise(Stream.run(stream, sumSink)).then(console.log) // Output: "15" ``` ## 过滤输入 Sink 还可以借助 `Sink.filterInput` 按特定条件过滤传入的元素。这个操作让 Sink 只处理满足特定条件的元素。 **示例**(按每三个一组过滤负数) 在下面的示例中,元素被收集为每三个一组,但只有正数会被包含进来: ```ts import { Stream, Sink, Effect } from "effect" // Define a stream with positive, negative, and zero values const stream = Stream.fromIterable([ 1, -2, 0, 1, 3, -3, 4, 2, 0, 1, -3, 1, 1, 6, ]).pipe( Stream.transduce( // Collect chunks of 3, filtering out non-positive numbers Sink.collectAllN(3).pipe(Sink.filterInput((n) => n > 0)), ), ) Effect.runPromise(Stream.runCollect(stream)).then((chunk) => console.log("%o", chunk), ) /* Output: { _id: 'Chunk', values: [ { _id: 'Chunk', values: [ 1, 1, 3, [length]: 3 ] }, { _id: 'Chunk', values: [ 4, 2, 1, [length]: 3 ] }, { _id: 'Chunk', values: [ 1, 1, 6, [length]: 3 ] }, { _id: 'Chunk', values: [ [length]: 0 ] }, [length]: 4 ] } */ ``` --- # Ref > 了解如何使用 Effect 的 Ref 数据类型在并发应用中管理状态,掌握可变引用,从而在多个 fiber 之间安全、可控地更新状态。 编写程序时,我们常常需要在程序的执行过程中跟踪某种形式的状态。状态指的是程序运行时可能发生变化的任何数据。例如,在计数器应用中,计数值会随着用户的递增或递减而改变;类似地,在银行应用中,账户余额会随着存款和取款而变化。状态管理对于构建交互式和动态应用至关重要。 在传统的命令式编程中,存储状态的一种常见方式是使用变量。然而,这种方式可能引入 bug,尤其是当状态在多个组件或函数之间共享时。随着程序变得越来越复杂,管理共享状态也会变得很有挑战。 为了解决这些问题,Effect 引入了一种强大的数据类型 `Ref`,它表示一个可变引用。借助 `Ref`,我们可以在程序的不同部分之间共享状态,而无需直接依赖可变变量。相反,`Ref` 提供了一种受控的方式来处理可变状态,并在并发环境中安全地更新它。 Effect 的 `Ref` 数据类型使程序中不同 fiber 之间能够通信。这一能力在并发编程中至关重要,因为多个任务可能需要同时访问并更新共享状态。 在本指南中,我们将探讨如何有效地使用 `Ref` 数据类型来管理程序中的状态。我们会介绍像计数这样的简单示例,也会涉及状态在程序不同部分之间共享的更复杂场景。此外,我们还会展示如何在并发环境中使用 `Ref`,让多个任务能够安全地与共享状态交互。 让我们深入看看,如何利用 `Ref` 在你的 Effect 程序中实现有效的状态管理。 ## 使用 Ref 下面是一个使用 `Ref` 创建计数器的简单示例: **示例**(使用 `Ref` 的基本计数器) ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.andThen(Ref.make(0), (value) => new Counter(value)) ``` **示例**(使用该计数器) ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.andThen(Ref.make(0), (value) => new Counter(value)) const program = Effect.gen(function* () { const counter = yield* make yield* counter.inc yield* counter.inc yield* counter.dec yield* counter.inc const value = yield* counter.get console.log(`This counter has a value of ${value}.`) }) Effect.runPromise(program) /* Output: This counter has a value of 2. */ ``` ## 在并发环境中使用 Ref 我们也可以在并发场景中使用 `Ref`,此时多个任务可能同时更新共享状态。 **示例**(并发更新共享计数器) 在这个示例中,我们并发地更新计数器: ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.andThen(Ref.make(0), (value) => new Counter(value)) const program = Effect.gen(function* () { const counter = yield* make // Helper to log the counter's value before running an effect const logCounter = (label: string, effect: Effect.Effect) => Effect.gen(function* () { const value = yield* counter.get yield* Effect.log(`${label} get: ${value}`) return yield* effect }) yield* logCounter("task 1", counter.inc).pipe( Effect.zip(logCounter("task 2", counter.inc), { concurrent: true }), Effect.zip(logCounter("task 3", counter.dec), { concurrent: true }), Effect.zip(logCounter("task 4", counter.inc), { concurrent: true }), ) const value = yield* counter.get yield* Effect.log(`This counter has a value of ${value}.`) }) Effect.runPromise(program) /* Output: timestamp=... fiber=#3 message="task 4 get: 0" timestamp=... fiber=#6 message="task 3 get: 1" timestamp=... fiber=#8 message="task 1 get: 0" timestamp=... fiber=#9 message="task 2 get: 1" timestamp=... fiber=#0 message="This counter has a value of 2." */ ``` ## 将 Ref 作为服务使用 你可以把 `Ref` 作为[服务](/docs/v3/requirements-management/services/)传入,从而在程序的不同部分之间共享状态。 **示例**(将 `Ref` 作为服务使用) ```ts import { Effect, Context, Ref } from "effect" // Create a Tag for our state class MyState extends Context.Tag("MyState")>() {} // Subprogram 1: Increment the state value twice const subprogram1 = Effect.gen(function* () { const state = yield* MyState yield* Ref.update(state, (n) => n + 1) yield* Ref.update(state, (n) => n + 1) }) // Subprogram 2: Decrement the state value and then increment it const subprogram2 = Effect.gen(function* () { const state = yield* MyState yield* Ref.update(state, (n) => n - 1) yield* Ref.update(state, (n) => n + 1) }) // Subprogram 3: Read and log the current value of the state const subprogram3 = Effect.gen(function* () { const state = yield* MyState const value = yield* Ref.get(state) console.log(`MyState has a value of ${value}.`) }) // Compose subprograms 1, 2, and 3 to create the main program const program = Effect.gen(function* () { yield* subprogram1 yield* subprogram2 yield* subprogram3 }) // Create a Ref instance with an initial value of 0 const initialState = Ref.make(0) // Provide the Ref as a service const runnable = program.pipe( Effect.provideServiceEffect(MyState, initialState), ) // Run the program and observe the output Effect.runPromise(runnable) /* Output: MyState has a value of 2. */ ``` 注意,我们使用 `Effect.provideServiceEffect` 而不是 `Effect.provideService` 来提供 `MyState` 服务的实际实现,因为 `Ref` 数据类型上的所有操作都是带 effect 的,包括创建操作 `Ref.make(0)`。 ## 在 Fiber 之间共享状态 你可以使用 `Ref` 在并发环境中管理多个 fiber 之间的共享状态。 **示例**(跨 Fiber 管理共享状态) 让我们看一个示例:持续从用户输入读取名字,直到用户输入 `"q"` 退出。 首先,我们引入一个 `readLine` 工具函数来读取用户输入(请确保已安装 `@types/node`): ```ts import { Effect } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) ``` 接下来,我们实现收集名字的主程序: ```ts import { Effect, Chunk, Ref } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) const getNames = Effect.gen(function* () { const ref = yield* Ref.make(Chunk.empty()) while (true) { const name = yield* readLine("Please enter a name or `q` to exit: ") if (name === "q") { break } yield* Ref.update(ref, (state) => Chunk.append(state, name)) } return yield* Ref.get(ref) }) Effect.runPromise(getNames).then(console.log) /* Output: Please enter a name or `q` to exit: Alice Please enter a name or `q` to exit: Bob Please enter a name or `q` to exit: q { _id: "Chunk", values: [ "Alice", "Bob" ] } */ ``` 现在我们已经学会如何使用 `Ref` 数据类型,接下来就可以用它来并发地管理状态。 例如,假设在我们从控制台读取输入的同时,还有另一个 fiber 试图从其他来源更新状态。 在这里,一个 fiber 从用户输入读取名字,另一个 fiber 则按固定间隔并发地添加预设名字: ```ts import { Effect, Chunk, Ref, Fiber } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) const getNames = Effect.gen(function* () { const ref = yield* Ref.make(Chunk.empty()) // Fiber 1: Reading names from user input const fiber1 = yield* Effect.fork( Effect.gen(function* () { while (true) { const name = yield* readLine("Please enter a name or `q` to exit: ") if (name === "q") { break } yield* Ref.update(ref, (state) => Chunk.append(state, name)) } }), ) // Fiber 2: Updating the state with predefined names const fiber2 = yield* Effect.fork( Effect.gen(function* () { for (const name of ["John", "Jane", "Joe", "Tom"]) { yield* Ref.update(ref, (state) => Chunk.append(state, name)) yield* Effect.sleep("1 second") } }), ) yield* Fiber.join(fiber1) yield* Fiber.join(fiber2) return yield* Ref.get(ref) }) Effect.runPromise(getNames).then(console.log) /* Output: Please enter a name or `q` to exit: Alice Please enter a name or `q` to exit: Bob Please enter a name or `q` to exit: q { _id: "Chunk", // Note: the following result may vary // depending on the speed of user input values: [ 'John', 'Jane', 'Joe', 'Tom', 'Alice', 'Bob' ] } */ ``` --- # SubscriptionRef > 掌握 Effect 中的 SubscriptionRef 共享状态管理:它让多个观察者都能订阅状态变化,并在并发环境中高效地对变化做出响应。 `SubscriptionRef` 是 [SynchronizedRef](/docs/v3/state-management/synchronizedref/) 的一种特化形式。它让我们可以订阅当前值以及对该值所做的任何改动,并接收相应的更新。 ```ts interface SubscriptionRef extends SynchronizedRef { /** * A stream containing the current value of the `Ref` as well as all changes * to that value. */ readonly changes: Stream } ``` 你可以对 `SubscriptionRef` 执行所有标准操作,例如 `get`、`set` 或 `modify`,以便与当前值交互。 `SubscriptionRef` 的关键特性是它的 `changes` 流。这个流让你能够观察到订阅那一刻的当前值,并接收随后发生的所有改动。每次运行该流时,它都会发出当前值并跟踪后续更新。 要创建 `SubscriptionRef`,你可以使用 `SubscriptionRef.make` 构造器并指定初始值: **示例**(创建 `SubscriptionRef`) ```ts import { SubscriptionRef } from "effect" const ref = SubscriptionRef.make(0) ``` 当多个观察者需要对变化做出响应时,`SubscriptionRef` 非常适合用来建模共享状态。例如,在函数式响应式编程中,`SubscriptionRef` 可以表示应用状态的一部分,而各个观察者(比如 UI 组件)会随着状态变化而更新。 **示例**(用 `SubscriptionRef` 实现服务端-客户端模型) 下面这个示例中,一个「服务端」持续更新共享值,而多个「客户端」则观察这些变化: ```ts import { Ref, Effect } from "effect" // Server function that increments a shared value forever const server = (ref: Ref.Ref) => Ref.update(ref, (n) => n + 1).pipe(Effect.forever) ``` `server` 函数作用于一个普通的 `Ref` 并持续更新该值。它不需要直接了解 `SubscriptionRef`。 接下来,我们定义一个 `client`,它订阅变化并收集指定数量的值: ```ts import { Ref, Effect, Stream, Random } from "effect" // Server function that increments a shared value forever const server = (ref: Ref.Ref) => Ref.update(ref, (n) => n + 1).pipe(Effect.forever) // Client function that observes the stream of changes const client = (changes: Stream.Stream) => Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) const chunk = yield* Stream.runCollect(Stream.take(changes, n)) return chunk }) ``` 类似地,`client` 函数只处理值的 `Stream`,并不关心这些值的来源。 为了把各部分串起来,我们启动服务端,并行启动多个客户端实例,并在完成后关闭服务端。同时,我们也在这一过程中创建 `SubscriptionRef`。 ```ts import { Ref, Effect, Stream, Random, SubscriptionRef, Fiber } from "effect" // Server function that increments a shared value forever const server = (ref: Ref.Ref) => Ref.update(ref, (n) => n + 1).pipe(Effect.forever) // Client function that observes the stream of changes const client = (changes: Stream.Stream) => Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) const chunk = yield* Stream.runCollect(Stream.take(changes, n)) return chunk }) const program = Effect.gen(function* () { // Create a SubscriptionRef with an initial value of 0 const ref = yield* SubscriptionRef.make(0) // Fork the server to run concurrently const serverFiber = yield* Effect.fork(server(ref)) // Create 5 clients that subscribe to the changes stream const clients = new Array(5).fill(null).map(() => client(ref.changes)) // Run all clients in concurrently and collect their results const chunks = yield* Effect.all(clients, { concurrency: "unbounded" }) // Interrupt the server when clients are done yield* Fiber.interrupt(serverFiber) // Output the results collected by each client for (const chunk of chunks) { console.log(chunk) } }) Effect.runPromise(program) /* Example Output: { _id: 'Chunk', values: [ 4, 5, 6, 7, 8, 9 ] } { _id: 'Chunk', values: [ 4 ] } { _id: 'Chunk', values: [ 4, 5, 6, 7, 8, 9 ] } { _id: 'Chunk', values: [ 4, 5 ] } { _id: 'Chunk', values: [ 4, 5, 6, 7, 8, 9 ] } */ ``` 这种安排确保每个客户端在启动时都能观察到当前值,并接收该值随后发生的所有改动。 由于变化是以流的形式表示的,你可以轻松地用熟悉的流操作符构建更复杂的程序。你可以对这些流进行转换、过滤,或将它们与其他流合并,从而实现更精细的行为。 --- # SynchronizedRef > 掌握 Effect 中的 SynchronizedRef 并发状态管理:它是一个可变引用,支持在并发环境中对共享状态进行原子且带 effect 的更新。 `SynchronizedRef` 是对类型为 `A` 的值的一个可变引用。 借助它,我们可以存储**不可变**数据,并以**原子**且带 effect 的方式执行更新。 `SynchronizedRef` 中与众不同的函数是 `updateEffect`。 该函数接收一个带 effect 的操作,并执行它来修改共享状态。 这正是 `SynchronizedRef` 区别于 `Ref` 的关键特性。 在真实应用中,当你需要执行 effect(例如查询数据库),再根据结果更新共享状态时,`SynchronizedRef` 会非常有用。它确保更新按顺序发生,从而在并发环境中保持一致性。 **示例**(使用 `SynchronizedRef` 进行并发更新) 在这个示例中,我们模拟并发地获取用户年龄,并更新一个存储这些年龄的共享状态: ```ts import { Effect, SynchronizedRef } from "effect" // Simulated API to get user age const getUserAge = (userId: number) => Effect.succeed(userId * 10).pipe(Effect.delay(10 - userId)) const meanAge = Effect.gen(function* () { // Initialize a SynchronizedRef to hold an array of ages const ref = yield* SynchronizedRef.make([]) // Helper function to log state before each effect const log = (label: string, effect: Effect.Effect) => Effect.gen(function* () { const value = yield* SynchronizedRef.get(ref) yield* Effect.log(label, value) return yield* effect }) const task = (id: number) => log( `task ${id}`, SynchronizedRef.updateEffect(ref, (sumOfAges) => Effect.gen(function* () { const age = yield* getUserAge(id) return sumOfAges.concat(age) }), ), ) // Run tasks concurrently with a limit of 2 concurrent tasks yield* Effect.all([task(1), task(2), task(3), task(4)], { concurrency: 2, }) // Retrieve the updated value const value = yield* SynchronizedRef.get(ref) return value }) Effect.runPromise(meanAge).then(console.log) /* Output: timestamp=... level=INFO fiber=#2 message="task 1" message=[] timestamp=... level=INFO fiber=#3 message="task 2" message=[] timestamp=... level=INFO fiber=#2 message="task 3" message="[ 10 ]" timestamp=... level=INFO fiber=#3 message="task 4" message="[ 10, 20 ]" [ 10, 20, 30, 40 ] */ ``` --- # 消费 Stream > 学习消费 Stream 的多种技巧,包括收集元素、通过回调处理,以及使用 fold 与 Sink。 在使用 Stream 时,理解如何消费它们产生的数据至关重要。在本指南中,我们将逐一介绍几种消费 Stream 的常见方法。 ## 使用 runCollect 要把 Stream 中的所有元素收集到单个 `Chunk` 中,可以使用 `Stream.runCollect` 函数。 ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) const collectedData = Stream.runCollect(stream) Effect.runPromise(collectedData).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, 4, 5 ] } */ ``` ## 使用 runForEach 消费 Stream 元素的另一种方式是使用 `Stream.runForEach`。它接收一个回调函数,该函数会收到 Stream 中的每个元素。示例如下: ```ts import { Stream, Effect, Console } from "effect" const effect = Stream.make(1, 2, 3).pipe( Stream.runForEach((n) => Console.log(n)), ) Effect.runPromise(effect).then(console.log) /* Output: 1 2 3 undefined */ ``` 在这个示例中,我们使用 `Stream.runForEach` 把每个元素输出到控制台。 ## 使用 fold 操作 `Stream.fold` 函数是消费 Stream 的另一种方式:它对值组成的 Stream 执行 fold 操作,并返回一个包含结果的 effect。下面有两个示例: ```ts import { Stream, Effect } from "effect" const foldedStream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.runFold(0, (a, b) => a + b), ) Effect.runPromise(foldedStream).then(console.log) // Output: 15 const foldedWhileStream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.runFoldWhile( 0, (n) => n <= 3, (a, b) => a + b, ), ) Effect.runPromise(foldedWhileStream).then(console.log) // Output: 6 ``` 在第一个示例(`foldedStream`)中,我们使用 `Stream.runFold` 计算所有元素的总和。在第二个示例(`foldedWhileStream`)中,我们使用 `Stream.runFoldWhile` 计算总和,但只累加到满足某个条件为止。 ## 使用 Sink 要使用 Sink 消费 Stream,可以把 `Sink` 传给 `Stream.run` 函数。示例如下: ```ts import { Stream, Sink, Effect } from "effect" const effect = Stream.make(1, 2, 3).pipe(Stream.run(Sink.sum)) Effect.runPromise(effect).then(console.log) // Output: 6 ``` 在这个示例中,我们使用 `Sink` 计算 Stream 中所有元素的总和。 --- # 创建 Stream > 学习创建 Effect stream 的各种方法,涵盖从基础构造函数到异步数据源、分页与调度的处理。 在本节中,我们将探讨创建 Effect `Stream` 的各种方法。这些方法能帮助你生成契合自身需求的 stream。 ## 常用构造函数 ### make 你可以使用 `Stream.make` 构造函数创建一个纯 stream。该构造函数接受一组数量可变的值作为参数。 ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2, 3 ] } ``` ### empty 有时你可能需要一个不产生任何值的 stream。这种情况下,可以使用 `Stream.empty`。这个构造函数创建的 stream 始终保持为空。 ```ts import { Stream, Effect } from "effect" const stream = Stream.empty Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [] } ``` ### void 如果你需要一个只包含单个 `void` 值的 stream,可以使用 `Stream.void`。当你想用一个 stream 表示单个事件或信号时,这很方便。 ```ts import { Stream, Effect } from "effect" const stream = Stream.void Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ undefined ] } ``` ### range 要创建指定范围 `[min, max]`(包含 `min` 和 `max` 两个端点)内的整数 stream,可以使用 `Stream.range`。这在生成连续数字的 stream 时特别有用。 ```ts import { Stream, Effect } from "effect" // Creating a stream of numbers from 1 to 5 const stream = Stream.range(1, 5) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2, 3, 4, 5 ] } ``` ### iterate 使用 `Stream.iterate`,你可以通过对初始值反复应用一个函数来生成 stream。初始值会成为 stream 产生的第一个元素,随后依次是由 `f(init)`、`f(f(init))` 等产生的值。 ```ts import { Stream, Effect } from "effect" // Creating a stream of incrementing numbers const stream = Stream.iterate(1, (n) => n + 1) // Produces 1, 2, 3, ... Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // { _id: 'Chunk', values: [ 1, 2, 3, 4, 5 ] } ``` ### scoped `Stream.scoped` 用于从作用域资源创建一个只含单个值的 stream。当处理需要显式获取、使用与释放的资源时,它会很有用。 ```ts import { Stream, Effect, Console } from "effect" // Creating a single-valued stream from a scoped resource const stream = Stream.scoped( Effect.acquireUseRelease( Console.log("acquire"), () => Console.log("use"), () => Console.log("release"), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: acquire use release { _id: 'Chunk', values: [ undefined ] } */ ``` ## 从成功与失败创建 与 `Effect` 数据类型很相似,你可以使用 `fail` 和 `succeed` 函数生成 `Stream`: ```ts import { Stream, Effect } from "effect" // Creating a stream that can emit errors const streamWithError: Stream.Stream = Stream.fail("Uh oh!") Effect.runPromise(Stream.runCollect(streamWithError)) // throws Error: Uh oh! // Creating a stream that emits a numeric value const streamWithNumber: Stream.Stream = Stream.succeed(5) Effect.runPromise(Stream.runCollect(streamWithNumber)).then(console.log) // { _id: 'Chunk', values: [ 5 ] } ``` ## 从 Chunk 创建 你可以像这样从 `Chunk` 构造 stream: ```ts import { Stream, Chunk, Effect } from "effect" // Creating a stream with values from a single Chunk const stream = Stream.fromChunk(Chunk.make(1, 2, 3)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2, 3 ] } ``` 此外,你也可以从多个 `Chunk` 创建 stream: ```ts import { Stream, Chunk, Effect } from "effect" // Creating a stream with values from multiple Chunks const stream = Stream.fromChunks(Chunk.make(1, 2, 3), Chunk.make(4, 5, 6)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2, 3, 4, 5, 6 ] } ``` ## 从 Effect 创建 你可以使用 `Stream.fromEffect` 构造函数从 Effect 工作流生成 stream。例如下面这个 stream,它生成一个随机数: ```ts import { Stream, Random, Effect } from "effect" const stream = Stream.fromEffect(Random.nextInt) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // Example Output: { _id: 'Chunk', values: [ 1042302242 ] } ``` 这个方法让你能够无缝地把 Effect 的输出转换为 stream,为在 stream 中处理异步操作提供了一种直接的方式。 ## 从异步回调创建 假设你有一个依赖回调的异步函数。如果你想把这些回调发出的结果捕获为一个 stream,可以使用 `Stream.async` 函数。这个函数专门用于适配那些会多次调用自身回调的函数,并把结果以 stream 的形式发出。 下面通过一个例子来拆解它的用法: ```ts import { Stream, Effect, Chunk, Option, StreamEmit } from "effect" const events = [1, 2, 3, 4] const stream = Stream.async( (emit: StreamEmit.Emit) => { events.forEach((n) => { setTimeout(() => { if (n === 3) { // Terminate the stream emit(Effect.fail(Option.none())) } else { // Add the current item to the stream emit(Effect.succeed(Chunk.of(n))) } }, 100 * n) }) }, ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2 ] } ``` `StreamEmit.Emit` 类型表示一个可以被多次调用的异步回调。该回调接收一个类型为 `Effect, Option, R>` 的值。各种可能的结果含义如下: - 当传给回调的值在成功时得到 `Chunk`,表示应当把指定的元素作为 stream 的一部分发出。 - 如果传给回调的值以 `Some` 失败,表示以指定的错误终止 stream。 - 当传给回调的值以 `None` 失败时,它充当 stream 结束的信号,本质上是终止这个 stream。 简单来说,这个类型让你可以指定异步回调与 stream 的交互方式:决定何时发出元素、何时以错误终止,以及何时发出 stream 结束的信号。 ## 从 Iterable 创建 ### fromIterable 你可以使用 `Stream.fromIterable` 构造函数从值的 `Iterable` 创建一个纯 stream。这是把一组值转换为 stream 的直接方式。 ```ts import { Stream, Effect } from "effect" const numbers = [1, 2, 3] const stream = Stream.fromIterable(numbers) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2, 3 ] } ``` ### fromIterableEffect 当你有一个产生 `Iterable` 类型值的 effect 时,可以使用 `Stream.fromIterableEffect` 构造函数从该 effect 生成 stream。 例如,假设你有一个获取用户列表的数据库操作。由于该操作涉及 effect,你可以利用 `Stream.fromIterableEffect` 把结果转换为 `Stream`: ```ts import { Stream, Effect, Context } from "effect" class Database extends Context.Tag("Database")< Database, { readonly getUsers: Effect.Effect> } >() {} const getUsers = Database.pipe(Effect.andThen((_) => _.getUsers)) const stream = Stream.fromIterableEffect(getUsers) Effect.runPromise( Stream.runCollect( stream.pipe( Stream.provideService(Database, { getUsers: Effect.succeed(["user1", "user2"]), }), ), ), ).then(console.log) // { _id: 'Chunk', values: [ 'user1', 'user2' ] } ``` 这让你能够无缝地处理 effect,并把它们的结果转换为 stream 以便进一步处理。 ### fromAsyncIterable 异步可迭代对象(async iterable)是另一类可以转换为 stream 的数据源。借助 `Stream.fromAsyncIterable` 构造函数,你可以处理异步数据源并优雅地处理潜在错误。 ```ts import { Stream, Effect } from "effect" const myAsyncIterable = async function* () { yield 1 yield 2 } const stream = Stream.fromAsyncIterable( myAsyncIterable(), (e) => new Error(String(e)), // Error Handling ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 1, 2 ] } ``` 在这段代码中,我们定义了一个异步可迭代对象,然后由它创建了一个名为 `stream` 的 stream。此外,我们还提供了一个错误处理函数,用来管理转换过程中可能出现的任何错误。 ## 从重复创建 ### 重复单个值 你可以使用 `Stream.repeatValue` 构造函数创建一个无休止重复某个特定值的 stream: ```ts import { Stream, Effect } from "effect" const stream = Stream.repeatValue(0) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // { _id: 'Chunk', values: [ 0, 0, 0, 0, 0 ] } ``` ### 重复 Stream 的内容 `Stream.repeat` 让你可以按照给定的调度重复指定 stream 的内容。这在生成周期性的事件或值时很有用。 ```ts import { Stream, Effect, Schedule } from "effect" // Creating a stream that repeats a value indefinitely const stream = Stream.repeat(Stream.succeed(1), Schedule.forever) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // { _id: 'Chunk', values: [ 1, 1, 1, 1, 1 ] } ``` ### 重复 Effect 的结果 假设你有一个 effectful 的 API 调用,并且想用该调用的结果来创建 stream。你可以通过从该 effect 创建 stream 并无限重复它来实现。 下面是一个生成随机数 stream 的例子: ```ts import { Stream, Effect, Random } from "effect" const stream = Stream.repeatEffect(Random.nextInt) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) /* Example Output: { _id: 'Chunk', values: [ 1666935266, 604851965, 2194299958, 3393707011, 4090317618 ] } */ ``` ### 重复 Effect 并在特定条件下终止 你可以重复求值一个给定的 effect,并根据特定条件终止 stream。 在这个例子中,我们通过耗尽(drain)一个 `Iterator` 来由它创建 stream: ```ts import { Stream, Effect, Option } from "effect" const drainIterator = (it: Iterator): Stream.Stream => Stream.repeatEffectOption( Effect.sync(() => it.next()).pipe( Effect.andThen((res) => { if (res.done) { return Effect.fail(Option.none()) } return Effect.succeed(res.value) }), ), ) ``` ### 生成 tick 你可以使用 `Stream.tick` 构造函数创建一个按指定间隔发出 `void` 值的 stream。这对创建周期性事件很有用。 ```ts import { Stream, Effect } from "effect" const stream = Stream.tick("100 millis") Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) /* Output: { _id: 'Chunk', values: [ undefined, undefined, undefined, undefined, undefined ] } */ ``` ## 从展开/分页创建 在函数式编程中,`unfold` 这一概念可以看作 `fold` 的对偶。 使用 `fold` 时,我们处理一个数据结构并产出一个返回值。例如,我们可以接收一个 `Array` 并计算其所有元素之和。 另一方面,`unfold` 表示这样的一种操作:从一个初始值开始,使用指定的状态函数一次添加一个元素,从而生成一个递归的数据结构。例如,我们可以从 `1` 开始、以 `increment` 函数作为状态函数,创建一段自然数序列。 ### 展开 #### unfold Stream 模块包含一个 `unfold` 函数,其定义如下: ```ts declare const unfold: ( initialState: S, step: (s: S) => Option.Option, ) => Stream ``` 它的工作方式如下: - **initialState**。这是初始状态值。 - **step**。状态函数 `step` 接收当前状态 `s` 作为输入。如果该函数的结果是 `None`,则 stream 结束。如果是 `Some<[A, S]>`,那么 stream 中的下一个元素就是 `A`,同时状态 `S` 会更新,供下一步处理使用。 例如,让我们用 `Stream.unfold` 创建一个自然数 stream: ```ts import { Stream, Effect, Option } from "effect" const stream = Stream.unfold(1, (n) => Option.some([n, n + 1])) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // { _id: 'Chunk', values: [ 1, 2, 3, 4, 5 ] } ``` #### unfoldEffect 有时,我们可能需要在展开过程中执行带 effect 的状态变换。这正是 `Stream.unfoldEffect` 的用武之地,它让我们可以在生成 stream 的同时处理 effect。 下面是一个使用 `Stream.unfoldEffect` 创建由随机 `1` 和 `-1` 组成的无限 stream 的示例: ```ts import { Stream, Effect, Option, Random } from "effect" const stream = Stream.unfoldEffect(1, (n) => Random.nextBoolean.pipe( Effect.map((b) => (b ? Option.some([n, -n]) : Option.some([n, n]))), ), ) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // Example Output: { _id: 'Chunk', values: [ 1, 1, 1, 1, -1 ] } ``` #### 其他变体 还有一些类似的操作,比如 `Stream.unfoldChunk` 与 `Stream.unfoldChunkEffect`,它们是专为处理 `Chunk` 数据类型而设计的。 ### 分页 #### paginate `Stream.paginate` 与 `Stream.unfold` 类似,但允许一步发出更多的值。 例如,下面的 stream 会发出 `0, 1, 2, 3` 这些元素: ```ts import { Stream, Effect, Option } from "effect" const stream = Stream.paginate(0, (n) => [ n, n < 3 ? Option.some(n + 1) : Option.none(), ]) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // { _id: 'Chunk', values: [ 0, 1, 2, 3 ] } ``` 它的工作方式如下: - 我们从一个初始值 `0` 开始。 - 传入的函数接收当前值 `n` 并返回一个元组。元组的第一个元素是要发出的值(`n`),第二个元素决定是继续(`Option.some(n + 1)`)还是停止(`Option.none()`)。 #### 其他变体 还有一些类似的操作,比如 `Stream.paginateChunk` 与 `Stream.paginateChunkEffect`,它们是专为处理 `Chunk` 数据类型而设计的。 ### 展开与分页的对比 你可能会好奇 `unfold` 与 `paginate` 这两个组合子之间有什么区别,以及何时该用其中一个而不是另一个。让我们通过一个示例来深入探讨。 设想我们有一个分页 API,它以分页的方式提供大量数据。当我们向这个 API 发起请求时,它会返回一个 `ResultPage` 对象,其中包含当前页的结果,以及一个标志,用于指示它是否是最后一页、或者下一页是否还有更多数据要获取。下面是我们这个 API 的简化表示: ```ts import { Chunk, Effect } from "effect" type RawData = string class PageResult { constructor( readonly results: Chunk.Chunk, readonly isLast: boolean, ) {} } const pageSize = 2 const listPaginated = ( pageNumber: number, ): Effect.Effect => { return Effect.succeed( new PageResult( Chunk.map( Chunk.range(1, pageSize), (index) => `Result ${pageNumber}-${index}`, ), pageNumber === 2, // Return 3 pages ), ) } ``` 我们的目标是把这样一个分页 API 转换成一个由 `RowData` 事件组成的 Stream。在最初的尝试中,我们可能会认为使用 `Stream.unfold` 操作就是可行之道: ```ts import { Chunk, Effect, Stream, Option } from "effect" type RawData = string class PageResult { constructor( readonly results: Chunk.Chunk, readonly isLast: boolean, ) {} } const pageSize = 2 const listPaginated = ( pageNumber: number, ): Effect.Effect => { return Effect.succeed( new PageResult( Chunk.map( Chunk.range(1, pageSize), (index) => `Result ${pageNumber}-${index}`, ), pageNumber === 2, // Return 3 pages ), ) } const firstAttempt = Stream.unfoldChunkEffect(0, (pageNumber) => listPaginated(pageNumber).pipe( Effect.map((page) => { if (page.isLast) { return Option.none() } return Option.some([page.results, pageNumber + 1] as const) }), ), ) Effect.runPromise(Stream.runCollect(firstAttempt)).then(console.log) /* Output: { _id: "Chunk", values: [ "Result 0-1", "Result 0-2", "Result 1-1", "Result 1-2" ] } */ ``` 然而,这种做法有一个缺点:它没有包含最后一页的结果。为了绕开这个问题,我们额外发起一次 API 调用,把这些缺失的结果也包含进来: ```ts import { Chunk, Effect, Stream, Option } from "effect" type RawData = string class PageResult { constructor( readonly results: Chunk.Chunk, readonly isLast: boolean, ) {} } const pageSize = 2 const listPaginated = ( pageNumber: number, ): Effect.Effect => { return Effect.succeed( new PageResult( Chunk.map( Chunk.range(1, pageSize), (index) => `Result ${pageNumber}-${index}`, ), pageNumber === 2, // Return 3 pages ), ) } const secondAttempt = Stream.unfoldChunkEffect(Option.some(0), (pageNumber) => Option.match(pageNumber, { // We already hit the last page onNone: () => Effect.succeed(Option.none()), // We did not hit the last page yet onSome: (pageNumber) => listPaginated(pageNumber).pipe( Effect.map((page) => Option.some([ page.results, page.isLast ? Option.none() : Option.some(pageNumber + 1), ]), ), ), }), ) Effect.runPromise(Stream.runCollect(secondAttempt)).then(console.log) /* Output: { _id: 'Chunk', values: [ 'Result 0-1', 'Result 0-2', 'Result 1-1', 'Result 1-2', 'Result 2-1', 'Result 2-2' ] } */ ``` 虽然这种做法可行,但显然 `Stream.unfold` 并不是从分页 API 获取数据的最友好选择。它需要额外的变通手段,才能把最后一页的结果包含进来。 这正是 `Stream.paginate` 大显身手的地方。它提供了一种更符合人体工程学的方式,把分页 API 转换成 Effect Stream。让我们用 `Stream.paginate` 重写这个方案: ```ts import { Chunk, Effect, Stream, Option } from "effect" type RawData = string class PageResult { constructor( readonly results: Chunk.Chunk, readonly isLast: boolean, ) {} } const pageSize = 2 const listPaginated = ( pageNumber: number, ): Effect.Effect => { return Effect.succeed( new PageResult( Chunk.map( Chunk.range(1, pageSize), (index) => `Result ${pageNumber}-${index}`, ), pageNumber === 2, // Return 3 pages ), ) } const finalAttempt = Stream.paginateChunkEffect(0, (pageNumber) => listPaginated(pageNumber).pipe( Effect.andThen((page) => { return [ page.results, page.isLast ? Option.none() : Option.some(pageNumber + 1), ] }), ), ) Effect.runPromise(Stream.runCollect(finalAttempt)).then(console.log) /* Output: { _id: 'Chunk', values: [ 'Result 0-1', 'Result 0-2', 'Result 1-1', 'Result 1-2', 'Result 2-1', 'Result 2-2' ] } */ ``` ## 从 Queue 和 PubSub 创建 在 Effect 中,有两种至关重要的异步消息数据类型:[Queue](/docs/v3/concurrency/queue/) 和 [PubSub](/docs/v3/concurrency/pubsub/)。你可以分别借助 `Stream.fromQueue` 和 `Stream.fromPubSub`,轻松地把这些数据类型转换为 `Stream`。 ## 从 Schedule 创建 我们可以从一个不需要任何额外输入的 `Schedule` 创建 Stream。该 Stream 会为 Schedule 输出的每个值发出一个元素,只要 Schedule 继续,它就会一直继续: ```ts import { Effect, Stream, Schedule } from "effect" // Emits values every 1 second for a total of 10 emissions const schedule = Schedule.spaced("1 second").pipe( Schedule.compose(Schedule.recurs(10)), ) const stream = Stream.fromSchedule(schedule) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] } */ ``` --- # 流中的错误处理 > 学习如何在流中处理错误,确保稳健的恢复、重试与优雅的错误管理,从而实现可靠的流式处理。 ## 从失败中恢复 处理可能出错的流时,知道如何优雅地应对这些错误至关重要。`Stream.orElse` 函数是一个强大的工具,它能在出错时从失败中恢复并切换到备用的流。 **示例** ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.orElse(s1, () => s2) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, "a", "b", "c" ] } */ ``` 在这个例子中,`s1` 遇到了错误,但我们没有终止整个流,而是通过 `Stream.orElse` 优雅地切换到了 `s2`。这保证了即使其中一个流出错,我们也能继续处理数据。 还有一个名为 `Stream.orElseEither` 的变体,它使用 [Either](/docs/v3/data-types/either/) 数据类型,根据成功与失败来区分两个流中的元素: ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.orElseEither(s1, () => s2) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ { _id: "Either", _tag: "Left", left: 1 }, { _id: "Either", _tag: "Left", left: 2 }, { _id: "Either", _tag: "Left", left: 3 }, { _id: "Either", _tag: "Right", right: "a" }, { _id: "Either", _tag: "Right", right: "b" }, { _id: "Either", _tag: "Right", right: "c" } ] } */ ``` 与 `Stream.orElse` 相比,`Stream.catchAll` 函数提供了更高级的错误处理能力。借助 `Stream.catchAll`,你可以根据所遇到失败的类型和值来做出决策。 ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Uh Oh!" as const)), Stream.concat(Stream.make(4, 5)), Stream.concat(Stream.fail("Ouch" as const)), ) const s2 = Stream.make("a", "b", "c") const s3 = Stream.make(true, false, false) const stream = Stream.catchAll(s1, (error): Stream.Stream => { switch (error) { case "Uh Oh!": return s2 case "Ouch": return s3 } }) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, "a", "b", "c" ] } */ ``` 在这个例子中,我们有一个流 `s1`,它可能会遇到两种不同类型的错误。我们没有像 `Stream.orElse` 那样直接切换到另一个流,而是使用 `Stream.catchAll` 来精确地决定如何处理每一种错误。这种对错误恢复的控制粒度,让你可以依据具体的错误情况来选择不同的流或动作。 ## 从 Defect 中恢复 在处理流时,必须为各种失败场景做好准备,包括流处理过程中可能出现的 defect。为此,`Stream.catchAllCause` 函数提供了一套稳健的解决方案。它让你能够优雅地处理并恢复任何类型的失败。 **示例** ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.dieMessage("Boom!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchAllCause(s1, () => s2) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, "a", "b", "c" ] } */ ``` 在这个例子中,`s1` 可能会遇到一个 defect,但我们没有让应用崩溃,而是使用 `Stream.catchAllCause` 优雅地切换到备用的流 `s2`。这保证了你的应用即使面对意料之外的问题,也能保持稳健并继续处理数据。 ## 从部分错误中恢复 在流处理中,有时你可能只需要从特定类型的失败中恢复。`Stream.catchSome` 和 `Stream.catchSomeCause` 函数正是为此而生,它们允许你有选择地处理和缓解错误。 如果你想从某个特定的错误中恢复,可以使用 `Stream.catchSome`: ```ts import { Stream, Effect, Option } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchSome(s1, (error) => { if (error === "Oh! Error!") { return Option.some(s2) } return Option.none() }) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, "a", "b", "c" ] } */ ``` 如果你想从某个特定的 cause 中恢复,可以使用 `Stream.catchSomeCause` 函数: ```ts import { Stream, Effect, Option, Cause } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.dieMessage("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchSomeCause(s1, (cause) => { if (Cause.isDie(cause)) { return Option.some(s2) } return Option.none() }) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: "Chunk", values: [ 1, 2, 3, "a", "b", "c" ] } */ ``` ## 恢复为一个 Effect 在流处理中,优雅地处理错误并在需要时执行清理任务非常关键。`Stream.onError` 函数正好能让我们做到这一点。如果我们的流遇到了错误,我们可以指定一个要执行的清理任务。 ```ts import { Stream, Console, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.dieMessage("Oh! Boom!")), Stream.concat(Stream.make(4, 5)), Stream.onError(() => Console.log( "Stream application closed! We are doing some cleanup jobs.", ).pipe(Effect.orDie), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Stream application closed! We are doing some cleanup jobs. error: RuntimeException: Oh! Boom! */ ``` ## 重试失败的流 有时,流可能遇到临时的、可恢复的失败。在这种情况下,`Stream.retry` 操作符就派上了用场。它允许你指定一个重试计划(schedule),流会按照该计划进行重试。 **示例** ```ts import { Stream, Effect, Schedule } from "effect" import * as NodeReadLine from "node:readline" const stream = Stream.make(1, 2, 3).pipe( Stream.concat( Stream.fromEffect( Effect.gen(function* () { const s = yield* readLine("Enter a number: ") const n = parseInt(s) if (Number.isNaN(n)) { return yield* Effect.fail("NaN") } return n }), ).pipe(Stream.retry(Schedule.exponential("1 second"))), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Enter a number: a Enter a number: b Enter a number: c Enter a number: 4 { _id: "Chunk", values: [ 1, 2, 3, 4 ] } */ const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) ``` 在这个例子中,流会要求用户输入一个数字,但如果输入了无效的值(例如 "a"、"b"、"c"),它就会以 "NaN" 失败。不过,我们使用了带有指数退避计划的 `Stream.retry`,这意味着它会在逐渐增长的延迟之后重试。这让我们能够应对临时性错误,并最终收集到有效的输入。 ## 细化错误 在处理流时,可能会出现这样的情况:你想有选择地保留某些错误,并以其余的错误终止流。你可以使用 `Stream.refineOrDie` 函数来实现这一点。 **示例** ```ts import { Stream, Option } from "effect" const stream = Stream.fail(new Error()) const res = Stream.refineOrDie(stream, (error) => { if (error instanceof SyntaxError) { return Option.some(error) } return Option.none() }) ``` 在这个例子中,`stream` 最初以一个通用的 `Error` 失败。不过,我们使用 `Stream.refineOrDie` 来过滤并只保留类型为 `SyntaxError` 的错误。任何其他错误都会终止流,而 `SyntaxError` 会被保留在 `refinedStream` 中。 ## 超时 在处理流时,可能会遇到需要处理超时的场景,比如当流在一定时长内没有产生值时将其终止。本节我们将探讨如何使用各种操作符来管理超时。 ### timeout `Stream.timeout` 操作符允许你为流设置超时。如果流在指定的时长内没有产生值,它就会终止。 ```ts import { Stream, Effect } from "effect" const stream = Stream.fromEffect(Effect.never).pipe(Stream.timeout("2 seconds")) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* { _id: "Chunk", values: [] } */ ``` ### timeoutFail `Stream.timeoutFail` 操作符把超时与自定义的失败消息结合在一起。如果流超时了,它就会以指定的错误消息失败。 ```ts import { Stream, Effect } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutFail(() => "timeout", "2 seconds"), ) Effect.runPromiseExit(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'timeout' } } */ ``` ### timeoutFailCause 与 `Stream.timeoutFail` 类似,`Stream.timeoutFailCause` 把超时与自定义的失败 cause 结合在一起。如果流超时了,它就会以指定的 cause 失败。 ```ts import { Stream, Effect, Cause } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutFailCause(() => Cause.die("timeout"), "2 seconds"), ) Effect.runPromiseExit(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Die', defect: 'timeout' } } */ ``` ### timeoutTo `Stream.timeoutTo` 操作符允许你在第一个流于指定时长内没有产生值时,切换到另一个流。 ```ts import { Stream, Effect } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutTo("2 seconds", Stream.make(1, 2, 3)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* { _id: "Chunk", values: [ 1, 2, 3 ] } */ ``` --- # Stream 简介 > 学习 Stream 的基础知识:它是一种强大的工具,用于发出多个值、处理错误,并在应用中处理有限或无限的序列。 在本指南中,我们将探讨 `Stream` 这一概念。`Stream` 是一种程序描述(program description):执行时,它可以发出类型为 `A` 的**零个或多个值**,处理类型为 `E` 的错误,并在类型为 `R` 的上下文中运行。 ## 使用场景 当你需要处理随时间推移而出现的值序列时,Stream 尤其方便。它们可以替代 observables、node streams 和 AsyncIterables。 ## 什么是 Stream? 可以把 `Stream` 看作 `Effect` 的扩展。`Effect` 表示一个需要类型为 `R` 的上下文、可能遇到类型为 `E` 的错误、并且总是产生一个类型为 `A` 的结果的程序;而 `Stream` 则更进一步,允许发出类型为 `A` 的零个或多个值。 为了说明这一点,让我们看几个使用 `Effect` 的示例: ```ts import { Effect, Chunk, Option } from "effect" // An Effect that fails with a string error const failedEffect = Effect.fail("fail!") // An Effect that produces a single number const oneNumberValue = Effect.succeed(3) // An Effect that produces a chunk of numbers const oneListValue = Effect.succeed(Chunk.make(1, 2, 3)) // An Effect that produces an optional number const oneOption = Effect.succeed(Option.some(1)) ``` 在以上每种情况中,`Effect` 最终都只会产生**恰好一个值**。没有任何变数:你总是只得到一个结果。 ## 理解 Stream 现在,让我们把注意力转向 `Stream`。`Stream` 表示一种与 `Effect` 有相似之处的程序描述:它需要类型为 `R` 的上下文,可能发出类型为 `E` 的错误,并产出类型为 `A` 的值。但关键区别在于,它可以产出**零个或多个值**。 `Stream` 有以下几种可能的场景: - **空 Stream**:它可以是空的,表示一个不含任何值的流。 - **单元素 Stream**:它可以表示只含一个值的流。 - **有限元素的 Stream**:它可以表示含有有限个值的流。 - **无限元素的 Stream**:它可以表示无限持续下去的流,本质上就是一个无限流。 让我们看看这些场景的实际效果: ```ts import { Stream } from "effect" // An empty Stream const emptyStream = Stream.empty // A Stream with a single number const oneNumberValueStream = Stream.succeed(3) // A Stream with a range of numbers from 1 to 10 const finiteNumberStream = Stream.range(1, 10) // An infinite Stream of numbers starting from 1 and incrementing const infiniteNumberStream = Stream.iterate(1, (n) => n + 1) ``` 总而言之,`Stream` 是一种用途广泛的工具,用于表示可能产出多个值的程序,因此适合从处理有限列表到处理无限序列的各类任务。 --- # Stream 操作 > 探索 Stream 中用于操作与管理数据的常用操作,包括旁路、映射、过滤、合并等,帮助你高效地处理和转换流式数据。 在本指南中,我们将介绍一些可以在 stream 上执行的基本操作。这些操作让你能够以多种方式操作并与 stream 的元素交互。 ## 旁路(Tapping) `Stream.tap` 操作允许你对 stream 发出的每个元素运行一个 effect,从而观察或执行副作用,而不改变元素本身或返回类型。它适合用于记录日志、监控,或在每次发出元素时触发额外的动作。 **示例**(使用 `Stream.tap` 记录日志) 例如,可以用 `Stream.tap` 在映射操作的前后记录每个元素: ```ts import { Stream, Console, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.tap((n) => Console.log(`before mapping: ${n}`)), Stream.map((n) => n * 2), Stream.tap((n) => Console.log(`after mapping: ${n}`)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: before mapping: 1 after mapping: 2 before mapping: 2 after mapping: 4 before mapping: 3 after mapping: 6 { _id: 'Chunk', values: [ 2, 4, 6 ] } */ ``` ## 取出元素 stream 中的「取出」操作让你按固定数量、条件或位置从 stream 中提取特定的元素集合。下面介绍几种应用这些操作的方式: | API | 说明 | | --- | --- | | `take` | 提取固定数量的元素。 | | `takeWhile` | 在满足某个条件期间持续提取元素。 | | `takeUntil` | 提取元素,直到满足某个条件为止。 | | `takeRight` | 从末尾提取指定数量的元素。 | **示例**(以不同方式提取元素) ```ts import { Stream, Effect } from "effect" const stream = Stream.iterate(0, (n) => n + 1) // Using `take` to extract a fixed number of elements: const s1 = Stream.take(stream, 5) Effect.runPromise(Stream.runCollect(s1)).then(console.log) /* Output: { _id: 'Chunk', values: [ 0, 1, 2, 3, 4 ] } */ // Using `takeWhile` to extract elements while a condition is met: const s2 = Stream.takeWhile(stream, (n) => n < 5) Effect.runPromise(Stream.runCollect(s2)).then(console.log) /* Output: { _id: 'Chunk', values: [ 0, 1, 2, 3, 4 ] } */ // Using `takeUntil` to extract elements until a condition is met: const s3 = Stream.takeUntil(stream, (n) => n === 5) Effect.runPromise(Stream.runCollect(s3)).then(console.log) /* Output: { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5 ] } */ // Using `takeRight` to take elements from the end of the stream: const s4 = Stream.takeRight(s3, 3) Effect.runPromise(Stream.runCollect(s4)).then(console.log) /* Output: { _id: 'Chunk', values: [ 3, 4, 5 ] } */ ``` ## Stream 作为 Async Iterable 的替代方案 在处理异步数据源(例如 async iterable)时,你常常需要在循环中消费数据,直到满足某个条件为止。Stream 提供了类似的思路,并带来了额外的灵活性。 使用 async iterable 时,数据会在循环中处理,直到遇到 `break` 或 `return` 语句。要在 Stream 中复现这种行为,可以考虑以下选项: | API | 说明 | | --- | --- | | `takeUntil` | 从 stream 中取出元素,直到满足指定条件为止,类似于跳出循环。 | | `toPull` | 返回一个 effect,它会持续从 stream 中拉取数据块(chunk)。当 stream 结束时,该 effect 会以 `None` 失败;如果出错,则以 `Some` 错误失败。 | **示例**(使用 `Stream.toPull`) ```ts import { Stream, Effect } from "effect" // Simulate a chunked stream const stream = Stream.fromIterable([1, 2, 3, 4, 5]).pipe(Stream.rechunk(2)) const program = Effect.gen(function* () { // Create an effect to get data chunks from the stream const getChunk = yield* Stream.toPull(stream) // Continuously fetch and process chunks while (true) { const chunk = yield* getChunk console.log(chunk) } }) Effect.runPromise(Effect.scoped(program)).then(console.log, console.error) /* Output: { _id: 'Chunk', values: [ 1, 2 ] } { _id: 'Chunk', values: [ 3, 4 ] } { _id: 'Chunk', values: [ 5 ] } (FiberFailure) Error: { "_id": "Option", "_tag": "None" } */ ``` ## 映射 ### 基本映射 `Stream.map` 操作会对 stream 中的每个元素应用指定的函数,生成一个包含转换后值的新 stream。 **示例**(把每个元素加 1) ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.map((n) => n + 1), // Increment each element by 1 ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 2, 3, 4 ] } */ ``` ### 映射为常量值 `Stream.as` 方法允许你把 stream 中的每个成功值替换为指定的常量值。当你希望 stream 中的所有元素都发出统一的值、而不关心原始数据时,这会很有用。 **示例**(映射为 `null`) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe(Stream.as(null)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ null, null, null, null, null ] } */ ``` ### 带 Effect 的映射 对于涉及 effect 的转换,请使用 `Stream.mapEffect`。该函数会对 stream 中的每个元素应用一个带 effect 的操作,生成一个包含 effect 结果的新 stream。 **示例**(生成随机数) ```ts import { Stream, Random, Effect } from "effect" const stream = Stream.make(10, 20, 30).pipe( // Generate a random number between 0 and each element Stream.mapEffect((n) => Random.nextIntBetween(0, n)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: { _id: 'Chunk', values: [ 5, 9, 22 ] } */ ``` 要并发处理多个带 effect 的转换,可以使用 [concurrency](/docs/v3/concurrency/basic-concurrency/#concurrency-options) 选项。该选项允许指定数量的 effect 并发运行,结果会按原始顺序向下游发出。 **示例**(并发获取 URL) ```ts import { Stream, Effect } from "effect" const fetchUrl = (url: string) => Effect.gen(function* () { console.log(`Fetching ${url}`) yield* Effect.sleep("100 millis") console.log(`Fetching ${url} done`) return [`Resource 0-${url}`, `Resource 1-${url}`, `Resource 2-${url}`] }) const stream = Stream.make("url1", "url2", "url3").pipe( // Fetch each URL concurrently with a limit of 2 Stream.mapEffect(fetchUrl, { concurrency: 2 }), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Fetching url1 Fetching url2 Fetching url1 done Fetching url3 Fetching url2 done Fetching url3 done { _id: 'Chunk', values: [ [ 'Resource 0-url1', 'Resource 1-url1', 'Resource 2-url1' ], [ 'Resource 0-url2', 'Resource 1-url2', 'Resource 2-url2' ], [ 'Resource 0-url3', 'Resource 1-url3', 'Resource 2-url3' ] ] } */ ``` ### 有状态映射 `Stream.mapAccum` 与 `Stream.map` 类似,但它在应用转换时会跟踪状态,让你可以在一次操作中同时完成映射与累加。它适合用于计算 stream 中的累计值这类任务。 **示例**(计算累计总和) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe( // ┌─── next state // │ ┌─── emitted value // ▼ ▼ Stream.mapAccum(0, (state, n) => [state + n, state + n]), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 3, 6, 10, 15 ] } */ ``` ### 映射与扁平化 `Stream.mapConcat` 操作与 `Stream.map` 类似,但它更进一步:先把每个元素映射为零个或多个元素(以 `Iterable` 形式),再把整个 stream 扁平化。当需要把每个元素转换为多个值时,它尤其有用。 **示例**(拆分并扁平化 Stream) ```ts import { Stream, Effect } from "effect" const numbers = Stream.make("1-2-3", "4-5", "6").pipe( Stream.mapConcat((s) => s.split("-")), ) Effect.runPromise(Stream.runCollect(numbers)).then(console.log) /* Output: { _id: 'Chunk', values: [ '1', '2', '3', '4', '5', '6' ] } */ ``` ## 过滤 `Stream.filter` 操作只放行满足特定条件的元素。它可以保留 stream 中符合某项标准的元素,并丢弃其余元素。 **示例**(过滤偶数) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 11).pipe(Stream.filter((n) => n % 2 === 0)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 2, 4, 6, 8, 10 ] } */ ``` ## 扫描 stream 扫描让你可以累积地把一个函数应用到 stream 的每个元素上,并发出每一个中间结果。与只给出最终结果的 `reduce` 不同,`scan` 提供了累积过程的逐步视图。 **示例**(累加求和) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe(Stream.scan(0, (a, b) => a + b)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 0, 1, 3, 6, 10, 15 ] } */ ``` 如果只需要最终的累积值,可以使用 [Stream.runFold](/docs/v3/stream/consuming-streams/#using-a-fold-operation): **示例**(最终的累积结果) ```ts import { Stream, Effect } from "effect" const fold = Stream.range(1, 5).pipe(Stream.runFold(0, (a, b) => a + b)) Effect.runPromise(fold).then(console.log) // Output: 15 ``` ## 排空 stream 排空让你可以在 stream 中执行带 effect 的操作,同时丢弃结果值。当你需要执行某些动作或副作用、但并不需要发出的值时,这会很有用。`Stream.drain` 函数通过忽略 stream 中的所有元素并产出一个空的输出 stream 来实现这一点。 **示例**(执行带 effect 的操作但不收集值) ```ts import { Stream, Effect, Random } from "effect" const stream = Stream.repeatEffect( Effect.gen(function* () { const nextInt = yield* Random.nextInt const number = Math.abs(nextInt % 10) console.log(`random number: ${number}`) return number }), ).pipe(Stream.take(3)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: random number: 7 random number: 5 random number: 0 { _id: 'Chunk', values: [ 7, 5, 0 ] } */ const drained = Stream.drain(stream) Effect.runPromise(Stream.runCollect(drained)).then(console.log) /* Example Output: random number: 0 random number: 1 random number: 7 { _id: 'Chunk', values: [] } */ ``` ## 检测 Stream 中的变化 `Stream.changes` 操作会检测并发出 stream 中与其前一个元素不同的元素。它适合用于跟踪变化,或对连续重复的值去重。 **示例**(发出连续但不同的元素) ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 1, 1, 2, 2, 3, 4).pipe(Stream.changes) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3, 4 ] } */ ``` ## 组合(Zipping) 组合(Zipping)会把两个 stream 的元素合并成一个新的 stream,将来自每个输入 stream 的元素配对。这可以通过 `Stream.zip` 或 `Stream.zipWith` 实现,后者允许自定义配对逻辑。 **示例**(基本的组合) 在这个示例中,两个 stream 的元素会按顺序依次配对。当其中一个 stream 耗尽时,结果 stream 随之结束。 ```ts import { Stream, Effect } from "effect" // Zip two streams together const stream = Stream.zip( Stream.make(1, 2, 3, 4, 5, 6), Stream.make("a", "b", "c"), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 1, 'a' ], [ 2, 'b' ], [ 3, 'c' ] ] } */ ``` **示例**(自定义组合逻辑) 这里,`Stream.zipWith` 会对每一对元素应用自定义逻辑,以用户定义的方式组合元素。 ```ts import { Stream, Effect } from "effect" // Zip two streams with custom pairing logic const stream = Stream.zipWith( Stream.make(1, 2, 3, 4, 5, 6), Stream.make("a", "b", "c"), (n, s) => [n + 10, s + "!"], ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 11, 'a!' ], [ 12, 'b!' ], [ 13, 'c!' ] ] } */ ``` ### 处理 Stream 的结束 如果一个输入 stream 在另一个之前结束,你可能希望用默认值来组合,以避免缺失配对。`Stream.zipAll` 与 `Stream.zipAllWith` 操作符提供了这一功能,允许你为任意一方指定默认值。 **示例**(使用默认值进行组合) 在这个示例中,当第二个 stream 完成后,第一个 stream 会继续,并以 "x" 作为第二个 stream 的默认值。 ```ts import { Stream, Effect } from "effect" const stream = Stream.zipAll(Stream.make(1, 2, 3, 4, 5, 6), { other: Stream.make("a", "b", "c"), defaultSelf: -1, defaultOther: "x", }) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 1, 'a' ], [ 2, 'b' ], [ 3, 'c' ], [ 4, 'x' ], [ 5, 'x' ], [ 6, 'x' ] ] } */ ``` **示例**(使用 zipAllWith 的自定义逻辑) 借助 `Stream.zipAllWith`,自定义逻辑决定了在任一方 stream 耗尽时如何组合元素,为处理这些情况提供了灵活性。 ```ts import { Stream, Effect } from "effect" const stream = Stream.zipAllWith(Stream.make(1, 2, 3, 4, 5, 6), { other: Stream.make("a", "b", "c"), onSelf: (n) => [n, "x"], onOther: (s) => [-1, s], onBoth: (n, s) => [n + 10, s + "!"], }) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 11, 'a!' ], [ 12, 'b!' ], [ 13, 'c!' ], [ 4, 'x' ], [ 5, 'x' ], [ 6, 'x' ] ] } */ ``` ### 以不同速率组合 Stream 当组合的流以不同速度发出元素时,你可能不想等待较慢的那个流发出元素。使用 `Stream.zipLatest` 或 `Stream.zipLatestWith`,只要任一流传出新值,就可以立即进行配对。当较快的流有新值到达时,这些函数会使用较慢的那个流最近一次发出的元素。 **示例**(组合发出速率不同的流) ```ts import { Stream, Schedule, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.schedule(Schedule.spaced("1 second")), ) const s2 = Stream.make("a", "b", "c", "d").pipe( Stream.schedule(Schedule.spaced("500 millis")), ) const stream = Stream.zipLatest(s1, s2) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 1, 'a' ], // s1 emits 1 and pairs with the latest value from s2 [ 1, 'b' ], // s2 emits 'b', pairs with the latest value from s1 [ 2, 'b' ], // s1 emits 2, pairs with the latest value from s2 [ 2, 'c' ], // s2 emits 'c', pairs with the latest value from s1 [ 2, 'd' ], // s2 emits 'd', pairs with the latest value from s1 [ 3, 'd' ] // s1 emits 3, pairs with the latest value from s2 ] } */ ``` ### 与前一个和后一个元素配对 | API | 说明 | | --- | --- | | `zipWithPrevious` | 把流的每个元素与其前一个元素配对。 | | `zipWithNext` | 把流的每个元素与其后一个元素配对。 | | `zipWithPreviousAndNext` | 把每个元素同时与其前一个和后一个元素配对。 | **示例**(把流的元素与其后一个元素配对) ```ts import { Stream, Effect } from "effect" const stream = Stream.zipWithNext(Stream.make(1, 2, 3, 4)) Effect.runPromise(Stream.runCollect(stream)).then((chunks) => console.log("%o", chunks), ) /* Output: { _id: 'Chunk', values: [ [ 1, { _id: 'Option', _tag: 'Some', value: 2 }, [length]: 2 ], [ 2, { _id: 'Option', _tag: 'Some', value: 3 }, [length]: 2 ], [ 3, { _id: 'Option', _tag: 'Some', value: 4 }, [length]: 2 ], [ 4, { _id: 'Option', _tag: 'None' }, [length]: 2 ], [length]: 4 ] } */ ``` ### 为流元素建立索引 `Stream.zipWithIndex` 操作符是为流中每个元素建立索引的实用工具,它会把每个元素与其在序列中的位置配对。当你需要跟踪流中元素的顺序时,它尤其有用。 **示例**(为流中的每个元素建立索引) ```ts import { Stream, Effect } from "effect" const stream = Stream.zipWithIndex( Stream.make("Mary", "James", "Robert", "Patricia"), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 'Mary', 0 ], [ 'James', 1 ], [ 'Robert', 2 ], [ 'Patricia', 3 ] ] } */ ``` ## 流的笛卡尔积 Stream 模块包含计算两个流的_笛卡尔积_的功能,让你可以生成来自两个不同流的元素组合。当你需要把一组中的每个元素与另一组的所有元素配对时,这会很有用。 简单来说,假设你有两个集合,想从每个集合中各取一项来组成所有可能的配对,这个配对过程就是笛卡尔积。在流中,该操作会生成一个新的流,其中包含两个输入流元素的所有可能配对。 要创建两个流的笛卡尔积,可以使用 `Stream.cross` 操作符及其类似变体。这些操作符会把两个流组合成一个包含所有可能元素组合的新流。 **示例**(创建两个流的笛卡尔积) ```ts import { Stream, Effect, Console } from "effect" const s1 = Stream.make(1, 2, 3).pipe(Stream.tap(Console.log)) const s2 = Stream.make("a", "b").pipe(Stream.tap(Console.log)) const cartesianProduct = Stream.cross(s1, s2) Effect.runPromise(Stream.runCollect(cartesianProduct)).then(console.log) /* Output: 1 a b 2 a b 3 a b { _id: 'Chunk', values: [ [ 1, 'a' ], [ 1, 'b' ], [ 2, 'a' ], [ 2, 'b' ], [ 3, 'a' ], [ 3, 'b' ] ] } */ ``` ## 分区 流的分区是指按照指定条件把一个流拆分为两个不同的流。Stream 模块为此提供了两个函数:`Stream.partition` 和 `Stream.partitionEither`。下面来看看它们的工作方式,以及最适合使用它们的场景。 ### partition `Stream.partition` 函数接收一个谓词(一个条件)作为输入,把原始流拆分为两个子流:一个子流包含满足条件的元素,另一个包含不满足条件的元素。得到的两个子流都被包装在 `Scope` 类型中。 **示例**(把流拆分为奇数和偶数) ```ts import { Stream, Effect } from "effect" // ┌─── Effect<[Stream, Stream], never, Scope> // ▼ const program = Stream.range(1, 9).pipe( Stream.partition((n) => n % 2 === 0, { bufferSize: 5 }), ) Effect.runPromise( Effect.scoped( Effect.gen(function* () { const [odds, evens] = yield* program console.log(yield* Stream.runCollect(odds)) console.log(yield* Stream.runCollect(evens)) }), ), ) /* Output: { _id: 'Chunk', values: [ 1, 3, 5, 7, 9 ] } { _id: 'Chunk', values: [ 2, 4, 6, 8 ] } */ ``` ### partitionEither 有些情况下,你可能需要用涉及 effect 的条件来对流进行分区,这时 `Stream.partitionEither` 函数正合适。它使用一个带 effect 的谓词把流拆分为两个子流:一个用于产生 `Either.left` 值的元素,另一个用于产生 `Either.right` 值的元素。 **示例**(用带 effect 的谓词对流进行分区) ```ts import { Stream, Effect, Either } from "effect" // ┌─── Effect<[Stream, Stream], never, Scope> // ▼ const program = Stream.range(1, 9).pipe( Stream.partitionEither( // Simulate an effectful computation (n) => Effect.succeed(n % 2 === 0 ? Either.right(n) : Either.left(n)), { bufferSize: 5 }, ), ) Effect.runPromise( Effect.scoped( Effect.gen(function* () { const [odds, evens] = yield* program console.log(yield* Stream.runCollect(odds)) console.log(yield* Stream.runCollect(evens)) }), ), ) /* Output: { _id: 'Chunk', values: [ 1, 3, 5, 7, 9 ] } { _id: 'Chunk', values: [ 2, 4, 6, 8 ] } */ ``` ## 分组 处理数据流时,你可能需要按照特定条件对元素进行分组。Stream 模块为此提供了 `groupByKey`、`groupBy`、`grouped` 和 `groupedWithin` 四个函数。下面逐一看看它们的工作方式以及各自适用的场景。 ### groupByKey `Stream.groupByKey` 函数根据一个类型为 `(a: A) => K` 的键函数对流进行分区,其中 `A` 是流中元素的类型,`K` 表示用于分组的键。该函数不涉及 effect,只是简单地应用所提供的键函数来对元素进行分组。 `Stream.groupByKey` 的结果是一个 `GroupBy` 数据类型,表示分组后的流。要处理每个分组,可以使用 `GroupBy.evaluate`,它接收一个类型为 `(key: K, stream: Stream) => Stream.Stream<...>` 的函数。该函数会作用于所有分组,并以不确定的顺序把它们合并在一起。 **示例**(按考试成绩的十位数分组) 在下面的示例中,我们使用 `Stream.groupByKey` 按十位数对考试成绩进行分组,并统计每个分组中的成绩数量: ```ts import { Stream, GroupBy, Effect, Chunk } from "effect" class Exam { constructor( readonly person: string, readonly score: number, ) {} } // Define a list of exam results const examResults = [ new Exam("Alex", 64), new Exam("Michael", 97), new Exam("Bill", 77), new Exam("John", 78), new Exam("Bobby", 71), ] // Group exam results by the tens place in the score const groupByKeyResult = Stream.fromIterable(examResults).pipe( Stream.groupByKey((exam) => Math.floor(exam.score / 10) * 10), ) // Count the number of exam results in each group const stream = GroupBy.evaluate(groupByKeyResult, (key, stream) => Stream.fromEffect( Stream.runCollect(stream).pipe( Effect.andThen((chunk) => [key, Chunk.size(chunk)] as const), ), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 60, 1 ], [ 90, 1 ], [ 70, 3 ] ] } */ ``` ### groupBy 当分组需求更复杂、分区过程涉及 effect 时,可以使用 `Stream.groupBy` 函数。它接收一个带 effect 的分区函数,并返回一个 `GroupBy` 数据类型,表示分组后的流。随后你可以像 `Stream.groupByKey` 那样,使用 `GroupBy.evaluate` 处理每个分组。 **示例**(按首字母对名字分组) 在下面的示例中,我们按名字的首字母进行分组,并统计每个分组中的名字数量。这里的分区操作是以带 effect 的方式设置的: ```ts import { Stream, GroupBy, Effect, Chunk } from "effect" // Group names by their first letter const groupByKeyResult = Stream.fromIterable([ "Mary", "James", "Robert", "Patricia", "John", "Jennifer", "Rebecca", "Peter", ]).pipe( // Simulate an effectful groupBy operation Stream.groupBy((name) => Effect.succeed([name.substring(0, 1), name])), ) // Count the number of names in each group and display results const stream = GroupBy.evaluate(groupByKeyResult, (key, stream) => Stream.fromEffect( Stream.runCollect(stream).pipe( Effect.andThen((chunk) => [key, Chunk.size(chunk)] as const), ), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ [ 'M', 1 ], [ 'J', 3 ], [ 'R', 2 ], [ 'P', 2 ] ] } */ ``` ### grouped `Stream.grouped` 函数适合把流划分为指定大小的块,从而更便于以更小、更规整的片段来处理数据。在批量处理或展示数据时,这尤其有用。 **示例**(把流划分为每 3 个元素的块) ```ts import { Stream, Effect } from "effect" // Create a stream of numbers and group them into chunks of 3 const stream = Stream.range(0, 8).pipe(Stream.grouped(3)) Effect.runPromise(Stream.runCollect(stream)).then((chunks) => console.log("%o", chunks), ) /* Output: { _id: 'Chunk', values: [ { _id: 'Chunk', values: [ 0, 1, 2, [length]: 3 ] }, { _id: 'Chunk', values: [ 3, 4, 5, [length]: 3 ] }, { _id: 'Chunk', values: [ 6, 7, 8, [length]: 3 ] }, [length]: 3 ] } */ ``` ### groupedWithin `Stream.groupedWithin` 函数提供了更灵活的分组方式:它根据指定的最大大小或时间间隔中先满足的那个条件来创建块。当处理的数据涉及时间约束时,这尤其有用。 **示例**(按大小或时间间隔分组) 在这个示例中,`Stream.groupedWithin(18, "1.5 seconds")` 会在累积满 18 个元素、或者距离上一块创建已过去 1.5 秒时,把流分成一块。 ```ts import { Stream, Schedule, Effect, Chunk } from "effect" // Create a stream that repeats every second and group by size or time const stream = Stream.range(0, 9).pipe( Stream.repeat(Schedule.spaced("1 second")), Stream.groupedWithin(18, "1.5 seconds"), Stream.take(3), ) Effect.runPromise(Stream.runCollect(stream)).then((chunks) => console.log(Chunk.toArray(chunks)), ) /* Output: [ { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7 ] }, { _id: 'Chunk', values: [ 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] }, { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7 ] } ] */ ``` ## 拼接 在流处理中,你可能需要把多个流的内容组合起来。Stream 模块提供了若干操作符来实现这一点,包括 `Stream.concat`、`Stream.concatAll` 和 `Stream.flatMap`。下面看看这些操作符各自的工作方式。 ### 简单拼接 `Stream.concat` 操作符是连接两个流最直接的方式。它返回一个新的流,先发出第一个流(左侧)的元素,再发出第二个流(右侧)的元素。当你希望按特定顺序组合两个流时,这会很有用。 **示例**(按顺序拼接两个流) ```ts import { Stream, Effect } from "effect" const stream = Stream.concat(Stream.make(1, 2, 3), Stream.make("a", "b")) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3, 'a', 'b' ] } */ ``` ### 拼接多个流 如果要拼接多个流,`Stream.concatAll` 提供了一种高效的方式,无需手动串联多个 `Stream.concat` 操作。该函数接收一个由流组成的 [Chunk](/docs/v3/data-types/chunk/),并返回一个按顺序包含各个流中元素的单一流。 **示例**(拼接多个流) ```ts import { Stream, Effect, Chunk } from "effect" const s1 = Stream.make(1, 2, 3) const s2 = Stream.make("a", "b") const s3 = Stream.make(true, false, false) const stream = Stream.concatAll( Chunk.make(s1, s2, s3), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 3, 'a', 'b', true, false, false ] } */ ``` ### 用 flatMap 进行高级拼接 `Stream.flatMap` 操作符支持更高级的拼接:它对源流的每个输出应用一个类型为 `(a: A) => Stream<...>` 的函数,从而生成一个新的流。随后该操作符会拼接所有得到的流,实际上把它们展平。 **示例**(用 `Stream.flatMap` 生成重复元素) ```ts import { Stream, Effect } from "effect" // Create a stream where each element is repeated 4 times const stream = Stream.make(1, 2, 3).pipe( Stream.flatMap((a) => Stream.repeatValue(a).pipe(Stream.take(4))), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 ] } */ ``` 如果需要并发执行 `flatMap` 操作,可以使用 [concurrency](/docs/v3/concurrency/basic-concurrency/#concurrency-options) 选项来控制同时运行多少个内部流。 此外,你还可以使用 `switch` 选项来实现“切换”行为:当源流有新的元素到达时,之前的流会被自动取消。当你只需要最新的结果,并希望通过取消过时的操作来节省资源时,这尤其有用。 **示例**(使用 `switch` 选项) ```ts import { Stream, Effect, Console } from "effect" // Helper function to create a stream with logging const createStreamWithLogging = (n: number) => Stream.fromEffect( Effect.gen(function* () { console.log(`Starting stream for value: ${n}`) const result = yield* Effect.delay(Effect.succeed(n), "500 millis") console.log(`Completed stream for value: ${result}`) return result }).pipe( Effect.onInterrupt(() => Console.log(`Interrupted stream for value: ${n}`), ), ), ) // Without switch (default behavior): // all streams run to completion const stream1 = Stream.fromIterable([1, 2, 3]).pipe( Stream.flatMap(createStreamWithLogging), ) // With switch behavior: // only the last stream completes, previous streams // are cancelled when new values arrive const stream2 = Stream.fromIterable([1, 2, 3]).pipe( Stream.flatMap(createStreamWithLogging, { switch: true }), ) // Run examples sequentially to see the difference Effect.runPromise( Effect.gen(function* () { console.log("=== Without switch (all streams complete) ===") const result1 = yield* Stream.runCollect(stream1) console.log(result1) console.log("\n=== With switch (only last stream completes) ===") const result2 = yield* Stream.runCollect(stream2) console.log(result2) }), ) /* Output: === Without switch (all streams complete) === Starting stream for value: 1 Completed stream for value: 1 Starting stream for value: 2 Completed stream for value: 2 Starting stream for value: 3 Completed stream for value: 3 { _id: 'Chunk', values: [ 1, 2, 3 ] } === With switch (only last stream completes) === Starting stream for value: 1 Interrupted stream for value: 1 Starting stream for value: 2 Interrupted stream for value: 2 Starting stream for value: 3 Completed stream for value: 3 { _id: 'Chunk', values: [ 3 ] } */ ``` `switch` 选项在搜索功能、实时数据处理等场景中尤其有价值:凡是希望在新输入到达时丢弃先前操作的情况,都很适用。 ## 合并 有时你可能希望把两个流的元素交错在一起,生成一个单一的输出流。这时 `Stream.concat` 并不合适,因为它会等第一个流完成后才去消费第二个流。若要在元素可用时就交错它们,`Stream.merge` 及其变体正是为此设计的。 ### merge `Stream.merge` 操作把两个源流的元素组合成一个流,并在元素产生时将它们交错输出。与 `Stream.concat` 不同,`Stream.merge` 不会等一个流结束后再开始另一个流。 **示例**(用 `Stream.merge` 交错两个流) ```ts import { Schedule, Stream, Effect } from "effect" // Create two streams with different emission intervals const s1 = Stream.make(1, 2, 3).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.make(4, 5, 6).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) // Merge s1 and s2 into a single stream that interleaves their values const merged = Stream.merge(s1, s2) Effect.runPromise(Stream.runCollect(merged)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 4, 2, 3, 5, 6 ] } */ ``` ### 终止策略 合并两个流时,考虑终止策略很重要,尤其是在每个流生命周期不同的情况下。默认情况下,`Stream.merge` 会等待两个流都终止后才结束合并后的流。不过,你可以通过 `haltStrategy` 修改这一行为,它提供了四种终止策略: | 终止策略 | 说明 | | --- | --- | | `"left"` | 当左侧的流终止时,合并后的流随之终止。 | | `"right"` | 当右侧的流终止时,合并后的流随之终止。 | | `"both"`(默认) | 只有当两个流都终止后,合并后的流才终止。 | | `"either"` | 只要任意一个流终止,合并后的流就立即终止。 | **示例**(用 `haltStrategy: "left"` 控制流的终止) ```ts import { Stream, Schedule, Effect } from "effect" const s1 = Stream.range(1, 5).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.repeatValue(0).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) const merged = Stream.merge(s1, s2, { haltStrategy: "left" }) Effect.runPromise(Stream.runCollect(merged)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 0, 2, 3, 0, 4, 5 ] } */ ``` ### mergeWith 有些情况下,你可能希望在合并两个流的同时把它们的元素转换为统一的类型。`Stream.mergeWith` 正是为此设计的,它允许你为每个源流指定转换函数。 **示例**(合并并转换两个流) ```ts import { Schedule, Stream, Effect } from "effect" const s1 = Stream.make("1", "2", "3").pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.make(4.1, 5.3, 6.2).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) const merged = Stream.mergeWith(s1, s2, { // Convert string elements from `s1` to integers onSelf: (s) => parseInt(s), // Round down decimal elements from `s2` onOther: (n) => Math.floor(n), }) Effect.runPromise(Stream.runCollect(merged)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 4, 2, 3, 5, 6 ] } */ ``` ## 交替 ### interleave `Stream.interleave` 操作符让你每次从两个流中各取出一个元素,从而生成一个新的交替流。如果其中一个流先结束,另一个流中剩余的元素会继续被取出,直到两个流都耗尽。 **示例**(两个流的基本交替) ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3) const s2 = Stream.make(4, 5, 6) const stream = Stream.interleave(s1, s2) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 4, 2, 5, 3, 6 ] } */ ``` ### interleaveWith 对于更复杂的交替需求,`Stream.interleaveWith` 通过一个由 `boolean` 值组成的第三个流来指定交替模式,提供了额外的控制:当该流发出 `true` 时,从左侧的流取一个元素;否则从右侧的流取一个元素。 **示例**(用 `Stream.interleaveWith` 实现自定义交替逻辑) ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 3, 5, 7, 9) const s2 = Stream.make(2, 4, 6, 8, 10) // Define a boolean stream to control interleaving const booleanStream = Stream.make(true, false, false).pipe(Stream.forever) const stream = Stream.interleaveWith(s1, s2, booleanStream) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 2, 4, 3, 6, 8, 5, 10, 7, 9 ] } */ ``` ## 穿插 穿插会在流中添加分隔元素或前后缀,这有助于对流中的数据进行格式化或结构化。 ### intersperse `Stream.intersperse` 操作符会在流中每两个元素之间插入一个指定的分隔元素。这个分隔元素可以是任意选定的值,会被添加到每一对相邻元素之间。 **示例**(在流元素之间插入分隔元素) ```ts import { Stream, Effect } from "effect" // Create a stream of numbers and intersperse `0` between them const stream = Stream.make(1, 2, 3, 4, 5).pipe(Stream.intersperse(0)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ 1, 0, 2, 0, 3, 0, 4, 0, 5 ] } */ ``` ### intersperseAffixes 对于更复杂的需求,`Stream.intersperseAffixes` 可以分别控制流开头、元素之间以及流末尾所添加的不同前后缀。 **示例**(为流添加前后缀) ```ts import { Stream, Effect } from "effect" // Create a stream and add affixes: // - `[` at the start // - `|` between elements // - `]` at the end const stream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.intersperseAffixes({ start: "[", middle: "|", end: "]", }), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: { _id: 'Chunk', values: [ '[', 1, '|', 2, '|', 3, '|', 4, '|', 5, ']' ] } */ ``` ## 广播 广播一个流会创建多个下游流,它们都会从源流接收到相同的元素。当你希望把每个元素同时发送给多个消费者时,这很有用。上游流有一个 `maximumLag` 参数,用于限制它在放慢速度以匹配最慢的下游流之前能领先多少。 **示例**(广播到多个下游流) 在下面的示例中,我们把一个数字流广播给两个下游消费者。第一个计算流中的最大值,第二个则带延迟地记录每个数字。上游流的速度会根据较慢的那个日志流进行调整: ```ts import { Effect, Stream, Console, Schedule, Fiber } from "effect" const numbers = Effect.scoped( Stream.range(1, 20).pipe( Stream.tap((n) => Console.log(`Emit ${n} element before broadcasting`)), // Broadcast to 2 downstream consumers with max lag of 5 Stream.broadcast(2, 5), Stream.flatMap(([first, second]) => Effect.gen(function* () { // First downstream stream: calculates maximum const fiber1 = yield* Stream.runFold(first, 0, (acc, e) => Math.max(acc, e), ).pipe( Effect.andThen((max) => Console.log(`Maximum: ${max}`)), Effect.fork, ) // Second downstream stream: logs each element with a delay const fiber2 = yield* second.pipe( Stream.schedule(Schedule.spaced("1 second")), Stream.runForEach((n) => Console.log(`Logging to the Console: ${n}`)), Effect.fork, ) // Wait for both fibers to complete yield* Fiber.join(fiber1).pipe( Effect.zip(Fiber.join(fiber2), { concurrent: true }), ) }), ), Stream.runCollect, ), ) Effect.runPromise(numbers).then(console.log) /* Output: Emit 1 element before broadcasting Emit 2 element before broadcasting Emit 3 element before broadcasting Emit 4 element before broadcasting Emit 5 element before broadcasting Emit 6 element before broadcasting Emit 7 element before broadcasting Emit 8 element before broadcasting Emit 9 element before broadcasting Emit 10 element before broadcasting Emit 11 element before broadcasting Logging to the Console: 1 Logging to the Console: 2 Logging to the Console: 3 Logging to the Console: 4 Logging to the Console: 5 Emit 12 element before broadcasting Emit 13 element before broadcasting Emit 14 element before broadcasting Emit 15 element before broadcasting Emit 16 element before broadcasting Logging to the Console: 6 Logging to the Console: 7 Logging to the Console: 8 Logging to the Console: 9 Logging to the Console: 10 Emit 17 element before broadcasting Emit 18 element before broadcasting Emit 19 element before broadcasting Emit 20 element before broadcasting Logging to the Console: 11 Logging to the Console: 12 Logging to the Console: 13 Logging to the Console: 14 Logging to the Console: 15 Maximum: 20 Logging to the Console: 16 Logging to the Console: 17 Logging to the Console: 18 Logging to the Console: 19 Logging to the Console: 20 { _id: 'Chunk', values: [ undefined ] } */ ``` ## 缓冲 Effect 的流采用拉取(pull-based)模型,下游消费者可以控制自己请求元素的速率。然而,当生产者与消费者的速度不匹配时,缓冲有助于平衡二者的交互。`Stream.buffer` 操作符正是为此设计的:即使消费者较慢,生产者也能继续工作。你可以通过 `capacity` 选项设置缓冲的最大容量。 ### buffer `Stream.buffer` 操作符会把元素排入队列,让生产者能够在指定容量内独立于消费者工作。当较快的生产者与较慢的消费者需要顺畅运行、互不阻塞时,这很有帮助。 **示例**(用缓冲区应对速度不匹配) ```ts import { Stream, Console, Schedule, Effect } from "effect" const stream = Stream.range(1, 10).pipe( // Log each element before buffering Stream.tap((n) => Console.log(`before buffering: ${n}`)), // Buffer with a capacity of 4 elements Stream.buffer({ capacity: 4 }), // Log each element after buffering Stream.tap((n) => Console.log(`after buffering: ${n}`)), // Add a 5-second delay between each emission Stream.schedule(Schedule.spaced("5 seconds")), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: before buffering: 1 before buffering: 2 before buffering: 3 before buffering: 4 before buffering: 5 before buffering: 6 after buffering: 1 after buffering: 2 before buffering: 7 after buffering: 3 before buffering: 8 after buffering: 4 before buffering: 9 after buffering: 5 before buffering: 10 ... */ ``` 不同的缓冲选项让你可以根据使用场景定制缓冲策略: | **缓冲类型** | **配置** | **说明** | | --- | --- | --- | | **有界队列** | `{ capacity: number }` | 把队列限制为固定大小。 | | **无界队列** | `{ capacity: "unbounded" }` | 允许缓冲的条目数量不受限制。 | | **滑动队列** | `{ capacity: number, strategy: "sliding" }` | 保留最新的条目,队列满时丢弃较旧的条目。 | | **丢弃队列** | `{ capacity: number, strategy: "dropping" }` | 保留最早的条目,队列满时丢弃新到的条目。 | ## 防抖 防抖是一种用来避免函数触发过于频繁的技术,当 stream 快速发射值、而我们只需要暂停之后的那最后一个值时,它尤其有用。 `Stream.debounce` 函数实现这一点的做法是:先延迟值的发射,直到经过一段指定的时间都没有新值到来。如果在这段等待期内有新值到达,计时器就会重置,最终只有在暂停之后的最新值才会被发射出去。 **示例**(对快速发射值的 stream 进行防抖) ```ts import { Stream, Effect } from "effect" // Helper function to log with elapsed time since the last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.make(1, 2, 3).pipe( // Emit the value 4 after 200 ms Stream.concat( Stream.fromEffect(Effect.sleep("200 millis").pipe(Effect.as(4))), ), // Continue with more rapid values Stream.concat(Stream.make(5, 6)), // Emit 7 after 150 ms Stream.concat( Stream.fromEffect(Effect.sleep("150 millis").pipe(Effect.as(7))), ), Stream.concat(Stream.make(8)), Stream.tap((n) => log(`Received ${n}`)), // Only emit values after a pause of at least 100 milliseconds Stream.debounce("100 millis"), Stream.tap((n) => log(`> Emitted ${n}`)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: Received 1 after 5ms Received 2 after 2ms Received 3 after 0ms > Emitted 3 after 104ms Received 4 after 99ms Received 5 after 1ms Received 6 after 0ms > Emitted 6 after 101ms Received 7 after 50ms Received 8 after 1ms > Emitted 8 after 101ms { _id: 'Chunk', values: [ 3, 6, 8 ] } */ ``` ## 节流 节流是一种调节 stream 发射元素速率的技术。它有助于保持稳定的数据输出节奏,在数据处理需要以恒定速率进行的场景中很有价值。 `Stream.throttle` 函数使用[令牌桶算法](https://en.wikipedia.org/wiki/Token_bucket)来控制 stream 的发射速率。 **示例**(节流配置) ```ts Stream.throttle({ cost: () => 1, duration: "100 millis", units: 1, }) ``` 在这份配置中: - 每个被处理的 chunk 消耗一个令牌(`cost = () => 1`)。 - 令牌会以每 100 毫秒(`duration: "100 millis"`)补充一个(`units: 1`)的速率得到补充。 ### Shape 策略(默认) "shape" 策略通过延迟 chunk 的发射、直到它们符合指定的带宽约束来调节数据流。 该策略确保数据吞吐量不会超过既定上限,从而实现平稳且受控的数据发射。 **示例**(使用 Shape 策略应用节流) ```ts import { Stream, Effect, Schedule, Chunk } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.fromSchedule(Schedule.spaced("50 millis")).pipe( Stream.take(6), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: Chunk.size, duration: "100 millis", units: 1, }), Stream.tap((n) => log(`> Emitted ${n}`)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: Received 0 after 56ms > Emitted 0 after 0ms Received 1 after 52ms > Emitted 1 after 48ms Received 2 after 52ms > Emitted 2 after 49ms Received 3 after 52ms > Emitted 3 after 48ms Received 4 after 52ms > Emitted 4 after 47ms Received 5 after 52ms > Emitted 5 after 49ms { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5 ] } */ ``` ### Enforce 策略 "enforce" 策略通过丢弃超出带宽约束的 chunk 来严格调节数据流。 **示例**(使用 Enforce 策略进行节流) ```ts import { Stream, Effect, Schedule, Chunk } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.make(1, 2, 3, 4, 5, 6).pipe( Stream.schedule(Schedule.exponential("100 millis")), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: Chunk.size, duration: "1 second", units: 1, strategy: "enforce", }), Stream.tap((n) => log(`> Emitted ${n}`)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: Received 1 after 106ms > Emitted 1 after 1ms Received 2 after 200ms Received 3 after 402ms Received 4 after 801ms > Emitted 4 after 1ms Received 5 after 1601ms > Emitted 5 after 1ms Received 6 after 3201ms > Emitted 6 after 0ms { _id: 'Chunk', values: [ 1, 4, 5, 6 ] } */ ``` ### burst 选项 `Stream.throttle` 函数提供了一个 burst(突发)选项,允许数据吞吐量暂时超出设定的速率上限。 把该选项设为大于 0 即可启用突发能力(默认为 0,表示不支持突发)。 突发容量为令牌桶提供了额外的令牌,使 stream 在出现数据突发时能够短暂超过其配置的速率。 **示例**(带突发容量的节流) ```ts import { Effect, Schedule, Stream, Chunk } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.fromSchedule(Schedule.spaced("10 millis")).pipe( Stream.take(20), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: Chunk.size, duration: "200 millis", units: 5, strategy: "enforce", burst: 2, }), Stream.tap((n) => log(`> Emitted ${n}`)), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Example Output: Received 0 after 16ms > Emitted 0 after 0ms Received 1 after 12ms > Emitted 1 after 0ms Received 2 after 11ms > Emitted 2 after 0ms Received 3 after 11ms > Emitted 3 after 0ms Received 4 after 11ms > Emitted 4 after 1ms Received 5 after 11ms > Emitted 5 after 0ms Received 6 after 12ms > Emitted 6 after 0ms Received 7 after 11ms Received 8 after 12ms Received 9 after 11ms Received 10 after 11ms > Emitted 10 after 0ms Received 11 after 11ms Received 12 after 11ms Received 13 after 12ms > Emitted 13 after 0ms Received 14 after 11ms Received 15 after 12ms Received 16 after 11ms Received 17 after 11ms > Emitted 17 after 0ms Received 18 after 12ms Received 19 after 10ms { _id: 'Chunk', values: [ 0, 1, 2, 3, 4, 5, 6, 10, 13, 17 ] } */ ``` 在这套设置中,stream 一开始的桶里装有 5 个令牌,因而前五个 chunk 可以立即发射。 额外的 2 个突发容量可以暂时容纳更多发射,从而更灵活地处理后续数据。 随着时间的推移,桶会按照节流配置不断补充,更多元素随之被发射出来,这展示了突发能力如何有效地应对不均衡的数据流。 ## 调度 在使用 stream 时,你可能需要为每个元素的发射之间引入特定的时间间隔。`Stream.schedule` 组合子允许你设置这些间隔。 **示例**(在 stream 发射之间添加延迟) ```ts import { Stream, Schedule, Console, Effect } from "effect" // Create a stream that emits values with a 1-second delay between each const stream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.schedule(Schedule.spaced("1 second")), Stream.tap(Console.log), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: 1 2 3 4 5 { _id: "Chunk", values: [ 1, 2, 3, 4, 5 ] } */ ``` 在这个示例中,我们使用 `Schedule.spaced("1 second")` 这个 schedule 在 stream 的每次发射之间引入了一秒的间隔。 --- # 资源管理型 Stream > 学习如何在 Stream 中管理资源:安全地获取与释放、用于清理任务的终结处理(finalization),以及确保在终结之后执行清理动作,从而在流式应用中稳健地处理资源。 在 Stream 模块中,你会发现大多数构造器都提供了一个特殊变体,用于把作用域资源(scoped resource)提升到 `Stream` 中。使用这些特定的构造器时,你本质上是在创建对资源管理天然安全的 stream。这些构造器会在创建 stream 之前完成资源的获取,并在 stream 使用完毕后确保它被正确关闭。 Stream 还提供了 `Stream.acquireRelease` 与 `Stream.finalizer` 构造器,它们与 `Effect.acquireRelease` 和 `Effect.addFinalizer` 有相似之处。这些工具让我们能够在 stream 结束运行之前执行清理或终结处理任务。 ## 获取与释放 本节通过一个示例演示在文件操作中使用 `Stream.acquireRelease`。 ```ts import { Stream, Console, Effect } from "effect" // Simulating File operations const open = (filename: string) => Effect.gen(function* () { yield* Console.log(`Opening ${filename}`) return { getLines: Effect.succeed(["Line 1", "Line 2", "Line 3"]), close: Console.log(`Closing ${filename}`), } }) const stream = Stream.acquireRelease( open("file.txt"), (file) => file.close, ).pipe(Stream.flatMap((file) => file.getLines)) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Opening file.txt Closing file.txt { _id: "Chunk", values: [ [ "Line 1", "Line 2", "Line 3" ] ] } */ ``` 在这段代码中,我们用 `open` 函数模拟文件操作。`Stream.acquireRelease` 用于确保文件被正确打开与关闭,随后我们使用所获取的资源处理文件中的各行。 ## 终结处理 本节将探讨 stream 中的终结处理(finalization)概念。终结处理让我们能在 stream 结束之前执行某个特定动作。当我们想执行清理任务,或对 stream 做最后的收尾时,它会特别有用。 设想这样一个场景:我们的流式应用需要在执行完成时清理一个临时目录。这可以用 `Stream.finalizer` 函数来实现: ```ts import { Stream, Console, Effect } from "effect" const application = Stream.fromEffect(Console.log("Application Logic.")) const deleteDir = (dir: string) => Console.log(`Deleting dir: ${dir}`) const program = application.pipe( Stream.concat( Stream.finalizer( deleteDir("tmp").pipe( Effect.andThen(Console.log("Temporary directory was deleted.")), ), ), ), ) Effect.runPromise(Stream.runCollect(program)).then(console.log) /* Output: Application Logic. Deleting dir: tmp Temporary directory was deleted. { _id: "Chunk", values: [ undefined, undefined ] } */ ``` 在这个代码示例中,我们首先用 `application` stream 表示应用逻辑。接着用 `Stream.finalizer` 定义一个终结处理步骤,它会删除临时目录并输出一条消息。这样就能确保应用执行完毕时临时目录被妥善清理。 ## 确保执行 本节将探讨这样一个场景:我们需要在 stream 终结处理之后执行一些动作。为此,我们可以使用 `Stream.ensuring` 操作符。 设想这样一种情况:应用已经完成主要逻辑,并终结处理了一些资源,但之后我们还需要执行额外的动作。为此可以使用 `Stream.ensuring`: ```ts import { Stream, Console, Effect } from "effect" const program = Stream.fromEffect(Console.log("Application Logic.")).pipe( Stream.concat(Stream.finalizer(Console.log("Finalizing the stream"))), Stream.ensuring( Console.log("Doing some other works after stream's finalization"), ), ) Effect.runPromise(Stream.runCollect(program)).then(console.log) /* Output: Application Logic. Finalizing the stream Doing some other works after stream's finalization { _id: "Chunk", values: [ undefined, undefined ] } */ ``` 在这个代码示例中,我们首先用 `Application Logic.` 这条消息表示应用逻辑。接着用 `Stream.finalizer` 指定终结处理步骤,它会输出 `Finalizing the stream`。之后,我们用 `Stream.ensuring` 表明希望在 stream 终结处理之后再执行一些额外任务,从而产生 `Performing additional tasks after stream's finalization` 这条消息。这样就能确保终结处理之后的动作按预期执行。 --- # TestClock > 在测试中用 Effect 的 TestClock 控制时间,模拟时间流逝、延迟与周期性重复的 Effect,而无需等待真实时间。 大多数情况下,我们希望单元测试尽可能快地运行。等待真实时间流逝会显著拖慢测试速度。Effect 提供了一个名为 `TestClock` 的便捷工具,它让我们能够**在测试期间控制时间**。这意味着我们可以高效且可预测地测试涉及时间的代码,而无需等待真实时间流逝。 ## TestClock 的工作原理 可以把 `TestClock` 想象成一个挂钟,只有当我们用 `TestClock.adjust` 和 `TestClock.setTime` 函数手动调整它时,它才会向前走。时钟时间不会自行推进。 当我们调整时钟时间时,任何计划在该时间点或之前运行的 Effect 都会执行。这让我们能在测试中模拟时间流逝,而无需等待真实时间。 **示例**(用 TestClock 模拟超时) ```ts import { Effect, TestClock, Fiber, Option, TestContext } from "effect" import * as assert from "node:assert" const test = Effect.gen(function* () { // Create a fiber that sleeps for 5 minutes and then times out // after 1 minute const fiber = yield* Effect.sleep("5 minutes").pipe( Effect.timeoutTo({ duration: "1 minute", onSuccess: Option.some, onTimeout: () => Option.none(), }), Effect.fork, ) // Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute") // Get the result of the fiber const result = yield* Fiber.join(fiber) // Check if the result is None, indicating a timeout assert.ok(Option.isNone(result)) }).pipe(Effect.provide(TestContext.TestContext)) Effect.runPromise(test) ``` 关键点在于要把调用 `Effect.sleep` 的那个 fiber fork 出去。对 `Effect.sleep` 及相关方法的调用会一直等待,直到时钟时间达到或超过它们计划执行的时间。通过 fork 这个 fiber,我们就能保留对时钟时间调整的控制权。 ## 测试周期性重复的 Effect 下面这个示例演示如何用 `TestClock` 测试一个按固定间隔运行的 Effect: **示例**(测试按固定间隔运行的 Effect) 在这个示例中,我们测试一个按固定间隔运行的 Effect。我们用一个无界队列来管理这些 Effect,并验证以下几点: 1. 在指定的重复周期之前不会发生任何 Effect。 2. 在重复周期之后会发生一次 Effect。 3. 该 Effect 恰好只执行一次。 ```ts import { Effect, Queue, TestClock, Option, TestContext } from "effect" import * as assert from "node:assert" const test = Effect.gen(function* () { const q = yield* Queue.unbounded() yield* Queue.offer(q, undefined).pipe( // Delay the effect for 60 minutes and repeat it forever Effect.delay("60 minutes"), Effect.forever, Effect.fork, ) // Check if no effect is performed before the recurrence period const a = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone)) // Adjust the TestClock by 60 minutes to simulate the passage of time yield* TestClock.adjust("60 minutes") // Check if an effect is performed after the recurrence period const b = yield* Queue.take(q).pipe(Effect.as(true)) // Check if the effect is performed exactly once const c = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone)) // Adjust the TestClock by another 60 minutes yield* TestClock.adjust("60 minutes") // Check if another effect is performed const d = yield* Queue.take(q).pipe(Effect.as(true)) const e = yield* Queue.poll(q).pipe(Effect.andThen(Option.isNone)) // Ensure that all conditions are met assert.ok(a && b && c && d && e) }).pipe(Effect.provide(TestContext.TestContext)) Effect.runPromise(test) ``` 需要注意,每次重复之后,下一次重复都会被安排在合适的时间发生。把时钟调整 60 分钟恰好会向队列放入一个值;再调整 60 分钟又会增加一个值。 ## 测试 Clock 这个示例演示如何用 `TestClock` 测试 `Clock` 的行为: **示例**(用 TestClock 模拟时间流逝) ```ts import { Effect, Clock, TestClock, TestContext } from "effect" import * as assert from "node:assert" const test = Effect.gen(function* () { // Get the current time using the Clock const startTime = yield* Clock.currentTimeMillis // Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute") // Get the current time again const endTime = yield* Clock.currentTimeMillis // Check if the time difference is at least // 60,000 milliseconds (1 minute) assert.ok(endTime - startTime >= 60_000) }).pipe(Effect.provide(TestContext.TestContext)) Effect.runPromise(test) ``` ## 测试 Deferred `TestClock` 同样会影响那些计划在特定时间之后运行的异步代码。 **示例**(用 Deferred 和 TestClock 模拟延迟执行) ```ts import { Effect, Deferred, TestClock, TestContext } from "effect" import * as assert from "node:assert" const test = Effect.gen(function* () { // Create a deferred value const deferred = yield* Deferred.make() // Run two effects concurrently: sleep for 10 seconds and succeed // the deferred with a value of 1 yield* Effect.all( [Effect.sleep("10 seconds"), Deferred.succeed(deferred, 1)], { concurrency: "unbounded", }, ).pipe(Effect.fork) // Adjust the TestClock by 10 seconds yield* TestClock.adjust("10 seconds") // Await the value from the deferred const readRef = yield* Deferred.await(deferred) // Verify the deferred value is correctly set assert.ok(readRef === 1) }).pipe(Effect.provide(TestContext.TestContext)) Effect.runPromise(test) ``` --- # Equal > 实现基于值的相等性检查,以提升 TypeScript 中的数据完整性与行为的可预测性。 `Equal` 模块提供了一种简单便捷的方式,用于在 TypeScript 中定义并检查两个值之间的相等性。 以下是 Effect 导出 `Equal` 模块的几个关键原因: 1. **基于值的相等性**:JavaScript 原生的相等运算符(`===` 和 `==`)按引用检查相等性,也就是说,它们根据对象的内存地址而非内容来比较对象。当你想比较值相同但引用不同的对象时,这种行为就会带来问题。`Equal` 模块提供了一种解决方案:允许开发者基于对象的值定义自定义的相等性检查。 2. **自定义相等性**:`Equal` 模块让开发者可以为自己的数据类型和类实现自定义的相等性检查。当你对「两个对象何时应被视为相等」有特定要求时,这一点至关重要。通过实现 `Equal` 接口,开发者可以定义自己的相等逻辑。 3. **数据完整性**:在某些应用中,维护数据完整性至关重要。能够执行基于值的相等性检查,可以确保相同的数据不会在 set、map 之类的集合中重复出现。这有助于更高效地使用内存,并带来更可预测的行为。 4. **可预测的行为**:`Equal` 模块让比较对象时的行为更加可预测。通过显式定义相等性判定标准,开发者可以避免 JavaScript 默认的基于引用的相等性检查可能带来的意外结果。 ## 如何在 Effect 中进行相等性检查 在 Effect 中,建议**停止使用** JavaScript 的 `===` 和 `==` 运算符,转而依赖 `Equal.equals` 函数。 该函数适用于任何实现了 `Equal` 接口的数据类型。 这类数据类型的例子包括 [Option](/docs/v3/data-types/option/)、[Either](/docs/v3/data-types/either/)、[HashSet](https://effect.website/docs/v3/api/effect/HashSet) 和 [HashMap](https://effect.website/docs/v3/api/effect/HashMap)。 当你使用 `Equal.equals` 而对象并未实现 `Equal` 接口时,它会默认使用 `===` 运算符来比较对象: **示例**(使用 `Equal.equals` 的默认比较) ```ts import { Equal } from "effect" // Two objects with identical properties and values const a = { name: "Alice", age: 30 } const b = { name: "Alice", age: 30 } // Equal.equals falls back to the default '===' comparison console.log(Equal.equals(a, b)) // Output: false ``` 在这个例子中,`a` 和 `b` 是两个内容相同但彼此独立的对象。然而,由于它们占用不同的内存位置,`===` 认为它们不同。当你想根据内容比较值时,这种行为可能导致意外结果。 不过,你可以配置自己的模型,以确保 `Equal.equals` 的行为与你的自定义相等性检查保持一致。有两种可选的做法: 1. **实现 `Equal` 接口**:当你需要定义自定义的相等性检查时,这种方式很有用。 2. **使用 Data 模块**:对于简单的值相等性,[Data](/docs/v3/data-types/data/) 模块提供了一种更直接的方案:自动为 `Equal` 生成默认实现。 下面我们来分别看看这两种方式。 ### 实现 `Equal` 接口 要创建自定义的相等行为,你可以在自己的模型中实现 `Equal` 接口。该接口扩展了 [Hash](/docs/v3/trait/hash/) 模块中的 `Hash` 接口。 **示例**(为自定义类实现 `Equal` 和 `Hash`) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier readonly name: string, readonly age: number, ) {} // Define equality based on id, name, and age [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generate a hash code based on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } ``` 在上面的代码中,我们为 `Person` 类定义了自定义的相等函数 `[Equal.symbol]` 和哈希函数 `[Hash.symbol]`。`Hash` 接口通过比较哈希值而非对象本身来优化相等性检查。当你使用 `Equal.equals` 函数比较两个对象时,它首先检查两者的哈希值是否相等。如果不相等,它就能迅速判定这两个对象不相等,从而无需逐属性进行细致比较。 实现 `Equal` 接口之后,你就可以使用 `Equal.equals` 函数,按自定义的逻辑来检查相等性。 **示例**(比较 `Person` 实例) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier for each person readonly name: string, readonly age: number, ) {} // Defines equality based on id, name, and age [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generates a hash code based primarily on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } const alice = new Person(1, "Alice", 30) console.log(Equal.equals(alice, new Person(1, "Alice", 30))) // Output: true const bob = new Person(2, "Bob", 40) console.log(Equal.equals(alice, bob)) // Output: false ``` 在这段代码中,当把 `alice` 与一个属性值完全相同的新 `Person` 对象比较时,相等性检查返回 `true`;而把 `alice` 与 `bob` 比较时,由于二者属性值不同,返回 `false`。 ### 使用 Data 模块简化相等性 当你需要的只是简单的值相等性检查时,同时实现 `Equal` 和 `Hash` 可能会变得很繁琐。所幸,[Data](/docs/v3/data-types/data/) 模块提供了更简单的方案。它提供的 API 可以自动为 `Equal` 和 `Hash` 生成默认实现。 **示例**(使用 `Data.struct` 进行相等性检查) ```ts import { Equal, Data } from "effect" const alice = Data.struct({ id: 1, name: "Alice", age: 30 }) const bob = Data.struct({ id: 2, name: "Bob", age: 40 }) console.log(Equal.equals(alice, Data.struct({ id: 1, name: "Alice", age: 30 }))) // Output: true console.log(Equal.equals(alice, { id: 1, name: "Alice", age: 30 })) // Output: false console.log(Equal.equals(alice, bob)) // Output: false ``` 在这个例子中,我们使用 [Data.struct](/docs/v3/data-types/data/#struct) 函数创建结构化数据对象,并用 `Equal.equals` 检查它们的相等性。Data 模块通过为 `Equal` 和 `Hash` 提供默认实现简化了这一过程,让你无需编写显式实现,就能专注于值的比较。 Data 模块不仅限于 struct。它还能处理多种数据类型,包括元组、数组和记录。如果你想了解如何充分利用它的全部功能,可以查阅 [Data 模块文档](/docs/v3/data-types/data/#value-equality)。 ## 处理集合 在检查相等性方面,JavaScript 内置的 `Set` 和 `Map` 可能会有些棘手: **示例**(使用基于引用的相等性的原生 `Set`) ```ts const set = new Set() // Adding two objects with the same content to the set set.add({ name: "Alice", age: 30 }) set.add({ name: "Alice", age: 30 }) // Even though the objects have identical values, they are treated // as different elements because JavaScript compares objects by reference, // not by value. console.log(set.size) // Output: 2 ``` 尽管集合中的两个元素具有相同的值,这个集合却包含两个元素。为什么?因为 JavaScript 的 `Set` 按引用而非按值检查相等性。 要执行基于值的相等性检查,你需要使用 `effect` 包中提供的 `Hash*` 集合类型。这些集合类型(例如 [HashSet](https://effect.website/docs/v3/api/effect/HashSet) 和 [HashMap](https://effect.website/docs/v3/api/effect/HashMap))支持 `Equal` 接口。 ### HashSet 使用 `HashSet` 时,它能正确处理基于值的相等性检查。在下面的例子中,尽管你添加了两个值相同的对象,`HashSet` 仍将它们视为单个元素。 **示例**(使用 `HashSet` 实现基于值的相等性) ```ts import { HashSet, Data } from "effect" // Creating a HashSet with objects that implement the Equal interface const set = HashSet.empty().pipe( HashSet.add(Data.struct({ name: "Alice", age: 30 })), HashSet.add(Data.struct({ name: "Alice", age: 30 })), ) // HashSet recognizes them as equal, so only one element is stored console.log(HashSet.size(set)) // Output: 1 ``` **注意**:务必使用实现了 `Equal` 接口的元素,无论是通过实现自定义的相等性检查,还是通过使用 Data 模块。这样才能保证 `HashSet` 正常工作。否则,你会遇到与原生 `Set` 数据类型相同的行为: **示例**(`HashSet` 中基于引用的相等性) ```ts import { HashSet } from "effect" // Creating a HashSet with objects that do NOT implement // the Equal interface const set = HashSet.empty().pipe( HashSet.add({ name: "Alice", age: 30 }), HashSet.add({ name: "Alice", age: 30 }), ) // Since these objects are compared by reference, // HashSet considers them different console.log(HashSet.size(set)) // Output: 2 ``` 在这种情况下,如果不搭配 Data 模块使用 `HashSet`,你会遇到与原生 `Set` 数据类型相同的行为。这个集合包含两个元素,因为它按引用而非按值检查相等性。 ### HashMap 使用 `HashMap` 时,你可以按值而非按引用来比较键,这是一个优势。在希望根据键的内容来关联值的场景中,这一点尤其有用。 **示例**(使用 `HashMap` 进行基于值的键比较) ```ts import { HashMap, Data } from "effect" // Adding two objects with identical values as keys const map = HashMap.empty().pipe( HashMap.set(Data.struct({ name: "Alice", age: 30 }), 1), HashMap.set(Data.struct({ name: "Alice", age: 30 }), 2), ) console.log(HashMap.size(map)) // Output: 1 // Retrieve the value associated with a key console.log(HashMap.get(map, Data.struct({ name: "Alice", age: 30 }))) /* Output: { _id: 'Option', _tag: 'Some', value: 2 } */ ``` 在这段代码中,`HashMap` 用于创建一个映射,其中的键是用 `Data.struct` 构造的对象。这些对象包含相同的值;由于普通 JavaScript `Map` 的默认比较是基于引用的,它们通常会在其中形成两个独立的条目。 然而,`HashMap` 使用基于值的比较,这意味着内容相同的两个对象会被视为同一个键。因此,当我们把两个对象都加入时,第二个键值对会覆盖第一个,最终映射中只有一个条目。 --- # Hash > 通过高效的哈希来优化相等性检查,从而在哈希集合、哈希映射等集合中实现更快的比较。 `Hash` 接口与 [Equal](/docs/v3/trait/equal/) 接口密切相关,它通过提供一种哈希机制,在优化相等性检查方面扮演辅助角色。哈希是高效判断两个值是否相等的重要步骤,尤其是在与哈希表这类数据结构配合使用时。 ## Hash 在相等性检查中的角色 `Hash` 接口的主要目的是提供一种快速且高效的方式,用来判断两个值是否**一定不相等**,从而对 [Equal](/docs/v3/trait/equal/) 接口形成补充。当两个值都实现了 [Equal](/docs/v3/trait/equal/) 接口时,会先比较它们的哈希值(通过 `Hash` 接口计算得出): - **哈希值不同**:如果哈希值不同,那么可以确定这两个值本身也不同。这一快速检查使系统能够避免一次可能开销很大的相等性比较。 - **哈希值相同**:如果哈希值相同,并不能保证这两个值相等,只能说它们可能相等。在这种情况下,会使用 [Equal](/docs/v3/trait/equal/) 接口执行一次更彻底的比较,以确定它们是否真正相等。 这种方式极大地加快了相等性检查的过程,尤其是在那些快速查找和插入至关重要的集合中,例如哈希集合(hash set)或哈希映射(hash map)。 ## 实现 Hash 接口 设想这样一个场景:你有一个自定义的 `Person` 类,并且希望根据属性判断两个实例是否相等。 通过同时实现 `Equal` 和 `Hash` 接口,你可以高效地管理这些检查: **示例**(为自定义类实现 `Equal` 和 `Hash`) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier readonly name: string, readonly age: number, ) {} // Define equality based on id, name, and age [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generate a hash code based on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } const alice = new Person(1, "Alice", 30) console.log(Equal.equals(alice, new Person(1, "Alice", 30))) // Output: true const bob = new Person(2, "Bob", 40) console.log(Equal.equals(alice, bob)) // Output: false ``` 说明: - `[Equal.symbol]` 方法通过比较 `Person` 实例的 `id`、`name` 和 `age` 字段来判断相等性。这种方式确保相等性检查是全面的,会考虑所有相关属性。 - `[Hash.symbol]` 方法使用这个 person 的 `id` 计算哈希码。该值用于在哈希操作中快速区分不同实例,从而优化那些使用哈希的数据结构的性能。 - 当把 `alice` 与一个属性值完全相同的新 `Person` 对象比较时,相等性检查返回 `true`;而把 `alice` 与 `bob` 比较时,由于它们的属性值不同,返回 `false`。 --- # 批处理 > 通过批处理请求并减少冗余 API 调用优化性能,提升数据获取与处理的效率。 在典型的应用开发中,当我们需要与外部 API、数据库或其他数据源交互时,常常会定义一些函数来发起请求,并相应地处理它们的结果或失败。 ### 简单的模型搭建 下面是一个基础模型,它勾勒出我们的数据结构以及可能出现的错误: ```ts import { Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} new GetUserError()._tag // => "GetUserError" ``` ### 定义 API 函数 接下来我们定义一些与外部 API 交互的函数,处理诸如获取 Todo 列表、查询用户详情和发送邮件这样的常见操作。 ```ts import { Effect, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // API // ------------------------------ // Fetches a list of todos from an external API const getTodos = Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }) // Retrieves a user by their ID from an external API const getUserById = (id: number) => Effect.tryPromise({ try: () => fetch(`https://api.example.demo/getUserById?id=${id}`).then( (res) => res.json() as Promise, ), catch: () => new GetUserError(), }) // Sends an email via an external API const sendEmail = (address: string, text: string) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmail", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ address, text }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }) // Sends an email to a user by fetching their details first const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Notifies the owner of a todo by sending them an email const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) new SendEmailError()._tag // => "SendEmailError" ``` 虽然这种做法直观易读,但未必最高效。重复的 API 调用,尤其是当多个 Todo 属于同一个所有者时,会显著增加网络开销,拖慢应用。 ### 使用这些 API 函数 这些函数清晰易懂,但使用它们的方式未必最高效。例如,通知 Todo 所有者会涉及重复的 API 调用,而这部分是可以优化的。 ```ts import { Effect, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // API // ------------------------------ // Fetches a list of todos from an external API const getTodos = Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }) // Retrieves a user by their ID from an external API const getUserById = (id: number) => Effect.tryPromise({ try: () => fetch(`https://api.example.demo/getUserById?id=${id}`).then( (res) => res.json() as Promise, ), catch: () => new GetUserError(), }) // Sends an email via an external API const sendEmail = (address: string, text: string) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmail", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ address, text }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }) // Sends an email to a user by fetching their details first const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Notifies the owner of a todo by sending them an email const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) // Orchestrates operations on todos, notifying their owners const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }) new GetTodosError()._tag // => "GetTodosError" ``` 这个实现会为每个 Todo 分别执行一次 API 调用,以获取所有者详情并发送邮件。如果多个 Todo 属于同一个所有者,就会产生冗余的 API 调用。 ## 批处理 假设 `getUserById` 和 `sendEmail` 可以批量执行。这意味着我们能在一次 HTTP 调用中发送多个请求,从而减少 API 请求数量并提升性能。 **批处理的分步指南** 1. **声明请求:** 我们首先把请求转换成结构化的数据模型。这需要详细描述输入参数、预期输出以及可能出现的错误。以这种方式组织请求,不仅有助于高效地管理数据,还能比较不同的请求,判断它们是否引用了相同的输入参数。 2. **声明 Resolver:** Resolver 旨在同时处理多个请求。借助比较请求的能力(确保它们引用相同的输入参数),Resolver 可以一次性执行多个请求,从而最大限度地发挥批处理的价值。 3. **定义查询:** 最后,我们定义一些查询,利用这些批量 Resolver 来执行操作。这一步把结构化的请求及其对应的 Resolver 组合成应用中可用的组成部分。 ### 声明请求 我们将借助 `Request` 这一概念,设计一个数据源可能支持的模型: ```ts Request ``` `Request` 是一种构造,表示对类型为 `Value` 的值的请求,它可能以类型为 `Error` 的错误失败。 我们先为数据源能够处理的各类请求定义一个结构化模型。 ```ts import { Request, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") GetTodos()._tag // => "GetTodos" ``` 每个请求都用一个具体的数据结构来定义,它继承自通用的 `Request` 类型,从而确保每个请求都携带自己特有的数据需求以及特定的错误类型。 通过使用 `Request.tagged` 这类带标签的构造器,我们可以轻松创建请求对象,使它们在整个应用中都能被识别和管理。 ### 声明 Resolver 定义好请求之后,下一步是配置 Effect 如何使用 `RequestResolver` 解析这些请求: ```ts RequestResolver ``` `RequestResolver` 能够执行类型为 `A` 的请求。传给 `Effect.request` 的 Resolver 自身不能带有任何依赖要求;关于如何在构造 Resolver 之前解析服务,请参见[带上下文的 Resolver](#resolvers-with-context)。 本节中,我们会为每种请求分别创建独立的 Resolver。Resolver 的粒度可以不同,但通常按照对应 API 调用是否支持批量处理来划分。 ```ts import { Effect, Request, RequestResolver, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers // ------------------------------ // Assuming GetTodos cannot be batched, we create a standard resolver const GetTodosResolver = RequestResolver.fromEffect( (_: Request.Entry): Effect.Effect => Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }), ) // Assuming GetUserById can be batched, we create a batched resolver const GetUserByIdResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/getUserByIdBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ users: entries.map(({ request }) => ({ id: request.id })), }), }).then((res) => res.json()) as Promise>, catch: () => new GetUserError(), }).pipe( Effect.andThen((users) => Effect.forEach(entries, (entry, index) => Request.completeEffect(entry, Effect.succeed(users[index]!)), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) // Assuming SendEmail can be batched, we create a batched resolver const SendEmailResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmailBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ emails: entries.map(({ request }) => ({ address: request.address, text: request.text, })), }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }).pipe( Effect.andThen( Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.void), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) SendEmail({ address: "a@b.com", text: "hi" })._tag // => "SendEmail" ``` 在这个配置中: - **GetTodosResolver** 负责获取多个 `Todo` 项。因为我们假设它不能批量处理,所以把它配置为普通 Resolver。 - **GetUserByIdResolver** 和 **SendEmailResolver** 被配置为批量 Resolver。这样设置的前提是这些请求可以按批处理,从而提升性能并减少 API 调用次数。 ### 定义查询 现在解析器已经就绪,我们可以把所有部分串联起来定义查询了。这一步让我们能够在应用中高效地执行数据操作。 ```ts import { Effect, Request, RequestResolver, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers // ------------------------------ // Assuming GetTodos cannot be batched, we create a standard resolver const GetTodosResolver = RequestResolver.fromEffect( (_: Request.Entry): Effect.Effect => Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }), ) // Assuming GetUserById can be batched, we create a batched resolver const GetUserByIdResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/getUserByIdBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ users: entries.map(({ request }) => ({ id: request.id })), }), }).then((res) => res.json()) as Promise>, catch: () => new GetUserError(), }).pipe( Effect.andThen((users) => Effect.forEach(entries, (entry, index) => Request.completeEffect(entry, Effect.succeed(users[index]!)), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) // Assuming SendEmail can be batched, we create a batched resolver const SendEmailResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmailBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ emails: entries.map(({ request }) => ({ address: request.address, text: request.text, })), }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }).pipe( Effect.andThen( Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.void), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) // ------------------------------ // Queries // ------------------------------ // Defines a query to fetch all Todo items const getTodos: Effect.Effect, GetTodosError> = Effect.request( GetTodos(), GetTodosResolver, ) // Defines a query to fetch a user by their ID const getUserById = (id: number) => Effect.request(GetUserById({ id }), GetUserByIdResolver) // Defines a query to send an email to a specific address const sendEmail = (address: string, text: string) => Effect.request(SendEmail({ address, text }), SendEmailResolver) // Composes getUserById and sendEmail to send an email to a specific user const sendEmailToUser = (id: number, message: string) => getUserById(id).pipe(Effect.andThen((user) => sendEmail(user.email, message))) // Uses getUserById to fetch the owner of a Todo and then sends them an email notification const notifyOwner = (todo: Todo) => getUserById(todo.ownerId).pipe( Effect.andThen((user) => sendEmailToUser(user.id, `hey ${user.name} you got a todo!`), ), ) GetUserById({ id: 1 }).id // => 1 ``` 通过使用 `Effect.request` 函数,我们让解析器与请求模型有效地结合在一起。这种方式确保每个查询都能用恰当的解析器以最优方式完成。 尽管代码结构与前面的示例看起来相似,但使用解析器能显著提升效率:它优化了请求的处理方式,并减少了不必要的 API 调用。 ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { batching: true, }) }) ``` 在最终的配置下,无论有多少个 todo,这个程序都只会向 API 执行 **3** 次查询。这与传统方式形成鲜明对比:后者可能执行 **1 + 2n** 次查询,其中 **n** 是 todo 的数量。这是效率上的显著提升,尤其是对于数据交互量很大的应用而言。 ### 带上下文的解析器 在复杂的应用中,解析器通常需要访问共享服务或配置,才能有效地处理请求。然而,在提供必要上下文的同时保持请求批处理能力,可能颇具挑战。这里我们将探讨如何在解析器中管理上下文,以确保批处理能力不受影响。 在创建请求解析器时,谨慎管理上下文至关重要。为解析器提供过多的上下文,或给不同的解析器提供不同的服务,都会使它们无法兼容批处理。为避免这类问题,传给 `Effect.request` 的解析器,其上下文被显式设为 `never`。这迫使开发者明确界定上下文在解析器内部是如何被访问和使用的。 考虑下面的例子,我们搭建了一个 HTTP 服务,供解析器用来执行 API 调用: ```ts import { Effect, Context, RequestResolver, Request, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request< Array, GetTodosError, HttpService > { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers With Context // ------------------------------ class HttpService extends Context.Service< HttpService, { fetch: typeof fetch } >()("HttpService") {} // the resolver itself must have no requirements, so we resolve HttpService // before constructing it, and let that outer effect carry the requirement const GetTodosResolver = Effect.map(HttpService, (http) => RequestResolver.fromEffect( (_: Request.Entry): Effect.Effect, GetTodosError> => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json() as Promise>), catch: () => new GetTodosError(), }), ), ) HttpService.key // => "HttpService" ``` 现在可以看到,`GetTodosResolver` 的类型不再是 `RequestResolver`,而是: ```ts const GetTodosResolver: Effect, never, HttpService> ``` 这是一个 effect,它访问 `HttpService`,并返回一个已经组装好、具备最小可用上下文的解析器。 有了这样一个 effect,我们就可以直接在查询定义中使用它: ```ts const getTodos: Effect.Effect = Effect.request(GetTodos(), GetTodosResolver) ``` 可以看到,这个 Effect 正确地要求提供 `HttpService`。 另一种做法是,把 `RequestResolver` 作为 `Layer` 的一部分来创建,在构造时直接访问上下文,或闭包捕获上下文。 **示例** ```ts import { Effect, Context, RequestResolver, Request, Layer, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers With Context // ------------------------------ class HttpService extends Context.Service< HttpService, { fetch: typeof fetch } >()("HttpService") {} // the resolver itself must have no requirements, so we resolve HttpService // before constructing it, and let that outer effect carry the requirement const GetTodosResolver = Effect.map(HttpService, (http) => RequestResolver.fromEffect( (_: Request.Entry): Effect.Effect, GetTodosError> => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json() as Promise>), catch: () => new GetTodosError(), }), ), ) // ------------------------------ // Layers // ------------------------------ class TodosService extends Context.Service< TodosService, { getTodos: Effect.Effect, GetTodosError> } >()("TodosService") {} const TodosServiceLive = Layer.effect( TodosService, Effect.gen(function* () { const http = yield* HttpService const resolver = RequestResolver.fromEffect((_: Request.Entry) => Effect.tryPromise({ try: () => http .fetch("https://api.example.demo/todos") .then((res) => res.json()), catch: () => new GetTodosError(), }), ) return { getTodos: Effect.request(GetTodos(), resolver), } }), ) const getTodos: Effect.Effect< Array, GetTodosError, TodosService > = Effect.andThen(TodosService, (service) => service.getTodos) TodosService.key // => "TodosService" ``` 鉴于 `Layer` 是把服务装配到一起的自然原语,对大多数场景而言,这种方式很可能也是最好的。 ## 缓存 虽然我们已经大幅优化了请求批处理,但还有一个领域可以进一步提升应用的效率:缓存。没有缓存时,即使批处理已经过优化,相同的请求仍可能被执行多次,导致不必要的数据获取。 缓存配置在 resolver 上。`RequestResolver.withCache` 会把 resolver 包装进一个以请求相等性为键的有界内存缓存。任何基于该 resolver、用 `Effect.request` 构建的查询都会自动沿用相同的缓存行为。 下面是为 `getUserById` 查询实现缓存的方式: ```ts import { Effect, Request, RequestResolver, Data } from "effect" // ------------------------------ // Model // ------------------------------ interface User { readonly _tag: "User" readonly id: number readonly name: string readonly email: string } class GetUserError extends Data.TaggedError("GetUserError")<{}> {} interface Todo { readonly _tag: "Todo" readonly id: number readonly message: string readonly ownerId: number } class GetTodosError extends Data.TaggedError("GetTodosError")<{}> {} class SendEmailError extends Data.TaggedError("SendEmailError")<{}> {} // ------------------------------ // Requests // ------------------------------ // Define a request to get multiple Todo items which might // fail with a GetTodosError interface GetTodos extends Request.Request, GetTodosError> { readonly _tag: "GetTodos" } // Create a tagged constructor for GetTodos requests const GetTodos = Request.tagged("GetTodos") // Define a request to fetch a User by ID which might // fail with a GetUserError interface GetUserById extends Request.Request { readonly _tag: "GetUserById" readonly id: number } // Create a tagged constructor for GetUserById requests const GetUserById = Request.tagged("GetUserById") // Define a request to send an email which might // fail with a SendEmailError interface SendEmail extends Request.Request { readonly _tag: "SendEmail" readonly address: string readonly text: string } // Create a tagged constructor for SendEmail requests const SendEmail = Request.tagged("SendEmail") // ------------------------------ // Resolvers // ------------------------------ // Assuming GetTodos cannot be batched, we create a standard resolver const GetTodosResolver = RequestResolver.fromEffect( (_: Request.Entry): Effect.Effect => Effect.tryPromise({ try: () => fetch("https://api.example.demo/todos").then( (res) => res.json() as Promise>, ), catch: () => new GetTodosError(), }), ) // Assuming GetUserById can be batched, we create a batched resolver const GetUserByIdResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/getUserByIdBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ users: entries.map(({ request }) => ({ id: request.id })), }), }).then((res) => res.json()) as Promise>, catch: () => new GetUserError(), }).pipe( Effect.andThen((users) => Effect.forEach(entries, (entry, index) => Request.completeEffect(entry, Effect.succeed(users[index]!)), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) // Assuming SendEmail can be batched, we create a batched resolver const SendEmailResolver = RequestResolver.make( (entries: ReadonlyArray>) => Effect.tryPromise({ try: () => fetch("https://api.example.demo/sendEmailBatch", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ emails: entries.map(({ request }) => ({ address: request.address, text: request.text, })), }), }).then((res) => res.json() as Promise), catch: () => new SendEmailError(), }).pipe( Effect.andThen( Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.void), ), ), Effect.catch((error) => Effect.forEach(entries, (entry) => Request.completeEffect(entry, Effect.fail(error)), ), ), ), ) // ------------------------------ // Caching // ------------------------------ // Wrap the resolver in a bounded, in-memory cache keyed by request equality. // Build it once and reuse the resulting resolver for every call. const cachedGetUserByIdResolver = Effect.runSync( RequestResolver.withCache(GetUserByIdResolver, { capacity: 256 }), ) const getUserById = (id: number) => Effect.request(GetUserById({ id }), cachedGetUserByIdResolver) cachedGetUserByIdResolver === GetUserByIdResolver // => false ``` ## 最终程序 假设你已经把所有部分正确串联起来: ```ts const program = Effect.gen(function* () { const todos = yield* getTodos yield* Effect.forEach(todos, (todo) => notifyOwner(todo), { concurrency: "unbounded", }) }).pipe(Effect.repeat(Schedule.fixed("10 seconds"))) ``` 在这个程序中,`getTodos` 操作会获取每个用户的 todo。随后,`Effect.forEach` 函数用于并发地通知每个 todo 的所有者,而无需等待这些通知完成。 `repeat` 函数被应用于整条操作链,它使用固定调度(fixed schedule)确保程序每 10 秒重复一次。这意味着整个流程——包括获取 todo 与发送通知——都会以 10 秒为间隔反复执行。 由于 `getUserById` 建立在 `cachedGetUserByIdResolver` 之上,只要该 resolver 的缓存尚未淘汰对应的 `GetUserById` 请求(淘汰受传给 `RequestResolver.withCache` 的 `capacity` 限制),程序就会复用该请求的缓存结果,从而减少获取用户数据的不必要请求。 此外,该程序设计为批量发送邮件,从而实现高效处理并更好地利用资源。 ## 自定义请求缓存 `RequestResolver.withCache` 接受一个 `strategy` 选项(`"lru"`(默认)或 `"fifo"`),用于控制当缓存达到 `capacity` 后淘汰哪个条目: ```ts const fifoCachedResolver = Effect.runSync( RequestResolver.withCache(GetUserByIdResolver, { capacity: 256, strategy: "fifo", }), ) ``` 以这种方式创建的缓存条目不会按时间过期。如果你需要基于存活时间(time-to-live)的过期机制,或者希望把缓存查找暴露为一等的 `Cache`(带有 `get`/`refresh`/`invalidate`,参见 [Cache](/docs/v4/caching/cache/))而不是普通的 `RequestResolver`,请改用 [`RequestResolver.asCache`](https://effect.website/docs/v4/api/effect/RequestResolver)。它接受同样的 `capacity` 选项,外加一个可选的 `timeToLive`。 --- # Equivalence > 为 TypeScript 值定义并自定义等价关系。 `Equivalence` 模块提供了一种在 TypeScript 中定义值之间等价关系的方式。等价关系是一种自反、对称且传递的二元关系,它为“两个值何时应被视为等价”建立了形式化的定义。 ## 什么是 Equivalence? 一个 `Equivalence` 表示一个函数,它比较两个类型为 `A` 的值并判断它们是否等价。与使用 `===` 的简单相等性检查相比,这种方式更灵活、也更可定制。 `Equivalence` 的结构如下: ```ts interface Equivalence { (self: A, that: A): boolean } ``` ## 使用内置的 Equivalence 该模块为常见数据类型提供了若干内置的等价关系: | Equivalence | 说明 | | ----------------------- | ------------------------------------------- | | `String` | 对字符串使用严格相等(`===`) | | `Number` | 对数字使用严格相等(`===`) | | `Boolean` | 对布尔值使用严格相等(`===`) | | `strictEqual()` | 对 symbol 使用严格相等(`===`) | | `BigInt` | 对 bigint 使用严格相等(`===`) | | `Date` | 按时间戳比较 `Date` 对象 | **示例**(使用内置的 Equivalence) ```ts import { Equivalence } from "effect" console.log(Equivalence.String("apple", "apple")) Equivalence.String("apple", "apple") // => true console.log(Equivalence.String("apple", "orange")) Equivalence.String("apple", "orange") // => false console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 1, 1))) Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 1, 1)) // => true console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 10, 1))) Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 10, 1)) // => false ``` ## 派生 Equivalence 对于更复杂的数据结构,你可能需要自定义的等价关系。`Equivalence` 模块允许你通过 `Equivalence.mapInput` 函数,从已有的 `Equivalence` 实例派生出新的实例。 **示例**(为对象创建自定义的 Equivalence) ```ts import { Equivalence } from "effect" interface User { readonly id: number readonly name: string } // Create an equivalence that compares User objects based only on the id const equivalence = Equivalence.mapInput( Equivalence.Number, // Base equivalence for comparing numbers (user: User) => user.id, // Function to extract the id from a User ) // Compare two User objects: they are equivalent if their ids are the same console.log(equivalence({ id: 1, name: "Alice" }, { id: 1, name: "Al" })) equivalence({ id: 1, name: "Alice" }, { id: 1, name: "Al" }) // => true ``` `Equivalence.mapInput` 函数接收两个参数: 1. 你想用作基础的现有 `Equivalence`(这里是 `Equivalence.Number`,用于比较数字)。 2. 一个从你的数据结构中提取值的函数,该值用于等价性检查(这里是 `(user: User) => user.id`)。 --- # Order > 使用可定制的工具在 TypeScript 中比较、排序并管理值的先后顺序。 `Order` 模块提供了一种比较值并确定其先后顺序的方式。它定义了一个接口 `Order`,表示用于比较两个类型为 `A` 的值的单个函数。该函数返回 `-1`、`0` 或 `1`,分别表示第一个值小于、等于或大于第二个值。 `Order` 的基本结构如下: ```ts interface Order { (first: A, second: A): -1 | 0 | 1 } ``` ## 使用内置的 Order `Order` 模块为常见数据类型提供了若干内置的比较器: | Order | 说明 | | -------- | ---------------------------------- | | `String` | 用于比较字符串。 | | `Number` | 用于比较数字。 | | `BigInt` | 用于比较大整数。 | | `Date` | 用于比较 `Date` 对象。 | **示例**(使用内置的比较器) ```ts import { Order } from "effect" console.log(Order.String("apple", "banana")) // Output: -1, as "apple" < "banana" Order.String("apple", "banana") // => -1 console.log(Order.Number(1, 1)) // Output: 0, as 1 = 1 Order.Number(1, 1) // => 0 console.log(Order.BigInt(2n, 1n)) // Output: 1, as 2n > 1n Order.BigInt(2n, 1n) // => 1 ``` ## 对数组排序 你可以使用这些比较器对数组排序。`Array` 模块提供了一个 `sort` 函数,它会在不改变原数组的前提下对数组排序。 **示例**(使用 `Order` 对数组排序) ```ts import { Order, Array } from "effect" const strings = ["b", "a", "d", "c"] const result = Array.sort(strings, Order.String) console.log(strings) // Original array remains unchanged strings // => ["b", "a", "d", "c"] console.log(result) // Sorted array result // => ["a", "b", "c", "d"] ``` 你也可以把 `Order` 作为比较器传给 JavaScript 原生的 `Array.sort` 方法,但请记住,这会修改原数组。 **示例**(在原生 `Array.prototype.sort` 中使用 `Order`) ```ts import { Order } from "effect" const strings = ["b", "a", "d", "c"] strings.sort(Order.String) // Modifies the original array console.log(strings) strings // => ["a", "b", "c", "d"] ``` ## 派生 Order 对于更复杂的数据结构,你可能需要自定义的排序规则。`Order` 模块允许你通过 `Order.mapInput` 函数,从已有的 `Order` 实例派生出新的实例。 **示例**(为对象创建自定义的 Order) 假设你有一个 `Person` 对象列表,想按姓名升序对它们排序。为此,你可以创建一个自定义的 `Order`。 ```ts import { Order } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create a custom order to sort Person objects by name in ascending order // // ┌─── Order // ▼ const byName = Order.mapInput(Order.String, (person: Person) => person.name) // "Alice" sorts before "Bob" byName({ name: "Alice", age: 25 }, { name: "Bob", age: 30 }) // => -1 ``` `Order.mapInput` 函数接收两个参数: 1. 你想用作基础的现有 `Order`(这里是 `Order.String`,用于比较字符串)。 2. 一个从你的数据结构中提取排序所用值的函数(这里是 `(person: Person) => person.name`)。 定义好自定义的 `Order` 后,你就可以用它来排序 `Person` 对象组成的数组: **示例**(使用自定义的 Order 排序对象) ```ts import { Order, Array } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create a custom order to sort Person objects by name in ascending order const byName = Order.mapInput(Order.String, (person: Person) => person.name) const persons: ReadonlyArray = [ { name: "Charlie", age: 22 }, { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, ] // Sort persons array using the custom order const sortedPersons = Array.sort(persons, byName) console.log(sortedPersons) sortedPersons // => [{ name: "Alice", age: 25 }, { name: "Bob", age: 30 }, { name: "Charlie", age: 22 }] ``` ## 组合 Order `Order` 模块允许你组合多个 `Order` 实例,以构建复杂的排序规则。当需要按多个属性排序时,这很有用。 **示例**(按多个条件排序) 假设你有一个人员列表,其中每个人用一个带有 `name` 和 `age` 的对象表示。你希望先按姓名排序,对于姓名相同的人再按年龄排序。 ```ts import { Order, Array } from "effect" // Define the Person interface interface Person { readonly name: string readonly age: number } // Create an Order to sort people by their names in ascending order const byName = Order.mapInput(Order.String, (person: Person) => person.name) // Create an Order to sort people by their ages in ascending order const byAge = Order.mapInput(Order.Number, (person: Person) => person.age) // Combine orders to sort by name, then by age const byNameAge = Order.combine(byName, byAge) const result = Array.sort( [ { name: "Bob", age: 20 }, { name: "Alice", age: 18 }, { name: "Bob", age: 18 }, ], byNameAge, ) console.log(result) /* Output: [ { name: 'Alice', age: 18 }, // Sorted by name { name: 'Bob', age: 18 }, // Sorted by age within the same name { name: 'Bob', age: 20 } ] */ result // => [{ name: "Alice", age: 18 }, { name: "Bob", age: 18 }, { name: "Bob", age: 20 }] ``` ## 其他实用函数 `Order` 模块还提供了用于常见比较操作的额外函数,让你更容易处理有先后顺序的值。 ### 反转顺序 `Order.flip` 会反转比较的顺序。如果你有一个用于升序的 `Order`,把它反转就会得到降序。 **示例**(反转 Order) ```ts import { Order } from "effect" const ascendingOrder = Order.Number const descendingOrder = Order.flip(ascendingOrder) console.log(ascendingOrder(1, 3)) // Output: -1 (1 < 3 in ascending order) ascendingOrder(1, 3) // => -1 console.log(descendingOrder(1, 3)) // Output: 1 (1 > 3 in descending order) descendingOrder(1, 3) // => 1 ``` ### 比较值 这些函数让你可以在值之间执行简单的比较: | API | 说明 | | ------------------------ | -------------------------------------------------------- | | `isLessThan` | 检查一个值是否严格小于另一个值。 | | `isGreaterThan` | 检查一个值是否严格大于另一个值。 | | `isLessThanOrEqualTo` | 检查一个值是否小于或等于另一个值。 | | `isGreaterThanOrEqualTo` | 检查一个值是否大于或等于另一个值。 | **示例**(使用比较函数) ```ts import { Order } from "effect" console.log(Order.isLessThan(Order.Number)(1, 2)) // Output: true (1 < 2) Order.isLessThan(Order.Number)(1, 2) // => true console.log(Order.isGreaterThan(Order.Number)(5, 3)) // Output: true (5 > 3) Order.isGreaterThan(Order.Number)(5, 3) // => true console.log(Order.isLessThanOrEqualTo(Order.Number)(2, 2)) // Output: true (2 <= 2) Order.isLessThanOrEqualTo(Order.Number)(2, 2) // => true console.log(Order.isGreaterThanOrEqualTo(Order.Number)(4, 4)) // Output: true (4 >= 4) Order.isGreaterThanOrEqualTo(Order.Number)(4, 4) // => true ``` ### 查找最小值和最大值 `Order.min` 和 `Order.max` 函数会根据给定的排序规则,返回两个值中的最小值或最大值。 **示例**(查找最小值和最大值) ```ts import { Order } from "effect" console.log(Order.min(Order.Number)(3, 1)) // Output: 1 (1 is the minimum) Order.min(Order.Number)(3, 1) // => 1 console.log(Order.max(Order.Number)(5, 8)) // Output: 8 (8 is the maximum) Order.max(Order.Number)(5, 8) // => 8 ``` ### 限制取值范围 `Order.clamp` 会把值限制在给定范围内。如果值超出该范围,它会被调整到最近的边界。 **示例**(把数字限制在某个范围内) ```ts import { Order } from "effect" // Define a function to clamp numbers between 20 and 30 const clampNumbers = Order.clamp(Order.Number)({ minimum: 20, maximum: 30, }) // Value 26 is within the range [20, 30], so it remains unchanged console.log(clampNumbers(26)) clampNumbers(26) // => 26 // Value 10 is below the minimum bound, so it is clamped to 20 console.log(clampNumbers(10)) clampNumbers(10) // => 20 // Value 40 is above the maximum bound, so it is clamped to 30 console.log(clampNumbers(40)) clampNumbers(40) // => 30 ``` ### 检查值是否在范围内 `Order.isBetween` 检查一个值是否落在指定的闭区间内。 **示例**(检查数字是否落在某个范围内) ```ts import { Order } from "effect" // Create a function to check if numbers are between 20 and 30 const betweenNumbers = Order.isBetween(Order.Number)({ minimum: 20, maximum: 30, }) // Value 26 falls within the range [20, 30], so it returns true console.log(betweenNumbers(26)) betweenNumbers(26) // => true // Value 10 is below the minimum bound, so it returns false console.log(betweenNumbers(10)) betweenNumbers(10) // => false // Value 40 is above the maximum bound, so it returns false console.log(betweenNumbers(40)) betweenNumbers(40) // => false ``` --- # Cache > 使用 Cache 优化性能,实现并发、可组合且高效的值获取。 在许多应用中,处理相互重叠的工作是很常见的。例如,在处理传入请求的 Service 中,避免重复工作(比如多次处理同一个请求)非常重要。Cache 模块通过防止重复工作来帮助提升性能。 Cache 的核心特性: | 特性 | 说明 | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **可组合性** | 允许应用中不同部分的工作相互重叠,同时保持可组合的编程方式。 | | **统一的同步与异步 Cache** | 通过统一的 lookup 函数整合同步 Cache 与异步 Cache,该函数可以按任一方式计算值。 | | **与 Effect 集成** | 与 Effect 库原生协作,支持并发查找、失败处理和中断。 | | **Cache 检查** | 提供 `Cache.size`、`Cache.keys` 和 `Cache.entries` 等函数,用于检查 Cache 的当前状态。 | ## 创建 Cache Cache 由一个 lookup 函数定义:当给定 key 的值尚未被缓存时,该函数负责计算这个值: ```ts type Lookup = ( key: Key, ) => Effect ``` lookup 函数接收一个 `Key`,并返回一个 `Effect`,这个 `Effect` 描述了如何计算值(`Value`)。该 `Effect` 可能需要一个环境(`Requirements`),可能以 `Error` 失败,并以 `Value` 成功。由于它返回的是 `Effect`,因此既能处理同步流程,也能处理异步流程。 创建 Cache 时,需要提供 lookup 函数,以及缓存值的最大容量和存活时间(TTL)。 ```ts declare const make: (options: { readonly capacity: number readonly timeToLive: Duration.Input readonly lookup: Lookup }) => Effect, never, Requirements> ``` Cache 创建之后,最符合习惯的用法是 `Cache.get`。 如果值已经存在,`Cache.get` 会返回 Cache 中的当前值;否则它会计算一个新值,将其放入 Cache,然后返回。 如果多个并发流程请求同一个值,该值只会被计算一次。其他所有流程都会在计算出的值可用时立即收到它。这由 Effect 基于 Fiber 的并发模型管理,不会阻塞底层线程。 **示例**(并发的 Cache 查找) 在这个示例中,我们用同一个 key 并发调用 `timeConsumingEffect` 三次。 Cache 只会运行这个 effect 一次,因此并发的查找会一直等到该值可用: ```ts import { Effect, Cache, Duration } from "effect" // Simulating an expensive lookup with a delay const expensiveLookup = (key: string) => Effect.sleep("100 millis").pipe(Effect.as(key.length)) const program = Effect.gen(function* () { // Create a cache with a capacity of 100 and an infinite TTL const cache = yield* Cache.make({ capacity: 100, timeToLive: Duration.infinity, lookup: expensiveLookup, }) // Perform concurrent lookups using the same key const result = yield* Effect.all( [ Cache.get(cache, "key1"), Cache.get(cache, "key1"), Cache.get(cache, "key1"), ], { concurrency: "unbounded" }, ) console.log( "Result of parallel execution of three effects" + `with the same key: ${result}`, ) // The lookup only ran once, so the cache now holds a single entry const size = yield* Cache.size(cache) console.log(`Cache size: ${size}`) return { result, size } }) const output = await Effect.runPromise(program) /* Output: Result of parallel execution of three effects with the same key: 4,4,4 Cache size: 1 */ output // => { result: [4, 4, 4], size: 1 } ``` ## 并发访问 Cache 被设计为对并发访问安全,并在并发条件下保持高效。如果两个并发流程请求同一个值,而它不在 Cache 中,那么这个值只会被计算一次,并在可用时立即提供给这两个流程。并发流程会等待该值,而不会阻塞底层线程。 如果 lookup 函数失败或被中断,错误会被传播给所有正在等待该值的并发流程。失败结果也会被缓存,以避免对同一个失败的值重复计算。如果被中断,该 key 会从 Cache 中移除,因此后续调用会再次尝试计算该值。 ## 容量 创建 Cache 时会指定一个容量。当 Cache 达到容量上限时,最近最少访问的值会被优先移除。在两次操作之间,Cache 的大小可能会略微超过指定的容量。 ## 存活时间(TTL) Cache 还可以指定存活时间(TTL)。超过 TTL 的值不会被返回。这个时长从值被载入 Cache 的时刻开始计算。 ## 函数 除了 `Cache.get` 之外,`Cache` 模块还提供了若干其他用于操作 Cache 的函数。每个函数都把 Cache 作为第一个参数,例如 `Cache.refresh(cache, key)`: | 函数 | 说明 | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Cache.refresh` | 触发对某个 key 的值重新计算,但不会移除旧值,因此旧值仍可继续访问。 | | `Cache.size` | 返回 Cache 的当前大小。在并发条件下,这个大小是近似值。 | | `Cache.has` | 检查 Cache 中是否存在与指定 key 关联的值。在并发访问下,结果只在检查的那一刻有效,之后可能立即改变。 | | `Cache.invalidate` | 逐出与某个特定 key 关联的值。 | | `Cache.invalidateAll` | 逐出 Cache 中的所有值。 | --- # 缓存 Effect > 使用可复用的工具高效管理 Effect 的缓存与记忆化。 本节介绍库中若干用于在应用中管理缓存与记忆化的函数。 ## 记忆化函数 要对一个 effectful 函数进行记忆化,可以创建一个 `Cache`,把它的 `lookup` 设为要记忆化的函数,然后对每个输入调用 `Cache.get`。Cache 会为每个输入保存一份结果,因此再次用同一个输入调用该函数时,会复用已缓存的结果,而不是重新计算。 **示例**(使用 Cache 记忆化函数) ```ts import { Cache, Effect } from "effect" let i = 1 // Simulating a task whose result changes on each call const randomNumber = (n: number) => Effect.sync(() => n + i++) const program = Effect.gen(function* () { console.log("non-memoized version:") const a = yield* randomNumber(10) // Computes a new result console.log(a) const b = yield* randomNumber(10) // Computes a different result console.log(b) console.log("memoized version:") const cache = yield* Cache.make({ capacity: Number.MAX_SAFE_INTEGER, lookup: randomNumber, }) const memoized = (n: number) => Cache.get(cache, n) const c = yield* memoized(10) // Computes and caches the result console.log(c) const d = yield* memoized(10) // Reuses the cached result console.log(d) return { a, b, c, d } }) const result = await Effect.runPromise(program) result // => { a: 11, b: 12, c: 13, d: 13 } ``` ## once 确保一个 Effect 只执行一次,即使它被多次调用也是如此。 **示例**(Effect 的单次执行) ```ts import { Effect, Console } from "effect" const program = Effect.gen(function* () { const task1 = Console.log("task1") // Repeats task1 three times yield* Effect.repeat(task1, { times: 2 }) // Ensures task2 is executed only once const task2 = yield* Effect.cached(Console.log("task2")) // Attempts to repeat task2, but it will only execute once yield* Effect.repeat(task2, { times: 2 }) }) const result = await Effect.runPromise(program) /* Output: task1 task1 task1 task2 */ result // => undefined ``` ## cached 返回一个 Effect,它会惰性地计算结果并缓存该结果。之后再次求值这个 Effect 时,会直接返回缓存的结果,而不会重新执行其中的逻辑。 **示例**(惰性缓存开销较大的任务) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Without caching, the task is executed each time console.log("-- non-cached version:") yield* expensiveTask.pipe(Effect.andThen(Console.log)) yield* expensiveTask.pipe(Effect.andThen(Console.log)) // With caching, the result is reused after the first run console.log("-- cached version:") const cached = yield* Effect.cached(expensiveTask) yield* cached.pipe(Effect.andThen(Console.log)) yield* cached.pipe(Effect.andThen(Console.log)) }) const result = await Effect.runPromise(program) /* Output: -- non-cached version: expensive task... result 1 expensive task... result 2 -- cached version: expensive task... result 3 result 3 */ result // => undefined ``` ## cachedWithTTL 返回一个 Effect,它会把结果缓存指定的时长,这个时长称为 `timeToLive`。当缓存在这个时长后过期时,该 Effect 会在下一次求值时重新计算。 **示例**(带存活时间的缓存) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Caches the result for 150 milliseconds const cached = yield* Effect.cachedWithTTL(expensiveTask, "150 millis") // First evaluation triggers the task yield* cached.pipe(Effect.andThen(Console.log)) // Second evaluation returns the cached result yield* cached.pipe(Effect.andThen(Console.log)) // Wait for 200 milliseconds, ensuring the cache expires yield* Effect.sleep("200 millis") // Recomputes the task after cache expiration yield* cached.pipe(Effect.andThen(Console.log)) }) const result = await Effect.runPromise(program) /* Output: expensive task... result 1 result 1 expensive task... result 2 */ result // => undefined ``` ## cachedInvalidateWithTTL 与 `Effect.cachedWithTTL` 类似,这个函数会把一个 Effect 的结果缓存指定的时长。它还额外提供一个 Effect,用于在缓存自然过期之前手动使其失效。 **示例**(手动使缓存失效) ```ts import { Effect, Console } from "effect" let i = 1 // Simulating an expensive task with a delay const expensiveTask = Effect.promise(() => { console.log("expensive task...") return new Promise((resolve) => { setTimeout(() => { resolve(`result ${i++}`) }, 100) }) }) const program = Effect.gen(function* () { // Caches the result for 150 milliseconds const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( expensiveTask, "150 millis", ) // First evaluation triggers the task yield* cached.pipe(Effect.andThen(Console.log)) // Second evaluation returns the cached result yield* cached.pipe(Effect.andThen(Console.log)) // Invalidate the cache before it naturally expires yield* invalidate // Third evaluation triggers the task again // since the cache was invalidated yield* cached.pipe(Effect.andThen(Console.log)) }) const result = await Effect.runPromise(program) /* Output: expensive task... result 1 result 1 expensive task... result 2 */ result // => undefined ``` --- # 品牌类型 > 使用品牌类型在 TypeScript 中强化类型安全并细化数据。 在本指南中,我们将探讨 TypeScript 中的**品牌类型**(branded types)概念,并学习如何使用 Brand 模块创建和使用它们。 品牌类型是带有额外类型标记(type tag)的 TypeScript 类型,有助于防止在错误的上下文中意外使用某个值。 它们允许我们基于已有的底层类型创建彼此不同的类型,从而实现类型安全和更好的代码组织。 ## TypeScript 结构类型系统的问题 TypeScript 的类型系统是结构化类型(structurally typed)的,这意味着只要两个类型的成员兼容,它们就被视为兼容。 这可能导致这样的情况:底层类型相同的值被互换使用,即使它们代表不同的概念或具有不同的含义。 考虑以下类型: ```ts type UserId = number type ProductId = number ``` 在这里,`UserId` 和 `ProductId` 在结构上完全相同,因为它们都基于 `number`。 TypeScript 会把二者视为可互换的,如果它们在应用中被混用,就可能引发 bug。 **示例**(意外的类型兼容) ```ts type UserId = number type ProductId = number const getUserById = (id: UserId) => { // Logic to retrieve user } const getProductById = (id: ProductId) => { // Logic to retrieve product } const id: UserId = 1 getProductById(id) // No type error, but incorrect usage ``` 在上面的例子中,把 `UserId` 传给 `getProductById` 不会产生类型错误,尽管这在逻辑上是不正确的。出现这种情况是因为这两个类型被视为可互换。 ## 品牌类型如何解决问题 品牌类型允许你通过添加唯一的类型标记,从相同的底层类型创建出彼此不同的类型,从而在编译期强制正确的用法。 品牌化(branding)是通过添加一个符号标识符来实现的,它在类型层面把一个类型与另一个类型区分开。 这种方法确保类型保持彼此不同,同时不改变它们的运行时特征。 让我们先引入 `BrandTypeId` 符号: ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" // unique identifier for ProductId } } ``` 这种方法把一个唯一标识符作为品牌赋予 `number` 类型,从而有效地将 `ProductId` 与其他数值类型区分开。 使用符号可以确保品牌字段不会与 `number` 类型的任何现有属性冲突。 现在,尝试用 `UserId` 替代 `ProductId` 会导致错误: **示例**(用品牌类型强制类型安全) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" } } const getProductById = (id: ProductId) => { // Logic to retrieve product } type UserId = number const id: UserId = 1 // @errors: 2345 getProductById(id) ``` 错误信息清楚地表明,`number` 不能用来替代 `ProductId`。 TypeScript 不会允许我们把 `number` 的实例传给接受 `ProductId` 的函数,因为它缺少品牌字段。 让我们也为 `UserId` 添加品牌: **示例**(为 UserId 和 ProductId 添加品牌) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") type ProductId = number & { readonly [BrandTypeId]: { readonly ProductId: "ProductId" // unique identifier for ProductId } } const getProductById = (id: ProductId) => { // Logic to retrieve product } type UserId = number & { readonly [BrandTypeId]: { readonly UserId: "UserId" // unique identifier for UserId } } declare const id: UserId // @errors: 2345 getProductById(id) ``` 这个错误表明,虽然两个类型都使用了品牌,但品牌字段关联的唯一值(`"ProductId"` 和 `"UserId"`)确保它们保持彼此不同、不可互换。 ## 泛化品牌类型 为了增强品牌类型的通用性和可复用性,可以用一种标准化的方式对它们进行泛化: ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") // Create a generic Brand interface using a unique identifier interface Brand { readonly [BrandTypeId]: { readonly [id in ID]: ID } } // Define a ProductId type branded with a unique identifier type ProductId = number & Brand<"ProductId"> // Define a UserId type branded similarly type UserId = number & Brand<"UserId"> ``` 这种设计允许用唯一标识符(字符串或符号)为任意类型添加品牌。 下面展示如何使用 `Brand` 接口——它由 Brand 模块直接提供,因此无需自己编写实现: **示例**(使用 Brand 模块中的 Brand 接口) ```ts import { Brand } from "effect" // Define a ProductId type branded with a unique identifier type ProductId = number & Brand.Brand<"ProductId"> // Define a UserId type branded similarly type UserId = number & Brand.Brand<"UserId"> ``` 然而,直接创建这些类型的实例会导致错误,因为类型系统期望的是品牌结构: **示例**(直接赋值错误) ```ts const BrandTypeId: unique symbol = Symbol.for("effect/Brand") interface Brand { readonly [BrandTypeId]: { readonly [k in K]: K } } type ProductId = number & Brand<"ProductId"> // @errors: 2322 const id: ProductId = 1 ``` 你不能直接把 `number` 赋值给 `ProductId`。Brand 模块提供了用于正确构造品牌类型值的工具。 ## 构造品牌类型 Brand 模块提供了两个用于创建品牌类型的主要函数:`nominal` 和 `make`。 ### nominal `Brand.nominal` 函数用于定义不需要运行时校验的品牌类型。 它只是给底层类型添加一个类型标记,让我们能够区分同一类型但含义不同的值。 当我们只是为了代码清晰和代码组织而想创建彼此不同的类型时,名义品牌类型(nominal branded types)就很有用。 **示例**(用名义品牌定义不同的标识符) ```ts import { Brand } from "effect" // Define UserId as a branded number type UserId = number & Brand.Brand<"UserId"> // Constructor for UserId const UserId = Brand.nominal() const getUserById = (id: UserId) => { // Logic to retrieve user } // Define ProductId as a branded number type ProductId = number & Brand.Brand<"ProductId"> // Constructor for ProductId const ProductId = Brand.nominal() const getProductById = (id: ProductId) => { // Logic to retrieve product } // `Brand.nominal` performs no runtime validation, it just brands the value ProductId(1) // => 1 ``` 尝试赋值一个非 `ProductId` 的值会导致编译期错误: **示例**(品牌标识符带来的类型安全) ```ts import { Brand } from "effect" type UserId = number & Brand.Brand<"UserId"> const UserId = Brand.nominal() const getUserById = (id: UserId) => { // Logic to retrieve user } type ProductId = number & Brand.Brand<"ProductId"> const ProductId = Brand.nominal() const getProductById = (id: ProductId) => { // Logic to retrieve product } // Correct usage getProductById(ProductId(1)) // Incorrect, will result in an error // @errors: 2345 getProductById(1) // Also incorrect, will result in an error // @errors: 2345 getProductById(UserId(1)) ``` ### refined `Brand.make` 函数会创建一个会校验其输入的品牌类型构造器。校验函数对有效值返回 `true`,或者描述该值为何无效。 用无效输入调用该构造器会抛出 `BrandError`。它的 `option`、`result` 和 `is` 方法提供了不会抛错的替代方案。 **示例**(创建带校验的品牌类型) ```ts import { Brand } from "effect" // Define a branded type 'Int' to represent integer values type Int = number & Brand.Brand<"Int"> // Define the constructor using 'make' to enforce integer values const Int = Brand.make( (n) => // Validation to ensure the value is an integer, with an error message if not Number.isInteger(n) || `Expected ${n} to be an integer`, ) // A valid integer passes validation and is returned unchanged Int(3) // => 3 ``` **示例**(使用 `Int` 构造器) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.make( (n) => // Check if the value is an integer, with an error message if not Number.isInteger(n) || `Expected ${n} to be an integer`, ) // Create a valid Int value const x: Int = Int(3) console.log(x) // Output: 3 // Attempt to create an Int with an invalid value const y: Int = Int(3.14) // throws BrandError(Expected 3.14 to be an integer) ``` 尝试赋值一个非 `Int` 的值会导致编译期错误: **示例**(错误赋值的编译期错误) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.make( (n) => Number.isInteger(n) || `Expected ${n} to be an integer`, ) // Correct usage const good: Int = Int(3) // Incorrect, will result in an error // @errors: 2322 const bad1: Int = 3 // Also incorrect, will result in an error // @errors: 2322 const bad2: Int = 3.14 ``` ## 组合品牌类型 在某些情况下,你可能需要组合多个品牌类型。为此,Brand 模块提供了 `Brand.all` API: **示例**(组合多个品牌类型) ```ts import { Brand } from "effect" type Int = number & Brand.Brand<"Int"> const Int = Brand.make( (n) => Number.isInteger(n) || `Expected ${n} to be an integer`, ) type Positive = number & Brand.Brand<"Positive"> const Positive = Brand.make( (n) => n > 0 || `Expected ${n} to be positive`, ) // Combine the Int and Positive constructors // into a new branded constructor PositiveInt const PositiveInt = Brand.all(Int, Positive) // Extract the branded type from the PositiveInt constructor type PositiveInt = Brand.Brand.FromConstructor // Usage example // Valid positive integer const good: PositiveInt = PositiveInt(10) // throws BrandError(Expected -5 to be positive) const bad1: PositiveInt = PositiveInt(-5) // throws BrandError(Expected 3.14 to be an integer) const bad2: PositiveInt = PositiveInt(3.14) ``` --- # 控制流操作符 > 学习用 Effect 提供的高级结构控制程序执行流:条件分支、循环,以及把多个 effect 组合到一起。 尽管 JavaScript 已经内置了控制流结构,Effect 仍额外提供了一些在 Effect 应用中很有用的控制流函数。本节介绍控制执行流的几种不同方式。 ## if 表达式 处理 Effect 值时,我们可以使用标准的 JavaScript `if-then-else` 语句: **示例**(对非法体重返回 None) 这里我们用 [Option](/docs/v4/data-types/option/) 数据类型来表示"没有有效值"。 ```ts import { Effect, Option } from "effect" // Function to validate weight and return an Option const validateWeightOption = ( weight: number, ): Effect.Effect> => { if (weight >= 0) { // Return Some if the weight is valid return Effect.succeed(Option.some(weight)) } else { // Return None if the weight is invalid return Effect.succeed(Option.none()) } } await Effect.runPromise(validateWeightOption(5)) // => Option.some(5) await Effect.runPromise(validateWeightOption(-5)) // => Option.none() ``` **示例**(对非法体重返回错误) 也可以用错误通道来处理非法输入:输入非法时返回一个错误。 ```ts import { Effect, Exit } from "effect" // Function to validate weight or fail with an error const validateWeightOrFail = ( weight: number, ): Effect.Effect => { if (weight >= 0) { // Return the weight if valid return Effect.succeed(weight) } else { // Fail with an error if invalid return Effect.fail(`negative input: ${weight}`) } } await Effect.runPromise(validateWeightOrFail(5)) // => 5 await Effect.runPromiseExit(validateWeightOrFail(-5)) // => Exit.fail("negative input: -5") ``` ## 条件操作符 ### when 根据另一个 effect 的结果,有条件地执行某个 effect。 当"要不要执行"这个条件本身取决于另一个产出布尔值的 effect 时,使用 `Effect.when`。 若条件 effect 求值为 `true`,则执行指定的 effect;若求值为 `false`,则不执行任何 effect。 effect 的结果会被包在 `Option` 里,用来表示这个 effect 是否被执行过: 条件为 `true` 时,结果被包在 `Some` 里;条件为 `false` 时结果是 `None`, 表示这个 effect 被跳过了。 **示例**(用 effect 作为条件) 下面的函数会产生一个随机整数,但仅当随机生成的布尔值为 `true` 时才产生。 ```ts import { Effect, Random } from "effect" const randomIntOption = Random.nextInt.pipe(Effect.when(Random.nextBoolean)) console.log(Effect.runSync(randomIntOption)) /* Example Output: { _id: 'Option', _tag: 'Some', value: 8609104974198840 } */ ``` ## 组合(Zipping) ### zip 把两个 effect 合并成一个 effect,产出一个包含两者结果的元组。 `Effect.zip` 先执行第一个 effect(左),再执行第二个 effect(右)。 两者都成功之后,它们的结果被组合成一个元组。 **示例**(顺序组合两个 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // Combine the two effects together // // ┌─── Effect<[number, string], never, never> // ▼ const program = Effect.zip(task1, task2) const result = await Effect.runPromise(program) // => [1, "hello"] console.log(result) /* Output: timestamp=... level=INFO fiber=#0 message="task1 done" timestamp=... level=INFO fiber=#0 message="task2 done" */ ``` 默认情况下两个 effect 是顺序执行的。要并发执行,请使用 `{ concurrent: true }` 选项。 **示例**(并发组合两个 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // Run both effects concurrently using the concurrent option const program = Effect.zip(task1, task2, { concurrent: true }) const result = await Effect.runPromise(program) // => [1, "hello"] console.log(result) /* Output: timestamp=... level=INFO fiber=#3 message="task2 done" timestamp=... level=INFO fiber=#2 message="task1 done" */ ``` 在这个并发版本里,两个 effect 并行运行。`task2` 先完成,但两个任务都会在完成的当下被记录和处理。 ### zipWith 顺序组合两个 effect,并对它们的结果套用一个函数,产出单一的值。 `Effect.zipWith` 与 [Effect.zip](#zip) 类似,区别在于它不返回结果的元组, 而是把给定的函数作用在两者的结果上,合并成单一的值。 默认情况下两个 effect 顺序执行。要并发执行,请使用 `{ concurrent: true }` 选项。 **示例**(用自定义函数组合 effect) ```ts import { Effect } from "effect" const task1 = Effect.succeed(1).pipe( Effect.delay("200 millis"), Effect.tap(Effect.log("task1 done")), ) const task2 = Effect.succeed("hello").pipe( Effect.delay("100 millis"), Effect.tap(Effect.log("task2 done")), ) // ┌─── Effect // ▼ const task3 = Effect.zipWith( task1, task2, // Combines results into a single value (number, string) => number + string.length, ) const result = await Effect.runPromise(task3) // => 6 console.log(result) /* Output: timestamp=... level=INFO fiber=#3 message="task1 done" timestamp=... level=INFO fiber=#2 message="task2 done" */ ``` ## 循环 ### whileLoop `Effect.whileLoop` 让你用一个 `step` 函数反复更新状态,直到 `while` 函数定义的条件变为 `false`。 它会把中间的每一个状态收集进数组,作为最终结果返回。 **语法** ```ts Effect.whileLoop(initial, { while: (state) => boolean, step: (state) => state, body: (state) => Effect, }) ``` 这个函数类似 JavaScript 里的 `while` 循环,只是循环中可以有带副作用的计算: ```ts let state = initial const result = [] while (options.while(state)) { result.push(options.body(state)) // Perform the effectful operation state = options.step(state) // Update the state } return result ``` **示例**(循环并收集结果) ```ts import { Effect } from "effect" // A loop that runs 5 times, collecting each iteration's result const result = Effect.gen(function* () { let state = 1 const results: Array = [] while (state <= 5) { results.push(yield* Effect.succeed(state)) state = state + 1 } return results }) const value = await Effect.runPromise(result) // => [1, 2, 3, 4, 5] console.log(value) ``` 在这个例子里,循环从状态 `1` 开始,一直持续到状态超过 `5`。每次状态加 `1` 并被收集进数组,该数组就是最终结果。 #### 丢弃中间结果 把 `discard` 选项设为 `true` 会丢弃每次带副作用操作的结果,返回 `void` 而不是数组。 **示例**(丢弃结果的循环) ```ts import { Effect, Console } from "effect" // Discard intermediate results const result = Effect.gen(function* () { let state = 1 while (state <= 5) { yield* Console.log(`Currently at state ${state}`) state = state + 1 } }) const value = await Effect.runPromise(result) // => undefined console.log(value) /* Output: Currently at state 1 Currently at state 2 Currently at state 3 Currently at state 4 Currently at state 5 */ ``` 在这个例子里,循环每次迭代都会产生一个打印当前下标的副作用,但所有中间结果都被丢弃,最终结果是 `undefined`。 ### forEach 对 `Iterable` 中的每个元素执行一次带副作用的操作。 `Effect.forEach` 把给定的操作作用在可迭代对象的每个元素上,产出一个**返回结果数组**的新 effect。 如果任何一个 effect 失败,迭代会立即停止(短路),错误被向外传播。 `concurrency` 选项控制有多少个操作并发执行。默认情况下操作是顺序执行的。 **示例**(对可迭代对象的元素施加 effect) ```ts import { Effect, Console } from "effect" const result = Effect.forEach([1, 2, 3, 4, 5], (n, index) => Console.log(`Currently at index ${index}`).pipe(Effect.as(n * 2)), ) const value = await Effect.runPromise(result) // => [2, 4, 6, 8, 10] console.log(value) /* Output: Currently at index 0 Currently at index 1 Currently at index 2 Currently at index 3 Currently at index 4 */ ``` 在这个例子里,我们遍历数组 `[1, 2, 3, 4, 5]`,对每个元素施加一个打印当前下标的 effect。 `Effect.as(n * 2)` 把每个值转换掉,最终得到数组 `[2, 4, 6, 8, 10]`。 最终输出就是所有转换后的值被收集起来的结果。 #### 丢弃结果 把 `discard` 选项设为 `true` 会丢弃每次带副作用操作的结果,返回 `void` 而不是数组。 **示例**(用 `discard` 忽略结果) ```ts import { Effect, Console } from "effect" // Apply effects but discard the results const result = Effect.forEach( [1, 2, 3, 4, 5], (n, index) => Console.log(`Currently at index ${index}`).pipe(Effect.as(n * 2)), { discard: true }, ) const value = await Effect.runPromise(result) // => undefined console.log(value) /* Output: Currently at index 0 Currently at index 1 Currently at index 2 Currently at index 3 Currently at index 4 */ ``` 这种情况下,每个元素上的 effect 照常执行,但结果被丢弃,所以最终输出是 `undefined`。 ## 收集 ### all 把多个 effect 合并成一个,并按输入的结构返回结果。 当你需要运行多个 effect、并把结果合并成一个输出时,使用 `Effect.all`。 它支持元组、可迭代对象、结构体和记录(record),因此对不同的输入类型都很灵活。 如果任何一个 effect 失败,它就会停止执行(短路)并传播错误。要改变这个行为, 可以使用 [`mode`](#the-mode-option) 选项:它让所有 effect 都跑完, 并把结果以 [Result](/docs/v4/data-types/result/) 的形式收集起来。 你可以用[并发选项](/docs/v4/concurrency/basic-concurrency/#concurrency-options)来控制执行顺序(例如顺序 vs 并发)。 举例来说,如果输入是一个元组: ```ts // ┌─── a tuple of effects // ▼ Effect.all([effect1, effect2, ...]) ``` 那么这些 effect 会顺序执行,结果是一个把结果作为元组包含在内的新 effect。 元组中结果的顺序与传给 `Effect.all` 的 effect 顺序一致。 下面我们分别看元组、可迭代对象、结构体和记录这几种结构的例子。 **示例**(在元组中组合 effect) ```ts import { Effect, Console } from "effect" const tupleOfEffects = [ Effect.succeed(42).pipe(Effect.tap(Console.log)), Effect.succeed("Hello").pipe(Effect.tap(Console.log)), ] as const // ┌─── Effect<[number, string], never, never> // ▼ const resultsAsTuple = Effect.all(tupleOfEffects) const result = await Effect.runPromise(resultsAsTuple) // => [42, "Hello"] console.log(result) /* Output: 42 Hello */ ``` **示例**(在可迭代对象中组合 effect) ```ts import { Effect, Console } from "effect" const iterableOfEffects: Iterable> = [1, 2, 3].map((n) => Effect.succeed(n).pipe(Effect.tap(Console.log)), ) // ┌─── Effect // ▼ const resultsAsArray = Effect.all(iterableOfEffects) const result = await Effect.runPromise(resultsAsArray) // => [1, 2, 3] console.log(result) /* Output: 1 2 3 */ ``` **示例**(在结构体中组合 effect) ```ts import { Effect, Console } from "effect" const structOfEffects = { a: Effect.succeed(42).pipe(Effect.tap(Console.log)), b: Effect.succeed("Hello").pipe(Effect.tap(Console.log)), } // ┌─── Effect<{ a: number; b: string; }, never, never> // ▼ const resultsAsStruct = Effect.all(structOfEffects) const result = await Effect.runPromise(resultsAsStruct) // => { a: 42, b: "Hello" } console.log(result) /* Output: 42 Hello */ ``` **示例**(在记录中组合 effect) ```ts import { Effect, Console } from "effect" const recordOfEffects: Record> = { key1: Effect.succeed(1).pipe(Effect.tap(Console.log)), key2: Effect.succeed(2).pipe(Effect.tap(Console.log)), } // ┌─── Effect<{ [x: string]: number; }, never, never> // ▼ const resultsAsRecord = Effect.all(recordOfEffects) const result = await Effect.runPromise(resultsAsRecord) // => { key1: 1, key2: 2 } console.log(result) /* Output: 1 2 */ ``` #### 短路行为 `Effect.all` 在遇到第一个错误时就停止执行,这被称为"短路"。 集合里任何一个 effect 失败,其余 effect 都不会再运行,错误会被传播出去。 **示例**(首次失败即中止) ```ts import { Effect, Console, Exit } from "effect" const program = Effect.all([ Effect.succeed("Task1").pipe(Effect.tap(Console.log)), Effect.fail("Task2: Oh no!").pipe(Effect.tap(Console.log)), // Won't execute due to earlier failure Effect.succeed("Task3").pipe(Effect.tap(Console.log)), ]) const result = await Effect.runPromiseExit(program) // => Exit.fail("Task2: Oh no!") console.log(result) /* Output: Task1 */ ``` 你可以用 `mode` 选项覆盖这个行为。 #### `mode` 选项 `{ mode: "result" }` 选项会改变 `Effect.all` 的行为:即使有 effect 失败,也保证所有 effect 都执行。 它不会在第一次失败时停下,而是同时收集成功与失败,返回一个由 `Result` 组成的数组。 **示例**(用 `mode: "result"` 收集结果) ```ts import { Effect, Console, Exit, Result } from "effect" const effects = [ Effect.succeed("Task1").pipe(Effect.tap(Console.log)), Effect.fail("Task2: Oh no!").pipe(Effect.tap(Console.log)), Effect.succeed("Task3").pipe(Effect.tap(Console.log)), ] const program = Effect.all(effects, { mode: "result" }) const result = await Effect.runPromiseExit(program) // => Exit.succeed([Result.succeed("Task1"), Result.fail("Task2: Oh no!"), Result.succeed("Task3")]) console.log(result) /* Output: Task1 Task3 */ ``` --- # 简化过度嵌套 > 使用 Do 模拟与 generator 简化嵌套代码。 假设你想创建一个自定义函数 `elapsed`,用来打印某个 effect 执行所耗费的时间。 ## 使用普通的 pipe 最初,你可能会写出使用标准 `pipe` [方法](/docs/v4/getting-started/building-pipelines/#the-pipe-method)的代码,但这种方式会导致过度嵌套,让代码变得冗长且难以阅读: **示例**(使用 `pipe` 测量耗时) ```ts import { Effect, Console } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) // Prints the elapsed time occurred to `self` to execute const elapsed = ( self: Effect.Effect, ): Effect.Effect => now.pipe( Effect.andThen((startMillis) => self.pipe( Effect.andThen((result) => now.pipe( Effect.andThen((endMillis) => { // Calculate the elapsed time in milliseconds const elapsed = endMillis - startMillis // Log the elapsed time return Console.log(`Elapsed: ${elapsed}`).pipe( Effect.map(() => result), ) }), ), ), ), ), ) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) const result = await Effect.runPromise(program) // => "some task" console.log(result) /* Output: Elapsed: 204 some task */ ``` 为了解决这个问题并让代码更易于维护,有一个方案:「do 模拟」(do simulation)。 ## 使用「do 模拟」 Effect 中的「do 模拟」让你能以更声明式的风格编写代码,类似于其他编程语言中的「do notation」。它提供了一种定义变量、并通过 `Effect.bind`、`Effect.let` 这类函数对它们执行操作的方式。 do 模拟的工作方式如下: 1. 使用 `Effect.Do` 值启动 do 模拟: ```ts const program = Effect.Do.pipe(/* ... rest of the code */) ``` 2. 在 do 模拟的作用域内,你可以使用 `Effect.bind` 函数定义变量,并把它绑定到 `Effect` 值: ```ts Effect.bind("variableName", (scope) => effectValue) ``` - `variableName` 是你为要定义的变量选择的名字。它在作用域内必须唯一。 - `effectValue` 是你想绑定到该变量的 `Effect` 值。它可以是函数调用的结果,也可以是任何其他合法的 `Effect` 值。 3. 你可以累积多个 `Effect.bind` 语句,在作用域内定义多个变量: ```ts Effect.bind("variable1", () => effectValue1), Effect.bind("variable2", ({ variable1 }) => effectValue2), // ... additional bind statements ``` 4. 在 do 模拟作用域内,你还可以使用 `Effect.let` 函数定义变量,并把它绑定到简单值: ```ts Effect.let("variableName", (scope) => simpleValue) ``` - `variableName` 是你给变量起的名字。和之前一样,它在作用域内必须唯一。 - `simpleValue` 是你想赋给该变量的值。它可以是 `number`、`string` 或 `boolean` 这样的简单值。 5. 在 do 模拟中仍然可以使用 `Effect.andThen`、`Effect.flatMap`、`Effect.tap` 和 `Effect.map` 这类常规 Effect 函数。在作用域内,这些函数会把累积的变量作为参数接收: ```ts Effect.andThen(({ variable1, variable2 }) => { // Perform operations using variable1 and variable2 // Return an `Effect` value as the result }) ``` 借助 do 模拟,你可以像这样重写 `elapsed` 函数: **示例**(使用 do 模拟测量耗时) ```ts import { Effect, Console } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) const elapsed = ( self: Effect.Effect, ): Effect.Effect => Effect.Do.pipe( Effect.bind("startMillis", () => now), Effect.bind("result", () => self), Effect.bind("endMillis", () => now), Effect.let( "elapsed", // Calculate the elapsed time in milliseconds ({ startMillis, endMillis }) => endMillis - startMillis, ), // Log the elapsed time Effect.tap(({ elapsed }) => Console.log(`Elapsed: ${elapsed}`)), Effect.map(({ result }) => result), ) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) const result = await Effect.runPromise(program) // => "some task" console.log(result) /* Output: Elapsed: 204 some task */ ``` ## 使用 Effect.gen 最简洁、最方便的解决方案是使用 [Effect.gen](/docs/v4/getting-started/using-generators/),它让你在处理 effect 时可以使用 [generator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator)。这种方式利用了 generator 语法提供的原生作用域,避免了过度嵌套,从而让代码更简洁。 **示例**(使用 Effect.gen 测量耗时) ```ts import { Effect } from "effect" // Get the current timestamp const now = Effect.sync(() => new Date().getTime()) // Prints the elapsed time occurred to `self` to execute const elapsed = ( self: Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const startMillis = yield* now const result = yield* self const endMillis = yield* now // Calculate the elapsed time in milliseconds const elapsed = endMillis - startMillis // Log the elapsed time console.log(`Elapsed: ${elapsed}`) return result }) // Simulates a successful computation with a delay of 200 milliseconds const task = Effect.succeed("some task").pipe(Effect.delay("200 millis")) const program = elapsed(task) const result = await Effect.runPromise(program) // => "some task" console.log(result) /* Output: Elapsed: 204 some task */ ``` 在 generator 内部,我们使用 `yield*` 调用 effect,并把它们的结果绑定到变量。这消除了嵌套,提供了更易读、更顺序化的代码结构。 Effect 中的 generator 风格采用更加线性、顺序化的执行流程,类似于传统的命令式编程语言。这让代码更易读、更易理解,尤其是对更熟悉命令式编程范式的开发者而言。 --- # Dual API > 探索 Effect 生态系统中 dual API 的 data-first 与 data-last 两种变体。 在 Effect 生态系统中使用 API 时,你可能会遇到同一个 API 有两种不同的用法。 这两种用法分别称为 "data-last" 变体和 "data-first" 变体。 当一个 API 同时支持这两种变体时,我们称它为 "dual" API。 下面用 `Effect.map` 来展示这两种变体。 ## Effect.map 作为 dual API `Effect.map` 函数由两个 TypeScript 重载定义。"data-last" 和 "data-first" 这两个说法,指的是 `self` 参数(也称为 "data")在两个重载签名中的位置: ```ts declare const map: { // ┌─── data-last // ▼ (f: (a: A) => B): (self: Effect) => Effect // ┌─── data-first // ▼ (self: Effect, f: (a: A) => B): Effect } ``` ### data-last 在第一个重载中,`self` 参数位于**最后**: ```ts declare const map: ( f: (a: A) => B, ) => (self: Effect) => Effect ``` 这种写法通常与 `pipe` 函数配合使用。你先把 `Effect` 作为初始参数传给 `pipe`,然后再链式调用 `Effect.map` 之类的变换: **示例**(使用 data-last 配合 `pipe`) ```ts const mappedEffect = pipe(effect, Effect.map(func)) ``` 当你需要串联多个变换时,这种风格很有帮助,能让代码以管道的形式更易读懂: ```ts pipe(effect, Effect.map(func1), Effect.map(func2), ...) ``` ### data-first 在第二个重载中,`self` 参数位于**最前**: ```ts declare const map: ( self: Effect, f: (a: A) => B, ) => Effect ``` 这种形式不需要 `pipe`。你可以直接把 `Effect` 作为第一个参数传入: **示例**(不使用 `pipe` 的 data-first 写法) ```ts const mappedEffect = Effect.map(effect, func) ``` 当你只需要对 `Effect` 执行单个操作时,这种写法很合适。 --- # 编码指南 > 运行 Effect 应用的最佳实践,以及如何保持安全、显式的编码风格。 ## 使用 runMain 在 Effect 中,`runMain` 是在 Node.js 上运行 Effect 应用的主要入口点。 **示例**(以优雅退出的方式运行 Effect 应用) ```ts import { Effect, Console, Schedule, pipe } from "effect" import { NodeRuntime } from "@effect/platform-node" const program = pipe( Effect.addFinalizer(() => Console.log("Application is about to exit!")), Effect.andThen(Console.log("Application started!")), Effect.andThen( Effect.repeat(Console.log("still alive..."), { schedule: Schedule.spaced("1 second"), }), ), Effect.scoped, ) // No graceful teardown on CTRL+C // Effect.runPromise(program) // Use NodeRuntime.runMain for graceful teardown on CTRL+C NodeRuntime.runMain(program) /* Output: Application started! still alive... still alive... still alive... still alive... ^C <-- CTRL+C Application is about to exit! */ ``` `runMain` 函数负责查找并中断所有 fiber。在内部,它会观察 fiber 并监听 `sigint` 信号,确保应用在被中断时(例如按下 CTRL+C)能够优雅关闭。 ### 不同平台上的版本 Effect 为不同平台提供了各自版本的 `runMain`: | 平台 | 运行时版本 | 导入路径 | | ------- | ------------------------ | -------------------------- | | Node.js | `NodeRuntime.runMain` | `@effect/platform-node` | | Bun | `BunRuntime.runMain` | `@effect/platform-bun` | | 浏览器 | `BrowserRuntime.runMain` | `@effect/platform-browser` | ## 避免隐式(point-free)用法 避免使用隐式(point-free)的函数调用,例如 `Effect.map(fn)`,也不要使用 `effect/Function` 模块中的 `flow`。 在 Effect 中,显式地书写函数通常更安全: ```ts Effect.map((x) => fn(x)) ``` 而不是写成 point-free 风格: ```ts Effect.map(fn) ``` 隐式函数虽然因其简洁而颇具吸引力,但它们可能引入一系列问题: - 使用隐式函数,尤其是在处理可选参数时,可能是不安全的。例如,如果一个函数有重载,以隐式风格书写可能会抹掉所有泛型,从而产生 bug。更多细节请参见这条 X 讨论串:[link to thread](https://twitter.com/MichaelArnaldi/status/1670715270845935616)。 - 隐式用法还可能损害 TypeScript 的类型推断能力,进而可能引发意料之外的错误。这不仅仅是一个风格问题,更是避免因类型推断问题而产生微妙错误的一种方式。 - 此外,使用隐式用法时,堆栈跟踪可能不够清晰。 避免隐式用法是一个简单的预防措施,它能让你的代码更加可靠。 --- # 模式匹配 > 使用 Match 模块进行模式匹配,简化复杂的分支逻辑。 模式匹配是一种让开发者能够在单个简洁表达式中处理复杂条件的方法。它简化了代码,使其更简洁、更容易理解。此外,它还包含一个称为穷尽性检查(exhaustiveness checking)的过程,用于帮助确保没有任何可能的情况被遗漏。 模式匹配源自函数式编程语言,是代码分支处理的一项强大技术。与 if/else 或 switch 语句这类命令式替代方案相比,它通常能提供更强大、更简洁的解决方案,尤其是在处理复杂条件时。 尽管模式匹配还不是 JavaScript 的原生特性,但目前有一个处于早期阶段的 [tc39 提案](https://github.com/tc39/proposal-pattern-matching),旨在把模式匹配引入 JavaScript。不过,该提案仍处于第 1 阶段,可能还需要数年才能落地。即便如此,开发者依然可以在自己的代码库中实现模式匹配。`effect/Match` 模块提供了一套可靠且类型安全的模式匹配实现,可立即使用。 **示例**(用模式匹配处理不同的数据类型) ```ts import { Match } from "effect" // Simulated dynamic input that can be a string or a number const input: string | number = "some input" // ┌─── string // ▼ const result = Match.value(input).pipe( // Match if the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Match if the value is a string Match.when(Match.string, (s) => `string: ${s}`), // Ensure all possible cases are covered Match.exhaustive, ) console.log(result) result // => "string: some input" ``` ## 模式匹配的工作原理 模式匹配遵循一个结构化的流程: 1. **创建匹配器**。 定义一个 `Matcher`,让它作用于某个特定的[类型](#matching-by-type)或[值](#matching-by-value)。 2. **定义模式**。 使用 `Match.when`、`Match.not` 和 `Match.tag` 这类组合子来指定匹配条件。 3. **完成匹配**。 应用 `Match.exhaustive`、`Match.orElse` 或 `Match.option` 这样的终结器,来决定未匹配的情况应如何处理。 ## 创建匹配器 你可以通过以下任意一种方式创建 `Matcher`: - `Match.type()`:针对特定的类型进行匹配。 - `Match.value(value)`:针对特定的值进行匹配。 ### 按类型匹配 `Match.type` 构造函数会定义一个作用于特定类型的 `Matcher`。创建之后,你就可以使用 `Match.when` 这类模式来定义处理不同情况的条件。 **示例**(匹配数字和字符串) ```ts import { Match } from "effect" // Create a matcher for values that are either strings or numbers // // ┌─── (u: string | number) => string // ▼ const match = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Match when the value is a string Match.when(Match.string, (s) => `string: ${s}`), // Ensure all possible cases are handled Match.exhaustive, ) console.log(match(0)) match(0) // => "number: 0" console.log(match("hello")) match("hello") // => "string: hello" ``` ### 按值匹配 除了为类型创建匹配器,你也可以使用 `Match.value` 直接基于某个具体的值来定义匹配器。 **示例**(按属性匹配对象) ```ts import { Match } from "effect" const input = { name: "John", age: 30 } // Create a matcher for the specific object const result = Match.value(input).pipe( // Match when the 'name' property is "John" Match.when( { name: "John" }, (user) => `${user.name} is ${user.age} years old`, ), // Provide a fallback if no match is found Match.orElse(() => "Oh, not John"), ) console.log(result) result // => "John is 30 years old" ``` ### 强制返回类型 你可以使用 `Match.withReturnType()` 来确保所有分支都返回特定的类型。 **示例**(校验返回类型的一致性) 这个示例强制要求每个匹配分支都返回 `string`。 ```ts import { Match } from "effect" const match = Match.type<{ a: number } | { b: string }>().pipe( // Ensure all branches return a string Match.withReturnType(), // ❌ Type error: returns a number // @errors: 2322 Match.when({ a: Match.number }, (_) => _.a), // ✅ Correct: returns a string Match.when({ b: Match.string }, (_) => _.b), Match.exhaustive, ) ``` ## 定义模式 ### when `Match.when` 函数允许你定义用于匹配值的条件。它同时支持直接的值比较和谓词函数。 **示例**(用值和谓词进行匹配) ```ts import { Match } from "effect" // Create a matcher for objects with an "age" property const match = Match.type<{ age: number }>().pipe( // Match when age is greater than 18 Match.when({ age: (age) => age > 18 }, (user) => `Age: ${user.age}`), // Match when age is exactly 18 Match.when({ age: 18 }, () => "You can vote"), // Fallback case for all other ages Match.orElse((user) => `${user.age} is too young`), ) console.log(match({ age: 20 })) match({ age: 20 }) // => "Age: 20" console.log(match({ age: 18 })) match({ age: 18 }) // => "You can vote" console.log(match({ age: 4 })) match({ age: 4 }) // => "4 is too young" ``` ### not `Match.not` 函数允许你排除特定的值,同时匹配其余所有值。 **示例**(忽略某个特定的值) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match any value except "hi", returning "ok" Match.not("hi", () => "ok"), // Fallback case for when the value is "hi" Match.orElse(() => "fallback"), ) console.log(match("hello")) match("hello") // => "ok" console.log(match("hi")) match("hi") // => "fallback" ``` ### tag `Match.tag` 函数允许基于[可辨识联合](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions)中的 `_tag` 字段进行模式匹配。你可以在单个模式中指定多个要匹配的 tag。 **示例**(按 tag 匹配可辨识联合) ```ts import { Match } from "effect" type Event = | { readonly _tag: "fetch" } | { readonly _tag: "success"; readonly data: string } | { readonly _tag: "error"; readonly error: Error } | { readonly _tag: "cancel" } // Create a matcher for Event const match = Match.type().pipe( // Match either "fetch" or "success" Match.tag("fetch", "success", () => `Ok!`), // Match "error" and extract the error message Match.tag("error", (event) => `Error: ${event.error.message}`), // Match "cancel" Match.tag("cancel", () => "Cancelled"), Match.exhaustive, ) console.log(match({ _tag: "success", data: "Hello" })) match({ _tag: "success", data: "Hello" }) // => "Ok!" console.log(match({ _tag: "error", error: new Error("Oops!") })) match({ _tag: "error", error: new Error("Oops!") }) // => "Error: Oops!" ``` ### 内置谓词 `Match` 模块为常见类型提供了内置谓词,例如 `Match.number`、`Match.string` 和 `Match.boolean`。这些谓词简化了针对原始类型的匹配过程。 **示例**(对属性键使用内置谓词) ```ts import { Match } from "effect" const matchPropertyKey = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `Key is a number: ${n}`), // Match when the value is a string Match.when(Match.string, (s) => `Key is a string: ${s}`), // Match when the value is a symbol Match.when(Match.symbol, (s) => `Key is a symbol: ${String(s)}`), // Ensure all possible cases are handled Match.exhaustive, ) console.log(matchPropertyKey(42)) matchPropertyKey(42) // => "Key is a number: 42" console.log(matchPropertyKey("username")) matchPropertyKey("username") // => "Key is a string: username" console.log(matchPropertyKey(Symbol("id"))) matchPropertyKey(Symbol("id")) // => "Key is a symbol: Symbol(id)" ``` | 谓词 | 说明 | | ------------------------- | ----------------------------------------------------------------------------- | | `Match.string` | 匹配 `string` 类型的值。 | | `Match.nonEmptyString` | 匹配非空字符串。 | | `Match.number` | 匹配 `number` 类型的值。 | | `Match.boolean` | 匹配 `boolean` 类型的值。 | | `Match.bigint` | 匹配 `bigint` 类型的值。 | | `Match.symbol` | 匹配 `symbol` 类型的值。 | | `Match.date` | 匹配 `Date` 的实例值。 | | `Match.record` | 匹配键为 `string` 或 `symbol`、值为 `unknown` 的对象。 | | `Match.null` | 匹配值 `null`。 | | `Match.undefined` | 匹配值 `undefined`。 | | `Match.defined` | 匹配任何已定义(非 null 且非 undefined)的值。 | | `Match.any` | 匹配任意值,不做限制。 | | `Match.is(...values)` | 匹配一组特定的字面量值(例如 `Match.is("a", 42, true)`)。 | | `Match.instanceOf(Class)` | 匹配给定类的实例。 | ## 完成匹配 ### exhaustive `Match.exhaustive` 方法通过确保所有可能的情况都已被覆盖,来终结模式匹配过程。如果有任何情况缺失,TypeScript 会产生类型错误。这在处理联合类型时特别有用,因为它有助于避免模式匹配中出现意外的遗漏。 **示例**(确保覆盖所有情况) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match when the value is a number Match.when(Match.number, (n) => `number: ${n}`), // Mark the match as exhaustive, ensuring all cases are handled // TypeScript will throw an error if any case is missing // @errors: 2345 Match.exhaustive, ) ``` ### orElse `Match.orElse` 方法定义当其他模式都不匹配时返回的 fallback 值。这确保匹配器始终能产出一个有效结果。 **示例**(在没有模式匹配时提供默认值) ```ts import { Match } from "effect" // Create a matcher for string or number values const match = Match.type().pipe( // Match when the value is "a" Match.when("a", () => "ok"), // Fallback when no patterns match Match.orElse(() => "fallback"), ) console.log(match("a")) match("a") // => "ok" console.log(match("b")) match("b") // => "fallback" ``` ### option `Match.option` 会把匹配结果包装进一个 [Option](/docs/v4/data-types/option/)。如果找到匹配,它会返回 `Some(value)`;否则返回 `None`。 **示例**(用 Option 提取用户角色) ```ts import { Match, Option } from "effect" type User = { readonly role: "admin" | "editor" | "viewer" } // Create a matcher to extract user roles const getRole = Match.type().pipe( Match.when({ role: "admin" }, () => "Has full access"), Match.when({ role: "editor" }, () => "Can edit content"), Match.option, // Wrap the result in an Option ) console.log(getRole({ role: "admin" })) getRole({ role: "admin" }) // => Option.some("Has full access") console.log(getRole({ role: "viewer" })) getRole({ role: "viewer" }) // => Option.none() ``` ### result `Match.result` 方法会把结果包装进一个 [Result](/docs/v4/data-types/result/),提供一种结构化的方式来区分匹配与未匹配的情况。如果找到匹配,它会返回 `Success(value)`;否则返回 `Failure(no match)`。 **示例**(用 Result 提取用户角色) ```ts import { Match, Result } from "effect" type User = { readonly role: "admin" | "editor" | "viewer" } // Create a matcher to extract user roles const getRole = Match.type().pipe( Match.when({ role: "admin" }, () => "Has full access"), Match.when({ role: "editor" }, () => "Can edit content"), Match.result, // Wrap the result in a Result ) console.log(getRole({ role: "admin" })) getRole({ role: "admin" }) // => Result.succeed("Has full access") console.log(getRole({ role: "viewer" })) getRole({ role: "viewer" }) // => Result.fail({ role: "viewer" }) ``` --- # 基础并发 > 通过并发、中断与竞速来管理和控制 effect 的执行。 ## 并发选项 Effect 提供了一些选项来管理 effect 的执行方式,尤其侧重控制有多少 effect 并发运行。 ```ts type Options = { readonly concurrency?: Concurrency } ``` `concurrency` 选项用于确定并发级别,取值如下: ```ts type Concurrency = number | "unbounded" ``` 下面我们详细探讨每一种配置。 ### 顺序执行(默认) 默认情况下,如果你不指定任何并发选项,effect 会顺序执行,一个接一个。这意味着每个 effect 只会在前一个 effect 完成之后才开始。 **示例**(顺序执行) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.Input) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const sequential = Effect.all([task1, task2]) await Effect.runPromise(sequential) // => [undefined, undefined] /* Output: start task1 task1 done start task2 <-- task2 starts only after task1 completes task2 done */ ``` ### 数字并发 你可以通过为 `concurrency` 设置一个 `number` 来控制有多少 effect 并发运行。例如,`concurrency: 2` 允许最多两个 effect 同时运行。 **示例**(限制为 2 个并发任务) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.Input) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") const numbered = Effect.all([task1, task2, task3, task4, task5], { concurrency: 2, }) await Effect.runPromise(numbered) // => [undefined, undefined, undefined, undefined, undefined] /* Output: start task1 start task2 <-- active tasks: task1, task2 task2 done start task3 <-- active tasks: task1, task3 task1 done start task4 <-- active tasks: task3, task4 task4 done start task5 <-- active tasks: task3, task5 task3 done task5 done */ ``` ### 无界并发 当使用 `concurrency: "unbounded"` 时,并发运行的 effect 数量没有上限。 **示例**(无界并发) ```ts import { Effect, Duration } from "effect" // Helper function to simulate a task with a delay const makeTask = (n: number, delay: Duration.Input) => Effect.promise( () => new Promise((resolve) => { console.log(`start task${n}`) // Logs when the task starts setTimeout(() => { console.log(`task${n} done`) // Logs when the task finishes resolve() }, Duration.toMillis(delay)) }), ) const task1 = makeTask(1, "200 millis") const task2 = makeTask(2, "100 millis") const task3 = makeTask(3, "210 millis") const task4 = makeTask(4, "110 millis") const task5 = makeTask(5, "150 millis") const unbounded = Effect.all([task1, task2, task3, task4, task5], { concurrency: "unbounded", }) await Effect.runPromise(unbounded) // => [undefined, undefined, undefined, undefined, undefined] /* Output: start task1 start task2 start task3 start task4 start task5 task2 done task4 done task5 done task1 done task3 done */ ``` ## 中断 Effect 中的所有 effect 都由 [Fiber](/docs/v4/concurrency/fibers/) 执行。如果你没有自己创建 Fiber,那么它要么是由你正在使用的某个操作创建的(如果该操作是并发的),要么是由 Effect [运行时](/docs/v4/runtime/) 系统创建的。 每当一个 effect 被运行时,都会创建一个 Fiber。并发运行 effect 时,会为每个并发 effect 创建一个 Fiber。 总结如下: - `Effect` 是更高层的概念,用于描述一段带副作用的计算。它是惰性且不可变的,这意味着它表示一段可能产生值、也可能失败的计算,但并不会立即执行。 - 而 Fiber 表示 `Effect` 正在运行的执行过程。它可以被中断,也可以被等待以获取其结果。可以把它看作一种控制和交互正在进行的计算的方式。 Fiber 可以通过多种方式被中断。下面我们来探讨其中一些场景,并看看在 Effect 中如何中断 Fiber 的示例。 ### interrupt 可以使用 `Effect.interrupt` effect 来中断指定的 Fiber。 这个 effect 模拟它所在的 Fiber 被显式中断的行为。 执行时,它会让该 Fiber 立即停止运行,并捕获中断的详细信息,例如该 Fiber 的 ID 和它的启动时间。 如果使用 [runPromiseExit](/docs/v4/getting-started/running-effects/#runpromiseexit) 这类函数运行 effect,就可以在 [Exit](/docs/v4/data-types/exit/) 类型中观察到由此产生的中断。 **示例**(无中断) 在这个例子中,程序在没有任何中断的情况下运行,记录了任务的开始与完成。 ```ts import { Effect, Exit } from "effect" const program = Effect.gen(function* () { console.log("start") yield* Effect.sleep("2 seconds") console.log("done") return "some result" }) await Effect.runPromiseExit(program) // => Exit.succeed("some result") /* Output: start done */ ``` **示例**(发生中断) 这里,Fiber 在打印日志 `"start"` 之后、打印 `"done"` 之前被中断。`Effect.interrupt` 会停止该 Fiber,因此它永远不会走到最后那行日志。 ```ts import { Effect } from "effect" const program = Effect.gen(function* () { console.log("start") yield* Effect.sleep("2 seconds") return yield* Effect.interrupt }) const exit = await Effect.runPromiseExit(program) exit._tag // => "Failure" /* Output: start { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ ``` ### onInterrupt 注册一个清理 effect,在某个 effect 被中断时运行。 这个函数允许你指定一个 effect,在 Fiber 被中断时运行。该 effect 会在 Fiber 被中断时执行, 让你可以执行清理或其他操作。 **示例**(在中断时运行清理操作) 在这个示例中,我们设置了一个处理器,每当 Fiber 被中断时就记录 "Cleanup completed"。然后展示了三种情况:成功的 effect、失败的 effect 以及被中断的 effect,以此说明处理器如何根据 effect 结束方式的不同而被触发。 ```ts import { Console, Effect, Exit } from "effect" // This handler is executed when the fiber is interrupted const handler = Effect.onInterrupt((_fibers) => Console.log("Cleanup completed"), ) const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) await Effect.runPromise(success) // => "some result" /* Output: Task completed */ const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) await Effect.runPromiseExit(failure) // => Exit.fail("some error") /* Output: Task failed */ const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) const interruptionExit = await Effect.runPromiseExit(interruption) interruptionExit._tag // => "Failure" /* Output: Task interrupted Cleanup completed */ ``` ### 并发 effect 的中断 当并发运行多个 effect 时(例如使用 `Effect.forEach`),如果其中一个 effect 被中断,就会导致所有并发 effect 也一并被中断。 由此得到的 [cause](/docs/v4/data-types/cause/) 会包含哪些 Fiber 被中断的信息。 **示例**(中断并发 effect) ```ts import { Effect, Console } from "effect" const program = Effect.forEach( [1, 2, 3], (n) => Effect.gen(function* () { console.log(`start #${n}`) yield* Effect.sleep(`${n} seconds`) if (n > 1) { return yield* Effect.interrupt } console.log(`done #${n}`) }).pipe(Effect.onInterrupt(() => Console.log(`interrupted #${n}`))), { concurrency: "unbounded" }, ) const exit = await Effect.runPromiseExit(program) console.log(JSON.stringify(exit, null, 2)) exit._tag // => "Failure" /* Output: start #1 start #2 start #3 done #1 interrupted #2 interrupted #3 { "_id": "Exit", "_tag": "Failure", "cause": { "_id": "Cause", "_tag": "Parallel", "left": { "_id": "Cause", "_tag": "Interrupt", "fiberId": { "_id": "FiberId", "_tag": "Runtime", "id": 3, "startTimeMillis": ... } }, "right": { "_id": "Cause", "_tag": "Sequential", "left": { "_id": "Cause", "_tag": "Empty" }, "right": { "_id": "Cause", "_tag": "Interrupt", "fiberId": { "_id": "FiberId", "_tag": "Runtime", "id": 0, "startTimeMillis": ... } } } } } */ ``` ## 竞速 ### race 这个函数接收两个 effect 并并发运行它们。第一个成功完成的 effect 将决定这次竞速的结果,而另一个 effect 会被中断。 如果两个 effect 都没有成功,该函数会以一个包含所有错误的 [cause](/docs/v4/data-types/cause/) 失败。 当你希望并发运行两个 effect、但只关心第一个成功的那个时,这很有用。它常用于超时、重试等场景,或者当你希望优化为更快得到响应、而不必顾虑另一个 effect 时。 **示例**(两个任务都成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) await Effect.runPromise(program) // => "task2" /* Output: task2 done task1 interrupted */ ``` **示例**(一个任务失败,一个任务成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) await Effect.runPromise(program) // => "task2" /* Output: task2 done */ ``` **示例**(两个任务都失败) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.fail("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const program = Effect.race(task1, task2) const exit = await Effect.runPromiseExit(program) console.log(exit) exit._tag // => "Failure" /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Parallel', left: { _id: 'Cause', _tag: 'Fail', failure: 'task1' }, right: { _id: 'Cause', _tag: 'Fail', failure: 'task2' } } } */ ``` 如果你想处理最先完成的任务的结果,无论它成功还是失败,都可以使用 `Effect.result` 函数。这个函数会把结果包装为 [Result](/docs/v4/data-types/result/) 类型,让你可以看出结果是成功(`Success`)还是失败(`Failure`): **示例**(用 Result 处理成功或失败) ```ts import { Effect, Console, Result } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) // Run both tasks concurrently, wrapping the result // in Result to capture success or failure const program = Effect.race(Effect.result(task1), Effect.result(task2)) await Effect.runPromise(program) // => Result.fail("task1") /* Output: task2 interrupted { _id: 'Result', _tag: 'Failure', failure: 'task1' } */ ``` ### raceAll 该函数会并发运行多个 effect,并返回第一个成功的 effect 的结果。一旦某个 effect 成功,其余的都会被中断。 如果所有 effect 都没有成功,该函数会以最后遇到的错误失败。 当你想要让多个 effect 竞速、但只关心第一个成功的那个时,这很有用。 它常用于超时、重试之类的场景, 或者当你想优化出更快的响应、 而不必关心其余 effect 的时候。 **示例**(所有任务都成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.succeed("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) await Effect.runPromise(program) // => "task1" /* Output: task1 done task2 interrupted task3 interrupted */ ``` **示例**(一个任务失败,两个任务成功) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.succeed("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) await Effect.runPromise(program) // => "task3" /* Output: task3 done task2 interrupted */ ``` **示例**(所有任务都失败) ```ts import { Effect, Console } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted")), ) const task2 = Effect.fail("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted")), ) const task3 = Effect.fail("task3").pipe( Effect.delay("150 millis"), Effect.tap(Console.log("task3 done")), Effect.onInterrupt(() => Console.log("task3 interrupted")), ) const program = Effect.raceAll([task1, task2, task3]) const exit = await Effect.runPromiseExit(program) console.log(exit) exit._tag // => "Failure" /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'task2' } } */ ``` ### raceFirst 该函数接收两个 effect 并发运行它们, 返回第一个完成的那个的结果, 无论它是成功还是失败。 当你想要让两个操作竞速, 并希望以先完成的那个继续执行(无论它是成功还是失败)时, 这个函数很有用。 **示例**(两个任务都成功) ```ts import { Effect, Console, Exit } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceFirst(task1, task2).pipe( Effect.tap(Console.log("more work...")), ) await Effect.runPromiseExit(program) // => Exit.succeed("task1") /* Output: task1 done task2 interrupted more work... */ ``` **示例**(一个任务失败,一个任务成功) ```ts import { Effect, Console, Exit } from "effect" const task1 = Effect.fail("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceFirst(task1, task2).pipe( Effect.tap(Console.log("more work...")), ) await Effect.runPromiseExit(program) // => Exit.fail("task1") /* Output: task2 interrupted */ ``` #### 观察胜出者 可选的 `onWinner` 回调会收到胜出的 fiber 及其索引(第一个 effect 为 `0`,第二个为 `1`)。该回调只用于观察:`raceFirst` 的结果仍然由最先完成的那个 effect 决定。 **示例**(观察哪个任务先完成) ```ts import { Effect, Console } from "effect" const task1 = Effect.succeed("task1").pipe( Effect.delay("100 millis"), Effect.tap(Console.log("task1 done")), Effect.onInterrupt(() => Console.log("task1 interrupted").pipe(Effect.delay("100 millis")), ), ) const task2 = Effect.succeed("task2").pipe( Effect.delay("200 millis"), Effect.tap(Console.log("task2 done")), Effect.onInterrupt(() => Console.log("task2 interrupted").pipe(Effect.delay("100 millis")), ), ) const program = Effect.raceFirst(task1, task2, { onWinner: ({ index }) => console.log(`task${index + 1} won`), }) Effect.runFork(program) /* Output: task1 done task1 won task2 interrupted */ ``` --- # Deferred > 掌握用 Deferred 进行异步协调的方法:这种一次性变量可用于管理 effect 的同步与通信。 `Deferred` 是 `Effect` 的一个特殊子类型,它的行为就像一个带有些许独特之处的一次性变量。它只能被完成一次,因此是管理异步操作以及程序不同部分之间同步的有力工具。 Deferred 本质上是一种同步原语,用来表示一个可能不会立即可用的值。当你创建一个 Deferred 时,它一开始是空的。之后,它可以用一个成功值 `Success` 或一个错误值 `Error` 来完成: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ▼ ▼ Deferred ``` 一旦完成,它就不能再被更改。 当一个 Fiber 调用 `Deferred.await` 时,它会暂停,直到该 Deferred 被完成。在 Fiber 等待期间,它并不会阻塞线程,而只是在语义上阻塞。这意味着其他 Fiber 仍然可以运行,从而保证了高效的并发。 Deferred 在概念上类似于 JavaScript 的 `Promise`。 关键区别在于它同时支持成功类型和错误类型,从而提供了更强的类型安全。 ## 创建 Deferred 可以用 `Deferred.make` 构造器创建 Deferred。它返回一个表示「创建 Deferred」这一过程的 effect。由于创建 Deferred 涉及内存分配,因此必须在 effect 内部完成,以确保对资源的安全管理。 **示例**(创建一个 Deferred) ```ts import { Deferred, Effect } from "effect" // ┌─── Effect> // ▼ const deferred = Deferred.make() const d = await Effect.runPromise(deferred) Deferred.isDeferred(d) // => true ``` ## 等待 要从 Deferred 中取出值,可以使用 `Deferred.await`。这个操作会挂起调用它的 Fiber,直到该 Deferred 以一个值或一个错误被完成。 ```ts import { Effect, Deferred } from "effect" // ┌─── Effect, never, never> // ▼ const deferred = Deferred.make() // ┌─── Effect // ▼ const value = deferred.pipe(Effect.andThen(Deferred.await)) // The Deferred is never completed, so awaiting it suspends the fiber const result = await Effect.runPromise( Effect.race(value, Effect.sleep("50 millis").pipe(Effect.as("timeout"))), ) result // => "timeout" ``` ## 完成 Deferred 你可以用多种方式完成一个 Deferred,具体取决于你想让它成功、失败,还是中断正在等待的 Fiber: | API | 说明 | | ----------------------- | --------------------------------------------------------------------------------------------------------------- | | `Deferred.succeed` | 用一个值成功完成该 Deferred。 | | `Deferred.done` | 用一个 [Exit](/docs/v4/data-types/exit/) 值完成该 Deferred。 | | `Deferred.complete` | 用某个 effect 的结果完成该 Deferred。 | | `Deferred.completeWith` | 用一个 effect 完成该 Deferred。该 effect 会被每一个正在等待的 Fiber 执行,因此请谨慎使用。 | | `Deferred.fail` | 用一个错误使该 Deferred 失败。 | | `Deferred.die` | 用一个用户定义的错误使该 Deferred 产生 defect。 | | `Deferred.failCause` | 用一个 [Cause](/docs/v4/data-types/cause/) 使该 Deferred 失败或产生 defect。 | | `Deferred.interrupt` | 中断该 Deferred,强制停止或中断正在等待的 Fiber。 | **示例**(用一个成功值完成 Deferred) ```ts import { Effect, Deferred } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Complete the Deferred successfully yield* Deferred.succeed(deferred, 1) // Awaiting the Deferred to get its value const value = yield* Deferred.await(deferred) console.log(value) value // => 1 }) await Effect.runPromise(program) ``` 完成一个 Deferred 会产生一个 `Effect`。如果该 Deferred 被成功完成,这个 effect 返回 `true`;如果它之前已经被完成过,则返回 `false`。这对于跟踪 Deferred 的状态很有用。 **示例**(检查完成状态) ```ts import { Effect, Deferred } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Attempt to fail the Deferred const firstAttempt = yield* Deferred.fail(deferred, "oh no!") // Attempt to succeed after it has already been completed const secondAttempt = yield* Deferred.succeed(deferred, 1) console.log([firstAttempt, secondAttempt]) const both = [firstAttempt, secondAttempt] // => [true, false] }) await Effect.runPromise(program) ``` ## 检查完成状态 有时,你可能需要在不挂起 Fiber 的情况下检查一个 Deferred 是否已经完成。这可以用 `Deferred.poll` 方法做到。它的工作方式如下: - `Deferred.poll` 返回一个 `Option>`: - 如果 `Deferred` 尚未完成,它返回 `None`。 - 如果 `Deferred` 已完成,它返回 `Some`,其中包含结果或错误。 此外,你可以用 `Deferred.isDone` 函数检查一个 Deferred 是否已经完成。该方法返回一个 `Effect`,如果 `Deferred` 已完成,它就求值为 `true`,让你能够快速检查它的状态。 **示例**(轮询并检查完成状态) ```ts import { Effect, Deferred, Option } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Polling the Deferred to check if it's completed const done1 = yield* Deferred.poll(deferred) // Checking if the Deferred has been completed const done2 = yield* Deferred.isDone(deferred) console.log([done1, done2]) const both = [done1, done2] // => [Option.none(), false] }) await Effect.runPromise(program) ``` ## 常见用例 当你需要等待程序中某件特定的事情发生时,`Deferred` 就派上了用场。 它非常适合这样的场景:你希望代码的某一部分在就绪时向另一部分发出信号。 以下是一些常见用例: | **用例** | **说明** | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **协调 Fiber** | 当你有多个并发任务并需要协调它们的动作时,`Deferred` 可以帮助一个 Fiber 在完成自己的任务时向另一个 Fiber 发出信号。 | | **同步** | 每当你希望确保某一段代码在另一段代码完成工作之前不继续执行时,`Deferred` 都能提供你所需要的同步。 | | **交接工作** | 你可以用 `Deferred` 把工作从一个 Fiber 移交到另一个 Fiber。例如,一个 Fiber 准备好一些数据,然后第二个 Fiber 继续处理它。 | | **挂起执行** | 当你希望一个 Fiber 暂停执行直到某个条件满足时,可以用 `Deferred` 阻塞它,直到该条件被满足。 | **示例**(用 Deferred 协调两个 Fiber) 在这个示例中,我们用 Deferred 在两个 Fiber 之间传递一个值。 通过并发运行这两个 Fiber,并把 Deferred 用作同步点,我们可以确保 `fiberB` 只有在 `fiberA` 完成自己的任务之后才继续执行。 ```ts import { Effect, Deferred, Fiber } from "effect" const program = Effect.gen(function* () { const deferred = yield* Deferred.make() // Completes the Deferred with a value after a delay const taskA = Effect.gen(function* () { console.log("Starting task to complete the Deferred") yield* Effect.sleep("1 second") console.log("Completing the Deferred") return yield* Deferred.succeed(deferred, "hello world") }) // Waits for the Deferred and prints the value const taskB = Effect.gen(function* () { console.log("Starting task to get the value from the Deferred") const value = yield* Deferred.await(deferred) console.log("Got the value from the Deferred") return value }) // Run both fibers concurrently const fiberA = yield* Effect.forkChild(taskA) const fiberB = yield* Effect.forkChild(taskB) // Wait for both fibers to complete const both = yield* Effect.zip(Fiber.join(fiberA), Fiber.join(fiberB)) console.log(both) both // => [true, "hello world"] }) await Effect.runPromise(program) /* Starting task to complete the Deferred Starting task to get the value from the Deferred Completing the Deferred Got the value from the Deferred [ true, 'hello world' ] */ ``` --- # Fiber > 了解 Effect 中的 Fiber——轻量级虚拟线程,带来强大并发、结构化生命周期与高效的资源管理。 Effect 是一个由 Fiber 驱动的高并发框架。Fiber 是轻量级虚拟线程,具备资源安全的取消能力,为 Effect 中的诸多特性提供了支撑。 在本节中,你将学习 Fiber 的基础知识,并熟悉一些利用 Fiber 的强大底层操作符。 ## 什么是虚拟线程? JavaScript 本质上是单线程的,也就是说它按单一指令序列执行代码。不过,现代 JavaScript 环境使用事件循环来管理异步操作,从而营造出多任务并行的假象。在这种语境下,虚拟线程(也就是 Fiber)是由 Effect 运行时模拟出来的逻辑线程。它们允许并发执行,而无需依赖 JavaScript 原生并不支持的真多线程。 ## Fiber 如何工作 Effect 中的所有 effect 都由 Fiber 执行。如果你没有自己创建 Fiber,那么它要么是由你正在使用的某个操作创建的(如果该操作是并发的),要么是由 Effect 运行时系统创建的。 每当一个 effect 被运行时,就会创建一个 Fiber。当并发运行多个 effect 时,会为每个并发 effect 创建一个 Fiber。 即使你编写的是没有任何并发操作的“单线程”代码,也总会至少存在一个 Fiber:执行你的 effect 的那个“主” Fiber。 Effect 的 Fiber 具有定义良好的生命周期,该生命周期基于它所执行的那个 effect。 每个 Fiber 的退出方式要么是失败,要么是成功,取决于它所执行的 effect 是失败还是成功。 Effect 的 Fiber 具有唯一的标识、局部状态以及状态(例如 done、running 或 suspended)。 总结如下: - `Effect` 是更高层的概念,用于描述一段带副作用的计算。它是惰性且不可变的,这意味着它表示一段可能产生值、也可能失败的计算,但并不会立即执行。 - 而 Fiber 表示 `Effect` 正在运行的执行过程。它可以被中断,也可以被等待以获取其结果。可以把它看作一种控制和交互正在进行的计算的方式。 ## Fiber 数据类型 Effect 中的 `Fiber` 数据类型表示对某个 effect 执行的“句柄”。 以下是 `Fiber` 的一般形式: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ▼ ▼ Fiber ``` 这个类型表明一个 Fiber: - 成功并返回类型为 `Success` 的值 - 失败并带有类型为 `Error` 的错误 Fiber 没有 `Requirements` 类型参数,因为它们只执行那些依赖需求已经被提供好的 effect。 ## Fork Effect 你可以通过 **fork** 一个 effect 来创建新的 Fiber。这会在一个新的 Fiber 中启动该 effect,而你会收到指向该 Fiber 的引用。 **示例**(Fork 一个 Fiber) 在这个示例中,斐波那契计算被 fork 到它自己的 Fiber 中,使它能够独立于主 Fiber 运行。`fib10Fiber` 的引用可以在之后用于 join 或中断该 Fiber。 ```ts import { Effect, Fiber } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.forkChild(fib(10)) await Effect.runPromise(fib10Fiber.pipe(Effect.andThen(Fiber.join))) // => 55 ``` ## Join Fiber 对 Fiber 最常见的操作之一是 **join**。使用 `Fiber.join` 函数,你可以等待某个 Fiber 完成并获取它的结果。被 join 的 Fiber 要么成功、要么失败,而 `join` 返回的 `Effect` 反映了该 Fiber 的结果。 **示例**(Join 一个 Fiber) ```ts import { Effect, Fiber } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.forkChild(fib(10)) const program = Effect.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Join the fiber and get the result const n = yield* Fiber.join(fiber) console.log(n) n // => 55 }) await Effect.runPromise(program) // => undefined ``` ## Await Fiber 在处理 Fiber 时,`Fiber.await` 函数是一个很有用的工具。它允许你等待某个 Fiber 完成,并获取关于它是如何结束的详细信息。结果被封装在一个 [Exit](/docs/v4/data-types/exit/) 值中,让你了解该 Fiber 是成功、失败还是被中断。 **示例**(等待 Fiber 完成) ```ts import { Effect, Fiber, Exit } from "effect" const fib = (n: number): Effect.Effect => n < 2 ? Effect.succeed(n) : Effect.zipWith(fib(n - 1), fib(n - 2), (a, b) => a + b) // ┌─── Effect, never, never> // ▼ const fib10Fiber = Effect.forkChild(fib(10)) const program = Effect.gen(function* () { // Retrieve the fiber const fiber = yield* fib10Fiber // Await its completion and get the Exit result const exit = yield* Fiber.await(fiber) console.log(exit) exit // => Exit.succeed(55) }) await Effect.runPromise(program) // => undefined ``` ## 中断模型 在开发并发应用时,有几种情况需要我们中断其他 Fiber 的执行,例如: 1. 父 Fiber 可能启动了一些子 Fiber 来执行某项任务,之后父 Fiber 可能认定它不再需要其中某些或全部子 Fiber 的结果。 2. 两个或多个 Fiber 相互竞争。结果最先计算出来的 Fiber 胜出,而其他所有 Fiber 都不再需要,应当被中断。 3. 在交互式应用中,用户可能希望停止某些已经在运行的任务,例如点击“停止”按钮以阻止继续下载文件。 4. 运行时间超出预期的计算,应当通过超时操作予以中止。 5. 当我们的应用根据用户输入执行计算密集型任务时,如果用户更改了输入,我们就应当取消当前任务并执行另一个任务。 ### 轮询 vs. 异步中断 在中断 Fiber 方面,一种朴素的做法是允许一个 Fiber 强制终止另一个 Fiber。然而这种做法并不理想,因为如果目标 Fiber 正在修改共享状态,强制终止就可能使该状态处于不一致、不可靠的状态。因此,它无法保证共享可变状态的内部一致性。 相反,有两种流行且有效的方案可以解决这个问题: 1. **半异步中断(轮询式中断)**:命令式语言通常采用轮询作为一种半异步信号机制,例如 Java。在这种模型中,一个 Fiber 向另一个 Fiber 发送中断请求。目标 Fiber 持续轮询中断状态,检查自己是否收到了来自其他 Fiber 的中断请求。如果检测到中断请求,目标 Fiber 会尽快终止自身。 采用这种方案时,临界区由 Fiber 自身处理。因此,如果某个 Fiber 正处于临界区中并收到中断请求,它会忽略该中断,并把对中断的处理推迟到临界区之后。 然而这种做法的一个缺点是:如果程序员忘记定期轮询,目标 Fiber 就可能变得无响应,从而导致死锁。此外,轮询一个全局标志与 Effect 所遵循的函数式范式并不契合。 2. **异步式中断**:在异步式中断中,允许一个 Fiber 终止另一个 Fiber。目标 Fiber 并不负责轮询中断状态。取而代之的是,在临界区中,目标 Fiber 会禁用这些区域的可中断性。这是一种纯函数式方案,不需要轮询全局状态。Effect 的中断模型采用了这一方案,它是一种完全异步的信号机制。 这种机制克服了忘记定期轮询的缺点。它也与函数式范式完全兼容,因为在纯函数式计算中,我们可以在任意时刻中止计算,除非处于那些禁用了中断的临界区。 ### 中断 Fiber 如果 Fiber 的结果不再被需要,就可以中断它。该操作会立即停止该 Fiber,并安全地运行所有终结器以释放资源。 与 `Fiber.await` 不同——后者返回一个描述 Fiber 如何完成的 [Exit](/docs/v4/data-types/exit/) 值——`Fiber.interrupt` 返回的是 `Effect`。它仍会等待该 Fiber 完全终止(在此期间运行它的所有终结器)后才恢复,但不会把该 Fiber 的 `Exit` 交还给你。 **示例**(中断一个 Fiber) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork a fiber that runs indefinitely, printing "Hi!" const fiber = yield* Effect.forkChild( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") // Interrupt the fiber and wait for it to fully terminate const result = yield* Fiber.interrupt(fiber) console.log(result) }) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! undefined */ ``` 默认情况下,`Fiber.interrupt` 返回的 effect 会等待该 Fiber 完全终止后才恢复。这确保了在前一批 Fiber 完成之前不会启动新的 Fiber,这种行为被称为“背压”(back-pressuring)。 如果你不需要这种等待行为,可以把这个中断操作本身 fork 出去,让主程序不必等待该 Fiber 终止就能继续执行: **示例**(Fork 一个中断操作) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") const _ = yield* Effect.forkChild(Fiber.interrupt(fiber)) console.log("Do something else...") }) await Effect.runPromise(program) // => undefined /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! Do something else... */ ``` 对于“发射后不管”式的中断,Fiber 还暴露了立即执行的 `interruptUnsafe` 方法。与 `Fiber.interrupt` 不同,它不会等待该 Fiber 的终结器执行完成。 ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.forever(Effect.log("Hi!").pipe(Effect.delay("10 millis"))), ) yield* Effect.sleep("30 millis") // const _ = yield* Effect.forkChild(Fiber.interrupt(fiber)) fiber.interruptUnsafe() console.log("Do something else...") }) await Effect.runPromise(program) // => undefined /* Output: timestamp=... level=INFO fiber=#1 message=Hi! timestamp=... level=INFO fiber=#1 message=Hi! Do something else... */ ``` ## 组合 Fiber 结果 Fiber 句柄不能直接组合。先 join 每个 Fiber 得到对应的 `Effect`,再用诸如 `Effect.zip` 之类的操作符组合这些 effect。 **示例**(组合两个 Fiber 的结果) 在这个示例中,两个 Fiber 并发运行,它们的结果被组合成一个元组。 ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork two fibers that each produce a string const fiber1 = yield* Effect.forkChild(Effect.succeed("Hi!")) const fiber2 = yield* Effect.forkChild(Effect.succeed("Bye!")) // Join both fibers and zip their results into a tuple const tuple = yield* Effect.zip(Fiber.join(fiber1), Fiber.join(fiber2)) console.log(tuple) tuple // => ["Hi!", "Bye!"] }) await Effect.runPromise(program) // => undefined ``` 同样的原则也适用于回退行为:join 两个 Fiber,并从第一个被 join 的 effect 的失败中恢复。 **示例**(提供一个回退 Fiber) ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function* () { // Fork a fiber that will fail const fiber1 = yield* Effect.forkChild(Effect.fail("Uh oh!")) // Fork another fiber that will succeed const fiber2 = yield* Effect.forkChild(Effect.succeed("Hurray!")) // If fiber1 fails, fiber2 will be used as a fallback const message = yield* Effect.catchCause(Fiber.join(fiber1), () => Fiber.join(fiber2), ) console.log(message) message // => "Hurray!" }) await Effect.runPromise(program) // => undefined ``` ## 子 Fiber 的生命周期 当我们 fork Fiber 时,根据 fork 方式的不同,子 Fiber 可以有四种不同的生命周期策略: 1. **带自动监督的 Fork**。如果我们使用普通的 `Effect.forkChild` 操作,子 Fiber 将由父 Fiber 自动监督。子 Fiber 的生命周期与父 Fiber 的生命周期绑定。这意味着这些 Fiber 要么在自然结束时终止,要么在父 Fiber 被终止时终止。 2. **在全局作用域中 Fork(Daemon)**。有时我们想运行长时间运行的后台 Fiber,它们不依附于父 Fiber,而且我们希望在全局作用域中 fork 它们。任何在全局作用域中 fork 的 Fiber 都会成为 daemon Fiber。这可以通过 `Effect.forkDetach` 操作实现。由于这些 Fiber 没有父 Fiber,它们不受监督;它们会在自然结束时终止,或在我们的应用被终止时终止。 3. **在局部作用域中 Fork**。有时,我们想运行一个不依附于父 Fiber 的后台 Fiber,但我们希望该 Fiber 生活在局部作用域中。我们可以使用 `Effect.forkScoped` 在局部作用域中 fork Fiber。这类 Fiber 可以比父 Fiber 存活得更久(因此不受父 Fiber 监督),它们会在自身完成时或局部作用域被关闭时终止。 4. **在指定作用域中 Fork**。这与上一种策略类似,但通过在指定作用域中 fork 子 Fiber,我们可以对子 Fiber 的生命周期进行更细粒度的控制。我们可以使用 `Effect.forkIn` 操作做到这一点。 ### 带自动监督的 Fork Effect 遵循**结构化并发**模型,其中子 Fiber 的生命周期与父 Fiber 绑定。简单来说,一个 Fiber 的寿命取决于其父 Fiber 的寿命。 **示例**(自动监督的子 Fiber) 在这个场景中,`parent` Fiber 启动了一个 `child` Fiber,后者每秒重复打印一条消息。 当 `parent` Fiber 完成时,`child` Fiber 将被终止。 ```ts import { Effect, Console, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Child fiber is supervised by the parent yield* Effect.forkChild(child) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) await Effect.runPromise(parent) // => undefined /* Output: parent: started! child: still running! child: still running! child: still running! parent: finished! */ ``` 这一行为可以扩展到任意层级的嵌套 Fiber,确保 Fiber 的生命周期可预测且受控。 ### 在全局作用域中 Fork(Daemon) 你可以使用 `Effect.forkDetach` 创建一个长时间运行的后台 Fiber。这类 Fiber 被称为 daemon Fiber,它不依附于父 Fiber 的生命周期,其寿命与全局作用域相关联。即使父 Fiber 被终止,daemon Fiber 仍会继续运行,只有当全局作用域被关闭或该 Fiber 自然完成时才会停止。 **示例**(创建一个 Daemon Fiber) 这个示例展示了 daemon Fiber 如何在父 Fiber 结束后仍然继续在后台运行。 ```ts import { Effect, Console, Schedule } from "effect" // Daemon fiber that logs a message repeatedly every second const daemon = Effect.repeat( Console.log("daemon: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Daemon fiber running independently yield* Effect.forkDetach(daemon) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) Effect.runFork(parent) /* Output: parent: started! daemon: still running! daemon: still running! daemon: still running! parent: finished! daemon: still running! daemon: still running! daemon: still running! daemon: still running! daemon: still running! ...etc... */ ``` 即使父 Fiber 被中断,daemon Fiber 也会继续独立运行。 **示例**(中断父 Fiber) 在这个示例中,中断父 Fiber 不会影响 daemon Fiber,它会继续在后台运行。 ```ts import { Effect, Console, Schedule, Fiber } from "effect" // Daemon fiber that logs a message repeatedly every second const daemon = Effect.repeat( Console.log("daemon: still running!"), Schedule.fixed("1 second"), ) const parent = Effect.gen(function* () { console.log("parent: started!") // Daemon fiber running independently yield* Effect.forkDetach(daemon) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }).pipe(Effect.onInterrupt(() => Console.log("parent: interrupted!"))) // Program that interrupts the parent fiber after 2 seconds const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(parent) yield* Effect.sleep("2 seconds") yield* Fiber.interrupt(fiber) // Interrupt the parent fiber }) Effect.runFork(program) /* Output: parent: started! daemon: still running! daemon: still running! parent: interrupted! daemon: still running! daemon: still running! daemon: still running! daemon: still running! daemon: still running! ...etc... */ ``` ### 在局部作用域中 Fork 有时我们想创建一个与局部 [scope](/docs/v4/resource-management/scope/) 绑定的 Fiber,也就是说它的生命周期不依赖于父 Fiber,而是绑定到它被 fork 时所处的局部作用域。这可以使用 `Effect.forkScoped` 操作来完成。 使用 `Effect.forkScoped` 创建的 Fiber 可以比其父 Fiber 存活得更久,只有当局部作用域本身被关闭时才会被终止。 **示例**(在局部作用域中 Fork 一个 Fiber) 在这个示例中,`child` Fiber 在 `parent` Fiber 的生命周期结束之后仍继续运行。`child` Fiber 与局部作用域绑定,只有当作用域结束时才会被终止。 ```ts import { Effect, Console, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) // ┌─── Effect // ▼ const parent = Effect.gen(function* () { console.log("parent: started!") // Child fiber attached to local scope yield* Effect.forkScoped(child) yield* Effect.sleep("3 seconds") console.log("parent: finished!") }) // Program runs within a local scope const program = Effect.scoped( Effect.gen(function* () { console.log("Local scope started!") yield* Effect.forkChild(parent) // Scope lasts for 5 seconds yield* Effect.sleep("5 seconds") console.log("Leaving the local scope!") }), ) await Effect.runPromise(program) // => undefined /* Output: Local scope started! parent: started! child: still running! child: still running! child: still running! parent: finished! child: still running! child: still running! Leaving the local scope! */ ``` ### 在指定作用域中 Fork 有些情况下我们需要更细粒度的控制,因此我们想在一个指定作用域中 fork 一个 Fiber。 我们可以使用 `Effect.forkIn` 操作,它接收目标作用域作为参数。 **示例**(在指定作用域中 Fork 一个 Fiber) 在这个示例中,`child` Fiber 被 fork 到 `outerScope` 中,这使它能够比内部作用域存活得更久,但在 `outerScope` 被关闭时仍会被终止。 ```ts import { Console, Effect, Schedule } from "effect" // Child fiber that logs a message repeatedly every second const child = Effect.repeat( Console.log("child: still running!"), Schedule.fixed("1 second"), ) const program = Effect.scoped( Effect.gen(function* () { yield* Effect.addFinalizer(() => Console.log("The outer scope is about to be closed!"), ) // Capture the outer scope const outerScope = yield* Effect.scope // Create an inner scope yield* Effect.scoped( Effect.gen(function* () { yield* Effect.addFinalizer(() => Console.log("The inner scope is about to be closed!"), ) // Fork the child fiber in the outer scope yield* Effect.forkIn(child, outerScope) yield* Effect.sleep("3 seconds") }), ) yield* Effect.sleep("5 seconds") }), ) await Effect.runPromise(program) // => undefined /* Output: child: still running! child: still running! child: still running! The inner scope is about to be closed! child: still running! child: still running! child: still running! child: still running! child: still running! child: still running! The outer scope is about to be closed! */ ``` ## Fiber 何时运行? 被 fork 的 Fiber 会在当前 Fiber 完成或让出之后开始执行。 **示例**(Fiber 启动过晚,只捕获到一个值) 在下面的示例中,`changes` Stream 只捕获到一个值 `2`。 这是因为由 `Effect.forkChild` 创建的 Fiber 在该值被更新**之后**才启动。 ```ts import { Effect, SubscriptionRef, Stream, Console } from "effect" const program = Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) yield* SubscriptionRef.changes(ref).pipe( // Log each change in SubscriptionRef Stream.tap((n) => Console.log(`SubscriptionRef changed to ${n}`)), Stream.runDrain, // Fork a fiber to run the stream Effect.forkChild, ) yield* SubscriptionRef.set(ref, 1) yield* SubscriptionRef.set(ref, 2) }) await Effect.runPromise(program) // => undefined /* Output: SubscriptionRef changed to 2 */ ``` 如果你使用 `Effect.sleep()` 添加一个短暂延迟,或者调用 `Effect.yieldNow()`,就能让当前 Fiber 让出执行权。这样,被 fork 的 Fiber 就有足够的时间在值被更新之前启动并收集到所有值。 **示例**(延迟让 Fiber 捕获所有值) ```ts import { Effect, SubscriptionRef, Stream, Console } from "effect" const program = Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) yield* SubscriptionRef.changes(ref).pipe( // Log each change in SubscriptionRef Stream.tap((n) => Console.log(`SubscriptionRef changed to ${n}`)), Stream.runDrain, // Fork a fiber to run the stream Effect.forkChild, ) // Allow the fiber a chance to start yield* Effect.sleep("100 millis") yield* SubscriptionRef.set(ref, 1) yield* SubscriptionRef.set(ref, 2) }) await Effect.runPromise(program) // => undefined /* Output: SubscriptionRef changed to 0 SubscriptionRef changed to 1 SubscriptionRef changed to 2 */ ``` --- # Latch > Latch 通过让 Fiber 等待某个特定事件发生来同步它们,并根据自身打开或关闭的状态控制访问。 Latch 是一种同步工具,其行为类似一道闸门:它让 Fiber 先等待,直到 Latch 被打开后才继续执行。Latch 可以处于打开或关闭两种状态: - 关闭时,到达 Latch 的 Fiber 会一直等待,直到它被打开。 - 打开时,Fiber 会立即通过。 一旦被打开,Latch 通常会保持打开状态,不过如有需要,你也可以再次将它关闭。 设想有一个应用,它只有在完成初始化设置(例如加载配置数据或建立数据库连接)之后才处理请求。 你可以在设置进行期间创建一个处于关闭状态的 Latch。 任何到达的请求(以 Fiber 表示)都会在 Latch 处等待,直到它被打开。 一旦设置完成,你调用 `latch.open`,请求便得以继续。 ## Latch 接口 `Latch` 包含若干操作,让你能够控制并观察它的状态: | 操作 | 说明 | | ---------- | -------------------------------------------------------------------------------------------------------- | | `whenOpen` | 仅当 Latch 处于打开状态时才运行给定的 effect;否则,等待直到它被打开。 | | `open` | 打开 Latch,让所有正在等待的 Fiber 得以继续。 | | `close` | 关闭 Latch,使 Fiber 在之后到达该 Latch 时进行等待。 | | `await` | 挂起当前 Fiber,直到 Latch 被打开。如果 Latch 已经处于打开状态,则立即返回。 | | `release` | 让正在等待的 Fiber 继续执行,但不会永久打开 Latch。 | ## 创建 Latch 使用 `Latch.make` 函数并传入一个布尔值,即可创建一个处于打开或关闭状态的 Latch。默认值为 `false`,也就是说它初始处于关闭状态。 **示例**(创建并使用 Latch) 在这个示例中,Latch 初始处于关闭状态。一个 Fiber 仅在 Latch 打开时才输出 “open sesame” 日志。等待一秒之后,Latch 被打开,该 Fiber 随之被释放: ```ts import { Console, Effect, Fiber, Latch } from "effect" // A generator function that demonstrates latch usage const program = Effect.gen(function* () { // Create a latch, starting in the closed state const latch = yield* Latch.make() // Fork a fiber that logs "open sesame" only when the latch is open const fiber = yield* Console.log("open sesame").pipe( latch.whenOpen, // Waits for the latch to open Effect.forkChild, // Fork the effect into a new fiber ) // Wait for 1 second yield* Effect.sleep("1 second") // Open the latch, releasing the fiber yield* latch.open // Wait for the forked fiber to finish yield* Fiber.await(fiber) }) await Effect.runPromise(program) // => undefined // Output: open sesame (after 1 second) ``` ## Latch 与 Semaphore 的对比 当你有一个一次性的事件或条件来决定 Fiber 能否继续执行时,Latch 是合适的选择。例如,你可以用 Latch 阻塞所有 Fiber,直到某个设置步骤完成,然后再打开 Latch,让所有 Fiber 继续执行。 而带一个锁的 [semaphore](/docs/v4/concurrency/semaphore/)(通常称为 binary semaphore 或 mutex)通常用于互斥:它确保同一时刻只有一个 Fiber 访问共享资源或代码段。一旦某个 Fiber 获取了锁,在锁被释放之前,其他 Fiber 都无法进入受保护的区域。 简而言之: - 如果你要用某个特定事件来闸控一组 Fiber(“在这里等待,直到条件成立”),请使用 **Latch**。 - 如果你需要确保同一时刻只有一个 Fiber 处于临界区或使用共享资源,请使用 **Semaphore(仅带一个锁)**。 --- # PubSub > 在 Effect 中使用 PubSub,轻松实现消息广播与异步通信。 `PubSub` 是一个异步消息中枢,发布者发送的消息可以被当前所有订阅者接收。 与 [Queue](/docs/v4/concurrency/queue/) 不同——在 Queue 中每个值只会投递给一个消费者——`PubSub` 会把每条已发布的消息广播给所有订阅者。因此,在需要消息广播而非负载分发的场景中,`PubSub` 是理想之选。 ## 基本操作 `PubSub` 存储类型为 `A` 的消息,并提供两个基础操作: | API | 说明 | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PubSub.publish` | 向 `PubSub` 发送一条类型为 `A` 的消息,返回一个 effect,指示该消息是否发布成功。 | | `PubSub.subscribe` | 创建一个 scoped effect,用于订阅该 `PubSub`,并在作用域结束时自动取消订阅。订阅者通过 [Dequeue](/docs/v4/concurrency/queue/#dequeue) 接收消息,Dequeue 中保存着已发布的消息。 | **示例**(向多个订阅者发布消息) ```ts import { Effect, PubSub } from "effect" const program = Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) // Two subscribers const dequeue1 = yield* PubSub.subscribe(pubsub) const dequeue2 = yield* PubSub.subscribe(pubsub) // Publish a message to the pubsub yield* PubSub.publish(pubsub, "Hello from a PubSub!") // Each subscriber receives the message const message1 = yield* PubSub.take(dequeue1) const message2 = yield* PubSub.take(dequeue2) console.log("Subscriber 1: " + message1) console.log("Subscriber 2: " + message2) message1 // => "Hello from a PubSub!" message2 // => "Hello from a PubSub!" }), ) await Effect.runPromise(program) // => undefined ``` ## 创建 PubSub ### 有界 PubSub 有界 `PubSub` 在达到容量上限时会对发布者施加背压(back pressure),暂停后续发布,直到有可用空间为止。 背压能确保所有订阅者在订阅期间都能收到全部消息。不过,如果某个订阅者速度较慢,消息投递也会随之变慢。 **示例**(创建有界 PubSub) ```ts import { Effect, PubSub } from "effect" // Creates a bounded PubSub with a capacity of 2 const boundedPubSub = PubSub.bounded(2) PubSub.capacity(await Effect.runPromise(boundedPubSub)) // => 2 ``` ### 丢弃式 PubSub 丢弃式 `PubSub` 在容量已满时会丢弃新值。如果消息被丢弃,`PubSub.publish` 操作会返回 `false`。 在丢弃式 pubsub 中,发布者可以继续发布新值,但不保证订阅者能收到所有消息。 **示例**(创建丢弃式 PubSub) ```ts import { Effect, PubSub } from "effect" // Creates a dropping PubSub with a capacity of 2 const droppingPubSub = PubSub.dropping(2) PubSub.capacity(await Effect.runPromise(droppingPubSub)) // => 2 ``` ### 滑动式 PubSub 滑动式 `PubSub` 会移除最早的消息,为新消息腾出空间,从而确保发布永不阻塞。 滑动式 pubsub 能避免慢订阅者影响消息投递速率。不过,慢订阅者仍有漏掉部分消息的风险。 **示例**(创建滑动式 PubSub) ```ts import { Effect, PubSub } from "effect" // Creates a sliding PubSub with a capacity of 2 const slidingPubSub = PubSub.sliding(2) PubSub.capacity(await Effect.runPromise(slidingPubSub)) // => 2 ``` ### 无界 PubSub 无界 `PubSub` 没有容量限制,因此发布总是立即成功。 无界 pubsub 保证所有订阅者都能收到全部消息,且不会拖慢消息投递。不过,如果消息的发布速度快于消费速度,它可以无限增长。 一般来说,除非你有特定的使用场景需要无界 pubsub,否则建议使用有界、丢弃式或滑动式 pubsub。 **示例** ```ts import { Effect, PubSub } from "effect" // Creates an unbounded PubSub with unlimited capacity const unboundedPubSub = PubSub.unbounded() PubSub.capacity(await Effect.runPromise(unboundedPubSub)) // => Number.MAX_SAFE_INTEGER ``` ## PubSub 上的操作符 ### publishAll `PubSub.publishAll` 函数让你可以一次性向 pubsub 发布多个值。 **示例**(发布多条消息) ```ts import { Effect, PubSub } from "effect" const program = Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) const dequeue = yield* PubSub.subscribe(pubsub) yield* PubSub.publishAll(pubsub, ["Message 1", "Message 2"]) const messages = yield* PubSub.takeAll(dequeue) console.log(messages) messages // => ["Message 1", "Message 2"] }), ) await Effect.runPromise(program) // => undefined ``` ### capacity / size 你可以分别用 `PubSub.capacity` 和 `PubSub.size` 查看 pubsub 的容量与当前大小。 注意,`PubSub.capacity` 返回一个 `number`,因为容量在 pubsub 创建时就已设定,之后不会再改变。 相比之下,由于 pubsub 中消息的数量会随时间变化,`PubSub.size` 返回一个 effect,用于获取 pubsub 的当前大小。 **示例**(获取 PubSub 的容量与大小) ```ts import { Effect, PubSub } from "effect" const program = Effect.gen(function* () { const pubsub = yield* PubSub.bounded(2) console.log(`capacity: ${PubSub.capacity(pubsub)}`) const capacityMessage = `capacity: ${PubSub.capacity(pubsub)}` // => "capacity: 2" console.log(`size: ${yield* PubSub.size(pubsub)}`) const sizeMessage = `size: ${yield* PubSub.size(pubsub)}` // => "size: 0" }) await Effect.runPromise(program) // => undefined ``` ### 关闭 PubSub 要关闭 pubsub,请使用 `PubSub.shutdown`。你也可以用 `PubSub.isShutdown` 检查它是否已关闭,或用 `PubSub.awaitShutdown` 等待关闭完成。关闭 pubsub 还会终止所有关联的队列,确保关闭信号被有效传达。 --- # Queue > 了解如何使用 Effect 的 Queue,以内置背压实现轻量、类型安全且异步的工作流。 `Queue` 是一个轻量级的内存队列,内置背压(back pressure),能够以异步、纯函数式且类型安全的方式处理数据。 ## 基本操作 `Queue` 存储类型为 `A` 的值,并提供两个基础操作: | API | 说明 | | ------------- | --------------------------------------- | | `Queue.offer` | 向队列中添加一个类型为 `A` 的值。 | | `Queue.take` | 移除并返回队列中最旧的值。 | **示例**(添加并取出一个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { // Creates a bounded queue with capacity 100 const queue = yield* Queue.bounded(100) // Adds 1 to the queue yield* Queue.offer(queue, 1) // Retrieves and removes the oldest value const value = yield* Queue.take(queue) return value }) await Effect.runPromise(program) // => 1 ``` ## 创建 Queue Queue 可以是**有界的**(带有指定容量),也可以是**无界的**(没有上限)。不同类型的队列在达到容量上限时,对新值的处理方式各不相同。 ### 有界 Queue 有界队列在已满时会施加背压,也就是说,任何 `Queue.offer` 操作都会挂起,直到有可用空间为止。 **示例**(创建有界 Queue) ```ts import { Effect, Queue } from "effect" // Creating a bounded queue with a capacity of 100 const boundedQueue = Queue.bounded(100) ;(await Effect.runPromise(boundedQueue)).capacity // => 100 ``` ### 丢弃式 Queue 丢弃式队列在队列已满时会丢弃新值。 **示例**(创建丢弃式 Queue) ```ts import { Effect, Queue } from "effect" // Creating a dropping queue with a capacity of 100 const droppingQueue = Queue.dropping(100) ;(await Effect.runPromise(droppingQueue)).capacity // => 100 ``` ### 滑动式 Queue 滑动式队列在达到容量上限时会移除旧值,为新值腾出空间。 **示例**(创建滑动式 Queue) ```ts import { Effect, Queue } from "effect" // Creating a sliding queue with a capacity of 100 const slidingQueue = Queue.sliding(100) ;(await Effect.runPromise(slidingQueue)).capacity // => 100 ``` ### 无界 Queue 无界队列没有容量限制,因此可以不受约束地添加新值。 **示例**(创建无界 Queue) ```ts import { Effect, Queue } from "effect" // Creates an unbounded queue without a capacity limit const unboundedQueue = Queue.unbounded() ;(await Effect.runPromise(unboundedQueue)).capacity // => Infinity ``` ## 向 Queue 添加元素 ### offer 使用 `Queue.offer` 向队列中添加值。 **示例**(添加单个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds 1 to the queue yield* Queue.offer(queue, 1) return yield* Queue.size(queue) }) await Effect.runPromise(program) // => 1 ``` 使用带背压的队列时,如果队列已满,`Queue.offer` 会挂起。为了避免阻塞主 Fiber,你可以把 `Queue.offer` 操作 fork 出去。 **示例**(用 `Effect.forkChild` 处理已满的队列) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(1) // Fill the queue with one item yield* Queue.offer(queue, 1) // Attempting to add a second item will suspend as the queue is full const fiber = yield* Effect.forkChild(Queue.offer(queue, 2)) // Empties the queue to make space yield* Queue.take(queue) // Joins the fiber, completing the suspended offer yield* Fiber.join(fiber) // Returns the size of the queue after additions return yield* Queue.size(queue) }) await Effect.runPromise(program) // => 1 ``` ### offerAll 你也可以用 `Queue.offerAll` 一次性添加多个元素。 **示例**(添加多个元素) ```ts import { Effect, Queue, Array } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) const items = Array.range(1, 10) // Adds all items to the queue at once yield* Queue.offerAll(queue, items) // Returns the size of the queue after additions return yield* Queue.size(queue) }) await Effect.runPromise(program) // => 10 ``` ## 从 Queue 消费元素 ### take `Queue.take` 操作会从队列中移除并返回最旧的元素。如果队列为空,`Queue.take` 会挂起,直到有元素被添加时才恢复。为避免阻塞,你可以把 `Queue.take` 操作 fork 到一个新的 Fiber 中。 **示例**(在 Fiber 中等待一个元素) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // This take operation will suspend because the queue is empty const fiber = yield* Effect.forkChild(Queue.take(queue)) // Adds an item to the queue yield* Queue.offer(queue, "something") // Joins the fiber to get the result of the take operation const value = yield* Fiber.join(fiber) return value }) await Effect.runPromise(program) // => "something" ``` ### poll 若想在不挂起的情况下取出队列的第一个元素,请使用 `Queue.poll`。如果队列为空,`Queue.poll` 返回 `None`;如果队列中有元素,它会将该元素包装在 `Some` 中。 **示例**(轮询一个元素) ```ts import { Effect, Queue, Option } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 10) yield* Queue.offer(queue, 20) // Retrieves the first item if available const head = yield* Queue.poll(queue) return head }) await Effect.runPromise(program) // => Option.some(10) ``` ### takeUpTo 要取出多个元素,请使用 `Queue.takeBetween`,它会返回最多达到指定数量的元素。 如果元素数量不足,它会返回所有当前可用的元素,而不会继续等待。 当不需要精确数量的元素时,这个函数对批处理特别有用。它能确保程序利用当前可用的数据继续工作。 如果你需要等待精确数量的元素再继续,可以考虑使用 [takeN](#taken)。 **示例**(最多取出 N 个元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) yield* Queue.offer(queue, 3) // Retrieves up to 2 items (min must be at least 1, otherwise // takeBetween short-circuits and returns an empty array) const items = yield* Queue.takeBetween(queue, 1, 2) console.log(items) return "some result" }) Effect.runPromise(program).then(console.log) /* Output: [ 1, 2 ] some result */ ``` ### takeN 从队列中取出指定数量的元素。如果队列中的元素不足,该操作会挂起,直到所需数量的元素可用为止。 在每次处理都需要精确数量元素的场景中,这个函数很有用:它能确保在该批次凑齐之前,操作不会继续。 **示例**(取出固定数量的元素) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { // Create a queue that can hold up to 100 elements const queue = yield* Queue.bounded(100) // Fork a fiber that attempts to take 3 items from the queue const fiber = yield* Effect.forkChild( Effect.gen(function* () { console.log("Attempting to take 3 items from the queue...") const chunk = yield* Queue.takeN(queue, 3) console.log(`Successfully took 3 items: ${chunk}`) }), ) // Offer only 2 items initially yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) console.log("Offered 2 items. The fiber is now waiting for the 3rd item...") // Simulate some delay yield* Effect.sleep("2 seconds") // Offer the 3rd item, which will unblock the takeN call yield* Queue.offer(queue, 3) console.log("Offered the 3rd item, which should unblock the fiber.") // Wait for the fiber to finish yield* Fiber.join(fiber) return "some result" }) await Effect.runPromise(program) // => "some result" /* Output: Offered 2 items. The fiber is now waiting for the 3rd item... Attempting to take 3 items from the queue... Offered the 3rd item, which should unblock the fiber. Successfully took 3 items: 1,2,3 */ ``` ### takeAll 要一次性取出队列中的所有元素,请使用 `Queue.takeAll`。该操作会立即完成:如果队列为空,则返回空集合。 **示例**(取出所有元素) ```ts import { Effect, Queue } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(100) // Adds items to the queue yield* Queue.offer(queue, 10) yield* Queue.offer(queue, 20) yield* Queue.offer(queue, 30) // Retrieves all items from the queue const chunk = yield* Queue.takeAll(queue) return chunk }) await Effect.runPromise(program) // => [10, 20, 30] ``` ## 关闭 Queue ### shutdown `Queue.shutdown` 操作允许你中断当前所有挂起在 `offer*` 或 `take*` 操作上的 Fiber。该操作还会清空队列,并使之后任何 `offer*` 与 `take*` 调用立即终止。 **示例**(关闭 Queue 时中断 Fiber) ```ts import { Effect, Queue, Fiber } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(3) // Forks a fiber that waits to take an item from the queue const fiber = yield* Effect.forkChild(Queue.take(queue)) // Shuts down the queue, interrupting the fiber yield* Queue.shutdown(queue) // Joins the interrupted fiber yield* Fiber.join(fiber) }) const exit = await Effect.runPromiseExit(program) exit._tag // => "Failure" ``` ### await `Queue.await` 操作会一直等待,直到队列进入 `Done` 状态。由于 `Queue.shutdown` 会以一个中断原因(interrupt cause)完成队列,正在等待关闭的 effect 自身也会被中断,而不会作为正常成功来结束。若想无论结果如何都执行清理逻辑,请使用 `Effect.onExit` 而不是 `Effect.andThen`,并使用 `Fiber.await` 而不是 `Fiber.join`,这样中断就不会传播到执行 join 的 Fiber。 **示例**(等待 Queue 关闭) ```ts import { Effect, Queue, Fiber, Console } from "effect" const program = Effect.gen(function* () { const queue = yield* Queue.bounded(3) // Forks a fiber to await queue shutdown and log a message, // regardless of whether the wait completes or is interrupted const fiber = yield* Effect.forkChild( Queue.await(queue).pipe(Effect.onExit(() => Console.log("shutting down"))), ) // Shuts down the queue, triggering (and interrupting) the await in the fiber yield* Queue.shutdown(queue) yield* Fiber.await(fiber) }) await Effect.runPromise(program) // => undefined // Output: shutting down ``` ## 只允许 offer / 只允许 take 的 Queue 有时,你可能希望代码的某些部分只能向队列添加值(`Enqueue`),或者只能从队列取出值(`Dequeue`)。Effect 提供了接口来强制约束这些特定的能力。 ### Enqueue 所有向队列添加值的方法都由 `Enqueue` 接口定义。这样就把队列限制为只能执行 offer 操作。 **示例**(把 Queue 限制为只能执行 offer 操作) ```ts import { Queue } from "effect" const send = (offerOnlyQueue: Queue.Enqueue, value: number) => { // This queue is restricted to offer operations only // Error: cannot use take on an offer-only queue // @errors: 2345 Queue.take(offerOnlyQueue) // Valid offer operation return Queue.offer(offerOnlyQueue, value) } ``` ### Dequeue 类似地,所有从队列取出值的方法都由 `Dequeue` 接口定义,它把队列限制为只能执行 take 操作。 **示例**(把 Queue 限制为只能执行 take 操作) ```ts import { Queue } from "effect" const receive = (takeOnlyQueue: Queue.Dequeue) => { // This queue is restricted to take operations only // Error: cannot use offer on a take-only queue // @errors: 2345 Queue.offer(takeOnlyQueue, 1) // Valid take operation return Queue.take(takeOnlyQueue) } ``` `Queue` 类型同时组合了 `Enqueue` 和 `Dequeue`,因此你可以轻松地把它传给代码的不同部分,并按需只暴露 `Enqueue` 或 `Dequeue` 的行为。 **示例**(同时使用只 offer 和只 take 的 Queue) ```ts import { Effect, Queue } from "effect" const send = (offerOnlyQueue: Queue.Enqueue, value: number) => { return Queue.offer(offerOnlyQueue, value) } const receive = (takeOnlyQueue: Queue.Dequeue) => { return Queue.take(takeOnlyQueue) } const program = Effect.gen(function* () { const queue = yield* Queue.unbounded() // Add values to the queue yield* send(queue, 1) yield* send(queue, 2) // Retrieve values from the queue const first = yield* receive(queue) const second = yield* receive(queue) console.log(first) console.log(second) first // => 1 second // => 2 }) await Effect.runPromise(program) // => undefined ``` --- # Semaphore > 学习在 Effect 中使用 semaphore,精确控制并发、管理资源访问,并高效协调异步任务。 semaphore 是一种同步机制,用于管理对共享资源的访问。在 Effect 中,semaphore 可以帮助控制资源访问,或在异步、并发操作中协调任务。 semaphore 就像一种通用化的互斥锁(mutex),它允许一定数量的**许可(permit)**被并发地获取和释放。许可就像票据,让任务或 Fiber 以受控的方式访问共享资源。当没有可用许可时,试图获取许可的任务会一直等待,直到有许可被释放。 ## 创建 Semaphore `Semaphore.make` 函数会用指定数量的许可初始化一个 semaphore。 每个许可允许一个任务并发地访问资源或执行操作,而多个许可则可以实现可配置的并发级别。 **示例**(创建一个带 3 个许可的 Semaphore) ```ts import { Effect, Semaphore } from "effect" // Create a semaphore with 3 permits const mutex = Semaphore.make(3) // The semaphore was created with exactly 3 permits available const acquired = await Effect.runPromise( mutex.pipe(Effect.flatMap((sem) => Semaphore.take(sem, 3))), ) acquired // => 3 ``` ## withPermits `withPermits` 方法允许你指定运行某个 effect 所需的许可数量。一旦所需的许可可用,它就会运行该 effect,并在任务完成时自动释放这些许可。 **示例**(用一个许可的 Semaphore 强制任务串行执行) 在这个示例中,三个任务被并发启动,但它们会串行执行,因为只有一个许可的 semaphore 一次只允许一个任务继续执行。 ```ts import { Effect, Semaphore } from "effect" const task = Effect.gen(function* () { yield* Effect.log("start") yield* Effect.sleep("2 seconds") yield* Effect.log("end") }) const program = Effect.gen(function* () { const mutex = yield* Semaphore.make(1) // Wrap the task to require one permit, forcing sequential execution const semTask = mutex.withPermits(1)(task).pipe(Effect.withLogSpan("elapsed")) // Run 3 tasks concurrently, but they execute sequentially // due to the one-permit semaphore yield* Effect.all([semTask, semTask, semTask], { concurrency: "unbounded", }) }) await Effect.runPromise(program) // => undefined /* Output: timestamp=... level=INFO fiber=#1 message=start elapsed=3ms timestamp=... level=INFO fiber=#1 message=end elapsed=2010ms timestamp=... level=INFO fiber=#2 message=start elapsed=2012ms timestamp=... level=INFO fiber=#2 message=end elapsed=4017ms timestamp=... level=INFO fiber=#3 message=start elapsed=4018ms timestamp=... level=INFO fiber=#3 message=end elapsed=6026ms */ ``` **示例**(使用多个许可控制并发任务的执行) 在这个示例中,我们创建一个带五个许可的 semaphore,并使用 `withPermits(n)` 为每个任务分配不同数量的许可: ```ts import { Effect, Semaphore } from "effect" const program = Effect.gen(function* () { const mutex = yield* Semaphore.make(5) const tasks = [1, 2, 3, 4, 5].map((n) => mutex .withPermits(n)(Effect.delay(Effect.log(`process: ${n}`), "2 seconds")) .pipe(Effect.withLogSpan("elapsed")), ) yield* Effect.all(tasks, { concurrency: "unbounded" }) }) await Effect.runPromise(program) // => undefined /* Output: timestamp=... level=INFO fiber=#1 message="process: 1" elapsed=2011ms timestamp=... level=INFO fiber=#2 message="process: 2" elapsed=2017ms timestamp=... level=INFO fiber=#3 message="process: 3" elapsed=4020ms timestamp=... level=INFO fiber=#4 message="process: 4" elapsed=6025ms timestamp=... level=INFO fiber=#5 message="process: 5" elapsed=8034ms */ ``` --- # 配置 > 使用 Config 与 ConfigProvider 描述、加载、校验并测试应用配置。 Effect 把配置的**描述**与提供其值的来源分离开来: - `Config` 描述如何加载并解码一个类型为 `T` 的值。它同时也是一个 `Effect`,因此可以直接在 `Effect.gen` 中被 yield。 - `ConfigProvider` 提供原始值。默认的 provider 从环境变量读取。 把这两件事分开,应用代码就可以只定义一次自己的需求,然后为生产环境、本地开发或测试选择不同的 provider。 ## 定义并解析 Config 对于单个值,使用 `Config` 模块中的构造器;要把它们组合起来,使用 `Config.all`。 **示例**(使用指定 Provider 解析 Config) ```ts import { Config, ConfigProvider, Effect } from "effect" const AppConfig = Config.all({ host: Config.NonEmptyString("HOST"), port: Config.Port("PORT"), }) const provider = ConfigProvider.fromEnv({ env: { HOST: "localhost", PORT: "8080", }, }) const result = Effect.runSync(AppConfig.parse(provider)) result // => { host: "localhost", port: 8080 } ``` 当 provider 已经可以显式拿到时,调用 `config.parse(provider)` 会很有用,尤其是在测试中。 在应用代码里,`Config` 也可以改为作为一个 Effect 被 yield。此时它会使用安装在 Effect 上下文中的 `ConfigProvider`;如果没有显式安装 provider,Effect 会使用 `ConfigProvider.fromEnv()`。 **示例**(使用默认的环境 Provider) ```ts import { Config, Effect } from "effect" const AppConfig = Config.all({ host: Config.NonEmptyString("HOST"), port: Config.Port("PORT").pipe(Config.withDefault(8080)), }) const program = Effect.gen(function* () { const { host, port } = yield* AppConfig console.log(`Application started: ${host}:${port}`) }) Effect.runPromise(program) ``` ```sh HOST=localhost PORT=3000 npx tsx app.ts ``` ```ansi Application started: localhost:3000 ``` ## 内置的 Config 值 Effect 为常见的标量值提供了便捷构造器: | 构造器 | 结果 | | ------------------------- | ----------------------------------------------------------- | | `String(name?)` | 字符串 | | `NonEmptyString(name?)` | 非空字符串 | | `Finite(name?)` | 有限数值 | | `Int(name?)` | 整数 | | `Port(name?)` | 1 到 65,535 之间的整数 | | `Boolean(name?)` | 布尔值 | | `Literal(value, name?)` | 单个字面量值 | | `Literals(values, name?)` | 若干字面量值之一 | | `Duration(name?)` | 一个 `Duration` | | `Date(name?)` | 一个 `Date` | | `URL(name?)` | 一个 `URL` | | `LogLevel(name?)` | 一个 [LogLevel](/docs/v4/observability/logging/#log-levels) | | `Redacted(name?)` | 一个 [`Redacted`](/docs/v4/data-types/redacted/) | 对于普通的数值配置,优先使用 `Config.Finite`。`Config.Number` 也会接受 `NaN` 和 `Infinity` 这类非有限值,而它们很少是有效的配置值。 `Config.Boolean` 接受区分大小写的字符串 `true`、`false`、`yes`、`no`、`on`、`off`、`1`、`0`、`y` 和 `n`。 ## 将 Config 与 Schema 一起使用 当某项配置需要自定义类型、校验或结构化表示时,使用 `Config.schema`。provider 提供编码后的表示,而生成的 `Config` 会产出该 schema 的 `Type`。 **示例**(校验配置值) ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" const Username = Schema.String.check( Schema.isMinLength(4, { message: "Expected at least 4 characters" }), ) const username = Config.schema(Username, "USERNAME") const provider = ConfigProvider.fromEnv({ env: { USERNAME: "alice" } }) Effect.runSync(username.parse(provider)) // => "alice" ``` Schema 也可以描述整个配置对象。 **示例**(读取结构化配置) ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" const ServerConfig = Config.schema( Schema.Struct({ host: Schema.String, port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })), }), "server", ) const provider = ConfigProvider.fromUnknown({ server: { host: "localhost", port: 8080, }, }) Effect.runSync(ServerConfig.parse(provider)) // => { host: "localhost", port: 8080 } ``` `Config.schema` 会从 schema 推导出一种树状的字符串编码。这样一来,同一份结构化配置既可以通过环境 provider 读取 `SERVER_HOST` 和 `SERVER_PORT`,也可以通过 `ConfigProvider.fromUnknown` 读取 `{ server: { host, port } }`。 更多细节参见 [Schema 的 Config](/docs/v4/schema/effect-data-types/#config)。 ### 数组与 Record `Config.Array` 和 `Config.Record` 用于读取这类值:既可以从结构化数据中读取,也可以从一个带分隔符的字符串中读取。**两者都直接返回 `Config`。** **示例**(读取逗号分隔的数组) ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" const exporters = Config.Array(Schema.String, "EXPORTERS") const provider = ConfigProvider.fromEnv({ env: { EXPORTERS: "otlp,prometheus" }, }) Effect.runSync(exporters.parse(provider)) // => ["otlp", "prometheus"] ``` `Config.Record(key, value, path)` 类似地既接受一个 record,也接受形如 `"service.name=api,service.version=1.0"` 的字符串。这两个构造器都接受用于自定义分隔符的选项。 当 provider 必须提供结构化的数组或对象、而不是一个带分隔符的标量值时,普通的 `Schema.Array` 和 `Schema.Record` 依然有用。 ## 组合 Config `Config.all` 把多个 config 组合成一个元组或一个具名对象,同时保留输入的形状。 **示例**(组合并嵌套 Config) ```ts import { Config, ConfigProvider, Effect } from "effect" const DatabaseConfig = Config.all({ host: Config.NonEmptyString("HOST"), port: Config.Port("PORT"), }).pipe(Config.nested("DATABASE")) const provider = ConfigProvider.fromEnv({ env: { DATABASE_HOST: "localhost", DATABASE_PORT: "5432", }, }) Effect.runSync(DatabaseConfig.parse(provider)) // => { host: "localhost", port: 5432 } ``` `Config.nested(config, path)` 会为该 config 执行的每一次查找都加上一个字符串或路径前缀。在使用 `ConfigProvider.fromEnv` 时,路径片段之间用 `_` 连接。 对于那些既接受已构建好的 `Config`、又接受嵌套 config record 的 API,`Config.Wrap` 和 `Config.unwrap` 分别提供对应的输入类型与转换。 ## 默认值与可选值 `Config.withDefault` 仅在相关输入都不存在时才提供一个值。 **示例**(提供默认值) ```ts import { Config, ConfigProvider, Effect } from "effect" const port = Config.Port("PORT").pipe(Config.withDefault(8080)) const provider = ConfigProvider.fromUnknown({}) Effect.runSync(port.parse(provider)) // => 8080 ``` 无效输入不会被当作缺失处理。例如 `PORT=not-a-port` 仍然会失败,而不会悄悄产出 `8080`。组合 config 也遵循同样的规则:如果组中的一部分已提供,那么缺失或无效的兄弟项会让这个组不完整,而不是用默认值替换整个组。 如果希望缺失时产出 `Option`,请使用 `Config.option`。 **示例**(读取可选值) ```ts import { Config, ConfigProvider, Effect, Option } from "effect" const apiKey = Config.String("API_KEY").pipe(Config.option) const provider = ConfigProvider.fromUnknown({}) Effect.runSync(apiKey.parse(provider)) // => Option.none() ``` ### 在任何 Config 错误之后 fallback `Config.orElse` 比 `Config.withDefault` 适用范围更广:它在出现任何 `ConfigError`(包括无效输入)之后都会尝试另一个 config。 **示例**(fallback 到另一个 Config) ```ts import { Config, ConfigProvider, Effect } from "effect" const host = Config.String("HOST").pipe( Config.orElse(() => Config.String("FALLBACK_HOST")), ) const provider = ConfigProvider.fromUnknown({ FALLBACK_HOST: "localhost" }) Effect.runSync(host.parse(provider)) // => "localhost" ``` 由于 `orElse` 可以从校验错误中恢复,只有在你确实有意替换无效输入时才使用它。对于「只有缺失才触发 fallback」这种常见情形,请使用 `withDefault`。 ## 转换值 对于不会失败的转换,使用 `Config.map`。 **示例**(映射 Config) ```ts import { Config, ConfigProvider, Effect } from "effect" const origin = Config.all({ host: Config.NonEmptyString("HOST"), port: Config.Port("PORT"), }).pipe(Config.map(({ host, port }) => `http://${host}:${port}`)) const provider = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: 8080, }) Effect.runSync(origin.parse(provider)) // => "http://localhost:8080" ``` 对于校验和解析,优先把规则表达在 schema 中并使用 `Config.schema`。如果某个转换需要改为返回 `Effect`,可以使用 `Config.mapEffect`。 ## 处理敏感值 `Config.Redacted` 会把字符串包装成 `Redacted`,其字符串表示不会暴露该值。要访问这个秘密,必须显式调用 `Redacted.value`。 **示例**(保护机密值) ```ts import { Config, ConfigProvider, Effect, Redacted } from "effect" const apiKey = Config.Redacted("API_KEY") const provider = ConfigProvider.fromEnv({ env: { API_KEY: "secret-value" }, }) const result = Effect.runSync(apiKey.parse(provider)) String(result) // => "" Redacted.value(result) // => "secret-value" ``` 如果需要先解码某个值、再把它包装起来,可以把 `Config.schema` 与 `Schema.RedactedFromValue` 结合使用。 ```ts import { Config, ConfigProvider, Effect, Redacted, Schema } from "effect" const secretNumber = Config.schema( Schema.RedactedFromValue(Schema.FiniteFromString), "SECRET_NUMBER", ) const provider = ConfigProvider.fromEnv({ env: { SECRET_NUMBER: "42" }, }) const result = Effect.runSync(secretNumber.parse(provider)) Redacted.value(result) // => 42 ``` ## Config Provider `ConfigProvider` 模块包含针对几种常见来源的 provider: | 构造器 | 来源 | | -------------------- | ---------------------------------------------------------- | | `fromEnv` | 环境变量 | | `fromUnknown` | 内存中的 JavaScript 值,包括解析后的 JSON | | `fromDotEnvContents` | `.env` 文件的内容 | | `fromDotEnv` | 通过 `FileSystem` service 读取的 `.env` 文件 | | `fromDir` | 目录树,包括挂载的 ConfigMap 与 Secret | | `make` | 自定义的后备存储 | `fromEnv` 默认会合并 `process.env` 与 `import.meta.env`。传入 `{ env }` 会替换掉这个来源,这使它便于在测试和非 Node 运行时中使用。 `fromEnv` 和 `fromUnknown` 默认都把空字符串视为缺失。当空字符串本身是有意义的值时,请传入 `{ preserveEmptyStrings: true }`。 ### 加载内存中的对象 对于 JavaScript 值或解析后的 JSON 对象,使用 `ConfigProvider.fromUnknown`。对象的键和数组索引会成为配置路径片段。 **示例**(加载解析后的 JSON 对象) ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromUnknown( JSON.parse(`{"server":{"host":"localhost","port":8080}}`), ) const server = Config.all({ host: Config.String("host"), port: Config.Port("port"), }).pipe(Config.nested("server")) Effect.runSync(server.parse(provider)) // => { host: "localhost", port: 8080 } ``` ### 加载 .env 文件与目录 `ConfigProvider.fromDotEnvContents` 解析已经加载好的字符串。`ConfigProvider.fromDotEnv()` 默认从当前目录读取 `.env`,并返回一个需要 `FileSystem` service 的 Effect。 `ConfigProvider.fromDir()` 把每个文件读取为一个叶子值,把每个目录读取为一个 record。这对于以文件形式挂载的配置很有用,例如 Kubernetes 的 ConfigMap 和 Secret。它需要 `FileSystem` 与 `Path` 这两个 service。 ### 转换 Provider 路径 Provider 组合子会改变所有 config 查找其路径的方式: - `ConfigProvider.nested` 为每一次查找加上前缀。 - `ConfigProvider.constantCase` 把字符串路径片段转换为 `CONSTANT_CASE`。 - `ConfigProvider.mapInput` 执行任意的路径转换。 **示例**(把 camelCase 键适配为环境变量) ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromEnv({ env: { DATABASE_HOST: "localhost" }, }).pipe(ConfigProvider.constantCase) const databaseHost = Config.String("databaseHost") Effect.runSync(databaseHost.parse(provider)) // => "localhost" ``` ### 组合 Provider `ConfigProvider.orElse(primary, fallback)` 仅在 primary provider 于所请求路径上没有值时才查询 fallback。与 `Config.orElse` 不同,它不会从来源错误或 schema 校验错误中恢复。 **示例**(为 Provider 添加默认值) ```ts import { Config, ConfigProvider, Effect } from "effect" const environment = ConfigProvider.fromEnv({ env: { HOST: "production.example.com" }, }) const defaults = ConfigProvider.fromUnknown({ HOST: "localhost", PORT: 8080, }) const provider = ConfigProvider.orElse(environment, defaults) const app = Config.all({ host: Config.String("HOST"), port: Config.Port("PORT"), }) Effect.runSync(app.parse(provider)) // => { host: "production.example.com", port: 8080 } ``` ### 安装 Provider 使用 `ConfigProvider.layer(provider)` 可以替换那些作为 Effect 被 yield 的 config 所使用的 provider。`ConfigProvider.layerAdd(provider)` 则是为当前 provider 添加一个 fallback;传入 `{ asPrimary: true }` 可以让新增的 provider 优先。 **示例**(提供 ConfigProvider Layer) ```ts import { Config, ConfigProvider, Effect } from "effect" const provider = ConfigProvider.fromUnknown({ PORT: 8080 }) const ProviderLayer = ConfigProvider.layer(provider) const program = Effect.gen(function* () { return yield* Config.Port("PORT") }) Effect.runSync(Effect.provide(program, ProviderLayer)) // => 8080 ``` 对于单个 config,`config.parse(provider)` 更简单,而且不会改变 Effect 程序中其余部分所使用的 provider。 --- # BigDecimal > BigDecimal 数据类型用于表示任意精度的十进制数。 在 JavaScript 中,数字通常以 64 位浮点数存储。浮点数虽然快速且通用,但会引入细小的舍入误差。这些误差在日常使用中往往难以察觉,但在金融或统计等领域却可能成为问题:细小的不精确随着时间累积,可能导致越来越大的偏差。 通过使用 BigDecimal 模块,你可以避免这些问题,并以更高的精度进行计算。 `BigDecimal` 数据类型可以表示小数位数很多的实数,从而避免浮点运算中常见的错误(例如 0.1 + 0.2 ≠ 0.3)。 ## BigDecimal 的工作原理 `BigDecimal` 用两个组成部分来表示一个数字: 1. `value`:一个 `BigInt`,存储数字的各位数字。 2. `scale`:一个 64 位整数,决定小数点的位置。 `BigDecimal` 所表示的数值按如下公式计算:value × 10-scale。 - 如果 `scale` 为零或正数,它表示小数点右侧的位数。 - 如果 `scale` 为负数,则将 `value` 乘以 10 的 `scale` 相反数次幂。 例如: - `value = 12345n`、`scale = 2` 的 `BigDecimal` 表示 `123.45`。 - `value = 12345n`、`scale = -2` 的 `BigDecimal` 表示 `1234500`。 最大精度很大,但并非无限,限制为 263 位小数。 ## 创建一个 BigDecimal ### make `make` 函数通过指定一个 `BigInt` 数值和一个 scale 来创建 `BigDecimal`。`scale` 决定小数点右侧的位数。 **示例**(使用指定的 scale 创建 BigDecimal) ```ts import { BigDecimal } from "effect" // Create a BigDecimal from a BigInt (1n) with a scale of 2 const decimal = BigDecimal.make(1n, 2) console.log(decimal) decimal // => BigDecimal.make(1n, 2) // Convert the BigDecimal to a string console.log(String(decimal)) String(decimal) // => "BigDecimal(0.01)" // Format the BigDecimal as a standard decimal string console.log(BigDecimal.format(decimal)) BigDecimal.format(decimal) // => "0.01" // Convert the BigDecimal to exponential notation console.log(BigDecimal.toExponential(decimal)) BigDecimal.toExponential(decimal) // => "1e-2" ``` ### fromBigInt `fromBigInt` 函数根据 `bigint` 创建 `BigDecimal`。`scale` 默认为 `0`,表示该数字没有小数部分。 **示例**(从 BigInt 创建 BigDecimal) ```ts import { BigDecimal } from "effect" const decimal = BigDecimal.fromBigInt(10n) console.log(decimal) decimal // => BigDecimal.fromBigInt(10n) ``` ### fromString 将数字字符串解析为 `BigDecimal`。返回 `Option`: - 字符串合法时返回 `Some(BigDecimal)`。 - 字符串非法时返回 `None`。 **示例**(将字符串解析为 BigDecimal) ```ts import { BigDecimal, Option } from "effect" const decimal = BigDecimal.fromString("0.02") console.log(decimal) decimal // => Option.some(BigDecimal.make(2n, 2)) ``` ### unsafeFromString `fromStringUnsafe` 函数是 `fromString` 的变体,当输入字符串非法时会抛出错误。仅当你确信输入始终合法时才使用它。 **示例**(不安全的字符串解析) ```ts import { BigDecimal } from "effect" const decimal = BigDecimal.fromStringUnsafe("0.02") console.log(decimal) decimal // => BigDecimal.make(2n, 2) ``` ### unsafeFromNumber 根据 JavaScript 的 `number` 创建 `BigDecimal`。对于非有限数(`NaN`、`+Infinity` 或 `-Infinity`),会抛出 `RangeError`。 **示例**(不安全的数字解析) ```ts import { BigDecimal } from "effect" console.log(BigDecimal.fromNumberUnsafe(123.456)) BigDecimal.fromNumberUnsafe(123.456) // => BigDecimal.make(123456n, 3) ``` ## 基本算术运算 BigDecimal 模块支持多种算术运算,它们能够保证精度,并避免标准 JavaScript 算术中常见的舍入误差。以下是受支持运算的列表: | 函数 | 说明 | | ----------------- | --------------------------------------------------------------------------------------------------------------- | | `sum` | 将两个 `BigDecimal` 值相加。 | | `subtract` | 从一个 `BigDecimal` 值中减去另一个。 | | `multiply` | 将两个 `BigDecimal` 值相乘。 | | `divide` | 将一个 `BigDecimal` 值除以另一个,返回 `Option`。 | | `divideUnsafe` | 将一个 `BigDecimal` 值除以另一个;若除数为零则抛出错误。 | | `negate` | 对 `BigDecimal` 值取负(即改变其符号)。 | | `remainder` | 返回一个 `BigDecimal` 值除以另一个的余数,结果为 `Option`。 | | `remainderUnsafe` | 返回一个 `BigDecimal` 值除以另一个的余数;若除数为零则抛出错误。 | | `sign` | 返回 `BigDecimal` 值的符号(`-1`、`0` 或 `1`)。 | | `abs` | 返回 `BigDecimal` 的绝对值。 | **示例**(使用 BigDecimal 执行基本算术运算) ```ts import { BigDecimal, Option } from "effect" const dec1 = BigDecimal.fromStringUnsafe("1.05") const dec2 = BigDecimal.fromStringUnsafe("2.10") // Addition console.log(String(BigDecimal.sum(dec1, dec2))) String(BigDecimal.sum(dec1, dec2)) // => "BigDecimal(3.15)" // Multiplication console.log(String(BigDecimal.multiply(dec1, dec2))) String(BigDecimal.multiply(dec1, dec2)) // => "BigDecimal(2.205)" // Subtraction console.log(String(BigDecimal.subtract(dec2, dec1))) String(BigDecimal.subtract(dec2, dec1)) // => "BigDecimal(1.05)" // Division (safe, returns Option) console.log(BigDecimal.divide(dec2, dec1)) BigDecimal.divide(dec2, dec1) // => Option.some(BigDecimal.make(2n, 0)) // Division (unsafe, throws if divisor is zero) console.log(String(BigDecimal.divideUnsafe(dec2, dec1))) String(BigDecimal.divideUnsafe(dec2, dec1)) // => "BigDecimal(2)" // Negation console.log(String(BigDecimal.negate(dec1))) String(BigDecimal.negate(dec1)) // => "BigDecimal(-1.05)" // Modulus (unsafe, throws if divisor is zero) console.log( String(BigDecimal.remainderUnsafe(dec2, BigDecimal.fromStringUnsafe("0.6"))), ) String(BigDecimal.remainderUnsafe(dec2, BigDecimal.fromStringUnsafe("0.6"))) // => "BigDecimal(0.3)" ``` 使用 `BigDecimal` 进行算术运算有助于避免 JavaScript 中浮点数常见的精度问题。例如: **示例**(避免浮点误差) ```ts const dec1 = 1.05 const dec2 = 2.1 console.log(String(dec1 + dec2)) String(dec1 + dec2) // => "3.1500000000000004" ``` ## 比较运算 `BigDecimal` 模块提供了多个用于比较小数值的函数。借助它们,你可以确定两个值的相对顺序、求最小值或最大值,并检查是否为正值、是否为整数等特定属性。 ### 比较函数 | 函数 | 说明 | | ------------------------ | ------------------------------------------------------------- | | `isLessThan` | 检查第一个 `BigDecimal` 是否小于第二个。 | | `isLessThanOrEqualTo` | 检查第一个 `BigDecimal` 是否小于或等于第二个。 | | `isGreaterThan` | 检查第一个 `BigDecimal` 是否大于第二个。 | | `isGreaterThanOrEqualTo` | 检查第一个 `BigDecimal` 是否大于或等于第二个。 | | `min` | 返回两个 `BigDecimal` 值中较小的那个。 | | `max` | 返回两个 `BigDecimal` 值中较大的那个。 | **示例**(比较两个 BigDecimal 值) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.fromStringUnsafe("1.05") const dec2 = BigDecimal.fromStringUnsafe("2.10") console.log(BigDecimal.isLessThan(dec1, dec2)) BigDecimal.isLessThan(dec1, dec2) // => true console.log(BigDecimal.isLessThanOrEqualTo(dec1, dec2)) BigDecimal.isLessThanOrEqualTo(dec1, dec2) // => true console.log(BigDecimal.isGreaterThan(dec1, dec2)) BigDecimal.isGreaterThan(dec1, dec2) // => false console.log(BigDecimal.isGreaterThanOrEqualTo(dec1, dec2)) BigDecimal.isGreaterThanOrEqualTo(dec1, dec2) // => false console.log(BigDecimal.min(dec1, dec2)) BigDecimal.min(dec1, dec2) // => BigDecimal.make(105n, 2) console.log(BigDecimal.max(dec1, dec2)) BigDecimal.max(dec1, dec2) // => BigDecimal.make(210n, 2) ``` ### 用于比较的谓词 该模块还包含用于检查 `BigDecimal` 特定属性的谓词: | 谓词 | 说明 | | ------------ | ------------------------------------------------ | | `isZero` | 检查该值是否恰好为零。 | | `isPositive` | 检查该值是否为正数。 | | `isNegative` | 检查该值是否为负数。 | | `between` | 检查该值是否落在指定范围内(含边界)。 | | `isInteger` | 检查该值是否为整数(即没有小数部分)。 | **示例**(检查 BigDecimal 值的符号与属性) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.fromStringUnsafe("1.05") const dec2 = BigDecimal.fromStringUnsafe("-2.10") console.log(BigDecimal.isZero(BigDecimal.fromStringUnsafe("0"))) BigDecimal.isZero(BigDecimal.fromStringUnsafe("0")) // => true console.log(BigDecimal.isPositive(dec1)) BigDecimal.isPositive(dec1) // => true console.log(BigDecimal.isNegative(dec2)) BigDecimal.isNegative(dec2) // => true console.log( BigDecimal.between({ minimum: BigDecimal.fromStringUnsafe("1"), maximum: BigDecimal.fromStringUnsafe("2"), })(dec1), ) BigDecimal.between({ minimum: BigDecimal.fromStringUnsafe("1"), maximum: BigDecimal.fromStringUnsafe("2"), })(dec1) // => true console.log( BigDecimal.isInteger(dec2), BigDecimal.isInteger(BigDecimal.fromBigInt(3n)), ) BigDecimal.isInteger(dec2) // => false BigDecimal.isInteger(BigDecimal.fromBigInt(3n)) // => true ``` ## 规范化与相等性 在某些情况下,两个 `BigDecimal` 值可能具有不同的内部表示,却仍然表示同一个数字。 例如,`1.05` 在内部可以用不同的 scale 表示,比如: - `105n`,scale 为 `2` - `1050n`,scale 为 `3` 为了保证一致性,你可以对 `BigDecimal` 进行规范化,以调整 scale 并去除末尾的零。 ### 规范化 `BigDecimal.normalize` 函数会调整 `BigDecimal` 的 scale,并消除其内部表示中不必要的末尾零。 **示例**(规范化 BigDecimal) ```ts import { BigDecimal } from "effect" const dec = BigDecimal.make(1050n, 3) console.log(BigDecimal.normalize(dec)) BigDecimal.normalize(dec) // => BigDecimal.make(105n, 2) ``` ### 相等性 若要检查两个 `BigDecimal` 值在数值上是否相等(无论其内部表示如何),请使用 `BigDecimal.equals` 函数。 **示例**(检查相等性) ```ts import { BigDecimal } from "effect" const dec1 = BigDecimal.make(105n, 2) const dec2 = BigDecimal.make(1050n, 3) console.log(BigDecimal.equals(dec1, dec2)) BigDecimal.equals(dec1, dec2) // => true ``` --- # Cause > 使用 Effect 中的 Cause 进行全面的错误分析 —— 精确追踪失败、defect 与中断的细节。 [`Effect`](/docs/v4/getting-started/the-effect-type/) 类型在错误类型 `E` 上是多态的,这让处理任意期望的错误类型都很灵活。然而,关于失败往往还有更多信息,单靠错误类型 `E` 是捕获不到的。 为了解决这个问题,Effect 使用 `Cause` 数据类型来存储各种细节,例如: - 非预期的错误或 defect - 堆栈与执行轨迹 - Fiber 被中断的原因 Effect 严格保留所有与失败相关的信息,在 `Cause` 类型中存储错误上下文的完整图景。这种全面的做法让失败能够被精确地分析和处理,确保不丢失任何数据。 虽然 `Cause` 值通常不会被直接操作,但它们是 Effect 工作流中错误的底层表示,既能提供并发的错误细节,也能提供顺序的错误细节。需要时,这让你可以对错误做彻底的分析。 ## 创建 Cause 你可以使用 `Effect.failCause` 有意创建一个带有特定 cause 的 effect。 **示例**(定义带有不同 Cause 的 Effect) ```ts import { Effect, Cause, Exit } from "effect" // Define an effect that dies with an unexpected error // // ┌─── Effect // ▼ const die = Effect.failCause(Cause.die("Boom!")) // Define an effect that fails with an expected error // // ┌─── Effect // ▼ const fail = Effect.failCause(Cause.fail("Oh no!")) Effect.runSyncExit(fail) // => Exit.fail("Oh no!") ``` 有些 cause 不会影响 effect 的错误类型,因此错误通道中会是 `never`: ```text ┌─── no error information ▼ Effect ``` 例如,`Cause.die` 不会为 effect 指定错误类型,而 `Cause.fail` 会,并据此设置错误通道的类型。 ## Cause 的变体 针对各种错误,存在若干种 cause。本节将逐一介绍这些 cause。 ### Empty `Empty` cause 表示没有任何错误,由一个空的 `reasons` 数组表示(`Cause.empty`)。 ### Fail `Fail` 原因表示由类型为 `E` 的预期错误导致的失败。只包含这一原因的 `Cause` 通过 `Cause.fail` 创建。 ### Die `Die` 原因表示由 defect(即非预期或意料之外的错误)导致的失败。只包含这一原因的 `Cause` 通过 `Cause.die` 创建。 ### Interrupt `Interrupt` 原因表示由 `Fiber` 中断导致的失败,并包含被中断的 `Fiber` 的数字 id(`number | undefined`)。只包含这一原因的 `Cause` 通过 `Cause.interrupt` 创建。 ### 组合 Cause `Cause` 把它的失败原因存储在一个扁平的 `reasons` 数组中。顺序发生的原因与并发发生的原因使用相同的表示形式。使用 `Cause.combine` 可以把两个 cause 合并成一个。 **示例**(把多个失败合并为单个 Cause) ```ts import { Cause } from "effect" const combined = Cause.combine(Cause.fail("Oh no!"), Cause.die("Boom!")) combined.reasons.map((reason) => reason._tag) // => ["Fail", "Die"] ``` ## 获取 Effect 的 Cause 要获取一个失败 effect 的 cause,请使用 `Effect.exit` 并检查 `Failure` 的 `cause` 字段。这让你可以检查或处理失败背后的确切原因。 **示例**(获取并检查失败的 Cause) ```ts import { Effect, Exit, Cause } from "effect" const program = Effect.gen(function* () { const exit = yield* Effect.exit(Effect.fail("Oh no!")) if (Exit.isFailure(exit)) { console.log(exit.cause) exit.cause // => Cause.fail("Oh no!") } }) await Effect.runPromise(program) ``` ## Guards 要判断 `Cause` 内部发生了什么,Cause 模块提供了两类 guard:cause 级谓词用于检查一个 `Cause` 是否包含某种原因,原因级 guard 用于收窄 `cause.reasons` 中的单个条目。 - `Cause.hasFails`:检查 cause 是否至少包含一个预期失败。 - `Cause.hasDies`:检查 cause 是否至少包含一个非预期 defect。 - `Cause.hasInterrupts`:检查 cause 是否至少包含一次 Fiber 中断。 - `Cause.hasInterruptsOnly`:检查 cause 中的每个原因是否都是中断。 - `Cause.isFailReason`:把 `Reason` 收窄为 `Fail`。 - `Cause.isDieReason`:把 `Reason` 收窄为 `Die`。 - `Cause.isInterruptReason`:把 `Reason` 收窄为 `Interrupt`。 空的 cause(没有任何错误)用 `cause.reasons.length === 0` 检查;并没有专门的 `isEmpty` 函数。 **示例**(使用 Guards 识别原因类型) ```ts import { Cause } from "effect" const cause = Cause.fail(new Error("my message")) for (const reason of cause.reasons) { if (Cause.isFailReason(reason)) { console.log(reason.error.message) reason.error.message // => "my message" } } ``` 这些 guard 让你能准确识别 `Cause` 背后的各种原因,从而更容易在代码中处理不同的错误情况。无论是应对预期失败、非预期 defect 还是中断,这些 guard 都提供了一种清晰的方法来评估和管理错误场景。 ## 格式化原因 若要根据具体的错误场景做出自定义响应,可以遍历 `cause.reasons` 并对每个原因的 `_tag` 做 switch。 **示例**(格式化 Cause 中的每个原因) ```ts import { Cause } from "effect" const cause = Cause.combine( Cause.fail(new Error("my fail message")), Cause.die("my die message"), ) const formatted = cause.reasons .map((reason) => { switch (reason._tag) { case "Fail": return `(error: ${reason.error.message})` case "Die": return `(defect: ${reason.defect})` case "Interrupt": return `(fiberId: ${reason.fiberId})` } }) .join(", ") formatted // => "(error: my fail message), (defect: my die message)" ``` ## 美化输出 清晰易读的错误信息是高效调试的关键。`Cause.pretty` 函数以结构化的方式格式化错误信息,让你更容易理解失败的细节。 **示例**(使用 `Cause.pretty` 获得易读的错误信息) ```ts import { Cause } from "effect" console.log(Cause.pretty(Cause.empty)) /* Output: (empty string) */ Cause.pretty(Cause.empty) // => "" console.log(Cause.pretty(Cause.fail(new Error("my fail message")))) /* Output: Error: my fail message ...stack trace... */ Cause.pretty(Cause.fail(new Error("my fail message"))).split("\n")[0] // => "Error: my fail message" console.log(Cause.pretty(Cause.die("my die message"))) /* Output: Error: my die message ...stack trace... */ Cause.pretty(Cause.die("my die message")).split("\n")[0] // => "Error: my die message" console.log(Cause.pretty(Cause.interrupt(1))) Cause.pretty(Cause.interrupt(1)) // => "InterruptError: All fibers interrupted without error {\n [cause]: InterruptCause: The fiber was interrupted by:\n at fiber (#1)\n}" console.log( Cause.pretty(Cause.combine(Cause.fail("fail1"), Cause.fail("fail2"))), ) /* Output: Error: fail1 ...stack trace... Error: fail2 ...stack trace... */ Cause.pretty(Cause.combine(Cause.fail("fail1"), Cause.fail("fail2"))) .split("\n") .filter((line) => line.startsWith("Error:")) // => ["Error: fail1", "Error: fail2"] ``` ## 提取失败与 Defect 用 `Cause.isFailReason` 和 `Cause.isDieReason` 过滤 `cause.reasons`,就能只检查发生的预期错误或非预期 defect。 **示例**(从 Cause 中提取失败与 Defect) ```ts import { Effect, Cause, Exit } from "effect" const program = Effect.gen(function* () { const exit = yield* Effect.exit( Effect.all([ Effect.fail("error 1"), Effect.die("defect"), Effect.fail("error 2"), ]), ) if (Exit.isFailure(exit)) { console.log( exit.cause.reasons .filter(Cause.isFailReason) .map((reason) => reason.error), ) exit.cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error) // => ["error 1"] console.log( exit.cause.reasons .filter(Cause.isDieReason) .map((reason) => reason.defect), ) exit.cause.reasons.filter(Cause.isDieReason).map((reason) => reason.defect) // => [] } }) await Effect.runPromise(program) ``` --- # Chunk > 了解 Chunk —— Effect 中高性能的不可变数据结构,提供拼接、切片与转换等高效操作。 `Chunk` 表示一个有序、不可变的值集合,其元素类型为 `A`。虽然它与数组类似,但 `Chunk` 提供了函数式的接口,并对某些用普通数组实现时开销很大的操作(例如反复拼接)做了优化。 ## 为什么使用 Chunk? - **不可变性**:普通 JavaScript 数组是可变的,而 `Chunk` 不同,它提供真正不可变的集合,防止数据在创建后被修改。这在并发编程场景中尤其有用,因为不可变性可以提升数据一致性。 - **高性能**:`Chunk` 为高效操作数组提供了专门的方法,例如追加单个元素或拼接多个 Chunk,使这些操作比普通 JavaScript 数组上的等价操作更快。 ## 创建 Chunk ### empty 使用 `Chunk.empty` 创建一个空的 `Chunk`。 **示例**(创建一个空 Chunk) ```ts import { Chunk } from "effect" // ┌─── Chunk // ▼ const chunk = Chunk.empty() Chunk.toReadonlyArray(chunk) // => [] ``` ### make 要创建包含特定值的 `Chunk`,请使用 `Chunk.make(...values)`。注意,得到的 chunk 在类型上被标记为非空。 **示例**(创建一个非空 Chunk) ```ts import { Chunk } from "effect" // ┌─── NonEmptyChunk // ▼ const chunk = Chunk.make(1, 2, 3) Chunk.toReadonlyArray(chunk) // => [1, 2, 3] ``` ### fromIterable 你可以通过提供一个集合来创建 `Chunk`,该集合既可以来自可迭代对象,也可以直接来自数组。 **示例**(从可迭代对象创建 Chunk) ```ts import { Chunk } from "effect" const fromArray = Chunk.fromIterable([1, 2, 3]) const fromSet = Chunk.fromIterable(new Set([1, 2, 3])) Chunk.toReadonlyArray(fromSet) // => [1, 2, 3] ``` ### unsafeFromArray `Chunk.fromArrayUnsafe` 直接从数组创建 `Chunk`,而不进行克隆。这种方式通过避免复制数据的开销来提升性能,但需要谨慎使用,因为它绕过了通常的不可变性保证。 **示例**(直接从数组创建 Chunk) ```ts import { Chunk } from "effect" const chunk = Chunk.fromArrayUnsafe([1, 2, 3]) Chunk.toReadonlyArray(chunk) // => [1, 2, 3] ``` ## 拼接 要将两个 `Chunk` 实例合并为一个,请使用 `Chunk.appendAll`。 **示例**(将两个 Chunk 合并为一个) ```ts import { Chunk } from "effect" // Concatenate two chunks with different types of elements // // ┌─── NonEmptyChunk // ▼ const chunk = Chunk.appendAll(Chunk.make(1, 2), Chunk.make("a", "b")) console.log(chunk) /* Output: { _id: 'Chunk', values: [ 1, 2, 'a', 'b' ] } */ Chunk.toReadonlyArray(chunk) // => [1, 2, "a", "b"] ``` ## 丢弃 要从 `Chunk` 的开头移除元素,请使用 `Chunk.drop`,并指定要丢弃的元素数量。 **示例**(从开头丢弃元素) ```ts import { Chunk } from "effect" // Drops the first 2 elements from the Chunk const chunk = Chunk.drop(Chunk.make(1, 2, 3, 4), 2) Chunk.toReadonlyArray(chunk) // => [3, 4] ``` ## 比较 要检查两个 `Chunk` 实例是否相等,请使用 [`Equal.equals`](/docs/v4/trait/equal/)。该函数会逐个比较每个 `Chunk` 的内容,判断结构上是否相等。 **示例**(比较两个 Chunk) ```ts import { Chunk, Equal } from "effect" const chunk1 = Chunk.make(1, 2) const chunk2 = Chunk.make(1, 2, 3) console.log(Equal.equals(chunk1, chunk1)) Equal.equals(chunk1, chunk1) // => true console.log(Equal.equals(chunk1, chunk2)) Equal.equals(chunk1, chunk2) // => false console.log(Equal.equals(chunk1, Chunk.make(1, 2))) Equal.equals(chunk1, Chunk.make(1, 2)) // => true ``` ## 转换 使用 `Chunk.toReadonlyArray` 可以把 `Chunk` 转换为 `ReadonlyArray`。得到的类型会随 `Chunk` 内容的不同而变化,用以区分空 chunk、非空 chunk 以及一般 chunk。 **示例**(将 Chunk 转换为 ReadonlyArray) ```ts import { Chunk } from "effect" // ┌─── readonly [number, ...number[]] // ▼ const nonEmptyArray = Chunk.toReadonlyArray(Chunk.make(1, 2, 3)) // ┌─── readonly never[] // ▼ const emptyArray = Chunk.toReadonlyArray(Chunk.empty()) declare const chunk: Chunk.Chunk // ┌─── readonly number[] // ▼ const array = Chunk.toReadonlyArray(chunk) ``` --- # Data > 使用 Effect 的 Data 模块定义不可变数据结构、确保相等性,并无缝管理错误。 Data 模块简化了在 TypeScript 中创建和处理数据结构的过程。它提供了用于**定义数据类型**、确保对象之间的**相等性**,以及对数据进行**哈希**以实现高效比较的工具。 ## 值相等性 默认情况下,普通的 JavaScript 对象、数组、元组、`Map` 和 `Set` 都通过 `Equal.equals` 获得结构相等性。无需特殊的构造函数。完整说明请参见 [Equal](/docs/v4/trait/equal/)。 这意味着,只要两个普通值具有相同的结构和值,它们就被视为相等。 ### struct 在普通 JavaScript 中,只有当两个对象引用的是完全相同的实例时,它们才被视为相等。 **示例**(用普通 JavaScript 比较两个对象) ```ts const alice = { name: "Alice", age: 30 } // This comparison is false because they are different instances // @errors: 2839 console.log(alice === { name: "Alice", age: 30 }) // Output: false ``` 不过,`Equal.equals` 允许你根据结构和内容来比较同样的两个对象。 **示例**(检查普通对象的相等性) ```ts import { Equal } from "effect" // ┌─── { readonly name: string; readonly age: number; } // ▼ const alice = { name: "Alice", age: 30 } // Check if Alice is equal to a new object // with the same structure and values console.log(Equal.equals(alice, { name: "Alice", age: 30 })) Equal.equals(alice, { name: "Alice", age: 30 }) // => true ``` `Equal.equals` 执行的比较是**深度**比较:嵌套对象会被递归比较,无需额外的工作。 **示例**(嵌套对象的深度比较) ```ts import { Equal } from "effect" const nested = { name: "Alice", nested_field: { value: 42 } } // Nested objects are compared recursively, so this is true console.log( Equal.equals(nested, { name: "Alice", nested_field: { value: 42 } }), ) Equal.equals(nested, { name: "Alice", nested_field: { value: 42 } }) // => true ``` 正如你所预期的,嵌套值的不同会使这两个对象不相等。 **示例**(嵌套对象的值不同) ```ts import { Equal } from "effect" const nested = { name: "Alice", nested_field: { value: 42 } } console.log( Equal.equals(nested, { name: "Alice", nested_field: { value: 43 } }), ) Equal.equals(nested, { name: "Alice", nested_field: { value: 43 } }) // => false ``` ### tuple 用作元组的普通数组也会按结构进行比较。 **示例**(检查元组的相等性) ```ts import { Equal } from "effect" // ┌─── readonly [string, number] // ▼ const alice = ["Alice", 30] as const // Check if Alice is equal to a new tuple // with the same structure and values console.log(Equal.equals(alice, ["Alice", 30])) Equal.equals(alice, ["Alice", 30]) // => true ``` ### array 普通数组同样支持结构相等性。 **示例**(检查数组的相等性) ```ts import { Equal } from "effect" // ┌─── readonly number[] // ▼ const numbers = [1, 2, 3, 4, 5] // Check if the array is equal to a new array // with the same values console.log(Equal.equals(numbers, [1, 2, 3, 4, 5])) Equal.equals(numbers, [1, 2, 3, 4, 5]) // => true ``` ## 构造器 该模块引入了一个称为 "Case classes" 的概念,它在定义数据类型时自动完成各种必要的操作。 这些操作包括生成**构造函数**、处理**相等性**检查以及管理**哈希**。 Case classes 主要有两种定义方式: - 作为普通对象,在需要可复用构造函数时使用普通的工厂函数;相等性和哈希都是免费的 - 使用 `Class` 或 `TaggedClass` 定义为 TypeScript 类,此时你希望获得带有方法和自定义逻辑的、面向类的结构 ### 构造函数 一个返回对象字面量的普通工厂函数就能给你一个可复用的构造函数。由于普通对象默认具有结构相等性,因此相等性和哈希都不需要特殊的辅助工具。 **示例**(定义构造函数并检查相等性) 在这个示例中,一个普通的箭头函数为 `Person` 创建了构造函数。得到的实例是普通对象,因此它们已经支持相等性检查。你可以直接用 `Equal.equals` 比较它们。 ```ts import { Equal } from "effect" interface Person { readonly name: string } // Create a constructor for `Person` // // ┌─── (args: Person) => Person // ▼ const make = (args: Person): Person => ({ ...args }) const alice = make({ name: "Alice" }) console.log(Equal.equals(alice, make({ name: "Alice" }))) Equal.equals(alice, make({ name: "Alice" })) // => true console.log(Equal.equals(alice, make({ name: "John" }))) Equal.equals(alice, make({ name: "John" })) // => false ``` **示例**(定义并比较嵌套数据) 这个示例演示了嵌套数据结构,例如一个包含 `Address` 的 `Person` 类型。`Person` 和 `Address` 构造函数都返回普通对象,因此相等性检查开箱即用。 ```ts import { Equal } from "effect" interface Address { readonly street: string readonly city: string } // Create a constructor for `Address` const Address = (args: Address): Address => ({ ...args }) interface Person { readonly name: string readonly address: Address } // Create a constructor for `Person` const Person = (args: Person): Person => ({ ...args }) const alice = Person({ name: "Alice", address: Address({ street: "123 Main St", city: "Wonderland" }), }) const anotherAlice = Person({ name: "Alice", address: Address({ street: "123 Main St", city: "Wonderland" }), }) console.log(Equal.equals(alice, anotherAlice)) Equal.equals(alice, anotherAlice) // => true ``` 由于嵌套的普通对象默认也按结构进行比较,你甚至不需要单独的 `Address` 构造函数。内联的对象字面量同样可以。 **示例**(用普通对象字面量表示嵌套数据) ```ts import { Equal } from "effect" interface Person { readonly name: string readonly address: { readonly street: string readonly city: string } } // Create a constructor for `Person` const Person = (args: Person): Person => ({ ...args }) const alice = Person({ name: "Alice", address: { street: "123 Main St", city: "Wonderland" }, }) const anotherAlice = Person({ name: "Alice", address: { street: "123 Main St", city: "Wonderland" }, }) console.log(Equal.equals(alice, anotherAlice)) Equal.equals(alice, anotherAlice) // => true ``` **示例**(定义并比较递归数据) 这个示例演示了一个递归结构,它定义了一棵二叉树,其中每个节点都可以包含其他节点。 ```ts import { Equal } from "effect" interface BinaryTree { readonly value: T readonly left: BinaryTree | null readonly right: BinaryTree | null } // Create a constructor for `BinaryTree` const BinaryTree = (args: BinaryTree): BinaryTree => ({ ...args, }) const tree1 = BinaryTree({ value: 0, left: BinaryTree({ value: 1, left: null, right: null }), right: null, }) const tree2 = BinaryTree({ value: 0, left: BinaryTree({ value: 1, left: null, right: null }), right: null, }) console.log(Equal.equals(tree1, tree2)) Equal.equals(tree1, tree2) // => true ``` ### 带标签的构造函数 当你处理的数据类型包含标签字段时(例如在可辨识联合类型中),为每个实例手动定义标签会变得很重复。 **示例**(手动定义带标签的构造函数) 这里,我们创建了一个带 `_tag` 字段的 `Person` 类型。请注意,每个新实例都需要指定 `_tag`。 ```ts interface Person { readonly _tag: "Person" // the tag readonly name: string } const Person = (args: Person): Person => ({ ...args }) // Repeating `_tag: 'Person'` for each instance const alice = Person({ _tag: "Person", name: "Alice" }) const bob = Person({ _tag: "Person", name: "Bob" }) ``` 为了简化这一过程,可以编写一个自动添加标签的构造函数。它遵循 Effect 生态系统中将标签字段命名为 `"_tag"` 的约定。 **示例**(用构造函数简化标签) 这样你只需定义一次标签,实例的创建就变得更简单。 ```ts interface Person { readonly _tag: "Person" // the tag readonly name: string } const Person = (args: Omit): Person => ({ ...args, _tag: "Person", }) // The `_tag` field is automatically added const alice = Person({ name: "Alice" }) const bob = Person({ name: "Bob" }) console.log(alice) alice // => { name: "Alice", _tag: "Person" } ``` ### Class 如果你更喜欢使用类而不是普通对象,可以用 `Data.Class` 作为构造函数的替代方案。在你想要获得带有方法和自定义逻辑的、面向类的结构时,这种方式可能感觉更自然。 **示例**(用 Data.Class 定义面向类的结构) 下面演示如何用 `Data.Class` 定义一个 `Person` 类: ```ts import { Data, Equal } from "effect" // Define a Person class extending Data.Class class Person extends Data.Class<{ name: string }> {} // Create an instance of Person const alice = new Person({ name: "Alice" }) // Check for equality between two instances console.log(Equal.equals(alice, new Person({ name: "Alice" }))) Equal.equals(alice, new Person({ name: "Alice" })) // => true ``` 使用类的好处之一是,你可以轻松地添加自定义方法和 getter。这让你能够扩展数据类型的功能。 **示例**(为类添加自定义 getter) 在这个示例中,我们为 `Person` 类添加了一个 `upperName` getter,用于返回大写形式的姓名: ```ts import { Data } from "effect" // Extend Person class with a custom getter class Person extends Data.Class<{ name: string }> { get upperName() { return this.name.toUpperCase() } } // Create an instance and use the custom getter const alice = new Person({ name: "Alice" }) console.log(alice.upperName) alice.upperName // => "ALICE" ``` ### TaggedClass 如果你更偏好基于类(class)的方式,同时又想获得标签(tag)为可辨识联合带来的好处,`Data.TaggedClass` 是个有用的选择。它的用法与 `tagged` 类似,但专门为类定义量身打造。 **示例**(定义自带标签的类) 下面演示如何使用 `Data.TaggedClass` 定义 `Person` 类。注意,标签 `"Person"` 会被自动添加: ```ts import { Data, Equal } from "effect" // Define a tagged class Person with the _tag "Person" class Person extends Data.TaggedClass("Person")<{ name: string }> {} // Create an instance of Person const alice = new Person({ name: "Alice" }) console.log(alice) // Output: Person { name: 'Alice', _tag: 'Person' } alice._tag // => "Person" // Check equality between two instances console.log(Equal.equals(alice, new Person({ name: "Alice" }))) Equal.equals(alice, new Person({ name: "Alice" })) // => true ``` 使用带标签的类的一个好处是,可以轻松添加自定义方法和 getter,按需扩展类的功能。 **示例**(为带标签的类添加自定义 getter) 在这个例子中,我们为 `Person` 类添加了一个 `upperName` getter,它会返回大写形式的 name: ```ts import { Data } from "effect" // Extend the Person class with a custom getter class Person extends Data.TaggedClass("Person")<{ name: string }> { get upperName() { return this.name.toUpperCase() } } // Create an instance and use the custom getter const alice = new Person({ name: "Alice" }) console.log(alice.upperName) alice.upperName // => "ALICE" ``` ## 带标签 struct 的联合 要创建带标签 struct 的可辨识联合,可以使用 `Data.TaggedEnum` 和 `Data.taggedEnum`。这些工具让定义和操作普通对象的联合变得非常简单。 ### 定义 传给 `Data.TaggedEnum` 的类型必须是一个对象,其中的键代表标签,值则定义对应数据类型的结构。 **示例**(定义带标签联合并检查相等性) ```ts import { Data, Equal } from "effect" // Define a union type using TaggedEnum type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { readonly data: string } Failure: { readonly reason: string } }> // Create constructors for each case in the union const { Loading, Success, Failure } = Data.taggedEnum() // Instantiate different states const state1 = Loading() const state2 = Success({ data: "test" }) const state3 = Success({ data: "test" }) const state4 = Failure({ reason: "not found" }) // Check equality between states console.log(Equal.equals(state2, state3)) Equal.equals(state2, state3) // => true console.log(Equal.equals(state2, state4)) Equal.equals(state2, state4) // => false // Display the states console.log(state1) state1 // => { _tag: "Loading" } console.log(state2) state2 // => { data: "test", _tag: "Success" } console.log(state4) state4 // => { reason: "not found", _tag: "Failure" } ``` ### $is and $match `Data.taggedEnum` 提供了 `$is` 和 `$match` 函数,方便进行类型守卫和模式匹配。 **示例**(使用类型守卫与模式匹配) ```ts import { Data } from "effect" type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { readonly data: string } Failure: { readonly reason: string } }> const { $is, $match, Loading, Success } = Data.taggedEnum() // Use `$is` to create a type guard for "Loading" const isLoading = $is("Loading") console.log(isLoading(Loading())) isLoading(Loading()) // => true console.log(isLoading(Success({ data: "test" }))) isLoading(Success({ data: "test" })) // => false // Use `$match` for pattern matching const matcher = $match({ Loading: () => "this is a Loading", Success: ({ data }) => `this is a Success: ${data}`, Failure: ({ reason }) => `this is a Failure: ${reason}`, }) console.log(matcher(Success({ data: "test" }))) matcher(Success({ data: "test" })) // => "this is a Success: test" ``` ### 添加泛型 使用 `TaggedEnum.WithGenerics` 可以创建更灵活、更可复用的带标签联合。这种方式让你能够定义可以动态处理不同数据类型的带标签联合。 **示例**(在 TaggedEnum 中使用泛型) ```ts import { Data } from "effect" // Define a generic TaggedEnum for RemoteData type RemoteData = Data.TaggedEnum<{ Loading: {} Success: { data: Success } Failure: { reason: Failure } }> // Extend TaggedEnum.WithGenerics to add generics interface RemoteDataDefinition extends Data.TaggedEnum.WithGenerics<2> { readonly taggedEnum: RemoteData } // Create constructors for the generic RemoteData const { Loading, Failure, Success } = Data.taggedEnum() // Instantiate each case with specific types const loading = Loading() const failure = Failure({ reason: "not found" }) const success = Success({ data: 1 }) success.data // => 1 ``` ## 错误 在 Effect 中,使用专门的构造函数可以简化错误处理: - `Error` - `TaggedError` 这些构造函数让定义自定义错误类型变得简单直接,同时还提供了诸如相等性检查和结构化错误处理之类的有用集成。 ### Error `Data.Error` 让你可以创建一种 `Error` 类型,在常规的 `message` 属性之外还能包含额外字段。 **示例**(创建带额外字段的自定义错误) ```ts import { Data } from "effect" // Define a custom error with additional fields class NotFound extends Data.Error<{ message: string; file: string }> {} // Create an instance of the custom error const err = new NotFound({ message: "Cannot find this file", file: "foo.txt", }) console.log(err instanceof Error) err instanceof Error // => true console.log(err.file) err.file // => "foo.txt" console.log(err) err.message // => "Cannot find this file" ``` 你可以直接在 [Effect.gen](/docs/v4/getting-started/using-generators/) 中 yield 一个 `NotFound` 实例,而无需使用 `Effect.fail`。 **示例**(在 `Effect.gen` 中 yield 自定义错误) ```ts import { Data, Effect } from "effect" class NotFound extends Data.Error<{ message: string; file: string }> {} const program = Effect.gen(function* () { yield* new NotFound({ message: "Cannot find this file", file: "foo.txt", }) }) Effect.runPromise(program) /* throws: NotFound [Error]: Cannot find this file ...stack trace... { file: 'foo.txt' } */ ``` ### TaggedError Effect 提供了 `TaggedError` API,用于自动为自定义错误添加 `_tag` 字段。配合 [Effect.catchTag](/docs/v4/error-management/expected-errors/#catchtag) 或 [Effect.catchTags](/docs/v4/error-management/expected-errors/#catchtags) 这类 API,错误处理会变得更简单。 ```ts import { Data, Effect, Console } from "effect" // Define a custom tagged error class NotFound extends Data.TaggedError("NotFound")<{ message: string file: string }> {} const program = Effect.gen(function* () { return yield* new NotFound({ message: "Cannot find this file", file: "foo.txt", }) }).pipe( // Catch and handle the tagged error Effect.catchTag("NotFound", (err) => Console.error(`${err.message} (${err.file})`), ), ) await Effect.runPromise(program) // => undefined // Output: Cannot find this file (foo.txt) ``` ### 原生 cause 支持 使用 `Data.Error` 或 `Data.TaggedError` 创建的错误可以包含 `cause` 属性,与 JavaScript `Error` 的原生 `cause` 功能集成,从而实现更详细的错误追踪。 **示例**(使用 `cause` 属性) ```ts import { Data, Effect } from "effect" // Define an error with a cause property class MyError extends Data.Error<{ cause: Error }> {} const program = Effect.gen(function* () { yield* new MyError({ cause: new Error("Something went wrong"), }) }) Effect.runPromise(program) /* throws: MyError ...stack trace... { [cause]: Error: Something went wrong ...stack trace... } */ ``` --- # DateTime > 使用 Effect 的 DateTime 处理精确的时间点,支持创建、比较和算术运算,从而高效地处理时间。 在 JavaScript 中处理日期和时间可能颇费周折。内置的 `Date` 对象会修改自身的内部状态,时区处理也可能令人困惑。这些设计选择会在开发依赖日期时间准确性的应用时引入错误,例如调度系统、时间戳服务或日志工具。 DateTime 模块旨在通过提供以下特性来解决这些局限: - **不可变数据**:每个 `DateTime` 都是不可变结构,可减少与就地修改相关的错误。 - **时区支持**:`DateTime` 为时区提供了完善的支持,包括自动处理夏令时调整。 - **算术运算**:你可以对 `DateTime` 实例执行算术运算,例如加上或减去一个时长(duration)。 ## DateTime 类型 `DateTime` 表示时间中的一个时刻。它既可以存储为简单的 UTC 值,也可以存储为带有关联时区的值。以这种方式存储时间,有助于你同时管理精确的时间戳,以及该时间应如何显示或解释的上下文。 `DateTime` 有两种主要变体: 1. **Utc**:一种不可变结构,使用 `epochMilliseconds`(自 Unix 纪元以来的毫秒数)表示协调世界时(UTC)中的一个时间点。 2. **Zoned**:包含 `epochMilliseconds` 以及一个 `TimeZone`,让你可以为时间戳附加偏移量或命名区域(如 "America/New_York")。 ### 为什么有两种变体? - 如果你只需要一个通用参照,而不依赖本地时区,**Utc** 就很直接。 - 当你需要跟踪时区信息时,**Zoned** 会很有帮助,例如转换为本地时间或针对夏令时进行调整。 ### TimeZone 变体 `TimeZone` 可以是以下两种之一: - **Offset**:表示相对 UTC 的固定偏移量(例如 UTC+2 或 UTC-5)。 - **Named**:使用命名区域(如 "Europe/London" 或 "America/New_York"),它会自动考虑特定区域的规则,例如夏令时变更。 ### TypeScript 定义 下面是 `DateTime` 类型的 TypeScript 定义: ```ts type DateTime = Utc | Zoned interface Utc { readonly _tag: "Utc" readonly epochMilliseconds: number } interface Zoned { readonly _tag: "Zoned" readonly epochMilliseconds: number readonly zone: TimeZone } type TimeZone = TimeZone.Offset | TimeZone.Named declare namespace TimeZone { interface Offset { readonly _tag: "Offset" readonly offset: number } interface Named { readonly _tag: "Named" readonly id: string } } ``` ## DateTime.Parts 类型 `DateTime.Parts` 类型定义了日期的主要组成部分,例如年、月、日、小时、分钟和秒。 ```ts namespace DateTime { interface Parts { readonly millisecond: number readonly second: number readonly minute: number readonly hour: number readonly day: number readonly month: number readonly year: number } interface PartsWithWeekday extends Parts { readonly weekDay: number } } ``` ## DateTime.Input 类型 `DateTime.Input` 是一种灵活的输入类型,可用于创建 `DateTime` 实例。它可以是以下之一: - 一个 `DateTime` 实例 - 一个 JavaScript `Date` 对象 - 一个表示自 Unix 纪元以来毫秒数的数值 - 一个包含部分日期 [parts](#the-datetimeparts-type) 的对象(例如 `{ year: 2024, month: 1, day: 1 }`) - 一个可由 JavaScript 的 [Date.parse](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse) 解析的字符串 ```ts namespace DateTime { type Input = DateTime | Partial | Date | number | string } ``` ## Utc 构造器 `Utc` 是一种不可变结构,使用 `epochMilliseconds`(自 Unix 纪元以来的毫秒数)表示协调世界时(UTC)中的一个时间点。 ### unsafeFromDate 从 JavaScript `Date` 创建一个 `Utc`。 如果提供的 `Date` 无效,则抛出 `IllegalArgumentError`。 传入 `Date` 对象时,它会被转换为 `Utc` 实例。该时间会被解释为执行代码的系统的本地时间,然后再调整为 UTC。这样可以确保日期和时间具有一致的、与时区无关的表示。 **示例**(把意大利的本地时间转换为 UTC) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime } from "effect" // Create a Utc instance from a local JavaScript Date // // ┌─── Utc // ▼ const utc = DateTime.fromDateUnsafe(new Date("2025-01-01 04:00:00")) console.log(utc) utc // => DateTime.makeUnsafe(1735700400000) console.log(utc.epochMilliseconds) utc.epochMilliseconds // => 1735700400000 ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月份为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 - `epochMilliseconds` 以自 Unix 纪元以来的毫秒数提供同一时间,确保 UTC 时间戳具有精确的数值表示。 ### unsafeMake 从 [DateTime.Input](#the-datetimeinput-type) 创建一个 `Utc`。 **示例**(使用 unsafeMake 创建 DateTime) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime } from "effect" // From a JavaScript Date const utc1 = DateTime.makeUnsafe(new Date("2025-01-01 04:00:00")) console.log(utc1) utc1 // => DateTime.makeUnsafe(1735700400000) // From partial date parts const utc2 = DateTime.makeUnsafe({ year: 2025 }) console.log(utc2) utc2 // => DateTime.makeUnsafe(1735689600000) // From a string const utc3 = DateTime.makeUnsafe("2025-01-01") console.log(utc3) utc3 // => DateTime.makeUnsafe(1735689600000) ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月份为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 ### make 与 [unsafeMake](#unsafemake) 类似,但会在输入无效时返回一个 [Option](/docs/v4/data-types/option/),而不是抛出错误。 如果输入无效,它返回 `None`;如果有效,则返回包含 `Utc` 的 `Some`。 **示例**(安全地创建 DateTime) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime, Option } from "effect" // From a JavaScript Date const maybeUtc1 = DateTime.make(new Date("2025-01-01 04:00:00")) console.log(maybeUtc1) maybeUtc1 // => Option.some(DateTime.makeUnsafe(1735700400000)) // From partial date parts const maybeUtc2 = DateTime.make({ year: 2025 }) console.log(maybeUtc2) maybeUtc2 // => Option.some(DateTime.makeUnsafe(1735689600000)) // From a string const maybeUtc3 = DateTime.make("2025-01-01") console.log(maybeUtc3) maybeUtc3 // => Option.some(DateTime.makeUnsafe(1735689600000)) ``` **解释**: - 本地时间 **2025-01-01 04:00:00**(意大利,CET)通过减去时区偏移量(1 月份为 UTC+1)转换为 **UTC**。 - 因此,UTC 时间变为 **2025-01-01 03:00:00.000Z**。 ## Zoned 构造器 `Zoned` 包含 `epochMilliseconds` 以及一个 `TimeZone`,让你可以为时间戳附加偏移量或命名区域(如 "America/New_York")。 ### unsafeMakeZoned 通过把 [DateTime.Input](#the-datetimeinput-type) 与可选的 `TimeZone` 组合来创建 `Zoned`。 这让你可以表示一个带有相关联时区的特定时间点。 时区可以通过几种方式提供: - 作为一个 `TimeZone` 对象 - 一个字符串标识符(例如 `"Europe/London"`) - 一个以毫秒为单位的数值偏移量 如果输入或时区无效,则会抛出 `IllegalArgumentError`。 **示例**(创建未指定时区的 Zoned DateTime) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime } from "effect" // Create a Zoned DateTime based on the system's local time zone const zoned = DateTime.makeZonedUnsafe(new Date("2025-01-01 04:00:00")) console.log(zoned) zoned // => DateTime.makeZonedUnsafe(1735700400000, { timeZone: 3600000 }) console.log(zoned.zone) zoned.zone // => DateTime.zoneMakeOffset(3600000) ``` 这里使用系统的时区(CET,1 月份为 UTC+1)来创建 `Zoned` 实例。 **示例**(指定命名时区) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime } from "effect" // Create a Zoned DateTime with a specified named time zone const zoned = DateTime.makeZonedUnsafe(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", }) console.log(zoned) zoned // => DateTime.makeZonedUnsafe(1735700400000, { timeZone: "Europe/Rome" }) console.log(zoned.zone) zoned.zone // => DateTime.zoneMakeNamedUnsafe("Europe/Rome") ``` 在这个例子中,显式提供了 `"Europe/Rome"` 时区,因此 `Zoned` 实例会绑定到这个命名时区。 默认情况下,输入日期会被当作 UTC 值,然后针对指定的时区进行调整。若要把输入日期解释为处于指定时区中,可以使用 `adjustForTimeZone` 选项。 **示例**(按指定时区解释输入日期) 下面的示例假设代码运行在位于意大利的系统上(CET 时区): ```ts import { DateTime } from "effect" // Interpret the input date as being in the specified time zone const zoned = DateTime.makeZonedUnsafe(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", adjustForTimeZone: true, }) console.log(zoned) zoned // => DateTime.makeZonedUnsafe(1735696800000, { timeZone: "Europe/Rome" }) console.log(zoned.zone) zoned.zone // => DateTime.zoneMakeNamedUnsafe("Europe/Rome") ``` **解释** - **不使用 `adjustForTimeZone`**:输入日期被解释为 UTC,然后调整为指定时区。例如,UTC 中的 `2025-01-01 04:00:00` 在 CET(UTC+1)中变为 `2025-01-01T04:00:00.000+01:00`。 - **使用 `adjustForTimeZone: true`**:输入日期被解释为处于指定时区中。例如,"Europe/Rome"(CET)中的 `2025-01-01 04:00:00` 会被调整为其对应的 UTC 时间,结果为 `2025-01-01T03:00:00.000+01:00`。 ### makeZoned `makeZoned` 函数的工作方式与 [unsafeMakeZoned](#unsafemakezoned) 类似,但提供了更安全的方式。当输入无效时,它不会抛出错误,而是返回一个 `Option`。 如果输入无效,它返回 `None`;如果有效,则返回包含 `Zoned` 的 `Some`。 **示例**(安全地创建 Zoned DateTime) ```ts import { DateTime, Option } from "effect" // ┌─── Option // ▼ const zoned = DateTime.makeZoned(new Date("2025-01-01 04:00:00"), { timeZone: "Europe/Rome", }) if (Option.isSome(zoned)) { console.log("The DateTime is valid") } Option.isSome(zoned) // => true ``` ### makeZonedFromString 通过解析格式为 `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[IANA timezone identifier]` 的字符串来创建 `Zoned`。 如果输入字符串有效,函数返回包含 `Zoned` 的 `Some`;如果输入无效,则返回 `None`。 **示例**(从字符串解析 Zoned DateTime) ```ts import { DateTime, Option } from "effect" // ┌─── Option // ▼ const zoned = DateTime.makeZonedFromString( "2025-01-01T03:00:00.000+01:00[Europe/Rome]", ) if (Option.isSome(zoned)) { console.log("The DateTime is valid") } Option.isSome(zoned) // => true ``` ## 当前时间 ### now 通过 [Clock](/docs/v4/requirements-management/default-services/) 服务,以 `Effect` 的形式提供当前 UTC 时间。 **示例**(获取当前 UTC 时间) ```ts import { DateTime, Effect } from "effect" const program = Effect.gen(function* () { // ┌─── Utc // ▼ const currentTime = yield* DateTime.now return DateTime.isUtc(currentTime) }) await Effect.runPromise(program) // => true ``` ### unsafeNow 使用 `Date.now()` 立即获取当前 UTC 时间,不经过 [Clock](/docs/v4/requirements-management/default-services/) 服务。 **示例**(立即获取当前 UTC 时间) ```ts import { DateTime } from "effect" // ┌─── Utc // ▼ const currentTime = DateTime.nowUnsafe() DateTime.isUtc(currentTime) // => true ``` ## 类型守卫 | 函数 | 说明 | | ------------------ | --------------------------------------------- | | `isDateTime` | 检查一个值是否为 `DateTime`。 | | `isTimeZone` | 检查一个值是否为 `TimeZone`。 | | `isTimeZoneOffset` | 检查一个值是否为 `TimeZone.Offset`。 | | `isTimeZoneNamed` | 检查一个值是否为 `TimeZone.Named`。 | | `isUtc` | 检查一个 `DateTime` 是否为 `Utc` 变体。 | | `isZoned` | 检查一个 `DateTime` 是否为 `Zoned` 变体。 | **示例**(校验一个 DateTime) ```ts import { DateTime } from "effect" function printDateTimeInfo(x: unknown) { if (DateTime.isDateTime(x)) { console.log("This is a valid DateTime") } else { console.log("Not a DateTime") } } DateTime.isDateTime(DateTime.nowUnsafe()) // => true DateTime.isDateTime("not a date") // => false ``` ## 时区管理 | 函数 | 说明 | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `setZone` | 通过应用给定的 `TimeZone`,从 `DateTime` 创建 `Zoned`。 | | `setZoneOffset` | 使用固定偏移量(毫秒),从 `DateTime` 创建 `Zoned`。 | | `setZoneNamed` | 根据 IANA 时区标识符从 `DateTime` 创建 `Zoned`;若标识符无效则返回 `None`。 | | `setZoneNamedUnsafe` | 根据 IANA 时区标识符从 `DateTime` 创建 `Zoned`;若标识符无效则抛出异常。 | | `zoneMakeNamedUnsafe` | 根据 IANA 时区标识符创建 `TimeZone.Named`;若标识符无效则抛出异常。 | | `zoneMakeNamed` | 根据 IANA 时区标识符创建 `TimeZone.Named`;若标识符无效则返回 `None`。 | | `zoneMakeNamedEffect` | 根据 IANA 时区标识符创建 `Effect`;若标识符无效则以 `IllegalArgumentError` 失败 | | `zoneMakeOffset` | 根据以毫秒为单位的数值偏移量创建 `TimeZone.Offset`。 | | `zoneMakeLocal` | 根据系统的本地时区创建 `TimeZone.Named`。 | | `zoneFromString` | 尝试从字符串解析时区;若无效则返回 `None`。 | | `zoneToString` | 返回 `TimeZone` 的字符串表示形式。 | **示例**(把时区应用到 DateTime) ```ts import { DateTime } from "effect" // Create a UTC DateTime // // ┌─── Utc // ▼ const utc = DateTime.makeUnsafe("2024-01-01") // Create a named time zone for New York // // ┌─── TimeZone.Named // ▼ const zoneNY = DateTime.zoneMakeNamedUnsafe("America/New_York") // Apply it to the DateTime // // ┌─── Zoned // ▼ const zoned = DateTime.setZone(utc, zoneNY) console.log(zoned) zoned // => DateTime.makeZonedUnsafe(1704067200000, { timeZone: "America/New_York" }) ``` ### zoneFromString 解析字符串以创建 `DateTime.TimeZone`。 该函数会尝试把输入的字符串解释为以下两种形式之一: - 数值形式的时区偏移量(例如 "GMT"、"+01:00") - IANA 时区标识符(例如 "Europe/London") 如果字符串匹配偏移量格式,就会转换为 `TimeZone.Offset`。 否则,它会尝试用该输入创建一个 `TimeZone.Named`。 如果输入的字符串无效,则返回 `Option.none()`。 **示例**(从字符串解析时区) ```ts import { DateTime, Option } from "effect" // Attempt to parse a numeric offset const offsetZone = DateTime.zoneFromString("+01:00") console.log(Option.isSome(offsetZone)) Option.isSome(offsetZone) // => true // Attempt to parse an IANA time zone const namedZone = DateTime.zoneFromString("Europe/London") console.log(Option.isSome(namedZone)) Option.isSome(namedZone) // => true // Invalid input const invalidZone = DateTime.zoneFromString("Invalid/Zone") console.log(Option.isSome(invalidZone)) Option.isSome(invalidZone) // => false ``` ## 比较 | 函数 | 说明 | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `distance` | 返回两个 `DateTime` 之间的差值,以带符号的 `Duration` 表示(若 `other` 早于 `self` 则为负)。 | | `min` | 返回两个 `DateTime` 值中较早的那个。 | | `max` | 返回两个 `DateTime` 值中较晚的那个。 | | `isGreaterThan`、`isGreaterThanOrEqualTo` 等 | 检查两个 `DateTime` 值之间的先后顺序。 | | `between` | 检查某个 `DateTime` 是否落在给定的边界范围内。 | | `isFuture`、`isPast`、`isFutureUnsafe` 等 | 检查某个 `DateTime` 位于未来还是过去。 | **示例**(求两个 DateTime 之间的距离) ```ts import { DateTime, Duration } from "effect" const utc1 = DateTime.makeUnsafe("2025-01-01T00:00:00Z") const utc2 = DateTime.add(utc1, { days: 1 }) // `distance` returns a signed Duration directly (one day) console.log(DateTime.distance(utc1, utc2)) DateTime.distance(utc1, utc2) // => Duration.millis(86400000) ``` ## 转换 | 函数 | 说明 | | ---------------- | ------------------------------------------------------------------------ | | `toDateUtc` | 返回 UTC 下的 JavaScript `Date`。 | | `toDate` | 应用时区(如果存在),并转换为 JavaScript `Date`。 | | `zonedOffset` | 对于 `Zoned` 类型的 DateTime,返回以毫秒为单位的时区偏移量。 | | `zonedOffsetIso` | 对于 `Zoned` 类型的 DateTime,返回形如 "+01:00" 的 ISO 偏移量字符串。 | | `toEpochMillis` | 返回以毫秒为单位的 Unix 纪元时间。 | | `removeTime` | 返回一个清除了时间部分的 `Utc`(只保留日期)。 | ## 日期各部分 | 函数 | 说明 | | -------------------------- | ----------------------------------------------------------------------- | | `toParts` | 返回按调整时区后的日期各部分(包括星期几)。 | | `toPartsUtc` | 返回 UTC 下的日期各部分(包括星期几)。 | | `getPart` / `getPartUtc` | 从日期中取出指定的部分(例如 `"year"` 或 `"month"`)。 | | `setParts` / `setPartsUtc` | 更新日期的某些部分,同时保留或忽略时区。 | **示例**(从 DateTime 中提取各部分) ```ts import { DateTime } from "effect" const zoned = DateTime.setZone( DateTime.makeUnsafe("2024-01-01"), DateTime.zoneMakeNamedUnsafe("Europe/Rome"), ) console.log(DateTime.getPart(zoned, "month")) DateTime.getPart(zoned, "month") // => 1 ``` ## 运算 | 函数 | 说明 | | ------------------ | ------------------------------------------------------------------------------------------ | | `addDuration` | 把给定的 `Duration` 加到 `DateTime` 上。 | | `subtractDuration` | 从 `DateTime` 中减去给定的 `Duration`。 | | `add` | 把数值形式的各部分(例如 `{ hours: 2 }`)加到 `DateTime` 上。 | | `subtract` | 减去数值形式的各部分。 | | `startOf` | 把 `DateTime` 移动到给定单位的起点(例如一天的开始或一个月的开始)。 | | `endOf` | 把 `DateTime` 移动到给定单位的终点。 | | `nearest` | 把 `DateTime` 舍入到最近的指定单位。 | ## 格式化 | 函数 | 说明 | | ------------------ | --------------------------------------------------------------------- | | `format` | 使用 `DateTimeFormat` API 把 `DateTime` 格式化为字符串。 | | `formatLocal` | 使用系统本地时区和区域设置进行格式化。 | | `formatUtc` | 强制按 UTC 格式化。 | | `formatIntl` | 使用传入的 `Intl.DateTimeFormat`。 | | `formatIso` | 返回 UTC 下的 ISO 8601 字符串。 | | `formatIsoDate` | 返回经过时区调整的 ISO 日期字符串。 | | `formatIsoDateUtc` | 返回 UTC 下的 ISO 日期字符串。 | | `formatIsoOffset` | 把 `Zoned` 格式化为带偏移量(形如 "+01:00")的字符串。 | | `formatIsoZoned` | 按 `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[Zone]` 的形式格式化 `Zoned`。 | ## 用于当前时区的 Layer | 函数 | 说明 | | ------------------------ | -------------------------------------------------------------------- | | `CurrentTimeZone` | 当前时区对应的服务键。 | | `setZoneCurrent` | 让某个 `DateTime` 使用当前时区。 | | `withCurrentZone` | 为某个 effect 提供指定的时区。 | | `withCurrentZoneLocal` | 为该 effect 使用系统本地时区。 | | `withCurrentZoneOffset` | 为该 effect 使用固定偏移量(毫秒)。 | | `withCurrentZoneNamed` | 使用具名时区标识符(例如 "Europe/London")。 | | `nowInCurrentZone` | 以配置的时区获取当前时间,结果为 `Zoned`。 | | `layerCurrentZone` | 创建一个提供 `CurrentTimeZone` 服务的 Layer。 | | `layerCurrentZoneOffset` | 根据固定偏移量创建 Layer。 | | `layerCurrentZoneNamed` | 根据具名时区创建 Layer,若无效则失败。 | | `layerCurrentZoneLocal` | 根据系统本地时区创建 Layer。 | **示例**(在 Effect 中使用当前时区) ```ts import { DateTime, Effect } from "effect" // Retrieve the current time in the "Europe/London" time zone const program = Effect.gen(function* () { const zonedNow = yield* DateTime.nowInCurrentZone console.log(zonedNow) return zonedNow }).pipe(DateTime.withCurrentZoneNamed("Europe/London")) const zonedNow = await Effect.runPromise(program) /* Example Output: DateTime.Zoned(2025-01-06T18:36:38.573+00:00[Europe/London]) */ zonedNow.zone // => DateTime.zoneMakeNamedUnsafe("Europe/London") ``` --- # Duration > 使用 Effect 的 Duration 处理精确的时间跨度,支持创建、比较与算术运算,从而高效地处理时间。 `Duration` 数据类型表示非负的时间跨度。它常用于超时、延迟和调度,并提供针对不同时间单位的操作。 ## 创建 Duration Duration 模块提供了若干构造函数,用于以不同单位创建时长。 **示例**(以各种单位创建 Duration) ```ts import { Duration } from "effect" // Create a duration of 100 milliseconds const duration1 = Duration.millis(100) // Create a duration of 2 seconds const duration2 = Duration.seconds(2) // Create a duration of 5 minutes const duration3 = Duration.minutes(5) Duration.toMillis(duration3) // => 300000 ``` 你可以使用纳秒(nanoseconds)、微秒(microsecond)、毫秒(milliseconds)、秒(seconds)、分钟(minutes)、小时(hours)、天(days)和周(weeks)等单位创建 Duration。 若要表示无限时长,请使用 `Duration.infinity`。 **示例**(创建无限时长) ```ts import { Duration } from "effect" console.log(String(Duration.infinity)) String(Duration.infinity) // => "Infinity" ``` 创建时长的另一种方式是使用 `Duration.fromInputUnsafe` 辅助函数: - `number` 值会被视为毫秒。 - `bigint` 值会被视为纳秒。 - 字符串必须遵循 `"${number} ${unit}"` 格式。 **示例**(将值解码为 Duration) ```ts import { Duration } from "effect" Duration.fromInputUnsafe(10n) // => Duration.nanos(10n) Duration.fromInputUnsafe(100) // => Duration.millis(100) Duration.fromInputUnsafe(Infinity) // => Duration.infinity Duration.fromInputUnsafe("10 nanos") // => Duration.nanos(10n) Duration.fromInputUnsafe("20 micros") // => Duration.micros(20n) Duration.fromInputUnsafe("100 millis") // => Duration.millis(100) Duration.fromInputUnsafe("2 seconds") // => Duration.seconds(2) Duration.fromInputUnsafe("5 minutes") // => Duration.minutes(5) Duration.fromInputUnsafe("7 hours") // => Duration.hours(7) Duration.fromInputUnsafe("3 weeks") // => Duration.weeks(3) ``` ## 获取 Duration 的值 你可以使用 `Duration.toMillis` 获取以毫秒表示的时长值。 **示例**(以毫秒获取 Duration) ```ts import { Duration } from "effect" console.log(Duration.toMillis(Duration.seconds(30))) Duration.toMillis(Duration.seconds(30)) // => 30000 ``` 若要获取以纳秒表示的时长值,请使用 `Duration.toNanos`。注意 `toNanos` 返回 `Option`,因为时长可能是无限的。 **示例**(以纳秒获取 Duration) ```ts import { Duration, Option } from "effect" console.log(Duration.toNanos(Duration.millis(100))) Duration.toNanos(Duration.millis(100)) // => Option.some(100000000n) ``` 若想直接得到 `bigint` 而不经过 `Option`,请使用 `Duration.toNanosUnsafe`。不过,对于无限的时长,它会抛出错误。 **示例**(不安全地获取纳秒值) ```ts import { Duration } from "effect" console.log(Duration.toNanosUnsafe(Duration.millis(100))) // Output: 100000000n console.log(Duration.toNanosUnsafe(Duration.infinity)) /* throws: Error: Cannot convert infinite duration to nanos ...stack trace... */ ``` ## 比较 Duration 使用以下函数比较两个 Duration: | API | 说明 | | ------------------------ | ---------------------------------------------------------------------------- | | `isLessThan` | 如果第一个 Duration 小于第二个,则返回 `true`。 | | `isLessThanOrEqualTo` | 如果第一个 Duration 小于或等于第二个,则返回 `true`。 | | `isGreaterThan` | 如果第一个 Duration 大于第二个,则返回 `true`。 | | `isGreaterThanOrEqualTo` | 如果第一个 Duration 大于或等于第二个,则返回 `true`。 | **示例**(比较两个 Duration) ```ts import { Duration } from "effect" const duration1 = Duration.seconds(30) const duration2 = Duration.minutes(1) console.log(Duration.isLessThan(duration1, duration2)) Duration.isLessThan(duration1, duration2) // => true console.log(Duration.isLessThanOrEqualTo(duration1, duration2)) Duration.isLessThanOrEqualTo(duration1, duration2) // => true console.log(Duration.isGreaterThan(duration1, duration2)) Duration.isGreaterThan(duration1, duration2) // => false console.log(Duration.isGreaterThanOrEqualTo(duration1, duration2)) Duration.isGreaterThanOrEqualTo(duration1, duration2) // => false ``` ## 执行算术运算 你可以对 Duration 执行算术运算,例如加法和乘法。 **示例**(对 Duration 做加法与乘法) ```ts import { Duration } from "effect" const duration1 = Duration.seconds(30) const duration2 = Duration.minutes(1) // Add two durations console.log(String(Duration.sum(duration1, duration2))) String(Duration.sum(duration1, duration2)) // => "90000 millis" // Multiply a duration by a factor console.log(String(Duration.times(duration1, 2))) String(Duration.times(duration1, 2)) // => "60000 millis" ``` ## 转换 将 `Duration` 转换为人类可读的字符串。 **示例** ```ts import { Duration } from "effect" Duration.format(Duration.millis(1000)) // => "1s" Duration.format(Duration.millis(1001)) // => "1s 1ms" ``` --- # Exit > 用 Exit 表示 Effect 工作流的运行结果,捕获成功值或失败原因。 一个 `Exit` 描述了一次 `Effect` 工作流运行的结果。 `Exit` 有两种可能的状态: - `Exit.Success`:包含类型为 `A` 的成功值。 - `Exit.Failure`:包含类型为 `E` 的失败 [Cause](/docs/v4/data-types/cause/)。 ## 创建 Exit Exit 模块提供了两个用于构造 Exit 值的主要函数:`Exit.succeed` 和 `Exit.failCause`。 这两个函数分别以成功或失败的形式,表示一次 effectful 计算的结果。 ### succeed `Exit.succeed` 创建一个表示成功结果的 `Exit` 值。 当你想表明一次计算成功完成,并提供其计算结果时,就可以使用这个函数。 **示例**(创建一个成功的 Exit) ```ts import { Exit } from "effect" // Create an Exit representing a successful outcome with the value 42 // // ┌─── Exit // ▼ const successExit = Exit.succeed(42) console.log(successExit) successExit // => Exit.succeed(42) ``` ### failCause `Exit.failCause` 创建一个表示失败的 `Exit` 值。 失败通过一个 [Cause](/docs/v4/data-types/cause/) 对象来描述,该对象可以封装预期内的错误、defect、中断,甚至是复合错误。 **示例**(创建一个失败的 Exit) ```ts import { Exit, Cause } from "effect" // Create an Exit representing a failure with an error message // // ┌─── Exit // ▼ const failureExit = Exit.failCause(Cause.fail("Something went wrong")) console.log(failureExit) failureExit // => Exit.fail("Something went wrong") ``` ## 模式匹配 你可以使用 `Exit.match` 函数处理 `Exit` 的不同结果。 该函数允许你提供两个独立的回调,分别处理一次 `Effect` 执行的成功与失败情况。 **示例**(同时匹配成功与失败状态) ```ts import { Effect, Exit, Cause } from "effect" // ┌─── Exit // ▼ const simulatedSuccess = Effect.runSyncExit(Effect.succeed(1)) console.log( Exit.match(simulatedSuccess, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }), ) Exit.match(simulatedSuccess, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }) // => "Exited with success value: 1" // ┌─── Exit // ▼ const simulatedFailure = Effect.runSyncExit( Effect.failCause(Cause.fail("error")), ) console.log( Exit.match(simulatedFailure, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }), ) /* Output: Exited with failure state: Error: error ...stack trace... */ Exit.match(simulatedFailure, { onFailure: (cause) => `Exited with failure state: ${Cause.pretty(cause)}`, onSuccess: (value) => `Exited with success value: ${value}`, }).split("\n")[0] // => "Exited with failure state: Error: error" ``` ## Exit 与 Result 的对比 从概念上讲,`Exit` 可以看作 `Result>`。不过 [Cause](/docs/v4/data-types/cause/) 类型所表示的并不只是类型为 `E` 的预期错误,它还包括: - 中断原因 - Defect(非预期的错误) - 多个 Cause 的组合 这让 `Cause` 相比简单的 `Result` 能够捕获更丰富、更复杂的错误状态。 ## Exit 与 Effect 的对比 `Exit` 实际上是 `Effect` 的子类型。这意味着 `Exit` 值也可以被视为 `Effect` 值。 - 从本质上讲,一个 `Exit` 就是一次“常量计算”。 - `Effect.succeed` 本质上等同于 `Exit.succeed`。 - `Effect.failCause` 等同于 `Exit.failCause`。 --- # HashSet > 了解 HashSet 数据结构 —— 既有不可变版本,也有可变版本。 HashSet 表示一个由**唯一**值组成的**无序**集合,并支持高效的查找、插入与删除操作。 Effect 库为该结构提供了两个版本: - [HashSet](/docs/v4/data-types/hash-set/#hashset) —— 不可变版本 - [MutableHashSet](/docs/v4/data-types/hash-set/#mutablehashset) —— 可变版本 两个版本的平均操作复杂度都是常数级。主要区别在于它们如何处理变更:一个返回新的集合,另一个则直接修改原集合。 ### 为什么使用 HashSet? HashSet 解决的是这样一个问题:维护一个**值不重复的无序集合**,并提供快速的成员检查与值的添加/删除操作。 一些常见的使用场景包括: - 跟踪唯一元素(例如已完成某个操作的用户) - 高效地判断某个值是否属于集合 - 执行并集、交集、差集等集合运算 - 从集合中消除重复项 ### 何时用 HashSet 替代其他集合 在以下情况下,应选择 HashSet(任一版本)而不是其他集合: - 你需要确保元素唯一 - 你经常需要检查某个元素是否存在于集合中 - 你需要执行并集、交集、差集等集合运算 - 元素的顺序对你的用例并不重要 在以下情况下,应选择其他集合: - 你需要保持插入顺序(使用 `List` 或 `Array`) - 你需要键值关联(使用 `HashMap` 或 `MutableHashMap`) - 你需要频繁按下标访问元素(使用 `Array`) ### 在不可变与可变版本之间做选择 Effect 同时提供不可变和可变的版本,以支持不同的编码风格与性能需求。 **HashSet** 该版本从不修改原集合,而是为每次变更返回一个新集合。 特点: - 操作返回新实例,而不是修改原集合 - 保留之前的状态 - 设计上线程安全 - 适合函数式编程模式 - 适合在应用的不同部分之间共享 **MutableHashSet** 该版本允许直接更新:添加和删除值会就地修改集合。 特点: - 操作直接修改原集合 - 在增量构建集合时更高效 - 需要小心处理,以避免意外的副作用 - 在修改频繁的场景中性能更好 - 适合局部使用,即修改不会影响其他位置 ### 何时使用哪个版本 在以下情况下使用 **HashSet**: - 你需要可预测且无副作用的行为 - 你希望保留数据的之前状态 - 你要在应用的不同部分之间共享集合 - 你偏好函数式编程模式 - 你需要在并发环境中保证 Fiber 安全 在以下情况下使用 **MutableHashSet**: - 性能至关重要,且你需要避免创建新实例 - 你正在以大量添加/删除的方式增量构建集合 - 你在一个可以安全修改的受控作用域中工作 - 你需要在性能关键的代码中优化内存占用 ### 混合使用 你可以通过 `pipe` 串联不可变操作,对 `HashSet` 施加多次更新。这样就能一次性完成多项变更,而不会修改原集合。 **示例**(批量修改而不改动原集合) ```ts import { HashSet } from "effect" // Create an immutable HashSet const original = HashSet.make(1, 2, 3) // Apply several updates by chaining immutable operations const modified = original.pipe( HashSet.add(4), HashSet.add(5), HashSet.remove(1), ) console.log(Array.from(original)) Array.from(original) // => [1, 2, 3] console.log(Array.from(modified)) Array.from(modified) // => [2, 3, 4, 5] ``` ## 性能特征 `HashSet` 与 `MutableHashSet` 在核心操作上提供相近的平均时间复杂度: | 操作 | HashSet | MutableHashSet | 说明 | | -------------- | ------------ | -------------- | ------------------------------- | | 查找 | O(1) 平均 | O(1) 平均 | 检查某个值是否存在 | | 插入 | O(1) 平均 | O(1) 平均 | 添加一个值 | | 删除 | O(1) 平均 | O(1) 平均 | 删除一个值 | | 迭代 | O(n) | O(n) | 遍历所有值 | | 集合运算 | O(n) | O(n) | 并集、交集、差集 | 主要区别在于更新是如何处理的: - **HashSet** 每次变更都返回一个新集合。如果连续进行大量变更,这可能较慢。 - **MutableHashSet** 就地更新同一个集合。在进行大量变更时,这通常更快。 ## 相等性与唯一性 `HashSet` 与 `MutableHashSet` 都使用 Effect 的 [`Equal`](/docs/v4/trait/equal/) trait 来判断两个元素是否相同。这确保了每个值在集合中只出现一次。 - **原始值**(如数字或字符串)按值比较,类似于 `===` 运算符。 - **对象与自定义类型**必须实现 `Equal` 接口,以定义两个实例在什么意义上相等。如果没有提供实现,相等性判断会回退到引用比较。 **示例**(使用自定义的相等性与哈希) ```ts import { Equal, Hash, HashSet } from "effect" // Define a custom class that implements the Equal interface class Person implements Equal.Equal { constructor( readonly id: number, readonly name: string, readonly age: number, ) {} // Two Person instances are equal if their id, name, and age match [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Hash code is based on the id (must match the equality logic) [Hash.symbol](): number { return Hash.hash(this.id) } } // Add two different instances with the same content const set = HashSet.empty().pipe( HashSet.add(new Person(1, "Alice", 30)), HashSet.add(new Person(1, "Alice", 30)), ) // Only one instance is kept console.log(HashSet.size(set)) HashSet.size(set) // => 1 ``` ### 用普通对象和 Schema 简化相等性 普通对象会自动实现 [`Equal`](/docs/v4/trait/equal/),其依据是结构相等性,因此无需任何包装。用 `Schema` 解码出的值已经天然具备这一特性,如下所示。 **示例**(普通对象的结构相等性) ```ts import { Equal, HashSet, pipe } from "effect" // Define two records with the same content const person1 = { id: 1, name: "Alice", age: 30 } const person2 = { id: 1, name: "Alice", age: 30 } // They are different object references console.log(Object.is(person1, person2)) Object.is(person1, person2) // => false // But they are equal in value (based on content) console.log(Equal.equals(person1, person2)) Equal.equals(person1, person2) // => true // Add both to a HashSet; only one will be stored const set = pipe(HashSet.empty(), HashSet.add(person1), HashSet.add(person2)) console.log(HashSet.size(set)) HashSet.size(set) // => 1 ``` **示例**(用 `Schema.Struct` 解码) ```ts import { Equal, MutableHashSet, Schema } from "effect" // Define a schema that describes the structure of a Person // (no Data wrapper needed - decoded objects have structural // equality automatically) const PersonSchema = Schema.Struct({ id: Schema.Finite, name: Schema.String, age: Schema.Finite, }) // Decode values from plain objects const Person = Schema.decodeSync(PersonSchema) const person1 = Person({ id: 1, name: "Alice", age: 30 }) const person2 = Person({ id: 1, name: "Alice", age: 30 }) // person1 and person2 are different instances but equal in value console.log(Equal.equals(person1, person2)) Equal.equals(person1, person2) // => true // Add both to a MutableHashSet; only one will be stored const set = MutableHashSet.empty().pipe( MutableHashSet.add(person1), MutableHashSet.add(person2), ) console.log(MutableHashSet.size(set)) MutableHashSet.size(set) // => 1 ``` ## HashSet `HashSet` 是一个**不可变**、**无序**且值**唯一**的集合。 它保证每个值只出现一次,并支持查找、插入、删除等快速操作。 任何会修改集合的操作(例如添加或删除值)都会返回一个新的 `HashSet`,而原集合保持不变。 ### 操作 | 分类 | 操作 | 说明 | 时间复杂度 | | ------------ | -------------------------------------------------------- | ------------------------------------------- | --------------- | | 构造器 | [empty](https://effect.website/docs/v4/api/effect/HashSet#empty) | 创建一个空 HashSet | O(1) | | 构造器 | [fromIterable](https://effect.website/docs/v4/api/effect/HashSet#fromIterable) | 从可迭代对象创建 HashSet | O(n) | | 构造器 | [make](https://effect.website/docs/v4/api/effect/HashSet#make) | 从多个值创建 HashSet | O(n) | | 元素 | [has](https://effect.website/docs/v4/api/effect/HashSet#has) | 检查某个值是否存在于集合中 | O(1) 平均 | | 元素 | [some](https://effect.website/docs/v4/api/effect/HashSet#some) | 检查是否有任一元素满足谓词 | O(n) | | 元素 | [every](https://effect.website/docs/v4/api/effect/HashSet#every) | 检查是否所有元素都满足谓词 | O(n) | | 元素 | [isSubset](https://effect.website/docs/v4/api/effect/HashSet#isSubset) | 检查一个集合是否为另一个集合的子集 | O(n) | | 读取器 | [size](https://effect.website/docs/v4/api/effect/HashSet#size) | 获取元素数量 | O(1) | | 变更 | [add](https://effect.website/docs/v4/api/effect/HashSet#add) | 向集合中添加一个值 | O(1) 平均 | | 变更 | [remove](https://effect.website/docs/v4/api/effect/HashSet#remove) | 从集合中删除一个值 | O(1) 平均 | | 运算 | [difference](https://effect.website/docs/v4/api/effect/HashSet#difference) | 计算集合差集(A - B) | O(n) | | 运算 | [intersection](https://effect.website/docs/v4/api/effect/HashSet#intersection) | 计算集合交集(A ∩ B) | O(n) | | 运算 | [union](https://effect.website/docs/v4/api/effect/HashSet#union) | 计算集合并集(A ∪ B) | O(n) | | 映射 | [map](https://effect.website/docs/v4/api/effect/HashSet#map) | 转换每个元素 | O(n) | | 折叠 | [reduce](https://effect.website/docs/v4/api/effect/HashSet#reduce) | 将集合归约为单个值 | O(n) | | 过滤 | [filter](https://effect.website/docs/v4/api/effect/HashSet#filter) | 保留满足谓词的元素 | O(n) | `HashSet` 本身直接就是 `Iterable`,因此可以用 `Array.from(self)` 或 `self[Symbol.iterator]()` 来访问其中的值。若要做变换与遍历,请组合使用 `Iterable` 模块中的操作;若要拆分集合,请调用两次 `HashSet.filter`。 **示例**(基本的创建与操作) ```ts import { HashSet } from "effect" // Create an initial set with 3 values const set1 = HashSet.make(1, 2, 3) // Add a value (returns a new set) const set2 = HashSet.add(set1, 4) // The original set is unchanged console.log(Array.from(set1)) Array.from(set1) // => [1, 2, 3] console.log(Array.from(set2)) Array.from(set2) // => [1, 2, 3, 4] // Perform set operations with another set const set3 = HashSet.make(3, 4, 5) // Combine both sets const union = HashSet.union(set2, set3) console.log(Array.from(union)) Array.from(union) // => [1, 2, 3, 4, 5] // Shared values const intersection = HashSet.intersection(set2, set3) console.log(Array.from(intersection)) Array.from(intersection) // => [3, 4] // Values only in set2 const difference = HashSet.difference(set2, set3) console.log(Array.from(difference)) Array.from(difference) // => [1, 2] ``` **示例**(用 `pipe` 串联操作) ```ts import { HashSet, pipe } from "effect" const result = pipe( // Duplicates are ignored HashSet.make(1, 2, 2, 3, 4, 5, 5), // Keep even numbers HashSet.filter((n) => n % 2 === 0), // Double each value HashSet.map((n) => n * 2), // Convert to array Array.from, ) console.log(result) result // => [4, 8] ``` ## MutableHashSet `MutableHashSet` 是一个**可变**、**无序**且值**唯一**的集合。 与 `HashSet` 不同,它允许直接修改:`add`、`remove`、`clear` 等操作会更新原集合,而不是返回一个新集合。 在你需要反复构建或更新集合时(尤其是在局部或隔离的作用域内),这种可变性可以提升性能。 ### 操作 | 分类 | 操作 | 说明 | 复杂度 | | ------------ | --------------------------------------------------------------- | ----------------------------------- | ---------- | | 构造器 | [empty](https://effect.website/docs/v4/api/effect/MutableHashSet#empty) | 创建一个空 MutableHashSet | O(1) | | 构造器 | [fromIterable](https://effect.website/docs/v4/api/effect/MutableHashSet#fromIterable) | 从可迭代对象创建集合 | O(n) | | 构造器 | [make](https://effect.website/docs/v4/api/effect/MutableHashSet#make) | 从多个值创建集合 | O(n) | | 元素 | [has](https://effect.website/docs/v4/api/effect/MutableHashSet#has) | 检查某个值是否存在于集合中 | O(1) 平均 | | 元素 | [add](https://effect.website/docs/v4/api/effect/MutableHashSet#add) | 向集合中添加一个值 | O(1) 平均 | | 元素 | [remove](https://effect.website/docs/v4/api/effect/MutableHashSet#remove) | 从集合中删除一个值 | O(1) 平均 | | 读取器 | [size](https://effect.website/docs/v4/api/effect/MutableHashSet#size) | 获取元素数量 | O(1) | | 变更 | [clear](https://effect.website/docs/v4/api/effect/MutableHashSet#clear) | 删除集合中的所有值 | O(1) | **示例**(使用可变集合) ```ts import { MutableHashSet } from "effect" // Create a mutable set with initial values const set = MutableHashSet.make(1, 2, 3) // Add a new element (updates the set in place) MutableHashSet.add(set, 4) // Check current contents console.log([...set]) Array.from(set) // => [1, 2, 3, 4] // Remove an element (modifies in place) MutableHashSet.remove(set, 1) console.log([...set]) Array.from(set) // => [2, 3, 4] // Clear the set entirely MutableHashSet.clear(set) console.log(MutableHashSet.size(set)) MutableHashSet.size(set) // => 0 ``` ## 与 JavaScript 的互操作性 `HashSet` 与 `MutableHashSet` 都实现了 `Iterable` 接口,因此可以将它们用于 JavaScript 的以下特性: - 展开运算符(`...`) - `for...of` 循环 - `Array.from` 你也可以用 `Array.from` 把其中的值提取成数组。 **示例**(以 JS 原生方式使用 HashSet 的值) ```ts import { HashSet, MutableHashSet } from "effect" // Immutable HashSet const hashSet = HashSet.make(1, 2, 3) // Mutable variant const mutableSet = MutableHashSet.make(4, 5, 6) // HashSet is directly Iterable - no conversion needed // // ┌─── Iterable // ▼ const iterable: Iterable = hashSet // Spread into console.log console.log(...iterable) // Output: 1 2 3 Array.from(iterable) // => [1, 2, 3] // Use in a for...of loop for (const value of mutableSet) { console.log(value) } // Output: 4 5 6 // Convert to array with Array.from console.log(Array.from(mutableSet)) Array.from(mutableSet) // => [4, 5, 6] // Convert immutable HashSet to array using Array.from // // ┌─── Array // ▼ const array = Array.from(hashSet) // => [1, 2, 3] console.log(array) ``` --- # Option > 用 Option 表示可选值,既可以是存在(Some),也可以是缺失(None),并支持映射、组合与模式匹配等无缝操作。 `Option` 数据类型表示可选值。一个 `Option` 要么是 `Some`,包含一个类型为 `A` 的值;要么是 `None`,表示值的缺失。 你可以在以下场景中使用 `Option`: - 用作初始值 - 从并非对所有可能输入都有定义的函数(即「偏函数」,partial function)中返回值 - 管理数据结构中的可选字段 - 处理可选的函数参数 ## 创建 Option ### some 使用 `Option.some` 构造器创建一个持有类型 `A` 值的 `Option`。 **示例**(创建一个带值的 Option) ```ts import { Option } from "effect" // An Option holding the number 1 const value = Option.some(1) console.log(value) value // => Option.some(1) ``` ### none 使用 `Option.none` 构造器创建一个表示值缺失的 `Option`。 **示例**(创建一个没有值的 Option) ```ts import { Option } from "effect" // An Option holding no value const noValue = Option.none() console.log(noValue) noValue // => Option.none() ``` ### liftPredicate 你可以基于谓词创建 `Option`,例如检查一个值是否为正数。 **示例**(显式创建 Option) 下面展示如何用 `Option.none` 和 `Option.some` 实现这一点: ```ts import { Option } from "effect" const isPositive = (n: number) => n > 0 const parsePositive = (n: number): Option.Option => isPositive(n) ? Option.some(n) : Option.none() parsePositive(5) // => Option.some(5) ``` **示例**(用 `Option.liftPredicate` 让代码更简洁) 或者,你可以用 `Option.liftPredicate` 简化上面的逻辑: ```ts import { Option } from "effect" const isPositive = (n: number) => n > 0 // ┌─── (b: number) => Option // ▼ const parsePositive = Option.liftPredicate(isPositive) parsePositive(5) // => Option.some(5) ``` ## 为可选属性建模 考虑一个 `User` 模型,其中 `"email"` 属性是可选的,可以保存 `string` 值。我们用 `Option` 类型来表示这个可选属性: ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option } ``` 下面的示例展示了如何创建带 email 和不带 email 的 `User` 实例: **示例**(创建带 email 和不带 email 的 User) ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option } const withEmail: User = { id: 1, username: "john_doe", email: Option.some("john.doe@example.com"), } const withoutEmail: User = { id: 2, username: "jane_doe", email: Option.none(), } withEmail.email // => Option.some("john.doe@example.com") withoutEmail.email // => Option.none() ``` ## Guards 你可以使用 `Option.isSome` 和 `Option.isNone` 这两个 guard 检查一个 `Option` 是 `Some` 还是 `None`。 **示例**(用 Guards 检查 Option 的值) ```ts import { Option } from "effect" const foo = Option.some(1) console.log(Option.isSome(foo)) Option.isSome(foo) // => true if (Option.isNone(foo)) { console.log("Option is empty") } else { console.log(`Option has a value: ${foo.value}`) } // Output: "Option has a value: 1" Option.getOrThrow(foo) // => 1 ``` ## 模式匹配 使用 `Option.match` 处理 `Option` 的两种情况:分别为 `None` 和 `Some` 指定独立的回调。 **示例**(对 Option 进行模式匹配) ```ts import { Option } from "effect" const foo = Option.some(1) const message = Option.match(foo, { onNone: () => "Option is empty", onSome: (value) => `Option has a value: ${value}`, }) console.log(message) message // => "Option has a value: 1" ``` ## 使用 Option ### map `Option.map` 函数让你无需手动解包再重新包装,就能转换 `Option` 内部的值。如果 `Option` 持有值(`Some`),就应用该转换函数。如果 `Option` 是 `None`,则忽略该函数,`Option` 保持不变。 **示例**(映射 Some 中的值) ```ts import { Option } from "effect" // Transform the value inside Some console.log(Option.map(Option.some(1), (n) => n + 1)) Option.map(Option.some(1), (n) => n + 1) // => Option.some(2) ``` 处理 `None` 时,映射函数不会执行,`Option` 仍然是 `None`: **示例**(对 None 进行映射) ```ts import { Option } from "effect" // Mapping over None results in None console.log(Option.map(Option.none(), (n) => n + 1)) Option.map(Option.none(), (n) => n + 1) // => Option.none() ``` ### flatMap `Option.flatMap` 函数与 `Option.map` 类似,但它用于处理转换可能返回另一个 `Option` 的情况。这让我们能够串联那些依赖于 `Option` 中是否存在值的计算。 考虑一个 `User` 模型,它包含嵌套的可选 `Address`,而 `Address` 自身又包含可选的 `street` 属性: ```ts import { Option } from "effect" interface User { readonly id: number readonly username: string readonly email: Option.Option readonly address: Option.Option
} interface Address { readonly city: string readonly street: Option.Option } ``` 在这个模型中,`address` 字段是 `Option
`,而 `Address` 中的 `street` 字段是 `Option`。 我们可以用 `Option.flatMap` 从 `address` 中提取 `street` 属性: **示例**(提取嵌套的可选属性) ```ts import { Option } from "effect" interface Address { readonly city: string readonly street: Option.Option } interface User { readonly id: number readonly username: string readonly email: Option.Option readonly address: Option.Option
} const user: User = { id: 1, username: "john_doe", email: Option.some("john.doe@example.com"), address: Option.some({ city: "New York", street: Option.some("123 Main St"), }), } // Use flatMap to extract the street value const street = user.address.pipe(Option.flatMap((address) => address.street)) console.log(street) street // => Option.some("123 Main St") ``` 如果 `user.address` 是 `Some`,`Option.flatMap` 会应用函数 `(address) => address.street` 来取出 `street` 值。 如果 `user.address` 是 `None`,该函数不会执行,`street` 保持为 `None`。 这种方式让我们能够简洁地处理嵌套的可选值,避免手动检查,使代码更干净、更易读。 ### filter `Option.filter` 函数允许你根据给定的谓词过滤 `Option`。如果谓词不满足,或者 `Option` 是 `None`,结果将是 `None`。 **示例**(过滤 Option 的值) 下面展示如何用 `Option.filter` 简化一些代码,写出更符合习惯的写法: 原始代码 ```ts import { Option } from "effect" // Function to remove empty strings from an Option const removeEmptyString = (input: Option.Option) => { if (Option.isSome(input) && input.value === "") { return Option.none() // Return None if the value is an empty string } return input // Otherwise, return the original Option } console.log(removeEmptyString(Option.none())) removeEmptyString(Option.none()) // => Option.none() console.log(removeEmptyString(Option.some(""))) removeEmptyString(Option.some("")) // => Option.none() console.log(removeEmptyString(Option.some("a"))) removeEmptyString(Option.some("a")) // => Option.some("a") ``` 重构后的习惯写法 使用 `Option.filter`,我们可以更简洁地写出同样的逻辑: ```ts import { Option } from "effect" const removeEmptyString = (input: Option.Option) => Option.filter(input, (value) => value !== "") console.log(removeEmptyString(Option.none())) removeEmptyString(Option.none()) // => Option.none() console.log(removeEmptyString(Option.some(""))) removeEmptyString(Option.some("")) // => Option.none() console.log(removeEmptyString(Option.some("a"))) removeEmptyString(Option.some("a")) // => Option.some("a") ``` ## 从 Option 中取值 要从 `Option` 内部取出存储的值,你可以使用 `Option` 模块提供的几个辅助函数。下面是可用方法的概览: ### getOrThrow 该函数从 `Some` 中提取值。如果 `Option` 是 `None`,它会抛出错误。 **示例**(取出值或抛出错误) ```ts import { Option } from "effect" console.log(Option.getOrThrow(Option.some(10))) // Output: 10 console.log(Option.getOrThrow(Option.none())) // throws: Error: getOrThrow called on a None ``` ### getOrNull / getOrUndefined 这些函数把 `None` 转换为 `null` 或 `undefined`,在与非 `Option` 风格的代码交互时很有用。 **示例**(把 `None` 转换为 `null` 或 `undefined`) ```ts import { Option } from "effect" console.log(Option.getOrNull(Option.some(5))) Option.getOrNull(Option.some(5)) // => 5 console.log(Option.getOrNull(Option.none())) Option.getOrNull(Option.none()) // => null console.log(Option.getOrUndefined(Option.some(5))) Option.getOrUndefined(Option.some(5)) // => 5 console.log(Option.getOrUndefined(Option.none())) Option.getOrUndefined(Option.none()) // => undefined ``` ### getOrElse 该函数允许你指定当 `Option` 为 `None` 时返回的默认值。 **示例**(当 `None` 时提供默认值) ```ts import { Option } from "effect" console.log(Option.getOrElse(Option.some(5), () => 0)) Option.getOrElse(Option.some(5), () => 0) // => 5 console.log(Option.getOrElse(Option.none(), () => 0)) Option.getOrElse(Option.none(), () => 0) // => 0 ``` ## 回退 ### orElse 当一次计算返回 `None` 时,你可能想尝试另一个会产生 `Option` 的计算。`Option.orElse` 函数在这种情况下很有用。它让你能够串联多个计算:如果当前计算得到 `None`,就继续尝试下一个。这种方式常用于重试逻辑,不断尝试计算,直到有一个成功或所有可能性都用尽。 **示例**(尝试备选计算) ```ts import { Option } from "effect" // Simulating a computation that may or may not produce a result const computation = (): Option.Option => Math.random() < 0.5 ? Option.some(10) : Option.none() // Simulates an alternative computation const alternativeComputation = (): Option.Option => Math.random() < 0.5 ? Option.some(20) : Option.none() // Attempt the first computation, then try an alternative if needed const program = computation().pipe( Option.orElse(() => alternativeComputation()), ) const result = Option.match(program, { onNone: () => "Both computations resulted in None", // At least one computation succeeded onSome: (value) => `Computed value: ${value}`, }) console.log(result) // Output: Computed value: 10 ``` ### firstSomeOf 你也可以用 `Option.firstSomeOf` 从一组 `Option` 值(可迭代对象)中取出第一个 `Some` 值: **示例**(取出第一个 `Some` 值) ```ts import { Option } from "effect" const first = Option.firstSomeOf([ Option.none(), Option.some(2), Option.none(), Option.some(3), ]) console.log(first) first // => Option.some(2) ``` ## 与可空类型互操作 处理 `Option` 数据类型时,你可能会遇到用 `undefined` 或 `null` 表示可选值的代码。`Option` 模块提供了若干 API,让与这些可空类型的交互变得简单直接。 ### fromNullable `Option.fromNullishOr` 把一个可空值(`null` 或 `undefined`)转换为 `Option`。如果值是 `null` 或 `undefined`,它返回 `Option.none()`。否则,它把值包装进 `Option.some()`。 **示例**(从可空值创建 Option) ```ts import { Option } from "effect" console.log(Option.fromNullishOr(null)) Option.fromNullishOr(null) // => Option.none() console.log(Option.fromNullishOr(undefined)) Option.fromNullishOr(undefined) // => Option.none() console.log(Option.fromNullishOr(1)) Option.fromNullishOr(1) // => Option.some(1) ``` 如果你需要把 `Option` 转换回可空值,有两个辅助方法: - `Option.getOrNull`:把 `None` 转换为 `null`。 - `Option.getOrUndefined`:把 `None` 转换为 `undefined`。 ## 与 Effect 互操作 `Option` 实现了 `Yieldable` trait,因此可以直接在 `Effect.gen` 中 yield。若要把 `Option` 传给 `Effect.all` 这类 Effect 组合子,请用 `Effect.fromOption` 显式转换。 ### Option 如何映射到 Effect | Option 变体 | 映射到的 Effect | 说明 | | ----------- | --------------- | ---- | | `None` | `Effect` | 表示值缺失 | | `Some` | `Effect` | 表示值存在 | **示例**(把 `Option` 与 `Effect` 结合使用) ```ts import { Effect, Option } from "effect" // Function to get the head of an array, returning Option const head = (array: ReadonlyArray): Option.Option => array.length > 0 ? Option.some(array[0]!) : Option.none() head([1, 2, 3]) // => Option.some(1) // Simulated fetch function that returns Effect const fetchData = (): Effect.Effect => { const success = Math.random() > 0.5 return success ? Effect.succeed("some data") : Effect.fail("Failed to fetch data") } // Option is not an Effect subtype - convert explicitly with Effect.fromOption const program = Effect.all([Effect.fromOption(head([1, 2, 3])), fetchData()]) Effect.runPromise(program).then(console.log, console.error) /* Example Output: [ 1, 'some data' ] */ ``` ## 组合两个或多个 Option ### zipWith `Option.zipWith` 函数让你用一个给定的函数组合两个 `Option` 值。它会创建一个新的 `Option`,其中保存两个原始 `Option` 值的组合值。 **示例**(把两个 Option 组合成一个对象) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) // Combine the name and age into a person object const person = Option.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) person // => Option.some({ name: "JOHN", age: 25 }) ``` 如果其中任意一个 `Option` 值是 `None`,结果就是 `None`: **示例**(处理 None 值) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.none() // Since maybeAge is a None, the result will also be None const person = Option.zipWith(maybeName, maybeAge, (name, age) => ({ name: name.toUpperCase(), age, })) console.log(person) person // => Option.none() ``` ### all 若要把多个 `Option` 值组合起来而不转换它们的内容,可以使用 `Option.all`。这个函数返回的 `Option` 结构与输入一致: - 如果传入元组,结果就是等长的元组。 - 如果传入 struct,结果就是包含相同键的 struct。 - 如果传入 `Iterable`,结果就是数组。 **示例**(把多个 Option 组合成元组与 struct) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) // ┌─── Option<[string, number]> // ▼ const tuple = Option.all([maybeName, maybeAge]) console.log(tuple) tuple // => Option.some(["John", 25]) // ┌─── Option<{ name: string; age: number; }> // ▼ const struct = Option.all({ name: maybeName, age: maybeAge }) console.log(struct) struct // => Option.some({ name: "John", age: 25 }) ``` 如果其中任意一个 `Option` 值是 `None`,结果就是 `None`: **示例** ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.none() console.log(Option.all([maybeName, maybeAge])) Option.all([maybeName, maybeAge]) // => Option.none() ``` ## gen 与 [Effect.gen](/docs/v4/getting-started/using-generators/) 类似,`Option.gen` 提供了一种更具可读性的、基于生成器的语法来处理 `Option` 值,让涉及 `Option` 的代码更易编写和理解。这种方式与 `async/await` 类似,但专为 `Option` 量身定制。 **示例**(使用 `Option.gen` 创建一个组合值) ```ts import { Option } from "effect" const maybeName: Option.Option = Option.some("John") const maybeAge: Option.Option = Option.some(25) const person = Option.gen(function* () { const name = (yield* maybeName).toUpperCase() const age = yield* maybeAge return { name, age } }) console.log(person) person // => Option.some({ name: "JOHN", age: 25 }) ``` 当序列中任意一个 `Option` 值是 `None` 时,生成器会立即返回该 `None` 值,并跳过后续操作: **示例**(用 `Option.gen` 处理 `None` 值) 在这个示例中,`Option.gen` 一遇到 `None` 值就停止执行,从而在不执行后续操作的情况下把缺失值传播出去。 ```ts import { Option } from "effect" const maybeName: Option.Option = Option.none() const maybeAge: Option.Option = Option.some(25) const program = Option.gen(function* () { console.log("Retrieving name...") const name = (yield* maybeName).toUpperCase() console.log("Retrieving age...") const age = yield* maybeAge return { name, age } }) console.log(program) /* Output: Retrieving name... */ program // => Option.none() ``` 这些示例中使用 `console.log` 仅用于演示目的。使用 `Option.gen` 时,请避免在生成器函数中引入副作用,因为 `Option` 应当保持为纯数据结构。 ## Equivalence 你可以用 `Option.makeEquivalence` 函数比较 `Option` 值。该函数允许你为 `Option` 可能包含的值的类型提供一个 [Equivalence](/docs/v4/behaviour/equivalence/),以此指定如何比较 `Option` 类型的内容。 **示例**(比较可选数值是否等价) 假设你有一些可选数值,想检查它们是否等价。可以这样使用 `Option.makeEquivalence`: ```ts import { Option, Equivalence } from "effect" const myEquivalence = Option.makeEquivalence(Equivalence.Number) console.log(myEquivalence(Option.some(1), Option.some(1))) // Output: true, both options contain the number 1 myEquivalence(Option.some(1), Option.some(1)) // => true console.log(myEquivalence(Option.some(1), Option.some(2))) // Output: false, the numbers are different myEquivalence(Option.some(1), Option.some(2)) // => false console.log(myEquivalence(Option.some(1), Option.none())) // Output: false, one is a number and the other is empty myEquivalence(Option.some(1), Option.none()) // => false ``` ## 排序 你可以用 `Option.makeOrder` 函数对一组 `Option` 值排序。该函数用于为 `Option` 中包含的值的类型指定自定义排序规则。 **示例**(对可选数值排序) 假设你有一个可选数值的列表,想按升序排序,并把空值(`Option.none()`)视为最小: ```ts import { Option, Array, Order } from "effect" const items = [Option.some(1), Option.none(), Option.some(2)] // Create an order for sorting Option values containing numbers const myOrder = Option.makeOrder(Order.Number) console.log(Array.sort(myOrder)(items)) /* Output: [ { _id: 'Option', _tag: 'None' }, // None appears first because it's considered the lowest { _id: 'Option', _tag: 'Some', value: 1 }, // Sorted in ascending order { _id: 'Option', _tag: 'Some', value: 2 } ] */ Array.sort(myOrder)(items) // => [Option.none(), Option.some(1), Option.some(2)] ``` **示例**(按倒序对可选日期排序) 考虑一个更复杂的情形:你有一个对象列表,其中包含可选日期,想按降序排序,并把 `Option.none()` 值放在末尾: ```ts import { Option, Array, Order } from "effect" const items = [ { data: Option.some(new Date(10)) }, { data: Option.some(new Date(20)) }, { data: Option.none() }, ] // Define the order to sort dates within Option values in reverse const sorted = Array.sortWith( items, (item) => item.data, Order.flip(Option.makeOrder(Order.Date)), ) console.log(sorted) /* Output: [ { data: { _id: 'Option', _tag: 'Some', value: 1970-01-01T00:00:00.020Z } }, { data: { _id: 'Option', _tag: 'Some', value: 1970-01-01T00:00:00.010Z } }, { data: { _id: 'Option', _tag: 'None' } } // None placed last ] */ sorted // => [{ data: Option.some(new Date(20)) }, { data: Option.some(new Date(10)) }, { data: Option.none() }] ``` --- # Redacted > 使用 Redacted 模块安全地处理敏感数据,防止在日志中意外泄漏,同时支持安全地访问与比较值。 `Redacted` 模块提供了在应用中安全处理敏感信息的能力。通过使用 `Redacted` 数据类型,你可以确保敏感值不会意外暴露在日志或错误信息中。 ## make `Redacted.make` 函数根据给定的值 `A` 创建一个 `Redacted` 实例,确保内容被安全地隐藏起来。 **示例**(从日志中隐藏敏感信息) 使用 `Redacted.make` 有助于防止 API key 之类的敏感信息意外暴露在日志或错误信息中。 ```ts import { Redacted, Effect } from "effect" // Create a redacted API key const API_KEY = Redacted.make("1234567890") console.log(API_KEY) // Output: console.log(String(API_KEY)) String(API_KEY) // => "" Effect.runSync(Effect.log(API_KEY)) /* Output: [...] INFO (#...): */ ``` ## value `Redacted.value` 函数会从 `Redacted` 实例中取出原始值。请谨慎使用该函数,因为它会暴露敏感数据,可能让这些数据出现在日志中,或以非预期的方式被访问到。 **示例**(访问底层的敏感值) ```ts import { Redacted } from "effect" const API_KEY = Redacted.make("1234567890") // Expose the redacted value console.log(Redacted.value(API_KEY)) Redacted.value(API_KEY) // => "1234567890" ``` ## unsafeWipe `Redacted.wipeUnsafe` 函数会擦除 `Redacted` 实例的底层值,使其无法再被访问。这有助于确保敏感数据不会在内存中保留超过必要的时间。 **示例**(从内存中擦除敏感数据) ```ts import { Redacted } from "effect" const API_KEY = Redacted.make("1234567890") console.log(Redacted.value(API_KEY)) // Output: "1234567890" Redacted.wipeUnsafe(API_KEY) console.log(Redacted.value(API_KEY)) /* throws: Error: Unable to get redacted value */ ``` ## getEquivalence `Redacted.makeEquivalence` 函数使用针对类型 `A` 底层值的 Equivalence,为 `Redacted` 值生成一个 [Equivalence](/docs/v4/behaviour/equivalence/)。这让你能够安全地比较 `Redacted` 值,而不会泄漏其内容。 **示例**(比较 Redacted 值) ```ts import { Redacted, Equivalence } from "effect" const API_KEY1 = Redacted.make("1234567890") const API_KEY2 = Redacted.make("1-34567890") const API_KEY3 = Redacted.make("1234567890") const equivalence = Redacted.makeEquivalence(Equivalence.String) console.log(equivalence(API_KEY1, API_KEY2)) equivalence(API_KEY1, API_KEY2) // => false console.log(equivalence(API_KEY1, API_KEY3)) equivalence(API_KEY1, API_KEY3) // => true ``` --- # Result > 用 Result 数据类型把互斥的取值表示为 Success 或 Failure,从而在计算中实现精确的控制流。 `Result` 数据类型表示两种互斥的取值:一个 `Result` 要么是 `Success` 值,要么是 `Failure` 值,其中 `A` 是 `Success` 值的类型,`E` 是 `Failure` 值的类型。 ## 理解 Result 与 Exit Result 主要用作**简单的可辨识联合(discriminated union)**,对于需要详细错误信息的操作,并不推荐把它当作主要的结果类型。 在 Effect 中,[Exit](/docs/v4/data-types/exit/) 才是首选的**结果类型**,用于捕获关于失败的完整细节。 它封装 effectful 计算的结果,区分成功以及各种失败模式,例如错误、defect 与中断。 ## 创建 Result 你可以使用 `Result.succeed` 和 `Result.fail` 这两个构造器创建 `Result`。 用 `Result.succeed` 创建一个类型为 `A` 的 `Success` 值。 **示例**(创建一个 Success 值) ```ts import { Result } from "effect" const successValue = Result.succeed(42) console.log(successValue) successValue // => Result.succeed(42) ``` 用 `Result.fail` 创建一个类型为 `E` 的 `Failure` 值。 **示例**(创建一个 Failure 值) ```ts import { Result } from "effect" const failureValue = Result.fail("not a number") console.log(failureValue) failureValue // => Result.fail("not a number") ``` ## Guards 使用 `Result.isFailure` 和 `Result.isSuccess` 检查一个 `Result` 是 `Failure` 值还是 `Success` 值。 **示例**(用 Guards 检查 Result 的类型) ```ts import { Result } from "effect" const foo = Result.succeed(42) if (Result.isFailure(foo)) { console.log(`The failure value is: ${foo.failure}`) } else { console.log(`The Success value is: ${foo.success}`) foo.success // => 42 } // Output: "The Success value is: 42" ``` ## 模式匹配 使用 `Result.match` 处理 `Result` 的两种情况:分别为 `Failure` 和 `Success` 指定独立的回调。 **示例**(对 Result 进行模式匹配) ```ts import { Result } from "effect" const foo = Result.succeed(42) const message = Result.match(foo, { onFailure: (failure) => `The failure value is: ${failure}`, onSuccess: (success) => `The Success value is: ${success}`, }) console.log(message) message // => "The Success value is: 42" ``` ## 映射 ### 映射 Success 值 使用 `Result.map` 转换 `Result` 的 `Success` 值。你提供的函数只会作用于 `Success` 值,`Failure` 值保持不变。 **示例**(转换 Success 值) ```ts import { Result } from "effect" // Transform the Success value by adding 1 const successResult = Result.map(Result.succeed(1), (n) => n + 1) console.log(successResult) successResult // => Result.succeed(2) // The transformation is ignored for Failure values const failureResult = Result.map(Result.fail("not a number"), (n) => n + 1) console.log(failureResult) failureResult // => Result.fail("not a number") ``` ### 映射 Failure 值 使用 `Result.mapError` 转换 `Result` 的 `Failure` 值。所提供的函数只会作用于 `Failure` 值,`Success` 值保持不变。 **示例**(转换 Failure 值) ```ts import { Result } from "effect" // The transformation is ignored for Success values const successResult = Result.mapError(Result.succeed(1), (s) => s + "!") console.log(successResult) successResult // => Result.succeed(1) // Transform the Failure value by appending "!" const failureResult = Result.mapError( Result.fail("not a number"), (s) => s + "!", ) console.log(failureResult) failureResult // => Result.fail("not a number!") ``` ### 同时映射两个值 使用 `Result.mapBoth` 同时转换 `Result` 的 `Failure` 值与 `Success` 值。这个函数接受两个独立的转换函数:一个用于 `Failure` 值,另一个用于 `Success` 值。 **示例**(同时转换 Failure 值与 Success 值) ```ts import { Result } from "effect" const transformedSuccess = Result.mapBoth(Result.succeed(1), { onFailure: (s) => s + "!", onSuccess: (n) => n + 1, }) console.log(transformedSuccess) transformedSuccess // => Result.succeed(2) const transformedFailure = Result.mapBoth(Result.fail("not a number"), { onFailure: (s) => s + "!", onSuccess: (n) => n + 1, }) console.log(transformedFailure) transformedFailure // => Result.fail("not a number!") ``` ## 与 Effect 互操作 `Result` 实现了 `Yieldable` trait,因此可以直接在 `Effect.gen` 中 yield。若要把 `Result` 传给 `Effect.all` 这类 Effect 组合子,请用 `Effect.fromResult` 显式转换。 ### Result 如何映射到 Effect | Result 变体 | 映射到的 Effect | 说明 | | ------------ | ------------------ | -------- | | `Failure` | `Effect` | 表示失败 | | `Success` | `Effect` | 表示成功 | **示例**(把 `Result` 与 `Effect` 结合使用) ```ts import { Effect, Result } from "effect" // Function to get the head of an array, returning Result const head = (array: ReadonlyArray): Result.Result => array.length > 0 ? Result.succeed(array[0]!) : Result.fail("empty array") head([1, 2, 3]) // => Result.succeed(1) // Simulated fetch function that returns Effect const fetchData = (): Effect.Effect => { const success = Math.random() > 0.5 return success ? Effect.succeed("some data") : Effect.fail("Failed to fetch data") } // Result is not an Effect subtype - convert explicitly with Effect.fromResult const program = Effect.all([Effect.fromResult(head([1, 2, 3])), fetchData()]) Effect.runPromise(program).then(console.log, console.error) /* Example Output: [ 1, 'some data' ] */ ``` ## 组合两个或多个 Result ### 用 flatMap 与 map 组合 用提供的函数组合两个 `Result` 值:串联 `Result.flatMap` 与 `Result.map`。这会创建一个新的 `Result`,其中保存两个原始 `Result` 值的组合值。 **示例**(把两个 Result 组合成一个对象) ```ts import { Result } from "effect" const maybeName: Result.Result = Result.succeed("John") const maybeAge: Result.Result = Result.succeed(25) // Combine the name and age into a person object const person = Result.flatMap(maybeName, (name) => Result.map(maybeAge, (age) => ({ name: name.toUpperCase(), age, })), ) console.log(person) person // => Result.succeed({ name: "JOHN", age: 25 }) ``` 如果任意一个 `Result` 值是 `Failure`,结果也会是 `Failure`,并保存最先遇到的 `Failure` 值: **示例**(组合出含 Failure 值的结果) ```ts import { Result } from "effect" const maybeName: Result.Result = Result.succeed("John") const maybeAge: Result.Result = Result.fail("Oh no!") // Since maybeAge is a Failure, the result will also be a Failure const person = Result.flatMap(maybeName, (name) => Result.map(maybeAge, (age) => ({ name: name.toUpperCase(), age, })), ) console.log(person) /* Output: { _id: 'Result', _tag: 'Failure', failure: 'Oh no!' } */ ``` ### all 若要把多个 `Result` 值组合起来而不转换它们的内容,可以使用 `Result.all`。这个函数返回的 `Result` 结构与输入一致: - 如果传入元组,结果就是等长的元组。 - 如果传入 struct,结果就是包含相同键的 struct。 - 如果传入 `Iterable`,结果就是数组。 **示例**(把多个 Result 组合成元组与 struct) ```ts import { Result } from "effect" const maybeName: Result.Result = Result.succeed("John") const maybeAge: Result.Result = Result.succeed(25) // ┌─── Result<[string, number], string> // ▼ const tuple = Result.all([maybeName, maybeAge]) console.log(tuple) tuple // => Result.succeed(["John", 25]) // ┌─── Result<{ name: string; age: number; }, string> // ▼ const struct = Result.all({ name: maybeName, age: maybeAge }) console.log(struct) struct // => Result.succeed({ name: "John", age: 25 }) ``` 如果有一个或多个 `Result` 值是 `Failure`,则返回最先遇到的 `Failure`: **示例**(处理多个 Failure 值) ```ts import { Result } from "effect" const maybeName: Result.Result = Result.fail("name not found") const maybeAge: Result.Result = Result.fail("age not found") // The first Failure value will be returned console.log(Result.all([maybeName, maybeAge])) Result.all([maybeName, maybeAge]) // => Result.fail("name not found") ``` ## gen 与 [Effect.gen](/docs/v4/getting-started/using-generators/) 类似,`Result.gen` 提供了一种更具可读性的、基于生成器的语法来处理 `Result` 值,让涉及 `Result` 的代码更易编写和理解。这种方式与 `async/await` 类似,但专为 `Result` 量身定制。 **示例**(使用 `Result.gen` 创建一个组合值) ```ts import { Result } from "effect" const maybeName: Result.Result = Result.succeed("John") const maybeAge: Result.Result = Result.succeed(25) const program = Result.gen(function* () { const name = (yield* maybeName).toUpperCase() const age = yield* maybeAge return { name, age } }) console.log(program) program // => Result.succeed({ name: "JOHN", age: 25 }) ``` 当序列中任意一个 `Result` 值是 `Failure` 时,生成器会立即返回该 `Failure` 值,并跳过后续操作: **示例**(用 `Result.gen` 处理 `Failure` 值) 在这个示例中,`Result.gen` 一遇到 `Failure` 值就停止执行,从而在不再执行后续操作的情况下把错误传播出去。 ```ts import { Result } from "effect" const maybeName: Result.Result = Result.fail("Oh no!") const maybeAge: Result.Result = Result.succeed(25) const program = Result.gen(function* () { console.log("Retrieving name...") const name = (yield* maybeName).toUpperCase() console.log("Retrieving age...") const age = yield* maybeAge return { name, age } }) console.log(program) /* Output: Retrieving name... */ program // => Result.fail("Oh no!") ``` 这些示例中使用 `console.log` 仅用于演示目的。使用 `Result.gen` 时,请避免在生成器函数中引入副作用,因为 `Result` 应当保持为纯数据结构。 --- # 错误累积 > 累积每一个类型化错误,或同时保留失败与成功。 诸如 `Effect.all` 和 `Effect.forEach` 这类组合子默认采用快速失败(fail fast)行为。而验证(validation)往往需要不同的做法:对每个输入都求值,并一次性报告所有问题。 ## validate `Effect.validate` 会把一个返回 Effect 的函数应用到每个元素上。如果所有元素都成功,它会返回全部成功值;只要有元素失败,它就会把每一个错误作为一个非空数组返回,并丢弃所有成功值。 **示例**(收集验证错误) ```ts import { Effect, Exit } from "effect" const program = Effect.validate([1, 2, 3, 4], (value) => value % 2 === 0 ? Effect.succeed(value) : Effect.fail(`${value} is not even`), ) Effect.runSyncExit(program) // => Exit.fail(["1 is not even", "3 is not even"]) ``` 每个元素都会被求值。可以用 `{ concurrency }` 控制并行度;当只需要验证结果、而不需要成功值时,可以使用 `{ discard: true }`。 ## partition `Effect.partition` 同样会对每个元素求值,但它永远不会失败。它返回 `[failures, successes]`,同时保留失败与成功两侧的结果。 **示例**(划分失败与成功) ```ts import { Effect } from "effect" const program = Effect.partition([0, 1, 2, 3, 4], (value) => value % 2 === 0 ? Effect.succeed(value) : Effect.fail(`${value} is not even`), ) Effect.runSync(program) // => [["1 is not even", "3 is not even"], [0, 2, 4]] ``` 与 `validate` 一样,`partition` 也接受 `concurrency` 选项。 --- # 错误通道操作 > 转换、过滤、观察、暴露并翻转 Effect 的错误通道。 错误通道操作符会改变或观察一个 Effect 的失败行为,而无需立即进行恢复。 ## 转换通道 ### mapError `Effect.mapError` 会转换类型化错误,同时保持成功值不变。 **示例**(映射一个错误) ```ts import { Effect } from "effect" const program = Effect.fail("unavailable").pipe( Effect.mapError((message) => new Error(message)), ) const error = Effect.runSync(Effect.flip(program)) error.message // => "unavailable" ``` ### mapBoth `Effect.mapBoth` 用一次操作同时转换成功通道和错误通道。 ```ts import { Effect } from "effect" const program = Effect.succeed(2).pipe( Effect.mapBoth({ onFailure: (message: string) => new Error(message), onSuccess: (value) => value * 2, }), ) Effect.runSync(program) // => 4 ``` 急切(eager)变体 `mapErrorEager` 与 `mapBothEager` 是针对可以立即求值的映射所做的优化。 ## 过滤成功通道 `Effect.filterOrFail` 在成功值满足谓词时保留它,否则创建一个类型化失败。 **示例**(校验一个成功值) ```ts import { Effect, Exit } from "effect" const program = Effect.succeed(-1).pipe( Effect.filterOrFail( (value) => value >= 0, (value) => `Expected a non-negative number, got ${value}`, ), ) Effect.runSyncExit(program) // => Exit.fail("Expected a non-negative number, got -1") ``` 用户定义的类型守卫会收窄成功类型。 ```ts import { Effect } from "effect" interface User { readonly name: string } const user: Effect.Effect = Effect.succeed({ name: "Alice" }) const name = user.pipe( Effect.filterOrFail( (value): value is User => value !== null, () => new Error("Unauthorized"), ), Effect.map((value) => value.name), ) Effect.runSync(name) // => "Alice" ``` 当谓词不满足时需要运行另一个 Effect、而不是直接产出一个值时,请使用 `Effect.filterOrElse`。 ## 观察失败 tap 操作符会运行一个用于观察的 Effect,并保留原本的结果。如果观察本身失败,这个新的失败会与原本的结果组合在一起。 ### tapError `Effect.tapError` 会观察每一个类型化错误。 ```ts import { Effect, Exit } from "effect" const observed: Array = [] const program = Effect.fail("NetworkError").pipe( Effect.tapError((error) => Effect.sync(() => { observed.push(error) }), ), ) Effect.runSyncExit(program) // => Exit.fail("NetworkError") observed // => ["NetworkError"] ``` ### tapErrorTag `Effect.tapErrorTag` 只观察 tagged error 联合类型中的一个成员,而不处理它。 ```ts import { Data, Effect, Exit } from "effect" class NetworkError extends Data.TaggedError("NetworkError")<{ readonly status: number }> {} const observed: Array = [] const error = new NetworkError({ status: 503 }) const program = Effect.fail(error).pipe( Effect.tapErrorTag("NetworkError", (error) => Effect.sync(() => { observed.push(error.status) }), ), ) Effect.runSyncExit(program) // => Exit.fail(error) observed // => [503] ``` ### tapCause `Effect.tapCause` 会观察完整的 `Cause`,包括类型化失败、defect、中断以及多个原因。 ```ts import { Effect, Exit } from "effect" const observed: Array> = [] const program = Effect.die("boom").pipe( Effect.tapCause((cause) => Effect.sync(() => { observed.push(cause.reasons.map((reason) => reason._tag)) }), ), ) Effect.runSyncExit(program) // => Exit.die("boom") observed // => [["Die"]] ``` ### tapDefect `Effect.tapDefect` 只观察 defect,对于普通的类型化失败不会运行。 ```ts import { Effect } from "effect" const observed: Array = [] const program = Effect.die("boom").pipe( Effect.tapDefect((defect) => Effect.sync(() => { observed.push(defect) }), ), Effect.ignoreCause, ) Effect.runSync(program) // => undefined observed // => ["boom"] ``` 当成功值和类型化失败都需要分别观察时,可以在 `tapError` 之后使用普通的 `Effect.tap`。 ## 把失败移入成功通道 `Effect.result` 会把类型化失败暴露为 `Result.Failure` 值,而 `Effect.exit` 会暴露完整的结果,包括完整的 `Cause`。 ```text Effect -> Effect, never, R> Effect -> Effect, never, R> ``` 关于 `Effect.result` 请参见[预期错误](/docs/v4/error-management/expected-errors/#result),关于 `Effect.exit` 请参见[意外错误](/docs/v4/error-management/unexpected-errors/#inspecting-the-complete-exit)。 当类型化错误和成功值需要变成同一个成功类型时,可以用 `Effect.catch` 进行恢复: ```ts import { Effect } from "effect" const program: Effect.Effect = Effect.fail(1) const merged = program.pipe(Effect.catch(Effect.succeed)) Effect.runSync(merged) // => 1 ``` ## 翻转通道 `Effect.flip` 会交换类型化错误通道和成功通道。 ```ts import { Effect } from "effect" const program = Effect.fail("unavailable").pipe(Effect.as(42)) const flipped = Effect.flip(program) Effect.runSync(flipped) // => "unavailable" ``` `flip` 适合对错误通道做聚焦的转换,但 `mapError` 或某个 catch 操作符通常能更直接地表达意图。 --- # 预期错误 > 如何创建、追踪、暴露并恢复 Effect 中类型化的预期错误。 预期错误(expected error)通过 [`Effect`](/docs/v4/getting-started/the-effect-type/) 的错误通道来表示: ```text ┌─── Success type │ ┌─── Error type │ │ ┌─── Requirements ▼ ▼ ▼ Effect ``` 由于错误类型是显式的,调用方可以看出可能发生哪些失败,并决定要从中恢复哪些。 ## 创建预期错误 `Effect.fail(error)` 会创建一个以 `error` 失败的 Effect。如果错误的构造应当推迟到 Effect 运行时才进行,请使用 `Effect.failSync`。 **示例**(创建一个类型化失败) ```ts import { Data, Effect } from "effect" class UserNotFound extends Data.TaggedError("UserNotFound")<{ readonly id: string }> {} const findUser = (id: string): Effect.Effect => id === "1" ? Effect.succeed("Alice") : Effect.fail(new UserNotFound({ id })) Effect.runSync(Effect.flip(findUser("2")))._tag // => "UserNotFound" ``` 用 `Data.Error` 或 `Data.TaggedError` 构建的错误也可以直接在 `Effect.gen` 中被 yield。参见 [可 yield 的错误](/docs/v4/error-management/yieldable-errors/)。 ## 追踪多种错误类型 当错误类型不同的 effect 组合在一起时,Effect 会追踪它们的并集。 ```ts import { Data, Effect } from "effect" class InvalidInput extends Data.TaggedError("InvalidInput")<{}> {} class UserNotFound extends Data.TaggedError("UserNotFound")<{}> {} declare const validate: Effect.Effect declare const loadUser: (id: string) => Effect.Effect // Effect const program = Effect.gen(function* () { const id = yield* validate return yield* loadUser(id) }) ``` 顺序组合会在首次失败时短路,失败之后的操作不会被求值。 ## 将错误作为值暴露 有时调用方需要在不恢复到另一个 Effect 的前提下,同时查看两种结果。 ### result `Effect.result` 会把类型化错误移入成功通道中的 [`Result`](/docs/v4/data-types/result/): ```text Effect -> Effect, never, R> ``` **示例**(查看 Result) ```ts import { Effect, Result } from "effect" const result = Effect.runSync(Effect.result(Effect.fail("unavailable"))) Result.match(result, { onFailure: (error) => `failure: ${error}`, onSuccess: (value) => `success: ${value}`, }) // => "failure: unavailable" ``` `Effect.result` 只处理类型化失败。defect 和中断仍然是 fiber 的失败。 ### option `Effect.option` 会丢弃错误值:成功时返回 `Option.some(value)`,类型化失败时返回 `Option.none()`。 ```ts import { Effect, Option } from "effect" Effect.runSync(Effect.option(Effect.succeed(1))) // => Option.some(1) Effect.runSync(Effect.option(Effect.fail("unavailable"))) // => Option.none() ``` 当错误值本身有意义时使用 `result`;只有当每一种类型化失败都表示“不存在”时才使用 `option`。 ## 捕获所有类型化错误 `Effect.catch` 用一个恢复 Effect 处理所有类型化错误,它不会捕获 defect 或中断。 **示例**(从每一种类型化错误中恢复) ```ts import { Effect } from "effect" const program = Effect.fail("unavailable").pipe( Effect.catch((error) => Effect.succeed(`recovered: ${error}`)), ) Effect.runSync(program) // => "recovered: unavailable" ``` 当处理函数不会失败时,对于可以立即求值的恢复 Effect,`Effect.catchEager` 是一种急切(eager)优化。 当处理函数需要完整的失败原因时,请使用 [`Effect.catchCause`](/docs/v4/error-management/unexpected-errors/#catchcause)。 ## 捕获选定的错误 选择性捕获操作符会把所有未匹配的错误保留在错误通道中。 ### catchTag `Effect.catchTag` 处理 tagged error 联合类型中的一个成员,并把该成员从结果错误类型中移除。 **示例**(捕获单个 tagged error) ```ts import { Data, Effect } from "effect" class NetworkError extends Data.TaggedError("NetworkError")<{ readonly status: number }> {} class ValidationError extends Data.TaggedError("ValidationError")<{ readonly field: string }> {} const request: Effect.Effect = Effect.fail(new NetworkError({ status: 503 })) // Effect const recovered = request.pipe( Effect.catchTag("NetworkError", (error) => Effect.succeed(`cached after ${error.status}`), ), ) Effect.runSync(recovered) // => "cached after 503" ``` 当多个 tagged error 共用一个处理函数时,`catchTag` 也接受一个非空的标签数组。 ### catchTags `Effect.catchTags` 通过一张按标签分别指定处理函数的表来处理多个 tagged error。 **示例**(捕获多个 tagged error) ```ts import { Data, Effect } from "effect" class NetworkError extends Data.TaggedError("NetworkError")<{ readonly status: number }> {} class ValidationError extends Data.TaggedError("ValidationError")<{ readonly field: string }> {} const request: Effect.Effect = Effect.fail(new ValidationError({ field: "email" })) const recovered = request.pipe( Effect.catchTags({ NetworkError: (error) => Effect.succeed(`network: ${error.status}`), ValidationError: (error) => Effect.succeed(`invalid: ${error.field}`), }), ) Effect.runSync(recovered) // => "invalid: email" ``` ### catchIf `Effect.catchIf` 用谓词或类型守卫来选定错误。 ```ts import { Effect } from "effect" const program = Effect.fail(404).pipe( Effect.catchIf( (status) => status === 404, () => Effect.succeed("not found"), ), ) Effect.runSync(program) // => "not found" ``` ### catchFilter `Effect.catchFilter` 使用 `Filter` 模块来实现可复用、可组合的筛选逻辑。具有类型收窄作用的 filter 还会把已处理的子类型从错误通道中移除。 **示例**(使用 Filter 捕获错误) ```ts import { Data, Effect, Filter } from "effect" class NetworkError extends Data.TaggedError("NetworkError")<{}> {} class ValidationError extends Data.TaggedError("ValidationError")<{}> {} const task: Effect.Effect = Effect.fail( new NetworkError(), ) const program = task.pipe( Effect.catchFilter(Filter.tagged("NetworkError"), () => Effect.succeed("using cache"), ), ) Effect.runSync(program) // => "using cache" ``` ## 捕获嵌套的错误原因 有些 tagged error 会在只读的 `reason` 字段中包含另一个 tagged error。`Effect.catchReason` 处理其中一个嵌套原因,并对未匹配的原因保留父错误类型;`Effect.catchReasons` 则处理多个。 **示例**(捕获一个嵌套原因) ```ts import { Data, Effect } from "effect" class RateLimitError extends Data.TaggedError("RateLimitError")<{ readonly retryAfter: number }> {} class QuotaExceededError extends Data.TaggedError("QuotaExceededError")<{}> {} class ApiError extends Data.TaggedError("ApiError")<{ readonly reason: RateLimitError | QuotaExceededError }> {} const request: Effect.Effect = Effect.fail( new ApiError({ reason: new RateLimitError({ retryAfter: 30 }) }), ) const program = request.pipe( Effect.catchReason("ApiError", "RateLimitError", (reason) => Effect.succeed(`retry after ${reason.retryAfter}s`), ), ) Effect.runSync(program) // => "retry after 30s" ``` 如果希望嵌套原因在错误通道中取代父错误,而不是被立即处理,请使用 `Effect.unwrapReason(errorTag)`。 --- # 回退 > 使用回退 Effect 与回退值从带类型的失败中恢复。 回退操作符用于从带类型的失败中恢复。defect 与中断保持不变。 ## catch `Effect.catch` 接收错误并返回一个回退 Effect。如果源 Effect 成功,则不会求值该回退。 **示例**(用另一个 Effect 恢复) ```ts import { Effect } from "effect" const primary = Effect.fail("primary unavailable") const program = primary.pipe( Effect.catch((error) => Effect.succeed(`fallback: ${error}`)), ) Effect.runSync(program) // => "fallback: primary unavailable" ``` 当只有部分错误通道应触发回退时,请使用 `Effect.catchTag`、`Effect.catchIf` 或 `Effect.catchFilter`。 ## orElseSucceed `Effect.orElseSucceed` 用惰性求值的成功值替换任何带类型的失败,并移除带类型的错误通道。 **示例**(提供一个默认值) ```ts import { Effect } from "effect" const program = Effect.fail("missing").pipe(Effect.orElseSucceed(() => 0)) Effect.runSync(program) // => 0 ``` 该操作符会处理每一个带类型的错误。如果只有"缺失"或某个特定错误应使用默认值,请先用选择性的 catch 操作符收窄错误范围。 ## firstSuccessOf `Effect.firstSuccessOf` 按顺序运行各个备选方案,并在第一个成功处停止。如果每个 Effect 都失败,则传播最后一个错误。 **示例**(尝试按优先级排列的备选方案) ```ts import { Effect } from "effect" const program = Effect.firstSuccessOf([ Effect.fail("primary unavailable"), Effect.succeed("secondary result"), Effect.die("not evaluated"), ]) Effect.runSync(program) // => "secondary result" ``` 传入空的可迭代对象会产生一个带有消息 `"Received an empty collection of effects"` 的 defect。 --- # 匹配 > 使用纯函数或 Effect 化的处理函数处理成功与失败的结果。 匹配会消费一个 Effect 的两个通道,并产出一个结果。对于带类型的失败,请使用普通的变体;当还必须考虑 defect 与中断时,请使用 `Cause` 变体。 ## match `Effect.match` 用纯函数处理带类型的失败或成功。defect 与中断不会被处理。 **示例**(同时匹配两个通道) ```ts import { Effect } from "effect" const task: Effect.Effect = Effect.fail("unavailable") const program = Effect.match(task, { onFailure: (error) => `failure: ${error}`, onSuccess: (value) => `success: ${value}`, }) Effect.runSync(program) // => "failure: unavailable" ``` ## matchEffect `Effect.matchEffect` 是 Effect 化的版本:两个处理函数都返回 Effect,并且可能引入新的错误或需求。 **示例**(运行 Effect 化的处理函数) ```ts import { Effect } from "effect" const task: Effect.Effect = Effect.succeed(42) const program = Effect.matchEffect(task, { onFailure: (error) => Effect.succeed(`failure: ${error}`), onSuccess: (value) => Effect.succeed(`success: ${value}`), }) Effect.runSync(program) // => "success: 42" ``` ## matchCause and matchCauseEffect `Effect.matchCause` 会把完整的 `Cause` 传给 `onFailure`,因此它也能处理 defect 与中断。`Effect.matchCauseEffect` 是与之对应的 Effect 化版本。 **示例**(匹配一个 defect) ```ts import { Cause, Effect } from "effect" const program = Effect.die("boom").pipe( Effect.matchCause({ onFailure: (cause) => Cause.hasDies(cause) ? "terminated by a defect" : "failed", onSuccess: () => "succeeded", }), ) Effect.runSync(program) // => "terminated by a defect" ``` ## ignore and ignoreCause `Effect.ignore` 丢弃成功值,并从带类型的错误中恢复,产出 `Effect`。defect 与中断会被保留。 `Effect.ignoreCause` 还会丢弃每一个失败 cause。请谨慎使用它,因为它可能掩盖 defect。 ```ts import { Effect, Exit } from "effect" Effect.runSync(Effect.ignore(Effect.fail("error"))) // => undefined const defect = Effect.ignore(Effect.die("boom")) Effect.runSyncExit(defect) // => Exit.die("boom") Effect.runSync(Effect.ignoreCause(Effect.die("boom"))) // => undefined ``` --- # 并行与顺序错误 > 了解 Effect 如何在 Cause 中表示多个失败原因。 大多数 Effect 组合子都是快速失败的:一旦某个 Effect 失败,后续工作就不会再启动,并发进行的工作也会被中断。不过,有些操作仍然可能产生多个失败原因,例如并发运行的 Fiber 一起失败,或者某个操作与它的 finalizer 同时失败。 ## 扁平的 Cause `Cause` 包含一个扁平的只读数组,其中的元素是 `Reason` 值: ```ts type Reason = Cause.Fail | Cause.Die | Cause.Interrupt ``` 按顺序组合的 Reason 与按并行组合的 Reason 使用相同的 `reasons` 数组表示。 **示例**(查看多个 Reason) ```ts import { Cause } from "effect" const cause = Cause.combine( Cause.fail("request failed"), Cause.die(new Error("finalizer failed")), ) cause.reasons.map((reason) => reason._tag) // => ["Fail", "Die"] ``` 如果只关心失败的种类,可以使用 `Cause.hasFails`、`Cause.hasDies` 和 `Cause.hasInterrupts`。如果需要各个具体的值,则使用 `cause.reasons`,或者 `Cause.findError`、`Cause.findDefect` 之类的提取器。 ## 累积领域错误 多个带类型的校验错误通常更适合表示为数据,而不是多个 `Cause` 原因。使用 [`Effect.validate`](/docs/v4/error-management/error-accumulation/#validate) 收集每一个带类型的错误,或者使用 [`Effect.partition`](/docs/v4/error-management/error-accumulation/#partition) 同时保留失败与成功。 --- # 重试 > 通过限制、条件、Schedule 与 fallback 来重试可恢复的类型化失败。 重试适用于临时性失败,例如临时的网络或服务不可用。它不能替代对永久性错误的处理,并且 defect 与中断永远不会被重试。 ## retry `Effect.retry` 会在出现类型化失败之后重新运行一个 Effect。源 Effect 总是先被求值一次,然后才应用重试策略。 **示例**(重试固定次数) ```ts import { Data, Effect } from "effect" class TemporaryError extends Data.TaggedError("TemporaryError")<{ readonly attempt: number }> {} let attempts = 0 const request = Effect.suspend(() => { attempts++ return attempts < 3 ? Effect.fail(new TemporaryError({ attempt: attempts })) : Effect.succeed("ok") }) const program = request.pipe(Effect.retry({ times: 5 })) Effect.runSync(program) // => "ok" attempts // => 3 ``` `times` 是初次尝试之后的重试次数。因此 `{ times: 5 }` 最多允许源 Effect 执行六次。 ### 只重试选定的错误 选项对象可以组合: - `while`:在谓词为 true 时重试; - `until`:在谓词为 true 时停止重试; - `times`:限制重试次数; - `schedule`:控制时序以及额外的停止条件。 谓词既可以返回布尔值,也可以返回一个 Effect。 **示例**(只重试临时性错误) ```ts import { Data, Effect, Exit } from "effect" class RequestError extends Data.TaggedError("RequestError")<{ readonly retryable: boolean }> {} let attempts = 0 const request = Effect.failSync(() => { attempts++ return new RequestError({ retryable: attempts < 2 }) }) const program = request.pipe( Effect.retry({ times: 5, while: (error) => error.retryable, }), ) Effect.runSyncExit(program) // => Exit.fail(new RequestError({ retryable: false })) attempts // => 2 ``` ### 使用 Schedule [`Schedule`](/docs/v4/scheduling/introduction/) 可以定义延迟、退避、抖动以及重试上限。例如,`Schedule.recurs(3)` 允许在初次尝试之后重试三次。 ```ts import { Effect, Schedule } from "effect" let attempts = 0 const request = Effect.suspend(() => { attempts++ return attempts < 2 ? Effect.fail("temporary") : Effect.succeed("ok") }) const program = request.pipe(Effect.retry(Schedule.recurs(3))) Effect.runSync(program) // => "ok" ``` 当重复取决于成功的值而不是错误时,请改用 [`Effect.repeat`](/docs/v4/scheduling/repetition/)。 ## retryOrElse `Effect.retryOrElse` 使用一个 Schedule,并在该 Schedule 耗尽时运行一个 fallback Effect。这个 fallback 会接收最终的错误以及 Schedule 的输出。 **示例**(重试后进行 fallback) ```ts import { Effect, Schedule } from "effect" let attempts = 0 const request = Effect.failSync(() => { attempts++ return "unavailable" }) const program = Effect.retryOrElse( request, Schedule.recurs(2), (error, retries) => Effect.succeed(`${error} after ${retries} retries`), ) Effect.runSync(program) // => "unavailable after 2 retries" attempts // => 3 ``` --- # 沙箱化 > 在类型化错误通道中暴露某个 Effect 的完整 Cause。 `Effect.sandbox` 会把完整的失败 `Cause` 暴露在类型化错误通道中: ```text Effect -> Effect, R> ``` 与普通的类型化错误不同,一个 `Cause` 可以包含类型化失败、defect、中断,或者同时包含多种原因。Cause 是扁平的:检查只读的 `reasons` 数组,并使用 `Cause` 模块提供的 reason 守卫。 **示例**(检查沙箱化后的 Cause) ```ts import { Cause, Effect } from "effect" const sandboxed = Effect.fail("invalid input").pipe(Effect.sandbox) const program = sandboxed.pipe( Effect.catch((cause) => { const failure = cause.reasons.find(Cause.isFailReason) return failure === undefined ? Effect.fail(cause) : Effect.succeed(`Recovered from: ${failure.error}`) }), ) Effect.runSync(program) // => "Recovered from: invalid input" ``` 当一个沙箱化的 effect 没有以其他方式恢复时,可以用 `Effect.catch(Effect.failCause)` 把它的错误通道转换回原来的失败模型: ```ts import { Effect, Exit } from "effect" const sandboxed = Effect.fail("invalid input").pipe(Effect.sandbox) const restored = sandboxed.pipe(Effect.catch(Effect.failCause)) Effect.runSyncExit(restored) // => Exit.fail("invalid input") ``` 对于单步恢复,`Effect.catchCause` 通常更简单,因为它直接提供相同的 `Cause`,而无需先改变错误类型。 --- # 超时 > 限制一个 Effect 允许运行多长时间,并自定义超时产生的结果。 超时操作符让一个 Effect 与一段时长竞速。如果超时胜出,源 Effect 会在超时结果产生之前被中断。 ## timeout `Effect.timeout` 会把超时表示为一个类型化的 `Cause.TimeoutError`。 **示例**(以 TimeoutError 失败) ```ts import { Effect } from "effect" const program = Effect.never.pipe(Effect.timeout(0)) const error = await Effect.runPromise(Effect.flip(program)) error._tag // => "TimeoutError" ``` 如果源在超时之前就失败了,它原本的错误会被保留。如果它及时成功,它的成功值会原样返回。 ```ts import { Effect } from "effect" const program = Effect.succeed("result").pipe(Effect.timeout("1 second")) Effect.runSync(program) // => "result" ``` ## timeoutOption `Effect.timeoutOption` 只把超时这一种情况表示为 `Option.none()`。及时的成功会变成 `Option.some(value)`,而来自源的类型化失败仍然留在错误通道里。 **示例**(超时时返回 None) ```ts import { Effect, Option } from "effect" const timedOut = await Effect.runPromise( Effect.never.pipe(Effect.timeoutOption(0)), ) timedOut // => Option.none() const completed = Effect.runSync( Effect.succeed("result").pipe(Effect.timeoutOption("1 second")), ) completed // => Option.some("result") ``` 只有当超时确实意味着「不存在」时,才使用这个操作符。它不会丢弃源 Effect 中普通的失败。 ## timeoutOrElse 当超时胜出时,`Effect.timeoutOrElse` 会切换到一个惰性构造的 fallback Effect。这个 fallback 可以引入它自己的成功、错误与依赖类型。 **示例**(超时时使用缓存数据) ```ts import { Effect } from "effect" const program = Effect.never.pipe( Effect.timeoutOrElse({ duration: 0, orElse: () => Effect.succeed("cached result"), }), ) await Effect.runPromise(program) // => "cached result" ``` ### 产生自定义错误 当超时应当使用领域特有的错误时,从 fallback 中返回 `Effect.fail`。 ```ts import { Data, Effect, Exit } from "effect" class RequestTimeout extends Data.TaggedError("RequestTimeout")<{ readonly endpoint: string }> {} const program = Effect.never.pipe( Effect.timeoutOrElse({ duration: 0, orElse: () => Effect.fail(new RequestTimeout({ endpoint: "/users" })), }), ) await Effect.runPromiseExit(program) // => Exit.fail(new RequestTimeout({ endpoint: "/users" })) ``` 改为从 fallback 中返回 `Effect.die` 或 `Effect.failCause(Cause.die(...))`,就会把这次超时变成一个 defect。这应当只保留给「超时意味着破坏了某个不变式」的场景。 ## 中断与不可中断的工作 当到达时长上限时,超时 fiber 会中断源。大多数 Effect 会立即响应。不可中断区域会把这次中断推迟到它重新变为可中断时,因此调用方等待的时间可能超过配置的时长。 如果某项工作被有意允许比调用方存活得更久,请通过把它 fork 到一个受到恰当监管或分离的 fiber 中,来显式地建模这种生命周期。不要仅仅为了让超时提前返回就使用分离:在调用方已经继续前进之后,被分离的工作仍在继续消耗资源。 --- # 两类错误 > 理解 Effect 中预期错误与意外 defect 之间的区别。 Effect 区分两类错误:一类是程序业务领域的一部分,另一类则是表明 bug 或不变式被破坏的意外问题。 ## 预期错误(Expected Errors) 预期错误(expected error)也称为**失败(failure)**、**类型化错误(typed error)**或**可恢复错误(recoverable error)**,它们是程序正常执行过程的一部分。例如无效输入、记录缺失或请求被拒绝。 它们会被记录在 `Effect` 的错误通道中: ```text ┌─── Success type │ ┌─── Error type │ │ ┌─── Requirements ▼ ▼ ▼ Effect ``` 这个类型让可能的失败对调用方可见,调用方可以用 `Effect.catch` 或 `Effect.catchTag` 之类的操作符从中恢复。 ## 意外错误(Unexpected Errors) 意外错误(unexpected error)也称为 **defect**,它们不属于预期的控制流。例如断言失败、不可能出现的状态,以及第三方代码中的 bug。 Defect 不会被记录在 `Effect` 的错误通道中。但运行时仍会将它们与类型化失败、fiber 中断一起保留在 effect 的 `Cause` 中。 通常不应在领域逻辑内部对 defect 做恢复处理。在应用边界处,可以使用 `Effect.exit`、`Effect.catchDefect` 或 `Effect.catchCause` 来检查或上报它们。 --- # 意外错误 > 创建、检查、上报,并选择性地从 defect 中恢复。 意外错误(unexpected error),也就是 **defect**,表示 bug、被破坏的不变式,或超出程序预期领域的失败。它们会被保留在运行时的 `Cause` 中,但不会出现在类型化的错误通道里。 Defect 通常应当被上报,并让受影响的 fiber 终止。只有在继续执行被明确证明是安全的时候,才应该在边界处从它们中恢复。 ## 创建一个 Defect(Effect.die) `Effect.die(defect)` 会创建一个以给定 defect 终止的 Effect,它的类型化错误通道是 `never`。 **示例**(在不可能出现的输入上终止) ```ts import { Effect, Exit } from "effect" const divide = (a: number, b: number) => b === 0 ? Effect.die(new Error("Cannot divide by zero")) : Effect.succeed(a / b) const exit = Effect.runSyncExit(divide(1, 0)) Exit.isFailure(exit) && exit.cause.reasons[0]?._tag // => "Die" ``` 向 `Effect.die` 传入字符串,或者更好的是传入一条带有效信息的 `Error`。 在求值 `Effect.sync` 这类 Effect 回调时抛出的异常,同样会被表示为 defect。 ## 将类型化错误转换为 Defect(Effect.orDie) `Effect.orDie` 会把每个类型化失败转换为 defect,并移除类型化错误通道。 **示例**(把一次失败当作不可恢复) ```ts import { Effect, Exit } from "effect" const program = Effect.fail(new Error("Invalid startup configuration")).pipe( Effect.orDie, ) const exit = Effect.runSyncExit(program) Exit.isFailure(exit) && exit.cause.reasons[0]?._tag // => "Die" ``` 要自定义 defect,请先用 `Effect.mapError` 转换类型化错误,再套用 `Effect.orDie`。 ```ts import { Cause, Effect, Exit, Predicate } from "effect" const program = Effect.fail("missing token").pipe( Effect.mapError((message) => new Error(`Startup failed: ${message}`)), Effect.orDie, ) const exit = Effect.runSyncExit(program) const reason = Exit.isFailure(exit) ? exit.cause.reasons[0] : undefined const message = reason !== undefined && Cause.isDieReason(reason) && Predicate.isError(reason.defect) ? reason.defect.message : undefined message // => "Startup failed: missing token" ``` ## 检查完整的 Exit(Effect.exit) `Effect.exit` 会把完整的结果移入成功通道: ```text Effect -> Effect, never, R> ``` 与 `Effect.result` 不同,`Exit` 会保留完整的 `Cause`,包括 defect 与中断。 **示例**(用 Exit 检查 defect) ```ts import { Cause, Effect, Exit } from "effect" const exit = Effect.runSync(Effect.exit(Effect.die("boom"))) const hasDefect = Exit.isFailure(exit) && Cause.hasDies(exit.cause) hasDefect // => true ``` 这在应用边界、测试中,以及与那些需要为每种结果都提供显式值的 API 集成时很有用。 ## catchDefect `Effect.catchDefect` 只处理 defect。类型化失败与中断保持原样。 **示例**(从 defect 中恢复) ```ts import { Effect, Predicate } from "effect" const program = Effect.die(new Error("plugin crashed")).pipe( Effect.catchDefect((defect) => Predicate.isError(defect) ? Effect.succeed(`disabled plugin: ${defect.message}`) : Effect.die(defect), ), ) Effect.runSync(program) // => "disabled plugin: plugin crashed" ``` ## catchCause `Effect.catchCause` 处理完整的 `Cause`,包括类型化失败、defect、中断以及多个原因。 **示例**(根据 Cause 恢复) ```ts import { Cause, Effect } from "effect" const program = Effect.die("boom").pipe( Effect.catchCause((cause) => Cause.hasDies(cause) ? Effect.succeed("recovered at the boundary") : Effect.failCause(cause), ), ) Effect.runSync(program) // => "recovered at the boundary" ``` 对于领域错误,请优先使用 `Effect.catch`、`Effect.catchTag` 这类类型化恢复操作符。只有在从意外失败中恢复是刻意且安全的时候,才应使用 `catchDefect` 或 `catchCause`。 --- # 可 yield 的错误 > 定义可以直接在 Effect.gen 中被 yield 的自定义错误。 用 `Data.Error` 和 `Data.TaggedError` 创建的错误是可 yield 的。在 [`Effect.gen`](/docs/v4/getting-started/using-generators/) 内部,yield 其中一个错误等价于把它传给 [`Effect.fail`](/docs/v4/getting-started/creating-effects/#fail)。 ## Data.Error 当错误不需要判别标签(discriminant tag)时,请使用 `Data.Error`。 **示例**(yield 一个自定义错误) ```ts import { Data, Effect, Exit } from "effect" class InvalidInput extends Data.Error<{ readonly message: string }> {} const program = Effect.gen(function* () { return yield* new InvalidInput({ message: "Name is required" }) }) Effect.runSyncExit(program) // => Exit.fail(new InvalidInput({ message: "Name is required" })) ``` ## Data.TaggedError `Data.TaggedError` 会额外添加一个只读的 `_tag` 字段。tagged error 构成可判别联合类型(discriminated union),可以用 [`Effect.catchTag`](/docs/v4/error-management/expected-errors/#catchtag) 和 [`Effect.catchTags`](/docs/v4/error-management/expected-errors/#catchtags) 精确处理。 **示例**(处理 tagged error) ```ts import { Data, Effect } from "effect" class NotFound extends Data.TaggedError("NotFound")<{ readonly id: string }> {} class PermissionDenied extends Data.TaggedError("PermissionDenied")<{ readonly id: string }> {} const loadUser = ( id: string, ): Effect.Effect => Effect.gen(function* () { if (id === "missing") { return yield* new NotFound({ id }) } return `user:${id}` }) const program = loadUser("missing").pipe( Effect.catchTag("NotFound", (error) => Effect.succeed(`No user ${error.id}`)), ) Effect.runSync(program) // => "No user missing" ``` 对于调用方可能需要区分的领域错误,请使用 tagged error。这个类既是该错误的构造函数,也是它的 TypeScript 类型。 --- # 入门 > Effect 文档导览,以及应该从哪里开始。 欢迎来到 Effect 指南。如果你是 Effect 新手,请先从 [入门引导](/docs/v4/onboarding) 开始,该章节会按顺序介绍核心概念。这些指南会更详细地 讲解每个主题,你也可以在动手构建时把它们当作参考资料使用。 ## 如何使用这些文档 文档按顺序组织,从基础开始,逐步推进到更进阶的主题。这样你就可以在构建 Effect 应用时 一步步跟着学。不过你也可以按任意顺序阅读,或者直接跳到与你具体使用场景相关的页面。 你可以使用页面顶部的版本选择器在 Effect 的不同版本之间切换。 为方便在页面内导航,屏幕右侧提供了目录。你可以借此轻松跳转到页面的不同小节。 ## 指南栏目 这些指南按领域分组: - [错误管理](/docs/v4/error-management/two-error-types) 指南涵盖带类型的错误、回退与重试。 - [并发](/docs/v4/concurrency/basic-concurrency) 指南涵盖 Fiber、有界并行与竞速。 - [资源管理](/docs/v4/resource-management/introduction) 指南讲解如何安全地获取与释放资源。 - [Schema](/docs/v4/schema/introduction) 指南涵盖数据的解析、校验与转换。 - [可观测性](/docs/v4/observability/logging) 指南涵盖日志、指标与追踪。 - [流式处理](/docs/v4/stream/introduction) 指南展示如何构建带背压的数据管道。 要查阅每个模块的完整参考,请见 [API 参考](https://effect.website/docs/v4/api)。 ## 使用 LLM 编写代码 下面这篇文章介绍了如何将 Effect 与 LLM 结合使用:[https://effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive/](https://effect.website/blog/the-one-weird-git-trick-that-makes-coding-agents-more-effect-ive/) 使用 LLM 时,将反馈回路优化到尽可能紧凑同样非常重要,其中可以包括编写契合你风格偏好与 模式的自定义 lint 规则。一个为 agentic coding 优化过的仓库示例见:[https://github.com/mikearnaldi/accountability](https://github.com/mikearnaldi/accountability) 优化反馈回路(以及总体上使用 Effect 时的开发者体验)的一个关键点是使用 Effect LSP 插件, 我们建议使用它最新的 "tsgo" 实现,见:[https://github.com/Effect-TS/tsgo](https://github.com/Effect-TS/tsgo) ## 加入我们的社区 如果你对任何与 Effect 相关的问题有疑问,欢迎加入[中文社区微信群](/community/)直接提问, 也可以在官方的 [GitHub 仓库](https://github.com/Effect-TS) 上参与讨论。 --- # 构建管道 > 学习如何在 Effect 中构建模块化、可读的管道,组合并串联操作,实现清晰高效的数据转换。 Effect 管道可以组合并串联对值的操作,让你以简洁、模块化的方式转换和处理数据。 ## 为什么管道有利于组织应用结构 管道是组织应用结构、以简洁且模块化的方式处理数据转换的绝佳方式。它带来了以下几方面好处: 1. **可读性**:管道让你以可读的、顺序化的方式组合函数。你可以清楚地看到数据的流动以及所施加的操作,从而更容易理解和维护代码。 2. **代码组织**:借助管道,你可以把复杂操作拆解为更小、更易管理的函数。每个函数只负责一项具体任务,让代码更加模块化,也更容易推理。 3. **可复用性**:管道促进函数的复用。把操作拆分为更小的函数后,你可以在不同的管道或场景中复用它们,从而提升代码复用率并减少重复。 4. **类型安全**:借助类型系统,管道有助于在编译期捕获错误。管道中的函数具有明确的输入和输出类型,确保数据正确地流经管道,并尽可能减少运行时错误。 ## 函数与方法 在 Effect 生态的库中使用函数,对于实现**可摇树优化(tree shakeability)**和确保**可扩展性(extensibility)**非常重要。函数能够通过剔除未使用的代码来实现高效打包,同时也为扩展库的功能提供了灵活、模块化的方式。 ### 可摇树优化 可摇树优化指的是构建系统在打包过程中剔除未使用代码的能力。函数是可摇树优化的,而方法不是。 在 Effect 生态中使用函数时,只有实际被导入并在应用中使用的函数才会包含在最终打包的代码里。未使用的函数会被自动移除,从而得到更小的包体积和更好的性能。 相反,方法挂载在对象或原型上,无法被轻易地摇树剔除。即使你只用到其中一部分方法,与该对象或原型关联的所有方法都会被打包进去,导致不必要的代码膨胀。 ### 可扩展性 在 Effect 生态中使用函数的另一个重要优势是易于扩展。如果使用方法,扩展已有 API 的功能通常需要修改对象的原型,这可能既复杂又容易出错。 相比之下,使用函数时扩展功能要简单得多。你可以把自定义的“扩展方法”定义为普通函数,而无需修改对象的原型。这有助于写出更清晰、更模块化的代码,也能更好地与其他库和模块兼容。 ## pipe `pipe` 是一个工具函数,让我们能够以可读、顺序化的方式组合函数。它把某个函数的输出作为输入传给管道中的下一个函数。这样我们就能通过串联多个函数来构建复杂的转换。 **语法** ```ts import { pipe } from "effect" const result = pipe(input, func1, func2, ..., funcN) ``` 在这个语法中,`input` 是初始值,`func1`、`func2`、…、`funcN` 是按顺序应用的函数。每个函数的结果会成为下一个函数的输入,最终返回最后的结果。 下面用图示说明 `pipe` 是如何工作的: ```text ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐ │ input │───►│ func1 │───►│ func2 │───►│ ... │───►│ funcN │───►│ result │ └───────┘ └───────┘ └───────┘ └───────┘ └───────┘ └────────┘ ``` 需要注意的是,传给 `pipe` 的函数必须是**单参数**的,因为它们只会以单个参数被调用。 下面通过一个例子更好地理解 `pipe` 是如何工作的: **示例**(串联算术运算) ```ts import { pipe } from "effect" // Define simple arithmetic operations const increment = (x: number) => x + 1 const double = (x: number) => x * 2 const subtractTen = (x: number) => x - 10 // Sequentially apply these operations using `pipe` const result = pipe(5, increment, double, subtractTen) console.log(result) result // => 2 ``` 在上面的例子中,我们从输入值 `5` 开始。`increment` 函数给初始值加 `1`,得到 `6`。接着 `double` 函数把值翻倍,得到 `12`。最后 `subtractTen` 函数从 `12` 中减去 `10`,最终输出 `2`。 这个结果等价于 `subtractTen(double(increment(5)))`,但使用 `pipe` 让代码更易读,因为操作是从左到右顺序书写的,而不是由内向外层层嵌套。 ## map 对 effect 内部的值应用一个函数进行转换。 **语法** ```ts const mappedEffect = pipe(myEffect, Effect.map(transformation)) // or const mappedEffect = Effect.map(myEffect, transformation) // or const mappedEffect = myEffect.pipe(Effect.map(transformation)) ``` `Effect.map` 接收一个函数,并将它应用到 effect 中包含的值上,从而创建一个带有转换后值的新 effect。 **示例**(添加服务费) 下面是一个实际例子:给交易金额加上一笔服务费。 ```ts import { pipe, Effect } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Apply service charge to the transaction amount const finalAmount = pipe(fetchTransactionAmount, Effect.map(addServiceCharge)) const result = await Effect.runPromise(finalAmount) // => 101 console.log(result) ``` ## as 用一个常量值替换 effect 内部的值。 `Effect.as` 让你可以忽略 effect 内部的原始值,并用一个新的常量值替换它。 **示例**(替换一个值) ```ts import { pipe, Effect } from "effect" // Replace the value 5 with the constant "new value" const program = pipe(Effect.succeed(5), Effect.as("new value")) const result = await Effect.runPromise(program) // => "new value" console.log(result) ``` ## flatMap 串联 effect 以产生新的 `Effect` 实例,适合组合那些依赖前一步结果的操作。 **语法** ```ts const flatMappedEffect = pipe(myEffect, Effect.flatMap(transformation)) // or const flatMappedEffect = Effect.flatMap(myEffect, transformation) // or const flatMappedEffect = myEffect.pipe(Effect.flatMap(transformation)) ``` 在上面的代码中,`transformation` 是接收一个值并返回 `Effect` 的函数,`myEffect` 是被转换的初始 `Effect`。 当你需要串联多个 effect 时,可以使用 `Effect.flatMap`,它确保每一步都产生一个新的 `Effect`,同时把可能出现的嵌套 effect 展平。 它类似于数组上使用的 `flatMap`,但专门作用于 `Effect` 实例,让你可以避免出现深层嵌套的 effect 结构。 **示例**(应用折扣) ```ts import { pipe, Effect } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Chaining the fetch and discount application using `flatMap` const finalAmount = pipe( fetchTransactionAmount, Effect.flatMap((amount) => applyDiscount(amount, 5)), ) const result = await Effect.runPromise(finalAmount) // => 95 console.log(result) ``` ### 确保所有 effect 都被考虑在内 请确保 `Effect.flatMap` 中的所有 effect 都对最终计算有所贡献。如果忽略某个 effect,可能会导致意料之外的行为: ```ts Effect.flatMap((amount) => { // This effect will be ignored Effect.sync(() => console.log(`Apply a discount to: ${amount}`)) return applyDiscount(amount, 5) }) ``` 在这个例子中,`Effect.sync` 调用被忽略了,不会影响 `applyDiscount(amount, 5)` 的结果。要正确处理 effect,请务必使用 `Effect.map`、`Effect.flatMap`、`Effect.andThen` 或 `Effect.tap` 这类函数显式地串联它们。 ## andThen 串联两个操作,其中第二个操作可以依赖第一个操作的结果。 **语法** ```ts const transformedEffect = pipe(myEffect, Effect.andThen(anotherEffect)) // or const transformedEffect = Effect.andThen(myEffect, anotherEffect) // or const transformedEffect = myEffect.pipe(Effect.andThen(anotherEffect)) ``` 当你需要按顺序运行多个操作,且第二个操作依赖第一个操作的结果时,可以使用 `andThen`。这对于组合 effect 或处理必须按顺序发生的计算很有用。 第二个操作可以是: 1. 一个 `Effect` 2. 一个返回 `Effect` 的函数(类似于 `Effect.flatMap`) 如果你只是想把结果转换为普通值(而不是包装在 `Effect`、`Promise` 之类的容器中),请改用 `Effect.map`。 **示例**(基于获取到的金额应用折扣) 下面这个例子对比了 `Effect.andThen` 与 `Effect.map`、`Effect.flatMap` 的用法: ```ts import { pipe, Effect } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Using Effect.map and Effect.flatMap const result1 = pipe( fetchTransactionAmount, Effect.map((amount) => amount * 2), Effect.flatMap((amount) => applyDiscount(amount, 5)), ) const value1 = await Effect.runPromise(result1) // => 190 console.log(value1) // Using Effect.andThen const result2 = pipe( fetchTransactionAmount, Effect.map((amount) => amount * 2), Effect.andThen((amount) => applyDiscount(amount, 5)), ) const value2 = await Effect.runPromise(result2) // => 190 console.log(value2) ``` ### Option 与 Result 搭配 andThen [Option](/docs/v4/data-types/option/#interop-with-effect) 和 [Result](/docs/v4/data-types/result/#interop-with-effect) 常用于处理可选值、缺失值或简单的错误场景。这两种类型与 `Effect.andThen` 配合得很好。在与 `Effect.andThen` 一起使用时,这些操作属于上面的第 2 种情形(一个返回 `Effect` 的函数),因为 `Option` 和 `Result` 都实现了 `Yieldable` trait,可以通过 `Effect.fromOption`/`Effect.fromResult` 转换为 `Effect`。 **示例**(使用 Option) ```ts import { pipe, Effect, Option } from "effect" // Simulated asynchronous task fetching a number from a database const fetchNumberValue = Effect.tryPromise(() => Promise.resolve(42)) // ┌─── Effect // ▼ const program = pipe( fetchNumberValue, Effect.andThen((x) => Effect.fromOption(x > 0 ? Option.some(x) : Option.none()), ), ) await Effect.runPromise(program) // => 42 ``` 你可能以为 `program` 的类型是 `Effect, UnknownError, never>`,但实际上它是 `Effect`。 这是因为 `Option` 被当作类型为 `Effect` 的 effect 处理,因此可能出现的错误会被合并为联合类型。 **示例**(使用 Result) ```ts import { pipe, Effect, Result } from "effect" // Function to parse an integer from a string that can fail const parseInteger = (input: string): Result.Result => isNaN(parseInt(input)) ? Result.fail("Invalid integer") : Result.succeed(parseInt(input)) // Simulated asynchronous task fetching a string from database const fetchStringValue = Effect.tryPromise(() => Promise.resolve("42")) // ┌─── Effect // ▼ const program = pipe( fetchStringValue, Effect.andThen((str) => Effect.fromResult(parseInteger(str))), ) await Effect.runPromise(program) // => 42 ``` 尽管你可能期望 `program` 的类型是 `Effect, UnknownError, never>`,但它实际上是 `Effect`。 这是因为 `Result` 被当作类型为 `Effect` 的 effect 处理,也就是说错误会被合并为联合类型。 ## tap 执行一个使用 effect 结果的副作用,同时不改变原始值。 当你需要执行日志记录或埋点之类的副作用,又不修改主值时,可以使用 `Effect.tap`。这在需要观察或记录某个动作,同时希望把原始值继续传给下一步时很有用。 `Effect.tap` 的工作方式与 `Effect.flatMap` 类似,但它会忽略传给它的函数的结果。前一个 effect 的值仍然可供链中的下一步使用。注意,如果这个副作用失败,整条链也会失败。 **示例**(在管道中记录日志) ```ts import { pipe, Effect, Console } from "effect" // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const finalAmount = pipe( fetchTransactionAmount, // Log the fetched transaction amount Effect.tap((amount) => Console.log(`Apply a discount to: ${amount}`)), // `amount` is still available! Effect.flatMap((amount) => applyDiscount(amount, 5)), ) const result = await Effect.runPromise(finalAmount) // => 95 console.log(result) /* Output: Apply a discount to: 100 */ ``` 在这个例子中,`Effect.tap` 用于在应用折扣前记录交易金额,而不会修改值本身。原始值(`amount`)仍然可供下一个操作(`applyDiscount`)使用。 使用 `Effect.tap` 可以让我们在计算过程中执行副作用而不改变结果。这对于日志记录、执行额外动作,或在不干扰主计算流程的前提下观察中间值都很有用。 ## all 把多个 effect 合并成一个,并根据输入结构返回结果。 当你需要运行多个 effect 并把它们的结果合并为单个输出时,可以使用 `Effect.all`。它支持元组、可迭代对象、结构体(struct)和记录(record),因此能灵活适配不同的输入类型。 例如,如果输入是一个元组: ```ts // ┌─── a tuple of effects // ▼ Effect.all([effect1, effect2, ...]) ``` 这些 effect 会按顺序执行,结果是一个包含各项结果的新 effect(以元组形式)。元组中结果的顺序与传给 `Effect.all` 的 effect 顺序一致。 默认情况下,`Effect.all` 会顺序运行 effect,并产生一个包含结果的元组或对象。如果其中任何 effect 失败,它会停止执行(短路)并传播错误。 关于 `Effect.all` 的更多用法,请参见 [Collecting](/docs/v4/code-style/control-flow/#all)。 **示例**(合并配置检查与数据库检查) ```ts import { Effect } from "effect" // Simulated function to read configuration from a file const webConfig = Effect.promise(() => Promise.resolve({ dbConnection: "localhost", port: 8080 }), ) // Simulated function to test database connectivity const checkDatabaseConnectivity = Effect.promise(() => Promise.resolve("Connected to Database"), ) // Combine both effects to perform startup checks const startupChecks = Effect.all([webConfig, checkDatabaseConnectivity]) const results = await Effect.runPromise(startupChecks) // => [{ dbConnection: "localhost", port: 8080 }, "Connected to Database"] const [config, dbStatus] = results console.log(`Configuration: ${JSON.stringify(config)}\nDB Status: ${dbStatus}`) ``` ## 构建你的第一个管道 现在让我们把 `pipe` 函数、`Effect.all` 和 `Effect.andThen` 组合起来,创建一个执行一系列转换的管道。 **示例**(构建一个交易管道) ```ts import { Effect, pipe } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Simulated asynchronous task to fetch a discount rate // from a configuration file const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) // Assembling the program using a pipeline of effects const program = pipe( // Combine both fetch effects to get the transaction amount // and discount rate Effect.all([fetchTransactionAmount, fetchDiscountRate]), // Apply the discount to the transaction amount Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate), ), // Add the service charge to the discounted amount Effect.map(addServiceCharge), // Format the final result for display Effect.map((finalAmount) => `Final amount to charge: ${finalAmount}`), ) // Execute the program and log the result const result = await Effect.runPromise(program) // => "Final amount to charge: 96" console.log(result) ``` 这个管道展示了如何通过把不同的 effect 组合成清晰、可读的流程来组织代码。 ## pipe 方法 Effect 提供了一个 `pipe` 方法,它的工作方式类似于 [rxjs](https://rxjs.dev/api/index/function/pipe) 中的 `pipe` 方法。这个方法让你可以把多个操作串联起来,使代码更简洁、更易读。 **语法** ```ts const result = effect.pipe(func1, func2, ..., funcN) ``` 它等价于这样使用 `pipe` **函数**: ```ts const result = pipe(effect, func1, func2, ..., funcN) ``` `pipe` 方法可用于所有 effect 以及许多其他数据类型,这样就不需要导入 `pipe` 函数,也能少敲一些代码。 **示例**(使用 `pipe` 方法) 这一次,我们用 `pipe` 方法来重写[前面的例子](#build-your-first-pipeline)。 ```ts import { Effect } from "effect" const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) const program = Effect.all([fetchTransactionAmount, fetchDiscountRate]).pipe( Effect.andThen(([transactionAmount, discountRate]) => applyDiscount(transactionAmount, discountRate), ), Effect.map(addServiceCharge), Effect.map((finalAmount) => `Final amount to charge: ${finalAmount}`), ) await Effect.runPromise(program) // => "Final amount to charge: 96" ``` ## 速查表 下面总结一下我们目前见到的转换函数: | API | 输入 | 输出 | | --------- | ------------------------------------------------------------ | --------------------------- | | `map` | `Effect`, `A => B` | `Effect` | | `flatMap` | `Effect`, `A => Effect` | `Effect` | | `andThen` | `Effect`, `Effect \| A => Effect` | `Effect` | | `tap` | `Effect`, `A => Effect` | `Effect` | | `all` | `[Effect, Effect, ...]` | `Effect<[A, B, ...], E, R>` | --- # 创建 Effect > 学习如何使用 Effect 的各类构造函数创建 Effect,显式处理成功、失败与副作用。 Effect 提供了多种创建 Effect 的方式,Effect 是封装副作用的计算单元。 在本指南中,我们将介绍一些常见的方法,你可以用它们来创建 Effect。 ## 为什么不抛出错误? 在传统编程中,当错误发生时,通常通过抛出异常来处理: ```ts // Type signature doesn't show possible exceptions const divide = (a: number, b: number): number => { if (b === 0) { throw new Error("Cannot divide by zero") } return a / b } divide(4, 2) // => 2 ``` 然而,抛出错误可能会带来问题。函数的类型签名并不会表明它可能抛出异常,这使得推断潜在的错误变得困难。 为了解决这个问题,Effect 引入了专门的构造函数来创建同时表示成功与失败的 Effect:`Effect.succeed` 和 `Effect.fail`。这些构造函数让你可以显式地处理成功与失败的情况,同时**利用类型系统追踪错误**。 ### succeed 创建一个总是以给定值成功的 `Effect`。 当你需要一个以特定值成功完成、并且没有任何错误或外部依赖的 Effect 时, 就使用这个函数。 **示例**(创建一个成功的 Effect) ```ts import { Effect } from "effect" // ┌─── Effect // ▼ const success = Effect.succeed(42) await Effect.runPromise(success) // => 42 ``` `success` 的类型是 `Effect`,这意味着: - 它产生一个 `number` 类型的值。 - 它不产生任何错误(`never` 表示没有错误)。 - 它不需要任何额外的数据或依赖(`never` 表示没有需求)。 ```text ┌─── Produces a value of type number │ ┌─── Does not generate any errors │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` ### fail 创建一个表示可以被恢复的错误的 `Effect`。 使用这个函数可以在 `Effect` 中显式地发出错误信号。除非被处理,否则该错误 会持续传播。你可以使用 [Effect.catch](/docs/v4/error-management/expected-errors/#catching-every-typed-error) 或 [Effect.catchTag](/docs/v4/error-management/expected-errors/#catchtag) 这类函数来处理错误。 **示例**(创建一个失败的 Effect) ```ts import { Effect, Exit } from "effect" // ┌─── Effect // ▼ const failure = Effect.fail(new Error("Operation failed due to network error")) await Effect.runPromiseExit(failure) // => Exit.fail(new Error("Operation failed due to network error")) ``` `failure` 的类型是 `Effect`,这意味着: - 它从不产生值(`never` 表示不会产生任何成功结果)。 - 它会以一个错误失败,具体来说是一个 `Error`。 - 它不需要任何额外的数据或依赖(`never` 表示没有需求)。 ```text ┌─── Never produces a value │ ┌─── Fails with an Error │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` 虽然你可以在 `Effect.fail` 中使用 `Error` 对象,但也可以根据你的错误管理策略传递字符串、数字或更复杂的对象。 使用「带标签的」(tagged)错误(含有 `_tag` 字段的对象)有助于识别错误类型,并且能与标准的 Effect 函数(例如 [Effect.catchTag](/docs/v4/error-management/expected-errors/#catchtag))很好地配合。 **示例**(使用带标签的错误) ```ts import { Effect, Data, Exit } from "effect" class HttpError extends Data.TaggedError("HttpError")<{}> {} // ┌─── Effect // ▼ const program = Effect.fail(new HttpError()) await Effect.runPromiseExit(program) // => Exit.fail(new HttpError()) ``` ## 错误追踪 借助 `Effect.succeed` 和 `Effect.fail`,你可以显式地处理成功与失败的情况,类型系统会确保错误被追踪并得到处理。 **示例**(重写一个除法函数) 下面展示了如何用 Effect 重写 [`divide`](#why-not-throw-errors) 函数,让错误处理变得显式。 ```ts import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) Effect.runSync(divide(4, 2)) // => 2 ``` 在这个例子中,`divide` 函数在其返回类型 `Effect` 中表明:该操作既可能以 `number` 成功,也可能以 `Error` 失败。 ```text ┌─── Produces a value of type number │ ┌─── Fails with an Error ▼ ▼ Effect ``` 这种清晰的类型签名有助于确保错误得到妥善处理,也让每个调用该函数的人都清楚可能的结果。 **示例**(模拟一次用户查询操作) 再设想另一个场景:我们用 `Effect.succeed` 和 `Effect.fail` 为一个简单的用户查询操作建模,其中的用户数据是硬编码的,这在测试场景或需要模拟数据时会很有用: ```ts import { Effect } from "effect" // Define a User type interface User { readonly id: number readonly name: string } // A mocked function to simulate fetching a user from a database const getUser = (userId: number): Effect.Effect => { // Normally, you would access a database or API here, but we'll mock it const userDatabase: Record = { 1: { id: 1, name: "John Doe" }, 2: { id: 2, name: "Jane Smith" }, } // Check if the user exists in our "database" and return appropriately const user = userDatabase[userId] if (user) { return Effect.succeed(user) } else { return Effect.fail(new Error("User not found")) } } // When executed, this will successfully return the user with id 1 const exampleUserEffect = getUser(1) Effect.runSync(exampleUserEffect).name // => "John Doe" ``` 在这个例子中,`exampleUserEffect` 的类型是 `Effect`,它会根据模拟数据库中是否存在该用户,产生一个 `User` 对象或者一个 `Error`。 如果想更深入地了解如何在应用中管理错误,请参阅[错误管理指南](/docs/v4/error-management/expected-errors/)。 ## 为同步 Effect 建模 在 JavaScript 中,你可以使用「thunk」来延迟同步计算的执行。 Thunk 对于把值的计算推迟到真正需要它的时候很有用。 为了给同步副作用建模,Effect 提供了 `Effect.sync` 和 `Effect.try` 构造函数,它们都接受一个 thunk。 ### sync 创建一个表示同步且带副作用的计算的 `Effect`。 当你确信操作不会失败时,使用 `Effect.sync`。 提供的函数(`thunk`)不得抛出错误;如果它抛出了错误,该错误会被视为[「缺陷」(defect)](/docs/v4/error-management/unexpected-errors/)。 这个缺陷并不是普通的错误,而是表明本应无错的逻辑中存在缺陷。 你可以把它类比为程序中意料之外的崩溃,可以用 [Effect.catchDefect](/docs/v4/error-management/unexpected-errors/#catchdefect) 这类工具进一步管理或记录它。 这一特性确保即使应用中出现了意料之外的失败也不会被忽略,而是能够得到妥善处理。 **示例**(记录一条消息) 在下面的例子中,`Effect.sync` 被用来延迟向控制台写入这一副作用。 ```ts import { Effect } from "effect" const log = (message: string) => Effect.sync(() => { console.log(message) // side effect }) // ┌─── Effect // ▼ const program = log("Hello, World!") Effect.runSync(program) // => undefined ``` 封装在 `program` 中的副作用(向控制台记录日志)只有在 Effect 被显式运行后才会发生(更多细节参见[运行 Effect](/docs/v4/getting-started/running-effects/)一节)。这让你可以在代码的某一处定义副作用,并掌控它们何时被激活,从而提升大型应用中副作用的可管理性和可预测性。 ### try 创建一个表示可能失败的同步计算的 `Effect`。 当你需要执行可能失败的同步操作(例如解析 JSON)时,可以使用 `Effect.try` 构造函数。 这个构造函数专为处理可能抛出异常的操作而设计:它会捕获这些异常,并把它们转换成可管理的错误。 **示例**(安全的 JSON 解析) 假设你有一个尝试解析 JSON 字符串的函数。如果输入的字符串不是正确的 JSON 格式,这个操作就可能失败并抛出错误: ```ts import { Effect } from "effect" const parse = (input: string) => // This might throw an error if input is not valid JSON Effect.try(() => JSON.parse(input)) // ┌─── Effect // ▼ const program = parse("") const error = await Effect.runPromise(Effect.flip(program)) error.message // => "An error occurred in Effect.try" ``` 在这个例子中: - `parse` 是一个函数,它创建了一个封装 JSON 解析操作的 Effect。 - 如果 `JSON.parse(input)` 因输入非法而抛出错误,`Effect.try` 会捕获这个错误,`program` 所表示的 Effect 将以 `UnknownError` 失败。这确保错误不会被悄无声息地忽略,而是在结构化的 Effect 流程中得到处理。 #### 自定义错误处理 你可能想把捕获到的异常转换成一个更具体的错误,或者在捕获错误时执行额外的操作。`Effect.try` 支持一个重载,允许你指定捕获到的异常应如何转换: **示例**(自定义错误处理) ```ts import { Effect } from "effect" const parse = (input: string) => Effect.try({ // JSON.parse may throw for bad input try: () => JSON.parse(input), // remap the error catch: (unknown) => new Error(`something went wrong ${unknown}`), }) // ┌─── Effect // ▼ const program = parse("") const error = await Effect.runPromise(Effect.flip(program)) error.message // => "something went wrong SyntaxError: Unexpected end of JSON input" ``` 你可以把它看作与 JavaScript 中传统的 try-catch 代码块类似的一种模式: ```ts try { return JSON.parse(input) } catch (unknown) { throw new Error(`something went wrong ${unknown}`) } ``` ## 为异步 Effect 建模 在传统编程中,我们经常使用 `Promise` 来处理异步计算。然而,处理 Promise 中的错误可能会很麻烦。默认情况下,`Promise` 只为已解析的值提供类型 `Value`,这意味着错误不会反映在类型系统中。这限制了表达力,也让有效处理与追踪错误变得困难。 为了克服这些限制,Effect 引入了专门的构造函数来创建在异步上下文中同时表示成功与失败的 Effect:`Effect.promise` 和 `Effect.tryPromise`。这些构造函数让你可以显式地处理成功与失败的情况,同时**利用类型系统追踪错误**。 ### promise 创建一个表示保证成功的异步计算的 `Effect`。 当你确信操作不会 reject 时,使用 `Effect.promise`。 提供的函数(`thunk`)返回一个绝不应 reject 的 `Promise`;如果它 reject 了,该错误会被视为[「缺陷」(defect)](/docs/v4/error-management/unexpected-errors/)。 这个缺陷并不是普通的错误,而是表明本应无错的逻辑中存在缺陷。 你可以把它类比为程序中意料之外的崩溃,可以用 [Effect.catchDefect](/docs/v4/error-management/unexpected-errors/#catchdefect) 这类工具进一步管理或记录它。 这一特性确保即使应用中出现了意料之外的失败也不会被忽略,而是能够得到妥善处理。 **示例**(延迟消息) ```ts import { Effect } from "effect" const delay = (message: string) => Effect.promise( () => new Promise((resolve) => { setTimeout(() => { resolve(message) }, 2000) }), ) // ┌─── Effect // ▼ const program = delay("Async operation completed successfully!") await Effect.runPromise(program) // => "Async operation completed successfully!" ``` `program` 值的类型是 `Effect`,可以把它理解为一个满足以下条件的 Effect: - 以 `string` 类型的值成功 - 不产生任何预期错误(`never`) - 不需要任何上下文(`never`) ### tryPromise 创建一个表示可能失败的异步计算的 `Effect`。 与 `Effect.promise` 不同,当底层的 `Promise` 可能 reject 时,适合使用这个构造函数。 它提供了一种捕获错误并妥善处理的方式。 默认情况下,如果发生错误,它会被捕获并作为 `UnknownError` 传播到错误通道。 **示例**(获取一条 TODO 待办项) ```ts import { Effect } from "effect" const getTodo = (id: number) => // Will catch any errors and propagate them as UnknownError Effect.tryPromise(() => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`), ) // ┌─── Effect // ▼ const program = getTodo(1) Effect.isEffect(program) // => true ``` `program` 值的类型是 `Effect`,可以把它理解为一个满足以下条件的 Effect: - 以 `Response` 类型的值成功 - 可能产生错误(`UnknownError`) - 不需要任何上下文(`never`) #### 自定义错误处理 如果你想更好地控制哪些内容会被传播到错误通道,可以使用 `Effect.tryPromise` 的一个接受重映射函数的重载: **示例**(自定义错误处理) ```ts import { Effect } from "effect" const getTodo = (id: number) => Effect.tryPromise({ try: () => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`), // remap the error catch: (unknown) => new Error(`something went wrong ${unknown}`), }) // ┌─── Effect // ▼ const program = getTodo(1) Effect.isEffect(program) // => true ``` ## 从回调函数创建 从基于回调的异步函数创建一个 `Effect`。 有时你必须使用那些不支持 `async/await` 或 `Promise`、而是采用回调风格的 API。 为了处理基于回调的 API,Effect 提供了 `Effect.callback` 构造函数。 **示例**(包装一个回调式 API) 下面把 Node.js `fs` 模块中的 `readFile` 函数包装成基于 Effect 的 API(请确保已安装 `@types/node`): ```ts import { Effect } from "effect" import * as NodeFS from "node:fs" const readFile = (filename: string) => Effect.callback((resume) => { NodeFS.readFile(filename, (error, data) => { if (error) { // Resume with a failed Effect if an error occurs resume(Effect.fail(error)) } else { // Resume with a succeeded Effect if successful resume(Effect.succeed(data)) } }) }) // ┌─── Effect // ▼ const program = readFile("example.txt") const error = await Effect.runPromise(Effect.flip(program)) error.message // => "ENOENT: no such file or directory, open 'example.txt'" ``` 在上面的例子中,我们在调用 `Effect.callback` 时手动标注了类型: ```ts Effect.callback((resume) => { // ... }) ``` 因为 TypeScript 无法根据回调体内的返回值推断出回调的类型参数。标注类型可以确保传给 `resume` 的值与期望的类型一致。 `Effect.callback` 中的 `resume` 函数应当恰好被调用一次。如果调用多次,多余的调用会被忽略。 **示例**(忽略后续的 `resume` 调用) ```ts import { Effect } from "effect" const program = Effect.callback((resume) => { resume(Effect.succeed(1)) resume(Effect.succeed(2)) // This line will be ignored }) // Run the program const result = await Effect.runPromise(program) // => 1 console.log(result) ``` ### 进阶用法 对于更进阶的用法,传给 Effect.callback 的回调可以返回一个 Effect:当运行这个 Effect 的 Fiber 被中断时,返回的 Effect 就会被执行。你可以用它来在操作被取消时执行清理。 **示例**(通过清理处理中断) 在这个例子中: - `writeFileWithCleanup` 函数把数据写入一个文件。 - 如果运行这个 Effect 的 Fiber 被中断,清理 Effect(删除该文件)就会被执行。 - 这确保在操作被取消时,打开的文件句柄这类资源会被妥善清理。 ```ts import { Effect, Fiber } from "effect" import * as NodeFS from "node:fs" // Simulates a long-running operation to write to a file const writeFileWithCleanup = (filename: string, data: string) => Effect.callback((resume) => { const writeStream = NodeFS.createWriteStream(filename) // Start writing data to the file writeStream.write(data) // When the stream is finished, resume with success writeStream.on("finish", () => resume(Effect.void)) // In case of an error during writing, resume with failure writeStream.on("error", (err) => resume(Effect.fail(err))) // Handle interruption by returning a cleanup effect return Effect.sync(() => { console.log(`Cleaning up ${filename}`) NodeFS.unlinkSync(filename) }) }) const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild( writeFileWithCleanup("example.txt", "Some long data..."), ) // Simulate interrupting the fiber after 1 second yield* Effect.sleep("1 second") yield* Fiber.interrupt(fiber) // This will trigger the cleanup }) // Run the program Effect.runPromise(program) /* Output: Cleaning up example.txt */ ``` 如果你包装的操作支持中断,`resume` 函数可以接收一个 `AbortSignal`,从而直接处理中断请求。 **示例**(使用 `AbortSignal` 处理中断) ```ts import { Effect, Fiber } from "effect" // A task that supports interruption using AbortSignal const interruptibleTask = Effect.callback((resume, signal) => { // Simulate a long-running task const timeoutId = setTimeout(() => { console.log("Operation completed") resume(Effect.void) }, 2000) // Handle interruption signal.addEventListener("abort", () => { console.log("Abort signal received") clearTimeout(timeoutId) }) }) const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(interruptibleTask) // Simulate interrupting the fiber after 1 second yield* Effect.sleep("1 second") yield* Fiber.interrupt(fiber) }) // Run the program await Effect.runPromise(program) // => undefined /* Output: Abort signal received */ ``` ## 挂起的 Effect `Effect.suspend` 用于延迟一个 Effect 的创建。 它允许你把 Effect 的求值推迟到真正需要它的时候。 `Effect.suspend` 函数接受一个表示该 Effect 的 thunk,并把它包装成一个挂起的 Effect。 **语法** ```ts const suspendedEffect = Effect.suspend(() => effect) ``` 下面来看看 `Effect.suspend` 特别有用的一些常见场景。 ### 惰性求值 当你想把 Effect 的求值推迟到需要它的时候。这对于优化 Effect 的执行很有用,尤其是当它们并不总是被用到、或者计算开销很大时。 另外,当创建带副作用或带作用域捕获的 Effect 时,使用 `Effect.suspend` 可以让它在每次调用时重新执行。 **示例**(带副作用的惰性求值) ```ts import { Effect } from "effect" let i = 0 const bad = Effect.succeed(i++) const good = Effect.suspend(() => Effect.succeed(i++)) const bad1 = Effect.runSync(bad) console.log(bad1) bad1 // => 0 const bad2 = Effect.runSync(bad) console.log(bad2) bad2 // => 0 const good1 = Effect.runSync(good) console.log(good1) good1 // => 1 const good2 = Effect.runSync(good) console.log(good2) good2 // => 2 ``` 在这个例子中,`bad` 是调用一次 `Effect.succeed(i++)` 的结果,它会递增作用域变量,但[返回的是它原来的值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Increment#postfix_increment)。`Effect.runSync(bad)` 不会带来任何新的计算,因为 `Effect.succeed(i++)` 已经被调用过了。另一方面,每次调用 `Effect.runSync(good)` 时,传给 `Effect.suspend()` 的 thunk 都会被执行,输出作用域变量最新的值。 ### 处理循环依赖 `Effect.suspend` 有助于管理 Effect 之间的循环依赖,即一个 Effect 依赖另一个 Effect,反之亦然。 例如,在递归函数中使用 `Effect.suspend` 来逃逸一次急切调用(eager call)是相当常见的做法。 **示例**(递归斐波那契) ```ts import { Effect } from "effect" const blowsUp = (n: number): Effect.Effect => n < 2 ? Effect.succeed(1) : Effect.zipWith(blowsUp(n - 1), blowsUp(n - 2), (a, b) => a + b) // console.log(Effect.runSync(blowsUp(32))) // crash: JavaScript heap out of memory const allGood = (n: number): Effect.Effect => n < 2 ? Effect.succeed(1) : Effect.zipWith( Effect.suspend(() => allGood(n - 1)), Effect.suspend(() => allGood(n - 2)), (a, b) => a + b, ) console.log(Effect.runSync(allGood(32))) // Output: 3524578 ``` `blowsUp` 函数在没有延迟执行的情况下创建了一个递归的斐波那契数列。每次调用 `blowsUp` 都会立即触发更多递归调用,迅速增大 JavaScript 调用栈的规模。 相反,`allGood` 通过使用 `Effect.suspend` 延迟递归调用来避免栈溢出。这个机制不会立即执行递归的 Effect,而是把它们安排到稍后运行,从而让调用栈保持较浅,避免崩溃。 ### 统一返回类型 在 TypeScript 难以统一返回的 Effect 类型的情况下,可以使用 `Effect.suspend` 来解决这个问题。 **示例**(借助 `Effect.suspend` 帮助 TypeScript 推断类型) ```ts import { Effect, Exit } from "effect" /* Without suspend, TypeScript may struggle with type inference. Inferred type: (a: number, b: number) => Effect | Effect */ const withoutSuspend = (a: number, b: number) => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) /* Using suspend to unify return types. Inferred type: (a: number, b: number) => Effect */ const withSuspend = (a: number, b: number) => Effect.suspend(() => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b), ) await Effect.runPromise(withSuspend(4, 2)) // => 2 await Effect.runPromiseExit(withSuspend(4, 0)) // => Exit.fail(new Error("Cannot divide by zero")) ``` ## 速查表 下表汇总了可用的构造函数及其输入与输出类型,方便你根据需求选择合适的函数。 | API | 给定 | 结果 | | ----------------------- | ---------------------------------- | ------------------------- | | `succeed` | `A` | `Effect` | | `fail` | `E` | `Effect` | | `sync` | `() => A` | `Effect` | | `try` | `() => A` | `Effect` | | `try` (overload) | `() => A`, `unknown => E` | `Effect` | | `promise` | `() => Promise` | `Effect` | | `tryPromise` | `() => Promise` | `Effect` | | `tryPromise` (overload) | `() => Promise`, `unknown => E` | `Effect` | | `callback` | `(Effect => void) => void` | `Effect` | | `suspend` | `() => Effect` | `Effect` | 完整的构造函数列表请访问 [Effect 构造函数文档](https://effect.website/docs/v4/api/effect/Effect#category-constructors)。 --- # 开发者工具 > 配置 Effect Language Service、Oxlint 与编辑器扩展,获得实时诊断、智能补全与重构。 Effect 提供了一系列强大的开发工具,用来提升你的编码体验,帮助你编写更安全、更易维护的代码。这些工具会直接集成到你的编辑器中,为你和你的 agent 提供实时反馈、智能重构以及有用的诊断信息。 ## Effect LSP (`@effect/tsgo`) Effect Language Service 用 Effect 专属功能扩展了你的编辑器。它会分析你的 Effect 代码,并通过诊断、快速信息、代码补全和自动重构来提供智能辅助。 该语言服务以 `@effect/tsgo` 的形式发布:它是 [TypeScript-Go](https://github.com/microsoft/TypeScript-Go)(新的基于 Go 的 TypeScript 编译器)的一个构建版本,并内置了 Effect 诊断层。`@effect/tsgo` 以 CLI 的形式运行,用于完成这个修改版 TypeScript-Go 的安装。 它可以在支持标准 TypeScript LSP 的编辑器中工作,例如 Code、Cursor、Zed、NVim 等。 ### 安装 在你的项目中设置 `@effect/tsgo` 最快的方式是使用交互式 CLI: ```sh npx @effect/tsgo setup ``` 它会引导你添加依赖、配置 `tsconfig.json`、调整插件选项,以及完成激活 LSP 所需的任何编辑器配置。 你也可以让 LLM agent 直接执行安装,只需把它指向 CLI 的设置与安装 readme: ```sh Install and enable https://github.com/Effect-TS/tsgo in this project using npx @effect/tsgo setup --help ``` 如果你想改为手动设置: 1. 将包安装为开发依赖: 对于 monorepo,我们建议在根目录层级安装。对于单包项目,请在包目录中安装。 ```sh npm install @effect/tsgo --save-dev ``` ```sh pnpm add -D @effect/tsgo ``` ```sh yarn add --dev @effect/tsgo ``` ```sh bun add --dev @effect/tsgo ``` `@effect/tsgo` 还需要安装原生 TypeScript 7:`typescript` 版本 7 或更高,例如 `typescript@latest` 或 `typescript@next`,或者使用像 `@typescript/native` 这样的别名。 2. 将插件添加到你的 `tsconfig.json`(尽管包名是 `@effect/tsgo`,插件的 `name` 仍然保持为 `@effect/language-service`): ```json { "compilerOptions": { "plugins": [ { "name": "@effect/language-service" } ] } } ``` 3. 将以下脚本添加到你的 `package.json`,以确保在重启后安装的是修改后的 TypeScript 版本: ```json { "scripts": { "prepare": "effect-tsgo patch" } } ``` 然后运行你的包管理器的安装命令 ```sh npm install ``` ```sh pnpm install ``` ```sh yarn install ``` ```sh bun install ``` 4. 确保你的编辑器使用工作区中的 TypeScript 版本: 这一步对语言服务能否正常工作至关重要。插件必须运行在你项目所安装的 TypeScript 版本上,而不是编辑器内置的版本。 5. 现在你可以开始上手了! 在你的项目中新建一个 file.ts 并写入以下代码,应该会出现一条错误诊断,提示 Effect 必须被 yield 或赋值给变量: ```ts import { Effect } from "effect" Effect.log("Hello world!") // ^- should be run or assigned to a variable! ``` ### 功能特性 Effect Language Service 提供了一整套完善的功能,用来提升你的开发工作流: #### 智能快速信息 将鼠标悬停在 Effect 值上,即可查看扩展的类型信息和详细的洞察: - **Effect 类型**:查看 Effect 值的完整类型信息 - **Generator 参数**:在 `Effect.gen` 中悬停在 `yield*` 上时,查看所 yield 值的详细信息 - **Layer 组合**:借助交互式图表可视化 Layer 依赖,展示各个 Layer 是如何组合在一起的 - **Service 依赖**:一眼看清服务的需求以及它们之间的依赖关系 #### 实时诊断 在编写代码时及时发现常见错误和潜在问题: - **游离的 Effect**:检测未被赋值或未 yield 的 Effect 值,避免出现静默 bug - **Layer 问题**:在运行前捕获 Layer 需求泄漏和作用域违规 - **不必要的代码**:识别多余的 `Effect.gen` 或 `pipe()` 调用 - **错误处理**:检测在不会失败的 Effect 上误用 catch 函数的情况 - **版本冲突**:检测项目中是否存在多个 Effect 版本 #### 智能补全 借助上下文感知的建议加快编码速度: - **Generator 样板代码**:快速生成 `Effect.gen` 函数骨架 - **脚手架**:用于 `Context.Service`、`Data.TaggedError` 以及类似结构。 - **Self 参数**:为服务声明中的 `Self` 参数提供自动补全 #### 强大的重构 通过智能自动重构来改造你的代码: - **Async 转 Effect**:使用 `gen` 或 `fn` 语法将 async 函数转换为 Effect - **错误生成**:从基于 Promise 的代码生成 tagged error - **Service 访问器**:自动实现服务访问器函数 - **Pipe 转换**:将函数调用转换为 pipe 语法 - **Pipe 风格**:在不同的 pipe 风格格式之间切换 - **Layer 魔法**:自动按正确的依赖关系组合 Layer ### 配置 Effect LSP 还提供了大量配置选项,例如修改严重级别或禁用某些诊断消息。 要查看完整的选项与功能列表,请访问 [tsgo 仓库的 README](https://github.com/Effect-TS/tsgo)。 ### 构建时诊断 LSP 只在编辑会话期间生效,而你可能希望在构建过程中也能捕获诊断信息。 安装 `@effect/tsgo` 后,Effect 诊断会被当作普通的 TypeScript 诊断来处理,因此你的 LLM agent 可以轻松访问和读取它们。 ## Oxlint 请确保同时安装了 Oxlint 和 Effect TypeScript-Go 集成。以下命令会安装两者的最新版本: ```sh npm install @effect/tsgo oxlint oxlint-tsgolint --save-dev ``` ```sh pnpm add -D @effect/tsgo oxlint oxlint-tsgolint ``` ```sh yarn add --dev @effect/tsgo oxlint oxlint-tsgolint ``` ```sh bun add --dev @effect/tsgo oxlint oxlint-tsgolint ``` 更新 `package.json` 的 scripts 部分,加入以下内容: ```json { "scripts": { "prepare": "effect-tsgo patch --oxlint" } } ``` 这样在每次安装依赖后,都会为 Oxlint 打补丁,使其使用 Effect TypeScript-Go 集成。如果不想修改 TypeScript,可以使用 `--no-typescript` 标志:`effect-tsgo patch --no-typescript --oxlint`。这样只会为 Oxlint 打补丁以使用 Effect TypeScript-Go 集成,而不会修改 TypeScript。 如果你同时启用了 Effect LSP,我们建议在 LSP 插件设置中将 `diagnostics` 设为 `false`,这样 Effect 诊断只会由 Oxlint 报告,不会重复出现: ```json { "compilerOptions": { "plugins": [ { "name": "@effect/language-service", "diagnostics": false } ] } } ``` 运行你的包管理器的安装命令来安装依赖,并执行用于为 Oxlint 打补丁的 prepare 脚本。 ```sh npm install ``` ```sh pnpm install ``` ```sh yarn install ``` ```sh bun install ``` Effect 规则需要 Oxlint 的类型感知模式以及 `effecttsgo` 插件。推荐的 preset 会同时启用这两者,并配置推荐的 Effect 规则。使用 `@effect/tsgo` 附带的 schema 来获得校验和补全: ```json { "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", "extends": ["./node_modules/@effect/tsgo/oxlint-presets/recommended.json"] } ``` 也可以通过 .ts 形式的 Oxlint/Vite Plus 配置文件启用规则,preset 可以通过以下方式获取: ```ts import { recommended } from "@effect/tsgo/oxlint-presets" import { defineConfig } from "oxlint" export default defineConfig({ extends: [recommended], }) ``` ## Vite Plus Vite Plus 在内部自带了一份捆绑的 Oxlint 和 Oxlint-TSGoLint。 请确保已安装 Effect TypeScript-Go 集成: ```sh npm install @effect/tsgo --save-dev ``` ```sh pnpm add -D @effect/tsgo ``` ```sh yarn add --dev @effect/tsgo ``` ```sh bun add --dev @effect/tsgo ``` 更新 `package.json` 的 scripts 部分,加入以下内容: ```json { "scripts": { "prepare": "effect-tsgo patch --oxlint" } } ``` 这样在每次安装依赖后,都会为 Vite Plus 捆绑的 Oxlint 打补丁,使其使用 Effect TypeScript-Go 集成。如果不想修改 TypeScript,可以使用 `--no-typescript` 标志:`effect-tsgo patch --no-typescript --oxlint`。这样只会为 Vite Plus 的 Oxlint 打补丁以使用 Effect TypeScript-Go 集成,而不会修改 TypeScript。 如果你同时启用了 Effect LSP,我们建议在 LSP 插件设置中将 `diagnostics` 设为 `false`,这样 Effect 诊断只会由 Vite Plus 的 Oxlint 报告,不会重复出现: ```json { "compilerOptions": { "plugins": [ { "name": "@effect/language-service", "diagnostics": false } ] } } ``` 运行你的包管理器的安装命令来安装依赖,并执行用于为 Oxlint 打补丁的 prepare 脚本。 ```sh npm install ``` ```sh pnpm install ``` ```sh yarn install ``` ```sh bun install ``` 现在你可以在 Vite Plus 配置文件中启用 Effect 规则,例如通过 extends 推荐的 preset: ```ts import { defineConfig } from "vite-plus" import { recommended } from "@effect/tsgo/oxlint-presets" // <- add import to recommended settings export default defineConfig({ lint: { extends: [recommended], // <- add extends recommended ones // ... }, // ... }) ``` ## VS Code / Cursor 扩展 编辑器扩展提供了一些实用工具,帮助你调试 Effect 应用。 目前只支持 Code 以及像 Cursor 这样的 Code 分支。 ### 安装 你可以在编辑器的扩展页面中直接搜索安装该扩展,也可以从 [Code Marketplace](https://marketplace.visualstudio.com/items?itemName=effectful-tech.effect-vscode) 或 [Open VSX Marketplace](https://open-vsx.org/extension/effectful-tech/effect-vscode) 安装。 ### 调试器功能 使用 Effect 扩展后,你会在编辑器的 Debug 区域中看到几个新的小节,当你暂停执行时,它们会显示相关信息。 - **Context**:允许你查看当前暂停的 Effect Fiber 的上下文。 - **Span Stack**:显示引导你进入当前暂停的 Effect 执行的遥测 span 堆栈。 - **Fibers**:列出应用中正在运行的所有 Effect Fiber,允许你查看诸如可中断性等信息,并允许请求中断它们。 - **Breakpoints**:启用 “pause on defect”,让调试器在某个 Effect fiber 因 defect 而失败时暂停。 --- # 导入 Effect > 安装 effect 包并导入核心模块与函数,快速开始构建类型安全的 TypeScript 应用。 如果你刚刚开始接触 Effect,可能会被它提供的众多模块和函数弄得不知所措。 不过请放心,你并不需要立刻把它们全部弄清楚。 本页将简要介绍如何导入模块与函数,并说明:通常只要安装 `effect` 包,就足以开始使用 Effect 了。 ## 安装 Effect 如果你还没有安装 `effect` 包,可以在终端中运行以下命令来安装: ```sh npm install effect@rc ``` ```sh pnpm add effect@rc ``` ```sh yarn add effect@rc ``` ```sh bun add effect@rc ``` ```sh deno add npm:effect@rc ``` 安装这个包之后,你就能使用 Effect 的核心功能了。 关于 Deno 或 Bun 等平台的具体安装步骤,请参阅[安装](/docs/v4/getting-started/installation/)指南,其中提供了逐步指导。 ## 导入模块与函数 安装好 `effect` 包之后,你就可以在项目中开始使用它的模块和函数了。 导入模块和函数的方式很直观,遵循标准的 JavaScript/TypeScript 导入语法。 要从 `effect` 包中导入一个模块或函数,只需在文件顶部使用 `import` 语句即可。下面是导入 `Effect` 模块的写法: ```ts import { Effect } from "effect" typeof Effect.succeed // => "function" ``` 现在你就可以使用 Effect 模块了,它是 Effect 库的核心,提供了各种用于创建、组合和操作 effectful 计算的函数。 ## 命名空间导入 除了像前面那样使用命名导入来导入 `Effect` 模块: ```ts import { Effect } from "effect" typeof Effect.succeed // => "function" ``` 你也可以像这样使用命名空间导入: ```ts import * as Effect from "effect/Effect" typeof Effect.succeed // => "function" ``` 这两种导入方式都能让你访问 `Effect` 模块提供的功能。 不过有一个重要的考量:**tree shaking**,它指的是在打包应用的过程中消除未使用代码的过程。 当打包器不支持深层作用域分析时,命名导入可能会引发 tree shaking 问题。 以下这些打包器支持深层作用域分析,因此使用命名导入不会有问题: - Rolldown - Rollup - Webpack 5+ ## 函数与方法 在 Effect 生态中,库通常暴露函数而不是方法。这一设计选择有两个关键原因:便于 tree shaking,以及易于扩展。 ### 可 Tree Shaking 性 可 Tree Shaking 性指的是构建系统在打包过程中消除未使用代码的能力。函数可以被 tree shaking,而方法不行。 在 Effect 生态中使用函数时,只有那些真正被导入并在应用中使用到的函数才会被打进最终的产物中。未使用的函数会被自动移除,从而减小打包体积并提升性能。 另一方面,方法依附于对象或原型,无法轻易被 tree shaking 掉。即使你只用到其中一部分方法,与某个对象或原型关联的所有方法都会被包含进打包结果中,导致不必要的代码膨胀。 ### 可扩展性 在 Effect 生态中使用函数的另一个重要优势是易于扩展。如果使用方法,要扩展某个已有 API 的功能,往往需要修改对象的原型,这既复杂又容易出错。 相比之下,使用函数时扩展功能要简单得多。你可以把自定义的「扩展方法」定义为普通的函数,而不必修改对象的原型。这有助于写出更清晰、更模块化的代码,也能更好地与其他库和模块兼容。 ## 常用函数 在开始 Effect 之旅时,你不需要立刻钻研 `effect` 包中的每一个函数。相反,可以先专注于一些常用函数,它们会为你走进 Effect 世界打下坚实的基础。 在接下来的指南中,我们会探讨其中一些必不可少的函数,特别是用于创建和运行 `Effect`、以及构建管道的函数。 但在深入这些内容之前,让我们从 Effect 最核心的部分开始:理解 `Effect` 类型。这将为你理解 Effect 如何为你的应用带来可组合性、类型安全和错误处理打下基础。 那么,让我们迈出第一步,一起探索 [Effect 类型](/docs/v4/getting-started/the-effect-type/)的基本概念。 --- # 安装 > 在 Node.js、Deno、Bun 与 Vite + React 中搭建并验证 Effect v4 项目的完整指南。 环境要求: - TypeScript 5.9 或更高版本。推荐使用 TypeScript 7,以获得最佳性能,并更好地兼容 [Effect 的 TypeScript 工具链](/docs/v4/getting-started/devtools/)。 - 支持 Node.js 22.18 或更高版本,以及 Deno 和 Bun。 ## 手动安装 ### JavaScript 运行时 按照以下步骤,为 [Node.js](https://nodejs.org/)、[Bun](https://bun.sh/) 或 [Deno](https://deno.com/) 创建一个新的 Effect 项目: 1. 创建项目目录并进入该目录: ```sh mkdir hello-effect cd hello-effect ``` 2. 初始化 TypeScript 项目: ```sh npm init -y npm install --save-dev typescript ``` ```sh pnpm init pnpm add --save-dev typescript ``` ```sh yarn init -y yarn add --dev typescript ``` ```sh bun init ``` ```sh deno init ``` 这会创建一个 `package.json` 文件,作为 TypeScript 项目的初始配置。对于 Bun,这还会生成一个 `tsconfig.json` 文件;对于 Deno,则会生成一个 `deno.json` 文件。 请确保 `package.json` 文件中包含 `"type": "module"` 字段,这样 Node.js 就会把你的源文件视为 ES 模块(`bun init` 会自动添加该字段): ```json { "type": "module" } ``` 3. 初始化 TypeScript: ```sh npx tsc --init ``` ```sh pnpm tsc --init ``` ```sh yarn tsc --init ``` `bun init` 已经生成了 `tsconfig.json` 文件。 Deno 开箱即可运行 TypeScript,并且 `deno init` 已经生成了 `deno.json` 文件,其中默认启用了 `strict` 模式,无需再做其他配置。 运行该命令后,会生成一个包含 TypeScript 配置选项的 `tsconfig.json` 文件。其中最需要关注的选项之一就是 `strict` 标志。 请打开 `tsconfig.json` 文件,确认 `strict` 选项的值已设置为 `true`。 ```json { "compilerOptions": { "strict": true } } ``` 4. 将所需的包安装为依赖项: ```sh npm install effect@rc ``` ```sh pnpm add effect@rc ``` ```sh yarn add effect@rc ``` ```sh bun add effect@rc ``` ```sh deno add npm:effect@rc ``` 这个包将为你的 Effect 项目提供基础功能。 接下来,我们编写并运行一个简单的程序,以确保一切配置正确。 在终端中执行以下命令: ```sh mkdir src touch src/index.ts ``` 打开 `src/index.ts` 文件并添加以下代码: ```ts import { Effect, Console } from "effect" const program = Console.log("Hello, World!") const result = Effect.runSync(program) // => undefined ``` 运行 `src/index.ts` 文件。Node.js 22.18 或更高版本、Bun 以及 Deno 都能直接运行 TypeScript 文件,因此无需额外的工具链: ```sh node src/index.ts ``` ```sh node src/index.ts ``` ```sh node src/index.ts ``` ```sh bun src/index.ts ``` ```sh deno run src/index.ts ``` 如果你使用的是较旧版本的 Node.js,可以改用 [tsx](https://github.com/privatenumber/tsx) 运行该文件:`npx tsx src/index.ts`。 你应该会看到打印出 `"Hello, World!"` 消息。这说明程序运行正常。 ### Vite + React 按照以下步骤,为 [Vite](https://vitejs.dev/guide/) + [React](https://react.dev/) 创建一个新的 Effect 项目: 1. 搭建 Vite 项目,打开终端并运行以下命令: ```sh # npm 6.x npm create vite@latest hello-effect --template react-ts # npm 7+, extra double-dash is needed npm create vite@latest hello-effect -- --template react-ts ``` ```sh pnpm create vite@latest hello-effect -- --template react-ts ``` ```sh yarn create vite@latest hello-effect -- --template react-ts ``` ```sh bun create vite@latest hello-effect -- --template react-ts ``` ```sh deno init --npm vite@latest hello-effect -- --template react-ts ``` 该命令会创建一个使用 React 和 TypeScript 模板的新 Vite 项目。 2. 进入新建的项目目录并安装所需的包: ```sh cd hello-effect npm install ``` ```sh cd hello-effect pnpm install ``` ```sh cd hello-effect yarn install ``` ```sh cd hello-effect bun install ``` ```sh cd hello-effect deno install ``` 包安装完成后,打开 `tsconfig.json` 文件,确保 `strict` 选项的值已设置为 true。 ```json { "compilerOptions": { "strict": true } } ``` 3. 将所需的包安装为依赖项: ```sh npm install effect@rc ``` ```sh pnpm add effect@rc ``` ```sh yarn add effect@rc ``` ```sh bun add effect@rc ``` ```sh deno add npm:effect@rc ``` 这个包将为你的 Effect 项目提供基础功能。 现在,我们编写并运行一个简单的程序,以确保一切配置正确。 打开 `src/App.tsx` 文件,并将其内容替换为以下代码: ```diff +import { useState, useMemo, useCallback } from "react" import reactLogo from "./assets/react.svg" import viteLogo from "/vite.svg" import "./App.css" +import { Effect } from "effect" function App() { const [count, setCount] = useState(0) + const task = useMemo( + () => Effect.sync(() => setCount((current) => current + 1)), + [setCount] + ) + + const increment = useCallback(() => Effect.runSync(task), [task]) return ( <>

Vite + React

+

Edit src/App.tsx and save to test HMR

Click on the Vite and React logos to learn more

) } export default App ``` 完成这些修改后,运行以下命令启动开发服务器: ```sh npm run dev ``` ```sh pnpm run dev ``` ```sh yarn run dev ``` ```sh bun run dev ``` ```sh deno run dev ``` 然后按 **o** 键在浏览器中打开应用。 点击按钮后,你应该会看到计数器递增。这说明程序运行正常。 --- # 运行 Effect > 学习如何用各类 run 函数同步或异步执行 Effect,并正确处理成功与失败的结果。 要执行一个 effect,你可以使用 `Effect` 模块提供的众多 `run` 函数之一。 ## runSync 同步执行一个 effect,立即运行并返回其结果。 **示例**(同步日志输出) ```ts import { Effect } from "effect" const program = Effect.sync(() => { console.log("Hello, World!") return 1 }) const result = Effect.runSync(program) // Output: Hello, World! console.log(result) result // => 1 ``` 使用 `Effect.runSync` 来运行不会失败、也不包含任何异步操作的 effect。如果该 effect 会失败或涉及异步操作,它会抛出错误,执行会在失败或异步操作发生的位置 停止。 **示例**(在会失败或异步的 effect 上错误地使用) ```ts import { Effect } from "effect" try { // Attempt to run an effect that fails Effect.runSync(Effect.fail("my error")) } catch (e) { console.error(e) } /* Output: my error */ try { // Attempt to run an effect that involves async work Effect.runSync(Effect.promise(() => Promise.resolve(1))) } catch (e) { console.error(e) } /* Output: { message: 'An asynchronous Effect was executed with Effect.runSync', fiber: FiberImpl { ... }, _tag: 'AsyncFiberError', '~effect/Cause/AsyncFiberError': '~effect/Cause/AsyncFiberError' } */ ``` ## runSyncExit 同步运行一个 effect,并将结果以 [Exit](/docs/v4/data-types/exit/) 类型返回,该类型 表示 effect 的结果(成功或失败)。 使用 `Effect.runSyncExit` 可以判断一个 effect 是成功还是失败(包括任何 defect), 同时无需处理异步操作。 `Exit` 类型表示 effect 的结果: - 如果 effect 成功,结果会被包装在 `Success` 中。 - 如果 effect 失败,失败信息会以 `Failure` 的形式给出,其中包含一个 [Cause](/docs/v4/data-types/cause/) 类型。 **示例**(将结果作为 Exit 处理) ```ts import { Effect, Exit } from "effect" console.log(Effect.runSyncExit(Effect.succeed(1))) Effect.runSyncExit(Effect.succeed(1)) // => Exit.succeed(1) console.log(Effect.runSyncExit(Effect.fail("my error"))) Effect.runSyncExit(Effect.fail("my error")) // => Exit.fail("my error") ``` 如果 effect 包含异步操作,`Effect.runSyncExit` 会返回一个带有 `Die` cause 的 `Failure`,表示该 effect 无法同步完成。 **示例**(异步操作导致 Die) ```ts import { Effect } from "effect" console.log(Effect.runSyncExit(Effect.promise(() => Promise.resolve(1)))) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', failures: [ { _tag: 'Die', defect: { message: 'An asynchronous Effect was executed with Effect.runSync', fiber: FiberImpl { ... }, _tag: 'AsyncFiberError', '~effect/Cause/AsyncFiberError': '~effect/Cause/AsyncFiberError' } } ] } } */ ``` ## runPromise 执行一个 effect,并将结果以 `Promise` 的形式返回。 当你需要执行 effect 并使用 `Promise` 语法处理结果时(通常是为了与其他基于 Promise 的代码兼容),请使用 `Effect.runPromise`。 **示例**(将成功的 effect 作为 Promise 运行) ```ts import { Effect } from "effect" const result = await Effect.runPromise(Effect.succeed(1)) // => 1 console.log(result) ``` 如果 effect 成功,promise 会以该结果 resolve;如果 effect 失败,promise 会以 错误 reject。 **示例**(将失败的 effect 作为被拒绝的 Promise 处理) ```ts import { Effect } from "effect" try { await Effect.runPromise(Effect.fail("my error")) } catch (e) { console.error(e) e // => "my error" } ``` ## runPromiseExit 运行一个 effect,并返回一个 resolve 为 [Exit](/docs/v4/data-types/exit/) 的 `Promise`,该类型表示 effect 的结果(成功或失败)。 当你需要判断一个 effect 是成功还是失败(包括任何 defect),并且希望使用 `Promise` 时,请使用 `Effect.runPromiseExit`。 `Exit` 类型表示 effect 的结果: - 如果 effect 成功,结果会被包装在 `Success` 中。 - 如果 effect 失败,失败信息会以 `Failure` 的形式给出,其中包含一个 [Cause](/docs/v4/data-types/cause/) 类型。 **示例**(将结果作为 Exit 处理) ```ts import { Effect, Exit } from "effect" const success = await Effect.runPromiseExit(Effect.succeed(1)) console.log(success) success // => Exit.succeed(1) const failure = await Effect.runPromiseExit(Effect.fail("my error")) console.log(failure) failure // => Exit.fail("my error") ``` ## runFork 这是运行 effect 的基础函数,返回一个可被观察或中断的 “fiber”。 `Effect.runFork` 通过创建一个 fiber 在后台运行 effect。它是所有其他 run 函数的 基础。它会启动一个可被观察或中断的 fiber。 **示例**(在后台运行 effect) ```ts import { Effect, Console, Schedule, Fiber } from "effect" // ┌─── Effect // ▼ const program = Effect.repeat( Console.log("running..."), Schedule.spaced("200 millis"), ) // ┌─── RuntimeFiber // ▼ const fiber = Effect.runFork(program) setTimeout(() => { Effect.runFork(Fiber.interrupt(fiber)) }, 500) ``` 在这个示例中,`program` 会不断打印 “running...”,每次重复之间间隔 200 毫秒。 你可以在[调度入门](/docs/v4/scheduling/introduction/)指南中进一步了解重复与调度。 要停止程序的执行,我们对 `Effect.runFork` 返回的 fiber 调用 `Fiber.interrupt`。 这样你就能控制执行流程,并在需要时终止它。 如果想深入了解 fiber 的工作原理以及如何处理中断,请参阅我们的 [Fibers](/docs/v4/concurrency/fibers/) 和 [Interruptions](/docs/v4/concurrency/basic-concurrency/#interruptions) 指南。 ## 同步与异步 effect 没有内置的方法可以事先判断一个 effect 会同步执行还是异步执行。追踪这一区别会 带来几个问题: 1. **复杂度:** 在类型系统中引入追踪同步/异步行为的特性,会让 Effect 更难使用, 并限制其可组合性。 2. **安全性:** 追踪异步 effect 并不会显著提升安全性。基于回调的 API 既可以 立即调用回调,也可以延迟调用,而类型系统无法可靠地区分这两种行为。 ### 运行 effect 的最佳实践 大多数情况下,effect 会在应用的最外层运行。通常,一个围绕 Effect 构建的应用 只会调用一次主 effect。下面是处理 effect 执行时应当遵循的方式: - 优先使用 `runPromise` 或 `runFork`:大多数情况下,异步执行应当是默认选择。 这些方法提供了处理基于 Effect 的工作流的最佳方式。 - 仅在必要时使用 `runSync`:同步执行应被视为边缘情况,只在无法进行异步执行的 场景中使用。例如,当你确定该 effect 完全是同步的,并且需要立即拿到结果时。 ## 速查表 下表汇总了可用的 `run*` 函数及其输入与输出类型,方便你根据自身需求选择合适的 函数。 | API | 给定 | 结果 | | ---------------- | -------------- | --------------------- | | `runSync` | `Effect` | `A` | | `runSyncExit` | `Effect` | `Exit` | | `runPromise` | `Effect` | `Promise` | | `runPromiseExit` | `Effect` | `Promise>` | | `runFork` | `Effect` | `RuntimeFiber` | 你可以在[这里](https://effect.website/docs/v4/api/effect/Effect#category-running)找到 `run*` 函数的完整 列表。 --- # Effect 类型 > 了解 Effect 类型如何以惰性、不可变的方式描述成功、失败与所需依赖。 `Effect` 类型是对一个工作流或操作的描述,它会被**惰性**执行。也就是说,当你创建一个 `Effect` 时,它不会立刻运行,而是定义了一段程序:它可能成功,可能失败,也可能需要一些额外的上下文才能完成。 下面是 `Effect` 的一般形式: ```text ┌─── Represents the success type │ ┌─── Represents the error type │ │ ┌─── Represents required dependencies ▼ ▼ ▼ Effect ``` 这个类型表明一个 effect: - 成功并返回 `Success` 类型的值 - 失败并带有 `Error` 类型的错误 - 执行时可能需要 `Requirements` 类型的上下文依赖 从概念上讲,你可以把 `Effect` 看作下面这个函数类型的带副作用(effectful)版本: ```ts type Effect = ( context: Context, ) => Error | Success ``` 不过,effect 实际上并不是函数。它们可以描述同步、异步、并发以及涉及资源管理的计算。 **不可变性**。`Effect` 值是不可变的,Effect 库中的每个函数都会产生一个新的 `Effect` 值。 **描述交互**。这些值本身不会执行任何动作,它们只是描述带副作用的交互。 **执行**。`Effect` 可以由 [Effect 运行时系统](/docs/v4/runtime/) 执行,运行时系统会把它解释为与外部世界的实际交互。理想情况下,这种执行只发生在应用中的单个入口点,例如发起这些带副作用操作的 main 函数。 ## 类型参数 `Effect` 类型有三个类型参数,它们的含义如下: | 参数 | 说明 | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Success** | 表示 effect 执行成功时返回的值的类型。如果该类型参数是 `void`,说明这个 effect 不会产生有用的信息;如果是 `never`,说明这个 effect 会一直运行下去(或直到失败)。 | | **Error** | 表示执行 effect 时可能出现的预期错误。如果该类型参数是 `never`,说明这个 effect 不会失败,因为不存在 `never` 类型的值。 | | **Requirements** | 表示 effect 执行所需的上下文数据。这些数据保存在名为 `Context` 的集合中。如果该类型参数是 `never`,说明这个 effect 没有依赖需求,此时 `Context` 集合为空。 | ## 提取推导出的类型 借助工具类型 `Effect.Success`、`Effect.Error` 和 `Effect.Services`,你可以从 effect 中提取出对应的类型。 **示例**(提取成功、错误与上下文类型) ```ts import { Effect, Context } from "effect" class SomeContext extends Context.Service()("SomeContext") {} // Assume we have an effect that succeeds with a number, // fails with an Error, and requires SomeContext declare const program: Effect.Effect // Extract the success type, which is number type A = Effect.Success // Extract the error type, which is Error type E = Effect.Error // Extract the context type, which is SomeContext type R = Effect.Services SomeContext.key // => "SomeContext" ``` --- # 使用 Generator > 学习如何使用 Generator 编写带副作用的代码,改善控制流、处理错误并简化异步操作。 Effect 提供了一种便捷的语法,它类似于 `async`/`await`,让你可以使用 [generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) 编写带副作用的代码。 ## 理解 Effect.gen `Effect.gen` 工具借助 JavaScript 的 generator 函数,简化了编写带副作用代码的工作。这种方式能让你的代码在外观和行为上更接近传统的同步代码,从而提升可读性并改善错误管理。 **示例**(执行带折扣的交易) 让我们来看一个实用的程序,它执行一系列在应用逻辑中常见的转换操作: ```ts import { Effect } from "effect" // Function to add a small service charge to a transaction amount const addServiceCharge = (amount: number) => amount + 1 // Function to apply a discount safely to a transaction amount const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) // Simulated asynchronous task to fetch a transaction amount from a // database const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) // Simulated asynchronous task to fetch a discount rate from a // configuration file const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) // Assembling the program using a generator function const program = Effect.gen(function* () { // Retrieve the transaction amount const transactionAmount = yield* fetchTransactionAmount // Retrieve the discount rate const discountRate = yield* fetchDiscountRate // Calculate discounted amount const discountedAmount = yield* applyDiscount(transactionAmount, discountRate) // Apply service charge const finalAmount = addServiceCharge(discountedAmount) // Return the total amount after applying the charge return `Final amount to charge: ${finalAmount}` }) // Execute the program and log the result const result = await Effect.runPromise(program) // => "Final amount to charge: 96" console.log(result) ``` 使用 `Effect.gen` 时需要遵循的关键步骤: - 把逻辑包裹在 `Effect.gen` 中 - 使用 `yield*` 处理 effect - 返回最终结果 如果你在 generator 中通过 `yield*` 处理的任何一个 effect 失败了,那么 generator 会停止执行,并以该失败退出。 ## 比较 Effect.gen 与 async/await 如果你熟悉 `async`/`await`,可能会注意到两者的代码编写流程很相似。 让我们比较一下这两种方式: ```ts import { Effect } from "effect" const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = ( total: number, discountRate: number, ): Effect.Effect => discountRate === 0 ? Effect.fail(new Error("Discount rate cannot be zero")) : Effect.succeed(total - (total * discountRate) / 100) const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100)) const fetchDiscountRate = Effect.promise(() => Promise.resolve(5)) export const program = Effect.gen(function* () { const transactionAmount = yield* fetchTransactionAmount const discountRate = yield* fetchDiscountRate const discountedAmount = yield* applyDiscount(transactionAmount, discountRate) const finalAmount = addServiceCharge(discountedAmount) return `Final amount to charge: ${finalAmount}` }) await Effect.runPromise(program) // => "Final amount to charge: 96" ``` ```ts const addServiceCharge = (amount: number) => amount + 1 const applyDiscount = (total: number, discountRate: number): Promise => discountRate === 0 ? Promise.reject(new Error("Discount rate cannot be zero")) : Promise.resolve(total - (total * discountRate) / 100) const fetchTransactionAmount = Promise.resolve(100) const fetchDiscountRate = Promise.resolve(5) export const program = async function () { const transactionAmount = await fetchTransactionAmount const discountRate = await fetchDiscountRate const discountedAmount = await applyDiscount(transactionAmount, discountRate) const finalAmount = addServiceCharge(discountedAmount) return `Final amount to charge: ${finalAmount}` } await program() // => "Final amount to charge: 96" ``` 需要注意的是,尽管代码看起来相似,但这两个程序并不完全相同。把它们并排比较,只是为了突出它们在写法上的相似之处。 ## 拥抱控制流 在配合 generator 使用 `Effect.gen` 时,一个显著优势是它能够在 generator 函数内部使用标准的控制流结构。这些结构包括 `if`/`else`、`for`、`while` 以及其他分支和循环机制,从而增强你在代码中表达复杂控制流逻辑的能力。 **示例**(使用控制流) ```ts import { Effect } from "effect" const calculateTax = ( amount: number, taxRate: number, ): Effect.Effect => taxRate > 0 ? Effect.succeed((amount * taxRate) / 100) : Effect.fail(new Error("Invalid tax rate")) const program = Effect.gen(function* () { let i = 1 while (true) { if (i === 10) { break // Break the loop when counter reaches 10 } else { if (i % 2 === 0) { // Calculate tax for even numbers console.log(yield* calculateTax(100, i)) } i++ continue } } }) await Effect.runPromise(program) // => undefined /* Output: 2 4 6 8 */ ``` ## 如何抛出错误 `Effect.gen` API 让你可以通过 yield 一个失败的 effect,把错误处理直接整合进工作流中。 你可以像下面这个示例一样,用 `Effect.fail` 引入错误。 **示例**(向流程中引入错误) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1...") const task2 = Console.log("task2...") const program = Effect.gen(function* () { // Perform some tasks yield* task1 yield* task2 // Introduce an error return yield* Effect.fail("Something went wrong!") }) try { await Effect.runPromise(program) } catch (e) { console.error(e) /* Output: task1... task2... */ e // => "Something went wrong!" } ``` ## 短路的作用 在使用 `Effect.gen` 时,理解它如何处理错误很重要。 这个 API 会在遇到**第一个错误**时停止执行,并返回该错误。 这对你的代码有什么影响?如果你有一系列顺序执行的操作,那么其中任何一个失败后,其余操作都不会运行,并且该错误会被返回。 简单来说,如果某个环节出了问题,程序会立刻停在那里,并把错误交给你。 如果你不想在出错时停止,可以使用 `Effect.result` 方法把错误封装进 [Result](/docs/v4/data-types/result/) 数据类型:请参阅[管理预期错误的示例](/docs/v4/error-management/expected-errors/#result)。 **示例**(在第一个错误处停止执行) ```ts import { Effect, Console } from "effect" const task1 = Console.log("task1...") const task2 = Console.log("task2...") const failure = Effect.fail("Something went wrong!") const task4 = Console.log("task4...") const program = Effect.gen(function* () { yield* task1 yield* task2 // The program stops here due to the error yield* failure // The following lines never run yield* task4 return "some result" }) Effect.runPromise(program).then(console.log, console.error) /* Output: task1... task2... Something went wrong! */ ``` 尽管执行永远不会到达失败之后的代码,但除非你在失败后显式 return,否则 TypeScript 仍可能认为错误下方的代码是可到达的。 例如,考虑下面这个场景,你希望收窄某个变量的类型: **示例**(没有显式 return 时的类型收窄) ```ts import { Effect } from "effect" type User = { readonly name: string } // Imagine this function checks a database or an external service declare function getUserById(id: string): Effect.Effect function greetUser(id: string) { return Effect.gen(function* () { const user = yield* getUserById(id) if (user === undefined) { // Even though we fail here, TypeScript still thinks // 'user' might be undefined later yield* Effect.fail(`User with id ${id} not found`) } // @errors: 18048 return `Hello, ${user.name}!` }) } ``` 在这个示例中,TypeScript 仍然认为 `user` 可能是 `undefined`,因为失败之后没有显式 return。 要解决这个问题,请在调用 `Effect.fail` 之后立即显式 return: **示例**(有显式 return 时的类型收窄) ```ts import { Effect } from "effect" type User = { readonly name: string } declare function getUserById(id: string): Effect.Effect function greetUser(id: string) { return Effect.gen(function* () { const user = yield* getUserById(id) if (user === undefined) { // Explicitly return after failing return yield* Effect.fail(`User with id ${id} not found`) } // Now TypeScript knows that 'user' is not undefined return `Hello, ${user.name}!` }) } greetUser.length // => 1 ``` ## 传递 `this` 在某些情况下,你可能需要把当前对象(`this`)的引用传入 generator 函数体。 你可以借助一个把该引用作为第一个参数接收的重载来实现: **示例**(向 Generator 传递 `this`) ```ts import { Effect } from "effect" class MyClass { readonly local = 1 compute = Effect.gen({ self: this }, function* () { const n = this.local + 1 yield* Effect.log(`Computed value: ${n}`) return n }) } const result = await Effect.runPromise(new MyClass().compute) // => 2 console.log(result) /* Output: timestamp=... level=INFO fiber=#0 message="Computed value: 2" */ ``` --- # 为什么选择 Effect? > 从类型系统出发跟踪错误与上下文,用 Effect 构建可靠、易维护的 TypeScript 应用。 编程本身充满挑战。在构建库和应用时,我们会借助各种工具来应对复杂性,让日常工作更可控。Effect 为 TypeScript 编程带来了一种全新的思考方式。 Effect 是一个工具生态,帮助你构建更好的应用与库。与此同时,你也会更深入地理解 TypeScript 这门语言,学会利用类型系统让你的程序更可靠、更易维护。 在不使用 Effect 的「典型」TypeScript 代码中,我们写下的函数要么成功返回,要么抛出异常。例如: ```ts const divide = (a: number, b: number): number => { if (b === 0) { throw new Error("Cannot divide by zero") } return a / b } divide(4, 2) // => 2 ``` 仅从类型上,我们完全看不出这个函数可能抛出异常,只能通过阅读代码来发现。当代码库里只有一个函数时,这似乎算不上什么大问题;但当你面对成百上千个函数时,这种代价就会不断累积。我们很容易忘记某个函数会抛异常,也很容易忘记去处理它。 通常,我们会选择「最省事」的做法:把函数包进 `try/catch` 块里。这是防止程序崩溃的良好第一步,但它并没有让你更容易管理或理解复杂的应用与库。我们可以做得更好。 TypeScript 中最重要的工具之一就是编译器。它是抵御 bug、领域错误(domain error)以及整体复杂性的第一道防线。 ## Effect 模式 Effect 是一个包含众多工具的庞大生态,但如果必须把它浓缩成唯一的核心思想,那就是下面这句话: Effect 最独特的关键洞见在于:我们可以用类型系统来跟踪 **errors** 和 **context**,而不仅仅是像上面的 divide 示例那样只跟踪 **success** 值。 下面是上面那个 divide 函数改用 Effect 模式后的写法: ```ts import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) Effect.runSync(divide(4, 2)) // => 2 ``` 采用这种方式后,函数不再抛出异常。错误被当作值来处理,可以像成功值一样被传递下去。类型签名也清晰地说明了: - 函数返回的成功值是什么(`number`)。 - 可能发生什么错误(`Error`)。 - 需要哪些额外的上下文或依赖(`never` 表示没有)。 ```text ┌─── Produces a value of type number │ ┌─── Fails with an Error │ │ ┌─── Requires no dependencies ▼ ▼ ▼ Effect ``` 此外,跟踪上下文让你无需把所有东西都当作参数传入,就能为函数提供额外信息。例如,你可以在测试中用 mock 替换线上外部服务的实现,而无需改动任何核心业务逻辑。 ## 不要重复造轮子 TypeScript 的应用代码常常在反复解决同样的问题。与外部服务、文件系统、数据库等打交道,是所有应用开发者都会遇到的常见问题。Effect 提供了丰富的库生态,为其中许多问题给出了标准化的解决方案。你既可以用这些库来构建自己的应用,也可以用它们来构建你自己的库。 错误处理、调试、tracing、async/Promise、重试、流式处理(streaming)、并发、缓存、资源管理等等挑战,在 Effect 中都变得可管理。你不必重新发明这些问题的解决方案,也不必安装成堆的依赖。Effect 在一个统一的体系下,解决了那些通常需要安装许多不同依赖、使用不同 API 才能解决的问题。 ## 解决实际问题 Effect 深受 Scala、Haskell 等其他语言中优秀成果的启发。但同样重要的是要理解:Effect 的目标是成为一个实用的工具箱,它不遗余力地解决开发者用 TypeScript 构建应用与库时每天都会遇到的真实问题。 ## 享受构建与学习 学习 Effect 是一件很有乐趣的事。Effect 生态中的许多开发者既用 Effect 解决日常工作中的真实问题,也在试验各种前沿想法,推动 TypeScript 成为它所能成为的最实用的语言。 你不需要一次性用上 Effect 的所有方面,可以先从生态中最契合你当前问题的部分入手。Effect 是一个工具箱,你可以按需挑选最适合自己场景的部分。不过,随着代码库中越来越多的部分用上 Effect,你大概会发现自己想要用上生态里更多的东西! Effect 的概念对你来说可能是全新的,一开始未必能完全理解,这完全正常。慢慢阅读文档,努力理解核心概念——当你之后接触 Effect 生态中更高级的工具时,这些投入会得到丰厚的回报。Effect 社区始终乐于帮助大家学习与成长。欢迎加入[中文社区微信群](/community/),或在官方的 [GitHub 仓库](https://github.com/Effect-TS) 上参与讨论!我们欢迎反馈与贡献,也一直在寻找改进 Effect 的方法。 --- # 日志 > 了解 Effect 的日志工具,包括动态日志级别、自定义输出以及对日志的细粒度控制。 日志是软件开发中的一个重要方面,尤其是在调试和监控应用程序行为时。在本节中,我们将探索 Effect 的日志工具,并看看它们与传统日志记录方法有何不同。 ## 相比传统日志记录的优势 相比传统的日志记录方式,Effect 的日志工具带来了几项优势: 1. **动态日志级别控制**:借助 Effect 的日志功能,你可以动态更改日志级别。这意味着你能根据严重程度控制哪些日志消息会被展示。例如,你可以把应用配置为只记录警告或错误,这在生产环境中对降低噪音非常有帮助。 2. **自定义日志输出**:Effect 的日志工具允许你改变日志的处理方式。借助[自定义 logger](#custom-loggers),你可以把日志消息导向各种目的地,例如某个服务或某个文件。这种灵活性确保日志的存储与处理方式最贴合你的应用需求。 3. **细粒度日志**:Effect 支持按程序的各个部分对日志进行细粒度控制。你可以为应用的不同部分设置不同的日志级别,从而为每个具体组件定制详细程度。这在调试和排查问题时非常有价值,因为你可以专注于最重要的信息。 4. **基于环境的日志**:Effect 的日志工具可以与部署环境结合,实现精细的日志策略。例如,在开发期间,你可能会选择以 trace 级别及以上记录所有内容,以便详细调试。相比之下,生产版本可以配置为只记录错误或严重问题,从而把对性能的影响以及生产日志中的噪音降到最低。 5. **其他特性**:Effect 的日志工具还带有其他特性,例如测量时间跨度、按 effect 调整日志级别,以及集成 span 用于性能监控。 ## log `Effect.log` 函数允许你以默认的 `INFO` 级别记录一条消息。 **示例**(记录一条简单消息) ```ts import { Effect, Logger } from "effect" const program = Effect.log("Application started") Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started" */ // Capture the logged message content (ignoring the non-deterministic timestamp) const messages: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["Application started"]] ``` Effect 中的默认 logger 会为每条日志条目添加若干有用的细节: | 注解 | 说明 | | --- | --- | | `timestamp` | 日志消息生成时的时间戳。 | | `level` | 记录该消息时使用的日志级别(例如 `INFO`、`ERROR`)。 | | `fiber` | 执行该程序的 [fiber](/docs/v4/concurrency/fibers/) 的标识符。 | | `message` | 日志消息的内容,可以包含多个字符串或值。 | | `span` | (可选)span 的持续时间,单位为毫秒,可帮助你了解各项操作的耗时。 | 你也可以一次记录多条消息。 **示例**(记录多条消息) ```ts import { Effect, Logger } from "effect" const program = Effect.log("message1", "message2", "message3") Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 message=message2 message=message3 */ // Capture the logged message content (ignoring the non-deterministic timestamp) const messages: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["message1", "message2", "message3"]] ``` 为了提供更多上下文,你还可以在日志中包含一个或多个 [Cause](/docs/v4/data-types/cause/) 实例, 它们会在额外的 `cause` 注解下提供详细的错误信息: **示例**(记录带 cause 的日志) ```ts import { Effect, Cause, Logger } from "effect" const program = Effect.log( "message1", "message2", Cause.die("Oh no!"), Cause.die("Oh uh!"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 message=message2 cause="Error: Oh no! Error: Oh uh!" */ // Capture the logged message content and cause (ignoring the non-deterministic timestamp) const messages: Array = [] const causes: Array> = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { messages.push(options.message) causes.push(options.cause) }), ]), ), ), ) messages // => [["message1", "message2"]] Cause.hasDies(causes[0]) // => true ``` ## 日志级别 ### logDebug 默认情况下,`DEBUG` 消息**不会被展示**。要为某个 effect 启用它们,请提供值为 `"Debug"` 的 `References.MinimumLogLevel` 上下文引用。 **示例**(启用调试日志) ```ts import { Effect, References, Logger } from "effect" const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") // Log a debug message }).pipe(Effect.provideService(References.MinimumLogLevel, "Debug")) // Enable DEBUG level const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") // This message won't be logged }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) Effect.runFork(program) /* Output: timestamp=... level=INFO message=start timestamp=... level=DEBUG message="task1 done" <-- 2 seconds later timestamp=... level=INFO message=done <-- 1 second later */ // Capture the logged messages (ignoring the non-deterministic timestamps) const messages: Array = [] await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) // task2's debug message is filtered out, since it never enables the Debug level messages // => [["start"], ["task1 done"], ["done"]] ``` ### logInfo `INFO` 日志级别默认会被展示。该级别通常用于一般性的应用事件或进度更新。 **示例**(以 INFO 级别记录日志) ```ts import { Effect, Logger } from "effect" const program = Effect.gen(function* () { yield* Effect.logInfo("start") yield* Effect.sleep("2 seconds") yield* Effect.sleep("1 second") yield* Effect.logInfo("done") }) Effect.runFork(program) /* Output: timestamp=... level=INFO message=start timestamp=... level=INFO message=done <-- 3 seconds later */ // Capture the logged messages (ignoring the non-deterministic timestamps) const messages: Array = [] await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["start"], ["done"]] ``` ### logWarning `WARN` 日志级别默认会被展示。该级别用于那些不会立即打断程序流程、但应当被关注的潜在问题或警告。 **示例**(以 WARN 级别记录日志) ```ts import { Effect, Result, Logger } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.result(task) if (Result.isFailure(failureOrSuccess)) { yield* Effect.logWarning(failureOrSuccess.failure) return 0 } else { return failureOrSuccess.success } }) Effect.runFork(program) /* Output: timestamp=... level=WARN fiber=#0 message="Oh uh!" */ // Capture the logged message content (ignoring the non-deterministic timestamp) const messages: Array = [] const result = await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) result // => 0 messages // => [["Oh uh!"]] ``` ### logError `ERROR` 日志级别默认会被展示。这些消息表示需要处理的问题。 **示例**(以 ERROR 级别记录日志) ```ts import { Effect, Result, Logger } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.result(task) if (Result.isFailure(failureOrSuccess)) { yield* Effect.logError(failureOrSuccess.failure) return 0 } else { return failureOrSuccess.success } }) Effect.runFork(program) /* Output: timestamp=... level=ERROR fiber=#0 message="Oh uh!" */ // Capture the logged message content (ignoring the non-deterministic timestamp) const messages: Array = [] const result = await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) result // => 0 messages // => [["Oh uh!"]] ``` ### logFatal `FATAL` 日志级别默认会被展示。该日志级别通常保留给不可恢复的错误。 **示例**(以 FATAL 级别记录日志) ```ts import { Effect, Result, Logger } from "effect" const task = Effect.fail("Oh uh!").pipe(Effect.as(2)) const program = Effect.gen(function* () { const failureOrSuccess = yield* Effect.result(task) if (Result.isFailure(failureOrSuccess)) { yield* Effect.logFatal(failureOrSuccess.failure) return 0 } else { return failureOrSuccess.success } }) Effect.runFork(program) /* Output: timestamp=... level=FATAL fiber=#0 message="Oh uh!" */ // Capture the logged message content (ignoring the non-deterministic timestamp) const messages: Array = [] const result = await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) result // => 0 messages // => [["Oh uh!"]] ``` ## 自定义注解 你可以使用 `Effect.annotateLogs` 函数添加自定义注解,从而增强日志输出。 这样可以让你为每条日志条目附加额外的元数据,提升可追溯性并提供更多上下文。 ### 添加单个注解 你可以以键/值对的形式,把单个注解应用到某个 effect 内的所有日志条目上。 **示例**(单个键/值注解) ```ts import { Effect, Logger, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") }).pipe( // Annotation as key/value pair Effect.annotateLogs("key", "value"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value */ // Capture the annotations attached to each logged message const annotations: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => annotations.push( options.fiber.getRef(References.CurrentLogAnnotations), ), ), ]), ), ), ) annotations // => [{ key: "value" }, { key: "value" }] ``` 在这个例子中,`program` 内生成的所有日志都会包含注解 `key=value`。 ### 嵌套 effect 中的注解 注解会传播到嵌套 effect 或下游 effect 中生成的所有日志,从而确保任何子 effect 的日志都继承父 effect 的注解。 **示例**(把注解传播到嵌套 effect) 在这个例子中,注解 `key=value` 会出现在所有日志中,甚至包括来自嵌套 `anotherProgram` effect 的日志。 ```ts import { Effect, Logger } from "effect" // Define a child program that logs an error const anotherProgram = Effect.gen(function* () { yield* Effect.logError("error1") }) // Define the main program const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") yield* anotherProgram // Call the nested program }).pipe( // Attach an annotation to all logs in the scope Effect.annotateLogs("key", "value"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value timestamp=... level=ERROR fiber=#0 message=error1 key=value */ // Capture the logged messages, confirming the annotation reaches the nested effect too const messages: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["message1"], ["message2"], ["error1"]] ``` ### 添加多个注解 你也可以通过传入一个包含键/值对的对象,一次应用多个注解。每一对键/值都会被添加到该 effect 内的每一条日志记录中。 **示例**(多个注解) ```ts import { Effect, Logger, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("message1") yield* Effect.log("message2") }).pipe( // Add multiple annotations Effect.annotateLogs({ key1: "value1", key2: "value2" }), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message=message1 key2=value2 key1=value1 timestamp=... level=INFO fiber=#0 message=message2 key2=value2 key1=value1 */ // Capture the annotations attached to each logged message const annotations: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => annotations.push( options.fiber.getRef(References.CurrentLogAnnotations), ), ), ]), ), ), ) annotations // => [{ key1: "value1", key2: "value2" }, { key1: "value1", key2: "value2" }] ``` 在这种情况下,每条日志都会同时包含 `key1=value1` 和 `key2=value2`。 ### 作用域内的注解 如果你希望限制注解的作用范围,使它们只对特定的日志记录生效,可以使用 `Effect.annotateLogsScoped`。这个函数会把注解限制在特定作用域内产生的日志上。 **示例**(作用域内的注解) ```ts import { Effect, Logger, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("no annotations") // No annotations yield* Effect.annotateLogsScoped({ key: "value" }) // Scoped annotation yield* Effect.log("message1") // Annotation applied yield* Effect.log("message2") // Annotation applied }).pipe( Effect.scoped, // Outside scope, no annotations Effect.andThen(Effect.log("no annotations again")), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="no annotations" timestamp=... level=INFO fiber=#0 message=message1 key=value timestamp=... level=INFO fiber=#0 message=message2 key=value timestamp=... level=INFO fiber=#0 message="no annotations again" */ // Capture each logged message together with the annotations active at that point const records: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { records.push({ message: options.message, annotations: options.fiber.getRef(References.CurrentLogAnnotations), }) }), ]), ), ), ) records // => [{ message: ["no annotations"], annotations: {} }, { message: ["message1"], annotations: { key: "value" } }, { message: ["message2"], annotations: { key: "value" } }, { message: ["no annotations again"], annotations: {} }] ``` ## 日志 Span Effect 内置支持日志 span(log span),它可以让你测量并记录特定任务或代码片段的耗时。这个特性有助于追踪某些操作耗费了多长时间,让你对应用的性能有更深入的了解。 **示例**(用日志 Span 测量任务耗时) ```ts import { Effect, Logger, References } from "effect" const program = Effect.gen(function* () { // Simulate a delay to represent a task taking time yield* Effect.sleep("1 second") // Log a message indicating the job is done yield* Effect.log("The job is finished!") }).pipe( // Apply a log span labeled "myspan" to measure // the duration of this operation Effect.withLogSpan("myspan"), ) Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="The job is finished!" myspan=1011ms */ // Capture the logged message and the active span label (the duration itself is non-deterministic) const records: Array = [] await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { records.push({ message: options.message, spans: options.fiber .getRef(References.CurrentLogSpans) .map(([label]) => label), }) }), ]), ), ), ) records // => [{ message: ["The job is finished!"], spans: ["myspan"] }] ``` ## 禁用默认日志 有时,比如在测试执行期间,你可能希望禁用应用中的默认日志。Effect 提供了几种在需要时关闭日志的方式。本节中,我们来看看在 Effect 框架中禁用日志的不同方法。 **示例**(提供最低日志级别) 有一种便捷的禁用日志方式:提供 `References.MinimumLogLevel`,并把它的值设为 `"None"`。 ```ts import { Effect, Logger, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Default behavior: logging enabled Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Executing task..." task done */ // Disable logging by setting minimum log level to 'None' Effect.runFork( program.pipe(Effect.provideService(References.MinimumLogLevel, "None")), ) /* Output: task done */ // Confirm that the minimum log level actually suppresses the log message const enabledMessages: Array = [] await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => enabledMessages.push(options.message)), ]), ), ), ) enabledMessages.length // => 1 const disabledMessages: Array = [] await Effect.runPromise( program.pipe( Effect.provideService(References.MinimumLogLevel, "None"), Effect.provide( Logger.layer([ Logger.make((options) => disabledMessages.push(options.message)), ]), ), ), ) disabledMessages.length // => 0 ``` **示例**(使用 Layer) 另一种禁用日志的方式是创建一个将最低日志级别设为 `"None"` 的 Layer,这样就可以彻底关闭所有日志输出。 ```ts import { Effect, Layer, Logger, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Create a layer that disables logging const layer = Layer.succeed(References.MinimumLogLevel, "None") // Apply the layer to disable logging Effect.runFork(program.pipe(Effect.provide(layer))) /* Output: task done */ // Confirm that no log message was emitted const messages: Array = [] await Effect.runPromise( program.pipe( Effect.provide(layer), Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages.length // => 0 ``` **示例**(使用自定义 Runtime) 你也可以通过创建一个包含关闭日志配置的自定义 Runtime 来禁用日志: ```ts import { Effect, Layer, Logger, ManagedRuntime, References } from "effect" const program = Effect.gen(function* () { yield* Effect.log("Executing task...") yield* Effect.sleep("100 millis") console.log("task done") }) // Create a custom runtime that disables logging const customRuntime = ManagedRuntime.make( Layer.succeed(References.MinimumLogLevel, "None"), ) // Run the program using the custom runtime customRuntime.runFork(program) /* Output: task done */ // Confirm that no log message was emitted through a runtime with logging disabled const messages: Array = [] const capturingRuntime = ManagedRuntime.make( Layer.merge( Layer.succeed(References.MinimumLogLevel, "None"), Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ) await capturingRuntime.runPromise(program) messages.length // => 0 ``` ## 从配置中加载日志级别 若要从[配置](/docs/v4/configuration/)中加载日志级别并应用到你的程序,请把配置值映射为一个用于 `References.MinimumLogLevel` 的 Layer。 **示例**(从配置中加载日志级别) ```ts import { Effect, Config, Layer, ConfigProvider, References, Logger, } from "effect" // Simulate a program with logs const program = Effect.gen(function* () { yield* Effect.logError("ERROR!") yield* Effect.logWarning("WARNING!") yield* Effect.logInfo("INFO!") yield* Effect.logDebug("DEBUG!") }) // Load the log level from the configuration and apply it as a layer const LogLevelLive = Config.LogLevel("LOG_LEVEL").pipe( Effect.map((level) => // Set the minimum log level Layer.succeed(References.MinimumLogLevel, level), ), Layer.unwrap, // Convert the effect into a layer ) // Provide the loaded log level to the program const configured = Effect.provide(program, LogLevelLive) // Test the program using a mock configuration provider const test = Effect.provide( configured, ConfigProvider.layer(ConfigProvider.fromUnknown({ LOG_LEVEL: "Warn" })), ) Effect.runFork(test) /* Output: ... level=ERROR fiber=#0 message=ERROR! ... level=WARN fiber=#0 message=WARNING! */ // Capture which messages actually pass the configured "Warn" minimum level const messages: Array = [] await Effect.runPromise( test.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["ERROR!"], ["WARNING!"]] ``` ## 自定义 Logger 本节中,你将学习如何定义自定义 logger 并把它设为应用中的默认 logger。自定义 logger 让你可以控制日志消息的处理方式,例如把它们路由到外部服务、写入文件,或以特定方式格式化日志。 ### 定义自定义 Logger 你可以使用 `Logger.make` 函数定义自己的 logger。这个函数允许你指定日志消息应当如何处理。 **示例**(定义一个简单的自定义 Logger) ```ts import { Logger } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.toUpperCase()}] ${message}`) }) Logger.isLogger(logger) // => true ``` 在这个例子中,自定义 logger 把日志以 `[LogLevel] Message` 的格式连同日志级别和消息一起输出到控制台。 ### 在程序中使用自定义 Logger 假设你已有下面这些任务,以及一个记录若干消息的程序: ```ts import { Effect, Logger } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.toUpperCase()}] ${message}`) }) const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") }) const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) Effect.isEffect(program) // => true ``` 创建一个 `Logger.layer`,其中包含应当接收消息的 logger,然后用 `Effect.provide` 把它提供给程序。 **示例**(用自定义 Logger 替换默认 Logger) ```ts import { Effect, Logger, References } from "effect" // Custom logger that outputs log messages to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.toUpperCase()}] ${message}`) }) const task1 = Effect.gen(function* () { yield* Effect.sleep("2 seconds") yield* Effect.logDebug("task1 done") }) const task2 = Effect.gen(function* () { yield* Effect.sleep("1 second") yield* Effect.logDebug("task2 done") }) const program = Effect.gen(function* () { yield* Effect.log("start") yield* task1 yield* task2 yield* Effect.log("done") }) // Replace the default logger with the custom logger const layer = Logger.layer([logger, Logger.tracerLogger]) Effect.runFork( program.pipe( Effect.provideService(References.MinimumLogLevel, "Debug"), Effect.provide(layer), ), ) // Capture the level+message pairs delivered to the custom logger const entries: Array = [] await Effect.runPromise( program.pipe( Effect.provideService(References.MinimumLogLevel, "Debug"), Effect.provide( Logger.layer([ Logger.make(({ logLevel, message }) => { entries.push(`[${logLevel.toUpperCase()}] ${message}`) }), ]), ), ), ) entries // => ["[INFO] start", "[DEBUG] task1 done", "[DEBUG] task2 done", "[INFO] done"] ``` 运行上面的程序时,控制台会打印如下日志消息: ```ansi [INFO] start [DEBUG] task1 done [DEBUG] task2 done [INFO] done ``` ## 内置 Logger Effect 提供了若干内置 logger,你可以根据自己的日志记录需求选用。这些 logger 提供不同的格式,各自适用于不同的环境或用途,例如开发、生产,或与外部日志服务集成。 每个 logger 都以两种形式提供:logger 本身,以及一个使用该 logger 并把输出发送到 `Console` [默认服务](/docs/v4/requirements-management/default-services/) 的 layer。例如,`structuredLogger` logger 以详细的对象格式生成日志,而 `structured` layer 使用同一个 logger,并把输出写入 `Console` 服务。 ### stringLogger(默认) `stringLogger` logger 以人类可读的键值风格生成日志。这种格式在开发和生产中都很常用,因为它简单,并且易于在控制台中阅读。 由于它是默认 logger,因此这个 logger 没有对应的 layer。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork(program) // Capture the formatted log line (ignoring the non-deterministic timestamp/fiber/span duration) let formatted = "" await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { formatted = Logger.formatSimple.log(options) }), ]), ), ), ) formatted.includes("message=msg1 message=msg2") // => true formatted.endsWith("key1=value1 key2=value2") // => true ``` 输出: ```ansi timestamp=2024-12-28T10:44:31.281Z level=INFO fiber=#0 message=msg1 message=msg2 message="[ \"msg3\", \"msg4\" ]" myspan=102ms key2=value2 key1=value1 ``` ### logfmtLogger `logfmtLogger` logger 以人类可读的键值格式生成日志,与 [stringLogger](#stringlogger-default) logger 类似。主要区别在于,`logfmtLogger` 会移除多余的空格,让日志更紧凑。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork( program.pipe( Effect.provide(Logger.layer([Logger.consoleLogFmt, Logger.tracerLogger])), ), ) // Capture the formatted log line (ignoring the non-deterministic timestamp/fiber/span duration) let formatted = "" await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { formatted = Logger.formatLogFmt.log(options) }), ]), ), ), ) formatted.includes("message=msg1 message=msg2") // => true formatted.endsWith("key1=value1 key2=value2") // => true ``` 输出: ```ansi timestamp=2024-12-28T10:44:31.281Z level=INFO fiber=#0 message=msg1 message=msg2 message="[\"msg3\",\"msg4\"]" myspan=102ms key2=value2 key1=value1 ``` ### prettyLogger `prettyLogger` logger 通过颜色和缩进来增强日志输出,以获得更好的可读性,因此在开发阶段需要目视浏览控制台日志时尤其有用。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork( program.pipe( Effect.provide(Logger.layer([Logger.consolePretty(), Logger.tracerLogger])), ), ) // Wait for the fire-and-forget run above to finish logging before capturing below await Effect.runPromise(Effect.sleep("150 millis")) // Capture the individual console lines written by the pretty logger // (ignoring the non-deterministic timestamp/fiber/span duration on the first line) const calls: Array> = [] const originalLog = console.log console.log = (...args: Array) => { calls.push(args) } try { await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([Logger.consolePretty(), Logger.tracerLogger]), ), ), ) } finally { console.log = originalLog } calls.length // => 5 calls.slice(1) // => [["msg2"], [["msg3", "msg4"]], ["key1:", "value1"], ["key2:", "value2"]] ``` 输出: ```ansi [11:37:14.265] INFO (#0) myspan=101ms: msg1 msg2 [ 'msg3', 'msg4' ] key2: value2 key1: value1 ``` ### structuredLogger `structuredLogger` logger 以详细的对象格式生成日志。当你需要更可追溯的日志时,这种格式很有帮助,特别是当其他系统要分析这些日志、或将其存储起来以便日后查看时。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork( program.pipe( Effect.provide( Logger.layer([Logger.consoleStructured, Logger.tracerLogger]), ), ), ) // Capture the structured record (ignoring the non-deterministic timestamp/fiberId/span duration) let structured: any await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { structured = Logger.formatStructured.log(options) }), ]), ), ), ) structured.message // => ["msg1", "msg2", ["msg3", "msg4"]] structured.level // => "INFO" structured.annotations // => { key1: "value1", key2: "value2" } Object.keys(structured.spans) // => ["myspan"] ``` 输出: ```ansi { message: [ 'msg1', 'msg2', [ 'msg3', 'msg4' ] ], level: 'INFO', timestamp: '2024-12-28T10:44:31.281Z', cause: undefined, annotations: { key2: 'value2', key1: 'value1' }, spans: { myspan: 102 }, fiberId: '#0' } ``` | 字段 | 说明 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `message` | 经过处理的单个值,或经过处理的值组成的数组,取决于记录了多少条消息。 | | `level` | 表示日志级别标签的字符串(例如 "INFO" 或 "DEBUG")。 | | `timestamp` | 日志生成时刻的 ISO 8601 时间戳(例如 "2024-01-01T00:00:00.000Z")。 | | `cause` | 展示详细错误信息的字符串;如果未提供 cause,则为 `undefined`。 | | `annotations` | 一个对象,其中每个键是一个注解标签,对应的值会被解析为结构化格式(例如 `{"key": "value"}`)。 | | `spans` | 一个对象,把每个 span 标签映射到它的毫秒级时长,该时长从 span 开始计时算起,到调用 logger 的那一刻为止(例如 `{"myspan": 102}`)。 | | `fiberId` | 生成这条日志的 fiber 的标识符(例如 "#0")。 | ### jsonLogger `jsonLogger` logger 以 JSON 格式生成日志。对于需要解析并存储 JSON 日志的工具或服务来说,这很有用。 它会对 [structuredLogger](#structuredlogger) logger 创建的对象调用 `JSON.stringify`。 ```ts import { Effect, Logger } from "effect" const program = Effect.log("msg1", "msg2", ["msg3", "msg4"]).pipe( Effect.delay("100 millis"), Effect.annotateLogs({ key1: "value1", key2: "value2" }), Effect.withLogSpan("myspan"), ) Effect.runFork( program.pipe( Effect.provide(Logger.layer([Logger.consoleJson, Logger.tracerLogger])), ), ) // Capture the JSON record (ignoring the non-deterministic timestamp/fiberId/span duration) let jsonString = "" await Effect.runPromise( program.pipe( Effect.provide( Logger.layer([ Logger.make((options) => { jsonString = Logger.formatJson.log(options) }), ]), ), ), ) const parsed = JSON.parse(jsonString) parsed.message // => ["msg1", "msg2", ["msg3", "msg4"]] parsed.level // => "INFO" parsed.annotations // => { key1: "value1", key2: "value2" } Object.keys(parsed.spans) // => ["myspan"] ``` 输出: ```ansi {"message":["msg1","msg2",["msg3","msg4"]],"level":"INFO","timestamp":"2024-12-28T10:44:31.281Z","annotations":{"key2":"value2","key1":"value1"},"spans":{"myspan":102},"fiberId":"#0"} ``` ## 组合多个 Logger ### 转发到多个 Logger 自定义 logger 可以调用其他 logger,从而把每条消息都转发给它们。 **示例**(组合两个 Logger) ```ts import { Effect, Logger } from "effect" // Define a custom logger that logs to the console const logger = Logger.make(({ logLevel, message }) => { globalThis.console.log(`[${logLevel.toUpperCase()}] ${message}`) }) // Combine the default logger and the custom logger // // ┌─── Logger // ▼ const combined = Logger.make((options) => [ Logger.defaultLogger.log(options), logger.log(options), ]) const program = Effect.log("something") Effect.runFork( program.pipe( // Replace the default logger with the combined logger Effect.provide(Logger.layer([combined, Logger.tracerLogger])), ), ) /* Output: timestamp=2025-01-09T13:50:58.655Z level=INFO fiber=#0 message=something [INFO] something */ // Capture the message that reaches the combined logger const messages: Array = [] Effect.runSync( program.pipe( Effect.provide( Logger.layer([Logger.make((options) => messages.push(options.message))]), ), ), ) messages // => [["something"]] ``` --- # Effect 中的 Metric > Effect Metrics 提供了强大的监控工具,包括 Counter、Gauge、Histogram、Summary 和 Frequency,用于跟踪应用的性能与行为。 在复杂且高度并发的应用中,管理各种相互关联的组件可能相当棘手。确保一切平稳运行、避免应用停机,在这类场景中变得至关重要。 现在,设想我们拥有一套复杂的基础设施,其中包含众多服务。这些服务被复制并分布到多台服务器上。然而,我们往往无法了解这些服务中正在发生什么,包括错误率、响应时间和服务正常运行时间。这种可见性的缺失会让我们难以有效地发现和解决问题。这正是 Effect Metrics 发挥作用的地方:它让我们能够捕获并分析各种 metric,为后续排查提供有价值的数据。 Effect Metrics 支持五种不同类型的 metric: | Metric | 说明 | | --- | --- | | **Counter** | Counter 用于跟踪随时间增长的数值,例如请求次数。它帮助我们掌握某个特定事件或动作已经发生了多少次。 | | **Gauge** | Gauge 表示一个会随时间上下波动的单一数值。它常用于监控内存使用量这类会持续变化的 metric。 | | **Histogram** | Histogram 适合跟踪观测值在不同 bucket 之间的分布。它常用于请求延迟这类 metric,让我们能够了解响应时间的分布情况。 | | **Summary** | Summary 提供对时间序列滑动窗口的洞察,并给出该时间序列特定百分位的 metric,这些百分位通常被称为分位数(quantile)。这对于理解与延迟相关的 metric(例如请求响应时间)特别有帮助。 | | **Frequency** | Frequency metric 统计不同字符串值出现的次数。当你想要跟踪应用中不同事件或条件的发生频率时,它非常有用。 | ## Counter 在 metric 的世界里,Counter 是一种表示单一数值的 metric,这个数值可以随时间递增,也可以随时间递减。可以把它想象成一个记录变化次数的计数器,例如你的应用收到的某类请求的数量,无论它是在增加还是减少。 与其他类型的 metric 不同(比如 [Gauge](#gauge)),我们关注的是某个特定时刻的值;而对于 Counter,我们关心的是随时间累积的值。也就是说,它提供的是变化的累计总量,这个总量可升可降,反映出某些 metric 的动态特性。 Counter 的一些典型使用场景包括: - **请求计数**:监控发往服务器的传入请求数量。 - **已完成任务**:跟踪有多少任务或流程已成功完成。 - **错误计数**:统计应用中错误出现的次数。 ### 如何创建 Counter 要创建 Counter,可以使用 `Metric.counter` 构造器。 **示例**(创建 Counter) ```ts import { Metric, Effect } from "effect" const requestCount = Metric.counter("request_count", { // Optional description: "A counter for tracking requests", }) Metric.isMetric(requestCount) // => true ``` 创建之后,Counter 可以接收一个返回 `number` 的 effect,这个值会让 Counter 递增或递减。 **示例**(使用 Counter) ```ts import { Metric, Effect } from "effect" const requestCount = Metric.counter("request_count") const program = Effect.gen(function* () { // Increment the counter by 1 const a = yield* Effect.succeed(1).pipe(Effect.trackSuccesses(requestCount)) // Increment the counter by 2 const b = yield* Effect.succeed(2).pipe(Effect.trackSuccesses(requestCount)) // Decrement the counter by 4 const c = yield* Effect.succeed(-4).pipe(Effect.trackSuccesses(requestCount)) // Get the current state of the counter const state = yield* Metric.value(requestCount) console.log(state) state.count // => -1 return a * b * c }) await Effect.runPromise(program) // => -8 /* Output: CounterState { count: -1, ... } */ ``` ### Counter 类型 你可以指定 Counter 跟踪的是 `number` 还是 `bigint`。 ```ts import { Metric } from "effect" const numberCounter = Metric.counter("request_count", { description: "A counter for tracking requests", // bigint: false // default }) const bigintCounter = Metric.counter("error_count", { description: "A counter for tracking errors", bigint: true, }) Metric.isMetric(numberCounter) && Metric.isMetric(bigintCounter) // => true ``` ### 仅递增的 Counter 如果你需要一个只递增的 Counter,可以使用 `incremental: true` 选项。 **示例**(使用仅递增的 Counter) ```ts import { Metric, Effect } from "effect" const incrementalCounter = Metric.counter("count", { description: "a counter that only increases its value", incremental: true, }) const program = Effect.gen(function* () { const a = yield* Effect.succeed(1).pipe( Effect.trackSuccesses(incrementalCounter), ) const b = yield* Effect.succeed(2).pipe( Effect.trackSuccesses(incrementalCounter), ) // This will have no effect on the counter const c = yield* Effect.succeed(-4).pipe( Effect.trackSuccesses(incrementalCounter), ) const state = yield* Metric.value(incrementalCounter) console.log(state) state.count // => 3 return a * b * c }) await Effect.runPromise(program) // => -8 /* Output: CounterState { count: 3, ... } */ ``` 在这种配置下,Counter 只接受正值。任何递减的尝试都不会生效,从而确保 Counter 严格向上计数。 ### 带常量输入的 Counter 你可以把 Counter 配置为每次被调用时都按固定值递增。 **示例**(常量输入) ```ts import { Metric, Effect } from "effect" const taskCount = Metric.counter("task_count").pipe( Metric.withConstantInput(1), // Automatically increments by 1 ) const task1 = Effect.succeed(1).pipe(Effect.delay("100 millis")) const task2 = Effect.succeed(2).pipe(Effect.delay("200 millis")) const task3 = Effect.succeed(-4).pipe(Effect.delay("300 millis")) const program = Effect.gen(function* () { const a = yield* task1.pipe(Effect.trackSuccesses(taskCount)) const b = yield* task2.pipe(Effect.trackSuccesses(taskCount)) const c = yield* task3.pipe(Effect.trackSuccesses(taskCount)) const state = yield* Metric.value(taskCount) console.log(state) state.count // => 3 return a * b * c }) await Effect.runPromise(program) // => -8 /* Output: CounterState { count: 3, ... } */ ``` ## Gauge 在 metric 的世界里,Gauge 是一种表示单一数值的 metric,这个数值可以被设置或调整。可以把它想象成一个会随时间变化的动态变量。Gauge 的一个常见用途是监控应用的当前内存使用量这类指标。 与 Counter 不同(我们关心的是随时间累积的值),对于 Gauge,我们关注的是某个特定时间点上的当前值。 当你想要监控既可增大也可减小、并且不关心其变化速率的数值时,Gauge 是最佳选择。换句话说,Gauge 帮助我们度量在某个特定时刻具有特定值的量。 Gauge 的一些典型使用场景包括: - **内存使用量**:留意应用当前正在使用多少内存。 - **队列大小**:监控等待处理任务的队列的当前大小。 - **进行中的请求数**:跟踪服务器当前正在处理的请求数量。 - **温度**:测量当前温度,它会上下波动。 ### 如何创建 Gauge 要创建 Gauge,可以使用 `Metric.gauge` 构造器。 **示例**(创建 Gauge) ```ts import { Metric } from "effect" const memory = Metric.gauge("memory_usage", { // Optional description: "A gauge for memory usage", }) Metric.isMetric(memory) // => true ``` 创建之后,可以通过传入一个产生目标值的 effect 来更新 Gauge,该值就是你想为 Gauge 设置的值。 **示例**(使用 Gauge) ```ts import { Metric, Effect, Random } from "effect" // Create a gauge to track temperature const temperature = Metric.gauge("temperature") // Simulate fetching a random temperature const getTemperature = Effect.gen(function* () { // Get a random temperature between -10 and 10 const t = yield* Random.nextIntBetween(-10, 10) console.log(`new temperature: ${t}`) return t }) // Program that updates the gauge multiple times const program = Effect.gen(function* () { const series: Array = [] // Update the gauge with new temperature readings series.push(yield* getTemperature.pipe(Effect.trackSuccesses(temperature))) series.push(yield* getTemperature.pipe(Effect.trackSuccesses(temperature))) series.push(yield* getTemperature.pipe(Effect.trackSuccesses(temperature))) // Retrieve the current state of the gauge const state = yield* Metric.value(temperature) console.log(state) // The gauge always reflects the most recently set value, regardless of // what that (randomly generated) value happens to be state.value === series[series.length - 1] // => true return series }) const series = await Effect.runPromise(program) console.log(series) series.length // => 3 /* Example Output: new temperature: 9 new temperature: -9 new temperature: 2 GaugeState { value: 2, // the most recent value set in the gauge ... } [ 9, -9, 2 ] */ ``` ### Gauge 类型 你可以指定 Gauge 跟踪的是 `number` 还是 `bigint`。 ```ts import { Metric } from "effect" const numberGauge = Metric.gauge("memory_usage", { description: "A gauge for memory usage", // bigint: false // default }) const bigintGauge = Metric.gauge("cpu_load", { description: "A gauge for CPU load", bigint: true, }) Metric.isMetric(numberGauge) && Metric.isMetric(bigintGauge) // => true ``` ## Histogram Histogram 是一种用于分析数值如何随时间分布的 metric。它并不关注单个数据点,而是把值归入预先定义的范围(称为 **bucket**),并跟踪每个范围内落入多少个值。 当一个值被记录时,它会根据自己的大小被分配到 Histogram 的某个 bucket 中。每个 bucket 都有一个上边界,如果该值小于或等于这个边界,该 bucket 的计数就会增加。一旦记录完成,单个值就被丢弃,关注点转移到每个 bucket 中落入了多少个值。 Histogram 还会跟踪: - **总计数**:已观测到的值的数量。 - **总和**:所有已观测值的总和。 - **最小值**:最小的观测值。 - **最大值**:最大的观测值。 Histogram 对于计算百分位数特别有用,它通过分析每个 bucket 中有多少个值,帮助你估计数据集中的特定位置。 这个概念受到 [Prometheus](https://prometheus.io/docs/concepts/metric_types#histogram) 的启发,它是一个广为人知的监控与告警工具包。 Histogram 在性能分析和系统监控中特别有用。通过考察响应时间、延迟或其他 metric 如何分布,你可以深入了解系统的行为。这些数据有助于你发现异常值、性能瓶颈,或可能需要优化的趋势。 Histogram 的常见使用场景包括: - **百分位估计**:Histogram 让你可以近似计算观测值的百分位数,例如响应时间的第 95 百分位。 - **已知范围**:如果你能提前估计值的范围,Histogram 可以把数据组织到预先定义的 bucket 中,以便更好地分析。 - **性能指标**:使用 Histogram 跟踪请求延迟、内存使用量或吞吐量随时间的变化。 - **聚合**:Histogram 可以跨多个实例聚合,这使它非常适合需要从不同来源收集数据的分布式系统。 **示例**(使用线性 bucket 的 Histogram) 在这个示例中,我们定义了一个使用线性 bucket 的 Histogram,其值的范围从 `0` 到 `100`,步长为 `10`。此外,我们还添加了最后一个用于大于 `100` 的值的 bucket,称为 "Infinity" bucket。这种配置适合在特定范围内跟踪数值,例如请求延迟。 该程序生成 `1` 到 `120` 之间的随机数,把它们记录到 Histogram 中,然后打印 Histogram 的状态,展示落入每个 bucket 的值的数量。 ```ts import { Effect, Metric, Random } from "effect" // Define a histogram to track request latencies, with linear buckets const latency = Metric.histogram("request_latency", { // Buckets from 0-100, with an extra Infinity bucket boundaries: Metric.linearBoundaries({ start: 0, width: 10, count: 11 }), // Optional description: "Measures the distribution of request latency.", }) const program = Effect.gen(function* () { // Generate 100 random values and record them in the histogram yield* Random.nextIntBetween(1, 120).pipe( Effect.trackSuccesses(latency), Effect.repeat({ times: 99 }), ) // Fetch and display the histogram's state const state = yield* Metric.value(latency) console.log(state) // The bucket boundaries are fixed by configuration, and every value is // observed exactly once, regardless of the randomly generated numbers state.count // => 100 state.buckets.length // => 10 }) await Effect.runPromise(program) /* Example Output: HistogramState { buckets: [ [ 10, 7 ], // 7 values <= 10 (all of them between 1 and 10) [ 20, 11 ], // 11 values <= 20 (4 values between 11 and 20) [ 30, 20 ], // 20 values <= 30 (9 values between 21 and 30) [ 40, 27 ], // and so on... [ 50, 38 ], [ 60, 53 ], [ 70, 64 ], [ 80, 73 ], [ 90, 84 ], [ Infinity, 100 ] // All 100 values have been recorded ], count: 100, // Total count of observed values min: 1, // Smallest observed value max: 119, // Largest observed value sum: 5980, // Sum of all observed values ... } */ ``` ### Timer Metric 在这个示例中,我们演示如何使用 timer metric 跟踪特定工作流的耗时。Timer 会记录某些任务执行了多长时间,并把这些信息存入 Histogram,从而让你了解这些耗时的分布情况。 我们生成随机值来模拟不同的等待时间,把耗时记录到 timer 中,然后打印出 Histogram 的状态。 **示例**(使用 Timer Metric 跟踪工作流耗时) ```ts import { Metric, Array, Random, Effect } from "effect" // Create a timer metric with predefined boundaries from 1 to 10 const timer = Metric.timer("timer", { boundaries: Array.range(1, 10) }) // Define a task that simulates random wait times const task = Effect.gen(function* () { // Generate a random value between 1 and 10 const n = yield* Random.nextIntBetween(1, 10) // Simulate a delay based on the random value yield* Effect.sleep(`${n} millis`) }) const program = Effect.gen(function* () { // Track the duration of the task and repeat it 100 times yield* task.pipe(Effect.trackDuration(timer), Effect.repeat({ times: 99 })) // Retrieve and print the current state of the timer histogram const state = yield* Metric.value(timer) console.log(state) // Every repetition is observed exactly once, regardless of the randomly // generated durations state.count // => 100 }) await Effect.runPromise(program) /* Example Output: HistogramState { buckets: [ [ 1, 3 ], // 3 tasks completed in <= 1 ms [ 2, 13 ], // 13 tasks completed in <= 2 ms (10 tasks between 1 and 2 ms) [ 3, 17 ], // and so on... [ 4, 26 ], [ 5, 35 ], [ 6, 43 ], [ 7, 53 ], [ 8, 56 ], [ 9, 65 ], [ 10, 72 ], [ Infinity, 100 ] // All 100 tasks have completed ], count: 100, // Total number of tasks observed min: 0.25797, // Shortest task duration in milliseconds max: 12.25421, // Longest task duration in milliseconds sum: 683.0266810000002, // Total time spent across all tasks ... } */ ``` ## Summary Summary 是一种通过计算特定百分位数来洞察一系列数据点的 Metric。百分位数有助于我们理解数据的分布。例如,如果你在跟踪过去一小时内请求的响应时间,可能会想查看第 50、90、95 或 99 百分位数这类关键百分位数,以更好地了解系统的性能。 Summary 与 Histogram 类似,都是观察 `number` 值,但采取的方式不同。Summary 不会立即把值分到各个桶中并丢弃它们,而是把观察到的值保留在内存里。不过,为了避免存储过多数据,Summary 使用两个参数: - **maxAge**:值在被丢弃之前可以存在的最大时长。 - **maxSize**:Summary 中存储的值的最大数量。 这样就形成了一个由近期值组成的滑动窗口,因此 Summary 始终表示固定数量的最近观测值。 Summary 通常用于在这个滑动窗口上计算 **分位数(quantile)**。**分位数**是 `0` 到 `1` 之间的一个数,表示小于或等于某个阈值的值所占的百分比。例如,分位数 `0.5`(即第 50 百分位数)是**中位数**,而 `0.95`(即第 95 百分位数)则表示有 95% 的观测数据落在其之下的那个值。 分位数有助于监控延迟等重要性能指标,也有助于确保系统满足性能目标(例如服务级别协议,即 SLA)。 Summary 在以下情况下特别有用: - 你观察的值的范围事先未知,也无法预估,这使得 Histogram 不太实用。 - 你不需要跨多个实例聚合数据,也不需要平均结果。Summary 在应用侧计算结果,这意味着它们只关注自身被使用的那个具体实例。 **示例**(创建并使用 Summary) 在这个示例中,我们将创建一个 Summary 来跟踪响应时间。这个 Summary 将: - 最多保留 `100` 个样本。 - 丢弃早于 `1 day` 的样本。 - 报告 `10%`、`50%` 和 `90%` 分位数,它们有助于跟踪响应时间的分布。 我们会把这个 Summary 应用到一个生成随机整数、用以模拟响应时间的 effect 上。 ```ts import { Metric, Random, Effect } from "effect" // Define the summary for response times const responseTimeSummary = Metric.summary("response_time_summary", { maxAge: "1 day", // Maximum sample age maxSize: 100, // Maximum number of samples to retain quantiles: [0.1, 0.5, 0.9], // Quantiles to observe (10%, 50%, 90%) // Optional description: "Measures the distribution of response times", }) const program = Effect.gen(function* () { // Record 100 random response times between 1 and 120 ms yield* Random.nextIntBetween(1, 120).pipe( Effect.trackSuccesses(responseTimeSummary), Effect.repeat({ times: 99 }), ) // Retrieve and log the current state of the summary const state = yield* Metric.value(responseTimeSummary) console.log("%o", state) // The sample count and number of reported quantiles are fixed by // configuration, regardless of the randomly generated response times state.count // => 100 state.quantiles.length // => 3 }) await Effect.runPromise(program) /* Example Output: { quantiles: [ [ 0.1, 17 ], // 10th percentile: 17 ms [ 0.5, 62 ], // 50th percentile (median): 62 ms [ 0.9, 109 ] // 90th percentile: 109 ms ], count: 100, // Total number of samples recorded min: 4, // Minimum observed value max: 119, // Maximum observed value sum: 6058 // Sum of all recorded values } */ ``` ## Frequency Frequency 是一种帮助统计特定值出现次数的 Metric。可以把它们看作一组 Counter,每个 Counter 关联一个唯一的值。当观察到新值时,Frequency Metric 会自动为这些值创建新的 Counter。 对于跟踪不同字符串值出现的频率,Frequency 特别有用。一些示例用例包括: - 统计应用中每个服务的调用次数,其中每个服务都有一个逻辑名称。 - 监控不同类型的失败发生的频率。 **示例**(跟踪错误出现次数) 在这个示例中,我们将创建一个 `Frequency` 来观察不同错误码出现的频率。它可以应用于返回 `string` 值的 effect: ```ts import { Metric, Random, Effect } from "effect" // Define a frequency metric to track errors const errorFrequency = Metric.frequency("error_frequency", { // Optional description: "Counts the occurrences of errors.", }) const task = Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) return `Error-${n}` }) // Program that simulates random errors and tracks their occurrences const program = Effect.gen(function* () { yield* task.pipe( Effect.trackSuccesses(errorFrequency), Effect.repeat({ times: 99 }), ) // Retrieve and log the current state of the summary const state = yield* Metric.value(errorFrequency) console.log("%o", state) // The total number of occurrences across all distinct error codes is // fixed by the number of repetitions, regardless of which codes were // randomly generated Array.from(state.occurrences.values()).reduce((sum, n) => sum + n, 0) // => 100 }) await Effect.runPromise(program) /* Example Output: FrequencyState { occurrences: Map(9) { 'Error-7' => 12, 'Error-2' => 12, 'Error-4' => 14, 'Error-1' => 14, 'Error-9' => 8, 'Error-6' => 11, 'Error-5' => 9, 'Error-3' => 14, 'Error-8' => 6 }, ... } */ ``` ## Metric 属性 属性(attribute)是可以添加到 Metric 上的键值对,用于提供额外的上下文。它们有助于对 Metric 进行分类和过滤,让你更容易分析应用性能或行为的特定方面。 属性既可以附加到单个 Metric 上,也可以通过上下文提供给程序中的某个区域。 ### 为单个 Metric 添加属性 你可以使用 `Metric.withAttributes` 为单个 Metric 添加属性。 **示例**(为单个 Metric 打标签) ```ts import { Metric } from "effect" // Create a counter metric for request count // and add the "environment: production" attribute const counter = Metric.counter("request_count").pipe( Metric.withAttributes({ environment: "production" }), ) Metric.isMetric(counter) // => true ``` 这里,`request_count` Metric 带有属性 `"environment": "production"`,让你之后可以过滤或分析该指标序列。 ### 为某个区域提供属性 `Metric.CurrentMetricAttributes` 上下文引用会为某个 effect 内的每一次 Metric 操作提供属性。当多个 Metric 共享同一上下文(例如部署环境)时,这很有用。 **示例**(为多个 Metric 打标签) ```ts import { Metric, Effect } from "effect" // Create two separate counters const counter1 = Metric.counter("counter1") const counter2 = Metric.counter("counter2") // Define a task that simulates some work with a slight delay const task = Effect.succeed(1).pipe(Effect.delay("100 millis")) // Apply the environment attribute to both counters in the same context const program = Effect.gen(function* () { yield* task.pipe(Effect.trackSuccesses(counter1)) yield* task.pipe(Effect.trackSuccesses(counter2)) }).pipe( Effect.provideService(Metric.CurrentMetricAttributes, { environment: "production", }), ) // Confirm that running the program updates both attributed counters // (state must be read under the same attribute context it was written under) const readState = Effect.gen(function* () { yield* program const state1 = yield* Metric.value(counter1) const state2 = yield* Metric.value(counter2) return { state1, state2 } }).pipe( Effect.provideService(Metric.CurrentMetricAttributes, { environment: "production", }), ) const { state1, state2 } = await Effect.runPromise(readState) state1.count // => 1 state2.count // => 1 ``` --- # Effect 中的追踪 > 探索分布式系统中的追踪,使用 span 和 trace 跨服务追踪请求的生命周期,以便进行调试和性能优化。 尽管日志和指标有助于理解单个服务的行为,但它们不足以完整呈现分布式系统中一个请求的生命周期。 在分布式系统中,一个请求可能跨越多个服务,而每个服务为了完成该请求也可能向其他服务发起多次请求。在这种情况下,我们需要一种方法来追踪请求在多个服务之间的生命周期,从而诊断哪些服务是瓶颈,以及请求把大部分时间花在了哪里。 ## Span **span** 表示一个请求中的单个工作单元或操作。它详细呈现了该特定操作执行期间发生了什么。 每个 span 通常包含以下信息: | Span 组件 | 说明 | | ---------------- | ------------------------------------------------------------------ | | **Name** | 描述正在追踪的具体操作。 | | **Timing Data** | 指示操作开始时间的时间戳及其持续时间。 | | **Log Messages** | 捕获操作期间重要事件的结构化日志。 | | **Attributes** | 提供该操作附加上下文的元数据。 | span 是追踪中的关键构建块,帮助你可视化和理解请求在各种服务之间的流转。 ## Trace Trace 记录请求(由应用程序或最终用户发起)在微服务、无服务器应用等多服务架构中传播时所经过的路径。 如果没有追踪,就很难在分布式系统中定位性能问题的根因。 Trace 由一个或多个 span 组成。第一个 span 表示根 span。每个根 span 都表示一个从开始到结束的完整请求。父 span 之下的各个 span 提供了更深入的上下文,说明请求期间发生了什么(或者说一个请求由哪些步骤构成)。 许多可观测性后端会把 trace 可视化为瀑布图,大致如下所示: ![Trace 瀑布图](../_assets/waterfall-trace.svg "一张以瀑布图形式展示应用 trace 的图片") 瀑布图展示了根 span 与其子 span 之间的父子关系。当一个 span 包裹另一个 span 时,这也表示一种嵌套关系。 ## 创建 Span 你可以使用 `Effect.withSpan` API 创建一个 span,从而为 effect 添加追踪能力。这有助于你追踪 effect 中的特定操作。 **示例**(为 Effect 添加 Span) ```ts import { Effect } from "effect" // Define an effect that delays for 100 milliseconds const program = Effect.void.pipe(Effect.delay("100 millis")) // Instrument the effect with a span for tracing const instrumented = program.pipe(Effect.withSpan("myspan")) Effect.isEffect(instrumented) // => true await Effect.runPromise(instrumented) // => undefined ``` 用 span 对 effect 进行插桩不会改变其类型。如果你传入的是 `Effect`,结果仍然是 `Effect`。 ## 打印 Span 为了调试或分析而打印 span,你需要安装所需的追踪工具。以下是为你的项目配置它们的方法。 ### 安装依赖 选择你的包管理器并安装所需的库: ```sh # Install the main library for integrating OpenTelemetry with Effect npm install @effect/opentelemetry@rc # Install the required OpenTelemetry SDKs for tracing and metrics npm install @opentelemetry/sdk-trace-base npm install @opentelemetry/sdk-trace-node npm install @opentelemetry/sdk-trace-web npm install @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect pnpm add @effect/opentelemetry@rc # Install the required OpenTelemetry SDKs for tracing and metrics pnpm add @opentelemetry/sdk-trace-base pnpm add @opentelemetry/sdk-trace-node pnpm add @opentelemetry/sdk-trace-web pnpm add @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect yarn add @effect/opentelemetry@rc # Install the required OpenTelemetry SDKs for tracing and metrics yarn add @opentelemetry/sdk-trace-base yarn add @opentelemetry/sdk-trace-node yarn add @opentelemetry/sdk-trace-web yarn add @opentelemetry/sdk-metrics ``` ```sh # Install the main library for integrating OpenTelemetry with Effect bun add @effect/opentelemetry@rc # Install the required OpenTelemetry SDKs for tracing and metrics bun add @opentelemetry/sdk-trace-base bun add @opentelemetry/sdk-trace-node bun add @opentelemetry/sdk-trace-web bun add @opentelemetry/sdk-metrics ``` ### 将 Span 打印到控制台 依赖安装完成后,就可以使用 OpenTelemetry 配置 span 打印。下面的示例展示了如何为 effect 打印 span。 **示例**(设置并打印 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" // Define an effect that delays for 100 milliseconds const program = Effect.void.pipe(Effect.delay("100 millis")) // Instrument the effect with a span for tracing const instrumented = program.pipe(Effect.withSpan("myspan")) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, // Export span data to the console spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(instrumented.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: '673c06608bd815f7a75bf897ef87e186', parentId: undefined, traceState: undefined, name: 'myspan', id: '401b2846170cd17b', kind: 0, timestamp: 1733220735529855.5, duration: 102079.958, attributes: {}, status: { code: 1 }, events: [], links: [] } */ ``` ### 理解 Span 输出 输出中提供了关于该 span 的详细信息: | 字段 | 说明 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `traceId` | 整个 trace 的唯一标识符,帮助在请求或操作流经应用时对其进行追踪。 | | `parentId` | 标识当前 span 的父 span;当没有父 span 时,输出中会标记为 `undefined`,从而说明它是一个根 span。 | | `name` | 描述 span 的名称,指示正在追踪的操作(例如 “myspan”)。 | | `id` | 当前 span 的唯一标识符,用于将其与同一 trace 中的其他 span 区分开。 | | `timestamp` | 表示 span 开始时间的时间戳,以自 Unix 纪元以来的微秒数计量。 | | `duration` | 指定 span 的持续时间,表示完成该操作所花费的时间(例如 `2895.769` 微秒)。 | | `attributes` | span 可以包含 attributes,它们是提供操作附加上下文或信息的键值对。在此输出中,它是一个空对象,表示这个 span 没有任何特定的 attributes。 | | `status` | status 字段提供 span 状态的信息。在此例中,它的 code 为 1,通常表示 OK 状态(而 code 为 2 表示 ERROR 状态)。 | | `events` | span 可以包含 events,它们是 span 生命周期中特定时刻的记录。在此输出中,它是一个空数组,表示没有记录任何特定事件。 | | `links` | links 可用于将这个 span 与其他 trace 中的 span 关联起来。在输出中,它是一个空数组,表示这个 span 没有特定的 links。 | ### Span 捕获错误 下面是 effect 遇到错误时 span 呈现的样子: **示例**(失败 Effect 的 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const program = Effect.fail("Oh no!").pipe( Effect.delay("100 millis"), Effect.withSpan("myspan"), ) const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) Effect.runPromiseExit(program.pipe(Effect.provide(NodeSdkLive))).then( console.log, ) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'eee9619866179f209b7aae277283e71f', parentId: undefined, traceState: undefined, name: 'myspan', id: '3a5725c91884c9e1', kind: 0, timestamp: 1733220830575626, duration: 106578.042, attributes: { 'code.stacktrace': 'at (/Users/giuliocanti/Documents/GitHub/website/content/dev/index.ts:10:10)' }, status: { code: 2, message: 'Oh no!' }, events: [ { name: 'exception', attributes: { 'exception.type': 'Error', 'exception.message': 'Oh no!', 'exception.stacktrace': 'Error: Oh no!' }, time: [ 1733220830, 682204083 ], droppedAttributesCount: 0 } ], links: [] } { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Fail', failure: 'Oh no!' } } */ ``` 在这个示例中,span 的 status code 为 `2`,表示发生了错误。status 中的 message 提供了关于该失败的更多细节。 ## 添加注解 你可以使用 `Effect.annotateCurrentSpan` 函数为 span 提供额外信息。 该函数允许你附加键值对,为 span 的执行提供更多上下文。 **示例**(为 Span 添加注解) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const program = Effect.void.pipe( Effect.delay("100 millis"), // Annotate the span with a key-value pair Effect.tap(() => Effect.annotateCurrentSpan("key", "value")), // Wrap the effect in a span named 'myspan' Effect.withSpan("myspan"), ) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(program.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'c8120e01c0f1ea83ccc1d388e5cdebd3', parentId: undefined, traceState: undefined, name: 'myspan', id: '81c430ba4979f1db', kind: 0, timestamp: 1733220874356084, duration: 102821.417, attributes: { key: 'value' }, status: { code: 1 }, events: [], links: [] } */ ``` ## 日志即事件 在追踪的语境中,日志会被转换为 “Span Events”。这些事件以结构化方式揭示应用的活动,并提供特定操作发生时间的时间线。 ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" // Define a program that logs a message and delays for 100 milliseconds const program = Effect.log("Hello").pipe( Effect.delay("100 millis"), Effect.withSpan("myspan"), ) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(program.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'b0f4f012b5b13c0a040f7002a1d7b020', parentId: undefined, traceState: undefined, name: 'myspan', id: 'b9ba8472002715a8', kind: 0, timestamp: 1733220905504162.2, duration: 103790, attributes: {}, status: { code: 1 }, events: [ { name: 'Hello', attributes: { 'effect.fiberId': '#0', 'effect.logLevel': 'INFO' }, // Log attributes time: [ 1733220905, 607761042 ], // Event timestamp droppedAttributesCount: 0 } ], links: [] } */ ``` 每个 span 都可以包含 events,它们捕获 span 执行过程中的特定时刻。在这个示例中,一条日志消息 `"Hello"` 被记录为该 span 内的一个事件。该事件的关键细节包括: | 字段 | 说明 | | ------------------------ | ------------------------------------------------------------------------------------------------- | | `name` | 事件的名称,与所记录的日志消息对应(例如 `'Hello'`)。 | | `attributes` | 提供事件附加上下文的键值对,例如 `fiberId` 和日志级别。 | | `time` | 事件发生的时间戳,以高精度格式显示。 | | `droppedAttributesCount` | 表示有多少 attributes 被丢弃(如果有的话)。在此例中,没有 attributes 被丢弃。 | ## 嵌套 Span span 可以嵌套,以表示操作的层次结构。这让你能够追踪应用的不同部分在执行期间如何相互关联。下面的示例演示了如何创建和管理嵌套 span。 **示例**(在 Trace 中嵌套 Span) ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { ConsoleSpanExporter, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base" const child = Effect.void.pipe( Effect.delay("100 millis"), Effect.withSpan("child"), ) const parent = Effect.gen(function* () { yield* Effect.sleep("20 millis") yield* child yield* Effect.sleep("10 millis") }).pipe(Effect.withSpan("parent")) // Set up tracing with the OpenTelemetry SDK const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new ConsoleSpanExporter()), })) // Run the effect, providing the tracing layer Effect.runPromise(parent.pipe(Effect.provide(NodeSdkLive))) /* Example Output: { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'a9cd69ad70698a0c7b7b774597c77d39', parentId: 'a09e5c3fdfdbbc1d', // This indicates the span is a child of 'parent' traceState: undefined, name: 'child', id: '210d2f9b648389a4', // Unique ID for the child span kind: 0, timestamp: 1733220970590126.2, duration: 101579.875, attributes: {}, status: { code: 1 }, events: [], links: [] } { resource: { attributes: { 'service.name': 'example', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': '@effect/opentelemetry', 'telemetry.sdk.version': '1.28.0' } }, instrumentationScope: { name: 'example', version: undefined, schemaUrl: undefined }, traceId: 'a9cd69ad70698a0c7b7b774597c77d39', parentId: undefined, // Indicates this is the root span traceState: undefined, name: 'parent', id: 'a09e5c3fdfdbbc1d', // Unique ID for the parent span kind: 0, timestamp: 1733220970569015.2, duration: 132612.208, attributes: {}, status: { code: 1 }, events: [], links: [] } */ ``` 父子关系在 span 输出中清晰可见:`child` span 的 `parentId` 与 `parent` span 的 `id` 相匹配。这种结构有助于追踪单个 trace 内各操作之间的关联关系。 ## 教程:可视化 Trace 在本教程中,我们将带你一步步可视化一个示例 Effect 应用生成的 Trace。这个示例应用还被配置为通过 HTTP 以 [OTLP 格式](https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md)导出 Trace 和/或指标。 为了可视化应用导出的 Trace,我们将使用一个 Docker 镜像,其中包含一套预配置的 OpenTelemetry 后端,它基于 [OpenTelemetry Collector](https://opentelemetry.io/docs/collector)、[Prometheus](https://github.com/prometheus/prometheus)、[Loki](https://github.com/grafana/loki)、[Tempo](https://github.com/grafana/tempo) 和 [Grafana](https://github.com/grafana/grafana)。 ### 工具说明 让我们用通俗的语言来理解将要使用的这些工具: - **Docker**:Docker 让我们可以在容器中运行应用。可以把容器看作一个轻量且隔离的环境,无论宿主机系统是什么,你的应用都能在其中一致地运行。它有点像虚拟机,但更高效。 - **Prometheus**:Prometheus 是一个监控与告警工具包。它会收集应用的指标与数据并存储起来,以便进一步分析。这有助于发现性能问题、理解应用的行为。 - **Loki**:Loki 是一个受 Prometheus 启发的日志聚合系统。它不会为日志内容建立索引,而是为每个日志流的一组标签建立索引。 - **Grafana**:Grafana 是一个可视化与分析平台。它有助于创建美观且可交互的仪表盘,用来可视化应用的数据。你可以用它以图形方式展示 Prometheus 收集的指标。 - **Tempo**:Tempo 是一个分布式追踪系统,让你能够追踪一个请求在应用中流转的全过程。它提供关于请求如何被处理的洞察,并帮助你调试和优化应用。 ### 获取 Docker 要获取 Docker,请按以下步骤操作: 1. 访问 Docker 网站 [https://www.docker.com/](https://www.docker.com/)。 2. 下载适用于你的操作系统(Windows 或 macOS)的 Docker Desktop 并安装。 3. 安装完成后,打开 Docker Desktop,它会在后台运行。 ### 模拟 Trace 1. **启动 OpenTelemetry 后端** 在开始从示例应用生成并导出 Trace 之前,我们需要先在 Docker 中把 OpenTelemetry 后端运行起来。 可以用下面的命令完成: ```sh docker run -p 3000:3000 -p 4317:4317 -p 4318:4318 --rm -it docker.io/grafana/otel-lgtm ``` 2. **安装依赖** 我们还需要安装一些额外的依赖,以及最新版本的 `effect`: ```sh # If not already installed npm install effect@rc # Required to integrate Effect with OpenTelemetry npm install @effect/opentelemetry@rc # Required to export traces over HTTP in OTLP format npm install @opentelemetry/exporter-trace-otlp-http # Required by all applications npm install @opentelemetry/sdk-trace-base # For NodeJS applications npm install @opentelemetry/sdk-trace-node # For browser applications npm install @opentelemetry/sdk-trace-web # If you also need to export metrics npm install @opentelemetry/sdk-metrics ``` ```sh # If not already installed pnpm add effect@rc # Required to integrate Effect with OpenTelemetry pnpm add @effect/opentelemetry@rc # Required to export traces over HTTP in OTLP format pnpm add @opentelemetry/exporter-trace-otlp-http # Required by all applications pnpm add @opentelemetry/sdk-trace-base # For NodeJS applications pnpm add @opentelemetry/sdk-trace-node # For browser applications pnpm add @opentelemetry/sdk-trace-web # If you also need to export metrics pnpm add @opentelemetry/sdk-metrics ``` ```sh # If not already installed yarn add effect@rc # Required to integrate Effect with OpenTelemetry yarn add @effect/opentelemetry@rc # Required to export traces over HTTP in OTLP format yarn add @opentelemetry/exporter-trace-otlp-http # Required by all applications yarn add @opentelemetry/sdk-trace-base # For NodeJS applications yarn add @opentelemetry/sdk-trace-node # For browser applications yarn add @opentelemetry/sdk-trace-web # If you also need to export metrics yarn add @opentelemetry/sdk-metrics ``` ```sh # If not already installed bun add effect@rc # Required to integrate Effect with OpenTelemetry bun add @effect/opentelemetry@rc # Required to export traces over HTTP in OTLP format bun add @opentelemetry/exporter-trace-otlp-http # Required by all applications bun add @opentelemetry/sdk-trace-base # For NodeJS applications bun add @opentelemetry/sdk-trace-node # For browser applications bun add @opentelemetry/sdk-trace-web # If you also need to export metrics bun add @opentelemetry/sdk-metrics ``` 3. **模拟 Trace** 现在,让我们用一个示例 Node.js 应用来模拟 Trace。 下面的代码模拟了一组任务,并为每个任务生成 Trace。它还设置了一个 `Layer`,用于通过 HTTP 以 OTLP 格式把应用中的 Trace 导出到我们的 OpenTelemetry 后端。 ```ts import { Effect } from "effect" import { NodeSdk } from "@effect/opentelemetry" import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" // Function to simulate a task with possible subtasks const task = ( name: string, delay: number, children: ReadonlyArray> = [], ) => Effect.gen(function* () { yield* Effect.log(name) yield* Effect.sleep(`${delay} millis`) for (const child of children) { yield* child } yield* Effect.sleep(`${delay} millis`) }).pipe(Effect.withSpan(name)) const poll = task("/poll", 1) // Create a program with tasks and subtasks const program = task("client", 2, [ task("/api", 3, [ task("/authN", 4, [task("/authZ", 5)]), task("/payment Gateway", 6, [task("DB", 7), task("Ext. Merchant", 8)]), task("/dispatch", 9, [ task("/dispatch/search", 10), Effect.all([poll, poll, poll]), task("/pollDriver/{id}", 11), ]), ]), ]) const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter()), })) Effect.runPromise( program.pipe( Effect.provide(NodeSdkLive), Effect.catchCause(Effect.logError), ), ) /* Output: timestamp=... level=INFO fiber=#0 message=client timestamp=... level=INFO fiber=#0 message=/api timestamp=... level=INFO fiber=#0 message=/authN timestamp=... level=INFO fiber=#0 message=/authZ timestamp=... level=INFO fiber=#0 message="/payment Gateway" timestamp=... level=INFO fiber=#0 message=DB timestamp=... level=INFO fiber=#0 message="Ext. Merchant" timestamp=... level=INFO fiber=#0 message=/dispatch timestamp=... level=INFO fiber=#0 message=/dispatch/search timestamp=... level=INFO fiber=#3 message=/poll timestamp=... level=INFO fiber=#4 message=/poll timestamp=... level=INFO fiber=#5 message=/poll timestamp=... level=INFO fiber=#0 message=/pollDriver/{id} */ ``` 4. **可视化 Trace** 打开浏览器并访问 `http://localhost:3000/explore`。你应该会看到 Grafana Tempo 的 TraceQL 界面。 ![Tempo TraceQL 界面](../_assets/tempo-traceql-interface.png "未指定 TraceQL 查询时的 Grafana Tempo TraceQL 界面") 要获取所有可用 Trace 的列表,我们可以选择 `"Search"` 查询类型。 ![Tempo 搜索选择器](../_assets/tempo-trace-list.png "Grafana Tempo TraceQL 界面,其中 Search 选择器用红框标出") 点击生成的 Trace ID,就可以查看该 Trace 的详细信息。 ![Grafana Tempo 中的 Trace](../_assets/trace.png "以瀑布图形式在 Grafana Tempo 中可视化的 Effect 应用 Trace 详情") ## 集成 ### Sentry 要把 Span 数据直接发送到 Sentry 进行分析,请把默认的 span processor 替换为 Sentry 的实现。这样你就可以把 Sentry 用作追踪与调试的后端。 **示例**(为追踪配置 Sentry) ```ts import { NodeSdk } from "@effect/opentelemetry" import { SentrySpanProcessor } from "@sentry/opentelemetry" const NodeSdkLive = NodeSdk.layer(() => ({ resource: { serviceName: "example" }, spanProcessor: new SentrySpanProcessor(), })) ``` --- # 跟踪 Fiber > 使用 FiberSet 和 FiberMap 跟踪成组的 Fiber。 Effect 提供了两个用于跟踪成组 Fiber 的结构化并发原语:`FiberSet` 用于无键的集合,`FiberMap` 用于以任意值为键的集合。当 Fiber 完成时,它们都会自动将其移除;当拥有它们的 `Scope` 关闭时,它们会中断所有剩余的 Fiber。 ## 使用 FiberSet 跟踪 Fiber `FiberSet` 会收集 Fiber,以便将它们作为一个整体来观察、join 或中断。你可以用 `FiberSet.run` 向其中添加 Fiber(fork 一个 effect 并跟踪产生的 Fiber),也可以用 `FiberSet.add`(跟踪一个你已经 fork 出来的 Fiber);并可以通过 `FiberSet.size` 或直接遍历它来查看集合。 **示例**(监控 Fiber 数量) 在这个示例中,我们在计算一个斐波那契数时,周期性地监控应用中正在运行的 Fiber 数量。程序为每个递归步骤 fork 两个子 Fiber,并把它们都加入一个共享的 `FiberSet`;同时,一个独立的监控 Fiber 会按计划记录该集合的大小,直到计算结束。 ```ts import { Effect, Fiber, FiberSet, Schedule } from "effect" // Main program that monitors fibers while calculating a Fibonacci number const program = Effect.gen(function* () { // Create a FiberSet to track child fibers const set = yield* FiberSet.make() // Start a Fibonacci calculation, forking every recursive step into the set const fibFiber = yield* Effect.forkChild(fib(10, set)) // Start monitoring the fibers, logging the FiberSet's size every 20ms const monitorFiber = yield* Effect.forkChild( monitorFibers(set).pipe(Effect.repeat(Schedule.spaced("20 millis"))), ) // Wait for the Fibonacci calculation to finish, then stop the monitor const result = yield* Fiber.join(fibFiber) yield* Fiber.interrupt(monitorFiber) console.log(`fibonacci result: ${result}`) // The final result is deterministic even though the intermediate // "number of fibers" logs above are racy and vary between runs result // => 55 }).pipe(Effect.scoped) // Function to monitor and log the number of active fibers const monitorFibers = (set: FiberSet.FiberSet) => Effect.gen(function* () { const count = yield* FiberSet.size(set) // Get the current number of tracked fibers console.log(`number of fibers: ${count}`) }) // Recursive Fibonacci calculation, adding a fiber to the set for each recursive step const fib = ( n: number, set: FiberSet.FiberSet, ): Effect.Effect => Effect.gen(function* () { if (n <= 1) { return n } yield* Effect.sleep("30 millis") // Simulate work by delaying // Fork two fibers for the recursive Fibonacci calls, tracked by the FiberSet const fiber1 = yield* FiberSet.run(set, fib(n - 2, set)) const fiber2 = yield* FiberSet.run(set, fib(n - 1, set)) // Join the fibers to retrieve their results const v1 = yield* Fiber.join(fiber1) const v2 = yield* Fiber.join(fiber2) return v1 + v2 // Combine the results }) await Effect.runPromise(program) /* Example Output: number of fibers: 0 number of fibers: 0 number of fibers: 2 number of fibers: 6 number of fibers: 6 number of fibers: 14 number of fibers: 30 number of fibers: 30 number of fibers: 55 number of fibers: 62 number of fibers: 62 number of fibers: 35 number of fibers: 8 number of fibers: 8 */ ``` ## 使用 FiberMap 跟踪 Fiber `FiberMap` 的行为与 `FiberSet` 类似,但每个 Fiber 都跟踪在一个键之下。当你之后需要查找、替换或中断某个特定的 Fiber 时,这很有用,例如为每个已连接的客户端分配一个 Fiber,并以客户端 ID 为键。为某个已被占用的键设置新的 Fiber 时,会先中断上一个 Fiber。 关于完整的 `FiberMap` API,请参见 [FiberMap](https://effect.website/docs/v4/api/effect/FiberMap) 模块参考。 --- # 欢迎来到 Effect > Effect 是一个 TypeScript 库,提供类型化错误处理、结构化并发、资源安全与可观测性。 Effect 是一个用于构建生产级软件的 TypeScript 库:类型化错误处理、结构化并发、资源安全与可观测性,全都来自同一个可组合的核心。 ## 为什么选择 Effect? ### 数据有类型,程序无类型 TypeScript 非常擅长描述你的数据,但它对你的程序几乎只字不提:一个函数的签名不会告诉你它可能出什么错、需要哪些依赖,也无法说明它能否被安全地重试、超时或中断。随着应用不断增长,团队最终只能用临时拼凑的 try/catch、缺乏结构的 Promise 以及彼此无法组合的库,手工去实现这些保障。 ### 程序即值 Effect 用一个构建单元填补了这一空白:`Effect` 类型,它是一个完整描述程序的值,包括它的成功值、可能出现的错误,以及运行它所需的依赖。正因为程序是值,它们才能够组合:重试、超时、并发、资源管理和链路追踪都是你可以施加的运算符,而不是需要反复重建的架构。
开箱即用
类型化错误
失败会体现在签名中,并且可以像数据一样被处理。
重试与调度
用可组合的退避策略,取代你自己编写的循环。
结构化并发
有界的并行任务,并且会自行善后。
资源安全
资源的获取与释放都有保证,即使出现失败也不例外。
依赖注入
服务通过类型系统串联起来,在测试中替换它们轻而易举。
可观测性
运行时内置链路追踪、指标和结构化日志。
流式处理
支持背压的 Stream 与其他一切共享同一套运算符。
Schema 校验
用与现实相符的类型来解析和转换数据。
配置
从环境中读取的类型化配置,在启动时完成校验,敏感信息会被 脱敏。
一致的生态系统
HTTP、SQL、CLI、AI 和平台相关的包都构建在同一个核心之上。
### 为 AI 时代而生 AI 时代让这一切变得更为关键。当编码智能体编写的软件在你项目中的占比越来越大时,瓶颈就从写代码转移到了信任代码。Effect 让程序的失败模式、依赖关系和生命周期对编译器可见,把运行时的意外转化为智能体可以据以行动的精确反馈。而当你正在构建的东西本身就是 AI 应用时,不稳定的服务提供方、重试、流式传输和速率限制,正是 Effect 开箱即用地解决的问题。 ## 你的学习路径 Effect 值得按顺序学习:每一步都建立在前一步之上。沿着这条主线走下来,大多数开发者只需专注投入几天;其余内容都从这里分叉出去。
  1. 理解核心思想 Effect 是一个描述程序的值:它产出什么、可能如何失败、运行需要 什么。其他一切都建立在这一个类型之上。
  2. 搭建你的项目 安装这个库并配置 TypeScript。Effect 只是一个依赖,无需任何 额外工具。
  3. 编写你的第一个程序 创建 Effect,用生成器把它们组合起来,并在应用的边界处运行 它们。
  4. 用 Effect 的方式处理错误 错误是有类型的值,而不是意外。了解预期失败与非预期失败、 回退方案以及重试。
  5. 进入并发世界 以有界并发并行运行 Effect,让它们相互竞速,并交给结构化并发 为你善后。
下面就是你在第一个小时内会写出的那种程序: ```ts import { Effect } from "effect" const program = Effect.gen(function* () { const name = yield* Effect.succeed("world") yield* Effect.log("Hello, " + name + "!") }) Effect.runPromise(program) ``` ## 继续前进 ## 加入我们的社区 Effect 社区非常活跃:核心团队和经验丰富的用户每天都在讨论,任何问题都不会被忽视。中文读者可以加入[中文社区微信群](/community/)直接在群里提问,官方的 [GitHub 仓库](https://github.com/Effect-TS) 同样欢迎参与。 --- # FileSystem > 探索 Effect 中用于读取、写入和管理文件与目录的文件系统操作。 `effect/FileSystem` 模块提供了一组用于从文件系统读取以及向文件系统写入的操作。 ## 基本用法 该模块只提供一个 `FileSystem` [service key](/docs/v4/requirements-management/services/),它是与文件系统交互的入口。 **示例**(访问文件系统操作) ```ts import { Effect, FileSystem } from "effect" const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem // Use `fs` to perform file system operations }) Effect.isEffect(program) // => true ``` `FileSystem` 接口包含以下操作: | 操作 | 说明 | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **access** | 检查文件是否可以被访问。你可以选择性地指定要检查的访问级别。 | | **copy** | 将文件或目录从 `fromPath` 复制到 `toPath`。等价于 `cp -r`。 | | **copyFile** | 将文件从 `fromPath` 复制到 `toPath`。 | | **chmod** | 更改文件的权限。 | | **chown** | 更改文件的所有者和所属组。 | | **exists** | 检查某个路径是否存在。 | | **link** | 从 `fromPath` 到 `toPath` 创建硬链接。 | | **makeDirectory** | 在 `path` 处创建目录。你可以选择性地指定权限模式以及是否递归创建嵌套目录。 | | **makeTempDirectory** | 创建一个临时目录。默认情况下,该目录会创建在系统的默认临时目录中。 | | **makeTempDirectoryScoped** | 在 scope 内创建一个临时目录。功能上等价于 `makeTempDirectory`,但当 scope 关闭时该目录会被自动删除。 | | **makeTempFile** | 创建一个临时文件。其目录创建方式在功能上等价于 `makeTempDirectory`。文件名将是一个随机生成的字符串。 | | **makeTempFileScoped** | 在 scope 内创建一个临时文件。功能上等价于 `makeTempFile`,但当 scope 关闭时该文件会被自动删除。 | | **open** | 以指定的 `options` 打开 `path` 处的文件。当 scope 关闭时,文件句柄会被自动关闭。 | | **readDirectory** | 列出目录的内容。你可以通过设置 `recursive` 选项来递归列出嵌套目录的内容。 | | **readFile** | 读取文件的内容。 | | **readFileString** | 以字符串形式读取文件的内容。 | | **readLink** | 读取符号链接的目标。 | | **realPath** | 将路径解析为规范化的绝对路径名。 | | **remove** | 删除文件或目录。通过将 `recursive` 选项设为 `true`,你可以递归删除嵌套目录。 | | **rename** | 重命名文件或目录。 | | **sink** | 为指定的 `path` 创建一个可写的 `Sink`。 | | **stat** | 获取 `path` 处文件的信息。 | | **stream** | 为指定的 `path` 创建一个可读的 `Stream`。 | | **symlink** | 从 `fromPath` 到 `toPath` 创建符号链接。 | | **truncate** | 将文件截断到指定长度。如果未指定 `length`,文件将被截断为长度 `0`。 | | **utimes** | 更改 `path` 处文件的文件系统时间戳。 | | **watch** | 监视目录或文件的变化。 | | **writeFile** | 将数据写入 `path` 处的文件。 | | **writeFileString** | 将字符串写入 `path` 处的文件。 | **示例**(将文件读取为字符串) ```ts import { Effect, FileSystem } from "effect" import { NodeServices, NodeRuntime } from "@effect/platform-node" // ┌─── Effect // ▼ const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem // Reading the content of the same file where this code is written const content = yield* fs.readFileString("./index.ts", "utf8") console.log(content) }) // Provide the necessary context and run the program NodeRuntime.runMain(program.pipe(Effect.provide(NodeServices.layer))) ``` ## Mock 文件系统 在测试环境中,你可能希望 mock 文件系统,以避免执行真实的磁盘操作。`FileSystem.layerNoop` 提供了 `FileSystem` service 的空操作实现。 `FileSystem.layerNoop` 中的大多数操作会返回 **failure**(例如对缺失文件返回 `Effect.fail`)或 **defect**(例如对未实现的功能返回 `Effect.die`)。 不过,你可以通过向 `FileSystem.layerNoop` 传入一个对象,为选定的方法定义自定义返回值,从而覆盖特定的行为。 **示例**(以自定义行为 Mock 文件系统) ```ts import { Effect, FileSystem } from "effect" const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const exists = yield* fs.exists("/some/path") console.log(exists) exists // => true const content = yield* fs.readFileString("/some/path") console.log(content) content // => "mocked content" }) // ┌─── Layer // ▼ const customMock = FileSystem.layerNoop({ readFileString: () => Effect.succeed("mocked content"), exists: (path) => Effect.succeed(path === "/some/path"), }) // Provide the customized FileSystem mock implementation Effect.runPromise(program.pipe(Effect.provide(customMock))) ``` --- # Effect Platform 简介 > 使用 Effect 内置的 platform 模块,以统一的抽象为 Node.js、Deno、Bun 和浏览器构建跨平台应用。 `effect` 包内置了 platform 模块,用于在 Node.js、Deno、Bun 和浏览器等环境中构建与平台无关的抽象。 借助这些模块,你可以把 [FileSystem](/docs/v4/platform/file-system/) 或 [Terminal](/docs/v4/platform/terminal/) 这类抽象服务集成到程序中。 在组装最终应用时,你可以使用对应的包,为目标平台提供具体的 [layers](/docs/v4/requirements-management/layers/): - `@effect/platform-node`,用于 Node.js 或 Deno - `@effect/platform-bun`,用于 Bun - `@effect/platform-browser`,用于浏览器 ### 稳定模块 以下模块已经稳定,它们的文档可以在本站查阅: | Module | Description | Status | | --------------------------------------------------- | ------------------------------------------------- | ----------------------------------------- | | [FileSystem](/docs/v4/platform/file-system/) | 一套用于文件系统操作的模块。 | | | [Path](/docs/v4/platform/path/) | 处理文件路径的工具。 | | | [PlatformLogger](/docs/v4/platform/platformlogger/) | 使用 FileSystem API 把日志消息写入文件。 | | | [Runtime](/docs/v4/platform/runtime/) | 以内置的错误处理与日志功能运行你的程序。 | | | [Terminal](/docs/v4/platform/terminal/) | 用于终端交互的工具。 | | ## 安装 platform 模块已包含在 `effect` 包中,因此无需额外安装。关于如何安装 `effect` 本身,请参阅[安装](/docs/v4/getting-started/installation/)。 `@effect/platform-node` 这类特定于平台的包,只有在具体平台上运行程序时才需要,如下文各节所示。 ## 跨平台编程入门 下面是一个基础示例,使用 `Path` 模块创建一个文件路径,它可以在不同环境中运行: **示例**(跨平台路径处理) ```ts import { Effect, Path } from "effect" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) return mypath }) Effect.runSync(program.pipe(Effect.provide(Path.layer))) // => "tmp/file.txt" ``` ### 在 Node.js 或 Deno 中运行程序 首先,安装 Node.js 专用的包: ```sh npm install @effect/platform-node@rc ``` ```sh pnpm add @effect/platform-node@rc ``` ```sh yarn add @effect/platform-node@rc ``` ```sh deno add npm:@effect/platform-node@rc ``` 更新程序,让它加载 Node.js 专用的 context: **示例**(提供 Node.js context) ```ts import { Effect, Path } from "effect" import { NodeServices, NodeRuntime } from "@effect/platform-node" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeServices.layer))) ``` 最后,使用 `tsx` 在 Node.js 中运行程序,或直接在 Deno 中运行: ```sh npx tsx index.ts # Output: tmp/file.txt ``` ```sh pnpm dlx tsx index.ts # Output: tmp/file.txt ``` ```sh yarn dlx tsx index.ts # Output: tmp/file.txt ``` ```sh deno run index.ts # Output: tmp/file.txt # or deno run -RE index.ts # Output: tmp/file.txt # (granting required Read and Environment permissions without being prompted) ``` ### 在 Bun 中运行程序 要在 Bun 中运行同一个程序,首先安装 Bun 专用的包: ```sh bun add @effect/platform-bun@rc ``` 更新程序,让它使用 Bun 专用的 context: **示例**(提供 Bun context) ```ts import { Effect, Path } from "effect" import { BunServices, BunRuntime } from "@effect/platform-bun" const program = Effect.gen(function* () { // Access the Path service const path = yield* Path.Path // Join parts of a path to create a complete file path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer))) ``` 在 Bun 中运行程序: ```sh bun index.ts tmp/file.txt ``` --- # Path > 跨平台执行文件路径操作,例如拼接、解析和规范化。 `effect/Path` 模块提供了一组用于处理文件路径的操作。 ## 基本用法 该模块只提供一个 `Path` [service key](/docs/v4/requirements-management/services/),它是与路径交互的入口。 **示例**(访问 Path 服务) ```ts import { Effect, Path } from "effect" const program = Effect.gen(function* () { const path = yield* Path.Path // Use `path` to perform various path operations }) Path.Path.key // => "effect/Path" ``` `Path` 接口包含以下操作: | 操作 | 说明 | | -------------------- | ------------------------------------------------------------------- | | **basename** | 返回路径的最后一部分,可选地移除给定的后缀。 | | **dirname** | 返回路径的目录部分。 | | **extname** | 返回路径中的文件扩展名。 | | **format** | 将路径对象格式化为路径字符串。 | | **fromFileUrl** | 将文件 URL 转换为路径。 | | **isAbsolute** | 检查路径是否为绝对路径。 | | **join** | 将多个路径片段拼接成一个。 | | **normalize** | 通过解析 `.` 和 `..` 片段来规范化路径。 | | **parse** | 将路径字符串解析为包含各片段的对象。 | | **relative** | 计算从一个路径到另一个路径的相对路径。 | | **resolve** | 将一组路径解析为绝对路径。 | | **sep** | 返回平台特定的路径片段分隔符(例如 POSIX 上的 `/`)。 | | **toFileUrl** | 将路径转换为文件 URL。 | | **toNamespacedPath** | 将路径转换为带命名空间的路径(Windows 特有)。 | **示例**(拼接路径片段) ```ts import { Effect, Path } from "effect" import { NodeServices, NodeRuntime } from "@effect/platform-node" const program = Effect.gen(function* () { const path = yield* Path.Path const mypath = path.join("tmp", "file.txt") console.log(mypath) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeServices.layer))) // Output: "tmp/file.txt" ``` --- # PlatformLogger > 使用 FileSystem API 将日志消息写入文件。 Effect 的日志系统默认通常会把消息写入控制台。不过,你可能更希望把日志存到文件里,以便调试或归档。`Logger.toFile` 函数会创建一个 logger,把日志消息写入磁盘上的文件。 ### toFile 基于已有的字符串 logger 创建一个新的 logger,并把它的输出写入指定文件。 如果在调用 `toFile` 时传入一个 `batchWindow` 时长,日志会先在该时间窗口内批量累积,然后再写入。当你的应用产生大量日志条目时,这可以降低开销。若不设置 `batchWindow`,日志会在到达时立即写入。 请注意,`toFile` 返回一个 `Effect`,如果文件无法打开或写入,它可能以 `PlatformError` 失败。如果你需要对文件 I/O 问题作出反应,请务必处理这种可能性。 **示例**(将日志写入文件) 这个 logger 需要一个 `FileSystem` 实现来打开并写入文件。在 Node.js 上,你可以使用 `NodeFileSystem.layer`。 ```ts import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer, Logger } from "effect" // Create a string-based logger (formatLogFmt in this case) const myStringLogger = Logger.formatLogFmt // Apply toFile to write logs to "/tmp/log.txt" const fileLogger = myStringLogger.pipe(Logger.toFile("/tmp/log.txt")) // Replace the default logger, providing NodeFileSystem // to access the file system const LoggerLive = Logger.layer([fileLogger]).pipe( Layer.provide(NodeFileSystem.layer), ) const program = Effect.log("Hello") // Run the program, writing logs to /tmp/log.txt await Effect.runPromise(program.pipe(Effect.provide(LoggerLive))) /* Logs will be written to "/tmp/log.txt" in the logfmt format, and won't appear on the console. */ // Read back the file to verify the log entry was written // (the timestamp is omitted from the assertion since it varies) const content = await Effect.runPromise( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem return yield* fs.readFileString("/tmp/log.txt") }).pipe(Effect.provide(NodeFileSystem.layer)), ) content.includes("level=Info") // => true content.includes("message=Hello") // => true ``` 在下面的示例中,日志会同时写入控制台和文件。控制台使用 pretty logger,而文件使用 logfmt 格式。 **示例**(同时将日志写入文件和控制台) ```ts import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer, Logger } from "effect" const fileLogger = Logger.formatLogFmt.pipe(Logger.toFile("/tmp/log.txt")) // Combine the pretty logger for console output with the file logger const LoggerLive = Logger.layer([Logger.consolePretty(), fileLogger]).pipe( Layer.provide(NodeFileSystem.layer), ) const program = Effect.log("Hello") // Run the program, writing logs to both the console (pretty format) // and "/tmp/log.txt" (logfmt) await Effect.runPromise(program.pipe(Effect.provide(LoggerLive))) // The console output includes ANSI styling and a wall-clock timestamp, // so only the file (logfmt) output is asserted here const content = await Effect.runPromise( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem return yield* fs.readFileString("/tmp/log.txt") }).pipe(Effect.provide(NodeFileSystem.layer)), ) content.includes("message=Hello") // => true ``` --- # Runtime > 使用内置的错误处理与日志功能来运行你的程序。 ## 使用 runMain 运行主程序 `runMain` 可以帮助你执行主 effect,并内置了错误处理、日志记录和信号管理。你可以专注于自己的 effect,而由 `runMain` 负责收尾资源、记录错误并设置退出码。 - **退出码(Exit Codes)** 如果你的 effect 失败或被中断,`runMain` 会指定一个合适的退出码(例如,出错时用 `1`,成功时用 `0`)。 - **日志(Logs)** 默认情况下,它会记录错误。如有需要,可以将其关闭。 - **中断处理(Interrupt Handling)** 如果应用程序收到 `SIGINT`(Ctrl+C)或类似的信号,`runMain` 会中断该 effect,并且仍然执行必要的清理步骤。 - **收尾逻辑(Teardown Logic)** 你可以依赖默认的收尾逻辑,也可以定义自己的逻辑。默认逻辑会为非中断型失败设置退出码 `1`。 ### 用法选项 调用 `runMain` 时,传入一个包含以下字段的配置对象(所有字段都是可选的): - `disableErrorReporting`:如果为 `true`,错误不会被自动记录到日志中。 - `teardown`:提供一个用于结束程序的自定义函数。如果未提供,默认逻辑会为非中断型失败设置退出码 `1`。 **示例**(运行一个成功的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const success = Effect.succeed("Hello, World!") NodeRuntime.runMain(success) // No Output ``` **示例**(运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure) /* Output: [12:43:07.186] ERROR (#0): Error: Uh oh! */ ``` **示例**(在关闭错误报告的情况下运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure, { disableErrorReporting: true }) // No Output ``` **示例**(使用自定义收尾逻辑运行一个失败的程序) ```ts import { NodeRuntime } from "@effect/platform-node" import { Effect } from "effect" const failure = Effect.fail("Uh oh!") NodeRuntime.runMain(failure, { teardown: function customTeardown(exit, onExit) { if (exit._tag === "Failure") { console.error("Program ended with an error.") onExit(1) } else { console.log("Program finished successfully.") onExit(0) } }, }) /* Output: [12:46:39.871] ERROR (#0): Error: Uh oh! Program ended with an error. */ ``` --- # Terminal > 与标准输入和输出交互,读取用户输入并在终端上显示消息。 `effect/Terminal` 模块提供了一个用于与标准输入和输出交互的抽象,包括读取用户输入以及在终端上显示消息。 ## 基本用法 该模块提供一个单独的 `Terminal` [service key](/docs/v4/requirements-management/services/),它是读取标准输入、写入标准输出的入口。 **示例**(使用 Terminal 服务) ```ts import { Effect, Terminal } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal // Use `terminal` to interact with standard input and output }) Effect.isEffect(program) // => true ``` ## 写入标准输出 **示例**(在终端上显示一条消息) ```ts import { NodeRuntime, NodeTerminal } from "@effect/platform-node" import { Effect, Terminal } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display("a message\n") }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeTerminal.layer))) // Output: "a message" ``` ## 从标准输入读取 **示例**(从标准输入读取一行) ```ts import { NodeRuntime, NodeTerminal } from "@effect/platform-node" import { Effect, Terminal } from "effect" const program = Effect.gen(function* () { const terminal = yield* Terminal.Terminal const input = yield* terminal.readLine console.log(`input: ${input}`) }) NodeRuntime.runMain(program.pipe(Effect.provide(NodeTerminal.layer))) // Input: "hello" // Output: "input: hello" ``` ## 示例:猜数字游戏 这个示例演示了如何通过从终端读取输入并向用户提供反馈,来实现一个完整的猜数字游戏。游戏会一直继续,直到用户猜中正确的数字。 **示例**(交互式猜数字游戏) ```ts import { Effect, Option, Random, Terminal } from "effect" import type { PlatformError } from "effect/PlatformError" import { NodeRuntime, NodeTerminal } from "@effect/platform-node" // Generate a secret random number between 1 and 100 const secret = Random.nextIntBetween(1, 100) // Parse the user's input into a valid number const parseGuess = (input: string) => { const n = parseInt(input, 10) return isNaN(n) || n < 1 || n > 100 ? Option.none() : Option.some(n) } // Display a message on the terminal const display = (message: string) => Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display(`${message}\n`) }) // Prompt the user for a guess const prompt = Effect.gen(function* () { const terminal = yield* Terminal.Terminal yield* terminal.display("Enter a guess: ") return yield* terminal.readLine }) // Get the user's guess, validating it as an integer between 1 and 100 const answer: Effect.Effect< number, Terminal.QuitError | PlatformError, Terminal.Terminal > = Effect.gen(function* () { const input = yield* prompt const guess = parseGuess(input) if (Option.isNone(guess)) { yield* display("You must enter an integer from 1 to 100") return yield* answer } return guess.value }) // Check if the guess is too high, too low, or correct const check = ( secret: number, guess: number, ok: Effect.Effect, ko: Effect.Effect, ) => Effect.gen(function* () { if (guess > secret) { yield* display("Too high") return yield* ko } else if (guess < secret) { yield* display("Too low") return yield* ko } else { return yield* ok } }) // End the game with a success message const end = display("You guessed it!") // Main game loop const loop = ( secret: number, ): Effect.Effect => Effect.gen(function* () { const guess = yield* answer return yield* check( secret, guess, end, Effect.suspend(() => loop(secret)), ) }) // Full game setup and execution const game = Effect.gen(function* () { yield* display( `We have selected a random number between 1 and 100. See if you can guess it in 10 turns or fewer. We'll tell you if your guess was too high or too low.`, ) yield* loop(yield* secret) }) // Run the game NodeRuntime.runMain(game.pipe(Effect.provide(NodeTerminal.layer))) ``` --- # 默认服务 > 了解 Effect 中的默认服务,包括 Clock、Console、Random、ConfigProvider 和 Tracer,以及它们如何被自动提供给我们的程序。 Effect 内置了五种服务的 live 实现:`Clock`、`ConfigProvider`、`Console`、`Random` 和 `Tracer`。 使用这些服务时,我们不需要显式提供它们的实现。Effect 会自动把它们的 live 版本提供给我们的 effect,省去了手动配置的麻烦。 **示例**(使用 Clock 和 Console) ```ts import { Effect, Clock, Console } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis yield* Console.log(`Application started at ${new Date(now)}`) }) Effect.runFork(program) // Output: Application started at await Effect.runPromise(program) // => undefined ``` 可以看到,即使我们的程序同时使用了 `Clock` 和 `Console`,代表该 effect 执行所需服务的 `Requirements` 参数依然保持为 `never`。 Effect 会替我们无缝地处理这些服务。 ## 覆盖默认服务 每个默认服务都以 `Context.Reference` 的形式暴露:`Clock.Clock`、`ConfigProvider.ConfigProvider`、`Console.Console`、`Random.Random` 和 `Tracer.Tracer`。使用 `Effect.provideService` 可以用不同的实现来运行 effect。该覆盖只对被提供的那个 effect 生效。 有些模块还提供了用于常见覆盖场景的更高级辅助函数。例如,`Random.withSeed` 可以为某个 effect 安装一个确定性的随机数生成器。 **示例**(覆盖 Random 服务) ```ts import { Effect, Random } from "effect" // A program that logs a random number const program = Effect.gen(function* () { console.log(yield* Random.next) }) Effect.runSync(program) // Example Output: 0.23208633934454326 (varies each run) // Override the Random service with a seeded generator const override = program.pipe(Random.withSeed("myseed")) Effect.runSync(override) // Output: 0.10428056576185751 (consistent output with the seed) // The seed makes the generated value fully deterministic Effect.runSync(Random.next.pipe(Random.withSeed("myseed"))) // => 0.10428056576185751 ``` --- # Layer 的记忆化 > 学习 Layer 的记忆化如何通过复用 Layer 并控制其实例化来优化 Effect 应用中的性能。 Layer 的记忆化允许一个 Layer 只创建一次,并在依赖图中被多次使用。如果我们两次使用同一个 Layer: ```ts Layer.merge(Layer.provide(L2, L1), Layer.provide(L3, L1)) ``` 那么 `L1` 这个 Layer 只会被分配一次。 ## 全局提供时的记忆化 Effect 应用的一个重要特性是:Layer 默认会被共享。这意味着,如果同一个 Layer 被使用了两次,并且我们以全局方式提供它,那么这个 Layer 只会被分配一次。对于依赖图中的每个 Layer,都只有一个实例,在所有依赖它的 Layer 之间共享。 **示例** 例如,假设我们有三个 Service:`A`、`B` 和 `C`。`B` 和 `C` 的实现都依赖 `A` 这个 Service: ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Service()("A") {} class B extends Context.Service()("B") {} class C extends Context.Service()("C") {} let initCount = 0 const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe( Effect.tap(() => Effect.log("initialized")), Effect.tap(() => Effect.sync(() => initCount++)), ), ) const BLive = Layer.effect( B, Effect.gen(function* () { const { a } = yield* A return { b: String(a) } }), ) const CLive = Layer.effect( C, Effect.gen(function* () { const { a } = yield* A return { c: a > 0 } }), ) const program = Effect.gen(function* () { yield* B yield* C }) const runnable = Effect.provide( program, Layer.merge(Layer.provide(BLive, ALive), Layer.provide(CLive, ALive)), ) await Effect.runPromise(runnable) /* Output: timestamp=... level=INFO fiber=#2 message=initialized */ // ALive is only built once, no matter how many layers depend on it initCount // => 1 ``` 尽管 `BLive` 和 `CLive` 这两个 Layer 都需要 `ALive`,但 `ALive` 只会被实例化一次。它被 `BLive` 和 `CLive` 共享。 ## 获取全新版本 如果我们不想共享某个模块,就应该通过 `Layer.fresh` 创建一个全新的、不共享的版本。 **示例** ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Service()("A") {} class B extends Context.Service()("B") {} class C extends Context.Service()("C") {} let initCount = 0 const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe( Effect.tap(() => Effect.log("initialized")), Effect.tap(() => Effect.sync(() => initCount++)), ), ) const BLive = Layer.effect( B, Effect.gen(function* () { const { a } = yield* A return { b: String(a) } }), ) const CLive = Layer.effect( C, Effect.gen(function* () { const { a } = yield* A return { c: a > 0 } }), ) const program = Effect.gen(function* () { yield* B yield* C }) const runnable = Effect.provide( program, Layer.merge( Layer.provide(BLive, Layer.fresh(ALive)), Layer.provide(CLive, Layer.fresh(ALive)), ), ) await Effect.runPromise(runnable) /* Output: timestamp=... level=INFO fiber=#2 message=initialized timestamp=... level=INFO fiber=#3 message=initialized */ // Layer.fresh forces a brand-new (non-shared) instance for each usage initCount // => 2 ``` ## 局部提供时不进行记忆化 如果我们不以全局方式提供 Layer,而是在局部提供它们,那么该 Layer 默认不支持记忆化。 **示例** 在下面的例子中,我们在局部两次提供了 `ALive` Layer,Effect 不会对 `ALive` 的构造进行记忆化。 因此,它会被初始化两次: ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Service()("A") {} let initCount = 0 const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe( Effect.tap(() => Effect.log("initialized")), Effect.tap(() => Effect.sync(() => initCount++)), ), ) const program = Effect.gen(function* () { yield* Effect.provide(A, ALive) yield* Effect.provide(A, ALive) }) await Effect.runPromise(program) /* Output: timestamp=... level=INFO fiber=#0 message=initialized timestamp=... level=INFO fiber=#0 message=initialized */ // Providing the layer locally rebuilds it every time, so it runs twice initCount // => 2 ``` ## 手动记忆化 我们可以使用 `Layer.MemoMap` 手动对一个 Layer 进行记忆化。`MemoMap` 会记录哪些 Layer 已经构建过,这样再次构建同一个 Layer 时,只要针对同一个 `MemoMap`,就会复用之前的结果,而不是重新执行它的获取 Effect。 使用 `Layer.makeMemoMap` 创建一个 `MemoMap`,然后用 `Layer.buildWithMemoMap` 把一个 Layer 构建到 `Context` 中,同时传入 `MemoMap` 和一个 `Scope`。 **示例** ```ts import { Effect, Context, Layer } from "effect" class A extends Context.Service()("A") {} let initCount = 0 const ALive = Layer.effect( A, Effect.succeed({ a: 5 }).pipe( Effect.tap(() => Effect.log("initialized")), Effect.tap(() => Effect.sync(() => initCount++)), ), ) const program = Effect.gen(function* () { const memoMap = yield* Layer.makeMemoMap const scope = yield* Effect.scope const context1 = yield* Layer.buildWithMemoMap(ALive, memoMap, scope) const context2 = yield* Layer.buildWithMemoMap(ALive, memoMap, scope) yield* Effect.provide(A, context1) yield* Effect.provide(A, context2) }) await Effect.runPromise(Effect.scoped(program)) /* Output: timestamp=... level=INFO fiber=#1 message=initialized */ // Building ALive twice against the same MemoMap reuses the first result initCount // => 1 ``` --- # 管理 Layer > 学习如何使用 Layer 管理服务依赖,为应用构建高效、清晰的依赖图。 在[管理 Service](/docs/v4/requirements-management/services/)页面中,你学习了如何创建依赖某个 Service 才能执行的 Effect,以及如何为该 Effect 提供这个 Service。 然而,如果 Effect 程序中的某个 Service 自身在构建时依赖其他 Service,该怎么办?我们希望避免把这些实现细节泄漏到 Service 接口中。 为了表示程序的“依赖图”并更有效地管理这些依赖,我们可以使用一个强大的抽象,称为 “Layer”。 Layer 充当**创建 Service 的构造器**,让我们能够在构造期间而非 Service 层面管理依赖。这种方式有助于保持 Service 接口的简洁与专注。 在深入细节之前,让我们先回顾一些关键概念: | 概念 | 描述 | | --------------- | ------------------------------------------------------------------------------------------------------ | | **service** | 可复用的组件,提供特定功能,在应用的不同部分被使用。 | | **service key** | 表示 **service** 的唯一标识符,让 Effect 能够定位并使用它。 | | **context** | Service 的集合,类似一个以 **service key** 为键、**service** 为值的 map。 | | **layer** | 用于构建 **service** 的抽象,在构造期间而非 Service 层面管理依赖。 | ## 设计依赖图 假设我们正在构建一个 Web 应用。可以想象,对于需要管理配置、日志和数据库访问的应用,其依赖图大致如下: - `Config` service 提供应用配置。 - `Logger` service 依赖 `Config` service。 - `Database` service 同时依赖 `Config` 和 `Logger` service。 我们的目标是构建 `Database` service 及其直接与间接依赖。这意味着需要确保 `Config` service 对 `Logger` 和 `Database` 都可用,然后把这些依赖提供给 `Database` service。 ## 避免需求泄漏 在构造 `Database` service 时,重要的是避免在 `Database` 接口中暴露对 `Config` 和 `Logger` 的依赖。 你可能会想按如下方式定义 `Database` service: **示例**(在 Service 接口中泄漏依赖) ```ts import { Effect, Context } from "effect" // Declaring a service key for the Config service class Config extends Context.Service()("Config") {} // Declaring a service key for the Logger service class Logger extends Context.Service()("Logger") {} // Declaring a service key for the Database service class Database extends Context.Service< Database, { // ❌ Avoid exposing Config and Logger as a requirement readonly query: ( sql: string, ) => Effect.Effect } >()("Database") {} Database.key // => "Database" ``` 这里,`Database` service 的 `query` 函数同时需要 `Config` 和 `Logger`。这种设计泄漏了实现细节,使 `Database` service 意识到自己的依赖,从而让测试变得复杂、难以 mock。 为演示这一问题,我们来创建一个 `Database` service 的测试实例: **示例**(创建带有泄漏依赖的测试实例) ```ts import { Effect, Context } from "effect" // Declaring a service key for the Config service class Config extends Context.Service()("Config") {} // Declaring a service key for the Logger service class Logger extends Context.Service()("Logger") {} // Declaring a service key for the Database service class Database extends Context.Service< Database, { readonly query: ( sql: string, ) => Effect.Effect } >()("Database") {} // Declaring a test instance of the Database service const DatabaseTest = Database.of({ // Simulating a simple response query: (sql: string) => Effect.succeed([]), }) import * as assert from "node:assert" // A test that uses the Database service const test = Effect.gen(function* () { const database = yield* Database const result = yield* database.query("SELECT * FROM users") assert.deepStrictEqual(result, []) }) // ┌─── Effect // ▼ const incompleteTestSetup = test.pipe( // Attempt to provide only the Database service without Config and Logger Effect.provideService(Database, DatabaseTest), ) Database.key // => "Database" ``` 由于 `Database` service 接口直接包含对 `Config` 和 `Logger` 的依赖,任何测试准备工作都被迫包含这些 service,即使它们与测试无关。这带来了不必要的复杂度,也让编写简单、隔离的单元测试变得困难。 与其把依赖直接绑定到 `Database` service 接口上,不如在构造阶段管理依赖。 我们可以使用 **Layer** 来正确构造 `Database` service 并管理其依赖,而不会把细节泄漏到接口中。 ## 创建 Layer `Layer` 类型的结构如下: ```text ┌─── The service to be created │ ┌─── The possible error │ │ ┌─── The required dependencies ▼ ▼ ▼ Layer ``` `Layer` 表示构造 `RequirementsOut`(即 service)的蓝图。它以 `RequirementsIn`(依赖)作为输入,并可能在构造过程中产生 `Error` 类型的错误。 | 参数 | 描述 | | ----------------- | ---------------------------------------------------------------- | | `RequirementsOut` | 要创建的 service 或资源。 | | `Error` | 构造 service 时可能发生的错误类型。 | | `RequirementsIn` | 构造 service 所需的依赖。 | 通过使用 Layer,你可以更好地组织 service,确保其依赖被清晰定义,并与实现细节分离。 为简单起见,我们假设值构造过程中不会遇到任何错误(即 `Error = never`)。 现在,让我们确定实现依赖图需要多少个 Layer: | Layer | 依赖 | 类型 | | -------------- | ---------------------------------------------------------- | ------------------------------------------ | | `ConfigLive` | `Config` service 不依赖任何其他 service | `Layer` | | `LoggerLive` | `Logger` service 依赖 `Config` service | `Layer` | | `DatabaseLive` | `Database` service 依赖 `Config` 和 `Logger` | `Layer` | 当一个 service 有多个依赖时,它们表示为**联合类型**。在我们的例子中,`Database` service 同时依赖 `Config` 和 `Logger` service。因此,`DatabaseLive` Layer 的类型为: ```ts Layer ``` ### Config `Config` service 不依赖任何其他 service,因此 `ConfigLive` 是最容易实现的 Layer。正如[管理 Service](/docs/v4/requirements-management/services/)页面中那样,我们必须为该 service 创建一个 service key。由于该 service 没有依赖,我们可以直接使用 `Layer.succeed` 构造器创建 Layer: ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed( Config, Config.of({ getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }), ) await Effect.runPromise( Effect.provide(Config.pipe(Effect.andThen((c) => c.getConfig)), ConfigLive), ) // => { logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name" } ``` 观察 `ConfigLive` 的类型,我们可以发现: - `RequirementsOut` 是 `Config`,表明构造该 Layer 将产出 `Config` service - `Error` 是 `never`,表明 Layer 构造不会失败 - `RequirementsIn` 是 `never`,表明该 Layer 没有依赖 注意,为了构造 `ConfigLive`,我们使用了 `Config.of` 构造器。然而,这只是一个用于确保实现具有正确类型推断的辅助方法。也可以跳过这个辅助方法,直接把实现构造成一个简单对象: ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) await Effect.runPromise( Effect.provide(Config.pipe(Effect.andThen((c) => c.getConfig)), ConfigLive), ) // => { logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name" } ``` ### Logger 现在我们继续实现 `Logger` service,它依赖 `Config` service 来获取一些配置。 正如我们在[管理 Service](/docs/v4/requirements-management/services/#using-the-service)页面中所做的那样,我们可以 yield `Config` service key,从 Context 中“提取”该 service。 由于使用 `Config` service key 是一个带 Effect 的操作,我们使用 `Layer.effect` 从生成的 Effect 创建 Layer。 ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a service key for the Logger service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) await Effect.runPromise( Effect.provide( Logger.pipe(Effect.andThen((logger) => logger.log("hello"))), Layer.provide(LoggerLive, ConfigLive), ), ) // => undefined ``` 观察 `LoggerLive` 的类型: ```ts Layer ``` 我们可以发现: - `RequirementsOut` 是 `Logger` - `Error` 是 `never`,表明 Layer 构造不会失败 - `RequirementsIn` 是 `Config`,表明该 Layer 有一个需求 ### Database 最后,我们可以使用 `Config` 和 `Logger` service 来实现 `Database` service。 ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a service key for the Logger service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a service key for the Database service class Database extends Context.Service< Database, { readonly query: (sql: string) => Effect.Effect } >()("Database") {} // Layer const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) const AppConfigLive = Layer.merge(ConfigLive, LoggerLive).pipe( Layer.provide(ConfigLive), ) await Effect.runPromise( Effect.provide( Database.pipe(Effect.andThen((db) => db.query("SELECT * FROM users"))), Layer.provide(DatabaseLive, AppConfigLive), ), ) // => { result: "Results from mysql://username:password@hostname:port/database_name" } ``` 观察 `DatabaseLive` 的类型: ```ts Layer ``` 我们可以发现 `RequirementsIn` 类型是 `Config | Logger`,也就是说 `Database` service 同时需要 `Config` 和 `Logger` service。 ## 组合 Layer Layer 可以通过两种主要方式组合:**合并(merging)**与**组合(composing)**。 ### 合并 Layer Layer 可以通过 `Layer.merge` 函数进行合并: ```ts import { Layer } from "effect" declare const layer1: Layer.Layer<"Out1", never, "In1"> declare const layer2: Layer.Layer<"Out2", never, "In2"> // Layer<"Out1" | "Out2", never, "In1" | "In2"> const merging = Layer.merge(layer1, layer2) ``` 当我们合并两个 Layer 时,得到的 Layer: - 需要它们两者所需的所有 service(`"In1" | "In2"`)。 - 产出它们两者产出的所有 service(`"Out1" | "Out2"`)。 例如,在上面的 Web 应用中,我们可以把 `ConfigLive` 和 `LoggerLive` 合并成单个 `AppConfigLive` Layer,它保留两个 Layer 的需求(`never | Config = Config`)以及两个 Layer 的输出(`Config | Logger`): ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a service key for the Logger service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) await Effect.runPromise( Effect.provide( Config.pipe(Effect.andThen((c) => c.getConfig)), AppConfigLive.pipe(Layer.provide(ConfigLive)), ), ) // => { logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name" } ``` ### 组合 Layer Layer 可以使用 `Layer.provide` 函数进行组合: ```ts import { Layer } from "effect" declare const inner: Layer.Layer<"OutInner", never, "InInner"> declare const outer: Layer.Layer<"InInner", never, "InOuter"> // Layer<"OutInner", never, "InOuter"> const composition = Layer.provide(inner, outer) ``` Layer 的顺序组合意味着一个 Layer 的输出被作为内层 Layer 的输入提供,结果得到单个 Layer:它拥有外层 Layer 的需求和内层 Layer 的输出。 现在我们可以把 `AppConfigLive` Layer 与 `DatabaseLive` Layer 组合起来: ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} // Layer const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a service key for the Logger service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} // Layer const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a service key for the Database service class Database extends Context.Service< Database, { readonly query: (sql: string) => Effect.Effect } >()("Database") {} // Layer const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) // Layer const MainLive = DatabaseLive.pipe( // provides the config and logger to the database Layer.provide(AppConfigLive), // provides the config to AppConfigLive Layer.provide(ConfigLive), ) await Effect.runPromise( Effect.provide( Database.pipe(Effect.andThen((db) => db.query("SELECT * FROM users"))), MainLive, ), ) // => { result: "Results from mysql://username:password@hostname:port/database_name" } ``` 我们得到了一个产出 `Database` service 的 `MainLive` Layer: ```ts Layer ``` 该 Layer 是我们应用完全解析后的 Layer。 ### 合并与组合 Layer 假设我们希望 `MainLive` Layer 同时返回 `Config` 和 `Database` service。可以通过 `Layer.provideMerge` 实现: ```ts import { Effect, Context, Layer } from "effect" // Declaring a service key for the Config service class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) // Declaring a service key for the Logger service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) // Declaring a service key for the Database service class Database extends Context.Service< Database, { readonly query: (sql: string) => Effect.Effect } >()("Database") {} const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) // Layer const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) // Layer const MainLive = DatabaseLive.pipe( Layer.provide(AppConfigLive), Layer.provideMerge(ConfigLive), ) await Effect.runPromise( Effect.provide( Effect.gen(function* () { const config = yield* Config const database = yield* Database const queryResult = yield* database.query("SELECT * FROM users") return { logLevel: (yield* config.getConfig).logLevel, queryResult } }), MainLive, ), ) // => { logLevel: "INFO", queryResult: { result: "Results from mysql://username:password@hostname:port/database_name" } } ``` ## 为 Effect 提供 Layer 现在我们已经为应用组装好了完全解析的 `MainLive`,可以使用 `Effect.provide` 将它提供给程序,以满足程序的需求: ```ts import { Effect, Context, Layer } from "effect" class Config extends Context.Service< Config, { readonly getConfig: Effect.Effect<{ readonly logLevel: string readonly connection: string }> } >()("Config") {} const ConfigLive = Layer.succeed(Config, { getConfig: Effect.succeed({ logLevel: "INFO", connection: "mysql://username:password@hostname:port/database_name", }), }) class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("Logger") {} const LoggerLive = Layer.effect( Logger, Effect.gen(function* () { const config = yield* Config return { log: (message) => Effect.gen(function* () { const { logLevel } = yield* config.getConfig console.log(`[${logLevel}] ${message}`) }), } }), ) class Database extends Context.Service< Database, { readonly query: (sql: string) => Effect.Effect } >()("Database") {} const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const config = yield* Config const logger = yield* Logger return { query: (sql: string) => Effect.gen(function* () { yield* logger.log(`Executing query: ${sql}`) const { connection } = yield* config.getConfig return { result: `Results from ${connection}` } }), } }), ) const AppConfigLive = Layer.merge(ConfigLive, LoggerLive) const MainLive = DatabaseLive.pipe( Layer.provide(AppConfigLive), Layer.provide(ConfigLive), ) // ┌─── Effect // ▼ const program = Effect.gen(function* () { const database = yield* Database const result = yield* database.query("SELECT * FROM users") return result }) // ┌─── Effect // ▼ const runnable = Effect.provide(program, MainLive) await Effect.runPromise(runnable) // => { result: "Results from mysql://username:password@hostname:port/database_name" } /* Output: [INFO] Executing query: SELECT * FROM users */ ``` 注意 `runnable` 的需求类型是 `never`,表明该程序运行时不需要任何额外的 service。 ## 把 Layer 转换为 Effect 有时你的整个应用可能就是一个 Layer,例如一个 HTTP server。你可以用 `Layer.launch` 把该 Layer 转换为 Effect。它会构造 Layer 并使其保持存活,直到被中断。 **示例**(启动一个 HTTP Server Layer) ```ts import { Console, Context, Effect, Layer } from "effect" class HTTPServer extends Context.Service()("HTTPServer") {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, // Log a message to simulate a server starting Console.log("Listening on http://localhost:3000"), ) // Converts the layer to an effect and runs it Effect.runFork(Layer.launch(server)) /* Output: Listening on http://localhost:3000 ... */ // Layer.launch never completes on its own; building the layer directly // lets us verify what it produces without hanging the process const context = await Effect.runPromise(Effect.scoped(Layer.build(server))) Context.get(context, HTTPServer) // => undefined ``` ## Tap 操作 `Layer.tap` 和 `Layer.tapError` 函数允许你根据 Layer 的成功或失败执行额外的 Effect。这些操作不会修改 Layer 的签名,但在 Layer 构造期间用于日志记录或执行副作用非常有用。 - `Layer.tap`:当 Layer 成功获取时执行指定的 Effect。 - `Layer.tapError`:当 Layer 获取失败时执行指定的 Effect。 **示例**(记录 Layer 获取过程中的成功与失败) ```ts import { Config, Context, Effect, Layer, Console } from "effect" class HTTPServer extends Context.Service()("HTTPServer") {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, Effect.gen(function* () { const host = yield* Config.String("HOST") console.log(`Listening on http://localhost:${host}`) }), ).pipe( // Log a message if the layer acquisition succeeds Layer.tap((ctx) => Console.log(`layer acquisition succeeded with:\n${ctx}`)), // Log a message if the layer acquisition fails Layer.tapError((err) => Console.log(`layer acquisition failed with:\n${err}`), ), ) Effect.runFork(Layer.launch(server)) /* Output: layer acquisition failed with: (Missing data at HOST: "Expected HOST to exist in the process context") */ ``` ## 错误处理 在构造 Layer 时,处理潜在错误很重要。`Layer.catch` 可以检查获取错误并返回一个回退 Layer。 ### catch `Layer.catch` 函数允许你通过指定回退 Layer 从 Layer 构造期间的错误中恢复。这对于处理特定错误情况、确保应用能以替代方案继续运行很有用。 **示例**(从 Layer 构造期间的错误中恢复) ```ts import { Config, Context, Effect, Layer } from "effect" class HTTPServer extends Context.Service()("HTTPServer") {} // Simulating an HTTP server const server = Layer.effect( HTTPServer, Effect.gen(function* () { const host = yield* Config.String("HOST") console.log(`Listening on http://localhost:${host}`) }), ).pipe( // Recover from errors during layer construction Layer.catch((configError) => Layer.effect( HTTPServer, Effect.gen(function* () { console.log(`Recovering from error:\n${configError}`) console.log(`Listening on http://localhost:3000`) }), ), ), ) Effect.runFork(Layer.launch(server)) /* Output: Recovering from error: (Missing data at HOST: "Expected HOST to exist in the process context") Listening on http://localhost:3000 ... */ ``` 如果回退逻辑不需要该错误,可以忽略传给 `Layer.catch` 的参数。 **示例**(回退到替代 Layer) ```ts import { Config, Context, Effect, Layer } from "effect" class Database extends Context.Service()("Database") {} // Simulating a database connection const postgresDatabaseLayer = Layer.effect( Database, Effect.gen(function* () { const databaseConnectionString = yield* Config.String("CONNECTION_STRING") console.log(`Connecting to database with: ${databaseConnectionString}`) }), ) // Simulating an in-memory database connection const inMemoryDatabaseLayer = Layer.effect( Database, Effect.gen(function* () { console.log(`Connecting to in-memory database`) }), ) // Fallback to in-memory database if PostgreSQL connection fails const database = postgresDatabaseLayer.pipe( Layer.catch(() => inMemoryDatabaseLayer), ) Effect.runFork(Layer.launch(database)) /* Output: Connecting to in-memory database ... */ ``` ## 使用 Context.Service 定义 Service `Context.Service` 可以把 service key 及其构造 Effect 一起定义为一个类。之后你可以把 Layer 作为该类的静态字段暴露出来。 ### 定义带依赖的 Service 下面的例子定义了一个依赖文件系统的 `Cache` service。 **示例**(定义 Cache Service) ```ts import { NodeFileSystem } from "@effect/platform-node" import { Context, Effect, FileSystem, Layer } from "effect" // Define a Cache service class Cache extends Context.Service()("app/Cache", { // Define how to create the service make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), }) { // Specify dependencies static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(NodeFileSystem.layer), ) } Cache.key // => "app/Cache" ``` ### 声明 Service 的 Layer 把 service 的 Layer 声明为静态类字段,通过 `Layer.effect` 从 `this.make` 构建。下面的字段名只是约定,并非 API 的一部分。 | 静态字段 | 描述 | | -------------------------------- | --------------------------------------------------------------------------------- | | `Cache.layer` | 提供 `Cache` service,并已包含其依赖。 | | `Cache.layerWithoutDependencies` | 提供 `Cache` service,但需要单独提供依赖。 | ```ts import { NodeFileSystem } from "@effect/platform-node" import { Context, Effect, FileSystem, Layer } from "effect" // Define a Cache service class Cache extends Context.Service()("app/Cache", { make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), }) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(NodeFileSystem.layer), ) static readonly layerWithoutDependencies = Layer.effect(this, this.make) } // Layer that includes all required dependencies // // ┌─── Layer // ▼ const layer = Cache.layer // Layer without dependencies, requiring them to be provided externally // // ┌─── Layer.Layer // ▼ const layerNoDeps = Cache.layerWithoutDependencies // Exercise layerNoDeps with a test FileSystem to confirm the wiring works const FileSystemTest = FileSystem.layerNoop({ readFileString: () => Effect.succeed("File Content..."), }) await Effect.runPromise( Effect.provide( Effect.gen(function* () { const cache = yield* Cache return yield* cache.lookup("my-key") }), Layer.provide(layerNoDeps, FileSystemTest), ), ) // => "File Content..." ``` ### 访问 Service 使用 `Context.Service` 创建的 service 可以像其他任何 Effect service 一样被访问。 **示例**(访问 Cache Service) ```ts import { NodeFileSystem } from "@effect/platform-node" import { Context, Effect, FileSystem, Layer, Console } from "effect" // Define a Cache service class Cache extends Context.Service()("app/Cache", { make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), }) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(NodeFileSystem.layer), ) static readonly layerWithoutDependencies = Layer.effect(this, this.make) } // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchCause((cause) => Console.log(cause))) const runnable = program.pipe(Effect.provide(Cache.layer)) Effect.runFork(runnable) /* { _id: 'Cause', failures: [ { _tag: 'Fail', error: { _tag: 'PlatformError', reason: { _tag: 'NotFound', module: 'FileSystem', method: 'readFile', pathOrDescriptor: 'cache/my-key', syscall: 'open', cause: [Error: ENOENT: no such file or directory, open 'cache/my-key'] } } } ] } */ ``` 由于该示例使用 `Cache.layer`,它会与真实文件系统交互。如果文件不存在,就会产生错误。 ### 注入测试依赖 为了在不依赖真实文件系统的情况下测试程序,我们可以使用 `Cache.layerWithoutDependencies` Layer 注入一个测试文件系统。 **示例**(使用测试文件系统) ```ts import { NodeFileSystem } from "@effect/platform-node" import { Context, Effect, FileSystem, Layer, Console } from "effect" // Define a Cache service class Cache extends Context.Service()("app/Cache", { make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), }) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(NodeFileSystem.layer), ) static readonly layerWithoutDependencies = Layer.effect(this, this.make) } // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchCause((cause) => Console.log(cause))) // Create a test file system that always returns a fixed value const FileSystemTest = FileSystem.layerNoop({ readFileString: () => Effect.succeed("File Content..."), }) const runnable = program.pipe( Effect.provide(Cache.layerWithoutDependencies), // Provide the mock file system Effect.provide(FileSystemTest), ) Effect.runFork(runnable) // Output: File Content... await Effect.runPromise(runnable) // => undefined ``` ### 直接 Mock Service 另一种方式是不替换依赖,而是直接 mock `Cache` service 本身。 **示例**(Mock Cache Service) ```ts import { NodeFileSystem } from "@effect/platform-node" import { Context, Effect, FileSystem, Layer, Console } from "effect" // Define a Cache service class Cache extends Context.Service()("app/Cache", { make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const lookup = (key: string) => fs.readFileString(`cache/${key}`) return { lookup } as const }), }) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(NodeFileSystem.layer), ) static readonly layerWithoutDependencies = Layer.effect(this, this.make) } // Accessing the Cache Service const program = Effect.gen(function* () { const cache = yield* Cache const data = yield* cache.lookup("my-key") console.log(data) }).pipe(Effect.catchCause((cause) => Console.log(cause))) // Create a mock implementation of Cache const cache = Cache.of({ lookup: () => Effect.succeed("Cache Content..."), }) // Provide the mock Cache service const runnable = program.pipe(Effect.provideService(Cache, cache)) Effect.runFork(runnable) // Output: Cache Content... await Effect.runPromise(runnable) // => undefined ``` ### 构造 `make` 的其他方式 `Context.Service` 的 `make` 字段接受任何 `Effect`,因此 service 可以使用不同的构造风格: | 风格 | 如何构建 `make` | | ------------ | ------------------------------------------------------------------------------------------------------------------------------ | | 静态值 | `Effect.succeed(...)`:一个常量实现。 | | 同步 | `Effect.sync(() => ...)`:一个同步构造器。 | | Effectful | `Effect.gen(function* () { ... })`(或任何其他 `Effect`):一个自身依赖其他 service 的构造器。 | | Scoped | 在 `make` 内使用 `Effect.acquireRelease`/`Effect.addFinalizer` 的 `Effect.gen(...)`:生命周期管理,无需单独的选项。 | **示例**(定义具有静态实现的 Service) 这是定义 service 最简单的方式。当你希望为 service 提供一个常量值时,它很有用。 ```ts import { Context, Effect, Layer } from "effect" class MagicNumber extends Context.Service()("MagicNumber", { make: Effect.succeed({ value: 42 }), }) { static readonly layer = Layer.effect(this, this.make) } // ┌─── Effect // ▼ const program = Effect.gen(function* () { const magicNumber = yield* MagicNumber console.log(`The magic number is ${magicNumber.value}`) }) await Effect.runPromise(program.pipe(Effect.provide(MagicNumber.layer))) // => undefined // The magic number is 42 ``` **示例**(定义具有同步构造器的 Service) ```ts import { Context, Effect, Layer, Random } from "effect" class Sync extends Context.Service()("Sync", { make: Effect.sync(() => ({ next: Random.nextInt, })), }) { static readonly layer = Layer.effect(this, this.make) } // ┌─── Effect // ▼ const program = Effect.gen(function* () { const sync = yield* Sync const n = yield* sync.next console.log(`The number is ${n}`) }) await Effect.runPromise(program.pipe(Effect.provide(Sync.layer))) // => undefined // Example Output: The number is 3858843290019673 ``` **示例**(定义具有生命周期控制的 Service) ```ts import { Context, Effect, Layer, Console } from "effect" class Scoped extends Context.Service()("Scoped", { make: Effect.gen(function* () { // Acquire the resource and ensure it is properly released const resource = yield* Effect.acquireRelease( Console.log("Acquiring...").pipe(Effect.as("foo")), () => Console.log("Releasing..."), ) // Register a finalizer to run when the effect is completed yield* Effect.addFinalizer(() => Console.log("Shutting down")) return { resource } }), }) { static readonly layer = Layer.effect(this, this.make) } // ┌─── Effect // ▼ const program = Effect.gen(function* () { const resource = (yield* Scoped).resource console.log(`The resource is ${resource}`) }) await Effect.runPromise( program.pipe( Effect.provide( // ┌─── Layer // ▼ Scoped.layer, ), ), ) // => undefined /* Acquiring... The resource is foo Shutting down Releasing... */ ``` `Scoped.layer` Layer 不需要 `Scope` 作为依赖,因为 `Scoped` 自身管理其生命周期。 --- # 管理服务 > 学习在 Effect 中管理可复用的服务、高效地处理依赖,并让应用保持清晰、解耦的架构。 在编程语境里,**服务(service)**指的是可以被应用不同部分复用的组件或功能。 服务被设计用来提供特定的能力,可以在多个模块或组件之间共享。 服务通常会把应用不同部分都需要的公共任务或操作封装起来。它们可以处理复杂的运算、 与外部系统或 API 交互、管理数据,或者执行其他专门的任务。 服务一般被设计成模块化的、与应用其余部分解耦的。这让它们易于维护、易于测试、 易于替换,且不会影响应用的整体功能。 在深入服务及其在应用开发中的集成方式之前,不妨先从最朴素的做法看起:不借助任何高级结构, 手工把服务传给每一个需要它的函数。想象一下你得手动到处传递一个服务: ```ts const processData = (data: Data, databaseService: DatabaseService) => { // Operations using the database service } ``` 随着应用变大,这种做法会变得笨重且难以管理——服务需要穿过层层函数被一路传递下去。 为了简化,你可能会考虑改用一个把各种服务打包在一起的环境对象: ```ts type Context = { databaseService: DatabaseService loggingService: LoggingService } const processData = (data: Data, context: Context) => { // Using multiple services from the context } ``` 但这又引入了新的复杂度:你必须保证这个环境在使用前已经正确装配好所有必需的服务, 这容易导致代码紧耦合,也让函数组合与测试变得更困难。 ## 用 Effect 管理服务 Effect 借助类型系统简化了这些依赖的管理。你不必再手工传递服务或环境对象, 而是可以直接在函数的类型签名里、通过 `Effect` 类型的 `Requirements` 参数声明服务依赖: ```ts ┌─── Represents required dependencies ▼ Effect ``` 在使用 Effect 时,实际的工作方式是这样的: **声明依赖**:你直接在类型里写清一个函数需要哪些服务,把依赖管理的复杂度推进类型系统。 **提供服务**:用 `Effect.provideService` 把服务的实现提供给需要它的函数。 在一开始就把服务提供好,可以保证应用各部分拿到的是一致的所需服务,从而维持清晰、解耦的架构。 这种做法把手工处理服务的细节抽象掉了,让开发者专注于业务逻辑,同时由编译器保证所有依赖都被正确管理。 它也让代码更易于维护和扩展。 下面按步骤走一遍 Effect 中的服务管理: 1. **创建服务**:定义一个服务,包含它独有的功能与接口。 2. **使用服务**:在应用的函数里访问并使用这个服务。 3. **提供服务实现**:为声明出来的需求提供一个真实的服务实现。 ## 原理 到目前为止,我们例子里的 effect 都是不依赖外部服务的。 也就是说,`Effect` 类型签名中的 `Requirements` 参数一直是 `never`,表示没有依赖。 但真实应用里的 effect 往往要依赖特定的服务才能正确工作。这些服务通过一个叫 `Context` 的结构来管理和访问。 `Context` 充当一个 effect 可能需要的所有服务的仓库或容器。 它像一个保存着这些服务的仓库,让应用的各个部分可以在需要时访问和使用它们。 存放在 `Context` 里的服务会直接反映在 `Effect` 类型的 `Requirements` 参数上。 `Context` 中的每个服务都由一个唯一的"服务键(service key)"标识,它本质上就是该服务的唯一标识符。 当一个 effect 需要使用某个具体的服务时,该服务的服务键就会被写进 `Requirements` 类型参数。 ## 创建服务 要创建一个新服务,你需要两样东西: 1. 一个唯一的**标识符**。 2. 一个描述该服务可执行操作的**类型**。 **示例**(定义一个随机数生成器服务) 我们来创建一个生成随机数的服务。 1. **标识符**:我们用字符串 `"MyRandomService"` 作为唯一标识符。 2. **类型**:这个服务类型只有一个操作 `next`,返回一个随机数。 ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} Random.key // => "MyRandomService" ``` 导出的 `Random` 值在 Effect 中被称为**服务键**。它代表这个服务, 让 Effect 能在运行时找到并使用它。 服务会被存放进一个叫 `Context` 的集合里,可以把它理解成一个 `Map`:键是服务键,值是服务。 ```ts type Context = Map ``` 我们来小结一下到目前为止涉及的概念: | 概念 | 说明 | | --- | --- | | **service** | 提供特定功能的可复用组件,在应用的不同部分之间使用。 | | **service key** | 代表某个 **service** 的唯一标识符,让 Effect 能找到并使用它。 | | **context** | 服务的集合,像一个以 **service key** 为键、**service** 为值的 map。 | ## 使用服务 服务键已经定义好了,现在来看看怎么用它写一个简单程序。 **示例**(使用 Random 服务) ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Using the service // // ┌─── Effect // ▼ const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // Providing a fixed implementation lets us exercise the program above await Effect.runPromise( Effect.provideService(program, Random, { next: Effect.succeed(42) }), ) // => undefined ``` 在上面的代码里,可以看到我们能像 yield 一个 effect 那样去 yield `Random` 这个服务键。 这让我们可以访问服务的 `next` 操作。 ```ts import { Effect, Context, Console } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Using the service // // ┌─── Effect // ▼ const program = Random.pipe( Effect.andThen((random) => random.next), Effect.andThen((randomNumber) => Console.log(`random number: ${randomNumber}`), ), ) // Providing a fixed implementation lets us exercise the program above await Effect.runPromise( Effect.provideService(program, Random, { next: Effect.succeed(42) }), ) // => undefined ``` 在上面的代码里,可以看到我们能像对一个 effect 做 flat-map 那样去串联 `Random` 这个服务键。 这让我们可以在 `Effect.andThen` 的回调里访问服务的 `next` 操作。 值得注意的是,`program` 变量的类型里,`Requirements` 类型参数包含了 `Random`: ```ts const program: Effect ``` 这表示我们的程序需要被提供 `Random` 服务才能成功执行。 如果我们试图在没提供必要服务的情况下执行这个 effect,就会遇到类型检查错误: **示例**(未提供服务时的类型错误) ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Using the service const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // @errors: 2345 Effect.runSync(program) ``` 要解决这个错误并成功执行程序,我们需要提供 `Random` 服务的一个真实实现。 下一节我们会看如何实现并提供 `Random` 服务,让程序能跑起来。 ## 提供服务实现 要提供 `Random` 服务的真实实现,可以使用 `Effect.provideService` 函数。 **示例**(提供一个随机数实现) ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Using the service const program = Effect.gen(function* () { const random = yield* Random const randomNumber = yield* random.next console.log(`random number: ${randomNumber}`) }) // Providing the implementation // // ┌─── Effect // ▼ const runnable = Effect.provideService(program, Random, { next: Effect.sync(() => Math.random()), }) // Run successfully await Effect.runPromise(runnable) // => undefined /* Example Output: random number: 0.8241872233134417 */ ``` 在上面的代码里,我们给先前定义的 `program` 提供了 `Random` 服务的一个实现。 我们用 `Effect.provideService` 把 `Random` 服务键与它的实现关联起来; 这个实现是一个带 `next` 操作、用来生成随机数的对象。 注意 `runnable` 这个 effect 的 `Requirements` 类型参数现在变成了 `never`。 这表示这个 effect 不再需要任何服务被提供。 有了 `Random` 服务的实现,我们就能在没有任何额外依赖的情况下运行程序了。 ## 提取服务类型 要从一个服务键取出服务类型,可以使用 `Context.Service.Shape` 工具类型。 **示例**(提取服务类型) ```ts import { Effect, Context } from "effect" // Declaring a service key class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Extracting the type type RandomShape = Context.Service.Shape /* This is equivalent to: type RandomShape = { readonly next: Effect.Effect; } */ ``` ## 使用多个服务 当我们需要用到多个服务时,做法与前面定义单个服务时类似,只是对每个需要的服务重复一遍。 **示例**(同时使用 Random 与 Logger 服务) 来看一个需要两个服务——`Random` 和 `Logger`——的例子: ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Declaring a service key for the logging service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("MyLoggerService") {} const program = Effect.gen(function* () { // Acquire instances of the 'Random' and 'Logger' services const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next yield* logger.log(String(randomNumber)) }) // Providing fixed implementations lets us exercise the program above await Effect.runPromise( program.pipe( Effect.provideService(Random, { next: Effect.succeed(7) }), Effect.provideService(Logger, { log: (message) => Effect.sync(() => console.log(message)), }), ), ) // => undefined ``` 此时 `program` 这个 effect 的 `Requirements` 类型参数是 `Random | Logger`: ```ts const program: Effect ``` 表示它需要 `Random` 和 `Logger` 两个服务都被提供。 要执行 `program`,我们需要为两个服务都提供实现: **示例**(提供多个服务) ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Declaring a service key for the logging service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("MyLoggerService") {} const program = Effect.gen(function* () { const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next return yield* logger.log(String(randomNumber)) }) // Provide service implementations for 'Random' and 'Logger' const runnable = program.pipe( Effect.provideService(Random, { next: Effect.sync(() => Math.random()), }), Effect.provideService(Logger, { log: (message) => Effect.sync(() => console.log(message)), }), ) await Effect.runPromise(runnable) // => undefined ``` 另一种做法是:不必多次调用 `provideService`,而是把各个服务的实现合并进一个 `Context`, 再用 `Effect.provide` 一次性提供整个 context: **示例**(合并多个服务实现) ```ts import { Effect, Context } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} // Declaring a service key for the logging service class Logger extends Context.Service< Logger, { readonly log: (message: string) => Effect.Effect } >()("MyLoggerService") {} const program = Effect.gen(function* () { const random = yield* Random const logger = yield* Logger const randomNumber = yield* random.next return yield* logger.log(String(randomNumber)) }) // Combine service implementations into a single 'Context' const context = Context.empty().pipe( Context.add(Random, { next: Effect.sync(() => Math.random()) }), Context.add(Logger, { log: (message) => Effect.sync(() => console.log(message)), }), ) // Provide the entire context const runnable = Effect.provide(program, context) await Effect.runPromise(runnable) // => undefined ``` ## 可选服务 有些情况下,我们只想在服务的实现确实存在时才去访问它。 这种场景可以用 `Effect.serviceOption` 来处理。 `Effect.serviceOption` 返回的实现只在**执行该 effect 之前确实被提供**了时才可用。 为了表达这种"可选",它返回的是实现的一个 [Option](/docs/v4/data-types/option/)。 **示例**(处理可选服务) 要决定该采取什么动作,可以用 Option 模块提供的 `Option.isNone` 函数。 它能让我们检查服务是否可用:当服务不可用时返回 `true`。 ```ts import { Effect, Context, Option } from "effect" // Declaring a service key for a service that generates random numbers class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()("MyRandomService") {} const program = Effect.gen(function* () { const maybeRandom = yield* Effect.serviceOption(Random) const randomNumber = Option.isNone(maybeRandom) ? // the service is not available, return a default value -1 : // the service is available yield* maybeRandom.value.next console.log(randomNumber) }) // Without providing the service, serviceOption resolves to None await Effect.runPromise(Effect.serviceOption(Random)) // => Option.none() ``` 在上面的代码里,可以看到尽管我们在和服务打交道,`program` 这个 effect 的 `Requirements` 类型参数仍然是 `never`。这让我们能够做到:只有在该 effect 执行前确实提供了某样东西时,才从 context 里取它。 当我们不提供 `Random` 服务、直接运行 `program` 时: ```ts Effect.runPromise(program).then(console.log) // Output: -1 ``` 会看到日志里输出 `-1`,也就是服务不可用时我们给出的默认值。 而如果我们提供 `Random` 服务的实现: ```ts Effect.runPromise( Effect.provideService(program, Random, { next: Effect.sync(() => Math.random()), }), ).then(console.log) // Example Output: 0.9957979486841035 ``` 就会看到日志里输出了一个由 `Random` 服务的 `next` 操作生成的随机数。 ## 处理带依赖的服务 有时应用里的某个服务会依赖其它服务。为了保持架构清晰, 重要的是管理好这些依赖、**不把它们暴露在服务接口里**。 相反,你可以用 **Layer** 在服务的构造阶段处理这些依赖。 **示例**(定义一个依赖配置的 Logger 服务) 考虑多个服务互相依赖的场景:这里 `Logger` 服务需要访问一个配置服务(`Config`)。 ```ts import { Effect, Context } from "effect" // Declaring a service key for the Config service class Config extends Context.Service()("Config") {} // Declaring a service key for the logging service class Logger extends Context.Service< Logger, { // ❌ Avoid exposing Config as a requirement readonly log: (message: string) => Effect.Effect } >()("MyLoggerService") {} Logger.key // => "MyLoggerService" ``` 想以结构化的方式处理这些依赖、并防止它们泄漏进服务接口,可以使用 `Layer` 抽象。 关于用 Layer 管理依赖的细节,参见[管理 Layer](/docs/v4/requirements-management/layers/)一页。 --- # 简介 > 安全资源管理的常见模式 在长时间运行的应用程序中,高效地管理资源至关重要,尤其是在构建大规模系统时。如果 socket 连接、数据库连接或文件描述符这类资源没有得到妥善管理,就可能导致资源泄漏,进而降低应用程序的性能与可靠性。Effect 提供了一些构造,帮助确保资源被妥善管理与释放,即使发生异常也是如此。 通过确保每次获取资源时都有对应的释放机制,Effect 简化了应用程序中资源管理的过程。 ## 终结处理 在许多编程语言中,`try` / `finally` 构造确保清理代码无论操作成功还是失败都会运行。Effect 通过 `Effect.ensuring`、`Effect.onExit` 和 `Effect.onError` 提供了类似的功能。 ### ensuring `Effect.ensuring` 函数保证终结器 effect 无论主 effect 成功、失败还是被中断都会运行。 这适用于执行清理操作,例如关闭文件句柄、记录日志消息或释放锁。 如果你需要访问 effect 的结果,请考虑使用 [onExit](#onexit)。 **示例**(在所有结果下运行终结器) ```ts import { Console, Effect, Exit } from "effect" // Define a cleanup effect const handler = Effect.ensuring(Console.log("Cleanup completed")) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed Cleanup completed */ await Effect.runPromise(success) // => "some result" // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed */ await Effect.runPromiseExit(failure) // => Exit.fail("some error") // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed */ // The interruption cause carries a fiber id, so we check the shape instead // of comparing the whole Exit for equality Exit.hasInterrupts(await Effect.runPromiseExit(interruption)) // => true ``` ### onExit `Effect.onExit` 允许你在主 effect 完成后运行一个清理 effect,并接收一个描述执行结果的 [Exit](/docs/v4/data-types/exit/) 值。 - 如果 effect 成功,`Exit` 持有成功值。 - 如果 effect 失败,`Exit` 包含错误或失败原因。 - 如果 effect 被中断,`Exit` 会反映该中断。 清理步骤本身是不可中断的,这有助于在复杂或高并发的情况下管理资源。 **示例**(带着 effect 的结果运行清理函数) ```ts import { Console, Effect, Exit, identity } from "effect" // Define a cleanup effect that logs the result const handler = Effect.onExit((exit) => Console.log( `Cleanup completed: ${Exit.match(exit, { onSuccess: identity, onFailure: String })}`, ), ) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed Cleanup completed: some result */ await Effect.runPromise(success) // => "some result" // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed: Error: some error */ await Effect.runPromiseExit(failure) // => Exit.fail("some error") // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed: All fibers interrupted without errors. */ // The interruption cause carries a fiber id, so we check the shape instead // of comparing the whole Exit for equality Exit.hasInterrupts(await Effect.runPromiseExit(interruption)) // => true ``` ### onError 这个函数让你可以附加一个清理 effect,只要调用它的 effect 失败就会运行,并把失败的原因传递给该清理 effect。 你可以用它来执行诸如记录日志、释放资源或应用额外恢复步骤之类的操作。 如果失败是由中断引起的,清理 effect 也会运行;而且它是不可中断的,因此一旦开始就总会执行完成。 **示例**(仅在失败时运行清理) ```ts import { Console, Effect, Exit } from "effect" // This handler logs the failure cause when the effect fails const handler = Effect.onError((cause) => Console.log(`Cleanup completed: ${cause}`), ) // Define a successful effect const success = Console.log("Task completed").pipe( Effect.as("some result"), handler, ) Effect.runFork(success) /* Output: Task completed */ await Effect.runPromise(success) // => "some result" // Define a failing effect const failure = Console.log("Task failed").pipe( Effect.andThen(Effect.fail("some error")), handler, ) Effect.runFork(failure) /* Output: Task failed Cleanup completed: Error: some error */ await Effect.runPromiseExit(failure) // => Exit.fail("some error") // Define a failing effect const defect = Console.log("Task failed with defect").pipe( Effect.andThen(Effect.die("Boom!")), handler, ) Effect.runFork(defect) /* Output: Task failed with defect Cleanup completed: Error: Boom! */ await Effect.runPromiseExit(defect) // => Exit.die("Boom!") // Define an interrupted effect const interruption = Console.log("Task interrupted").pipe( Effect.andThen(Effect.interrupt), handler, ) Effect.runFork(interruption) /* Output: Task interrupted Cleanup completed: All fibers interrupted without errors. */ // The interruption cause carries a fiber id, so we check the shape instead // of comparing the whole Exit for equality Exit.hasInterrupts(await Effect.runPromiseExit(interruption)) // => true ``` ## acquireUseRelease 许多真实场景中的操作都涉及使用那些不再需要时必须释放的资源,例如: - 数据库连接 - 文件句柄 - 网络请求 Effect 提供了 `Effect.acquireUseRelease`,它确保资源能够: 1. 被正确地**获取**(Acquired)。 2. 被用于其预期用途(**Used**)。 3. 即使发生错误也能被**释放**(Released)。 **语法** ```ts Effect.acquireUseRelease(acquire, use, release) ``` **示例**(自动管理资源生命周期) ```ts import { Effect, Console } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) const use = (res: MyResource) => Console.log(`content is ${res.contents}`) // ┌─── Effect // ▼ const program = Effect.acquireUseRelease(acquire, use, release) await Effect.runPromise(program) // => undefined /* Output: Resource acquired content is lorem ipsum Resource released */ ``` --- # Scope > 了解 Effect 如何借助 Scope 简化资源管理,确保在长时间运行的应用程序中高效清理并安全地处理资源。 `Scope` 数据类型是 Effect 中的核心构件,用于以安全且可组合的方式管理资源。 一个 scope 代表一个或多个资源的生命周期。当 scope 被关闭时,其中的所有资源都会被释放,从而确保不会有资源泄漏。Scope 还允许添加 **finalizer**,由它来定义如何释放资源。 借助 `Scope` 数据类型,你可以: - **添加 finalizer**:finalizer 指定资源的清理逻辑。 - **关闭 scope**:当 scope 被关闭时,所有资源都会被释放,且 finalizer 会被执行。 **示例**(管理 Scope) ```ts import { Scope, Effect, Console, Exit } from "effect" const program = // create a new scope Scope.make().pipe( // add finalizer 1 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("finalizer 1")), ), // add finalizer 2 Effect.tap((scope) => Scope.addFinalizer(scope, Console.log("finalizer 2")), ), // close the scope Effect.andThen((scope) => Scope.close(scope, Exit.succeed("scope closed successfully")), ), ) Effect.runPromise(program) /* Output: finalizer 2 <-- finalizers are closed in reverse order finalizer 1 */ await Effect.runPromise(program) // => undefined ``` 在上面的示例中,finalizer 被添加到 scope 中;当 scope 被关闭时,这些 finalizer 会**以相反的顺序执行**。 这种相反的顺序很重要,因为它能确保资源按正确的次序释放。 例如,如果你先获取一个网络连接,然后访问远程服务器上的文件,那么必须先关闭文件再关闭网络连接,以避免出错。 ## addFinalizer `Effect.addFinalizer` 函数是一个高层 API,它允许你把 finalizer 添加到某个 effect 的 scope 中。finalizer 是一段保证会在关联 scope 关闭时运行的代码。finalizer 的行为会根据 [Exit](/docs/v4/data-types/exit/) 值而变化,该值表示 scope 是以何种方式关闭的:是成功还是出错。 **示例**(在成功时添加 finalizer) ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return "some result" }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Success */ await Effect.runPromiseExit(runnable) // => Exit.succeed("some result") ``` ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.succeed("some result"))) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Success */ await Effect.runPromiseExit(runnable) // => Exit.succeed("some result") ``` 在这个示例中,我们使用 `Effect.addFinalizer` 添加一个 finalizer,它会在 scope 关闭后记录 exit 状态。该 finalizer 会在 effect 结束时执行,并记录 effect 是成功完成还是失败。 类型签名如下: ```ts const program: Effect ``` 这表明该工作流需要 `Scope` 才能运行。你可以使用 `Effect.scoped` 函数来提供这个 `Scope`:它会创建一个新的 scope,在其中运行该 effect,并确保 scope 关闭时执行这些 finalizer。 **示例**(在失败时添加 finalizer) ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return yield* Effect.fail("Uh oh!") }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure */ await Effect.runPromiseExit(runnable) // => Exit.fail("Uh oh!") ``` ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.fail("Uh oh!"))) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure */ await Effect.runPromiseExit(runnable) // => Exit.fail("Uh oh!") ``` 在这种情况下,即使 effect 失败,finalizer 也会执行。日志输出表明 finalizer 在失败之后运行,并记录了失败的详细信息。 **示例**(在[中断](/docs/v4/concurrency/basic-concurrency/#interruptions)时添加 finalizer) ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.gen(function* () { yield* Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ) return yield* Effect.interrupt }) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ // The interruption cause carries a fiber id, so we check the shape instead // of comparing the whole Exit for equality Exit.hasInterrupts(await Effect.runPromiseExit(runnable)) // => true ``` ```ts import { Effect, Console, Exit } from "effect" // ┌─── Effect // ▼ const program = Effect.addFinalizer((exit) => Console.log(`Finalizer executed. Exit status: ${exit._tag}`), ).pipe(Effect.andThen(Effect.interrupt)) // Wrapping the effect in a scope // // ┌─── Effect // ▼ const runnable = Effect.scoped(program) Effect.runPromiseExit(runnable).then(console.log) /* Output: Finalizer executed. Exit status: Failure { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', _tag: 'Interrupt', fiberId: { _id: 'FiberId', _tag: 'Runtime', id: 0, startTimeMillis: ... } } } */ // The interruption cause carries a fiber id, so we check the shape instead // of comparing the whole Exit for equality Exit.hasInterrupts(await Effect.runPromiseExit(runnable)) // => true ``` 这个示例展示了 effect 被中断时 finalizer 的行为。finalizer 会在中断之后运行,而 exit 状态也反映出该 effect 是在执行途中被停止的。 ## 手动创建和关闭 Scope 当你在单个操作中处理多个受 scope 管理的资源时,理解这些 scope 之间如何交互很重要。 默认情况下,这些 scope 会合并成一个,但你可以手动创建和关闭 scope,从而更精细地控制每个 scope 的关闭时机。 我们先来看看默认情况下 scope 是如何合并的: **示例**(合并 scope) ```ts import { Effect, Console } from "effect" const task1 = Effect.gen(function* () { console.log("task 1") yield* Effect.addFinalizer(() => Console.log("finalizer after task 1")) }) const task2 = Effect.gen(function* () { console.log("task 2") yield* Effect.addFinalizer(() => Console.log("finalizer after task 2")) }) const program = Effect.gen(function* () { // The scopes of both tasks are merged into one yield* task1 yield* task2 }) Effect.runPromise(Effect.scoped(program)) /* Output: task 1 task 2 finalizer after task 2 finalizer after task 1 */ await Effect.runPromise(Effect.scoped(program)) // => undefined ``` 在这里,`task1` 和 `task2` 的 scope 被合并成单个 scope;运行该程序时,它会以特定顺序输出这些任务及其 finalizer。 如果你想更精细地控制每个 scope 的关闭时机,可以手动创建和关闭它们: **示例**(手动创建和关闭 scope) ```ts import { Console, Effect, Exit, Scope } from "effect" const task1 = Effect.gen(function* () { console.log("task 1") yield* Effect.addFinalizer(() => Console.log("finalizer after task 1")) }) const task2 = Effect.gen(function* () { console.log("task 2") yield* Effect.addFinalizer(() => Console.log("finalizer after task 2")) }) const program = Effect.gen(function* () { const scope1 = yield* Scope.make() const scope2 = yield* Scope.make() // Extend the scope of task1 into scope1 yield* task1.pipe(Scope.provide(scope1)) // Extend the scope of task2 into scope2 yield* task2.pipe(Scope.provide(scope2)) // Manually close scope1 and scope2 yield* Scope.close(scope1, Exit.void) yield* Console.log("doing something else") yield* Scope.close(scope2, Exit.void) }) Effect.runPromise(program) /* Output: task 1 task 2 finalizer after task 1 doing something else finalizer after task 2 */ await Effect.runPromise(program) // => undefined ``` 在这个示例中,我们创建了两个独立的 scope:`scope1` 和 `scope2`,并把每个任务的 scope 扩展进各自的 scope。运行该程序时,它输出的任务及其 finalizer 顺序有所不同。 你可能会好奇:如果 scope 已经关闭,但该 scope 中的某个任务尚未完成,会发生什么? 关键在于,关闭 scope 并不会强制中断该任务。 **示例**(在存在未完成任务时关闭 scope) ```ts import { Console, Effect, Exit, Scope } from "effect" const task = Effect.gen(function* () { yield* Effect.sleep("1 second") console.log("Executed") yield* Effect.addFinalizer(() => Console.log("Task Finalizer")) }) const program = Effect.gen(function* () { const scope = yield* Scope.make() // Close the scope immediately yield* Scope.close(scope, Exit.void) console.log("Scope closed") // This task will be executed even if the scope is closed yield* task.pipe(Scope.provide(scope)) }) Effect.runPromise(program) /* Output: Scope closed Executed <-- after 1 second Task Finalizer */ await Effect.runPromise(program) // => undefined ``` ## 定义资源 ### acquireRelease `Effect.acquireRelease(acquire, release)` 函数让你可以定义资源,这些资源会被获取,并在不再需要时被安全地释放。这在管理文件句柄、数据库连接或网络套接字这类资源时很有用。 要使用 `Effect.acquireRelease`,你需要定义两个操作: 1. **获取资源**:一个描述如何获取资源的 effect,例如打开文件或建立数据库连接。 2. **释放资源**:确保资源被正确释放的清理 effect,例如关闭文件或连接。 获取过程是**不可中断**的,以确保资源只被获取了一部分时不会让系统处于不一致的状态。 `Effect.acquireRelease` 函数保证:一旦资源被成功获取,当 `Scope` 关闭时,它的释放步骤总会执行。 **示例**(定义一个简单的资源) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow // // ┌─── Effect // ▼ const resource = Effect.acquireRelease(acquire, release) await Effect.runPromise( Effect.scoped(Effect.map(resource, (res) => res.contents)), ) // => "lorem ipsum" ``` 在上面的代码中,`Effect.acquireRelease` 函数创建了一个需要 `Scope` 的资源工作流: ```ts const resource: Effect ``` 这意味着该工作流需要一个 `Scope` 才能运行,而当 `Scope` 关闭时,资源会被自动释放。 现在,你可以使用 `Effect.andThen` 或类似函数,通过链式操作来使用这个资源。 我们可以借助 `Effect.andThen` 或其他 Effect 操作符,想使用该资源多久就使用多久。例如,下面是读取其内容的方式: **示例**(使用资源) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow const resource = Effect.acquireRelease(acquire, release) // ┌─── Effect // ▼ const program = Effect.gen(function* () { const res = yield* resource console.log(`content is ${res.contents}`) }) await Effect.runPromise(Effect.scoped(program)) // => undefined ``` 为确保资源得到妥善管理,资源用完后应关闭 `Scope`。`Effect.scoped` 函数会替你完成这件事:它创建一个 `Scope`,运行该 effect,然后在 effect 结束时关闭 `Scope`。 **示例**(用 `Effect.scoped` 提供 `Scope`) ```ts import { Effect } from "effect" // Define an interface for a resource interface MyResource { readonly contents: string readonly close: () => Promise } // Simulate resource acquisition const getMyResource = (): Promise => Promise.resolve({ contents: "lorem ipsum", close: () => new Promise((resolve) => { console.log("Resource released") resolve() }), }) // Define how the resource is acquired const acquire = Effect.tryPromise({ try: () => getMyResource().then((res) => { console.log("Resource acquired") return res }), catch: () => new Error("getMyResourceError"), }) // Define how the resource is released const release = (res: MyResource) => Effect.promise(() => res.close()) // Create the resource management workflow const resource = Effect.acquireRelease(acquire, release) // ┌─── Effect // ▼ const program = Effect.scoped( Effect.gen(function* () { const res = yield* resource console.log(`content is ${res.contents}`) }), ) // We now have a workflow that is ready to run Effect.runPromise(program) /* Resource acquired content is lorem ipsum Resource released */ await Effect.runPromise(program) // => undefined ``` ### 示例模式:顺序执行操作 在某些场景中,你可能需要执行一连串链式操作,其中每个操作能否成功都取决于前一个操作。但是,只要其中有任何操作失败,你就希望撤销此前所有成功操作产生的影响。当你需要确保要么所有操作都成功、要么它们都不产生任何影响时,这个模式就很有价值。 让我们看一个实现该模式的示例。假设我们要在应用中创建一个“Workspace”,这涉及创建一个 S3 存储桶、一个 ElasticSearch 索引,以及一条依赖前两者的 Database 记录。 首先,我们为所需的[服务](/docs/v4/requirements-management/services/)定义领域模型: - `S3` - `ElasticSearch` - `Database` ```ts import { Effect, Context, Data } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Service< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >()("S3") {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Service< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >()("ElasticSearch") {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Service< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >()("Database") {} Database.key // => "Database" ``` 接下来,我们定义三个 create 操作,以及 Workspace 的总体事务(`make`)。 ```ts import { Effect, Context, Exit, Data, Layer } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Service< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >()("S3") {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Service< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >()("ElasticSearch") {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Service< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >()("Database") {} // Create a bucket, and define the release function that deletes the // bucket if the operation fails. const createBucket = Effect.gen(function* () { const { createBucket, deleteBucket } = yield* S3 return yield* Effect.acquireRelease(createBucket, (bucket, exit) => // The release function for the Effect.acquireRelease operation is // responsible for handling the acquired resource (bucket) after the // main effect has completed. It is called regardless of whether the // main effect succeeded or failed. If the main effect failed, // Exit.isFailure(exit) will be true, and the function will perform // a rollback by calling deleteBucket(bucket). If the main effect // succeeded, Exit.isFailure(exit) will be false, and the function // will return Effect.void, representing a successful, but // do-nothing effect. Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void, ) }) // Create an index, and define the release function that deletes the // index if the operation fails. const createIndex = Effect.gen(function* () { const { createIndex, deleteIndex } = yield* ElasticSearch return yield* Effect.acquireRelease(createIndex, (index, exit) => Exit.isFailure(exit) ? deleteIndex(index) : Effect.void, ) }) // Create an entry in the database, and define the release function that // deletes the entry if the operation fails. const createEntry = (bucket: Bucket, index: Index) => Effect.gen(function* () { const { createEntry, deleteEntry } = yield* Database return yield* Effect.acquireRelease( createEntry(bucket, index), (entry, exit) => Exit.isFailure(exit) ? deleteEntry(entry) : Effect.void, ) }) const make = Effect.scoped( Effect.gen(function* () { const bucket = yield* createBucket const index = yield* createIndex return yield* createEntry(bucket, index) }), ) // Exercise the happy path with minimal stub implementations const S3Stub = Layer.succeed(S3, { createBucket: Effect.succeed({ name: "bucket" }), deleteBucket: () => Effect.void, }) const ElasticSearchStub = Layer.succeed(ElasticSearch, { createIndex: Effect.succeed({ id: "idx" }), deleteIndex: () => Effect.void, }) const DatabaseStub = Layer.succeed(Database, { createEntry: () => Effect.succeed({ id: "entry" }), deleteEntry: () => Effect.void, }) await Effect.runPromise( Effect.provide(make, Layer.mergeAll(S3Stub, ElasticSearchStub, DatabaseStub)), ) // => { id: "entry" } ``` 接下来,我们创建一些简单的服务实现,用来测试 Workspace 代码的行为。 为此,我们将利用 [Layer](/docs/v4/requirements-management/layers/) 来构造测试用的实现。 这些 Layer 能够处理各种场景,其中包括错误,而我们可以通过 `FailureCase` 类型来控制这些错误。 ```ts import { Effect, Context, Exit, Data, Layer, Console, Result } from "effect" class S3Error extends Data.TaggedError("S3Error")<{}> {} interface Bucket { readonly name: string } class S3 extends Context.Service< S3, { readonly createBucket: Effect.Effect readonly deleteBucket: (bucket: Bucket) => Effect.Effect } >()("S3") {} class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{}> {} interface Index { readonly id: string } class ElasticSearch extends Context.Service< ElasticSearch, { readonly createIndex: Effect.Effect readonly deleteIndex: (index: Index) => Effect.Effect } >()("ElasticSearch") {} class DatabaseError extends Data.TaggedError("DatabaseError")<{}> {} interface Entry { readonly id: string } class Database extends Context.Service< Database, { readonly createEntry: ( bucket: Bucket, index: Index, ) => Effect.Effect readonly deleteEntry: (entry: Entry) => Effect.Effect } >()("Database") {} // Create a bucket, and define the release function that deletes the // bucket if the operation fails. const createBucket = Effect.gen(function* () { const { createBucket, deleteBucket } = yield* S3 return yield* Effect.acquireRelease(createBucket, (bucket, exit) => // The release function for the Effect.acquireRelease operation is // responsible for handling the acquired resource (bucket) after the // main effect has completed. It is called regardless of whether the // main effect succeeded or failed. If the main effect failed, // Exit.isFailure(exit) will be true, and the function will perform // a rollback by calling deleteBucket(bucket). If the main effect // succeeded, Exit.isFailure(exit) will be false, and the function // will return Effect.void, representing a successful, but // do-nothing effect. Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void, ) }) // Create an index, and define the release function that deletes the // index if the operation fails. const createIndex = Effect.gen(function* () { const { createIndex, deleteIndex } = yield* ElasticSearch return yield* Effect.acquireRelease(createIndex, (index, exit) => Exit.isFailure(exit) ? deleteIndex(index) : Effect.void, ) }) // Create an entry in the database, and define the release function that // deletes the entry if the operation fails. const createEntry = (bucket: Bucket, index: Index) => Effect.gen(function* () { const { createEntry, deleteEntry } = yield* Database return yield* Effect.acquireRelease( createEntry(bucket, index), (entry, exit) => Exit.isFailure(exit) ? deleteEntry(entry) : Effect.void, ) }) const make = Effect.scoped( Effect.gen(function* () { const bucket = yield* createBucket const index = yield* createIndex return yield* createEntry(bucket, index) }), ) // The `FailureCaseLiterals` type allows us to provide different error // scenarios while testing our // // For example, by providing the value "S3", we can simulate an error // scenario specific to the S3 service. This helps us ensure that our // program handles errors correctly and behaves as expected in various // situations. // // Similarly, we can provide other values like "ElasticSearch" or // "Database" to simulate error scenarios for those In cases // where we want to test the absence of errors, we can provide // `undefined`. By using this parameter, we can thoroughly test our // services and verify their behavior under different error conditions. type FailureCaseLiterals = "S3" | "ElasticSearch" | "Database" | undefined class FailureCase extends Context.Service()( "FailureCase", ) {} // Create a test layer for the S3 service const S3Test = Layer.effect( S3, Effect.gen(function* () { const failureCase = yield* FailureCase return { createBucket: Effect.gen(function* () { console.log("[S3] creating bucket") if (failureCase === "S3") { return yield* Effect.fail(new S3Error()) } else { return { name: "" } } }), deleteBucket: (bucket) => Console.log(`[S3] delete bucket ${bucket.name}`), } }), ) // Create a test layer for the ElasticSearch service const ElasticSearchTest = Layer.effect( ElasticSearch, Effect.gen(function* () { const failureCase = yield* FailureCase return { createIndex: Effect.gen(function* () { console.log("[ElasticSearch] creating index") if (failureCase === "ElasticSearch") { return yield* Effect.fail(new ElasticSearchError()) } else { return { id: "" } } }), deleteIndex: (index) => Console.log(`[ElasticSearch] delete index ${index.id}`), } }), ) // Create a test layer for the Database service const DatabaseTest = Layer.effect( Database, Effect.gen(function* () { const failureCase = yield* FailureCase return { createEntry: (bucket, index) => Effect.gen(function* () { console.log( "[Database] creating entry for bucket" + `${bucket.name} and index ${index.id}`, ) if (failureCase === "Database") { return yield* Effect.fail(new DatabaseError()) } else { return { id: "" } } }), deleteEntry: (entry) => Console.log(`[Database] delete entry ${entry.id}`), } }), ) // Merge all the test layers for S3, ElasticSearch, and Database // services into a single layer const layer = Layer.mergeAll(S3Test, ElasticSearchTest, DatabaseTest) // Create a runnable effect to test the Workspace code. The effect is // provided with the test layer and a FailureCase service with undefined // value (no failure case). const runnable = make.pipe( Effect.provide(layer), Effect.provide(Layer.succeed(FailureCase, undefined)), ) await Effect.runPromise(Effect.result(runnable)) // => Result.succeed({ id: "" }) ``` 我们来看看 `FailureCase` 被设为 `undefined`(正常路径)时的测试结果: ```ansi [S3] creating bucket [ElasticSearch] creating index [Database] creating entry for bucket and index { _id: 'Result', _tag: 'Success', value: { id: '' } } ``` 在这个例子中,所有操作都成功,我们看到了一个包含数据库记录的 `Result.Success`。 现在,让我们模拟一次 `Database` 失败: ```ts const runnable = make.pipe( Effect.provide(layer), Effect.provideService(FailureCase, "Database"), ) ``` 控制台输出将是: ```ansi [S3] creating bucket [ElasticSearch] creating index [Database] creating entry for bucket and index [ElasticSearch] delete index [S3] delete bucket { _id: 'Result', _tag: 'Failure', failure: { _tag: 'DatabaseError' } } ``` 你可以看到,一旦发生 `Database` 错误,就会有一次完整的回滚:先删除 `ElasticSearch` 索引,再删除关联的 `S3` 存储桶。结果是一个包含 `DatabaseError` 的 `Result.Failure`。 现在,让我们改为让索引创建失败: ```ts const runnable = make.pipe( Effect.provide(layer), Effect.provideService(FailureCase, "ElasticSearch"), ) ``` 在这种情况下,控制台输出将是: ```ansi [S3] creating bucket [ElasticSearch] creating index [S3] delete bucket { _id: 'Result', _tag: 'Failure', failure: { _tag: 'ElasticSearchError' } } ``` 如预期的那样,一旦 `ElasticSearch` 索引创建失败,就会发生一次回滚,删除 `S3` 存储桶。结果是一个包含 `ElasticSearchError` 的 `Result.Failure`。 --- # Runtime 入门 > 了解 Effect 的运行时系统如何以灵活高效的方式执行并发程序、管理资源并处理配置。 Effect 的**运行时系统**(runtime system)把 `Effect` 这样的蓝图变成真正运行的程序:它提供 `R` 所代表的需求,逐步执行每一个步骤,并产出结果。 `Effect.run*` 系列函数(`Effect.runPromise`、`Effect.runFork`、`Effect.runSync` 等)会利用 effect 的运行时系统立即执行它。如果你已经拥有满足该 effect 需求的 `Context`,那么 `Effect.run*With` 变体(`Effect.runPromiseWith`、`Effect.runForkWith`、`Effect.runSyncWith`)可以让你直接带着该 context 运行。若你想要一个可复用、顶层的执行配置,请使用 `ManagedRuntime`(本页稍后会介绍)。 ## 什么是运行时系统? 当我们编写 Effect 程序时,我们会用各种构造器和组合子来构造一个 `Effect`。本质上,我们是在创建一份程序的蓝图。`Effect` 只是一个描述并发程序执行过程的数据结构。它表现为一种树状结构,把各种原语组合在一起,定义该 effect 应该做什么。 然而,这个数据结构本身不会执行任何动作,它仅仅是对一个并发程序的描述。 要执行这个程序,就需要 Effect 运行时系统登场。`Effect.run*` 系列函数(例如 `Effect.runPromise`、`Effect.runFork`)负责接收这份蓝图并执行它。 当运行时系统运行一个 effect 时,它会创建一个根 Fiber,并用以下内容初始化它: - 初始 [context](/docs/v4/requirements-management/services/#how-it-works) - 初始的 Fiber 局部状态 - 初始 effect 然后它启动一个循环,逐步执行 `Effect` 所描述的指令。 你可以把运行时看作这样一个系统:它接收一个 [`Effect`](/docs/v4/getting-started/the-effect-type/) 及其关联的 context `Context`,并产出 [`Exit`](/docs/v4/data-types/exit/) 结果。 ```text ┌────────────────────────────────┐ │ Context + Effect │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ Effect Runtime System │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ Exit │ └────────────────────────────────┘ ``` 运行时系统肩负着许多职责: | 职责 | 说明 | | --- | --- | | **执行程序** | 运行时必须循环执行 effect 的每一个步骤,直到程序完成。 | | **处理错误** | 它同时处理执行过程中出现的预期错误与意外错误。 | | **管理并发** | 当调用 `Effect.forkChild` 时,运行时会生成新的 Fiber 来处理并发操作。 | | **协作式让出** | 它确保 Fiber 不会独占资源,并在必要时让出控制权。 | | **确保资源清理** | 运行时保证终结器正确运行,以便在需要时清理资源。 | | **处理异步回调** | 运行时透明地处理异步操作,让你可以用统一的方式编写异步与同步代码。 | ## 使用显式 context 运行 当我们使用[运行 effect 的函数](/docs/v4/getting-started/running-effects/)(如 `Effect.runPromise` 或 `Effect.runFork`)时,我们完全不需要提及任何运行时对象。并不存在一个单独的“默认运行时”值需要查找或传递。这些函数直接执行 `Effect`,使用空的 context 以及[默认 services](/docs/v4/requirements-management/default-services/)。 如果你的 effect 仍有未满足的需求(`R` 不是 `never`),而你又已经拥有满足这些需求的 `Context`——例如通过 `Context.make` 手动构建、而非经由 `Layer` 构建的 context——那么你可以直接用对应的 `Effect.run*With` 函数运行它,而不必先用 `Effect.provide` 把它包起来: **示例**(使用显式 context 同步运行) ```ts import { Context, Effect } from "effect" // Define a service and its shape class MathService extends Context.Service< MathService, { readonly add: (a: number, b: number) => number } >()("MathService") {} // Build a context providing an implementation directly const context = Context.make(MathService, { add: (a, b) => a + b, }) const program = Effect.gen(function* () { const math = yield* MathService return math.add(2, 3) }) Effect.runSyncWith(context)(program) // => 5 ``` 在大多数场景下,这种直接的方式已足以执行 effect。不过,有些情况下构建一个可复用的 runtime 会很有帮助,尤其是当你需要在许多次独立调用之间复用特定配置或 context 时。 例如,在 React 应用里,或者在服务器上响应 API 请求执行操作时,你可能想用 `ManagedRuntime` 从一个 [layer](/docs/v4/requirements-management/layers/) `Layer` 构建可复用的 runtime,下一节会介绍。这样你就能在不同的执行边界之间保持一致的 context。 ## 局部作用域的运行时配置 在 Effect 中,运行时配置通常从父级工作流**继承**。这意味着,当我们在某个工作流内部访问运行时配置或获取一个 runtime 时,实际上使用的就是父级工作流的配置。 不过,有时我们想临时**覆盖代码中某个特定部分的运行时配置**。这个概念称为局部作用域的运行时配置。一旦该代码区域的执行结束,运行时配置就会**恢复**为原来的设置。 为此,我们使用 `Effect.provide`,它允许我们把新的运行时配置提供给代码的某个特定区段。 **示例**(覆盖 Logger 配置) 在这个示例中,我们创建一个包含自定义 logger 的 layer,它记录消息时不带时间戳和级别。然后我们用 `Effect.provide` 把这个 logger layer 应用到程序上。 ```ts import { Logger, Effect, Fiber, Exit } from "effect" const addSimpleLogger = Logger.layer([ // Custom logger implementation Logger.make(({ message }) => console.log(message)), ]) const program = Effect.gen(function* () { yield* Effect.log("Application started!") yield* Effect.log("Application is about to exit!") }) // Running with the default logger Effect.runFork(program) /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" timestamp=... level=INFO fiber=#0 message="Application is about to exit!" */ // Overriding the default logger with a custom one const fiber = Effect.runFork(program.pipe(Effect.provide(addSimpleLogger))) /* Output: [ 'Application started!' ] [ 'Application is about to exit!' ] */ Effect.runSync(Fiber.await(fiber)) // => Exit.succeed(undefined) ``` 为了确保运行时配置只应用于 Effect 应用的某个特定部分,我们应该只把配置 layer 提供给那一个部分。 **示例**(把配置 layer 提供给嵌套的工作流) 在这个示例中,我们演示如何只把自定义 logger 配置应用到程序的某个特定区段。程序的大部分都使用默认 logger,但当我们应用 `Effect.provide(addSimpleLogger)` 调用时,它会覆盖那个特定嵌套块内部的 logger。之后,配置会恢复为原来的状态。 ```ts import { Logger, Effect } from "effect" const addSimpleLogger = Logger.layer([ // Custom logger implementation Logger.make(({ message }) => console.log(message)), ]) const removeDefaultLogger = Logger.layer([]) const program = Effect.gen(function* () { // Logs with default logger yield* Effect.log("Application started!") yield* Effect.gen(function* () { // This log is suppressed yield* Effect.log("I'm not going to be logged!") // Custom logger applied here yield* Effect.log("I will be logged by the simple logger.").pipe( Effect.provide(addSimpleLogger), ) // This log is suppressed yield* Effect.log( "Reset back to the previous configuration, so I won't be logged.", ) }).pipe( // Remove the default logger temporarily Effect.provide(removeDefaultLogger), ) // Logs with default logger again yield* Effect.log("Application is about to exit!") }) Effect.runSync(program) // => undefined /* Output: timestamp=... level=INFO fiber=#0 message="Application started!" [ 'I will be logged by the simple logger.' ] timestamp=... level=INFO fiber=#0 message="Application is about to exit!" */ ``` ## ManagedRuntime 在开发 Effect 应用并使用 `Effect.run*` 函数执行它时,应用会在幕后自动使用默认 runtime 运行。虽然可以通过 `Effect.provide` 提供局部作用域的配置 layer 来调整应用的特定部分,但有些场景下你可能想**从顶层为整个应用自定义运行时配置**。 在这些情况下,你可以使用 `ManagedRuntime.make` 构造器把一个配置 layer 转换成 runtime,从而创建顶层 runtime。 **示例**(创建并使用自定义 ManagedRuntime) 在这个示例中,我们首先创建一个名为 `appLayer` 的自定义配置 layer,它用一个会把消息输出到控制台的简单 logger 替换默认 logger。接着,我们用 `ManagedRuntime.make` 把这个配置 layer 变成 runtime。 ```ts import { Effect, ManagedRuntime, Logger } from "effect" // Define a configuration layer that replaces the default logger const appLayer = Logger.layer([ // Custom logger implementation Logger.make(({ message }) => console.log(message)), ]) // Create a custom runtime from the configuration layer const runtime = ManagedRuntime.make(appLayer) const program = Effect.log("Application started!") // Execute the program using the custom runtime runtime.runSync(program) // => undefined // Clean up resources associated with the custom runtime Effect.runFork(runtime.disposeEffect) /* Output: [ 'Application started!' ] */ ``` ### Context.Service 在与需要四处传递的 runtime 打交道时,`Context.Service` 可以简化对 service 的访问。它让你可以把 service key 及其形状一起定义成单个类。 **示例**(为通知定义一个 service) ```ts import { Context, Effect } from "effect" class Notifications extends Context.Service< Notifications, { readonly notify: (message: string) => Effect.Effect } >()("Notifications") {} Notifications.key // => "Notifications" ``` 使用 `.use()`(见下文)可以用解析后的 service 运行一个回调,或者在 `Effect.gen` 中通过 `yield* Notifications` 直接访问它。 这让你可以直接与该 service 交互: **示例**(使用 Notifications service key) ```ts import { Context, Effect, Layer } from "effect" class Notifications extends Context.Service< Notifications, { readonly notify: (message: string) => Effect.Effect } >()("Notifications") {} // Create an effect that depends on the Notifications service // // ┌─── Effect // ▼ const action = Notifications.use((n) => n.notify("Hello, world!")) Effect.runSync( action.pipe( Effect.provide(Layer.succeed(Notifications, { notify: () => Effect.void })), ), ) // => undefined ``` 在这个示例中,`action` effect 依赖于 `Notifications` service。这种方式让你无需手动传递就能引用 service。之后,你可以创建一个提供 `Notifications` service 的 `Layer`,并用该 layer 构建 `ManagedRuntime`,以确保该 service 在需要的地方可用。 ### 集成 `ManagedRuntime` 简化了 service 与 layer 同其他框架或工具的集成,尤其是在 Effect 并非主要框架、且对主入口点的访问受到限制的环境中。 例如,在 React 这类框架或环境中,你对应用主入口点的控制有限,`ManagedRuntime` 有助于管理 service 的生命周期。 下面介绍如何在外部框架中管理 service 的生命周期: **示例**(在外部框架中使用 `ManagedRuntime`) ```ts import { Context, Effect, ManagedRuntime, Layer, Console } from "effect" // Define the Notifications service using Context.Service class Notifications extends Context.Service< Notifications, { readonly notify: (message: string) => Effect.Effect } >()("Notifications") { // Provide a live implementation of the Notifications service static Live = Layer.succeed(this, { notify: (message) => Console.log(message), }) } // Example entry point for an external framework async function main() { // Create a custom runtime using the Notifications layer const runtime = ManagedRuntime.make(Notifications.Live) // Run the effect const result = await runtime.runPromise( Notifications.use((n) => n.notify("Hello, world!")), ) // Dispose of the runtime, cleaning up resources await runtime.dispose() return result } await main() // => undefined ``` --- # 内置调度 > 了解 Effect 中的内置调度模式,用于高效地实现定时重复与延迟。 为了演示不同调度的行为,我们会用到下面这个辅助函数:它把每一次重复连同对应的延迟(毫秒)一起打印出来,格式为: ```text #: ``` **辅助函数**(打印执行延迟) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): void => { const maxRecurs = 10 // Limit the number of executions const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." // Indicate truncation if there are more executions : i === delays.length - 1 ? "(end)" // Mark the last execution : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } typeof log // => "function" ``` ## 无限重复与固定次数重复 ### forever 一个无限重复的调度,每次运行时产出当前的重复次数。 **示例**(无限重复的调度) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.forever log(schedule) /* Output: #1: 0ms < forever #2: 0ms #3: 0ms #4: 0ms #5: 0ms #6: 0ms #7: 0ms #8: 0ms #9: 0ms #10: 0ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] ``` ### once 一个只重复一次的调度。 **示例**(只重复一次的调度) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.duration(Duration.zero) log(schedule) /* Output: #1: 0ms < once (end) */ const result = log(schedule) result.map(Duration.toMillis) // => [0] ``` ### recurs 一个重复**指定次数**的调度,每次运行时产出当前的重复次数。 **示例**(固定重复次数) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.recurs(5) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms #4: 0ms #5: 0ms (end) */ const result = log(schedule) result.map(Duration.toMillis) // => [0, 0, 0, 0, 0] ``` ## 按固定间隔重复 你可以定义控制"两次执行之间间隔多久"的调度。`spaced` 与 `fixed` 的区别在于**间隔如何计量**: - `spaced`:从**上一次执行结束**开始计算下一次的延迟。 - `fixed`:保证以**固定节奏**重复,不受执行耗时影响。 ### spaced 一个无限重复的调度,每次重复与上一次运行之间相隔指定的时长。它每次运行时返回重复次数。 **示例**(执行之间带延迟地重复) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.spaced("200 millis") // ┌─── Simulating an effect that takes // │ 100 milliseconds to complete // ▼ log(schedule, "100 millis") /* Output: #1: 300ms < spaced #2: 300ms #3: 300ms #4: 300ms #5: 300ms #6: 300ms #7: 300ms #8: 300ms #9: 300ms #10: 300ms ... */ const result = log(schedule, "100 millis") result.map(Duration.toMillis) // => [300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300] ``` 第一次延迟大约是 100 毫秒,因为首次执行不受调度影响。之后的每次延迟大约相隔 200 毫秒,体现了 `spaced` 调度的效果。 ### fixed 一个按固定间隔重复的调度,每次运行时返回重复次数。 如果两次更新之间执行的动作耗时超过了间隔,那么该动作会被立即执行,但重复执行**不会堆积**。 ```text |-----interval-----|-----interval-----|-----interval-----| |---------action--------|action-------|action------------| ``` **示例**(固定间隔重复) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.fixed("200 millis") // ┌─── Simulating an effect that takes // │ 100 milliseconds to complete // ▼ log(schedule, "100 millis") /* Output: #1: 300ms < fixed #2: 200ms #3: 200ms #4: 200ms #5: 200ms #6: 200ms #7: 200ms #8: 200ms #9: 200ms #10: 200ms ... */ const result = log(schedule, "100 millis") result.map(Duration.toMillis) // => [300, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200] ``` ## 逐渐拉长执行间隔 ### exponential 一个使用指数退避重复的调度,每次延迟按指数增长。返回当前的重复间隔。 **示例**(指数退避调度) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.exponential("10 millis") log(schedule) /* Output: #1: 10ms < exponential #2: 20ms #3: 40ms #4: 80ms #5: 160ms #6: 320ms #7: 640ms #8: 1280ms #9: 2560ms #10: 5120ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120, 10240] ``` ### fibonacci 一个总是重复的调度,每次延迟等于**前两次延迟之和**(类似斐波那契数列)。返回当前的重复间隔。 **示例**(斐波那契延迟调度) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.fibonacci("10 millis") log(schedule) /* Output: #1: 10ms #2: 20ms #3: 30ms #4: 50ms #5: 80ms #6: 130ms #7: 210ms #8: 340ms #9: 550ms #10: 890ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [10, 20, 30, 50, 80, 130, 210, 340, 550, 890, 1440] ``` --- # Cron > 探索 Effect 中的 cron 调度:在特定时间与间隔执行操作。 `Cron` 模块让你可以用类似 [UNIX cron 表达式](https://en.wikipedia.org/wiki/Cron) 的风格定义调度。 它还支持部分约束(例如特定的月份或星期几)、通过 [DateTime](/docs/v4/data-types/datetime/) 模块实现的时区感知,以及健壮的错误处理。 这个模块可以帮助你: - **创建(Create)**:由各个单独的字段构造一个 `Cron` 实例。 - **解析并校验(Parse and validate)**:解析 cron 表达式并校验其有效性。 - **匹配(Match)**:检查已有日期是否满足给定的 cron 调度。 - **查找(Find)**:找出给定日期之后该调度的下一次触发时间。 - **迭代(Iterate)**:遍历符合某个调度的未来日期。 - **转换(Convert)**:把 `Cron` 实例转换为 `Schedule`,以便在 effectful 程序中使用。 ## 创建 Cron 你可以通过为秒、分、时、日、月、星期几指定数值约束来定义 cron 调度。`make` 函数要求你定义表示该调度约束的所有字段。 **示例**(创建 Cron) ```ts import { Cron, DateTime } from "effect" // Build a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.make({ seconds: [0], // Trigger at the start of a minute minutes: [0], // Trigger at the start of an hour hours: [4], // Trigger at 4:00 AM days: [8, 9, 10, 11, 12, 13, 14], // Specific days of the month months: [], // No restrictions on the month weekdays: [], // No restrictions on the weekday tz: DateTime.zoneMakeNamedUnsafe("Europe/Rome"), // Optional time zone }) const hours = [...cron.hours] hours // => [4] ``` - `seconds`、`minutes` 和 `hours`:定义一天中的时间。 - `days` 和 `months`:指定哪些日历日和月份是有效的。 - `weekdays`:把调度限制在一周中的特定几天。 - `tz`:可选地为该调度指定时区。 如果某个字段留空(例如 `months`),它会被视为「无约束」,该日期部分可以取任意有效值。 ## 解析 cron 表达式 除了手动构造 `Cron`,你也可以使用类 UNIX 的 cron 字符串,并用 `parse` 或 `parseUnsafe` 解析它们。 ### parse `parse(cronExpression, tz?)` 函数会安全地把 cron 字符串解析为 `Cron` 实例。它返回一个 [Result](/docs/v4/data-types/result/),其中要么是解析得到的 `Cron`,要么是一个解析错误。 **示例**(安全地解析 cron 表达式) ```ts import { Result, Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const expression = "0 0 4 8-14 * *" // Parse the cron expression const result = Cron.parse(expression) if (Result.isSuccess(result)) { // Successfully parsed console.log("Parsed cron:", result.success) } else { // Parsing failed console.error("Failed to parse cron:", result.failure.message) } Result.isSuccess(result) // => true ``` ### parseUnsafe `parseUnsafe(cronExpression, tz?)` 函数的工作方式与 [parse](#parse) 类似,但当输入无效时它会抛出异常,而不是返回 [Result](/docs/v4/data-types/result/)。 **示例**(解析 cron 表达式) ```ts import { Cron } from "effect" // Parse a cron expression for 4:00 AM // on the 8th to the 14th of every month // Throws if the expression is invalid const cron = Cron.parseUnsafe("0 0 4 8-14 * *") const hours = [...cron.hours] hours // => [4] ``` ## 用 match 检查日期 `match` 函数让你可以判断给定的 `Date`(或任意 [DateTime.Input](/docs/v4/data-types/datetime/#the-datetimeinput-type))是否满足某个 cron 调度的约束。 如果该日期满足调度的条件,`match` 返回 `true`;否则返回 `false`。 **示例**(检查日期是否匹配 cron 调度) ```ts import { Cron } from "effect" // Suppose we have a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.parseUnsafe("0 0 4 8-14 * *") const checkDate = new Date("2025-01-08 04:00:00") console.log(Cron.match(cron, checkDate)) Cron.match(cron, checkDate) // => true ``` ## 查找下一次运行时间 `next` 函数从指定日期开始,找出满足给定 cron 调度的下一个日期。如果没有提供起始日期,则以当前时间作为起点。 如果 `next` 在预定义的迭代次数内找不到匹配的日期,它会抛出错误,以避免无限循环。 **示例**(确定下一个匹配的日期) ```ts import { Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC") // Specify the starting point for the search const after = new Date("2025-01-08") // Find the next matching date const nextDate = Cron.next(cron, after) console.log(nextDate) nextDate.toISOString() // => "2025-01-08T04:00:00.000Z" ``` ## 迭代未来的日期 要生成多个符合某个 cron 调度的未来日期,可以使用 `sequence` 函数。该函数会从指定日期开始,提供一个匹配日期的无限迭代器。 **示例**(用迭代器生成未来的日期) ```ts import { Cron } from "effect" // Define a cron expression for 4:00 AM // on the 8th to the 14th of every month const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC") // Specify the starting date const start = new Date("2021-01-08") // Create an iterator for the schedule const iterator = Cron.sequence(cron, start) // Get the first matching date after the start date const first = iterator.next().value console.log(first) first?.toISOString() // => "2021-01-08T04:00:00.000Z" // Get the second matching date after the start date const second = iterator.next().value console.log(second) second?.toISOString() // => "2021-01-09T04:00:00.000Z" ``` ## 转换为 Schedule `Schedule` 模块让你可以定义重复发生的行为,例如重试或周期性事件。`cron` 函数在 `Cron` 模块与 `Schedule` 模块之间架起桥梁,让你能够基于 cron 表达式或 `Cron` 实例创建调度。 ### cron `Schedule.cron` 函数会生成一个 [Schedule](/docs/v4/scheduling/introduction/),它在给定的 cron 表达式或 `Cron` 实例所定义的每个区间开始时触发。触发时,该调度会产出一个元组 `[start, end]`,表示该 cron 区间窗口的时间戳(以毫秒为单位)。 **示例**(由 Cron 创建 Schedule) ```ts import { Effect, Schedule, Fiber, Cron, Console, Duration } from "effect" import { TestClock } from "effect/testing" // A helper function to log output at each interval of the schedule const log = ( action: Effect.Effect, schedule: Schedule.Schedule, ) => { let i = 0 return Effect.gen(function* () { const fiber = yield* Effect.gen(function* () { yield* action i++ }).pipe( Effect.repeat( schedule.pipe( // Limit the number of iterations for the example Schedule.upTo({ times: 10 }), Schedule.tap(({ now, output }) => Console.log( i === 11 ? "..." : new Date(now + Duration.toMillis(output)), ), ), ), ), Effect.forkChild, ) yield* TestClock.adjust(Infinity) yield* Fiber.join(fiber) }).pipe(Effect.provide(TestClock.layer()), Effect.runPromise) } // Build a cron that triggers at 4:00 AM // on the 8th to the 14th of each month const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC") // Convert the Cron into a Schedule const schedule = Schedule.cron(cron) // Define a dummy action to repeat const action = Effect.void // Log the schedule intervals await log(action, schedule) /* Output: 1970-01-08T04:00:00.000Z 1970-01-09T04:00:00.000Z 1970-01-10T04:00:00.000Z 1970-01-11T04:00:00.000Z 1970-01-12T04:00:00.000Z 1970-01-13T04:00:00.000Z 1970-01-14T04:00:00.000Z 1970-02-08T04:00:00.000Z 1970-02-09T04:00:00.000Z 1970-02-10T04:00:00.000Z ... */ // The schedule fires at the same instants the Cron itself reports, // starting from the Unix epoch (TestClock starts at time 0) const iterator = Cron.sequence(cron, new Date(0)) const firstTen = Array.from({ length: 10 }, () => iterator.next().value?.toISOString(), ) firstTen // => ["1970-01-08T04:00:00.000Z", "1970-01-09T04:00:00.000Z", "1970-01-10T04:00:00.000Z", "1970-01-11T04:00:00.000Z", "1970-01-12T04:00:00.000Z", "1970-01-13T04:00:00.000Z", "1970-01-14T04:00:00.000Z", "1970-02-08T04:00:00.000Z", "1970-02-09T04:00:00.000Z", "1970-02-10T04:00:00.000Z"] ``` --- # 示例 > 探索在 Effect 中处理调度、重试、超时与周期性任务执行的实用示例。 这些示例展示了使用 Effect 处理超时、重试与周期性执行的几种不同方式。每个场景都能让应用保持响应、在面对失败时具备韧性,并动态适应各种条件。 ## 为 API 调用处理超时与重试 在调用第三方 API 时,通常需要强制施加超时并实现重试机制,以处理临时性失败。在这个示例中,API 调用在失败时最多重试两次,如果耗时超过 4 秒就会被中断。 **示例**(为带超时的 API 调用重试) ```ts import { Console, Effect } from "effect" // Function to make the API call const getJson = (url: string) => Effect.tryPromise(() => fetch(url).then((res) => { if (!res.ok) { console.log("error") throw new Error(res.statusText) } console.log("ok") return res.json() as unknown }), ) // Program that retries the API call twice, times out after 4 seconds, // and logs errors const program = (url: string) => getJson(url).pipe( Effect.retry({ times: 2 }), Effect.timeout("4 seconds"), Effect.catch(Console.error), ) // Test case: successful API response Effect.runFork(program("https://dummyjson.com/products/1?delay=1000")) /* Output: ok */ // Test case: API call exceeding timeout limit Effect.runFork(program("https://dummyjson.com/products/1?delay=5000")) /* Output: { message: undefined, _tag: 'TimeoutError', '~effect/Cause/TimeoutError': '~effect/Cause/TimeoutError' } */ // Test case: API returning an error response Effect.runFork(program("https://dummyjson.com/auth/products/1?delay=500")) /* Output: error error error { message: 'An error occurred in Effect.tryPromise', cause: Error: ..., _tag: 'UnknownError', '~effect/Cause/UnknownError': '~effect/Cause/UnknownError' } */ ``` ## 根据特定错误重试 API 调用 有时,只有特定的错误条件才应该触发重试。例如,如果 API 调用以 `401 Unauthorized` 响应失败,重试可能是合理的;而 `404 Not Found` 错误则不应该触发重试。 **示例**(只针对特定错误码重试) ```ts import { Console, Effect, Data } from "effect" // Custom error class for handling status codes class Err extends Data.TaggedError("Err")<{ readonly message: string readonly status: number }> {} // Function to make the API call const getJson = (url: string) => Effect.tryPromise({ try: () => fetch(url).then((res) => { if (!res.ok) { console.log(res.status) throw new Err({ message: res.statusText, status: res.status }) } return res.json() as unknown }), catch: (e) => e as Err, }) // Program that retries only when the error status is 401 (Unauthorized) const program = (url: string) => getJson(url).pipe( Effect.retry({ while: (err) => err.status === 401 }), Effect.catch(Console.error), ) // Test case: API returns 401 (triggers multiple retries) Effect.runFork(program("https://dummyjson.com/auth/products/1?delay=1000")) /* Output: 401 401 401 401 ... */ // Test case: API returns 404 (no retries) Effect.runFork(program("https://dummyjson.com/-")) /* Output: 404 Err [Error]: Not Found */ ``` ## 基于错误信息动态调整延迟的重试 有些 API 错误(例如 `429 Too Many Requests`)会带有 `Retry-After` 响应头,指明在重试之前需要等待多久。我们可以根据这个值动态调整重试间隔,而不是使用固定的延迟。 **示例**(使用 `Retry-After` 响应头设置重试延迟) 这种方式确保重试延迟能够动态适应服务端的响应,既避免不必要的重试,又遵循服务端给出的 `Retry-After` 值。 ```ts import { Duration, Effect, Schedule, Data } from "effect" // Custom error class representing a "Too Many Requests" response class TooManyRequestsError extends Data.TaggedError("TooManyRequestsError")<{ readonly retryAfter: number }> {} let n = 1 const request = Effect.gen(function* () { // Simulate failing a particular number of times if (n < 3) { const retryAfter = n * 500 console.log(`Attempt #${n++}, retry after ${retryAfter} millis...`) // Simulate retrieving the retry-after header return yield* Effect.fail(new TooManyRequestsError({ retryAfter })) } console.log("Done") return "some result" }) // Retry policy that extracts the retry delay from the error const policy = Schedule.max([ Schedule.identity().pipe( Schedule.addDelay(({ output: error }) => Effect.succeed( error._tag === "TooManyRequestsError" ? // Wait for the specified retry-after duration Duration.millis(error.retryAfter) : Duration.zero, ), ), ), // Limit retries to 5 attempts Schedule.recurs(5), ]) const program = request.pipe(Effect.retry(policy)) const result = await Effect.runPromise(program) /* Output: Attempt #1, retry after 500 millis... Attempt #2, retry after 1000 millis... Done */ result // => "some result" ``` ## 周期性运行任务直到另一个任务完成 在某些情况下,我们需要以固定间隔重复执行某个动作,直到另一个运行时间更长的任务完成。这种模式常见于轮询机制或周期性日志记录。 **示例**(运行一个计划任务直到完成) ```ts import { Effect, Console, Schedule } from "effect" // Define a long-running effect // (e.g., a task that takes 5 seconds to complete) const longRunningEffect = Console.log("done").pipe(Effect.delay("5 seconds")) // Define an action to run periodically const action = Console.log("action...") // Define a fixed interval schedule const schedule = Schedule.fixed("1.5 seconds") // Run the action repeatedly until the long-running task completes const program = Effect.race(Effect.repeat(action, schedule), longRunningEffect) const result = await Effect.runPromise(program) /* Output: action... action... action... action... done */ result // => undefined ``` --- # 简介 > 学习 Effect 中调度的基础知识,包括可组合的重复模式,以及如何处理重试与重复。 # 调度 调度是 Effect 中的一个重要概念,它让你能够定义按计划重复执行的 effect 操作。这需要用到 `Schedule` 类型,它是一个不可变的值,用于描述执行 effect 的调度模式。 `Schedule` 类型的结构如下: ```text ┌─── The type of output produced by the schedule │ ┌─── The type of input consumed by the schedule │ │ ┌─── Additional requirements for the schedule ▼ ▼ ▼ Schedule ``` 一个 Schedule 通过消费 `In` 类型的值(例如 `retry` 情况下的错误,或 `repeat` 情况下的值)并产出 `Out` 类型的值来运作。它根据输入值及其内部状态,决定何时停止或继续执行。 引入 `Requirements` 参数,使 Schedule 能够按需使用额外的服务或资源。 Schedule 被定义为一组在时间上分散的区间。每个区间代表一个时间窗口,在此期间 effect 有可能重复发生。 ## 重试与重复 在调度领域中有两个相关概念:[重试](/docs/v4/error-management/retrying/) 和 [重复](/docs/v4/scheduling/repetition/)。它们共享同一个基本思想,但侧重点不同。重试旨在通过再次执行 effect 来处理失败,而重复则侧重于反复执行 effect 以达成期望的结果。 在使用 Schedule 进行重试或重复时,每个区间的起始边界决定了 effect 何时会被再次执行。例如在重试中,如果发生错误,Schedule 便定义了该 effect 应在何时重试。 ## Schedule 的可组合性 Schedule 是可组合的,这意味着你可以将简单的 Schedule 组合起来,构建出更复杂的重复模式。像 `Schedule.min` 或 `Schedule.max` 这样的操作符,允许你通过组合和修改已有的 Schedule 来构建精巧的调度方案。这种灵活性让你能够量身定制调度行为,以满足特定需求。 --- # 重复执行 > 探索 Effect 中的重复执行:按照指定策略多次执行同一个 Effect,并控制重复次数、失败与条件。 在软件开发中,重复执行 Effect 是一项常见需求。它允许我们按照特定的重复策略多次执行同一个 Effect。 ## repeat `Effect.repeat` 函数返回一个新的 Effect,它会按照指定的 Schedule 重复给定的 Effect,或者重复到第一次失败为止。 **示例**(重复一个成功的 Effect) ```ts import { Effect, Schedule, Console } from "effect" // Define an effect that logs a message to the console const action = Console.log("success") // Define a schedule that repeats the action 2 more times with a delay const policy = Schedule.addDelay(Schedule.recurs(2), () => Effect.succeed("100 millis"), ) // Repeat the action according to the schedule const program = Effect.repeat(action, policy) // Run the program and log the number of repetitions const repetitions = await Effect.runPromise(program) console.log(`repetitions: ${repetitions}`) /* Output: success success success repetitions: 2 */ repetitions // => 2 ``` **示例**(处理重复中的失败) ```ts import { Effect, Schedule, Exit } from "effect" let count = 0 // Define an async effect that simulates an action with potential failure const action = Effect.callback((resume) => { if (count > 1) { console.log("failure") resume(Effect.fail("Uh oh!")) } else { count++ console.log("success") resume(Effect.succeed("yay!")) } }) // Define a schedule that repeats the action 2 more times with a delay const policy = Schedule.addDelay(Schedule.recurs(2), () => Effect.succeed("100 millis"), ) // Repeat the action according to the schedule const program = Effect.repeat(action, policy) // Run the program and observe the result on failure const exit = await Effect.runPromiseExit(program) console.log(exit) /* Output: success success failure */ exit // => Exit.fail("Uh oh!") ``` ### 跳过首次执行 如果你想避免首次执行,只按照 Schedule 运行该动作,可以使用 `Effect.schedule`。这会让 Effect 跳过最初的运行,直接遵循定义好的重复策略。 **示例**(跳过首次执行) ```ts import { Effect, Schedule, Console } from "effect" const action = Console.log("success") const policy = Schedule.addDelay(Schedule.recurs(2), () => Effect.succeed("100 millis"), ) const program = Effect.schedule(action, policy) const repetitions = await Effect.runPromise(program) console.log(`repetitions: ${repetitions}`) /* Output: success success repetitions: 2 */ repetitions // => 2 ``` ## repeatN `Effect.repeat` 函数返回一个新的 Effect,它会将指定的 Effect 重复给定的次数,或者重复到第一次失败为止。这些重复次数是在初次执行之外额外增加的,因此 `Effect.repeat(action, { times: 1 })` 会先执行一次 `action`,如果成功,再额外重复一次。 **示例**(多次重复一个动作) ```ts import { Effect, Console } from "effect" const action = Console.log("success") // Repeat the action 2 additional times after the first execution const program = Effect.repeat(action, { times: 2 }) const result = await Effect.runPromise(program) /* Output: success success success */ result // => undefined ``` ## repeatOrElse `repeatOrElse` 函数返回一个新的 Effect,它会按照给定的 Schedule 重复指定的 Effect,或者重复到第一次失败为止。 发生失败时,失败值和 Schedule 的输出会被传给指定的处理函数。 计划中的重复次数是在初次执行之外额外增加的,因此 `Effect.repeat(action, Schedule.duration(Duration.zero))` 会先执行一次 `action`,如果成功,再额外重复一次。 **示例**(处理重复过程中的失败) ```ts import { Effect, Schedule } from "effect" let count = 0 // Define an async effect that simulates an action with possible failures const action = Effect.callback((resume) => { if (count > 1) { console.log("failure") resume(Effect.fail("Uh oh!")) } else { count++ console.log("success") resume(Effect.succeed("yay!")) } }) // Define a schedule that repeats up to 2 times // with a 100ms delay between attempts const policy = Schedule.addDelay(Schedule.recurs(2), () => Effect.succeed("100 millis"), ) // Provide a handler to run when failure occurs after the retries const program = Effect.repeatOrElse(action, policy, () => Effect.sync(() => { console.log("orElse") return count - 1 }), ) const repetitions = await Effect.runPromise(program) console.log(`repetitions: ${repetitions}`) /* Output: success success failure orElse repetitions: 1 */ repetitions // => 1 ``` ## 基于条件重复 你可以使用 `while` 或 `until` 选项,通过条件来控制一个 Effect 的重复,从而根据运行时的结果进行动态控制。 **示例**(重复直到满足某个条件) ```ts import { Effect } from "effect" let count = 0 // Define an effect that simulates varying outcomes on each invocation const action = Effect.sync(() => { console.log(`Action called ${++count} time(s)`) return count }) // Repeat the action until the count reaches 3 const program = Effect.repeat(action, { until: (n) => n === 3 }) const result = await Effect.runPromise(program) /* Output: Action called 1 time(s) Action called 2 time(s) Action called 3 time(s) */ result // => 3 ``` --- # 调度组合子 > 学习如何通过组合与定制 Effect 中的调度,构建复杂的重复模式,包括并集、交集、顺序连接等。 调度(Schedule)定义的是**有状态、可能带副作用**的事件重复计划,并且可以以多种方式组合。组合子(combinator)让我们可以把多个调度组合在一起,得到新的调度。 为了演示不同调度的行为,我们会用到下面这个辅助函数:它把每一次重复连同对应的延迟(毫秒)一起打印出来,格式为: ```text #: ``` **辅助函数**(打印执行延迟) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): void => { const maxRecurs = 10 // Limit the number of executions const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." // Indicate truncation if there are more executions : i === delays.length - 1 ? "(end)" // Mark the last execution : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) } typeof log // => "function" ``` ## 组合 调度可以通过不同方式组合: | 模式 | 说明 | | --- | --- | | **并集(Union)** | 组合两个调度,只要其中一个还想继续就重复,并取**较短**的延迟。 | | **交集(Intersection)** | 组合两个调度,只有两个都想继续才重复,并取**较长**的延迟。 | | **顺序连接(Sequencing)** | 先完整跑完第一个调度,再切换到第二个。 | ### 并集(Union) 组合两个调度,只要其中一个还想继续就重复,并取**较短**的延迟。 **示例**(指数退避与固定间隔的组合) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.min([ Schedule.exponential("100 millis"), Schedule.spaced("1 second"), ]) log(schedule) /* Output: #1: 100ms < exponential #2: 200ms #3: 400ms #4: 800ms #5: 1000ms < spaced #6: 1000ms #7: 1000ms #8: 1000ms #9: 1000ms #10: 1000ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [100, 200, 400, 800, 1000, 1000, 1000, 1000, 1000, 1000, 1000] ``` `Schedule.min` 运算符在每一步都取最短的延迟,因此把指数退避和固定间隔组合时,初始的重复会走指数退避,等到延迟超过那个固定值后就稳定成固定间隔。 ### 交集(Intersection) 组合两个调度,只有两个都想继续才重复,并取**较长**的延迟。 **示例**(用固定的重试次数限制指数退避) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.max([ Schedule.exponential("10 millis"), Schedule.recurs(5), ]) log(schedule) /* Output: #1: 10ms < exponential #2: 20ms #3: 40ms #4: 80ms #5: 160ms (end) < recurs */ const result = log(schedule) result.map(Duration.toMillis) // => [10, 20, 40, 80, 160] ``` `Schedule.max` 运算符会同时受两个调度的约束。在这个例子中,调度走的是指数退避,但因为 `Schedule.recurs(5)` 的限制,在第 5 次重复后停止。 ### 顺序连接(Sequencing) 组合两个调度,先完整跑完第一个,再切换到第二个。 **示例**(从固定重试切换到周期执行) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.concat( Schedule.recurs(5), Schedule.spaced("1 second"), ) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms #4: 0ms #5: 0ms #6: 1000ms < spaced #7: 1000ms #8: 1000ms #9: 1000ms #10: 1000ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [0, 0, 0, 0, 0, 1000, 1000, 1000, 1000, 1000, 1000] ``` 第一个调度先跑到结束,之后由第二个调度接管。在这个例子中,effect 一开始连续执行 5 次(无延迟),然后每 1 秒执行一次。 ## 给重试延迟加入随机性 `Schedule.jittered` 组合子通过在一个指定范围内施加随机延迟来修改一个调度。 当某个资源因为过载或竞争而不可用时,重试加退避并不能帮上忙。如果所有失败的 API 调用都被退避到同一个时间点,它们会再次造成过载或竞争。Jitter(抖动)会给调度的延迟加入一定量的随机性,这样我们就不会在无意中把请求同步化、从而意外地把服务压垮。 [研究](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)表明,`Schedule.jittered(0.0, 1.0)` 是为重试引入随机性的有效方式。 **示例**(带抖动的指数退避) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.jittered(Schedule.exponential("10 millis")) log(schedule) /* Output: #1: 10.448486ms #2: 21.134521ms #3: 47.245117ms #4: 88.263184ms #5: 163.651367ms #6: 335.818848ms #7: 719.126709ms #8: 1266.18457ms #9: 2931.252441ms #10: 6121.593018ms ... */ // The exact delays are randomized by jitter, but the schedule still // produces exactly 11 values within maxRecurs (10 shown + 1 truncated) const result = log(schedule) result.length // => 11 ``` `Schedule.jittered` 组合子会在指定范围内给延迟加入随机性。例如,对指数退避施加抖动,能保证每次重试发生在略微不同的时刻,从而降低压垮系统的风险。 ## 用过滤器控制重复次数 使用 `Schedule.while` 可以限制调度持续多久。它的谓词会收到一段元数据,里面包含调度的输入和输出。 **示例**(基于输出停止) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.while(Schedule.recurs(5), ({ output }) => output <= 2) log(schedule) /* Output: #1: 0ms < recurs #2: 0ms #3: 0ms (end) < whileOutput */ const result = log(schedule) result.map(Duration.toMillis) // => [0, 0, 0] ``` `Schedule.while` 根据调度的输出过滤重复。在这个例子中,即便 `Schedule.recurs(5)` 允许最多 5 次重复,一旦输出超过 `2`,调度就会停止。 ## 根据输出调整延迟 `Schedule.modifyDelay` 组合子让你能基于重复次数或其它输出条件,动态地改变调度的延迟。 **示例**(在达到一定重复次数后缩短延迟) ```ts import { Array, Duration, Effect, Pull, Schedule } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.modifyDelay( Schedule.spaced("1 second"), ({ output, duration }) => Effect.succeed(output > 2 ? "100 millis" : duration), ) log(schedule) /* Output: #1: 1000ms #2: 1000ms #3: 1000ms #4: 100ms < modifyDelay #5: 100ms #6: 100ms #7: 100ms #8: 100ms #9: 100ms #10: 100ms ... */ const result = log(schedule) result.map(Duration.toMillis) // => [1000, 1000, 1000, 100, 100, 100, 100, 100, 100, 100, 100] ``` 延迟的修改在运行时动态生效。在这个例子中,前三次重复沿用原始的 `1 秒` 间隔;之后延迟降到 `100 毫秒`,使后续重复更加频繁。 ## 旁路(Tapping) `Schedule.tap` 会执行一个额外的、带副作用的操作,而不改变调度的行为。它的回调会收到一段元数据,里面包含调度的输入和输出。 **示例**(记录调度输出) ```ts import { Array, Duration, Effect, Pull, Schedule, Console } from "effect" const log = ( schedule: Schedule.Schedule, delay: Duration.Input = 0, ): Array => { const maxRecurs = 10 const withDelay = Schedule.addDelay(schedule, () => Effect.succeed(delay)) const delays = Effect.runSync( Effect.gen(function* () { const step = yield* Schedule.toStep(withDelay) const out: Array = [] let now = Date.now() for (const input of Array.range(0, maxRecurs)) { const duration = yield* Pull.matchEffect(step(now, input), { onSuccess: ([, duration]) => Effect.succeed(duration), onFailure: Effect.failCause, onDone: () => Effect.succeed(undefined), }) if (duration === undefined) break out.push(duration) now += Duration.toMillis(duration) } return out }), ) delays.forEach((duration, i) => { console.log( i === maxRecurs ? "..." : i === delays.length - 1 ? "(end)" : `#${i + 1}: ${Duration.toMillis(duration)}ms`, ) }) return delays } const schedule = Schedule.tap(Schedule.recurs(2), ({ output }) => Console.log(`Schedule Output: ${output}`), ) log(schedule) /* Output: Schedule Output: 0 Schedule Output: 1 #1: 0ms (end) */ const result = log(schedule) result.map(Duration.toMillis) // => [0, 0] ``` `Schedule.tap` 会在每次重复之前运行一个 effect,并以调度当前的输出作为输入。它可以用于打日志、调试,或触发副作用。 --- # 高级用法 > 学习定义和扩展数据 schema 的高级技巧,包括递归类型与互递归类型、可选字段、品牌类型以及 schema 变换。 ## 声明新的数据类型 ### 原始数据类型 要为一种不透明的、非泛型的数据类型声明 schema,可以把 `Schema.declare` 与类型守卫配合使用。下面的示例以 `File` 展示了这种底层模式。 **示例**(为 `File` 声明 Schema) ```ts import { Schema } from "effect" // Declare a schema for the File type using a type guard const FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, ) const decode = Schema.decodeUnknownSync(FileSchema) // Decoding a valid File object console.log(decode(new File([], ""))) /* Output: File { size: 0, type: '', name: '', lastModified: 1724774163056 } */ // Decoding an invalid input decode(null) /* throws SchemaError: Expected */ ``` 你可以添加 `identifier`、`title` 和 `description` 注解,让这个声明更容易被人和 schema 解释器理解。`identifier` 和 `title` 还能改进默认的期望值消息。 - **Identifier**:schema 的唯一名称 - **Title**:简短、描述性的标题 - **Description**:对 schema 用途的详细说明 **示例**(声明带注解的 Schema) ```ts import { Schema } from "effect" // Declare a schema for the File type with additional annotations const FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { // A unique identifier for the schema identifier: "File", // Detailed description of the schema description: "The `File` type in JavaScript", }, ) const decode = Schema.decodeUnknownSync(FileSchema) // Decoding a valid File object console.log(decode(new File([], ""))) /* Output: File { size: 0, type: '', name: '', lastModified: 1724774163056 } */ // Decoding an invalid input decode(null) /* throws SchemaError: Expected File */ ``` ### 类型构造器 类型构造器是接收一个或多个类型作为参数、并返回一个新类型的泛型类型。要为类型构造器定义 schema,可以使用 `Schema.declare` 函数。 **示例**(为 `ReadonlySet` 声明 Schema) ```ts import { Effect, Schema, SchemaIssue, SchemaParser, SchemaTransformation, } from "effect" export const MyReadonlySet = ( // Schema for the elements of the Set item: S, ) => Schema.declareConstructor< ReadonlySet, ReadonlySet >()( // Store the schema for the Set's elements [item], // Decoding function ([item]) => (input, ast, options) => { if (input instanceof Set) { // Decode each element in the Set return Effect.map( SchemaParser.decodeUnknownEffect(Schema.Array(item))( Array.from(input.values()), options, ), // Return a ReadonlySet containing the decoded elements (values): ReadonlySet => new Set(values), ) } // Handle invalid input return Effect.fail(new SchemaIssue.InvalidType(ast)) }, { expected: "ReadonlySet", // Define the encoding side by linking back to an Array schema toCodec: ([item]) => Schema.link>()( Schema.Array(item), SchemaTransformation.transform({ // Decode an array into a ReadonlySet decode: (values): ReadonlySet => new Set(values), // Encode a ReadonlySet back into an array encode: (set) => Array.from(set.values()), }), ), }, ) // Define a schema for a ReadonlySet of numbers const setOfNumbers = MyReadonlySet(Schema.FiniteFromString) const decode = Schema.decodeUnknownSync(setOfNumbers) console.log(decode(new Set(["1", "2", "3"]))) // Set(3) { 1, 2, 3 } // Decode an invalid input decode(null) /* throws SchemaError: Expected ReadonlySet */ // Decode a Set with an invalid element decode(new Set(["1", null, "3"])) /* throws SchemaError: Expected string at [1] */ ``` ### 添加解释器注解 定义一种新的数据类型时,诸如 [Arbitrary](/docs/v4/schema/arbitrary) 或 [Formatter](/docs/v4/schema/formatter) 这样的 schema 解释器可能不知道如何处理这个新类型。 这会导致错误,因为解释器可能缺少生成实例或产出可读输出所需的信息: **示例**(在没有必需注解的情况下尝试生成 Arbitrary 值) ```ts import { Schema } from "effect" // Define a schema for the File type const FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "File", }, ) // Try creating an Arbitrary instance for the schema const arb = Schema.toArbitrary(FileSchema) /* throws: Error: Missing annotation details: Generating an Arbitrary for this schema requires an "arbitrary" annotation schema (Declaration): File */ ``` 在上面的示例中,为 `FileSchema` 生成 arbitrary 值会失败,因为解释器缺少必需的注解。要解决这个问题,请提供用于生成 arbitrary 数据的注解: **示例**(为自定义的 `File` Schema 添加 Arbitrary 注解) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const FileSchema = Schema.declare( (input: unknown): input is File => input instanceof File, { identifier: "File", // Provide a function to generate random File instances toArbitrary: () => (fc) => fc .tuple(fc.string(), fc.string()) .map(([content, path]) => new File([content], path)), }, ) // Create an Arbitrary instance for the schema const arb = Schema.toArbitrary(FileSchema) // Generate sample files using the Arbitrary instance const files = FastCheck.sample(arb, 2) console.log(files) /* Example Output: [ File { size: 5, type: '', name: 'C', lastModified: 1706435571176 }, File { size: 1, type: '', name: '98Ggmc', lastModified: 1706435571176 } ] */ ``` 关于如何为 Arbitrary 解释器添加注解的更多细节,请参阅 [Arbitrary](/docs/v4/schema/arbitrary) 文档。 ## 品牌类型 TypeScript 的类型系统是结构化的,这意味着任何两个在结构上等价的类型都会被视为同一个类型。 当语义上不同的类型被当作同一个类型处理时,这就会带来问题。 **示例**(结构化类型带来的问题) ```ts type UserId = string type Username = string declare const getUser: (id: UserId) => object const myUsername: Username = "gcanti" getUser(myUsername) // This erroneously works ``` 在上面的示例中,`UserId` 和 `Username` 都是同一个类型 `string` 的别名。这意味着 `getUser` 函数会误把一个 `Username` 当作合法的 `UserId` 接受,从而带来 bug 和错误。 为了避免这种情况,Effect 引入了**品牌类型**(branded types)。这类类型会给一个类型附加一个唯一标识(也就是 "brand"),让你能够区分结构相似但语义不同的类型。 **示例**(定义品牌类型) ```ts import { Brand } from "effect" type UserId = string & Brand.Brand<"UserId"> type Username = string declare const getUser: (id: UserId) => object const myUsername: Username = "gcanti" // @errors: 2345 getUser(myUsername) ``` 通过把 `UserId` 定义为品牌类型,`getUser` 函数就只能接受 `UserId` 类型的值,而不能接受普通字符串或其他与字符串兼容的类型。这有助于避免因误把错误类型的值传给函数而引发的 bug。 为品牌类型定义 schema 有两种方式,取决于你是: - 想从零开始定义 schema - 已经通过 [`effect/Brand`](/docs/v4/code-style/branded-types) 定义了品牌类型,想复用它来定义 schema ### 从零定义品牌 schema 要从零为品牌类型定义 schema,请使用 `Schema.brand` 函数。 **示例**(为品牌类型创建 schema) ```ts import { Schema } from "effect" const UserId = Schema.String.pipe(Schema.brand("UserId")) // string & Brand<"UserId"> type UserId = typeof UserId.Type ``` ### 复用已有的品牌构造器 如果你已经使用 [`effect/Brand`](/docs/v4/code-style/branded-types) 模块定义过品牌类型,就可以通过 `Schema.fromBrand` 函数复用它来定义 schema。 **示例**(复用已有的品牌类型) ```ts import { Schema } from "effect" import { Brand } from "effect" // the existing branded type type UserId = string & Brand.Brand<"UserId"> const UserId = Brand.nominal() // Define a schema for the branded type const UserIdSchema = Schema.String.pipe(Schema.fromBrand("UserId", UserId)) ``` ### 使用默认构造器 `Schema.brand` 函数包含一个默认构造器,便于创建品牌类型的值。 ```ts import { Schema } from "effect" const UserId = Schema.String.pipe(Schema.brand("UserId")) const userId = UserId.make("123") // => "123" ``` ## 属性签名 属性签名组合子可以分别在编码侧和解码侧独立控制 Struct 的字段。它们可以让某个键变成可选、允许 `undefined`、提供默认值、附加键级别的注解,或者重命名编码后的键。 ### 基本用法 属性签名可以带注解定义,从而为字段提供额外的上下文。 **示例**(为属性签名添加注解) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.FiniteFromString.pipe( Schema.annotateKey({ title: "Age", // Annotation to label the age field }), ), }) ``` 字段元数据请使用 `Schema.annotateKey`。当外部表示使用不同的键时,请在 Struct 上使用 `Schema.encodeKeys`。 **示例**(从不同的键映射) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.FiniteFromString, // Maps from "AGE" to "age" }).pipe(Schema.encodeKeys({ age: "AGE" })) console.log(Schema.decodeUnknownSync(Person)({ name: "name", AGE: "18" })) // Output: { name: 'name', age: 18 } ``` ### 可选字段 #### 基本的可选属性 `Schema.optional` 让某个键变成可选,并在该键存在时允许 `undefined`。 ##### 解码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `e: E` | transforms to `t: T` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `t: T` | transforms back to `e: E` | **示例**(定义可选数字字段) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.FiniteFromString), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number | undefined; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} console.log(Schema.encodeSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } ``` #### 可空的可选字段 当 `null` 应当被视为缺失值时,可以组合使用 `Schema.optional`、`Schema.NullOr` 以及可空字段变换。 ##### 解码 | Input | Output | | ----------------- | ------------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `null` | transforms to `` | | `e: E` | transforms to `t: T` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | remains `undefined` | | `t: T` | transforms back to `e: E` | **示例**(把 Null 作为缺失值处理) ```ts import { Option, Predicate, Schema, SchemaGetter } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.optional(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => o.pipe(Option.filter(Predicate.isNotNull)), ), encode: SchemaGetter.transformOptional((o) => o), }), ), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number | undefined; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: {} // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} console.log(Schema.encodeSync(Product)({ quantity: undefined })) // Output: { quantity: undefined } ``` #### 精确的可选键 `Schema.optionalKey` 让某个键变成可选,但不会在该键的值类型中加入 `undefined`。如果该键存在,它的值必须能被所包裹的 schema 接受。 ##### 解码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `undefined` | `SchemaError` | | `e: E` | transforms to `t: T` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `t: T` | transforms back to `e: E` | **示例**(对可选字段使用精确性) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.FiniteFromString), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string at ["quantity"] */ // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} ``` #### 带可空性的精确可选键 当 `null` 应当被视为缺失的键、而 `undefined` 仍应被拒绝时,可以组合使用 `Schema.optionalKey`、`Schema.NullOr` 以及可空字段变换。 ##### 解码 | Input | Output | | ----------------- | ------------------------------- | | `` | remains `` | | `null` | transforms to `` | | `undefined` | `SchemaError` | | `e: E` | transforms to `t: T` | ##### 编码 | Input | Output | | ----------------- | ------------------------- | | `` | remains `` | | `t: T` | transforms back to `e: E` | **示例**(对可选字段使用精确性并把 Null 作为缺失值处理) ```ts import { Option, Predicate, Schema, SchemaGetter } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.optionalKey(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => o.pipe(Option.filter(Predicate.isNotNull)), ), encode: SchemaGetter.transformOptional((o) => o), }), ), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: number; } // ▼ type Type = typeof Product.Type // Decoding examples console.log(Schema.decodeUnknownSync(Product)({ quantity: "1" })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({})) // Output: {} console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string | null at ["quantity"] */ console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: {} // Encoding examples console.log(Schema.encodeSync(Product)({ quantity: 1 })) // Output: { quantity: "1" } console.log(Schema.encodeSync(Product)({})) // Output: {} ``` ### 使用 never 类型表示可选字段 当你创建一个 schema 来复刻某个包含 `never` 类型可选字段的 TypeScript 类型时,例如: ```ts type MyType = { readonly quantity?: never } ``` 这些字段的处理方式取决于 `tsconfig.json` 中的 `exactOptionalPropertyTypes` 设置。 该设置会影响 schema 应当把可选的 `never` 类型字段视为单纯不存在,还是允许把 `undefined` 作为它的值。 **示例**(`exactOptionalPropertyTypes: false`) 当该特性关闭时,你可以使用 `Schema.optional` 函数。这种方式允许该字段隐式接受 `undefined` 作为值。 ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.Never), }) // ┌─── { readonly quantity?: undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: undefined; } // ▼ type Type = typeof Product.Type ``` **示例**(`exactOptionalPropertyTypes: true`) 当该特性开启时,请使用 `Schema.optionalKey`,这样该字段就只能缺失。 ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.Never), }) // ┌─── { readonly quantity?: never; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity?: never; } // ▼ type Type = typeof Product.Type ``` ### 默认值 使用 `Schema.withDecodingDefaultType` 或 `Schema.withDecodingDefaultTypeKey` 可以提供解码后的默认值。构造器默认值是独立的,可以用 `Schema.withConstructorDefault` 添加。 #### 基本默认值 这是最简单的用例。如果输入缺失或为 `undefined`,就会应用默认值。 | 操作 | 行为 | | -------- | ------------------------------------------------------ | | **解码** | 如果输入缺失或为 `undefined`,则应用默认值 | | **编码** | 把输入 `t: T` 转换回 `e: E` | **示例**(当字段缺失或为 `undefined` 时应用默认值) ```ts import { Effect, Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.FiniteFromString.pipe( Schema.withDecodingDefaultType(Effect.succeed(1)), // Default value for quantity Schema.withConstructorDefault(Effect.succeed(1)), ), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type // Decoding examples with default applied console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } // Object construction examples with default applied console.log(Product.make({})) // Output: { quantity: 1 } console.log(Product.make({ quantity: 2 })) // Output: { quantity: 2 } ``` #### 键缺失时的默认值 如果默认值只应在键缺失时应用、而不应在键存在但其值为 `undefined` 时应用,请使用 `Schema.withDecodingDefaultTypeKey`。 | 操作 | 行为 | | -------- | -------------------------------- | | **解码** | 仅当输入缺失时应用默认值 | | **编码** | 把输入 `t: T` 转换回 `e: E` | **示例**(仅在字段缺失时应用默认值) ```ts import { Effect, Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.FiniteFromString.pipe( Schema.withDecodingDefaultTypeKey(Effect.succeed(1)), // Default value for quantity, only if quantity is not provided ), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string at ["quantity"] */ ``` #### 带可空性的默认值 当缺失、`undefined` 和 `null` 都应产生默认值时,可以把可选且可空的字段与 `SchemaGetter.transformOptional` 组合起来。 | 操作 | 行为 | | -------- | --------------------------------------------------------- | | **解码** | 如果输入缺失,或为 `undefined` 或 `null`,则应用默认值 | | **编码** | 把输入 `t: T` 转换回 `e: E` | **示例**(当字段缺失,或为 `undefined` 或 `null` 时应用默认值) ```ts import { Option, Predicate, Schema, SchemaGetter } from "effect" const Product = Schema.Struct({ quantity: Schema.optional(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOptional((o) => o.pipe( Option.filter(Predicate.isNotNullish), Option.orElseSome(() => 1), ), ), encode: SchemaGetter.required(), }), ), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } ``` #### 键缺失或为 null 时的默认值 当缺失和 `null` 都应产生默认值、而 `undefined` 应被拒绝时,请使用精确可选(exact optional)的可空字段。 | 操作 | 行为 | | -------- | -------------------------------------- | | **解码** | 如果输入缺失或为 `null`,则应用默认值 | | **编码** | 把输入 `t: T` 转换回 `e: E` | **示例**(仅在字段缺失或为 `null` 时应用默认值) ```ts import { Option, Predicate, Schema, SchemaGetter } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOptional((o) => o.pipe( Option.filter(Predicate.isNotNull), Option.orElseSome(() => 1), ), ), encode: SchemaGetter.required(), }), ), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: number; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: 1 } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: 2 } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string | null at ["quantity"] */ ``` ### 作为 Option 的可选字段 处理可选字段时,你可能希望把它们当作 [Option](/docs/v4/data-types/option) 值来处理。这种方式让你能够显式地管理字段的存在或缺失,而不必依赖 `undefined` 或 `null`。 #### 使用 Option 类型的基本可选字段 `Schema.OptionFromOptional` 会把缺失或为 `undefined` 的字段转换为 `Option.none()`,把已存在的值转换为 `Option.some()`。 ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | transforms to `Option.none()` | | `e: E` | transforms to `Option.some(t: T)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(t: T)` | transforms back to `e: E` | **示例**(把可选字段作为 Option 处理) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.OptionFromOptional(Schema.FiniteFromString), }) // ┌─── { readonly quantity?: string | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } ``` #### 精确可选键作为 Option `Schema.OptionFromOptionalKey` 会把缺失的键转换为 `Option.none()`,同时在键存在时拒绝 `undefined`。 ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | `SchemaError` | | `e: E` | transforms to `Option.some(t: T)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(t: T)` | transforms back to `e: E` | **示例**(在可选字段作为 Option 时使用精确性) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalKey(Schema.FiniteFromString), }) // ┌─── { readonly quantity?: string; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string at ["quantity"] */ ``` #### 可空的可选字段 `Schema.OptionFromOptionalNullOr` 还会把 `null` 视为 `Option.none()`。 ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | transforms to `Option.none()` | | `null` | transforms to `Option.none()` | | `e: E` | transforms to `Option.some(t: T)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(t: T)` | transforms back to `e: E` | **示例**(在可选字段作为 Option 时把 null 视为缺失值) ```ts import { Schema } from "effect" const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalNullOr(Schema.FiniteFromString), }) // ┌─── { readonly quantity?: string | null | undefined; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } ``` #### 可空的精确可选键作为 Option 当缺失和 `null` 都应变成 `Option.none()`、而 `undefined` 应被拒绝时,可以把 `Schema.optionalKey`、`Schema.NullOr` 和 `SchemaGetter.transformOptional` 组合起来。 ##### 解码 | Input | Output | | ----------------- | --------------------------------- | | `` | transforms to `Option.none()` | | `undefined` | `SchemaError` | | `null` | transforms to `Option.none()` | | `e: E` | transforms to `Option.some(t: T)` | ##### 编码 | Input | Output | | ------------------- | ------------------------------- | | `Option.none()` | transforms to `` | | `Option.some(t: T)` | transforms back to `e: E` | **示例**(在可选字段作为 Option 时使用精确性并把 null 视为缺失值) ```ts import { Option, Predicate, Schema, SchemaGetter } from "effect" const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo(Schema.Option(Schema.Finite), { decode: SchemaGetter.transformOptional((o) => Option.some(o.pipe(Option.filter(Predicate.isNotNull))), ), encode: SchemaGetter.transformOptional(Option.flatten), }), ), }) // ┌─── { readonly quantity?: string | null; } // ▼ type Encoded = typeof Product.Encoded // ┌─── { readonly quantity: Option; } // ▼ type Type = typeof Product.Type console.log(Schema.decodeUnknownSync(Product)({})) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: null })) // Output: { quantity: { _id: 'Option', _tag: 'None' } } console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" })) // Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } } console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined })) /* throws: SchemaError: Expected string | null at ["quantity"] */ ``` ## 可选字段的转换 ### 从可选到可选 使用 `Schema.decodeTo` 搭配 `SchemaGetter.transformOptional`,可以把编码侧的可选字段转换为解码侧的可选字段。这样,转换逻辑就能自行决定该键在任意一侧是否存在。 一个常见用例是把某个特定的编码值(例如空字符串)视为解码输出中缺失的字段。 解码和编码 getter 接收的是一个 `Option`:`None` 表示键缺失,返回 `None` 就会把它从输出中省略。 **示例**(从输出中省略空字符串) 考虑一个 `string` 类型的可选字段:输入中的空字符串应当从输出中移除。 ```ts import { Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ nonEmpty: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { // ┌─── Option // ▼ decode: SchemaGetter.transformOptional((maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return Option.none() to omit it in the output. return Option.none() } // Extract the value from the `Some` instance const value = maybeString.value if (value === "") { // Treat empty strings as missing in the output // by returning Option.none(). return Option.none() } // Include non-empty strings in the output. return Option.some(value) }), // In the encoding phase, you can decide to process the field // similarly to the decoding phase or use a different logic. // Here, the logic is left unchanged. // // ┌─── Option // ▼ encode: SchemaGetter.transformOptional((maybeString) => maybeString), }), ), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({})) // Output: {} console.log(decode({ nonEmpty: "" })) // Output: {} console.log(decode({ nonEmpty: "a non-empty string" })) // Output: { nonEmpty: 'a non-empty string' } // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({})) // Output: {} console.log(encode({ nonEmpty: "" })) // Output: { nonEmpty: '' } console.log(encode({ nonEmpty: "a non-empty string" })) // Output: { nonEmpty: 'a non-empty string' } ``` 你可以用 `Option.filter` 简化解码逻辑,它以简洁的方式过滤掉不需要的值。 **示例**(使用 `Option.filter` 进行解码) ```ts import { identity, Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ nonEmpty: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { decode: SchemaGetter.transformOptional(Option.filter((s) => s !== "")), encode: SchemaGetter.transformOptional(identity), }), ), }) ``` ### 从可选到必需 在编码侧使用可选 schema,在解码侧使用必需 schema。当编码侧的键缺失时,`SchemaGetter.transformOptional` 可以提供一个值;在编码期间,它也可以省略选定的值。 **示例**(把 `null` 设为缺失字段的默认值) 这个例子在编码字段缺失时提供一个 `null` 值。在编码期间,解码后的 `null` 值会省略该字段。 ```ts import { Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ nullable: Schema.optionalKey( // Input schema for an optional string Schema.String, ).pipe( Schema.decodeTo( // Output schema allowing null or string Schema.NullOr(Schema.String), { // ┌─── Option // ▼ decode: SchemaGetter.transformOptional((maybeString) => { if (Option.isNone(maybeString)) { // If `maybeString` is `None`, the field is absent in the input. // Return `null` as the default value for the output. return Option.some(null) } // Extract the value from the `Some` instance // and use it as the output. return Option.some(maybeString.value) }), // During encoding, treat `null` as an absent field // // ┌─── string | null // ▼ encode: SchemaGetter.transformOptional((maybeStringOrNull) => Option.flatMap(maybeStringOrNull, (stringOrNull) => stringOrNull === null ? // Omit the field by returning `None` Option.none() : // Include the field by returning `Some` Option.some(stringOrNull), ), ), }, ), ), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({})) // Output: { nullable: null } console.log(decode({ nullable: "a value" })) // Output: { nullable: 'a value' } // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({ nullable: "a value" })) // Output: { nullable: 'a value' } console.log(encode({ nullable: null })) // Output: {} ``` 你可以用 `Option.getOrElse` 和 `Option.liftPredicate` 来精简解码与编码逻辑,写出简洁易读的转换。 **示例**(使用 `Option.getOrElse` 和 `Option.liftPredicate`) ```ts import { Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ nullable: Schema.optionalKey(Schema.String).pipe( Schema.decodeTo(Schema.NullOr(Schema.String), { decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)), encode: SchemaGetter.transformOptional( Option.filter((value) => value !== null), ), }), ), }) ``` ### 从必需到可选 在编码侧使用必需 schema,在解码侧使用可选 schema。这种转换可以省略选定的解码值,并且必须在编码期间恢复出一个必需的值。 **示例**(把空字符串视为缺失值) 在这个例子中,`name` 字段是必需的,但如果它的值是空字符串,就会被当作可选。解码时,`name` 中的空字符串被视为缺失;编码时则保证一定有一个值(如果 `name` 缺失,就用空字符串作为默认值)。 ```ts import { Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ name: Schema.String.pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { // ┌─── Option // ▼ decode: SchemaGetter.transformOptional((maybeString) => Option.flatMap(maybeString, (string) => { // Treat empty string as a missing value if (string === "") { // Omit the field by returning `None` return Option.none() } // Otherwise, return the string as is return Option.some(string) }), ), // ┌─── Option // ▼ encode: SchemaGetter.transformOptional((maybeString) => { // Check if the field is missing if (Option.isNone(maybeString)) { // Provide an empty string as default return Option.some("") } // Otherwise, return the string as is return maybeString }), }), ), }) // Decoding examples const decode = Schema.decodeUnknownSync(schema) console.log(decode({ name: "John" })) // Output: { name: 'John' } console.log(decode({ name: "" })) // Output: {} // Encoding examples const encode = Schema.encodeSync(schema) console.log(encode({ name: "John" })) // Output: { name: 'John' } console.log(encode({})) // Output: { name: '' } ``` 你可以用 `Option.liftPredicate` 和 `Option.getOrElse` 来精简解码与编码逻辑,写出简洁易读的转换。 **示例**(使用 `Option.liftPredicate` 和 `Option.getOrElse`) ```ts import { Option, Schema, SchemaGetter } from "effect" const schema = Schema.Struct({ name: Schema.String.pipe( Schema.decodeTo(Schema.optionalKey(Schema.String), { decode: SchemaGetter.transformOptional((maybeString) => Option.flatMap( maybeString, Option.liftPredicate((s) => s !== ""), ), ), encode: SchemaGetter.transformOptional((maybeString) => Option.some(Option.getOrElse(maybeString, () => "")), ), }), ), }) ``` ## 扩展 schema Struct schema 会暴露自己的 `fields`,你可以把它展开到新的 struct 中,也可以用 `Schema.fieldsAssign` 来扩展。Union 会暴露 `mapMembers`,因此可以把同一个字段操作应用到每个 struct 成员上。 ### 展开 Struct 的字段 Struct 通过 `fields` 属性提供对其字段的访问,这让你可以扩展已有的 struct:既能添加额外的字段,也能把多个 struct 的字段合并起来。 **示例**(添加新字段) ```ts import { Schema } from "effect" const Original = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Extended = Schema.Struct({ ...Original.fields, // Adding new fields c: Schema.String, d: Schema.String, }) // ┌─── { // | readonly a: string; // | readonly b: string; // | readonly c: string; // | readonly d: string; // | } // ▼ type Type = typeof Extended.Type ``` **示例**(添加额外的索引签名) ```ts import { Schema } from "effect" const Original = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Extended = Schema.StructWithRest( Schema.Struct(Original.fields), // Adding an index signature [Schema.Record(Schema.String, Schema.String)], ) // ┌─── { // │ readonly [x: string]: string; // | readonly a: string; // | readonly b: string; // | } // ▼ type Type = typeof Extended.Type ``` **示例**(合并多个 struct 的字段) ```ts import { Schema } from "effect" const Struct1 = Schema.Struct({ a: Schema.String, b: Schema.String, }) const Struct2 = Schema.Struct({ c: Schema.String, d: Schema.String, }) const Extended = Schema.Struct({ ...Struct1.fields, ...Struct2.fields, }) // ┌─── { // | readonly a: string; // | readonly b: string; // | readonly c: string; // | readonly d: string; // | } // ▼ type Type = typeof Extended.Type ``` ### fieldsAssign 函数 `Schema.fieldsAssign(fields)` 是 `struct.mapFields(Struct.assign(fields))` 的简洁写法。你可以直接在 struct 上使用它,也可以把它映射到 union 的每个成员上。 **示例**(为每个 union 成员添加字段) ```ts import { Schema, Tuple } from "effect" const Struct = Schema.Struct({ a: Schema.String, }) const UnionOfStructs = Schema.Union([ Schema.Struct({ b: Schema.String }), Schema.Struct({ c: Schema.String }), ]) const Extended = UnionOfStructs.mapMembers( Tuple.map(Schema.fieldsAssign(Struct.fields)), ) // ┌─── { // | readonly a: string; // | } & ({ // | readonly b: string; // | } | { // | readonly c: string; // | }) // ▼ type Type = typeof Extended.Type ``` ## 重命名属性 ### 在定义时重命名属性 如果希望在编码表示中使用不同的键,请在定义 struct 之后应用 `Schema.encodeKeys`。 **示例**(重命名必需属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.String, b: Schema.Finite, }).pipe(Schema.encodeKeys({ a: "c" })) // ┌─── { readonly c: string; readonly b: number; } // ▼ type Encoded = typeof schema.Encoded // ┌─── { readonly a: string; readonly b: number; } // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } ``` **示例**(重命名可选属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.optional(Schema.String), b: Schema.Finite, }).pipe(Schema.encodeKeys({ a: "c" })) // ┌─── { readonly b: number; readonly c?: string | undefined; } // ▼ type Encoded = typeof schema.Encoded // ┌─── { readonly a?: string | undefined; readonly b: number; } // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)({ c: "c", b: 1 })) // Output: { a: 'c', b: 1 } console.log(Schema.decodeUnknownSync(schema)({ b: 1 })) // Output: { b: 1 } ``` ### 重命名已有 schema 的属性 对于已有的 struct,用 `mapFields` 重命名其解码后的字段,然后用 `Schema.encodeKeys` 在编码表示中保留原来的名字。对于 union,把同样的操作应用到每个成员上。 **示例**(重命名 struct schema 中的属性) ```ts import { Schema, Struct } from "effect" const Original = Schema.Struct({ c: Schema.String, b: Schema.Finite, }) // Renaming the "c" property to "a" // // // ┌─── Struct<{ // | readonly a: string; // | readonly b: number; // | }> // ▼ const Renamed = Original.mapFields((fields) => ({ a: fields.c, ...Struct.omit(fields, ["c"]), })).pipe(Schema.encodeKeys({ a: "c" })) console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } ``` **示例**(重命名 union schema 中的属性) ```ts import { Schema } from "effect" const Original = Schema.Union([ Schema.Struct({ a: Schema.String, b: Schema.Finite, }), Schema.Struct({ a: Schema.String, d: Schema.Boolean, }), ]) // Use "c" for "a" in the encoded representation of every member const Renamed = Original.mapMembers( ([first, second]) => [ first.pipe(Schema.encodeKeys({ a: "c" })), second.pipe(Schema.encodeKeys({ a: "c" })), ] as const, ) console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", b: 1 })) // Output: { a: "c", b: 1 } console.log(Schema.decodeUnknownSync(Renamed)({ c: "c", d: false })) // Output: { a: "c", d: false } ``` ## 递归 schema `Schema.suspend` 函数用于定义引用自身的 schema,例如递归数据结构中的 schema。 **示例**(自引用 schema) 在这个例子中,`Category` schema 通过 `subcategories` 字段引用自身,该字段是一个由 `Category` 对象组成的数组。 ```ts import { Schema } from "effect" interface Category { readonly name: string readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Codec => Category), ), }) ``` **示例**(类型推断错误) ```ts import { Schema } from "effect" // @errors: 7022 const Category = Schema.Struct({ name: Schema.String, // @errors: 7022 7024 subcategories: Schema.Array(Schema.suspend(() => Category)), }) ``` ### 简化 schema 定义的实用模式 正如我们所见,为了能够定义递归 schema,必须为 schema 的 `Type` 定义一个 interface, 这会让事情变得复杂,而且相当繁琐。 缓解这一问题的一种模式是,把**负责递归的字段**与所有其他字段分离开来。 **示例**(分离递归字段) ```ts import { Schema } from "effect" const fields = { name: Schema.String, // ...other fields as needed } // Define an interface for the Category schema, // extending the Type of the defined fields interface Category extends Schema.Struct.Type { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, // Spread in the base fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Codec => Category), ), }) ``` ### 相互递归的 schema 你也可以使用 `Schema.suspend` 创建相互递归的 schema,即两个 schema 互相引用。在下面的例子中,`Expression` 和 `Operation` 通过相互引用构成一棵简单的算术表达式树。 **示例**(定义相互递归的 schema) ```ts import { Schema } from "effect" interface Expression { readonly type: "expression" readonly value: number | Operation } interface Operation { readonly type: "operation" readonly operator: "+" | "-" readonly left: Expression readonly right: Expression } const Expression = Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union([ Schema.Finite, Schema.suspend((): Schema.Codec => Operation), ]), }) const Operation = Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literals(["+", "-"]), left: Expression, right: Expression, }) ``` ### Encoded 与 Type 不同的递归类型 定义 `Encoded` 类型与 `Type` 类型不同的递归 schema 会再增加一层复杂度。在这种情况下,我们需要定义两个 interface:一个用于 `Type` 类型(如前所见),另一个用于 `Encoded` 类型。 **示例**(Encoded 与 Type 定义不同的递归 schema) 让我们看一个 `id` 字段由 `Schema.FiniteFromString` 定义的例子。 它的 `Type` 是 `number`,而它的 `Encoded` 类型是 `string`。 当我们把这个字段添加到 `Category` schema 时,TypeScript 会报错: ```ts import { Schema } from "effect" const fields = { id: Schema.FiniteFromString, name: Schema.String, } interface Category extends Schema.Struct.Type { readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Codec => Category), ), }) ``` 这样写会失败,因为 `Schema.Codec` 会把编码类型默认为 `Category`。递归的边还必须指定 `CategoryEncoded`: ```ts import { Schema } from "effect" const fields = { id: Schema.FiniteFromString, name: Schema.String, } interface Category extends Schema.Struct.Type { readonly subcategories: ReadonlyArray } interface CategoryEncoded extends Schema.Struct.Encoded { readonly subcategories: ReadonlyArray } const Category = Schema.Struct({ ...fields, subcategories: Schema.Array( Schema.suspend((): Schema.Codec => Category), ), }) ``` --- # Schema Annotation > 了解如何用 Annotation 增强 schema,以便在基于 Effect 的应用中更好地自定义、处理错误、编写文档并控制并发。 Schema AST 节点可以携带可选的元数据,称为 Annotation。解码侧使用 `.annotate(...)` 方法或 `Schema.annotate(...)`,编码侧使用 `Schema.annotateEncoded(...)`,而 struct 字段或 tuple 元素则使用 `Schema.annotateKey(...)`。 **示例**(用 Annotation 自定义 Schema) ```ts import { Schema } from "effect" // Define a Password schema, starting with a string type const Password = Schema.String // Add a custom error message for non-string values .annotate({ message: "not a string" }) .pipe( // Enforce non-empty strings and provide a custom error message Schema.check( Schema.isNonEmpty({ message: "required" }), // Restrict the string length to 10 characters or fewer // with a custom error message for exceeding length Schema.makeFilter((s) => s.length <= 10 ? undefined : "must be at most 10 characters long", ), ), ) .annotate({ // Add a unique identifier for the schema identifier: "Password", // Provide a title for the schema title: "password", // Include a description explaining what this schema represents description: "A password is a secret string used to authenticate a user", // Add examples for better clarity examples: ["1Ki77y", "jelly22fi$h"], // Include any additional documentation documentation: `...technical information on Password schema...`, }) ``` ## 内置 Annotation 可用的 Annotation 取决于 schema 节点的种类。下面是最常见的一些: | Annotation | 作用范围 | 说明 | | ---------------------- | --------------------- | ------------------------------------------------------------------------------------- | | `identifier` | schema | schema 解释器使用的稳定名称,包括 JSON Schema 引用与期望值消息。 | | `expected` | schema 或 check | 默认错误格式化器使用的人类可读描述。 | | `title` | schema 或 key | 简短的显示标题,JSON Schema 工具也能识别。 | | `description` | schema 或 key | 针对所表示值的更详细文档。 | | `documentation` | schema 或 key | 面向开发者的附加文档。 | | `examples` | schema 或 key | 示例解码值;它们只是元数据,不会被校验。 | | `default` | schema 或 key | 一个有文档记录的默认值;它不会改变解码或构造行为。 | | `message` | schema 或 check | 替换匹配失败时的默认消息。 | | `messageMissingKey` | key | 当必需 key 缺失时替换错误消息。 | | `messageUnexpectedKey` | schema | 当 `onExcessProperty` 为 `"error"` 时,替换多余 key 的消息。 | | `parseOptions` | schema | 覆盖该 schema 节点的 [parse options](/docs/v4/schema/getting-started#parse-options)。 | | `toJsonSchema` | check | 向 [JSON Schema](/docs/v4/schema/json-schema) 解释器描述一个自定义 check。 | | `toArbitrary` | schema 或 declaration | 定制 [Arbitrary](/docs/v4/schema/arbitrary) 的生成。 | | `toFormatter` | declaration | 定义自定义 declaration 的 [Formatter](/docs/v4/schema/formatter) 行为。 | | `toEquivalence` | declaration | 定义自定义 declaration 的 [Equivalence](/docs/v4/schema/equivalence) 行为。 | | `toCodecJson` | declaration | 定义 JSON codec 解释器如何表示自定义 declaration。 | ## 并发 parse option 对于 `Struct`、`Array` 或 `Union` 这类包含多个带 effect 的 schema,`concurrency` parse option 控制可以并发运行多少个解析 effect。 ```ts type Concurrency = number | "unbounded" | undefined ``` 下面用表格给出更简洁的版本: | 值 | 说明 | | ------------- | ------------------------------------ | | `number` | 限制并发任务的最大数量。 | | `"unbounded"` | 所有任务并发运行,没有数量限制。 | | `undefined` | 同一时刻最多运行一个任务(默认值)。 | **示例**(顺序执行) 在这个示例中,我们定义了三个任务,模拟耗时不同的异步操作。由于没有指定 concurrency,这些任务会一个接一个地顺序执行。 ```ts import { Schema, SchemaGetter } from "effect" import type { Duration } from "effect" import { Effect } from "effect" // Simulates an async task const item = (id: number, duration: Duration.Input) => Schema.String.pipe( Schema.decode({ decode: SchemaGetter.checkEffect(() => Effect.gen(function* () { yield* Effect.sleep(duration) console.log(`Task ${id} done`) return true }), ), encode: SchemaGetter.passthrough(), }), ) const Sequential = Schema.Tuple([ item(1, "30 millis"), item(2, "10 millis"), item(3, "20 millis"), ]) Effect.runPromise(Schema.decodeEffect(Sequential)(["a", "b", "c"])) /* Output: Task 1 done Task 2 done Task 3 done */ ``` **示例**(并发执行) 通过向解释器传入 `{ concurrency: "unbounded" }`,这些任务就可以并发运行,而不必互相等待。 ```ts import { Schema, SchemaGetter } from "effect" import type { Duration } from "effect" import { Effect } from "effect" // Simulates an async task const item = (id: number, duration: Duration.Input) => Schema.String.pipe( Schema.decode({ decode: SchemaGetter.checkEffect(() => Effect.gen(function* () { yield* Effect.sleep(duration) console.log(`Task ${id} done`) return true }), ), encode: SchemaGetter.passthrough(), }), ) const Concurrent = Schema.Tuple([ item(1, "30 millis"), item(2, "10 millis"), item(3, "20 millis"), ]) Effect.runPromise( Schema.decodeEffect(Concurrent, { concurrency: "unbounded" })([ "a", "b", "c", ]), ) /* Output: Task 2 done Task 3 done Task 1 done */ ``` ## 用 fallback 处理解码错误 `Schema.catchDecoding` 让你可以用 fallback 逻辑从解码问题中恢复。 ```ts type DecodingFallback = ( issue: SchemaIssue.Issue, ) => Effect.Effect, SchemaIssue.Issue> ``` 这个 Annotation 让你能够在解码失败时指定 fallback 行为,从而优雅地从错误中恢复。 **示例**(基本 fallback) 在这个基本示例中,当解码失败时(例如输入为 `null`),会返回 fallback 值而不是报错。 ```ts import { Schema } from "effect" import { Effect } from "effect" // Schema with a fallback value const schema = Schema.String.pipe( Schema.catchDecoding(() => Effect.succeedSome("")), ) console.log(Schema.decodeUnknownSync(schema)("valid input")) // Output: valid input console.log(Schema.decodeUnknownSync(schema)(null)) // Output: ``` **示例**(带日志的进阶 fallback) 在这个进阶示例中,当发生解码错误时,schema 会记录该 issue,然后返回一个 fallback 值。这展示了如何在错误处理过程中加入日志和其他副作用。 ```ts import { Schema } from "effect" import { Effect } from "effect" // Schema with logging and fallback const schemaWithLog = Schema.String.pipe( Schema.catchDecoding((issue) => Effect.gen(function* () { // Log the error issue yield* Effect.log(issue._tag) // Simulate a delay yield* Effect.sleep(10) // Return a fallback value return yield* Effect.succeedSome("") }), ), ) // Run the effectful fallback logic Effect.runPromise(Schema.decodeUnknownEffect(schemaWithLog)(null)).then( console.log, ) /* Output: timestamp=... level=INFO fiber=#0 message=InvalidType */ ``` ## 自定义 Annotation 除了内置 Annotation 之外,你还可以定义自定义 Annotation 来满足特定需求。例如,下面演示如何创建一个 `deprecated` Annotation: **示例**(定义一个自定义 Annotation) ```ts import { Schema } from "effect" // Define a unique identifier for your custom annotation const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) // Apply the custom annotation to the schema const MyString = Schema.String.annotate({ [DeprecatedId]: true }) ``` 为了让新的自定义 Annotation 具备类型安全,你可以使用 module augmentation。在下一个示例中,我们希望自定义 Annotation 是一个 boolean。 **示例**(为自定义 Annotation 添加类型安全) ```ts import { Schema } from "effect" const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) // Module augmentation declare module "effect/Schema" { namespace Annotations { interface Annotations { [DeprecatedId]?: boolean } } } const MyString = Schema.String.annotate({ // @errors: 2418 [DeprecatedId]: "bad value", }) ``` 你可以使用 `Schema.resolveAnnotations` 辅助函数读取自定义 Annotation。 **示例**(读取一个自定义 Annotation) ```ts import { Schema } from "effect" const DeprecatedId = Symbol.for( "some/unique/identifier/for/your/custom/annotation", ) declare module "effect/Schema" { namespace Annotations { interface Annotations { [DeprecatedId]?: boolean } } } const MyString = Schema.String.annotate({ [DeprecatedId]: true }) // Helper function to check if a schema is marked as deprecated const isDeprecated = (schema: Schema.Top): boolean => Schema.resolveAnnotations(schema)?.[DeprecatedId] ?? false console.log(isDeprecated(Schema.String)) // Output: false console.log(isDeprecated(MyString)) // Output: true ``` --- # 从 Schema 到 Arbitrary > 从 Schema 派生 fast-check 的 Arbitrary,并用 filter、candidate 和注解自定义生成过程。 `Schema.toArbitrary` 会派生出一个 [fast-check](https://fast-check.dev/) `Arbitrary`,用于生成某个 schema 的 `Type` 的值。 **示例**(根据 Schema 生成值) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Int.check(Schema.isBetween({ minimum: 18, maximum: 80 })), }) const arbitrary = Schema.toArbitrary(Person) const samples = FastCheck.sample(arbitrary, 10) samples.every(({ name, age }) => name.length > 0 && age >= 18 && age <= 80) // => true ``` 当需要由调用方提供 fast-check 模块时,请使用 `Schema.toArbitraryLazy`。 **示例**(延迟创建 Arbitrary) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const makeArbitrary = Schema.toArbitraryLazy(Schema.String) const arbitrary = makeArbitrary(FastCheck) FastCheck.sample(arbitrary, 1).every((value) => typeof value === "string") // => true ``` `Schema.Never`,以及没有 `toArbitrary` 注解的 declaration schema,都无法自动派生。不可能满足的 constraint,以及没有有限终止路径的递归 schema,也会立即失败。 ## Filter 生成的值在返回之前,总会先经过 schema 类型侧(type-side)filter 的检查。内置 filter 还会提供元数据,让派生过程能够选择高效的生成器,而不必只依赖 fast-check 的丢弃(discard)机制。 **示例**(使用内置 constraint) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const Username = Schema.String.check( Schema.isMinLength(3), Schema.isMaxLength(20), Schema.isPattern(/^[a-z0-9_]+$/), ) const samples = FastCheck.sample(Schema.toArbitrary(Username), 20) samples.every( (value) => value.length >= 3 && value.length <= 20 && /^[a-z0-9_]+$/.test(value), ) // => true ``` 长度、范围、整数、pattern、唯一性等内置 constraint,在可能的情况下都会被转换为 fast-check 中对应的 constraint。 ### 不透明 filter 与 report 没有 arbitrary 元数据的自定义 filter 仍然是正确的,因为每个生成的值都会被检查。当有效值很稀少时,它可能效率不高。 传入 `{ report: true }` 可以找出那些无法指导生成的 filter。`OpaqueFilter` 警告意味着谓词会被强制执行,但它无助于构造基础的 arbitrary。 **示例**(检查派生过程中的警告) ```ts import { Schema } from "effect" const isPalindrome = (value: string) => value === Array.from(value).reverse().join("") const Palindrome = Schema.String.check( Schema.makeFilter(isPalindrome, { expected: "a palindrome" }), ) const result = Schema.toArbitrary(Palindrome, { report: true }) result.report.warnings[0]?._tag // => "OpaqueFilter" ``` report 只包含警告。不受支持的 schema、不可能满足的 constraint、无效的 candidate 以及无效的递归,仍然会在派生过程中抛出错误。 ### 带 constraint 的自定义 filter 如果自定义 filter 能用普通的生成 constraint 部分描述其有效值,可以附上 `arbitrary.constraint` 注解。谓词始终拥有最终决定权。 **示例**(引导质数生成器) ```ts import { Order, Schema } from "effect" const isPrime = (value: number) => { if (!Number.isInteger(value) || value < 2) return false for (let divisor = 2; divisor * divisor <= value; divisor++) { if (value % divisor === 0) return false } return true } const prime = Schema.makeFilter(isPrime, { expected: "a prime number", arbitrary: { constraint: { integer: true, ordered: { order: Order.Number, minimum: 2, }, }, }, }) const Prime = Schema.Finite.check(prime) ``` 该 constraint 会避开非整数以及小于 `2` 的数;filter 仍然会检查是否为质数。 ### 带 candidate 的自定义 filter 当 filter 无法用 constraint 的词汇表来表达时,可以使用 candidate。candidate 是相对于基础生成器的带权重的备选方案,其值仍然会被每一个 filter 检查。 **示例**(提供回文 candidate) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const reverse = (value: string) => Array.from(value).reverse().join("") const isPalindrome = (value: string) => value === reverse(value) const palindrome = Schema.makeFilter(isPalindrome, { expected: "a palindrome", arbitrary: { candidate: { weight: 5, make: (fc) => fc.string().map((half) => `${half}${reverse(half)}`), }, }, }) const Palindrome = Schema.String.check(palindrome) const samples = FastCheck.sample(Schema.toArbitrary(Palindrome), 20) samples.every(isPalindrome) // => true ``` 基础生成器的权重为 `1`;candidate 默认也是 `1`,除非你提供另一个正整数。 ## 变换 `Schema.toArbitrary` 生成的是 schema 的 `Type`,而不是它的 `Encoded`。因此对于 codec 而言,派生会沿着类型侧的 schema 及其 constraint 进行。 **示例**(生成 codec 的 Type 侧) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const schema = Schema.FiniteFromString const samples = FastCheck.sample(Schema.toArbitrary(schema), 20) samples.every((value) => typeof value === "number" && Number.isFinite(value)) // => true ``` 如果你需要编码后的值,请改为生成 `Schema.toEncoded(schema)`。 ## Schema 级覆盖 使用 `toArbitrary` 注解可以替换某个 schema 节点的生成器。尽可能把覆盖放在基础 schema 上、并在添加 filter 之前进行,这样 filter 仍然是独立的最终检查。 **示例**(提供自定义生成器) ```ts import { Schema } from "effect" import { FastCheck } from "effect/testing" const Name = Schema.String.annotate({ toArbitrary: () => (fc) => fc.constantFrom("Alice", "Dante", "Marta"), }).check(Schema.isNonEmpty()) const Person = Schema.Struct({ name: Name, age: Schema.Int.check(Schema.isBetween({ minimum: 18, maximum: 80 })), }) const samples = FastCheck.sample(Schema.toArbitrary(Person), 20) samples.every(({ name }) => ["Alice", "Dante", "Marta"].includes(name)) // => true ``` 除非覆盖是有意处理这些 filter 的,否则请避免把它放在 filter 之后。例如,一个总是产生 `""` 的覆盖无法满足前置的 `Schema.isNonEmpty()` 检查,并且会耗尽 fast-check 的丢弃预算。 --- # 基本用法 > 学习定义和使用基础 schema,包括原始类型、字面量、联合和 Struct,以实现有效的数据校验与转换。 ## 原始类型 Schema 模块为常见的原始类型提供了内置 schema。 | Schema | 等价的 TypeScript 类型 | | ---------------------- | -------------------------- | | `Schema.String` | `string` | | `Schema.Finite` | `number` | | `Schema.Boolean` | `boolean` | | `Schema.BigInt` | `bigint` | | `Schema.Symbol` | `symbol` | | `Schema.ObjectKeyword` | `object` | | `Schema.Undefined` | `undefined` | | `Schema.Void` | `void` | | `Schema.Any` | `any` | | `Schema.Unknown` | `unknown` | | `Schema.Never` | `never` | **示例**(使用原始类型 schema) ```ts import { Schema } from "effect" const schema = Schema.String // Infers the type as string // // ┌─── string // ▼ type Type = typeof schema.Type // Attempt to decode a null value, which will throw a SchemaError Schema.decodeUnknownSync(schema)(null) /* throws: SchemaError: Expected string */ ``` ## revealCodec 为了更方便地使用 schema,内置 schema 在可能的情况下会以更简短的不透明类型暴露。 `Schema.revealCodec` 函数会返回同一个 schema 值,但将其拓宽为完整的 `Codec` 视图,促使 TypeScript 推断出全部四个参数,且没有任何运行时开销。 **示例**(展开完整的 Codec 视图) 例如,`Schema.String` 的具体类型是 `typeof Schema.String`。把它传给 `Schema.revealCodec` 会暴露出它的完整视图 `Codec`。 ```ts import { Schema } from "effect" // ┌─── typeof Schema.String // ▼ const schema = Schema.String // ┌─── Codec // ▼ const codec = Schema.revealCodec(schema) ``` ## 唯一 Symbol 你可以使用 `Schema.UniqueSymbol` 为唯一 symbol 创建 schema。 **示例**(为唯一 symbol 创建 schema) ```ts import { Schema } from "effect" const mySymbol = Symbol.for("mySymbol") const schema = Schema.UniqueSymbol(mySymbol) // ┌─── typeof mySymbol // ▼ type Type = typeof schema.Type Schema.decodeUnknownSync(schema)(null) /* throws: SchemaError: Expected Symbol(mySymbol) */ ``` ## 字面量 字面量 schema 表示一种[字面量类型](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types)。你可以用它们来指定某个类型必须具有的确切值。 字面量可以是以下类型: - `string` - `number` - `boolean` - `null` - `bigint` **示例**(定义字面量 schema) ```ts import { Schema } from "effect" // Define various literal schemas Schema.Null // Same as Schema.Literal(null) Schema.Literal("a") // string literal Schema.Literal(1) // number literal Schema.Literal(true) // boolean literal Schema.Literal(2n) // BigInt literal ``` **示例**(为 `"a"` 定义字面量 schema) ```ts import { Schema } from "effect" // ┌─── Literal<"a"> // ▼ const schema = Schema.Literal("a") // ┌─── "a" // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)("a")) // Output: "a" console.log(Schema.decodeUnknownSync(schema)("b")) /* throws: SchemaError: Expected "a" */ ``` ### 字面量联合 你可以把多个字面量作为参数传给 `Schema.Literals` 构造器,从而创建它们的联合: **示例**(定义字面量联合) ```ts import { Schema } from "effect" // ┌─── Literals<["a", "b", "c"]> // ▼ const schema = Schema.Literals(["a", "b", "c"]) // ┌─── "a" | "b" | "c" // ▼ type Type = typeof schema.Type Schema.decodeUnknownSync(schema)(null) /* throws: SchemaError: Expected "a" | "b" | "c" */ ``` 你可以为整个联合添加注解,以替换其默认错误信息(见[自定义错误信息](/docs/v4/schema/error-messages#custom-error-messages))。 **示例**(为字面量联合添加自定义信息) ```ts import { Schema } from "effect" // Schema with individual messages for each literal const individualMessages = Schema.Literals(["a", "b", "c"]) console.log(Schema.decodeUnknownSync(individualMessages)(null)) /* throws: SchemaError: Expected "a" | "b" | "c" */ // Schema with a unified custom message for all literals const unifiedMessage = Schema.Literals(["a", "b", "c"]).annotate({ message: "Not a valid code", }) console.log(Schema.decodeUnknownSync(unifiedMessage)(null)) /* throws: SchemaError: Not a valid code */ ``` ### 暴露的值 你可以通过 `literals` 属性访问字面量 schema 中定义的字面量: ```ts import { Schema } from "effect" const schema = Schema.Literals(["a", "b", "c"]) // ┌─── readonly ["a", "b", "c"] // ▼ const literals = schema.literals // => ["a", "b", "c"] ``` ### 挑选字面量 你可以使用 `Schema.Literals` 值的 `.pick` 方法缩小其可能的取值范围。 **示例**(挑选字面量的子集) ```ts import { Schema } from "effect" // Create a schema for a subset of literals ("a" and "b") from a larger set // // ┌─── Literals<["a", "b"]> // ▼ const schema = Schema.Literals(["a", "b", "c"]).pick(["a", "b"]) ``` 有时,你可能需要在代码的其他部分复用一个字面量 schema。下面是一个演示如何做到这一点的示例: **示例**(从字面量 schema 创建子类型) ```ts import { Schema } from "effect" // Define the base set of fruit categories const FruitCategory = Schema.Literals(["sweet", "citrus", "tropical"]) // Define a general Fruit schema with the base category set const Fruit = Schema.Struct({ id: Schema.Finite, category: FruitCategory, }) // Define a specific Fruit schema for only "sweet" and "citrus" categories const SweetAndCitrusFruit = Schema.Struct({ id: Schema.Finite, category: FruitCategory.pick(["sweet", "citrus"]), }) ``` 在这个示例中,`FruitCategory` 是各种水果类别的唯一事实来源。我们复用它创建了 `Fruit` 的一个子类型 `SweetAndCitrusFruit`,确保只允许指定的类别(`"sweet"` 和 `"citrus"`)。这种做法有助于在整个代码中保持一致,并在类别定义发生变化时提供类型安全。 ## 模板字面量 在 TypeScript 中,[模板字面量类型](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)允许你在字符串字面量中嵌入表达式。`Schema.TemplateLiteral` 构造器让你可以为这些模板字面量类型创建 schema。 **示例**(定义模板字面量) ```ts import { Schema } from "effect" // This creates a schema for: `a${string}` // // ┌─── TemplateLiteral // ▼ const schema1 = Schema.TemplateLiteral(["a", Schema.String]) // This creates a schema for: // `https://${string}.com` | `https://${string}.net` const schema2 = Schema.TemplateLiteral([ "https://", Schema.String, ".", Schema.Literals(["com", "net"]), ]) ``` **示例**(来自[模板字面量类型](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)文档) 来看一个更复杂的例子。假设你有两套用于邮件和页脚的 locale ID。你可以使用 `Schema.TemplateLiteral` 构造器创建一个组合这些 ID 的 schema: ```ts import { Schema } from "effect" const EmailLocaleIDs = Schema.Literals(["welcome_email", "email_heading"]) const FooterLocaleIDs = Schema.Literals(["footer_title", "footer_sendoff"]) // This creates a schema for: // "welcome_email_id" | "email_heading_id" | // "footer_title_id" | "footer_sendoff_id" const schema = Schema.TemplateLiteral([ Schema.Union([EmailLocaleIDs, FooterLocaleIDs]), "_id", ]) ``` ### 支持的片段类型 `Schema.TemplateLiteral` 构造器支持以下类型的片段(span): - `Schema.String` - `Schema.Finite` - 字面量:`string | number | boolean | null | bigint`。它们既可以用 `Schema.Literal` 包装,也可以直接使用 - 上述类型的联合 - 上述类型的品牌类型(Brand) **示例**(在模板字面量中使用品牌类型字符串) ```ts import { Schema } from "effect" // Create a branded string schema for an authorization token const AuthorizationToken = Schema.String.pipe( Schema.brand("AuthorizationToken"), ) // This creates a schema for: // `Bearer ${string & Brand<"AuthorizationToken">}` const schema = Schema.TemplateLiteral(["Bearer ", AuthorizationToken]) ``` ### TemplateLiteralParser `Schema.TemplateLiteral` 构造器作为简单的校验器很有用,但它只会把模板字面量定义转换成正则表达式,以此验证输入是否符合某个特定的字符串模式。类似地,[`Schema.isPattern`](/docs/v4/schema/filters#string-filters) 也直接使用正则表达式来达到同样的目的。校验之后,这两种方式都需要额外的手动解析,才能把校验过的字符串转换成可用的数据格式。 为了解决这些局限并免去校验后的手动解析,我们开发了 `Schema.TemplateLiteralParser` API。它不仅校验输入格式,还会自动把它解析为结构更清晰、类型更安全的输出,具体来说是一个**元组**(tuple)格式。 `Schema.TemplateLiteralParser` 构造器支持与 `Schema.TemplateLiteral` 相同类型的[片段](#supported-span-types)。 **示例**(使用 TemplateLiteralParser 进行解析与编码) ```ts import { Schema } from "effect" const schema = Schema.TemplateLiteralParser([ Schema.FiniteFromString, "a", Schema.NonEmptyString, ]) console.log(Schema.decodeSync(schema)("100afoo")) // Output: [ 100, 'a', 'foo' ] console.log(Schema.encodeSync(schema)([100, "a", "foo"])) // Output: '100afoo' ``` ## 原生枚举 Schema 模块支持 TypeScript 的原生枚举。你可以使用 `Schema.Enum` 为枚举定义 schema,从而校验属于该枚举的值。 **示例**(为枚举定义 schema) ```ts import { Schema } from "effect" enum Fruits { Apple, Banana, } // ┌─── Enum // ▼ const schema = Schema.Enum(Fruits) // // ┌─── Fruits // ▼ type Type = typeof schema.Type ``` ### 暴露的值 枚举可以通过 schema 的 `enums` 属性访问。你可以用这个属性获取单个成员或整个枚举值集合。 ```ts import { Schema } from "effect" enum Fruits { Apple, Banana, } const schema = Schema.Enum(Fruits) schema.enums // Returns all enum members schema.enums.Apple // Access the Apple member schema.enums.Banana // Access the Banana member ``` ## 联合类型 Schema 模块内置了 `Schema.Union` 构造器,用于创建「OR」类型,让你可以定义能表示多种类型的 schema。 **示例**(定义联合 schema) ```ts import { Schema } from "effect" // ┌─── Union<[typeof Schema.String, typeof Schema.Finite]> // ▼ const schema = Schema.Union([Schema.String, Schema.Finite]) // ┌─── string | number // ▼ type Type = typeof schema.Type ``` ### 联合成员的求值顺序 解码时,联合成员会按照定义顺序依次求值。如果某个值与第一个成员匹配,就会使用该 schema 对它解码;如果不匹配,解码过程会继续尝试下一个成员。 如果多个 schema 都能解码同一个值,顺序就很关键。把更通用的 schema 放在更具体的 schema 之前,可能会导致属性丢失,因为会使用第一个匹配的 schema。 **示例**(处理联合中相互重叠的 schema) ```ts import { Schema } from "effect" // Define two overlapping schemas const Member1 = Schema.Struct({ a: Schema.String, }) const Member2 = Schema.Struct({ a: Schema.String, b: Schema.Finite, }) // ❌ Define a union where Member1 appears first const Bad = Schema.Union([Member1, Member2]) console.log(Schema.decodeUnknownSync(Bad)({ a: "a", b: 12 })) // Output: { a: 'a' } (Member1 matched first, so `b` was ignored) // ✅ Define a union where Member2 appears first const Good = Schema.Union([Member2, Member1]) console.log(Schema.decodeUnknownSync(Good)({ a: "a", b: 12 })) // Output: { a: 'a', b: 12 } (Member2 matched first, so `b` was included) ``` ### 字面量联合 你固然可以通过组合各个字面量 schema 来创建字面量联合: **示例**(使用各个字面量 schema) ```ts import { Schema } from "effect" // ┌─── Union<[Schema.Literal<"a">, Schema.Literal<"b">, Schema.Literal<"c">]> // ▼ const schema = Schema.Union([ Schema.Literal("a"), Schema.Literal("b"), Schema.Literal("c"), ]) ``` 你可以把多个字面量直接传给 `Schema.Literals` 构造器,从而简化这一过程: **示例**(定义字面量联合) ```ts import { Schema } from "effect" // ┌─── Literals<["a", "b", "c"]> // ▼ const schema = Schema.Literals(["a", "b", "c"]) // ┌─── "a" | "b" | "c" // ▼ type Type = typeof schema.Type ``` 你可以为整个联合添加注解,以替换其默认错误信息(见[自定义错误信息](/docs/v4/schema/error-messages#custom-error-messages))。 **示例**(为字面量联合添加自定义信息) ```ts import { Schema } from "effect" // Schema with individual messages for each literal const individualMessages = Schema.Literals(["a", "b", "c"]) console.log(Schema.decodeUnknownSync(individualMessages)(null)) /* throws: SchemaError: Expected "a" | "b" | "c" */ // Schema with a unified custom message for all literals const unifiedMessage = Schema.Literals(["a", "b", "c"]).annotate({ message: "Not a valid code", }) console.log(Schema.decodeUnknownSync(unifiedMessage)(null)) /* throws: SchemaError: Not a valid code */ ``` ### 可空类型 Schema 模块提供了一些工具函数,用于定义允许可空类型的 schema,帮助你处理可能是 `null`、`undefined` 或两者兼有的值。 **示例**(创建可空 Schema) ```ts import { Schema } from "effect" // Represents a schema for a string or null value Schema.NullOr(Schema.String) // Represents a schema for a string, null, or undefined value Schema.NullishOr(Schema.String) // Represents a schema for a string or undefined value Schema.UndefinedOr(Schema.String) ``` ### 可辨识联合 TypeScript 中的[可辨识联合](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions)是一种对复杂数据结构建模的方式,这类结构可能根据一组特定的条件或属性呈现不同的形态。它允许你定义一个表示多个相关形状的类型,其中每个形状都由一个共享的判别属性唯一标识。 在可辨识联合中,联合的每个变体都有一个公共属性,称为判别属性(discriminant)。判别属性是字面量类型,这意味着它只能取有限的一组可能值。TypeScript 可以根据判别属性的值推断出当前使用的是联合中的哪个变体。 **示例**(在 TypeScript 中定义可辨识联合) ```ts type Circle = { readonly kind: "circle" readonly radius: number } type Square = { readonly kind: "square" readonly sideLength: number } type Shape = Circle | Square ``` 在 `Schema` 模块中,你可以为每个类型指定一个字面量字段作为判别属性,从而以类似的方式定义可辨识联合。 **示例**(使用 Schema 定义可辨识联合) ```ts import { Schema } from "effect" const Circle = Schema.Struct({ kind: Schema.Literal("circle"), radius: Schema.Finite, }) const Square = Schema.Struct({ kind: Schema.Literal("square"), sideLength: Schema.Finite, }) const Shape = Schema.Union([Circle, Square]) ``` 在这个例子中,`Schema.Literal` 构造器把 `kind` 属性设置为 `Circle` 和 `Square` 两个 schema 共同的判别属性。随后 `Shape` schema 表示这两个类型的联合,让 TypeScript 能够根据 `kind` 的值推断出具体的形状。 ### 把简单联合转换为可辨识联合 如果你从一个简单联合开始,并想把它转换为可辨识联合,可以为每个成员添加一个特殊属性。这样 TypeScript 就能根据判别属性的值自动推断出正确的类型。 **示例**(最初的简单联合) 例如,假设你定义了一个由 `Circle` 和 `Square` 组合而成、不带任何特殊属性的 `Shape` 联合: ```ts import { Schema } from "effect" const Circle = Schema.Struct({ radius: Schema.Finite, }) const Square = Schema.Struct({ sideLength: Schema.Finite, }) const Shape = Schema.Union([Circle, Square]) ``` 为了让代码更易于管理,你可能想把简单联合转换为可辨识联合。这样,TypeScript 就能根据某个特定属性的值自动判断你正在处理联合中的哪个成员。 为此,你可以为联合的每个成员添加一个特殊属性,让 TypeScript 在运行时知道它面对的是哪个类型。 下面演示如何把 `Shape` schema [转换](/docs/v4/schema/transformations#infallible-transformations)为另一个表示可辨识联合的 schema: **示例**(添加判别属性) ```ts import { Schema, SchemaTransformation } from "effect" const Circle = Schema.Struct({ radius: Schema.Finite, }) const Square = Schema.Struct({ sideLength: Schema.Finite, }) const DiscriminatedShape = Schema.Union([ Circle.pipe( Schema.decodeTo( // Add a "kind" property with the literal value "circle" to Circle Schema.Struct({ ...Circle.fields, kind: Schema.Literal("circle") }), SchemaTransformation.transform({ // Add the discriminant property to Circle decode: (circle) => ({ ...circle, kind: "circle" as const }), // Remove the discriminant property encode: ({ kind: _kind, ...rest }) => rest, }), ), ), Square.pipe( Schema.decodeTo( // Add a "kind" property with the literal value "square" to Square Schema.Struct({ ...Square.fields, kind: Schema.Literal("square") }), SchemaTransformation.transform({ // Add the discriminant property to Square decode: (square) => ({ ...square, kind: "square" as const }), // Remove the discriminant property encode: ({ kind: _kind, ...rest }) => rest, }), ), ), ]) console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 })) // Output: { radius: 10, kind: 'circle' } console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ sideLength: 10 })) // Output: { sideLength: 10, kind: 'square' } ``` 前面这个方案可行,但需要大量样板代码。你可以用 `mapFields` 添加判别属性,并使用 `Schema.tagDefaultOmit` 提供解码时的默认值,同时在编码时省略它: **示例**(使用 `Schema.tagDefaultOmit`) ```ts import { Schema } from "effect" const Circle = Schema.Struct({ radius: Schema.Finite, }) const Square = Schema.Struct({ sideLength: Schema.Finite, }) const DiscriminatedShape = Schema.Union([ Circle.mapFields((fields) => ({ ...fields, kind: Schema.tagDefaultOmit("circle"), })), Square.mapFields((fields) => ({ ...fields, kind: Schema.tagDefaultOmit("square"), })), ]) // decoding console.log(Schema.decodeUnknownSync(DiscriminatedShape)({ radius: 10 })) // Output: { radius: 10, kind: 'circle' } // encoding console.log( Schema.encodeSync(DiscriminatedShape)({ kind: "circle", radius: 10, }), ) // Output: { radius: 10 } ``` ### 暴露的值 你可以访问以元组形式表示的联合 schema 中的各个成员: ```ts import { Schema } from "effect" const schema = Schema.Union([Schema.String, Schema.Finite]) // Accesses the members of the union const members = schema.members // ┌─── typeof Schema.String // ▼ const firstMember = members[0] // ┌─── typeof Schema.Finite // ▼ const secondMember = members[1] ``` ## 元组 Schema 模块允许你定义元组,即元素类型可以不同的有序集合。 你可以定义包含必需元素、可选元素或剩余元素的元组。 ### 必需元素 要定义包含必需元素的元组,可以使用 `Schema.Tuple` 构造器,按顺序列出各个元素 schema 即可: **示例**(定义包含必需元素的元组) ```ts import { Schema } from "effect" // Define a tuple with a string and a number as required elements // // ┌─── Tuple<[typeof Schema.String, typeof Schema.Finite]> // ▼ const schema = Schema.Tuple([Schema.String, Schema.Finite]) // ┌─── readonly [string, number] // ▼ type Type = typeof schema.Type ``` ### 追加必需元素 你可以使用展开运算符,向已有元组追加额外的必需元素: **示例**(向已有元组添加元素) ```ts import { Schema } from "effect" const tuple1 = Schema.Tuple([Schema.String, Schema.Finite]) // Append a boolean to the existing tuple const tuple2 = Schema.Tuple([...tuple1.elements, Schema.Boolean]) // ┌─── readonly [string, number, boolean] // ▼ type Type = typeof tuple2.Type ``` ### 可选元素 要定义可选元素,请使用 `Schema.optionalKey` 构造器。 **示例**(定义包含可选元素的元组) ```ts import { Schema } from "effect" // Define a tuple with a required string and an optional number const schema = Schema.Tuple([ Schema.String, // required element Schema.optionalKey(Schema.Finite), // optional element ]) // ┌─── readonly [string, number?] // ▼ type Type = typeof schema.Type ``` ### 剩余元素 要定义剩余元素,请把它添加在必需元素或可选元素列表之后。 剩余元素让元组可以接受特定类型的额外元素。 **示例**(使用剩余元素) ```ts import { Schema } from "effect" // Define a tuple with required elements and a rest element of type boolean const schema = Schema.TupleWithRest( Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Finite)]), // elements [Schema.Boolean], // rest element ) type Type = typeof schema.Type ``` 你还可以在剩余元素之后包含其他元素: **示例**(在剩余元素之后包含额外元素) ```ts import { Schema } from "effect" // Define a tuple with required elements, a rest element, // and an additional element const schema = Schema.TupleWithRest( Schema.Tuple([Schema.String, Schema.UndefinedOr(Schema.Finite)]), // elements [Schema.Boolean, Schema.String], // rest element, then an additional element ) type Type = typeof schema.Type ``` ### 元素注解 注解(annotation)可用于为元组元素添加元数据,从而更容易描述它们的用途或要求。 这在生成文档或 JSON schema 时尤其有用。 **示例**(为元组元素添加注解) ```ts import { Schema } from "effect" // Define a tuple representing a point with annotations for each coordinate const Point = Schema.Tuple([ Schema.Finite.annotateKey({ title: "X", description: "X coordinate", }), Schema.optionalKey(Schema.Finite).annotateKey({ title: "Y", description: "optional Y coordinate", }), ]) // Generate a JSON Schema from the tuple console.log(Schema.toJsonSchemaDocument(Point)) /* Output: { dialect: 'draft-2020-12', schema: { type: 'array', prefixItems: [ { type: 'number', allOf: [{ title: 'X', description: 'X coordinate' }] }, { type: 'number', allOf: [{ title: 'Y', description: 'optional Y coordinate' }] } ], maxItems: 2, minItems: 1 }, definitions: {} } */ ``` ### 暴露的值 你可以使用 `elements` 和 `rest` 属性访问元组 schema 的元素与剩余元素: **示例**(访问元组 schema 的元素与剩余元素) ```ts import { Schema } from "effect" // Define a tuple with required, optional, and rest elements const schema = Schema.TupleWithRest( Schema.Tuple([Schema.String, Schema.UndefinedOr(Schema.Finite)]), // elements [Schema.Boolean, Schema.String], // rest element, then an additional element ) // Access the required and optional elements of the tuple // // ┌─── readonly [typeof Schema.String, Schema.UndefinedOr] // ▼ const tupleElements = schema.schema.elements // Access the rest element of the tuple // // ┌─── readonly [typeof Schema.Boolean, typeof Schema.String] // ▼ const restElement = schema.rest ``` ## 数组 Schema 模块允许你为数组定义 schema,从而轻松校验由特定类型的元素组成的集合。 **示例**(定义数组 Schema) ```ts import { Schema } from "effect" // Define a schema for an array of numbers // // ┌─── $Array // ▼ const schema = Schema.Array(Schema.Finite) // ┌─── readonly number[] // ▼ type Type = typeof schema.Type ``` ### 可变数组 默认情况下,`Schema.Array` 生成的类型被标记为 `readonly`。 要为可变数组创建 schema,可以使用 `Schema.mutable` 函数,它以**浅层**方式让数组类型变为可变。 **示例**(创建可变数组 Schema) ```ts import { Schema } from "effect" // Define a schema for a mutable array of numbers // // ┌─── mutable> // ▼ const schema = Schema.mutable(Schema.Array(Schema.Finite)) // ┌─── number[] // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `value` 属性访问数组 schema 的值类型: **示例**(访问数组 Schema 的值类型) ```ts import { Schema } from "effect" const schema = Schema.Array(Schema.Finite) // Access the value type of the array schema // // ┌─── typeof Schema.Finite // ▼ const value = schema.value ``` ## 非空数组 Schema 模块还提供了为非空数组定义 schema 的方式,确保数组始终至少包含一个元素。 **示例**(定义非空数组 Schema) ```ts import { Schema } from "effect" // Define a schema for a non-empty array of numbers // // ┌─── NonEmptyArray // ▼ const schema = Schema.NonEmptyArray(Schema.Finite) // ┌─── readonly [number, ...number[]] // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `value` 属性访问非空数组 schema 的值类型: **示例**(访问非空数组 schema 的值类型) ```ts import { Schema } from "effect" // Define a schema for a non-empty array of numbers const schema = Schema.NonEmptyArray(Schema.Finite) // Access the value type of the non-empty array schema // // ┌─── typeof Schema.Finite // ▼ const value = schema.value ``` ## Record Schema 模块提供了定义 record 类型的支持:record 是键值对的集合,其中的键可以是字符串、symbol 或其他类型,而值则具有一个已定义的 schema。 ### 字符串键 你可以定义键为字符串、并为其值指定类型的 record。 **示例**(字符串键与数字值) ```ts import { Schema } from "effect" // Define a record schema with string keys and number values // // ┌─── $Record // ▼ const schema = Schema.Record(Schema.String, Schema.Finite) // ┌─── { readonly [x: string]: number; } // ▼ type Type = typeof schema.Type ``` ### Symbol 键 Record 也可以使用 symbol 作为键。 **示例**(Symbol 键与数字值) ```ts import { Schema } from "effect" // Define a record schema with symbol keys and number values const schema = Schema.Record(Schema.Symbol, Schema.Finite) // ┌─── { readonly [x: symbol]: number; } // ▼ type Type = typeof schema.Type ``` ### 字面量键的联合 使用字面量的联合可以把键限制在一组特定的值上。 **示例**(用字符串字面量作为键) ```ts import { Schema } from "effect" // Define a record schema where keys are limited // to specific string literals ("a" or "b") const schema = Schema.Record( Schema.Union([Schema.Literal("a"), Schema.Literal("b")]), Schema.Finite, ) // ┌─── { readonly a: number; readonly b: number; } // ▼ type Type = typeof schema.Type ``` ### 模板字面量键 Record 可以使用模板字面量作为键,从而支持更复杂的键模式。 **示例**(模板字面量键与数字值) ```ts import { Schema } from "effect" // Define a record schema with keys that match // the template literal pattern "a${string}" const schema = Schema.Record( Schema.TemplateLiteral([Schema.Literal("a"), Schema.String]), Schema.Finite, ) // ┌─── { readonly [x: `a${string}`]: number; } // ▼ type Type = typeof schema.Type ``` ### 细化后的键 你可以用额外的约束来细化键的类型。 **示例**(按最小长度过滤键) ```ts import { Schema } from "effect" // Define a record schema where keys are strings with a minimum length of 2 const schema = Schema.Record( Schema.String.check(Schema.isMinLength(2)), Schema.Finite, ) // ┌─── { readonly [x: string]: number; } // ▼ type Type = typeof schema.Type ``` 对键的细化起的是过滤作用,而不会导致解码失败。 如果某个键不满足约束(例如模式或最小长度检查),它会被从解码输出中移除,而不是触发错误。 **示例**(不满足约束的键会被移除) ```ts import { Schema } from "effect" const schema = Schema.Record( Schema.String.check(Schema.isMinLength(2)), Schema.Finite, ) console.log(Schema.decodeUnknownSync(schema)({ a: 1, bb: 2 })) // Output: { bb: 2 } ("a" is removed because it is too short) ``` 如果你希望在键不满足约束时让解码失败,可以把 [`onExcessProperty`](/docs/v4/schema/getting-started#managing-excess-properties) 设为 `"error"`。 **示例**(对无效键强制报错) ```ts import { Schema } from "effect" const schema = Schema.Record( Schema.String.check(Schema.isMinLength(2)), Schema.Finite, ) console.log( Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })({ a: 1, bb: 2, }), ) /* throws: SchemaError: { readonly [x: minLength(2)]: number } └─ ["a"] └─ is unexpected, expected: minLength(2) */ ``` ### 转换键 `Schema.Record` API 不支持对键 schema 做转换。 尝试对键应用转换会得到 `Unsupported key schema` 错误: **示例**(尝试转换键) ```ts import { Schema } from "effect" const schema = Schema.Record(Schema.Trim, Schema.FiniteFromString) /* throws: Error: Unsupported key schema schema (Transformation): Trim */ ``` 要修改 record 的键,你必须在 `Schema.Record` 之外应用转换。 一种常见做法是用 [`Schema.decodeTo`](/docs/v4/schema/transformations#infallible-transformations) 搭配 `SchemaTransformation.transform`,在解码过程中调整键。 **示例**(解码时修剪键) ```ts import { Record, Schema, SchemaTransformation, identity } from "effect" const schema = Schema.Record(Schema.String, Schema.FiniteFromString).pipe( Schema.decodeTo( // Define the output schema with transformed keys Schema.Record(Schema.Trimmed, Schema.Finite), SchemaTransformation.transform({ // Trim keys during decoding decode: (record) => Record.mapKeys(record, (key) => key.trim()), encode: identity, }), ), ) console.log(Schema.decodeUnknownSync(schema)({ " key1 ": "1", key2: "2" })) // Output: { key1: 1, key2: 2 } ``` ### 可变 Record 默认情况下,`Schema.Record` 生成的类型被标记为 `readonly`。 要创建可变 record 的 schema,可以使用 `Schema.mutable` 函数,它以**浅层**(shallow)方式让 record 类型可变。 **示例**(创建可变 Record 的 schema) ```ts import { Schema } from "effect" // Create a schema for a mutable record with string keys and number values const schema = Schema.Record(Schema.String, Schema.mutableKey(Schema.Finite)) // ┌─── { [x: string]: number; } // ▼ type Type = typeof schema.Type ``` ### 暴露的值 你可以使用 `key` 和 `value` 属性访问 record schema 的 `key` 和 `value` 类型: **示例**(访问键与值的类型) ```ts import { Schema } from "effect" const schema = Schema.Record(Schema.String, Schema.Finite) // Accesses the key // // ┌─── typeof Schema.String // ▼ const key = schema.key // Accesses the value // // ┌─── typeof Schema.Finite // ▼ const value = schema.value ``` ## Struct ### 属性签名 `Schema.Struct` 构造器为具有特定属性的对象定义 schema。 **示例**(定义 Struct schema) 这个示例为一个对象定义了 struct schema,该对象具有以下属性: - `name`:字符串 - `age`:数字 ```ts import { Schema } from "effect" // ┌─── Schema.Struct<{ // │ name: typeof Schema.String; // │ age: typeof Schema.Finite; // │ }> // ▼ const schema = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // The inferred TypeScript type from the schema // // ┌─── { // │ readonly name: string; // │ readonly age: number; // │ } // ▼ type Type = typeof schema.Type ``` ### 索引签名 使用 `Schema.StructWithRest` 可以把一个 struct 与一个或多个表示索引签名的 record 组合起来。 **示例**(添加索引签名) ```ts import { Schema } from "effect" // Define a struct with a specific property "a" // and an index signature allowing additional properties const schema = Schema.StructWithRest( // Defined properties Schema.Struct({ a: Schema.Finite }), // Index signature: allows additional string keys with number values [Schema.Record(Schema.String, Schema.Finite)], ) // The inferred TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly a: number; // │ } // ▼ type Type = typeof schema.Type ``` ### 多个索引签名 每种键类型(`string` 或 `symbol`)只能定义**一个**索引签名。不允许定义多个同类型的索引签名。 **示例**(合法的多个索引签名) ```ts import { Schema } from "effect" // Define a struct with a fixed property "a" // and valid index signatures for both strings and symbols const schema = Schema.StructWithRest(Schema.Struct({ a: Schema.Finite }), [ // String index signature Schema.Record(Schema.String, Schema.Finite), // Symbol index signature Schema.Record(Schema.Symbol, Schema.Finite), ]) // The inferred TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly [x: symbol]: number; // │ readonly a: number; // │ } // ▼ type Type = typeof schema.Type ``` 定义多个同一种键类型(`string` 或 `symbol`)的索引签名会导致错误。 **示例**(非法的多个索引签名) ```ts import { Schema } from "effect" Schema.StructWithRest( Schema.Struct({ a: Schema.Finite }), // Attempting to define multiple string index signatures [ Schema.Record(Schema.String, Schema.Finite), Schema.Record(Schema.String, Schema.Boolean), ], ) /* throws: Error: Duplicate index signature details: string index signature */ ``` ### 冲突的索引签名 在定义带索引签名的 schema 时,如果某个固定属性的类型与索引签名允许的值类型不同,就会产生冲突。 这可能导致意外的 TypeScript 行为。 **示例**(冲突的索引签名) ```ts import { Schema } from "effect" // Attempting to define a struct with a conflicting index signature // - The fixed property "a" is a string // - The index signature requires all values to be numbers const schema = Schema.StructWithRest(Schema.Struct({ a: Schema.String }), [ Schema.Record(Schema.String, Schema.Finite), ]) // ❌ Incorrect TypeScript type: // // ┌─── { // │ readonly [x: string]: number; // │ readonly a: string; // │ } // ▼ type Type = typeof schema.Type ``` TypeScript 编译器在手动定义该类型时会把它标记为错误: ```ts // @errors: 2411 // This type is invalid because the index signature // conflicts with the fixed property `a` type Test = { readonly a: string readonly [x: string]: number } ``` 出现这种情况是因为 TypeScript 不允许索引签名与固定属性相矛盾。 #### 冲突索引签名的变通方案 在使用 schema 时,如果某个固定属性与索引签名允许的值类型不同,就可能发生冲突。这种情况常常出现在处理不遵循严格 TypeScript 约定的外部 API 时。 为避免冲突,你可以把固定属性与索引属性分开,并把它们作为 schema 中两个独立的部分来处理。 **示例**(提取固定属性与索引属性) 考虑这样一个对象: - `"a"` 是类型为 `string` 的固定属性。 - 其他所有键都存储数字,这与 `"a"` 冲突。 ```ts // @errors: 2411 // This type is invalid because the index signature // conflicts with the fixed property `a` type Test = { a: string [x: string]: number } ``` 为避免这个问题,我们可以把这些属性拆成两个不同的类型: ```ts // Fixed properties schema type FixedProperties = { readonly a: string } // Index signature properties schema type IndexSignatureProperties = { readonly [x: string]: number } // The final output groups both properties in a tuple type OutputData = readonly [FixedProperties, IndexSignatureProperties] ``` 通过 [`Schema.decodeTo`](/docs/v4/schema/transformations#composition) 和 `SchemaTransformation.transform`,你可以在解码前对输入数据做预处理。这种方式能确保固定属性与索引签名属性被独立处理。 ```ts import { Schema, SchemaTransformation } from "effect" // Define a schema for the fixed property "a" const FixedProperties = Schema.Struct({ a: Schema.String, }) // Define a schema for index signature properties const IndexSignatureProperties = Schema.Record( // Exclude keys that are already present in FixedProperties Schema.String.check( Schema.makeFilter( (key) => !Object.keys(FixedProperties.fields).includes(key), ), ), Schema.Finite, ) // Create a schema that duplicates an object into two parts const Duplicate = Schema.ObjectKeyword.pipe( Schema.decodeTo( Schema.Tuple([Schema.ObjectKeyword, Schema.ObjectKeyword]), SchemaTransformation.transform({ // Create a tuple containing the input twice decode: (a) => [a, a] as const, // Merge both parts back when encoding encode: ([a, b]) => ({ ...a, ...b }), }), ), ) const Result = Duplicate.pipe( Schema.decodeTo( Schema.Tuple([FixedProperties, IndexSignatureProperties]).annotate({ parseOptions: { onExcessProperty: "ignore" }, }), ), ) // Decoding: Separates fixed and indexed properties console.log(Schema.decodeUnknownSync(Result)({ a: "a", b: 1, c: 2 })) // Output: [ { a: 'a' }, { b: 1, c: 2 } ] // Encoding: Combines them back into an object console.log(Schema.encodeSync(Result)([{ a: "a" }, { b: 1, c: 2 }])) // Output: { a: 'a', b: 1, c: 2 } ``` ### 暴露的值 你可以使用 `fields` 和 `records` 属性访问 struct schema 的字段与 record: **示例**(访问字段与 record) ```ts import { Schema } from "effect" const schema = Schema.StructWithRest(Schema.Struct({ a: Schema.Finite }), [ Schema.Record(Schema.String, Schema.Finite), ]) // Accesses the fields // // ┌─── { readonly a: typeof Schema.Finite; } // ▼ const fields = schema.schema.fields // Accesses the records // // ┌─── readonly [Schema.$Record] // ▼ const records = schema.records ``` ### 可变 Struct 默认情况下,`Schema.Struct` 生成的类型中,属性被标记为 `readonly`。 要为 struct 创建可变版本,可以使用 `Schema.mutable` 函数,它以**浅层**方式让属性变为可变。 **示例**(创建可变 Struct Schema) ```ts import { Schema, Struct } from "effect" const schema = Schema.Struct({ a: Schema.String, b: Schema.Finite }).mapFields( Struct.map(Schema.mutableKey), ) // ┌─── { a: string; b: number; } // ▼ type Type = typeof schema.Type ``` ## 带标签的结构体 在 TypeScript 中,标签有助于增强类型判别与模式匹配,它提供了一种简单而强大的方式来定义和识别不同的数据类型。 ### 什么是标签? 标签是添加到数据结构上的一个字面量值,常用于 struct 中,用来区分带标签联合里的各种对象类型或变体。这个字面量充当判别属性,让人能更轻松、更高效地正确处理不同类型的数据。 ### 使用 tag 构造器 `Schema.tag` 构造器专门用于创建一个持有特定字面量值的属性签名,作为对象类型的判别属性。 **示例**(定义带标签的结构体) ```ts import { Schema } from "effect" const User = Schema.Struct({ _tag: Schema.tag("User"), name: Schema.String, age: Schema.Finite, }) // ┌─── { readonly _tag: "User"; readonly name: string; readonly age: number; } // ▼ type Type = typeof User.Type console.log(User.make({ name: "John", age: 44 })) /* Output: { _tag: 'User', name: 'John', age: 44 } */ ``` 在上面的例子中,`Schema.tag("User")` 为 `User` struct schema 附加了一个 `_tag` 属性,从而把该 struct 类型的对象标记为 "User"。 使用 `make` 方法创建新实例时,这个标签会被自动应用,从而简化对象创建并确保标签一致。 ### 用 TaggedStruct 简化带标签的结构体 `Schema.TaggedStruct` 构造器把标签直接整合进 struct 定义中,从而简化了创建带标签 struct 的过程。这种方式为构建内嵌判别属性的数据结构提供了更清晰、更具声明性的途径。 **示例**(使用 `TaggedStruct` 简化带标签的结构体) ```ts import { Schema } from "effect" const User = Schema.TaggedStruct("User", { name: Schema.String, age: Schema.Finite, }) // `_tag` is automatically applied when constructing an instance console.log(User.make({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } // `_tag` is required when decoding from an unknown source console.log(Schema.decodeUnknownSync(User)({ name: "John", age: 44 })) /* throws: SchemaError: { readonly _tag: "User"; readonly name: string; readonly age: number } └─ ["_tag"] └─ is missing */ ``` 在这个示例中: - 使用 `make` 构造实例时,`_tag` 属性是可选的,schema 会自动应用它。 - 解码未知数据时,必须提供 `_tag`,以确保正确识别类型。这种「实例构造」与「解码」之间的区别很有用:既保留了标签作为类型判别属性的角色,又简化了实例创建。 如果你需要 `_tag` 在解码时也被自动应用,可以创建一个定制版的 `Schema.TaggedStruct`: **示例**(定制 `TaggedStruct`,在解码时应用 `_tag`) ```ts import type { SchemaAST } from "effect" import { Schema } from "effect" const TaggedStruct = < Tag extends SchemaAST.LiteralValue, Fields extends Schema.Struct.Fields, >( tag: Tag, fields: Fields, ) => Schema.Struct({ _tag: Schema.tagDefaultOmit(tag), ...fields, }) const User = TaggedStruct("User", { name: Schema.String, age: Schema.Finite, }) console.log(User.make({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } console.log(Schema.decodeUnknownSync(User)({ name: "John", age: 44 })) // Output: { _tag: 'User', name: 'John', age: 44 } ``` ### 多个标签 虽然一个主标签通常就足够了,但 TypeScript 允许你为更复杂的数据结构需求定义多个标签。下面是一个在单个 struct 中使用多个标签的示例: **示例**(为一个 struct 添加多个标签) 这个示例定义了一个产品 schema,其中包含一个主标签(`"Product"`)和一个额外的分类标签(`"Electronics"`),为数据结构增加了更细致的区分。 ```ts import { Schema } from "effect" const Product = Schema.TaggedStruct("Product", { category: Schema.tag("Electronics"), name: Schema.String, price: Schema.Finite, }) // `_tag` and `category` are optional when creating an instance console.log(Product.make({ name: "Smartphone", price: 999 })) /* Output: { _tag: 'Product', category: 'Electronics', name: 'Smartphone', price: 999 } */ ``` ## instanceOf 当你需要为通过 `class` 定义的自定义数据类型定义 schema 时,最方便、最快捷的方式是使用 `Schema.instanceOf` 构造器。 **示例**(使用 `instanceOf` 定义 schema) ```ts import { Schema } from "effect" // Define a custom class class MyData { constructor(readonly name: string) {} } // Create a schema for the class const MyDataSchema = Schema.instanceOf(MyData) // ┌─── MyData // ▼ type Type = typeof MyDataSchema.Type console.log(Schema.decodeUnknownSync(MyDataSchema)(new MyData("name"))) // Output: MyData { name: 'name' } console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" })) /* throws: SchemaError: Expected MyData */ ``` `Schema.instanceOf` 构造器只是 [Schema.declare](/docs/v4/schema/advanced-usage#declaring-new-data-types) API 的一个轻量包装,后者是 `effect/Schema` 中用于声明新自定义数据类型的原语。 ### 私有构造器 注意,`Schema.instanceOf` 只能用于暴露了**公开构造器**的类。 如果你尝试把它用于因某种原因把构造器标记为 `private` 的类,会收到一个 TypeScript 错误: **示例**(私有构造器导致的错误) ```ts import { Schema } from "effect" class MyData { static make = (name: string) => new MyData(name) private constructor(readonly name: string) {} } // @errors: 2345 const MyDataSchema = Schema.instanceOf(MyData) ``` 在这种情况下,你无法使用 `Schema.instanceOf`,必须像下面这样依赖 [Schema.declare](/docs/v4/schema/advanced-usage#declaring-new-data-types): **示例**(对私有构造器使用 `Schema.declare`) ```ts import { Schema } from "effect" class MyData { static make = (name: string) => new MyData(name) private constructor(readonly name: string) {} } const MyDataSchema = Schema.declare( (input: unknown): input is MyData => input instanceof MyData, ).annotate({ identifier: "MyData" }) console.log(Schema.decodeUnknownSync(MyDataSchema)(MyData.make("name"))) // Output: MyData { name: 'name' } console.log(Schema.decodeUnknownSync(MyDataSchema)({ name: "name" })) /* throws: SchemaError: Expected MyData */ ``` ### 校验实例的字段 要校验类实例的字段,你可以使用[过滤器(filter)](/docs/v4/schema/filters/)。这种方式把实例校验与对实例字段的额外检查结合起来。 **示例**(为实例 schema 添加字段校验) ```ts import { Result, Schema } from "effect" class MyData { constructor(readonly name: string) {} } const MyDataFields = Schema.Struct({ name: Schema.NonEmptyString, }) // Define a schema for the class instance with additional field validation const MyDataSchema = Schema.instanceOf(MyData).check( Schema.makeFilter((a, _ast, options) => { // Validate the fields of the instance const result = Schema.decodeUnknownResult(MyDataFields)(a, options) // Return undefined if validation succeeds, or the issue if it fails return Result.isFailure(result) ? result.failure.issue : undefined }), ) const decodeTypeSync = Schema.decodeSync(Schema.toType(MyDataSchema)) // Example: Valid instance console.log(decodeTypeSync(new MyData("John"))) // Output: MyData { name: 'John' } // Example: Invalid instance (empty name) console.log(decodeTypeSync(new MyData(""))) /* throws: SchemaError: { MyData | filter } └─ Predicate refinement failure └─ { readonly name: NonEmptyString } └─ ["name"] └─ NonEmptyString └─ Predicate refinement failure └─ Expected a non empty string */ ``` ## 挑选 使用 `Struct.pick` 搭配 `Struct.mapFields`,通过从已有 struct schema 中选取字段来创建新的 struct schema。 **示例**(从 struct 中挑选属性) ```ts import { Schema, Struct } from "effect" // Define a struct schema with properties "a", "b", and "c" const MyStruct = Schema.Struct({ a: Schema.String, b: Schema.Finite, c: Schema.Boolean, }) // Create a new schema that picks properties "a" and "c" // // ┌─── Struct<{ // | a: typeof Schema.String; // | c: typeof Schema.Boolean; // | }> // ▼ const PickedSchema = MyStruct.mapFields(Struct.pick(["a", "c"])) ``` ## 省略 使用 `Struct.omit` 搭配 `Struct.mapFields`,通过从已有 struct schema 中排除字段来创建新的 struct schema。 **示例**(从 struct 中省略属性) ```ts import { Schema, Struct } from "effect" // Define a struct schema with properties "a", "b", and "c" const MyStruct = Schema.Struct({ a: Schema.String, b: Schema.Finite, c: Schema.Boolean, }) // Create a new schema that omits property "b" // // ┌─── Schema.Struct<{ // | a: typeof Schema.String; // | c: typeof Schema.Boolean; // | }> // ▼ const OmittedSchema = MyStruct.mapFields(Struct.omit(["b"])) ``` ## 让属性可选 使用 `Struct.map` 搭配 `Schema.optional`,让 struct 中的每个字段都变为可选。 **示例**(让所有属性可选) ```ts import { Schema, Struct } from "effect" // Create a schema with an optional property "a" const schema = Schema.Struct({ a: Schema.String }).mapFields( Struct.map(Schema.optional), ) // ┌─── { readonly a?: string | undefined; } // ▼ type Type = typeof schema.Type ``` `Schema.optional` 会为每个字段类型加上 `undefined`。如果某个字段可以被省略,但一旦出现就必须包含其 schema 接受的值,请改用 `Schema.optionalKey`。 **示例**(定义一个精确的 partial schema) ```ts import { Schema, Struct } from "effect" // Create a schema with an optional property "a" without allowing undefined const schema = Schema.Struct({ a: Schema.String, }).mapFields(Struct.map(Schema.optionalKey)) // ┌─── { readonly a?: string; } // ▼ type Type = typeof schema.Type ``` ## 让属性必需 使用 `Struct.map` 搭配 `Schema.requiredKey`,让 struct 中的每个可选键都变为必需。 **示例**(让所有属性必需) ```ts import { Schema, Struct } from "effect" // Create a schema and make all properties required const schema = Schema.Struct({ a: Schema.optionalKey(Schema.String), b: Schema.optionalKey(Schema.Finite), }).mapFields(Struct.map(Schema.requiredKey)) // ┌─── { readonly a: string; readonly b: number; } // ▼ type Type = typeof schema.Type ``` 在这个示例中,尽管 `a` 和 `b` 最初被定义为可选,现在它们都变成了必需。 --- # Class API > 通过经过验证的构造函数、方法、相等性与递归字段,定义并扩展由 schema 支撑的类。 当你使用 schema 时,如果你的领域模型更适合用类的实例来表达,可以用 `Schema.Class` 来代替普通的 [Schema.Struct](/docs/v4/schema/basic-usage/#structs)。 类提供了若干能简化 schema 创建过程的特性: - **Schema 与类合二为一**:类本身可以在任何需要 schema 的地方直接使用。 - **经过验证的构造**:构造函数与 `make` 会检查其输入。 - **共享行为**:实例可以暴露方法和 getter。 - **结构相等性**:实例可以用 [`Equal.equals`](/docs/v4/trait/equal/) 进行比较。 ## 定义 要用 `Schema.Class` 定义一个类,你需要指定: - 作为 `Self` 类型参数的类类型。 - 一个用于诊断信息与 schema 元数据的稳定标识符(identifier)。 - 类的字段。 **示例**(定义一个 Schema 类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} ``` 在这个示例中,`Person` 既是一个 schema,也是一个 TypeScript 类。 **示例**(创建实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} console.log(new Person({ id: 1, name: "John" })) /* Output: Person { id: 1, name: 'John' } */ // Using the factory function console.log(Person.make({ id: 1, name: "John" })) /* Output: Person { id: 1, name: 'John' } */ ``` ### 类 Schema 是变换 类 schema 会把一个 struct schema [变换](/docs/v4/schema/transformations/) 成一个代表该类的 [声明(declaration)](/docs/v4/schema/advanced-usage/#declaring-new-data-types) schema。 - 解码时,普通对象会被转换成类的一个实例。 - 编码时,类实例会被转换回普通对象。 **示例**(解码与编码一个类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} const person = Person.make({ id: 1, name: "John" }) // Decode from a plain object into a class instance const decoded = Schema.decodeUnknownSync(Person)({ id: 1, name: "John" }) console.log(decoded) // Output: Person { id: 1, name: 'John' } // Encode a class instance back into a plain object const encoded = Schema.encodeUnknownSync(Person)(person) console.log(encoded) // Output: { id: 1, name: 'John' } ``` ### 定义不含字段的类 当你的 schema 不需要任何字段时,可以定义一个带有空对象的类。 **示例**(定义并使用一个不带参数的类) ```ts import { Schema } from "effect" // Define a class with no fields class NoArgs extends Schema.Class("NoArgs")({}) {} // Create an instance using the default constructor const noargs1 = new NoArgs() // => new NoArgs({}) // Alternatively, create an instance by explicitly passing an empty object const noargs2 = new NoArgs({}) // => new NoArgs() ``` ### 定义带过滤器的类 过滤器让你能够在解码、编码或创建实例时校验输入。你可以传入一个带有过滤器的 `Schema.Struct`,而不是指定原始字段。 **示例**(为 Schema 类应用过滤器) ```ts import { Schema } from "effect" class WithFilter extends Schema.Class("WithFilter")( Schema.Struct({ a: Schema.FiniteFromString, b: Schema.FiniteFromString, }).check( Schema.makeFilter( ({ a, b }) => a >= b || "a must be greater than or equal to b", ), ), ) {} // Constructor console.log(new WithFilter({ a: 1, b: 2 })) /* throws: a must be greater than or equal to b */ // Decoding console.log(Schema.decodeUnknownSync(WithFilter)({ a: "1", b: "2" })) /* throws: a must be greater than or equal to b */ ``` ## 通过类构造函数验证属性 当你使用 `Schema.Class` 定义一个类时,构造函数会自动检查所提供的属性是否符合 schema 的规则。 ### 定义并实例化一个有效的类实例 构造函数确保每一个属性(例如 `id` 和 `name`)都符合 schema。例如,`id` 必须是一个数字,`name` 必须是一个非空字符串。 **示例**(创建一个有效实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} // Create an instance with valid properties const john = new Person({ id: 1, name: "John" }) // => new Person({ id: 1, name: "John" }) ``` ### 处理无效属性 如果在实例化时提供了无效的属性,构造函数会抛出一个错误,说明验证失败的原因。 **示例**(用无效属性创建一个实例) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} // Attempt to create an instance with an invalid `name` new Person({ id: 1, name: "" }) /* throws: Expected a value with a length of at least 1 at ["name"] */ ``` 该错误清晰地指出,`name` 字段未能满足 `NonEmptyString` 的要求。 ### 跳过检查 在某些场景下,你可能希望绕过验证逻辑。虽然一般不建议这样做,但库提供了一个选项来实现。 **示例**(跳过检查) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} // Skip the schema checks during instantiation const john = new Person({ id: 1, name: "" }, { disableChecks: true }) ``` ## 结构相等性 [`Equal.equals`](/docs/v4/trait/equal/) 会按结构比较类实例,包括嵌套的对象和数组。 **示例**(按值比较实例) ```ts import { Equal, Schema } from "effect" class Person extends Schema.Class("Person")({ name: Schema.NonEmptyString, hobbies: Schema.Array(Schema.String), }) {} const john1 = new Person({ name: "John", hobbies: ["reading", "coding"], }) const john2 = new Person({ name: "John", hobbies: ["reading", "coding"], }) Equal.equals(john1, john2) // => true ``` ## 用自定义逻辑扩展类 Schema 类提供了灵活性,允许你加入自定义的 getter 和方法,从而将功能扩展到已定义字段之外。 ### 添加自定义 getter getter 可以用来从类的字段中派生出计算值。例如,`Person` 类可以包含一个 getter,用于返回大写形式的 `name` 属性。 **示例**(添加返回大写名字的 getter) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) { // Custom getter to return the name in uppercase get upperName() { return this.name.toUpperCase() } } const john = new Person({ id: 1, name: "John" }) // Use the custom getter console.log(john.upperName) // Output: "JOHN" ``` ### 添加自定义方法 除了 getter,你还可以定义方法来封装更复杂的逻辑或涉及类字段的操作。 **示例**(添加一个方法) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) { // Custom method to return a greeting greet() { return `Hello, my name is ${this.name}.` } } const john = new Person({ id: 1, name: "John" }) // Use the custom method console.log(john.greet()) // Output: "Hello, my name is John." ``` ## 将类用作 Schema 定义 当你用 `Schema.Class` 定义一个类时,它既充当 schema,也充当类。这种双重功能让该类可以在任何需要 schema 的地方被使用。 **示例**(在数组 schema 中使用一个类) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} // Use the Person class in an array schema const Persons = Schema.Array(Person) // ┌─── readonly Person[] // ▼ type Type = typeof Persons.Type ``` ### 暴露的值 该类还包含一个 `fields` 静态属性,它列出了在创建类时所定义的字段。 **示例**(访问 `fields` 属性) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) {} // ┌─── { // | readonly id: Schema.Finite; // | readonly name: Schema.NonEmptyString; // | } // ▼ Person.fields ``` ## 添加注解 将注解作为字段或 struct 之后的第二个参数传入。传给 `Schema.Class` 的标识符同时也会作为默认的 `identifier` 注解存储。 **示例**(为类 schema 添加注解) ```ts import { Schema } from "effect" class Person extends Schema.Class("Person")( { id: Schema.Finite, name: Schema.NonEmptyString, }, { title: "Person model" }, ) {} Person.identifier // => "Person" Person.ast.annotations?.title // => "Person model" ``` ## 递归 Schema 当你需要定义一个依赖自身的 schema 时(例如递归数据结构),`Schema.suspend` 组合子就很有用。在这个示例中,`Category` schema 依赖自身,因为它有一个 `subcategories` 字段,该字段是 `Category` 对象组成的数组。 **示例**(自引用 schema) ```ts import { Schema } from "effect" // Define a Category schema with a recursive subcategories field class Category extends Schema.Class("Category")({ name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Codec => Category), ), }) {} ``` **示例**(缺少类型注解导致的错误) ```ts import { Schema } from "effect" // @errors: 2506 7024 class Category extends Schema.Class("Category")({ name: Schema.String, subcategories: Schema.Array(Schema.suspend(() => Category)), }) {} ``` ### 互递归 Schema 有时,多个 schema 会以互递归的方式彼此依赖。例如,一个算术表达式树可能包含 `Expression` 节点,其既可以是数字,也可以是 `Operation` 节点,而 `Operation` 节点反过来又会引用 `Expression` 节点。 **示例**(算术表达式树) ```ts import { Schema } from "effect" class Expression extends Schema.Class("Expression")({ type: Schema.Literal("expression"), value: Schema.Union([ Schema.Finite, Schema.suspend((): Schema.Codec => Operation), ]), }) {} class Operation extends Schema.Class("Operation")({ type: Schema.Literal("operation"), operator: Schema.Literals(["+", "-"]), left: Expression, right: Expression, }) {} ``` ### Encoded 与 Type 不同的递归类型 在定义 `Encoded` 类型与 `Type` 类型不同的递归 schema 时,需要显式给出编码(encoded)表示。例如,`FiniteFromString` 的 `Type` 是 `number`,而 `Encoded` 是 `string`。 在这种情况下,我们需要为 `Encoded` 类型定义一个接口。 让我们用 `FiniteFromString` 给 `Category` schema 增加一个 `id` 字段。当把这个字段加入 `Category` schema 时,TypeScript 会报错: ```ts import { Schema } from "effect" class Category extends Schema.Class("Category")({ id: Schema.FiniteFromString, name: Schema.String, subcategories: Schema.Array( // @errors: 2322 Schema.suspend((): Schema.Codec => Category), ), }) {} ``` `Schema.Codec` 注解假定 `Type` 和 `Encoded` 都是 `Category`。此时应当把递归的 encoded 类型作为第二个参数传入: **示例**(用显式 `Encoded` 类型调整 schema) ```ts import { Schema } from "effect" interface CategoryEncoded { readonly id: string readonly name: string readonly subcategories: ReadonlyArray } class Category extends Schema.Class("Category")({ id: Schema.FiniteFromString, name: Schema.String, subcategories: Schema.Array( Schema.suspend((): Schema.Codec => Category), ), }) {} ``` 正如我们所见,为了支持递归 schema 的定义,有必要为 schema 的 `Encoded` 定义一个接口,这会让事情变得复杂且相当繁琐。一种缓解该问题的模式是**将负责递归的字段从其它所有字段中分离出来**。 **示例**(分离递归字段) ```ts import { Schema } from "effect" const fields = { id: Schema.FiniteFromString, name: Schema.String, // ...possibly other fields } interface CategoryEncoded extends Schema.Struct.Encoded { // Define `subcategories` using recursion readonly subcategories: ReadonlyArray } class Category extends Schema.Class("Category")({ ...fields, // Include the fields subcategories: Schema.Array( // Define `subcategories` using recursion Schema.suspend((): Schema.Codec => Category), ), }) {} ``` ## 带标签的类变体 `Schema.TaggedClass` 会自动添加一个 `_tag` 字段,而 `Schema.TaggedError` 还会创建一个可 yield 的 `Error`。二者默认都会以该标签作为自己的标识符。 **示例**(创建带标签的类与错误) ```ts import { Schema } from "effect" // Define a tagged class with a "name" field class TaggedPerson extends Schema.TaggedClass()("TaggedPerson", { name: Schema.String, }) {} // Define a tagged error with a "status" field class HttpError extends Schema.TaggedError()("HttpError", { status: Schema.Finite, }) {} const joe = new TaggedPerson({ name: "Joe" }) console.log(joe._tag) // Output: "TaggedPerson" const error = new HttpError({ status: 404 }) console.log(error._tag) // Output: "HttpError" console.log(error.stack) // access the stack trace ``` ## 扩展已有类 `extend` 静态工具允许你通过添加**额外的**字段与功能来增强一个已有的 schema 类。这种方式有助于在现有 schema 之上进行扩展,而无需从头重新定义。 **示例**(扩展一个 Schema 类) ```ts import { Schema } from "effect" // Define the base class class Person extends Schema.Class("Person")({ id: Schema.Finite, name: Schema.NonEmptyString, }) { // A custom getter that converts the name to uppercase get upperName() { return this.name.toUpperCase() } } // Extend the base class to include an "age" field class PersonWithAge extends Person.extend("PersonWithAge")({ age: Schema.Finite, }) { // A custom getter to check if the person is an adult get isAdult() { return this.age >= 18 } } // Usage const john = new PersonWithAge({ id: 1, name: "John", age: 25 }) console.log(john.upperName) // Output: "JOHN" console.log(john.isAdult) // Output: true ``` --- # 默认构造器 > 使用 make、makeOption、makeEffect、校验选项与默认值来构造 Schema 值。 每个 Schema 都暴露了构造器,用于在应用构造器默认值与类型侧检查的同时,创建其 `Type` 类型的值。 当失败应当抛出异常时使用 `make`;当你只需要知道构造是否成功时使用 `makeOption`;当你需要在 `Effect` 的错误通道中获取 `SchemaError` 时使用 `makeEffect`。 **示例**(使用 Refinement 的默认构造器) ```ts import { Schema } from "effect" const schema = Schema.FiniteFromString.check( Schema.isBetween({ minimum: 1, maximum: 10 }), ) // The constructor only accepts numbers console.log(schema.make(5)) // Output: 5 // This will throw an error because the number is outside the valid range console.log(schema.make(20)) /* throws: Expected a number between 1 and 10 */ ``` ## Struct Struct Schema 允许你定义具有特定字段和约束的对象。可以使用 `make` 函数创建 Struct Schema 的实例。 **示例**(创建 Struct 实例) ```ts import { Schema } from "effect" const Struct = Schema.Struct({ name: Schema.NonEmptyString, }) // Successful creation Struct.make({ name: "a" }) // This will throw an error because the name is empty Struct.make({ name: "" }) /* throws Expected a value with a length of at least 1 at ["name"] */ ``` 当输入已经可信时,`make` 可以跳过 Schema 检查。对于不可信的值,不建议这样做。 **示例**(跳过检查) ```ts import { Schema } from "effect" const Struct = Schema.Struct({ name: Schema.NonEmptyString, }) // Skip checks when the input is already trusted Struct.make({ name: "" }, { disableChecks: true }) ``` ## Record Record Schema 允许你定义键值映射,其中的键和值必须满足特定条件。 **示例**(创建 Record 实例) ```ts import { Schema } from "effect" const Record = Schema.Record(Schema.String, Schema.NonEmptyString) // Successful creation Record.make({ a: "a", b: "b" }) // This will throw an error because 'b' is empty Record.make({ a: "a", b: "" }) /* throws Expected a value with a length of at least 1 at ["b"] */ // Skips checks Record.make({ a: "a", b: "" }, { disableChecks: true }) ``` ## Filter Filter 允许你为单个值定义约束。 **示例**(使用 Filter 强制取值范围) ```ts import { Schema } from "effect" const MyNumber = Schema.Finite.check( Schema.isBetween({ minimum: 1, maximum: 10 }), ) // Successful creation const n = MyNumber.make(5) // This will throw an error because the number is outside the valid range MyNumber.make(20) /* throws Expected a value between 1 and 10 */ // Skips checks MyNumber.make(20, { disableChecks: true }) ``` ## Branded Type Branded Schema 会为值添加元数据,从而赋予它更具体的类型,同时仍保留其原始类型。 **示例**(创建 Branded 值) ```ts import { Schema } from "effect" const BrandedNumberSchema = Schema.Finite.pipe( Schema.check(Schema.isBetween({ minimum: 1, maximum: 10 })), Schema.brand("MyNumber"), ) // Successful creation const n = BrandedNumberSchema.make(5) // This will throw an error because the number is outside the valid range BrandedNumberSchema.make(20) /* throws Expected a value between 1 and 10 */ // Skips checks BrandedNumberSchema.make(20, { disableChecks: true }) ``` 在使用默认构造器时,理解它们产出的值的类型会很有帮助。 例如,在 `BrandedNumberSchema` 示例中,构造器的返回类型是 `number & Brand<"MyNumber">`。这表明得到的值是一个带有额外 branding 信息 `"MyNumber"` 的 `number`。 这种行为与 Filter 示例形成对比:后者的返回类型就是 `number`。Branding 会增加一层额外的类型信息,有助于更有效地识别和处理你的数据。 ## 构造器中的错误处理 当无效的构造器输入属于异常情况时,`make` 是合适的。如果失败是预期之内的,请改用 `makeOption` 或 `makeEffect`。 `makeOption` 在成功时返回 `Option.some`,在遇到 Schema 问题时返回 `Option.none`。当你需要完整的 `SchemaError` 时,请使用 `makeEffect`。 **示例**(不抛出异常地进行构造) ```ts import { Option, Schema } from "effect" const schema = Schema.FiniteFromString.check( Schema.isBetween({ minimum: 1, maximum: 10 }), ) schema.makeOption(5) // => Option.some(5) schema.makeOption(20) // => Option.none() // Effect.Effect const safely = schema.makeEffect(20) ``` ## 设置默认值 在创建对象时,你可能希望为某些字段指定默认值,以简化对象的构造。`Schema.withConstructorDefault` 函数让你可以处理默认值,从而使这些字段在默认构造器中变为可选。 **示例**(包含必填字段的 Struct) 在这个示例中,创建新实例时所有字段都是必填的。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Finite, }) // Both name and age must be provided console.log(Person.make({ name: "John", age: 30 })) /* Output: { name: 'John', age: 30 } */ ``` **示例**(带默认值的 Struct) 这里,`age` 字段是可选的,因为它有默认值 `0`。 ```ts import { Effect, Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Finite.pipe(Schema.withConstructorDefault(Effect.succeed(0))), }) // The age field is optional and defaults to 0 console.log(Person.make({ name: "John" })) /* Output: { name: 'John', age: 0 } */ console.log(Person.make({ name: "John", age: 30 })) /* Output: { name: 'John', age: 30 } */ ``` ### 嵌套默认值 构造器默认值可以穿过嵌套 Schema 组合生效。内层默认值会在外层字段的值被提供或取默认值之后再解析。 **示例**(解析嵌套默认值) ```ts import { Effect, Schema } from "effect" const Config = Schema.Struct({ web: Schema.Struct({ application_url: Schema.String.pipe( Schema.withConstructorDefault(Effect.succeed("http://localhost")), ), application_port: Schema.Finite, }).pipe( Schema.withConstructorDefault(Effect.succeed({ application_port: 3000 })), ), }) console.log(Config.make({})) /* Output: { web: { application_url: 'http://localhost', application_port: 3000 } } */ ``` ### 默认值的惰性求值 默认值是惰性求值的:每次调用构造器时,都会生成一个新的默认值实例。 **示例**(默认值的惰性求值) 在这个示例中,`timestamp` 字段会为每个实例生成一个新值。 ```ts import { Effect, Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Finite.pipe(Schema.withConstructorDefault(Effect.succeed(0))), timestamp: Schema.Finite.pipe( Schema.withConstructorDefault(Effect.sync(() => new Date().getTime())), ), }) console.log(Person.make({ name: "name1" })) /* Example Output: { age: 0, timestamp: 1714232909221, name: 'name1' } */ console.log(Person.make({ name: "name2" })) /* Example Output: { age: 0, timestamp: 1714232909227, name: 'name2' } */ ``` ### 跨 Schema 复用默认值 默认值也是「可移植的」:如果你在另一个 Schema 中复用同一个属性签名,该默认值会被一并带过去。 **示例**(在另一个 Schema 中复用默认值) ```ts import { Effect, Schema } from "effect" const Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Finite.pipe(Schema.withConstructorDefault(Effect.succeed(0))), timestamp: Schema.Finite.pipe( Schema.withConstructorDefault(Effect.sync(() => new Date().getTime())), ), }) const AnotherSchema = Schema.Struct({ foo: Schema.String, age: Person.fields.age, }) console.log(AnotherSchema.make({ foo: "bar" })) /* Output: { foo: 'bar', age: 0 } */ ``` ### 在 Class 中使用默认值 在使用 `Class` API 时也可以应用默认值,从而确保基于 Class 的 Schema 之间保持一致。 **示例**(Class 中的默认值) ```ts import { Effect, Schema } from "effect" class Person extends Schema.Class("Person")({ name: Schema.NonEmptyString, age: Schema.Finite.pipe(Schema.withConstructorDefault(Effect.succeed(0))), timestamp: Schema.Finite.pipe( Schema.withConstructorDefault(Effect.sync(() => new Date().getTime())), ), }) {} console.log(new Person({ name: "name1" })) /* Example Output: Person { age: 0, timestamp: 1714400867208, name: 'name1' } */ console.log(new Person({ name: "name2" })) /* Example Output: Person { age: 0, timestamp: 1714400867215, name: 'name2' } */ ``` --- # Effect 数据类型 > 为 Option、Result、Exit、Effect 集合、Duration、Redacted 值以及配置定义 schema。 Effect 为其运行时数据类型提供了对应的 schema,包括 `Option`、`Result`、`Exit`、hash 集合、`Duration` 和 `Redacted`。 这些 schema 要求两侧都是相应的运行时值。它们内部的 schema 仍然可以转换其中包含的值。当你需要一种与 JSON 兼容的表示形式时,可以用 `Schema.toCodecJson` 派生出来。 **示例**(运行时值及其 JSON 表示形式) ```ts import { Option, Schema } from "effect" const RuntimeOption = Schema.Option(Schema.FiniteFromString) Schema.decodeUnknownSync(RuntimeOption)(Option.some("1")) // => Option.some(1) Schema.encodeSync(RuntimeOption)(Option.some(1)) // => Option.some("1") const JsonOption = Schema.toCodecJson(RuntimeOption) Schema.decodeUnknownSync(JsonOption)({ _tag: "Some", value: "1" }) // => Option.some(1) Schema.encodeSync(JsonOption)(Option.some(1)) // => { _tag: "Some", value: "1" } ``` ## Config 使用 `Config.schema` 可以通过 schema 读取并解码配置。provider 提供其编码后的表示形式,得到的 `Config` 则产出该 schema 的 `Type`。 **示例**(读取结构化配置) ```ts import { Config, ConfigProvider, Effect, Schema } from "effect" const DatabaseConfig = Config.schema( Schema.Struct({ host: Schema.String, port: Schema.Finite, }), "database", ) const provider = ConfigProvider.fromUnknown({ database: { host: "localhost", port: 5432, }, }) Effect.runSync(DatabaseConfig.parse(provider)) // => { host: "localhost", port: 5432 } ``` 关于配置 provider、嵌套、默认值和密钥,请参见[配置](/docs/v4/configuration/)。 ## Option `Schema.Option(value)` 描述 `Option` 值,并把 `value` 应用到 `Some` 的内容上。 **示例**(转换 Option 的值) ```ts import { Option, Schema } from "effect" const schema = Schema.Option(Schema.FiniteFromString) // Option -> Option Schema.decodeUnknownSync(schema)(Option.some("1")) // => Option.some(1) // Option -> Option Schema.encodeSync(schema)(Option.some(1)) // => Option.some("1") Schema.decodeUnknownSync(schema)(Option.none()) // => Option.none() ``` ### 从可空值与可选值得到 Option 以下 schema 会把常见的可空(nullable)和可选(optional)表示形式转换为 `Option` 值: | Schema | 会被解码为 `None` 的值 | `None` 的默认编码 | | -------------------------- | --------------------------------- | ----------------- | | `OptionFromUndefinedOr` | `undefined` | `undefined` | | `OptionFromNullOr` | `null` | `null` | | `OptionFromNullishOr` | `null` 或 `undefined` | `undefined` | | `OptionFromOptionalKey` | 缺失的属性 | 缺失的属性 | | `OptionFromOptional` | 缺失的属性或 `undefined` | 缺失的属性 | | `OptionFromOptionalNullOr` | 缺失的属性、`null` 或 `undefined` | 缺失的属性 | `OptionFromNullishOr` 接受一个取值为 `null` 或 `undefined` 的 `onNoneEncoding` 选项。`OptionFromOptionalNullOr` 接受 `"omit"`、`null` 或 `undefined`。 **示例**(把可选属性映射为 Option) ```ts import { Option, Schema } from "effect" const Profile = Schema.Struct({ nickname: Schema.OptionFromOptionalKey(Schema.String), }) Schema.decodeUnknownSync(Profile)({}) // => { nickname: Option.none() } Schema.decodeUnknownSync(Profile)({ nickname: "Ada" }) // => { nickname: Option.some("Ada") } Schema.encodeSync(Profile)({ nickname: Option.none() }) // => {} ``` **示例**(把 nullish 值映射为 Option) ```ts import { Option, Schema } from "effect" const schema = Schema.OptionFromNullishOr(Schema.FiniteFromString, { onNoneEncoding: null, }) Schema.decodeUnknownSync(schema)(undefined) // => Option.none() Schema.decodeUnknownSync(schema)(null) // => Option.none() Schema.decodeUnknownSync(schema)("1") // => Option.some(1) Schema.encodeSync(schema)(Option.none()) // => null ``` ## Result `Schema.Result(success, failure)` 描述 `Result` 值,并分别转换成功通道与失败通道。 **示例**(转换 Result 值) ```ts import { Result, Schema } from "effect" const schema = Schema.Result(Schema.FiniteFromString, Schema.Trim) Schema.decodeUnknownSync(schema)(Result.succeed("1")) // => Result.succeed(1) Schema.decodeUnknownSync(schema)(Result.fail(" error ")) // => Result.fail("error") Schema.encodeSync(schema)(Result.succeed(1)) // => Result.succeed("1") ``` 其默认的 JSON 表示形式使用 `{ _tag: "Success", success }` 和 `{ _tag: "Failure", failure }`。 **示例**(Result 的 JSON 形式) ```ts import { Result, Schema } from "effect" const schema = Schema.toCodecJson( Schema.Result(Schema.FiniteFromString, Schema.Trim), ) Schema.decodeUnknownSync(schema)({ _tag: "Success", success: "1" }) // => Result.succeed(1) Schema.encodeSync(schema)(Result.fail("error")) // => { _tag: "Failure", failure: "error" } ``` ## Exit `Schema.Exit(success, failure, defect)` 描述 `Exit` 值。它把提供的 schema 分别应用到成功值、预期失败和 defect 上。 **示例**(转换 Exit 值) ```ts import { Exit, Schema } from "effect" const schema = Schema.Exit( Schema.FiniteFromString, Schema.Trim, Schema.Defect(), ) Schema.decodeUnknownSync(schema)(Exit.succeed("1")) // => Exit.succeed(1) Schema.decodeUnknownSync(schema)(Exit.fail(" error ")) // => Exit.fail("error") Schema.encodeSync(schema)(Exit.succeed(1)) // => Exit.succeed("1") ``` 其 JSON 表示形式在成功时使用 `{ _tag: "Success", value }`,在失败时使用 `{ _tag: "Failure", cause }`。 **示例**(Exit 的 JSON 形式) ```ts import { Exit, Schema } from "effect" const schema = Schema.toCodecJson( Schema.Exit(Schema.FiniteFromString, Schema.String, Schema.Defect()), ) Schema.decodeUnknownSync(schema)({ _tag: "Success", value: "1" }) // => Exit.succeed(1) Schema.encodeSync(schema)(Exit.fail("not found")) // => { _tag: "Failure", cause: [{ _tag: "Fail", error: "not found" }] } ``` `Schema.Defect()` 会把与 JSON 兼容的 defect 数据转换回 defect。带有 `name`、`message` 以及可选 `stack` 的对象会被重建为 JavaScript 错误。 ## Collections Effect 集合的 schema 要求两侧都是集合值,并在解码和编码期间应用元素 schema。它们的 JSON codec 使用值数组或键值对条目数组。 ### ReadonlySet **示例**(ReadonlySet 的值与其 JSON 形式) ```ts import { Schema } from "effect" const RuntimeSet = Schema.ReadonlySet(Schema.FiniteFromString) Schema.decodeUnknownSync(RuntimeSet)(new Set(["1", "2"])) // => new Set([1, 2]) Schema.encodeSync(RuntimeSet)(new Set([1, 2])) // => new Set(["1", "2"]) const JsonSet = Schema.toCodecJson(RuntimeSet) Schema.decodeUnknownSync(JsonSet)(["1", "2"]) // => new Set([1, 2]) Schema.encodeSync(JsonSet)(new Set([1, 2])) // => ["1", "2"] ``` ### ReadonlyMap **示例**(ReadonlyMap 的值与其 JSON 形式) ```ts import { Schema } from "effect" const RuntimeMap = Schema.ReadonlyMap(Schema.String, Schema.FiniteFromString) Schema.decodeUnknownSync(RuntimeMap)(new Map([["a", "1"]])) // => new Map([["a", 1]]) Schema.encodeSync(RuntimeMap)(new Map([["a", 1]])) // => new Map([["a", "1"]]) const JsonMap = Schema.toCodecJson(RuntimeMap) Schema.decodeUnknownSync(JsonMap)([["a", "1"]]) // => new Map([["a", 1]]) Schema.encodeSync(JsonMap)(new Map([["a", 1]])) // => [["a", "1"]] ``` ### HashSet **示例**(HashSet 的值与其 JSON 形式) ```ts import { HashSet, Schema } from "effect" const RuntimeSet = Schema.HashSet(Schema.FiniteFromString) Schema.decodeUnknownSync(RuntimeSet)(HashSet.fromIterable(["1", "2"])) // => HashSet.fromIterable([1, 2]) const JsonSet = Schema.toCodecJson(RuntimeSet) Schema.decodeUnknownSync(JsonSet)(["1", "2"]) // => HashSet.fromIterable([1, 2]) Schema.encodeSync(JsonSet)(HashSet.fromIterable([1, 2])) // => ["1", "2"] ``` ### HashMap **示例**(HashMap 的值与其 JSON 形式) ```ts import { HashMap, Schema } from "effect" const RuntimeMap = Schema.HashMap(Schema.String, Schema.FiniteFromString) Schema.decodeUnknownSync(RuntimeMap)(HashMap.make(["a", "1"])) // => HashMap.make(["a", 1]) const JsonMap = Schema.toCodecJson(RuntimeMap) Schema.decodeUnknownSync(JsonMap)([["a", "1"]]) // => HashMap.make(["a", 1]) Schema.encodeSync(JsonMap)(HashMap.make(["a", 1])) // => [["a", "1"]] ``` ## Duration `Schema.Duration` 校验已有的 `Duration` 值。当编码值是字符串、毫秒数或纳秒 bigint 时,请使用转换 schema。 | Schema | 编码类型 | 类型 | | --------------------------- | ---------- | ---------- | | `Schema.Duration` | `Duration` | `Duration` | | `Schema.DurationFromString` | `string` | `Duration` | | `Schema.DurationFromMillis` | `number` | `Duration` | | `Schema.DurationFromNanos` | `bigint` | `Duration` | **示例**(解码 Duration) ```ts import { Duration, Schema } from "effect" Schema.decodeUnknownSync(Schema.Duration)(Duration.seconds(2)) // => Duration.seconds(2) Schema.decodeUnknownSync(Schema.DurationFromString)("2 seconds") // => Duration.seconds(2) Schema.encodeSync(Schema.DurationFromString)(Duration.seconds(2)) // => "2000 millis" Schema.decodeUnknownSync(Schema.DurationFromMillis)(2000) // => Duration.seconds(2) Schema.encodeSync(Schema.DurationFromMillis)(Duration.seconds(2)) // => 2000 Schema.decodeUnknownSync(Schema.DurationFromNanos)(2_000_000_000n) // => Duration.nanos(2_000_000_000n) ``` `Schema.Duration` 的默认 JSON 表示形式是一个带标签对象,它保留毫秒、纳秒以及无限时长。 **示例**(Duration 的 JSON 形式) ```ts import { Duration, Schema } from "effect" const schema = Schema.toCodecJson(Schema.Duration) Schema.encodeSync(schema)(Duration.seconds(2)) // => { _tag: "Millis", value: 2000 } Schema.decodeUnknownSync(schema)({ _tag: "Millis", value: 2000 }) // => Duration.seconds(2) ``` ## Redacted `Schema.Redacted(value)` 校验已有的 `Redacted` 值,并把 `value` 应用到其隐藏的内容上。若要解码原始值并将其包装为 `Redacted`,请使用 `Schema.RedactedFromValue(value)`。 **示例**(把原始值解码为 Redacted) ```ts import { Redacted, Schema } from "effect" const schema = Schema.RedactedFromValue(Schema.Trim) const secret = Schema.decodeUnknownSync(schema)(" secret ") Redacted.value(secret) // => "secret" Schema.encodeSync(schema)(secret) // => "secret" ``` `Schema.Redacted(value)` 的默认 JSON 表示形式会暴露编码后的内部值。如果某个 redacted 值绝不能被序列化,请设置 `disallowJsonEncode: true`。 **示例**(阻止 JSON 编码) ```ts import { Redacted, Schema } from "effect" const Secret = Schema.Redacted(Schema.String, { label: "Secret", disallowJsonEncode: true, }) const JsonSecret = Schema.toCodecJson(Secret) // Encoding fails instead of exposing "password" Schema.encodeSync(JsonSecret)(Redacted.make("password", { label: "Secret" })) ``` --- # 从 Schema 派生 Equivalence > 从 schema 结构派生并定制等价性检查。 `Schema.toEquivalence` 会为某个 schema 的 `Type` 派生出一个 [Equivalence](/docs/v4/behaviour/equivalence/)。嵌套值会按照对应的嵌套 schema 进行比较。 **示例**(比较 Struct) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, tags: Schema.Array(Schema.String), }) const equivalence = Schema.toEquivalence(Person) equivalence( { name: "John", age: 23, tags: ["admin"] }, { name: "John", age: 23, tags: ["admin"] }, ) // => true equivalence( { name: "John", age: 23, tags: ["admin"] }, { name: "John", age: 24, tags: ["admin"] }, ) // => false ``` 对于 struct,只有 schema 中描述的字段会参与派生出的 equivalence。数组和嵌套的 struct 会递归地比较。 ## 宽泛的 Schema `Schema.Any`、`Schema.Unknown`、`Schema.ObjectKeyword` 以及空的 `Schema.Struct({})` 都使用 `Equal.equals`。`Equal.equals` 会为对象和数组执行深层结构比较,而不是默认采用引用相等。 **示例**(空 Struct 的结构相等性) ```ts import { Schema } from "effect" const equivalence = Schema.toEquivalence(Schema.Struct({})) equivalence({ nested: [1, 2] }, { nested: [1, 2] }) // => true ``` ## 声明 声明(declaration)对于自动派生而言是不透明的。当声明需要 `Equal.equals` 之外的行为时,请提供一个 `toEquivalence` 注解。 **示例**(为一个类定义 Equivalence) ```ts import { Schema } from "effect" class User { constructor( readonly id: number, readonly displayName: string, ) {} } const UserSchema = Schema.instanceOf(User, { toEquivalence: () => (self, that) => self.id === that.id, }) const equivalence = Schema.toEquivalence(UserSchema) equivalence(new User(1, "Alice"), new User(1, "Alicia")) // => true ``` 参数化的声明会在注解回调中接收为每个类型参数派生出的 equivalence。 ## 覆盖 使用 `Schema.overrideToEquivalence` 可以替换为已有 schema 派生出的 equivalence。 **示例**(按单个字段比较 Struct) ```ts import { Schema } from "effect" const User = Schema.Struct({ id: Schema.Finite, displayName: Schema.String, }).pipe(Schema.overrideToEquivalence(() => (self, that) => self.id === that.id)) const equivalence = Schema.toEquivalence(User) equivalence({ id: 1, displayName: "Alice" }, { id: 1, displayName: "Alicia" }) // => true equivalence({ id: 1, displayName: "Alice" }, { id: 2, displayName: "Alice" }) // => false ``` --- # 错误 Formatter > 将 schema issue 格式化为可读字符串,或格式化为 Standard Schema V1 的 issue 数组。 `SchemaIssue` 模块提供两个内置 Formatter:一个人类可读的字符串 Formatter,以及一个结构化的 Standard Schema V1 Formatter。 ## 默认字符串 Formatter `SchemaIssue.makeFormatterDefault()` 返回一个多行字符串。`SchemaError.message` 使用的就是这个 Formatter,因此大多数应用可以直接从错误中读取 message。 **示例**(解码时缺少属性) ```ts import { Result, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const decode = Schema.decodeUnknownResult(Person) const result = decode({}) if (Result.isFailure(result)) { console.error("Decoding failed:") console.error(result.failure.message) result.failure.message // => "Missing key\n at [\"name\"]" } /* Decoding failed: Missing key at ["name"] */ ``` 在这个示例中: - `["name"]` 指出导致错误的具体字段。 - `Missing key` 描述该 issue。 ### 处理多个错误 默认情况下,`Schema.decodeUnknownResult` 这类解码函数只报告第一个错误。要列出所有错误,请使用 `{ errors: "all" }` 选项。 **示例**(列出所有错误) ```ts import { Result, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const decode = Schema.decodeUnknownResult(Person, { errors: "all" }) const result = decode({}) if (Result.isFailure(result)) { console.error("Decoding failed:") console.error(result.failure.message) result.failure.message // => "Missing key\n at [\"name\"]\nMissing key\n at [\"age\"]" } /* Decoding failed: Missing key at ["name"] Missing key at ["age"] */ ``` ## Standard Schema V1 Formatter `SchemaIssue.makeFormatterStandardSchemaV1()` 返回一个 Standard Schema V1 的失败结果。每个叶子 issue 都会变成一个带有 `message` 和完整 `path` 的对象,因此该结果便于表单和其他结构化消费者使用。 **示例**(以数组格式表示单个错误) ```ts import { Result, Schema, SchemaIssue } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const decode = Schema.decodeUnknownResult(Person) const result = decode({}) if (Result.isFailure(result)) { console.error("Decoding failed:") console.error( SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues, ) SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues // => [{ path: ["name"], message: "Missing key" }] } /* Decoding failed: [ { path: [ 'name' ], message: 'Missing key' } ] */ ``` 在这个示例中: - `path`:指定错误在数据中的位置(`['name']`)。 - `message`:描述该 issue(`'Missing key'`)。 ### 处理多个错误 默认情况下,`Schema.decodeUnknownResult` 这类解码函数只报告第一个错误。要列出所有错误,请使用 `{ errors: "all" }` 选项。 **示例**(列出所有错误) ```ts import { Result, Schema, SchemaIssue } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const decode = Schema.decodeUnknownResult(Person, { errors: "all" }) const result = decode({}) if (Result.isFailure(result)) { console.error("Decoding failed:") console.error( SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues, ) SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues // => [{ path: ["name"], message: "Missing key" }, { path: ["age"], message: "Missing key" }] } /* Decoding failed: [ { path: [ 'name' ], message: 'Missing key' }, { path: [ 'age' ], message: 'Missing key' } ] */ ``` ### 自定义消息 传入 `leafHook` 即可自定义终端 issue,而把其余情形委托给 `SchemaIssue.defaultLeafHook`。 **示例**(自定义 Missing key 的消息) ```ts import { Result, Schema, SchemaIssue } from "effect" const Person = Schema.Struct({ name: Schema.String, }) const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "MissingKey" ? "This field is required" : SchemaIssue.defaultLeafHook(issue), }) const result = Schema.decodeUnknownResult(Person)({}) if (Result.isFailure(result)) { formatter(result.failure.issue).issues // => [{ path: ["name"], message: "This field is required" }] } ``` ## React Hook Form 如果你在使用 React,`@hookform/resolvers` 为 React Hook Form 提供了一个 `effectTsResolver` 适配器。 安装配置与示例请参见 [`effect-ts` resolver 文档](https://github.com/react-hook-form/resolvers#effect-ts)。 --- # 错误消息 > 定制并强化 schema 解码的错误消息:默认消息、精炼消息与自定义消息。 ## 默认错误消息 默认情况下,`SchemaError` 会把问题格式化成一条简洁的消息,并在失败发生在嵌套位置时带上路径(见[错误格式化器](/docs/v4/schema/error-formatters))。 例如,当必需的属性缺失、或值的类型不对时,消息会说明**期望是什么**以及**失败发生在哪里**。 **示例**(类型不匹配) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) Schema.decodeUnknownSync(Person)(null) // throws: SchemaError: Expected object ``` **示例**(缺少属性) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) Schema.decodeUnknownSync(Person)({}, { errors: "all" }) /* throws: SchemaError: Missing key at ["name"] Missing key at ["age"] */ ``` **示例**(属性类型不正确) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) Schema.decodeUnknownSync(Person)({ name: null, age: "age" }, { errors: "all" }) /* throws: SchemaError: Expected string at ["name"] Expected number at ["age"] */ ``` ### 用标识符让错误消息更清楚 当一个 schema 有多个字段或嵌套结构时,默认的错误消息可能变得过于复杂冗长。 为此,你可以借助 `identifier`、`title`、`description` 等注解,让消息更清晰、更简短。 **示例**(用标识符提升可读性) ```ts import { Schema } from "effect" const Name = Schema.String.annotate({ identifier: "Name" }) const Age = Schema.Finite.annotate({ identifier: "Age" }) const Person = Schema.Struct({ name: Name, age: Age, }).annotate({ identifier: "Person" }) Schema.decodeUnknownSync(Person)(null) /* throws: SchemaError: Expected Person */ Schema.decodeUnknownSync(Person)({}, { errors: "all" }) /* throws: SchemaError: Missing key at ["name"] Missing key at ["age"] */ Schema.decodeUnknownSync(Person)({ name: null, age: null }, { errors: "all" }) /* throws: SchemaError: Expected Name at ["name"] Expected Age at ["age"] */ ``` ### 检查(Check) 检查只在基础 schema 接受输入之后才运行。因此"基础类型失败"与"检查失败"会得到不同的消息。 **示例**(基础类型错误与检查错误) ```ts import { Schema } from "effect" const Name = Schema.String.check( Schema.isNonEmpty({ expected: "a non-empty name" }), ) const Person = Schema.Struct({ name: Name, }).annotate({ identifier: "Person" }) // The base string schema rejects null before the check runs Schema.decodeUnknownSync(Person)({ name: null }) /* throws: SchemaError: Expected string at ["name"] */ // The input is a string, so the non-empty check runs and fails Schema.decodeUnknownSync(Person)({ name: "" }) /* throws: SchemaError: Expected a non-empty name at ["name"] */ ``` ### 变换(Transformation) 在不同类型或格式之间做变换时偶尔也会出错。系统提供结构化的错误消息来指明错误发生在哪一侧: - **编码侧失败(Encoded Side Failure)**:这类错误通常表示变换的**输入**不符合期望的初始类型或格式。例如期望 `string` 却收到 `null`。 - **变换过程失败(Transformation Process Failure)**:当变换逻辑本身失败时出现,例如输入不满足变换函数里指定的条件。 - **类型侧失败(Type Side Failure)**:当变换的**输出**不满足解码侧 schema 的要求时出现,例如变换后的值没通过后续校验或条件。 **示例**(变换错误) ```ts import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect" const schema = Schema.String.pipe( Schema.decodeTo(Schema.String.check(Schema.isMinLength(2)), { decode: SchemaGetter.transformOrFail((s) => s.length > 0 ? Effect.succeed(s) : Effect.fail(new SchemaIssue.InvalidValue()), ), encode: SchemaGetter.passthrough(), }), ) // Encoded side failure Schema.decodeUnknownSync(schema)(null) /* throws: SchemaError: Expected string */ // transformation failure Schema.decodeUnknownSync(schema)("") /* throws: SchemaError: Expected a valid value */ // Type side failure Schema.decodeUnknownSync(schema)("a") /* throws: SchemaError: Expected a value with a length of at least 2 */ ``` ## 自定义错误消息 用 `message` 注解替换某个 schema 节点或检查的默认消息。 ```ts type MessageAnnotation = string ``` **示例**(给 string schema 添加自定义错误消息) ```ts import { Schema } from "effect" // Define a string schema without a custom message const MyString = Schema.String // Attempt to decode `null`, resulting in a default error message Schema.decodeUnknownSync(MyString)(null) /* throws: SchemaError: Expected string */ // Define a string schema with a custom error message const MyStringWithMessage = Schema.String.annotate({ message: "not a string", }) // Decode with the custom schema, showing the new error message Schema.decodeUnknownSync(MyStringWithMessage)(null) /* throws: SchemaError: not a string */ ``` **示例**(联合 schema 的自定义错误消息) ```ts import { Schema } from "effect" // Define a union schema without a custom message const MyUnion = Schema.Union([Schema.String, Schema.Finite]) // Decode `null`, resulting in default union error messages Schema.decodeUnknownSync(MyUnion)(null) /* throws: SchemaError: Expected string | number */ // Define a union schema with a custom message const MyUnionWithMessage = Schema.Union([ Schema.String, Schema.Finite, ]).annotate({ message: "Please provide a string or a number", }) // Decode with the custom schema, showing the new error message Schema.decodeUnknownSync(MyUnionWithMessage)(null) /* throws: SchemaError: Please provide a string or a number */ ``` ### 消息的通用准则 把 `message` 挂到**你想替换其失败消息的那个节点**上。针对某个具体检查,就把注解传给该检查的构造函数; 在 `.check(...)` **之后**再注解,则作用于它的**最后一个**检查。如果是别的内部节点失败,就会使用那个节点自己的消息或默认格式。 ### 标量 schema **示例**(标量 schema 的简单自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String.annotate({ message: "my custom message", }) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) e.message // => "my custom message" } ``` ### 检查 下面这个例子给**检查链里的最后一个检查**设置了自定义消息。该自定义消息只在 `isMaxLength` 失败时才会用到;其它情况仍使用默认消息。 **示例**(给最后一个检查设置自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(2), ).annotate({ // This message is displayed only if the last filter (`isMaxLength`) fails message: "my custom message", }) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) e.message // => "Expected string" } try { decode("") } catch (e: any) { console.log(e.message) e.message // => "Expected a value with a length of at least 1" } try { decode("abc") } catch (e: any) { console.log(e.message) e.message // => "my custom message" } ``` 当多个检查都带自定义消息时,由**第一个失败的检查**提供消息: **示例**(多个检查的自定义消息) ```ts import { Schema } from "effect" const MyString = Schema.String // This message is displayed only if a non-String is passed as input .annotate({ message: "String custom message" }) .check( // This message is displayed only if the filter `isMinLength` fails Schema.isMinLength(1, { message: "minLength custom message" }), // This message is displayed only if the filter `isMaxLength` fails Schema.isMaxLength(2, { message: "maxLength custom message" }), ) const decode = Schema.decodeUnknownSync(MyString) try { decode(null) } catch (e: any) { console.log(e.message) e.message // => "String custom message" } try { decode("") } catch (e: any) { console.log(e.message) e.message // => "minLength custom message" } try { decode("abc") } catch (e: any) { console.log(e.message) e.message // => "maxLength custom message" } ``` ### 变换 在下面的例子里,`IntFromString` 是一个把字符串转成整数的变换 schema。它针对不同场景给出特定的校验消息。 **示例**(字符串转整数的自定义错误消息) ```ts import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect" const IntFromString = Schema.String // This message is displayed only if the input is not a string .annotate({ message: "please enter a string" }) .pipe( Schema.decodeTo( // This message is displayed only if the input can be converted // to a number but it's not an integer Schema.Int.annotate({ message: "please enter an integer" }), { decode: SchemaGetter.transformOrFail((s) => { const n = Number(s) return Number.isNaN(n) ? Effect.fail( // This message is displayed only if the input // cannot be converted to a number new SchemaIssue.InvalidValue({ message: "please enter a parseable string", }), ) : Effect.succeed(n) }), encode: SchemaGetter.transform((n) => String(n)), }, ), ) const decode = Schema.decodeUnknownSync(IntFromString) try { decode(null) } catch (e: any) { console.log(e.message) e.message // => "please enter a string" } try { decode("1.2") } catch (e: any) { console.log(e.message) e.message // => "please enter an integer" } try { decode("not a number") } catch (e: any) { console.log(e.message) e.message // => "please enter a parseable string" } ``` ### 复合 schema 相比 `string`、`number` 这类简单标量值,自定义消息在处理**复杂 schema** 时格外好用。 比如一个由嵌套结构组成的 schema:结构体里含有一个由其它结构体构成的数组。 下面的例子展示了在处理这类嵌套结构的解码错误时,默认消息的优势: **示例**(嵌套 schema 中的自定义错误消息) ```ts import { Schema } from "effect" const schema = Schema.Struct({ outcomes: Schema.Array( Schema.Struct({ id: Schema.String, text: Schema.String.annotate({ message: "error_invalid_outcome_type", }).check( Schema.isMinLength(1, { message: "error_required_field" }), Schema.isMaxLength(50, { message: "error_max_length_field", }), ), }), ).check(Schema.isMinLength(1, { message: "error_min_length_field" })), }) Schema.decodeUnknownSync(schema, { errors: "all" })({ outcomes: [], }) /* throws SchemaError: error_min_length_field at ["outcomes"] */ Schema.decodeUnknownSync(schema, { errors: "all" })({ outcomes: [ { id: "1", text: "" }, { id: "2", text: "this one is valid" }, { id: "3", text: "1234567890".repeat(6) }, ], }) /* throws SchemaError: error_required_field at ["outcomes"][0]["text"] error_max_length_field at ["outcomes"][2]["text"] */ ``` ### 缺失字段的消息 你可以用 `messageMissingKey` 注解为**缺失的字段**或**元组元素**提供自定义消息。 **示例**(缺失属性的自定义消息) 下面这个例子为 `Person` schema 中缺失的 `name` 属性定义了自定义消息。 ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String.pipe( // Custom message if "name" is missing Schema.annotateKey({ messageMissingKey: "Name is required" }), ), }) Schema.decodeUnknownSync(Person)({}) /* throws: SchemaError: Name is required at ["name"] */ ``` **示例**(缺失元组元素的自定义消息) 这里,`Point` 元组 schema 中的每个元素在缺失时都有各自的自定义消息。 ```ts import { Schema } from "effect" const Point = Schema.Tuple([ Schema.Finite.pipe( // Message if X is missing Schema.annotateKey({ messageMissingKey: "X coordinate is required" }), ), Schema.Finite.pipe( // Message if Y is missing Schema.annotateKey({ messageMissingKey: "Y coordinate is required" }), ), ]) Schema.decodeUnknownSync(Point)([], { errors: "all" }) /* throws: SchemaError: X coordinate is required at [0] Y coordinate is required at [1] */ ``` --- # 过滤器 > 通过过滤器定义自定义校验逻辑,在基础类型检查之外增强数据校验能力。 开发者可以定义超出基础类型检查的自定义校验逻辑,从而更精细地控制数据如何被校验。 ## 声明过滤器 用 `Schema.makeFilter` 创建一个过滤器,然后用 `.check(...)` 把它加到 schema 上。谓词(predicate)会接收解码后的值,并返回它是否满足约束;当不满足时,还可以选择性地提供一个或多个 issue。 **示例**(定义一个最小字符串长度过滤器) ```ts import { Schema } from "effect" // Define a string schema with a filter to ensure the string // is at least 10 characters long const LongString = Schema.String.check( Schema.makeFilter( // Custom error message for strings shorter than 10 characters (s) => s.length >= 10 || "a string at least 10 characters long", ), ) // ┌─── string // ▼ type Type = typeof LongString.Type console.log(Schema.decodeUnknownSync(LongString)("a")) /* throws: SchemaError: a string at least 10 characters long */ ``` 注意,过滤器并不会改变 schema 的 `Type`: ```ts // ┌─── string // ▼ type Type = typeof LongString.Type ``` 过滤器在不修改 schema 底层类型的情况下,添加了额外的校验约束。 ## 谓词函数 过滤器中的谓词函数遵循如下结构: ```ts type Predicate = ( input: T, ast: SchemaAST.AST, options: SchemaAST.ParseOptions, ) => FilterOutput ``` 其中 ```ts type FilterIssue = | string | SchemaIssue.Issue | { readonly path: ReadonlyArray readonly issue: string | SchemaIssue.Issue } type FilterOutput = undefined | boolean | FilterIssue | ReadonlyArray ``` 过滤器的谓词可以返回几种不同类型的值,每种都会以不同方式影响校验: | 返回类型 | 行为 | | ---------------------------- | ------------------------------------------------------------------------ | | `true` 或 `undefined` | 数据满足过滤器的条件,通过校验。 | | `false` | 数据不满足条件,且不提供具体的错误信息。 | | `string` | 校验失败,提供的字符串被用作错误信息。 | | `SchemaIssue.Issue` | 校验失败,带有详细的错误结构,指明失败的位置与原因。 | | `FilterIssue` | 允许携带特定路径的更详细错误信息,提供更强的错误报告能力。 | | `ReadonlyArray` | 如果需要报告多个校验错误,可以返回一个 issue 数组。 | ## 添加注解 在 schema 中嵌入元数据(例如标识符、JSON Schema 规范与描述),有助于理解和分析 schema 的约束与用途。 **示例**(用注解添加元数据) ```ts import { Schema } from "effect" const LongString = Schema.String.check( Schema.makeFilter( (s) => s.length >= 10 ? undefined : "a string at least 10 characters long", { identifier: "LongString", toJsonSchema: () => ({ minLength: 10 }), description: "Lorem ipsum dolor sit amet, ...", }, ), ) console.log(Schema.decodeUnknownSync(LongString)("a")) /* throws: SchemaError: a string at least 10 characters long */ console.log(JSON.stringify(Schema.toJsonSchemaDocument(LongString), null, 2)) /* Output: { "dialect": "draft-2020-12", "schema": { "$ref": "#/$defs/LongString" }, "definitions": { "LongString": { "type": "string", "allOf": [ { "minLength": 10, "description": "Lorem ipsum dolor sit amet, ..." } ] } } } */ ``` ## 指定错误路径 在校验表单或结构化数据时,可以把特定的错误信息关联到特定的字段或路径上。这能增强错误报告能力,在与 [react-hook-form](https://react-hook-form.com/) 这类库集成时尤其有用。 **示例**(校验密码一致) ```ts import { Result, Schema, SchemaIssue } from "effect" const Password = Schema.Trim.check(Schema.isMinLength(2)) const MyForm = Schema.Struct({ password: Password, confirm_password: Password, }).check( // Add a filter to ensure that passwords match Schema.makeFilter((input) => input.password === input.confirm_password ? undefined : // Return an error message associated // with the "confirm_password" field { path: ["confirm_password"], issue: "Passwords do not match", }, ), ) const result = Schema.decodeUnknownResult(MyForm)({ password: "abc", confirm_password: "abd", // Confirm password does not match }) if (Result.isFailure(result)) { console.log( JSON.stringify( SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues, null, 2, ), ) SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues // => [{ path: ["confirm_password"], message: "Passwords do not match" }] } /* Output: [ { "path": [ "confirm_password" ], "message": "Passwords do not match" } ] */ ``` 在这个示例中,我们定义了一个带两个密码字段(`password` 和 `confirm_password`)的 `MyForm` schema。我们用 `Schema.makeFilter` 来检查两个密码是否一致。如果不一致,就会返回一个错误,并专门关联到 `confirm_password` 字段。这样更容易定位校验失败的确切位置。 错误通过 `SchemaIssue.makeFormatterStandardSchemaV1` 被格式化为 Standard Schema issue 数组,便于后续处理或传给表单库。 ## 报告多个错误 `Schema.makeFilter` API 支持一次性报告多个校验 issue,这在表单校验等场景(多个检查可能同时失败)下尤其有用。 **示例**(报告多个校验错误) ```ts import { Result, Schema, SchemaIssue } from "effect" const Password = Schema.Trim.check(Schema.isMinLength(2)) const OptionalString = Schema.optional(Schema.String) const MyForm = Schema.Struct({ password: Password, confirm_password: Password, name: OptionalString, surname: OptionalString, }).check( Schema.makeFilter((input) => { const issues: Array = [] // Check if passwords match if (input.password !== input.confirm_password) { issues.push({ path: ["confirm_password"], issue: "Passwords do not match", }) } // Ensure either name or surname is present if (!input.name && !input.surname) { issues.push({ path: ["surname"], issue: "Surname must be present if name is not present", }) } return issues }), ) const result = Schema.decodeUnknownResult(MyForm)({ password: "abc", confirm_password: "abd", // Confirm password does not match }) if (Result.isFailure(result)) { console.log( JSON.stringify( SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues, null, 2, ), ) SchemaIssue.makeFormatterStandardSchemaV1()(result.failure.issue).issues // => [{ path: ["confirm_password"], message: "Passwords do not match" }, { path: ["surname"], message: "Surname must be present if name is not present" }] } /* Output: [ { "path": [ "confirm_password" ], "message": "Passwords do not match" }, { "path": [ "surname" ], "message": "Surname must be present if name is not present" } ] */ ``` 在这个示例中,我们定义了一个 `MyForm` schema,包含用于密码校验的字段以及可选的 name/surname 字段。`Schema.makeFilter` 函数会检查密码是否一致,并确保 name 与 surname 至少提供了其一。只要任一校验失败,相应的错误信息就会关联到相关字段,并以结构化形式返回这两个错误。 ## 内置过滤器 ### 字符串过滤器 以下是 Schema 模块提供的一些实用的字符串过滤器: ```ts import { Schema } from "effect" // Specifies maximum length of a string Schema.String.check(Schema.isMaxLength(5)) // Specifies minimum length of a string Schema.String.check(Schema.isMinLength(5)) // Equivalent to isMinLength(1) Schema.String.check(Schema.isNonEmpty()) // or Schema.NonEmptyString // Specifies exact length of a string Schema.String.check(Schema.isLengthBetween(5, 5)) // Specifies a range for the length of a string Schema.String.check(Schema.isLengthBetween(2, 4)) // Matches a string against a regular expression pattern Schema.String.check(Schema.isPattern(/^[a-z]+$/)) // Ensures a string starts with a specific substring Schema.String.check(Schema.isStartsWith("prefix")) // Ensures a string ends with a specific substring Schema.String.check(Schema.isEndsWith("suffix")) // Checks if a string includes a specific substring Schema.String.check(Schema.isIncludes("substring")) // Validates that a string has no leading or trailing whitespaces Schema.String.check(Schema.isTrimmed()) // Validates that a string is entirely in lowercase Schema.String.check(Schema.isLowercased()) // Validates that a string is entirely in uppercase Schema.String.check(Schema.isUppercased()) // Validates that a string is capitalized Schema.String.check(Schema.isCapitalized()) // Validates that a string is uncapitalized Schema.String.check(Schema.isUncapitalized()) ``` ### 数字过滤器 以下是 Schema 模块提供的一些实用的数字过滤器: ```ts import { Schema } from "effect" // Specifies a number greater than 5 Schema.Finite.check(Schema.isGreaterThan(5)) // Specifies a number greater than or equal to 5 Schema.Finite.check(Schema.isGreaterThanOrEqualTo(5)) // Specifies a number less than 5 Schema.Finite.check(Schema.isLessThan(5)) // Specifies a number less than or equal to 5 Schema.Finite.check(Schema.isLessThanOrEqualTo(5)) // Specifies a number between -2 and 2, inclusive Schema.Finite.check(Schema.isBetween({ minimum: -2, maximum: 2 })) // Specifies that the value must be an integer Schema.Finite.check(Schema.isInt()) // or Schema.Int // Specifies a positive number (> 0) Schema.Finite.check(Schema.isGreaterThan(0)) // Specifies a non-negative number (>= 0) Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) // A non-negative integer Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)) // Specifies a negative number (< 0) Schema.Finite.check(Schema.isLessThan(0)) // Specifies a non-positive number (<= 0) Schema.Finite.check(Schema.isLessThanOrEqualTo(0)) // Specifies a number that is evenly divisible by 5 Schema.Finite.check(Schema.isMultipleOf(5)) // A 8-bit unsigned integer (0 to 255) Schema.Finite.check( Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 255 }), ) ``` ### ReadonlyArray 过滤器 以下是 Schema 模块提供的一些实用的数组过滤器: ```ts import { Schema } from "effect" // Specifies the maximum number of items in the array Schema.Array(Schema.Finite).check(Schema.isMaxLength(2)) // Specifies the minimum number of items in the array Schema.Array(Schema.Finite).check(Schema.isMinLength(2)) // Specifies the exact number of items in the array Schema.Array(Schema.Finite).check(Schema.isLengthBetween(2, 2)) ``` ### 日期过滤器 ```ts import { Schema } from "effect" // Specifies a valid date (rejects values like `new Date("Invalid Date")`) Schema.Date // Specifies a date greater than the current date Schema.Date.check(Schema.isGreaterThanDate(new Date())) // Specifies a date greater than or equal to the current date Schema.Date.check(Schema.isGreaterThanOrEqualToDate(new Date())) // Specifies a date less than the current date Schema.Date.check(Schema.isLessThanDate(new Date())) // Specifies a date less than or equal to the current date Schema.Date.check(Schema.isLessThanOrEqualToDate(new Date())) // Specifies a date between two dates Schema.Date.check( Schema.isBetweenDate({ minimum: new Date(0), maximum: new Date() }), ) ``` ### BigInt 过滤器 以下是 Schema 模块提供的一些实用的 `BigInt` 过滤器: ```ts import { Schema } from "effect" // Specifies a BigInt greater than 5 Schema.BigInt.check(Schema.isGreaterThanBigInt(5n)) // Specifies a BigInt greater than or equal to 5 Schema.BigInt.check(Schema.isGreaterThanOrEqualToBigInt(5n)) // Specifies a BigInt less than 5 Schema.BigInt.check(Schema.isLessThanBigInt(5n)) // Specifies a BigInt less than or equal to 5 Schema.BigInt.check(Schema.isLessThanOrEqualToBigInt(5n)) // Specifies a BigInt between -2n and 2n, inclusive Schema.BigInt.check(Schema.isBetweenBigInt({ minimum: -2n, maximum: 2n })) // Specifies a positive BigInt (> 0n) Schema.BigInt.check(Schema.isGreaterThanBigInt(0n)) // Specifies a non-negative BigInt (>= 0n) Schema.BigInt.check(Schema.isGreaterThanOrEqualToBigInt(0n)) // Specifies a negative BigInt (< 0n) Schema.BigInt.check(Schema.isLessThanBigInt(0n)) // Specifies a non-positive BigInt (<= 0n) Schema.BigInt.check(Schema.isLessThanOrEqualToBigInt(0n)) ``` ### BigDecimal 过滤器 以下是 Schema 模块提供的一些实用的 `BigDecimal` 过滤器: ```ts import { Schema, BigDecimal } from "effect" // Specifies a BigDecimal greater than 5 Schema.BigDecimal.check( Schema.isGreaterThanBigDecimal(BigDecimal.fromNumberUnsafe(5)), ) // Specifies a BigDecimal greater than or equal to 5 Schema.BigDecimal.check( Schema.isGreaterThanOrEqualToBigDecimal(BigDecimal.fromNumberUnsafe(5)), ) // Specifies a BigDecimal less than 5 Schema.BigDecimal.check( Schema.isLessThanBigDecimal(BigDecimal.fromNumberUnsafe(5)), ) // Specifies a BigDecimal less than or equal to 5 Schema.BigDecimal.check( Schema.isLessThanOrEqualToBigDecimal(BigDecimal.fromNumberUnsafe(5)), ) // Specifies a BigDecimal between -2 and 2, inclusive Schema.BigDecimal.check( Schema.isBetweenBigDecimal({ minimum: BigDecimal.fromNumberUnsafe(-2), maximum: BigDecimal.fromNumberUnsafe(2), }), ) // Specifies a positive BigDecimal (> 0) Schema.BigDecimal.check( Schema.isGreaterThanBigDecimal(BigDecimal.fromNumberUnsafe(0)), ) // Specifies a non-negative BigDecimal (>= 0) Schema.BigDecimal.check( Schema.isGreaterThanOrEqualToBigDecimal(BigDecimal.fromNumberUnsafe(0)), ) // Specifies a negative BigDecimal (< 0) Schema.BigDecimal.check( Schema.isLessThanBigDecimal(BigDecimal.fromNumberUnsafe(0)), ) // Specifies a non-positive BigDecimal (<= 0) Schema.BigDecimal.check( Schema.isLessThanOrEqualToBigDecimal(BigDecimal.fromNumberUnsafe(0)), ) ``` ### Duration 过滤器 以下是 Schema 模块提供的一些实用的 [Duration](/docs/v4/data-types/duration) 过滤器: ```ts import { Schema, Duration } from "effect" // Specifies a duration greater than 5 seconds Schema.Duration.check( Schema.makeFilter((d) => Duration.isGreaterThan(d, Duration.seconds(5))), ) // Specifies a duration greater than or equal to 5 seconds Schema.Duration.check( Schema.makeFilter((d) => Duration.isGreaterThanOrEqualTo(d, Duration.seconds(5)), ), ) // Specifies a duration less than 5 seconds Schema.Duration.check( Schema.makeFilter((d) => Duration.isLessThan(d, Duration.seconds(5))), ) // Specifies a duration less than or equal to 5 seconds Schema.Duration.check( Schema.makeFilter((d) => Duration.isLessThanOrEqualTo(d, Duration.seconds(5)), ), ) // Specifies a duration between 5 seconds and 10 seconds, inclusive Schema.Duration.check( Schema.makeFilter((d) => Duration.between(d, { minimum: Duration.seconds(5), maximum: Duration.seconds(10), }), ), ) ``` --- # 从 Schema 到 Formatter > 根据 Schema 生成值的格式化字符串表示。 `Schema.toFormatter` 为某个 Schema 的 `Type` 派生出一个人类可读的 Formatter。它会递归地格式化 struct、array、union 和 declaration;它不会校验值。 这个值 Formatter 与用于解码和编码失败的 [Error Formatters](/docs/v4/schema/error-formatters/) 不同。 **示例**(为 Struct Schema 生成 Formatter) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const PersonFormatter = Schema.toFormatter(Person) PersonFormatter({ name: "Alice", age: 30 }) // => `{ "name": "Alice", "age": 30 }` ``` ## 自定义 Formatter 的生成 使用 `Schema.overrideToFormatter` 可以替换为已有 Schema 派生出的 Formatter。 **示例**(为数字自定义 Formatter) ```ts import { Schema } from "effect" const schema = Schema.Finite.pipe( Schema.overrideToFormatter(() => (value) => `my format: ${value}`), ) const customFormatter = Schema.toFormatter(schema) customFormatter(1) // => "my format: 1" ``` Declaration 也可以在定义时提供 `toFormatter` 注解。参数化的 declaration 会接收为每个类型参数派生出的 Formatter。 ## 拦截 AST 节点 传入 `onBefore` 钩子,即可在派生默认 Formatter 之前拦截选中的 AST 节点。返回 `undefined` 表示保留默认行为。 **示例**(自定义所有 String 节点) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, city: Schema.String, }) const formatter = Schema.toFormatter(Person, { onBefore: (ast) => ast._tag === "String" ? (value: string) => `<${value}>` : undefined, }) formatter({ name: "Alice", city: "Rome" }) // => `{ "name": , "city": }` ``` --- # Schema 入门 > 了解如何定义 schema、提取类型,以及处理解码与编码。 你可以从 `effect/Schema` 模块导入所需的类型与函数: **示例**(命名空间导入) ```ts import * as Schema from "effect/Schema" ``` **示例**(具名导入) ```ts import { Schema } from "effect" ``` ## 定义 Schema 定义 `Schema` 的一种常见方式就是使用 `Struct` 构造器。这个构造器让你可以创建一个新的 schema,用来描述一个具有特定属性的对象。 对象中的每个属性都由它自己的 schema 定义,该 schema 规定了数据类型以及任何校验规则。 **示例**(定义简单的对象 Schema) 这个 `Person` schema 描述了一个带有 `name`(string)和 `age`(number)属性的对象: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) ``` ## 提取推导出的类型 ### Type 定义 schema 之后,你可以通过两种方式提取它推导出的解码类型 `T`: 1. 使用 `Schema.Schema.Type` 工具类型 2. 直接在 schema 上访问 `Type` 字段 **示例**(提取推导出的类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // 1. Using the Schema.Schema.Type utility type Person = Schema.Schema.Type // 2. Accessing the Type field directly type Person2 = typeof Person.Type ``` 得到的类型如下所示: ```ts type Person = { readonly name: string readonly age: number } ``` 另一种方式是使用 `interface` 关键字提取 `Person` 类型,在某些情况下这可以提升可读性与性能。 **示例**(用 interface 提取类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) interface Person extends Schema.Schema.Type {} ``` 两种方式得到的结果相同,但使用 interface 有性能优势和更好的可读性等好处。 ### Encoded 对于被视为 `Codec` 的 schema,编码类型 `E` 可能与解码类型 `T` 不同。你可以通过两种方式提取编码类型: 1. 使用 `Schema.Codec.Encoded` 工具类型 2. 直接在 schema 上访问 `Encoded` 字段 **示例**(提取编码类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.FiniteFromString, }) // 1. Using the Schema.Codec.Encoded utility type PersonEncoded = Schema.Codec.Encoded // 2. Accessing the Encoded field directly type PersonEncoded2 = typeof Person.Encoded ``` 得到的类型是: ```ts type PersonEncoded = { readonly name: string readonly age: string } ``` 注意,`age` 在 schema 的 `Encoded` 类型中是 `string` 类型,而在 schema 的 `Type` 类型中是 `number` 类型。 另一种方式是使用 `interface` 关键字定义 `PersonEncoded` 类型,这可以提升可读性与性能。 **示例**(用 interface 提取编码类型) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, // a schema that decodes a string to a number age: Schema.FiniteFromString, }) interface PersonEncoded extends Schema.Codec.Encoded {} ``` 两种方式得到的结果相同,但使用 interface 有性能优势和更好的可读性等好处。 ### Services `Codec` 会在两个方向上分别跟踪各自的 service 需求:`RD` 包含解码所需的 service,而 `RE` 包含编码所需的 service。你可以通过两种方式提取这两个类型: 1. 使用 `Schema.Codec.DecodingServices` 和 `Schema.Codec.EncodingServices` 工具类型。 2. 直接在 schema 上访问 `DecodingServices` 和 `EncodingServices` 字段。 **示例**(提取 Service 需求) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // 1. Using the Schema.Codec.DecodingServices / EncodingServices utilities type PersonDecodingServices = Schema.Codec.DecodingServices type PersonEncodingServices = Schema.Codec.EncodingServices // 2. Accessing the DecodingServices / EncodingServices field directly type PersonDecodingServices2 = typeof Person.DecodingServices type PersonEncodingServices2 = typeof Person.EncodingServices ``` ## 默认的 Readonly 类型 需要注意的是,默认情况下,`effect/Schema` 导出的多数构造器都会返回 `readonly` 类型。 **示例**(Schema 中的 Readonly 类型) 例如,在下面的 `Person` schema 中: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) ``` 推导出的 `Type` 是: ```ts { readonly name: string; readonly age: number; } ``` ## 解码 在 TypeScript 中处理未知数据类型时,把未知值解码成已知结构可能很有挑战。好在 `effect/Schema` 提供了若干函数来帮助完成这一过程。下面来看看如何使用这些函数解码未知值。 | API | 说明 | | ---------------------- | ---------------------------------------------------------------------------------- | | `decodeUnknownSync` | 同步解码一个值,解析失败时抛出错误。 | | `decodeUnknownExit` | 解码一个值并返回 [Exit](/docs/v4/data-types/exit)。 | | `decodeUnknownOption` | 解码一个值并返回 [Option](/docs/v4/data-types/option) 类型。 | | `decodeUnknownResult` | 解码一个值并返回 [Result](/docs/v4/data-types/result) 类型。 | | `decodeUnknownPromise` | 解码一个值并返回 `Promise`。 | | `decodeUnknownEffect` | 解码一个值并返回 [Effect](/docs/v4/getting-started/the-effect-type)。 | ### decodeUnknownSync 当你想要解析一个值,并在解析失败时立即抛出错误时,`Schema.decodeUnknownSync` 函数很有用。 **示例**(使用 `decodeUnknownSync` 立即解码) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Simulate an unknown input const input: unknown = { name: "Alice", age: 30 } // Example of valid input matching the schema console.log(Schema.decodeUnknownSync(Person)(input)) // Output: { name: 'Alice', age: 30 } // Example of invalid input that does not match the schema console.log(Schema.decodeUnknownSync(Person)(null)) /* throws: SchemaError: Expected object */ ``` ### decodeUnknownResult `Schema.decodeUnknownResult` 函数让你可以解析一个值,并以 [Result](/docs/v4/data-types/result) 的形式获得结果,它表示成功(`Success`)或失败(`Failure`)。这种方式让你能够更优雅地处理解析错误,而不必抛出异常。 **示例**(用 `Schema.decodeUnknownResult` 处理错误) ```ts import { Result, Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const decode = Schema.decodeUnknownResult(Person) // Simulate an unknown input const input: unknown = { name: "Alice", age: 30 } // Attempt decoding a valid input const result1 = decode(input) // => Result.succeed({ name: "Alice", age: 30 }) if (Result.isSuccess(result1)) { console.log(result1.success) // Output: { name: 'Alice', age: 30 } } // Simulate decoding an invalid input const result2 = decode(null) if (Result.isFailure(result2)) { console.log(result2.failure.message) // Output: Expected object } ``` ### decodeUnknownEffect 如果 schema 中包含异步转换,`Sync`、`Option`、`Result` 和 `Exit` 这些解释器无法执行它们。请改用 `Schema.decodeUnknownEffect` 或 `Schema.decodeUnknownPromise`。 **示例**(处理异步解码) ```ts import { Effect, Schema, SchemaGetter } from "effect" const PersonId = Schema.Finite const Person = Schema.Struct({ id: PersonId, name: Schema.String, age: Schema.Finite, }) const asyncSchema = PersonId.pipe( Schema.decodeTo(Person, { // Decode with simulated async transformation decode: SchemaGetter.transformOrFail((id) => Effect.succeed({ id, name: "name", age: 18 }).pipe( Effect.delay("10 millis"), ), ), encode: SchemaGetter.transformOrFail((person) => Effect.succeed(person.id).pipe(Effect.delay("10 millis")), ), }), ) // Attempting to use a synchronous decoder on an async schema console.log(Schema.decodeUnknownExit(asyncSchema)(1)) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', failures: [ [Object] ] } } */ // Decoding asynchronously with `Schema.decodeUnknownEffect` Effect.runPromise(Schema.decodeUnknownEffect(asyncSchema)(1)).then(console.log) /* Output: { id: 1, name: 'name', age: 18 } */ ``` 在上面的代码中,第一种使用 `Schema.decodeUnknownExit` 的方式会产生错误,表明该转换无法同步完成。这是因为 `Schema.decodeUnknownExit` 并不是为异步操作设计的。第二种方式使用 `Schema.decodeUnknownEffect`,它可以正常工作,让你能够处理异步转换并返回预期的结果。 ## 编码 `Schema` 模块提供了若干 `encode*` 函数,用于按照 schema 编码数据: | API | 说明 | | --------------- | ----------------------------------------------------------------------------------------------------- | | `encodeSync` | 同步编码数据,编码失败时抛出错误。 | | `encodeExit` | 编码数据并返回 [Exit](/docs/v4/data-types/exit)。 | | `encodeOption` | 编码数据并返回 [Option](/docs/v4/data-types/option) 类型。 | | `encodeResult` | 编码数据并返回表示成功或失败的 [Result](/docs/v4/data-types/result) 类型。 | | `encodePromise` | 编码数据并返回 `Promise`。 | | `encodeEffect` | 编码数据并返回 [Effect](/docs/v4/getting-started/the-effect-type)。 | **示例**(使用 `Schema.encodeSync` 立即编码) ```ts import { Schema } from "effect" const Person = Schema.Struct({ // Ensure name is a non-empty string name: Schema.NonEmptyString, // Allow age to be decoded from a string and encoded to a string age: Schema.FiniteFromString, }) // Valid input: encoding succeeds and returns expected types console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 })) // Output: { name: 'Alice', age: '30' } // Invalid input: encoding fails due to empty name string console.log(Schema.encodeSync(Person)({ name: "", age: 30 })) /* throws: SchemaError: Expected a value with a length of at least 1 at ["name"] */ ``` 注意,在编码过程中,数字值 `30` 被转换成了字符串 `"30"`。 ## SchemaError `Schema.decodeUnknownResult` 和 `Schema.encodeResult` 函数返回 [Result](/docs/v4/data-types/result),两个方向上的成功类型不同: ```ts decodeUnknownResult: (input: unknown) => Result encodeResult: (input: T) => Result ``` 其中 `SchemaError` 的定义如下(简化版): ```ts interface SchemaError { readonly _tag: "SchemaError" readonly issue: SchemaIssue.Issue } ``` 在这个结构中,`SchemaIssue.Issue` 表示解码或编码过程中可能出现的错误。它被包装成 tagged error,以便用 [Effect.catchTag](/docs/v4/error-management/expected-errors#catchtag) 更容易地捕获错误。 解码成功时得到解码类型 `T`,编码成功时得到编码类型 `E`。无论哪个方向,schema 不匹配都会产生一个包含 `SchemaError` 的 `Failure`。 ## Parse 选项 下面的选项可以控制解码与编码的行为。 ### 处理多余属性 默认情况下,解析一个值时,schema 中未定义的任何属性都会从输出中移除。这可以确保解析后的数据严格符合预期的结构。 如果你希望检测并处理意料之外的属性,可以使用 `onExcessProperty` 选项(默认值:`"ignore"`),它允许你为多余属性抛出错误。当你需要校验并捕获意料之外的属性时,这会很有帮助。 **示例**(把 `onExcessProperty` 设为 `"error"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Excess properties are ignored by default console.log( Schema.decodeUnknownSync(Person)({ name: "Bob", age: 40, email: "bob@example.com", // Ignored }), ) /* Output: { name: 'Bob', age: 40 } */ // With `onExcessProperty` set to "error", // an error is thrown for excess properties Schema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", // Will raise an error }, { onExcessProperty: "error" }, ) /* throws SchemaError: Expected no excess property at ["email"] */ ``` 如果要保留额外的属性,请把 `onExcessProperty` 设为 `"preserve"`。 **示例**(把 `onExcessProperty` 设为 `"preserve"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Excess properties are preserved in the output Schema.decodeUnknownSync(Person)( { name: "Bob", age: 40, email: "bob@example.com", }, { onExcessProperty: "preserve" }, ) // => { email: "bob@example.com", name: "Bob", age: 40 } ``` ### 接收全部错误 `errors` 选项让你可以获取解析过程中遇到的全部错误。默认情况下只返回第一个错误。把 `errors` 设为 `"all"` 会提供完整的错误反馈,这在调试或给出详细的校验反馈时很有用。 **示例**(把 `errors` 设为 `"all"`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Attempt to parse with multiple issues in the input data Schema.decodeUnknownSync(Person)( { name: "Bob", age: "abc", email: "bob@example.com", }, { errors: "all", onExcessProperty: "error" }, ) /* throws SchemaError: Expected no excess property at ["email"] Expected number at ["age"] */ ``` ### 管理属性顺序 `propertyOrder` 选项让你可以控制输出中对象字段的顺序。当键的顺序对消费这些数据的过程很重要,或者保持输入顺序能提升可读性与易用性时,这个特性特别有用。 默认情况下,`propertyOrder` 选项被设为 `"none"`。这意味着由内部系统决定键的顺序,以优化解析速度。该模式下的键顺序不应被视为稳定的,建议不要依赖键的顺序,因为它可能在未来的更新中发生变化。 把 `propertyOrder` 设为 `"original"` 可以确保在解码/编码过程中,键按它们在输入中出现的顺序排列。 **示例**(同步解码) ```ts import { Schema } from "effect" const schema = Schema.Struct({ a: Schema.Finite, b: Schema.Literal("b"), c: Schema.Finite, }) // Default decoding, where property order is system-defined Schema.decodeUnknownSync(schema)({ b: "b", c: 2, a: 1 }) // => { a: 1, b: "b", c: 2 } // Decoding while preserving input order Schema.decodeUnknownSync(schema)( { b: "b", c: 2, a: 1 }, { propertyOrder: "original" }, ) // => { b: "b", c: 2, a: 1 } ``` **示例**(异步解码) ```ts import type { Duration } from "effect" import { Effect, Schema, SchemaGetter } from "effect" // Helper function to simulate an async operation in schema const effectify = (duration: Duration.Input) => Schema.Finite.pipe( Schema.decodeTo(Schema.Finite, { decode: SchemaGetter.transformOrFail((x) => Effect.sleep(duration).pipe(Effect.andThen(Effect.succeed(x))), ), encode: SchemaGetter.passthrough(), }), ) // Define a structure with asynchronous behavior in each field const schema = Schema.Struct({ a: effectify("200 millis"), b: effectify("300 millis"), c: effectify("100 millis"), }) // Default decoding, where property order is system-defined Schema.decodeEffect(schema)({ a: 1, b: 2, c: 3 }, { concurrency: 3 }) .pipe(Effect.runPromise) .then(console.log) // Output decided internally: { a: 1, b: 2, c: 3 } // Decoding while preserving input order Schema.decodeEffect(schema)( { a: 1, b: 2, c: 3 }, { concurrency: 3, propertyOrder: "original" }, ) .pipe(Effect.runPromise) .then(console.log) // Output preserving input order: { a: 1, b: 2, c: 3 } ``` ### 在 Schema 层级自定义解析行为 `parseOptions` 注解(annotation)允许你在不同的 schema 层级自定义解析行为,从而可以为结构中的嵌套 schema 应用各自特有的解析设置。在某个 schema 内定义的选项会覆盖父级设置,并应用于其所有嵌套 schema。 **示例**(用 `parseOptions` 自定义错误处理) ```ts import { Result, Schema } from "effect" const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.String, c: Schema.String, }).annotate({ title: "first error only", // Limit errors to the first in this sub-schema parseOptions: { errors: "first" }, }), d: Schema.String, }).annotate({ title: "all errors", // Capture all errors for the main schema parseOptions: { errors: "all" }, }) // Decode input with custom error-handling behavior const result = Schema.decodeUnknownResult(schema)( { a: {} }, { errors: "first" }, ) if (Result.isFailure(result)) { console.log(result.failure.message) result.failure.message // => 'Missing key\n at ["a"]["b"]\nMissing key\n at ["d"]' } ``` **输出详解:** 在这个例子中: - 主 schema 被配置为显示全部错误。因此,你既会看到与 `d` 字段相关的错误(因为它缺失),也会看到来自 `a` 子 schema 的错误。 - 子 schema(`a`)被设置为只显示第一个错误。尽管 `b` 和 `c` 两个字段都缺失,但只会报告第一个缺失的字段(`b`)。 ## 类型守卫 `Schema.is` 函数提供了一种验证某个值是否符合给定 schema 的方法。它充当[类型守卫](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates),接收一个 `unknown` 类型的值,并判断它是否匹配 schema 中定义的结构与类型约束。 `Schema.is` 函数的工作方式如下: 1. **Schema 定义**:定义 schema,用来描述你期望的数据类型的结构与约束。它解码后的类型 `T` 就是类型守卫所检查的目标类型。 2. **创建类型守卫**:使用该 schema 创建一个用户定义的类型守卫 `(input: unknown) => input is T`。这个函数可以在运行时用来检查某个值是否满足 schema 的要求。 **示例**(创建并使用类型守卫) ```ts import { Schema } from "effect" // Define a schema for a Person object const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Generate a type guard from the schema const isPerson = Schema.is(Person) // Test the type guard with various inputs isPerson({ name: "Alice", age: 30 }) // => true isPerson(null) // => false isPerson({}) // => false ``` 生成的 `isPerson` 函数签名如下: ```ts const isPerson: (input: Input) => input is Input & { readonly name: string readonly age: number } ``` ## 断言 类型守卫验证的是某个值是否符合特定类型,而 `Schema.asserts` 函数更进一步:它断言输入匹配 schema 所描述的解码类型 `T`。如果输入不匹配该 schema,它会抛出详细的错误,因此很适合用于运行时校验。 **示例**(创建并使用断言) ```ts import { Schema } from "effect" // Define a schema for a Person object const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) // Define an assertion wrapper for the schema const assertsPerson: (input: unknown) => asserts input is { readonly name: string readonly age: number } = (input) => Schema.asserts(Person, input) try { // Attempt to assert that the input matches the Person schema assertsPerson({ name: "Alice", age: "30" }) } catch (e: any) { console.error("The input does not match the schema:") console.error(e.message) e.message // => 'Expected number\n at ["age"]' } // This input matches the schema and will not throw an error assertsPerson({ name: "Alice", age: 30 }) ``` `assertsPerson` 包装函数的签名如下: ```ts const assertsPerson: (input: unknown) => asserts input is { readonly name: string readonly age: number } ``` ## 命名约定 Schema 名称描述的是解码后的类型;当涉及转换时,还描述它所解码自的编码表示。 解码类型与编码类型相同的 schema,通常以该类型命名: - `Schema.Finite` 在两个方向上描述有限数字。 - `Schema.Date` 描述两个方向上的 `Date` 值。 对于带转换的 schema,形如 `TFromE` 的名称读作“把 `E` 解码为 `T`”: - `Schema.FiniteFromString` 把 `string` 解码为有限的 `number`,再把该数字编码回 `string`。 - `Schema.DateFromString` 把 ISO 格式的 `string` 解码为 `Date`,再把该 `Date` 编码回 `string`。 --- # Effect Schema 简介 > `effect/Schema` 简介:一个用于定义、校验和转换数据 schema 的模块。 欢迎阅读 `effect/Schema` 的文档。这是一个用于在 TypeScript 中定义并使用 schema 来校验和转换数据的模块。 `effect/Schema` 模块让你能够定义 schema 值,用以描述数据的结构与数据类型。定义之后,你就可以借助这些 schema 执行一系列操作,包括: | 操作 | 说明 | | --------------- | ----------------------------------------------------------------------------------------------------------------- | | Decoding | 把数据从输入类型 `Encoded` 转换为输出类型 `Type`。 | | Encoding | 把数据从输出类型 `Type` 转换回输入类型 `Encoded`。 | | Asserting | 校验某个值是否符合 schema 的输出类型 `Type`。 | | Standard Schema | 生成一个 [Standard Schema V1](https://standardschema.dev/)。 | | Arbitraries | 为 [fast-check](https://github.com/dubzzz/fast-check) 测试生成 [Arbitrary](/docs/v4/schema/arbitrary)。 | | JSON Schemas | 为 schema 的编码表示形式创建 [JSON Schema](/docs/v4/schema/json-schema)。 | | Equivalence | 基于 schema 创建 [Equivalence](/docs/v4/schema/equivalence)。 | | Formatting | 基于 schema 创建 [Formatter](/docs/v4/schema/formatter)。 | ## 环境要求 - TypeScript 5.9 或更高版本。推荐使用 TypeScript 7,以获得最佳性能以及与 [Effect 的 TypeScript 工具链](/docs/v4/getting-started/devtools/) 的兼容性。 - 在 `tsconfig.json` 文件中启用 `strict` 标志。 - (可选)在 `tsconfig.json` 文件中启用 `exactOptionalPropertyTypes` 标志。 ```jsonc { "compilerOptions": { "strict": true, "exactOptionalPropertyTypes": true, // optional }, } ``` ### exactOptionalPropertyTypes 选项 `effect/Schema` 模块会利用 `tsconfig.json` 的 `exactOptionalPropertyTypes` 选项。这个选项会影响可选属性的类型标注方式(想进一步了解这个选项,可以参考官方的 [TypeScript 文档](https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes))。 **示例**(启用 `exactOptionalPropertyTypes`) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.optionalKey(Schema.String), }) type T = typeof Person.Type /* type T = { readonly name?: string; } */ // @errors: 2379 Schema.decodeSync(Person)({ name: undefined }) ``` 启用 `exactOptionalPropertyTypes` 后,`name` 可以被省略;但当该属性存在时,它的值必须是 `string`。TypeScript 不会把该属性的类型放宽为 `string | undefined`,因此类型检查器会拒绝显式传入 `{ name: undefined }`。 **示例**(禁用 `exactOptionalPropertyTypes`) 如果由于某些原因(比如与其他第三方库存在冲突)你无法启用 `exactOptionalPropertyTypes` 选项,你仍然可以使用 `effect/Schema`。不过,类型与运行时行为之间会出现不一致: ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.optionalKey(Schema.String), }) type T = typeof Person.Type /* type T = { readonly name?: string | undefined; } */ // No type error, but a decoding failure occurs Schema.decodeSync(Person)({ name: undefined }) /* throws SchemaError: Expected string at ["name"] */ ``` 在这种情况下,`name` 的类型会被放宽为 `string | undefined`,这意味着类型检查器不会捕获这个非法值(`undefined`)。但在解码过程中,你会遇到一个错误,表明 `undefined` 是不被允许的。 ## Schema 的视图 schema 是一个不可变的值,用于描述数据的结构。同一个 schema 值可以通过不同的接口来查看,具体取决于 API 需要哪些类型层面的信息: | 视图 | 保留的类型层面信息 | | ---------------------------------------------------------- | ------------------------------------------------------ | | `Top` | 不含特定的类型信息;接受任何 schema | | `Schema` | 解码后的类型 | | `Decoder` | 解码后的类型以及解码所需的服务 | | `Encoder` | 编码后的类型以及编码所需的服务 | | `Codec` | 解码后的类型、编码后的类型,以及两个方向所需的服务 | 例如,`Codec` 视图保留了全部四个带方向的类型参数: ```text ┌─── Type of the decoded value │ ┌─── Encoded type (input/output) │ │ ┌─── Services required for decoding │ │ │ ┌─── Services required for encoding ▼ ▼ ▼ ▼ Codec ``` 这些类型参数的含义如下: | 参数 | 说明 | | -------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Type** | 解码所产生的值的类型。 | | **Encoded** | 解码时接受、编码时产生的编码表示形式。默认为 `Type`。 | | **DecodingServices** | 解码所需的服务。默认为 `never`,表示解码没有服务要求。 | | **EncodingServices** | 编码所需的服务。默认为 `never`,表示编码没有服务要求。 | **示例** - `Schema` 是任何解码类型为 `string` 的 schema 的纯类型视图。 - `Decoder` 保留解码类型,但不约束编码类型或编码服务。 - `Encoder` 保留编码类型,但不约束解码类型或解码服务。 - `Codec` 是 `Codec` 的简写。 - `Codec` 表示这样一个 codec:从 `string` 解码出 `number`,把 `number` 编码为 `string`,并且不需要任何服务。 ## 理解 Schema 值 **Schema 值(Schema Values)**。schema 值是对数据的不可变描述。用于组合、细化或转换 schema 的 combinator 会返回一个新的 schema,而不会修改原来的 schema。 **Schema 解释器(Schema Interpreters)**。一个 schema 可以被不同的解释器解释,从而产生解码、编码、格式化以及 arbitrary 生成等操作。 ## 理解解码与编码 在 TypeScript 中处理数据时,你经常需要处理来自外部系统或要发送给外部系统的数据。这些数据未必总是符合你预期的格式或类型,尤其是在处理用户输入、来自 API 的数据,或存储为不同格式的数据时。为了处理这些差异,我们使用**解码(decoding)**与**编码(encoding)**。 | 术语 | 说明 | | ------------ | ----------------------------------------------- | | **Decoding** | 把值从其编码类型 `E` 转换为它的类型 `T`。 | | **Encoding** | 把值从其类型 `T` 转换为它的编码类型 `E`。 | 例如,考虑一个 HTTP 端点,它的请求体与响应体都包含一个以 JSON 字符串表示的有限数值 number。请求体被解析为 JSON 之后,解码会把 `"42"` 转换为数值 `42`。在发送响应之前,编码会把 `42` 转回 `"42"`,随后就可以将其序列化为 JSON。 下面的图示通过 `Codec` 视图展示了编码与解码之间的关系: ```text ┌─────────┐ ┌───┐ ┌───┐ ┌─────────┐ │ unknown │ │ T │ │ E │ │ unknown │ └─────────┘ └───┘ └───┘ └─────────┘ │ │ │ │ │ is │ │ │ │───────────────────▶ │ │ │ │ │ │ │ asserts │ │ │ │───────────────────▶ │ │ │ │ │ │ │ encodeUnknownEffect │ │ │────────────────────────────────────────────▶ │ │ │ │ │ │ │ encodeEffect │ │ │ │────────────────────────▶ │ │ │ │ │ │ │ decodeEffect │ │ │ ◀────────────────────────│ │ │ │ │ │ │ │ decodeUnknownEffect │ │ │ ◀──────────────────────────────────────────────────│ │ │ │ │ ``` 图中展示的是基于 Effect 的解释器,因为它们保留了 `RD` 与 `RE` 服务要求。`Sync`、`Result`、`Exit`、`Option` 和 `Promise` 这些变体遵循相同的方向,但只有在对应的操作不需要任何服务时才能使用。 我们将用 `Schema.FiniteFromString` 来演示这些概念,它可以被看作一个 `Codec`。它把 `string` 解码为有限数值 `number`,把有限数值 `number` 编码为 `string`,并且两个方向都不需要任何服务。 ### 编码 当我们谈到「编码」时,指的是把有限数值 `number` 转换为 `string` 的过程。简单来说,就是把数据从一种格式转换为另一种格式。 ### 解码 反过来,「解码」则是把 `string` 转换为有限数值 `number`。它本质上是编码的逆操作,让数据恢复为其原本的形式。 ### 从 Unknown 解码 从 `unknown` 解码包含两个关键步骤: 1. **检查(Checking):** 首先,我们验证输入数据(其类型为 `unknown`)是否符合预期的结构。在我们这个具体场景中,这意味着确保输入确实是一个 `string`。 2. **解码(Decoding):** 检查通过之后,我们继续把 `string` 转换为有限数值 `number`。这一过程完成了整个解码操作,数据在此过程中既被校验也被转换。 ### 从 Unknown 编码 从 `unknown` 编码包含两个关键步骤: 1. **检查(Checking):** 首先,我们验证输入数据(其类型为 `unknown`)是否符合预期的结构。在我们这个具体场景中,这意味着确保输入确实是一个有限数值 `number`。 2. **编码(Encoding):** 检查通过之后,我们继续把有限数值 `number` 转换为 `string`。这一过程完成了整个编码操作,数据在此过程中既被校验也被转换。 ## 往返(Round-Trip) schema 有一个非常理想的属性:一次编码—解码的往返之后,返回的值与原值等价: ```text decode(encode(value)) ≈ value ``` 这个往返从编码开始,因为 `value` 的类型是 `T`:编码产生一个 `E`,随后它又被解码回 `T`。 这个属性并不被保证。有些 transformation 会有意在编码或解码过程中规范化信息或丢弃信息。 --- # 从 Schema 到 JSON Schema > 把 schema 的规范 JSON 表示导出为 JSON Schema Draft 2020-12。 `Schema.toJsonSchemaDocument` 会为某个 schema 的规范 JSON 表示生成一份 JSON Schema Draft 2020-12 文档。 在内部,Effect 首先派生出 `Schema.toCodecJson(schema)`,然后描述该 codec 的编码侧。因此,生成的 JSON Schema 与规范 JSON codec 所接受和产生的值一致,其中也包含 Effect 数据类型的 JSON 表示。 ## 基本转换 **示例**(为 Struct 生成 JSON Schema) ```ts import { Schema } from "effect" const Person = Schema.Struct({ name: Schema.String, age: Schema.Finite, }) const document = Schema.toJsonSchemaDocument(Person) document.dialect // => "draft-2020-12" document.schema.type // => "object" document.schema.required // => ["name", "age"] document.schema.additionalProperties // => false ``` 返回的文档包含: - `dialect`:源方言,始终为 `"draft-2020-12"`。 - `schema`:根 JSON Schema。 - `definitions`:通过 `$ref` 引用的定义。 JSON Schema 生成是尽力而为的。JSON Schema 无法精确表达的语义可能会被近似处理,而不带结构化 JSON codec 的不透明声明则会产生一个不受约束的 schema。 ## 规范 JSON 表示 对于 codec,输出描述的是编码后的 JSON 形状,而不是解码后的 `Type`。 **示例**(描述编码侧) ```ts import { Schema } from "effect" // Type: number, Encoded: string Schema.toJsonSchemaDocument(Schema.FiniteFromString).schema // => { type: "string" } ``` 诸如 `Option`、`Duration` 和 `BigInt` 这样的声明定义了规范 JSON codec。为它们生成的 JSON Schema 描述的就是这些表示。 **示例**(描述 Option 的 JSON 表示) ```ts import { Schema } from "effect" const document = Schema.toJsonSchemaDocument(Schema.Option(Schema.String)) console.log(document.schema) /* Output: { anyOf: [ { type: "object", properties: { _tag: { type: "string", enum: ["Some"] }, value: { type: "string" } }, required: ["_tag", "value"], additionalProperties: false }, { type: "object", properties: { _tag: { type: "string", enum: ["None"] } }, required: ["_tag"], additionalProperties: false } ] } */ ``` 定义自定义声明时,如果它具有有意义的 JSON 表示,请提供一个 `toCodecJson` 注解。这样 `Schema.toCodecJson` 和 `Schema.toJsonSchemaDocument` 都会使用同一个形状。 ## 其他 Draft `Schema.toJsonSchemaDocument` 始终生成 Draft 2020-12。如需其他 draft,请使用 `JsonSchema` 模块转换生成的文档。 **示例**(转换为 Draft 07) ```ts import { JsonSchema, Schema } from "effect" const schema = Schema.Tuple([Schema.String, Schema.Finite]) const draft2020_12 = Schema.toJsonSchemaDocument(schema) const draft07 = JsonSchema.toDocumentDraft07(draft2020_12) draft07.dialect // => "draft-07" draft07.schema.items // => [{ type: "string" }, { type: "number" }] ``` `JsonSchema.toDocumentDraft04` 同样可以把文档转换为 Draft 04。 ## 注解 以下标准 JSON Schema 注解会被自动写入: - `title` - `description` - `default` - `examples` - `readOnly` - `writeOnly` - `format` - `contentEncoding` - `contentMediaType` - `contentSchema` **示例**(添加标准元数据) ```ts import { Schema } from "effect" const Username = Schema.String.annotate({ title: "Username", description: "A user name", default: "anonymous", examples: ["alice", "bob"], }) const document = Schema.toJsonSchemaDocument(Username) document.schema.title // => "Username" document.schema.description // => "A user name" document.schema.default // => "anonymous" document.schema.examples // => ["alice", "bob"] ``` ### 注解 codec 的编码侧 在 codec 上调用 `.annotate(...)` 注解的是它的解码侧。对于属于 JSON 表示的元数据,请使用 `Schema.annotateEncoded`。 **示例**(注解编码后的输入) ```ts import { Schema } from "effect" const schema = Schema.Trim.pipe( Schema.annotateEncoded({ title: "Encoded text", description: "Text before trimming", }), ) const document = Schema.toJsonSchemaDocument(schema) document.schema.type // => "string" document.schema.title // => "Encoded text" document.schema.description // => "Text before trimming" ``` ### 自定义注解键 使用 `includeAnnotationKey` 可以把编辑器元数据、vendor 扩展等非标准注解加入白名单。标准键始终会被包含。 **示例**(包含自定义元数据) ```ts import { Schema } from "effect" const schema = Schema.String.annotate({ description: "A name", markdownDescription: "The **name** field", "x-widget": "text", }) const document = Schema.toJsonSchemaDocument(schema, { includeAnnotationKey: (key) => key === "markdownDescription" || key.startsWith("x-"), }) document.schema.description // => "A name" document.schema.markdownDescription // => "The **name** field" document.schema["x-widget"] // => "text" ``` ## Filter 与约束 内置 filter 会贡献诸如 `minLength`、`maximum`、`pattern` 和 `uniqueItems` 之类的 JSON Schema 约束。 **示例**(生成校验约束) ```ts import { Schema } from "effect" const Username = Schema.String.check( Schema.isMinLength(3), Schema.isMaxLength(20), Schema.isPattern(/^[a-z0-9_]+$/), ) Schema.toJsonSchemaDocument(Username).schema.allOf // => [{ minLength: 3 }, { maxLength: 20 }, { pattern: "^[a-z0-9_]+$" }] ``` 对于自定义 filter,当其约束存在对应的 JSON Schema 时,请提供一个 `toJsonSchema` 回调。 **示例**(描述自定义 filter) ```ts import { Schema } from "effect" const LongString = Schema.String.check( Schema.makeFilter((value) => value.length >= 3, { expected: "a string with at least three characters", toJsonSchema: () => ({ minLength: 3 }), }), ) Schema.toJsonSchemaDocument(LongString).schema.allOf // => [{ minLength: 3 }] ``` 在没有显式提供 `description` 时,设置 `generateDescriptions: true` 可以把 check 的 `expected` 注解转成 `description`。 ## 可选属性 用 `optionalKey` 定义的属性会从 `required` 中省略。用 `optional` 定义的属性同样会从 `required` 中省略;并且由于 JSON 没有 `undefined` 值,它的显式 `undefined` 情形会被表示为 `null`。 **示例**(可选属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ name: Schema.optionalKey(Schema.String), nickname: Schema.optional(Schema.String), }) const document = Schema.toJsonSchemaDocument(schema) console.log(document.schema) /* Output: { type: "object", properties: { name: { type: "string" }, nickname: { anyOf: [{ type: "string" }, { type: "null" }] } }, additionalProperties: false } */ ``` ## 引用与递归 `identifier` 注解会创建一个定义,并把该 schema 的使用处替换为 `$ref`。 **示例**(创建可复用的定义) ```ts import { Schema } from "effect" const Name = Schema.String.annotate({ identifier: "Name" }) const Person = Schema.Struct({ name: Name }) const document = Schema.toJsonSchemaDocument(Person) console.log(document.schema) /* Output: { type: "object", properties: { name: { $ref: "#/$defs/Name" } }, required: ["name"], additionalProperties: false } */ ``` 递归 schema 需要一个 identifier,这样它的自引用才能以 `$ref` 的形式生成。 **示例**(生成递归 JSON Schema) ```ts import { Schema } from "effect" interface Category { readonly name: string readonly categories: ReadonlyArray } const Category = Schema.Struct({ name: Schema.String, categories: Schema.Array( Schema.suspend((): Schema.Codec => Category), ), }).annotate({ identifier: "Category" }) const document = Schema.toJsonSchemaDocument(Category) console.log(document) /* Output: { dialect: "draft-2020-12", schema: { $ref: "#/$defs/Category" }, definitions: { Category: { type: "object", properties: { name: { type: "string" }, categories: { type: "array", items: { $ref: "#/$defs/Category" } } }, required: ["name", "categories"], additionalProperties: false } } } */ ``` ## 生成选项 `Schema.toJsonSchemaDocument` 接受三个选项: - `additionalProperties`:默认为 `false`,设为 `true` 可允许额外属性,也可以传入一个描述这些属性的 JSON Schema。 - `generateDescriptions`:根据 `expected` 注解生成缺失的 check `description`。 - `includeAnnotationKey`:包含选定的非标准注解键。 **示例**(允许额外属性) ```ts import { Schema } from "effect" const schema = Schema.Struct({ name: Schema.String }) const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true, }) document.schema.additionalProperties // => true ``` ## JSON 字符串 `Schema.fromJsonString` 接受一个 JSON 字符串,并用另一个 schema 解码其解析后的内容。它的 JSON Schema 描述外层字符串,并把其媒体类型标记为 JSON。 **示例**(描述 JSON 字符串) ```ts import { Schema } from "effect" const schema = Schema.fromJsonString(Schema.Struct({ name: Schema.String })) const document = Schema.toJsonSchemaDocument(schema) document.schema.type // => "string" document.schema.contentMediaType // => "application/json" ``` --- # Schema 投影 > 通过提取并定制已有 schema 的 Type 或 Encoded 组成部分来创建新 schema。 有时,你可能想基于已有的 schema 创建一个新 schema,并专门关注它的 `Type` 或 `Encoded` 其中一面。Schema 模块提供了若干函数来实现这一点。 ## toType `Schema.toType` 提取一个 schema 的解码侧。结果会把原始的 `Type` 同时作为它的 `Type` 和 `Encoded`,不需要任何 service,并丢弃编码路径。 **函数签名** ```ts declare const toType: ( schema: S, ) => Schema.toType ``` **示例**(只提取 Type 侧特有的属性) ```ts import { Schema } from "effect" const Original = Schema.Struct({ quantity: Schema.FiniteFromString.check(Schema.isGreaterThanOrEqualTo(2)), }) // This creates a schema where 'quantity' is defined as a number // that must be greater than or equal to 2. const TypeSchema = Schema.toType(Original) // TypeSchema is equivalent to: const TypeSchema2 = Schema.Struct({ quantity: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(2)), }) Schema.decodeUnknownSync(TypeSchema)({ quantity: 5 }) // => { quantity: 5 } ``` ## toEncoded `Schema.toEncoded` 提取一个 schema 的编码侧。结果会把原始的 `Encoded` 同时作为它的 `Type` 和 `Encoded`,不需要任何 service,并在保留作用于编码表示(encoded representation)的检查的同时丢弃解码路径。 **函数签名** ```ts declare const toEncoded: ( schema: S, ) => Schema.toEncoded ``` **示例**(只保留最初的 refinement) ```ts import { Schema } from "effect" const Original = Schema.Struct({ foo: Schema.String.check(Schema.isMinLength(3)).pipe( Schema.decodeTo(Schema.Trim), ), }) // The EncodedSchema preserves the minLength(3) check, // ensuring the string length condition is enforced // but omits the Schema.Trim transformation. const EncodedSchema = Schema.toEncoded(Original) // EncodedSchema is equivalent to: const EncodedSchema2 = Schema.Struct({ foo: Schema.String.check(Schema.isMinLength(3)), }) Schema.decodeUnknownSync(EncodedSchema)({ foo: "abcd" }) // => { foo: "abcd" } ``` --- # 从 Schema 到 Standard Schema > 生成 Standard Schema V1。 `Schema.toStandardSchemaV1` 通过 [Standard Schema V1](https://standardschema.dev/) 接口暴露一个 Effect schema。得到的对象可以传给支持该标准的库,同时保留原有的 Effect schema API。 **示例**(生成 Standard Schema V1) ```ts import { Schema } from "effect" const schema = Schema.Struct({ name: Schema.String, }) // Convert an Effect schema into a Standard Schema V1 object const standardSchema = Schema.toStandardSchemaV1(schema) standardSchema["~standard"].vendor // => "effect" ``` ## 同步校验与异步校验 Standard Schema 的 `validate` 方法会首先尝试同步解码。如果解码过程中遇到异步的 transformation 或 check,则改为返回一个 `Promise`。 **示例**(处理同步与异步校验) ```ts import { Effect, Schema, SchemaGetter } from "effect" // Utility function to display sync and async results const print = (t: T) => t instanceof Promise ? t.then((x) => console.log("Promise", JSON.stringify(x, null, 2))) : console.log("Value", JSON.stringify(t, null, 2)) // Define a synchronous schema const sync = Schema.Struct({ name: Schema.String, }) // Generate a Standard Schema V1 object const syncStandardSchema = Schema.toStandardSchemaV1(sync) // Validate synchronously print(syncStandardSchema["~standard"].validate({ name: null })) syncStandardSchema["~standard"].validate({ name: null }) // => { issues: [{ path: ["name"], message: "Expected string" }] } /* Output: { "issues": [ { "path": [ "name" ], "message": "Expected string" } ] } */ // Define an asynchronous schema with a transformation const async = sync.pipe( Schema.decodeTo( Schema.Struct({ name: Schema.NonEmptyString, }), { // Simulate an asynchronous validation delay decode: SchemaGetter.transformOrFail((x) => Effect.sleep("100 millis").pipe(Effect.as(x)), ), encode: SchemaGetter.passthrough(), }, ), ) // Generate a Standard Schema V1 object const asyncStandardSchema = Schema.toStandardSchemaV1(async) // Validate asynchronously print(asyncStandardSchema["~standard"].validate({ name: "" })) await asyncStandardSchema["~standard"].validate({ name: "" }) // => { issues: [{ path: ["name"], message: "Expected a value with a length of at least 1" }] } /* Output: Promise { "issues": [ { "path": [ "name" ], "message": "Expected a value with a length of at least 1" } ] } */ ``` ## Defect 如果校验期间出现意外的 defect,它会被报告为单个不带 `path` 的 issue。这样可以确保意外的错误不会中断 schema 校验,同时仍会被捕获并报告。 **示例**(处理 Defect) ```ts import { Effect, Schema, SchemaGetter } from "effect" // Define a schema with a defect in the decode function const defect = Schema.String.pipe( Schema.decodeTo(Schema.String, { // Simulate an internal failure decode: SchemaGetter.transformOrFail(() => Effect.die("Boom!")), encode: SchemaGetter.passthrough(), }), ) // Generate a Standard Schema V1 object const defectStandardSchema = Schema.toStandardSchemaV1(defect) // Validate input, triggering a defect console.log(defectStandardSchema["~standard"].validate("a")) /* Output: { issues: [ { message: 'Error: Boom!' } ] } */ ``` Standard Schema 的失败会使用 [Error Formatters](/docs/v4/schema/error-formatters/#standard-schema-v1-formatter) 中所述的同一个 formatter。向 `Schema.toStandardSchemaV1` 传入 `leafHook`、`checkHook` 或 `parseOptions`,即可自定义其输出。 --- # Schema 变换 > 使用基于 schema 的变换来转换和处理数据,包括类型转换、校验以及自定义处理。 在处理 schema 时,变换非常重要。它让你可以把数据从一种类型转换为另一种类型。例如,你可以把字符串解析为数字,或者把日期字符串转换为 `Date` 对象。 使用 `Schema.decodeTo` 把源 schema 连接到目标 schema。对于不会失败的转换,提供 `SchemaTransformation.transform`;当任一方向可能失败或需要 service 时,则改用 `SchemaGetter.transformOrFail`。 ## 不会失败的变换 `Schema.decodeTo` 通过把源 schema 解码后的 `Type` 连接到目标 schema 所期望的 `Encoded` 类型,创建一个新的 schema。当这两种类型不同时,`SchemaTransformation.transform` 会提供所需的两个不会失败的转换函数。 ### 理解输入与输出 “输出”与“输入”取决于你正在做什么(解码还是编码): **解码时:** - 源 codec 从 `SourceEncoded` 产出 `SourceType`。 - 自定义的 `decode` 函数把 `SourceType` 转换为 `TargetEncoded`。 - 目标 codec 从 `TargetEncoded` 产出 `TargetType`。 - 完整的解码路径是 `SourceEncoded` → `TargetType`。 如果 `SourceType` 与 `TargetEncoded` 不同,你可以提供一个 `decode` 函数,把源 schema 的输出转换为目标 schema 的输入。 **编码时:** - 目标 codec 从 `TargetType` 产出 `TargetEncoded`。 - 自定义的 `encode` 函数把 `TargetEncoded` 转换为 `SourceType`。 - 源 codec 从 `SourceType` 产出 `SourceEncoded`。 - 完整的编码路径是 `TargetType` → `SourceEncoded`。 如果 `TargetEncoded` 与 `SourceType` 不同,你可以提供一个 `encode` 函数,把目标 schema 的输出转换为源 schema 的输入。 ### 组合两个原始 schema 在这个示例中,我们从一个接受 `"on"` 或 `"off"` 的 schema 出发,把它转换为一个布尔 schema。`decode` 函数把 `"on"` 变为 `true`、把 `"off"` 变为 `false`,`encode` 函数则执行相反的操作。得到的 codec 以 `boolean` 作为其 `Type`,以 `"on" | "off"` 作为其 `Encoded` 类型。 **示例**(把字符串转换为布尔值) ```ts import { Schema, SchemaTransformation } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.Literals(["on", "off"]).pipe( Schema.decodeTo( // Target schema: boolean Schema.Boolean, SchemaTransformation.transform({ // Transformation to convert the output of the // source schema ("on" | "off") into the input of the // target schema (boolean) decode: (literal) => literal === "on", // Always succeeds here // Reverse transformation encode: (bool) => (bool ? "on" : "off"), }), ), ) // ┌─── "on" | "off" // ▼ type Encoded = typeof BooleanFromString.Encoded // ┌─── boolean // ▼ type Type = typeof BooleanFromString.Type console.log(Schema.decodeUnknownSync(BooleanFromString)("on")) // Output: true ``` 上面的 `decode` 函数本身永远不会失败。不过,如果输入不符合源 schema,整个解码过程仍然可能失败。例如,如果你提供的是 `"wrong"` 而不是 `"on"` 或 `"off"`,源 schema 会在调用 `decode` 之前就失败。 **示例**(处理无效输入) ```ts import { Schema, SchemaTransformation } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.Literals(["on", "off"]).pipe( Schema.decodeTo( Schema.Boolean, SchemaTransformation.transform({ decode: (s) => s === "on", encode: (bool) => (bool ? "on" : "off"), }), ), ) // Providing input not allowed by the source schema Schema.decodeUnknownSync(BooleanFromString)("wrong") /* throws: SchemaError: Expected "on" | "off" */ ``` ### 组合两个变换 schema 下面这个示例中,源 schema 与目标 schema 都会对各自的数据做变换: - 源 schema 是 `Schema.FiniteFromString`,其 `Type` 为 `number`,`Encoded` 类型为 `string`。 - 目标 schema 是 `BooleanFromString`,其 `Type` 为 `boolean`,`Encoded` 类型为 `"on" | "off"`。 这个示例涉及四种类型,需要进行两次转换: - 解码时,把 `number` 转换为 `"on" | "off"`。例如,把任何正数都视为 `"on"`。 - 编码时,把 `"on" | "off"` 转换回 `number`。例如,把 `"on"` 视为 `1`,把 `"off"` 视为 `-1`。 通过组合这些变换,我们得到一个 codec,其 `Type` 为 `boolean`,`Encoded` 类型为 `string`。 **示例**(组合两个变换 schema) ```ts import { Schema, SchemaTransformation } from "effect" // Convert "on"/"off" to boolean and back const BooleanFromString = Schema.Literals(["on", "off"]).pipe( Schema.decodeTo( Schema.Boolean, SchemaTransformation.transform({ decode: (s) => s === "on", encode: (bool) => (bool ? "on" : "off"), }), ), ) const BooleanFromNumericString = Schema.FiniteFromString.pipe( Schema.decodeTo( // Target schema: Convert "on"/"off" -> boolean BooleanFromString, SchemaTransformation.transform({ // If number is positive, use "on", otherwise "off" decode: (n) => (n > 0 ? "on" : "off"), // If boolean is "on", use 1, otherwise -1 encode: (bool) => (bool === "on" ? 1 : -1), }), ), ) // ┌─── string // ▼ type Encoded = typeof BooleanFromNumericString.Encoded // ┌─── boolean // ▼ type Type = typeof BooleanFromNumericString.Type console.log(Schema.decodeUnknownSync(BooleanFromNumericString)("100")) // Output: true ``` **示例**(把数组转换为 ReadonlySet) 在这个示例中,我们把一个数组转换为 `ReadonlySet`。`decode` 函数接收一个数组并创建一个新的 `ReadonlySet`,`encode` 函数则把 set 转换回数组。我们还提供了数组元素的 schema,以便它们得到正确的校验。 ```ts import { Schema, SchemaTransformation } from "effect" // This function builds a schema that converts between a readonly array // and a readonly set of items const ReadonlySetFromArray = (itemSchema: S) => Schema.Array(itemSchema).pipe( Schema.decodeTo( // Target schema: readonly set of items // **IMPORTANT** We use `Schema.toType` here to obtain the schema // of the items to avoid decoding the elements twice Schema.ReadonlySet(Schema.toType(itemSchema)), SchemaTransformation.transform({ decode: (items: ReadonlyArray): ReadonlySet => new Set(items), encode: (set: ReadonlySet): ReadonlyArray => Array.from(set.values()), }), ), ) const schema = ReadonlySetFromArray(Schema.String) // ┌─── readonly string[] // ▼ type Encoded = typeof schema.Encoded // ┌─── ReadonlySet // ▼ type Type = typeof schema.Type console.log(Schema.decodeUnknownSync(schema)(["a", "b", "c"])) // Output: Set(3) { 'a', 'b', 'c' } console.log(Schema.encodeSync(schema)(new Set(["a", "b", "c"]))) // Output: [ 'a', 'b', 'c' ] Schema.encodeSync(schema)(new Set(["a", "b", "c"])) // => ["a", "b", "c"] ``` ## 可能失败的变换 当解码或编码可能失败、需要异步执行,或者需要 Effect service 时,在 `Schema.decodeTo` 中使用 `SchemaGetter.transformOrFail`。 这个函数让解码/编码函数既可以返回成功结果,也可以返回错误,因此在校验和处理那些未必总是符合预期格式的数据时特别有用。 ### 错误处理 该 getter 返回一个 `Effect`:成功时给出转换后的值,失败时给出 `SchemaIssue.Issue`。当你需要结构化的错误信息时,可以使用更具体的 issue,例如 `SchemaIssue.InvalidValue`、`Pointer` 或 `Composite`。 **示例**(规范化颜色名称) 变换可以把一个更宽泛的输入规范化,并在没有任何目标值匹配时报告一个领域相关的 issue。 ```ts import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect" const Color = Schema.Literals(["red", "green", "blue"]) export const ColorFromString = Schema.String.pipe( Schema.decodeTo(Color, { decode: SchemaGetter.transformOrFail((input) => { const normalized = input.toLowerCase() if ( normalized === "red" || normalized === "green" || normalized === "blue" ) { return Effect.succeed(normalized) } return Effect.fail( new SchemaIssue.InvalidValue({ message: "Unsupported color" }), ) }), encode: SchemaGetter.passthrough(), }), ) // ┌─── string // ▼ type Encoded = typeof ColorFromString.Encoded // ┌─── "red" | "green" | "blue" // ▼ type Type = typeof ColorFromString.Type console.log(Schema.decodeUnknownSync(ColorFromString)("RED")) // Output: "red" console.log(Schema.decodeUnknownSync(ColorFromString)("yellow")) /* throws: SchemaError: Unsupported color */ ``` 传给 `SchemaGetter.transformOrFail` 的函数会接收该值以及当前生效的 [parse 选项](/docs/v4/schema/getting-started#parse-options)。 ### 异步变换 在现代应用中,尤其是那些需要与外部 API 交互的应用,你可能需要异步地转换数据。`SchemaGetter.transformOrFail` 通过返回一个 `Effect` 来支持这一点。 **示例**(通过 API 调用校验数据) 假设你需要通过发起 API 调用来校验一个人的 ID,可以这样实现: ```ts import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect" // Define a function to make API requests const get = (url: string): Effect.Effect => Effect.tryPromise({ try: () => fetch(url).then((res) => { if (res.ok) { return res.json() as Promise } throw new Error(String(res.status)) }), catch: (e) => new Error(String(e)), }) // Create a branded schema for a person's ID const PeopleId = Schema.String.pipe(Schema.brand("PeopleId")) // Define a schema with async transformation const PeopleIdFromString = Schema.String.pipe( Schema.decodeTo(PeopleId, { decode: SchemaGetter.transformOrFail((s) => // Make an API call to validate the ID Effect.mapBoth(get(`https://swapi.dev/api/people/${s}`), { // Error handling for failed API call onFailure: (e) => new SchemaIssue.InvalidValue({ message: e.message }), // Return the ID if the API call succeeds onSuccess: () => s, }), ), encode: SchemaGetter.passthrough(), }), ) // ┌─── string // ▼ type Encoded = typeof PeopleIdFromString.Encoded // ┌─── string & Brand<"PeopleId"> // ▼ type Type = typeof PeopleIdFromString.Type // ┌─── never // ▼ type DecodingServices = typeof PeopleIdFromString.DecodingServices // Run a successful decode operation Effect.runPromiseExit(Schema.decodeUnknownEffect(PeopleIdFromString)("1")).then( console.log, ) /* Output: { _id: 'Exit', _tag: 'Success', value: '1' } */ // Run a decode operation that will fail Effect.runPromiseExit( Schema.decodeUnknownEffect(PeopleIdFromString)("fail"), ).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', failures: [ [Object] ] } } */ ``` ### 声明依赖 当变换依赖 service 时,这些依赖会分别记录在 codec 的 `DecodingServices` 与 `EncodingServices` 视图中。 ```text Codec ``` **示例**(使用 Service 校验数据) ```ts import { Context, Effect, Schema, SchemaGetter, SchemaIssue, Layer, } from "effect" // Define a Validation service for dependency injection class Validation extends Context.Service< Validation, { readonly validatePeopleid: (s: string) => Effect.Effect } >()("Validation") {} // Create a branded schema for a person's ID const PeopleId = Schema.String.pipe(Schema.brand("PeopleId")) // Transform a string into a validated PeopleId, // using an external validation service const PeopleIdFromString = Schema.String.pipe( Schema.decodeTo(PeopleId, { decode: SchemaGetter.transformOrFail((s) => // Asynchronously validate the ID using the injected service Effect.gen(function* () { // Access the validation service const validator = yield* Validation // Use service to validate ID yield* validator.validatePeopleid(s) return s }).pipe( Effect.mapError( (e) => new SchemaIssue.InvalidValue({ message: e.message }), ), ), ), encode: SchemaGetter.passthrough(), // Encode by simply returning the string }), ) // ┌─── string // ▼ type Encoded = typeof PeopleIdFromString.Encoded // ┌─── string & Brand<"PeopleId"> // ▼ type Type = typeof PeopleIdFromString.Type // ┌─── Validation // ▼ type DecodingServices = typeof PeopleIdFromString.DecodingServices // Layer to provide a successful validation service const SuccessTest = Layer.succeed(Validation, { validatePeopleid: (_) => Effect.void, }) // Run a successful decode operation Effect.runPromiseExit( Schema.decodeUnknownEffect(PeopleIdFromString)("1").pipe( Effect.provide(SuccessTest), ), ).then(console.log) /* Output: { _id: 'Exit', _tag: 'Success', value: '1' } */ // Layer to provide a failing validation service const FailureTest = Layer.succeed(Validation, { validatePeopleid: (_) => Effect.fail(new Error("404")), }) // Run a decode operation that will fail Effect.runPromiseExit( Schema.decodeUnknownEffect(PeopleIdFromString)("fail").pipe( Effect.provide(FailureTest), ), ).then(console.log) /* Output: { _id: 'Exit', _tag: 'Failure', cause: { _id: 'Cause', failures: [ [Object] ] } } */ ``` ## 禁止编码的单向变换 在某些情况下,把值编码回其原始形式可能没有意义,或者并不希望如此。对于那个方向可以使用 `SchemaGetter.forbidden`,让这种限制以 schema issue 的形式表示出来。 **示例**(禁止编码的内容摘要) 计算摘要会丢失原始内容。这个变换把文本解码为其 SHA-256 摘要,并显式禁止把摘要编码回源文本。 ```ts import { Schema, SchemaGetter } from "effect" import { createHash } from "node:crypto" const Content = Schema.String const Sha256Digest = Schema.String.pipe(Schema.brand("Sha256Digest")) export const ContentDigest = Content.pipe( Schema.decodeTo(Sha256Digest, { decode: SchemaGetter.transform((content) => createHash("sha256").update(content).digest("hex"), ), encode: SchemaGetter.forbidden( () => "A SHA-256 digest cannot be encoded as its source.", ), }), ) // ┌─── string // ▼ type Encoded = typeof ContentDigest.Encoded // ┌─── string & Brand<"Sha256Digest"> // ▼ type Type = typeof ContentDigest.Type console.log(Schema.decodeUnknownSync(ContentDigest)("hello")) // Output: "2cf24dba5fb0a30e..." Schema.encodeUnknownSync(ContentDigest)("2cf24dba5fb0a30e...") /* throws: SchemaError: A SHA-256 digest cannot be encoded as its source. */ ``` ## 组合 当源 codec 的 `Type` 已经与目标 codec 的 `Encoded` 类型一致时,可以不提供自定义变换,直接调用 `Schema.decodeTo`。得到的结果会同时组合两条解码路径与两条编码路径。 **示例**(组合 schema,把带分隔符的字符串解析为数字) ```ts import { Schema, SchemaTransformation } from "effect" // Schema to split a string by commas into an array of strings const split = (separator: string) => Schema.String.pipe( Schema.decodeTo( Schema.Array(Schema.String), SchemaTransformation.transform({ decode: (value): ReadonlyArray => value.split(separator), encode: (values) => values.join(separator), }), ), ) // Schema to convert an array of strings to an array of numbers const FiniteArrayFromStringArray = Schema.Array(Schema.FiniteFromString) // Composed schema that takes a string, splits it by commas, // and converts the result into an array of numbers const ComposedSchema = split(",").pipe( Schema.decodeTo(FiniteArrayFromStringArray), ) Schema.decodeUnknownSync(ComposedSchema)("1,2,3") // => [1, 2, 3] ``` ## 带副作用的过滤器 当校验需要异步操作或 service 时,可以在变换中使用 `SchemaGetter.checkEffect`。如果是同步校验,请使用[过滤器](/docs/v4/schema/filters#declaring-filters)。 **示例**(异步校验用户名) ```ts import { Effect, Schema, SchemaGetter } from "effect" // Mock async function to validate a username async function validateUsername(username: string) { return Promise.resolve(username === "gcanti") } // Define a schema with an effectful filter const ValidUsername = Schema.String.pipe( Schema.decode({ decode: SchemaGetter.checkEffect((username) => Effect.promise(() => // Validate the username asynchronously, // returning an error message if invalid validateUsername(username).then((valid) => valid || "Invalid username"), ), ), encode: SchemaGetter.passthrough(), }), ).annotate({ identifier: "ValidUsername" }) Effect.runPromise(Schema.decodeUnknownEffect(ValidUsername)("xxx")).then( console.log, ) /* throws: SchemaError: Invalid username */ ``` ## 字符串转换 ### split 按指定的分隔符把字符串拆分为子字符串数组。 **示例**(按逗号拆分字符串) ```ts import { Schema, SchemaTransformation } from "effect" function split(separator: string) { return Schema.String.pipe( Schema.decodeTo( Schema.Array(Schema.String), SchemaTransformation.transform({ decode: (s) => s.split(separator) as ReadonlyArray, encode: (as) => as.join(separator), }), ), ) } const schema = split(",") const decode = Schema.decodeUnknownSync(schema) console.log(decode("")) // [""] console.log(decode(",")) // ["", ""] console.log(decode("a,")) // ["a", ""] console.log(decode("a,b")) // ["a", "b"] decode("a,b") // => ["a", "b"] ``` ### Trim 去掉字符串开头和结尾的空白字符。 **示例**(去除空白字符) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.Trim) console.log(decode("a")) // "a" console.log(decode(" a")) // "a" console.log(decode("a ")) // "a" console.log(decode(" a ")) // "a" decode(" a ") // => "a" ``` ### Lowercase 把字符串转换为小写。 **示例**(转换为小写) ```ts import { Schema, SchemaTransformation } from "effect" const decode = Schema.decodeUnknownSync( Schema.String.pipe( Schema.decodeTo( Schema.String.check(Schema.isLowercased()), SchemaTransformation.toLowerCase(), ), ), ) console.log(decode("A")) // "a" console.log(decode(" AB")) // " ab" console.log(decode("Ab ")) // "ab " console.log(decode(" ABc ")) // " abc " decode("A") // => "a" ``` ### Uppercase 把字符串转换为大写。 **示例**(转换为大写) ```ts import { Schema, SchemaTransformation } from "effect" const decode = Schema.decodeUnknownSync( Schema.String.pipe( Schema.decodeTo( Schema.String.check(Schema.isUppercased()), SchemaTransformation.toUpperCase(), ), ), ) console.log(decode("a")) // "A" console.log(decode(" ab")) // " AB" console.log(decode("aB ")) // "AB " console.log(decode(" abC ")) // " ABC " decode("a") // => "A" ``` ### Capitalize 把字符串的第一个字符转换为大写。 **示例**(把字符串首字母大写) ```ts import { Schema, SchemaTransformation } from "effect" const decode = Schema.decodeUnknownSync( Schema.String.pipe( Schema.decodeTo( Schema.String.check(Schema.isCapitalized()), SchemaTransformation.capitalize(), ), ), ) console.log(decode("aa")) // "Aa" console.log(decode(" ab")) // " ab" console.log(decode("aB ")) // "AB " console.log(decode(" abC ")) // " abC " decode("aa") // => "Aa" ``` ### Uncapitalize 把字符串的第一个字符转换为小写。 **示例**(把字符串首字母小写) ```ts import { Schema, SchemaTransformation } from "effect" const decode = Schema.decodeUnknownSync( Schema.String.pipe( Schema.decodeTo( Schema.String.check(Schema.isUncapitalized()), SchemaTransformation.uncapitalize(), ), ), ) console.log(decode("AA")) // "aA" console.log(decode(" AB")) // " AB" console.log(decode("Ab ")) // "ab " console.log(decode(" AbC ")) // " AbC " decode("AA") // => "aA" ``` ### JSON 字符串 `Schema.fromJsonString` 创建的 schema 会用 `JSON.parse` 解码 JSON 文本,并用 `JSON.stringify` 编码值。当解析出的值可以是任意与 JSON 兼容的结构时,请使用 `Schema.Unknown`。 **示例**(解析 JSON 字符串) ```ts import { Schema } from "effect" const schema = Schema.fromJsonString(Schema.Unknown) const decode = Schema.decodeUnknownSync(schema) // Parse valid JSON strings console.log(decode("{}")) // Output: {} console.log(decode(`{"a":"b"}`)) // Output: { a: "b" } // Attempting to decode an empty string results in an error decode("") /* throws: SchemaError: Expected a valid JSON string */ ``` 传入一个更具体的 schema 来校验解析出的值。 **示例**(带结构化校验的 JSON 解析) 在这个示例中,struct 确保解析出的 JSON 是一个对象,且带有一个有限的数字属性 `a`。 ```ts import { Schema } from "effect" const schema = Schema.fromJsonString(Schema.Struct({ a: Schema.Finite })) ``` ### StringFromBase64 把 base64(RFC4648)编码的字符串解码为 UTF-8 字符串。 **示例**(解码 Base64) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromBase64) console.log(decode("Zm9vYmFy")) // Output: "foobar" decode("Zm9vYmFy") // => "foobar" ``` ### StringFromBase64Url 把 base64(URL)编码的字符串解码为 UTF-8 字符串。 **示例**(解码 Base64 URL) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromBase64Url) console.log(decode("Zm9vYmFy")) // Output: "foobar" decode("Zm9vYmFy") // => "foobar" ``` ### StringFromHex 把十六进制编码的字符串解码为 UTF-8 字符串。 **示例**(解码十六进制字符串) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.StringFromHex) console.log(new TextEncoder().encode(decode("0001020304050607"))) /* Output: Uint8Array(8) [ 0, 1, 2, 3, 4, 5, 6, 7 ] */ ``` ### StringFromUriComponent 把 URI 编码的字符串解码为 UTF-8 字符串。它适合在 URL 中编码与解码数据。 **示例**(解码 URI 组件) ```ts import { Schema } from "effect" const PaginationSchema = Schema.Struct({ maxItemPerPage: Schema.Finite, page: Schema.Finite, }) const UrlSchema = Schema.StringFromUriComponent.pipe( Schema.decodeTo(Schema.fromJsonString(PaginationSchema)), ) console.log(Schema.encodeSync(UrlSchema)({ maxItemPerPage: 10, page: 1 })) // Output: %7B%22maxItemPerPage%22%3A10%2C%22page%22%3A1%7D ``` ## 数字转换 ### FiniteFromString 把字符串转换为有限数字。 如果值无法转换,或者表示 `NaN`、`Infinity`、`-Infinity` 这类非有限数字,它会返回错误。 **示例**(从字符串解析有限数字) ```ts import { Schema } from "effect" const schema = Schema.FiniteFromString const decode = Schema.decodeUnknownSync(schema) // success cases console.log(decode("1")) // 1 console.log(decode("-1")) // -1 console.log(decode("1.5")) // 1.5 decode("1") // => 1 ``` ## BigInt 转换 ### BigIntFromString 使用 `BigInt` 构造函数把字符串转换为 `BigInt`。 **示例**(从字符串解析 BigInt) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigIntFromString) // success cases console.log(decode("1")) // 1n console.log(decode("-1")) // -1n // failure cases decode("a") /* throws: SchemaError: Expected a string representing a bigint */ decode("1.5") // throws decode("NaN") // throws decode("Infinity") // throws decode("-Infinity") // throws ``` ## Date 转换 ### DateFromString 把字符串转换为**合法的** `Date`,确保 `new Date("Invalid Date")` 这类非法日期会被拒绝。 **示例**(解析并校验日期) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.DateFromString) console.log(decode("1970-01-01T00:00:00.000Z")) // Output: 1970-01-01T00:00:00.000Z decode("a") /* throws: SchemaError: Expected a valid Date */ const decodeDate = Schema.decodeSync(Schema.Date) console.log(decodeDate(new Date(0))) // Output: 1970-01-01T00:00:00.000Z console.log(decodeDate(new Date("Invalid Date"))) /* throws: SchemaError: Expected a valid Date */ ``` ## BigDecimal 转换 ### BigDecimalFromString 把字符串转换为 `BigDecimal`。 **示例**(从字符串解析 BigDecimal) ```ts import { Schema } from "effect" const decode = Schema.decodeUnknownSync(Schema.BigDecimalFromString) console.log(decode(".124")) // Output: { _id: 'BigDecimal', value: '124', scale: 3 } ``` --- # 创建 Sink > 了解如何创建和使用各种用于处理 Stream 的 Sink,包括计数、求和、收集、折叠,以及处理成功与失败。 在 Stream 处理中,`Sink` 用于消费和处理来自 stream 的元素。本节将探索各种 Sink 构造函数,它们让你可以为特定任务创建 `Sink`。 ## 常用构造函数 ### head `Sink.head` 只取 stream 的第一个元素,并用 `Some` 包装它。如果 stream 没有任何元素,则返回 `None`。 **示例**(获取第一个元素) ```ts import { Stream, Sink, Effect, Option } from "effect" const nonEmptyStream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(nonEmptyStream, Sink.head())) // => Option.some(1) const emptyStream = Stream.empty await Effect.runPromise(Stream.run(emptyStream, Sink.head())) // => Option.none() ``` ### last `Sink.last` 只取 stream 的最后一个元素,并用 `Some` 包装它。如果 stream 没有任何元素,则返回 `None`。 **示例**(获取最后一个元素) ```ts import { Stream, Sink, Effect, Option } from "effect" const nonEmptyStream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(nonEmptyStream, Sink.last())) // => Option.some(4) const emptyStream = Stream.empty await Effect.runPromise(Stream.run(emptyStream, Sink.last())) // => Option.none() ``` ### count `Sink.count` 会消费 stream 的所有元素,并统计传给它的元素数量。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.count)) // => 4 ``` ### sum `Sink.sum` 会消费 stream 的所有元素,并对传入的数值求和。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.sum)) // => 10 ``` ### take `Sink.take` 会从 stream 中取出指定数量的值,并以数组的形式返回它们。 ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.take(3))) // => [1, 2, 3] ``` ### drain `Sink.drain` 会忽略它的输入,实际上就是把它们丢弃。 ```ts import { Stream, Console, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4).pipe(Stream.tap(Console.log)) await Effect.runPromise(Stream.run(stream, Sink.drain)) // => undefined ``` ### timed `Sink.timed` 会执行 stream 并测量其执行时间,返回一个 [Duration](/docs/v4/data-types/duration/)。 ```ts import { Stream, Schedule, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) Effect.runPromise(Stream.run(stream, Sink.timed)).then(console.log) /* Output: { _id: 'Duration', _tag: 'Millis', millis: 408 } */ ``` ### forEach `Sink.forEach` 会针对传给它的每个元素执行所提供的 effect 函数。 ```ts import { Stream, Console, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.forEach(Console.log))) // => undefined ``` ## 从成功与失败创建 Sink 正如你可以定义 stream 来保存或操作数据,你也可以使用 `Sink.fail` 和 `Sink.succeed` 函数创建具有特定成功或失败结果的 `Sink`。 ### 成功的 Sink 下面的示例创建了一个 `Sink`:它不消费上游源中的任何元素,而是立即以一个指定的数值成功结束: **示例**(总是以某个值成功的 Sink) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.succeed(0))) // => 0 ``` ### 失败的 Sink 在这个示例中,这个 `Sink` 同样不消费上游源中的任何元素,而是以一个 `string` 类型的指定错误消息失败: **示例**(总是以错误消息失败的 Sink) ```ts import { Stream, Sink, Effect, Exit } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromiseExit(Stream.run(stream, Sink.fail("fail!"))) // => Exit.fail("fail!") ``` ## 收集 ### 收集所有元素 要把数据流中的所有元素汇总到一个数组里,可以使用 `Sink.collect`。 最终输出会按元素被发出的顺序包含 stream 中的所有元素。 **示例**(收集 Stream 中的所有元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise(Stream.run(stream, Sink.collect())) // => [1, 2, 3, 4] ``` ### 收集指定数量 要把 stream 中固定数量的元素收集到一个数组里,可以使用 `Sink.take`。这个 Sink 在达到指定上限后就停止收集。 **示例**(收集有限数量的元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) await Effect.runPromise( Stream.run( stream, // Collect the first 3 elements into an array Sink.take(3), ), ) // => [1, 2, 3] ``` ### 在满足条件时收集 要在元素满足特定条件时从 stream 中收集它们,可以使用 `Sink.takeWhile`。这个 Sink 会持续收集元素,直到给定的谓词返回 `false`。 **示例**(收集元素直到条件不再满足) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 0, 4, 0, 6, 7) await Effect.runPromise( Stream.run( stream, // Collect elements while they are not equal to 0 Sink.takeWhile((n) => n !== 0), ), ) // => [1, 2] ``` ### 收集到 HashSet 要把 stream 的元素累积到一个原生 `Set` 中,可以用 `Sink.reduce` 对它们进行折叠。这样可以确保每个元素在最终集合中只出现一次。 **示例**(把去重后的元素收集到 HashSet) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 2, 3, 4, 4) await Effect.runPromise( Stream.run( stream, Sink.reduce( () => new Set(), (s, n) => s.add(n), ), ), ) // => new Set([1, 2, 3, 4]) ``` ### 收集到指定大小的 HashSet 如果需要以受控方式把元素收集到有指定最大大小的 `Set` 中,可以用 `Sink.reduceWhile` 进行折叠,并在集合达到给定上限时停止。 **示例**(在限制集合大小的情况下收集去重元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 2, 3, 4, 4) await Effect.runPromise( Stream.run( stream, // Collect unique elements, limiting the set size to 3 Sink.reduceWhile( () => new Set(), (s) => s.size < 3, (s, n) => s.add(n), ), ), ) // => new Set([1, 2, 3]) ``` ### 收集到 HashMap 对于更复杂的收集场景,可以用 `Sink.reduce` 把元素折叠进一个原生 `Map`:用一个 key 函数定义每个元素的分组,再用一个合并函数把具有相同 key 的值合并起来。 **示例**(在 HashMap 中分组并合并 Stream 元素) 在这个示例中,我们用 `(n) => n % 3` 确定 map 的 key,用 `(a, b) => a + b` 合并具有相同 key 的元素: ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 3, 2, 3, 1, 5, 1) await Effect.runPromise( Stream.run( stream, Sink.reduce( () => new Map(), (m, n) => { const key = n % 3 // Key function to group by element value return m.set(key, m.has(key) ? m.get(key)! + n : n) // Merge function to sum values with the same key }, ), ), ) // => new Map([[1, 3], [0, 6], [2, 7]]) ``` ### 收集到 key 数量受限的 HashMap 要把元素累积到一个 key 数量有上限的原生 `Map` 中,可以用 `Sink.reduceWhile` 进行折叠,并在 map 达到指定的 key 上限时停止。这需要一个 key 函数来定义每个元素的分组,以及一个合并函数来合并具有相同 key 的值。 **示例**(限制 HashMap 中收集的 key 数量) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 3, 2, 3, 1, 5, 1) await Effect.runPromise( Stream.run( stream, Sink.reduceWhile( () => new Map(), (m) => m.size < 3, // Stop once the map has 3 keys (m, n) => { const key = n // Key function to group by element value return m.set(key, m.has(key) ? m.get(key)! + n : n) // Merge function to sum values with the same key }, ), ), ) // => new Map([[1, 1], [3, 3], [2, 2]]) ``` ## 折叠 ### 归约元素 如果你想把 stream 归约成单个累积值——也就是按顺序对每个元素应用一个操作——可以使用 `Sink.reduce` 函数。 **示例**(用 `Sink.reduce` 对 Stream 中的元素求和) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4) await Effect.runPromise( Stream.run( stream, // Use reduce to sequentially add each element, starting with 0 Sink.reduce( () => 0, (a, b) => a + b, ), ), ) // => 10 ``` ### 带终止条件的折叠 有时,你可能想折叠 stream 中的元素,但在满足某个特定条件时就停止这一过程。这被称为“短路”(short-circuiting)。你可以用 `Sink.fold` 函数做到这一点,它允许你定义终止条件。 **示例**(带提前停止条件的折叠) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.iterate(0, (n) => n + 1) await Effect.runPromise( Stream.run( stream, Sink.fold( () => 0, // Initial value (sum) => sum <= 10, // Termination condition (a, b) => Effect.succeed(a + b), // Folding operation ), ), ) // => 15 ``` ### 折叠到某个上限 要累积元素直到达到特定数量,可以使用 `Sink.foldUntil`。这个 Sink 会一直折叠元素,直到达到指定上限,然后停止。 **示例**(累积固定数量的元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) await Effect.runPromise( Stream.run( stream, // Fold elements, stopping after accumulating 3 values Sink.foldUntil( () => 0, 3, (a, b) => Effect.succeed(a + b), ), ), ) // => 6 ``` ### 带权重元素的折叠 在某些场景中,你可能希望按定义好的“权重”(weight)或“代价”(cost)来折叠元素,累积元素直到达到指定的最大代价。为此,你可以基于 `Sink.fold` 构建一个自定义的 Sink,把它的终止条件改为检查累积代价,而不是简单地检查元素数量。 **示例**(按权重累积元素) 在下面的示例中,每个元素的权重都是 `1`,当累积权重达到 `3` 时折叠就会重新开始。 ```ts import { Stream, Sink, Effect } from "effect" const foldWeighted = (cost: (a: A) => number, maxCost: number) => Sink.fold<{ readonly elements: Array; readonly cost: number }, A>( () => ({ elements: [], cost: 0 }), (state) => state.cost < maxCost, // Keep accumulating while under the max cost (state, a) => Effect.succeed({ elements: [...state.elements, a], cost: state.cost + cost(a), }), ).pipe(Sink.map((state) => state.elements)) const stream = Stream.make(3, 2, 4, 1, 5, 6, 2, 1, 3, 5, 6).pipe( Stream.transduce( foldWeighted( () => 1, // Each element has a weight of 1 3, // Maximum accumulated cost ), ), ) await Effect.runPromise(Stream.runCollect(stream)) // => [[3, 2, 4], [1, 5, 6], [2, 1, 3], [5, 6]] ``` --- # 简介 > 了解 Sink 在 Stream 处理中的角色:处理元素的消费、错误管理、结果的产出以及剩余元素。 在 Stream 处理中,`Sink` 是一种用于消费 `Stream` 所产生元素的结构。 ```text ┌─── Type of the result produced by the Sink | ┌─── Type of elements consumed by the Sink | | ┌─── Type of any leftover elements │ | | ┌─── Type of possible errors │ │ | | ┌─── Type of required dependencies ▼ ▼ ▼ ▼ ▼ Sink ``` 下面是 `Sink` 所做事情的总览: - 它会消费数量不定的 `In` 元素,这个数量可以是零个、一个或多个。 - 它在处理过程中可能遇到 `E` 类型的错误。 - 它在处理完成后会产出一个 `A` 类型的结果。 - 它还可能返回 `L` 类型的剩余部分,表示任何未被消费的元素。 要使用 `Sink` 处理一个 stream,你可以把它直接传给 `Stream.run` 函数: **示例**(使用 Sink 收集 Stream 元素) ```ts import { Stream, Sink, Effect } from "effect" // ┌─── Stream // ▼ const stream = Stream.make(1, 2, 3) // Create a sink to take the first 2 elements of the stream // // ┌─── Sink, number, number, never, never> // ▼ const sink = Sink.take(2) // Run the stream through the sink to collect the elements // // ┌─── Effect, never, never> // ▼ const sum = Stream.run(stream, sink) await Effect.runPromise(sum) // => [1, 2] ``` `sink` 的类型如下: ```text ┌─── result | ┌─── consumed elements | | ┌─── leftover elements │ | | ┌─── no errors │ │ | | ┌─── no dependencies ▼ ▼ ▼ ▼ ▼ Sink, number, number, never, never> ``` 下面逐项说明: - `Array`:Sink 处理完元素后产出的最终结果。 - `number`(第一次出现):Sink 将从 stream 中消费的元素类型。 - `number`(第二次出现):未被消费的剩余元素(如果有的话)的类型。 - `never`(第一次出现):表示这个 Sink 不会产生任何错误。 - `never`(第二次出现):表示运行这个 Sink 不需要任何依赖。 --- # 剩余元素 > 学习如何处理 Stream 中未被消费的元素:收集或忽略剩余元素,从而实现高效的数据处理。 在本节中,我们将探讨如何处理 Sink 未消费的元素。Sink 可能只处理上游源中的一部分元素,而把其余元素留作「剩余元素」(leftovers)。下面介绍如何收集或忽略这些剩余元素。 ## 收集剩余元素 如果 Sink 没有消费上游源中的所有元素,那么剩下的元素就称为剩余元素(leftovers)。`Sink.mapEnd` 会同时变换 Sink 的结果和它可选的剩余元素,因此你可以把剩余元素移到 `Stream.run` 返回的结果中。 **示例**(收集剩余元素) ```ts import { Stream, Sink, Effect, Option } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) // Take the first 3 elements and collect any leftovers const sink1 = Sink.take(3).pipe( Sink.mapEnd(([a, leftover]) => [[a, leftover ?? []] as const]), ) await Effect.runPromise(Stream.run(stream, sink1)) // => [[1, 2, 3], [4, 5]] // Take only the first element and collect the rest as leftovers const sink2 = Sink.head().pipe( Sink.mapEnd(([a, leftover]) => [[a, leftover ?? []] as const]), ) await Effect.runPromise(Stream.run(stream, sink2)) // => [Option.some(1), [2, 3, 4, 5]] ``` ## 忽略剩余元素 如果不需要这些剩余元素,可以用 `Sink.ignoreLeftover` 忽略它们。这种做法会丢弃所有未消费的元素,让 Sink 操作只关注它需要的元素。 **示例**(忽略剩余元素) ```ts import { Stream, Sink, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) // Take the first 3 elements and ignore any remaining elements const sink = Sink.take(3).pipe( Sink.ignoreLeftover, Sink.mapEnd(([a, leftover]) => [[a, leftover ?? []] as const]), ) await Effect.runPromise(Stream.run(stream, sink)) // => [[1, 2, 3], []] ``` --- # Sink 操作 > 探索用于变换、过滤和适配 Sink 的操作,从而在 Stream 处理中实现自定义的输入输出处理与元素过滤。 在前面几节中,我们学习了如何创建和使用 Sink。现在,让我们来探索一些可以变换或过滤 Sink 行为的操作。 ## 适配 Sink 的输入 有时,你的 Sink 处理的是一种输入类型,而当前的 stream 使用的是另一种类型。`Sink.mapInput` 函数通过变换输入值,帮助你让 Sink 适配新的输入类型。`Sink.map` 改变的是 Sink 的输出,而 `Sink.mapInput` 改变的是它接受的输入。 **示例**(将字符串输入转换为数值以便求和) 假设你有一个用于计算数字之和的 `Sink.sum`。如果你的 stream 中包含的是字符串而不是数字,那么 `Sink.mapInput` 可以把这些字符串转换为数字,从而让 `Sink.sum` 能与你的 stream 配合工作: ```ts import { Stream, Sink, Effect } from "effect" // A stream of numeric strings const stream = Stream.make("1", "2", "3", "4", "5") // Define a sink for summing numeric values const numericSum = Sink.sum // Use mapInput to adapt the sink, converting strings to numbers const stringSum = numericSum.pipe( Sink.mapInput((s: string) => Number.parseFloat(s)), ) await Effect.runPromise(Stream.run(stream, stringSum)) // => 15 ``` ## 同时变换输入与输出 当你需要同时变换 Sink 的输入和输出时,可以把 `Sink.mapInput` 与 `Sink.map` 组合起来使用。二者配合,可以让你先变换输入类型、执行操作,再把输出变换为新类型。这在需要在输入类型和输出类型之间做完整转换时很有用。 **示例**(将输入转换为整数、求和,再把输出转换为字符串) ```ts import { Stream, Sink, Effect } from "effect" // A stream of numeric strings const stream = Stream.make("1", "2", "3", "4", "5") // Convert string inputs to numbers, sum them, // then convert the result to a string const sumSink = Sink.sum.pipe( // Transform input: string to number Sink.mapInput((s: string) => Number.parseFloat(s)), // Transform output: number to string Sink.map((n) => String(n)), ) await Effect.runPromise(Stream.run(stream, sumSink)) // => "15" ``` ## 过滤输入 你可以先过滤为 Sink 提供元素的 stream,再用 `Stream.transduce` 把它交给 Sink,从而过滤 Sink 最终要处理的元素。这样就能把过滤条件与 `Sink.take` 这类只关心满足特定条件的元素的 Sink 组合起来。 **示例**(按每三个一组过滤负数) 在下面的示例中,元素被收集为每三个一组的数组,但只有正数会被包含进来: ```ts import { Stream, Sink, Effect } from "effect" // Define a stream with positive, negative, and zero values const stream = Stream.fromIterable([ 1, -2, 0, 1, 3, -3, 4, 2, 0, 1, -3, 1, 1, 6, ]).pipe( // Filter out non-positive numbers before grouping Stream.filter((n) => n > 0), // Collect the remaining elements in groups of 3 Stream.transduce(Sink.take(3)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [[1, 1, 3], [4, 2, 1], [1, 1, 6], []] ``` --- # Ref > 了解如何使用 Effect 的 Ref 数据类型在并发应用中管理状态,掌握可变引用,从而在多个 fiber 之间安全、可控地更新状态。 编写程序时,我们常常需要在程序的执行过程中跟踪某种形式的状态。状态指的是程序运行时可能发生变化的任何数据。例如,在计数器应用中,计数值会随着用户的递增或递减而改变;类似地,在银行应用中,账户余额会随着存款和取款而变化。状态管理对于构建交互式和动态应用至关重要。 在传统的命令式编程中,存储状态的一种常见方式是使用变量。然而,这种方式可能引入 bug,尤其是当状态在多个组件或函数之间共享时。随着程序变得越来越复杂,管理共享状态也会变得很有挑战。 为了解决这些问题,Effect 引入了一种强大的数据类型 `Ref`,它表示一个可变引用。借助 `Ref`,我们可以在程序的不同部分之间共享状态,而无需直接依赖可变变量。相反,`Ref` 提供了一种受控的方式来处理可变状态,并在并发环境中安全地更新它。 Effect 的 `Ref` 数据类型使程序中不同 fiber 之间能够通信。这一能力在并发编程中至关重要,因为多个任务可能需要同时访问并更新共享状态。 在本指南中,我们将探讨如何有效地使用 `Ref` 数据类型来管理程序中的状态。我们会介绍像计数这样的简单示例,也会涉及状态在程序不同部分之间共享的更复杂场景。此外,我们还会展示如何在并发环境中使用 `Ref`,让多个任务能够安全地与共享状态交互。 让我们深入看看,如何利用 `Ref` 在你的 Effect 程序中实现有效的状态管理。 ## 使用 Ref 下面是一个使用 `Ref` 创建计数器的简单示例: **示例**(使用 `Ref` 的基本计数器) ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.map(Ref.make(0), (value) => new Counter(value)) await Effect.runPromise( Effect.gen(function* () { const counter = yield* make yield* counter.inc yield* counter.inc return yield* counter.get }), ) // => 2 ``` **示例**(使用该计数器) ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.map(Ref.make(0), (value) => new Counter(value)) const program = Effect.gen(function* () { const counter = yield* make yield* counter.inc yield* counter.inc yield* counter.dec yield* counter.inc const value = yield* counter.get console.log(`This counter has a value of ${value}.`) }) Effect.runPromise(program) /* Output: This counter has a value of 2. */ await Effect.runPromise( Effect.gen(function* () { const counter = yield* make yield* counter.inc yield* counter.inc yield* counter.dec yield* counter.inc return yield* counter.get }), ) // => 2 ``` ## 在并发环境中使用 Ref 我们也可以在并发场景中使用 `Ref`,此时多个任务可能同时更新共享状态。 **示例**(并发更新共享计数器) 在这个示例中,我们并发地更新计数器: ```ts import { Effect, Ref } from "effect" class Counter { inc: Effect.Effect dec: Effect.Effect get: Effect.Effect constructor(private value: Ref.Ref) { this.inc = Ref.update(this.value, (n) => n + 1) this.dec = Ref.update(this.value, (n) => n - 1) this.get = Ref.get(this.value) } } const make = Effect.map(Ref.make(0), (value) => new Counter(value)) const program = Effect.gen(function* () { const counter = yield* make // Helper to log the counter's value before running an effect const logCounter = (label: string, effect: Effect.Effect) => Effect.gen(function* () { const value = yield* counter.get yield* Effect.log(`${label} get: ${value}`) return yield* effect }) yield* logCounter("task 1", counter.inc).pipe( Effect.zip(logCounter("task 2", counter.inc), { concurrent: true }), Effect.zip(logCounter("task 3", counter.dec), { concurrent: true }), Effect.zip(logCounter("task 4", counter.inc), { concurrent: true }), ) const value = yield* counter.get yield* Effect.log(`This counter has a value of ${value}.`) }) Effect.runPromise(program) /* Output: timestamp=... fiber=#3 message="task 4 get: 0" timestamp=... fiber=#6 message="task 3 get: 1" timestamp=... fiber=#8 message="task 1 get: 0" timestamp=... fiber=#9 message="task 2 get: 1" timestamp=... fiber=#0 message="This counter has a value of 2." */ // The interleaving of the concurrent updates is non-deterministic, but // the final value is not: +1 +1 -1 +1 always nets out to 2 await Effect.runPromise( Effect.gen(function* () { const counter = yield* make yield* counter.inc.pipe( Effect.zip(counter.inc, { concurrent: true }), Effect.zip(counter.dec, { concurrent: true }), Effect.zip(counter.inc, { concurrent: true }), ) return yield* counter.get }), ) // => 2 ``` ## 将 Ref 作为服务使用 你可以把 `Ref` 作为[服务](/docs/v4/requirements-management/services/)传入,从而在程序的不同部分之间共享状态。 **示例**(将 `Ref` 作为服务使用) ```ts import { Effect, Context, Ref } from "effect" // Create a service key for our state class MyState extends Context.Service>()("MyState") {} // Subprogram 1: Increment the state value twice const subprogram1 = Effect.gen(function* () { const state = yield* MyState yield* Ref.update(state, (n) => n + 1) yield* Ref.update(state, (n) => n + 1) }) // Subprogram 2: Decrement the state value and then increment it const subprogram2 = Effect.gen(function* () { const state = yield* MyState yield* Ref.update(state, (n) => n - 1) yield* Ref.update(state, (n) => n + 1) }) // Subprogram 3: Read and log the current value of the state const subprogram3 = Effect.gen(function* () { const state = yield* MyState const value = yield* Ref.get(state) console.log(`MyState has a value of ${value}.`) }) // Compose subprograms 1, 2, and 3 to create the main program const program = Effect.gen(function* () { yield* subprogram1 yield* subprogram2 yield* subprogram3 }) // Create a Ref instance with an initial value of 0 const initialState = Ref.make(0) // Provide the Ref as a service const runnable = program.pipe( Effect.provideServiceEffect(MyState, initialState), ) // Run the program and observe the output Effect.runPromise(runnable) /* Output: MyState has a value of 2. */ await Effect.runPromise( Effect.gen(function* () { yield* subprogram1 yield* subprogram2 const state = yield* MyState return yield* Ref.get(state) }).pipe(Effect.provideServiceEffect(MyState, initialState)), ) // => 2 ``` 注意,我们使用 `Effect.provideServiceEffect` 而不是 `Effect.provideService` 来提供 `MyState` 服务的实际实现,因为 `Ref` 数据类型上的所有操作都是带 effect 的,包括创建操作 `Ref.make(0)`。 ## 在 Fiber 之间共享状态 你可以使用 `Ref` 在并发环境中管理多个 fiber 之间的共享状态。 **示例**(跨 Fiber 管理共享状态) 让我们看一个示例:持续从用户输入读取名字,直到用户输入 `"q"` 退出。 首先,我们引入一个 `readLine` 工具函数来读取用户输入(请确保已安装 `@types/node`): ```ts import { Effect } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) ``` 接下来,我们实现收集名字的主程序: ```ts import { Effect, Chunk, Ref } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) const getNames = Effect.gen(function* () { const ref = yield* Ref.make(Chunk.empty()) while (true) { const name = yield* readLine("Please enter a name or `q` to exit: ") if (name === "q") { break } yield* Ref.update(ref, (state) => Chunk.append(state, name)) } return yield* Ref.get(ref) }) Effect.runPromise(getNames).then(console.log) /* Output: Please enter a name or `q` to exit: Alice Please enter a name or `q` to exit: Bob Please enter a name or `q` to exit: q { _id: "Chunk", values: [ "Alice", "Bob" ] } */ ``` 现在我们已经学会如何使用 `Ref` 数据类型,接下来就可以用它来并发地管理状态。 例如,假设在我们从控制台读取输入的同时,还有另一个 fiber 试图从其他来源更新状态。 在这里,一个 fiber 从用户输入读取名字,另一个 fiber 则按固定间隔并发地添加预设名字: ```ts import { Effect, Chunk, Ref, Fiber } from "effect" import * as NodeReadLine from "node:readline" // Utility to read user input const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) const getNames = Effect.gen(function* () { const ref = yield* Ref.make(Chunk.empty()) // Fiber 1: Reading names from user input const fiber1 = yield* Effect.forkChild( Effect.gen(function* () { while (true) { const name = yield* readLine("Please enter a name or `q` to exit: ") if (name === "q") { break } yield* Ref.update(ref, (state) => Chunk.append(state, name)) } }), ) // Fiber 2: Updating the state with predefined names const fiber2 = yield* Effect.forkChild( Effect.gen(function* () { for (const name of ["John", "Jane", "Joe", "Tom"]) { yield* Ref.update(ref, (state) => Chunk.append(state, name)) yield* Effect.sleep("1 second") } }), ) yield* Fiber.join(fiber1) yield* Fiber.join(fiber2) return yield* Ref.get(ref) }) Effect.runPromise(getNames).then(console.log) /* Output: Please enter a name or `q` to exit: Alice Please enter a name or `q` to exit: Bob Please enter a name or `q` to exit: q { _id: "Chunk", // Note: the following result may vary // depending on the speed of user input values: [ 'John', 'Jane', 'Joe', 'Tom', 'Alice', 'Bob' ] } */ ``` --- # SubscriptionRef > 了解如何在 Effect 中使用 SubscriptionRef 管理共享状态,让多个观察者能够订阅并高效地响应并发环境中的状态变化。 `SubscriptionRef` 是 [SynchronizedRef](/docs/v4/state-management/synchronizedref/) 的一种特化形式。它让我们可以订阅当前值以及对该值所做的任何更改,并接收相应的更新。 ```ts interface SubscriptionRef { readonly value: A } /** * A stream containing the current value of the `Ref` as well as all changes * to that value. */ declare const changes: (self: SubscriptionRef) => Stream ``` 你可以对 `SubscriptionRef` 执行所有标准操作,例如用 `get`、`set` 或 `modify` 与当前值交互。 `SubscriptionRef` 的关键特性是它的 `changes` 流。这个流让你能够观察到订阅那一刻的当前值,并接收之后所有的变化。每次运行该流时,它都会发出当前值,并跟踪后续的更新。 要创建一个 `SubscriptionRef`,你可以使用 `SubscriptionRef.make` 构造函数,并指定初始值: **示例**(创建一个 `SubscriptionRef`) ```ts import { SubscriptionRef, Effect } from "effect" const ref = SubscriptionRef.make(0) await Effect.runPromise(Effect.map(ref, (r) => r.value)) // => 0 ``` 当多个观察者需要对变化做出反应时,`SubscriptionRef` 尤其适合用于对共享状态建模。例如在函数式响应式编程中,`SubscriptionRef` 可以表示应用状态的一部分,而各种观察者(比如 UI 组件)会随状态变化而更新。 **示例**(使用 `SubscriptionRef` 的服务器-客户端模型) 在下面的示例中,一个“服务器”持续更新共享值,而多个“客户端”观察这些变化: ```ts import { SubscriptionRef, Effect, Fiber } from "effect" // Server function that increments a shared value forever const server = (ref: SubscriptionRef.SubscriptionRef) => SubscriptionRef.update(ref, (n) => n + 1).pipe(Effect.forever) // Run the server briefly, then interrupt it, to confirm it does increment await Effect.runPromise( Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) const fiber = yield* Effect.forkChild(server(ref)) yield* Effect.sleep("50 millis") yield* Fiber.interrupt(fiber) return (yield* SubscriptionRef.get(ref)) > 0 }), ) // => true ``` `server` 函数操作的是一个普通的 `Ref`,并持续更新该值。它不需要直接了解 `SubscriptionRef`。 接下来,我们定义一个 `client`,它订阅变化并收集指定数量的值: ```ts import { SubscriptionRef, Effect, Stream, Random } from "effect" // Server function that increments a shared value forever const server = (ref: SubscriptionRef.SubscriptionRef) => SubscriptionRef.update(ref, (n) => n + 1).pipe(Effect.forever) // Client function that observes the stream of changes const client = (changes: Stream.Stream) => Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) const chunk = yield* Stream.runCollect(Stream.take(changes, n)) return chunk }) // Exercise client with a deterministic (seeded) source stream const testStream = Stream.iterate(1, (n) => n + 1) await Effect.runPromise(client(testStream).pipe(Random.withSeed("seed"))) // => [1, 2] ``` 同样地,`client` 函数只处理值的 `Stream`,并不关心这些值的来源。 为了把所有部分串起来,我们启动服务器,并行启动多个客户端实例,然后在我们完成后关闭服务器。我们还会在这个过程中创建 `SubscriptionRef`。 ```ts import { Effect, Stream, Random, SubscriptionRef, Fiber } from "effect" // Server function that increments a shared value forever const server = (ref: SubscriptionRef.SubscriptionRef) => SubscriptionRef.update(ref, (n) => n + 1).pipe(Effect.forever) // Client function that observes the stream of changes const client = (changes: Stream.Stream) => Effect.gen(function* () { const n = yield* Random.nextIntBetween(1, 10) const chunk = yield* Stream.runCollect(Stream.take(changes, n)) return chunk }) const program = Effect.gen(function* () { // Create a SubscriptionRef with an initial value of 0 const ref = yield* SubscriptionRef.make(0) // Fork the server to run concurrently const serverFiber = yield* Effect.forkChild(server(ref)) // Create 5 clients that subscribe to the changes stream const clients = new Array(5) .fill(null) .map(() => client(SubscriptionRef.changes(ref))) // Run all clients in concurrently and collect their results const chunks = yield* Effect.all(clients, { concurrency: "unbounded" }) // Interrupt the server when clients are done yield* Fiber.interrupt(serverFiber) // Output the results collected by each client for (const chunk of chunks) { console.log(chunk) } }) Effect.runPromise(program) /* Example Output: [ 4, 5, 6, 7, 8, 9 ] [ 4 ] [ 4, 5, 6, 7, 8, 9 ] [ 4, 5 ] [ 4, 5, 6, 7, 8, 9 ] */ // The chunk contents and their interleaving are non-deterministic, but each // of the 5 clients always contributes exactly one chunk const chunkCount = await Effect.runPromise( Effect.gen(function* () { const ref = yield* SubscriptionRef.make(0) const serverFiber = yield* Effect.forkChild(server(ref)) const clients = new Array(5) .fill(null) .map(() => client(SubscriptionRef.changes(ref))) const chunks = yield* Effect.all(clients, { concurrency: "unbounded" }) yield* Fiber.interrupt(serverFiber) return chunks.length }), ) chunkCount // => 5 ``` 这套设置确保每个客户端在启动时都能观察到当前值,并接收该值之后的所有变化。 由于这些变化以流的形式表示,你可以使用熟悉的流操作符轻松构建更复杂的程序。你可以对这些流进行转换、过滤,或将其与其他流合并,从而实现更精细的行为。 --- # SynchronizedRef > 掌握 Effect 中的 SynchronizedRef 并发状态管理:它是一个可变引用,支持在并发环境中对共享状态进行原子且带 effect 的更新。 `SynchronizedRef` 是对类型为 `A` 的值的一个可变引用。 借助它,我们可以存储**不可变**数据,并以**原子**且带 effect 的方式执行更新。 `SynchronizedRef` 中与众不同的函数是 `updateEffect`。 该函数接收一个带 effect 的操作,并执行它来修改共享状态。 这正是 `SynchronizedRef` 区别于 `Ref` 的关键特性。 在真实应用中,当你需要执行 effect(例如查询数据库),再根据结果更新共享状态时,`SynchronizedRef` 会非常有用。它确保更新按顺序发生,从而在并发环境中保持一致性。 **示例**(使用 `SynchronizedRef` 进行并发更新) 在这个示例中,我们模拟并发地获取用户年龄,并更新一个存储这些年龄的共享状态: ```ts import { Effect, SynchronizedRef } from "effect" // Simulated API to get user age const getUserAge = (userId: number) => Effect.succeed(userId * 10).pipe(Effect.delay(10 - userId)) const meanAge = Effect.gen(function* () { // Initialize a SynchronizedRef to hold an array of ages const ref = yield* SynchronizedRef.make([]) // Helper function to log state before each effect const log = (label: string, effect: Effect.Effect) => Effect.gen(function* () { const value = yield* SynchronizedRef.get(ref) yield* Effect.log(label, value) return yield* effect }) const task = (id: number) => log( `task ${id}`, SynchronizedRef.updateEffect(ref, (sumOfAges) => Effect.gen(function* () { const age = yield* getUserAge(id) return sumOfAges.concat(age) }), ), ) // Run tasks concurrently with a limit of 2 concurrent tasks yield* Effect.all([task(1), task(2), task(3), task(4)], { concurrency: 2, }) // Retrieve the updated value const value = yield* SynchronizedRef.get(ref) return value }) Effect.runPromise(meanAge).then(console.log) /* Output: timestamp=... level=INFO fiber=#2 message="task 1" message=[] timestamp=... level=INFO fiber=#3 message="task 2" message=[] timestamp=... level=INFO fiber=#2 message="task 3" message="[ 10 ]" timestamp=... level=INFO fiber=#3 message="task 4" message="[ 10, 20 ]" [ 10, 20, 30, 40 ] */ // The order in which concurrent tasks append their result depends on // real timing, but all four ages always end up in the shared state const value = await Effect.runPromise(meanAge) value.slice().sort((a, b) => a - b) // => [10, 20, 30, 40] ``` --- # 消费 Stream > 学习消费 Stream 的各种技巧,包括收集元素、使用回调处理,以及使用 fold 与 Sink。 使用 Stream 时,理解如何消费它们产出的数据至关重要。在本指南中,我们将逐一介绍几种常见的 Stream 消费方法。 ## 使用 runCollect 要把 Stream 中的所有元素收集到一个数组里,可以使用 `Stream.runCollect` 函数。 ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3, 4, 5) const collectedData = Stream.runCollect(stream) await Effect.runPromise(collectedData) // => [1, 2, 3, 4, 5] ``` ## 使用 runForEach 另一种消费 Stream 元素的方式是使用 `Stream.runForEach`。它接收一个回调函数,该函数会接收 Stream 的每个元素。示例如下: ```ts import { Stream, Effect, Console } from "effect" const effect = Stream.make(1, 2, 3).pipe( Stream.runForEach((n) => Console.log(n)), ) await Effect.runPromise(effect) // => undefined ``` 在这个示例中,我们使用 `Stream.runForEach` 把每个元素打印到控制台。 ## 使用 runFold `Stream.runFold` 通过归约 Stream 的值来消费它,并返回一个包含结果的 Effect。若需要提前终止,可以使用 `Stream.runForEachWhile`,并把累加器保持为 `Effect.suspend` 块内的局部变量。 ```ts import { Stream, Effect } from "effect" const foldedStream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.runFold( () => 0, (a, b) => a + b, ), ) await Effect.runPromise(foldedStream) // => 15 const foldedWhileStream = Effect.suspend(() => { let acc = 0 return Stream.make(1, 2, 3, 4, 5) .pipe( Stream.runForEachWhile((n) => { acc = acc + n return Effect.succeed(acc <= 3) }), ) .pipe(Effect.map(() => acc)) }) await Effect.runPromise(foldedWhileStream) // => 6 ``` 在第一个示例中,`Stream.runFold` 计算所有元素之和。在第二个示例中,`Stream.runForEachWhile` 在累加器超过 `3` 后停止;使谓词返回 false 的那个元素已经被消费,因此结果是 `6`。 ## 使用 Sink 要使用 Sink 消费 Stream,可以把 `Sink` 传给 `Stream.run` 函数。示例如下: ```ts import { Stream, Sink, Effect } from "effect" const effect = Stream.make(1, 2, 3).pipe(Stream.run(Sink.sum)) await Effect.runPromise(effect) // => 6 ``` 在这个示例中,我们使用 `Sink` 计算 Stream 中所有元素之和。 --- # 创建 Stream > 学习创建 Effect stream 的各种方法,涵盖从基础构造函数到异步数据源、分页与调度的处理。 在本节中,我们将探讨创建 Effect `Stream` 的各种方法。这些方法能帮助你生成契合自身需求的 stream。 ## 常用构造函数 ### make 你可以使用 `Stream.make` 构造函数创建一个纯 stream。该构造函数接受一组数量可变的值作为参数。 ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3] ``` ### empty 有时你可能需要一个不产生任何值的 stream。这种情况下,可以使用 `Stream.empty`。这个构造函数创建的 stream 始终保持为空。 ```ts import { Stream, Effect } from "effect" const stream = Stream.empty await Effect.runPromise(Stream.runCollect(stream)) // => [] ``` ### void 如果你需要一个只包含单个 `void` 值的 stream,可以使用 `Stream.succeed(void 0)`。当你想用一个 stream 表示单个事件或信号时,这很方便。 ```ts import { Stream, Effect } from "effect" const stream = Stream.succeed(void 0) await Effect.runPromise(Stream.runCollect(stream)) // => [undefined] ``` ### range 要创建指定范围 `[min, max]`(包含 `min` 和 `max` 两个端点)内的整数 stream,可以使用 `Stream.range`。这在生成连续数字的 stream 时特别有用。 ```ts import { Stream, Effect } from "effect" // Creating a stream of numbers from 1 to 5 const stream = Stream.range(1, 5) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, 4, 5] ``` ### iterate 使用 `Stream.iterate`,你可以通过对初始值反复应用一个函数来生成 stream。初始值会成为 stream 产生的第一个元素,随后依次是由 `f(init)`、`f(f(init))` 等产生的值。 ```ts import { Stream, Effect } from "effect" // Creating a stream of incrementing numbers const stream = Stream.iterate(1, (n) => n + 1) // Produces 1, 2, 3, ... await Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))) // => [1, 2, 3, 4, 5] ``` ### scoped `Stream.scoped` 用于从作用域资源创建一个只含单个值的 stream。当处理需要显式获取、使用与释放的资源时,它会很有用。 ```ts import { Stream, Effect, Console } from "effect" // Creating a single-valued stream from a scoped resource const stream = Stream.scoped( Stream.fromEffect( Effect.acquireUseRelease( Console.log("acquire"), () => Console.log("use"), () => Console.log("release"), ), ), ) await Effect.runPromise(Stream.runCollect(stream)) // => [undefined] /* Output: acquire use release */ ``` ## 从成功与失败创建 与 `Effect` 数据类型很相似,你可以使用 `fail` 和 `succeed` 函数生成 `Stream`: ```ts import { Stream, Effect } from "effect" // Creating a stream that can emit errors const streamWithError: Stream.Stream = Stream.fail("Uh oh!") Effect.runPromise(Stream.runCollect(streamWithError)) // throws Error: Uh oh! // Creating a stream that emits a numeric value const streamWithNumber: Stream.Stream = Stream.succeed(5) Effect.runPromise(Stream.runCollect(streamWithNumber)).then(console.log) // [ 5 ] ``` ## 从数组创建 你可以像这样从数组构造 stream: ```ts import { Stream, Effect } from "effect" // Creating a stream with values from a single array const stream = Stream.fromArray([1, 2, 3]) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3] ``` 此外,你也可以从多个数组创建 stream: ```ts import { Stream, Effect } from "effect" // Creating a stream with values from multiple arrays const stream = Stream.fromArrays([1, 2, 3], [4, 5, 6]) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, 4, 5, 6] ``` ## 从 Effect 创建 你可以使用 `Stream.fromEffect` 构造函数从 Effect 工作流生成 stream。例如下面这个 stream,它生成一个随机数: ```ts import { Stream, Random, Effect } from "effect" const stream = Stream.fromEffect(Random.nextInt) Effect.runPromise(Stream.runCollect(stream)).then(console.log) // Example Output: [ 1042302242 ] // The value is random, but the stream always emits exactly one element const result = await Effect.runPromise(Stream.runCollect(stream)) result.length // => 1 ``` 这个方法让你能够无缝地把 Effect 的输出转换为 stream,为在 stream 中处理异步操作提供了一种直接的方式。 ## 从异步回调创建 假设你有一个依赖回调的异步函数。如果你想把这些回调发出的结果捕获为一个 stream,可以使用 `Stream.callback` 函数。这个函数专门用于适配那些会多次调用自身回调的函数,并把结果以 stream 的形式发出。 下面通过一个例子来拆解它的用法: ```ts import { Stream, Effect, Queue } from "effect" const events = [1, 2, 3, 4] const stream = Stream.callback((queue) => Effect.sync(() => { events.forEach((n) => { setTimeout(() => { if (n === 3) { // Terminate the stream Queue.endUnsafe(queue) } else { // Add the current item to the stream Queue.offerUnsafe(queue, n) } }, 100 * n) }) }), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2] ``` 传给 `Stream.callback` 的函数会接收一个 `Queue`,你可以在异步代码中使用它来驱动这个 stream。各种可能的操作含义如下: - 在队列上调用 `Queue.offerUnsafe`(或基于 effect 的 `Queue.offer`)会把给定的元素作为 stream 的一部分发出。 - 在队列上调用 `Queue.fail`(或 `Queue.failCauseUnsafe`/`Queue.failCause`)会以指定的错误终止 stream。 - 在队列上调用 `Queue.endUnsafe`/`Queue.end` 会发出 stream 结束的信号,从而成功地终止它。 简单来说,这让你完全掌控异步回调与 stream 的交互方式:决定何时发出元素、何时以错误终止,以及何时发出 stream 结束的信号。 ## 从 Iterable 创建 ### fromIterable 你可以使用 `Stream.fromIterable` 构造函数从值的 `Iterable` 创建一个纯 stream。这是把一组值转换为 stream 的直接方式。 ```ts import { Stream, Effect } from "effect" const numbers = [1, 2, 3] const stream = Stream.fromIterable(numbers) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3] ``` ### fromIterableEffect 当你有一个产生 `Iterable` 类型值的 effect 时,可以使用 `Stream.fromIterableEffect` 构造函数从该 effect 生成 stream。 例如,假设你有一个获取用户列表的数据库操作。由于该操作涉及 effect,你可以利用 `Stream.fromIterableEffect` 把结果转换为 `Stream`: ```ts import { Stream, Effect, Context } from "effect" class Database extends Context.Service< Database, { readonly getUsers: Effect.Effect> } >()("Database") {} const getUsers = Database.use((_) => _.getUsers) const stream = Stream.fromIterableEffect(getUsers) await Effect.runPromise( Stream.runCollect( stream.pipe( Stream.provideService(Database, { getUsers: Effect.succeed(["user1", "user2"]), }), ), ), ) // => ["user1", "user2"] ``` 这让你能够无缝地处理 effect,并把它们的结果转换为 stream 以便进一步处理。 ### fromAsyncIterable 异步可迭代对象(async iterable)是另一类可以转换为 stream 的数据源。借助 `Stream.fromAsyncIterable` 构造函数,你可以处理异步数据源并优雅地处理潜在错误。 ```ts import { Stream, Effect } from "effect" const myAsyncIterable = async function* () { yield 1 yield 2 } const stream = Stream.fromAsyncIterable( myAsyncIterable(), (e) => new Error(String(e)), // Error Handling ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2] ``` 在这段代码中,我们定义了一个异步可迭代对象,然后由它创建了一个名为 `stream` 的 stream。此外,我们还提供了一个错误处理函数,用来管理转换过程中可能出现的任何错误。 ## 从重复创建 ### 重复单个值 你可以使用 `Stream.forever(Stream.succeed(value))` 创建一个无休止重复某个特定值的 stream: ```ts import { Stream, Effect } from "effect" const stream = Stream.forever(Stream.succeed(0)) await Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))) // => [0, 0, 0, 0, 0] ``` ### 重复 Stream 的内容 `Stream.repeat` 让你可以按照给定的调度重复指定 stream 的内容。这在生成周期性的事件或值时很有用。 ```ts import { Stream, Effect, Schedule } from "effect" // Creating a stream that repeats a value indefinitely const stream = Stream.repeat(Stream.succeed(1), Schedule.forever) await Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))) // => [1, 1, 1, 1, 1] ``` ### 重复 Effect 的结果 假设你有一个 effectful 的 API 调用,并且想用该调用的结果来创建 stream。你可以通过从该 effect 创建 stream 并无限重复它来实现。 下面是一个生成随机数 stream 的例子: ```ts import { Stream, Effect, Random } from "effect" const stream = Stream.fromEffectRepeat(Random.nextInt) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) /* Example Output: [ 1666935266, 604851965, 2194299958, 3393707011, 4090317618 ] */ // The values are random, but the stream always emits exactly 5 elements const result = await Effect.runPromise( Stream.runCollect(stream.pipe(Stream.take(5))), ) result.length // => 5 ``` ### 重复 Effect 并在特定条件下终止 你可以重复求值一个给定的 effect,并根据特定条件终止 stream。 在这个例子中,我们通过耗尽(drain)一个 `Iterator` 来由它创建 stream: ```ts import { Stream, Effect, Cause } from "effect" const drainIterator = (it: Iterator): Stream.Stream => Stream.fromEffectRepeat( Effect.sync(() => it.next()).pipe( Effect.andThen((res) => { if (res.done) { return Cause.done() } return Effect.succeed(res.value) }), ), ) const numbers = [10, 20, 30] await Effect.runPromise( Stream.runCollect(drainIterator(numbers[Symbol.iterator]())), ) // => [10, 20, 30] ``` ### 生成 tick 你可以使用 `Stream.tick` 构造函数创建一个按指定间隔发出 `void` 值的 stream。这对创建周期性事件很有用。 ```ts import { Stream, Effect } from "effect" const stream = Stream.tick("100 millis") await Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))) // => [undefined, undefined, undefined, undefined, undefined] ``` ## 从展开/分页创建 在函数式编程中,`unfold` 这一概念可以看作 `fold` 的对偶。 使用 `fold` 时,我们处理一个数据结构并产出一个返回值。例如,我们可以接收一个 `Array` 并计算其所有元素之和。 另一方面,`unfold` 表示这样的一种操作:从一个初始值开始,使用指定的状态函数一次添加一个元素,从而生成一个递归的数据结构。例如,我们可以从 `1` 开始、以 `increment` 函数作为状态函数,创建一段自然数序列。 ### 展开 #### unfold Stream 模块包含一个 `unfold` 函数,其定义如下: ```ts declare const unfold: ( initialState: S, step: (s: S) => Effect.Effect, ) => Stream ``` 它的工作方式如下: - **initialState**。这是初始状态值。 - **step**。状态函数 `step` 接收当前状态 `s` 作为输入,并返回一个 effect。如果该 effect 解析为 `undefined`,则 Stream 结束。如果它解析为一个元组 `[A, S]`,那么 Stream 中的下一个元素就是 `A`,同时状态 `S` 会更新,供下一步处理使用。 例如,让我们用 `Stream.unfold` 创建一个自然数 Stream: ```ts import { Stream, Effect } from "effect" const stream = Stream.unfold(1, (n) => Effect.succeed([n, n + 1] as const)) await Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))) // => [1, 2, 3, 4, 5] ``` #### 带 Effect 的展开 有时,我们可能需要在展开过程中执行带 effect 的状态变换。由于传给 `Stream.unfold` 的 `step` 函数本身就返回一个 `Effect`,它在产出下一个元素和状态时,可以依赖任意带 effect 的计算,比如生成一个随机值。 下面是一个使用 `Stream.unfold` 创建由随机 `1` 和 `-1` 组成的无限 Stream 的示例: ```ts import { Stream, Effect, Random } from "effect" const stream = Stream.unfold(1, (n) => Random.nextBoolean.pipe( Effect.map((b) => (b ? ([n, -n] as const) : ([n, n] as const))), ), ) Effect.runPromise(Stream.runCollect(stream.pipe(Stream.take(5)))).then( console.log, ) // Example Output: [ 1, 1, 1, 1, -1 ] // The sign is random, but the state starts at 1 and only ever flips sign, // so its absolute value is deterministically always 1 const result = await Effect.runPromise( Stream.runCollect(stream.pipe(Stream.take(5))), ) result.map(Math.abs) // => [1, 1, 1, 1, 1] ``` ### 分页 #### paginate `Stream.paginate` 与 `Stream.unfold` 类似,但允许一步发出更多的值。 例如,下面的 Stream 会发出 `0, 1, 2, 3` 这些元素: ```ts import { Stream, Effect, Option } from "effect" const stream = Stream.paginate(0, (n) => Effect.succeed([[n], n < 3 ? Option.some(n + 1) : Option.none()] as const), ) await Effect.runPromise(Stream.runCollect(stream)) // => [0, 1, 2, 3] ``` 它的工作方式如下: - 我们从一个初始值 `0` 开始。 - 传入的函数接收当前值 `n` 并返回一个元组。元组的第一个元素是要发出的值(`n`),第二个元素决定是继续(`Option.some(n + 1)`)还是停止(`Option.none()`)。 ### 展开与分页的对比 你可能会好奇 `unfold` 与 `paginate` 这两个组合子有何区别,以及何时该用哪一个。`Stream.unfold` 每一步恰好产出一个值,因此它无法消费那种每一步天然会一次返回一批值的 API。`Stream.paginate` 正是为这种形态而设计的:每一步返回一个值数组以及下一个状态,因此单次调用可以在决定是否继续之前发出零个、一个或多个元素。 这正是一个分页 API 的形态。设想一个 `fetchUsers` 请求:给定一个游标,它返回一页用户,如果还有更多数据,还会返回下一页的游标: ```ts import { Effect, Option, Stream } from "effect" interface Page { readonly items: ReadonlyArray readonly nextCursor: number | undefined } // A mock paginated API: six users, two per page. const fetchUsers = (cursor: number): Effect.Effect> => { const users = ["Alice", "Bob", "Carol", "Dave", "Erin", "Frank"] const pageSize = 2 const items = users.slice(cursor, cursor + pageSize) const nextCursor = cursor + pageSize < users.length ? cursor + pageSize : undefined return Effect.succeed({ items, nextCursor }) } const stream = Stream.paginate(0, (cursor) => fetchUsers(cursor).pipe( Effect.map( (page) => [page.items, Option.fromUndefinedOr(page.nextCursor)] as const, ), ), ) await Effect.runPromise(Stream.runCollect(stream)) // => ["Alice", "Bob", "Carol", "Dave", "Erin", "Frank"] ``` 每次调用 `fetchUsers` 都会返回一整页元素,而 `Stream.paginate` 会把每一页展平进结果 Stream,并在 `nextCursor` 为 `undefined` 时停止。若用 `Stream.unfold` 来建模,就需要每次从当前页中剥出一个元素,并把剩余部分作为额外的状态保存起来。`Stream.paginate` 通过让每一步接收一个数组,已经处理好了这套逻辑。 ## 从 Queue 和 PubSub 创建 在 Effect 中,有两种至关重要的异步消息数据类型:[Queue](/docs/v4/concurrency/queue/) 和 [PubSub](/docs/v4/concurrency/pubsub/)。你可以分别借助 `Stream.fromQueue` 和 `Stream.fromPubSub`,轻松地把这些数据类型转换为 `Stream`。 ## 从 Schedule 创建 我们可以从一个不需要任何额外输入的 `Schedule` 创建 Stream。该 Stream 会为 Schedule 输出的每个值发出一个元素,只要 Schedule 继续,它就会一直继续: ```ts import { Effect, Stream, Schedule } from "effect" // Emits values every 100 milliseconds for a total of 10 emissions const schedule = Schedule.spaced("100 millis").pipe( Schedule.upTo({ times: 10 }), ) const stream = Stream.fromSchedule(schedule) await Effect.runPromise(Stream.runCollect(stream)) // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ``` --- # Stream 中的错误处理 > 学习如何处理 Stream 中的错误,实现稳健的恢复、重试与优雅的错误管理,从而保证可靠的流式处理。 ## 从失败中恢复 当处理可能遇到错误的 Stream 时,知道如何优雅地处理这些错误至关重要。`Stream.catch` 函数是一个强大的工具,它能在失败时恢复,并切换到另一个 Stream。 **示例** ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catch(s1, () => s2) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", "c"] ``` 在这个例子中,`s1` 遇到了错误,但我们没有终止这个 Stream,而是用 `Stream.catch` 优雅地切换到 `s2`。这样即使其中一个 Stream 失败,我们也能继续处理数据。 你也可以在合并两个 Stream 之前,用 [Result](/docs/v4/data-types/result/) 数据类型为每一侧打上标记,把 `s1` 的元素映射为 `Result.succeed`,把 `s2` 的元素映射为 `Result.fail`,从而根据成功或失败来区分两个 Stream 中的元素: ```ts import { Stream, Effect, Result } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.map(s1, Result.succeed).pipe( Stream.catch(() => Stream.map(s2, Result.fail)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [Result.succeed(1), Result.succeed(2), Result.succeed(3), Result.fail("a"), Result.fail("b"), Result.fail("c")] ``` 与 `Stream.catch` 相比,`Stream.catchFilter` 提供了更高级的错误处理能力。借助 `Stream.catchFilter`,你可以根据所遇到失败的类型和取值来做出决策。 ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Uh Oh!" as const)), Stream.concat(Stream.make(4, 5)), Stream.concat(Stream.fail("Ouch" as const)), ) const s2 = Stream.make("a", "b", "c") const s3 = Stream.make(true, false, false) const stream = Stream.catch(s1, (error): Stream.Stream => { switch (error) { case "Uh Oh!": return s2 case "Ouch": return s3 } }) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", "c"] ``` 在这个例子中,我们有一个 Stream `s1`,它可能遇到两种不同类型的错误。我们没有像 `Stream.catch` 那样直接切换到另一个 Stream,而是用 `Stream.catch` 来精确决定如何处理每一种错误。这种对错误恢复的控制能力,让你可以根据具体的错误情况选择不同的 Stream 或操作。 ## 从 Defect 中恢复 处理 Stream 时,必须为各种失败场景做好准备,包括在 Stream 处理过程中可能出现的 defect。为此,`Stream.catchCause` 函数提供了一个稳健的解决方案。它让你能够优雅地处理并恢复任何类型的失败。 **示例** ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.die(new Error("Boom!"))), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchCause(s1, () => s2) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", "c"] ``` 在这个例子中,`s1` 可能遇到 defect,但我们没有让应用崩溃,而是用 `Stream.catchCause` 优雅地切换到另一个 Stream `s2`。这样即使面对意料之外的问题,应用也能保持稳健并继续处理数据。 ## 从部分错误中恢复 在 Stream 处理中,有些场景需要只从特定类型的失败中恢复。`Stream.catchFilter` 和 `Stream.catchCauseFilter` 函数正好派上用场,它们让你可以有针对性地处理和缓解错误。 如果你想从某个特定的错误中恢复,可以使用 `Stream.catchFilter`: ```ts import { Stream, Effect, Filter } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.fail("Oh! Error!")), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchFilter( s1, Filter.fromPredicate((error) => error === "Oh! Error!"), () => s2, ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", "c"] ``` 如果想从某个特定的 cause 中恢复,可以使用 `Stream.catchCauseFilter` 函数: ```ts import { Stream, Effect, Cause } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.die(new Error("Oh! Error!"))), Stream.concat(Stream.make(4, 5)), ) const s2 = Stream.make("a", "b", "c") const stream = Stream.catchCauseFilter(s1, Cause.findDie, () => s2) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", "c"] ``` ## 失败时执行清理 `Stream.onError` 会在 Stream 失败时执行一个 effect,然后保留原本的失败。它适合用于清理或诊断,而不是用于恢复。 ```ts import { Stream, Console, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.concat(Stream.die(new Error("Oh! Boom!"))), Stream.concat(Stream.make(4, 5)), Stream.onError(() => Console.log( "Stream application closed! We are doing some cleanup jobs.", ).pipe(Effect.orDie), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Stream application closed! We are doing some cleanup jobs. Error: Oh! Boom! */ ``` ## 重试失败的 Stream 有时,Stream 遇到的失败是临时的、可以恢复的。这时 `Stream.retry` 操作符就派上了用场。它允许你指定一个重试计划,Stream 会按照该计划重试。 **示例** ```ts import { Stream, Effect, Schedule } from "effect" import * as NodeReadLine from "node:readline" const stream = Stream.make(1, 2, 3).pipe( Stream.concat( Stream.fromEffect( Effect.gen(function* () { const s = yield* readLine("Enter a number: ") const n = parseInt(s) if (Number.isNaN(n)) { return yield* Effect.fail("NaN") } return n }), ).pipe(Stream.retry(Schedule.exponential("1 second"))), ), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: Enter a number: a Enter a number: b Enter a number: c Enter a number: 4 [ 1, 2, 3, 4 ] */ const readLine = (message: string): Effect.Effect => Effect.promise( () => new Promise((resolve) => { const rl = NodeReadLine.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(message, (answer) => { rl.close() resolve(answer) }) }), ) ``` 在这个例子中,Stream 要求用户输入一个数字,但如果输入了无效值(例如 "a"、"b"、"c"),就会以 "NaN" 失败。不过,我们使用了带指数退避计划的 `Stream.retry`,这意味着它会在逐渐变长的延迟之后重试。这样我们就能处理临时错误,并最终收集到合法的输入。 ## 细化错误 处理 Stream 时,有些场景需要只保留特定的错误,并用其余错误终止 Stream。你可以通过在 `Stream.catch` 内对错误进行模式匹配来实现:对想保留的错误重新失败,对其余错误则让它 die(`Stream.die`)。 **示例** ```ts import { Stream, Option, Effect, Exit } from "effect" const stream = Stream.fail(new Error()) const res = Stream.catch(stream, (error) => { const refined = error instanceof SyntaxError ? Option.some(error) : Option.none() return Option.isSome(refined) ? Stream.fail(refined.value) : Stream.die(error) }) await Effect.runPromiseExit(Stream.runCollect(res)) // => Exit.die(new Error()) ``` 在这个例子中,`stream` 最初以一个通用的 `Error` 失败。不过,`res` 会过滤并只保留 `SyntaxError` 类型的错误,让 Stream 用这些错误重新失败。任何其他错误都会通过 `Stream.die` 变成 defect,从而终止 Stream。 ## 超时 处理 Stream 时,有些场景需要处理超时,例如当 Stream 在一段时长内没有产出一个值时就终止它。本节我们将探讨如何使用各种操作符来管理超时。 ### timeout `Stream.timeout` 操作符允许你为 Stream 设置超时。如果 Stream 在指定时长内没有产出一个值,它就会终止。 ```ts import { Stream, Effect } from "effect" const stream = Stream.fromEffect(Effect.never).pipe(Stream.timeout("2 seconds")) await Effect.runPromise(Stream.runCollect(stream)) // => [] ``` ### timeoutFail `Stream.timeoutOrElse` 操作符把超时与自定义的失败消息结合起来。如果 Stream 超时,它就会以指定的错误消息失败。 ```ts import { Stream, Effect, Exit } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutOrElse({ duration: "2 seconds", orElse: () => Stream.fail("timeout"), }), ) await Effect.runPromiseExit(Stream.runCollect(stream)) // => Exit.fail("timeout") ``` ### timeoutFailCause 与 `Stream.timeoutOrElse` 类似,`Stream.timeoutOrElse` 把超时与自定义的失败 cause 结合起来。如果 Stream 超时,它就会以指定的 cause 失败。 ```ts import { Stream, Effect, Cause, Exit } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutOrElse({ duration: "2 seconds", orElse: () => Stream.failCause(Cause.die("timeout")), }), ) await Effect.runPromiseExit(Stream.runCollect(stream)) // => Exit.die("timeout") ``` ### timeoutTo `Stream.timeoutOrElse` 操作符允许你在第一个 Stream 于指定时长内没有产出值时切换到另一个 Stream。 ```ts import { Stream, Effect } from "effect" const stream = Stream.fromEffect(Effect.never).pipe( Stream.timeoutOrElse({ duration: "2 seconds", orElse: () => Stream.make(1, 2, 3), }), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3] ``` --- # Stream 简介 > 学习 Stream 的基础知识:它是一种强大的工具,用于发出多个值、处理错误,并在应用中处理有限或无限的序列。 在本指南中,我们将探讨 `Stream` 这一概念。`Stream` 是一种程序描述(program description):执行时,它可以发出类型为 `A` 的**零个或多个值**,处理类型为 `E` 的错误,并在类型为 `R` 的上下文中运行。 ## 使用场景 当你需要处理随时间推移而出现的值序列时,Stream 尤其方便。它们可以替代 observables、node streams 和 AsyncIterables。 ## 什么是 Stream? 可以把 `Stream` 看作 `Effect` 的扩展。`Effect` 表示一个需要类型为 `R` 的上下文、可能遇到类型为 `E` 的错误、并且总是产生一个类型为 `A` 的结果的程序;而 `Stream` 则更进一步,允许发出类型为 `A` 的零个或多个值。 为了说明这一点,让我们看几个使用 `Effect` 的示例: ```ts import { Effect, Option, Exit } from "effect" // An Effect that fails with a string error const failedEffect = Effect.fail("fail!") // An Effect that produces a single number const oneNumberValue = Effect.succeed(3) // An Effect that produces one array of numbers const oneListValue = Effect.succeed([1, 2, 3]) // An Effect that produces an optional number const oneOption = Effect.succeed(Option.some(1)) await Effect.runPromiseExit(failedEffect) // => Exit.fail("fail!") ``` 成功时,每个 `Effect` 都恰好产生一个值,即使该值本身就是一个集合。Effect 也可能在产生其成功值之前失败。 ## 理解 Stream 现在,让我们把注意力转向 `Stream`。`Stream` 表示一种与 `Effect` 有相似之处的程序描述:它需要类型为 `R` 的上下文,可能发出类型为 `E` 的错误,并产出类型为 `A` 的值。但关键区别在于,它可以产出**零个或多个值**。 `Stream` 有以下几种可能的场景: - **空 Stream**:它可以是空的,表示一个不含任何值的流。 - **单元素 Stream**:它可以表示只含一个值的流。 - **有限元素的 Stream**:它可以表示含有有限个值的流。 - **无限元素的 Stream**:它可以表示无限持续下去的流,本质上就是一个无限流。 让我们看看这些场景的实际效果: ```ts import { Stream, Effect } from "effect" // An empty Stream const emptyStream = Stream.empty // A Stream with a single number const oneNumberValueStream = Stream.succeed(3) // A Stream with a range of numbers from 1 to 10 const finiteNumberStream = Stream.range(1, 10) // An infinite Stream of numbers starting from 1 and incrementing const infiniteNumberStream = Stream.iterate(1, (n) => n + 1) await Effect.runPromise(Stream.runCollect(finiteNumberStream)) // => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] ``` 总而言之,`Stream` 是一种用途广泛的工具,用于表示可能产出多个值的程序,因此适合从处理有限列表到处理无限序列的各类任务。 --- # Stream 操作 > 探索 Stream 中用于操作与管理数据的常用操作,包括旁路、映射、过滤、合并等,帮助你高效地处理和转换流式数据。 在本指南中,我们将介绍一些可以在 Stream 上执行的基本操作。借助这些操作,你可以用多种方式操作和处理 Stream 的元素。 ## 旁路(Tapping) `Stream.tap` 操作允许你对 Stream 发出的每个元素运行一个 effect,从而观察或执行副作用,而不改变元素本身或返回类型。它适合用于记录日志、监控,或在每次发出元素时触发额外的动作。 **示例**(使用 `Stream.tap` 记录日志) 例如,可以用 `Stream.tap` 在映射操作的前后记录每个元素: ```ts import { Stream, Console, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.tap((n) => Console.log(`before mapping: ${n}`)), Stream.map((n) => n * 2), Stream.tap((n) => Console.log(`after mapping: ${n}`)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [2, 4, 6] ``` ## 取出元素 Stream 中的「取出」操作让你按固定数量、条件或位置从 Stream 中提取特定的元素集合。下面介绍几种使用这些操作的方式: | API | 说明 | | ----------- | ----------------------------------------------------- | | `take` | 提取固定数量的元素。 | | `takeWhile` | 在满足某个条件期间持续提取元素。 | | `takeUntil` | 提取元素,直到满足某个条件为止。 | | `takeRight` | 从末尾提取指定数量的元素。 | **示例**(以不同方式提取元素) ```ts import { Stream, Effect } from "effect" const stream = Stream.iterate(0, (n) => n + 1) // Using `take` to extract a fixed number of elements: const s1 = Stream.take(stream, 5) await Effect.runPromise(Stream.runCollect(s1)) // => [0, 1, 2, 3, 4] // Using `takeWhile` to extract elements while a condition is met: const s2 = Stream.takeWhile(stream, (n) => n < 5) await Effect.runPromise(Stream.runCollect(s2)) // => [0, 1, 2, 3, 4] // Using `takeUntil` to extract elements until a condition is met: const s3 = Stream.takeUntil(stream, (n) => n === 5) await Effect.runPromise(Stream.runCollect(s3)) // => [0, 1, 2, 3, 4, 5] // Using `takeRight` to take elements from the end of the stream: const s4 = Stream.takeRight(s3, 3) await Effect.runPromise(Stream.runCollect(s4)) // => [3, 4, 5] ``` ## Stream 作为 Async Iterable 的替代方案 在处理异步数据源(例如 async iterable)时,你常常需要在循环中消费数据,直到满足某个条件为止。Stream 提供了类似的思路,并带来了额外的灵活性。 使用 async iterable 时,数据会在循环中处理,直到遇到 `break` 或 `return` 语句。要在 Stream 中复现这种行为,可以考虑以下选项: | API | 说明 | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `takeUntil` | 从 Stream 中取出元素,直到满足指定条件为止,类似于跳出循环。 | | `toPull` | 返回一个 effect,它会持续从 Stream 中拉取由元素组成的数组。当 Stream 结束时,该 effect 会以 `Cause.Done` 完成信号失败;否则会以 Stream 自身的错误失败。 | **示例**(使用 `Stream.toPull`) ```ts import { Stream, Effect } from "effect" // Simulate a chunked stream const stream = Stream.fromIterable([1, 2, 3, 4, 5]).pipe(Stream.rechunk(2)) const program = Effect.gen(function* () { // Create an effect to get data chunks from the stream const getChunk = yield* Stream.toPull(stream) // Continuously fetch and process chunks while (true) { const chunk = yield* getChunk console.log(chunk) } }) Effect.runPromise(Effect.scoped(program)).then(console.log, console.error) /* Output: [ 1, 2 ] [ 3, 4 ] [ 5 ] { '~effect/Cause/Done': '~effect/Cause/Done', _tag: 'Done', value: undefined } */ ``` ## 映射 ### 基本映射 `Stream.map` 操作会对 Stream 中的每个元素应用指定的函数,生成一个包含转换后值的新 Stream。 **示例**(把每个元素加 1) ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 2, 3).pipe( Stream.map((n) => n + 1), // Increment each element by 1 ) await Effect.runPromise(Stream.runCollect(stream)) // => [2, 3, 4] ``` ### 映射为常量值 `Stream.map` 方法允许你把 Stream 中的每个成功值替换为指定的常量值。当你希望 Stream 中的所有元素都发出统一的值、而不关心原始数据时,这会很有用。 **示例**(映射为 `null`) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe(Stream.map(() => null)) await Effect.runPromise(Stream.runCollect(stream)) // => [null, null, null, null, null] ``` ### 带 Effect 的映射 对于涉及 effect 的转换,请使用 `Stream.mapEffect`。该函数会对 Stream 中的每个元素应用一个带 effect 的操作,生成一个包含 effect 结果的新 Stream。 **示例**(生成随机数) ```ts import { Stream, Random, Effect } from "effect" const stream = Stream.make(10, 20, 30).pipe( // Generate a random number between 0 and each element Stream.mapEffect((n) => Random.nextIntBetween(0, n)), ) const randomResults = await Effect.runPromise(Stream.runCollect(stream)) randomResults.length // => 3 ``` 要并发处理多个带 effect 的转换,可以使用 [concurrency](/docs/v4/concurrency/basic-concurrency/#concurrency-options) 选项。该选项允许指定数量的 effect 并发运行,结果会按原始顺序向下游发出。 **示例**(并发获取 URL) ```ts import { Stream, Effect } from "effect" const fetchUrl = (url: string) => Effect.gen(function* () { console.log(`Fetching ${url}`) yield* Effect.sleep("100 millis") console.log(`Fetching ${url} done`) return [`Resource 0-${url}`, `Resource 1-${url}`, `Resource 2-${url}`] }) const stream = Stream.make("url1", "url2", "url3").pipe( // Fetch each URL concurrently with a limit of 2 Stream.mapEffect(fetchUrl, { concurrency: 2 }), ) await Effect.runPromise(Stream.runCollect(stream)) // => [["Resource 0-url1", "Resource 1-url1", "Resource 2-url1"], ["Resource 0-url2", "Resource 1-url2", "Resource 2-url2"], ["Resource 0-url3", "Resource 1-url3", "Resource 2-url3"]] ``` ### 有状态映射 `Stream.mapAccum` 与 `Stream.map` 类似,但它在应用转换时会跟踪状态,让你可以在一次操作中同时完成映射与累加。它适合用于计算 Stream 中的累计值这类任务。 **示例**(计算累计总和) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe( // ┌─── next state // │ ┌─── emitted values // ▼ ▼ Stream.mapAccum( () => 0, (state, n) => [state + n, [state + n]], ), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 3, 6, 10, 15] ``` ### 映射与扁平化 `Stream.flattenIterable` 操作与 `Stream.map` 类似,但它更进一步:先把每个元素映射为零个或多个元素(以 `Iterable` 形式),再把整个 Stream 扁平化。当需要把每个元素转换为多个值时,它尤其有用。 **示例**(拆分并扁平化 Stream) ```ts import { Stream, Effect } from "effect" const numbers = Stream.make("1-2-3", "4-5", "6").pipe( Stream.map((s) => s.split("-")), Stream.flattenIterable, ) await Effect.runPromise(Stream.runCollect(numbers)) // => ["1", "2", "3", "4", "5", "6"] ``` ## 过滤 `Stream.filter` 操作只放行满足特定条件的元素。它可以保留 Stream 中符合某项标准的元素,并丢弃其余元素。 **示例**(过滤偶数) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 11).pipe(Stream.filter((n) => n % 2 === 0)) await Effect.runPromise(Stream.runCollect(stream)) // => [2, 4, 6, 8, 10] ``` ## 扫描 Stream 扫描让你可以累积地把一个函数应用到 Stream 的每个元素上,并发出每一个中间结果。与只给出最终结果的 `reduce` 不同,`scan` 提供了累积过程的逐步视图。 **示例**(累加求和) ```ts import { Stream, Effect } from "effect" const stream = Stream.range(1, 5).pipe(Stream.scan(0, (a, b) => a + b)) await Effect.runPromise(Stream.runCollect(stream)) // => [0, 1, 3, 6, 10, 15] ``` 如果只需要最终的累积值,可以使用 [Stream.runFold](/docs/v4/stream/consuming-streams/#using-runfold): **示例**(最终的累积结果) ```ts import { Stream, Effect } from "effect" const fold = Stream.range(1, 5).pipe( Stream.runFold( () => 0, (a, b) => a + b, ), ) await Effect.runPromise(fold) // => 15 ``` ## 排空 Stream 排空让你可以在 Stream 中执行带 effect 的操作,同时丢弃结果值。当你需要执行某些动作或副作用、但并不需要发出的值时,这会很有用。`Stream.drain` 函数通过忽略 Stream 中的所有元素并产出一个空的输出 Stream 来实现这一点。 **示例**(执行带 effect 的操作但不收集值) ```ts import { Stream, Effect, Random } from "effect" const stream = Stream.fromEffectRepeat( Effect.gen(function* () { const nextInt = yield* Random.nextInt const number = Math.abs(nextInt % 10) console.log(`random number: ${number}`) return number }), ).pipe(Stream.take(3)) const withValues = await Effect.runPromise(Stream.runCollect(stream)) withValues.length // => 3 const drained = Stream.drain(stream) await Effect.runPromise(Stream.runCollect(drained)) // => [] ``` ## 检测 Stream 中的变化 `Stream.changes` 操作会检测并发出 Stream 中与其前一个元素不同的元素。它适合用于跟踪变化,或对连续重复的值去重。 **示例**(发出连续但不同的元素) ```ts import { Stream, Effect } from "effect" const stream = Stream.make(1, 1, 1, 2, 2, 3, 4).pipe(Stream.changes) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, 4] ``` ## 组合(Zipping) 组合(Zipping)会把两个 Stream 的元素合并成一个新的 Stream,将来自每个输入 Stream 的元素配对。这可以通过 `Stream.zip` 或 `Stream.zipWith` 实现,后者允许自定义配对逻辑。 **示例**(基本的组合) 在这个示例中,两个 Stream 的元素会按顺序依次配对。当其中一个 Stream 耗尽时,结果 Stream 随之结束。 ```ts import { Stream, Effect } from "effect" // Zip two streams together const stream = Stream.zip( Stream.make(1, 2, 3, 4, 5, 6), Stream.make("a", "b", "c"), ) await Effect.runPromise(Stream.runCollect(stream)) // => [[1, "a"], [2, "b"], [3, "c"]] ``` **示例**(自定义组合逻辑) 这里,`Stream.zipWith` 会对每一对元素应用自定义逻辑,以用户定义的方式组合元素。 ```ts import { Stream, Effect } from "effect" // Zip two streams with custom pairing logic const stream = Stream.zipWith( Stream.make(1, 2, 3, 4, 5, 6), Stream.make("a", "b", "c"), (n, s) => [n + 10, s + "!"], ) await Effect.runPromise(Stream.runCollect(stream)) // => [[11, "a!"], [12, "b!"], [13, "c!"]] ``` ### 以不同速率组合 Stream 当组合的两个 Stream 以不同速度发出元素时,你可能不想等待较慢的那个 Stream 发出元素。使用 `Stream.zipLatest` 或 `Stream.zipLatestWith`,只要任一 Stream 产生新值,就可以立即进行配对。当较快的 Stream 有新值到达时,这些函数会使用较慢的那个 Stream 最近一次发出的元素。 **示例**(组合发出速率不同的 Stream) ```ts import { Stream, Schedule, Effect } from "effect" const s1 = Stream.make(1, 2, 3).pipe( Stream.schedule(Schedule.spaced("1 second")), ) const s2 = Stream.make("a", "b", "c", "d").pipe( Stream.schedule(Schedule.spaced("500 millis")), ) const stream = Stream.zipLatest(s1, s2) // The exact interleaving in the middle depends on real wall-clock timing, // but `zipLatest` always waits for both sides to emit before starting (so // the first pair is fixed) and both streams are exhausted together at the // end (so the last pair is fixed too) const zipLatestResults = await Effect.runPromise(Stream.runCollect(stream)) zipLatestResults[0] // => [1, "a"] zipLatestResults.at(-1) // => [3, "d"] ``` ### 与前一个和后一个元素配对 | API | 说明 | | ------------------------ | --------------------------------------------------------- | | `zipWithPrevious` | 把 Stream 的每个元素与其前一个元素配对。 | | `zipWithNext` | 把 Stream 的每个元素与其后一个元素配对。 | | `zipWithPreviousAndNext` | 把每个元素同时与其前一个和后一个元素配对。 | **示例**(把 Stream 元素与其后一个元素配对) ```ts import { Stream, Effect, Option } from "effect" const stream = Stream.zipWithNext(Stream.make(1, 2, 3, 4)) await Effect.runPromise(Stream.runCollect(stream)) // => [[1, Option.some(2)], [2, Option.some(3)], [3, Option.some(4)], [4, Option.none()]] ``` ### 为 Stream 元素建立索引 `Stream.zipWithIndex` 操作符是为 Stream 中每个元素建立索引的实用工具,它会把每个元素与其在序列中的位置配对。当你需要跟踪 Stream 中元素的顺序时,它尤其有用。 **示例**(为 Stream 中的每个元素建立索引) ```ts import { Stream, Effect } from "effect" const stream = Stream.zipWithIndex( Stream.make("Mary", "James", "Robert", "Patricia"), ) await Effect.runPromise(Stream.runCollect(stream)) // => [["Mary", 0], ["James", 1], ["Robert", 2], ["Patricia", 3]] ``` ## Stream 的笛卡尔积 Stream 模块包含计算两个 Stream 的_笛卡尔积_的功能,让你可以生成来自两个不同 Stream 的元素组合。当你需要把一组中的每个元素与另一组的所有元素配对时,这会很有用。 简单来说,假设你有两个集合,想从每个集合中各取一项来组成所有可能的配对,这个配对过程就是笛卡尔积。在 Stream 中,该操作会生成一个新的 Stream,其中包含两个输入 Stream 元素的所有可能配对。 要创建两个 Stream 的笛卡尔积,可以使用 `Stream.cross` 操作符及其类似变体。这些操作符会把两个 Stream 组合成一个包含所有可能元素组合的新 Stream。 **示例**(创建两个 Stream 的笛卡尔积) ```ts import { Stream, Effect, Console } from "effect" const s1 = Stream.make(1, 2, 3).pipe(Stream.tap(Console.log)) const s2 = Stream.make("a", "b").pipe(Stream.tap(Console.log)) const cartesianProduct = Stream.cross(s1, s2) await Effect.runPromise(Stream.runCollect(cartesianProduct)) // => [[1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]] ``` ## 分区 流的分区是指按照指定条件把一个流拆分为两个不同的流。Stream 模块为此提供了两个函数:`Stream.partition` 和 `Stream.partitionEffect`。下面来看看它们的工作方式,以及最适合使用它们的场景。 ### partition `Stream.partition` 函数接收一个 `Filter` 作为输入,把原始流拆分为两个子流:一个子流包含满足条件的元素,另一个包含不满足条件的元素。得到的两个子流都被包装在 `Scope` 类型中。 **示例**(把流拆分为奇数和偶数) ```ts import { Stream, Effect, Filter } from "effect" // ┌─── Effect<[Stream, Stream], never, Scope> // ▼ const program = Stream.range(1, 9).pipe( Stream.partition( Filter.fromPredicate((n) => n % 2 === 0), { bufferSize: 5 }, ), ) await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const [odds, evens] = yield* program return [yield* Stream.runCollect(odds), yield* Stream.runCollect(evens)] }), ), ) // => [[1, 3, 5, 7, 9], [2, 4, 6, 8]] ``` ### partitionEffect 有些情况下,你可能需要用涉及 effect 的条件来对流进行分区,这时 `Stream.partitionEffect` 函数正合适。它使用一个带 effect 的 `Filter` 把流拆分为两个子流:一个用于产生 `Result.succeed` 值的元素,另一个用于产生 `Result.fail` 值的元素。 **示例**(用带 effect 的谓词对流进行分区) ```ts import { Stream, Effect, Filter, Result } from "effect" // ┌─── Effect<[Stream, Stream], never, Scope> // ▼ const program = Stream.range(1, 9).pipe( Stream.partitionEffect( // Simulate an effectful computation Filter.makeEffect((n: number) => Effect.succeed(n % 2 === 0 ? Result.succeed(n) : Result.fail(n)), ), { capacity: 5 }, ), ) await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const [evens, odds] = yield* program return [yield* Stream.runCollect(odds), yield* Stream.runCollect(evens)] }), ), ) // => [[1, 3, 5, 7, 9], [2, 4, 6, 8]] ``` ## 分组 处理数据流时,你可能需要按照特定条件对元素进行分组。Stream 模块为此提供了 `groupByKey`、`groupBy`、`grouped` 和 `groupedWithin` 四个函数。下面逐一看看它们的工作方式以及各自适用的场景。 ### groupByKey `Stream.groupByKey` 函数根据一个类型为 `(a: A) => K` 的键函数对流进行分区,其中 `A` 是流中元素的类型,`K` 表示用于分组的键。该函数不涉及 effect,只是简单地应用所提供的键函数来对元素进行分组。 `Stream.groupByKey` 的结果是一个普通的 `Stream`,其元素为 `readonly [K, Stream]` 对,表示分组后的流。要处理每个分组,可以配合 `{ concurrency: "unbounded" }` 使用 `Stream.flatMap`,并传入一个类型为 `([key, stream]: [K, Stream]) => Stream.Stream<...>` 的函数。该函数会作用于所有分组,并以不确定的顺序把它们合并在一起。 **示例**(按考试成绩的十位数分组) 在下面的示例中,我们使用 `Stream.groupByKey` 按十位数对考试成绩进行分组,并统计每个分组中的成绩数量: ```ts import { Stream, Effect } from "effect" class Exam { constructor( readonly person: string, readonly score: number, ) {} } // Define a list of exam results const examResults = [ new Exam("Alex", 64), new Exam("Michael", 97), new Exam("Bill", 77), new Exam("John", 78), new Exam("Bobby", 71), ] // Group exam results by the tens place in the score const groupByKeyResult = Stream.fromIterable(examResults).pipe( Stream.groupByKey((exam) => Math.floor(exam.score / 10) * 10), ) // Count the number of exam results in each group const stream = groupByKeyResult.pipe( Stream.flatMap( ([key, stream]) => Stream.fromEffect( Stream.runCollect(stream).pipe( Effect.map((values) => [key, values.length] as const), ), ), { concurrency: "unbounded" }, ), ) await Effect.runPromise(Stream.runCollect(stream)) // => [[60, 1], [90, 1], [70, 3]] ``` ### groupBy 当分组需求更复杂、分区过程涉及 effect 时,可以使用 `Stream.groupBy` 函数。它接收一个带 effect 的分区函数,并返回一个普通的 `Stream`,其元素为 `readonly [K, Stream]` 对,表示分组后的流。随后你可以像 `Stream.groupByKey` 那样,使用 `Stream.flatMap` 处理每个分组。 **示例**(按首字母对名字分组) 在下面的示例中,我们按名字的首字母进行分组,并统计每个分组中的名字数量。这里的分区操作是以带 effect 的方式设置的: ```ts import { Stream, Effect } from "effect" // Group names by their first letter const groupByKeyResult = Stream.fromIterable([ "Mary", "James", "Robert", "Patricia", "John", "Jennifer", "Rebecca", "Peter", ]).pipe( // Simulate an effectful groupBy operation Stream.groupBy((name) => Effect.succeed([name.substring(0, 1), name] as const), ), ) // Count the number of names in each group and display results const stream = groupByKeyResult.pipe( Stream.flatMap( ([key, stream]) => Stream.fromEffect( Stream.runCollect(stream).pipe( Effect.map((values) => [key, values.length] as const), ), ), { concurrency: "unbounded" }, ), ) await Effect.runPromise(Stream.runCollect(stream)) // => [["M", 1], ["J", 3], ["R", 2], ["P", 2]] ``` ### grouped `Stream.grouped` 函数适合把流划分为指定大小的块,从而更便于以更小、更规整的片段来处理数据。在批量处理或展示数据时,这尤其有用。 **示例**(把流划分为每 3 个元素的块) ```ts import { Stream, Effect } from "effect" // Create a stream of numbers and group them into chunks of 3 const stream = Stream.range(0, 8).pipe(Stream.grouped(3)) await Effect.runPromise(Stream.runCollect(stream)) // => [[0, 1, 2], [3, 4, 5], [6, 7, 8]] ``` ### groupedWithin `Stream.groupedWithin` 函数提供了更灵活的分组方式:它根据指定的最大大小或时间间隔中先满足的那个条件来创建块。当处理的数据涉及时间约束时,这尤其有用。 **示例**(按大小或时间间隔分组) 在这个示例中,`Stream.groupedWithin(18, "1.5 seconds")` 会在累积满 18 个元素、或者距离上一块创建已过去 1.5 秒时,把流分成一块。 ```ts import { Stream, Schedule, Effect } from "effect" // Create a stream that repeats every second and group by size or time const stream = Stream.range(0, 9).pipe( Stream.repeat(Schedule.spaced("1 second")), Stream.groupedWithin(18, "1.5 seconds"), Stream.take(3), ) await Effect.runPromise(Stream.runCollect(stream)) // => [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7], [8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]] ``` ## 拼接 在流处理中,你可能需要把多个流的内容组合起来。Stream 模块提供了若干操作符来实现这一点,包括 `Stream.concat`、`Stream.flatten` 和 `Stream.flatMap`。下面看看这些操作符各自的工作方式。 ### 简单拼接 `Stream.concat` 操作符是连接两个流最直接的方式。它返回一个新的流,先发出第一个流(左侧)的元素,再发出第二个流(右侧)的元素。当你希望按特定顺序组合两个流时,这会很有用。 **示例**(按顺序拼接两个流) ```ts import { Stream, Effect } from "effect" const stream = Stream.concat(Stream.make(1, 2, 3), Stream.make("a", "b")) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b"] ``` ### 拼接多个流 如果要拼接多个流,`Stream.flatten` 提供了一种高效的方式,无需手动串联多个 `Stream.concat` 操作。该函数接收一个由流组成的流,并返回一个按顺序包含各个流中元素的单一流。 **示例**(拼接多个流) ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3) const s2 = Stream.make("a", "b") const s3 = Stream.make(true, false, false) const stream = Stream.flatten( Stream.fromIterable>([s1, s2, s3]), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, "a", "b", true, false, false] ``` ### 用 flatMap 进行高级拼接 `Stream.flatMap` 操作符支持更高级的拼接:它对源流的每个输出应用一个类型为 `(a: A) => Stream<...>` 的函数,从而生成一个新的流。随后该操作符会拼接所有得到的流,实际上把它们展平。 **示例**(用 `Stream.flatMap` 生成重复元素) ```ts import { Stream, Effect } from "effect" // Create a stream where each element is repeated 4 times const stream = Stream.make(1, 2, 3).pipe( Stream.flatMap((a) => Stream.forever(Stream.succeed(a)).pipe(Stream.take(4))), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3] ``` 如果需要并发执行 `flatMap` 操作,可以使用 [concurrency](/docs/v4/concurrency/basic-concurrency/#concurrency-options) 选项来控制同时运行多少个内部流。 此外,你还可以使用 `switch` 选项来实现“切换”行为:当源流有新的元素到达时,之前的流会被自动取消。当你只需要最新的结果,并希望通过取消过时的操作来节省资源时,这尤其有用。 **示例**(使用 `switch` 选项) ```ts import { Stream, Effect, Console } from "effect" // Helper function to create a stream with logging const createStreamWithLogging = (n: number) => Stream.fromEffect( Effect.gen(function* () { console.log(`Starting stream for value: ${n}`) const result = yield* Effect.delay(Effect.succeed(n), "500 millis") console.log(`Completed stream for value: ${result}`) return result }).pipe( Effect.onInterrupt(() => Console.log(`Interrupted stream for value: ${n}`), ), ), ) // Without switch (default behavior): // all streams run to completion const stream1 = Stream.fromIterable([1, 2, 3]).pipe( Stream.flatMap(createStreamWithLogging), ) // With switch behavior: // only the last stream completes, previous streams // are cancelled when new values arrive const stream2 = Stream.fromIterable([1, 2, 3]).pipe( Stream.switchMap(createStreamWithLogging), ) // Run examples sequentially to see the difference await Effect.runPromise( Effect.gen(function* () { console.log("=== Without switch (all streams complete) ===") const result1 = yield* Stream.runCollect(stream1) console.log(result1) console.log("\n=== With switch (only last stream completes) ===") const result2 = yield* Stream.runCollect(stream2) console.log(result2) return [result1, result2] }), ) // => [[1, 2, 3], [3]] ``` `switch` 选项在搜索功能、实时数据处理等场景中尤其有价值:凡是希望在新输入到达时丢弃先前操作的情况,都很适用。 ## 合并 有时你可能希望把两个流的元素交错在一起,生成一个单一的输出流。这时 `Stream.concat` 并不合适,因为它会等第一个流完成后才去消费第二个流。若要在元素可用时就交错它们,`Stream.merge` 及其变体正是为此设计的。 ### merge `Stream.merge` 操作把两个源流的元素组合成一个流,并在元素产生时将它们交错输出。与 `Stream.concat` 不同,`Stream.merge` 不会等一个流结束后再开始另一个流。 **示例**(用 `Stream.merge` 交错两个流) ```ts import { Schedule, Stream, Effect } from "effect" // Create two streams with different emission intervals const s1 = Stream.make(1, 2, 3).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.make(4, 5, 6).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) // Merge s1 and s2 into a single stream that interleaves their values const merged = Stream.merge(s1, s2) // The relative order between the two streams can jitter under real // scheduling, but each stream's own emission order is always preserved const mergedValues = await Effect.runPromise(Stream.runCollect(merged)) mergedValues.filter((n) => n <= 3) // => [1, 2, 3] mergedValues.filter((n) => n > 3) // => [4, 5, 6] ``` ### 终止策略 合并两个流时,考虑终止策略很重要,尤其是在每个流生命周期不同的情况下。默认情况下,`Stream.merge` 会等待两个流都终止后才结束合并后的流。不过,你可以通过 `haltStrategy` 修改这一行为,它提供了四种终止策略: | 终止策略 | 说明 | | --- | --- | | `"left"` | 当左侧的流终止时,合并后的流随之终止。 | | `"right"` | 当右侧的流终止时,合并后的流随之终止。 | | `"both"`(默认) | 只有当两个流都终止后,合并后的流才终止。 | | `"either"` | 只要任意一个流终止,合并后的流就立即终止。 | **示例**(用 `haltStrategy: "left"` 控制流的终止) ```ts import { Stream, Schedule, Effect } from "effect" const s1 = Stream.range(1, 5).pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.forever(Stream.succeed(0)).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) const merged = Stream.merge(s1, s2, { haltStrategy: "left" }) await Effect.runPromise(Stream.runCollect(merged)) // => [1, 0, 2, 3, 0, 4, 5] ``` ### mergeWith 有些情况下,你可能希望在合并两个流的同时把它们的元素转换为统一的类型。为此,可以把 `Stream.merge` 与作用于每个源流的 `Stream.map` 结合使用,从而为每个源流指定转换函数。 **示例**(合并并转换两个流) ```ts import { Schedule, Stream, Effect } from "effect" const s1 = Stream.make("1", "2", "3").pipe( Stream.schedule(Schedule.spaced("100 millis")), ) const s2 = Stream.make(4.1, 5.3, 6.2).pipe( Stream.schedule(Schedule.spaced("200 millis")), ) const merged = Stream.merge( // Convert string elements from `s1` to integers Stream.map(s1, (s) => parseInt(s)), // Round down decimal elements from `s2` Stream.map(s2, (n) => Math.floor(n)), ) const mergedResults = await Effect.runPromise(Stream.runCollect(merged)) mergedResults.length // => 6 ``` ## 交替 ### interleave `Stream.interleave` 操作符让你每次从两个流中各取出一个元素,从而生成一个新的交替流。如果其中一个流先结束,另一个流中剩余的元素会继续被取出,直到两个流都耗尽。 **示例**(两个流的基本交替) ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 2, 3) const s2 = Stream.make(4, 5, 6) const stream = Stream.interleave(s1, s2) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 4, 2, 5, 3, 6] ``` ### interleaveWith 对于更复杂的交替需求,`Stream.interleaveWith` 通过一个由 `boolean` 值组成的第三个流来指定交替模式,提供了额外的控制:当该流发出 `true` 时,从左侧的流取一个元素;否则从右侧的流取一个元素。 **示例**(用 `Stream.interleaveWith` 实现自定义交替逻辑) ```ts import { Stream, Effect } from "effect" const s1 = Stream.make(1, 3, 5, 7, 9) const s2 = Stream.make(2, 4, 6, 8, 10) // Define a boolean stream to control interleaving const booleanStream = Stream.make(true, false, false).pipe(Stream.forever) const stream = Stream.interleaveWith(s1, s2, booleanStream) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 4, 3, 6, 8, 5, 10, 7, 9] ``` ## 穿插 穿插会在流中添加分隔元素或前后缀,这有助于对流中的数据进行格式化或结构化。 ### intersperse `Stream.intersperse` 操作符会在流中每两个元素之间插入一个指定的分隔元素。这个分隔元素可以是任意选定的值,会被添加到每一对相邻元素之间。 **示例**(在流元素之间插入分隔元素) ```ts import { Stream, Effect } from "effect" // Create a stream of numbers and intersperse `0` between them const stream = Stream.make(1, 2, 3, 4, 5).pipe(Stream.intersperse(0)) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 0, 2, 0, 3, 0, 4, 0, 5] ``` ### intersperseAffixes 对于更复杂的需求,`Stream.intersperseAffixes` 可以分别控制流开头、元素之间以及流末尾所添加的不同前后缀。 **示例**(为流添加前后缀) ```ts import { Stream, Effect } from "effect" // Create a stream and add affixes: // - `[` at the start // - `|` between elements // - `]` at the end const stream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.intersperseAffixes({ start: "[", middle: "|", end: "]", }), ) await Effect.runPromise(Stream.runCollect(stream)) // => ["[", 1, "|", 2, "|", 3, "|", 4, "|", 5, "]"] ``` ## 广播 广播一个流会创建多个下游流,它们都会从源流接收到相同的元素。当你希望把每个元素同时发送给多个消费者时,这很有用。上游流有一个 `capacity` 参数,用于限制它在放慢速度以匹配最慢的下游流之前能领先多少。 **示例**(广播到多个下游流) 在下面的示例中,我们把一个数字流广播给两个下游消费者。第一个计算流中的最大值,第二个则带延迟地记录每个数字。上游流的速度会根据较慢的那个日志流进行调整: ```ts import { Effect, Stream, Console, Schedule, Fiber } from "effect" const numbers = Effect.scoped( Effect.gen(function* () { // Broadcast to 2 downstream consumers with a capacity of 5 const [first, second] = yield* Stream.range(1, 20).pipe( Stream.tap((n) => Console.log(`Emit ${n} element before broadcasting`)), Stream.broadcastN({ n: 2, capacity: 5 }), ) // First downstream stream: calculates maximum const fiber1 = yield* Stream.runFold( first, () => 0, (acc, e) => Math.max(acc, e), ).pipe( Effect.andThen((max) => Console.log(`Maximum: ${max}`)), Effect.forkChild, ) // Second downstream stream: logs each element with a delay const fiber2 = yield* second.pipe( Stream.schedule(Schedule.spaced("1 second")), Stream.runForEach((n) => Console.log(`Logging to the Console: ${n}`)), Effect.forkChild, ) // Wait for both fibers to complete yield* Fiber.join(fiber1).pipe( Effect.zip(Fiber.join(fiber2), { concurrent: true }), ) }), ) await Effect.runPromise(numbers) // => undefined ``` ## 缓冲 Effect 的流采用拉取(pull-based)模型,下游消费者可以控制自己请求元素的速率。然而,当生产者与消费者的速度不匹配时,缓冲有助于平衡二者的交互。`Stream.buffer` 操作符正是为此设计的:即使消费者较慢,生产者也能继续工作。你可以通过 `capacity` 选项设置缓冲的最大容量。 ### buffer `Stream.buffer` 操作符会把元素排入队列,让生产者能够在指定容量内独立于消费者工作。当较快的生产者与较慢的消费者需要顺畅运行、互不阻塞时,这很有帮助。 **示例**(用缓冲区应对速度不匹配) ```ts import { Stream, Console, Schedule, Effect } from "effect" const stream = Stream.range(1, 10).pipe( // Log each element before buffering Stream.tap((n) => Console.log(`before buffering: ${n}`)), // Buffer with a capacity of 4 elements Stream.buffer({ capacity: 4 }), // Log each element after buffering Stream.tap((n) => Console.log(`after buffering: ${n}`)), // Add a 5-second delay between each emission Stream.schedule(Schedule.spaced("5 seconds")), ) Effect.runPromise(Stream.runCollect(stream)).then(console.log) /* Output: before buffering: 1 before buffering: 2 before buffering: 3 before buffering: 4 before buffering: 5 before buffering: 6 after buffering: 1 after buffering: 2 before buffering: 7 after buffering: 3 before buffering: 8 after buffering: 4 before buffering: 9 after buffering: 5 before buffering: 10 ... */ ``` 不同的缓冲选项让你可以根据使用场景定制缓冲策略: | **缓冲类型** | **配置** | **说明** | | --- | --- | --- | | **有界队列** | `{ capacity: number }` | 把队列限制为固定大小。 | | **无界队列** | `{ capacity: "unbounded" }` | 允许缓冲的条目数量不受限制。 | | **滑动队列** | `{ capacity: number, strategy: "sliding" }` | 保留最新的条目,队列满时丢弃较旧的条目。 | | **丢弃队列** | `{ capacity: number, strategy: "dropping" }` | 保留最早的条目,队列满时丢弃新到的条目。 | ## 防抖 防抖是一种用来避免函数触发过于频繁的技术,当流快速发射值、而你只需要停顿之后的最后一个值时,它尤其有用。 `Stream.debounce` 函数通过推迟值的发射来实现这一点:只有在指定时间段内没有新值出现时,才会发射值。如果在等待期间有新值到达,计时器就会重置,最终只会在一次停顿之后发射最新的那个值。 **示例**(对快速发射值的流进行防抖) ```ts import { Stream, Effect } from "effect" // Helper function to log with elapsed time since the last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.make(1, 2, 3).pipe( // Emit the value 4 after 200 ms Stream.concat( Stream.fromEffect(Effect.sleep("200 millis").pipe(Effect.as(4))), ), // Continue with more rapid values Stream.concat(Stream.make(5, 6)), // Emit 7 after 150 ms Stream.concat( Stream.fromEffect(Effect.sleep("150 millis").pipe(Effect.as(7))), ), Stream.concat(Stream.make(8)), Stream.tap((n) => log(`Received ${n}`)), // Only emit values after a pause of at least 100 milliseconds Stream.debounce("100 millis"), Stream.tap((n) => log(`> Emitted ${n}`)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [3, 6, 8] ``` ## 节流 节流是一种调节流元素发射速率的技术。它有助于保持稳定的数据输出节奏,在数据处理需要以恒定速率进行时很有价值。 `Stream.throttle` 函数使用[令牌桶算法](https://en.wikipedia.org/wiki/Token_bucket)来控制流的发射速率。 **示例**(节流配置) ```ts Stream.throttle({ cost: () => 1, duration: "100 millis", units: 1, }) ``` 在这个配置中: - 每个被处理的 chunk 消耗一个令牌(`cost = () => 1`)。 - 令牌以每 100 毫秒(`duration: "100 millis"`)补充一个(`units: 1`)的速率重新填充。 ### shape 策略(默认) `shape` 策略通过延迟 chunk 的发射来调节数据流,直到它们满足指定的带宽约束。 该策略确保数据吞吐量不超过设定的上限,从而实现稳定可控的数据发射。 **示例**(使用 `shape` 策略进行节流) ```ts import { Stream, Effect, Schedule } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.fromSchedule(Schedule.spaced("50 millis")).pipe( Stream.take(6), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: (arr) => arr.length, duration: "100 millis", units: 1, }), Stream.tap((n) => log(`> Emitted ${n}`)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [0, 1, 2, 3, 4, 5] ``` ### enforce 策略 `enforce` 策略通过丢弃超出带宽约束的 chunk 来严格调节数据流。 **示例**(使用 `enforce` 策略进行节流) ```ts import { Stream, Effect, Schedule } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.make(1, 2, 3, 4, 5, 6).pipe( Stream.schedule(Schedule.exponential("100 millis")), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: (arr) => arr.length, duration: "1 second", units: 1, strategy: "enforce", }), Stream.tap((n) => log(`> Emitted ${n}`)), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 4, 5, 6] ``` ### burst 选项 `Stream.throttle` 函数提供了 burst 选项,允许数据吞吐量暂时超过设定的速率上限。 将该选项设为大于 0 即可启用 burst 能力(默认值为 0,表示不支持 burst)。 burst 容量会在令牌桶中提供额外的令牌,使流在出现数据突发时能够短暂超过其配置的速率。 **示例**(带 burst 容量的节流) ```ts import { Effect, Schedule, Stream } from "effect" // Helper function to log with elapsed time since last log let last = Date.now() const log = (message: string) => Effect.sync(() => { const end = Date.now() console.log(`${message} after ${end - last}ms`) last = end }) const stream = Stream.fromSchedule(Schedule.spaced("10 millis")).pipe( Stream.take(20), Stream.tap((n) => log(`Received ${n}`)), Stream.throttle({ cost: (arr) => arr.length, duration: "200 millis", units: 5, strategy: "enforce", burst: 2, }), Stream.tap((n) => log(`> Emitted ${n}`)), ) // Exact emitted values depend on real wall-clock timing and vary between // runs, but the "enforce" strategy only ever drops chunks. It never // reorders or duplicates them, so the result is always a strictly // increasing subsequence of 0..19 starting with 0 const burstResults = await Effect.runPromise(Stream.runCollect(stream)) burstResults[0] // => 0 burstResults.every((n, i) => i === 0 || n > burstResults[i - 1]) // => true ``` 在这个配置中,流开始时令牌桶里有 5 个令牌,因此最初的五个 chunk 会被立即发射。 额外的 burst 容量 2 可以暂时容纳更多发射,从而更灵活地处理后续数据。 随着时间推移,令牌桶会按照节流配置重新填充,更多元素会被发射,这也展示了 burst 能力如何有效地应对不均匀的数据流。 ## 调度 在处理流时,你可能需要在每个元素的发射之间引入特定的时间间隔。`Stream.schedule` 组合子允许你设置这些间隔。 **示例**(在流发射之间添加延迟) ```ts import { Stream, Schedule, Console, Effect } from "effect" // Create a stream that emits values with a 1-second delay between each const stream = Stream.make(1, 2, 3, 4, 5).pipe( Stream.schedule(Schedule.spaced("1 second")), Stream.tap(Console.log), ) await Effect.runPromise(Stream.runCollect(stream)) // => [1, 2, 3, 4, 5] ``` 在这个示例中,我们使用 `Schedule.spaced("1 second")` 在流的每次发射之间引入了一秒的间隔。 --- # 资源管理型 Stream > 学习如何在 Stream 中管理资源:安全地获取与释放、用于清理任务的终结处理(finalization),以及确保在终结之后执行清理动作,从而在流式应用中稳健地处理资源。 由 Stream 获取的资源必须在 Stream 被消费的整个期间保持打开。组合使用 `Effect.acquireRelease`、`Stream.fromEffect` 和 `Stream.scoped`,即可把资源的生命周期绑定到 Stream 上。当 Stream 只需要一个 finalizer 时,使用 `Stream.ensuring`。 ## 获取与释放 下面的示例获取一个文件,逐行发出其内容,并在 Stream 消费结束时关闭它。 ```ts import { Stream, Console, Effect } from "effect" // Simulating File operations const open = (filename: string) => Effect.gen(function* () { yield* Console.log(`Opening ${filename}`) return { getLines: Effect.succeed(["Line 1", "Line 2", "Line 3"]), close: Console.log(`Closing ${filename}`), } }) const stream = Stream.scoped( Stream.fromEffect( Effect.acquireRelease(open("file.txt"), (file) => file.close), ), ).pipe(Stream.flatMap((file) => Stream.fromIterableEffect(file.getLines))) await Effect.runPromise(Stream.runCollect(stream)) // => ["Line 1", "Line 2", "Line 3"] ``` `Effect.acquireRelease` 会把 `file.close` 注册到 `Stream.scoped` 创建的 Scope 中。因此,在 `Stream.fromIterableEffect` 发出文件内容期间,文件会一直保持打开。 ## 终结处理 `Stream.ensuring` 会在 Stream 自身的 finalizer 之后运行一个 finalizer,无论 Stream 是成功、失败还是被中断。 ```ts import { Stream, Console, Effect } from "effect" const application = Stream.fromEffect(Console.log("Application Logic.")) const deleteDir = (dir: string) => Console.log(`Deleting dir: ${dir}`) const program = application.pipe( Stream.ensuring( deleteDir("tmp").pipe( Effect.andThen(Console.log("Temporary directory was deleted.")), ), ), ) await Effect.runPromise(Stream.runCollect(program)) // => [undefined] ``` --- # TestClock > 在测试中用 Effect 的 TestClock 控制时间,模拟时间流逝、延迟与周期性重复的 Effect,而无需等待真实时间。 大多数情况下,我们希望单元测试尽可能快地运行。等待真实时间流逝会显著拖慢测试速度。Effect 提供了一个名为 `TestClock` 的便捷工具,它让我们能够**在测试期间控制时间**。这意味着我们可以高效且可预测地测试涉及时间的代码,而无需等待真实时间流逝。 ## TestClock 的工作原理 可以把 `TestClock` 想象成一个挂钟,只有当我们用 `TestClock.adjust` 和 `TestClock.setTime` 函数手动调整它时,它才会向前走。时钟时间不会自行推进。 当我们调整时钟时间时,任何计划在该时间点或之前运行的 Effect 都会执行。这让我们能在测试中模拟时间流逝,而无需等待真实时间。 **示例**(用 TestClock 模拟超时) ```ts import { Effect, Fiber, Option } from "effect" import { TestClock } from "effect/testing" import * as assert from "node:assert" const test = Effect.gen(function* () { // Create a fiber that sleeps for 5 minutes and then times out // after 1 minute const fiber = yield* Effect.sleep("5 minutes").pipe( Effect.map(Option.some), Effect.timeoutOrElse({ duration: "1 minute", orElse: () => Effect.succeed(Option.none()), }), Effect.forkChild, ) // Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute") // Get the result of the fiber const result = yield* Fiber.join(fiber) // Check if the result is None, indicating a timeout assert.ok(Option.isNone(result)) }).pipe(Effect.provide(TestClock.layer())) const outcome = await Effect.runPromise(test) outcome // => undefined ``` 关键点在于要把调用 `Effect.sleep` 的那个 fiber fork 出去。对 `Effect.sleep` 及相关方法的调用会一直等待,直到时钟时间达到或超过它们计划执行的时间。通过 fork 这个 fiber,我们就能保留对时钟时间调整的控制权。 ## 测试周期性重复的 Effect 下面这个示例演示如何用 `TestClock` 测试一个按固定间隔运行的 Effect: **示例**(测试按固定间隔运行的 Effect) 在这个示例中,我们测试一个按固定间隔运行的 Effect。我们用一个无界队列来管理这些 Effect,并验证以下几点: 1. 在指定的重复周期之前不会发生任何 Effect。 2. 在重复周期之后会发生一次 Effect。 3. 该 Effect 恰好只执行一次。 ```ts import { Effect, Queue, Option } from "effect" import { TestClock } from "effect/testing" import * as assert from "node:assert" const test = Effect.gen(function* () { const q = yield* Queue.unbounded() yield* Queue.offer(q, undefined).pipe( // Delay the effect for 60 minutes and repeat it forever Effect.delay("60 minutes"), Effect.forever, Effect.forkChild, ) // Check if no effect is performed before the recurrence period const a = yield* Queue.poll(q).pipe(Effect.map(Option.isNone)) // Adjust the TestClock by 60 minutes to simulate the passage of time yield* TestClock.adjust("60 minutes") // Check if an effect is performed after the recurrence period const b = yield* Queue.take(q).pipe(Effect.as(true)) // Check if the effect is performed exactly once const c = yield* Queue.poll(q).pipe(Effect.map(Option.isNone)) // Adjust the TestClock by another 60 minutes yield* TestClock.adjust("60 minutes") // Check if another effect is performed const d = yield* Queue.take(q).pipe(Effect.as(true)) const e = yield* Queue.poll(q).pipe(Effect.map(Option.isNone)) // Ensure that all conditions are met assert.ok(a && b && c && d && e) }).pipe(Effect.provide(TestClock.layer())) const outcome = await Effect.runPromise(test) outcome // => undefined ``` 需要注意,每次重复之后,下一次重复都会被安排在合适的时间发生。把时钟调整 60 分钟恰好会向队列放入一个值;再调整 60 分钟又会增加一个值。 ## 测试 Clock 这个示例演示如何用 `TestClock` 测试 `Clock` 的行为: **示例**(用 TestClock 模拟时间流逝) ```ts import { Effect, Clock } from "effect" import { TestClock } from "effect/testing" import * as assert from "node:assert" const test = Effect.gen(function* () { // Get the current time using the Clock const startTime = yield* Clock.currentTimeMillis // Adjust the TestClock by 1 minute to simulate the passage of time yield* TestClock.adjust("1 minute") // Get the current time again const endTime = yield* Clock.currentTimeMillis // Check if the time difference is at least // 60,000 milliseconds (1 minute) assert.ok(endTime - startTime >= 60_000) }).pipe(Effect.provide(TestClock.layer())) const outcome = await Effect.runPromise(test) outcome // => undefined ``` ## 测试 Deferred `TestClock` 同样会影响那些计划在特定时间之后运行的异步代码。 **示例**(用 Deferred 和 TestClock 模拟延迟执行) ```ts import { Effect, Deferred } from "effect" import { TestClock } from "effect/testing" import * as assert from "node:assert" const test = Effect.gen(function* () { // Create a deferred value const deferred = yield* Deferred.make() // Run two effects concurrently: sleep for 10 seconds and succeed // the deferred with a value of 1 yield* Effect.all( [Effect.sleep("10 seconds"), Deferred.succeed(deferred, 1)], { concurrency: "unbounded", }, ).pipe(Effect.forkChild) // Adjust the TestClock by 10 seconds yield* TestClock.adjust("10 seconds") // Await the value from the deferred const readRef = yield* Deferred.await(deferred) // Verify the deferred value is correctly set assert.ok(readRef === 1) }).pipe(Effect.provide(TestClock.layer())) const outcome = await Effect.runPromise(test) outcome // => undefined ``` --- # Equal > 为 TypeScript 中的值实现基于值的相等性检查,提升数据完整性并带来可预测的行为。 `Equal` 模块提供了一种简单便捷的方式,用于在 TypeScript 中定义并检查两个值之间的相等性。 以下是 Effect 导出 `Equal` 模块的一些关键原因: 1. **默认采用基于值的相等性**:JavaScript 原生的相等运算符(`===` 和 `==`)按引用检查相等性,也就是说它们依据内存地址而非内容来比较对象。当你想要比较值相同但引用不同的对象时,这种行为会带来麻烦。`Equal.equals` 函数开箱即用地解决了常见场景:普通对象、数组、`Map`、`Set`、`Date` 和 `RegExp` 都会进行结构比较,无需任何额外设置。 2. **自定义相等性**:有时结构比较并不是你想要的,例如相等性只应取决于值的一部分。`Equal` 模块让开发者能为自己的数据类型和类实现自定义的相等性检查。通过实现 `Equal` 接口,开发者可以定义自己的相等性逻辑。 3. **数据完整性**:在某些应用中,维护数据完整性至关重要。能够执行基于值的相等性检查,可以确保相同的数据不会在 `Set` 或 `Map` 这类集合中重复出现。这可以带来更高效的内存使用和更可预测的行为。 4. **可预测的行为**:`Equal` 模块让对象比较更加可预测。结构化的值会一致地按内容比较,自定义类型会使用它们自己定义的逻辑比较,而在确实需要时,你仍然可以让个别对象采用引用相等。 ## 如何在 Effect 中进行相等性检查 在 Effect 中,建议**停止使用** JavaScript 的 `===` 和 `==` 运算符,转而依赖 `Equal.equals` 函数。 该函数可以处理任何实现了 `Equal` 接口的数据类型。 这类数据类型的例子包括 [Option](/docs/v4/data-types/option/)、[Result](/docs/v4/data-types/result/)、[HashSet](https://effect.website/docs/v4/api/effect/HashSet) 和 [HashMap](https://effect.website/docs/v4/api/effect/HashMap)。 默认情况下,`Equal.equals` 会执行深度结构比较。普通对象、数组、`Map`、`Set`、`Date` 和 `RegExp` 都会按内容而非引用进行比较,即使它们并没有实现 `Equal` 接口: **示例**(使用默认结构比较的 `Equal.equals`) ```ts import { Equal } from "effect" // Two objects with identical properties and values const a = { name: "Alice", age: 30 } const b = { name: "Alice", age: 30 } // Equal.equals compares plain objects structurally by default console.log(Equal.equals(a, b)) Equal.equals(a, b) // => true ``` 在这个例子中,`a` 和 `b` 是两个内容相同但彼此独立的对象。`===` 会认为它们不同,因为它们位于不同的内存位置,而 `Equal.equals` 会判定它们相等,因为普通对象默认进行结构比较。 不过,结构比较并不总是你想要的。有时相等性只应取决于值的一部分(例如某个标识符),而忽略其余部分;有时你可能需要让 `Equal.equals` 按引用而非按值比较某个特定对象。有两种方式可以改变默认行为: 1. **实现 `Equal` 接口**:当你需要定义自定义相等性逻辑时,这种方式很有用。 2. **选择引用相等**:标记特定对象,让 `Equal.equals` 按引用而非结构来比较它们。 下面我们来逐一探索。 ### 实现 Equal 接口 要创建自定义的相等性行为,你可以在自己的模型中实现 `Equal` 接口。该接口继承自 [Hash](/docs/v4/trait/hash/) 模块中的 `Hash` 接口。 **示例**(实现 `Equal` 和 `Hash` 以忽略无关字段) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier readonly name: string, readonly age: number, readonly updatedAt: Date, // Changes on every edit, irrelevant to identity ) {} // Define equality based on id, name, and age only [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generate a hash code based on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } // Two Person instances with the same id, name, and age, but different updatedAt, // are considered equal because updatedAt is ignored by [Equal.symbol] Equal.equals( new Person(1, "Alice", 30, new Date("2024-01-01")), new Person(1, "Alice", 30, new Date("2024-06-01")), ) // => true ``` 如果没有自定义实现,默认的结构比较也会把 `updatedAt` 考虑进去,因此同一个人的两条在不同时间记录的数据会被视为不同。上面的 `[Equal.symbol]` 方法仅基于 `id`、`name` 和 `age` 定义相等性,忽略了 `updatedAt`。`Hash` 接口通过比较哈希值而非对象本身来优化相等性检查。当你使用 `Equal.equals` 函数比较两个对象时,它首先检查它们的哈希值是否相等。如果不相等,它就能迅速判定这两个对象不相等,从而避免逐属性进行细致的比较。 实现 `Equal` 接口后,你就可以利用 `Equal.equals` 函数按自定义逻辑检查相等性。 **示例**(比较 `Person` 实例) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier for each person readonly name: string, readonly age: number, readonly updatedAt: Date, ) {} // Defines equality based on id, name, and age [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generates a hash code based primarily on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } const alice = new Person(1, "Alice", 30, new Date("2024-01-01")) console.log( Equal.equals(alice, new Person(1, "Alice", 30, new Date("2024-06-01"))), ) Equal.equals(alice, new Person(1, "Alice", 30, new Date("2024-06-01"))) // => true const bob = new Person(2, "Bob", 40, new Date("2024-01-01")) console.log(Equal.equals(alice, bob)) Equal.equals(alice, bob) // => false ``` 在这段代码中,把 `alice` 与另一条 `id`、`name`、`age` 相同但 `updatedAt` 不同的 `Person` 记录比较时,相等性检查返回 `true`,因为 `updatedAt` 不参与比较。而把 `alice` 与 `bob` 比较时返回 `false`,因为它们的标识字段不同。 ### 选择引用相等 有时你希望 `Equal.equals` 按引用而非按值比较某个特定对象或数组,例如当身份比内容更重要时。`Equal.byReference` 函数会返回一个代理,让某个值退出结构比较,同时不会改动原对象: **示例**(让某个值退出结构比较) ```ts import { Equal } from "effect" const alice = { id: 1, name: "Alice", age: 30 } const aliceCopy = { id: 1, name: "Alice", age: 30 } console.log(Equal.equals(alice, aliceCopy)) Equal.equals(alice, aliceCopy) // => true const aliceByReference = Equal.byReference(alice) console.log(Equal.equals(aliceByReference, aliceCopy)) Equal.equals(aliceByReference, aliceCopy) // => false ``` `Equal.byReferenceUnsafe` 做的是同一件事,但不分配代理,而是直接在原对象上做标记。对于该对象的整个生命周期而言,这个标记是不可逆的。 [Data](/docs/v4/data-types/data/) 模块仍然提供 `Data.Class`、`Data.TaggedClass`、`Data.TaggedError` 和 `Data.taggedEnum`,用于构建带标签的数据类型和错误。详情请参阅 [Data 模块文档](/docs/v4/data-types/data/)。 ## 使用集合 在检查相等性时,JavaScript 内置的 `Set` 和 `Map` 可能会有点棘手: **示例**(采用引用相等的原生 `Set`) ```ts const set = new Set() // Adding two objects with the same content to the set set.add({ name: "Alice", age: 30 }) set.add({ name: "Alice", age: 30 }) // Even though the objects have identical values, they are treated // as different elements because JavaScript compares objects by reference, // not by value. console.log(set.size) set.size // => 2 ``` 尽管集合中的两个元素值相同,但这个集合仍然包含两个元素。为什么呢?因为 JavaScript 的 `Set` 按引用而非按值检查相等性。这不受 Effect 的 `Equal` 模块影响,因为原生 `Set` 从不咨询它。 要执行基于值的相等性检查,你需要使用 `effect` 包中提供的 `Hash*` 集合类型。这些集合类型,例如 [HashSet](https://effect.website/docs/v4/api/effect/HashSet) 和 [HashMap](https://effect.website/docs/v4/api/effect/HashMap),使用 `Equal.equals` 进行比较。这意味着普通对象、数组以及其他可进行结构比较的值会自动去重,无需任何额外设置。 ### HashSet 使用 `HashSet` 时,它能正确处理基于值的相等性检查。在下面的例子中,尽管你添加了两个值相同的对象,`HashSet` 也会把它们当作单个元素。 **示例**(使用 `HashSet` 实现基于值的相等性) ```ts import { HashSet } from "effect" // Creating a HashSet with plain objects const set = HashSet.empty().pipe( HashSet.add({ name: "Alice", age: 30 }), HashSet.add({ name: "Alice", age: 30 }), ) // HashSet recognizes them as equal, so only one element is stored console.log(HashSet.size(set)) HashSet.size(set) // => 1 ``` **注意**:不过,结构比较会覆盖所有可枚举属性。如果两个值本意是表示同一个实体,却带有一些确实不同的额外字段(时间戳、请求 ID 之类的元数据),`Equal.equals` 会把它们视为不相等,`HashSet` 也就不会对它们去重: **示例**(结构相等会考虑每一个字段) ```ts import { HashSet } from "effect" // Two records for "the same" data, but with differing request IDs const set = HashSet.empty().pipe( HashSet.add({ name: "Alice", age: 30, requestId: "a1b2" }), HashSet.add({ name: "Alice", age: 30, requestId: "c3d4" }), ) // requestId differs, so the objects are not structurally equal console.log(HashSet.size(set)) HashSet.size(set) // => 2 ``` 在这种情况下,`HashSet` 会保留两条记录,因为 `requestId` 是结构比较的一部分。如果你希望只根据部分字段去重,请像[上文](#implementing-the-equal-interface)那样实现 `Equal` 接口,让相等性忽略那些无关的字段。 ### HashMap 使用 `HashMap` 时,你可以按值而不是按引用来比较键,这是一大优势。当你想根据键的内容来关联值时,这一点尤其有用。 **示例**(使用 `HashMap` 进行基于值的键比较) ```ts import { HashMap, Option } from "effect" // Adding two objects with identical values as keys const map = HashMap.empty().pipe( HashMap.set({ name: "Alice", age: 30 }, 1), HashMap.set({ name: "Alice", age: 30 }, 2), ) console.log(HashMap.size(map)) HashMap.size(map) // => 1 // Retrieve the value associated with a key console.log(HashMap.get(map, { name: "Alice", age: 30 })) HashMap.get(map, { name: "Alice", age: 30 }) // => Option.some(2) ``` 在这段代码里,`HashMap` 被用来创建一个以内容相同的普通对象为键的映射。普通的 JavaScript `Map` 会把它们当作不同的条目,因为它的默认比较是基于引用的。 而 `HashMap` 使用 `Equal.equals` 进行比较,因此内容相同的普通对象会被当作同一个键,无需任何额外设置。于是,当我们添加这两个对象时,后一个键值对会覆盖前一个,最终映射中只有一条记录。 --- # Hash > 通过高效的哈希优化相等性检查,让哈希集合、哈希映射等集合中的比较更快。 `Hash` 接口与 [Equal](/docs/v4/trait/equal/) 接口紧密相关,它通过提供哈希(hashing)机制,在优化相等性检查方面起到辅助作用。哈希是高效判定两个值是否相等的重要一步,尤其是在与哈希表这类数据结构配合使用时。 ## Hash 在相等性检查中的作用 `Hash` 接口的主要目的,是提供一种快速、高效的方式来判断两个值是否**肯定不相等**,从而与 [Equal](/docs/v4/trait/equal/) 接口形成互补。当两个值都实现了 [Equal](/docs/v4/trait/equal/) 接口时,会先比较它们的哈希值(用 `Hash` 接口计算得到): - **哈希值不同**:如果哈希值不同,那么这两个值本身必然不同。这一快速检查让系统可以避免一次可能开销很大的相等性检查。 - **哈希值相同**:如果哈希值相同,并不能保证两个值相等,只说明它们可能相等。这种情况下,会使用 [Equal](/docs/v4/trait/equal/) 接口进行更彻底的比较,以确定它们是否真正相等。 这种做法能大幅加快相等性检查的过程,尤其是在哈希集合(hash set)或哈希映射(hash map)这类查找与插入速度至关重要的集合中。 ## 实现 Hash 接口 设想这样一个场景:你有一个自定义的 `Person` 类,想根据实例的属性来判断两个实例是否相等。 通过同时实现 `Equal` 和 `Hash` 接口,你可以高效地完成这类检查: **示例**(为自定义类实现 `Equal` 和 `Hash`) ```ts import { Equal, Hash } from "effect" class Person implements Equal.Equal { constructor( readonly id: number, // Unique identifier readonly name: string, readonly age: number, ) {} // Define equality based on id, name, and age [Equal.symbol](that: Equal.Equal): boolean { if (that instanceof Person) { return ( Equal.equals(this.id, that.id) && Equal.equals(this.name, that.name) && Equal.equals(this.age, that.age) ) } return false } // Generate a hash code based on the unique id [Hash.symbol](): number { return Hash.hash(this.id) } } const alice = new Person(1, "Alice", 30) console.log(Equal.equals(alice, new Person(1, "Alice", 30))) Equal.equals(alice, new Person(1, "Alice", 30)) // => true const bob = new Person(2, "Bob", 40) console.log(Equal.equals(alice, bob)) Equal.equals(alice, bob) // => false ``` 解释: - `[Equal.symbol]` 方法通过比较 `Person` 实例的 `id`、`name` 和 `age` 字段来判定相等性。这种做法确保相等性检查是全面的,会考虑所有相关属性。 - `[Hash.symbol]` 方法使用该 `Person` 实例的 `id` 计算哈希码。该值用于在哈希操作中快速区分不同的实例,从而优化那些使用哈希的数据结构的性能。 - 当把 `alice` 与一个属性值完全相同的新 `Person` 对象比较时,相等性检查返回 `true`;而由于属性值不同,把 `alice` 与 `bob` 比较时返回 `false`。 --- # 2026 年,Effect-TS 正在成为 AI Agent 的下一种工程范式吗? > 数据快照:2026-09-14 · 数据集 v0.1 > 在线数据面板:[/observatory/](/observatory/) · 口径与偏差声明:[docs/observatory/methodology.md](https://github.com/aaronlou/effect-ts.cn/blob/main/docs/observatory/methodology.md) > > **本文的一切数字都从数据集现算,不手抄。** 判定标准是"下载整个仓库、只统计确实 import 了 effect 的文件", > 不是看 README 也不是看 package.json——理由见第 4 章。 ## Chapter 1 · Agent 正在改变软件工程 先不谈 Effect。 2026 年的 Agent 项目里,真正难的部分早就不是"怎么调 LLM"了。任何一个能跑起来的 Agent 都会很快撞上这些: ``` 失败 → 工具会挂、模型会超时、第三方 API 会 429 并发 → 多个工具要同时跑,一个挂了不能拖垮全部 取消 → 用户按了 Ctrl-C,子进程和连接要跟着收拾干净 资源 → 进程、数据库连接、文件句柄、MCP 连接的生命周期 状态 → 会话中断后能不能接着跑 流式 → 边生成边给用户看,而不是憋完再说 可观测 → 出了事要能查,而不是靠 printf ``` 这些不是 AI 问题,是**运行时工程问题**。而它们恰好是 TypeScript 生态长期以来最薄的一块: `try/catch` 管不住类型,`Promise.all` 管不住取消,`setTimeout` 管不住超时后的悬挂。 ## Chapter 2 · 竞争的焦点正在转移 于是有了一个值得验证的问题: > **Agent 框架的竞争,是否正在从"谁更方便调用 LLM"转向"谁更能可靠地管理 Agent Runtime 的复杂性"?** 这个问题很重要,但**这篇报告不回答它**。回答它需要对 Mastra / LangGraph / Vercel AI SDK 做同任务、同故障注入的对等实现对比 —— 那是 v0.2 的事。 这篇报告只回答一个更基础的问题:**真的有人在用 Effect 做 Agent 吗?用得多深?** ## Chapter 3 · Effect 是什么(一句话给没写过的人) Effect 是一个 TypeScript 的 effect system。与本文相关的部分: | 你要解决的问题 | Effect 的对应能力 | | --- | --- | | 工具失败的建模 | 类型化错误(`Data.TaggedError` / `Schema.TaggedError`) | | 工具输入输出 | `Schema`(运行时校验 + 类型 + 可注解给模型看) | | 并行工具 | 结构化并发(`Fiber`、`Effect.all`) | | 重试 | `Schedule` | | LLM 依赖替换 | `Layer` + `Context`(依赖注入) | | 流式 | `Stream` | | 资源生命周期 | `Scope` | | 取消 | `Fiber` 中断(结构化并发) | | 可观测 | `@effect/opentelemetry`、`Metric` | 关键不是"功能多",而是**这些能力共享同一套组合语义** —— 一个 `Scope` 能同时管住 超时、取消与资源释放,这在 Promise 世界里要手写四遍且很难写对。 ## Chapter 4 · 数据:GitHub 上到底有没有人在用 Effect 做 Agent? ### 怎么数的(这段比数字重要) **不看 README,不看 star。** 判定分两步: 1. **依赖清单**:仓库里**所有** `package.json`(不是只读根目录)的 `dependencies` / `peerDependencies` 里有 `effect` 或 `@effect/*`; 2. **真的在用**:下载 tarball **全仓扫描**,只统计**确实 `import` 了 effect** 的文件 —— 并且剔除"仓库里塞了一份 Effect 源码"的情况(真有人把整个 effect 拷进 `.context/effect/`, 不剔除的话统计到的是库自己)。 然后按用法分档(这是本文最重要的一个设计): | 深度 | 含义 | | --- | --- | | L0 | 不用 | | L1 | 只在边角路径依赖(拿它当性能对比基准之类) | | L2 | 部分业务逻辑用了 | | L3 | Effect 是重要架构组成 | | **L4** | **Effect 是 Agent Runtime 的地基** | ### 有界宇宙(必须是第一页就说清的事) GitHub 搜索**每条查询最多返回 1000 条**。所以"全量爬取"在 API 层面不存在。 本次调查的宇宙是: > stars ≥ 300 ∧ 180 天内有 push ∧ 5 种语言 × 16 个 Agent 方向词(命中 name/description/topics) **这是有界宇宙,不是 GitHub 上全部 Agent 项目。** 所有比例的分母都是它。 ### 数字 | | | | --- | --- | | 候选仓库 | **4,861** | | 判定为 Agent | **2,275**(另有 2,177 个"判不准",**不计入**分子分母) | | TypeScript Agent | **856** | | **其中真正在用 Effect(L2+)** | **33 → 3.9%** | 语言分布(GitHub 的 `language` 字段,仅供参考): | 语言 | 候选 | 判定为 Agent | | --- | ---: | ---: | | Python | 2,223 | 934(42.0%) | | **TypeScript** | **1,612** | **856(53.1%)** | | Go | 465 | 217(46.7%) | | Rust | 402 | 185(46.0%) | | Java | 158 | 83(52.5%) | ## Chapter 5 · Effect 在 TypeScript Agent 生态里的位置 3.9% 是什么概念?**它不是一个"范式"该有的数字。** (本文不拿它和 LangChain 系比 —— 我们没按同一口径做过那组测量,没有数字就不比较。) 但把 33 拆开看,故事变了: | 深度 | 项目数 | | --- | ---: | | L2(部分业务逻辑) | 6 | | L3(重要架构组成) | 4 | | **L4(Runtime 地基)** | **23** | **23 / 33 是 L4。** 也就是说:**用 Effect 做 Agent 的人,绝大多数不是浅尝,而是把执行模型建在它上面。** 这个分布极不均匀,而且方向很明确 —— 它支持一个假设: **Effect 的价值在 Agent Loop 之下,而不是在 Loop 里面。** ## Chapter 6 · 我们找到的真实 Effect Agent ### anomalyco/opencode —— 20 万 star 的样板 3,271 个文件里 **950 个** import 了 effect,命中 **14 项**能力,渗透度 52.5%(主体包里一半运行时依赖 effect)。 它的 Agent 本身就是一个 Effect Service: ```ts import { Context, Effect, Layer } from "effect" export interface Interface extends State.Transformable { readonly get: (id: ID) => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Agent") {} const layer = Layer.effect(Service, Effect.gen(function* () { ... })) ``` 它的工具契约用 Schema 一份定义三处受益(运行时校验 / 类型 / **给模型看的说明**): ```ts export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)).pipe(Schema.optional), }) ``` **这是本次调查里最值得抄的一处设计**:工具的校验规则与模型看到的描述不可能漂移,因为它们本来就是同一个值。 ### 另外四篇 - **[ComposioHQ/composio](../packages/observatory/case-studies/composio.md)**(30k★,L4): 渗透度只有 3.7%,但 286 个文件真的在用 —— 而且它专门做了 `json-schema-to-effect-schema` 这座桥(Agent 工具生态的元数据是 JSON Schema,运行时是 Effect Schema)。 - **[baptisteArno/typebot.io](../packages/observatory/case-studies/typebot.io.md)**(10k★,L4): 一个**真实产品**(不是框架),用 `@effect/sql-pg` + `@effect/opentelemetry`, 而且 Effect 出现在 **React 组件**里 —— "Effect 只在后端"是误解。 - **[latitude-dev/latitude-llm](../packages/observatory/case-studies/latitude-llm.md)**(4.6k★,L4): 渗透度 **79.6%**(本次调查最高),Effect 集中在**服务端中间件**这一横切层 —— 与 opencode 领域完全不同,却做了同一个结构选择。 - **[elizaOS/eliza](../packages/observatory/case-studies/eliza.md)**(19k★,**L2**)—— **反例**:22,043 个文件里只有 **3 个** import 了 effect。 按"依赖里有 effect"判,它是 Effect 项目;按"真的成了架构"判,它只是两个插件用了 Effect 的 Agent 平台。 ## Chapter 7 · Effect 为什么可能适合 Agent Runtime 把 33 个 Effect Agent 的能力命中汇总(同一项目命中多项,数字取自 `dataset/stats.json`): | 能力 | 命中项目数(共 33) | | --- | ---: | | `Effect.gen` 组合 | 32 | | 类型化错误 | 31 | | 并发原语(Queue / Ref / Semaphore) | 28 | | 服务与依赖注入(Layer / Context) | 26 | | Fiber 与并发 | 26 | | Schema 校验 | 26 | | Scope 资源管理 | 26 | | Stream 流处理 | 22 | | 可观测性 | 20 | | Config 配置 | 18 | | `@effect/platform` | 17 | | 集群与工作流 | 14 | | HttpApi | 11 | | `@effect/sql` | 9 | | **`@effect/ai`** | **0** | **读法**:几乎人人用 `Effect.gen`(32/33),但这只是"会写 Effect"; 真正区分度在**类型化错误(31)、并发原语(28)、Layer/Context(26)、Scope(26)** —— 这些是**运行时能力**,而不是"怎么调 LLM"。 最扎眼的一行是最后一行:**`@effect/ai` 在 33 个项目里命中 0 个** (全部 1,612 个 TS 候选里也只有 1 个项目用了它)。 也就是说:这些团队用 Effect 不是因为官方提供了 AI 包,而是**为了把 Agent 的部件组装起来并且管住失败**。 官方 AI 包在这批项目里几乎不存在感 —— 这个事实对"Effect 官方该怎么投入"比对外宣传更有价值。 ## Chapter 8 · Effect vs Mastra vs LangGraph **这一章在 v0.1 里只有一句话:没有取证,所以不下结论。** 框架对比矩阵里的每个格子都需要证据(文档 + 源码 + 可复现的行为), 而"谁能更好地处理 failure / concurrency / lifecycle"这类问题, 不看源码、不做故障注入是答不出来的。填一个靠印象的矩阵,比留白更糟。 ## Chapter 9 · 我们真的做了一个 Benchmark **没有。v0.1 不做 Benchmark。** 原因不是没时间,而是**同 LLM、同 prompt 下的耗时差异支撑不了"谁更好"的结论** —— 它主要反映框架开销与重试策略,噪声足以被写成任何一个你想写的结论。 真正值得测的是**失败语义**: ``` 给三个框架同一个 Agent + 同一组故障注入(工具超时 / 网络错误 / 用户取消), 然后问:取消之后,资源释放了吗?错误被静默吞掉了吗?悬挂的 Fiber 还在吗? ``` 这是 v0.2 的第一件事。 ## Chapter 10 · 结论:不是下一种范式,但用法很重 回到标题的问题:**Effect-TS 正在成为 AI Agent 的下一种工程范式吗?** 按我们自己的判定标准,答案是 **Scenario C**: > **Effect 在 Agent Runtime 中有明显技术价值,但生态规模仍然有限。** 三条支撑,三条限制: **支撑** 1. 用 Effect 做 Agent 的项目里,**23/33 是 L4** —— 不是浅尝,是把执行模型建在它上面; 2. 最常用的能力是依赖注入、类型化错误、Schema、Scope、Fiber —— 全是**运行时能力**, 而不是"怎么调 LLM",这支持"Effect 的价值在 Agent Loop 之下"(H4); 3. 这些项目里既有 20 万 star 的旗舰(opencode),也有真实产品(typebot),不是一堆 demo。 **限制** 1. **3.9%** 的占比,离"范式"很远; 2. **44.8% 的候选我们判不准是不是 Agent** —— "Agent"这个概念在开源世界还没有稳定边界, 任何把这个比例当精确数字引用的做法都比数字本身更危险; 3. **v0.1 没有 Benchmark**,所以本文**不声称任何性能或可靠性优势**。 **这也是一份可以失败的调查。** 如果数据说的是"Effect 在 Agent 领域没什么人用",这篇会照原样写出来。 把结论建立在"我们不预设答案"上,是这类数据研究唯一值得做的事。 --- ### 附:数据与复现 - 数据集:`packages/observatory/data/dataset/`(`agents.csv` / `typescript-agents.csv` / `effect-agents.csv` / `frameworks.csv` / `dataset.json`) - 快照:`packages/observatory/data/snapshots/2026-09-14/`(含每次查询的命中数、被排除的仓库、被抬高的星数下限) - 方法与已知偏差:[docs/observatory/methodology.md](../docs/observatory/methodology.md) - 一条命令复现:`pnpm --filter @ecn/observatory discover && … scan && … classify && … report` > 本报告的一切数字都从数据集现算,不手抄。欢迎核对我们算错了哪里。 --- # Jev 火了,而它恰好是 Effect 用户的事:当「AI 判断」变成一种数据类型 最近几周,TypeSafe 的 **Jev** 在技术社区里讨论度很高。它不是又一个聊天模型,而是 TypeSafe 所谓 **System One** 的第一个模型:你给它状态(state)和一组**类型化的问题**(questions),它返回 **结构化的答案与校准过的概率**——不生成文本,不解释推理,不替你做任何决定。 这篇文章想论证一件事:**这个设计恰好落在 Effect 的主场**。并且,它和我们上一篇 [生态调查](/blog/effect-agent-ecosystem/)里最扎眼的那个发现,构成了一个漂亮的呼应。 ## Chapter 1 · Jev 是什么:一个不说话、只回答的模型 先把事实说准(以下均以 [TypeSafe 官方文档](https://docs.typesafe.ai/)为准,本文写作时对应 JS SDK v0.6.0): 调用 Jev 的方式是 `client.systemOne()`,传入两部分: ```ts import { choice, TypeSafeClient } from "@typesafe-ai/sdk" const client = new TypeSafeClient() const response = await client.systemOne({ state: { document: "I was charged twice. Please fix this ASAP." }, questions: { category: choice("What is this ticket about?", { billing: null, technical: null, other: null, }), }, }) console.log(response.answers.category.choice) // => "billing" ``` 三个值得注意的设计: 1. **答案空间是调用方声明的**。`choice` 的选项由你定义,模型只能从中选——返回值天然落在 你声明的类型里,没有「先拿到一段文本再祈祷 JSON.parse 成功」这一步。 2. **三种原语,覆盖三种判断**:`Choice`(从选项集中选一个)、`Score`(沿一组有序等级打分)、 `Noul`(陈述为真的概率)。每种都返回答案 + 概率分布 + 置信度。 3. **多个独立问题打包一次调用,并行评估**。官方的说法是:增加问题几乎不增加响应时间, 且问题之间彼此隔离,不会互相污染。 而它**不做**的事,比它做的事更有信息量:不写回复、不生成代码、不编排工具调用、不维护会话状态。 官方的编程模型写得很直白——**代码掌控工作流,模型只提供窄域判断**。 ## Chapter 2 · 哲学会师:Jev 管判断,Effect 管编排 还记得[上一篇生态调查](/blog/effect-agent-ecosystem/)里最扎眼的一行吗? > **`@effect/ai` 在 33 个真正在用 Effect 的 Agent 项目里命中 0 个。** 我们当时的读法是:这些团队选 Effect,**不是为了「更方便地调 LLM」,而是为了把 Agent 的部件 组装起来并且管住失败**——类型化错误、并发、取消、资源、重试。AI 封装层不是他们缺的东西, 运行时才是。 Jev 的设计恰好从模型那一侧给出了对称的答案:**它也拒绝做编排**。它不试图成为 Agent 框架, 不抢工作流的位置,只把「语义理解」这一小块做成可编程的原语。 把两边拼起来,分工就非常清楚: ``` Jev :提供带类型与概率的判断 —— 语义理解 Effect :组合判断、管住失败与并发 —— 运行时 ``` 这不是修辞上的类比,而是接口形状上的事实:Jev 的输出(类型化答案 + 置信度 + 概率分布)**正是 Effect 最擅长消费的那种值**——可以 `map`、可以 `filter`、可以按置信度分支、 可以把「不够确定」建模成一个类型化的失败而不是一个 `if`。 官方对 Effect 的定位是 *Reliable TypeScript for the AI era*。Jev 这类模型的出现, 让这句话里「AI era」的部分第一次有了具体的接口形态。 ## Chapter 3 · 动手:把 Jev 包成一个像样的依赖 空谈哲学不如看代码。下面是用 Effect v4 把 Jev 包成服务的完整示范——场景就用官方文档自己的 例子:工单分诊。两种判断打包一次调用(路由到哪个团队 / 是否紧急),然后按置信度决定 自动执行还是转人工。 先定义错误与服务契约。注意这里有两类**语义不同**的失败: ```ts import { Config, Context, Effect, Layer, Schedule, Schema } from "effect" import { choice, TypeSafeClient } from "@typesafe-ai/sdk" // 失败一:传输层(网络抖动、超时、5xx)—— 值得重试 class JevTransportError extends Schema.TaggedError()( "JevTransportError", { reason: Schema.String }, ) {} // 失败二:判断层(答案完全合法,但置信度不足以自动执行)—— 重试没有意义,转人工 class NeedsHumanReview extends Schema.TaggedError()( "NeedsHumanReview", { team: Schema.String, confidence: Schema.Number, }, ) {} // 服务契约:调用方只看得见类型,看不见 SDK class JevTriage extends Context.Service< JevTriage, { readonly triage: ( ticket: string, ) => Effect.Effect } >()("JevTriage") {} interface Triage { readonly team: "billing" | "technical" | "other" readonly urgency: "routine" | "urgent" } ``` 「置信度不够」是不是失败,取决于业务——但把它建模成 `TaggedError` 的好处是**它出现在类型里**: 任何调用 `triage` 的代码都会被编译器提醒「这里可能需要人工介入」。这正是 [Schema.Class 文档](/docs/v4/schema/classes/)里讲过的模式——`Schema.TaggedError` 生成的是可 yield 的类型化错误。 然后是实现。配置缺失在启动时就炸(而不是等到第一次调用);传输错误指数退避重试; 成功后按置信度分流: ```ts export const JevTriageLive = Layer.effect( JevTriage, Effect.gen(function* () { // API key 从配置读取:缺失会在装配阶段失败,而不是运行中途 yield* Config.string("TYPESAFE_API_KEY") const client = new TypeSafeClient() const triage = (ticket: string) => Effect.tryPromise({ try: () => client.systemOne({ state: { ticket }, questions: { team: choice("Which team should handle this ticket?", { billing: null, technical: null, other: null, }), urgency: choice("How urgent is this ticket?", { routine: null, urgent: null, }), }, }), catch: (reason) => new JevTransportError({ reason: String(reason) }), }).pipe( // 指数退避,最多重试 3 次(惯用法见 /docs/v4/scheduling/examples) Effect.retry( Schedule.max([Schedule.exponential("200 millis"), Schedule.recurs(3)]), ), Effect.flatMap((response) => response.answers.team.confidence >= 0.7 ? Effect.succeed({ team: response.answers.team.choice, urgency: response.answers.urgency.choice, }) : // TaggedError 是可 yield 的,直接出现在失败分支里 new NeedsHumanReview({ team: response.answers.team.choice, confidence: response.answers.team.confidence, }), ), ) return { triage } }), ) ``` 用起来是这样的——注意调用方拿到的 `Effect`, 失败的全部语义都在类型签名上,一个都跑不掉: ```ts import { Effect } from "effect" const program = Effect.gen(function* () { const result = yield* JevTriage.use((jev) => jev.triage("I was charged twice. Please fix this ASAP."), ) return `Routed to ${result.team} (${result.urgency})` }) // 交给各自的上层处理:转人工的进工单队列,传输失败的按需重试或告警 const handled = program.pipe( Effect.catchTag("NeedsHumanReview", (e) => Effect.succeed(`Escalated to human (team=${e.team}, confidence=${e.confidence})`), ), Effect.catchTag("JevTransportError", (e) => Effect.succeed(`Transport failure: ${e.reason}`), ), ) ``` 把这段和 Chapter 1 的裸 SDK 版本放在一起看,多出来的每一行都在回答同一个问题: **当「判断」成为系统的一个部件,谁对它的失败、重试与升级负责?** 在 Promise 世界里这些是 散落各处的 `if` 和 `try/catch`;在 Effect 里它们是签名的一部分。 顺带一提:`Layer` 让测试时把 `JevTriageLive` 换成一个返回固定答案的 `Layer.succeed` 即可,不需要 mock 网络层——依赖注入是免费的。 ## Chapter 4 · 三个诚实的提醒 本站的传统(见[第 0 号公告](/blog/welcome/)和[生态调查](/blog/effect-agent-ecosystem/)): 先泼冷水,再谈信仰。 **1. 类型化输出保证的是接口,不是真相。** Jev 保证 `choice` 落在你声明的选项集里, 不保证它选对了。官方文档自己也强调校准概率是**群体层面的度量**——它是路由策略的输入, 不是免责声明。阈值(比如上面的 0.7)必须按你业务里「错了多疼」来定,并拿真实数据验证。 **2. 置信度 ≠ 正确率。** `confidence` 反映的是概率分布的集中程度:模型很确定地选错, confidence 照样很高。它是「该不该自动执行」的信号之一,而不是「答案对不对」的信号。 两个概念混用,是这类系统最常见的事故来源。 **3. 本文没有 benchmark,也没有生产验证。** 代码示范忠实于官方文档的 API (JS SDK v0.6.0,`client.systemOne` / `choice`),但没在我们自己的生产里跑过; SDK 还在 0.x,API 随时可能变。把它当思路示范读,别当复制粘贴模板用。 ## Chapter 5 · 对本站意味着什么 这个站本身就是一个「代码掌控、判断可组合」的系统:`/api/knowledge/ask` 的检索 + 引用 + 拒答管线,全部是确定性代码,答案的每条引用都带可核验的 `citationId`——**引用为空即拒答**, 这是硬规则,不交给模型自由发挥。 但管线里有两个环节,目前是纯启发式的,天然适合「窄域判断」模型补位: - **检索重排(rerank)**:候选切片与问句的相关性排序; - **拒答判定**:检索结果「够不够好到可以回答」——一个典型的、答案空间只有两档的判断。 如果后续把 Jev 接进这两个环节,约束不会变:引用仍必须来自检索结果,判断模型只负责打分和 门槛,**不做生成、不进答案正文**。到那时我们会按惯例写一篇带数据与复现步骤的实测报告—— 在那之前,本文只是(我们希望是)一次准确的观察: > Jev 们把「AI 判断」变成了一种数据类型。而 Effect 用户早就知道, > 数据类型的事,就该交给类型系统来管。 --- # 欢迎来到 Effect 中文社区 # 欢迎来到 Effect 中文社区 > 本文是本站第一篇(原创)内容,作者是我们自己。它主要回答一个问题:**我们为什么要在中文世界再造一个 Effect 社区?** ## Effect 是什么 [Effect](https://effect.website/) 是 TypeScript 世界里少见的、把「可追踪的错误、依赖、异步并发」全部收编进类型系统的生产级库。简单说,它想让你写出的 TypeScript 拥有接近 Rust/OCaml 的可靠性,却仍运行在你熟悉的 Node / Deno / Bun / 浏览器里。 官方给它的定位是:*Reliable TypeScript for the AI era*。 ## 我们想做什么 中文世界不缺英文文档的机器翻译,缺的是三样东西: 1. **可追溯、有审校、带版本锚点的文档** —— 每一页都告诉你它对着上游哪一次 commit 译的,落后了会直接标出来; 2. **问得起来、答得下去的地方** —— 用中文讨论 Effect 的问答与投稿社区; 3. **「看得见」的工程示范** —— 这个网站本身就是用 Effect + DDD 写的,源码全开源。你学到的架构,在这里能看到活的实现。 ## 本站是 Dogfood 的 后端是 Effect + `@effect/platform`,采用领域驱动设计(限界上下文 + 洋葱分层);前端是 Astro;前后端共享的 API 契约由 `@effect/schema` 单一来源生成。翻译工作流由 `packages/content` 管线驱动。 ## 来搭把手 - 想翻译文档 / 投稿:请先阅读[译者指南](https://github.com/aaronlou/effect-ts.cn/blob/main/docs/translation-guide.md),一切从 GitHub PR 开始; - 想讨论:**群还没建**。有具体问题请提到 issue —— 公开、可搜、别人也能受益; - 想挑刺:提 issue 永远受欢迎。 期待你的第一个 PR。