NODEJS硬實戰筆記 (TCP與UDP)

weixin_34120274發表於2017-03-23

NodeJS TCP與UDP

一個最簡單的TCP服務端

var net = require('net');
var clients = 0;
    
var server = net.createServer(function (client) {
    
  clients++;
  var clientId = clients;
  console.log('Client connected:', clientId);
    
  client.on('end', function () {
    console.log('Client disconnected:', clientId);
  });
    
  client.write('Welcome client: ' + clientId + 'rn');
  client.pipe(client);
});
    
server.listen(8000, function () {
  console.log('Server started on port 8000');
});

當一個客戶端建立了一個新連線,傳遞給net.createServer回撥函式將會執行。回撥接收一個面向事件的連線物件。這個服務物件是net.Server的一個例項,僅僅是對net.Socket類的一個封裝,而net.Socket類又是使用雙工流來實現的,所以服務端在傳送資訊給客戶端的時候可以使用client.pipe()管道來傳送。

TCP客戶端和服務端

// 主要是驗證每次的連線都對應不同的clientId
var assert = require('assert');
var net = require('net');
    
var clients = 0;
var expectedAssertions = 2;
    
// 建立一個服務端
var server = net.createServer(function (client) {
  clients++;
  var clientId = clients;
  console.log('Client connected:', clientId);
    
  client.on('end', function () {
    console.log('Client disconnected:', clientId);
  });
    
  client.write('Welcome client:' + clientId + '\r\n');
  client.pipe(client);
});
    
// 主執行緒監聽8000埠
server.listen(8000, function () {
  console.log('Server started on port 8000');
    
  runTest(1, function () {
    runTest(2, function () {
      console.log('Tests finished');
      assert.equal(0, expectedAssertions);
      server.close()
    })
  });
    
  // 建立客戶端並去連線服務端
  function runTest(expectedId, done) {
    var client = net.connect(8000);
    
    client.on('data', function (data) {
      var expected = 'Welcome client:' + expectedId + '\r\n';
      assert.equal(data.toString(), expected);
      expectedAssertions--;
      client.end();
    });
    
    client.on('end', done)
  }
});

這裡發現了一個問題,當服務端和客戶端處於同一執行緒中的時候,兩邊互發訊息,服務端接收到的流存在異常,內容將變成客戶端傳送的訊息+服務端之前傳送的訊息。暫時只知道在write後再通過管道傳遞會傳送這種情況,不知道為什麼?並且也不是很清楚為什麼在write了之後還需要通過管道傳遞。

  • 連線服務端:
    • 連線TCP服務端要建立一個客戶端物件,這個物件是是一個UNIX Socket,即:net.Socket例項。它是了一個雙向的流介面,建立物件伺服器端的connection事件會被觸發。建立一個TCP客戶端可以使用createConnection()方法或其別名方法connect(),也可以使用建構函式new net.Socket()。連線成功後'connect'事件會被觸發。
  • 和服務端交換資料:
    • 與TCP服務端建立連線後就可以向服務端傳送資料,或接收來自伺服器的資料。接收到伺服器端的資料資料後後觸發data事件,可以通監聽這個事件接收資料。向伺服器傳送資料可以使用write()方法。資料傳輸前可以通過setEncoding()方法設定流的編碼格式。
    • socket.write(data, [encoding], [callback]):在UNIX Socket套接字上傳送資料時,如果第二引數用於設定傳送資料的編碼方式,預設為UTF8編碼。如果所有資料被成功重新整理到緩衝區,則返回true。如果所有或部分資料在使用者記憶體裡還處於佇列中,則返回false。當緩衝區再次被釋放時,'drain'事件會被觸發。(注意這邊write的實現本身就是流模式的,所以更不明白上面write後還通過管道傳遞的寫法
    • 當資料最終被完整寫入時,可選引數callback會被執行。
    • socket.setEncoding([encoding]):設定Socket流的編碼格式
  • Socket流的暫停與關閉:
    • 關閉與終端的的連線使用end()方法,end()方法在關閉連線前也可以向終端傳送資料。對於發生錯誤的連線,可以呼叫destroy()方法,關閉已沒有 I/O 活動的TCP連線。
    • socket.end(data, [encoding]):半關閉Socket套接字。例如:當傳送一個FIN包時,可能伺服器仍在傳送資料。(管道是雙向的,所以這邊是半關閉,具體原因參見後期將會寫的TCP狀態機)
      如果傳入data參, 等同於呼叫 socket.write(data, encoding)然後呼叫socket.end()。
    • socket.destroy():銷燬已沒有I/O活動的TCP連線
    • Socket客戶端是一個可讀寫的流,這意味著你可以對它進行暫停和恢復。
      • socket.pause():暫停讀取資料,暫停後'data'事件不會再觸發
      • socket.resume():恢復pause()方法暫停的流
  • Socket客戶端一些設定和方法:
    • 除了前面介紹的設定編碼的setEncoding()方法外,還有其它一些設定方法,如:設定超時的setTimeout()方法。
    • socket.setTimeout(timeout[, callback]):套接字超過timeout毫秒閒置狀態,則將套接字設為超時。預設net.Socket不存在超時。
      當一個閒置超時被觸發時,會觸發一個'timeout'事件,但是連線將不會被斷開。使用者必須手動end()或destroy()斷開這個套接字。可選引數callback會被新增成為'timeout'事件的一次性監聽器。
    • socket.setNoDelay([noDelay]):禁用Nagle演算法。預設情況下TCP連線使用Nagle演算法,這些連線在傳送資料之前對資料進行緩衝處理。 將noDelay設成true會在每次socket.write()被呼叫時立刻傳送資料。noDelay預設為true。
    • socket.setKeepAlive([enable], [initialDelay]):禁用/啟用長連線功能,並在第一個在閒置套接字上的長連線probe被髮送之前,可選地設定初始延時。enable預設為false。
      設定initialDelay (毫秒),來設定在收到的最後一個資料包和第一個長連線probe之間的延時。設定為0會保留預設(或者之前)的值。預設為0。
    • socket.address():返回Socket套接字繫結的IP地址, 協議型別以及埠號。其返回值是一個包含三個屬性的物件, 形如{ port: 2345, family: 'IPv4', address: '127.0.0.1' }。
    • socket.unref():如果當前套接字物件是事件系統中唯一一個活動的套接字,呼叫unref方法將允許程式退出。如果套接字已被 unref,則再次呼叫 unref 並不會產生影響。
    • socket.ref():與unref 相反。如果當前套接字物件是僅剩的套接字,在一個之前被 unref 了的套接字上呼叫 ref 將不會讓程式退出(預設行為)。如果一個套接字已經被 ref,則再次呼叫 ref 並不會產生影響。
  • Socket類中的屬性:
    • socket.bufferSize:當前準備寫入緩衝區的字元數,使用者可根據此屬性對資料流進行控制。遇到很大或增長很快的 bufferSize 時,使用者可用嘗試用pause() 和 resume()來控制字元流。
    • socket.remoteAddress:遠端的IP地址(TCP服務端),例如:'74.125.127.100'或'2001:4860:a005::68'
    • socket.remoteFamily:遠端IP協議版本,例如:'IPv4'或'IPv6'
    • socket.remotePort:遠端埠號,例如:80或22
    • socket.localAddress:本地IP地址(TCP客戶端),例如:'198.168.0.10'
    • socket. localPort:本地埠號,例如:80或22
    • socket.bytesRead:客戶端收到的位元組數
    • socket.bytesWritten:客戶端傳送的位元組數

TCP基礎知識

資料包與MTU

  • 資料包主要分為IPv4和IPv6資料包,那這裡先簡要說一下IPv4協議與IPv6協議的區別:
    • 更大的地址空間,IPv4中規定IP地址長度為32,即有232-1個地址;而IPv6中IP地址的長度為128,即有2128-1個地址。
    • 更小的路由表。IPv6的地址分配一開始就遵循聚類(Aggregation)的原則,這使得路由器能在路由表中用一條記錄(Entry)表示一片子網,大大減小了路由器中路由表的長度,提高了路由器轉發資料包的速度。 增強的組播(Multicast)支援以及對流的支援(Flow-control)。
    • IPv4的資料包大小是65535位元組,包括IPv4的首部,首部中說明大小的欄位為16位。
    • IPv6的資料包大小是65575位元組,因為IPv6的首部是40位元組,但因為不算在其中所以比IPv4大一個首部。
    • (具體的比較分析見後期將會寫的IPv4與IPv6資料包分析)
  • MTU(Maximum Transmission Unit):最大傳輸單元
    • MTU就像是高速公路上的車道寬度。
    • 許多網路有一個可由硬體規定的MTU。乙太網的MTU為1500位元組。有一些鏈路的MTU的MTU可以由認為配置。IPv4要求的最小鏈路MTU為68位元組。這允許最大的IPv4首部(包括20位元組的固定長度部分和最多40位元組的選項部分)拼接最小的片段(IPv4首部中片段偏移欄位以8個位元組為單位)IPv6要求的最小鏈路MTU為1280位元組

分片

  • 當一個IP資料包從某個介面送出時,如果它的大小超過相應鏈路的MTU,IPv4和IPv6都將執行分片。這些片段在到達終點之前通常不會被重組(reassembling)。IPv4主機對其產生的資料包執行分片,IPv4路由器則對其轉發的資料包進行分片。然後IPv6只有主機對其產生的資料包執行分片,IPv6路由器不對其轉發的資料包執行分片。
  • IPv4首部的“不分片”(do not fragment)位(即DF位)若被設定,那麼不管是傳送這些資料包的主機還是轉發他們的路由器,都不允許對它們分片。當路由器接收到一個超過其外出鏈路MTU大小且設定了DF位的IPv4資料包時,它將產生一個ICMPv4“destination unreachable,fragmentation needed but DF bit set”(目的不可到達,需分片但DF位已設定)的出錯訊息。
  • 既然IPv6路由器不執行分片,每個IPv6資料包於是隱含一個DF位。當IPv6路由器接收到一個超過其外出鏈路MTU大小的IPv6資料包時,它將產生一個ICMPv6 “packet too big”的出錯訊息。IPv4的DF位和隱含DF位可用於路徑MTU發現。

緩衝區

  • 緩衝區是TCP進行資料交流的最主要的承載部分。就像高速公路兩端的休息站。
  • 而緩衝區主要是分為傳送緩衝區重組緩衝區
    • MSS(maximun segment size): 最大分段尺寸
      • TCP有一個最大分段大小,用於對端TCP通告對端每個分段中能傳送的最大TCP資料量。MSS的目的是告訴對端其重組緩衝區大小的實際值,從而避免分片。MSS經常設計成MTU減去IP和TCP首部的固定長度。乙太網中使用IPv4MSS值為1460,使用IPv6的MSS值為1440(兩者TCP首部都是20位元組,但是IPv6首部是40位元組,IPv4首部是20位元組)。
    • 傳送緩衝區:
      • 每個TCP套接字有一個傳送緩衝區,我們可以用SO_SNDBUF套接字選項來更改該緩衝區的大小。當某個應用程式呼叫write時,核心從該應用程式的緩衝區複製所有資料到縮寫套接字的傳送緩衝區。如果該套接字的傳送緩衝區容不下該應用程式的所有資料(或是應用程式的緩衝區大於套接字的傳送緩衝區,或是套接字的傳送緩衝區中已有其他資料),該應用程式將被投入睡眠。這裡假設該套接字是阻塞的,它通常是預設設定。核心將不從write系統呼叫返回,直到應用程式緩衝區中的所有資料都複製到套接字傳送緩衝區。因此,從寫一個TCP套接字的write呼叫成功返回僅僅表示我們可以重新使用原來的應用程式緩衝區,並不表明對端的TCP或應用程式已接受到資料。
      • 這一端的TCP提取套接字傳送緩衝區中的資料並把它傳送給對端的TCP,其過程基於TCP資料傳送的所有規則。對端TCP必須確認收到的資料,伴隨來自對端的ACK的不斷到達,本段TCP至此才能從套接字傳送緩衝區中丟棄已確認的資料。TCP必須為已傳送的資料保留一個副本,直到它被對端確認為止。本端TCP以MSS大小或是更小的塊把資料傳遞給IP,同時給每個資料塊安上一個TCP首部以構成TCP分節,其中MSS或是由對端告知的值,或是536(若未傳送一個MSS選項為576-TCP首部-IP首部)。IP給每個TCP分節安上一個IP首部以構成IP資料包,並按照其目的的IP地址查詢路由表項以確定外出介面,然後把資料包傳遞給相應的資料鏈路。每個資料鏈路都有一個資料佇列,如果該佇列已滿,那麼新到的分組將被丟棄,並沿協議棧向上返回一個錯誤:從資料鏈路到IP,在從IP到TCP。TCP將注意到這個錯誤,並在以後某個時候重傳相應的分節。應用程式不知道這種暫時的情況。
    • 重組緩衝區:
      • IPv4和IPv6都定義了最小緩衝區大小,它是IPv4或IPv6任何實現都必須保重支援的最小資料包大小。其值對IPv4為576位元組,對於IPv6為1500位元組。例如,對於IPv4而言,我們不能判定某個給定的目的能否接受577位元組的資料包,為此很多應用避免產生大於這個大小的資料包。

Nagle演算法

  • TCP/IP協議中,無論傳送多少資料,總是要在資料前面加上協議頭,同時,對方接收到資料,也需要傳送ACK表示確認。為了儘可能的利用網路頻寬,TCP總是希望儘可能的傳送足夠大的資料。(一個連線會設定MSS引數,因此,TCP/IP希望每次都能夠以MSS尺寸的資料塊來傳送資料)。Nagle演算法就是為了儘可能傳送大塊資料,避免網路中充斥著許多小資料塊。
  • Nagle演算法的基本定義是任意時刻,最多隻能有一個未被確認的小段。 所謂“小段”,指的是小於MSS尺寸的資料塊,所謂“未被確認”,是指一個資料塊傳送出去後,沒有收到對方傳送的ACK確認該資料已收到。
  • Nagle演算法的規則(可參考tcp_output.c檔案裡tcp_nagle_check函式註釋):
    • 如果包長度達到MSS,則允許傳送;
    • 如果該包含有FIN,則允許傳送;
    • 設定了TCP_NODELAY選項,則允許傳送;
    • 未設定TCP_CORK選項時,若所有發出去的小資料包(包長度小於MSS)均被確認,則允許傳送;
    • 上述條件都未滿足,但發生了超時(一般為200ms),則立即傳送。

UDP服務端和客戶端

var assert = require('assert');
var dgram = require('dgram');
var fs = require('fs');
var defaultSize = 16;
var port = 41234;

// 建立客戶端
function Client(remoteIP) {
  var socket = dgram.createSocket('udp4');
  var readline = require('readline');
  var rl = readline.createInterface(process.stdin, process.stdout);

  socket.send(new Buffer('<JOIN>'), 0, 6, port, remoteIP);

  rl.setPrompt('Message> ');
  // 開始等待使用者的輸入
  rl.prompt();

  // 當使用者輸入完一行按回車後觸發
  rl.on('line', function (line) {
    sendData(line)
  // readline一開始執行就不會結束,所以需要監聽close事件來關閉程式
  }).on('close', function () {
    process.exit(0)
  });

  socket.on('message', function (msg, rinfo) {
    console.log('\n<' + rinfo.address + '>', msg.toString());
    rl.prompt();
  });

  function sendData(message) {
    socket.send(new Buffer(message), 0, message.length, port, remoteIP,
      function (err, bytes) {
        console.log('Sent:', message);
        rl.prompt();
      })
  }
}

// 建立服務端
function Server() {
  var clients = [];
  var server = dgram.createSocket('udp4');
  server.on('message', function (msg, rinfo) {
    var clientId = rinfo.address + ':' + rinfo.port;
    msg = msg.toString();

    if (!clients[clientId]) {
      clients[clientId] = rinfo;
    }

    if (msg.match(/^</)) {
      console.log('Control message:', msg);
      return;
    }

    for (var client in clients) {
      if (client !== clientId) {
        client = clients[client];
        server.send(
          new Buffer(msg), 0,
          msg.length, client.port, client.address,
          function (err, bytes) {
            if (err) console.error(err);
            console.log('Bytes sent:', bytes);
          }
        )
      }
    }
  });

  server.on('listening', function () {
    console.log('Server ready:', server.address());
  });

  server.bind(port);
}

module.exports = {
  Client: Client,
  Server: Server
};

// module.parent 返回引用該模板的模板
if (!module.parent) {
  switch (process.argv[2]) {
    case 'client':
      new Client(process.argv[3]);
      break;
    case 'server':
      new Server();
      break;
    default:
      console.log('Unknown option');
  }
}

使用dgram.createSocket建立一個客戶端socket與服務端相同。傳送一個資料包需要一個buffer來承載,用偏移量來表明buffer中訊息的開始、訊息的長度、服務埠、遠端IP和一個可選的回撥,當訊息發出時會被觸發。

  • UDP傳送緩衝區
    • 任何UDP套接字都有傳送緩衝區大小(我們可以用SO_SNDBUF套接字選項更改它),不過它僅僅是可寫道套接字UDP資料包大小上限。如果一個應用程式寫一個大於套接字傳送緩衝區大小的資料包,核心將返回該程式一個EMSGSIZE錯誤。既然UDP是不可靠的,它不必儲存應用程式資料的一個副本,因此無需一個真正的傳送緩衝區。(應用程式的資料在沿協議棧向下傳遞時,通常被複制到某種格式的一個核心緩衝區中,然而當該資料被髮送之後,這個副本被資料鏈路層丟棄了。)
    • UDP簡單地給來自使用者的資料包安上8位元組首部以構成UDP資料包,然後傳遞給IP。IPv4或IPv6給UDP資料包安上相應的IP首部以構成IP資料包,執行路由操作確定外出介面,然後或者直接把資料包加入資料鏈路層輸出佇列(如果適合於MTU),或者分片後在把每個片段加入資料集鏈路層的輸出佇列。如果某個UDP程式傳送大資料包,那麼它們相比TCP應用資料更有可能被分片,因為TCP會把應用資料劃分成MSS大小的塊,而UDP卻沒有對等的手段。
    • 從寫一個UDP套接字的write呼叫成功返回表示所寫的資料包或其所有片段已被加入資料鏈路層的輸出佇列。如果該佇列沒有足夠的空間存放該資料包或它的某個片段,核心通常會返回一個ENOBUFS錯誤給它的應用程式。有些UDP實現不返回這種錯誤,這樣甚至資料包未經傳送就被丟棄的情況程式也不知道。

部分內容摘抄自網上部落格

相關文章