广而告知:我在GitChat的领域驱动战略设计实践达人课已经发布,目前正在进入预售期。点击文末“阅读原文”即可查看与订阅,也可以通过微信扫描下方的专属海报进行订阅。由于本文内容太长,你也可以移步至我的博客http://zhangyi.xyz获得更加阅读体验。记住我的博客,张逸的拼音全名,域名为xyz。
在领域驱动设计过程中,正确地进行领域建模是至为关键的环节。如果我们没有能够从业务需求中发现正确的领域概念,就可能导致职责的分配不合理,业务流程不清晰,出现没有任何领域行为的贫血对象,甚至做出错误的设计决策。
最初的实现
在一个结算系统中,业务需求要求导入一个结算账单模板的Excel文档,然后通过账单号查询该模板需要填充的变量值,生成并导出最终需要的结算账单。结算账单有多种,例如内部结算账单等。不同账单的模板并不相同,需要填充的变量值也不相同。
团队确实进行了领域建模,发现了如下的几个领域概念以及对应的服务和资源库对象:
- InternalSettlementBill
- InternalSettlementBillRepository
- TemplateReplacement
- BaseBillReviewExportTemplate
- InternalSettlementBillService
- BillReviewService
为了方便大家对这个设计有直观认识,我先贴出这些关键类型的实现代码:
package settlement.domain; import lombok.Data; @Data public class InternalSettlementBill { private String billNumber; private String newAndOldBillNumber; private String flightIdentity; private String flightNumber; private String flightRoute; private String scheduledDate; private String passengerClass; private List<Passenger> passengers; private String serviceReason; private List<CostDetail> costDetails; private BigDecimal totalCost; } public interface InternalSettlementBillRepository { InternalSettlementBill queryByBillNumber(String billNumber); } package settlement.infrastructure.file; import lombok.data; import lombok.AllArgsConstructor; @Data @AllArgsConstructor public class TemplateReplacement { private int rowIndex; private int cellNum; private String replaceValue; } pakcage settlement.domain; import settlement.infrastructure.file.TemplateReplacement; abstract class BaseBillReviewExportTemplate<T> { public final List<TemplateReplacement> queryAndComposeTemplateReplacementsBy(String billNumber) { T t = queryFilledDataBy(billNumber); return composeTemplateReplacements(t); } protected abstract T queryFilledDataBy(String billNumber); protected abstract List<TemplateReplacement> composeTemplateReplacements(T t); } pakcage settlement.domain; import settlement.infrastructure.file.TemplateReplacement; import org.springframework.stereotype.Service; import javax.annotation.Resource; @Service public class InternalSettlementBillService extends BaseBillReviewExportTemplate<InternalSettlementBill> { @Resource private InternalSettlementBillRepository internalSettlementBillRepository; @Override protected InternalSettlementBill queryFilledDataBy(String billNumber) { return internalSettlementBillRepository.queryByBillNumber(billNumber); } @Override protected List<TemplateReplacement> composeTemplateReplacements(InternalSettlementBill t) { List<TemplateReplacement> templateReplacements = new ArrayList<>(); templateReplacements.add(new TemplateReplacement(0, 0, t.getNewAndOldBillNumber())); templateReplacements.add(new TemplateReplacement(1, 0, t.getFlightIdentity())); templateReplacements.add(new TemplateReplacement(1, 2, t.getFlightRoute())); return templateReplacements; } } package settlement.domain; import settlement.infrastructure.file.FileDownloader; import settlement.infrastructure.file.PoiUtils; import settlement.infrastructure.file.TemplateReplacement; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; @Service public class BillReviewService { private static final String DEFAULT_REPLACE_PATTERN = "@replace"; private static final int DEFAULT_SHEET_INDEX = 0; @Value("${file-path.bill-templates-dir}") private String billTemplatesDirPath; @Resource private PoiUtils poiUtils; @Resource private FileDownloader fileDownloader; @Resource private InternalSettlementBillService internalSettlementBillService; @Resource private ExportBillReviewConfiguration configuration; public void exportBillReviewByTemplate(HttpServletResponse response, String billNumber, String templateName) { try { String className = fetchClassNameFromConfigBy(templateName); List<TemplateReplacement> replacements = templateReplacementsBy(billNumber, className); HSSFWorkbook workbook = poiUtils.getHssfWorkbook(billTemplatesDirPath + templateName); poiUtils.fillCells(workbook, DEFAULT_SHEET_INDEX, DEFAULT_REPLACE_PATTERN, replacements); fileDownloader.downloadHSSFFile(response, workbook, templateName); } catch (Exception e) { logger.error("Export bill review by template failed, templateName: {}", templateName); e.printStackTrace(); } } private List<TemplateReplacement> templateReplacementsBy(String billNumber, String className) { switch (className) { case "InternalSettlementBill": return internalSettlementBillService.queryAndComposeTemplateReplacementsBy(billNumber); default: return null; } } private String fetchClassNameFromConfigBy(String templateName) throws Exception { for (ExportBillReviewConfiguration.Item item : configuration.getItems()) { if (item.getTemplateName().equals(templateName)) { return item.getClassName(); } } throw new Exception("can not found className by templateName in configuration file"); } } package settlement.web.controllers; import settlement.domain.*; import settlement.web.model.ExportBillReviewRequest; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; @RestController @RequestMapping("/bill-review") public class BillReviewController { @Resource private BillReviewService billReviewService; @PostMapping("/export-template") public void exportBillReviewByTemplate(HttpServletResponse response, @RequestBody ExportBillReviewRequest request) { billReviewService.exportBillReviewByTemplate(response, request.getBillNumber(), request.getTemplateName()); } }
在这些类中,领域层的InternalSettlementBill
表达的是“内部结算账单”领域概念,显然,如代码所示,这个对象是一个典型的贫血对象。BaseBillReviewExportTemplate
类是一个抽象类,InternalSettlementBillService
是继承它的子类。团队开发人员运用了模板方法模式,BaseBillReviewExportTemplate
是获取各种结算账单的TemplateReplacement
的一个抽象,因为开发人员发现这个过程是通用的:
- 通过billNumber查询结算账单
- 根据结算账单的值组装导出账单需要的模板替换对象
提炼领域知识
BaseBillReviewExportTemplate
是一个领域服务,但它其实有一个很糟糕的命名,让人无法看懂它到底承担了什么职责?从命名看,它蕴含了多个概念:bill、review、export、template。究竟要做什么?是账单评阅的导出模板?还是导出账单评阅的模板?它代表了模板的名词概念,还是代表导出的领域行为?真是让人丈二和尚摸不着头脑。其实,阅读其代码实现,发现这个类要做的不过就是获得结算账单的所谓“模板替换(TemplateReplacement)”对象罢了?
TemplateReplacement
表达的是什么概念呢?通过和团队成员沟通需求,结合代码我梳理出要实现的业务逻辑:
- 用户首先导入一个结算账单模板的Excel工作薄;
- Excel工作薄模板中对应的单元格中定义了一些变量值;系统需要从数据库中读取结算账单的信息,然后基于结算账单信息中的值去替换定义在模板中的这些变量;
- 导出替换了变量值的Excel工作薄。
显然,替换模板中的变量值是我们期望完成的行为,其本质其实应该是一个模板变量:TemplateVariable
。这个对象属于领域层的领域概念,不应该被定义在基础设施层。
如此,BaseBillReviewExportTemplate
这个服务的命名就真可以说是名实不副了,不如更名为BaseBillTemplateVariablesComposer
。但仔细看它的实现,我发现它不过就是通过一个Repository获得结算账单,再用结算账单中的对应值去组装模板变量。这个组装模板变量的行为放在这个服务中合适吗?遵循“信息专家模式”,InternalSettlementBill
自身就具备了组装模板变量的信息,它才是承担组装职责的最佳专家啊!于是,我们可以转移职责:
package settlement.domain; @Data public class InternalSettlementBill { private String billNumber; private String newAndOldBillNumber; private String flightIdentity; private String flightNumber; private String flightRoute; private String scheduledDate; private String passengerClass; private List<Passenger> passengers; private String serviceReason; private List<CostDetail> costDetails; private BigDecimal totalCost; public List<TemplateVariable> composeVariables() { return Lists.newArrayList( new TemplateVariable(0, 0, this.newAndOldBillNumber()), new TemplateVariable(1, 0, this.flightIdentity()), new TemplateVariable(1, 2, this.flightRoute()) ); } }
由于不同的结算模板都提供了不同的模板变量,我们就可以为其定义一个抽象的结算模板类型:
package settlement.domain; public interface SettlementBill { List<TemplateVariable> composeVariables(); } package settlement.domain; public class InternalSettlementBill implements SettlementBill {}
在转移了组装模板变量的职责后,我已经看不出BaseBillTemplateVariablesComposer
这个服务还有什么存在必要了!是的,它还承担了调用Repository去获得结算账单的职责,但在转移了组装模板变量的职责后,这个服务已经被弱化为只剩下查询职责了。这个查询结算账单的职责不是Repository提供的么?再对这个查询功能做一次封装有何意义?所以,在InternalSettlementBill
摆脱“贫血对象”的身份后,看起来很酷的模板方法模式就变得没有任何价值了!
保持清晰的领域服务
再来看服务BillReviewService
服务。从实现内容看,它才是真正负责导出结算账单的服务。这个服务的类名既含糊,实现代码又混乱,看起来它根本就不是一个纯粹的业务服务,因为它将业务逻辑与技术实现搅在了一起:既有Excel工作薄的获取,又有通过poi框架实现对单元格数据的填充,还有文件的下载,同时还通过结算账单获得了需要填充的模板变量值。
之所以会出现如此混乱的局面,除了没有有效地将技术实现与业务逻辑通过抽象去隔离之外,最关键的还是没有正确地建立领域模型。实际上,这里的结算账单模板不正是我们要操作的领域对象吗?实际上,我们要完成的业务功能是填充以及导出结算账单模板,而不是填充工作薄的单元格,自然也不是下载工作薄文件。所谓的“工作薄”概念,其实是实现层面的细节。
为保障设计的纯粹性,我们理当将结算账单模板定义为一个POJO类型的领域实体对象。即使需要将其导出为Excel工作薄,我们也可以令其持有数据,然后再将数据写入到工作薄。但是,由于结算账单模板的部分内容是通过模板文件直接导入的,除了需要替换的模板变量值之外,其余内容无需重新写入。如果硬要将其定义为纯粹的领域对象,就需要记录账单所有值在工作薄中的坐标,以便于在生成模板文件时正确地填充值;然而,这个模板的部分值在工作薄文件中已经存在了,再做一次无谓的填充就显得多余了。故而,我们需要做一个设计妥协,直接将poi框架的HSSFWorkbook
作为结算账单模板对象内部持有的属性。让领域层依赖poi框架使得我们的领域模型不再纯粹,但为了技术实现的便利性,偶尔退让一步,也未为不可,只要我们能守住底线:保持系统架构的清晰层次。
一旦将工作薄对象赋予结算账单模板对象,则模板自身就不再有多种结算账单类别,因为它们的区别在于workbook。因此,我们没有必要为各种结算账单定义对应的模板对象,只需一个SettlementBillTemplate
即可:
package settlement.domain; import org.apache.poi.hsf.usermodel.*; public class SettlementBillTemplate { private HSSFWorkbook workbook; private int sheetIndex; private String replacePattern; public SettlementBillTemplate(HSSFWorkbook workbook) { this(workbook, 0, "@replace"); } public SettlementBillTemplate(HSSFWorkbook workbook, int sheetIndex, String replacePattern) { this.workbook = workbook; this.sheetIndex = sheetIndex; this.replacePattern = replacePattern; } }
既然SettlementBillTemplate
已经拥有了工作薄对象,为何不将填充模板变量值的功能赋予它呢?
public class SettlementBillTemplate { public void fillWith(SettlementBill bill) { HSSFSheet sheet = hssfWorkbook.getSheetAt(sheetIndex); bill.composeVariables().foreach( v -> { HSSFCell cell = sheet.getRow(v.getRowIndex()).getCell(v.getCellNum()); String cellValue = cell.getStringCellValue(); String replaceValue = v.getReplaceValue(); if (replaceValue == null) { logger.warn("{} -> {} 替换值为空,未从数据库中查出相应字段值", cellValue, replaceValue); continue; } logger.info("{} -> {}", cellValue, replaceValue); if (cellValue.toLowerCase().contains(replacePattern)) { cell.setCellValue(cellValue.replace(replacePattern, replaceValue)); } else { cell.setCellValue(replaceValue); } }); } }
现在,组装模板以及模板变量的工作已经完成,剩下的就是导出模板了。那么,谁该拥有导出模板的能力呢?虽然要导出的数据是SettlementBillTemplate
拥有的,但它并不具备读取与下载工作薄文件的能力,既然如此,就只能将其放到领域服务。你看,我在分配表达领域逻辑的职责时,是将领域服务排在最后的顺序。
在此之前,我们还需要分离业务逻辑与技术实现。什么是业务逻辑?组装模板变量,组装模板以及导出模板都是业务逻辑,而读/写工作薄文件则是技术实现。既然如此,工作薄文件的读写职责就应该分配给基础设施层,然后在interfaces模块中定义它们的抽象接口。注:改进后的代码采用的代码结构皆以我的推荐为准。例如下面的接口定义是放在interfaces/file包中,实现放在gateways/file包中:
package settlement.interfaces.file; import org.apache.poi.hssf.usermodel.HSSFWorkbook; public interface WorkbookReader { HSSFWorkbook readFrom(String templateName); } public interface WorkbookWriter { void writeTo(HSSFWorkbook workbook, String targetPath); } package settlement.gateways.file; import settlement.interfaces.file.WorkbookReader; import org.apache.poi.hssf.usermodel.HSSFWorkbook; public class ExcelWorkbookReader implements WorkbookReader {} package settlement.gateways.file; import settlement.interfaces.file.WorkbookWriter; import org.apache.poi.hssf.usermodel.HSSFWorkbook; public class ExcelWorkbookWriter implements WorkbookWriter {}
解决资源库多态的问题
还有一个问题没有解决,就是不同的结算账单是通过不同的Repository获得的。虽然模板已经没有类型的区别了,但用组装模板的模板变量值确实是不相同的。我们需要根据传入的templateName
决定获得什么样的结算账单对象。但是,我们之前已经为InternalSettlementBill
定义了对应的Repository,且它被定义为一个接口。是否可以将这个接口作为服务的属性,交给依赖注入去注入实现呢?例如:
public class SettlementBillTemplateExporter { @Resource private InternalSettlementBillRepository repository; }
这是不对的。因为采用这样的定义,就意味着SettlementBillTemplateExporter
服务只能查询InternalSettlementBill
。要解决这个问题,似乎可以为资源库查询所有结算账单的行为定义一个统一接口,如SettlementBillFinder
接口。然而,这一改进还是不能解决问题,因为决定实例化哪个Repository,是由调用者传递的templateName
决定的。
在进行领域驱动设计时,为了隔离业务逻辑与技术实现,一般建议对技术实现尽可能做抽象,例如定义抽象的Repository接口,然后再利用依赖注入(Dependency Injection)完成对具体实现的注入。当我们使用框架来完成依赖注入时,就要求领域层的领域对象包括Repository、Service等对象都将由IoC框架来管理生命周期。这些IoC框架在带来依赖管理的便利时,也给我们的设计施加了一些约束。
一种解决办法是为资源库引入静态工厂:
package settlement.repositories; public interface SettlementBillFinder { SettlementBill settlementBillBy(String billNumber); } package settlement.repositories; public interface InternalSettlementBillRepository extends SettlementBillFinder { // other methods; } package settlement.domain; import settlement.repositories.SettlementBillFinder; import settlement.gateways.persistence.InternalSettlementBillMapper; public class SettlementBillFinderFactory { public static SettlementBillFinder create(String templateName) { switch (templateName.toLowerCase()) { case "internal": return new InternalSettlementBillMapper(); // 其余分支略 } } }
然而,这样的设计是有问题的,因为它破坏了各层的职责。如上所示的SettlementBillFinderFactory
是一个静态工厂,它需要创建具体的资源库对象,就意味着它依赖了基础设施层的类,即放在gateways/persistence中的InternalSettlementBillMapper类,而工厂自身却属于领域层。倘若采用这种做法的话,前面运用的依赖注入方法就变得没有意义了。在领域驱动设计的实现时,我们确实需要时时刻刻保持谨慎,防守住因为某种实现原因导致对整洁架构的破坏。
要做到这一点,可以考虑使用工厂方法模式,为工厂再定义一个抽象,转而将实现放到基础设施层。例如:
package settlement.domain; public interface SettlementBillFinderFactory { SettlementBillFinder create(); }
可惜,这样一个多态的工厂让我们又走回了老路,因为需要调用者根据templateName
决定使用哪一个具体工厂!这与通过templateName
确定选择使用哪一个Repository又有何区别呢?反而引入了不必要的间接。
如果使用Spring来管理依赖注入,有一种做法是在服务中定义一个HashMap<String, String>
,其中key值对应模板名,value值对应SettlementBillFinder
实现子类的类名,然后在配置文件中配置这些映射信息。当服务传入一个templateName
时,在这个hashmap中搜索获得的子类类型,然后利用反射来创建这些类。这一方法看起来保证了可扩展性,但实在太繁琐,太复杂,且反射的使用也在一定程度影响了性能。
有两种更简单的办法:
- 让Repository的实现子类自行判断:如果我们将结算账单视为一个领域概念,就应该为其只抽象一个
SettlementBillRepository
。即无需为每种结算账单提供专有的资源库抽象。在定义Repository的查询方法时,将templateName
与billNumber
都视为查询的条件,然后在实现类中根据templateName
去查询不同的表,获得不同的结算账单领域对象。这个方法胜在简单,但较为死板不易扩展。 - 采用惯例优于配置(CoC):依然将
templateName
作为服务方法的参数,也依旧提供一个SettlementBillRepository
抽象,但在基础设施层为每个结算账单提供一个实现,且实现类遵循命名规则,即以{templateName}
名字(单词首字母大写)为前缀,后缀统一为SettlementBillRepository
,这样就可以基于规则组装类的类型名,再通过反射创建资源库对象。这一方法胜在能扩展,但依旧引入了反射。
这里我选择使用最简单的第一种方案,于是导出服务就变为:
package settlement.domain; import settlement.repositories.SettlementBillRepository; import settlement.interfaces.file.WorkbookReader; import settlement.interfaces.file.WorkbookWriter; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import javax.servlet.http.HttpServletResponse; public class SettlementBillTemplateExporter { @Service private WorkbookReader reader; @Service private WorkbookWriter writer; @Repository private SettlementBillRepository repository; public void export(HttpServletResponse response, String templateName, String billNumber) { SettlementBill bill = repository.settlementBillBy(templateName, billNumber); SettlementBillTemplate template = new SettlementBillTemplate(reader.readFrom(templateName)); template.fillWith(bill); writer.writeTo(response, template, templateName); } }
尽可能保证领域层的整洁
事情还未结束,因为在领域服务的方法中出现了“恼人”的HttpServletReponse
,它属于servlet包的核心对象。在干净的领域层中,怎么能容忍它的出现呢?(当然poi框架的依赖算是例外,前面已经分析过。)仔细分析,我发现在导出逻辑的实现中,其实仅仅用到了HttpServletResponse
对象的getOutputStream()
方法,返回的OutputStream
对象则是JDK中java.io
库中的一个类。既然如此,我们可以在领域层为这一需求提供一个抽象,例如定义接口OutputStreamProvider
:
package settlement.domain; import java.io.OutputStream; public interface OutputStreamProvider { OutputStream getOutputStream(); }
现在的领域服务就可以使用在领域层中自定义的OutputStreamProvider
抽象。此外,还得加上一些异常处理:
package settlement.domain; import settlement.domain.exceptions.*; import settlement.repositories.SettlementBillRepository; import settlement.interfaces.file.WorkbookReader; import settlement.interfaces.file.WorkbookWriter; import org.apache.poi.hssf.usermodel.HSSFWorkbook; public class SettlementBillTemplateExporter { @Service private WorkbookReader reader; @Service private WorkbookWriter writer; @Repository private SettlementBillRepository repository; public void export(OutputStreamProvider streamProvider, String templateName, String billNumber) { try { SettlementBill bill = repository.settlementBillBy(templateName, billNumber); SettlementBillTemplate template = new SettlementBillTemplate(reader.readFrom(templateName)); template.fillWith(bill); writer.writeTo(streamProvider, template, templateName); } catch (DownloadTemplateFileException | OpenTemplateFileException ex) { throw new TemplateFileFailedException(ex.getMessage(), ex); } } }
应用服务的定义就变得简单了:
package settlement.application; import settlement.domain.SettlementBillTemplateExporter; import settlement.domain.OutputStreamProvider; import settlement.domain.exceptions.TemplateFileFailedException; public class SettlementBillAppService { @Service private SettlementBillTemplateExporter exporter; public void exportByTemplate(OutputStreamProvider streamProvider, String templateName, String billNumber) { try { exporter.export(streamProvider, templateName, billNumber); } catch (TemplateFileFailedException ex) { throw new ApplicationException("Failed to export settlement bill file.", ex); } } }
对应的,控制器的实现修改为:
package settlement.gateways.controllers; import settlement.application.SettlementBillAppService; import settlement.gateways.controllers.model.ExportBillReviewRequest; import java.io.OutputStream; import javax.servlet.http.HttpServletResponse; @RestController @RequestMapping("/bill-review") public class BillTemplateController { @Resource private SettlementBillAppService settlementBillService; @PostMapping("/export-template") public void exportBillReviewByTemplate(HttpServletResponse response, @RequestBody ExportBillReviewRequest request) { exportService.exportByTemplate(response::getOutputStream, request.getTemplateName(), request.getBillNumber()); } }
代码的层次结构
如上代码的层次结构为:
settlement - application - SettlementBillAppService - domain - SettlementBill - TemplateVariable - InternalSettlementBill - SettlementBillTemplate - SettlementBillTemplateExporter - OutputStreamProvider - exceptions - TemplateFileFailedException - DownloadTemplateFileException - OpenTemplateFileException - repositories(persistence技术实现的抽象) - SettlementBillRepository - interfaces(技术实现层面的抽象) - file - WorkbookReader - WorkbookWriter - gateways(包含技术实现层面) - persistence - SettlementBillMapper - file - ExcelWorkbookReader - ExcelWorkbookWriter - controllers - BillTemplateController - model - ExportBillReviewRequest
总结
通过以上对代码的逐步演化,我们就此可以发现原来代码的诸多问题。这些问题往往是许多领域驱动设计新手容易犯的错误,包括:
- 未能正确地表达领域知识
- 贫血的领域模型
- 层次不清,对DDD的分层架构理解混乱
- 领域服务与应用服务概念混乱
- 业务逻辑与技术实现纠缠在一起
回归这些问题的原点,其实还是在于团队没有正确地进行领域建模。如果还要继续深究,则在于团队没有为领域建立统一语言。我们看前面对模板导出业务的分析,每一个步骤都没有正确表达业务逻辑,因而获得的领域对象也是不正确的。又由于没有建立统一语言,导致对类和方法的命名都不能很好地体现领域概念,甚至导致某些表达领域概念的类被错误地放在了基础设施层。当我们在运用面向对象编程范式来实现领域驱动设计时,对OO思想的理解偏差与知识缺乏也反映到了代码的实现上,尤其是对“贫血模型”的理解,对职责分配的认知,都会直接反映到代码层面上。
最后,如果团队成员没有清晰地理解分层架构各层的含义,以及为何要引入分层架构,就无法守住分层架构各层的边界,最后就会导致业务复杂度与技术复杂度的混搭。若系统简单还好说,一旦系统的业务复杂度增加带来系统规模的扩大,不紧守架构层次的边界,就可能导致我们事先建立的分层架构名存实亡,代码变成大泥球,重新回归到太初的混沌世界。