Python 28 网络编程 socket-TCP服务

Python 28 网络编程 socket-TCP服务

旧博客Python系列

Socket编程

C/S是客户端/服务端架构.在web上,都是采取的B/S C/S 架构,即Browser / Server 架构,更广泛的来讲,是 Client / Server 架构.
浏览器就是客户端的一种.
由于C/S架构必然要通过网络,网络的核心是一堆协议,socket(套接字)就是将TCP和UDP协议封装起来,在传输层使用的一个接口,API或者说对象.我们自己编写的程序是运行在应用层,通过socket与网络上的东西进行通信.
学习Socket编程,就是为了开发基于网络的C/S架构.这里有一篇文章介绍了Socket.

套接字(socket)是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的IP地址,本地进程的协议端口,远地主机的IP地址,远地进程的协议端口。
应用层通过传输层进行数据通信时,TCP会遇到同时为多个应用程序进程提供并发服务的问题。多个TCP连接或多个应用程序进程可能需要通过同一个 TCP协议端口传输数据。为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了套接字(Socket)接口。应 用层可以和传输层通过Socket接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。
建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。

套接字的工作流程

py2801.jpg
socket工作流程非常重要,对socket流程的理解影响之后的编程.

简单应用

把连接方式想象成两个电话来拨号

# server 端的写法
import socket

phone = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # AF_INET表示网络套接字,SOCK_STREAM表示TCP/IP协议,这样就建立了一个TCP/IP连接.

phone.bind(('127.0.0.1', 8000,))  # 绑定IP地址和端口,服务端必须绑定自己的IP,这是用本地IP自己和自己通信,实际上应该绑定自己的IP地址

phone.listen(5)  # 代表有几个电话在那里listen

conn, addr = phone.accept()  # 从accept中获得一个连接对象和一个地址,在这个位置等待连接

msg = conn.recv(1024, )   # 这个连接对象的recv方法就是接受消息

print('客户端发来的消息是: ', msg)

conn.send(msg.upper())  # 发消息就是连接对象的send方法

conn.close()

phone.close()
# client 端的写法
import socket

phone = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

phone.connect(('127.0.0.1', 8000,))  # 去连接IP地址和端口

phone.send('hello'.encode('utf-8'))  # 必须发送字节对象,用utf-8编码之后就变成字节对象

data = phone.recv(1024)
print('收到服务端发来的消息', data)

这之间的逻辑是服务端首先启动服务,然后监听.客户端连接过来,双方建立连接,然后通过连接对象,客户端给服务端发了一条消息,服务端打印该消息之后立刻将该消息大写后再发给客户端,客户端接受并打印该消息.然后程序继续执行,关闭连接对象,然后关闭服务.

详细讲解socket的实现

server端的详细解释:

socket模块下边的socket(socket_family, socket_type, protocol=0)用于生成一个对象,其中socket_family是AF_UNIX(基于UNIX文件系统) 或AF_INET(基于网络),因为是网络编程,所以选择AF_INET. 之后的socket_type指的是协议,可以选择SOCK_STREAM(流式传输,即TCP/IP协议)或SOCK_DGRAM(datagram数据包传输,即UDP/IP协议).最后一个参数不用管.
执行过socket.socket之后,就是生成了一个socket套接字对象.

phone.bind(('127.0.0.1', 8000,)),这个是将socket对象绑定到IP和端口上,知道了IP和端口,就可以来建立socket连接.

HINT:SYN洪水攻击
backlog-半连接池-的概念:客户端在发送请求的时候,服务端回应直到第三次握手成功之间的过程,都叫做半连接.可能会同时涌进来很多的客户端请求,服务端不可能立刻全部响应,会将所有的请求放入一个半连接池,然后从池子里取出后再响应.简单的理解:就是backlog挂起了很多电话,然后一个一个接过来.如果完成全连接,就踢出出池子.

phone.listen()的参数,就是指的半连接池的大小,在调整backlog的值.如果是设置成5的大小,那么最多也就有5个连接等在池子里连接,如果大量服务同时发起,服务器也只会响应其中的5个,正常的请求就N进不来了,所以防止SYN FLOOD洪水攻击的一个手段是增大半连接池,还有就是减少回复响应的次数.

phone.accept()的结果,就是在等候客户端连接,如果连接进来,会进行三次握手,如果成功,会返回一个三次握手成功之后建立好的双向数据传输的TCP连接对象(这也是一个socket对象)以及地址和端口.

有了TCP连接对象,发送数据就调用send方法,接受数据就是.recv方法返回的内容.

conn.close()表示关闭三次握手,也就是触发四次挥手,结束TCP连接对象.

phone.close()是指的关闭这个socket对象,就是释放端口.

client的详细解释:

首先以相同的方式建立一个socket套接字对象.

之后就不是绑定地址了,而是要设定连接的地址,用connect方法传入IP地址和端口构成的元组.这是一个动作,会立刻去连接指定的地址和端口.

之后也一样采用send方法发送消息,注意发送的消息依然要是字节类型的信息.

采用recv方法接收信息.

注意这里代码和服务端的区别,客户端直接通过连接好的socket对象进行操作,而服务端是要先建立一个绑定端口的大的socket对象,也可以认为是一种服务;每次有连接的时候再拿到具体的完成连接以后的TCP连接对象后进行send和recv.
实际的情况是:
建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。
套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。
服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。
客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。
连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发 给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

这也就是为什么服务端先要写一个socket绑定端口进行listen动作,再从accept中获取一个socket对象用于和客户机通信的原理.

TCP的三次握手和四次握手

py2802.jpg

这里有两篇文章可帮助理解:
https://github.com/jawil/blog/issues/14
https://blog.csdn.net/whuslei/article/details/6667471

循环接受发送消息的服务器和客户端编写

刚才实现了一次简单的客户端-主机-客户端的通信,但是在运行结束之后服务器和客户端都结束了,有没有办法像实际中的服务器一样来编写一个呢. 只要通过循环来组织一下代码即可,然后将之前的各种配置参数单独写起来.
# server
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

conn, addr = tcp_server.accept() #服务端阻塞
print('双向连接是:', conn)
print('客户端地址是:', addr)

while True:
    data = conn.recv(buffer_size) # 阻塞,直到客户端发来消息
    print('客户端发来的消息是: ', data.decode('utf-8'))
    conn.send(data.upper())
    print('向客户端传送的消息是: ', data.upper())

conn.close()
# client
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_client = socket(AF_INET, SOCK_STREAM)

tcp_client.connect(ip_port)

while True:
    msg = input('>>>: ')
    tcp_client.send(msg.encode('utf-8'))
    data = tcp_client.recv(buffer_size)  # 阻塞,直到客户端发来消息
    print('客户端收到的是:', data.decode('utf-8'))

tcp_client.close()

在这个基础上继续探索各种问题和改进:

这样可以实现输入一条消息,就发送,然后对方接收,再发送回来,再次等待输入的效果.
这个时候如果输入空,也就是直接回车,发现客户端会卡住.
客户端卡在了接收消息data = tcp_client.recv(buffer_size)那行,为什么会出现这种情况,要看socket的收发原理.

socket的收发过程:
socket是一个应用程序,说明底层还是运行在操作系统上,发消息的时候,socket应用程序把信息从自己的用户态内存中将信息和指令发给内核态内存,操作系统会将消息放在缓冲区,然后会按照指令去和硬件交互,最后操作网卡发送数据.数据接收端接收也是先用网卡接收,然后交给操作系统,操作系统拿到消息放到内核态内存的缓冲区,然后再交给用户态的socket程序.
之所以会卡在recv,是因为在向data赋值的时候,会先求右边表达式的值,也就是socket应用程序会发起一个recv的要求,这个要求要收取的信息是从内核态内存拿过来的(不是直接从网卡拿东西,发送消息也是一样,send的数据是发送到内核态内存去了.具体如何发和收,是由操作系统来管理.)在输入空的时候,由于什么都没有,所以实际上送给内核态的东西是空,所以操作系统没有实际发送数据,导致服务端的内核态的缓冲区内没有数据,服务端的recv也卡住了,拿不到东西,无法返回,则客户端的recv也卡住了,都在等待消息.
recv的参数就是buffer_size,也就是要求数据的大小.在收一次的情况下,如果缓冲区里的数据小于1024,只会按照实际的拿,如果大于1024,说明没收完全数据.缓冲区类似一个队列,先发来的消息优先被收走.
为了防止这种情况,只要保证客户端不发送空即可.所以加上一些逻辑让客户端不发空即可.

如果直接关闭客户端程序,会发现服务端程序也一起崩溃了:

Traceback (most recent call last):
  File ""D:/Coding/Python/learning/bin/server.py"", line 16, in <module>
    data = conn.recv(buffer_size)
ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。

这是因为没有正常的退出,socket是一个双向连接,客户端代码中已经连接上了,结果直接退出,导致这个socket对象无法再使用,而服务器此刻在用这个对象等待接受消息,所以就崩溃了.这提醒我们在服务端,要对连接出现异常的情况进行处理.

现在的服务器程序里,只写了一个连接对象,在获得之后,就立刻进入while循环来通过那个连接对象进行操作,执行完毕以后就直接退出了.这其实只能提供一次性服务,必须要来一个连接提供一个服务,来一个连接提供一个服务,这个时候要想办法能够多次接受,由于之前想到了,每一个socket连接就像是从总的服务里弄一个线程出来.所以改动如下:

from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
    conn, addr = tcp_server.accept()
    print('双向连接是:', conn)
    print('客户端地址是:', addr)

    while True:
        data = conn.recv(buffer_size)
        print('客户端发来的消息是: ', data.decode('utf-8'))
        conn.send(data.upper())
        print('向客户端传送的消息是: ', data.upper())

conn.close()

结果用多个客户端测试的时候,发现第二个客户端发送消息以后就卡住了,这是为什么呢?
原因是服务端运行以后,有第一个程序连接完成以后,程序反复在第二层while里运行.
而当时设置了back_log,所以其实被服务端挂起了连接,没有完成三次握手.必须要终结前一个连接.
那么就想办法需要关闭前一个连接.已经知道了用直接关闭的方法是不行的,会直接出错退出.这个时候想到了,如果可以捕捉连接时候出现的异常,然后跳出循环回到上一层,就又可以阻塞并等待一个新连接.

# 修改后的server
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
    print('server is ready.')
    conn, addr = tcp_server.accept()
    print('双向连接是:', conn)
    print('客户端地址是:', addr)

    while True:
        try:

            data = conn.recv(buffer_size)
            print('客户端发来的消息是: ', data.decode('utf-8'))
            conn.send(data.upper())
            print('向客户端传送的消息是: ', data.upper())
        except Exception:
            break

conn.close()

然后再次打开两个客户端进行试验,关闭其中一个之后,另外一个自动连入. 比刚才的服务又提升了一步,可以循环反复接受不同客户的请求了.

在了解了上述情况后,需要知道的是,windows环境下直接断开连接会导致程序出现异常,而在unix环境下,data = conn.recv(buffer_size)这句话很可能会一直收到空,从而进入死循环,所以可以在下边加一行,如果data为空,就break掉当前循环,重新建立连接.

如果运行服务的时候端口被占用(主要是因为退出之后,端口没有释放,还处在tcp四次挥手的time_wait阶段),则可以在服务端的bind之前加一条命令来重新使用地址和端口即可:
phone.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)

经过上述设置,简单的socket通信以及可以实现了
一般的TCP服务端和客户端的基础逻辑用伪代码写就是:

# server
1 ss = socket() #创建服务器套接字
2 ss.bind()      #把地址绑定到套接字
3 ss.listen()      #监听链接
4 inf_loop:      #服务器无限循环
5     cs = ss.accept() #接受客户端链接
6     comm_loop:         #通讯循环
7         cs.recv()/cs.send() #对话(接收与发送)
8     cs.close()    #关闭客户端套接字
9 ss.close()        #关闭服务器套接字(可选)
1 cs = socket()    # 创建客户套接字
2 cs.connect()    # 尝试连接服务器
3 comm_loop:        # 通讯循环
4     cs.send()/cs.recv()    # 对话(发送/接收)
5 cs.close()            # 关闭客户套接字
LICENSED UNDER CC BY-NC-SA 4.0
Comment