疯狂Python讲义学习笔记(含习题)之网络编程

urllib模块是Python访问网络资源最常用的工具,不仅可以用于访问各种网络资源,也可以用于向Web服务器发送GET、POST、DELETE、PUT等各种请求,同时能有效地管理cookie等。

Python可以通过在服务器端与客户端间建立socket连接后,通过socket的send()、recv()方法来发送和接受数据。

同时Python也提供了UDP网络通信支持,UDP协议是无连接的,因此基于UDP协议的socket在发送数据时要使用sendto()方法将数据报发送到指定地址。

Python还可以利用smtplib、poplib来发送和接收邮件。

一、网络编程基础

(一)网络基础知识

计算机网络的主要功能:

● 资源共享

● 信息传输与集中处理

● 均衡负载与分布处理

● 综合信息服务

在计算机网络中实现通信必须有一些约定,这些约定被称为通信协议。通信协议负责对传输速率、传输代码、代码结构、传输控制步骤、出错控制等制定处理标准。

通信协议的组成:

① 语义部分,用于决定双方对话的类型。

② 语法部分,用于决定双方对话的格式。

③ 变换规则,用于决定双方的应答关系。

OSI模型:

1. 来源:OSI(Open System Interconnect),即开放式系统互联。 一般都叫OSI参考模型,是ISO(国际标准化组织)组织在1985年研究的网络互连模型。ISO为了更好的使网络应用更为普及,推出了OSI参考模型。其含义就是推荐所有公司使用这个规范来控制网络。这样所有公司都有相同的规范,就能互联了。

2. 划分:OSI定义了网络互连的七层框架(物理层、数据链路层、网络层、传输层、会话层、表示层、应用层),即ISO开放互连系统参考模型。如下图。

 

3. 各层定义

层名称 定义
应用层 OSI参考模型中最靠近用户的一层,为计算机用户提供应用接口,也为用户直接提供各种网络服务。我们常见应用层的网络服务协议有:HTTP,HTTPS,FTP,POP3、SMTP等。
表示层 表示层提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能被另一个系统的应用层识别。如果必要,该层可提供一种标准表示形式,用于将计算机内部的多种数据格式转换成通信中采用的标准表示形式。数据压缩和加密也是表示层可提供的转换功能之一。
会话层 会话层就是负责建立、管理和终止表示层实体之间的通信会话。该层的通信由不同设备中的应用程序之间的服务请求和响应组成。
传输层 传输层建立了主机端到端的链接,传输层的作用是为上层协议提供端到端的可靠和透明的数据传输服务,包括处理差错控制和流量控制等问题。该层向高层屏蔽了下层数据通信的细节,使高层用户看到的只是在两个传输实体间的一条主机到主机的、可由用户控制和设定的、可靠的数据通路。我们通常说的,TCP UDP就是在这一层。端口号既是这里的“端”。
网络层 本层通过IP寻址来建立两个节点之间的连接,为源端的运输层送来的分组,选择合适的路由和交换节点,正确无误地按照地址传送给目的端的运输层。就是通常说的IP层。这一层就是我们经常说的IP协议层。IP协议是Internet的基础。
数据链路层 将比特组合成字节,再将字节组合成帧,使用链路层地址 (以太网使用MAC地址)来访问介质,并进行差错检测。数据链路层又分为2个子层:逻辑链路控制子层(LLC)和媒体访问控制子层(MAC)。
MAC子层处理CSMA/CD算法、数据出错校验、成帧等;LLC子层定义了一些字段使上次协议能共享数据链路层。 在实际使用中,LLC子层并非必需的。
物理层 实际最终信号的传输是通过物理层实现的。通过物理介质传输比特流。规定了电平、速度和电缆针脚。常用设备有(各种物理设备)集线器、中继器、调制解调器、网线、双绞线、同轴电缆。这些都是物理层的传输介质。

4. 通信特点:对等通信,为了使数据分组从源传送到目的地,源端OSI模型的每一层都必须与目的端的对等层进行通信,这种通信方式称为对等层通信。在每一层通信过程中,使用本层自己协议进行通信。

TCP/IP协议模型:

TCP/IP五层协议和OSI的七层协议对应关系如下:

 

OSI七层模型详解:

 

(二)IP地址和端口号

IP 地址用于唯一标识网络中的一个通信实体,这个通信实体既可以是一个主机,也可以是一台打印机,或者是路由器的某一个端口。而在基于IP协议的网络中传输的数据包,都必须使用IP地址来进行标识。

IP 地址是数字型的,它是一个32 位( 32bit )整数。但为了便于记忆,通常把它分成4 个8 位的二进制数,每8 位之间用圆点隔开,每个8 位整数都可以转换成一个0~255 的十进制整数。

NIC (Internet Network Information Center )统一负责全球Internet IP 地址的规划和管理,而InterNIC 、APNIC 、RIPE 三大网络信息中心则具体负责美国及其他地区的IP 地址分配。其中APNIC 负责亚太地区的IP 地址管理,我国申请IP 地址也要通过APNIC, APNIC 的总部设在日本东京大学。

IP 地址被分成A、B 、C 、D 、E 五类, 每个类别的网络标识和主机标识各有规则。

● A类:10.0.0.0~10.255.255.255

● B类:172.16.0.0~172.31.255.255

● C类:192.168.0.0~192.168.255.255

IP 地址用于唯一标识网络上的一个通信实体,但一个通信实体可以有多个通信程序同时提供网络服务,此时还需要使用端口。

端口是一个16 位的整数,用于表示将数据交给哪个通信程序处理,端口号可以为0~65535。

端口分类:

● 公认端口(Well Known Port):端口号为0~1023,紧密地绑定(Binding)一些特定的服务。

● 注册端口(Registered Port):端口号为1024~49151,松散地绑定一些服务。应用程序通常应该使用这个范围。

● 动态和/或私有端口(Dynamic and/or Private Port):端口号为49152~65535,这些端口是应用程序使用的动态端口,应用程序一般不会主动使用这些端口。

当一个程序需要发送数据时,需要指定目的地的IP 地址和端口号,只有指定了正确的IP 地址和端口号,计算机网络才可以将数据发送给该IP 地址和端口号所对应的程序。

二、Python的基本网络支持

(一)Python的网络模块概述

网络分层模型中各层对应的网络协议如图:

 

Python标准库中的网络相关模块:

模块 描述
socket 基于传输层TCP、UDP协议进行网络编程的模块
asyncore socket模块的异步版, 支持基于传输层协议的异步通信
asynchat asyncore的增强版
cgi 必本的CGI (Common Gateway Interface, 早期开发动态网站的技术)支持
email E-mail和MIME 消息处理模块
ftplib 支持FTP 协议的客户端模块
httplib、http.client 支持HTTP 协议以及HTTP 客户揣的模块
imaplib 支持IMAP4 协议的客户端模块
mailbox 操作不同格式邮箱的模块
mailcap 支持Mailcap 文件处理的模块
nntplib 支持NTTP 协议的客户端模块
smtplib 支持SMTP 协议(发送邮件)的客户端模块
poplib 支持POP3 协议的客户端模块
telnetlib 支持TELNET协议的客户端模块
urllib及其子模块 支持URL 处理的模块
xmlrpc、xmlrpc.server、xmlrpc.client 支持XML-RPC 协议的服务器端和客户端模块

(二)使用urllib.parse子模块

URL ( Uniform Resource Locator)对象代表统一资源定位器,它是指向互联网“资源”的指针。

URL 可以由协议名、主机、端口和资源路径组成

urllib子模块:

● urllib.request:这是最核心的子模块,包含了打开和读取URL的各种函数。

● urllib.error:包含由urllib.request子模块所引发的各类异常。

● urllib.parse:用于解析URL。

● urllib.robotparser:用于解析robots.txt文件。

urllib.parse常用解析URL地址及查询字符串函数:

● urllib.parse.urlparse(urlstring, scheme='', allow_fragments=True): 该函数用于解析URL 字符串。程序返回一个ParseResult 对象,可以获取解析出来的数据。

● urllib.parse.urlunparse(parts): 该函数是上一个函数的反向操作, 用于将解析结果反向拼接成URL 地址。

● urllib.parse.parse_qs(qs, keep_ blank_ values=False, strict_parsing=False,encoding=’utf-8’,errors='replace'):该函数用于解析查询字符串( application/x-www-form -urlencoded 类型的数据),并以dict形式返回解析结果。

● urllib.parse.parse_qsl(qs, keep_ blank_ values=False, strict_parsing=False,encoding=’utf-8’,errors='replace'):该函数用于解析查询字符串( application/x-www-form -urlencoded 类型的数据),并以列表形式返回解析结果。

● urllib.parse.urlencode(query, doseq=False, safe='', encoding=None, errors=None, quote_via=quote_plus):将字典形式或列表形式的请求参数恢复成请求字符串。该函数相当于parse_qs()、parse qsl()的逆函数。

● urllib.parse.urljoin(base, url, allow_ fragments=True): 该函数用于将一个base URL 和另一个资源URL 连接成代表绝对地址的URL 。

from urllib.parse import urlparse
​
# 解析URL字符串
result = urlparse(
    'http://www.ib-top.com:8080/index.php;yeeku?name=parse_test#parse')
print(result)
# 通过属性名和索引来获取URL的各部分
print('scheme:', result.scheme, result[0])
print('主机和端口:', result.netloc, result[1])
print('主机:', result.hostname)
print('端口:', result.port)
print('资源路径:', result.path, result[2])
print('参数:', result.params, result[3])
print('查询字符串:', result.query, result[4])
print('fragment:', result.fragment, result[5])
print(result.geturl())
运行结果:

ParseResult(scheme='http', netloc='www.ib-top.com:8080', path='/index.php', params='yeeku', query='name=parse_test', fragment='parse')
scheme: http http
主机和端口: www.ib-top.com:8080 www.ib-top.com:8080
主机: www.ib-top.com
端口: 8080
资源路径: /index.php /index.php
参数: yeeku yeeku
查询字符串: name=parse_test name=parse_test
fragment: parse parse
http://www.ib-top.com:8080/index.php;yeeku?name=parse_test#parse

ParseResult各属性与元组索引的对应关系

属性名 元组索引 返回值 默认值
scheme 0 返回URL的scheme scheme参数
netloc 1 网络位置部分(主机名+端口) 空字符串
path 2 资源路径 空字符串
params 3 资源路径的附加参数 空字符串
query 4 查询字符串 空字符串
fragment 5 Fragment标识符 空字符串
username   用户名 None
password   密码 None
hostname   主机名 None
port   端口 None

如果被解析的URL以双斜线(//)开头,那么urlparse() 函数可以识别出主机, 只是缺少scheme部分。但如果被解析的URL 既没有scheme ,也没有以双斜线(//)开头,那么urlparse()函数将会把这些URL 都当成资源路径。

urlunparse()函数,则可以把一个ParseResult 对象或元组恢复成URL字符串。

result = urlunparse(('http', 'www.ib-top.com:8080', 'index.php', 'yeeku',
                    'name=parse_test', 'parse'))
print('URL为:', result)

运行结果:

URL为: http://www.ib-top.com:8080/index.php;yeeku?name=parse_test#parse
from urllib.parse import urlparse
​
# 解析以//开头的URL
result = urlparse('//www.ib-top.com:8080/index.php')
print('scheme:', result.scheme, result[0])
print('主机和端口:', result.netloc, result[1])
print('资源路径:', result.path, result[2])
print('------------------------')
# 解析没有scheme,也没有以双斜线(//)开头的URL
# 从开头部分开始就会被当成资源路径
result = urlparse('wwww.ib-top.com/index.php')
print('scheme:', result.scheme, result[0])
print('主机和端口:', result.netloc, result[1])
print('资源路径:', result.path, result[2])
​

运行结果:

scheme:
主机和端口: www.ib-top.com:8080 www.ib-top.com:8080
资源路径: /index.php /index.php
------------------------
scheme:
主机和端口:
资源路径: wwww.ib-top.com/index.php wwww.ib-top.com/index.php

parse_qs()和parse_qsl()两个函数都用于解析查询字符串,只不过返回值不同而已——parse_qsl()函数的返回值是list。

urljoin()函数将两个URL拼接在一起,返回代表绝对地址的URL。

该函数接收2个参数base和url,形如:urljoin(base, url),共有三种可能的情况:

● 被拼接的URL只是一个相对路径path(不以斜线开头),则url将会被拼接到base之后,如果base本身包含path部分,则用被拼接的URL替换base所包含的path部分。

● 被拼接的URL是一个根路径path(以单斜线开头),那么该URL将会被拼接到base的域名之后。

● 被拼接的URL是一个绝对路径path(以双斜线开头),那么该URL将会拼接到base的scheme之后。

from urllib.parse import urljoin
​
# 被拼接的URL不以斜线开头
result = urljoin('http://www.ib-top.com/users/login.html', 'help.html')
print(result)  # http://www.ib-top.com/users/help.html
result = urljoin('http://www.ib-top.com/users/login.html', 'book/list.html')
print(result)  # http://www.ib-top.com/users/book/list.html
# 被拼接的URL以斜线(代表根路径path)开头
result = urljoin('http://www.ib-top.com/users/login.html', '/help.html')
print(result)  # http://www.ib-top.com/help.html
# 被拼接的URL以双斜线(代表绝对路径path)开头
result = urljoin('http://www.ib-top.com/users/login.html', '//help.html')
print(result)  # http://help.html

(三)使用urllib.request读取资源

urllib.request.urlopen(url, data=None)方法用于打开url指定的资源,并从中读取数据。如果url是一个http地址,那么该方法返回一个http.client.HTTPResponse对象。

from urllib.request import urlopen
​
# 打开URL对应的资源
result = urlopen('http://www.ib-top.com')
# 按字节读取数据
data = result.read(326)
# 将字节数据恢复成字符串
print(data.decode('utf-8'))
​
# 用context manager来管理打开的URL资源
with urlopen('http://www.ib-top.com') as f:
    # 按字节读取数据
    data = f.read(326)
    # 将字节数据恢复成字符串
    print(data.decode('utf-8'))
在使用urlopen时,可以通过data属性向被请求的URL发送数据。



from urllib.request import urlopen
​
# 向http://localhost/test.php发送请求数据
with urlopen(url='http://localhost/test.php',
             data='测试数据'.encode('utf-8')) as f:
    # 读取服务器的全部响应数据
    print(f.read().decode('utf-8'))

如果使用urlopen()函数向服务求页面发送GET请求参数,则无需使用data属性,直接把请求参数附加在URL之后即可。

from urllib.request import urlopen
import urllib.parse
​
params = urllib.parse.urlencode({'name': 'ib-top', 'password': '123456'})
# 将请求参数添加到URL后面
url = 'http://localhost/test.php?{0}'.format(params)
with urlopen(url=url) as f:
    # 读取服务器的全部响应数据
    print(f.read().decode('utf-8'))
​
如果想通过urlopen()函数发送POST请求参数,则同样可以通过data属性来实现。



from urllib.request import urlopen
import urllib.parse
​
params = urllib.parse.urlencode({'name': 'ib-top', 'password': '123456'}).encode('utf-8')
# 将请求参数添加到URL后面
url = 'http://localhost/test.php'
with urlopen(url=url, data=params) as f:
    # 读取服务器的全部响应数据
    print(f.read().decode('utf-8'))
​

使用data属性不仅可以发送POST请求,还可以发送PUT、PATCH、DELETE等请求,只需使用urllib.request.Request来构建请求参数:

urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)

from urllib.request import Request, urlopen
​
params = 'put请求数据'.encode('utf-8')
# 创建Request对象,设置使用PUT请求方式
req = Request(url='http://localhost/test.php', data=params, method='PUT')
with urlopen(req) as f:
    print(f.status)
    print(f.read().decode('utf-8'))

一个多线程下载工具类:

from urllib.request import *
import threading
​
​
class DownUtil:
    def __init__(self, path, target_file, thread_num):
        # 定义下载资源的路径
        self.path = path
        # 定义需要使用多少个线程下载资源
        self.thread_num = thread_num
        # 指定所下载的文件的保存位置
        self.target_file = target_file
        # 初始化thread数组
        self.threads = []
​
    def download(self):
        # 创建Request对象
        req = Request(url=self.path, method='GET')
        # 添加请求头
        req.add_header('Accept', '*/*')
        req.add_header('Charset', 'UTF-8')
        req.add_header('Connection', 'Keep-Alive')
        # 打开要下载的资源
        f = urlopen(req)
        # 获取要下载的文件大小
        self.file_size = int(dict(f.headers).get('Content-Length', 0))
        f.close()
        print('文件大小为:', self.file_size)
        # 计算每个线程要下载的资源大小
        current_part_size = self.file_size // self.thread_num + 1
        for i in range(self.thread_num):
            # 计算每个线程下载的开始位置
            start_pos = i * current_part_size
            # 每个线程都使用一个wb模式打开的文件进行下载
            t = open(self.target_file, 'wb')
            # 定位该线程的下载位置
            t.seek(start_pos, 0)
            # 创建下载线程
            td = DownThread(self.path, start_pos, current_part_size, t)
            self.threads.append(td)
            # 启动下载线程
            td.start()
​
    # 获取下载完成的百分比
    def get_complete_rate(self):
        # 统计多个线程已经下载的资源总大小
        sum_size = 0
        for i in range(self.thread_num):
            sum_size += self.threads[i].length
        # 返回已经完成的百分比
        return sum_size / self.file_size
​
​
class DownThread(threading.Thread):
    def __init__(self, path, start_pos, current_part_size, current_part):
        super().__init__()
        self.path = path
        # 当前线程的下载位置
        self.start_pos = start_pos
        # 定义当前线程负责下载的文件大小
        self.current_part_size = current_part_size
        # 当前线程需要下载的文件块
        self.current_part = current_part
        # 定义该线程已下载的字节数
        self.length = 0
​
    def run(self):
        # 创建Request对象
        req = Request(url=self.path, method='GET')
        # 添加请求头
        req.add_header('Accept', '*/*')
        req.add_header('Charset', 'UTF-8')
        req.add_header('Connection', 'Keep-Alive')
        # 打开要下载的资源
        f = urlopen(req)
        # 跳过self.start_pos个字节,表明该线程只下载自己负责的那部分内容
        for i in range(self.start_pos):
            f.read(1)
        # 读取网络数据,并写入本地文件中
        while self.length < self.current_part_size:
            data = f.read(1024)
            if data is None or len(data) <= 0:
                break
            self.current_part.write(data)
            # 累积线程下载的资源总大小
            self.length += len(data)
        self.current_part.close()
        f.close()
​

使用方法:

from DownUtil import *
​
du = DownUtil(
    "http://dubapkg.cmcmcdn.com/cs/166def/W.P.S%202019.09%E8%BD%AF%E4%BB%B6.exe",
    'wps.exe', 5)
du.download()
​
​
def show_process():
    print("已完成:{0:0.2f}".format(du.get_complete_rate()))
    global t
    if du.get_complete_rate() < 1:
        # 通过定时器启动0.1s之后执行show_process函数
        t = threading.Timer(0.1, show_process)
        t.start()
​
​
# 通过定时器启动0.1s之后执行show_process函数
t = threading.Timer(0.1, show_process)
t.start()
​

(四)管理cookie

http.cookiejar模块可有效的管理session。

使用urllib.request模块通过cookie来管理session操作步骤:

① 创建http.cookiejar.CookieJar对象或其子类对象。

② 以CookieJar对象为参数,创建urllib.request.HTTPCookieProcessor对象,该对象负责调用CookieJar来管理cookie。

③ 以HTTPCookieProcessor对象为参数,调用urllib.request.build_opener()函数创建OpenerDirector对象。

④ 使用OpenerDirector对象来发送请求,该对象将会通过HTTPCookieProcessor调用CookieJar来管理cookie。

from urllib.request import *
import http.cookiejar, urllib.parse
​
# 以指定文件创建CookieJar对象,该对象可以把cookie信息保存在文件中
cookie_jar = http.cookiejar.MozillaCookieJar('a.txt')
# 创建HTTPCookieProcessor对象
cookie_processor = HTTPCookieProcessor(cookie_jar)
# 创建OpenerDirector对象
opener = build_opener(cookie_processor)
​
# 定义模拟chrome浏览器的User-Agent
user_agent = r'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
# 定义请求头
headers = {'User-Agent': user_agent, 'Connection': 'keep-alive'}
# ----------------下面代码发送登陆的POST请求-------------
# 定义登陆系统的请求参数
params = {'name': 'ib-top', 'password': '123456'}
postdata = urllib.parse.urlencode(params).encode()
# 创建向登陆页面发送POST请求的Request
request = Request('http://localhost:8080/login.php',
                  data=postdata,
                  headers=headers)
# 使用OpenerDirector发送POST请求
response = opener.open(request)
print(response.read().decode('utf-8'))
​
# 将cookie信息写入文件中
# cookie_jar.save(ignore_discard=True, ignore_expires=True)
​
# -----------------下面代码发送访问被保护资源的GET请求---------
# 创建向被保护页面发送GET请求的Request
request = Request('http://localhost:8080/secret.php', headers=headers)
response = opener.open(request)
print(response.read().decode())
​login.php
<?php

$name = $_POST["name"];
$password = $_POST["password"];
if($name == "ib-top" && $password=="123456")
{
    session_start();
    $_SESSION["name"] = $name;
    $_SESSION["password"] = $password;
    echo "登陆成功!";
}
else
{
    echo "用户名或者密码不正确";
}
?>

secret.php

<?php
session_start();
$name=$_SESSION["name"];
if ($name != null && $name=="ib-top")
{
?>
<!DOCTYPE html>
<html>
<head>
    <meta name="author" content="Yeeku.H.Lee(CrazyIt.org)" />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title> 安全资源 </title>
</head>
<body>
    安全资源,只有登录用户<br/>
    且用户名是ib-top才可访问该资源
</body>
</html>
<?php
}
else
{
    echo "您没有被授权访问该页面";
}
?>

将以上写入cookie信息代码的注释取消,则会将session信息持久化到文件中,以后的程序就可以直接读取这个文件来实现cookie信息的读取。

from urllib.request import *
import http.cookiejar, urllib.parse
​
# 以指定文件创建CookieJar对象,该对象将可以把cookie信息保存在文件中
cookie_jar = http.cookiejar.MozillaCookieJar('a.txt')
# 直接加载a.txt中的cookie信息
cookie_jar.load('a.txt', ignore_discard=True, ignore_expires=True)
# 遍历a.txt中保存的cookie信息
for item in cookie_jar:
    print('Name =' + item.name)
    print('Value =' + item.value)
# 创建HTTPCookieProcessor对象
cookie_processor = HTTPCookieProcessor(cookie_jar)
# 创建OpenerDirector对象
opener = build_opener(cookie_processor)
​
# 定义模拟Chrome浏览器的User-Agent
user_agent = r'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
# 定义请求头
headers = {'User-Agent': user_agent, 'Connection': 'keep-alive'}
​
# -----------------下面代码发送访问被保护资源的GET请求---------
# 创建向被保护页面发送GET请求的Request
request = Request('http://localhost:8080/secret.php', headers=headers)
response = opener.open(request)
print(response.read().decode())

三、基于TCP协议的网络编程

TCP/IP通信协议是一种可靠的网络协议,它在通信的两端各建立一个socket,从而形成虚拟的网络链路。

(一)TCP协议基础

IP(Internet Protocol)协议只保证计算机能发送和接收分组数据,它负责将消息从一个主机传送到另一个主机,消息在传送的过程中被分割成一个个小包。TCP协议全称是传输控制协议是一种面向连接的、可靠的、基于字节流的传输层通信协议,由 IETF 的RFC 793定义。TCP 是面向连接的、可靠的流协议。流就是指不间断的数据结构,你可以把它想象成排水管中的水流。

TCP被称作端对端协议,它负责收集IP协议发送的数据包,并将其按照适当的顺序传送,接收端接收到数据包后再将其正确地还原。TCP协议采用重发机制——当一个通信实体发送一个消息给另一个通信实体后,需要接收到另一个通信实体的确认信息,如果没有接收到该确认信息,则会重发信息。

1. TCP连接过程(三次握手)

 

第一次握手

客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。

第二次握手

服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。

第三次握手

当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

这里可能大家会有个疑惑:为什么 TCP 建立连接需要三次握手,而不是两次?这是因为这是为了防止出现失效的连接请求报文段被服务端接收的情况,从而产生错误。

2. TCP断开连接(四次挥手)

 

TCP 是全双工的,在断开连接时两端都需要发送 FIN 和 ACK。

第一次挥手

若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求。

第二次挥手

B 收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明 A 到 B 的连接已经释放,不再接收 A 发的数据了。但是因为 TCP 连接是双向的,所以 B 仍旧可以发送数据给 A。

第三次挥手

B 如果此时还有没发完的数据会继续发送,完毕后会向 A 发送连接释放请求,然后 B 便进入 LAST-ACK 状态。

第四次挥手

A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。

3. TCP特点

面向连接

面向连接,是指发送数据之前必须在两端建立连接。建立连接的方法是“三次握手”,这样能建立可靠的连接。建立连接,是为数据的可靠传输打下了基础。

仅支持单播传输

每条TCP传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播传输方式。

面向字节流

TCP不像UDP一样那样一个个报文独立地传输,而是在不保留报文边界的情况下以字节流方式进行传输。

可靠传输

对于可靠传输,判断丢包,误码靠的是TCP的段编号以及确认号。TCP为了保证报文传输的可靠,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的字节发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据(假设丢失了)将会被重传。

提供拥塞控制

当网络出现拥塞的时候,TCP能够减小向网络注入数据的速率和数量,缓解拥塞

TCP提供全双工通信

TCP允许通信双方的应用程序在任何时候都能发送数据,因为TCP连接的两端都设有缓存,用来临时存放双向通信的数据。当然,TCP可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于MSS)

(二)使用socket创建TCP服务器端

socket构造器:

socket.socket(family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None)

参数详解:

● family用于指定网络类型,支持socket_AF_UNIX(UNIX网络)、socket_AF_INET(基于IPv4协议的网络)和socket_AF_INET6(基于IPv6协议的网络)。

● type用于指定网络Sock类型,支持SOCK_STREAM(默认值,创建基于TCP协议的socket)、SOCK_DGRAM(创建基于UDP协议的socket)和SOCK_RAW(创建原始socket)。

● proto用于指定协议号,如果没有特殊要求,该参数默认为0,并可以忽略。

作为服务器端使用的socket必须被绑定到指定IP地址和端口,并在该IP地址和端口进行监听,接受来自客户端的连接。

常用方法:

方法 描述
accept() 作为服务器端使用的socket调用该方法接受来自客户端的连接
bind(address) 作为服务器端使用的socket调用该方法将socket绑定到指定address,该address是一个元组,包含IP地址和端口
close() 关闭连接,回收资源
connect(address) 客户端使用的socket,调用该方法连接远程服务器
connect_ex(address) 连接远程服务器,当程序出错时,该方法不抛出异常,而是返回一个错误标识符
listen([backlog]) 作为服务器端使用的socket调用该方法进行监听
makefile() makefile(mode='r', buffering=None, *, encoding=None, errors=None, newline=None),创建一个和该socket关联的文件对象
recv([bufsize[, flags]) 接收sokcet中的数据,该方法返回bytes对象代表接收到的数据
recvfrom(bufsize[, flags]) 与recv()方法相同,该方法返回值为(bytes, address)元组。
recvmsg(bufsize[, ancbufsize[, flags]]) 该方法不仅接收来自socket的数据,还接收来自socket的辅助数据,因此该方法返回值是一个长度为4的元组——(data, ancdata, msg_flags, address),ancdata代表辅助数据。
recvmsg_into(buffers[, ancbufsize[, flags]]) 类似于recvmsg(),该方法将接收到的数据放入buffers中
recvfrom_into(buffer[, nbytes[, flags]]) 类似于recvfrom(),该方法将接收到的数据放入buffer中
recv_into(buffer[, nbytes[, flags]]) 类似于recv(),该犯法将接收到的数据放入buffer中
send(bytes[, flags]) 向socket发送数据,该socket必须与远程socket建立了连接。该方法通常用于在基于TCP协议的网络中发送数据
sendto(bytes, address) 向socket发送数据,该socket应该没有与远程socket建立连接。该方法通常用于在基于UDP协议的网络中发送数据
sendfile(file, offset=0, count=None) 将整个文件内容发送出去,知道遇到文件的EOF
shutdown(how) 关闭连接。其中how用于设置关闭方式

建立TCP通信服务器端的基本步骤:

① 服务器端先创建一个socket对象

② 服务器端socket将自己绑定到指定IP地址和端口

③ 服务器端socket调用listen()方法监听网络

④ 程序采用循环不断调用socket的accept()方法接收来自客户端的连接

通常推荐使用1024以上的端口作为socket端口

(三)使用socket通信

TCP客户端建立基本步骤:

① 客户端先创建一个socket对象

② 客户端socket调用connect()方法连接远程服务器。

通信基本步骤:

① 发送数据:使用send()方法。sendto()用于UDP协议。

② 接收数据:使用recv_xxx()方法

server.py

# 导入socket模块
import socket
​
# 创建socket对象
s = socket.socket()
# 将socket绑定到本机IP地址和端口
s.bind(('127.0.0.1', 30000))
# 服务器端开始监听来自客户端的连接
s.listen()
while True:
    # 每当接收到客户端socket的请求时,就返回对应的socket和远程地址
    c, addr = s.accept()
    print(c)
    print('连接地址:', addr)
    c.send('您好,您收到了服务器的消息!'.encode('utf-8'))
    # 关闭连接
    c.close()

client.py

# 导入socket模块
import socket
​
# 创建socket对象
c = socket.socket()
# 连接远程服务器
c.connect(('127.0.0.1', 30000))
print('--{0}--'.format(c.recv(1024).decode('utf-8')))
c.close()

(三)加入多线程

socket的recv()方法在成功读取到数据之前,线程会被阻塞,因此,服务器端应该为每个socket都单独启动一个线程,每个线程负责与一个客户端进行通信。客户端也同样需要单独启动一个线程专门负责读取服务器端数据。

假设现在要实现一个基于命令行的C/S聊天室应用,服务器端应该包含多个线程,每个socket对应一个线程,该线程负责从socket中读取数据(从客户端发送过来的数据),并将所读取到的数据向每个socket发送一次(将一个客户端发送过来的数据“广播”给其他客户端),因此需要在服务器端使用list来保存所有的socket。

MyServer.py

import socket
import threading
​
# 定义保存所有socket的列表
socket_list = []
# 创建socket对象
ss = socket.socket()
# 将socket绑定到本机IP地址和端口
ss.bind(('127.0.0.1', 30000))
# 服务器端开始监听来自客户端的连接
ss.listen()
​
​
def read_from_client(s):
    try:
        return s.recv(2048).decode('utf-8')
    # 如果捕获到异常,则表明该socket对应的客户端已经关闭
    except:
        # 删除该socket
        socket_list.remove(s)
​
​
def server_target(s):
    try:
        # 采用循环不断地从socket中读取客户端发送过来的数据
        while True:
            content = read_from_client(s)
            print(content)
            if content is None:
                break
            for client_s in socket_list:
                client_s.send(content.encode('utf-8'))
    except Exception as e:
        print(e.strerror)
​
​
while True:
    # 此行代码会被阻塞,将一直等待别人的连接
    s, addr = ss.accept()
    socket_list.append(s)
    # 每当客户端连接后,都会启动一个线程为该客户端服务
    threading.Thread(target=server_target, args=(s, )).start()
​

MyClient.py

import socket
import threading
​
# 创建socket对象
s = socket.socket()
# 连接远程服务器
s.connect(('127.0.0.1', 30000))
​
​
def read_from_server(s):
    while True:
        print(s.recv(2048).decode('utf-8'))
​
​
# 客户端启动线程不断地读取来自服务器端的数据
threading.Thread(target=read_from_server, args=(s, )).start()
while True:
    line = input('')
    if line is None or line == 'exit':
        break
    # 将用户的键盘输入内容写入socket中
    s.send(line.encode('utf-8'))

以上代码已经粗略的实现了一个c/s结构的聊天室应用。

(五)记录用户信息

之前的聊天室程序虽然实现了消息的发送和接收,但是服务器端从未记录过用户信息,因此并不能识别消息是哪个客户端发送的,进一步完善以上程序,以便实现私聊和让服务器记录用户信息,首先需要解决一下问题:

● 客户端发送的消息必须有特殊的标识——让服务器端可以判断出是公聊信息还是私聊信息。

● 如果是私聊信息,客户端会将该消息的目的用户(私聊对象)发送给服务器端,服务器端要将信息发送给私聊对象。

解决第一个问题可以让客户端在发送不同的消息之前,先对这些信息进行适当处理。比如在内容前后添加一些特殊字符——这些特殊字符被称为协议字符串。如以下代码所示:

MyProtocol.py

# 定义协议字符串的长度
PROTOCOL_LEN = 2
# 协议字符串,在服务器端和客户端的信息前后都应该添加这些特殊字符串
MSG_ROUND = "ξγ"
USER_ROUND = "ⅡΣ"
LOGIN_SUCCESS = "1"
NAME_REP = "-1"
PRIVATE_ROUND = "★【"
SPLIT_SIGN = "※"

因为服务器端和客户端都要使用这些协议字符串,所以在服务器端和客户端都要引入这个文件

解决第二个问题,可以考虑使用一个dict(字典)来保存聊天室所有用户和对应socket之间的映射关系。

MyDict.py

class MyDict(dict):
    # 根据value查找key
    def key_from_value(self, val):
        # 遍历所有key组成的集合
        for key in self.keys():
            # 如果指定key对应的value与被搜索的value相同,则返回对应的key
            if self[key] == val:
                return key
        return None
​
    # 根据value删除key
    def remove_by_value(self, val):
        # 遍历所有key组成的集合
        for key in self.keys():
            # 如果指定key对应的value与被搜索的value相同,则返回对应的key
            if self[key] == val:
                self.pop(key)
                return

现在服务器端的主线程依然只是建立socket来监听客户端socket的连接请求,同时增加一些异常处理代码。

ChatServer.py

import socket
import threading
import MyDict
​
from ServerThread import server_target
SERVER_PORT = 30000
# 使用MyDict来保存每个用户名和对应socket之间的映射关系
clients = MyDict.MyDict()
# 创建socket对象
s = socket.socket()
try:
    # 将socket绑定到本机IP地址和端口
    s.bind(('127.0.0.1', SERVER_PORT))
    # 服务器端开始监听来自客户端的连接
    s.listen()
    # 采用死循环不断地接收来自客户端的请求
    while True:
        # 每当接收到客户端socket的请求时,该方法都返回对应的socket和远程地址
        c, addr = s.accept()
        threading.Thread(target=server_target, args=(c, clients)).start()
# 如果出现异常
except Exception as e:
    print("服务器启动失败,是否端口{0}已被占用?".format(SERVER_PORT))

此时的server_target要更加复杂一些,因为该函数要分别处理公聊、私聊两类聊天信息。还要处理用户名是否重复问题。

ServerThread.py

import MyProtocol
​
​
def server_target(s, clients):
    try:
        while True:
            # 从socket读取数据
            line = s.recv(2048).decode('utf-8')
            print(line)
            # 如果读取到的行以MyProtocol.USER_ROUND开始,并以其结束
            # 则可以确定读取到的是用户登陆的用户名
            if line.startswith(MyProtocol.USER_ROUND) and line.endswith(
                    MyProtocol.USER_ROUND):
                # 得到真实消息
                user_name = line[MyProtocol.
                                 PROTOCOL_LEN:-MyProtocol.PROTOCOL_LEN]
                # 如果用户名重复
                if user_name in clients:
                    print("用户名重复")
                    s.send(MyProtocol.NAME_REP.encode('utf-8'))
                else:
                    print("登陆成功")
                    s.send(MyProtocol.LOGIN_SUCCESS.encode('utf-8'))
                    clients[user_name] = s
            # 如果读取到的行以MyProtocol.PRIVATE_ROUND开始,并以其结束
            # 则可以确定是私聊消息,私聊消息指向特定的socket发送
            elif line.startswith(MyProtocol.PRIVATE_ROUND) and line.endswith(
                    MyProtocol.PRIVATE_ROUND):
                # 得到真实消息
                user_and_msg = line[MyProtocol.
                                    PROTOCOL_LEN:-MyProtocol.PROTOCOL_LEN]
                # 以SPLIT_SIGN分隔字符串,前一半是私聊用户,后一半是聊天信息
                user = user_and_msg.split(MyProtocol.SPLIT_SIGN)[0]
                msg = user_and_msg.split(MyProtocol.SPLIT_SIGN)[1]
                # 获取私聊用户对应的socket,并发送私聊信息
                clients[user].send((clients.key_from_value(s) + "悄悄对你说:" +
                                    msg).encode('utf-8'))
            # 公聊信息要向每个socket发送
            else:
                # 得到真实信息
                msg = line[MyProtocol.PROTOCOL_LEN:-MyProtocol.PROTOCOL_LEN]
                # 遍历clients中的每个socket
                for client_socket in clients.values():
                    client_socket.send((clients.key_from_value(s) + "说:" +
                                        msg).encode('utf-8'))
    # 捕获到异常后,表明该socket对应的客户端出现了问题
    # 所以程序将其对应的socket从dict中删除
    except Exception:
        clients.remove_by_values(s)
        print(len(clients))
        # 关闭网络、I/O资源
        if s is not None:
            s.close()
​

下面来完善客户端代码,增加让用户输入用户名的代码,并且不允许用户名重复。并根据用户的键盘输入内容来判断用户是否想发送私聊信息。

ChatClient.py

import socket
import threading
import os
import MyProtocol
import time
​
SERVER_PORT = 30000
​
​
# 定义一个读取键盘输入内容,并向网络中发送的函数
def read_send(s):
    # 采用死循环不断地读取键盘输入内容
    while True:
        line = input('')
        if line is None or line == 'exit':
            break
        # 如果发哦是哪个的信息中有冒号,并且以//开头,则认为想发送私聊信息
        if ":" in line and line.startswith("//"):
            line = line[2:]
            s.send((MyProtocol.PRIVATE_ROUND + line.split(":")[0] +
                    MyProtocol.PRIVATE_ROUND).encode('utf-8'))
        else:
            s.send((MyProtocol.MSG_ROUND + line +
                    MyProtocol.MSG_ROUND).encode('utf-8'))
​
​
# 创建socket对象
s = socket.socket()
try:
    # 连接服务器
    s.connect(('127.0.0.1', SERVER_PORT))
    tip = ""
    # 采用循环不断地弹出对话框要求输入用户名
    while True:
        user_name = input(tip + '输入用户名:\n')
        # 在用户输入的用户名前后增加协议字符串后发送
        s.send((MyProtocol.USER_ROUND + user_name +
                MyProtocol.USER_ROUND).encode('utf-8'))
        time.sleep(0.2)
        # 读取服务器端响应信息
        result = s.recv(2048).decode('utf-8')
        if result is not None and result != '':
            # 如果用户名重复,则开始下一次循环
            if result == MyProtocol.NAME_REP:
                tip = '用户名重复!请重新输入'
                continue
            # 如果服务器端返回登陆成功信息,则结束循环
            if result == MyProtocol.LOGIN_SUCCESS:
                break
# 如果捕获到异常,关闭网络资源,并退出该程序
except Exception:
    print("网络异常!请重新登陆!")
    s.close()
    os._exit(1)
​
​
def client_target(s):
    try:
        # 不断地从socket中读取数据,并将这些数据打印出来
        while True:
            line = s.recv(2048).decode('utf-8')
            if line is not None:
                print(line)
            ''' 这里仅打印出从服务器端读取到的内容,实际上,此处可以更
                复杂,如果希望客户端能看到聊天室的用户列表,则可以让服务器端每次有用户登陆、用户退出时,都将所有用户列表信息向客户端发送一遍。为了区分服务器端发送的是聊天信息还是用户列表信息,服务器端也应该字啊要发送的信息前后添加协议字符串,客户端则根据协议字符串的不同而进行不同的处理。
            更复杂的情况是:
            如果两端进行游戏,则还可能发送游戏信息。例如两端进行五子棋游戏,则需要发送下棋座标信息等,服务器端同样需要在这些座标信息前后添加协议字符串,然后再发送,客户端可以根据该信息知道对手的下棋座标
            '''
           
    # 使用finally块来关闭该线程对应的socket
    finally:
        s.close()
​
​
# 启动客户端线程
threading.Thread(target=client_target, args=(s, )).start()
read_send(s)
​

 

(六)半关闭的socket

服务器和客户端通通信时,总是以一个bytes对象作为通信的最小数据单位,服务器在处理信息时也是针对每个bytes进行的。在一些协议中,通信的数据单位可能需要多个bytes对象——在这种情况下,就需要解决一个问题:socket如何表示输出数据已经结束?

如果要表示输出数据已经结束,则可以通过关闭socket来实现。但如果彻底关闭了socket,则会导致程序无法再从该socket中读取数据。

socket提供了一个shutdown(how)方法,可以只关闭socket的输入或输出部分,用以表示数据已经发送完成。该方法的how参数接受如下参数值:

● SHUT_RD:关闭socket的输入部分,程序还可以通过该socket输出数据。

● SHUT_WR:关闭socket的输出部分,程序还可以通过该socket读取数据。

● SHUT_RDWR:全关闭。该socket即不能读取数据,也不能写入数据,但并没有彻底清理该socket。

服务器端代码:

import socket
​
#创建socket对象
s = socket.socket()
#将socket绑定到本机IP地址和端口
s.bind(('192.168.31.10', 30000))
#服务器端开始监听来自客户端的连接
s.listen()
# 每当接收到客户端socket的请求时,该方法返回对于应的socket和远程地址
skt, addr = s.accept()
skt.send("服务器的第一行数据".encode('utf-8'))
skt.send("服务器的第二行数据".encode('utf-8'))
# 关闭socket的输出部分,表明输出数据已结束
skt.shutdown(socket.SHUT_WR)
while True:
    # 从socket中读取数据
    line = skt.recv(2048).decode('utf-8')
    if line is None or line == '':
        break
    print(line)
skt.close()
s.close()

客户端代码:

import socket
​
# 创建socket对象
s = socket.socket()
# 连接远程主机
s.connect(('192.168.31.10', 30000))
while True:
    # 从socket读取数据
    line = s.recv(2048).decode('utf-8')
    if line is None or line == '':
        break
    print(line)
s.send("客户端的第一行数据".encode('utf-8'))
s.send("客户端的第二行数据".encode('utf-8'))
s.close()

以上代码中,服务器端代码中虽然关闭了socket的输出部分,但此时该socket并未彻底关闭,程序只是不能再向该socket中写入数据了,但依然可以从该socket中读取数据。

当调用socket的shutdown()方法关闭了输入或输出部分之后,该socket无法再次打开输入或输出部分,因此这种做法通常不适合保持持久通信状态的交互式应用,只适用于一站式的通信协议,如HTTP协议——客户端连接到服务器端,开始发送请求数据,当发送完成后无须再次发送数据,只需要读取服务器端的响应数据即可,当读取响应数据完成后,该socket连接就被完全关闭了。

(七)selectors模块

通过selectors模块允许socket以非阻塞方式进行通信:selectors相当于一个事件注册中心,程序只要将socket的所有事件注册给selectors管理,当selectors检测到socket中的特定事件后,程序就调用相应的监听方法进行处理。

selectors主要支持两种事件:

● selectors.EVENT_READ:当socket有数据可读时触发该事件。当有客户端连接进来时也会触发该事件。

● selectors.EVENT_WRITE:当socket将要写数据时触发该事件。

使用selectors实现非阻塞式编程的步骤:

① 创建selectors对象

② 通过selectors对象为socket的selectors.EVENT_READ或selectors.EVENT_WRITE事件注册监听函数。每当socket有数据需要读写时,系统负责触发所注册的监听函数

③ 在监听函数中处理socket通信

服务器端代码:

import selectors
import socket
​
# 创建默认的selectors对象
sel = selectors.DefaultSelector()
socket_list = []
​
​
# 负责监听“有数据可读”事件的函数
def read(skt, mask):
    try:
        # 读取数据
        data = skt.recv(1024)
        if data:
            # 将所读取的数据采用循环向每个socket发送一次
            for s in socket_list:
                s.send(data)  # Hope it won't block
        else:
            # 如果该socket已被对方关闭,则关闭该socket
            # 并将其从socket_list列表中删除
            print('关闭', skt)
            sel.unregister(skt)
            skt.close()
            socket_list.remove(skt)
    # 如果捕获到异常,则将该socket关闭,并将其从socket_list列表中删除
    except Exception:
        print('关闭', skt)
        sel.unregister(skt)
        socket_list.remove(skt)
​
​
# 负责监听“有客户端连接进来”事件的函数
def accept(sock, mask):
    conn, addr = sock.accept()
    # 使用socket_list保存代表客户端的socket
    socket_list.append(conn)
    conn.setblocking(False)
    # 使用sel为conn的EVENT_READ事件注册read监听函数
    sel.register(conn, selectors.EVENT_READ, read)
​
​
sock = socket.socket()
sock.bind(('192.168.31.10', 30000))
sock.listen()
# 设置该socket是非阻塞的
sock.setblocking(False)
# 使用sel为sock的EVENT_READ事件注册accept监听函数
sel.register(sock, selectors.EVENT_READ, accept)
# 采用死循环不断提取sel事件
while True:
    events = sel.select()
    for key, mask in events:
        # 使用key的data属性获取为该事件注册的监听函数
        callback = key.data
        # 调用监听函数,使用key的fileobj属性获取被监听的socket对象
        callback(key.fileobj, mask)
​

服务器端代码中定义了两个监听器函数:accept()和read(),其中accept()函数作为“有客户端连接进来”事件的监听函数,read()函数则作为“有数据可读”事件的监听函数。通过这种方式,程序避免了采用死循环不断地调用accept()方法来接受客户端连接,也避免了采用死循环不断地调用recv()方法来接受数据。

客户端代码:

import selectors
import socket
import threading
​
# 创建默认的selectors对象
sel = selectors.DefaultSelector()
​
​
# 负责监听“有数据可读”事件的函数
def read(conn, mask):
    data = conn.recv(1024)  # Should be ready
    if data:
        print(data.decode('utf-8'))
    else:
        print('closing', conn)
        sel.unregister(conn)
        conn.close()
​
​
# 创建socket对象
s = socket.socket()
# 连接远程服务器
s.connect(('192.168.31.10', 30000))
# 设置该socket是非阻塞的
s.setblocking(False)
# 使用sel为s的EVENT_READ事件注册read监听函数
sel.register(s, selectors.EVENT_READ, read)
​
​
# 定义不断读取用户的键盘输入内容的函数
def keyboard_input(s):
    while True:
        line = input('')
        if line is None or line == 'exit':
            break
        # 将用户的键盘输入内容写入socket中
        s.send(line.encode('utf-8'))
​
​
# 采用线程不断读取用户的键盘输入内容
threading.Thread(target=keyboard_input, args=(s, )).start()
while True:
    # 获取事件
    events = sel.select()
    for key, mask in events:
        # 使用key的data属性获取为该事件注册的监听函数
        callback = key.data
        # 调用监听函数,使用key的fileobj属性获取被监听的socket对象
        callback(key.fileobj, mask)

四、基于UDP协议的网络编程

UDP是一种不可靠的网络协议

(一)UDP协议基础

UDP(User Datagram Protocol,用户数据报协议),UDP是一种面向非连接的协议,通信之前不必与对方先建立连接,不管对方状态就直接发送数据。UDP协议使用与一次只传送少量数据、对可靠性要求不高的应用环境。在网络中它与TCP协议一样用于处理数据包,是一种无连接的协议。在OSI模型中,在第四层——传输层,处于IP协议的上一层。UDP有不提供数据包分组、组装和不能对数据包进行排序的缺点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。

UDP协议直接位于IP协议之上,属于OSI参考模型的传输层协议,由于没有建立连接的过程,因此它的通信效率很高。

UDP协议的主要作用是完成网络数据流和数据报之间的转换——在信息发送端,UDP协议将网络数据封装成数据报,然后将数据报发送出去,在信息接收端,UDP协议将数据报转换成实际数据内容。

UDP特点:

1. 面向无连接

首先 UDP 是不需要和 TCP一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了。并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。具体来说就是:在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头标识下是 UDP 协议,然后就传递给网络层了。在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作。

2. 有单播,多播,广播的功能

UDP 不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能。

3. UDP是面向报文的

发送方的UDP对应用程序交下来的报文,在添加首部后就向下交付IP层。UDP对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。因此,应用程序必须选择合适大小的报文。

4. 不可靠性

首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。

5. 头部开销小,传输数据报文时是很高效的。

 

UDP 头部包含了以下几个数据:

● 两个十六位的端口号,分别为源端口(可选字段)和目标端口

● 整个数据报文的长度

● 整个数据报文的检验和(IPv4 可选 字段),该字段用于发现头部信息和数据中的错误

因此 UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要少得多,在传输数据报文时是很高效的

UDP与TCP简单对比:

  UDP TCP
是否连接 无连接 面向连接
是否可靠 不可靠传输,不使用流量控制和拥塞控制 可靠传输,使用流量控制和拥塞控制
连接对象个数 支持一对一,一对多,多对一和多对多交互通信 只能是一对一通信
传输方式 面向报文 面向字节流
首部开销 首部开销小,仅8字节 首部最小20字节,最大60字节
适用场景 适用于实时应用(IP电话、视频会议、直播等) 适用于要求可靠传输的应用,如文件传输

(二)适用socket发送和接受数据

创建socket时,可以通过type参数指定该socket类型,如果该参数指定为SOCK_DGRAM,则创建基于UDP协议的socket。

● socket.sendto(bytes, address):将bytes数据发送到address地址。

● socket.recvfrom(bufsize[, flags]):接受数据。该方法可同时返回socket中的数据和数据来源地址。

UDP必须通过sendto来发送数据,可以通过recv()和recvfrom()来接收数据。

程序在使用UDP 协议进行网络通信时,实际上并没有明显的服务器端和客户端,因为双方都需要先建立一个socket 对象,用来接收或发送数据报。但在实际编程中,通常具有固定IP地址和端口的socket对象所在的程序被称为服务器,因此该socket应该调用bind()方法被绑定到指定IP地址和端口,这样其他socket(客户端socket )才可向服务器端socket ( 绑定了固定IP 地址和端口的socket )发送数据报,而服务器端socket就可以接收这些客户端数据报。当服务器端( 也可以是客户端)接收到一个数据报后,如果想向该数据报的发送者“反馈” 一些信息, 此时就必须获取数据报的“来源信息” ,这就到了recvfrom()方法“闪亮登场”的时候,该方法不仅可以获取socket中的数据,也可以获取数据的来源地址,程序就可以通过该来源地址来“反馈”信息。

一般来说,服务器端socket的IP 地址和端口应该是固定的,因此客户端程序可以直接向服务器端socket 发送数据: 但服务器端无法预先知道各客户端socket的IP地址和端口,因此必须调用recvfrom()方法来获取客户端socket的IP地址和端口。

udp_server.py

import socket
​
PORT = 30000
# 定义每个数据报的大小最大为4KB
DATA_LEN = 4096
# 定义一个字符串数组,服务器端发送该数组的元素
datas = ('Python', 'Kotlin', 'Android', 'Swift')
# 通过type属性指定创建基于UDP协议的socket
s = socket.socket(type=socket.SOCK_DGRAM)
# 将该socket绑定到本机的指定IP地址和端口
s.bind(('192.168.31.10', PORT))
# 采用循环接收数据
for i in range(1000):
    # 读取s中的数据的发送地址
    data, addr = s.recvfrom(DATA_LEN)
    # 将接收到的内容转换成字符串后输出
    print(data.decode('utf-8'))
    # 从字符串数组中取出一个元素作为发送数据
    send_data = datas[i % 4].encode('utf-8')
    # 将数据报发送给addr地址
    s.sendto(send_data, addr)
s.close()
​

以上代码接收1000个客户端发送过来的数据。

udp_client.py

import socket
​
PORT = 30000
# 定义每个数据报的大小为4KB
DATA_LEN = 4096
DEST_IP = "192.168.31.10"
# 通过type属性指定创建基于UDP协议的socket
s = socket.socket(type=socket.SOCK_DGRAM)
# 不断地读取用户的键盘输入内容
while True:
    line = input('')
    if line is None or line == 'exit':
        break
    data = line.encode('utf-8')
    # 发送数据报
    s.sendto(data, (DEST_IP, PORT))
    # 读取socket中的数据
    data = s.recv(DATA_LEN)
    print(data.decode('utf-8'))
s.close()

 

在使用UDP 协议进行网络通信时,服务器端无须也无法保存每个客户端的状态,客户端把数据报发送到服务器端后,完全有可能立即退出。但不管客户端是否退出,服务器端都无法知道客户端的状态。

(三)使用UDP协议实现多点广播

若要使用多点广播,则需要将数据报发送到一个组目标地址,当数据报发出后,整个组的所有主机都能接收到该数据报。IP 多点广播(或多点发送〉实现了将单一信息发送给多个接收者的广播,其思想是设置——组特殊的网络地址作为多点广播地址,每一个多点广播地址都被看作一个组,当客户端需要发送和接收广播信息时,加入该组即可。

IP 协议为多点广播提供了特殊的 IP 地址,这些 IP 地址的范围是 224.0.0.0~239.255.255.255。多点广播示意图如图 所示。

 

从图 1中可以看出,当 socket 把一个数据报发送到多点广播 IP 地址时,该数据报将被自动广播到加入该地址的所有 socket。该 socket 既可以将数据报发送到多点广播地址,也可以接收其他主机的广播信息。

在创建了 socket 对象后,还需要将该 socket 加入指定的多点广播地址中,socket 使用 setsockopt() 方法加入指定组。

如果创建仅用于发送数据报的 socket 对象,则使用默认地址、随机端口即可。但如果创建接收数据报的 socket 对象,则需要将该 socket 对象绑定到指定端口;否则,发送方无法确定发送数据报的目标端口。

支持多点广播的 socket 还可设置广播信息的 TTL(Time-To-Live),该 TTL 参数用于设置数据报最多可以跨过多少个网络:

● 当TTL的值为 0 时,指定数据报应停留在本地主机中;

● 当TTL的值为 1 时,指定将数据报发送到本地局域网中;

● 当TTL 的值为 32 时,意味着只能将数据报发送到本站点的网络上;

● 当TTL 的值为 64 时,意味着数据报应被保留在本地区;

● 当TTL 的值为 128 时,意味着数据报应被保留在本大洲;

● 当TTL 的值为 255 时,意味着数据报可被发送到所有地方;

在默认情况下,TTL 的值为1。

从图 中可以看出,使用 socket 进行多点广播时所有的通信实体都是平等的,它们都将自己的数据报发送到多点广播IP地址,并使用 socket 接收其他人发迭的广播数据报。

import socket
import threading
import os
​
# 定义本机IP地址
SENDERIP = '192.168.31.10'
# 定义本地端口
SENDERPORT = 30000
# 定义本程序的多点广播IP地址
MYGROUP = '230.0.0.1'
# 通过type属性指定创建基于UDP协议的socket
s = socket.socket(type=socket.SOCK_DGRAM)
# 将该socket绑定到0.0.0.0这个虚拟IP地址
s.bind(('0.0.0.0', SENDERPORT))
# 设置广播信息的TTL
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 64)
# 设置允许多点广播使用相同的端口
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 使用socket进入广播组
status = s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
                      socket.inet_aton(MYGROUP) + socket.inet_aton(SENDERIP))
​
​
# 定义从socket中读取数据的方法
def read_socket(sock):
    while True:
        data = sock.recv(2048)
        print("信息:", data.decode('utf-8'))
​
​
# 以read_socket作为target启动多线程
threading.Thread(target=read_socket, args=(s, )).start()
# 采用循环不断读取用户的键盘输入内容,并输出到socket中
while True:
    line = input('')
    if line is None or line == 'exit':
        break
        os._exit(0)
    # 将line输出到socket中
    s.sendto(line.encode('utf-8'), (MYGROUP, SENDERPORT))
​

五、电子邮件支持

Python为邮件支持提供了smtplib、smtpd、poplib等模块,即可以发送邮件,也可以收取邮件。

(一)使用smtplib模块发送邮件

发送邮件步骤:

① 连接SMTP服务器,并使用用户名、密码登陆服务器

② 创建EmailMessage对象,该对象代表邮件本身

③ 调用代表SMTP服务器连接对象的sendmail()方法发送邮件

import smtplib
from email.message import EmailMessage
​
# 定义SMTP服务器地址
smtp_server = 'smtp.qq.com'
# 定义发件人地址
from_addr = '[email protected]'
# 定义登陆邮箱的密码
password = '123456'
# 定义收件人地址
to_addr = '[email protected]'
​
# 创建SMTP连接
# conn = smtplib.SMTP(smtp_server, 25)
conn = smtplib.SMTP_SSL(smtp_server, 465)
conn.set_debuglevel(1)
conn.login(from_addr, password)
# 创建邮件对象
msg = EmailMessage()
# 设置邮件内容
msg.set_content('您好,这是一封来自Python的邮件', 'plain', 'utf-8')
# 发送邮件
conn.sendmail(from_addr, [to_addr], msg.as_string())
# 退出连接
conn.quit()
​

由于程序打开了smtplib 调试模式(将debuglevel 设置为I ),因此在运行该程序时,可以看到SMTP 发送邮件的详细过程如下:

send: 'ehlo [169.254.125.4]\r\n'
reply: b'250-smtp.qq.com\r\n'
reply: b'250-PIPELINING\r\n'   
reply: b'250-SIZE 73400320\r\n'
reply: b'250-AUTH LOGIN PLAIN\r\n'
reply: b'250-AUTH=LOGIN\r\n'
reply: b'250-MAILCOMPRESS\r\n'
reply: b'250 8BITMIME\r\n'
reply: retcode (250); Msg: b'smtp.qq.com\nPIPELINING\nSIZE 73400320\nAUTH LOGIN PLAIN\nAUTH=LOGIN\nMAILCOMPRESS\n8BITMIME'
send: 'AUTH PLAIN ADE4OTQwMjczMkBxcS5jb20AeHp2YXBxYnV3YWJwYmlnZw==\r\n'
reply: b'235 Authentication successful\r\n'
reply: retcode (235); Msg: b'Authentication successful'
send: 'mail FROM:<[email protected]> size=161\r\n'
reply: b'250 Ok\r\n'
reply: retcode (250); Msg: b'Ok'
send: 'rcpt TO:<[email protected]>\r\n'
reply: b'250 Ok\r\n'
reply: retcode (250); Msg: b'Ok'
send: 'data\r\n'
reply: b'354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: b'End data with <CR><LF>.<CR><LF>'
data: (354, b'End data with <CR><LF>.<CR><LF>')
send: b'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\nMIME-Version: 1.0\r\n\r\n5oKo5aW977yM6L+Z5piv5LiA5bCB5p2l6IeqUHl0aG9u55qE6YKu5Lu2Cg==\r\n.\r\n'
reply: b'250 Ok: queued as \r\n'
reply: retcode (250); Msg: b'Ok: queued as'
data: (250, b'Ok: queued as')
send: 'quit\r\n'
reply: b'221 Bye\r\n'
reply: retcode (221); Msg: b'Bye'

● 早期SMTP服务器都采用普通的网络连接,因此默认端口是25。但现在绝大部分SMTP都是基于SSL (Secure Socket Layer)的,这样保证网络上传输的信息都是加密过的,从而使得信息更加安全。这种基千SSL的SMTP服务器的默认端口是465。上面程序中第一行代码连接的是QQ邮箱的基于SSL的SMTP服务器,QQ邮箱服务器不支持普通的SMTP。

● 国内有些公司的免费邮箱(比如QQ邮箱)默认是关闭了SMTP的因此衙要读者登录邮箱进行设置。

● 由于该程序发送的邮件太简单,邮件没有主题,而且程序在测试过程中可能会发送很多邮件,因此有些邮箱服务商会将该程序发送的邮件当成垃圾邮件。

如果要为邮件设置主题、发件人名字和l收件人名字,那么只需设置EmailMessage 对象的相应属性即可。如果程序要将邮件内容改为HTML 内容, 那么只需将调用EmailMessage的set_content()方法的第二个参数设置为html 即可。

 

import smtplib
from email.message import EmailMessage
​
# 定义SMTP服务器地址
smtp_server = 'smtp.qq.com'
# 定义发件人地址
from_addr = '[email protected]'
# 定义登陆邮箱的密码
password = '123456'
# 定义收件人地址
to_addr = '[email protected]'
​
# 创建SMTP连接
# conn = smtplib.SMTP(smtp_server, 25)
conn = smtplib.SMTP_SSL(smtp_server, 465)
conn.set_debuglevel(1)
conn.login(from_addr, password)
# 创建邮件对象
msg = EmailMessage()
# 设置邮件内容
# msg.set_content('您好,这是一封来自Python的邮件', 'plain', 'utf-8')
msg = EmailMessage()
msg.set_content('<h2>邮件内容</h2>' + '<p>您好,这是一封来自Python的邮件</p>' + '来自<a href="http://www.ib-top.com">冰蓝工作室</a>', 'html', 'utf-8')
msg['subject'] = '一封HTML邮件'
msg['from'] = '冰蓝工作室<{0}>'.format(from_addr)
msg['to'] = '新用户<{0}>'.format(to_addr)
# 发送邮件
conn.sendmail(from_addr, [to_addr], msg.as_string())
# 退出连接
conn.quit()
​

debug信息

send: 'ehlo [169.254.125.4]\r\n'
reply: b'250-smtp.qq.com\r\n'
reply: b'250-PIPELINING\r\n'
reply: b'250-SIZE 73400320\r\n'
reply: b'250-AUTH LOGIN PLAIN\r\n'
reply: b'250-AUTH=LOGIN\r\n'
reply: b'250-MAILCOMPRESS\r\n'
reply: b'250 8BITMIME\r\n'
reply: retcode (250); Msg: b'smtp.qq.com\nPIPELINING\nSIZE 73400320\nAUTH LOGIN PLAIN\nAUTH=LOGIN\nMAILCOMPRESS\n8BITMIME'
send: 'AUTH PLAIN ADE4OTQwMjczMkBxcS5jb20AeHp2YXBxYnV3YWJwYmlnZw==\r\n'
reply: b'235 Authentication successful\r\n'
reply: retcode (235); Msg: b'Authentication successful'
send: 'mail FROM:<[email protected]> size=431\r\n'
reply: b'250 Ok\r\n'
reply: retcode (250); Msg: b'Ok'
send: 'rcpt TO:<[email protected]>\r\n'
reply: b'250 Ok\r\n'
reply: retcode (250); Msg: b'Ok'
send: 'data\r\n'
reply: b'354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: b'End data with <CR><LF>.<CR><LF>'
data: (354, b'End data with <CR><LF>.<CR><LF>')
send: b'Content-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\nMIME-Version: 1.0\r\nsubject: =?utf-8?b?5LiA5bCBSFRNTOmCruS7tg==?=\r\nfrom: =?utf-8?b?5Yaw6JOd5bel5L2c5a6k?=<[email protected]>\r\nto: =?utf-8?b?5paw55So5oi3?=<[email protected]>\r\n\r\nPGgyPumCruS7tuWGheWuuTwvaDI+PHA+5oKo5aW977yM6L+Z5piv5LiA5bCB5p2l6IeqUHl0aG9u\r\n55qE6YKu5Lu2PC9wPuadpeiHqjxhIGhyZWY9Imh0dHA6Ly93d3cuaWItdG9wLmNvbSI+5Yaw6JOd\r\n5bel5L2c5a6kPC9hPgo=\r\n.\r\n'
reply: b'250 Ok: queued as \r\n'
reply: retcode (250); Msg: b'Ok: queued as'
data: (250, b'Ok: queued as')
send: 'quit\r\n'
reply: b'221 Bye\r\n'
reply: retcode (221); Msg: b'Bye'

如果希望实现图文并茂的邮件,也就是在邮件中插入图片,则首先要给邮件添加附件-一不要直接在邮件中嵌入外链的图片,很多邮箱出于安全考虑,都会禁用邮件中外链的资源。因此,如果直接在HTML 右键中外链其他图片,那么该图片很有可能显示不出来。为了给邮件添加附件, 只需调用EmailMessage的add _attachment()方法即可。该方法支持很多参数,最常见的参数如下。

● maintype : 指定附件的主类型。比如指定image 代表附件是图片。

● subtype: 指定附件的子类型。比如指定为png ,代表附件是PNG 图片。一般来说,子类型受主类型的限制。

● filename :指定附件的文件名。

● cid= img : 指定附件的资源ID ,邮件正文可通过资源ID 来号引用该资源。

例如,如下程序为邮件添加了3 个附件。

import smtplib
import email.utils
from email.message import EmailMessage
​
# 定义SMTP服务器地址
smtp_server = 'smtp.qq.com'
# 定义发件人地址
from_addr = '[email protected]'
# 定义登陆邮箱的密码
password = '123456'
# 定义收件人地址
to_addr = '[email protected]'
​
# 创建SMTP连接
# conn = smtplib.SMTP(smtp_server, 25)
conn = smtplib.SMTP_SSL(smtp_server, 465)
conn.set_debuglevel(1)
conn.login(from_addr, password)
# 创建邮件对象
msg = EmailMessage()
# 随机生成两个图片id
first_id, second_id = email.utils.make_msgid(), email.utils.make_msgid()
# 设置邮件内容,指定邮件内容为HTML
msg.set_content(
    '<h2>邮件内容</h2>' + '<p>您好,这是一封来自Python的邮件' + '<img src="cid:' +
    second_id[1:-1] + '"><p>' + '来自<a href="http://www.ib-top.com">冰蓝工作室</a>' +
    '<img src="cid:' + first_id[1:-1] + '">', 'html', 'utf-8')
msg['subject'] = '一封HTML邮件'
msg['from'] = '冰蓝工作室<{0}>'.format(from_addr)
msg['to'] = '新用户 <{0}>'.format(to_addr)
with open('G:\code\python\crazy_python\chapter15\logo.jpg', 'rb') as f:
    # 添加第一个附件
    msg.add_attachment(f.read(),
                       maintype='image',
                       subtype='jpeg',
                       filename='test.png',
                       cid=first_id)
with open('G:\code\python\crazy_python\chapter15\logo.gif', 'rb') as f:
    # 添加第二个附件
    msg.add_attachment(f.read(),
                       maintype='image',
                       subtype='gif',
                       filename='test.gif',
                       cid=second_id)
with open('G:\code\python\crazy_python\chapter15\logo.pdf', 'rb') as f:
    # 添加第三个附件,邮件正文不需引用该附件,因此不指定cid
    msg.add_attachment(
        f.read(),
        maintype='application',
        subtype='pdf',
        filename='test.pdf',
    )
# 发送邮件
conn.sendmail(from_addr, [to_addr], msg.as_string())
# 退出连接
conn.quit()

(二)使用poplib模块收取邮件

该模块提供了poplib.POP3和poplib.POP3_SSL两个类,分别用于连接普通的POP服务器和基于SSL的POP服务器。

POP3协议也属于请求——响应式交互协议。POP3的命令和响应都是基于ASCII文本的,并以CR和LF(/r/n)作为结束符,响应数据包括一个表示返回状态的符号(+/-)和描述信息。

请求和响应的标准格式如下:

请求标准格式:命令 [参数] CRLF

响应标准格式:+OK/[-ERR] description CRLF

POP3协议客户端的命令和服务器端对应的响应数据如下:

● user name:向pop服务器发送登陆的用户名

● pass string:向POP服务器发送登陆的密码

● quit:退出POP服务器

● stat:统计邮件服务器状态,包括邮件数和总大小

● list[msg_no]:列出全部邮件或指定邮件。返回邮件编号和对应大小

● retr msg_no:获取指定邮件的内容(根据邮件编号来获取,编号从1开始)

● dele msg_no:删除指定邮件(根据邮件编号来删除,编号从1开始)

● noop:空操作。仅用于与服务器保持连接

● rset:用于撤销dele命令

收取邮件步骤:

① 使用poplib.POP3或poplib.POP3_SSL按POP3协议从服务器端下载邮件。

② 使用email.parser.Parser或email.parser.BytesParser解析邮件内容,得到EmailMessage对象,从EmailMessage对象中读取邮件内容。

import poplib
import os.path
import mimetypes
from email.parser import BytesParser, Parser
from email.policy import default
​
# 输入邮件地址、密码和POP服务器地址
email = '[email protected]'
password = '123456'
pop3_server = 'pop.qq.com'
​
# 连接到POP服务器
# conn = poplib.POP3(pop3_server, 110)
conn = poplib.POP3_SSL(pop3_server, 995)
# 可以打开或关闭调试信息
conn.set_debuglevel(1)
# 可选:打印pop服务器的欢迎文字
print(conn.getwelcome().decode('utf-8'))
# 输入用户名、密码信息
# 相当于发送pop3的user命令
conn.user(email)
# 相当于发送pop3的pass命令
conn.pass_(password)
# 获取邮件统计信息,相当于发送pop3的stat命令
message_num, total_size = conn.stat()
print('邮件数:{0}。总大小:{1}'.format(message_num, total_size))
# 获取服务器上的邮件列表,相当于发送pop3的list命令
# 使用resp保存服务器的响应码
# 使用mails列表保存每封邮件的编号、大小
resp, mails, octets = conn.list()
print(resp, mails)
# 获取指定邮件的内容(此处传入总长度,也就是获取最后一封邮件)
# 相当于发送pop3的retr命令
# 使用resp保存服务器的响应码
# 使用data保存该邮件的内容
resp, data, octets = conn.retr(len(mails))
# 将data的所有数据(原本是一个字节列表)拼接在一起
msg_data = b'\r\n'.join(data)
# 将字符串内容解析成邮件,此处一定要指定policy=default
msg = BytesParser(policy=default).parsebytes(msg_data)
print(type(msg))
print('发件人:' + msg['from'])
print('收件人:' + msg['to'])
print('主题:' + msg['subject'])
print('第一个收件人名字:' + msg['to'].addresses[0].username)
print('第一个发件人名字:' + msg['from'].addresses[0].username)
for part in msg.walk():
    counter = 1
    # 如果maintype是multipart,则说明是容器(用于包含正文、附件等)
    if part.get_content_maintype() == 'multipart':
        continue
    # 如果是text则说明是邮件正文部分
    elif part.get_content_maintype() == 'text':
        print(part.get_content())
    # 处理附件
    else:
        # 获取附件的文件名
        filename = part.get_filename()
        # 如果没有文件名,程序要负责为附件生成文件名
        if not filename:
            # 根据附件的content_type来推测它的后缀名
            ext = mimetypes.guess_extension(part.get_content_type())
            # 如果推测不出后缀名
            if not ext:
                # 使用.bin作为后缀名
                exe = '.bin'
            # 程序为附件生成文件名
            filename = 'part-{0:03d}{1}'.format(counter, ext)
        counter += 1
        # 将附件写入本地文件中
        with open(os.path.join('.', filename), 'wb') as fp:
            fp.write(part.get_payload(decode=True))
# 退出服务器,相当于发送pop3的quit命令
conn.quit()

习题:

1. 编写一个程序,使用urllib.request读取http://www.crazyit.org首页的内容。

from urllib.request import urlopen
​
url = 'http://www.crazyit.org'
# 打开URL对应的资源
result = urlopen(url)
# 将资源内容读入变量data中
data = result.read()
# 将字节数据恢复成字符串
data = data.decode('utf-8')
​
# 此处可以将获取到的内容保存到文件或直接打印出来
print(data)
with open('crazyit.html', 'w+') as f:
    f.write(data)
​

2. 编写一个程序,结合使用urllib.request和re模块,下载井识别http://www.crazyit.org首页的全部链接地址。

from urllib.request import urlopen
import re
​
url = 'http://www.crazyit.org'
# 使用urlopen()获取远程资源
result = urlopen(url)
# 将读取到的字节数据存入变量data
data = result.read()
# 创建正则表达式匹配<a href标签并获取标签中的链接内容
pattern = r'<a\s+href=\"([a-zA-Z0-9\.:\?&=\-;/%]+)\"'
link_list = re.finditer(pattern, data.decode('utf-8'))
for link in link_list:
    print(link.group(1))
# 还可以进一步对地址进行筛选,去除如javascript;这类的链接,也可以将forum.php?mobile=yes这类的链接补全

3. 开发并完善本章介绍的聊天室程序,并为该程序提供界面。

一般情况下,服务器端不需要界面,只负责提供聊天服务就好。首先还是建立两个公共类来实现协议字符串和用户字典

ChatDict.py

class ChatDict(dict):
    # 根据value查找key
    def key_from_value(self, value):
        # 遍历所有key组成的集合
        for key in self.keys():
            # 如果指定key对应的value与被搜索的value相同,则返回对应的Key
            if self[key] == value:
                return key
        return None
​
    # 根据value删除key
    def remove_by_value(self, value):
        # 遍历所有key组成的集合
        for key in self.keys():
            # 如果指定key对应的value与被搜索的value相同,则删除对应的键值对
            if self[key] == value:
                self.pop(key)
                return
​

ChatProtocol.py

# 定义协议字符串长度
PROTOCOL_LEN = 2
# 下面是一些协议字符串,服务器端和客户端交换的信息都应该在前、后添加这种特殊字符串
MSG_ROUND = "§γ"
USER_ROUND = "∏∑"
LOGIN_SUCCESS = "1"
NAME_REP = "-1"
PRIVATE_ROUND = "★【"
SPLIT_SIGN = "※"
服务器端供多线程调用的函数

ServerThread.py

import ChatProtocol
​
​
def server_target(s, clients):
    try:
        while True:
            # 从socket中读取数据
            line = s.recv(2048).decode('utf-8')
            print(line)
            # 如果读取到的行以CharProtocol.USER_ROUND开始,并以此协议字符串结束,则可以确定督导的是用户登陆的用户名
            if line.startswith(ChatProtocol.USER_ROUND) and line.endswith(
                    ChatProtocol.USER_ROUND):
                # 解析出真实消息
                user_name = line[ChatProtocol.
                                 PROTOCOL_LEN:-ChatProtocol.PROTOCOL_LEN]
                # 检测用户名是否重复
                if user_name in clients:
                    print('用户名重复')
                    s.send(ChatProtocol.NAME_REP.encode('utf-8'))
                else:
                    print('登陆成功')
                    s.send(ChatProtocol.LOGIN_SUCCESS.encode('utf-8'))
                    # 将用户名与socket关联起来
                    clients[user_name] = s
            # 如果读取到的行以PRIVATE_ROUND开始,并以此结束,则可以确定是私聊信息,私聊信息只向特定的socket发送
            elif line.startswith(ChatProtocol.PRIVATE_ROUND) and line.endswith(
                    ChatProtocol.PRIVATE_ROUND):
                # 去除协议字符串,得到真实消息内容
                user_and_msg = line[ChatProtocol.
                                    PROTOCOL_LEN:-ChatProtocol.PROTOCOL_LEN]
                # 以SPLIT_SIGN分割字符串,前半部分是私聊用户,后半部分是聊天内容
                user = user_and_msg.split(ChatProtocol.SPLIT_SIGN)[0]
                msg = user_and_msg.split(ChatProtocol.SPLIT_SIGN)[1]
                # 检查私聊用户是否还在线,也就是看用户名是否在clients中
                if user in clients:
                    # 获取私聊用户对应的socket,并发送私聊信息
                    clients[user].send((clients.key_from_value(s) + "悄悄对你说:" +
                                        msg).encode('utf-8'))
                else:
                    s.send("用户已下线".encode('utf-8'))
            # 公聊信息要向每个socket发送
            else:
                # 得到真实消息
                msg = line[ChatProtocol.
                           PROTOCOL_LEN:-ChatProtocol.PROTOCOL_LEN]
                # 遍历clients中的每个socket
                for client_socket in clients.values():
                    client_socket.send((clients.key_from_value(s) + "说:" +
                                        msg).encode('utf-8'))
    # 如果程序抛出了异常,表明该socket对应的客户端已经出现了问题,所以程序将其对应的socket从dict中移除
    except Exception:
        clients.remove_by_value(s)
        print(len(clients))
        # 关闭网络、IO资源
        if s is not None:
            s.close()

服务器端代码:

ChatServer.py

import socket
import threading
import ChatDict
from ServerThread import server_target
​
# 定义聊天室端口
SERVER_PORT = 30000
# 定义聊天室服务器地址
HOST = '192.168.31.10'
# 使用ChatDict来保存每个用户名和对应socket之间的关系
clients = ChatDict.ChatDict()
# 创建socket对象
s = socket.socket()
try:
    # 将socket绑定到本机IP和端口
    s.bind((HOST, SERVER_PORT))
    # 服务器端开始监听来自客户端的连接
    s.listen()
    # 采用循环来不断接收来自客户端的请求
    while True:
        # 每当接收到客户端socket的请求时,返回对应的socket对象和远程地址
        c, addr = s.accept()
        # 为每一个客户端启动一个线程
        threading.Thread(target=server_target, args=(c, clients)).start()
# 如果抛出异常,打印服务器启动失败
except Exception:
    print("服务器启动失败,请检查端口{0}是否被占用?".format(SERVER_PORT))
​

客户端代码:

ChatClient.py

import socket
import threading
import ChatProtocol
import os
import time
from tkinter import simpledialog
from tkinter import *
from tkinter import ttk
​
# 定义socket端口
SERVER_PORT = 30000
HOST = '192.168.31.10'
​
​
# 定义客户端类,使用tkinter实现界面UI
class App:
    def __init__(self, master):
        self.master = master
        self.init()  # 初始化用户界面
​
    # 定义客户端线程执行函数
    def client_target(self, s):
        try:
            # 使用循环不断从socket中读取数据,并将这些数据打印输出
            while True:
                line = self.s.recv(2048).decode('utf-8')
                if line is not None:
                    # 打开聊天内容文本框编辑
                    self.text.config(state=NORMAL)
                    # 在文本框结尾插入聊天内容
                    self.text.insert(END, line + "\n")
                    # 禁用编辑
                    self.text.config(state=DISABLED)
        # 使用finally关闭线程对应的socket
        finally:
            self.s.close()
​
    # 初始化用户界面
    def init(self):
        # 定义容器
        showf = Frame(self.master)
        # 定义布局
        showf.pack()
        # 定义显示聊天内容文本框
        self.text = Text(showf,
                         width=80,
                         border=1,
                         height=30,
                         font=('StSong', 14),
                         foreground='gray')
        # 禁止编辑Text
        self.text.config(state=DISABLED)
        # 定义聊天内容文本框布局
        self.text.pack(side=LEFT, fill=BOTH, expand=YES)
        # 创建Scrollbar组件,设置该组件与self.text的纵向滚动关联
        scroll = Scrollbar(showf, command=self.text.yview)
        scroll.pack(side=RIGHT, fill=Y)
        # 设置self.text的总想滚动影响scroll滚动条
        self.text.configure(yscrollcommand=scroll.set)
        f = Frame(self.master)
        f.pack()
        # 定义聊天信息输入框
        self.entry = ttk.Entry(f,
                               width=74,
                               font=('StSong', 14),
                               foreground='gray')
        self.entry.pack(side=LEFT, fill=BOTH, expand=YES)
        # 定义发送按钮
        self.button = ttk.Button(f, text='发送')
        self.button.pack(side=RIGHT, fill=BOTH, expand=YES)
        # ------------------------初始化网络-----------------
        # 创建socket
        self.s = socket.socket()
        try:
            # 连接远程主机
            self.s.connect((HOST, SERVER_PORT))
            # 定义提示信息
            tip = ''
            # 采用循环不断地弹出对话框要求输入用户名
            while True:
                user_name = simpledialog.askstring('您的用户名', tip + '输入用户名:')
                # 在用户输入的用户名后增加协议字符串后发送给服务器
                self.s.send((ChatProtocol.USER_ROUND + user_name +
                             ChatProtocol.USER_ROUND).encode('utf-8'))
                # 线程睡眠0.2秒
                time.sleep(0.2)
                # 读取服务器端响应
                result = self.s.recv(2048).decode('utf-8')
                # 判断服务器响应
                if result is not None and result != '':
                    # 如果用户名重复,则开始下一次循环
                    if result == ChatProtocol.NAME_REP:
                        tip = "用户名重复!请重新"
                        continue
                    # 如果登陆成功则结束循环
                    if result == ChatProtocol.LOGIN_SUCCESS:
                        break
        # 如果抛出异常,则关闭网络资源并退出程序
        except Exception as e:
            print("网络异常!请重新登陆!", e)
            self.s.close()
            os._exit(1)
        self.button['command'] = self.send
        self.entry.bind('<Return>', self.entry_send)
        # 启动客户端线程
        threading.Thread(target=self.client_target, args=(self.s, )).start()
​
    # 绑定输入框回车键方法
    def entry_send(self, e):
        self.send()
​
    # 发送消息的方法
    def send(self):
        line = self.entry.get()
        if line is None or line == 'exit':
            return
        # 清空输入框内容
        self.entry.delete(0, END)
        # 如果发送的信息中有冒号,且以//开头,则认为想发送私聊信息
        if ":" in line and line.startswith("//"):
            line = line[2:]
            self.s.send((ChatProtocol.PRIVATE_ROUND + line.split(":")[0] +
                         ChatProtocol.SPLIT_SIGN + line.split(":")[1] +
                         ChatProtocol.PRIVATE_ROUND).encode('utf-8'))
        else:
            # 发送公聊信息
            self.s.send((ChatProtocol.MSG_ROUND + line +
                         ChatProtocol.MSG_ROUND).encode('utf-8'))
​
​
# 创建Tk对象
root = Tk()
root.title('聊天室')
App(root)
root.mainloop()

4. 开发并完善本章介绍的多点广播程序,并为该程序提供界面,使之成为一个局域网内的聊天程序。

5. 结合使用smtplib和poplib 模块,开发一个简单的邮件客户端程序,该客户端程序既可以发送邮件,也可以收取邮件。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章