前言
网络应用程序的设计主要有两个模式,分别是:CS设计模式和BS设计模式。
CS设计模式:
C代表的是Client(也就是客户端),S代表的是Server(也就是服务器)。
BS设计模式:
B代表的是Browser(也就是浏览器),S代表的是Server。
简单的打个比方,比如我们玩的英雄联盟,登录进去会在一个客户端,你在客户端做的任何响应都会发送给服务器那边,服务器那边会给你反馈,比如点击商店,就会出现商店里面卖的东西展示给你看,这就是CS设计模式。而BS设计模式就是我们的网页与远程服务器相连的设计模式,你点击网页的相应内容,服务器做一定的处理反馈。
无论是哪种设计模式都需要服务器的参与,接下来讲解所谓的socket编程,也就是用系统提供给我们的api创造出一个服务器。
socket 编程
使用socket的API函数编写服务端和客户端程序的步骤图示:
接下来会介绍相应的函数作用。
socket函数
传统的进程间通信借助内核提供的IPC机制(也就是进程间通信机制)进行, 但是只能限于本机通信, 若要跨机通信, 就必须使用网络通信( 本质上借助内核-内核提供了socket伪文件的机制实现通信----实际上是使用文件描述符), 这就需要用到内核提供给用户的socket API函数库。既然提到socket伪文件, 所以可以使用文件描述符相关的函数read write。
使用socket会建立一个socket pair
。
socket的意思是插座,可以这样理解,使用socket之后会创建出1个文件描述符两个缓冲区
,客户端那边也需要使用socket创建1个文件描述符两个缓冲区,当使用连接函数的时候也就是相当于插上了插座,这时候客户端的写缓冲区就连接着服务器的读缓冲区,客户端的读缓冲区就对应着服务端的写缓冲区,可以理解为连接之后就有了两根管道,服务端和客户端可以通过操作文件描述符来进行管道的读写这样就完成的远程操作。具体实现是通过协议栈以及物理网卡等,可先暂时这么理解。
实际上使用socket不仅仅创建了一个文件描述符和读写两个缓冲区,同时还会创建两个队列, 分别是请求连接队列和已连接队列。但是只有监听文件描述符才有,用来通信的文件描述符没有(也可以理解为服务端有这两个队列,客服端没有这两个队列,后面服务端有listen()函数将文件描述符设置为监听描述符)。
int socket(int domain, int type, int protocol);
domain: 协议版本
AF_INET IPV4
AF_INET6 IPV6
AF_UNIX AF_LOCAL本地套接字使用
type:协议类型
SOCK_STREAM 流式, 默认使用的协议是TCP协议
SOCK_DGRAM 报式, 默认使用的是UDP协议
protocal:
一般填0, 表示使用对应类型的默认协议.
返回值:
- 成功: 返回一个大于0的文件描述符
- 失败: 返回-1, 并设置errno
bind函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数描述: 将socket文件描述符和IP,PORT绑定。
返回值:
成功: 返回0
失败: 返回-1, 并设置errno
socket: 调用socket函数返回的文件描述符
addr: 本地服务器的IP地址和PORT,
下面的这个结构体和上面传入的结构体不一样,没关系,本质上是一样的。下面会着重介绍它的。
struct sockaddr_in serv;
serv.sin_family = AF_INET;//选择使用的网络协议
serv.sin_port = htons(8888);//绑定本机端口,通常占2字节。注意:端口号尽量不要填1024以前的数字,因为可以被系统预留了。
serv.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY: 表示使用本机任意有效的可用IP
如果想自己指定ip地址作为服务器连接就需要这个:
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);
或者这个:addr.sin_addr.s_addr = inet_addr("192.168.239.1");
addrlen: addr变量的占用的内存大小
“端口号所谓的端口,就好像是门牌号一样,客户端可以通过ip地址找到对应的服务器端,但是服务器端是有很多端口的,每个应用程序对应一个端口号,通过类似门牌号的端口号,客户端才能真正的访问到该服务器。为了对端口进行区分,将每个端口进行了编号,这就是端口号。”
你可能对出现的htons()、htonl和inet_pton()不知道是何意,在网络传输中,不同的机器端不一样,有的机器是大端有的机器是小端。这些函数是为了帮助你在传输网络数据的时候统一格式。(没有超过一个字节不需要转)
大端: 低位地址存放高位数据, 高位地址存放低位数据(也叫网络字节序)
小端: 低位地址存放低位数据, 高位地址存放高位数据(也叫小端字节序)
网络中传输使用的是大端法,如果机器使用的是小端,则需要进行大小端的转换。
下面4个函数就是进行大小端转换的函数:
#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, s表示short, l表示long
上述的几个函数, 如果本来不需要转换函数内部就不会做转换.
IP地址转换函数:
- p->表示点分十进制的字符串形式
- to->到
- n->表示network网络
int inet_pton(int af, const char *src, void *dst);
函数说明: 将字符串形式的点分十进制IP转换为大端模式的网络IP(整形4字节数
)
参数说明:
af: AF_INET
src: 字符串形式的点分十进制的IP地址
dst: 存放转换后的变量的地址
如192.168.232.145, 先将4个正数分别转换为16进制数,
192—>0xC0 168—>0xA8 232—>0xE8 145—>0x91
最后按照大端字节序存放: 0x91E8A8C0, 这个就是4字节的整形值.
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
函数说明: 网络IP转换为字符串形式的点分十进制的IP
参数说明:
af: AF_INET
src: 网络的十六进制的IP地址
dst: 转换后的IP地址,一般为字符串数组
size: dst的长度
返回值:
成功--返回执行dst的指针
失败--返回NULL, 并设置errno
如 IP地址为010aa8c0, 转换为点分十进制的格式:
01---->1 0a---->10 a8---->168 c0---->192
由于从网络中的IP地址是高端模式, 所以转换为点分十进制后应该为: 192.168.10.1
struct sockaddr
socket编程用到的重要的结构体:struct sockaddr
注意bind()第二个参数,const struct sockaddr *addr
,为什么我在上面介绍的是struct sockaddr_in
这个结构体呢?因为早期用的是struct sockaddr *addr这个结构体,14字节数据类型是字符型数据,要按照顺序依次填好2字节端口,4字节ip地址,还要8个是空闲的不使用。如果你能掌握内存对齐,进行赋值,也是可以用的,只不过比较麻烦,后面就衍生出了struct sockaddr_in这个结构体,直接给成员变量赋值,就非常方便了。
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */内容为网络字节序
struct in_addr sin_addr; /* internet address */
};
struct in_addr {
uint32_t s_addr; /* address in network byte order */内容为网络字节序
};
通过man 7 ip可以查看相关说明
listen函数
int listen(int sockfd, int backlog);
函数描述: 将套接字由主动态变为被动态,也就是设置为监听文件描述符。
参数说明:
sockfd: 调用socket函数返回的文件描述符
backlog: 同时请求连接的最大个数(还未建立连接)
注意:在linux系统中,这里代表全连接队列(已连接队列)的数量。在unix系统种,这里代表全连接队列(已连接队列)+ 半连接队列(请求连接队列)的总数
返回值:
成功: 返回0
失败: 返回-1, 并设置errno
accept函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
函数说明:获得一个连接, 若当前没有连接则会阻塞等待.
函数参数:
sockfd: 调用socket函数返回的文件描述符
addr: 传出参数, 保存客户端的地址信息。如果不关心可以传NULL。
addrlen: 传入传出参数, addr变量所占内存空间大小,这个传出的时候会告诉我们填充的多少的内容。如果不关心可以传NULL。
返回值:
成功: 返回一个新的文件描述符,用于和客户端通信
失败: 返回-1, 并设置errno值.
accept函数是一个阻塞函数, 若没有新的连接请求, 则一直阻塞
。从已连接队列中获取一个新的连接, 并获得一个新的文件描述符, 该文件描述符用于和客户端通信. (内核会负责将请求队列中的连接拿到已连接队列中)。
读取数据和发送数据函数
接下来就可以使用write和read函数进行读写操作了。除了使用read/write函数以外, 还可以使用recv和send函数。
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
//对应recv和send这两个函数flags直接填0就可以了.
注意: 如果写缓冲区已满, write也会阻塞, read读操作的时候, 若读缓冲区没有数据会引起阻塞.
close函数
最后通讯完之后记得close()文件描述符,关闭文件描述符后就断开了连接,就从已连接队列里面去掉了
connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数说明: 连接服务器
函数参数:
sockfd: 调用socket函数返回的文件描述符
addr: 服务端的地址信息
addrlen: addr变量的内存大小
返回值:
成功: 返回0
失败: 返回-1, 并设置errno值
主要用于客户端连接,客户端不需要绑定端口、ip什么的,因为只要能连上然后传输接收数据就行。
总结
可以将服务器比作大酒店,bind()是绑定从大酒店哪个门进来,listen()相当于迎宾小姐,当有人(有连接)到来,这期间完成tcp的三次握手,accept()相当于叫来个服务员,每个客人一个服务员,你的所作所为由这个服务员来进行交接。