网络应用程序设计

Posted by QingJun's Blog on July 4, 2021

本文笔记针对的是西安电子科技大学计算机科学与技术专业计算机软件与理论方向或数字媒体方向在大三下学期方向限选的课程《网络应用程序设计》

配图参考的是张彤老师给的 PPT

第一章 网络编程概述

网络回顾

网络概念

  • 定义:通过通信线路把若干个计算机(应用程序)相连
  • 特点:数量多、操作系统各异、字节顺序不同、通讯方式、通信速度、处理能力不同, 通信线路上通常包含路由器、网关等部件,数据传输过程中存在缓存、丢失、重复、出错等问题

基本问题

  • 可行性:寻址(计算机、应用程序)、数据一致性
  • 数据质量:n速度、稳定性、安全性等

基本历程

  • 以单计算机为中心的联机网络
  • 计算机 - 计算机网络
  • 体系结构标准化网络

网络编程模型

客户机/服务器模式(C/S模式)

  • 循环服务器

  • 并发服务器

浏览器/服务器模式(B/S模式)

三层结构

  • 客户端(浏览器)
  • Web 服务器
  • 数据库服务器

网络协议

OSI 参考模型

TCP/IP 参考模型

image-20210603145115425

第二章 基本socket函数

image-20210618153919716

socket

socket,套接字,通信通道上的端结点

分类:

  • 流式套接字 SOCK_STREAM
  • 数据报套接字 SOCK_DGRAM
  • 原始套接字 SOCK_RAW
1
2
3
4
5
6
// 创建 socket
int socket(int family, int type, int protocol);
// family 的值表示使用的协议簇,一般用 AF_INET ,即 TCP/IP 协议簇
// type 表示套接字类型,一般用 sock_STREAM ,即流式套接字;
// protocol 表示所用协议,一般设为 0 
// 返回 socket 描述符

socket 除了需要指定协议类型,还需要绑定地址,下面介绍 socket 地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 通用套接字地址
struct sockaddr{
    unsigned short sa_family;	 	// 协议簇,sa_family 的值为 AF_INET 表示 TCP/IP 协议簇
    char sa_data[14]; 				// 协议地址
};

// TCP/IP 协议簇的套接字地址
// internet 地址
struct in_addr{
    _u32 s_addr; 					// 32 位的 IP 地址
};
struct sockaddr_in{
    short int sin_family;			// 地址类型,或者说使用的协议簇
    unsigned short int sin_port;	// 端口号,网络字节顺序
    struct in_addr sin_addr;		// IP 地址,网络字节顺序
   	unsigned char sin_zero[8];		// 用于与 sockaddr 兼容(保持相同尺寸),没有什么用
};

// 将 IP 地址由字符串转换为网络地址形式(32位),如 192.168.5.10 -> C0A8000A
int inet_aton(const char* cp, struct in_addr* inp);
unsigned long int inet_addr(const char* cp);
// 将网络地址转换为数字和句点的字符串形式,如 C0A8000A -> 192.168.5.10
char *inet_ntoa(struct in_addr in);

字节顺序:大端存储和小端存储

网络字节顺序(NBO),网络数据在传输中的规定的数据格式,从高到低位顺序存储

主机字节顺序(HBO),数据的存储顺序由CPU决定

1
2
3
4
5
6
7
// 主机字节顺序转换成网络字节顺序
unsigned short int htons(unsigned short int hostshort);
unsigned long int htonl(unsigned long int hostlong);

// 网络字节顺序转换成主机字节顺序
unsigned short int ntohs(unsigned short int netshort);
unsigned long int ntohl(unsigned long int netlong);

bind

bind,绑定本地地址和端口

1
2
3
4
5
6
7
// 绑定本地地址和端口
int bind(int fd, struct sockaddr* addressp, int addrlen);
// fd 表示 socket ;
// addressp 表示本地地址;
// addrlen 表示地址结构的字节数

// 不绑定地址时系统自动分配一个端口,并用该端口和本机ip地址填充客户端socket地址

connect

connect,连接服务器

1
2
3
4
5
6
7
// 绑定对方地址
int connect(int fd, struct sockaddr* addressp, int addrlen);
// fd 表示 socket ;
// addressp 表示服务器地址;
// addrlen 表示地址结构的字节数

// 对一个socket描述符不能两次使用connect函数 

listen

listen,监听本地地址和端口

1
2
3
4
// 监听
int listen(int fd, int qlen);
// fd 表示已绑定的 socket ;
// qlen 表示请求队列长度

TCP协议为每个侦听 socket 维护两个队列:未完成连接队列和已完成连接队列。qlen 指定已完成连接队列的最大长度

accept

accept,接收客户端连接

1
2
3
4
5
// 接受连接
int accept(int fd, sockaddr* addressp, int* addrlen);
// fd 表示监听 socket  ;
// addressp 返回客户端套接字地址;
// addrlen 返回地址结构长度

accept 函数返回的 socket 描述符是真正可以和客户端通信的 socket,服务器的侦听 socket 只接受连接,不能用于通信。

accept 函数在没有已完成的连接时将阻塞进程

read/write

1
2
3
4
5
// 读取和写入
int read(int fd, char* buf, int len);
int write(int fd, char* buf, int len);
// buf 是缓冲区
// len 是数据大小

进程的应用缓冲区–write–>套接字发送缓冲区–internet–>套接字接收缓冲区–read–>进程的应用缓冲区

image-20210618161332723

image-20210618161400214

close

close,关闭 socket

1
int close(int sockfd);

调用 close 只是将对 sockfd 的引用减1,直到对 sockfd 的引用为 0 时才清除 sockfd ,TCP协议将继续使用 sockfd,直到所有数据发送完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// TCP 套接字编程示例
// Server
int main(){
    // 创建socket
    int sockfd;
    if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
        //显示 error
        exit(1);
    }
    
    // 写本地套接字地址
    struct sockaddr_in my_addr;
    my_addr.sin_family = AF_INET;				// 协议
    my_addr.sin_port = htons(PORT);				// 端口
    my_addr.sin_addr.s_addr = <32  IP 地址>;	// IP 地址
    
    // 绑定本地套接字地址
    if(bind(sockfd, (struct sockaddr*) &my_addr, sizeof(struct sockaddr)) < 0){
        // 显示 error
        exit(1);
    }
    
    // 监听
    if(listen(sockfd, <请求队列长度>) < 0){
        // 显示 error
        exit(1);
    }
    
    // 处理
    int client_fd;
    struct sockaddr_in remote_addr;
    int sin_size = sizeof(struct sockaddr_in);
    while(true){
        if((client_fd = accept(sockfd, (struct sockaddr*) &remote_addr, &sin_size)) < 0){
            // 显示 error
            exit(1);
        }
        // 创建进程进行发送或接收,结束后 close(client_fd);
    }
    close(sockfd);
    return 0;
}

// Client
int main(void){
    // 创建socket
    int sockfd;
    if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
        //显示 error
        exit(1);
    }
    
    // 写服务器套接字地址
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;				// 协议
    server_addr.sin_port = htons(PORT);				// 端口
    server_addr.sin_addr.s_addr = <32  IP 地址>;	// IP 地址
    
    // 发起连接请求
    if(connect(sockfd, (struct sockaddr*) &server_addr, sizeof(struct sockaddr)) < 0){
        // 显示 error
        exit(1);
    }
    // 进行发送或接收
    close(sockfd);
    return 0;    
}

第三章 高级socket函数

DHCP

DHCP(动态主机配置协议),使网络环境中的主机动态的获得IP地址、Gateway地址、DNS服务器地址等

分配方式:

  • 自动分配
  • 动态分配
  • 人工分配

image-20210603151641210

image-20210603151654638

DNS

域名系统,DNS

域名,IP 地址的别名

image-20210618163104357

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 查询域名对应 IP
struct hostent* gethostbyname(const char* name);

struct hosten{
    char h_name;		//主机名
    char** h_aliases;	//主机的备用名称列表,以 NULL 结尾
    int h_addrtype;		//主机地址类型,一般为AF_INET
    int h_length;		//主机地址长度,4 字节 32 位
    char** h_addr_list;	//主机网络地址列表,以 NULL 结尾
}
// 一般用主机的第一个网络地址,h_addr_list[0]

// 对同一DNS服务器两次调用 gethostbyname 返回的 IP 地址列表顺序不同
// 在不同的DNS服务器上查询,返回结果不同


// 兼容 域名 和 IP 的主机地址查询
int addr_conv(char *address, struct in_addr *inaddr)
{
    struct hostent *he;
    if (inet_aton(address, in_addr) != 0) // IP 地址转换为 32 位 IP 地址
        return 1;
    he = gethostbyname(address);
    if (he != NULL)
    {
        *inaddr = *((struct in_addr *)he->h_addr_list[0]);
        return 1;
    }
    return -1;
}

// 查询 IP 对应的域名
struct hostent *gethostbyaddr(const char *addr, size_t len, int family);

高级 socket 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/socket.h>

int recv(int sockfd, void* buf, int len, int flags);
int send(int sockfd, void* buf, int len, int flags);
// sockfd,socket 描述符
// buf,数据缓冲区
// len,数据长度
// flags,控制参数
// 返回值,大于等于 0 ,成功

int shutdown(int sockfd, int howto);
// sockfd,socket 描述符
// howto,指定关闭操作的类型
// 返回值,0,成功;-1,失败

多路复用

TCP 并发时,服务器要创建子进程来处理,而创建子进程是一种非常消耗资源的操作。为了减少创建子进程带来的系统资源消耗,人们想出了多路复用I/O模型。

一般的来说,当我们在对文件读写时,进程有可能在读写处阻塞,直到一定的条件满足。比如我们从一个套接字读数据时,可能缓冲区里面没有数据可读(通信的对方还没有发送数据过来),这个时候我们的读调用就会等待(阻塞)直到有数据可读。如果我们不希望阻塞,我们的一个选择是用 select 系统调用,只要我们设置好 select 的各个参数,那么当文件可以读写的时候 select 会”通知”我们说:可以读写了。

maxfd是所有我们监控的文件描述符中最大的那一个加1,rdset 是所有要读的文件文件描述符的集合,wrset 是所有要的写文件文件描述符的集合,exset 是其他的服务要向我们通知的文件描述符 ,timeout 超时设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 检查多个文件描述符(socket描述符)是否就绪,当某一个描述符就绪(可读、可写或发生异常)时函数返回。可以实现输入输出多路复用
int select(int maxfd, struct fd_set* rdset, struct fd_set* wrset, struct fd_set* exset), struct timeval* timeout);
// maxfd,描述符最大值+1
// rdset,需要测试是否可读的描述符集合
// wrset,需要测试是否可写的描述符集合
// exset,需要测试是否异常的描述符集合
// timeout,指定测试超时的时间,= NULL 时,永远阻塞直到有一个描述符就绪,或者出现错误
// 返回:有描述符就绪则返回就绪的描述符个数;超时时间内没有描述符就绪返回 0 ;执行失败返回 -1

struct timeval{
    long tv_sec;	// 秒
    long tv_usec;	// 毫秒
}

void FD_SET(int fd, fd_set* fdset);	// 添加
void FD_CLR(int fd, fd_set* fdset);	// 清除一个
void FD_ZERO(fd_set* fdset);		// 清空
void FD_ISSET(int fd, fd_set* fdset);	// 检测一个描述符是否就绪 

socket 选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 获取 socket 选项
int getsockopt(int sockfd, int level, int optname, void *optval, sock_len *optlen);
// 设置 socket 选项
int setsockopt(int sockfd, int level, int optname, void *optval, sock_len optlen);
// sockfd,socket 描述符
// level,选项级别,SOL_SOCKET,通用 socket选项;IPPROTO_IP,IP选项;IPPROTO_TCP,TCP选项
// optname,选项名称
// optval,选项值
// optlen,选项值长度
// 返回 0 表示成功,1 表示失败

int nOptval = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (const void *)&nOptval, sizeof(int);

// 设置 socket 为阻塞或非阻塞式;设置/获取 socket 的所有者
int fcntl(int fa, int cmd, ...);
// fd,socket 描述符
// cmd,执行的操作
// 其他参数,根据 cmd 选择
// 返回 >= 0 成功,-1 失败

// 控制输入输出
int ioctl(int fd, int req, ...);
// fd,socket 描述符
// req,执行的操作类型
// 第三个参数,总是指针类型,存储操作返回的数据或操作所需的数据
// 返回 0 成功,-1 失败

image-20210618170206076

image-20210618170340417

第四章 UDP与原始SOCKET编程

UDP Socket 编程

image-20210618171129283

1
2
3
4
5
6
7
8
9
// 接受 UDP 数据报
int recvfrom(int sockfd,void *buf,int len,unsigned char flags,struct socketaddr *from,socklen_t *addrlen);
// sockfd,socket 描述符
// buf,数据缓冲区
// len,数据长度
// flags,控制参数
// from,发送者 socket 地址,NULL 表示不需要
// addrlen,socket 地址长度,from为NULL时必须置为NULL
// 返回值,大于等于 0 ,成功

UDP 协议给每个 UDP SOCKET 设置一个接收缓冲区,每一个收到的数据报根据其端口放在不同缓冲区。

recvfrom 函数每次从接收缓冲区队列取回一个数据报,没有数据报时将阻塞,返回值为 0 表示收到长度为 0 的空数据报,不表示对方已结束发送

1
2
3
4
5
6
7
8
9
// 发送 UDP 数据报
int sendto(int sockfd,const void *buf,int len,unsigned char flags,struct socketaddr *to,int  tolen);
// sockfd,socket 描述符
// buf,数据缓冲区
// len,数据长度
// flags,控制参数
// from,接收者 socket 地址
// addrlen,socket 地址长度
// 返回值,大于等于 0 ,成功

每次调用 sendto 都必须指明接收方 socket 地址,UDP 协议没有设置发送缓冲区,sendto 将数据报拷贝到系统缓冲区后返回,通常不会阻塞

UDP 特点:

  • 服务器
    • 不接受客户端连接,只需监听端口
    • 循环服务器,可以交替处理各个客户端数据包,不会被一个客户端独占
  • 客户端
    • 客户端不用建立连接,第一次调用 sendto 函数时,UDP 协议为这个 UDP socket 选择一个端口号,以后的发送和接收操作均使用这个端口号
    • 客户端可以接收来自任何主机的数据报
    • 客户端可能永远阻塞(服务器主机崩溃)

有连接的 udp socket:

在 udp socket 上调用 connect 函数,但不会产生3次握手过程,只记录连接另一方的IP和端口,connect 函数立即返回

特点:

  • 发送 UDP 数据报时不用指定服务器地址
  • 只能接收来自指定服务器的数据报

udp socket 允许对一个 socket 多次调用 connect 函数,每次调用 connect 函数将释放原来绑定的地址,绑定到新地址

原始 socket 编程

TCP 和 UDP Socket 对 TCP 和 UDP 协议做了封装,简化了编程接口,但失去了对 IP 数据包操作的灵活性;原始 socket 直接针对 IP 数据包编程,具有更强的灵活性,能够访问 ICMP 和 IGMP 数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 创建原始 socket
int socket(int family, int type, int protocol);
// family——AF_INET
// TYPE——SOCK_RAW
// protocol,IPPROTO_ICMP,ICMP 数据包;IPPROTO_IGMP,IGMP 数据包;IPPROTO_IP,IP 数据包

// 设置 IP 选项
int on;
setsockopt(sockfd,IPPROTO_IP,IP_HDRINCL,&on,sizeof(on));
// on = 0,协议自动填充 IP 首部,on = 1,用户程序填充

// 绑定本地 IP 地址
bind();

// 绑定对方 IP 地址
connect();

// 发送数据包
// 没有 connect ,只能 sendto或sendmsg
// connect,可以用 write或send

// 接收数据包
// ...

image-20210619163659425

image-20210619163720359

image-20210619163904083

第五章 Linux进程与信号机制

进程

进程的状态:新建 -> 运行 -> 阻塞 -> 就绪 -> 完成

进程按运行方式分类:

  • 核心进程
  • 守护进程,后台运行的进程
  • 用户进程,用户创建的进程

进程按继承关系分类:

  • 父、子、孙进程
  • 兄弟进程
  • 孤儿进程

系统进程表

1
2
3
4
5
6
// 创建进程
pid_t fork(void);
// 返回值
// > 0 ,返回子进程的标识符,只在父进程中返回
// -1 ,调用失败
// = 0 ,只在子进程返回

image-20210606201132856

父、子进程的执行顺序是随机的

1
2
3
4
// 执行另一个程序
int execve(const char *path,char * const argv[],char *envp); 
int execl(const char *path, const char * argv,); 
// 调用 exec 后当前进程 “死亡”,代码段替换为新的代码段,废弃原进程的数据段和堆栈段,创建新的数据段和堆栈段,但进程号保留

信号机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 常用信号
// SIGCHLD,子进程终止时通知父进程
// SIGALRM,计时器到时
// SIGKILL,终止进程
// SIGSTOP,停止进程
// SIGINT,中断
// SIGIO,

// 发送信号
int kill(pid_t pid, int sig);
// pid,接收信号的进程集合
// sig,要发送的信号

// 在指定时间后,向进程自身发送 SIGALARM 信号
unsigned int alarm(unsigned int seconds);

// 处理信号
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
// signum,指定捕获的信号
// act,处理信号的动作
// oldact,储存旧的动作

struct sigaction {
    void (*sa_handler)(int);					// 函数指针
    void (*sa_sigaction)(int,siginfo_t*, void*);  // 函数指针
    sigset_t sa_mask;						    // 屏蔽的信号集
    int sa_flags;							    // 标志,SA_SIGINFO
    void (*sa_restorer)(void)					 // 已废弃
}

// 进程终止
exit(int status);


// 设置进程对信号的响应
struct sigaction act;					// 定义 sigaction 变量
act.sa_handler = sig_handle;			// 设置中断处理函数
// ...
sigaction(<要处理的信号>, &act, NULL);	// 设置信号处理函数
// ...
void sig_handle(int sig){				// 中断处理函数
    // ...
}

僵尸进程

  • 子进程终止时如果父进程存在且未处理 SIGCHLD 信号则子进程变为僵尸进程
  • 僵尸进程占据系统进程表项

清除僵尸进程的方法:

  • 忽略 SIGCHILD 信号,系统将清除子进程的进程表项

  • 调用 wait 或 waitpid 等待子进程

    1
    2
    3
    4
    
    pid_t wait(int *status);
    // 等待任意子进程终止,没有子进程终止时阻塞,如果没有子进程返回-1
    pid_t waitpid(pid_t pid, int *status, int option);
    // pid > 0,只等待该进程id,pid = -1,等待任何一个子进程,同 wait
    
  • 捕获 SIGCHILD 信号

  • 调用fork两次,使子进程成为孤儿进程,由 init 进程管理

    • 第一次调用 fork 产生的子进程可能成为僵尸进程
    • 第二次调用 fork 产生的子进程由 init 处理子进程退出,不会成为僵尸进程

守护进程

将用户进程转换为守护进程:

  • 调用 fork,然后父进程退出,子进程 child 继续运行
  • 调用 setsid 创建新的 session, child 成为头进程
  • 忽略信号 SIGHUP,再次调用 fork,然后父进程 child (session的头进程)退出
  • 调用函数 chdir(“/”),使进程不使用任何目录
  • 调用函数 unmask(0),使进程对任何写的内容有权限
  • 关闭所有打开的文件描述符
  • 为标准输入(0),标准输出(1),标准错误输出(2)打开新的文件描述符
  • 处理信号SIGCLD,避免守护进程的子进程成为僵尸进程

inetd 守护进程

第六章 Linux进程间通信

管道

管道,单向通信管道,只适用于父子进程间通信,可通过两个管道实现双向通信。

1
2
3
4
// 创建管道
int pipe(int fd[2]);
// fd,用于通信的一对文件描述符,fd[0] 用于读,fd[1] 用于写
// 返回 0 成功,-1 失败

管道通信流程:

  • 创建两组管道,pipe1 和 pipe2
  • 父进程用 pipe1 写数据(关闭读端口),用 pipe2 读数据(关闭写端口)
  • 子进程相反
  • 父子进程用未关闭的端口通信

命名管道

  • 命名管道与一个路径名相关联,以文件形式存在于文件系统中

  • 命名管道的文件名只是便于其他进程引用该管道,文件名所对应的文件中没有数据(只能以阻塞模式使用)
  • 命名管道可以在无父子关系的进程间通信
1
2
3
4
5
// 创建命名管道
int mkfifo(char *pathname, mode_t mode);
// pathname,管道名称,绝对路径名
// mode,打开文件的模式
// 返回 0 成功,-1 失败

命名管道通信流程:

  • 写进程创建命名管道
  • 写进程调用open以写阻塞方式打开管道
  • 读进程调用open以读阻塞方式打开管道
  • 写进程调用write写入数据
  • 读进程调用read读出数据

第七章 I/O模型

阻塞式I/O模型

产生阻塞的原因:linux进程调度算法-时间片调度算法

产生阻塞的函数:读、写、建立连接、接受连接

非阻塞式I/O模型

fcntl

输入输出多路复用I/O模型

select

信号驱动I/O模型

第八章 服务器模型

循环服务器,同一时刻只能处理一个客户端请求

并发服务器,同一时刻可以处理多个客户端请求

image-20210619192351144