☰
SSH四件套整合实战:Spring Security与Hibernate事务懒加载避坑指南
2026/10/20 9:59:02 网站建设 项目流程

简介:基于Spring Security、Spring MVC、Spring和Hibernate整合的权限管理系统项目源码,面向Java后端开发者,重点演示SSH框架下用户认证、授权及角色-资源管理的实现方式。整个压缩包共196个文件,约18.66MB,包含60个jar依赖库、34个Java源文件及对应class文件,同时提供JSP页面、XML配置、SQL脚本、CSS与JavaScript等前端资源,便于直接导入IDE运行与调试。该资源已有283人学习浏览,适合作为课程设计、毕业设计或企业项目中的权限管理模块参考。项目实现了完整的用户登录认证与权限拦截流程,通过SecurityMetadataSource动态加载资源,AccessDecisionManager进行访问决策,支持用户-角色-资源三级关联管理,并利用Hibernate完成ORM持久化。结合Spring的依赖注入与AOP事务管理,整体代码模块划分明确,能够帮助开发者快速理解SSH整合的关键配置与权限控制核心逻辑,便于二次开发与功能扩展。

1. SSH 安全四件套:为什么老系统改造时最头疼的就是它

springsecurity+springmvc+spring+hibernate 这套组合在教程里已经很少见了,但存量系统改造时反而绕不开。我这两年接手过的项目里,至少有三分之一还在跑类似结构,最麻烦的安全部分往往要整个重写:登录、会话、细粒度权限,几乎每个老系统都要过一遍。这套整合的难点在于三者互相牵扯——Spring Security 的过滤器链、Hibernate 的懒加载、Spring 的事务边界会互相影响,改一处,另一处就翻车。这份资源把四件套串成一个可运行的完整工程,从 web.xml 装配、会话工厂加载,到认证授权与事务优化,每一步都带对应配置和可复制代码。适合要接手 SSH 存量系统的开发者,也适合想把 Spring MVC + Hibernate + Spring Security 整合逻辑一次理顺的从业者。下面统一叫它 SSH 四件套。

2. Spring 与 Hibernate 握手:会话工厂装配与实体映射的关键配置

这一章先把地基打牢。Hibernate 会话工厂装配得好不好,直接决定后面 Spring Security 的 UserDetailsService 和 Service 层事务能不能安稳跑起来。

2.1 装配一个真正可用的 SessionFactory:四个必填参数

Hibernate 单独使用时,要自己写 Configuration().configure(),最后再 buildSessionFactory()。一旦放进 Spring 容器,这套手动流程就交给 LocalSessionFactoryBean 管理。它的作用不是替代 Hibernate 的 Configuration,而是把数据源、方言、实体扫描路径、参数等统一从 XML 注入,由 Spring 决定会话工厂的创建与销毁时机。

常见做法是在 applicationContext-dao.xml 里这样配置:

<bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/sshdemo?useUnicode=true&amp;characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="initialSize" value="5"/> <property name="maxTotal" value="20"/> </bean> <bean id="sessionFactory" class="org.springframework.orm.hibernate5.LocalSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="packagesToScan" value="com.demo.entity"/> <property name="hibernateProperties"> <props> <prop key="hibernate.dialect">org.hibernate.dialect.MySQL5Dialect</prop> <prop key="hibernate.show_sql">true</prop> <prop key="hibernate.format_sql">true</prop> <prop key="hibernate.hbm2ddl.auto">update</prop> <prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate5.SpringSessionContext</prop> </props> </property> </bean>

先解释这段配置的作用顺序:Spring 先创建数据源,再把数据源交给 LocalSessionFactoryBean,最后扫描 com.demo.entity 下的实体类,按方言生成与 MySQL 对应的 SQL 方言实现。整个过程中 Spring 负责管理 SessionFactory 的生命周期,应用重启时容器会负责释放旧连接。

四个关键参数单独挑出来说。hibernate.dialect 决定分页语法、主键生成策略和字段类型映射,MySQL 5 用 MySQL5Dialect,Oracle 11g 用 Oracle10gDialect。这里容易踩坑的是库表大小写:Linux 下 MySQL 表名默认大小写敏感,方言本身解决不了这个问题,要配合后文的命名策略一起处理。hibernate.show_sql 建议开发环境开、生产关,format_sql 打开后至少能看出 Hibernate 生成的 SQL 是两行还是二十行,省得排查问题全靠猜。hbm2ddl.auto 是双刃剑,update 模式适合开发,但它只增不改、只建不删,别指望它做表结构变更;生产环境我更习惯用 validate,让启动时校验实体和表结构是否对得上。

最后一行 current_session_context_class 单独讲。这个属性把 Hibernate 的 Session 上下文绑定到 Spring 的事务同步管理器上,这样一来 getCurrentSession() 拿到的就是事务内部那个 Session,它会在事务提交时自动 flush 并释放。如果漏掉这行,最常见报错就是 No Session bound to thread,后面避坑章还会专门讲到。

2.2 实体映射与命名策略:Java 驼峰与数据库下划线之间没有巧合

实体类表面上只是加注解,实际决定了一张表的名称、字段类型、约束和关联关系能否如预期生成。我见过不少项目直接把 Java 类的字段名拿去做列名,结果数据库里全是 username、createDate 这种大小写混写,换到 PostgreSQL 或 Linux 的 MySQL 上,查询时连引号都要纠结半天。

推荐写法是显式指定 @Table 和 @Column,不要依赖 Hibernate 自动推断。示例:

@Entity @Table(name = "sys_user") public class SysUser implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @Column(name = "username", nullable = false, unique = true, length = 50) private String username; @Column(name = "password", length = 100, nullable = false) private String password; @Column(name = "enabled", nullable = false) private Boolean enabled = true; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "sys_user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) private List<SysRole> roles = new ArrayList<>(); }

这个实体直接服务于 Spring Security 的用户加载逻辑。@Column 的 nullable、unique、length 会同步反映到 hbm2ddl 生成的建表语句里,所以约束最好一次写对,别让 Hibernate 去猜。@ManyToMany 这里选择 FetchType.EAGER,是因为 Spring Security 登录认证成功后需要立刻拿到角色列表,如果在认证流程中触发懒加载,又要额外处理 Session 边界,很容易把问题搞复杂。

命名策略方面,Spring 整合 Hibernate 时默认会走驼峰转下划线规则,也就是 createDate 自动映射为 create_date。如果你的数据库表是老同事按 Java 字段名建的,每个列都带大小写,反而要禁用这种自动转换:

<property name="hibernate.physical_naming_strategy"> org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl </property>

参数说明:PhysicalNamingStrategyStandardImpl 表示不转换物理列名,实体里写什么列名,SQL 里就用什么。另一个常见配置是 SpringPhysicalNamingStrategy,会把列名统一成小写下划线。这个选择没有绝对对错,但一定要和数据库现有表的命名风格一致,否则启动时 validate 就会报一堆列找不到。

2.3 DAO 层写法:别再用 HibernateTemplate,直接用 getCurrentSession

早期 SSH 教程里大量出现 HibernateTemplate 和 HibernateDaoSupport,这两个东西借助 Spring 的回调机制帮忙管理 Session 开关。问题是代码被套成了模板模式,查一条记录也要包一层回调,一旦出现嵌套调用,事务边界和异常转换反而更难判断。

在普通的 @Repository Bean 里,直接注入 SessionFactory 就行:

@Repository public class SysUserDao { @Autowired private SessionFactory sessionFactory; public SysUser findByUsername(String username) { String hql = "from SysUser where username = :username"; Query<SysUser> query = sessionFactory.getCurrentSession() .createQuery(hql, SysUser.class); query.setParameter("username", username); return query.uniqueResult(); } }

getCurrentSession() 的前提是当前线程已经处于 Spring 事务上下文中,这个 Session 由事务管理器拦截器负责关闭,不需要手动 close。相比之下 openSession() 会创建一个与事务无关的临时 Session,用完必须自己处理异常和关闭,稍一疏忽就是连接泄漏。

findByUsername 里的 createQuery(hql, SysUser.class) 是 Hibernate 5.2 之后带实体类型的重载,省去了强转。查询里不用写 select *,直接 from SysUser 即可,Hibernate 会自动映射字段。参数绑定统一用 :username 加 setParameter,一方面防止拼接注入,另一方面也便于 Hibernate 做 SQL 计划缓存。

到这里,DAO 层与 SessionFactory 的关系就打通了。下一步要解决的是入口问题:外部请求怎么安全地进来,认证和权限规则放在哪一层,这是第 3 章的内容。

3. Spring MVC 接收请求、Spring Security 守住入口:认证与授权落地

3.1 过滤器链如何拦截每一个请求

SSH 整合里常见的困惑是:Security 配置写在 security.xml,它怎么知道要拦截 /login、/admin/** 这些 URL?原因是 web.xml 里注册了 springSecurityFilterChain 过滤器,它本身是一个 DelegatingFilterProxy,把过滤逻辑委托给 Spring 容器里名为 springSecurityFilterChain 的过滤器链 Bean。

web.xml 的关键配置:

<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> <dispatcher>REQUEST</dispatcher> </filter-mapping>

这里的过滤顺序值得注意。如果后面加了 OpenSessionInViewFilter,尽量让它在 springSecurityFilterChain 之前,因为认证过程要查数据库,如果 Session 还没绑定到当前线程,UserDetailsService 里的查询也会碰上懒加载问题。

参数说明:DispatcherServlet 的 url-pattern 配成 / 表示接管所有非静态资源请求,静态资源要单独放行。springSecurityFilterChain 的 url-pattern 用 /*,包含所有请求路径,登录成功与失败的跳转由过滤器链内部处理,不需要在 Controller 里手写会话判断。

开启过滤器链后,请求流是这样:请求进 Servlet 容器,被 springSecurityFilterChain 拦截,未认证则跳转登录页或返回 401;已认证则继续放行到 DispatcherServlet,再按 Controller 匹配。这个过程对调用方是透明的,但对调试很有用:登录失败后先看过滤器链有没有短路,再看 Controller 有没有执行。

3.2 登录认证:UserDetailsService、数据库表设计与 BCrypt 密码

Spring Security 不需要依赖固定表结构,只要实现 UserDetailsService 接口,从自己的表里把用户和角色捞出来组装成 UserDetails 交给认证管理器即可。安全模块只关心两件事:用户名是否存在、密码是否匹配。

我一般会在 security.xml 里做如下配置:

<security:http auto-config="false" use-expressions="true"> <security:intercept-url pattern="/login" access="permitAll"/> <security:intercept-url pattern="/css/**" access="permitAll"/> <security:intercept-url pattern="/js/**" access="permitAll"/> <security:intercept-url pattern="/admin/**" access="hasRole('ADMIN')"/> <security:intercept-url pattern="/**" access="isAuthenticated()"/> <security:form-login login-page="/login" default-target-url="/index" authentication-failure-url="/login?error=true"/> <security:logout logout-url="/logout" logout-success-url="/login?logout=true" delete-cookies="JSESSIONID"/> </security:http> <security:authentication-manager> <security:authentication-provider user-service-ref="userDetailsService"> <security:password-encoder ref="passwordEncoder"/> </security:authentication-provider> </security:authentication-manager> <bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

use-expressions="true" 允许在 access 里写 hasRole、hasAuthority、isAuthenticated 这类表达式。login-page 指向的是 Spring MVC 的 /login 控制器,这个页面本身必须 permitAll,否则用户还没登录就进不去登录页。default-target-url 是登录成功后跳转的地址,如果需要记录用户原本想访问的页面,可以借助 savedRequest 机制,这里不再展开。

UserDetailsService 的实现如下:

@Service public class SysUserDetailsService implements UserDetailsService { @Autowired private SysUserDao sysUserDao; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = sysUserDao.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在: " + username); } List<GrantedAuthority> authorities = new ArrayList<>(); if (user.getRoles() != null) { for (SysRole role : user.getRoles()) { authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName())); } } return new User(user.getUsername(), user.getPassword(), authorities); } }

两个细节需要说明。第一,角色前缀 ROLE_ 要在这里拼好,因为后面的 hasRole('ADMIN') 实际匹配的是 ROLE_ADMIN。如果库里存的是 ADMIN、代码里写的也是 ADMIN,但 hasRole 始终不通过,多半就是前缀没统一。第二,authorities 里的内容会进入 SecurityContext,Controller 里可以通过 Authentication 对象直接读取,不要在 Service 层反复查库取角色。

密码加密这块,老项目最常见的错误是数据库里存明文,然后用自定义逻辑做解密。正确做法是存 BCrypt 密文,匹配时由 BCryptPasswordEncoder 的 matches 方法完成。数据库密码字段长度至少要 60,因为 BCrypt 输出固定 60 字符。

3.3 授权模型:URL 规则与方法级安全一起用

只配置 URL 拦截规则,权限判断容易集中在 Controller 层,复用性差。配合方法级安全,把权限放在 Service 方法上,是更稳的组合。开启方式是在 security.xml 里加:

<security:global-method-security pre-post-annotations="enabled"/>

这样就能在 Service 方法上写 @PreAuthorize:

@PreAuthorize("hasRole('ADMIN')") public void deleteUser(Long id) { // 删除用户逻辑 }

URL 规则解决“能访问哪个页面”,方法级权限解决“能操作哪个动作”。两者不一致时,以更严格的一侧为准。实际项目里经常出现菜单已经隐藏了某个按钮,但接口仍能被直接调用的情况,方法级权限就是最后的防线。

这里给一张常用权限表达式对照表:

表达式含义典型位置
permitAll不要求认证URL 规则,登录页/静态资源
isAuthenticated()已登录可访问URL 规则默认值
hasRole('ADMIN')拥有 ROLE_ADMIN 角色URL/方法级
hasAuthority('user:delete')拥有某个具体权限码方法级
hasAnyRole('ADMIN','AUDITOR')多个角色任一即可方法级

拥有角色和拥有权限码是两种建模思路。角色适合粗粒度,权限码适合细粒度;如果系统需求已经细到“导出报表”和“删除报表”要分开授权,建议改用 hasAuthority,角色只作为权限码集合的逻辑分组。

4. 事务边界与懒加载:跨层调用最容易翻车的两个点

4.1 事务要放在 Service:注解与代理的生效条件

DAO 层直接使用 getCurrentSession,就要求外层必须有事务,那事务应该放在哪一层?标准做法是放在 Service 实现类的方法上,因为一个业务操作通常要跨多个 DAO 方法,比如创建用户时要插入用户表、插入用户角色关系表,这两步必须同生共死。

事务配置如下:

<bean id="transactionManager" class="org.springframework.orm.hibernate5.HibernateTransactionManager"> <property name="sessionFactory" ref="sessionFactory"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

Service 方法上标注 @Transactional 后,Spring 会为类生成代理,代理在方法进入前开启事务,方法正常返回后提交,抛出 RuntimeException 时回滚。

@Service public class SysUserServiceImpl implements SysUserService { @Transactional(rollbackFor = Exception.class, readOnly = false) public void createUserWithRoles(SysUser user, List<Long> roleIds) { sysUserDao.save(user); for (Long roleId : roleIds) { sysUserRoleDao.saveRelation(user.getId(), roleId); } } @Transactional(readOnly = true) public List<SysUser> listUsers(Integer pageNo, Integer pageSize) { return sysUserDao.findPage(pageNo, pageSize); } }

两个参数值得单独说明。rollbackFor = Exception.class 是把受检异常也纳入回滚范围,因为 Spring 默认只在 RuntimeException 和 Error 时回滚,如果业务代码里抛出业务自定义的 Exception,不加这个参数就会提交一个半成品事务。readOnly = true 是给数据库和 Hibernate 的查询优化提示,它不会真的阻止写操作,但能让 Session 的 flush 模式变慢,减少无意义的脏数据检查。

4.2 LazyInitializationException:发生在哪里、怎么处理

Hibernate 的实体关联默认都是懒加载,@ManyToOne、@OneToMany 只有在属性被访问时才发 SQL 查询。这本身是性能优化,但跨层调用时有个致命问题:Service 里查询出来的实体,到 Controller 层或视图模板里访问它的集合属性时,Session 可能已经关了,于是抛 LazyInitializationException。

最常见的报错现场有两种:Controller 把查询到的 User 对象直接传给视图,JSP 里写 user.roles.size,渲染时崩溃;或者把实体直接转 JSON 返回给前端,序列化 roles 属性时抛异常。解决思路有两条线。

第一条线是打开 OpenSessionInViewFilter,把 Session 生命周期拉长到整个请求:

<filter> <filter-name>openSessionInView</filter-name> <filter-class>org.springframework.orm.hibernate5.support.OpenSessionInViewFilter</filter-class> </filter> <filter-mapping> <filter-name>openSessionInView</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

这个过滤器会把 Spring 管理的 Session 绑定到当前线程,直到视图渲染完成才关闭。优点是改动小、见效快,代价是数据库连接被占用更久,连接池如果太小,并发一高就会打满。生产系统不建议无脑挂这个过滤器,请想清楚自己的并发量再决定。

第二条线是 DTO 转换。在 Service 内把需要的数据查全并拷贝到 DTO,Controller 只面向 DTO 编程。比如用户列表页只需要 userId、username、roleNames 三个字段,就写一个专用查询方法:

@Transactional(readOnly = true) public List<UserListDTO> listUserWithRoles(int pageNo, int pageSize) { String hql = "select new com.demo.dto.UserListDTO(u.id, u.username, r.roleName) " + "from SysUser u join u.roles r order by u.id desc"; return sessionFactory.getCurrentSession() .createQuery(hql, UserListDTO.class) .setFirstResult((pageNo - 1) * pageSize) .setMaxResults(pageSize) .list(); }

关键在 join u.roles 这个内连接,它已经在 SQL 层把角色查出来了,Hibernate 返回的 DTO 里只有三个标量字段,彻底避开集合懒加载。DTO 构造器参数顺序要与 select 子句一致,字段类型和返回值类型要固定。

4.3 事务与懒加载一起用:getCurrentSession 的必要前提

把前面的内容汇到一起看,getCurrentSession 能用,前提是当前线程已有 Spring 事务上下文。如果你在 Controller 里直接调 DAO 的 findById,而 Controller 没加 @Transactional,DAO 的 getCurrentSession 就会抛异常。这不是 DAO 写错了,而是事务边界没遵守分层约定。

常规做法是:Controller 不碰事务,所有业务逻辑进 Service;Service 的每个方法成为事务边界;DAO 只做数据访问。如果方法内部确实需要先查询再更新,也都在同一个事务内完成,比如先 getUser 再更新 role 集合,事务会把这两步包成一个整体。

提示:如果你在 Controller 里看到自己手动调用了 sessionFactory.getCurrentSession(),基本可以判定事务分层已经乱套了。重构时先把 Controller 里的数据访问代码挪到 Service,再加上 @Transactional 注解,很多运行期怪问题会不治而愈。

5. 避坑指南:SSH 整合中我踩过的 5 个运行时问题

这一章把工程中最常见的几个运行期问题按现象、原因、解决的顺序列出来,每条都值得对照自己的项目查一遍。

5.1 hbm2ddl.auto=update 不建表也不更新字段

现象:项目启动不报错,但数据库里没有表,或者新增实体字段后 update 模式没有生成新列。

原因:dialect 配置不正确、账号没有 DDL 权限,或者实体类没有被 packagesToScan 扫描到。还有一种隐蔽情况——数据库连接的是 A 库,但配置文件里写的是 B 库的 URL,表自然建到了 B。

解决:启动时打开 show_sql,观察有没有生成建表语句;检查数据库 URL、账号权限;确认实体类所在的包确实在 packagesToScan 里。开发环境可以用 validate 模式做一次校验,让缺列缺表在启动阶段就暴露出来。我在一个新项目中遇到过 update 失效,排查到最后发现是 Maven 打出来的 jar 里没把实体类带进去,连 ClassNotFoundException 都没报,只是静默扫描不到。

5.2 getCurrentSession 报 “No Session bound to thread”

现象:Service 方法上没加 @Transactional,DAO 里调用 getCurrentSession() 抛异常;或者加了注解仍然报错。

原因:缺少 hibernate.current_session_context_class=SpringSessionContext,或事务管理器没有交给 Spring 管理,或 Service 方法不是通过代理调用的。

解决:按顺序检查 SessionFactory 的 hibernateProperties 中是否配置了 SpringSessionContext;检查 Spring 配置文件里有没有注册 HibernateTransactionManager;检查 Service 实现类是否被 Spring 扫描到,进入方法时在 IDE 里确认 AopProxy 有生效。如果是在工具类或定时任务里手动调用 DAO,也要确保调用入口上有事务注解。

5.3 @Transactional 没有生效、没有回滚

现象:代码里写了 rollbackFor = Exception.class,但异常抛出后数据还是入库了。

原因:最常见的是自调用,即同一类的 A 方法调用 this.B 方法,B 上的注解代理逻辑根本不会执行。其次是方法声明为 private,Spring 的 CGLIB 代理无法介入。还有一种情况是 try-catch 把异常吞掉,事务管理器感知不到异常。

解决:自调用拆到不同 Bean 里,比如把 B 放到另一个 Service 类中,通过注入的对象调用;private 方法改成 public;异常要抛出去,不要用 try-catch 吃掉后再返回一个错误码。这条规则也同样适用于 @PreAuthorize 之类的方法级权限注解,代理失效时权限检查也会一并失效。

5.4 用户密码正确但 Spring Security 一直提示 Bad credentials

现象:数据库里密码已经存了密文,用户名也对,登录始终失败。

原因:最可能是密码编码器没配置或配置不一致。比如数据库是用 MD5 生成的密文,而 security.xml 里配的是 BCryptPasswordEncoder;或者 authentication-provider 没有指定 password-encoder,Security 默认按明文比对。

解决:统一密码算法。存量数据如果是 MD5,一种做法是把数据库历史密文切换为 BCrypt,登录成功后把明文密码转换成 BCrypt 再回写;另一种做法是暂时保留 MD5 并提供对应编码器,同时做双编码兼容。我一般建议直接迁移到 BCrypt,MD5 作为凭证存储算法在安全场景下已经不够用。排查时可以临时打印传入的 password 和存储的 password 前后缀,确认不是配置了两次编码器导致二次哈希。

5.5 返回 JSON 时一直报 LazyInitializationException

现象:接口返回用户列表,Jackson 序列化时提示无法初始化 roles 集合属性。

原因:实体里的 @ManyToMany 设置为懒加载,返回 JSON 时 Session 已经关闭,序列化器会尝试加载所有关联属性,从而触发异常。

解决:两层配套处理。实体上给需要序列化的关联属性标注 @JsonIgnore,或配置 Hibernate5Module 让它忽略无法加载的懒加载属性;业务层改用 DTO 输出,只暴露需要返回的字段。查询时如果确实需要关联数据,就用第 4.2 节的 join 查询,而不是把整个实体直接往外丢。顺带提一句,开启 OpenSessionInViewFilter 只能解决页面渲染场景,对 JSON 序列化同样有效,但代价是连接占用变长,要评估连接池容量再决定。

6. 进阶技巧:把安全审计与分页查询打磨顺手

登录记录是很多管理系统的隐性需求。把 Spring Security 的 AuthenticationSuccessEvent 监听起来,就能在不侵入原有登录流程的前提下记录每次成功登录。

@Component public class LoginSuccessListener implements ApplicationListener<AuthenticationSuccessEvent> { @Autowired private OperationLogDao operationLogDao; @Override public void onApplicationEvent(AuthenticationSuccessEvent event) { String username = event.getAuthentication().getName(); Object details = event.getAuthentication().getDetails(); String ip = ""; if (details instanceof WebAuthenticationDetails) { ip = ((WebAuthenticationDetails) details).getRemoteAddress(); } operationLogDao.save(new OperationLog(username, "LOGIN_SUCCESS", ip, new Date())); } }

这里从 WebAuthenticationDetails 里取 IP 是最常见做法,它由表单登录的过滤器写入 authentication details。如果部署在反向代理后面,取到的可能是代理地址,需要再结合 X-Forwarded-For 头处理。监听器里不要做耗时操作,写日志的 DAO 操作建议走独立事务或异步线程,避免阻塞认证响应。

分页查询也是 Hibernate 项目里反复要写的东西。数量统计和列表查询往往是两条 SQL,但很少有人解释为什么要拆开。因为 count 和 list 的返回结构不同,一条查询只能选一种结果类型。下面这段是 HQL 分页的基础写法:

@Transactional(readOnly = true) public PageResult<Course> findCoursePage(int pageNo, int pageSize) { Session session = sessionFactory.getCurrentSession(); Query<Course> query = session.createQuery( "from Course c where c.status = :status order by c.createTime desc", Course.class); query.setParameter("status", 1); query.setFirstResult((pageNo - 1) * pageSize); query.setMaxResults(pageSize); List<Course> rows = query.list(); Long total = session.createQuery( "select count(c) from Course c where c.status = :status", Long.class) .setParameter("status", 1) .uniqueResult(); return new PageResult<>(rows, total.intValue()); }

setFirstResult 与 setMaxResults 配合方言生成对应的 limit 语句,页码从 1 开始,第一页偏移量是 0。pageSize 建议放在常量类里统一管理,避免每个方法写死一个 10 或 20。统计查询里写 select count(c) 而不是 count(*),便于 Hibernate 把 count 语句优化成单列计数。Hibernate 5.2 及以上版本里列表查询统一用 query.list(),如果工程升级到较新的 Hibernate 6,签名会变成 getResultList(),迁移时留意这一处即可。

这套 SSH 四件套整合做完一遍,我最大的教训是:配置文件的顺序和职责边界比任何一行代码都重要。从那以后我每次接手老系统,都强制先画一遍 web.xml 的过滤器顺序、事务边界和懒加载触发点,再动手改代码,很多半夜才能查完的“玄学问题”其实都只是分层没理清。拿到这份工程后,按这个顺序把配置文件和实体类逐一过一遍,应该能少走不少弯路。希望这些经验能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询