2019年1月1日 星期二

Node.js 學習筆記 (四) : http 模組測試

最近對聊天機器人重燃興趣, 將圖書館之前借過的 Node.js 書籍重新借回來, 打算好好把 NodeJS 徹底測試一番, 距離上次玩 NodeJS 已快一年了, 版本也從 8.9.0 飛躍至 11.3.0, 所以把 Win10 與樹莓派上的 NodeJS 都更新到最新版. Node.js 目前應用日廣, 滲透力越來越強, 不僅可開發桌上型程式 (Electron), 還有機器學習框架 (Keras.js), Javascript 已經快變成通用語言了.

本系列之前的測試紀錄參考 :

關於 Node.js
如何更新樹莓派的 Node.js
Node.js 學習筆記 (一) : 在 Windows 安裝 Node.js

以下測試主要參考下面幾本書 :
  1. 深入淺出 Node.js (博碩, 朴靈)  
  2. Node.js 實戰手冊 (碁峰, 楊仁和)   
  3. Google 御用語言 Node.js (第二版, 佳魁, 郭家寶)  
  4. 網頁應用程式 : 使用 Node 和 Express (歐萊禮)
  5. PhoneGap + Node.js 整合實作 (PCuSER, 彭祖乙)
  6. Javascript+jQuery Mobile+Node.js 跨平台網頁設計範例教本 (碁峰, 陳會安)   
"Google 御用語言 Node.js" 這本書我買的是第一版, 目前已出第三版, 但我比對目次並無發現新增內容, 可能只是更正勘誤或局部改寫, 有趣的是封面的花瓶又從左側移回第一版時的右邊了. 佳魁的美編是怎樣?

Node 標準函式庫提供 http 模組, 此核心模組以 Javascript 封裝了一個底層由 C++ 實作的高效率 HTTP 伺服器 (http.server 物件) 與簡易的 HTTP 客戶端 (http.request 物件), 兼顧了高性能與使用簡易的雙重需求. Node 的設計哲學是只維持一個小而強的網路 API 核心 (如 http, net, 與 querryString 等), 而非自行實作高階的框架, 這些都交給第三方模組如 Connect 與 Express 等框架提供. 因此在 Node 核心看不到 Session 與 Cookies 等高階元素.

Node 的 http 模組是一個實作 HTTP 協定的低階事件驅動 API, 可以用來建立網頁伺服器以處理 HTTP 請求 (Request) 與回應 (Response) 之串流 (Stream) 資料, 將其剖析成本文 (Body) 與標頭 (Header) 兩大部分 (但不會進一步解析 Body 內容), 大大簡化了串流資料編解碼與訊息處理的困難. http 模組也可以用來建立客戶端程式, 模擬瀏覽器與伺服器連線, 參考 :

https://nodejs.org/api/http.html

使用 http 模組建立 Web 應用前須用 require 載入此模組並指定一個變數接收傳回之 http 物件 :

var http=require('http');

傳回值是一個 http 物件,  用 console.log(http) 顯示物件內容 :

console.log(JSON.stringify(http, null, 4));

http 物件主要的兩個屬性 :
  1. STATUS_CODES : 定義回應碼
  2. METHODS : 定義 GET, POST, HEAD, DELETE, UPDATE 等 HTTP 協定方法
HTTP 回應碼常見為 200 (正常), 404 (網頁或資源不存在), 500 (伺服器內部錯誤) 等, Node 將全部 HTTP 回應碼放在 http.STATUS_CODES 屬性中, 例如 :

var http=require("http")
undefined
http.STATUS_CODES
{ '100': 'Continue',
  '101': 'Switching Protocols',
  '102': 'Processing',
  '103': 'Early Hints',
  '200': 'OK',
  '201': 'Created',
  '202': 'Accepted',
  '203': 'Non-Authoritative Information',
  '204': 'No Content',
  '205': 'Reset Content',
  '206': 'Partial Content',
  '207': 'Multi-Status',
  '208': 'Already Reported',
  '226': 'IM Used',
  '300': 'Multiple Choices',
  '301': 'Moved Permanently',
  '302': 'Found',
  '303': 'See Other',
  '304': 'Not Modified',
  '305': 'Use Proxy',
  '307': 'Temporary Redirect',
  '308': 'Permanent Redirect',
  '400': 'Bad Request',
  '401': 'Unauthorized',
  '402': 'Payment Required',
  '403': 'Forbidden',
  '404': 'Not Found',
  '405': 'Method Not Allowed',
  '406': 'Not Acceptable',
  '407': 'Proxy Authentication Required',
  '408': 'Request Timeout',
  '409': 'Conflict',
  '410': 'Gone',
  '411': 'Length Required',
  '412': 'Precondition Failed',
  '413': 'Payload Too Large',
  '414': 'URI Too Long',
  '415': 'Unsupported Media Type',
  '416': 'Range Not Satisfiable',
  '417': 'Expectation Failed',
  '418': "I'm a Teapot",
  '421': 'Misdirected Request',
  '422': 'Unprocessable Entity',
  '423': 'Locked',
  '424': 'Failed Dependency',
  '425': 'Unordered Collection',
  '426': 'Upgrade Required',
  '428': 'Precondition Required',
  '429': 'Too Many Requests',
  '431': 'Request Header Fields Too Large',
  '451': 'Unavailable For Legal Reasons',
  '500': 'Internal Server Error',
  '501': 'Not Implemented',
  '502': 'Bad Gateway',
  '503': 'Service Unavailable',
  '504': 'Gateway Timeout',
  '505': 'HTTP Version Not Supported',
  '506': 'Variant Also Negotiates',
  '507': 'Insufficient Storage',
  '508': 'Loop Detected',
  '509': 'Bandwidth Limit Exceeded',
  '510': 'Not Extended',
  '511': 'Network Authentication Required' }
http.METHODS
[ 'ACL',
  'BIND',
  'CHECKOUT',
  'CONNECT',
  'COPY',
  'DELETE',
  'GET',
  'HEAD',
  'LINK',
  'LOCK',
  'M-SEARCH',
  'MERGE',
  'MKACTIVITY',
  'MKCALENDAR',
  'MKCOL',
  'MOVE',
  'NOTIFY',
  'OPTIONS',
  'PATCH',
  'POST',
  'PROPFIND',
  'PROPPATCH',
  'PURGE',
  'PUT',
  'REBIND',
  'REPORT',
  'SEARCH',
  'SOURCE',
  'SUBSCRIBE',
  'TRACE',
  'UNBIND',
  'UNLINK',
  'UNLOCK',
  'UNSUBSCRIBE' ]

http 物件主要的三個方法如下 :

 http 物件方法 說明
 createServer([callback]) 建立 HTTP 伺服器, 傳回 Server 物件
 callback=監聽 HTTP 請求事件之回呼函數, 攜帶 2 個參數
 ServerRequest (請求物件), ServerResponse (回應物件)
 request(options, callback) 建立 HTTP 客戶端連線
 options=伺服器選項 (URL, port, 請求方法等)
 callback=攜帶回應物件 ClientResponse 的回呼函數
 get(options, callback) 以 GET 方法建立 HTTP 客戶端連線
 options=伺服器選項 (URL, port, 請求方法等)
 callback=攜帶回應物件 ClientResponse 為參數的回呼函數

其中 http.createServer() 用在伺服端建立 HTTP 伺服器 (回呼函數是被選參數); 而 http.request() 與 http.get() 則用來建立客戶端 HTTP 連線. 呼叫 http.createServer() 會傳回伺服器物件 http.Server, 其常用方法如下:

 Server 物件之方法 說明
 listen(port, [host], [backlog], [callback]) 監聽指定主機埠的事件
 host 預設為 'localhost' 或 '127.0.0.1' 
 on(event, callback(req, res)) 監聽指定事件 event (字串), 觸發時呼叫 callback() 函數
 常用事件 :
 'request' : 接到客戶端請求時觸發
 'connection' : TCP 連線建立時觸發
 'close' : 關閉伺服器時觸發

http.Server 是以事件為基礎的伺服器, 所有來自客戶端的請求都會被封裝成獨立之事件物件 (繼承自 EventEmitter), 只要針對觸發之事件撰寫對應之回呼函數即可實作 HTTP 伺服器之所有功能. 注意, server.on() 的回呼函數與 http.createServer() 的回呼函數是一樣的, 均傳入 req 與 res 物件當參數.

Node 的 http 模組是一個封裝等級很低階的 API, 僅僅是低層的串流控制與訊息剖析, 實務上通常不直接使用 http 模組來建構網頁應用程式, 而是使用較高階的第三方框架如 Express 等.

呼叫 http 物件的 createServer() 方法可來建立 HTTP 伺服器 (傳回 http.Server 物件), 此方法須指定一個帶有兩個參數 req 與 res 的回呼函數來處理回應, 當請求標頭被解析完成後, 它會被打包成 http.ServerRequest 物件傳入回呼函數的第一參數 req 中 :

function(req, res) {
  res.writeHead(200, {'ContentType': 'text/plain'});
  res.end();
  };

第一個參數 req 代表來自客戶端之資源請求物件 http.ServerRequest, 這是後端程式開發最重要的資訊, 主要內容是請求標頭, 其屬性如下表 :

 Request 物件之屬性 說明
 url 原始之請求路徑
 method HTTP 請求方法, 如 GET, PUT, POST, DELETE 等
 httpVersion HTTP 協定版本 (1.0 或 1.1)
 headers HTTP 請求標頭 (物件)
 trailers HTTP 請求尾部 (物件)
 complete 客戶端請求是否已發送完畢 (true/false)
 connection HTTP 連線 socket, 為 net.Socket 物件 (物件)
 socket 同 connection, 為其別名 (物件)
 client 客戶端別名 (物件)

第二個參數 res 是伺服器用來發出回應的 http.ServerResponse 物件. 在第一個 res.write() 或 res.end() 出現之前可以任何順序新增或移除回應標頭, 當第一個 res.write() 或 res.end() 出現時, Node 就會送出 (flush) 設定好的回應標頭.

在回呼函數中可以用下列 Response 物件的方法來回應客戶端要求 :

 Response 物件之方法 說明
 writeHead(code, header) 送出回應碼 code (3 位數整數) 與 HTTP 回應標頭物件 header
 setHeader(attr, value) 設定 HTTP 標頭屬性 attr 之值為 value
 getHeader(attr) 傳回指定標頭屬性之值
 removeHeader(attr) 刪除指定標頭屬性
 write(data [, encoding]) 將 data 碼寫入回應串流, 若為字串以 encoding 編碼 (預設 utf8) 
 end([data, encoding]) 將 data 寫入回應串流並結束回應訊息, 若為字串以 encoding 編碼 (預設 utf8) 

此處 data 可以是 buffer 或字串. 方法 end() 會結束回應訊息, 可以傳入要輸出至回應之資料, 也可以直接結束回應訊息. 如果是字串應指定 encoding 編碼. 方法 writeHead() 可以一次將全部標頭資訊 (物件) 與回應碼寫入緩衝區; 而方法 setHeader() 則是只能一次寫入一個標頭屬性. 若使用 setHeader(), 則回應碼要用 res.statusCode 屬性設定.

注意, writeHead() 方法在回呼函數內只能使用一次, 而 write() 可以多次呼叫, 但都必須在 end() 之前呼叫.

一. 基本的 HTTP 伺服器 :

Node.js 打造的最簡單 HTTP 伺服器如下, 是直接在程式中輸出回應訊息 :

測試 1 : 回應 HelloWorld 的 HTTP 伺服器 (1)

var http=require('http')
http.createServer(function(req, res) {
  res.writeHead(200, {'ContentType': 'text/plain'});
  res.end('Hello World!\n');
  }).listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

對於輸出純文字而言, 也可以不用 res.writeHead() 指定回應碼與 ContentType 標頭屬性 , 因為伺服器預設回應 200 OK 與 text/plain 內容型態; 但如果是回應 HTML 內容時必須指定 text/html, 這樣瀏覽器才知道要將內容渲染為 HTML. 將此程式存成 js 檔例如 test1.js, 然後開啟命令提示字元視窗, 以 node 指令執行此 js :

D:\Node.js\test>node test1.js
HTTP 伺服器正在監聽 8080 埠 ...

可見伺服器已經啟動, 按 Ctrl+C 可停止程式. 這時 Windows 防火牆會彈出警告視窗, 要按 "允許存取" 程式才會有效 :




然後開啟瀏覽器連線 localhost:8080 或者 127.0.0.1:8080 即可看到伺服器輸出的網頁 :



上面測試 1 程式也可以寫成如下 :

測試 2 : 回應 HelloWorld 的 HTTP 伺服器 (2)

var http=require('http')
var server=http.createServer(function(req, res) {
  res.setHeader('ContentType', 'text/plain');
  res.statusCode=200;
  res.write('Hello World!\n');
  res.end();
  });
server.listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

注意, 上述範例中呼叫 http.createServer() 時有傳入一個回呼函數作為請求事件監聽器, 但那不是必要的參數, 也可以呼叫 server.on() 來添加事件監聽器, 測試 1 程式改寫如下 :

測試 3 : 呼叫 server.on() 來添加事件監聽器 [看原始碼]

var http=require('http');
var server=http.createServer();  //傳回 http.Server 物件
server.on('request', function(req, res) {   //監聽請求事件
  res.writeHead(200, {'ContentType': 'text/plain'});
  res.end('Hello World!\n');
  });
server.listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

此例在呼叫 createServer() 不傳入回呼函數, 而是以 server 變數紀錄所建立之 http.Server 物件, 然後呼叫其 on() 函數並傳入回呼函數來監聽 request 請求事件, 效果與測試 1 一樣.

若要回應 HTML 網頁, 則 Content-Type 要改為 'text/html', 如下面範例 :

測試 4 : 回應 HelloWorld 的 HTTP 伺服器 (HTML)  [看原始碼]

var http=require('http')
var server=http.createServer(function(req, res) {
  res.writeHead(200, {'ContentType': 'text/html'});
  res.write('<!DOCTYPE html>\n');
  res.write('<head>\n');
  res.write('<title>Hello</title>\n');
  res.write('</head>\n');
  res.write('<body>\n');
  res.write('<b>Hello World!</b>\n');
  res.write('</body>\n');
  res.write('</html>');
  res.end();
  });
server.listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

顯示網頁原始碼如下 :




注意, res.write() 中 HTML 碼以 \n 結尾的目的是要讓 HTML 原始碼的可讀性高, 若無 \n 則全部 HTML 會串在一起.

下面範例主要是檢視請求物件 req 的內容 :

測試 5 : 顯示 req 物件之屬性 (1) [看原始碼]

var http=require('http')
http.createServer(function(req, res) {
  console.log("req.url=" + req.url + "\r\n");
  console.log("req.method=" + req.method + "\r\n");
  console.log("req.httpVersio=" + req.httpVersion + "\r\n");
  console.log("req.complete=" + req.complete + "\r\n");
  console.log("req.headers=" + req.headers + "\r\n");
  console.log("req.trailers=" + req.trailers + "\r\n");
  console.log("req.connection=" + req.connection + "\r\n");
  console.log("req.socket=" + req.socket + "\r\n");
  console.log("req.client=" + req.client + "\r\n");
  res.write('Hello World!\n');
  res.end();
  }).listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ... \r\n');

執行結果如下 :

HTTP 伺服器正在監聽 8080 埠 ...
req.url=/
req.method=GET
req.httpVersio=1.1
req.complete=false
req.headers=[object Object]
req.trailers=[object Object]
req.connection=[object Object]
req.socket=[object Object]
req.client=[object Object]
req.url=/favicon.ico
req.method=GET
req.httpVersio=1.1
req.complete=falsereq.headers=[object Object]
req.trailers=[object Object]
req.connection=[object Object]
req.socket=[object Object]
req.client=[object Object]

結果有兩個請求出現, 除了瀏覽首頁這個請求外, 還有一個 favicon.ico 圖檔資源的請求. 可見 headers, socket, connection 等後面 5 個屬性值為物件, 其中 headers 與 trailers 可用 JSON.stringify() 將其轉成字串來顯示內容, 例如 :

測試 6 : 顯示 req 物件之屬性 (2) [看原始碼]

var http=require('http')
http.createServer(function(req, res) {
  console.log("req.url=" + req.url + "\r\n");
  console.log("req.method=" + req.method + "\r\n");
  console.log("req.httpVersio=" + req.httpVersion + "\r\n");
  console.log("req.complete=" + req.complete + "\r\n");
  console.log("req.headers=" + JSON.stringify(req.headers) + "\r\n");
  console.log("req.trailers=" + JSON.stringify(req.trailers) + "\r\n");
  console.log("req.connection=" + req.connection + "\r\n");
  console.log("req.socket=" + req.socket + "\r\n");
  console.log("req.client=" + req.client + "\r\n");
  res.write('Hello World!\n');
  res.end();
  }).listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ... \r\n');

結果如下 :

HTTP 伺服器正在監聽 8080 埠 ...
req.url=/
req.method=GET
req.httpVersio=1.1
req.complete=false
req.headers={"host":"localhost:8080","connection":"keep-alive","cache-control":"max-age=0","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8","accept-encoding":"gzip, deflate, br","accept-language":"zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7"}
req.trailers={}
req.connection=[object Object]
req.socket=[object Object]
req.client=[object Object]
req.url=/favicon.ico
req.method=GET
req.httpVersio=1.1
req.complete=false
req.headers={"host":"localhost:8080","connection":"keep-alive","pragma":"no-cache","cache-control":"no-cache","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36","accept":"image/webp,image/apng,image/*,*/*;q=0.8","referer":"http://localhost:8080/","accept-encoding":"gzip, deflate, br","accept-language":"zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7"}
req.trailers={}
req.connection=[object Object]
req.socket=[object Object]
req.client=[object Object]

可見 headers 屬性儲存了客戶端請求標頭, 而 trailers 為空的物件. 注意, connection, socket, 與 client 三者為 circular 結構不能用 JSON.stringify(), 否則執行錯誤.

二. HTTP 客戶端程式 :

上面的範例全部都是以瀏覽器作為 HTTP 客戶端代理, 實際上 http 模組除了可用 createServer() 建立 HTTP 伺服器外, 還可以用 request() 建立客戶端程式, 取代瀏覽器來與伺服器連線.

http 的 request() 方法須傳入兩個參數, 第一參數是關於遠端伺服器的連線選項物件 options, 包含下列屬性 :
  1. host : 伺服器之主機位址 
  2. port : 伺服器之主機埠號
  3. path : 資源路徑
  4. method : HTTP 請求方法 (GET/POST/PUT/DELETE 等)
  5. headers : HTTP 請求標頭
第二參數是一個接收到伺服器回應事件後的回呼函數, 回應標頭與訊息會在解析之後封裝在 http.ClientResponse 物件中傳入回呼函數參數 res 中

測試 7 : HTTP 客戶端程式 [看原始碼]

var http=require('http');
var options={
hostname: 'localhost',
port: 8080,
path: '/',
method: 'POST'
    }
var client=http.request(options, function(res) {
      console.log('Status code : ' + res.statusCode);
      res.setEncoding('utf8');
      res.on('data',function(chunk) {  //監視 data 事件 (收到 body)
      console.log('BODY:' + chunk);
      });
    });
client.on('error', function(e) {
    console.log('Errror : ' + e.message);
    });
client.write('ABC\n');
client.end();

先開啟 DOS 視窗執行上面測試 1 或測試 2 程式, 然後開啟另一個 DOS 視窗執行上面的 HTTP 客戶端程式. 結果如下 :

D:\Node.js\test>node client_request.js
Status code : 200
Headers : {"contenttype":"text/plain","date":"Fri, 04 Jan 2019 03:35:18 GMT","connection":"close","transfer-encoding":"chunked"}
BODY:Hello World!

可見同樣是收到伺服端回應的 "Hello World!".


三. 取得 HTTP 請求內容 :

http 模組處理 HTTP 請求的程序與其他伺服端框架如 PHP 有些不同, PHP 在執行應用邏輯之前, header 與 body 均已被解析完畢; 然而 Node 卻不是這樣, 當 Node 伺服器收到 HTTP 請求時, request 事件會被觸發, Node 只會解析請求標頭, 並將結果封裝成 http.ServerRequest 物件傳給回呼函數, Node 預設不會對正文進行解析處理, 但提供下列三個事件讓使用者自行處理正文之接收 :

 Request 物件之事件 說明
 data  收到請求本文時觸發 data 事件, 並傳入 chunk 參數給回呼函數. 
 end 請求本文全部傳送完成時觸發 end 事件
 close 使用者強制停止請求時觸發 close 事件

這是因為請求正文 (body) 可能很長 (例如檔案上傳), 等待 body 傳輸可能很耗時而拖累伺服器效能之故. Node 作法的好處是, 若有需要可以在 body 被解析之前利用 data 事件的回呼函數先進行一些處理, 此回呼函數會傳入所收到的正文內容 chunk, 這是一個 Buffer 物件 (Byte 陣列).

1. 取得 GET 方法之請求內容 :

HTTP 1.1 定義的 8 種請求方法中常用的只有 GET 與 POST 方法 (但在採用 RESTful 架構的設計中則使用了較多其他請求方法來簡化設計). GET 請求的內容直接嵌入於 URL 路徑內而非本文 (body) 中, 例如執行上面測試 6 的伺服器程式後, 在瀏覽器輸入下列網址 :

http://127.0.0.1:8080/user?name=tony&phone=0912345678

GET 請求的內容就是 ? 後面以 & 串起來的參數, 經過 http 模組剖析後會放在 req 物件的 url 屬性中, 伺服端程式執行結果中的 req.url 輸出如下 :

D:\Node.js\test>node http_test_6.js
HTTP 伺服器正在監聽 8080 埠 ...

req.url=/user?name=tony&phone=0912345678

使用 Node 核心模組 url 的 parse() 方法可以解析 req.url 屬性, 此方法可傳入三個參數, 第一個參數為 URL 字串, 其他為備選參數, 預設為 false, 如果要進一步解析查詢字串, 則第二參數要傳入 true :

url.parse(urlString[, parseQueryString[, slashesDenoteHost]])

url.parse() 會傳回一個 URL 物件, 其中有三個屬性與路徑以及 GET 請求內容有關 :
  1. pathname : 路徑
  2. path : 路徑 + 參數
  3. query : 參數物件
其中 pathname 是 ? 以前的路徑, path 則是完整的路徑與參數字串, 而 query則是 ? 後面所帶的查詢參數字串, 例如 :

測試 8 : 讀取 GET 請求內容 [看原始碼]

var http=require('http');
var url=require('url');
http.createServer(function(req, res) {
  res.writeHead(200, {'ContentType': 'text/plain'});
  console.log(req.url);
  console.log(url.parse(req.url));
  console.log(url.parse(req.url, true).query);
  res.end('Hello World!');
  }).listen(8080);
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

注意, 上面 url.parse() 第二參數傳入 true 表示要對參數進行解析, 其傳回值為一物件, 在瀏覽器輸入如下網址 :

http://127.0.0.1:8080/user?name=tony&phone=0912345678

伺服端程式輸出如下 :

D:\Node.js\test>node http_test_8.js
HTTP 伺服器正在監聽 8080 埠 ...
/user?name=tony&phone=0912345678
Url {
  protocol: null,
  slashes: null,
  auth: null,
  host: null,
  port: null,
  hostname: null,
  hash: null,
  search: '?name=tony&phone=0912345678',
  query: 'name=tony&phone=0912345678',
  pathname: '/user',
  path: '/user?name=tony&phone=0912345678',
  href: '/user?name=tony&phone=0912345678' }
[Object: null prototype] { name: 'tony', phone: '0912345678' }

從最後一列輸出可知, 查詢參數已被解析成物件. 在 RESTful 風格的架構中, 會將路徑當作控制器, 行為, 與判斷參數的組合, 例如 :

/controller/action/a/b/c

controller : 控制器
action : 控制器要採取的動作
a/b/c : 判斷用的參數

利用 url 模組的 parse() 方法可解析出 pathname 字串, 再用 split() 即可取得判斷用的參數 :

var url=require('url');
var pathname=url.parse(req.url).pathname
var paths=pathname.split()



改用如下的 URL :

http://127.0.0.1:8080/controller/action/a/b/c?who=tony&gender=male

則伺服端程式輸出如下 :

D:\Node.js\test>node http_test_8.js
HTTP 伺服器正在監聽 8080 埠 ...
/controller/action/a/b/c?who=tony&gender=male
Url {
  protocol: null,
  slashes: null,
  auth: null,
  host: null,
  port: null,
  hostname: null,
  hash: null,
  search: '?who=tony&gender=male',
  query: 'who=tony&gender=male',
  pathname: '/controller/action/a/b/c',
  path: '/controller/action/a/b/c?who=tony&gender=male',
  href: '/controller/action/a/b/c?who=tony&gender=male' }
[Object: null prototype] { who: 'tony', gender: 'male' }


2. 取得 POST 方法之請求內容 :

POST 請求內容放在本文 (Body) 中, 如上所述, Node 的 http 模組不解析 Body 本文, 而是提供 req 物件的 data 事件由開發者自行接收解析. 例如下列範例中, 利用上面測試 7 的 HTTP Client 程式送出本文內容為 'ABC' 的 POST 請求, 伺服端則利用監聽 req 物件的 data 事件來取得 HTTP 的請求訊息本文 :

測試 9 : 利用 data 事件讀取 POST 請求內容 (1) [看原始碼]

var http=require('http')
var server=http.createServer(function(req, res) {
  req.on('data', function(chunk) {
    console.log(chunk);
    });
  req.on('end', function() {
    console.log('Request ends');
    res.end();
    });
  });
server.listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

執行上面測試 7 的 HTTP 客戶端程式 (不要用瀏覽器, 因為此處需要送出 HTTP Body 本文), 則此伺服端程式輸出如下 :

D:\Node.js\test>node test10.js
HTTP 伺服器正在監聽 8080 埠 ...

<Buffer 41 42 43 0a>
Request ends

此處輸出之位元組陣列 41 42 43 分別是 'ABC' 的 ASCII 碼, 如果要顯示 'ABC', 必須設定 req 物件的編碼為 'utf8', 例如 :

測試 10 : 利用 data 事件讀取請求內容 (2) [看原始碼]

var http=require('http')
var server=http.createServer(function(req, res) {
  req.setEncoding('utf8'); 
  req.on('data', function(chunk) {
    console.log(chunk);
    });
  req.on('end', function() {
    console.log('Request ends');
    res.end();
    });
  });
server.listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

同樣用 HTTP 客戶端程式提出 POST 請求, 伺服端輸出就變成字串 ABC, 而不是 Buffer 物件了 :

D:\Node.js\test>node server_request_data_event_1.js
HTTP 伺服器正在監聽 8080 埠 ...
ABC

其實不設定編碼, 呼叫 Buffer 物件 (chunk) 的 toString() 方法也會轉換成可讀字串 (預設 utf8 編碼), 例如 :

測試 11 : 利用 data 事件讀取請求內容 (2) [看原始碼]

var http=require('http')
var server=http.createServer(function(req, res) {
  req.on('data', function(chunk) {
    console.log(chunk.toString());
    });
  req.on('end', function() {
    console.log('Request ends');
    res.end();
    });
  });
server.listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

結果與用 setEncoder() 是一樣的.

3. 依據請求方法解析請求內容 :

當 http 模組的剖析器分析請求封包抽取標頭時, 其中第一行的第一個單字就是請求方法 (通常為大寫), 並將其設定為 req.method 屬性之值, 在伺服端程式的回呼函數中可利用 req.method 來判斷對此請求進行適當之操作. 架構如下 :

function(req, res) {
  switch (req.method) {
     case 'POST' :
         update(req, res);
         break;
     case 'GET' :
     default :
         get(req, res);
         break;
     }
  }

例如上面的 GET 與 POST 請求, 回呼函數可利用 req.method 來取得請求內容, 例如 :

測試 12 : 利用 req.metod 判斷請求方法 [看原始碼]


var http=require('http');
var url=require('url');
var server=http.createServer(function(req, res) {
  res.writeHead(200, {'ContentType': 'text/plain'});
  switch (req.method) {
     case 'POST' :
          req.setEncoding('utf8');
          req.on('data', function(chunk) {
            console.log(chunk);
            });
          req.on('end', function() {
            console.log('Request ends');
            res.end();
            });
         break;
     case 'GET' :
     default :
         console.log(url.parse(req.url, true).query);
         res.end('Hello World!');
         break;
     }
  });
server.listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...');

先在瀏覽器輸入 :

http://127.0.0.1:8080/controller/action/a/b/c?who=tony&gender=male

然後執行測試 7 的 HTTP 客戶端程式, 結果如下 :

D:\Node.js\test>node http_test_12.js
HTTP 伺服器正在監聽 8080 埠 ...
[Object: null prototype] { who: 'tony', gender: 'male' }
[Object: null prototype] {}
您好

Request ends

可見不論是 GET 或 POST 都能順利取得請求內容了. 這個功能在 PHP 非常簡單, 只要用 $_GET 與 $_POST 屬性即可取得, 但在 Node 卻如此麻煩, 因為 http 模組是 Node 的核心低階模組之故. 

以上的測試範例都是將欲輸出之訊息直接寫死在後端程式中, 這樣缺乏彈性, 理想作法應該是根據客戶端請求之網址路徑, 利用檔案模組 fs 讀取儲存於伺服器檔案系統中的靜態網頁檔案後回應給客戶端, 伺服器的處理程序如下 :
  1. 建立 HTTP 伺服器並監聽指定的 port
  2. 等待客戶端連線
  3. 收到客戶端連線
  4. 分析網址取得請求資源之檔案路徑 
  5. 檢查檔案是否存在, 是則讀取檔案回應給客戶端, 否則回應不存在
依據客戶端請求資源來讀取並輸出網頁需要用到 Node 內建常數 __dirname 以便取得程式目前所在之路徑, 另外還需要 path 與 fs 這兩個核心模組, fs 模組用法參考 :

Node.js 學習筆記 (三) : 檔案模組 fs 測試

而 path 模組主要是用來解析與合成檔案路徑, 因為不同的作業系統採用的路徑分隔符號不同 (Windows 用左斜 '\', Linux 用右斜 '/'), 使用 path 模組可以免除這個平台移植性問題, 用法參考 :

https://nodejs.org/api/path.html

主要會用到 path 模組的兩個方法 :
  1. path.basename(path [,ext]) :
    傳回路徑的最後部分 (目錄或檔案名稱), 不包含副檔名 ext
  2. path.join(seg1, seg2, ....)
    將路徑片段 seg1, seg2, ... 以系統路徑分隔符號依序串成完整路徑. 
例如 :

測試 13 : 利用 fs 讀取檔案並回應客戶端 [看原始碼]

var http=require('http');
var path=require('path');
var fs=require('fs');
http.createServer(function(req, res) {
  var filename=path.basename(req.url);   //請求之檔名
  //串接檔案路徑, 若無要求資源檔預設 index.htm
  var filepath=path.join(__dirname, 'public', (filename || 'index.htm'));
  console.log(__dirname);
  console.log(filename);
  console.log(filepath);
  fs.exists(filepath, function(exists) {
    if (exists) { //網頁存在
      fs.readFile(filepath, function(err, data) {
        if (!err) {
          res.writeHead(200, {'ContentType': 'text/html'});
          res.end(data);
          }
        else {console.log(err);}
        });
      }
    else {  //網頁不存在
      res.writeHead(404, {'ContentType': 'text/plain'});
      res.end('Not Found');
      }
    });
  }).listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...\n');

此例我先在目前程式目錄 D:\Node.js\test 下建立一個存放網頁檔的資料夾 public, 然後製作一個簡單的首頁 index.htm 放在 public 下. 程式先用 path.basename() 從 req.url 取得請求網址的最後部分 (即檔名, 若無為空字串), 然後用path.join() 串接伺服器程式所在目錄 __dirname, 網頁檔所在目錄 public, 以及網頁檔名. 此處請求中若無網頁檔名, 則 filename 為空字串, 這時應該回應首頁檔, 故用 || 與 index.htm 做 or 運算. 如果不這麼做, 當客戶端不指定網頁檔名時 (例如 http://localhost:8080 時), join() 會出現如下錯誤 :

{ [Error: EISDIR: illegal operation on a directory, read] errno: -4068, code: 'EISDIR', syscall: 'read' }

執行上述瀏覽器程式後, 在瀏覽器網址列分別輸入 :

http://127.0.0.1:8080
http://127.0.0.1:8080/index.htm
http://127.0.0.1:8080/a.htm

在伺服器視窗輸出如下 :

D:\Node.js\test>node test12.js
HTTP 伺服器正在監聽 8080 埠 ...

D:\Node.js\test                               (請求 http://127.0.0.1:8080)

D:\Node.js\test\public\index.htm
D:\Node.js\test                               (請求 http://127.0.0.1:8080/index.htm)
index.htm
D:\Node.js\test\public\index.htm
D:\Node.js\test                               (請求 http://127.0.0.1:8080/a.htm)
a.htm
D:\Node.js\test\public\a.htm

由於 a.htm 不存在, 因此第三次請求會得到 "Not Found" 回應.

上面測試 13 根據 URL 最後面的檔名來從指定目錄 public 下讀取網頁檔回應客戶端, 但對於 RESTful 風格的架構而言, 不會把資源檔名放在 URL 中, 而是用路由器來對應 controller 與實際檔案, 例如 :

http://127.0.0.1:8080
http://127.0.0.1:8080/about

對應 controller 為 index 與 about, 伺服器需分別讀取 index.htm 與 about.htm 回應客戶端. 在下面範例中, 在伺服器程式所在目錄的 public 子目錄下新增 about.htm 與 page_not_found.htm 兩個網頁, 實作 URL 路由器如下 :

測試 14 : 利用 fs 讀取檔案並回應客戶端-實作路由器 [看原始碼]

var http=require('http');
var path=require('path');
var url=require('url');
var fs=require('fs');
http.createServer(function(req, res) {
  var folder=path.join(__dirname, 'public');  //串接目錄
  var url_obj=url.parse(req.url);  //解析 URL
  console.log(folder);
  console.log(url_obj.pathname);  //取得路徑名稱 (controller)
  switch(url_obj.pathname) {    //依據 controller 選取路由
    case '/':
         filepath=folder + "/index.htm";
         break;
    case '/about':
         filepath=folder + "/about.htm";
         break;
    default:
         filepath=folder + '/page_not_found.htm';
         break;
    }
  fs.readFile(filepath, function(err, data) {
    if (!err) {
      res.writeHead(200, {'ContentType': 'text/html'});
      res.end(data);
      }
    else {console.log(err);}
    });
  }).listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...\n');

此例省略了檔案存在與否的檢查, 而且因為有 page_not_found.htm, 所以全部都用 200 狀態碼回應. 凡是沒有定義在 Switch 路由器中的 pathname (controller) 都會以 page_not_found.htm 回應.

上例的路由器做法也可擴展到有表單 (form) 提交的網頁, 例如登入表單網頁 可賦予 controller 名稱 login, 處理此路由時就直接用 fs 讀取 login.htm, 而處理程式可賦予 controller 名稱 check, 主要是核對帳號密碼. 在下面範例中, 為求簡單不進行實際檢查, 而是直接回應用戶所提交之帳號密碼. 登入表單網頁如下 :

<!doctype html>
<html>
 <head>
  <meta charset="UTF-8">
  <title>登入</title>
 </head>
 <body>
   <form method="POST" action="/check">
     <h1>請登入</h1>
     <p>請輸入帳號</p>
     <input type="text" name="account">
     <p>請輸入密碼</p>
     <input type="text" name="password">
     <p><button>登入</butto></p>
   </form>
 </body>
</html>

此表單中有 account 與 password 兩個欄位, 按登入鈕會向控制器 /check 以 POST 方法提交此表單. 伺服器程式修改為如下 :

測試 15 : 利用 fs 讀取檔案並回應客戶端-實作路由器 [看原始碼]

var http=require('http');
var path=require('path');
var url=require('url');
var fs=require('fs');
var qstr=require('querystring');
http.createServer(function(req, res) {
  var folder=path.join(__dirname, 'public');
  var url_obj=url.parse(req.url);
  console.log(folder);
  console.log(url_obj.pathname);
  switch(url_obj.pathname) { 
    case '/':
         filepath=folder + "/index.htm";
         break;
    case '/about':
         filepath=folder + "/about.htm";
         break;
    case '/login':
         filepath=folder + "/login.htm";
         break;
    case '/check':
         if (req.method=="POST") { //處理登入表單
           req.setEncoding('utf8');
           var body='';
           req.on('data', function(chunk) {  //監聽 data 事件 (接收 body)
             body += chunk;
             });
           req.on('end', function() {
             res.writeHead(200, {'Content-Type': 'text/html'});
             res.write("<html><head><title>Check</title></head><body>");
             res.write("Content-Type:: " + req.headers["content-type"] + '<br>');
             res.write("BODY: " + body + '<br>');
             res.write("account: " + qstr.parse(body).account + '<br>');
             res.write("password: " + qstr.parse(body).password + '<br>');
             res.end("</body></html>");
             });
           }
         else { //不是 POST 方法
           filepath=folder + '/page_not_found.htm';
           }
         break;
    default:
         filepath=folder + '/page_not_found.htm';
         break;
    }
  fs.readFile(filepath, function(err, data) {
    if (!err) {
      res.writeHead(200, {'ContentType': 'text/html'});
      res.end(data);
      }
    else {console.log(err);}
    });
  }).listen(8080, 'localhost');
console.log('HTTP 伺服器正在監聽 8080 埠 ...\n');

此程式使用 querystring 模組的 parse() 方法來解析表單提交請求內容中所帶的參數 (欄位), 只要將參數字串 p1=v1&p2=v2&p3=v3 ... 傳入, 傳回值是一個物件, {p1:v1, p2:v2, p3:v3, ...}, 用鍵當索引即可取得參數值, 其介面如下 :

querystring.parse(str[, sep[, eq[, options]]])
瀏覽器網址輸入 :

http://127.0.0.1:8080/login

結果如下 :



輸入帳號密碼按登入結果如下 :




OK, 終於把 http 測完啦! 雖然一般不會用 http 模組進行開發, 但走一遍會對 HTTP 底層運作較清楚.

參考 :

node.js伺服器實戰(4) - 內建模組與http伺服器開發的必備知識
基於 Node.js 的智慧家庭
Node.js 在企業系統開發應用
Node.js 與 Express 開發實戰 (Jollen 課程)
Node.js 雲端技術與軟體思惟 (Jollen 的電子書)
How to make an HTTP POST request in node.js?
How can I get the full object in Node.js's console.log(), rather than '[Object]'?
淺談 HTTP Method:表單中的 GET 與 POST 有什麼差別?
HTTP POST GET 本質區別詳解 (轉載)

沒有留言 :