什么是協(xié)議
在網(wǎng)絡通信中,協(xié)議(Protocol)是指計算機或設備之間進行通信的一系列規(guī)則的集合。
不管是網(wǎng)絡還是生活中,協(xié)議是一種事先約定好的規(guī)則,通信的參與方按照同一份規(guī)則進行通信,如連接方式,如何識別等等。只有事先約定好了規(guī)則,才能保證后續(xù)通信時的效率和一定的安全性。
協(xié)議規(guī)定了通信實體之間所交換的消息的格式、意義、順序以及針對收到信息或發(fā)生的事件所采取的動作。協(xié)議可以分為不同的層次,每一層負責不同的通信功能。常見的協(xié)議有IP、TCP、HTTP、POP3、SMTP等,它們屬于TCP/IP協(xié)議族,也就是基于TCP和IP這兩個最初的協(xié)議之上的不同的通信協(xié)議的大集合。
結(jié)構(gòu)化數(shù)據(jù)
在網(wǎng)絡通信時,數(shù)據(jù)可以分為結(jié)構(gòu)化數(shù)據(jù)和非結(jié)構(gòu)化數(shù)據(jù)。
處理數(shù)據(jù)的主體是機器,數(shù)據(jù)的本質(zhì)是二進制序列,因此數(shù)據(jù)在網(wǎng)絡中傳輸也是以二進制序列的形式傳輸?shù)摹?/p>
值得注意的是:
-
二進制序列和二進制數(shù)據(jù)流是不同的概念。二進制序列是指一串由0和1組成的數(shù)字,它可以表示任何類型的數(shù)據(jù),例如文本、圖像、音頻等。二進制數(shù)據(jù)流是指一種數(shù)據(jù)傳輸方式,它是指以二進制序列為單位的連續(xù)數(shù)據(jù)流,例如從網(wǎng)絡或文件中讀取或?qū)懭霐?shù)據(jù)。
-
二進制序列流可以認為是字符流,也就是字符串,只要它們遵循一定的編碼規(guī)則,例如ASCII、UTF-8等。不同的編碼規(guī)則會影響二進制序列和字符之間的對應關(guān)系。
也就是說,數(shù)據(jù)在網(wǎng)絡中傳輸時,都是以二進制序列的形式傳輸?shù)?,無論是結(jié)構(gòu)化數(shù)據(jù)還是非結(jié)構(gòu)化數(shù)據(jù)。只是它們的組織方式不同,結(jié)構(gòu)化數(shù)據(jù)有固定的格式和模式,非結(jié)構(gòu)化數(shù)據(jù)沒有預定義的格式和模式。因此,處理和分析這兩種類型的數(shù)據(jù)需要不同的工具和方法。
結(jié)構(gòu)化數(shù)據(jù)
結(jié)構(gòu)化數(shù)據(jù)是指按照一定的規(guī)則、格式和順序組織的數(shù)據(jù),通常以表格、樹形結(jié)構(gòu)、圖形等形式呈現(xiàn),可以用數(shù)據(jù)庫二維邏輯表來表現(xiàn)的數(shù)據(jù)。例如,在一個數(shù)據(jù)庫中,數(shù)據(jù)以表格的形式存儲,每個表格都有固定的字段和數(shù)據(jù)類型,數(shù)據(jù)的格式和順序都是固定的。在網(wǎng)絡通信中,結(jié)構(gòu)化數(shù)據(jù)通常以XML、JSON、Protocol Buffers等格式進行傳輸。
簡單理解結(jié)構(gòu)化數(shù)據(jù):例如我們的身份證ID,雖然看起來是一串數(shù)字,但是不同的位數(shù)表示了不同的含義。這和下面的序列化和反序列化息息相關(guān)。
非結(jié)構(gòu)化數(shù)據(jù)
非結(jié)構(gòu)化數(shù)據(jù)是指沒有固定格式和順序的數(shù)據(jù),通常以文本、圖像、音頻、視頻等形式呈現(xiàn),不方便用數(shù)據(jù)庫二維邏輯表來表現(xiàn)的數(shù)據(jù)。例如,在一個文本文件中,數(shù)據(jù)沒有固定的格式和順序,而是由一些字符和換行符組成。在網(wǎng)絡通信中,非結(jié)構(gòu)化數(shù)據(jù)通常以二進制數(shù)據(jù)流的形式進行傳輸,例如在FTP、HTTP等協(xié)議中,文件就是以二進制數(shù)據(jù)流的形式傳輸?shù)摹?/p>
相比于非結(jié)構(gòu)化數(shù)據(jù),結(jié)構(gòu)化數(shù)據(jù)更容易被處理和解析,因為它們有固定的格式和順序,可以通過解析規(guī)則來進行處理。而非結(jié)構(gòu)化數(shù)據(jù)則需要更強的語義理解和處理能力,因為它們沒有固定的格式和順序,需要通過文本分析、圖像處理、音頻識別等技術(shù)來進行處理。在網(wǎng)絡通信中,結(jié)構(gòu)化數(shù)據(jù)通常用于傳輸少量的、格式固定的數(shù)據(jù),而非結(jié)構(gòu)化數(shù)據(jù)則用于傳輸大量的、沒有固定格式和順序的數(shù)據(jù),例如圖像、音頻、視頻等。
半結(jié)構(gòu)化數(shù)據(jù)
半結(jié)構(gòu)化數(shù)據(jù)是指非關(guān)系模型的、有基本固定結(jié)構(gòu)模式的數(shù)據(jù),例如日志文件、XML文檔、JSON文檔等。在此暫不考慮。
結(jié)構(gòu)化數(shù)據(jù)的傳輸
在網(wǎng)絡中傳輸結(jié)構(gòu)化數(shù)據(jù)時,通常會將數(shù)據(jù)轉(zhuǎn)換為字符串的形式進行傳輸,這樣可以方便在網(wǎng)絡上傳輸和解析。具體地說,結(jié)構(gòu)化數(shù)據(jù)在網(wǎng)絡中傳輸時,有以下兩種情況:
-
字符串形式傳輸:一種常見的方法是將結(jié)構(gòu)化數(shù)據(jù)轉(zhuǎn)換為字符串格式(也就是說結(jié)構(gòu)化的數(shù)據(jù)不能拆分地發(fā)送),例如XML、JSON等格式,然后通過網(wǎng)絡協(xié)議(如HTTP、TCP、UDP等)進行傳輸。在接收端,可以通過解析字符串,將其還原為結(jié)構(gòu)化數(shù)據(jù)。
如果數(shù)據(jù)本身已經(jīng)是字符串格式,那么在網(wǎng)絡中傳輸時可以直接將其作為消息體進行傳輸,無需進行額外的轉(zhuǎn)換。例如,在使用HTTP協(xié)議進行通信時,可以將字符串格式的數(shù)據(jù)直接放在HTTP請求或響應的消息體中進行傳輸。
-
二進制形式傳輸:另一種方法是將結(jié)構(gòu)化數(shù)據(jù)直接編碼為二進制數(shù)據(jù),然后通過網(wǎng)絡協(xié)議進行傳輸。這種方法可以減少數(shù)據(jù)傳輸?shù)拇笮。岣邆鬏斝?。在接收端,可以將接收到的二進制數(shù)據(jù)解碼為結(jié)構(gòu)化數(shù)據(jù)。
需要注意的是:
-
[重要]結(jié)構(gòu)化數(shù)據(jù)在網(wǎng)絡中傳輸時,通常是作為一個整體進行傳輸?shù)?,而不是拆分成多個部分進行分別發(fā)送的。這是因為結(jié)構(gòu)化數(shù)據(jù)通常具有一定的層次結(jié)構(gòu),其中包含了多個元素或字段,這些元素或字段之間存在著一定的關(guān)系和依賴關(guān)系。如果將結(jié)構(gòu)化數(shù)據(jù)拆分成多個部分進行分別發(fā)送,可能會導致數(shù)據(jù)不完整或順序不正確,從而影響數(shù)據(jù)的正確性和解析。
可以認為若干數(shù)據(jù)本身是一個結(jié)構(gòu)體或類的屬性(在示例中也是這么做的)。
例如,假設要傳輸一個XML格式的文檔,其中包含多個標簽、元素和屬性。如果將文檔拆分成多個部分進行分別發(fā)送,可能會導致某些標簽、元素或?qū)傩员环珠_發(fā)送,從而無法正確解析文檔。此外,如果拆分后的數(shù)據(jù)包過小,還會導致網(wǎng)絡傳輸效率低下,增加網(wǎng)絡傳輸?shù)拈_銷。
需要注意的是,盡管可以使用特殊的協(xié)議或技術(shù)將結(jié)構(gòu)化數(shù)據(jù)拆分成多個部分進行傳輸,但這種方式仍然可能會增加數(shù)據(jù)傳輸?shù)膹碗s度和開銷,因此只有在必要的情況下才應該使用。如果數(shù)據(jù)本身不需要拆分,那么應該將其作為一個整體進行傳輸,以確保數(shù)據(jù)的正確性和傳輸效率。
-
如果字符串中包含一些特殊字符(例如空格、換行符、制表符、單引號、雙引號等),則需要對其進行轉(zhuǎn)義,以避免在傳輸過程中出現(xiàn)解析錯誤。常見的轉(zhuǎn)義方式包括使用轉(zhuǎn)義字符(如
\n
、\t
、'
、"
等)或?qū)⒆址M行Base64編碼等。在接收端,需要根據(jù)具體的轉(zhuǎn)義方式進行解析和還原字符串數(shù)據(jù)。 -
雖然字符串格式和二進制格式是兩種常見的數(shù)據(jù)傳輸方式,但在實際應用中也有其他的數(shù)據(jù)傳輸方式。例如,一些協(xié)議(如HTTP)支持直接傳輸HTML、CSS等格式的文本數(shù)據(jù),UDP協(xié)議可以支持直接傳輸音視頻流等二進制數(shù)據(jù)。因此,在選擇數(shù)據(jù)傳輸方式時,需要根據(jù)具體的應用場景和要求進行選擇。
-
此外,如果字符串數(shù)據(jù)需要進行壓縮,可以使用壓縮算法(如Gzip、Deflate等)將其壓縮后再進行傳輸,以減少數(shù)據(jù)傳輸?shù)拇笮 T诮邮斩?,需要將接收到的壓縮數(shù)據(jù)進行解壓縮,還原為原始的字符串數(shù)據(jù)。
例如稍后要實現(xiàn)的網(wǎng)絡版計算器,操作符和其兩側(cè)的操作數(shù)就是結(jié)構(gòu)化的數(shù)據(jù),它不應該被分散地發(fā)送,因為對于一次運算它們是必要的。由于這是一個跨網(wǎng)絡的數(shù)據(jù)傳輸,因此對于客戶端向服務端發(fā)送計算請求時,應該將操作符和操作數(shù)打包在一起,作為一個整體發(fā)送給服務端處理;這樣就能保證服務端能夠接收到完整的數(shù)據(jù)。這便是“結(jié)構(gòu)化”的意義。
序列化和反序列化
序列化和反序列化是一種在網(wǎng)絡傳輸中處理對象的方法,它們是一對相反的操作。
- 序列化是把(結(jié)構(gòu)化的)對象轉(zhuǎn)換為可以傳輸?shù)亩M制流(二進制序列)的過程。
- 反序列化是把二進制流(序列)轉(zhuǎn)換為(結(jié)構(gòu)化的)對象的過程。
進行序列化和反序列化的原因有兩個:
- 實現(xiàn)數(shù)據(jù)的持久化,通過序列化可以把數(shù)據(jù)永久地保存到硬盤上(通常存放在文件里);
- 利用序列化實現(xiàn)遠程通信,即在網(wǎng)絡上傳送對象的字節(jié)序列。
網(wǎng)絡版計算器概述
制定協(xié)議
協(xié)議是通信方事先約定好的規(guī)則,由于通信的內(nèi)容有所不同,對于若干個綁定在一起的數(shù)據(jù),通過網(wǎng)絡傳輸會提高風險,因此使用一個類或結(jié)構(gòu)體保存它,然后將它打包通過網(wǎng)絡傳輸。
值得注意的是,網(wǎng)絡只是數(shù)據(jù)傳輸?shù)耐ǖ溃瑪?shù)據(jù)處理的主體是計算機,在計算機眼里,數(shù)據(jù)是由01組成的二進制序列。
為什么不直接傳輸結(jié)構(gòu)化數(shù)據(jù),而首先要轉(zhuǎn)換為二進制序列?
-
一是為了保證數(shù)據(jù)的可移植性,不同的平臺或語言可能有不同的數(shù)據(jù)表示方式,而二進制數(shù)據(jù)是一種通用的格式,可以在不同的環(huán)境中進行交換;
例如不同系統(tǒng)和不同平臺看待結(jié)構(gòu)體的視角不同、大小端也可能不同。
-
二是為了提高數(shù)據(jù)的傳輸效率,結(jié)構(gòu)化的數(shù)據(jù)通常包含很多元數(shù)據(jù)和冗余信息,而二進制數(shù)據(jù)是一種緊湊的格式,可以減少數(shù)據(jù)的大小和帶寬消耗;
-
三是為了保證數(shù)據(jù)的安全性,結(jié)構(gòu)化的數(shù)據(jù)容易被人為篡改或破解,而二進制數(shù)據(jù)是一種難以直接閱讀和修改的格式,可以增加數(shù)據(jù)的保密性。
客戶端和服務端是相對的,一般將請求計算資源的一端稱之為客戶端,將響應請求返回結(jié)果的一端稱之為服務端。向網(wǎng)絡中發(fā)送數(shù)據(jù)的主體可能是客戶端(發(fā)送請求)也可能是服務端(響應請求),這需要將數(shù)據(jù)轉(zhuǎn)化為二進制序列,也就是序列化;同樣地,從網(wǎng)絡中接受數(shù)據(jù)的主體可能是客戶端(接收服務端的處理結(jié)果)也可能是服務端(處理客戶端的請求),需要將二進制序列形式的數(shù)據(jù)轉(zhuǎn)換為服務器機器能處理的結(jié)構(gòu)化的數(shù)據(jù),才能進行處理
也就是說,二進制序列是通用的,它能被各種機器識別,而不被它們之間的設計差異而有所區(qū)別。因此服務端或客戶端機器將從網(wǎng)絡中獲取的二進制序列轉(zhuǎn)換為結(jié)構(gòu)化的數(shù)據(jù)后,這個結(jié)構(gòu)化數(shù)據(jù)(的二進制組成)不一定和原主機進行序列化之前的結(jié)構(gòu)化數(shù)據(jù)完全相同,這取決于機器和軟件的實現(xiàn)。
服務端大多數(shù)機器是Linux,客戶端的操作系統(tǒng)可能是各種各樣的。
通過字符串傳輸
很容易想到的一種協(xié)議是:客戶端發(fā)送形如操作數(shù)1 運算符 操作數(shù)2
這樣的字符串給服務端,然后服務端解析這個字符串,取出操作數(shù)和運算符,將運算以后的結(jié)果返回。但是這樣的操作實在太麻煩了。而且服務端響應請求都要執(zhí)行這樣繁瑣的操作,大大降低效率。
結(jié)構(gòu)化數(shù)據(jù)+序列化與反序列化
將操作數(shù)和運算符打包為一個結(jié)構(gòu)化數(shù)據(jù),放在一個類或結(jié)構(gòu)體中,客戶端將屬性填充好以后對其進行序列化操作,使之能通過網(wǎng)絡傳輸給對端服務器。當服務器接受到二進制形式的結(jié)構(gòu)化數(shù)據(jù)后,對其進行反序列化,轉(zhuǎn)換為客戶端主機能處理的結(jié)構(gòu)化數(shù)據(jù),直接取出結(jié)構(gòu)體或類中的屬性,而不需要花費過多資源解析。
簡單地說,序列化后的數(shù)據(jù)是方便機器處理,反序列化后的數(shù)據(jù)是方便用戶層查看。
實現(xiàn)計算器
網(wǎng)絡相關(guān)接口
將Linux中的網(wǎng)絡操作例如創(chuàng)建套接字、監(jiān)聽、獲取連接、連接等操作封裝為函數(shù),然后放在類Sock
中。
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Log.hpp"
class Sock
{
private:
const static int _backlog = 20;
public:
Sock() {}
~Sock() {}
// 1. 創(chuàng)建套接字
int Socket()
{
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd < 0)
{
logMessage(FATAL, "%d:%s", errno, strerror(errno));
exit(2);
}
logMessage(DEBUG, "%s: %d", "create socket success, sockfd", listen_sockfd);
return listen_sockfd;
}
// 2. 綁定
void Bind(int listen_sockfd, uint16_t port, std::string ip = "0.0.0.0")
{
// 2.1 填充屬性
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET; // 網(wǎng)絡傳輸
local.sin_port = htons(port); // 本地->網(wǎng)絡
local.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());
// 2.2 綁定
if (bind(listen_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind():errno:%d:%s", errno, strerror(errno));
exit(3);
}
}
void Listen(int listen_sockfd)
{
// 3. 監(jiān)聽
if (listen(listen_sockfd, _backlog) < 0)
{
logMessage(FATAL, "listen()errno:%d:%s", errno, strerror(errno));
exit(4);
}
logMessage(NORMAL, "initialize tdp server...%s", strerror(errno));
}
int Accept(int listen_sockfd, std::string *ip, uint16_t *port)
{
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t len = sizeof(client);
int service_sockfd = accept(listen_sockfd, (struct sockaddr *)&client, &len);
// 獲取連接失敗
if (service_sockfd < 0)
{
logMessage(ERROR, "accept()errno:%d:%s", errno, strerror(errno));
return -1;
}
if (port)
*port = ntohs(client.sin_port);
if (ip)
*ip = inet_ntoa(client.sin_addr);
return service_sockfd;
}
// 獲取連接成功
// 通信準備 (網(wǎng)絡->主機)
bool Connect(int service_sockfd, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if (connect(service_sockfd, (struct sockaddr *)&server, sizeof(server)) == 0)
return true;
else
return false;
}
};
框架
服務端
目前的服務端并未使用線程池,只是每獲取一個客戶端請求后,創(chuàng)建一個線程執(zhí)行線程函數(shù)。
-
下面的邏輯將會在命名空間
ns_tcpserver
中定義,表示網(wǎng)絡流通信(Network Stream )。 -
定義一個類
ThreadData
,保存網(wǎng)絡通信獲取的套接字文件描述符,為了能夠在靜態(tài)的線程函數(shù)中直接調(diào)用TcpServer
類中的接口,用一個成員保存指向TcpServer
對象的地址。 -
定義一個
TcpServer
類,其中包含監(jiān)聽套接字文件描述符,以及剛才實現(xiàn)的Sock
對象,以便直接通過這個對象執(zhí)行網(wǎng)絡相關(guān)操作。還有一個數(shù)組_func
保存不同的線程函數(shù)。- 構(gòu)造函數(shù):獲取監(jiān)聽套接字文件描述符、綁定和監(jiān)聽。
- 析構(gòu)函數(shù):關(guān)閉監(jiān)聽套接字文件描述符。
- Bind():綁定一個服務,即將數(shù)組
_func
中的一個函數(shù)和內(nèi)核綁定起來。 - Excute():執(zhí)行任務。
- Start():通過
Sock
對象中的Accept()
獲取連接,然后創(chuàng)建一個線程執(zhí)行任務。
// CalServer.hpp
#pragma once
#include <pthread.h>
#include <functional>
#include <vector>
#include "Sock.hpp"
namespace ns_tcpserver
{
using func_t = std::function<void(int)>;
class TcpServer; // 聲明TcpServer類,以供ThreadData定義成員
class ThreadData
{
public:
ThreadData(int sockfd, TcpServer *server)
: _sockfd(sockfd), _server(server)
{
}
~ThreadData() {}
public:
int _sockfd;
TcpServer *_server;
};
class TcpServer
{
private:
static void *ThreadRoutine(void *args)
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->_server->Excute(td->_sockfd);
close(td->_sockfd);
return nullptr;
}
public:
TcpServer(const uint16_t &port, const std::string &ip = "")
{
_listen_sockfd = _sock.Socket();
_sock.Bind(_listen_sockfd, port, ip);
_sock.Listen(_listen_sockfd);
}
~TcpServer()
{
if (_listen_sockfd >= 0)
close(_listen_sockfd);
}
void BindService(func_t func)
{
_func.push_back(func);
}
void Excute(int sockfd)
{
for (auto &f : _func)
f(sockfd);
}
void Start()
{
while (1)
{
std::string client_ip;
uint16_t client_port;
int sockfd = _sock.Accept(_listen_sockfd, &client_ip, &client_port);
if (sockfd == -1)
continue;
logMessage(NORMAL, "%s: %d", "link success, sockfd: %d", sockfd);
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this);
pthread_create(&tid, nullptr, ThreadRoutine, td);
}
}
private:
int _listen_sockfd;
Sock _sock;
std::vector<func_t> _func;
};
}
值得注意的是:
-
ThreadData
的成員_server
類型是TcpServer *
,但是后者還沒有實現(xiàn),所以要在ThreadData
之前使用class TcpServer;
聲明TcpServer
類才能編譯通過。 -
using func_t = std::function<void(int)>;
是一種常用的給函數(shù)對象起別名的方法。
// CalServer.cc
#include <iostream>
#include <memory>
#include "CalServer.hpp"
using namespace ns_tcpserver;
void Usage(const std::string &proc)
{
std::cout << "\nUsage: " << proc << " [PORT]\n" << std::endl;
}
void debug(int sock)
{
std::cout << "test" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
std::unique_ptr<TcpServer> server_ptr(new TcpServer(atoi(argv[1])));
server_ptr->BindService(debug);
server_ptr->Start();
}
目前的代碼能編譯通過,后續(xù)只要在這個基礎上修改即可。
客戶端
由于TCP面向連接,客戶端無需手動綁定,實現(xiàn)起來比服務端更簡單。
// CalClient.hpp
#include "Sock.hpp"
namespace ns_tcpclient
{
class TcpClient
{
public:
TcpClient(const uint16_t &server_port, const std::string &server_ip = "")
{
_sockfd = _sock.Socket();
if (!_sock.Connect(_sockfd, server_ip, server_port))
{
logMessage(FATAL, "Sock.Connect():errno:%d:%s", errno, strerror(errno));
exit(2);
}
}
~TcpClient()
{
if (_sockfd >= 0)
close(_sockfd);
}
void Start()
{
bool quit = false;
while (!quit)
{
// 獲取請求
// 發(fā)送請求
// ...
}
}
private:
int _sockfd;
Sock _sock;
};
}
- 成員屬性:
_sockfd
保存套接字文件描述符;Sock
類型的_sock
對象,以便客戶端調(diào)用網(wǎng)絡相關(guān)接口。 - 構(gòu)造函數(shù):獲取用戶在命令行傳入的IP和PORT,然后通過
_sock
對象調(diào)用Socket()
接口創(chuàng)建套接字,同時保存文件描述符。 - 析構(gòu)函數(shù):關(guān)閉文件描述符。
-
Start()
:客戶端發(fā)起請求的邏輯,將在后續(xù)實現(xiàn)。
// CalClient.cc
#include <iostream>
#include <memory>
#include "CalClient.hpp"
using namespace ns_tcpclient;
void Usage(const std::string &proc)
{
std::cout << "\nUsage: " << proc << " [IP] [PORT]\n" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
std::unique_ptr<TcpClient> client_ptr(new TcpClient(port, ip));
client_ptr->Start();
}
制定協(xié)議
指定協(xié)議相關(guān)的邏輯將在Protocol.hpp
中實現(xiàn)。
計算器的例子中,通信雙方是客戶端和服務端,它們都需要按照一套相同的規(guī)則通信,因此單獨將這一套相同的規(guī)則包裝。
客戶端和服務端都需要處理請求(request)和響應(response),因此它們都需要對數(shù)據(jù)進行序列化和反序列化。
- 請求:
- 序列化(Serialize):[客戶端生產(chǎn)請求]將結(jié)構(gòu)化的數(shù)據(jù)轉(zhuǎn)換為二進制序列,例如將形如
1 + 1
這兩個操作數(shù)和1個操作符以及2個空格作為一個字符串通過網(wǎng)絡傳輸給對端主機。 - 反序列化(Deserialized):[服務端處理請求]將從網(wǎng)絡中獲取的序列化的二進制序列(字符串)按照規(guī)則,提取出運算需要的兩個操作數(shù)和一個操作符。
- 序列化(Serialize):[客戶端生產(chǎn)請求]將結(jié)構(gòu)化的數(shù)據(jù)轉(zhuǎn)換為二進制序列,例如將形如
- 響應:
- 序列化(Serialize):[服務端生產(chǎn)響應]將得到的結(jié)果和錯誤碼轉(zhuǎn)換為二進制序列的字符串,通過網(wǎng)絡傳輸給請求的發(fā)出者。
- 反序列化(Deserialized):[客戶端處理響應]將從網(wǎng)絡中獲取的結(jié)果和錯誤碼提取出來,然后根據(jù)錯誤碼和錯誤原因的映射情況處理。(錯誤碼一般和不同的錯誤原因有映射關(guān)系)如果沒有出現(xiàn)異常,客戶端則直接輸出結(jié)果。
實際上,為保證效率和穩(wěn)定性,一般會采用成熟的方案,而不會自己訂制協(xié)議。在此只是為了演示。
請求
序列化:
將形如1 + 1
這樣的操作序列化為一個形如"1 + 1"
這樣的字符串。其中包含2兩個空格,2個數(shù)字和1個操作符。為了方便后續(xù)操作,將空格和它的長度用宏定義。
序列化就是將這些操作數(shù)和操作符以及空格拼接成一個字符串。
反序列化:
和序列化的過程相反,從字符串中拆分,然后再轉(zhuǎn)換為操作數(shù)和操作符。
// Protocol.hpp
namespace ns_protocol
{
#define SPACE " "
#define SPACE_LEN strlen(SPACE)
// 請求
class Request
{
public:
// 序列化
// 1 + 1 -> "1 + 1"
// _x + _y
std::string Serialize()
{
std::string str;
str += std::to_string(_x);
str += SPACE;
str += _op;
str += SPACE;
str += std::to_string(_y);
return str;
}
// 反序列化
// 1 + 1 <- "1 + 1"
bool Deserialize(const std::string &str)
{
std::size_t left = str.find(SPACE);
if (left == std::string::npos)
return false;
std::size_t right = str.rfind(SPACE);
if (right == std::string::npos)
return false;
_x = atoi(str.substr(0, left).c_str());
_y = atoi(str.substr(right + SPACE_LEN).c_str());
if (left + SPACE_LEN > str.size())
return false;
else
_op = str[left + SPACE_LEN];
return true;
}
public:
Request() {}
Request(int x, int y, char op)
: _x(x), _y(y), _op(op)
{
}
~Request() {}
public:
int _x;
int _y;
char _op;
};
}
同樣地,它將在命名空間ns_protocol
中被定義。
響應
響應的對象是請求,即處理請求。那么它應該包含兩個操作數(shù)和一個運算符,用_result
保存結(jié)果。
除此之外,運算本身也是有一定前提的,因此它可能會產(chǎn)生錯誤,例如分母不能為零,模數(shù)不能為零。因此使用一個_code
錯誤碼來標識錯誤類型,通常情況下,若干錯誤碼對應著不同的錯誤類型,當出現(xiàn)錯誤時從表中獲取錯誤類型并打印出來,以供調(diào)試和告知。這里的錯誤類型比較少,就直接用了。
// Protocol.hpp
namespace ns_protocol
{
#define SPACE " "
#define SPACE_LEN strlen(SPACE)
#define SEP "\r\n"
#define SEP_LEN strlen(SEP)
// 響應
class Response
{
public:
// 序列化
std::string Serialize()
{
std::string str;
str += std::to_string(_code);
str += SPACE;
str += std::to_string(_result);
return str;
}
// 反序列化
bool Deserialize(const std::string &str)
{
std::size_t pos = str.find(SPACE);
if (pos == std::string::npos)
return false;
_code = atoi(str.substr(0, pos).c_str());
_result = atoi(str.substr(pos + SPACE_LEN).c_str());
return true;
}
public:
Response() {}
Response(int result, int code, int x, int y, char op)
: _result(result), _code(code), _x(x), _y(y), _op(op)
{
}
~Response() {}
public:
int _result; // 結(jié)果
int _code; // 錯誤碼
int _x;
int _y;
char _op;
};
// 發(fā)送數(shù)據(jù)
void Send(int sockfd, const std::string &s)
{
if (send(sockfd, s.c_str(), s.size(), 0) < 0)
{
logMessage(FATAL, "send error, %d:%s", errno, strerror(errno));
exit(5);
}
logMessage(DEBUG, "send %s", strerror(errno));
}
// 接收數(shù)據(jù)
std::string Recv(int sockfd)
{
char inputBuffer[1024];
if (recv(sockfd, inputBuffer, sizeof(inputBuffer), 0) < 0)
{
logMessage(FATAL, "recv error, %d:%s", errno, strerror(errno));
exit(6);
}
logMessage(DEBUG, "recv %s", strerror(errno));
return inputBuffer;
}
}
發(fā)送和接收數(shù)據(jù)
服務端處理獲取請求,需要從網(wǎng)絡中獲?。环斩颂幚碚埱笠院?,可能需要將結(jié)果回傳給客戶端,因此需要將數(shù)據(jù)發(fā)送到網(wǎng)絡中??蛻舳送?。
發(fā)送和接收數(shù)據(jù)的邏輯是和網(wǎng)絡相關(guān)的,它可以放在Sock.hpp
中,但由于服務端中處理請求的邏輯可能并不是被類包裝的,也就沒有Sock
類的成員,因此也就無法直接通過成員調(diào)用被封裝在對象中的發(fā)送和接收數(shù)據(jù)的邏輯。
因此將發(fā)送和接收數(shù)據(jù)的邏輯放在了Protocol.hpp
中,不被ns_protocol
命名空間限制。
// 發(fā)送數(shù)據(jù)
void Send(int sockfd, const std::string &s)
{
if (send(sockfd, s.c_str(), s.size(), 0) < 0)
{
logMessage(FATAL, "send error, %d:%s", errno, strerror(errno));
exit(5);
}
logMessage(DEBUG, "send %s", strerror(errno));
}
// 接收數(shù)據(jù)
std::string Recv(int sockfd)
{
char inputBuffer[1024];
if (recv(sockfd, inputBuffer, sizeof(inputBuffer), 0) < 0)
{
logMessage(FATAL, "recv error, %d:%s", errno, strerror(errno));
exit(6);
}
logMessage(DEBUG, "recv %s", strerror(errno));
return inputBuffer;
}
實際上真正在執(zhí)行接收和發(fā)送數(shù)據(jù)的操作只有幾行,其他部分都是差錯處理和打日志的操作。
需要注意響應Response
和請求Request
中序列化和反序列化的參數(shù)及返回值。
計算邏輯
服務端首先從網(wǎng)絡中獲被客戶端序列化的字符串,然后定義一個Request
類型的對象req
,使用它來將其反序列化。
接著定義一個Response
類型的對象resp
,獲取處理請求后的結(jié)果,序列化以后發(fā)送到網(wǎng)絡中。
計算的邏輯很簡單,就是通過對象中_op
的類型來進行不同的計算。需要注意的是calculatorHelper
的參數(shù)是Request
類型對象,返回值是Response
類型對象。
static Response calculatorHelper(const Request &req)
{
Response resp(0, 0, req._x, req._y, req._op);
switch (req._op)
{
case '+':
resp._result = req._x + req._y;
break;
case '-':
resp._result = req._x - req._y;
break;
case '*':
resp._result = req._x * req._y;
break;
case '/':
if (req._y == 0) resp._code = 1;
else resp._result = req._x / req._y;
break;
case '%':
if (req._y == 0) resp._code = 2;
else resp._result = req._x % req._y;
break;
default:
resp._code = 3;
break;
}
return resp;
}
void calculator(int sockfd)
{
while (1)
{
std::string str = Recv(sockfd);
Request req;
req.Deserialize(str); // 網(wǎng)絡->反序列化
Response resp = calculatorHelper(req); // 處理請求
std::string respStr = resp.Serialize(); // 序列化->網(wǎng)絡
Send(sockfd, respStr); // 回傳數(shù)據(jù)
}
}
測試
回顧之前定義的BindService()
函數(shù),它的作用是將函數(shù)的地址push_back到數(shù)組中,以供線程調(diào)用。
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(0);
}
std::unique_ptr<TcpServer> server_ptr(new TcpServer(atoi(argv[1])));
server_ptr->BindService(calculator);
server_ptr->Start();
}
從標準輸入獲取結(jié)構(gòu)化數(shù)據(jù):
// CalClient.hpp
namespace ns_tcpclient
{
class TcpClient
{
void Start()
{
bool quit = false;
while (!quit)
{
int x, y;
char op;
std::cin >> x >> op >> y;
Request req(x, y, op);
// ...
}
}
};
}
在這里,calculator
就是被push_back的函數(shù)。
簡單測試幾個功能,注意到當除數(shù)為0時,錯誤碼是剛剛設置的1。
存在的問題
- 當前的邏輯當客戶端斷開連接時,服務端會直接退出。
- 服務端獲取序列化的字符串不應該為空,否則后續(xù)的Send()函數(shù)會出現(xiàn)問題。例如常見的問題是一端在寫入時,另一端直接關(guān)閉了。
解決辦法是增加差錯處理邏輯:
- 忽略
SIGPIPE
信號 - 當服務端沒有獲取到數(shù)據(jù)時,就直接break退出。
// Protocol.hpp
// 接收數(shù)據(jù)
bool Recv(int sockfd, std::string *out)
{
char inBuffer[1024];
ssize_t s = recv(sockfd, inBuffer, sizeof(inBuffer) - 1, 0);
if (s > 0)
{
inBuffer[s] = 0;
*out += inBuffer;
}
else if (s == 0)
{
logMessage(FATAL, "client quit %d:%s", errno, strerror(errno));
return false;
}
else
{
logMessage(FATAL, "recv %d:%s", errno, strerror(errno));
return false;
}
logMessage(DEBUG, "recv %s", strerror(errno));
return true;
}
Recv()
增加一個輸出型參數(shù),返回值改為bool類型。
// Calserver.cc
void calculator(int sockfd)
{
while (1)
{
std::string str;
bool ret = Recv(sockfd, &str);
if (ret)
{
Request req;
req.Deserialize(str); // 網(wǎng)絡->反序列化
Response resp = calculatorHelper(req); // 處理請求
std::string respStr = resp.Serialize(); // 序列化->網(wǎng)絡
Send(sockfd, respStr); // 回傳數(shù)據(jù)
}
else break;
}
}
// CalClient.hpp
void Start()
{
bool quit = false;
while (!quit)
{
Request req;
std::cout << "請輸入>>>";
std::cin >> req._x >> req._op >> req._y;
std::string s = req.Serialize(); // 序列化->網(wǎng)絡
Send(_sockfd, s);
std::string buffer;
while (1)
{
bool ret = Recv(_sockfd, &buffer);
if (!ret)
{
quit = true;
break;
}
Response resp;
resp.Deserialize(buffer); // 反序列化->主機
std::string err;
switch (resp._code)
{
case 1:
err = "除0錯誤";
break;
case 2:
err = "模0錯誤";
break;
case 3:
err = "非法操作";
break;
default:
std::cout << "code: " << resp._code << std::endl;
std::cout << "result: " << resp._result << std::endl;
break;
}
if (!err.empty()) std::cerr << err << std::endl;
break;
}
}
}
現(xiàn)在就從代碼邏輯上解決了服務端在讀取時,如果讀取失敗就直接退出。但是沒有解決一方正在寫入時對方把連接關(guān)閉的問題。一般經(jīng)驗:服務端在編寫時,要有較為嚴謹?shù)呐袛噙壿?,一般服務器都要忽略SIGPIPE信號,防止非法寫入的問題。
TCP服務端
TCP是面向字節(jié)流的,從實現(xiàn)上可以簡單地理解為使用字符串。不同于UDP,前者面向數(shù)據(jù)包,相當于發(fā)快遞,是客戶端發(fā)一次,服務端接收一次(調(diào)用一次recvfrom函數(shù)),因此讀取的是完整的Request對象。在TCP協(xié)議的服務端中,可能一個服務端會同時等待多個Request請求,然后一次性讀取多個Request對象,問題在TCP服務端獲取Request時,如何保證數(shù)據(jù)傳輸?shù)耐暾?/mark>。
TCP和UDP是「傳輸控制」協(xié)議。在TCP協(xié)議中,客戶端和服務端調(diào)用函數(shù)發(fā)送或接收數(shù)據(jù)時,只是將數(shù)據(jù)拷貝到緩沖區(qū),并未真正交給函數(shù)處理。 這是因為TCP是流式傳輸,沒有邊界,需要根據(jù)窗口大小和網(wǎng)絡狀況來確定何時發(fā)送數(shù)據(jù)。
所以不能認為調(diào)用send()和recvfrom()等函數(shù)是將數(shù)據(jù)發(fā)送/接收到網(wǎng)絡或?qū)Ψ街鳈C中。
一般來說,當緩沖區(qū)滿了或者超時了,TCP就會發(fā)送數(shù)據(jù)。當收到對方的確認信息或者重置信息,TCP就會接收數(shù)據(jù)。
結(jié)論:
- IO函數(shù)的本質(zhì)都是拷貝函數(shù),因為一般IO接口都會有一個用戶自定義的緩沖區(qū)作為參數(shù)。
- 數(shù)據(jù)何時發(fā)送、每次發(fā)送多少數(shù)據(jù),以及差錯處理等細節(jié),都不是應用層應該關(guān)心的,這取決于「傳輸控制」協(xié)議即TCP協(xié)議決定。
- 由于緩沖區(qū)的存在,發(fā)送數(shù)據(jù)的次數(shù)和接收次數(shù)沒有任何關(guān)系。(例如管道中寫端快讀端慢,兩端讀寫的次數(shù)不一定是相同的)
雖然如此,但是具有這些性質(zhì)并不能保證讀取的完整性。
保證報文的完整性
Protocol.hpp
中的Recv()
函數(shù)的返回值不再是一個字符串,而是以一個輸出型參數(shù)作為返回值,只返回bool類型以表示成功與否。這么做可以讓這個函數(shù)值負責接收數(shù)據(jù),CalServer.cc
中的計算器函數(shù)calculator()
函數(shù)使用一個指針類型的變量作為輸出型參數(shù)獲取處理以后的數(shù)據(jù)。如何在此函數(shù)中解析和處理數(shù)據(jù)。
像之前Recv()
的邏輯,無法保證讀到inbuffer
緩沖區(qū)中的數(shù)據(jù)是一個完整的請求。
因此需要在原有的基礎上對協(xié)議進一步定制。
定制協(xié)議(劃分報文)
在之前寫過的TCP socket中,TCP的客戶端-服務端(c-s)都無法保證報文的完整性,因此在協(xié)議中增加一個成員length
,表示報文的長度。因此,報文的信息就包含了:報文長度+報文內(nèi)容。
形如"length\r\nx_ op_ y_\r\n"
中的'\r\n'
(宏SEP
)是為了將length
屬性字段和其他屬性字段劃分,是通過特殊字符區(qū)分屬性的方法。因為有效信息的長度本身是變化的,因此這個屬性length
的值也可能會變,因此要有一個方法,使得機器無論如何都能得到length
字段。
長度是一個整數(shù),內(nèi)部不會出現(xiàn)特殊符號,那么以length
分隔的字段就是數(shù)據(jù)本身了,即正文。那么length
即使協(xié)議報頭,后面的內(nèi)容就是有效載荷。
為什么使用
\r\n
劃分?
-
\r\n
的可讀性比較好,如果不加的話也可以。
注意:length
不能包含\r\n
(2字節(jié))
這樣就通過多個標記字符'\r\n'
(當做一個整體)將報文劃分為2個區(qū)域:有效數(shù)據(jù)的長度+有效數(shù)據(jù)。這樣當主機從網(wǎng)絡中獲取到報文時,通過這個標記字符就能取出它們,并驗證長度是否正確,這樣就能保證報文的完整性。為了方便使用,用宏定義它們:
#define SEP "\r\n"
#define SEP_LEN strlen(SEP)
注意不能用sizeof計算它的長度,否則會包含最后的’\0’。
編碼
編碼實際上就是將一個完整的報文用一個長度字段的報頭包裝起來,使得有效載荷是完整的。
實際上就是返回一個字符串,這個字符串的格式是這樣的:"length\r\nx_ op_ y_\r\n"
,編碼的格式就是簡單的字符串拼接,中間的有效數(shù)據(jù)(有效載荷)序列化時處理好了,現(xiàn)在要拼接的只有一個長度length
和兩個'\r\n'
:
// 編碼
std::string Encode(std::string &s)
{
std::string package = std::to_string(s.size());
package += SEP;
package += s;
package += SEP;
return package;
}
為什么在后面也要加一個
\r\n
呢?
因為可能一次發(fā)送的報文不止一個,例如:
length\r\nx_ op_ y_\r\nlength\r\nx_ op_ y_\r\n
這樣就能和其他報文區(qū)分了。
解碼
解碼的過程和編碼相反,是將字符串拆分的過程。對應的,是去除長度報頭,提取被包裝的有效載荷的過程。
Recv()
只接受數(shù)據(jù),Decode()
的作用就是根據(jù)定制的協(xié)議,提取數(shù)據(jù)。步驟:
- 用find()提取
length
字段,如果它不存在則返回空串。 - 驗證正文長度是否與
length
相等。 - 截取有效內(nèi)容,去除無效內(nèi)容。只要只有一個完整的報文,就可以提取。
// 解碼
// "length\r\nx_ op_ y_\r\n"
std::string Decode(std::string &buffer)
{
// 查找length字段
std::size_t pos = buffer.find(SEP);
if (pos == std::string::npos)
return "";
// 驗證length和有效數(shù)據(jù)長度是否相等
int size = atoi(buffer.substr(0, pos).c_str());
int surplus = buffer.size() - pos - 2 * SEP_LEN;
if (surplus >= size) // 至少有一個完整的報文,提取
{
buffer.erase(0, pos + SEP_LEN);
std::string s = buffer.substr(0, size);
buffer.erase(0, size + SEP_LEN);
return s;
}
else
return "";
}
修改
在calculator()
和TcpServer::Start()
中增加添加和取出報頭的邏輯。
// CalClient.hpp
void Start()
{
bool quit = false;
std::string buffer;
while (!quit)
{
// 生產(chǎn)請求
Request req;
std::cout << "請輸入>>> "; // 從標準輸入獲取數(shù)據(jù)
std::cin >> req._x >> req._op >> req._y;
// 序列化->網(wǎng)絡
std::string s = req.Serialize();
// 添加長度報頭
s = Encode(s);
// 發(fā)送數(shù)據(jù)到網(wǎng)絡中(服務器)
Send(_sockfd, s);
// 讀取
while (1)
{
bool ret = Recv(_sockfd, &buffer);
if (!ret)
{
quit = true;
break;
}
std::string package = Decode(buffer); // 協(xié)議解析(提取有效載荷)
if (package.empty()) // 得到的字符串為空,進行下一次讀取
continue; // 保證了讀取報文的安全性
Response resp;
resp.Deserialize(package); // 反序列化->主機(注意是有效載荷)
std::string err;
switch (resp._code) // 打印錯誤信息
{
case 1:
err = "除0錯誤";
break;
case 2:
err = "模0錯誤";
break;
case 3:
err = "非法操作";
break;
default:
std::cout << "result: " << resp._result << std::endl;
break;
}
if (!err.empty())
std::cerr << err << std::endl;
break;
}
}
}
// CalServer.cc
void calculator(int sockfd)
{
std::string inbuffer;
while (1)
{
bool ret = Recv(sockfd, &inbuffer); // 從網(wǎng)絡中讀取請求
if (!ret)
break;
std::string package = Decode(inbuffer); // 協(xié)議解析(提取有效載荷)
if (package.empty()) // 得到的字符串為空,進行下一次讀取
continue; // 保證了讀取報文的安全性
logMessage(NORMAL, "inbuffer: %s", package.c_str());
Request req;
req.Deserialize(package); // 網(wǎng)絡->反序列化(注意要使用有效載荷)
Response resp = calculatorHelper(req); // 處理請求
std::string respStr = resp.Serialize(); // 序列化->網(wǎng)絡
respStr = Encode(respStr); // 添加長度報頭
Send(sockfd, respStr); // 回傳數(shù)據(jù)
}
}
測試
簡單測試一下:
守護進程版本
守護進程(Daemon)是一種在后臺運行的特殊進程,它不屬于任何終端,也不受用戶的交互控制,通常用來執(zhí)行一些系統(tǒng)服務或應用程序。
像上面包括之前博文實現(xiàn)的UDP和TCP服務器,都是在前臺運行的進程(即運行起來以后光標一直在閃動,因為需要用無限循環(huán)使邏輯不斷運行)。關(guān)于守護進程:
- 每當一個用戶登錄計算機時,系統(tǒng)會自動創(chuàng)建一個新的shell會話,通常是bash、zsh、fish等。那么它(這個窗口)就是一個前臺進程(如果你試著kill掉某個bash進程,你的窗口就會被關(guān)閉)。每一個shell會話,只允許存在一個前臺進程,而可以用若干個后臺進程。用戶在這個窗口中執(zhí)行的各種命令的父進程都是shell會話進程(如bash)。
- 使用PID標識進程ID,使用PPID標識父進程ID,使用PGID標識進程組ID。
- 可以使用
|
管道同時啟動多個進程,這些進程是兄弟關(guān)系,它們的父進程是會話進程(例如bash),它作為前臺進程和用戶進行交互。這些兄弟進程可以使用匿名管道進行通信。 - 被同時啟動的進程總體被稱為進程組,通常第一個進程被作為這個進程的組長進程。這個進程組提供的服務就稱之為會話。
每當用戶登錄計算機,OS會為用戶創(chuàng)建一個新的會話,以提供相應服務;如果用戶退出登錄(如注銷),理論上OS會釋放所用對應的資源:
- 前臺服務:(有可能)退出(取決于OS)。
- 后臺服務:后臺服務不屬于任何終端,這種服務一般被期望用于長期服務的進程,那么它會自成一個會話,不被shell會話關(guān)閉而受影響,稱之為“守護進程”。
如何讓進程(組)自成一個會話?
可以使用setsid命令。它不僅會創(chuàng)建一個新的進程組,還會在一個新的會話中啟動命令。例如:
setsid COMMAND
這會在一個新的會話和進程組中運行COMMAND??梢允褂?code>ps -o pid,sid,pgid,comm命令來查看進程的會話ID(SID)和進程組ID(PGID)。
setsid()要成功被執(zhí)行,有什么前提?
- 調(diào)用進程不能是一個進程組的組長進程。
如果調(diào)用進程是一個進程組的組長進程,setsid()會返回-1,并設置errno為EPERM(表示操作不被允許)。這是為了防止一個進程組的組長進程將自己放到一個新的會話中,而其他進程仍然留在原來的會話中,這樣會破壞會話和進程組的兩級層次結(jié)構(gòu)。為了確保setsid()能夠成功,可以先調(diào)用fork(2)并讓父進程退出,而子進程(一定不是一個進程組的組長進程)調(diào)用setsid()。
實現(xiàn)
下面將在進程中調(diào)用該函數(shù),讓它自己成為一個守護進程:
#pragma once
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void MyDaemon()
{
// 1. 忽略信號
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 不要讓自己成為組長
if (fork() > 0)
exit(0);
// 3. 調(diào)用setsid
setsid();
// 4. 將標準輸入,標準輸出和標準錯誤的重定向到該路徑
// 使得守護進程不能直接向顯示器打印消息
int devnull = open("/dev/null", O_RDONLY | O_WRONLY);
if (devnull > 0)
{
dup2(0, devnull);
dup2(1, devnull);
dup2(2, devnull);
close(devnull);
}
}
在CalServer.cc
中調(diào)用它:
int main(int argc, char *argv[])
{
// ...
MyDaemon();
std::unique_ptr<TcpServer> server_ptr(new TcpServer(atoi(argv[1])));
server_ptr->BindService(calculator);
server_ptr->Start();
}
守護進程為什么要將標準輸入,標準輸出和標準錯誤的重定向?
- 守護進程通常不需要和用戶交互,所以關(guān)閉標準輸入可以防止它們被意外地阻塞在等待用戶輸入的地方。
- 守護進程可能會從父進程繼承一些打開的文件描述符,這些文件描述符可能是不需要的或者占用了系統(tǒng)資源。關(guān)閉所有不必要的文件描述符,包括標準輸入,標準輸出和標準錯誤,可以釋放這些資源,并避免對這些文件的誤操作。
- 守護進程可能會產(chǎn)生一些輸出或者錯誤信息,如果不重定向到合適的地方,這些信息可能會丟失或者干擾其他程序。重定向標準輸出和標準錯誤到一個日志文件或者/dev/null,可以方便地記錄或者忽略這些信息。
總的來說,不能向顯示器打印消息的原因是它已經(jīng)是獨立的進程(組),和當前的會話(終端)已經(jīng)沒有關(guān)系了。一旦打印消息,就會被暫停或者被終止。
簡單測試一下:
可以看到,這個進程自成會話,表現(xiàn)是PPID=1,PID和PGID相同,TTY=?。守護進程是孤兒進程的一種,守護進程自成會話。這樣就能讓服務端在后臺運行,關(guān)閉終端窗口也不影響服務,這樣就能無時無刻的為客戶端提供服務了。而且后臺進程不會影響當前終端窗口的其他任務的執(zhí)行,因為含有無限循環(huán)的前臺進程會阻塞I/O,它會一直占用CPU資源,導致其他進程無法得到調(diào)度。
在同一shell會話中也能啟動客戶端進程測試:
json版本
json介紹
JavaScript Object Notation,是一種輕量級的數(shù)據(jù)交換格式,使用人類可讀的文本來存儲和傳輸由屬性值對和數(shù)組(或其他可序列化的值)組成的數(shù)據(jù)對象。JSON是一種語言無關(guān)的數(shù)據(jù)格式,它源自于JavaScript,但是許多現(xiàn)代編程語言都包含了生成和解析JSON格式數(shù)據(jù)的代碼。JSON文件使用.json作為擴展名,但不是強制的。
JSON有以下特點:
- 數(shù)據(jù)以鍵值對的形式表示,類似于JavaScript對象的屬性。
- 數(shù)據(jù)由逗號分隔,花括號保存對象,方括號保存數(shù)組。
- JSON是“自描述”的,易于理解和使用。
- JSON可以用于存儲和交換各種類型的數(shù)據(jù),如數(shù)字、布爾值、字符串、日期、對象、數(shù)組等。
JSON有以下用途:
-
JSON通常用于從服務器向網(wǎng)頁發(fā)送數(shù)據(jù)。
-
JSON可以用于在不同的編程語言之間進行數(shù)據(jù)交換。
-
JSON可以用于存儲配置文件、日志文件、用戶偏好等信息。
-
JSON可以用于構(gòu)建復雜的數(shù)據(jù)結(jié)構(gòu),如樹、圖、地圖等。
-
JSON數(shù)據(jù)格式比較簡單,易于讀寫,格式都是壓縮的,占用帶寬小。
-
JSON易于解析,客戶端JavaScript可以簡單地通過eval()函數(shù)進行JSON數(shù)據(jù)的讀取。
-
JSON格式能夠直接為服務器端代碼使用,大大簡化了服務器端和客戶端的代碼開發(fā)量,但是完成的任務不變,且易于維護。
優(yōu)點
相比于手動對字符串encode和手動decode,后者有以下缺點:
- 需要編寫額外的代碼,增加了開發(fā)和維護的成本和復雜度。
- 容易出錯,比如忘記轉(zhuǎn)義特殊字符,或者解析時沒有考慮到邊界情況。
- 對字符串encode和手動decode可能導致數(shù)據(jù)類型的丟失或不一致,比如數(shù)字、布爾值、日期等在字符串中無法區(qū)分。
- 對字符串encode和手動decode可能導致數(shù)據(jù)結(jié)構(gòu)的不清晰或不規(guī)范,比如數(shù)組、對象、嵌套等在字符串中無法直觀地表示。
實際上,之前手動寫的協(xié)議是一個很粗略的協(xié)議,實際上用戶只要輸入兩個操作數(shù)和一個操作符,中間的空格理論上可以是任意個,諸如此類的問題還有很多。因此實際應用中會使用成熟的協(xié)議。
實現(xiàn)
C++沒有內(nèi)置的json庫,但是有很多第三方的json庫可以使用,如RapidJSON, JsonCpp, sonic-cpp等。安裝JsonCpp:
sudo yum install jsoncpp-devel
驗證安裝是否成功:
在編譯選項中要增加-ljsoncpp
鏈接該第三方庫。
用法
注意頭文件的包含。
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
std::string a = "hello";
std::string b = "world";
char c = '!';
std::string d = "用變量賦值";
Json::Value root;
root["a"] = a;
root["b"] = b;
root["c"] = c;
root["d"] = d;
Json::Value son;
son["aa"] = 233;
son["str"] = "hi world(直接插入)";
root["son(嵌套)"] = son;
Json::StyledWriter swriter; // 格式化輸出(適合人類閱讀)
Json::FastWriter fwriter; // 無格式輸出(適合機器讀取)
std::string sstr1 = swriter.write(root);
std::string fstr1 = fwriter.write(root);
std::cout << "格式化輸出:" << std :: endl << sstr1;
std::cout << "無格式輸出:" << std :: endl << fstr1;
std::string sstr2 = swriter.write(son);
std::string fstr2 = fwriter.write(son);
std::cout << "格式化輸出:" << std :: endl << sstr2;
std::cout << "無格式輸出:" << std :: endl << fstr2;
}
-
初始化鍵值對主要用兩種辦法,一是先初始化各種類型的變量,然后賦值給鍵(key);二是直接用值(value)賦值給鍵(key)。后者更方便。
-
root["son"] = son
表示將son對象嵌套進root對象中。 -
其次轉(zhuǎn)換為字符串有兩種格式,一是格式化,比較美觀,適合人類閱讀,方便調(diào)試;而是無格式,比較緊湊,能節(jié)省空間,提高傳輸效率。
例如:
實現(xiàn)
下面修改Request
和Response
中序列化和反序列化的邏輯(Protocol.hpp):
// Request::Serialize
std::string Serialize()
{
Json::Value root;
root["x"] = _x;
root["y"] = _y;
root["op"] = _op;
Json::FastWriter fwriter;
return fwriter.write(root);
}
// Request::Deserialize
bool Deserialize(const std::string &str)
{
Json::Value root;
Json::Reader reader;
reader.parse(str, root);
_x = root["x"].asInt();
_y = root["y"].asInt();
_op = root["op"].asInt();
return true;
}
// Response::Serialize
std::string Serialize()
{
Json::Value root;
root["code"] = _code;
root["result"] = _result;
root["xx"] = _x;
root["yy"] = _y;
root["zz"] = _op;
Json::FastWriter fwriter;
return fwriter.write(root);
}
// Response::Deserialize
bool Deserialize(const std::string &str)
{
Json::Value root;
Json::Reader reader;
reader.parse(str, root);
_code = root["code"].asInt();
_result = root["result"].asInt();
_x = root["xx"].asInt();
_y = root["yy"].asInt();
_op = root["zz"].asInt();
return true;
}
這樣就能保證每次獲取的數(shù)據(jù)是一個完整的Json字節(jié)流。
簡單測試一下(為了方便測試,暫時不作為守護進程):
使用成熟的協(xié)議,能很方便地擴充或修改協(xié)議,可以在數(shù)據(jù)中包含x、y和op,那么就不用在函數(shù)內(nèi)部使用臨時字符串保存數(shù)據(jù)了,而Json數(shù)據(jù)本身就攜帶了這些信息。
例如日志記錄的Json字符串:文章來源:http://www.zghlxwxcb.cn/news/detail-471253.html
[NORMAL] [1685870721] inbuffer: {"op":42,"x":55,"y":2}
源代碼文章來源地址http://www.zghlxwxcb.cn/news/detail-471253.html
到了這里,關(guān)于認識協(xié)議【網(wǎng)絡基礎】的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!