二. 微服务的高级进阶

1. Ribbon API和负载均衡算法

1. Ribbon API

Ribbon 是一个独立的组件,是用来进行远程接口调用的,代码如下

@Slf4j
@Service
@Scope(proxyMode = ScopedProxyMode.INTERFACES)
public class UserServiceImpl implements UserService {

    public static String SERVIER_NAME = "micro-order";
    @Autowired
    private RestTemplate restTemplate;

    @Override
    public List<String> queryContents() {
        List<String> results = restTemplate.getForObject("http://"
            + SERVIER_NAME + "/user/queryContent", List.class);
        log.info("#results = {}", results);
        return results;
    }
}

通过 getForObject 方法可以掉到用 micro-order 服务的,queryUser 接口。然后在调用期间会 存在负载均衡,micro-order 服务对应有几个服务实例就会根据负载均衡算法选择某一个去 调用。

Get 请求

getForEntity:此方法有三种重载形式,分别为:

getForEntity(String url, Class responseType)

getForEntity(String url, Class responseType, Object… uriVariables)

getForEntity(String url, Class responseType, Map<String, ?> uriVariables)

getForEntity(URI url, Class responseType)

注意:此方法返回的是一个包装对象 ResponseEntity其中 T 为 responseType 传入类型,

想拿到返回类型需要使用这个包装类对象的 getBody()方法

getForObject:此方法也有三种重载形式,这点与 getForEntity 方法相同:

getForObject(String url, Class responseType)

getForObject(String url, Class responseType, Object… uriVariables)

getForObject(String url, Class responseType, Map<String, ?> uriVariables)

getForObject(URI url, Class responseType)

注意:此方法返回的对象类型为 responseType 传入类型

Post 请求

post 请求和 get 请求都有ForEntity 和ForObject 方法,其中参数列表有些不同,除了这两个

方法外,还有一个 postForLocation 方法,其中 postForLocation 以 post 请求提交资源,并返

回新资源的 URI

postForEntity:此方法有三种重载形式,分别为:

postForEntity(String url, Object request, Class responseType, Object… uriVariables)

postForEntity(String url, Object request, Class responseType, Map<String, ?> uriVariables)

postForEntity(URI url, Object request, Class responseType)

注意:此方法返回的是一个包装对象 ResponseEntity其中 T 为 responseType 传入类型,

想拿到返回类型需要使用这个包装类对象的 getBody()方法

postForObject:此方法也有三种重载形式,这点与 postForEntity 方法相同:

postForObject(String url, Object request, Class responseType, Object… uriVariables)

postForObject(String url, Object request, Class responseType, Map<String, ?> uriVariables)

postForObject(URI url, Object request, Class responseType)

注意:此方法返回的对象类型为 responseType 传入类型

postForLocation:此方法中同样有三种重载形式,分别为:

postForLocation(String url, Object request, Object… uriVariables)

postForLocation(String url, Object request, Map<String, ?> uriVariables)

postForLocation(URI url, Object request)

注意:此方法返回的是新资源的 URI,相比 getForEntity、getForObject、postForEntity、

postForObject 方法不同的是这个方法中无需指定返回类型,因为返回类型就是 URI,通过

Object… uriVariables、Map<String, ?> uriVariables 进行传参依旧需要占位符

2. 负载均衡算法

@Bean
public IRule ribbonRule() {
    //线性轮询
    new RoundRobinRule();
    //可以重试的轮询
    new RetryRule();
    //根据运行情况来计算权重
    new WeightedResponseTimeRule();
    //过滤掉故障实例,选择请求数最小的实例
    new BestAvailableRule();
    // 随机
    return new RandomRule();
}

3. Ribbon 配置

application.properties 配置 (eureka-web)

############ ribbon 配置
# 关闭ribbon访问注册中心Eureka Server发现服务,但是服务依旧会注册。
#true使用eureka false不使用
ribbon.eureka.enabled=true
#指定调用的节点    localhost:8765  localhost:8764  localhost:8763
micro-order.ribbon.listOfServers=localhost:8765,localhost:8764,localhost:8763
#单位ms ,请求连接超时时间
micro-order.ribbon.ConnectTimeout=1000
#单位ms ,请求处理的超时时间
micro-order.ribbon.ReadTimeout=2000

micro-order.ribbon.OkToRetryOnAllOperations=true
#切换实例的重试次数
micro-order.ribbon.MaxAutoRetriesNextServer=2
#对当前实例的重试次数 当Eureka中可以找到服务,但是服务连不上时将会重试
micro-order.ribbon.MaxAutoRetries=2

micro-order.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule
micro-order.ribbon.NFLoadBalancerPingClassName=com.netflix.loadbalancer.PingUrl

启动三个micro-order实例

java -jar netflix-eureka-order-1.0-SNAPSHOT.jar --server.port=8763

java -jar netflix-eureka-order-1.0-SNAPSHOT.jar --server.port=8764

java -jar netflix-eureka-order-1.0-SNAPSHOT.jar --server.port=8765

注意要配置src/main/resources过滤, 不然打出来的包运行没有配置, 可能会出错

<resources>
    <resource>
        <directory>src/main/java</directory>
        <includes>
            <include>**/*.properties</include>
            <include>**/*.xml</include>
        </includes>
        <filtering>false</filtering>
    </resource>
    <resource>
        <directory>src/main/resources</directory>
        <includes>
            <include>**/*.properties</include>
            <include>**/*.xml</include>
        </includes>
        <filtering>false</filtering>
    </resource>
</resources>

web的代码配置

使用@RibbonClients 加载配置

/*
 * 这个是针对 micro-order服务的 ribbon配置
 * */
@Configuration
@RibbonClients(value = {
    @RibbonClient(name = "micro-order", configuration = RibbonLoadBalanceMicroOrderConfig.class)
})
public class LoadBalanceConfig {
}

这个配置类只针对 micro-order 服务,微服务系统里面有很多服务,这就可以区别化配置。

配置 configuration 配置类的时候,一定要注意,配置类不能被@ComponentScan 注解扫描到, 如果被扫描到了则该配置类就是所有服务共用的配置了。

image-20220213163829972

配置类

/*
* 这个类最好不要出现在启动类的@ComponentScan扫描范围
* 如果出现在@ComponentScan扫描访问,那么这个配置类就是每个服务共用的配置了
* */
@Configuration
public class RibbonLoadBalanceMicroOrderConfig {

//    @RibbonClientName
    private String name = "micro-order";

    @Bean
    @ConditionalOnClass
    public IClientConfig defaultClientConfigImpl() {
        DefaultClientConfigImpl config = new DefaultClientConfigImpl();
        config.loadProperties(name);
        config.set(CommonClientConfigKey.MaxAutoRetries,2);
        config.set(CommonClientConfigKey.MaxAutoRetriesNextServer,2);
        config.set(CommonClientConfigKey.ConnectTimeout,2000);
        config.set(CommonClientConfigKey.ReadTimeout,4000);
        config.set(CommonClientConfigKey.OkToRetryOnAllOperations,true);
        return config;
    }
    /*
    * 判断服务是否存活
    * 不建议使用
    * */
	// @Bean
    public IPing iPing() {
        //这个实现类会去调用服务来判断服务是否存活
        return new PingUrl();
    }

    @Bean
    public IRule ribbonRule() {
        //线性轮训
        new RoundRobinRule();
        //可以重试的轮训
        new RetryRule();
        //根据运行情况来计算权重
        new WeightedResponseTimeRule();
        //过滤掉故障实例,选择请求数最小的实例
        new BestAvailableRule();
        return new RandomRule();
    }
}

4. Ribbon 单独使用

Ribbon 是一个独立组件,可以脱离 springcloud 使用的

@SpringBootTest(classes = EurekaWebApp.class)
@WebAppConfiguration
public class RibbonTest {

    /*
     * ribbon作为调用客户端,可以单独使用
     * */
    @Test
    public void test1() {
        try {
            //myClients  随便取值
            ConfigurationManager.getConfigInstance().setProperty("myClients.ribbon.listOfServers", "localhost:8001,localhost:8002");
            RestClient client = (RestClient) ClientFactory.getNamedClient("myClients");
            HttpRequest request = HttpRequest.newBuilder().uri(new URI("/user/queryContent")).build();

            for (int i = 0; i < 10; i++) {
                HttpResponse httpResponse = client.executeWithLoadBalancer(request);
                String entity = httpResponse.getEntity(String.class);
                System.out.println(entity);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

需要依赖两个 jar

Maven: com.netflix.servo:servo-core:0.12.21
Maven: com.netflix.ribbon:ribbon-httpclient:2.3.0

2. Hystrix

1. 服务雪崩

雪崩是系统中的蝴蝶效应导致其发生的原因多种多样,有不合理的容量设计,或者是高并发 下某一个方法响应变慢,亦或是某台机器的资源耗尽。从源头上我们无法完全杜绝雪崩源头 的发生,但是雪崩的根本原因来源于服务之间的强依赖,所以我们可以提前评估。当整个微 服务系统中,有一个节点出现异常情况,就有可能在高并发的情况下出现雪崩,导致调用它 的上游系统出现响应延迟响应延迟就会导致 tomcat 连接耗尽,导致该服务节点不能正常的接收到正常的情况,这就是服务雪崩行为。

2. 服务隔离

如果整个系统雪崩是由于一个接口导致的,由于这一个接口响应不及时导致问题,那么我们 就有必要对这个接口进行隔离,就是只允许这个接口最多能接受多少的并发,做了这样的限制后,该接口的主机就会空余线程出来接收其他的情况,不会被哪个坏了的接口占用满。 Hystrix 就是一个不错的服务隔离框架。

Hystrix 的starter

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

启动类开启 hystrix 功能

@SpringBootApplication
@EnableEurekaClient
@EnableCircuitBreaker // 开启短路器 /ˈsɜːkɪt/ 
//@EnableHystrix // 或者通过开启hystrix也可以
public class EurekaWebApp {

    public static void main(String[] args) {
        SpringApplication.run(EurekaWebApp.class, args);
    }

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

代码使用

@HystrixCommand
@Override
public String queryTicket() {
    return "queryTicket";
}
3. Hystrix 服务隔离策略

1、线程池隔离

THREAD 线程池隔离策略 独立线程接收请求,默认采用的就是线程池隔离

代码配置

 /**
     * Command属性
     * execution.isolation.strategy  执行的隔离策略
     * THREAD 线程池隔离策略  独立线程接收请求
     * SEMAPHORE 信号量隔离策略 在调用线程上执行
     * <p>
     * execution.isolation.thread.timeoutInMilliseconds  设置HystrixCommand执行的超时时间,单位毫秒
     * execution.timeout.enabled  是否启动超时时间,true,false
     * execution.isolation.semaphore.maxConcurrentRequests  隔离策略为信号量的时候,该属性来配置信号量的大小,最大并发达到信号量时,后续请求被拒绝
     * <p>
     * circuitBreaker.enabled   是否开启断路器功能
     * circuitBreaker.requestVolumeThreshold  该属性设置在滚动时间窗口中,断路器的最小请求数。默认20,如果在窗口时间内请求次数19,即使19个全部失败,断路器也不会打开
     * circuitBreaker.sleepWindowInMilliseconds    该属性用来设置当断路器打开之后的休眠时间,休眠时间结束后断路器为半开状态,断路器能接受请求,如果请求失败又重新回到打开状态,如果请求成功又回到关闭状态(半开状态就是下一个请求成功与否决定下一个状态是什么)
     * circuitBreaker.errorThresholdPercentage  该属性设置断路器打开的错误百分比。在滚动时间内,在请求数量超过circuitBreaker.requestVolumeThreshold,如果错误请求数的百分比超过这个比例,断路器就为打开状态
     * circuitBreaker.forceOpen   true表示强制打开断路器,拒绝所有请求
     * circuitBreaker.forceClosed  true表示强制进入关闭状态,接收所有请求
     * <p>
     * metrics.rollingStats.timeInMilliseconds   设置滚动时间窗的长度,单位毫秒。这个时间窗口就是断路器收集信息的持续时间。断路器在收集指标信息的时会根据这个时间窗口把这个窗口拆分成多个桶,每个桶代表一段时间的指标,默认10000
     * metrics.rollingStats.numBuckets   滚动时间窗统计指标信息划分的桶的数量,但是滚动时间必须能够整除这个桶的个数,要不然抛异常
     * <p>
     * requestCache.enabled   是否开启请求缓存,默认为true
     * requestLog.enabled 是否打印日志到HystrixRequestLog中,默认true
     * <p>
     * '@HystrixCollapser 请求合并
     * maxRequestsInBatch  设置一次请求合并批处理中允许的最大请求数
     * timerDelayInMilliseconds  设置批处理过程中每个命令延迟时间
     * requestCache.enabled   批处理过程中是否开启请求缓存,默认true
     * <p>
     * threadPoolProperties
     * threadPoolProperties 属性
     * coreSize   执行命令线程池的最大线程数,也就是命令执行的最大并发数,默认10
     */
    @HystrixCommand(fallbackMethod = "queryContentsFallback",
            commandKey = "queryContents",
            groupKey = "querygroup-one",
            commandProperties = {
                    @HystrixProperty(name = "execution.isolation.semaphore.maxConcurrentRequests", value = "100"),
                    @HystrixProperty(name = "execution.isolation.strategy", value = "THREAD"), // SEMAPHORE, THREAD
                    @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000000000")
            },
            threadPoolKey = "queryContentshystrixHgypool", threadPoolProperties = {
//            @HystrixProperty(name = "coreSize", value = "100") // 默认线程池中线程大小是10
    })
    @Override
    public List<ConsultContent> queryContents() {
        log.info(Thread.currentThread().getName() + "========queryContents=========");
        s.incrementAndGet();
        return restTemplate.getForObject("http://"
                + SERVIER_NAME + "/user/queryContent", List.class);
    }

线程池隔离策略,hystrix 是会单独创建线程的, 单元测试如下

http://localhost:8766/user/queryUser

image-20220220185100483

但我没不开启hystrix时候, 则是通过http-nio线程去发起请求的

image-20220220185837918

hystrix测试类

@SpringBootTest(classes = EurekaWebApp.class)
@WebAppConfiguration
public class MyTest {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    private final Integer count = 11;

    private final CountDownLatch cdl = new CountDownLatch(count);

    @Autowired
    UserService userService;

    @Test
    public void hystrixTest() {

        for (int i = 0; i < count; i++) {
            new Thread(() -> {
                try {
                    cdl.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                logger.info(Thread.currentThread().getName() + "==>" + userService.queryContents());
            }).start();
            cdl.countDown();
        }

        try {
            Thread.currentThread().join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Test
    @PerfTest(invocations = 11,threads = 11)
    public void hystrixTest2() {
        logger.info(Thread.currentThread().getName() + "==>" + userService.queryContents());
    }

    /*
    * ribbon作为调用客户端,可以单独使用
    * */
    @Test
    public void test1() {
        try {
            ConfigurationManager.getConfigInstance().setProperty("myClients.ribbon.listOfServers","localhost:8001,localhost:8002");
            RestClient client = (RestClient)ClientFactory.getNamedClient("myClients");
            HttpRequest request = HttpRequest.newBuilder().uri(new URI("/user/queryContent")).build();

            for (int i = 0; i < 10; i++) {
                HttpResponse httpResponse = client.executeWithLoadBalancer(request);
                String entity = httpResponse.getEntity(String.class);
                System.out.println(entity);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

测试代码测试

image-20220220191907969

image-20220220191842730

可以看到,单元测试中的线程和业务类中的线程不是一样的,单独开启线程。

2、信号量隔离

信号量隔离是采用一个全局变量来控制并发量,一个请求过来全局变量加 1,单加到跟配置 中的大小相等是就不再接受用户请求了。

代码配置

image-20220220190129788

execution.isolation.semaphore.maxConcurrentRequests

这参数是用来控制信号量隔离级别的并发大小的。

测试类测试

image-20220220191712902

image-20220220191643838

可以看到,单元测试中的线程和业务类中的线程是一样的,没有单独开启线程。

3. Hystrix 服务降级

服务降级是对服务调用过程的出现的异常的友好封装,当出现异常时,我们不希 望直接把异常原样返回,所以当出现异常时我们需要对异常信息进行包装,抛一 个友好的信息给前端。

代码示例

@HystrixCommand(fallbackMethod = "queryContentsFallback", // 服务降级的回调方法
                commandKey = "queryContents",
                groupKey = "querygroup-one",
                commandProperties = {
                    @HystrixProperty(name = "execution.isolation.semaphore.maxConcurrentRequests", value = "100"),
                    @HystrixProperty(name = "execution.isolation.strategy", value = "THREAD"), // SEMAPHORE, THREAD
                    @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000000000")
                },
                threadPoolKey = "queryContentshystrixHgypool", threadPoolProperties = {
                    //            @HystrixProperty(name = "coreSize", value = "100")
                })
@Override
public List<ConsultContent> queryContents() {
    log.info(Thread.currentThread().getName() + "========queryContents=========");
    s.incrementAndGet();
    return restTemplate.getForObject("http://"
                                     + SERVIER_NAME + "/user/queryContent", List.class);
}

定义降级方法,降级方法的返回值和业务方法的方法值要一样

public List<ConsultContent> queryContentsFallback() {
    f.incrementAndGet();
    log.info("===============queryContentsFallback=================");

    return null;
}

4. Hystrix 数据监控

Hystrix 进行服务熔断时会对调用结果进行统计,比如超时数、bad 请求数、降 级数、异常数等等都会有统计,那么统计的数据就需要有一个界面来展示, hystrix-dashboard 就是这么一个展示 hystrix 统计结果的服务。

依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

插件

<build>
    <finalName>micro-dashboard</finalName>
    <plugins>
        <!--打包可执行的jar -->
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>2.2.2.RELEASE</version>
            <configuration>
                <mainClass>len.hgy.NetflixHystrixDashboard</mainClass>
            </configuration>
        </plugin>
        <plugin>
            <groupId>com.spotify</groupId>
            <artifactId>docker-maven-plugin</artifactId>
            <version>0.4.13</version>
            <configuration>
                <imageName>hgy/hystrix-dashboard</imageName>
                <dockerDirectory>${project.basedir}/src/main/docker</dockerDirectory>
                <resources>
                    <resource>
                        <targetPath>/</targetPath>
                        <directory>${project.build.directory}</directory>
                        <include>${project.build.finalName}.jar</include>
                    </resource>
                </resources>
            </configuration>
        </plugin>
    </plugins>
</build>

application.properties

server.port=9990
eureka.instance.hostname=localhost
eureka.client.registerWithEureka=false
eureka.client.fetchRegistry=false
##暴露eureka服务的地址
eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:9876/eureka/

#自我保护模式,当出现出现网络分区、eureka在短时间内丢失过多客户端时,会进入自我保护模式,即一个服务长时间没有发送心跳,eureka也不会将其删除,默认为true
eureka.server.enable-self-preservation=false

# 暴露监控端点
management.endpoints.web.exposure.include=*

启动类

/**
 * 监控界面:http://localhost:9990/hystrix
 * 需要监控的端点(使用了hystrix组件的端点):http://localhost:8083/actuator/hystrix.stream
 */
@SpringBootApplication
@EnableHystrixDashboard
public class NetflixHystrixDashboard {
    public static void main(String[] args) {
        SpringApplication.run(NetflixHystrixDashboard.class, args);
    }
}

Dashboadr 界面

http://localhost:9990/hystrix

然后在界面中输入需要监控的端点 url:

http://localhost:8766/actuator/hystrix.stream

image-20220220215438286

注意需要被监控的服务需要开启hystrix并且有如下配置

#@EnableCircuitBreaker # 断路器功能
#@EnableHystrix # 或者开启hystrix功能, 两个注解效果一样
management.endpoint.health.show-details=always
management.endpoint.shutdown.enabled=true
#hystrix.stream  开放所有的监控接口
management.endpoints.web.exposure.include=*

监控后的页面, 没有服务请求是就会一直显示loading

image-20220220223230256

请求服务后: http://localhost:8766/user/queryUser

image-20220220223529019

5. Hystrix熔断

熔断就像家里的保险丝一样,家里的保险丝一旦断了,家里就没点了,家里用电器功率高了就会导致保险丝端掉。在我们springcloud领域也可以这样理解,如果并发高了就可能触发hystrix的熔断。

熔断发生的三个必要条件:

  1. 有一个统计的时间周期,滚动窗口

    相应的配置属性
    metrics.rollingStats.timeInMilliseconds
    默认10000毫秒(10s)

  2. 请求次数必须达到一定数量

    相应的配置属性
    circuitBreaker.requestVolumeThreshold
    默认20次

  3. 失败率达到默认失败率

    相应的配置属性
    circuitBreaker.errorThresholdPercentage
    默认50%

上述3个条件缺一不可,必须全部满足才能开启hystrix的熔断功能。当我们的对一个线程池大小是10的方法压测时看看hystrix的熔断效果:方法配置如下

@HystrixCommand(fallbackMethod = "queryContentsFallback",
                commandKey = "queryContents",
                groupKey = "querygroup-one",
                commandProperties = {
                    @HystrixProperty(name = "execution.isolation.semaphore.maxConcurrentRequests", value = "100"),
                    @HystrixProperty(name = "execution.isolation.strategy", value = "THREAD"), // SEMAPHORE, THREAD
                    @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000000000")
                },
                threadPoolKey = "queryContentshystrixHgypool", threadPoolProperties = {
                    @HystrixProperty(name = "coreSize", value = "10")
                })

Jmeter压测:

image-20220220224634894

线程组配置

image-20220220225013444

观察现象是熔断后, 请求过一会又正常了

image-20220220225414322

可以看到失败率超过50%时,circuit的状态是open的。

熔断器的三个状态:

1、关闭状态

关闭状态时用户请求是可以到达服务提供方的

2、开启状态

开启状态时用户请求是不能到达服务提供方的,直接会走降级方法

3、半开状态

当hystrix熔断器开启时,过一段时间后,熔断器就会由开启状态变成半开状态。半开状态的熔断器是可以接受用户请求并把请求传递给服务提供方的,这时候如果远程调用返回成功,那么熔断器就会有半开状态变成关闭状态,反之,如果调用失败,熔断器就会有半开状态变成开启状态。

Hystrix功能建议在并发比较高的方法上使用,并不是所有方法都得使用的。

3. Feign的使用

Feign是对服务端和客户端通用接口的封装,让代码可以复用做到统一管理。

1. pom依赖(micro-web)

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

2. 启动类导入feign客户端(micro-web)

// StudentService.class,TeacherServiceFeign.class feign代理的客户端, 就是服务的发起方
@EnableFeignClients(clients = {StudentService.class,TeacherServiceFeign.class})

3. feign客户端(micro-web)

/**
 * fallback = StudentServiceFallback.class
 * 不能获取具体异常
 */
@FeignClient(name = "MICRO-ORDER", // 服务端的服务名
         path = "/feign", // 前缀
        /*fallback = StudentServiceFallback.class,*/
        fallbackFactory = StudentServiceFallbackFactory.class // 服务降级工厂, 可以处理异常
)
public interface StudentService {

    @GetMapping("/student/getAllStudent")
    String getAllStudent();

    @PostMapping("/student/saveStudent")
    String saveStudent(@RequestBody Student student);

    @GetMapping("/student/getStudentById")
    String getStudentById(@RequestParam("id") Integer id);

    @GetMapping("/student/errorMessage")
    String errorMessage(@RequestParam("id") Integer id);

    @GetMapping("/student/queryStudentTimeout")
    String queryStudentTimeout(@RequestParam("millis") int millis);
}

// 服务降级工厂, 可以获取具体异常
@Slf4j
@Component
public class StudentServiceFallbackFactory implements FallbackFactory<StudentService> {

    @Override
    public StudentService create(Throwable throwable) {

        if (throwable == null) {
            return null;
        }
        final String msg = throwable.getMessage();
        log.info("exception:" + msg);
        return new StudentService() {
            @Override
            public String getAllStudent() {
                log.info("exception=====getAllStudent==========" + msg);
                return msg;
            }

            @Override
            public String saveStudent(Student student) {
                log.info("exception=====saveStudent==========" + msg);
                return msg;
            }

            @Override
            public String getStudentById(Integer id) {
                log.info("exception=====getStudentById==========" + msg);
                return msg;
            }

            @Override
            public String errorMessage(Integer id) {
                log.info("exception=====errorMessage==========" + msg);
                return msg;
            }

            @Override
            public String queryStudentTimeout(int millis) {
                log.info("exception=====queryStudentTimeout==========" + msg);
                return msg;
            }
        };
    }
}

4. 服务端接口(micro-order)

提供服务的一方, 服务调用的客户端是micro-web的service层的方法, 服务提供方是micro-order的controller层

@Slf4j
@RestController
public class StudentController implements StudentService {

    @Autowired
    private StudentService studentService;

    @RequestMapping("/feign/student/getAllStudent") // url要和服务client调用保持一致
    @Override
    public String getAllStudent() {
        return studentService.getAllStudent();
    }

    @RequestMapping("/feign/student/getStudentById")
    @Override
    public String queryStudentById(@RequestParam("id") Integer id) {
        return studentService.queryStudentById(id);
    }

    @RequestMapping("/feign/student/saveStudent")
    @Override
    public String saveStudent(@RequestBody Student student) {
        return studentService.saveStudent(student);
    }

    @RequestMapping("/feign/student/errorMessage")
    @Override
    public String errorMessage(@RequestParam("id") Integer id) {
        return studentService.errorMessage(id);
    }

    @RequestMapping("/feign/student/queryStudentTimeout")
    @Override
    public String queryStudentTimeout(@RequestParam("millis") int millis) {
        log.info("provider--->" + millis);
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "provider--->" + millis;
    }
}

服务端接口必须定义跟feign客户端相同的url

5. 参数传递

调用方

@PostMapping("/student/saveStudent")
String saveStudent(@RequestBody Student student);

@GetMapping("/student/getStudentById")
String getStudentById(@RequestParam("id") Integer id);

服务方

@RequestMapping("/feign/student/getAllStudent")
@Override
public String getAllStudent() {
    return studentService.getAllStudent();
}

@RequestMapping("/feign/student/getStudentById")
@Override
public String queryStudentById(@RequestParam("id") Integer id) {
    return studentService.queryStudentById(id);
}

实际上传的是字符串

6. 服务降级

fallback: 应急计划

接口上已经指明了服务名和降级方法, 如下

@FeignClient(
    name = "MICRO-ORDER", // 服务名
    path = "/feign", // 前缀
	fallbackFactory = StudentServiceFallbackFactory.class // 降级类
)
public interface StudentService {

降级类

@Slf4j
@Component
public class StudentServiceFallbackFactory implements FallbackFactory<StudentService> {

    @Override
    public StudentService create(Throwable throwable) {
		// 可以获取到异常
        if(throwable == null) {
            return null;
        }
        final String msg = throwable.getMessage();
        log.info("exception:" + msg);
        return new StudentService() {
            @Override
            public String getAllStudent() { // 降级的实例类是直接new出来匿名类, 不再走网络请求了
                log.info("exception=====getAllStudent==========" + msg);
                return msg;
            }

            @Override
            public String saveStudent(Student student) {
                log.info("exception=====saveStudent==========" + msg);
                return msg;
            }

            @Override
            public String getStudentById(Integer id) {
                log.info("exception=====getStudentById==========" + msg);
                return msg;
            }

            @Override
            public String errorMessage(Integer id) {
                log.info("exception=====errorMessage==========" + msg);
                return msg;
            }
        };
    }
}

这里在调用对应feign客户端方法出现异常了,就会回调到create方法中,最终会回调到对应的客户端方法中。

7. Feign的异常过滤器

这个过滤器是对异常信息的再封装,把feign的异常信息封装成我们系统的通用异常对象

@Configuration
public class FeignErrMessageFilter {

    @Bean
    public ErrorDecoder errorDecoder() {
        return new FeignErrorDecoder();
    }

    /**
     * 当调用服务时,如果服务返回的状态码不是200,就会进入到Feign的ErrorDecoder中
     * {"timestamp":"2020-02-17T14:01:18.080+0000","status":500,"error":"Internal Server Error","message":"/ by zero","path":"/feign/student/errorMessage"}
     * 只有这种方式才能获取所有的被feign包装过的异常信息
     * <p>
     * 这里如果创建的Exception是HystrixBadRequestException
     * 则不会走熔断逻辑,不记住熔断统计
     */
    class FeignErrorDecoder implements ErrorDecoder {

        private Logger logger = LoggerFactory.getLogger(FeignErrorDecoder.class);

        @Override
        public Exception decode(String s, Response response) {

            RuntimeException runtimeException = null;

            try {
                String retMsg = Util.toString(response.body().asReader());
                logger.info(retMsg);

                runtimeException = new RuntimeException(retMsg);

            } catch (IOException e) {
                e.printStackTrace();
            }
            return runtimeException;
        }
    }
}

过滤器把异常返回后,feign前面定义的降级方法就会调到create方法。

测试:

http://localhost:8766/student/getAllStudent

对应的feig url,也就是micro-order的url

http://localhost:8766/feign/student/getAllStudent

4. 分布式配置中心

分布式配置中心解决了什么问题

1、抽取出各模块公共的部分,做到一处修改各处生效的目标

2、做到系统的高可用,修改了配置文件后可用在个模块动态刷新,不需要重启服务器

1. Springcloud 配置中心服务端搭建

1. pom依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-config-server</artifactId> 
</dependency>

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
2. 启动类
/**
 * post
 * 加密:http://localhost:8767/encrypt?data=123456
 * 解密:http://localhost:8767/decrypt
 */
@SpringBootApplication(scanBasePackages = {"len.hgy"})
@EnableConfigServer
// 注册到eureka
@EnableEurekaClient
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}
3. application.properties配置
server.port=8767

eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:9876/eureka/
spring.application.name=config-server
spring.cloud.config.server.git.uri=https://gitee.com/xxx/yyy.git
spring.cloud.config.server.git.search-paths=cloud-demo
spring.cloud.config.server.git.username=ooo
spring.cloud.config.server.git.password=zzz
#本地缓存目录
spring.cloud.config.server.git.basedir=E:/repository/config-center-cache
#强制从gitee配置中心中拉取配置信息,不走缓存
spring.cloud.config.server.git.force-pull=true

#生成秘钥指令
#keytool -genkeypair -alias config-server -keyalg RSA -keystore config-server.keystore -validity 365
# 123456

#加密配置
encrypt.key-store.location=config-server.keystore
encrypt.key-store.alias=config-server
encrypt.key-store.password=123456
encrypt.key-store.secret=123456

#redis.password={cipher}AQCCoeQc6KFhVwpyVX2BaeFUHvrlAY1PV07E5zkN03tsM8oRA5gdDGJUfws6PRhstrwxd9MIgS2qFYDrKr6CW7VGmXELVN0tR/aHvJLUBijLMJGMuGNT0LUePtSo6c2QHyZbcGn2wRrd434dI2z+SmMMhXPOwq2fJjWhXOGzp4oVitfs4xXFovmU74rw35wbLPbxhfmg+X5oPf0Nw9pz9aSXtIgKecx3fZLMpE3AQ0njwYJE3SsRl+se0K637OarlYOrjAb1lQllHqQE/rjO7lgHTfUnpdsLpDxpZ/VkZg7MpRqPK8YdmjydJf+eNe26CzUTdHV16RuqDWL94kpvu8V0owkmtVzvgEVjcrsdVwn3CDrvv8GocfDrFKgcPxpAvEU=

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin

management.endpoints.web.exposure.include=*
4. keystore生成
# 进入jre/bin
keytool -genkeypair -alias config-server -keyalg RSA -keystore config-server.keystore -validity 365

放入resources目录下面

2. 客户端使用配置中心

客户端只需要指定连接的服务端就行了,从服务端拉取配置信息

1. pom依赖
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
2. properties 配置
spring.cloud.config.profile=dev
spring.cloud.config.label=master
#这种配置是configserver还单机情况,直接连接这个单机服务就行
#spring.cloud.config.uri=http://localhost:8767/
#configserver高可用配置
#开启configserver服务发现功能
spring.cloud.config.discovery.enabled=true
#服务发现的服务名称
spring.cloud.config.discovery.service-id=config-server
3. 客户端快速失败和重试

当客户端连服务端失败时,客户端就快速失败,不进行加载其他的 spring 容器 快速失败

#如果连接不上获取配置有问题,快速响应失败
spring.cloud.config.fail-fast=true
#默认重试的间隔时间,默认1000ms
spring.cloud.config.retry.multiplier=1000
#下一间隔时间的乘数,默认是1.1
#spring.cloud.config.retry.initial-interval=1.1
#最大间隔时间,最大2000ms
spring.cloud.config.retry.max-interval=2000
#最大重试次数,默认6次
spring.cloud.config.retry.max-attempts=6

重试功能 jar 包导入

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

3. 配置信息的加密

在配置中心中,有些信息是比较敏感的,比如密码信息,在配置密码信息的时候有必要对密 码信息加密以免密码信息泄露,springcloud 配置中心也支持配置信息加密的,这里一 RSA 非对称加密举例

1、本地生成秘钥对

cd 到 jdk 的 keytool 目录:…\Java\jdk1.8.0_92\jre\bin

里面有一个 keytool.exe 可执行文件

2、执行指令生成秘钥文件

keytool -genkeypair -alias config-server -keyalg RSA -keystore config-server.keystore -validity 365 

指令执行成功后会在 bin 目录生成一个 config-server.keystore 文件,把该文件 copy 到配置中

心服务工程中 resources 目录下。

3、服务端工程配置

Properties 配置文件

添加秘钥配置

#加密配置 
encrypt.key-store.location=config-server.keystore 
encrypt.key-store.alias=config-server 
encrypt.key-store.password=123456 
encrypt.key-store.secret=123456

pom 中添加静态文件扫描,让能够扫描到.keystore 文件

<resource>
    <directory>src/main/resources</directory>
    <includes>
        <include>**/*.properties</include>
        <include>**/*.keystore</include>
    </includes>
    <filtering>false</filtering>
</resource>

4、密码加密和解密接口

在服务端中有提供对信息加密和解密接口的

加密接口:http://localhost:8767/encrypt?data=123456,post 请求

解密接口:http://localhost:8767/decrypt,post 请求

5、代码仓库密文配置

redis.password={cipher}AQCCoeQc6KFhVwpyVX2BaeFUHvrlAY1PV07E5zkN03tsM8oRA5gdDGJUfws6PRhstrwxd9MIgS2qFYDrKr6CW7VGmXELVN0tR/aHvJLUBijLMJGMuGNT0LUePtSo6c2QHyZbcGn2wRrd434dI2z+SmMMhXPOwq2fJjWhXOGzp4oVitfs4xXFovmU74rw35wbLPbxhfmg+X5oPf0Nw9pz9aSXtIgKecx3fZLMpE3AQ0njwYJE3SsRl+se0K637Oarl798809/rjO7lgHTfUnpdsLpDxpZ/VkZg7MpRqPK8YdmjydJf+eNe26CzUTdHV16RuqDWL94kpvu8V0owkmtVzvgEVjcrsdVwn3CDrvv8GocfDrFKgcPxpAvEU=

密文前面一定要加上{cipher}标识这个是密文配置,需要服务端来解密的。

4. 配置动态加载刷新

这个是一个革命性的功能,在运行期修改配置文件后,我们通过这个动态刷新功能可以不重 启服务器,这样我们系统理论上可以保证 7*24 小时的工作

1、Environment 的动态刷新

动态刷新其实要有一个契机,其实这个契机就是手动调用刷新接口,如果你想刷新哪台主机 的配置,就调用哪台注解的刷新接口 刷新接口为:

http://localhost:8088/actuator/refresh

2、@Value 注入的属性动态刷新

其实前面在调用刷新接口后,@Value 注入的属性是没有刷新的还是老的配置,这个也好理 解,@Value 注入的属性是项目启动就已经定了的。如果要使@Value 属性也刷新,就必须要 在类上面加上:@RefreshScope 注解。

但是调用每台主机的刷新接口显然太麻烦了,如果需要刷新的集群机器有几百台,是不是就 需要手动调用几百次呢,这几乎是一个不能完成的工作量。 Springcloud 中也提供了消息总线的东西,借助 mq 来完成消息的广播,当需要刷新时我们就 只要调用一次刷新接口即可。

5. 消息总线

消息总线其实很简单,就是为了解决一点刷新的功能,在一个点调用请求刷新接口,然后所 有的在消息总线中的端点都能接到刷新的消息,所有我们必须把每一个端点都拉入到消息总线中来。

1、jar 导入
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
2、properties 配置

其实就是连接 mq 的配置

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin
spring.rabbitmq.virtual-host=my_vhost

配置中心客户端配置自动刷新(刷新的url是配置中心的服务端)

spring.cloud.bus.refresh.enabled=true
spring.cloud.bus.trace.enabled=true

通过这两步就已经完成了拉入消息总线的工作了。

如果要刷新配置,就只要调用任意一个消息总线端点调用刷新接口即可,其他的端点就会收

到刷新配置的消息。

刷新接口:http://localhost:8767/actuator/bus-refresh

这个接口也可以配置到gitee中, github类似, 如下图

image-20220222220143533

image-20220222220332735

消息总线弊端就是太重了,一个集群通知刷新配置功能,还得用一个 rabbitmq 来做,如果项目中根本用不到 rabbitmq 呢?就加大了项目负担,加大了维护成本。

6. 分布式配置中心的配置优先级

根据处于的位置,配置分为远程配置和本地配置文件

根据配置的名称可以分为默认配置文件(application.properties),应用配置文件(服务名.properties)

根据是否需要profile激活分为,默认配置文件和profile配置文件(application-dev.properties, 服务名-dev.properties)

假设服务名是: micro-order

因此配置文件细分如下8中

远程分布式配置中心可以生效的配置

application.properties

application-dev.properties

micro-order.properties

micro-order-dev.properties

本地配置文件可以生效的配置

application.properties

application-dev.properties

bootstrap.properties

# bootstrap-dev.properties, 这个配置是不生效的, 就是说bootstrap.properties不支持profile配置

优先级测试结果如下:

激活profile dev情况下:

远程配置

micro-order-dev.properties>application-dev.properties>micro-order.properties>application.properties

本地配置

application-dev.properties>application.properties>bootstrap.properties

远程配置>本地配置

总结就是: 远程优先级高于本地,profile优先级高于非profile, 应用配置优先级高于默认配置(application.properties>bootstrap.properties)

关于bootstrap.properties和application.properties优先级说明, bootstrap.properties由spring cloud的上下文先加载,而application.properties有spring cloud的子上下文spring boot上下文后加载, 后加载的application.properties生效, 上面说的优先级指的是生效的优先级, 不是加载的优先级

5. Zuul服务网关

Zuul是分布式springcloud项目的流量入口,理论上所有进入到微服务系统的请求都要经过zuul来过滤和路由。

1. zuul服务网关的搭建

pom依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>

启动类

@SpringBootApplication
@EnableZuulProxy
//@EnableZuulServer
public class NetflixZuulStarter {
    public static void main(String[] args) {
        SpringApplication.run(NetflixZuulStarter.class, args);
    }

    @Bean
    @RefreshScope
    @ConfigurationProperties("zuul")
    @Primary
    public ZuulProperties zuulProperties() {
        return new ZuulProperties();
    }
}

2. 配置

application.properties

# 使用路径方式匹配路由规则。
# 参数key结构: zuul.routes.customName.path=xxx
# 用于配置路径匹配规则。
# 其中customName自定义。通常使用要调用的服务名称,方便后期管理
# 可使用的通配符有: * ** ?
# ? 单个字符
# * 任意多个字符,不包含多级路径
# ** 任意多个字符,包含多级路径
zuul.routes.micro-web.path=/web/**

# 参数key结构: zuul.routes.customName.url=xxx
# url用于配置符合path的请求路径路由到的服务地址。
#zuul.routes.micro-web.url=http://localhost:8080/

# key结构 : zuul.routes.customName.serviceId=xxx
# serviceId用于配置符合path的请求路径路由到的服务名称。
zuul.routes.micro-web.serviceId=micro-web-no

#zuul.routes.micro-web1.path=/web/path/**
#zuul.routes.micro-web1.serviceId=micro-web-no
# ignored service id pattern
# 配置不被zuul管理的服务列表。多个服务名称使用逗号','分隔。
# 配置的服务将不被zuul代理。
#zuul.ignored-services=eureka-application-service

# 此方式相当于给所有新发现的服务默认排除zuul网关访问方式,只有配置了路由网关的服务才可以通过zuul网关访问
# 通配方式配置排除列表。
zuul.ignored-services=*

# 通配方式配置排除网关代理路径。所有符合ignored-patterns的请求路径都不被zuul网关代理。
zuul.ignored-patterns=/**/local/**

# prefix URL pattern 前缀路由匹配
# 配置请求路径前缀,所有基于此前缀的请求都由zuul网关提供代理。
#zuul.prefix=/api

management.endpoints.web.exposure.include=*

# http://localhost:6060/actuator/hystrix.stream

#配置敏感请求头过滤
#针对某个服务传输指定的headers信息 ,默认是过滤掉 Cookie,Set-Cookie,Authorization 这三个信息的
#这里置空就是不要过滤掉这三个
zuul.routes.micro-web.sensitive-headers=

#指定全局的headers传输,对所有路由的微服务
#zuul.sensitive-headers=Cookie,Set-Cookie,Authorization

#添加host头信息,标识最初的服务端请求地址
zuul.add-host-header=true

#默认添加  X-Forwarded-*头域
zuul.add-proxy-headers=true

# Zuul本地跳转
zuul.routes.zuul-server.path=/local/**
zuul.routes.zuul-server.url=forward:/local

# Zuul跳转到指定地址
zuul.routes.blog.path=/blog/**
zuul.routes.blog.url=http://localhost:8003/

#全局关闭重试
zuul.retryable=false
#关闭该服务的重试
zuul.routes.micro-web.retryable=false

ribbon.ConnectTimeout=2000
ribbon.ReadTimeout=10000
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=100000

bootstrap.properties

spring.application.name=api-gateway
server.port=8888

eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:9876/eureka/

spring.cloud.config.profile=dev
spring.cloud.config.label=master
#这种配置是configserver还单机情况,直接连接这个单机服务就行
spring.cloud.config.uri=http://localhost:8767/
#configserver高可用配置
#开启configserver服务发现功能
#spring.cloud.config.discovery.enabled=true
#服务发现的服务名称
#spring.cloud.config.discovery.service-id=config-server

#如果连接不上获取配置有问题,快速响应失败
spring.cloud.config.fail-fast=true
#默认重试的间隔时间,默认1000ms
spring.cloud.config.retry.multiplier=1000
#下一间隔时间的乘数,默认是1.1
#spring.cloud.config.retry.initial-interval=1.1
#最大间隔时间,最大2000ms
spring.cloud.config.retry.max-interval=2000
#最大重试次数,默认6次
spring.cloud.config.retry.max-attempts=6

ribbon.ConnectTimeout=1000
ribbon.ReadTimeout=2000
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin
spring.rabbitmq.virtual-host=my_vhost

management.endpoints.web.exposure.include=*

3. 路由动态刷新

配置动态刷新类

@SpringBootApplication
@EnableZuulProxy
//@EnableZuulServer
public class NetflixZuulApp {
    public static void main(String[] args) {
        SpringApplication.run(NetflixZuulApp.class, args);
    }

    @Bean
    @RefreshScope
    @ConfigurationProperties("zuul")
    @Primary
    public ZuulProperties zuulProperties() {
        return new ZuulProperties();
    }
}

获取路由规则的接口

http://localhost:8888/actuator/routes

4. zuul过滤器

Zuul大部分功能都是通过过滤器来实现的,Zuul定义了4种标准的过滤器类型,这些过滤器类型对应于请求的典型生命周期。

a、pre:这种过滤器在请求被路由之前调用。可利用这种过滤器实现身份验证、在集群中选择请求的微服务,记录调试信息等。

b、routing: 这种过滤器将请求路由到微服务。这种过滤器用于构建发送给微服务的请求,并使用apache httpclient或netflix ribbon请求微服务。

c、post: 这种过滤器在路由到微服务以后执行。这种过滤器可用来为响应添加标准的http header、收集统计信息和指标、将响应从微服务发送给客户端等。

e、error: 在其他阶段发送错误时执行该过滤器

执行流程

image-20220225004528180

通过网关访问:

http://localhost:8888/web/user/queryUser

# micro-web服务路由的前缀
zuul.routes.micro-web.path=/web/**
# 通过服务id方式路由
zuul.routes.micro-web.serviceId=micro-web
# 除了zuul.routes.*.path之外的全部忽略
zuul.ignored-services=*

6. Springcloud admin

Springcloud admin是基于actuator, 把actuator负责统计数据,admin是根据统计出来的数据来进行展示的,可以很好的监控整个微服务系统中的实例运行情况信息。

1. pom依赖

<dependencies>
    <dependency>
        <groupId>de.codecentric</groupId>
        <artifactId>spring-boot-admin-starter-server</artifactId>
        <version>2.2.2</version>
        <!--<version>LATEST</version>-->
    </dependency>
    <dependency>
        <groupId>de.codecentric</groupId>
        <artifactId>spring-boot-admin-server-ui</artifactId>
        <version>2.2.2</version>
        <!--可以使用LATEST寻找我们不知道的版本号, 必须大写-->
        <!--<version>LATEST</version>-->
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

2. 启动类加注解

@SpringBootApplication
@EnableEurekaClient
@EnableAdminServer
public class AdminServerApp {
    public static void main(String[] args) {
        SpringApplication.run(AdminServerApp.class,args);
    }
}

3. 安全配置

application.properties

spring.application.name=admin-server
server.port=8889
#是否注册到eureka
eureka.client.registerWithEureka=true
#是否从eureka中拉取注册信息
eureka.client.fetchRegistry=true
eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:9876/eureka/

# 安全配置
spring.security.user.name=admin
spring.security.user.password=admin
eureka.instance.metadata-map.user.name=${spring.security.user.name}
eureka.instance.metadata-map.user.password=${spring.security.user.password}

SecuritySecureConfig

package len.hgy;

import de.codecentric.boot.admin.server.config.AdminServerProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;

@Configuration
public class SecuritySecureConfig extends WebSecurityConfigurerAdapter {

    private final String adminContextPath;

    public SecuritySecureConfig(AdminServerProperties adminServerProperties) {
        this.adminContextPath = adminServerProperties.getContextPath();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        SavedRequestAwareAuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
        successHandler.setTargetUrlParameter("redirectTo");
        http.authorizeRequests()
                .antMatchers(adminContextPath + "/assets/**").permitAll()
                .antMatchers(adminContextPath + "/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().loginPage(adminContextPath + "/login").successHandler(successHandler).and()
                .logout().logoutUrl(adminContextPath + "/logout").and()
                .httpBasic().and()
                .csrf().disable();
    }
}

4. 登录查看

http://localhost:8889/applications

应用强

http://localhost:8889/wallboard

image-20220225012200152

7. Spring Cloud OAuth2

1. 微服务系统的权限校验

image-20220225012338080

采用 token 认证的方式校验是否有接口调用权限,然后在下游系统设置访问白名单只允许 zuul 服务器访问。理论上 zuul 服务器是不需要进行权限校验的,因为 zuul 服务器没有接口, 不需要从 zuul 调用业务接口,zuul 只做简单的路由工作。下游系统在获取到 token 后,通过 过滤器把 token 发到认证服务器校验该 token 是否有效,如果认证服务器校验通过就会携带 这个 token 相关的验证信息传回给下游系统,下游系统根据这个返回结果就知道该 token 具 有的权限是什么了。所以校验 token 的过程,涉及到下游系统和认证服务器的交互,这点不 好。占用了宝贵的请求时间。

2. 获取 token 的过程

Token 是通过一个单独的认证服务器来颁发的,只有具备了某种资质认证服务器才会把 token 给申请者。

1、平台认证申请

平台认证申请往往是一个比较繁琐的过程,需要申请方提供比较完整的认证申请材料,比如 公司资质,营业执照等等信息,提交申请后认证方审核通过后,该平台才会允许申请 token。

image-20220225012743291
2、获取 token

获取 token 在 oauth2.0 里面有 4 中模式

3. 客户端模式

image-20220227153538588

我们可以看到客户端模式申请 token,只要带上有平台资质客户端 id、客户端密码、然后 带上授权类型是客户端授权模式,带上 scope 就可以了。这里要注意的是客户端必须是具有资质的。

基于 redis 存储 token 信息的认证服务器搭建

1. 依赖
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-data</artifactId>
    </dependency>
    <dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
      <!--不要随意指定版本, 很容易不兼容-->
    </dependency>
    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.8.2</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      <!-- 1.5的版本默认采用的连接池技术是jedis  2.0以上版本默认连接池是 lettuce, 在这里采用jedis,所以需要排除lettuce的jar -->
      <exclusions>
        <exclusion>
          <groupId>redis.clients</groupId>
          <artifactId>jedis</artifactId>
        </exclusion>
        <exclusion>
          <groupId>io.lettuce</groupId>
          <artifactId>lettuce-core</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- 把mybatis的启动器引入 -->
    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
      <version>1.0.0</version>
    </dependency>
  </dependencies>
2. 启动类
/**
 * EnableResourceServer注解开启资源服务,因为程序需要对外暴露获取token的API和验证token的API所以该程序也是一个资源服务器
 */
@SpringBootApplication
@EnableEurekaClient
@EnableResourceServer
public class CloudSecurityRedisApp {
    public static void main(String[] args) {
        SpringApplication.run(CloudSecurityRedisApp.class, args);
    }
}

application.properties

spring.application.name=micro-security
server.port=8030
eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:9876/eureka/

# security.oauth2.resource.filter-order=3

# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=localhost
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-idle=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=
# 连接池中的最大空闲连接
# spring.redis.jedis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=0

logging.level.org.springframework.security=debug
3. 服务端配置

认证配置

@Configuration // spring 配置类
@EnableAuthorizationServer // 一定要开启认证服务
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

  @Autowired
  private AuthenticationManager authenticationManager;

  @Autowired
  private RedisConnectionFactory connectionFactory;

  @Autowired
  private UserDetailsService userDetailsService;

  @Bean // 配置存储token的类
  public TokenStore tokenStore() {
    return new MyRedisTokenStore(connectionFactory);
  }

  /**
   * AuthorizationServerEndpointsConfigurer:用来配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token
   * services)。
   */
  @Override
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(authenticationManager).userDetailsService(
            userDetailsService) // 若无,refresh_token会有UserDetailsService is required错误
        .tokenStore(tokenStore())
        .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
  }

  /**
   * AuthorizationServerSecurityConfigurer 用来配置令牌端点(Token Endpoint)的安全约束
   */
  @Override
  public void configure(AuthorizationServerSecurityConfigurer security) {
    // 允许表单认证
    security.allowFormAuthenticationForClients().tokenKeyAccess("permitAll()")
        .checkTokenAccess("isAuthenticated()");
  }

  /**
   * <p>ClientDetailsServiceConfigurer:</p>
   * 用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息
   * <p>1.授权码模式(authorization code) </p>
   * <p>2.简化模式(implicit)</p>
   * <p>3.密码模式(resource owner password credentials)</p>
   * <p>4.客户端模式(client credentials)</p>
   */
  @Override
  public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    String finalSecret = "{bcrypt}" + new BCryptPasswordEncoder().encode("123456");

    clients.
//                jdbc(dataSource).
    inMemory().withClient("micro-web").resourceIds("micro-web")
        .authorizedGrantTypes("client_credentials", "refresh_token")
        .scopes("all", "read", "write", "aa").authorities("client_credentials").secret(finalSecret)
        .accessTokenValiditySeconds(1200).refreshTokenValiditySeconds(50000).and()
        .withClient("client_2").resourceIds("client_2")
        .authorizedGrantTypes("password", "refresh_token").scopes("server").authorities("oauth2")
        .secret(finalSecret).accessTokenValiditySeconds(1200).refreshTokenValiditySeconds(50000);
  }
}

存储配置

public class MyRedisTokenStore implements TokenStore {

    private static final String ACCESS = "access:";
    private static final String AUTH_TO_ACCESS = "auth_to_access:";
    private static final String AUTH = "auth:";
    private static final String REFRESH_AUTH = "refresh_auth:";
    private static final String ACCESS_TO_REFRESH = "access_to_refresh:";
    private static final String REFRESH = "refresh:";
    private static final String REFRESH_TO_ACCESS = "refresh_to_access:";
    private static final String CLIENT_ID_TO_ACCESS = "client_id_to_access:";
    private static final String UNAME_TO_ACCESS = "uname_to_access:";

    private final RedisConnectionFactory connectionFactory;
    private AuthenticationKeyGenerator authenticationKeyGenerator = new DefaultAuthenticationKeyGenerator();
    private RedisTokenStoreSerializationStrategy serializationStrategy = new JdkSerializationStrategy();

    private String prefix = "";

    public MyRedisTokenStore(RedisConnectionFactory connectionFactory) {
        this.connectionFactory = connectionFactory;
    }

    public void setAuthenticationKeyGenerator(AuthenticationKeyGenerator authenticationKeyGenerator) {
        this.authenticationKeyGenerator = authenticationKeyGenerator;
    }

    public void setSerializationStrategy(RedisTokenStoreSerializationStrategy serializationStrategy) {
        this.serializationStrategy = serializationStrategy;
    }

    public void setPrefix(String prefix) {
        this.prefix = prefix;
    }

    private RedisConnection getConnection() {
        return connectionFactory.getConnection();
    }

    private byte[] serialize(Object object) {
        return serializationStrategy.serialize(object);
    }

    private byte[] serializeKey(String object) {
        return serialize(prefix + object);
    }

    private OAuth2AccessToken deserializeAccessToken(byte[] bytes) {
        return serializationStrategy.deserialize(bytes, OAuth2AccessToken.class);
    }

    private OAuth2Authentication deserializeAuthentication(byte[] bytes) {
        return serializationStrategy.deserialize(bytes, OAuth2Authentication.class);
    }

    private OAuth2RefreshToken deserializeRefreshToken(byte[] bytes) {
        return serializationStrategy.deserialize(bytes, OAuth2RefreshToken.class);
    }

    private byte[] serialize(String string) {
        return serializationStrategy.serialize(string);
    }

    private String deserializeString(byte[] bytes) {
        return serializationStrategy.deserializeString(bytes);
    }

    @Override
    public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) {
        String key = authenticationKeyGenerator.extractKey(authentication);
        byte[] serializedKey = serializeKey(AUTH_TO_ACCESS + key);
        byte[] bytes = null;
        RedisConnection conn = getConnection();
        try {
            bytes = conn.get(serializedKey);
        } finally {
            conn.close();
        }
        OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
        if (accessToken != null) {
            OAuth2Authentication storedAuthentication = readAuthentication(accessToken.getValue());
            if ((storedAuthentication == null || !key.equals(authenticationKeyGenerator.extractKey(storedAuthentication)))) {
                // Keep the stores consistent (maybe the same user is
                // represented by this authentication but the details have
                // changed)
                storeAccessToken(accessToken, authentication);
            }

        }
        return accessToken;
    }

    @Override
    public OAuth2Authentication readAuthentication(OAuth2AccessToken token) {
        return readAuthentication(token.getValue());
    }

    @Override
    public OAuth2Authentication readAuthentication(String token) {
        byte[] bytes = null;
        RedisConnection conn = getConnection();
        try {
            bytes = conn.get(serializeKey(AUTH + token));
        } finally {
            conn.close();
        }
        OAuth2Authentication auth = deserializeAuthentication(bytes);
        return auth;
    }

    @Override
    public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken token) {
        return readAuthenticationForRefreshToken(token.getValue());
    }

    public OAuth2Authentication readAuthenticationForRefreshToken(String token) {
        RedisConnection conn = getConnection();
        try {
            byte[] bytes = conn.get(serializeKey(REFRESH_AUTH + token));
            OAuth2Authentication auth = deserializeAuthentication(bytes);
            return auth;
        } finally {
            conn.close();
        }
    }

    @Override
    public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
        byte[] serializedAccessToken = serialize(token);
        byte[] serializedAuth = serialize(authentication);
        byte[] accessKey = serializeKey(ACCESS + token.getValue());
        byte[] authKey = serializeKey(AUTH + token.getValue());
        byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + authenticationKeyGenerator.extractKey(authentication));
        byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
        byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());

        RedisConnection conn = getConnection();
        try {
            conn.openPipeline();
            conn.stringCommands().set(accessKey, serializedAccessToken);
            conn.set(authKey, serializedAuth);
            conn.set(authToAccessKey, serializedAccessToken);
            if (!authentication.isClientOnly()) {
                conn.rPush(approvalKey, serializedAccessToken);
            }
            conn.rPush(clientId, serializedAccessToken);
            if (token.getExpiration() != null) {
                int seconds = token.getExpiresIn();
                conn.expire(accessKey, seconds);
                conn.expire(authKey, seconds);
                conn.expire(authToAccessKey, seconds);
                conn.expire(clientId, seconds);
                conn.expire(approvalKey, seconds);
            }
            OAuth2RefreshToken refreshToken = token.getRefreshToken();
            if (refreshToken != null && refreshToken.getValue() != null) {
                byte[] refresh = serialize(token.getRefreshToken().getValue());
                byte[] auth = serialize(token.getValue());
                byte[] refreshToAccessKey = serializeKey(REFRESH_TO_ACCESS + token.getRefreshToken().getValue());
                conn.set(refreshToAccessKey, auth);
                byte[] accessToRefreshKey = serializeKey(ACCESS_TO_REFRESH + token.getValue());
                conn.set(accessToRefreshKey, refresh);
                if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
                    ExpiringOAuth2RefreshToken expiringRefreshToken = (ExpiringOAuth2RefreshToken) refreshToken;
                    Date expiration = expiringRefreshToken.getExpiration();
                    if (expiration != null) {
                        int seconds = Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L)
                                .intValue();
                        conn.expire(refreshToAccessKey, seconds);
                        conn.expire(accessToRefreshKey, seconds);
                    }
                }
            }
            conn.closePipeline();
        } finally {
            conn.close();
        }
    }

    private static String getApprovalKey(OAuth2Authentication authentication) {
        String userName = authentication.getUserAuthentication() == null ? ""
                : authentication.getUserAuthentication().getName();
        return getApprovalKey(authentication.getOAuth2Request().getClientId(), userName);
    }

    private static String getApprovalKey(String clientId, String userName) {
        return clientId + (userName == null ? "" : ":" + userName);
    }

    @Override
    public void removeAccessToken(OAuth2AccessToken accessToken) {
        removeAccessToken(accessToken.getValue());
    }

    @Override
    public OAuth2AccessToken readAccessToken(String tokenValue) {
        byte[] key = serializeKey(ACCESS + tokenValue);
        byte[] bytes = null;
        RedisConnection conn = getConnection();
        try {
            bytes = conn.get(key);
        } finally {
            conn.close();
        }
        OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
        return accessToken;
    }

    public void removeAccessToken(String tokenValue) {
        byte[] accessKey = serializeKey(ACCESS + tokenValue);
        byte[] authKey = serializeKey(AUTH + tokenValue);
        byte[] accessToRefreshKey = serializeKey(ACCESS_TO_REFRESH + tokenValue);
        RedisConnection conn = getConnection();
        try {
            conn.openPipeline();
            conn.get(accessKey);
            conn.get(authKey);
            conn.del(accessKey);
            conn.del(accessToRefreshKey);
            // Don't remove the refresh token - it's up to the caller to do that
            conn.del(authKey);
            List<Object> results = conn.closePipeline();
            byte[] access = (byte[]) results.get(0);
            byte[] auth = (byte[]) results.get(1);

            OAuth2Authentication authentication = deserializeAuthentication(auth);
            if (authentication != null) {
                String key = authenticationKeyGenerator.extractKey(authentication);
                byte[] authToAccessKey = serializeKey(AUTH_TO_ACCESS + key);
                byte[] unameKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(authentication));
                byte[] clientId = serializeKey(CLIENT_ID_TO_ACCESS + authentication.getOAuth2Request().getClientId());
                conn.openPipeline();
                conn.del(authToAccessKey);
                conn.lRem(unameKey, 1, access);
                conn.lRem(clientId, 1, access);
                conn.del(serialize(ACCESS + key));
                conn.closePipeline();
            }
        } finally {
            conn.close();
        }
    }

    @Override
    public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) {
        byte[] refreshKey = serializeKey(REFRESH + refreshToken.getValue());
        byte[] refreshAuthKey = serializeKey(REFRESH_AUTH + refreshToken.getValue());
        byte[] serializedRefreshToken = serialize(refreshToken);
        RedisConnection conn = getConnection();
        try {
            conn.openPipeline();
            conn.set(refreshKey, serializedRefreshToken);
            conn.set(refreshAuthKey, serialize(authentication));
            if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
                ExpiringOAuth2RefreshToken expiringRefreshToken = (ExpiringOAuth2RefreshToken) refreshToken;
                Date expiration = expiringRefreshToken.getExpiration();
                if (expiration != null) {
                    int seconds = Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L)
                            .intValue();
                    conn.expire(refreshKey, seconds);
                    conn.expire(refreshAuthKey, seconds);
                }
            }
            conn.closePipeline();
        } finally {
            conn.close();
        }
    }

    @Override
    public OAuth2RefreshToken readRefreshToken(String tokenValue) {
        byte[] key = serializeKey(REFRESH + tokenValue);
        byte[] bytes = null;
        RedisConnection conn = getConnection();
        try {
            bytes = conn.get(key);
        } finally {
            conn.close();
        }
        OAuth2RefreshToken refreshToken = deserializeRefreshToken(bytes);
        return refreshToken;
    }

    @Override
    public void removeRefreshToken(OAuth2RefreshToken refreshToken) {
        removeRefreshToken(refreshToken.getValue());
    }

    public void removeRefreshToken(String tokenValue) {
        byte[] refreshKey = serializeKey(REFRESH + tokenValue);
        byte[] refreshAuthKey = serializeKey(REFRESH_AUTH + tokenValue);
        byte[] refresh2AccessKey = serializeKey(REFRESH_TO_ACCESS + tokenValue);
        byte[] access2RefreshKey = serializeKey(ACCESS_TO_REFRESH + tokenValue);
        RedisConnection conn = getConnection();
        try {
            conn.openPipeline();
            conn.del(refreshKey);
            conn.del(refreshAuthKey);
            conn.del(refresh2AccessKey);
            conn.del(access2RefreshKey);
            conn.closePipeline();
        } finally {
            conn.close();
        }
    }

    @Override
    public void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) {
        removeAccessTokenUsingRefreshToken(refreshToken.getValue());
    }

    private void removeAccessTokenUsingRefreshToken(String refreshToken) {
        byte[] key = serializeKey(REFRESH_TO_ACCESS + refreshToken);
        List<Object> results = null;
        RedisConnection conn = getConnection();
        try {
            conn.openPipeline();
            conn.get(key);
            conn.del(key);
            results = conn.closePipeline();
        } finally {
            conn.close();
        }
        if (results == null) {
            return;
        }
        byte[] bytes = (byte[]) results.get(0);
        String accessToken = deserializeString(bytes);
        if (accessToken != null) {
            removeAccessToken(accessToken);
        }
    }

    @Override
    public Collection<OAuth2AccessToken> findTokensByClientIdAndUserName(String clientId, String userName) {
        byte[] approvalKey = serializeKey(UNAME_TO_ACCESS + getApprovalKey(clientId, userName));
        List<byte[]> byteList = null;
        RedisConnection conn = getConnection();
        try {
            byteList = conn.lRange(approvalKey, 0, -1);
        } finally {
            conn.close();
        }
        if (byteList == null || byteList.size() == 0) {
            return Collections.<OAuth2AccessToken> emptySet();
        }
        List<OAuth2AccessToken> accessTokens = new ArrayList<OAuth2AccessToken>(byteList.size());
        for (byte[] bytes : byteList) {
            OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
            accessTokens.add(accessToken);
        }
        return Collections.<OAuth2AccessToken> unmodifiableCollection(accessTokens);
    }

    @Override
    public Collection<OAuth2AccessToken> findTokensByClientId(String clientId) {
        byte[] key = serializeKey(CLIENT_ID_TO_ACCESS + clientId);
        List<byte[]> byteList = null;
        RedisConnection conn = getConnection();
        try {
            byteList = conn.lRange(key, 0, -1);
        } finally {
            conn.close();
        }
        if (byteList == null || byteList.size() == 0) {
            return Collections.<OAuth2AccessToken> emptySet();
        }
        List<OAuth2AccessToken> accessTokens = new ArrayList<OAuth2AccessToken>(byteList.size());
        for (byte[] bytes : byteList) {
            OAuth2AccessToken accessToken = deserializeAccessToken(bytes);
            accessTokens.add(accessToken);
        }
        return Collections.<OAuth2AccessToken> unmodifiableCollection(accessTokens);
    }

}

服务端安全配置

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Bean
  @Override
  protected UserDetailsService userDetailsService() {
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

    String finalPassword = "{bcrypt}" + bCryptPasswordEncoder.encode("123456");
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(
        User.withUsername("hgy").password(finalPassword).authorities("USER").build());
    manager.createUser(
        User.withUsername("admin").password(finalPassword).authorities("USER").build());

    return manager;
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService());
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Bean
  @Override
  public AuthenticationManager authenticationManagerBean() throws Exception {
    AuthenticationManager manager = super.authenticationManagerBean();
    return manager;
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable();
//        http.requestMatchers().anyRequest()
//                .and()
//                .authorizeRequests()
//                .antMatchers("/oauth/**").permitAll();

    http.authorizeRequests()
        .antMatchers("/oauth/**").permitAll()
        .and()
        // .and().antMatcher(" /actuator/health").anonymous().and(), 可以对健康检查匿名, 这样debug就不会打印错误
        .httpBasic().disable();
  }
}

搭建一个redis环境

使用docker搭建

docker pull redis

# start a redis instance
# docker run --name cloud-security-redis -d redis

# create one network
docker network create redis-network

# start with persistent storage
docker run --network redis-network -p 6379:6379 --name cloud-security-redis -d redis redis-server --save 60 1 --loglevel warning

# connecting via redis-cli
docker run -it --network redis-network --rm redis redis-cli -h cloud-security-redis

Oauth2.0 权限校验认证服务器代码配置和客户端代码配置基本上是固定写法,关键是理解认证授权过程,oauth2.0 授权流程基本上是行业认证授权的标准了。

获取token: http://localhost:8030/oauth/token POST

image-20220227193355129

response

{
    "access_token": "9b924793-549d-4f32-8d6e-8d811d41bd03",
    "token_type": "bearer",
    "expires_in": 1199,
    "scope": "all"
}

token和客户端绑定的

4. 密码模式

密码模式获取 token,也就是说在获取 token 过程中必须带上用户的用户名和密码,获取到 的 token 是跟用户绑定的。

密码模式获取 token:

客户端 id 和客户端密码必须要经过 base64 算法加密,并且放到 header 中,

加密模式为: Base64(clientId:clientPassword)

生成base64

通过浏览器js操作

window.btoa("client_2:123456");

Y2xpZW50XzI6MTIzNDU2

Authorization=Basic Y2xpZW50XzI6MTIzNDU2

url

post http://localhost:8030/oauth/token

Authorization

会生成下面的头部信息

image-20220301003910677

form-data

image-20220227231328517

上面的也可以通过oauth2.0直接统一配置

对应的配置如下

image-20220227230854837

用户的token和客户端+用户绑定

response

{
    "access_token": "13a6d93a-9ef7-4a10-aaaf-3e5b9a39c095",
    "token_type": "bearer",
    "refresh_token": "5f5da4da-6416-419b-b6cf-fbfa6fce7e5d",
    "expires_in": 1199,
    "scope": "server"
}
1、密码模式认证服务器代码配置
@Configuration
@EnableAuthorizationServer // 加上注解,说明是认证服务器
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

1、客户端配置,客户端是存储在表中的

对应的客户端表为:oauth_client_details

image-20220228000356150

把客户端信息加入到 oauth2.0 框架中

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.withClientDetails(clientDetailsService);
}
2、token 的保存方式,token 是也存储在数据库中
@Bean
public TokenStore tokenStore() {
    return new JdbcTokenStore(dataSource);
}

对应的 token 存储表为:oauth_access_token

token 是跟用户绑定的。

设置 token 的属性

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    // redisTokenStore
    //        endpoints.tokenStore(new MyRedisTokenStore(redisConnectionFactory))
    //                .authenticationManager(authenticationManager)
    //                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);

    // 存数据库
    endpoints.tokenStore(tokenStore).authenticationManager(authenticationManager)
        .userDetailsService(userServiceDetail);

    // 配置tokenServices参数
    DefaultTokenServices tokenServices = new DefaultTokenServices();
    tokenServices.setTokenStore(endpoints.getTokenStore());
    tokenServices.setSupportRefreshToken(false);
    tokenServices.setClientDetailsService(endpoints.getClientDetailsService());
    tokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
    // token有效期
    tokenServices.setAccessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(30)); // 30天
    // token可以重复使用
    // tokenServices.setReuseRefreshToken(false);
    // token刷新时间
    // tokenServices.setRefreshTokenValiditySeconds(60*10);
    endpoints.tokenServices(tokenServices);
}
3、认证服务器 token 校验和校验结果返回接口
@Slf4j
@RestController
@RequestMapping("/security")
public class SecurityController {

    @RequestMapping(value = "/check", method = RequestMethod.GET)
    public Principal getUser(Principal principal) {
        log.info(principal.toString());
        return principal;
    }
}
4、密码模式下游系统配置

1、properties 配置

指定客户端请求认证服务器接口

security.oauth2.resource.user-info-uri=http://127.0.0.1:8030/auth/security/check
security.oauth2.resource.prefer-token-info=false
#security.oauth2.client.id=micro-web
security.oauth2.client.clientId=micro-web
security.oauth2.client.client-secret=123456
security.oauth2.client.access-token-uri=http://api-gateway/auth/oauth/token
security.oauth2.client.grant-type=client_credentials
security.oauth2.client.scope=all

2、声明使用 oauth2.0 框架并说明这是一个客户端

@EnableOAuth2Client // 开启oauth2.0

/**
 * 鉴权过滤器
 * <p>
 * OAuth2AuthenticationProcessingFilter
 */
@EnableOAuth2Client // 开启oauth2.0
@EnableConfigurationProperties
@Configuration
public class OAuth2ClientConfig {


    @Bean
    @ConfigurationProperties(prefix = "security.oauth2.client")
    public ClientCredentialsResourceDetails clientCredentialsResourceDetails() {
        return new ClientCredentialsResourceDetails();
    }

    //    @Bean
    public RequestInterceptor oauth2FeignRequestInterceptor(ClientCredentialsResourceDetails clientCredentialsResourceDetails) {
        return new OAuth2FeignRequestInterceptor(new DefaultOAuth2ClientContext(), clientCredentialsResourceDetails);
    }

    @Bean
    public OAuth2RestTemplate clientCredentialsRestTemplate() {
        return new OAuth2RestTemplate(clientCredentialsResourceDetails());
    }
}

3、开启权限的方法级别注解和指定拦截路径

开启后可以在方法上使用注解: @EnableGlobalMethodSecurity(prePostEnabled = true)

@Configuration
@EnableResourceServer
//启用全局方法安全注解,就可以在方法上使用注解来对请求进行过滤
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

//    @Autowired
//    private TokenStore tokenStore;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        // 配置order访问控制,必须认证后才可以访问
        http.authorizeRequests()
                .antMatchers("/order/**","/user/**").authenticated();
    }

    /*
    * 把token验证失败后,重新刷新token的类设置到 OAuth2AuthenticationProcessingFilter
    * token验证过滤器中
    * */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        super.configure(resources);
        resources.authenticationEntryPoint(new RefreshTokenAuthenticationEntryPoint());
//        resources.tokenStore(tokenStore);
    }
}

认证服务器和下游系统权限校验流程

image-20220301013602393

1、zuul 携带 token 请求下游系统,被下游系统 filter 拦截

2、下游系统过滤器根据配置中的 user-info-uri 请求到认证服务器

3、请求到认证服务器被 filter 拦截进行 token 校验,把 token 对应的用户、和权限从数据库查询出来封装到 Principal

4、认证服务器 token 校验通过后过滤器放行执行 security/check 接口,把 principal 对象返回

5、下游系统接收到 principal 对象后就知道该 token 具备的权限了,就可以进行相应用户对应的 token 的权限执行

数据库方式获取token遇到问题:

“error_description”: “Full authentication is required to access this resource”

spring boot 和 spring cloud版本不兼容导致

使用token请求 GET http://micro-web-security/user/queryUser 接口

对应的网关路由是: /web

网关端口是: 7070

所以请求连接是: http://localhost:7070/web/user/queryUser

image-20220303011027466

Basic Token(Basic base64(username:password))<=>Authorization<=>Bearer Token(Bearer Access Token)

image-20220303011121019

这样也是可以的

image-20220303020906087

5. 调用报错调试
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation

断点发现,获取token时候token信息没有入库

public <T> T queryForObject(String sql, RowMapper<T> rowMapper, @Nullable Object... args) throws DataAccessException {
    List<T> results = query(sql, args, new RowMapperResultSetExtractor<>(rowMapper, 1));
    return DataAccessUtils.nullableSingleResult(results);
}

// select token_id, token from oauth_access_token where token_id = '2e03a3334cfe4ed0eb297e5fe4e010cf';

所以问题还是出现在认证服务器了

认证服务接口调用

image-20220303011628062

生成token时候其实已经报出来了,只不过是一个info级别的信息

image-20220303011854104

断点: org.springframework.security.oauth2.common.OAuth2AccessToken

image-20220303011947466

select token_id, token from oauth_access_token where token_id = ?

从堆栈中寻找关键信息

image-20220303012218807

这个token只是过期被删除了, 问题更应不在此处

看看这里的源码

image-20220303013539315

UserDetailsService returned null, which is an interface contract violation

翻译: UserDetailsService返回null,是接口契约违规

这里报了404

org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoTokenServices#getMap

image-20220303021731527

http://127.0.0.1:8077/auth/security/check

404原因: auth是网关的路由,但是没有通过网关访问

private OAuth2RestOperations restTemplate; // 由于这个restTemplate并不是 RestTemplate, 可能是不支持服务名调用
// http://api-gateway-security/auth/security/check

这里不能使用服务名,是因为这个对象是一个假的RestTemplate

5. 授权码模式

授权码模式获取 token,在获取 token 之前需要有一个获取 code 的过程。

1、获取 code 的流程如下:

image-20220305120540247

http://localhost:7070/auth/oauth/authorize?

client_id=pc&response_type=code&redirect_uri=http://localhost:8083/login/callback

image-20220305120150494

image-20220305120712380

1、用户请求获取 code 的链接
http://localhost:7070/auth/oauth/authorize?client_id=pc&response_type=code&redirect_uri=http://localhost:8083/login/callback
2、提示要输入用户名密码
3、用户名秘密成功则会弹出界面

image-20220305125826758

4、点击 approve 则会回调 redirect_uri 对应的回调地址并且把 code 附带到该回调地址里面
5、根据获取到的 code 获取 token

这里必须带上 redirect_uri 和 code,其他就跟前面的类似

http://localhost:7070/oauth/token

image-20220305130307363

其他配置跟密码模式的是一样的,拿到 token 后就可以访问了。

三种模式比较

1、客户端模式

一般用在无需用户登录的系统做接口的安全校验,因为 token 只需要跟客户端绑定,控制粒 度不够细

2、密码模式

密码模式,token 是跟用户绑定的,可以根据不同用户的角色和权限来控制不同用户的访问权限,相对来说控制粒度更细

3、授权码模式

授权码模式更安全,因为前面的密码模式可能会存在密码泄露后,别人拿到密码也可以照样 的申请到 token 来进行接口访问,而授权码模式用户提供用户名和密码获取后,还需要有一 个回调过程,这个回调你可以想象成是用户的手机或者邮箱的回调,只有用户本人能收到这 个 code,即使用户名密码被盗也不会影响整个系统的安全。

6. JWT 模式

JWT:json web token 是一种无状态的权限认证方式,一般用于前后端分离,时效性比较短的权限校验,jwt 模式获取 token 跟前面的,客户端,密码,授权码模式是一样的,只是需要配置秘钥:

1、生成秘钥文件

cd 到 jdk 的 bin 目录执行该指令,会在 bin 目录下生成 micro-jwt.jks 文件,把该文件放到认 证服务工程里面的 resources 目录下:

keytool -genkeypair -alias micro-jwt ^
	-validity 3650 ^
	-keyalg RSA ^
	-dname "CN=jwt,OU=jtw,O=jwt,L=zurich,S=zurich, C=CH" ^
	-keypass 123456 ^
	-keystore micro-jwt.jks ^
	-storepass 123456 
2、生成公钥

需要先安装openssl

keytool -list -rfc --keystore micro-jwt.jks | openssl x509 -inform pem -pubkey

把生成的公钥内容放到 public.cert 文件中,

输入秘钥口令: 123456

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgsW9GTizo/1DqlJPh+4e
cXnLbhaDeHV2XxMprDpX0wcp4unGN36u+HW/S3GZ00SKzd/+MFhrmt66cZMLwIZu
XrU+1kiYohmrGg6ZmOBB2rPkaqC1mC46We9byaXSROBnYgnpmcCm122BAhMymtvi
xfpwk9vUq0v6jCj1NX9L/ldtNouKekB8FH5DhM1q8k8HiJAVWpNVjbw35FRasFel
XlwmRpYmP0mKltusLdI+wXBrIaIwdwLnTL2PA6z5lNPl/x7H7FsespAVbGWwHhGw
LxpgHEKw55jm5QjvMVdS20jJYUnXTNzNgXaN2RGr3u2awPokBtdHUzXrGSJ32GGy
DQIDAQAB
-----END PUBLIC KEY-----
-----BEGIN CERTIFICATE-----
MIIDUTCCAjmgAwIBAgIEIvLsJjANBgkqhkiG9w0BAQsFADBZMQswCQYDVQQGEwJD
SDEPMA0GA1UECBMGenVyaWNoMQ8wDQYDVQQHEwZ6dXJpY2gxDDAKBgNVBAoTA2p3
dDEMMAoGA1UECxMDanR3MQwwCgYDVQQDEwNqd3QwHhcNMjIwMzA1MDYzNTM1WhcN
MzIwMzAyMDYzNTM1WjBZMQswCQYDVQQGEwJDSDEPMA0GA1UECBMGenVyaWNoMQ8w
DQYDVQQHEwZ6dXJpY2gxDDAKBgNVBAoTA2p3dDEMMAoGA1UECxMDanR3MQwwCgYD
VQQDEwNqd3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCCxb0ZOLOj
/UOqUk+H7h5xectuFoN4dXZfEymsOlfTByni6cY3fq74db9LcZnTRIrN3/4wWGua
3rpxkwvAhm5etT7WSJiiGasaDpmY4EHas+RqoLWYLjpZ71vJpdJE4GdiCemZwKbX
bYECEzKa2+LF+nCT29SrS/qMKPU1f0v+V202i4p6QHwUfkOEzWryTweIkBVak1WN
vDfkVFqwV6VeXCZGliY/SYqW26wt0j7BcGshojB3AudMvY8DrPmU0+X/HsfsWx6y
kBVsZbAeEbAvGmAcQrDnmOblCO8xV1LbSMlhSddM3M2Bdo3ZEave7ZrA+iQG10dT
NesZInfYYbINAgMBAAGjITAfMB0GA1UdDgQWBBRZYQvplmNUWhO7nM7z7ZO8jXEH
1TANBgkqhkiG9w0BAQsFAAOCAQEAIZxFdGcWDETxZ93gbM1202yV9wzsDXWwNQIt
vSC489d55mzWHoM5brS9S4ZHDNg3w76GwUeCyoIGq1utDrsB51A08+d2tq8ropy2
idD5Xc2GkFiEmNzAKvkTNPw77mozssf3EuSpBKcxA409BU12yGrWXPXnuESwm3Px
KD7e5qLduAMLUoYfwsrj05AawwWKrXfqf+/7HjkZ69uHuHL6VRmE9n7/twkKDFA6
1QvQLL+E2GzUEuZuYlc+ZH0mmnHUWfTx/VneHATMB8keaDsPiQVZIGPfEEh03oHb
l4Uczz8dMETnLZg+rNNjNHj5NUD8FgSyQ7NDdNzdHONzdnWpJQ==
-----END CERTIFICATE-----

把公钥文件放到客户端的 resources 目录下。

3. 密码模式获取 jwt token:

http://localhost:7070/jwt/oauth/token

image-20220305145307401

image-20220305145427518

请求参数和密码模式一模一样

image-20220305145821261

Jwt 的 token 信息分成三个部分,用“.”号分割的。

第一步部分:头信息,通过 base64 加密生成

第二部分:有效载荷,通过 base64 加密生成

第三部分:签名,根据头信息中的加密算法通过,RSA(base64(头信息) + “.” + base64(有效载 荷))生成的第三部分内容

可以到 jwt 的官网看看这三部分信息的具体内容:jwt 官网 jwt.io

4. jwt 认证服务器配置
// 配置jks文件
@Bean
protected JwtAccessTokenConverter jwtTokenEnhancer() {
    // 配置jks文件
    KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("micro-jwt.jks"), "123456".toCharArray());
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setKeyPair(keyStoreKeyFactory.getKeyPair("micro-jwt"));
    return converter;
}

// 配置token的存储方式为JwtTokenStore
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    // 配置token的存储方式为JwtTokenStore
    endpoints.tokenStore(tokenStore())
        // 配置用于JWT私钥加密的增强器
        .tokenEnhancer(jwtTokenEnhancer())
        // 配置安全认证管理
        .authenticationManager(authenticationManager)
        .userDetailsService(userServiceDetail);
}
// 装配 JwtTokenStore
@Bean
public TokenStore tokenStore() {
    return new JwtTokenStore(jwtTokenEnhancer());
}
5. application.properties
spring.application.name=micro-jwt
server.port=3031
eureka.client.serviceUrl.defaultZone=http://admin:admin@localhost:8763/eureka/

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/consult?serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456

spring.jpa.hibernate.ddl-auto:update
spring.jpa.show-sql:true

#日志级别
logging.level.root=info
#所有包下面都以debug级别输出
#logging.level.org.springframework.*=debug

#sql日志
logging.level.com.xiangxue.jack.dao=debug
logging.level.org.springframework.security=debug

7. 链路追踪

其实链路追踪就是日志追踪,微服务下日志跟踪,微服务系统之间的调用变得非常复杂,往 往一个功能的调用要涉及到多台微服务主机的调用,那么日志追踪也就要在多台主机之间进 行,人为的去每台主机查看日志这种工作几乎是不能完成的工作,所以需要有专门的日志监 控工具,这里讲的就是 zipkin 工具。

1. 客户端

添加 pom 依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>

客户端配置

客户端使用链路追踪只需要修改 properties 配置即可

# zipkin 客户端配置
spring.sleuth.sampler.percentage=1.0
# 若在同一个注册中心的话可以启用自动发现,省略base-url
#spring.zipkin.locator.discovery.enabled=true
spring.zipkin.base-url=http://localhost:4040/
2. 服务端启动

Zipkin 收集链路追踪日志,zipkin 启动

Java -jar zipkin.jar

也可以通过微服务部署(这样我们的zipkin就可以和其他的微服务一起部署了)

pom依赖

    <dependencies>
        <dependency>
            <groupId>io.zipkin.java</groupId>
            <artifactId>zipkin-server</artifactId>
        </dependency>
        <dependency>
            <!--没有stater依赖, 只有个自动配置的依赖, 这个功能就类似stater-->
            <groupId>io.zipkin.java</groupId>
            <artifactId>zipkin-autoconfigure-ui</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--保存到数据库需要如下依赖-->
<!--        <dependency>
            <groupId>io.zipkin.java</groupId>
            <artifactId>zipkin-autoconfigure-storage-mysql</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>-->
    </dependencies>

配置

server.port=4040
spring.application.name=micro-zipkin

#zipkin数据保存到数据库中需要进行如下配置
#表示当前程序不使用sleuth
#spring.sleuth.enabled=false
#表示zipkin数据存储方式是mysql
#zipkin.storage.type=mysql
#数据库脚本创建地址,当有多个是可使用[x]表示集合第几个元素
#spring.datasource.schema[0]=classpath:/zipkin.sql
#spring boot数据源配置
#spring.datasource.url=jdbc:mysql://localhost:3306/zipkin?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=false
#spring.datasource.username=root
#spring.datasource.password=123456
#spring.datasource.driver-class-name=com.mysql.jdbc.Driver
#spring.datasource.initialize=true
#spring.datasource.continue-on-error=true

启动类

@EnableZipkinServer // 开启zipkin
//@EnableEurekaClient
@SpringBootApplication
public class MicroZipkinApplication {
    public static void main(String[] args) {
        SpringApplication.run(MicroZipkinApplication.class,args);
    }
}
Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐