一个后端程序是如何运作起来的

创建一个后端服务器

服务器通信

# 创建一个socket
# bind该socket(host,port)
# listen该socket

通过创建一个socket,然后将主机地址、端口与该socket绑定并监听。最后就得到一个server_socket。

while True:
	 conn_socket = server_socket.accept()
	 request_data = conn_socket.recv(1024).decode('utf-8')
	 client_socket.sendall(response_data.encode('utf-8'))

通过操作系统执行三次握手,然后操作系统唤醒监听线程。监听线程执行accept操作从全连接队列中获取连接。通过获取到对应的conn_socket,此时两台设备就可以通过tcp协议进行通信了。
conn_socket是一个四元组,包含了客户端的host以及port,服务端的host以及port。秉承linux一切皆文件的思想,服务端和客户端可以通过socket进行通信。客户端发送的数据会被存储到socket缓冲区,服务端发送的数据也会存储到socket缓冲区。

服务器如何接收连接

客户端发送的数据,通过网络协议栈,通过物理层根据ip地址传输到服务端。服务端的网卡接收数据,然后网卡根据网卡中的驱动程序以及提前和操作系统协商好的内存地址,将接收到的数据放置到指定内存地址。
发送数据时候是从应用层->传输层->网络层->物理层 层层装包,自然到达服务器以后也需要层层解包。物理层即网络到网卡接收到了数据,数据解包到网络层,根据ip地址确定是发送到本服务器的。然后继续解包,就到传输层,也就是使用的tcp协议。根据tcp协议,可以获取对应服务器的port。这个时候操作系统就可以根据ip和port确定是哪一个监听的socket需要处理的连接。
解释一下,当使用host监听时一般使用"0.0.0.0"表示监听所有ip,这里所有的ip就包含了"127.0.0.1" 以及本机ip。所以操作系统根据ip和port就可以找到对应监听的socket。三次握手过程中第一次接收的时候,将客户端的ip和port放到半连接队列,然后服务端回复sync+ack,再次接收到客户端的响应时,就将客户端的ip和port放到全连接队列。此时就可以唤醒服务器的监听线程,进行accept操作了。
accept操作后就会生成一个新的socket,往后的通信就使用这个包含了客户端ip+port以及服务端ip+port的四元组进行发送和接收数据。

现在回来解决,服务器是如何接收数据这个疑问。网卡将数据放到指定地址后,操作系统是如何感知到的?
cpu指令流水线执行有五个阶段:取指令 译码 执行 访存 写回。理想情况下,每个时钟周期都有一条指令进入流水线,每个时钟周期都有一条指令完成。CPU每执行完一条指令就检查中断请求信号线,如果检测到中断请求,则进入中断响应周期。我想强调一下这里速度是极其快的,因为每个时钟周期的时间单位应该是ps级别的。
网卡将数据复制到指定内存地址后,会通过数据总线给CPU发送一个中断。cpu感知到中断后,执行中断的上部分即硬中断,然后通知内核线程去执行中断号对应的中断程序。此时内核线程就去执行三次握手操作。完成三次握手后,内核线程通过监听socket获取到注册的回调方法以及监听在该socket上的线程。内核线程就可以执行回调方法以及唤醒该监听线程。该监听线程被唤醒后拷贝全连接中的连接,然后进行模态切换,从内核态切回用户态的accept方法,开始执行accept的剩余操作。

服务器如何进行发送数据接收数据

创建连接后,客户端和服务端就通过连接后的conn_socket进行通信。服务器段通过conn_socket提供的写操作方法可以发送消息到socket缓冲区。同时客户端可以发送数据到该socket。那么这样的一个通信过程,可以通过创建一个新的线程A来进行操作。当服务端的A发送完数据后,A进行休眠。当客户端发送的数据到达socket缓冲区后,A同样会随着注册在该socket上的回调事件被内核线程执行然后A成功被唤醒。
这里需要直到,缓冲区都是有限制的,缓冲区不可能无限大。一旦客户端发送的数据量极其大的时候,就需要通过多次读才能读取完。这里就会有两个名词可读事件、可写事件。当socket可写时,服务端就可以继续发送数据,当socket可读时,服务端就可以继续读取数据。

服务器如何根据tcp协议解析出其中的数据Transport

服务器此时从socket中读取出来的数据都是tcp协议的数据。如果需要提取去除了tcp头的数据,那么服务器就需要提供对应的transport报文解析的方法。所以实现一个服务器还需要针对不同协议报文,实现不同的解析方法。

class Transport:
	self.buffer=[] # transport的缓冲区
    def set_protocol(self, protocol):
        """设置一个新的解析接收的tcp报文数据的协议。 如http协议或者redis的resp协议或者其他rpc自定义的协议"""
        pass

    def pause_reading(self):
        """"
        当protocol的缓冲区已经满了,可以调用pause_reading
        暂停读取socket
        移除socket读事件监听
        """
        pass

    def resume_reading(self):
        """
        当protocol的缓冲区为空,恢复监听读取数据
        恢复读取socket
        添加socket读事件监听+回调read_ready
        """
        pass


    def _read_ready(self):
        """
        读事件回调
        当socket可读时调用,读取socket中的数据
        如果读取到数据,调用protocol.data_received(data)进行解析
        """
        pass


    def write(self, data):
        """
        将数据写入socket
        如果数据没有写完,将注册socket写事件监听回调write_ready
        同时将剩余数据添加到缓冲区,等待下次通过write_ready写入
        如果当前缓冲区还是超过高水位区,调用protocl pause_writing暂停写入缓冲区
        """
        pass


    def _write_ready(self):
       """
        写事件回调
        当socket可写时调用,将缓冲区中的数据写入socket
        调用protocol的resume_writing检查是否有可读数据加入transport的buffer缓冲区
        如果缓冲区为空,将移除socket写事件监听
        如果缓冲区没有写完,将保留socket写事件监听
       """
       pass

    def write_eof(self):
        """关闭socket的写端"""
        pass


    def close(self):
        """关闭传输。
        """
        pass

通过上面的transport类的伪代码,其实就应该知道服务器应该负责实现解析接收到的tcp包数据。然后根据服务器提供的功能如http请求实现对应的http协议,亦或者有的服务器提供websocket功能,那么服务器也需要提供具体的websocket协议的解析方法。

服务器提供应用层协议的实现http protocol

class Protocol:
    """流协议的接口。
    当连接成功建立时,tranport会调用 connection_made(),
    然后会调用 data_received()方法零次或多次,传递从传输接收到的数据(字节);
    最后,会准确一次调用 connection_lost()。
    调用的状态机:

      start -> CM [-> DR*] [-> ER?] -> CL -> end

    * CM: connection_made()
    * DR: data_received()
    * ER: eof_received()
    * CL: connection_lost()
    """
    self.buffer=[] # 用于protocol接收数据 
    def connection_made(
        self, transport
    ) :
        """
        创建连接的时候被调用
        同时设置了transport
        将当前连接加入到connections中
        """
        pass


    def connection_lost(self, exc: Exception | None) -> None:
        """"
        连接丢失时调用
        从connections中移除当前连接
        调用transport.close()关闭连接
        """
        pass

    def eof_received(self) -> None:
        pass


    def data_received(self, data: bytes) -> None:
        """
        被transport调用,用于处理接收到的数据并解析请求
        """


    def pause_writing(self) -> None:
        """
        当写缓冲区达到高水位标记时由transport调用。
        """
        pass

    def resume_writing(self) -> None:
        """
        当写缓冲区低于低水位标记时由transport调用。
        """
        pass

服务器根据http协议实现protocol,然后和transport组合。transport解析tcp数据,调用protocol解析http协议。protocol解析数据进行处理请求,调用transport的write进行响应。最终就可以拿到一个web框架需要的http协议实现的数据,并且返回响应。
上面的resume和pasue操作都是transport和protocl为了实现流量控制才需要考虑的。transport和protocol会分别实现各自的flowcontroll类,进而可以控制互相的流量。transport需要控制protocl的写入流量,protocol需要控制transport的读取流量。

服务器的网络IO模型和IO多路复用

网络IO模型主要有两种reactor和proactor,具体的可以去网上了解。解释一下什么是reactor,事件驱动模型。很直观的事件驱动就是你敲击一下你的键盘,你的输入法就会出现对应的字。当事件到达的时候,能够根据事件的类型进行处理就是事件驱动。服务器接收数据,接收终端,然后执行硬中断,软中断根据中断号去执行处理也是一种事件驱动。使用mq,通过订阅消息,当消息到来的时候,执行消费消息也是一种事件驱动。 事件驱动就是监听+回调。
服务器不可能为每一个连接都创建一个线程来处理,这样是非常浪费资源的。因为涉及到频繁的线程创建销毁,以及大量线程也会耗费大量内存,需要明白内存是宝贵且有限的。但是要实现监听这么多的socket,如果不使用per connection per thread,那么就使用一个线程来监听这些socket。这就是IO多路复用。用一个线程监听多个socket。
IO多路复用有select、poll、epoll这三种比较常见的。
selelct使用一个数组来保存socket的可写、可读事件的监听。每次进行监听都需要把数组从用户态拷贝到内核态,然后执行一遍所有需要监听socket的poll函数,看看有没有数据到来,或者可以发送数据。然后我们知道,每个socket上都可以设置回调事件,一旦可读或者可写了内核线程就可以通知监听线程。监听线程被唤醒后,会在内核中再次执行一遍所有socket的poll函数,确保这次唤醒不是虚假唤醒。然后将到达事件(可读和可写)的总数拷贝到用户态,监听线程也随之切换到用户态。监听线程在用户态会再次执行遍历,直到遍历到指定个数的事件才停止遍历。然后根据对应的事件进行操作。这里是否就可以看出select的不足:频繁拷贝,需要自行遍历获取事件,监听socket的数量有限。因为linux系统对select设置了限制,32位只能监听1024,64位默认可以监听2048个。网上说可以修改这个设置,大家可以自行考证一下。
poll在select的基础上改进了。poll在用户态使用数组记录监听的socket,但是传递到内核态后就转换为链表来记录,从而解决监听数量的限制。
epoll在poll的基础上再次进行优化。因为需要频繁拷贝,所以epoll在内核态创建了红黑树。因为每次都需要在用户再次遍历一遍获取到达事件,所以epoll在内核态建立了一个就绪链表存储所有的到达事件。每个需要监听的socket在内核都是一个epollitem,epollitem中存有epoll的引用。epollitem被注册到socket的回调中。每次socket可读或者可写,都会通过socket的回调事件,确保当前epollitem中注册的监听事件是感兴趣的(比如当前socket注册了读事件,那么就需要验证当前到达的事件是可读事件)。然后将epollitem加入到就绪链表,然后唤醒监听epoll的线程。监听epoll的线程就会类似select poll一样先遍历一遍socket的poll函数,避免虚假唤醒。然后监听线程就会把就绪链表拷贝到用户态。每当需要注册、取消、修改对应socket的感兴趣的事件,就将对应的socket以及感兴趣事件传递到内核的epoll中。epoll通过log(n)的时间复杂度在红黑树上执行对应的操作。
不知道你有没有考虑到accept这个server_socket应该如何处理?server_socket只需要注册可读事件,然后在用户态会判断socket是否是server_socket来确定是否是accept事件,然后去执行accept操作。
以上IO多路复用依赖于非阻塞IO。阻塞IO就是线程需要陷入阻塞等待,非阻塞IO则不需要。

eventloop单线程事件循环

IO多路复用经常和事件循环搭配。node 以及python这类脚本语言的web框架都是这种网络IO模型。一个单线程执行非阻塞的任务,使用一个线程池来执行阻塞任务。所谓的阻塞任务就是类似文件IO这种。为什么文件IO不可以使用IO多路复用,不知道你是否思考过。在一些优化中,可以使用eventfd来把文件IO注册到epoll中。为什么文件IO需要这样的处理?因为文件IO的socket和网络socket是不一样的。网络io有poll接口,文件io是没有poll接口的。文件io因为历史的原因,默认实现就是阻塞,而且vfs虚拟文件系统就算对文件io实现了异步也不会有很多的提升,反而增加了维护和实现的复杂度。
回归主题,单线程事件循环的实现主要依赖于回调。python有协程,通过await可以将一个协程挂起,然后在等待socket的回调上注册当前协程,当可读可写事件到达时回调对应的协程,就可以重新恢复协程的实现。具体的实现原理,我后续会找时间以源码走读的形式po文出来。

如何创建一个web框架

通过上面的方式,已经知道了服务器负责接收请求,然后实现tcp协议的数据包的解析,然后再使用http协议再次解析数据包获取到对应的http数据。然后服务器还提供了对应的网络IO模型。所以一个web框架需要考虑的就是接收数据,然后处理请求,然后写回数据。所以一个web框架最简化的入口就是(read,write)。protocol提供的read可以获取解析后的数据,transport提供的write可以发送数据。抽象出来,就是一个web框架传递(read,write)即可。

一个web框架具有全局异常处理、各种拦截器、然后最后的用户实现的各种restful的api接口处理逻辑。
一个后端程序是如何运作起来的_第1张图片
服务器为web框架提供read\write回调,web框架根据read读取请求的http数据。然后获取其中的url进行解析,然后根据url匹配对应的拦截器和endpoint处理方法。这里路径匹配可以使用前缀树这样的方法。然后执行在需要执行的方法最外层包裹了异常捕获。用户可以自定义异常类型以及对应的异常处理方法,并将异常处理方法注册到web框架中。当处理过程中抛出异常,最外层可以捕获到用户的异常类型匹配对应的异常处理方法响应客户端。然后职责链执行拦截器,到达endpoint方法时就需要反射获取endpoint方法的入参。然后执行反序列生成对象,以及对于路径参数的绑定等。在反序列化过程中就可以增加参数验证了。剩下的逻辑就是endpoint中执行完毕后响应客户端。在返回客户端之前会执行一系列注册好的后置拦截器,在这里可以修改响应内容,拦截响应进行返回结果的验证等等。前置拦截器可以获取http请求中的内容进行例如用户认证、token加入threadlocal这样的操作。

一个后端程序是如何运作起来的_第2张图片
所以web框架让开发人员可以很方便的实现核心逻辑处理,一切符合框架要求的操作都可以以注册回调的形式加入到整个处理链路上。web框架是注册到protocol中的,执行endpoint的逻辑是注册到web框架中的。所以如果你使用python开发后端你会经常看到

$ uvicorn main:app

这样的命令就是将一个web服务注册到服务器中。

你可能感兴趣的:(后端开发,mysql,spring,spring,boot,后端,restful)