专栏名称: 逸言
文学与软件,诗意地想念。
目录
相关文章推荐
逸言  ·  从设计本质分析三种设计过程 ·  昨天  
程序员小灰  ·  字节这薪资,真舍得给! ·  昨天  
码农翻身  ·  两个阴险的软件,潜入了每一台Windows电脑! ·  3 天前  
51CTO官微  ·  公开版 OpenAI Sora ... ·  6 天前  
OSC开源社区  ·  Python即将成为TIOBE ... ·  1 周前  
51好读  ›  专栏  ›  逸言

从设计本质分析三种设计过程

逸言  · 公众号  · 程序员  · 2024-12-18 08:30

正文

从本质分析过程式设计、面向对象设计和领域驱动设计,会有助于我们理解这三者之间的差异,并明确它们各自的优势,以便于更好地运用它们。
从最底层的本质来讲,为所有业务型软件编写的业务代码都是对数据的增删改查(CRUD)。只要属于业务逻辑的范围,没有什么业务操作可以超出这个范围。这或许是RESTful风格之所以将资源的操作归纳为GET、POST、PUT(含PATCH)和DELETE动词的主要原因。
过程式设计极为匹配CRUD操作,因为它的设计思想就是数据结构+算法,只要明确了CRUD的操作对象,就可以将该操作对象的数据封装到数据结构中,对它进行CRUD操作即可。
这一设计完全契合面向关系数据库的操作过程,关系表就是数据结构,CRUD就是SQL语句中的create、select、update和delete。实际上,SQL语句才是编写业务软件的世界语言,既然所有业务代码都是对数据的增删改查,就完全可以用SQL来完成这一切。过去的许多C/S软件几乎都沿用了这一做法,做过PowerBuilder、Visual Foxpro和Delphi项目的老手们都深有体会,实际上,类似这些语言也不过是一层外壳罢了,在底层,只需要用SQL编写存储过程就能完成业务代码的一切。然而,使用SQL(包括使用存储过程)编写业务逻辑的弊病有很多,对此,业界已经普遍认识到这一点,除了数据分析和某些纯数据操作的场景,都不再推荐这一方案了。
由于Java语言与C#语言的工程化特征,以及它们的配套生态发展和学习门槛的原因,大多数业务系统软件都是用这两门语言中的一种编写。它们都属于面向对象语言,但许多Java或C#开发人员并不能很好地驾驭面向对象的特征,实际上是用面向对象语言在进行过程式设计,于是,代码就会形成典型的贫血对象+事务脚本。典型的代码特征体现为在一个Service的静态方法中,通过DAO获得要操作的持久化对象,然后根据业务规则编写对应的业务代码,形成如下图所示的协作形式:
这些代码的主要特征就是调用类的get和set方法,对内存中的数据进行读取和修改,再将修改好的对象传递给DAO进行数据库的操作,如下代码所是:
public class ShipmentServices { // 共361行代码 public static Map<String, Object> calcShipmentCostEstimate(DispatchContext dctx, Map<String, ? extends Object> context) { // 准备数据 String productStoreShipMethId = (String) context.get("productStoreShipMethId"); ... 以下共26行代码
// 获得ShipmentCostEstimate(s) Map<String, String> estFields = UtilMisc.toMap("productStoreId", productStoreId, "shipmentMethodTypeId", shipmentMethodTypeId, "carrierPartyId", carrierPartyId, "carrierRoleTypeId", carrierRoleTypeId); EntityCondition estFieldsCond = EntityCondition.makeCondition(estFields, EntityOperator.AND); ... 以下共30行代码 // 获得PostalAddress // 获得可能的估算值 // 获得ShippableItem的size列表和feature字典 // 根据相关数据计算优先级
// 计算 BigDecimal itemFlatAmount = shippableQuantity.multiply(orderItemFlat); BigDecimal orderPercentage = shippableTotal.multiply(orderPercent.movePointLeft(2)); }

对于Java或C#语言来说,这样的做法简直是对面向对象的一种“羞辱”,就是挂着面向对象的羊头卖着过程设计的狗肉。可是从业务数据操作的本质来看,这却是一种自然而然得出的结果。

面向对象设计和过程式设计的主要区别在于一个原则:“数据与行为应该封装在一起”,简便起见,就是“信息专家模式”。遵循专家模式,优先把数据和行为放在一起,自然就不需要额外调用get和set方法了,因为方法操作的数据都是自己的。我认为这样的设计赋予了对象以自我决策的“意识”,它真正拥有了操作自己数据的权利。
由于面向对象设计没有明确规定一个对象到底该多大,而数据表的设计则有原则可依,即遵循数据表设计的二范式和三范式。这就使得大多数业务软件的设计者都会选择从数据建模开始,只有在确定了每个数据表之后,再将表映射为对象。
这样的映射顺序却不符合面向对象分析与设计的要求,有些数据表的设计甚至破坏了对象设计的原则与模式。例如设计出如下图所示的product数据表,就需要定义一个相对庞大的Product类与之对应:
归根结底,关系表的设计是没有层次的,而对象的设计在理论上允许对象的无限嵌套。从单一职责原则分析,也更建议设计粒度更小的类。只是要设计出合理的粒度,根本无法把握。这就使得许多开发人员退而求其次,选择把数据建模与过程式设计结合起来,只不过选择了面向对象语言作为一种载体而已。事实上,数据建模的方式确实与过程式设计更加契合。
面向对象设计为什么一贯坚持“信息专家模式”呢?原因在于只有遵循该原则,才能有效地控制对数据和行为的封装,合理地将许多不必要暴露的操作细节隐藏起来,就能减少不必要的对象之间的依赖,使得重用和扩展都变得更加容易,也就能更好地应对变化。
为了“抵抗”数据建模与过程式设计的联盟,面向对象设计也在寻找联盟,又或者说,反过来是Eric Evans提出的领域驱动设计主动找到了面向对象设计作为盟友,它为面向对象设计带来了一个设计的重要补充,那就是聚合,通过它规定了CRUD操作的边界,这也是面向对象设计和领域驱动设计在战术设计层面的主要区别。
理论上,面向对象设计不考虑数据库操作,无论粒度大小,每个对象都是CRUD的操作边界,但在现实中,这是不可能做到的。你会发现,许多文章和书籍在讨论和讲解面向对象设计时,几乎很少谈到对象持久化的问题。仿佛对象是天生而来,又或者说面向对象是唱着阳春白雪的清流,不屑于去考虑与数据库如何交互的“俗事儿”。
事与愿违,由于内存的限制,使得对象必须在合适时候进行持久化。如果是遵循面向对象设计原则进行的领域建模,就可能出现对象与关系表之间的不一致,于是,产生了ORM(Object-Relationship Mapping)的需求。在Eric Evans的《领域驱动设计》一书中,对此问题几乎避而不谈。一方面,在写作该书时,还没有类似Hibernate这样成熟的ORM框架;另一方面,Eric Evans将解决方案寄托到对象数据库上,只可惜,事到如今,仍然没有合适的对象数据库能取代关系型数据库在OLTP场景下的地位。
无论如何,领域驱动设计通过聚合解决了面向对象设计对对象边界的不确定性,因此,要遵循面向对象设计原则开展业务软件的编写,以便于更好地应对需求变化,最佳选择就是领域驱动设计。
但是,这并不能说明面向对象设计和领域驱动设计的结合就一定是最优解。“信息专家模式”带来的优势是否能真正体现,需要一分为四地多角度剖析。
如前所述,既然本质上所有业务行为都是对业务对象的CRUD,也就需要从这四种不同的操作分开讨论。在讨论之前,先做一个规定,我们把CRUD这四种操作行为的操作目标统一称之为“业务单元”。那么,过程式设计的业务单元就是数据表对应的类,而领域驱动设计的业务单元就是聚合。为行文方便,我将过程式设计和领域驱动设计分别缩写为PD和DDD。
先讨论R(Read)操作,因为它不会改变数据。R操作的业务逻辑其实就是各种查询条件的不同组装。无论采用PD还是DDD,读操作往往都会突破“业务单元”的边界,即在数据库层面的多表查询。然而,DDD的聚合却坚持要“守”住边界,使得DDD与R操作天然是不契合的。这也解释了为什么在推进DDD时,往往建议把查询与命令操作分开,即运用CQRS(Command Query Responsibility Segregation,命令查询职责分离)模式,该模式中的读模型就是PD推荐的做法。
C(Create)和D(Delete)操作彼此为互逆的操作,可以把它们结合起来看。理论上,若能从领域概念完整性与业务操作一致性的角度设计聚合,聚合的粒度就是合理的,对C和D操作就比较友好
对C操作而言,就是需要创建聚合对象,而作为“信息专家”的聚合对象自身,除了需要对输入数据做合法性与合规性校验,没有其他业务逻辑。这也解释了DDD为何特别引入工厂模式,如果创建一个聚合的逻辑比较复杂,可以交给工厂对象或工厂方法来完成。
D操作的数据一般不会突破业务单元的范围,除非是数据清洗之类的特殊操作。D操作存在两种情况:根据id删除和根据条件删除。虽说根据id删除也是一种条件,但最好将它们分开,因为根据条件删除可能引起批量删除。
对聚合执行D操作时,不需要事先将聚合实例加载到内存,这意味着在执行D操作之前,聚合对象是不存在的,它自然也不需要执行业务逻辑,无法体现“信息专家”的优势。在对聚合执行批量删除时,就更没有优势了。
然而,DDD的优势在于通过聚合边界确保了聚合内各个元素之间的一致性和完整性。只要C和D的操作不超过这个边界,就能让调用者的代码变得更简单。基于数据建模的PD则不然,因为操作可能不只限于一张数据表,就需要考虑表之间的关联关系,也需要考虑级联删除。
聪明如你一定会发现,DDD的这点优势其实是一种高明的“伪装”,伪装的关键就是资源库(Repository)。实际上,PD对C和D操作的实现在DDD中同样需要考虑,只是这些实现逻辑都被隐藏到资源库了。让资源库的实现去完成这些“脏活累活”,也就维持了领域层的表面光鲜。这是DDD解决业务复杂性的一种手段,即坚定不移地确保业务逻辑与技术实现的正交性,不让技术对业务带来干扰,避免因为叠加效应增加复杂度。
当然,也有人会说,PD的方式同样可以通过对DAO的抽象做到业务逻辑与技术实现的正交。没错,但由于根据表映射到的类没有按照概念完整性和一致性来设计(从数据库角度设计,而非业务角度),就不可避免会出现一点点逻辑的泄露。
以订单Order为例,采用DDD的聚合设计,Order包含了OrderHeader和OrderItem,而Order作为聚合根对象,会形成一层隐藏的边界,将内部的元素包裹起来,然后统一由OrderRepository来管理整个聚合:
通过资源库管理聚合,只需要一行调用代码,如下所示对订单的创建和删除:
// 创建订单,order通过工厂创建orderRepo.add(order);
// 删除订单orderRepo.removeBy(orderId);
PD的建模方式是数据库驱动的,没有聚合的概念,就需要为每个表对应的类定义一个DAO:
PD设计了两张表OrderHeader和OrderItem,并定义为对应的类。注意,此时并没有定义Order类,也没有对应的Order表,因为此时的OrderHeader类合并了DDD设计中Order类与OrderHeader类的所有属性。对应的也是两个DAO对象:
// 创建订单String orderId = orderHeaderDao.insert(orderHeader);orderItemDao.insert(orderId, orderItemList);
// 删除订单orderHeaderDao.delete(orderId);orderItemDao.deleteAllItems(orderId);
以上C和D操作牵涉到两张表,为确保数据的一致性,还需要显式提供事务控制。如前所述,并非DDD不需要考虑对两张表的操作,而是这些操作被隐藏到聚合和资源库的背后了。
整体说来,由于C、R和D操作无法体现“信息专家模式”的优势,就使得DDD引入聚合的价值大打折扣。相较于PD而言,DDD并无明显优势。不过,重要的业务逻辑大部分出现在U操作的场景,DDD存在优势吗?
还是以订单为例,数据建模的PD方式得到的设计是OrderHeader与OderItem表,但如果采用面向对象设计方式进行领域建模,并引入DDD的聚合概念,得到的领域模型大概如下所示:
图中,深绿色的Order是聚合根实体,绿色的OrderItem是非根实体,其余浅绿色的类都是值对象。通过OrderRepository把一个订单聚合加载到内存中时,意味着Order聚合根是图中所示11个对象的主宰,从而形成一个小型的对象社区。聚合根在操作这些对象的数据时,可以无需考虑内存之外的任何非业务因素,可以自由地修改它们的值。当然,在这个小型的对象社区中,每个对象同样需要遵循“信息专家模式”,尽量只操作属于自己的数据,如此就能形成合理的职责分配。修改完成之后,再交给OrderRepository的save()方法,就可以完成对修改数据的持久化。
当然,为U操作定义Order的方法时,需要遵循统一语言,并按照不同的业务场景识别不同的领域行为,再分配给Order聚合根。例如,修改配送地址和取消订单都是修改Order的属性,却属于不同的业务场景,就需要定义不同的方法:
public class Order { private OrderId orderId; private Address shippingAddress; private OrderStatus orderStatus; private LocalDateTime orderDate;
...
public void relocateTo(Address newShippingAddress) { if (newShippingAddress == null) { throw new OrderException("Null Shipping Address"); } newShippingAddress.validate(); if (!newShippingAddress.sameAs(shippingAddress)) { this.shippingAddress = newShippingAddress; } }
public void cancel() { long daysBetween = ChronoUnit.DAYS.between(orderDate, LocalDateTime.now); if (daysBetween >= 10) { // 假定下单日期不能超过10天,且不精确到小时 throw new OrderException("The order date must not exceed 10 days"); } if (!orderStatus.canBeCancelled()) { throw new OrderException("Current order can be cancelled due to wrong status"); }
this.orderStatus = OrderStatus.Cancel; }}
一旦为Order聚合根定义了如上方法,就可以在领域服务中通过协作资源库完成最终操作:
public class OrderService { public void relocateTo(OrderId orderId, Address newShippingAddress) { Order order = orderRepo.orderOf(orderId).get(); order.relocateTo(newShippingAddress); orderRepo.save(order); }
public void cancel(OrderId orderId) { Order order = orderRepo.orderOf(orderId).get(); order.cancel(customerId); orderRepo.save(order); }}
PD的做法则不然,它更多是把OrderHeader与OrderItem当做两个数据来对待。以之前得到的数据模型为例,该模型甚至没有订单的整体概念。既然PD不遵循“信息专家模式”,业务行为就只能分配给服务(注意,此时的配送地址也没有封装为Address类):
public class OrderService { public void relocateTo(String orderId, String streetAddress1, String streetAddress2, String city, String stateOrProvince, String postalCode, String country) { if (order.getStreetAddress1().equals(streetAddress1) && order.getStreetAddress2().equals(streetAddress2) && order.getCity().equals(city) && order.getStateOrProvince().equals(stateOrProvince) && order.getPostalCode().equals(postalCode) && order.getCountry().equals(country) ) { return; } OrderHeader order = orderHeaderDao.read(orderId); if (order != null) { order.setStreetAddress1(streetAddress1); order.setStreetAddress2(streetAddress2); order.setCity(city); order.setStateOrProvince(stateOrProvince); order.setPostalCode(postalCode); order.setCountry(country); orderHeaderDao.update(order); }
public void cancel(String orderId) { OrderHeader order = orderHeaderDao.read(orderId); if (order != null) { long daysBetween = ChronoUnit.DAYS.between(order.getOrderDate, LocalDateTime.now); if (daysBetween >= 10) { // 假定下单日期不能超过10天,且不精确到小时 throw new OrderException("The order date must not exceed 10 days"); } if (!order.getOrderStatus.canBeCancelled()) { throw new OrderException("Current order can be cancelled due to wrong status"); }
order.setOrderStatus(OrderStatus.Cancel); orderHeaderDao.update(order); } }}
以上代码大量调用了OrderHeader的get和set访问器,大大影响了代码的可读性,同时,本该属于OrderHeader类的职责被大量泄露到OrderService中。当然,这样的问题可以通过良好地面向对象设计来解决,即让OrderHeader同样具有“信息专家”的能力。遗憾的是,通过数据建模进行PD实现的开发人员,往往不具备这样的封装意识。
如果牵涉到Order与OrderItem两个对象之间的协作,聚合的优势就更明显了,因为在聚合创造的“小型对象社区”里,不需要考虑与外部环境交互的技术细节。例如,移除订单项的业务行为:
public class Order { private List orderItems; public void removeItem(String orderItemId) { if (orderItems.size() == 0) { return; } if (orderItems.size() == 1 && orderItems.get(0).id() == orderItemId) { throw new OrderException("Can not remove last item."); }
while (orderItems.iterator().hasNext()) { OrderItem nextItem = orderItems.iterator().next(); if (nextItem.id() == orderItemId) { orderItems.remove(nextItem); } } }}
removeItem()方法的实现就是对内存中List的操作。对订单项数量的限制,则可以认为是确保订单有效性的业务规则,即这里规定不能在移除订单项时,将订单中的订单项清空。一旦清空,订单就无效了。如果采用数据建模的PD方式,这个用于验证的业务规则就需要同时操作两张表,既要确保指定orderId的订单项不能为空,即通过R操作读取OrderHeader表,又要在确定操作合法后,通过D操作删除OrderItem的指定记录。这些本该封装到Order类的逻辑自然不可避免地泄露到了OrderService中。
在引入订单聚合后,订单项是构成订单整体的一个元素,对OrderItem数据表执行的D操作变成了对订单整体的U操作:
public class OrderService { public void removeOrderItem(OrderId orderId, String orderItemId) { Order order = orderRepo.orderOf(orderId).get(); order.removeItem(orderItemId); orderRepo.save(order); }}
当然,在实现OrderRepository的save()方法时,需要支持对Order实例值差异的判断,并根据差异值决定执行什么样的操作,以及确定要操作的数据表。这对ORM框架提出了更高的要求。这一切都是为了降低业务的复杂度,并让业务逻辑放到最合适的地方。
因此,对于U操作而言,如果通过建模得到的类遵循了“信息专家模式”,就能够尽可能让大量的业务逻辑集中到领域模型对象中,且它的方法遵循“统一语言”,极大地增加了代码的可读性,降低了领域专家与开发团队的沟通难度。这样的建模方才可以称之为是“领域建模”,因为它纯粹是从领域的角度建立的由抽象领域概念与行为构成的领域模型,其理念与设计驱动力迥然不同于数据建模。在开展领域建模过程中,再考虑引入聚合,就能更加清晰地界定业务操作的边界,并让业务的内部操作变得更加“丝滑”,而把与数据表操作相关的持久化实现推给了资源库的实现类。