
这次是第三次写JavaWeb相关的记录。前两篇一篇是环境搭建踩坑一篇是Servlet入门时反复绕不清的生命周期问题本以为把黑马JavaWeb的笔记过一遍就差不多了结果真正动手做完整案例的时候才发现视频里的知识点和能跑通的代码完全是两回事。这一篇我不打算再讲“什么是Servlet”这种基础概念而是把黑马JavaWeb笔记里那些高频考点、MySQL版本完整案例里真正用到的代码、以及我实操中反复踩的坑全部串起来做成一篇可以照着复现的记录。如果你是那种看视频能懂、合上电脑就忘的JavaWeb学习者这篇记录应该能帮你省不少时间。我会用一个基于ServletJSPMySQL的完整小项目为主线把笔记里的零散知识点全部装进去最后再给一份我整理的问题排查清单。1. JavaWeb到底在学什么第三次记录的范围和思路1.1 为什么前面写了两次还不够第一次记录的时候我以为把JDK、Tomcat、IDEA装好能在浏览器里跑出一个Hello World就算入门了。第二次记录的时候我终于搞懂了Servlet的生命周期、doGet和doPost的区别也知道了JSP本质上就是一个Servlet但当时只会照着视频敲离了老师的代码什么都写不出来。这次决定换一个完全不同的学习方式不再按视频顺序一节一节记笔记而是先定一个完整的项目需求然后把黑马JavaWeb笔记里所有相关知识点拆开在项目里一个一个验证。这个方法我强烈建议你试一次它比刷三遍视频都管用。所谓的“黑马JavaWeb笔记”核心内容其实集中在这样几条线上HTTP协议与请求响应、Servlet与JSP、Cookie与Session、Filter过滤器与Listener监听器、JDBC与数据库连接池、MyBatis基础以及最后整合起来做的增删改查案例。这些东西单个拆开都不难但它们组合在一起的时候很多人就懵了——为什么登录之后刷新一下又跳到登录页为什么查询列表的中文全变成了问号为什么明明数据库有数据页面却报空指针这些问题的答案往往不在任何一个单独的知识点上而在知识的连接处。第三次记录我重点就是把连接处补齐。1.2 黑马笔记和完整项目案例怎么配合使用网上搜“黑马javaweb笔记数据”“javaweb项目完整案例mysql”能找到非常多资源但大多数笔记的问题是太全了全到你根本不知道从哪里下手。我的用法是三步走第一步先把笔记里关于“请求响应流程”的那张图彻底吃透。不管是Servlet、JSP、Filter还是Listener最终都是在一个HTTP请求从浏览器到后端再返回的链条上工作。你脑子里有这张图后面所有代码都能找到自己的位置。第二步选择一个MySQL版本的完整案例。为什么强调MySQL因为笔记里大量SQL、连接池参数、中文乱码的处理方案都是针对MySQL的用Oracle或者SQL Server会遇到很多八竿子打不着的兼容问题学习阶段没必要给自己加戏。第三步先自己动手把案例跑起来再对照笔记找“为什么”。比如案例里有Filter统一处理编码笔记里也有这个知识点但只有自己遇到一次乱码之后再翻笔记你才会真正记住Filter解决乱码的原理是“在请求进入Servlet之前统一设置编码”。直接背笔记三天就忘带着问题翻笔记想忘都难。1.3 技术选型为什么还是ServletJSP而不是Spring Boot我知道很多人会问2025年了学JavaWeb还用ServletJSP是不是过时了我的观点很明确如果你是零基础直接从Spring Boot开始反而更难。Spring Boot帮你封装了太多东西请求怎么进来、响应怎么出去、过滤器怎么生效全被框架藏起来了出了问题你根本不知道去哪里排查。用ServletJSP做一次完整项目相当于把底层流程亲手走了一遍。以后再学Spring MVC、Spring Boot你会非常清楚它封装的是什么遇到诡异问题也能猜到大概在哪一层。这次案例的技术栈就定为JDK 8 Tomcat 9 Servlet JSP JSTL MySQL 5.7 Druid连接池用Maven管理依赖。够用且和黑马笔记的主流版本统一。2. JavaWeb核心概念再复盘动手之前必须理清的几个点2.1 Servlet生命周期为什么你的多线程问题比想象中更多黑马笔记里对Servlet生命周期的总结是三句话构造器只执行一次init只执行一次service根据请求次数执行多次destroy在容器关闭时执行一次。很多初学者把这当成概念背下来就完了但实际项目里这个特性会引发一个很有迷惑性的问题——并发安全。Servlet是单实例多线程的也就是说整个应用里某个Servlet类只有一个对象所有请求都调用这一个对象的service方法。如果你的Servlet里定义了成员变量比如private int count 0;那这个变量是所有用户共享的多个人同时访问时就会产生数据覆盖。我在案例里就因为这个踩过坑统计访问次数用成员变量结果多个浏览器同时刷新数字完全不对。正确做法是能用局部变量就用局部变量如果一定要用成员变量保存状态必须加同步或者保证它是线程安全的。记住一个口诀Servlet不要写可变成员变量写了就要背锅。等一下这个在案例里具体怎么体现呢登录那部分我本来想用全局变量记录当前登录的用户名后来改成从Session里取就是同样的道理。并发问题不是只有高并发才需要考虑哪怕只有两三个用户在同时操作都可能踩中。2.2 一次HTTP请求从进入到返回到底经过哪些组件这个流程是整个JavaWeb最核心的图景我建议你亲手把下面这个链条画下来浏览器发请求先被Tomcat的Connector接收解析成HttpServletRequest对象然后把请求交给当前应用。应用先走Filter过滤器链如果过滤器链放行再找对应的Servlet。Servlet收到请求后可以调用Service层、Dao层操作数据库拿到数据后方式一直接通过response对象输出JSON或者HTML方式二请求转发给JSP由JSP把数据渲染成HTML方式三重定向到另一个资源。最后Tomcat把响应内容返回浏览器。很多新手分不清请求转发和重定向的区别笔记里的原话是“转发是一次请求重定向是两次请求”。展开说转发forward浏览器地址栏不变request对象是同一个request域里放的数据在转发后的资源里还能拿到。重定向sendRedirect浏览器地址栏变成新地址request对象是重新创建的原来的request域数据全部丢失。这个区别直接决定你登录成功后应该用哪种跳转。如果登录成功后用转发跳到主页用户按F5刷新就会重复提交登录表单正确做法是先重定向到主页的Servlet再把数据通过Session带过去。这个细节案例里非常典型。2.3 Cookie与Session记住登录状态的底层逻辑黑马笔记里Cookie和Session是分开讲的但案例里它们总是同时出现。简单理解Cookie是浏览器端的小数据块服务器通过Set-Cookie头把它写给浏览器浏览器后续请求会带上它。Session是服务器端的存储空间每个会话有一个唯一的JSESSIONID这个ID通常就放在Cookie里。登录状态的核心逻辑是用户登录成功后服务器把用户信息放进Session然后把Session的ID写到浏览器Cookie里。用户下一次请求时浏览器带上JSESSIONID服务器找到对应的Session就知道“这个人已经登录过了”。实际项目里最常见的错误是把用户名放在request域里登录成功后重定向到主页结果取不到数据。因为重定向是两次请求request域已经清空了必须用Session。我在案例里的处理方式很朴素登录成功时用session.setAttribute(loginUser, username)Filter里每次请求都检查这个属性是否存在不存在就拦下来跳回登录页。Session还有一个小坑就是会话过期时间。Tomcat默认30分钟没有任何请求就销毁Session。很多人测试时打开页面放着去查资料回来一刷新明明没关浏览器Session却没了还以为是程序写错了。这个默认行为我在2.3节后面会再提一次因为排查登录跳转问题时最容易忽略。2.4 中文乱码问题JavaWeb新手最典型的坑中文乱码是个老生常谈的问题但我敢说十个JavaWeb初学者有九个被它卡过。乱码的本质只有一个数据在传输过程中发送方用一种编码写接收方用另一种编码读。解决思路就是保证全程统一编码。按数据流动的路径需要检查四个位置第一个JSP页面本身要声明pageEncodingUTF-8同时contentType也要指定charsetUTF-8。第二个Servlet接收POST请求参数时需要在读取参数之前执行request.setCharacterEncoding(UTF-8)。注意这个必须在第一次获取参数之前设置否则无效。第三个Servlet输出响应时要设置response.setContentType(text/html;charsetUTF-8)并且还要处理response的CharacterEncoding。第四个连接MySQL时JDBC的URL要加参数characterEncodingutf8比如jdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8。除了这四个位置Filter是全局统一处理的更优方案。我自己写案例时直接在Filter里先执行request和response的编码设置后面所有Servlet都不再重复处理。这样既避免遗漏也符合笔记里“统一编码在Filter层做”的建议。如果你只想记住一个结论字符编码问题优先在Filter里解决比在每一个Servlet里都设置要可靠得多。3. 完整案例落地基于ServletJSPMySQL的员工管理系统3.1 需求范围确定登录后做一套标准增删改查这个案例选择的是一个员工管理系统原因在于它覆盖了JavaWeb几乎全部核心点登录验证、列表展示、分页查询、新增、修改、删除再加上权限拦截。规模不大但五脏俱全。你可以按照这个需求先自己构思一遍表结构和代码结构再对照下面的实现差距就是你最需要补的地方。数据库设计上我用了两张表第一张用户表 tb_user字段很简单id、username、password。第二张员工表 tb_employee字段为id、name、sex、age、dept部门、create_time入职时间。下面是建表SQL直接复制到MySQL执行就行。注意字段类型和字符集我在案例里踩过create_time类型设成DATE导致时间精度丢失的坑后来改成datetime才正常。CREATE DATABASE IF NOT EXISTS emp_manager DEFAULT CHARACTER SET utf8mb4; USE emp_manager; CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_employee ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sex VARCHAR(10) DEFAULT 男, age INT, dept VARCHAR(50), create_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO tb_user (username, password) VALUES (admin, 123456); INSERT INTO tb_employee (name, sex, age, dept, create_time) VALUES (张三, 男, 28, 研发部, NOW()), (李四, 女, 25, 市场部, NOW()), (王五, 男, 32, 产品部, NOW());为什么不把员工表也带上外键关联user因为现阶段学习增删改查越简单越好等理解了基础流程再去加关联关系比一开始就被复杂的表设计劝退更实际。3.2 Maven项目结构与pom.xml关键配置我用Maven的war包结构来组织项目目录如下emp-manager ├── pom.xml └── src └── main ├── java │ └── com.example.emp │ ├── filter │ │ └── AuthFilter.java │ ├── servlet │ │ ├── LoginServlet.java │ │ ├── LogoutServlet.java │ │ ├── EmployeeListServlet.java │ │ ├── EmployeeAddServlet.java │ │ ├── EmployeeEditServlet.java │ │ └── EmployeeDeleteServlet.java │ ├── dao │ │ └── EmployeeDao.java │ └── util │ └── DBUtil.java ├── resources │ ├── db.properties │ └── mybatis-config.xml └── webapp ├── WEB-INF │ └── web.xml ├── jsp │ ├── list.jsp │ ├── add.jsp │ └── edit.jsp └── login.jsppom.xml里要引入的依赖Servlet API、JSP API、JSTL、MySQL驱动、Druid连接池。这里有个非常关键的细节Servlet和JSP的APIscope必须设成provided因为Tomcat容器本身已经有这些类了。如果不设provided最后打出来的war包会把它们打进去和Tomcat自带的类冲突出现一些奇怪的NoSuchMethodError。properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target /properties 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 version5.1.49/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.1.23/version /dependency /dependenciesMySQL驱动版本我特意选了5.1.49而不是8.x主要是为了和MySQL 5.7匹配。如果你本地装的是MySQL 8记得把驱动换成com.mysql.cj.jdbc.DriverURL也要带上时区参数否则启动时会直接报CLient does not support authentication protocol的错误。3.3 核心实现登录校验、列表分页、增删改查一步步来这个案例中登录模块是最能体现JavaWeb流程完整性的部分。我这里先写登录Servlet的核心逻辑。注意看几个关键点密码先通过PreparedStatement查询用户、登录成功后数据放Session、然后重定向到列表页。WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, username); // 避免表单重复提交重定向而不是转发 response.sendRedirect(request.getContextPath() /employee?actionlist); } else { request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }为什么登录失败用转发登录成功用重定向我上面讲过了——转发是地址栏不动F5会造成表单重复提交重定向是全新请求浏览器也能正确回到列表页。然后是列表分页的实现。分页这个功能JavaWeb笔记里必备核心就是SQL的LIMIT子句。它的坑在于页码参数处理人是1开始数的SQL的偏移量是从0开始的。所以当页面传入currentPage1时偏移量计算为(1-1) * pageSize 0取记录从第0条开始这个换算关系写错你会发现第一页数据永远不对。我的EmployeeDao里分页查询方法如下public ListEmployee findPage(int currentPage, int pageSize) { String sql SELECT id, name, sex, age, dept, create_time FROM tb_employee LIMIT ?, ?; // currentPage从1开始offset (currentPage - 1) * pageSize int offset (currentPage - 1) * pageSize; // 使用QueryRunner或JdbcTemplate传参两个问号分别对应offset和pageSize }EmployeeListServlet里要做的事情有两件一是接收当前页参数二是统计总记录数得到总页数。总页数的计算也有坑直接totalCount / pageSize除不尽的会丢掉一页。正确思路是totalPages (totalCount pageSize - 1) / pageSize或者单独判断余数。我在案例里用的是向上取整。新增员工和修改员工没什么神秘的地方都是获取表单参数、执行SQL。但有一个小坑我必须提醒日期字段的处理。JSP页面表单提交的日期默认是yyyy-MM-dd字符串如果数据库字段是datetime直接拼SQL可能没问题但如果你想在代码里做格式化就要注意SimpleDateFormat的parse会抛出异常需要try-catch。不然用户随便填个不合法日期整个请求就500了。删除功能更要小心。案例里我用了两种删除方式物理删除直接DELETE逻辑删除是加一个status字段。从项目角度我建议至少了解逻辑删除的概念因为真实项目里数据一般不会硬删但学习阶段用物理删除更直观成本更低。3.4 Filter拦截登录状态一个注解就够吗在案例里我定义了一个AuthFilter作用是对除/login、/login.jsp以外的所有资源做登录校验。这里会有个很容易犯的错误——很多人给Filter加WebFilter(/*)之后发现登录页的CSS、JS全打不开了而且登录接口本身也被拦住造成死循环。正确做法是在Filter里手动排除放行名单WebFilter(/*) public class AuthFilter implements Filter { 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(); // 登录接口和登录页放行 if (uri.endsWith(/login) || uri.endsWith(/login.jsp)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() /login.jsp); } } }关于静态资源的问题如果你的页面里引用了CSS、JS而它们就在webapp目录下那么在Filter里最好再放行一批静态资源后缀比如.css、.js、.png。不要一上来就/*全部拦截那是给自己找麻烦。Session的获取方式也值得强调我用了request.getSession(false)如果当前请求没有Session返回null不会强制创建一个新的。如果用getSession()不带参数没有Session时也自动创建那Filter判断“Session为null”永远不成立登录状态判断就彻底失效了。这个点黑马笔记里没明说但真的很重要。3.5 数据库访问封装Druid连接池的参数配置项目里数据库访问我直接用了Druid连接池的简单封装配置文件 db.properties 如下driverClassNamecom.mysql.jdbc.Driver urljdbc:mysql://localhost:3306/emp_manager?useUnicodetruecharacterEncodingutf8useSSLfalse usernameroot password123456 initialSize5 maxActive10 maxWait3000这里有个很实用的经验maxWait必须按业务情况配置。如果数据库连接池满了请求最多等待3000毫秒然后超时总比一直卡在那里强。页面卡死和连接池打满往往是连在一起的出现了这种问题先去查maxActive是不是太小。DBUtil类的写法不复杂就是加载properties、创建DruidDataSource、提供getConnection方法。但要注意properties文件加载路径不能写错。很多人放在resources目录下却用new FileInputStream(db.properties)读一部署到Tomcat就报文件找不到。正确做法用类加载器InputStream in DBUtil.class.getClassLoader().getResourceAsStream(db.properties);这个是JavaWeb里非常典型的一个坑——本地开发时项目路径下能找到文件部署后当前工作目录变成了Tomcat的bin目录于是相对路径全部失效。用classpath方式读取资源是唯一靠谱的做法。4. 实操中的常见问题与排查清单4.1 启动与部署阶段端口占用、404、热部署失效先看一个最基础的问题启动Tomcat时端口被占用。IDEA控制台报Port 8080 was already in use。解决方式很简单换端口或者杀掉占用进程。Windows下用netstat -ano | findstr 8080找到PID然后taskkill /F /PID pid杀掉即可。Linux下用lsof -i:8080加kill -9。这些命令我每次写JavaWeb项目都要用到建议直接记住。另一个经常遇到的是项目启动不报错但访问路径404。排查顺序一般是IDEA Deployment配置里Application context是不是写错了比如项目名写成/emp-manager而你访问的时候路径少了项目名再看看web.xml或注解里的Servlet映射是否和页面提交的action一致。这里有个常见错位表单提交的action/login但项目部署名也是子路径很多新手不知道request.getContextPath()拿到的项目名需要拼在路径最前面。最后一个关于IDEA热部署失效的问题改了Java代码刷新页面不生效。原因是IDEA默认不会自动编译Java文件需要按CtrlShiftF9重新编译当前文件或者在Tomcat配置里把On frame deactivation和On update action都改成Update classes and resources。如果改完之后还不行就重启Tomcat别死磕有时候是缓存问题。4.2 数据库连接阶段驱动类、时区、连接池配置数据库相关的问题在完整案例里出现频率最高。最常见的报错信息是ClassNotFoundException: com.mysql.jdbc.Driver原因基本只有一个pom.xml里没有引入MySQL驱动依赖或者驱动jar没有部署到WEB-INF/lib下单纯引入Maven依赖但没重新部署。用mvn clean package重新打war包再部署一次基本能解决。其次是Unknown database或Access denied for user这类错。前者是数据库没有建后者是账号密码错误或者没有远程访问权限。本地开发时建议用root账号密码不要搞太复杂能跑起来就行。部署到Tomcat后出现The server time zone value ... is unrecognized的报错是MySQL 8时代的经典问题。两个解决思路要么在URL上加serverTimezoneAsia/Shanghai要么把驱动降到5.1.49配合MySQL 5.7。如果你是和笔记保持一致我建议用MySQL 5.7 5.1.49驱动能少很多麻烦。还有一个很容易被忽略的Druid连接池问题连接池的maxActive设置得太小而项目里有大量慢查询时会出现运行一段时间后接口报错、Tomcat日志一堆wait millis 3000, active 10之类的提示。这说明连接被占满了SQL卡住或者连接没释放。排查方法检查所有conn是否在finally中关闭尤其要注意有些同学在增删改查里只关了resultSet忘了关connection一个请求就占掉一个连接多请求几次连接池就满了。4.3 中文乱码速查表一条数据从输入到显示逐个排查我把乱码排查整理成表格方便直接对照表现可能原因解决办法JSP页面本身中文乱码页面编码没设UTF-8顶部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %表单提交到Servlet后中文乱码POST请求未设置编码在Servlet获取参数前执行request.setCharacterEncoding(UTF-8)响应到浏览器中文乱码response未设置编码执行response.setContentType(text/html;charsetUTF-8)存入MySQL后乱码数据库或表不是utf8mb4建库时DEFAULT CHARSETutf8mb4JDBC URL加characterEncodingutf8Cookie存储中文乱码Cookie不支持直接存中文使用URLEncoder.encode转码读取时URLDecoder.decode解码重定向页面乱码URL中带中文参数用response.encodeRedirectURL处理中文乱码排查的万能法则数据在哪一段坏了就在哪一段找编码设置。不要一股脑把所有地方都设置一遍那样出了问题仍然不知道根源在哪。实际工作中我用“从浏览器F12看请求头里的编码 - 再到Servlet打断点看request里的参数值 - 再到数据库里看最终记录”的顺序排查基本五分钟内能定位问题。4.4 写JavaWeb笔记的实用心得不要抄要用自己的话最后聊点笔记整理的经验。这次第三次记录之所以能跑起来最大的原因是我没有照抄黑马笔记而是用自己的语言重新组织了一遍。黑马JavaWeb的笔记有个特点表格多、结论多比如“Filter和Interceptor的区别”“转发和重定向的区别”但它没有告诉你这些结论是在什么场景下得出的。我的做法是每学完一个模块就写一个不超过200字的小结内容必须包括三件事这个技术解决了什么问题、在哪里用、不加会怎么样。比如Filter的笔记我写的是“Filter用于统一请求处理比如登录校验和编码设置不加的话每个Servlet都要重复写校验代码”。这种笔记才是能在写代码时真正用上的笔记光抄黑板记是记不住的。另外完整案例的代码一定要自己敲一遍再提交到git仓库。哪怕照着文章敲敲的过程中也会发现很多“哦原来导入的是这个包”“哦原来这里需要try-catch”这样的细节。如果只是复制粘贴跑通这些细节全都会漏掉。这是我这三篇记录里最想强调的经验代码这行真的没有捷径手感和细节都是靠一遍一遍敲出来的。最后再分享一个我常用的习惯每次做完一个小案例我会把所有遇到过的报错信息复制到一个单独的markdown文件里按报错关键字分类下面写上当时是怎么解决的。下次遇到同样报错直接CtrlF搜索两分钟定位。这套“个人报错字典”比任何笔记都可靠因为它记录的全是你自己的真实项目里发生过的问题。我这篇记录里的排查清单其实就是从这份报错字典里整理出来的希望也能成为你排查问题时的参考。