Skip to content

通用Base层抽象设计与统一API响应封装:构建企业级脚手架的基础设施

作者: 必码 | bima.cc


前言

在 Java 企业级开发中,有一个被反复讨论却始终没有被完美解决的问题:如何优雅地消除重复代码。当项目从几十个接口增长到数百个接口时,开发者会发现大量 CRUD 操作的代码结构高度雷同——Service 层的增删改查逻辑如出一辙,Controller 层的响应封装千篇一律,Mapper 层的 SQL 映射模式固定不变。这种重复不仅浪费开发时间,更可怕的是,当团队规范需要调整时(比如统一修改响应格式),意味着要对数百个文件进行逐一修改。

传统的解决方案通常有两种:一种是通过代码生成工具(如 MyBatis Generator)批量生成样板代码,另一种是借助 MyBatis-Plus 等框架提供的通用 Mapper 来减少数据访问层的重复。然而,这两种方案都存在明显的局限性——前者只是将重复代码的编写工作交给了工具,代码量并没有减少;后者虽然解决了数据访问层的问题,但对 Service 层和 Controller 层的重复逻辑无能为力。

真正的解决方案是在架构层面建立一套完善的 Base 层抽象体系。通过精心设计的泛型基类、模板方法模式和统一的响应封装,让业务代码只需要关注差异化的逻辑,而将通用的增删改查、参数校验、结果封装等工作交给框架来完成。

本文基于 smart-scaffold 系列项目的真实源码,深入剖析 BaseMapper、BaseService、BaseResult、PageDTO、PageEntity 等核心组件的设计思路与实现细节。我们不会堆砌完整的源码,而是通过精心简化的教学示例,帮助读者理解每一个设计决策背后的"为什么"。无论你是正在设计企业级脚手架的技术负责人,还是希望提升代码抽象能力的资深开发者,这篇文章都将为你提供一套经过生产验证的设计方案。


一、企业级项目 Base 层设计的必要性

1.1 消除重复代码的痛点

在企业级 Java 项目中,重复代码是一个普遍存在且代价高昂的问题。让我们从一个典型的场景开始:假设你的项目需要管理用户、部门、角色、权限、字典、配置等十几个基础数据实体,每个实体都需要标准的增删改查功能。

教学示例——未经抽象的典型 Service 实现:

java
// 教学示例:未经抽象的 UserService
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public UserDTO getById(Long id) {
        UserEntity entity = userMapper.selectByPrimaryKey(id);
        if (entity == null) {
            throw new BizException("用户不存在");
        }
        return convertToDTO(entity);
    }

    public void save(UserDTO dto) {
        // 参数校验
        if (StringUtils.isBlank(dto.getName())) {
            throw new BizException("用户名不能为空");
        }
        UserEntity entity = convertToEntity(dto);
        if (dto.getId() == null) {
            userMapper.insertSelective(entity);
        } else {
            userMapper.updateByPrimaryKeySelective(entity);
        }
    }

    public void delete(Long id) {
        UserEntity entity = userMapper.selectByPrimaryKey(id);
        if (entity == null) {
            throw new BizException("用户不存在");
        }
        userMapper.deleteByPrimaryKey(id);
    }

    public PageEntity<UserDTO> selectPage(UserQueryDTO query) {
        // 分页参数处理
        if (query.getIsPage() == null || !query.getIsPage()) {
            query.setIsPage(true);
        }
        if (query.getPage() == null) query.setPage(1);
        if (query.getPageSize() == null) query.setPageSize(10);
        query.setStart((query.getPage() - 1) * query.getPageSize());
        query.setEnd(query.getPage() * query.getPageSize());

        List<UserEntity> list = userMapper.selectBy(query);
        int count = userMapper.countBy(query);

        List<UserDTO> dtoList = list.stream()
            .map(this::convertToDTO)
            .collect(Collectors.toList());

        PageEntity<UserDTO> page = new PageEntity<>();
        page.setList(dtoList);
        page.setPage(query.getPage());
        page.setPageSize(query.getPageSize());
        page.setCount(count);
        return page;
    }
}

当项目中有十几个类似的 Service 时,你会发现每个 Service 都包含几乎完全相同的结构:参数校验、空值判断、分页计算、DTO 转换、异常抛出。真正差异化的业务逻辑可能只占整个代码量的 20%,剩下的 80% 都是重复的样板代码。

这种重复代码带来的问题远不止"看着不爽"那么简单:

维护成本急剧上升。当团队决定统一修改分页参数的默认值(比如将默认页大小从 10 改为 20),你需要修改十几个 Service 中的分页逻辑。如果某个开发者遗漏了某个 Service,就会导致系统中出现不一致的行为。

Bug 修复不彻底。假设你发现分页计算存在边界问题(比如当 pageSize 为 0 时会导致 SQL 错误),修复后需要确保所有 Service 都应用了相同的修复。在缺乏 Base 层抽象的项目中,这种修复往往是不彻底的,总会有几个角落被遗漏。

新人上手困难。新加入团队的成员需要阅读大量结构相似的代码才能理解项目的编码模式。每个 Service 都有自己微妙的差异(比如参数校验的写法不同、异常消息的格式不同),这些差异增加了认知负担。

代码审查效率低下。审查者在审查一个新增的 Service 时,需要逐行检查是否遵循了团队的编码规范,而不是将注意力集中在真正的业务逻辑上。

1.2 统一编码规范的价值

Base 层抽象不仅仅是消除重复代码的技术手段,更是**将团队编码规范从"约定"升级为"约束"**的架构策略。

在没有 Base 层的项目中,编码规范通常以文档的形式存在——开发者在编写新代码时需要参考规范文档,确保自己的代码风格与团队一致。但文档规范存在天然的执行弱点:开发者可能忘记阅读、可能理解偏差、可能故意偏离。代码审查虽然能发现部分问题,但审查者的精力有限,不可能检查每一个细节。

Base 层抽象通过继承约束将规范固化到代码结构中:

java
// 教学示例:BaseService 中的模板方法约束
public abstract class BaseService<T, M extends BaseMapper<T>> {

    /**
     * 子类必须实现的查询参数预处理钩子
     * 规范:所有条件查询必须经过此方法预处理
     */
    protected void handleQueryParam(Object queryDTO) {
        // 默认空实现,子类按需覆盖
    }

    /**
     * 子类必须实现的查询结果后处理钩子
     * 规范:所有查询结果必须经过此方法后处理
     */
    protected void handleQueryResult(List<T> list) {
        // 默认空实现,子类按需覆盖
    }

    /**
     * 子类必须实现的保存前校验钩子
     * 规范:所有保存操作必须经过此方法校验
     */
    protected void checkSaveInput(T entity) {
        // 默认空实现,子类按需覆盖
    }

    /**
     * 子类必须实现的删除前校验钩子
     * 规范:所有删除操作必须经过此方法校验
     */
    protected void checkRemove(T entity) {
        // 默认空实现,子类按需覆盖
    }
}

通过这种设计,团队的编码规范不再是"建议",而是"架构约束"。开发者编写新的 Service 时,继承 BaseService 即可自动获得统一的参数处理、结果封装、异常处理能力。如果需要自定义行为,只需覆盖对应的钩子方法,而不是重写整个流程。

这种约束带来的价值是深远的:

一致性。所有 Service 的行为模式保持一致,无论是参数校验、分页处理还是异常抛出,都遵循相同的逻辑路径。这种一致性极大地降低了系统的认知复杂度。

可预测性。当开发者熟悉了一个 Service 的行为模式后,就能准确预测其他 Service 的行为。这种可预测性使得代码阅读和调试变得更加高效。

可维护性。当需要调整全局行为时(比如修改分页逻辑、增加日志记录),只需要修改 Base 层的实现,所有子类自动继承新的行为。

可测试性。Base 层的通用逻辑可以集中测试,子类只需要测试差异化的业务逻辑,大幅降低了测试工作量。

1.3 泛型抽象的设计原则

泛型是 Java 语言中实现类型安全的代码复用的核心机制。在 Base 层设计中,泛型的正确使用决定了整个抽象体系的灵活性和类型安全性。

泛型约束的核心思想是:在编译期就确保类型的一致性,将运行时可能出现的类型错误提前到编译期暴露。

java
// 教学示例:BaseMapper 的泛型约束设计
public interface BaseMapper<T> {

    int insertSelective(T entity);

    int deleteByPrimaryKey(Long id);

    T selectByPrimaryKey(Long id);

    int updateByPrimaryKeySelective(T entity);

    List<T> selectBy(Object queryDTO);

    int countBy(Object queryDTO);

    T uniqueBy(Object queryDTO);
}

在这个设计中,泛型参数 T 代表实体类型。当 UserMapper 继承 BaseMapper<UserEntity> 时,selectByPrimaryKey 方法的返回类型自动被约束为 UserEntity,而不是需要开发者手动进行类型转换。

泛型抽象的设计需要遵循几条核心原则:

最小知识原则。Base 层不应该知道太多关于具体实体类型的信息。泛型参数 T 的约束应该尽可能宽松,只在必要时添加边界约束。比如,如果 Base 层需要访问实体的 ID,可以通过定义一个 getId() 方法的接口来约束,而不是要求 T 继承某个特定的基类。

里氏替换原则。所有继承 Base 层的子类都应该能正确地替换基类使用。这意味着 Base 层定义的契约(方法签名、前置条件、后置条件)必须被所有子类严格遵守。如果某个子类需要违反基类的契约,这通常意味着抽象设计存在问题。

开闭原则。Base 层应该对扩展开放,对修改关闭。通过模板方法模式和钩子方法,Base 层提供了丰富的扩展点,使得子类可以在不修改 Base 层代码的情况下定制行为。

单一职责原则。每个 Base 类应该只负责一个维度的抽象。BaseMapper 负责数据访问的通用逻辑,BaseService 负责业务逻辑的通用编排,BaseResult 负责响应封装的统一格式。职责的清晰划分使得每个 Base 类都能保持简洁和可维护。

在 smart-scaffold 项目中,这些原则得到了充分的体现。BaseMapper 只定义了 8 个最通用的数据访问方法,BaseService 通过模板方法模式提供了 4 个标准的扩展钩子,BaseResult 通过静态工厂方法提供了多种便捷的响应构建方式。每一层都有清晰的职责边界,每一层都通过泛型确保了类型安全。


二、BaseMapper 通用数据访问接口

2.1 泛型约束设计:BaseMapper<T>

BaseMapper 是整个 Base 层抽象体系的地基。它定义了数据访问层的通用契约,所有具体的 Mapper 接口都继承自 BaseMapper,从而自动获得标准的 CRUD 能力。

在 smart-scaffold 项目中,BaseMapper 的设计遵循"最小通用集"原则——只抽取所有实体都需要的、行为完全一致的方法,而不试图覆盖所有可能的数据访问场景。

java
// 教学示例:BaseMapper 核心定义
public interface BaseMapper<T> {

    /**
     * 选择性插入:只插入非null字段
     * @param entity 实体对象
     * @return 影响行数
     */
    int insertSelective(T entity);

    /**
     * 根据主键删除
     * @param id 主键值
     * @return 影响行数
     */
    int deleteByPrimaryKey(Long id);

    /**
     * 根据主键查询
     * @param id 主键值
     * @return 实体对象,不存在时返回null
     */
    T selectByPrimaryKey(Long id);

    /**
     * 选择性更新:只更新非null字段
     * @param entity 实体对象(必须包含主键)
     * @return 影响行数
     */
    int updateByPrimaryKeySelective(T entity);

    /**
     * 条件查询列表
     * @param queryDTO 查询条件对象
     * @return 实体列表
     */
    List<T> selectBy(Object queryDTO);

    /**
     * 条件统计数量
     * @param queryDTO 查询条件对象
     * @return 匹配记录数
     */
    int countBy(Object queryDTO);

    /**
     * 条件唯一查询
     * @param queryDTO 查询条件对象
     * @return 唯一实体,多条或零条时返回null
     */
    T uniqueBy(Object queryDTO);
}

这个接口设计中有几个值得深入讨论的细节。

泛型参数 T 的选择T 代表实体类型(Entity),而不是 DTO 类型。这是因为在数据访问层,操作的对象应该是与数据库表结构一一对应的实体类,而不是面向前端的数据传输对象。Entity 和 DTO 的分离是分层架构的基本要求——Entity 可能包含数据库特有的字段(如创建时间戳、逻辑删除标记),而 DTO 只包含业务需要的字段。

方法命名遵循 MyBatis 惯例insertSelectiveupdateByPrimaryKeySelective 等方法名与 MyBatis Generator 自动生成的方法名保持一致。这种一致性不是偶然的——smart-scaffold 项目使用 MyBatis Generator 生成 Mapper XML 中的 SQL 语句,BaseMapper 的方法名与生成器输出的方法名对齐,使得 BaseMapper 可以直接调用生成器生成的 SQL 实现。

selectByuniqueBy 的参数类型是 Object。这是一个有意的设计决策。查询条件对象(QueryDTO)的类型因业务而异——UserQueryDTO、DeptQueryDTO、RoleQueryDTO 等——它们之间没有共同的父类(除了 Object)。将参数类型设为 Object,使得 BaseMapper 不需要为每种查询条件定义泛型参数,简化了接口签名。类型安全由 MyBatis 的 XML 映射在运行时保证。

uniqueBy 的语义约定。当查询结果有多条记录时,uniqueBy 返回 null 而不是抛出异常。这是一个"宽容"的设计——调用方需要自行处理"期望唯一但实际不唯一"的情况。这种设计在实际开发中更加灵活,因为不同的业务场景可能需要不同的处理策略。

2.2 八个通用方法详解

BaseMapper 定义了 8 个通用方法(含 insertSelective 的重载变体),覆盖了数据访问的最常见场景。让我们逐一分析每个方法的设计考量。

insertSelective —— 选择性插入

java
// 教学示例:insertSelective 的 SQL 映射
<insert id="insertSelective" parameterType="cc.bima.scaffold.entity.UserEntity">
    INSERT INTO sys_user
    <trim prefix="(" suffix=")" suffixOverrides=",">
        <if test="name != null">name,</if>
        <if test="email != null">email,</if>
        <if test="phone != null">phone,</if>
        <if test="status != null">status,</if>
        <if test="createTime != null">create_time,</if>
    </trim>
    <trim prefix="VALUES (" suffix=")" suffixOverrides=",">
        <if test="name != null">#{name},</if>
        <if test="email != null">#{email},</if>
        <if test="phone != null">#{phone},</if>
        <if test="status != null">#{status},</if>
        <if test="createTime != null">#{createTime},</if>
    </trim>
</insert>

"选择性插入"意味着只插入非 null 的字段。这与"全量插入"(insert)形成对比——全量插入会将所有字段都写入 SQL,包括值为 null 的字段,这可能导致数据库默认值被 null 覆盖。

在实际开发中,insertSelective 是更安全的选择。它允许数据库为未设置的字段使用默认值(如自增主键、默认状态码、当前时间戳等),同时也避免了将不必要的 null 值写入数据库。

deleteByPrimaryKey —— 主键删除

java
// 教学示例:deleteByPrimaryKey 的 SQL 映射
<delete id="deleteByPrimaryKey">
    DELETE FROM sys_user WHERE id = #{id}
</delete>

这是最简单的删除方式,直接根据主键删除记录。在实际项目中,物理删除通常只用于无关紧要的数据(如日志、临时表)。对于核心业务数据,更常见的做法是实现逻辑删除(通过 is_deleted 字段标记),但这属于业务层面的决策,不应该在 BaseMapper 中强制约束。

selectByPrimaryKey —— 主键查询

java
// 教学示例:selectByPrimaryKey 的 SQL 映射
<select id="selectByPrimaryKey" parameterType="java.lang.Long"
        resultType="cc.bima.scaffold.entity.UserEntity">
    SELECT id, name, email, phone, status, create_time
    FROM sys_user
    WHERE id = #{id}
</select>

根据主键查询单条记录,返回实体对象。如果记录不存在,返回 null。调用方需要自行处理 null 的情况——这也是 BaseService 中 get 方法封装了空值检查和异常抛出的原因。

updateByPrimaryKeySelective —— 选择性更新

java
// 教学示例:updateByPrimaryKeySelective 的 SQL 映射
<update id="updateByPrimaryKeySelective"
        parameterType="cc.bima.scaffold.entity.UserEntity">
    UPDATE sys_user
    <set>
        <if test="name != null">name = #{name},</if>
        <if test="email != null">email = #{email},</if>
        <if test="phone != null">phone = #{phone},</if>
        <if test="status != null">status = #{status},</if>
    </set>
    WHERE id = #{id}
</update>

insertSelective 类似,"选择性更新"只更新非 null 的字段。这意味着调用方可以通过将不需要更新的字段设为 null 来实现部分更新。这种设计在"编辑表单"场景中特别有用——前端只提交用户修改的字段,后端只更新这些字段,其他字段保持不变。

selectBy —— 条件查询列表

java
// 教学示例:selectBy 的 SQL 映射
<select id="selectBy" parameterType="cc.bima.scaffold.dto.UserQueryDTO"
        resultType="cc.bima.scaffold.entity.UserEntity">
    SELECT id, name, email, phone, status, create_time
    FROM sys_user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
        <if test="startTime != null">
            AND create_time >= #{startTime}
        </if>
        <if test="endTime != null">
            AND create_time &lt;= #{endTime}
        </if>
    </where>
    ORDER BY create_time DESC
</select>

条件查询是最灵活也是最复杂的方法。查询条件通过 QueryDTO 传入,SQL 中使用 <if> 标签动态拼接 WHERE 子句。MyBatis 的 <where> 标签会自动处理 AND 前缀的问题,避免条件为空时产生语法错误。

countBy —— 条件统计

sql
-- 教学示例:countBy 的 SQL 映射
SELECT COUNT(*) FROM sys_user
<where>
    <if test="name != null and name != ''">
        AND name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="status != null">
        AND status = #{status}
    </if>
</where>

countBy 的查询条件与 selectBy 保持一致,确保分页查询时总数统计与数据查询使用相同的过滤条件。这是分页功能正确性的基础——如果 countByselectBy 的过滤条件不一致,分页信息就会出现错误。

uniqueBy —— 唯一查询

java
// 教学示例:uniqueBy 的 SQL 映射
<select id="uniqueBy" parameterType="cc.bima.scaffold.dto.UserQueryDTO"
        resultType="cc.bima.scaffold.entity.UserEntity">
    SELECT id, name, email, phone, status, create_time
    FROM sys_user
    <where>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
    </where>
    LIMIT 1
</select>

uniqueBy 用于根据唯一条件查询单条记录。SQL 中使用 LIMIT 1 确保只返回一条记录,即使实际有多条匹配。当业务上需要确保唯一性时(如根据邮箱查用户),调用方应该在查询后检查结果数量,或者在数据库层面建立唯一索引。

2.3 与 MyBatis-Plus 的 BaseMapper 对比

MyBatis-Plus 是目前 Java 生态中最流行的 MyBatis 增强框架,它也提供了一个 BaseMapper 接口。将 smart-scaffold 的 BaseMapper 与 MyBatis-Plus 的 BaseMapper 进行对比,有助于理解 smart-scaffold 的设计取舍。

MyBatis-Plus 的 BaseMapper 方法列表(部分):

java
// 教学示例:MyBatis-Plus BaseMapper 的核心方法
public interface BaseMapper<T> extends Mapper<T> {
    int insert(T entity);
    int deleteById(Serializable id);
    int deleteByMap(Map<String, Object> columnMap);
    int delete(Wrapper<T> queryWrapper);
    int updateById(@Param("et") T entity);
    int update(T entity, Wrapper<T> updateWrapper);
    T selectById(Serializable id);
    List<T> selectByMap(Map<String, Object> columnMap);
    T selectOne(Wrapper<T> queryWrapper);
    Long selectCount(Wrapper<T> queryWrapper);
    List<T> selectList(Wrapper<T> queryWrapper);
    IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper);
    // ... 更多方法
}

核心差异分析:

维度smart-scaffold BaseMapperMyBatis-Plus BaseMapper
方法数量8 个17+ 个
条件构造基于 QueryDTO + XML基于 Wrapper(Lambda/QueryWrapper)
SQL 控制完全手写 XML自动生成 + 可自定义
分页支持通过 PageDTO 手动控制内置 IPage 分页插件
逻辑删除不内置,按需实现内置逻辑删除插件
主键策略不内置内置多种主键生成策略
依赖要求仅 MyBatisMyBatis-Plus Starter

smart-scaffold 选择自研 BaseMapper 的原因:

完全掌控 SQL。MyBatis-Plus 的 Wrapper 虽然方便,但在复杂查询场景下(多表关联、子查询、窗口函数)会变得笨拙。smart-scaffold 的设计哲学是"SQL 归 XML,逻辑归 Java"——所有 SQL 语句都在 XML 文件中维护,开发者可以完全掌控 SQL 的执行细节,便于性能优化和问题排查。

轻量级依赖。smart-scaffold 的 BaseMapper 不依赖任何第三方框架(除了 MyBatis 本身),这使得它可以在任何 MyBatis 环境中使用,不受 MyBatis-Plus 版本升级的影响。

与 Generator 无缝集成。BaseMapper 的方法名与 MyBatis Generator 生成的默认方法名完全一致,这意味着 Generator 生成的 Mapper XML 可以直接作为 BaseMapper 方法的实现,无需任何修改。

更简洁的学习曲线。8 个方法 vs 17+ 个方法,smart-scaffold 的 BaseMapper 更容易上手。开发者不需要学习 Wrapper 的复杂语法,只需要熟悉标准的 MyBatis XML 映射即可。

当然,MyBatis-Plus 的 BaseMapper 也有其优势——它提供了更丰富的开箱即用功能(如逻辑删除、自动填充、乐观锁等),适合快速开发场景。smart-scaffold 的选择是在"灵活性"和"便捷性"之间做出了偏向灵活性的取舍。

2.4 在三种架构中的复用情况

BaseMapper 作为数据访问层的抽象,在 smart-scaffold 的三种架构中的复用情况有所不同,这反映了不同架构对数据访问层的不同定位。

SpringBoot 单体架构中的 BaseMapper

在 SpringBoot 版中,BaseMapper 定义在 smart-scaffold-common 模块中,被 smart-scaffold-dao 模块中的所有 Mapper 接口继承。这是最完整的使用方式——所有 Mapper 接口都继承 BaseMapper,所有通用方法都可以直接使用。

java
// 教学示例:SpringBoot 版中的 Mapper 继承
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
    // 继承 BaseMapper 的 8 个通用方法
    // 可以添加自定义的查询方法
    List<UserEntity> selectByRole(Long roleId);
}

Dubbo 分布式架构中的 BaseMapper

在 Dubbo 版中,情况有所不同。BaseMapper 定义在 smart-scaffold-api 模块中,但 API 模块只定义接口,不包含实现。真正的 Mapper 接口和 XML 映射文件位于 smart-scaffold-provider 模块中。Provider 模块中的 Mapper 接口继承 API 模块中的 BaseMapper,从而在服务提供者内部实现了数据访问的统一抽象。

java
// 教学示例:Dubbo 版中的模块关系
// smart-scaffold-api 模块
public interface BaseMapper<T> { ... }

// smart-scaffold-provider 模块
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> { ... }

这种设计确保了 BaseMapper 的定义在 API 模块中是可见的(因为 Provider 依赖 API),但具体的 SQL 实现被封装在 Provider 内部,Consumer 无法直接访问。

SpringCloud 微服务架构中的 BaseMapper

在 SpringCloud 版中,BaseMapper 定义在 smart-scaffold-common 模块中。与 Dubbo 版类似,具体的 Mapper 接口和 XML 映射文件位于 smart-scaffold-provider 模块中。但由于 SpringCloud 使用 HTTP/Feign 进行服务间通信,Consumer 端完全不涉及 MyBatis 和 Mapper,BaseMapper 的复用仅限于 Provider 内部。

java
// 教学示例:SpringCloud 版中的 BaseMapper 使用
// smart-scaffold-common 模块
public interface BaseMapper<T> { ... }

// smart-scaffold-provider 模块
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> { ... }

// smart-scaffold-consumer 模块
// 不涉及 BaseMapper,通过 Feign 客户端调用 Provider 的 API
@FeignClient(name = "scaffold-provider")
public interface UserFace {
    BaseResult<PageEntity<UserDTO>> selectPage(UserQueryDTO query);
}

三种架构的复用对比总结:

维度SpringBootDubboSpringCloud
BaseMapper 定义位置commonapicommon
BaseMapper 使用范围dao 模块provider 模块provider 模块
Consumer 是否可见N/A(单体)不可见不可见
继承关系完整度完整完整完整

可以看到,无论在哪种架构中,BaseMapper 在 Provider 内部的使用方式是一致的。架构的差异主要体现在 BaseMapper 定义的位置和跨模块的可见性上,而不是 BaseMapper 本身的设计。


三、BaseService 业务逻辑基类

3.1 泛型继承 BaseMapper 的设计

BaseService 是 smart-scaffold 脚手架中最重要的抽象层之一。它位于 Service 层,封装了业务逻辑的通用编排模式,让子类只需关注差异化的业务规则。

BaseService 的泛型设计比 BaseMapper 更复杂,因为它需要同时管理实体类型和 Mapper 类型:

java
// 教学示例:BaseService 的泛型定义
public abstract class BaseService<T, M extends BaseMapper<T>> {

    @Autowired
    protected M mapper;

    /**
     * 获取实体(带空值检查)
     */
    public T get(Long id) {
        T entity = mapper.selectByPrimaryKey(id);
        if (entity == null) {
            throw new BizException("数据不存在");
        }
        return entity;
    }

    /**
     * 删除实体(带存在性检查)
     */
    public void remove(Long id) {
        T entity = mapper.selectByPrimaryKey(id);
        if (entity == null) {
            throw new BizException("数据不存在");
        }
        checkRemove(entity);
        mapper.deleteByPrimaryKey(id);
    }
}

泛型参数解析:

  • T:实体类型(如 UserEntity),与 BaseMapper 的泛型参数一致
  • M extends BaseMapper<T>:Mapper 类型(如 UserMapper),约束为 BaseMapper 的子类

这个泛型约束确保了类型安全:mapper.selectByPrimaryKey(id) 返回的类型是 T,而不是 Object,避免了运行时的类型转换错误。

@Autowired 注入的泛型子类

java
// 教学示例:BaseService 子类的 Mapper 注入
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    // mapper 字段由 BaseService 中的 @Autowired 自动注入
    // 注入的具体类型是 UserMapper
    // 无需在 UserService 中重复声明
}

Spring 的依赖注入机制能够正确处理泛型字段的注入。当 UserService 继承 BaseService<UserEntity, UserMapper> 时,Spring 会将容器中的 UserMapper Bean 注入到 mapper 字段中。这种设计使得子类完全不需要关心 Mapper 的获取方式。

为什么 BaseService 是抽象类而不是接口?

BaseService 选择抽象类而非接口,是因为它需要提供默认实现。接口只能定义方法签名,而 BaseService 中的 getremoveselectPageBy 等方法都有完整的默认实现。子类继承抽象类后,可以直接使用这些方法,也可以覆盖它们来定制行为。

如果使用接口 + 默认方法(Java 8+),虽然也能实现类似的效果,但抽象类可以声明实例字段(如 protected M mapper),而接口的字段默认是 public static final 的,无法满足注入 Mapper 的需求。

3.2 模板方法模式:四大钩子方法

模板方法模式(Template Method Pattern)是 BaseService 设计的核心。它将业务操作的算法骨架定义在 Base 层,将可变的部分延迟到子类实现。

BaseService 定义了四个标准的钩子方法:

java
// 教学示例:BaseService 的四大钩子方法
public abstract class BaseService<T, M extends BaseMapper<T>> {

    /**
     * 查询参数预处理
     * 在执行查询前调用,用于设置默认值、格式化参数等
     */
    protected void handleQueryParam(Object queryDTO) {
        // 默认空实现
    }

    /**
     * 查询结果后处理
     * 在查询完成后调用,用于数据转换、权限过滤等
     */
    protected void handleQueryResult(List<T> list) {
        // 默认空实现
    }

    /**
     * 保存前校验
     * 在插入或更新前调用,用于业务规则校验
     */
    protected void checkSaveInput(T entity) {
        // 默认空实现
    }

    /**
     * 删除前校验
     * 在删除前调用,用于检查是否允许删除
     */
    protected void checkRemove(T entity) {
        // 默认空实现
    }
}

handleQueryParam —— 查询参数预处理

这个钩子在查询执行前被调用,典型的使用场景包括:

java
// 教学示例:handleQueryParam 的典型用法
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Override
    protected void handleQueryParam(Object queryDTO) {
        if (queryDTO instanceof UserQueryDTO) {
            UserQueryDTO query = (UserQueryDTO) queryDTO;
            // 设置默认排序
            if (query.getOrder() == null) {
                query.setOrder("create_time DESC");
            }
            // 处理时间范围查询
            if (query.getEndTime() != null) {
                // 将结束时间延长到当天 23:59:59
                query.setEndTime(DateUtils.endOfDay(query.getEndTime()));
            }
        }
    }
}

通过 handleQueryParam,BaseService 可以在执行查询前统一处理分页参数、排序规则、时间范围等通用逻辑,而子类只需要覆盖这个方法来添加特定的参数处理逻辑。

handleQueryResult —— 查询结果后处理

这个钩子在查询完成后被调用,典型的使用场景包括:

java
// 教学示例:handleQueryResult 的典型用法
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Override
    protected void handleQueryResult(List<UserEntity> list) {
        // 脱敏处理:隐藏手机号中间四位
        for (UserEntity entity : list) {
            if (StringUtils.isNotBlank(entity.getPhone())) {
                entity.setPhone(entity.getPhone().replaceAll(
                    "(\\d{3})\\d{4}(\\d{4})", "$1****$2"
                ));
            }
        }
    }
}

checkSaveInput —— 保存前校验

这个钩子在插入或更新操作前被调用,用于执行业务规则校验:

java
// 教学示例:checkSaveInput 的典型用法
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Override
    protected void checkSaveInput(UserEntity entity) {
        // 用户名唯一性校验
        UserQueryDTO query = new UserQueryDTO();
        query.setName(entity.getName());
        UserEntity existing = mapper.uniqueBy(query);
        if (existing != null && !existing.getId().equals(entity.getId())) {
            throw new BizException("用户名已存在");
        }
        // 邮箱格式校验
        if (StringUtils.isNotBlank(entity.getEmail())
            && !entity.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[\\w]+$")) {
            throw new BizException("邮箱格式不正确");
        }
    }
}

checkRemove —— 删除前校验

这个钩子在删除操作前被调用,用于检查是否允许删除:

java
// 教学示例:checkRemove 的典型用法
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Override
    protected void checkRemove(UserEntity entity) {
        // 不允许删除超级管理员
        if ("admin".equals(entity.getName())) {
            throw new BizException("超级管理员不允许删除");
        }
        // 检查用户是否有关联数据
        if (entity.getOrderCount() > 0) {
            throw new BizException("该用户存在关联订单,不允许删除");
        }
    }
}

模板方法的执行流程

这四个钩子方法在 BaseService 的核心方法中被有组织地调用。以分页查询为例:

java
// 教学示例:selectPageBy 中的模板方法调用流程
public PageEntity<T> selectPageBy(Object queryDTO) {
    // 第一步:参数预处理(调用钩子方法)
    handleQueryParam(queryDTO);

    // 第二步:执行查询
    List<T> list = mapper.selectBy(queryDTO);
    int count = mapper.countBy(queryDTO);

    // 第三步:结果后处理(调用钩子方法)
    handleQueryResult(list);

    // 第四步:封装分页结果
    PageEntity<T> page = new PageEntity<>();
    page.setList(list);
    page.setPage(getPage(queryDTO));
    page.setPageSize(getPageSize(queryDTO));
    page.setCount(count);
    return page;
}

这种设计将业务操作的流程固化在 Base 层,而将可变的部分通过钩子方法暴露给子类。开发者编写新的 Service 时,只需要关注"这个查询需要什么特殊的参数处理"和"这个保存需要什么特殊的校验规则",而不需要关心整个操作的流程编排。

3.3 便捷方法体系

除了模板方法模式,BaseService 还提供了一系列便捷方法,进一步简化了子类的开发工作。

get 方法 —— 安全获取

java
// 教学示例:BaseService.get 方法
public T get(Long id) {
    T entity = mapper.selectByPrimaryKey(id);
    if (entity == null) {
        throw new BizException("数据不存在");
    }
    return entity;
}

get 方法是对 selectByPrimaryKey 的安全封装。它自动处理了"数据不存在"的情况,抛出统一的业务异常。调用方不需要每次都写 if (entity == null) 的检查逻辑。

remove 方法 —— 安全删除

java
// 教学示例:BaseService.remove 方法
public void remove(Long id) {
    T entity = get(id);  // 复用 get 方法进行存在性检查
    checkRemove(entity);  // 调用删除前校验钩子
    mapper.deleteByPrimaryKey(id);
}

remove 方法组合了三个步骤:存在性检查、业务校验、执行删除。注意它复用了 get 方法来进行存在性检查,避免了代码重复。

selectPageBy 方法 —— 分页查询

java
// 教学示例:BaseService.selectPageBy 方法
public PageEntity<T> selectPageBy(Object queryDTO) {
    handleQueryParam(queryDTO);

    List<T> list;
    int count;

    if (isPageQuery(queryDTO)) {
        // 数据库分页
        list = mapper.selectBy(queryDTO);
        count = mapper.countBy(queryDTO);
    } else {
        // 全量查询
        list = mapper.selectBy(queryDTO);
        count = list.size();
    }

    handleQueryResult(list);

    PageEntity<T> page = new PageEntity<>();
    page.setList(list);
    page.setPage(getPage(queryDTO));
    page.setPageSize(getPageSize(queryDTO));
    page.setCount(count);
    return page;
}

selectPageBy 方法是 BaseService 中最复杂的方法之一。它根据查询条件中的 isPage 标志决定是执行数据库分页还是全量查询,并统一封装分页结果。

selectBy 方法 —— 列表查询

java
// 教学示例:BaseService.selectBy 方法
public List<T> selectBy(Object queryDTO) {
    handleQueryParam(queryDTO);
    List<T> list = mapper.selectBy(queryDTO);
    handleQueryResult(list);
    return list;
}

selectByselectPageBy 的简化版本,不包含分页逻辑,直接返回完整的列表。

uniqueBy 方法 —— 唯一查询

java
// 教学示例:BaseService.uniqueBy 方法
public T uniqueBy(Object queryDTO) {
    handleQueryParam(queryDTO);
    return mapper.uniqueBy(queryDTO);
}

uniqueBy 方法封装了唯一查询的逻辑,包括参数预处理。调用方可以根据返回值是否为 null 来判断是否存在匹配的记录。

3.4 三种架构中的演进

BaseService 在 smart-scaffold 的三种架构中呈现出不同的演进形态,反映了不同架构对业务逻辑层的不同要求。

SpringBoot 架构:最完整的 BaseService

在 SpringBoot 版中,BaseService 定义在 smart-scaffold-common 模块中,提供了最完整的通用能力。所有 Service 类都继承 BaseService,直接使用其提供的便捷方法和模板方法。

java
// 教学示例:SpringBoot 版中的典型 Service
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Override
    protected void checkSaveInput(UserEntity entity) {
        // 业务校验逻辑
    }

    @Override
    protected void checkRemove(UserEntity entity) {
        // 删除校验逻辑
    }

    // 其他业务方法...
}

SpringBoot 版的 BaseService 是最"纯粹"的版本,因为它不需要考虑跨进程通信的复杂性。所有方法都在同一个 JVM 中执行,事务管理通过 Spring 的 @Transactional 注解即可实现。

Dubbo 架构:BaseService 与 Face 接口的协作

在 Dubbo 版中,BaseService 仍然存在于 Provider 内部,但对外暴露的是 Face 接口(定义在 API 模块中)。Face 接口定义了服务契约,BaseService 提供了实现基础。

java
// 教学示例:Dubbo 版中的 Face 接口与 Service 实现
// smart-scaffold-api 模块
public interface UserFace {
    BaseResult<UserDTO> get(Long id);
    BaseResult<Void> save(UserDTO dto);
    BaseResult<PageEntity<UserDTO>> selectPage(UserQueryDTO query);
}

// smart-scaffold-provider 模块
@Service
public class UserService extends BaseService<UserEntity, UserMapper>
        implements UserFace {

    @Override
    public BaseResult<UserDTO> get(Long id) {
        UserEntity entity = super.get(id);
        return BaseResult.success(convertToDTO(entity));
    }

    @Override
    public BaseResult<Void> save(UserDTO dto) {
        UserEntity entity = convertToEntity(dto);
        checkSaveInput(entity);
        if (entity.getId() == null) {
            mapper.insertSelective(entity);
        } else {
            mapper.updateByPrimaryKeySelective(entity);
        }
        return BaseResult.success();
    }
}

在 Dubbo 版中,BaseService 的模板方法和便捷方法仍然被 Service 实现类使用,但 Service 实现类还需要负责 DTO 与 Entity 之间的转换,以及将结果封装为 BaseResult。这是因为 Face 接口的入参和出参都是 DTO 类型,而不是 Entity 类型。

SpringCloud 架构:BaseService 与 Feign 客户端的协作

在 SpringCloud 版中,BaseService 的使用方式与 Dubbo 版类似,但服务间通信通过 Feign(HTTP)实现,而不是 Dubbo(RPC)。

java
// 教学示例:SpringCloud 版中的 Feign 客户端定义
// smart-scaffold-common 模块
@FeignClient(name = "scaffold-provider", path = "/user")
public interface UserFace {
    @GetMapping("/get/{id}")
    BaseResult<UserDTO> get(@PathVariable("id") Long id);

    @PostMapping("/save")
    BaseResult<Void> save(@RequestBody UserDTO dto);

    @PostMapping("/selectPage")
    BaseResult<PageEntity<UserDTO>> selectPage(@RequestBody UserQueryDTO query);
}

SpringCloud 版的 Face 接口使用 Spring MVC 的注解(@GetMapping@PostMapping 等)来定义 HTTP 映射,而 Dubbo 版的 Face 接口是纯粹的 Java 接口。这是两种架构在服务间通信层面的根本差异。

三种架构中 BaseService 的演进对比:

维度SpringBootDubboSpringCloud
BaseService 位置commonprovidercommon
对外接口形式ControllerFace 接口(RPC)Face 接口(HTTP/Feign)
DTO 转换位置Service 内部Service 实现 Face 时Service 实现 Face 时
事务管理@Transactional@Transactional(Provider 内)@Transactional(Provider 内)
模板方法可用性完整可用完整可用完整可用

可以看到,BaseService 的核心设计(模板方法、便捷方法、泛型约束)在三种架构中保持一致。架构的差异主要体现在服务间通信的层面,而不是业务逻辑层的抽象方式。


四、BaseResult 统一 API 响应封装

4.1 继承 ApiResult 的设计

BaseResult 是 smart-scaffold 脚手架中统一 API 响应格式的核心组件。它继承自 ApiResult,提供了标准化的响应结构,确保所有 API 接口返回一致的数据格式。

java
// 教学示例:ApiResult 基类定义
public class ApiResult<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 响应码 */
    private Integer code;

    /** 响应消息 */
    private String message;

    /** 响应数据 */
    private T data;

    // getter/setter 方法...
}

ApiResult 定义了三个核心字段:

  • code:响应码,用于标识请求的处理结果。0 表示成功,非 0 表示各种类型的失败。
  • message:响应消息,用于向调用方传递处理结果的描述信息。
  • data:响应数据,泛型参数,可以是任意类型的业务数据。

ApiResult 实现了 Serializable 接口,这是为了支持在分布式环境中的序列化传输。在 Dubbo 架构中,Face 接口的返回值需要通过网络传输,因此必须是可序列化的。在 SpringCloud 架构中,Feign 客户端使用 JSON 进行序列化,Serializable 接口虽然不是必需的,但保持一致性是有价值的。

BaseResult 在 ApiResult 的基础上增加了链式调用能力和丰富的静态工厂方法:

java
// 教学示例:BaseResult 的核心定义
@Data
@Accessors(chain = true)
public class BaseResult<T> extends ApiResult<T> {

    // 静态工厂方法将在下一节详细介绍
}

@Accessors(chain = true) 的作用

Lombok 的 @Accessors(chain = true) 注解使得 setter 方法返回当前对象(this),从而支持链式调用:

java
// 教学示例:BaseResult 的链式调用
BaseResult<UserDTO> result = new BaseResult<UserDTO>()
    .setCode(0)
    .setMessage("操作成功")
    .setData(userDTO);

链式调用使得响应对象的构建更加简洁,特别是在需要设置多个字段时,避免了重复的变量名书写。

4.2 链式调用与静态工厂方法

BaseResult 提供了一系列静态工厂方法,用于快速构建各种类型的响应对象。这些方法的设计遵循"语义化"原则——方法名直接表达了响应的语义,调用方不需要关心 code 和 message 的具体值。

success 方法族 —— 成功响应

java
// 教学示例:BaseResult.success 的多种重载
public class BaseResult<T> extends ApiResult<T> {

    /** 无数据的成功响应 */
    public static <T> BaseResult<T> success() {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(BaseResultEnum.SUCCESS.getCode());
        result.setMessage(BaseResultEnum.SUCCESS.getMessage());
        return result;
    }

    /** 携带数据的成功响应 */
    public static <T> BaseResult<T> success(T data) {
        BaseResult<T> result = success();
        result.setData(data);
        return result;
    }

    /** 携带消息的成功响应 */
    public static <T> BaseResult<T> success(String message) {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(BaseResultEnum.SUCCESS.getCode());
        result.setMessage(message);
        return result;
    }

    /** 携带数据和消息的成功响应 */
    public static <T> BaseResult<T> success(String message, T data) {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(BaseResultEnum.SUCCESS.getCode());
        result.setMessage(message);
        result.setData(data);
        return result;
    }
}

四种 success 重载覆盖了最常见的成功响应场景:

  • success():用于删除、更新等不需要返回数据的操作
  • success(data):用于查询操作,返回业务数据
  • success(message):用于需要自定义成功消息的场景
  • success(message, data):用于同时需要自定义消息和数据的场景

fail 方法族 —— 失败响应

java
// 教学示例:BaseResult.fail 的多种重载
public class BaseResult<T> extends ApiResult<T> {

    /** 默认失败响应 */
    public static <T> BaseResult<T> fail() {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(BaseResultEnum.FAIL.getCode());
        result.setMessage(BaseResultEnum.FAIL.getMessage());
        return result;
    }

    /** 携带消息的失败响应 */
    public static <T> BaseResult<T> fail(String message) {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(BaseResultEnum.FAIL.getCode());
        result.setMessage(message);
        return result;
    }

    /** 携带响应码和消息的失败响应 */
    public static <T> BaseResult<T> fail(Integer code, String message) {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(code);
        result.setMessage(message);
        return result;
    }

    /** 基于枚举的失败响应 */
    public static <T> BaseResult<T> fail(BaseResultEnum resultEnum) {
        BaseResult<T> result = new BaseResult<>();
        result.setCode(resultEnum.getCode());
        result.setMessage(resultEnum.getMessage());
        return result;
    }
}

fail 方法族提供了更灵活的失败响应构建方式:

  • fail():使用默认的失败码和消息
  • fail(message):使用默认失败码,自定义消息
  • fail(code, message):完全自定义响应码和消息
  • fail(resultEnum):基于枚举构建,确保响应码和消息的一致性

静态工厂方法 vs 构造函数

使用静态工厂方法而非构造函数来创建 BaseResult 实例,有几个重要的优势:

语义化BaseResult.success(data)new BaseResult<>(0, "操作成功", data) 更清晰地表达了意图。调用方不需要记住成功码是 0 还是 200,也不需要知道默认的成功消息是什么。

缓存优化。静态工厂方法可以缓存频繁使用的实例(如无数据的成功响应),减少对象创建的开销。虽然在大多数场景下这种优化微不足道,但在高并发的 API 网关场景中,减少 GC 压力是有价值的。

返回类型灵活性。静态工厂方法可以返回子类型的实例,而构造函数只能返回当前类型的实例。这为未来的扩展留下了空间。

命名灵活性。静态工厂方法可以有多个名称相同但参数不同的方法(重载),而构造函数的参数列表必须唯一。

4.3 successPage 内存级分页截取

在 smart-scaffold 项目中,分页有两种实现方式:数据库分页和内存分页。successPage 方法提供了内存级分页的能力,适用于数据量较小或数据源不支持分页的场景。

java
// 教学示例:BaseResult.successPage 方法
public class BaseResult<T> extends ApiResult<T> {

    /**
     * 内存级分页:从完整列表中截取指定页的数据
     * @param fullList 完整数据列表
     * @param page 页码(从1开始)
     * @param pageSize 每页大小
     * @return 包含分页数据的成功响应
     */
    public static <T> BaseResult<PageEntity<T>> successPage(
            List<T> fullList, Integer page, Integer pageSize) {

        PageEntity<T> pageEntity = new PageEntity<>();
        int total = fullList.size();

        // 计算分页范围
        int start = (page - 1) * pageSize;
        int end = Math.min(start + pageSize, total);

        // 边界检查
        if (start >= total) {
            pageEntity.setList(Collections.emptyList());
        } else {
            pageEntity.setList(fullList.subList(start, end));
        }

        pageEntity.setPage(page);
        pageEntity.setPageSize(pageSize);
        pageEntity.setCount(total);

        return BaseResult.success(pageEntity);
    }
}

内存分页的适用场景:

  • 数据量较小的配置数据。如字典表、枚举值列表等,通常只有几十到几百条记录,全量加载到内存后再分页是合理的。
  • 聚合查询结果。当数据来自多个数据源,无法在数据库层面进行统一分页时,可以先全量查询再在内存中分页。
  • 缓存数据。当数据已经被缓存在内存中(如 Redis 缓存的列表),直接在内存中分页可以避免额外的数据库查询。
  • 第三方 API 数据。当调用第三方 API 获取数据,且第三方不支持分页时,需要在本地进行分页。

内存分页的风险与注意事项:

内存分页的最大风险是全量加载可能导致 OOM。如果数据量很大(如超过 10 万条),将所有数据加载到内存中不仅消耗大量内存,还会导致 GC 频繁触发,影响系统性能。

因此,successPage 方法应该只在确认数据量可控的场景中使用。对于大数据量的分页查询,应该使用数据库分页(通过 PageDTO 的 isPage 参数控制)。

java
// 教学示例:Controller 中选择分页策略
@RestController
@RequestMapping("/dict")
public class DictController {

    @Autowired
    private DictService dictService;

    /**
     * 字典列表查询:使用内存分页(数据量小)
     */
    @PostMapping("/list")
    public BaseResult<PageEntity<DictDTO>> list(DictQueryDTO query) {
        List<DictEntity> allData = dictService.selectBy(query);
        return BaseResult.successPage(allData, query.getPage(), query.getPageSize());
    }

    /**
     * 用户列表查询:使用数据库分页(数据量大)
     */
    @PostMapping("/userList")
    public BaseResult<PageEntity<UserDTO>> userList(UserQueryDTO query) {
        PageEntity<UserEntity> page = dictService.selectPageBy(query);
        return BaseResult.success(page);
    }
}

4.4 BaseResultEnum 响应码枚举

BaseResultEnum 定义了系统中所有标准响应码,是响应码体系的"字典"。通过枚举来管理响应码,可以确保响应码和消息的一致性,避免在代码中出现"魔法数字"。

java
// 教学示例:BaseResultEnum 枚举定义
public enum BaseResultEnum {

    SUCCESS(0, "操作成功"),
    FAIL(1, "操作失败"),
    NO_AUTH(403, "无权限访问"),
    NOT_FOUND(404, "资源不存在"),
    SERVER_ERROR(500, "服务器内部错误"),
    PARAM_ERROR(400, "参数错误"),
    TOKEN_EXPIRED(401, "登录已过期,请重新登录"),
    TOO_MANY_REQUESTS(429, "请求过于频繁,请稍后再试");

    private final Integer code;
    private final String message;

    BaseResultEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

响应码设计原则:

语义化。每个响应码都有明确的语义,调用方可以根据响应码判断请求的处理结果,而不需要解析消息文本。这对于前端国际化特别重要——前端可以根据响应码显示对应语言的错误消息,而不是硬编码中文消息。

与 HTTP 状态码对齐NO_AUTH(403)NOT_FOUND(404)SERVER_ERROR(500) 等响应码与 HTTP 状态码保持一致,降低了开发者的认知负担。但需要注意的是,这些响应码是业务层面的响应码,不是 HTTP 状态码。在实际项目中,HTTP 状态码通常统一返回 200(表示请求已到达服务器并被处理),业务层面的成功或失败通过响应体中的 code 字段来区分。

可扩展性。枚举可以方便地添加新的响应码,而不影响已有的代码。当业务需要新的错误类型时,只需在枚举中添加一个新的常量即可。

在 Controller 中的使用示例:

java
// 教学示例:Controller 中使用 BaseResultEnum
@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/get/{id}")
    public BaseResult<UserDTO> get(@PathVariable Long id) {
        UserDTO user = userService.getDTO(id);
        if (user == null) {
            return BaseResult.fail(BaseResultEnum.NOT_FOUND);
        }
        return BaseResult.success(user);
    }

    @PostMapping("/save")
    public BaseResult<Void> save(@RequestBody UserDTO dto) {
        try {
            userService.save(dto);
            return BaseResult.success();
        } catch (BizException e) {
            return BaseResult.fail(e.getMessage());
        }
    }
}

响应码体系的扩展策略:

在实际项目中,除了 BaseResultEnum 定义的全局响应码外,每个业务模块通常还需要定义自己的业务响应码。smart-scaffold 的做法是在 BaseResultEnum 中定义通用的响应码,业务特定的响应码通过 fail(code, message) 方法直接传入:

java
// 教学示例:业务特定响应码的使用
public class OrderResultEnum {
    public static final Integer ORDER_NOT_EXIST = 1001;
    public static final Integer ORDER_STATUS_ERROR = 1002;
    public static final Integer ORDER_CANCEL_TIMEOUT = 1003;
}

// 使用方式
return BaseResult.fail(OrderResultEnum.ORDER_NOT_EXIST, "订单不存在");

这种策略在保持全局响应码一致性的同时,也为业务模块提供了灵活的自定义空间。


五、PageDTO 与 PageEntity 分页封装

5.1 PageDTO 分页查询参数

PageDTO 是 smart-scaffold 脚手架中分页查询的参数基类。它封装了分页查询所需的通用参数,所有具体的 QueryDTO 都继承 PageDTO,从而自动获得分页能力。

java
// 教学示例:PageDTO 核心定义
@Data
public class PageDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 查询字段(逗号分隔) */
    private String fields;

    /** 排序规则 */
    private String order;

    /** 是否分页 */
    private Boolean isPage;

    /** 当前页码(从1开始) */
    private Integer page;

    /** 每页大小 */
    private Integer pageSize;

    /** 计算字段:起始位置(SQL LIMIT 偏移量) */
    private Integer start;

    /** 计算字段:结束位置 */
    private Integer end;
}

各字段的详细说明:

fields —— 查询字段选择

fields 参数允许前端指定需要返回的字段,实现"按需查询"。这在移动端场景中特别有用——移动端通常只需要部分字段,全量返回会浪费带宽。

sql
-- 教学示例:fields 参数的 SQL 映射
SELECT
<if test="fields != null and fields != ''">
    ${fields}
</if>
<if test="fields == null or fields == ''">
    id, name, email, phone, status, create_time
</if>
FROM sys_user

需要注意的是,fields 参数使用 ${} 而不是 #{} 进行 SQL 注入。${} 是 MyBatis 的字符串替换(直接拼接 SQL),而 #{} 是参数化查询(预编译)。使用 ${} 存在 SQL 注入的风险,因此在使用 fields 参数时,必须在 Service 层进行白名单校验,确保只允许已知的字段名。

order —— 排序规则

order 参数允许前端指定查询结果的排序方式。典型的值如 "create_time DESC""name ASC, create_time DESC"

sql
-- 教学示例:order 参数的 SQL 映射
<if test="order != null and order != ''">
    ORDER BY ${order}
</if>
<if test="order == null or order == ''">
    ORDER BY create_time DESC
</if>

fields 类似,order 参数也使用 ${} 进行字符串替换,同样存在 SQL 注入的风险。在生产环境中,应该对 order 参数进行严格的白名单校验。

isPage —— 分页开关

isPage 参数是一个布尔值,用于控制是否执行分页查询。当 isPagetrue 时,SQL 中会添加 LIMIT 子句;当 isPagefalse 时,返回全部数据。

这个设计解决了"列表选择"场景的需求——比如下拉框中的选项列表,通常不需要分页,只需要返回全部数据。通过 isPage 参数,前端可以用同一个接口同时满足分页查询和全量查询的需求。

page 和 pageSize —— 分页参数

page 表示当前页码(从 1 开始),pageSize 表示每页的记录数。这两个参数通常由前端传入。

start 和 end —— 计算字段

startend 是由 BaseService 的 handleQueryParam 方法根据 pagepageSize 计算得出的,用于 SQL 的 LIMIT 子句:

java
// 教学示例:分页参数的计算逻辑
protected void handleQueryParam(Object queryDTO) {
    if (queryDTO instanceof PageDTO) {
        PageDTO pageDTO = (PageDTO) queryDTO;
        if (pageDTO.getIsPage() == null || !pageDTO.getIsPage()) {
            return;
        }
        if (pageDTO.getPage() == null || pageDTO.getPage() < 1) {
            pageDTO.setPage(1);
        }
        if (pageDTO.getPageSize() == null || pageDTO.getPageSize() < 1) {
            pageDTO.setPageSize(10);
        }
        // 防止 pageSize 过大
        if (pageDTO.getPageSize() > 500) {
            pageDTO.setPageSize(500);
        }
        pageDTO.setStart((pageDTO.getPage() - 1) * pageDTO.getPageSize());
        pageDTO.setEnd(pageDTO.getPage() * pageDTO.getPageSize());
    }
}

注意 pageSize 的上限检查(最大 500)。这是一个重要的安全措施——防止恶意请求传入极大的 pageSize 值,导致数据库返回大量数据,消耗过多内存和带宽。

具体 QueryDTO 的继承示例:

java
// 教学示例:UserQueryDTO 继承 PageDTO
@Data
@EqualsAndHashCode(callSuper = true)
public class UserQueryDTO extends PageDTO {

    /** 用户名(模糊查询) */
    private String name;

    /** 邮箱(精确查询) */
    private String email;

    /** 状态 */
    private Integer status;

    /** 创建时间-起始 */
    private Date startTime;

    /** 创建时间-结束 */
    private Date endTime;
}

通过继承 PageDTO,UserQueryDTO 自动获得了 fieldsorderisPagepagepageSize 等分页参数,只需要添加业务特定的查询条件即可。

5.2 PageEntity 分页结果封装

PageEntity 是分页查询结果的统一封装。它与 PageDTO 形成对称关系——PageDTO 是分页查询的"输入",PageEntity 是分页查询的"输出"。

java
// 教学示例:PageEntity 核心定义
@Data
public class PageEntity<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 数据列表 */
    private List<T> list;

    /** 当前页码 */
    private Integer page;

    /** 每页大小 */
    private Integer pageSize;

    /** 总记录数 */
    private Integer count;
}

PageEntity 的四个字段解析:

  • list:当前页的数据列表。泛型参数 T 可以是 Entity 类型,也可以是 DTO 类型,取决于调用场景。
  • page:当前页码,与请求参数中的 page 一致。
  • pageSize:每页大小,与请求参数中的 pageSize 一致。
  • count:总记录数,用于前端计算总页数(totalPages = Math.ceil(count / pageSize))。

PageEntity 的 JSON 输出示例:

json
// 教学示例:PageEntity 的 JSON 序列化结果
{
    "code": 0,
    "message": "操作成功",
    "data": {
        "list": [
            {
                "id": 1,
                "name": "张三",
                "email": "zhangsan@example.com",
                "status": 1,
                "createTime": "2025-01-15 10:30:00"
            },
            {
                "id": 2,
                "name": "李四",
                "email": "lisi@example.com",
                "status": 1,
                "createTime": "2025-01-16 14:20:00"
            }
        ],
        "page": 1,
        "pageSize": 10,
        "count": 156
    }
}

PageDTO 与 PageEntity 的协作流程:

前端请求 → Controller 接收 PageDTO 子类
         → Service.handleQueryParam() 计算分页参数
         → Mapper.selectBy() 执行分页查询
         → Mapper.countBy() 统计总记录数
         → Service 封装 PageEntity
         → Controller 包装为 BaseResult<PageEntity<T>>
         → 前端接收 JSON 响应

这个流程中,PageDTO 和 PageEntity 分别承担了"输入"和"输出"的职责,BaseService 负责中间的编排逻辑,BaseResult 负责最终的响应封装。每一层都有清晰的职责边界,协作方式简洁明了。

5.3 内存分页与数据库分页的选择

在 smart-scaffold 项目中,分页查询有两种实现策略:数据库分页和内存分页。选择哪种策略取决于具体的业务场景和数据特征。

数据库分页

数据库分页是通过 SQL 的 LIMIT 子句在数据库层面实现分页,只返回当前页的数据。

sql
-- 教学示例:MySQL 数据库分页
SELECT id, name, email, status, create_time
FROM sys_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10 OFFSET 0

优势:

  • 内存占用低:只加载当前页的数据到内存
  • 性能稳定:不受数据总量增长的影响
  • 适合大数据量:即使表中有数百万条记录,也能快速响应

劣势:

  • 深分页性能问题:当 OFFSET 值很大时(如 LIMIT 10 OFFSET 100000),MySQL 需要扫描前 100010 条记录,然后丢弃前 100000 条,性能急剧下降
  • 排序限制:只能基于数据库中的字段进行排序
  • 跨表分页困难:当数据来自多个表时,数据库分页的实现变得复杂

内存分页

内存分页是将全部数据加载到内存中,然后在内存中进行截取。

java
// 教学示例:内存分页实现
List<UserEntity> allUsers = userMapper.selectBy(query);
int start = (page - 1) * pageSize;
int end = Math.min(start + pageSize, allUsers.size());
List<UserEntity> pageData = allUsers.subList(start, end);

优势:

  • 实现简单:不需要修改 SQL
  • 灵活性高:可以对任意数据源的数据进行分页
  • 无深分页问题:内存中的 subList 操作是 O(1) 的

劣势:

  • 内存占用高:需要将全部数据加载到内存
  • 大数据量风险:当数据量超过内存容量时,可能导致 OOM
  • 不适合实时数据:全量加载的延迟较高

选择策略:

场景推荐策略原因
用户列表、订单列表数据库分页数据量大,增长快
字典表、配置表内存分页数据量小,变化少
聚合查询结果内存分页数据来自多个源,无法在数据库分页
导出功能不分页需要全量数据
实时搜索数据库分页需要快速响应
缓存数据内存分页数据已在内存中

深分页问题的优化方案:

对于数据库分页的深分页问题,smart-scaffold 建议以下优化策略:

sql
-- 教学示例:深分页优化——游标分页
-- 替代方案:使用上一页最后一条记录的 ID 作为游标
SELECT id, name, email, status, create_time
FROM sys_user
WHERE status = 1 AND id < #{lastId}
ORDER BY id DESC
LIMIT 10

游标分页通过记录上一页最后一条数据的 ID,在下一页查询时添加 id < lastId 条件,避免了 OFFSET 的性能问题。但游标分页也有局限性——它只支持顺序翻页,不支持跳转到任意页。


六、三种架构的公共模块复用策略对比

6.1 SpringBoot:smart-scaffold-common 模块

在 SpringBoot 单体架构中,smart-scaffold-common 模块是最完整的公共模块。它包含了脚手架的所有基础组件——BaseMapper、BaseService、BaseResult、PageDTO、PageEntity、通用工具类、常量定义等。

smart-scaffold-common/
├── cc.bima.scaffold.common
│   ├── base
│   │   ├── BaseMapper.java          # 通用数据访问接口
│   │   ├── BaseService.java         # 通用业务逻辑基类
│   │   ├── BaseResult.java          # 统一API响应封装
│   │   └── BaseResultEnum.java      # 响应码枚举
│   ├── dto
│   │   ├── PageDTO.java             # 分页查询参数基类
│   │   └── PageEntity.java          # 分页结果封装
│   ├── entity
│   │   └── (通用实体基类)
│   ├── enums
│   │   └── (通用枚举定义)
│   └── util
│       └── (通用工具类)

SpringBoot 版的 common 模块之所以最完整,是因为单体架构中所有代码都在同一个进程中运行,不存在跨进程通信的限制。BaseService 可以直接注入 BaseMapper,BaseResult 可以直接在 Controller 中使用,所有组件之间的协作都是本地方法调用。

依赖关系:

smart-scaffold-web
    └── smart-scaffold-service
        └── smart-scaffold-dao
            └── smart-scaffold-common

依赖方向严格单向:上层依赖下层,下层不感知上层。common 模块位于依赖链的最底层,不依赖任何业务模块,确保了其稳定性和可复用性。

6.2 Dubbo:smart-scaffold-api 模块

在 Dubbo 分布式架构中,公共模块的角色发生了根本性的变化。smart-scaffold-api 模块不再包含 BaseService 等实现类,而是专注于服务接口定义和数据传输对象

smart-scaffold-api/
├── cc.bima.scaffold.api
│   ├── face
│   │   ├── UserFace.java            # 用户服务接口
│   │   ├── DeptFace.java            # 部门服务接口
│   │   ├── DictFace.java            # 字典服务接口
│   │   ├── RoleFace.java            # 角色服务接口
│   │   ├── MenuFace.java            # 菜单服务接口
│   │   ├── ConfigFace.java          # 配置服务接口
│   │   ├── FileFace.java            # 文件服务接口
│   │   ├── LogFace.java             # 日志服务接口
│   │   ├── MessageFace.java         # 消息服务接口
│   │   ├── NoticeFace.java          # 通知服务接口
│   │   ├── ScheduleFace.java        # 定时任务服务接口
│   │   └── TenantFace.java          # 租户服务接口
│   ├── dto
│   │   ├── UserDTO.java             # 用户数据传输对象
│   │   ├── DeptDTO.java             # 部门数据传输对象
│   │   ├── PageDTO.java             # 分页查询参数
│   │   └── PageEntity.java          # 分页结果封装
│   ├── enums
│   │   ├── BaseResultEnum.java      # 响应码枚举
│   │   └── (业务枚举)
│   └── base
│       ├── BaseMapper.java          # 通用数据访问接口
│       └── BaseResult.java          # 统一API响应封装

12 个 Face 接口的设计意义:

Dubbo 版的 API 模块定义了 12 个 Face 接口,每个接口对应一个业务领域。这些 Face 接口是服务提供者和服务消费者之间的契约——Provider 实现这些接口,Consumer 通过 RPC 调用这些接口。

Face 接口的设计遵循以下原则:

接口隔离原则。每个 Face 接口只包含一个业务领域的方法,而不是将所有方法放在一个"上帝接口"中。这使得 Consumer 可以按需依赖,而不是被迫依赖不需要的接口。

DTO 驱动设计。Face 接口的入参和出参都是 DTO 类型,而不是 Entity 类型。这是因为 Entity 可能包含数据库特有的字段(如逻辑删除标记、版本号等),这些字段不应该暴露给 Consumer。

BaseResult 统一封装。所有 Face 接口的方法返回值都是 BaseResult<T> 类型,确保了响应格式的统一。

java
// 教学示例:Dubbo 版 Face 接口定义
public interface UserFace {

    BaseResult<UserDTO> get(Long id);

    BaseResult<Void> save(UserDTO dto);

    BaseResult<Void> delete(Long id);

    BaseResult<PageEntity<UserDTO>> selectPage(UserQueryDTO query);

    BaseResult<List<UserDTO>> selectList(UserQueryDTO query);
}

API 模块中 BaseMapper 的特殊性:

在 Dubbo 版中,BaseMapper 虽然定义在 API 模块中,但它只在 Provider 内部使用。Consumer 不直接依赖 BaseMapper,而是通过 Face 接口间接访问数据。BaseMapper 被放在 API 模块中的原因是为了保持与 SpringBoot 版的代码一致性——相同的 BaseMapper 定义可以在三种架构中复用。

6.3 SpringCloud:smart-scaffold-common 模块(精简版)

SpringCloud 版的 smart-scaffold-common 模块是 SpringBoot 版的精简版本。它保留了核心的 Base 组件定义,但增加了 Feign 客户端接口。

smart-scaffold-common/
├── cc.bima.scaffold.common
│   ├── base
│   │   ├── BaseMapper.java          # 通用数据访问接口
│   │   ├── BaseService.java         # 通用业务逻辑基类
│   │   ├── BaseResult.java          # 统一API响应封装
│   │   └── BaseResultEnum.java      # 响应码枚举
│   ├── dto
│   │   ├── PageDTO.java             # 分页查询参数
│   │   └── PageEntity.java          # 分页结果封装
│   ├── face
│   │   ├── UserFace.java            # Feign 客户端接口
│   │   ├── DeptFace.java            # Feign 客户端接口
│   │   └── (其他 Feign 接口)
│   └── util
│       └── (通用工具类)

与 Dubbo 版 Face 接口的对比:

SpringCloud 版的 Face 接口使用 Spring MVC 注解定义 HTTP 映射,而 Dubbo 版的 Face 接口是纯粹的 Java 接口:

java
// 教学示例:SpringCloud 版 Feign 客户端接口
@FeignClient(name = "scaffold-provider", path = "/user")
public interface UserFace {

    @GetMapping("/get/{id}")
    BaseResult<UserDTO> get(@PathVariable("id") Long id);

    @PostMapping("/save")
    BaseResult<Void> save(@RequestBody UserDTO dto);

    @PostMapping("/delete/{id}")
    BaseResult<Void> delete(@PathVariable("id") Long id);

    @PostMapping("/selectPage")
    BaseResult<PageEntity<UserDTO>> selectPage(@RequestBody UserQueryDTO query);
}
java
// 教学示例:Dubbo 版 Face 接口(对比)
public interface UserFace {

    BaseResult<UserDTO> get(Long id);

    BaseResult<Void> save(UserDTO dto);

    BaseResult<Void> delete(Long id);

    BaseResult<PageEntity<UserDTO>> selectPage(UserQueryDTO query);
}

两者的方法签名完全一致,差异仅在于通信协议和注解方式。这种一致性使得业务代码(Service 实现类)可以在 Dubbo 和 SpringCloud 之间保持高度一致。

6.4 三种架构复用策略对比总结

维度SpringBootDubboSpringCloud
公共模块名称commonapicommon
BaseMapper包含包含包含
BaseService包含不包含(Provider 内)包含
BaseResult包含包含包含
PageDTO/PageEntity包含包含包含
Face 接口无(直接 Controller)12 个 Java 接口12 个 Feign 接口
DTO/Entity都包含只包含 DTO都包含
通信方式本地方法调用Dubbo RPCHTTP/Feign
序列化要求Hessian2/ProtobufJSON

核心洞察:

三种架构的公共模块虽然在组成上有所不同,但核心 Base 组件(BaseMapper、BaseResult、PageDTO、PageEntity)在三种架构中保持一致。这意味着:

  1. Base 组件的设计是架构无关的。无论采用单体架构、RPC 架构还是微服务架构,数据访问的抽象模式、响应封装的格式、分页查询的参数结构都是通用的。
  2. 架构差异主要体现在通信层面。Dubbo 的 Face 接口使用 RPC 协议,SpringCloud 的 Face 接口使用 HTTP 协议,但接口的方法签名和数据结构保持一致。
  3. 公共模块的精简程度与架构复杂度正相关。SpringBoot 版的 common 模块最完整,Dubbo 版的 api 模块最精简(只包含接口定义和 DTO),SpringCloud 版的 common 模块介于两者之间。

七、MyBatis Generator 代码生成集成

7.1 generatorConfig.xml 配置

MyBatis Generator(MBG)是 MyBatis 官方提供的代码生成工具,能够根据数据库表结构自动生成 Entity 类、Mapper 接口和 XML 映射文件。smart-scaffold 项目深度集成了 MBG,将 Base 层抽象与代码生成有机结合。

xml
<!-- 教学示例:generatorConfig.xml 核心配置 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
    PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
    "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
    <context id="ScaffoldTables" targetRuntime="MyBatis3">

        <!-- 数据库连接配置 -->
        <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver"
            connectionURL="jdbc:mysql://localhost:3306/scaffold_db"
            userId="root" password="root" />

        <!-- Entity 生成策略 -->
        <javaModelGenerator targetPackage="cc.bima.scaffold.entity"
            targetProject="src/main/java">
            <property name="enableSubPackages" value="false" />
            <property name="trimStrings" value="true" />
        </javaModelGenerator>

        <!-- SQL Map (XML) 生成策略 -->
        <sqlMapGenerator targetPackage="mapper"
            targetProject="src/main/resources">
            <property name="enableSubPackages" value="false" />
        </sqlMapGenerator>

        <!-- Mapper 接口生成策略 -->
        <javaClientGenerator type="XMLMAPPER"
            targetPackage="cc.bima.scaffold.mapper"
            targetProject="src/main/java">
            <property name="enableSubPackages" value="false" />
        </javaClientGenerator>

        <!-- 数据库表配置 -->
        <table tableName="sys_user" domainObjectName="User" />
        <table tableName="sys_dept" domainObjectName="Dept" />
        <table tableName="sys_role" domainObjectName="Role" />
        <table tableName="sys_dict" domainObjectName="Dict" />

    </context>
</generatorConfiguration>

配置详解:

jdbcConnection:配置数据库连接信息。MBG 通过 JDBC 连接数据库,读取表结构和字段信息,作为代码生成的基础。

javaModelGenerator:配置 Entity 类的生成策略。targetPackage 指定 Entity 类的包名,trimStrings 设置为 true 表示自动对字符串类型的字段执行 trim 操作。

sqlMapGenerator:配置 XML 映射文件的生成策略。生成的 XML 文件包含 insertSelectiveupdateByPrimaryKeySelectiveselectByPrimaryKey 等方法的 SQL 实现,这些方法名与 BaseMapper 中定义的方法名完全一致。

javaClientGenerator:配置 Mapper 接口的生成策略。type="XMLMAPPER" 表示生成的 Mapper 接口的方法实现来自 XML 文件(而不是注解)。

table:配置需要生成代码的数据库表。tableName 是数据库表名,domainObjectName 是生成的 Entity 类名。

7.2 Entity/DTO/Mapper 接口/XML 的生成策略

Entity 生成策略

MBG 生成的 Entity 类与 BaseMapper 的泛型参数 T 直接对应。生成的 Entity 类包含与数据库表字段一一对应的属性,以及 getter/setter 方法。

java
// 教学示例:MBG 生成的 Entity 类(简化版)
public class UserEntity implements Serializable {
    private Long id;
    private String name;
    private String email;
    private String phone;
    private Integer status;
    private Date createTime;
    // getter/setter 方法...
}

在实际项目中,通常会对 MBG 生成的 Entity 类进行以下定制:

  • 添加 @Data 注解(Lombok),消除手写的 getter/setter
  • 添加 @TableName 注解(如果使用 MyBatis-Plus),指定表名
  • 添加业务相关的计算属性(如 statusText 状态文本描述)

Mapper 接口生成策略

MBG 生成的 Mapper 接口包含标准的 CRUD 方法,方法名与 BaseMapper 完全一致:

java
// 教学示例:MBG 生成的 Mapper 接口(简化版)
public interface UserMapper {
    int insertSelective(UserEntity entity);
    int deleteByPrimaryKey(Long id);
    UserEntity selectByPrimaryKey(Long id);
    int updateByPrimaryKeySelective(UserEntity entity);
    List<UserEntity> selectBy(UserQueryDTO query);
    int countBy(UserQueryDTO query);
    UserEntity uniqueBy(UserQueryDTO query);
}

关键步骤:让生成的 Mapper 继承 BaseMapper

MBG 默认生成的 Mapper 接口不会继承 BaseMapper。在 smart-scaffold 项目中,通过以下方式将生成的 Mapper 与 BaseMapper 关联:

java
// 教学示例:手动修改生成的 Mapper 接口,继承 BaseMapper
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
    // MBG 生成的方法与 BaseMapper 的方法签名一致
    // 可以添加自定义查询方法
    List<UserEntity> selectByRole(Long roleId);
}

由于 MBG 生成的方法签名与 BaseMapper 中定义的方法签名完全一致,继承 BaseMapper 后,这些方法自动成为 BaseMapper 接口方法的实现。子类 Service 通过 mapper.insertSelective() 等方式调用时,实际执行的是 MBG 生成的 SQL。

XML 映射文件生成策略

MBG 生成的 XML 文件包含完整的 SQL 映射。以 insertSelective 为例:

xml
<!-- 教学示例:MBG 生成的 insertSelective SQL -->
<insert id="insertSelective" parameterType="cc.bima.scaffold.entity.UserEntity">
    INSERT INTO sys_user
    <trim prefix="(" suffix=")" suffixOverrides=",">
        <if test="id != null">id,</if>
        <if test="name != null">name,</if>
        <if test="email != null">email,</if>
        <if test="phone != null">phone,</if>
        <if test="status != null">status,</if>
        <if test="createTime != null">create_time,</if>
    </trim>
    <trim prefix="VALUES (" suffix=")" suffixOverrides=",">
        <if test="id != null">#{id},</if>
        <if test="name != null">#{name},</if>
        <if test="email != null">#{email},</if>
        <if test="phone != null">#{phone},</if>
        <if test="status != null">#{status},</if>
        <if test="createTime != null">#{createTime},</if>
    </trim>
</insert>

selectBy 和 countBy 的自定义 SQL

MBG 默认不会生成 selectBycountBy 方法(因为这两个方法的参数是 Object 类型,MBG 无法自动推断查询条件)。在 smart-scaffold 项目中,这两个方法的 SQL 需要手动编写:

xml
<!-- 教学示例:手动编写的 selectBy SQL -->
<select id="selectBy" parameterType="cc.bima.scaffold.dto.UserQueryDTO"
        resultType="cc.bima.scaffold.entity.UserEntity">
    SELECT id, name, email, phone, status, create_time
    FROM sys_user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
    <if test="order != null and order != ''">
        ORDER BY ${order}
    </if>
    <if test="isPage != null and isPage">
        LIMIT #{start}, #{pageSize}
    </if>
</select>
xml
<!-- 教学示例:手动编写的 countBy SQL -->
<select id="countBy" parameterType="cc.bima.scaffold.dto.UserQueryDTO"
        resultType="int">
    SELECT COUNT(*) FROM sys_user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
</select>

注意countBy 的 WHERE 条件必须与 selectBy 保持一致(不包括 ORDER BY 和 LIMIT),否则分页的总数统计将不准确。

7.3 代码生成与 Base 层的协作模式

MBG 代码生成与 Base 层抽象形成了一种高效的协作模式:

数据库表结构
    ↓ (MBG 读取)
Entity 类 + Mapper XML(基础 CRUD 的 SQL)
    ↓ (手动修改)
Mapper 接口继承 BaseMapper<T>
    ↓ (自动获得)
BaseService 的 8 个通用方法 + 4 个钩子方法
    ↓ (子类覆盖)
具体 Service 的业务逻辑

这种协作模式的核心价值在于:

减少手写 SQL 的工作量。MBG 自动生成 insertSelectiveupdateByPrimaryKeySelectiveselectByPrimaryKeydeleteByPrimaryKey 等方法的 SQL,这些 SQL 占据了数据访问层代码的 60% 以上。

保持 Base 层的一致性。MBG 生成的方法名与 BaseMapper 的方法名一致,使得生成的代码可以直接与 Base 层协作,无需额外的适配层。

支持增量生成。当数据库表结构变更时(如新增字段),可以重新运行 MBG 生成代码,MBG 会自动更新 Entity 类和 XML 映射文件。手动添加的自定义方法不会被覆盖(因为 MBG 的 XML 合并策略会保留自定义内容)。

三种架构中的生成策略差异:

维度SpringBootDubboSpringCloud
Entity 生成位置common 或 daoprovidercommon 或 provider
Mapper 生成位置daoproviderprovider
XML 生成位置dao/resourcesprovider/resourcesprovider/resources
DTO 生成手动编写MBG + 手动手动编写

在 Dubbo 版中,DTO 的生成策略略有不同——由于 API 模块需要独立的 DTO 定义,MBG 生成的 Entity 不能直接作为 DTO 使用。通常的做法是:MBG 生成 Entity,然后手动创建对应的 DTO(或使用 MapStruct 等工具自动转换)。


八、生产环境 Base 层设计最佳实践

8.1 泛型约束的边界

泛型是 Base 层设计的核心工具,但泛型的使用也有边界。过度使用泛型会导致代码可读性下降,而泛型约束不足又会导致运行时类型错误。在生产环境中,需要在灵活性和安全性之间找到平衡。

泛型擦除的陷阱

Java 的泛型在运行时会被擦除(Type Erasure),这意味着泛型信息在运行时不可用。这可能导致一些意想不到的问题:

java
// 教学示例:泛型擦除导致的问题
public abstract class BaseService<T, M extends BaseMapper<T>> {

    @Autowired
    protected M mapper;

    // 这个方法在运行时无法获取 T 的具体类型
    public Class<T> getEntityType() {
        // 编译错误:无法直接获取泛型参数的类型
        // return T.class;
    }
}

在 smart-scaffold 项目中,通过在子类构造函数中显式传递 Class 对象来解决这个问题:

java
// 教学示例:通过子类传递 Class 对象解决泛型擦除
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    private final Class<UserEntity> entityClass = UserEntity.class;

    // 使用 entityClass 进行反射操作...
}

泛型边界的合理设置

BaseMapper 的泛型参数 T 没有设置上界约束(即 T 等价于 T extends Object)。这是一个有意的设计决策——不强制要求所有实体类继承某个基类,保持了最大的灵活性。

但在某些场景下,设置泛型边界是有价值的。比如,如果 Base 层需要访问实体的 ID 字段:

java
// 教学示例:通过接口约束泛型边界
public interface Identifiable {
    Long getId();
    void setId(Long id);
}

public interface BaseMapper<T extends Identifiable> {
    T selectByPrimaryKey(Long id);
    // ...
}

这种设计通过 Identifiable 接口约束了泛型参数的上界,使得 BaseMapper 可以安全地调用 getId()setId() 方法,而不需要反射。

何时应该避免泛型

以下场景中,使用泛型可能不是最佳选择:

  • 方法参数类型差异很大时。如果不同实体的查询方法需要完全不同的参数类型,强制使用统一的 Object 参数类型会降低类型安全性。此时,在具体的 Mapper 接口中定义类型安全的方法可能更好。
  • 需要运行时类型信息时。如果 Base 层的逻辑依赖于运行时的类型信息(如根据实体类型选择不同的处理策略),泛型擦除会导致问题。此时,应该使用 Class 对象或其他方式传递类型信息。
  • 继承层次过深时。如果 Base 层的泛型参数链过长(如 BaseService<T, M extends BaseMapper<T>, Q extends PageDTO>),代码的可读性会急剧下降。此时,应该考虑简化泛型设计或拆分职责。

8.2 模板方法的扩展点设计

模板方法模式是 BaseService 的核心设计模式,但模板方法的扩展点设计需要仔细考量。扩展点太少会导致子类无法定制行为,扩展点太多会导致 Base 层的逻辑过于复杂,难以理解和维护。

smart-scaffold 的四个扩展点回顾:

扩展点调用时机典型用途
handleQueryParam查询执行前设置默认值、格式化参数
handleQueryResult查询完成后数据脱敏、权限过滤
checkSaveInput保存执行前业务规则校验、唯一性检查
checkRemove删除执行前关联数据检查、权限验证

扩展点设计的最佳实践:

保持扩展点的正交性。每个扩展点应该只负责一个维度的扩展,不应该有重叠的职责。比如,不应该在 handleQueryParam 中同时处理参数预处理和权限校验——参数预处理是 handleQueryParam 的职责,权限校验应该有独立的扩展点或在 checkRemove 中处理。

提供合理的默认实现。所有扩展点都应该有安全的默认实现(通常是空方法),使得不覆盖任何扩展点的子类也能正常工作。这降低了子类的开发门槛——开发者只需要覆盖真正需要定制的扩展点。

文档化每个扩展点的契约。每个扩展点的前置条件、后置条件和副作用都应该有清晰的文档说明。比如,checkSaveInput 的文档应该说明:当校验失败时应该抛出 BizException,而不是返回 false。

避免在扩展点中引入副作用。扩展点方法应该是纯函数或至少是副作用可控的。如果在 handleQueryResult 中修改了查询结果列表(如排序、过滤),应该确保这种修改不会影响其他逻辑。

扩展点的高级用法——组合模式:

在复杂业务场景中,一个 Service 可能需要多个维度的校验逻辑。此时,可以通过组合模式将校验逻辑拆分为多个独立的校验器:

java
// 教学示例:校验逻辑的组合模式
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    @Autowired
    private UserUniqueValidator uniqueValidator;

    @Autowired
    private UserFormatValidator formatValidator;

    @Override
    protected void checkSaveInput(UserEntity entity) {
        // 组合多个校验器
        uniqueValidator.validate(entity);
        formatValidator.validate(entity);
    }
}

这种设计使得校验逻辑可以被多个 Service 复用(比如 UserUniqueValidator 可以在用户创建和用户更新时共用),同时也保持了 BaseService 的简洁性。

8.3 响应码体系设计

响应码体系是 API 设计的重要组成部分。一个设计良好的响应码体系可以帮助前端快速判断请求的处理结果,也有助于后端的错误排查和监控告警。

响应码分段策略:

java
// 教学示例:响应码分段设计
public enum BaseResultEnum {

    // 0-99:系统级响应码
    SUCCESS(0, "操作成功"),
    FAIL(1, "操作失败"),

    // 100-199:参数校验相关
    PARAM_ERROR(100, "参数错误"),
    PARAM_MISSING(101, "缺少必要参数"),
    PARAM_TYPE_ERROR(102, "参数类型错误"),
    PARAM_FORMAT_ERROR(103, "参数格式错误"),

    // 200-299:认证授权相关
    TOKEN_EXPIRED(200, "登录已过期"),
    TOKEN_INVALID(201, "登录凭证无效"),
    NO_AUTH(403, "无权限访问"),
    ACCOUNT_DISABLED(204, "账号已被禁用"),

    // 300-399:业务逻辑相关(由具体业务模块定义)
    // 300-319:用户模块
    // 320-339:订单模块
    // 340-359:支付模块
    // ...

    // 500-599:系统错误
    SERVER_ERROR(500, "服务器内部错误"),
    DB_ERROR(501, "数据库操作失败"),
    RPC_ERROR(502, "远程服务调用失败"),
    TOO_MANY_REQUESTS(429, "请求过于频繁");
}

响应码设计原则:

分段管理。将响应码按照功能模块分段,每段预留足够的编码空间。系统级响应码使用 0-99,参数校验使用 100-199,认证授权使用 200-299,业务逻辑使用 300-399,系统错误使用 500-599。这种分段方式使得通过响应码就能快速定位问题所属的模块。

向前兼容。一旦定义了某个响应码的含义,就不应该再修改。如果需要变更某个错误类型的处理方式,应该新增一个响应码,而不是复用已有的响应码。

全局唯一。每个响应码在整个系统中应该是唯一的,不应该在不同模块中重复使用同一个响应码表示不同的含义。

可读性。响应码的枚举名称应该清晰表达其含义,如 TOKEN_EXPIREDCODE_200 更具可读性。

与前端约定。响应码体系应该与前端团队充分沟通,确保前端能够正确处理各种响应码。特别是业务逻辑相关的响应码(300-399),前端可能需要根据不同的响应码显示不同的提示信息或执行不同的跳转逻辑。

8.4 分页性能优化

分页查询是 Web 应用中最常见的操作之一,也是性能问题的高发区域。在生产环境中,分页性能优化需要从多个层面进行。

数据库层面的优化:

sql
-- 教学示例:分页查询的索引优化
-- 确保 ORDER BY 字段有索引覆盖
CREATE INDEX idx_user_create_time ON sys_user(create_time);

-- 复合索引优化:WHERE 条件 + ORDER BY 字段
CREATE INDEX idx_user_status_create_time
    ON sys_user(status, create_time);

索引是分页查询性能优化的基础。确保 WHERE 条件和 ORDER BY 字段都有合适的索引,可以避免全表扫描和文件排序(filesort)。

避免 SELECT *

sql
-- 教学示例:避免 SELECT * 的分页查询
-- 不推荐
SELECT * FROM sys_user WHERE status = 1 ORDER BY create_time DESC LIMIT 10 OFFSET 0;

-- 推荐:只查询需要的字段
SELECT id, name, email, status, create_time
FROM sys_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10 OFFSET 0;

SELECT * 会查询所有字段,包括大文本字段(如备注、描述等),这些字段不仅增加网络传输量,还可能导致 MySQL 无法使用覆盖索引(Covering Index),从而降低查询性能。

深分页优化:

当 OFFSET 值很大时(如超过 10 万),MySQL 的深分页性能会急剧下降。以下是几种优化方案:

sql
-- 教学示例:方案一——子查询优化
SELECT a.id, a.name, a.email, a.status, a.create_time
FROM sys_user a
INNER JOIN (
    SELECT id FROM sys_user
    WHERE status = 1
    ORDER BY create_time DESC
    LIMIT 10 OFFSET 100000
) b ON a.id = b.id;

-- 教学示例:方案二——游标分页(推荐)
SELECT id, name, email, status, create_time
FROM sys_user
WHERE status = 1 AND id < #{lastId}
ORDER BY id DESC
LIMIT 10;

-- 教学示例:方案三——延迟关联
SELECT a.id, a.name, a.email, a.status, a.create_time
FROM sys_user a
INNER JOIN (
    SELECT id FROM sys_user
    WHERE status = 1
    ORDER BY create_time DESC
    LIMIT 100010
) b ON a.id = b.id
ORDER BY a.create_time DESC
LIMIT 10;

方案一(子查询优化):先通过子查询获取目标页的 ID 列表(只查询 ID,可以利用覆盖索引),然后通过 INNER JOIN 获取完整的记录。这种方式在大多数场景下都能显著提升深分页性能。

方案二(游标分页):使用上一页最后一条记录的 ID 作为游标,避免 OFFSET。这是性能最优的方案,但只支持"下一页"操作,不支持跳转到任意页。

方案三(延迟关联):与方案一类似,但先查询比目标页更多的 ID(LIMIT 100010),然后在外层查询中进行排序和截取。这种方式适用于需要精确跳页的场景。

count 查询优化:

在分页查询中,countBy 查询的性能同样值得关注。对于复杂查询,COUNT(*) 可能和 SELECT 一样慢:

sql
-- 教学示例:count 查询优化
-- 如果不需要精确的总数,可以使用估算值
SHOW TABLE STATUS LIKE 'sys_user';

-- 或者使用覆盖索引加速 count 查询
SELECT COUNT(*) FROM sys_user WHERE status = 1;
-- 确保 status 字段有索引
CREATE INDEX idx_user_status ON sys_user(status);

在实际项目中,如果对总数精度要求不高(如只需要显示"约 1000 条"),可以考虑使用估算值或缓存总数,避免每次分页查询都执行 COUNT(*)

应用层面的优化:

java
// 教学示例:分页查询的缓存策略
@Service
public class DictService extends BaseService<DictEntity, DictMapper> {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 字典列表查询:优先从缓存获取
     */
    public PageEntity<DictDTO> selectPageWithCache(DictQueryDTO query) {
        String cacheKey = "dict:page:" + query.getPage() + ":" + query.getPageSize();
        PageEntity<DictDTO> cached = (PageEntity<DictDTO>)
            redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        PageEntity<DictEntity> page = selectPageBy(query);
        PageEntity<DictDTO> result = convertToDTOPage(page);

        // 缓存 5 分钟
        redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
        return result;
    }
}

对于变化不频繁的数据(如字典表、配置表),可以使用 Redis 缓存分页结果,减少数据库查询压力。缓存的过期时间应该根据数据的变更频率来设置。

pageSize 上限控制:

在 BaseService 的 handleQueryParam 方法中,已经包含了 pageSize 的上限检查(最大 500)。这是一个重要的安全措施,但在生产环境中,可能需要根据具体的业务场景进行调整:

java
// 教学示例:按接口设置不同的 pageSize 上限
public class PageConfig {
    /** 默认分页大小 */
    public static final int DEFAULT_PAGE_SIZE = 10;
    /** 最大分页大小(通用接口) */
    public static final int MAX_PAGE_SIZE = 500;
    /** 最大分页大小(导出接口) */
    public static final int EXPORT_MAX_PAGE_SIZE = 5000;
    /** 最大分页大小(下拉选择接口) */
    public static final int SELECT_MAX_PAGE_SIZE = 100;
}

不同的接口类型可以设置不同的 pageSize 上限。导出接口通常需要更大的 pageSize,而下拉选择接口的 pageSize 应该更小。

8.5 Base 层的测试策略

Base 层作为整个脚手架的基础设施,其正确性直接影响所有业务模块的稳定性。因此,Base 层的测试策略需要特别重视。

BaseMapper 的测试策略:

BaseMapper 的测试主要关注 SQL 的正确性。由于 BaseMapper 的方法是通过 MyBatis XML 实现的,测试需要验证 SQL 是否按预期执行:

java
// 教学示例:BaseMapper 的集成测试
@SpringBootTest
@Transactional
public class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void testInsertSelective() {
        UserEntity entity = new UserEntity();
        entity.setName("测试用户");
        entity.setEmail("test@example.com");
        entity.setStatus(1);

        int rows = userMapper.insertSelective(entity);
        assertThat(rows).isEqualTo(1);
        assertThat(entity.getId()).isNotNull();
    }

    @Test
    public void testSelectByPrimaryKey() {
        UserEntity entity = userMapper.selectByPrimaryKey(1L);
        assertThat(entity).isNotNull();
        assertThat(entity.getName()).isNotNull();
    }

    @Test
    public void testSelectBy() {
        UserQueryDTO query = new UserQueryDTO();
        query.setName("测试");
        query.setIsPage(true);
        query.setPage(1);
        query.setPageSize(10);

        List<UserEntity> list = userMapper.selectBy(query);
        assertThat(list).isNotEmpty();
    }
}

BaseService 的测试策略:

BaseService 的测试需要覆盖模板方法的执行流程和便捷方法的正确性:

java
// 教学示例:BaseService 的单元测试
@SpringBootTest
public class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    public void testGet() {
        // 正常获取
        UserEntity entity = userService.get(1L);
        assertThat(entity).isNotNull();

        // 不存在时抛出异常
        assertThatThrownBy(() -> userService.get(999999L))
            .isInstanceOf(BizException.class)
            .hasMessageContaining("不存在");
    }

    @Test
    public void testSelectPageBy() {
        UserQueryDTO query = new UserQueryDTO();
        query.setIsPage(true);
        query.setPage(1);
        query.setPageSize(10);

        PageEntity<UserEntity> page = userService.selectPageBy(query);
        assertThat(page.getList()).isNotEmpty();
        assertThat(page.getCount()).isGreaterThan(0);
        assertThat(page.getPage()).isEqualTo(1);
    }
}

BaseResult 的测试策略:

BaseResult 的测试主要关注静态工厂方法的正确性和链式调用的流畅性:

java
// 教学示例:BaseResult 的单元测试
public class BaseResultTest {

    @Test
    public void testSuccess() {
        BaseResult<String> result = BaseResult.success("hello");
        assertThat(result.getCode()).isEqualTo(0);
        assertThat(result.getMessage()).isEqualTo("操作成功");
        assertThat(result.getData()).isEqualTo("hello");
    }

    @Test
    public void testFail() {
        BaseResult<Void> result = BaseResult.fail("操作失败");
        assertThat(result.getCode()).isEqualTo(1);
        assertThat(result.getMessage()).isEqualTo("操作失败");
        assertThat(result.getData()).isNull();
    }

    @Test
    public void testChainCall() {
        BaseResult<String> result = new BaseResult<String>()
            .setCode(0)
            .setMessage("自定义消息")
            .setData("chain data");

        assertThat(result.getCode()).isEqualTo(0);
        assertThat(result.getMessage()).isEqualTo("自定义消息");
        assertThat(result.getData()).isEqualTo("chain data");
    }

    @Test
    public void testSuccessPage() {
        List<String> list = Arrays.asList("a", "b", "c", "d", "e");
        BaseResult<PageEntity<String>> result =
            BaseResult.successPage(list, 1, 3);

        assertThat(result.getData().getList()).hasSize(3);
        assertThat(result.getData().getCount()).isEqualTo(5);
        assertThat(result.getData().getPage()).isEqualTo(1);
    }
}

8.6 Base 层的版本演进策略

在企业级项目中,Base 层的稳定性至关重要。任何 Base 层的变更都可能影响所有依赖它的业务模块。因此,Base 层的版本演进需要遵循严格的策略。

向后兼容原则

Base 层的所有变更都必须向后兼容。这意味着:

  • 不能删除已有的公共方法
  • 不能修改已有方法的签名(参数类型、返回类型)
  • 不能改变已有方法的行为语义

如果需要引入破坏性变更,应该创建新的方法,而不是修改已有的方法:

java
// 教学示例:向后兼容的演进方式
public abstract class BaseService<T, M extends BaseMapper<T>> {

    // 旧方法:保持不变
    public PageEntity<T> selectPageBy(Object queryDTO) {
        // 原有实现...
    }

    // 新方法:提供增强功能
    public PageEntity<T> selectPageBy(Object queryDTO, boolean cacheable) {
        if (cacheable) {
            // 带缓存的实现...
        }
        return selectPageBy(queryDTO);
    }
}

渐进式增强

Base 层的新功能应该以"渐进式增强"的方式引入。新功能默认不启用,需要子类显式选择使用:

java
// 教学示例:渐进式增强的钩子方法
public abstract class BaseService<T, M extends BaseMapper<T>> {

    // 原有钩子方法:保持不变
    protected void handleQueryParam(Object queryDTO) {
        // 默认空实现
    }

    // 新增钩子方法:默认不启用
    protected void handleQueryParamV2(Object queryDTO) {
        // 增强版参数处理,默认空实现
    }

    // 核心方法:先调用 V2,再调用 V1
    public PageEntity<T> selectPageBy(Object queryDTO) {
        handleQueryParamV2(queryDTO);  // 新版本优先
        handleQueryParam(queryDTO);     // 兼容旧版本
        // ...
    }
}

抽象泄漏的防范

Base 层的抽象应该对子类隐藏实现细节。如果子类需要依赖 Base 层的内部实现(如调用 Base 层的私有方法或访问 Base 层的内部字段),这通常意味着抽象设计存在"泄漏"。

java
// 教学示例:抽象泄漏的反模式
@Service
public class UserService extends BaseService<UserEntity, UserMapper> {

    public void someBusinessMethod() {
        // 反模式:直接访问 Base 层的内部字段
        // mapper.selectBy(...);  // 虽然 mapper 是 protected 的,但直接使用它绕过了 Base 层的模板方法

        // 推荐:通过 Base 层的公共方法访问
        List<UserEntity> list = selectBy(query);
    }
}

当子类直接使用 mapper 字段时,它绕过了 Base 层的 handleQueryParamhandleQueryResult 钩子方法,可能导致行为不一致。推荐的做法是通过 Base 层的公共方法(如 selectBygetremove)来访问数据,确保模板方法的执行流程不被破坏。


总结与展望

本文基于 smart-scaffold 系列项目的真实源码,系统性地剖析了企业级脚手架中 Base 层抽象设计与统一 API 响应封装的完整方案。让我们回顾一下核心要点:

BaseMapper 定义了 8 个通用的数据访问方法,通过泛型约束确保类型安全,与 MyBatis Generator 的代码生成无缝集成。它不依赖任何第三方框架,保持了最大的灵活性和可控性。

BaseService 通过模板方法模式封装了业务逻辑的通用编排流程,提供了 handleQueryParamhandleQueryResultcheckSaveInputcheckRemove 四个标准扩展点,以及 getremoveselectPageByselectByuniqueBy 等便捷方法。它将 80% 的重复逻辑下沉到 Base 层,让子类只关注 20% 的差异化逻辑。

BaseResult 继承 ApiResult,通过 @Accessors(chain=true) 支持链式调用,通过丰富的静态工厂方法(success/fail 的多种重载)提供语义化的响应构建方式。successPage 方法提供了内存级分页能力,适用于数据量较小的场景。

PageDTO 和 PageEntity 形成了分页查询的输入输出对称结构,通过 isPage 开关支持数据库分页和全量查询的灵活切换。

三种架构(SpringBoot/Dubbo/SpringCloud)的公共模块虽然组成不同,但核心 Base 组件保持一致,证明了 Base 层设计的架构无关性。

展望未来,企业级脚手架的 Base 层设计还有以下演进方向:

一是响应式编程的适配。随着 Spring WebFlux 的普及,Base 层需要支持响应式数据访问(Reactive Repository)和响应式响应封装(Mono/Flux),这对泛型设计和模板方法模式提出了新的挑战。

二是多租户支持的增强。在 SaaS 场景中,Base 层需要自动注入租户 ID 到查询条件和数据插入操作中,这可以通过在 handleQueryParamcheckSaveInput 中增加租户上下文处理来实现。

三是AI 辅助代码生成的深化。结合大语言模型的能力,未来可以实现更智能的代码生成——不仅生成 Entity 和 Mapper,还能根据业务描述自动生成 Service 的钩子方法实现和自定义查询 SQL。

四是可观测性的内置。在 Base 层中内置分布式追踪(Trace ID 传递)、指标采集(方法执行耗时统计)和日志增强(结构化日志),可以让所有业务模块自动获得可观测性能力,无需在每个模块中重复配置。

smart-scaffold 项目将持续演进,为 Java 企业级开发提供更完善的基础设施。如果你对项目的完整源码感兴趣,或者希望参与项目的共建,欢迎访问 bima.cc


版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc