1. 程式人生 > >[NodeJS]建立HTTP、HTTPS伺服器與客戶端

[NodeJS]建立HTTP、HTTPS伺服器與客戶端

超文字傳輸協議(HTTP,HyperText Transfer Protocol)是網際網路上應用最為廣泛的一種網路協議。所有的WWW檔案都必須遵守這個標準。設計HTTP最初的目的是為了提供一種釋出和接收HTML頁面的方法。其屬於下圖七層網路協議的“應用層”

七層網路協議

HTTP伺服器

建立HTTP伺服器

建立服務

方式一:回撥方式

var server = http.createServer((request, response) => {
    // 接受客戶端請求時觸發
    ...
});

server.listen(10000, 'localhost', 511
, () => { // 開始監聽 ... });

方式二:事件監聽方式

var server = http.createServer();
// 接受客戶端請求時觸發
server.on('request', (request, rsponse) => {
    ...
});

server.listen(10000, 'localhost', 511);
// 開始監聽
server.on('listening', () => {
    ...
});

注意:

  • server.listen(port, [host], [backlog], [callback])
    中的backlog引數為整數,指定位於等待佇列中客戶端連線的最大數量,一旦超過這個長度,HTTP伺服器將開始拒絕來自新客戶端的連線,預設值為511。
  • 在HTTP請求伺服器時,會發送兩次請求。一次是使用者發出請求,另一次是瀏覽器為頁面在收藏夾中的顯示圖示(預設為favicon.ico)而自動發出的請求。

關閉伺服器

server.close();
// 伺服器關閉時會觸發close事件
server.on('close', () => {...});

超時

server.setTimeout(60 * 1000, () => {
   console.log('超時了'
); }); // 或者通過事件形式 server.setTimeout(60 * 1000); server.on('timeout', () => {...});

注意:預設超時時間為2分鐘

錯誤

server.on('error', (e) => {
    if(e.code === 'EADDRINUSE') {
        // 埠被佔用
    }
});

獲取客戶端請求資訊

當從客戶端請求流中讀取到資料時會觸發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();
    }  
});

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'}));
});

轉換URL字串與查詢字串

  • querystring模組:轉換URL中的查詢字串(URL中?之後,#之前)

    querystring.stringify(obj, [sep], [eq])
    querystring.parse(str, [sep], [eq], [option])
    • 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' }
  • url模組:轉換完整URL字串

    url.parse(urlStr, [parseQueryString])
    • parseQueryString:如果為true,將查詢字元通過querystring轉換為物件;預設false。
    url.resolve(from, to);

    將二者結合成一個路徑,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'); 

    注意:具體合併規則,請檢視《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' 
}
*/

傳送伺服器端響應流

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

響應頭中包含的一些常用欄位:

欄位 說明
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');

writeHead和setHeader區別:

  • writeHead:該方法被呼叫時傳送響應頭
  • setHeader:write方法第一次被呼叫時傳送響應頭
/* 獲取響應頭中的某個欄位值 */
response.getHeader(name);
/* 刪除一個響應欄位值 */
response.removeHeader(name);
/* 該屬性表示響應頭是否已傳送 */
response.headersSent;
/* 在響應資料的尾部增加一個頭資訊 */
response.addTrailers(headers);

示例:

// 必須再響應頭中新增Trailer欄位,並且其值設定為追加的響應頭中所指定的欄位名
response.write(200, {'Content-Type': 'text/plain', 'Trailer': 'Content-MD5'});
response.write('....');
response.addTrailers({'Content-MD5', '...'});
response.end();

特別說明:

當再快速網路且資料量很小的情況下,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('連線中斷!');
});
/**
 * 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}`);
    }
});

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();

其中,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}`);
    }
});

代理伺服器

/**
 * 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;
}

注意:

  • POST請求必須指定headers資訊,否則會報錯socket hang up
  • 獲取到options後需要重新指定其methodoptions.method = req.method;

HTTPS伺服器

  • HTTPS使用https協議,預設埠號44;
  • HTTPS需要向證書授證中心申請證書;
  • HTTPS伺服器與客戶端之間傳輸是經過SSL安全加密後的密文資料;

建立公鑰、私鑰及證書

(1)建立私鑰

openssl genrsa -out privatekey.pem 1024

(2)建立證書籤名請求

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

(3)獲取證書,線上證書需要經過證書授證中心簽名的檔案;下面只建立一個學習使用證書

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

(4)建立pfx檔案

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

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);

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);

說明: 普通的 HTTPS 服務中,服務端不驗證客戶端的證書(但是需要攜帶證書),中間人可以作為客戶端與服務端成功完成 TLS 握手; 但是中間人沒有證書私鑰,無論如何也無法偽造成服務端跟客戶端建立 TLS 連線。 當然如果你擁有證書私鑰,代理證書對應的 HTTPS 網站當然就沒問題了,所以這裡的私鑰和公鑰只是格式書寫,沒有太大意義,只要將請求回來的資料原原本本交給瀏覽器來解析就算完成任務。
關於代理的兩篇文章:HTTP 代理原理及實現(一)HTTP 代理原理及實現(二)