سوکتها در سطح سیستمعامل و پایتون
از چند روز پیش که دور دوم مطالعه کتاب HTTP: The Definitive Guide رو شروع کردم یاد جمله حسینناصر افتادم که میگه:
من با هیچ ابزاری کار نمیکنم مگر اینکه ۱۰۰ درصد جزییات داخلیش رو عمیقا درک کرده باشم.
همین شد که به بخش سوکتها که رسیدیم تصمیم گرفتم یکبار دستور اجرای سرور HTTP و همینطور هندل کردن یک درخواست HTTP رو توی پایتون دیباگ کنم. هدفم هم این بود internal کتابخونه استاندارد پایتون و همینطور اینترفیس WSGI رو بیشتر درک کنم به علاوه برام جالب بود که بدونم از سمت سیستم عامل هم این وسط چه اتفاقاتی میوفته؟
خیلی خلاصه بخوام بگم بازیگر اصلی این وسط یه مفهومی هست به نام سوکت که هم سیستمعامل و هم کتابخونه استاندارد پایتون یه API برای اون دارن و در حقیقت اون چرخه request - response بین کلاینت و سرور از طریق همین سوکت هندل میشه.
این نوشته دو بخش داره:
-
اول اینکه سوکت چی هست و در سطح سیستمعامل چه عملیاتی میشه باهاش انجام داد؟
-
دوم اینکه وقتی تو پایتون یه سرور HTTP اجرا میکنیم و یک درخواست از سمت کلاینت میاد تا پاسخ رو دریافت کنه چه فراخوانیهایی در پایتون انجام میشه و پشتصحنه اونها چه فراخوانیهایی در سطح سیستمعامل انجام میشه؟
سوکت در سطح سیستمعامل
1️⃣ سوکت چی هست؟!
سوکت رو میشه یه ساختار داده در نظر گرفت که processها از طریق اون میتونن با هم صحبت کنن. این processها میتونن روی یک ماشین باشن که عملا نوع سوکتشون میشه Unix Socket یا اینکه میتونن روی دو ماشین باشن و از طریق شبکه با هم صحبت کنن (کلاینت و سرور) که نوع سوکتهاش میشه TCP یا UDP که اینجا روی سوکتهای TCP تمرکز میکنیم.
سوکتها ساختار داده لازم برای اندپوینتهای TCPرو ایجاد میکنن و امکان میدن که جریان دادهها خوانده و نوشته بشن. API مربوط به اون تمام جزئیات مربوط به فرآیند handshaking
و سایر لایههای زیرساختی شبکه و جریان دادهها مربوط به بستههای IP رو پنهان میکنه. به عبارت دیگه تو مدل OSI شبکه برنامهنویسها در لایه ۷ (اپلیکیشن) با سوکتها سروکار دارن و از طریق کتابخونههای سطح بالا فراخوانیها سیستمی مربوط به سوکت رو انجام میدن تا متناظر با اون فراخوانیها در لایه 4 (شبکه) دادهها بین دو تا process در شبکه منتقل بشن.
در سطح سیستمعامل، سوکت رو میشه شبیه یک فایل در نظر گرفت که میشه روی اون عملیات خواندن و نوشتن انجام داد. وقتی یک سرور راهاندازی میشه سیستمعامل ابتدا یک سوکت شنونده (Listening Socket)
ایجاد میکنه که روی یک آدرس IP و پورت خاص (مثلاً 0.0.0.0:8000) آماده دریافت درخواستهاست.
این سوکت صرفاً مسئول دریافت درخواستهای اتصال (SYN)
هست و دادهٔ واقعی رو منتقل نمیکنه. به محض اینکه کلاینت درخواست اتصال میده، سیستمعامل یک سوکت جدید به نام سوکت اتصال (Connection Socket)
میسازه که مختص همون کلاینت است. بنابراین هر کلاینت سوکت مستقل خودش یا File Descriptor
خودش رو داره و دادههایش با سایر کلاینتها تداخل پیدا نمیکنه.
2️⃣ عملیات قابل انجام با سوکت در سطح سیستمعامل
در چرخه درخواست پاسخ بین کلاینت و سرور bind و listen و accept و read و write پنج عملیات اصلی در این فرآیند هستند. در جدول زیر همه عملیات قابل انجام توسط API سوکت در سطح سیستمعامل خلاصه شدهاند:
API سوکت در سطح سیستمعامل | توضیحات |
---|---|
(< parameters >)socket | ایجاد یک سوکت جدید بدون نام و بدون اتصال |
(s, local IP : port )bind | اختصاص یک شماره پورت و اینترفیس به سوکت |
(s, remote IP : port )connect | ایجاد یک اتصال TCP به سوکت و یک host و پورت remote |
(…, s)listen | تغییر وضعیت سوکت برای دریافت درخواست |
(s)accept | منتظر ماندن برای ایجاد یک اتصال به پورت |
(s, buffer, n)read | خواندن n بایت از سوکت به داخل بافر |
(s, buffer, n)write | نوشتن n بایت از بافر به داخل سوکت |
(s)close | بستن کامل اتصال TCP |
(s, side)shutdown | بستن فقط ورودی یا خروجی اتصال TCP |
(…, s)getsockopt | خواندن مقدار یک پیکربندی داخلی سوکت |
(…, s)setsockopt | تغییر مقدار یک پیکربندی داخلی سوکت |
سوکت در سطح کتابخانه استاندارد پایتون
1️⃣ اینترفیس WSGI
WSGI یا Web Server Gateway Interface یک استاندارد رسمی در پایتون برای برقراری ارتباط بین وبسرورها و فریمورکها یا وباپلیکیشنهای پایتونی هست. پیش از معرفی WSGI، هر وبسرور و فریمورک روش خاص خودش رو برای تبادل داده پیادهسازی میکرد و این باعث ناسازگاری و پیچیدگی توسعه میشد. WSGI این مشکل رو با تعریف یک قرارداد ساده حل کرد:
وبسرور درخواست HTTP رو به شکل دادههای ساختیافته به اپلیکیشن منتقل میکنه و اپلیکیشن هم پاسخ HTTP رو به همان شکل استاندارد به سرور برمیگردونه.
این مدل، اپلیکیشن و سرور رو از هم تفکیک میکنه و به توسعهدهنده اجازه میده بدون وابستگی به نوع وبسرور، اپلیکیشن خودش رو پیادهسازی و اجرا کنه. مثلا وقتی با جنگو سرور لوکال رو راهاندازی میکنم کد زیر اجرا میشه که یه instance از کلاس WSGIServer
رو ایجاد میکنه و بهش میگه که تمامی درخواستهای کلاینتها رو با WSGIRequestHandler
پاسخ بده:
# file: django/core/servers/basehttp.py
def run(addr, port, wsgi_handler, ipv6=False, threading=False, on_bind=None,server_cls=WSGIServer):
server_address = (addr, port)
if threading:
httpd_cls = type("WSGIServer", (socketserver.ThreadingMixIn, server_cls), {})
else:
httpd_cls = server_cls
httpd = httpd_cls(server_address, WSGIRequestHandler, ipv6=ipv6)
if on_bind is not None:
on_bind(getattr(httpd, "server_port", port))
if threading:
httpd.daemon_threads = True
httpd.set_app(wsgi_handler)
httpd.serve_forever()
متدهایی که اینجا لازمه بیشتر دربارهاش عمیق بشیم یکی همین serve_forever
مربوط به WSGIServer
هست و یکی دیگه هم داخل کلاس WSGIRequestHandler
هست به نام handle_one_request
ولی قبل اینها لازمه درباره سوکتها در کتابخونه استاندارد پایتون یکمی بیشتر بدونیم .
2️⃣ سوکتها در کتابخانه استاندارد پایتون
کتابخونه استاندارد socket
پایتون یه wrapper روی همون API سطح سیستمعامل برای سوکتهاست که عملا اسم متدهای پایتونی با اسم عملیات سیستمعاملی مشابه و یکی هست. مثلا وقتی توی پایتون ()socket.socket
رو فراخوانی کنیم در اصل به سیستمعامل درخواست دادیم که یک سوکت جدید ساخته بشه که این کار از طریق فراخوانی سیستمی ()socket
انجام میشه.
API سوکت در سطح پایتون | توضیحات |
---|---|
()bind | اتصال سوکت به یک آدرس و پورت |
(address)connect | ایجاد یک برقراری اتصال TCP با سرور مقصد |
(backlog)listen | تغییر وضعیت سوکت برای دریافت درخواست |
()accept | ایجاد یک سوکت جدید برای هر کلاینت |
(buffersize, flags)recv | خواندن داده از سوکت |
(data, flags)send | ارسال داده به سوکت |
()close | بستن کامل اتصال TCP |
(flag)shutdown | بستن فقط ورودی یا خروجی اتصال TCP |
3️⃣ نمونه سرور echo با کتابخانه استاندارد
کتابخونه استاندارد پایتون همون API سیستمعامل رو در اختیار میگذاره یعنی همون عملکردها با همون اسامی. به صورت کلی میشه گفت فرایند مربوط به سوکتها همیشه همین الگو رو داره که این الگو رو در مثال زیر توضیح میدم:
socket → bind → listen → accept → send/recv → close
البته قبلش بگم اینکه این مثال برای اجرا از asyncio
استفاده کرده یا برای راهاندازی سرور WSGI
از ThreadingMixIn
استفاده میشه موضوعی هست که بعدا دربارهاش توضیح میدم ولی مستقیما به موضوع سوکت مربوط نیست.
کد زیر یک سرور echo ساده هست و سرور هر چیزی که کلاینت براش میفرسته رو بهش برمیگردونه. بعد از اجراش میشه با telnet زدن به لوکالهاست و پورت 9999 اون رو تست کرد.
import asyncio
import socket
from asyncio import AbstractEventLoop
async def echo(connection: socket, loop: AbstractEventLoop) -> None:
while data := await loop.sock_recv(connection, 1024):
await loop.sock_sendall(connection, data)
async def listen_for_connection(server_socket: socket, loop: AbstractEventLoop):
while True:
connection, client_address = await loop.sock_accept(server_socket)
connection.setblocking(False)
print('Connection from', client_address)
asyncio.create_task(echo(connection, loop))
async def main() -> None:
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 9999)
server_socket.bind(server_address)
server_socket.setblocking(False)
server_socket.listen()
await listen_for_connection(server_socket, asyncio.get_event_loop())
if __name__ == '__main__':
asyncio.run(main())
با اجرای socket.socket(socket.AF_INET, socket.SOCK_STREAM)
یه سوکت TCP ایجاد میشه و عملا ()socket
در سطح سیستمعامل فراخوانی میشه. مقادیری که به عنوان آرگومان پاس داده میشن پارامترهای سوکت هستن: AF_INET
یعنی آدرسها از نوع IPv4
باید باشن و SOCK_STREAM
هم یعنی نوع سوکت از پروتکل TCP هست. بعد از اون سوکت به آدرس و پورت bind
میشه و بعدش به حالت listen
نغییر وضعیت میده که فراخوانیهای سیستمعامل و پایتون اون دقیقا هم نام هستن.
بعد از این مرحله برای اینکه درخواستهای همزمان کلاینتها همدیگه رو بلاک نکنن هندل کردن اونها از طریق event loop
کنترل میشه. متد listen_for_connection
منتظر یه درخواست اتصال جدید میمونه و از طریق loop.sock_accept
فراخوانی سیستمعامل accept
رو انجام میده. این کار به صورت non-blocking
انجام میشه و سرور میتونه بلافاصله درخواست جدید یک کلاینت دیگه رو هم هندل کنه.
داخل متد echo
یه حلقه بینهایت هست که دادهها رو از کلاینت دریافت میکنه و هموندادهها رو به کلاینت برمیگردونه. متدهای loop.sock_recv
و loop.sock_sendall
عملا فراخوانیهای مربوط به recv
و send
سیستمعامل رو انجام میدن.
telnet 127.0.0.1 9999
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
hello world!
hello world!
4️⃣ فراخوانیهای سمت پایتون در زمان پاسخ به درخواست HTTP
حالا میتونیم به سرور WSGI
برگردیم. در زمان راهاندازی سرور WSGI
در صورتی که آرگومان threading
پاس داده شده باشه به سرور WSGI
رفتار socketserver.ThreadingMixIn
هم اضافه میشه. در حقیقت میشه اینطوری فرض کرد که برای هندل کردن هر درخواست یه Thread
جدید درست میشه. ایده مشابهی که بالاتر مثالش رو با asyncio
دیدیم البته اونجا task
درست میشد. برای اینکه سادهتر بشه میتونیم فرض کنیم متد ()serve_forever
در سرور WSGI
همون کاری رو میکنه که listen_for_connection
در مثال سرور echo انجام میده یعنی با همون ایده که درخواستها همدیگه رو بلاک نکنن به درخواستهای همزمان پاسخ میده.
از life cycle یک درخواست HTTP تا اینجا پیش اومدیم که سرور یک سوکت شنونده ایجاد کرده، به یک آدرس bind
شده و حالا آماده شنیدن درخواستهاست. یه سرور پایتونی بعد از اینکه درخواست براش میاد و accept
میکنه با یه اینترفیس به نام BaseRequestHandler
درخواست رو هندل میکنه. مطابق اون فرایند اصلی سوکتها که بالاتر گفتم این اینترفیس کارش اینه که دادهها رو بخونه یا بنویسه send/recv
و در نهایت سوکت رو ببنده close
. این اینترفیس به شکل زیر هست:
# file: python3.12:socketserver.py
class BaseRequestHandler:
def __init__(self, request, client_address, server):
self.request = request
self.client_address = client_address
self.server = server
self.setup()
try:
self.handle()
finally:
self.finish()
def setup(self):
pass
def handle(self):
pass
def finish(self):
pass
همونطور که بالاتر توضیح دادیم تو استاندارد WSGI
درخواستها با کلاس WSGIRequestHandler
از این اینترفیس پیادهسازی میشن. اون بخش مهمی که نیاز به توضیح داره متد handle
هست که فراخوانیها send/recv
اونجا انجام میشه. کد زیر پیادهسازی جنگو برای هندل کردن ریکوئستهاست:
# file: django/core/servers/basehttp.py
# class WSGIRequestHandler
def handle(self):
self.close_connection = True
self.handle_one_request()
while not self.close_connection:
self.handle_one_request()
try:
self.connection.shutdown(socket.SHUT_WR)
except (AttributeError, OSError):
pass
def handle_one_request(self):
self.raw_requestline = self.rfile.readline(65537)
if len(self.raw_requestline) > 65536:
self.requestline = ""
self.request_version = ""
self.command = ""
self.send_error(414)
return
if not self.parse_request(): # An error code has been sent, just exit
return
handler = ServerHandler(
self.rfile, self.wfile, self.get_stderr(), self.get_environ()
)
handler.request_handler = self # backpointer for logging & connection closing
handler.run(self.server.get_app())
برای توضیح اتفاقاتی که اینجا میوفته فرض کنیم که سرور روی لوکالهاست و پورت 8000 راهاندازی شده و ما یه درخواست برای اندپوینت /products
میزنیم:
curl -X GET http://localhost:8000/products/
وقتی این درخواست به سرور WSGI
میرسه و کارهای راهاندازی سوکت کلاینت و accept
شدنش انجام میشه دو تا آبجت درست میشن به نامهای rfile
و wfile
که عملا شبیه بافر عمل میکنن که فراخوانیهای مربوط به send/recv
رو برای سوکتها انجام میدن.
به عبارت دیگه کلاینت درخواستهاش رو روی rfile
میذاره، آبجکت WSGIRequestHandler
اون رو میخونه، پردازش میکنه به اپلیکیشن تحویل میده، پاسخ رو از اپلیکیشن میگیره و پاسخ رو روی wfile
مینویسه و ازونجا برای کلاینت ارسال میشه. مثلا وقتی که درخواست بالا برای سرور میاد مقدار زیر در rfile
قرار داده میشه:
b'GET /products/ HTTP/1.1\r\n'
وظیفه آبجت WSGIRequestHandler
اینه که این مقدار رو بخونه بعدش parse
کنه و به اپلیکیشن تحویل بده. از اینجا به بعد مسیر درخواست توی اپلیکیشن پایتونی که ما نوشتیم طی میشه تا خروجی تولید بشه. بعد از اینکه خروجی تولید شد این مقدار دوباره به سرور WSGI
میره تا اون رو به کلاینت برگردونه:
# file: wsgiref/handlers.py
# class BaseHandler
def finish_response(self):
try:
if not self.result_is_file() or not self.sendfile():
for data in self.result:
self.write(data)
self.finish_content()
except:
if hasattr(self.result, 'close'):
self.result.close()
raise
else:
self.close()
اگه موقع دیباگ روی خط for data in self.result
توقف کنیم یه محتوایی شبیه این میبینیم که واقعا تو اپلیکیشن ما تولید شده:
b'\n\n<!DOCTYPE html>\n<html dir="rtl">\n<head>\n <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n <met...\n\n \n \n\n\n\n \n ...
کار اصلی که متد بالا انجام میده اینه که دادههای خروجی رو روی wfile
بنویسه و بعدش این بافر رو خالی کنه و بعدش هم سوکت رو ببنده. سیستمعامل هم متناظر با اینها اقدامات خودش رو انجام میده: یعنی بافر رو flush
میکنه و دادههای اون رو از طریق سوکت کلاینت براش میفرسته و بعدش سوکت رو میبنده. اینجا دیگه واقعا کلاینت جواب رو تو مرورگر خودش دریافت میکنه و رندر شدهاش رو میبینه.
(😮💨 Such a long journey)
جمعبندی
درک جزئیات و فهم عمیق ابزارها و طریقه استفاده از اونها دغدغه همیشگی یک مهندس نرمافزار هست. اگه ذهن ما لایههای عملکرد داخلی ابزارها رو درک نکنه ما در ارائه راهحلها وابستگی زیادی به فریمورکها پیدا میکنیم و به اصطلاح نمیتونیم out of the box
فکر کنیم چون اجازه دادیم فریمورکها این جزییات رو از ما مخفی کنن، به جای ما فکر کنن و تصمیم بگیرند.
به نظرم در فرایند توسعه ما لحظهای میتونیم ادعا کنیم که راهحلهای خلاقانه یا اصیل ارائه میدیم که ذهنیت خودمون رو از تقلید کورکورانه از فریمورکها آزاد کرده باشیم یا به قول پیچه:
کسی که چرایی را بداند، با هر چگونگیای خواهد ساخت 🙏🏻.