语义化的错误处理在Typescript的应用以及对领域驱动设计的益处

译者的话:本篇文章主要阐述了如何用typescript实现语义化的错误处理,您需要对DDD有一定的了解,如果您不了解,可以参考这篇文章DDD极简教程

当你初始化项目时,是如何处理错误的?

对于我来说,用Javascript或者Typescript来正确的处理以及抛出错误是十分困难的,并且很容易走错路。

在项目的初期,错误处理常常被低估甚至忽视,一些方法除了抛错别无选择,而其他错误则会从我们的代码中抛出,因为某些的不可能存在状态最终导致了 所有错误冒泡到应用程序的入口点

随着项目的成熟,当您想要根据错误的来源,原因或类型来差异化处理错误时,这些错误很快变得难以管理。

此外,抛出错误将停止所有操作,因此,当您逐步构建复杂的用例时,您常常需要用一种安全的方式来处理失败的情况以及在失败的情况下也会继续的操作。

那么,大多数引发/捕获的错误是什么呢?

从一个方法的签名,您是是无法提前预知抛出的错误的。

如果我们使用了一个方法,这个方法会计算多个患者之和,得出患者总数:

const sumPatientCounts = (...patientCounts: Array<number>): number => Math.sum(...patientCounts);

这个版本与下面的版本实现的功能相同

const sumPatientCounts = (...patientCounts: Array<number>): number => {

if (patientCounts.some(patientCount => patientCount < 0)) {

    throw new Error('All patient counts should be striclty positive');

 }

 return Math.sum(...patientCounts);

};

但是从调用者的角度来说,他无法从方法签名得知,这个方法可能会抛出错误,除非他看了这个方法的具体实现。

从这个方面来说,这个方法的签名是不完整的,并可能因为信息不足引发危险。

如果不提示潜在的错误,那么调用者可能毫不关心如何处理错误

由于方法的签名没有反应这个方法可能抛错,因此诸如Typescript之类的类型化语言的所有安全性都将变得无用,因为这个方法既没有被强迫也没有暗示(方法的调用者)对该错误进行处理。

这种特征使开发人员倾向于对应用程序/项目中的错误状态不负责任,并导致大量错误直到项目的入口点都未处理。

可能是第一次编写此方法与第一次调用该方法是在同一时间,因此开发人员显然知道此方法可能会抛出。六个月后,一个新的开发人员加入了团队,在任务量较大的情况下,必须重新使用此功能或调用该功能的功能。这个新的的开发人员可能不会意识到其中有个方法可能会抛出错误而带来风险,而Typescript的所有键入安全性都将无济于事。

那么可以做些什么呢?

返回一个不同的结果而不是抛出错误

之前的例子如下:

const sumPatientCounts = (...patientCounts: Array<number>): number => {

 if (patientCounts.some(patientCount => patientCount < 0)) {

    throw new Error('All patient counts should be striclty positive');

  }

  return Math.sum(...patientCounts);

};

我们可以更改方法签名,增加null类型的返回值,以强制调用者根据其上下文来处理 至少一个患者计数为负数 情况。

一种常见的模式是将功能更改为:

const sumPatientCounts = (...patientCounts: Array<number>): number | null => {

  if (patientCounts.some(patientCount => patientCount < 0)) {

        return null;

  }

  return Math.sum(...patientCounts);

};

最大的优点是该方法的签名的变化,并且任何调用者都必须处理该签名,如以下示例所示:

const globalPatientCount = sumPatientCounts(1, 2);

if (globalPatientCount == null) {

 // do something in that case

}

return computeSomething(globalPatientCount);

处理错误的责任已经从错误转移到了调用者,这样 更加安全 ,因为可以根据调用者的上下文来不同地处理该错误,并且由于调用者只能选择处理该错误,因此 更具表达性

但是,由于该方法签名的表达能力仍然很差,因此适用范围也很受限。 null没有特定的语义,可以在整个代码库中互换使用。此外,返回null(或未定义或任何占位符)将无法在给定方法中支持几种错误类型,因为最终它们都会全部返回为null,并且无法追溯到该null的起源

最后,此方法的另一个显著缺点是,它破坏了方法的线性流程:作为调用方,您被迫为每个此类方法的调用使用if(myResult == null)进行分支条件判断,事实证明这个方法在调用多个方法的大型用例会减少可读性和简单性。

那么,那又该如何处理呢?

如果我们总结以上:

  • 我们希望方法的签名能够明确表达错误状态的可能性
  • 我们希望每种方法支持任意数量的错误类型
  • 在不必要的情况下,我们不想中断此方法的调用者流程

事实证明,我们可以利用Typescript及其泛型来构建一个结构来做到这一点, 例如: Either ,这种模式受到了 fp-ts库 的启发。

让我们看下面的代码:

export type Either<L, A> = Left<L, A> | Right<L, A>;



export class Left<L, A> {

readonly value: L;



constructor(value: L) {

    this.value = value;

}



isLeft(): this is Left<L, A> {

    return true;

}



isRight(): this is Right<L, A> {

    return false;

}

}



export class Right<L, A> {

readonly value: A;



constructor(value: A) {

    this.value = value;

}



isLeft(): this is Left<L, A> {

    return false;

}



isRight(): this is Right<L, A> {

    return true;

}

}



export const left = <L, A>(l: L): Either<L, A> => {

  return new Left(l);

};



export const right = <L, A>(a: A): Either<L, A> => {

  return new Right<L, A>(a);

};

我们利用通用性和类型推断的优势来创建两个类 LeftRight ,将在联合类型 Either 中使用。它们有相同的接口,但行为不同,具体取决于使用了Either的哪一侧。

Either 来实现之前的例子

让我们更改示例,看看如何使用新结构。

const negativePatientCountError = () => ({

message: 'All patient counts should be strictly positive',

});



const sumPatientCounts = (

  ...patientCounts: Array<number>

): Either<{ message: string }, number> => {

if (patientCounts.some(patientCount => patientCount < 0)) {

    return left(negativePatientCountError());

}

return right(Math.sum(...patientCounts));

};

该函数返回的结果可能是带有消息的错误对象或者数字。然后,调用者可能会变成这样:

const globalPatientCountResult = sumPatientCounts(1, 2);

if (globalPatientCountResult.isLeft()) {

  const { message } = globalPatientCountResult.value;

  // do something in that case

}

const globalPatientCount = globalPatientCountResult.value;

return computeSomething(globalPatientCount);

我们很高兴,因为现在这个方法已经满足了前两个要求(签名表达能力和对任意数量的错误类型的支持)。但是,截至目前,调用者的流程依旧会被打断而变得分散。

强化 Either

Either最具影响力的好处之一是,您可以同时向Left和Right类添加方法以增强它的功能。

让我们尝试改善从以上示例中提取的调用者流程。

一种常见的模式是 获取函数的结果如果这是一个错误,则将其转发,否则对结果进行处理

在Either中,意味着我们只需要在right分支添加一个新的方法。

export type Either<L, A> = Left<L, A> | Right<L, A>;



export class Left<L, A> {

  // ......

  applyOnRight<B>(_: (a: A) => B): Either<L, B> {

      return this as any;

  }

}



export class Right<L, A> {

 // ......

  applyOnRight<B>(func: (a: A) => B): Either<L, B> {

      return new Right(func(this.value));

  }

}

// ......

我们在两个类中添加了同样的方法 applyOnRight ,但是两个的实现差别很大。

- 如果对象是Left的实例,则不执行任何操作,并将自身作为Either<L,B>对象返回。

- 如果该对象是Right的实例,则它将按其自身的值应用该函数,并且还返回Either <L,B>对象

该功能的最大好处是我们可以将调用者示例重构为:

const globalPatientCountResult = sumPatientCounts(1, 2);

return globalPatientCountResult.applyOnRight(globalPatientCount => {

return computeSomething(globalPatientCount);

});



// 或者更短

const globalPatientCountResult = sumPatientCounts(1, 2);

return globalPatientCountResult.applyOnRight(computeSomething);

如果 computeSomething 也返回数字,则调用者的最终签名也将为 Either &lt;{message:string;},number>

返回一个 Either 迫使调用者来处理这个 Either ,但是他有一种有效且干净的方式来仅处理其中一个分支并转发另一个分支,因此会影响其自身的签名。

您还应该注意, applyOnRight 可以一个接一个地链接在一起,从而在右边(例如通常是成功的)分支上创建一连串的操作,并且最终签名忠实于错误的可能性。

我们已经满足了第三项要求:您不必使用多个if语句来中断您的代码流程,您可以按照都是 成功的情况 来编写代码

领域驱动设计中的错误处理也可以用 Either

本节将假定您熟悉域驱动设计(DDD)的最基本概念。

如果您遵循的是DDD原则,那么错误处理就变得更加重要了, 某些错误会是您业务逻辑的一部分

那么那些错误将会与您的核心领域(domain)相关呢?

一般来说,在DDD的上下文中,会将错误归为两类:

  1. 预期中的错误 :有时会有一些由逻辑错误引起错误状态,并且调用者可以理解并且有效处理这些错误。例如,在之前的sumPatientCounts的例子中,当某一个病人数目是负数时候,就会抛错,这个错误是确定的并且有意义的,所以这是个预期中的错误。每次传入负数给这个函数时,都会收到错误消息,这个消息使调用者了解这个领域中强制执行的业务逻辑。这些是DDD中最重要的错误,因为它们是有意义并且传达了业务逻辑。
  2. 意外错误 : 另一方面,一些错误是不在预期中的并且也没有传达任何业务逻辑。一旦您依赖于一些不确定性的功能(例如从数据库拿取数据,保存文件,调用外部接口)时候,这种错误将会非常常见。当我试图从数据库中获取一个对象时,可能由于网络问题,数据库无法回应,因此这个功能具有不确定性,不确定性并不是核心领域的一部分(除非您的主要领域是与数据库打交道)

预期中错误是您真正需要避免抛出的错误,并且应该显示在方法的签名中。

否则,由该错误状态代表的强制执行的业务规则将变得隐晦,导致您无法在核心领域中表达它。

预期中的错误对您的领域上下文非常有用

上下文:假如您的领域中有一个 User ValueObject,并且您想加上一个强制条件-当创建用户的时候,必须有email和姓名,这是一条业务规则,您需要向您领域中其他使用这个value object的部分明确表明这个逻辑。

大部分期望中的错误会有一个类型以及一条消息说明失败原因。以下是该结构的简单示例:

export interface Failure<FailureType extends string> {

type: FailureType;

reason: string;

}

让我们回头看看之前那个简单的Either结构,并尝试使用它来让传达的业务规则变得更加明确。

// 文件 : user/user.ts



// 引入....



interface UserConstructorArgs {

email: string;

firstName: string;

lastName: string;

}



export class User {

readonly email: string;



readonly firstName: string;



readonly lastName: string;



private constructor(props: UserConstructorArgs) {

  this.email = props.email;

  this.firstName = props.firstName;

  this.lastName = props.lastName;

}



static build({

  email,

  firstName,

  lastName,

}: {

  email: string;

  firstName: string;

  lastName: string;

}): Either<Failure<UserError.InvalidCreationArguments>, User> {

  if ([email, firstName, lastName].some(field => field.length === 0)) {

    return left(invalidCreationArgumentsError());

  }

  return right(new User({ email, firstName, lastName }));

}

}
// 文件 : user/error.ts

export enum UserError {

  InvalidCreationArguments,

}



export const invalidCreationArgumentsError = (): Failure<

  UserError.InvalidCreationArguments

> => ({

  type: UserError.InvalidCreationArguments,

  reason: 'Email, Firstname and Lastname cannot be empty',

});

可以看到, build 方法抛出的错误已明确集成到您的核心域中。该方法的签名直接暗示所执行的业务规则。

查看代码,我们引入了通用的简单结构Failure, 它可以成为在域中返回错误的标准方法。

文件 error.ts 表明你可以将期望中的错误按照范围分组,可以按照Entity/ValueObject(这里用的是User)或者领域中的Service或者可能引发错误的用例来进行分组,这样一来,错误的类型可以更具有表达力。

最终,您很可能遇到一些不言自明的错误类型,例如UserCreationError.NotAuthorized或TransferFundError.NotEnoughFund (点前面为错误类型分组,点后为详细错误)

交易完整性

在DDD的上下文中,甚至在一般的软件工程中,您通常需要确保成功执行多条指令,如果至少一条失败,则不执行任何一条指令。

领域驱动设计的示例:

  • 存储一个aggregate :存储一个aggregate时,您要确保已成功保留该aggregate中的实体,但如果发生错误,则不应该存储其中的任一个实体,以免影响业务aggregate要求的一致性。
  • 领域服务 : 假设您有一个领域服务是用来进行两个账户之间的转账。那么您必须确保一个账户扣款,另一个账户存款,否则,就应该什么都不做。

如果事务中调用的方法之一抛出错误,而您却忘记了用 try / catch 包围它,那么您可以能面临某些指令已被执行而其余指令将永远不会执行的风险,因此破坏数据的完整性。

当您需要类似事务的机制时,让此事务中调用的所有函数显式返回其可能的错误将会很有帮助。 Typescript将强制开发人员处理每个错误状态,从而更容易检查每个指令是否已执行。

默认将Either用作返回预期错误不会帮助您实现实际的交易机制(例如,回滚+提交),但是这种方式可以使调用者注意到,这个方法可能在交易中抛出错误,那么调用者在使用时会更加注意,避免错误发生。

实现转账收账的领域服务示例:

原生版本:

export class TransferFundService {

// 构造函数和参数



public async transferFund(

  debtor: Customer,

  creditor: Customer,

  dollars: number,

) {

  try{

    debtor.takeMoney(dollars);

    // 收账者,收取钱

    creditor.giveMoney(dollars);

    // 转账者,转钱

  } catch{

    // 这个实现不太好,因为我们不知道是哪个操作失败了,如果我们想要处理

    // 某个操作(比如发送邮件),或者就算这个操作不成功,也需要进行其他的操// 作



    return;

}

await this.customerRepository.store(debtor);

await this.customerRepository.store(creditor);

await this.emailService.sendConfirmationEmail(

    debtor.email,

    '你的转账成功 !',

);

}

}

备注 :为清楚起见,在此我们特意省略了交易机制。

您可以看到,仅当我们成功地从转账者那里收取了钱并将其交给收款者后,我们才会存储转账者和收款者的aggregates(并发送通知)。

如果让 takeMoney (接收转账)和 giveMoney (转账)分别返回结果:

Either&lt;CustomerError.InsufficientFund, 'Success'> (用户错误.金额不足)以及

Either&lt;CustomerError.AccountAlreadyFull, 'Success'> (用户错误.账户金额达到最大值)

  • 让您轻松检查两个操作是否成功以及 哪个操作 失败抛出了错误。
  • 每个操作的结果语义非常明确,因此可以根据失败原因直接作出决定,您不用以相同的方式处理收款者账户已满,和转账者余额不足。

如果没有 Either ,那么您实现这两个需求会变得很麻烦,您需要将 每个操作try/catch 包裹起来进行判断。

结论

这篇文章的重点不在于用 Either 显式处理所有错误,某些错误将仍然是意外错误,应该被抛出并且不能污染签名和代码,但更多的是, 在调用者的代码,以及方法的签名中都应该表现出预期中的有意义的错误。

如果考虑到错误处理,那么您的方法将会变得更具有表现力以及安全性,您和您的团队,作为开发人员,将对错误变得更加有责任感和主人翁意识。防止错误在生产环境中抛出。 加粗文字

【原文链接】 Expressive error handling in TypeScript and benefits for domain-driven design

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章