计算机网络基础

计算机网络是独立自主的计算机互联而成的系统的总称,组建计算机网络最主要的目的是实现多台计算机之间的通信和资源共享。今天计算机网络中的设备和计算机网络的用户已经多得不可计数,而计算机网络也可以称得上是一个“复杂巨系统”,对于这样的系统,我们不可能用一两篇文章把它讲清楚,有兴趣的读者可以自行阅读Andrew S.Tanenbaum老师的经典之作《计算机网络》或Kurose和Ross老师合著的《计算机网络:自顶向下方法》来了解计算机网络的相关知识。

计算机网络发展史

  1. 1960s - 美国国防部ARPANET项目问世,奠定了分组交换网络的基础。

arpanet.png

  1. 1980s - 国际标准化组织(ISO)发布OSI/RM,奠定了网络技术标准化的基础。

osimodel.png

  1. 1990s - 英国人蒂姆·伯纳斯-李发明了图形化的浏览器,浏览器的简单易用性使得计算机网络迅速被普及。

在没有浏览器的年代,上网是这样的。
before-browser.jpg
有了浏览器以后,上网是这样的。
after-browser.jpg

TCP/IP模型

实现网络通信的基础是网络通信协议,这些协议通常是由互联网工程任务组 (IETF)制定的。所谓“协议”就是通信计算机双方必须共同遵从的一组约定,例如怎样建立连接、怎样互相识别等,网络协议的三要素是:语法、语义和时序。构成我们今天使用的Internet的基础的是TCP/IP协议族,所谓协议族就是一系列的协议及其构成的通信模型,我们通常也把这套东西称为TCP/IP模型。与国际标准化组织发布的OSI/RM这个七层模型不同,TCP/IP是一个四层模型,也就是说,该模型将我们使用的网络从逻辑上分解为四个层次,自底向上依次是:网络接口层、网络层、传输层和应用层,如下图所示。
TCP-IP-model.png
IP通常被翻译为网际协议,它服务于网络层,主要实现了寻址和路由的功能。接入网络的每一台主机都需要有自己的IP地址,IP地址就是主机在计算机网络上的身份标识。当然由于IPv4地址的匮乏,我们平常在家里、办公室以及其他可以接入网络的公共区域上网时获得的IP地址并不是全球唯一的IP地址,而是一个局域网(LAN)中的内部IP地址,通过网络地址转换(NAT)服务我们也可以实现对网络的访问。计算机网络上有大量的被我们称为“路由器”的网络中继设备,它们会存储转发我们发送到网络上的数据分组,让从源头发出的数据最终能够找到传送到目的地通路,这项功能就是所谓的路由。

TCP全称传输控制协议,它是基于IP提供的寻址和路由服务而建立起来的负责实现端到端可靠传输的协议,之所以将TCP称为可靠的传输协议是因为TCP向调用者承诺了三件事情:

  1. 数据不传丢不传错(利用握手、校验和重传机制可以实现)。
  2. 流量控制(通过滑动窗口匹配数据发送者和接收者之间的传输速度)。
  3. 拥塞控制(通过RTT时间以及对滑动窗口的控制缓解网络拥堵)。

    网络应用模式

  4. C/S模式和B/S模式。这里的C指的是Client(客户端),通常是一个需要安装到某个宿主操作系统上的应用程序;而B指的是Browser(浏览器),它几乎是所有图形化操作系统都默认安装了的一个应用软件;通过C或B都可以实现对S(服务器)的访问。关于二者的比较和讨论在网络上有一大堆的文章,在此我们就不再浪费笔墨了。

  5. 去中心化的网络应用模式。不管是B/S还是C/S都需要服务器的存在,服务器就是整个应用模式的中心,而去中心化的网络应用通常没有固定的服务器或者固定的客户端,所有应用的使用者既可以作为资源的提供者也可以作为资源的访问者。

    用Python获取网络数据

    HTTP(超文本传输协议)

    HTTP是超文本传输协议(Hyper-Text Transfer Proctol)的简称,维基百科上对HTTP的解释是:超文本传输协议是一种用于分布式、协作式和超媒体信息系统的应用层协议,它是万维网数据通信的基础,设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法,通过HTTP或者HTTPS(超文本传输安全协议)请求的资源由URI(统一资源标识符)来标识。关于HTTP的更多内容,我们推荐阅读阮一峰老师的《HTTP 协议入门》,简单的说,通过HTTP我们可以获取网络上的(基于字符的)资源,开发中经常会用到的网络API(有的地方也称之为网络数据接口)就是基于HTTP来实现数据传输的。

    JSON格式

    JSONJavaScript Object Notation)是一种轻量级的数据交换语言,该语言以易于让人阅读的文字(纯文本)为基础,用来传输由属性值或者序列性的值组成的数据对象。尽管JSON最初只是Javascript中一种创建对象的字面量语法,但它在当下更是一种独立于语言的数据格式,很多编程语言都支持JSON格式数据的生成和解析,Python内置的json模块也提供了这方面的功能。由于JSON是纯文本,它和XML一样都适用于异构系统之间的数据交换,而相较于XML,JSON显得更加的轻便和优雅。下面是表达同样信息的XML和JSON,而JSON的优势是相当直观的。

XML的例子:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <message>
  3. <from>Alice</from>
  4. <to>Bob</to>
  5. <content>Will you marry me?</content>
  6. </message>

JSON的例子:

  1. {
  2. "from": "Alice",
  3. "to": "Bob",
  4. "content": "Will you marry me?"
  5. }

requests库

requests是一个基于HTTP协议来使用网络的第三库,其官方网站有这样的一句介绍它的话:“Requests是唯一的一个非转基因的Python HTTP库,人类可以安全享用。”简单的说,使用requests库可以非常方便的使用HTTP,避免安全缺陷、冗余代码以及“重复发明轮子”(行业黑话,通常用在软件工程领域表示重新创造一个已有的或是早已被优化过的基本方法)。

通过requests库,我们可以让程序向浏览器一样向Web服务器发起请求,并接收到服务器返回的响应,从响应中我们就可以提取出我们想要的数据。下面通过两个例子来演示如何获取网页代码和网络资源(如:图片),浏览器呈现给我们的网页是用HTML编写的,浏览器相当于是HTML的解释器环境,我们看到的网页中的内容都包含在HTML的标签中。在获取到HTML代码后,就可以从标签的属性或标签体中提取我们需要的内容。

获取搜狐网首页。

  1. import requests
  2. # requests的get函数会返回一个Response对象
  3. resp = requests.get('https://www.sohu.com/')
  4. if resp.status_code == 200:
  5. # 通过Response对象的text属性获取服务器返回的文本内容
  6. print(resp.text)

获取百度Logo并保存到名为baidu.png的本地文件中。首先在百度的首页上,右键点击百度Logo,并通过“复制图片地址”菜单获取图片的URL。

  1. import requests
  2. resp = requests.get('https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png')
  3. with open('image/baidu.png', 'wb') as file:
  4. # 通过Response对象的content属性获取服务器返回的二进制内容
  5. file.write(resp.content)

说明:关于requests库的详细使用方法,大家可以参考官方文档的内容。

访问网络数据接口

国内外的很多网站都提供了开放数据接口,在开发商业项目时,如果有些事情我们自己无法解决,就可以借助这些开放的数据接口来处理。例如要根据用户或企业上传的资料进行实名认证或企业认证,我们就可以调用第三方提供的开放接口来识别用户或企业信息的真伪;例如要获取某个城市的天气信息,我们不可能直接从气象卫星拿到数据然后自己进行运算,只能通过第三方提供的数据接口来得到对应的天气信息。通常,提供有商业价值的数据的接口都是需要支付费用后才能访问的,在访问接口时还需要提供身份标识,便于服务器判断用户是不是付费用户以及进行费用扣除等相关操作。当然,还有些接口是可以免费使用的,但是必须先提供个人或者公司的信息才能访问,例如:深圳市政府数据开放平台蜻蜓FM开放平台等。如果查找自己需要的数据接口,可以访问聚合数据这类型的网站。

目前,我们访问的网络数据接口大多会返回JSON格式的数据,我们在第24课讲解序列化和反序列的时候,提到过JSON格式的字符串跟Python中的字典如何进行转换,并以天行数据为例讲解过网络数据接口访问的相关知识,这里我们就不再进行赘述了。

开发爬虫/蜘蛛程序

有的时候,我们需要的数据并不能通过开放数据接口来获得,但是可能在某些网页上能够获取到,这个时候就需要我们开发爬虫程序通过爬取页面来获得需要的内容。我们可以按照上面提供的方法,使用requests先获取到网页的HTML代码,我们可以将整个代码看成一个长字符串,这样我们就可以使用正则表达式的捕获组从字符串提取我们需要的内容。下面我们通过代码演示如何从豆瓣电影获取排前250名的电影的名称。豆瓣电影Top250页面的结构和对应的代码如下图所示。

  1. import random
  2. import re
  3. import time
  4. import requests
  5. """
  6. 获取豆瓣电影Top250的电影名称
  7. """
  8. for page in range(1, 11):
  9. # 请求https://movie.douban.com/top250时,start参数代表了从哪一部电影开始.
  10. # 如果不设置HTTP请求头中的User-Agent,豆瓣会检测出爬虫程序而阻止我们的请求.
  11. # User-Agent可以设置为浏览器的标识(可以在浏览器的开发者工具查看HTTP请求头找到)
  12. # 由于豆瓣网允许百度爬虫获取它的数据,因此直接将我们的爬虫伪装成百度的爬虫
  13. resp = requests.get(
  14. url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
  15. headers={'User-Agent': 'BaiduSpider'}
  16. )
  17. # 创建正则表达式对象,通过捕获组捕获span标签中的电影标题
  18. # 通过正则表达式获取class属性为title且标签内容不以&符号开头的span标签
  19. pattern = re.compile(r'\<span class="title"\>([^&]*?)\<\/span\>')
  20. results = pattern.findall(resp.text)
  21. for result in results:
  22. print(result)
  23. time.sleep(random.randint(1, 3))

编写爬虫程序比较重要的一点就是让爬虫程序隐匿自己的身份,因为一般的网站都比较反感爬虫。隐匿身份除了像上面的代码中修改User-Agent之外,还可以使用商业IP代理(如:蘑菇代理芝麻代理等),让被爬取的网站无法得知爬虫程序的真实IP地址,也就无法通过IP地址对爬虫程序进行封禁。当然,爬虫本身也是一个处于灰色地带的东西,没有谁说它是违法的,但也没有谁说它是合法的,本着法不禁止即为许可的精神,我们可以根据自己工作的需要去编写爬虫程序,但是如果被爬取的网站能够举证你的爬虫程序有破坏动产的行为,那么在打官司的时候,基本上是要败诉的,这一点需要注意。

用Python解析HTML页面

如果我们获取到一个或多个页面,需要从页面中提取出指定的信息,首先得掌握解析HTML页面的技术。上面的练习我们把整个HTML页面当成一个字符串,使用正则表达式的捕获组提取出了需要的内容。但是,写出一个正确的正则表达式经常也是一件让人头疼的事情。为此,我们可以先了解HTML页面的结构,在此基础上就可以掌握其他的解析HTML页面的方法。

HTML页面的结构

我们在浏览器中打开任意一个网站,然后通过鼠标右键菜单,选择“显示网页源代码”菜单项,就可以看到网页对应的HTML代码。
image.png
代码的第1行是文档类型声明,第2行的<html>标签是整个页面根标签的开始标签,最后一行是根标签的结束标签</html><html>标签下面有两个子标签<head><body>,放在<body>标签下的内容会显示在浏览器窗口中,这部分内容是网页的主体;放在<head>标签下的内容不会显示在浏览器窗口中,但是却包含了页面重要的元信息,通常称之为网页的头部。HTML页面大致的代码结构如下所示。

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <!-- 页面的元信息,如字符编码、标题、关键字、媒体查询等 -->
  5. </head>
  6. <body>
  7. <!-- 页面的主体,显示在浏览器窗口中的内容 -->
  8. </body>
  9. </html>

标签、层叠样式表(CSS)、JavaScript是构成HTML页面的三要素,其中标签用来承载页面要显示的内容,CSS负责对页面的渲染,而JavaScript用来控制页面的交互式行为。对HTML页面的解析可以使用一种名为XPath的语法,根据HTML标签的层次结构提取标签中的内容或标签属性;除此之外,也可以使用CSS选择器来定位页面元素,如果不清楚什么是CSS选择器,可以移步到我的《Web前端概述》一文进行了解。

XPath解析

XPath是在XML(eXtensible Markup Language)文档中查找信息的一种语法,XML跟HTML类似也是一种用标签承载数据的标签语言,不同之处在于XML的标签是可扩展的,可以自定义的,而且XML对语法有更严格的要求。XPath使用路径表达式来选取XML文档中的节点或者节点集,这里所说的节点包括元素、属性、文本、命名空间、处理指令、注释、根节点等。下面我们通过一个例子来说明如何使用XPath对页面进行解析。

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <bookstore>
  3. <book>
  4. <title lang="eng">Harry Potter</title>
  5. <price>29.99</price>
  6. </book>
  7. <book>
  8. <title lang="eng">Learning XML</title>
  9. <price>39.95</price>
  10. </book>
  11. </bookstore>

对于上面的XML文件,我们可以用如下所示的XPath语法获取文档中的节点。
day14 网络编程入门 - 图7
XPath还支持通配符用法,如下所示。
day14 网络编程入门 - 图8
如果要选取多个节点,可以使用如下所示的方法。
day14 网络编程入门 - 图9

说明:上面的例子来自于“菜鸟教程”网站上的XPath教程,有兴趣的读者可以自行阅读原文。

当然,如果不理解或不熟悉XPath语法,可以在浏览器的开发者工具中按照如下所示的方法查看元素的XPath语法,下图是在Chrome浏览器的开发者工具中查看豆瓣网电影详情信息中影片标题的XPath语法。
image.png
实现XPath解析需要三方库lxml 的支持,可以使用下面的命令安装lxml

  1. pip install lxml

下面我们用XPath解析方式改写之前获取豆瓣电影Top250的代码,如下所示。

  1. import random
  2. import time
  3. from lxml import etree
  4. import requests
  5. for page in range(1, 11):
  6. resp = requests.get(
  7. url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
  8. headers={
  9. 'User-Agent': 'BaiduSpider',
  10. }
  11. )
  12. tree = etree.HTML(resp.text)
  13. # 通过XPath语法从页面中提取需要的数据
  14. spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]')
  15. for span in spans:
  16. print(span.text)
  17. time.sleep(random.randint(1, 3))

CSS选择器解析

对于熟悉CSS选择器和JavaScript的开发者来说,通过CSS选择器获取页面元素可能是更为简单的选择,因为浏览器中运行的JavaScript本身就可以document对象的querySelector()querySelectorAll()方法基于CSS选择器获取页面元素。在Python中,我们可以利用三方库bs4(BeautifulSoup)或pyquery来做同样的事情。BeautifulSoup可以用来解析HTML和XML文档,修复含有未闭合标签等错误的文档,通过为待解析的页面在内存中创建一棵树结构,实现对从页面中提取数据操作的封装。可以用下面的命令来安装BeautifulSoup。

  1. pip install beautifulsoup4

下面是使用bs4改写的获取豆瓣电影Top250电影名称的代码。

  1. import bs4 as bs4
  2. import random
  3. import time
  4. import requests
  5. """
  6. 获取豆瓣电影Top250的电影名称-用bs4解析
  7. """
  8. for page in range(1, 11):
  9. resp = requests.get(
  10. url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
  11. headers={
  12. 'User-Agent': 'BaiduSpider',
  13. }
  14. )
  15. soup = bs4.BeautifulSoup(resp.text, 'lxml')
  16. spans = soup.select('div.info > div.hd > a > span:nth-child(1)')
  17. for span in spans:
  18. print(span.text)
  19. time.sleep(random.randint(1, 3))

关于BeautifulSoup更多的知识,可以参考它的官方网站

下面我们对三种解析方式做一个简单比较。
day14 网络编程入门 - 图11

基于传输层协议的套接字编程

套接字这个词对很多不了解网络编程的人来说显得非常晦涩和陌生,其实说得通俗点,套接字就是一套用C语言写成的应用程序开发库,主要用于实现进程间通信和网络编程,在网络应用开发中被广泛使用。在Python中也可以基于套接字来使用传输层提供的传输服务,并基于此开发自己的网络应用。实际开发中使用的套接字可以分为三类:流套接字(TCP套接字)、数据报套接字和原始套接字。

TCP套接字

所谓TCP套接字就是使用TCP协议提供的传输服务来实现网络通信的编程接口。在Python中可以通过创建socket对象并指定type属性为SOCK_STREAM来使用TCP套接字。由于一台主机可能拥有多个IP地址,而且很有可能会配置多个不同的服务,所以作为服务器端的程序,需要在创建套接字对象后将其绑定到指定的IP地址和端口上。这里的端口并不是物理设备而是对IP地址的扩展,用于区分不同的服务,例如我们通常将HTTP服务跟80端口绑定,而MySQL数据库服务默认绑定在3306端口,这样当服务器收到用户请求时就可以根据端口号来确定到底用户请求的是HTTP服务器还是数据库服务器提供的服务。端口的取值范围是0~65535,而1024以下的端口我们通常称之为“著名端口”(留给像FTP、HTTP、SMTP等“著名服务”使用的端口,有的地方也称之为“周知端口”),自定义的服务通常不使用这些端口,除非自定义的是HTTP或FTP这样的著名服务。

下面的代码实现了一个提供时间日期的服务器。

  1. from datetime import datetime
  2. from socket import socket, AF_INET, SOCK_STREAM
  3. def main():
  4. # 1.创建套接字对象指定使用哪种传输服务
  5. # family=AF_INET - IPv4地址
  6. # family=AF_INET6 - IPv6地址
  7. # type=SOCK_STREAM - TCP套接字
  8. # type=SOCK_DGRAM - UDP套接字
  9. # type=SOCK_RAW - 原始套接字
  10. server = socket(family=AF_INET, type=SOCK_STREAM)
  11. # 2.绑定IP地址和端口号
  12. # 同一时间在同一个端口上只能绑定一个服务器否则报错
  13. server.bind(('127.0.0.1', 6789))
  14. # 3.开启监听 - 监听客户端连接到服务器
  15. # 参数512可以理解为连接队列的大小
  16. server.listen(512)
  17. print('服务器启动开始监听...')
  18. while True:
  19. # 4.通过循环接收客户端的请求并做出相应处理
  20. # accept方法是一个阻塞方法如果没有客户端连接到服务器代码不会向下执行
  21. # accept方法返回一个元组其中的第一个元素是客户端对象
  22. # 第二个元素连接到服务器的客户端的地址(由IP和端口两部分组成)
  23. client, address = server.accept()
  24. print(str(address) + '连接到了服务器。')
  25. # 5.发送数据
  26. client.send(str(datetime.now()).encode('utf-8'))
  27. # 6.断开连接
  28. client.close()
  29. if __name__ == '__main__':
  30. main()

运行服务器程序后我们可以通过Windows系统的telnet来访问该服务器,结果如下图所示。

  1. >telnet 127.0.0.1 6789
  2. 2021-07-27 16:47:09.128587
  3. 遗失对主机的连接。

当然我们也可以通过Python的程序来实现TCP客户端的功能,相较于实现服务器程序,实现客户端程序就简单多了,代码如下所示。

  1. from socket import socket
  2. def main():
  3. # 1.创建套接字对象默认使用IPv4和TCP协议
  4. client = socket()
  5. # 2.连接到服务器(需要指定IP地址和端口)
  6. client.connect(('192.168.1.2', 6789))
  7. # 3.从服务器接收数据
  8. print(client.recv(1024).decode('utf-8'))
  9. client.close()
  10. if __name__ == '__main__':
  11. main()

需要注意的是,上面的服务器并没有使用多线程或者异步I/O的处理方式,这也就意味着当服务器与一个客户端处于通信状态时,其他的客户端只能排队等待。很显然,这样的服务器并不能满足我们的需求,我们需要的服务器是能够同时接纳和处理多个用户请求的。下面我们来设计一个使用多线程技术处理多个用户请求的服务器,该服务器会向连接到服务器的客户端发送一张图片。

服务器端代码

  1. from socket import socket, SOCK_STREAM, AF_INET
  2. from base64 import b64encode
  3. from json import dumps
  4. from threading import Thread
  5. def main():
  6. # 自定义线程类
  7. class FileTransferHandler(Thread):
  8. def __init__(self, cclient):
  9. super().__init__()
  10. self.cclient = cclient
  11. def run(self):
  12. my_dict = {}
  13. my_dict['filename'] = 'guido.jpg'
  14. # JSON是纯文本不能携带二进制数据
  15. # 所以图片的二进制数据要处理成base64编码
  16. my_dict['filedata'] = data
  17. # 通过dumps函数将字典处理成JSON字符串
  18. json_str = dumps(my_dict)
  19. # 发送JSON字符串
  20. self.cclient.send(json_str.encode('utf-8'))
  21. self.cclient.close()
  22. # 1.创建套接字对象并指定使用哪种传输服务
  23. server = socket()
  24. # 2.绑定IP地址和端口(区分不同的服务)
  25. server.bind(('192.168.1.2', 5566))
  26. # 3.开启监听 - 监听客户端连接到服务器
  27. server.listen(512)
  28. print('服务器启动开始监听...')
  29. with open('image/guido.png', 'rb') as f:
  30. # 将二进制数据处理成base64再解码成字符串
  31. data = b64encode(f.read()).decode('utf-8')
  32. while True:
  33. client, addr = server.accept()
  34. # 启动一个线程来处理客户端的请求
  35. FileTransferHandler(client).start()
  36. if __name__ == '__main__':
  37. main()

客户端代码:

  1. from socket import socket
  2. from json import loads
  3. from base64 import b64decode
  4. def main():
  5. client = socket()
  6. client.connect(('192.168.1.2', 5566))
  7. # 定义一个保存二进制数据的对象
  8. in_data = bytes()
  9. # 由于不知道服务器发送的数据有多大每次接收1024字节
  10. data = client.recv(1024)
  11. while data:
  12. # 将收到的数据拼接起来
  13. in_data += data
  14. data = client.recv(1024)
  15. # 将收到的二进制数据解码成JSON字符串并转换成字典
  16. # loads函数的作用就是将JSON字符串转成字典对象
  17. my_dict = loads(in_data.decode('utf-8'))
  18. filename = my_dict['filename']
  19. filedata = my_dict['filedata'].encode('utf-8')
  20. with open('image/1/' + filename, 'wb') as f:
  21. # 将base64格式的数据解码成二进制数据并写入文件
  22. f.write(b64decode(filedata))
  23. print('图片已保存.')
  24. if __name__ == '__main__':
  25. main()

UDP套接字

传输层除了有可靠的传输协议TCP之外,还有一种非常轻便的传输协议叫做用户数据报协议,简称UDP。TCP和UDP都是提供端到端传输服务的协议,二者的差别就如同打电话和发短信的区别,后者不对传输的可靠性和可达性做出任何承诺从而避免了TCP中握手和重传的开销,所以在强调性能和而不是数据完整性的场景中(例如传输网络音视频数据),UDP可能是更好的选择。可能大家会注意到一个现象,就是在观看网络视频时,有时会出现卡顿,有时会出现花屏,这无非就是部分数据传丢或传错造成的。在Python中也可以使用UDP套接字来创建网络应用,对此我们不进行赘述,有兴趣的读者可以自行研究。