验证逻辑应该放在哪里?

13

问题

在需要进行复杂验证逻辑的情况下,最好如何管理对象图的构建?我希望保留依赖注入和无操作构造函数以实现可测试性。

可测试性对我来说非常重要,您的建议如何维护代码的这一属性?

背景

我有一个普通的 Java 对象,它为我管理一些业务数据的结构:

class Pojo
{
    protected final String p;

    public Pojo(String p) {
        this.p = p;
    }
}

我希望确保p的格式是有效的,因为如果没有这个保证,这个业务对象就没有意义;如果p是无意义的话,甚至不应该创建它。然而,验证p是非常棘手的。 注意事项 实际上,这需要复杂的验证逻辑,以至于这个逻辑应该可以完全在自己的类中进行测试,所以我把这个逻辑放在了一个单独的类中:
final class BusinessLogic() implements Validator<String>
{
    public String validate(String p) throws InvalidFoo, InvalidBar {
        ...
    }
}

可能存在的重复问题

  • 应该在哪里实现验证逻辑? - 接受的答案对我来说难以理解。我将“在类的本地环境中运行”解读为同义反复,那么验证规则除了在“类的本地环境”中运行,还能在什么地方运行呢?第二点我无法理解,因此无法评论。
  • 在哪里提供验证逻辑规则? - 两个答案都建议客户端/数据提供者负责,这在原则上是可以接受的。然而,在我的情况下,客户端可能不是数据的发起者,无法对其进行验证。
  • 在哪里保留验证逻辑? - 建议模型拥有验证功能,但我认为这种方法不太理想用于测试。具体而言,对于每个单元测试,我需要关心所有验证逻辑,即使我正在测试模型的其他部分,我也无法完全隔离我想要测试的内容,遵循建议的方法。

我的思考

尽管以下构造函数公开声明了Pojo的依赖关系并保留其简单的可测试性,但它完全不安全。这里没有任何东西可以阻止客户端提供声称每个输入都是可接受的验证器!

public Pojo(Validator businessLogic, String p) throws InvalidFoo, InvalidBar {
    this.p = businessLogic.validate(p);
}

因此,我对构造函数的可见性进行了一定的限制,并提供了一个工厂方法来确保验证后再进行构造:
@VisibleForTesting
Pojo(String p) {
    this.p = p;
}

public static Pojo createPojo(String p) throws InvalidFoo, InvalidBar {
    Validator businessLogic = new BusinessLogic();
    businessLogic.validate(p);
    return new Pojo(p);
}

现在我可以将createPojo重构为工厂类,这将恢复"单一职责"到Pojo,并简化工厂逻辑的测试,更不用说不再浪费地在每个新Pojo上创建一个新(无状态)BusinessLogic带来的性能优势了。
我的直觉告诉我应该停下来,寻求外部意见。我是否正确?

1
为了将验证逻辑与其他代码分开,您应该考虑使用jsr-303 bean验证。 - SpaceTrucker
3个回答

13
以下是一些回复元素... 如果这些内容让您明白/回答了您的问题,请告诉我。

介绍: 我认为您的系统可以是一个简单的库、多层应用程序或复杂的分布式系统,但在验证方面并没有太大区别:

  • 客户端: 远程客户端(例如 HTTP 客户端)或仅调用您的库的另一个类。
  • 服务: 远程服务(例如 REST 服务)或您公开的 API。

应该在哪里进行验证?

通常需在以下位置验证输入参数:

  • 客户端上,在将参数传递给服务之前,确保对象在后续过程中是有效的。如果它是远程服务或参数生成和对象创建之间存在复杂流,则尤其需要这样做。

  • 服务端上:

    • 在类级别的构造函数中,确保创建有效的对象;
    • 在子系统级别上,即管理这些对象的层次(例如持久化您的Pojo的DAL);
    • 在服务的边界处,例如您库的facade或外部API,或MVC中的控制器(例如REST端点、Spring控制器等)。

如何进行验证?

假设以上情况,由于您可能需要在多个地方重用验证逻辑,将其提取到一个实用类中可能是一个好主意。这样:

  • 您不会重复它(DRY!);
  • 您可以确保系统的每个层都以相同的方式进行验证;
  • 您可以轻松地单独测试此逻辑(因为它是无状态的)。

更具体地说,您至少应在构造函数中调用此逻辑,以强制执行对象的有效性(将有效依赖项视为算法中的前提条件,出现在Pojo的方法中):

实用类

public final class PojoValidator() {
    private PojoValidator() {
        // Pure utility class, do NOT instantiate.
    }

    public static String validateFoo(final String foo) {
        // Validate the provided foo here.
        // Validation logic can throw:
        // - checked exceptions if you can/want to recover from an invalid foo, 
        // - unchecked exceptions if you consider these as runtime exceptions and can't really recover (less clutering of your API).
        return foo;
    }

    public static Bar validateBar(final Bar bar) {
        // Same as above...
        // Can itself call other validators.
        return bar;
    }
}

Pojo类: 注意静态导入语句,以提高可读性。

import static PojoValidator.validateFoo;
import static PojoValidator.validateBar;

class Pojo {
    private final String foo;
    private final Bar bar;

    public Pojo(final String foo, final Bar bar) {
        validateFoo(foo);
        validateBar(bar);
        this.foo = foo;
        this.bar = bar;
    }
}

如何测试我的Pojo?

  1. 您应该添加创建单元测试,以确保在构造时调用验证逻辑(以避免人们“简化”构造函数后删除此验证逻辑的回归,原因为X、Y、Z等)。

  2. 如果依赖关系很简单,可以将其内联创建,因为这样可以使您的测试更易读,因为您使用的所有内容都是本地的(滚动较少,精神印象较小等)。

  3. 但是,如果您的Pojo的依赖项设置足够复杂/冗长,以至于测试不再可读,则可以将此设置分解为一个@Before/setUp方法,以便测试Pojo的逻辑真正专注于验证您的Pojo的行为。

无论如何,我同意Jeff Storey的观点:

  1. 使用有效参数编写测试。
  2. 不要为了测试而创建没有验证的构造函数。这确实是一种代码异味,因为您混合了生产和测试代码,并且某个时候肯定会被某人无意中使用,从而影响服务的稳定性。

最后,将您的测试视为代码示例、示例或可执行规范:您不希望通过以下方式提供混乱的示例:

  • 注入无效参数;
  • 注入验证器,这会使您的API变得混乱/阅读起来“奇怪”。

如果 Pojo 需要非常复杂的依赖关系怎么办?

[如果是这种情况,请告诉我们]

生产代码: 您可以尝试在工厂中隐藏此复杂性。

测试代码: 要么:

  • 如上所述,在不同的方法中分离出这些依赖关系;或者
  • 使用您的工厂;或者
  • 使用模拟参数,并配置它们以确保它们符合测试要求,从而使测试通过。

编辑:以下是几个与安全相关的输入验证链接,也可能会有用:


与马克的交谈让我得出以下结论:在简单情况下,在构造函数中进行验证。如果验证过程太复杂(可能依赖于数据库查找),你可以继续在构造函数中进行验证(在测试时,可以通过设置注入模拟数据库数据访问对象来禁用对数据库的依赖,以此来测试验证类)。或者你可以选择上述方法,这样就不需要维护任何额外的测试代码。这个选择是一个权衡之间的取舍。也许使用前一种方式可以防止其他开发人员违反对象的约定,而使用后一种方式可能会增加更多的单元测试。 - CraigJPerry

3
我希望确保p是有效格式,因为如果没有这个保证,这个业务对象就毫无意义;如果p是无意义的话,它甚至不应该被创建。然而,验证p并不是一件简单的事情。
"有效格式的p"表示p的类型不仅仅是String,而是更具体的类型。
例如,EmailString
class Pojo
{
    protected final EmailString p;

    public Pojo(EmailString p) {
        this.p = p;
    }
}
  1. 必须存在一个将StringEmailString之间转换的转换器。这个非常重要的转换器是业务逻辑的一部分,确保p不能无效。每当消费者拥有String并想要创建Pojo时,就会使用这个转换器。

有几种实现这个转换器的方法,可以方便地捕获无法转换的情况,例如,像.net一样:

class Converter
{
    public static bool TryParse(String s, out EmailString p) {

    }
}

测试性对我来说非常重要,你的建议如何维护代码的这一属性?

这样可以使您能够将 Pojo 逻辑与 String 解析/转换逻辑分开测试。


3
首先,我认为验证逻辑不应该仅存在于客户端。这有助于确保您不会将无效数据放入数据存储器中。如果您添加其他客户端(例如您有一个厚客户端并添加一个Web服务客户端),则需要在两个位置维护验证。
我认为不应该为构造一个不进行验证的对象而拥有构造函数(带有那个@VisibleForTesting注释)。通常情况下,您应该使用有效的对象进行测试(除非您正在测试错误情况)。另外,在生产代码中只为测试而添加额外的代码是一种代码异味,因为它并不是真正的生产代码。
我认为放置验证逻辑的合适位置是在域对象本身内部。这将确保您不会创建无效的域对象。
我不太喜欢将验证器传递到域对象中的想法。这会给域对象的客户端带来很多工作,他们需要知道验证器的存在。如果要创建单独的验证器,则可以增加重用性和模块化的好处,但我不会注入它。在测试中,您始终可以使用完全绕过验证的模拟对象。
向域模型添加验证是Web框架(如grails / rails)常见的做法。我认为这是一个好的方法,不应该影响可测试性。

如果我理解正确,要点是“将验证逻辑放在构造函数中”。我倾向于投反对票,因为它是链接的潜在重复问题“在哪里保留验证逻辑?”所采取的方法的副本。我已经在问题正文中拒绝了这个建议,因为它会影响测试(无法将正在测试的代码与验证逻辑隔离开来)。建议使用模拟构造函数进行测试可能会导致危险的代码——如果构造函数随着时间的推移而增长,测试将无法反映这一点。 - CraigJPerry
1
这就是我所说的,但我不同意你的前提,即它会使代码难以测试。我认为,如果你正在测试模型的其他部分,域对象应该是有效的,否则你就是在对无效的域对象进行域逻辑代码测试。 - Jeff Storey
如果你必须在测试域逻辑的每个部分之前满足所有域对象验证,那么你正在创建一个可维护性时间消耗 - 当验证逻辑发生变化时,你将不得不重构所有单元测试,而不仅仅是验证逻辑单元测试,这些测试应该是唯一的(直接或间接)关于验证逻辑的断言。 - CraigJPerry
1
我认为测试始终应该在有效对象上进行测试,否则测试可能无效。我建议构建一个测试数据构建器,这就是Grails使用其插件http://www.grails.org/plugin/build-test-data的示例。 始终有可能类中的方法依赖于验证约束(例如,可能某个方法不检查null,因为该类不允许null),因此使用约束的不仅仅是验证测试。 - Jeff Storey

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接