前言
或許你在面試時遇到過這樣的問題:從輸入URL到瀏覽器顯示頁面發生了什麼?
簡單的回答就是:
- DNS解析
- TCP建立連線
- 傳送HTTP請求
-
伺服器處理請求
- 如果有快取直接讀快取
- 沒有快取返回響應內容
- TCP斷開連線
- 瀏覽器解析渲染頁面
如果你覺得這樣回答過於簡單,不如來深入瞭解一下吧。
網路基礎
在此之前,先了解一下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報文格式
大致說一下:
- 計算機通過埠號識別訪問哪個服務,比如http;源埠號進行隨機埠,目的埠決定哪個程式進行接收
- 資料序號和確認序號用於保障傳輸資料的完整性和順序
-
需要注意的是TCP的連線、傳輸和斷開都受六個控制位的指揮(比如三次握手和四次揮手)
- PSH(push急迫位)快取區將滿,立刻速度傳輸
- RST(reset重置位)連線斷了重新連線
- URG(urgent緊急位)緊急訊號
- ACK(acknowlegement確認)為1就表示確認號
- SYN(synchronous建立聯機)同步序號位 TCP建立連線時將這個值設為1
- 使用者資料儲存了應用層生成的HTTP報文
瞭解了這些,那麼開始講重點
2.2 TCP三次握手和四次揮手
三次握手
- 客戶端先傳送一個帶SYN標誌的資料包給伺服器端
- 伺服器收到後,回傳一個帶有SYN/ACK標誌的資料包表示確認收到
- 客戶端再傳送一個帶SYN/ACK標誌的資料包,代表握手結束
四次揮手
- 客戶端向伺服器發出了FIN報文段
- 伺服器收到後,回覆一個ACK應答
- 伺服器也向客戶端傳送一個FIN報文段,隨後關閉了伺服器端的連線
- 客戶端收到之後,又向伺服器回覆一個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》。
本人水平有限,有不足之處,望大家指出改正。