☰
SpringBoot内嵌容器Servlet三大组件注册与顺序控制实践
2026/11/3 15:19:38 网站建设 项目流程

内嵌 Tomcat 跑起来的那一刻,很多人第一反应是:web.xml去哪儿了?以前在传统容器里,Servlet、Filter、Listener 三大组件靠一份 XML 声明得明明白白,容器启动时按顺序加载,谁先谁后、拦截哪个路径,一目了然。换到 SpringBoot 的嵌入式容器之后,web.xml消失了,组件的注册权从"容器读配置文件"变成了"应用自己向容器注册"。这个转变看着只是形式变化,实际影响挺大:注册时机、执行顺序、依赖注入能不能生效,全都跟着变了。

这篇内容就专门聊 SpringBoot Web 开发里注册 Servlet 三大组件这件事。我会把三种注册方式的取舍、注册链路的底层时机、路径映射和顺序控制的细节,以及我这些年踩过的坑,一条条摊开讲。适合已经能用 SpringBoot 起一个 Web 服务、但遇到"自定义 Filter 顺序不对""Servlet 里@Autowired是 null""Listener 启动时拿不到 Bean"这类问题的开发者。纯新手也能看,我会把关键概念用生活化的方式解释清楚;老手可以直接跳到第四、五节的顺序控制和时机部分,那里干货密度最高。

1. 内嵌容器时代,三大组件为什么还得自己注册

1.1 从外置容器的 web.xml 到 SpringBoot 的注册 Bean

在传统的外置 Tomcat 部署模式下,应用和容器是两个独立的东西。你打一个 war 包丢进webapps目录,容器启动时扫WEB-INF/web.xml,读到<servlet>就 new 一个 Servlet 实例,读到<filter>就登记到过滤器链上,读到<listener>就在上下文初始化时回调。整个流程是容器主导,应用只是被动地被装配。这个模型的好处是清晰,坏处是重,改一个映射要重新打包部署。

SpringBoot 换了思路:容器是应用自己拉起来的,那就没必要搞中间配置文件,应用在启动过程中直接把组件注册给容器。你在配置类里new ServletRegistrationBean(...),Spring 容器创建这个 Bean 之后,会把它收集起来,在 Web 容器真正启动前统一塞进ServletContext。这套机制的关键在于,"谁在什么时候调用了ServletContext.addServlet()"这件事,从容器变成了 Spring 自己。

这个转变带来两个直接后果。第一个后果是注册动作可以拿到完整的 Spring 容器能力,你在注册 Bean 里能读配置、能算参数、能按 profile 条件化注册,灵活性比 XML 高一个数量级。第二个后果是组件的生命周期跟 Spring Bean 的生命周期产生了交叉,如果没搞清楚谁创建了谁,就容易出现"注解不生效""属性是 null"这类问题——这也是后面第六节要重点讲的坑。

1.2 三种注册方式的横向对比

SpringBoot 里注册三大组件,实际可选的路子有三条,我用一个表格把它们摆在一起,方便你直接对照选型。

方式核心写法能否控制顺序组件是否为 Spring Bean适用场景
注解扫描@WebServlet/@WebFilter/@WebListener+@ServletComponentScanFilter 无法显式控制否,由容器反射实例化老代码迁移、组件简单且无依赖
注册 BeanServletRegistrationBean/FilterRegistrationBean/ListenerRegistrationBean可以,setOrder()精确控制取决于构造时传的是 new 还是注入的 Bean绝大多数生产场景
原生 API实现ServletContextInitializer,在onStartup里手动addServlet可以,但比较笨拙自行决定需要极端动态化注册的框架层代码

第一列的方式最省事,一个注解搞定,但它有一个致命短板:注解标注的组件由 Servlet 容器反射创建,不经过 Spring 容器,所以组件内部没法用依赖注入。第二列是我推荐的主力方案,注册逻辑和组件本身解耦,顺序、路径、初始化参数都能用代码表达,配置类里还能加@ConditionalOnProperty做开关。第三列属于框架层的玩法,一般写业务代码用不上,除非你要做某种插件化注册。

有一点得提前说清楚:这三种方式可以在同一个项目里共存,但混用的时候顺序关系会变得很难推理。我个人的习惯是,一个新项目只用注册 Bean 方式,注解方式只在接手老代码时作为过渡保留,然后逐个迁移。

2. 注册链路拆解:SpringBoot 到底在什么时候把组件塞进 ServletContext

2.1 ServletContextInitializer 这条总线

ServletRegistrationBean这个类,往上追父类,链条是RegistrationBean→ServletContextInitializerBeans收集 → 最终调用ServletContextInitializer.onStartup(ServletContext)。也就是说,RegistrationBean家族全部实现了一个叫ServletContextInitializer的接口,这个接口只有一个方法onStartup,参数是ServletContext。SpringBoot 在创建 Web 服务器之前,会把容器里所有实现了这个接口的 Bean 捞出来,按 order 排序,然后挨个调用它们的onStartup。

这个时机非常关键。ServletContextInitializer.onStartup的调用发生在ServletContext已经存在、但 Web 服务器还没真正对外提供服务的那一刻。你可以把ServletContext理解成一块公共布告栏,各路组件在上面签字登记,签完之后服务器才开始接客。SpringBoot 做的事情就是:把所有想签字的 Bean 排好队,按顺序让他们上去签。

对应的源码位置在ServletWebServerApplicationContext的selfInitialize方法里,它调用getServletContextInitializerBeans()拿到一个ServletContextInitializerBeans集合,这个集合会在构造时扫描容器里所有ServletContextInitializer类型的 Bean,同时还要处理排序。理解这条总线之后,你就能推理出很多行为,比如为什么FilterRegistrationBean的 order 能生效、为什么组件注册代码里可以安全地读配置。

2.2 order 与 loadOnStartup 不是一回事

这两个词特别容易被混为一谈,我第一次接触的时候也绕了半天。order是RegistrationBean的属性,控制的是在同一次onStartup批量注册中,谁先被注册到 ServletContext。loadOnStartup是 Servlet 规范里的概念,控制的是Servlet 实例化的时机,值越小越早初始化,负数表示容器启动时不初始化,等第一次请求来了再说。

两者的影响范围完全不同。Filter 的 order 决定了请求进来之后过滤器链的执行顺序,这个是实打实影响业务逻辑的。Servlet 的 loadOnStartup 决定了应用启动时这个 Servlet 会不会立刻被init(),对启动耗时和初始化逻辑的执行时机有影响,但对请求路由没有影响。而 Listener 的 order,影响的是多个 Listener 之间谁先收到contextInitialized回调。

还有一个隐含的知识点:RegistrationBean的默认 order 是Ordered.LOWEST_PRECEDENCE,也就是排在最后。所以如果你不显式设置 order,你的 Filter 大概率会排在一个非常靠后的位置,而被框架自带的编码过滤器、安全过滤器甩在后面。我在第一版代码里漏了setOrder,结果自定义的日志 Filter 在字符编码转换之后才执行,日志里全是乱码的请求参数,排查了大半天才反应过来。

3. Servlet 注册实操与路径映射细节

3.1 一个能跑的最小 Servlet 注册示例

先上代码,直接能跑。Spring Boot 3.x 之后 Servlet API 的包名从javax.servlet换成了jakarta.servlet,这个变化让不少从 2.x 升上来的项目直接编译不过,如果你还在 2.x,把 import 改回去就行。

package com.example.demo.servlet; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import java.io.IOException; @Component public class ReportServlet extends HttpServlet { private final ReportService reportService; public ReportServlet(ReportService reportService) { this.reportService = reportService; } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String type = req.getParameter("type"); String result = reportService.generate(type); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write(result); } }

注意这个 Servlet 上加了@Component,也就是它本身是一个 Spring Bean,构造器注入ReportService完全可用。这是注册 Bean 方式相对注解方式最大的优势,我在 3.3 节还会再强调一次。

注册代码放在一个@Configuration类里:

@Configuration public class ServletComponentConfig { @Bean public ServletRegistrationBean<ReportServlet> reportServletRegistration( ReportServlet reportServlet) { ServletRegistrationBean<ReportServlet> registration = new ServletRegistrationBean<>(reportServlet, "/api/report/*"); registration.setName("reportServlet"); registration.setLoadOnStartup(1); registration.setOrder(100); registration.addInitParameter("maxPageSize", "500"); return registration; } }

setName这一行建议加上。不显式指定的话,SpringBoot 会用 Bean 名称推导出一个名字,虽然大多数时候没问题,但一旦出现重名冲突,报错信息会非常隐晦。显式命名之后,容器日志里也能清楚看到是哪个组件被注册了。

3.2 urlPatterns 的四类写法与冲突排查

urlPatterns的规则完全遵循 Servlet 规范,只有四种合法形态,没有第五种:

写法匹配规则典型用法
/api/report精确匹配单一接口路径
/api/report/*前缀匹配一组相关接口
*.json扩展名匹配按后缀分派
/默认 Servlet,兜底匹配极少用于业务 Servlet

这里有一个隐藏的深坑:DispatcherServlet 自己默认就映射在/,你如果再注册一个映射到/的 Servlet,两者会打架,表现为 SpringMVC 的 Controller 全部 404,或者你自己的 Servlet 接不到请求。SpringBoot 的处理方式是,一旦检测到自定义 Servlet 占用了/,会放弃注册默认的静态资源处理,结果就是静态文件也全挂了。

排查这类问题的思路很简单:启动之后翻一下日志里Tomcat started on port(s)之前的那几行,SpringBoot 会打印Mapping servlet: 'xxx' to [xxx]这样的注册日志,把所有映射列一遍。如果看到两个 Servlet 抢同一个路径,日志会给出明确的冲突提示。我一般习惯在开发环境把日志级别调到 DEBUG,org.springframework.boot.web.servlet这个包下面能看到完整的注册过程。

另外补充一个版本差异:Spring Boot 2.x 里ServletRegistrationBean的构造参数还支持setUrlMappings传 List,3.x 之后依然支持,只是推荐用可变参数形式。如果你的项目里出现urlPatterns为空导致的"注册了但访问 404",检查一下是不是传了个空集合进去——空 mappings 的RegistrationBean会被直接跳过,连日志都不打。

3.3 loadOnStartup、异步、文件上传三个进阶开关

setLoadOnStartup这个参数,我在实际项目里主要用于两类场景。一类是需要在应用启动时就建立连接的 Servlet,比如某些老系统的报文解析服务,初始化成本高,让它提前init()能避免第一个请求超时。另一类是反过来,把loadOnStartup设为负数,让 Servlet 按需初始化,减少启动时间。这里有个细节:loadOnStartup设为 0 或正数时,数字越小越早初始化;设为负数时,容器完全不主动初始化,直到第一次请求到达。

异步 Servlet 需要显式开启,registration.setAsyncSupported(true)。不开的话,你在 Servlet 里调request.startAsync()会直接抛IllegalStateException。注意这个开关是有传导性的:如果 Filter 链上任何一个 Filter 没开asyncSupported,异步就会在那一层被掐断。所以做异步的时候,得把整条链上的 Filter 都检查一遍,包括 Spring Security 那条默认链。

文件上传的 Servlet 需要MultipartConfigElement,不能只靠 SpringBoot 的spring.servlet.multipart.*配置,那个配置只对 DispatcherServlet 生效,自定义 Servlet 是另一套:

MultipartConfigElement multipartConfig = new MultipartConfigElement( "/tmp/upload", 10 * 1024 * 1024, // 单文件上限 10MB 50 * 1024 * 1024, // 整个请求上限 50MB 1024 * 1024 // 超过 1MB 写磁盘 ); registration.setMultipartConfig(multipartConfig);

这三个参数怎么定,我的经验是:单文件上限按业务允许的最大附件来,整个请求上限按"一次最多传几个文件"乘以单文件上限再留 20% 余量,写磁盘阈值设成 1MB 左右比较合适——太小会导致频繁 IO,太大则占用堆内存,赶上多个并发上传容易把内存顶上去。

4. Filter 注册实操与执行顺序控制

4.1 FilterRegistrationBean 的完整参数清单

Filter 是三大组件里用得最多、也最容易出问题的一个。先看一份完整的注册代码:

@Bean public FilterRegistrationBean<AccessLogFilter> accessLogFilterRegistration( AccessLogFilter filter) { FilterRegistrationBean<AccessLogFilter> registration = new FilterRegistrationBean<>(filter); registration.setName("accessLogFilter"); registration.addUrlPatterns("/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); registration.setDispatcherTypes( DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR); registration.setAsyncSupported(true); registration.addInitParameter("excludePaths", "/health,/metrics"); return registration; }

setDispatcherTypes这个参数值得单独说。默认值是DispatcherType.REQUEST,意味着这个 Filter 只处理直接到达的请求。如果你的接口用了forward转发,或者用了异步处理,或者走了错误页面,默认配置下 Filter 是不会执行的。我在一个项目里遇到过"错误日志 Filter 记录不到异常请求",查了半天发现是ERROR类型没开,容器内部转发到/error的时候 Filter 根本没进。

setMatchAfter这个参数更冷门,它只对FORWARD和INCLUDE两种类型有意义。设为true时,Filter 在请求派发目标处理完成之后才执行;设为false(默认)则在派发之前执行。这个参数基本只有做内容包装类需求时才会用到,日常开发不用管。

4.2 顺序控制的真相:order 决定一切

Filter 的执行顺序只看order,值越小越靠前。这一点跟很多人直觉相反——直觉上会以为是"先注册的先执行",但实际是"order 小的先执行",注册顺序在排序之后完全失效。这一点如果没搞清楚,调顺序的时候会非常痛苦,因为改代码位置根本没用。

框架自带 Filter 的 order 值可以参考一下,方便你决定自己的 Filter 该插在哪个位置:

Filter大致 order说明
字符编码过滤器Ordered.HIGHEST_PRECEDENCE排在最前,保证后续读参数不乱码
安全框架主过滤器链-100数值为负,通常在业务 Filter 之前
表单内容过滤器Ordered.LOWEST_PRECEDENCE - 9900用于PUT请求解析表单
自定义业务 Filter由你指定建议用正数,明确位置

从这张表能看出一个规律:想读请求参数不出乱码,你的 Filter 必须排在字符编码过滤器后面;想拿到经过安全框架认证的用户信息,必须排在安全链后面。我一般的做法是,业务 Filter 的 order 从 0 开始往上排,每加一个加 10,留出插入空间。这样后续要往中间插一个 Filter,改一个数字就行,不用动其他代码。

还有一个容易忽略的点:多个 Filter 的 order 相同时,执行顺序是不确定的,取决于 Bean 的注册顺序,而这个顺序在不同启动之间可能变化。所以永远不要依赖"order 相同"来做顺序假设,该给不同值就给不同值。

4.3 三个高频 Filter 场景的落地写法

第一个是请求耗时统计。这个需求看着简单,但要注意异常路径也要记录耗时,所以try-finally是必须的,光在chain.doFilter后面写统计代码是不够的。我见过不少实现只在正常返回时打印日志,一旦抛异常就丢了数据,导致性能分析的样本严重偏斜。

第二个是跨域处理。用 Filter 处理跨域的时候,要特别注意预检请求(OPTIONS)的处理,预检请求必须直接返回,不能继续走chain.doFilter,否则会一路走到 DispatcherServlet 然后 405。同时Access-Control-Allow-Origin这类响应头要在chain.doFilter之前设置好,因为响应一旦提交就没法再加头了。

第三个是请求体重复读取。这个坑很经典:Filter 里读了request.getInputStream(),Controller 里就再也读不到了。解决办法是用ContentCachingRequestWrapper包一层,或者自己写一个HttpServletRequestWrapper把输入流缓存成字节数组。注意ContentCachingRequestWrapper的缓存是在请求处理完之后才填充的,如果你想在 Filter 里读完整个请求体再往下传,得自己实现包装类,把流一次性读完然后重放。

关于 Filter 和 Interceptor 的分工,我的判断标准很简单:需要拿到原始HttpServletRequest、需要在 DispatcherServlet 之前介入、需要处理非 SpringMVC 的请求,就用 Filter;需要用到 Spring 的 Handler 信息、需要和 Controller 方法参数打交道,就用 Interceptor。两者不是替代关系,而是层次不同。

5. Listener 注册实操与生命周期时机

5.1 ServletContextListener 做启动预热

ServletContextListener是最常用的 Listener,两个回调方法:contextInitialized在上下文初始化完成时调用,contextDestroyed在上下文销毁时调用。典型的用法是做启动预热,比如把一些配置字典加载到本地缓存,或者建立连接池。

@Component public class WarmUpListener implements ServletContextListener { private final DictCache dictCache; private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); public WarmUpListener(DictCache dictCache) { this.dictCache = dictCache; } @Override public void contextInitialized(ServletContextEvent sce) { scheduler.execute(() -> { try { dictCache.reloadAll(); } catch (Exception e) { sce.getServletContext().log("dict warmup failed", e); } }); } @Override public void contextDestroyed(ServletContextEvent sce) { scheduler.shutdownNow(); } }

注册就一行:

@Bean public ServletListenerRegistrationBean<WarmUpListener> warmUpListenerRegistration( WarmUpListener listener) { ServletListenerRegistrationBean<WarmUpListener> registration = new ServletListenerRegistrationBean<>(listener); registration.setOrder(1); return registration; }

这里我把预热逻辑丢到了单独的线程里执行,而不是直接在contextInitialized里同步跑。原因很简单,contextInitialized是阻塞的,你在里面跑一个耗时 30 秒的初始化,整个应用启动就会多 30 秒,健康检查一直失败,容器编排那边可能直接判定启动超时。异步执行的话,代价是预热完成前有短暂的服务降级窗口,这个得跟业务方提前对齐。

5.2 HttpSessionListener 做会话统计

HttpSessionListener用来监听会话的创建和销毁,比较常见的用途是在线人数统计、会话资源清理。这里有个坑要提醒:在前后端分离的架构下,如果用的是无状态 Token,服务端根本不会创建HttpSession,这个 Listener 永远不会触发。它的前提是应用真的在用 Session,比如用了HttpSession存用户状态,或者某些框架组件依赖 Session。

@Component public class SessionCountListener implements HttpSessionListener { private final AtomicInteger activeSessions = new AtomicInteger(); @Override public void sessionCreated(HttpSessionEvent se) { activeSessions.incrementAndGet(); se.getSession().setAttribute("loginTime", System.currentTimeMillis()); } @Override public void sessionDestroyed(HttpSessionEvent se) { activeSessions.decrementAndGet(); } public int getActiveCount() { return activeSessions.get(); } }

注意sessionDestroyed的触发时机是不可靠的。会话过期销毁是被动触发的,取决于容器的清理线程什么时候跑到,中间可能延迟好几分钟。如果业务对实时性有要求,比如"当前在线人数",靠 Session 计数是不够准的,必须配合心跳机制。这一点我在早期项目里吃过亏,页面上显示的在线人数总是比实际高一截,就是因为过期会话还没被清理。

5.3 Listener 里拿 Spring Bean 的正确姿势

这里要重点区分两种情况,这是全篇最容易出错的地方。

第一种情况,Listener 用ServletListenerRegistrationBean注册,并且传入的是 Spring 管理的 Bean(比如上面那样构造器注入)。这种情况依赖注入完全正常,因为对象是 Spring 创建的。至于回调时机,内嵌容器里ServletContext的初始化被挪到了容器 refresh 的末尾,所以此时大部分 Bean 已经就绪,contextInitialized里拿依赖基本没问题。但有个前提:不要在 Listener 的构造器里做任何依赖 Bean 的初始化动作,因为构造时机更早。

第二种情况,Listener 用@WebListener注解注册。这时候 Listener 实例是 Servlet 容器反射创建的,@Autowired完全无效,字段是 null。解决方案有两个,一是手动从ServletContext里取 Spring 上下文:

@Override public void contextInitialized(ServletContextEvent sce) { WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(sce.getServletContext()); if (ctx != null) { DictCache cache = ctx.getBean(DictCache.class); cache.reloadAll(); } }

二是干脆放弃注解方式,改用注册 Bean。我强烈推荐第二种,因为手动取上下文会让依赖关系变隐式,测试也不好写。

再补充一个AttributeListener的用法。ServletContextAttributeListener可以监听上下文属性的增删改,HttpSessionAttributeListener可以监听会话属性的变化。这两个在调试时特别有用,比如你怀疑某个配置项被别的组件覆盖了,加个 Listener 打印一下属性变更日志,一眼就能看出来是谁干的。

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

6.1 问题速查表

下面这张表是我整理过的高频问题清单,基本覆盖了日常能遇到的绝大部分情况。

现象最可能原因排查动作解决方式
Servlet 访问 404urlPatterns 为空或被 DispatcherServlet 抢占查启动日志的 Mapping 行检查映射路径,避免与/冲突
Filter 不执行dispatcherTypes 没包含当前派发类型打印DispatcherType确认补上FORWARD/ERROR/ASYNC
Filter 顺序不对没设 order 或设了相同值启动日志里看注册顺序显式设置不同的 order
组件内@Autowired为 null组件由容器反射创建,非 Spring Bean检查注册方式改用RegistrationBean传入 Bean
异步请求报IllegalStateException链路中某个 Filter 未开启异步支持逐层检查asyncSupported全链路开启
请求参数乱码Filter 排在编码过滤器之前确认 order 大小关系把 order 调到编码过滤器之后
上传大文件失败自定义 Servlet 未配MultipartConfig检查是否设置multipartConfig补上配置,按业务定三个阈值
Listener 启动报 NPE构造器里用了依赖 Bean打日志确认执行时机移到contextInitialized里

表格里最后一行的问题特别隐蔽,因为报错堆栈指向的是 Listener 的构造器,而你注入了依赖之后会觉得"我都注入了怎么还空"。根本原因是构造器执行时 Spring 还没把依赖塞进来,构造函数注入的字段在构造器体内本来就是 null。

6.2 我踩过的几个坑与避坑清单

第一个坑是重复注册。我曾经在一个项目里同时用了@WebFilter和FilterRegistrationBean注册同一个 Filter,想着双保险,结果请求进来 Filter 执行了两次,日志全重复。注解扫描和注册 Bean 是两套独立的注册通道,同一个类走两条路会被注册成两个不同的 Filter 实例。排查这类问题有个简单办法:在doFilter第一行打印this.hashCode(),如果看到不同的 hashCode,就说明实例化了多次。

第二个坑是Filter里读不到路径变量。这个跟 Filter 的工作层次有关,Filter 拿到的request.getRequestURI()是原始 URI,不是 SpringMVC 解析出来的路径。如果你在代码里配了server.servlet.context-path,URI 前面还会多一段前缀,做路径匹配的时候必须把这个前缀算进去。我见过有人写死/api/user/*做拦截,部署到带 context-path 的环境之后整个拦截逻辑全都失效,而且不会有任何报错。

第三个坑是Listener的关闭顺序。contextDestroyed执行的时候,Spring 容器可能已经在销毁 Bean 了,你在这里面调用的依赖 Bean 有可能已经处于不可用状态。稳妥的做法是,销毁逻辑只做最基本的事情,比如关掉自己创建的线程池、刷一下本地缓冲区,不要依赖业务 Bean。如果一定要做,用SmartLifecycle或@PreDestroy会更可控,因为它们的执行时机跟 Spring 生命周期绑定,不受 Servlet 容器调用顺序影响。

第四个坑是开发环境和生产环境的行为差异。开发时可能开了热部署,ServletContext会被重建,组件的注册和销毁会执行多次,一些有状态的 Listener 会算错数。我一般会在contextInitialized和contextDestroyed里各打一行带时间戳的日志,环境一换就能立刻看出来是不是重复执行了。

关于 register 这件事,最后再提一个我自己的取舍原则:凡是需要顺序保证的、需要依赖注入的、需要按环境开关的,一律用RegistrationBean;只有那种完全独立、无状态、不需要任何 Spring 能力的简单组件,才考虑用注解方式图个省事。这条线划清楚之后,我在多个项目里基本没再遇到过注册相关的诡异问题。

还有个小技巧分享一下:在配置类上加一个@ConditionalOnProperty(prefix = "app.servlet", name = "report.enabled", havingValue = "true"),把整套组件注册包起来,这样本地调试不想加载某个重组件的时候,改一下配置文件就行,不用动代码。这个习惯是从一次线上紧急回滚里养成的——当时某个自定义 Servlet 的初始化逻辑拖慢了整个启动,改配置比改代码快得多。

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

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

立即咨询