9.13. TCP sockets

TCP socket có hai dạng trông khác nhau nhưng dùng chung một kiểu cơ bản: socket client dùng connect() để kết nối tới máy chủ từ xa, và socket server dùng bind(), listen(), và accept() để chấp nhận kết nối đến. Cả hai vai trò đều dùng cùng lớp socket được giới thiệu trên Đối tượng socket; chỉ khác nhau ở các phương thức được gọi.

9.13.1. TCP client

Client đơn giản nhất mở một kết nối, gửi yêu cầu, đọc phản hồi rồi đóng lại:

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() thực hiện quy trình bắt tay ba bước được trình bày trên TCP -- luồng byte đáng tin cậy và trả về khi kết nối đã được mở. send() ghi các byte vào kết nối; recv() đọc tối đa một số byte nhất định từ kết nối đó. Khi ứng dụng hoàn tất, close() sẽ đóng kết nối.

Đoạn tập lệnh tương tự được bọc trong cú pháp câu lệnh with từ Đối tượng socket, để socket được đóng ngay cả khi có ngoại lệ xảy ra:

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. Đọc cho đến khi hoàn tất

Một lần gọi recv() trả về tối đa số byte được yêu cầu -- có thể trả về ít hơn, vì TCP là một luồng dữ liệu liên tục chứ không phải một chuỗi các tin nhắn. Ứng dụng phải tiếp tục đọc cho đến khi nhận được toàn bộ phản hồi:

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

Vòng lặp kết thúc khi recv() trả về một bytes rỗng. Điều này xảy ra khi phía bên kia đã đóng hẳn nửa kết nối của mình; ứng dụng xem "kết thúc luồng" tương đương với "kết thúc tin nhắn" trong kiểu giao thức này.

9.13.1.2. Gửi cho đến khi hoàn tất

Điều tương tự cũng áp dụng cho send(): nó có thể gửi ít byte hơn yêu cầu, trả về số byte thực sự đã ghi. Với dữ liệu lớn, hãy thử lại với phần còn chưa gửi:

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

sendall() thực hiện vòng lặp này bên trong, vì vậy hầu hết code chỉ cần gọi hàm đó và tránh việc thử lại thủ công:

s.sendall(some_big_bytes)

9.13.2. TCP server

Phía server gồm bốn bước: yêu cầu một cổng, chuyển socket sang chế độ lắng nghe, chấp nhận kết nối từng cái một, giao tiếp trên mỗi socket được chấp nhận. Một echo server tối giản:

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()

Từng bước một:

  • bind() yêu cầu một host và cổng trên camera. "0.0.0.0" chấp nhận trên bất kỳ giao diện nào; thay bằng một IP cụ thể sẽ giới hạn listener trên giao diện đó.

  • listen() chuyển socket từ một socket thông thường sang socket lắng nghe. Đối số là backlog -- số lượng kết nối đang chờ mà MicroPython sẽ xếp hàng trong khi ứng dụng đang bận. Chọn một con số nhỏ; 1 là đủ cho hầu hết các trường hợp.

  • accept() chặn cho đến khi có client kết nối, rồi trả về (conn, addr): một socket mới đại diện cho kết nối này, và địa chỉ của client. Socket lắng nghe vẫn tiếp tục mở để chấp nhận thêm kết nối.

  • Toàn bộ byte của cuộc trò chuyện đi qua conn, socket mới. Đọc và ghi dùng các lệnh gọi recv() / send() giống như ở phía client.

  • Khi client đóng kết nối, recv() trả về b"";vòng lặp bên trong kết thúc và server đóng kết nối của mình bằng close().

Vòng while True bên ngoài quay lại accept() để chờ client tiếp theo. Server xử lý từng client một trong cấu trúc này; để xử lý nhiều client song song cần dùng luồng (threads) hoặc asyncio. Chủ đề sau là nội dung của trang tiếp theo.

9.13.3. Những lỗi thường gặp

  • Coi recv() như có ranh giới tin nhắn. Thực tế không phải vậy. Hai lần gọi send(b"hi") có thể đến như một lần recv(4) với b"hihi", hoặc như hai lần recv(2). Ứng dụng phải thêm cơ chế phân tách tin nhắn nếu ranh giới quan trọng -- một ký tự xuống dòng, tiền tố độ dài, hoặc bất cứ thứ gì phù hợp.

  • Quên thử lại khi gửi thiếu byte. Dùng sendall() cho bất kỳ dữ liệu nào vượt quài vài trăm byte.

  • Quên đóng socket đã được chấp nhận. Mỗi conn là một socket riêng biệt; đóng socket lắng nghe không đóng các socket đã chấp nhận. Dùng khối with cho cả hai sẽ tránh được lỗi này:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • Bind lại vào cổng đang ở trạng thái TIME_WAIT. Khi server khởi động lại trong vài giây sau khi đóng, bind() có thể thất bại với lỗi "address in use" vì MicroPython vẫn đang giữ cổng đó cho kết nối trước. Gọi server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) trước bind() để khắc phục điều này.

9.13.4. Tiếp theo là gì

Chặn ở accept() có nghĩa là server chỉ phục vụ được một client tại một thời điểm. Chặn ở recv() có nghĩa là một client chậm sẽ làm treo toàn bộ vòng lặp. Giải pháp tiêu chuẩn trên camera là asyncio -- chạy mỗi kết nối như một task riêng, để event loop điều phối giữa chúng. Trang tiếp theo trình bày phiên bản asyncio của mọi thứ trên trang này.