Node和http:一本通【附tcp實現http小程式碼】

Cris_冷崢子發表於2018-04-04
  • TCP與HTTP
  • HTTP報文格式
    • 請求報文
      • 請求行
      • 請求頭
      • 請求體
    • 響應報文
      • 響應行
      • 響應頭
      • 響應體
    • 關於url
    • 關於method
    • 關於請求體
    • 關於狀態碼
      • 2xx 請求正常
      • 3xx 快取和重定向
      • 4xx 客戶端錯誤
      • 5xx 服務端錯誤
  • 在node中獲取請求報文
  • 在node中建立http伺服器與客戶端
    • 建立一個伺服器
    • 建立一個客戶端
      • 響應注意事項
  • 關於響應實體和Content-Type
  • 用tcp實現一個簡單的http伺服器
    • 註冊響應回撥
    • parser分離請求報文與發射request
    • 解析請求頭
    • demo程式碼
  • http其它
    • 非短連線
    • 管線化

pre-notify

previously:

TCP是HTTP的基石,對tcp還不是灰常清楚的小夥伴可以看看我的這篇NodeJS和TCP:一本通

本文主要用於個人知識梳理,可能篇幅較長,So專門在開頭copy了一份目錄以便配合右下方連線點選分段閱讀(づ ̄ 3 ̄)づ

TCP與HTTP

首先,HTTP 是基於 TCP 協議的,只有當tcp連線順利建立時,瀏覽器客戶端才能向伺服器傳送http請求。(詳見TCP三次握手)

TCP 讓讓一臺pc端對端的連線上另一臺pc後,兩臺機器之間可以互通資料,但這個資料並沒有經過什麼額外的加工,是純粹的資料,即使用者輸入什麼資料,伺服器就會拿到什麼資料。

HTTP 有些許不一樣, 一個http請求會將使用者的輸入經過瀏覽器包裝後再傳送給服務端,而包裝後的資料即是我們說的 http請求報文。對應的,伺服器要向瀏覽器回覆響應,也需要經過一層包裝,包裝成 http響應報文再響應給客戶端。

從傳輸層面上來講,http僅僅是tcp的一項子集,一種再封裝。

HTTP報文格式

HTTP報文格式 是對 http協議最直觀的闡釋,也是我們學習http協議最有效率的手段。

HTTP報文主要分文兩大類,請求報文響應報文,請求報文和響應報文又都分為 三部分,並且頭和體這兩部分之間有 空行 隔開。

請求報文

以下圖片出自於Android網路程式設計隨想錄(2)

Node和http:一本通【附tcp實現http小程式碼】

請求行

請求行分為以下三部分,每個部分之間用空格隔開

  • method: 主要是用來標識是要傳資料還是獲取資料
  • path: url地址
  • protocol http的協議版本號

請求頭

請求頭和請求行不一樣,它是多行的,每一行都是一組鍵值對,鍵和值之間用:空格隔開。

  • 請求首部: Host:xxx.com
  • 通用首部: 請求和響應都有的,比如 Connection:keep-alive
  • 實體首部: 以 Content-開頭的
  • 其它

請求體

正經的,使用者想要傳給伺服器的資料

響應報文

以下圖片出自於Android網路程式設計隨想錄(2)

Node和http:一本通【附tcp實現http小程式碼】
Node和http:一本通【附tcp實現http小程式碼】

響應行

  • protocol http協議版本號
  • statusCode 狀態碼
  • statusCode-reason 原因短語,狀態碼的解釋

響應頭

同請求頭

響應體

同請求體

關於url

客戶端封裝請求報文時會將url中的hash給去掉(query是會保留的)

Node和http:一本通【附tcp實現http小程式碼】
故伺服器端是永遠接收不到客戶端的hash值的

關於method

GET 獲取資源

POST 想伺服器端傳送資料,傳輸實體主體

PUT 傳輸檔案 , RESTful中是更新修改操作

HEAD 獲取報文首部

DELETE 刪除檔案

OPTIONS 詢問支援的方法 ,試探方法,比如跨域,會先詢問服務端能否跨域

TRACE 追蹤路徑

關於請求體

當提交的表單只包含一條資料時,且表單型別為預設時,請求報文長這樣

Node和http:一本通【附tcp實現http小程式碼】

如果是多條資料,會用空行隔開

Node和http:一本通【附tcp實現http小程式碼】

但如果是multipart/form-data編碼時,請求體中多端資料間則是用特殊的分隔符來隔開的

即使只有一段資料也會用特殊的分隔符包裹住

Node和http:一本通【附tcp實現http小程式碼】

多段資料時

Node和http:一本通【附tcp實現http小程式碼】

關於狀態碼

狀態碼主要分為五大類

  • 1xx imformational(資訊狀態碼) websocket
  • 2xx Success
  • 3xx Redirect
  • 4xx Client Error
  • 5xx Server Error

2xx

  • 200 OK 客戶端傳送過來的資料被正常處理
  • 204 Not Content 正常響應,沒有實體
  • 206 Partial Content 範圍請求,返回部分資料,響應報文中由Content-Range指定內容

3xx

  • 301 Moved Permanently 永久重定向
  • 302 Found 臨時重定向 不一定去哪 跳轉到不同的地方 Nginx
  • 303 See Other和302類似,但必須用GET方法
  • 304 Not Modified 狀態未改變 需要和(if-Match、if-Modified-since、if-None_Match、if-Range、if-Unmodified-since)配合使用
  • 307 Temporary Redirect 臨時重定向,不改變請求方法

4xx

  • 400 Bad Request 請求報文語法錯誤
  • 401 unauthorized 需要認證
  • 403 Forbidden 伺服器拒絕訪問對應的資源
  • 404 Not Found 伺服器上無法找到資源

5xx

  • 500 Internal Server Error 伺服器故障
  • 503 Service Unavailable 伺服器處於超負載或正在停機維護

在node中獲取請求報文

console.log(req.method); //請求方法
console.log(req.url); //url地址
console.log(req.httpVersion); //http協議版本
console.log(req.headers); //請求頭
複製程式碼
// 獲取請求體
req.on('data',function(data){
    console.log(data.toString());
})
複製程式碼

在node中建立http伺服器與客戶端

建立一個伺服器

let http = require('http');
let server = http.createServer();
server.on('request',function(req,res){
  res.end('ok');
});
server.listen(8080);
複製程式碼

你可以可以這樣簡寫

let http = require('http');
let server = http.createServer(function(req,res){
  res.end('ok');
});
server.listen(8080);
複製程式碼

建立一個客戶端

let http = require('http');
let options = {
  host:'localhost'
  ,port:8080
  ,method:'POST'
  ,headers:{
    'Content-Type':'application/x-www-form-urlencoded'
//      ,'Content-Length':15 //一般來說這個數值會自動計算
  }
}

let req = http.request(options);
req.write('id=999');
// 只有呼叫end才會真正向伺服器傳送請求
req.end();

// 當客戶端收到伺服器響應的時候觸發
req.on('response',function(res){ //只有一個引數
  console.log(res.statusCode);
  console.log(res.headers);
  let result = [];
  res.on('data',function(data){
    result.push(data);
  })
  res.on('end',function(data){
    let str = Buffer.concat(result);
    console.log(str.toString());
  })

})
複製程式碼

還可以把request()on('response')合在一起寫,不過此時無法像服務端主動傳送頭以外的資料(只有呼叫http.request(opt)才會返回req,才能呼叫write())。

http.get(options,function(res){
    ...
    res.on('data',function(chunk){
      ...
    });
    res.on('end',function(){
      ...
    })
})
複製程式碼

響應注意事項

end後無法繼續寫入(可寫流規定)

res.write()
res.end()

<<<
Erorr::write after end!
複製程式碼

設定狀態碼以後 會自動補全狀態碼文字描述

res.statusCode = 200; //預設
複製程式碼

我們不僅可以設定,也可以刪除一個準備傳送給客戶端的響應頭

res.setHeader('Content-Type','text/plain');
res.setHeader('name','ahhh');
res.removeHeader('name'); //刪除一個準備設定的頭
複製程式碼

writeHead相較於setHead能同時設定多個頭,並且連狀態碼一起設定。但它和setHeader最大的不同在於,writeHeader一旦呼叫會立刻傳送。

console.log(res.headersSent) //false
res.writeHead(200,{'Content-Type':'text/plain'}); //writeHead設定完後不能再呼叫res.setHeader,因為呼叫writeHead會直接把頭髮送出去
// res.setHeader('name','zfpx'); //Can't set headers after they are sent.
console.log(res.headersSent) //true
複製程式碼

setHeader設定的頭是在呼叫write方法之後才會傳送,另外需要注意的一點是頭必須在write之前設定。

console.log('--- --- ---')
console.log(res.headersSent); //false
res.setHeader('name','ahhh');
console.log(res.headersSent) //false
res.write('ok');
console.log(res.headersSent) //true
res.end('end');
console.log(res.headersSent) //true  
console.log('--- --- ---')
複製程式碼

關於響應實體和Content-Type

客戶端傳送請求和服務端回以響應時都需要設定這個Content-Type頭,

對於服務端來說,它需要拿這個頭解析客戶端傳送過來的實體資料,(縱然不少情況下,請求都沒有實體部分,比如get請求)。

let buffers = [];
req.on('data',function(chunk){
    buffers.push(chunk);
})
req.on('end',function(){
    let content = Buffer.concat(buffers).toString();
    if(contentType === 'application/json'){
      console.log(JSON.parse(content).name);
    }else if(contentType === 'application/x-www-form-urlencoded'){
      let queryString = require('querystring');
      console.log(queryString.parse(content).name);
    }
})
複製程式碼

實際情況下,如果有請求體(實體資料),可能會很複雜。(前面的請求體部分)

並且服務端響應客戶端資料時也需要發給它這麼一個頭以便客戶端解析資料,而這個Content-Type往往和要返回給客戶端的資原始檔的字尾名是相關聯的,So我們一般使用一個npm包幫我們進行轉換,

...
let mime = require('mime');
...
res.setHeader('Content-type',mime.getType(filepath)+';charset=utf-8');
複製程式碼

用tcp實現一個簡單的http伺服器

http伺服器相較於tcp伺服器其實就多做了一件事,即解析請求頭,剩下的請求體部分該on data還是一樣on data監聽即可。

但需要注意的是,data,即請求體是什麼時候 發射 的呢?嗯,是在分離出請求頭並解析完畢請求頭後發射的。

Node和http:一本通【附tcp實現http小程式碼】

註冊響應回撥

// http.createServer(function(req,res){})

server.on('request',function(req,res){
    //do somtheing like you are doing at tcp
}
複製程式碼

parser分離請求報文與發射request

let server = net.createServer(function(socket){
  parser(socket,function(req,res){
    server.emit('request',req,res);
  });
});

server.listen(3000);
複製程式碼

這裡分離請求報文是指將 請求體 與其它兩部分(請求行,請求頭)分成兩塊,怎麼分?嗯,前面說過,請求體和請求頭之間有一行空行作為分隔,\r\n\r\n 或則說 0x0d 0x0a 0x0d 0x0a

嗯。。原理就是這麼個原理咯,但有一個坑。

socket作為一個雙工流,在讀取客戶端發來的資料時和普通的可讀流一樣有一個預設讀取值,So,這可能導致你要讀很多下才摸得到\r\n\r\n這個分隔符,

並且可能最終讀到\r\n\r\n 時還會多讀出一些不屬於"請求頭"部分資料,我們還需要將這部分多餘的屬於請求體的資料回去,以便發射requset事件時我們能拿取到完整的 請求體 資料。

嗯。。坑比較多,這裡就不獻醜貼程式碼了,有興趣的小夥伴可以自己去實現以下,有兩點需要注意

  • 讀取時使用readable暫停模式來讀取(以便把多餘的資料按回去)

  • 推薦用0x0d 0x0a這種buffer級別的來判斷而不是\r\n這種字元級別,因為字元可能會導致亂碼不好判斷,需要處理的情況就更多了。

解析請求頭

這裡的請求頭 包括 請求行與請求頭

function parseHeader(head){
  let lines = head.split(/\r\n/);
  let start = lines.shift();
  let lr = start.split(' ');
  let method = lr[0];
  let url = lr[1];
  let httpVersion = lr[2].split('/')[1];
  let headers = {};
  lines.forEach(line=>{
    let col = line.split(': '); //注意這裡的空格
    headers[col[0]] = col[1];
  });
  return {url,method,httpVersion,headers};
}
複製程式碼

demo程式碼

倉庫:點我點我!

http其它

非短連線

雖然http不想tcp一樣可以一直保持長連線,但我們說過它畢竟是基於tcp的,所以也具有保持連線的能力。

在響應頭中往往會包含 Connection:keep-alive 字樣的欄位,就是讓瀏覽器保持連線不要中斷,即使接受完響應資訊,這個連線一般也能保持一定的時間(大概,嗯,2min?)

管線化

http傳送請求時如果包含多個,可以不用等待就能直接傳送下一個請求。

Chrome 併發量約為6個,Firefox 4個。


To be Continue...

相關文章