本文基于若依3.9.2、SpringBoot3版本。
Controller 基类封装:BaseController 的模板方法设计
写一个业务 Controller,真正与业务有关的代码往往只有几行,剩下的都是固定动作:列表接口先开分页、再把结果装进统一的表格结构;增删改接口要把影响行数翻译成成功或失败响应;不少接口还要取当前登录人的用户名填进创建人字段。这些动作每个 Controller 都要做,写法应该全仓库统一。若依在 com.ruoyi.common.core.controller 包下提供了 BaseController,把它们收拢成基类方法,做增删改查的 Controller 继承后直接调用:
public class SysUserController extends BaseController
SysUserController、SysRoleController、SysDeptController、SysConfigController 以及 GenController、SysJobController 都属于这一类。也有几个控制器没有继承它:SysLoginController(登录)、CaptchaController(验证码)、CommonController(文件上传下载)、CacheController 和 ServerController(监控),它们不做分页查询、也不返回表格结构,用不到基类这批方法。
BaseController 本身不带任何业务逻辑,只做两件事:提供一个日志对象,收拢四类通用方法:
- 初始化:
initBinder,注册日期类型转换器 - 分页相关:
startPage、startOrderBy、clearPage、getDataTable - 返回值相关:
success、error、warn、toAjax、redirect - 用户相关:
getLoginUser、getUserId、getDeptId、getUsername
日志对象是一个 protected 字段:
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
getLogger(this.getClass()) 传入的是实际子类的 Class,日志里打印的是子类名而不是 BaseController,排查问题时能直接定位到具体控制器。
initBinder:全局生效的日期转换
initBinder 方法上标了 @InitBinder 注解:
@InitBinder
public void initBinder(WebDataBinder binder) {
// Date 类型转换
binder.registerCustomEditor(Date.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
setValue(DateUtils.parseDate(text));
}
});
}
它的作用是给当前 Controller 注册一个 Date 类型的属性编辑器:前端传上来的日期字符串,在绑定到 Date 类型的字段或参数时,自动转成 Date 对象。
@InitBinder 是 Spring MVC 的注解,标了它的方法会在每次请求进入这个 Controller、开始绑定参数之前被自动调用一次,用来初始化本次请求的 WebDataBinder。WebDataBinder 是 Spring 把请求参数绑到 Java 对象上的工具,这里通过 registerCustomEditor(Date.class, ...) 给它注册了一个针对 Date 类型的转换器。因为做增删改查的 Controller 都继承 BaseController,这个日期转换对它们全都生效;没有继承的那几个功能型控制器不带这个转换,不过它们的接口不接收实体或 Date 参数,所以没有实际影响。
转换逻辑用的是 JDK 的 PropertyEditorSupport:重写 setAsText,把字符串交给 DateUtils.parseDate(text) 解析成 Date 再设回去。这里的 DateUtils 是框架自己的类,它继承了 commons-lang3 的 DateUtils,并在子类里备好一组常见日期格式(yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、yyyy.MM.dd 等);parseDate(Object) 是若依加的方法,把字符串和这组格式交给父类逐个格式尝试,匹配上返回 Date、全不匹配返回 null,前端用哪种写法都能接住。
⚠️ 父类的解析是宽松模式,不合法的日期不报错而是被自动进位。实测 2026-02-30 解析成 3 月 2 日、2026-13-01 解析成 2027 年 1 月 1 日;格式完全不匹配时(如带 T 的 2026-09-14T10:20:30)解析失败返回 null,Date 字段被绑成 null 而不是抛出绑定异常。也就是说这条链路只负责兼容多种日期写法,不负责校验日期是否真实存在,需要业务侧自己判断。
这套机制针对的是表单参数和 query 参数的绑定路径。BaseEntity 的 createTime、updateTime 上标了 @JsonFormat,那是对 @RequestBody JSON 请求体生效的;而 GET 列表查询这类 list(SysUser user) 直接拿实体当参数的接口,参数走的是 WebDataBinder 绑定、不经过 Jackson,@JsonFormat 管不到,日期转换靠的就是这里的 @InitBinder。
分页方法:查询接口的标准两步
BaseController 收口了四个分页方法,内部都委托给 PageUtils 和 PageHelper:
protected void startPage() {
PageUtils.startPage();
}
protected void startOrderBy() {
PageDomain pageDomain = TableSupport.buildPageRequest();
if (StringUtils.isNotEmpty(pageDomain.getOrderBy())) {
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
PageHelper.orderBy(orderBy);
}
}
protected void clearPage() {
PageUtils.clearPage();
}
protected TableDataInfo getDataTable(List<?> list) {
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal());
return rspData;
}
四个方法各管一段:
startPage():开启分页。内部从请求参数里取pageNum、pageSize、orderByColumn、isAsc、reasonable五项(缺省时pageNum=1、pageSize=10、isAsc=asc、reasonable=true),把排序列名转成下划线风格、拼上排序方向后交给SqlUtil.escapeOrderBySql过滤,再连同分页数据放进 ThreadLocal;PageHelper 的拦截器发现 ThreadLocal 里有分页参数,就给紧接着的下一条查询 SQL 追加 LIMIT,查询完成后自动清理。所以它必须紧挨着查询调用、只对下一次查询生效。startOrderBy():只设排序、不分页,用于「要全量数据但按某字段排序」的场景。clearPage():手动清理 ThreadLocal 里的分页参数。正常路径下 PageHelper 的拦截器执行完查询会在 finally 里清理,查询本身抛异常也会被清理,所以常规业务流程不需要手动调;真正会残留参数的场景是startPage()之后没有执行到任何 MyBatis 查询——中间代码先抛了异常,或分支逻辑跳过了查询,拦截器从未运行,ThreadLocal 里的分页参数就会留到线程复用时污染下一次查询。clearPage() 兜的就是这种情况。getDataTable():把查询结果封装成TableDataInfo返回,total通过PageInfo从结果 List 里取——PageHelper 分页后返回的 List 实际是一个携带总记录数的 Page 对象,new PageInfo(list).getTotal()就是把它取出来放进响应。如果查询没有经过startPage()(比如只用了startOrderBy()),取到的总记录数就是当前返回的条数。
排序字段这里有个容易看错的地方:SqlUtil.escapeOrderBySql 名字里的 escape 容易理解成转义,实际做的是校验——排序列只允许字母、数字、下划线、空格、逗号、小数点,且长度不超过 500 字符,不符合就抛 UtilException("参数不符合规范,不能进行查询")。排序列最终要拼进 SQL、没法用占位符传参,所以这里用的是白名单校验。另外 reasonable 是 PageHelper 的分页合理化开关,开启后页码超出范围时自动回到第一页或最后一页,若依把它默认打开。
当前项目的分页查询用的就是 startPage() 加 getDataTable() 的标准两步,以 SysConfigController 为例:
public TableDataInfo list(SysConfig config) {
startPage();
List<SysConfig> list = configService.selectConfigList(config);
return getDataTable(list);
}
三行代码完成「接分页参数、执行查询、封装表格响应」的完整流程,业务代码里看不到任何 PageHelper 的 API。
返回值方法:对 AjaxResult 的薄封装
返回值方法都是对 AjaxResult 静态工厂方法的委托,让 Controller 不必每次写 AjaxResult.success(),直接 success() 就行:
public AjaxResult success() { return AjaxResult.success(); }
public AjaxResult success(String message) { return AjaxResult.success(message); }
public AjaxResult success(Object data) { return AjaxResult.success(data); }
public AjaxResult error() { return AjaxResult.error(); }
public AjaxResult error(String message) { return AjaxResult.error(message); }
public AjaxResult warn(String message) { return AjaxResult.warn(message); }
protected AjaxResult toAjax(int rows) { return rows > 0 ? AjaxResult.success() : AjaxResult.error(); }
protected AjaxResult toAjax(boolean result) { return result ? success() : error(); }
success、error、warn 三类分别对应成功(200)、失败(500)、警告(601)三种响应,code 都由 AjaxResult 内部定好。默认提示只有 success 和 error 有:success() 默认「操作成功」、error() 默认「操作失败」;warn 没有无参版本,必须显式传提示语。success 的三个重载里,success(String) 与 success(Object) 容易混:传字符串时按提示语处理,想把字符串当数据返回就得先声明成 Object 或包一层。
warn(601)在项目里用在删除前的业务预检上。SysDeptController 删除部门前先查有没有下级部门、有没有关联用户,命中就直接返回警告:
public AjaxResult remove(@PathVariable Long deptId) {
if (deptService.hasChildByDeptId(deptId)) {
return warn("存在下级部门,不允许删除");
}
if (deptService.checkDeptExistUser(deptId)) {
return warn("部门存在用户,不允许删除");
}
deptService.checkDeptDataScope(deptId);
return toAjax(deptService.deleteDeptById(deptId));
}
601 与 500 的区别在响应语义上:500 表示操作执行失败,601 表示请求本身没问题、只是业务前置条件不满足。前端对这两个码的处理也不同,601 弹一条警告提示、500 弹错误提示,两者都走 Promise 的失败分支、不会进入调用方的成功回调,所以删除接口返回警告时,页面上看到的是提示而不是「删除成功」的后续处理。
toAjax 是给增删改接口用的:Service 返回的是受影响行数或布尔结果,toAjax 把它翻译成对应的成功或失败响应——行数大于 0 或 true 走成功,否则走失败。项目里的 Service 增删改方法都返回 int,所以实际调用的是 toAjax(int rows);toAjax(boolean) 是为返回布尔结果的 Service 准备的另一个重载,当前没有调用方。
redirect 方法是这里唯一不走 AjaxResult 的,用来做页面跳转:
public String redirect(String url) {
return StringUtils.format("redirect:{}", url);
}
它只是拼出一个以 redirect: 开头的字符串。这是 Spring MVC 的约定:Controller 方法返回的 String 会被当作视图名,视图名以 redirect: 前缀开头时,Spring 向客户端发一个 HTTP 302 重定向,而不是走视图渲染。前后端分离的若依接口基本都返回 JSON,当前项目里没有任何地方调用这个方法,它属于保留的传统 MVC 能力;而且要用它跳转页面的话,Controller 类就不能标 @RestController,否则返回值会被直接序列化成 JSON 字符串。
用户方法:从安全上下文取当前登录人
四个用户方法都建立在 getLoginUser 之上:
public LoginUser getLoginUser() {
return SecurityUtils.getLoginUser();
}
public Long getUserId() {
return getLoginUser().getUserId();
}
public Long getDeptId() {
return getLoginUser().getDeptId();
}
public String getUsername() {
return getLoginUser().getUsername();
}
getLoginUser() 委托 SecurityUtils.getLoginUser():后者从 Spring Security 的 SecurityContextHolder 里取出当前线程的 Authentication,再拿它的 principal——这个 principal 就是登录时放进去的 LoginUser 对象。getUserId、getDeptId、getUsername 都是从 LoginUser 里取对应字段的一行封装。
需要注意这几个方法取不到登录用户时不是返回 null:SecurityUtils 的取值逻辑包在 try-catch 里,一旦拿不到 Authentication 或 principal,就抛出携带 401 状态码的 ServiceException。所以未登录时调用这四个方法会直接抛异常,调用方拿到的是异常而不是 null。
实际使用中以 SysDictDataController 新增字典数据为例:
public AjaxResult add(@Validated @RequestBody SysDictData dict) {
dict.setCreateBy(getUsername());
return toAjax(dictDataService.insertDictData(dict));
}
一行 getUsername() 完成创建人字段的填充,紧接的 toAjax(...) 把插入结果翻译成统一响应,一个接口把基类的两类方法都用上了。
思考
用继承收拢固定动作的取舍:BaseController 这批方法也可以做成一个辅助工具类,Controller 注入后调用。若依选择继承,是因为这批方法在 Controller 里出现频率极高,继承后调用路径最短——直接 startPage()、success(),不用前缀、不用注入。代价是占用了 Java 唯一的继承名额,Controller 无法再继承其他基类;从框架的实际结构看,Controller 层几乎不存在需要第二重继承的场景,这笔交换是划算的。
BaseController 不是严格的模板方法模式:经典模板方法模式由父类定义算法骨架、留抽象步骤给子类填空,BaseController 没有抽象方法、没有骨架,它提供的是一组现成的通用动作,标题里的「模板」指的是「Controller 层的标准写法由此固定下来」——分页查询两步、增删改一行 toAjax,全仓库写法一致。这更像「模板式的方法集合」:子类不覆写它,只是反复使用它。
@InitBinder 放在基类而非全局配置:Spring 还提供 @ControllerAdvice 加 @InitBinder 的组合,可以不依赖继承就让日期转换全局生效。若依把它放在 BaseController,与「业务 Controller 普遍继承基类」这个既有结构自然衔接,不需要额外注册全局配置类;缺点是转换能力跟着继承关系走,登录、验证码、文件上传、监控这几个没继承基类的控制器就没有日期转换,要在这些控制器里处理 Date 参数得自己补。两种做法都成立,若依的选择与它的基类体系是一体的。