飙血推荐
  • HTML教程
  • MySQL教程
  • JavaScript基础教程
  • php入门教程
  • JavaScript正则表达式运用
  • Excel函数教程
  • UEditor使用文档
  • AngularJS教程
  • ThinkPHP5.0教程

一文看懂socket编程

时间:2021-12-03  作者:lh4217  

1.网络模型的设计模式

1.1 B/S模式

B/S: Browser/Server,浏览器/服务器模式,在一端部署服务器,在另外外一端使用默认配置的浏览器即可完成数据的传输。
B/S结构是随着互联网的发展,web出现后兴起的一种网络结构模式。这种模式统一了客户端,让核心的业务处理在服务端完成。你只需要在自己电脑或手机上安装一个浏览器,就可以通过web Server与数据库进行数据交互

  • 优点:跨平台移植性好、将系统功能实现的核心部分集中到服务器上,简化了系统的开发、维护和使用。
  • 缺点:安全性较差,不能缓存大量数据,且要严格遵守http协议

1.2 C/S模式

C/S: Client/Server,客户/服务器模式服务器通常采用高性能的PC、工作站或小型机,并采用大型数据库系统,如ORACLE、SYBASE、InfORMix或 SQL Server。客户端需要安装专用的客户端软件。通过将任务合理分配到Client端和Server端,降低了系统的通讯开销,可以充分利用两端硬件环境的优势。
我们常用的微信、QQ等应用程序就是C/S结构。

  • 优点:安全性能可以很容易保证。(因为只有两层的传输,而不是中间有很多层),传输速度和响应速度很快、可以在客户端本地事先缓存大量数据、协议灵活。
  • 缺点:需要对客户端和发服务端开发,工作量大,用户群固定,护成本高,发生一次升级,则所有客户端的程序都需要改变

2.预备知识

2.1 socket套接字的概念

在linux系统下,所有资源都以文件形式存在,socket是用来表示进程间网络通信的特殊文件类型,本质是linux内核借助缓冲区形成的伪文件。
既然是文件,所以我们就可以使用文件描述符引用套接字,用于网络进程间的数据传递。

2.2 网络进程之间是如何进行通信的

  1. TCP/IP协议中利用IP地址唯一标识一台主机。
  2. IP地址 + 端口号 唯一标识一台主机中的唯一进程。

因此,我们利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。

2.3 主机字节序和网络字节序

学习socke地址API,我们首先要了解主机字节序和网络字节序。

内存中的多字节数据相对于内存地址有大端和小端之分,例如JAVA虚拟机采用打大端字节序,即低地址高字节,最高有效字节在最前面。
比如0x012345

socket地址数据结构

现代的PC大多采用小端字节序,因此又被称为主机字节序,即低字节地地址,最低有效字节在最前面。

网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?

发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节,也叫做网络字节序。

当格式化的数据在两台使用不同字节序的逐级之间传递时,如果不进行字节序转换,则必然会发生错误。
为使网络程序具有可移植性,使同样的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);

这里的含义很明确,htol代表“host to network long”,将长整形32bit的主机字节序转化为网络字节序。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回
长整形通常用来转换IP地址,短整型用来转换端口号

2.4 IP地址转换函数

通常情况下我们用点分十进制字符串来表示IPv4地址,十六进制字符串表示IPv6地址,可读性好,但实际使用中需要把他们转化成二进制。记录日志时,则相反。
下面几个函数分别完成这些功能。

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

inet_pton函数将字符串src表示的IP地址(IPv4、Ipv6)转化成网络字节序整数表示的IP地址,并存于dst中,af指定地址族(AF_INET/AF_INET6),成功返回0,失败返回-1并shezhierrno。
inet_ntop成功返回目标存储单元地址,失败返回null并设置errno。

2.5 socket地址结构

通用socket地址:

struct sockaddr {
	sa_family_t sa_family; 		/* 地址族, AF_xxx */
	char sa_data[14];		/* 14 bytes of protocol address */
};

这个通用地址结构体不好用,更多的用的是专用socket地址结构体:sockaddr_in、sockaddr_in6

struct sockaddr_in {
	sa_family_t sin_family; 		/*  地址族:AF_INET*/  	
	uint16_t sin_port;			/* 端口号,要用网络字节序表示*/       
	struct in_addr sin_addr;		/* IPv4地址*/	
};

struct in_addr {				/* IPv4地址,要用网络字节序表示 */
	u_int32_t s_addr;
};

struct sockaddr_in6 {
	sa_family_t  sin6_family; 		/* 地址族:AF_INET6 */
	uint16_t sin6_port; 			/* 端口号,要用网络字节序表示  */
	uint32_t sin6_flowinfo; 		/* 流信息,应设置为0 */
	struct in6_addr sin6_addr;		/* IPv6 address */
	uint32_t sin6_scope_id; 		/* scope id ,尚处于实验阶段 */
};

struct in6_addr {
    unsigned char sa_addr[16];                  /* IPv4地址,要用网络字节序表示 */
};

3.套接字函数

3.1 创建一个socket

UNIX/Linux的一个哲学:所见皆文件。
创建一个socket用到下面函数:

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

domain:告诉系统使用哪个底层协议族

  • AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
  • AF_INET6 与上面类似,不过是来用IPv6的地址
  • AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用。

type:

  • SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
  • SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
  • SOCK_SEQPACKET 该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
  • SOCK_RAW socket 类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
  • SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序

protocol:

  • 传0 表示使用默认协议。
  • 返回值:
    成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno

socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可

3.2 bind函数

创建socket时,我们只指定了地址族,并未指定那个具体的socket地址。bind()函数就是将socket套接字与地址绑定。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:socket文件描述符
  • addr:构造出IP地址加端口号
  • addrlen:sizeof(addr)长度返回值:
  • 成功返回0,失败返回-1, 设置errno

在linux中我们也可以使用man指令查看这些函数的端口信息,如man bind

bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。
struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。如:

struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
域名family = AF_INET;
域名dr = htonl(INADDR_ANY);
域名port = htons(8888);

端口号一般是0-65536之间,不能超过这个值。
首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为8888。

3.3 listen函数

socket绑定地址后,还不能马上接受客户连接,需要使用listen创建监听队列一存放待处理的客户连接。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);

  • sockfd:socket文件描述符
  • backlog:提示内核监听队列的最大长度,默认为256,如果监听队列的长度超过backlog,服务器不再受理新的客户连接。
  • 成功返回0,失败返回-1

查看系统默认backlog
cat /proc/sys/net/ipv4/tcp_max_syn_backlog

3.4 accept函数

当客户端发起连接请求时,服务器调用accept()接受连接,返回一个新的连接socket文件描述符,服务器可通过读写该socket来与被接受连接对应的客户端通信。如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。

#include <sys/types.h> 		/* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:socket文件描述符
  • addr:传出参数,返回连接客户端地址信息,含IP地址和端口号
  • addrlen:传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
  • 返回值:成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno

3.5 connect函数

#include <sys/types.h> 					/* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • sockfd:socket文件描述符
  • addr:传入参数,指定服务器端地址信息,含IP地址和端口号
  • addrlen:传入参数,传入sizeof(addr)大小
  • 返回值:成功返回0,失败返回-1,设置errno

客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。

3.6 关闭连接

关闭连接有两种方式:close和shutdown

#include<unistd,h>
int close(int fd);

close并非总是立即关闭一个连接,而是将fd的引用计数减1,只有当fd的引用计数为0时,才真正关闭连接。多进程程序中,一次fork将使父进程中打开的socket引用计数加1。

如果无论如何都要立即终止连接,可以使用shutdown。

#include<sys/socket.h>
int shutdown( int socfd, int howto);
  • howto决定了shutdown的行为,能够设置分别关闭socket上的读或写,或者都关闭。而close是将读写全关闭。

4.简单的C/S模型

下图是简单的socket模型创建流程图,编写程序就可以直接参考这个框架。

下图是基于TCP协议的客户端/服务器程序的一般流程:

TCP协议通讯流程:
服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。

数据传输的过程:
建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。
如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

下面给出简单的C/S模型程序,可实现服务器从客户端读字符,然后将每个字符转换为大写并回送给客户端。
服务端程序:

#include <stdio.h>
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>

#define SERV_PORT 8888

void sys_err(const char *str)
{
     perror(str);
     exit(1);
}

int main(int argc, char *argv[])
{
    int lfd = 0, cfd = 0;
    int ret, i;
    char buf[BUFSIZ], client_IP[1024];

    struct sockaddr_in serv_addr, clit_addr;  // 定义服务器地址结构 和 客户端地址结构
    socklen_t clit_addr_len;                  // 客户端地址结构大小
 
    域名family = AF_INET;             // IPv4
    域名port = htons(SERV_PORT);      // 转为网络字节序的 端口号
    域名dr = htonl(INADDR_ANY);  // 获取本机任意有效IP

    lfd = socket(AF_INET, SOCK_STREAM, 0);      //创建一个 socket
    if (lfd == -1) {
        sys_err("socket error");
    }
 
    bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));//给服务器socket绑定地址结构(IP+port)

    listen(lfd, 128);                   //  设置监听上限
  
    clit_addr_len = sizeof(clit_addr);  //  获取客户端地址结构大小

    cfd = accept(lfd, (struct sockaddr *)&clit_addr, &clit_addr_len);   // 阻塞等待客户端连接请求
    if (cfd == -1)
        sys_err("accept error");

    printf("client ip:%s port:%d\n", 
            inet_ntop(AF_INET, &域名dr, client_IP, sizeof(client_IP)), 
            ntohs(域名port));         // 根据accept传出参数,获取客户端 ip 和 port

    while (1) {
        ret = read(cfd, buf, sizeof(buf));      // 读客户端数据
        write(STDOUT_FILENO, buf, ret);         // 写到屏幕查看
 
        for (i = 0; i < ret; i++)               // 小写 -- 大写
             buf[i] = toupper(buf[i]);

        write(cfd, buf, ret);                   // 将大写,写回给客户端。
    }
 
    close(lfd);
    close(cfd);
  
    return 0;

客户端程序:

#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>

#define SERV_PORT 8888
#define BUFFSIZE 1024
void sus_err(const char *str){
    perror(str);
    exit(1);
}

int main(int argc, char *argv[]){
    int cfd;  
    char buf[BUFFSIZE];
    
    struct sockaddr_in serv_addr;  // 服务器地质结构
    域名family = AF_INET;
    域名port = htons(SERV_PORT);
    inet_pton(AF_INET, "127.0.0.1", &域名addr);
    
    cfd = socket(AF_INET, SOCK_STREAM, 0);

    if(cfd == -1) sys_err("socket error");

    int ret = connect(cfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if(ret != 0) sys_err("socket error");
    
    while(1){
        ret = read(cfd, buf, sizeof(buf));
        write(STDOUT_FILENO, buf, ret);
        sleep(1);
    }
    close(cfd);

    return 0;

}

5.错误处理封装

系统调用不能保证每次都成功,必须进行错误处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。
为使错误处理的代码不影响主程序的可读性,我们把与socket相关的一些系统函数加上错误处理代码包装成新的函数,在新函数里面处理错误,在主程序中就可以直接使用这些封装过的函数,更加简洁明了。

#ifndef __WRAP_H_
#define __WRAP_H_
void perr_exit(const char *s);
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int Bind(int fd, const struct sockaddr *sa, socklen_t salen);
int Connect(int fd, const struct sockaddr *sa, socklen_t salen);
int Listen(int fd, int backlog);
int Socket(int family, int type, int protocol);
ssize_t Read(int fd, void *ptr, size_t nbytes);
ssize_t Write(int fd, const void *ptr, size_t nbytes);
int Close(int fd);
ssize_t Readn(int fd, void *vptr, size_t n);
ssize_t Writen(int fd, const void *vptr, size_t n);
ssize_t my_read(int fd, char *ptr);
ssize_t Readline(int fd, void *vptr, size_t maxlen);
#endif

具体封装函数如下:

点击查看代码
#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>
void perr_exit(const char *s)
{
	perror(s);
	exit(1);
}
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;
	again:
	if ( (n = accept(fd, sa, salenptr)) < 0) {
		if ((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else
			perr_exit("accept error");
	}
	return n;
}
int Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = bind(fd, sa, salen)) < 0)
		perr_exit("bind error");
	return n;
}
int Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = connect(fd, sa, salen)) < 0)
		perr_exit("connect error");
	return n;
}
int Listen(int fd, int backlog)
{
	int n;
	if ((n = listen(fd, backlog)) < 0)
		perr_exit("listen error");
	return n;
}
int Socket(int family, int type, int protocol)
{
	int n;
	if ( (n = socket(family, type, protocol)) < 0)
		perr_exit("socket error");
	return n;
}
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = read(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = write(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
int Close(int fd)
{
	int n;
	if ((n = close(fd)) == -1)
		perr_exit("close error");
	return n;
}
ssize_t Readn(int fd, void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nread;
	char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nread = read(fd, ptr, nleft)) < 0) {
			if (errno == EINTR)
				nread = 0;
			else
				return -1;
		} else if (nread == 0)
			break;
		nleft -= nread;
		ptr += nread;
	}
	return n - nleft;
}

ssize_t Writen(int fd, const void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nwritten;
	const char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
			if (nwritten < 0 && errno == EINTR)
				nwritten = 0;
			else
				return -1;
		}
		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

static ssize_t my_read(int fd, char *ptr)
{
	static int read_cnt;
	static char *read_ptr;
	static char read_buf[100];

	if (read_cnt <= 0) {
again:
		if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
			if (errno == EINTR)
				goto again;
			return -1;	
		} else if (read_cnt == 0)
			return 0;
		read_ptr = read_buf;
	}
	read_cnt--;
	*ptr = *read_ptr++;
	return 1;
}

ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
	ssize_t n, rc;
	char c, *ptr;
	ptr = vptr;

	for (n = 1; n < maxlen; n++) {
		if ( (rc = my_read(fd, &c)) == 1) {
			*ptr++ = c;
			if (c == \'\n\')
				break;
		} else if (rc == 0) {
			*ptr = 0;
			return n - 1;
		} else
			return -1;
	}
	*ptr = 0;
	return n;
}

参考资料:

  1. 《linux高性能服务器编程》游双 著

标签:编程
湘ICP备14001474号-3  投诉建议:234161800@qq.com   部分内容来源于网络,如有侵权,请联系删除。