SSH分层架构实战:Hibernate映射、Spring事务与HQL调优
2026/9/17 23:43:34 网站建设 项目流程

简介:《基于SSH框架的Web网站设计与实现》是一份面向计算机与通信工程专业学生、Java Web初学者及毕业设计撰写者的PDF论文资料,围绕Struts、Spring、Hibernate三大开源框架的整合应用,讲解Web网站从可行性分析、需求分析到系统设计与实现的完整流程,并涵盖摘要、前言、设计技术、系统运行环境与结论建议等章节。压缩包内为1个PDF文件,整体约75KB,篇幅紧凑,便于在电脑或移动端随时查阅。目前已有127人学习下载。文档以官网系统、图书出售系统、办公系统为设计案例,依次展开系统架构、数据库设计、持久层Hibernate实现、控制层Struts实现与业务层Spring实现,并附有结论与建议,可帮助读者理解三层架构的分层职责、框架配置思路与数据库建表逻辑,适合作为课程设计、毕业论文的参考范本和SSH整合开发的入门指引。

1. 从论文到可运行系统:SSH分层架构到底解决了什么问题

翻这份《基于SSH框架的Web网站设计与实现》的人,第一反应多半是"又是三个框架叠在一起的老题"。可真正按目录把官网系统、图书出售系统、办公系统三块功能落地,会发现它是一份很典型的企业级 Java Web 训练样本:Struts 管请求分发与页面跳转,Spring 管对象装配和事务,Hibernate 管实体类与关系表之间的映射。它处理的正是同一份代码里"页面流转、业务编排、SQL 拼装"三件事互相缠绕的老毛病——分层之前,一个 Servlet 里既写 SQL 又做校验还 forward 页面,改一处牵全身;分层之后,每层只对自己的输入输出负责。适合两类人:要照着目录补课程设计或毕业论文的在校生,以及想回头把 SSH 这套分层思想摸清、再对照当下 Spring Boot 单体项目的后端工程师。下面按数据库、持久层、业务层、控制层、联调排错五段拆开讲,每一步都落到能跑的配置和代码上。

2. 数据库设计与Hibernate实体映射落地

数据库这一层是整份资料里最容易被写虚的部分,论文往往给一张 E-R 图就过了,但真正让三个子系统跑起来,表结构必须先定死。官网、图书出售、办公三块业务共享一套用户表,其余各自独立,这种"一库多域"的划分直接决定了后面 Hibernate 映射文件怎么拆。

2.1 三个子系统的表结构拆解

表名关键字段归属子系统说明
t_userid, username, password, role, status共用role 区分前台会员与后台管理员
t_noticeid, title, content, publish_time官网系统公告与新闻
t_bookid, book_name, price, stock, category_id图书出售category_id 外键指向 t_category
t_categoryid, cate_name, parent_id图书出售支持一级/二级分类
t_orderid, order_no, user_id, total_amount, order_state图书出售order_state 表示待付款/已发货
t_order_itemid, order_id, book_id, quantity, unit_price图书出售订单明细,价格快照
t_oa_docid, doc_no, title, drafter, flow_state办公系统flow_state 表示审批流转状态

设计时有两个点值得较真。一是价格字段统一用DECIMAL(10,2)而不是 float,金额运算一旦用浮点,累加几十条明细就会出现零点几分的误差,对账时非常难查。二是订单明细里冗余存unit_price,而不是下单时靠book_id现查,因为商品调价后历史订单金额必须保持原样,这是订单类表设计的常见做法。

2.2 实体类与映射文件怎么写

论文里提到 Hibernate 实现,2013 年前后主流是 XML 映射,也可以直接用注解。用注解更省事,实体和映射放一起,改字段不容易漏同步:

@Entity @Table(name = "t_book") public class Book implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "book_name", nullable = false, length = 100) private String bookName; @Column(name = "price", precision = 10, scale = 2) private BigDecimal price; @Column(name = "stock") private Integer stock; // 多对一:多本书属于一个分类,LAZY 避免查书时顺带把分类整表拉出来 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; // getter / setter 省略 }

@GeneratedValue(strategy = IDENTITY)对应 MySQL 的自增主键,交给数据库生成,比native更明确。FetchType.LAZY是关键,默认多对一在部分版本是 EAGER,一旦图书列表页渲染时循环访问book.getCategory().getCateName(),N+1 查询会瞬间拖慢响应,改成 LAZY 后由业务代码决定何时取。

如果用 hbm.xml,等价写法是:

<class name="com.demo.entity.Book" table="t_book"> <id name="id" column="id"> <generator class="identity"/> </id> <property name="bookName" column="book_name" not-null="true" length="100"/> <property name="price" column="price" type="big_decimal"/> <many-to-one name="category" column="category_id" lazy="proxy" fetch="select"/> </class>

lazy="proxy"表示关联对象用代理,访问时才发 SQL;fetch="select"是默认的单独查询策略,如果确认列表页一定会用到分类名,可以改成fetch="join"用一条外连接捞回来,用空间换查询次数。

2.3 泛型DAO基类与HQL分页

三个子系统每个都有增删改查,如果每个实体写一套重复代码,维护成本会很高。常见做法是抽一个泛型基类:

public abstract class BaseDaoHibernate<T, ID extends Serializable> extends HibernateDaoSupport { private Class<T> entityClass; @SuppressWarnings("unchecked") public BaseDaoHibernate() { // 通过反射拿到子类上声明的泛型实参,避免手写 Class 参数 Type type = getClass().getGenericSuperclass(); this.entityClass = (Class<T>) ((ParameterizedType) type).getActualTypeArguments()[0]; } public T get(ID id) { return (T) getHibernateTemplate().get(entityClass, id); } public void save(T entity) { getHibernateTemplate().save(entity); } @SuppressWarnings("unchecked") public List<T> findByHql(String hql, Object... args) { return (List<T>) getHibernateTemplate().find(hql, args); } }

getHibernateTemplate()HibernateDaoSupport提供,它内部会判断当前线程是否已有事务绑定好的 Session,有就复用,没有就临时开一个,这也是后面 Spring 声明式事务能生效的前提。分页则用Query.setFirstResult / setMaxResults实现:

public Page<Book> pageByKeyword(String keyword, int pageNo, int pageSize) { String hql = "from Book b where b.bookName like ?"; String countHql = "select count(b.id) from Book b where b.bookName like ?"; Object[] args = new Object[]{"%" + keyword + "%"}; int total = ((Long) getHibernateTemplate() .find(countHql, args).get(0)).intValue(); Query query = getSession().createQuery(hql); query.setParameter(0, "%" + keyword + "%"); query.setFirstResult((pageNo - 1) * pageSize); // 起始行号从 0 开始 query.setMaxResults(pageSize); // 每页条数 return new Page<Book>(query.list(), total, pageNo, pageSize); }

setFirstResult计算的是偏移量而不是页码,这是新手最容易写错的地方,写成pageNo * pageSize会直接漏掉第一页数据。另外模糊查询的%必须拼在参数值里,不能写成like '%?%',那样 Hibernate 会当成字面量而不是占位符。

提示:hibernate.hbm2ddl.auto在开发期设成update方便自动建表,上线前务必改成validatenone,否则一次误删字段就可能引发线上事故。

3. Spring容器接管Bean与声明式事务配置

分层之后,Action 要拿 Service,Service 要拿 DAO,DAO 要拿 SessionFactory,如果全靠new手动串起来,改动一处依赖就得改一片代码。Spring 在这份资料里的角色就是把这个对象装配的活接过去,同时用 AOP 给业务方法套上事务。

3.1 数据源与SessionFactory装配

applicationContext-dao.xml里先把数据源和会话工厂配出来:

<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/ssh_web?useUnicode=true&amp;characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="root"/> <property name="initialSize" value="5"/> <property name="maxActive" value="50"/> </bean> <bean id="sessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mappingResources"> <list> <value>com/demo/entity/Book.hbm.xml</value> <value>com/demo/entity/Order.hbm.xml</value> </list> </property> <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.jdbc.batch_size">30</prop> </props> </property> </bean>

连接串里的useUnicode=true&characterEncoding=utf8必须带上,少了它中文入库会变成问号。maxActive默认只有 8,图书列表并发一高就会排队等连接,按并发量调到 30 到 50 比较稳妥。batch_size在批量导入书籍数据时能明显减少往返数据库的次数。

3.2 Service层与AOP事务切面

事务配置推荐用<tx:advice>+<aop:config>这种切面式写法,而不是每个 Bean 上挂TransactionProxyFactoryBean,后者类一多配置就失控:

<bean id="txManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager"> <property name="sessionFactory" ref="sessionFactory"/> </bean> <tx:advice id="txAdvice" transaction-manager="txManager"> <tx:attributes> <tx:method name="save*" propagation="REQUIRED" rollback-for="Exception"/> <tx:method name="add*" propagation="REQUIRED"/> <tx:method name="update*" propagation="REQUIRED"/> <tx:method name="delete*" propagation="REQUIRED"/> <tx:method name="get*" read-only="true" propagation="SUPPORTS"/> <tx:method name="find*" read-only="true" propagation="SUPPORTS"/> </tx:attributes> </tx:advice> <aop:config> <aop:pointcut id="servicePointcut" expression="execution(* com.demo.service..*.*(..))"/> <aop:advisor advice-ref="txAdvice" pointcut-ref="servicePointcut"/> </aop:config>

命名规则在这里很关键:Service 方法名以save/add/update/delete开头的走写事务,get/find开头的走只读事务。只读事务里 Hibernate 会关闭脏检查,查询性能会好一些。业务层还要负责把多个 DAO 操作拼成一个原子单元,比如下单:

public void createOrder(Order order, List<OrderItem> items) { for (OrderItem item : items) { Book book = bookDao.get(item.getBookId()); if (book.getStock() < item.getQuantity()) { throw new BizException("库存不足:" + book.getBookName()); } book.setStock(book.getStock() - item.getQuantity()); bookDao.update(book); orderItemDao.save(item); } orderDao.save(order); }

扣库存和写明细必须在一个事务里,中间任何一步抛异常都要整体回滚。这里抛的是运行时异常,配合rollback-for指定的规则,BizException才能触发回滚。

3.3 事务失效的几个典型坑

第一个坑是把业务方法写成 private 或 final。Spring AOP 默认走 JDK 动态代理或 CGLIB 子类代理,private 方法拦不住,final 类没法被继承,事务注解静默失效,日志里也不会报错。第二个坑是同类内部自调用,比如saveOrder()里直接this.updateStock(),调用不经过代理对象,事务同样不起作用,得注入自身的代理或者把方法挪到另一个 Bean。第三个坑是 Service 方法里吞掉了异常,try-catch后只打日志不往外抛,事务管理器收不到异常信号就会照常提交。

注意:数据源如果用DataSourceTransactionManager配 Hibernate,Session 不会自动绑定到线程,getHibernateTemplate()每次都会新开 Session,懒加载和一级缓存都会出问题,必须用HibernateTransactionManager

4. Struts控制层与web.xml装配,打通请求链路

Spring 把下半身的对象管起来之后,最上面还差一层把浏览器请求接到 Service 上。Struts 负责这件事:解析 URL、封装参数、调用业务方法、根据返回值挑一个 JSP 渲染。整条链路能不能通,web.xml 的加载顺序是决定性因素。

4.1 struts.xml 动作映射与结果视图

<struts> <!-- 交给 Spring 创建 Action,Action 里才能直接注入 Service --> <constant name="struts.objectFactory" value="spring"/> <constant name="struts.devMode" value="false"/> <package name="book" extends="struts-default" namespace="/book"> <action name="list" class="bookAction" method="list"> <result name="success">/WEB-INF/jsp/book/list.jsp</result> </action> <action name="detail" class="bookAction" method="detail"> <result name="success">/WEB-INF/jsp/book/detail.jsp</result> <result name="error">/WEB-INF/jsp/common/error.jsp</result> </action> </package> </struts>

struts.objectFactory=spring是 Struts 与 Spring 集成的开关,不设这一项,Action 由 Struts 自己反射 new 出来,@Autowired的 Service 永远是 null。class属性写的是 Spring 容器里 Bean 的 id,所以 Action 的 Bean 名要和它对齐。JSP 放在WEB-INF下是刻意的,这样浏览器无法直接访问.jsp,所有页面都只能经过 Action,权限校验才拦得住。

4.2 Action与参数封装

public class BookAction extends ActionSupport { private BookService bookService; // 由 Spring 注入 private List<Book> bookList; private Long id; private int pageNo = 1; private Page<Book> page; public String list() { page = bookService.pageByKeyword("", pageNo, 10); this.bookList = page.getData(); return SUCCESS; } public String detail() { Book book = bookService.getBook(id); if (book == null) { return ERROR; } this.bookList = Collections.singletonList(book); return SUCCESS; } // setter 必须提供,Struts 靠 setter 把请求参数灌进来 public void setBookService(BookService bookService) { this.bookService = bookService; } public void setId(Long id) { this.id = id; } public void setPageNo(int pageNo) { this.pageNo = pageNo; } public Page<Book> getPage() { return page; } public List<Book> getBookList() { return bookList; } }

Struts 的参数注入靠 setter,?id=12会调setId(12L),类型转换由内置转换器完成。pageNo这种基本类型如果不传参,默认是 0 而不是 1,所以在字段声明时就赋初值 1,避免分页计算出现负数偏移。list()返回的字符串SUCCESS对应 struts.xml 里的<result name="success">,这是 Struts 的约定,全部换成自定义常量也行,但读起来不如直接返回SUCCESS直观。

4.3 web.xml 加载顺序与过滤器链

<!-- 1. Spring 容器随应用启动,先于任何 Filter --> <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> <!-- 2. 编码过滤器必须排在 Struts 之前 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 3. Struts 核心过滤器 --> <filter> <filter-name>struts2</filter-name> <filter-class>org.apache.struts2.dispatcher.FilterDispatcher</filter-class> </filter> <filter-mapping> <filter-name>struts2</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

加载顺序有两条硬规则。第一,ContextLoaderListener是 Listener,Servlet 规范保证 Listener 先于 Filter 初始化,所以 Action 被创建时 Spring 容器一定已经就绪。第二,多个 Filter 的执行顺序由filter-mapping在文件中出现的先后决定,不是由<filter>定义的顺序决定,编码过滤器写在前面才能真正对请求体生效,放到 Struts 后面中文参数照样乱码。

提示:如果页面报No bean named 'bookAction' available,八成是 contextConfigLocation 的通配符没匹配到文件,或者 Action 的 bean id 与 struts.xml 里class属性的值不一致,先把 Spring 启动日志里的 Bean 定义列表打出来核对。

4.4 部署到Tomcat与懒加载越界

打包成 war 丢进 Tomcat 的 webapps 目录,或者用 IDE 的 artifact 部署都可以。这里最常见的运行期报错是LazyInitializationException: could not initialize proxy - no Session,原因就是 JSP 渲染时想访问关联对象,但事务已经在 Service 方法返回时提交、Session 关闭了。三种解法,各有取舍:

方案做法适用场景代价
OpenSessionInViewOpenSessionInViewFilter页面层级简单Session 生命周期拉长,连接占用变多
提前抓取HQL 用join fetch关联明确查询语句变复杂
DTO 转换Service 里转成 VO 返回结构稳定多写转换代码

我一般先用join fetch改查询,实在绕不开再上OpenSessionInViewFilter,后者是个兜底方案而不是首选,因为它让 Session 拖到视图渲染完才关闭,数据库连接池压力会明显上升。

5. 用HQL调优与SQL日志定位性能问题

系统能跑起来只是及格线,图书列表这种数据量会持续增长的页面才是考验。Hibernate 默认show_sql打出的语句没有参数值,排查慢查询时看不到实际入参,把日志切到 SQL 参数模式更有用:

# log4j.properties log4j.logger.org.hibernate.SQL=DEBUG log4j.logger.org.hibernate.type=TRACE log4j.logger.org.hibernate.stat=DEBUG

org.hibernate.type开到 TRACE 后,日志里会带上每个绑定参数的实际取值,模糊查询里的%关键词%一眼就能看到。org.hibernate.stat打开统计后,可以通过sessionFactory.getStatistics()拿到查询次数、缓存命中率这些指标,用它来验证一级缓存是不是真的生效。

判定 N+1 问题有个简单办法:打开一本书列表页,数一下日志里出现了几条select ... from t_book。如果列表页显示 10 本书,日志里却出现了 10 次以上针对t_category的查询,那就是典型的懒加载逐条触发。改法是把 HQL 写成:

String hql = "select distinct b from Book b left join fetch b.category where b.stock > 0";

join fetch让分类信息在同一条 SQL 里用左外连接带回来,distinct是为了去掉一对多连接可能产生的重复行。要注意join fetchsetFirstResult一起用的时候,部分 Hibernate 版本会在内存里做分页,数据量大反而更慢,这种情况就得退回两步查询——先查主键分页,再按主键批量捞关联对象。

批量操作的写法也值得注意。逐条save一万本书,日志会刷出一万条 insert,改成 flush 加 clear 能显著降低内存占用:

for (int i = 0; i < books.size(); i++) { getHibernateTemplate().save(books.get(i)); if (i % 30 == 0) { getHibernateTemplate().flush(); // 把缓存里的 SQL 刷到数据库 getHibernateTemplate().clear(); // 清一级缓存,防止内存持续膨胀 } }

这里的 30 要和前面数据源里配的hibernate.jdbc.batch_size对齐,否则 JDBC 层攒不够一批就发出去,批处理等于白配。最后留一个判断经验:如果同一条 HQL 在开发机几百毫秒、到服务器上要好几秒,先看执行计划里的全表扫描,再确认like '%关键词%'这种前置通配是不是让book_name上的索引直接失效了,这种情况要么改成前缀匹配,要么单独建一张检索用的宽表。

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

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

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

立即咨询