《Head First Servlet & JSP》这本经典的入门书,第三章正好是从“纯Servlet输出HTML”过渡到“Servlet + JSP 分工协作”的关键节点。这篇笔记03,我打算换个玩法,不按目录一章章念,而是直接把这一阶段最常碰到的实操场景串起来——从Eclipse里建Maven的Servlet项目、写一个JSP个人信息页、到让Servlet调用大模型API接口、再看清楚Tomcat编译后的JSP真实面目。这个阶段搞透了,后面学MVC、自定义标签都会顺很多。
这篇笔记适合两类人:一是刚把Servlet基础语法啃完、想找个完整项目练手的新手,二是已经在用IDEA或Eclipse写Web项目,但对JSP内部原理还停留在“会用但不清楚为什么”的朋友。我会把每一步操作背后的原理也顺带讲清楚,不光是让你能跑起来,更重要的是让你知道为什么这么写、这个坑是怎么来的。
1. 整体设计与思路拆解
1.1 为什么第三章开始推荐用Maven建项目
《Head First Servlet & JSP》书里的例子基本都是手动把class文件扔到WEB-INF/classes目录下,再手工重启Tomcat。这种操作在2008年左右很主流,放到今天开发效率实在太低了。现在的Web项目基本都走Maven或Gradle来管理依赖和构建流程。
用Maven建Servlet项目的好处有三个:依赖版本不用自己满网找jar包、部署打包一条命令完成、同事之间协作时项目结构完全一致。第三章的练习恰好涉及很多第三方库——JSTL标签库、数据库驱动、JSON解析库,这时候Maven的依赖管理优势体现得淋漓尽致。
我当时在Eclipse里创建一个Maven Web项目,选的是maven-archetype-webapp骨架。这个骨架生成的目录结构非常标准:src/main/java放Java源码,src/main/webapp放JSP、CSS、JS等Web资源。Eclipse里操作路径是:File → New → Maven Project → Next → 选中maven-archetype-webapp,Group Id和Artifact Id随便填,比如com.example和servlet-jsp-demo。
创建完后有个小问题:默认生成的web.xml是Servlet 2.3的旧版本规范,如果用的Tomcat 9以上,建议手动把web.xml升级到4.0版本,或者在pom.xml里配置maven-war-plugin指定failOnMissingWebXml为false,直接走注解方式配置Servlet。我用的是注解方式,省掉了web.xml里一大段配置代码。
1.2 Servlet和JSP的职责边界
第三章的核心思想就是:Servlet负责处理请求、调用业务逻辑,JSP负责展示数据。这个思想第一次接触时容易犯迷糊,因为上一章里Servlet还能用out.println()输出HTML,怎么到了第三章突然说“别这么写了”?
我个人的理解方式是这样的:Servlet里拼HTML就像用System.out.println写一篇带格式的公众号文章,写的人痛苦、看的人更痛苦。一旦页面复杂,Java代码和HTML标签混在一起,改个样式都要重新编译重启。而JSP天生就是为“在HTML中嵌入Java代码”设计的,浏览器看到的最终结果就是纯HTML,JSP里的Java代码执行完就消失了。
实际开发中,这个分工还要再细化一层:Servlet拿到请求参数后,调用Service层处理业务,把结果放到request或session作用域里,然后forward给JSP。JSP不直接访问数据库、不直接处理请求参数校验,只管把数据渲染出来。这个“Model 2”架构就是后面MVC的雏形。
第三章练习里比较典型的场景是:用户提交一个表单(比如个人信息),Servlet接收参数后做简单校验,封装成一个JavaBean对象,存到request作用域,再转发到display.jsp展示。这样写的好处是,如果以后想换页面样式,只改JSP;如果想改校验逻辑,只改Servlet,互不干扰。
2. 核心实操细节
2.1 创建Servlet并用注解配置映射路径
项目骨架建好后,开始写第一个Servlet。在src/main/java下建一个InfoServlet类,继承HttpServlet,重写doGet和doPost方法。
Servlet 3.0以后支持注解方式配置访问路径,不需要在web.xml里写<servlet>和<servlet-mapping>了。我用的是@WebServlet("/info")这种方式:
@WebServlet("/info") public class InfoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Student student = new Student("张三", 23, "计算机科学与技术"); req.setAttribute("studentInfo", student); req.getRequestDispatcher("/display.jsp").forward(req, resp); } }这里有个关键的forward方法,内部机制很重要:forward是服务器内部跳转,浏览器地址栏不会变,整个过程只有一次请求、一次响应。与之相对的sendRedirect是浏览器重新发一次请求,地址栏会变。第三章刚开始用forward就够了,后面学PRG模式时才用redirect。
然后写一个简单的JavaBean类Student,三个属性:name、age、major,配上无参构造器和getter/setter方法。有人可能觉得写getter/setter很啰嗦,但JSP在EL表达式(${student.name})取值时,底层就是通过反射调用get方法,没有getter就拿不到值,这是硬性约束。
2.2 JSP页面编写与EL表达式取值
JSP页面放在src/main/webapp下,新建display.jsp。页面顶部可以用<%@ page contentType="text/html;charset=UTF-8" language="java" %>声明。然后通过EL表达式从request作用域里取出Servlet放进去的数据:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>个人信息展示</title> </head> <body> <h2>个人信息</h2> <table> <tr> <td>姓名:</td> <td>${studentInfo.name}</td> </tr> <tr> <td>年龄:</td> <td>${studentInfo.age}</td> </tr> <tr> <td>专业:</td> <td>${studentInfo.major}</td> </tr> </table> </body> </html>EL表达式的取值路径是:先找pageContext→request→session→application四个作用域,看有没有叫studentInfo的属性,找到后调用它的getter方法。所以${studentInfo.name}实际上对应的是request.getAttribute("studentInfo").getName()。
如果页面上发现值没显示出来,八成是作用域不匹配。比如Servlet里用session.setAttribute存的数据,在JSP里用${studentInfo}也能取到,但请求转发时用request作用域更合理,因为数据只跟本次请求有关,不该占session内存。
如果是IDEA用户,新建JSP时有个容易忽略的问题:IDEA默认颜色方案(Color Scheme)里没有JSP文件的语法高亮选项,看起来就像纯文本文件,很不舒服。解决方法是在Settings → Editor → File Types里确认*.jsp有没有关联到JSP文件类型,或者安装专门支持JSP的插件。这个问题不影响功能,但会影响写代码的体验,我一开始用IDEA写JSP时总觉得哪里怪怪的,后来发现是高亮没生效。
2.3 屏蔽JSP离开页面提示的两种思路
这个话题在排除页面跳转问题时非常实用。所谓“JSP离开页面提示”,本质是浏览器的beforeunload事件在起作用。很多管理后台为了防止用户误关页面,会用window.onbeforeunload弹一个“确定要离开吗”的提示框。但如果你在本地调试时每个页面都弹这个框,非常烦躁。
屏蔽的思路有两种:一种是在JSP页面里去掉或者覆盖掉这段JavaScript代码,另一种是直接通过URL“白名单”方式控制。实战里我用得比较多的是在公共的footer里加一个全局开关:
// common.js 中增加全局开关,调试时改成false即可 window.ENABLE_UNLOAD_CONFIRM = false; if (window.ENABLE_UNLOAD_CONFIRM) { window.onbeforeunload = function() { return "数据尚未保存,确认离开吗?"; }; } else { window.onbeforeunload = null; }把这段脚本放到公共JS里,所有JSP页面统一引用。正式上线时把开关置为true,本地调试时置为false。如果只是临时调试,直接在浏览器控制台执行window.onbeforeunload = null就能一次性解除,刷新页面后如果脚本重新赋值才会再次弹窗。
这节课对应的场景里,更多是“跳转/屏蔽弹窗”这类前端与后端配合的问题,属于现代Web开发里很现实的需求。
3. 让Servlet调用大模型API接口
3.1 思路与准备
现在很多项目都会在Servlet后端里调用第三方大模型API接口,比如根据用户输入返回一句话、生成摘要、做意图识别。这部分内容在传统《Head First Servlet & JSP》里没有,但既然很多读者搜到了这个关键词,我觉得值得写一段。
核心思路很简单:Servlet作为后端Java程序,通过HTTP客户端工具(java.net.http.HttpClient 或 Apache HttpClient)向大模型服务发送请求,拿到结果后再把数据封装进request/session,转发到JSP渲染。
先确认JDK版本,JDK 11以上自带了java.net.http.HttpClient,不需要额外引依赖。如果你用的是JDK 8,那就只能引入Apache HttpClient或OkHttp的jar包了。我在这台机器上是JDK 17,所以直接用JDK内置的HttpClient。
大模型API服务有很多种形态,这里以“调用远程HTTP接口”为例,你的代码里只需要关心两个东西:请求URL和请求体格式。一般通用格式是一个JSON,包含你输入给模型的内容。这里不涉及任何特殊网络手段,就是用Java发了一个标准的HTTPS请求。
3.2 完整的Servlet代码结构
下面这个AiServlet,是我在个人项目里常用的一个模板。它接收前端传来的文本,调用大模型API,把返回结果放回页面:
@WebServlet("/ai") public class AiServlet extends HttpServlet { private static final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String userInput = req.getParameter("text"); String result = callAiApi(userInput); req.setAttribute("aiResult", result); req.getRequestDispatcher("/result.jsp").forward(req, resp); } private String callAiApi(String text) { // 构造JSON请求体 String jsonBody = "{" + "\"messages\":[{\"role\":\"user\",\"content\":\"" + text + "\"}]," + "\"max_tokens\":200" + "}"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://your-api-endpoint.example/v1/chat")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json") .header("Authorization", "Bearer YOUR_API_KEY") .POST(BodyPublishers.ofString(jsonBody)) .build(); try { HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString()); // 这里只是简单返回响应体,实际项目里需要解析JSON return response.body(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return "请求被中断"; } catch (IOException e) { e.printStackTrace(); return "接口调用失败"; } } }这段代码要注意的细节:
Authorization头里的API Key不要硬编码在Java代码里,应该放到环境变量、配置文件或数据库配置表中。- 大模型API接口地址和响应格式因服务商而异,我这里留的是占位符,实际对接时以你使用的API文档为准。
- 如果响应时间较长,前端页面会一直等着。更优雅的方案是异步调用,或者在Servlet里用Callable配合线程池,但这个属于进阶,初学者先同步调用把流程跑通。
3.3 JSP页面接收AI结果
result.jsp里用EL表达式展示结果:
<div> <p>AI返回结果:</p> <p>${aiResult}</p> </div>这里我踩过一个坑:大模型API返回的内容可能是JSON格式,直接显示在JSP页面上非常丑。后来我在Servlet里先把response.body()解析一下,取出里面需要的字段,塞到一个简单的ResultDto对象里,再放request作用域。这样JSP页面拿到的就是纯文本内容,不需要关心JSON结构。如果大家拿到的返回格式比较复杂,建议用Jackson或Gson库来解析,Maven里加依赖就行:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency>解析代码大概是:
ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(responseBody); String content = root.path("choices").path(0).path("message").path("content").asText();这样“Servlet调用大模型API接口再转发给JSP展示”的完整链路就通了。这个模式在企业项目里很常见,本质上跟调数据库接口没区别,只是把“数据源”换成了“外部AI服务”。
4. 查看JSP编译后的Java类
4.1 Tomcat的work目录藏着JSP的“真身”
很多读者搜“web项目配置tomcat后查看jsp编译后的java类”,我猜是和我当初一样,学完了JSP标签、表达式,还想进一步确认JSP底层到底是什么。
JSP本质上就是一个Servlet。这个说法所有人都会背,但只有真的去Tomcat的work目录里翻出那个编译出来的Java文件,才算真正理解它的生命周期。
以一个名字叫display.jsp的页面为例。项目发布到Tomcat后,Tomcat会把JSP翻译成一个Java类,路径一般在:{Tomcat安装目录}/work/Catalina/localhost/{项目名}/org/apache/jsp/display_jsp.java。
我第一次打开这个文件的感受是很震撼的:里面就是一个org.apache.jsp.display_jsp类,继承了org.apache.jasper.runtime.HttpJspBase,而HttpJspBase最终继承的是HttpServlet。
这个类里的_jspService方法就是完整版的service方法。你在JSP里写的静态HTML代码,全都被封装成了out.write()语句,一行一行输出;你在JSP里写的<% %>脚本片段,Java代码会原样保留在_jspService方法体里;你用到的request、response、session、application等隐式对象,在这里都变成了方法参数或预定义变量。
这个文件能看的信息量很大:
- 能看到JSP翻译成Java时,EL表达式被编译成了什么样的运行时调用。
- 能看到为什么JSP比Servlet更“重”——每次请求都要经过
_jspService里的检查和输出逻辑。 - 能看到JSP页面改动后,Tomcat如何自动检测并重新编译。
4.2 在Eclipse和IDEA里分别怎么查看
Eclipse里如果你的项目已经发布到Tomcat,可以在Servers视图里双击Tomcat服务器,打开“Overview”面板,找到“Server Path”和“Deploy Path”,那个目录对应的work目录里就有编译后的文件。默认发布目录一般是wtpwebapps,对应的work目录是work/Catalina/localhost/项目名。
IDEA里的路径稍有不同。IDEA不是把项目发布到Tomcat的webapps目录,而是用“exploded war”的方式直接部署。可以在Tomcat配置界面里找到“Tomcat Server → Application server → Tomcat installation directory”,然后去{TOMCAT_HOME}/work/Catalina/localhost/{context}里找。如果没有自动生成,等第一次通过IDEA启动并访问过该JSP后再去看。
IDEA用户还有一个常见问题是Color Scheme里没有JSP的语法高亮。这通常是因为IDEA的Settings里没有识别到JSP文件类型,或者缺少JSP相关的插件支持。处理方法是:File → Settings → Editor → File Types → 找到“JavaServer Pages”或“JSP”文件类型,确认注册了*.jsp后缀。如果文件类型里压根没有JSP,可以尝试安装“Jakarta EE: Web”相关插件,然后重启IDEA。
4.3 从编译类反推JSP的“隐式对象”
看编译后的Java类,还有一个好处:能验证那几个“隐式对象”的本质。
request→_jspx_page_context.getRequest(),实际类型是ServletRequest,实际运行时对象是Tomcat封装后的Request。response→_jspx_page_context.getResponse()。session→pageContext.getSession(),如果页面配置了session="false",这个变量就不可用。application→pageContext.getServletContext()。out→pageContext.getOut(),实际是JspWriter,是带缓冲的写输出流。
如果你在JSP里用了pageContext.setAttribute("key", value),编译后的代码其实是_jspx_page_context.setAttribute("key", value)。
理解了这一层,再看《Head First Servlet & JSP》第三章关于“JSP是Servlet的模板化实现”的表述,就顺理成章了。
5. 综合实操:JSP学生信息管理系统落地
5.1 数据库表与连接准备
趁热打铁,我把第三章学的知识点融进一个Mini学生信息管理系统,这个系统在网上也特别多人搜索。功能很简单:学生列表展示、新增学生、删除学生。技术栈是Servlet + JSP + JDBC。
先建表:
CREATE TABLE student ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT, major VARCHAR(100) );连接数据库用JDBC的DriverManager即可,为了省事我没有上连接池。把连接信息放到一个JdbcUtil工具类里:
public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/school?useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "yourpassword"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }Maven里需要加MySQL驱动依赖:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>5.2 Servlet与JSP分层实现
项目包结构我习惯这样分:
com.example.entity:Student实体类com.example.dao:StudentDao,操作数据库com.example.servlet:ListStudentServlet、AddStudentServlet、DeleteStudentServletwebapp:list.jsp、add.jsp
StudentDao里写一个最简单查询方法:
public List<Student> findAll() { List<Student> list = new ArrayList<>(); String sql = "SELECT id, name, age, major FROM student"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setName(rs.getString("name")); s.setAge(rs.getInt("age")); s.setMajor(rs.getString("major")); list.add(s); } } catch (SQLException e) { e.printStackTrace(); } return list; }ListStudentServlet里调用dao,把列表放到request作用域并转发到list.jsp:
@WebServlet("/list") public class ListStudentServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { StudentDao dao = new StudentDao(); List<Student> students = dao.findAll(); req.setAttribute("students", students); req.getRequestDispatcher("/list.jsp").forward(req, resp); } }list.jsp里用EL + JSTL遍历。JSTL标签需要引入依赖:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>JSP核心:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr> <th>ID</th> <th>姓名</th> <th>年龄</th> <th>专业</th> <th>操作</th> </tr> <c:forEach items="${students}" var="s"> <tr> <td>${s.id}</td> <td>${s.name}</td> <td>${s.age}</td> <td>${s.major}</td> <td><a href="${pageContext.request.contextPath}/delete?id=${s.id}">删除</a></td> </tr> </c:forEach> </table>这里${pageContext.request.contextPath}一定要用,否则项目部署在非根路径时,链接会404。这个细节我是踩过坑的:直接写/delete?id=1在Eclipse嵌入式Tomcat里可能刚好没问题,但一旦部署到Tomcat的某个子应用路径下,永远找不到资源。
新增页面的逻辑类似,form提交到AddStudentServlet,Servlet里接收参数、装成Student对象、调用dao.insert、最后resp.sendRedirect("list")重定向到列表页。注意这里用的是重定向而不是转发,这样才能避免用户按F5时表单重复提交。
5.3 常见问题排查速查表
| 问题现象 | 原因分析 | 解决思路 |
|---|---|---|
| JSP页面报500错误,日志里有ClassNotFoundException | Maven依赖没打进去,或者Tomcat运行时找不到jar包 | 检查WEB-INF/lib里有没有mysql或jstl,Maven项目可以执行mvn package后看war内容 |
| 页面文字乱码 | 请求编码或响应编码不一致 | Servlet里req.setCharacterEncoding("UTF-8"),JSP页面里contentType加上charset=UTF-8,URL连接串加上characterEncoding=utf8 |
EL表达式输出原样${studentInfo.name} | 页面没启用EL,或者Servlet容器版本过低 | 检查web.xml的web-app版本是否在2.4以上,或查看page指令里isELIgnored是否设置成了true |
| 点击删除按钮后404 | 链接少了contextPath | 使用${pageContext.request.contextPath}拼接路径 |
| 刷新页面数据重复提交 | 新增操作用了forward | 新增、修改、删除这类“写”操作结束后,一定要用sendRedirect重定向到列表页 |
| JSP改动后浏览器显示的还是旧页面 | Tomcat JSP缓存 | 删除Tomcat的work目录下对应项目文件,重新启动;IDEA里可以执行Build → Rebuild Project |
| Tomcat启动报端口被占用 | 8005、8080、8009端口被其他进程占用 | 找到占用进程结束,或修改Tomcat的server.xml端口 |
这些坑大多不是理论和算法问题,纯粹是配置和规范上的细节,但排查起来很费时间。我把它们汇总成一张表,方便大家对照。
6. 个人体会与这个项目的扩展方向
按《Head First Servlet & JSP》第三章的进度,学到这个阶段最明显的感受是:Servlet + JSP这套组合并没有被时代淘汰,而是变成了Java Web开发的“底层语言”。现在流行的Spring MVC,核心分发器还是一个Servlet(DispatcherServlet),页面渲染用的是整合后的视图解析器。在学Spring框架之前,把Servlet、JSP、Filter、Listener这几个基本功打扎实,后面调框架底层问题时能省太多力气。
这个学生信息管理系统如果还想继续扩展,顺着几个方向往下写都是很好的练手项目:加一个Filter做登录拦截和字符编码统一处理、用EL和JSTL替代所有<%= %>脚本、用自己的BaseServlet抽象公共逻辑,或者接一个连接池替代JDBC直连。我个人特别建议把“Filter + 编码处理”作为下一个目标,因为实际项目里几乎没有一个Web应用不涉及Filter,这是Servlet规范里最容易被人低估、但生产环境却离不开的组件。
最后再分享一个小技巧:学习JSP时,没必要把所有标签都背下来。真正经常用的就是<c:forEach>、<c:if>、<c:choose>这几个核心标签,外加EL表达式。页面上遇到复杂逻辑时,优先考虑是不是应该在Servlet这边把数据整理好,而不是硬塞一堆Java脚本到JSP里。能保持页面简单的工作方式,才是可持续的工作方式。