UDP/TCP网络编程
UDP/TCP网络编程
简介:文本介绍两种分别基于UDP和TCP传输协议的应用层程序,并进行简单的应用。
引言
网络是用户与用户之间、用户与服务器之间通讯的桥梁。计算机网络在实际应用中常常被分为五层,在软件层面常常接触到的即是网络层、传输层以及应用层。现有的网络体系结构中,大部分的网络终端是通过网络层的IP
协议连接,基于传输层的面向连接的TCP
协议和面向无连接的UDP
协议传输数据。在应用层根据对数据传输和连接的要求采用传输层的两种不同协议。DNS
协议用于将域名转换为IP
地址,往往需要频繁向服务器发起请求,采用UDP
协议省去了繁杂的可靠传输机制,大大提高了效率。而HTTP
协议,需要确保连接内的数据准确无误地发送到目的地,因而采用TCP
协议进行数据传输。本文分别介绍DNS
、http
的请求和响应报文结构,并使用C语言实现基本功能。
协议及原理
DNS
协议
该协议用于将人类可阅读的域名转换为便于计算机处理的IP
地址。同样采用C/S
架构,常常在用户本地网络内设有相应的本地域名服务器。当用户请求某个域名时,先向本地服务器发起请求,一般情况下本地域名服务器会有短时缓存,若在缓存中没有可匹配条目,则依次向根服务器等服务器逐级发起请求。
本文所实现的就是通过协议报文的规定格式向服务器发送请求,接收到请求之后解析响应内容。需将Header
和Queries
部分进行填充,基于socket
连接发送和接收数据。
HTTP
协议
与DNS
不同,HTTP
协议基于TCP
协议通讯,采用请求头响应头的方向传输数据。具体来说,在与服务器通信时只需要对请求头进行字段填充即可。
代码实现
DNS
的请求与响应
1. 初始化DNS
中的请求头和请求内容体。需要注意的是,在定义各个字段时要按照请求头格式选择该字段争取的字节长度。
//初始化请求头
struct dns_header{
//分别对应DNS协议请求头的各字段
//16位数据 使用short类型
unsigned short id;//会话标识
unsigned short flags;//标志
unsigned short questions;///问题数
unsigned short answer;///回答
unsigned short authority;
unsigned short additional;
};
//定义询问内容
struct dns_question{
int length;
unsigned short qtype;
unsigned short qclass;
unsigned char* name;
};
便于后续处理,将域名与IP
一并封装:
struct dns_item{
char* domain;
char* ip;
};
分别初始化请求头和询问体。在请求头中,会话标识标志着本次请求的唯一标识;flage
字段用于控制该请求的功能:
位(Bit) | 字段名 | 长度 | 含义 |
---|---|---|---|
0 (最高位) | QR | 1 bit | 查询/响应标志(0=Query,1=Response) |
1-4 | Opcode | 4 bits | 操作码,表示查询类型(0=标准查询,1=反向查询,2=服务器状态请求等) |
5 | AA | 1 bit | 权威应答(Authoritative Answer),仅响应有效(1=应答服务器是权威) |
6 | TC | 1 bit | 截断标志(Truncated),1=报文因过长被截断(通常用于UDP) |
7 | RD | 1 bit | 递归期望(Recursion Desired),1=客户端请求递归查询 |
8 | RA | 1 bit | 递归可用(Recursion Available),响应有效(1=服务器支持递归) |
9-11 | Z | 3 bits | 保留位,必须为0 |
DNS
作为请求报文使用递归查询所以该字段值为:00000001 00000000
,各字段含义如下:
int dns_create_header(struct dns_header* header){
if(header == NULL) return -1;
memset(header,0,sizeof(struct dns_header));
//生成随机值作为id
srandom(time(NULL));
header->id = random();
header->flags = htons(0x0100);//将数字转为网络序
header->questions = htons(1);//一次性回答一个问题
return 0;
}
请求体用于存放需要查询的域名等信息,其中域名在请求体中存放的规则:以"."
隔断域名,每个被隔断的部分以该部分的长度开头,例如请求域名www.baidu.com
在请求体中应该表示为3www5baidu3com00
;还有常用的查询类型type
和查询类class
分别表示该请求所用的IP
地址类别和协议族等信息。
初始化查询类型type
和查询类class
并赋值,此处表示使用ipv4
地址internet
协议族。
//初始化查询类别
question->qtype = htons(1);
question->qclass = htons(1);
初始化分配请求问题的空间,预留两个位置用于表示结尾以及为最后一个标签的长度前缀预留空间。
question->name = (char*)malloc(strlen(hostname) + 2);
question->length = strlen(hostname) + 2;
按照上述规则处理域名,借用临时变量处理hostname中的值。
char* qname = question->name;
char* hostname_dup = strdup(hostname);
定义分隔符,使用方法strtok()
该方法按照分隔符对指定字符串进行分割,依次取到被截断的值。
const char* delim = ".";//用于截断的标准
char* token = strtok(hostname_dup,delim);
代码运行到此处,若hostname
值为www.baidu.com
则token
的值为www
,此后循环地从strtok()
取出字符放入到qname
中。在循环中,先取到截断部分的字符长度放入qname
随后放入token
值,进入下一次循环。
while(token != NULL){
size_t len = strlen(token);//将数字先放入
*qname = len;
qname++;
strncpy(qname,token,len+1);//此处+1多考虑一位'\0'
qname += len;
token = strtok(NULL,delim);//因为一开始有值 所以不需要在在第一个参数给值
}
至此请求头和请求体的内容填充完成,只需要依次将数据放入请求中:
memset(request,0,rlen);
memcpy(request,header,sizeof(struct dns_header));
int offset = sizeof(struct dns_header);
memcpy(request+offset,question->name,question->length);
offset += question->length;
memcpy(request+offset,&question->qtype,sizeof(question->qtype));
offset += sizeof(question->qtype);
memcpy(request+offset,&question->qclass,sizeof(question->qclass));
offset += sizeof(question->qclass);
- 连接服务器
所有数据都准备完成,通过socket连接服务器发送请求并接收响应。
int sockfd = socket(AF_INET,SOCK_DGRAM,0);//初始化socket
struct sockaddr_in servaddr = {0};
servaddr.sin_family = AF_INET;//设定协议族
servaddr.sin_port = htons(DNS_SERVER_PORT);//设置目标端口号
servaddr.sin_addr.s_addr = inet_addr(DNS_SERVER_IP);//设置目标ip
虽然UDP
无需三报文挥手建立连接,但先与服务器段建立连接可以简化后续的操作。
int ret = connect(sockfd,(struct sockaddr*)&servaddr,sizeof(servaddr));
分别构造请求头、请求体:
struct dns_header header = {0};
dns_create_header(&header);
struct dns_question question = {0};
dns_create_question(&question,domain);
准备一个缓冲区存放需要处理的域名,随后使用sendto()
方法,向目标地址即DNS
服务器发送请求。
char request[1024] = {0};
int length = dns_build_request(&header,&question,request,1024);
int slen = sendto(sockfd,request,length,0,(struct sockaddr*)&servaddr,sizeof(struct sockaddr));//返回值表示实际发送数据长度
- 接收并处理信息
若上述流程成功连接到服务器则会收到服务器的响应,使用缓冲区接收到服务器的响应再对响应进行解析得出对应的IP
地址。
char response[1024] = {0};
struct sockaddr_in addr;
size_t addr_len = sizeof(struct sockaddr_in);
int n = recvfrom(sockfd,response,sizeof(response),0,(struct sockaddr*)&addr,(socklen_t*)&addr_len);
此处定义的struct sockaddr_in addr
在接收信息时会自动填充为发送方即服务器的IP
地址,用于确认信息是否是来自于目标服务器。此时响应信息被存放在缓冲区response
中。响应信息同样是按照请求头请求体组织,所有的信息都存放在answer
部分。该部分有以下多种信息:
字段 | 长度 | 描述 |
---|---|---|
NAME | 变长 | 域名(可能使用压缩指针,如0xC00C ) |
TYPE | 2字节 | 记录类型(如A=1 , AAAA=28 , CNAME=5 ) |
CLASS | 2字节 | 记录类(通常IN=1 ,表示Internet) |
TTL | 4字节 | 缓存时间(秒),如3600 表示可缓存1小时 |
RDLENGTH | 2字节 | RDATA的数据长度 |
RDATA | 变长 | 记录的实际数据(如IP地址、域名等) |
在answer
部分,回答信息按以上方式组成一条;而在一般情况下响应往往包含多条回答,多条数据按照这种方式依次排列。而此时就会出现一个问题,会频繁出现相同的域名,考虑到性能采用压缩指针。
例如当有响应报文如下
Offset 0x00: ... (Header) ...
Offset 0x0C: 03 77 77 77 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 # Question:
Offset 0x1C: C0 0C 00 01 00 01 00 00 0E 10 00 04 5D B8 D8 22 # Answer RR
前两行分别表示请求头和请求体,在0x1c
位置解析到NAME
字段值为C0 0C
代表此处使用了压缩指针,该字段转换位比特11000000 00001100
读取除前两位之外的值为12,表示该部分内容去到12位置去读取,即offset
值为0x0C
,此时直接解析该位置上的值即可。
首先判断当前NAME
字段是否为压缩指针,将值与0xC0
相与,即相同为1不同为0,若结果与0xC0
相等,说明该值有与0xC0
相同位置的1。
static int is_pointer(int in){
return ((in & 0xC0) == 0xC0);
}
服务器返回的数据类型一般情况下为unsigned char*
类型指针的数组ptr
,往往在处理数据时逐字节进行访问。在answer
前几个字节用于解析域名。
while(1){
flag = (int)ptr[0];//读取当前字节
if(flag == 0) break;//当读到末尾 即0就结束
//检查是否是dns压缩指针
if(is_pointer(flag)){
n = (int)ptr[1];//获取指针偏移量
ptr = chunk + n;//跳转到指针指向的位置 chunk相对起始位置
dns_parse_name(chunk,ptr,out,len);//递归解析
break;
}else{//处理非指针
ptr++;
memcpy(pos,ptr,flag);
pos += flag;
ptr += flag;
*len += flag;
if((int)ptr[0] != 0){
memcpy(pos,".",1);
pos += 1;
(*len) += 1;
}
}
}
因为域名会使用分片存储方式,例如www.baidu.com
会以www+指针-->baidu+指针-->com+结束符
。所以使用变量chunk
记录相对起始位置,递归进行域名解析。
例如有数据如下:
Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 20
Data: 04 6D 61 69 6C C0 10 00 01 00 01 00 00 00 00 00 07 61 72 63 68 69 76 65 C0 1C 00 00 03 63 6F 6D 00
指针ptr
从0X00
位置开始,读取长度为0x04
将后续四个字节解析;到0x05
高位为11读取偏移量0x10
跳转到0x10
,读取长度为0x07
将后续七个字节解析;指针来到0x18
,高位为11读取偏移量为0x1C
;跳转到0x1C
位置,长度为0x03
,解析后续三个字节。直到0x20
位置值为0x00
域名结束返回。
ptr++;
memcpy(pos,ptr,flag);
pos += flag;
ptr += flag;
*len += flag;
if((int)ptr[0] != 0){
memcpy(pos,".",1);
pos += 1;
(*len) += 1;
}
若当前NAME
字段不是压缩指针,则ptr++
跳过长度字节,flag = (int)ptr[0]
所以将指定长度的值复制到pos
中,之后其指针都要对应后移已加入的长度。同时判断下一个标签是否为0
即结束标签,若不为0
则在pos
中加入一个"."
更新长度和指针位置。
基于上述方法实现dns_parse_response()
,输入整个服务器的响应请求进行处理。首先需要跳过头部所有的内容和查询的部分内容。
int i = 0;
unsigned char* ptr = buffer;//移动指针用于遍历DNS报文
ptr += 4;//跳过事务ID
int querys = ntohs(*(unsigned short*)ptr);//读取查询数量(2字节) 使用ntohs转换为主机字节
ptr += 2;
int answers = ntohs(*(unsigned short*)ptr);
ptr += 6;
//跳过所有的查询记录
for(i = 0;i < querys;i++){
while(1){
int flag = (int)ptr[0];
ptr += (flag + 1);
if(flag == 0) break;
}
ptr += 4;
}
上述代码控制指针跳过所有无关代码,并从中取出了answers
即回答数量。
字段 | 长度 | 描述 |
---|---|---|
NAME | 变长 | 域名(可能使用压缩指针,如0xC00C ) |
TYPE | 2字节 | 记录类型(如A=1 , AAAA=28 , CNAME=5 ) |
CLASS | 2字节 | 记录类(通常IN=1 ,表示Internet) |
TTL | 4字节 | 缓存时间(秒),如3600 表示可缓存1小时 |
RDLENGTH | 2字节 | RDATA的数据长度 |
RDATA | 变长 | 记录的实际数据(如IP地址、域名等) |
按照上述结构解析每一个回答。首先解析域名提取出记录类型,得到其他相关数据。
//清空aname缓冲区
bzero(aname,sizeof(aname));
len = 0;
//解析域名
dns_parse_name(buffer,ptr,aname,&len);
ptr += 2;
//读取记录类型
type = htons(*(unsigned short*)ptr);
ptr += 4;
//读取TTL(4字节)并转换字节序
//读取数据长度(2字节)并转换字节序
//移动指针6字节
ttl = htons(*(unsigned short*)ptr);
ptr += 4;
datalen = ntohs(*(unsigned short*)ptr);
ptr += 2;//清空aname缓冲区
bzero(aname,sizeof(aname));
len = 0;
//解析域名
dns_parse_name(buffer,ptr,aname,&len);
ptr += 2;
//读取记录类型
type = htons(*(unsigned short*)ptr);
ptr += 4;
//读取TTL(4字节)并转换字节序
//读取数据长度(2字节)并转换字节序
//移动指针6字节
ttl = htons(*(unsigned short*)ptr);
ptr += 4;
datalen = ntohs(*(unsigned short*)ptr);
ptr += 2;
根据type
中取到的数据将类型分成两类处理:DNS_CNAME
类型,表明当前域名作为另一个域名的别名;DNS_HOST
类型,直接域名映射到IP
。前一类只需要简单地对数据进行域名解析,后一类还需要进一步检查数据是否为IPv4
类型,并将数据转换为点分十进制。
if(type == DNS_CNAME){
/*
如果是CNAME记录
清空cname缓冲区
解析别名
移动指针到数据末尾
*/
bzero(cname,sizeof(cname));
len = 0;
dns_parse_name(buffer,ptr,cname,&len);
ptr += datalen;
}else if(type == DNS_HOST){
bzero(ip,sizeof(ip));//清空缓存
if(datalen == 4){//检查数据是否为4(IPV4)
memcpy(netip,ptr,datalen);
inet_ntop(AF_INET,netip,ip,sizeof(struct sockaddr));//转换为点分十进制
printf("%s has address %s\n",aname,ip);
printf("\tTime to live: %d minutes, %d seconds\n",ttl/60,ttl%60);
list[cnt].domain = (char*)calloc(strlen(aname) + 1,1);
memcpy(list[cnt].domain,aname,strlen(aname));
list[cnt].ip = (char*)calloc(strlen(ip)+1,1);
memcpy(list[cnt].ip,ip,strlen(ip));
cnt++;
}
ptr += datalen;
}
4. 主函数调用
上述功能完成之后,只需要在主函数中将域名作为外部参数传入,调用dns_client_commit()
函数即可得到结果。
HTTP
的请求与响应
计算机只认识IP
地址,所以需要将操作终端输入的域名转换为IP
地址。
char* host_to_ip(const char* hostname){
//使用系统提供的接口
struct hostent* host_entry = gethostbyname(hostname);
if(host_entry){
/**
* host_entry 用于表示主机条目的数据结构
* h_addr_list 表示主机的网络地址列表
* struct in_addr 表示ipv4地址的基本数据结构
* inet_ntoa 将点分十进制转换为字符串类型
*/
return inet_ntoa(*(struct in_addr*)*host_entry->h_addr_list);
}
return NULL;
}
使用socket
连接服务器。将该连接设置为非阻塞,在调用后续函数时若该函数无法立即完成,则立刻返回结果。
int http_create_socket(char* ip){
int sockfd = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in sin = {0};
sin.sin_family = AF_INET;
sin.sin_port = htons(80);
sin.sin_addr.s_addr = inet_addr(ip);//将点分十进制字符串地址转换为网络字节序32位整数值
if(0 != connect(sockfd,(struct sockaddr*)&sin,sizeof(struct sockaddr_in))){
return -1;
}
//将连接设为非阻塞
fcntl(sockfd,F_SETFL,O_NONBLOCK);
return sockfd;
}
基于socket
发送数据,按照需要在请求头中填入方法。
方法 | 用途描述 |
---|---|
GET | 请求获取指定资源 |
POST | 提交数据到服务器(如创建资源/表单提交) |
PUT | 替换目标资源的完整内容(需客户端提供完整数据) |
DELETE | 删除指定资源 |
HEAD | 类似GET,但只返回响应头(不返回实体主体) |
OPTIONS | 查询服务器支持的HTTP方法/跨域预检请求 |
PATCH | 对资源进行部分修改(RFC 5789定义) |
CONNECT | 建立隧道协议(如HTTPS) |
TRACE | 回显服务器收到的请求(用于诊断,存在安全风险已不推荐使用) |
//获取到ip
char* ip = host_to_ip(hostname);
int sockfd = http_create_socket(ip);
使用sprin()
方法往缓冲区中填入数据,此处应该注意,各字段之间的缩进。
sprintf(buffer,
"GET %s %s\r\n\
Host: %s\r\n\
User-Agent: Mozilla/5.0 (X11; Linux x86_64)\r\n\
%s\r\n\
\r\n"
,resource,HTTP_VERSION,hostname,CONNETION_TYPE);
//发送数据
send(sockfd,buffer,strlen(buffer),0);
在大多数场景下需要同时处理多个连接,本文引入一种常用的I/O
复用方案。使用select
,基于多路复用数据结构fd_set
,统一管理文件描述符。
//初始化fd_set 将sockfd放入
fd_set fdread;
FD_ZERO(&fdread);
FD_SET(sockfd,&fdread);
将fd_set
通过select()
方法交管给select
管理。
int selection = select(sockfd+1,&fdread,NULL,NULL,&tv);
当socket
中数据准备就绪,recv
方法将接收的数据放入缓冲区中。
if( !selection || !FD_ISSET(sockfd,&fdread)){
break;
}else{
memset(buffer,0,BUFFER_SIZE);
int len = recv(sockfd,buffer,BUFFER_SIZE,0);
if(len == 0){
break;
}
result = realloc(result,(strlen(result)+len+1)*sizeof(char));
strncat(result,buffer,len);
}
最后在主函数中将请求的网络以及资源作为外部参数传入。s
char* response = http_send_request(argv[1],argv[2]);
printf("response: %s\n",response);
总结
本文详细阐述了基于UDP和TCP协议的网络编程实现,分别以DNS域名解析和HTTP请求为例,展示了应用层协议与传输层协议的协同工作机制,并提供了C语言实现的关键思路。
代码见http://www.github.com/208822032/UDP-HTTP