引言
sockets的歷史悠久衅鹿,它們最早在 1971 年的 APPANET 中使用愕够,后來(lái)成為1983年發(fā)布的Berkeley Software Distribution(BSD)操作系統(tǒng)中的API,稱為Berkeley sockets浪讳。
Web服務(wù)器和瀏覽器并不是使用sockets的唯一程序缰盏,各種規(guī)模和類型的客戶端 - 服務(wù)器(client - server)應(yīng)用程序也得到了廣泛使用。
今天,盡管socket API使用的底層協(xié)議已經(jīng)發(fā)展多年口猜,而且已經(jīng)有新協(xié)議出現(xiàn)形葬,但是底層 API 仍然保持不變。
最常見(jiàn)的套接字應(yīng)用程序類型是客戶端 - 服務(wù)器(client - server)應(yīng)用程序暮的,其中一方充當(dāng)服務(wù)器并等待來(lái)自客戶端的連接笙以。
Socket API介紹
Python中的socket模塊提供了一個(gè)到Berkeley sockets API的接口,其中的主要接口函數(shù)如下:
socket()
bind()
listen()
accept()
connect()
connect_ex()
send()
recv()
close()
這些方便使用的接口函數(shù)和系統(tǒng)底層的功能調(diào)用相一致冻辩。
TCP Sockets
我們準(zhǔn)備構(gòu)建一個(gè)基于 TCP 協(xié)議的socket對(duì)象猖腕,為什么使用 TCP 呢,因?yàn)椋?/p>
可靠性:如果在傳輸過(guò)程中因?yàn)榫W(wǎng)絡(luò)原因?qū)е聰?shù)據(jù)包丟失恨闪,會(huì)有相關(guān)機(jī)制檢測(cè)到并且進(jìn)行重新傳輸
按序到達(dá):一方發(fā)送到另一方的數(shù)據(jù)包是按發(fā)送順序被接收的倘感。
對(duì)比之下,UDP 協(xié)議是不提供這些保證的咙咽,但是它的響應(yīng)效率更高老玛,資源消耗更少。
TCP 協(xié)議并不需要我們自己去實(shí)現(xiàn)钧敞,在底層都已經(jīng)實(shí)現(xiàn)好了蜡豹,我們只需要使用Python的socket模塊,進(jìn)行協(xié)議指定就可以了溉苛。socket.SOCK_STREAM表示使用 TCP 協(xié)議镜廉,socket.SOCK_DGRAM表示使用 UDP 協(xié)議
我們來(lái)看看基于 TCP 協(xié)議socket的 API 調(diào)用和數(shù)據(jù)傳送流程圖,右邊的一列是服務(wù)器端(server)愚战,左邊的一列是客戶端(client)娇唯。
要實(shí)現(xiàn)左邊的處于監(jiān)聽(tīng)狀態(tài)的server,我們需要按照順序調(diào)用這樣幾個(gè)函數(shù):
socket(): 創(chuàng)建一個(gè)socket對(duì)象
bind(): 關(guān)聯(lián)對(duì)應(yīng) ip 地址和端口號(hào)
listen(): 允許對(duì)象接收其他socket的連接
accept(): 接收其他socket的連接寂玲,返回一個(gè)元組(conn, addr)塔插,conn 是一個(gè)新的socket對(duì)象,代表這個(gè)連接拓哟,addr 是連接端的地址信息想许。
client調(diào)用connect()時(shí),會(huì)通過(guò) TCP 的三次握手彰檬,建立連接伸刃。當(dāng)client連接到server時(shí),server會(huì)調(diào)用accept()完成這次連接逢倍。
雙方通過(guò)send()和recv()來(lái)接收和發(fā)送數(shù)據(jù)捧颅,最后通過(guò)close()來(lái)關(guān)閉這次連接,釋放資源较雕。一般server端是不關(guān)閉的碉哑,會(huì)繼續(xù)等待其他的連接挚币。
Echo Client and Server
剛才我們弄清楚了server和client使用socket進(jìn)行通信的過(guò)程,我們現(xiàn)在要自己進(jìn)行一個(gè)簡(jiǎn)單的也是經(jīng)典的實(shí)現(xiàn):server復(fù)述從client接收的信息扣典。
Echo Server
import socket
HOST = '127.0.0.1' # Standard loopback interface address (localhost)
PORT = 65431 # Port to listen on (non-privileged ports are > 1023)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
socket.socket()創(chuàng)建了一個(gè)socket對(duì)象妆毕,它實(shí)現(xiàn)了上下文管理器協(xié)議,我們直接用?with?語(yǔ)句進(jìn)行創(chuàng)建即可贮尖,而且最后不需要調(diào)用close()函數(shù)笛粘。
socket()中的兩個(gè)參數(shù)指明了連接需要的?ip地址類型和傳輸協(xié)議類型,socket.AF_INET 表示使用 IPv4的地址進(jìn)行連接湿硝,socket.SOCK_STREAM 表示使用 TCP 協(xié)議進(jìn)行數(shù)據(jù)的傳輸薪前。
bind()用來(lái)將socket對(duì)象和特定的網(wǎng)絡(luò)對(duì)象和端口號(hào)進(jìn)行關(guān)聯(lián),函數(shù)中的兩個(gè)參數(shù)是由創(chuàng)建socket對(duì)象時(shí)指定的 ip地址類型 決定的关斜,這里使用的是socket.AF_INET(IPv4)示括,因此,bind()函數(shù)接收一個(gè)元組對(duì)象作為參數(shù)(HOST, PORT)
host可以是一個(gè)主機(jī)名痢畜,IP地址垛膝,或者空字符串。如果使用的是 IP地址丁稀,host必須是 IPv4格式的地址字符串吼拥。127.0.0.1是本地環(huán)路的標(biāo)準(zhǔn)寫法,因此只有在主機(jī)上的進(jìn)程才能夠連接到server,如果設(shè)置為空字符串,它可以接受所有合法 IPv4地址的連接娇掏。
port應(yīng)該是從1 - 65535的一個(gè)整數(shù)(0被保留了)辕狰,它相當(dāng)于是一個(gè)窗口和其他的客戶端建立連接,如果想使用1 - 1024的端口唬复,一些系統(tǒng)可能會(huì)要求要有管理員權(quán)限矗积。
listen()使得server可以接受連接,它可以接受一個(gè)參數(shù):backlog敞咧,用來(lái)指明系統(tǒng)可以接受的連接數(shù)量棘捣,雖然同一時(shí)刻只能與一端建立連接,但是其他的連接請(qǐng)求可以被放入等待隊(duì)列中休建,當(dāng)前面的連接斷開(kāi)乍恐,后面的請(qǐng)求會(huì)依次被處理,超過(guò)這個(gè)數(shù)量的連接請(qǐng)求再次發(fā)起后测砂,會(huì)被server直接拒絕茵烈。
從Python 3.5開(kāi)始,這個(gè)參數(shù)是可選的砌些,如果我們不明確指明呜投,它就采用系統(tǒng)默認(rèn)值加匈。如果server端在同一時(shí)刻會(huì)收到大量的連接請(qǐng)求,通常要把這個(gè)值調(diào)大一些仑荐,在Linux中雕拼,可以在/proc/sys/net/core/somaxconn看到值的情況,詳細(xì)請(qǐng)參閱:
Will increasing net.core.somaxconn make a difference?
How TCP backlog works in Linux
accept()監(jiān)聽(tīng)連接的建立粘招,是一個(gè)阻塞式調(diào)用啥寇,當(dāng)有client連接之后,它會(huì)返回一個(gè)代表這個(gè)連接的新的socket對(duì)象和代表client地址信息的元組洒扎。對(duì)于 IPv4 的地址連接辑甜,地址信息是?(host, port),對(duì)于 IPv6 逊笆,(host, port, flowinfo, scopeid)
有一件事情需要特別注意栈戳,accept()之后,我們獲得了一個(gè)新的socket對(duì)象难裆,它和server以及client都不同子檀,我們用它來(lái)進(jìn)行和client的通信。
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
conn是我們新獲得的socket對(duì)象乃戈,conn.recv()也是一個(gè)阻塞式調(diào)用褂痰,它會(huì)等待底層的 I/O 響應(yīng),直到獲得數(shù)據(jù)才繼續(xù)向下執(zhí)行症虑。外面的while循環(huán)保證server端一直監(jiān)聽(tīng)缩歪,通過(guò)conn.sendall將數(shù)據(jù)再發(fā)送回去。
Echo Client
import socket
HOST = '127.0.0.1' # The server's hostname or IP address
PORT = 65431 # The port used by the server
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall("Hello, world".encode("utf8"))
data = s.recv(1024)
print('Received', data.decode("utf8"))
和server相比谍憔,client更加簡(jiǎn)單匪蝙,先是創(chuàng)建了一個(gè)socket對(duì)象,然后將它和server連接习贫,通過(guò)s.sendall()將信息發(fā)送給server逛球,通過(guò)s.recv()獲得來(lái)自server的數(shù)據(jù),然后將其打印輸出苫昌。
在發(fā)送數(shù)據(jù)時(shí)颤绕,只支持發(fā)送字節(jié)型數(shù)據(jù),所以我們要將需要發(fā)送的數(shù)據(jù)進(jìn)行編碼祟身,在收到server端的回應(yīng)后奥务,將得到的數(shù)據(jù)進(jìn)行解碼,就能還原出我們能夠識(shí)別的字符串了袜硫。
啟動(dòng)程序
我們要先啟動(dòng)server端氯葬,做好監(jiān)聽(tīng)準(zhǔn)備,然后再啟動(dòng)client端父款,進(jìn)行連接溢谤。
這個(gè)信息是在client連接后打印出來(lái)的瞻凤。
可以使用netstat這個(gè)命令查看socket的狀態(tài),更詳細(xì)使用可以查閱幫助文檔世杀。
查看系統(tǒng)中處于監(jiān)聽(tīng)狀態(tài)的socket阀参,過(guò)濾出了使用 TCP協(xié)議 和 IPv4 地址的對(duì)象:
如果先啟動(dòng)了client,會(huì)有下面這個(gè)經(jīng)典的錯(cuò)誤:
造成的原因可能是端口號(hào)寫錯(cuò)了瞻坝,或者server根本就沒(méi)運(yùn)行蛛壳,也可能是在server端存在防火墻阻值了連接建立,下面是一些常見(jiàn)的錯(cuò)誤異常:
Exceptionerrno ConstantDescriptionBlockingIOErrorEWOULDBLOCKResource temporarily unavailable. For example, in non-blocking mode, when calling send() and the peer is busy and not reading, the send queue (network buffer) is full. Or there are issues with the network. Hopefully this is a temporary condition.OSErrorADDRINUSEAddress already in use. Make sure there’s not another process running that’s using the same port number and your server is setting the socket option SO_REUSEADDR: socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1).ConnectionResetErrorECONNRESETConnection reset by peer. The remote process crashed or did not close its socket properly (unclean shutdown). Or there’s a firewall or other device in the network path that’s missing rules or misbehaving.TimeoutErrorETIMEDOUTOperation timed out. No response from peer.ConnectionRefusedErrorECONNREFUSEDConnection refused. No application listening on specified port.
連接的建立
現(xiàn)在我們仔細(xì)看一下server和client是怎樣建通信的:
當(dāng)使用環(huán)路網(wǎng)絡(luò)((IPv4 address 127.0.0.1 or IPv6 address ::1))的時(shí)候所刀,數(shù)據(jù)沒(méi)有離開(kāi)過(guò)主機(jī)跑到外部的網(wǎng)絡(luò)衙荐。如圖所示,環(huán)路網(wǎng)絡(luò)是在主機(jī)內(nèi)部建立的浮创,數(shù)據(jù)就經(jīng)過(guò)它來(lái)發(fā)送忧吟,從主機(jī)上運(yùn)行的一個(gè)程序發(fā)送到另一個(gè)程序,從主機(jī)發(fā)到主機(jī)斩披。這就是為什么我們喜歡說(shuō)環(huán)路網(wǎng)絡(luò)和 IP地址 127.0.0.1(IPv4) 或 ::1(IPv6) 都表示主機(jī)
如果server使用的時(shí)其他的合法IP地址溜族,它就會(huì)通過(guò)以太網(wǎng)接口與外部網(wǎng)絡(luò)建立聯(lián)系:
如何處理多端連接
echo server最大的缺點(diǎn)就是它同一時(shí)間只能服務(wù)一個(gè)client,直到連接的斷開(kāi)垦沉,echo client同樣也有不足煌抒,當(dāng)client進(jìn)行如下操作時(shí),有可能s.recv()只返回了一個(gè)字節(jié)的數(shù)據(jù)厕倍,數(shù)據(jù)并不完整寡壮。
data = s.recv(1024)
這里所設(shè)定的參數(shù) 1024 表示單次接收的最大數(shù)據(jù)量,并不是說(shuō)會(huì)返回 1024 字節(jié)的數(shù)據(jù)讹弯。在server中使用的send()與之類似况既,調(diào)用后它有一個(gè)返回值,標(biāo)示已經(jīng)發(fā)送出去的數(shù)據(jù)量组民,可能是小于我們實(shí)際要發(fā)送的數(shù)據(jù)量坏挠,比如說(shuō)有 6666 字節(jié)的數(shù)據(jù)要發(fā)送,用上面的發(fā)送方式要發(fā)送很多此才行邪乍,也就是說(shuō)一次調(diào)用send()數(shù)據(jù)并沒(méi)有被完整發(fā)送,我們需要自己做這個(gè)檢查來(lái)確保數(shù)據(jù)完整發(fā)送了对竣。
因此庇楞,這里使用了sendall(),它會(huì)不斷地幫我們發(fā)送數(shù)據(jù)直到數(shù)據(jù)全部發(fā)送或者出現(xiàn)錯(cuò)誤否纬。
所以吕晌,目前有兩個(gè)問(wèn)題:
怎樣同時(shí)處理多個(gè)連接?
怎樣調(diào)用send()和recv()直到數(shù)據(jù)全部發(fā)送或接收临燃。
要實(shí)現(xiàn)并發(fā)睛驳,傳統(tǒng)方法是使用多線程烙心,最近比較流行的方法是使用在Python3.4中引入的異步IO模塊asyncio。
這里準(zhǔn)備用更加傳統(tǒng)乏沸,但是更容易理解的方式來(lái)實(shí)現(xiàn)淫茵,基于系統(tǒng)底層的一個(gè)調(diào)用:select(),Python中也提供了
對(duì)應(yīng)的模塊:selectors
select()通過(guò)了一種機(jī)制蹬跃,它來(lái)監(jiān)聽(tīng)操作發(fā)生情況匙瘪,一旦某個(gè)操作準(zhǔn)備就緒(一般是讀就緒或者是寫就緒),然后將需要進(jìn)行這些操作的應(yīng)用程序select出來(lái)蝶缀,進(jìn)行相應(yīng)的讀和寫操作丹喻。到這里,你可能會(huì)發(fā)現(xiàn)這并沒(méi)有實(shí)現(xiàn)并發(fā)翁都,但是它的響應(yīng)速度非嘲郏快,通過(guò)異步操作柄慰,足夠模擬并發(fā)的效果了鳍悠。
Muti-Connection Client and Server
Multi-Connection Server
import selectors
sel = selectors.DefaultSelector()
# ...
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
和echo server最大的不同就在于,通過(guò)lsock.setblocking(False)先煎,將這個(gè)socket對(duì)象設(shè)置成了非阻塞形式贼涩,與sel.select()一起使用,就可以在一個(gè)或多個(gè)socket對(duì)象上等待事件薯蝎,然后在數(shù)據(jù)準(zhǔn)備就緒時(shí)進(jìn)行數(shù)據(jù)的讀寫操作遥倦。
sel.register()給server注冊(cè)了我們需要的事件,對(duì)server來(lái)說(shuō)占锯,我們需要 I/O 可讀袒哥,從而進(jìn)行client發(fā)送數(shù)據(jù)的讀入,因此消略,通過(guò)selector.EVENT_READ來(lái)指明堡称。
data用來(lái)存儲(chǔ)和socket有關(guān)的任何數(shù)據(jù),當(dāng)sel.select()返回結(jié)果時(shí)艺演,它也被返回却紧,我們用它作為一個(gè)標(biāo)志,來(lái)追蹤擁有讀入和寫入操作的socket對(duì)象胎撤。
接下來(lái)是事件循環(huán):
import selectors
sel = selectors.DefaultSelector()
# ...
while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
service_connection(key, mask)
sel.select(timeout=None)是一個(gè)阻塞式調(diào)用晓殊,直到有socket對(duì)象準(zhǔn)備好了 I/O 操作,或者等待時(shí)間超過(guò)設(shè)定的timeout伤提。它將返回(key, events)這類元組構(gòu)成的一個(gè)列表巫俺,每一個(gè)對(duì)應(yīng)一個(gè)就緒的socket對(duì)象。
key是一個(gè)SeletorKey類型的實(shí)例肿男,它有一個(gè)fileobj的屬性介汹,這個(gè)屬性就是sokect對(duì)象却嗡。
mask是就緒操作的狀態(tài)掩碼。
如果key.data is None嘹承,我們就知道窗价,這是一個(gè)server對(duì)象,于是要調(diào)用accept()方法赶撰,用來(lái)等待client的連接舌镶。不過(guò)我們要調(diào)用我們自己的accept_wrapper()函數(shù),里面還會(huì)包含其他的邏輯豪娜。
如果key.data is not None餐胀,我們就知道,這是一個(gè)client對(duì)象瘤载,它帶著數(shù)據(jù)來(lái)建立連接啦否灾!然后我們要為它提供服務(wù),于是就調(diào)用service_connection(key, mask)鸣奔,完成所有的服務(wù)邏輯墨技。
def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print('accepted connection from', addr)
conn.setblocking(False)
data = types.SimpleNamespace(addr=addr, inb=b'', outb=b'')
events = selectors.EVENT_READ | selectors.EVENT_WRITE
sel.register(conn, events, data=data)
這個(gè)函數(shù)用來(lái)處理與client的連接,使用conn.setblocking(False)將該對(duì)象設(shè)置為非阻塞狀態(tài)挎狸,這正是我們?cè)谶@個(gè)版本的程序中所需要的扣汪,否則,整個(gè)server會(huì)停止锨匆,直到它返回崭别,這意味著其他socket對(duì)象進(jìn)入等待狀態(tài)。
然后恐锣,使用types.SimplleNamespace()構(gòu)建了一個(gè)data對(duì)象茅主,存儲(chǔ)我們想保存的數(shù)據(jù)和socket對(duì)象。
因?yàn)閿?shù)據(jù)的讀寫都是通過(guò)conn土榴,所以使用selectors.EVENT_READ | selectors.EVENT_WRITE诀姚,然后用sel.register(conn, events, data=data)進(jìn)行注冊(cè)。
def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
recv_data = sock.recv(1024) # Should be ready to read
if recv_data:
data.outb += recv_data
else:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if data.outb:
print('echoing', repr(data.outb), 'to', data.addr)
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
這就時(shí)服務(wù)邏輯的核心玷禽,key中包含了socket對(duì)象和data對(duì)象赫段,mask是已經(jīng)就緒操作的掩碼。根據(jù)sock可以讀矢赁,將數(shù)據(jù)保存在data.outb中瑞佩,這也將成為寫出的數(shù)據(jù)。
if recv_data:
data.outb += recv_data
else:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
如果沒(méi)有接收到數(shù)據(jù)坯台,說(shuō)明client數(shù)據(jù)發(fā)完了,sock的狀態(tài)不再被追蹤瘫寝,然后關(guān)閉這次連接蜒蕾。
Multi-Connection Client
messages = [b'Message 1 from client.', b'Message 2 from client.']
def start_connections(host, port, num_conns):
server_addr = (host, port)
for i in range(0, num_conns):
connid = i + 1
print('starting connection', connid, 'to', server_addr)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
sock.connect_ex(server_addr)
events = selectors.EVENT_READ | selectors.EVENT_WRITE
data = types.SimpleNamespace(connid=connid,
msg_total=sum(len(m) for m in messages),
recv_total=0,
messages=list(messages),
outb=b'')
sel.register(sock, events, data=data)
使用connect_ex()而不是connect()稠炬,因?yàn)閏onnect()會(huì)立即引發(fā)BlockingIOError異常。connect_ex()只返回錯(cuò)誤碼 errno.EINPROGRESS咪啡,而不是在連接正在進(jìn)行時(shí)引發(fā)異常首启。連接完成后,socket對(duì)象就可以進(jìn)行讀寫撤摸,并通過(guò)select()返回毅桃。
連接建立完成后,我們使用了types.SimpleNamespace構(gòu)建出和socket對(duì)象一同保存的數(shù)據(jù)准夷,里面的messages對(duì)我們要發(fā)送的數(shù)據(jù)做了一個(gè)拷貝钥飞,因?yàn)樵诤罄m(xù)的發(fā)送過(guò)程中,它會(huì)被修改衫嵌。client需要發(fā)送什么读宙,已經(jīng)發(fā)送了什么以及已經(jīng)接收了什么都要進(jìn)行追蹤,總共要發(fā)送的數(shù)據(jù)字節(jié)數(shù)也保存在了data對(duì)象中楔绞。
def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
recv_data = sock.recv(1024) # Should be ready to read
if recv_data:
print('received', repr(recv_data), 'from connection', data.connid)
data.recv_total += len(recv_data)
if not recv_data or data.recv_total == data.msg_total:
print('closing connection', data.connid)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if not data.outb and data.messages:
data.outb = data.messages.pop(0)
if data.outb:
print('sending', repr(data.outb), 'to connection', data.connid)
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
client要追蹤來(lái)自server的數(shù)據(jù)字節(jié)數(shù)结闸,如果收到的數(shù)據(jù)字節(jié)數(shù)和發(fā)送的相等,或者有一次沒(méi)有收到數(shù)據(jù)酒朵,說(shuō)明數(shù)據(jù)接收完成桦锄,本次服務(wù)目的已經(jīng)達(dá)成,就可以關(guān)閉這次連接了蔫耽。
data.outb用來(lái)維護(hù)發(fā)送的數(shù)據(jù)结耀,前面提到過(guò),一次發(fā)送不一定能將數(shù)據(jù)全部送出针肥,使用data.outb = data.outb[sent:]來(lái)更新數(shù)據(jù)的發(fā)送饼记。發(fā)送完畢后,再messages中取出數(shù)據(jù)準(zhǔn)備再次發(fā)送慰枕。
可以在這里看到最后的完整代碼:
server.py
client.py
最后的運(yùn)行效果如下:
還是要先啟動(dòng)server具则,進(jìn)入監(jiān)聽(tīng)狀態(tài),然后client啟動(dòng)具帮,與server建立兩條連接博肋,要發(fā)送的信息有兩條,這里分開(kāi)發(fā)送蜂厅,先將fist message分別發(fā)送到server匪凡,然后再發(fā)送second message。server端收到信息后進(jìn)行暫時(shí)保存掘猿,當(dāng)兩條信息都收到了才開(kāi)始進(jìn)行echo病游,client端收到完整信息后表示服務(wù)結(jié)束,斷開(kāi)連接。
注:喜歡python + qun:839383765 可以獲取Python各類免費(fèi)最新入門學(xué)習(xí)資料衬衬!