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 properties
System.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!");
}
// scan
long 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;
}
// startup
Map<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);
// shutdown
Runtime.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生成,一塌糊涂。。。