• part15:Python网络编程(urllib模块:request、error、parse、robotparse,cookie管理,TCP协议网络编程:socket.socket()、半关闭socket.shutdown(),selectors模块,UDP协议网络编程:UDP实现多点广播,邮件:smtplib、smtpd、poplib)



    知识点:

    • IP 地址和端口号
    • Python 的基本网络支持模块
    • urllib.parse 子模块的功能和用法
    • 使用 urllib.request 读取资源
    • 使用 urllib.request 发送各种请求
    • 通过 cookie 来管理 urllib.request 的连接状态
    • TCP 协议
    • 使用 socket 创建 TCP 服务器端
    • 使用基于 TCP 协议的 socket 通信
    • 半关闭的 socket
    • 使用 selectors 模块实现非阻塞通信
    • UDP 协议
    • 使用 socket 发送和接收数据
    • 使用 UDP 协议实现多点广播
    • 使用 smtplib 模块发送邮件
    • 使用 poplib 模块收取邮件

    使用网络模块,Python 可以访问互联网上的 HTTP服务、FTP服务,可以获取互联网上的远程资源,向远程资源发送 GET、POST 请求。

    urllib 模块是 Python 访问网络资源最常用的工具,可用于访问各种网络资源,也可用于向 Web 服务器发送GET、POST、DELETE、PUT等各种请求,还能管理 cooke,这是一个非常实用的网络模块。

    Python的 TCP 网络通信支持,利用 socket 建立 TCP 服务器端和客户端。通过 socket 的send()、recv() 方法发送和接收数据。

    Python的 UDP 通信支持。UDP 协议是非连接的。基于 UDP 协议的 socket 在发送数据时要使用 sendto() 方法。

    smtplib、poplib 用来发送和接收邮件。


    一、网络编程基础知识


    1、网络基础知识

    计算机网络是现代通信技术与计算机技术相结合的产物,主要功能如下:

    • 资源共享
    • 信息传输与集中处理
    • 均衡负荷与分布处理
    • 综合信息服务

    计算机网络可按照规模大小和延伸范围来分类,常见分类有:局域网(LAN)、城域网(MAN)和广域网(WAN)。Internet 是最大的广域网。

    计算机网络通信的约定,称为通信协议。通信协议负责对传输速率、传输代码、代码结构、传输控制步骤、出错控制等制定处理标准。

    通信协议由三部分组成:

    • 一是语义部分:用于决定双方对话的类型;
    • 二是语法部分:用于决定双方对话的格式;
    • 三是变换规则:用于决定通信双方的应答关系。

    国际标准化组织( ISO )于1978 年提出了“开放系统互连参考模型”,即著名的OSI (Open System lnterconnection )参考模型。OSI 参考模型力求将网络简化,并以模块化的方式来设计网络。

    OSI 参考模型把计算机网络分成七层:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层,经过多年的发展和推进, OSI 模式己成为各种计算机网络结构的参考标准。

    通信协议是网络通信的基础,IP 协议则是一种非常重要的通信协议。IP (Internet Protocol )又称网际协议,是支持网间互联的数据报协议。IP 协议提供了网间连接的完善功能,包括 IP 数据报规定的互联网络范围内的地址格式。

    经常与IP 协议放在一起的还有TCP (Transmission Control Protocol ),即传输控制协议,它规定了一种可靠的数据信息传递服务。虽然 IP 和TCP 这两个协议的功能不尽相同,也可以分开单独使用,但它们是在同一个时期作为一个协议来设计的,并且在功能上是互补的,因此,在实际使用中常常把这两个协议统称为TCP/IP 协议。TCP/IP 协议最早出现在UNIX 操作系统中,现在几乎所有的操作系统都支持TCP/IP 协议,因此, TCP/IP 协议也是Internet 中最常用的基础协议。

    TCP/IP协议将网络模型分为四层,分别是:

    • 网络接口层,对应OSI的物理层、数据链路层;
    • 网络层,对应OSI的网络层;
    • 传输层,对应OSI的传输层;
    • 应用层,对应OSI的会话层、表示层、应用层。

    2、IP 地址和端口号

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

    计算机网络中被传输的数据包有一个源 IP 地址和目的 IP 地址,数据包在传输过程中这两个地址保持不变。确保网络设备总能根据确定的 IP 地址,将数据送到目的主机上。

    IP 地址是一个32位的整数,分成4个8位的二进制数,每8位之间用小圆点分隔,每个8位的二进制数可转换成一个0~255的十进制数。

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

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

    端口号是一个整数,由16位二进制数组成,端口号的范围是 0~65535。端口是应用程序与外界交流的出入口,它是一种抽象的软件结构,包括一些数据结构和 I/O(输入/输出)缓冲区。同一台计算机中不能有两个程序使用同一个端口。端口号可划分为下面三类:

    • 公认端口(Well Known Port):端口号是0~1023,这些端口号绑定了特殊服务,在使用时要避开这些端口。
    • 注册端口(Registered Port):端口号是 1024~49151,应用程序程序通常使用这个范围内的端口,例如 8000。
    • 动态和/或私有端口(Dynamic and/or Private Port):端口号是 49152~65535,属于应用程序的动态端口,应用程序不会主动使用这些端口。

    网络中传输的数据需要指定目的地的 IP 地址和端口号,计算机网络才能将数据发送到目的的。


    二、Python 的基本网络支持


    1、Python的网络模块简介

    TCP/IP 将网络划分为四层,每一层都有对应的协议:

    • 网络接口层:LAN、MAN、WAN
    • 网络层:IP协议族,子协议有 ICMP、IGMP、ARP、RARP等
    • 传输层:TCP和UDP协议
    • 应用层:有很多协议,常见的是 HTTP、FTP、SMTP、DNS、NFS、TELENT等。

    网络层协议主要是 IP,它是所有互联网协议的基础,其中 ICMP(Internet Control Message Protocol)、IGMP(Internet Group Manage Protocol)、ARP(Address Resolution Protocol)、RARP(Reverse Address Resolution Protocol)等协议是 IP 协议族的子协议。通常不会直接基于网络层进行应用程序编程。

    传输层协议主要是 TCP 和 UDP,Python 的 socket 模块针对传输层协议进行编程。

    应用层协议有很多,Python 同样基于应用层的编程提供了丰富的支持。

    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 协议的客户端模块
    telentlib 支持 TELNET 协议的客户端模块
    urllib 及其子模块 支持 URL 处理的模块
    xmlrpc、xmlrpe、server、xmlrpc.client 支持 XML-RPC 协议的服务器端和客户端模块

    2、使用 urllib.parse 子模块

    URL(Uniform Resource Locator)是统一资源定位器,是指向互联网“资源”的指针。资源可以是文件或目录,也可以是复杂的对象引用。URL 由协议名、主机、端口和资源路径组成,格式如下:

    protocol://host:port/path
    示例:
    http://www.maichael.com/index.html
    

    urllib 模块中用于处理 URL 的子模块:

    • urllib.request:核心模块,包含有打开和读取 URL 的各种函数。
    • urllib.error:包含了由 urllib.request 子模块所引发的各种异常。
    • urllib.parse:用于解析 URL。
    • urllib.robotparse:主要用于解析 robots.txt 文件。

    在 Python 2.x 中,urllib 模块分为 urllib 和urllib2 两个模块,urllib 用于简单的下载,urllib2 可实现 HTTP验证、cookie管理。

    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 。

    下面使用 urlparse() 函数解析 URL 字符串。

    from urllib.parse import *
    
    # 解析 URL 字符串
    result = urlparse('http://www.michael.com:80/index.html;jack?name=python#dddd')
    print(result)
    # 通过属性名和索引号获取 ULR 的各部分
    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())
    

    使用 urlparse() 解析 URL 字符串,返回结果是一个 ParseResult 对象,该对象是 tuple 的子类。因此可通过属性和索引号来获取URL的各部分。

    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

    上面代码的输出结果如下:

    ParseResult(scheme='http', netloc='www.michael.com:80', path='/index.html', params='jack', query='name=python', fragment='dddd')
    scheme:  http http
    主机和端口:  www.michael.com:80 www.michael.com:80
    主机: www.michael.com
    端口: 80
    资源路径:  /index.html /index.html
    参数: jack jack
    查询字符串:  name=python name=python
    fragment:  dddd dddd
    http://www.michael.com:80/index.html;jack?name=python#dddd
    

    使用 urlunparse() 函数,可以把一个 ParseResult 对象或元组恢复成 URL 字符串,例如:

    result2 = urlunparse(('https', 'www.michael.com:80', 'index.html', 'jack', 'name=python', 'dddd'))
    print('URL为:', result2)
    

    可以得到输出结果如下:

    URL为: https://www.michael.com:80/index.html;jack?name=python#dddd
    

    如果被解析的 URL 以双斜线(//)开头,urlunparse() 可以识别出主机,此时缺少 scheme 部分;如果 URL 没有 scheme,也没有双斜线(//)开头,urlunparse() 会将这些 URL 都当成资源路径。示例如下:

    from urllib.parse import *
    # 解析以 // 开头的 URL
    result3 = urlparse('//www.michael.com:80/index.html')
    print('scheme: ', result3.scheme, result3[0])
    print('主机和端口:', result3.netloc, result3[1])
    print('资源路径:', result3.path, result3[2])
    print('-' * 20)
    # 解析没有 scheme,也没有以双斜线 // 开头的 URL
    # 此时 URL 从开始就会被当成资源路径
    result4 = urlparse('www.michael.com/index.html')
    print('scheme: ', result4.scheme, result4[0])
    print('主机和端口:', result4.netloc, result4[1])
    print('资源路径:', result4.path, result4[2])
    

    输出结果如下:

    scheme:   
    主机和端口: www.michael.com:80 www.michael.com:80
    资源路径: /index.html /index.html
    --------------------
    scheme:   
    主机和端口:  
    资源路径: www.michael.com/index.html www.michael.com/index.html
    

    parse_qs() 和 parse_qsl() 都用于解析查询字符串,前一个返回值是字典,后一个返回值是列表。urlencode() 是它们的逆函数。示例如下:

    from urllib.parse import *
    # 解析查询字符串,返回 dict
    result = parse_qs('name=python&name=Web编程&age=18')
    print(result)
    # 解析查询字符串,返回 list
    result = parse_qsl('name=python&name=Web编程&age=18')
    print(result)
    # 将列表形式的请求参数恢复成字符串
    print(urlencode(result))
    

    输出结果如下:

    {'name': ['python', 'Web编程'], 'age': ['18']}
    [('name', 'python'), ('name', 'Web编程'), ('age', '18')]
    name=python&name=Web%E7%BC%96%E7%A8%8B&age=18
    

    从上面输出可知,parse_qs() 返回一个字典,key 是参数名,value 是参数值。parse_qsl() 返回一个 list,每个元素代表一个查询参数。

    urljoin() 函数负责将两个URL 拼接在一起, 返回代表绝对地址的 URL 。这里主要可能出现3种情况:

    • 被拼接的 URL 只是一个相对路径 path (不以斜线开头),那么该URL 将会被拼接到 base 之后, 如果 base 本身包含path 部分, 则用被拼接的URL 替换base 所包含的path 部分。
    • 被拼接的URL 是一个根路径 path ( 以单斜线开头〉,那么该 URL 将会被拼接到 base 的域名之后。
    • 被拼接的 URL 是一个绝对路径 path ( 以双斜线开头),那么该URL 将会被拼接到 base 的 scheme 之后。

    关于 urljoin() 的功能和用法示例如下:

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

    3、使用 urllib.request 读取资源

    在 urllib.request 子模块下的 urlopen(url, data=None) 方法可用于打开 url 指定的资源,并从中读取数据。根据请求 url 的不同,该方法的返回值会发生动态变化。如果 url 是一个 HTTP 地址,那么该方法返回一个 http.client.HTTPResponse 对象。示例如下:

    from urllib.request import *
    
    # 打开 URL 对应的资源
    result = urlopen('https://www.jd.com')
    # 按字节读取数据
    data = result.read(1000)
    # 将字节数据恢复成字符串
    print(data.decode('utf-8'))
    
    # 用 context manager 来管理打开的 URL 资源
    with urlopen('http://www.baidu.com') as f:
        # 按字节读取数据
        data = f.read(500)
        # 将字节数据恢复成字符串
        print(data.decode('utf-8'))
    

    上面这段代码向京东和百度发送了请求,请求成功就会输出网页的源代码。

    在使用 urlopen() 函数时,可以通过 data 属性向被请求的 URL 发送数据。示例如下。

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

    需要先在服务端启动服务器才能正常测试这段代码。启动过程省略。上面代码中的 data 参数是一个 bytes 字节数据,该字节数据会以原始二进制流的方式提交给服务器。

    urlopen() 函数还可以向服务器页面发送 GET 请求参数,无须使用 data 属性,直接把请求参数附加在 URL 之后即可。示例如下:

    from urllib.request import *
    import urllib.parse
    
    # 将字典形式的参数恢复成请求字符串
    params = urllib.parse.urlencode({'name': 'michael', 'password': '123456'})
    # 将请求参数添加到 URL 的后面
    url = 'http://httpbin.org/get?%s' % params
    with urlopen(url=url) as f:
        # 读取服务器的全部响应数据
        print(f.read().decode('utf-8'))
    

    上面这段代码在发送 GET 请求时,直接将 GET 请求参数附加到 URL 的后面。请求成功会返回正确输出结果。此外,GET 请求的参数会出现在 URL 中。输出结果如下:

    {
      "args": {
        "name": "michael", 
        "password": "123456"
      }, 
    ......
      "url": "http://httpbin.org/get?name=michael&password=123456"
    }
    

    urlopen() 函数发送 POST 请求参数,也可通过 data 属性来实现。示例如下:

    from urllib.request import *
    import urllib.parse
    
    params = urllib.parse.urlencode({'name': '李白', 'password': '123456'})
    params = params.encode('utf-8')
    # 使用 data 指定请求参数
    with urlopen('http://httpbin.org/post', data=params) as f:
        print(f.read().decode('utf-8'))
    

    上面这段代码通过 data 参数向指定地址发送 POST 请求,POST 请求的 URL 中没有出现请求参数。输出如下:

    {
    ......
      "form": {
        "name": "u674eu767d", 
        "password": "123456"
      }, 
    ......
      "url": "http://httpbin.org/post"
    }
    

    data 参数还可以发送 PUT、PATCH、DELETE 等请求,此时需要使用 urllib.request.Request 来构建请求参数。在使用 urlopen() 函数打开运程资源时,url 参数可以 URL 字符串,也可以使用 urllib.request.Request 对象。urllib.request.Request 对象的构造器如下:

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

    这个构造器,通过 method 指定请求方法,通过 data 指定请求参数,通过 headers 指定请求头。示例如下:

    from urllib.request import *
    
    params = 'put请求数据'.encode('utf-8')
    req = Request(url='http://httpbin.org/put', data=params, method='PUT')
    with urlopen(req) as f:
        print(f.status)
        print(f.read().decode('utf-8'))
    

    上面代码在创建 Request 对象时通过 method 指定使用 PUT 请求方式,这样就会向指定页面发送PUT请求。输出如下:

    200
    {
    ......
      "form": {
        "putu8bf7u6c42u6570u636e": ""
      }, 
    ......
      "url": "http://httpbin.org/put"
    }
    

    使用 Request 对象还可以添加请求头,示例如下:

    from urllib.request import *
    
    req = Request('http://httpbin.org')
    # 添加请求头
    req.add_header('Referer', 'http://www.michael.com')
    with urlopen(req) as f:
        print(f.status)
        print(f.read().decode('utf-8'))
    

    使用 urlopen() 打开远程资源后,可以方便的对远程资源读取,也可能使用多线程进行下载。下面代码实现一个多线程下载的工具类。

    #!/usr/bin/env python3
    #-*- coding: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_files = target_file
            # 初始化 threads 数组
            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()
            # 计算每个线程要下载的资源大小
            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_files, '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_post 个字节,表明该线程只下载自己负责的那部分内容
            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()
    
    if __name__ == '__main__':
        du = DownUtil('https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1588067606985&di=6a0a3e4c92cd37c94bed50e5d66edf10&imgtype=0&src=http%3A%2F%2Fa3.att.hudong.com%2F14%2F75%2F01300000164186121366756803686.jpg', 'a.jpg', 3)
        du.download()
        def show_process():
            print("已完成:%.2f" % 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()
    

    上面代码中定义的 DownThread 线程类,负责读取从 start_pos 开始、长度为 current_part_size 的所有字节,并写入本地文件对象中。其 run() 方法就是一个简单的输入/输出实现。

    其中 DownUtils 类的 download() 方法负责按下面的步骤实现多线程下载:

    • 使用 urlopen() 方法打开远程资源。
    • 获取指定的 URL 对象所指向资源的大小(通过 Content-Length 响应头获取)。
    • 计算每个线程应该下载网络资源的哪个部分(从哪个字节开始,到哪个字节结束)。
    • 依次创建并启动多个线程来下载网络资源的指定部分。

    cookie 管理器用来实现维护客户端与服务器端之间的 session。服务器通过 session id 来辨别多次请求是否是来自同一个客户端。

    有效管理 session,引入 http.cookiejar模块。使用 OpenerDirector 对象发送请求。

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

    • 创建 http.cookiejar.CookieJar 对象或其子类的对象。
    • 以 CookieJar 对象为参数,创建 urllib.request.HTTPCookieProcessor 对象,该对象负责调用 CookieJar 来管理 cookie。
    • 以 HTTPCookieProcessor 对象为参数, 调用 urllib .request.build_opener() 函数创建 OpenerDirector 对象。
    • 使用 OpenerDirector 对象来发送请求, 该对象将会通过 HTTPCookieProcessor 调用 CookieJar 来管理 cookie。

    下面代码示例,先登录 Web 应用,再请求 Web 应用中被保护的页面。

    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 浏览器的 UserAgent
    user_agent = r'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
    # 定义请求头
    headers = {'User-Agent' : user_agent, 'Connection': 'keep-alive'}
    
    # -----------下面代码发送登录的 POST 请求---------------
    # 定义登录系统的请求参数
    params = {'name': 'michael', 'pass': '123456'}
    postdata = urllib.parse.urlencode(params).encode()
    # 创建向登录页面发送 POST 请求的 Request
    request = Request('http://localhost:8888/test/login.jsp', 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)     # 第27行
    
    # --------------下面代码发送访问被保护资源的 GET 请求--------------
    # 创建向被保护页面发送 GET 请求的 Request
    request = Request('http://localhost:8888/test/secret.jsp', headers=headers)
    response = opener.open(request)
    print(response.read().decode())
    

    上面这段代码中,如果将注释的 第27行 代码取消注释,则会将 cookie 信息写入 a.txt 文件中。这意味着服务器响应的 session id 等 cookie 持久化保存在 a.txt 文件中。只要读取该文件的 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 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
    # 定义请求头
    headers = {'User-Agent':user_agent, 'Connection':'keep-alive'}
    
    #-------------下面代码发送访问被保护资源的GET请求----------------
    # 创建向"受保护页面"发送GET请求的Request
    request = Request('http://localhost:8888/test/secret.jsp',
        headers=headers)
    response = opener.open(request)
    print(response.read().decode())
    

    上面代码使用 建CookieJar对象的 load方法加载指定文件中的 cookie 信息。运行成功后就会输出页面的源码信息。


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

    TCP/IP 是可靠的网络协议,它在通信两端各建立一个 socket,形成虚拟的网络链路。一旦建立了虚拟网络链路,两端可通过该链路进行通信。socket 模块封装了 TCP 协议,可使用 socket 对象代表两端的通信端口,并通过socket 进行网络通信。


    1、TCP 协议基础

    • IP协议(Internet Protocol,即 Internet协议),一个关键协议。允许连接不同类型的计算机和不同的操作系统的网络。
    • IP 协议只保证计算机能发送和接收分组数据。数据在传送过程中被分成多个小的数据包。
    • IP 协议不能保证数据在传输过程中出现的问题,因此需要 TCP 协议提供可靠且无差错的通信服务。
    • TCP 是端对端协议,在两台计算机需要连接时建立一个虚拟链路,用于发送和接收数据。
    • TCP 协议发送端收集数据包,按照一定的顺序发送,接收端收到数据包再将其正确的还原。
    • TCP 协议重发机制,接收端收到数据包后,会向发送端发送确认信息,如果没有收到确认信息,发送端会重发信息。
    • TCP和IP协议结合,才能保护 Internet 在复杂的环境下正常运行。所以连接到 Internet 的计算机,都要安装和使用 TCP/IP 协议。

    2、使用 socket 创建 TCP 服务器端

    在使用 socket 之前先创建 socket 对象。socket 类的构造函数如下:

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

    参数说明:

    • family 参数:指定协议类型。AF_INET 基于 IPv4 协议;AF_INET6基于 IPv6 协议;AF_UNIX(UNIX网络)。
    • type 参数:指定 Sock 类型。默认是 SOCK_STREAM,基于 TCP 协议的 socket;SOCK_DGRAM 基于 UDP 协议的 socket;SOCK_RAW 创建原始的 socket。常用前两种。
    • proto 参数指定协议号,没有特殊要求的话,该参数默认为 0,并可以忽略。

    服务器端使用的 socket 必须绑定到指定 IP 地址和端口,并在该 IP 地址和端口进行监听,接收来自客户端的连接。socket 对象的常用方法如下:

    • socket.accept():服务端使用该方法接收来自客户端的连接。
    • socket.bind(address):服务端使用该绑定到指定的 address,address 可以是一个元组,包含 IP 地址和端口。
    • socket.close():关闭连接,回收资源。
    • socket.connect(address):客户端使用该方法连接远程服务器。
    • socket.connect_ex(address):与上一个方法功能相似,该方法在连接出错时,不会抛出异常,而是返回一个错误标识。
    • socket.listen([backlog]):服务端使用该方法进行监听。
    • socket.makefile(mode='r',buffering=None,*,encoding=None,errors=None,newline=None):创建一个和该 socket 关联的文件对象。
    • socket.recv(bufsize[,flags]):接收数据。该方法返回 bytes 对象表示接收到的数据。
    • socket.recvfrom(bufsize[,flags]):与上一个方法功能大致相同,只是该方法返回的是 (bytes, address) 元组。
    • socket.recvmsg(bufsize[,ancbufsize[,flags]]):不仅接收数据,还接收辅助数据。该方法返回值是一个长度为 4 的元组(data, ancdata, msg_flags, address),其中 ancdata 代表辅助数据。
    • socket.recvmsg_into(buffers[,ancbufsize[,flags]]):将接收到的数据放入 buffers 中。
    • socket.recvfrom_into(buffer[, nbytes[, flags]]):类似于 recvfrom() 方法。将接收到数据放入 buffer 中。
    • socket.send(bytes[,flags]):发送数据。常用于基于 TCP 协议的网络中发送数据。
    • socket.sendto(bytes,address):发送数据。常用于基于 UDP 协议的网络发送数据。
    • socket.sendfile(file,offset=0,count=None):发送文件内容,直到遇到文件的 EOF。
    • socket.shutdown(how):关闭连接。how 参数设置关闭方式。

    基于 TCP 的服务器端编程步骤如下:

    1、服务器端先创建一个 socket 对象。
    2、服务器端 socket 将自己绑定到指定 IP 地址和端口。
    3、服务器端 socket 调用 listen() 方法监听网络。
    4、采用循环不断调用 socket 的 accept() 方法接收来自客户端的连接。


    3、使用 socket 通信

    客户端同样要创建 socket 对象,接着调用该对象的 connect() 方法与服务器端建立一个基于TCP协议的连接。所以客户端的步骤是:

    1、客户端先创建一个 socket 对象。
    2、客户端 socket 调用 connect() 方法连接远程服务器。

    当客户端与服务端建立连接后,就不用再区分服务器端和客户端,他们通过各自的 socket 进行通信。此时就用到 socket 对象提供的发送和接收数据的方法。例如发送数据 send()、sendto()等,接收数据 recv_xxx() 等方法。

    下面的代码在服务器端建立 socket,并监听来自客户端的连接,有客户端连接进来,就向 socket 发送一条简单的信息。

    import socket
    
    # 创建 socket 对象
    s = socket.socket()
    # 将 socket 绑定到本机 IP 地址和端口,注意参数是元组
    s.bind(('localhost', 8888))
    # 服务器开始监听来自客户端的连接
    s.listen()
    while True:
        # 每当收到客户端的 socket 请求时,就返回对应的 socket 和 远程地址
        c, addr = s.accept()
        print(c)
        print('连接地址:', addr)
        c.send('您好,欢迎来到网络世界!'.encode('utf-8'))
        # 关闭连接
        c.close()
    

    下面代码创建一个客户端,使用 socket 与指定 IP 地址和端口的服务器端建立连接,并获取服务器端发送的数据。

    import socket
    
    # 创建 socket 对象
    s = socket.socket()
    # 连接远程服务器
    s.connect(('localhost', 8888))
    # 客户端使用 socket 对象的 recv() 方法接收服务器端发送来的数据
    print('--%s--' % s.recv(1024).decode('utf-8'))
    s.close()
    

    先运行服务器端的代码,此时服务器一直处于等待状态,因为服务器端使用了 while 循环来接收客户端的请求。接下来再运行客户端代码,此时会看到服务器端和客户端都有输出,这说明客户端和服务器端通信成功。

    服务器的输出信息如下:

    <socket.socket fd=868, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 8888), raddr=('127.0.0.1', 59978)>
    连接地址: ('127.0.0.1', 59978)
    

    客户端的输出信息如下:

    --您好,欢迎来到网络世界!--
    

    4、加入多线程

    客户端与服务器端需要长时间保持通信,即服务器端不断读取客户端数据,并向客户端写入数据;客户端也需要不断读取服务器端数据,并向服务器端写入数据。

    socket 的 recv() 方法在成功读取到数据之前,线程会被阻塞,程序不能继续执行。所以,服务器端需要为每个 socket 单独启动一个线程,每个线程负责与一个客户端进行通信。

    客户端读取服务器端数据的线程同样会被阻塞,所以需要单独启动一个线程,专门负责读取服务器数据。

    案例:命令行界面的 C/S 聊天室

    案例分析:服务器端要包含多个线程,每个 socket 对应一个线程,该线程负责从 socket 中读取数据,并将所读取到的数据向每个 socket 发送一次(广播给其他客户端),因此需要在服务器使用 list 来保存所有的 socket。

    下面的服务器端代码定义一个 server_target() 函数,作为线程执行的 target,负责处理每个 socket 的数据通信。

    import socket
    import threading
    
    # 定义保存所有 socket 的列表
    socket_list = []
    # 创建 socket 对象
    ss = socket.socket()
    # 将 socket 绑定到本机 IP 地址和端口
    ss.bind(('localhost', 9998))
    # 服务端开始监听来自客户端的连接
    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:
                    # if client_s == s:	# 可加入判断,不发送给当前客户端
                    #    continue
                    client_s.send(content.encode('utf-8'))
        except e:
            print(e.strerror)
    while True:
        # 此行代码会被阻塞,将一直等待别人的连接
        s, addr = ss.accept()
        socket_list.append(s)
        # 每当客户端连接后,都会启动一个线程为该客户端服务
        threading.Thread(target=server_target, args=(s, )).start()
    

    上面服务器商的代码负责处理客户端的 socket 连接请求,当客户端连接进来后,就将客户端对应的 socket 加入 socket_list 列表中保存,并为该 socket 启动一个线程,该线程负责处理该 socket 所有的通信任务。

    server_target() 函数调用 read_from_client() 函数不断读取客户端数据,如果读取出现异常,则表明该 socket 对应的客户端 socket 出现了问题(不用深究问题,反正不正常),此时该 socket 就从 socket_list 列表中删除。

    当服务端接收到客户端数据后,就遍历 socket_list 列表,并将数据向该列表中的每个 socket 发送一次。

    在客户端程序中,每个客户端都应该包含两个线程,一个负责读取用户的键盘输入内容,并将数据输出到 socket 中;另一个线程负责读取服务器发送过来的数据,并将这些数据打印输出。可由主线程读取键盘输入,子线程读取 socket 数据。客户端代码如下:

    import socket
    import threading
    
    # 创建 socket 对象
    s = socket.socket()
    # 连接远程服务器
    s.connect(('localhost', 9998))
    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 结构的聊天室应用。


    5、记录用户信息(群发及私聊)

    前面的服务端代码中没有保存客户端 socket 关联的用户信息。

    下面使用字典dict来保存用户状态信息,实现私聊功能。即一个客户端可以将信息发送给另一个指定客户端。但是,所有的客户端只与服务端连接,客户端之间并没有互相连接。所以,当一个客户端将信息发送到服务端后,服务器端必须可以判断出该信息到底向所有用户发送的,还是向指定用户发送的,并需要知道向哪个用户发送。因此需要解决下面两个问题:

    • 客户端发送的信息必须有特殊的标识,让服务器可以判断出是公聊信息还是私聊信息。
    • 如果是私聊信息,客户端会将该信息的目的用户(私聊对象)发送给服务器,服务器端要将该信息发送给该私聊对象。

    要解决第一个问题,可以让客户端在发送不同的信息之前,先对信息进行适当处理。比如在内容前后添加一些特殊字符,这些特殊字符被称为协议字符串。本案例中,将专门用于定义协议字符的代码保存在 sendProtocol.py 文件中,代码如下:

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

    这些协议字符串在客户端和服务器端都要使用,所以都需要保留这个sendProtocol.py 文件。

    服务器端使用一个 dict 的子类,该类根据 value 获取 key、根据 value 删除 key 等方法。文件名是 sendDict.py,代码如下:

    class sendDict(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
    

    这里的 sendDict 类已经不是标准的字典结构,但对于该案例需要一个这样的数据结构来保存用户名和对应的 socket 之间的的映射关系,这样可以通过用户名找到对应的 socket,也可以根据 socket 找到对应的用户名。

    接下是服务器端的主线程,主线程只建立 socket 来监听客户端 socket 的连接请求。这次在服务器端增加异常处理。文件名是 server.py,代码如下:

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

    上面这段代码中,主要的就3行,即监听客户端连接、接收客户端请求、给每个已连接的客户端分配一个单独线程。这里以 server_target 作为新线程的 target。

    服务器端的 server_target 函数需要处理公聊、私聊两类信息。此外,还要处理用户名是否重复的问题。服务器端线程的 server_target 函数在 server_thread.py 文件中,代码如下。

    import sendProtocol
    
    def server_thrad(s, clients):
        try:
            while True:
                # 从 socket 读取数据
                line = s.recv(2048).decode('utf-8')
                print(line)
                # 如果读取到的行以 sendProtocol.USER_ROUND 开始,并以其结束
                # 则可以确定读取到的是用户登录的用户名
                if line.startswith(sendProtocol.USER_ROUND) and line.endswith(sendProtocol.USER_ROUND):
                    # 得到真实用户名
                    user_name = line[sendProtocol.PROTOCOL_LEN:-sendProtocol.PROTOCOL_LEN]
                    # 如果用户名重复
                    if user_name in clients:
                        print("重复")
                        s.send(sendProtocol.NAME_REP.encode('utf-8'))
                    else:
                        # 登录成功
                        print("成功")
                        s.send(sendProtocol.LOGIN_SUCCESS.encode('utf-8'))
                        clients[user_name] = s
                # 如果读取到的行以sendProtocol.PRIVATE_ROUND 开始,并以其结束
                # 则可以确定是私聊信息,私聊信息只向特定的 socket 发送
                elif line.startswith(sendProtocol.PRIVATE_ROUND) and line.endswith(sendProtocol.PRIVATE_ROUND):
                    # 得到真实信息
                    user_and_msg = line[sendProtocol.PROTOCOL_LEN: -sendProtocol.PROTOCOL_LEN]
                    # 以 SPLIT_SIGN 分割字符串,前一半是私聊用户,后一半是聊天信息
                    user = user_and_msg.split(sendProtocol.SPLIT_SIGN)[0]
                    msg = user_and_msg.split(sendProtocol.SPLIT_SIGN)[1]
                    # 判断私聊用户对象是否已登录
                    if user in clients.keys():
                        # 私聊用户已登录,获取私聊用户对应的 socket,并发送私聊信息
                        clients[user].send((clients.key_from_value(s) + "悄悄地对你说:" + msg).encode("utf-8"))
                    else:
                        # 私聊用户未登录,向客户端发送未登录信息
                        s.send(("%s未登录!" % user).encode("utf-8"))
                # 公聊信息要向每个 socket 发送
                else:
                    # 得到真实信息
                    msg = line[sendProtocol.PROTOCOL_LEN: -sendProtocol.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:
            clients.remove_by_value(s)
            print(len(clients))
            # 关闭网络、I/O资源
            if s is not None:
                s.close()
    

    上面的代码中,增加了异常处理,还增加了对读取数据的判断代码。当读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串进行相应的处理。

    客户端程序需要增加输入用户名,并且不允许用户名重复。此外,还可以根据用户的键盘输入内容来判断用户是否想发送私聊信息。客户端的代码文件名称是client.py,代码如下:

    import socket, threading, os
    import sendProtocol
    
    # from tkinter import simpledialog
    import time
    
    SERVER_PORT = 8888
    
    # 定义一个读取键盘输入内容,并向网络中发送数据
    def read_send(s):
        msg = """
    群聊:输入要发送的消息按下回车键;
    私聊:格式是“//用户名:消息”。"""
        print(msg)
        # 采用死循环不断地读取键盘输入内容
        while True:
            line = input("")
            if line is None or line == 'exit':
                break
            # 如果发送的信息中有冒号,且以 // 开头,则认为想发送私聊信息
            if ":" in line and line.startswith("//"):
                line = line[2:]     # 去掉双 //线,冒号前面是私聊用户
                s.send((sendProtocol.PRIVATE_ROUND +
                       line.split(":")[0] + sendProtocol.SPLIT_SIGN +
                       line.split(":")[1] + sendProtocol.PRIVATE_ROUND).encode("utf-8"))
            else:
                s.send((sendProtocol.MSG_ROUND + line + sendProtocol.MSG_ROUND).encode("utf-8"))
    
    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()
    
    # 创建 socket 对象
    s = socket.socket()
    try:
        # 连接远程服务器
        s.connect(('localhost', SERVER_PORT))
        tip = ""
        # 采用循环不断的弹出对象话框要求输入用户名
        while True:
            user_name = input(tip + '输入用户名:
    ')
            # 在用户输入的用户名前后增加协议字符串后发送
            s.send((sendProtocol.USER_ROUND + user_name + sendProtocol.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 == sendProtocol.NAME_REP:
                    tip = "用户名重复!请重新输入
    "
                    continue
            # 如果服务器返回登录成功的信息,则结束循环
            if result == sendProtocol.LOGIN_SUCCESS:
                print("登录成功")
                break
    # 捕获到异常,关闭网络资源,并退出程序
    except:
        print("网络异常!请重新登录!")
        s.close()
        os._exit(1)
    
    # 启动客户端线程
    threading.Thread(target=client_target, args=(s,)).start()
    read_send(s)
    

    先运行服务器程序(server.py),再运行客户端程序(client.py),在客户端会提示用户输入用户名。如果用户名不重复,服务器端会返回客户登录成功的暗号;如果用户名重复,则服务器端返回客户端登录失败的暗号。

    客户端登录成功后就可以发送信息。如果发送的是私聊信息,则需要指定私聊用户。当私聊用户未登录时,客户端会收到未登录的信息。


    6、半关闭的 socket

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

    通过关闭 socket 可表示输出数据已经结束。但如果彻底关闭 sokcet,会导致不能再从该 socket 中读取数据。所以 socket 提供了一个 shutdown(how) 关闭方法,该方法可以只关闭 socket 的输入或输出部分。用以表示输出数据已经发送完成。how 参数取值如下:

    • socket.SHUT_RD:关闭 socket 输入部分,此时还可通过 socket 输出数据;
    • socket.SHUT_WR:关闭 socket 的输出部分,此时还可通过 socket 读取数据;
    • socket.SHUT_RDWR:全关闭。该socket 不能读取数据,也不能写入数据。

    当调用 shutdown() 方法关闭 socket 的输入或输出部分之后,该 socket 处于“半关闭”状态。

    注意:一个socket 调用 shutdown() 方法时传入了 SHUT_RDWR 参数,该 socket 也依然没有被彻底清理,与 close() 方法不同。只是该 socket 不能输出数据,也不能读取数据。

    下面代码是 shutdown() 方法使用示例。在服务器端先向客户端发送多条数据后,该 socket 对象调用 shutdown() 来关闭输出部分,表明数据发送结束。虽然关闭了输出部分,但是还可以从socket 中读取数据。

    import socket
    
    # 创建 socket 对象
    s = socket.socket()
    # 将 sokcet 绑定梧桐IP 地址和端口
    s.bind(('localhost', 8800))
    # 监听客户端请求
    s.listen()
    # 接收到客户端的请求时,accept() 方法返回对应的 socket 和IP地址
    c, addr = s.accept()
    # 向客户端发送2条数据
    c.send("我是服务器端".encode("utf-8"))
    c.send("我是服务器端".encode("utf-8"))
    # 关闭 socket 的输出部分,表明输出数据已经结束
    c.shutdown(socket.SHUT_WR)
    while True:
        # 从 socket 中读取数据
        line = c.recv(2048).decode("utf-8")
        if line is None or  line == "":
            break
        print(line)
        # 不能再次发送数据,下面代码会引发异常
        # c.send(line[::-1].encode("utf-8"))
    c.close()
    s.close()
    

    对应的客户端代码如下:

    import socket
    
    # 创建 socket 对象
    s = socket.socket()
    # 连接服务器
    s.connect(('localhost', 8800))
    # 接收数据
    print(s.recv(1024).decode("utf-8"))
    print(s.recv(1024).decode("utf-8"))
    # 发送数据
    while True:
        line = input("")
        if line is None or line == 'exit':
            break
        s.send(line.encode("utf-8"))
        # 服务器已关闭输出数据,所以不能再接收
        # print(s.recv(1024).decode("utf-8"))
    s.close()
    

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


    7、selectors 模块

    socket 默认是阻塞式通信,当调用 recv() 方法从 socket 中读取数据时,如果没有读取到数据,则当前线程会被阻塞。为解决这个问题,可使用多线程并发编程:服务器为每个客户端连接都启动一个单独线程,不同的线程负责对应的 socket 的通信工作。

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

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

    selectors 非阻塞式编程步骤如下:

    第1步:创建 selectors 对象;

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

    第3步:在监听器函数中处理socket 通信。

    selectors 模块非阻塞式通信的服务器示例如下:

    import selectors, socket
    
    # 创建默认的 selectors 对象
    sel = selectors.DefaultSelector()
    # 负责监听 有数据可读 事件的函数
    def read(soc, mask):
        try:
            # 读取数据
            data = soc.recv(1024)
            if data:
                # 将所读取的数据用循环方式向每个 socket 发送一次
                for s in socket_list:
                    s.send(data)
            else:
                # 如果该 socket 已被对方关闭,则关闭该 socket
                # 并将其从 socket_list 列表中移除
                print('关闭', soc)
                sel.unregister(soc)     # 解除监听
                soc.close()
                socket_list.remove(soc)
        # 如果捕获到异常,则将该 socket 关闭,并将其从 socket_list 列表中删除
        except:
            print('关闭', soc)
            sel.unregister(soc)  # 解除监听
            soc.close()
            socket_list.remove(soc)
    
    socket_list = []
    
    # 负责监听 有客户端连接进来 事件的函数
    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(('localhost', 8801))
    sock.listen()
    # 设置该 socket 是非阻塞的
    sock.setblocking(False)
    # 使用 selectors 为 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() 是有数据可读事件的监听函数。

    客户端的代码如下:

    import selectors, socket, threading
    
    # 创建默认的 selectors 对象
    sel = selectors.DefaultSelector()
    # 负责监听 有数据可读 事件函数
    def read(conn, mask):
        data = conn.recv(1024)
        if data:
            print(data.decode("utf-8"))
        else:
            print('closing', conn)
            sel.unregister(conn)
            conn.close()
    
    # 创建 socket 对象
    s = socket.socket()
    # 连接远程服务器
    s.connect(('localhost',8801))
    # 设置 socket 是非阻塞的
    s.setblocking(False)
    # 使用 sel 为 s 的 EVENT_READ 事件注册 read 函数
    # 当 socket 中有数据可读时,就会触发 read() 函数读取 socket 中的数据
    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)
    

    客户端 socket 的 selectors.EVENT_READ 事件注册的 read() 函数在有数据可读时,就会触发 read() 函数来读取 socket 中的数据。在客户端采用循环不断的调用 selectors.select() 方法 监测事件,每当监测到相应的事件后,就会调用对应的事件监听函数。

    运行服务器端 server.py 文件,在服务器只有当客户端关闭连接的时候才有输出。多运行几个 client.py 文件,就实现了一个简单的 C/S 聊天室。


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


    1、UDP 协议基础

    UDP(User Datagram Protocol,用户数据报协议),UDP 协议的应用虽然不如 TCP广泛,但 UDP 依然是一种非常实用和可行的网络传输层协议。在一些实时性很强的应用场景中,如网络游戏、视频会议等,UDP 协议的快速能力更具有独特的魅力。

    UDP是一种面向非连接的协议,是指在正式通信前不必与对方先建立连接,不管对方状态就直接发送数据。对方是否能接收数据,UDP 协议无法控制。

    UDP 协议与 TCP 协议都直接位于 IP 协议之上。IP 协议是 OSI 参考模型的网络层协议,UDP 和 TCP 协议是传输层协议。

    UDP 是面向非连接的,没有建立连接的过程。因此通信效率高,也因此可靠性不如 TCP 协议。

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

    可将 UDP 协议的socket 看作是码头,数据报则类似于集装箱。码头的作用就是负责发送、接收集装箱, 而socket 的作用则是发送、接收数据报。因此,对于基于 UDP 协议的通信双方而言,没有所谓的客户端和服务器端的概念。

    UDP 与 TCP 简单对比:

    • TCP 协议: 可靠, 传输大小无限制, 但是需要连接建立时间,差错控制开销大。
    • UDP 协议:不可靠, 差错控制开销较小, 传输大小限制在 64kb 以下, 不需要建立连接。

    2、使用 socket 发送和接收数据

    在创建 socket 对象时,指定 type 参数为 SOCK_DGRAM 即创建基于 UDP 协议的 socket。UDP 协议的 socket 可用于发送和接收的方法如下:

    • socket.sendto(bytes, address):将 bytes 数据发送到 address 地址。
    • socket.recvfrom(bufize[, flags]):接收数据。该方法可以同时返回 socket 中的数据和数据来源地址。

    注意:使用 sendto() 方法发送数据时需要指定数据的目标地址(通过 address参数指定);接收数据可使用 recv() 方法和 recvfrom() 方法。如果需要用到数据报来源,可使用 recvfrom() 方法。

    由于 UDP 不建立虚拟链路,因此在发送数据时必须通过 sendto() 方法的 address 参数来指定数据报的目的地,这个地址会被附加到所发送的数据报上。

    使用 UDP 进行通信时,通常具有固定 IP 地址和端口的 socket 对象所在的程序被称为服务器,因此要调用 bind() 方法绑定到指定 IP 地址和端口。这样其他 socket 才可以向服务器端 socket 发送数据报,服务器端 socket 也可以接收这些客户端数据报。

    下面是使用 UDP 协议的 socket 实现的简单网络通信。在服务器端循环1000次读取 socket 中的数据,每当读取到内容后,便向该数据报发送者发送一条信息。代码如下:

    import socket
    
    PORT = 8801
    # 定义每个数据报大小最大为 4KB
    DATA_LEN = 4096
    # 定义一个字符串列表,服务器发送该列表元素
    books = ['Python 入门', 'Python 进阶', 'Python 高级', 'Python 核心编程', 'Python 应用']
    # 创建 UDP 协议的 socket
    s = socket.socket(type=socket.SOCK_DGRAM)
    # 绑定到指定IP地址和端口
    s.bind(('localhost', PORT))
    # 循环接收数据
    for i in range(1000):
        # 读取 s 中的数据发送地址
        data, addr = s.recvfrom(DATA_LEN)
        print(data.decode('utf-8')) # 将内容转换为字符串输出
        send_data = books[i % 5].encode('utf-8')
        s.sendto(send_data, addr)   # 发送数据报给指定地址
    s.close()
    

    该服务器端可接收1000个客户端发送过来的数据。客户端通过读取键盘输入,并将数据发送出去;接下来读取来自 socket 中的信息。代码如下:

    import socket
    
    PORT = 8801
    DATA_LEN = 4096
    DEST_IP = "localhost"
    # 创建 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)) # 发送数据
        data = s.recv(DATA_LEN) # 读取数据
        print(data.decode("utf-8"))
    s.close()
    

    上面的服务器端与客户端区别在于:服务器端IP地址和端口是固定的,所以客户端可以直接将数据报发送给服务器;而服务器端需要根据所接收到的数据报来决定“反馈”数据报的目的地。


    3、使用 UDP 协议实现多点广播

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

    IP 协议的多点广播地址范围是:224.0.0.0 ~ 239.255.255.255。

    创建 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 的值为l 。

    下面代码使用 socket 实现一个基于广播的多人聊天室。只需要一个 socket、两个线程即可。其中 socket 用于发送和接收数据;主线程读取用户的键盘输入内容,并向 socket 发送数据,子线程负责从 socket 中读取数据。

    import socket, threading, os
    
    # 定义本机IP 地址
    SENDERIP = 'localhost'
    # 定义本地端口
    SENDERPORT= 8801
    # 定义多点广播IP地址
    MYGROUP = '230.1.2.3'
    # 创建基于 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)
    # 进入广播组
    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 作为 taget 启动多线程
    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))
    

    五、电子邮件支持

    smtplib、smtpd、poplib 等模块既可发送邮件,也可接收邮件。


    1、使用 smtplib 模块发送邮件

    使用方法分为3步:

    • 第1步:连接SMTP 服务器,并使用用户名、密码登录服务器。
    • 第2步:创建 EmailMessage 对象,该对象代表邮件本身。
    • 第3步:调用代表与 SMTP 服务器连接对象的 sendmail() 方法发送邮件。

    邮件发送简单示例如下:

    import smtplib
    from email.message import EmailMessage
    
    # 定义 SMTP 服务器地址
    smtp_server = 'smtp.qq.com'
    # 定义发件人地址
    from_addr = '123456789@qq.com'
    # 定义登录邮件的密码
    password = 'abcdefghijklmnopt'   # 该密码是QQ邮箱授权码
    # 定义收件人地址
    to_addr = 'michael@126.com'
    
    # 第1步:创建 SMTP 连接,连接SMTP 服务器,并使用用户名、密码登录服务器。
    # conn = smtplib.SMTP(smtp_server, 25)
    conn = smtplib.SMTP_SSL(smtp_server, 465)
    conn.set_debuglevel(1)
    conn.login(from_addr, password)
    # 第2步:创建 EmailMessage 对象,该对象代表邮件本身
    msg = EmailMessage()
    # 设置邮件内容
    msg.set_content('hello, This email from Python,收到请忽略', 'plain', 'utf-8')
    # 第3步:调用代表与 SMTP 服务器连接对象的 sendmail() 方法发送邮件
    conn.sendmail(from_addr, [to_addr], msg.as_string())
    # 退出连接
    conn.quit()
    

    早期 SMTP 服务器采用普通网络连接,默认端口是25。现在大部分 SMTP 都是基于 SSL(Secure Socket Layer)的,保证在网络上传输的信息都是加密过的,便得信息更加安全。基于 SSL 的 SMTP 服务器的默认端口是 465。QQ邮件不支持普通的STMP,因此上面代码使用基于 SSL 的 SMTP 服务器。

    上面代码中使用的QQ邮箱密码实际是 QQ邮箱授权码,需要到QQ邮箱的【设置/账户】下申请。

    Python 2.x 提供了 email.mime、email.header、email.charset、email.encoders、email.iterators等库处理邮件,这些使用不方便,设计烦琐,尽量少用。

    上面代码中打开了 smtplib 调试模式(将 debuglevel 设置为 1),因此在运行时可以看到 SMTP 发送邮件的详细过程。

    在发送邮件时可以设置主题、发件人名字、收件人名字,这些在 EmailMessage 对象中设置相应的属性即可。还可以将邮件内容改为 HTML 内容,只需将 EmailMessage 对象的 set_content() 方法的第二个参数设置为 html 即可。示例如下:

    import smtplib
    from email.message import EmailMessage
    
    # 定义 SMTP 服务器地址
    smtp_server = 'smtp.qq.com'
    # 定义发件人地址
    from_addr = '123456789@qq.com'
    # 定义登录邮件的密码
    password = 'abcdefghijklmnopt'   # 该密码是QQ邮箱授权码
    # 定义收件人地址
    to_addr = 'michael@126.com'
    
    # 第1步:创建 SMTP 连接,连接SMTP 服务器,并使用用户名、密码登录服务器。
    # conn = smtplib.SMTP(smtp_server, 25)
    conn = smtplib.SMTP_SSL(smtp_server, 465)
    conn.set_debuglevel(1)
    conn.login(from_addr, password)
    # 第2步:创建 EmailMessage 对象,该对象代表邮件本身
    msg = EmailMessage()
    # 设置邮件内容
    content = """<h2>邮件内容</h2>
            <p style='color: red;'>hello, This email from Python</p>
            来自<a href='javascript:;'>michael</a>,收到请忽略"""
    msg.set_content(content, 'html', 'utf-8')
    msg['subject'] = 'Test HTML 邮件'
    msg['from'] = 'micaehl<%s>' % from_addr
    msg['to'] = '新用户<%s>' % to_addr
    # 第3步:调用代表与SMTP 服务器连接对象的 sendmail() 方法发送邮件
    conn.sendmail(from_addr, [to_addr], msg.as_string())
    # 退出连接
    conn.quit()
    

    上面代码中在调用 set_content() 方法时将第二个参数改为 html,即将邮件内容按照 HTML 格式发送。此外,还设置了邮件主题、发件人名字和收件名字。

    如果要发送带图片的邮件,即在邮件中插入图片,首先需要给邮件添加附件,不要直接在邮件中嵌入外链(外部连接)的图片,多数邮箱出于安全考虑,会禁用邮件中的外链资源。因此,如果直接在 HTML 右键中外链其他图片,该图片很有可能显示不出来。

    使用 EmailMessage 对象的 add_attachment() 方法可给邮件添加附件。该方法参数有点多,常见的参数如下:

    • maintype : 指定附件的主类型。比如指定 image 代表附件是图片。
    • subtype : 指定附件的子类型。比如指定为 png ,代表附件是PNG 图片。一般来说,子类型受主类型的限制。
    • filename :指定附件的文件名。
    • cid=img : 指定附件的资源 ID ,邮件正文可通过资源 ID 来号引用该资源。

    示例如下:

    import smtplib, email.utils
    from email.message import EmailMessage
    
    # 定义 SMTP 服务器地址
    smtp_server = 'smtp.qq.com'
    # 定义发件人地址
    from_addr = '123456789@qq.com'
    # 定义登录邮件的密码
    password = 'abcdefghijklmnopt'   # 该密码是QQ邮箱授权码
    # 定义收件人地址
    to_addr = 'michael@126.com'
    
    # 第1步:创建 SMTP 连接,连接SMTP 服务器,并使用用户名、密码登录服务器。
    # conn = smtplib.SMTP(smtp_server, 25)
    conn = smtplib.SMTP_SSL(smtp_server, 465)
    conn.set_debuglevel(1)
    conn.login(from_addr, password)
    # 第2步:创建 EmailMessage 对象,该对象代表邮件本身
    msg = EmailMessage()
    # 随机生成两个图片 ID,返回值是字符串
    first_id, second_id = email.utils.make_msgid(), email.utils.make_msgid()
    # 设置邮件内容,指定内容为 HTML 内容
    content = """<h2>邮件内容</h2>
            <p style='color: red;'>hello, This email from Python
            <img src='cid:%s' /></p>
            来自<a href='javascript:;'>michael</a>,收到请忽略
            <img src='cid:%s' />""" % (second_id[1: -1], first_id[1: -1])
    msg.set_content(content, 'html', 'utf-8')
    msg['subject'] = 'Test HTML 邮件'
    msg['from'] = 'micaehl<%s>' % from_addr
    msg['to'] = '新用户<%s>' % to_addr
    
    with open('./images/logo.jpg', 'rb') as f:
        # 添加第1个附件
        msg.add_attachment(f.read(), maintype='images', subtype='jpeg', filename='test.jpg', cid=first_id)
    with open('./images/mylogo.gif', 'rb') as f:
        # 添加第2个附件
        msg.add_attachment(f.read(), maintype='images', subtype='gif', filename='test.gif', cid=second_id)
    with open('./images/mypdf.pdf', 'rb') as f:
        # 添加第3个附件,邮件正文不需要引用该附件,因此不指定 cid
        msg.add_attachment(f.read(), maintype='application', subtype='pdf', filename='test.pdf')
    
    # 第3步:调用代表与SMTP 服务器连接对象的 sendmail() 方法发送邮件
    conn.sendmail(from_addr, [to_addr], msg.as_string())
    # 退出连接
    conn.quit()
    

    上面代码中的三条 with 语句为邮件添加了三个附件,由于邮件正文不需要引用第三个附件,因此添加的第3个附件时没有指定 cid 属性。在添加附件时指定 cid 属性后,就可以在邮件正文中通过该 cid 来引用附件。

    通过上面3个示例可知,使用 smtplib 模块发送邮件主要分为三步:连接邮件服务器、创建邮件和发送邮件。如果要构建复杂的邮件内容,主要通过 EmailMessage 对象来进行设置。EmailMessage 也是 Python 3.x 对邮件处理的巨大简化。它把对邮件内容的各种处理封装在 EmailMessage 类中,使得编程变得轻松、简单。


    2、使用 poplib 模块收取邮件

    poplib 模块收取邮件也很简单,该模块提供了 poplib.POP3 和 poplib.POP3_SSL 两个类,分别用于连接普通的POP 服务器和基于 SSL 的POP 服务器。

    连接服务器成功后,就按照 POP3 协议与服务器进行交互。POP3 协议简介如下。

    POP3 协议属于请求-响应式交互协议,当客户端连接到服务器后,客户端向 POP 服务器发送请求,而 POP 服务器则对客户端生成响应数据,客户端可通过响应数据下载得到邮件内容。当下载完成后,邮件客户端可以删除或修改任意邮件,而无须与电子邮件服务器进行进一步交互。

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

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

    请求标准格式:命令[参数] CRLF
    响应标准格式:+OK/[-ERP] 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 模块模拟了上面命令,poplib.POP3 或 poplib.POP3_SSL 为上面命令提供了相应的方法。使用相应的方法主可以从服务器下载对应的邮件。

    使用 poplib 收取邮件可分为两步:

    • 第1步:使用 poplib.POP3 或 poplib.POP3_SSL 按 POP3 协议从服务器端下载邮件。
    • 第2步:使用 email.parser.Parser 或 email.parser.BytesParser 解析邮件内容,得到 EmailMessage 对象,从EmailMessage 对象中读取邮件内容。

    poplib 模块的使用示例如下:

    import poplib, os.path, mimetypes
    from email.parser import BytesParser, Parser
    from email.policy import default
    
    # 定义邮箱地址、密码和POP服务服务器地址
    email = '123456789@qq.com'
    password = 'abcdefghijklmnopt'   # 该密码是授权码
    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)	# 第18 行
    # 相当于发送 pop3 的 pass 命令
    conn.pass_(password)
    # 获取邮件统计信息,相当于发送 pop 3的 stat 命令
    message_num, total_size = conn.stat()
    print('邮件数:%s. 总大小:%s' % (message_num, total_size))
    # 获取服务器上的邮件列表,相当于发送 pop3 的 list 命令
    # resp 保存服务器的响应码,mails 列表保存每封邮件的编号、大小
    resp, mails, octets = conn.list()
    print(resp, mails)
    # 获取指定邮件内容,这里传入总长度,也就是获取最后一封邮件
    # 相当于发送 pop 3 的 retr 命令
    # 使用 resp 保存服务器的响应码,data 保存邮件的内容
    resp, data, octets = conn.retr(len(mails))	# 第31 行
    # 将 data 的所有数据(原本是一个字节列表)拼接在一起
    msg_data = b'
    '.join(data)
    # 将字符串内容解析成邮件,此处一定要指定 policy=default
    msg = BytesParser(policy=default).parsebytes(msg_data)	# 第35 行
    print(type(msg))    # 输出:<class 'email.message.EmailMessage'>
    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
        # 如果 maintype 是 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:
                    # 使用 .txt 作为后缀名
                    ext = '.txt'
                # 为附件生成文件名
                filename = 'part-%03d%s' % (counter, ext)
            counter += 1
            # 将附件写入本地文件中
            with open(os.path.join('.', filename), 'wb') as fp:
                fp.write(part.get_payload(decode=True))
    # 退出服务器,相当于发送 pop 3 的 quit 命令
    conn.quit()
    

    上面这段代码中注释的【第18行】到【第31行】示例了通过 poplib 模块使用 POP 3命令从服务器端下载邮件的步骤,其实就是依次发送 user、pass、stat、list、retr命令的过程。当 retr 命令执行完成后,将得到最后一封邮件的数据:data,该 data 是一个 list 列表,因此需要先将 data 数据拼接成一个整体,然后使用注释的【第35行】代码将邮件数据恢复成 EmailMessage 对象。

    需要注意的是,在创建 BytesParser(解析字节串格式的邮件数据)或Parser(解析字符串格式的邮件数据)时,必须指定 policy=default;否则,BytesParse 或 Parser 解析邮件数据得到的就是过时的 Message 对象,处理起来非常不方便。

    从输出解析得到的 msg 类型可以看到是 EmailMessage,而不是过时的 Message 对象。

    在注释的【第35行】之前,完成的是 poplib 模块收取邮件的第一步:从服务器端下载邮件;在这之后完成的是 poplib 模块收取邮件的第二步:解析邮件内容。

    要获取邮件的发件人、收件人和主题等,可通过 EmailMessage 对象的相应属性获取即可,这与通过 EmailMessage 对象设置发件人、收件和主题等的方式是相对应的。

    接下来调用 EmailMessage 对象的 walk() 方法读取邮件的各部分,该方法返回一个可迭代对象,上面代码中使用 for 循环遍历 walk() 方法的返回值,对邮件内容进行逐项处理。

    • 如果邮件某项的 maintype 是 ’multi part’,则说明这一项是容器,用于包含邮件内容、附件等其他项;
    • 如果邮件某项的 maintype 是 ’text’ , 则说明这一项的内容是文本,通常就是邮件正文或文本附件。对于这种文本内容,直接将其输出到控制台中;
    • 如果邮件某项的maintype 是其他,则说明这一项的内容是附件,程序将附件内容保存在本地文件中。

    运行上面这段代码,会收取邮箱中的最后一封邮件,并将邮件内容输出到控制台,将附件保存到本地文件中。运行后的输出结果如下:

    +OK XMail POP3 Server v1.0 Service Ready(XMail v1.0)
    *cmd* 'USER 123456789@qq.com'
    *cmd* 'PASS abcdefghijklmnopt'
    *cmd* 'STAT'
    *stat* [b'+OK', b'1', b'13221']
    邮件数:1. 总大小:13221
    *cmd* 'LIST'
    b'+OK' [b'1 13221']
    *cmd* 'RETR 1'
    <class 'email.message.EmailMessage'>
    发件人:michael <michael@126.com>
    收件人:123456789@qq.com
    主题:Hello Michael
    第一个收件人名字:123456789
    第一个发件人名字:michael
    这里是邮件正文内容,此处省略
    这里是邮件正文内容的HTML形式,此处省略
    *cmd* 'QUIT'
    

    六、小结

    • 网络编程基础:计算机网络相关知识、IP 地址和端口概念;
    • Python网络编程使用最广泛的工具:urllib 模块及子模块的功能和用法;
    • 基于 TCP 协议和 UDP 协议的 socket 通信,也是基于传输层协议的编程,属于比较底层的、真正的网络编程。
    • 通过 C/S 结构的多人网络聊天工具,掌握基于 TCP协议和UDP协议的网络编程。
    • TCP协议和UDP协议的区别:TCP协议的两个通信实体之间存在虚拟链路连接,在使用基于 TCP 协议的 socket 通信时,要先建立两个 socket 之间的连接,然后通过 send()、recv() 方法来发送和接收数据;
    • 基于 UDP 协议的两个通信实体间并无连接,必须使用 sendto() 方法发送数据,并且要指定数据的目的地址。
    • 通过 selectors 模块实现非阻塞通信方式。
    • 应用层协议网络编程:smtplib和poplib模块。smtplib 用来发送邮件,poplib 用来收取邮件,收发邮件是网络编程中常用功能。通过学习这两个模块了解Python其他应用层协议支持模块的用法。

    练习1、写一个程序,使用 urllib.request 读取 https://www.cnblogs.com/ 首页的内容
    import urllib
    
    with urllib.request.urlopen('https://www.cnblogs.com/') as f:
        # 按字节读取数据
        data = f.read()
        # 将字节数据恢复成字符串
        print(data.decode("utf-8"))
    

    练习2:结合使用 urllib.request 和 re 模块,下载并识别 https://www.cnblogs.com/ 首页的全部链接地址。
    import urllib, re
    
    with urllib.request.urlopen('https://www.cnblogs.com/') as f:
        # 按字节读取数据
        data = f.read()
        # 将字节数据恢复成字符串
        content = data.decode("utf-8")
        link_pattern = '<as+href="([a-zA-Z0-9.:?&=-;/%]+)"'
        result = re.finditer(link_pattern, content)
        for r in result:
            print(r.group(1))
    
  • 相关阅读:
    shell提交hive sql保存运行过程日志
    hive中 exists与left semi join
    hbase shell 导出数据转json
    ubuntu使用
    fast json
    elasticsearch 用户密码配置
    linux 自带php切换xampp
    Ubuntu查看crontab运行日志
    Linux服务器 XAMPP后添加PHP和MYSQL环境变量
    HBuilder 模拟器
  • 原文地址:https://www.cnblogs.com/Micro0623/p/13361560.html
Copyright © 2020-2023  润新知