API网关——zuul

zuul是什么

1. API网关

在微服务架构中,通常会有多个服务提供者。设想一个电商系统,可能会有商品、订单、支付、用户等多个类型的服务,而每个类型的服务数量也会随着整个系统体量的增大也会随之增长和变更。作为UI端,在展示页面时可能需要从多个微服务中聚合数据,而且服务的划分位置结构可能会有所改变。网关就可以对外暴露聚合API,屏蔽内部微服务的微小变动,保持整个系统的稳定性。

简单来说,API网关可以提供一个单独且统一的API入口用于访问内部一个或多个API。

当然这只是网关众多功能中的一部分,它还可以做负载均衡,统一鉴权,协议转换,监控监测等一系列功能。

2. 网关的选择

现在市场上有很多的网关可供选择:

  • Spring Cloud Zuul:本身基于Netflix开源的微服务网关,可以和Eureka,Ribbon,Hystrix等组件配合使用。
  • Kong : 基于OpenResty的 API 网关服务和网关服务管理层。
  • Spring Cloud Gateway:是由spring官方基于Spring5.0,Spring Boot2.0,Project Reactor等技术开发的网关,提供了一个构建在Spring - Ecosystem之上的API网关,旨在提供一种简单而有效的途径来发送API,并向他们提供交叉关注点,例如:安全性,监控/指标和弹性。目的是为了替换Spring Cloud Netfilx Zuul的。

在Spring cloud体系中,一般上选择zuul或者Gateway。当然,也可以综合自己的业务复杂性,自研一套或者改在一套符合自身业务发展的api网关的,最简单做法是做个聚合api服务,通过SpringBoot构建对外的api接口,实现统一鉴权、参数校验、权限控制等功能,说白了就是一个rest服务。

3. zuul

Zuul是Netflix开源的微服务网关,可以和Eureka、Ribbon、Hystrix等组件配合使用,Spring Cloud对Zuul进行了整合与增强,Zuul的主要功能是路由转发和过滤器。

Zuul是基于JVM的路由器和服务器端负载均衡器。同时,Zuul的规则引擎允许规则和过滤器基本上用任何JVM语言编写,内置支持Java和Groovy。这个功能,就可以实现动态路由的功能了。当需要添加某个新的对外服务时,一般上不停机更新是通过数据缓存配置或者使用Groovy进行动态路由的添加的。

zuul=路由+过滤器

Zuul的核心一系列的过滤器:

  • 身份认证与安全:识别每个资源的验证要求,并拒绝那些与要求不符的请求。
  • 审查与监控:在边缘位置追踪有意义的数据和统计结果,从而带来精确的生产视图。
  • 动态路由:动态地将请求路由到不同的后端集群。
  • 压力测试:逐渐增加指向集群的流量,以了解性能。
  • 负载分配:为每一种负载类型分配对应容量,并启用超出限定值的请求。
  • 静态响应处理:在边缘位置直接建立部分相应,从而避免其转发到内部集群。

二、zuul的工作原理

1. 过滤器机制

zuul的核心是一系列的filters, 其作用可以类比Servlet框架的Filter,或者AOP。

zuul把Request route到 用户处理逻辑 的过程中,这些filter参与一些过滤处理,比如Authentication,Load Shedding等。
在这里插入图片描述
Zuul提供了一个框架,可以对过滤器进行动态的加载,编译,运行。

Zuul的过滤器之间没有直接的相互通信,他们之间通过一个RequestContext的静态类来进行数据传递的。RequestContext类中有ThreadLocal变量来记录每个Request所需要传递的数据。

Zuul的过滤器是由Groovy写成,这些过滤器文件被放在Zuul Server上的特定目录下面,Zuul会定期轮询这些目录,修改过的过滤器会动态的加载到Zuul Server中以便过滤请求使用。

2. 过滤器类型

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

  1. PRE:在请求路由到目标之前执行。我们可利用这种过滤器实现身份验证、在集群中选择请求的微服务、记录调试信息等。
  2. ROUTING:这种过滤器将请求路由到微服务。这种过滤器用于构建发送给微服务的请求,并使用Apache HttpClient或Netfilx Ribbon请求微服务。
  3. POST:这种过滤器在目标请求路由到微服务以后执行。这种过滤器可用来为响应添加标准的HTTP Header、收集统计信息和指标、将响应从微服务发送给客户端等。
  4. ERROR:在其他阶段发生错误时执行该过滤器。

内置的特殊过滤器

zuul还提供了一类特殊的过滤器,分别为:StaticResponseFilter和SurgicalDebugFilter

StaticResponseFilter:StaticResponseFilter允许从Zuul本身生成响应,而不是将请求转发到源。

SurgicalDebugFilter:SurgicalDebugFilter允许将特定请求路由到分隔的调试集群或主机。

自定义的过滤器

除了默认的过滤器类型,Zuul还允许我们创建自定义的过滤器类型。

例如,我们可以定制一种STATIC类型的过滤器,直接在Zuul中生成响应,而不将请求转发到后端的微服务。

3. 过滤器生命周期

Zuul请求的生命周期如图,该图详细描述了各种类型的过滤器的执行顺序。
在这里插入图片描述

4. 与应用的集成方式

ZuulServlet - 处理请求(调度不同阶段的filters,处理异常等)

ZuulServlet类似SpringMvc的DispatcherServlet,所有的Request都要经过ZuulServlet的处理

三个核心的方法preRoute(),route(), postRoute(),zuul对request处理逻辑都在这三个方法里

ZuulServlet交给ZuulRunner去执行。

由于ZuulServlet是单例,因此ZuulRunner也仅有一个实例。

ZuulRunner直接将执行逻辑交由FilterProcessor处理,FilterProcessor也是单例,其功能就是依据filterType执行filter的处理逻辑

FilterProcessor对filter的处理逻辑:

  • 首先根据Type获取所有输入该Type的filter,List list。
  • 遍历该list,执行每个filter的处理逻辑,processZuulFilter(ZuulFilter filter)
  • RequestContext对每个filter的执行状况进行记录,应该留意,此处的执行状态主要包括其执行时间、以及执行成功或者失败,如果执行失败则对异常封装后抛出。
  • 到目前为止,zuul框架对每个filter的执行结果都没有太多的处理,它没有把上一filter的执行结果交由下一个将要执行的filter,仅仅是记录执行状态,如果执行失败抛出异常并终止执行。

ContextLifeCycleFilter - RequestContext 的生命周期管理

ContextLifecycleFilter的核心功能是为了清除RequestContext; 请求上下文RequestContext通过ThreadLocal存储,需要在请求完成后删除该对象。

RequestContext提供了执行filter Pipeline所需要的Context,因为Servlet是单例多线程,这就要求RequestContext即要线程安全又要Request安全。

context使用ThreadLocal保存,这样每个worker线程都有一个与其绑定的RequestContext,因为worker仅能同时处理一个Request,这就保证了Request Context 即是线程安全的由是Request安全的。

三、zuul实践

1. 自定义Filter

添加一个PreRequestLogFilter:

public class PreRequestLogFilter extends ZuulFilter{
 
    @Override
    public String filterType() {
        return PRE_TYPE;
    }
 
    @Override
    public int filterOrder() {
        return 0;
    }
 
    @Override
    public boolean shouldFilter() {
        return true;
    }
 
    @Override
    public Object run() {
        RequestContext currentContext = RequestContext.getCurrentContext();
        HttpServletRequest request = currentContext.getRequest();
        System.out.print(String.format("send %s request to %s",request.getMethod(),request.getRequestURL()));
        return null;
    }
}

修改启动类GatewayZuulDemoApplication,添加Filter注入:

@Bean
public PreRequestLogFilter preRequestLogFilter(){
    return new PreRequestLogFilter();
}

启动服务并请求服务,观察控制台输出日志信息。

2. 实现动态路由

动态路由需要达到可持久化配置,动态刷新的效果。不仅要能满足从spring的配置文件properties加载路由信息,还需要从数据库加载我们的配置。另外一点是,路由信息在容器启动时就已经加载进入了内存,我们希望配置完成后,实施发布,动态刷新内存中的路由信息,达到不停机维护路由信息的效果。

网关项目

启动类:GatewayApplication.Java

@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}
自定义路由定位器:
```java
public class CustomRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator{
    public final static Logger logger = LoggerFactory.getLogger(CustomRouteLocator.class);
    private JdbcTemplate jdbcTemplate;
    private ZuulProperties properties;
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate){
        this.jdbcTemplate = jdbcTemplate;
    }
    public CustomRouteLocator(String servletPath, ZuulProperties properties) {
        super(servletPath, properties);
        this.properties = properties;
        logger.info("servletPath:{}",servletPath);
    }
    //父类已经提供了这个方法,这里写出来只是为了说明这一个方法很重要!!!
//    @Override
//    protected void doRefresh() {
//        super.doRefresh();
//    }

    @Override
    public void refresh() {
        doRefresh();
    }
    @Override
    protected Map<String, ZuulRoute> locateRoutes() {
        LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<String, ZuulRoute>();
        //从application.properties中加载路由信息
        routesMap.putAll(super.locateRoutes());
        //从db中加载路由信息
        routesMap.putAll(locateRoutesFromDB());
        //优化一下配置
        LinkedHashMap<String, ZuulRoute> values = new LinkedHashMap<>();
        for (Map.Entry<String, ZuulRoute> entry : routesMap.entrySet()) {
            String path = entry.getKey();
            // Prepend with slash if not already present.
            if (!path.startsWith("/")) {
                path = "/" + path;
            }
            if (StringUtils.hasText(this.properties.getPrefix())) {
                path = this.properties.getPrefix() + path;
                if (!path.startsWith("/")) {
                    path = "/" + path;
                }
            }
            values.put(path, entry.getValue());
        }
        return values;
    }
    private Map<String, ZuulRoute> locateRoutesFromDB(){
        Map<String, ZuulRoute> routes = new LinkedHashMap<>();
        List<ZuulRouteVO> results = jdbcTemplate.query("select * from gateway_api_define where enabled = true ",new BeanPropertyRowMapper<>(ZuulRouteVO.class));
        for (ZuulRouteVO result : results) {
            if(org.apache.commons.lang3.StringUtils.isBlank(result.getPath()) || org.apache.commons.lang3.StringUtils.isBlank(result.getUrl()) ){
                continue;
            }
            ZuulRoute zuulRoute = new ZuulRoute();
            try {
                org.springframework.beans.BeanUtils.copyProperties(result,zuulRoute);
            } catch (Exception e) {
                logger.error("=============load zuul route info from db with error==============",e);
            }
            routes.put(zuulRoute.getPath(),zuulRoute);
        }
        return routes;
    }
    public static class ZuulRouteVO {
        /**
         * The ID of the route (the same as its map key by default).
         */
        private String id;
        /**
         * The path (pattern) for the route, e.g. /foo/**.
         */
        private String path;
        /**
         * The service ID (if any) to map to this route. You can specify a physical URL or
         * a service, but not both.
         */
        private String serviceId;
        /**
         * A full physical URL to map to the route. An alternative is to use a service ID
         * and service discovery to find the physical address.
         */
        private String url;
        /**
         * Flag to determine whether the prefix for this route (the path, minus pattern
         * patcher) should be stripped before forwarding.
         */
        private boolean stripPrefix = true;
        /**
         * Flag to indicate that this route should be retryable (if supported). Generally
         * retry requires a service ID and ribbon.
         */
        private Boolean retryable;
        private Boolean enabled;
        public String getId() {
            return id;
        }
        public void setId(String id) {
            this.id = id;
        }
        public String getPath() {
            return path;
        }
        public void setPath(String path) {
            this.path = path;
        }
        public String getServiceId() {
            return serviceId;
        }
        public void setServiceId(String serviceId) {
            this.serviceId = serviceId;
        }
        public String getUrl() {
            return url;
        }
        public void setUrl(String url) {
            this.url = url;
        }
        public boolean isStripPrefix() {
            return stripPrefix;
        }
        public void setStripPrefix(boolean stripPrefix) {
            this.stripPrefix = stripPrefix;
        }
        public Boolean getRetryable() {
            return retryable;
        }
        public void setRetryable(Boolean retryable) {
            this.retryable = retryable;
        }
        public Boolean getEnabled() {
            return enabled;
        }
        public void setEnabled(Boolean enabled) {
            this.enabled = enabled;
        }
    }
}

路由定位器配置:

@Configuration
public class CustomZuulConfig {
    @Autowired
    ZuulProperties zuulProperties;
    @Autowired
    ServerProperties server;
    @Autowired
    JdbcTemplate jdbcTemplate;

    @Bean
    public CustomRouteLocator routeLocator() {
        CustomRouteLocator routeLocator = new CustomRouteLocator(this.server.getServletPrefix(),
                this.zuulProperties);
        routeLocator.setJdbcTemplate(jdbcTemplate);
        return routeLocator;
    }
}

动态刷新服务:默认的ZuulConfigure已经配置了事件监听器,我们只需要发送一个事件就可以实现刷新了。

public class RefreshRouteService {
    @Autowired
    ApplicationEventPublisher publisher;
    @Autowired
    RouteLocator routeLocator;

    public void refreshRoute() {
        RoutesRefreshedEvent routesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
        publisher.publishEvent(routesRefreshedEvent);
    }
}

配置文件:application.properties

#不使用注册中心,会带来侵入性
ribbon.eureka.enabled=false

#网关端口
server.port=8080

业务项目

启动类:BookApplication.java

@RestController
@SpringBootApplication
public class BookApplication {
    @RequestMapping(value = "/available")
    public String available() {
        System.out.println("Spring in Action");
        return "Spring in Action";
    }
    @RequestMapping(value = "/checked-out")
    public String checkedOut() {
        return "Spring Boot in Action";
    }
    public static void main(String[] args) {
        SpringApplication.run(BookApplication.class, args);
    }
}

zuul实现转发、路由源码解读

@Configuration
@EnableConfigurationProperties({ZuulProperties.class
})
@ConditionalOnClass(ZuulServlet.class)
@Import(ServerPropertiesAutoConfiguration.class)
public class ZuulConfiguration {

    @Autowired
 //zuul的配置文件,对应了application.properties中的配置信息
    protected ZuulProperties zuulProperties;
    @Autowired
    protected ServerProperties server;
    @Autowired(required = false)
    private ErrorController errorController;

    @Bean
    public HasFeatures zuulFeature() {
        return HasFeatures.namedFeature("Zuul (Simple)", ZuulConfiguration.class);
    }

    //核心类,路由定位器,最最重要
    @Bean
    @ConditionalOnMissingBean(RouteLocator.class)
    public RouteLocator routeLocator() {
       //默认配置的实现是SimpleRouteLocator.class
        return new SimpleRouteLocator(this.server.getServletPrefix(),
            this.zuulProperties);
    }

    //zuul的控制器,负责处理链路调用
    @Bean
    public ZuulController zuulController() {
        return new ZuulController();
    }

    //MVC HandlerMapping that maps incoming request paths to remote services.
    @Bean
    public ZuulHandlerMapping zuulHandlerMapping(RouteLocator routes) {
        ZuulHandlerMapping mapping = new ZuulHandlerMapping(routes,
                zuulController());
        mapping.setErrorController(this.errorController);

        return mapping;
    }

    //注册了一个路由刷新监听器,默认实现是ZuulRefreshListener.class,这个是我们动态路由的关键
    @Bean
    public ApplicationListener<ApplicationEvent> zuulRefreshRoutesListener() {
        return new ZuulRefreshListener();
    }

    @Bean
    @ConditionalOnMissingBean(name = "zuulServlet")
    public ServletRegistrationBean zuulServlet() {
        ServletRegistrationBean servlet = new ServletRegistrationBean(new ZuulServlet(),
                this.zuulProperties.getServletPattern());
        // The whole point of exposing this servlet is to provide a route that doesn't
        // buffer requests.
        servlet.addInitParameter("buffer-requests", "false");

        return servlet;
    }

    // pre filters
    @Bean
    public ServletDetectionFilter servletDetectionFilter() {
        return new ServletDetectionFilter();
    }

    @Bean
    public FormBodyWrapperFilter formBodyWrapperFilter() {
        return new FormBodyWrapperFilter();
    }

    @Bean
    public DebugFilter debugFilter() {
        return new DebugFilter();
    }

    @Bean
    public Servlet30WrapperFilter servlet30WrapperFilter() {
        return new Servlet30WrapperFilter();
    }

    // post filters
    @Bean
    public SendResponseFilter sendResponseFilter() {
        return new SendResponseFilter();
    }

    @Bean
    public SendErrorFilter sendErrorFilter() {
        return new SendErrorFilter();
    }

    @Bean
    public SendForwardFilter sendForwardFilter() {
        return new SendForwardFilter();
    }

    @Configuration
    protected static class ZuulFilterConfiguration {
        @Autowired
        private Map<String, ZuulFilter> filters;

        @Bean
        public ZuulFilterInitializer zuulFilterInitializer() {
            return new ZuulFilterInitializer(this.filters);
        }
    }

   //上面提到的路由刷新监听器
    private static class ZuulRefreshListener implements ApplicationListener<ApplicationEvent> {
        @Autowired
        private ZuulHandlerMapping zuulHandlerMapping;
        private HeartbeatMonitor heartbeatMonitor = new HeartbeatMonitor();

        @Override
        public void onApplicationEvent(ApplicationEvent event) {
            if (event instanceof ContextRefreshedEvent ||
                    event instanceof RefreshScopeRefreshedEvent                ||   event instanceof RoutesRefreshedEvent) {
               //设置为脏,下一次匹配到路径时,如果发现为脏,则会去刷新路由信息
                this.zuulHandlerMapping.setDirty(true);
            } else if (event instanceof HeartbeatEvent) {
                if (this.heartbeatMonitor.update(
                            ((HeartbeatEvent) event).getValue())) {
                    this.zuulHandlerMapping.setDirty(true);
                }
            }
        }
    }
} 
public class SimpleRouteLocator implements RouteLocator {

    //配置文件中的路由信息配置
    private ZuulProperties properties;
    //路径正则配置器,即作用于path:/books/**
    private PathMatcher pathMatcher = new AntPathMatcher();

    private String dispatcherServletPath = "/";
    private String zuulServletPath;

    private AtomicReference<Map<String, ZuulRoute>> routes = new AtomicReference<>();

    public SimpleRouteLocator(String servletPath, ZuulProperties properties) {
        this.properties = properties;
        if (servletPath != null && StringUtils.hasText(servletPath)) {
            this.dispatcherServletPath = servletPath;
        }

        this.zuulServletPath = properties.getServletPath();
    }

    //路由定位器和其他组件的交互,是最终把定位的Routes以list的方式提供出去,核心实现
    @Override
    public List<Route> getRoutes() {
        if (this.routes.get() == null) {
            this.routes.set(locateRoutes());
        }
        List<Route> values = new ArrayList<>();
        for (String url : this.routes.get().keySet()) {
            ZuulRoute route = this.routes.get().get(url);
            String path = route.getPath();
            values.add(getRoute(route, path));
        }
        return values;
    }

    @Override
    public Collection<String> getIgnoredPaths() {
        return this.properties.getIgnoredPatterns();
    }

    //这个方法在网关产品中也很重要,可以根据实际路径匹配到Route来进行业务逻辑的操作,进行一些加工
    @Override
    public Route getMatchingRoute(final String path) {

        if (log.isDebugEnabled()) {
            log.debug("Finding route for path: " + path);
        }

        if (this.routes.get() == null) {
            this.routes.set(locateRoutes());
        }

        if (log.isDebugEnabled()) {
            log.debug("servletPath=" + this.dispatcherServletPath);
            log.debug("zuulServletPath=" + this.zuulServletPath);
            log.debug("RequestUtils.isDispatcherServletRequest()="
                    + RequestUtils.isDispatcherServletRequest());
            log.debug("RequestUtils.isZuulServletRequest()="
                    + RequestUtils.isZuulServletRequest());
        }

        String adjustedPath = adjustPath(path);

        ZuulRoute route = null;
        if (!matchesIgnoredPatterns(adjustedPath)) {
            for (Entry<String, ZuulRoute> entry : this.routes.get().entrySet()) {
                String pattern = entry.getKey();
                log.debug("Matching pattern:" + pattern);
                if (this.pathMatcher.match(pattern, adjustedPath)) {
                    route = entry.getValue();
                    break;
                }
            }
        }
        if (log.isDebugEnabled()) {
            log.debug("route matched=" + route);
        }

        return getRoute(route, adjustedPath);

    }

    private Route getRoute(ZuulRoute route, String path) {
        if (route == null) {
            return null;
        }
        String targetPath = path;
        String prefix = this.properties.getPrefix();
        if (path.startsWith(prefix) && this.properties.isStripPrefix()) {
            targetPath = path.substring(prefix.length());
        }
        if (route.isStripPrefix()) {
            int index = route.getPath().indexOf("*") - 1;
            if (index > 0) {
                String routePrefix = route.getPath().substring(0, index);
                targetPath = targetPath.replaceFirst(routePrefix, "");
                prefix = prefix + routePrefix;
            }
        }
        Boolean retryable = this.properties.getRetryable();
        if (route.getRetryable() != null) {
            retryable = route.getRetryable();
        }
        return new Route(route.getId(), targetPath, route.getLocation(), prefix,
                retryable,
                route.isCustomSensitiveHeaders() ? route.getSensitiveHeaders() : null);
    }

    //注意这个类并没有实现refresh接口,但是却提供了一个protected级别的方法,旨在让子类不需要重复维护一个private AtomicReference<Map<String, ZuulRoute>> routes = new AtomicReference<>();也可以达到刷新的效果
    protected void doRefresh() {
        this.routes.set(locateRoutes());
    }


    //具体就是在这儿定位路由信息的,我们之后从数据库加载路由信息,主要也是从这儿改写
    /**
     * Compute a map of path pattern to route. The default is just a static map from the
     * {@link ZuulProperties}, but subclasses can add dynamic calculations.
     */
    protected Map<String, ZuulRoute> locateRoutes() {
        LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<String, ZuulRoute>();
        for (ZuulRoute route : this.properties.getRoutes().values()) {
            routesMap.put(route.getPath(), route);
        }
        return routesMap;
    }

    protected boolean matchesIgnoredPatterns(String path) {
        for (String pattern : this.properties.getIgnoredPatterns()) {
            log.debug("Matching ignored pattern:" + pattern);
            if (this.pathMatcher.match(pattern, path)) {
                log.debug("Path " + path + " matches ignored pattern " + pattern);
                return true;
            }
        }
        return false;
    }

    private String adjustPath(final String path) {
        String adjustedPath = path;

        if (RequestUtils.isDispatcherServletRequest()
                && StringUtils.hasText(this.dispatcherServletPath)) {
            if (!this.dispatcherServletPath.equals("/")) {
                adjustedPath = path.substring(this.dispatcherServletPath.length());
                log.debug("Stripped dispatcherServletPath");
            }
        }
        else if (RequestUtils.isZuulServletRequest()) {
            if (StringUtils.hasText(this.zuulServletPath)
                    && !this.zuulServletPath.equals("/")) {
                adjustedPath = path.substring(this.zuulServletPath.length());
                log.debug("Stripped zuulServletPath");
            }
        }
        else {
            // do nothing
        }

        log.debug("adjustedPath=" + path);
        return adjustedPath;
    }

}

四、内置组件

1. RequestContext

zuul的源码中是这样解释RequestContext的:

RequestContext保存了request、response、状态信息和数据,以供ZuulFilter来访问和共享。RequestContext在请求期间有效,并且保存在ThreadLocal中。可以通过设置contextClass替换RequestContext的扩展。RequestContext是ConcurrentHashMap的扩展实现。

setSendZuulResponse

ctx.setSendZuulResponse(false)表示不进行路由。换句话说,如果设置为false,这个请求最终不会被zuul转发到后端服务器,但是如果当前Filter后面还存在其他Filter,那么其他Filter仍然会被调用到,所以一般我们在Filter的shouldFilter方法中,都会通过

@Override
public boolean shouldFilter() {
    RequestContext ctx = RequestContext.getCurrentContext();
 
    if(!ctx.sendZuulResponse()){
        return false;
    }
}

这样的方法来做判断,如果这个请求最终被拦截掉,则后面的过滤器逻辑也不需要执行了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值