优雅设计封装基于Okhttp3的网络框架(完):原生HttpUrlConnction请求、多线程分发 及 数据转换

前5篇博文完成了此框架的一大模块-----多线程下载,而这两篇文章实现另一大模块------Http基本框架封装,在上一篇博文中完成了HttpHeader的接口定义和实现、状态码定义及response、request接口封装和实现,定义了许多接口和抽象类,在接下来编码过程中会体现出程序的扩展性重要性。

在此篇博文中将添加新功能------原生请求的类库支持,你会发现在此基础上只需增加3个类即可,充分体现出了程序的扩展性。新增功能如下:

  • 原生HttpUrlConnction请求和响应
  • 业务层多线程分发处理
  • 移除请求
  • 请求成功类型转换包装处理

(建议阅读此篇文章之前,需理解前两篇文章的讲解,此系列文章是环环相扣,不可缺一,链接如下:)
优雅设计封装基于Okhttp3的网络框架(一):Http网络协议与Okhttp3解析
优雅设计封装基于Okhttp3的网络框架(二):多线程下载功能原理设计 及 简单实现
优雅设计封装基于Okhttp3的网络框架(三):多线程下载功能核心实现 及 线程池、队列机制解析
优雅设计封装基于Okhttp3的网络框架(四):多线程下载添加数据库支持(greenDao)及 进度更新
优雅设计封装基于Okhttp3的网络框架(五):多线程、单例模式优化 及 volatile、构建者模式使用解析
优雅设计封装基于Okhttp3的网络框架(六):HttpHeader接口设计实现 及 Response、Request封装实现


##一. 原生HttpConnection方式请求和响应

以下的封装是为了增强此网络框架的功能扩展性,在除了使用Okhttp方式请求外,在此基础上增加最少的类使网络框架可以支持别的类库请求,例如原生的UrlConnction请求。此时前期所封装的接口扩展性就显得很重要了,所以在上一篇博文中我们定义了大量的接口与抽象类,看似复杂冗余,其实都是在为代码扩展性考虑,而此点中将完成原生请求的封装。

###1. OriginHttpRequest 原生请求实现类

此类与OkHttpRequest 类相似,都继承于BufferHttpRequest 接口,区别在于一个是原生(HttpURLConnection对象)请求实现类,一个是Okhttp(OkhttpClient对象)请求实现类。所以两者大体实现类似,只是底层执行对象、操作API不同(区别主要体现在executeInternal方法实现上)。组成如下:

  • 定义成员变量HttpURLConnection及参数HttpMethodUrl来实现Okhttp的请求过程。
  • 提供构造方法初始化以上3个成员变量。
  • 实现抽象方法getMethod()getUri()。(这两个抽象方法实现简单,只需返回成员变量即可)
  • 实现抽象方法executeInternal(HttpHeader header, byte[] data)
  • header进行处理,循环该参数将所有请求头封装至HttpURLConnection
  • 判断data即传输参数是否为空,写入到HttpURLConnection输出流中。
  • 最后封装完毕,创建原生的响应实现类OriginHttpResponse,将HttpURLConnection传入其构造方法,最后将原生响应实现类OriginHttpResponse返回出去即可。
/**
 * @function OriginHttpRequest 原生请求实现类(继承BufferHttpRequest接口)
 * @author lemon guo
 */

public class OriginHttpRequest extends BufferHttpRequest {

    private HttpURLConnection mConnection;

    private String mUrl;

    private HttpMethod mMethod;

    public OriginHttpRequest(HttpURLConnection connection, HttpMethod method, String url) {
        this.mConnection = connection;
        this.mUrl = url;
        this.mMethod = method;
    }

    @Override
    protected HttpResponse executeInternal(HttpHeader header, byte[] data) throws IOException {

        for (Map.Entry<String, String> entry : header.entrySet()) {
            mConnection.addRequestProperty(entry.getKey(), entry.getValue());
        }
        mConnection.setDoOutput(true);
        mConnection.setDoInput(true);
        mConnection.setRequestMethod(mMethod.name());
        mConnection.connect();
        if (data != null && data.length > 0) {
            OutputStream out = mConnection.getOutputStream();
            out.write(data,0,data.length);
            out.close();
        }
        OriginHttpResponse response = new OriginHttpResponse(mConnection);
        return response;
    }


    @Override
    public HttpMethod getMethod() {
        return mMethod;
    }

    @Override
    public URI getUri() {
        return URI.create(mUrl);
    }
}

###2. 原生工厂类 OriginHttpRequestFactory

OkHttpRequest后续设计实现类似,需要在实现类的基础上对HttpRequest对象进行封装,提供方法供上层接口调用,那具体的网络请求是调用**HttpURLConnection **来完成。

  • 定义成员变量**HttpURLConnection **
  • 为此类提供构造方法初始化成员变量
  • 实现接口中的createHttpRequest方法,即创建OriginHttpRequest 对象并返回。
  • 再提供一些基本方法setConnectionTimeOut设置请求超时时间,setReadTimeOutsetWriteTimeOut设置读写时间。(若有其他需求,此处可继续增加)
/**
 * @function 实现类 OriginHttpRequestFactory(返回HttpRequest对象)
 * @author lemon guo
 */

public class OriginHttpRequestFactory implements HttpRequestFactory {

    private HttpURLConnection mConnection;

    public OriginHttpRequestFactory() {

    }

    public void setReadTimeOut(int readTimeOut) {
        mConnection.setReadTimeout(readTimeOut);
    }

    public void setConnectionTimeOut(int connectionTimeOut) {
        mConnection.setConnectTimeout(connectionTimeOut);
    }

    @Override
    public HttpRequest createHttpRequest(URI uri, HttpMethod method) throws IOException {
        mConnection = (HttpURLConnection) uri.toURL().openConnection();
        return new OriginHttpRequest(mConnection, method, uri.toString());
    }
}

###3. 原生响应实现类 OriginHttpResponse

相对应的,同Okhttp中的响应实现类OkHttpResponse类似,继承抽象类AbstractHttpResponse,实现父类的方法:

  • 实现类内部定义重要成员变量:HttpURLConnection
  • 为实现类提供构造方法,参数为HttpURLConnection
  • 实现类内部待实现的方法具体编码都依赖于HttpURLConnection成员变量。

代码量虽然不少,主要是实现方法,但是编码简单,查看即可理解,代码如下:

/**
 * @function 响应实现类 OriginHttpResponse
 * @author lemon Guo
 */

public class OriginHttpResponse extends AbstractHttpResponse {

    private HttpURLConnection mConnection;

    public OriginHttpResponse(HttpURLConnection connection) {
        this.mConnection = connection;
    }

    @Override
    public HttpStatus getStatus() {
        try {
            return HttpStatus.getValue(mConnection.getResponseCode());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public String getStatusMsg() {
        try {
            return mConnection.getResponseMessage();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public long getContentLength() {
        return mConnection.getContentLength();
    }


    @Override
    protected InputStream getBodyInternal() throws IOException {
        return mConnection.getInputStream();
    }

    @Override
    protected void closeInternal() {
        mConnection.disconnect();

    }

    @Override
    public HttpHeader getHeaders() {

        HttpHeader header = new HttpHeader();
        for (Map.Entry<String, List<String>> entry : mConnection.getHeaderFields().entrySet()) {
            header.set(entry.getKey(), entry.getValue().get(0));
        }
        return header;
    }
}

###4. 供开发者调用类HttpRequestProvider ☆☆☆☆☆

以上原生请求方式封装完毕后,可以发现总共新增了OriginHttpRequest、OriginHttpRequestFactory、OriginHttpResponse3个类而已,这说明此网络框架代码的扩展性还是可行的,在后续想要添加别的请求类库,只要新增此3种代码即可。

在新增完代码后,最后需要在HttpRequestProvider进行判断调用,这是一个供开发者调用的类

    public HttpRequestProvider() {
        if (OKHTTP_REQUEST) {
            mHttpRequestFactory = new OkHttpRequestFactory();
        } else {
            mHttpRequestFactory = new OriginHttpRequestFactory();
        }
    }

在其构造方法中进行判断更改,可以直接改变网络请求所使用的依赖类库!




###二. 业务层多线程分发处理

上一点已经完成此网络框架对原生UrlConnction请求的支持,但是还有一个重点没有完成------多线程处理请求,大家都知道在主线程进行网络请求会出现异常,此点就是为了完成异步请求。

###1. 队列中的请求对象 MultiThreadRequest

在请求队列中需要定义业务层的相关接口,用于上层开发人员调用,上层只关注请求成功success还是失败fail,对于底层具体试下并不关心。

在代码实现之前再次强调此网络框架中“队列”的概念,因为在处理多线程请求时,不可能无限制的创建多个线程来处理,而一个队列中存储的是一个Request对象,存储着请求Url、请求方式、数据等相关信息,再提供对应的getset方法。

/**
 * @funtion 业务层多线程分发处理,队列中的Request对象MultiThreadRequest
 * @author lemon Guo
 */

public class MultiThreadRequest {

    private String mUrl;

    private HttpMethod mMethod;

    private byte[] mData;

    private MultiThreadResponse mResponse;

    private String mContentType;

    public String getUrl() {
        return mUrl;
    }

    public void setUrl(String url) {
        mUrl = url;
    }

    //相对应的get/set方法
    ......
}

###2. 响应对象 MultiThreadRequest

根据上一点所讲,上层只关心请求结果成功还是失败,所以响应接口只有以下两个方法。

/**
 * @funtion 响应抽象类MultiThreadResponse
 * @author lemon Guo
 */

public abstract class MultiThreadResponse<T> {

    public abstract void success(MultiThreadRequest request, T data);

    public abstract void fail(int errorCode, String errorMsg);
}

###3. 工作站WorkStation

接下来需要一个类来处理多线程中的请求,属于服务的一种,用于处理多线程的控制和队列的管理。

  • 内部维护一个线程池成员变量,这里为了能够快速响应多个线程的同时请求数据,将线程数量最大值设置为Integer.MAX_VALUE再引入两个队列,一个队列存储着请求request,另一个存储着cache,即待执行的请求request队列(考虑到处理线程数量超过最大限制时)。
  • 提供构造方法初始化成员变量HttpRequestProvider,经过前期封装后,获取请求request对象由专门供上层调用的类HttpRequestProvider完成。
  • 提供add 方法将请求任务添加到队列中。注意在这里需要做一个开启线程最大数判断,例如最多同时开启60个线程处理请求:
  • 若超过则将request添加cache队列中,等待执行。
  • 若未超过,则通过HttpRequestProvider获取请求request对象,最后由线程池执行。注意,既然是由线程池执行,这里还需要一个Runnable,后续编写。
    -提供finish 方法,在线程池执行Runnable时,即请求结束会调用此方法,将完成的Request移除队列。
/**
 * @funtion 业务层多线程分发处理:用于处理多线程的控制和队列的管理 MultiThreadRequest
 * @author lemon Guo
 */

public class WorkStation {

    private static final int MAX_REQUEST_SIZE = 60;

    private static final ThreadPoolExecutor sThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new ThreadFactory() {

        private AtomicInteger index = new AtomicInteger();

        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r);
            thread.setName("http thread name is " + index.getAndIncrement());
            return thread;
        }
    });


    private Deque<MultiThreadRequest> mRunning = new ArrayDeque<>();

    private Deque<MultiThreadRequest> mCache = new ArrayDeque<>();

    private HttpRequestProvider mRequestProvider;

    public WorkStation() {
        mRequestProvider = new HttpRequestProvider();
    }

    public void add(MultiThreadRequest request) {

        if (mRunning.size() > MAX_REQUEST_SIZE) {
            mCache.add(request);
        } else {
            doHttpRequest(request);
        }
    }


    public void doHttpRequest(MultiThreadRequest request) {
        HttpRequest httpRequest = null;
        try {
            httpRequest = mRequestProvider.getHttpRequest(URI.create(request.getUrl()), request.getMethod());
        } catch (IOException e) {
            e.printStackTrace();
        }
        sThreadPool.execute(new HttpRunnable(httpRequest, request, this));
    }


    public void finish(MultiThreadRequest request) {
        mRunning.remove(request);
        if (mRunning.size() > MAX_REQUEST_SIZE) {
            return;
        }

        if (mCache.size() == 0) {
            return;
        }

        Iterator<MultiThreadRequest> iterator = mCache.iterator();

        while (iterator.hasNext()) {
            MultiThreadRequest next = iterator.next();
            mRunning.add(next);
            iterator.remove();
            doHttpRequest(next);
        }
    }

}

###4. HttpRunnable

在专门用于处理多线程的控制和队列的管理类WorkStation中维护了一个线程池,用来执行网络请求,所以需要对应的Runnable来执行下载任务。

  • 成员变量有基本Http封装的接口HttpRequest、多线程请求的接口MultiThreadRequest、管理多线程和队列类WorkStation
  • WorkStation主要用于run方法执行完后调用此对象中的方法,来移除队列中已执行完的request。
  • HttpRequest中的重要请求数据获取并封装到MultiThreadRequest中来执行请求。
/**
 * @funtion 业务层多线程分发处理:用于处理下载任务
 * @author lemon Guo
 */

public class HttpRunnable implements Runnable {

    private HttpRequest mHttpRequest;

    private MultiThreadRequest mRequest;

    private WorkStation mWorkStation;

    public HttpRunnable(HttpRequest httpRequest, MultiThreadRequest request, WorkStation workStation) {
        this.mHttpRequest = httpRequest;
        this.mRequest = request;
        this.mWorkStation = workStation;

    }

    @Override
    public void run() {
        try {
            mHttpRequest.getBody().write(mRequest.getData());
            HttpResponse response = mHttpRequest.execute();
            String contentType = response.getHeaders().getContentType();
            mRequest.setContentType(contentType);
            if (response.getStatus().isSuccess()) {
                if (mRequest.getResponse() != null) {
                    mRequest.getResponse().success(mRequest, new String(getData(response)));
                }

            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            mWorkStation.finish(mRequest);
        }


    }

    public byte[] getData(HttpResponse response) {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream((int) response.getContentLength());
        int len;
        byte[] data = new byte[512];
        try {
            while ((len = response.getBody().read(data)) != -1) {
                outputStream.write(data, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return outputStream.toByteArray();
    }
}

###5. HttpApiProvider上层调用API

以上代码基本完成,但是为了方便上层调用,需要在此基础上封装一个接口的API,类似之前专门提供Request对象的HttpRequestProvider,此类名为HttpApiProvider

public class HttpApiProvider {
    
    private static final String ENCODING = "utf-8";

    private static WorkStation sWorkStation = new WorkStation();

    /*
    *   对请求参数进行编码处理
    * */
    public static byte[] encodeParam(Map<String, String> value) {
        if (value == null || value.size() == 0) {
            return null;
        }
        StringBuffer buffer = new StringBuffer();
        int count = 0;
        try {
            for (Map.Entry<String, String> entry : value.entrySet()) {
            buffer.append(URLEncoder.encode(entry.getKey(), ENCODING)).append("=").
                        append(URLEncoder.encode(entry.getValue(), ENCODING));
                if (count != value.size() - 1) {
                    buffer.append("&");
                }
                count++;
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return buffer.toString().getBytes();
    }

    public static void helloWorld(String ul, Map<String, String> value, MultiThreadResponse response) {
        MultiThreadRequest request = new MultiThreadRequest();
        request.setUrl(ul);
        request.setMethod(HttpMethod.POST);
        request.setData(encodeParam(value));
        request.setResponse(response);
        sWorkStation.add(request);
    }
}

###6. 测试

以上封装功能已经完成,接下来以一个POST请求来测试这一系列HTTP封装请求,代码如下:

Map<String, String> map = new HashMap<>();
        map.put("username", "nate");
        map.put("userage", "12");
        MoocApiProvider.helloWorld("http:....../web/HelloServlet", map, new MoocResponse<Person>() {

            @Override
            public void success(MoocRequest request, Person data) {
                Logger.debug("nate", data.toString());
            }

            @Override
            public void fail(int errorCode, String errorMsg) {
            }
        });

结果显示:

这里写图片描述

可以看出日志打印,代表POST请求成功,以上代码封装无误。




##三. 数据类型自动转换

现在还剩下一个需求需要完善,即响应请求到的数据更加简单直接,是对象类型而并非xml、json数据,所以这涉及到了数据类型转换,为了整个程序的扩展性考虑,首要的还是来封装接口。

###1. 数据类型转换接口Convert

  • parse方法中进行类型转换。
  • isCanParse方法判断此数据是否可以进行转换
/**
 * @funtion 数据类型转换接口
 * @author lemon Guo
 */

public interface Convert {

    Object parse(HttpResponse response, Type type) throws IOException;

    boolean isCanParse(String contentType);

    Object parse(String content, Type type) throws IOException;
}

##2. 转换实现类JsonConvert

如上在实现接口之后,可以定义不同类型转换的实现类来实现此接口,此项目中我只定义了JsonConvert,用来进行Json数据转换,相应的,还可以定义XmlConvert实现类等等。

这里方法的具体实现都是借助开源库Gson来解析数据,比较常用的方法,实现不难,代码如下:

/**
 * @funtion 转换实现类JsonConvert
 * @author lemon Guo
 */

public class JsonConvert implements Convert {

    private Gson gson = new Gson();

    private static final String CONTENT_TYPE = "application/json;charset=UTF-8";

    @Override
    public Object parse(HttpResponse response, Type type) throws IOException {

        Reader reader = new InputStreamReader(response.getBody());
        return gson.fromJson(reader, type);

    }

    @Override
    public boolean isCanParse(String contentType) {
        return CONTENT_TYPE.equals(contentType);
    }

    @Override
    public Object parse(String content, Type type) throws IOException {
        return gson.fromJson(content, type);
    }
}

###3. 解析Response数据WrapperResponse

以上封装好类型转换后,需要将此结合到网络请求中,在对MultiThreadResponse做一个上层封装,相当于一层过滤,将获取到的响应数据通过类型转换后再返回。

WrapperResponse 继承于MultiThreadResponse实现其抽象方法success,在成功响应方法中对数据进行解析类型转换操作。

/**
 * @funtion WrapperResponse类型转换封装 Response
 * @author lemon Guo
 */

public class WrapperResponse extends MultiThreadResponse<String> {

    private MultiThreadResponse mMoocResponse;

    private List<Convert> mConvert;

    public WrapperResponse(MultiThreadResponse moocResponse, List<Convert> converts) {
        this.mMoocResponse = moocResponse;
        this.mConvert = converts;
    }

    @Override
    public void success(MultiThreadRequest request, String data) {

        for (Convert convert : mConvert) {
            if (convert.isCanParse(request.getContentType())) {
                try {
                    Object object = convert.parse(data, getType());
                    mMoocResponse.success(request, object);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                return;
            }
        }
    }

    public Type getType() {
        Type type = mMoocResponse.getClass().getGenericSuperclass();
        Type[] paramType = ((ParameterizedType) type).getActualTypeArguments();
        return paramType[0];
    }

    @Override
    public void fail(int errorCode, String errorMsg) {
    }
}

###4. 修改API调用类HttpApiProvider

    public static void helloWorld(String ul, Map<String, String> value, MultiThreadResponse response) {
        MultiThreadRequest request = new MultiThreadRequest();
        request.setUrl(ul);
        request.setMethod(HttpMethod.POST);
        request.setData(encodeParam(value));
        request.setResponse(response);
        sWorkStation.add(request);
    }

如上,这是未解析响应数据时Api暴露网络请求接口中的实现,其中使用的响应数据是MultiThreadResponse ,在我们封装好可自动解析的数据后,修改使用WrapperResponse ,代码如下:

    public static void helloWorld(String ul, Map<String, String> value, MultiThreadResponse response) {
        MultiThreadRequest request = new MultiThreadRequest();
        WrapperResponse wrapperResponse = new WrapperResponse(response, sConverts);
        request.setUrl(ul);
        request.setMethod(HttpMethod.POST);
        request.setData(encodeParam(value));
//        request.setResponse(response);
        request.setResponse(wrapperResponse);
        sWorkStation.add(request);
    }

这里写图片描述

再次测试,结果正确,以上类型转换封装无误,此网络框架封装完成。




##四. 网络框架总结

此系列文章旨于:基于okhttp3原始框架来设计封装一个满足业务需求、扩展性强、耦合度低的网络框架。具体框架功能为:

  • 封装基本的网络请求
  • 扩展其对数据库的支持
  • 对多文件上传、多线程文件下载的支持
  • 对Json数据解析等功能的支持

###1.整体代码

这里写图片描述

以上是这个网络框架EasyOkhttp封装的全部代码,看似代码量并不少,但是其中定义了大量的接口和抽象类,注重扩展性和解耦性,所以读者可在我封装的基础上继续拓展,根据自身需求添加代码。


###2. 架构设计

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ARi1jQDK-1585049230060)(https://img-blog.csdn.net/20170622091717809?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvSVRlcm1lbmc=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)]

如上图所示,此框架可以分为三个层次

  • 第一层:便于框架扩展,第一层即最底层是Http InterfaceAbstact,例如Http中的Headers、Request、Response等通用的原生接口。

  • 第二层:有了第一层请求接口定义,便于第二层对接口的实现,此框架采用两种方式对接口进行实现,分别是Okhttp和原生的HttpURLConnection。通过这两个相关的API去实现整个Http请求和响应的过程,若还想要做相应的拓展,采用别的第三方http请求库,在此处可增加。(已经预先在第一层定义了足够多的接口实现网络请求的回调,第一层可无需修改)对于整个上层业务来说,无需直接接触到底层Okhttp、HttpURLConnection具体实现,所以提供二次封装的 HttpProvider ,暴露接口给上层调用。(具体底层是调用Http还是HttpURLConnection取决于配置,首先判断Okhttp依赖在项目中是否存在,若有则主要采用Okhttp来进行网络请求,否则采用HttpURLConnection)

  • 第三层:即最上层由 WorkstationConvert组成。Workstation 的中文意思是工作站,用来处理一些线程的调度分发和任务的队列,之所以将它设计在最上层,因为整个多线程、队列机制是与业务层紧密相关的。Convert是为上层开发者提供了更好的接口封装,用于接口返回类型转换、数据解析,例如json、xml等。


###3. 文件多线程下载和Http设计封装

整个系列的文章可以分成两个部分,即前5篇博文在重点讲解多线程下载有关设计与编码实现,而后两篇博文则是重点讲解Http请求、响应接口封装,两部分的思维导图如下,讲解顺序也是按此进行:

文件多线程下载思维导图:

这里写图片描述


Http设计封装思维导图:

这里写图片描述


###4. 小结

源码

此系列所完成的网络框架封装编码工作暂告一段落,有些功能可能完成的不是很全面,编写此系列过程中收益最大的应当是整体规划封装思想。多线程下载模块多涉及的是Java线程、Http字段有关知识,而后半部分------Http网络框架实现过程中充分体现出了接口、抽象类、实现类这之间的封装思想,而大量的接口和抽象类也体现出整个程序的扩展性、解耦性,这两点从一开始封装网络框架就被视为重点,同时也是我们需要学习的。

此框架可能只算一个简单封装demo,些许部分完成的不是很好,但是这整个封装过程便是精华所在,从一开始的框架架构设计,到功能设计实现、编码优化、bug程序调试等等。这不仅仅只是编码,只涉及到Java单一的内容,同时融合了 Okhttp相关内容、Http协议、接口设计、代码隔离、架构设计、解决思路等综合考虑,此乃重中之重。

编程,或不只是编程。

共勉~



若有错误,虚心指教~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值