9.13. TCP sockets

TCP sockets มีสองรูปแบบที่ดูแตกต่างกันแต่ใช้ประเภทพื้นฐานเดียวกัน ได้แก่ client sockets ที่ connect() ไปยังเซิร์ฟเวอร์ระยะไกล และ server sockets ที่ bind(), listen(), และ accept() การเชื่อมต่อขาเข้า ทั้งสองบทบาทใช้คลาส socket เดียวกันที่แนะนำไว้ใน อ็อบเจกต์ Socket โดยแตกต่างกันเพียงเมธอดที่เรียกใช้เท่านั้น

9.13.1. TCP client

client ที่ง่ายที่สุดจะเปิดการเชื่อมต่อ ส่งคำขอ อ่านการตอบกลับ แล้วปิด:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.20", 9000))

s.send(b"hello\n")
reply = s.recv(1024)
print("reply:", reply)

s.close()

connect() ทำการ three-way handshake ที่อธิบายไว้ใน TCP -- สตรีมไบต์ที่เชื่อถือได้ และคืนค่าเมื่อการเชื่อมต่อเปิดแล้ว send() เขียนไบต์ลงในการเชื่อมต่อ และ recv() อ่านไบต์จากการเชื่อมต่อได้สูงสุดตามจำนวนที่กำหนด เมื่อแอปพลิเคชันเสร็จสิ้น close() จะปิดการเชื่อมต่อ

สคริปต์เดียวกันที่ห่อด้วยรูปแบบ with-statement จาก อ็อบเจกต์ Socket เพื่อให้ socket ถูกปิดแม้ว่าจะเกิดข้อผิดพลาด:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("192.168.1.20", 9000))
    s.send(b"hello\n")
    print(s.recv(1024))

9.13.1.1. อ่านจนครบ

recv() ครั้งเดียวจะคืนค่าไบต์ ได้มากสุด ตามจำนวนที่ขอ -- อาจคืนค่าน้อยกว่า เนื่องจาก TCP เป็น stream ไม่ใช่ลำดับของข้อความ แอปพลิเคชันต้องอ่านต่อไปจนกว่าจะได้รับการตอบกลับครบถ้วน:

chunks = []
while True:
    chunk = s.recv(1024)
    if not chunk:                  # empty bytes -> other side closed
        break
    chunks.append(chunk)
reply = b"".join(chunks)

ลูปสิ้นสุดเมื่อ recv() คืนค่า bytes ว่าง ซึ่งเกิดขึ้นเมื่ออีกฝั่งปิดการเชื่อมต่อครึ่งหนึ่งของตนอย่างสะอาด แอปพลิเคชันอ่าน "end of stream" เหมือนกับ "end of message" ในรูปแบบโปรโตคอลนี้

9.13.1.2. ส่งจนครบ

การเตือนในทางกลับกันใช้กับ send() คือ อาจส่งไบต์ น้อยกว่า ที่ขอ โดยคืนค่าจำนวนไบต์ที่เขียนจริง สำหรับข้อมูลขนาดใหญ่ ให้ลองส่งส่วนที่ยังไม่ได้ส่งซ้ำ:

payload = some_big_bytes
while payload:
    n = s.send(payload)
    payload = payload[n:]

sendall() ทำลูปนี้ภายใน ดังนั้นโค้ดส่วนใหญ่สามารถเรียกใช้แทนเพื่อหลีกเลี่ยงการลองซ้ำด้วยตนเอง:

s.sendall(some_big_bytes)

9.13.2. TCP server

ฝั่งเซิร์ฟเวอร์มีสี่ขั้นตอน ได้แก่ จองพอร์ต สลับ socket เป็นโหมดรับฟัง รับการเชื่อมต่อทีละรายการ และสื่อสารบน socket ที่ยอมรับแต่ละรายการ ตัวอย่าง echo server ขั้นต่ำ:

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 9000))
server.listen(1)
print("listening on port 9000")

while True:
    conn, addr = server.accept()
    print("connection from", addr)

    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.send(data)            # echo back

    conn.close()

ทีละขั้นตอน:

  • bind() จองโฮสต์และพอร์ตบน camera "0.0.0.0" รับบนทุกอินเทอร์เฟซ การเปลี่ยนเป็น IP เฉพาะจะจำกัด listener ไปยังอินเทอร์เฟซนั้น

  • listen() สลับ socket จาก socket ปกติเป็น listening socket อาร์กิวเมนต์คือ backlog -- จำนวนการเชื่อมต่อที่รอดำเนินการที่ MicroPython จะจัดคิวขณะที่แอปพลิเคชันกำลังทำงาน เลือกตัวเลขเล็กน้อย โดย 1 เพียงพอสำหรับกรณีส่วนใหญ่

  • accept() บล็อกจนกว่า client จะเชื่อมต่อ แล้วคืนค่า (conn, addr) ได้แก่ socket ใหม่ ที่แทนการเชื่อมต่อนี้ และที่อยู่ของ client listening socket เองยังคงเปิดอยู่เพื่อรับการเชื่อมต่อเพิ่มเติม

  • ไบต์ทั้งหมดสำหรับการสนทนาไหลผ่าน conn ซึ่งเป็น socket ใหม่ การอ่านและเขียนใช้เมธอด recv() / send() เดียวกับฝั่ง client

  • เมื่อ client ปิดการเชื่อมต่อ recv() จะคืนค่า b"" ลูปภายในสิ้นสุดและเซิร์ฟเวอร์ปิดฝั่งของตนด้วย close()

while True ด้านนอกกลับไปที่ accept() เพื่อรอ client รายถัดไป เซิร์ฟเวอร์จัดการ client ทีละรายในรูปแบบนี้ การรัน client หลายรายพร้อมกันต้องใช้ threads หรือ asyncio ซึ่งหัวข้อหลังเป็นเนื้อหาของหน้าถัดไป

9.13.3. ปัญหาที่พบบ่อย

  • การปฏิบัติต่อ recv() เหมือนเป็นข้อความ ไม่ใช่เช่นนั้น การเรียก send(b"hi") สองครั้งอาจมาถึงเป็น recv(4) เดียวที่ได้ b"hihi" หรือเป็น recv(2)สองครั้ง แอปพลิเคชันต้องเพิ่มการกำหนดขอบเขตข้อความหากขอบเขตข้อความมีความสำคัญ เช่น ขึ้นบรรทัดใหม่ คำนำหน้าความยาว หรือวิธีอื่น

  • ลืมลองซ้ำเมื่อส่งไม่ครบ ใช้ sendall() สำหรับข้อมูลที่มากกว่าไม่กี่ร้อยไบต์

  • ลืมปิด socket ที่ยอมรับแล้ว conn แต่ละรายการเป็น socket แยกต่างหาก การปิด listening socket ไม่ได้ปิด socket ที่ยอมรับแล้ว การใช้บล็อก with บนทั้งสองทำให้ยากที่จะทำผิด:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • การ bind ซ้ำกับพอร์ตที่ยังอยู่ใน TIME_WAIT เมื่อเซิร์ฟเวอร์รีสตาร์ทภายในไม่กี่วินาทีหลังปิด bind() อาจล้มเหลวด้วย "address in use" เนื่องจาก MicroPython ยังถือพอร์ตสำหรับการเชื่อมต่อก่อนหน้าอยู่ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) ก่อน bind() จะแก้ปัญหานี้

9.13.4. ขั้นตอนถัดไป

การบล็อกที่ accept() หมายความว่าเซิร์ฟเวอร์สามารถให้บริการ client ได้ทีละรายเท่านั้น การบล็อกที่ recv() หมายความว่า client ที่ช้าเพียงรายเดียวทำให้ลูปทั้งหมดหยุดนิ่ง คำตอบมาตรฐานบน camera คือ asyncio -- รันแต่ละการเชื่อมต่อเป็น task ของตัวเอง ให้ event loop จัดการการสลับระหว่างกัน หน้าถัดไปครอบคลุมเวอร์ชัน asyncio ของทุกสิ่งในหน้านี้