服务端模块化架构设计|网关路由模块化支持与条件配置
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
本专栏将通过以下几块内容来搭建一个模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展高扩展的后端服务
项目结构与模块化构建思路RESTful与API设计&管理网关路由模块化支持与条件配置(本文)DDD领域驱动设计与业务模块化RPC模块化设计与分布式事务事件模块化设计与生命周期日志在之前的文章服务端模块化架构设计|项目结构与模块化构建思路中,我们以掘金的部分功能为例,搭建了一个支持模块化的后端服务项目juejin,其中包含三个模块:juejin-user(用户),juejin-pin(沸点),juejin-message(消息)
通过添加启动模块来任意组合和扩展功能模块
示例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
模块化项目结构.jpg为了能更好的理解本专栏中的模块化,建议读者先阅读服务端模块化架构设计|项目结构与模块化构建思路
网关路由问题一般情况下,我们会配置网关的路由规则按照请求路径的前缀进行匹配
以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
网关启动类.jpg因为网关不太可能会和其他业务模块合并,所以这里直接添加启动类作为一个单独的服务
在build.gradle中只需要引入spring-cloud-starter-gateway就行了,因为其他的配置我们已经在allprojects中配置好了
网关build配置.jpg最后添加bootstrap.yml就行了
网关配置文件.jpg我们来启动服务
网关报错.jpg嗯?报错了,因为发现集成了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,我们稍微改进一下写法
varexcludeBasic=['juejin-basic','juejin-gateway']
varexcludeWeb=['juejin-gateway']
dependencies{
if(!excludeBasic.contains(project.name)){
implementationproject(':juejin-basic')
}
if(!excludeWeb.contains(project.name)){
implementation'org.springframework.boot:spring-boot-starter-web'
}
//其他依赖...
}
定义两个变量来指定需要排除juejin-basic和spring-boot-starter-web的模块,这样看起来就更简洁了
然后就启动成功啦
网关启动成功.jpg手动路由配置先来看看手动配置
手动配置路由.jpg我们只需要在配置文件中添加两个路由即可,当请求路径匹配/juejin-user/**或/juejin-message/**时,路由到juejin-application-system服务
上面也提到过,这种方式麻烦的地方是,需要每次根据我们模块的组合情况修改路由配置,就算可以用配置中心和动态刷新解决重新打包和重启服务的问题,也还是需要手动修改配置文件
所以有没有一种方式可以做到自动配置呢,大家有兴趣可以先停下来思考一会儿,看看能不能想到一种解决方案
自动路由配置要实现自动配置,首先我们的各个启动服务需要知道自己包含了几个模块,其次要把各个启动服务包含的模块信息传递给网关
如何获得模块信息我们的启动服务是通过在build.gradle中依赖其他模块的,所以我们有没有可能在打包时拿到依赖信息然后在resources目录下生成一个路由文件,这样我们就可以通过读取这个路由文件来获得当前服务包含的模块了
在build.gradle的allprojects中添加脚本代码
processResources{
//资源文件处理之前
doFirst{
SetStringmSet=newHashSet()
//遍历所有的依赖
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()){
//获得资源目录
FileresourcesDir=newFile(project.projectDir,'/src/main/resources')
//创建路由文件
Filefile=newFile(resourcesDir,'router.properties')
if(!file.exists()){
file.createNewFile()
}
//将模块信息写入文件
Propertiesproperties=newProperties()
properties.setProperty("routers",String.join(',',mSet))
OutputStreamos=newFileOutputStream(file)
properties.store(os,"Routersgeneratedfile")
os.close()
}
}
}
给processResources添加一个前置处理逻辑,遍历所有依赖并筛选出其中的业务模块,在resources目录下创建router.properties,将业务模块信息写入文件中
这样在我们build或者bootJar的时候就会自动生成对应的文件了
自动生成路由文件.jpg我还额外添加了一个clean时候的后置逻辑
clean{
doLast{
FileresourcesDir=newFile(project.projectDir,'/src/main/resources')
Filefile=newFile(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
publicclassRouterRegister{
/**
*监听服务注册前置事件
*/
@EventListener
publicvoidregister(InstancePreRegisteredEventevent)throwsException{
//读取router.properties资源文件
ClassPathResourceresource=newClassPathResource("router.properties");
//加载到Properties中
Propertiesproperties=newProperties();
try(InputStreamis=resource.getInputStream()){
properties.load(is);
}
//获得routers值
Stringrouters=properties.getProperty("routers");
//写入metadata中
MapString,Stringmetadata=event.getRegistration().getMetadata();
metadata.put("routers",routers);
}
}
我们监听服务注册的前置事件InstancePreRegisteredEvent读取router.properties文件中生成的路由信息添加到metadata中
这样所有的服务启动后就会将自身包含的模块信息注册上去
路由注册.jpg在注册中心上也可以看到路由信息已经同步上去了
同步路由信息
我们在juejin-gateway中自定义一个JuejinRouterDefinitionLocator通过监听HeartbeatEvent来定时刷新路由
@Component
@RequiredArgsConstructor
publicclassJuejinRouterDefinitionLocatorimplementsRouteDefinitionLocator{
/**
*服务发现组件
*/
privatefinalDiscoveryClientdiscoveryClient;
/**
*路由缓存
*/
privatevolatileListRouteDefinitionrouteDefinitions=Collections.emptyList();
@Override
publicFluxRouteDefinitiongetRouteDefinitions(){
returnFlux.fromIterable(routeDefinitions);
}
/**
*监听心跳事件
*/
@EventListener
publicvoidrefreshRouters(HeartbeatEventevent){
//新的路由
ListRouteDefinitionnewRouteDefinitions=newArrayList();
//获得服务名
ListStringservices=discoveryClient.getServices();
for(Stringservice:services){
//获得服务实例
ListServiceInstanceinstances=discoveryClient.getInstances(service);
if(instances.isEmpty()){
continue;
}
//这里直接拿第一个
ServiceInstanceinstance=instances.get(0);
//获得metadata中的routers
StringroutersMetadata=instance.getMetadata()
.getOrDefault("routers","");
String[]routers=routersMetadata.split(",");
//生成新的RouteDefinition
for(Stringrouter:routers){
RouteDefinitionrd=newRouteDefinition();
rd.setId("router@"+service);
rd.setUri(URI.create("lb://"+service));
PredicateDefinitionpd=newPredicateDefinition();
pd.setName("Path");
pd.addArg("juejin","/"+router+"/**");
rd.setPredicates(Collections.singletonList(pd));
FilterDefinitionfd=newFilterDefinition();
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
publicclassJuejinBootConfiguration{
@Bean(initMethod="init",destroyMethod="close")
@ConfigurationProperties(prefix="spring.datasource")
publicDruidDataSourcedruidDataSource(){
returnnewDruidDataSource();
}
}
再定义一个@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@interfaceJuejinBootApplication{
}
我们把basePackages指定为com.bytedance.juejin,这样所有的模块都能被扫描到,同时添加一个过滤器排除com.bytedance.juejin.basic也就是juejin-basic下的组件方便我们手动进行条件配置
JuejinBootConfiguration中配置和微服务无关的组件,如数据源,不管是单体应用还是微服务都需要配置的组件
把juejin-application-single中的@SpringBootApplication换成@JuejinBootApplication即可
@JuejinBootApplication
publicclassJuejinSingleApplication{
publicstaticvoidmain(String[]args){
SpringApplication.run(JuejinSingleApplication.class,args);
}
}
@JuejinCloudApplication接下来,我们要在@JuejinBootApplication的基础上添加微服务需要的组件配置
先定义一个JuejinCloudConfiguration把我们的RouterRegister放到这里配置
@Configuration
publicclassJuejinCloudConfiguration{
@Bean
publicRouterRegisterrouterRegister(){
returnnewRouterRegister();
}
}
再定义一个@JuejinCloudApplication来启用该配置
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(JuejinCloudConfiguration.class)
@JuejinBootApplication
public@interfaceJuejinCloudApplication{
}
@JuejinCloudApplication包含@JuejinBootApplication中的所有配置,同时额外配置JuejinCloudConfiguration
最后,将微服务模式的启动模块上的@SpringBootApplication换成@JuejinCloudApplication即可
这样我们就可以通过@JuejinBootApplication和@JuejinCloudApplication来控制在单体应用模式和微服务模式所配置的组件
总结不管是通过构建工具代替人工来生成路由信息,或是借助注册中心代替额外的服务间通信来传递路由信息,还是通过心跳事件代替不知如何配置的线程池来刷新路由信息,包括基于Spring的功能实现条件配置,我们应该要善于发现身边可利用的资源和技术来更有效率更有质量的完成我们的需求
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线