Cloud-Gateway 网关的认识、使用以及源码分析

本篇将开始初步认识gateway 网关的使用。网关也就是服务的边界,当任意请求发送到微服务项目上的时候,需要有一个网关来简单初步处理这些请求,比如:鉴权,然后再通过断言规则路由到各个不同的服务上,如果项目中有服务发现的中间件,比如nacos、zk,那么还需要负载选择之后,再路由到不同服务器上的服务。

同时要注意gateway 的项目本身也是一个微服务,它同样可以集群多节点部署,所以当gateway 项目集群部署的时候,网络请求的第一个到达点就不是gateway 了,而是如Nginx 之类的中间件,所以先是对应的中间件先进行负载选择到gateway 项目节点之后,才是gateway 的表演时间。

Gateway 的概述和配置使用

在gateway 的正式使用之前,我们简单说一下gateway 的基础概念,先弄清楚gateway 的作用是什么,为什么项目中要加入gateway。

Spring Cloud Gateway 是Spring Cloud 团队的一个全新项目,基于Spring5.0、SpringBoot2.0、Project Reactor 等技术开发的网关。旨在为微服务架构提供一种简单有效统一的API路由管理方式。

Spring Cloud Gateway 作为SpringCloud 生态系统中的网关,目标是替代Netflix Zuul。Gateway 不仅提供统一路由方式,并且基于Filter 链的方式提供网关的基本功能。例如:安全,监控/指标,和限流。

微服务网关就是一个系统,通过暴露该微服务网关系统,方便我们进行相关的鉴权,安全控制,日志统一处理,易于监控,限流等相关功能。

这个补充一点:因为所有的客户端请求过来时,都是先进过gateway 网关才能访问到对应的各个微服务,所以gateway 本身的服务要求就比较高,一定是要高性能的,其中的业务逻辑也是一定要少,这样才能做到高响应,同时后面源码分析的时候我们也可以知道gateway 也不是Tomcat 之类的容器来启动的,而是netty 来做消息之间的交互。

gateway 的工作原理

这里先简单说一下gateway 的工作原理

  1. 当client 请求访问当gateway 项目的时候,请求首先会被HttpWebHandlerAdapter 进行提取组装成网关的上下文,然后网关的上下文会传递到DispatcherHandler;
  2. 然后是dispatcherHandler,循环遍历Mapping,获得Handler,让后将请求分发给RoutePredicateHandlerMapping;
  3. 最后通过RoutePredicateHandlerMapping,匹配断言信息,通过断言判断路由是否可用;
  4. 断言失败则返回,成功则继续走到FilteringWebHandler,创建过滤器链,然后调用所有的过滤器Filter;
  5. 最后将请求给到对应的微服务进行处理,之后将得到的响应给到客户端。

从上述的工作原理中,不难看出gateway 最只要的两块内容就是路由和Filter。对于这两块,下面做一下简单的演示,因为这里的内容只要大致知道怎么配置即可,后续的源码分析流程会详谈这些功能的具体效果。

gateway 路由的两种配置方式

在正式项目中gateway 的路由就是它最核心的功能,因为各个不同客户端的请求,在经过gateway 之后都会分到不同的微服务中,甚至根据请求方式的不一样,同样的请求内容也可以分到不同的微服务中。

gateway 的路由也分为两种配置方式,基于配置文件的静态路由设置基于代码的动态路由设置,这两种都各有特点,但是本质其实都是通过gateway 本身的代码逻辑来实现的。

下面分别实现以下这两种配置的情况。

基于配置文件的静态路由设置

因为是spring cloud 项目,所以基本上都是配置在application.yml,或者bootstrap.yml 中,这两者没什么太大的区别,就是后者的执行时机要早于application.yml。

spring:
  cloud:
    gateway:
      routes:
        - id: hailtaxi-driver
          uri: lb://hailtaxi-deriver
          predicates:
            - Path=/driver/**

上面就是一个简单的路由断言配置,我们自己如果要独立配置的话,其实可以点击routes,这里会导到GatewayProperties 这个类,这里有一个属性是routes,对应的对象是RouteDefinition,definition 其实就可以想到这是一个配置信息对象,跟进去就可以看到它的一些属性,这些就是我们要配置的内容,其中有两个不为空的属性是一定要配置的,uripredicates

private String id;

@NotEmpty
@Valid
private List<PredicateDefinition> predicates = new ArrayList<>();

@Valid
private List<FilterDefinition> filters = new ArrayList<>();

@NotNull
private URI uri;

private Map<String, Object> metadata = new HashMap<>();

private int order = 0;

predicates 的配置,我在上面的配置文件内容中只有一个- Path,但是这里是有很多内容的,可以跟着官方提供出来的文档 一步一步配置,比如:监听请求头的- Header、监听Cookie 的- Cookie 等等很多,这里就不一个个配置了。

**这里总结一下:**其实总来的说配置文件的设置相对来说简单一点,因为具体的内容都是gateway 自己实现的,我们就是通过一些参数配置来构建出对应的routes。

这里如果要实现动态的配置,我具体没有操作过,但是应该是可以结合nacos 的配置中心来实现的,因为nacos 的配置中心文件有改动时,是有事件发布给spring 的事件多播器的,这样就可以做到动态刷新。

基于代码的动态路由设置

上面说了基于配置文件的设置,接着说的就是代码的设置了,还有这个为什么要说是动态的,原因就是可以将配置放在数据库中,用实时查询来做,我们这里就不配置数据库了,直接上代码。

@Configuration
public class RouterConfig {

    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder builder) {
        return builder.routes()
                .route("hailtaxi-driver", r -> r
                        .path("/driver/**")
                        .uri("lb://hailtaxi-driver"))
                .build();
    }
}

解析一下:其实也很简单,这里就是将一个RouteLocator 接口的实现对象放到了容器中,上面说gateway 工作原理的时候就说过,它有一个RoutePredicateHandlerMapping 对象来匹配断言规则,其实就是通过RouteLocator 的注入实现类来判断匹配。

这里的实现类对象是通过RouteLocatorBuilder 对象来build 构建的,具体的写法就是跟我上面的一样,还有一点就是上面的.path 方法跟配置文件中的- Path 效果是一致的,跟着这个方法可以对应的PredicateSpec 对象中还有如:beforecookie 之类的方法。

**这里再总结一下:**代码设置我感觉跟配置文件配置差不多,它所谓的结合数据库查询实现动态配置,我也不是很建议,因为gateway 要求的是一个高性能、高响应的服务,数据库的查询虽然说不慢,但是还是会有影响,所以不建议。一定要做到动态配置的话,也可以结合能动态刷新配置的配置中心,比如说阿里的nacos,还有携程的Apollo。

gateway filter 的两种配置方式

gateway 有意思的点就是filter 它也提供出了两种不同的配置方式,GatewayFilter 接口和GlobalFilter 接口,我们通过实现这两个接口来配置filter。

它们两者的区别在于:

  1. GatewayFilter:需要通过spring.cloud.routes.filters 配置在具体路由下,只作用在当前路由上或通过spring.cloud.default-filters配置在全局,作用在所有路由上。gateway 本身也提供出来很多的GatewayFilter 的实现对象,比如:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vr3hIm58-1683883705386)(C:\Users\liwqsh\AppData\Roaming\Typora\typora-user-images\image-20230428153743918.png)]
  2. GlobalFilter:全局过滤器,不需要在配置文件中配置,作用在所有的路由上,最终通过GatewayFilterAdapter 包装成GatewayFilterChain 可识别的过滤器,它为请求业务以及路由的URI转换为真实业务服务的请求地址的核心过滤器,不需要配置,系统初始化时加载,并作用在每个路由上。

**小结一下:**过滤器作为Gateway 的重要功能。常用于请求鉴权、服务调用时长统计、修改请求或响应header、限流、去除路径等等。它提供出的两种实现方式,区别也就是一个全局的,一个是单个路由的,具体的可以参考官方的文档

自定义一个GatewayFilter 的实现

下面我们手动配置一个自己的两个不同类型的filter,先来GatewayFilter。

如果我们这是第一次上手写这个GatewayFilter,那么其实可以在上面官方提供的GatewayFilter 中随便找一个看看,仿照还是可一个嘛。比如这个HystrixGatewayFilterFactory,注意命名规则,这里几乎所有的都加了GatewayFilterFactory 的尾缀,而且有点可以看到真正实现的也不是GatewayFilter,而是GatewayFilterFactory。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yCSVQxEl-1683883705387)(C:\Users\liwqsh\AppData\Roaming\Typora\typora-user-images\image-20230428155135518.png)]

现在我们才开始手写代码,可以注意到是继承了AbstractGatewayFilterFactory 对象,然后重写了apply 方法,但是注意这个返回其实还是一个GatewayFilter 的实现。

我这里在GatewayFilter 的filter 方法中是没有做任何操作的,但是鉴权,或者日志输出之类的都是在部分代码中,ServerWebExchange 中就存在所有的请求信息。

@Component
public class MyGatewayFilterFactory extends AbstractGatewayFilterFactory {

    @Override
    public GatewayFilter apply(Object config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                 // 调用chain.filter 继续向下游执行
                return chain.filter(exchange);
            }
        };
    }
}

接下来就是bootstrap.yml 的配置了,当然如果是代码设置的断言规则,就是在代码中配置,我这里就用yml 就行了。在上面配置路由断言规则的时候,提倒的RouteDefinition 对象中有一个filters 的属性,这个里面就是放GatewayFilter。

spring:
  cloud:
    gateway:
      routes:
        - id: hailtaxi-driver
          uri: lb://hailtaxi-deriver
          filters:
            - My
          predicates:
            - Path=/driver/**

**小结:**这样就配置好了一个GatewayFilter 的内容,但是一般情况下很少配置这个,因为gateway 已经提供出了很全面的这类filter,再者鉴权之类的内容都是放在全局的,没有必要放在这类filter 里面,所以这个只要大致了解就行。

自定义一个GlobalFilter 的实现

这里跟上面就不一样了GlobalFilter 我们是直接实现的GlobalFilter 接口,然后将实现类注入到spring 的IOC 里面即可。

@Component
@Slf4j
public class MyGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        System.out.println("MyGlobalFilter-----");
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

**小结:**相对于GatewayFilter 而言,GlobalFilter 的配置更加简单,因为是全局的,所以也不需要额外的配置,就是实现接口,然后注入IOC 容器,剩下的交给gateway 和spring 就行了。


**总结一下上述的使用内容:**到这里其实已经大致说完了gateway 的基本使用和配置,至于它的鉴权、跨域、限流配置的代码内容,有兴趣的可以自己去看下,这里就不多说了。gateway 的配置和编辑过程中,一定要注意的就是保证它的高性能和高响应,就是因为它是网关,要是请求在这里卡住了,后面的服务就不用再说了。

Gateway 的源码流程分析

上面上述内容中,其实一开始就已经概述了gateway 的源码流程,gateway 的工作原理其实就是它的流程,我们跟代码的话,也是跟着这部分内容。

这里有一部是关于spring MVC 的内容,这里就不赘述了,所以下面直接就从DispatcherHandler 开始切入,我们要待着目的看源码,从上面的原理描述中,可以知道DispatcherHandler 中的目的是找到RoutePredicateHandlerMapping,具体切入代码看下。

public class DispatcherHandler implements WebHandler, ApplicationContextAware

DispatcherHandler 这里是实现了WebHandler,根据spring MVC 的逻辑,它一定会重写handler 方法,我们直接看它的handler 方法。

@Override
public Mono<Void> handle(ServerWebExchange exchange) {
    if (this.handlerMappings == null) {
        return createNotFoundError();
    }
    return Flux.fromIterable(this.handlerMappings)
        .concatMap(mapping -> mapping.getHandler(exchange))
        .next()
        .switchIfEmpty(createNotFoundError())
        .flatMap(handler -> invokeHandler(exchange, handler))
        .flatMap(result -> handleResult(exchange, result));
}

这部分handler 的代码,我们分三部分看,第一部分mapping.getHandler,找到对应的handlerMapping,第二部分invokeHandler,执行对应的handlerMapping,handleResult 处理返回结果。

既然我们已经知道这里要找的handlerMapping 就是RoutePredicateHandlerMapping,那么直接切入这边代码看。

public class RoutePredicateHandlerMapping extends AbstractHandlerMapping

这里是继承了AbstractHandlerMapping,但是这个对象还是实现了HandlerMapping,所以可以直接找到它的getHandler 方法,但是这里显示的是return getHandlerInternal(exchange).map....,所以我们看RoutePredicateHandlerMapping 的方法就是它重写父类的getHandlerInternal 方法。

这个方法里面,我们重点找到的是lookupRoute(exchange) 方法的调用,代码直接切入。

这里其实就是根据this.routeLocator.getRoutes() 获取到我们配置的断言规则,也就是我们之前配置了的所有断言规则,然后通过下面的apply 方法就行断言。

当匹配成功之后,这里就会返回一个FilteringWebHandler 的对象,然后回到在DispatcherHandler 中执行invokeHandler 的方法,这里面也就是调用对应WebHandler 的handler 方法return handlerAdapter.handle(exchange, handler);,这里可以直接切入到对应的FilteringWebHandler 对应的handler 方法中。

protected Mono<Route> lookupRoute(ServerWebExchange exchange) {
    // 拿到所有的route
    return this.routeLocator.getRoutes()
        .concatMap(route -> Mono.just(route).filterWhen(r -> {
            // add the current route we are testing
            exchange.getAttributes().put(GATEWAY_PREDICATE_ROUTE_ATTR, r.getId());
            // 进行路由断言 Predicate
            return r.getPredicate().apply(exchange);
        })
        .doOnError(e -> logger.error(
                   "Error applying predicate for route: " + route.getId(),
                    e))
        .onErrorResume(e -> Mono.empty()))
        // .defaultIfEmpty() put a static Route not found
        // or .switchIfEmpty()
        // .switchIfEmpty(Mono.<Route>empty().log("noroute"))
        .next()
        // TODO: error handling
        .map(route -> {
            if (logger.isDebugEnabled()) {
                logger.debug("Route matched: " + route.getId());
            }
            validateRoute(route, exchange);
            return route;
        });
}

注意啊这里不是直接进入的FilteringWebHandler,而是通过SimpleHandlerAdapter 对象,然后调用WebHandler 的实现类的handler 方法。

@Override
public Mono<HandlerResult> handle(ServerWebExchange exchange, Object handler) {
    WebHandler webHandler = (WebHandler) handler;
    Mono<Void> mono = webHandler.handle(exchange);
    return mono.then(Mono.empty());
}

到这里,我们再切入FilteringWebHandler 的handler 方法代码。

这里首先是combined 集合的构建,它包含了所有的GlobalFilter 和GatewayFilter,然后将集合重新排序,构建一个DefaultGatewayFilterChain 对象,调用它的filter 方法。

@Override
public Mono<Void> handle(ServerWebExchange exchange) {
    Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
    List<GatewayFilter> gatewayFilters = route.getFilters();
    List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
    combined.addAll(gatewayFilters);
    // TODO: needed or cached?
    AnnotationAwareOrderComparator.sort(combined);
    if (logger.isDebugEnabled()) {
        logger.debug("Sorted gatewayFilterFactories: " + combined);
    }
    return new DefaultGatewayFilterChain(combined).filter(exchange);
}

这里就需要提到一个设计模式:责任链模式,这个在spring 的aop 中其实就有体现,优点就是调用简单,在当前代码调用完成之后,在最后一行代码中会调用下一层代码逻辑,一直往复,单个filter 也只做一件事情,目的明确。缺点也明显:可读行较差,项目重构时会非常麻烦。

@Override
public Mono<Void> filter(ServerWebExchange exchange) {
    return Mono.defer(() -> {
        if (this.index < filters.size()) {
            GatewayFilter filter = filters.get(this.index);
            DefaultGatewayFilterChain chain = new DefaultGatewayFilterChain(this,
                                                                            this.index + 1);
            return filter.filter(exchange, chain);
        }
        else {
            return Mono.empty(); // complete
        }
    });
}

后面就是filter 的逻辑调用了,这里涉及到filter 除了自己的,其余的官方给出的,我们这里就只需要关注三个具体的filter 即可,RouteToRequestUrlFilter、LoadBalancerClientFilter、NettyRoutingFilter,这三个都属于GlobalFilter,全局的,所有的路由都是要走着三个的,具体执行的顺序也就是这个。

我们下面一个个看,先看RouteToRequestUrlFilter,上面也说了这个写都是GlobalFilter 的实现类,所以我们要关注的就是这对象的filter 方法就行。

这里的代码虽然多,但是目的非常明确,就是根据断言的内容,还有请求的内容,获取到具体的URL,在下面代码中URI mergedUrl 属性的获取就能看出,获得之后,会将URL 放置到exchange 里面,最后传给下层filter,但是需要注意:这里的URL 是存在断言信息的,不是正在的URL。

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); //获取当前的route
    if (route == null) {
        return chain.filter(exchange);
    }
    log.trace("RouteToRequestUrlFilter start");
    //得到uri  = http://localhost:8001/driver/info/1?token=123456
    URI uri = exchange.getRequest().getURI();
    boolean encoded = containsEncodedParts(uri);
    URI routeUri = route.getUri(); // lb://hailtaxi-driver

    if (hasAnotherScheme(routeUri)) {
        // this is a special url, save scheme to special attribute
        // replace routeUri with schemeSpecificPart
        exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR,
                                     routeUri.getScheme());
        routeUri = URI.create(routeUri.getSchemeSpecificPart());
    }

    if ("lb".equalsIgnoreCase(routeUri.getScheme()) && routeUri.getHost() == null) {
        // Load balanced URIs should always have a host. If the host is null it is
        // most
        // likely because the host name was invalid (for example included an
        // underscore)
        throw new IllegalStateException("Invalid host: " + routeUri.toString());
    }
    //将uri换成 lb://hailtaxi-driver/driver/info/1?token=123456
    URI mergedUrl = UriComponentsBuilder.fromUri(uri)
        // .uri(routeUri)
        .scheme(routeUri.getScheme()).host(routeUri.getHost())
        .port(routeUri.getPort()).build(encoded).toUri();
    exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, mergedUrl);
    // 继续下一个过滤器
    return chain.filter(exchange);
}

接着看LoadBalancerClientFilter 的filter 方法。

这里首先就是获取上层filter 得到的URL,然后判断URL 是否符合断言后的信息,然后重点就是获取到负载均衡的实例:final ServiceInstance instance = choose(exchange),这里如果没有特定配置的话,就是RibbonServer 的实现RibbonLoadBalancerClient 对象。

最后会根据负载均衡的对象去获取正在的URL,也就是URI requestUrl 属性的赋值,然后还是将URL 放在exchange 里面,最后传给下层filter。

@Override
@SuppressWarnings("Duplicates")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); // url:lb://hailtaxi-driver/driver/info/1?token=123456
    String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR);
    if (url == null
        || (!"lb".equals(url.getScheme()) && !"lb".equals(schemePrefix))) {
        return chain.filter(exchange);
    }
    // preserve the original url
    addOriginalRequestUrl(exchange, url);

    if (log.isTraceEnabled()) {
        log.trace("LoadBalancerClientFilter url before: " + url);
    }
    // 负载均衡选择服务实例
    final ServiceInstance instance = choose(exchange);

    if (instance == null) {
        throw NotFoundException.create(properties.isUse404(),
                                       "Unable to find instance for " + url.getHost());
    }
    //用户提交的URI = http://localhost:8001/driver/info/1?token=123456
    URI uri = exchange.getRequest().getURI();

    // if the `lb:<scheme>` mechanism was used, use `<scheme>` as the default,
    // if the loadbalancer doesn't provide one.
    String overrideScheme = instance.isSecure() ? "https" : "http";
    if (schemePrefix != null) {
        overrideScheme = url.getScheme();
    }
    // 真正要请求的url = http://172.16.17.251:18081/driver/info/1?token=123456
    URI requestUrl = loadBalancer.reconstructURI(
        new DelegatingServiceInstance(instance, overrideScheme), uri);

    if (log.isTraceEnabled()) {
        log.trace("LoadBalancerClientFilter url chosen: " + requestUrl);
    }
    // 将真正要请求的url设置到上下文中
    exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl);
    return chain.filter(exchange);
}

最后的一个NettyRoutingFilter,这个的详细代码就不用看,从类名就可以看出来,这里就是封装了Netty 调用的地方。这里就能体现出来Gateway 的底层不是用Tomcat 进行请求的,而且Netty,这样才能做到一个高响应的框架。

总结

上述基本上就是gateway 的简单实用及大体的运行流程,其实可以看出gateway 的重点就是Route 断言规则、Filter 过滤责任链、以及最后的Netty 请求转发调用。

还有一点,gateway 是基于spring MVC 使用的,gateway 的filter 的调用是通过spring MVC 提供的handler 来调用,所以gateway 使用的第一点就是找到gateway 的FilteringWebHandler 对象,然后才会有后面的事情。

;
return chain.filter(exchange);
}


最后的一个NettyRoutingFilter,这个的详细代码就不用看,从类名就可以看出来,这里就是封装了Netty 调用的地方。这里就能体现出来Gateway 的底层不是用Tomcat 进行请求的,而且Netty,这样才能做到一个高响应的框架。

## 总结

上述基本上就是gateway 的简单实用及大体的运行流程,其实可以看出gateway 的重点就是Route 断言规则、Filter 过滤责任链、以及最后的Netty 请求转发调用。

还有一点,gateway 是基于spring MVC 使用的,gateway 的filter 的调用是通过spring MVC 提供的handler 来调用,所以gateway 使用的第一点就是找到gateway 的FilteringWebHandler 对象,然后才会有后面的事情。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
SpringCloud Gateway是一个基于Spring Cloud的全新项目,它是基于Spring 5.0、Spring Boot 2.0和Project Reactor等技术开发的网关。它的主要目标是为微服务架构提供一种简单有效的统一API路由管理方式。SpringCloud Gateway具有许多特性,包括动态路由、断路器功能、服务发现功能、请求限流功能和支持路径重写等。它是基于WebFlux框架实现的,底层使用了高性能的Reactor模式通信框架Netty。总之,SpringCloud Gateway是一个功能强大且易于使用网关,可用于构建和管理微服务架构中的API路由。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* [springcloud入门——gateway](https://blog.csdn.net/tang_seven/article/details/118523647)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *3* [springcloudGateWay](https://blog.csdn.net/qq_35512802/article/details/122049808)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值