DDD

论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等类型化语言中的一切安全机制都将瞬间失效。这意味着作为此类方法的调用方,我们得不到关于错误的任何处理提醒。

正因为如此,开发人员往往会选择逃避责任——对应用程序/项目中的错误彻底不管不顾。最终结果,只能是大量错误涌至项目入口点,并导致应用程序到底崩溃。

我们假设首次方法编写与首次方法调用恰好是同一位开发者负责,那他自己肯定很清楚抛错问题的存在。但几个月后,可能有新的开发人员加入团队,并着手重用此项函数——或者利用其他函数调用这项函数。无论是哪种情况,后来者根本意识到抛错的存在,并最终导致Typerscript的一切内置安全机制彻底失效。

该如何解决?

相较于无脑抛错,返回不同结果才是正道

我们再来回顾之前的示例:
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);
};



大家首先想到的,应该是调整方法签名,从而强制要求调用方根据签名上下文处理患者计数为负的情况。



比较常见的方法之一,是将函数修改为:
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(或者undefined等其他点位符)仍然无法揭示特定方法中到底出的是哪类错误。返回结果全部是null,我们不可能追溯到当前null的来源。

最后,这种方法的另一大缺陷,在于破坏了原有方法的线性流程:作为调用方,我们被迫在每个指向此方法的调用中使用if(myResult == null)分支。事实证明,这会严重影响大型用例当中方法调用机制的可读性与简单性。

还有更好的办法吗?

要回答这个问题,我们先来整理一下目前的发现:
  • 我们希望方法签名能够明确表示引发错误状态的可能性
  • 我们希望每种方法都能支持任意多种错误类型
  • 除非必要,否则我们不希望破坏方法调用流程


问题明确了,我们完全可以利用Typerscript及其泛型构建一套新的结构——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);
};



我们将泛型与类型推断的优势结合起来创建Left与Right两个类,并在联合类型Either当中使用。二者共享同样的接口,只是行为有所区别——具体取决于Either到底使用Left还是Right。

在示例中使用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));
}
}
// ......



我们在Left与Right这两个类中,引入了相同的方法applyOnRight,但具体实现却大不相同:



* 如果对象属于Left实例,则不执行任何操作,并将自身作为Either<L,B>对象返回。
* 如果对象为Right实例,则按其value应用函数计算,而后返回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<{message: string;}, number> 。



一旦返回Either,调用方就必须得着手处理错误;但在处理当中,调用方只需要接触一个分支,另一分支直接转发就行。

大家可能已经注意到,applyOnRight还可以一个接一个链接起来,进而在Right(通常代表成功)分支上创建一连串操作,并保证潜在错误在签名中得到体现。

到这里,第三点也宣告完成。调用流程顺畅有序,不会被多个if语句所打断。

Either——领域驱动设计中的错误处理利器

本小节要讨论的,是如何在领域驱动设计(DDD)当中实现良好的错误处理功能。

在遵循 DDD原则时,错误处理的重要性将得到进一步提升,这是因为某些残留的错误很可能成为固有业务逻辑的组成部分。

哪些错误与核心领域相关?

一般来说,特别是在DDD场景下,我们可以将错误分为两大基本类型:
  1. 预期内错误:有时候,大家可以很清楚错误状态的逻辑来源,而调用方能够明确理解并处理这些错误状态。以我们之前的sumPatientCounts为例,患者计数为负值显然是种不合逻辑的情况,因此属于预期内错误。每当使用患者计数负值调用函数时,都会收到错误消息,并借此意识到该领域中强制执行的业务规则。预期内错误属于DDD当中最重要的错误类型,因为其具备明确的意义并体现出基本业务逻辑。
  2. 预期外错误:另一方面,某些错误完全在预期之外,而且跟业务逻辑毫无关联。这类问题在涉及不确定性函数(例如从数据库中获取对象、存储文件或者调用外部API)时,就会表现得特别普遍。我尝试从数据库处获取对象,但对方由于网络问题而无法及时响应——这类问题纯属意外,没有丝毫的确定性,而且并不属于核心领域的组成部分(当然,除非您的核心领域就是处理数据库)。


预期内错误才是真正需要避免的错误,应该在方法签名中得到体现。
如果处理不当,这些错误状态就会被隐含在业务规则当中,且错误本身无法在核心领域中得到表示。

预期内错误——一封写给领域的情书

背景:假设当前领域中存在一个User ValueObject,我们希望确保使用者不得以空电子邮件、名或姓创建新User。这是一条业务规则,因此大家必须通过这条值对象将逻辑明确传达给领域中的其他部分。

领域中的大部分预期内错误都拥有明确的类型,而且可能附带一条说明错误原因的消息。下面来看一个简单的结构示例:
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)、领域服务或者应用用例关联起来,从而加强对错误类型的表示效果。

这样,很多错误类型就变得非常明确了,例如 UserCreationError.NotAuthorized或者TransferFundError.NotEnoughFund

事务完整性

在DDD场景下,包括更为常规的软件工程中,我们往往需要同时成功执行多条指令——如果其中一条失败,则全部指令都将失效。

下面来看领域驱动设计中的相关示例:
  • 聚合存储:在对聚合进行存储时,大家需要保证该聚合中的各Entities不致因故障受到影响。如果做不到这一点,业务的整体一致性将无从谈起。
  • 领域服务:假设某项领域服务负责在两个账户之间转移资金。这时我们需要保证只在第二账户成功贷记且第一账户成功借记的前提下,才真正执行交易;如果一者出错,则取消操作。


万一其中某项方法调用出错,而我们又忘记使用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,
'你的转账成功 !',
);
}


备注:为了明确起见,这里我们略去了交易部分。

可以看到,只有成功从转账者处收取资金并转交给收账者后,我们才能存储双方聚合(并发送通知)。
让takeMoney及giveMoney函数中返回Either<CustomerError.InsufficientFund, 'Success'> 或者Either<CustomerError.AccountAlreadyFull, 'Success'> 提示,能够:
  • 让我们轻松检查两项操作是否成功,以及出错时究竟是哪项操作未能完成。
  • 保证两项操作结果的语义始终不变,根据错误原因做出修正决策——避免出现转账者资金未出、收账者资金未入,却显示交易成功的情况。


可以想象,如果在每一项操作中分别使用try/catch,会是多么麻烦。现在明显简洁得多。

总结

本文的重点不在于如何利用Either处理所有错误,毕竟那些预期外错误确实应该直接抛出,且不应影响到签名与代码。但对于预期内错误,我们确实该在方法签名以及调用方代码当中加以体现。

如此一来,您的方法将拥有更好的表示效果与安全保障,您的团队(开发人员)也将及时获得与错误处理相关的责任划分与归属指引,最终防止生产流程因错误堆积而被迫中断。

原文链接:Expressive error handling in TypeScript and benefits for domain-driven design(翻译:Grace)

0 个评论

要回复文章请先登录注册