Effect 里怎么给已有的服务加日志?

想给 UserRepo.findById 每次调用前打一行日志,直觉的做法是去改它的实现。但实现可能在别人的包里,也可能有好几份(真实的一份、测试的一份),逐个改既麻烦又容易漏。

写一个 Layer,在构造时取到原来的 UserRepo,再返回一个包了日志的新 UserRepo;最后用 Layer.provide 把原实现接进去。 原实现完全不动,调用方也不用知道日志的存在。

这是 Layer 的一个特点:它可以依赖自己所提供的那个服务。加日志、加缓存、加限流都可以用这个形状。下面按写法、原理、组装的顺序拆开。

装饰层的代码怎么写?

先有一个服务和它的一份实现:

import { Context, Data, Effect, Layer } from "effect"

interface User {
  readonly id: number
  readonly name: string
}

class UserNotFound extends Data.TaggedError("UserNotFound")<{
  readonly id: number
}> {}

class UserRepo extends Context.Service<UserRepo, {
  readonly findById: (id: number) => Effect.Effect<User, UserNotFound>
}>()("app/UserRepo") {}

const UserRepoLive = Layer.succeed(UserRepo, {
  findById: (id) =>
    id === 1
      ? Effect.succeed({ id, name: "Ada" })
      : Effect.fail(new UserNotFound({ id })),
})

装饰层是这样:

const UserRepoLogged = Layer.effect(
  UserRepo,
  Effect.gen(function* () {
    const inner = yield* UserRepo
    return UserRepo.of({
      findById: (id) =>
        Effect.log(`findById ${id}`).pipe(Effect.andThen(inner.findById(id))),
    })
  })
).pipe(Layer.provide(UserRepoLive))

构造体里 yield* UserRepo 取到的是被包装的原实现,记作 inner。返回的新对象先打日志,再把调用转给 inner.findById(id)。

为什么输入和输出可以是同一个服务?

Layer.effect(UserRepo, ...) 声明「我提供 UserRepo」,构造体里的 yield* UserRepo 又声明「我需要 UserRepo」。装饰层的输入是 UserRepo,输出还是 UserRepo,所以对下游来说,它和一份普通实现没有区别,接口、错误类型都不变。

UserRepo.of 只是一个帮你检查类型的恒等函数:返回的对象必须满足服务接口,少一个方法、错一个错误类型,编译器会在这里报错。

原来的实现怎么接进去?

装饰层自己还需要一个 UserRepo 作为输入,这个输入由 Layer.provide(UserRepoLive) 提供。Layer.provide 把 UserRepoLive 的输出喂给装饰层,并消掉装饰层的输入;它的输出里不再暴露被喂入的那一份,只剩外面这个带日志的 UserRepo。

这一点很重要:被包装的原实现和包装后的服务是同一个 key("app/UserRepo"),如果两份都留在输出里就会争同一个槽位。所以这里应该用 provide,而不是 provideMerge。两者的区别见 provide 和 provideMerge 的区别。

要换成别的底层实现也一样:把 Layer.provide 里的 UserRepoLive 换掉,装饰层的代码不用动。

入口处怎么用装饰后的 Layer?

装饰后的 UserRepoLogged 和普通 Layer 一样,在入口提供给业务代码。假设业务函数 welcome 还用到了 Mailer,入口就把两者合起来:

const main = welcome(1).pipe(
  Effect.catchTag("UserNotFound", (e) => Effect.succeed(`missing ${e.id}`)),
  Effect.provide(UserRepoLogged.pipe(Layer.provideMerge(MailerLive)))
)

这里 Layer.provideMerge(MailerLive) 的结果里保留了 Mailer,业务代码要用的两个服务都在。业务函数本身没有任何改动,也不需要知道 UserRepo 是不是被装饰过;想去掉日志,只要在入口换回 UserRepoLive。依赖为什么应该只在入口一次性提供,见Effect.run 应该放在哪里。

代码取自旧版 Effect 服务与 Layer 指南,基于 Effect v4;本文没有重新编译或运行,其中 welcome、Mailer、MailerLive 沿用旧指南的定义,没有在这里重复。写进项目前请自己用编译器确认。