
写过前面两篇到第三篇的时候学习状态明显不一样了。前两篇还在逐个概念地啃Servlet生命周期、HttpServletRequest/Response、JSP脚本元素、page指令……到这一篇开始真正做东西了。我在整理这一阶段笔记时顺手翻了翻社区的常见问题eclipse创建基于Maven的servlet项目、IDEA创建JSP文件、JSP学生信息管理系统、屏蔽JSP离开页面提示、Servlet调用大模型API接口……有意思的是一边是IDEA里Color Scheme完全没有JSP这种很基础的环境问题一边已经有人把Servlet接上大模型了。Servlet和JSP这套技术从诞生到现在二十多年学习路径居然还能同时覆盖最原始的JDK工具和最新的AI接口这说明它在JavaWeb世界的基础地位确实没有被撼动。这一篇笔记就把我这一阶段实操时沉淀下来的东西按主题拆开写项目怎么从手动拷包升级成Maven、IDE里跟JSP相关的几个高频问题的处理办法、第一个完整的ServletJSP数据展示页、以及可以拿来当毕业设计雏形的学生信息管理系统。最后再往底层挖一层看看Tomcat把JSP编译成Java类之后到底长什么样然后补一个老技术接新接口的实战Servlet调用大模型API。都是实操中能直接参考的东西。1. Maven化改造从手动拷jar包到工程化构建1.1 为什么非要用Maven刚开始学Servlet的时候大部分人的做法是新建一个Dynamic Web Project然后在WEB-INF/lib目录下手动粘贴servlet-api.jar或者干脆依赖IDE自带的运行时。这个阶段写点小Demo没问题但一旦项目涉及数据库驱动、JSON处理库、日志框架手动管理jar包就会变成一场灾难版本对不上、jar包缺失、两台开发机上lib目录内容不一致最经典的翻车现场是代码在同事电脑上编译通过到自己这里爆一堆ClassNotFoundException。Maven解决的就是这件事。它通过pom.xml声明依赖自动下载、统一管理版本而且构建流程标准化。用Maven之后不再需要关心lib目录因为打包时Maven会根据依赖配置自动生成最终的WAR结构。这个改造对Servlet和JSP学习来说意义很大——你不用把精力花在拷对jar包上而是把注意力集中在写代码本身。1.2 eclipse里一步步创建Maven Web项目在eclipse里创建一个基于Maven的Servlet项目完整路线是这样File → New → Other搜索Maven选择Maven Project。弹窗里可以选择archetype。新手最容易踩坑的是选了maven-archetype-webapp之后发现项目结构不全——它只生成src/main/webapp和index.jsp没有src/main/java、src/main/resources这些目录。选择Create a simple project跳过archetype模板然后在Packaging里明确选择war结构反而更干净。填坐标GroupId填写公司域名反写比如com.exampleArtifactId填写项目名比如student-infoVersion默认1.0-SNAPSHOT即可。Packaging务必选择war因为Servlet和JSP项目要部署到Tomcat最终产物必须是Web归档格式。如果不小心选成了jar后面做Web配置时会遇到一堆麻烦。创建完成后手动补上src/main/java目录右键项目 → New → Source Folder输入src/main/java和src/main/resources。这是一个非常容易被忽略的步骤缺了它后面新建Servlet类时会发现New菜单里根本没有Servlet选项。打开pom.xml添加Servlet API依赖。这是整个流程里最关键的一步dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency右键项目 → Maven → Update Project让依赖生效。看到Maven Dependencies里出现servlet-api项目就算搭建完毕。有几个细节必须说清楚scope为什么是provided。Servlet API本身由Tomcat提供部署时Tomcat自带的lib里已经有一份完整实现。如果Maven把它一起打进WAR运行时就会因为类重复引发各种稀奇古怪的问题。provided的含义是编译时需要发布时不打包等于把这个依赖留给了运行时容器。版本号里的javax和jakarta之分。Tomcat 9及之前的版本对应javax命名空间Tomcat 10之后全部迁移到jakarta命名空间。如果你用的是Tomcat 10需要把groupId改成jakarta.servletartifactId改成jakarta.servlet-api。这个坑几乎每天都有新人踩IDEA里编译通过一部署到Tomcat就报NoClassDefFoundError或者ClassNotFoundException根源多半是API命名空间和容器版本对不上。提示判断当前Tomcat所支持API的最快方式是到Tomcat安装目录的lib文件夹下看jar包名称。名字是servlet-api.jar就是javax名字是jakarta.servlet-api-x.x.jar就是jakarta。1.3 部署到Tomcat的细节Maven Web项目创建完成后部署到Tomcat有两种方式。一种是直接在Servers面板里Add and Remove把项目挂到本地Tomcat实例上然后Run on Server。另一种是执行mvn package生成WAR文件把WAR丢到Tomcat的webapps目录下。学习阶段建议用第一种调试方便改代码后热重载很快。第一次Run on Server容易遇到404。排查思路很简单确认部署上下文路径。右键项目 → Properties → Web Project Settings可以看到Context root。比如Context root为/student-info那么访问首页URL就是http://localhost:8080/student-info/。很多人访问8080端口时报404就是因为忘了带项目上下文路径。这一阶段我自己的体会是项目搭建这件事别嫌烦。很多人在Servlet上面卡住不是因为概念难而是因为环境的坑一个接一个导致还没开始写代码就放弃了。Maven搭配Tomcat这套组合值得在前期花时间跑通跑通一次之后后面几乎所有的练习项目都可以复制这套模板。2. JSP的IDE日常创建、识别、编码与放对位置2.1 IDEA里创建JSP文件的正确打开方式三篇笔记写下来环境相关的坑真的占了很大比例。IDEA里创建JSP文件这个操作看起来简单实际上有不少条件约束。前提是项目具备了Web模块支持。如果新建的是一个普通Java项目右键New菜单里是找不到JSP File选项的。解决办法是先给项目添加Web FacetFile → Project Structure → Facets点击号选择Web。在Web模块配置里指定Web Resource Directory也就是webapp目录路径写成src/main/webapp。配置Deployment Descriptors里的web.xml路径如果是新项目可以用默认位置之后通过AltEnter在项目中自动创建web.xml。完成这个步骤后在src/main/webapp目录上右键New里就会出现JSP/JSPX选项。输入文件名IDEA会自动生成一个带page指令的index.jsp骨架。还有一个细节IDEA对JSP文件类型有严格的识别。如果项目很特殊.jsp后缀没有被关联到JSP文件类型新建出来的文件会被当成纯文本处理。这种情况去Settings → Editor → File Types里检查一下在Recognized File Types列表中找到JSP确认注册的Patterns里包含.jsp。2.2 Color Scheme里找不到JSP是怎么回事这个问题很有意思intellij idea中color scheme中没有jsp——很多人想在Settings → Editor → Color Scheme里给JSP单独调整配色翻遍列表也找不到JSP这一项。这不是IDEA出问题而是它的设计逻辑决定的JSP在国内通常是指HTMLJava混合页面IDEA在语法高亮层面把它的大部分内容直接复用了HTML的配色方案只有JSP标签部分才使用独立的JSP配色定义。所以如果你想在Color Scheme里调节JSP页面的字体颜色最直接的办法是找到并修改HTML那一组配色改完之后打开JSP文件HTML部分的文本颜色会跟着变。JSP专属的几个配置项则分散在Color Scheme下的Other或JSP条目中不同版本位置不同。如果实在找不到还有一个兜底办法打开任意一个JSP文件然后进入Settings → Editor → Color Scheme这时候右上角会有一个下拉框默认选项是Current file它会把当前文件涉及的所有语言配色条目全都列出来里面一定能看到JSP相关配置。2.3 JSP该放在哪个文件夹以及WEB-INF的特殊性jsp选择文件夹这个问题我理解大部分新人真正想问的是JSP文件到底该放到项目里的哪个目录下。答案是统一的在标准Maven Web项目里JSP文件必须放在src/main/webapp目录下。这个目录很特殊它对应Web应用在容器里的根路径。部署后webapp下的文件结构会直接映射到URL。比如webapp下面有个index.jsp访问URL就是/项目名/index.jsp。放错了位置比如放到了src/main/java里JSP就不会被容器识别访问时直接404这是很多新人在Maven项目里反复碰壁的原因。顺带说一个反直觉但很重要的知识点WEB-INF目录下的JSP不能通过URL直接访问但可以通过Servlet转发的方式渲染输出。很多管理系统为了保证页面安全会把JSP放在WEB-INF/jsp目录下用户任何请求都先到达ServletServlet做权限校验后通过RequestDispatcher转发到具体JSP。这样做的好处是用户永远无法绕过Servlet直接打开某个页面相当于给页面加了一层访问控制。// 转发到WEB-INF下JSP的标准写法 request.getRequestDispatcher(/WEB-INF/jsp/user_list.jsp) .forward(request, response);直接访问http://localhost:8080/项目名/WEB-INF/jsp/user_list.jspTomcat会返回404而不是把页面内容暴露出来。这个机制在后面的学生信息管理系统里非常实用。2.4 中文乱码三处编码一处都不能少JSP中文乱码问题是每一个用这套技术栈的人必经的坎。乱码问题可以拆成三个环节三处编码要同时设置对第一处是页面文件本身的编码。在JSP的page指令里设置pageEncodingUTF-8并且保证文件物理存储也是UTF-8。IDEA可以全局设置File Encodings为UTF-8避免文件以GBK保存。% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 %第二处是响应编码。上面contentType里的charsetUTF-8其实已经决定了返回给浏览器的编码页面文件是UTF-8响应头也标明UTF-8浏览器才会正确渲染。Tomcat 9及以前部分版本还可能出现response.getWriter()默认使用ISO-8859-1的情况所以在Servlet里也可以显式设置response.setContentType(text/html; charsetUTF-8); response.setCharacterEncoding(UTF-8);第三处是请求参数编码。这个最容易漏。POST表单提交过来的中文在Servlet里如果没有调用request.setCharacterEncoding(UTF-8)容器默认按ISO-8859-1去解码请求体拿到手就是乱码。而且这个方法必须在读取任何参数之前调用否则不生效。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String name request.getParameter(name); // ... }这三处缺一不可。我在初学时只设置了第一处结果页面正常而数据库里的中文乱码折腾了很久才发现是请求编码没设置。排查乱码的方向记住一句话数据从浏览器到Java代码乱码是请求编码问题数据从Java代码到浏览器乱码是响应编码问题文件里本身就乱码是存储编码问题。3. 个人信息展示页第一个真正意义的Servlet JSP联动3.1 页面和数据的完整流转链路把Servlet和JSP组装到一起做的第一个典型功能就是用户注册后展示个人信息。这个功能麻雀虽小五脏俱全完整覆盖了Web开发最核心的数据流转链路。我当时的实现场景是用户在register.jsp页面填写姓名、年龄、邮箱点击提交Servlet接收参数并封装成一个User对象再转发到profile.jsp页面把个人信息回显出来。整个流程可以拆成四步用户在浏览器填写表单POST请求发送到Servlet。Servlet调用request.getParameter()逐一取出表单字段创建User对象并填充数据。Servlet执行request.setAttribute(user, user)把对象放进request作用域。Servlet通过request.getRequestDispatcher(/profile.jsp).forward(request, response)执行转发JSP页面从request作用域取出对象并渲染。这里有一个关键认知Servlet负责接收请求和处理逻辑JSP负责取数据并渲染页面两者各有分工。这其实就是MVC思想的最简形态Servlet是控制器JSP是视图User对象是模型。3.2 EL表达式取值省掉的getter背后是什么到了profile.jsp页面最舒服的写法是用EL表达式% page contentTypetext/html;charsetUTF-8 languagejava % html head title个人信息/title /head body h2个人信息/h2 p姓名${user.name}/p p年龄${user.age}/p p邮箱${user.email}/p /body /html${user.name}这套语法很多人一开始不理解它怎么能直接取到对象的属性。原理其实不复杂EL表达式在运行时会调用User类里的getName()方法它遵循JavaBean规范。换句话说如果User类只有私有属性name而没有对应的getName()EL表达式就会失败。所以实体类的命名必须严格遵循规范属性名name对应getName()/setName()属性名age对应getAge()/setAge()。3.3 常见的取值报错与IDE提示处理用EL表达式时有两个常见问题值得提一下。第一个问题IDEA在${user.name}下面画红色波浪线提示Cannot resolve property。这是IDE无法推断request作用域里user这个属性的具体类型导致的因为request.setAttribute(user, user)传的是一个ObjectJSP页面无法静态感知它的真实类型。这个问题不影响运行但很多人会被红波浪线搞得不自信。解决办法有两个在JSP页面头部用 jsp:useBean 声明类型jsp:useBean iduser scoperequest classcom.example.entity.User /IDEA就能自动识别user的类型红色波浪线消失。第二个更彻底的办法是不管它只要运行结果正确就行红波浪线只是IDE静态分析的局限。第二个问题浏览器里EL表达式被原样输出${user.name}用大括号原样显示在页面上。这通常意味着JSP页面没有被容器当作JSP处理。常见原因包括文件后缀不是.jsp但被当成静态资源访问或者web.xml里的Servlet映射把URL匹配规则搞错了。按JSP本质是Servlet的思路去排查基本都能找到原因。3.4 request、session、application选哪个在一个数据展示页面里数据放哪个作用域直接决定数据在什么范围内可见。这个选择是判断一个Servlet开发者基本功是否扎实的重要标准。request作用域一次请求内有效Servlet转发到JSP后可以取到但如果浏览器刷新或重定向request里的数据就丢了。适合放本次操作产生的临时数据比如查询结果、表单回显数据、操作成功提示。session作用域一次会话内有效从登录到退出一直存在适合放登录用户信息、购物车信息、权限标识。application作用域整个应用存活期间有效所有用户共享适合放全局配置、网站访问计数、全局缓存。pageContext只在当前JSP页面内有效基本不用来存放业务数据。个人信息展示场景用request就对了。因为数据从Servlet产生同一次请求内转发给JSP渲染一次用完即弃。如果把user放进session虽然也能显示但会带来两个问题数据在会话期间一直占着内存用户点击刷新后页面显示的仍然是旧数据不符合预期。表单回显是这个场景下的延伸需求。编辑个人信息时需要把当前用户的数据预先填进输入框写法也很简单input typetext namename value${user.name} /这样页面打开时输入框里自动带上数据库里已有的数据。注意value里的引号必须用双引号因为EL表达式里可能包含特殊字符如果用单引号容易出问题。4. 学生信息管理系统把散装知识点组装成第一个综合项目4.1 最小功能清单与分层设计学完Servlet和JSP基础之后很多人会卡在同一个地方知识点都学过了但不知道它们怎么配合成一个完整项目。学生信息管理系统就是用来解决这个问题的。这个项目极其经典网上随便一搜有一堆教程但绝大多数教程只给代码不给思路导致很多人做完一遍还是云里雾里。这里我把设计过程拆开讲。一个最小可用的学生信息管理系统功能清单控制在四个学生列表展示带分页、新增学生、编辑学生、删除学生。如果再加一个按姓名模糊查询就非常完整了。再多的功能对一个练习项目都是负担贪多只会分散注意力。项目结构上一开始就要有分层的意识哪怕代码量不大。我建议至少拆出三层entity层Student实体类字段对应数据库表。dao层StudentDAO负责所有JDBC数据库操作。servlet层StudentServlet作为控制器接收请求、调用DAO、转发JSP。jsp层页面student_list.jsp、student_add.jsp、student_edit.jsp。分层最大的好处是改代码时不用全局翻找。比如把数据库从MySQL换成Oracle只需要改DAO层页面样式调整只需要改JSP加一个新功能新增Servlet方法即可。如果把所有逻辑都糊在JSP里项目一变大就是灾难这也是JSP最容易写坏的姿势。4.2 StudentDAO里的关键细节DAO层是这套系统的技术核心。它调用JDBC操作数据库有几个细节是平时练习中最容易写出问题的。PreparedStatement替代Statement。初学者很容易写成这样String sql SELECT * FROM student WHERE name keyword ;这是标准SQL注入写法只是学习阶段没人攻击你而已。正确写法是用占位符String sql SELECT * FROM student WHERE name LIKE ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, % keyword %); ResultSet rs ps.executeQuery();PreparedStatement不仅防SQL注入还有预编译性能优势更重要的是它让SQL和参数彻底分离代码可读性高了很多。连接关闭的顺序。传说中用完的数据库连接不关闭问题在管理系统里会直接表现为运行几天后报too many connections。每次操作都必须按ResultSet → PreparedStatement → Connection的顺序依次关闭并且放在finally块里保证一定执行。建议封装一个DBUtil工具类统一处理连接获取和关闭否则每个DAO方法写一遍驱动加载和连接创建代码冗余还很痛苦。分页查询是另一个要点。MySQL的分页SQL是LIMIT offset, size但有个细节必须注意前端传来的pageNo是从1开始算的而LIMIT的offset是从0开始算的。所以查询前要计算int offset (pageNo - 1) * pageSize; String sql SELECT * FROM student LIMIT ?, ?;同时还要查一次总数算出总页数int totalPages (totalCount pageSize - 1) / pageSize;这条公式很关键。取整除法是向下取整所以必须加上pageSize - 1才能保证总页数是向上取整。比如总记录11条每页10条totalPages (11 9) / 10 2恰好是2页。如果直接写totalCount / pageSize得到1最后一页就漏了。4.3 分页和条件查询的坑这个项目里最典型的逻辑坑是分页和条件查询同时存在时条件丢失。场景是这样的用户搜索关键字张点击第2页结果发现第2页返回的是全量数据的第2页搜索条件没了。原因是分页导航条上的链接只带了pageNo参数没带keyword参数。解决办法是在生成分页链接时把查询条件一起拼上a hrefstudent?actionlistpageNo${currentPage 1}keyword${keyword}下一页/a同时Servlet里取参数时要约定好默认值int pageNo 1; String pageNoStr request.getParameter(pageNo); if (pageNoStr ! null !pageNoStr.isEmpty()) { pageNo Integer.parseInt(pageNoStr); } String keyword request.getParameter(keyword); if (keyword null) { keyword ; }这个坑看起来小但几乎每个第一次做分页的人都会踩。踩过一次之后你会养成一个习惯任何分页链接触发时都要把当前页面的所有查询条件全部带上。4.4 这个项目为什么到现在还值得做有人可能会问现在前端框架都这么发达了为什么还要做JSP版的学生信息管理系统我的看法是它的价值不在于技术多先进而在于它用最直接的方式把Web开发的主链路完整呈现了一遍——浏览器发请求、Servlet接请求、DAO查数据库、数据封装进实体、转发给JSP渲染、浏览器展示。这个链路上每一个环节在任何一个现代Web框架里都依然存在只是换了形式。把这条链路吃透后面学Spring MVC、学MyBatis、学Vue前后端分离都只是换壳不换核。此外这个项目还能让你练到编码、分页、模糊查询、表单数据绑定这些在任何项目里都绕不开的基本功。5. JSP编译后的Java类翻开Tomcat的work目录看看5.1 如何找到编译产物web项目配置tomcat后查看jsp编译后的java类——这个说法其实已经是开了窍的人才会问的问题。很多学了几个月JSP的人都没意识到JSP并不是直接运行的文件它本质上是Servlet会被容器先翻译成Java源文件再编译成class文件最后作为Servlet实例运行。想亲眼看看这个过程只需要在Tomcat的work目录里翻一下。路径一般是Tomcat安装目录/work/Catalina/localhost/项目名/org/apache/jsp/。在这个目录下你会看到一堆以_jsp.java和_jsp.class结尾的文件文件名对应JSP文件名。比如login.jsp对应的翻译结果就是login_jsp.java。如果用eclipse或IDEA内嵌的Tomcat运行项目work目录同样存在只是位置可能在IDE配置的Tomcat副本下。搜索work/Catalina很快能找到。5.2 login.jsp的Java类长什么样打开login_jsp.java你会发现一个关键事实JSP翻译后的类继承自org.apache.jasper.runtime.HttpJspBase而HttpJspBase的父类正是javax.servlet.http.HttpServlet。也就是说每一个JSP文件最终都是一个标准的Servlet类。翻译后的类结构大体是这样public final class login_jsp extends org.apache.jasper.runtime.HttpJspBase implements org.apache.jasper.runtime.JspSourceDependent { public void _jspInit() { } public void _jspDestroy() { } public void _jspService(final HttpServletRequest request, final HttpServletResponse response) throws java.io.IOException, ServletException { // 以下代码是容器自动生成的初始化逻辑 final PageContext pageContext; HttpSession session null; final ServletContext application; final ServletConfig config; JspWriter out null; final Object page this; response.setContentType(text/html; charsetUTF-8); pageContext _jspxFactory.getPageContext(this, request, response, null, true, 8192, true); application pageContext.getServletContext(); config pageContext.getServletConfig(); session pageContext.getSession(); out pageContext.getOut(); // JSP页面里的静态内容被容器翻译成out.write out.write(html); out.write(headtitle登录/title/head); out.write(body); // JSP里插入的Scriptlet代码原样搬进_jspService方法 String message 请输入用户名和密码; out.write(p); out.print(message); out.write(/p); out.write(/body); out.write(/html); } }这里面信息量很大值得逐个拆解。第一JSP页面的所有静态内容也就是HTML部分全部被封装进out.write()调用。JSP的本质就是在输出流上拼接字符串区别只在于拼接的过程由容器代劳了。第二JSP里用% %包裹的Java代码也就是Scriptlet会被原样搬进_jspService方法内部。所以Scriptlet里声明的变量都是_jspService方法的局部变量每次请求都会重新创建。第三JSP页面里能直接用request、response、session、out、application这些内置对象是因为容器在_jspService开头就帮我们完成了这些变量的初始化。这些不是魔法只是省略了声明代码而已。5.3 由此得出的几个重要推论看懂了编译后的Java类很多以前觉得玄乎的知识点一下就通了。为什么修改JSP文件后不需要重启Tomcat因为容器每次处理请求时都会检查JSP源文件的时间戳一旦发现文件更新就会重新翻译、重新编译下次请求使用新的_class文件。这也是为什么在开发阶段频繁修改JSP页面刷新即可生效不需要重启。注意这个机制只对JSP有效对Java类修改并利用热部署时很多版本需要重启才能生效。为什么%! %声明的变量跨请求共用而且有线程安全问题看编译后的类就知道%! %声明的内容被翻译成类的成员变量成员变量存在于对象实例中多个线程共享同一个Servlet实例自然存在并发冲突。而% %声明的内容是_jspService方法内局部变量每个线程栈上各有一份天然隔离。这个知识点在任何Servlet和JSP面试里几乎必考。为什么在JSP里写System.out.println()看不到输出因为System.out输出到了Tomcat进程的标准输出流也就是Tomcat的catalina.out日志或控制台而不是JSP页面更不是浏览器。如果想把信息输出到浏览器应该用out.print()。为什么JSP里不能直接声明一个带main方法的方法因为_jspService内部是方法体不能在里面再定义方法。想在JSP里声明方法必须用%! %声明否则直接语法错误。这个章节是整个JSP学习曲线里最值得花时间的内容。很多人在JSP上学的玄学在看过编译产物之后都变成了常识。6. 页面离开提示的屏蔽是谁在拦截你的浏览器6.1 弹窗的根源屏蔽jsp离开页面提示看这个关键词就知道一定有人在使用某个管理系统或者自己开发页面时被浏览器那个离开此页面系统可能不会保存你所做的更改的原生弹窗搞到崩溃。先说结论默认情况下JSP页面向浏览器输出HTML后浏览器不会主动弹出任何离开确认提示。这个弹窗的唯一触发条件是页面注册了beforeunload事件。所以问题不是怎么屏蔽这个弹窗而是找到谁注册了这个监听事件。在实际项目中触发来源通常是这两类一类是你自己或同事在页面里主动写了类似下面的代码window.onbeforeunload function() { return 确定要离开吗; };另一类是第三方组件自动注入的。比如引入了富文本编辑器、表单自动保存插件、某些UI框架的页面状态保护模块它们会在脚本加载时自动给window注册beforeunload事件。这是最恶心的情况因为代码不是自己写的翻遍业务代码都找不到只能通过浏览器调试工具定位。6.2 定位绑定来源的三种手段定位问题有三种很实用的手段。第一种在浏览器控制台执行window.onbeforeunload直接查看当前是否已经绑定了处理函数。如果返回null说明没人主动绑定如果返回一个函数说明某个脚本设置过。由于onbeforeunload只能存在一个处理函数这个检查简单有效但cover不了addEventListener方式注册的监听。第二种用开发者工具定位事件绑定来源。打开DevTools的Sources面板右侧找到Event Listener Breakpoints展开Control类别勾选beforeunload。然后刷新页面并关闭当前标签页代码执行会在注册监听的那一刻中断调用栈里能清楚看到是哪一行脚本注册的。这是排查第三方库注入事件的最强工具。第三种如果项目用了jQuery检查是不是jQuery回调函数参与了绑定$(window).off(beforeunload);注意removeEventListener需要拿到原函数引用如果不知道原函数是什么off()比removeEventListener省事得多。6.3 屏蔽与保留的平衡写法排查到来源之后屏蔽方法就很直接了。在JSP页面的底部追加一小段script // 清除可能导致弹窗的事件 window.onbeforeunload null; /script如果是用addEventListener绑定的事件且你无法拿到原函数引用可以尝试清空所有监听但现代浏览器没有直接清空全部事件的标准API最稳妥的方式还是从调用栈找到源头代码再处理。不过这里我更想多说一句屏蔽弹窗之前先想清楚这个提示到底有没有它的作用。在后台管理系统里表单有未保存内容时提醒用户是一个合理需求。如果你是在做这个功能不应该屏蔽它而应该用正确的方式实现它。标准写法是这样let dirty false; // 表单内容变更时置为true $(#form).on(input change, function() { dirty true; }); window.onbeforeunload function(e) { if (dirty) { e.preventDefault(); e.returnValue ; } }; // 表单保存成功后置为false function saveSuccess() { dirty false; }这个写法和直接绑定onbeforeunload的区别在于只有dirty为true时才拦截保存成功后dirty变为false用户刷新或关闭页面不会再有提示。如果你面临的问题是每次保存成功之后刷新还是弹提示多半就是忘记加dirty这个开关。还有一个初学者常犯的错误试图在beforeunload里弹一个自定义的确认框alert/confirm再根据用户选择决定是否放行。现代浏览器直接禁止了这个行为beforeunload里只能显示浏览器默认的文案任何自定义弹窗API都不会生效。这是浏览器出于安全性考虑做的限制想绕过的思路从根上就不成立。7. 当Servlet调用大模型API一个另类但很有价值的实战7.1 为什么必须在后端接这本笔记写到这里突然出现Servlet调用大模型API接口这个热搜词一开始我愣了一下后来一想这其实是很多人学习时都有的念头学了一套技术总想把它跟当下最热门的东西结合一下。用Servlet这个老牌技术去调大模型API听起来复古可信度反而出奇地高。正经做这个功能时第一步要解决的问题是为什么不能让前端直接调大模型API答案不是技术不允许而是三个方面都不允许第一是安全。大模型的API Key一旦暴露在浏览器源码或网络请求里等于把钥匙交了出去。任何用户都能从DevTools里看到请求可以直接盗用Key去调用API产生费用由你承担。把调用放在后端前端永远接触不到API Key密钥只存在于服务端环境变量或配置文件中。第二是跨域。大多数大模型厂商的API并不允许浏览器跨域直接调用浏览器的CORS机制会拦截这种请求。如果不想为每一个来源配置白名单后端代理是最省事的方案。第三是请求封装与后续处理。大模型调用往往需要附带历史上下文、系统提示词、防泄露过滤等参数这些逻辑放在后端更灵活也方便统一记录日志和做限流。7.2 手写一个最简转发Servlet有了后端代理的思路实现就非常清晰了。前端页面发送一个表单或Ajax请求到自己的ServletServlet负责调用第三方大模型HTTP接口拿到响应后把结果放进request或直接写回浏览器。我用JDK自带的HttpURLConnection写了一个最简版本不引入额外HTTP客户端依赖方便你直接跑通流程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.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; WebServlet(/chat) public class ChatServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String question request.getParameter(question); // API Key从环境变量读取绝不能硬编码在源码里 String apiKey System.getenv(LLM_API_KEY); String apiUrl https://api.example.com/v1/chat/completions; // 用JSON库构建请求体这里以fastjson为例 String requestBody buildRequestBody(question); // 建立连接 HttpURLConnection conn (HttpURLConnection) new URL(apiUrl).openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json; charsetUTF-8); conn.setRequestProperty(Authorization, Bearer apiKey); conn.setConnectTimeout(10000); conn.setReadTimeout(30000); conn.setDoOutput(true); // 发送请求体 try (OutputStream os conn.getOutputStream()) { os.write(requestBody.getBytes(UTF-8)); } // 读取响应 int code conn.getResponseCode(); if (code 200) { BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), UTF-8)); StringBuilder result new StringBuilder(); String line; while ((line reader.readLine()) ! null) { result.append(line); } reader.close(); // 解析大模型返回的JSON提取回答文本 String answer parseAnswer(result.toString()); // 把结果转发给JSP展示 request.setAttribute(answer, answer); request.getRequestDispatcher(/chat.jsp).forward(request, response); } else { response.setStatus(code); response.getWriter().write(调用大模型接口失败HTTP状态码 code); } conn.disconnect(); } private String buildRequestBody(String question) { // 注意对特殊字符做JSON转义最好用JSON库的toJSONString方法 return {\model\:\your-model-name\, \messages\:[{\role\:\user\,\content\:\ question \}]}; } private String parseAnswer(String json) { // 示例解析逻辑按response JSON结构定位content字段 // 推荐使用fastjson或Jackson完成 return json; // 简化版实际需要解析JSON } }前端chat.jsp只需一个输入框和一个提交按钮提交后把answer渲染到页面% page contentTypetext/html;charsetUTF-8 languagejava % html head title智能助手/title /head body h2输入你的问题/h2 form methodpost action${pageContext.request.contextPath}/chat textarea namequestion rows4 cols50 placeholder请输入问题/textarea br/ button typesubmit发送/button /form c:if test${not empty answer} h3回答/h3 p${answer}/p /c:if /body /html一个最简单的JSP页面 Servlet后端代理 大模型API链路就通了。7.3 接入过程中的几个常见坑第一请求体里的JSON字符串千万别用字符串拼接直接插入用户输入。用户输入的内容里如果包含双引号、反斜杠拼出来的JSON就是非法的后端解析直接报错。正确做法是用JSON库的JSONObject一次性构建JSONObject message new JSONObject(); message.put(role, user); message.put(content, question); JSONObject msgObj new JSONObject(); msgObj.put(model, your-model-name); msgObj.put(messages, new JSONObject[]{message}); String requestBody msgObj.toJSONString();第二接口超时很常见。大模型生成内容本来就是耗时操作读超时设置为30秒甚至更长是合理的否则一次稍长的生成就会触发SocketTimeoutException。同时生产环境中建议用线程池异步处理避免长时间占用Tomcat的工作线程否则一旦请求量大整个Web应用都会被拖垮。第三密钥管理是最容易出问题的地方。硬编码的API Key会直接进入Git提交历史哪怕之后删除历史记录里也永远抹不掉。正确做法是用环境变量或者部署目录外的配置文件读取同时确保把配置文件名加进.gitignore。自己练手的时候虽然没人偷你的Key但养成好习惯的价值在工作中会直接体现出来。第四大模型API返回的内容里可能包含换行、Markdown标记等特殊字符在JSP页面里用${answer}原样输出时浏览器不会把换行渲染成HTML的。如果希望看到排版效果需要在前端做一次换行替换或者用专门的Markdown渲染库处理。第五如果想做成更好的交互效果可以把JSP页面的表单提交改成Ajax后端响应改为直接写回JSON字符串前端用JavaScript渲染结果。这样用户点击发送后页面不会整页刷新体验会好很多。这个实战最大的意义在于它证明了Servlet和JSP这套经典技术栈完全可以接入现代互联网服务。老技术不能接新服务是一种错觉Servlet本质上就是一个能处理HTTP请求的服务端程序任何基于HTTP的第三方接口都能通过它来对接大模型API也不例外。搞明白了这一点就不需要担心学了一套过时技术——真正过时的从来不是技术而是只会用一个技术栈、不懂底层原理的思维方式。我自己写完这个demo之后最深的体会是Servlet的请求处理模型虽然原始但恰恰因为原始反而能更清楚地看到一次HTTP交互的完整面貌。写Spring的时候很多细节被框架隐藏了而你如果只用Servlet写过一遍底层代理后面用任何框架都觉得通透很多。