灵活的错误处理/结果类 | Node.js + TypeScript

106 阅读7分钟

你是否曾经发现自己想知道到底应该在哪里抛出一个错误,以便由try-catch来消耗?你是否有时会出现多层的try-catch块?你是否应该直接返回null来代替?

如果你的回答是否定的,我会很惊讶。作为一个曾经的新开发者,从初级Java开发者到Node.js开发者,再到TypeScript的狂热者,我已经经历了数百次。

考虑一下创建一个User 对象。我们需要传入几个需要验证的参数,以便创建一个。

class User {
  public email: string;
  public firstName: string;
  public lastName: string;

  private constructor (email: string, firstName: string, lastName: string): User {
    this.email = email;
    this.firstName = firstName;
    this.lastName = lastName;
  }

  public static createUser (email: string, firstName: string, lastName: string): User {
    if (!isValidEmail(email)) {
      throw new Error("Email is invalid")
    }

    // .. validate firstName

    // .. validate lastName

    // return new user
  }
}

这是一个相当常见的情况。在这一点上,我们可能会考虑,"从这个类中捕捉这个错误有意义吗?"。

不,那是没有意义的。在User 类中捕捉创建错误有什么好处?它的全部目的是被其他东西所使用。即使如此,我们会怎么做呢?返回null ?这不是一个好主意。调用代码希望从这个方法中得到一个User

好吧,这意味着任何创建User的方法都需要确保他们用一个try-catch 块来包装创建一个User

我不认为这是一个好的方法来处理如此微不足道的操作

为什么有目的的抛出错误不一定是最好的选择?

请注意我说*"不一定是最好的选择 "*是多么谨慎。这是因为有时它是一个好的选择。

但是如果像创建新对象这样的事情如此危险,它就会对调用的代码进行约束。这是一种代码的味道,如果我曾经闻到过的话。

我们不想这样做的另一个原因是,使用throw 关键字并不是非常类型安全的。

当我们使用throw 关键字时,我们打破了代码的流程,跳到了最近的错误处理程序(如果它存在的话,而且最好存在,否则我们会得到一个uncaughtException错误)。

这种跳跃性的行为与有时被批评的 GOTO 语句相似之处。

支持和反对这种行为的观点都有,但对于我们的需求来说,我们想要的是可预测的类型安全的程序行为。这也是我们这么多人首先被吸引到TypeScript的主要原因之一。

是的,你可以记住try-catch 块放在所有的地方,并预测将要发生的事情,但同样,编译器在这方面完全没有帮助你。这都是你自己的事。

介绍一下结果类

我第一次发现结果类是在Vladimir Khorikov的pluralsight课程中学习[贫血领域模型]的时候。

这是他的C#结果类,转换为TypeScript。

export class Result<T> {
  public isSuccess: boolean;
  public isFailure: boolean
  public error: string;
  private _value: T;

  private constructor (isSuccess: boolean, error?: string, value?: T) {
    if (isSuccess && error) {
      throw new Error(`InvalidOperation: A result cannot be 
        successful and contain an error`);
    }
    if (!isSuccess && !error) {
      throw new Error(`InvalidOperation: A failing result 
        needs to contain an error message`);
    }

    this.isSuccess = isSuccess;
    this.isFailure = !isSuccess;
    this.error = error;
    this._value = value;
    
    Object.freeze(this);
  }

  public getValue () : T {
    if (!this.isSuccess) {
      throw new Error(`Cant retrieve the value from a failed result.`)
    } 

    return this._value;
  }

  public static ok<U> (value?: U) : Result<U> {
    return new Result<U>(true, null, value);
  }

  public static fail<U> (error: string): Result<U> {
    return new Result<U>(false, error);
  }

  public static combine (results: Result<any>[]) : Result<any> {
    for (let result of results) {
      if (result.isFailure) return result;
    }
    return Result.ok<any>();
  }
}

使用这个类有很多好处。它使我们能够。

  • 安全地返回错误状态
  • 返回有效的结果
  • 结合几个结果并确定整体的成功或失败状态

有了一个新的Result<T> 实例,我们可以。

  • isSuccess ,检查是否有效
  • 用检查失败isFailure
  • 收集错误error
  • 收集值getValue()
  • 使用Result的数组来检查其有效性。Result.combine(results: Result[])

使用结果类

让我们调整一下User 类,从静态createUser() 工厂方法返回一个Result<User> ,而不是明确地抛出一个错误。

class User {
  public email: string;
  public firstName: string;
  public lastName: string;

  private constructor (email: string, firstName: string, lastName: string): User {
    this.email = email;
    this.firstName = firstName;
    this.lastName = lastName;
  }

  public static createUser (email: string, firstName: string, lastName: string): Result<User> {
    if (!isValidEmail(email)) {
      return Result.fail<User>('Email is invalid')
    }

    if (!!firstName === false && firstName.length > 1 && firstName.length < 50) {
      return Result.fail<User>('First name is invalid')
    }

    if (!!lastName === false && lastName.length > 1 && lastName.length < 50) {
      return Result.fail<User>('Last name is invalid')
    }

    return Result.ok<User>(new User(email, firstName, lastName));
  }
}

注意:另一个潜在的重构是将验证规则放在email,firstNamelastNameValue Objects中。

然后让我们从一个父类创建一个User

class CreateUserController {
  public executeImpl (): void {
    const { req } = this;
    const { email, firstName, lastName } = req.body;
    const userOrError: Result<User> = User.create(email, firstName, lastName);

    if (userOrError.isFailure) {
      return this.fail(userOrError.error)
    }

    const user: User = userOrError.getValue();

    // persist to database ...
    
  }
}

瞧!这就对了。

如果我们使用Value Objects,我们可以使用Result.combine() 方法来一次验证一个数组的Result,就像这样。

class CreateUserController {
  public executeImpl (): void {
    const { req } = this;
    const { email, firstName, lastName } = req.body;
    const emailOrError: Result<Email> = Email.create(email);
    const firstNameOrError: Result<FirstName> = FirstName.create(firstName);
    const lastNameOrError: Result<LastName> = LastName.create(lastName);

    const userPropsResult: Result<any> = Result.combine([ 
      emailOrError, firstNameOrError, lastNameOrError
    ])

    // If this failed, it will return the first error that occurred.
    if (userPropsResult.isFailure) {
      return this.fail(userPropsResult.error)
    }

    const userOrError: Result<User> = User.create(
      emailOrError.getValue(), 
      firstNameOrError.getValue(), 
      lastNameOrError.getValue()
    );

    if (userOrError.isFailure) {
      return this.fail(userOrError.error)
    }

    const user: User = userOrError.getValue();

    // persist to database ...
  }
}

这就是了!这就是我们如何使用Result 类来让编译器帮助我们处理预期的边缘案例错误。


在有些情况下,有目的地抛出错误确实有很大的意义!

什么时候要有目的地抛出错误

答:当你正在开发一个库或一个工具,供其他开发者使用。

在这种情况下,你不想强迫他们使用我们喜欢的Result 类或任何其他非标准的方法来捕捉错误。我们应该让他们自己来决定。

虽然,在JavaScript世界中,一个常见的惯例是将错误作为回调的第一个参数返回。

例子:将回调错误包装成被拒绝的承诺

实现Redisnpm包的开发者决定,他们想用回调的方式来报告错误。

client.get(key,
  (error: Error, reply: unknown) => {
    if (error) {
      // handle error
    } else {
      // handle reply
    }
});

在我消费这个库的实际应用程序代码中,我通常会将这些包裹在一个Promises中,以便被我的其他代码用async/await使用。

import { RedisClient } from 'redis'

export abstract class AbstractRedisClient {
  protected client: RedisClient;

  constructor (client: RedisClient) {
    this.client = client;
  }

  public getOne<T> (key: string): Promise<T> {
    return new Promise((resolve, reject) => {
      this.client.get(key,
         (error: Error, reply: unknown) => {
          if (error) {
            return reject(error)
          } else {
            return resolve(<T>reply);
          }
      });
    })
  }
}

当你处理API、外部资源或其他对外界的适配器时,需要创建你自己的适配器来使用它是一件很常见的事情,这样你就可以在自己的代码库中使用自己的代码风格安全地引用它。

但是,如果需要为每个类(如User 类)创建一个Adapter,以便安全地封装它们,那就不太干净了......所以我们需要别的东西。

B: 当我们遇到我们不期望或不知道如何处理的错误时。

有点像对A 的延伸,因为当我们在处理库的代码时,我们并不真正知道将来使用我们代码的人打算如何处理错误,我们只需要让他们知道错误正在发生

对于那些我们不知道如何处理的错误,通常,我们希望取消正在进行的操作,因为发生了一些不好的事情。

这些错误真的会把我们想做的事情弄得一团糟,而且可能是由无限可能的事情引起的,我们没有想到或假设会发生。

  • 数据库连接问题
  • 代码错误
  • 空指针错误(有时)
  • 内存不足

如果是一个http请求,我们可能会向客户端抛出一个500 错误。

如果是一个脚本,我们可能会以一个非零的错误代码退出。

或者,是的,使用throw 语句,但只有当你写的代码要被别人使用,而你又不知道他们打算如何使用它的时候。

在这种情况下,我们真正有意义的是杀死我们正在做的任何事情,直接退出或取消网络请求。

总结

有些开发者在这方面可以做得非常非常花哨。我个人并没有对这个问题做太多的研究,但它源于单体和类似的东西,如果你把它做到极致,你可以得到一些非常有趣的类似于Rxjs的结果。

我不认为这种编程方式是主流,以至于我现在还不想向我的同龄人大力宣传它,因为即使是Rx.js有时也是一种挑战。也许在某个时候,我会找到时间去学习更多。