瘋狂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 模塊,開發一個簡單的郵件客戶端程序,該客戶端程序既可以發送郵件,也可以收取郵件。

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