前言

本章节主要介绍通过橙单基础框架进行二次开发时需要注意的编码约定。当然如果你说我不想按照这个套路来可以吗,一定是可以的。但是整体的开发效率,以及日后的项目维护成本都会变得很高,具体规则主要包含以下几点。

  • 代码分层规则,以及每一层之间的调用规范。
  • 对象命名约定。
  • 增删改查接口的逻辑处理顺序。
  • 多表关联查询的数据组装规则。

层次职责

橙单的代码生成器是基于下面的分层原则来生成后端代码的,

Client

  • 可以直观的理解为,被 FeignClient 注解标注的接口,既服务间远程调用接口。

Controller

  • 服务请求的真正业务入口。
  • 验证输入数据的合法性。
  • 为 Service 层的方法调用,提供规格化的数据结构。
  • 调用 Service 方法,并处理各种调用异常。
  • 编排 Service 返回的数据。
  • 为前端或其他服务返回需要的、易于处理的数据结构。
  • Controller 中通常只调用不同 Service 中的方法,不直接调用 Dao 层方法。

Service

  • 执行真正的数据交互和逻辑处理。
  • 对于数据修改方法,要保证事务的一致性。
  • 对于查询方法,完成主表数据与关联数据的组装。
  • Service 中通常只调用主表访问层的方法,如 CourseService --> CourseMapper。   
  • Service 也可调用其他关联 Service 的方法,而不要直接调用表的 Dao 层方法。如 CourseService --> GradeService 是推荐的,而 CourseService --> GradeMapper 是不推荐的。

Dao

  • 数据访问层,执行单一的数据操作与访问逻辑。
  • 数据权限的统一拦截点。

Model

  • 实体对象中首先包含的就是数据表字段的映射。
  • 被 @TableField(exist = false) 注解标注的各种字段,如字典关联、一对一关联、聚合计算结果等。
  • 范围过滤字段,比如查询时所需的 startTime 和 endTime 等。

DTO

  • 通常为接口调用的入参域对象,微服务间接口调用的入参也是基于 DTO 的。
  • Model 中包含的字段他通常都会包含。

VO

  • 通常为接口调用的出参返回对象。
  • 与 Model 对象并非一对一的关系,除了包含 Model 中的部分数据外,还可能会包含自定义的关联数据对象。

类命名规则

Java 的代码规范,我们目前完全遵循阿里巴巴编码规范。这里只简述一下我们业务类的命名规则。

主表实体对象类名 + 业务类后缀,如年级实体类 Grade,按照职责层级分别命名为:
GradeClient / GradeDto / GradeVo / GradeController / GradeService / GradeServiceImpl / GradeMapper / Grade

逻辑处理规则

下面会列出最常用的「增删改查」等接口的逻辑处理顺序,这也是橙单代码生成器的默认生成规则。我们也强烈推荐开发者用户,在自己手写代码时,尽量与我们保持一致,相信这样一定会让您后续的开发工作变得事半功倍。在随后的小节中,我们都会先列出每个接口的逻辑处理过程,然后再给出精确匹配的代码示例。

数据新增

  • 新增接口命名为「/add」,参数为主表数据的 DTO 对象。
  • 基于 DTO 对象字段的 Validator 注解,验证数据的合法性。
  • 根据关联关系验证实体对象数据的一致性,如字典 ID 或父主键 ID 的验证等。
  • 对于微服务架构,根据关联关系,通过远程服务调用接口,验证实体对象数据的一致性。
  • 调用主表 Service 实现类的 saveNew 方法,插入数据。
  • 在 Service 实现类的 saveNew 方法中,计算新主键 ID,设置默认值字段数据,如 create_user_id、create_time、update_user_id 和 update_time 等。
  • 利用 Mybatis Plus / Mybatis Flex 的 insert 方法插入数据。
// 以下方法来自于Controller ...
// 新增接口命名为“/add”,参数为主表数据的DTO对象。
@PostMapping("/add")
public ResponseResult<JSONObject> add(@MyRequestBody("course") CourseDto courseDto) {
   // 基于DTO对象字段的Validator注解,验证数据的合法性。
   String errorMessage = MyCommonUtil.getModelValidationError(courseDto);
   if (errorMessage != null) {
       return ResponseResult.error(
               ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 到了service层,尽量不使用DTO对象作为参数,因此这里进行了对象转换。
   Course course = Course.INSTANCE.toModel(courseDto);
   // 根据关联关系验证实体对象数据的一致性,如字典ID或父主键ID的验证等。
   CallResult callResult = courseService.verifyRelatedData(course, null);
   if (!callResult.isSuccess()) {
       errorMessage = callResult.getErrorMessage();
       return ResponseResult.error(
               ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 对于微服务架构,根据关联关系,通过远程服务调用接口,验证实体对象数据的一致性。
   CallResult remoteCallResult = courseService.verifyRemoteRelatedData(course, null);
   if (!remoteCallResult.isSuccess()) {
       errorMessage = remoteCallResult.getErrorMessage();
       return ResponseResult.error(
               ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   course = courseService.saveNew(course);
   // 调用主表Service实现类的saveNew方法,插入数据。
   JSONObject responseData = new JSONObject();
   responseData.put("courseId", course.getCourseId());
   return ResponseResult.success(responseData);
}
// 以下方法来自于ServiceImpl ...
@Transactional(rollbackFor = Exception.class)
public Course saveNew(Course course) {
  // 计算新主键ID。
   course.setCourseId(idGenerator.nextLongId());
   TokenData tokenData = TokenData.takeFromRequest();
   // 设置默认值字段数据
   course.setCreateUserId(tokenData.getUserId());
   Date now = new Date();
   course.setCreateTime(now);
   course.setUpdateTime(now);
   // 利用Mybatis Plus/Mybatis Flex的insert方法插入数据
   courseMapper.insert(course);
   return course;
}

数据更新

  • 更新接口命名为「/update」,参数为主表数据的 DTO 对象。
  • 基于 DTO 对象字段的 Validator 注解,验证数据的合法性。
  • 根据 DTO 中的主键字段,判断当前修改的数据是否存在。
  • 比较新老对象之间的数据变化,判断是否存在不可变数据被设置了新值。
  • 根据关联关系验证实体对象数据的一致性,这里仅验证发生变化的关联数据,如字典 ID 等。
  • 对于微服务工程,根据关联关系,通过远程服务调用接口,验证实体对象数据的一致性。
  • 调用 Service 实现类的 update 方法,更新数据。
  • 利用 Mybatis Plus / Mybatis Flex 的 update 方法更新数据。
// 以下方法来自于Controller ...
// 更新接口命名为 “/update”,参数为主表数据的 DTO 对象。
@PostMapping("/update")
public ResponseResult<Void> update(@MyRequestBody("course") CourseDto courseDto) {
   // 基于 DTO 对象字段的 Validator 注解,验证数据的合法性。
   String errorMessage = MyCommonUtil.getModelValidationError(
           courseDto, Default.class, UpdateGroup.class);
   if (errorMessage != null) {
       return ResponseResult.error(
               ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 到了service层,尽量不使用DTO对象作为参数,因此这里进行了对象转换。
   Course course = Course.INSTANCE.toModel(courseDto);
   // 根据 DTO 中的主键字段,判断当前修改的数据是否存在。
   Course originalCourse = courseService.getById(course.getCourseId());
   if (originalCourse == null) {
       errorMessage = "数据验证失败,当前 [数据] 并不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   // 根据关联关系验证实体对象数据的一致性,这里仅验证发生变化的关联数据。
   CallResult callResult = courseService.verifyRelatedData(course, originalCourse);
   if (!callResult.isSuccess()) {
       errorMessage = callResult.getErrorMessage();
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 对于微服务工程,根据关联关系,通过远程服务调用接口,验证实体对象数据的一致性。
   CallResult remoteCallResult = 
           courseService.verifyRemoteRelatedData(course, originalCourse);
   if (!remoteCallResult.isSuccess()) {
       errorMessage = remoteCallResult.getErrorMessage();
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 这里也有可能出现重复性数据的异常,可根据业务需要进行异常捕捉。
   // 如果这里没有捕捉,框架的统一异常处理器也会统一处理。
   // 如果需要给前端返回精确错误信息,推荐在这里近距离捕捉。
   // 调用 Service 实现类的 update 方法,更新数据。
   if (!courseService.update(course, originalCourse)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}
// 以下方法来自于ServiceImpl ...
@Transactional(rollbackFor = Exception.class)
public boolean update(Course course, Course originalCourse) {
   course.setCreateUserId(originalCourse.getCreateUserId());
   course.setCreateTime(originalCourse.getCreateTime());
   course.setUpdateTime(new Date());
   // 利用 Mybatis Plus / Mybatis Flex 的 update 方法更新数据
   return courseMapper.update(course) == 1;
}

数据删除

  • 删除接口命名为「/delete」,参数为主表的主键 ID。
  • 接口参数的非空性验证。
  • 根据主键判断数据是否存在。
  • 通过树形关系判断是否存在下游数据,如下级部门,如果存在则不能直接删除。
  • 通过关联关系判断是否存在关联从属数据,如部门包含用户,如果存在则不能直接删除。
  • 对于微服务工程,根据关联关系,通过远程服务调用接口,判断是否存在下游数据。
  • 调用 Service 实现类的 remove 方法,删除数据。
  • 物理删除或逻辑删除当前主键数据。
  • 在多对多中间表中,删除该主键的关联数据。
  • 删除支持级联删除的一对多从表数据。
// 以下方法来自于Controller ...
// 删除接口命名为 “/delete”,参数为主表的主键 ID。
@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody Long deptId) {
   String errorMessage;
   // 接口参数的非空性验证。
   if (MyCommonUtil.existBlankArgument(deptId)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 根据主键判断数据是否存在。
   SysDept originalSysDept = sysDeptService.getById(deptId);
   if (originalSysDept == null) {
       errorMessage = "数据验证失败,当前 [对象] 并不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   // 通过树形关系判断是否存在下游数据,如下级部门,如果存在则不能直接删除。
   if (sysDeptService.hasChildren(deptId)) {
       errorMessage = "数据验证失败,当前 [对象存在子对象],请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.HAS_CHILDREN_DATA, errorMessage);
   }
   // 通过关联关系判断是否存在关联从属数据,如部门包含用户,如果存在则不能直接删除。
   CallResult callResult = 
           sysDeptService.verifyRelatedDataBeforeDelete(originalSysDept);
   if (!callResult.isSuccess()) {
       errorMessage = callResult.getErrorMessage();
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATAED_FAILED, errorMessage);
   }
   // 调用 Service 实现类的 remove 方法,删除数据
   if (!sysDeptService.remove(deptId)) {
       errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!";
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
   }
   return ResponseResult.success();
}
// 删除前主动验证是否包含从属数据,比如部门中包含用户。
public CallResult verifyRelatedDataBeforeDelete(SysDept sysDept) {
   // 这里验证本地一对多从表数据是否存在。
   SysUser sysUser = new SysUser();
   sysUser.setDeptId(sysDept.getDeptId());
   if (sysUserService.getCountByFilter(sysUser) > 0) {
       //NOTE: 可以根据需求修改下面方括号中的提示信息。
       return CallResult.error("数据验证失败,[SysUser] 存在关联数据!");
   }
   return CallResult.ok();
}
// 以下方法来自于ServiceImpl ...
@Transactional(rollbackFor = Exception.class)
public boolean remove(Long deptId) {
   // 物理删除或逻辑删除当前主键数据。
   if (sysDeptMapper.deleteById(deptId) == 0) {
       return false;
   }
   // 在多对多中间表中,删除该主键的关联数据。
   SysDeptRelation deptRelation = new SysDeptRelation();
   deptRelation.setDeptId(deptId);
   sysDeptRelationMapper.delete(new QueryWrapper<>(deptRelation));
   SysDataPermDept dataPermDept = new SysDataPermDept();
   dataPermDept.setDeptId(deptId);
   sysDataPermDeptMapper.delete(new QueryWrapper<>(dataPermDept));
   return true;
}

列表查询

  • 列表查询接口命名为「/list」,参数通常为主表过滤对象、一对一从表过滤对象、排序和分页对象。
  • PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条 SQL 语句生效,再后面的语句将不会被分页。
  • 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
  • 调用 Service 的查询方法。先通过过滤、排序和分页等条件获取主表数据,之后再根据主表实体对象中的 Java 注解进行关联数据组装。如 @RelationConstDict 和 @RelationDict 等以 Relation 开头的注解。
  • Controller 接口方法会将 Service 查询结果,从实体对象列表转换为 VO 对象列表后返回给前端。
// 来自于Controller接口 ...
// 列表查询接口命名为 “/list”,参数通常为主表过滤对象、一对一从表过滤对象、排序和分页对象。
@PostMapping("/list")
public ResponseResult<JSONObject> list(
       @MyRequestBody VideoDto videoDtoFilter,
       @MyRequestBody KnowledgeDto knowledgeDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   // PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条SQL语句生效,再后面的语句将不会被分页。
   // 在本例中是指getVideoListWithRelation里面的第一条SQL查询语句
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   Video videoFilter = Video.INSTANCE.toModel(videoDtoFilter);
   Knowledge knowledgeFilter =
           Knowledge.INSTANCE.toModel(knowledgeDtoFilter);
   // 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Video.class);
   // 调用主表 Service 的查询方法。
   List<Video> videoList = videoService.getVideoListWithRelation(videoFilter, knowledgeFilter, orderBy);
   // 将 Service 查询结果从实体对象列表转换为 VO 对象列表后返回给前端。
   return ResponseResult.success(MyPageUtil.makeResponseData(responseData));
}
// 来自于Service的列表查询方法 ...
public List<Video> getVideoListWithRelation(
       Video filter, Knowledge knowledgeFilter, String orderBy) {
   // 先通过过滤、排序和分页等条件获取主表数据
   List<Video> resultList = 
           videoMapper.getVideoListEx(null, null, filter, knowledgeFilter, orderBy);
   // 根据主表实体对象中的 Java 注解进行关联数据组装
   this.buildAllRelationForDataList(resultList, MyRelationParam.normal(), criteriaMap);
   return resultList;
}

字典接口

  • 字典列表接口命名为「/listDict」,该接口仅以键值对的形式返回字典数据。
  • 字典表字典列表接口没有参数。
  • 数据源字典列表接口参数为实体对象。
  • 树形字典的子数据列表查询接口为「/listDictByParentId」,参数为 parentId,同样以键值对的形式返回字典数据。
  • 批量查询接口命名为「/listDictByIds」,接口参数是字典 ID 的集合,同样以键值对的形式返回匹配的字典数据列表。
// 字典表字典列表接口没有参数。
@GetMapping("/listDict")
public ResponseResult<List<Map<String, Object>>> listDict() {
   List<Grade> resultList = gradeService.getAllListFromCache();
   return ResponseResult.success(
           MyCommonUtil.toDictDataList(resultList, Grade::getGradeId, Grade::getGradeName));
}
// 数据源字典列表接口参数为实体对象。
@GetMapping("/listDict")
public ResponseResult<List<Map<String, Object>>> listDict(SysDept filter) {
   List<SysDept> resultList = sysDeptService.getListByFilter(filter);
   return ResponseResult.success(MyCommonUtil.toDictDataList(
           resultList, SysDept::getDeptId, SysDept::getDeptName, SysDept::getParentId));
}
// 树形字典的子数据列表查询接口参数为parentId,同样以键值对的形式返回字典数据。
@GetMapping("/listDictByParentId")
public ResponseResult<List<Map<String, Object>>> listDictByParentId(@RequestParam(required = false) Long parentId) {
   List<SysDept> resultList = sysDeptService.getListByParentId("parentId", parentId);
   return ResponseResult.success(MyCommonUtil.toDictDataList(
           resultList, SysDept::getDeptId, SysDept::getDeptName, SysDept::getParentId));
}
// 批量查询接口参数是字典ID的集合,同样以键值对的形式返回匹配的字典数据列表。
@PostMapping("/listDictByIds")
public ResponseResult<List<Map<String, Object>>> listDictByIds(@MyRequestBody List<Long> dictIds) {
   List<SysDept> resultList = sysDeptService.getInList(new HashSet<>(dictIds));
   return ResponseResult.success(MyCommonUtil.toDictDataList(
           resultList, SysDept::getDeptId, SysDept::getDeptName, SysDept::getParentId));
}

分组查询

  • 分组查询接口命名为「/listWithGroup」,参数通常为主表过滤对象、分组、排序和分页对象。
  • 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
  • 构建分组对象,这里没有直接使用前端参数作为 GROUP BY 从句,可以从机制上规避 SQL 注入的风险。
  • PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条 SQL 语句生效,再后面的语句将不会被分页。
  • 调用 Service 的分组查询方法。先通过过滤、分组、排序和分页等条件获取计算后数据,之后再根据主表实体对象中的 Java 注解进行关联数据组装。如 @RelationConstDict 和 @RelationDict 等以 Relation 开头的注解。
  • Controller 接口方法会将 Service 查询结果,从实体对象列表转换为 VO 对象列表后返回给前端。
// 来自于Controller接口 ...
// 分组查询接口命名为 “/listWithGroup”,参数通常为主表过滤对象、分组、排序和分页对象
@PostMapping("/listWithGroup")
public ResponseResult<JSONObject> listWithGroup(
       @MyRequestBody("videoStatsFilter") VideoStatsDto videoStatsDtoFilter,
       @MyRequestBody MyGroupParam groupParam,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   // 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
   String orderBy = MyOrderParam.buildOrderBy(orderParam, VideoStats.class);
   // 构建分组对象,这里没有直接使用前端参数作为 GROUP BY 从句,可以从机制上规避 SQL 注入的风险。
   groupParam = MyGroupParam.buildGroupBy(groupParam, VideoStats.class);
   if (groupParam == null) {
       return ResponseResult.error(
               ErrorCodeEnum.INVALID_ARGUMENT_FORMAT, "数据参数错误,分组参数不能为空!");
   }
   // PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条SQL语句生效,再后面的语句将不会被分页。
   // 在本例中是指getGroupedVideoStatsListWithRelation里面的第一条SQL查询语句
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   VideoStats filter = VideoStats.INSTANCE.toModel(videoStatsDtoFilter);
   MyGroupCriteria criteria = groupParam.getGroupCriteria();
   // 调用 Service 的分组查询方法。先通过过滤、分组、排序和分页等条件获取计算后数据。
   List<VideoStats> resultList = videoStatsService.getGroupedVideoStatsListWithRelation(
           filter, criteria.getGroupSelect(), criteria.getGroupBy(), orderBy);
   // 将分组查询结果列表,从实体对象列表转换为VO对象列表后返回给前端。
   JSONObject responseData = MyPageUtil.makeResponseData(resultList, VideoStats.INSTANCE);
   return ResponseResult.success(responseData);
}
// 来自于Service的分组查询方法 ...
public List<VideoStats> getGroupedVideoStatsListWithRelation(
   VideoStats filter, String groupSelect, String groupBy, String orderBy) {
   // 只有这一条SQL会被PageHelper分页,后面在执行的SQL不会被分页了。
   List<VideoStats> resultList =
           videoStatsMapper.getGroupedVideoStatsList(filter, groupSelect, groupBy, orderBy);
   int batchSize = resultList instanceof Page ? 0 : 1000;
   // 根据主表实体对象中的Java注解进行关联数据组装
   this.buildRelationForDataList(resultList, MyRelationParam.normal(), batchSize);
   return resultList;
}

多对多新增

本小节的代码是以「作业和习题」的多对多关系为例的。

  • 多对多新增接口为「/addPaperExercise」,其命名规则是「add + 中间表实体类名」,如本例的 PaperExercise。
  • 接口包含两个参数,分别是主表主键 ID 和多对多关联中间表的 DTO 对象列表,
  • 对输入参数进行非空性验证。
  • 分别验证主表主键 ID 和新增从表主键 ID 列表的合法性,既他们在各自的表中是否都存在。
  • 调用 Service 的 addPaperExerciseList 方法,批量插入数据。
  • 批量插入多对多关联数据。
// 来自于Controller接口 ...
// 多对多新增接口为“/addPaperExercise”,其命名规则是“add + 中间表实体类名”,如本例的PaperExercise。
// 接口包含两个参数,分别是主表主键 ID 和多对多关联中间表的 DTO 对象列表,
@PostMapping("/addPaperExercise")
public ResponseResult<?> addPaperExercise(
      @MyRequestBody Long paperId,
           @MyRequestBody List<PaperExerciseDto> paperExerciseDtoList) {
   // 对输入参数进行非空性验证。
   if (MyCommonUtil.existBlankArgument(paperId, paperExerciseDtoList)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   Set<Long> exerciseIdSet = paperExerciseDtoList.stream()
           .map(PaperExerciseDto::getExerciseId).collect(Collectors.toSet());
   // 分别验证主表主键 ID 和新增从表主键 ID 列表的合法性,既他们在各自的表中是否都存在。
   if (!paperService.existId(paperId)
           || !exerciseService.existUniqueKeyList("exerciseId", exerciseIdSet)) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID);
   }
   List<PaperExercise> paperExerciseList =
           MyModelUtil.copyCollectionTo(paperExerciseDtoList, PaperExercise.class);
   // 调用 Service 的 addPaperExerciseList 方法,批量插入数据。
   paperService.addPaperExerciseList(paperExerciseList, paperId);
   return ResponseResult.success();
}
// 来自于Service的方法 ...
@Transactional(rollbackFor = Exception.class)
public void addPaperExerciseList(List<PaperExercise> paperExerciseList) {
   // 批量插入多对多关联数据。
   paperMapper.addPaperExerciseList(paperExerciseList);
}

多对多更新

本小节继续使用「作业和习题」的多对多关联为例。

  • 多对多更新接口「/updatePaperExercise」的命名规则是「update + 中间表实体类名」,如本例的 PaperExercise。
  • 接口参数为中间表 DTO 对象。
  • 对中间表实体对象的字段,进行基于 Validator 的合法性验证。
  • 调用 Service 的 updatePaperExercise 方法,更新单条中间表数据,这里只能更新主表和从表主键 ID 之外的其他字段。
// 来自于Controller接口 ...
// 多对多更新接口 “/updatePaperExercise” 的命名规则是 “update + 中间表实体类名”,如本例的 PaperExercise。
// 接口参数为中间表 DTO 对象。
@PostMapping("/updatePaperExercise")
public ResponseResult<Void> updatePaperExercise(@MyRequestBody PaperExerciseDto paperExerciseDto) {
   // 对中间表实体对象的字段,进行基于 Validator 的合法性验证。
   String errorMessage = MyCommonUtil.getModelValidationError(paperExerciseDto);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   PaperExercise paperExercise = MyModelUtil.copyTo(paperExerciseDto, PaperExercise.class);
   // 调用 Service 的 updatePaperExercise 方法,更新单条中间表数据。
   if (!paperService.updatePaperExercise(paperExercise)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}
// 来自于Service的方法 ...
@Transactional(rollbackFor = Exception.class)
public boolean updatePaperExercise(PaperExercise paperExercise) {
   // 只能更新主表和从表主键 ID 之外的其他字段。
   PaperExercise filter = new PaperExercise();
   filter.setPaperId(paperExercise.getPaperId());
   filter.setExerciseId(paperExercise.getExerciseId());
   UpdateWrapper<PaperExercise> uw =
           BaseService.createUpdateQueryForNullValue(paperExercise, PaperExercise.class);
   uw.setEntity(filter);
   return paperExerciseMapper.update(paperExercise, uw) > 0;
}

多对多移除

  • 多对多移除接口「/deletePaperExercise」的命名规则是「delete + 中间表实体类名」,如本例的 PaperExercise。
  • 接口参数待移除的为主表和从表的主键 ID。
  • 接口参数的非空性验证。
  • 调用 Service 的 removePaperExercise 方法,直接从中间表中移除该条记录。
// 来自于Controller接口 ...
// 多对多移除接口 “/deletePaperExercise” 的命名规则是 “delete + 中间表实体类名”,如本例的 PaperExercise。
// 接口参数待移除的为主表和从表的主键 ID。
@PostMapping("/deletePaperExercise")
public ResponseResult<?> deletePaperExercise(
       @MyRequestBody Long paperId, @MyRequestBody Long exerciseId) {
   // 接口参数的非空性验证。
   if (MyCommonUtil.existBlankArgument(paperId, exerciseId)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 调用 Service 的 removePaperExercise 方法,直接从中间表中移除该条记录。
   if (!paperService.removePaperExercise(paperId, exerciseId)) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   return ResponseResult.success();
}
// 来自于Service的方法 ...
@Transactional(rollbackFor = Exception.class)
public boolean removePaperExercise(Long paperId, Long exerciseId) {
   PaperExercise filter = new PaperExercise();
   filter.setPaperId(paperId);
   filter.setExerciseId(exerciseId);
   return paperExerciseMapper.delete(new QueryWrapper<>(filter)) > 0;
}

多对多列表

  • 多对多列表查询接口「/listPaperExercise」的命名规则是「list + 中间表实体类名」,如本例的 PaperExercise。
  • 接口参数通常为主表关联 ID、关联从表过滤对象、关联中间表过滤对象、排序和分页对象。
  • 验证主表关联 ID 是否存在。
  • PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条 SQL 语句生效,再后面的语句将不会被分页。
  • 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
  • 调用 Service 的查询方法。先通过过滤、排序和分页等条件获取查询后数据列表,之后再根据主表实体对象中的 Java 注解进行关联数据组装。如 @RelationConstDict 和 @RelationDict 等以 Relation 开头的注解。
  • Controller 接口方法会将 Service 查询结果,从实体对象列表转换为 VO 对象列表后返回给前端。
// 来自于Controller接口 ...
// 多对多列表查询接口 “/listPaperExercise” 的命名规则是 “list + 中间表实体类名”,如本例的 PaperExercise。
// 接口参数通常为主表关联 ID、关联从表过滤对象、关联中间表过滤对象、排序和分页对象。
@PostMapping("/listPaperExercise")
public ResponseResult<?> listPaperExercise(
       @MyRequestBody Long paperId,
       @MyRequestBody ExerciseDto exerciseDtoFilter,
       @MyRequestBody PaperExercise paperExerciseFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   // 验证主表关联 ID 是否存在。
   if (!paperService.existId(paperId)) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_RELATED_RECORD_ID);
   }
   // PageHelper 分页对象存在一个小坑,即分页操作仅对随后的第一条 SQL 语句生效,再后面的语句将不会被分页。
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   Exercise filter = MyModelUtil.copyTo(exerciseDtoFilter, Exercise.class);
   // 构建排序对象,这里没有直接使用前端参数作为 ORDER BY 从句,可以从机制上规避 SQL 注入的风险。
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Exercise.class);
   PaperExercise paperExerciseFilter =
           MyModelUtil.copyTo(paperExerciseDtoFilter, PaperExercise.class);
   // 调用 Service 的查询方法。先通过过滤、排序和分页等条件获取查询后数据列表。
   List<Exercise> exerciseList =
           exerciseService.getExerciseListByPaperId(paperId, filter, paperExerciseFilter, orderBy);
   // Controller 接口方法会将 Service 查询结果,从实体对象列表转换为 VO 对象列表后返回给前端。
   return ResponseResult.success(MyPageUtil.makeResponseData(exerciseList, Exercise.INSTANCE));
}
// 来自于Service的方法 ...
public List<Exercise> getExerciseListByPaperId(
   Long paperId, Exercise filter, PaperExercise paperExerciseFilter, String orderBy) {
   // 这里只有这条SQL查询,会受到Controller中PageHelper分页的影响,后面的SQL则不会。
   List<Exercise> resultList = exerciseMapper.getExerciseListByPaperId(
           paperId, filter, paperExerciseFilter, orderBy);
  // 根据主表实体对象中的 Java 注解进行关联数据组装。
   this.buildRelationForDataList(resultList, MyRelationParam.dictOnly());
   this.buildRemoteRelationForDataList(resultList, MyRelationParam.dictOnly());
   return resultList;
}

上传下载

这里主要介绍一下上传下载操作中,如何读写数据文件,这部分逻辑在这两个接口中是一致的。

  • 读取接口参数字段 fieldName,然后去数据对象中查看该字段是否包含 @UploadFlagColumn 注解,如果没有包含就直接返回错误信息给前端。
  • 如果支持,则从注解中获取该字段数据的存储类型,如本地或 Minio。
  • 根据存储类型,通过 UpDownloader 的工厂方法获取具体的实现类。
  • 最后通过实现类,完成具体的文件上传和下载操作。下面仅给出 upload 请求接口的部分实现代码。
@PostMapping("/upload")
public void upload(
       @RequestParam String fieldName,
       @RequestParam Boolean asImage,
       @RequestParam("uploadFile") MultipartFile uploadFile) throws Exception {
   UploadStoreInfo storeInfo = 
         MyModelUtil.getUploadStoreInfo(Video.class, fieldName);
   if (!storeInfo.isSupportUpload()) {
       ResponseResult.output(HttpServletResponse.SC_FORBIDDEN,
               ResponseResult.error(ErrorCodeEnum.INVALID_UPLOAD_FIELD));
       return;
   }
   BaseUpDownloader upDownloader = 
           upDownloaderFactory.get(storeInfo.getStoreType());
   UploadResponseInfo responseInfo = upDownloader.doUpload(null,
           appConfig.getUploadFileBaseDir(), Video.class.getSimpleName(), fieldName, asImage, uploadFile);
}

最后解释一下,这样的设计方式,可以使上传下载字段在变更数据存储类型时,仅需修改注解的参数值 (storeType) 即可,从而使得业务逻辑和底层基础服务最大化解耦。另外一个不得不说的好处是,底层存储服务的封装 Jar,仅需在需要的时候被依赖进来,并在启动时自动完成存储类型和实现类的注册,这样可以让我们仅仅集成所需的依赖 Jar,从而尽可能的减少业务服务 Jar 的 Size。在当前版本中,本地存储的实现类为 LocalUpDownloader,Minio 分布式存储的实现类为 MinioUpDownloader。

其他接口

以下接口相对简单,推荐参考橙单默认生成的代码处理逻辑。

  • 明细查看接口 (view),返回单条记录。关联数据组装过程和 list 完全一致。
  • 明细打印接口 (print),获取数据逻辑等同于 view 接口。该接口支持自定义模板的数据绑定和打印。
  • 列表导出接口 (export),获取数据的逻辑等同于 list 和 listWithGroup。导出的文件格式支持 xlsx 和 csv。
  • 字典数据列表 (listDict),以 key/value 形式返回数据列表,通常用于前端的下拉列表。
  • 多对多未关联数据列表 (listNotIn${中间表实体类名}),流程几乎等同于多对多关联列表接口,只是返回相反的数据列表。

结语

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