一、TCP网络套接字编程
1.日志等级分类的日志输出API
1. 为了让我们的代码更规范化,所以搞出了日志等级分类,常见的日志输出等级有DEBUG NORMAL WARNING ERROR FATAL等,再配合上程序运行的时间,输出的内容等,公司中就是使用日志分类的方式来记录程序的输出,方便程序员找bug。 实际上在系统目录/var/log/messages文件中也记录了Linux系统自己的日志输出,可以看到我的Linux系统中之前在使用时产生了很多的error和warning,我们的代码也可以搞出来这样的输出日志信息到文件的功能。
2. log.hpp中设置了5个日志等级,ERROR是一种程序已经出错了但并不影响代码继续跑的错误,而FATAL是一种致命的错误,一旦出错将会直接终止程序继续运行。 我们将DEBUG 和NORMAL等级的输出内容放到文件log.txt中,把WARNING ERROR FATAL等级的输出内容放到文件log.error中,以此来进行一个简单的日志等级的文件分类。 logMessage使用了可变参数列表,使得外部调用logMessage进行日志输出时能够更加灵活的使用该函数,可变参数列表大家可以在网上搜一搜,其实就是va_list va_start va_arg va_end等宏的使用,对可变参数列表的参数进行控制,但今天我们不使用最基本的玩法,也就是一个个的读取参数列表中的参数,而是直接用vsnprintf(int vsnprintf(char *str, size_t size, const char *format, va_list ap);)进行可变参数列表中参数的读取,并将读取到的参数内容格式化输出到缓冲区str当中,这样就能够完成参数列表中参数内容的读取。format是需要格式化的字符串,也就是使用可变参数列表时的" "中的内容,这部分字符串会作为参数传递给vsnprintf中的format。 今天我们将日志输出内容分为logprefix和logcontent两部分内容,将日志的前缀格式化输出到logprefix数组中,将日志的后缀内容也就是含有可变参数的部分内容,进行可变参数读取并将其格式化输出到logcontent数组中。 完成上述工作之后,只需要fopen打开log.txt和log.error两个文件,进行追加式的写入logprefix和logcontent内容的集联即可。这样就完成了日志信息的文件输出。
2.单进程版本的服务器客户端通信
下面是tcp服务器客户端的makefile文件,和udp没什么区别。
tcp服务器的调用逻辑和udp服务器一样,都是在运行时指明服务器进程绑定的端口号即可,服务器绑定的ip地址为任意ip,其中有个守护进程化daemonSelf()接口,这个现在用不到,最后讲守护进程时会谈论。
1. tcpserver类的成员变量只需要listenSockfd套接字和bind的端口号即可。 tcp服务器要做的第一件事和udp服务器相同,都是创建套接字,在调用socket时,第二个参数不再是SOCK_DGRAM,而是变成了SOCK_STREAM即创建字节流式的套接字。 第二步也是给listenSockfd套接字文件描述符绑定ip和端口号,绑定的逻辑也和udp服务器相同,只不过在tcp服务器这里的查错处理我们改成了日志等级输出的文件方式而已。 第三步tcp服务器与udp就不同了,由于tcp协议是面向连接的,所以想要和tcp服务器通信就必须先建立连接,而TCP服务器需要设置自己的套接字listenSockfd为监听状态,即被动的等待客户端发送connect连接请求。到此为止就完成了tcp服务器的初始化准备工作了。 TCP服务器死循环运行开始后,服务器accept接收来自客户端的连接请求,accept接收请求是阻塞式的,即接收来自哪里的客户端的请求,需要一个peer结构体作为输出型参数传递给accept函数,accept会返回一个专门用来网络通信的套接字文件描述符sockfd,而之前socket创建出来的listenSockfd是专门用来监听客户端的连接请求的。建立连接成功之后,就可以用accept返回的sockfd开始通信了,我们今天把通信的模块serviceIO单拿出来做一个解耦,方便后续其他版本的通信代码进行测试,其他版本的代码直接调用serviceIO即可。
初始化好的服务器会首先处于监听状态,靠的就是listenSockfd套接字文件描述符,
在通信之后,要把accept返回的sockfd关闭掉,否则随着建立连接的次数不断上升,则可用的sockfd会越来越少,造成文件描述符资源泄露,我的云服务器默认能打开的文件个数有10w多个,但是只要服务器通信过后不关闭已经使用完了的sockfd的话,一旦连接数变得很多,比如几百个连接,那么服务器就会造成大量的文件描述符资源泄露,服务器就会变得越来越卡,所以通信完毕之后,关闭套接字文件描述符是一个好的习惯。
2. serviceIO的通信逻辑也很简单,就是系统接口的文件操作read的使用,从文件描述符sockfd中读取客户端的消息,然后再做一个简单的回显消息到客户端,写回的时候调用system call write即可,如果read读到0,则说明写端也就是客户端不写了,那么读端会一直阻塞等待读取sockfd中的内容,为了不让读端一直阻塞,我们此时break跳出serviceIO的死循环,结束掉这一次的通信即可。
tcp客户端调用这里的代码也没什么好说的,和udp一模一样。
3. 客户端初始化时也是一样,需要创建网络套接字,tcp的客户端也是需要绑定的,但为了程序的鲁棒性更好,就无需程序员显示绑定,由OS来动态分配客户端绑定的ip和端口号。 客户端开始死循环运行时,第一件事就是向服务器发起连接请求,这个连接的工作也不难做,因为客户端知道目的ip和目的port,所以直接填充server结构体中的各个字段,然后直接发起连接请求即可。连接成功后就可以开始通信,同样的客户端也是使用read和write等接口来进行数据包的发送和接收。如果客户端读到0,则说明服务器已经不写了,那么如果客户端继续向服务器发消息,就相当于写端向已经关闭的读端继续写入,此时OS会终止掉客户端进程。 由于udp和tcp分别是无连接和面向连接的,所以两者有些许不同,tcp的服务器如果挂掉,客户端继续写,则客户端进程会被操作系统终止掉,而udp的服务器如果挂掉,客户端是可以继续写的,只不过客户端发送的数据包会被简单的丢弃掉罢了。
4. 下面是实验现象,客户端发送的消息是可以被服务器正确回显的,一旦服务器终止掉之后,客户端继续向服务器写入时,客户端进程会立马被操作系统杀死从而终止掉,这其实就是我们所说的读端关闭,写端继续写则写端进程会被终止的现象。
可以看到当服务器成功启动时,日志消息确实被输出到了文件log.txt当中。
当客户端和服务器连接成功之后,服务器依旧还有一个LISTEN监听状态,除此之外还有客户端和服务器的ESTABLISHED连接建立成功的状态。其实一条连接就已经是全双工的通信状态了,而我们能看到两条链接是因为今天做测试时,客户端和服务器在同一台主机上,如果在不同的主机上,则各自主机都只能看到唯一的一条连接状态。
5. 而现在又出现了一个新的问题,用户1连接成功并开始通信时,用户2可以连接服务器,因为服务器一直处于监听状态,但用户2发送的消息却并不会被服务器回显,而只有当第一个用户进程被终止掉之后,用户2进程才会立马回显刚刚所发送的一堆消息,接下来用户2才可以正常和服务器通信,这是为什么呢?其实主要是因为我们的代码逻辑是串行执行的,一旦服务器启动时,因为是单进程,所以连接一个客户端之后,服务器就会陷入serviceIO的死循环,无法继续循环执行accept以接收来自客户端的连接请求。而当连接的客户端终止掉之后,serviceIO会读到0,此时才会break跳出死循环,重新执行accept建立新的连接。 所以如果想要让服务器同时建立多个连接,可以通过多进程或多线程以及线程池的方式来实现。
3.多进程版本和多线程版本
1. 多进程的实现方案也很简单,让父进程去执行和客户端连接的代码,也就是执行accept的功能,让fork出来的子进程执行和客户端进行通信的服务代码,也就是执行serviceIO,创建子进程后,子进程应该将自己不使用的文件描述符关闭,防止子进程对父进程打开的文件进行误操作,尤其是像listenSockfd这样的文件描述符,如果子进程对listenSockfd进行写入什么的,很有可能会导致服务器崩溃,此外,关闭不用的文件描述符也可以给子进程腾出来一部分文件描述符表的下标位置,防止文件描述符泄露。 创建出来的子进程是需要等待的,在下面代码中使用非阻塞式等待是一个非常不好用的做法,这会让服务器的工作主线偏离,因为如果要使用非阻塞式等待,则势必得通过轮询的方式来检测子进程的状态,那服务器就需要一直询问子进程是否退出,但我服务器的核心工作主线是接收客户端的请求并建立连接进行网络通信的啊,一旦非阻塞等待,服务器的性能就一定会下降,因为需要一直做不必要的工作:比如询问子进程状态,况且waitpid还是系统调用,每次循环还要陷入内核,所以非阻塞式等待是一个非常不好的方案,不要用他。 第一种解决方案就是让子进程fork出孙子进程,子进程立马退出终止,让孙子进程去提供serviceIO服务,孙子进程退出时会被1号进程init进程接管,回收孙子进程(孤儿进程)的资源。父进程此时就可以阻塞式等待子进程退出,这个阻塞其实可以忽略不计,因为一旦创建出子进程,子进程就会立马退出,父进程也会立马回收掉子进程的资源,从而父进程可以继续向后accept其他客户端的连接请求,而让孙子进程提供serviceIO服务,当孙子进程退出后,1号进程会回收他的资源。 第二种解决方案就比较简单轻松,可以直接捕捉SIGCHLD信号显示设置为SIG_IGN,这样的话,父进程就不需要等待子进程,当子进程退出时,linux系统会自动帮我们回收子进程资源,父进程就省心了,不用管子进程退不退出的事了,把这件事丢给linux系统来干,我父进程专心accept其他的客户端连接请求就OK。
下面是多进程版本代码的实验现象,服务器这回就可以同时给多个客户端提供通信服务了,因为服务器中搞出了多进程,也就是多个执行流,主执行流负责接收来自多个客户端的连接请求,其他的多个子进程负责给多个客户端提供通信服务。
2. 多进程并不是一个好的实现方案,因为创建一个进程的代价远比线程大得多,频繁的创建和回收进程会给系统带来很大的压力,所以多进程是一个比较重量化方案,而反观多线程是一种轻量化的方案,所以使用多线程能让服务器的性能消耗更小一些。 实现的方式也比较简单,我们知道threadRoutine要传给pthread_create的话,必须为静态方法,如果是静态方法就无法调用serviceIO,所以我们搞一个结构体td包含TcpServer类的指针this,以及accept返回的用于通信的套接字文件描述符sockfd,将td地址传递给threadRoutine函数,线程函数内部进行回调serviceIO,serviceIO如果调用结束不要忘记将sockfd关闭,避免文件描述符资源泄露。在线程这里只有阻塞式等待join和不等待两种情况,没有非阻塞式等待,所以主线程创建线程之后如果不想阻塞式join从线程的退出,则可以创建线程之后立马将从线程设置为detach状态即线程分离,线程函数执行完毕之后退出时,由操作系统负责回收从线程资源,主线程也就撒手不管了。
3. 下面是实验现象,在多线程方案下,我们的tcp服务器依旧可以实现同时连接多个客户端,线程不仅在内存上较为轻量化,实际在代码上写起来也比较轻量化,不费头发。
4.线程池版本
1. 多进程和多线程从实现服务器的功能角度来说其实都挺不错的,但他们都有一个共同的问题,当连接请求到来的时候他们才会去创建对应的线程或进程,所以就会存在一个频繁创建和销毁的问题,那么能不能提前创建好一批线程,等到请求到来的时候不用去创建线程,而是直接让线程去和客户端进行通信,这样服务器的效率是不是会高一些呢?当然是的。 一般来说,线程池适用于快速响应客户端的请求,执行短暂不繁琐的任务处理,在执行过后可以将线程还给线程池,那么线程池内的线程就都可以重复利用起来,而我们现在写的serviceIO代码中执行的是一个死循环,实际中是肯定不会这样做的,因为一旦线程去执行serviceIO后就回不来了,线程无法返回给线程池,那么有可能线程池中的所有线程都被serviceIO占着,此时若有其他客户端发起连接请求,服务器此时就无法接收连接了,我们的代码主要是demo代码,并不是真实的实际中应用的代码。
2. 之前的文章实现过条件变量和互斥锁构成的单例模式的线程池,我们直接把当时的线程池作为组件拿来用,线程池自带任务队列,线程池的构造函数和run方法,分别对应了Thread.hpp中的无参构造函数Thread(),线程函数和给线程函数传的参数这两个为参的Thread(func_t func, void *args=nullptr)参数,实现无参构造的主要目的是想构建出来线程名,把所有带有线程名的线程push_back到线程池的任务队列中,等待线程池run起来的时候,再把线程执行的方法也就是serviceIO传给线程,调用pthread_create让线程执行serviceIO,实现和客户端的网络通信。 所以线程池的任务队列中存放的任务实际就是serviceIO,当客户端发起连接时,我们就把Task(sockfd, serviceIO)任务对象push_back到线程池的任务队列_task_queue里面,然后线程池中在条件变量下等待的各个线程中的某个线程会被signal,线程此时就会执行serviceIO和客户端进行网络通信。
3. 下面是线程池代码的实验现象,一旦线程池服务器启动,可以看到tcpserver进程里面直接多出来10个线程,线程池中的线程都可以提供通信服务。
5.守护进程化的线程池服务器
1. 上面的线程池服务器已经很完美了,但美中不足的是只要我的xshell或者vscode关闭了,该线程池服务器就会被终止掉,我们还需要重新启动服务器,我们希望的是只要服务器启动之后,就不再受用户登录和注销的影响,这样的服务器进程我们把他叫做守护进程。 当xshell打开时,linux会为我们创建一个会话,在一个会话当中有且只能有一个前台任务,可以有0个或多个后台任务,linux创建的会话中,刚开始都是以bash作为前台任务,bash就是命令行解释器,用于客户输入指令和linux kernel进行交互,当我们的程序运行起来时,bash进程会自动被切换为后台进程,所以你可以简单的试一下,当在命令行中启动进程后,执行pwd,ls,touch等bash指令一定是无效的,因为此时bash被切到后台运行了,等到进程终止退出后,linux会重新将bash从后台切换为前台进程,此时用户就又可以通过bash指令重新和Linux kernel交互了。
2. 命令行中运行进程时加上&,此进程将会被切换为后台一直运行,此时客户端可以直接连接服务器进行网络通信,通过jobs可以查看当前会话中后台的进程组,fg可以用于把进程组提到前台运行,ctrl z可以暂停前台进程组的运行,此时该进程组会自动被切换为后台并暂停运行,如果想要恢复后台进程的运行则可以使用bg 进程组编号恢复后台作业的运行,可以看到我们搞出来的6个sleep进程的会话id都是27119,而27119正是bash进程的pid,所以你在命令行中启动的进程都是在bash这个会话里面的,bash不仅仅是一个命令行解释器他也是一个shell脚本语言。 当你关闭终端,也就是关闭该会话时,该会话中的所有进程组中的所有进程都会被终止,所以只要关闭会话这些进程就又全都没有了,包括我们的tcp服务器进程,所以想要让进程不受用户登录注销的影响唯一的办法就是让TCP服务器进程自成一个会话,那么bash会话关闭时是不会影响到我们的TCP服务器进程的。
3. 下面是线程池服务器守护进程话的组件daemon.hpp,首先做的第一件事就是让服务器进程忽略掉一些信号,以增强服务器的鲁棒性,比如忽略掉SIGPIPE信号,这样客户端关闭时,服务器不会受影响。 第二件事就是守护进程化我们的TCP服务器,可以使用daemon和setsid来实现服务器的守护进程化,我们使用setsid即可,但调用setsid的前提是调用的进程不是进程组组长,这个其实也好解决我们fork子进程即可,让子进程调用setsid来进行服务器的守护进程化,所以调用setsid之前相当于狸猫换太子,原先的父进程直接退出,由子进程接管父进程剩余的所有代码的执行。 第三件事就是将原先父进程打开的012文件描述符都重定向到文件黑洞/dev/null中,让守护进程不依赖终端,使其能够独立运行。如果不重定向012,那么当终端退出时,守护进程的012三个文件描述符会变成无效的,则会导致守护进程异常退出。 重定向到文件黑洞之后,守护进程服务器可以将日志消息输出到文件中,方便后续从文件中来读取服务器的日志。 因为守护进程往往运行很长时间,如果直接将进程的消息输出到终端,会积累很多日志,这可能会淹没有效信息,降低日志的有用性,所以我们要dup2重定向012文件描述符到文件黑洞,以便于后期从文件中读取服务器日志。如果/deb/null无法打开,那就无法实现重定向,我们也就只能被迫选择关闭012文件描述符。 第四件事可做可不做,我们可以选择更改守护进程服务器的工作目录,通过chdir来实现,可以选择改也可以选择不改。
4. 下面是守护进程服务器的实验现象,进程一旦启动就会守护进程化,客户端可以直接连接服务器进行网络通信。
下面的实验现象中我说错了一点,bash会话中还是能看到守护进程的,只不过不是在bash会话中看到的,而是在bash中执行ps axj指令查看系统中所有的进程来看到的,所以可以算是说对了一半。
5. 实际上除使用setsid进行进程的守护化外,还可以使用daemon接口,但这样的接口实际没有setsid好用,两者的作用是相同的没有什么差别。
6.三次握手和四次挥手的感性理解
1. 上面我们所写的TCP服务器实际上是存在很大问题的,比如read的时候,你怎么确定你读到的数据一定是完整的呢?有没有可能对方发来一块数据,你读了一半呢?或者是发来多块数据,你只读了一个半呢?又或者是你直接把所有数据一下子读取上来了呢?这些情况对于面向字节流的TCP协议来说,都是有可能发生的!和我们以前学的管道一样,写端有可能写了一大批数据,读端有多少读多少,一下子把所有数据都读上来了,一般取决于读端的缓冲区有多大。 对于面向字节流这样不确定的读取该怎么解决呢?实际要通过定制协议来解决! 定制协议这个话题我们先抛出来,第二部分会进行讲解。
2. 实际上连接的过程并没有我们所想象那么简单,只要客户端调用connect,服务器先调用listen,后调用accept就完成连接过程了,根本不是这么简单的事情! 而connect仅仅只是发起了连接请求,发起连接的请求和真正建立连接这是两码事,你看到一个喜欢的女生,你想要发起追求人家的请求,那和你们俩真正成为男女朋友是一回事吗?当然不是一回事!想要真正成为男女朋友,中间是要有复杂的连接过程的。我们的客户端连接过程同样也是如此。真正连接的过程实际就是双方操作系统三次握手的过程,这个过程是由双方的操作系统自动完成的。 我们知道上层发起连接请求和收获连接结果是通过connect和accept系统调用来完成的,而真实的连接过程和这两个系统调用没什么关系,连接过程是由双方的操作系统执行各自的内核代码自动完成连接过程的。 所以accept并不参与三次握手的任何细节,他仅仅只负责拿走连接结果的胜利果实。换句话说,就算上层不调用accept,三次握手的过程也能够建立好,因为应用是应用,底层是底层,三次握手就是底层,和你应用没半毛钱关系,这是我双方的操作系统自主完成的工作。 另外我们所说的TCP协议保证可靠性和应用有关系吗?照样没半毛钱关系!因为应用是应用,底层是底层,TCP协议是传输层的,传输层在操作系统内部实现。 相同的,四次挥手的过程也是由双方操作系统完成的,而close(sockfd)的作用仅仅只是触发了四次挥手而已。
3. 我们知道肯定不可能只有一个客户端连接服务器,如果是多个客户端连接服务器的话,服务器要不要对这么多的连接请求做管理呢?反过来一个客户端如果连接了多个服务器的话,那么多个服务器返回的连接结果,客户端要不要做管理呢?当然是要的! 所以客户端和服务器都要对大量的连接请求做管理,那该怎么做管理呢?先描述,再组织!双方的操作系统内部一定维护了连接请求所对应的内核结构对象,描述特定的某个连接的属性信息,然后再用数据结构将这些对象连接起来进行管理,至此我们就完成了从表层泛泛而谈的连接到内核这一层的理解过程。 所以维护TCP的连接有成本吗?一定是有的,因为双方的操作系统要在各自底层建立描述连接的结构对象,然后用数据结构将这些结构对象管理起来,这些都是要花时间和内存空间的,所以维护连接一定是有成本的。 而四次挥手与三次握手有所不同,三次握手是某一方先发起连接请求然后进行连接,断开连接是双方的事情,client对server说我要和你断开连接,server说好呀,我同意,然后server又对client说,我也要和你断开连接,client说OK,我也同意,至此才完成了断开连接的过程。所以断开连接是双方的事情,少了任何一方都只能算作通知,只有双方共同协商才能完成断开连接的过程。
4. 三次握手:client调用connect,向服务器发起连接请求,connect会发出SYN段并阻塞等待服务器应答(第一次),服务器收到客户端的SYN段后,会给客户端应答一个SYN-ACK段表示"同意建立连接"(第二次),客户端收到SYN-ACK段后会从connect系统调用返回,同时应答一个ACK段(第三次),此时连接建立成功。 四次挥手:客户端如果没有请求之后,就会调用close关闭连接,此时客户端会向服务器发送FIN段(第一次),服务器收到FIN段后,会回应一个ACK段(第二次),同时服务器的read会读到0,当read返回后服务器就会知道客户端关闭了连接,他此时也会调用close关闭连接,同时向客户端发送FIN段(第三次), 客户端收到FIN段后,会给服务器返回一个ACK段(第四次)。 (socketAPI的connect被调用时会发出SYN段,read返回时表明服务器收到FIN段)
二、序列化/反序列化的协议定制
1.定制协议
1. 在定制协议的时候,一定是离不开序列化和反序列化的,这两个名词听起来高大上,实际啥也不是。在网络发送数据时,比如我要发头像URL,时间,昵称,消息等字段,如果我一个一个发送的话,效率很非常的低,并且接收的一方也会很痛苦,这么多数据接收方该如何分辨哪个是头像,哪个是时间,哪个是昵称,哪个是消息呢? 所以为了提升网络发送的效率,往往要进行零散字段的序列化,将他们打包为一个报文(也可以称为一个字符串),一并发送到网络中,当服务器收到这个序列化后的报文时,如果想要拿到报文里面的各个字段进行客户端请求的响应,则首先需要进行反序列化,将这一个报文拆开,拿到对应的各个零散字段。 实际上序列化和反序列化的工作对应的就是将零散字段打包进行发送,和将报文打散为可读取的零散字段。
2. 而我们所说的定制协议服务于哪个部分呢?实际就是用于网络发送或网络读取时的数据黏包问题,因为面向字节流的TCP会存在这样的问题,而UDP并不会,他发送的数据是一个数据报,服务器再接收时,天然接收到的就是一个数据报,不会存在什么黏包问题等等。
2.网络发送的本质和读取黏包的处理(应用层和传输层之间数据的拷贝)
1. 实际上sockfd会指向一个操作系统给分配好的socket file control block,而这个socket文件控制块内部会维护网络发送和网络接收的缓冲区,我们调用的所有网络发送函数,write send sendto等实际就是将数据从应用层缓冲区拷贝到TCP协议层,也就是操作系统内部的发送缓冲区,而网络接收函数,read recv recvfrom等实际就是将数据从TCP协议层的接收缓冲区拷贝到用户层的缓冲区中,而实际双方主机的TCP协议层之间的数据发送是完全由TCP自主决定的,什么时候发?发多少?发送时出错了怎么办?这些全部都是由TCP协议自己决定的,这是操作系统内部的事情,和我们用户层没有任何瓜葛,这也就是为什么TCP叫做传输控制协议的原因,因为传输的过程是由他自己所控制决定的。 c->s和s->c之间发送使用的是不同对儿的发送和接收缓冲区,所以c给s发是不影响s给c发送的,这也就能说明TCP是全双工的,一个在发送时,不影响另一个也再发送,所以网络发送的本质就是数据拷贝。 服务器在调用网络接收函数进行TCP协议层接收缓冲区的数据拷贝到应用层时,有一个问题,如果客户端发送的报文很多怎么办?接收缓冲区会堆积很多的报文,而这些报文都会黏到一起,服务器该怎么保证read的时候读取到的是一个完整的报文呢?为了解决这个问题,就需要我们在应用层定制协议明确报文和报文的边界。 常见的解决方式有定长,特殊符号,自描述方式等,而我们今天所写的代码会将后两个方式合起来一块使用,我们会进行报文与报文之间添加特殊符号进行分隔,同时还会在报文前增加报头来表示报文的长度,以及在报头和报文的正文间增加特殊符号进行分隔,那么在读取的时候就可以以报头和报文之间的特殊符号作为依据先将报头读取上来,然后报头里存储的不是正文长度吗?那我们就再向后读取正文长度个字节,这样就完成了一个完整报文的读取。
2. 说应用层缓冲区怕大家感觉到抽象,其实所谓的应用层缓冲区就是我们自己定义的buffer,可以看到下面的6个网络发送接收接口都有对应的buf形参,我们在使用的时候肯定要传参数进去,而传的参数就是我们在应用层所定义出来的缓冲区。 这里多说一句,下面的六个接口在进行网络发送和网络读取数据的时候,都会做网络字节序和主机字节序之间的转换,recvfrom和sendto是程序员自己显示做转换,其余的四个接口是操作系统自动做转换,这是铁铁的事实! 网上有人会说其余的四个接口不会做转换,这是错误的!他们一定会做转换的,因为不转换网络通信时一定会出现问题的,但事实上他们并不会出现问题,所以调用下面这六个接口时,一定都会做网络字节序和主机字节序之间的转换。(gpt也是从网络中爬出来的数据,他说的不一定是对的,不要完全相信gpt!)
3.自定义协议和序列化方案的代码
1. TCP服务器的日志函数我们做了裁剪,不搞那么麻烦了,直接将日志内容输出到显示器上,方便我们进行观察。
2. 接下来要做的工作就是将我们原先的TCP服务器改造一下,将其改造为一个网络版本的计算器,它能够说明很多上面我们上面谈到过的问题,例如零散字段的序列化和报文的构建,报头及有效载荷分离和有效载荷的反序列化,以及对于黏包的网络读取等问题。 对于服务器的代码,我们不应该简单的进行read读取报文,这是一个繁杂的处理过程,所以我们要进行软件的分层,将服务器和客户端的会话分为一层,将报文的读取和处理再单独分为一层,也就是将handlerEnter接口单独拿出来,不要和服务器的通信耦合在一起,我们的服务器很纯粹,他只负责accept接收来自多个客户端的连接请求,至于连接后报文的读取和报文的处理工作交给子进程来做,子进程执行的代码就是handlerEnter接口。 在handlerEnter接口中,我们该如何确定服务器读到了一个完整的请求报文req_text呢?这个工作就交给recvPackage来做,将网络接收的报文放到我们定义的string req_text里面,然后我们要对这个报文进行有效载荷和报头协议的分离,也就是deLength接口,将req_text的内容进行分离,然后放到我们定义的req_str里面,到此我们仅仅只拿到了有效载荷,还没有进行真正的反序列化,所以我们可以定义出Request req对象,将有效载荷req_str进行反序列化deserialize,将反序列化的结果填充到req对象中,到此为止我们才算是真正拿到了客户端想要发送给我们的数据,然后服务器要构建响应对象Response resp,将客户端发送的数据进行计算处理,而这个计算处理就是我们软件分出来的第三层也就是应用层,等服务器真正将数据拿到手之后,才开始进行数据的实际的业务场景处理,而这个逻辑处理我们通过回调func的方式来完成,这个func会在服务器启动start()的时候,由外部将具体的业务逻辑处理方法传进来,而handlerEnter内部只需要回调外部的业务逻辑处理方法即可,至此我们实际就将软件分为了三层,业务逻辑处理之后,会将对应的处理结果填充到resp对象里面,然后我们需要将这个响应结果发送回客户端,发送前我们需要将resp进行序列化,调用resp的serialize将序列化的结果放到string resp_str中,还差最后一步就是通过调用enLength接口进行string resp_str对象的添加报头处理,这样才算彻底构建完成了完整的响应报文,然后再调用send将send_string这个完整的响应报文发送回客户端。
3. 上面的服务器处理报文的框架说完了,接下来就是实际的protocol.hpp协议组件的实现了,包括了我们所说的请求和响应的类,请求和响应对象的序列化和反序列化,以及如何从网络中读取到完整的请求recvPackage,以及网络发送报文前的报头添加enLength(),网络接收报文后的报头和有效载荷分离deLength(),等接口的具体实现。 我们先来谈Request和Response,他们中的序列化和反序列化我们现在只看MYSELF条件编译的部分,#else的部分是使用json的序列化和反序列化,后面我们会讲。 Request主要包含x y op三个字段,序列化的工作其实也很简单,我们自己定制协议,要求请求的序列化结果必须为"x op y"这样以空格作为分隔符的字符串形式,所以serialize就直接做字符串拼接,包含定义的SEP宏,表示字符串中的空格,然后将拼接的结果赋值给输出型参数out即可。 反序列化的工作先将操作符左右的空格找到,将其迭代器位置记录下来,也就是left和right,接下来要做的是对于left和right的位置进行查错处理,我们定好协议了,所以你这里不能违反协议,如果left和right找不到或者是位置重叠,那么就说明请求在序列化的时候违反了我们的协议,那我们就返回false,表示反序列化失败,外层的handlerEnter函数也会直接return,此次的客户端和服务器通信过程就此结束。如果查找成功,我们接下来要做的工作其实就是截取子串,对输入型参数in字符串进行子串的截取,在截取这里也有一些细节,substr的第一个参数是开始截取的位置,第二个参数是截取的子串的长度,那我们就可以调用substr进行子串的截取,截取之后还是要做判断,不能违反协议规定,op的左右两个操作数都不能为空,判断成功后,我们利用stoi进行整数的转换,然后将对应的x y op都分别赋值给Request的三个成员变量中,这就完成了反序列化的工作,将有效载荷进行反序列化后的各个内容分别填充到Request的各个字段中。 Response主要包含exitcode和result三个字段,我们定好协议,规定exitcode为0,1,2,3分别对应OK,DIV_ZERO,MOD_ZERO,OP_ERROR,等四个含义,如果exitcode为0则说明计算成功,对应的result为计算的结果,如果exitcode为其他的三个数字,则计算结果result已经不重要了,因为此时计算已经失败出错了。Response的序列化就是将他的成员变量搞成"exitcode result",也是以宏SEP作为分隔符,前面是我们的协议规定,将序列化后的结果赋值到out输出型参数即可。 反序列化的工作先进行SEP的查找,查找后对mid迭代器位置进行查错,看是否违反了协议规定,如果没违反,那就还是对输入型参数in进行子串的截取,截取到exitcode和result,将截取的结果也就是查错,看是否违反了协议规定,如果没有违反那就调用stoi将转换后的结果填充到Response的两个成员变量中,至此完成有效载荷的反序列化工作。 接下来需要谈论的就是关于报头的添加和分离了,这也属于协议的定制,我们规定一个完整的请求报文必须是"x op y" -> “content_len”rn"x op y"rn这个样子的,一个完整的响应报文必须是"exitcode result" -> “content_len”rn"exitcode result"rn这个样子的,根据这样的标准来进行报文的构建和其与有效载荷的分离。对于enLength,我们返回以text作为正文,正文长度字符串化的结果作为报头,也就是加上to_string(text.size()),以及用LINE_SEP作为分隔符,对于Request和Response我们定的完整报文标准是一样的,都是以正文长度作为报头,报头和有效载荷之间用LINE_SEP分隔,有效载荷尾部也增加LINE_SEP作为报文和报文之间的分隔。对于deLength,其实就是完整报文package进行报头和有效载荷的分离,然后把有效载荷放到text输出型参数里面,过程也并不复杂,先进行LINE_SEP的find,找到之后进行content_len报头字符串的截取,然后将这个字符串转成int整数text_len,而text_len不就是有效载荷的长度吗?根据这个长度我们就可以截取出有效载荷,不包含有效载荷后面的rn,然后将这个有效载荷赋值给输出型参数text即完成报文的有效载荷分离工作。 接下来最重要的部分就是recvPackage了,你怎么保证你从服务器传输层的接收缓冲区读到的是一个完整的请求报文呢?反过来你又怎么保证你从客户端传输层的接收缓冲区读到的是一个完整的响应报文呢?这样面向字节流的网络读取的问题就是通过recvPackage接口来解决的!!! 从网络中读取的逻辑是一个while死循环,我们先定义一个char buffer,把recv从sockfd中读到的报文暂时存储到buffer里面,如果读到的字节数大于0,我们将读取到的内容进行字符串化处理,因为发送的时候我们发送的是C 字符串string,C 字符串不会以 作为字符串的末尾标识,而读取这里我们用的是C语言的字符串,我们将读到的内容进行C语言式的字符串化处理,所以进行buffer[n] = 0这样的操作,然后我们再将C语言字符串化后的buffer尾插到string inbuffer里面。我们之前定过协议,所以我们调用inbuffer的find()接口查找LINE_SEP,如果找不到,那就说明你现在连一个完整的报文都没有,那就重新循环continue继续读取,重新调用recv进行接收缓冲区中内容的读取,如果找到了LINE_SEP那就说明最起码现在肯定是有content_len这个报头了,然后我们将报头截取出来存到text_len_string里面,再调用stoi将其转换为整数text_len,有了text_len和text_len_string报头字符串的size()接口我们就能计算出一个完整报文的大小total_len了,接下来继续进行判断,拿inbuffer字符串的size和total_len进行比较,如果小于那就还是说明inbuffer中连一个完整的报文都没有,但此时是有content_len这个报头的,所以我们在这里打印一个提示语句:“你输入的消息, 没有严格遵守我们的协议, 正在等待后续的内容, continue”,因为有可能客户端发出的请求违反了协议,长度没有达到标准,有可能少发了一部分数据,那么此时就继续continue重新到网络里面进行recv,如果inbuffer的size大于等于total_len,那就说明至少inbuffer里面存在一个完整的报文,那此时就可以将报文内容直接赋值到输出型参数text里面,然后在调用inbuffer的erase接口将这一个完整的报文去除掉,进行下一个完整报文的读取,此时就可以break出死循环,返回true了。 至此为止就完成了一个完整报文的读取。 多提一句,由于recvPackage未来可能是多线程调用的函数,所以在函数内部定义静态的inbuffer变量会出现线程安全的问题,所以我们给recvPackage接口多增加一个输出型参数inbuffer,由外部调用recvPackage的地方进行inbuffer的定义,并传入到recvPackage里面来。
除了上面recvPackage一次读取一个报文外,我们还可以设置出recvPackageAll这样的接口来进行多个报文的读取,用vector来保存每一个完整的报文,但我们今天不用这样的读取报文的方式,仅仅只是作为demo来谈论一下而已。
4. 下面就是服务器代码的调用逻辑,我们之前说软件分成了三层,其中的第三层应用层就是在这里体现出来的,也就是对于读取到真正要处理的内容,也就是Request的有效载荷反序列化后的结果进行业务逻辑处理,并将业务逻辑处理后的结果放到响应对象resp这个输出型参数中。 代码实现也并不复杂,因为Request已经反序列化了,所以他的成员变量已经是客户端发给服务器要处理的数据了,我们可以直接进行字段的提取并进行计算,计算的逻辑就是switch,如果计算成功那么exitcode就是0,result为对应的计算结果,如果计算失败,那么exitcode为非0,具体的值对应我们定的标准中的错误类型,初始化时,我们将exitcode和result都初始化为宏OK,也就是0. 代码主要逻辑执行完后,将业务逻辑从处理结果填充到Response resp对象中后,直接返回true即可,因为计算的成功与否已经通过resp的exitcode字段体现了,无须通过返回值来体现计算的正误。 而这个第三层的接口cal,在服务器启动的时候传到start中,start中进行会话层和表示层的工作后,会回调这个cal方法进行网络通信所拿到的客户端数据的处理。
5. 下面是客户端代码的实现,客户端的创建套接字socket,发起连接请求connect等代码我们都没有变,但需要改变的是客户端发送报文的逻辑,我们现在发送的不再是之前那样的一段聊天消息了,我们现在发送的是一个请求报文,接收的是一个响应报文。 首先需要做的就是从键盘中读取需要计算的数据,我们定好标准,输入的形式必须是"1 1"这样的形式,中间不能有空格,否则就违反了标准,将输入后的内容暂存到line里面,然后我们对line作Parse解析,将line中的内容解析到Request res这个请求对象中,ParseLine是一个简易版本的状态机,根据status的状态进行分批处理,如果status为0表示op的左操作数,如果为1表示op操作符,如果为2表示op的右操作数,我们定义出line字符串的下标i,以及左操作数string left和右操作数string right,以及操作符op,将line截取后的结果分别存储到left right op中,然后把left和right转换为int类型,构建出一个Request对象,将这个对象拷贝返回即可。至此为止就定义出了一个Request请求对象。 定义出这个请求对象还不行,我们需要将这个对象序列化为一个字符串,然后给这个字符串添加报头以及分隔符LINE_SEP等,也就是调用enLength接口,这样才能发送完整的请求报文到网络中,等待服务器接收报文并做处理。 所以我们调用req的序列化接口serialize,将序列化后的内容放到string content中,然后我们再给content添加报头做完整报文的处理工作,直接调用enLength即可,enLength返回的string结果就是一个完整的请求报文,我们直接调用sendto将这个完整的请求报文发送到TCP传输层的发送缓冲区,之后由TCP协议自主决定什么时候发送报文到服务器。 客户端需要进行响应的读取,和服务器面临的问题相同,客户端如何确定自己读到的是一个完整的响应报文呢?所以客户端也需要调用recvPackage来从自己传输层的接收缓冲区中读取出一个完整的响应报文,如果没有读到那就直接continue。读到报文的第一件事就是将报头和有效载荷进行分离,将分离后的有效载荷存储到text中,之后我们定义出一个响应对象resp,调用resp的反序列化接口deserialize,将text这个有效载荷反序列化后的结果放到resp对象里面,然后我们打印出resp对象中的exitcode和result即可。
下面是客户端的调用逻辑,调用逻辑并没有什么变化和之前的TCP服务器一模一样,大家看一眼就可以。
6. 下面是代码的全部功能测试结果,除0模0非法op等操作都对应了123这三个退出码,并且inbuffer处理前是有一个完整的报文的,处理后inbuffer就空了,报文处理前是有报头表面有效载荷的长度的,去报文之后就只剩有效载荷了,服务器计算完成后会有序列化后的结果也就是一个字符串,添加报头后就变成了一个完整的响应报文。
7. 如果我们故意少发送报文,也就是只发送报文的一部分长度,那么服务器就会打印出提示消息,我们现在已经违反了协议的规定,所以client就会阻塞住了,一直保持cin等待我们输入的状态,但我们是多进程版本的服务器,一个连接挂了不影响其他连接,其他的客户端以及可以连接我们的网络版计算器功能的服务器,比如左边的客户端挂了不会影响中间的客户端,除非中间的客户端也违反了协议规定。
4.自定义协议和json序列化方案的代码
1. 到目前为止,我们的代码都是采用自己定制协议,自己手写序列化和反序列化的方案,但实际上序列化和反序列化的工作已经有人替我们做好了,常见现成的方案一般有json,protobuf,XML这三种,企业内部自己一般会使用protobuf,对外使用json,所以对于序列化和反序列化是有现成的解决方案的,绝对不会自己去写! 但协议还是可以自己定的,所以序列化时我们都会直接使用现成的方案,例如json和protobuf等。
2. 下面是json库的下载和头文件以及库文件的路径,在包含json头文件时,由于json在include路径下还有二级三级目录,所以包含json头文件时我们要将目录jsoncpp/json也包含上,包含的形式就是#include <jsoncpp/json/json.h> 。 库文件默认下载的是动态库,没有静态库,库文件直接安装到了lib64目录下,我们直接可以通过-ljsoncpp进行使用
3. 使用条件编译和jsoncpp库时,makefile比较容易写错,注意两个文件都要带上-ljsoncpp,否则编译会报错找不到库文件,如果想要使用自己的序列化方案可以在两个文件的依赖方法后都带上-DMYSELF。 也可以自己定义一个变量-LD存储#-DMYSELF,想要切换回我们自己的序列化方案时,只要去掉#就可以了。
4. json的序列化和反序列化方案用起来就比较简单了,Json::Reader中的parse方法就是进行反序列化,Json::FastWriter中的write方法就是进行序列化,Json::Value则是定义一个万能对象,实际是用key value的键值对来存储的,这个万能对象可以存储很多的键值对,例如在请求的序列化中,我们定义了三个键值对,分别是<first,_x> <second,_x> <oper,_x>,序列化时直接调用write方法即可,传入万能对象,write方法会返回一个string对象,该string对象就是序列化后的结果。 反序列化时,需要将输入型参数in的反序列化结果解析到root万能对象中,然后我们可以直接通过root的key拿到对应的value值,把提取出来的value值分别赋值给_x _y _op成员变量,这就完成了反序列化的工作。 对于Response同样也是如此,我们通过json就可以很轻松的完成序列化和反序列化的工作。
5. 下面是json方案的服务器和客户端输出结果,可以看到请求和响应序列化后的结果就是json的格式,即以逗号作为分隔符的键值对的序列化形式。 在序列化和反序列化这里,json还是非常香的,使用起来可读性非常好而且用起来还很简单,使用成本不高。
6. 只要我们需要,我们可以定制出无数多个协议,服务器想要知道用的是哪个协议,这样的信息我们依旧可以藏在报文里面,比如第一部分是有效载荷的长度,第二部分是协议编号,第三部分是有效载荷,不同的协议针对于不同的使用场景。 比如http,https,SSH等应用层协议,他们的地位和我们自己定制的协议有什么不同呢?只不过我们今天定制的协议是为了解决数据的计算所定制的,他们的协议是针对于其他的场景所定制的,两者从本质上来讲,并没有任何差别。
5.软件分层和OSI上三层模型的联系
1. 我们今天所写的网络版计算器的server代码,完美契合了OSI的上三层模型,分别是会话,表示和应用。 server通过listen accept等接口来申请和拿到连接就是会话层。 handlerEnter进行网络中请求报文的读取和处理,以及构建响应报文发送回客户端等就是表示层,在表示层这里我们使用了json或自定义的序列化方案,以及自定义的针对于数据简单计算场景的协议。 而handlerEnter中回调的cal方法就是应用层,即对报文解包反序列化后的数据进行业务逻辑处理。