写在前面
如果你正在学习Java Web开发,面对Servlet、JSP、Filter、Session这些概念感到眼花缭乱,不知道它们之间有什么关系,也不知道学了能做什么------那么这篇文章就是为你准备的。
本文采用「问题驱动」的方式,带你重新理解Java Web开发的每一个核心知识点。我们不堆砌概念,而是先搞清楚每个技术「解决了什么痛点」,再学习「怎么用」,最后落地到「企业如何实战」。
让我们开始这段Java Web探索之旅。
阶段一:问题锚定------我们为什么要学Java Web?
场景化抛出问题
想象这样一个场景:你要开发一个用户登录功能。用户在浏览器输入用户名密码,点击登录,系统验证身份后,跳转到欢迎页面。
看起来很简单,对吧?但仔细想想,这里面有一堆问题需要解决:
-
用户输入的数据怎么传给服务器? 用户在表单里填的信息,浏览器怎么包装?服务器怎么解析?
-
服务器怎么处理登录请求? 谁来接收HTTP请求?谁来调用业务逻辑?
-
登录成功后,怎么记住用户? HTTP协议是无状态的,第二次请求时服务器怎么知道「这个用户已经登录过」?
-
页面怎么动态显示用户信息? 不可能给每个用户都写一个静态HTML页面吧?
-
所有请求都要做权限校验,难道每个页面都写一遍验证代码?
-
项目启动时要加载一些初始化数据,写在哪儿?
这些问题,就是Java Web要解决的核心问题。
技术定位梳理
Java Web,简单说就是用Java语言开发网站应用的一套技术体系。它的核心价值在于:
-
接收并处理HTTP请求(用户访问网站,服务器得知道他要干什么)
-
动态生成响应内容(不同用户看到不同的页面)
-
管理用户会话状态(记住谁登录了、购物车里有什么)
-
提供标准化的开发组件(让开发者专注于业务逻辑,不用重复造轮子)
技术边界说明
Java Web能做什么?几乎所有你能想到的网站后端功能:用户登录注册、商品展示、订单处理、数据管理......
但Java Web不是万能的:
-
它不负责页面好不好看(那是CSS的事)
-
它不处理用户交互特效(那是JavaScript的事)
-
它不适合做实时性极高的游戏服务器(那需要Netty等NIO框架)
清楚了这些问题,我们再来看具体的技术组件,就会有「原来它是为解决这个问题而生的」的恍然大悟感。
阶段二:基础认知------构建最小知识体系
在深入每个技术之前,我们需要先建立一个「最小化」的知识框架,理解这些概念到底是干什么的。
一丢丢前端基础:用户看到的和用户操作的
核心概念拆解
前端就是你打开一个网站看到的页面:文字、图片、按钮、输入框。用户在前端填写表单、点击按钮,这些操作会产生HTTP请求,发送到后端服务器。
最简单的HTML表单长这样:
html
<form action="login" method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<input type="submit" value="登录">
</form>
用户点击登录后,浏览器会把用户名和密码打包,发送到login这个地址。
为什么要懂一点前端?
因为Java Web开发中,你既要处理后端逻辑,也要写前端页面(JSP本质上就是HTML+Java代码)。理解请求怎么发出去的,才能知道后端怎么接。
XML:配置文件的「老祖宗」
问题锚定:程序的某些参数(比如数据库连接地址、端口号)如果硬编码在代码里,改起来要重新编译,太麻烦了。能不能把这些「可变的部分」抽出来单独管理?
核心概念:XML(可扩展标记语言)就是一种用来存储结构化数据的文本格式。它用标签来定义数据,长得像HTML,但标签不是预定义的,你可以自己发明。
<user> <name>张三</name> <age>25</age> </user>
最小示例 :
以前Java Web项目的配置文件web.xml长这样:
<web-app> <servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.example.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> </web-app>
核心原理:XML通过标签嵌套表达层级关系,通过属性添加元信息。程序可以解析XML文件,读取里面的配置项,实现「配置分离」。
现在还用吗? 现在更流行用properties或YAML格式,因为更简洁。但XML依然广泛存在于老项目和一些中间件配置中,理解它对维护老系统很有帮助。
JSON:前后端通信的「普通话」
问题锚定:前端和后端要交换数据,怎么格式化这些数据?XML太啰嗦了,能不能有一种更轻量、更易读的格式?
核心概念:JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。它长得特别像JavaScript里的对象,但语言无关,几乎所有编程语言都支持。
json
{ "name": "张三", "age": 25, "hobbies": ["读书", "编程"] }
对比XML:
XML:
<user><name>张三</name><age>25</age></user>JSON:
{"name":"张三","age":25}
JSON明显更简洁,而且解析速度更快。现在的Web开发中,JSON已经是前后端通信的事实标准。
最小示例 :
前端发一个JSON格式的POST请求:
javascript
fetch('/api/user', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: '张三', age: 25})
})
后端收到后,可以方便地解析成Java对象。
Servlet:Java Web的「发动机」
问题锚定:用户发了HTTP请求过来,总得有段代码来处理吧?谁来接收请求参数?谁来调用业务方法?谁来返回响应页面?
核心概念:Servlet就是Java Web中专门处理HTTP请求的Java程序。它运行在Web服务器(如Tomcat)里面,负责:
-
接收HTTP请求
-
解析请求参数
-
调用业务逻辑(查询数据库、计算等)
-
生成响应(HTML页面、JSON数据等)
最小示例 :
创建一个最简单的Servlet:
java
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<html><body>");
out.println("<h1>Hello, Java Web!</h1>");
out.println("</body></html>");
}
}
在web.xml中配置访问路径:
XML
<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.example.HelloServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>HelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
启动Tomcat,访问http://localhost:8080/你的应用/hello,就能看到页面了。
核心原理:Servlet的生命周期由容器(Tomcat)管理:
-
初始化 :第一次访问或启动时,调用
init()方法 -
服务 :每次请求,调用
service()方法(根据请求类型分发给doGet()或doPost()) -
销毁 :应用卸载时,调用
destroy()方法
JSP:在HTML里写Java的「便利贴」
问题锚定 :用Servlet输出HTML太痛苦了,每一行HTML都要用out.println()包起来,要是页面复杂一点,代码根本没法维护。能不能直接在HTML里嵌入Java代码?
核心概念:JSP(Java Server Pages)本质上就是HTML,但允许在里面插入Java代码片段。它运行时会先被容器翻译成Servlet,再执行。
最小示例 :
创建一个hello.jsp文件:
XML
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
<h1>当前时间:<%= new java.util.Date() %></h1>
<%-- 这是JSP注释 --%>
<%
String name = request.getParameter("name");
if (name != null) {
out.println("你好," + name);
}
%>
</body>
</html>
访问http://localhost:8080/你的应用/hello.jsp?name=张三,页面会显示当前时间和欢迎语。
核心原理:JSP的工作原理是「先翻译后编译」:
-
客户端请求JSP页面
-
JSP容器将JSP文件翻译成Servlet源码(.java文件)
-
编译成字节码(.class文件)
-
执行这个Servlet,输出HTML到浏览器
JSTL:让JSP页面更干净的「标签库」
问题锚定:JSP虽然方便,但如果在页面里写大量Java代码(<% ... %>),页面会变得混乱难维护。能不能用类似HTML标签的方式来写逻辑?
核心概念:JSTL(JSP标准标签库)就是一套预定义的标签,用来替代JSP中的Java代码。比如循环、判断、格式化等。
最小示例 :
使用JSTL的<c:forEach>遍历列表:
XML
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html>
<body>
<ul>
<c:forEach items="${userList}" var="user">
<li>${user.name} - ${user.age}岁</li>
</c:forEach>
</ul>
</body>
</html>
配合EL表达式(${user.name}),JSP页面可以做到几乎不含Java代码,只关注数据展示。
Filter:请求的「安检门」
问题锚定:网站有很多功能,比如大部分页面都需要检查用户是否登录、记录访问日志、处理中文乱码。难道每个Servlet都写一遍这些代码?
核心概念:Filter(过滤器)就像一道安检门,在请求到达Servlet之前、响应返回客户端之前,可以对请求和响应进行拦截和处理。
最小示例 :
写一个过滤器处理全站中文乱码:
java
public class EncodingFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
chain.doFilter(request, response); // 放行,继续执行后续过滤器或目标Servlet
}
}
核心原理 :多个Filter可以组成过滤器链,按顺序执行。每个Filter决定是继续执行链(chain.doFilter()),还是直接返回响应(拦截)。
Listener:容器的「监控摄像头」
问题锚定:应用启动时想加载一些初始化数据(比如缓存字典表),应用关闭时要释放资源。谁来监听这些「事件」?
核心概念:Listener(监听器)用来监听Web应用中的各种事件:应用启动/关闭、会话创建/销毁、属性添加/移除等。
最小示例 :
监听应用启动,加载配置:
java
public class AppStartupListener implements ServletContextListener {
public void contextInitialized(ServletContextEvent sce) {
System.out.println("应用启动,加载初始化数据...");
// 加载配置文件、初始化数据库连接池等
}
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("应用关闭,释放资源...");
}
}
Cookie:存在浏览器里的「小纸条」
问题锚定:用户登录后,下次再来访问,怎么记住他?HTTP是无状态的,每次请求都是独立的。
核心概念:Cookie是服务器发给浏览器、保存在浏览器上的一小段文本。浏览器下次访问同一网站时,会自动携带这些Cookie。
最小示例 :
在Servlet中设置Cookie:
Cookie cookie = new Cookie("username", "张三"); cookie.setMaxAge(60 * 60 * 24); // 有效期1天 response.addCookie(cookie);
读取Cookie:
Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("username".equals(cookie.getName())) { String username = cookie.getValue(); } } }
Session:存在服务器里的「档案袋」
问题锚定:Cookie存在浏览器,容量有限(4KB左右),而且用户可能禁用Cookie。更严重的是,敏感信息(如用户角色、权限)存在浏览器不安全。怎么办?
核心概念:Session是服务器端为每个用户创建的一个独立存储空间。服务器会给每个Session分配一个唯一的ID(Session ID),通过Cookie(或URL重写)把这个ID传给浏览器。浏览器下次请求时带上这个ID,服务器就知道对应哪个Session了。
最小示例 :
存数据到Session:
HttpSession session = request.getSession(); // 获取或创建Session session.setAttribute("user", userObject); // 存对象 session.setMaxInactiveInterval(30 * 60); // 设置30分钟超时
取数据:
User user = (User) session.getAttribute("user"); if (user != null) { // 用户已登录 }
核心原理:Session的生命周期:
-
创建 :首次调用
request.getSession()时创建 -
维护:每次请求携带Session ID,服务器更新最后访问时间
-
销毁 :超时未访问、主动调用
invalidate()、或应用关闭
阶段三:核心用法拆解------每个技术怎么用
了解了基础概念,我们来深入看看每个技术在企业开发中的常见用法和坑点。
3.1 前端基础:表单提交与请求方式
两种请求方法:
-
GET :参数在URL后面,
?name=value形式。用于获取数据(如查看商品详情),有长度限制,不安全(参数暴露)。 -
POST:参数在请求体里。用于提交数据(如登录、注册),无长度限制,相对安全。
中文乱码问题 :
Tomcat 8之前,GET请求的默认编码是ISO-8859-1,中文会乱码。解决方案:
-
POST乱码:在Servlet开头加
request.setCharacterEncoding("UTF-8"); -
GET乱码:修改Tomcat的
server.xml,添加URIEncoding="UTF-8"
3.2 XML:配置与解析
常见用法:
-
Web应用配置 :
web.xml中配置Servlet、Filter、Listener -
框架配置文件:如MyBatis的Mapper XML文件
-
数据传输:早期WebService使用XML格式
解析方式:
-
DOM:把整个XML加载到内存,形成树,方便操作但耗内存
-
SAX:事件驱动,边读边解析,适合大文件
-
JAXB:Java对象和XML互相转换,最常用
3.3 JSON:序列化与反序列化
企业常用库:
-
Jackson:Spring Boot默认集成,功能强大
-
Fastjson:阿里出品,速度快但存在一些安全漏洞
-
Gson:Google出品,简洁易用
示例(Jackson):
// Java对象转JSON ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(user); // JSON转Java对象 User user = mapper.readValue(json, User.class);
3.4 Servlet:接收请求与返回响应
核心API:
-
HttpServletRequest:获取请求参数、请求头、Session等 -
HttpServletResponse:设置响应状态码、响应头、写入响应内容
常见用法:
// 获取参数
String username = request.getParameter("username");
String[] hobbies = request.getParameterValues("hobby");
// 请求转发(服务器内部跳转)
request.getRequestDispatcher("/success.jsp").forward(request, response);
// 重定向(浏览器重新发起请求)
response.sendRedirect("login.jsp");
// 返回JSON数据
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":200,\"msg\":\"成功\"}");
坑点:
-
请求转发 vs 重定向:转发是服务器内部跳转,URL不变;重定向是浏览器重新请求,URL改变。
-
Servlet线程安全:Servlet默认单例多线程,成员变量要注意线程安全问题。
3.5 JSP:脚本元素与内置对象
JSP组成:
-
指令 :
<%@ page ... %>,设置页面属性 -
声明 :
<%! ... %>,声明成员变量和方法 -
脚本 :
<% ... %>,写Java代码片段 -
表达式 :
<%= ... %>,输出表达式结果 -
注释 :
<%-- ... --%>,JSP注释(客户端看不到)
9大内置对象(直接可用):
-
request:HttpServletRequest -
response:HttpServletResponse -
session:HttpSession -
application:ServletContext(应用上下文) -
out:JspWriter(输出流) -
pageContext:页面上下文 -
config:ServletConfig -
page:当前Servlet实例 -
exception:异常对象(仅错误页面可用)
坑点:
-
避免在JSP中写大量Java代码(难以维护)
-
JSP最终会被编译成Servlet,所以JSP里的变量其实是
_jspService()方法的局部变量
3.6 JSTL+EL:无脚本JSP开发
EL表达式 :${对象.属性},自动调用getter方法。支持算术运算、逻辑运算。
JSTL核心标签:
<%-- 条件判断 --%>
<c:if test="${user != null}">
欢迎,${user.name}
</c:if>
<%-- 多条件选择 --%>
<c:choose>
<c:when test="${score >= 90}">优秀</c:when>
<c:when test="${score >= 60}">及格</c:when>
<c:otherwise>不及格</c:otherwise>
</c:choose>
<%-- 循环 --%>
<c:forEach items="${list}" var="item" varStatus="status">
第${status.count}个:${item.name}
</c:forEach>
<%-- 格式化日期 --%>
<fmt:formatDate value="${date}" pattern="yyyy-MM-dd"/>
3.7 Filter:拦截与控制
配置方式:
- web.xml配置:
<filter>
<filter-name>AuthFilter</filter-name>
<filter-class>com.example.AuthFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>AuthFilter</filter-name>
<url-pattern>/*</url-pattern> <!-- 拦截所有请求 -->
</filter-mapping>
- 注解配置(Servlet 3.0+):
@WebFilter("/*")
public class AuthFilter implements Filter { ... }
常见应用场景:
-
权限控制:检查Session中是否有登录用户,没有则跳转到登录页
-
编码设置:统一设置请求和响应的编码
-
日志记录:记录请求的IP、URL、耗时
-
敏感词过滤:替换响应内容中的敏感词
-
压缩响应:对响应内容进行GZIP压缩
执行顺序 :按filter-mapping的配置顺序(或Filter类名的字母顺序)执行。
3.8 Listener:监听与回调
监听器类型:
-
ServletContextListener:监听应用启动/关闭
-
HttpSessionListener:监听会话创建/销毁(可用于统计在线人数)
-
ServletRequestListener:监听请求到达/结束
-
各种属性监听器:
ServletContextAttributeListener、HttpSessionAttributeListener、ServletRequestAttributeListener
在线人数统计示例:
@WebListener
public class OnlineUserListener implements HttpSessionListener {
private static int onlineCount = 0;
public void sessionCreated(HttpSessionEvent se) {
onlineCount++;
System.out.println("新用户上线,当前在线:" + onlineCount);
}
public void sessionDestroyed(HttpSessionEvent se) {
onlineCount--;
System.out.println("用户下线,当前在线:" + onlineCount);
}
}
3.9 Cookie:客户端存储
重要属性:
-
setMaxAge(int expiry):有效期(秒),负数为浏览器关闭即失效,0为删除Cookie -
setPath(String uri):指定哪些路径携带此Cookie,默认当前路径及其子路径 -
setDomain(String domain):指定哪些域名携带此Cookie -
setHttpOnly(boolean httpOnly):设为true可防止XSS攻击窃取Cookie -
setSecure(boolean secure):设为true只会在HTTPS下发送
坑点:
-
不同浏览器对Cookie数量和大小有限制(一般50个左右,4KB左右)
-
Cookie中不能存中文(需要URL编码)
-
敏感信息不要存Cookie
3.10 Session:服务端状态管理
核心方法:
-
setAttribute(String name, Object value):存数据 -
getAttribute(String name):取数据 -
removeAttribute(String name):移除数据 -
invalidate():销毁Session -
setMaxInactiveInterval(int interval):设置超时时间(秒) -
getId():获取Session ID
Session实现机制 :
默认通过Cookie实现:Cookie中存JSESSIONID=xxxx,服务器根据这个ID找对应的Session。
如果用户禁用了Cookie,可以通过URL重写:
String newURL = response.encodeRedirectURL("index.jsp");
response.sendRedirect(newURL);
这样如果Cookie不可用,Session ID会附加在URL后面:index.jsp;jsessionid=xxxx
分布式Session问题 :
在多台服务器部署时,用户请求可能落到不同服务器,需要解决Session共享问题。解决方案:
-
粘性Session:让同一用户的请求始终落到同一台服务器(但服务器宕机就丢了)
-
Session复制:在集群间同步Session(网络开销大)
-
集中式存储:用Redis统一存储Session(最常用方案)
3.11(使用芋道举例:)芋道请求处理的全链路流程图
第一阶段:容器启动与监听(Listener)
在请求还没进来之前,Listener 已经完成了舞台的搭建。
-
关键类 :
JdbcContextHolder或自定义的ConfigListener。 -
源码路径 :通常在
yudao-module-system的初始化逻辑中。 -
动作:
-
项目启动,
ServletContextListener触发。 -
芋道会加载数据库中的字典数据 (Dict Data)和系统参数到 Redis 缓存。
-
结果:此时服务器已准备就绪,等待第一个 HTTP 请求。
-
第二阶段:安全与协议过滤(Filter)
请求到达 Tomcat 后,首先进入 Filter 链。这是 Spring Security 的主战场。
-
CacheRequestBodyFilter(最先执行):
-
动作 :发现这是一个 POST 请求,它把
request.getInputStream()读出来存进字节数组,包装成CacheRequestBodyWrapper。 -
目的:为了后面拦截器记日志时,还能读到 Body 数据。
-
-
TokenAuthenticationFilter:
-
源码路径 :
yudao-spring-boot-starter-security -
动作 :从 Header 提取
Authorization,去 Redis 查这个 Token 对应的LoginUser。 -
结果 :将用户信息塞入
SecurityContextHolder。
-
第三阶段:进入 Spring MVC 核心(Servlet)
经过所有过滤器后,请求终于传给了 DispatcherServlet。它像个接线员,开始寻找该由哪个 Controller 处理。
第四阶段:业务逻辑拦截(Interceptor)
在进入 Controller 之前,Interceptor 开始细化处理。
-
TenantContextWebInterceptor(多租户拦截):
-
动作 :读取 Header 里的
tenant-id。 -
逻辑 :执行
TenantContextHolder.setTenantId(tenantId)。 -
影响 :这行代码非常关键!它利用 ThreadLocal 保证了当前线程后续所有的数据库操作(MyBatis-Plus)都会自动带上这个租户 ID。
-
-
ApiAccessLogInterceptor(前置逻辑):
- 动作:记录下请求开始的时间戳。
第五阶段:业务执行(Controller)
终于到了你写的业务代码,比如 @GetMapping("/get-info")。
-
此时,你可以直接调用
SecurityFrameworkUtils.getLoginUserId()获取当前用户。 -
执行 SQL 时,多租户插件已静默生效。
第六阶段:收尾与日志(Interceptor & Filter 后置)
-
ApiAccessLogInterceptor (
afterCompletion):-
动作 :计算
结束时间 - 开始时间。 -
逻辑 :从之前的
CacheRequestBodyFilter留下的包装类里取出 Body,连同执行结果一起,异步 写入数据库system_api_access_log表。
-
-
Listener:
- 如果是请求结束,
ServletRequestListener会清理 ThreadLocal,防止内存泄漏。
- 如果是请求结束,
⚖️ 核心差异总结表(实战视角)
| 特性 | Filter | Interceptor | Listener |
|---|---|---|---|
| 在芋道里改谁? | 认证、XSS、跨域、请求体缓存 | 租户切换、操作日志、权限细化 | 缓存预热、系统配置加载 |
| 能拿到 Body 吗? | 能(通过 Wrapper 改造) | 能(前提是 Filter 先包装了) | 基本不拿 |
| 异常处理 | 需手动写 JSON 返回 | 可以交给全局异常处理器 | 无法感知业务异常 |
| 依赖注入 | 需要通过 FilterRegistrationBean |
直接使用 @Autowired |
直接使用 @Autowired |
阶段四:场景融合------各技术如何协同作战
理解了单个技术的用法,我们来看它们在真实业务场景中如何配合。
场景一:用户登录与权限控制(全流程)
这是一个最经典的场景,几乎串联了所有Java Web核心技术。
流程图示:
1. 用户访问首页 → Filter拦截检查登录 → 未登录 → 重定向到登录页
2. 用户提交登录表单 → LoginServlet接收请求 → 调用业务层验证 → 验证成功 → 创建Session → 跳转首页
3. 首页加载 → JSP从Session取用户信息 → 动态显示欢迎语
4. 用户注销 → LogoutServlet → 销毁Session → 清理Cookie → 跳转登录页
完整实现:
1. 登录页面 login.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>登录</title> </head> <body> <h2>用户登录</h2> <%-- 显示错误信息 --%> <c:if test="${not empty error}"> <p style="color:red">${error}</p> </c:if> <form action="${pageContext.request.contextPath}/login" method="post"> 用户名:<input type="text" name="username"><br> 密码:<input type="password" name="password"><br> <input type="checkbox" name="remember">记住我<br> <input type="submit" value="登录"> </form> </body> </html>
2. 登录Servlet LoginServlet.java
java
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
private UserService userService = new UserService(); // 业务层
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 获取参数
String username = request.getParameter("username");
String password = request.getParameter("password");
String remember = request.getParameter("remember");
// 调用业务层验证
User user = userService.login(username, password);
if (user != null) {
// 登录成功,创建Session
HttpSession session = request.getSession();
session.setAttribute("user", user);
session.setMaxInactiveInterval(30 * 60); // 30分钟
// 如果勾选了"记住我",设置Cookie
if ("on".equals(remember)) {
Cookie cookie = new Cookie("username", username);
cookie.setMaxAge(7 * 24 * 60 * 60); // 一周
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
// 重定向到首页
response.sendRedirect(request.getContextPath() + "/index");
} else {
// 登录失败,返回错误信息
request.setAttribute("error", "用户名或密码错误");
request.getRequestDispatcher("/login.jsp").forward(request, response);
}
}
}
3. 权限控制过滤器 AuthFilter.java
java
@WebFilter("/*")
public class AuthFilter implements Filter {
// 不需要登录就能访问的路径
private static final List<String> ALLOWED_PATHS = Arrays.asList(
"/login", "/login.jsp", "/register", "/register.jsp", "/css", "/js"
);
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String path = request.getServletPath();
// 放行静态资源和登录相关路径
if (isAllowedPath(path)) {
chain.doFilter(request, response);
return;
}
// 检查是否登录
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("user") != null) {
// 已登录,放行
chain.doFilter(request, response);
} else {
// 未登录,重定向到登录页
response.sendRedirect(request.getContextPath() + "/login.jsp");
}
}
private boolean isAllowedPath(String path) {
for (String allowed : ALLOWED_PATHS) {
if (path.startsWith(allowed)) {
return true;
}
}
return false;
}
}
4. 首页 index.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>首页</title> </head> <body> <h2>欢迎,${sessionScope.user.name}!</h2> <p>你的角色:${sessionScope.user.role}</p> <a href="${pageContext.request.contextPath}/logout">注销</a> <h3>功能列表</h3> <ul> <c:forEach items="${functionList}" var="func"> <li><a href="${func.url}">${func.name}</a></li> </c:forEach> </ul> </body> </html>
5. 注销Servlet LogoutServlet.java
java
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 销毁Session
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// 清理记住我的Cookie
Cookie cookie = new Cookie("username", "");
cookie.setMaxAge(0);
response.addCookie(cookie);
// 跳转登录页
response.sendRedirect(request.getContextPath() + "/login.jsp");
}
}
场景二:购物车功能(Session的应用)
购物车是Session的典型应用场景------每个用户有自己的购物车,数据保存在服务器端。
java
@WebServlet("/cart")
public class CartServlet extends HttpServlet {
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String action = request.getParameter("action");
HttpSession session = request.getSession();
// 从Session获取购物车,如果没有则创建
Cart cart = (Cart) session.getAttribute("cart");
if (cart == null) {
cart = new Cart();
session.setAttribute("cart", cart);
}
if ("add".equals(action)) {
// 添加商品
String productId = request.getParameter("productId");
int quantity = Integer.parseInt(request.getParameter("quantity"));
cart.addItem(productId, quantity);
// 返回购物车总件数(用于页面右上角更新)
response.setContentType("application/json");
response.getWriter().write("{\"totalCount\":" + cart.getTotalCount() + "}");
} else if ("list".equals(action)) {
// 查看购物车
request.setAttribute("cart", cart);
request.getRequestDispatcher("/cart.jsp").forward(request, response);
}
}
}
场景三:应用启动初始化(Listener的应用)
很多应用需要在启动时加载配置、初始化线程池、预热缓存等,这时就用到了ServletContextListener。
java
@WebListener
public class AppInitListener implements ServletContextListener {
public void contextInitialized(ServletContextEvent sce) {
ServletContext context = sce.getServletContext();
// 1. 加载配置文件
String configPath = context.getInitParameter("configLocation");
Properties props = loadConfig(configPath);
// 2. 初始化数据库连接池
DataSource dataSource = createDataSource(props);
context.setAttribute("dataSource", dataSource);
// 3. 预热缓存(加载热门数据)
CacheManager.init(dataSource);
// 4. 启动定时任务
ScheduleManager.start();
context.log("应用启动完成");
}
public void contextDestroyed(ServletContextEvent sce) {
// 释放资源
CacheManager.shutdown();
ScheduleManager.stop();
sce.getServletContext().log("应用关闭,资源已释放");
}
}
阶段五:企业级实战------构建完整项目
理论知识学完了,我们动手做一个小型但完整的项目,模拟企业开发的全流程。
项目:员工信息管理系统
功能需求:
-
员工信息列表展示(分页)
-
添加新员工
-
编辑员工信息
-
删除员工
-
部门管理
-
用户登录/权限控制
技术栈:
-
Servlet 4.0(处理请求)
-
JSP + JSTL + EL(展示页面)
-
Filter(权限控制、编码处理)
-
Listener(加载初始化数据)
-
Cookie(记住我功能)
-
Session(用户登录状态)
-
JDBC(数据库操作)
-
MySQL(数据库)
-
Tomcat 9(服务器)
5.1 项目结构
src/ ├── main/ │ ├── java/ │ │ └── com/example/ems/ │ │ ├── controller/ # Servlet(控制器) │ │ │ ├── EmployeeServlet.java │ │ │ ├── DepartmentServlet.java │ │ │ ├── LoginServlet.java │ │ │ └── LogoutServlet.java │ │ ├── service/ # 业务逻辑层 │ │ │ ├── EmployeeService.java │ │ │ └── DepartmentService.java │ │ ├── dao/ # 数据访问层 │ │ │ ├── EmployeeDAO.java │ │ │ └── DepartmentDAO.java │ │ ├── entity/ # 实体类 │ │ │ ├── Employee.java │ │ │ └── Department.java │ │ ├── filter/ # 过滤器 │ │ │ ├── AuthFilter.java │ │ │ └── EncodingFilter.java │ │ ├── listener/ # 监听器 │ │ │ └── AppInitListener.java │ │ ├── util/ # 工具类 │ │ │ ├── DBUtil.java │ │ │ └── PageUtil.java │ │ └── config/ # 配置 │ │ └── Constants.java │ └── webapp/ │ ├── WEB-INF/ │ │ └── web.xml │ ├── view/ # JSP页面 │ │ ├── employee/ │ │ │ ├── list.jsp │ │ │ ├── add.jsp │ │ │ └── edit.jsp │ │ ├── department/ │ │ │ └── list.jsp │ │ ├── login.jsp │ │ └── index.jsp │ └── static/ # 静态资源 │ ├── css/ │ └── js/
5.2 核心代码实现
实体类 Employee.java
java
public class Employee {
private int id;
private String name;
private String gender;
private int age;
private String position;
private int deptId;
private Date hireDate;
private Department department; // 关联部门对象
// getter/setter 省略
}
数据访问层 EmployeeDAO.java
java
public class EmployeeDAO {
private DataSource dataSource;
public EmployeeDAO() {
this.dataSource = DBUtil.getDataSource();
}
// 分页查询员工(含部门名称)
public List<Employee> findEmployeesByPage(int pageNum, int pageSize) {
String sql = "SELECT e.*, d.name as dept_name FROM employee e " +
"LEFT JOIN department d ON e.dept_id = d.id " +
"LIMIT ?, ?";
int offset = (pageNum - 1) * pageSize;
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, offset);
pstmt.setInt(2, pageSize);
ResultSet rs = pstmt.executeQuery();
List<Employee> list = new ArrayList<>();
while (rs.next()) {
Employee emp = new Employee();
emp.setId(rs.getInt("id"));
emp.setName(rs.getString("name"));
emp.setGender(rs.getString("gender"));
emp.setAge(rs.getInt("age"));
emp.setPosition(rs.getString("position"));
emp.setDeptId(rs.getInt("dept_id"));
emp.setHireDate(rs.getDate("hire_date"));
// 设置关联部门信息
Department dept = new Department();
dept.setId(rs.getInt("dept_id"));
dept.setName(rs.getString("dept_name"));
emp.setDepartment(dept);
list.add(emp);
}
return list;
} catch (SQLException e) {
throw new RuntimeException("查询员工失败", e);
}
}
// 获取总记录数(用于分页)
public int countEmployees() {
String sql = "SELECT COUNT(*) FROM employee";
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
rs.next();
return rs.getInt(1);
} catch (SQLException e) {
throw new RuntimeException("统计员工失败", e);
}
}
// 新增员工
public void insertEmployee(Employee emp) {
String sql = "INSERT INTO employee(name, gender, age, position, dept_id, hire_date) " +
"VALUES(?, ?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, emp.getName());
pstmt.setString(2, emp.getGender());
pstmt.setInt(3, emp.getAge());
pstmt.setString(4, emp.getPosition());
pstmt.setInt(5, emp.getDeptId());
pstmt.setDate(6, new java.sql.Date(emp.getHireDate().getTime()));
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("新增员工失败", e);
}
}
// 更新员工
public void updateEmployee(Employee emp) {
String sql = "UPDATE employee SET name=?, gender=?, age=?, position=?, dept_id=?, hire_date=? " +
"WHERE id=?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, emp.getName());
pstmt.setString(2, emp.getGender());
pstmt.setInt(3, emp.getAge());
pstmt.setString(4, emp.getPosition());
pstmt.setInt(5, emp.getDeptId());
pstmt.setDate(6, new java.sql.Date(emp.getHireDate().getTime()));
pstmt.setInt(7, emp.getId());
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("更新员工失败", e);
}
}
// 删除员工
public void deleteEmployee(int id) {
String sql = "DELETE FROM employee WHERE id=?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("删除员工失败", e);
}
}
}
业务层 EmployeeService.java
java
public class EmployeeService {
private EmployeeDAO employeeDAO = new EmployeeDAO();
public PageResult<Employee> getEmployeesByPage(int pageNum, int pageSize) {
// 校验参数
if (pageNum < 1) pageNum = 1;
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100; // 防止页大小过大
// 查询数据
List<Employee> list = employeeDAO.findEmployeesByPage(pageNum, pageSize);
int total = employeeDAO.countEmployees();
// 计算总页数
int totalPages = (int) Math.ceil((double) total / pageSize);
return new PageResult<>(list, pageNum, pageSize, total, totalPages);
}
public void addEmployee(Employee emp) {
// 业务校验
if (emp.getName() == null || emp.getName().trim().isEmpty()) {
throw new BusinessException("员工姓名不能为空");
}
if (emp.getAge() < 18 || emp.getAge() > 65) {
throw new BusinessException("年龄必须在18-65岁之间");
}
employeeDAO.insertEmployee(emp);
}
// 其他业务方法...
}
控制器 EmployeeServlet.java
java
@WebServlet("/employee/*")
public class EmployeeServlet extends HttpServlet {
private EmployeeService employeeService = new EmployeeService();
private DepartmentService departmentService = new DepartmentService();
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String pathInfo = request.getPathInfo();
if (pathInfo == null || "/list".equals(pathInfo)) {
list(request, response);
} else if ("/add".equals(pathInfo)) {
showAddForm(request, response);
} else if ("/edit".equals(pathInfo)) {
showEditForm(request, response);
} else if ("/delete".equals(pathInfo)) {
delete(request, response);
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String pathInfo = request.getPathInfo();
if ("/add".equals(pathInfo)) {
add(request, response);
} else if ("/update".equals(pathInfo)) {
update(request, response);
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
// 员工列表(带分页)
private void list(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 获取页码参数
int pageNum = 1;
String pageParam = request.getParameter("page");
if (pageParam != null && !pageParam.isEmpty()) {
pageNum = Integer.parseInt(pageParam);
}
int pageSize = 10; // 可配置化
// 调用业务层
PageResult<Employee> pageResult = employeeService.getEmployeesByPage(pageNum, pageSize);
// 存入request
request.setAttribute("pageResult", pageResult);
// 转发到JSP
request.getRequestDispatcher("/WEB-INF/view/employee/list.jsp")
.forward(request, response);
}
// 新增员工表单
private void showAddForm(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 获取部门列表(用于下拉框)
List<Department> depts = departmentService.getAllDepartments();
request.setAttribute("departments", depts);
request.getRequestDispatcher("/WEB-INF/view/employee/add.jsp")
.forward(request, response);
}
// 新增员工提交
private void add(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
try {
// 参数封装
Employee emp = new Employee();
emp.setName(request.getParameter("name"));
emp.setGender(request.getParameter("gender"));
emp.setAge(Integer.parseInt(request.getParameter("age")));
emp.setPosition(request.getParameter("position"));
emp.setDeptId(Integer.parseInt(request.getParameter("deptId")));
String hireDateStr = request.getParameter("hireDate");
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
emp.setHireDate(sdf.parse(hireDateStr));
// 调用业务层
employeeService.addEmployee(emp);
// 成功,重定向到列表页(带成功消息)
response.sendRedirect(request.getContextPath() + "/employee/list?success=添加成功");
} catch (Exception e) {
// 失败,返回表单并显示错误
request.setAttribute("error", e.getMessage());
request.setAttribute("departments", departmentService.getAllDepartments());
request.getRequestDispatcher("/WEB-INF/view/employee/add.jsp")
.forward(request, response);
}
}
// 其他方法...
}
列表页面 list.jsp(JSTL+EL)
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head> <title>员工管理</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/style.css"> </head> <body> <div class="container"> <h2>员工列表</h2> <%-- 显示操作消息 --%> <c:if test="${not empty param.success}"> <div class="alert alert-success">${param.success}</div> </c:if> <c:if test="${not empty param.error}"> <div class="alert alert-error">${param.error}</div> </c:if> <div class="toolbar"> <a href="${pageContext.request.contextPath}/employee/add" class="btn btn-primary"> 新增员工 </a> </div> <table class="table"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>性别</th> <th>年龄</th> <th>职位</th> <th>部门</th> <th>入职日期</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${pageResult.list}" var="emp"> <tr> <td>${emp.id}</td> <td>${emp.name}</td> <td>${emp.gender}</td> <td>${emp.age}</td> <td>${emp.position}</td> <td>${emp.department.name}</td> <td><fmt:formatDate value="${emp.hireDate}" pattern="yyyy-MM-dd"/></td> <td> <a href="${pageContext.request.contextPath}/employee/edit?id=${emp.id}"> 编辑 </a> <a href="${pageContext.request.contextPath}/employee/delete?id=${emp.id}" οnclick="return confirm('确定删除吗?')"> 删除 </a> </td> </tr> </c:forEach> <c:if test="${empty pageResult.list}"> <tr> <td colspan="8" class="text-center">暂无数据</td> </tr> </c:if> </tbody> </table> <%-- 分页组件 --%> <div class="pagination"> <c:if test="${pageResult.pageNum > 1}"> <a href="?page=${pageResult.pageNum-1}">上一页</a> </c:if> <c:forEach begin="1" end="${pageResult.totalPages}" var="i"> <c:choose> <c:when test="${i == pageResult.pageNum}"> <span class="current">${i}</span> </c:when> <c:otherwise> <a href="?page=${i}">${i}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test="${pageResult.pageNum < pageResult.totalPages}"> <a href="?page=${pageResult.pageNum+1}">下一页</a> </c:if> <span>共 ${pageResult.total} 条记录</span> </div> </div> </body> </html>
5.3 企业开发规范落地
在这个项目中,我们遵循了以下企业开发规范:
-
分层架构:Controller(Servlet)→ Service(业务逻辑)→ DAO(数据访问)→ Entity(实体),各层职责清晰
-
命名规范:
-
类名:大驼峰,如
EmployeeService -
方法名:小驼峰,如
getEmployeesByPage -
常量:全大写下划线,如
MAX_PAGE_SIZE
-
-
异常处理 :自定义
BusinessException,在Controller层统一处理并展示给用户 -
日志记录 :使用
ServletContext.log()或集成Log4j记录关键操作 -
配置分离 :数据库连接、分页大小等配置在
Constants.java或配置文件中 -
参数校验:在Service层进行业务校验,避免脏数据进入数据库
-
防止SQL注入:使用PreparedStatement代替Statement
5.4 测试与部署
单元测试(JUnit):
java
public class EmployeeServiceTest {
private EmployeeService employeeService;
@Before
public void setUp() {
employeeService = new EmployeeService();
}
@Test
public void testAddEmployee_ValidData() {
Employee emp = new Employee();
emp.setName("张三");
emp.setAge(25);
emp.setGender("男");
emp.setPosition("开发");
emp.setDeptId(1);
emp.setHireDate(new Date());
// 应正常执行,不抛出异常
employeeService.addEmployee(emp);
}
@Test(expected = BusinessException.class)
public void testAddEmployee_InvalidAge() {
Employee emp = new Employee();
emp.setName("李四");
emp.setAge(16); // 年龄太小,应抛出异常
// 其他属性...
employeeService.addEmployee(emp);
}
}
部署步骤:
-
Maven打包:
mvn clean package,生成ems.war -
将war包复制到Tomcat的
webapps目录 -
启动Tomcat:
bin/startup.sh(Linux)或bin/startup.bat(Windows) -
访问:
http://localhost:8080/ems/
阶段六:复盘升华------从会用到用好
6.1 最佳实践总结
经过前面的学习和实战,我们来提炼一些企业开发中经过验证的最佳实践。
Servlet最佳实践
-
一个Servlet只处理一类业务:不要写万能Servlet,按模块拆分(如EmployeeServlet、DepartmentServlet)
-
使用路径映射简化URL :
@WebServlet("/employee/*")+ 路径分发 -
避免在Servlet中写业务逻辑:Servlet只做参数解析、调用Service、页面跳转
JSP最佳实践
-
杜绝Java脚本(<% ... %>):全部用EL+JSTL替代,页面更干净
-
使用JSP包含机制复用页面 :
<%@ include file="header.jsp" %>或<jsp:include> -
JSP放在WEB-INF下:防止直接访问,只能通过Servlet转发
Filter最佳实践
-
Filter顺序要合理:编码Filter在前,权限Filter在后
-
不要过度使用Filter:影响性能,只做横切关注点的事情
-
使用
@WebFilter的urlPatterns精确控制拦截范围
Session最佳实践
-
Session中只存必要数据:不要存大对象,影响内存和序列化性能
-
设置合理的超时时间 :web.xml中配置
<session-timeout>30</session-timeout> -
敏感信息加密存储:用户角色、权限等敏感信息可以考虑加密
-
登录后重置Session ID:防止Session Fixation攻击
-
集群环境使用集中式存储:Redis统一管理Session
Cookie最佳实践
-
设置HttpOnly和Secure:防止XSS窃取和HTTP明文传输
-
不要存敏感信息:密码、令牌等不能存Cookie
-
设置合理的Path:避免Cookie被无关路径携带
-
敏感Cookie设置较短有效期
6.2 技术演进路线
Java Web技术一直在演进,了解发展脉络有助于理解为什么有些技术现在用得少了:
1. 视图层演进
JSP(早期)→ JSP+JSTL(中期)→ FreeMarker/Velocity → Thymeleaf(现代)
JSP现在逐渐被模板引擎取代,因为前后端分离趋势下,后端更专注于提供API。
2. 控制层演进
Servlet(原始)→ Struts2 → Spring MVC(现代)
Spring MVC本质上还是Servlet,但封装得极其优雅,成为事实标准。
3. 数据层演进
JDBC(原始)→ MyBatis → JPA/Hibernate → MyBatis-Plus/Spring Data JPA
现代开发很少直接写JDBC,都用ORM框架简化数据库操作。
4. 构建方式演进
手动部署 → Ant → Maven → Gradle
现代项目几乎都用Maven或Gradle管理依赖和构建。
5. 架构演进
单体应用 → 垂直拆分 → 分布式服务 → 微服务 → Service Mesh
Java Web从单一的JSP+Servlet,发展到现在复杂的微服务架构。
6.3 高频面试题
Q1:Servlet是单例还是多例?线程安全吗?
A:Servlet默认单例多线程。容器只创建一个Servlet实例,每个请求由不同线程执行。因此成员变量需要考虑线程安全,尽量不定义可修改的成员变量。
Q2:Cookie和Session的区别?
A:
-
Cookie存在客户端,Session存在服务端
-
Cookie容量小(4KB),Session容量理论上无限
-
Cookie相对不安全(可被篡改),Session安全
-
典型应用:记住我功能用Cookie,登录状态用Session
Q3:请求转发(forward)和重定向(sendRedirect)的区别?
A:
-
转发是服务器内部跳转,URL不变,可以携带request范围内的数据
-
重定向是浏览器重新发起请求,URL改变,无法直接携带request数据
-
转发只能跳转到站内资源,重定向可以站外
Q4:Filter和Interceptor有什么区别?
A:Filter是Servlet规范的一部分,基于回调函数,只能在Servlet前后起作用;Interceptor是Spring MVC的概念,基于反射,更细粒度,可以深入到方法调用前后。
Q5:Session共享问题怎么解决?
A:
-
粘性Session:负载均衡配置同一用户始终访问同一服务器
-
Session复制:Tomcat自带集群会话复制功能
-
集中式存储:Redis统一存储Session(最推荐)
-
客户端存储:JWT等令牌机制,服务端无状态
Q6:JSP的9大内置对象有哪些?
A: request、response、session、application、out、pageContext、config、page、exception
Q7:如果客户端禁用了Cookie,Session还能用吗?
A:能,但需要URL重写。通过response.encodeURL()在URL后面附加jsessionid=xxx。
Q8:如何防止Session固定攻击?
A: 用户登录成功后,调用session.invalidate()销毁旧Session,然后创建新Session。
写在最后
Java Web开发涉及的知识点看起来很多,但它们并不是孤立的。Servlet是地基,JSP是展示,Filter是拦截,Listener是监听,Session是状态,Cookie是标识------它们组合在一起,才构成了一个完整的Web应用。
希望这篇长达两万字的指南,能帮你建立起Java Web的知识体系。从「问题锚定」开始,到「基础认知」理解概念,到「核心用法」掌握操作,到「场景融合」看到配合,到「实战落地」完整开发,再到「复盘升华」总结经验------这正是企业开发中需要具备的思维方式。
如果你读完这篇文章,能动手把那个员工管理系统做出来,那么恭喜你,你已经具备了Java Web开发的基础能力。接下来就是不断练习、不断踩坑、不断总结的过程。
技术在变,但解决问题的本质不变。理解每个技术「为什么存在」,比记住「怎么用」更重要。
祝你在Java Web的世界里玩得开心!