DDD符合OOP:如何实现面向对象的存储库?


12

DDD存储库的典型实现看起来不太像面向对象,例如一种save()方法:

package com.example.domain;

public class Product {  /* public attributes for brevity */
    public String name;
    public Double price;
}

public interface ProductRepo {
    void save(Product product);
} 

基础架构部分:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {
    private JdbcTemplate = ...

    public void save(Product product) {
        JdbcTemplate.update("INSERT INTO product (name, price) VALUES (?, ?)", 
            product.name, product.price);
    }
} 

这样的接口Product至少在使用吸气剂的情况下期望a 是贫血模型。

另一方面,OOP表示Product对象应该知道如何保存自己。

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save() {
        // save the product
        // ???
    }
}

关键是,当Product知道如何保存自身时,这意味着基础结构代码不会与域代码分开。

也许我们可以将保存委托给另一个对象:

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage
            .with("name", this.name)
            .with("price", this.price)
            .save();
    }
}

public interface Storage {
    Storage with(String name, Object value);
    void save();
}

基础架构部分:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {        
    public void save(Product product) {
        product.save(new JdbcStorage());
    }
}

class JdbcStorage implements Storage {
    private final JdbcTemplate = ...
    private final Map<String, Object> attrs = new HashMap<>();

    private final String tableName;

    public JdbcStorage(String tableName) {
        this.tableName = tableName;
    }

    public Storage with(String name, Object value) {
        attrs.put(name, value);
    }
    public void save() {
        JdbcTemplate.update("INSERT INTO " + tableName + " (name, price) VALUES (?, ?)", 
            attrs.get("name"), attrs.get("price"));
    }
}

实现此目标的最佳方法是什么?是否可以实现面向对象的存储库?


6
OOP表示Product对象应该知道如何保存自身 -我不确定这是否正确... OOP本身并没有真正规定这一点,它更多是设计/模式问题(DDD /无论您在哪里) -use进来)
jleach,

1
请记住,在OOP的上下文中,它是在谈论对象。只是对象,而不是数据持久性。您的陈述表明,对象状态不应该在自身外部进行管理,这一点我同意。存储库负责从某个持久层(在OOP领域之外)进行加载/保存。是的,类属性和方法应该保持自己的完整性,但这并不意味着另一个对象不能负责持久化状态。并且,获取器和设置器将确保对象的传入/传出数据的完整性。
jleach

1
“这并不意味着另一个对象不能负责保持状态。” -我没那么说。重要的声明是,对象应该是活动的。这意味着对象(没有其他人)可以将此操作委派给另一个对象,但不能反过来:任何对象都不应仅从被动对象中收集信息来处理自己的自私操作(就像回购程序对吸气剂所做的那样) 。我试图在上面的代码片段中实现这种方法。
ttulka

1
@jleach是的,我们对OOP的理解是不同的,对我来说,getters + setters根本不是OOP,否则我的问题毫无意义。不管怎样,谢谢你!:-)
ttulka

1
这是一篇关于我的观点的文章:martinfowler.com/bliki/AnemicDomainModel.html在所有情况下,我都不是贫血模型,例如,它是函数式编程的好策略。只是不是OOP。
ttulka

Answers:


7

你写了

另一方面,OOP表示Product对象应该知道如何保存自身

并在评论中。

...应对所有使用它的操作负责

这是一个普遍的误解。Product是一个域对象,因此它应该负责涉及单个产品对象的操作,不少于,也不多,因此绝对不负责所有操作。通常,持久性不被视为域操作。恰恰相反,在企业应用程序中,尝试在域模型中实现持久性无知(至少在一定程度上)并不罕见,为此,将持久性机制保持在单独的存储库类中是一种流行的解决方案。“ DDD”是针对这种应用的技术。

那么对于a来说,明智的域操作是Product什么?这实际上取决于应用程序系统的域上下文。如果系统是小型系统,并且仅专门支持CRUD操作,那么实际上,Product如您的示例中所示,a 可能保持“贫乏”状态。对于此类应用程序,如果值得将数据库操作放在单独的存储库类中或完全使用DDD,则值得商bat。

但是,一旦您的应用程序支持真实的业务操作,例如购买或出售产品,将其保留在库存中并对其进行管理或为它们计算税金,您就开始发现可以合理地归入一Product类的操作,这是很普遍的。例如,CalcTotalPrice(int noOfItems)当考虑批量折扣时,可能会有一个计算n种商品价格的操作。

简而言之,在设计类时,您需要考虑自己的上下文,您在Joel Spolsky的五个世界中所处的世界,并且如果系统包含足够的域逻辑,那么DDD将是有益的。如果答案是肯定的,那么仅由于将持久性机制置于域类之外就不会最终获得贫乏模型。


你的观点对我来说很明智。因此,当越过贫血数据结构(数据库)的上下文的边界时,产品成为贫血数据结构,并且存储库是网关。但这仍然意味着我必须通过getter和setter提供对对象内部结构的访问,然后将它们变成其API的一部分,并且很容易被其他代码误用,而与持久性无关。有一种好的做法如何避免这种情况?谢谢!
ttulka

“但这仍然意味着我必须通过getter和setter提供对对象内部结构的访问” -不太可能。忽略持久性的域对象的内部状态通常仅由一组与域相关的属性给出。对于这些属性,必须存在getter和setter(或构造函数初始化),否则将无法进行“有趣的”域操作。在一些框架中,还存在一些持久性功能,这些功能允许通过反射来持久化私有属性,因此仅针对该机制而不是针对“其他代码”破坏封装。
布朗

1
我同意持久性通常不是域操作的一部分,但是它应该是需要它的对象内部的“真实”域操作的一部分。例如Account.transfer(amount)应坚持转移。它是如何做到的是对象的责任,而不是某些外部实体的责任。显示对象,另一方面平时域操作!需求通常会详细描述物料的外观。它是项目成员,业务人员或其他人员语言的一部分。
罗伯特·布劳蒂加姆(RobertBräutigam),

@RobertBräutigam:经典Account.transfer的通常涉及两个帐户对象和一个工作单元对象。然后,事务持久化操作可能是后者的一部分(以及对相关存储库的调用),因此它不属于“转移”方法。这样,Account可以保持持久性无知。我并不是说这肯定比您设想的解决方案要好,但是您的解决方案也只是几种可能的方法之一。
布朗

1
@RobertBräutigam很确定您对对象和表之间的关系考虑得太多了。可以将对象想像成在存储器中都具有状态的对象。在完成帐户对象的转帐后,您将获得具有新状态的对象。那就是您想要保留的东西,幸运的是,帐户对象提供了一种让您了解其状态的方法。这并不意味着它们的状态必须等于数据库中的表-即,转移的金额可以是包含原始金额和货币的货币对象。
Steve Chamaillard '19

5

实践胜过理论。

经验告诉我们Product.Save()会导致很多问题。为了解决这些问题,我们发明了存储库模式。

确保它违反了隐藏产品数据的OOP规则。但是效果很好。

制定一套涵盖所有内容的一致规则要比制定一些有例外的一般好规则要困难得多。


3

DDD符合OOP

这有助于记住,这两个想法之间并不存在紧张关系-值对象,集合,存储库是使用的一系列模式,有些人认为这是正确的OOP。

另一方面,OOP表示Product对象应该知道如何保存自己。

不是这样 对象封装自己的数据结构。您在产品中的记忆表示形式负责展示产品行为(无论行为如何);但是永久性存储位于该存储库(位于存储库之后)上方,并且有自己的工作要做。

确实需要某种方法在数据库的内存表示形式与持久存储的内存之间复制数据在边界处,事物趋于变得原始。

从根本上讲,只写数据库并不是特别有用,并且它们在内存中的等效项也没有比“持久”排序有用。Product如果您永远不会将信息带出对象,则将信息放入对象没有任何意义。您不一定会使用“获取器”-您不会尝试共享产品数据结构,当然也不应共享对产品内部表示形式的可变访问。

也许我们可以将保存委托给另一个对象:

那肯定行得通-您的持久性存储有效地成为了回调。我可能会简化界面:

interface ProductStorage {
    onProduct(String name, double price);
}

还有就是在内存中表示和存储机制之间进行耦合,因为这些信息需要得到从这里到那里(然后再返回)。更改要共享的信息将影响对话的两端。因此,我们最好在可能的地方明确说明这一点。

这种通过回调传递数据的方法在TDD模拟的开发中发挥了重要作用。

请注意,将信息传递给回调具有与从查询返回信息相同的所有限制-您不应传递数据结构的可变副本。

这种方法与Evans在《蓝皮书》中所描述的有点相反,后者通过查询返回数据是处理问题的常规方法,并且专门设计了域对象以避免混入“持久性问题”。

我确实将DDD理解为一种OOP技术,因此我想完全理解这种看似矛盾的地方。

要记住的一件事-《蓝皮书》是15年前写的,当时Java 1.4在地球上漫游。特别是,这本书早于Java 泛型 -从Evans提出想法开始,到现在我们拥有更多技术。


2
还值得一提的是:“保存自身”将始终需要与其他对象(文件系统对象,数据库或远程Web服务,其中一些可能还需要建立用于访问控制的会话)进行交互。因此,这样的对象不会是独立的和独立的。因此,OOP不需要这样做,因为它的目的是封装对象并减少耦合。
Christophe

感谢您的出色回答。首先,我Storage以与您相同的方式设计了接口,然后考虑了高耦合并对其进行了更改。但是您是对的,无论如何,不​​可避免地存在耦合,所以为什么不使其更加明确。
ttulka

1
“这种方法与Evans在蓝皮书中所描述的有点相反” -毕竟存在一些紧张:-)这实际上是我的问题,我确实将DDD理解为一种OOP技术,因此我想充分理解这种看似矛盾的地方。
ttulka

1
以我的经验来看,所有这些东西(通常是OOP,DDD,TDD,pick-your-acronym)本身听起来都不错,但是在涉及“现实世界”实现时,总会有一些折衷或要使它起作用,必须要有比理想主义要少的东西。
jleach

我不同意持久性(和表示形式)是“特殊的”的概念。他们不是。它们应该是建模的一部分,以扩展需求需求。除非有相反的实际要求,否则应用程序内部无需有人为的(基于数据的)边界。
罗伯特·布劳蒂加姆(RobertBräutigam),

1

很好的观察,我完全同意你的看法。这是我关于这个主题的一个话题(仅适用于幻灯片):面向对象的域驱动设计

简短的回答:不。您的应用程序中不应存在纯粹是技术性且与域无关的对象。这就像在会计应用程序中实现日志记录框架。

您的Storage接口示例是一个很好的示例,假设Storage即使您编写了该接口,也将其视为某种外部框架。

同样,save()仅当对象是域的一部分(“语言”)时,才应允许该对象。例如,Account在调用时,我不需要显式“保存”一个transfer(amount)。我应该正确地期望业务职能transfer()将继续我的转移。

总而言之,我认为DDD的想法很好。使用无处不在的语言,通过对话,有限的上下文等来练习领域。但是,如果要与面向对象的兼容,构建块确实需要进行彻底的检查。有关详细信息,请参见链接的卡座。


您的演讲在某个地方值得关注吗?(我看到的只是链接下方的幻灯片)。谢谢!
ttulka

我只有讲,这里的德国录音:javadevguy.wordpress.com/2018/11/26/...
罗伯特Bräutigam

好说!(幸运的是我会说德语)。我认为您的整个博客值得一读...谢谢您的工作!
ttulka

非常有见地的滑块罗伯特。我发现它非常具有说明性,但最终我觉得,解决不破坏封装和LoD的许多解决方案都基于对域对象的大量责任:打印,序列化,UI格式等。是否增加了领域和技术之间的耦合(实施细节)?例如,AccountNumber与Apache Wicket API结合使用。或带有Json对象的Account?您认为这值得一试吗?
Laiv

@Laiv您的问题的语法表明,使用技术来实现业务功能是否有问题?让我们这样说:问题不是域和技术之间的耦合,而是不同抽象级别之间的耦合。例如,AccountNumber 应该知道它可以表示为TextField。如果其他人(例如“视图”)知道这一点,是不应该存在的耦合,因为该组件将需要知道AccountNumber组成是什么,即内部。
罗伯特·

1

也许我们可以将保存委托给另一个对象

避免不必要地传播领域知识。对单个字段了解的越多,添加或删除字段就越困难:

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage.save( toString() );
    }
}

在这里,产品不知道您要保存到日志文件还是数据库或同时保存到两者。在这里,保存方法不知道您有4个字段还是40个字段。那是松散耦合的。这是好事。

当然,这仅是如何实现此目标的一个示例。如果您不喜欢构建和解析用作DTO的字符串,则还可以使用集合。LinkedHashMap是我的最爱,因为它可以保留顺序,并且toString()在日志文件中看起来不错。

无论您做什么,都请不要传播周围领域的知识。这是人们经常忽略的一种耦合形式,直到很晚为止。我想要很少的事情来静态地知道我的对象有多少个字段。这样,添加字段不会在很多地方涉及很多编辑。


这实际上是我在问题中发布的代码,对吗?我用过一个Map,您建议一个String或一个List。但是,正如@VoiceOfUnreason在他的回答中提到的那样,耦合仍然存在,只是不明确。至少当作为对象读回时,仍然不需要知道产品的数据结构以将其保存在数据库或日志文件中。
ttulka

我更改了保存方法,但是是的,它几乎相同。不同之处在于,耦合不再是静态的,从而允许添加新字段而无需强制对存储系统进行代码更改。这使得存储系统可在许多不同的产品上重复使用。它只会迫使您做一些不自然的事情,例如将双曲转换成字符串然后再转换为双曲。但这确实可以解决,也可以解决。
candied_orange

参见乔什·布洛赫(Josh Bloch)的异类收藏
-candied_orange

但是正如我所说,我看到耦合仍然存在(通过解析),只是因为不是静态的(显式的)带来了无法由编译器检查的缺点,因此更容易出错。它Storage是域的一部分(以及存储库接口),并且是这样的持久性API。更改后,最好在编译时通知客户端,因为无论如何它们都必须做出反应,以免在运行时中断。
ttulka

这是一个误解。编译器无法检查日志文件或数据库。它所检查的只是一个代码文件是否与另一个代码文件一致,而另一个文件也不能保证与日志文件或数据库一致。
candied_orange

0

已经提到的模式还有另一种选择。Memento模式非常适合封装域对象的内部状态。内存对象表示域对象公共状态的快照。域对象知道如何从其内部状态创建此公共状态,反之亦然。然后,存储库仅适用于州的公共代表。这样,内部实现就与任何持久性细节脱钩了,它只需要维护公共合同即可。同样,您的域对象也不必公开确实会使其变得贫乏的任何吸气剂。

有关该主题的更多信息,我推荐一本很棒的书:Scott Millett和Nick Tune撰写的“域驱动设计的模式,原理和实践”

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.