1. 为什么还要死磕Servlet基础
很多人刚接触Java Web开发,第一反应就是直接上Spring Boot,觉得Servlet是上古时代的东西,学了没用。我带过不少新人,几乎每隔一段时间就会听到类似的说法。但实际情况是,你如果不懂Servlet,后面遇到过滤器不生效、请求参数拿不到、中文乱码、Session丢失、转发和重定向搞混这些问题,你连排查的方向都找不到。Servlet是整个Java Web体系的底层地基,Spring MVC再封装,它最终也是跑在Servlet容器里的一个Servlet。你把这个地基摸透了,上层框架对你来说就是透明的。
这篇内容适合谁看?一是正在上Java Web课程、需要完成Servlet基础实验的同学;二是自学Java后端、想真正搞明白请求响应全流程的开发者;三是从其他语言转过来、对Java Web这套机制比较陌生的朋友。我会从一个完整的实验项目出发,把Servlet的核心知识点、实操步骤、踩坑经验全部拆开讲清楚。你跟着走一遍,能独立写出一个基于Servlet的完整Web应用,并且知道每一行代码为什么这么写。
先明确一下这个实验到底要做什么。所谓Servlet基础实验,核心目标就是:用最原始的方式,不依赖任何高级框架,手写Servlet来处理浏览器发来的HTTP请求,并返回响应内容。听起来简单,但里面涉及的知识点非常多:Servlet的生命周期、请求参数的获取、中文编码处理、请求转发与重定向的区别、Session和Cookie的使用、ServletContext的作用等等。这些知识点不是孤立的,它们串起来就是一次完整的请求处理链路。
我个人的习惯是,学任何东西都要先搞清楚它在整个体系里的位置。Servlet在Java Web里的位置,简单说就是:浏览器发HTTP请求,Web服务器(比如Tomcat)接收请求,根据URL找到对应的Servlet,调用Servlet的方法处理,Servlet把结果写回响应,服务器再把响应发回浏览器。整个链路里,Servlet就是那个真正干活的角色。你把这个链路理解了,后面学什么框架都是在这个基础上加东西。
2. 实验环境搭建与项目结构设计
2.1 工具选型与版本搭配
做Servlet实验,环境搭建是第一步,也是最容易劝退人的一步。我见过太多人卡在环境上,还没开始写代码就放弃了。这里我把选型和理由说清楚,你照着配就行。
JDK版本建议用JDK 8或者JDK 11。为什么不用更高的版本?因为Servlet实验本身不涉及新特性,JDK 8足够稳定,而且大部分教程和容器对JDK 8的兼容性最好。如果你已经装了更高版本,也能跑,但要注意编译级别设置一致。
Tomcat版本要和Servlet规范对应。Tomcat 9对应Servlet 4.0,Tomcat 10对应Servlet 5.0。这里有个大坑:Tomcat 10开始,Servlet的包名从javax.servlet变成了jakarta.servlet。如果你用Tomcat 10,但代码里import的是javax.servlet,会直接报ClassNotFoundException。所以新手我建议先用Tomcat 9,网上资料多,踩坑少。等你熟悉了再升级。
IDE用IntelliJ IDEA或者Eclipse都行。IDEA的体验更好,但对新手来说,Eclipse的Web项目结构更直观,能让你看清楚每个文件放在哪。我下面以IDEA为主讲,Eclipse的操作逻辑类似。
构建工具方面,这个实验我建议先用最原始的方式:手动建Web项目,手动导jar包。为什么不用Maven?因为用Maven会自动帮你处理依赖和目录结构,你反而看不清Servlet到底需要哪些东西。等你把原始方式走通了,再用Maven重构一遍,理解会更深。
2.2 标准Web项目目录结构
一个标准的Java Web项目目录结构是这样的,我用表格列出来,你对照着建:
| 目录/文件 | 作用 | 是否必须 |
|---|---|---|
| src | 存放Java源代码 | 必须 |
| web/WEB-INF/web.xml | 部署描述文件,配置Servlet映射 | 必须(注解方式可省略) |
| web/WEB-INF/lib | 存放第三方jar包 | 按需 |
| web/WEB-INF/classes | 编译后的class文件 | 编译后自动生成 |
| web/index.jsp | 默认首页 | 可选 |
| web/static | 存放静态资源 | 可选 |
这里重点说WEB-INF目录。这个目录是受保护的,浏览器不能直接访问里面的文件。你把JSP放在WEB-INF下,就只能通过Servlet转发访问,这是一种安全设计。很多新手把JSP随便放在web根目录下,结果用户可以直接访问,绕过了你的权限控制,这是很危险的。
web.xml是部署描述文件,早期Servlet只能通过它来配置映射。Servlet 3.0之后支持注解配置,用@WebServlet就能搞定,但web.xml依然有用,比如配置欢迎页、错误页、过滤器顺序等。我建议你两种方式都试一遍,理解它们的区别。
2.3 第一个Servlet的完整搭建过程
我带你走一遍从零搭建的过程。打开IDEA,新建项目,选择Java Enterprise,勾选Web Application。如果没有这个选项,就新建普通Java项目,然后手动添加Web框架支持。
建好之后,在src下新建一个包,比如com.example.servlet。然后新建一个类,叫HelloServlet,让它继承HttpServlet。代码如下:
package com.example.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; @WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); PrintWriter out = resp.getWriter(); out.println("<h1>Hello Servlet</h1>"); } }这段代码有几个关键点。@WebServlet("/hello")是注解配置,表示访问/hello路径时由这个Servlet处理。doGet方法处理GET请求,如果是POST请求就要重写doPost。resp.setContentType设置响应类型和编码,这一步不做的话中文会乱码。getWriter()拿到字符输出流,往里面写内容就是返回给浏览器的内容。
配置Tomcat运行环境,把项目部署上去,启动。浏览器访问http://localhost:8080/项目名/hello,就能看到Hello Servlet。如果报404,检查URL路径和注解是否一致;如果报500,看控制台异常堆栈。
注意:IDEA里配置Tomcat时,Deployment选项卡里的Application context决定了URL前缀。如果设成
/,访问路径就是http://localhost:8080/hello;如果设成/demo,就是http://localhost:8080/demo/hello。这个前缀很多人会搞混。
3. Servlet核心机制深度拆解
3.1 生命周期:Servlet什么时候被创建和销毁
Servlet的生命周期是面试高频题,也是理解Servlet运行机制的关键。整个生命周期分四个阶段:加载和实例化、初始化、请求处理、销毁。
加载和实例化由容器负责。默认情况下,Servlet是在第一次被访问时才创建实例,这叫懒加载。你也可以在web.xml里配置<load-on-startup>1</load-on-startup>,让容器启动时就创建,数字越小优先级越高。为什么要用启动时加载?因为有些Servlet初始化很耗时,如果等用户第一次访问才初始化,用户体验会很差。
初始化阶段调用init(ServletConfig config)方法。这个方法只调用一次,适合做资源加载、数据库连接池初始化等操作。你可以重写它,但记得调用super.init(config),否则getServletConfig()会返回null。
请求处理阶段调用service()方法,service()根据请求方法类型分发到doGet()、doPost()等方法。每次请求都会调用一次。这里有个重要特性:Servlet是单实例多线程的。容器只创建一个Servlet实例,多个请求并发访问同一个实例。这意味着你的Servlet里如果有成员变量,会有线程安全问题。
我举个实际踩过的坑。有一次我在Servlet里定义了一个成员变量private int count = 0;,然后在doGet里count++,想统计访问次数。结果并发测试时发现数字对不上,有时候还会少。原因就是多个线程同时读写这个变量,没有同步。正确的做法是用AtomicInteger,或者把变量放到数据库、Session里。这个坑很典型,新手一定要记住:Servlet里尽量不定义可变的成员变量。
销毁阶段调用destroy()方法,在容器关闭或应用卸载时执行,用来释放资源。同样只调用一次。
3.2 请求参数的获取与中文乱码根治
获取请求参数是Servlet最常用的功能。HttpServletRequest提供了几个方法:getParameter(String name)获取单个参数值,getParameterValues(String name)获取多个值(比如复选框),getParameterMap()获取所有参数的Map。
GET请求的参数在URL里,比如?name=张三&age=20。POST请求的参数在请求体里。这里有个经典的中文乱码问题:POST请求如果不在读取参数前设置编码,中文会乱码。解决办法是在doPost方法的第一行调用req.setCharacterEncoding("UTF-8")。注意,这行代码必须在getParameter之前调用,否则无效。
GET请求的中文乱码处理比较麻烦。Tomcat 8之后,GET请求默认用UTF-8解码,一般不会乱码。但如果你的Tomcat版本较老,或者配置改了,就需要在server.xml里配置URIEncoding="UTF-8"。我建议统一在代码里处理,不要依赖容器配置。
我整理了一个参数获取的对照表:
| 场景 | 方法 | 注意事项 |
|---|---|---|
| 单个参数 | getParameter | 返回String,不存在返回null |
| 多个值 | getParameterValues | 返回String[],如复选框 |
| 所有参数 | getParameterMap | 返回Map,适合封装 |
| 参数名不存在 | getParameter | 返回null,不是空字符串 |
| 参数值为空 | getParameter | 返回空字符串"" |
实操心得:判断参数是否存在,不要用
getParameter(name) != null,因为如果参数名存在但值为空,返回的是空字符串。更严谨的做法是结合getParameterMap().containsKey(name)来判断。
3.3 请求转发与重定向的本质区别
这是Servlet实验里最容易搞混的知识点,也是面试必问。我用一个生活类比来解释:请求转发像是你在公司前台问路,前台直接带你去目的地,你全程只发了一次请求;重定向像是前台告诉你"你去二楼找某某",你得自己再走一趟,发了两次请求。
技术上,请求转发用req.getRequestDispatcher("/target").forward(req, resp),重定向用resp.sendRedirect("/target")。区别体现在几个方面:
地址栏变化:转发不变,重定向变。因为转发是服务器内部行为,浏览器不知道;重定向是服务器告诉浏览器"你去新地址",浏览器会发起新请求。
请求次数:转发1次,重定向2次。这意味着重定向会丢失request域中的数据,因为第二次请求是全新的。
能否访问WEB-INF:转发可以,重定向不可以。因为转发是服务器内部跳转,能访问受保护资源;重定向是浏览器行为,访问不了WEB-INF。
效率:转发效率高,重定向效率低。但重定向能防止表单重复提交,这是它的优势场景。
我画个表格对比更清楚:
| 对比项 | 请求转发 | 重定向 |
|---|---|---|
| 方法 | forward | sendRedirect |
| 地址栏 | 不变 | 改变 |
| 请求次数 | 1次 | 2次 |
| request域数据 | 保留 | 丢失 |
| 访问WEB-INF | 可以 | 不可以 |
| 目标范围 | 本应用内 | 任意URL |
| 典型场景 | 查询后展示 | 登录成功后跳转 |
3.4 Session与Cookie的配合使用
HTTP是无状态协议,服务器默认不记得你是谁。Session和Cookie就是用来解决这个问题的。Cookie存在浏览器端,Session存在服务器端,Session的ID通过Cookie传给浏览器。
创建Session很简单:HttpSession session = req.getSession();。如果当前没有Session就创建一个,有就返回现有的。存数据用session.setAttribute("key", value),取数据用session.getAttribute("key")。
Session有个超时时间,默认30分钟。可以在web.xml里配置<session-config><session-timeout>30</session-timeout></session-config>,单位是分钟。超时后Session失效,数据丢失。
Cookie的创建稍微麻烦一点:
Cookie cookie = new Cookie("username", "zhangsan"); cookie.setMaxAge(60 * 60 * 24); // 保存一天 cookie.setPath("/"); resp.addCookie(cookie);读取Cookie需要遍历req.getCookies()数组,因为Cookie可能有很多个。
注意:Cookie的值不能包含中文和特殊字符。如果要存中文,需要先URL编码,读取时再解码。另外,Cookie有大小限制,单个Cookie不超过4KB,一个域名下的Cookie数量也有限制。
Session和Cookie的选择原则:敏感数据放Session,因为服务器端安全;不敏感的小数据可以放Cookie,减轻服务器压力。但要注意,Session在服务器集群环境下需要额外处理共享问题,这是后话。
4. 完整实验案例:用户登录功能实现
4.1 功能拆解与流程设计
光讲理论不够,我带你做一个完整的登录功能,把前面讲的知识点全部串起来。功能需求是:用户输入用户名和密码,提交后服务器验证,成功则跳转到欢迎页并显示用户名,失败则返回登录页并提示错误。
流程设计如下:登录页(login.jsp)提交表单到LoginServlet,LoginServlet获取参数,验证用户名密码,成功则把用户信息存入Session,然后重定向到WelcomeServlet;失败则把错误信息存入request,转发回login.jsp。WelcomeServlet从Session取用户信息,如果取不到说明没登录,重定向回登录页。
这个流程用到了参数获取、中文编码、Session、转发、重定向,是一个很典型的综合练习。
4.2 登录页与表单提交
先写login.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>登录</title> </head> <body> <h2>用户登录</h2> <p style="color:red">${error}</p> <form action="login" method="post"> 用户名:<input type="text" name="username"><br> 密码:<input type="password" name="password"><br> <input type="submit" value="登录"> </form> </body> </html>表单的action是login,对应LoginServlet的映射路径。method是post,因为登录涉及密码,不能用get把密码暴露在URL里。${error}是EL表达式,用来显示错误信息。
4.3 LoginServlet的完整实现
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); if ("admin".equals(username) && "123456".equals(password)) { HttpSession session = req.getSession(); session.setAttribute("user", username); resp.sendRedirect("welcome"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }这段代码有几个细节值得说。第一行设置编码,必须在获取参数前。验证逻辑这里写死了,实际项目要查数据库。成功时用重定向而不是转发,为什么?因为如果转发到welcome,地址栏还是login,用户刷新页面会重复提交表单。重定向后地址栏变成welcome,刷新不会重复提交。失败时用转发,因为要把错误信息通过request传回login.jsp,重定向会丢失request数据。
4.4 WelcomeServlet与登录校验
@WebServlet("/welcome") public class WelcomeServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); HttpSession session = req.getSession(false); if (session == null || session.getAttribute("user") == null) { resp.sendRedirect("login.jsp"); return; } String username = (String) session.getAttribute("user"); PrintWriter out = resp.getWriter(); out.println("<h1>欢迎你," + username + "</h1>"); out.println("<a href='logout'>退出登录</a>"); } }注意req.getSession(false),参数false表示如果没有Session就返回null,不创建新的。这个细节很重要,如果写成getSession(),每次访问都会创建Session,就失去了登录校验的意义。
4.5 退出登录与Session销毁
@WebServlet("/logout") public class LogoutServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); if (session != null) { session.invalidate(); } resp.sendRedirect("login.jsp"); } }session.invalidate()会销毁整个Session,所有数据清空。退出登录后重定向回登录页。
5. 常见问题排查与避坑指南
5.1 启动与部署类问题
404错误是最常见的。排查顺序:先看URL路径对不对,注意项目名和Servlet映射路径;再看web.xml或注解配置有没有写错;最后看Tomcat的部署日志,确认项目是否成功部署。我遇到过一个情况,IDEA里改了代码但没重新部署,访问的还是旧版本,重启Tomcat就好了。
500错误通常是代码抛异常了。看控制台堆栈,定位到具体行。常见原因有:空指针、类型转换错误、SQL异常等。如果是ClassNotFoundException,检查jar包有没有导入,或者包名是不是javax和jakarta搞混了。
端口占用:Tomcat默认8080端口,如果被占用启动会失败。改端口在server.xml里改<Connector port="8080">,或者关掉占用端口的程序。
5.2 中文乱码问题全解
中文乱码分三种情况,我整理成表格:
| 场景 | 原因 | 解决方案 |
|---|---|---|
| POST请求参数乱码 | 请求体编码不对 | req.setCharacterEncoding("UTF-8") |
| GET请求参数乱码 | URL编码不对 | 配置URIEncoding或手动解码 |
| 响应内容乱码 | 响应头编码不对 | resp.setContentType("text/html;charset=UTF-8") |
| JSP页面乱码 | 页面编码不对 | page指令设置contentType |
实操心得:最省事的做法是在项目里加一个CharacterEncodingFilter,统一处理所有请求的编码。这样就不用每个Servlet都写一遍了。过滤器的写法后面会讲。
5.3 转发重定向的典型误用
我见过最多的误用是:登录成功后用转发跳转,结果用户刷新页面重复提交表单。记住一个原则:涉及数据修改的操作(增删改),成功后用重定向;只是查询展示,可以用转发。
还有一个坑:转发后,浏览器地址栏不变,用户以为还在原页面,如果此时刷新,会重复执行上一次请求。重定向就没这个问题。
另外,转发不能跨应用,只能在本应用内跳转。如果要跳到外部网站,只能用重定向。
5.4 Session失效与Cookie禁用
Session默认30分钟超时,用户如果长时间不操作,再访问时Session就没了。这时候要引导用户重新登录,而不是报错。可以在过滤器里统一判断。
如果用户浏览器禁用了Cookie,Session ID没法通过Cookie传递,Session就会失效。解决办法是URL重写,用resp.encodeURL()把Session ID拼到URL后面。不过现在禁用Cookie的情况很少了,了解即可。
6. 过滤器与监听器:Servlet的左右护法
6.1 Filter解决全局编码问题
前面提到每个Servlet都要写编码设置很麻烦,用Filter可以一次性解决。Filter可以在请求到达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;charset=UTF-8"); chain.doFilter(req, resp); } }@WebFilter("/*")表示拦截所有请求。chain.doFilter是放行,让请求继续往下走。如果不调用这行,请求就被拦截了,不会到达Servlet。
Filter还可以做登录校验、权限控制、日志记录等。多个Filter的执行顺序由web.xml里的配置顺序决定,注解方式无法指定顺序,这是个局限。
6.2 Listener监听应用生命周期
Listener用来监听Web应用中的事件,比如应用启动、Session创建销毁等。最常用的是ServletContextListener,在应用启动时做一些初始化工作。
@WebListener public class AppListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { System.out.println("应用启动了"); } @Override public void contextDestroyed(ServletContextEvent sce) { System.out.println("应用关闭了"); } }实际项目中,可以在contextInitialized里加载配置文件、初始化数据库连接池等。这样整个应用只执行一次,效率高。
7. 从Servlet到框架:理解底层才能用好上层
做完这个实验,你应该对Java Web的请求处理流程有了完整的认识。现在回头看Spring MVC,你会发现它其实就是把Servlet的这些东西封装了一层。DispatcherServlet本质上就是一个Servlet,它负责接收所有请求,然后根据配置分发到不同的Controller方法。你之前手动写的参数获取、编码设置、转发重定向,框架都帮你做了,但底层原理没变。
我个人的体会是,学框架之前一定要先把Servlet搞明白。不然你遇到框架的奇怪问题,根本不知道从哪查。比如Spring MVC里中文乱码,你知道是CharacterEncodingFilter的问题;比如重定向和转发的选择,你知道背后的原理。这些都不是框架教你的,是Servlet基础给你的。
这个实验做完之后,建议你再做两件事:一是用Maven重构一遍项目,理解依赖管理和标准目录结构;二是尝试用JSP+Servlet+JDBC做一个完整的增删改查小系统,把数据库操作也串进来。做完这两个,你的Java Web基础就相当扎实了。
最后分享一个我调试Servlet的小技巧:在doGet或doPost的第一行打印请求方法和URL,System.out.println(req.getMethod() + " " + req.getRequestURI());。这样每次请求你都能在控制台看到,排查问题时非常直观,尤其是分不清请求到底走到哪个Servlet的时候。