1. 为什么Servlet依然是JavaWeb开发的基石?
2003年我刚接触JavaWeb开发时,Servlet就是入门的第一课。二十年过去了,虽然各种框架层出不穷,但Servlet依然是JavaEE规范的核心组件。最近帮团队新人排查一个诡异的问题:他们用SpringBoot写的接口在Tomcat 9上运行正常,迁移到Jetty就报404。最终发现是没理解Servlet的URL映射机制——这个案例让我意识到,无论用什么框架,Servlet的基础知识都绕不开。
Servlet本质上是个Java类,它通过实现特定的接口获得了与Web容器对话的能力。当你在浏览器输入一个URL时,容器(比如Tomcat)会根据web.xml或注解配置找到对应的Servlet类,创建实例(或复用现有实例),调用其service()方法处理请求。这个过程看似简单,但新手常犯的错误是直接继承HttpServlet后就开始写业务逻辑,忽略了底层机制。
关键认知:Servlet是单例多线程的!这意味着你的doGet/doPost方法必须考虑线程安全问题。我曾见过有人把数据库连接放在Servlet的成员变量里,结果生产环境频繁报连接泄露。
2. 环境搭建:避开那些"理所当然"的配置陷阱
2.1 Tomcat选型与版本兼容性
新手常问:"我用最新版Tomcat 10应该没问题吧?"实际上,Tomcat 10实现了Jakarta EE 9规范,包名从javax.改成了jakarta.。如果你的项目依赖的库还在用javax.servlet,就必须用Tomcat 9或以下版本。去年我们升级时就踩了这个坑,编译通过的war包在Tomcat 10上抛出ClassNotFoundException。
版本选择建议:
- 学习阶段:Tomcat 9.0.x(最稳定)
- 生产环境:Tomcat 8.5.x(长期支持版)
- 尝鲜体验:Tomcat 10.1.x(注意Jakarta EE兼容性)
2.2 IDEA中配置Tomcat的隐藏关卡
在IDEA里配置外部Tomcat时,有几点教科书不会提:
- Deployment选项卡中,Application context填
/和空字符串是不同含义。填/表示根路径,访问时用http://localhost:8080/;留空则表示部署为ROOT应用,需要删除Tomcat的webapps/ROOT目录 - "Update classes and resources"选项在调试时非常有用,但修改静态资源后可能需要手动Rebuild模块才能生效
- 如果遇到控制台乱码,需要同时修改三处:
- IDEA的VM Options添加
-Dfile.encoding=UTF-8 - Tomcat的conf/logging.properties中指定
java.util.logging.ConsoleHandler.encoding = UTF-8 - 如果是Windows系统,还需修改注册表中HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Command Processor的AutoRun值为chcp 65001
- IDEA的VM Options添加
3. 核心API:那些官方文档没强调的细节
3.1 HttpServletRequest的输入流陷阱
读取请求体时,新手常犯的错误是多次调用getInputStream()或getReader()。这两个方法只能调用一次!第二次调用会抛出IllegalStateException。正确的做法是:
java复制protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
try (InputStream in = req.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
// 处理请求体
}
} catch (IOException e) {
// 处理异常
}
}
更隐蔽的坑是:如果你在Filter里读取了请求体,后续的Servlet中将无法再次读取。解决方案是使用HttpServletRequestWrapper:
java复制public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(this.cachedBody);
}
// 省略其他方法...
}
3.2 响应输出的正确姿势
HttpServletResponse的输出流同样有讲究:
- 设置Content-Type要带上字符集:
resp.setContentType("text/html;charset=UTF-8") - 获取Writer前必须先设置字符集,否则可能乱码
- 二进制输出应该用getOutputStream(),文本输出用getWriter(),二者不能混用
- 重要:响应提交后(flush或close)再操作响应对象会抛出IllegalStateException
4. 生命周期管理与线程安全实战
4.1 init()方法的执行时机
Servlet的init()方法会在容器启动时或第一次请求时执行(取决于load-on-startup配置)。常见的错误认知:
- 认为每个请求都会调用init() —— 实际上Servlet是单例
- 在init()里执行耗时操作导致应用启动变慢
- 忽略init()可能抛出ServletException的情况
推荐做法:
java复制@Override
public void init() throws ServletException {
// 初始化线程安全的资源(如数据库连接池)
DataSource ds = createDataSource();
getServletContext().setAttribute("DB_POOL", ds);
// 不要在这里做耗时操作!
}
4.2 多线程环境下的数据隔离
由于Servlet实例是共享的,所有成员变量都面临并发访问问题。我曾见过这样的危险代码:
java复制public class DangerServlet extends HttpServlet {
private int visitCount; // 线程不安全!
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
visitCount++;
// ...
}
}
解决方案:
- 使用局部变量而非成员变量
- 必须共享时用线程安全容器(如ConcurrentHashMap)
- 同步代码块要精确控制范围,避免性能问题
5. 部署与排错:从开发到生产的必经之路
5.1 打包时的路径问题
Maven项目的标准目录结构:
code复制src/
main/
java/ # Java源代码
resources/ # 配置文件
webapp/ # WEB-INF、静态资源等
常见错误:
- 把jsp放在WEB-INF外导致直接暴露
- 配置文件放错目录导致运行时找不到
- 忘记在pom.xml中设置
<packaging>war</packaging>
5.2 日志配置技巧
推荐使用SLF4J+Logback组合,在Servlet中获取Logger的正确方式:
java复制public class LoggingServlet extends HttpServlet {
// 每个类一个Logger实例是安全的
private static final Logger log = LoggerFactory.getLogger(LoggingServlet.class);
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
log.debug("Request URI: {}", req.getRequestURI());
// ...
}
}
关键配置项:
xml复制<!-- logback.xml -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 生产环境建议按天滚动日志 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${catalina.base}/logs/myapp.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${catalina.base}/logs/myapp.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
6. 现代Servlet开发:注解 vs web.xml
6.1 @WebServlet注解的隐藏规则
使用注解配置Servlet时,这些细节容易忽略:
java复制@WebServlet(
name = "modernServlet",
urlPatterns = {"/api/*", "/v2/api/*"}, // 支持多个模式
loadOnStartup = 1, // 启动时加载顺序
initParams = {
@WebInitParam(name = "configFile", value = "/WEB-INF/config.properties")
}
)
public class ModernServlet extends HttpServlet {
// ...
}
注意:
- urlPatterns的匹配优先级与声明顺序无关,而是按最长路径匹配
- 如果同时存在注解和web.xml配置,web.xml会覆盖注解
- 在SpringBoot中,@ServletComponentScan需要扫描包含@WebServlet的包
6.2 文件上传的坑与解决方案
处理multipart/form-data请求时,常见问题:
- 忘记配置multipart配置:
java复制@MultipartConfig(
maxFileSize = 1024 * 1024 * 5, // 5MB
maxRequestSize = 1024 * 1024 * 10 // 10MB
)
public class UploadServlet extends HttpServlet
- 获取上传文件名时没有处理路径:
java复制Part filePart = request.getPart("file");
String fileName = Paths.get(filePart.getSubmittedFileName()).getFileName().toString(); // 处理路径注入风险
- 内存溢出:大文件上传应该直接流式处理,不要全部读到内存
7. 性能优化:从入门到精通的必经之路
7.1 连接池的正确使用方式
在Servlet中获取数据库连接的黄金法则:
- 不要在每次请求时创建新连接
- 不要用成员变量保存单个连接
- 确保finally块中关闭资源
推荐方案:
java复制protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
Connection conn = null;
try {
DataSource ds = (DataSource) getServletContext().getAttribute("DB_POOL");
conn = ds.getConnection();
// 使用连接...
} catch (SQLException e) {
// 处理异常
} finally {
if (conn != null) try { conn.close(); } catch (SQLException ignored) {}
}
}
7.2 缓存策略实战
根据数据特性选择缓存方案:
- 静态资源:设置HTTP缓存头
java复制resp.setHeader("Cache-Control", "public, max-age=3600");
- 频繁读取的数据:使用ServletContext级别的缓存
java复制Map<String, Object> cache = (Map<String, Object>) getServletContext().getAttribute("GLOBAL_CACHE");
if (cache == null) {
cache = new ConcurrentHashMap<>();
getServletContext().setAttribute("GLOBAL_CACHE", cache);
}
- 用户会话数据:合理使用HttpSession,注意及时清理
8. 安全防护:新手必须建立的意识
8.1 常见攻击与防御
- XSS防护:
java复制String userInput = request.getParameter("input");
// 错误做法:直接输出
// response.getWriter().write(userInput);
// 正确做法:转义HTML特殊字符
response.getWriter().write(Encode.forHtml(userInput));
- CSRF防护:
java复制// 生成并存储token
String token = UUID.randomUUID().toString();
request.getSession().setAttribute("csrfToken", token);
// 在表单中嵌入token
<input type="hidden" name="csrfToken" value="${csrfToken}">
// 验证token
String sessionToken = (String) request.getSession().getAttribute("csrfToken");
String requestToken = request.getParameter("csrfToken");
if (!sessionToken.equals(requestToken)) {
throw new SecurityException("CSRF token validation failed");
}
8.2 敏感信息处理原则
- 不要将堆栈信息直接返回给客户端
- 生产环境关闭JSP的showSource选项
- 配置文件不要放在WEB-INF以外的地方
- 密码等敏感信息必须加密存储
我在实际项目中见过最离谱的错误是有人把数据库密码硬编码在Servlet里,然后把这个Servlet映射到了/debug路径。这种低级错误一定要避免。
