一般的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
#include
#include
#include
#include
#include
#include
#include
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}.
这里也引出了一个问题,就是数据传输 接收端存在的拆包问题。简单粗暴的解决办法就是间隔时间段发送,但是实际中这个办法不管用,因为会有很多客户端不同时间发送大量的数据,所以间隔发送不能从根本上解决拆包问题。