如何正确映射持久层和领域对象

5
假设我有一个表示人的Java域类:
```java public class Person { private String name; private int age; private String gender;
// constructor, getters and setters } ```
class Person {

    private final String id; // government id
    private String name;
    private String status;

    private Person(String id, String name) {
        this.id = id;
        this.name = name;
        this.status = "NEW";
    }

    Person static createNew(String id, String name) {
        return new Person(id, name);
    }

    void validate() {
        //logic
        this.status = "VALID";
    }


    public static final class Builder {

        private String id;
        private String name;
        private String status;

        private Builder() {
        }

        public static Builder aPerson() {
            return new Builder();
        }

        public Builder id(String id) {
            this.id = id;
            return this;
        }

        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder status(String status) {
            this.status = status;
            return this;
        }

        public Person build() {
            Person person = new Person(id, name);
            person.status = this.status;
            return person;
        }
    }

我将这个领域类对象存储在数据库中,它是一个普通的类,具有相同的字段+getter和setter。 当我想要存储对象时,我创建一个新的PersonDocument(数据存储在mongo中),使用getter和setter并保存它。 当我想从DB中获取它时,变得复杂了。 我希望我的领域对象仅公开必要的内容,对于业务逻辑,目前只需要创建和验证。 简单地说:

Person p = Person.createNew("1234", "John");
p.validate();
repository.save(p); 

另一种变得复杂的方式是,目前有一个生成器可以允许在任何状态下创建对象。我们确信在数据库中存储的数据具有适当的状态,因此可以以这种方式创建,但缺点是有一个公共API可用,让任何人都可以做任何事情。
最初的想法是使用MapStruct java映射库,但它使用setter来创建对象,并且暴露域类中的setter(据我所知)应该被避免。
有任何建议如何正确实现吗?

“在领域类中公开设置器应该避免” - 为什么? - Luke Garrigan
为什么不提供一个只显示你想要展示的方法的接口? - Ralf Renz
@LukeGarrigan 嗯,它不应该吗? 它难道不应该只包含与业务相关的方法吗? - SirKometa
我从未听说过这个,通常你会把与业务相关的逻辑放在你的服务层中 @SirKometa - Luke Garrigan
1
@LukeGarrigan 你所提到的做法被许多人视为一种反模式,称为贫血领域模型 - plalx
有趣的阅读,谢谢 @plalx - Luke Garrigan
4个回答

2
您的问题很可能来自于两个相互冲突的要求:
1. 您想要仅公开业务方法。 2. 您还想公开数据,因为您希望能够在对象外部实现序列化/反序列化。
其中必须放弃一个。老实说,大多数遇到这个问题的人会忽略第一个,只是引入setter/getters。另一种选择是忽略第二个,并将序列化/反序列化引入到对象中。
例如,您可以在对象中引入一个方法Document toDocument(),以生成与Mongo兼容的json文档,还可以使用Person fromDocument(Document)进行反序列化。
大多数人不喜欢这种解决方案,因为它将技术“耦合”到了对象中。这是好事还是坏事?这取决于您的用例。您想要优化哪一个:更改业务逻辑还是更改技术?如果您不打算经常更改技术并且不打算在完全不同的应用程序中使用相同的类,则没有理由将技术与对象分离。

例如,您可以在生成Mongo兼容的JSON文档的对象中引入一个名为toDocument()的方法,并且使用fromDocument(Document)反序列化一个Person。但是这会倾向于持久性知识并依赖于域中的持久性,这是您在DDD中应该绝对避免的一件事情。 - Tseng
我有些同意。实际上,我会将持久性编码到对象的业务方法中,而不是明确地将其公开为单独的函数,但问题没有列出任何“真正”的业务功能。你绝对不应该做的是尝试使对象“持久性无关”。除非您在完全不同的应用程序中真正重复使用相同的对象,否则您将引入很多耦合,而且没有任何好处。 - Robert Bräutigam
这就是领域驱动设计的全部意义,让聚合/实体完全与持久化无关,并且在您的领域中没有依赖或持久化知识泄漏。请注意,该问题标记为[tag:domain-driven-design],而不仅仅是一些常规的架构模式。持久性无关是可能的,但需要事件溯源才能真正做到无关(或支持映射到后备字段的特殊ORM)。 - Tseng
1
在DDD项目中,通常您会创建聚合和领域逻辑,并在完成后开始实现持久性,这是避免将持久性知识泄漏到域中的好方法。持久性应该适应于域模型,而不是域模型适应于持久性。毕竟,关键点是当您交换持久性时,并不需要在您的域中进行任何更改,并且您不会破坏聚合/域封装,因为持久性框架要求如此。 - Tseng
我不同意你对DDD的解释。但无论如何,“主要点”为什么是能够“交换持久性”?这经常发生吗?在实现业务功能方面,你花费多少时间百分比来交换持久性?在我最近的项目中,我们在4年内只交换了一次持久性(从关系型数据库到Elasticsearch)。这花费了2个月的时间,而且我们不得不重写所有东西,因为改变了可能性和不同的处理逻辑。这大约占我们时间的5%。我宁愿优化95%的时间。 - Robert Bräutigam

2

罗伯特·布劳蒂加姆的观点很好:

两个相互冲突的要求

但艾伦·凯更好的一句话是:

“我很抱歉很久以前为这个主题创造了‘对象’这个术语,因为它让很多人关注较小的想法。大的想法是消息。” ~ 艾伦·凯

所以,不要处理冲突,只需改变方法来避免它。我发现最好的方法是采用功能方法,通过将域更改表达为事件来避免类中的不必要状态和变异。

而不是将类(聚合、V.O.或实体)映射到持久性,我这样做:

  1. 构建一个聚合,其中包含应用聚合规则和不变量所需的数据(V.O.和实体),给定一个操作。此数据来自持久性。聚合不公开getter也不setter;只有操作。
  2. 使用命令数据作为参数调用aggretate的操作。如果整体规则需要,这将调用内部实体操作。这允许责任隔离和解耦,因为聚合根本不需要知道它们的内部实体是如何实现的(告诉,不要问)。
  3. 操作(在聚合根和内部实体中)不修改其内部状态;相反,它们返回表达域更改的事件。聚合主要操作协调并检查其内部实体返回的事件以应用规则和不变量(聚合具有“大局观”),并构建最终的域事件,这是主要操作调用的输出。
  4. 您的持久层对每个必须处理的域事件都有一个应用程序方法(Persistence.Apply(event))。这样,只要事件具有所需的所有数据来保存更改,您的持久性就知道发生了什么,并且可以将更改应用于(甚至包括行为!)。
  5. 发布您的域事件。让您的系统的其余部分知道刚刚发生了什么。

查看此帖子(值得检查此博客中的所有DDD系列),以了解类似的实现。


这是关于将数据保存到数据库的问题。那么如何从数据库中获取对象的数据呢? - Flavius
@Flavius 不是仅仅为了保存数据。这是另一种完全不同的方法,允许解耦和隐藏持久性责任。它回答了OP的两个主要问题。1:“我希望我的领域对象只暴露必要的内容”2:“在创建中[...]的缺点是有一个公共API可用,让任何人都可以做任何事情。”通过创建具有公共操作的AR,并使用持久性层中的持久性数据来使持久性层持久化更改事件,所有这些问题都得到了解决。因此,不需要映射来保存数据。 - jlvaquero

0

这基本上归结为两件事,取决于您愿意在必要的基础设施上添加多少额外工作以及您的ORM /持久性有多么具约束性。

使用CQRS + ES模式

在更大和复杂的领域中使用的最明显的选择是使用CQRS(命令/查询责任分离)“事件溯源”模式。这意味着每个可变操作都会生成一个事件,该事件将被持久化。

当加载聚合时,所有事件将从数据库中加载并按时间顺序应用。应用后,您的聚合将具有其当前状态。

CQRS只是意味着您分离了读取和写入操作。编写操作将在聚合中发生,方法是通过创建事件(通过应用命令)来存储/读取事件溯源。

“查询”将是对投影数据的查询,它使用事件创建对象的当前状态,该状态仅用于查询和阅读。聚合仍然通过重新应用来自事件溯源存储的所有事件进行读取。

优点

  • 您可以查看聚合物上进行的所有更改历史记录。这可以视为对业务和审计的增值。

  • 如果您的预期数据库已损坏或处于无效状态,则可以通过重放所有事件并从头生成投影来恢复它。

  • 很容易回到以前的状态(即通过应用补偿事件,执行与先前事件相反的操作)

  • 很容易修复错误(即在计算聚合状态时),然后重新播放所有事件以获得新的、更正的值。

    假设您有一个BankingAccount聚合,并计算余额,而您使用的是常规舍入而不是“四舍五入”。在这里,您可以修正计算,然后重新应用所有事件,您将获得新的、正确的账户余额。

缺点

  • 有成千上万的事件聚合可能需要一些时间来实现(可以使用快照/备忘录模式,在加载快照后应用事件)
  • 最初需要更多时间来实现必要的基础设施
  • 您无法查询没有读取存储的事件源聚合;需要投影和消息队列来发布事件源事件,以便可以将其处理并应用于可以用于查询的投影(SQL或文档表)

直接映射到领域实体

某些 ORM 和文档数据库提供程序允许您直接映射到支持字段,即通过反射。

MongoDb C# Driver中,可以通过类似链接答案中的方法来完成。

EF Core ORM也适用。我相信在Java世界中也有类似的东西。

这可能会限制您的数据库持久化库和技术使用,因为它需要您使用支持此类 API 的库,通过流畅或代码配置。您不能使用属性/注释来实现此目的,因为这些通常是特定于数据库的,它会泄漏持久性知识到您的领域中。

这也可能会限制您使用强类型查询API(如C#中的Linq,Java中的Streams),因为通常需要getter和setter,因此您可能需要在持久层中使用魔术字符串(存储中字段或属性的名称)。

对于较小/较简单的领域,这可能是可以接受的。但是,如果可能且在预算/时间范围内,应始终首选CQRS+ES,因为它最灵活,并且适用于所有持久性存储和框架(甚至包括键值存储)。

优点

  • 不需要利用更复杂的基础设施(CQRS、ES、Pub/Sub消息/队列)
  • 没有将持久性知识泄漏到您的模型中,也不需要打破封装

缺点

  • 没有更改历史记录
  • 无法恢复之前的状态
  • 在持久层查询时可能需要使用魔术字符串(取决于框架/ORM)
  • 可能需要在持久层中进行大量的流畅/代码配置才能将其映射到后备字段
  • 当您重命名后备字段时,可能会出现故障

不错的回答,尽管我不同意“始终应优先选择CQRS+ES”。这些会给系统带来更多的复杂性,而且并没有真正回答OP所问的简单问题。 - guillaume31

0
我是这样做的:
人作为一个领域实体,具有状态(指定义实体的实体字段,而不是您的“状态”字段)和行为(方法)。
在数据库中存储的只是状态。然后我在领域中创建了一个“PersonStatus”接口(带有我们需要持久化的字段的getter方法),以便PersonRepository处理状态。
Person实体实现PersonStatus(或者您可以放置一个返回状态的静态方法)。
在基础设施中,我还有一个实现PersonStatus的PersonDB类,它是持久性模型。
所以:
领域模型:
// ENTITY
public class Person implements PersonStatus {

// Fields that define status
private String id;
private String name;
...

// Constructors and behaviour
...
...

// Methods implementing PersonStatus
@Override
public String id() {
    return this.id;
}
@Override
public String name() {
    return this.name;
}
...
}


// STATUS OF ENTITY
public interface PersonStatus {
    public String id(); 
    public String name();   
    ...
}


// REPOSITORY
public interface PersonRepository {
    public void add ( PersonStatus personStatus );
    public PersonStatus personOfId ( String anId );
}

基础设施:

public class PersonDB implements PersonStatus {

private String id;
private String name;
...

public PersonDB ( String anId, String aName, ... ) {
    this.id = anId;
    this.name = aName;
    ...
}

@Override
public String id() {
    return this.id;
}

@Override
public String name() {
    return this.name;
}
...
}


// AN INMEMORY REPOSITORY IMPLEMENTATION
public class InmemoryPersonRepository implements PersonRepository {

    private Map<String,PersonDB> inmemorydb;

    public InmemoryPersonRepository() {
        this.inmemoryDb = new HashMap<String,PersonDB>();
    }

    @Override
    public void add ( PersonStatus personStatus );
        PersonDB personDB = new PersonDB ( personStatus.id(), personStatus.name(), ... );
        this.inmemoryDb.put ( personDB.id(), personDB );
    }

    @Override
    public PersonStatus personOfId ( String anId ) {
        return this.inmemoryDb.personOfId ( anId );
}
}

应用层:

...
Person person = new Person ( "1", "John Doe", ... );
personRepository.add ( person );
...
PersonStatus personStatus = personRepository.personOfId ( "1" );
Person person = new Person ( personStatus.id(), personStatus.name(), ... );
...

new Person("1"... 中的“1”不是领域层概念。将ID放入领域层中,会将领域与数据库耦合。 - Flavius
这只是一个快速的例子。我的错,我放了一个uuid值而不是1。 - choquero70

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