☰
原生Servlet应用程序设计:从容器原理到分层架构实战
2026/10/22 11:56:50 网站建设 项目流程

1. 项目概述:这不是写个“Hello World”就完事的Servlet设计

“Servlet 的应用程序设计”——这八个字在Java Web开发的老手听来,像一句暗号;对刚学完HTTP协议、还在纠结doGet和doPost区别的人而言,却可能是一堵没贴标签的墙。它不是指“用IDEA新建一个Servlet类然后点运行”,而是指在真实业务约束下,把Servlet作为核心调度单元,构建出可维护、可扩展、能应对并发请求、具备清晰分层逻辑的Web应用骨架。关键词里没有“Spring Boot”,没有“微服务”,甚至不提“MVC框架”,恰恰说明这个标题锚定的是Java Web最底层、最本源的设计能力:你得亲手搭地基,而不是直接买精装房。

我带过不少刚从培训班出来的开发者,他们能熟练写出带MyBatis注解的Controller,但一问“如果不用Spring,只用原生Servlet,登录请求的完整链路怎么串?Session校验放哪?异常统一拦截怎么实现?静态资源和动态路由如何共存?”——十有八九卡壳。这背后暴露的,不是语法问题,而是对Servlet容器生命周期、请求处理模型、线程安全边界、以及组件协作契约的系统性理解缺失。这个标题的价值,正在于逼你退回到技术栈的根部,重新校准对“Web应用到底是什么”的认知。

它解决的核心问题非常具体:当业务需求明确(比如一个内部审批系统,需支持多角色、流程状态机、文件上传、操作日志),而技术选型又受限于老旧环境(如必须兼容JDK 1.7、Tomcat 7)、或出于教学/嵌入式场景需要极简依赖时,如何用最轻量的原生Servlet组合出稳定可靠的工程结构?适合谁?适合三类人:一是想真正吃透Java Web底层机制的中级开发者;二是需要在资源受限设备(如工业网关、边缘计算节点)上部署轻量Web管理界面的嵌入式工程师;三是高校教师设计《Web程序设计》课程实验时,需要避开框架黑盒、让学生亲手组装每个环节的教学者。它不教你怎么快速上线,而是教你怎么让代码在三年后还能被自己看懂、改得动、压得住流量。

2. 整体架构设计与方案选型逻辑

2.1 为什么坚持用原生Servlet?放弃框架不是倒退,而是精准控制

看到“Servlet的应用程序设计”这个标题,第一反应常是:“现在谁还手写Servlet?早该用Spring MVC了。”这话没错,但错在混淆了“生产效率”和“设计能力”。就像学开车先练手动挡,不是为了以后永远开手动,而是为了理解离合、转速、档位之间的物理耦合关系。Servlet设计的核心价值,在于它强制你直面三个不可绕过的底层事实:

第一,容器即上帝。Tomcat/Jetty不是工具,而是你的运行时环境。init()方法在容器启动时调用一次,service()方法被容器线程池反复调用,destroy()在容器关闭前执行——这些不是API,而是你和容器签订的契约。一旦你跳过这层,所有“自动注入”“AOP代理”都成了空中楼阁。我曾参与一个金融后台系统重构,因某中间件升级导致Spring上下文初始化失败,整个应用启动卡死。排查三天才发现,问题根源是某个自定义ServletContextListener里,错误地在contextInitialized()中执行了阻塞IO操作,违反了Servlet规范对监听器初始化时间的要求。这种坑,只有亲手写过ServletContextListener并读过Servlet规范第2.3节的人,才能一眼识破。

第二,线程安全是默认不存在的。HttpServlet实例在容器中是单例的,所有请求共享同一个对象。这意味着你在doGet()里声明的局部变量是安全的,但类成员变量(如private List<String> cache;)就是定时炸弹。框架帮你做了线程隔离(如Spring的@Scope("prototype")),但原生Servlet逼你思考:缓存该放ServletContext(全局)、HttpSession(用户级)还是HttpServletRequest(请求级)?我见过最典型的反模式,是把数据库连接池配置对象作为Servlet成员变量,结果高并发下连接池被多个线程同时修改,抛出ConcurrentModificationException。解决方案?要么用final修饰确保不可变,要么用ThreadLocal隔离,要么——更合理的——把连接池交给ServletContext管理,Servlet只负责从上下文中获取。

第三,职责必须切得足够薄。没有@Controller注解帮你自动路由,你得自己决定URL路径怎么映射到具体类;没有@Service帮你管理事务,你得在doPost()里手动开启/提交/回滚数据库事务。这种“麻烦”,恰恰是设计思维的磨刀石。比如用户登录,框架里可能一行@Transactional搞定,但原生设计中,你必须明确:密码校验(DAO层)、会话创建(HttpSession.setAttribute())、登录日志记录(独立DAO)、重定向响应(response.sendRedirect())——这四个动作,哪些该放在同一个Servlet里?哪些该拆成独立的Filter或Listener?我的经验是:凡是跨多个业务场景复用的逻辑(如权限检查、日志埋点、编码过滤),一律抽成Filter;凡是影响全局状态的初始化(如加载配置、初始化缓存),一律用Listener;Servlet本身只做一件事:协调数据流,不做业务计算,不碰数据库连接,不写日志文件。这样拆分后,代码可测试性飙升——你可以单独MockHttpServletRequest和HttpServletResponse,用JUnit跑通整个登录流程,而无需启动Tomcat。

2.2 架构分层:拒绝“上帝Servlet”,用三层结构守住边界

很多初学者写的Servlet,是一个巨大的if-else嵌套体:if (path.equals("/login")) { ... } else if (path.equals("/upload")) { ... }。这叫“瑞士军刀式Servlet”,功能全但无法维护。真正的应用程序设计,必须建立清晰的分层契约。我采用的经典三层结构是:

  • 表现层(Presentation Layer):由HttpServlet子类构成,仅负责三件事:解析请求参数(request.getParameter())、调用业务层接口、构造响应(response.getWriter().print()或request.getRequestDispatcher().forward())。它不包含任何SQL、不处理文件IO、不进行复杂计算。例如LoginServlet只做:取用户名密码→调UserService.login()→根据返回值跳转到成功页或返回错误JSON。

  • 业务层(Business Layer):纯Java接口+实现类,无任何Servlet API依赖。UserService接口定义login(String user, String pwd)方法,其实现类UserServiceImpl内部调用UserDAO,并封装密码加密、验证码校验等逻辑。关键点在于:业务层代码可以脱离Web容器独立测试。我习惯用JUnit写测试,直接new一个UserServiceImpl(),传入Mock的DAO,验证登录失败时是否抛出AuthenticationException。这种测试速度比启动Tomcat快100倍,且100%覆盖业务逻辑。

  • 数据访问层(Data Access Layer):UserDAO接口及其实现(如JdbcUserDAO),专注与数据库交互。这里的关键设计是DAO不持有Connection。每次方法调用时,由调用方(业务层)传入Connection对象,DAO只负责执行SQL和ResultSet映射。为什么?因为事务控制必须在业务层。UserService.login()方法内,先conn.setAutoCommit(false),再调userDAO.findByUsername(),再调logDAO.insertLoginLog(),最后conn.commit()。如果DAO自己管理Connection,事务就失控了。这个细节,90%的初学者会忽略,直到线上出现“用户登录成功但日志没写”的诡异问题。

这三层之间通过接口而非实现类耦合。LoginServlet依赖UserService接口,UserServiceImpl依赖UserDAO接口。最终在ServletContextListener.contextInitialized()中,用getServletContext().setAttribute("userService", new UserServiceImpl(new JdbcUserDAO()));完成依赖注入。没有Spring,但实现了同样的松耦合效果——只是手动写,所以你更清楚每一行代码的代价。

2.3 路由与调度:从web.xml硬编码到注解驱动的演进

Servlet 3.0之前,所有URL映射必须写在web.xml里:

<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.example.web.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping>

这导致两个痛点:一是配置分散,新增一个Servlet要改两处;二是无法动态注册。Servlet 3.0引入@WebServlet注解后,设计自由度大幅提升。但直接在每个Servlet类上加@WebServlet("/login"),依然不够优雅。我的方案是:用一个中央调度Servlet(DispatcherServlet)统一路由,其他业务Servlet降级为POJO。

具体做法:web.xml中只注册一个DispatcherServlet,映射到/*;所有业务逻辑封装成Action接口实现类(如LoginAction、UploadAction);DispatcherServlet.service()方法中,根据request.getRequestURI()提取路径,查哈希表找到对应Action实例,调用其execute(request, response)方法。哈希表在ServletContextListener中初始化,键是路径(如"/login"),值是Action实例。这样做的好处是:路由规则集中管理,支持运行时热更新(比如灰度发布时,把"/login"映射临时指向LoginActionV2);业务Action彻底摆脱Servlet API,可单元测试;DispatcherServlet本身代码不到50行,职责单一,稳定如磐石。

提示:DispatcherServlet必须继承HttpServlet,但它的doGet()/doPost()方法只需调用service(request, response),将所有HTTP方法统一交由service()处理。这是Servlet规范允许的,也是Spring MVC DispatcherServlet的原始设计思想。

3. 核心细节解析与实操要点

3.1 请求参数解析:别再用getParameter()硬编码了

request.getParameter("username")看似简单,但藏着三个深坑:中文乱码、空值陷阱、类型转换。新手常写的代码:

String username = request.getParameter("username"); int age = Integer.parseInt(request.getParameter("age")); // 可能NPE或NumberFormatException

这在生产环境必然崩溃。正确的参数解析必须包含四步:字符集预设 → 空值校验 → 类型转换 → 业务校验。

第一步,字符集必须在doGet()/doPost()开头强制设置:

// POST请求必须在getParameter()前设置,否则乱码 if ("POST".equalsIgnoreCase(request.getMethod())) { request.setCharacterEncoding("UTF-8"); } // GET请求的乱码需在Tomcat的server.xml中配置URIEncoding="UTF-8" String username = request.getParameter("username"); // 此时才安全

为什么GET和POST处理不同?因为GET参数在URL中,由浏览器编码,Tomcat默认用ISO-8859-1解码;POST参数在请求体中,setCharacterEncoding()可指定解码方式。这个差异,是无数中文乱码问题的根源。

第二步,空值校验不能只用== null。getParameter()返回null(参数不存在)或空字符串""(参数存在但值为空)。业务上,“未填用户名”和“用户名填了空格”都应报错。我的工具类ParamUtil提供getString(request, "username", true)方法,第二个参数true表示“非空校验”,内部逻辑:

public static String getString(HttpServletRequest req, String name, boolean required) { String value = req.getParameter(name); if (value == null) { if (required) throw new IllegalArgumentException("Parameter '" + name + "' is required"); return null; } value = value.trim(); if (required && value.isEmpty()) { throw new IllegalArgumentException("Parameter '" + name + "' cannot be blank"); } return value; }

第三步,类型转换必须捕获异常。ParamUtil.getInt(request, "age", 0)内部用Integer.valueOf()并捕获NumberFormatException,返回默认值或抛出业务异常。绝不允许parseInt()裸奔。

第四步,业务校验。比如邮箱格式,用正则^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$;手机号用^1[3-9]\\d{9}$。这些校验规则不应散落在各个Servlet中,而应抽取为Validator接口,如EmailValidator,在LoginAction.execute()中组合调用:

Validator usernameValidator = new LengthValidator(3, 20); Validator emailValidator = new EmailValidator(); if (!usernameValidator.validate(username) || !emailValidator.validate(email)) { throw new BusinessException("Invalid username or email format"); }

3.2 会话管理:HttpSession不是万能钥匙,滥用必死

HttpSession是Servlet中最易被误用的API。新手常犯三大错误:存储大对象、不设超时、忽略分布式场景。

错误一:存储大对象。有人把整个用户信息对象(含头像字节数组、权限树List)塞进session.setAttribute("user", user)。这会导致内存爆炸。Tomcat默认Session存储在JVM堆内存,一个1MB的用户对象,1000个并发用户就是1GB内存。正确做法是:Session只存最小必要标识。session.setAttribute("userId", 123L),所有用户数据从数据库或Redis按需加载。我设计的UserContext工具类,提供getCurrentUser()方法:先从Session取userId,再查缓存,缓存未命中才查DB。这样既保证性能,又控制内存。

错误二:不设超时。session.setMaxInactiveInterval(30 * 60)必须显式调用,单位秒。默认值由容器决定(Tomcat是30分钟),但业务需求常不同:登录态需2小时,购物车需7天。更重要的是,超时时间必须和前端Token过期时间严格一致,否则用户刷新页面时,后端Session已失效,前端还在发无效Token,造成体验割裂。

错误三:忽略分布式。单机部署时HttpSession完美工作,但集群部署时,用户请求可能被负载均衡到不同Tomcat节点,导致Session丢失。解决方案只有两个:一是粘性Session(Nginx的ip_hash),但丧失故障转移能力;二是Session外置。我推荐用Redis,但绝不直接序列化整个HttpSession对象。因为HttpSession包含大量容器私有字段,跨版本反序列化必失败。正确姿势是:自定义SessionStore,只存Map<String, Object>,Key是业务属性名(如"cartItems"),Value是可序列化的POJO。LoginAction登录成功后,调sessionStore.set(sessionId, "user", user);CartAction取购物车时,调sessionStore.get(sessionId, "cartItems")。这样,Session逻辑与容器解耦,未来迁移到Spring Session也只需替换SessionStore实现。

注意:HttpSession的getId()返回的字符串,是容器生成的唯一ID,但不能直接用作数据库主键。因为Tomcat重启后,旧Session ID可能失效,而数据库记录永久存在。必须用独立的sessionId字段(UUID生成)关联业务数据。

3.3 过滤器(Filter)设计:横切逻辑的黄金分割线

Filter是Servlet设计中提升代码质量的核武器。它能在请求到达Servlet前、响应返回客户端前,插入任意逻辑。但新手常把它写成“万能胶水”,什么日志、权限、编码都往里塞,导致Filter链臃肿难维护。我的原则是:每个Filter只做一件事,且这件事必须是跨所有请求的通用需求。

我标配的Filter链有四层(按执行顺序):

  1. CharacterEncodingFilter:强制设置request.setCharacterEncoding("UTF-8")和response.setCharacterEncoding("UTF-8")。注意:它必须是第一个Filter,否则后续Filter中getParameter()已乱码。

  2. CorsFilter:处理跨域。response.setHeader("Access-Control-Allow-Origin", "*")。生产环境绝不配*,而是白名单校验Origin头。这个Filter必须在所有业务Filter之前,否则OPTIONS预检请求会被后续Filter拦截。

  3. AuthFilter:权限校验。检查request.getRequestURI()是否在免登录白名单(如/login,/static/**),不在则从Cookie或Header中提取Token,调用AuthService.validate(token)。验证失败,直接response.sendError(HttpServletResponse.SC_UNAUTHORIZED),不放行。关键点:AuthFilter不处理登录逻辑,只做鉴权。登录由LoginServlet专门处理。

  4. LogFilter:记录请求日志。重点不是记“谁访问了什么”,而是记“耗时”和“异常”。我在doFilter()前后打时间戳,计算endTime - startTime。如果chain.doFilter()抛出异常,catch块中记录堆栈。日志格式:[2023-10-01 10:20:30] [INFO] /api/user/123 GET 200 142ms。这个Filter必须在最后,确保捕获所有下游异常。

Filter的配置在web.xml中必须按顺序声明,否则执行顺序错乱。Servlet 3.0后可用@WebFilter,但@Order注解在部分老容器中不生效,web.xml仍是首选。

3.4 异常处理:从printStackTrace()到用户友好的错误页

原生Servlet的异常处理,是区分业余和专业的试金石。新手代码常见:

try { userService.login(user, pwd); } catch (Exception e) { e.printStackTrace(); // 错!打印到控制台,用户看不到 response.sendError(500); // 错!返回空白页,体验极差 }

这会造成两个后果:一是生产环境日志分散,运维找不到错误源头;二是用户看到“HTTP Status 500”,不知所措。专业方案是全局异常处理器 + 分级响应策略。

第一步,定义业务异常体系。所有业务错误抛BusinessException(继承RuntimeException),如UserNotFoundException、InsufficientBalanceException;系统错误抛SystemException(继承RuntimeException),如DatabaseConnectionException。这样,Filter中可区分处理:

try { chain.doFilter(request, response); } catch (BusinessException e) { // 返回友好JSON:{"code":400,"message":"用户不存在"} response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setContentType("application/json;charset=UTF-8"); response.getWriter().print("{\"code\":400,\"message\":\"" + e.getMessage() + "\"}"); } catch (SystemException e) { // 记录ERROR日志,返回通用错误页 logger.error("System error", e); request.getRequestDispatcher("/error/system.jsp").forward(request, response); }

第二步,配置web.xml的错误页映射:

<error-page> <error-code>404</error-code> <location>/error/404.jsp</location> </error-page> <error-page> <exception-type>java.lang.Throwable</exception-type> <location>/error/general.jsp</location> </error-page>

general.jsp中,可通过exception隐式对象获取异常,但绝不显示堆栈给用户。只显示:“系统繁忙,请稍后再试”,并将exception.toString()写入日志文件。

第三步,日志必须结构化。我用Logback,配置<encoder>输出JSON格式,包含timestamp、level、class、method、message、throwable。这样ELK日志系统可直接解析,按异常类型聚合统计。比如发现UserNotFoundException每分钟突增1000次,立刻知道是爬虫在暴力枚举用户ID。

4. 实操过程与核心环节实现

4.1 从零搭建项目:Maven依赖与目录结构

不依赖IDE向导,手动创建一个可运行的Servlet应用,是检验设计功底的第一步。以下是经过千次验证的最小可行结构:

my-servlet-app/ ├── pom.xml ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/ │ │ ├── action/ # Action接口及实现 │ │ ├── filter/ # Filter实现 │ │ ├── listener/ # ServletContextListener │ │ ├── servlet/ # DispatcherServlet │ │ └── util/ # 工具类(ParamUtil, Validator) │ ├── resources/ │ │ └── logback.xml # 日志配置 │ └── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置 │ │ └── lib/ # 手动添加的jar(如mysql-connector-java) │ ├── static/ # CSS/JS/IMG,不走Servlet │ └── jsp/ # JSP页面 └── target/ # Maven编译输出

pom.xml关键依赖(精简版,无Spring):

<dependencies> <!-- Servlet API,provided scope,不打包进war --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <!-- 日志 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.11</version> </dependency> <!-- 数据库 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSON处理(替代Jackson,更轻量) --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.42</version> </dependency> </dependencies>

web.xml是灵魂,必须精确配置:

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <!-- 启动监听器,初始化业务对象 --> <listener> <listener-class>com.example.listener.AppContextListener</listener-class> </listener> <!-- 全局过滤器链 --> <filter> <filter-name>CharacterEncodingFilter</filter-name> <filter-class>com.example.filter.CharacterEncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>CharacterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 中央调度Servlet --> <servlet> <servlet-name>DispatcherServlet</servlet-name> <servlet-class>com.example.servlet.DispatcherServlet</servlet-class> <load-on-startup>1</load-on-startup> <!-- 启动时加载 --> </servlet> <servlet-mapping> <servlet-name>DispatcherServlet</servlet-name> <url-pattern>/</url-pattern> <!-- 拦截所有请求 --> </servlet-mapping> <!-- 静态资源不走Servlet,提高性能 --> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/static/*</url-pattern> </servlet-mapping> <!-- 错误页 --> <error-page> <error-code>404</error-code> <location>/error/404.jsp</location> </error-page> </web-app>

关键点:<load-on-startup>1</load-on-startup>确保DispatcherServlet在应用启动时初始化,避免首次请求时编译JSP的延迟;<url-pattern>/</url-pattern>是根路径映射,配合<servlet-name>default</servlet-name>的静态资源映射,实现动静分离。

4.2AppContextListener:应用启动时的初始化总控

ServletContextListener是Servlet应用的“main方法”,所有全局初始化逻辑必须在此完成。我把它设计成一个工厂,负责创建和绑定所有核心服务对象到ServletContext。

public class AppContextListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { ServletContext context = sce.getServletContext(); // 1. 初始化日志(Logback自动配置,此处可补充MDC) MDC.put("app", "my-servlet-app"); // 2. 创建数据库连接池(HikariCP) HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(20); config.setMinimumIdle(5); HikariDataSource dataSource = new HikariDataSource(config); context.setAttribute("dataSource", dataSource); // 绑定到ServletContext // 3. 创建业务服务(依赖注入) UserDAO userDAO = new JdbcUserDAO(dataSource); UserService userService = new UserServiceImpl(userDAO); context.setAttribute("userService", userService); // 4. 创建Redis Session Store(如果启用) JedisPool jedisPool = new JedisPool("redis://localhost:6379"); SessionStore sessionStore = new RedisSessionStore(jedisPool); context.setAttribute("sessionStore", sessionStore); // 5. 注册Action映射(中央路由表) Map<String, Action> actionMap = new HashMap<>(); actionMap.put("/login", new LoginAction()); actionMap.put("/logout", new LogoutAction()); actionMap.put("/api/user", new UserApiAction()); context.setAttribute("actionMap", actionMap); System.out.println("Application initialized successfully."); } @Override public void contextDestroyed(ServletContextEvent sce) { // 清理资源:关闭连接池、释放Redis连接 ServletContext context = sce.getServletContext(); HikariDataSource ds = (HikariDataSource) context.getAttribute("dataSource"); if (ds != null) ds.close(); JedisPool jp = (JedisPool) context.getAttribute("jedisPool"); if (jp != null) jp.close(); } }

这个类的威力在于:它把所有“硬编码”的依赖关系,变成了ServletContext中的命名对象。LoginAction中,通过getServletContext().getAttribute("userService")就能拿到服务,完全解耦。更重要的是,contextDestroyed()方法确保应用关闭时,所有连接池、缓存连接都被优雅关闭,避免资源泄漏——这是线上系统稳定性的底线。

4.3DispatcherServlet:100行代码的中央调度器

DispatcherServlet是整个设计的枢纽,代码必须极度精简、稳定、可预测。以下是经过生产环境验证的核心实现:

public class DispatcherServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { // 1. 预处理:设置响应类型、字符集 response.setContentType("text/html;charset=UTF-8"); // 2. 解析请求路径(去除ContextPath和QueryString) String uri = request.getRequestURI(); String contextPath = request.getContextPath(); String path = uri.substring(contextPath.length()); // 如 "/login" -> "/login" // 3. 从ServletContext获取Action映射表 ServletContext context = getServletContext(); @SuppressWarnings("unchecked") Map<String, Action> actionMap = (Map<String, Action>) context.getAttribute("actionMap"); // 4. 查找匹配的Action(支持路径前缀匹配,如 "/api/user/*") Action action = findAction(actionMap, path); if (action == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } // 5. 执行Action,捕获业务异常 action.execute(request, response); } catch (BusinessException e) { // 业务异常:返回JSON错误 response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setContentType("application/json;charset=UTF-8"); response.getWriter().print( String.format("{\"code\":400,\"message\":\"%s\"}", e.getMessage())); } catch (Exception e) { // 系统异常:记录日志,返回通用错误页 Logger.getLogger(DispatcherServlet.class.getName()) .error("Unhandled exception in DispatcherServlet", e); request.getRequestDispatcher("/error/general.jsp").forward(request, response); } } // 路径匹配算法:支持精确匹配和前缀匹配 private Action findAction(Map<String, Action> actionMap, String path) { // 优先精确匹配 if (actionMap.containsKey(path)) { return actionMap.get(path); } // 尝试前缀匹配(如 "/api/user/123" 匹配 "/api/user/*") for (Map.Entry<String, Action> entry : actionMap.entrySet()) { String pattern = entry.getKey(); if (pattern.endsWith("/*") && path.startsWith(pattern.substring(0, pattern.length() - 1))) { return entry.getValue(); } } return null; } }

这段代码的精妙之处在于findAction()方法。它不仅支持/login这样的精确匹配,还支持/api/user/*这样的通配符匹配,让RESTful风格成为可能。/api/user/123和/api/user/456都能路由到同一个UserApiAction,Action内部再用request.getPathInfo()提取/123进行ID解析。这种设计,让路由配置简洁,又不失灵活性。

4.4LoginAction实战:一个完整业务流程的拆解

以登录为例,展示如何将一个业务需求,分解到各层协同工作:

// com.example.action.LoginAction.java public class LoginAction implements Action { @Override public void execute(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { // 1. 参数解析(使用ParamUtil工具类) String username = ParamUtil.getString(request, "username", true); String password = ParamUtil.getString(request, "password", true); // 2. 业务校验(长度、格式) if (username.length() < 3 || username.length() > 20) { throw new BusinessException("用户名长度必须在3-20位"); } // 3. 获取业务服务(从ServletContext) ServletContext context = request.getServletContext(); UserService userService = (UserService) context.getAttribute("userService"); // 4. 调用业务逻辑(返回User对象或抛BusinessException) User user = userService.login(username, password); // 5. 创建会话(使用SessionStore,非HttpSession) SessionStore sessionStore = (SessionStore) context.getAttribute("sessionStore"); String sessionId = UUID.randomUUID().toString(); sessionStore.set(sessionId, "user", user); // 6. 设置Cookie(HttpOnly, Secure) Cookie cookie = new Cookie("SESSION_ID", sessionId); cookie.setHttpOnly(true); cookie.setSecure(true); // 生产环境必须HTTPS cookie.setPath("/"); cookie.setMaxAge(2 * 60 * 60); // 2小时 response.addCookie(cookie); // 7. 响应成功(JSON格式) response.setContentType("application/json;charset=UTF-8"); response.getWriter().print( String.format("{\"code\":200,\"message\":\"登录成功\",\"data\":{\"userId\":%d}}", user.getId())); } catch (BusinessException e) { throw e; // 让DispatcherServlet捕获并处理 } } }

这个Action的每一行,都对应着设计原则:

  • 第1-2行:参数解析与校验,由工具类封装,保证一致性;
  • 第3行:服务获取,通过ServletContext解耦,不硬编码new;
  • 第4行:业务逻辑下沉,Action只协调,不计算;
  • 第5-6行:会话管理,用SessionStore抽象,为未来分布式铺路;
  • 第7行:响应标准化,JSON格式,字段语义清晰。

整个流程,没有一行代码涉及Servlet容器细节(如HttpSession、ServletContext的创建),所有基础设施由AppContextListener和DispatcherServlet兜底。这就是“应用程序设计”的真谛:把变化的业务逻辑,和不变的基础设施,用清晰的边界隔开。

5. 常见问题与排查技巧实录

5.1 中文乱码:90%的问题都出在这三行代码

中文乱码是Servlet开发者的头号敌人,但根源极其简单。我整理了一个速查表,覆盖所有场景:

场景现象根本原因解决方案验证方法
POST请求乱码getParameter()返回????Tomcat默认用ISO-8859-1解码POST体在doPost()开头加request.setCharacterEncoding("UTF-8")打印request.getCharacterEncoding(),应为UTF-8
GET请求乱码URL参数?name=张三在getParameter()中变??浏览器用UTF-8编码URL,Tomcat用ISO-8859-1解码修改$TOMCAT_HOME/conf/server.xml,在<Connector>标签中添加URIEncoding="UTF-8"重启Tomcat,访问/test?name=张三,打印参数
响应乱码浏览器

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询