Hibernate、Value Object和旧数据向后兼容性问题

cygmwpex  于 2023-06-30  发布在  其他
关注(0)|答案(2)|浏览(166)

我发现Value Object模式真的很令人满意。许多书籍,如Secure by DesignImplementing Domain-Driven DesignDomain Modeling Made Functional,都提倡应用值对象来使域类型更清晰,类型更安全。
例如,假设我有一个包含name的Order实体。我可以将name字段标记为String,但值对象模式建议我创建一个单独的类OrderName。构造函数可能包含Order名称的验证。看看下面的代码示例。

@Entity
class Order {
    ...
    @Embedded
    private OrderName name;
}

@Data
@Setter(PRIVATE)
@Embeddedable
class OrderName extends SelfValidated {
    // custom bean validation annotation
    @OrderNameValid
    private String value;

    public OrderName(String value) {
        // name should be no longer than 50 characters
        this.value = value;
        // call bean validator manually
        validateSelf();
    }

    protected OrderName() {
        // called by Hibernate
        // Hibernate automatically scans bean validation annotaions
        // and invokes validator each time value object is persisted/loaded
    }
}

乍一看,一切都很好。如果传入的输入违反了规定的业务规则(在本例中,最大长度为50个字符),则无法创建OrderName示例。但如果需求发生变化呢?假设业务决定订单名称最大长度应为30个字符。这意味着我不能用旧的命令工作。因为我必须从数据库中读取行并创建Order实体,如果我需要以某种方式修改它的话。
这个问题我想了很久。我可以考虑4解决方案,但我不认为它们都是完全有效的。我想知道你对此有什么想法吗?我很乐意听听你的意见。无论如何,以下是我克服这个问题的方法。

更新数据库中的旧数据以匹配新的业务规则

这个很简单。如果数据无效,请更改它,使其变为有效。开发人员倾向于添加诸如FlywayLiquibase迁移之类的更新。这可以在特定情况下工作。无论如何,解决方案远非完美。原因如下:
1.一些数据可以存储为复杂结构(例如,jsonb对象)。这意味着更新本身将是相当不平凡和麻烦的。
1.这些更新迁移很难测试。如果您使用Testcontainers运行一个数据库示例,并在它之后立即应用迁移,那么它们将在空数据集上运行。因此,全新的更新将不会有任何影响。您已经将这些脚本作为单个测试用例单独重新运行。
1.有时候你无法更新旧数据。在某些情况下,这不是一种选择。你必须以某种方式处理当前存在的数据。

将Value Object作为仅输入参数

我自己想出了这个解决方案,甚至给出了a talk at the conference about it。这个想法很简单。看看下面的代码示例。

@Entity
class Order {
    ...
    private String name;

    // pass value object to update Order state
    public void changeName(OrderName name) {
        // unwrap it to store raw value
        this.name = name.getValue();
    }
}

如您所见,Hibernate实体将name存储为原始String类型。但是如果我们想改变它,那么public方法接受OrderName值对象,然后再解包。当你从数据库中读取数据时,Hibernate会创建一个带有无参数构造函数的实体示例,并通过Java Reflection API为字段设置值。它给了我们几个机会:
1.值对象仍然是域级别的公共API的一部分
1.如果您接受ValueObject作为参数,那么您绝对可以肯定它符合当前的业务规则
1.如果需求的更改不是向后兼容的,它不会阻止您阅读旧数据。
然而,这种方法仍然有一些缺点:
1.代码变得更难,更不明显。可能不清楚为什么我们将值存储为原始类型,但接受值对象作为输入
1.如果您有另一个Order方法,需要它的名称来进行进一步的操作,则无法从它构造值对象。因为如果你这样做,你可能会得到一个异常,由于旧数据无效。因此,即使在域级别中,您仍然使用旧数据
1.如果你想得到一个订单的名字,你也不能用value对象 Package 它(阅读上一点)。
看起来这里值对象的用法不是不言自明的。这就是为什么出现了最后一个选择。

值对象只能在公共构造函数内部验证

在这种情况下,只有直接在代码中创建值对象时,才会验证它们。否则,如果Hibernate使用Java Reflection API示例化Value Object,则该值不会被验证,而只是按原样设置。看看下面的代码示例:

@Data
@Setter(PRIVATE)
@Embeddedable
class OrderName {
    private String value;

    public OrderName(String value) {
        // name should be no longer than 50 characters
        this.value = validateValue(value);
    }

    protected OrderName() {
        // called by Hibernate
        // No validations happens here
    }
}

向后兼容性的问题不再是一个问题。然而,价值对象的概念被打破了。值对象的全部意义在于它不能用无效数据示例化。但现在不是了。因为Hibernate可以通过调用protected构造函数来创建带有无效valueOrderName
如果你应用这样的解决方案,那么你的代码就变得不那么有弹性了。看看下面的代码示例:

@Entity
class Order {
    ...
    private String name;

    public void changeName(OrderName name) {
        // is it a valid object?
        this.name = name;
    }
}

OrderName是由Hibernate创建的,而不是由客户端创建的。在这种情况下,我不能保证OrderName符合当前的验证规则。因此,代码转换为如下内容:

@Entity
class Order {
    ...
    private String name;

    public void changeName(OrderName name) {
        if (name.isValid()) {
            this.name = name;
        } else {
            throw new OrderNameNotValidException(...);
        }
    }
}

如果我允许一个值对象是有效的或无效的,我就不会从使用它中得到任何好处。此外,我甚至让代码变得更难,更不简单。

完全不要使用值对象

所以,如果价值对象模式给我们带来了这么多的障碍,也许我们只需要把它扔掉?此外,一些ITMaven,如艾伦Holub,声称Value Object is an anti-pattern as the entire idea。也许是吧,我不确定。

我很乐意听听你对这个问题的看法。特别是如果你是像我一样的Hibernate用户。我真的很感激任何有价值的建议。

oyxsuwqo

oyxsuwqo1#

我将提请注意ValueObjects的正确性概念。在我看来,从存储在数据库中的旧数据创建的ValueObject被认为是正确的。如果由于业务需求而无法进行数据库迁移,则可以合乎逻辑地假设业务需求通过业务规则为先前创建的对象指定行为,例如要求用户在对对象执行操作之前手动更改名称;在应用新业务需求之前创建的对象上的操作遵循与业务需求出现之后创建的对象所遵循的逻辑路径不同的逻辑路径。在这种情况下,实现ValueObject并不矛盾,但不幸的是,它不能保护它免受日益增加的复杂性的影响--在这种情况下,这是很自然的。

bttbmeg0

bttbmeg02#

构造函数可以包含订单名称的验证
我认为这是你的设计中的一个错误,这使得问题更加困难。

  • 当你需要分支时,构造函数是一个糟糕的设计选择。*

从构造函数引发异常是一种适当的编程错误对策。但是从构造函数中引发异常通常不适用于数据错误--这是一种有效的模式,直到它不起作用。
这里实际上得到的是一个parsing problem--你得到了一些通用的数据结构,在常见的情况下,它直接Map到你所期望的域对象,但在边缘情况下,你需要做一些其他的事情。

historicalData -> Either<CommonCase, EdgeCase>

此外,您的设计可能会受到影响,因为您将太多的责任转移到O/RM中。换句话说,从数据库中获取历史记录,并根据该信息创建一个本地对象--此时,O/RM已经完成了它的工作;你需要精心编写代码,根据你所拥有的历史做正确的事情。
(JSON序列化有一个similar pattern,可能对查看有用)。
找出正确的方法来处理历史数据中的边缘情况实际上是一个领域需求收集练习。这可能是你可以“宣布破产”,并简单地消除/存档任何记录,不满足新的政策。或者,您可能需要保留数据,并在出现问题时将其升级到人类。
另一个常见的模式是历史数据应该继续加载,但是对数据的“更改”必须遵守新策略的约束。

相关问题