深入學習 Node.js Net
網路上的兩個程式通過一個雙向的通訊連線實現資料的交換,這個連線的一端稱為一個 socket(套接字),因此建立網路通訊連線至少要一對埠號。 socket 本質是對 TCP/IP 協議棧的封裝,它提供了一個針對 TCP 或者 UDP 程式設計的介面,並不是另一種協議 。通過 socket,你可以使用 TCP/IP 協議。
Socket的英文原義是“孔”或“插座”。作為BSD UNIX的 ofollow,noindex">程序通訊 機制,取後一種意思。通常也稱作” 套接字 “,用於描述IP地址和埠,是一個通訊鏈的控制代碼,可以用來實現不同虛擬機器或不同計算機之間的通訊。在Internet上的 主機 一般運行了多個服務軟體,同時提供幾種服務。每種服務都開啟一個Socket,並繫結到一個埠上,不同的埠對應於不同的服務。Socket正如其英文原義那樣,像一個多孔插座。一臺主機猶如佈滿各種插座的房間,每個插座有一個編號,有的插座提供220伏交流電, 有的提供110伏交流電,有的則提供有線電視節目。 客戶軟體將插頭插到不同編號的插座,就可以得到不同的服務。—— 百度百科
關於 Socket,可以總結以下幾點:
- 它可以實現底層通訊,幾乎所有的應用層都是通過 socket 進行通訊的。
- 對 TCP/IP 協議進行封裝,便於應用層協議呼叫,屬於二者之間的中間抽象層。
- TCP/IP 協議族中,傳輸層存在兩種通用協議: TCP、UDP,兩種協議不同,因為不同引數的 socket 實現過程也不一樣。
(圖片來源 —— 初步研究node中的網路通訊模組 )
Node.js 網路模組架構
在 Node.js 的模組裡面,與網路相關的模組有: Net 、 DNS 、 HTTP 、 TLS/SSL 、 HTTPS 、 UDP/Datagram ,除此之外,還有 v8 底層相關的網路模組有 tcp_wrap.cc
、 udp_wrap.cc
、 pipe_wrap.cc
、 stream_wrap.cc
等等,在 JavaScript 層以及 C++ 層之間通過 process.binding
進行橋接相互通訊。
(圖片來源 —— Node.js之網路通訊模組淺析 )
Nagle 演算法
Nagle 演算法描述是當一個連線有未確認的資料,小片段應該保留。當足夠的資料已被收件人確認,這些小片段將被分批成能夠被傳輸的更大的片段。
在很多小的資料包傳輸的網路,理想的情況將小的包集合起來一起傳送以減少擁堵。但有時等待時間比其他都重要,所以傳送小包是非常重要的。
這對互動應用尤其重要,像 ssh 或者 X Window 系統。在這些應用中,體積小的訊息應毫不延遲地輸送,以給人實時反饋的感覺。針對這種需求場景,我們可以通過 setNoDelay(true)
方法,來關閉 Nagle 演算法。
nc 命令
nc(netcat)可以用於涉及 TCP 或 UDP 的相關內容,比如通過它我們可以開啟 TCP 連線,傳送 UDP 資料包,監聽任意的 TCP 和 UDP 埠,執行埠掃描和處理 IPv4 和 IPv6 等。
利用 nc
命令,我們可以方便地連線一個 UNIX 域套接字(socket)伺服器,如:
$ nc -U /tmp/echo.sock # -U — Use UNIX domain socket
socket API 原本是為網路通訊設計的,但後來在 socket 的框架上發展出一種 IPC (Inter-Process Communication)機制,就是 UNIX Domain Socket 也稱為本地域。雖然網路 socket 也可用於同一臺主機的程序間通訊(通過loopback地址127.0.0.1),但是 UNIX Domain Socket 用於 IPC 更有效率: 不需要經過網路協議棧,不需要打包拆包、計算校驗和、維護序號和應答等,只是將應用層資料從一個程序拷貝到另一個程序 。這是因為,IPC 機制本質上是可靠的通訊,而網路協議是為不可靠的通訊設計的。UNIX Domain Socket 也提供面向流和麵向資料包兩種 API 介面,類似於 TCP 和 UDP,但是面向訊息的 UNIX Domain Socket 也是可靠的,訊息既不會丟失也不會順序錯亂。
在 Windows 上,本地域通過命名管道實現。路徑必須是以 \\?\pipe\
或 \\.\pipe\
為入口。路徑允許任何字元,但後面的字元可能會對管道名稱進行一些處理,例如解析 ..
序列。儘管如此,管道空間是平面的。管道不會持續,當最後一次引用關閉時,管道就會被刪除。
Node.js net
net
模組提供了建立基於流的 TCP 或 IPC 伺服器 ( net.createServer()
) 和客戶端 ( net.createConnection()
) 的非同步網路 API。
net 基本使用
server.js
// 建立socket伺服器 const net = require("net"); let clients = 0; const server = net.createServer(client => { clients++; let clientId = clients; console.log("Client connect:", clientId); client.on("end", () => { console.log("Client disconnected:", clientId); }); client.write("Welcome client: " + clientId + " \r\n"); client.pipe(client); // 把客戶端的資料返回給客戶端 }); server.listen(8000, () => { console.log("Server started on port 8000"); });
client.js
// 建立socket客戶端 const net = require("net"); const client = net.connect(8000); client.on("data", data => { console.log(data.toString()); }); client.on("end", () => { console.log("Client disconnected"); });
新開兩個終端,按順序執行 node server.js
和 node client.js
命令後,就可以在控制檯看到輸出的資料了。
接下來我們來分別分析一下 server.js 和 client.js 檔案。
TCP Server
// 建立socket伺服器 const net = require("net"); let clients = 0; const server = net.createServer(client => { // 參考net基本使用相關程式碼 }); server.listen(8000, () => { console.log("Server started on port 8000"); });
以上程式碼通過呼叫 net.createServer() 方法來建立一個新的 TCP 伺服器,該方法的實現如下:
function createServer(options, connectionListener) { return new Server(options, connectionListener); }
可以看到 net.createServer() 方法內部是通過呼叫 Server
的建構函式建立 TCP 伺服器,Server 建構函式(程式碼片段)如下:
function Server(options, connectionListener) { if (!(this instanceof Server)) // 確保以new形式呼叫建構函式 return new Server(options, connectionListener); EventEmitter.call(this); if (typeof options === 'function') { connectionListener = options; options = {}; this.on('connection', connectionListener); } else if (options == null || typeof options === 'object') { options = options || {}; if (typeof connectionListener === 'function') { this.on('connection', connectionListener); } } this._connections = 0; this._handle = null; } util.inherits(Server, EventEmitter);
通過觀察以上程式碼,我們發現 Server 類繼承了 EventEmitter 類,Server 例項內部會監聽 connection
事件,該事件觸發後,會執行使用者設定的 connectionListener
回撥函式。那麼何時會觸發 connection
事件,通過原始碼我們發現在 onconnection 函式內部會觸發 connection
事件,具體如下(程式碼片段):
function onconnection(err, clientHandle) { var handle = this; var self = handle.owner; // 判斷是否超過最大連線數 if (self.maxConnections && self._connections >= self.maxConnections) { clientHandle.close(); return; } // util.inherits(Socket, stream.Duplex); var socket = new Socket({ handle: clientHandle, allowHalfOpen: self.allowHalfOpen, pauseOnCreate: self.pauseOnConnect }); socket.readable = socket.writable = true; self._connections++; self.emit('connection', socket); }
在 onconnection 函式內部,我們通過呼叫 Socket 建構函式來建立 socket 物件,因為 Socket 類繼承於 stream.Duplex 類,所以 socket 物件也是一個可讀可寫流,可以使用 stream.Duplex 中定義的方法。
那麼接下來的問題就是何時呼叫 onconnection
函式,我們繼續在原始碼中找答案。最終我們發現在 setupListenHandle 函式內部會通過執行 this._handle.onconnection = onconnection;
語句設定 onconnection
函式。
顧名思義,setupListenHandle 函式的作用是用於設定監聽處理器,該函式物件會被繫結到 Server 原型物件的 _listen2
屬性上:
Server.prototype._listen2 = setupListenHandle;
不知道小夥伴們,還記得以下這段程式碼:
server.listen(8000, () => { console.log("Server started on port 8000"); });
以上程式碼我們通過 listen()
方法來設定 TCP 伺服器的監聽埠,這裡的 listen()
方法與 _listen2()
方法是不是會有聯絡?嗯,沒錯,它們之間有緊密的聯絡,誰讓它們長得像。
接下來我們先來看一下建立 TCP 伺服器 listen()
方法的簽名:
server.listen([port\][, host][, backlog][, callback])]
- 支援 port、host、backlog 和 callback 引數。
- 返回相應的 server 物件。
而建立 IPC 伺服器 listen()
方法的簽名為:
server.listen(path[, backlog][, callback])
- 支援 path(伺服器需要監聽的路徑,詳情可以檢視 Identifying paths for IPC connections 。)backlog 和 callback 引數。
- 返回相應的 server 物件。
這裡我們先來分析建立 TCP 伺服器的情形:
Server.prototype.listen = function(...args) { var normalized = normalizeArgs(args); var options = normalized[0]; var cb = normalized[1]; var hasCallback = (cb !== null); if (hasCallback) { this.once('listening', cb); } options = options._handle || options.handle || options; var backlog; // options.port:8000 if (typeof options.port === 'number' || typeof options.port === 'string') { // start TCP server listening on host:port if (options.host) { lookupAndListen(this, options.port | 0, options.host, backlog, options.exclusive); } else { // Undefined host, listens on unspecified address // Default addressType 4 will be used to search for master server listenInCluster(this, null, options.port | 0, 4, backlog, undefined, options.exclusive); } return this; } };
而 listenInCluster
函式的內部實現如下(程式碼片段):
function listenInCluster(server, address, port, addressType, backlog, fd, exclusive) { // 如果exclusive是false(預設),則叢集的所有程序將使用相同的底層控制代碼,允許共享連線處理任務。 // 如果exclusive是true,則控制代碼不會被共享,如果嘗試埠共享將導致錯誤。 exclusive = !!exclusive; // 引入cluster(叢集)模組 // Node.js在單個執行緒中執行單個例項。 使用者(開發者)為了使用現在的多核系統,有時候, // 使用者(開發者)會用一串Node.js程序去處理負載任務。 if (cluster === null) cluster = require('cluster'); // 當該程序是主程序時,返回true。 if (cluster.isMaster || exclusive) { // Will create a new handle // _listen2 sets up the listened handle, it is still named like this // to avoid breaking code that wraps this method server._listen2(address, port, addressType, backlog, fd); return; } }
我們繼續來看一下 _listen2()
方法(程式碼片段):
// Server.prototype._listen2 = setupListenHandle; function setupListenHandle(address, port, addressType, backlog, fd) { if (this._handle) { debug('setupListenHandle: have a handle already'); } else { debug('setupListenHandle: create a handle'); var rval = null; if (rval === null) rval = createServerHandle(address, port, addressType, fd); this._handle = rval; } // 此處繫結onconnection函式 this._handle.onconnection = onconnection; }
以上程式碼的核心在於 createServerHandle
函式,所以我們繼續來分析一下該函式(程式碼片段):
function createServerHandle(address, port, addressType, fd) { var handle; var isTCP = false; // server.listen(handle[, backlog][, callback]) // 啟動一個伺服器,監聽已經繫結到埠、UNIX域套接字或Windows命名管道的給定控制代碼上的連線。 // 控制代碼物件可以是伺服器、套接字(任何具有底層_handle成員的東西),也可以是含有fd成員的物件, // 該成員是一個有效的檔案描述符。 if (typeof fd === 'number' && fd >= 0) { try { handle = createHandle(fd, true); } catch (e) { // Not a fd we can listen on.This will trigger an error. debug('listen invalid fd=%d:', fd, e.message); return UV_EINVAL; } handle.open(fd); handle.readable = true; handle.writable = true; } else if (port === -1 && addressType === -1) { // 處理UNIX domain socket或Windows pipe handle = new Pipe(PipeConstants.SERVER); } else { handle = new TCP(TCPConstants.SERVER); // 建立TCP服務 isTCP = true; } return handle; } function createHandle(fd, is_server) { // 基於檔案描述符確認handle的型別,TTY(文字終端) const type = TTYWrap.guessHandleType(fd); if (type === 'PIPE') { return new Pipe( is_server ? PipeConstants.SERVER : PipeConstants.SOCKET ); } if (type === 'TCP') { return new TCP( is_server ? TCPConstants.SERVER : TCPConstants.SOCKET ); } }
需要注意的是 createHandle 函式中的 Pipe 和 TCP 類內部是由 C++ 實現:
const { TCP, constants: TCPConstants } = process.binding('tcp_wrap'); const { Pipe, constants: PipeConstants } = process.binding('pipe_wrap');
在 createServerHandle
函式內部,如果是建立 TCP 伺服器,只需呼叫 new TCP(TCPConstants.SERVER)
即可。現在我們來簡單總結一下示例中建立 TCP 伺服器的過程:
- 呼叫
net.createServer()
方法建立 server 物件,該物件建立完後,我們呼叫listen()
方法執行監聽操作。 - 在
listen()
方法內,將解析相關引數,然後呼叫listenInCluster()
方法。 - 由於當該程序是主程序,所以
listenInCluster()
方法內會直接呼叫_listen2()
方法。 - 因為
_listen2
是指向setupListenHandle
函式,所以最終呼叫的是setupListenHandle
函式。該函式的主要作用是呼叫createServerHandle
函式建立對應的 handle 物件(本示例為 TCP 物件),併為該物件設定onconnection
處理器,然後在把返回的物件賦值給 server 物件的 _handle 屬性。 - 最後當伺服器接收到連線請求時,就會呼叫
onconnection
處理器,隨後建立 Socket 物件,並觸發connection
事件,然後就會執行我們設定的 connectionListener 監聽函式。
UNIX Domain Socket
在預備知識章節,我們瞭解到 UNIX Domain Socket 用於 IPC (Inter-Process Communication)更有效率:
不需要經過網路協議棧,不需要打包拆包、計算校驗和、維護序號和應答等,只是將應用層資料從一個程序拷貝到另一個程序。
接下來我們就來介紹一下,如何建立簡單的 UNIX 域套接字伺服器。
在 createServerHandle
函式中,不知道小夥伴們有沒有注意到 port === -1 && addressType === -1
這一行,竟然有 port 和 addressType(一般為 4 或 6 即表示 IPv4 或 IPv6)為 -1 的情況。其實這就是建立 UNIX domain socket 或 Windows pipe 伺服器的場景。
最後我們來建立一個 UNIX 域套接字伺服器(實現 echo 功能),具體的示例如下:
const net = require("net"); const server = net.createServer(c => { c.on("end", () => { console.log("client disconnected"); }); c.write("hello\r\n"); c.pipe(c); }); server.on("error", err => { throw err; }); // server.listen(path[, backlog][, callback]) for IPC servers server.listen("/tmp/echo.sock", () => { console.log("server bound"); });
成功執行伺服器後,我們就可以用前面介紹的 nc
命令來連線 UNIX 域套接字伺服器:
$ nc -U /tmp/echo.sock
命令執行後,控制檯首先會輸出 hello
,當我們輸入任何訊息後,UNIX 域套接字伺服器也會返回同樣的訊息:
➜~ nc -U /tmp/echo.sock hello semlinker semlinker i love node i love node
有興趣的小夥伴可以親自動手試一試,體驗一下上面 echo
伺服器。
總結
本文通過兩個簡單的示例,分別介紹瞭如何建立簡單 TCP 和用於 IPC 的 UNIX Domain Socket 伺服器,同時也介紹了 Socket、Nagle 演算法、nc 命令等相關的知識。其實 Node.js 的 Net 模組還有挺多知識點的,比如核心的 Socket 類,這裡就不做進一步介紹了。如果想更全面和深入瞭解 Net 模組的小夥伴,建議閱讀相關的文章或原始碼。