验证逻辑应该位于何处?

2022-09-04 19:56:14

问题

如何最好地管理需要复杂验证逻辑的对象图的构造?出于可测试性的原因,我想保留注入的依赖关系,无所事事的构造函数。

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

背景

我有一个普通的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 {
        ...
    }
}

可能的重复问题

  • 验证逻辑应在哪里实现?- 公认的答案对我来说是难以理解的。我读“在类的本机环境中运行”作为重言式,验证规则如何在“类的本机环境”以外的任何内容中运行?第2点,我无法摸索,所以我不能评论。
  • 在哪里提供用于验证的逻辑规则?- 两个答案都表明责任在于客户/数据提供者,原则上我喜欢。但是,在我的情况下,客户端可能不是数据的创建者,因此无法对其进行验证。
  • 在哪里保留验证逻辑?- 建议验证可以归模型所有,但是我发现这种方法不太适合测试。具体来说,对于每个单元测试,我需要关心所有的验证逻辑,即使我正在测试模型的其他部分 - 我永远无法完全隔离我想要测试的内容,遵循建议的方法。

到目前为止,我的想法

尽管下面的构造函数公开声明了 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

以下是一些回应要素...让我知道它是否有意义/回答你的问题。


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

  • client:一个远程客户端(例如HTTP客户端)或只是另一个调用你的库的类。
  • 服务:远程服务(例如 REST 服务)或仅显示 API 的内容。

在哪里验证?

您通常希望验证输入参数:

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

  • 服务端:

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

如何验证?

假设上述情况,由于您可能必须在多个位置重用验证逻辑,因此在实用程序类中提取它可能是一个好主意。这边:

  • 你不会复制它(干!);
  • 您确信系统的每一层都将以相同的方式进行验证;
  • 您可以轻松地单独对此逻辑进行单元测试(因为它是无状态的)。

更具体地说,您至少可以在构造函数中调用此逻辑,以强制执行对象的有效性(考虑将有效的依赖项作为算法的先决条件,这些前提条件出现在 中):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 依赖项的设置足够复杂/冗长,以至于测试不再可读,则可以在 / 方法中分解此设置,以便单元测试的逻辑真正专注于验证 Pojo 的行为。@BeforesetUpPojo

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

  1. 使用有效参数编写测试,
  2. 没有仅用于测试的未经验证构造函数。当您混合生产和测试代码时,它确实是一种代码气味,并且肯定会在某个时候被某人无意中使用,从而构成服务的稳定性。

最后,将您的测试视为代码示例,示例或可执行规范:您不想通过以下方式给出一个令人困惑的示例:

  • 注入无效参数;
  • 注入验证器,这将使您的API混乱/读取“奇怪”。

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

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

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

测试代码:任一:

  • 如上所述,用不同的方法解决这个问题;或
  • 使用您的工厂;或
  • 使用模拟参数,并对其进行配置,以便验证测试通过的要求。

编辑:关于安全上下文中输入验证的一些链接,这也很有用:


答案 2

首先,我想说的是,验证逻辑不应该仅仅存在于客户端中。这有助于确保您最终不会在数据存储中放置无效数据。如果添加其他客户端(例如,您可能有一个胖客户端,并且添加了一个 Web 服务客户端),则需要在这两个位置维护验证)。

我不认为你应该有一个构造函数来构造一个不验证的对象(使用该注释)。通常应该使用有效对象进行测试(除非您正在测试错误情况)。此外,在生产代码中添加仅用于测试的其他代码是一种代码异味,因为它不是真正的生产代码。@VisibleForTesting

我认为放置验证逻辑的适当位置是在域对象本身内。这将确保您不会创建无效的域对象。

我真的不喜欢将验证器传递到域对象中的想法。这给需要了解验证程序的域对象的客户端带来了大量工作。如果你想创建一个单独的验证器,这可能会增加重用和模块化的好处,但我不会注入这一点。在测试中,您始终可以使用完全绕过验证的模拟对象。

向域模型添加验证是 Web 框架(圣杯/轨道)的常见做法。我认为这是一个好方法,它不应该损害可测试性。