
简介本资源是面向JavaWeb初学者与进阶学习者的完整实践项目源自尚硅谷JavaWeb课程体系聚焦Servlet核心机制与前后端协同开发能力培养。项目涵盖用户管理、购物车、图书操作等典型业务模块通过真实可运行的代码帮助学习者深入理解请求响应流程、会话控制、MVC分层设计及XML配置原理。压缩包共436个文件总计22.03MB包含148个编译后Class文件如UserServlet.class、CartServlet.class、76个JSP页面、72个Java源码、54个依赖JAR包、33个web.xml等XML配置文件以及HTML、CSS、JavaScript和少量图片资源结构清晰、模块完整便于逐层剖析与调试验证。已有512人下载学习配套代码具备良好可读性与教学适配性特别适合课堂实训、自学复盘及面试项目参考。 学JavaWeb绕不开Servlet。哪怕现在Spring Boot铺天盖地开发效率比传统ServletJSP高了一个量级但Servlet这套东西仍然是JavaWeb的地基。很多同学在看尚硅谷JavaWeb课程的时候觉得视频看懂了代码也敲了一遍结果面试问“Servlet生命周期”“转发和重定向的区别”依然答不利索。原因很简单学习源码和背概念是两码事只有把一份完整的基于Servlet的JavaWeb项目源码拆开揉碎了看一遍才知道那些框架替我们做的工作到底是怎么回事。这篇文章我就拿一份基于Java和Servlet的尚硅谷JavaWeb学习设计源码来拆解以最常见的“图书管理系统”为业务载体把从浏览器发起请求到Servlet处理再到数据库返回结果的完整链路讲清楚。涉及Servlet生命周期、MVC分层、JDBC连接池、事务处理、分页查询、Filter拦截等核心点适配正在学JavaWeb的初学者也适合准备Java面试需要复习底层原理的同学。每个环节我会把自己实际调试时踩过的坑一并说出来照着走能少走很多弯路。1. 项目整体设计与核心思路1.1 为什么用Servlet JSP练手而不是一步到位学Spring Boot很多初学者会有这个疑问JavaWeb课程直接上Spring Boot不香吗用Servlet写CRUD代码量又大又啰嗦。这个想法我很理解但我不建议零基础直接冲Spring Boot。Servlet是JavaWeb一切框架的底层支撑。Spring Boot的DispatcherServlet本质上也是一个Servlet过滤器是Servlet的Filter监听器是ServletContextListener。把这些底层机制搞明白你以后看SpringMVC源码、看SpringBoot自动配置才能从“背配置”变成“看门道”。反过来如果连请求怎么进Tomcat、怎么被Servlet接收、怎么返回给浏览器都不清楚那框架在你眼里就是黑盒出了问题只能靠盲猜。这份源码的设计思路就是最经典的JavaWeb标准架构浏览器请求最终由多个Servlet分别处理Servlet调用Service层完成业务逻辑Service层通过DAO层访问MySQL数据库JSP负责页面展示。没有引入任何高阶框架纯粹用Servlet原生的API和JDBC写出来结构透明每一行代码都能看懂这是它最大的学习价值。1.2 功能模块与整体结构这份源码的典型功能模块包括用户登录、图书列表分页展示、图书新增、图书修改、图书删除、条件查询这几大块。为什么选图书管理这类CRUD项目做学习载体因为它能覆盖JavaWeb最常用的技能点又没有多余的业务复杂度能把注意力集中在技术本身。模块核心功能涉及技术点用户登录验证用户名密码、Session会话保持表单提交、Session、Cookie图书分页列表分页展示图书信息、条件筛选分页算法、EL表达式、JSTL图书新增录入图书信息入数据库POST请求、请求参数封装图书修改回显已有信息并更新GET请求传参、表单回显图书删除按ID删除记录超链接传参、DAO操作登录拦截未登录用户访问受限页面Filter过滤器1.3 包结构分层拿到源码后第一件事不是看代码而是看包结构。规范的分层方式决定了后期维护的难度。这份源码采用的是标准的MVC三层架构src ├── com.example.book │ ├── controller # Servlet层处理请求和响应 │ │ ├── UserServlet │ │ ├── BookServlet │ ├── service # 业务逻辑层处理具体业务 │ │ ├── UserService │ │ ├── BookService │ ├── service.impl # Service接口实现 │ ├── dao # 数据访问层定义接口 │ ├── dao.impl # DAO接口实现 │ ├── entity # 实体类Book、User对应数据库表 │ ├── filter # 过滤器登录验证、字符编码 │ ├── util # 工具类JDBC连接、分页封装 │ └── web # 如果需要自定义监听器等 ├── web │ ├── static # css、js、图片等静态资源 │ ├── jsp # JSP页面 │ └── WEB-INF │ ├── web.xml │ └── lib分层最大的意义是职责单一。Servlet只做参数接收、调用Service、跳转页面不写SQLDAO只做数据库操作不包含业务判断Service组合DAO完成完整业务。这样任何一层改动都不影响其他层比如把数据库从MySQL换成Oracle只需要改DAO实现Controller和JSP完全不用动。2. 核心细节解析与实操要点2.1 Servlet生命周期代码跑起来之前先弄懂这些Servlet生命周期是面试必问题也是理解整个请求处理机制的关键。容器如Tomcat负责Servlet的创建和销毁整个过程分四个阶段。加载与实例化。当Tomcat启动时或者在第一个请求到达时容器会通过反射调用Class.forName()加载Servlet类然后调用无参构造器创建实例。默认是第一次请求时才实例化如果想要Tomcat启动时就创建可以在web.xml里配置load-on-startup数值小的优先加载。实际项目中像SpringMVC的DispatcherServlet通常就会设置这个值因为框架初始化耗时长不希望第一个用户去承担这个等待。初始化。实例化后容器调用init(ServletConfig config)方法。这个方法在Servlet生命周期中只执行一次。我们可以在里面读取初始化参数、初始化数据库连接池等资源。实操中要注意init()方法里的异常如果抛出Servlet会直接被容器标记为不可用。处理请求。每当有请求到达容器调用service()方法。HttpServlet父类已经在service()中根据请求方法GET/POST/PUT/DELETE分发到对应的doGet()或doPost()所以我们自己写的Servlet只需要重写这两个方法即可。销毁。容器关闭或应用卸载时调用destroy()方法释放资源。这里也需要注意destroy()只调用一次如果容器强制关闭比如直接kill进程可能不会执行。有个小细节经常被忽视service()方法在每次请求时都可能被多个线程同时调用所以Servlet是多线程单实例的。这就是为什么在Servlet里定义成员变量要格外小心。比如我在书单Servlet里定义了一个int count用来统计访问次数并发一上来就出错。解决办法就是能用局部变量绝不用成员变量如果必须用需要考虑线程同步。2.2 WebServlet注解与web.xml配置尚硅谷的课程里早期版本使用web.xml配置Servlet映射后期版本使用WebServlet注解。两种方式本质一样注解是Servlet 3.0规范引入的省去了xml里的重复配置。WebServlet(urlPatterns {/book/list, /book/list.do}, name BookServlet) public class BookServlet extends HttpServlet { // ... }这里有个细节urlPatterns里配置的是虚拟路径不是实际的Java类路径。请求/book/list时Tomcat根据注解或者web.xml找到对应的Servlet然后调用其service()方法。如果项目加了WebServlet注解但不起作用通常有两个原因一是Servlet版本低于3.0Tomcat6及以下版本不支持注解二是注解写在类上但项目没有被正确编译。我遇到的更多情况是注解成功但web.xml里还配置了相同的url-pattern导致启动报错。提示使用注解方式时WebServlet的类一定要在src目录下并且能被编译到classes目录否则Tomcat扫描不到。2.3 转发和重定向别选错了请求转发和重定向这两个概念初学者特别容易混。我用自己的话给你捋一下。请求转发Forward在服务端内部完成的跳转。浏览器只发起一次请求Servlet把请求“转交”给另一个Servlet或JSP浏览器地址栏不变请求域request scope中的数据可以共享。request.getRequestDispatcher(/jsp/book/list.jsp).forward(request, response);重定向Redirect服务端返回一个302状态码和Location头浏览器收到后重新发起新的请求。地址栏会变请求域中的数据不能共享只能通过session或URL参数传递。response.sendRedirect(request.getContextPath() /book/list);实操中的选择原则很简单如果跳转后还需要用request.setAttribute()传数据给页面用转发如果跳转后地址必须变比如新增成功后跳转到列表页防止刷新重复提交用重定向。我在写新增图书功能时开始用转发跳转到列表结果用户一刷新就重复插入一条记录后来改成重定向才解决。这里提醒一个坑重定向时路径必须带上request.getContextPath()也就是项目上下文路径。比如项目名是bookSystem_war_exploded重定向到/book/list就会变成localhost:8080/book/list少了一个上下文路径报404。这个我一开始经常漏后面养成了习惯才没再犯。2.4 中文乱码处理三处位置都要堵中文乱码是JavaWeb初学者最崩溃的问题之一其实乱码本质上就是字符编码不一致。请求从浏览器到服务器再从服务器到数据库每一段的编码方式如果对不上就会出现乱码。第一处页面端。JSP页面顶部必须设置% page contentTypetext/html;charsetUTF-8 languagejava %这告诉浏览器以UTF-8解析页面内容同时页面发起的请求也是以UTF-8编码提交。第二处请求端。POST请求的参数放在请求体中Tomcat默认使用ISO-8859-1解码和浏览器的UTF-8编码对不上导致乱码。解决方法是在Servlet中获取参数前设置request.setCharacterEncoding(UTF-8);这个设置对POST请求有效。但对于GET请求参数在URL中Tomcat8默认已经使用UTF-8解码URL所以Tomcat8及以上一般不用处理。如果用的Tomcat7或更低版本需要修改server.xml中Connector的URIEncodingUTF-8。第三处响应端。Servlet往页面输出内容时需要告诉浏览器用什么编码读response.setContentType(text/html;charsetUTF-8);如果这三处都设置了基本不会出现中文乱码。还有一个容易忽略的点过滤器统一设置。与其在每个Servlet里写两行设置编码的代码不如写一个CharacterEncodingFilter一次搞定所有请求。public class CharacterEncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }2.5 请求参数封装从表单拿到的一堆请求参数最朴素的方式是一个一个request.getParameter()取出来然后set到实体类里。图书有书名、作者、价格、库存、分类五个字段那就要写五行setter。如果有十个字段代码就非常难看。这里可以用Apache Commons BeanUtils工具类做自动封装Book book new Book(); BeanUtils.populate(book, request.getParameterMap());一行代码完成所有参数的封装前提是Book实体类的属性名和表单控件的name属性一一对应。这也是为什么实体类属性命名要规范的原因。学习阶段手动setter写一遍有助于理解写完一遍之后建议换成BeanUtils熟悉工具类的使用。3. 实操过程从登录到分页的完整源码解析3.1 登录模块一个典型的request session流程登录模块是几乎所有JavaWeb项目都有的功能也是一个完整的“请求-处理-会话”闭环。我先说登录的Servlet代码结构再解释关键点。WebServlet(/user/login) public class UserServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); UserService userService new UserServiceImpl(); User user userService.login(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); response.sendRedirect(request.getContextPath() /book/list); } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/jsp/user/login.jsp).forward(request, response); } } }登录成功把用户对象放进Session后续页面从Session中取值判断登录状态。这里有几个细节值得展开。密码对比不能放在Servlet里做。Servlet只负责接收参数和页面跳转真正的业务逻辑比如校验密码放在Service层。有些培训机构的代码在Servlet里直接查数据库虽然能跑但是架构分层就乱套了。Service层的好处是可以组合多个DAO操作比如登录成功后如果要记录日志直接在Service层加一个方法调用Servlet不用改动。Session的超时时间。默认情况下Tomcat的Session超时时间是30分钟可以在web.xml里配置session-config session-timeout30/session-timeout /session-config这个30分钟是指用户最后一次访问开始算起的空闲时间不是绝对30分钟后失效。我见过很多同学以为这30分钟是“登录后30分钟强制下线”其实是误解。3.2 分页查询前端展示还是SQL语句分页图书列表往往有几十条甚至上百条记录一次全查出来渲染在页面上性能很差。所以分页查询是JavaWeb项目的必修功能。分页思路有两种内存分页和SQL分页。内存分页是查出全部数据然后在Java代码里截取一部分数据量小的时候简单直接SQL分页是让数据库只返回当前页的数据数据量大时效率更高。实际项目中几乎都用SQL分页MySQL的分页关键字是LIMIT。SELECT * FROM book ORDER BY id LIMIT ?, ?第一个参数是起始偏移量第二个参数是每页条数。如果每页显示5条第1页的偏移量是0第2页是5第3页是10。偏移量计算公式(currentPage - 1) * pageSize。在DAO层实现分页时还需要一个总数查询SELECT COUNT(*) FROM book总记录数除以每页条数向上取整得到总页数。Java代码里用(total pageSize - 1) / pageSize实现向上取整。登录、分页、业务处理这些逻辑在项目中的组织方式可以参考这个模式public class BookServiceImpl implements BookService { private BookDAO bookDAO new BookDAOImpl(); Override public PageResultBook getPage(String keyword, int currentPage, int pageSize) { PageResultBook pageResult new PageResult(); int total bookDAO.count(keyword); int totalPages (total pageSize - 1) / pageSize; // 边界处理 if (currentPage 1) currentPage 1; if (currentPage totalPages) currentPage totalPages; int offset (currentPage - 1) * pageSize; ListBook list bookDAO.findPage(keyword, offset, pageSize); pageResult.setList(list); pageResult.setCurrentPage(currentPage); pageResult.setPageSize(pageSize); pageResult.setTotal(total); pageResult.setTotalPages(totalPages); return pageResult; } }把分页参数封装成一个PageResult对象控制层、页面层都直接用这个对象比零散地传递四五个参数清晰得多。页面上用JSTL遍历、用EL表达式取值c:forEach items${pageResult.list} varbook tr td${book.id}/td td${book.name}/td td${book.author}/td td${book.price}/td td${book.stock}/td td a href${pageContext.request.contextPath}/book/edit?id${book.id}修改/a a href${pageContext.request.contextPath}/book/delete?id${book.id} onclickreturn confirm(确定删除吗)删除/a /td /tr /c:forEach分页导航的页码生成有一些细节比如当前页高亮、上一页下一页禁用状态。手动写一遍能加深对条件判断的理解。我第一次写分页时页码总比实际页数多一页原因是总条数恰好被每页条数整除时(total pageSize - 1) / pageSize这个公式虽然向上取整是对的但我在totalPages计算里用了Math.ceil((double)total / pageSize)强转除法的优先级出了问题调了半天才发现是double位置不对。3.3 JDBC连接池Druid配置与PreparedStatement连接数据库不能每次操作都DriverManager.getConnection()那样性能非常差。实际项目都要用连接池。尚硅谷课程里常用的是Druid连接池阿里开源的在src目录下建一个druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/book_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingUTF-8 usernameroot password123456 initialSize5 maxActive10 maxWait3000工具类从连接池中获取连接public class JDBCUtils { private static DruidDataSource dataSource; static { try { Properties props new Properties(); props.load(JDBCUtils.class.getClassLoader().getResourceAsStream(druid.properties)); dataSource (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(AutoCloseable... resources) { for (AutoCloseable resource : resources) { if (resource ! null) { try { resource.close(); } catch (Exception e) { e.printStackTrace(); } } } } }这里强烈建议所有SQL操作都用PreparedStatement而不是Statement。原因有三点一是防SQL注入二是性能更好预编译三是代码可读性更好。Statement拼接SQL字符串的方式比如SELECT * FROM book WHERE name keyword 一旦用户输入的keyword是 OR 11 --整张表都会被查出来。用PreparedStatement的方式String sql SELECT * FROM book WHERE name LIKE ? LIMIT ?, ?; ps conn.prepareStatement(sql); ps.setString(1, % keyword %); ps.setInt(2, offset); ps.setInt(3, pageSize); rs ps.executeQuery();3.4 事务控制一个Service方法一把锁事务是保证数据一致性的关键。在图书管理项目中典型场景是用户下单购买图书需要同时更新图书库存和生成订单记录。如果更新库存成功了生成订单失败了数据就出现了不一致。这时候就需要事务让这两个操作要么全部成功要么全部失败。JavaWeb中的事务控制核心在Connection上因为Connection默认是自动提交的需要手动关闭自动提交并提交或回滚。一个常见的做法是在Service层控制public void purchase(int bookId, int userId) { Connection conn null; try { conn JDBCUtils.getConnection(); conn.setAutoCommit(false); bookDAO.decreaseStock(conn, bookId, 1); orderDAO.createOrder(conn, userId, bookId); conn.commit(); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }注意这里DAO层的方法都接收一个Connection参数。这带来的麻烦是每个DAO方法都要多传一个连接对象代码不够优雅。更高级的做法是用ThreadLocal绑定当前线程的Connection这样DAO方法不需要显式传参从ThreadLocal里拿同一个连接即可。public class TransactionManager { private static ThreadLocalConnection threadLocal new ThreadLocal(); public static Connection getConnection() throws SQLException { Connection conn threadLocal.get(); if (conn null) { conn JDBCUtils.getConnection(); threadLocal.set(conn); } return conn; } public static void begin() throws SQLException { getConnection().setAutoCommit(false); } public static void commit() throws SQLException { getConnection().commit(); } public static void rollback() throws SQLException { getConnection().rollback(); } public static void close() throws SQLException { getConnection().close(); threadLocal.remove(); } }这个TransactionManager是学习JavaWeb时很有价值的工具类。它把事务的开启、提交、回滚、关闭封装成静态方法使用过滤器统一包裹Service层调用实现事务的“声明式”控制。我在这个项目里就是这么做的写一个TransactionFilter把/book/*和/order/*的所有请求包在事务里业务代码完全不用关心事务问题。这个思路已经非常接近Spring的Transactional注解了理解了它以后看Spring事务源码会顺畅很多。3.5 登录拦截Filter过滤器的实战用法有了登录功能还要防止用户绕过登录直接访问受限页面。比如用户直接在地址栏输入/book/list如果没有拦截他也可能看到图书列表。这个功能用Filter实现。WebFilter(/private/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() /jsp/user/login.jsp); } } }关键点在于req.getSession(false)。传false表示如果当前没有Session不要创建一个新的直接返回null。如果不传参数直接getSession()那么未登录用户每次点进受限页面服务器都会新建一个Session白白浪费内存。这个小细节看出一个人对Servlet API掌握的精细度。Filter的执行顺序也很重要。这个项目里有两个Filter一个是字符编码过滤一个是登录过滤。如果登录过滤的优先级比字符编码高那么未登录用户访问受限页面时被重定向到登录页而登录页是中文响应编码还是乱码状态页面就会显示乱码。所以Filter的配置顺序或WebFilter的执行顺序要保证编码过滤一定排在最前面。在web.xml配置filter-mapping的顺序就是执行顺序纯注解方式则按照类名的字母顺序或Servlet 3.0规范中的定义执行所以更推荐用web.xml控制顺序。3.6 Maven 使用与依赖管理如果你的项目已经引入Maven那么依赖管理会轻松很多。以往的JavaWeb项目要把一堆jar包复制到WEB-INF/lib目录版本冲突还不好排查。Maven能自动下载和管理依赖只需要在pom.xml中声明dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependencies这里要特别提醒provided这个作用域。javax.servlet-api这个依赖Tomcat自身已经带了如果声明为compile默认打包时会把Servlet API的jar包也打进去部署到Tomcat时反而可能和Tomcat自带的类起冲突出现ClassCastException之类的诡异问题。设为provided表示“编译时需要运行时不打包”。另外还有一个版本适配问题javax.servlet是旧版包名Servlet 5.0之后改成了jakarta.servlet。用Tomcat 9及以下版本就用javax.servlet-api用Tomcat 10及以上版本必须用jakarta.servlet-api不然项目启动时直接ClassNotFoundException: javax.servlet.http.HttpServlet。这个坑特别隐蔽一看是环境问题其实是个版本对应问题。4. 环境准备与部署运行4.1 JDK Tomcat IDEA 环境配置在运行源码之前先把环境理清楚。JDK版本建议用JDK 8或JDK 11这两个版本对JavaWeb的学习项目支持最稳定。Tomcat版本方面如果用Servlet注解必须Tomcat 7以上如果JDK是17建议Tomcat 9以上。我用的是 JDK 8 Tomcat 8.5 IDEA 2023实测跑尚硅谷这类老项目没有任何问题。环境变量配置的关键点在于JAVA_HOME。IDEA启动Tomcat时其实是通过JAVA_HOME找到Java环境的。如果你的JAVA_HOME指向了错误路径Tomcat会启动失败或者报“Unable to find a Java Virtual Machine”。配置好后可以在命令行用java -version、javac -version验证。IDE里如果也报了找不到JDK还要检查Project Structure里的Project SDK是否设置正确。4.2 IDEA中部署Tomcat的完整步骤IDEA部署Web项目的步骤很多新手卡在最后一步“配置Artifacts”上。项目跑不起来80%是Artifact没有配置对。简单记录一下基本流程打开IDEA菜单Run - Edit Configurations点左上角“”号选择“Tomcat Server - Local”在Server标签页里点击Configure选择本地的Tomcat目录切到Deployment标签页点击“”号选择“Artifact”选择带有war exploded后缀的ArtifactApplication context填项目访问路径比如/bookSystem设置好后启动war exploded和war的区别简单说一下war exploded是解压目录形式IDEA能直接修改页面后热部署war是压缩包形式每次修改都要重新打包。本地开发用war exploded生产部署用war。另一个容易踩坑的是端口占用。Tomcat默认8080端口如果被其他程序占用启动日志会报Address already in use: JVM_Bind。解决方式有两个一是找到占用进程杀掉二是改Tomcat端口。IDEA里启动的Tomcat端口其实是IDEA配置里的“HTTP port”不是Tomcat安装目录下的server.xml这个要注意区分。第一次使用的人很容易改错地方改完发现端口没变。4.3 启动报错排查思路项目启动报错五花八门但归类下来常见的就那几类类找不到、端口被占、编码不对、依赖缺失。说一下排查这类问题的通用思路。先看异常栈最顶部的几行不要一上来就复制整个异常到搜索引擎。ClassNotFoundException说明jar包没到位检查WEB-INF/lib目录或Maven依赖NoClassDefFoundError多半是jar包冲突HTTP Status 404区分是项目访问路径错了还是某个Servlet映射路径写错了HTTP Status 500是服务器内部异常页面会带Java异常栈看第一行总结找到出错代码行。举一个实际例子启动时一切正常但点登录按钮后页面报500日志显示java.lang.NullPointerException定位到JDBCUtils.getConnection()返回null。查下来是druid.properties文件路径问题ClassLoader.getResourceAsStream(druid.properties)找不到文件。把文件放到src目录的根路径下就好了。这类资源文件路径问题我调试一两次之后就养成了习惯所有配置文件都放在src/main/resources或src根目录这样IDEA编译后会在classes目录下加载时用类加载器配合相对路径才稳定。5. 常见问题与排查技巧实录5.1 六个高频问题速查把学习和实际调试中碰到的高频问题整理成了表格照着排查能节省不少时间。现象可能原因解决办法页面404项目上下文路径不对或Servlet映射错误检查IDEA中Application context和WebServlet路径地址栏访问时带上项目名检查Servlet是否被扫描到页面500 NullPointerException实体类未实例化、连接返回null、参数名不对定位异常栈中项目代码行检查new对象和getParameter名称是否与表单一致中文乱码页面、请求、响应三处编码不一致统一设置UTF-8重点检查POST请求的request.setCharacterEncoding(UTF-8)是否在获取参数之前执行SQLException: Unknown column实体类属性名和表字段名不一致检查SQL语句中的字段名和数据库表实际字段名注意MySQL的order等关键字要加反引号ClassNotFoundException: HttpServletTomcat版本和Servlet API版本不匹配Tomcat 9及以下用javax.servletTomcat 10用jakarta.servlet或检查依赖scope刷新页面重复提交新增/删除后用了转发而非重定向用sendRedirect重定向到列表页避免刷新时重复执行上次请求5.2 一个排查了三个小时的BUG说一个我实际调试中印象最深的Bug。当时图书列表页怎么都查不出中文关键字的数据直接在Navicat里执行SQL有结果但在页面上搜索返回空。排查步骤一开始就错了我以为SQL写错了反反复复改了五六版SQL又怀疑编码搞了两小时都没结果。后来我把SQL打印出来发现一个惊人的事实SELECT * FROM book WHERE name LIKE %java%中的java在Java代码里拼接时并没有经过Java的字符串变量而是直接给setString(1, % keyword %)PreparedStatement是不需要拼接的问题出在我之前使用了Statement并且keyword里带了一个看不见的字符。仔细检查发现是表单input里多了个空格用户在输入时手滑打了一个全角空格导致LIKE % java%匹配失败。这个经历给我的教训有三条一是SQL打印出来看真实执行语句而不是猜测二是字符串比对异常时先看参数是否带不可见字符三是能使用PreparedStatement就不要用Statement拼接。5.3 源码学习的正确姿势最后说点源码学习的经验。很多同学拿到源码之后喜欢复制粘贴跑起来然后觉得自己“会了”。等面试官问细节又支支吾吾。正确的学习方式应该是四步走。第一步画流程图。把项目里每个请求的完整链路画出来比如“用户点登录按钮 - 浏览器发POST请求 - Tomcat解析 - UserServlet.service() - doPost() - 调用UserService - 调用UserDAO - JDBC查询MySQL - 返回结果 - 判断 - 转发或重定向”。先不看代码根据自己理解画画不出来再对照源码印象会深刻很多。第二步改代码验证。把某个方法的返回值改掉比如让登录条件永远返回null然后观察页面表现。这样能直观理解每一行代码在流程中的作用。第三步调试断点。在doPost()入口、Service实现类、DAO实现类三处打断电跟一遍完整的调用链。IDEA的Debug模式下能看到每一步的变量变化这对理解程序执行流程非常有帮助。第四步重构。学完原始源码之后试着把某个模块用新方式重写。比如把原来的Statement改成PreparedStatement把手动封装参数改成BeanUtils再把分页代码封装成通用工具类。每次重构都是在加深理解也是在为以后学习框架做铺垫。6. 从Servlet到框架这份源码还能怎么用学完这份基于Servlet的JavaWeb源码不要急着扔掉。它最好的用法是作为学习SpringMVC、Spring Boot的参照物。为什么这么说当你使用SpringMVC时你会看到一个名叫DispatcherServlet的核心类它是整个SpringMVC请求处理流程的门面。如果你之前有过手写Servlet的经验你会更容易理解DispatcherServlet到底做了什么它把原先自己写在Servlet里的那些代码解析请求参数、调用Service、转发或重定向交给各个组件去完成。参数解析交给HandlerAdapter业务调用交给Controller视图渲染交给ViewResolver。同理Spring的Transactional注解底层就是基于动态代理对Service方法做事务管理——这和我们前面用TransactionManagerThreadLocal 过滤器实现的事务控制如出一辙。理解了Servlet版本的实现再去看Spring的事务源码你就能看出它只是在“把已经存在的事情做得更自动、更优雅”。MySQL导出的SQL脚本中有几个点值得保留一是合理的数据类型选择比如价格用DECIMAL(10,2)而不是FLOAT避免浮点精度问题二是为经常查询的字段加索引比如图书分类字段三是外键约束适度使用学习项目可以不用但真实项目中需要保证数据完整性。最后再分享一个我自己常用的验证方式给本项目加一个简单的日志输出功能。在Service实现类中打印每个方法的入参和出参然后在控制台观察一个请求从进入到返回的完整日志顺序。这个顺序能不能和Servlet生命周期、Service调用链对应上就是你对自己掌握程度的检验标准。能对照上这份源码就算真正吃透了。本文还有配套的精品资源点击获取