框架-SpringCloud

概述

SpringCloud是微服务架构下的一站式解决方案,主要提供了以下功能:

  • 服务注册与服务发现;
  • 负载均衡;
  • 熔断降级;
  • 调用链追踪;
  • 网关;
  • 分布式配置;

在线资源

Spring Cloud官网

Spring Cloud

Spring Cloud中文网

Spring Cloud中文网-官方文档中文版

SpringCloud中国社区

Spring Cloud中国社区

Netfilx Github

https://github.com/Netflix

SpringCloutDemo项目

source-reading/spring_cloud_demo at master · echo20222022/source-reading · GitHub

Eureka

简介

Eureka是Netflix开发的一个用于服务注册与发现的组件,被Spring团队集成后形成了Spring Cloud Eureka,成为Spring Cloud微服务解决方案的一个组件。

架构

 CAP定理

CAP定理说的是在一个分布式系统中,不可能同时满足一致性、可用性和分区容错性,Eureka作为一个分布式注册中心它满足了AP,而像Zookeeper这种配置中心满足的是CP,所以说Eureka的性能和可用性更好,但是可能会出现集群节点内数据不一致的情况,而Zookeeper的性能相对比较差,但他能够满足数据一致性。

DiscoveryClient

DiscoveryClient是Eureka的客户端,可以通过Spring自动注入的方式添加到应用系统中,从而拿到Eureka中服务相关的信息。

自我保护机制

默认情况下,接入Eureka的业务系统会定时向Eureka Server发送心跳续约消息(默认30s),如果某个服务在3个心跳周期内没有上报续约消息,就会被Eureka剔除服务列表,但有时候是由于网络抖动导致的心跳续约失败,所以Eureka引入了自我保护机制来最大化的保证服务的可用性,即如果Eureka判定接收到的续约消息少于某个阈值(默认85%),就开启自我宝珠机制,在自我保护机制下Eureka内的服务列表不会被剔除,只能查询和新增,这样避免了由于网络抖动造成上游服务调用不到下游服务的严重异常,保证了可用性。 

eureka:
  server:
  # 打开/关闭自我保护机制,默认开启
  enable-self-preservation: false
  # 指定自我保护机制的开启阈值,默认85%
  renewal-percent-threshold: 0.75
  # 设置server端剔除不可用服务的时间窗,单位毫秒, 默认30s
  eviction-interval-timer-in-ms: 4000

关闭服务

高可用 

集群环境

  • 启动多个eureka节点;
  • eureka.service-url.defaultZone 包含所有节点的schema;

异地多活

为了能够实现服务的高可用以及就近访问,Eureka引入了Region和Zone的概念,实际上就相当于是给某个服务打上了两个标签,在进行服务间调用的时候,优先匹配同一个Region&Zone下的服务,Eureka一般情况下在不同Region之间不进行数据复制,而在同一个Region内的不同Region之间进行数据复制,就行Eureka架构中表示的那样:

其中三台Eureka Server属于同一个Region(即us-east),但是不同的Zone(1c,1d,1e),但在实际生产环境中,图中的每一个Eureka Server实际上又是一个Eureka集群,比如向构建一个如下的系统,配置见:

https://github.com/echo20222022/source-reading/blob/master/spring_cloud_demo/cloud-eureka/region-zone.config

Ribbon

Ribbon是Netflix开发的一个面向服务名的HttpRest调用工具,内部提供了负载均衡的能力,需要Eureka的支持来获取服务实例列表。

负载均衡策略

RoundRobinRule

轮询策略,是默认的负载均衡策略

RandomRule

随机策略

RetryRule

重试策略,先按照轮序策略进行选择,如果获取失败,则在指定的时间内重试,默认为500ms

BestAvaliableRule

最佳可用策略,选择并发量最小的provider

AvaliabilityFilteringRule

可用过滤策略

ZoneAvoidanceRule

zone回避策略,根据provider提供的zone及provider的可用性进行选择

WeightedResponseRule

权重响应时间策略,根据每个provider的平均响应时间计算权重,响应时间约快权重越大。刚启动时采用轮询策略,逐渐切换到权重响应时间策略。

  • 通过配置文件修改
service-name:
    ribbon:
        NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
  • 通过配置类修改
@Bean
public IRule loadbalance() {
    return new RandomRule();
}
  • 自定义负载均衡策略
实现IRule接口,重写相关方法,并通过上面两种方式进行配置

配置

核心配置

cloud-fd:
  ribbon:
    #度超时时间
    ReadTimeout: 5000
    #连接超时时间
    ConnectTimeout: 5000
    #是否开启重试
    OkToRetryOnAllOperations: true
    #总的重试次数
    MaxAutoRetriesNextServer: 2
    #在单个实例上重试的次数
    MaxAutoRetries: 1
    #负载均衡策略
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule

日志配置

logging:
  level:
    com:
      cloud:
        fd:
          client:
             CloudApiFeign: debug

Feign

Feign是一个能够把接口调用转换成RestHttp调用的工具,一般是Feign配合Ribbon一块使用,实现一个面向接口调用切带有负载均衡能力的跨服务调用,但也可以单独使用。

使用方式

  1. 定义API接口;
  2. 通过@EnableFeignClients开启对Fein的支持;
  3. 实现API接口,配置@FeignClient;
  4. 将@FeignClient接口注入到业务类中;

@FeignClient

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FeignClient {

    //名称,对应服务id
    @AliasFor("name")
    String value() default "";

    /** @deprecated */
    @Deprecated
    String serviceId() default "";

    //在spring 中的id
    String contextId() default "";

    @AliasFor("value")
    String name() default "";

    String qualifier() default "";

    //在不引入eureka时,请求资源对应的url
    String url() default "";

    boolean decode404() default false;
    
    //外部配置类
    Class<?>[] configuration() default {};

    //降级类
    Class<?> fallback() default void.class;

    //降级工厂类
    Class<?> fallbackFactory() default void.class;

    //路径前缀
    String path() default "";

    boolean primary() default true;
}

内容压缩

feign:
 compression:
   request: 
     #请求压缩是否开启
     enable: true
     #指定mime类型
     mime-types: ["",""],
     #指定请求体的大小
     min-request-size: 2048
   response:
     enable: true

Hystrix

Hystrix是Netflix开发的一个具有熔断降级功能的组件,它的实现方式就是在被调放的基础上包装了一个熔断器,当触发熔断条件时就会自动断开对下游的访问,实现降级返回,这样在分布式系统中就保证了异常不会在整个系统中传导,最终导致整个服务出现雪崩效应。

原理

触发熔断降级的四个场景:

  1. 熔断器开启;
  2. 线程池资源占满;
  3. 调用异常;
  4. 调用超时;

隔离策略

  1. 线程隔离,适合大部分场景,能够对调用进行精准控制,但有额外的开销;

  2. 信号量隔离,适合调用量特别大但响应时间特别快的服务 ;

使用方式

  • @HystrixCommand
  • 基于配置

降级方式

  • 方法级别的服务降级(配合@HystrixCommand)
  • 类级别的服务降级(配合Feign)

高级配置

Configuration · Netflix/Hystrix Wiki · GitHub

execution.isolation.strategy

隔离策略,THREAD/SEMAPHORE

THREAD

execution.isolation.thread.timeoutInMilliseconds

线程隔离策略下的超时时间

1000ms

execution.timeout.enabled

是否开启超时机制

true

execution.isolation.thread.interruptOnTimeout

发生超时时是否中断对下游服务的调用

true

execution.isolation.thread.interruptOnCancel

发生取消时是否中断对下游服务的调用

false

execution.isolation.semaphore.maxConcurrentRequests

信号量隔离策略下最大的信号量数量

10

fallback.isolation.semaphore.maxConcurrentRequests

信号量隔离策略下执行fallback的最大并发数,超过的不会执行fallback,直接拒绝

10

fallback.enabled

是否启动fallback机制

true

circuitBreaker.requestVolumeThreshold

熔断器滑动窗口中的最小请求数量

20

circuitBreaker.sleepWindowInMilliseconds

熔断器开始后经过多长时间开始释放探测请求

5000ms

circuitBreaker.errorThresholdPercentage

当错误请求的数量达到多少百分比是开启熔断器

50

circuitBreaker.forceOpen

是否强制开启熔断器

false

circuitBreaker.forceClosed

是否强制关闭熔断器

false

监控仪表

  • Hystrix GUI
  • Turbin

trubin: http://localhost:9000/hystrix

监控地址:http://localhost:10000/turbine.stream?cluster=default

Zuul

Zuul是SpringCloud微服务解决方案中的网关组件,核心功能是路由和过滤,路由就是将请求转发到后端对应的服务上,过滤就是对请求进行逻辑过滤,比如认证和授权等,基于过滤的功能,还可以实现诸如限流和灰度发布能功能,同时也包含了其他一些高级功能,比如负载均衡、异常降级、请求头过滤等。

路由配置

路由配置的方式有三种:

  • ①不依赖于注册中心;
#①不依赖于注册中心的配置
zuul:
  routes:
      cloud-fd:
          path: /fd/**
          serviceId: cloud-fd
      #如果是单节点的服务,则不用配置listOfServers
      cloud-robot:
          path: /robot/**
          url: "http://localhost:8085"
cloud-fd:
  ribbon:
    listOfServers: "http://localhost:8081,http://localhost:8082"
  • ②标准配置;
#②标准配置  
zuul:
  routes:
      cloud-fd:
          path: /fd/**
          serviceId: cloud-fd  
      cloud-robot:
          path: /robot/**
          serviceId: cloud-robot   
          #向下游转发时,是否移除前传,在当前例子中,如果不移除就会出现404
          strip-prefix: true 
  • ③简化配置;
#③简写
zuul:
  routes:
      cloud-fd: /fd/**
        cloud-robot: /robot/**   
#  通配符规则
#  /** 可以匹配0或多级路径
#  /* 可以匹配1级路径
#  /? 可以匹配1级路径,且只能包含一个字符
#ignore-patterns: /**/list/** 

配置URL前缀

有时候需要在url中增加统一的前缀来规范url的格式,比如在一个API系统中如果要在url中增加/api前缀,就可以通过以下配置实现:

zuul:
  routers:
      prefix: /cloud
      #向下游转发时是否移除前缀,正常需要移除,因为下游服务无法识别这个统一前缀
      stripPrefix: true    

忽略服务名

默认情况下,可以通过服务名直接路由到下游服务,比如下面的配置:

zuul:
  routes:
      cloud-fd:
          path: /fd/**
          serviceId: cloud-fd 
      prefix: /cloud   

也可以通过/cloud/cloud-fd/xxx/xxx来访问下游服务,但对外暴露服务名称是存在安全风险的,所以需要将通过服务名路由的能力禁用掉:

zuul:
 #屏蔽掉通过服务名称访问
 ignored-services: "*"  

过滤请求头

有时候一些敏感的请求头不需要传递到下游服务中,比如认证、授权的一些请求头,就可以通过下面的配置过滤掉:

zuul:
  sensitive-headers: token,Cookie

ProxyRequestHelper.java

public boolean isIncludedHeader(String headerName) {
   String name = headerName.toLowerCase();
   RequestContext ctx = RequestContext.getCurrentContext();
   if (ctx.containsKey(IGNORED_HEADERS)) {
      Object object = ctx.get(IGNORED_HEADERS);
      if (object instanceof Collection && ((Collection<?>) object).contains(name)) {
         return false;
      }
   }
   switch (name) {
   case "host":
      if (addHostHeader) {
         return true;
      }
   case "connection":
   case "content-length":
   case "server":
   case "transfer-encoding":
   case "x-application-context":
      return false;
   default:
      return true;
   }
}

传递Host

Host header是客户端在访问服务端的时候制定的需要访问的服务端地址,由于网关是统一入口,所以Host一般都是统一的域名,对下游来说没有什么用,所以不会传递下去,如果想传递到下游服务则可以通过如下配置实现:

zuul:
  add-host-header: true

Hystrix&Ribbon配置

hystrix:
  command:
    #默认的超时时间
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000
    #某个指定的服务的超时时间
    cloud-robot:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

#ribbon指定服务的超时配置

cloud-robot:
  ribbon:
    ReadTimeout: 5000
    ConnectTimeout: 5000
cloud-fd:
  ribbon:
    ReadTimeout: 5000
    ConnectTimeout: 5000
    OkToRetryOnAllOperations: true
    MaxAutoRetriesNextServer: 2
    MaxAutoRetries: 1

#ribbon的全局配置
ribbon:
  ReadTimeout: 5000
  ConnectTimeout: 5000

过滤器

过滤器是zuul中的核心组件,zuul的很多功能都是基于过滤器实现的。

逻辑抽象

#过滤器类型 pre/routing/post/error
String filterType();
#过滤器执行顺序,数值越小优先级越高
int filterOrder();
#是否应该执行该filter
boolean shouleFilter();
#过滤器的执行逻辑
Object run();

生命周期

内置过滤器 

过滤器名称

类型

描述

顺序

ServletDetectionFilter

pre

检测当前请求是通过Spring的DispatcherServlet处理运行的还是ZuulServlet来处理运行的

-3

Servlet30WrapperFilter

将HttpServletRequest包装成Servlet30RequestWrapper

-2

DebugFilter

根据配置参数zuul.debug.request和请求中的debug参数来决定是否执行过滤器中的操作

1

PreDecorationFilter

5

RibbonRoutingFilter

routing

通过service id来将请求路由到下游服务

10

SimpleHostRoutingFilter

通过HttpClient的方式将请求路由到下游服务

100

SendForwardFilter

500

SendErrorFilter

post

1

SendResponseFilter

1000

ServletDetectionFilter

该过滤器的执行顺序为-3,默认情况下最先被执行,主要作用是判断当前请求的入口源头,然后向RequestContext中设置标识,供后续过滤器使用。

@Override
public Object run() {
   RequestContext ctx = RequestContext.getCurrentContext();
   HttpServletRequest request = ctx.getRequest();
   if (!(request instanceof HttpServletRequestWrapper)
         && isDispatcherServletRequest(request)) {
      ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, true);
   }
   else {
      ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, false);
   }

   return null;
}

默认情况下是通过DispatcherServlet和ZuulController来间接的将请求转发到ZuulServlet中(ZuulController继承了SpringMVC的ServletWrappingController,即是一个SpringMVC的处理器),但Zuul实际上也将ZuulServlet注册到了Servlet容器中,只不过需要通过特殊的路径进行访问,默认时/zuul/**,可以通过zuul.servlet-path参数进行修改。

Servlet30WrapperFilter

该过滤器的执行顺序为-3,默认情况下被第二个执行,将HttpServletRequest包装成Servlet30RequestWrapper,即能够支持Servlet 3.0规范。

DebugFilter

该过滤器的执行顺序为1,主要作用是在Zuul的上下文中设置debug标识,这里涉及到两个参数,zuul.debug.request=true/false和zuul.debug.parameter=key,zuul.debug.parameter的优先级要高于zuul.debug.request,当设置了zuul.debug.parameter并且在请求参数中key对应的值=true时,或没有配置zuul.debug.parameter但配置了zuul.debug.request=true时,都会开启debug模式,即向RequestContext中设置两个参数:debugRouting和debugRequest,在后续的Filter中可以通过下面的API来获取:

//DebugFilter
@Override
public boolean shouldFilter() {
   HttpServletRequest request = RequestContext.getCurrentContext().getRequest();
   if ("true".equals(request.getParameter(DEBUG_PARAMETER.get()))) {
      return true;
   }
   return ROUTING_DEBUG.get();
}

@Override
public Object run() {
   RequestContext ctx = RequestContext.getCurrentContext();
   ctx.setDebugRouting(true);
   ctx.setDebugRequest(true);
   return null;
}


//CustomFilter
RequestContext ctx = RequestContext.getCurrentContext();
boolean debugRouting = ctx.debugRouting();
boolean debugRequest = ctx.debugRequest();

这样当线上环境出现问题需要紧急处理的时候,就可以通过参数来控制某些debug日志的输出,但这仅限于Zuul的内部。

PreDecoreationFilter

该过滤器的执行顺序5,他的主要作用是通过解析url及路由配置,将一些路由信息设置到RequestContext中,同时还有其他一些参数,比如敏感Header、重试、Host请求头等。

@Override
public Object run() {
   RequestContext ctx = RequestContext.getCurrentContext();
   final String requestURI = this.urlPathHelper
         .getPathWithinApplication(ctx.getRequest());
   Route route = this.routeLocator.getMatchingRoute(requestURI);
   if (route != null) {
       //cloud-fd 服务名称 或 http://
      String location = route.getLocation();
      if (location != null) {
         ctx.put(REQUEST_URI_KEY, route.getPath());
         ctx.put(PROXY_KEY, route.getId());
         if (!route.isCustomSensitiveHeaders()) {
            this.proxyRequestHelper.addIgnoredHeaders(
                  this.properties.getSensitiveHeaders().toArray(new String[0]));
         }
         else {
            this.proxyRequestHelper.addIgnoredHeaders(
                  route.getSensitiveHeaders().toArray(new String[0]));
         }

         if (route.getRetryable() != null) {
            ctx.put(RETRYABLE_KEY, route.getRetryable());
         }

            
         //如果localtion是以http开头的,就是这事RouteHost,后面会利用SimpleHostRoutingFilter进行处理   
         if (location.startsWith(HTTP_SCHEME + ":")
               || location.startsWith(HTTPS_SCHEME + ":")) {
            ctx.setRouteHost(getUrl(location));
            ctx.addOriginResponseHeader(SERVICE_HEADER, location);
         }
         else if (location.startsWith(FORWARD_LOCATION_PREFIX)) {
             //处理forward
            ctx.set(FORWARD_TO_KEY,
                  StringUtils.cleanPath(
                        location.substring(FORWARD_LOCATION_PREFIX.length())
                              + route.getPath()));
            ctx.setRouteHost(null);
            return null;
         }
         else {
           //如果是服务ID,就设置Service id key,后面会用RibbonRoutingFilter进行处理  
            // set serviceId for use in filters.route.RibbonRequest
            ctx.set(SERVICE_ID_KEY, location);
            ctx.setRouteHost(null);
            ctx.addOriginResponseHeader(SERVICE_ID_HEADER, location);
         }
         if (this.properties.isAddProxyHeaders()) {
            addProxyHeaders(ctx, route);
            String xforwardedfor = ctx.getRequest()
                  .getHeader(X_FORWARDED_FOR_HEADER);
            String remoteAddr = ctx.getRequest().getRemoteAddr();
            if (xforwardedfor == null) {
               xforwardedfor = remoteAddr;
            }
            else if (!xforwardedfor.contains(remoteAddr)) { // Prevent duplicates
               xforwardedfor += ", " + remoteAddr;
            }
            ctx.addZuulRequestHeader(X_FORWARDED_FOR_HEADER, xforwardedfor);
         }
         //这里处理add-host-header
         if (this.properties.isAddHostHeader()) {
            ctx.addZuulRequestHeader(HttpHeaders.HOST,
                  toHostHeader(ctx.getRequest()));
         }
      }
   }
   else {
      log.warn("No route found for uri: " + requestURI);
      String forwardURI = getForwardUri(requestURI);

      ctx.set(FORWARD_TO_KEY, forwardURI);
   }
   return null;
}

RibbionRoutingFilter

该过滤器的执行顺序10,内部是通过service id将请求路由到下游服务的逻辑。

public boolean shouldFilter() {
   RequestContext ctx = RequestContext.getCurrentContext();
   //这里的ServiceID就是PreDecoreationFilter根据配置信息设置到上下文的
   return (ctx.getRouteHost() == null && ctx.get(SERVICE_ID_KEY) != null
         && ctx.sendZuulResponse());
}

SimpleHostRoutingFilter

该过滤器的执行顺序100,如果不依赖于注册中心进行请求转发,那就会利用这个Filter使用HttpClient向下游转发请求,使用这种方式进行请求转发时,会涉及到HttpClient底层线程池的设置。

SendForwardFilter

RequestContext ctx = RequestContext.getCurrentContext();
//key = forward.to
String path = (String) ctx.get(FORWARD_TO_KEY);
RequestDispatcher dispatcher = ctx.getRequest().getRequestDispatcher(path);
if (dispatcher != null) {
   ctx.set(SEND_FORWARD_FILTER_RAN, true);
   if (!ctx.getResponse().isCommitted()) {
      dispatcher.forward(ctx.getRequest(), ctx.getResponse());
      ctx.getResponse().flushBuffer();
   }
}

SendErrorFilter

该过滤器的执行顺序1,是post阶段第一个执行的过滤器,该过滤器仅在上下人中包含error.status_code参数并且没有被改过滤器处理过的时候执行,具体就是利用上下文中的错误信息组装成一个forward到api网关的/error错误端点的请求来产生错误响应。

SendResponseFilter

该过滤器的执行顺序1000,是post阶段最后要给执行的过滤器,用于把响应结果发送给客户端。

网关限流

令牌桶算法 

漏桶算法

简单实现

@Component
public class RateLimitFilter extends ZuulFilter {

    //每秒钟产生2个令牌
    private static final RateLimiter limiter = RateLimiter.create(1);
    @Override
    public String filterType() {
        return "pre";
    }
    @Override
    public int filterOrder() {
        return -10;
    }
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        //尝试获取令牌,如果获取失败,则直接返回结果
        if (!limiter.tryAcquire()) {
            System.out.println("限流检测未通过...");
            //不向后传播了
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(429);
            context.setResponseDataStream(new ByteArrayInputStream("rate limit error".getBytes(StandardCharsets.UTF_8)));
            return false;
        }
        return true;
    }
    @Override
    public Object run() throws ZuulException {
        System.out.println("限流通过...");
        return null;
    }
}

多维限流

GitHub - marcosbarbero/spring-cloud-zuul-ratelimit: Rate limit auto-configure for Spring Cloud Netflix Zuul

<dependency>
    <groupId>com.marcosbarbero.cloud</groupId>
    <artifactId>spring-cloud-zuul-ratelimit</artifactId>
    <version>2.0.5.RELEASE</version>
</dependency>


zuul:
 ratelimit:
   enabled: true  #开启多维度限流

   #在一个单位时间窗内 通过该zuul的用户数量、ip数量、及url数量 都不能超过3个
   default-policy:  #设置限流策略
      quota: 1     #指定限流的时间窗数量
      refresh-interval: 3    #时间窗大小,单位是秒
      limit: 3               #在指定的单位时间窗内 启用限流的 限定值
      type: origin   
      #  user  #cloud-gateway:fd:anonymous   request.getRemoteUser();
      #  url   cloud-gateway:fd:/hello
      #  origin  cloud-gateway:fd:0:0:0:0:0:0:0:1
      #  origin,url   #指定限流维度

总结 

SpringCloud是一个微服务架构解决方案,提供了服务注册与服务发现、负载均衡、熔断降级、面向接口的Http调用、网关、监控、调用链追踪等功能,大部分功能是通过集成第三方开源项目的方式实现的(主要以Netflix为主),但SpringCloud存在两个比较大的问题:

  1. 功能分散,组件众多,学习成功很高;
  2. 有很多功能都耦合在了业务系统中,会影响代码的管理,对业务代码有一定的侵入性;

针对上面这两个问题,很多框架都做出了调整,比如dubbo 3.0对Service Mesh架构进行了支持,Spring Cloud Tencent提供了更加通用的管控平台北极星,将很多功能封装并统一管理等,但由于Spring Cloud是Spring官方提供了完整解决方案,目前还是有很大一部分项目在使用。

在代码实现层面,几乎所有的组件都是基于SpringBoot的自动装配功能实现初始化的,通过自定义Configuration/spring.factories/Marker Bean/标记注解的方式自动注册对应的组件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

echo20222022

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值