为什么我们需要正确地领域建模

广而告知:我在GitChat的领域驱动战略设计实践达人课已经发布,目前正在进入预售期。打开链接即可查看与订阅。同时,我还将在我的个人公众号上做抽奖活动,对于积极评论者会有本次课程的免费码赠送,敬请期待!

在领域驱动设计过程中,正确地进行领域建模是至为关键的环节。如果我们没有能够从业务需求中发现正确的领域概念,就可能导致职责的分配不合理,业务流程不清晰,出现没有任何领域行为的贫血对象,甚至做出错误的设计决策。

最初的实现

在一个结算系统中,业务需求要求导入一个结算账单模板的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的查询方法时,将templateNamebillNumber都视为查询的条件,然后在实现类中根据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思想的理解偏差与知识缺乏也反映到了代码的实现上,尤其是对“贫血模型”的理解,对职责分配的认知,都会直接反映到代码层面上。

最后,如果团队成员没有清晰地理解分层架构各层的含义,以及为何要引入分层架构,就无法守住分层架构各层的边界,最后就会导致业务复杂度与技术复杂度的混搭。若系统简单还好说,一旦系统的业务复杂度增加带来系统规模的扩大,不紧守架构层次的边界,就可能导致我们事先建立的分层架构名存实亡,代码变成大泥球,重新回归到太初的混沌世界。

达人课专属海报

下图为《领域驱动战略设计实践》达人课的专属海报,可微信扫描购买:

您的赞赏是我创作的动力!