服务端模块化架构设计|网关路由模块化支持与条件配置
本专栏 将通过以下几块内容来搭建一个 模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展 的后端服务
网关路由模块化支持与条件配置(本文)
未完待续......
通过添加启动模块来任意组合和扩展功能模块
-
示例1:通过启动模块
juejin-appliaction-system
将juejin-user(用户)
和juejin-message(消息)
合并成一个服务减少服务器资源的消耗,通过启动模块juejin-appliaction-pin
来单独提供juejin-pin(沸点)
模块服务以支持大流量功能模块的精准扩容 -
示例2:通过启动模块
juejin-appliaction-single
将juejin-user(用户)
,juejin-message(消息)
,juejin-pin(沸点)
直接打包成一个单体应用来运行,适合项目前期体量较小的情况
PS:示例基于IDEA + Spring Cloud
为了能更好的理解本专栏中的模块化,建议读者先阅读 服务端模块化架构设计|项目结构与模块化构建思路
网关路由问题
一般情况下,我们会配置网关的路由规则按照请求路径的前缀进行匹配
以juejin-user(用户)
和juejin-message(消息)
这两个模块来举例
路径以/juejin-user
为前缀的请求会被路由到juejin-user
对应的服务
路径以/juejin-message
为前缀的请求会被路由到juejin-message
对应的服务
但是现在我们把juejin-user(用户)
和juejin-message(消息)
这两个模块合并了,使用juejin-appliaction-system
作为启动模块来提供服务
这个时候,要不前端把/juejin-user
和/juejin-message
作为前缀的接口都改成/juejin-appliaction-system
作为前缀(如果是用nginx
来代理请求的也要修改接口映射配置),要不后端修改网关的路由配置,把/juejin-user
和/juejin-message
作为前缀的接口都路由到juejin-appliaction-system
这个服务上
但是我们的模块是可以任意开分组合的,每次组合的情况不一样,需要路由的服务也都不一样,那就要每次都手动修改,很不方便,所以接下来我们就来解决这个问题
创建网关服务
我们先添加一个网关模块juejin-gateway
因为网关不太可能会和其他业务模块合并,所以这里直接添加启动类作为一个单独的服务
在build.gradle
中只需要引入spring-cloud-starter-gateway
就行了,因为其他的配置我们已经在allprojects
中配置好了
最后添加bootstrap.yml
就行了
我们来启动服务
嗯?报错了,因为发现集成了Spring MVC
Spring Cloud Gateway
是基于Reactive
的,所以和spring-boot-starter-web
模块有冲突
不过解决方案也已经给我们了,设置spring.main.web-application-type=reactive
或者移除spring-boot-starter-web
模块
设置属性就不用说了,大家看有什么办法能移除spring-boot-starter-web
模块呢
没错,就是在依赖这个模块之前判断是否是juejin-gateway
,在 服务端模块化架构设计|项目结构与模块化构建思路 文章里面也有提到
dependencies {
if (project.name != "juejin-gateway") {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
//其他依赖...
}
但是这样还是不行,因为我们的juejin-basic
也引入了spring-boot-starter-web
模块,会导致juejin-gateway
还是会引入spring-boot-starter-web
模块
不过网关一般来说不会和业务有太大的关系,所以我们可以直接让juejin-gateway
也不依赖juejin-basic
,我们稍微改进一下写法
var excludeBasic = ['juejin-basic', 'juejin-gateway']
var excludeWeb = ['juejin-gateway']
dependencies {
if (!excludeBasic.contains(project.name)) {
implementation project(':juejin-basic')
}
if (!excludeWeb.contains(project.name)) {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
//其他依赖...
}
定义两个变量来指定需要排除juejin-basic
和spring-boot-starter-web
的模块,这样看起来就更简洁了
然后就启动成功啦
手动路由配置
先来看看手动配置
我们只需要在配置文件中添加两个路由即可,当请求路径匹配/juejin-user/**
或/juejin-message/**
时,路由到juejin-application-system
服务
上面也提到过,这种方式麻烦的地方是,需要每次根据我们模块的组合情况修改路由配置,就算可以用配置中心和动态刷新解决重新打包和重启服务的问题,也还是需要手动修改配置文件
所以有没有一种方式可以做到自动配置呢,大家有兴趣可以先停下来思考一会儿,看看能不能想到一种解决方案
自动路由配置
要实现自动配置,首先我们的各个启动服务需要知道自己包含了几个模块,其次要把各个启动服务包含的模块信息传递给网关
如何获得模块信息
我们的启动服务是通过在build.gradle
中依赖其他模块的,所以我们有没有可能在打包时拿到依赖信息然后在resources
目录下生成一个路由文件,这样我们就可以通过读取这个路由文件来获得当前服务包含的模块了
在build.gradle
的allprojects
中添加脚本代码
processResources {
//资源文件处理之前
doFirst {
Set<String> mSet = new HashSet<>()
//遍历所有的依赖
project.configurations.forEach(configuration -> {
configuration.allDependencies.forEach(dependency -> {
//如果是我们项目中的业务模块则添加该模块名称
if (dependency.group == 'com.bytedance.juejin') {
mSet.add(dependency.name)
}
})
})
//移除,基础模块不需要路由
mSet.remove('juejin-basic')
//如果包含了业务模块
if (!mSet.isEmpty()) {
//获得资源目录
File resourcesDir = new File(project.projectDir, '/src/main/resources')
//创建路由文件
File file = new File(resourcesDir, 'router.properties')
if (!file.exists()) {
file.createNewFile()
}
//将模块信息写入文件
Properties properties = new Properties()
properties.setProperty("routers", String.join(',', mSet))
OutputStream os = new FileOutputStream(file)
properties.store(os, "Routers generated file")
os.close()
}
}
}
给processResources
添加一个前置处理逻辑,遍历所有依赖并筛选出其中的业务模块,在resources
目录下创建router.properties
,将业务模块信息写入文件中
这样在我们build
或者bootJar
的时候就会自动生成对应的文件了
我还额外添加了一个clean
时候的后置逻辑
clean {
doLast {
File resourcesDir = new File(project.projectDir, '/src/main/resources')
File file = new File(resourcesDir, 'router.properties')
if (file.exists()) {
file.delete()
}
}
}
在执行clean
之后,删除生成的路由文件
如何传递模块信息
最开始我想到的一种方式就是使用RPC
如Feign
来同步数据
我们可以在服务启动时,调用juejin-gateway
提供的Feign
接口来注册路由,但是这需要保证juejin-gateway
在其他服务之前启动,一个不注意juejin-gateway
没启动,Feign
就会调用失败导致路由注册不上,需要有个定时任务不断同步路由信息
那么反过来呢,juejin-gateway
主动调用所有服务提供的Feign
接口来同步路由信息,每隔一段时间同步一次来保证路由的及时性和成功率
不管是哪种都需要额外添加Feign
这类的RPC
接口,然后开一个定时任务,总觉得不够优雅
所以我们为什么不借助注册中心这个现有的组件呢?
我们可以将这个信息放到服务实例的metadata
字段中,然后通过监听服务注册的心跳事件HeartbeatEvent
来同步路由信息
添加Metadata
我们在juejin-basic
中添加一个通用组件RouterRegister
@Component
public class RouterRegister {
/**
* 监听服务注册前置事件
*/
@EventListener
public void register(InstancePreRegisteredEvent event) throws Exception {
//读取 router.properties 资源文件
ClassPathResource resource = new ClassPathResource("router.properties");
//加载到 Properties 中
Properties properties = new Properties();
try (InputStream is = resource.getInputStream()) {
properties.load(is);
}
//获得 routers 值
String routers = properties.getProperty("routers");
//写入 metadata 中
Map<String, String> metadata = event.getRegistration().getMetadata();
metadata.put("routers", routers);
}
}
我们监听服务注册的前置事件InstancePreRegisteredEvent
读取router.properties
文件中生成的路由信息添加到metadata
中
这样所有的服务启动后就会将自身包含的模块信息注册上去
在注册中心上也可以看到路由信息已经同步上去了
同步路由信息
我们在juejin-gateway
中自定义一个JuejinRouterDefinitionLocator
通过监听HeartbeatEvent
来定时刷新路由
@Component
@RequiredArgsConstructor
public class JuejinRouterDefinitionLocator implements RouteDefinitionLocator {
/**
* 服务发现组件
*/
private final DiscoveryClient discoveryClient;
/**
* 路由缓存
*/
private volatile List<RouteDefinition> routeDefinitions = Collections.emptyList();
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
return Flux.fromIterable(routeDefinitions);
}
/**
* 监听心跳事件
*/
@EventListener
public void refreshRouters(HeartbeatEvent event) {
//新的路由
List<RouteDefinition> newRouteDefinitions = new ArrayList<>();
//获得服务名
List<String> services = discoveryClient.getServices();
for (String service : services) {
//获得服务实例
List<ServiceInstance> instances = discoveryClient.getInstances(service);
if (instances.isEmpty()) {
continue;
}
//这里直接拿第一个
ServiceInstance instance = instances.get(0);
//获得 metadata 中的 routers
String routersMetadata = instance.getMetadata()
.getOrDefault("routers", "");
String[] routers = routersMetadata.split(",");
//生成新的 RouteDefinition
for (String router : routers) {
RouteDefinition rd = new RouteDefinition();
rd.setId("router@" + service);
rd.setUri(URI.create("lb://" + service));
PredicateDefinition pd = new PredicateDefinition();
pd.setName("Path");
pd.addArg("juejin", "/" + router + "/**");
rd.setPredicates(Collections.singletonList(pd));
FilterDefinition fd = new FilterDefinition();
fd.setName("StripPrefix");
fd.addArg("juejin", "1");
rd.setFilters(Collections.singletonList(fd));
newRouteDefinitions.add(rd);
}
}
//更新缓存
this.routeDefinitions = newRouteDefinitions;
}
}
当监听到心跳事件HeartbeatEvent
后,通过DiscoveryClient
获得最新的metadata
信息刷新路由配置(不要忘了删除配置文件中的路由配置)
这样,不管我们如何组合拆分模块,网关都能自动进行路由适配
条件配置
看到这里大家可能会注意到一个问题,那就是当我们选择单体应用的模式时,路由这一块的组件会不会有什么问题,或者说那些只有在微服务的架构下才需要的组件该怎么兼容呢
对于这个问题,我的一个解决思路是,通过条件配置来处理
我们可以在juejin-basic
中定义@JuejinBootApplication
和@JuejinCloudApplication
来分别配置单体应用和微服务
@JuejinBootApplication
我们先定义一个JuejinBootConfiguration
来配置单体应用和微服务都需要的组件,如数据源
@Configuration
public class JuejinBootConfiguration {
@Bean(initMethod = "init", destroyMethod = "close")
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
}
再定义一个@JuejinBootApplication
来启用该配置
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(JuejinBootConfiguration.class)
@ComponentScan(
basePackages = "com.bytedance.juejin",
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com.bytedance.juejin.basic.*"
)
)
@SpringBootApplication
public @interface JuejinBootApplication {
}
我们把basePackages
指定为com.bytedance.juejin
,这样所有的模块都能被扫描到,同时添加一个过滤器排除com.bytedance.juejin.basic
也就是juejin-basic
下的组件方便我们手动进行条件配置
JuejinBootConfiguration
中配置和微服务无关的组件,如数据源,不管是单体应用还是微服务都需要配置的组件
把juejin-application-single
中的@SpringBootApplication
换成@JuejinBootApplication
即可
@JuejinBootApplication
public class JuejinSingleApplication {
public static void main(String[] args) {
SpringApplication.run(JuejinSingleApplication.class, args);
}
}
@JuejinCloudApplication
接下来,我们要在@JuejinBootApplication
的基础上添加微服务需要的组件配置
先定义一个JuejinCloudConfiguration
把我们的RouterRegister
放到这里配置
@Configuration
public class JuejinCloudConfiguration {
@Bean
public RouterRegister routerRegister() {
return new RouterRegister();
}
}
再定义一个@JuejinCloudApplication
来启用该配置
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(JuejinCloudConfiguration.class)
@JuejinBootApplication
public @interface JuejinCloudApplication {
}
@JuejinCloudApplication
包含@JuejinBootApplication
中的所有配置,同时额外配置JuejinCloudConfiguration
最后,将微服务模式的启动模块上的@SpringBootApplication
换成@JuejinCloudApplication
即可
这样我们就可以通过@JuejinBootApplication
和@JuejinCloudApplication
来控制在单体应用模式和微服务模式所配置的组件
总结
不管是通过构建工具代替人工来生成路由信息,或是借助注册中心代替额外的服务间通信来传递路由信息,还是通过心跳事件代替不知如何配置的线程池来刷新路由信息,包括基于Spring
的功能实现条件配置,我们应该要善于发现身边可利用的资源和技术来更有效率更有质量的完成我们的需求
转载自:https://juejin.cn/post/7151947711133483022