前言

本章主要介绍基于多表关联的「增删改查」接口的设计与实现,建议我们的开发者用户,在自己动手编码之前,先花上 1-2 个小时的时间通读本章,相信磨刀不误砍柴工,您的付出会让后续的开发工作变得事半功倍。以下是橙单可生成的标准化表单接口的功能列表。

  • 新增,可跨服务支持,主表与一对一、一对多和多对多关联从表的级联添加。
  • 修改,可跨服务支持,主表与一对一、一对多和多对多关联从表的级联修改。
  • 删除,可跨服务支持,主表与一对一、一对多和多对多关联从表的级联删除。
  • 查询,可跨服务支持,主表与一对一、一对多和多对多关联从表的关联查询,同时还支持主从表数据的字典翻译。
  • 聚合,可跨服务支持,主表与一对多和多对多从表数据的聚合计算。
  • 分组,可跨服务支持,GROUP BY 结果数据的字典翻译。
  • 更多,以上所有功能,无论是单体还是微服务工程,代码接口完全一致。

新增接口

本小节介绍的所有接口,均可通过橙单代码生成器自动生成。

新增主表数据

功能截图。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/add")
public ResponseResult<Long> add(@MyRequestBody PaperDto paperDto) {
   // 1. 基于DTO对象字段上的的Validator API注解,进行输入数据的合法性验证。比如,非空验证等。
   // 这里我们还会针对那些取值范围是静态字典的字段数据,做合法性验证。规则也是遵守Validator API
   // 的标准注解规则。具体可参考下面PaperDto,部分字段声明的代码片段。
   String errorMessage = MyCommonUtil.getModelValidationError(paperDto, false);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   // 2. 因为DTO对象,仅仅面向于前后端的数据交互,而Controller和Service之间的数据传递,
   // 还是使用服务内部的实体对象。所以这里需要做一次对象拷贝。
   Paper paper = MyModelUtil.copyTo(paperDto, Paper.class);
   // 3. 验证关联Id的数据合法性。比如Paper对象中,包含一些字典字段,就需要去字典表中验证这些待插入的数据
   // 是否都是合法的数据。我们还是推荐要从操作入口,尽量保证数据的干净。这也可以参考下面的代码片段。
   CallResult callResult = paperService.verifyRelatedData(paper, null);
   if (!callResult.isSuccess()) {
       return ResponseResult.errorFrom(callResult);
   }
   // 4. 所有的输入数据验证都通过了,就可以在下面的事务方法中,插入该条数据就行了。
   paper = paperService.saveNew(paper);
   return ResponseResult.success(paper.getPaperId());
}
// 这数据验证的方法是上面的接口方法调用的具体实现。
@Override
public CallResult verifyRelatedData(Paper paper, Paper originalPaper) {
   String errorMessageFormat = "数据验证失败,关联的%s并不存在,请刷新后重试!";
   // 这里是判断Paper中的gradeId字段,是否是合法的字典数据。
   // 首先会判断该值是否为NULL,如果是NULL,就不用验证,顺利通过。
   // 如果该字段不允许为NULL,那么逻辑也走不到这里, 前面的实体字段Validator API验证就会返回错误信息了。
   // 因为add/update使用的相同接口,所以对于新增而言是每次都要验证的,如果是update,还会判断gradeId
   // 是否变化了,如果没有变化,就不用验证,提升了效率。
   if (this.needToVerify(paper, originalPaper, Paper::getGradeId)
           && !gradeService.existId(paper.getGradeId())) {
       return CallResult.error(String.format(errorMessageFormat, "所属年级"));
   }
   // 这里是对于作业类型字典的数据验证。
   // 在橙单代码生成器中,我们会根据表关系和字典关系,生成这些可二次开发的高质量代码。
   if (this.needToVerify(paper, originalPaper, Paper::getPaperType)
           && !paperTypeService.existId(paper.getPaperType())) {
       return CallResult.error(String.format(errorMessageFormat, "作业类型"));
   }
   return CallResult.ok();
}
// 与Paper实体对象对应的Dto对象。因为是录入接口的参数对象,所以Validator API的验证注解,
// 都声明在该对象中。
@Data
public class PaperDto {

   @Schema(description = "主键Id")
   @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class})
   private Long paperId;

   @Schema(description = "作业名称")
   @NotBlank(message = "数据验证失败,作业名称不能为空!")
   private String paperName;

   @Schema(description = "科目Id")
   @NotNull(message = "数据验证失败,所属科目不能为空!")
   @ConstDictRef(constDictClass = Subject.class, message = "数据验证失败,所属科目为无效值!")
   private Integer subjectId;
 
   // ... ... 为了节省篇幅和流量,这里省略其他字段的定义。
}  

新增多对多关联数据

在上一个代码示例中,我们添加了一个作业对象。在本例中,作业数据仍然是主表数据,我们需要为作业添加与其「多对多关联」的习题对象。功能很直观,每个线上作业都会包含多道习题,反之亦然,每到习题也可能被多个作业所包含,这就是非常典型的多对多关联,见如下截图。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/addPaperExercise")
public ResponseResult<Void> addPaperExercise(
       @MyRequestBody Long paperId, @MyRequestBody List<PaperExerciseDto> paperExerciseDtoList) {
   if (MyCommonUtil.existBlankArgument(paperId, paperExerciseDtoList)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 1. 这里需要从列表参数中,提取出所有多对多关联的从表关联Id字段的数据集合,比如本例的习题Id(exerciseId)。
   Set<Long> exerciseIdSet =
           paperExerciseDtoList.stream().map(PaperExerciseDto::getExerciseId).collect(Collectors.toSet());
   // 2. 这里会同时判断主表关联Id(paperId)和所有从表关联Id集合(exerciseIdSet)是否在原有的主表(作业表)和从表(习题表)
   // 中是否都是存在的。注意这里的existUniqueKeyList方法是批量验证,比如有10条习题数据,也只会查询数据库一次,其余的
   // 数据比对工作,都是在Java服务的内存中完成的,以此降低与数据库的交互次数并提升接口性能。
   if (!paperService.existId(paperId)
           || !exerciseService.existUniqueKeyList("exerciseId", exerciseIdSet)) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID);
   }
   // 3. 这是一个规范化处理,所有前端传入的对象都是Dto,Controller和Service之间传递的是对应的实体对象。
   List<PaperExercise> paperExerciseList =
           MyModelUtil.copyCollectionTo(paperExerciseDtoList, PaperExercise.class);
   // 4. 在同一个事务内,插入作业和习题的多对多关联数据。
   paperService.addPaperExerciseList(paperExerciseList, paperId);
   return ResponseResult.success();
}

新增主从表关联数据

这里是以合同录入的审批流程为例的。合同数据通常存储于合同主表,与其关联的合同产品和交付详情等数据,存储于合同产品关联表和合同交付详情表。合同主表与合同产品表是多对多关系,与合同交付详情是一对多的关联关系,通过合同 ID 进行表关联。最后补充一句,本实例是主表数据与一对多和多对多从表数据列表的级联添加,然而事实上,对于并不常见的一对一级联添加我们也是支持的,只是当前的演示示例没有配置,具体配置如下图所示。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/add")
public ResponseResult<Long> add(
       // 主表合同数据。
       @MyRequestBody FlowContractDto flowContractDto,
       // 一对多合同交付详情数据列表。
       @MyRequestBody List<FlowDeliveryDetailDto> flowDeliveryDetailDtoList,
       // 多对多合同产品详情数据列表。
       @MyRequestBody List<flowContractProductDto> flowContractProductDtoList) {
   // 这里会验证验证每一个输入数据的合法性,并将他们从DTO转换为对应的实体对象类型。
   // 该验证方法在下面的级联更新的示例中也会被调用,由此可见,我们在生成代码的过程中,
   // 已经考虑到,尽可能的减少重复代码的生成。
   ResponseResult<Tuple2<FlowContract, JSONObject>> verifyResult = this.doBusinessDataVerifyAndConvert(
           flowContractDto,
           // forUpdate参数,在级联添加中是false,在级联更新中是true。
           false,
           flowDeliveryDetailDtoList,
           flowContractProductDtoList);
   if (!verifyResult.isSuccess()) {
       return ResponseResult.errorFrom(verifyResult);
   }
   Tuple2<FlowContract, JSONObject> bizData = verifyResult.getData();
   FlowContract flowContract = bizData.getFirst();
   // 这里会在同一个事务之内,批量插入主表和一对多从表的数据的。
   // 为了保证接口的应答效率,橙单会生成真正的批量插入INSERT LIST的SQL脚本代码。
   flowContract = flowContractService.saveNewWithRelation(flowContract, bizData.getSecond());
   return ResponseResult.success(flowContract.getContractId());
}
// 该私有方法,就是上面接口调用的私有方法。
private ResponseResult<Tuple2<FlowContract, JSONObject>> doBusinessDataVerifyAndConvert(
       // 合同主表数据。
       FlowContractDto flowContractDto,
       // 为了保证代码的最大可复用性,add和update接口都会调用该方法,只是该参数值不同。
       boolean forUpdate,
       // 一对多的合同详情列表数据。
       List<FlowDeliveryDetailDto> flowDeliveryDetailDtoList,
       // 多对多的合同所包含的产品列表数据。
       List<lowContractProductDto> flowContractProductDtoList) {
   ErrorCodeEnum errorCode = ErrorCodeEnum.DATA_VALIDATED_FAILED;
   // 1. 因为add和update都需要验证实体对象字段的合法性,所以我们一并发到了这个数据验证的私有方法中了。
   // 都是基于Validator API的机制,这个在前面的例子中已经介绍过了。
   // 下面分别对合同主表对象,一对多合同详情对象列表,多对多合同产品中间表数据对象列表,都要执行该验证。
   String errorMessage = MyCommonUtil.getModelValidationError(flowContractDto, forUpdate);
   if (errorMessage != null) {
       return ResponseResult.error(errorCode, errorMessage);
   }
   errorMessage = MyCommonUtil.getModelValidationError(flowDeliveryDetailDtoList);
   if (errorMessage != null) {
       return ResponseResult.error(errorCode, "参数[flowDeliveryDetailDtoList]数据验证失败!" + errorMessage);
   }
   errorMessage = MyCommonUtil.getModelValidationError(flowContractProductDtoList);
   if (errorMessage != null) {
       return ResponseResult.error(errorCode, "参数[flowContractProductDtoList]数据验证失败!" + errorMessage);
   }
   // 全部关联从表数据的验证和转换
   JSONObject relationData = new JSONObject();
   CallResult verifyResult;
   // 下面是输入参数中,主表关联数据的验证。
   FlowContract flowContract = MyModelUtil.copyTo(flowContractDto, FlowContract.class);
   FlowContract originalData = null;
   // 如果是更新接口调用的验证方法,就需要验证主表主键id关联的数据是否存在了。
   if (forUpdate && flowContract != null) {
       originalData = flowContractService.getById(flowContract.getContractId());
       if (originalData == null) {
           return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
       }
       relationData.put("originalData", originalData);
   }
   // 验证主表中字典id字段的合法性,这个在前面的例子中已经介绍过了。
   verifyResult = flowContractService.verifyAllRelatedData(flowContract, originalData);
   if (!verifyResult.isSuccess()) {
       return ResponseResult.errorFrom(verifyResult);
   }
   // 验证并转换主表的一对多关联 [合同交付详情数据列表]
   List<FlowDeliveryDetail> flowDeliveryDetailList =
           MyModelUtil.copyCollectionTo(flowDeliveryDetailDtoList, FlowDeliveryDetail.class);
   // 这里的列表数据验证,橙单也是批量验证的,比如有10条从表数据,但是和数据库的交互仅有一次,
   // 其余的验证逻辑,都是在java服务的内存中完成的,从而减少数据库的交互,提升接口应答效率。
   verifyResult = flowDeliveryDetailService.verifyAllRelatedData(flowDeliveryDetailList);
   if (!verifyResult.isSuccess()) {
       return ResponseResult.errorFrom(verifyResult);
   }
   relationData.put("flowDeliveryDetailList", flowDeliveryDetailList);
   // 验证并转换主表的多对多关联 [合同产品多对多关联表]
   List<FlowContractProduct> flowContractProductList =
           MyModelUtil.copyCollectionTo(flowContractProductDtoList, FlowContractProduct.class);
   relationData.put("flowContractProductList", flowContractProductList);
   return ResponseResult.success(new Tuple2<>(flowContract, relationData));
}

修改接口

本小节介绍的所有接口,均可通过橙单代码生成器自动生成。

修改主表数据

功能截图。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/update")
public ResponseResult<Void> update(@MyRequestBody PaperDto paperDto) {
   // 1. 与前面的add接口一样,这里也必须对参数对象进行Validator API的验证。
   String errorMessage = MyCommonUtil.getModelValidationError(paperDto, true);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   Paper paper = MyModelUtil.copyTo(paperDto, Paper.class);
   // 2. 根据主键值获取之前的对象,如果不存在就不能修改。
   Paper originalPaper = paperService.getById(paper.getPaperId());
   if (originalPaper == null) {
       // NOTE: 修改下面方括号中的话述
       errorMessage = "数据验证失败,当前 [数据] 并不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   // 验证关联Id的数据合法性。代码的实现逻辑,在前面的add接口中,已经给出了详细注释。
   // 说一下和add接口数据字段验证的区别。add接口中,所有非空的字典字段都要验证值的合法性,
   // 而这里之所以要传递原有对象(originalXxxxxx)参数,是因为verifyRelatedData方法的
   // 内部实现,会比较字典字段值前后是否发生变化,如果没有变化就不用验证了,尽可能的减少了
   // 和数据库的交互次数,提升接口的响应性能。
   CallResult callResult = paperService.verifyRelatedData(paper, originalPaper);
   if (!callResult.isSuccess()) {
       return ResponseResult.errorFrom(callResult);
   }
   // 更新主表数据。
   if (!paperService.update(paper, originalPaper)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}

修改多对多关联数据

继续前面的「新增作业习题多对多」示例场景,如果作业中的某道习题是必做习题,但是该习题在其他作业中,不一定是必做习题,这样我们就需要在作业习题中间表中添加「是否为必做习题」的标记字段,用以标记该习题在作业中是否必做。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/updatePaperExercise")
public ResponseResult<Void> updatePaperExercise(@MyRequestBody PaperExerciseDto paperExerciseDto) {
   String errorMessage = MyCommonUtil.getModelValidationError(paperExerciseDto);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   PaperExercise paperExercise = MyModelUtil.copyTo(paperExerciseDto, PaperExercise.class);
   // 以主表关联Id(paperId)和从表关联Id(exerciseId)作为过滤条件,修改中间表数据的字段值。
   // 这样就不用提前验证这两个关联字段的合法性了,如果不存在,下面的updatePaperExercise就会
   // 返回false。
   if (!paperService.updatePaperExercise(paperExercise)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}

修改主从表关联数据

这里继续以前面的合同审批流程为例。在审批过程中,有些合同数据可能会被审批人修改,为了避免出现脏数据,主从表的所有数据变更需要在同一事务内级联更新。否则如果放在多个接口的多个事务中,一旦前面的事务提交成功,而后面的接口调用出现网络故障,就会导致审批中的合同及其关联数据,出现不一致的现象。我想这就是橙单必须要支持,生成级联更新接口代码的原因吧。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/update")
public ResponseResult<Long> update(
       // 合同主表数据对象。
       @MyRequestBody FlowContractDto flowContractDto,
       // 合同的一对多关联从表数据列表(合同交付详情)
       @MyRequestBody List<FlowDeliveryDetailDto> flowDeliveryDetailDtoList,
       // 合同的多对多关联从表数据列表(合同产品关联表)
       @MyRequestBody List<FlowContractProductDto> flowContractProductDtoList) {
   String errorMessage;
   // 这个验证方法的代码和详解,我们在上面的级联添加的代码中已经给出,这里不再展开。
   // 由此可见,我们在生成代码的过程中已经考虑到,尽可能的减少重复代码的生成。
   ResponseResult<Tuple2<FlowContract, JSONObject>> verifyResult = this.doBusinessDataVerifyAndConvert(
           flowContractDto,
           // forUpdate参数在这里是true,级联添加中是false。
           true,
           flowDeliveryDetailDtoList,
           flowContractProductDtoList);
   if (!verifyResult.isSuccess()) {
       return ResponseResult.errorFrom(verifyResult);
   }
   Tuple2<FlowContract, JSONObject> bizData = verifyResult.getData();
   FlowContract originalFlowContract = bizData.getSecond().getObject("originalData", FlowContract.class);
   FlowContract flowContract = bizData.getFirst();
   // 级联更新主从表数据。如果是同库,下面的方法会标有 @Transactional 注解,并在同一事务内完成。
   // 如果主表和任一从表不同库,我们就会添加Seata的 @GlobalTransactional 注解,从而保证分布式事务的数据一致性。
   // 最后需要说明的是,对于从表数据,如果为null,则忽略更新,如果是空数组,则会删除从表数据。
   if (!flowContractService.updateWithRelation(flowContract, originalFlowContract, bizData.getSecond())) {
       errorMessage = "数据验证失败,[FlowContract] 数据不存在!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   return ResponseResult.success(flowContract.getContractId());
}

删除接口

本小节介绍的所有接口,均可通过橙单代码生成器自动生成。

删除主表数据

功能截图。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody(required = true) Long paperId) {
   // 在下面的remove方法中,不仅会删除主表数据,同时一定会删除与其关联的多对多关联数据。
   // 重点说明的是,当前表无论作为多对多关联中的主表还是从表,下面的方法都会删除与其关联的中间表数据。
   // 具体可见下面的代码。
   if (!paperService.remove(paperId)) {
       String errorMessage = "数据操作失败,删除作业不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   return ResponseResult.success();
}
@Transactional(rollbackFor = Exception.class)
@Override
public boolean remove(Long paperId) {
   if (paperMapper.deleteById(paperId) == 0) {
       return false;
   }
   // 在这个多对多关联中,作业表是主表,习题表是从表。开始删除多对多子表的关联。
   PaperExercise paperExercise = new PaperExercise();
   paperExercise.setPaperId(paperId);
   paperExerciseMapper.delete(new QueryWrapper<>(paperExercise));
   // 在这个多对多关联中,课程章节表是主表,作业表是从表。开始删除多对多父表的关联。
   CourseSectionPaper courseSectionPaper = new CourseSectionPaper();
   courseSectionPaper.setPaperId(paperId);
   courseSectionPaperMapper.delete(new QueryWrapper<>(courseSectionPaper));
   return true;
}

删除多对多关联数据

继续前面的「新增作业习题多对多」示例场景,这里可以移除任何一个作业习题的关联数据,移除后,仅仅该习题不在与该作业存在关联关系,而习题本身的数据不会被删除。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/deletePaperExercise")
public ResponseResult<Void> deletePaperExercise(
       @MyRequestBody Long paperId, @MyRequestBody Long exerciseId) {
   if (MyCommonUtil.existBlankArgument(paperId, exerciseId)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 移除单条作业和习题之间的多对多关系。
   if (!paperService.removePaperExercise(paperId, exerciseId)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}

删除主从表关联数据

这里继续以前面的合同审批流程为例。合同与合同交付详情是一对多的主从关系,从业务视角来看,如果合同被删除了,那么合同详情数据也没有存在的意义了,因此这种强关联的数据,可以考虑级联删除。具体细节可参考以下代码中的注释说明。

// 仅从Controller的接口代码逻辑来看,和普通删除没啥区别。因为级联删除需要在同一事务内完成,
// 所以实现细节都在下面的ServiceImpl提供的remove方法中。
@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody(required = true) Long contractId) {
   String errorMessage;
   if (!testFlowContractService.remove(contractId)) {
       errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   return ResponseResult.success();
}
// 级联删除主从表数据。如果是同库,下面的方法会标有 @Transactional 注解,并在同一事务内完成。
// 如果主表和任一从表不同库,我们就会添加Seata的 @GlobalTransactional 注解,从而保证分布式事务的数据一致性。
@Transactional(rollbackFor = Exception.class)
@Override
public boolean remove(Long contractId) {
   // 这里必须先获取主表数据对象,从而可以拿到与其他关联表的关联字段数据。
   FlowContract flowContract = flowContractMapper.selectById(contractId);
   if (flowContract == null) {
       return false;
   }
   // 删除主表数据。
   if (flowContractMapper.deleteById(contractId) == 0) {
       return false;
   }
   // 根据关联字段删除一对多从表数据,下面的removeByContractId方法也是橙
   // 单根据配置动态生成的方法。
   flowDeliveryDetailService.removeByContractId(contractId);
   // 开始删除多对多中间表的关联
   FlowContractProduct flowContractProduct = new FlowContractProduct();
   flowContractProduct.setContractId(contractId);
   flowContractProductMapper.delete(new QueryWrapper<>(flowContractProduct));
   return true;
}

查询接口

本小节介绍的所有接口,均可通过橙单代码生成器自动生成。

查询主表及关联数据

这里主要分为两个场景,一个是主表的查询列表数据中,包含一对一从表的关联字典,如下图的视频课程列表中,包含了年级、学科、课程难度等字典翻译类型的数据关联。另一种是主表和一对多从表的主从联动,这些我们都可以通过橙单的表单编辑配置而得。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/list")
public ResponseResult<MyPageData<VideoCourseVo>> list(
       @MyRequestBody VideoCourseDto videoCourseDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   // 标准化的处理方式,前端和后台之前用Dto对象传递数据,而Controller和Service之间可以使用
   // 注解信息更多的实体对象。
   VideoCourse videoCourseFilter = MyModelUtil.copyTo(videoCourseDtoFilter, VideoCourse.class);
   // 构建动态多字段排序ORDER BY字符串。前端会根据规则传递需要排序的字段和升降序规则。
   // 在保证如此足够灵活性的同时,buildOrderBy方法进行了较为严格的验证,避免了SQL注入的发生。
   String orderBy = MyOrderParam.buildOrderBy(orderParam, VideoCourse.class);
   // getXxxxxListWithRelation方法,这样命名规则的方法,都会根据实体对象中,数据关联的注解,
   // 实现从表数据的批量获取和数据自动组装,最后以统一的格式返回给前端页面。
   List<VideoCourse> videoCourseList =
           videoCourseService.getVideoCourseListWithRelation(videoCourseFilter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(videoCourseList, VideoCourse.INSTANCE));
}
@Override
public List<VideoCourse> getVideoCourseListWithRelation(VideoCourse filter, String orderBy) {
   // 单表查询出主表的数据列表。
   List<VideoCourse> resultList = videoCourseMapper.getVideoCourseList(filter, orderBy);
   // 这里基类中的通用方法,会根据实体对象中的注解,自动查询并组装关联的从表数据。
   this.buildRelationForDataList(resultList, MyRelationParam.normal());
   return resultList;
}
@Data
@TableName(value = "zz_video_course")
public class VideoCourse {

   @TableId(value = "video_course_id")
   private Long videoCourseId;

   @TableField(value = "course_name")
   private String courseName;

   @TableField(value = "grade_id")
   private Integer gradeId;
   // ... ... 为了便于演示,省略了若干其他字段。
 
   // 这里仅是字典数据翻译的注解。关联后的字典数据会组装到gradeIdDictMap字段。
   // 这类注解都会被上面的buildRelationForDataList方法识别,并自动批量查询和自动组装。
   // 更多细节可以参考橙单的线上开发文档。
   @RelationDict(
           masterIdField = "gradeId",
           slaveServiceName = "gradeService",
           slaveModelClass = Grade.class,
           slaveIdField = "gradeId",
           slaveNameField = "gradeName")
   @TableField(exist = false)
   private Map<String, Object> gradeIdDictMap;
}

查询主表及关联聚合数据

在下面的截图中,班级和学生、班级和课程都是多对多的关联关系。其中班级主表中并不包含「课时数量」、「学生数量」和「课程进度」等字段,这里是根据多对多关联从表中的数据动态聚合计算而来。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/list")
public ResponseResult<MyPageData<StudentClassVo>> list(
       @MyRequestBody StudentClassDto studentClassDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   StudentClass studentClassFilter = MyModelUtil.copyTo(studentClassDtoFilter, StudentClass.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, StudentClass.class);
   // 所有的文章都在这里,getStudentClassListWithRelation会根据实体对象中的注解,
   // 实现从表或中间表数据查询,然后再将查询结果,根据注解说明进行指定的聚合计算后,以虚拟字段的形式,赋值给主表数据列表。
   // 在下面的查询中,班级学生数量,仅仅通过聚合中间表数据即可,因此可以不用查询从表了,从而可以提升查询效率。
   // 而“课程数量”和“课程进度”两个动态聚合虚拟字段,其数据必须来自于从表课程表的“课时数量”和“完成状态”两个字段,因此,
   // 他们的计算必须通过从表数据的聚合计算而得。
   List<StudentClass> studentClassList =
           studentClassService.getStudentClassListWithRelation(studentClassFilter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(studentClassList, StudentClass.INSTANCE));
}
@Data
@TableName(value = "zz_class")
public class StudentClass {

   @TableId(value = "class_id")
   private Long classId;

   @TableField(value = "class_name")
   private String className;

   @TableField(value = "finish_class_hour")
   private Integer finishClassHour;

   @TableLogic
   private Integer status;
   // ... ... 中间忽略若干字段的声明。
   // 重点看下面两个注解声明的字段,注解的具体含义可参考橙单的线上文档。
   // 这里我们只需知道上面的buildRelationForDataList方法,会识别该注解,并实现数据查询、优化和聚合计算。
 
   // 总课时数 (多对多聚合计算字段)。
   @RelationManyToManyAggregation(
           // 当前主表和中间表的关联字段是classId。
           masterIdField = "classId",
           // 通过课程服务实现类去查询课程数据。
           slaveServiceName = "courseService",
           // 从表的对象类型是Course。
           slaveModelClass = Course.class,
           // 从表和中间表关联的字段是courseId。
           slaveIdField = "courseId",
           // 中间表的对象类型。
           relationModelClass = ClassCourse.class,
           // 中间表中和主表的关联字段。
           relationMasterIdField = "classId",
           // 中间表中和从表的关联字段。
           relationSlaveIdField = "courseId",
           // 基于从表的对象字段进行聚合计算。
           aggregationModelClass = Course.class,
           // 聚合计算的类型是SUM求和。
           aggregationType = AggregationType.SUM,
           // 从表中求和的字段是课时字段。
           aggregationField = "classHour")
   @TableField(exist = false)
   private Integer totalClassHour = 0;

   // 学生数量 (多对多聚合计算字段)。
   @RelationManyToManyAggregation(
           // 当前主表和中间表的关联字段是classId。
           masterIdField = "classId",
           // 通过学生服务实现类去查询课程数据。
           slaveServiceName = "studentService",
           // 从表的对象类型是Student。
           slaveModelClass = Student.class,
           // 从表和中间表关联的字段是studentId。
           slaveIdField = "studentId",
           // 中间表的对象类型。
           relationModelClass = ClassStudent.class,
           // 中间表中和主表的关联字段。
           relationMasterIdField = "classId",
           // 中间表中和从表的关联字段。
           relationSlaveIdField = "studentId",
           // 基于从表的对象字段进行聚合计算。
           aggregationModelClass = Student.class,
           // 聚合计算的类型是COUNT汇总,这里会被优化为基于中间表的计算。
           aggregationType = AggregationType.COUNT,
           aggregationField = "studentId")
   @TableField(exist = false)
   private Integer studentCount = 0;
}

查询主表分组统计数据

在下面的截图中,课程流水统计主表包含三个统计图表,GROUP BY 的维度字段分别为「年级」、「科目」和「统计日期」。这里的用例可以在橙单代码生成器中,将主表配置为具备统计能力的数据表,并为该表分别指定可能的维度和指标字段,前端可以根据图表的配置,作为参数动态的传入分组字段值。

代码示例,具体细节可参考以下代码中的注释说明。

@PostMapping("/listWithGroup")
public ResponseResult<MyPageData<CourseTransStatsVo>> listWithGroup(
       @MyRequestBody CourseTransStatsDto courseTransStatsDtoFilter,
       @MyRequestBody(required = true) MyGroupParam groupParam,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   String orderBy = MyOrderParam.buildOrderBy(orderParam, CourseTransStats.class);
   // 和上面的排序对象参数一样,分组字段也是由前端作为参数动态传入的,这样就会更加灵活。
   // buildGroupBy工具方法会根据接口参数和主表对象的类型进行必要的验证和转换,最后返回
   // GROUP BY从句所需的字符串,这个过程可以完全规避SQL注入的风险。
   groupParam = MyGroupParam.buildGroupBy(groupParam, CourseTransStats.class);
   if (groupParam == null) {
       return ResponseResult.error(
               ErrorCodeEnum.INVALID_ARGUMENT_FORMAT, "数据参数错误,分组参数不能为空!");
   }
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   CourseTransStats filter = MyModelUtil.copyTo(courseTransStatsDtoFilter, CourseTransStats.class);
   MyGroupCriteria criteria = groupParam.getGroupCriteria();
   // 进行分组计算,同时分组计算后的结果,还可以进行字典翻译。如上图中的年级信息,在课程流水表中,我们保存
   // 保存的都是grade_id值,分组的统计也是基于这些id数据,但是在返回给前端显示之前,我们还会根据配置,对
   // 分组统计的结果集,进行一次字典翻译的工作。
   List<CourseTransStats> resultList = courseTransStatsService.getGroupedCourseTransStatsListWithRelation(
           filter, criteria.getGroupSelect(), criteria.getGroupBy(), orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(resultList, CourseTransStats.INSTANCE));
}
@Override
public List<CourseTransStats> getGroupedCourseTransStatsListWithRelation(
       CourseTransStats filter, String groupSelect, String groupBy, String orderBy) {
   // 橙单会根据用户在生成器中的配置,生成getGroupedCourseTransStatsList方法所需的SQL语句。
   List<CourseTransStats> resultList =
           courseTransStatsMapper.getGroupedCourseTransStatsList(filter, groupSelect, groupBy, orderBy);
   // 可以看到这里对分组计算的结果进行了关联绑定和字典翻译等工作。该方法在前面已经介绍过了。
   this.buildRelationForDataList(resultList, MyRelationParam.normal());
   return resultList;
}
<!-- 这里的SQL也是橙单根据开发者的在线配置生成而来,无需手写。-->
<select id="getGroupedCourseTransStatsList" resultMap="BaseResultMap" 
       parameterType="com.orangeforms.demo.webadmin.app.model.CourseTransStats">
   SELECT * FROM
       (SELECT
           SUM(student_attend_count) student_attend_count,
           SUM(student_flower_amount) student_flower_amount,
           SUM(student_flower_count) student_flower_count,
           ${groupSelect}
       FROM zz_course_trans_stats
       <where>
           <include refid="filterRef"/>
       </where>
       GROUP BY ${groupBy}) zz_course_trans_stats
   <if test="orderBy != null and orderBy != ''">
       ORDER BY ${orderBy}
   </if>
</select>

查询多对多关联数据

在前面的作业和习题多对多关联的示例代码中,我们需要显示当前作业已经关联了哪些习题的列表,并在前端页面中展示出来。

代码示例,具体细节可参考以下代码中的注释说明。

// 这里的标准化的接口,都是由橙单代码生成工具,根据用户的配置动态生成的,无需手写一行。
@PostMapping("/listPaperExercise")
public ResponseResult<MyPageData<ExerciseVo>> listPaperExercise(
       @MyRequestBody(required = true) Long paperId,
       // 这里我们还支持主表的过滤条件。
       @MyRequestBody ExerciseDto exerciseDtoFilter,
       // 支持中间表的过滤条件,比如本例可以查询“必做习题”的列表。
       @MyRequestBody PaperExerciseDto paperExerciseDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   // 必要的数据验证,先判断当前主表id是否存在。
   if (!paperService.existId(paperId)) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID);
   }
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   // 进行标准化的对象转换,所有Controller和Service之间,都是传递实体对象的,而非Dto。
   Exercise filter = MyModelUtil.copyTo(exerciseDtoFilter, Exercise.class);
   PaperExercise paperExerciseFilter =
           MyModelUtil.copyTo(paperExerciseDtoFilter, PaperExercise.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Exercise.class);
   // 这里会根据配置生成下面的方法,即根据主表对象的关联Id,获取从表的数据对象列表。
   List<Exercise> exerciseList =
           exerciseService.getExerciseListByPaperId(paperId, filter, paperExerciseFilter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(exerciseList, Exercise.INSTANCE));
}

上面执行的关联查询语句如下。

<select id="getExerciseListByPaperId" resultMap="BaseResultMapWithPaperExercise">
   SELECT
       zz_exercise.*,
       zz_paper_exercise.*
   FROM
       zz_exercise,
       zz_paper_exercise
   <where>
       AND zz_paper_exercise.paper_id = #{paperId}
       AND zz_paper_exercise.exercise_id = zz_exercise.exercise_id
       <!-- 这里支持主表字段过滤 -->
       <include refid="filterRef"/>
       <!-- 中间表字段过滤,如:是否必选习题标记 -->
       <include refid="com.orangeforms.demo.webadmin.app.dao.PaperExerciseMapper.filterRef"/>
   </where>
   <if test="orderBy != null and orderBy != ''">
       ORDER BY ${orderBy}
   </if>
</select>

查询多对多未关联数据

在前面的作业和习题多对多关联的示例代码中,当为作业添加新习题时,我们需要显示出尚未与当前作业进行关联的那些习题列表,并作为候选者在前端页面中展示出来。

代码示例,具体细节可参考以下代码中的注释说明。

// 这里的标准化的接口,都是由橙单代码生成工具,根据用户的配置动态生成的,无需手写一行。
@PostMapping("/listNotInPaperExercise")
public ResponseResult<MyPageData<ExerciseVo>> listNotInPaperExercise(
       @MyRequestBody Long paperId,
       @MyRequestBody ExerciseDto exerciseDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   // 必要的数据验证,先判断当前主表id是否存在。
   if (MyCommonUtil.isNotBlankOrNull(paperId) && !paperService.existId(paperId)) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID);
   }
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   Exercise filter = MyModelUtil.copyTo(exerciseDtoFilter, Exercise.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Exercise.class);
   // 这里会根据配置生成下面的方法,即根据主表对象的关联Id,获取未关联的从表数据对象列表。
   List<Exercise> exerciseList =
           exerciseService.getNotInExerciseListByPaperId(paperId, filter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(exerciseList, Exercise.INSTANCE));
}

上面执行的关联查询语句如下。

<!-- 这里的SQL,也是橙单代码生成工具根据开发者的配置动态生成的。-->
<select id="getNotInExerciseListByPaperId" resultMap="BaseResultMap">
   SELECT
       zz_exercise.*
   FROM
       zz_exercise
   <where>
       AND NOT EXISTS (SELECT * FROM zz_paper_exercise pe
           WHERE pe.paper_id = #{paperId} AND pe.exercise_id = zz_exercise.exercise_id)
       <include refid="filterRef"/>
   </where>
   <if test="orderBy != null and orderBy != ''">
       ORDER BY ${orderBy}
   </if>
</select>

微服务实现

我们需要继续强调的是,上面的所有表关联接口,全部适用于「主表和从表位于不同服务」的微服务架构场景,并且主表的 Controller 接口参数完全一致。这样最大的一个优势就是,如果你的项目前期是单体架构,而后面准备升级改造为微服务架构时,前端代码的接口调用逻辑,权限规则等均可以保持不变。变化的只是 Controller 的内部实现,以及调用的 Service 方法,从之前的本地事务注解 @Transational,改为基于 Seata 的分布式事务注解 @GlobalTransactional。

在橙单代码生成工具中,我们会根据用户的配置,为每个选定的业务表 Controller 接口,生成与之对应的标准化的 FeignClient 接口,以便于接口间进行标准化的相互调用。

远程数据验证

调用主表的数据新增接口时,如果该主表包含其他微服务的字典表数据,此时就需要调用远程微服务的数据验证接口,以判断主表中的关联字段是否为合法数据。在前面的代码示例中,我们已经给出了本服务内的关联字段验证,下面的代码为基于远程 FeignClient 接口的数据验证。

@PostMapping("/add")
public ResponseResult<Long> add(@MyRequestBody CourseDto courseDto) {
   String errorMessage = MyCommonUtil.getModelValidationError(courseDto, false);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   Course course = MyModelUtil.copyTo(courseDto, Course.class);
   // 新增课程的时候,课程表中的teacher_id和school_id字段,与其关联的zz_teacher和zz_school表,
   // 与当前课程表zz_course,位于不同的微服务中,因此对teacherId和schoolId字段数据的验证,就需要
   // 通过FeignClient的远程服务接口实现。
   CallResult callResult = courseService.verifyAllRelatedData(course, null);
   if (!callResult.isSuccess()) {
       return ResponseResult.errorFrom(callResult);
   }
   course = courseService.saveNew(course);
   return ResponseResult.success(course.getCourseId());
}
// 下面的verifyRemoteRelatedData方法,仅包含所有需要远程验证的字典。而本地验证的字段,会在
// verifyRelatedData方法中实现,这两个方法会被上面的verifyAllRelatedData分别调用。这样拆分
// 的好处是,代码结构和性能测试都非常清晰。
@Override
public CallResult verifyRemoteRelatedData(Course course, Course originalCourse) {
   String errorMessageFormat = "数据验证失败,关联的%s并不存在,请刷新后重试!";
   // needToVerify方法,顾名思义,判断当前对象的字段值是否需要验证,如果关联字段值为null,
   // 或者与原有对象originalXxxx对象字段值相比没有变化的时候,需无需验证,从而避免额外的性能开销。
   if (this.needToVerify(course, originalCourse, Course::getTeacherId)) {
       // teacherClient,在橙单缺省生成的代码中,多有FeignClient对象都是XxxxClient的命名规则。
       ResponseResult<Boolean> responseResult = teacherClient.existId(course.getTeacherId());
       // 如果验证没有通过,远程服务的接口会返回明确的错误信息,这里直接返回即可。
       if (this.hasErrorOfVerifyRemoteRelatedData(responseResult)) {
           return CallResult.error(String.format(errorMessageFormat, "主讲老师Id"));
       }
   }
   // ... ... 这里省略了schoolId的验证逻辑。
   return CallResult.ok();
}
// 在upms服务的TeacherController接口中,会给出该FeignClient接口的实现。逻辑比较简单,这里就不给出具体实现了。
@FeignClient(
       // Teacher主表数据和接口位于upms微服务中。
       name = "upms",
       configuration = FeignConfig.class,
       fallbackFactory = TeacherClient.TeacherClientFallbackFactory.class)
public interface TeacherClient extends BaseClient<TeacherDto, TeacherVo, Long> {

   // 判断主键Id是否存在。
   @Override
   @PostMapping("/teacher/existId")
   ResponseResult<Boolean> existId(@RequestParam("teacherId") Long teacherId);
 
   // ... ... 这里省略了其他标准化远程调用接口的声明,以及服务熔断降级接口的声明。
}
```

远程数据级联增删改

为了缩减篇幅,这里我们仅给出远程级联删除的代码示例,另外两个数据操作场景 (远程级联增改),与本例的代码实现机制基本一致。在开始看代码之前,我们这里需要重点说明一下的是,橙单自动生成的所有分布式事务代码逻辑,都是基于 Seata 分布式事务框架中的 AT 模式,该模式可以自动推演简单 SQL 的反向回滚补偿逻辑,「看好重点是简单SQL」。因此橙单自动生成的代码中,都是非常简单的单表增删改逻辑,非常易于 Seata AT 模式的回滚补偿推演。然而对于您手写的复杂逻辑,如果担心 Seata 的自动补偿推演会产生错误,那么就只能使用 TCC 的补偿模式了。

// 可以看出这里的接口定义和实现逻辑,与前面单体服务中的示例代码完全一样。
@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody Long courseId) {
   String errorMessage;
   if (MyCommonUtil.existBlankArgument(courseId)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 删除课程的同时,还有删除与之关联的远程从表数据。具体见下面的代码实现。
   if (!courseService.remove(courseId)) {
       errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   return ResponseResult.success();
}
// 这里添加了Seata的分布式事务的注解。
@GlobalTransactional(rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
@Override
public boolean remove(Long courseId) {
   // 删除课程主表数据
   if (courseMapper.deleteById(courseId) == 0) {
       return false;
   }
   // 删除与之一对多关联的课程章节的从表数据。课程和课程章节位于同一服务之内,因此下面的调用是本地Service的删除逻辑。
   courseSectionService.removeByCourseId(courseId);
   // 删除与远程多对多父表的关联。
   // 课程和班级是多对多的关联关系,其中在橙单的教学版配置示例中,班级是主表,课程是从表,班级课程表是中间表。
   // 在这里demo中,班级和班级课程位于upms服务,而这里的课程,则位于course-paper服务,因此待删除的中间表
   // 数据,与课程接口位于不同的微服务。这就需要用到了FeignClient的远程删除接口。
   // 注意,下面的接口定义和实现,都是橙单工具为您生成的,无需手写一行。
   ResponseResult<Integer> courseResult = studentClassClient.deleteClassCourseByCourseId(courseId);
   if (!courseResult.isSuccess()) {
       // 如果远程调用失败了,这里可以获得准确的远程错误信息。
       String errorMessage = MyCommonUtil.makeDeleteRelationGlobalTransError(
               getClass(), studentClassClient.getClass(), courseResult.getErrorMessage(), courseId);
       // 在当前服务的日志中,记录准确错误信息。至于错误的异常栈,会在服务降级接口中统一处理,绝对不会漏掉的。
       log.error(errorMessage);
       try {
           // 由于Feign的降级接口自动吃掉了远程的异常错误,所以Seata的代码不会捕捉到异常,也就不会自动回滚,
           // 所以这里必须手动及时调用回滚操作。切记!
           GlobalTransactionContext.reload(RootContext.getXID()).rollback();
       } catch (TransactionException e) {
           e.printStackTrace();
       }
       // 为了保证事务的一致性,务必以异常的方式抛出错误。统一异常处理AOP,会拦截该异常,并返回给前端具体的错误信息。
       throw new MyRuntimeException(errorMessage);
   }
   return true;
}
// 完全不用担心,这些远程调用接口,都是橙单低代码工具,会根据您的配置,为您自动生成的。
// 当然如果您没有为Controller配置远程接口,我们也不会生成相关的代码,以污染您的工程的。
@FeignClient(
       // 由此可见,学生班级对象及其接口,均位于upms微服务中。
       name = "upms",
       configuration = FeignConfig.class,
       fallbackFactory = StudentClassClient.StudentClassClientFallbackFactory.class)
public interface StudentClassClient extends BaseClient<StudentClassDto, StudentClassVo, Long> {

   // 保存或更新数据。
   @Override
   @PostMapping("/studentClass/saveNewOrUpdate")
   ResponseResult<StudentClassVo> saveNewOrUpdate(@RequestBody StudentClassDto data);
 
   // 批量新增或保存数据列表。
   @Override
   @PostMapping("/studentClass/saveNewOrUpdateBatch")
   ResponseResult<Void> saveNewOrUpdateBatch(@RequestBody List<StudentClassDto> dataList);

   // 删除主键Id关联的对象。
   @Override
   @PostMapping("/studentClass/deleteById")
   ResponseResult<Integer> deleteById(@RequestParam("classId") Long classId);
 
   // 删除多对多关联关系表中,和从表id相关的所有关联数据。
   @PostMapping("/studentClass/deleteClassCourseByCourseId")
   ResponseResult<Integer> deleteClassCourseByCourseId(@RequestParam("courseId") Long courseId);
 
   // ... ... 这里省略了其他标准化远程调用接口的声明,以及服务熔断降级接口的声明。
}
```

远程数据查询关联

查询主表数据列表时,其某些字段可以来自于远程服务的字典数据和一对一关联数据,如前面介绍的课程对象,其中的 teacherId 字段所关联的数据,来自于 upms 微服务。在查询课程列表时,我们会根据实体对象中的注解信息,自动实现远程关联数据的批量查询和数据组装。

@PostMapping("/list")
public ResponseResult<MyPageData<CourseVo>> list(
       @MyRequestBody CourseDto courseDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   Course courseFilter = MyModelUtil.copyTo(courseDtoFilter, Course.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Course.class);
   // 下面的列表查询方法,会实现远程关联数据的批量查询和自动组装。
   List<Course> courseList = courseService.getCourseListWithRelation(courseFilter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(courseList, Course.INSTANCE));
}
@Override
public List<Course> getCourseListWithRelation(Course filter, String orderBy) {
   // 这里仅仅基于单表查询,获取到主表数据列表。
   List<Course> resultList = courseMapper.getCourseListEx(null, null, filter, orderBy);
   // 下面的基类方法,会识别远程调用数据关联的注解。并自动实现批量查询和数据组装。
   this.buildRelationForDataList(resultList, MyRelationParam.normal());
   return resultList;
}
@Data
@TableName(value = "zz_course")
public class Course {

   @TableId(value = "course_id")
   private Long courseId;

   @TableField(value = "course_name")
   private String courseName;

   @TableField(value = "teacher_id")
   private Long teacherId;
   @RelationOneToOne(
           // 通过课程主表中的teacherId字段进行关联。
           masterIdField = "teacherId",
           // 调用TeacherClient接口方法。
           slaveClientClass = TeacherClient.class,
           // 返回的数据对象类型是TeacherVo对象。
           slaveModelClass = TeacherVo.class,
           // 从表老师表中的关联字段是teacherId。
           slaveIdField = "teacherId")
   @TableField(exist = false)
   private TeacherVo teacher;
   // ... ... 为了缩减篇幅,这里仅仅给出需要远程关联的teacherId的注解。
}
// 下面的listByIds接口,会在TeacherController中给出具体的实现。
// 通过listByIds接口的定义可以看出,我们是批量获取从表数据的。具体步骤如下。
// 1. 根据课程主表的查询结果集,可以提取出这些课程列表中的所有teacherId集合,并去重。
// 2. 将teacherIds的集合作为参数调用TeacherClient.listByIds接口,可以一次获取所有老师对象。
// 3. 批量的读取,可以降低远程RPC调用的次数,以提升本次应该和系统整体的运行时效率。
// 4. 如果withDict为true,也可以获取从表的字典翻译字段的翻译后数据。
@FeignClient(
       name = "upms",
       configuration = FeignConfig.class,
       fallbackFactory = TeacherClient.TeacherClientFallbackFactory.class)
public interface TeacherClient extends BaseClient<TeacherDto, TeacherVo, Long> {
 
   // 基于主键的(In-list)条件获取远程数据接口。
   @Override
   @PostMapping("/teacher/listByIds")
   ResponseResult<List<TeacherVo>> listByIds(
           @RequestParam("teacherIds") Set<Long> teacherIds,
           @RequestParam("withDict") Boolean withDict);
}

远程数据聚合关联

还是以前面的班级列表为例,在橙单微服务教学版工程配置中,班级和课程分别位于 upms 和 course-paper 两个不同的微服务中,别问为什么如此拆分,该工程只是一个用于我们内部测试,以及对外演示我们代码生成能力的示例工程。具体见下图。

代码示例如下。

@PostMapping("/list")
public ResponseResult<MyPageData<StudentClassVo>> list(
       @MyRequestBody StudentClassDto studentClassDtoFilter,
       @MyRequestBody SysDeptDto sysDeptDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   StudentClass studentClassFilter = MyModelUtil.copyTo(studentClassDtoFilter, StudentClass.class);
   SysDept sysDeptFilter = MyModelUtil.copyTo(sysDeptDtoFilter, SysDept.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, StudentClass.class);
   // 下面的关联查询方法,会根据学生班级主表实体对象中的注解,进行聚合计算的动态数据关联。
   List<StudentClass> studentClassList =
           studentClassService.getStudentClassListWithRelation(studentClassFilter, sysDeptFilter, orderBy);
   return ResponseResult.success(MyPageUtil.makeResponseData(studentClassList, StudentClass.INSTANCE));
}
@Override
public List<StudentClass> getStudentClassListWithRelation(StudentClass filter, SysDept sysDeptFilter, String orderBy) {
   List<StudentClass> resultList =
           studentClassMapper.getStudentClassListEx(null, null, filter, sysDeptFilter, orderBy);
   // 这个方法在前面已经多次提到了,BaseService基类中的方法。会根据实体对象的注解,实现数据组装。
   this.buildRelationForDataList(resultList, MyRelationParam.normal());
   return resultList;
}
@Data
@TableName(value = "zz_class")
public class StudentClass {

   @TableId(value = "class_id")
   private Long classId;

   @TableField(value = "class_name")
   private String className;
 
   // 班级课程数量 (多对多聚合计算字段)。
   @RelationManyToManyAggregation(
           // 班级主表的关联字段
           masterIdField = "classId",
           // 需要通过远程FeignClient调用,获取从表聚合计算后的数据。
           slaveClientClass = CourseClient.class,
           // 中间表对象类型。
           relationModelClass = ClassCourse.class,
           // 中间表和主表关联的Id
           relationMasterIdField = "classId",
           // 中间表和从表关联的Id
           relationSlaveIdField = "courseId",
           // 从表的Vo对象。
           slaveModelClass = CourseVo.class,
           // 从表中的关联字段。
           slaveIdField = "courseId",
           // 聚合计算后的数据,所在的对象类型。
           aggregationModelClass = CourseVo.class,
           // 聚合计算类型,对从表的课时字段进行求和计算。
           aggregationType = AggregationType.SUM,
           // 参与聚合计算的从表字段,这里是课程表的课时字段。
           aggregationField = "classHour")
   @TableField(exist = false)
   private Integer totalClassHour = 0;
 
   // ... ... 为了缩减篇幅,省略了其他字段的定义。
}
// 本示例中的课程作业服务(course-paper)的CourseController接口类,会提供该API方法的实现。
// 这里需要强调的是,橙单自动生成的代码中,仍然会按照批量查询的方式,对远程对象数据进行汇总求和。
// 1. 根据班级主表的查询结果集,提取中classIds集合,并去重。
// 2. 在服务本地的中间表中,批量得到与这些classIds关联的课程Id(courseIds)集合。
// 3. 再将这些courseIds集合参数,通过一次远程调用获取远程聚合计算的结果。
// 4. 通过批量查询的方法,可以将远程RPC调用降低为一次,从而提升本次应答以及系统的整体运行时性能。
@FeignClient(
       name = "course-paper",
       configuration = FeignConfig.class,
       fallbackFactory = CourseClient.CourseClientFallbackFactory.class)
public interface CourseClient extends BaseClient<CourseDto, CourseVo, Long> {
 
   // 获取远程对象中符合查询条件的分组聚合计算Map列表。
   @Override
   @PostMapping("/course/aggregateBy")
   ResponseResult<List<Map<String, Object>>> aggregateBy(@RequestBody MyAggregationParam aggregationParam);
 
   // ... ... 为了缩减篇幅,省略了其他接口方法的定义。
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。