likes
comments
collection
share

服务端模块化架构设计|网关路由模块化支持与条件配置

作者站长头像
站长
· 阅读数 16

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

本专栏 将通过以下几块内容来搭建一个 模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展 的后端服务

项目结构与模块化构建思路

RESTful与API设计&管理

网关路由模块化支持与条件配置(本文)

DDD领域驱动设计与业务模块化(概念与理解)

DDD领域驱动设计与业务模块化(落地与实现)

DDD领域驱动设计与业务模块化(薛定谔模型)

DDD领域驱动设计与业务模块化(优化与重构)

RPC模块化设计与分布式事务

v2.0:项目结构优化升级

v2.0:项目构建+代码生成「插件篇」

v2.0:扩展模块实现技术解耦

未完待续......

在之前的文章 服务端模块化架构设计|项目结构与模块化构建思路 中,我们以掘金的部分功能为例,搭建了一个支持模块化的后端服务项目juejin,其中包含三个模块:juejin-user(用户)juejin-pin(沸点)juejin-message(消息)

通过添加启动模块来任意组合和扩展功能模块

  • 示例1:通过启动模块juejin-appliaction-systemjuejin-user(用户)juejin-message(消息)合并成一个服务减少服务器资源的消耗,通过启动模块juejin-appliaction-pin来单独提供juejin-pin(沸点)模块服务以支持大流量功能模块的精准扩容

  • 示例2:通过启动模块juejin-appliaction-singlejuejin-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-basicspring-boot-starter-web的模块,这样看起来就更简洁了

然后就启动成功啦

服务端模块化架构设计|网关路由模块化支持与条件配置

手动路由配置

先来看看手动配置

服务端模块化架构设计|网关路由模块化支持与条件配置

我们只需要在配置文件中添加两个路由即可,当请求路径匹配/juejin-user/**/juejin-message/**时,路由到juejin-application-system服务

上面也提到过,这种方式麻烦的地方是,需要每次根据我们模块的组合情况修改路由配置,就算可以用配置中心和动态刷新解决重新打包和重启服务的问题,也还是需要手动修改配置文件

所以有没有一种方式可以做到自动配置呢,大家有兴趣可以先停下来思考一会儿,看看能不能想到一种解决方案

自动路由配置

要实现自动配置,首先我们的各个启动服务需要知道自己包含了几个模块,其次要把各个启动服务包含的模块信息传递给网关

如何获得模块信息

我们的启动服务是通过在build.gradle中依赖其他模块的,所以我们有没有可能在打包时拿到依赖信息然后在resources目录下生成一个路由文件,这样我们就可以通过读取这个路由文件来获得当前服务包含的模块了

build.gradleallprojects中添加脚本代码

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之后,删除生成的路由文件

如何传递模块信息

最开始我想到的一种方式就是使用RPCFeign来同步数据

我们可以在服务启动时,调用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的功能实现条件配置,我们应该要善于发现身边可利用的资源和技术来更有效率更有质量的完成我们的需求

源码

上一篇:RESTful与API设计&管理

下一篇:DDD领域驱动设计与业务模块化(概念与理解)