OkHttp原理分析小结

OkHttp 是 Square 公司开源的一款网络框架,封装了一个高性能的 http 请求库,本文对OkHttp原理给大家详细讲解,感兴趣的朋友跟随小编一起看看吧

Okhttp 介绍
OkHttp 是 Square 公司开源的一款网络框架,封装了一个高性能的 http 请求库。

https://github.com/square/okhttp

特点

支持 spdy、http2.0、websocket 等协议
支持同步、异步请求
封装了线程池,封装了数据转换,提高性能。
在 Android 6.0 中自带的网络请求 API 的底层就是使用了 okhttp 来进行的
使用 okhttp 比较接近真正的 HTTP 协议的框架
这个类主要是用来配置 okhttp 这个框架的,通俗一点讲就是这个类是管理这个框架的各种设置的。

Call 类的工厂,通过 OkHttpClient 才能得到 Call 对象。

Okhttp 中几个重要类的介绍

OkHttpClient
这个类主要是用来配置 okhttp 这个框架的,通俗一点讲就是这个类是管理这个框架的各种设置的。

Call 类的工厂,通过 OkHttpClient 才能得到 Call 对象。

OkHttpClient使用注意
OkHttpClient 应该被共享,使用 okhttp 这个框架的时候,最好要将 OkHttpClient 设置成单例模式,所有的 HTTP 在进行请求的时候都要使用这一个 Client 。因为每个 OkHttpClient 都对应了自己的连接池和线程池。减少使用连接池和线程池可以减少延迟和内存的使用。相反的如果每个请求都创建一个 OkHttpClient 的话会很浪费内存资源。

OkHttpClient的创建
OkHttpClient 有三个创建方法

第一个方法:直接使用 new OkHttpClient() 来创建一个实例对象就可以了,这个实例对象有默认的配置。默认请求连接超时时间 10 s ,读写超时时间 10 s,连接不成功会自动再次连接。

第二个方法:就是通过 Builder的方式来自己定义一个 OkHttpclient 。当然如果你直接 build 没有自己配置参数的话,效果和第一个方法是一样的。

public final OkHttpClient = new OkHttpClient.Builder()
  .addInterceptor(new HttpLoggingInterceptor())
  .cache(new Cache(cacheDir,cacheSize))
  .等等配置
  .build();

第三个方法:就是通过已有的 OkHttpClient 对象来复制一份共享线程池和其他资源的 OkHttpClient 对象。

OkHttpClient agerClient = client.newBuilder()
  .readTimeout(500,TimeUnit.MILLSECONS)
  .build();

这种方法的好处就是,当我们有一个特殊的请求,有的配置有点不一样,比如要求连接超过 1 s 就算超时,这个时候我们就可以使用这个方法来生成一个新的实例对象,不过他们共用很多其他的资源,不会对资源造成浪费。

关于 OkHttpClient 的配置改变都在 Builder 中进行

不需要了可以关闭
其实持有的线程池和连接池将会被自定释放如果他们保持闲置的话。

你也可以自动释放,释放后将来再调用 call 的时候会被拒接。

`client.dispatcher().excurorService().shutdown()```
清除连接池,注意清除后,连接池的守护线程可能会立刻退出。

client.connectionPool().evictAll()
如果 Client 有缓存,可以关闭。注意:再次调用一个被关闭的 cache 会发生错误。也会造成 crash。

client.cache().close();
OkHttp 在 HTTP/2 连接的时候也会使用守护线程。他们闲置的时候将自动退出。

知道有这么一回事就行,一般不会主动调用。

Call 类
Call 这个类就是用来发送 HTTP 请求和读取 HTTP 响应的一个类

image-20221108142020997

这个类的方法很少,从上到下依次是:放弃请求、异步执行请求、同步执行请求。

Request 类
这个类就是相当于 http 请求中的请求报文,是用来表达请求报文的,所以这里可以设置请求的 url、请求头、请求体等等和请求报文有关的内容。

主要方法罗列:

// 获取请求 url
public HttpUrl url();
// 获取请求方法类型
public String method();
// 获取请求头
public Headers headers();
//获取请求体
public RequestBody body();
// 获取 tag
public Object tag();
// 返回缓存控制指令,永远不会是 null ,即使响应不包含 Cache-Control 响应头
public CacheControl cacheControl();
// 是否是 https 请求
public boolean isHttps();
// Resquest{method=" ",url=" ",tag = " "}
public String toString();
image-20221108142440493

这是它的 Builder 中提供的方法,只设置 .url() 的时候默认是 post 请求。

RequestBody
介绍完请求报文就要介绍请求体了,这都是和 http协议紧密联系的。

RequestBody 就是用来设置请求体的,它的主要方法就是下面这个几个静态方法,用来生成对应的请求体:

在这里插入图片描述

就是通过这几个方法来产生对应的不同的请求体。MediaType 是用来描述请求体或者响应体类型的。比如请求体类型是 json 串格式的,那对应的 MediaType 就是MediaType.parse("application/json; charset=utf-8"); ,如果上传的是文件那么对应的就是 application/octet-stream,还有几个常用的类型 text/plain imge/png text/x-markdown 等等。

它还有两个子类:

在这里插入图片描述

FormBody 这个请求体是我们平时最常用的,就是我们平时使用 post 请求的时候,参数是键值对的形式。就是使用这个请求体最简单了。

说深一点,对应的请求报文是:

POST /test HTTP/1.1 请求行
Host: 32.106.24.148:8080 下面都是请求头
Content-Type: application/x-www-form-urlencoded 用于指明请求体的类型。
User-Agent: PostmanRuntime/7.15.0
Accept: /
Cache-Control: no-cache
Postman-Token: 954bda0d-dbc2-4193-addf-a7631cab2cfa,5ba2ebed-90b4-4f35-bcf5-80c4777de471
Host: 39.106.24.148:8080
accept-encoding: gzip, deflate
content-length: 133
Connection: keep-alive
cache-control: no-cache

key0=value0&key1=value1 请求体(也是我们的参数)

这是发送的原始的报文格式,用代码实现的话就是

// 创建客户端
OkHttpClient client = new OkHttpclient();
// 建立请求体 
FormBody formBody = new FormBody.Builder()
                    .add("key0", "value0")
                    .add("key1","value1")
                    .build();
// 建立请求报文
Request request = new Request.Builder
                                .post(formBody)
                                .url("请求url")
                                .addHeader("Content-Type", "application/x-www-form-urlencoded")
  .addHeader("User-Agent", "PostmanRuntime/7.15.0")
  .addHeader("Accept", "*/*")
  .addHeader("Cache-Control", "no-cache")
  .addHeader("Postman-Token", "954bda0d-dbc2-4193-addf-a7631cab2cfa,af7c027c-a7ba-4560-98ae-3a2a473ab88a")
  .addHeader("Host", "39.106.24.148:8080")
  .addHeader("accept-encoding", "gzip, deflate")
  .addHeader("content-length", "133")
  .addHeader("Connection", "keep-alive")
  .addHeader("cache-control", "no-cache")
  .build();
// 发起请求
client.newCall(request).excute();

上面是使用了 FormBody 的形式,如果使用 RequestBody 的话就要更麻烦一些。

OkHttpClient client = new OkHttpClient();
MediaType mediaType = MediaType.parse("application/x-www-form-urlencoded");
RequestBody body = RequestBody.create(mediaType, "key0=value0&key1=value1");
Request request = new Request.Builder()
  .url("http://39.106.24.148:8080/test")
  .post(body)
  .addHeader("Content-Type", "application/x-www-form-urlencoded")
  .addHeader("User-Agent", "PostmanRuntime/7.15.0")
  .addHeader("Accept", "*/*")
  .addHeader("Cache-Control", "no-cache")
  .addHeader("Postman-Token", "954bda0d-dbc2-4193-addf-a7631cab2cfa,af7c027c-a7ba-4560-98ae-3a2a473ab88a")
  .addHeader("Host", "39.106.24.148:8080")
  .addHeader("accept-encoding", "gzip, deflate")
  .addHeader("content-length", "133")
  .addHeader("Connection", "keep-alive")
  .addHeader("cache-control", "no-cache")
  .build();
Response response = client.newCall(request).execute();

当然平时我们使用的时候,不用拼上这么多的请求头,我这样写的目的就是为了更加还原请求报文。

还有一个子类 MultipartBody这个可以用来构建比较复杂的请求体。

1995 年 Content-Type 的类型扩充了 multipart/form-data 用来支持向服务器发送二进制数据。如果一次提交多种类型的数据,比如:一张图片和一个文字,这个时候引入了 boundary ,boundary使得 POST 可以满足这种提交多种不同的数据类型。通过 boundary 可以实现多个不同类型的数据同时存在在一个 Request 中。两个 boundary之间就是一个类型的数据,并且可以重新设置 Content-Type

与 HTML 文件上传形式兼容。每块请求体都是一个请求体,可以定义自己的请求头。这些请求头可以用来描述这块请求。例如,他们的 Content-Disposition。如果 Content-Length 和 Content-Type 可用的话,他们会被自动添加到请求头中。

来看一下这种类型的请求报文是什么样的:

POST /web/UploadServlet HTTP/1.1
Content-Type: multipart/form-data; boundary=e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Length: 66089
Host: localhost.tt.com:8080
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.5.0

–e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Disposition: form-data; name=”file”; filename=”**.png”
Content-Type: image/png
Content-Length: 65744

fdPNG
IHDR�0B7M�iM�M�CCPIM�CC ProfileH��……………………IEND�B`�
–e1b05ca4-fc4e-4944-837d-cc32c43c853a
Content-Disposition: form-data; name=”comment”
Content-Length: 30

上传一个图
–e1b05ca4-fc4e-4944-837d-cc32c43c853a–

第一个数据是一张 png 的图,重新设置了 Content-Type:image/png 中间的乱码就是图片的数据。这一堆数据前有一个空行,表示上下分别是请求头、请求体。

第二个数据,就是一个文本数据。

这样它们一起构成了请求体。

讲起来可能比较复杂,就记住,当既需要上传参数,又需要上传文件的时候用这种请求体。

MediaType mediaType = MediaType.parse("image/png");
        RequestBody requestBody = new MultipartBody.Builder()
                    // 需要设置成表单形式否则无法上传键值对参数
                .setType(MultipartBody.FORM)
                .addPart(Headers.of("Content-Disposition", "form-data;name=\"title\""),
                        RequestBody.create(null, "Square Logo"))
                .addPart(
                        Headers.of("Content-Disposition", "form-data;name=\"imge\""),
                        RequestBody.create(mediaType, new File("路径/logo.png"))
                ).
                        build();
        Request request = new Request.Builder()
                .post(requestBody)
                .url("https://api.imgur.com/3/image")
                .build();
        try {
            mOkHttpClient.newCall(request).execute();
        } catch (IOException e) {
            e.printStackTrace();
        }

简化写法:

MediaType mediaType = MediaType.parse("image/png");
        RequestBody requestBody = new MultipartBody.Builder()
                 .setType(MultipartBody.FORM)
               .addFormDataPart("title","logo")
                .addFormDataPart("img","logo.png",RequestBody.create(mediaType,new File("路径/logo.png")))
                .build();

Content-Disposition 可以用在消息体的子部分中,用来给出其对应字段的相关信息。作为 multipart body 中的消息头,第一个参数总是固定不变的 form-data; 附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号连接,参数之间用分号分隔。参数值用双引号括起来

// 比如这样,就是这种固定的格式
"Content-Disposition","form-data;name=\"mFile\";filename=\"xxx.mp4\""

到这里关于请求的几个重要的类就讲完了。

总结一下
只要掌握 http 请求的原理,使用起 okhttp 来也就不是什么问题了。

首先 OkHttpClient 是用来设置关于请求工具的一些参数的,比如超时时间、是否缓存等等。

Call 对象是发起 Http 请求的对象,通过 Call 对象来发起请求。

发起请求的时候,需要有请求报文,Request 对象就是对应的请求报文,可以添加对应的请求行、请求头、请求体。

说起请求体就是对应了 RequestBody 了。然后这个网络请求过程就完成了!

OKHTTP架构图
OKHTTP架构图

OKHttp发送主体流程
image-20221108145253547

在使用OkHttp发起一次请求时,对于使用者最少存在OkHttpClient、Request与Call三个角色。其中OkHttpClient和Request的创建可以使用它为我们提供的Builder(建造者模式)。而Call则是把Request交给OkHttpClient之后返回的一个已准备好执行的请求。

同时OkHttp在设计时采用的门面模式,将整个系统的复杂性给隐藏起来,将子系统接口通过一个客户端OkHttpClient统一暴露出来。

OkHttpClient中全是一些配置,比如代理的配置、ssl证书的配置等。而Call本身是一个接口,我们获得的实现为:RealCall

static RealCall newRealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket) {
    // Safely publish the Call instance to the EventListener.
    RealCall call = new RealCall(client, originalRequest, forWebSocket);
    call.eventListener = client.eventListenerFactory().create(call);
    return call;
}

Call的execute代表了同步请求,而enqueue则代表异步请求。两者唯一区别在于一个会直接发起网络请求,而另一个使用OkHttp内置的线程池来进行。这就涉及到OkHttp的任务分发器。

Call: 每一个请求的实例,比如登录login 对应一个Call、获取用户信息 对应一个Call。Call本身就是一个接口,用户的每一个Http请求就是一个Call实例,而且每一个Call都对应一个线程。
Call包含了 request()、execute()、enqueue() 方法。
RealCall: 具体的Call接口实现类,代表每一个HTTP请求。每一个RealCall内部有一个AsyncCall final类。
AsyncCall: RealCall类的内部final类,实现了NamedRunnable类的execute()。继承于NamedRunnable类,NamedRunnable类实现了Runnable接口,并且有一个execute()抽象方法,这个抽象方法在Runnable的run()里执行。
Dispatcher:
OkHttp的任务队列,其内部维护了一个线程池,进行线程分发,实现非阻塞,高可用,高并发。
当有接收到一个Call时,Dispatcher负责在线程池中找到空闲的线程并执行其execute方法。
Okhttp采用Deque作为缓存队列,按照入队的顺序先进先出。
OkHttp最出彩的地方就是在try/finally中调用了finished函数,可以主动控制等待队列的移动,而不是采用 锁或者wait/notify,极大减少了编码复杂性。

分发器
Dispatcher,分发器就是来调配请求任务的,内部会包含一个线程池。可以在创建OkHttpClient时,传递我们自己定义的线程池来创建分发器。

这个Dispatcher中的成员有:

//异步请求同时存在的最大请求
private int maxRequests = 64;
//异步请求同一域名同时存在的最大请求
private int maxRequestsPerHost = 5;
//闲置任务(没有请求时可执行一些任务,由使用者设置)
private @Nullable Runnable idleCallback;
//异步请求使用的线程池
private @Nullable ExecutorService executorService;
//异步请求等待执行队列
private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
//异步请求正在执行队列
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();
//同步请求正在执行队列
private final Deque<RealCall> runningSyncCalls = new ArrayDeque<>();

同步请求

synchronized void executed(RealCall call) {
    runningSyncCalls.add(call);
}

因为同步请求不需要线程池,也不存在任何限制。所以分发器仅做一下记录。

异步请求

synchronized void enqueue(AsyncCall call) {
    if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost)       {
        runningAsyncCalls.add(call);
        executorService().execute(call);
    } else {
        readyAsyncCalls.add(call);
    }
}

当正在执行的任务未超过最大限制64,同时runningCallsForHost(call) < maxRequestsPerHost同一Host的请求不超过5个,则会添加到正在执行队列,同时提交给线程池。否则先加入等待队列。

加入线程池直接执行没啥好说的,但是如果加入等待队列后,就需要等待有空闲名额才开始执行。因此每次执行完一个请求后,都会调用分发器的finished方法

//异步请求调用
void finished(AsyncCall call) {
    finished(runningAsyncCalls, call, true);
}
//同步请求调用
void finished(RealCall call) {
    finished(runningSyncCalls, call, false);
}
private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
    int runningCallsCount;
    Runnable idleCallback;
    synchronized (this) {
        //不管异步还是同步,执行完后都要从队列移除(runningSyncCalls/runningAsyncCalls)
        if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
        if (promoteCalls) promoteCalls();
        //异步任务和同步任务正在执行的和
        runningCallsCount = runningCallsCount();
        idleCallback = this.idleCallback;
    }
    // 没有任务执行执行闲置任务
    if (runningCallsCount == 0 && idleCallback != null) {
        idleCallback.run();
    }
}

需要注意的是 只有异步任务才会存在限制与等待,所以在执行完了移除正在执行队列中的元素后,异步任务结束会执行promoteCalls()。很显然这个方法肯定会重新调配请求。

private void promoteCalls() {
    //如果任务满了直接返回
    if (runningAsyncCalls.size() >= maxRequests) return; 
    //没有等待执行的任务,返回
    if (readyAsyncCalls.isEmpty()) return; 
    //遍历等待执行队列
    for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
        AsyncCall call = i.next();
        //等待任务想要执行,还需要满足:这个等待任务请求的Host不能已经存在5个了
        if (runningCallsForHost(call) < maxRequestsPerHost) {
            i.remove();
            runningAsyncCalls.add(call);
            executorService().execute(call);
        }
        if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
    }
}

请求流程
用户是不需要直接操作任务分发器的,获得的RealCall中就分别提供了execute与enqueue来开始同步请求或异步请求。

@Override public Response execute() throws IOException {
    synchronized (this) {
      if (executed) throw new IllegalStateException("Already Executed");
      executed = true;
    }
    captureCallStackTrace();
    eventListener.callStart(this);
    try {
      //调用分发器
      client.dispatcher().executed(this);
      //执行请求
      Response result = getResponseWithInterceptorChain();
      if (result == null) throw new IOException("Canceled");
      return result;
    } catch (IOException e) {
      eventListener.callFailed(this, e);
      throw e;
    } finally {
      //请求完成
      client.dispatcher().finished(this);
    }
}

异步请求的后续同时是调用getResponseWithInterceptorChain()来执行请求

@Override
public void enqueue(Callback responseCallback) {
    synchronized (this) {
        if (executed) throw new IllegalStateException("Already Executed");
        executed = true;
    }
    captureCallStackTrace();
    eventListener.callStart(this);
    //调用分发器
    client.dispatcher().enqueue(new AsyncCall(responseCallback));
}

如果该RealCall已经执行过了,再次执行是不允许的。异步请求会把一个AsyncCall提交给分发器。

AsyncCall实际上是一个Runnable的子类,使用线程启动一个Runnable时会执行run方法,在AsyncCall中被重定向到execute方法:

final class AsyncCall extends NamedRunnable {
    private final Callback responseCallback;
    AsyncCall(Callback responseCallback) {
        super("OkHttp %s", redactedUrl());
        this.responseCallback = responseCallback;
    }
    //线程池执行
    @Override
    protected void execute() {
     boolean signalledCallback = false;
      try {
        Response response = getResponseWithInterceptorChain();
       //.......
      } catch (IOException e) {
       //......
      } finally {
        //请求完成
        client.dispatcher().finished(this);
      }
    }
}
public abstract class NamedRunnable implements Runnable {
    protected final String name;
    public NamedRunnable(String format, Object... args) {
        this.name = Util.format(format, args);
    }
    @Override
    public final void run() {
        String oldName = Thread.currentThread().getName();
        Thread.currentThread().setName(name);
        try {
            execute();
        } finally {
            Thread.currentThread().setName(oldName);
        }
    }
    protected abstract void execute();
}

同时AsyncCall也是RealCall的普通内部类,这意味着它是持有外部类RealCall的引用,可以获得直接调用外部类的方法。

可以看到无论是同步还是异步请求实际上真正执行请求的工作都在getResponseWithInterceptorChain()中。这个方法就是整个OkHttp的核心:拦截器责任链。但是在介绍责任链之前,我们再来回顾一下线程池的基础知识。

分发器线程池
前面我们提过,分发器就是来调配请求任务的,内部会包含一个线程池。当异步请求时,会将请求任务交给线程池来执行。那分发器中默认的线程池是如何定义的呢?为什么要这么定义?

public synchronized ExecutorService executorService() {
    if (executorService == null) {
      executorService = new ThreadPoolExecutor(
                            0,                  //核心线程
                            Integer.MAX_VALUE,  //最大线程
                            60,                 //空闲线程闲置时间
                            TimeUnit.SECONDS,   //闲置时间单位
                            new SynchronousQueue<Runnable>(), //线程等待队列
                            Util.threadFactory("OkHttp Dispatcher", false) //线程创建工厂
      );
    }
    return executorService;
}

为什么选择使用OKHttp
1.可扩展性高。类似于缓存,Dns,请求/连接/响应超时时间等等都可以通过配置传入,甚至线程池都可以根据自己的需求来配置。
2.OKHttp使用了连接池缓存,提高通信效率。
3.责任链五层拦截器模式,每层功能清晰明了,并且提供了两层可扩展的拦截器方便进行所需要的改造。
4.层次结构清晰,方便进行问题的排查。
5.观察者模式的充分使用,查看请求状态和监控请求状态变得十分简单。
6.使用了OKIO框架进行数据的处理,效率和安全性上更高。

到此这篇关于OkHttp原理分析总结的文章就介绍到这了,更多相关OkHttp原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持vb.net教程C#教程python教程SQL教程access 2010教程xin3721自学网

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
OkHttp 是一个开源的 HTTP 客户端,用于 Android 平台和 Java 应用。它建立在 Java 的 HttpURLConnection 类之上,并提供了更简洁、更强大的 API。 OkHttp 的工作原理主要涉及以下几个关键组件: 1. `OkHttpClient`:这是 OkHttp 的核心类,负责配置和创建请求、设置拦截器、管理连接池等。你可以通过构建 OkHttpClient 实例来自定义请求的行为和参数。 2. `Request`:表示一个 HTTP 请求,包括 URL、请求方法(如 GET、POST)、请求体、请求头等信息。你可以通过 Request.Builder 构建一个 Request 实例。 3. `Response`:表示一个 HTTP 响应,包括响应码、响应体、响应头等信息。OkHttp 会将服务器返回的数据解析成 Response 对象。 4. `Interceptor`:拦截器用于在发送请求和接收响应之前进行一些额外的处理。OkHttp 提供了很多内置的拦截器,如重试拦截器、缓存拦截器等,同时也支持自定义拦截器。 5. `Dispatcher`:调度器负责管理请求的调度和执行。它可以控制同时并发执行的请求数量,还可以设置请求超时时间等。 6. `ConnectionPool`:连接池用于管理 HTTP 连接的复用和回收。OkHttp 会自动复用连接以减少网络延迟,提高性能。 7. `Cache`:缓存可以保存服务器返回的响应,以便在后续的请求中复用。OkHttp 支持对响应进行缓存,并提供了灵活的配置选项。 当你使用 OkHttp 发起一个网络请求时,它会通过 OkHttpClient 来创建一个 Request 对象,并通过 Dispatcher 来执行这个请求。在执行过程中,OkHttp 会根据设置的拦截器进行一系列的处理,如添加请求头、重试、缓存等。最终,OkHttp 将返回一个 Response 对象,你可以从中获取到服务器返回的数据。 总体来说,OkHttp 的工作原理是通过封装底层的 HttpURLConnection,提供了简洁易用的 API,并通过拦截器和连接池等机制优化了网络请求的性能和可定制性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值