多SpringBoot项目同进程下统一启动
1.背景
使用SpringBoot技术栈进行REST HTTP接口开发服务时,一般来说如果模块较多或者涉及多人协作开发,大家会不自觉的将每个模块独立成一个单独的项目进行开发,部署时则将每个服务进行单独部署和运行。服务间的调用则通过FeignClients,服务的接入、负载、路由则是在前面摆个SpringCloud Gateway,同时服务注册/发现、配置则使用一个统一的Nacos。这样做好处显而易见,例如:开发时的代码冲突及分支合并、运行时系统资源分配及性能优化等都不打架、对某个服务的扩缩容也方便、K8S容器化也方便。这些对于公有云来说确实就应该这样搞,但是假如哪天要私有化售卖和交付的话,这样又问题颇多:一个服务一个K8S容器势必导致服务器数量的增加、私有自动化部署成本大等。那么面对这样的情况,最好的方式无非是:单独部署和统一集成部署双支持。想要的结果现在已经很明确了,可是咋搞呢?现在已经这样了,难道把所有分散的子项目合并到一个项目下,然后用一个SpringApplication.run()去启动?那么问题来了:之前是N个服务,现在是一个服务,人肉合并代码工作量大,Nacos中的一堆配置要改要整合等;那么如果能让nacos中的配置保持不变,各个服务代码不变或者很少变动则一种比较合适的方案。
2. 实现方案
基于上面的背景和前提,这里给出一个具体的实例,出于时间问题,我就不写一个完成的Demo了。方法和套路懂了,自行就能写出测试验证Demo。
2.1 Nacos服务注册说明
统一启动服务通过使用 SpringApplicationBuilder 启动了多个独立的 Spring Boot 应用实例,但由于它们共享了同一个 JVM 环境,而Nacos 客户端使用全局配置单例,这样会导致多个服务实例在同一进程中运行时,它们共享相同的 Nacos 客户端,后面的服务实例在注册时会覆盖前面的注册信息,从而导致只注册了一个服务。为了解决这个问题需要在 SpringApplicationBuilder 中为每个实例设置不同的 Nacos 配置前缀来隔离它们的注册信息,这样它们在 Nacos 中会被识别为独立的服务实例,从而避免了注册信息的覆盖问题。同样的子服务中的代码如果使用了@Value(“${spring.application.name}”)的方式获取服务名,统一聚合启动时同样有覆盖问题。
2.2 子服务约束
- 为避免统一集成部署时jar包冲突问题,要求所有子服务相关依赖的版本必须使用根POM中统一定义的版本(当前我们各个子服务就是这样做的);// 之前各个子服务独立运行肯定不会有问题,当所集成到一个进程下运行之后,如果某些依赖的版本不一致,那么就会出现jar包冲突问题。
- 所有子服务的根包名相对统一,例如,都是com.china.xxx(这一点一般来说都满足,毕竟依赖管理中的groupId是重要标识);所有子服务的SpringBootApplication启动类增加一个自定义注解用于进行子服务启动类的发现。 // 这条是可选的,只我不想在统一启动服务中去做一个配置,于是选择了用反射扫描的方式去进行子服务启动类发现的方式。
- 所有子服务必须打原包,不能使用SpringBoot 的"FAT JAR"方式打包(skip掉spring-boot-maven-plugin的repackage即可;);spring-boot-maven-plugin 是 Spring Boot 提供的一个插件,用于简化 Maven 项目中的构建、打包和运行过程。它默认会执行一个名为 repackage 的任务,将项目的 JAR 重新打包成一个包含所有依赖的可执行 JAR(也称为 Fat JAR 或 Uber JAR)。Fat JAR中的SpringBootApplication启动类不太好直接拿到; // 反正我们的子项目都用了,因此要增加这条约束。
- 代码中禁止通过@Value(“${spring.application.name}”)获取服务名(解决方案:服务名都是固定的,定义一个常量即可;);
2.3 统一启动服务实现思路
- 引用所有子服务的JAR包;
- 使用Reflections.getTypesAnnotatedWith的扫描方式获取所有子服务的SpringBootApplication启动类;同时使用VM参数支持子服务的In和Out的配置。
- 使用SpringApplicationBuilder分别启动各个子服务,同时为每个子服务创建的一个新的 ConfigurableApplicationContext 实例,以确保每个服务都在独立的上下文中运行;
- 统一启动服务通过使用 SpringApplicationBuilder 启动了多个独立的 Spring Boot 应用实例,但由于它们共享了同一个 JVM 环境,而Nacos 客户端使用全局配置单例,这样会导致多个服务实例在同一进程中运行时,它们共享相同的 Nacos 客户端,后面的服务实例在注册时会覆盖前面的注册信息,从而导致只注册了一个服务。为了解决这个问题需要在 SpringApplicationBuilder 中为每个实例设置不同的 Nacos 配置前缀来隔离它们的注册信息,这样它们在 Nacos 中会被识别为独立的服务实例,从而避免了注册信息的覆盖问题。
- 增加Shutdown钩子以确保创建的ApplicationContext 实例可以被优雅的关闭;
2. 主要实现
2.1 项目结构
项目结构大致如下,一共3个项目:bw-server-all是统一启动服务项目,bw-job和bw-ai-app是2个独立的子服务项目;
--统一启动服务:bw-server-all
├── /bw-server-all
│ ├── /src
│ │ ├── /main
│ │ │ ├── /java
│ │ │ │ └── /com
│ │ │ │ └── beam
│ │ │ │ └── work
│ │ │ │ └── server
│ │ │ │ └── all
│ │ │ │ └── boot
│ │ │ │ └── Bootstrap.java // 统一启动类
│ │ │ └── /resources
│ │ │ └── application.yml
│ │ └── /test
│ │ └── ...
│ └── pom.xml--子服务1:bw-job
├── /bw-job
│ ├── /src
│ │ ├── /main
│ │ │ └── /java
│ │ │ └── /com
│ │ │ └── beam
│ │ │ └── job
│ │ │ └── provider
│ │ │ └── JobApplicationRun.java // 启动类
│ │ └── /resources
│ │ └── application.yml
│ └── pom.xml--子服务2:bw-ai-app
└── /bw-ai-app├── /src│ ├── /main│ │ └── /java│ │ └── /com│ │ └── beam│ │ └── ai│ │ └── app│ │ └── provider│ │ └── ApplicationBootstrap.java // 启动类│ └── /resources│ └── application.yml└── pom.xml
2.2 启动类扫描自定义注解
/*** BwApplication** @author chenx*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface BwApplication {/*** service name*/String name() default "";/*** contextId:default: name + "-context"*/String contextId() default "";
}
2.3 子服务启动类示例
2.4 子服务打包示例
2.4 统一启动服务实现
2.4.1 引用所有子服务的JAR包
2.4.2 BootstrapHelper实现
package com.beam.work.server.all.boot;import com.umbrella.work.common.annotation.BwApplication;
import com.umbrella.work.common.exception.BeemRuntimeException;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;
import org.slf4j.Logger;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.core.env.StandardEnvironment;import java.io.PrintStream;
import java.util.*;/*** BootstrapHelper** @author chenx*/
public class BootstrapHelper {private static final String[] SCAN_BASE_PACKAGES = {"com.beam", "com.umbrella.work", "com.beem"};private BootstrapHelper() {// do nothing}/*** getApplications** @param appIn* @param appOut* @param nacosGroup* @return*/public static Map<String, SpringApplicationBuilder> getApplications(String appIn, String appOut, String nacosGroup) {if (StringUtils.isEmpty(nacosGroup)) {throw new BeemRuntimeException("nacosGroup is empty!");}Set<String> in = getAppSet(appIn);Set<String> out = getAppSet(appOut);Reflections reflections = new Reflections(new ConfigurationBuilder().forPackages(SCAN_BASE_PACKAGES).addScanners(Scanners.TypesAnnotated));Set<Class<?>> annotatedClasses = reflections.getTypesAnnotatedWith(BwApplication.class);Map<String, SpringApplicationBuilder> map = new HashMap<>(annotatedClasses.size());for (Class<?> clazz : annotatedClasses) {BwApplication annotation = clazz.getAnnotation(BwApplication.class);String appName = annotation.name().toLowerCase();if (!isLoadApplication(appName, in, out)) {continue;}String contextId = StringUtils.isEmpty(annotation.contextId()) ? appName + "-context" : annotation.contextId();SpringApplicationBuilder builder = new SpringApplicationBuilder();builder.sources(clazz).environment(new StandardEnvironment()).properties("spring.application.name=" + appName, "spring.main.application-context-id=" + contextId, "spring.main.allow-bean-definition-overriding=true", "spring.cloud.nacos.discovery.group=" + nacosGroup, "spring.cloud.nacos.discovery.service=" + appName).web(WebApplicationType.SERVLET);map.putIfAbsent(appName, builder);}return map;}/*** printSeparatedLog** @param logger* @param info*/public static void printSeparatedLog(Logger logger, String info) {if (Objects.isNull(logger)) {return;}String separator = getSeparator(info);logger.info(separator);logger.info(info);logger.info(separator);}/*** getSeparator** @param info* @return*/private static String getSeparator(String info) {if (StringUtils.isEmpty(info)) {return "";}StringBuilder sb = new StringBuilder();for (int i = 0; i < info.length(); i++) {sb.append("=");}return sb.toString();}/*** getAppSet*/private static Set<String> getAppSet(String apps) {if (StringUtils.isEmpty(apps)) {return Collections.emptySet();}Set<String> set = new HashSet<>();String[] array = apps.split(",");for (String entry : array) {String appName = entry.toLowerCase();if (StringUtils.isEmpty(appName) || set.contains(appName)) {continue;}set.add(appName);}return set;}/*** isLoadApplication*/private static boolean isLoadApplication(String appName, Set<String> in, Set<String> out) {if (StringUtils.isEmpty(appName)) {return false;}if (CollectionUtils.isEmpty(in) && CollectionUtils.isEmpty(out)) {return true;}// APP_IN优先if (!CollectionUtils.isEmpty(in)) {return in.contains(appName.toLowerCase());}if (!CollectionUtils.isEmpty(out)) {return !out.contains(appName.toLowerCase());}return true;}/*** BootstrapBanners*/public enum BootstrapBanners {START(new String[]{" ###### ######## ### ######## ######## ","## ## ## ## ## ## ## ## ","## ## ## ## ## ## ## "," ###### ## ## ## ######## ## "," ## ## ######### ## ## ## ","## ## ## ## ## ## ## ## "," ###### ## ## ## ## ## ## "}),FAIL(new String[]{" _______ ___ __ __ ","| ____| / \\ | | | | ","| |__ / ^ \\ | | | | ","| __| / /_\\ \\ | | | | ","| | / _____ \\ | | | `----.","|__| /__/ \\__\\ |__| |_______|"}),;private final String[] banner;BootstrapBanners(String[] banner) {this.banner = banner;}/*** printBanner** @param logger*/public void printBanner(Logger logger) {if (this.banner != null) {for (String line : this.banner) {logger.warn(line);}}}/*** printBanner** @param out*/public void printBanner(PrintStream out) {if (this.banner != null) {for (String line : this.banner) {out.println(line);}}}}
}
2.4.3 Bootstrap实现
package com.beam.work.server.all.boot;import com.umbrella.work.common.exception.BeemRuntimeException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;import java.util.HashMap;
import java.util.Map;/*** Bootstrap** @author chenx*/
@Slf4j
public class Bootstrap {private static final String PROPERTY_KEY_APP_IN = "APP_IN";private static final String PROPERTY_KEY_APP_OUT = "APP_OUT";private static final String PROPERTY_KEY_NACOS_GROUP = "NACOS_GROUP";/*** VM参数说明:* 1. APP_IN(可选): 要启动的服务(多个服务逗号分割);* 2. APP_OUT(可选): 不要启动的服务(多个服务逗号分割);* 3. NACOS_GROUP(必须):Nacos组名,对应根POM中profile.nacos.group(由于统一启动服务不是Springboot项目因此这里走VM参数配置);* 4. 某个服务同时存在于APP_IN和APP_OUT时APP_IN优先;* 5. 示例:-DAPP_IN=bw-job,bw-ai-app -DNACOS_GROUP=bw-dev* <p>* 服务发现机制:* 1. 扫SCAN_BASE_PACKAGES下所有含有@BwApplication注解的Springboot启动类;* 2. BwApplication.name():服务名称(为空则忽略该服务启动类);* 3. BwApplication.contextId():每个子服务的启动都使用 SpringApplicationBuilder 创建的一个新的 ConfigurableApplicationContext 实例,以确保每个服务都在独立的上下文中运行;* <p>* Nacos服务注册说明:* 统一启动服务通过使用 SpringApplicationBuilder 启动了多个独立的 Spring Boot 应用实例,但由于它们共享了同一个 JVM 环境,* 而Nacos 客户端使用全局配置单例,这样会导致多个服务实例在同一进程中运行时,它们共享相同的 Nacos 客户端,后面的服务实例在注册时会覆盖前面的注册信息,从而导致只注册了一个服务。* 为了解决这个问题需要在 SpringApplicationBuilder 中为每个实例设置不同的 Nacos 配置前缀来隔离它们的注册信息,这样它们在 Nacos 中会被识别为独立的服务实例,从而避免了注册信息的覆盖问题。** <p>* 支持聚合启动子服务开发规范:* 1. SpringBootApplication启动类增加@BwApplication注解;* 2. 为避免jar包冲突,所有依赖版本必须使用根POM中统一定义的版本;* 3. 服务根包名为:"com.beam", "com.umbrella.work", "com.beem" 范围之一;* 4. 服务必须打原包,不能使用SpringBoot 的"FAT JAR"方式打包(skip掉spring-boot-maven-plugin的repackage即可);* 5. 代码中禁止通过@Value("${spring.application.name}")获取服务名(原因:统一聚合启动时同样有覆盖问题;解决方案:服务名都是固定的,定义一个常量即可;);*/public static void main(String[] args) {try {// system propertiesSystem.setProperty("java.net.preferIPv4Stack", "true");String appIn = System.getProperty(PROPERTY_KEY_APP_IN);String appOut = System.getProperty(PROPERTY_KEY_APP_OUT);String nacosGroup = System.getProperty(PROPERTY_KEY_NACOS_GROUP);if (StringUtils.isEmpty(nacosGroup)) {throw new BeemRuntimeException("Missing VM-Args NACOS_GROUP!");}// scanlong begin = System.currentTimeMillis();Map<String, SpringApplicationBuilder> appMap = BootstrapHelper.getApplications(appIn, appOut, nacosGroup);if (MapUtils.isEmpty(appMap)) {BootstrapHelper.printSeparatedLog(log, "No Valid Application Need to Bootstrap!");return;}// startupMap<String, ConfigurableApplicationContext> appContextMap = new HashMap<>();for (Map.Entry<String, SpringApplicationBuilder> entry : appMap.entrySet()) {String appName = entry.getKey();SpringApplicationBuilder builder = entry.getValue();ConfigurableApplicationContext applicationContext = builder.run(args);appContextMap.putIfAbsent(appName, applicationContext);String logInfo = appName + " Startup Done";BootstrapHelper.printSeparatedLog(log, logInfo);}long time = System.currentTimeMillis() - begin;BootstrapHelper.BootstrapBanners.START.printBanner(log);log.info("All Application Startup Complete. time: {}", time);// shutdownRuntime.getRuntime().addShutdownHook(new Thread(() -> {for (Map.Entry<String, ConfigurableApplicationContext> entry : appContextMap.entrySet()) {String appName = entry.getKey();ConfigurableApplicationContext appContext = entry.getValue();appContext.close();String logInfo = appName + " Shutdown Done";BootstrapHelper.printSeparatedLog(log, logInfo);}log.info("All Application Shutdown Complete.");}));} catch (Exception ex) {BootstrapHelper.BootstrapBanners.FAIL.printBanner(log);log.error("All Application Startup Error!", ex);}}
}
3. 验证
3.1 启动验证
日志太长了,一屏幕截不下;
3.2 Nacos服务注册验证
1、bw-ai-app服务
2、bw-job服务
从nacos中可以看到每个服务都按照预期进行了注册,这样前面的SpringCloud Gateway服务之前配置好个各种routes都不用更改;
4. 结束语
上面就是说了:“出于时间问题,我就不写一个完成的Demo了。方法和套路懂了,自行就能写出测试验证Demo。”,现在回过头来看,这玩意真的不难,不过如果没有想到又或者没有参考的话,好像很多人还是真不知道咋弄。毕竟SpringApplicationBuilder不常被用到,毕竟SpringApplication.run(XXX.class, args)写法上太简单,执行却又太重了,里面的1234567好像不去专门背几个八股文,又真的能有几个人能条理清晰的说出其要点呢(SpringBoot不就是一个希望大家使用简单的开发框架吗,TMD,面试却卷的要死)?所以我觉得就算是偷懒也得给出个实例参考(毕竟在我看来,不给出一个完整的Demo好像并不是那么的对读者负责,因此我也只能有图有真相了)。
封面由微软AI生成,一塌糊涂。。。