一般的tcp協定示例,大家給出的demo都是類似一個helloworld的示例,簡單羅列了socket建立,建立連接配接,發送資料,關閉連接配接的過程,實際上tcp通信确實也就這麼多内容。但是,在實際的開發中,我們用tcp通信,肯定不會隻是發送一句簡單的“你好”。
實際應用中,我們需要自定義一個協定,也就是protocol,然後與服務端約定網絡位元組序,最後雙方都能根據協定實作資料編碼與解碼即可。
自定義協定,沒有固定的格式,沒有嚴格的資料類型限制,隻要雙方都認可就行了。因為通信的雙方都需要編解碼,不存在隻有一方需要編碼或者解碼,或者說我用協定發資料,你回複資料就用一個"ok"或者200就搞定了,既然是協定,也就是雙方都要遵守,tcp是雙向通信,兩邊都需要編解碼。
這裡給出一個簡單的demo,說明tcp通信如何通過自定義協定來實作。
自定義協定一般重點在于發送方,因為這是資料源頭,是以這裡隻給出一個client,服務端我用一個netcat的工具來模拟,這裡主要看資料的定義,以及最終形成的網絡位元組序。
c語言預設采用的是小端序表示的網絡位元組序。這個會跟解碼有關,一般而言,我們資料類型都遵守大端序,尤其是在java語言中,位元組序預設就是大端序,這個順序跟我們的認知是相符合的,也就是高位在前,低位在後,比如0x0001,大端序就表示的是1,這也符合我們的認知習慣,尤其是對于十進制非常熟悉的人來說。
而小端序恰好相反,比如0x0001,實際上他表示卻是0x0100,高位在後,低位在前,不符合我們的認知習慣。
當然,雖然說大端序,小端序是相反的,但是并不是說我們在表示的時候就是相反的,剛才舉的示例都是在轉換之後的表現,而且隻有數字類型才會表現這種情況,我們通常表示數字都不會直接使用這種十六進制來表示,但是在協定的表示中,為了與位數對齊,會使用0x0001來表示short類型的1。
定義協定格式如下所示:
字段 | 資料類型 | 備注 |
flag | short | 辨別 |
version | short | 版本 |
type | short | 類型 |
reserved | short | 保留位 |
length | int | 資料長度 |
payload | char[] | 資料體 |
checksum | char | 校驗位 |
前面說過,協定沒有固定的格式,也沒有嚴格的資料類型限制,根據需要定義。但是我是基于以下幾點來做的考慮:
1、協定最好有個明顯的起始标志
2、一般有個操作類型,就是表示這條封包是幹什麼的,不可能什麼東西都在資料體中描述。
3、封包定義可能為了将來的考慮,設定一個保留位,以免考慮不周,将來需要新增字段整個協定就廢了。
4、封包中一個長度來表示資料體的長度,這個可以使用定長,但是定長多長合适也是一個問題,将來可能有更大的資料進來也說不定,設定太大了,很多時候都是預設值,增加了網絡傳輸的壓力,設定太小了,将來擴充沒法玩。
5、資料體是真實的封包内容,加密或者不加密都可以,使用json還是其他格式也可以。
6、校驗位,用來校驗封包的正确性,一般有意義,但是如果所有封包都有問題,那隻能說傳輸太不可靠了,網絡有問題或者程式哪裡有bug。雖說是校驗位,但是不做強制要求,本示例就沒有指派,預設0x00。
因為在c語言中定義結構體,存在一個位對齊的問題,是以本協定前面的幾個字段都使用short,統一起來,最後一位使用char。
上面說了這麼多,都是個人在實際中的總結,下面直接show me the code:
main.cpp
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
typedef struct _pktdata{
uint16_t flag;
uint16_t version;
uint16_t type;
uint16_t reserved;
uint32_t length;
uint16_t payload[1024];
uint8_t checksum;
}pktdata;
void build(pktdata *data){
data->flag = (0x5aa5);
data->version = (0x0001);
data->type = (0x0001);
data->reserved = (0xffff);
const char* payload = "{\"name\":\"buejee\",\"age\":18}";
memcpy(data->payload,payload,strlen(payload));
data->length = strlen(payload);
data->checksum = (0x00);
}
int main()
{
int sockfd,ret;
sockfd = socket(AF_INET,SOCK_STREAM,0);
if(-1 == sockfd){
printf("create socket error : (%d)\n",errno);
return 0;
}
printf("socket start.\n");
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(6666);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
ret = connect(sockfd,(struct sockaddr*)&addr,sizeof(addr));
if(-1 == ret){
printf("connect error : (%d)\n",errno);
return 0;
}
for(int i=0;i<10;i++){
char sendData[1024];
pktdata pd;
build(&pd);
int len = pd.length+12;
memcpy(sendData,(pktdata *)&pd,len);
send(sockfd,sendData,len+1,0);
sleep(3);
}
close(sockfd);
printf("done.\n");
return 0;
}
這段代碼是在linux或者mac下運作的,擴充名是cpp,其實都是c的東西,這個不重要。因為平台之間的差别太大,windows下很多api都發生了改變,包括引用的頭檔案都有很大差别。這裡千萬不要在windows下去試。
這段代碼隻是表示了client如何自定義協定發送給服務端的,是以我們要運作,需要先開啟一個服務端。
我這裡在mac下,可以使用netcat這個指令,也是brew install netcat安裝的,linux下面也有這個指令。
啟動一個tcp server并開啟輸出,這裡使用輸出可以很明顯的看到最終接收的十六進制數。
netcat -lp 6666 -o bin.out
接着在ide中運作上面的程式,其實這個單檔案程式可以直接g++編譯,然後運作可執行程式。
我在程式中設定預設發送10次消息,然後停止。是以我們看服務端的輸出檔案:
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
Received 39 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 e":18}.
這裡模拟了一個真實的自定義協定發送資料,并最終接收到資料。
我們在實際的開發中,看到的tcp協定封包資料大部分都是這個樣子的,涉及到很多位元組轉數字,位元組轉字元串,位元組轉十六進制。
需要注意的是小端序,大端序。上面的程式預設傳輸的網絡位元組序數字類型就是小端序。比如flag=(0x5aa5),最終傳輸變成了A55A,version=(0x0001),傳過來就是0100,這裡特别要注意的是length這個字段,1A 00 00 00,這個很明顯就是小端序,十進制的26在正常的位元組序中,肯定是00 00 00 1A。這個數字還關系到後面取資料體的内容,是以如果端序弄反了,取到的就是一個天文數字,最終影響到協定解析。
這裡看到的服務端接收的封包裡面正好是10條分開的封包,是因為我在send調用之後,sleep讓程式睡眠了3秒,是以接收端資料是斷斷續續的,沒有連在一起,如果不睡眠,用戶端一下子發送完成,服務端接收的就是10條全部合在一起的封包。
Received 390 bytes from the socket
00000000 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 .Z..........{"na
00000010 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 me":"buejee","ag
00000020 65 22 3A 31 38 7D 00 A5 5A 01 00 01 00 FF FF 1A e":18}..Z.......
00000030 00 00 00 7B 22 6E 61 6D 65 22 3A 22 62 75 65 6A ...{"name":"buej
00000040 65 65 22 2C 22 61 67 65 22 3A 31 38 7D 00 A5 5A ee","age":18}..Z
00000050 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 6D 65 ..........{"name
00000060 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 65 22 ":"buejee","age"
00000070 3A 31 38 7D 00 A5 5A 01 00 01 00 FF FF 1A 00 00 :18}..Z.........
00000080 00 7B 22 6E 61 6D 65 22 3A 22 62 75 65 6A 65 65 .{"name":"buejee
00000090 22 2C 22 61 67 65 22 3A 31 38 7D 00 A5 5A 01 00 ","age":18}..Z..
000000A0 01 00 FF FF 1A 00 00 00 7B 22 6E 61 6D 65 22 3A ........{"name":
000000B0 22 62 75 65 6A 65 65 22 2C 22 61 67 65 22 3A 31 "buejee","age":1
000000C0 38 7D 00 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 8}..Z..........{
000000D0 22 6E 61 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C "name":"buejee",
000000E0 22 61 67 65 22 3A 31 38 7D 00 A5 5A 01 00 01 00 "age":18}..Z....
000000F0 FF FF 1A 00 00 00 7B 22 6E 61 6D 65 22 3A 22 62 ......{"name":"b
00000100 75 65 6A 65 65 22 2C 22 61 67 65 22 3A 31 38 7D uejee","age":18}
00000110 00 A5 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E ..Z..........{"n
00000120 61 6D 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 ame":"buejee","a
00000130 67 65 22 3A 31 38 7D 00 A5 5A 01 00 01 00 FF FF ge":18}..Z......
00000140 1A 00 00 00 7B 22 6E 61 6D 65 22 3A 22 62 75 65 ....{"name":"bue
00000150 6A 65 65 22 2C 22 61 67 65 22 3A 31 38 7D 00 A5 jee","age":18}..
00000160 5A 01 00 01 00 FF FF 1A 00 00 00 7B 22 6E 61 6D Z..........{"nam
00000170 65 22 3A 22 62 75 65 6A 65 65 22 2C 22 61 67 65 e":"buejee","age
00000180 22 3A 31 38 7D 00 ":18}.
這裡也引出了一個問題,就是資料傳輸 接收端存在的拆包問題。簡單粗暴的解決辦法就是間隔時間段發送,但是實際中這個辦法不管用,因為會有很多用戶端不同時間發送大量的資料,是以間隔發送不能從根本上解決拆包問題。