如果模型正在验证数据,那么是否应该在输入错误时抛出异常?


9

读这个SO问题,似乎不赞成抛出用于验证用户输入的异常。

但是谁应该验证这些数据呢?在我的应用程序中,所有验证都是在业务层完成的,因为只有类本身才真正知道哪些值对其属性中的每个属性都有效。如果我要将用于验证属性的规则复制到控制器,则验证规则可能会更改,并且现在有两个地方需要进行修改。

我以为应该在业务层上进行验证的前提是否错误?

我做的事

因此,我的代码通常最终如下所示:

<?php
class Person
{
  private $name;
  private $age;

  public function setName($n) {
    $n = trim($n);
    if (mb_strlen($n) == 0) {
      throw new ValidationException("Name cannot be empty");
    }
    $this->name = $n;
  }

  public function setAge($a) {
    if (!is_int($a)) {
      if (!ctype_digit(trim($a))) {
        throw new ValidationException("Age $a is not valid");
      }
      $a = (int)$a;
    }
    if ($a < 0 || $a > 150) {
      throw new ValidationException("Age $a is out of bounds");
    }
    $this->age = $a;
  }

  // other getters, setters and methods
}

在控制器中,我只是将输入数据传递给模型,并捕获引发的异常以向用户显示错误:

<?php
$person = new Person();
$errors = array();

// global try for all exceptions other than ValidationException
try {

  // validation and process (if everything ok)
  try {
    $person->setAge($_POST['age']);
  } catch (ValidationException $e) {
    $errors['age'] = $e->getMessage();
  }

  try {
    $person->setName($_POST['name']);
  } catch (ValidationException $e) {
    $errors['name'] = $e->getMessage();
  }

  ...
} catch (Exception $e) {
  // log the error, send 500 internal server error to the client
  // and finish the request
}

if (count($errors) == 0) {
  // process
} else {
  showErrorsToUser($errors);
}

这是一个不好的方法吗?

替代方法

也许我应该为该方法创建isValidAge($a)返回true / false的方法,然后从控制器中调用它们?

<?php
class Person
{
  private $name;
  private $age;

  public function setName($n) {
    $n = trim($n);
    if ($this->isValidName($n)) {
      $this->name = $n;
    } else {
      throw new Exception("Invalid name");
    }
  }

  public function setAge($a) {
    if ($this->isValidAge($a)) {
      $this->age = $a;
    } else {
      throw new Exception("Invalid age");
    }
  }

  public function isValidName($n) {
    $n = trim($n);
    if (mb_strlen($n) == 0) {
      return false;
    }
    return true;
  }

  public function isValidAge($a) {
    if (!is_int($a)) {
      if (!ctype_digit(trim($a))) {
        return false;
      }
      $a = (int)$a;
    }
    if ($a < 0 || $a > 150) {
      return false;
    }
    return true;
  }

  // other getters, setters and methods
}

而且控制器基本上是相同的,只是现在有了if / else而不是try / catch:

<?php
$person = new Person();
$errors = array();
if ($person->isValidAge($age)) {
  $person->setAge($age);
} catch (Exception $e) {
  $errors['age'] = "Invalid age";
}

if ($person->isValidName($name)) {
  $person->setName($name);
} catch (Exception $e) {
  $errors['name'] = "Invalid name";
}

...

if (count($errors) == 0) {
  // process
} else {
  showErrorsToUser($errors);
}

所以我该怎么做?

我对自己的原始方法感到非常满意,并且向我展示过它的同事们都喜欢它。尽管如此,我应该改用其他方法吗?还是我做这件事很错,我应该寻找另一种方式?


我已经对“原始”代码进行了一些修改以处理ValidationException其他异常
CarlosCampderrós13

2
向最终用户显示异常消息的一个问题是,模型突然需要知道用户说什么语言,但这主要是View所关心的。
Bart van Ingen Schenau 2013年

@BartvanIngenSchenau好收获。我的应用程序一直都是单语言的,但是最好考虑一下任何实现中可能出现的本地化问题。
卡洛斯·坎德罗斯(CarlosCampderrós)

验证异常只是将类型注入流程的一种奇特方法。您可以通过返回实现验证接口(如)的对象来获得相同的结果IValidateResults
2014年

Answers:


7

我过去使用的方法是放置所有验证逻辑专用的Validation类。

然后,您可以将这些Validation类注入到Presentation Layer中以进行早期输入验证。而且,没有什么可以阻止您的Model类使用完全相同的类来实施Data Integrity。

使用这种方法,然后可以根据发生在哪一层上来不同地对待验证错误:

  • 如果模型中的数据完整性验证失败,则抛出异常。
  • 如果“用户输入验证”在“表示层”中失败,则显示有用的提示,并延迟将值推入模型。

因此,您拥有PersonValidator具有用于验证a的不同属性的所有逻辑PersonPerson类,以及依赖于此的类PersonValidator,对吗?与我在问题中建议的替代方法相比,您的提议提供的优势是什么?我只看到了为注入不同的Validation类的能力Person,但是我想不到在任何情况下都需要这样做。
卡洛斯·坎德罗斯(CarlosCampderrós),

我同意,至少在这种相对简单的情况下,添加一个全新的类进行验证是多余的。对于复杂得多的问题,它可能很有用。

嗯,对于您打算出售给多个人/公司的应用程序来说,这样做很有意义,因为每个公司对于验证一个人的年龄的有效范围可能有不同的规则。因此这可能是有用的,但对于我的需求来说确实太过分了。无论如何,也为您+1
卡洛斯·坎德罗斯(CarlosCampderrós)

1
从耦合和内聚的角度来看,将验证与模型分开也是有意义的。在这种简单的情况下,这可能是过大的了,但是只需要一个“跨域”验证规则就可以使单独的Validator类更具吸引力。
赛斯·

8

我对自己的原始方法感到非常满意,并且向我展示过它的同事们都喜欢它。尽管如此,我应该改用其他方法吗?还是我做这件事很错,我应该寻找另一种方式?

如果您和您的同事对此感到满意,我认为没有迫切需要进行更改。

务实的角度来看,唯一值得怀疑的是,您是在扔Exception东西而不是更具体的东西。问题是,如果您捕获Exception,则可能最终捕获与验证用户输入无关的异常。


现在有很多人说诸如“例外只应用于例外情况,而XYZ并非例外”之类的话。(例如,@ dann1111的答案...,他在其中将用户错误标记为“完全正常”。)

我对此的回答是,没有客观标准来确定某物(“ XY Z”)是否为例外。这是一个主观的措施。(事实上​​,任何程序都需要检查用户输入中的错误并不能使发生错误变为“正常”。实际上,从客观的角度来看,“正常”在很大程度上没有意义。)

那个口头禅有真相。在某些语言(或更准确地说,是某些语言实现)中,异常的创建,抛出和/或捕获比简单的条件要昂贵得多。但是,从这种角度来看,您需要将创建/抛出/捕获的成本与如果避免使用异常可能需要执行的额外测试的成本进行比较。并且“等式​​”必须考虑需要引发异常的可能性

对异常的另一种说法是,他们可以使代码更难理解。但另一方面是,如果适当使用它们,它们可使代码更易于理解。


简而言之,应在权衡优点之后再决定是否使用例外,而不是基于一些简单的教条。


关于泛型Exception被捕获的好点。我确实抛出了的自己的子类Exception,而setter的代码通常不执行可能引发另一个异常的操作。
卡洛斯·坎德罗斯

我已经稍微修改了“原始”代码以处理ValidationException和其他异常/ cc @ dan1111
CarlosCampderrós13

1
+1,我宁愿有一个描述性的ValidationException,而不是回到必须检查每个方法调用的返回值的黑暗时代。代码更简单=错误可能更少。
Heinzi 2013年

2
@ dan1111 -虽然我尊重你有一个意见的权利,没有在你的意见是什么,其他的不是意见。验证的“正常性”与处理验证错误的机制之间没有逻辑联系。您正在做的就是背诵教条。
Stephen C

@StephenC,经过反思,我觉得我的立场确实太过分了。我同意这更多是个人喜好。

6

在我看来,区分应用程序错误用户错误是有用的,并且仅对前者使用异常。

  • 异常旨在涵盖导致程序无法正常执行的情况

    它们是无法预料的,​​阻止您继续操作,它们的设计反映了这一点:它们破坏了正常执行,并跳转到允许错误处理的位置。

  • 诸如无效输入之类的用户错误是完全正常的(从程序的角度来看),不应被应用程序视为意外错误

    如果用户输入了错误的值,并且您显示错误消息,那么程序是否“失败”或有任何错误?不。您的应用程序成功-给出某种输入,在这种情况下它产生了正确的输出。

    处理用户错误(因为它是正常执行的一部分)应该成为常规程序流程的一部分,而不是通过跳出异常来进行处理。

当然,可以将异常用于其预期目的之外,但是这样做会使范式混乱,并在发生错误时冒着错误行为的风险。

您的原始代码有问题:

  • setAge()方法的调用者必须对方法的内部错误处理了解得太多:调用者需要知道年龄无效时会引发异常,并且方法内不会引发其他异常。如果您在中添加了其他功能,则以后可能会打破这种假设setAge()
  • 如果调用方没有捕获到异常,则无效的年龄异常将在以后以其他一些可能最不透明的方式进行处理。甚至导致未处理的异常崩溃。输入无效数据的行为不佳。

备用代码也有问题:

  • isValidAge()引入了额外的,可能不必要的方法。
  • 现在,该setAge()方法必须假定呼叫者已经检查isValidAge()(一个糟糕的假设)或再次验证年龄。如果它再次验证了年龄,则setAge() 仍然必须提供某种错误处理,您将再次回到正题。

建议的设计

  • 制作setAge()返回成功返回真,失败错误。

  • 检查的返回值,setAge()如果返回值失败,则通知用户年龄是无效的,不是异常,而是具有向用户显示错误的正常功能。


那我该怎么办呢?使用我建议的替代方法还是完全没有想到的其他方法?另外,我的前提是“应该在业务层上进行验证”是错误的吗?
卡洛斯·坎德罗斯(CarlosCampderrós)

@CarlosCampderrós,请参阅更新;我在添加您所评论的信息。您的原始设计在正确的位置进行了验证,但是使用异常执行该验证是一个错误。

替代方法的确迫使setAge再次验证,但是由于逻辑基本上是“如果有效,则设置年龄否则抛出异常”,因此我无法回到正题。
卡洛斯·坎德罗斯

2
我发现替代方法和建议的设计都存在一个问题,即它们失去了区分年龄为何无效的能力。可以使它返回true或错误字符串(是的,php太脏了),但这可能会导致很多问题,因为"The entered age is out of bounds" == true并且人们应该始终使用===,所以这种方法比它尝试解决的问题更成问题。解决
CarlosCampderrós13年

2
但是对应用程序进行编码确实很麻烦,因为对于setAge()您在任何地方所做的每件事,都必须检查它是否确实有效。抛出异常意味着您不必担心记住检查一切正常。如我所见,尝试在属性/属性中设置无效值是一种例外,然后值得抛出Exception。该模型不关心它是从数据库还是从用户那里获取输入。它永远不会收到错误的输入,因此我认为在此处抛出异常是合法的。
卡洛斯·坎德罗斯(CarlosCampderrós),

4

以我的观点(我是Java专家),以第一方式实现它是完全有效的。

当不满足某些先决条件(例如,空字符串)时,对象引发Exception是有效的。在Java中,检查异常的概念就是出于这样的目的-必须在签名中声明的异常才能被适当地抛出,并且调用者明确需要捕获这些异常。相反,未经检查的异常(aka RuntimeExceptions)可能随时发生,而无需在代码中定义捕获子句。前一种用于可恢复的情况(例如,错误的用户输入,文件名不存在),而后一种则用于用户/程序员无法执行任何操作的情况(例如,内存不足)。

但是,正如@Stephen C所提到的,您应该定义自己的异常,并专门捕获那些不会无意中捕获其他异常的异常。

但是,另一种方法是使用数据传输对象,这些对象只是没有任何逻辑的数据容器。然后,您将此类DTO移交给验证器或模型对象本身进行验证,并且只有在成功之后才在模型对象中进行更新。当表示逻辑和应用程序逻辑是分离的层(表示是网页,应用是Web服务)时,通常使用此方法。这样,它们在物理上是分开的,但是如果您两者都在同一层上(如您的示例中所示),则必须确保没有解决方法来设置未经验证的值。


4

戴上我的Haskell帽子,这两种方法都是错误的。

从概念上讲,您首先要有一堆字节,然后在解析和验证之后,可以构造一个Person。

该人具有某些不变性,例如姓名和年龄的前缀。

您要不惜一切代价避免能够代表一个只有名字但没有年龄的人,因为这就是创造共融的原因。严格的不变量意味着您以后无需再检查是否存在年龄。

因此,在我的世界中,Person是通过使用单个构造函数或函数原子创建的。该构造函数或函数可以再次检查参数的有效性,但不应构造半人。

不幸的是,Java,PHP和其他OO语言使正确的选项非常冗长。在适当的Java API中,经常使用构建器对象。在这样的API中,创建一个人看起来像这样:

Person p = new Person.Builder().setName(name).setAge(age).build();

或更详细:

Person.Builder builder = new Person.Builder();
builder.setName(name);
builder.setAge(age);
Person p = builder.build();
// Person object must have name and age here

在这些情况下,无论在何处引发异常或在何处进行验证,都不可能收到无效的Person实例。


您在此处所做的只是将问题移至Builder类,您尚未真正回答。
Cypher 2014年

2
我已经将问题本地化到了原子执行的builder.build()函数。该功能是所有验证步骤的列表。这种方法与临时方法之间存在巨大差异。除了简单类型之外,Builder类没有任何不变量,而Person类具有很强的不变量。建立正确的程序就是要在数据中强制使用强不变性。
user239558 2014年

它仍然不能回答问题(至少不能完全解决)。您能否详细说明如何将单个错误消息从Builder类传递到调用堆栈并传递给View?
Cypher 2014年

三种可能性:build()可以引发特定的异常,例如在OP的第一个示例中。可以有一个公共Set <String> validate()返回一组人类可读的错误。对于支持i18n的错误,有一个公共的Set <Error> validate()。关键是这种情况会在转换为Person对象的过程中发生。
user239558 2014年

2

用外行的话来说:

第一种方法是正确的。

第二种方法假定那些业务类将仅由那些控制器调用,并且绝不会从其他上下文中调用它们。

每当违反业务规则时,业务类都必须引发Exception。

控制器或表示层必须决定是抛出它们还是自己进行验证,以防止发生异常。

请记住:您的类可能会在不同的上下文中以及由不同的集成者使用。因此,它们必须足够聪明,可以将异常抛出给错误的输入。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.