okhttp原理解析和封装

 okhttp的流程图

流程图片
标题

1.对okhttpClient做初始化

2. 创建新的Call对象,

Call call = client.newCall(request);
public class OkHttpClient implements Cloneable, Call.Factory, WebSocket.Factory {
   @Override 
   public Call newCall(Request request) {
    return new RealCall(this, request, false /* for web socket */);
   }

}

RealCall实现了Call.Factory接口创建了一个RealCall的实例

final class RealCall implements Call {
   @Override 
   public void enqueue(Callback responseCallback) {
   synchronized (this) {
   if (executed) throw new IllegalStateException("Already Executed");
      executed = true;
   }
    captureCallStackTrace();
    client.dispatcher().enqueue(new AsyncCall(responseCallback));
  }
}

1) 检查这个 call 是否已经被执行了,每个 call 只能被执行一次,如果想要一个完全一样的 call,可以利用 call#clone 方法进行克隆。

2)利用 client.dispatcher().enqueue(this) 来进行实际执行

3)AsyncCall是RealCall的子类

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();
  if (retryAndFollowUpInterceptor.isCanceled()) {
     signalledCallback = true;
     responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
  } else {
    signalledCallback = true;
    responseCallback.onResponse(RealCall.this, response);
  }
 } catch (IOException e) {
  ......
  responseCallback.onFailure(RealCall.this, e);
        
} finally {
    client.dispatcher().finished(this);
  }
}

AsyncCall继承了NamedRunnable实现了execute方法,首先是调用getResponseWithInterceptorChain()方法获取响应,然后获取成功后,就调用回调的onReponse方法,如果失败,就调用回调的onFailure方法。最后,调用Dispatcher的finished方法。

public abstract class NamedRunnable implements Runnable {
  ......

  @Override 
  public final void run() {
   ......
    try {
      execute();
    }
    ......
  }

  protected abstract void execute();
}

可以看到NamedRunnable实现了Runnbale接口并且是个抽象类,其抽象方法是execute(),该方法是在run方法中被调用的,这也就意味着NamedRunnable是一个任务,并且其子类AsyncCall实现了execute方法

Dispatcher(调度器)介绍

public synchronized ExecutorService executorService() {
    if (executorService == null) {
      executorService = new ThreadPoolExecutor(
//corePoolSize 最小并发线程数,如果是0的话,空闲一段时间后所有线程将全部被销毁
    0, 
//maximumPoolSize: 最大线程数,当任务进来时可以扩充的线程最大值,当大于了这个值就会根据丢弃处理机制来处理
    Integer.MAX_VALUE, 
//keepAliveTime: 当线程数大于corePoolSize时,多余的空闲线程的最大存活时间
    60, 
//单位秒
    TimeUnit.SECONDS,
//工作队列,先进先出
    new SynchronousQueue<Runnable>(),   
//单个线程的工厂         
   Util.threadFactory("OkHttp Dispatcher", false));
    }
    return executorService;
  }

OkHttp,如上构造了单例线程池ExecutorService:

在Okhttp中,构建了一个核心为[0, Integer.MAX_VALUE]的线程池,它不保留任何最小线程数,随时创建更多的线程数,当线程空闲时只能活60秒,它使用了一个不存储元素的阻塞工作队列,一个叫做"OkHttp Dispatcher"的线程工厂

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

realCall中的调用看出当前还能执行并发请求,则加入 runningAsyncCalls ,立即执行,否则加入 readyAsyncCalls 队列。

针对同步请求,Dispatcher使用了一个Deque保存了同步任务;针对异步请求,Dispatcher使用了两个Deque,一个保存准备执行的请求,一个保存正在执行的请求

1.运行队列中立即异步执行

2.先进先出的顺序缓存队列

 

//Dispatcher的finished函数
void finished(AsyncCall call) {
    finished(runningAsyncCalls, call, true);
  }

private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
    int runningCallsCount;
    Runnable idleCallback;
    synchronized (this) {
      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();
    }
  }

//打开源码,发现它将正在运行的任务Call从队列runningAsyncCalls中移除后,获取运行数量判断是否进入了Idle状态,接着执行promoteCalls()函数,下面是promoteCalls()方法

private void promoteCalls() {
    if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity.
    if (readyAsyncCalls.isEmpty()) return; // No ready calls to promote.

    for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
      AsyncCall call = i.next();

      if (runningCallsForHost(call) < maxRequestsPerHost) {
        i.remove();
        runningAsyncCalls.add(call);
        executorService().execute(call);
      }

      if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
    }
  }

拦截器链

RealCall的execute方法有这么一段代码:Response result = getResponseWithInterceptorChain();

流程图:先是自定义拦截器

 


Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    List<Interceptor> interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors());
    interceptors.add(retryAndFollowUpInterceptor);
    interceptors.add(new BridgeInterceptor(client.cookieJar()));
    interceptors.add(new CacheInterceptor(client.internalCache()));
    interceptors.add(new ConnectInterceptor(client));
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors());
    }
    interceptors.add(new CallServerInterceptor(forWebSocket));
 
    Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
        originalRequest, this, eventListener, client.connectTimeoutMillis(),
        client.readTimeoutMillis(), client.writeTimeoutMillis());
 
    return chain.proceed(originalRequest);

可以看到,在该方法中,我们依次添加了用户自定义的interceptor、retryAndFollowUpInterceptor、BridgeInterceptor、CacheInterceptor、ConnectInterceptor、 networkInterceptors、CallServerInterceptor,并将这些拦截器传递给了这个RealInterceptorChain。拦截器之所以可以依次调用,并最终再从后先前返回Response,都依赖于RealInterceptorChain的proceed方法
 

public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec,
      RealConnection connection) throws IOException {
    if (index >= interceptors.size()) throw new AssertionError();
 
    ......
 
    // Call the next interceptor in the chain.
    RealInterceptorChain next = new RealInterceptorChain(interceptors, streamAllocation, httpCodec,
        connection, index + 1, request, call, eventListener, connectTimeout, readTimeout,
        writeTimeout);
    Interceptor interceptor = interceptors.get(index);
    Response response = interceptor.intercept(next);
 
    ......
 
    return response;

执行当前拦截器的Intercept方法,并调用下一个(index+1)拦截器。下一个(index+1)拦截器的调用依赖于当前拦截器的Intercept方法中,对RealInterceptorChain的proceed方法的调用 

重试及 followup拦截器

 @Override
        public Response intercept(Chain chain) throws IOException {
            Request request = chain.request();//获取Request对象
            RealInterceptorChain realChain = (RealInterceptorChain) chain;//获取拦截器链对象,用于后面的chain.proceed(...)方法
            Call call = realChain.call();
            EventListener eventListener = realChain.eventListener();//监听器
 
            streamAllocation = new StreamAllocation(client.connectionPool(), createAddress(request.url()),
                    call, eventListener, callStackTrace);
 
            int followUpCount = 0;
            Response priorResponse = null;
            while (true) {//循环
                if (canceled) {
                    streamAllocation.release();
                    throw new IOException("Canceled");
                }
 
                Response response;
                boolean releaseConnection = true;
                try {
                    response = realChain.proceed(request, streamAllocation, null, null);//调用下一个拦截器
                    releaseConnection = false;
                } catch (RouteException e) {
                    // The attempt to connect via a route failed. The request will not have been sent.
                    if (!recover(e.getLastConnectException(), false, request)) {//路由异常,尝试恢复,如果再失败就抛出异常
                        throw e.getLastConnectException();
                    }
                    releaseConnection = false;
                    continue;//继续重试
                } catch (IOException e) {
                    // An attempt to communicate with a server failed. The request may have been sent.
                    boolean requestSendStarted = !(e instanceof ConnectionShutdownException);
                    if (!recover(e, requestSendStarted, request)) throw e;连接关闭异常,尝试恢复
                            releaseConnection = false;
                    continue;//继续重试
                } finally {
                    // We're throwing an unchecked exception. Release any resources.
                    if (releaseConnection) {
                        streamAllocation.streamFailed(null);
                        streamAllocation.release();
                    }
                }
 
                // Attach the prior response if it exists. Such responses never have a body.
                if (priorResponse != null) {//前一个重试得到的Response
                    response = response.newBuilder()
                            .priorResponse(priorResponse.newBuilder()
                                    .body(null)
                                    .build())
                            .build();
                }
                //Figures out the HTTP request to make in response to receiving {@code userResponse}. This will
                //either add authentication headers, follow redirects or handle a client request timeout. If a
                //follow-up is either unnecessary or not applicable, this returns null.
                // followUpRequest方法的主要作用就是为新的重试Request添加验证头等内容
                Request followUp = followUpRequest(response);
                
                if (followUp == null) {//如果一个请求得到的响应code是200,则followUp是为null的。
                    if (!forWebSocket) { streamAllocation.release(); } return response; } 
                closeQuietly(response.body());
                //-------------------------------异常处理--------------------------------------------- 
                // if (++followUpCount > MAX_FOLLOW_UPS) {//超过最大的次数,抛出异常 
                 streamAllocation.release(); 
                throw new ProtocolException("Too many follow-up requests: " + followUpCount); }
            if (followUp.body() instanceof UnrepeatableRequestBody) {
                streamAllocation.release();
            } throw new HttpRetryException("Cannot retry streamed HTTP body", response.code()); 
        } if (!sameConnection(response, followUp.url())) {
            streamAllocation.release();
            streamAllocation = new StreamAllocation(client.connectionPool(), createAddress(followUp.url()), call, eventListener, callStackTrace);
        } else if (streamAllocation.codec() != null) {
            throw new IllegalStateException("Closing the body of " + response + " didn't close its backing stream. Bad interceptor?");
        }
        //--------------------------------------------------------------------------------
        request = followUp;//得到处理之后的Request,以用来继续请求,在哪继续请求?肯定还是沿着拦截器链继续搞呗 
         priorResponse = response;//由priorResponse持有 
         }
    }
 }

该拦截器主要的作用就是重试及followup 

当一个请求由于各种原因失败了,如果是路由或者连接异常,则尝试恢复,否则,根据响应码(ResponseCode),followup方法会对Request进行再处理以得到新的Request,然后沿着拦截器链继续新的Request。当然,如果responseCode是200的话,这些过程就结束了
 

BridgeInterceptor 

BridgeInterceptor的主要作用就是为请求(request before)添加请求头,为响应(Response Before)添加响应头。看源码:

@Override public Response intercept(Chain chain) throws IOException {
    Request userRequest = chain.request();
    Request.Builder requestBuilder = userRequest.newBuilder();
//----------------------request----------------------------------------------
    RequestBody body = userRequest.body();
    if (body != null) {
      MediaType contentType = body.contentType();
      if (contentType != null) {//添加Content-Type请求头
        requestBuilder.header("Content-Type", contentType.toString());
      }
 
      long contentLength = body.contentLength();
      if (contentLength != -1) {
        requestBuilder.header("Content-Length", Long.toString(contentLength));
        requestBuilder.removeHeader("Transfer-Encoding");
      } else {
        requestBuilder.header("Transfer-Encoding", "chunked");//分块传输
        requestBuilder.removeHeader("Content-Length");
      }
    }
 
    if (userRequest.header("Host") == null) {
      requestBuilder.header("Host", hostHeader(userRequest.url(), false));
    }
 
    if (userRequest.header("Connection") == null) {
      requestBuilder.header("Connection", "Keep-Alive");
    }
 
    // If we add an "Accept-Encoding: gzip" header field we're responsible for also decompressing
    // the transfer stream.
    boolean transparentGzip = false;
    if (userRequest.header("Accept-Encoding") == null && userRequest.header("Range") == null) {
      transparentGzip = true;
      requestBuilder.header("Accept-Encoding", "gzip");
    }
 
    List<Cookie> cookies = cookieJar.loadForRequest(userRequest.url());
    if (!cookies.isEmpty()) {
      requestBuilder.header("Cookie", cookieHeader(cookies));
    }
 
    if (userRequest.header("User-Agent") == null) {
      requestBuilder.header("User-Agent", Version.userAgent());
    }
 
    Response networkResponse = chain.proceed(requestBuilder.build());
//----------------------------------response----------------------------------------------
    HttpHeaders.receiveHeaders(cookieJar, userRequest.url(), networkResponse.headers());//保存cookie
 
    Response.Builder responseBuilder = networkResponse.newBuilder()
        .request(userRequest);
 
    if (transparentGzip
        && "gzip".equalsIgnoreCase(networkResponse.header("Content-Encoding"))
        && HttpHeaders.hasBody(networkResponse)) {
      GzipSource responseBody = new GzipSource(networkResponse.body().source());
      Headers strippedHeaders = networkResponse.headers().newBuilder()
          .removeAll("Content-Encoding")//Content-Encoding、Content-Length不能用于Gzip解压缩
          .removeAll("Content-Length")
          .build();
      responseBuilder.headers(strippedHeaders);
      String contentType = networkResponse.header("Content-Type");
      responseBuilder.body(new RealResponseBody(contentType, -1L, Okio.buffer(responseBody)));
    }
 
    return responseBuilder.build();
  }

CacheInterceptor

 http缓存浅谈

HTTP缓存机制

服务器收到请求时,会在200 OK中回送该资源的Last-Modified和ETag头(服务器支持缓存的情况下才会有这两个头哦),客户端将该资源保存在cache中,并记录这两个属性。当客户端需要发送相同的请求时,根据Date + Cache-control来判断是否缓存过期,如果过期了,会在请求中携带If-Modified-Since和If-None-Match两个头。两个头的值分别是响应中Last-Modified和ETag头的值。服务器通过这两个头判断本地资源未发生变化,客户端不需要重新下载,返回304响应

CacheStrategy是一个缓存策略类,该类告诉CacheInterceptor是使用缓存还是使用网络请求;

Cache是封装了实际的缓存操作;

DiskLruCache:Cache基于DiskLruCache;


@Override public Response intercept(Chain chain) throws IOException {
    Response cacheCandidate = cache != null
        ? cache.get(chain.request())//以request的url而来key,获取缓存
        : null;
 
    long now = System.currentTimeMillis();
    //缓存策略类,该类决定了是使用缓存还是进行网络请求
    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    Request networkRequest = strategy.networkRequest;//网络请求,如果为null就代表不用进行网络请求
    Response cacheResponse = strategy.cacheResponse;//缓存响应,如果为null,则代表不使用缓存
 
    if (cache != null) {//根据缓存策略,更新统计指标:请求次数、使用网络请求次数、使用缓存次数
      cache.trackResponse(strategy);
    }
    //缓存不可用,关闭
    if (cacheCandidate != null && cacheResponse == null) {
      closeQuietly(cacheCandidate.body()); // The cache candidate wasn't applicable. Close it.
    }
    //如果既无网络请求可用,又没有缓存,则返回504错误
    // If we're forbidden from using the network and the cache is insufficient, fail.
    if (networkRequest == null && cacheResponse == null) {
      return new Response.Builder()
          .request(chain.request())
          .protocol(Protocol.HTTP_1_1)
          .code(504)
          .message("Unsatisfiable Request (only-if-cached)")
          .body(Util.EMPTY_RESPONSE)
          .sentRequestAtMillis(-1L)
          .receivedResponseAtMillis(System.currentTimeMillis())
          .build();
    }
 
    // If we don't need the network, we're done.缓存可用,直接返回缓存
    if (networkRequest == null) {
      return cacheResponse.newBuilder()
          .cacheResponse(stripBody(cacheResponse))
          .build();
    }
 
    Response networkResponse = null;
    try {
      networkResponse = chain.proceed(networkRequest);//进行网络请求,得到网络响应
    } finally {
      // If we're crashing on I/O or otherwise, don't leak the cache body.
      if (networkResponse == null && cacheCandidate != null) {
        closeQuietly(cacheCandidate.body());
      }
    }
    //HTTP_NOT_MODIFIED缓存有效,合并网络请求和缓存
    // If we have a cache response too, then we're doing a conditional get.
    if (cacheResponse != null) {
      if (networkResponse.code() == HTTP_NOT_MODIFIED) {
        Response response = cacheResponse.newBuilder()
            .headers(combine(cacheResponse.headers(), networkResponse.headers()))
            .sentRequestAtMillis(networkResponse.sentRequestAtMillis())
            .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
            .cacheResponse(stripBody(cacheResponse))
            .networkResponse(stripBody(networkResponse))
            .build();
        networkResponse.body().close();
 
        // Update the cache after combining headers but before stripping the
        // Content-Encoding header (as performed by initContentStream()).
        cache.trackConditionalCacheHit();
        cache.update(cacheResponse, response);//更新缓存
        return response;
      } else {
        closeQuietly(cacheResponse.body());
      }
    }
 
    Response response = networkResponse.newBuilder()
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build();
 
    if (cache != null) {
      //有响应体 & 可缓存
      if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) {
        // Offer this request to the cache.
        CacheRequest cacheRequest = cache.put(response);
        return cacheWritingResponse(cacheRequest, response);//写缓存
      }
 
      if (HttpMethod.invalidatesCache(networkRequest.method())) {//判断缓存的有效性
        try {
          cache.remove(networkRequest);
        } catch (IOException ignored) {
          // The cache cannot be written.
        }
      }
    }
 
    return response;
  }

根据缓存策略类返回的结果:
1、如果网络不可用并且无可用的有效缓存,则返回504错误;
2、继续,如果不需要网络请求,则直接使用缓存;
3、继续,如果需要网络可用,则进行网络请求;
4、继续,如果有缓存,并且网络请求返回HTTP_NOT_MODIFIED,说明缓存还是有效的,则合并网络响应和缓存结果。同时更新缓存;
5、继续,如果没有缓存,则写入新的缓存;
我们可以看到,CacheStrategy在CacheInterceptor中起到了很关键的作用。该类决定了是网络请求还是使用缓存。该类最关键的代码是getCandidate()方法:
 

 private CacheStrategy getCandidate() {
      // No cached response.
      if (cacheResponse == null) {//没有缓存,直接网络请求
        return new CacheStrategy(request, null);
      }
 
      // Drop the cached response if it's missing a required handshake.
      if (request.isHttps() && cacheResponse.handshake() == null) {//https,但没有握手,直接网络请求
        return new CacheStrategy(request, null);
      }
 
      // If this response shouldn't have been stored, it should never be used
      // as a response source. This check should be redundant as long as the
      // persistence store is well-behaved and the rules are constant.
      if (!isCacheable(cacheResponse, request)) {//不可缓存,直接网络请求
        return new CacheStrategy(request, null);
      }
 
      CacheControl requestCaching = request.cacheControl();
      if (requestCaching.noCache() || hasConditions(request)) {
        //请求头nocache或者请求头包含If-Modified-Since或者If-None-Match
        //请求头包含If-Modified-Since或者If-None-Match意味着本地缓存过期,需要服务器验证
        //本地缓存是不是还能继续使用
        return new CacheStrategy(request, null);
      }
 
      CacheControl responseCaching = cacheResponse.cacheControl();
      if (responseCaching.immutable()) {//强制使用缓存
        return new CacheStrategy(null, cacheResponse);
      }
 
      long ageMillis = cacheResponseAge();
      long freshMillis = computeFreshnessLifetime();
 
      if (requestCaching.maxAgeSeconds() != -1) {
        freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
      }
 
      long minFreshMillis = 0;
      if (requestCaching.minFreshSeconds() != -1) {
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
      }
 
      long maxStaleMillis = 0;
      if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
      }
      //可缓存,并且ageMillis + minFreshMillis < freshMillis + maxStaleMillis
      // (意味着虽过期,但可用,只是会在响应头添加warning)
      if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        if (ageMillis + minFreshMillis >= freshMillis) {
          builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
        }
        long oneDayMillis = 24 * 60 * 60 * 1000L;
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
          builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
        }
        return new CacheStrategy(null, builder.build());//使用缓存
      }
 
      // Find a condition to add to the request. If the condition is satisfied, the response body
      // will not be transmitted.
      String conditionName;
      String conditionValue;
      //流程走到这,说明缓存已经过期了
      //添加请求头:If-Modified-Since或者If-None-Match
      //etag与If-None-Match配合使用
      //lastModified与If-Modified-Since配合使用
      //前者和后者的值是相同的
      //区别在于前者是响应头,后者是请求头。
      //后者用于服务器进行资源比对,看看是资源是否改变了。
      // 如果没有,则本地的资源虽过期还是可以用的
      if (etag != null) {
        conditionName = "If-None-Match";
        conditionValue = etag;
      } else if (lastModified != null) {
        conditionName = "If-Modified-Since";
        conditionValue = lastModifiedString;
      } else if (servedDate != null) {
        conditionName = "If-Modified-Since";
        conditionValue = servedDateString;
      } else {
        return new CacheStrategy(request, null); // No condition! Make a regular request.
      }
 
      Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
      Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
 
      Request conditionalRequest = request.newBuilder()
          .headers(conditionalRequestHeaders.build())
          .build();
      return new CacheStrategy(conditionalRequest, cacheResponse);
    }

大致流程如下:(if-else的关系呀)
1、没有缓存,直接网络请求;
2、如果是https,但没有握手,直接网络请求;
3、不可缓存,直接网络请求;
4、请求头nocache或者请求头包含If-Modified-Since或者If-None-Match,则需要服务器验证本地缓存是不是还能继续使用,直接网络请求;
5、可缓存,并且ageMillis + minFreshMillis < freshMillis + maxStaleMillis(意味着虽过期,但可用,只是会在响应头添加warning),则使用缓存;
6、缓存已经过期,添加请求头:If-Modified-Since或者If-None-Match,进行网络请求;


ConnectInterceptor(核心,连接池)

未完的后面再补充

okhttp解析

OkHttp 3.7源码分析(一)——整体架构

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值