Java Web综合案例实战:Servlet+JSP+JDBC三层架构完整实现

发布时间:2026/10/15 19:32:19
Java Web综合案例实战:Servlet+JSP+JDBC三层架构完整实现 1. 一个综合案例到底在练什么很多人学Java Web学到一半就卡住了Servlet会写JSP会写JDBC也能连数据库但真给一个完整需求比如“做一个带登录、带列表、带增删改查的小系统”就不知道从哪下手。这个综合案例存在的意义就是把这些散落的知识点串成一条线让你体验一次从需求到跑通的完整过程。我见过太多人学完单个技术点后简历上写着“熟悉Java Web”结果面试官让描述一个完整项目的数据流从浏览器发请求到数据库返回中间经过哪些层、每层干什么就说不清楚了。问题不在于技术没学会而在于从来没有把技术点放进一个完整场景里跑过一遍。综合案例就是补这一课的。这篇文章我会按真实开发顺序来拆先讲整体分层设计为什么这么分再讲每个核心环节的具体实现然后是完整的实操流程和参数配置最后把我踩过的坑和排查思路整理出来。适合已经学过Servlet、JSP、JDBC基础但还没独立做过完整项目的朋友。如果你正在准备课程设计或者想拿一个项目练手这篇可以直接照着复现。2. 整体分层设计与技术选型思路2.1 为什么一定要分层刚入门的时候最容易犯的错就是把所有代码写进一个Servlet里接收参数、写SQL、处理业务、跳转页面全在一个doGet方法里搞定。小功能确实能跑但一旦需求变化比如登录逻辑要加一个“连续输错三次锁定账号”你就得在几百行代码里找位置改完还容易碰坏别的地方。分层的本质是职责分离。每一层只干一件事改一处不影响其他层。经典的三层结构是这样的表现层Web层负责接收请求、校验参数格式、调用业务层、把结果转发给页面。它不关心数据怎么存、业务怎么算。业务层Service层负责业务规则。比如“注册时用户名不能重复”“转账时两边金额要一致”这些判断都在这里。数据访问层DAO层只负责和数据库打交道增删改查不掺业务逻辑。举个生活化的例子去餐厅吃饭服务员Web层负责点单和上菜厨师Service层负责做菜和把控口味仓库管理员DAO层负责取食材。服务员不会自己去炒菜厨师不会自己去仓库翻食材各司其职效率才高。2.2 技术选型为什么是Servlet加JSP加JDBC市面上的框架很多Spring Boot、MyBatis这些确实好用但对于学习阶段我建议先用原生技术把综合案例做一遍。原因有三个第一框架是封装底层还是这些东西。Spring MVC的DispatcherServlet本质上就是一个ServletMyBatis底层还是JDBC。你先把原生流程走通以后学框架就是“哦原来它帮我封装了这一步”理解会深很多。第二排查问题的能力。用框架出错了报错信息可能被层层包装你根本不知道底层发生了什么。用原生技术异常栈是清晰的你能看到是SQL写错了还是连接没关。第三面试常问底层。很多面试官会问“Servlet的生命周期”“JDBC的预编译为什么能防注入”这些用框架的人往往答不上来。具体选型上Servlet用3.1以上版本支持注解配置不用写web.xmlJSP用2.3以上数据库驱动用对应数据库的JDBC驱动连接池可以用Druid或者HikariCP。前端不用搞太复杂JSP加JSTL加一点原生JavaScript就够了重点在后端流程。2.3 项目结构怎么组织包结构建议按层来分清晰直观com.example.web -- Servlet com.example.service -- 业务接口和实现 com.example.dao -- 数据访问接口和实现 com.example.entity -- 实体类 com.example.util -- 工具类数据库连接、字符串处理等 com.example.filter -- 过滤器编码、登录校验这里有个经验接口和实现分开。比如UserService是接口UserServiceImpl是实现。刚开始觉得多此一举但当你需要换实现或者写单元测试时就知道好处了。DAO层同理。实体类的字段和数据库表字段对应命名用驼峰数据库用下划线查询的时候用别名映射或者用工具类做转换。这个细节后面实操部分会讲。3. 核心模块的细节拆解与实现要点3.1 数据库连接管理别每次都新建连接新手最常见的写法是在每个DAO方法里DriverManager.getConnection()用完close()。功能没问题但性能很差。每次建立连接都要经过TCP握手、身份验证开销很大。并发稍微高一点数据库连接数就被打满。正确做法是用连接池。连接池在应用启动时预先建立一批连接用的时候从池里借用完还回去不用真正关闭。Druid和HikariCP都是成熟的选择配置也简单。以Druid为例配置文件druid.properties大概长这样driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/demo_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot passwordyour_password initialSize5 maxActive20 maxWait3000参数说明一下initialSize是启动时建立的连接数maxActive是最大连接数maxWait是获取连接的最大等待毫秒数超过就抛异常。这几个值要根据实际并发量调小项目5到20足够了。工具类里用静态代码块加载配置保证只初始化一次public class JdbcUtil { private static DataSource dataSource; static { try { Properties props new Properties(); props.load(JdbcUtil.class.getClassLoader() .getResourceAsStream(druid.properties)); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(数据库连接池初始化失败); } } public static DataSource getDataSource() { return dataSource; } }注意静态代码块里如果抛异常类加载会失败后续所有调用都会报NoClassDefFoundError。所以这里的异常一定要打印详细日志方便定位是配置路径错了还是驱动没引入。3.2 实体类与数据库表的映射假设有一张用户表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );对应的实体类public class User { private Integer id; private String username; private String password; private String email; private Date createTime; // getter和setter省略 }这里有个坑数据库字段是create_time实体类是createTime。查询的时候如果直接SELECT *然后用ResultSet.getObject(create_time)字段名对不上。解决办法有两个一是SQL里用别名SELECT create_time AS createTime二是写一个通用的结果集映射工具把下划线转成驼峰。我一般用第二种写一个BeanHandler工具类通过反射把ResultSet的一行映射成一个对象。核心逻辑是获取结果集的元数据拿到所有列名把下划线命名转成驼峰然后找实体类对应的setter方法赋值。这样以后加字段不用改映射代码。3.3 登录功能的完整实现登录看起来简单但要做好有几个关键点。密码不能明文存。数据库里存的应该是加密后的值。常用的是MD5加盐或者BCrypt。MD5现在不推荐单独用因为彩虹表能撞出来加盐可以缓解。BCrypt更安全每次加密结果都不一样但需要引入额外依赖。学习阶段用MD5加固定盐值也能接受生产环境建议BCrypt。SQL注入要防。如果用字符串拼接SQL输入 OR 11就能绕过登录。必须用PreparedStatement参数用占位符String sql SELECT * FROM t_user WHERE username ? AND password ?;PreparedStatement会把参数当纯数据传不做SQL语法解析从根本上杜绝注入。登录状态要保持。登录成功后把用户信息存到Session里HttpSession session request.getSession(); session.setAttribute(loginUser, user);后续请求通过过滤器检查Session里有没有这个属性没有就跳回登录页。登录的Servlet核心代码protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); // 参数校验 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { request.setAttribute(msg, 用户名和密码不能为空); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } UserService userService new UserServiceImpl(); User user userService.login(username, password); if (user ! null) { request.getSession().setAttribute(loginUser, user); response.sendRedirect(request.getContextPath() /user/list); } else { request.setAttribute(msg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } }实操心得登录失败时不要明确提示“用户名不存在”还是“密码错误”统一提示“用户名或密码错误”。否则攻击者可以枚举出哪些用户名是存在的。3.4 列表查询与分页列表查询是每个管理系统都有的功能。数据量小的时候直接SELECT *没问题但数据上万条以后一次性查出来既慢又占内存必须分页。分页的核心是两个参数当前页码pageNo和每页条数pageSize。SQL里用LIMITSELECT * FROM t_user LIMIT ?, ?第一个问号是偏移量等于(pageNo - 1) * pageSize第二个是每页条数。比如第3页每页10条偏移量就是20查第21到30条。还需要一个总数查询用来算总页数SELECT COUNT(*) FROM t_user总页数 总数除以每页条数向上取整。Java里可以用(total pageSize - 1) / pageSize避免用Math.ceil的浮点运算。分页数据封装成一个PageBean对象包含当前页数据列表、当前页码、每页条数、总记录数、总页数。这样传给JSP页面直接遍历列表底部分页条根据总页数生成。这里有个细节页码边界处理。如果用户手动把URL里的pageNo改成0或者负数或者超过总页数要自动纠正到合法范围。否则SQL的LIMIT偏移量算出来是负数直接报错。3.5 增删改查的请求方式设计RESTful风格现在很流行但用原生Servlet实现起来稍微麻烦因为Servlet的doGet和doPost是分开的。我的做法是在URL里加一个method参数来区分操作/user/list查询列表/user/add新增/user/delete?id1删除/user/update修改或者用一个Servlet处理一个模块的所有操作通过参数区分String method request.getParameter(method); if (list.equals(method)) { list(request, response); } else if (add.equals(method)) { add(request, response); } else if (delete.equals(method)) { delete(request, response); }这种方式叫“BaseServlet模式”好处是减少Servlet数量一个模块一个Servlet就够了。实现上让所有Servlet继承一个BaseServlet在BaseServlet的service方法里通过反射调用对应的方法。public class BaseServlet extends HttpServlet { Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String method req.getParameter(method); if (method null || method.isEmpty()) { method index; } try { Method m this.getClass().getDeclaredMethod(method, HttpServletRequest.class, HttpServletResponse.class); m.setAccessible(true); m.invoke(this, req, resp); } catch (Exception e) { throw new ServletException(方法调用失败 method, e); } } }注意反射调用有性能开销但在这个量级下可以忽略。真正要注意的是异常处理反射调用抛出的异常会被包装成InvocationTargetException要拆开拿到真实原因否则排查问题很痛苦。4. 完整实操流程与关键配置4.1 环境准备与项目搭建先确认环境JDK 8或11建议118也够用Tomcat 8.5或9MySQL 5.7或8.0IDE用IntelliJ IDEA或者Eclipse都行。创建项目时选Dynamic Web ProjectEclipse或者Maven Web项目IDEA。用Maven的话pom.xml里加这些依赖dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.16/version /dependency /dependenciesservlet和jsp的scope设为provided因为Tomcat自带这些jar打包时不需要重复包含否则可能冲突。4.2 数据库建表与测试数据建库建表插入几条测试数据CREATE DATABASE demo_db DEFAULT CHARACTER SET utf8mb4; USE demo_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO t_user (username, password, email) VALUES (zhangsan, e10adc3949ba59abbe56e057f20f883e, zhangsantest.com), (lisi, e10adc3949ba59abbe56e057f20f883e, lisitest.com);这里的密码是123456的MD5值。字符集用utf8mb4支持emoji等特殊字符比utf8更全。4.3 编码过滤器配置中文乱码是Java Web的经典问题。POST请求的乱码通过request.setCharacterEncoding(UTF-8)解决GET请求在Tomcat 8以后默认就是UTF-8不用额外处理。但每个Servlet都写一遍太麻烦用过滤器统一处理WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }WebFilter(/*)表示拦截所有请求。过滤器的执行顺序按类名字母排序所以如果有多个过滤器命名要注意。比如登录校验过滤器叫LoginFilter编码过滤器叫EncodingFilterE在L前面编码先执行这是对的。4.4 登录校验过滤器的实现除了登录页和登录请求本身其他请求都要检查是否已登录WebFilter(/*) public class LoginFilter implements Filter { private static final String[] WHITE_LIST { /login.jsp, /login, /register.jsp, /register, /css/, /js/, /images/ }; Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); String contextPath request.getContextPath(); String path uri.substring(contextPath.length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(request, response); return; } } // 检查登录状态 Object user request.getSession().getAttribute(loginUser); if (user ! null) { chain.doFilter(request, response); } else { response.sendRedirect(contextPath /login.jsp); } } }实操心得白名单里的路径要仔细核对。我踩过一次坑白名单写了/login但登录请求实际是/user/login结果登录请求本身被拦截了一直跳回登录页排查了半天。建议白名单用startsWith匹配并且把静态资源目录也加进去否则CSS和JS加载不了页面样式全乱。4.5 前端页面的关键写法JSP页面顶部要加% page contentTypetext/html;charsetUTF-8 languagejava %否则中文乱码。引入JSTL标签库% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %列表页用c:forEach遍历table tr thID/thth用户名/thth邮箱/thth操作/th /tr c:forEach items${pageBean.list} varuser tr td${user.id}/td td${user.username}/td td${user.email}/td td a href${pageContext.request.contextPath}/user/edit?id${user.id}编辑/a a hrefjavascript:del(${user.id})删除/a /td /tr /c:forEach /table删除操作加一个确认框防止误点function del(id) { if (confirm(确定要删除这条记录吗)) { location.href ${pageContext.request.contextPath}/user/delete?id id; } }${pageContext.request.contextPath}是获取项目上下文路径保证部署到不同路径下链接都正确。这个细节很多人忽略本地跑没问题一部署到服务器就404。4.6 事务控制的实现业务层如果有多个DAO操作必须保证要么全成功要么全失败。比如转账扣款和入账必须在一个事务里。原生JDBC的事务控制流程Connection conn null; try { conn JdbcUtil.getDataSource().getConnection(); conn.setAutoCommit(false); // 开启事务 // 多个DAO操作共用同一个conn dao1.update(conn, ...); dao2.update(conn, ...); conn.commit(); // 提交 } catch (Exception e) { if (conn ! null) { conn.rollback(); // 回滚 } throw new RuntimeException(e); } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); // 还回连接池 } }关键点是多个DAO操作必须共用同一个Connection。如果每个DAO方法自己从连接池拿连接那就是不同的事务回滚不了。所以DAO方法要设计成接收Connection参数的重载版本。注意连接池里的连接用完必须close但这个close不是真正关闭而是还回池里。如果不close连接泄漏池里的连接很快耗尽后续请求全部阻塞。我见过一个项目因为忘记close跑了几小时就卡死排查了很久才发现是连接泄漏。5. 常见问题与排查技巧实录5.1 中文乱码问题速查乱码是最高频的问题按下面这个表排查基本能解决现象原因解决办法POST请求中文乱码请求体编码不对过滤器里setCharacterEncoding(UTF-8)GET请求中文乱码URL编码不对Tomcat 8以后默认UTF-8老版本改server.xml响应页面乱码响应头编码不对response.setContentType(text/html;charsetUTF-8)数据库存进去乱码数据库字符集不对建库用utf8mb4连接串加characterEncodingutf8JSP页面乱码page指令没写加contentTypetext/html;charsetUTF-8排查顺序先看浏览器控制台的响应头Content-Type再看数据库里的实际存储值最后看Java代码里的编码设置。一层层排除别瞎改。5.2 404和500错误的定位思路404是路径找不到500是服务器内部错误。这两个要分开处理。404常见原因URL写错、Servlet的WebServlet路径和请求路径不一致、web.xml配置错误、项目部署路径不对。排查方法是在浏览器按F12看Network面板确认实际请求的URL是什么然后和Servlet的映射路径对比。500常见原因空指针、SQL语法错误、类型转换异常、数据库连接失败。排查方法是看服务器日志的异常栈从最下面一行往上看找到自己代码的那一行。Caused by后面的才是根本原因别只看最上面的包装异常。5.3 数据库连接池的常见故障连接池用起来省心但配置不对也会出问题连接获取超时maxWait设太小或者连接泄漏导致池里没连接。先检查代码有没有忘记close再适当调大maxActive。连接失效数据库重启或者空闲太久池里的连接被服务端断开了。配置validationQuerySELECT 1和testWhileIdletrue定期检测连接有效性。启动报驱动找不到驱动jar没引入或者driverClassName写错。MySQL 8的驱动类是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。5.4 分页查询的边界问题分页最容易出问题的地方是边界页码为0或负数自动纠正为1页码超过总页数自动纠正为总页数总记录数为0总页数设为1避免除零删除最后一页的最后一条后当前页可能超出范围删除后重新计算页码这些边界处理不写测试的时候不一定发现但用户一操作就出问题。建议在Service层统一处理不要指望前端传对。5.5 我踩过的几个典型坑坑一Session超时时间太短。默认30分钟测试的时候经常刚登录没多久就掉线了。在web.xml里配置session-config session-timeout60/session-timeout /session-config单位是分钟。生产环境根据实际需求调一般30到60分钟。坑二重定向和转发的混用。登录成功后用重定向sendRedirect防止刷新页面重复提交查询列表用转发forward可以携带request域数据。用错了会导致数据丢失或者重复提交。坑三JSP里写Java代码。虽然JSP支持% %写Java代码但综合案例里尽量用EL和JSTL保持页面干净。页面里写业务逻辑后期维护是灾难。坑四忘记处理请求参数为null。request.getParameter返回null时直接调用方法会空指针。所有参数都要判空尤其是从URL里取的id用户可能手动改URL。6. 项目扩展与进阶方向基础功能跑通后可以往几个方向扩展让项目更有含金量。加权限控制。用户表加一个role字段区分管理员和普通用户。管理员能看所有数据普通用户只能看自己的。在过滤器里根据角色判断或者在Service层做数据过滤。加文件上传。用户头像上传是常见需求。Servlet 3.0以后用MultipartConfig注解加request.getPart()就能实现不用引入额外jar。注意文件大小限制和文件类型校验防止上传可执行文件。加日志记录。用Log4j2或者Logback替换System.out.println日志分级输出到文件。生产环境排查问题全靠日志这个技能必须掌握。改造成Maven多模块。把Web层、Service层、DAO层拆成独立模块用Maven管理依赖。这是企业级项目的标准结构提前熟悉有好处。引入前端框架。把JSP换成纯HTML加Ajax请求后端返回JSON。这样前后端分离更接近现在的开发模式。可以用Jackson或者Gson做JSON转换。这些扩展不用一次全做挑一两个感兴趣的深入就行。重点是理解每个扩展背后的设计思路而不是堆功能。我在实际带新人的过程中发现把综合案例完整做一遍的人后面学Spring Boot上手特别快因为底层的东西他都见过。而跳过这一步直接学框架的遇到问题往往一头雾水不知道框架帮他把什么藏起来了。所以别嫌原生技术麻烦这一遍走下来后面都是捷径。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询