![](https://miro.medium.com/v2/resize:fit:1200/1*QVDZ2dMlPpWKHNrLqFVQaQ.jpeg)
[*][*]
[*]
[*]
[*]
![先天](https://miro.medium.com/v2/resize:fill:48:48/1*jQ20egiMhbQg3tKbvemD0w.png)
在 Inato,我们于 2024 年初从 fp-ts 迁移到 Effect。鉴于我们庞大的代码库(约 50 万行 TypeScript 代码),我们需要一种方法来确保任何新代码都可以使用 Effect 编写,同时允许现有的 fp-ts 代码共存。我们仅用两个月就实现了这一目标,为此投入了大约 10% 的时间。在本文中,您将找到我们详细的迁移策略、我们开发的帮助程序(您可以在此找到 存储库),以及我们如何确保代码库的顺利过渡。
本文的 Dev.to 版本已发布 这里。
在 Inato,我们很早就非常积极地采用函数式编程,因此我们从 2020 年初开始在我们的代码库中使用 fp-ts。如果您想了解更多信息,请查看我们的函数式编程之旅。
现在让我们进入正题:今年年初,我们正式决定改用 Effect!为什么?
将我们的代码库迁移到 Effect 是一个伟大的目标,但事实证明,这样做更具挑战性,需要仔细规划。我们还想限制花在这个项目上的时间,所以我们同意了 2.5 个月的期限。考虑到所有这些,我们制定了以下策略。
首先,这是我们服务器端代码库的表示:我们有代表业务行为的用例,这些用例具有多个依赖项(服务、存储库等 – 我们将它们称为端口),我们还有将执行我们的用例的运行器:
当我们开始迁移时,我们有大约 400 个用例和 80 个端口及其适配器需要迁移。
我们这次迁移的目标很明确:在 2.5 个月的窗口期结束时,任何新的用例或端口都将使用 Effect 编写。为了实现平稳过渡,让 fp-ts 和 Effect 代码能够共存,我们制定了以下计划:
ReaderTaskEither
促进向 Effect 的过渡 [*][*] ReaderTaskEither
(我们将其称为 RTE
(后来)是促进向 Effect 迁移的先决条件。为什么?从概念上讲, ReaderTaskEither
可以表示如下:
ReaderTaskEither
= Reader>
= (context: R) => () => Promise>
如果我们看一下对效果的表示 官方网站,我们可以看到这些是非常相似的概念(这是我们在迁移过程中将利用的概念):
Effect ~ (context: Context) => E | A
现在让我们深入研究代码!以下是我们将要遵循的步骤:
为了说明我们的迁移过程,我们将重点关注一个代表我们的代码库如何组织的示例程序。
注意:所有代码和帮助程序都可以在👉中找到 这个存储库 👈
假设我们的领域模型由一个简单的 Foo
班级:
// domain.ts
export class Foo {
constructor(readonly id: string) {}
static make = (id = "random-id") => new Foo(id);
}
我们定义一个存储库端口来获取和存储 Foo
:
// FooRepository.ts
export interface FooRepository {
getById: (id: string) => RTE;
store: (foo: Foo) => RTE;
}export interface FooRepositoryAccess {
fooRepository: FooRepository;
}
export declare const FooRepository: {
getById: (id: string) => RTE;
store: (foo: Foo) => RTE;
};
export declare const makeFooRepository: () => Promise;
笔记:
FooRepositoryAccess
在组合多个时启用上下文聚合的接口 ReaderTaskEither
:declare const a: RTE<{ serviceA: ServiceA },never,void>
declare const b: RTE<{ serviceB: ServiceB },never,void>
const ab: RTE<{ serviceA: ServiceA; serviceB: ServiceB },never,void>
= rte.flatMap(a,() => b)
FooRepository
它公开与存储库本身相同的方法,只是它们每个都需要一个上下文 FooRepositoryAccess
. 这使得以后的代码更加简洁:const theLongWay: RTE = pipe(
rte.ask(),
rte.flatMap(({ fooRepository }) => fooRepository.getById('id'))
);const theEasyWay: RTE
= FooRepository.getById('id')
我们还定义了一个服务端口来转换 Foo
:
// TransformFooService.ts
export interface TransformFooService {
transform: (foo: Foo) => RTE;
}export interface TransformFooServiceAccess {
transformFooService: TransformFooService;
}
export declare const TransformFooService: {
transform: (foo: Foo) => RTE;
};
declare const makeTransformFooService: () => Promise;
接下来我们可以编写两个用例:一个用于创建新的 Foo
,另一个用来转换 Foo
:
// usecases.ts
export const createFooUseCase = (id:string) =>
pipe(
rte.of(Foo.make(id)),
rte.tap(FooRepository.store)
);export const transformFooUseCase = (id: string) =>
pipe(
FooRepository.getById(id),
rte.flatMap(TransformFooService.transform),
rte.flatMap(FooRepository.store)
);
最后,我们可以编写 main
这将创建所有端口适配器并调用我们的用例:
// index.ts
const main = async () => {
const fooRepository = await makeFooRepository();
const transformFooService = await makeTransformFooService();
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
await transformFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
此步骤包括生成新的伴随对象 FooRepository
和 TransformFooService
对于我们的端口,我们公开了成员方法的效果版本。
首先我们重命名伴生对象,添加一个 Fpts
后缀:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶{̶
export declare const FooRepositoryFpts: {
getById: (id: string) => RTE;
store: (foo: Foo) => RTE;
};// TransformFooService.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶{̶
export declare const TransformFooServiceFpts: {
transform: (foo: Foo) => RTE;
};
// usecases.ts
export const createFooUseCase = (id:string) =>
pipe(
rte.of(Foo.make(id)),
r̶t̶e̶.̶t̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶
rte.tap(FooRepositoryFpts.store)
);
export const transformFooUseCase = (id: string) =>
pipe(
F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶
FooRepositoryFpts.getById(id),
rte.flatMap(TransformFooServiceFpts.transform),
rte.flatMap(FooRepositoryFpts.store)
);
然后我们使用 portToEffect
辅助函数从先前的伴随对象生成 Effect 伴随对象:
// FooRepository.ts
export const FooRepositoryTag = Context.GenericTag(
"FooRepository"
);
export const FooRepository = portToEffect(FooRepositoryFpts, {
fooRepository: FooRepositoryTag,
}); // { getById: (id: string) => Effect ... }// TransformFooService.ts
export const TransformFooServiceTag = Context.GenericTag(
"TransformFooService"
);
export const TransformFooService = portToEffect(TransformFooServiceFpts, {
transformFooService: TransformFooServiceTag,
}); // { transform: (foo: Foo) => Effect }
此时我们可以开始使用新生成的 Effect 伴随对象来重写 transformFooUseCase
用例效果。请注意,我们自愿留下 createFooUseCase
按原样模拟用例的迁移,而不是“大爆炸”迁移,我们会将所有用例一次性转换为 Effect(更加困难和冒险)。
// usecases.ts
export const transformFooUseCase = (id: string) =>
pipe(
F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶s̶t̶o̶r̶e̶)̶
FooRepository.getById(id),
Effect.flatMap(TransformFooService.transform),
Effect.flatMap(FooRepository.store)
); // Effect
因为我们不想影响我们的 main
程序尚未开发,我们必须维护此用例的 fp-ts 版本,以实现向后兼容性。我们可以从 Effect 版本生成它,这要归功于 functionToFpts
辅助功能:
// usecases.ts
export const transformFooUseCaseFpts = functionToFpts(transformFooUseCase, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
}); // RTE// index.ts
const main = async () => {
const fooRepository = await makeFooRepository();
const transformFooService = await makeTransformFooService();
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶
await transformFooUseCaseFpts("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
接下来我们将转换 FooRepository
直接将端口移植到 Effect 中:
// FooRepository.ts
export interface FooRepository {
g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶
getById: (id: string) => Effect.Effect;
store: (foo: Foo) => Effect.Effect;
}
我们现在可以使用以下方式生成 Effect 伴随对象 Effect.serviceFunctions
:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶,̶ ̶{̶
̶ ̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶
̶}̶)̶;̶
export const FooRepository = Effect.serviceFunctions(FooRepositoryTag);
最后,为了向后兼容,我们必须维护 fp-ts 伴随对象。我们可以使用 portToFpts
辅助功能:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶:̶ ̶{̶
̶ ̶ ̶g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
̶ ̶ ̶s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶
̶}̶;̶export const FooRepositoryFpts = portToFpts(FooRepository, {
fooRepository: FooRepositoryTag,
}); // { getById: (id: string) => RTE; ... }
我们对 TransformFooService
港口:
// TransformFooService.ts
export interface TransformFooService {
t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
transform: (foo: Foo) => Effect;
}e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶,̶ ̶{̶
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶
̶}̶)̶;̶
export const TransformFooService = Effect.serviceFunctions(
TransformFooServiceTag
);
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶:̶ ̶{̶
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
̶}̶;̶
export const TransformFooServiceFpts = portToFpts(TransformFooService, {
fooRepository: FooRepositoryTag,
}); // { transform: (foo: Foo) => RTE; }
请注意,我们没有改变我们的 main
在此步骤中它仍然可以无问题地运行。
为了运行 transformFooUseCase
作为一种效果,我们必须能够通过层提供我们的端口:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶>̶;̶
export declare const FooRepositoryLive: Layer.Layer;// TransformFooService.ts
d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶>̶;̶
declare const TransformFooServiceLive: Layer.Layer;
接下来我们可以创建一个 ManagedRuntime
并使用 contextToFpts
帮手:
// index.ts
const main = async () => {
c̶o̶n̶s̶t̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶(̶)̶;̶
c̶o̶n̶s̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶(̶)̶;̶
const runtime = ManagedRuntime.make(
Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)
);
const { context } = await runtime.runtime();
const { fooRepository, transformFooService } = contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
await transformFooUseCaseFpts("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
最后,我们可以使用运行时来运行 Effect transformFooUseCase
:
// index.ts
const main = async () => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)
);
const { context } = await runtime.runtime();
const { fooRepository, transformFooService } = contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶F̶p̶t̶s̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶
̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶,̶
̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶,̶
}̶)̶(̶)̶;̶
await runtime.runPromise(transformFooUseCase("my-foo-id"));
};
main();
请注意,我们再次离开了 createFooUseCase
按原样迁移用例以表明我们可以处于混合状态,其中只有部分用例已迁移到 Effect。
我们在整个迁移过程中使用的所有帮助程序都需要一个映射对象,以便从 fp-ts 端口访问接口的键名(例如 transformFooService
的 TransformFooServiceAccess
)到 Tag
相应效果端口的设置。例如:
contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
此映射对于所有帮助程序的正常工作至关重要。总是这样制作它们并不理想。为了帮助我们做到这一点,我们引入了:
const FptsConvertibleId = Symbol();
interface FptsConvertible {
[FptsConvertibleId]: T;
}
我们现在可以将此转换信息嵌入到我们的端口的类型级别:
// FooRepository.ts
export interface FooRepository extends FptsConvertible<"fooRepository"> {
getById: (id: string) => Effect.Effect;
store: (foo: Foo) => Effect.Effect;
}// TransformFooService.ts
export interface TransformFooService
extends FptsConvertible<"transformFooService"> {
transform: (foo: Foo) => Effect;
}
我们能做的第一件事是使用类型助手来简化访问接口的定义 FptsAccess
:
// FooRepository.ts
export interface FooRepositoryAccess extends FptsAccess {}// TransformFooService.ts
export interface TransformFooServiceAccess
extends FptsAccess {}
我们还可以使用新的辅助函数定义更小的原子映射对象 getFptsMapping
:
// FooRepository.ts
const FooRepositoryFptsMapping = getFptsMapping(
FooRepositoryTag,
"fooRepository"
); // { fooRepository: FooRepositoryTag }// TransformFooService.ts
const TransformFooServiceFptsMapping = getFptsMapping(
TransformFooServiceTag,
"transformFooService"
); // { transformFooService: TransformFooServiceTag }
注意:看起来我们又一次输入了密钥 "fooRepository"
或者 "transformFooService"
但实际上,该函数 getFptsMapping
是类型安全的,因此 FooRepositoryTag
作为第一个参数,只有字符串 "fooRepository"
作为第二个参数有效。因此您的代码编辑器将为您自动完成。此外,如果您更改 FptsConvertible
所以这实际上并不是一个额外的负担。
现在我们可以在调用时组合这两个映射对象 contextToFpts
或任何其他帮助者:
contextToFpts(context, {
f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶
t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶
...FooRepositoryFptsMapping,
...TransformFooServiceFptsMapping,
});
我们的目标是能够使用 Effect 编写任何新用例或端口,并在 2 个月内实现(大约花费了 10% 的时间)!
团队合作无疑是这一成功的重要因素:首先,我们必须提到 Stephane Ledorze,因为他独自迁移了我们所有的存储库,并就如何定义迁移策略给了我们很好的建议。我们在 Inato 每周三下午举行的专门“技术会议”上与整个团队一起处理了其余工作:在这些会议期间,我们停止提供功能,以便能够专注于纯技术主题,这是一个很好的机会来迁移我们必须处理的众多端口并让团队加入 Effect。
在我们撰写本文时,我们有大约 150 个完整的 Effect 用例。其余现有用例将在我们需要更新时随时迁移!
我们已经看到了巨大的改进:例如,使用 Effect 仅用几行代码即可实现速率限制,而使用 fp-ts 则需要大量代码才能实现。既然我们已经正式迁移到 Effect 生态系统,我们渴望更多地利用它!
我们希望本文能够激励您从 fp-ts 转向 Effect,如果您有任何问题或意见,请随时发表评论!