实体基类设计:BaseEntity 公共字段抽取与 TreeEntity 树形继承

本文基于若依3.9.2、SpringBoot3版本。

实体基类设计:BaseEntity 公共字段抽取与 TreeEntity 树形继承

一个管理后台的实体类少则十几个、多则上百个,但不管什么业务,实体中几乎都带有同一批字段:创建者、创建时间、修改者、修改时间、备注。这批字段每个类都重复定义一遍是纯粹的重复劳动,字段名或类型要调整时还得全仓库同步修改。若依在 com.ruoyi.common.core.domain 包下提供了两个实体基类,把公共字段收拢到父类:

  • BaseEntity :所有实体类的父类,收拢审计字段(创建/更新人与时间、备注)和一个通用参数袋 params
  • TreeEntity:继承 BaseEntity,再收拢树形结构特有的字段(父级 ID、祖级列表、子节点等)

BaseEntity:审计字段收进父类

BaseEntity 实现了 Serializable 接口,com.ruoyi.common.core.domain.entity 包下的 SysUserSysDeptSysRole 等核心实体都直接继承它:

java 复制代码
public class SysUser extends BaseEntity

它定义的字段如下:

java 复制代码
    /** 搜索值 */
    @JsonIgnore
    private String searchValue;

    /** 创建者 */
    private String createBy;

    /** 创建时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    /** 更新者 */
    private String updateBy;

    /** 更新时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date updateTime;

    /** 备注 */
    private String remark;

    /** 请求参数 */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private Map<String, Object> params;

七个字段分两类:createBycreateTimeupdateByupdateTimeremark 是审计字段,与数据库表的 create_bycreate_timeupdate_byupdate_timeremark 列一一对应------自己新建业务表时只要保留这五列,实体继承 BaseEntity 后这些字段就不必再声明;searchValueparams 不对应任何数据库列,是框架级载体,下面分别说明。

补充一个细节:BaseEntity 声明了 implements SerializableserialVersionUID,但若依里实体对象的序列化实际都走 JSON------返回前端用 Jackson,存 Redis 用 FastJson2,两者都不依赖 Serializable 接口、也不校验 serialVersionUID(这是 Java 原生二进制序列化才看的东西,若依没有用到)。这个声明只是企业开发里的防御性惯例:万一某处走了原生序列化,没实现接口会直接抛 NotSerializableException,提前声明可以避免。

三个 JSON 注解

字段上的三个 Jackson 注解,各自解决一个前后端对接问题。

searchValue 上的 @JsonIgnore 表示该字段在序列化和反序列化时都不参与------响应里不会出现它,JSON 请求体里传了也不会绑定进来。

createTimeupdateTime 上的 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 固定了时间的序列化格式。这个注解在前后端对接里很关键:没有它,Jackson 默认把 Date 序列化成时间戳或 ISO-8601 字符串,前端拿到一串数字没法直接展示。

params 上的 @JsonInclude(JsonInclude.Include.NON_EMPTY) 表示序列化时只在非空时输出,null 或空 Map 都算空。多数请求里 params 根本没用到,不加这个注解,响应里会到处都是 "params":{} 这样的噪声字段。

searchValue:预留的关键字搜索位

@JsonIgnore 只作用于 Jackson(JSON 请求体和响应),不影响 GET 查询参数的表单绑定------前端传 ?searchValue=xxx,Spring 照样把它绑进实体的 searchValue 字段。所以这个字段的定位是:专门给 GET 列表查询预留的通用关键字搜索位。

框架里已有同类的关键字搜索场景。SysNoticeController 查询某公告的已读用户时,按关键字在用户名、昵称上做模糊匹配:

java 复制代码
public TableDataInfo readUsersList(Long noticeId, String searchValue) {
    startPage();
    List<?> list = noticeReadService.selectReadUsersByNoticeId(noticeId, searchValue);
    return getDataTable(list);
}

对应 XML 里 searchValue 做 LIKE 匹配:

bash 复制代码
    <if test="searchValue != null and searchValue != ''">
        and (
            u.user_name like concat('%', #{searchValue}, '%')
            or u.nick_name like concat('%', #{searchValue}, '%')
        )
    </if>

这里用的是独立的 @Param 参数。如果改用 BaseEntity.searchValue,实体继承后无需声明该字段,前端传 ?searchValue=xxx 绑进实体,XML 里同样用 #{searchValue} 取值,省去每个列表接口单独声明搜索参数。把「关键字搜索」这个高频需求收进基类,正是这个字段的设计意图。

params:实体上的通用参数袋

params 是整个基类最重要的设计------定义在每个实体上的通用 Map,承载「不属于业务字段、但查询时要用」的动态参数。它的 getter 做了懒初始化,保证取到的一定是非空 Map

java 复制代码
    public Map<String, Object> getParams() {
        if (params == null) {
            params = new HashMap<>();
        }
        return params;
    }

这个 Map 在框架里有三类用法,每一类都有实际的使用位置。

第一类:时间范围检索。 列表接口是 GET 请求,Controller 方法签名直接拿实体当参数:

java 复制代码
public TableDataInfo list(SysUser user) {
    startPage();
    List<SysUser> list = userService.selectUserList(user);
    return getDataTable(list);
}

前端传 params[beginTime]=2024-01-01 这样的参数时,Spring MVC 的数据绑定会按 params[key] 语法调用实体的 getParams() 再往里 put。这里能绑成功,靠的正是上面那个懒初始化的 getParams()------它保证返回非空 Map,否则绑定时往里 put 就会空指针。

beginTimeendTime 确实是 MVC 绑进去的、而非代码写进去的:全仓库搜索 getParams().put,只有数据权限切面写 dataScope、菜单服务写 userId 两处,没有任何代码写入过 beginTime/endTime,但 mapper 里却在读取它们,说明它们只能来自请求参数的自动绑定。

mapper 里通过 #{params.beginTime} / #{params.endTime} 取值做时间范围过滤,以 SysUserMapper.xml 为例:

xml 复制代码
    <if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
        AND date_format(u.create_time,'%Y%m%d') &gt;= date_format(#{params.beginTime},'%Y%m%d')
    </if>
    <if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
        AND date_format(u.create_time,'%Y%m%d') &lt;= date_format(#{params.endTime},'%Y%m%d')
    </if>

好处是不用给每个实体、每个接口反复添加 beginTimeendTime 字段和参数,所有继承 BaseEntity 的实体都自动具备这套时间范围检索能力。

第二类:数据权限 SQL 的挂载点。 @DataScope 切面在 Service 方法执行前,按当前用户的角色算出一条数据权限 WHERE 子句,直接写入 params

less 复制代码
    // DataScopeAspect.java
    baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");

XML 里用 ${params.dataScope} 把它原样拼进 SQL------注意这里是 ${} 字符串拼接而非 #{} 预编译占位,因为拼的是结构化的 SQL 片段:

bash 复制代码
    ${params.dataScope}

这里有一层安全考虑:params 能被前端请求自动绑进去(第一类用法),而 dataScope 又是字符串拼接,前端完全可以传 params[dataScope]=... 把伪造的权限 SQL 拼进查询。所以切面执行的第一步是先清空再重算:

java 复制代码
    // DataScopeAspect.doBefore
    clearDataScope(point);                       // 先清空,拦截前端注入的 dataScope
    handleDataScope(point, controllerDataScope); // 再按当前用户角色计算真正的权限 SQL 写回 params

于是同一条 selectUserList,普通用户执行时 SQL 末尾多一句部门过滤,管理员则没有。数据权限机制能对所有实体生效,靠的就是 params 这一个公共挂载点。

第三类:Service 向 Mapper 的扩展传参。 业务参数不在实体字段里、又需要传进 SQL 时,params 提供了统一出口。SysMenuServiceImpl 查菜单列表时,非管理员要把当前 userId 传进 SQL 做过滤,直接写入 params

java 复制代码
    // SysMenuServiceImpl.selectMenuList
    menu.getParams().put("userId", userId);
    menuList = menuMapper.selectMenuListByUserId(menu);

mapper 里再通过 #{params.userId} 取值。不为这种临时参数修改实体类、添加方法签名,是 params 带来的灵活性。三类用法汇总如下:

用法 谁往 params 里写 XML 里怎么取
时间范围检索 Spring MVC 自动绑定请求参数 #{params.beginTime} / #{params.endTime}
数据权限 @DataScope 切面写入权限 SQL ${params.dataScope}
扩展传参 Service 主动 put #{params.userId}

把「非业务的运行时参数」和「业务字段」分开,是 params 设计的核心:业务字段写在类上,强类型、可校验;运行时检索与权限参数放在 params 上,灵活、通用。

TreeEntity:树形结构的第二层抽取

TreeEntity 继承自 BaseEntity,是树形结构实体的基类。部门、菜单、分类这类树形数据,除了审计字段外还有一批树特有字段,TreeEntity 把这批字段也收拢进来,树形实体继承后两批字段同时具备:

java 复制代码
    /** 父菜单名称 */
    private String parentName;

    /** 父菜单ID */
    private Long parentId;

    /** 显示顺序 */
    private Integer orderNum;

    /** 祖级列表 */
    private String ancestors;

    /** 子部门 */
    private List<?> children = new ArrayList<>();

五个字段里,parentId 指向直接父节点,ancestors 是逗号分隔的祖级全路径(如 0,100,101 表示从根到当前节点的完整路径),orderNum 控制同级排序,children 存放子节点,parentName 冗余存父级名称供前端展示。

ancestors 的作用是支撑子孙查询:有了它,「查某节点的所有子孙」不必递归,用 MySQL 的 find_in_set 一条 SQL 就能查完。SysDeptMapper 查某部门的所有子部门就是这种写法:

csharp 复制代码
    select * from sys_dept where find_in_set(#{deptId}, ancestors)

这里用 find_in_set 而非 like 的原因:like '%100%' 会误命中祖级串里恰好含 100 子串的行(比如 0,1000,),而 find_in_set(100, ancestors) 只在 100 是逗号列表里的完整元素时才匹配,结果精确。

children 的 List<?> 问题

children 的类型声明是 List<?> 通配符,这个类型在使用中有明显限制:List<?> 是只读视图,add 方法编译不过------children.add(new SysDept(...)) 无法通过编译;而树构建要递归往 children 里添加子节点,通配符类型加不进元素,只能靠强转绕过,编译期的类型检查也就失效了。

树形实体需要的 children 类型各不相同:SysDeptList<SysDept>SysMenuList<SysMenu>,基类无法预知。所以系统里真正的两棵树 SysDeptSysMenu,都是直接继承 BaseEntity、各自声明强类型 children,没有继承 TreeEntity:

java 复制代码
// SysDept.java
public class SysDept extends BaseEntity {
    private Long parentId;                                       // 自己声明,没从 TreeEntity 继承
    private String ancestors;
    private Integer orderNum;
    private String parentName;
    private List<SysDept> children = new ArrayList<SysDept>();   // 强类型
    // ...
}

SysMenu 同理,声明 List<SysMenu> children。手写的核心树实体不继承 TreeEntity、自己声明强类型字段,既保证了类型安全,递归构建的代码也更直接。

TreeEntity 的实际使用者:代码生成器

TreeEntity 在框架核心业务代码里没有实际使用者,真正用到它的是代码生成器。生成树表的 CRUD 代码时,模板按表类型决定实体继承的基类:

shell 复制代码
#if($table.crud || $table.sub)
#set($Entity="BaseEntity")        ## 普通表 -> 继承 BaseEntity
#elseif($table.tree)
#set($Entity="TreeEntity")         ## 树表 -> 继承 TreeEntity
#end
public class ${ClassName} extends ${Entity}

当选中的表里有 parent_id 等树字段、生成类型选成「树表」时,生成的实体就继承 TreeEntity,树字段无需再声明,只需补充自己的业务字段。配套地,生成器判断「哪些字段是父类已有、不在子类重复生成」时,把两个基类的字段都列进了名单:

java 复制代码
    // GenTableColumn.java
    public static boolean isSuperColumn(String javaField) {
        return StringUtils.equalsAnyIgnoreCase(javaField,
                // BaseEntity
                "createBy", "createTime", "updateBy", "updateTime", "remark",
                // TreeEntity
                "parentName", "parentId", "orderNum", "ancestors");
    }

也就是说,TreeEntity 的定位是给生成代码用的现成基类:生成器生成的树表实体继承它以省去重复声明,而框架自己的核心树实体没有使用它。

思考

公共字段上提的收益与代价 :BaseEntity 用继承消除了实体字段的重复声明,审计字段全仓库只定义一次,配合 mapper 里 #{createBy} 等统一取值方式,新增实体几乎零成本获得完整审计能力。代价是 Java 单继承------实体继承了 BaseEntity,就占用了唯一的继承名额,无法再继承其他业务父类。若依认为实体类极少需要第二重继承,这笔交换是划算的;若项目中实体已有平行业务基类,公共字段就只能靠组合或代码生成来补。

主键为什么不放进 BaseEntity :BaseEntity 收拢的是所有表都有、且框架统一处理的字段------审计字段由 Service 统一写入、params 供切面和 MVC 绑定统一读写,主键不属于这一类。若依的表规范是「表名前缀 + Id」命名主键(SysUser.userId 对应 user_idSysDept.deptId 对应 dept_id),各表主键的名字和类型都不同,MyBatis 又按属性名绑定取值,基类定义一个 id 字段帮不到任何子类,反而会与子类自己的主键并存、造成取值混乱。所以主键由每个实体自己声明;TreeSelect 这类确实只需要通用 id 的类(前端树组件的约定字段),也是在类内自己声明。

params 把运行时参数与业务字段分离beginTimedataScopeuserId 这类参数有两个共同点------不属于实体业务字段,却要传进 SQL。若依没有为它们各自添加字段或修改方法签名,而是在基类放一个 Map 作为统一出口:业务字段强类型、可校验,运行时参数弱类型但零成本扩展,两类需求互不干扰。这个设计的边界也很清晰------params 里的值绕过了编译期检查,尤其 ${params.dataScope} 这种字符串拼接,必须像切面那样先清空再重算,否则 params 就成了 SQL 注入的入口。

List<?> 的教训 :TreeEntity 用 List<?> 想兼容所有树形子类,实际效果是既不能 add、又要强转,导致框架核心实体 SysDeptSysMenu 不继承它、自己声明字段。合理的做法是把泛型交给子类:声明为 TreeEntity<T> 并提供 List<T> getChildren(),子类继承时指定自身类型。基类为了通用而放弃类型信息,反而会失去使用者------生成器生成的简单树表用它够用,复杂树形需求还是强类型字段更可靠。

相关推荐
阿拉斯攀登1 小时前
安全Python编程:requests库、正则、脚本编写(POC与扫描器基础)
架构
阿拉斯攀登1 小时前
SQL注入工具实战:sqlmap使用、参数详解、批量扫描方案
架构
她的男孩1 小时前
AI 写完 100 万行代码没人 Review,我做了一件事:把 AI 写的代码管起来了
java·人工智能·程序员
Gl�ria1 小时前
Linux 查看日志常用命令
java·linux·servlet
阿拉斯攀登1 小时前
Windows安全基础:CMD-PowerShell、注册表、系统日志、权限机制
架构
2601_953988071 小时前
Ricon组态实时监控 - 毫秒级数据可视化
前端·物联网·数学建模·信息可视化·架构·前端框架
大模型丫丫1 小时前
LangGraph + MCP(Model Context Protocol)完整讲解
java·开发语言·数据库
lemon_sjdk1 小时前
JavaFX 源码深度剖析:揭开 StringBinding 的神秘面纱
java·javafx·源码解析·绑定框架
万岳科技程序员小金1 小时前
同城O2O外卖系统源码开发详解:从APP、小程序到后台管理平台的完整架构解析
小程序·架构·同城外卖系统源码·外卖app开发·外卖小程序开发·外卖软件开发·外卖平台搭建