前阵子接了个活儿,把一个跑了好几年的SpringBoot单体应用迁移到宝兰德BES 9.5.5上。说白了就是信创改造里最典型的一步——把应用从开源Tomcat搬到国产中间件。整个过程不算复杂,但坑是真不少。网上关于SpringBoot部署到BES的资料不多,很多细节都是进了现场才发现的,所以我把这次迁移的完整过程和踩坑记录整理出来,给后面做同类改造的同学省点时间。
这次改造涉及的内容包括:为什么选BES 9.5.5而不是继续用Tomcat、SpringBoot版本如何和BES的Java EE规范对齐、war包怎么打、内嵌容器怎么排除、部署到管理控制台之后出现了哪些诡异的类加载和日志问题,以及最后怎么调通数据源和静态资源。如果你手头也有一个SpringBoot项目要迁移到国产中间件,这篇记录可以直接照着走。
1. 迁移前的方案选型:为什么从Tomcat迁到宝兰德BES
1.1 信创改造中的中间件替换逻辑
最开始接到需求时,团队里有人提出一个很实在的问题:"SpringBoot默认就内嵌了Tomcat,java -jar跑得好好的,为什么还要单独装一个BES?"这个问题几乎每个刚开始做信创改造的人都会问。
答案在于信创改造并不是只看应用本身能不能跑,还要看整条技术链路的国产化覆盖情况。客户在验收时通常会核查操作系统、数据库、中间件这几个关键组件是否在国产化目录内。SpringBoot内嵌的Tomcat属于开源组件,虽然应用层还是自己的代码,但中间件这一层没有完成替换,整个交付物就无法通过合规审查。而且很多单位的运维体系要求中间件统一管理,要有管理控制台、集群能力、监控接口和审计日志。这些能力如果自己用Tomcat拼,工程量很大;直接用BES这类国产应用服务器,基本开箱即得。
所以我给出的结论是:迁移到BES不是技术上的倒退,而是把"应用运行载体"从内嵌容器切换成独立中间件。SpringBoot本身只是一个开发框架,它完全可以跑在外部Servlet容器上,只是大多数开发者习惯了内嵌Tomcat,忘了SpringBoot还有war包部署这条传统路线。
1.2 BES 9.5.5和SpringBoot的兼容性怎么看
宝兰德BES 9.5.5是一款兼容Java EE 8规范的应用服务器。这个版本信息很关键,它直接决定了SpringBoot项目的选型方向。
Java EE 8时代,Servlet规范对应的是javax.servlet命名空间;到了Jakarta EE 9之后,命名空间才改为jakarta.servlet。BES 9.5.5既然兼容Java EE 8,就意味着它对javax命名空间的支持最成熟。所以SpringBoot项目如果还在用javax体系,迁移成本最低,基本不需要改代码里的Servlet相关引用。
基于这个前提,我建议优先考虑以下版本组合:
| 项目组合 | 兼容性评估 | 说明 |
|---|---|---|
| SpringBoot 2.7.x + JDK8 + javax | 最稳妥 | 与BES 9.5.5的Java EE 8模型完全对应,社区案例最多 |
| SpringBoot 2.7.x + JDK11 | 一般可行 | 需确认BES所在主机有对应架构的JDK11 |
| SpringBoot 3.x + JDK17 + jakarta | 风险较高 | BES 9.5.5默认面向javax,需联系厂商确认补丁或后续版本支持情况 |
我自己实际用的组合是SpringBoot 2.7.18 + JDK8。之所以没有上SpringBoot 3,除了命名空间兼容性之外,还有一个现实原因:信创环境经常搭配的麒麟V10、统信UOS等操作系统上,JDK8的对应架构发行版最好找,国产化适配也最成熟。团队里如果还有老工程师不熟悉新版本语法,JDK8的学习成本也最低。
1.3 迁移路径:从内嵌容器到外部war
想明白"为什么迁"和"迁到什么版本"之后,迁移路径就清晰了。
SpringBoot默认启动方式是java -jar app.jar,应用内嵌Tomcat,自己监听端口自己跑。部署到BES之后的启动方式变成"BES加载war包",应用本身不再负责监听HTTP端口,而是由BES统一管理连接、线程池、会话和生命周期。
这个变化会带来一串连锁反应:
- 打包方式从jar变成war;
- 内嵌Tomcat的依赖要么排除、要么改成provided;
- 启动类需要继承
SpringBootServletInitializer; server.port配置可能不再生效,端口由BES决定;- 日志、配置文件的默认路径可能指向BES安装目录;
- 静态资源、JSP、Filter、Listener的加载顺序和类加载机制全部由外部容器接管。
很多团队在迁移时只改了一个打包方式,结果启动报各种ClassCastException和NoSuchMethodError,就是因为没意识到这是一次"运行模型"的变化。后面几个章节我会把每个环节拆开详细说。
2. 迁移前准备和版本对齐
2.1 SpringBoot版本、JDK版本怎么定
版本对齐是迁移之前最不该偷懒的环节。我见过有人直接把SpringBoot 3.2项目打成war丢到BES里,结果启动类加载阶段直接报jakarta.servlet相关的ClassNotFound,整个应用起不来,最后不得不返工改命名空间。
如果你也面临类似情况,我的建议是不要纠结,直接用SpringBoot 2.7.x的最终版本,也就是2.7.18。这个版本是SpringBoot 2.x生命周期里最成熟的一个,大量信创项目都验证过,碰到问题能找到的参考资料也最多。
JDK版本方面,强烈推荐JDK8。不是说JDK11不行,而是信创环境下JDK8的发行版最多、兼容性最好。比如飞腾、鲲鹏、龙芯这些平台上,基本都有对应的JDK8发行版。安装JDK时要注意架构匹配,在ARM架构的机器上装x86的JDK,java -version能装出来但跑起来迟早出问题。装完之后用java -version确认一下,同时看一下系统架构是不是一致。
另外提醒一件事:BES本身是Java应用,也要运行在同一个JDK环境上。BES要求使用哪个JDK版本,以宝兰德官方安装手册为准。你应用用的JDK和BES用的JDK最好保持一致,避免应用编译目标版本和容器运行版本不一致,出现各种莫名其妙的UnsupportedClassVersionError。
2.2 改成war包后的构建细节
版本确定之后,第一步是修改Maven的打包方式。
<packaging>war</packaging>就这一行,构建产物从app.jar变成app.war。简单,但影响深远。
原来的target目录结构是BOOT-INF/classes、BOOT-INF/lib,改完之后变成标准的Servlet规范结构:WEB-INF/classes、WEB-INF/lib。这个变化意味着外部容器可以按照标准方式加载你的应用。
这里有一个附带问题:spring-boot-maven-plugin的repackage目标。如果POM里保留了repackage配置,打成war之后它会生成一个"可执行war",也就是这个war既能丢到BES里部署,也能用java -jar app.war直接跑。可执行war内部会包含org/springframework/boot/loader相关类,依赖里必须保留提供spring-boot-loader的依赖,同时内嵌Tomcat也要以provided形式存在。如果你只打算部署到BES,其实不需要这个能力。
但我不建议把repackage完全关掉。因为开发阶段经常还是想本地java -jar验证一下,保留可执行war反而方便。只要注意一点:war里如果出现了WEB-INF/lib/tomcat-embed-core-*.jar,说明provided范围没写对,这个包会被BES的类加载器优先加载,后续大概率产生类冲突。
2.3 剥离内嵌Tomcat的正确姿势
既然要部署到BES,SpringBoot自带的Tomcat就不能打进war里,不然BES启动web应用时,容器自带Servlet实现和应用内嵌的Tomcat实现同时存在,类加载器分分钟给你表演一场"我是谁我在哪"的ClassCastException。
通常有两种做法:
第一种,在spring-boot-starter-web里排除spring-boot-starter-tomcat,然后再单独引入一个provided范围的spring-boot-starter-tomcat:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>第二种,只排除spring-boot-starter-tomcat,不再额外添加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency>两种方式都能把Tomcat从war包中剥离出去。区别在于:保留一个provided依赖,本地用main方法启动SpringBoot时,内嵌Tomcat仍然可用,开发体验不受影响。只排除不新增的话,本地java -jar会直接报错,因为类路径里找不到Tomcat的类了。
我这次选择了第一种,原因就是不想牺牲本地开发调试的便利性。provided范围不会把Tomcat打进war,所以对BES部署没有影响,是性价比最高的做法。
3. 依赖与配置改造的详细过程
3.1 POM标准配置参考
下面是我这次迁移最终使用的POM核心片段,你可以直接参考。除了打包方式和Tomcat处理之外,还包含了日志排除和JDK版本声明。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <packaging>war</packaging> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency> <!-- 其他业务依赖 --> </dependencies>这里把spring-boot-starter-logging一起排除掉是有意的。BES自带日志框架,如果你用的SpringBoot默认logback,两者会在启动时争抢SLF4J绑定,轻则刷警告,重则日志丢失。后面3.4节我会详细展开。
3.2 启动类改造:继承SpringBootServletInitializer
SpringBoot能通过java -jar直接启动,是因为main方法调用了SpringApplication.run。但部署到外部Servlet容器时,容器不知道你的main方法在哪,它只认标准接口。所以启动类必须继承SpringBootServletInitializer,让外部容器能找到SpringBoot应用上下文。
改造后的启动类长这样:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }注意一点:configure方法里传入的DemoApplication.class必须和你当前的启动类是同一个,不要抄模板时抄成其他类,否则BES启动后访问到的Spring容器是空的,Controller全部404。
另外,如果你的项目里用@ServletComponentScan扫描了@WebServlet、@WebFilter、@WebListener这类注解,在BES上可能会失效。传统应用服务器对Servlet的注册有自己的一套扫描逻辑,和SpringBoot内嵌容器不完全一致。遇到这种情况,建议改为用ServletRegistrationBean、FilterRegistrationBean、ServletListenerRegistrationBean手动注册,虽然代码多一点,但行为最可预期。
3.3 application配置:路径、上下文与资源
SpringBoot内嵌Tomcat时,端口和上下文路径都由application.properties控制。迁到BES之后,情况变了。
先说端口。部署到BES后,HTTP请求先到BES,再由BES转发给应用。应用自己配置的server.port基本不会生效。如果你不删掉这个配置,应用日志里可能显示"Tomcat started on port 8085",但实际访问的还是BES监听的那个端口。这种"端口错觉"在运维排查时特别坑,建议把server.port直接注释掉,让BES统一管理端口。
上下文路径也一样。如果应用之前配置了server.servlet.context-path=/demo,在BES上部署时,这个路径仍然可能生效,但实际访问路径还和BES中的应用部署名有关。我建议把上下文路径保持和BES部署名称一致,少一层映射就少一个问题。我这次直接设置成:
server.servlet.context-path=/demo然后BES控制台部署war时,也把应用名称设为demo。这样访问路径就是http://IP:端口/demo,直观不绕。
静态资源路径也值得检查。SpringBoot默认从classpath:/static读取静态资源,这个在BES下仍然可用。但如果自定义过spring.web.resources.static-locations,注意路径必须写classpath:前缀,否则外部容器找不到。
spring.mvc.static-path-pattern=/static/** spring.web.resources.static-locations=classpath:/static/如果应用有上传文件下载文件的需求,还要注意临时目录变化。BES运行时的user.dir指向的是BES安装目录,而不是你原来java -jar所在的目录。代码里凡是用了相对路径读写的,都要改成绝对路径或者通过配置项动态获取。
3.4 日志冲突处理
日志冲突是这次迁移里我最想吐槽的一个点。表面上看项目没改任何代码,启动时突然出现:
SLF4J: Class path contains multiple SLF4J bindings. SLF4J: Found binding in [jar:file:/.../logback-classic-1.2.12.jar] SLF4J: Found binding in [jar:file:/.../slf4j-log4j12-1.7.25.jar]原因是BES自带的日志框架和SpringBoot的logback同时出现在类路径里,SLF4J不知道该听谁的。严重时还会因为log4j和logback的桥接冲突抛出NoSuchMethodError,应用直接启动失败。
解决方案有三种:
- 排除SpringBoot默认的logback,统一交给BES管理;
- 保留应用的logback,关闭BES的自带日志;
- 用
logback-spring.xml做更细粒度的配置,强制指定绑定。
我这次选的是第一种,也就是在POM里排除spring-boot-starter-logging,让应用内的日志输出统一走BES的日志体系。这样运维只需要看BES一个地方,日志归档、文件清理、监控对接都方便。
但要注意:排除默认logging之后,应用里的org.slf4j.LoggerFactory仍然可用,因为BES带了SLF4J API。只有那些直接依赖logback-classic特有API的代码会受影响,比如显式调用ch.qos.logback.classic.Logger的场合,真遇到这种情况再单独引入logback即可。
3.5 数据源与数据库适配
信创项目普遍搭配达梦数据库,我这次的目标数据库也是达梦。迁移前后数据源的差异主要在三方面:驱动类、URL、连接池兼容性。
达梦的典型配置:
spring.datasource.url=jdbc:dm://127.0.0.1:5236/DMSERVER spring.datasource.driver-class-name=dm.jdbc.driver.DmDriver spring.datasource.username=SYSDBA spring.datasource.password=your_password驱动依赖需要根据达梦官方提供的jar来引入。不同版本坐标不一样,有的用com.dameng:DmJdbcDriver18,有的直接用本地jar安装到Maven仓库。如果公司有统一私服,建议让DBA或者中间件管理员把驱动jar推到私服,避免每台机器手动装。
连接池方面,HikariCP和Druid在BES下都能工作。我这次用的是Druid,因为信创项目普遍要求监控SQL和连接状态。但要注意Druid的版本和JDK8的兼容性,老版本在JDK8下会报UnsupportedOperationException,建议用较新的稳定版。
如果你的项目原来用的是MySQL,现在要切到达梦,那就不能只改依赖和URL了。分页语法、LIMIT关键字、日期函数、自增主键获取方式都不一样。建议迁移前先让开发把所有SQL过一遍,做一个方言不兼容清单,逐条改写。这个工作量不小,但躲是躲不掉的。
4. 部署到BES 9.5.5的完整过程
4.1 安装BES和访问管理控制台
BES的安装过程不算复杂,安装包里一般有图形安装向导和静默安装两种方式。我的建议是生产环境用静默安装,方便重复执行和自动化交付。安装完成之后,启动BES服务,访问管理控制台。
默认情况下,管理控制台的地址一般是http://IP:8080/console或者类似的路径,具体端口和上下文以安装完成后的提示为准。首次登录需要设置管理员密码,这个密码一定要设成强密码,因为BES管理控制台一旦被入侵,整个集群的应用都在别人手里了。
安装目录建议单独规划。不要装在系统盘,也不要把应用war和日志直接丢在BES安装目录里。我习惯建一套目录结构:
/opt/bes/ ├── bes952/ # BES安装目录 ├── apps/ # 应用war包归档 ├── config/ # 应用外部化配置 └── logs/ # 应用日志这样BES升级或重装时,应用和数据不用跟着动。
4.2 两种部署war包方式
BES部署war包有两种常见方式。
第一种是管理控制台部署。登录控制台之后,找到"部署"或"应用管理"入口,上传war包,指定应用名称,点击部署。这种方式适合单机或者集群规模小的情况,界面能看到部署状态,遇到启动失败还能直接看日志。
第二种是直接拷贝到BES的自动部署目录。很多版本支持把war丢到指定目录后自动解压部署。这种方式适合脚本化发布,但需要注意文件权限和部署目录的配置。
我这次用的是控制台部署。原因是迁移初期需要频繁看应用启动日志,控制台里查看比较方便。上传war包之前,我会先手动停掉旧应用,再上传新包。如果直接覆盖上传,BES可能因为war被占用或解压目录冲突产生奇怪的中间状态。
4.3 JVM参数与启动参数传递
BES启动web应用时会创建一个或多个JVM实例。JVM参数默认在启动脚本里配置,比如setEnv.sh或者start.sh里面的JAVA_OPTS。
我这次给应用分配了2G堆内存:
JAVA_OPTS="-Xms2g -Xmx2g -XX:MaxMetaspaceSize=512m -Dspring.config.location=file:/opt/bes/config/application.properties"其中-Dspring.config.location是SpringBoot的外部化配置参数,用来指向应用自己的配置文件。这样做的好处是,war包里不打包生产环境的配置,不同环境直接用不同配置文件,省得每次发版都要改配置重新打war。
还有一个容易被忽略的点:应用代码里如果用System.getProperty("user.dir")获取当前目录,在BES下拿到的是BES的启动目录,不是war解压目录。代码里写相对路径的地方,一定要改成从配置中心或-D参数传入的绝对路径。
4.4 安全加固与信创合规
迁移完成不能光看业务通不通,安全和合规层面的检查也很重要。我总结了几条必须做的事:
第一,修改BES管理控制台的默认账号和密码,关闭不必要的远程管理端口,生产环境建议把控制台绑定到内网管理网段。
第二,如果应用暴露了SpringBoot Actuator端点,必须严格限制访问。特别是/actuator/heapdump,如果被外部访问到,JVM堆内存里的敏感信息会直接泄露,这是SpringBoot常见的安全漏洞之一。最稳妥的做法是只开启health和info,并把所有Actuator端点放到内网访问。
第三,BES对HTTPS、国密算法有支持能力。如果客户要求国密SSL,需要参考BES文档配置对应的加密套件。这个在信创验收里经常会被问到。
5. 常见问题清单与现场排查方法
5.1 高频问题速查表
迁移过程中我记录了一批高频问题,整理成速查表供参考。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
启动报ClassNotFoundException: org.springframework.web.context.WebApplicationContext | war未包含spring-web,或BES类加载顺序异常 | 检查依赖,确保spring-web存在;查看BES是否开启应用优先类加载 |
启动报NoSuchMethodError: javax.servlet.http.HttpServletRequest.getServletContext() | 应用打包了低版本servlet-api | 将servlet-api依赖改为provided或删除 |
| 启动时多个Spring容器同时初始化 | 内嵌Tomcat未彻底排除 | 检查WEB-INF/lib下是否存在tomcat-embed相关jar |
| 日志警告SLF4J绑定冲突 | logback与BES自带日志框架冲突 | 排除spring-boot-starter-logging |
| 应用启动成功但页面404 | 上下文路径配置不一致或静态资源路径错误 | 统一server.servlet.context-path和BES部署名称 |
| 上传文件功能异常 | user.dir变化或临时目录不存在 | 使用绝对路径存储,设置spring.servlet.multipart.location |
| 数据库连接反复断开 | 驱动版本不兼容或空闲连接回收策略问题 | 更换达梦驱动版本,调整连接池参数 |
访问/actuator/heapdump能下载文件 | Actuator端点暴露过多 | 限制端点列表或关闭Actuator |
5.2 案例分析:类加载冲突
迁移中最容易遇到的一类问题就是类加载冲突。BES这类传统应用服务器有一套复杂的类加载机制,父加载器加载容器自身的类,子加载器加载应用的类。当应用war里带了和容器重复的类时,不同加载器加载出来的类即使包路径完全一样,JVM也认为它们是不同的类型,强制转换时就抛出ClassCastException。
我遇到过的一个典型报错:
java.lang.ClassCastException: org.apache.catalina.core.ApplicationHttpRequest cannot be cast to org.apache.catalina.core.ApplicationHttpRequest第一眼看到这个报错非常懵,前后两个类名一模一样,居然还有Cast异常。后来检查war包,发现WEB-INF/lib里躺着tomcat-embed-core-9.x.jar。BES用的是自己内部的Servlet容器实现,war包里的Tomcat类被应用类加载器优先加载,导致同一个类出现两份。
解决办法很直接:清干净target,重新构建,确保tomcat-embed-core不进入WEB-INF/lib。排查时用下面这条命令快速检查:
jar tf app.war | grep "tomcat-embed"只要在WEB-INF/lib下看到tomcat-embed开头的jar,基本就是这个坑。
5.3 案例分析:NoSuchMethodError
另一个高频问题由Servlet API版本不一致引起。应用代码在编译期依赖了较高版本的javax.servlet-api,但war包里又打包了一份旧版本,运行到某个方法时,BES提供的Servlet实现里根本没有这个方法,直接抛NoSuchMethodError。
我遇到的具体报错是:
java.lang.NoSuchMethodError: javax.servlet.http.HttpServletRequest.getServletContext()Ljavax/servlet/ServletContext;排查方法很简单,查看war包的WEB-INF/lib里有没有javax.servlet-api或者servlet-api的jar。如果有,把它从依赖中移除或者改成provided范围,让运行期统一使用BES提供的Servlet API。
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <scope>provided</scope> </dependency>这个建议也适用于其他"应用服务器本来就提供"的API,比如JSP API、EL API、JTA API。凡是BES能提供的,统统用provided,避免和容器打架。
5.4 验证与性能回归
部署成功只代表能启动,不代表业务能用。我做迁移后的第一轮验证,重点覆盖这几类功能:
- 登录认证和会话保持;
- 列表查询和分页;
- 文件上传、下载;
- Excel导出;
- 定时任务;
- WebSocket实时通知。
以上功能最容易受容器切换影响,因为都涉及Servlet规范、HttpSession、文件处理等底层能力。登录和大文件上传是最容易翻车的点,建议优先测。
性能方面,迁移到BES之后如果感觉响应变慢,不要急着甩锅给中间件,先看这几点:JVM堆内存是否充足、数据库连接池是否够用、线程池配置是否合理、是否因为日志冲突导致大量同步写盘。这些点逐一排查完,大部分性能问题都能定位。
最后再说一点个人体会。信创改造最大的成本往往不是中间件本身,而是应用围绕容器形成的各种隐式约定。SpringBoot的内嵌容器把复杂度藏了起来,一旦切到BES这类外部应用服务器,很多"看不见的依赖"都会浮出水面。所以做这类迁移时,提前把依赖梳理、日志统一、配置外部化这三件事做好,后面会省非常多时间。另外,不管多熟悉SpringBoot,部署前都要做一次war包内容检查,看看WEB-INF/lib里到底有哪些和Servlet容器相关的jar,这一步能挡掉80%的启动期问题。