Spring Cloud笔记(一)

165 阅读10分钟

我正在参加「掘金·启航计划」

1、微服务发展史

单体架构->集群及垂直化->SOA服务->微服务架构

1、单体架构

单体架构:一个war包或者jar包包含一个应用的所有功能,如图所示为单体架构图。

2、集群及垂直化

产生原因:

  • 用户量越来越大,服务端负载越来越高。
  • 业务场景越来越多并且越来越复杂。

优化:

  • 横向增加服务器,单台机器变为多台机器的集群。
  • 按照业务垂直领域进行拆分,减少业务耦合度。

总体思想:

分而治之

3、SOA服务

SOA(Service Oriented Architecture:面向服务的体系结构。

4、微服务架构

微服务架构优点:

  • 复杂度可控:体积小,复杂度低,开发、维护更加方便。
  • 技术选型更加灵活。
  • 可扩展性更强。
  • 独立部署。
  • 容错性:可通过重试、降级机制实现应用层面的容错。

微服务的挑战:

  • 故障排查难:微服务与微服务多次交互,开发人员定位难。
  • 服务监控:服务监控开销大。
  • 分布式架构的复杂度:微服务与微服务调用会存在网络延迟和网络故障无法避免。
  • 服务依赖:服务增多以后,使得系统整体更为复杂。
  • 运维成本:众多微服务的情况下,运维成本随之增加。

微服务技术挑战:

  • 分布式配置中心。
  • 服务路由。
  • 负载均衡。
  • 熔断限流。
  • 莲路监控。

在微服务架构中,每个微服务都是相对于独立开发和运行的组件。而一个完整的微服务架构由一系列的独立运行的微服务组成。而每个微服务之间通过轻量级的通信机制RUST API或RPC完成通信。

实现微服务以后,用户查看商品详情可能需要调用多个微服务才能完成商品详情页面的组装。这也随之带来了新的问题:

  • 客户端需要发起多次请求,增加了网络通信的成本以及客户端处理的复杂度。
  • 服务端的鉴权会分布在每个微服务中处理,客户端对于每个服务的调用需要重复鉴权。(每个微服务都会做重复的事情)
  • 后端的服务架构中,可能不同的服务采用的协议不同,比如Htpp、RPC等,客户端如果需要调用多个微服务,需要适配不用的协议。

SpringCloud Gateway

1. API网关的作用

API网关可以解决上述带来的三个问题。

Client端与Server端新增一个API网关。API网关相当于门面,所有的外部请求先经过网关层。

对于商品详情场景来说,增加了API网关层以后。在API网关层可以把多个服务进行整合,然后提供唯一的业务接口。客户端只需要调用一个接口完成数据的获取与渲染。在网关层消费多个微服务,进行统一的整合,给客户端返回唯一的响应。

对于网关层,不仅仅是做请求的转发和整合,还可以接入其他功能。

    • 针对所有请求进行统一的鉴权、限流、熔断、日志。
    • 协议转换,针对后端多种不同的协议,在网关层统一处理后以Http对外提供服务。(例如DUBBO框架,针对Dubbo服务还需要提供一个web应用进行协议转化)
    • 统一错误码处理。
    • 请求转发,并且可以基于网关实现内、外网隔离。

2. 统一认证鉴权

统一认证鉴权包含两部分。

    • 客户端身份认证:主要判断当前用户是否为合法用户,一般来说使用账号密码验证。但是对于一些复杂的认证场景会采用加密算法来实现,比如公私钥。
    • 访问权限控制,身份认证和访问权限一般是相互联系的,当身份认证通过后,就需要判断用户是否有访问该资源,或者该用户的访问权限是否被限制。

在单体应用中,客户端身份认证及访问权限的控制比较简单,只需要再服务端通过session保存该用户信息即可。但是在微服务架构下,单体应用被拆分多个微服务,鉴权的过程比较复杂。

    • 在微服务系统下,原单体应用下的session方式无法用户微服务场景。
    • 如何实现对每个微服务鉴权。
  • 解决方案
    • 针对问题一:AccessToken、Oauth
    • 针对问题二:鉴权的功能抽离出一个统一认证服务,所有的微服务被访问之前,先访问认证服务进行鉴权。(针对这个问题会出现一个请求多次鉴权操作,增加了网络通信开销)

在增加API网关之后,在网关层进行请求拦截,获取请求中附带的用户身份信息,调用统一认证中心请求进行身份认证,确认完身份以后检查资源访问权限。

3. 灰度发布

互联网产品有一个特点就是迭代的非常快,一般来说是一周一发布的迭代模式。在高频率的迭代模式下,会存在一定的风险。例如:

  • 新老代码的兼容性问题
  • 新功能的发布,用户接受程度,是否会导致用户流失(可以进行A/B实验)
  • 代码存在隐藏bug,导致线上故障。

而针对以上问题,一般来说对于较大的改动一般都会采用灰度发布(又称金丝雀发布)实现平滑过渡。

所谓的灰度发布,就是将要发布的功能先给小部分用户使用,把影响范围控制在非常小的范围。比如A/B实验就是一种灰度发布方式。一部分用户继续使用A功能,另外一小部分用户使用B功能。通过对使用B功能的用户进行做满意度调查,以及对新发布的代码的性能及稳定性指标做评测,逐步放大该新版本的投放,直到全量放开or下线该功能。

对于应用系统来说,可以将新的功能发布到特定的灰度机器上,然后根据设定的规则将部分请求路由到灰度服务器上。

而对用微服务来说,网关是所有流量的入口,因此可以在网关层进行灰度规则的配置与流量的分发,从而实现灰度发布。网关对请求进行拦截后,根据分流引擎配置的分流规则进行请求路由。

4 Spring Cloud GateWay 实战

  • 创建一个spring-cloud-gateway-service工程,引入Spring-boot-starter-web依赖。并创建一个HelloCtrl类,启动该应用
package com.coding.gateway.sample.ctrl;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @description:
 * @author:美式续命
 * @date: 2022/12/4
 * @Copyright:
 */
@RestController
public class HelloCtrl {

    @RequestMapping("/say")
    public String sayHello(){
        return "[spring-cloud-gateway-service]:say hello";
    }
}
  • 创建一个spring-cloud-gateway-sample应用
    • 引入spring-cloud-starter-gateway依赖。
    • application.yml新加路由配置
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
  <version>2.2.9.RELEASE</version>
</dependency>
spring:
cloud:
gateway:
routes:
- predicates:
- Path=/gateway/** # 匹配路径
filters:
- StripPrefix=1 # 跳过前缀
uri: http://localhost:8080/say # 访问路径
server:
port: 8088
  • id:自定义路由ID,具有唯一性。
  • uri:目标服务地址,支持普通以及lb://应用注册服务名称,后者表示从注册中心获取集群服务地址。
  • predicates:路由条件,根据匹配的结果决定是否执行该请求路由。
  • filters:过滤规则,包含pre和pos过滤,StripPrefix=1,表示GateWay根据该配置的值去掉url路径部分前缀(这边去掉一个前缀)。

5 Spring Cloud GateWay 原理分析

如图为请求处理过程。引入几个非常重要的概念。

    • 路由(Route):由ID、目标URI、Predicate集合、filter组成
    • 谓语(Predicate):java8引入的函数式接口,提供了断言的功能。匹配http请求的任何内容,只有为true才会进行转发
    • 过滤去(filter):提供前置和后置过滤

springCloudGateWay启动时基于nettyServer监听一个指定的端口。客户端发起一个请求到网关时,网关会根据一系列的断言去做匹配决定访问哪个route。然后再走过滤器。首先执行pre过滤器,再执行post过滤器。

断言集合

  1. 请求在指定日期之前,BeforeRoutePredicateFactory
  2. 请求在指定日期之后:AfterRoutePredicateFactory
  3. 请求在指定的两个日期之间:BetweenRoutePredicateFactory
spring:
  cloud:
    gateway:
      routes:
        - id: before_route
          uri: https://www.baidu.com
          predicates:
            - After=2022-12-08T23:56:36+08:00[Asia/Shanghai]
spring:
  cloud:
    gateway:
      routes:
        - id: before_route
          uri: https://www.baidu.com
          predicates:
            - Cookie=ccc,d
spring:
  cloud:
    gateway:
      routes:
        - id: before_route
          uri: https://www.baidu.com
          predicates:
            - Header=x-request-id, \d+

6. 自定义GlobalFilter完成对请求的拦截

package com.medicine.smooth.filter;

import java.util.Arrays;
import java.util.List;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.google.common.base.Throwables;
import com.medicine.smooth.utils.jwt.JWTUtils;
import com.medicine.smooth.utils.redis.RedisCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
@Slf4j
public class SignFilter implements GlobalFilter, Ordered {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedisCache redisCache;

    private static final List<String> WHITE_LIST = Arrays.asList("白名单");

    private static final String signOut = "/users/signOut";

    public static final String SOURCES = "sources";
    public static final String VERSION = "version";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        try {
            ServerHttpRequest request = exchange.getRequest();
            HttpHeaders headers = exchange.getRequest().getHeaders();
            String auth = headers.getFirst(HttpHeaders.AUTHORIZATION);
            String sources = headers.getFirst(SOURCES);
            String path = request.getURI().getPath();
            buildCommonParams(exchange, chain, headers);
            if (org.apache.commons.lang3.StringUtils.isNotBlank(auth)) {
                String token = auth.replace("Bearer", "").trim();
                if (StringUtils.hasLength(token) && !"undefined".equalsIgnoreCase(token)) {
                    DecodedJWT verify = JWTUtils.verify(token);
                    String userId = verify.getClaim("userId").asString();
                    String tokenKey = verify.getClaim("tokenSince").asString();

                    if (path.contains("admin") && !"/admin/register".equals(path)) {
                        if (org.apache.commons.lang3.StringUtils.isNotBlank(tokenKey) && !tokenKey.contains(
                            "ADMIN_LOGIN_USERS")) {
                            return getVoidMono(exchange, "{"code":403,"message":"暂无权限","data":null}");
                        }
                    }

                    if (path.equals(signOut)) {
                        stringRedisTemplate.delete(tokenKey);
                        return getVoidMono(exchange, "{"code":200,"message":"退出登录成功","data":null}");
                    }

                    String redisToken = null;
                    if (redisCache.existKey(tokenKey)) {
                        redisToken = redisCache.getCacheObject(tokenKey);
                    }
                    if (org.apache.commons.lang3.StringUtils.equals(redisToken, token)) {
                        return buildUserId(exchange, chain, userId);
                    } else {
                        log.info("token已经过期");
                        return getVoidMono(exchange, "{"code":401,"message":"请登录"}");
                    }
                }
            }
            PathMatcher pathMatcher = new AntPathMatcher();
            for (String url : WHITE_LIST) {
                if (pathMatcher.match(url, path)) {
                    return chain.filter(exchange);
                }
            }
        } catch (Exception e) {
            log.error("解析token异常,异常原因为:{}", Throwables.getStackTraceAsString(e));
        }

        ServerHttpResponse serverHttpResponse = exchange.getResponse();
        log.info("没有权限,请先登录");
        String rsp = "{"code":401,"message":"请登录"}";
        DataBuffer d = serverHttpResponse.bufferFactory().wrap(rsp.getBytes());
        serverHttpResponse.getHeaders().add("Content-Type", "text/plain;charset=UTF-8");
        return serverHttpResponse.writeWith(Flux.just(d));
    }

    private static Mono<Void> getVoidMono(ServerWebExchange exchange, String rsp) {
        ServerHttpResponse serverHttpResponse = exchange.getResponse();
        DataBuffer d = serverHttpResponse.bufferFactory().wrap(rsp.getBytes());
        serverHttpResponse.getHeaders().add("Content-Type", "text/plain;charset=UTF-8");
        return serverHttpResponse.writeWith(Flux.just(d));
    }

    private void buildCommonParams(ServerWebExchange exchange, GatewayFilterChain chain, HttpHeaders headers) {

        ServerHttpRequest request = exchange.getRequest().mutate()
            .header(SOURCES, headers.getFirst(SOURCES))
            .header(VERSION, headers.getFirst(VERSION))
            .build();
        ServerWebExchange build = exchange.mutate().request(request).build();
        chain.filter(build);
    }

    /**
     * 全局过滤器 核心方法
     *
     * @param exchange
     * @param chain
     * @return
     */
    public Mono<Void> buildUserId(ServerWebExchange exchange, GatewayFilterChain chain, String userId) {
        //将现在的request 变成 change对象
        ServerHttpRequest request = exchange.getRequest().mutate()
            .header("userId", userId)
            .build();
        ServerWebExchange build = exchange.mutate().request(request).build();
        return chain.filter(build);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

需要注意的是:

getOrder代表执行顺序,值越小,执行优先级越高。

7.集成nacas实现请求负载

如图为整体的架构图。集成nacos完成负载均衡。

    • spring-cloud-gateway-nacos-provider提供Rest服务,并且将服务注册到nacos
    • spring-cloud-gateway-nacos-cunsumer:提供网关路由,基于nacos服务注册中心。

7.1 spring-cloud-gateway-nacos-provider项目

  • 添加依赖
<dependencies>

  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter</artifactId>
    <version>2.2.9.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
    <version>2.2.0.RELEASE</version>
  </dependency>

</dependencies>
  • 创建application.yml和application-dev.yml并且配置yml文件
spring.application.name=spring.cloud.gateway.nacos.provider
spring.cloud.nacos.discovery.server-addr=116.204.104.246:8848
server.port=9091
spring.application.name=spring.cloud.gateway.nacos.provider
spring.cloud.nacos.discovery.server-addr=116.204.104.246:8848
server.port=9092
  • 复制一个启动类,新的启动类激活dev文件

7.2 spring-cloud-gateway-nacos-consumer项目

  • 添加依赖
<dependencies>

  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter</artifactId>
    <version>2.2.9.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
    <version>2.2.0.RELEASE</version>
  </dependency>

</dependencies>
  • 创建application.yml
server.port=8888
spring.application.name=spring-cloud-gateway-nacos-consumer
spring.cloud.nacos.discovery.server-addr=116.204.104.246:8848
## 开启从注册中心获取路由
spring.cloud.gateway.discovery.locator.enabled=true
## 开启使用service-id为小写(默认大写)
spring.cloud.gateway.discovery.locator.lower-case-service-id=true
spring.cloud.gateway.routes[0].id=nacos-gateway-provider
spring.cloud.gateway.routes[0].uri=lb://spring-cloud-gateway-nacos-provider
spring.cloud.gateway.routes[0].predicates[0]=Path=/nacos/*
## 省略url上的第一个参数
spring.cloud.gateway.routes[0].filters[0]=StripPrefix=1

请求url:http://localhost:8888/nacos/say