HTTP面試指南

acdseen發表於2019-02-16

前言


或許你在面試時遇到過這樣的問題:從輸入URL到瀏覽器顯示頁面發生了什麼?
簡單的回答就是:

  1. DNS解析
  2. TCP建立連線
  3. 傳送HTTP請求
  4. 伺服器處理請求

    • 如果有快取直接讀快取
    • 沒有快取返回響應內容
  5. TCP斷開連線
  6. 瀏覽器解析渲染頁面

如果你覺得這樣回答過於簡單,不如來深入瞭解一下吧。

網路基礎


在此之前,先了解一下TCP/IP基礎知識。

TCP/IP參考模型

  • 早期的TCP/IP模型是一個四層結構,從下往上依次是網路介面層、網際網路層、傳輸層和應用層,後來將網路介面層劃分為了物理層和資料鏈路層

    • 應用層(Application)提供網路與使用者應用軟體之間的介面服務
    • 傳輸層(Transimission)提供建立、維護和取消傳輸連線功能,負責可靠地傳輸資料(PC)
      傳輸層有兩個性質不同的協議:TCP(傳輸控制協議)和UDP(使用者資料包協議)
    • 網路層(Network)處理網路間路由,確保資料及時傳送(路由器)
    • 資料鏈路層(DataLink)負責無錯傳輸資料,確認幀、發錯重傳等(交換機)
    • 物理層(Physics)提供機械、電氣、功能和過程特性(網路卡、網線、雙絞線、同軸電纜、中繼器)

各層常用協議

這裡可以看到HTTP協議是構建於TCP之上,屬於應用層協議

具體過程

1. DNS解析

DNS服務是和HTTP協議一樣位於應用層的協議,提供域名到IP地址的解析服務。

得到IP地址後就可以建立連線了,這裡還有兩個知識需要了解:

持久連線

持久連線(也稱為HTTP keep-alive)的特點是,只要任意一段沒有提出斷開連線,就保持TCP連線狀態。

管線化

持久連線建立後就可以使用管線化傳送了,可以同時併發多個請求,不用等待一個接一個的響應。(在這裡我想到了流的pipe方法。)

2. TCP連線與斷開

2.1 TCP報文格式


大致說一下:

  1. 計算機通過埠號識別訪問哪個服務,比如http;源埠號進行隨機埠,目的埠決定哪個程式進行接收
  2. 資料序號和確認序號用於保障傳輸資料的完整性和順序
  3. 需要注意的是TCP的連線、傳輸和斷開都受六個控制位的指揮(比如三次握手和四次揮手)

    • PSH(push急迫位)快取區將滿,立刻速度傳輸
    • RST(reset重置位)連線斷了重新連線
    • URG(urgent緊急位)緊急訊號
    • ACK(acknowlegement確認)為1就表示確認號
    • SYN(synchronous建立聯機)同步序號位 TCP建立連線時將這個值設為1
  4. 使用者資料儲存了應用層生成的HTTP報文

瞭解了這些,那麼開始講重點

2.2 TCP三次握手和四次揮手

三次握手

  1. 客戶端先傳送一個帶SYN標誌的資料包給伺服器端
  2. 伺服器收到後,回傳一個帶有SYN/ACK標誌的資料包表示確認收到
  3. 客戶端再傳送一個帶SYN/ACK標誌的資料包,代表握手結束

四次揮手

  1. 客戶端向伺服器發出了FIN報文段
  2. 伺服器收到後,回覆一個ACK應答
  3. 伺服器也向客戶端傳送一個FIN報文段,隨後關閉了伺服器端的連線
  4. 客戶端收到之後,又向伺服器回覆一個ACK應答,過了一段計時等待,客戶端也關閉了連線(計時等待是為了確認伺服器端已正常關閉)

四次揮手並不是必然的,當伺服器已經沒有內容發給客戶端了,就直接傳送FIN報文段,這樣就變成了三次揮手。

3. HTTP請求/響應

3.1 HTTP報文

HTTP報文大致可分為報文首部和報文主體兩塊,兩者由空行(就相當於用了兩個換行符rnrn)來劃分。報文主體並不是一定要有的。

3.1.1 請求報文

常用請求行方法:

  • GET 獲取資源
  • POST 向伺服器端傳送資料,傳輸實體主體
  • PUT 傳輸檔案
  • HEAD 獲取報文首部
  • DELETE 刪除檔案
  • OPTIONS 詢問支援的方法
  • TRACE 追蹤路徑

3.1.2 響應報文

說到響應報文,就必要談到狀態碼:

  • 2XX 成功

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

    • 301(Moved Permanently) 永久重定向
    • 302(Found) 臨時重定向,規範要求方法名不變,但是都會改變
    • 303(See Other) 和302類似,但必須用GET方法
    • 304(Not Modified) 狀態未改變 配合(If-Match、If-Modified-Since、If-None_Match、If-Range、If-Unmodified-Since) (通常快取會返回304狀態碼)
  • 4XX 客戶端錯誤

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

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

3.1.3 首部

通用首部

首部欄位名 說明
Cache-Control 控制快取行為
Connection 連線的管理
Date 報文日期
Pragma 報文指令
Trailer 報文尾部的首部
Trasfer-Encoding 指定報文主體的傳輸編碼方式
Upgrade 升級為其他協議
Via 代理伺服器資訊
Warning 錯誤通知

請求首部

首部欄位名 說明
Accept 使用者代理可處理的媒體型別
Accept-Charset 優先的字符集
Accept-Encoding 優先的編碼
Accept-Langulage 優先的語言
Authorization Web認證資訊
Expect 期待伺服器的特定行為
From 使用者的電子郵箱地址
Host 請求資源所在的伺服器
If-Match 比較實體標記
If-Modified-Since 比較資源的更新時間
If-None-Match 比較實體標記
If-Range 資源未更新時傳送實體Byte的範圍請求
If-Unmodified-Since 比較資源的更新時間(和If-Modified-Since相反)
Max-Forwards 最大傳輸條數
Proxy-Authorization 代理伺服器需要客戶端認證
Range 實體位元組範圍請求
Referer 請求中的URI的原始獲取方
TE 傳輸編碼的優先順序
User-Agent HTTP客戶端程式的資訊

響應首部

首部欄位名 說明
Accept-Ranges 是否接受位元組範圍
Age 資源的建立時間
ETag 資源的匹配資訊
Location 客戶端重定向至指定的URI
Proxy-Authenticate 代理伺服器對客戶端的認證資訊
Retry-After 再次傳送請求的時機
Server 伺服器的資訊
Vary 代理伺服器快取的管理資訊
www-Authenticate 伺服器對客戶端的認證

實體首部

首部欄位名 說明
Allow 資源可支援的HTTP方法
Content-Encoding 實體的編碼方式
Content-Language 實體的自然語言
Content-Length 實體的內容大小(位元組為單位)
Content-Location 替代對應資源的URI
Content-MD5 實體的報文摘要
Content-Range 實體的位置範圍
Content-Type 實體主體的媒體型別
Expires 實體過期時間
Last-Modified 資源的最後修改時間

3.2 實現客戶端訪問服務端

建立HTTP服務端

let http = require(`http`);
let app = http.createServer((req, res) => {// req是可讀流/res是可寫流
    // 獲取請求報文資訊
    let method = req.method;// 方法
    let httpVersion = req.httpVersion;// HTTP版本
    let url = req.url;
    let headers = req.headers;
    console.log(method, httpVersion, url, headers);
    // 獲取請求體(如果請求體的資料大於64k,data事件會被觸發多次)
    let buffers = [];
    req.on(`data`, data => {
        buffers.push(data);
    })
    req.on(`end`, () => {
        console.log(Buffer.concat(buffers).toString());
        res.write(`hello`);
        res.end(`world`);
    })
})
// 監聽伺服器事件
app.on(`connection`, socket => {
    console.log(`建立連線`);
});
app.on(`close`, () => {
    console.log(`伺服器關閉`)
});
app.on(`error`, err => {
    console.log(err);
});
app.listen(3000, () => {
    console.log(`server is starting on port 3000`);
});

建立客戶端

let http = require(`http`);
let options = {
    hostname: `localhost`,
    port: 3000,
    path: `/`,
    method: `GET`,
    // 設定實體首部 告訴服務端我當前要給你發什麼樣的資料
    headers: {
        `content-Type`: `application/x-www-form-urlencoded`,
        `Content-Length`: 15
    }
}
let req = http.request(options);
req.on(`response`, res => {
    res.on(`data`, chunk => {
        console.log(chunk.toString());
    });
});
req.end(`name=js&&age=22`)

然後使用node執行我們的客戶端

說了這麼多,你可能已經大致瞭解了
從輸入URL到瀏覽器顯示頁面發生了什麼,不用多說,我們再來看一下快取

4. 快取

4.1 快取作用

  • 減少了冗餘的資料傳輸,節省了網費。
  • 減少了伺服器的負擔, 大大提高了網站的效能
  • 加快了客戶端載入網頁的速度

4.2 快取分類

強制快取

強制快取:說白了就是第一次請求資料時,服務端將資料和快取規則一併返回,下一次請求時瀏覽器直接根據快取規則進行判斷,有就直接讀快取資料庫,不用連線伺服器;沒有,再去找伺服器。

對比快取
  • 對比快取,顧名思義,需要進行比較判斷是否可以使用快取。
  • 瀏覽器第一次請求資料時,伺服器會將快取標識與資料一起返回給客戶端,客戶端將二者備份至快取資料庫中。
  • 再次請求資料時,客戶端將備份的快取標識傳送給伺服器,伺服器根據快取標識進行判斷,判斷成功後,返回304狀態碼,通知* 客戶端比較成功,可以使用快取資料。

4.3 請求流程

第一次請求,此時沒有快取

第二次請求

從上張圖我們可以看到,判斷快取是否可用,有兩種方式

  • ETag是實體標籤的縮寫,根據實體內容生成的一段hash字串,可以標識資源的狀態。當資源發生改變時,ETag也隨之發生變化。ETag是Web服務端產生的,然後發給瀏覽器客戶端。
  • Last-Modified是此資源的最後修改時間,

    • 如果客戶端在請求到的資源中發現實體首部裡有Last-Modified宣告,再次請求就會在頭裡帶上if-Modified-Since欄位
    • 服務端收到請求後發現if-Modified-Since欄位則與被請求資源的最後修改時間進行對比

說了這麼多,不如直接來實現一下快取

通過最後修改時間來判斷快取是否可用

let http = require(`http`);
let url = require(`url`);
let path = require(`path`);
let fs = require(`fs`);
let mime = require(`mime`);
let app = http.createServer((req, res) => {
    // 根據url獲取客戶端要請求的檔案路徑
    let { parsename } = url.parse(req.url);
    let p = path.join(__dirname, `public`, `.` + pathname);
    // fs.stat()用來讀取檔案資訊,檔案最後修改時間就是stat.ctime
    fs.stat(p, (err, stat) => {
        if (!err) {
            let since = req.headers[`if-modified-since`];//客戶端發來的檔案最後修改時間
            if (since) {
                if (since === stat.ctime.toUTCString()) {//最後修改時間相等,讀快取
                    res.statusCode = 304;
                    res.end();
                } else {
                    sendFile(req, res, p, stat);//最後修改時間不相等,返回新內容
                }
            } else {
                sendError(res);
            }
        }
    })
})
function sendError(res) {
    res.statusCode = 404;
    res.end();
}
function sendFile(req, res, p, stat) {
    res.setHeader(`Cache-Control`, `no-cache`);// 設定通用首部欄位 控制快取行為
    res.setHeader(`Last-Modified`, stat.ctime.toUTCString());// 實體首部欄位 資源最後修改時間
    res.setHeader(`Content-Type`, mime.getType(p) + `;charset=utf8`)
    fs.createReadStream(p).pipe(res);
}
app.listen(3000, () => {
    console.log(`server is starting on port 3000`);
});

最後修改時間存在問題:
1. 某些伺服器不能精確得到檔案的最後修改時間, 這樣就無法通過最後修改時間來判斷檔案是否更新了。
2. 某些檔案的修改非常頻繁,在秒以下的時間內進行修改. Last-Modified只能精確到秒。
3. 一些檔案的最後修改時間改變了,但是內容並未改變。 我們不希望客戶端認為這個檔案修改了。
4. 如果同樣的一個檔案位於多個CDN伺服器上的時候內容雖然一樣,修改時間不一樣。

通過ETag來判斷快取是否可用

ETag就是根據檔案內容來判斷,說白了就是採用MD5(md5並不叫加密演算法,它不可逆,應該叫摘要演算法)產生資訊摘要,用摘要來進行比對。

let http = require(`http`);
let url = require(`url`);
let path = require(`path`);
let fs = require(`fs`);
let mime = require(`mime`);
// crypto是node.js中實現加密和解密的模組 具體詳解請自行了解
let crypto = require(`crypto`);
let app = http.createServer((req, res) => {
    // 根據url獲取客戶端要請求的檔案路徑
    let { parsename } = url.parse(req.url);
    let p = path.join(__dirname, `public`, `.` + pathname);
    // fs.stat()用來讀取檔案資訊,檔案最後修改時間就是stat.ctime
    fs.stat(p, (err, stat) => {
        let md5 = crypto.createHash(`md5`);//建立md5物件
        let rs = fs.createReadStream(p);
        rs.on(`data`, function (data) {
            md5.update(data);
        });
        rs.on(`end`, () => {
            let r = md5.digest(`hex`); // 對檔案進行md5加密
            // 下次就拿最新檔案的加密值 和客戶端請求來比較
            let ifNoneMatch = req.headers[`if-none-match`];
            if (ifNoneMatch) {
                if (ifNoneMatch === r) {
                    res.statusCode = 304;
                    res.end();
                } else {
                    sendFile(req, res, p, r);
                }
            } else {
                sendFile(req, res, p, r);
            }
        });
    })
});
function sendError(res) {
    res.statusCode = 404;
    res.end();
}
function sendFile(req, res, p, stat) {
    res.setHeader(`Cache-Control`, `no-cache`);// 設定通用首部欄位 控制快取行為
    res.setHeader(`Etag`, r);// 響應首部欄位 資源的匹配資訊
    res.setHeader(`Content-Type`, mime.getType(p) + `;charset=utf8`)
    fs.createReadStream(p).pipe(res);
}
app.listen(3000, () => {
    console.log(`server is starting on port 3000`);
});

最後

想深入學習http的同學,我推薦一本書《圖解HTTP》
本人水平有限,有不足之處,望大家指出改正。

相關文章