资讯动态

JavaWeb Servlet优化:前端控制器统一调度请求全解析

发布时间:2026/10/23 11:44:35 来源:尧图企业网站定制
这一篇继续整理JavaWeb系列里Servlet优化的第一部分用同一个组件去接收所有请求再由它统一调度到具体的处理逻辑最后把响应返回给浏览器。说白了就是我们常说的“前端控制器”思路也是很多框架入口设计的雏形。适合正在学JavaWeb的读者也适合小项目做了一半觉得Servlet代码太乱的人参考。先说明一下这个优化并不需要引入任何框架它基于原生Servlet API就能落地。核心目标只有一个让请求不再零散地落在不同Servlet里而是先进入一个统一的入口由这个入口负责路由、编码、异常和视图跳转。这样一来业务代码可以变得更干净后续加权限校验、日志记录这类横切逻辑时也会舒服很多。1. 为什么要做统一调度多Servlet模式的三个痛点先回顾一下最常见的Servlet写法一个功能一个Servlet类。注册用户写一个RegisterServlet登录写一个LoginServlet查列表写一个UserListServlet每个类都通过WebServlet或者web.xml配一个URL。看起来思路很清晰但随着功能增多问题就出来了。1.1 重复代码散落在每个Servlet中我最早写项目时三个Servlet里有三份几乎一模一样的开头代码设置请求编码为UTF-8设置响应编码获取参数并做空值判断如果参数非法返回错误提示执行完业务后转发到对应JSP这些代码在每个类中都要重写一遍。当时复制粘贴确实快但后面要统一改一个逻辑就麻烦了。举个例子如果想把字符编码改成UTF-8变成“动态配置”或者想给每个请求都加上访问日志就不得不打开所有Servlet一个一个改。这不是“代码多写几行”的问题而是维护成本被放大了。1.2 扩展新功能时牵一发动全身新增一个“用户删除”功能时原来的做法是新建DeleteUserServlet配置URL映射注册到web.xml然后复制一遍参数处理、编码设置、跳转逻辑。如果项目里已经有十几个Servlet你会发现很多操作其实是重复劳动而且容易出现“这个Servlet配置了那个Servlet忘配了”的情况。更麻烦的是URL规则没有统一管理。可能有人喜欢/user/deleteUser有人喜欢/deleteUser.do还有人直接在WebServlet里写一个模糊路径。后续接手的同学看代码时很难一眼看出系统到底暴露了哪些请求入口。整个应用的请求列表需要靠人工去翻类文件才能整理出来。1.3 请求流转路径混乱难排查没有统一入口时请求的流转路径是“散装”的。每个Servlet都可以直接转发到JSP也可以重定向回列表页甚至可以在Servlet里直接输出一段HTML。时间久了你想排查一个请求从浏览器发起到页面渲染中间到底经历了哪些环节难度会非常大。权限校验也是一个典型的痛点。如果目标是“管理端的请求都必须校验登录状态”在没有统一入口的情况下你只能在每个管理相关的Servlet里手动添加校验代码。漏掉一个就是安全隐患多写一遍就是重复劳动。而用一个中央组件来接收所有请求这些事情都能在一个地方统一处理。1.4 统一调度到底解决了什么问题统一调度就是把原先每个Servlet重复处理的“公共事”和“路由事”收拢到一个组件中。这个组件就像一个总机接线员来电统一打到总机总机根据对方要找的人把电话转给具体工位。工位不需要知道外部电话是怎么打进来的只需要处理自己手里那件事处理完再由总机把结果返回出去。落到JavaWeb里就是浏览器请求统一访问一个入口Servlet入口解析请求路径找到对应处理器处理器只写业务逻辑不处理编码、路由和跳转入口统一渲染视图或输出JSON这种设计并不是什么高深概念它就是经典的Front Controller模式。将它作为Servlet优化的第一步是因为它能立刻见效而且后续继续优化时还有一个集中落点不用返回去翻老代码。2. 核心设计思路职责划分与调度模型动工之前先把组件之间怎么分工想清楚。过早写代码容易变成“另一个更大的Servlet”反而违背了优化初衷。2.1 从“一个Servlet一个功能”到“一个入口统一分发”新的结构里我习惯把职责拆成四层入口组件统一接收请求做编码设置、静态资源放行、异常捕获路由表记录请求路径和业务处理器之间的对应关系业务处理器具体的登录、注册、列表查询等逻辑只接收处理好的参数返回一个结果标记工具类参数封装、JSON输出、响应编码等重复动作入口组件本身不应该写具体业务只负责“接、找、转、答”四个动作。接收请求后从URL中解析出请求路径去路由表里找对应的业务处理器找到就调用找不到就返回404最后根据处理器的返回结果决定是转发页面、重定向还是写JSON回前端。2.2 选择基于路径映射还是基于注解实现路由时我最初考虑了两种方案第一种是路径映射。规则很简单请求路径是/user/login我就去一个Map里查actions.get(/user/login)拿到一个处理器对象后执行。这个方案直白新手容易看懂排查问题也方便缺点是每加一个处理器都要手动往Map里加一次。第二种是注解映射。自己定义Action(/user/login)注解程序启动时扫描项目里带这个注解的类自动建立路由Map。这个方案省去了手动注册的步骤但扫描逻辑本身也得写对于原生Servlet阶段来说会增加不少代码量。我建议第一次优化时选路径映射把路由表集中放在入口组件的init方法里。需要看有哪些请求入口打开这个类就能一目了然这是最直接的收益。等结构稳定后再引入注解扫描也不迟。2.3 用Servlet还是Filter作为统一入口实现统一入口有两种载体一种是继承HttpServlet的中央Servlet一种是实现Filter接口的过滤器。中央Servlet比较符合“接收请求并响应”的语义因为它本来就有doGet、doPost这些方法还能在init中初始化路由表而Filter更适合做前置处理比如登录校验、编码过滤、跨域配置。如果我们要求的统一调度包含“选路径、调处理器、选视图、写响应”这套完整闭环用中央Servlet更顺手。当然实际项目中Filter也有一席之地。比如“登录校验”这种横切逻辑放在中央Servlet前面作为过滤器可以让中央Servlet更纯粹地做路由分发。但作为优化第一步最好先让中央Servlet的闭环跑通再考虑把部分逻辑前置到Filter里否则一开始就把两者混在一起出了问题反而不知道去哪查。2.4 需要提前想清楚的三件事第一件请求路径规则。我建议统一使用/模块/动作的格式比如/user/login、/user/register、/order/list。不要一会儿用.do结尾一会儿不带后缀规则统一入口解析代码才简单。第二件处理器怎么返回结果。我是这么约定的返回user/login这种普通字符串代表转发到对应JSP返回redirect:/user/list代表重定向到某个路径返回json:{\code\:0}代表直接输出JSON第三件静态资源怎么放行。中央Servlet一旦配置成/默认映射它就会接管所有未被其他映射匹配的请求包括css、js、图片。分布式配置时要提前想好是让Dispatcher转发静态资源还是单独配置默认Servlet处理。我放在后面的实操部分讲。3. 动手实现构建可复用的统一调度组件接下来是完整实现步骤。这里以模拟项目X为例功能包括用户注册、登录、用户列表。工程结构先列一下com.example.web ├── DispatcherServlet.java ├── action │ ├── Action.java │ ├── RegisterAction.java │ ├── LoginAction.java │ └── UserListAction.java └── util ├── ParamUtils.java └── ResponseUtils.java3.1 定义处理器接口与参数工具类先定义Action接口它是所有业务处理器的统一签名。返回String由中央Servlet根据这个字符串决定后续操作。public interface Action { String execute(HttpServletRequest request, HttpServletResponse response) throws Exception; }为什么返回String而不是直接转发JSP因为这样可以把视图跳转控制权收回到中央组件业务处理器只告诉入口“我要去哪”或“我要输出什么”不用亲自操作RequestDispatcher。测试时也可以直接调用处理器拿到字符串方便断言结果。然后是参数工具类把request.getParameter的冗长写法收拢起来public class ParamUtils { public static String getString(HttpServletRequest request, String name, String defaultValue) { String value request.getParameter(name); return (value null || value.trim().isEmpty()) ? defaultValue : value.trim(); } public static int getInt(HttpServletRequest request, String name, int defaultValue) { try { return Integer.parseInt(getString(request, name, String.valueOf(defaultValue))); } catch (NumberFormatException e) { return defaultValue; } } }这里有一个容易被忽略的细节绝大多数用户提交的表单值都不应该直接使用原始空格trim()能有效避免 这种看起来为空但实际不是null的输入。我在早期项目里就遇到过用户名全是空格查了半天编码问题最后发现是没做空值清洗。3.2 中央调度器的完整代码中央Servlet是这套优化的核心。我把核心代码贴出来然后详细解释每段的作用。public class DispatcherServlet extends HttpServlet { private MapString, Action actionMap new HashMap(); Override public void init() throws ServletException { actionMap.put(/user/register, new RegisterAction()); actionMap.put(/user/login, new LoginAction()); actionMap.put(/user/list, new UserListAction()); actionMap.put(/, new IndexAction()); } Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String path getRequestPath(request); if (path null || /.equals(path)) { path /; } if (isStaticResource(path)) { request.getRequestDispatcher(path).forward(request, response); return; } Action action actionMap.get(path); if (action null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } try { String result action.execute(request, response); processResult(result, request, response); } catch (Exception e) { request.setAttribute(error, e.getMessage()); request.getRequestDispatcher(/error.jsp).forward(request, response); } } private String getRequestPath(HttpServletRequest request) { String uri request.getRequestURI(); String contextPath request.getContextPath(); String path uri.substring(contextPath.length()); if (path.endsWith(.do)) { path path.substring(0, path.length() - 3); } return path; } private boolean isStaticResource(String path) { return path.endsWith(.css) || path.endsWith(.js) || path.endsWith(.png) || path.endsWith(.jpg) || path.endsWith(.ico) || path.endsWith(.gif); } private void processResult(String result, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { if (result null || result.isEmpty()) { return; } if (result.startsWith(redirect:)) { response.sendRedirect(request.getContextPath() result.substring(redirect:.length())); } else if (result.startsWith(json:)) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write(result.substring(json:.length())); } else { request.getRequestDispatcher(result).forward(request, response); } } }这里有几个关键点需要注意。第一getRequestPath()里必须去掉contextPath。如果直接使用request.getRequestURI()而不去除上下文路径本地部署时路径可能是/项目名/user/login映射表中永远匹配不上/user/login就会出现“本地能跑上线就404”的经典问题。第二配置文件里给DispatcherServlet的映射是/不是/*。两者区别很大。/会作为应用默认Servlet处理未被其他映射匹配的请求/*会拦截一切请求连JSP页面引擎自己的映射都会被覆盖极容易导致页面全部404。我第一次使用时踩的就是这个坑。第三actionMap在init()中初始化而不是每次请求都创建一个Map。这样处理器对象能够复用避免无谓的对象创建开销。如果某个处理器里保存了状态需要注意并发问题最好的做法是所有Action都设计成无状态单例只通过方法参数传递数据。web.xml这样配置servlet servlet-namedispatcher/servlet-name servlet-classcom.example.web.DispatcherServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingload-on-startup加了个1表示应用启动时就初始化DispatcherServlet这样路由表在第一次请求到来之前就已经准备好而不是等到第一个请求才触发init()。3.3 业务处理器的写法示范处理器不再继承HttpServlet只需要实现Action接口。拿登录举例public class LoginAction implements Action { Override public String execute(HttpServletRequest request, HttpServletResponse response) throws Exception { String username ParamUtils.getString(request, username, ); String password ParamUtils.getString(request, password, ); if (username.isEmpty() || password.isEmpty()) { return redirect:/login.jsp?errorempty; } if (admin.equals(username) 123456.equals(password)) { request.getSession().setAttribute(user, username); return redirect:/user/list; } return redirect:/login.jsp?errorbad; } }注意这里没有任何编码设置代码也没有RequestDispatcher只有参数获取、业务判断、返回结果。这就是优化后业务代码该有的样子一个方法解决一个场景输入输出明确测试也容易写。注册的逻辑类似做完插入操作后返回redirect:/login.jsp让用户去登录页。查询列表的处理器返回视图字符串public class UserListAction implements Action { Override public String execute(HttpServletRequest request, HttpServletResponse response) throws Exception { ListUser userList UserDao.findAll(); request.setAttribute(userList, userList); return /user/list.jsp; } }写到这里你会发现所谓的“统一调度”落地后就是一张路由表加一个分发循环。但正是这么简单的一层抽象把其他Servlet里散落的公共代码全部吸走了。3.4 视图跳转与JSON输出的统一控制processResult这个方法承担了三种结果类型的处理。我建议在项目初期就约法三章所有业务处理器必须返回这三种字符串中的一个不允许返回null后自己偷偷response.getWriter().write()因为那样会让入口组件失去对响应过程的控制。JSON输出场景也很常见。比如注册时校验用户名是否已被占用前端用AJAX异步请求这个时候处理器返回return json:{\code\:1,\msg\:\用户名已存在\};然后中央组件统一设置Content-Type为application/json;charsetUTF-8并写出内容。这样业务层不需要关心响应头设置也避免AJAX中文乱码。对于JSON字符串的构造如果项目规模不大我直接手动拼接如果传入内容复杂建议换一个你习惯的JSON处理库在ResponseUtils里统一转换。3.5 静态资源与默认访问路径的处理Dispatcher被配置为/后静态资源会被它拦截。处理方式是先判断路径后缀是否属于静态资源如果是就用RequestDispatcher转发到对应资源。这种方案胜在不需要额外配置而且静态资源在web项目里通常就是文件转发后容器能直接找到。如果项目里静态资源放在/static/目录下也可以选择让容器默认Servlet来处理servlet-mapping servlet-namedefault/servlet-name url-pattern/static/*/url-pattern /servlet-mapping两种方式选一种即可。我自己的习惯是入口Servlet里做静态后缀判断因为这样只有一个映射配置所有处理逻辑都集中在入口中后面排查时不用同时猜Servlet和默认Servlet哪个生效。4. 优化过程中的典型踩坑与排查思路这部分记录一些我实际调试时遇到的问题。很多问题不是复杂逻辑导致而是JavaWeb里那些“想当然”的细节。4.1 部署名导致路径匹配错乱项目部署在我的本地时访问地址是http://localhost:8080/项目名/request.getContextPath()返回的就是/项目名。如果不把它从请求URI中去掉DispatcherServlet拿到的路径就会变成/项目名/user/login而映射表里存的是/user/login自然匹配不上。排查这类问题时最简单的办法是在入口Servlet里临时输出日志把request.getRequestURI()和contextPath原样打出来。看到路径差异后你就能秒懂问题出在哪。我后来把getRequestPath()写成一个独立方法并且在重定向时也统一加上request.getContextPath()前缀这样就保证了不管部署名怎么变代码内部永远只认“去掉上下文后的虚拟路径”。4.2 编码问题在GET和POST下表现不一致在入口Servlet中统一执行request.setCharacterEncoding(UTF-8)可以解决POST请求中文乱码。但GET请求的编码处理不由这个方法决定而是由服务器连接器的URI编码配置决定。在较新版本的Tomcat中默认URI编码已经是UTF-8所以很多情况下GET也不会乱码但如果你用的是别的服务器或者老配置GET请求里的中文参数就可能出现乱码。我常用的排查方式是在页面上提交一个中文参数然后在Servlet里System.out.println打印参数值看是乱码还是正常。如果只有GET乱码优先检查服务器连接器配置如果POST也乱码检查背景是否在JSP和Servlet两侧用了不同编码。统一调度组件能挡掉一部分重复设置但底层连接器的配置仍然属于必查项。4.3 类型转换和空参数导致异常参数从request.getParameter拿到时都是String转成int、long、boolean时可能会有解析异常。如果不做防御一旦前端传了ageabc后台就会在Integer.parseInt处抛异常然后整个请求变成500页面。我在ParamUtils里让所有转换都带默认值解析失败就返回默认值业务层不需要关心每个参数转换细节。但这也带来另一个风险默认值可能掩盖“用户确实传了非法参数”的事实。所以我建议在重要表单校验时业务层再对getString的结果做一次显式判断比如用户名是否为空、年龄是否大于0。工具类负责“不崩”业务类负责“对不对”两边配合才稳。4.4 频繁修改处理器必须重启调试原生Servlet项目在开发阶段有一个很现实的问题修改了Action类的Java代码必须重新编译并重启Web应用才能生效。JSP修改后容器通常能自动检测并重新编译但Java类没有这么方便。早期我用开发工具的热部署功能偶尔会碰到容器没有完全重启导致旧类还在内存里或者新类加载后状态不一致的情况。实际经验是开发时尽量把业务逻辑放在Action类里保持方法短小这样重启成本会小很多同时把启动方式固定为一套脚本每次改完Java代码后一键重启应用并查看日志。对于框架级别的大改动才考虑使用更高级的热部署工具否则就用“重启看日志”这种笨办法反而最快。4.5 统一调度常见问题速查表现象可能原因处理建议所有请求都404DispatcherServlet映射配置成/*改为/并保留JSP引擎映射样式图片全部丢失静态资源被入口Servlet接管在入口中判断静态后缀并转发路径一直匹配不上未去掉ContextPathgetRequestURI()减去getContextPath()POST中文乱码未设置request.setCharacterEncoding在入口Servlet的service开头统一设置重定向地址多出部署名或缺少部署名重定向时没有用getContextPath()统一在processResult中拼接ContextPath处理器注册了但请求不到请求路径和Map key不一致打印实际path对照检查每次请求都很慢Action反复创建或每次反射查类改为init中实例化并缓存Map5. 效果对比测试与后续演进把统一调度做完后我特意对比过改造前后的代码量也复盘了这套结构下一步还能怎么走。5.1 改造前后代码量和维护性对比拿用户登录、注册、列表三个功能来说维度改造前三个Servlet改造后统一调度编码设置代码每个Servlet都要写一遍共3份入口Servlet统一写1份参数处理逻辑每个Servlet自己解析、判空、转类型ParamUtils中统一封装异常跳转各Servlet自己catch自己处理入口Servlet统一catch转发错误页新增一个功能新建Servlet 配置URL 复制公共代码新建Action类 路由表加一行查看所有请求入口翻阅所有Servlet类打开路由表即可这个对比还没算上登录校验、日志记录这类横切功能。没有统一入口时加一个日志要动所有Servlet有了统一入口后只在service方法里加几行拦截逻辑就能覆盖所有请求。这就是为什么很多成熟框架都把请求入口集中到一个核心组件上。5.2 这套代码还能继续升级的方向统一入口搭建好后后续优化会非常顺畅。第一个可以考虑的方向是注解注册定义一个Action(/user/login)注解启动时扫描包自动建立路由表省掉手动Map注册那一步。第二个方向是参数封装把request直接绑定到业务方法入参省掉每个Action里反复调ParamUtils。第三个方向是加入拦截器链在入口组件里维护一个过滤器列表按顺序执行校验、日志、权限等逻辑。这些升级都以统一入口为依托不用再去拆散每个Servlet。所以我说这一篇是Servlet优化的基础把这步走牢后面的路会顺很多。最后再分享一个实际体会我最初也犹豫要不要引入框架后来发现原生Servlet自己写一遍这一套最大的收获不是代码量减少而是明白了“请求处理流程”到底是怎么串起来的。以后再看任何框架的入口设计你就能认出它不过是这台“总机接线员”的增强版。如果你正在被一堆重复Servlet困扰建议抽出半天时间按这个思路重写一次跑通之后再看项目里的请求会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑