Zuul网关使用笔记

Zuul

介绍

Zuul包含了对请求的路由和过滤两个主要的功能,其中路由功能负责将外部的请求转发到具体的微服务实例上,是实现外部访问统一入口的基础上,而过滤功能则负责对请求的处理过程进行干预,是实现请求校验,服务聚合等功能的基础。

Zuul和Eureka进行整合,将Zuul自身注册为Eureka服务治理下的应用,同时从Eureka中获取其他微服务的信息,也即以后访问微服务都是通过Zuul跳转后获得。

代理+路由+过滤三大功能。

Pom 中引入包

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

启动类中添加@EnableZuulProxy注解启用Zuul的API网关功能

@EnableZuulProxy  //开启zuul
@EnableDiscoveryClient //开启eurkea客户端
@SpringBootApplication
public class ZuulProxyApplication {
    public static void main(String[] args) {
        SpringApplication.run(ZuulProxyApplication.class, args);
    }

}

yml文件中配置相关属性信息

zuul:
  prefix: /api
#  strip-prefix: true # 默认不生效,要想生效,zuul.stripPrefix=false 把忽略前缀设置成false
  add-host-header: true #设置为true重定向是会添加host请求头
  host:
    connect-timeout-millis: 6000000 # url时生效
    socket-timeout-millis: 6000000
    max-per-route-connections: 6000000
    max-total-connections: 6000000
  routes:
    eshop-admin:
      path: /sys/**
      serviceId: eshop-admin
    eshop-task:
      path: /task/**
      serviceId: eshop-task
  servlet-path: /
  sensitive-headers: Set-Cookie、Cookie、Host、Connection、Content-Length、Content-Encoding、Server、Transfer-Encoding、X-Application-Context
ribbon:  # ribbonTimeout = (ribbonReadTimeout + ribbonConnectTimeout) * (maxAutoRetries + 1) * (maxAutoRetriesNextServer + 1);
  ReadTimeout: 6000000 # 以serviceId访问服务时生效 单位:ms
  SocketTimeout: 6000000 #  以serviceId访问服务时生效 单位:ms
#  MaxAutoRetries: 0
#  MaxAutoRetriesNextServer: 1
#hystrix:
#  command:
#    default:
#      execution:
#        isolation:
#          thread:
#            timeoutInMilliseconds: 3000000 #单位:ms
eureka:
  client:
    service-url:
       defaultZone: http://localhost:8901/eureka/
  instance:
    prefer-ip-address: true #相较于hostname是否优先使用IP
    hostname: ${spring.cloud.client.ip-address}
    instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${spring.application.instance_id:${server.port}}

路由映射规则

传统路由实现方式

传统路由就是不依赖于服务发现机制通过配置文件映射服务实例关系来实现的API网关对外请求路由。

没有服务治理框架的帮助,不同服务实例的数量采用不同的方式配置来实现路由规则

zuul:
  routes:
    traditional-url:                             #传统的路由配置,此名称可以自定义
      path: /tr-url/**                           #映射的url
      url: http://localhost:9001/                #被映射的url

面向服务的路由

快速入门中已经讲了面向服务路由的配置,通过与Eureka的整合,实现了对服务实例的自动维护,所以在使用服务路由的时候,无须指定serviceId所指定具体服务实例地址,只需要通过zuul.routes.<路由名>.pathzuul.routes.<路由名>.serviceId成对配置即可。

zuul:
  routes:
    traditional-url:                             #传统的路由配置,此名称可以自定义
      path: /tr-url/**                           #映射的url
      url: http://localhost:9001/                #被映射的url
    orient-service-url:                          #面向服务的路由配置,此名称可以自定义
      path: /os-url/**
      service-id: feign-customer                 #服务名

与传统路由相比,有外部请求到API网关的时候,面向服务路由发生了什么?

当有外部请求到达API网关的时候,根据请求的URL路径去匹配path的规则,通过path找到路由名,去找对应的serviceId的服务名,

  • 传统路由就会去根据这个服务名去找listOfServers参数,从而进行负载均衡和请求转发
  • 面向服务路由会从注册到服务治理框架中取出服务实例清单,通过清单直接找到对应的实例地址清单,从而通过Ribbon进行负载均衡选取实例进行路由(请求转发)

服务路由的默认配置

Eureka与Zuul整合为我们省去了大量的维护服务实例清单的配置工作,但是实际操作中我们会将path与serviceId都用服务名开头,如上边我所举的几个例子都是,这样的配置其实Zuul已经默认为我们实现了,当我们直接使用http://localhost:5555/feign-customer/hello的时候,会发现我们没有配置这个path确实访问成功了!

其实,Zuul在注册到Eureka服务中心之后,它会为Eureka中的每个服务都创建一个默认的路由规则,默认规则的path会使用serviceId配置的服务名作为请求前缀。

这会使一些我们不希望的开放的服务有可能被外部访问到,此时,我们可以使用zuul.ignored-services参数来设置一个不自动创建该服务的默认路由。Zuul在自动创建服务路由的时候会根据这个表达式进行判断,如果服务名匹配表达式,那么Zuul将跳过此服务,不为其创建默认路由。如下

zuul: 
  ignored-services: feign-customer,eureka-service

路径匹配

无论传统还是服务路由都是要使用path的,即用来匹配请求url中的路径。

正则路径匹配

path通常需要使用通配符,这里简单讲讲

通配符说明举例
?匹配单个字符/feign/?
*匹配任意数量字符,但不支持多级目录/feign/*
**匹配任意数量字符,支持多级目录/feign/**

如果有一个可以同时满足多个path的匹配的情况,此时匹配结果取决于路由规则的定义顺序,

忽略表达式

Zuul提供了用于忽略路径表达式的参数zuul.ignored-patterns。使用该参数可以用来设置不希望被API网关进行路由的URL表达式。

举例:

上文中使用过/feign/hello的接口,这次我们使用如下代码去忽略这个/hello的接口

zuul: 
  ignored-patterns: /**/hello/**

zuul相关的默认配置 springcloud(F版)

属性默认值描述
zuul.retryablefalse是否开启重试
ribbon.ConnectTimeout1000链接超时时间
ribbon.ReadTimeout1000读超时时间
ribbon.MaxAutoRetries0对第一次请求的服务的重试次数
ribbon.MaxAutoRetriesNextServer1要重试的下一个服务的最大数量(不包括第一个服务)
ribbon.OkToRetryOnAllOperationsfalse所有请求都重试
ribbon.MaxTotalHttpConnections200?
ribbon.MaxConnectionsPerHost50?
hystrix.command.default.execution.timeout.enabledtrue是否开启
  • hystrix.command.default.execution.timeout.enabled=true #如果enabled设置为false,则请求超时交给ribbon控制,为true,则超时作为熔断根据

  • hystrix.command.default.execution.isolation.strategy=SEMAPHORE #THREAD —— 在固定大小线程池中,以单独线程执行,并发请求数受限于线程池大小 SEMAPHORE —— 在调用线程中执行,通过信号量来限制并发量

  • hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=60000 #断路器超时时间,默认1000ms

路由前缀

为了方便全局为路由path增加前缀信息,Zuul提供了zuul.prefix参数来进行设置,但是代理前缀会从默认路径中移除掉,为避免这种情况,可以使用zuul.stripPrefix=false 来关闭移除代理前缀的动作,也可以通过zuul.routes.<路由名>.strip-prefix=false来指定服务关闭移除代理前缀的动作

 zuul:
  prefix: /api
  routes:
    feign-customer:
      path: /feign/**
      stripPrefix: false

Cookie与头信息

默认情况下,Zuul在请求路由时不会过滤掉HTTP请求头信息中的一些敏感信息(包括Cookie、Set-Cookie、Authorization),这些敏感信息对于下游服务是没有用处的且易导致下游服务头信息混乱,可以通过zuul.sensitiveHeaders参数来过滤掉这些敏感信息,防止这些敏感信息回到调用者手中,可以设置的属性有Cookie、Set-Cookie、Authorization

可以分为全局指定放行Cookie和Headers信息和指定路由放行

全局过滤

zuul: 
  sensitiveHeaders: Cookie,Set-Cookie,Authorization

指定路由名过滤

 zuul:
  routes:
    users:
      path: /myusers/**
      sensitiveHeaders: Cookie,Set-Cookie,Authorization
      url: feign-customeer

过滤返回的请求头

有时候我们需要把被代理的微服务返回的请求头过滤掉(例如跨域在网关添加了,如果各微服务也添加了也将造成跨域失败,所以我们需要过滤掉):

zuul:
  ignored-headers:
    - Access-Control-Allow-Origin
    - Access-Control-Allow-Methods
    - Access-Control-Allow-Headers
    - Access-Control-Allow-Credentials
    - Access-Control-Max-Age

本地跳转

迁移现有应用程序或API时的一种常见模式是“关闭”旧的端点,并慢慢地用不同的实现替换它们。Zuul代理是一个有用的工具,因为可以使用它来处理来自旧端点客户端的所有流量,但会将某些请求重定向到新端点。

zuul.routes.<路由名>.url=forward:/<要跳转到的端点>
 zuul:
  routes:
    first:
      path: /first/**
      url: http://first.example.com
    second:
      path: /second/**
      url: forward:/first

Filter

微服务应用中的每个客户端在提供服务的接口时,都会将访问权限加以限制,并不会放开所有的接口,为了安全,我们应该为每个微服务加入校验签名和鉴权等的过滤器或者拦截器,这样一来,会增加日后系统的维护难度,同时大部分的校验和鉴权的逻辑代码是相同的,那么我们就应该将这些重复逻辑提取出来,上文中曾说到“Zuul相当于整个微服务系统的门面”,那么接下来我们来看下在zuul网关中实现客户端的请求校验,即Spring Cloud Zuul 的核心功能之一的请求过滤

在Spring Cloud Zuul 中实现过滤器必须包含4 个基本特征:过滤类型、执行顺序、执行条件、具体操作。

  • filterType:该方法需要返回一个字符串来代表过滤器的类型,而这个类型就是Zuul中的4种不同生命周期的过滤器类型,如下
    • pre:在请求到达路由前被调用
    • route:在路由请求时被调用
    • error: 处理请求时发生的错误时被调用。
    • post:在route和error过滤器之后被调用,最后调用。
  • filterOrder:通过int值定义过滤器执行顺序,数值越小优先级越高。
  • shouldFilter:返回布尔值来判断该过滤器是否执行。
  • run:过滤器的具体逻辑。可以在此确定是否拦截当前请求等。
名称类型次序描述
ServletDetectionFilterpre-3通过Spring Dispatcher检查请求是否通过
Servlet30WrapperFilterpre-2适配HttpServletRequest为Servlet30Wrapper对象
FormBodyWrapperFilterpre-1解析表单数据为下游请求重新编译
DebugFilterpre1Debug路由标识
PreDecorationFilterpre5处理请求上下文供后续使用,设置下游相关头信息
RibbonRoutingFilterroute10使用Ribbon,Hystrix或者嵌入式HTTP客户端发送请求
SimpleHostRoutingFilterroute100使用Apache的HttpClient发送请求
SendForwardFilterroute500使用Servlet发送请求
SendResponseFilterpost1000将代理请求的响应写入当前响应
SendErrorFiltererror0如果RquestContext.getThrowable()不为空,则转发到error,path配置的路径

Demo

我们先定义一个简单的Zuul过滤器,实现检查HttpServletRequest中是否有Authorization参数,如果有就将其添加到requestContext.addZuulRequestHeader中,如果没有则不作任何处理

创建一个filter包下创建AccessFilter,继承ZuulFilter并重写以下方法

@Component
public class WebFilter extends ZuulFilter {
    @Override
    public String filterType() {
        return "pre";
    }

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

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run(){
        System.out.println("zuul过滤器...");
        //向header中添加鉴权令牌
        RequestContext requestContext = RequestContext.getCurrentContext();
        //获取header
        HttpServletRequest request = requestContext.getRequest();
        String authorization = request.getHeader("Authorization");
        if(authorization != null) {
            requestContext.addZuulRequestHeader("Authorization", authorization);
        }
        return null;
    }
}
  • 我们可以对过滤器进行禁用的配置,配置格式如下:
zuul:
  filterClassName:
    filter:
      disable: true 
  • 以下是禁用PreLogFilter的示例配置:

    zuul:
      PreLogFilter:
        pre:
          disable: true 
    

Ribbon

负载均衡概念

  • 服务器端负载均衡:例如Nginx,通过Nginx进行负载均衡,先发送请求,然后通过负载均衡算法,在多个服务器之间选择一个进行访问;即在服务器端再进行负载均衡算法分配。

  • 客户端负载均衡:例如spring cloud中的ribbon,客户端会有一个服务器地址列表,在发送请求前通过负载均衡算法选择一个服务器,然后进行访问,这是客户端负载均衡;即在客户端就进行负载均衡算法分配。

Zuul中包含了Hystrix和Ribbon的依赖,所以Zuul拥有线程隔离和断路器的自我保护功能,以及对服务调用的客户端负载均衡,需要注意的是传统路由也就是使用path与url映射关系来配置路由规则的时候,对于路由转发的请求不会使用HystrixCommand来包装,所以没有线程隔离和断路器的保护,并且也不会有负载均衡的能力。所以我们在使用Zuul的时候推荐使用path与serviceId的组合来进行配置。

设置Ribbon连接超时

 # ribbonTimeout = (ribbonReadTimeout + ribbonConnectTimeout) * (maxAutoRetries + 1) * (maxAutoRetriesNextServer + 1);
ribbon: 
  ReadTimeout: 6000000 #连接建立后,服务返回值的超时时间
  SocketTimeout: 6000000 #建立stocket连接的超时时间
  MaxAutoRetries: 0 #对第一次请求的服务的重试次数
  MaxAutoRetriesNextServer: 1 #要重试的下一个服务的最大数量

使用ribbon.ConnectTimeout参数创建请求连接的超时时间,当ribbon.ConnectTimeout的配置值小于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds的配置值时,若出现请求超时的时候,会自动进行重试路由请求,如果依然失败,Zuul会返回如下JSON信息给外部调用方

{
	  "timestamp":20200705141032,
    "status":500,
    "error":"Internal Server Error",
    "exception":"com.netflix.zuul.exception.ZuulException",
    "message":"NUMBEROF_RETRIES_NEXTSERVER_EXCEEDED"
}

如果ribbon.ConnectTimeout的配置值大于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds的配置值时,当出现请求超时的时候不会进行重试,直接超时处理返回TIMEOUT的错误信息

关闭重试配置

  • 全局配置zuul.retryable=false

  • 针对路由配置zuul.routes.<路由名>.retryable=false

Ribbon 客户端HttpClient 与okhttp

  • 当ribbon使用的是httpclient时,重试机制是默认关闭的,如果要启动重试机制需要在项目中引用spring-retry包,以及手工打开zuul.retryable=true设置,其实除此之外spring.cloud.loadbalancer.retry.enabled=true 也是需要设置的只不过这个值默认为true,所以此处可以忽略设置。

  • 当ribbon使用的是okhttp时,重试机制是自动打开的,重试的效果与我们设置的ribbon超时时间以及重试次数都有关系。

httclient是通过RibbonRoutingFilter.setResponse(ClientHttpResponse resp)

okhttp是通过AbstractRibbonCommand.getFallbackResponse

  • 在AbstractRibbonCommand中我们可以看到两个计算时间的方式
    getRibbonTimeout 上面我们已经说过,除些之外还有一个计算hystrix的时间方法getHystrixTimeout,默认情况下hystrix超时时间defaultHystrixTimeout为0,网上大部分都说默认是1秒,但其实我认为在zuul网关这个场景下,这种说法不对的。如果我们设置了hystrix超时时间,刚会已我们设置的为准,但如果我们不设置,代码会使用ribbon的超时时间为hystrix超时时间。

  • 而且当hystrixTimeout设置的值小于ribbonTimeout,则会打印警告。也就是说当hystrixTimeout<ribbonTimeout ribbon的超时设置就没有意义了,因为提前触发了hystrix的服务降级策略.但是反过来,如果 hystrixTimeout>ribbonTimeout ribbonTimeout的设置也没意义了啊,因为ribbon超时了,也触发网关回退机制了,hystrixTimeout就没意思了,但感觉这点设计的比较乱。

使用场景

重试默认都是只支持get请求,如果我把请求方式修改为post重试是不生效的,我们需要设置OkToRetryOnAllOperations为true, 这种情况不太建议,因为post请求大多都是写入请求,如果要支持重试,服务自身的幂等性一定要健壮。

Hystrix

设置Hystrix超时时间

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 3000000 #单位:ms

使用hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds来设置API网关中路由转发请求的命令执行时间超过配置值后,Hystrix会将该执行命令标记为TIMEOUT并抛出异常,Zuul会对该异常进行处理并返回如下JSON信息给外部调用方

{
	  "timestamp":20200705141032,
    "status":500,
    "error":"Internal Server Error",
    "exception":"com.netflix.zuul.exception.ZuulException",
    "message":"TIMEOUT"
}

网关限流

引入配置

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

源码解读

public class RateLimitFilter extends ZuulFilter {
    public static final String LIMIT_HEADER = "X-RateLimit-Limit";
    public static final String REMAINING_HEADER = "X-RateLimit-Remaining";
    public static final String RESET_HEADER = "X-RateLimit-Reset";

    public String filterType() {
        return "pre";
    }
    public int filterOrder() {
        return -1;
    }
    public boolean shouldFilter() {
        return this.properties.isEnabled() && this.policy(this.route()).isPresent();
    }
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletResponse response = ctx.getResponse();
        HttpServletRequest request = ctx.getRequest();
        Route route = this.route();
        this.policy(route).ifPresent((policy) -> {
            String key = this.rateLimitKeyGenerator.key(request, route, policy);
            Rate rate = this.rateLimiter.consume(policy, key);
            response.setHeader("X-RateLimit-Limit", policy.getLimit().toString());
            response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(rate.getRemaining().longValue(), 0L)));
            response.setHeader("X-RateLimit-Reset", rate.getReset().toString());
            if(rate.getRemaining().longValue() < 0L) {
                ctx.setResponseStatusCode(HttpStatus.TOO_MANY_REQUESTS.value());
                ctx.put("rateLimitExceeded", "true");
                throw new ZuulRuntimeException(new ZuulException(HttpStatus.TOO_MANY_REQUESTS.toString(), HttpStatus.TOO_MANY_REQUESTS.value(), (String)null));
            }
        });
        return null;
    }
}

配置参数

zuul:

    ratelimit:

        key-prefix: your-prefix  #对应用来标识请求的key的前缀

        enabled: true

        repository: REDIS  #对应存储类型(用来存储统计信息)

        behind-proxy: true  #代理之后

        default-policy: #可选 - 针对所有的路由配置的策略,除非特别配置了policies

             limit: 10 #可选 - 每个刷新时间窗口对应的请求数量限制

             quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

              refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

               type: #可选 限流方式

                    - user

                    - origin

                    - url

          policies:

                myServiceId: #特定的路由

                      limit: 10 #可选- 每个刷新时间窗口对应的请求数量限制

                      quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

                      refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

                      type: #可选 限流方式

                          - user

                          - origin

                          - url

全局限流

#全局配置限流
zuul.ratelimit.enabled=true
##60s 内请求超过 3 次,服务端就抛出异常,60s 后可以恢复正常请求
zuul.ratelimit.default-policy.limit=3
zuul.ratelimit.default-policy.refresh-interval=60
##针对 IP 进行限流,不影响其他 IP
zuul.ratelimit.default-policy.type=origin

局部限流

# 局部限流:针对某个服务进行限流
##开启限流
zuul.ratelimit.enabled=true
##60s 内请求超过 3 次,服务端就抛出异常,60s 后可以恢复正常请求
zuul.ratelimit.policies.e-book-product.limit=3
zuul.ratelimit.policies.e-book-product.refresh-interval=60
##针对某个 IP 进行限流,不影响其他 IP
zuul.ratelimit.policies.e-book-product.type=origin

增对error异常,我们可以提供一个全局异常处理器

@RestController
public class ExceptionHandler implements ErrorController{

	@Override
	public String getErrorPath() {
		
		return "error";
	}
	
	@RequestMapping(value="/error")
	public String error(){
		return "{\"result\":\"访问太多频繁,请稍后再访问!!!\"}";
	}

}

常见问题

Spring cloud 用zuul做网关,请求头信息丢失,导致用户自定义Authorization头部信息无法传递到应用服务中,导致jwt验证失败。

解决方案一:使用zuul实现验证自定义请求头中的token

jwt验证放到zuul层进行验证

这个方法的特点就是, 只要他能在遇到 return null 就表示成功完成了验证的逻辑

@Override
public Object run() throws ZuulException {
    System.err.println("经过了后台的过滤器");

    RequestContext currentContext = RequestContext.getCurrentContext();
    HttpServletRequest request = currentContext.getRequest();
    // 获取出请求头
    String header = request.getHeader("Authorization");

    // 放行zuul的第一次请求 todo 我并没有触发这个方法的执行
    if (request.getMethod().equals("OPTIONS")){
        System.err.println("OPTIONS");
        return null;
    }

    // 放行登录请求
    if (request.getRequestURL().indexOf("login")>0){
        return null;
    }

    if (StringUtils.isNotBlank(header)){
        if (header.startsWith("Bearer ")){
            String token = header.substring(7);
            if (StringUtils.isNotBlank(token)){
                System.out.println("token=="+token);
                try{
                    Claims claims = jwtUtil.parseJWT(token);
                    String roles =(String) claims.get("roles");
                    System.out.println("roles=="+roles);
                    // 对admin放行,
                    if (roles.equals("admin")){
                        return null;
                    }
                    // todo 转发头信息,我改了配置文件, 让zuul不过滤任何头信息
                    // 其他情况, 终止访问
                    currentContext.setSendZuulResponse(false);

                }catch (Exception e){
                    // 解析token出现的异常,说明token有问题, 终止本次请求
                    System.out.println("token出错了,终止本次访问: "+e);
                    currentContext.setSendZuulResponse(false);
                }
            }
        }
    }

    currentContext.setSendZuulResponse(false);
    currentContext.getResponse().setContentType("text/html;chatset=utf-8");
    try {
        currentContext.getResponse().getWriter().write("权限不足");
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
解决方案二:在 yml中添加配置

zuul默认过滤参数:
Authorization、Set-Cookie、Cookie、Host、Connection、Content-Length、Content-Encoding、Server、Transfer-Encoding、X-Application-Context

其在zuul配置中属于:zuul.sensitive-headers中

比如要使用Authorization参数,在设置中将其移除即可:
zuul.sensitive-headers: Set-Cookie、Cookie、Host、Connection、Content-Length、Content-Encoding、Server、Transfer-Encoding、X-Application-Context

设置zuul敏感头部信息为空:

img

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值