☰
Java Web综合案例实战:从Servlet到JDBC的用户管理系统
2026/10/15 19:34:59 网站建设 项目流程

1. 从零散知识点到完整项目:为什么需要一个综合案例

很多人学Java Web的经历都差不多:Servlet学一遍、JSP学一遍、JDBC学一遍、Filter和Listener再各学一遍,每个知识点单独看都能理解,但一旦要把它们串起来做一个完整的东西,脑子就空了。这不是你一个人的问题,而是因为大部分教程都是按“技术点”来组织的,而不是按“项目”来组织的。技术点像是一颗颗散落的珠子,综合案例就是那根把它们串起来的线。

这个综合案例要解决的问题很明确:把前面学的Servlet、JSP、JDBC、Session、Filter、MVC分层这些东西,揉进一个能跑起来的Web应用里。它适合已经学完Java Web基础语法、但还没有独立做过完整项目的同学。做完这个案例,你应该能清楚地知道一个请求从浏览器发出到数据库返回数据、再到页面渲染,中间到底经过了哪些环节,每个环节由谁负责。

我当初学完Servlet之后,觉得自己什么都会了,结果第一次动手做项目,连“登录功能该写几个类”都搞不清楚。后来带过几个新人,发现大家卡住的地方高度一致:不是某个技术点不会,而是不知道代码该往哪里放。所以这篇内容不会再去重复讲Servlet的API怎么用,而是重点讲一个综合案例从设计到落地的完整思路,以及那些教程里不会写的坑。

2. 案例选型:为什么是“用户管理系统”而不是别的

2.1 选一个“麻雀虽小五脏俱全”的业务场景

综合案例的业务不能太复杂,否则你会把精力耗在业务逻辑上而不是技术上;但也不能太简单,否则Filter、Session、分页这些技术点根本用不上。我对比过几种常见的案例选题,最后认为“用户管理系统”是最合适的。

它天然包含登录、注册、列表展示、增删改查、分页、权限控制这些功能,每一个都对应着Java Web的核心技术点。登录对应Session和Filter,列表对应JDBC查询和JSP渲染,增删改查对应Servlet的请求分发,分页对应SQL的limit和页码计算。业务本身你闭着眼睛都能理解,这样注意力就能全部放在技术实现上。

2.2 功能清单与技术点映射

在动手写代码之前,先把功能和技术点的对应关系列清楚,这一步非常关键。很多人上来就建表写代码,写到一半发现某个功能需要用到Filter,又回头改结构,返工成本极高。

功能模块涉及技术点难度
用户登录Servlet、Session、Filter基础
用户注册Servlet、JDBC、表单验证基础
用户列表JDBC查询、JSP、EL表达式基础
分页展示SQL limit、页码计算中等
修改用户Servlet请求分发、JDBC更新中等
删除用户批量删除、事务处理中等
权限拦截Filter、Session校验中等
中文乱码处理CharacterEncodingFilter基础

这张表建议你自己也画一遍,画完之后你对整个项目的全貌就清楚了。我每次带新人做项目,第一步都是让他们先画这张表,而不是急着写代码。

2.3 技术栈的取舍:为什么不用框架

有人会问,都什么年代了还用原生Servlet和JSP?直接用Spring Boot不香吗?这个问题要分两面看。如果你是为了快速做一个能上线的产品,那确实应该用框架。但如果你是为了理解Web开发的底层原理,原生Servlet和JSP是最好的教材。

框架帮你屏蔽了大量细节,比如请求参数怎么解析、Session怎么管理、数据库连接怎么池化。这些细节在你用框架的时候感觉不到,但一旦出了问题,你连排查的方向都没有。我见过太多人用Spring Boot写了两年的接口,却不知道一个HTTP请求在Servlet容器里是怎么被处理的。所以这个综合案例的价值不在于做出一个多牛的产品,而在于让你把Web开发的每个环节都亲手摸一遍。

3. 项目骨架搭建:目录结构和分层设计

3.1 包结构的划分逻辑

一个清晰的包结构能让你在写代码的时候不用思考“这个类该放哪”。我推荐的分层方式是按照MVC的思路来,但比教科书上的分层要更实用一些。

com.example.usermanager ├── controller // Servlet层,负责接收请求和分发 ├── service // 业务逻辑层,处理业务规则 ├── dao // 数据访问层,只负责和数据库打交道 ├── entity // 实体类,和数据库表一一对应 ├── util // 工具类,如数据库连接、字符串处理 └── filter // 过滤器,如编码过滤、登录校验

这个结构看起来简单,但每个包都有明确的职责边界。controller只负责拿参数、调service、跳页面,不写业务逻辑。service负责业务规则,比如“用户名不能重复”这种判断就放在这里。dao只负责SQL的执行,不关心业务。这样分层的好处是,当你需要改数据库的时候,只需要动dao层;当你需要改业务规则的时候,只需要动service层。

3.2 数据库连接的管理方式

数据库连接是新手最容易出问题的地方。很多人写代码是这样的:每次需要查数据库就DriverManager.getConnection()一下,用完就close。这种写法在功能上没问题,但性能极差,因为每次建立连接都要经过TCP握手和身份验证。

正确的做法是用连接池。在综合案例阶段,我建议用Druid或者HikariCP,配置简单,性能也好。如果你不想引入第三方库,至少也要自己写一个简单的连接池工具类,把连接缓存起来复用。

public class DBUtil { private static DataSource dataSource; static { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/user_manager?useSSL=false&serverTimezone=UTC"); ds.setUsername("root"); ds.setPassword("your_password"); ds.setInitialSize(5); ds.setMaxActive(20); ds.setMinIdle(5); dataSource = ds; } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 依次关闭,注意异常处理 } }

这里有个细节要注意:serverTimezone=UTC这个参数在MySQL 8.0以上是必须的,否则会报时区错误。我当初被这个问题卡了整整一个下午,一直以为是代码写错了,最后才发现是连接字符串少了这个参数。

3.3 实体类的设计原则

实体类就是和数据库表一一对应的Java类。设计的时候有一个原则:实体类的属性名和数据库字段名要能对应上,但命名风格可以不同。数据库用下划线命名(如user_name),Java用驼峰命名(如userName)。这样在写SQL的时候需要做映射,但代码可读性更好。

public class User { private Integer id; private String userName; private String password; private String email; private Date createTime; // getter和setter省略 }

实体类不要加任何业务逻辑,它就是一个数据容器。有些人喜欢在实体类里写一些计算方法,比如getDisplayName()之类的,这在简单项目里没问题,但在复杂项目里会导致实体类和业务逻辑耦合,后期很难维护。

4. 核心功能实现:从登录到分页的完整链路

4.1 登录功能:Session和Filter的配合

登录功能看起来简单,但它是理解Session和Filter的最佳入口。整个流程是这样的:用户在登录页面输入用户名和密码,Servlet接收参数,调用service层验证,验证通过后把用户信息存入Session,然后跳转到首页。同时,一个登录校验Filter会拦截所有需要登录才能访问的页面,检查Session中是否有用户信息。

// LoginServlet的核心逻辑 protected void doPost(HttpServletRequest request, HttpServletResponse response) { String userName = request.getParameter("userName"); String password = request.getParameter("password"); User user = userService.login(userName, password); if (user != null) { request.getSession().setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/user/list"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }

这里有一个新手经常踩的坑:登录失败后用的是forward而不是redirect。为什么?因为forward是在服务器内部跳转,request域中的errorMsg还能被JSP取到;如果用redirect,request域就失效了,错误信息传不过去。这个细节很多教程不会专门讲,但实际开发中非常关键。

Filter的写法也有讲究。不是所有请求都需要登录校验,登录页面本身、注册页面、静态资源(CSS、JS、图片)都应该放行。我见过有人在Filter里判断URL是否包含“login”,这种写法很脆弱,一旦URL变了就失效。更好的做法是在web.xml或者注解里配置Filter的拦截路径,把不需要拦截的路径排除掉。

4.2 用户列表与分页:SQL limit的正确用法

分页是综合案例里最有技术含量的部分之一。它的核心是两个SQL:一个查总数,一个查当前页的数据。

-- 查总数 SELECT COUNT(*) FROM user; -- 查当前页数据 SELECT * FROM user LIMIT #{offset}, #{pageSize};

offset的计算方式是:(当前页码 - 1) * 每页条数。比如每页10条,第3页的offset就是20。这个计算看起来简单,但页码的边界处理很容易出错。如果用户手动把URL里的page参数改成0或者负数怎么办?如果改成999怎么办?这些都需要在代码里做校验。

public PageResult<User> getUserPage(int pageNum, int pageSize) { // 页码校验 if (pageNum < 1) pageNum = 1; int totalCount = userDao.countAll(); int totalPage = (int) Math.ceil((double) totalCount / pageSize); if (pageNum > totalPage) pageNum = totalPage; int offset = (pageNum - 1) * pageSize; List<User> list = userDao.findByPage(offset, pageSize); PageResult<User> result = new PageResult<>(); result.setList(list); result.setPageNum(pageNum); result.setTotalPage(totalPage); result.setTotalCount(totalCount); return result; }

分页还有一个容易被忽略的点:排序。如果不加ORDER BY,数据库返回的数据顺序是不确定的,可能导致同一页数据在不同请求中重复或遗漏。所以分页查询一定要加ORDER BY,通常按id排序。

4.3 增删改查的请求分发策略

一个模块的增删改查通常有5个操作:列表、新增页面、新增保存、修改页面、修改保存、删除。如果每个操作都写一个Servlet,那一个用户模块就要写6个Servlet,类会非常多。

常见的做法是用一个Servlet处理一个模块的所有请求,通过一个参数来区分具体操作。比如在URL后面加一个method参数:

// /user?method=list // /user?method=add // /user?method=edit // /user?method=delete

在Servlet的service()方法里根据method参数分发到不同的处理方法。这种写法减少了Servlet的数量,但缺点是随着功能增加,这个Servlet会越来越臃肿。另一种做法是用反射,根据method参数直接调用同名方法,代码更简洁,但调试的时候稍微麻烦一点。

我个人的建议是:在综合案例阶段,用if-else分发就够了,简单直接,容易调试。等你以后用Spring MVC的时候,框架会自动帮你做请求映射,那时候你自然就理解为什么需要框架了。

4.4 中文乱码:一个看似简单却反复出现的问题

中文乱码是Java Web开发中最经典的问题,没有之一。它出现的原因是浏览器和服务器对字符集的编码方式不一致。浏览器默认用UTF-8发送数据,但Tomcat 8之前默认用ISO-8859-1解码,两边对不上就乱码了。

解决方案分两种情况。对于POST请求,在获取任何参数之前设置request.setCharacterEncoding("UTF-8")。对于GET请求,Tomcat 8之后默认就是UTF-8,不需要额外处理;如果是Tomcat 7或更早版本,需要在server.xml的Connector标签里加URIEncoding="UTF-8"。

但最省事的做法是写一个CharacterEncodingFilter,统一处理所有请求的编码:

public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }

这个Filter要配置在所有Filter的最前面,否则后面的Filter如果先获取了参数,编码设置就无效了。这个顺序问题我踩过坑,当时把编码Filter放在登录Filter后面,结果登录Filter里获取的用户名全是乱码,排查了半天才发现是顺序问题。

5. 那些教程不会告诉你的踩坑实录

5.1 ClassNotFoundException:驱动包到底放哪里

这个问题几乎每个新手都会遇到。代码写得没问题,但一运行就报ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因通常只有一个:MySQL驱动包没有放到正确的位置。

在Web项目中,驱动包必须放在WEB-INF/lib目录下,而不是项目的classpath里。因为Web应用运行在Tomcat容器中,容器有自己的类加载器体系,WEB-INF/lib下的jar包才会被Web应用的类加载器加载。如果你只是把jar包加到IDE的build path里,编译能通过,但运行时会找不到。

还有一个细节:MySQL 5.x和8.x的驱动类名不一样。5.x是com.mysql.jdbc.Driver,8.x是com.mysql.cj.jdbc.Driver。如果你用的驱动包版本和代码里写的类名不匹配,也会报ClassNotFoundException。

5.2 空指针异常:那些你以为不会为null的地方

在Web开发中,空指针异常是最常见的运行时错误。有几个地方特别容易出问题:

第一个是request.getParameter()。如果前端没有传这个参数,它返回的是null,不是空字符串。如果你直接拿它去做equals判断或者trim操作,就会空指针。正确的做法是先判断是否为null,或者用工具类做统一处理。

第二个是Session中取数据。用户如果直接访问一个需要登录的页面,Session里可能没有用户信息,这时候getAttribute返回null,如果你直接调用它的方法就会空指针。这就是为什么需要登录Filter,在Filter层就把未登录的请求拦截掉,后面的代码就不用反复判空了。

第三个是数据库查询结果。根据id查询用户,如果id不存在,返回的就是null。如果你不判断直接使用,就会空指针。这种地方我建议用Optional来包装,强制调用方处理null的情况。

5.3 事务问题:删除操作只成功了一半

批量删除是一个典型的需要事务的场景。假设你要删除3个用户,代码是这样的:

for (Integer id : ids) { userDao.deleteById(id); }

如果删除第2个的时候数据库出错了,第1个已经删了,第3个还没删,数据就不一致了。正确的做法是把这三个删除操作放在一个事务里,要么全成功,要么全回滚。

Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); for (Integer id : ids) { userDao.deleteById(conn, id); } conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException(e); } finally { conn.setAutoCommit(true); DBUtil.close(conn); }

这里有一个关键点:dao层的方法必须接收Connection参数,而不能自己从连接池获取连接。因为事务是绑定在Connection上的,如果dao层自己获取连接,那就和service层的Connection不是同一个,事务就失效了。这个设计模式叫做“事务传播”,在Spring里是自动处理的,但在原生JDBC里需要手动传递Connection。

5.4 路径问题:相对路径和绝对路径的坑

在JSP和Servlet中跳转页面时,路径问题非常容易出错。比如在/user/list这个URL下,如果JSP里写 ,浏览器会解析成/user/edit?id=1,而不是你期望的/user/edit?id=1。如果写 ,浏览器会解析成/edit?id=1,丢掉了应用上下文路径。

正确的做法是用request.getContextPath()获取应用上下文路径,然后拼接:

<a href="${pageContext.request.contextPath}/user?method=edit&id=${user.id}">编辑</a>

在Servlet中做重定向时也一样,response.sendRedirect()的路径是相对于服务器根路径的,必须加上contextPath。而request.getRequestDispatcher()的路径是相对于Web应用根路径的,不需要加contextPath。这两个的区别我当初混淆了很久,后来总结了一句话:sendRedirect是给浏览器看的,要加contextPath;forward是给服务器看的,不加contextPath。

6. 从能跑到好用:几个值得做的优化

6.1 把JDBC代码抽成BaseDao

在写dao层的时候,你会发现增删改查的代码结构高度相似:获取连接、创建Statement、设置参数、执行、处理结果、关闭资源。如果每个方法都写一遍,代码会非常冗余。

解决办法是抽一个BaseDao,把公共部分封装起来,子类只需要提供SQL和参数即可。

public abstract class BaseDao { protected int update(String sql, Object... params) { // 公共的更新逻辑 } protected <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) { // 公共的查询逻辑 } }

RowMapper是一个接口,负责把ResultSet的一行转换成一个对象。这样UserDao只需要写SQL和映射逻辑,不用再关心连接和资源关闭。

这个重构的价值不在于代码变短了,而在于当你需要修改数据库连接方式或者加日志的时候,只需要改BaseDao一个地方。这就是分层和抽象的意义。

6.2 用Filter统一处理登录校验和编码

前面已经提到了编码Filter,登录校验Filter也是同样的思路。把这两个横切关注点从业务代码中抽离出来,Servlet里就只需要关注业务逻辑,代码会清爽很多。

登录校验Filter的配置需要注意放行路径。登录页面、注册页面、登录接口、注册接口、静态资源这些都不需要拦截。我通常会在Filter的init方法里配置一个白名单:

private List<String> whiteList = Arrays.asList( "/login.jsp", "/register.jsp", "/user/login", "/user/register", "/css/", "/js/", "/images/" );

然后在doFilter里判断请求路径是否在白名单中,如果在就放行,不在就检查Session。

6.3 前端页面的复用:把公共部分抽成include

JSP有一个很实用的特性叫include指令,可以把公共的页面片段(如头部导航、底部版权、侧边栏)抽成单独的JSP文件,然后在其他页面中引入。这样修改导航栏的时候只需要改一个文件,所有页面都会生效。

<%@ include file="/common/header.jsp" %> <!-- 页面内容 --> <%@ include file="/common/footer.jsp" %>

include有两种写法:<%@ include file="" %>是静态包含,在编译期把文件内容合并进来;<jsp:include page="" />是动态包含,在运行期分别编译再合并。静态包含效率更高,但被包含的文件不能是动态生成的;动态包含更灵活,但效率稍低。在综合案例中,用静态包含就够了。

7. 项目做完之后,你真正学到了什么

做完这个综合案例,你手里会有一个能跑的用户管理系统,但比这个系统更重要的,是你对Web开发整体流程的理解。你知道了一个请求从浏览器发出后,先经过Filter,再到达Servlet,Servlet调用Service,Service调用Dao,Dao查数据库,结果原路返回,最后JSP渲染成HTML发给浏览器。这条链路在你脑子里是清晰的,而不是一堆孤立的知识点。

你也应该体会到了分层的价值。当需求变化时,你只需要修改对应的层,而不是到处改代码。这种思维方式在你以后学习任何框架的时候都会用到,因为Spring MVC、Spring Boot本质上也是在帮你做分层和依赖管理,只是它们做得更自动化、更优雅。

最后说一个我自己的体会:综合案例不要只做一遍。第一遍跟着教程做,能跑起来就行;第二遍关掉教程自己从头写,遇到卡住的地方再查;第三遍尝试加一些教程里没有的功能,比如用Ajax做无刷新登录、用拦截器做权限控制。每做一遍,你对这些技术的理解就会深一层。我当初把用户管理系统写了三遍,第三遍的时候才真正觉得自己懂了Java Web。

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

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

立即咨询