我目前正在学习更多关于函数式编程和错误处理替代方法的知识,而不是我习惯的方法(主要是 try/catch)。并且一直在研究各种编程语言中的 Either monad。我一直在尝试将这个概念应用到一个使用 Express 和 fp-ts 的小型简单应用程序中。
考虑我有以下用于处理请求的架构(假设它是从数据库中检索实体):
Express -> route handler -> controller -> repository -> data source -> database
这提供了多个有机会发生错误的地方。首先我们看一下数据来源。我试图将其限制为一个接口,允许我将来在需要时交换数据源。所以我的数据源界面最初看起来像:
export type TodoDataSource = {
findAll(): Promise<ReadonlyArray<TodoDataSourceDTO>>;
findOne(id: string): Promise<TodoDataSourceDTO | null>;
create(data: CreateTodoDTO): Promise<TodoDataSourceDTO>;
update(id: string, data: UpdateTodoDTO): Promise<TodoDataSourceDTO>;
remove(id: string): Promise<TodoDataSourceDTO>;
};
创建后,此数据源将传递到存储库工厂函数:
export function createTodoRepository(
dataSource: TodoDataSource,
): TodoRepository {
return {
findAll: async () => await findAll(dataSource),
findOne: async (id: string) => await findOne(dataSource, id),
create: async (data: CreateTodoDTO) => await create(dataSource, data),
update: async (id: string, data: UpdateTodoDTO) =>
await update(dataSource, id, data),
remove: async (id: string) => await remove(dataSource, id),
};
}
以类似的方式,我的存储库实现旨在满足
TodoRepository
接口的约定:
export type TodoRepository = {
findAll(): Promise<ReadonlyArray<Todo>>;
findOne(id: string): Promise<Todo | null>;
create(data: CreateTodoDTO): Promise<Todo>;
update(id: string, data: UpdateTodoDTO): Promise<Todo>;
remove(id: string): Promise<Todo>;
};
我遇到的问题是,一旦我尝试应用使用
Either
的方法,我的界面就开始变得非常紧密耦合并且非常冗长。
首先,让我将数据源更新为使用
Either
作为它的返回类型:
import * as TaskEither from 'fp-ts/TaskEither';
export type DataSourceError = { type: "DATA_SOURCE_ERROR"; error?: unknown };
export type TodoDataSource = {
findAll(): Promise<
TaskEither.TaskEither<DataSourceError, ReadonlyArray<TodoDataSourceDTO>>
>;
findOne(
id: string,
): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO | null>>;
create(
data: CreateTodoDTO,
): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
update(
id: string,
data: UpdateTodoDTO,
): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
remove(
id: string,
): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
};
还不错。这将处理底层数据源抛出的任何意外情况 - 我们可以捕获并转换为
Either
。太棒了。
现在到我的存储库界面:
import type * as TaskEither from "fp-ts/TaskEither";
import { type DataSourceError } from "../../infra/data-sources/todo.data-source";
import { type ParseError } from "../../shared/parsers";
export type TodoNotFoundError = { type: "TODO_NOT_FOUND"; id: string };
export type TodoRepository = {
findAll(): Promise<
TaskEither.TaskEither<DataSourceError | ParseError, ReadonlyArray<Todo>>
>;
findOne(
id: string,
): Promise<
TaskEither.TaskEither<
DataSourceError | ParseError | TodoNotFoundError,
Todo
>
>;
create(
data: CreateTodoDTO,
): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
update(
id: string,
data: UpdateTodoDTO,
): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
remove(
id: string,
): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
};
现在我的存储库和数据源开始与其错误类型耦合。然而,存储库现在开始在此处添加一些它自己的错误类型,例如
PARSE_ERROR
(当将 DTO 解析为域实体失败时)和 TODO_NOT_FOUND
(当 findOne 函数返回 null 时 - 尽管可能更好使用选项).
我想这还不错,但我们只有两次调用深度,我的存储库不应该处理对用户出现问题的响应,我应该让这些错误沿着调用链传递回控制器这样控制器就可以使用适当的响应代码/文本进行响应。
在这种情况下,我认为处理起来很容易,因为调用堆栈很简单,但是如果控制器最终调用多个服务和/或存储库,并且我最终必须输入所有接口及其可能的左错误类型,该怎么办?
我的做法是否错误?或者这是预期的?
另一种方法是使用
try/catch
混合两种方法来处理异常,并在控制器处捕获它们。并使用 Either
来处理错误,我可以在调用堆栈中更早地处理它。
您已经深入研究了函数式编程中错误处理的一个重要方面,特别是使用 Either monad。虽然类型的冗长和耦合的增加最初可能看起来令人难以承受,但在 FP 中,在每一层显式处理错误类型是很常见的。
您对在调用堆栈中传播错误的担忧是有效的。正如您正确地提到的,控制器不一定要处理低级错误详细信息;它更适合解释请求上下文中的错误并制定适当的响应。
一个潜在的改进可能是引入常见的错误联合类型或更细粒度的错误处理策略。例如:
type AppError =
| DataSourceError
| ParseError
| TodoNotFoundError
// Add more specific errors as necessary
// In the repository interface
export type TodoRepository = {
findAll(): Promise<TaskEither.TaskEither<AppError, ReadonlyArray<Todo>>>;
findOne(id: string): Promise<TaskEither.TaskEither<AppError, Todo>>;
// ...
}
这种整合可能会减少类型中的一些冗长内容,并提供更集中的方法来处理错误。此外,随着应用程序的增长,考虑显式错误处理与其引入的复杂性之间的权衡至关重要。
关于将 try/catch 与 Either 混合,这是一种有效的方法。您可以为真正意外的异常情况(例如灾难性故障)保留 try/catch,同时使用 Either 来处理域内可恢复的错误或预期的故障情况。
混合使用这两种方法可以在 FP 的显式错误处理和传统的基于异常的处理之间提供平衡,但保持两者之间的明确界限对于代码库的清晰度和一致性至关重要。
请记住,在错误处理的明确性和代码维护的实用性之间找到适当的平衡是关键。每个项目可能有不同的要求和权衡,因此相应地调整错误处理策略是一个很好的做法。