Spring Cloud Gateway 从数据库动态获取路由信息

我们使用 spring cloud gateway 时,绝大部分情况下都是使用 application.yml 配置文件或者 nacos 、apollo 等配置中心存储路由信息,每当对路由进行增删改操作后,需要重启 gateway 服务才能生效。

在工作中也很可能会遇到这样的场景:用户想自己根据实际情况配置路由转发地址等信息。这就需要我们把用户配置的内容,存储到数据库中,并且在不重启 gateway 服务的前提下,更新 gateway 服务内存中的路由信息。要想实现将 spring cloud gateway 的路由和过滤器等信息存储到其它地方,最简单的方案就是实现 ApplicationEventPublisherAware 接口,并对其进行数据存取方案的扩展即可。

本篇博客仅仅针对最常用的路由转发和 StripPrefix 过滤器进行 mysql 数据库存取,实现动态路由配置功能,在博客最后提供源代码下载。

有关 spring cloud gateway 的路由条件设置和过滤器设置,可以参考如下官网地址:

https://docs.spring.io/spring-cloud-gateway/docs/3.1.4-SNAPSHOT/reference/html/#gateway-request-predicates-factories
https://docs.spring.io/spring-cloud-gateway/docs/3.1.4-SNAPSHOT/reference/html/#gatewayfilter-factories

一、项目搭建

很久之前写过一篇 spring cloud gateway 的博客:https://www.cnblogs.com/studyjobs/p/16913715.html

本篇博客仍然采用之前编写的博客 demo,工程结构完全一模一样,仅仅改造 gateway 模块。为了方便,仍然采用了 Eureka 作为注册中心。大家在实际工作中可以更改为使用 nacos 或 consul 作为注册中心。

先看一下 gateway 服务结构,如下图所示,在原来 demo 的基础上,新增加了 entity、mapper、service、controller 内容,主要实现对 mysql 中配置的路由信息进行增删改,并同时将最新的路由信息更新到 getway 服务的内存中。

image

gateway 服务的 pom 文件的内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>springcloud_gateway</artifactId>
        <groupId>com.jobs</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>gateway_app</artifactId>

    <dependencies>
        <!--引入 gateway 网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <!-- eureka-client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <!--引入 mysql 相关依赖-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
        </dependency>
    </dependencies>
</project>

为了能够更简单的进行演示,mysql 数据库表设计的非常简单,具体的 sql 语句如下:

DROP TABLE IF EXISTS `routes_config`;
CREATE TABLE `routes_config`  (
  `id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '路由id名称',
  `uri` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '转发的目标地址',
  `predicates` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '访问路由地址',
  `filters` int(255) NULL DEFAULT 0 COMMENT '过滤掉左侧的路径数量',
  `remarks` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '备注信息',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '路由配置表' ROW_FORMAT = Dynamic;

INSERT INTO `routes_config` VALUES ('baidu', 'https://www.baidu.com', '/baidu/**', 1, '百度');
INSERT INTO `routes_config` VALUES ('providerA', 'lb://PROVIDER-A', '/testa/**', 0, '服务A');
INSERT INTO `routes_config` VALUES ('providerB', 'lb://PROVIDER-B', '/testb/**', 0, '服务B');
INSERT INTO `routes_config` VALUES ('qq', 'https://www.qq.com', '/qq/**', 1, '腾讯');

为了实现从数据库中动态读取配置的路由信息,因此需要将 gateway 的配置文件中配置的路由信息注释掉:

server:
  port: 8089

eureka:
  instance:
    # 配置主机名
    hostname: gateway-app
    # 显示 ip 地址,代替显示主机名
    prefer-ip-address: true
    # 所注册服务实例名称的显示形式
    instance-id: ${eureka.instance.hostname}:${server.port}
    # 每隔 3 秒发一次心跳包
    lease-renewal-interval-in-seconds: 3
    # 如果 15 秒没有发送心跳包,就让 eureka 把自己从服务列表中移除
    lease-expiration-duration-in-seconds: 15
  client:
    service-url:
      # 将当前 springboot 服务注册到 eureka 中
      defaultZone: http://localhost:8761/eureka
    # 是否将自己的路径注册到 eureka 上
    register-with-eureka: true
    # 是否需要从 eureka 中抓取路径
    fetch-registry: true

# provider 集群需要使用相同的 application 名称
spring:
  application:
    name: gateway-app
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://192.168.139.128:3306/gatewayDb?characterEncoding=utf8&allowMultiQueries=true&useSSL=false
    username: root
    password: root
  cloud:
    # 网关配置
    gateway:
      # 允许跨域请求(仅配置这里可能不行,还得代码中进行配置)
      globalcors:
        add-to-simple-url-handler-mapping: true
        corsConfigurations:
          '[/**]':
            allowedHeaders: "*"
            allowedOrigins: "*"
            allowedMethods:
              - GET
              - POST
              - DELETE
              - PUT
              - OPTION
      # 路由配置:转发规则
      #routes:
      #  - id: aaa
      #    # 静态路由
      #    # uri: http://localhost:8100/
      #    # 动态路由
      #    uri: lb://PROVIDER-A
      #    predicates:
      #      - Path=/testa/**
      #  - id: gateway-consumer
      #    uri: lb://PROVIDER-B
      #    predicates:
      #      - Path=/testb/**
      # 微服务名称配置
      discovery:
        locator:
          # 设置为true 请求路径前可以添加微服务名称
          enabled: true
          # 允许为小写
          lower-case-service-id: true

二、代码细节

RouteEntity 的内容如下:

package com.jobs.gateway.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("routes_config")
public class RouteEntity {

    @TableId("id")
    private String id;

    @TableField("uri")
    private String uri;

    @TableField("predicates")
    private String predicates;

    @TableField("filters")
    private Integer filters;

    @TableField("remarks")
    private String remarks;
}

由于采用了 mybatis plus 框架,因此 RouteMapper 就比较简单,具体内容如下:

package com.jobs.gateway.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jobs.gateway.entity.RouteEntity;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface RouteMapper extends BaseMapper<RouteEntity> {
}

核心功能主要在 RouteService 中,需要实现 ApplicationEventPublisherAware 接口。另外之所以实现 CommandLineRunner 接口,主要目的是让 gateway 服务启动成功后,自动去 mysql 中读取路由配置信息,加载到网关内存中。

package com.jobs.gateway.service;

import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.jobs.gateway.entity.RouteEntity;
import com.jobs.gateway.mapper.RouteMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
import org.springframework.cloud.gateway.filter.FilterDefinition;
import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
@Slf4j
public class RouteService implements ApplicationEventPublisherAware, CommandLineRunner {

    private ApplicationEventPublisher publisher;
    @Autowired
    private RouteDefinitionWriter routeDefinitionWriter;
    @Autowired
    private RouteMapper routeMapper;

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.publisher = applicationEventPublisher;
    }

    public void refresh() {
        loadRouteConfig();
    }

    @Override
    public void run(String... args) throws Exception {
        //CommandLineRunner 项目启动成功后执行,只执行一次
        //加载数据库中的路由信息
        loadRouteConfig();
    }

    private void loadRouteConfig() {
        List<RouteEntity> gatewayList = routeMapper.selectList(null);
        if (!CollectionUtils.isEmpty(gatewayList)) {
            for (RouteEntity gateway : gatewayList) {
                RouteDefinition definition = handleData(gateway);
                routeDefinitionWriter.save(Mono.just(definition)).subscribe();
            }
            publisher.publishEvent(new RefreshRoutesEvent(this));
        }
    }

    private RouteDefinition handleData(RouteEntity gateway) {
        RouteDefinition definition = new RouteDefinition();

        PredicateDefinition predicate = new PredicateDefinition();
        Map<String, String> predicateParams = new HashMap<>(8);

        FilterDefinition filterDefinition = new FilterDefinition();
        Map<String, String> filterParams = new HashMap<>(8);

        //设置id
        definition.setId(gateway.getId());

        URI url;
        if (gateway.getUri().startsWith("http") || gateway.getUri().startsWith("https")) {
            //直接转发到具体的网址
            url = UriComponentsBuilder.fromHttpUrl(gateway.getUri()).build().toUri();
        } else {
            //负载均衡地址
            url = UriComponentsBuilder.fromUriString(gateway.getUri()).build().toUri();
        }
        //设置转发的 url
        definition.setUri(url);

        //这里只设置请求的路由条件
        predicate.setName("Path");
        predicateParams.put("pattern", gateway.getPredicates());
        predicate.setArgs(predicateParams);
        //设置predicate路由条件
        definition.setPredicates(Arrays.asList(predicate));

        if (gateway.getFilters() != 0) {
            filterDefinition.setName("StripPrefix");
            filterParams.put("_genkey_0", gateway.getFilters().toString());
            filterDefinition.setArgs(filterParams);
            //设置过滤器
            definition.setFilters(Arrays.asList(filterDefinition));
        }

        //设置过滤器的优先级
        definition.setOrder(0);

        return definition;
    }

    //新增路由并更新网关
    public void addRoute(RouteEntity gateway) {
        RouteDefinition definition = handleData(gateway);
        routeMapper.insert(gateway);
        routeDefinitionWriter.save(Mono.just(definition)).subscribe();
        publisher.publishEvent(new RefreshRoutesEvent(this));
    }

    //更新路由并更新网关
    public void updateRoute(RouteEntity gateway) {
        RouteDefinition definition = handleData(gateway);
        routeMapper.updateById(gateway);
        routeDefinitionWriter.delete(Mono.just(definition.getId()));
        routeDefinitionWriter.save(Mono.just(definition)).subscribe();
        publisher.publishEvent(new RefreshRoutesEvent(this));
    }

    //删除路由并更新网关
    public void deleteRoute(String routeId) {
        routeMapper.deleteById(routeId);
        routeDefinitionWriter.delete(Mono.just(routeId)).subscribe();
        publisher.publishEvent(new RefreshRoutesEvent(this));
    }
}

最后编写一个 RouteController 用于对外提供接口,对 mysql 中的路由信息表配置进行增删改操作

package com.jobs.gateway.controller;

import com.jobs.gateway.entity.RouteEntity;
import com.jobs.gateway.service.RouteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/route")
@RestController
public class RouteController {

    @Autowired
    private RouteService routeService;

    @PostMapping("/add")
    public String addRoute(@RequestBody RouteEntity routeEntity) {
        try {
            routeService.addRoute(routeEntity);
            return "ok";
        } catch (Exception ex) {
            return ex.getMessage();
        }
    }

    @PutMapping("/update")
    public String updateRoute(@RequestBody RouteEntity routeEntity) {
        try {
            routeService.updateRoute(routeEntity);
            return "ok";
        } catch (Exception ex) {
            return ex.getMessage();
        }
    }

    @DeleteMapping("/delete")
    public String deleteRoute(String routeId) {
        try {
            routeService.deleteRoute(routeId);
            return "ok";
        } catch (Exception ex) {
            return ex.getMessage();
        }
    }
}

三、验证成果

启动 eureka_app、gateway_app、provider_a1、provider_b1 等服务后,由于 gateway_app 的 application.yml 配置文件中,已经注释掉了所配置的路由信息,因此通过网关无法访问 provider 所提供的接口,此时可以通过 Apipost 工具调用 gateway_app 服务所提供的接口,添加路由信息:

image

添加完成之后,此时访问 http://localhost:8089/testa/getdata/1 就可以将请求转发到 PROVIDER-A 服务,获取结果了。也就是说当访问网关域名时,如果访问的路径以 /testa 开头的话,就会访问 PROVIDER-A 服务的 /testa 对应的地址。

转发的目标路径【lb://服务名】表示负载均衡转发。同样你可以把 PROVIDER-B 服务的路由信息,添加到数据库中。

如果想直接转发到具体的网址时,比如转发到百度网站,可以添加如下路由:

image

添加完成后,就可以访问 http://localhost:8089/baidu 就可以访问百度网站了。

需要注意的是:这里的 filters 必须配置为 1,由于我们代码中添加的过滤器是 StripPrefix,当其值为 1 时,表示去掉路径中的第一段。

例如:如果 StripPrefix = 0 的话,访问 http://localhost:8089/baidu ,转发后的地址是:https://www.baidu.com/baidu ,访问的结果是 404

如果 StripPrefix = 1 的话,访问 http://localhost:8089/baidu ,转发后的地址是:https://www.baidu.com ,去掉了访问路由 /baidu/** 中的第一段,因此就可以正常访问百度网站。

当然你想 http://localhost:8089/baidu 查询百度的内容时,访问的地址也必须是以 http://localhost:8089/baidu 开头的地址。

例如通过网关在百度查询 gateway 关键字:http://localhost:8089/baidu/s?wd=gateway

image


本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springcloud_gateway2.zip

posted @ 2024-05-23 17:59  乔京飞  阅读(3305)  评论(0编辑  收藏  举报