Oct. 26th, 2018
大家好,我是一个编程小白。虽然编程经验不多,但是个人比较喜欢探索尝试,喜欢做一些小实验来加深对新学东西的理解。最近由于工作原因要学习一些网络编程的知识,通过发送网络请求来使用一个服务器提供的Web服务。虽然在Python的各种神包(比如requests,urllib等等)的帮助下问题很快就解决了,但是自己还是好奇,requests这种包发送出去的网络请求是长什么样的?如何才能看到这些网络请求的信息?了解了它的请求协议之后,我如何自己写代码响应这种请求?于是自己拿Python的socket做了几个小实验。
其实我相信大多数程序员在刚接触网络编程的时候肯定都做过这种简单的实验。但是网上搜索相关资料的时候,还是感觉大神们写文章大都直接给出专业的技术内容,而以探索为主的文章相比比较少。所以这里本小白还是斗胆献丑,给出自己实验的过程。虽然对大多数来说简单地不值一提,但是如果能偶尔对刚入门的小伙伴提供一点帮助的话,我还是很高兴的。
注意:下面的实验用python代码编写。每一段代码都只有短短几行,很容易看懂。代码运行的截图里使用的是Mac的terminal。不过如果使用Windows或者Linux的小伙伴也可以在任何自己喜欢的运行环境里运行这些代码。
复习:分层的互联网协议
大家都知道,所谓互联网协议,或者说TCP/IP协议,并不是一个单一的协议,而是有很多层组合在一起形成的“协议套餐”。我们先来简单复习一下这个协议套餐。
互联网的4个协议层我们使用的互联网协议大致分为四层。最下面是物理通信层,负责规定如何用电信号或者光信号(光纤)的形式加载和解码信息。往上走,信息的物理表达方式解决了,所谓网络层就解决网络传输和网络地址问题。一个信息,要发给谁,沿着什么途径发送过去,都是网络层规定的东西。我们熟悉的IP地址就是在这一层上工作的。大家可以把这一层协议理解成普通邮递业务中规定收件人发件人地址的协议。大家都认同以某种格式在信封上写明收件人和发件人的地址,邮递员也利用这个普遍认同的格式,去确定这封信到底应该怎么运送。(比如先送去收件人所在的省邮局,再由省邮局根据地市信息分发到下面的邮局,再由下属邮局的邮递员根据街道信息往下分发。这个信息下分投递的方式也就是常常说的“路由”。)
找到了要联系的对象,通信渠道也就畅通了。上面的传输层就规定具体的通信方式。双方在通信过程中是否要一直保持渠道畅通直到一方结束通信?通信前双方要如何进行握手(确认过眼神,你就是收件人?)还是说,要进行一种佛系的通信,不去建立所谓连接,接收方也不去反馈确认信息有没有收到(接收发送,一切随缘?)这有没有握手建立通信渠道,有没有接收确认信息的区别,就是TCP协议和UDP协议的区别。(在普通的邮递过程里,基本上对应平邮和挂号信的区别。)后者可信度较低,收发成本也低。但随着网络传输能力的增强,人们大多数情况下不再需要刻意控制手法成本,于是稳定可信度高的TCP协议就更受人青睐。
最后是应用层。这个最好理解。眼神也确认过了,手也握过了,但是大家具体要干什么(一脸严肃)?这要回到当初是基于什么目的来进行通信的。比如浏览器和网页服务器建立了通信,是为了获取网页的信息(文字、图片、链接等等),而邮件收发工具跟smtp服务器建立了通信,是为了发送电子邮件。请求者用什么格式的文字描述自己的请求内容,服务器又以什么格式进行反馈,这就是应用层的工作了。比如服务器看懂了对方的请求并作出了正确的响应,会发送一个200的代码,表示一切正常。当然我们平时上网时看不到这个代码,因为既然一切正常,浏览器只把得到的信息显示给用户就好了。不过一旦出现了问题,比如服务器找不到请求的资源,大家就会看到这个错误代码了,那就是噩梦般存在的404了。
作为本节结束,我们简单过一遍,当你在浏览器上点击一个链接或者在地址栏输入一个网址的时候会发生什么。首先,在这一瞬间,你其实产生了一个HTTP的请求。一般来说,这会是一个“GET请求”,就是说你想得到某个页面,或者某些网络资源。这个请求里还要指明一些其他内容(后面具体讨论)。请求信息准备好之后,浏览器要选择传输层上那种协议来发这个请求(一般会是TCP协议)。再往下,就是确定寄送方式(网络层),再往下就是决定如何把这些信息加载成电信号或者光信号进行发送了(物理层)。
Requests之类的包,允许用户在应用层上表达自己的请求,而不用管下层发生什么。而我们今天要看的是,这些请求在TCP层上长什么样。为了建立TCP层的通信,我们使用python的TCP工具:socket。
实验1: python socket的TCP通信
socket的使用很简单。两个程序通信,一个要作为主机,开放自己的某个接口允许别的程序连接。这个程序要监听这个接口,直到连接建立;另一个作为从机,要知道主机IP地址以及开放了哪个接口来通信,然后主动连接的这个接口。这样以来,连接就建立了。这个有点类似联机打游戏,要有一方建立游戏,就是开放一个接口等待其他玩家连接;另一方加入别人开的游戏。一旦连接建立起来,双方就可以愉快地玩游戏了。
我们先来看主机那边的代码。
# 主机:开放连接口,等待连接
import socket # 引用socket包
s_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建主机的连接口。括号里参数指明了连接的类型(基于网络的流连接)
s_server.bind(('127.0.0.1', 1212)) # 指定连接的IP地址和端口。
print("主机:等待连接...")
s_server.listen(1) # 侦听这个端口,等待另一方来连接
conn, addr = s_server.accept() # 连接建立,返回连接起来的socket和对方的IP地址。
print("主机:接收到来自{}的连接请求。连接已创建!".format(str(addr)))
从机方面代码更简单,创建一个socket,然后去connect主机的socket就可以了。
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建socket
mysock.connect(('127.0.0.1', 1212)) # 连接到主机IP地址和端口。
# 这行代码会导致上面的listen函数停止等待并进入下一运行。
print("从机:已连接至主机!")
启动程序时注意首先运行主机代码。你会看到屏幕显示"主机:等待连接..."之后就停在了这里。这时打开另一个窗口运行从机代码。这时从机窗口上显示"从机:已连接至主机!",同时主机窗口上也会显示"主机:接收到来自<从机地址>的连接请求。连接已创建!"的信息。这时,一个TCP连接就建立起来了。
为了验证这个连接,可以在让一方给另一方发送一些信息,让另一方接收并显示到屏幕上。比如,这里让从机给主机发送信息,让主机等待接收信息,并把信息显示到屏幕上。完成后,从机再进入信息接收状态,主机返回一个信息,从机接收到之后也显示到它的屏幕上。
为此在主机连接代码后面加上“等待信息-->显示信息-->反馈信息”的代码。
# 接上面,主机连接accept之后,主机代码
data = conn.recv(1024) # 假设接收到的信息不会超过1024字节
print("从机发来信息:")
print(data.decode()) # 使用python3,data是byte字符串,通过decode传唤成python3默认的unicode字符串
# 如果您使用python2,则不需要decode
msg_feedback = "DO NOT REPLY!" # 将要传送给从机的信息
print("我将返回信息:" + str)
conn.send(msg.encode()) # 注意:使用python3时,str为unicode字符串。发出前要编码成Byte字符串。
conn.close() # 结束本次通信
注意上面注释中提到的python3中str和bytes转换的问题。这是由于python3中默认的字符串(str)类型的编码方式为unicode编码(UTF-8),而socket发送和接收要以纯byte的形式进行。因此发送前要先进行encode。同样,接收到之后要进行decode才会变成python3本身的字符串。python2中,默认的字符串编码方式是AscII码,因此不需要做这样的转换。
下面是从机的代码。从机要发送信息,然后接收进入接收状态,直到收到主机的返回信息,然后将返回信息显示到屏幕上。
# 接前面,从机connect之后,从机代码
msg_send = "Hello! Greeting from the Earth!" # 待发送信息
mysock.send(msg_send.encode()) # python3的字符串编码
data = mysock.recv(1024) # 依然假设接收到的信息不会超过1024字节
print("接收到信息:" + data.decode())
print("通信结束!")
这里做了一个简化,假设发送的信息不会超过1024字节,因此只做了一次接收。实际情况下,信息可能很长,就要用一个循环持续接收。这时可以写一个循环来接收数据。
while True:
data = mysock.recv(8)
if len(data) < 1:
break
print(data.decode())
但是这会碰到一个问题:当一条信息的最后一点内容被接收到之后,后面没有信息了,而recv
函数默认会进入等待模式,接收程序就会卡在这里不再继续往下执行。除非对方关闭了连接,这个函数才会结束并返回空字符串。此时循环中if
条件满足,循环跳出而继续往下运行。在一个需要建立持续连接的程序中,这是不会发生的。这时候可以通过设置timeout
属性来让recv超时没收到任何信息时结束等待。对这个有兴趣的读者可以查阅socket的文档,这里不再赘述。后面我们要模拟http通信时,可以在每次http请求结束后断开连接,因此recv
函数的这一特性对我们影响将不会很大。
实验2: 接收浏览器发来的http请求
有了上面的实验,我们就有了一个小工具来探查浏览器发过来的http请求的内容。过程很简单。只需要把实验1中的从机想象成浏览器就可以了。让我们先把主机的代码整合一下:
# 主机接收浏览器的信息,并显示到屏幕上。不做任何回复。
import socket
s_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s_server.bind(('127.0.0.1', 1212))
print("等待浏览器建立连接...")
s_server.listen(1) # 侦听这个端口,等待另一方来连接
conn, addr = s_server.accept()
print("接收到连接请求!")
print("连接建立!\n等待浏览器发送请求信息...")
str_received = "" # 储存接收到的信息
while True: # 进入接收和显示循环
data = conn.recv(1024)
if len(data) < 1:
break
str_received += data.decode() # 存入str_received变量
print("浏览器等得不耐烦了,中断了请求。通信结束!")
print("以下是其发来的请求内容:")
print(str_received)
使用这段代码时,先运行这段代码,屏幕上显示"等待浏览器建立连接..."的字样,如下图。
程序正在等待浏览器发送请求(你能看出这里有个小错误吗?我修改了代码,但是图没有更新过来)这时候,打开浏览器,在网址栏里输入"http://127.0.0.1:1212/hello/some/random/words“按下回车。
在浏览器地址栏输入“http://127.0.0.1:1212/hello/some/random/words”按回车此时接收程序将显示建立连接的信息:
程序接收到了浏览器请求,但进入到了recv函数中。点击浏览器左上角的X号断开连接,可以让程序从recv中返回0从而跳出循环。这时候浏览器正处于接收模式(注意那个“正在读取”的圆圈),显然这时候浏览器已经发送完了请求信息,但是由于没有得到我们程序的回应,所以一直处于等待状态。这是我们手动点击浏览器上的“X”停止请求。这个动作会让浏览器关闭连接,从而使得我们的程序跳出循环,进行信息显示。
程序显示出所有接收到的请求信息并结束运行。从上图中可以看到:
- 由于浏览器没有接收到任何信息,提前结束了请求,其页面显示了“找不到页面”的错误信息。
- 我们的程序将整段请求显示到了屏幕上。
- 请求信息的第一行:
GET /hello/some/random/words HTTP/1.1
指明用GET方法,访问地址(url)为“/hello/some/random/words”的资源。这是我们在浏览器的地址栏里输入的那一段。HTTP服务版本为1.1. - 从第二行开始,是请求的一些具体参数。比如说"Host"是主机端口地址,"Connection: keep-alive"表明在请求的资源返回后浏览器希望服务器不要断开连接(后面可能有后续请求)。"User-Agent"属性中可以看到,发送请求的是一台苹果电脑。并且标明了其上安装的Chrome和Safari浏览器的版本号。"Accept"属性告诉服务器,本浏览器支持html数据、xhtml、xml等数据格式。后面还有一些属性表明浏览器支持的压缩格式、语言编码格式等信息。这些信息放在一起叫做请求的头部(headers)。一个请求包括第一行的请求命令,后面的头部,一般还包括一个主体(body)部分,用来传输完成这个请求所需要的数据。这里因为GET请求只是简单地要获取某个资源,不需要给服务器提供其他数据,所以主体信息为空。(但你依然可以看到最后的两个空行。这是http协议中,头部和主体部分之间必须要存在的两个空行。)
实验3:如何获得一个网页服务器标准的反馈信息?
在得到了请求信息格式之后,大家不妨来考虑,如何用我们的TCP socket程序获得某个网页服务器给的反馈信息呢?(解决了这个问题,你就可以写出属于自己的requests
包了。)
其实,你可能已经猜到了。还是用TCP socket程序,只不过这一次,我们的角色由等待请求的主机,变成了主动进行连接并发送请求的从机。为了方便显示,我们需要找一个内容不多,没有图片等信息的网页。比如著名的python教程"Python for everyone"(作者:Dr. Charles Russell Severance)在其网页上给出了一个很短的纯文本的资源作为学生练习使用。这个文本的连接是“http://data.pr4e.org/romeo.txt”。在进行实验之前,大家可以在浏览器地址栏直接输入这个地址,看一眼这个文本。如下图。
先用浏览器查看一下“http://data.pr4e.org/romeo.txt”资源的内容。一首四行小诗。很简单的一首四行小诗。其服务器也是很友好,不需要请求的头部有太多信息。事实上,一个空的头部都可以,只要把第一行的指令写清楚就可以了。但是为了增强一点教学性,我们还是指明一条Accept
属性吧。现在来看请求的代码。
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
cmd = 'GET http://data.pr4e.org/romeo.txt HTTP/1.0\r\n' # 注意这里的换行。
cmd += 'Accept: text/plain\r\n\r\n' # 这是头部的最后,要用两个换行符。
cmd = cmd.encode() # 用python3的话,别忘了decode
mysock.send(cmd)
while True: # 想一下,这个接收循环会在什么时候结束?
data = mysock.recv(512)
if len(data) < 1:
break
print(data.decode(),end='') # end=''使得print不会自作主张地进行换行。
mysock.close()
注意代码中的换行,统一使用\r\n
进行。另外,上面也提到过,头部分的后面要用两个换行符表示头部分结束。另外,注意这里没有提供Connection: keep-alive
属性,因此服务器在响应请求后会默认断开连接。这也是为什么上面的死循环可以跳出的原因。注意,并不是所有服务器都会在没有keep-alive
属性时默认断开的。每个服务器都有自己的默认设置。
保存运行一下吧!
服务器返回的完整内容,包括第一行的状态信息,头部分和一个空行(两个换行符)之后的主体信息。这里代价看到了响应第一行里的200 OK
,表示响应被正确执行,一切正常(讨厌的404并没有出现)。后面有一些其他的头部信息,跟请求的头部类似,这里不再赘述。头部之后默认两个换行(于是有了一个空行),跟主体部分分开。后面的主体是一首四行小诗的纯文本。
写在最后
我们的小实验到这里就结束啦。希望通过这样的小实验,像我一样刚刚入门网络编程的朋友们能够对http协议有一些初步了解。大家有什么问题和建议,或者有任何东西分享,可以在留言区给我留言!
网友评论