简介:这份文档面向Java Web初学者与需要搭建企业级应用的中级开发者,系统讲解表示层、业务逻辑层与数据访问层的职责划分,并围绕Spring、Struts、Hibernate组成的SSH组合框架,说明如何借助IoC管理Action等各类bean,构建结构清晰、可复用且易维护的Web应用。内容从框架技术切入,依次剖析Struts的MVC组件、Spring的IoC与DI机制及模块构成、Hibernate的对象关系映射与HQL查询,再结合案例展示SSH集成方案在实际项目中的落地方式,帮助读者理解各层解耦思路与轻量级J2EE开发流程。资源包共1个doc文件,约102KB,以文字讲解与结构图示为主,适合作为架构入门与方案参考。目前已有2378人学习下载,可用于快速建立三层架构的整体认知,并为后续框架整合实践提供思路铺垫。
1. 三层架构:为什么“分层”反而让改代码变快了
很多人第一次接触 Java Web 三层架构,直觉是“多此一举”:一个用户查询功能,明明一个 Servlet 里十几行 JDBC 就能跑通,为什么非要拆成 Action、Service、DAO 三个类,再配三份 XML?分层看起来是增加了文件数量,但真正跑过几个迭代、经历过一次数据库字段改名或前端改版的人会发现一个反直觉的结论:分层不是为了让代码“好看”,而是为了把变化关进笼子。表示层、业务逻辑层、数据访问层各自只对一类变化敏感,改 JSP 不碰 SQL,换数据库不动 Action。SSH 组合(Struts + Spring + Hibernate)正是这套思路在 J2EE 时代的经典落地:Struts 管 MVC 分离,Spring 用 IoC 把 bean 的装配权从代码里拿走,Hibernate 把对象与表的映射从手写 SQL 里抽出来。搞懂这三者各自解决了哪一段耦合,才能明白为什么一个ListUser.do请求能穿过四层而不乱。
2. 表示层与 Struts:ActionServlet 如何把请求“派”出去
2.1 从*.do到 Action:一次请求的完整路由
Struts 的核心不是 JSP 标签库,而是那个前端控制器 ActionServlet。它在web.xml里被配置成拦截特定后缀(典型是*.do)的请求,然后读取struts-config.xml里的<action-mappings>,把 URL 映射到具体的 Action 类。这个设计的关键在于:请求路径与处理类之间的对应关系被外置到了配置文件,而不是写死在if-else里。新增一个功能,改配置加一个 Action,不用动路由代码。
一个典型的映射片段长这样:
<!-- struts-config.xml:把 /ListUser 这个逻辑路径绑定到 ListUserAction --> <action-mappings> <action path="/ListUser" type="com.simon.oa.user.controller.ListUserAction" scope="request"> <!-- 处理成功后跳转到用户列表页 --> <forward name="success" path="/user/list.jsp"/> </action> </action-mappings>path是浏览器请求的逻辑名,type是实际处理类,forward定义结果页。ActionServlet 收到/ListUser.do后,实例化(或复用)对应的 Action,调用它的execute方法,再把返回的ActionForward交给页面。这里有个容易被忽略的机制:Action 本身是无状态的,Struts 默认对每个请求复用同一个 Action 实例,所以 Action 里不能存请求级数据,否则并发下就是血泪经验。请求参数要么放ActionForm,要么从request里取。
2.2 Action 为什么不该写业务:控制器只做“翻译”
Struts 的 MVC 分工里,Action 属于 Controller,它的职责是“翻译”:把 HTTP 参数翻译成业务层能理解的入参,把业务层返回的结果翻译成 forward 名字。它不该知道数据库表结构,也不该写事务边界。原文的案例里,ListUserAction只做一件事——调用UserService拿到用户列表,塞进 request,返回success。这样做的直接收益是:同一段业务逻辑,Web 端和定时任务端可以共用,因为业务不在 Action 里。
常见的实现思路是让 Action 继承DispatchAction或直接实现execute,把不同操作(增、删、查)收敛到一个 Action 的不同方法里,减少类爆炸。但要注意,Struts2 与 Struts1 在这一点上机制不同:Struts2 的 Action 是每次请求新建实例,且通过拦截器栈完成参数绑定,Action 可以持有请求级状态。原文用的是 Struts2.0,所以 Action 里出现成员变量是安全的,但这也意味着它和 Spring 的单例 bean 生命周期不一致——这正是下一章要处理的装配问题。
2.3 视图层只放标记:JSP 里为什么不该有流程逻辑
原文强调视图“只有标记,没有流程逻辑、业务逻辑和模型信息”。这不是洁癖,而是因为 JSP 一旦嵌入if判断和数据库调用,页面就变成了不可测试的黑匣子。Struts 的 TagLib(如<logic:iterate>、<bean:write>)把循环和输出收敛成标签,页面里剩下的只是“把 request 里的集合渲染成表格”。这样前端改版时,改的是标签和 HTML,业务代码零改动。我一般会这么理解:视图层的唯一输入是“已经准备好的数据”,它不负责准备数据,只负责摆放数据。
3. Spring IoC:谁在管理 Action 和 DAO 之间的那根线
3.1 控制反转到底“反转”了什么
传统写法里,ListUserAction要调用UserService,就得自己new UserService();UserService要调UserDao,又得自己new UserDao()。这条依赖链是硬编码在代码里的,换一个实现类就得改源码。Spring 的 IoC 把“创建对象、装配依赖”这件事从代码里搬到容器里:容器启动时读applicationContext.xml,按配置把UserDao实例注入到UserService,再把UserService注入到 Action。控制权从“程序代码”转移到了“外部容器”,这就是“反转”的字面含义。
原文特别提到“包括 Struts2 的 action”也交给 Spring 管理,这是 SSH 集成里最关键的一步。Struts2 自己会创建 Action 实例,如果不做整合,Action 里的userService永远是 null。整合的办法是让 Struts2 的 objectFactory 委托给 Spring,Action 的class属性写 Spring 里的 bean 名,由 Spring 负责实例化和注入。这样 Action 才真正成为容器里的一颗 bean,而不是游离在容器外的孤儿。
3.2 设值注入与构造注入:两种装配方式的取舍
Spring 支持两种注入方式。设值注入靠 setter:
<!-- applicationContext.xml:把 userDao 注入到 userService 的 setter --> <bean id="userService" class="com.simon.oa.user.service.impl.UserServiceImpl"> <property name="userDao" ref="userDao"/> </bean> <bean id="userDao" class="com.simon.oa.user.dao.hibernate.UserDaoImpl"> <property name="sessionFactory" ref="sessionFactory"/> </bean>构造注入则通过构造方法参数完成。设值注入的好处是依赖关系清晰、便于后期替换,且能处理循环依赖;构造注入的好处是对象一旦创建就处于完整状态,不会出现“半初始化”的 bean。原文的案例用的是设值注入,这也是当时的主流选择,因为 Hibernate 的HibernateDaoSupport本身就依赖 setter 注入SessionFactory。
这里有个机制细节值得说透:UserDaoImpl继承HibernateDaoSupport后,不需要自己写getSession(),父类的getHibernateTemplate()已经封装了 session 的获取、事务参与和异常转换。save、loadAll这些方法背后,HibernateTemplate 会确保每次操作都在一个受管的 session 里,异常被转成 Spring 的DataAccessException体系。这就是为什么 DAO 里看不到try-catch和session.close()——容器和模板类替你做了。
3.3 事务与连接池:业务层拿到的“隐形服务”
Spring 在业务层提供的不仅是 bean 装配,还有声明式事务。通过 AOP 代理,UserService的方法可以被织入事务边界:方法进入开事务,正常返回提交,抛异常回滚。配置上通常是一个TransactionProxyFactoryBean或<tx:advice>,把save*、update*这类方法名匹配成需要事务的方法。开发者写业务代码时完全不用碰Transaction对象,这就是“横切关注点”被容器吸收的价值。
连接池同理。Hibernate 的数据源由 Spring 注入,连接池参数(最大连接数、超时)在配置文件里调,DAO 代码无感知。原文说“由业务层的 Spring 容器提供”,准确理解是:这些服务组件由容器创建和管理,业务层只是使用者。这种“基础设施与业务代码分离”的设计,让换连接池实现(比如从基础池换到成熟池)只需要改配置,不动一行 Java。
4. Hibernate 持久层:对象和表之间的那道映射怎么建
4.1 POJO 与.hbm.xml:映射文件在描述什么
Hibernate 的核心动作是把一个 Java 类的属性对应到一张表的列。这个对应关系写在.hbm.xml里:
<!-- User.hbm.xml:把 User 类的属性映射到 t_user 表的列 --> <hibernate-mapping> <class name="com.simon.oa.user.model.User" table="t_user"> <id name="id" column="id" type="java.lang.Integer"> <generator class="native"/> <!-- 主键生成策略交给数据库 --> </id> <property name="userName" column="user_name" type="java.lang.String"/> <property name="score" column="score" type="java.lang.Integer"/> </class> </hibernate-mapping><id>的generator决定主键怎么来,native表示跟随底层数据库(MySQL 自增、Oracle 序列)。<property>把属性名和列名解耦,Java 里叫userName,表里叫user_name,映射文件是唯一的翻译表。有了这层映射,DAO 里操作的是User对象,不是ResultSet。原文说“屏蔽了具体的数据库和表、字段”,指的就是这层抽象:换数据库改hibernate.cfg.xml的方言和连接,映射文件基本不动。
4.2 HQL 与 JDBC 的差别:查询为什么也面向对象
Hibernate 提供 HQL,查询语句里写的是类名和属性名,不是表名和列名:
// 查询积分大于阈值的用户,from 后面是类名,where 里是属性名 List<User> users = getHibernateTemplate() .find("from User u where u.score > ?", minScore);这段代码经过 Hibernate 解析后,才翻译成目标数据库的 SQL。好处是查询语句与数据库方言解耦,且返回的是已经装配好的对象列表,不需要手动rs.getInt("score")。代价是 HQL 有学习成本,且复杂查询(多表关联统计)写起来不如原生 SQL 直观。常见的做法是简单查询用 HQL,复杂报表查询走原生 SQL 并配合createSQLQuery,两者在同一个 DAO 里共存。
4.3 DAO 继承HibernateDaoSupport后省掉了什么
原文的UserDao extends HibernateDaoSupport是个很典型的写法。父类持有SessionFactory,并提供getHibernateTemplate()。模板类替你做了三件事:获取当前线程绑定的 session(保证同一事务内共用一个 session)、把受检的HibernateException转成运行时异常、在操作结束后正确释放资源。如果没有这层封装,每个 DAO 方法都要写openSession、beginTransaction、commit、close,而且一旦漏掉close,连接池很快耗尽——这是早期项目里最常见的翻车点之一。
5. 避坑与排查:SSH 集成里最容易踩的五个坑
5.1 Action 里的 Service 为 null
现象:请求进入 Action 后抛NullPointerException,日志显示userService是 null。原因:Struts2 自己 new 了 Action,没有走 Spring 容器,注入自然不生效。解决:确认struts.xml里 Action 的class写的是 Spring bean 的 id,且web.xml里配置了 Spring 的ContextLoaderListener和 Struts2 的SpringObjectFactory。检查顺序是先看容器是否启动、再看 bean id 是否拼错。
5.2LazyInitializationException在页面渲染时爆发
现象:Action 里拿到对象没问题,JSP 遍历关联集合时报session is closed。原因:Hibernate 的延迟加载依赖 session 存活,而 session 在 Service 方法结束、事务提交后就关了,页面渲染时再去查关联数据就晚了。解决:要么在 Service 层用Hibernate.initialize()提前初始化需要的集合,要么把事务边界延伸到视图渲染(OpenSessionInView 过滤器),后者方便但会拉长 session 生命周期,高并发下要谨慎。
5.3 事务不生效:方法内部调用绕过了代理
现象:Service 里 A 方法调用本类的 B 方法,B 上配了事务,但异常时没有回滚。原因:Spring 的声明式事务靠 AOP 代理实现,this.b()是对象内部调用,不经过代理,事务切面根本没织入。解决:把 B 方法挪到另一个 bean,或者通过注入自身代理来调用。这个坑很隐蔽,因为代码看起来完全正常。
5.4 映射文件与 POJO 不一致导致启动失败
现象:应用启动时抛MappingException,提示某个属性找不到。原因:.hbm.xml里写的属性名和 POJO 的字段/getter 对不上,或者映射文件没被注册进sessionFactory的mappingResources。解决:对照 POJO 逐个核对属性名,确认配置文件里列出了所有映射文件。反向工程生成的映射也要人工过一遍,别默认它一定对。
5.5 DAO 被当成实现类而不是接口
原文作者自己提到“误将 DAO 理解成一个能够实际操作的类,而不是一个接口”。现象:想用 Spring 给 DAO 做替换或代理时发现无从下手。原因:直接写了一个具体 DAO 类,业务层依赖的是实现而非抽象。解决:先定义UserDao接口,再在dao.hibernate包下写UserDaoImpl实现,Spring 配置里注入接口类型。这样换实现、加事务代理、做单元测试 mock 才有空间。
6. 分层的代价与边界:什么时候该拆,什么时候该合
三层架构不是免费的。每加一层就多一次方法调用、多一个配置文件、多一处需要同步修改的地方。原文的目录方案里,一个用户模块要拆出controller、dao、dao.hibernate、model、service、service.impl、vo七个子包,小项目里这种粒度会让人在包之间反复跳转。我一般会这么判断:变化频率不同的东西才值得分层。数据库 schema 和页面改版几乎不会同步发生,所以持久层和表示层该分开;但一个只有三行逻辑的 Service,如果只是透传 DAO,那它存在的价值是给未来留扩展点,而不是当下。
另一个取舍是 VO 与 DTO 的边界。原文作者也坦承“VO 和 DTO 类似,原则上只有 public fields,但很多人也加 setter/getter”。我的经验是:跨进程传输的对象保持贫血、只带数据;需要参与业务计算的对象才加行为。把两者混成一个类,短期省事,长期会让传输对象背上业务方法,改一处牵动两端。
验证分层是否合理,有个笨办法但有效:试着只改数据库表名,看需要动几个文件。理想情况下只动.hbm.xml和配置文件;如果 Action 或 JSP 里也出现了表名,说明分层漏了。再试着只改页面布局,看 Service 是否要动;如果动了,说明视图逻辑漏进了业务层。这种“单点变更影响面”的检查,比任何架构图都更能暴露耦合。
从那以后我每次搭新模块,都会先画一张“谁依赖谁”的箭头图,确认箭头只从上往下走、不出现反向引用,再开始写代码。分层本身不产生价值,被约束的依赖方向才产生价值。希望帮到你。
本文还有配套的精品资源,点击获取