socket网络编程及通过socket接口实现一个自我通信的简易UDP服务器

网络编程socket基础

认识socket套接字

先用一张图引出socket的内容

根据上面的图示我们可以知道socket其实是位于应用层与传输层之间的一层软件抽象层。它是一组接口,在后面的部分我们会将这组接口具体介绍。我们只需要知道,TCP/IP协议族及网络层的IP协议在具体的网络通信中会由内核部分实现,而网络层的一部分以及更底层的链路层则会由内核指挥硬件部分的网卡去完成操作,但是学习过操作系统的知识,我们就知道内核不相信任何人,因此用户无法直接访问内核,控制外设及硬件,那么只能通过内核向上层暴露出来的系统调用接口来实现相关操作,socket接口就位于这里。socket使得用户可以用一组简单地接口实现网络通信,背后的复杂部分由socket组织数据,并完成相关的协议内容,交付给内核,极大降低了网络编程的难度。

认识socket之后再来介绍两个概念:IP地址和端口号

IP协议有两个版本分别为IPV4和IPV6,目前我们比较通用的还是IPV4,其中IP地址是在IP协议中用来标识网络中不同主机的地址,就好比我们每个人的家庭住址一样,通过具体的IP地址,就可以精确定位到这个主机。

在IPV4中,IP地址是一个4字节的32位整数,人们通常使用点分十进制的字符串来标识IP地址,比如我们在设置路由器时需要进入路由器的网关地址”192.168.1.1“,用点来分割每个数字,每个数字用一个字节来表示,所以该数字的范围是0~255。

端口号是传输层协议的内容,端口号使用的是短整型,2字节16位的整数,端口号可以用来标识一个进程,从而告诉操作系统,当前数据应该交给哪个进程,所以IP地址+端口号就可以标识整个网络中一台主机上的一个进程,这也就限制了每个端口号只能绑定一个进程,反之不一定(一个进程可以绑定多个端口号,即一个进程可以通过多个端口号接收数据,但是端口号只能跟一个进程绑定,否则由这个端口号传输过来的数据不知道该交付给哪个进程了)。

端口号与PID的区别:学习过操作系统部分的知识我们就会知道,对于每个进程,操作系统为了方便管理都会给它分配一个pid(进程ID),一个pid对应一个进程,这里端口号也可以对应一个进程,那两者之间有什么关系呢?我们可以想象成pid是每个人的身份证,而端口号是某大学学生卡的卡号,每个人都会有身份证(pid)但不一定会有这个学生卡(端口号),所以只有需要进行网络通信的进程,才会有端口号这个标识,普通的进程是没有端口号的。

我们可以简单理解:socket = IP地址 + 端口号

源IP+源端口号,标识了互联网中唯一的一台主机以及该主机上唯一的一个进程,目的IP+目的端口号,也标识了唯一的一台主机以及唯一的一个进程,因此socket通信的本质其实是进程间通信

网络字节序

内存中的多字节数据在存储式有大端存储和小端存储之分,网络数据流同样会有大端小端的区别,所以我们要如何定义网络数据流的地址呢?

  1. 发送端的主机通常会将发送到缓冲区的数据按照内存地址从低到高发出
  2. 接收方的主机会将从网络接收的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存
  3. 由1,2得出网络数据流的地址应该是:先发出低地址数据,再发出高地址的数据
  4. TCP/IP协议规定,网络数据流应该采用大端字节序,即低地址存放的是高位字节
  5. 这样就可以进行统一,如果是小端机发送数据,现将数据转换为大端字节序再发送,如果是大端机发送数据,不用进行任何操作,直接发送即可。

为了让网络程序具有可移植性,C语言提供了库函数,帮助我们将网络字节序和主机字节序进行转换。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);

uint16_t htons(uint16_t hostshort);

uint32_t ntohl(uint32_t netlong);

uint16_t ntohs(uint16_t netshort);

其中函数名中的h表示host即本机,n表示network即网络,l表示long即32位长整数,s表示short即16位短整数。

htonl就表示将本机上的32位长整数从本机字节序转换成网络数据流的网络字节序。

不论大小端机器,只需要记住只要从网络字节流中读取出来的一定是大端字节序,再根据本机的存储方式,选择是否继续进行操作即可。

如果本机是大端机,那么这些函数不会做什么转换,会将参数原封不动地返回。

如果本机是小端机,那么这些函数就会帮我们完成小端字节到网络字节序的转换工作然后返回。

常见的socket接口

以下接口需要包含的头文件为:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int socket(int domain, int type, int protocol);创建一个socket文件描述符,其中参数domain是协议族,常用的协议族有AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等;参数type是指明使用什么类型的socket,即下面所提到的SOCK_STREAM、SOCK_DGRAM和SOCK_RAW;参数protocol一般设置为0,这样会由系统自动进行选择。

int bind(int socket, const struct sockaddr* address, socklen_t address_len);绑定端口号,即将我们刚才创建的socket绑定到指定的IP地址和端口上,其中参数socket是我们刚刚通过socket()函数所得到的文件描述符,address即要绑定的本地IP和端口号,并且需要使用网络字节序,这里我们就会通过下面的sockaddr_in结构体,对它进行内容填充,后将其强转为sockaddr结构体指针即可,address_len即参数address结构体的长度,我们一般使用sizeof(struct sockaddr)。返回值是int,如果成功返回0,如果失败返回-1。

int listen(int socket, int backlog);监听socket,如果主机是一台服务器,在创建socket并绑定操作结束后,就需要listen(监听)这个socket,客户端如果使用connect(),发送连接请求,服务器端就可以收到这个请求,其中参数socket是需要监听的socket的文件描述符,backlog则表示该socket可以排队等待的最大连接数。

int accept(int socket, struct sockaddr* address, socklen_t* address_len);接收请求,在TCP连接中,服务器通过上述socket、bind、listen之后,就会一直监听指定的socket,如果发现监听的socket收到了来自TCP客户端发送的connect请求,服务器就会使用accept接收请求,完成TCP连接的建立。连接建立完成后,就可以实现网络通信了。

int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);建立连接,这个由客户端发送,参数sockfd表示客户端的socket,addr表示的是该客户端要连接的目标服务器的socket地址,第三个参数则是第二个参数addr的长度。在TCP连接中,客户端socket调用connect方法与服务器建立连接。

套接字的种类比较多,常见的套接字有三种类型,分别是:①流式套接字(SOCK_STREAM),用于TCP类型连接,保证数据可靠性;②数据报套接字(SOCK_DGRAM),用于UDP类型连接,无可靠性;③原生套接字(SOCK_RAW),允许对底层协议如IP或ICMP进行直接访问,不太常用。不同套接字本应该设计出不同的网络接口,才能满足它们应用的不同协议,但是设计者在设计时并没有这样,为了实现一组通用的socket接口,抽象出了一个sockaddr的结构,用户通过对sockaddr结构不同字节的内容进行填充,从而实现不同种类的套接字使用同一套接口。

通过一个sockaddr结构体,我们只需要对其头部的16位地址类型进行填充,填充之后这个socket的类型就可以被确定了,上图就是一个案例,如果我们对一个sockaddr类型的结构体头部以"AF_INET"进行填充,我们就可以得到一个sockaddr_in类型的结构体,后面只需要按要求继续填充端口号和IP地址,就可以得到一个支持IPV4的socket,如果填写的是"AF_UNIX",得到的则是一个UNIX中使用的socket。

再介绍两个函数recvfromsendto

#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
//参数sockfd为socket的文件描述符
//buf是UDP数据报缓冲区,包含着接受到的数据
//len是缓冲区的长度
//flags是调用的操作方式,默认使用0即可
//src_addr是指向发送数据的对端主机地址信息的结构体
//addrlen是一个指针,指向src_addr结构体的长度
//src_addr和addrlen都设置为指针说明这是一个输入输出型参数,既可以给参数输入数据,也可以从该参数获取数据
//返回值是实际接收到的字节数,如果失败则返回-1
#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
//sockfd是socket的文件描述符
//buf是UDP数据报缓冲区,包含待发送的数据
//len是UDP数据报的长度
//flags一般设置为0
//dest_addr 指向接收数据的对端主机的地址信息结构体
//addrlen即dest_addr的长度

后面再附上一个简单的UDP连接中服务器和客户端的案例,方便理解socket接口调用,功能是先启动服务器,再启动客户端,有客户端连接服务器,向服务器发送字符串,服务器收到字符串后将字符串返还发送给客户端。

//UDP通信的服务器
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <cstring>
#include <unistd.h>
#define PORT 8081
int main(){
    int sock = socket(AF_INET, SOCK_DGRAM, 0);//先创建一个socket
    if(sock < 0){
		//如果sock文件描述符小于0则说明创建socket失败
        std::cerr << "socket error" << std::endl;
        return 1}
    std::cout << "sock is : " << sock << std::endl;//打印并观察socket文件描述符
    //创建一个sockaddr_in结构体,是操作系统为用户提供的一个结构体,用户只需要对其内容进行填充
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));//现将local内容清空,再对其进行填充
    local.sin_family = AF_INET;//协议族填充为IPV4
    local.sin_port = htons(PORT);//填充服务器的端口号,需要注意端口号在网络中传输,需要先将其由主机序列转化为网络字节序列
    local.sin_addr.s_addr = htonl(INADDR_ANY);//填充服务器的IP,推荐使用INADDR_ANY,这样在bind操作时会绑定主机上所有IP,如果主机有两张网卡,只固定绑定其中一张的IP,则由另一张网卡传输进来的数据就无法接收了。
    //绑定操作是将刚才主机相关的IP、端口号、协议家族等信息写入到特定的fd标识的文件中,即将结构体local与socket进行绑定
    if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0){
        //bind成功返回0,失败返回-1,如果小于0说明绑定失败了
        std::cerr << "bind error" << std::endl;
        return 2;
    }
    
    
    char message[1024];
    //服务器是一个软件程序,理想状态下应该一直处于运行状态
    for(;;){
        struct sockaddr_in peer;//存放对端地址的结构体
        socklen_t len = sizeof(peer);
        ssize_t s = recvfrom(sock, message, sizeof(message) - 1, 0, (struct sockaddr*)&peer, &len)//从sock中读数据,读取到message数组中,返回值s是读取到的有效字节数,并且我们在读取过程中就可以将对端的地址信息通过指针输入到peer和len中。
        if(s > 0){
        	message[s] = '\0';
            std::cout << "这是客户端发送过来的:" << message << std::endl;
            //为了实现起来简单一些,服务器并不将数据做过多处理,而是加上服务器标记,再回显给客户端
            ssize_t s = sendto(sock, message, strlen(message), 0, (struct sockaddr*)&peer, len);
        }
    }
    close(sock);//完成通信后将sock关闭
    return 0;
}
//UDP通信的客户端
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <cstring>
#include <unistd.h>
#define IP 127.0.0.1//本地环回,这样可以连接到自己本机
#define PORT 8081
int main(){
	int sock = socket(AF_INET, SOCK_DGRAM, 0);//与服务器类似,先创建一个socket
    if(sock < 0){
		std::cerr << "socket error" <<std::endl;
    }
    //客户端也会bind,但是不需要像服务器那样由用户手动填写IP地址和端口号进行bind,实际上,在sendto的时候,操作系统会随机给客户端bind端口号
    
    char buffer[1024];
    
    struct sockaddr_in desc;
    memset(&desc, 0, sizeof(desc));
    desc.sin_family = AF_INET;
    desc.sin_port = htons(PORT);
    desc.sin_addr.s_addr = htonl(IP);
    
    for(;;){
       	std::cout << "请向服务器输入数据: " << std::endl;
    	buffer[0] = 0;//将字符串清零
        ssize_t size = read(0, buffer, sizeof(buffer) - 1);//从标准输入中读取数据,读到buffer中
        if(size > 0){
            buffer[size] = 0;//将输入结尾处的回车取消
            std::cout << "刚刚输入的数据是: " << buffer << std::endl;
            sendto(sock, buffer, strlen(buffer), 0, (struct sockaddr*)&desc, sizeof(desc));//将这个数据发送到服务器
			struct sockaddr_in peer;//用来保存服务器地址信息的结构体
            socklen_t len = sizeof(peer);
            ssize_t s = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
            if(s > 0){
                buffer[s] = 0;
                std::cout << "从服务器返回的数据: " << buffer << std::endl;
            }
        }
    }
	close(scok);//完成通信后,将socket关闭
    return 0;
}

最后重点解释一下为什么服务器端需要手动进行bind,但是客户端不需要:因为服务器就是为了给客户提供服务的,需要尽可能将自己推销出去,以最方便,最稳定的方式将自己暴露给更多的客户。那么最简单而且稳定的方式就是IP+端口号,以百度为例,人更喜欢记住baidu.com,但是计算机则不一样,计算机更容易记录的是点分十进制的IP地址,其实是由于域名系统帮我们将百度的IP地址与baidu.com进行了关联,所以我们才能在浏览器窗口中输入百度的网址而不用输入百度服务器的IP地址,同时现在的浏览器都非常高级,它们在我们输入了网站域名后,会自动帮我们选择该服务器提供的端口,所以我们日常在浏览网站的时候,从来都不需要输入IP+端口号,只需要记住网站的域名即可,域名部分的知识在IP协议部分再进行分享。服务器应该将自己的服务稳定地提供给客户,IP地址难以改变,但我们更应该注意的是端口号一定不能随意改变,如果服务器随意更改了自己某项服务的端口号,那么很有可能导致大量的客户端找不到服务器,造成连接失败的严重后果。

所以服务器端明确进行bind将端口号绑定,其实就是在将端口号**“私有化”**,一旦绑定, 这个端口号就只能为某项服务专用的,这样服务器在启动某项服务时,就必须使用这个端口号,否则就会报错。那么客户端为什么不需要明确进行bind绑定端口号呢?因为客户端并不需要被人所熟知,客户端是数据的发起方,它要主动去连接服务器,不会有别人主动来连接客户端,就好比我们只会用浏览器去访问百度的服务器,而不会用浏览器去访问朋友电脑上的浏览器。如果主动给客户端进行bind,成功了还好说,但假如客户端的端口被别的程序占用了,这个客户端就无法启动了,客户端不是必须要绑定哪一个端口号,只需要有一个端口号来帮助标识自己的唯一性就行了。所以在进行socket通信时,一般不会自己给客户端进行bind,而是由操作系统随机帮我们找到一个空闲的端口,分配给当前要启动的客户端。我们可以认为,我们电脑上安装的软件客户端,在运行时都是将申请提供给操作系统,再由操作系统为其分配端口号。因此我们在电脑上每次启动客户端时,大概率端口号都不同。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值