[NodeJS]创建HTTP、HTTPS服务器与客户端区别

http://blog.csdn.net/ligang2585116/article/details/72827781

超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的WWW文件都必须遵守这个标准。设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法。其属于下图七层网路协议的“应用层”

七层网络协议

HTTP服务器

创建HTTP服务器

创建服务

方式一:回调方式

var server = http.createServer((request, response) => {
    // 接受客户端请求时触发
    ...
});

server.listen(10000, 'localhost', 511, () => {
   // 开始监听
   ...
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

方式二:事件监听方式

var server = http.createServer();
// 接受客户端请求时触发
server.on('request', (request, rsponse) => {
    ...
});

server.listen(10000, 'localhost', 511);
// 开始监听
server.on('listening', () => {
    ...
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

注意:

  • server.listen(port, [host], [backlog], [callback])中的backlog参数为整数,指定位于等待队列中客户端连接的最大数量,一旦超过这个长度,HTTP服务器将开始拒绝来自新客户端的连接,默认值为511。
  • 在HTTP请求服务器时,会发送两次请求。一次是用户发出请求,另一次是浏览器为页面在收藏夹中的显示图标(默认为favicon.ico)而自动发出的请求。
关闭服务器
server.close();
// 服务器关闭时会触发close事件
server.on('close', () => {...});
 
 
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3
超时
server.setTimeout(60 * 1000, () => {
   console.log('超时了');
});
// 或者通过事件形式
server.setTimeout(60 * 1000);
server.on('timeout', () => {...});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

注意:默认超时时间为2分钟

错误
server.on('error', (e) => {
    if(e.code === 'EADDRINUSE') {
        // 端口被占用
    }
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

获取客户端请求信息

当从客户端请求流中读取到数据时会触发data事件,当读取完客户端请求流中的数据时触发end事件。

请求对象的属性 说明
method 请求的方法Get、Post、Put、Delete
url 客户端发送请求时使用的URL参数字符串;通常用来判断请求页面
headers 请求头对象
httpVersion HTTP1.0或者HTTP1.1
trailers 客户端发送的trailers对象
socket 服务器用于监听客户端请求的socket对象

Get请求

server.on('request', (request, response) => {
    if(request.url !== '/favicon.ico') {
        /* Get请求 */
        var params = url.parse(req.url, true).query;
        // 或者
        // var params = querystring.parse(url.parse(request.url).query);
        // 根据参数做处理
        // ...
        // 结束请求
        response.end();
    }  
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

Post请求

server.on('request', (request, response) => {
    request.setEncoding('utf-8');
    if(request.url !== '/favicon.ico') {
        let result = '';
        request.on('data', (data) => {
            result += data;
        });
        request.on('end', () => {
            var params = JSON.parse(postData);
            console.log(`数据接收完毕:${result}`);
        });
        // 结束本次请求
        response.end();
    }
    // 结束本次请求
    response.end(JSON.stringify({status: 'success'}));
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

转换URL字符串与查询字符串

  • querystring模块:转换URL中的查询字符串(URL中?之后,#之前)

    querystring.stringify(obj, [sep], [eq])
    querystring.parse(str, [sep], [eq], [option])
       
       
    • 1
    • 2
    • 1
    • 2
    • sep:分割符,默认&
    • eq:分配字符,默认=
    • options:{maxKeys: number}指定转换后对象中的属性个数
    let str = querystring.stringify({name: 'ligang', age: 27});
    console.log(str); // name=ligang&age=27
    let obj = querystring.parse(str);
    console.log(obj); // { name: 'ligang', age: '27' }
       
       
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4
  • url模块:转换完整URL字符串

    url.parse(urlStr, [parseQueryString])
       
       
    • 1
    • 1
    • parseQueryString:如果为true,将查询字符通过querystring转换为对象;默认false。
    url.resolve(from, to);
       
       
    • 1
    • 1

    将二者结合成一个路径,from、to既可以是相对路径也可以是绝对路径。

    // http://ligangblog.com/javascript/a?a=1
    url.resolve('http://ligangblog.com/javascript/', 'a?a=1'); 
    // http://ligangblog.com/a?a=1
    url.resolve('http://ligangblog.com/javascript/', '/a?a=1'); 
       
       
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    注意:具体合并规则,请查看《Node权威指南》— 8.1HTTP服务器。

属性 含义
href 原URL字符串
protocol 协议
slashes 在协议与路径中间是否使用“//”分隔符
host URL完整地址及端口号,可能是一个IP地址
hostname URL完整地址,可能是一个IP地址
port 端口号
path URL字符串中的路径,包含查询字符串
pathname URL字符串中的路径,不包含查询字符串
search 查询字符串,包含起始字符“?”
query 查询字符串,不包含起始字符“?”
hash hash值,包含起始字符“#”
var urlStr = 'http://ligangblog.com/javascript/?name=lg&uid=1#a/b/c';
console.log(url.parse(urlStr, true));
/*
Url {
    protocol: 'http:',
    slashes: true,
    auth: null,
    host: 'ligangblog.com',
    port: null,
    hostname: 'ligangblog.com',
    hash: '#a/b/c',
    search: '?name=lg&uid=1',
    query: { name: 'lg', uid: '1' },
    pathname: '/javascript/',
    path: '/javascript/?name=lg&uid=1',
    href: 'http://ligangblog.com/javascript/?name=lg&uid=1#a/b/c' 
}
*/
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

发送服务器端响应流

response.writeHead(statusCode, [reasonPhrase], [headers]);
// 或者
response.setHeader(name, value);
 
 
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

响应头中包含的一些常用字段:

字段 说明
content-type 用于指定内容类型
location 用于将客户端重定向到另一个URL地址
content-disposition 用于指定一个被下载的文件名
content-length 用于指定服务器端响应内容的字节数
set-cookie 用于在客户端创建一个cookie
content-encoding 用于指定服务器端响应内容的编码方式
Cache-Control 用于开启缓存机制
Expires 用于指定缓存过期时间
Tag 用于指定当服务器响应内容没有变换时不重新下载数据

示例:

response.writeHead(200, {'Content-Type': 'text/plain', 
                         'Access-Control-Allow-Origin': 'http://localhost'});
// 或者
response.statusCode = 200;
response.setHeader('Content-Type', 'text/plain');
response.setHeader('Access-Control-Allow-Origin', 'http://localhost');
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

writeHead和setHeader区别:

  • writeHead:该方法被调用时发送响应头
  • setHeader:write方法第一次被调用时发送响应头
/* 获取响应头中的某个字段值 */
response.getHeader(name);
/* 删除一个响应字段值 */
response.removeHeader(name);
/* 该属性表示响应头是否已发送 */
response.headersSent;
/* 在响应数据的尾部增加一个头信息 */
response.addTrailers(headers);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

示例:

// 必须再响应头中添加Trailer字段,并且其值设置为追加的响应头中所指定的字段名
response.write(200, {'Content-Type': 'text/plain', 'Trailer': 'Content-MD5'});
response.write('....');
response.addTrailers({'Content-MD5', '...'});
response.end();
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

特别说明:

当再快速网路且数据量很小的情况下,Node将数据直接发送到操作系统内核缓存区中,然后从该内核缓存区中取出数据发送给请求方;如果网速很慢或者数据量很大,Node通常会将数据缓存在内存中,在对方可以接受数据的情况下将内存中的数据通过操作系统内核缓存区发送给请求方。response.write返回true,说明直接写到了操作系统内核缓存区中;返回false,说明暂时缓存的内存中。每次需要通过调用response.end()来结束响应。

创建HTTP与HTTPS服务器与客户端-write

响应超时会触发timeout事件;response.end()方法调用之前,如果连接中断,会触发close事件。

/* 设置请求超时时间2分钟 */
response.setTimeout(2 * 60 * 1000, () => {
  console.error('请求超时!'); 
});
// 或者
response.setTimout(2 * 60 * 1000);
response.on('timeout', () => {
  console.error('请求超时!');
});

/* 连接中断 */
response.on('close', () => {
  console.error('连接中断!');
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
/**
 * HTTP服务端
 * Created by ligang on 17/5/28.
 */

import http from 'http';

var server = http.createServer();
// 接受客户端请求时触发
server.on('request', (request, response) => {
    if(request.url !== '/favicon.ico') {
        response.setTimeout(2 * 60 * 1000, () => {
           console.error('请求超时!');
        });
        response.on('close', () => {
            console.error('请求中断!');
        });
        let result = '';
        request.on('data', (data) => {
            result += data;
        });
        request.on('end', () => {
            console.log(`服务器数据接收完毕:${result}`);
            response.statusCode = 200;
            response.write('收到!');
            response.end(); // 结束本次请求
        });
    }
});

server.listen(10000, 'localhost', 511);
// 开始监听
server.on('listening', () => {
    console.log('开始监听');
});

server.on('error', (e) => {
    if(e.code === 'EADDRINUSE') {
        console.log('端口被占用');
    }else {
        console.log(`发生错误:${e.code}`);
    }
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

HTTP客户端

Node.js可以轻松向任何网站发送请求并读取网站的响应数据。

var req = http.request(options, callback);
// get请求
var req = http.get(options, callback);
// 向目标网站发送数据
req.write(chunk, [encoding]);
// 结束本次请求
req.end([chucnk], [encoding]);
// 中止本次请求
req.abort();
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

其中,options用于指定目标URL地址,如果该参数是一个字符串,将自动使用url模块中的parse方法转换为一个对象。注意:http.get()方法只能使用Get方式请求数据,且无需调用req.end()方法,node.js会自动调用。

/**
 * HTTP客户端
 * Created by ligang on 17/5/30.
 */
import http from 'http';

const options = {
        hostname: 'localhost',
        port: 10000,
        path: '/',
        method: 'post'
    },
    req = http.request(options);

req.write('你好,服务器');
req.end();

req.on('response', (res) => {
    console.log(`状态码:${res.statusCode}`);
    let result = '';
    res.on('data', (data) => {
        result += data;
    });
    res.on('end', () => {
        console.log(`客户端接受到响应:${result}`);
    })
});
req.setTimeout(60* 1000, () => {
    console.log('超时了');
    req.abort();
});
req.on('error', (error) => {
    if(error.code === 'ECONNERSET') {
        console.log('socket端口超时');
    }else {
        console.log(`发送错误:${error.code}`);
    }
});
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

代理服务器

/**
 * HTTP代理
 * Created by ligang on 17/5/30.
 */

import http from 'http';
import url from 'url';

/**
 * 服务端
 */
const server = http.createServer(async (req, res) => {
    // req.setEncoding('utf-8');
    /* 超时 2分钟 */
    res.setTimeout(2 * 60 * 1000, () => {
        // ...
    });
    /* 连接中断 */
    res.on('close', () => {
        // ...
    });

    let options = {},
        result = "";

    options = await new Promise((resolve, reject) => {
        if(req.method === 'GET') {
            options = url.parse('http://localhost:10000' + req.url);
            resolve(options);
        }else if(req.method === 'POST') {
            req.on('data', (data) => {
                result += data;
            });

            req.on('end', () => {
                options = url.parse('http://localhost:10000' + req.url);
                // post请求必须制定
                options.headers = {
                    'content-type': 'application/json',
                };
                resolve(options);
            });
        }
    });
    options.method = req.method;

    let content = await clientHttp(options, result ? JSON.parse(result) : result);
    res.setHeader('Content-Type', 'text/html');
    res.write('<html><head><meta charset="UTF-8" /></head>')
    res.write(content);
    res.write('</html>');
    // 结束本次请求
    res.end();
});
server.listen(10010, 'localhost', 511);
/* 开始监听 */
server.on('listening', () => {
    // ...
});
/* 监听错误 */
server.on('error', (e) => {
    console.log(e.code);
    // ...
});

/**
 * 客户端
 * @param options 请求参数
 * @param data 请求数据
 */
async function clientHttp(options, data) {
    let output = new Promise((resolve, reject) => {
        let req = http.request(options, (res) => {
            let result = '';
            res.setEncoding('utf8');
            res.on('data', function (chunk) {
                result += chunk;
            });
            res.on('end', function () {
                resolve(result);
            });
        });
        req.setTimeout(60000, () => {
            console.error(`连接后台超时 ${options.href}`);
            reject();
            req.abort();
        });
        req.on('error', err => {
            console.error(`连接后台报错 ${err}`);
            if (err.code === 'ECONNRESET') {
                console.error(`socket超时 ${options.href}`);
            } else {
                console.error(`连接后台报错 ${err}`);
            }
            reject();
            req.abort();
        });
        // 存在请求数据,发送请求数据
        if (data) {
            req.write(JSON.stringify(data));
        }
        req.end();
    });
    return await output;
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105

注意:

  • POST请求必须指定headers信息,否则会报错socket hang up
  • 获取到options后需要重新指定其methodoptions.method = req.method;

HTTPS服务器

  • HTTPS使用https协议,默认端口号44;
  • HTTPS需要向证书授证中心申请证书;
  • HTTPS服务器与客户端之间传输是经过SSL安全加密后的密文数据;

创建公钥、私钥及证书

(1)创建私钥

openssl genrsa -out privatekey.pem 1024
 
 
  • 1
  • 1

(2)创建证书签名请求

openssl req -new -key privatekey.pem -out certrequest.csr
 
 
  • 1
  • 1

(3)获取证书,线上证书需要经过证书授证中心签名的文件;下面只创建一个学习使用证书

openssl x509 -req -in certrequest.csr -signkey privatekey.pem -out certificate.pem
 
 
  • 1
  • 1

(4)创建pfx文件

openssl pkcs12 -export -in certificate.pem -inkey privatekey.pem -out certificate.pfx
 
 
  • 1
  • 1

HTTPS服务

创建HTTPS服务器同HTTP服务器大致相同,需要增加证书,创建HTTPS服务器时通过options参数设置。

import https from 'https';
import fs from 'fs';

var pk = fs.readFileSync('privatekey.pem'),
    pc = fs.readFileSync('certificate.pem');

var opts = {
    key: pk,
    cert: pc
};

var server = https.createServer(opts);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

opts参数为一个对象,用于指定创建HTTPS服务器时配置的各种选项,下面只描述几个必要选项:

属性名 说明
pff 用于指定从pfx文件读取出的私钥、公钥以及证书(指定该属性后,无需再指定key、cert、ca
key 用于指定后缀名为pem的文件,读出私钥
cert 用于指定后缀名为pem的文件,读出公钥
ca 用于指定一组证书,默认值为几个著名的证书授证中心

HTTPS客户端

const options = {
        hostname: 'localhost',
        port: 1443,
        path: '/',
        method: 'post',
        key: fs.readFileSync('privatekey.pem'),
        cert: fs.readFileSync('certificate.pem'),
        rejectUnhauthorized: false,
        agent: false // 从连接池中指定挑选一个当前连接状态为关闭的https.Agent
    },
    req = https.request(options);

// 或者
const options = {
        hostname: 'localhost',
        port: 1443,
        path: '/',
        method: 'post',
        key: fs.readFileSync('privatekey.pem'),
        cert: fs.readFileSync('certificate.pem'),
        rejectUnhauthorized: false,
    };
// 显示指定https.Agent对象
options.agent = new https.Agent(options);
var req = https.request(options);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

说明: 普通的 HTTPS 服务中,服务端不验证客户端的证书(但是需要携带证书),中间人可以作为客户端与服务端成功完成 TLS 握手; 但是中间人没有证书私钥,无论如何也无法伪造成服务端跟客户端建立 TLS 连接。 当然如果你拥有证书私钥,代理证书对应的 HTTPS 网站当然就没问题了,所以这里的私钥和公钥只是格式书写,没有太大意义,只要将请求回来的数据原原本本交给浏览器来解析就算完成任务。 
关于代理的两篇文章:HTTP 代理原理及实现(一)HTTP 代理原理及实现(二)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值