Java Nio(四)Java Nio实现HTTP请求

HTTP相比于HTTPS来说要简单的多,完整代码在github上 https://github.com/cxsummer/net-nio,我先说原理。

在文章开始我先抛出一个问题。HTTP的GET和POS请求方法区别在哪呢?

答案是除了名字没区别。因为我们是按照HTTP规范来发送的数据,而 HTTP 规范并未规定说 GET 就不能发送 body 数据,GET也能在报文体负载数据,只看服务器那边是不是给解析body并暴露出来罢了,如果服务器解析来GET报文体的内容,那么GET跟POST有什么区别呢,换句话说,POST请求就不能在url上加querystring吗?当然不是。网上大部分说GET和POST区别主要是按照http大众的约定来说的。但从功能上来说给GET加上request body,给POST带上url参数,技术上是完全行的通的。

HTTP协议的四个步骤:

(1)客户端与服务器建立连接。浏览器首先向Web服务器发出建立连接请求,建立TCP连接,打开一个称为socket的虚拟文件,此文件的建立标志着连接建立成功。

(2)客户端向服务器提出请求。客户端发出数据请求包,通过socket向Web服务器提交请求,在此浏览器是将请求的对象的统一资源定位符传给服务器,HTTP的请求一般是GET或POST命令(POST用于FORM参数的传递)。GET命令的格式为:GET路径/文件名HTTP/1.0

(3)服务器接受请求,并根据请求返回相应的文件作为回应,每个服务器上都运行着一个侦听50端口的进程等待来自客户端HTTP请求。当服务器接收到请求命令后根据命令作出响应,将HTTP头和酷虎段所请求的URL数据返回给客户端。

(4)客户端与服务器关闭连接。在数据返回完成之后服务器立即发出关闭这个TCP连接的命令,客户端响应这个命令关闭连接,一次连接完成了。

因为一次请求响应就断开了连接,所以http请求是无状态无连接的,因为第二次请求来了我并不知道和上次请求是用一个客户端,那怎样才让他有状态呢,那就是session,这个比较简单,相信大家都会我就不介绍了。http请求主要就是通过socket也就是tcp传输http格式的数据,并获得响应数据,之后断开连接即可。所以说只要了解了http格式就能用socket做http客户端了。

一、URI结构

HTTP使用统一资源标识符(URI)来传输数据和建立连接。URL(统一资源定位符)是一种特殊种类的URI,包含了用于查找的资源的足够的信息,我们一般常用的就是URL,而一个完整的URL包含下面几部分:

http://www.fishbay.cn:80/mix/76.html?name=kelvin&password=123456#first

1.协议部分

URL的协议部分为http:,表示网页用的是HTTP协议,后面的//为分隔符

2.域名部分

域名是www.fishbay.cn,发送请求时,需要向DNS服务器解析IP。如果为了优化请求,可以直接用IP作为域名部分使用

3.端口部分

域名后面的80表示端口,和域名之间用:分隔,端口不是一个URL的必须的部分。如果端口是80,也可以省略不写

4.虚拟目录部分

从域名的第一个/开始到最后一个/为止,是虚拟目录的部分。其中,虚拟目录也不是URL必须的部分,本例中的虚拟目录是/mix/

5.文件名部分

从域名最后一个/开始到?为止,是文件名部分;如果没有?,则是从域名最后一个/开始到#为止,是文件名部分;如果没有?#,那么就从域名的最后一个/从开始到结束,都是文件名部分。本例中的文件名是76.html,文件名也不是一个URL的必须部分,如果没有文件名,则使用默认文件名

6.锚部分

#开始到最后,都是锚部分。本部分的锚部分是first,锚也不是一个URL必须的部分

7.参数部分

?开始到#为止之间的部分是参数部分,又称为搜索部分、查询部分。本例中的参数是name=kelvin&password=123456,如果有多个参数,各个参数之间用&作为分隔符。

当然url是有大小限制的,一般是2K,具体还是看浏览器和服务器的限制情况而定。

二、Request

HTTP的请求包括:请求行(request line)、请求头部(header)、空行 和 请求数据 四个部分组成。

Http请求消息结构

抓包的request结构如下:

GET /mix/76.html?name=kelvin&password=123456 HTTP/1.1
Host: www.fishbay.cn
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6

1.请求行

GET为请求类型,/mix/76.html?name=kelvin&password=123456为要访问的资源,HTTP/1.1是协议版本

2.请求头部

从第二行起为请求头部,Host指出请求的目的地(主机域名);User-Agent是客户端的信息,它是检测浏览器类型的重要信息,由浏览器定义,并且在每个请求中自动发送。

3.空行

请求头后面必须有一个空行

4.请求数据

请求的数据也叫请求体,可以添加任意的其它数据。这个例子的请求体为空。

Response

一般情况下,服务器收到客户端的请求后,就会有一个HTTP的响应消息,HTTP响应也由4部分组成,分别是:状态行、响应头、空行 和 响应体。

http响应消息格式

抓包的数据如下:

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 20 Feb 2017 09:13:59 GMT
Content-Type: text/plain;charset=UTF-8
Vary: Accept-Encoding
Cache-Control: no-store
Pragrma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache
Content-Encoding: gzip
Transfer-Encoding: chunked
Proxy-Connection: Keep-alive

{"code":200,"notice":0,"follow":0,"forward":0,"msg":0,"comment":0,"pushMsg":null,"friend":{"snsCount":0,"count":0,"celebrityCount":0},"lastPrivateMsg":null,"event":0,"newProgramCount":0,"createDJRadioCount":0,"newTheme":true}

1.状态行

状态行由协议版本号、状态码、状态消息组成

2.响应头

响应头是客户端可以使用的一些信息,如:Date(生成响应的日期)、Content-Type(MIME类型及编码格式)、Connection(默认是长连接)等等

3.空行

响应头和响应体之间必须有一个空行

4.响应体

响应正文,本例中是键值对信息

三、状态码

HTTP协议的状态码由3位数字组成,第一个数字定义了响应的类别,共有5中类别:

1.1xx: 指示信息--表示请求已接收,继续处理

2.2xx: 成功--表示请求已被成功接收、理解、接受

3.3xx: 重定向--要完成请求必须进行更进一步的操作

4.4xx: 客户端错误--请求有语法错误或请求无法实现

5.5xx: 服务器端错误--服务器未能实现合法的请求

其中,常用的状态码如下:

200 OK                        //客户端请求成功
400 Bad Request               //客户端请求有语法错误,不能被服务器所理解
401 Unauthorized              //请求未经授权,这个状态代码必须和WWW-Authenticate报头域一起使用 
403 Forbidden                 //服务器收到请求,但是拒绝提供服务
404 Not Found                 //请求资源不存在,eg:输入了错误的URL
500 Internal Server Error     //服务器发生不可预期的错误
503 Server Unavailable        //服务器当前不能处理客户端的请求,一段时间后可能恢复正常

如需了解更多的状态码,请参考这个网址:HTTP状态码

四、请求方法

HTTP定义了多种请求方法,来满足各种需求。HTTP/1.0定义了三种请求方法:GETPOST 和 HEAD,到了HTTP/1.1,新增了五种请求方法:OPTIONSPUTDELETETRACE 和 CONNECT。各个请求方法的具体功能如下:

GET         请求指定的页面信息,并返回实体主体。
HEAD        类似于get请求,只不过返回的响应中没有具体的内容,用于获取报头
POST        向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。
PUT         从客户端向服务器传送的数据取代指定的文档的内容。
DELETE      请求服务器删除指定的页面。
CONNECT     HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。
OPTIONS     允许客户端查看服务器的性能。
TRACE       回显服务器收到的请求,主要用于测试或诊断。

五、TCP为什么三次握手和四次挥手

三次握手是有两个原因

  1. 防止重复连接,如果两次握手,那么就服务器而言,请求到了就建立连接,如果客户端第一次请求因为网络原因没有还没到达服务器,就进行重发了,那么第一次的请求在某刻到达了服务器,服务器就会建立连接等待客服端数据,发给客服端第二次握手,但是因为两次握手,接收方不能回应服务器,只能选择接受请求或者拒绝接受请求,因为是过期连接客服端就拒绝这次请求,但是服务器并不知道还在等待数据。这便导致服务器白白耗费了很多资源。如果TCP 是三次握手的话,那么客户端在接收到服务器端 SEQ+1 的消息之后,就可以判断当前的连接是否为历史连接,如果判断为历史连接的话就会发送终止报文(RST)给服务器端终止连接;如果判断当前连接不是历史连接的话就会发送指令给服务器端来建立连接。并且如果第三次握手也是因为网络原因很久之后才到服务器,那么因为服务器知道他是第三次握手,会知道他和第一次握手的时间差,如果太长的话就直接拒绝接受请求了。
  2. 保证双方都能知道对方的读写都是正常的。客户端第一次握手到服务器,服务器收到后便会知道客户端的写和服务器的读是正常的,服务器第二次握手到客户端,客户端就知道服务器的读写和客服端的读写都是正常的,第三次握手到服务器,服务器就知道客户端的读和服务器的写是正常的。

四次挥手

  • 仅仅为了是确保双方的读和写能正常关闭,确保数据的完整性,和提早关闭读写的维护以节省资源。比如,客服端发送完数据那么会第一次挥手告诉服务器我要关了写,服务器收到会发送第二次挥手告诉客服端可以关,并且我关了读,这时客户端就可以关了写。等服务器将响应完全发送完,会发送第三次挥手告诉客户端我要关了写。客户端收到会关了读,并发送第四次挥手告诉服务器收到,并且我关了读,这时服务器就可以关了写。双方读和写都关闭了,至此连接就可以断开了。

Java NIO发送HTTP请求

按照上面说的HTTP发送数据格式来说,发送HTTP请求,我们只需将数据组装成HTTP格式的字节数组,再通过socket发送过去即可。

请求方法(GET) 请求path(/mix/76.html?name=kelvin&password=123456) 协议(HTTP/1.1)\r\n
请求头key: 请求头value\r\n
请求头key: 请求头value\r\n
......
\r\n报文体

ip/域名和端口是通过socket来进行绑定和连接。废话不多说先上段代码

public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("caibaojian.com", 80));
        socketChannel.configureBlocking(false);
        StringBuilder stringBuilder = new StringBuilder("GET /t/json/ HTTP/1.1 \r\n");
        stringBuilder.append("Host: caibaojian.com\r\n");
        stringBuilder.append("\r\n");
        ByteBuffer byteBuffer = ByteBuffer.wrap(stringBuilder.toString().getBytes());
        while (byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }
        ByteBuffer respByteBuffer = ByteBuffer.allocate(1024 * 10);
        while (socketChannel.read(respByteBuffer) > -1) {
            respByteBuffer.flip();
            byte[] b = new byte[respByteBuffer.limit()];
            respByteBuffer.get(b);
            System.out.print(new String(b));
            respByteBuffer.clear();
        }
    }

这样你会发现打印出上述响应格式的字符串

这就是一个简单的http请求了,当然这还不完整,毕竟我们是通过socketChannel.read(respByteBuffer) = -1来判断是否读完,但事实上返回-1的情况是对方主动断开或超时断开,这样需要的时间太长,有时候还会出现Connection reset by peer的报错,所以我们应该自己判断是否读完,并主动关闭连接。那么怎么判断是否读完呢,答案在响应头里面,当然我们发送报文体的时候也要加上Content-Length或者Transfer-Encoding,报文体的格式也要依此改变,这样服务端才能解析,不然服务器会认为没有报文体(不知道在哪结束)不予解析。我现在知道的有两种:

  • Content-Length。报文体的长度就是Content-Length的值即从head(第一个\r\n\r\n)后的字节数
  • Transfer-Encoding等于chunked。报文体是分段返回的,从head(第一个\r\n\r\n)后开始,每一段的开始是  当前段长度(16进制)\r\n 结束是 \r\n 中间的字节就是段内容,其字节数等于当前段长度,最后一个分段是长度值为0,读到这就代表读取完毕了。如下图所示,红框是16进制,是下面绿框的长度,一个红框和一个绿框是一个组合,直到红框是0.

解析出响应头

既然响应体依赖于响应头,那么我们先解析出响应头。由http格式来看,响应头是从第一个\r\n到第一个\r\n\r\n,那么我们改下代码,因为不知道响应头的大小,所以我们需要对数组进行动态扩容,话不多说上代码。

public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("caibaojian.com", 80));
        socketChannel.configureBlocking(false);
        StringBuilder stringBuilder = new StringBuilder("GET /t/json/ HTTP/1.1 \r\n");
        stringBuilder.append("Host: caibaojian.com\r\n");
        stringBuilder.append("\r\n");
        ByteBuffer byteBuffer = ByteBuffer.wrap(stringBuilder.toString().getBytes());
        while (byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }
        int headerIndex = 0;
        byte[] originHeader = new byte[1024];
        LinkedHashMap<String, List<String>> head = null;
        ByteBuffer respByteBuffer = ByteBuffer.allocate(1024 * 10);
        while (socketChannel.read(respByteBuffer) > -1) {
            for (int i = 0; i < respByteBuffer.position(); i++) {
                byte b = respByteBuffer.get(i);
                originHeader[headerIndex++] = b;
                if (originHeader.length == headerIndex) {
                    originHeader = byteExpansion(originHeader, 1024);
                }
                if (originHeader[headerIndex - 1] == '\n' && originHeader[headerIndex - 2] == '\r' && originHeader[headerIndex - 3] == '\n' && originHeader[headerIndex - 4] == '\r') {
                    String headerStr = new String(originHeader);
                    String[] headerList = headerStr.split("\r\n");
                    head = Arrays.stream(headerList).skip(1).filter(h -> h.contains(":")).collect(Collectors.groupingBy(h -> h.split(":")[0].trim(), LinkedHashMap::new, Collectors.mapping(h -> h.split(":")[1].trim(), Collectors.toList())));
                    return;
                }
            }
            respByteBuffer.clear();
        }
    }

    /**
     * 扩容
     */
    public static byte[] byteExpansion(byte[] origin, int num) {
        return Optional.ofNullable(origin).map(o -> {
            byte[] temp = new byte[o.length + num];
            IntStream.range(0, o.length).forEach(i -> temp[i] = o[i]);
            return temp;
        }).orElseGet(() -> new byte[num]);
    }

这个head对象就是我们解析出来的请求头

Content-Length的报文体解析

public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("caibaojian.com", 80));
        socketChannel.configureBlocking(false);
        StringBuilder stringBuilder = new StringBuilder("GET /t/json/ HTTP/1.1 \r\n");
        stringBuilder.append("Host: caibaojian.com\r\n");
        stringBuilder.append("\r\n");
        ByteBuffer byteBuffer = ByteBuffer.wrap(stringBuilder.toString().getBytes());
        while (byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }
        int num;
        byte[] body = null;
        int bodyIndex = 0;
        int headerIndex = 0;
        Integer contentLength = null;
        byte[] originHeader = new byte[1024];
        LinkedHashMap<String, List<String>> head = null;
        ByteBuffer respByteBuffer = ByteBuffer.allocate(1024 * 10);
        while ((num = socketChannel.read(respByteBuffer)) > -2) {
            for (int i = 0; i < respByteBuffer.position(); i++) {
                byte b = respByteBuffer.get(i);
                if (head == null) {
                    originHeader[headerIndex++] = b;
                    if (originHeader.length == headerIndex) {
                        originHeader = byteExpansion(originHeader, 1024);
                    }
                    if (originHeader[headerIndex - 1] == '\n' && originHeader[headerIndex - 2] == '\r' && originHeader[headerIndex - 3] == '\n' && originHeader[headerIndex - 4] == '\r') {
                        String headerStr = new String(originHeader);
                        String[] headerList = headerStr.split("\r\n");
                        head = Arrays.stream(headerList).skip(1).filter(h -> h.contains(":")).collect(Collectors.groupingBy(h -> h.split(":")[0].trim(), LinkedHashMap::new, Collectors.mapping(h -> h.split(":")[1].trim(), Collectors.toList())));
                        contentLength = Optional.ofNullable(head.get("Content-Length")).map(c -> Integer.parseInt(c.get(0))).orElse(-1);
                    }
                } else {
                    Integer finalContentLength = contentLength;
                    body = Optional.ofNullable(body).orElseGet(() -> new byte[finalContentLength]);
                    body[bodyIndex++] = b;
                    if (bodyIndex == contentLength) {
                        num = -2;
                        break;
                    }
                }
            }
            if (num < 0) {
                socketChannel.close();
                System.out.println(new String(body));
                return;
            }
            respByteBuffer.clear();
        }
    }

Transfer-Encoding等于chunked的报文体解析

public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("www.tietuku.com", 80));
        socketChannel.configureBlocking(false);
        StringBuilder stringBuilder = new StringBuilder("GET /album/1735537-2 HTTP/1.1 \r\n");
        stringBuilder.append("Host: www.tietuku.com\r\n");
        stringBuilder.append("\r\n");
        ByteBuffer byteBuffer = ByteBuffer.wrap(stringBuilder.toString().getBytes());
        while (byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }
        int num;
        byte[] body = null;
        int bodyIndex = 0;
        int headerIndex = 0;
        Integer chunkedNum = null;
        int chunkedInitIndex = 0;
        String chunked = "";
        byte[] originHeader = new byte[1024];
        LinkedHashMap<String, List<String>> head = null;
        ByteBuffer respByteBuffer = ByteBuffer.allocate(1024 * 10);
        while ((num = socketChannel.read(respByteBuffer)) > -2) {
            for (int i = 0; i < respByteBuffer.position(); i++) {
                byte b = respByteBuffer.get(i);
                if (head == null) {
                    originHeader[headerIndex++] = b;
                    if (originHeader.length == headerIndex) {
                        originHeader = byteExpansion(originHeader, 1024);
                    }
                    if (originHeader[headerIndex - 1] == '\n' && originHeader[headerIndex - 2] == '\r' && originHeader[headerIndex - 3] == '\n' && originHeader[headerIndex - 4] == '\r') {
                        String headerStr = new String(originHeader);
                        String[] headerList = headerStr.split("\r\n");
                        head = Arrays.stream(headerList).skip(1).filter(h -> h.contains(":")).collect(Collectors.groupingBy(h -> h.split(":")[0].trim(), LinkedHashMap::new, Collectors.mapping(h -> h.split(":")[1].trim(), Collectors.toList())));
                    }
                } else {
                    if (chunked.endsWith("\r\n")) {
                        if (chunkedNum == 0) {
                            num = -2;
                            break;
                        }
                        body[bodyIndex++] = b;
                        if (bodyIndex - chunkedInitIndex == chunkedNum) {
                            chunked = "";
                        }
                    } else if (!chunked.equals("") || (b != '\r' && b != '\n')) {
                        if (b == '\r') {
                            chunkedNum = Integer.parseInt(chunked, 16);
                            body = byteExpansion(body, chunkedNum);
                            chunkedInitIndex = bodyIndex;
                        }
                        chunked = chunked + new String(new byte[]{b});
                    }
                }
            }
            if (num < 0) {
                socketChannel.close();
                System.out.println(new String(body));
                return;
            }
            respByteBuffer.clear();
        }
    }

压缩报文体解压

当响应头Content-Encoding为gzip,那么就代表响应体是压缩过的,需要解压。或者我们在请求头添加Accept-Encoding为gzip,告诉服务器我们支持解压数据,请求服务器返回压缩数据。当然压缩算法很多,这里我只是拿gzip举例因为它用的比较广泛。

解压方法:

public static byte[] uncompress(byte[] bytes) {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ByteArrayInputStream in = new ByteArrayInputStream(bytes);
        try {
            GZIPInputStream gzip = new GZIPInputStream(in);
            byte[] buffer = new byte[256];
            int n;
            while ((n = gzip.read(buffer)) >= 0) {
                out.write(buffer, 0, n);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return out.toByteArray();
    }

只需要在全部读完后执行上述解压方法即可,示例

if (num < 0) {
    socketChannel.close();
    System.out.println(new String(uncompress(body)));
    return;
}

由此HTTP的请求就算是写完了,但是上面说的都是非阻塞IO实现的,前面的文章也说过了非阻塞IO的弊端。所以我们要将其改为IO多路复用,真正利用java NIO的优势。

IO多路复用发送HTTP请求

io多路复用就是让一个或者少量的线程来监听多个连接,在实现上就是用Selector。注意多路复用调用业务代码是用的回调,是异步的,如果我们收发数据的时候出错(没执行回调函数之前)这时候报错对于用户来说是未知的,毕竟是异步并且没执行到回调函数,导致用户不知道这次请求的情况,所以设计的时候要注意把框架异常通知到用户。完整的代码篇幅比较长,所以在这里我只写实现原理,完整代码可去github上查看。

  1. 初始化Selector对象
  2. 使用selector.selectNow()不断的遍历到可用的连接
  3. 提交请求注册到Selector上
  4. 设置连接为不让selector监听,即selectionKey.interestOps(0)。因为具体处理方法的线程和selector的线程不是用一个,避免再次监听到此连接可用,导致重复处理此连接。
  5. 将用的连接放到线程池中处理,处理完成后或报错,关闭连接。
  6. 如果socketChannel.write返回0,并且没有写完,便不再继续循环等待,而是将连接设置为写监听,即selectionKey.interestOps(SelectionKey.OP_WRITE),结束方法,线程可以去做别的事情,等Selector判断有可写空间后再提交线程池处理,这样便避免了等待时间,读同样如此。注意,因为有可能一个请求要写/读好几次,每次都是重新提交线程池处理,所以每次都不是用一个线程,我们要记录到上一次写/读的记录,每次操作要在上一次的记录处继续写/读。
关闭socketChannel,当再次执行selector.select()时,会将此socketChannel从selector中移除,所以读取完毕后需要执行socketChannel.close()。当调用SelectionKey的cancel()方法或关闭与SelectionKey关联的Channel或与SelectionKey关联的Selector被关闭。SelectionKey对象会失效,意味着Selector再也不会监控与它相关的事件。selector.selectedKeys就是获取可用连接,处理完selector.selectedKeys()后,要把它从selectedKeys中删除,因为selector不会主动删除,如果不删除的话,下次遍历的时候它还会在。selector.keys方法是获取所以注册到Selector上的连接。

为什么不用selector.select方法

因为select方法执行的是lockAndDoSelect方法,里面是用的synchronized锁住的SelectorImpl类的publicKeys变量 那么selector.select就不能阻塞,因为一旦selector阻塞,而根据锁的可重入性,当前线程可以直接执行但是别的线程需要等待锁的释放select先执行,如果selector.select查询不到可用的连接,那么就不会释放锁,而只有向selector注册连接才会有可用连接即socketChannel.register(selector),这个方法也需要获取publicKeys对象锁,又因为是其他线程,这样就会造成死锁。 所以要selector.selectNow方法,查询到或者查不到都会释放锁,不会在获取到锁后阻塞。

这里只讲了客服端发送请求,和接受响应。其实服务端和客户端的解析和发送方式一模一样,我就不重复讲了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值