资讯动态

Servlet原理与实战:从生命周期到Spring MVC底层

发布时间:2026/10/14 21:43:12 来源:尧图企业网站定制
1. Servlet在JavaWeb里的生态位为什么十几年了它依然绕不开想当年我第一次学JavaWeb时也犯嘀咕页面都用框架生成了前后端都开始分离了还有必要啃Servlet这种老古董吗后来被现实反复教育了几次——面试被问Spring MVC底层、排查一个诡异的请求乱码、自己搭一个最朴素的HTTP接口入口全都要回到Servlet这套机制上。先给一个定位Servlet本质上不是框架是Java制定的一套服务端处理HTTP请求的规范。它定义了你写的Java类如何被容器加载、如何接收HTTP请求、如何把响应写回客户端。现在大家熟悉的Spring MVC、Struts2底层都是基于Servlet规范做封装前端控制器本质上就是一个经过特殊配置的Servlet。这也解释了为什么凡是做Java服务端开发的人早晚都要面对这类问题为什么继承HttpServlet后要重写doGet和doPost而不是像普通类那样写个方法就行为什么容器里一个Servlet只有一个实例却能同时处理无数个请求为什么web.xml里配置的url-pattern会影响整个项目能不能跑到入口为什么Spring MVC的DispatcherServlet只需要在web.xml里注册一次就能接管所有请求把这些搞清楚你理解Spring MVC、理解过滤器、理解拦截器都会顺畅得多因为它们全都在Servlet这套协作模型上生长。这篇我按自己实际学习和写代码的顺序来拆。先讲原理和生命周期再讲请求、响应、会话这三个最常用口子接着聊配置演进最后给一个完整的用户注册例子把知识点串起来。中间穿插的坑都是我在本地和服务器上真踩过的应该对刚入门或者想系统过一遍Servlet的人都有用。适用对象大概是这几类正在学JavaWeb但总觉得Servlet知识点零散的人会用Spring MVC但没细看过底层的人以及准备面试需要对Servlet原理做体系化梳理的人。不用你有多深的基础会Java语法、装过Tomcat就行。2. 生命周期和容器协作机制实例化时机、线程模型与常见误解2.1 从写一个类到容器启动之间发生了什么先做个小实验。按标准步骤新建一个动态Web项目写一个普通的Java类让它继承HttpServlet重写三个方法public class LifecycleServlet extends HttpServlet { public LifecycleServlet() { System.out.println(1. 构造器被调用 this); } Override public void init(ServletConfig config) { System.out.println(2. init被调用参数 config.getServletName()); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { System.out.println(3. doGet被调用线程 Thread.currentThread().getName()); resp.setContentType(text/plain;charsetUTF-8); resp.getWriter().write(lifecycle ok); } Override public void destroy() { System.out.println(4. destroy被调用); } }接着在web.xml里这样注册servlet servlet-namelifecycle/servlet-name servlet-classcom.demo.servlet.LifecycleServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namelifecycle/servlet-name url-pattern/lifecycle/url-pattern /servlet-mapping启动Tomcat观察控制台输出顺序。如果配置了load-on-startup启动阶段就会打印构造器被调用和init被调用如果没有这一项这两行会等到第一次请求到达时才打印。第一次访问/lifecycle后请求处理完毕doGet打印完Servlet实例依然留在内存里。再次访问只会看到第三次打印不会再走构造器和init。所以Servlet生命周期其实就是四个节点加载和实例化容器读到Servlet配置后通过反射调用无参构造器创建实例。默认第一次请求时创建load-on-startup配正整数可以改成容器启动时创建数值小的先加载。初始化创建完实例后立即调用init(ServletConfig)只执行一次。你可以在这里读取初始化参数、创建数据库连接池、启动后台定时任务。注意init抛异常会导致这个Servlet不可用容器会销毁这个实例。服务每次请求到达容器分配一个工作线程调用service方法。HttpServlet的service已经帮我们做了HTTP方法分派所以通常我们只管doGet、doPost。销毁容器关闭或应用重新部署时调用destroy释放资源。理解这个流程后一个关键结论就浮出来了Servlet是单实例多线程的。不管并发多高容器只维持一个Servlet对象所有请求共享它。这既是省内存的设计也埋了线程安全的雷。2.2 线程模型和实例变量的坑我之前接手过一个做报表的项目核心Servlet里放了一个成员变量用于统计导出次数public class ReportServlet extends HttpServlet { private int count 0; protected void doGet(HttpServletRequest req, HttpServletResponse resp) { count; // 其他业务逻辑 } }部署上线后导出几次就发现数字对不上偶尔还出现重复数据。原因就是多个请求线程同时执行count这不是原子操作读改写三步之间可能被其他线程插一脚导致更新丢失。这就是典型的Servlet并发隐患。正确的做法是尽量不在Servlet里写可变实例字段。如果要统计用AtomicInteger或者加锁如果要存放跟请求相关的数据一律存HttpServletRequest的attribute里再不行就放到ThreadLocal。不要在init里放重量级对象之外的共享可变状态。数据库连接池这类只读配置没问题但连接本身不要共享。如果你真的在doGet或doPost里new了一个SimpleDateFormat作为局部变量那是安全的把它提成成员变量就有并发格式化错误的风险。这里再延伸一个不少新手理解偏的点service方法本身不要随便重写。HttpServlet的service协议是先把request和response包装好再判断HTTP方法调用对应的doXxx。你如果自己重写service并忘记调用super.service那所有doGet、doPost都不会执行。2.3 请求到达Servlet的完整链路把一次请求的路径完整走一遍会对你排查问题很有帮助。假设地址是http://localhost:8080/hello/lifecycle浏览器建立TCP连接发送HTTP请求到Tomcat的8080端口。Tomcat的连接器Coyote解析HTTP报文把请求行、请求头、请求体解析成org.apache.catalina.connector.Request对象同时创建对应的Response对象。这两个对象会被包装成HttpServletRequest和HttpServletResponse交给容器内部的ApplicationFilterChain执行过滤器链。过滤器链走完容器根据请求的URI在映射表里查找对应的Servlet和servlet-mapping。找到后分配线程池里的一个线程调用Servlet的service方法最终分派到你的doGet或doPost。方法执行完毕响应通过Response对象写回容器负责把响应报文格式标准化最后连接器把数据送给浏览器。理解这条链路最大的价值在于过滤器、监听器、Servlet各管一段职责分离。不要想着在一个Servlet里干所有事也不要在过滤器里做业务判断。后面排问题的时候你能快速定位是哪一段出了问题。3. 请求处理全景从HTTP报文到Request对象的常用操作3.1 参数读取的三种方式HTTP请求携带数据的方式主要三种URL查询参数、表单体、路径参数。对应到Servlet里// 方式一GET查询参数 String name request.getParameter(name); String[] hobbies request.getParameterValues(hobby); // 方式二POST表单体表单enctype默认是application/x-www-form-urlencoded String username request.getParameter(username); // 方式三路径里的信息配合WebServlet(/user/{id})这种REST风格需要自己解析 String pathInfo request.getPathInfo();这里最容易被忽略的是getParameterValues。同一个参数名出现多次比如复选框用getParameter只会拿到第一个值必须用数组形式。我自己就犯过只取到复选框一个值的错。还有一种情况请求体不是表单格式而是application/json。这时用getParameter什么都拿不到得读取输入流再解析BufferedReader reader request.getReader(); StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } String json sb.toString(); // 再用JSON工具解析如果是文件上传请求体是multipart/form-data在Servlet 3.0之后可以这样处理// 首先确保Servlet是支持multipart的 MultipartConfig(maxFileSize 1024 * 1024 * 10, maxRequestSize 1024 * 1024 * 50) WebServlet(/upload) public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part req.getPart(file); String fileName part.getSubmittedFileName(); part.write(/tmp/upload/ fileName); } }注意MultipartConfig的分量没加它getPart会直接抛异常。这个坑我见很多人踩过。3.2 请求头、请求编码以及其他实用方法读取请求头很简单String userAgent request.getHeader(User-Agent); String token request.getHeader(Authorization); // 遍历所有请求头 EnumerationString names request.getHeaderNames(); while (names.hasMoreElements()) { String name names.nextElement(); String value request.getHeader(name); }关于请求编码先记住一个结论GET请求的参数编码由服务器连接器决定POST请求表单体的编码由request.setCharacterEncoding决定且必须在读取参数之前设置。Tomcat 8及以上版本中连接器的URIEncoding默认是UTF-8所以GET里带中文参数一般没问题。POST表单如果不设置容器默认按ISO-8859-1去解析请求体中文就会乱码。正确姿势是在doPost最开始加一行request.setCharacterEncoding(UTF-8);这里有个容易翻车的地方只要你已经调用过getParameter或getParameterValues再调用setCharacterEncoding就晚了容器已经按旧编码解析完参数了。所以这一行务必放在任何参数读取之前。3.3 请求域的attribute和转发request.setAttribute往请求里放数据配合RequestDispatcher.forward做服务端跳转是早期JavaWeb页面数据传递的核心手段request.setAttribute(user, user); RequestDispatcher dispatcher request.getRequestDispatcher(/userDetail.jsp); dispatcher.forward(request, response);转发有四个细节要记牢转发是服务端行为浏览器地址栏不会变。转发可以传递request里的attribute但重定向不行。转发目标路径是容器内部的前面不要加域名和项目上下文直接写/userDetail.jsp这种容器会自动拼上上下文路径。转发后不要再调用response.getWriter().write(...)否则会报IllegalStateException或者出现内容拼接的奇怪现象。重定向则是另一种response.sendRedirect(/app/login.jsp); // 或者带相对路径 response.sendRedirect(req.getContextPath() /login.jsp);重定向本质是服务器返回302状态码和Location头浏览器再发起一次新请求。所以重定向能跳外部地址转发不行。判定用哪个的标准就一条要带着请求上下文继续处理用转发要改变浏览器地址并开启一个新请求流程用重定向。4. 响应输出与中文乱码字节流、字符流和编码的完整链路4.1 getWriter与getOutputStream的选择HttpServletResponse给你两个输出口getWriter()输出字符流适合输出HTML、JSON文本。getOutputStream()输出字节流适合输出图片、文件下载、二进制内容。二者只能选一个不能混用。混用会抛IllegalStateException。判断依据是你要写的是字符串还是二进制。比如输出JSONresp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\status\:\ok\});输出文件下载resp.setContentType(application/octet-stream); resp.setHeader(Content-Disposition, attachment;filenamereport.xlsx); byte[] data fileService.getBytes(); resp.getOutputStream().write(data); resp.getOutputStream().flush();4.2 中文乱码的全链路排查乱码问题在Servlet开发里几乎每天都能遇到。把链路拆开看一共三个编码点响应内容的字符编码getWriter拿到的是PrintWriter它用什么编码写取决于response.setCharacterEncoding设置的编码。不设置的话用容器默认的ISO-8859-1中文直接变成问号。响应头里的Content-Type浏览器看到Content-Type里的charset才知道用什么编码解析。所以setContentType(text/html;charsetUTF-8)其实同时做了两件事设置响应编码、告诉浏览器解码方式。服务器发送数据时使用的字节编码容器最终要把字符转成字节发出去。如果前面设了UTF-8这一环就是UTF-8。因此最稳妥的方案是在响应方法最开始设置resp.setContentType(text/html;charsetUTF-8);或者resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html);这两种写法效果等价。如果你用getOutputStream()输出中文那setCharacterEncoding对字节流是无效的你需要自己保证字符串已经是正确的UTF-8字节最简单的方式是用resp.getBytes(UTF-8)。我踩过的一个典型场景是JSP页面里pageEncoding设了UTF-8页面也是UTF-8但Servlet往响应里写中文就是乱。最后发现Servlet里只设了resp.setContentType(text/html)没带charsetUTF-8容器默认按ISO-8859-1输出。加上charsetUTF-8后问题消失。4.3 响应对象的其他实用配置状态码response.setStatus(404)直接设置response.sendError(404, Not Found)会走容器错误页机制并且会清空已经写入正文的内容。头信息setHeader设置单个值addHeader追加多个值。重定向、缓存控制、CORS头都是靠这套。缓存控制response.setHeader(Cache-Control, no-cache)对动态页面比较常用静态资源可以反过来用Cache-Control: max-age3600减少请求量。我建议你把响应的设置统一封装到一个工具方法里比如WebResponses.writeJson(resp, obj)里面固定设置ContentType、编码再写JSON序列化结果。这样所有Servlet输出风格统一不会某个接口漏了编码设置导致线上乱码。5. 会话管理的边界Cookie与Session的配合、分布式困惑5.1 URL、Cookie和Session的关系HTTP无状态但业务需要记住你是谁。Servlet规范给的方案是HttpSession第一次访问时容器创建一个Session对象生成一个唯一的ID通过Set-Cookie头把它下发给浏览器。浏览器下次请求带上JSESSIONID这个Cookie容器就能从存储里找到对应的Session。代码层面你甚至不需要感知IDHttpSession session request.getSession(); // 获取或创建 session.setAttribute(loginUser, user); // 再取 HttpSession session request.getSession(false); // false表示不创建 if (session ! null) { Object user session.getAttribute(loginUser); }几个容易混淆的概念getSession()和getSession(true)相同没有就新建。getSession(false)没有就返回null不会创建。判断登录态时推荐用这个。invalidate()会让会话立即失效一般用于登出。setMaxInactiveInterval(seconds)控制超时时间配置文件里的session-timeout也是它单位是分钟。5.2 Cookie操作与跨域限制有时你想自己写Cookie而不依赖容器自动发的JSESSIONID比如记住用户名、记住一周登录。基本套路Cookie cookie new Cookie(username, URLEncoder.encode(username, UTF-8)); cookie.setMaxAge(7 * 24 * 3600); // 存活时间秒 cookie.setPath(/); // 访问路径 resp.addCookie(cookie);读取Cookie[] cookies req.getCookies(); if (cookies ! null) { for (Cookie c : cookies) { if (username.equals(c.getName())) { String value URLDecoder.decode(c.getValue(), UTF-8); } } }这里有个特别容易忽略的细节Cookie的value不建议直接放中文要URL编码。浏览器对Cookie有字符限制规范上很多字符不能直接出现在value里。我之前放过登录名结果部分浏览器直接把这个Cookie丢弃排查了半天才发现是编码问题。另外setMaxAge设为0表示让浏览器立即删除这个Cookie这个是登出时清Cookie的标准做法不要设置成负数负数表示会话Cookie浏览器关闭就没了常被误用来删除。5.3 Session存哪儿去了单机内存、持久化和分布式不一致默认情况下Session存在Tomcat的内存里。这种方案在单机部署下简单粗暴好用。但问题是一旦你上了多台服务器前面加个负载均衡请求可能分到不同机器上。用户第一次请求在机器A创建了Session第二次请求被分到机器B就找不到这个Session登录态就丢了。缓解方案常见有这么几个Nginx粘滞会话ip_hash让同一个IP的请求固定打到一台后端配置简单但一旦这台机器挂掉它的Session全丢。Session持久化到数据库或Redis自己实现HttpSessionListener配合代码读写或者用现成的Session共享框架。这是分布式环境下比较普遍的做法。把状态外置Session里放的东西越来越少登录态改用Token需要的数据存Redis。这类做法其实已经跳出Servlet范畴是Spring Security那套思路。在Servlet学习阶段你不需要一上来就搞分布式Session但必须知道Session默认在单机内存里这个前提。很多人在本机测试一切正常上了两台服务器就说登录不稳定就属于这个问题。6. 两种配置方式的对比web.xml的写法、注解写法与Spring MVC的关系6.1 web.xml里的核心配置项早期Servlet开发没有注解所有注册都写在web.xml。一个典型的Servlet配置长这样web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namedemo/display-name servlet servlet-nameuserServlet/servlet-name servlet-classcom.demo.web.UserServlet/servlet-class init-param param-namepageSize/param-name param-value20/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-nameuserServlet/servlet-name url-pattern/user/*/url-pattern /servlet-mapping session-config session-timeout30/session-timeout /session-config welcome-file-list welcome-fileindex.html/welcome-file /welcome-file-list /web-appurl-pattern的类型要留个心/user精确匹配只有路径完全一样才命中。/user/*路径匹配/user、/user/list、/user/1都能命中。*.do后缀匹配旧项目里很常见。/默认Servlet匹配没有被其他Servlet处理的请求。这个配置要小心配了可能把静态资源也揽走除非你把它转发给静态资源处理器。容器匹配顺序是精确匹配优先于路径匹配路径匹配优先于后缀匹配。在有多个Servlet都沾边的时候按这个规则决定谁执行。6.2 Servlet 3.0之后的注解方式注解方式让配置轻量了很多WebServlet( name userServlet, urlPatterns {/user/*}, initParams { WebInitParam(name pageSize, value 20) }, loadOnStartup 1 ) public class UserServlet extends HttpServlet { }写起来比web.xml舒服但有两个注意点注解扫描需要Servlet容器支持Servlet 3.0对应Tomcat 7及以上版本。老项目如果跑在Tomcat 6上注解不生效。一旦类上加了WebServlet它会和web.xml里的Servlet注册共存。同一个类不能既被web.xml注册、又被注解注册容易出现ambiguous mapping的启动报错。实际项目里一般二选一别混着来。6.3 Spring MVC是怎么寄生在Servlet规范上的很多人学完Servlet不解Spring MVC的入口在哪里。答案很直接DispatcherServlet本身就是一个HttpServletSpring MVC通过web.xml或者Java配置把它注册到容器里然后把几乎所有请求映射给它。配置的核心在于url-pattern写法。传统Spring MVC项目写的是servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping这里用了/表示它成了默认Servlet。所有没被精确匹配的请求都会先过DispatcherServlet再由它内部的路由解析交给对应的Controller。所以你发现问题了吧Controller里的RequestMapping跟Servlet规范不是一回事那是在Spring容器内部再做的二次路由。但触发这一切的前提是请求成功到达了作为Servlet存在的DispatcherServlet。这也就是为什么有时候你写了RequestMapping却总是404——请求根本没被Spring MVC接管反而被别的Servlet或者静态资源处理器处理了。排查这类问题时把思路切回它也是个Servlet很多疑问就迎刃而解。6.4 load-on-startup与init-param的实际应用load-on-startup这个配置看起来小实际使用有讲究。设为0或正整数Tomcat启动时就初始化Servlet。通常给那些启动就要干活的Servlet用比如加载配置、预热缓存、启动定时任务。但注意设了它不代表所有初始化都能成功如果init抛异常应用虽然能启动但这个Servlet无法使用请求会404。init-param适合放不影响代码逻辑的轻量配置。代码里这样读public void init(ServletConfig config) { String pageSize config.getInitParameter(pageSize); }注意和上下文参数context-param区分init-param是单个Servlet私有的context-param是整个应用共享的通过getServletContext().getInitParameter()读取。如果你在多个Servlet里都要用同一个配置放context-param更合适。7. 一个能落地的用户注册功能Servlet层面的完整实现前面把原理讲了不少现在做一个完整的例子。需求不花哨用户填用户名、密码、年龄提交注册保存到数据库注册成功后跳到列表页显示所有用户。用纯Servlet实现不引入框架但我会在关键位置标注哪些地方换成Spring MVC可以怎么做。7.1 建表和JDBC工具先准备数据库表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, age INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );JDBC部分用一个简单的工具类public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }连接URL里的characterEncodingUTF-8很重要它保证数据库层面读写中文不乱。7.2 注册ServletdoGet显示表单doPost处理提交我这里把表单和提交处理放在同一个Servlet里用doGet渲染表单页面用doPost处理提交。实际开发中更常见是页面交给HTML/前端框架这个Servlet只管doPost。WebServlet(/register) public class RegisterServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher(/WEB-INF/register.jsp).forward(req, resp); } 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); String ageStr req.getParameter(age); // 基础校验 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { req.setAttribute(error, 用户名和密码不能为空); req.getRequestDispatcher(/WEB-INF/register.jsp).forward(req, resp); return; } int age 0; if (ageStr ! null !ageStr.trim().isEmpty()) { try { age Integer.parseInt(ageStr); } catch (NumberFormatException e) { req.setAttribute(error, 年龄必须是数字); req.getRequestDispatcher(/WEB-INF/register.jsp).forward(req, resp); return; } } // 这里实际应该用BCrypt加密密码示例仅为演示 User user new User(username, password, age); try (Connection conn DbUtil.getConnection()) { String sql INSERT INTO t_user(username, password, age) VALUES (?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setInt(3, user.getAge()); ps.executeUpdate(); } // 注册成功重定向到列表避免刷新重复提交 resp.sendRedirect(req.getContextPath() /user/list); } catch (SQLException e) { e.printStackTrace(); req.setAttribute(error, 注册失败可能用户名已存在); req.getRequestDispatcher(/WEB-INF/register.jsp).forward(req, resp); } } }常见的踩坑点这里先说req.setCharacterEncoding必须放在getParameter之前。错误信息通过setAttribute放在请求里然后forward到表单页。注意表单页要把error显示出来同时保留用户之前填的用户名和年龄别让用户填错了就全丢这也是个好习惯。注册成功后用sendRedirect而不是forward这是防止表单重复提交。没有重定向的话用户按F5刷新浏览器会重新提交上一次的POST请求导致数据库里多出重复数据。密码不能明文存库至少用哈希算法。真实项目请直接用BCrypt一类的密码哈希库。7.3 查询列表ServletWebServlet(/user/list) public class UserListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { ListUser userList new ArrayList(); String sql SELECT id, username, age, create_time FROM t_user ORDER BY id DESC; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setAge(rs.getInt(age)); user.setCreateTime(rs.getTimestamp(create_time)); userList.add(user); } } catch (SQLException e) { e.printStackTrace(); } req.setAttribute(userList, userList); req.getRequestDispatcher(/WEB-INF/userList.jsp).forward(req, resp); } }这个Servlet有两个小细节值得说一下。第一WEB-INF目录下的JSP外部不能直接访问必须通过Servlet转发进去。这是一种安全设计防止用户绕过Servlet逻辑直接打开页面。如果你把JSP直接放在webapp根目录下别人就能输入路径直接访问像上面的注册页还好列表页没有登录限制的话数据直接裸奔。第二forward到JSP时JSP里可以用request.getAttribute(userList)取出数据。对应的JSP片段% page contentTypetext/html;charsetUTF-8 languagejava % html body h2用户列表/h2 table border1 tr thID/th th用户名/th th年龄/th th注册时间/th /tr c:forEach items${userList} varuser tr td${user.id}/td td${user.username}/td td${user.age}/td tdfmt:formatDate value${user.createTime} patternyyyy-MM-dd HH:mm:ss//td /tr /c:forEach /table /body /html注意JSP页面顶部一定要有pageEncoding和contentType双保险否则列表里的中文用户名可能出现乱码。7.4 给这个Demo做的分层反思上面Demo把逻辑全写在Servlet里适合演示和学原理。但真实项目里Servlet的角色应该被严格限制为接收请求、解析参数、调用业务层、跳转或响应。数据库操作、业务校验这些要下沉到Service和DAO否则Servlet会越来越胖。我当时是这样重构成自己熟悉的模式的WebServlet(/register) public class RegisterServlet extends HttpServlet { private UserService userService new UserService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 参数解析、编码设置 // 调用 userService.register(user) // 根据返回结果决定 forward 还是 redirect } }UserService负责校验和调DAOUserDao只管SQL。这样改完Servlet的代码量减少一半后面要加验证码、加登录态、加分布式锁都在Service层扩展不需要改动Servlet。8. Servlet实际开发中的5个高频坑与排查思路8.1 404的排查顺序先别急着看代码Servlet开发里遇到404不少人第一反应是代码逻辑问题实际上多半是映射没配对。我建议按这个顺序排查确认应用是否成功部署看Tomcat日志里有没有启动Exception。确认访问路径是否带上了上下文路径。ContextPath就是你部署的war包名字或者IDE里配置的Application context。比如项目名叫demoURL应该是http://localhost:8080/demo/register少敲/demo就404。确认url-pattern是否匹配。WebServlet(/register)对应/register访问/register.do肯定不行。确认Servlet类有没有被容器扫描到。包路径和扫描配置要对应类上忘了加WebServlet也会404。最后再看业务代码里有没有自己sendError或者异常没处理导致容器返回404。8.2 启动报错java.lang.ClassNotFoundException该怎么看报这个错一般情况是你的类压根没打进去或者类名写错。先检查web.xml里的servlet-class全限定名是否正确再去target目录看看.class文件有没有被编译进WEB-INF/classes。如果用了Maven很大概率是源代码目录结构不对比如java目录没有被识别为源码根。我遇到过几次改一下模块设置就解决了。8.3 请求参数全部为null先看请求方式doGet里用request.getParameter拿不到POST body里的参数。doPost里用getParameter拿不到URL查询参数之外的JSON体参数。这不是代码问题是你请求的类型和数据格式压根不对。建议调试时先打开浏览器的开发者工具在Network面板看请求详情——Method是GET还是POST、Content-Type是什么、Payload形式是什么。一眼就能定位。8.4 明明写了响应内容浏览器却空白最常见原因就两个一个是用了getOutputStream写文本但没有设置Content-Type为text/html或者application/json浏览器不知道该怎么显示另一个是用完getOutputStream后调用了close()把整个响应连接关了后面的内容写不进去。正确的做法是只flush()不要close()。容器负责关闭和回收你越俎代庖反而弄坏响应通道。8.5 过滤器修改了请求和响应却没传下去这个坑说大不大但真出事很难查。写了个Filter做请求耗时统计或者统一编码在doFilter里把request包装了一层却忘了传递response或者传了旧对象。代码可能不报错但后续Servlet拿到的Response已经和过滤器里设置的不一致。排查这类问题先在过滤器里打印request和response的类名再看Servlet里打印如果类名不一致多半是包装链出了问题。9. 学习Servlet的正确姿势从原理到框架的迁移路径最后聊聊学习路径的问题。如果目标是快速上手Spring Boot写CRUDServlet是不是可以跳过我的观点是可以不写但不能不懂。Spring Boot内嵌的Tomcat里跑的还是Servlet体系DispatcherServlet还在过滤器链还在HttpSession还在。只是被框架藏起来了。遇到疑难杂症时框架藏起来的那层恰恰是问题所在。建议按这个顺序练习手写至少2个Servlet一个做注册一个做列表查询把doGet/doPost、forward/redirect、request/response都过一遍。手动配置一次web.xml注册Servlet、过滤器、监听器各一个理解容器怎么加载它们。写一个过滤器统一处理编码和登录校验体会过滤器链在请求链路中的位置。把上面注册功能里的密码换成哈希存储自己实现登录和Session判断理解会话机制。最后再去看Spring MVC的源码或文档你会发现DispatcherServlet处理请求的过程和你手写的Servlet大同小异——解析请求、调用业务方法、渲染响应。我还建议你在本地搭一个最小项目用Tomcat直接跑别一上来就用Spring Boot。等你手动打过war包、手动部署过Tomcat、手动配置过数据源再退回Spring Boot会明显感觉不同报错不再是黑盒你能从异常栈里一眼看出是连接池问题、Servlet映射问题还是过滤器顺序问题。Servlet这套技术虽然出身早但它定义的请求处理模型至今没有过时。理解它就是理解JavaWeb服务端的地基。地基打稳了上面盖多少层框架都踏实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑