本文将使用python套接字编程从零搭建一个简易的web服务器,对应于教材《计算机网络:自顶向下方法》第二章后面套接字编程作业,我们先来看一看客户机(浏览器)和服务器交互的过程中在服务器端发生了哪些事情:
- 当一个客户(浏览器)联系服务器时创建一个连接套接字;
- 服务器从这个连接接受HTTP请求;
- 解释该请求以确定所请求的特定文件;
- 从服务器的文件系统获得请求的文件;
- 创建一个由请求的文件组成的HTTP响应报文,报文前有首部行;
- 经TCP连接向请求的浏览器发送响应;如果文件不存在,则返回
404 Not Found
差错报文。
1.Web服务器
假设我们通过浏览器向服务器请求的文件是HelloWorld.html
,文件内容自定(我这里写的内容就是一句话:太棒了,服务器正常工作!),我们需要将该文件放在与服务器同级的目录下,然后通过浏览器向服务器发起请求,服务器按照上述步骤进行响应。服务器端的全部代码如下:
from socket import *
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.bind(('', 6789))
serverSocket.listen(1)
while True:
print('服务器已就位')
connectionSocket, addr = serverSocket.accept()
try:
message = connectionSocket.recv(1024).decode()
filename = message.split()[1]
f = open(filename[1:], encoding='utf-8')
outputdata = f.read()
header = 'HTTP/1.1 200 OK\nConnection: close\nContent-Type: text/html\nContent-Length: %d\n\n' % (len(outputdata)+24)
connectionSocket.send(header.encode())
for i in range(0, len(outputdata)):
connectionSocket.send(outputdata[i].encode())
connectionSocket.send("\r\n".encode())
connectionSocket.close()
except IOError:
header = 'HTTP/1.1 404 Not Found'
connectionSocket.send(header.encode())
connectionSocket.close()
bind(('', 6789))
指定了套接字与端口号6789绑定,如果代码是运行在本地的,那么只需在浏览器中输入http://localhost:6789/HelloWorld.html
即可访问页面;如果代码是部署在云服务器上的,就需要将IP改为服务器的公网IP,通过这种方式,我们可以很容易地在服务器上部署类似个人简介这样的静态网页。
listen(1)
指定了服务器在同一时刻只接受一个请求,后续我们将通过多线程编码来同时处理多个请求。
在构造的头部信息中,我们通过Content-Length
指定了实体(封装的TCP报文)的长度,即等于数据长度 + TCP头部信息长度,通常TCP的头部信息长度是20字节,但是通过实际观察网页源代码我发现少了四个字节,不难猜测这是因为TCP的选项字段占用了四个字节,因此这里头部信息长度就是24字节。我们甚至不需要自己指定报文长度,只需要返回最基本的HTTP/1.1 200 OK
即可。
这里插入一个题外话,关于Content-Length
的使用,通过实践我发现存在以下四种情况:
- 不显式指定
Content-Length
,前端页面显示完好,数据完整;- 显式指定
Content-Length
且小于实体的长度,前端页面显示不完好,数据缺失;- 显式指定
Content-Length
且等于实体的长度,前端页面显示完好,数据完整;- 显式指定
Content-Length
且大于实体的长度,前端页面不显示,浏览器控制台报错ERR_CONTENT_LENGTH_MISMATCH
;
也就是说,最糟糕的情况是指定的长度大于实体的长度,由于长度不匹配,浏览器会报错且前端不显示任何东西。如果指定的长度小于实体长度,浏览器只取消息实体的前面一部分,则前端页面显示不完好。在效果上,不显示指定和显式指定为实体长度都是一样的,如果怕麻烦可以不指定。
2.多线程Web服务器
参照上面的代码,一个最基本的简易Web服务器就搭建好了,但是它在同一时刻只能处理一个请求,现在我们给它升下级,我们使用多线程的方式让它能够同时处理多个请求。具体代码如下:
from socket import *
import threading
def tcp_process(connectionSocket):
print(threading.current_thread())
try:
message = connectionSocket.recv(1024).decode()
print(repr(message))
print(message)
filename = message.split()[1]
f = open(filename[1:], encoding='utf-8')
outputdata = f.read()
header = 'HTTP/1.1 200 OK\nConnection: close\nContent-Type: text/html\nContent-Length: %d\n\n' % (len(outputdata)+24)
connectionSocket.send(header.encode())
for i in range(0, len(outputdata)):
connectionSocket.send(outputdata[i].encode())
connectionSocket.send("\r\n".encode())
connectionSocket.close()
except IOError:
header = 'HTTP/1.1 404 Not Found'
connectionSocket.send(header.encode())
connectionSocket.close()
if __name__ == "__main__":
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.bind(('', 6789))
serverSocket.listen(10)
while True:
print('服务器已就位')
connectionSocket, addr = serverSocket.accept()
thread = threading.Thread(target=tcp_process, args=(connectionSocket, ))
thread.start()
从上面可以看出,我们只是将connectionSocket
交给一个具体的线程来执行,该线程负责为具体的客户服务,而主进程不必等待它服务完这个用户就可以接受下一个用户的请求,这样就大大提高了服务器的工作效率。
3.客户端
最后我们来看看客户端的代码,通过客户端可以不经过浏览器直接向服务器发起请求。
from socket import *
clientSocket = socket(AF_INET, SOCK_STREAM)
clientSocket.connect(('localhost', 6789))
while True:
header = 'GET /HelloWorld.html HTTP/1.1\nHost: localhost:6789\nConnection: keep-alive\nUser-Agent: Mozilla/5.0\n\n'
clientSocket.send(header.encode())
message = clientSocket.recv(1024)
print(message.decode())
当我通过运行客户端代码向服务器发起请求时,虽然数据成功获取了,但是在客户端也收到了以下错误:
ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。
这个错误一般出现在爬虫过程中,因为抓取信息太过频繁,而被服务器认定为恶意攻击。但是这里显然不是这个原因,在我将服务器代码中的connectionSocket.close()
注释掉之后,这个错误就没有了,但是产生错误具体的原因至今未明,如果有知道的同学欢迎在评论区告诉我。