برمجة المقابس ضرورية لاتصالات الشبكة، إذ تُمكّن من تبادل البيانات بين مختلف الأجهزة. في بايثون، تتيح المقابس التواصل بين العمليات (IPC) عبر الشبكات. يُقدّم هذا البرنامج التعليمي دليلاً شاملاً حول إنشاء خوادم وعملاء المقابس، والتعامل مع اتصالات متعددة، وإدارة الأخطاء في وحدة المقابس في بايثون.
خلال هذه الدورة، ستتعلم الوظائف والتوابع الرئيسية في وحدة socket في بايثون، والتي تتيح لك كتابة تطبيقات خادم/عميل خاصة بك بناءً على مقابس TCP. ستتعلم كيفية إرسال الرسائل والبيانات بين نقاط النهاية بكفاءة، وإدارة اتصالات متعددة في آنٍ واحد.
الشبكات والمنافذ موضوعان شائكان، وقد كُتب عنهما مجلدات طويلة. إذا كنتَ جديدًا على المنافذ أو الشبكات، فمن الطبيعي أن تشعر بالارتباك من كثرة المصطلحات والتفاصيل.
الخلفية التاريخية
للمقابس تاريخ طويل. بدأ استخدامها مع شبكة ARPANET عام ١٩٧١، ثم أصبحت لاحقًا واجهة برمجة تطبيقات (API) في نظام تشغيل Berkeley Software Distribution (BSD) الذي صدر عام ١٩٨٣، والمعروف باسم Berkeley sockets.
مع انطلاق الإنترنت في تسعينيات القرن الماضي مع شبكة الويب العالمية، ازدهرت برمجة الشبكات. لم تكن خوادم الويب والمتصفحات التطبيقات الوحيدة التي استفادت من الشبكات المتصلة حديثًا واستخدمت المنافذ. بل انتشرت على نطاق واسع تطبيقات الخادم-العميل بجميع أنواعها وأحجامها.
اليوم، على الرغم من أن البروتوكولات الأساسية التي تستخدمها واجهة برمجة التطبيقات (API) الخاصة بالمقبس قد تطورت على مر السنين، وتم تطوير بروتوكولات جديدة، إلا أن واجهة برمجة التطبيقات منخفضة المستوى ظلت كما هي.
أكثر أنواع تطبيقات المقابس شيوعًا هي تطبيقات العميل-الخادم، حيث يعمل أحد الجانبين كخادم وينتظر اتصالات من العملاء. هذا هو نوع التطبيق الذي ستنشئه في هذا البرنامج التعليمي. وبشكل أكثر تحديدًا، ستركز على واجهة برمجة تطبيقات المقابس لمقابس الإنترنت، والتي تُسمى أحيانًا مقابس بيركلي أو مقابس BSD. هناك أيضًا مقابس نطاق يونكس، والتي لا يمكن استخدامها إلا للتواصل بين العمليات على نفس المضيف.
نظرة عامة على واجهة برمجة تطبيقات مقبس بايثون
توفر وحدة socket في بايثون واجهةً لواجهة برمجة تطبيقات Berkeley sockets. هذه هي الوحدة التي ستستخدمها في هذا البرنامج التعليمي.
الدوال والتوابع الأساسية لواجهة برمجة التطبيقات في هذه الوحدة هي:
socket()
.bind()
.listen()
.accept()
.connect()
.connect_ex()
.send()
.recv()
.close()
يوفر بايثون واجهة برمجة تطبيقات سهلة الاستخدام ومتسقة، تُربط مباشرةً باستدعاءات النظام، وهي نظيراتها في لغة C. في القسم التالي، ستتعلم كيفية استخدامها معًا.
كجزء من مكتبتها القياسية، تحتوي بايثون أيضًا على فئات تُسهّل استخدام دوال المقبس منخفضة المستوى. على الرغم من عدم تناولها في هذا البرنامج التعليمي، يمكنك الاطلاع على وحدة socketserver، وهي إطار عمل لخوادم الشبكة. تتوفر أيضًا العديد من الوحدات التي تُطبّق بروتوكولات إنترنت عالية المستوى مثل HTTP وSMTP. للاطلاع على نظرة عامة، راجع “بروتوكولات الإنترنت والدعم“.
مقابس TCP
ستنشئ كائن مقبس باستخدام ()socket.socket، مع تحديد نوع المقبس كـ socket.SOCK_STREAM. عند القيام بذلك، يكون البروتوكول الافتراضي المستخدم هو بروتوكول التحكم في الإرسال (TCP). هذا خيار افتراضي جيد، وربما ما تريده.
لماذا يجب عليك استخدام بروتوكول التحكم في الإرسال (TCP)؟
- موثوق: يتم اكتشاف الحزم التي تم إسقاطها في الشبكة وإعادة إرسالها بواسطة المرسل.
- يتضمن تسليم البيانات بالترتيب: تتم قراءة البيانات بواسطة تطبيقك بالترتيب الذي كتبها به المرسل.
على النقيض من ذلك، فإن مآخذ بروتوكول بيانات المستخدم (UDP) التي تم إنشاؤها باستخدام socket.SOCK_DGRAM ليست موثوقة، وقد تكون البيانات التي يقرأها المستقبل غير مرتبة مقارنة بعمليات الكتابة الخاصة بالمرسل.
لماذا هذا مهم؟ الشبكات هي نظام توصيل يبذل أقصى جهد. لا يوجد ضمان لوصول بياناتك إلى وجهتها أو استلامك ما أُرسل إليك.
تتمتع أجهزة الشبكة، مثل أجهزة التوجيه والمفاتيح، بنطاق ترددي محدود، وتأتي مع قيود نظامها الخاصة. فهي تحتوي على وحدات معالجة مركزية (CPU) وذاكرة وناقلات ومخازن مؤقتة لحزم الواجهة، تمامًا مثل أجهزة العملاء والخوادم. يُريحك بروتوكول TCP من القلق بشأن فقدان الحزم، ووصول البيانات بشكل غير منتظم، وغيرها من المشاكل التي تحدث عادةً عند الاتصال عبر الشبكة.
لفهم هذا بشكل أفضل، راجع تسلسل مكالمات API للمقبس وتدفق البيانات لـ TCP:

العمود الأيسر يُمثل الخادم، أما العمود الأيمن فيمثل العميل.
بدءًا من العمود الأيسر العلوي، لاحظ مكالمات واجهة برمجة التطبيقات التي يقوم بها الخادم لإعداد مقبس “الاستماع”:
socket()
.bind()
.listen()
.accept()
يقوم مقبس الاستماع بما يوحي به اسمه تمامًا: يستمع لاتصالات العملاء. عند اتصال العميل، يستدعي الخادم دالة .accept()
لقبول الاتصال أو إكماله.
يستدعي العميل دالة .connect()
لإنشاء اتصال بالخادم وبدء عملية المصافحة الثلاثية. تُعد خطوة المصافحة مهمة لأنها تضمن إمكانية الوصول إلى كلا طرفي الاتصال في الشبكة، أي أن العميل يستطيع الوصول إلى الخادم والعكس صحيح. قد لا يتمكن سوى مضيف أو عميل أو خادم واحد من الوصول إلى الآخر.
في المنتصف يوجد قسم الذهاب والإياب، حيث يتم تبادل البيانات بين العميل والخادم باستخدام استدعاءات .send()
و.recv()
.
في الجزء السفلي، يقوم العميل والخادم بإغلاق المقابس الخاصة بهم.
صدى العميل والخادم
بعد أن تعرفت على واجهة برمجة تطبيقات المقبس وكيفية تواصل العميل والخادم، أنت جاهز لإنشاء أول عميل وخادم لك. ستبدأ بتنفيذ بسيط. سيقوم الخادم ببساطة بعكس كل ما يستقبله إلى العميل.
صدى الخادم
هذا هو الكود المصدر للخادم:
import socket
HOST = "127.0.0.1" # Standard loopback interface address (localhost)
PORT = 65432 # 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(f"Connected by {addr}")
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
لا تقلق بشأن فهم كل ما سبق الآن. هناك الكثير مما يدور في هذه الأسطر القليلة من التعليمات البرمجية. هذه مجرد نقطة بداية لتتمكن من رؤية خادم أساسي أثناء العمل.
حسنًا، ما الذي يحدث بالضبط في استدعاء واجهة برمجة التطبيقات؟
يُنشئ ()socket.socket كائن socket يدعم نوع مدير السياق، لذا يُمكنك استخدامه في عبارة with. لا حاجة لاستدعاء ()s.close:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
pass # Use the socket object without calling s.close().
الوسائط المُمرَّرة إلى ()socket هي ثوابت تُستخدم لتحديد عائلة العناوين ونوع المقبس. AF_INET هي عائلة عناوين الإنترنت لـ IPv4. SOCK_STREAM هو نوع المقبس لـ TCP، وهو البروتوكول المُستخدَم لنقل الرسائل عبر الشبكة.
يتم استخدام التابع .bind()
لربط المقبس بواجهة شبكة محددة ورقم منفذ:
# ...
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
# ...
تعتمد القيم المُمرَّرة إلى .bind()
على عائلة عناوين المقبس. في هذا المثال، ستستخدم socket.AF_INET (IPv4). لذا، فهو يتوقع ثنائيًا: (host, port).
يمكن أن يكون host
اسم مضيف، أو عنوان IP، أو سلسلة فارغة. في حال استخدام عنوان IP، يجب أن يكون host
سلسلة عناوين بصيغة IPv4. عنوان IP 127.0.0.1 هو عنوان IPv4 القياسي لواجهة loopback، لذا ستتمكن العمليات على المضيف فقط من الاتصال بالخادم. إذا مررت سلسلة فارغة، فسيقبل الخادم الاتصالات على جميع واجهات IPv4 المتاحة.
يُمثل port
رقم منفذ TCP لقبول الاتصالات من العملاء. يجب أن يكون عددًا صحيحًا من 1 إلى 65535، حيث أن 0 محجوز. قد تتطلب بعض الأنظمة صلاحيات المستخدم المدير إذا كان رقم port
أقل من 1024.
ستتعلم المزيد عن هذا لاحقًا في قسم “استخدام أسماء المضيفين”. في الوقت الحالي، عليك أن تفهم أنه عند استخدام اسم مضيف، قد تظهر لك نتائج مختلفة بناءً على ما يتم إرجاعه من عملية تحليل الأسماء. قد تكون هذه النتائج أي شيء. في المرة الأولى التي تُشغّل فيها تطبيقك، قد تحصل على العنوان 10.1.2.3. وفي المرة التالية، تحصل على عنوان مختلف، 192.168.0.1. وفي المرة الثالثة، قد تحصل على 172.16.7.8، وهكذا.
في مثال الخادم، يُمكّن .listen()
الخادم من قبول الاتصالات ويجعل الخادم منفذ استماع:
# ...
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
# ...
تحتوي دالة .listen()
على مُعامل تراكم. يُحدد هذا المُعامل عدد الاتصالات غير المقبولة التي سيسمح بها النظام قبل رفض اتصالات جديدة. بدءًا من إصدار Python 3.5، أصبح هذا المُعامل اختياريًا. في حال عدم تحديده، سيتم اختيار قيمة تراكم افتراضية.
إذا كان خادمك يستقبل عددًا كبيرًا من طلبات الاتصال في آنٍ واحد، فقد يُساعدك تحديد الحد الأقصى لطول قائمة انتظار الاتصالات المُعلّقة على زيادة قيمة المتراكم. تعتمد القيمة القصوى على النظام. على سبيل المثال، في نظام لينكس، راجع /proc/sys/net/core/somaxconn
.
يمنع التابع .accept()
التنفيذ وينتظر اتصالاً وارداً. عند اتصال العميل، يرجع كائن مقبس جديد يُمثل الاتصال ومجموعة تحتوي على عنوان العميل. تحتوي المجموعة على (host, port) لاتصالات IPv4 أو (host, port, flowinfo, scopeid) لاتصالات IPv6. راجع عائلات عناوين المقبس في قسم المراجع لمزيد من التفاصيل حول قيم المجموعة.
من الضروري فهم أن لديك الآن كائن مقبس جديد من .accept()
. هذا مهم لأنه المقبس الذي ستستخدمه للتواصل مع العميل. وهو يختلف عن المقبس المُنصت الذي يستخدمه الخادم لقبول الاتصالات الجديدة:
# ...
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
print(f"Connected by {addr}")
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
بعد أن يوفر .accept()
كائن مقبس العميل conn، تُستخدم حلقة while لا نهائية لتكرار استدعاءات الحظر لـ conn.recv(). تقرأ هذه الحلقة أي بيانات يرسلها العميل وتعيد إرسالها باستخدام ()conn.sendall.
إذا أعادت دالة ()conn.recv كائن بايتات فارغًا، b”، فهذا يُشير إلى إغلاق العميل للاتصال وإنهاء الحلقة. تُستخدم عبارة with مع conn لإغلاق المقبس تلقائيًا في نهاية الكتلة.
صدى العميل
الآن، حان الوقت لإلقاء نظرة على الكود المصدر للعميل:
import socket
HOST = "127.0.0.1" # The server's hostname or IP address
PORT = 65432 # The port used by the server
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall(b"Hello, world")
data = s.recv(1024)
print(f"Received {data!r}")
بالمقارنة مع الخادم، يُعد العميل بسيطًا للغاية. فهو يُنشئ كائن مقبس، ويستخدم دالة .connect() للاتصال بالخادم، ويستدعي دالة s.sendall() لإرسال رسالته. وأخيرًا، يستدعي دالة s.recv() لقراءة رد الخادم ثم طباعته.
تشغيل صدى العميل والخادم
في هذا القسم، ستقوم بتشغيل العميل والخادم لمعرفة سلوكهما وفحص ما يحدث.
افتح الطرفية أو موجه الأوامر، وانتقل إلى الدليل الذي يحتوي على البرامج النصية الخاصة بك، وتأكد من تثبيت Python 3.6 أو أعلى ووضعه في المسار الخاص بك، ثم قم بتشغيل الخادم:
$ python echo-server.py
سيبدو جهازك معطلاً. هذا لأن الخادم محظور أو مُعلّق على .accept()
:
# ...
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
print(f"Connected by {addr}")
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
ينتظر اتصال العميل. الآن، افتح نافذة طرفية أخرى أو موجه أوامر وشغّل العميل:
$ python echo-client.py
Received b'Hello, world'
في نافذة الخادم، يجب أن تلاحظ شيئًا كهذا:
$ python echo-server.py
Connected by ('127.0.0.1', 64623)
في المخرجات أعلاه، قام الخادم بطباعة مجموعة addr التي تم إرجاعها من ()s.accept هذا هو عنوان IP الخاص بالعميل ورقم منفذ TCP. من المُرجَّح أن يختلف رقم المنفذ، 64623، عند تشغيله على جهازك.
عرض حالة المقبس
للاطلاع على الحالة الحالية للمنافذ على جهازك المضيف، استخدم netstat. هذا الأمر متاح افتراضيًا على أنظمة macOS وLinux وWindows.
إليك مخرجات netstat من نظام التشغيل macOS بعد بدء تشغيل الخادم:
$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.65432 *.* LISTEN
لاحظ أن Local Address
هو 127.0.0.1.65432. لو echo-server.py
استخدم من قبل ""= HOST
بدلاً من HOST = "127.0.0.1"
، لكان netstat سيعرض التالي:
$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 *.65432 *.* LISTEN
Local Address
هو *.65432، مما يعني أنه سيتم استخدام جميع واجهات المضيف المتاحة التي تدعم عائلة العناوين هذه لقبول الاتصالات الواردة. في هذا المثال، تم استخدام socket.AF_INET في استدعاء ()socket. يمكنك رؤية ذلك في Proto
العمود : tcp4
.
تم اقتصاص الناتج أعلاه لعرض صدى الخادم فقط. من المرجح أن ترى ناتجًا أكبر بكثير، حسب النظام الذي تشغّله عليه. العناصر التي يجب ملاحظتها هي أعمدة “Proto
” و” Local Address
” و”state
“. في المثال الأخير، يُظهر netstat أن صدى الخادم يستخدم مقبس IPv4 TCP (tcp4
)، على المنفذ 65432 على جميع الواجهات (*.65432
)، وهو في حالة استماع (LISTEN
).
هناك طريقة أخرى للوصول إلى هذا، بالإضافة إلى معلومات مفيدة إضافية، وهي استخدام lsof
(قائمة الملفات المفتوحة). يتوفر هذا الخيار افتراضيًا على نظام macOS، ويمكن تثبيته على نظام Linux باستخدام مدير الحزم، إن لم يكن مثبتًا بالفعل:
$ lsof -i -n
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
Python 67982 nathan 3u IPv4 0xecf272 0t0 TCP *:65432 (LISTEN)
يُعطيك الأمر lsof الأمر (COMMAND)، ومعرف العملية (PID)، ومعرف المستخدم (USER) لمقابس الإنترنت المفتوحة عند استخدامه مع الخيار -i. أعلاه عملية صدى خادم .
تتوفر العديد من الخيارات لأمري netstat وlsof، وتختلف باختلاف نظام التشغيل الذي تستخدمهما عليه. راجع صفحة الدليل أو الوثائق لكليهما. من المفيد جدًا قضاء بعض الوقت في فهمهما والتعرف عليهما. ستُجد نفعًا. على نظامي macOS وLinux، استخدم الأمرين man netstat وman lsof. أما بالنسبة لنظام Windows، فاستخدم الأمرين netstat /?
.
فيما يلي خطأ شائع قد تواجهه عند محاولة الاتصال بمنفذ لا يحتوي على مقبس استماع:
$ python echo-client.py
Traceback (most recent call last):
File "./echo-client.py", line 9, in <module>
s.connect((HOST, PORT))
ConnectionRefusedError: [Errno 61] Connection refused
إما أن رقم المنفذ المحدد خاطئ، أو أن الخادم لا يعمل. أو ربما يوجد جدار حماية في المسار يمنع الاتصال، وهو أمر يسهل نسيانه. قد يظهر لك أيضًا خطأ “انتهت مهلة الاتصال”. أضف قاعدة جدار حماية تسمح للعميل بالاتصال بمنفذ TCP!
انقطاع الاتصالات
الآن سوف تقوم بإلقاء نظرة عن كثب على كيفية تواصل العميل والخادم مع بعضهما البعض:

عند استخدام واجهة الحلقة الراجعة (عنوان IPv4 127.0.0.1 أو عنوان IPv6 ::1)، لا تغادر البيانات المضيف ولا تصل إلى الشبكة الخارجية. في الرسم التخطيطي أعلاه، توجد واجهة الحلقة الراجعة داخل المضيف. هذا يُظهر الطبيعة الداخلية لواجهة الحلقة الراجعة، ويُظهر أن الاتصالات والبيانات التي تنتقل عبرها محلية بالنسبة للمضيف.
تستخدم التطبيقات واجهة الحلقة الراجعة للتواصل مع العمليات الأخرى الجارية على المضيف، ولأغراض الأمان والعزل عن الشبكة الخارجية. ولأنها داخلية ولا يمكن الوصول إليها إلا من داخل المضيف، فهي غير معرضة للخطر.
يمكنك ملاحظة ذلك عمليًا إذا كان لديك خادم تطبيقات يستخدم قاعدة بيانات خاصة به. إذا لم تكن قاعدة البيانات هذه مستخدمة من قِبل خوادم أخرى، فمن المحتمل أنها مُهيأة للاستماع إلى الاتصالات عبر واجهة الحلقة الراجعة فقط. في هذه الحالة، لن تتمكن الأجهزة المضيفة الأخرى على الشبكة من الاتصال بها.
عند استخدام عنوان IP غير 127.0.0.1 أو ::1 في تطبيقاتك، فمن المرجح أنه مرتبط بواجهة إيثرنت متصلة بشبكة خارجية. هذه هي بوابتك إلى مضيفين آخرين خارج نطاق “المضيف المحلي” الخاص بك:

كن حذرًا في هذا العالم، إنه عالمٌ قاسٍ ومُرعب. تأكد من قراءة قسم “استخدام أسماء المضيفين” قبل المخاطرة بتجاوز حدود “localhost” الآمنة. هناك ملاحظة أمنية تُطبق حتى لو لم تكن تستخدم أسماء مضيفين، بل عناوين IP فقط.
التعامل مع اتصالات متعددة
لخادم الصدى قيوده بالتأكيد. أهمها أنه يخدم عميلًا واحدًا فقط ثم يخرج. ويواجه عميل الصدى هذا القيد أيضًا، ولكن هناك مشكلة إضافية. عند استخدام العميل لـ ()s.recv، من الممكن أن يُرجع بايتًا واحدًا فقط، b’H’ من b’Hello, world’:
# ...
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall(b"Hello, world")
data = s.recv(1024)
print(f"Received {data!r}")
قيمة bufsize (١٠٢٤) المستخدمة أعلاه هي الحد الأقصى لكمية البيانات التي سيتم استلامها دفعةً واحدة. هذا لا يعني أن دالة .recv()
ستعيد ١٠٢٤ بايت.
تعمل دالة .send()
بنفس الطريقة. فهي تُرجع عدد البايتات المُرسلة، والذي قد يكون أقل من حجم البيانات المُرسلة. أنت مسؤول عن التحقق من ذلك واستدعاء دالة .send()
عدة مرات حسب الحاجة لإرسال جميع البيانات:
التطبيقات مسؤولة عن التحقق من إرسال جميع البيانات؛ إذا تم إرسال جزء فقط من البيانات، فيجب على التطبيق محاولة تسليم البيانات المتبقية. (المصدر)
في المثال أعلاه، تجنبت الاضطرار إلى القيام بذلك عن طريق استخدام .sendall()
:
بخلاف send()، تستمر هذه الطريقة في إرسال البيانات من البايتات حتى يتم إرسال جميع البيانات أو حدوث خطأ. لا يتم إرجاع أي بيانات في حال نجاح العملية. (المصدر)
لديك مشكلتين في هذه المرحلة:
- كيف تتعامل مع اتصالات متعددة في وقت واحد؟
- يجب عليك استدعاء
.send()
و.recv()
حتى يتم إرسال أو استلام كافة البيانات.
ماذا يمكنك أن تفعل؟ هناك العديد من الطرق للتزامن. أحدها الشائع هو استخدام الإدخال/الإخراج غير المتزامن. أُضيفت asyncio إلى المكتبة القياسية في بايثون 3.4. الخيار التقليدي هو استخدام الخيوط.
تكمن مشكلة التزامن في صعوبة تطبيقه بشكل صحيح. هناك العديد من التفاصيل الدقيقة التي يجب مراعاتها والحذر منها. كل ما يتطلبه الأمر هو ظهور أحد هذه التفاصيل، وقد يفشل تطبيقك فجأةً بطرق غير مباشرة.
هذا لا يُثنيك عن تعلم البرمجة المتزامنة واستخدامها. إذا كان تطبيقك بحاجة إلى التوسع، فهو ضروري إذا كنت ترغب في استخدام أكثر من معالج أو نواة واحدة. مع ذلك، في هذا البرنامج التعليمي، ستستخدم شيئًا أكثر تقليدية من الخيوط وأسهل في الفهم. ستستخدم دالة النظام الأساسية:.select()
.
تتيح لك دالة .select()
التحقق من اكتمال الإدخال/الإخراج على أكثر من مقبس. لذا، يمكنك استدعاء .select()
لمعرفة أي المقابس جاهزة للقراءة و/أو الكتابة. ولكن هذا بايثون، لذا هناك المزيد. ستستخدم وحدة selectors في المكتبة القياسية لاستخدام التنفيذ الأكثر كفاءة، بغض النظر عن نظام التشغيل الذي تستخدمه:
مع ذلك، باستخدام .select()
، لن تتمكن من التشغيل المتزامن. مع ذلك، قد يكون هذا النهج سريعًا جدًا، وذلك حسب حجم العمل لديك. يعتمد ذلك على ما يحتاج تطبيقك إلى فعله عند معالجة طلب، وعدد العملاء الذين يحتاج إلى دعمهم.
يستخدم asyncio تعدد مهام تعاوني أحادي الخيط وحلقة أحداث لإدارة المهام. باستخدام .select()
، ستكتب نسختك الخاصة من حلقة الأحداث، وإن كانت أبسط وأكثر تزامنًا. عند استخدام خيوط متعددة، حتى مع وجود التزامن، يتعين عليك حاليًا استخدام قفل المترجم العام (GIL) مع CPython وPyPy. هذا يحدّ فعليًا من حجم العمل الذي يمكنك إنجازه بالتوازي على أي حال.
هذا يعني أن استخدام .select()
قد يكون خيارًا ممتازًا. لا تشعر بأنك مضطر لاستخدام asyncio أو threads أو أحدث مكتبة غير متزامنة. عادةً، في تطبيقات الشبكة، يكون تطبيقك مرتبطًا بالإدخال/الإخراج على أي حال: قد يكون في انتظار الشبكة المحلية، أو لنقاط النهاية على الجانب الآخر من الشبكة، أو لعمليات الكتابة على القرص، وما إلى ذلك.
إذا كنت تتلقى طلبات من عملاء يبدؤون عملًا مرتبطًا بوحدة المعالجة المركزية (CPU)، فراجع وحدة concurrent.futures. تحتوي على فئة ProcessPoolExecutor، التي تستخدم مجموعة من العمليات لتنفيذ الاستدعاءات بشكل غير متزامن.
في القسم التالي، ستستعرض أمثلةً لخوادم وعميلات تُعالج هذه المشاكل. تستخدم هذه الخوادم .select()
لمعالجة اتصالات متعددة في آنٍ واحد، وتُستدعي .send()
و.recv()
عدة مرات حسب الحاجة.
عميل وخادم متعدد الاتصالات
في القسمين التاليين، ستقوم بإنشاء خادم وعميل يتعاملان مع اتصالات متعددة باستخدام كائن selector
تم إنشاؤه من وحدة selectors.
خادم متعدد الاتصالات
أولاً، انتبه إلى خادم الاتصالات المتعددة. الجزء الأول يُهيئ منفذ الاستماع:
import sys
import socket
import selectors
import types
sel = selectors.DefaultSelector()
# ...
host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
الفرق الرئيسي بين هذا الخادم وخادم الصدى هو استدعاء دالة lsock.setblocking(False)
لتكوين المقبس في وضع عدم الحظر. لن تُحظر استدعاءات هذا المقبس بعد الآن. عند استخدامه مع دالة sel.select()
، كما سترى لاحقًا، يمكنك انتظار الأحداث على مقبس واحد أو أكثر، ثم قراءة البيانات وكتابتها عندما تصبح جاهزة.
يقوم sel.register()
بتسجيل المقبس الذي سيتم مراقبته باستخدام ()sel.select للأحداث التي تهتم بها. بالنسبة لمقبس الاستماع، تريد قراءة الأحداث: selectors.EVENT_READ.
لتخزين أي بيانات عشوائية ترغب بها مع المقبس، ستستخدم البيانات. تُعاد هذه البيانات عند عودة دالة .select()
. ستستخدم البيانات لتتبع ما تم إرساله واستقباله على المقبس.
التالي هو حلقة الحدث:
# ...
try:
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)
except KeyboardInterrupt:
print("Caught keyboard interrupt, exiting")
finally:
sel.close()
sel.select(timeout=None)
يمنع حتى تتوفر مقابس جاهزة للإدخال/الإخراج. يُرجع قائمة من الثنائيات، واحدة لكل مقبس. تحتوي كل ثنائي على key
و mask
. key
هو SelectorKey namedtuple الذي يحتوي على سمة fileobj. key.fileobj هو كائن المقبس، وmask هو قناع حدث للعمليات الجاهزة.
إذا كانت قيمة key.data مساوية لـ None، فهذا يعني أنها من المقبس المُنصت، وعليك قبول الاتصال. ستستدعي دالة ()accept_wrapper الخاصة بك للحصول على كائن المقبس الجديد وتسجيله في المُحدد. ستُلقي نظرة على ذلك لاحقًا.
إذا لم يكن key.data هو None، فأنت تعلم أنه مقبس عميل تم قبوله بالفعل، وتحتاج إلى خدمته. يتم بعد ذلك استدعاء ()service_connection مع key وmask كحجج، وهذا كل ما تحتاجه للعمل على المقبس.
إليك ما تفعله دالة ()accept_wrapper الخاصة بك:
# ...
def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print(f"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)
# ...
بما أن مقبس الاستماع مُسجَّل لحدث selectors.EVENT_READ، فيجب أن يكون جاهزًا للقراءة. يمكنك استدعاء ()sock.accept ثم استدعاء conn.setblocking(False) لوضع المقبس في وضع عدم الحظر.
تذكر، هذا هو الهدف الرئيسي في هذا الإصدار من الخادم، لأنك لا تريد أن يتوقف. إذا توقف، فسيتوقف الخادم بأكمله حتى يعود. هذا يعني أن المقابس الأخرى ستبقى في حالة انتظار حتى لو لم يكن الخادم يعمل بشكل نشط. هذه هي حالة “التوقف” المزعجة التي لا تريد أن يبقى فيها خادمك.
بعد ذلك، أنشئ كائنًا لحفظ البيانات التي تريد تضمينها مع المقبس باستخدام SimpleNamespace. نظرًا لأنك تريد معرفة متى يكون اتصال العميل جاهزًا للقراءة والكتابة، يتم تعيين كلا هذين الحدثين باستخدام عامل التشغيل OR:
# ...
def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print(f"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)
# ...
يتم بعد ذلك تمرير events
القناع والمقبس وكائنات البيانات إلى ()sel.register.
الآن قم بإلقاء نظرة على ()service_connection لمعرفة كيفية التعامل مع اتصال العميل عندما يكون جاهزًا:
# ...
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(f"Closing connection to {data.addr}")
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if data.outb:
print(f"Echoing {data.outb!r} to {data.addr}")
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
# ...
هذا هو قلب خادم الاتصالات المتعددة البسيط. المفتاح هو namedtuple الذي تم إرجاعه من .select()
والذي يحتوي على كائن المقبس (fileobj) وكائن البيانات. يحتوي القناع على الأحداث الجاهزة.
إذا كان المقبس جاهزًا للقراءة، فسيتم تقييم mask وselectors.EVENT_READ على أنها صحيحة، لذا يتم استدعاء ()sock.recv. تُضاف أي بيانات مقروءة إلى data.outb لإرسالها لاحقًا.
لاحظ كتلة else: للتحقق مما إذا لم يتم استلام أي بيانات:
# ...
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(f"Closing connection to {data.addr}")
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if data.outb:
print(f"Echoing {data.outb!r} to {data.addr}")
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
# ...
إذا لم يتم استلام أي بيانات، فهذا يعني أن العميل قد أغلق مقبسه، وينبغي على الخادم إغلاقه أيضًا. ولكن لا تنسَ استدعاء ()sel.unregister قبل الإغلاق، حتى لا تتم مراقبة المقبس بواسطة .select()
.
عندما يكون المقبس جاهزًا للكتابة، وهو ما ينبغي أن يكون دائمًا في المقبس السليم، تُرسَل أي بيانات مُستلَمة مُخزَّنة في data.outb إلى العميل باستخدام ()sock.send. ثم تُزال البايتات المُرسَلة من مخزن الإرسال:
# ...
def service_connection(key, mask):
# ...
if mask & selectors.EVENT_WRITE:
if data.outb:
print(f"Echoing {data.outb!r} to {data.addr}")
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
# ...
تُرجع دالة .send()
عدد البايتات المُرسلة. يُمكن بعد ذلك استخدام هذا العدد مع تدوين الشريحة في المخزن المؤقت .outb
لتجاهل البايتات المُرسلة.
عميل متعدد الاتصالات
الآن، ألقِ نظرة على عميل الاتصالات المتعددة، multiconn-client.py. إنه مشابه جدًا للخادم، ولكنه يبدأ ببدء الاتصالات عبر دالة ()start_connections بدلاً من الاستماع إلى الاتصالات.
import sys
import socket
import selectors
import types
sel = selectors.DefaultSelector()
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(f"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=messages.copy(),
outb=b"",
)
sel.register(sock, events, data=data)
# ...
يُقرأ num_conns من سطر الأوامر، وهو عدد الاتصالات المطلوب إنشاؤها بالخادم. وكما هو الحال مع الخادم، يُضبط كل مقبس على وضع عدم الحظر.
تستخدم دالة .connect_ex()
بدلاً من .connect()
لأن دالة .connect()
ستُثير استثناء BlockingIOError فورًا. تُرجع دالة .connect_ex()
في البداية مؤشر خطأ، errno.EINPROGRESS، بدلاً من إثارة استثناء قد يُعيق الاتصال الجاري. بمجرد اكتمال الاتصال، يكون المقبس جاهزًا للقراءة والكتابة، ويتم إرجاعه بواسطة دالة .select()
.
بعد إعداد المقبس، تُنشأ البيانات التي تريد تخزينها عليه باستخدام SimpleNamespace. تُنسخ الرسائل التي سيرسلها العميل إلى الخادم باستخدام ()messages.copy لأن كل اتصال سيستدعي ()socket.send ويُعدّل القائمة. يُخزَّن كل ما يلزم لتتبع ما يحتاج العميل إلى إرساله وما أرسله وما استلمه، بما في ذلك إجمالي عدد البايتات في الرسائل، في بيانات الكائن.
قم بالاطلاع على التغييرات التي تم إجراؤها من ()service_connection الخاصة بالخادم لإصدار العميل:
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
+ print(f"Received {recv_data!r} from connection {data.connid}")
+ data.recv_total += len(recv_data)
- else:
- print(f"Closing connection {data.connid}")
+ if not recv_data or data.recv_total == data.msg_total:
+ print(f"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(f"Echoing {data.outb!r} to {data.addr}")
+ print(f"Sending {data.outb!r} to connection {data.connid}")
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
إنه متشابه جوهريًا، ولكن مع اختلاف مهم واحد. يتتبع العميل عدد البايتات التي يتلقاها من الخادم ليتمكن من إغلاق جانبه من الاتصال. عندما يكتشف الخادم ذلك، يُغلق جانبه من الاتصال أيضًا.
بهذا، يعتمد الخادم على حسن سلوك العميل: يتوقع الخادم أن يُغلق العميل جانبه من الاتصال عند انتهاء إرسال الرسائل. إذا لم يُغلق العميل، فسيترك الخادم الاتصال مفتوحًا. في التطبيقات الحقيقية، قد ترغب في حماية خادمك من هذا الأمر من خلال تطبيق مهلة زمنية لمنع تراكم اتصالات العميل إذا لم يُرسل طلبًا بعد فترة زمنية محددة.
تشغيل العميل والخادم متعدد الاتصالات
الآن حان وقت تشغيل multiconn-server.py وmulticonn-client.py. كلاهما يستخدم وسيطات سطر الأوامر. يمكنك تشغيلهما بدون وسيطات لعرض الخيارات.
بالنسبة للخادم، قم بتمرير أرقام المضيف والمنفذ:
$ python multiconn-server.py
Usage: multiconn-server.py <host> <port>
بالنسبة للعميل، قم أيضًا بتمرير عدد الاتصالات التي يجب إنشاؤها إلى الخادم، num_connections:
$ python multiconn-client.py
Usage: multiconn-client.py <host> <port> <num_connections>
يوجد أدناه مخرجات الخادم عند الاستماع على واجهة الاسترجاع على المنفذ 65432:
$ python multiconn-server.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)
Accepted connection from ('127.0.0.1', 61354)
Accepted connection from ('127.0.0.1', 61355)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61354)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61355)
Closing connection to ('127.0.0.1', 61354)
Closing connection to ('127.0.0.1', 61355)
فيما يلي إخراج العميل عندما ينشئ اتصالين بالخادم أعلاه:
$ python multiconn-client.py 127.0.0.1 65432 2
Starting connection 1 to ('127.0.0.1', 65432)
Starting connection 2 to ('127.0.0.1', 65432)
Sending b'Message 1 from client.' to connection 1
Sending b'Message 2 from client.' to connection 1
Sending b'Message 1 from client.' to connection 2
Sending b'Message 2 from client.' to connection 2
Received b'Message 1 from client.Message 2 from client.' from connection 1
Closing connection 1
Received b'Message 1 from client.Message 2 from client.' from connection 2
Closing connection 2
رائع! الآن شغّلتَ العميل والخادم متعددَي الاتصالات. في القسم التالي، ستُفصّل هذا المثال أكثر.
تطبيق العميل والخادم
يُعدّ مثال العميل والخادم متعددي الاتصالات تحسنًا ملحوظًا مقارنةً بالنقطة الأولى. ومع ذلك، يُمكنك الآن اتخاذ خطوة إضافية ومعالجة عيوب مثال multiconn
السابق في التنفيذ النهائي: تطبيق العميل والخادم.
أنت تريد عميلاً وخادماً يتعاملان مع الأخطاء بكفاءة حتى لا تتأثر الاتصالات الأخرى. من البديهي ألا ينهار عميلك أو خادمك فجأةً إذا لم يتم اكتشاف استثناء. هذا أمرٌ لم يكن لديك ما يدعو للقلق بشأنه حتى الآن، لأن الأمثلة أغفلت معالجة الأخطاء عمداً للإيجاز والوضوح.
الآن وقد تعرفت على واجهة برمجة التطبيقات الأساسية، والمنافذ غير الحاجزة، و.select()
، يمكنك إضافة بعض معالجة الأخطاء ومعالجة المشكلة الكبيرة التي أخفتها الأمثلة عنك خلف الستار الكبير. هل تذكر فئة التخصيص المذكورة سابقًا في المقدمة؟ هذا ما ستستكشفه لاحقًا.
أولاً، عليك معالجة الأخطاء:
جميع الأخطاء تُثير استثناءات. يمكن إثارة الاستثناءات العادية لأنواع الحجج غير الصالحة وحالات نفاد الذاكرة؛ بدءًا من إصدار بايثون 3.3، تُثير الأخطاء المتعلقة بدلالات المقبس أو العنوان خطأ OSError أو إحدى فئاته الفرعية. (المصدر)
لذا، عليك اكتشاف خطأ OSError. ومن الاعتبارات المهمة الأخرى المتعلقة بالأخطاء، حالات انتهاء المهلة. ستجدها مذكورة في العديد من الوثائق. تحدث حالات انتهاء المهلة، وهي ما يُسمى خطأً عاديًا. تتم إعادة تشغيل الأجهزة المضيفة وأجهزة التوجيه، وتتعطل منافذ المحول، وتتعطل الكابلات، وتنفصل الكابلات، وما إلى ذلك. يجب أن تكون مستعدًا لهذه الأخطاء وغيرها، وأن تتعامل معها في شفرتك البرمجية.
ماذا عن المشكلة الكبيرة؟ كما يُشير نوع المقبس socket.SOCK_STREAM، عند استخدام TCP، فإنك تقرأ من تدفق مستمر من البايتات. يشبه الأمر القراءة من ملف على القرص، ولكنك تقرأ بايتات من الشبكة. ومع ذلك، على عكس قراءة الملف، لا توجد دالة ()f.seek.
بعبارة أخرى، لا يمكنك إعادة وضع مؤشر المقبس، إذا كان هناك واحد، ونقل البيانات.
عندما تصل البايتات إلى المقبس، توجد مخازن مؤقتة للشبكة. بعد قراءتها، يجب حفظها في مكان ما، وإلا ستُفقد. يؤدي استدعاء .recv()
مرة أخرى إلى قراءة التدفق التالي من البايتات المتاحة من المقبس.
ستقرأ من المقبس على دفعات. لذا، عليك استدعاء دالة .recv()
وحفظ البيانات في مخزن مؤقت حتى تقرأ بايتات كافية للحصول على رسالة كاملة ومفهومة لتطبيقك.
أنت مسؤول عن تحديد حدود الرسائل وتتبعها. أما بالنسبة لمقبس TCP، فهو يُرسل ويستقبل بايتات خام من وإلى الشبكة، ولا يعرف شيئًا عن معنى هذه البايتات الخام.
لهذا السبب، عليك تحديد بروتوكول طبقة التطبيق. ما هو بروتوكول طبقة التطبيق؟ ببساطة، سيرسل تطبيقك الرسائل ويستقبلها. صيغة هذه الرسائل هي بروتوكول تطبيقك.
بمعنى آخر، يُحدد طول وتنسيق هذه الرسائل دلالات تطبيقك وسلوكه. يرتبط هذا مباشرةً بما تعلمته في الفقرة السابقة بشأن قراءة البايتات من المقبس. عند قراءة البايتات باستخدام دالة .recv()
، عليك متابعة عدد البايتات المقروءة، وتحديد حدود الرسائل.
كيف يمكنك فعل ذلك؟ إحدى الطرق هي إرسال رسائل ثابتة الطول دائمًا. إذا كانت بنفس الحجم دائمًا، فسيكون الأمر سهلًا. عندما تقرأ هذا العدد من البايتات في المخزن المؤقت، ستعلم أن لديك رسالة كاملة.
مع ذلك، يُعد استخدام الرسائل ذات الطول الثابت غير فعال في الرسائل الصغيرة، حيث يتطلب الأمر استخدام الحشو لملئها. كما ستظل تواجه مشكلة كيفية التعامل مع البيانات التي لا تتسع لها رسالة واحدة.
في هذا البرنامج التعليمي، ستتعلم نهجًا عامًا، يُستخدم في العديد من البروتوكولات، بما في ذلك HTTP. ستبدأ الرسائل بheader تتضمن طول المحتوى وأي حقول أخرى تحتاجها. بهذه الطريقة، ما عليك سوى متابعة header. بعد قراءة header، يمكنك معالجتها لتحديد طول محتوى الرسالة. باستخدام طول المحتوى، يمكنك قراءة عدد البايتات المطلوب لقراءته.
ستُطبّق ذلك بإنشاء فئة مُخصّصة تُمكّنك من إرسال واستقبال رسائل تحتوي على نص أو بيانات ثنائية. يُمكنك تحسين هذه الفئة وتوسيع نطاقها لتطبيقاتك الخاصة. والأهم من ذلك، ستتمكن من رؤية مثال لكيفية القيام بذلك.
قبل البدء، هناك شيء يجب معرفته بشأن المقابس والبايتات. كما تعلمت سابقًا، عند إرسال واستقبال البيانات عبر المقابس، فإنك ترسل وتستقبل بايتات خام.
إذا كنت تستقبل بيانات وترغب في استخدامها في سياق يُفسَّر على أنه بايتات متعددة، على سبيل المثال عدد صحيح مكون من 4 بايتات، فعليك مراعاة أنها قد تكون بتنسيق غير متوافق مع وحدة المعالجة المركزية لجهازك. قد يستخدم العميل أو الخادم على الطرف الآخر وحدة معالجة مركزية تستخدم ترتيب بايتات مختلفًا عن ترتيب بايتاتك. في هذه الحالة، ستحتاج إلى تحويلها إلى ترتيب بايتات جهازك المضيف قبل استخدامها.
يُشار إلى ترتيب البايتات هذا باسم “ترتيب البايتات في وحدة المعالجة المركزية”. يمكنك تجنب هذه المشكلة بالاستفادة من ترميز Unicode لرأس الرسالة واستخدام الترميز UTF-8. بما أن UTF-8 يستخدم ترميزًا من 8 بتات، فلا توجد مشاكل في ترتيب البايتات.
يمكنك العثور على شرح في وثائق ترميزات بايثون ويونيكود. يُرجى ملاحظة أن هذا ينطبق على رأس النص فقط. ستستخدم نوعًا وترميزًا محددين في الرأس للمحتوى المُرسَل، أي حمولة الرسالة. سيسمح لك هذا بنقل أي بيانات تُريدها (نصية أو ثنائية)، بأي صيغة.
يمكنك بسهولة تحديد ترتيب البايتات في جهازك باستخدام sys.byteorder. على سبيل المثال، يمكنك رؤية ما يلي:
$ python -c 'import sys; print(repr(sys.byteorder))'
'little'
إذا قمت بتشغيل هذا في جهاز افتراضي يحاكي وحدة المعالجة المركزية كبيرة الحجم (PowerPC)، فسيحدث شيء مثل هذا:
$ python -c 'import sys; print(repr(sys.byteorder))'
'big'
في هذا التطبيق التجريبي، يُعرّف بروتوكول طبقة التطبيق الخاص بك العنوان كنص Unicode بترميز UTF-8. بالنسبة للمحتوى الفعلي في الرسالة، أي حمولتها، سيتعيّن عليك تبديل ترتيب البايتات يدويًا عند الحاجة.
يعتمد هذا على تطبيقك وما إذا كان يحتاج إلى معالجة بيانات ثنائية متعددة البايتات من جهاز ذي ترتيب طرفي مختلف. يمكنك مساعدة عميلك أو خادمك على دعم البيانات الثنائية بإضافة رؤوس إضافية واستخدامها لتمرير المعلمات، كما هو الحال في HTTP.
لا تقلق إذا لم تفهم هذا بعد. في القسم التالي، ستفهم كيفية عمل كل هذا وترابطه.
رأس بروتوكول التطبيق
الآن ستُعرّف رأس البروتوكول بالكامل. رأس البروتوكول هو:
- نص متغير الطول
- Unicode مع الترميز UTF-8
- قاموس بايثون متسلسل باستخدام JSON
العناوين المطلوبة، أو العناوين الفرعية، في قاموس رأس البروتوكول هي كما يلي:
الاسم | الوصف |
---|---|
byteorder | ترتيب بايتات الجهاز (يستخدم sys.byteorder). قد لا يكون هذا مطلوبًا لتطبيقك. |
content-length | طول المحتوى بالبايت. |
content-type | نوع المحتوى في الحمولة، على سبيل المثال، text/json أو binary/my-binary-type. |
content-encoding | الترميز المستخدم بواسطة المحتوى، على سبيل المثال، utf-8 لنص Unicode أو ثنائي للبيانات الثنائية. |
تُعلم هذه الرؤوس المُستقبِلَ بمحتوى حمولة الرسالة. يتيح لك هذا إرسال بيانات عشوائية مع توفير معلومات كافية تُمكّن المُستقبِل من فك تشفير المحتوى وتفسيره بشكل صحيح. ولأن الرؤوس مُخزّنة في قاموس، يُمكن إضافة رؤوس إضافية بسهولة عن طريق إدراج أزواج مفتاح-قيمة حسب الحاجة.
إرسال رسالة تطبيق
لا تزال هناك مشكلة صغيرة. لديك رأس متغير الطول، وهو أمر جيد ومرن، ولكن كيف تعرف طول الرأس عند قراءته باستخدام .recv()
؟
عندما تعلمتَ سابقًا استخدام .recv()
وحدود الرسائل، تعلمتَ أيضًا أن الرؤوس ذات الطول الثابت قد تكون غير فعّالة. هذا صحيح، ولكنك ستستخدم رأسًا صغيرًا بطول ثابت، مكونًا من بايتين، لإضافة بادئة لرأس JSON الذي يحتوي على طوله.
يمكنك اعتبار هذا نهجًا هجينًا لإرسال الرسائل. في الواقع، أنت تُمهّد عملية استلام الرسالة بإرسال طول الترويسة أولًا. هذا يُسهّل على المُستقبِل تحليل الرسالة.
لمنحك فكرة أفضل عن تنسيق الرسالة، راجع الرسالة بالكامل:

تبدأ الرسالة برأس ثابت الطول مكون من بايتين، وهو عدد صحيح بترتيب بايتات الشبكة. هذا هو طول الرأس التالي، وهو رأس JSON متغير الطول. بمجرد قراءة بايتين باستخدام دالة .recv()
، يمكنك معالجة البايتين كعدد صحيح، ثم قراءة هذا العدد من البايتات قبل فك تشفير رأس JSON بتنسيق UTF-8.
يحتوي رأس JSON على قاموس رؤوس إضافية. أحدها هو طول المحتوى، وهو عدد بايتات محتوى الرسالة (باستثناء رأس JSON). بمجرد استدعاء دالة .recv()
وقراءة بايتات طول المحتوى، تكون قد وصلت إلى حد الرسالة، أي أنك قرأت الرسالة بأكملها.
فئة رسالة التطبيق
أخيرًا، المكافأة! في هذا القسم، ستدرس فئة الرسائل وترى كيفية استخدامها مع دالة .select()
عند حدوث أحداث القراءة والكتابة على المقبس.
يعكس هذا التطبيق النموذجي أنواع الرسائل التي يمكن للعميل والخادم استخدامها بشكل معقول. أنت الآن أبعد ما يكون عن استخدام عملاء وخوادم صدى الألعاب!
لتبسيط الأمور وتوضيح كيفية عمل التطبيقات الحقيقية، يستخدم هذا المثال بروتوكول تطبيق يُطبّق ميزة بحث أساسية. يُرسل العميل طلب بحث، ويبحث الخادم عن تطابق. إذا لم يُتعرّف على الطلب المُرسل من العميل كبحث، يفترض الخادم أنه طلب ثنائي ويُعيد استجابة ثنائية.
بعد قراءة الأقسام التالية، وتشغيل الأمثلة، وتجربة الكود، ستفهم آلية العمل. يمكنك بعد ذلك استخدام فئة Message
كنقطة بداية وتعديلها بما يناسب احتياجاتك.
هذا التطبيق ليس بعيدًا عن مثال العميل والخادم متعدد الاتصالات. يبقى كود حلقة الحدث كما هو في ملفي app-client.py وapp-server.py. ما ستفعله هو نقل كود الرسالة إلى فئة باسم Message وإضافة دوال لدعم قراءة وكتابة ومعالجة العناوين والمحتوى.
كما تعلمتَ سابقًا وسترى لاحقًا، يتضمن العمل مع المقابس الحفاظ على الحالة. باستخدام فئة، يمكنك الاحتفاظ بجميع الحالة والبيانات والرموز البرمجية مُجمّعة معًا في وحدة مُنظّمة. يتم إنشاء مثيل للفئة لكل مقبس في العميل والخادم عند بدء أو قبول اتصال.
الفئة متشابهة تقريبًا لكلٍّ من العميل والخادم في طُرق التغليف والأدوات المساعدة. تبدأ هذه الطُرق بعلامة سفلية، مثل Message._json_encode()
. تُبسّط هذه الطُرق العمل مع الفئة، وتُساعد الطُرق الأخرى على اختصارها، وتدعم مبدأ DRY.
تعمل فئة Message
في الخادم بنفس طريقة عمل العميل، والعكس صحيح. الفرق هو أن العميل يبدأ الاتصال ويرسل رسالة طلب، ثم يعالج رسالة استجابة الخادم. في المقابل، ينتظر الخادم الاتصال، ويعالج رسالة طلب العميل، ثم يرسل رسالة استجابة.
يبدو الأمر كالتالي:
الخطوة | نقطة النهاية | محتوى الإجراء / الرسالة |
---|---|---|
1 | العميل | يرسل رسالة تحتوي على محتوى الطلب |
2 | الخادم | يستقبل ويعالج رسالة طلب العميل |
3 | الخادم | إرسال رسالة تحتوي على محتوى الرد |
4 | العميل | يستقبل ويعالج رسالة استجابة الخادم |
إليك تخطيط الملف والكود:
التطبيق | الملف | الكود |
---|---|---|
الخادم | app-server.py | البرنامج النصي الرئيسي للخادم |
الخادم | libserver.py | فئة رسالة الخادم |
العميل | app-client.py | النص الرئيسي للعميل |
العميل | libclient.py | فئة رسالة العميل |
وبهذا، يجب أن يكون لديك نظرة عامة عالية المستوى على المكونات الفردية وأدوارها داخل التطبيق.
نقطة إدخال الرسالة
قد يكون فهم آلية عمل فئة Message
أمرًا صعبًا، نظرًا لوجود جانب من تصميمها قد لا يكون واضحًا للوهلة الأولى. لماذا؟ إدارة الحالة.
بعد إنشاء كائن Message
، يتم ربطه بمقبس تتم مراقبته بحثًا عن الأحداث باستخدام ()selector.register:
# ...
def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print(f"Accepted connection from {addr}")
conn.setblocking(False)
message = libserver.Message(sel, conn, addr)
sel.register(conn, selectors.EVENT_READ, data=message)
# ...
الفكرة الأساسية هنا هي أن كل كائن Message
يُنشأ عند قبول اتصال جديد. يرتبط بمقبس ويُسجل بمحدد لمراقبة الأحداث الواردة. يسمح هذا الإعداد للخادم بمعالجة اتصالات متعددة في وقت واحد، مما يضمن قراءة الرسائل فور توفرها.
عندما تكون الأحداث جاهزة على المقبس، تُرجعها دالة ()selector.select. يمكنك بعد ذلك الحصول على مرجع إلى كائن Message
باستخدام سمة البيانات في كائن key
، واستدعاء دالة في Message
:
# ...
try:
while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
message = key.data
try:
message.process_events(mask)
# ...
# ...
بالنظر إلى حلقة الأحداث أعلاه، ستلاحظ أن دالة ()sel.select هي المسؤولة. فهي تحجب الأحداث، وتنتظرها في أعلى الحلقة. وهي مسؤولة عن تنبيه أحداث القراءة والكتابة عندما تكون جاهزة للمعالجة على المقبس. وهذا يعني، بشكل غير مباشر، أنها مسؤولة أيضًا عن استدعاء دالة .process_events()
. ولهذا السبب، تُعتبر .process_events()
نقطة الدخول.
إليك ما يفعله التابع .process_events()
:
# ...
class Message:
def __init__(self, selector, sock, addr):
# ...
# ...
def process_events(self, mask):
if mask & selectors.EVENT_READ:
self.read()
if mask & selectors.EVENT_WRITE:
self.write()
# ...
هذا جيد: دالة .process_events()
بسيطة. لا يمكنها سوى تنفيذ وظيفتين: استدعاء .read()
و.write()
.
هنا يأتي دور إدارة الحالة. إذا اعتمدت طريقة أخرى على متغيرات حالة ذات قيمة معينة، فسيتم استدعاؤها فقط من .read()
و.write()
. هذا يُبقي المنطق بسيطًا قدر الإمكان مع وصول الأحداث إلى المقبس للمعالجة.
قد تميل إلى استخدام مزيج من بعض الطرق للتحقق من متغيرات الحالة الحالية، واستدعاء طرق أخرى، بناءً على قيمتها، لمعالجة البيانات خارج .read()
أو .write()
. في النهاية، قد يكون هذا الأمر معقدًا للغاية بحيث يصعب إدارته ومواكبته.
يجب عليك بالتأكيد تعديل الفئة لتناسب احتياجاتك الخاصة حتى تعمل بشكل أفضل. ولكن، من المرجح أن تحصل على أفضل النتائج إذا حافظت على عمليات التحقق من الحالة واستدعاءات الدوال التي تعتمد عليها في الدوال .read()
و.write()
إن أمكن.
الآن، انظر إلى .read()
. هذا هو إصدار الخادم، لكن إصدار العميل هو نفسه. فقط يستخدم اسم طريقة مختلف، .process_response()
بدلاً من .process_request()
:
# ...
class Message:
# ...
def read(self):
self._read()
if self._jsonheader_len is None:
self.process_protoheader()
if self._jsonheader_len is not None:
if self.jsonheader is None:
self.process_jsonheader()
if self.jsonheader:
if self.request is None:
self.process_request()
# ...
يتم استدعاء دالة ._read()
أولاً. وهي تستدعي دالة ()socket.recv لقراءة البيانات من المقبس وتخزينها في مخزن الاستقبال.
تذكر أنه عند استدعاء دالة ()socket.recv، قد لا تكون جميع البيانات التي تُكوّن الرسالة الكاملة قد وصلت بعد. قد يلزم استدعاء دالة ()socket.recv مرة أخرى. لهذا السبب، تُجرى عمليات فحص حالة لكل جزء من الرسالة قبل استدعاء الطريقة المناسبة لمعالجتها.
قبل أن تُعالج أي طريقة الجزء الخاص بها من الرسالة، فإنها تتحقق أولاً من قراءة عدد كافٍ من البايتات في مخزن الاستقبال. إذا قُرئت، تُعالج البايتات الخاصة بها، وتُزيلها من المخزن، ثم تُكتب مُخرجاتها في مُتغير يُستخدم في مرحلة المعالجة التالية. ولأن الرسالة تتكون من ثلاثة مكونات، فهناك ثلاثة اختبارات حالة واستدعاءات لطريقة المعالجة:
مكونات الرسالة | التابع | الناتج |
---|---|---|
Fixed-length header | process_protoheader() | self._jsonheader_len |
JSON header | process_jsonheader() | self.jsonheader |
Content | process_request() | self.request |
بعد ذلك، تحقق من .write()
. هذا هو إصدار الخادم:
# ... libserver.py
class Message:
# ...
def write(self):
if self.request:
if not self.response_created:
self.create_response()
self._write()
# ...
تتحقق دالة .write()
أولاً من وجود طلب. إذا وُجد طلب ولم تُنشأ استجابة، تُستدعى دالة .create_response(). تُعيّن دالة .create_response() متغير الحالة response_created وتكتب الاستجابة في مخزن الإرسال.
تستدعي طريقة ._write()
التابع ()socket.send إذا كانت هناك بيانات في مخزن الإرسال.
تذكر أنه عند استدعاء دالة ()socket.send، قد لا تكون جميع البيانات في مخزن الإرسال مُدرجة في قائمة الانتظار للإرسال. قد تكون مخازن الشبكة للمقبس ممتلئة، وقد يلزم استدعاء دالة ()socket.send مرة أخرى. لهذا السبب، توجد عمليات تحقق من الحالة. يجب استدعاء دالة .create_response()
مرة واحدة فقط، ولكن من المتوقع استدعاء دالة ._write()
عدة مرات.
إصدار العميل من .write()
مشابه:
# ... libclient.py
class Message:
def __init__(self, selector, sock, addr, request):
# ...
def write(self):
if not self._request_queued:
self.queue_request()
self._write()
if self._request_queued:
if not self._send_buffer:
# Set selector to listen for read events, we're done writing.
self._set_selector_events_mask("r")
# ...
لأن العميل يبدأ اتصالاً بالخادم ويرسل طلبًا أولًا، يُفعّل متغير الحالة _request_queued
. إذا لم يُوضع الطلب في قائمة الانتظار، فإنه يستدعي دالة .queue_request()
. تُنشئ دالة ()queue_request الطلب وتكتبه في مخزن الإرسال. كما تُعيّن متغير الحالة _request_queued
ليتم استدعاؤه مرة واحدة فقط.
كما هو الحال مع الخادم، تستدعي دالة ._write()
دالة ()socket.send إذا كانت هناك بيانات في مخزن الإرسال. الفرق الملحوظ في إصدار العميل من دالة .write()
هو التحقق الأخير للتأكد من وضع الطلب في قائمة الانتظار.
سيتم شرح ذلك بمزيد من التفصيل في قسم “النص الرئيسي للعميل”، ولكن الغرض من ذلك هو توجيه دالة ()selector.select للتوقف عن مراقبة المقبس بحثًا عن أحداث الكتابة. إذا تم وضع الطلب في قائمة الانتظار وكان مخزن الإرسال فارغًا، فقد انتهيت من الكتابة، وستقتصر اهتمامك على أحداث القراءة فقط. لا داعي لإعلامك بأن المقبس قابل للكتابة.
لاختتام هذا القسم، فكر في هذه الفكرة: كان الغرض الرئيسي من هذا القسم هو شرح أن ()selector.select تستدعي فئة Message عبر التابع .process_events()
ووصف كيفية إدارة الحالة.
هذا مهم لأن دالة .process_events()
ستُستدعى عدة مرات طوال عمر الاتصال. لذلك، تأكد من أن أي دالة يجب استدعاؤها مرة واحدة فقط تتحقق من متغير حالة بنفسها، أو أن المُستدعي يتحقق من متغير الحالة الذي تُحدده الدالة.
البرنامج النصي الرئيسي للخادم
في البرنامج النصي الرئيسي للخادم app-server.py، تتم قراءة الوسائط من سطر الأوامر التي تحدد الواجهة والمنفذ للاستماع عليهما:
$ python app-server.py
Usage: app-server.py <host> <port>
على سبيل المثال، للاستماع على واجهة الحلقة الراجعة على المنفذ 65432، أدخل:
$ python app-server.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)
استخدم سلسلة فارغة لـ <host>
للاستماع على كافة الواجهات.
بعد إنشاء المقبس، يتم إجراء مكالمة إلى ()socket.setsockopt مع الخيار socket.SO_REUSEADDR:
# ... app-server.py
host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Avoid bind() exception: OSError: [Errno 48] Address already in use
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
# ...
يؤدي ضبط خيار المقبس هذا إلى تجنب خطأ ” Address already in use
“. ستلاحظ هذا عند بدء تشغيل الخادم على منفذ به اتصالات في حالة TIME_WAIT.
على سبيل المثال، إذا أغلق الخادم اتصالاً بنشاط، فسيبقى في حالة انتظار مؤقت (TIME_WAIT) لمدة دقيقتين أو أكثر، حسب نظام التشغيل. إذا حاولت تشغيل الخادم مرة أخرى قبل انتهاء حالة الانتظار المؤقت، فستتلقى استثناء خطأ OSError للعنوان المستخدم مسبقًا. هذا الإجراء وقائي لضمان عدم وصول أي حزم متأخرة في الشبكة إلى التطبيق الخطأ.
تلتقط حلقة الحدث أي أخطاء حتى يتمكن الخادم من البقاء قيد التشغيل ومواصلة التشغيل:
# ... app-server.py
try:
while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
message = key.data
try:
message.process_events(mask)
except Exception:
print(
f"Main: Error: Exception for {message.addr}:\n"
f"{traceback.format_exc()}"
)
message.close()
except KeyboardInterrupt:
print("Caught keyboard interrupt, exiting")
finally:
sel.close()
عند قبول اتصال العميل، يتم إنشاء كائن Message
:
# ... app-server.py
def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print(f"Accepted connection from {addr}")
conn.setblocking(False)
message = libserver.Message(sel, conn, addr)
sel.register(conn, selectors.EVENT_READ, data=message)
# ...
كائن Message
مرتبط بالمقبس في استدعاء ()sel.register، ومُعد في البداية لمراقبة أحداث القراءة فقط. بعد قراءة الطلب، ستُعدِّله لمراقبة أحداث الكتابة فقط.
من فوائد اتباع هذا النهج في الخادم أنه في معظم الحالات، عندما يكون المقبس سليمًا ولا توجد مشكلات في الشبكة، فسيكون دائمًا قابلاً للكتابة.
إذا طلبت من دالة ()sel.register مراقبة EVENT_WRITE أيضًا، فستُفعّل حلقة الحدث فورًا وتُعلمك بذلك. مع ذلك، في هذه المرحلة، لا داعي للاستيقاظ واستدعاء دالة .send()
على المقبس. لا توجد استجابة لإرسالها، لأن الطلب لم يُعالَج بعد. سيؤدي هذا إلى استهلاك دورات وحدة المعالجة المركزية (CPU) القيّمة وإهدارها.
فئة رسالة الخادم
في قسم “نقطة إدخال الرسالة”، تعلمت كيفية استدعاء كائن Message
عند جاهزية أحداث المقبس عبر دالة .process_events()
. ستتعلم الآن ما يحدث عند قراءة البيانات على المقبس وجاهزية أحد مكونات الرسالة للمعالجة بواسطة الخادم.
فئة رسالة الخادم موجودة في ملف libserver.py.
تظهر التوابع في الفصل بالترتيب الذي تتم به معالجة الرسالة.
عندما يقرأ الخادم بايتين على الأقل، يمكن معالجة الرأس ذي الطول الثابت:
# ...libserver.py
class Message:
def __init__(self, selector, sock, addr):
# ...
# ...
def process_protoheader(self):
hdrlen = 2
if len(self._recv_buffer) >= hdrlen:
self._jsonheader_len = struct.unpack(
">H", self._recv_buffer[:hdrlen]
)[0]
self._recv_buffer = self._recv_buffer[hdrlen:]
# ...
الرأس ذو الطول الثابت هو عدد صحيح ثنائي البايتات بترتيب شبكي أو ترتيب بايتات كبير. يحتوي على طول رأس JSON. ستستخدم دالة ()struct.unpack لقراءة القيمة وفك تشفيرها وتخزينها في self._jsonheader_len. بعد معالجة جزء الرسالة المسؤول عنه، تقوم دالة .process_protoheader()
بإزالته من مخزن الاستلام المؤقت.
تمامًا كما هو الحال مع الرأس ذي الطول الثابت، عندما تكون هناك بيانات كافية في المخزن المؤقت للاستلام لاحتواء رأس JSON، فيمكن معالجتها أيضًا:
# ... libserver.py
class Message:
# ...
def process_jsonheader(self):
hdrlen = self._jsonheader_len
if len(self._recv_buffer) >= hdrlen:
self.jsonheader = self._json_decode(
self._recv_buffer[:hdrlen], "utf-8"
)
self._recv_buffer = self._recv_buffer[hdrlen:]
for reqhdr in (
"byteorder",
"content-length",
"content-type",
"content-encoding",
):
if reqhdr not in self.jsonheader:
raise ValueError(f"Missing required header '{reqhdr}'.")
# ...
التالي هو المحتوى الفعلي، أو الحمولة، للرسالة. يُوصف برأس JSON في self.jsonheader. عند توفر بايتات content-length
في مخزن الاستلام، يُمكن معالجة الطلب:
# ... libserver.py
class Message:
# ...
def process_request(self):
content_len = self.jsonheader["content-length"]
if not len(self._recv_buffer) >= content_len:
return
data = self._recv_buffer[:content_len]
self._recv_buffer = self._recv_buffer[content_len:]
if self.jsonheader["content-type"] == "text/json":
encoding = self.jsonheader["content-encoding"]
self.request = self._json_decode(data, encoding)
print(f"Received request {self.request!r} from {self.addr}")
else:
# Binary or unknown content-type
self.request = data
print(
f"Received {self.jsonheader['content-type']} "
f"request from {self.addr}"
)
# Set selector to listen for write events, we're done reading.
self._set_selector_events_mask("w")
# ...
بعد حفظ محتوى الرسالة في متغير البيانات، تقوم دالة .process_request()
بإزالته من مخزن الاستلام. ثم، إذا كان نوع المحتوى JSON، تقوم دالة .process_request()
بفك تشفيره وإلغاء تسلسله. أما إذا لم يكن كذلك، فسيفترض هذا التطبيق أنه طلب ثنائي، ويطبع نوع المحتوى ببساطة.
آخر ما تفعله دالة .process_request()
هو تعديل المُحدِّد لمراقبة أحداث الكتابة فقط. في البرنامج النصي الرئيسي للخادم، app-server.py، يكون المقبس مُعيَّنًا في البداية لمراقبة أحداث القراءة فقط. الآن، وبعد معالجة الطلب بالكامل، لم تعد مهتمًا بالقراءة.
يمكن الآن إنشاء استجابة وكتابتها في المقبس. عندما يكون المقبس قابلاً للكتابة، يتم استدعاء دالة .create_response()
من دالة .write()
:
# ...libserver.py
class Message:
# ...
def create_response(self):
if self.jsonheader["content-type"] == "text/json":
response = self._create_response_json_content()
else:
# Binary or unknown content-type
response = self._create_response_binary_content()
message = self._create_message(**response)
self.response_created = True
self._send_buffer += message
يتم إنشاء الاستجابة عن طريق استدعاء دوال أخرى، حسب نوع المحتوى. في هذا المثال، يتم إجراء بحث بسيط في القاموس لطلبات JSON عندما يكون action == 'search'
. لتطبيقاتك الخاصة، يمكنك تعريف دوال أخرى يتم استدعاؤها هنا.
بعد إنشاء رسالة الاستجابة، يُضبط متغير الحالة self.response_created بحيث لا تستدعي دالة .write()
دالة .create_response()
مرة أخرى. وأخيرًا، تُضاف الاستجابة إلى مخزن الإرسال. يتم عرضها وإرسالها عبر دالة ._write()
.
من الأمور الصعبة معرفة كيفية إغلاق الاتصال بعد كتابة الاستجابة. يمكنك وضع استدعاء .close()
في الدالة ._write()
:
# ... libserver.py
class Message:
# ...
def _write(self):
if self._send_buffer:
print(f"Sending {self._send_buffer!r} to {self.addr}")
try:
# Should be ready to write
sent = self.sock.send(self._send_buffer)
except BlockingIOError:
# Resource temporarily unavailable (errno EWOULDBLOCK)
pass
else:
self._send_buffer = self._send_buffer[sent:]
# Close when the buffer is drained. The response has been sent.
if sent and not self._send_buffer:
self.close()
# ...
على الرغم من أن الأمر مخفي نوعًا ما، إلا أن هذا تنازل مقبول نظرًا لأن فئة Message
تتعامل مع رسالة واحدة فقط لكل اتصال. بعد كتابة الاستجابة، لا يتبقى للخادم أي شيء ليفعله. لقد أكمل عمله.
البرنامج النصي الرئيسي للعميل
في البرنامج النصي الرئيسي للعميل، app-client.py، تتم قراءة الوسائط من سطر الأوامر واستخدامها لإنشاء الطلبات وبدء الاتصالات بالخادم:
$ python app-client.py
Usage: app-client.py <host> <port> <action> <value>
وهنا مثال:
$ python app-client.py 127.0.0.1 65432 search needle
بعد إنشاء قاموس يمثل الطلب من وسيطات سطر الأوامر، يتم تمرير قاموس المضيف والمنفذ والطلب إلى .start_connection()
:
# ...app-client.py
def start_connection(host, port, request):
addr = (host, port)
print(f"Starting connection to {addr}")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
sock.connect_ex(addr)
events = selectors.EVENT_READ | selectors.EVENT_WRITE
message = libclient.Message(sel, sock, addr, request)
sel.register(sock, events, data=message)
# ...
يتم إنشاء مقبس لاتصال الخادم، بالإضافة إلى كائن رسالة باستخدام قاموس request
.
كما هو الحال بالنسبة للخادم، يرتبط كائن Message
بالمقبس عند استدعاء دالة ()sel.register. أما بالنسبة للعميل، فيتم ضبط المقبس مبدئيًا لمراقبة أحداث القراءة والكتابة. بعد كتابة الطلب، ستُعدِّله للاستماع لأحداث القراءة فقط.
يمنحك هذا النهج نفس ميزة الخادم: عدم إهدار دورات وحدة المعالجة المركزية. بعد إرسال الطلب، لن تعود مهتمًا بأحداث الكتابة، لذا لا داعي للاستيقاظ ومعالجتها.
فئة رسالة العميل
في قسم “نقطة إدخال الرسالة”، تعلّمت كيفية استدعاء كائن الرسالة عند جاهزية أحداث المقبس عبر دالة .process_events()
. ستتعلّم الآن ما يحدث بعد قراءة البيانات وكتابتها على المقبس، وبعد أن تصبح الرسالة جاهزة للمعالجة من قِبل العميل.
تظهر التوابع في الفصل بالترتيب الذي تتم به معالجة الرسالة.
المهمة الأولى للعميل هي وضع الطلب في قائمة الانتظار:
# ...libclient.py
class Message:
# ...
def queue_request(self):
content = self.request["content"]
content_type = self.request["type"]
content_encoding = self.request["encoding"]
if content_type == "text/json":
req = {
"content_bytes": self._json_encode(content, content_encoding),
"content_type": content_type,
"content_encoding": content_encoding,
}
else:
req = {
"content_bytes": content,
"content_type": content_type,
"content_encoding": content_encoding,
}
message = self._create_message(**req)
self._send_buffer += message
self._request_queued = True
# ...
القواميس المستخدمة لإنشاء الطلب، بناءً على ما تم تمريره عبر سطر الأوامر، موجودة في البرنامج النصي الرئيسي للعميل، app-client.py. يُمرر قاموس الطلب كمُعامل إلى الفئة عند إنشاء كائن رسالة.
يتم إنشاء رسالة الطلب وإضافتها إلى مخزن الإرسال، الذي يراها ويرسلها عبر دالة ._write()
. يتم ضبط متغير الحالة self._request_queued بحيث لا يتم استدعاء دالة .queue_request()
مرة أخرى.
بعد إرسال الطلب، ينتظر العميل الرد من الخادم.
تُعدّ طرق قراءة ومعالجة الرسائل في العميل هي نفسها المستخدمة في الخادم. عند قراءة بيانات الاستجابة من المقبس، تُستدعى طريقتا رأس العملية: .process_protoheader()
و.process_jsonheader()
.
الفرق هو في تسمية توابع العملية النهائية وحقيقة أنها تقوم بمعالجة الاستجابة، وليس إنشاء واحدة: .process_response()
، و._process_response_json_content()
، و._process_response_binary_content()
.
أخيرًا، ولكن ليس آخرًا، هو النداء الأخير لـ .process_response()
:
# ...libclient.py
class Message:
# ...
def process_response(self):
# ...
# Close when response has been processed
self.close()
# ...
حسنًا. يمكنك الآن إنهاء فئة الرسالة.
تشغيل تطبيق العميل والخادم
بعد كل هذا العمل الشاق، حان الوقت للاستمتاع ببعض المرح وإجراء بعض عمليات البحث!
في هذه الأمثلة، ستشغّل الخادم بحيث يستمع إلى جميع الواجهات عن طريق تمرير سلسلة فارغة لمعلمة المضيف. سيسمح لك هذا بتشغيل العميل والاتصال من جهاز افتراضي متصل بشبكة أخرى. يُحاكي هذا جهاز PowerPC كبير الطرف.
أولاً، قم بتشغيل الخادم:
$ python app-server.py '' 65432
Listening on ('', 65432)
الآن شغّل العميل وأدخل بحثًا. حاول العثور عليه:
$ python app-client.py 10.0.1.1 65432 search morpheus
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 41}{"action": "search", "value": "morpheus"}' to ('10.0.1.1', 65432)
Received response {'result': 'Follow the white rabbit. 🐰'} from ('10.0.1.1', 65432)
Got result: Follow the white rabbit. 🐰
Closing connection to ('10.0.1.1', 65432)
قد تلاحظ أن المحطة الطرفية تقوم بتشغيل ترميز نص Unicode (UTF-8)، وبالتالي تتم طباعة الإخراج أعلاه بشكل جيد مع الرموز التعبيرية.
الآن انظر إذا كان بإمكانك العثور على الجراء:
$ python app-client.py 10.0.1.1 65432 search 🐶
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"action": "search", "value": "\xf0\x9f\x90\xb6"}' to ('10.0.1.1', 65432)
Received response {'result': '🐾 Playing ball! 🏐'} from ('10.0.1.1', 65432)
Got result: 🐾 Playing ball! 🏐
Closing connection to ('10.0.1.1', 65432)
لاحظ سلسلة البايتات المُرسلة عبر الشبكة للطلب في سطر الإرسال. يسهل عليك معرفة ذلك إذا بحثت عن البايتات المطبوعة بالنظام السداسي عشري التي تُمثل رمز الجرو التعبيري: \xf0\x9f\x90\xb6. إذا كان جهازك يستخدم نظام Unicode بترميز UTF-8، فستتمكن من إدخال الرمز التعبيري للبحث.
هذا يُظهر أنك تُرسل بايتات خام عبر الشبكة، ويجب على المُستقبِل فك تشفيرها لتفسيرها بشكل صحيح. لهذا السبب، بذلتَ كل هذا الجهد لإنشاء رأس يحتوي على نوع المحتوى والترميز.
فيما يلي إخراج الخادم من اتصالات العميل أعلاه:
$ python app-server.py '' 65432
Listening on ('', 65432)
Accepted connection from ('10.0.2.2', 55340)
Received request {'action': 'search', 'value': 'morpheus'} from ('10.0.2.2', 55340)
Sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 43}{"result": "Follow the white rabbit. \xf0\x9f\x90\xb0"}' to ('10.0.2.2', 55340)
Closing connection to ('10.0.2.2', 55340)
Accepted connection from ('10.0.2.2', 55338)
Received request {'action': 'search', 'value': '🐶'} from ('10.0.2.2', 55338)
Sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"result": "\xf0\x9f\x90\xbe Playing ball! \xf0\x9f\x8f\x90"}' to ('10.0.2.2', 55338)
Closing connection to ('10.0.2.2', 55338)
انظر إلى سطر sending
لترى البايتات التي كُتبت في مقبس العميل. هذه هي رسالة استجابة الخادم.
يمكنك أيضًا اختبار إرسال الطلبات الثنائية إلى الخادم إذا كانت وسيطة الإجراء أي شيء آخر غير search
:
$ python app-client.py 10.0.1.1 65432 binary 😃
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00|{"byteorder": "big", "content-type": "binary/custom-client-binary-type", "content-encoding": "binary", "content-length": 10}binary\xf0\x9f\x98\x83' to ('10.0.1.1', 65432)
Received binary/custom-server-binary-type response from ('10.0.1.1', 65432)
Got response: b'First 10 bytes of request: binary\xf0\x9f\x98\x83'
Closing connection to ('10.0.1.1', 65432)
لأن طلب content-type
ليس text/json
، يعامله الخادم كنوع ثنائي مخصص ولا يُفكك تشفير JSON. يطبع نوع المحتوى ببساطة ويُعيد أول عشرة بايتات إلى العميل.
$ python app-server.py '' 65432
Listening on ('', 65432)
Accepted connection from ('10.0.2.2', 55320)
Received binary/custom-client-binary-type request from ('10.0.2.2', 55320)
Sending b'\x00\x7f{"byteorder": "little", "content-type": "binary/custom-server-binary-type", "content-encoding": "binary", "content-length": 37}First 10 bytes of request: binary\xf0\x9f\x98\x83' to ('10.0.2.2', 55320)
Closing connection to ('10.0.2.2', 55320)
إذا كان كل شيء يسير كما هو متوقع، فأنت جاهز! ولكن، إذا واجهت أي مشاكل أثناء العملية، فلا تقلق. إليك بعض الإرشادات لمساعدتك على العودة إلى المسار الصحيح.
استكشاف الأخطاء وإصلاحها
لا مفر من أن يحدث خطأ ما، وستتساءل عما يجب فعله. لا تقلق، فهذا يحدث للجميع. نأمل أن تتمكن من البدء من جديد باستخدام جزء الكود المصدري بمساعدة هذا البرنامج التعليمي، ومصحح الأخطاء، ومحرك البحث المفضل لديك.
إذا لم يكن الأمر كذلك، فإن محطتك الأولى هي وثائق وحدة socket في بايثون. تأكد من قراءة جميع وثائق كل دالة أو تابع تستدعيه. اقرأ أيضًا قسم المراجع أدناه للحصول على أفكار، وخاصةً قسم الأخطاء.
أحيانًا، لا يقتصر الأمر على شفرة المصدر. قد تكون شفرة المصدر صحيحة، والمشكلة في المضيف الآخر، العميل أو الخادم. أو قد تكون الشبكة. ربما يكون جهاز التوجيه، أو جدار الحماية، أو أي جهاز شبكة آخر يلعب دور الوسيط.
لحل هذه المشاكل، تُعد الأدوات الإضافية ضرورية. فيما يلي بعض الأدوات المساعدة التي قد تُساعد، أو على الأقل تُقدم بعض الأدلة.
أمر ping
يتحقق أمر ping من أن المضيف نشط ومتصل بالشبكة عبر إرسال طلب صدى ICMP. يتواصل هذا الأمر مباشرةً مع حزمة بروتوكولات TCP/IP لنظام التشغيل، ما يعني أنه يعمل بشكل مستقل عن أي تطبيق يعمل على المضيف.
فيما يلي مثال لتشغيل ping على macOS:
$ ping -c 3 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.058 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.165 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.164 ms
--- 127.0.0.1 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.058/0.129/0.165/0.050 ms
لاحظ الإحصائيات في النهاية. قد يكون هذا مفيدًا عند محاولة اكتشاف مشاكل الاتصال المتقطعة. على سبيل المثال، هل هناك أي فقدان في حزم البيانات؟ ما هو زمن الوصول؟ يمكنك التحقق من أوقات الذهاب والإياب.
إذا كان هناك جدار حماية بينك وبين المضيف الآخر، فقد لا يُسمح بطلب صدى ping. يطبق بعض مسؤولي جدران الحماية سياساتٍ تُطبّق ذلك. والهدف هو منع اكتشاف مضيفيهم. إذا كان الأمر كذلك، وكنت قد أضفت قواعد جدار حماية للسماح للمضيفين بالتواصل، فتأكد من أن هذه القواعد تسمح أيضًا بمرور بروتوكول ICMP بينهما.
بروتوكول ICMP هو البروتوكول المستخدم في أمر ping، وهو أيضًا البروتوكول الذي يستخدمه TCP وغيره من البروتوكولات منخفضة المستوى لتوصيل رسائل الخطأ. إذا كنت تواجه سلوكًا غريبًا أو اتصالات بطيئة، فقد يكون هذا هو السبب.
يتم تحديد رسائل ICMP حسب نوعها وترميزها. ولإعطائك فكرة عن المعلومات المهمة التي تحملها، إليك بعضًا منها:
نوع ICMP | رمز ICMP | الوصف |
---|---|---|
8 | 0 | طلب صدى |
0 | 0 | رد الصدى |
3 | 0 | شبكة الوجهة غير قابلة للوصول |
3 | 1 | المضيف الوجهة غير قابل للوصول |
3 | 2 | بروتوكول الوجهة غير قابل للوصول |
3 | 3 | منفذ الوجهة غير قابل للوصول |
3 | 4 | التجزئة مطلوبة، وتعيين علم DF |
11 | 0 | انتهت صلاحية TTL أثناء النقل |
الأمر netstat
في قسم “عرض حالة المقبس”، تعلّمت كيفية استخدام أداة netstat لعرض معلومات حول المقابس وحالتها الحالية. هذه الأداة متوفرة على أنظمة macOS وLinux وWindows.
لم يذكر هذا القسم عمودي Recv-Q وSend-Q في مُخرَجات المثال. سيُظهر لك هذان العمودان عدد البايتات المُخزَّنة في مخازن الشبكة المُعَدَّة للإرسال أو الاستلام، ولكن لسببٍ ما لم تتم قراءتها أو كتابتها بواسطة التطبيق البعيد أو المحلي.
بمعنى آخر، تنتظر البايتات في مخازن الشبكة ضمن طوابير نظام التشغيل. قد يكون أحد الأسباب هو أن التطبيق مُقيّد بوحدة المعالجة المركزية (CPU) أو غير قادر على استدعاء ()socket.recv أو ()socket.send ومعالجة البايتات. أو قد تكون هناك مشاكل في الشبكة تؤثر على الاتصالات، مثل ازدحام الشبكة أو عطل في أجهزة الشبكة أو الكابلات.
لإثبات ذلك ومعرفة كمية البيانات التي يمكنك إرسالها قبل ظهور خطأ، يمكنك تجربة عميل اختبار يتصل بخادم اختبار ويستدعي ()socket.send بشكل متكرر. لا يستدعي خادم الاختبار ()socket.recv أبدًا، بل يقبل الاتصال فقط. يؤدي هذا إلى امتلاء مخازن الشبكة على الخادم، مما يؤدي في النهاية إلى ظهور خطأ على العميل.
أولاً، قم بتشغيل الخادم:
$ python app-server-test.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)
ثم قم بتشغيل العميل لمعرفة الخطأ:
$ python app-client-test.py 127.0.0.1 65432 binary test
Error: socket.send() blocking io exception for ('127.0.0.1', 65432):
BlockingIOError(35, 'Resource temporarily unavailable')
فيما يلي مخرجات netstat أثناء استمرار تشغيل العميل والخادم، مع قيام العميل بطباعة رسالة الخطأ أعلاه عدة مرات:
$ netstat -an | grep 65432
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 408300 0 127.0.0.1.65432 127.0.0.1.53225 ESTABLISHED
tcp4 0 269868 127.0.0.1.53225 127.0.0.1.65432 ESTABLISHED
tcp4 0 0 127.0.0.1.65432 *.* LISTEN
الإدخال الأول هو الخادم (العنوان المحلي لديه المنفذ 65432):
لاحظ Recv-Q: 408300.
الإدخال الثاني هو العميل (العنوان الأجنبي لديه المنفذ 65432):
لاحظ Send-Q: 269868.
كان العميل يحاول كتابة البايتات، لكن الخادم لم يكن يقرأها. أدى ذلك إلى امتلاء قائمة انتظار مخزن الشبكة المؤقت للخادم في جهة الاستقبال، وامتلاء قائمة انتظار مخزن الشبكة المؤقت للعميل في جهة الإرسال.
أدوات لنظام التشغيل Windows
إذا كنت تعمل بنظام Windows، فهناك مجموعة من الأدوات المساعدة التي يجب عليك بالتأكيد التحقق منها إذا لم تكن قد فعلت ذلك بالفعل: Windows Sysinternals.
أحدها هو TCPView و هو أداة إحصائية رسومية لنظام ويندوز. بالإضافة إلى العناوين وأرقام المنافذ وحالة المقبس، يعرض لك إجمالي عدد الحزم والبايتات المرسلة والمستلمة.

كما هو الحال مع أداة يونكس lsof، يمكنك أيضًا الحصول على اسم العملية ومعرّفها. راجع القوائم للاطلاع على خيارات العرض الأخرى.
محلل الشبكة Wireshark
أحيانًا تحتاج إلى رؤية ما يحدث على الشبكة. انسَ ما يُشير إليه سجل التطبيق أو القيمة المُعادة من استدعاء مكتبة. أنت تُريد رؤية ما يُرسل أو يُستقبل فعليًا على الشبكة. كما هو الحال مع مُصححات الأخطاء، لا بديل عن ذلك.
Wireshark هو تطبيق لتحليل بروتوكولات الشبكة والتقاط حركة البيانات، يعمل على أنظمة macOS وLinux وWindows وغيرها. يتوفر منه إصدار بواجهة رسومية يُسمى Wireshark، وإصدار نصي طرفي يُسمى tshark.
يُعدّ تشغيل خاصية التقاط حركة البيانات طريقةً رائعةً لمراقبة أداء التطبيق على الشبكة وجمع أدلة حول ما يرسله ويستقبله، ومدى تكراره وكميته. ستتمكن أيضًا من معرفة متى يُغلق العميل أو الخادم اتصالًا أو يُلغيه أو يتوقف عن الاستجابة. يمكن أن تكون هذه المعلومات مفيدةً للغاية عند استكشاف الأخطاء وإصلاحها.
هناك العديد من البرامج التعليمية الجيدة والموارد الأخرى على الويب التي سترشدك خلال أساسيات استخدام Wireshark و TShark.
مرجع سريع
يمكنك استخدام هذا القسم كمرجع عام يحتوي على معلومات إضافية وروابط لموارد خارجية حول الشبكات والمقابس.
توثيق بايثون
أولاً، قد ترغب في التحقق من وثائق Python الرسمية:
لمزيد من القراءة، فكر في استكشاف البرامج التعليمية والإرشادات عبر الإنترنت التي توفر أمثلة عملية وشروحات متعمقة لمفاهيم برمجة المقبس.
أخطاء المقبس
ما يلي مأخوذ من وثائق وحدة socket الخاصة بـ Python:
جميع الأخطاء تُثير استثناءات. يمكن إثارة الاستثناءات العادية لأنواع الحجج غير الصالحة وحالات نفاد الذاكرة؛ بدءًا من إصدار بايثون 3.3، تُثير الأخطاء المتعلقة بدلالات المقبس أو العنوان خطأ OSError أو إحدى فئاته الفرعية. (المصدر)
فيما يلي بعض الأخطاء الشائعة التي من المحتمل أن تواجهها عند العمل مع المقابس:
استثناء | ثابت errno | الوصف |
---|---|---|
BlockingIOError | EWOULDBLOCK | المورد غير متاح مؤقتًا. على سبيل المثال، في وضع عدم الحظر، عند استدعاء دالة .send() وكان الطرف الآخر مشغولًا ولا يقرأ، تكون قائمة انتظار الإرسال (مخزن الشبكة المؤقت) ممتلئة. أو قد تكون هناك مشاكل في الشبكة. نأمل أن تكون هذه مشكلة مؤقتة. |
OSError | EADDRINUSE | العنوان مُستخدَم بالفعل. تأكد من عدم وجود عملية أخرى قيد التشغيل تستخدم رقم المنفذ نفسه، وأن خادمك يُعيِّن خيار المقبس SO_REUSEADDR: socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ). |
ConnectionResetError | ECONNRESET | تم إعادة ضبط الاتصال بواسطة نظير. تعطلت العملية البعيدة أو لم تُغلق منفذها بشكل صحيح، وهو ما يُعرف أيضًا بإغلاق غير نظيف. أو يوجد جدار حماية أو جهاز آخر في مسار الشبكة لا يعمل بشكل صحيح. |
TimeoutError | ETIMEDOUT | انتهت العملية. لا يوجد رد من الطرف الآخر. |
ConnectionRefusedError | ECONNREFUSED | تم رفض الاتصال. لا يوجد تطبيق يستمع على المنفذ المحدد. |
من الجيد أن تتعرف على أخطاء المقبس الشائعة هذه، حيث أن فهمها يمكن أن يساعدك في تشخيص مشكلات الشبكة واستكشاف أخطائها وإصلاحها بشكل أكثر فعالية.
عائلات عناوين المقبس
يُمثل socket.AF_INET و socket.AF_INET6 عائلات العناوين والبروتوكولات المستخدمة للوسيطة الأولى لـ ()socket.socket. تتوقع واجهات برمجة التطبيقات (APIs) التي تستخدم عنوانًا أن يكون بتنسيق معين، وذلك حسب ما إذا كان المقبس قد تم إنشاؤه باستخدام socket.AF_INET أو socket.AF_INET6.
عنوان العائلة | البروتوكول | مجموعة عناوين | الوصف |
---|---|---|---|
socket.AF_INET | IPv4 | (host, port) | host عبارة عن سلسلة تحتوي على اسم مضيف مثل ‘www.example.com’ أو عنوان IPv4 مثل ‘10.1.2.3’. port هو عدد صحيح. |
socket.AF_INET6 | IPv6 | (host, port, flowinfo, scopeid) | host عبارة عن سلسلة تحتوي على اسم مضيف مثل ‘www.example.com’ أو عنوان IPv6 مثل ‘fe80::6203:7ab:fe88:9c23’. port هو عدد صحيح. يمثل flowinfo وscopeid الأعضاء sin6_flowinfo وsin6_scope_id في بنية C sockaddr_in6. |
لاحظ المقتطف أدناه من وثائق وحدة socket الخاصة بـ Python فيما يتعلق بقيمة المضيف لمجموعة العناوين:
بالنسبة لعناوين IPv4، يُقبل نموذجان خاصان بدلاً من عنوان المضيف: السلسلة الفارغة تُمثل INADDR_ANY، والسلسلة ‘<broadcast>’ تُمثل INADDR_BROADCAST. هذا السلوك غير متوافق مع IPv6، لذا يُنصح بتجنبهما إذا كنت تنوي دعم IPv6 في برامج بايثون. (المصدر)
راجع وثائق عائلات Socket الخاصة بـ Python للحصول على مزيد من المعلومات.
يستخدم هذا البرنامج التعليمي مقابس IPv4، ولكن إذا كانت شبكتك تدعمها، فحاول اختبار IPv6 واستخدامه إن أمكن. إحدى الطرق السهلة لدعم ذلك هي استخدام الدالة ()socket.getaddrinfo. تُترجم هذه الدالة وسيطات host
و port
إلى سلسلة من خمسة أزواج تحتوي على جميع الوسيطات اللازمة لإنشاء مقبس متصل بتلك الخدمة.
ملاحظة: سوف تفهم وتفسر socket.getaddrinfo() عناوين IPv6 المرسلة وأسماء المضيفين التي يتم تحويلها إلى عناوين IPv6، بالإضافة إلى IPv4.
يعيد المثال التالي معلومات العنوان لاتصال TCP إلى example.org على المنفذ 80:
>>> socket.getaddrinfo("example.org", 80, proto=socket.IPPROTO_TCP)
[(<AddressFamily.AF_INET6: 10>, <SocketType.SOCK_STREAM: 1>,
6, '', ('2606:2800:220:1:248:1893:25c8:1946', 80, 0, 0)),
(<AddressFamily.AF_INET: 2>, <SocketType.SOCK_STREAM: 1>,
6, '', ('93.184.216.34', 80))]
قد تختلف النتائج على نظامك إذا لم يكن IPv6 مُفعّلاً. يمكن استخدام القيم المُعادة أعلاه بتمريرها إلى ()socket.socket و ()socket.connect. يتوفر مثال للعميل والخادم في قسم “الأمثلة” في وثائق وحدة socket في بايثون.
استخدام أسماء المضيفين
للسياق، ينطبق هذا القسم بشكل أساسي على استخدام أسماء المضيفين مع .bind()
و.connect()
، أو .connect_ex()
، عندما تنوي استخدام واجهة loopback، localhost.
ومع ذلك، ينطبق هذا أيضًا على أي وقت تستخدم فيه اسم مضيف، ويُتوقع أن يُحل إلى عنوان معين، وأن يكون له معنى خاص بتطبيقك، مما يؤثر على سلوكه أو افتراضاته. هذا على عكس السيناريو المعتاد الذي يستخدم فيه العميل اسم مضيف للاتصال بخادم يُحل بواسطة نظام أسماء النطاقات (DNS)، مثل www.example.com.
ما يلي مأخوذ من وثائق وحدة socket الخاصة بـ Python:
إذا استخدمت اسم مضيف في جزء المضيف من عنوان مقبس IPv4/v6، فقد يُظهر البرنامج سلوكًا غير حتمي، لأن بايثون يستخدم العنوان الأول المُسترجع من تحليل DNS. سيتم تحليل عنوان المقبس بشكل مختلف إلى عنوان IPv4/v6 فعلي، وذلك بناءً على نتائج تحليل DNS و/أو تكوين المضيف. للحصول على سلوك حتمي، استخدم عنوانًا رقميًا في جزء المضيف. (المصدر)
الاصطلاح القياسي لاسم “localhost” هو أن يُحل إلى 127.0.0.1 أو ::1
، واجهة الحلقة الراجعة. من المرجح أن ينطبق هذا على نظامك، ولكن ربما لا. يعتمد ذلك على كيفية تهيئة نظامك لتحليل الأسماء. وكما هو الحال في جميع جوانب تكنولوجيا المعلومات، هناك دائمًا استثناءات، ولا توجد ضمانات بأن استخدام اسم “localhost” سيتصل بواجهة الحلقة الراجعة.
على سبيل المثال، في نظام لينكس، راجع man nsswitch.conf
، ملف تكوين تبديل خدمة الأسماء. يمكنك أيضًا التحقق من ملف /etc/hosts
في نظامي macOS وLinux. في نظام ويندوز، راجع C:\Windows\System32\drivers\etc\hosts
. يحتوي ملف hosts على جدول ثابت لتعيينات الأسماء إلى العناوين بتنسيق نصي بسيط. يُعد نظام أسماء النطاقات (DNS) جزءًا آخر من اللغز.
من المهم فهم أنه عند استخدام أسماء المضيفين في تطبيقك، قد تكون العناوين المُعادة أي شيء. لا تفترض اسمًا محددًا إذا كان تطبيقك حساسًا أمنيًا. قد يُثير هذا الأمر قلقك أو لا، وذلك حسب تطبيقك وبيئتك.
حظر المكالمات
دالة أو تابع المقبس التي تُعلّق تطبيقك مؤقتًا هي استدعاء حظر. على سبيل المثال، تُحظر الدوال .accept()
و.connect()
و.send()
و.recv()
، مما يعني أنها لا تُرجع فورًا. يجب أن تنتظر استدعاءات الحظر اكتمال استدعاءات النظام (الإدخال/الإخراج) قبل أن تُعيد قيمة. لذا، تُحظر أنت، المُستدعي، حتى تنتهي أو يحدث خطأ ما.
يمكن ضبط استدعاءات مقبس التوصيل المحظورة على وضع عدم الحظر، بحيث تعود فورًا. في هذه الحالة، ستحتاج على الأقل إلى إعادة هيكلة أو تصميم تطبيقك للتعامل مع عملية مقبس التوصيل عندما يكون جاهزًا.
لأن المكالمة تعود فورًا، فقد لا تكون البيانات جاهزة. المُستدعى عليه ينتظر على الشبكة ولم يُتح له الوقت لإكمال عمله. في هذه الحالة، تكون الحالة الحالية هي قيمة errno
socket.EWOULDBLOCK
. يُدعم وضع عدم الحظر باستخدام دالة .setblocking()
.
افتراضيًا، يتم إنشاء المقابس دائمًا في وضع الحظر. راجع ملاحظات حول مهلة انتهاء صلاحية المقابس لوصف الأوضاع الثلاثة.
إغلاق الاتصالات
من الأمور الجديرة بالملاحظة في بروتوكول TCP أنه من القانوني تمامًا للعميل أو الخادم إغلاق جانبه من الاتصال بينما يبقى الجانب الآخر مفتوحًا. يُشار إلى هذا باسم “الاتصال نصف المفتوح”. يعود قرار ما إذا كان هذا مرغوبًا أم لا إلى التطبيق. بشكل عام، ليس كذلك. في هذه الحالة، لا يعود بإمكان الجانب الذي أغلق جانبه من الاتصال إرسال البيانات، بل يمكنه فقط استقبالها.
لا يُنصح بهذا النهج بالضرورة، ولكن على سبيل المثال، يستخدم بروتوكول HTTP رأسًا يُسمى “اتصال” يُستخدم لتوحيد كيفية إغلاق التطبيقات للاتصالات المفتوحة أو استمرارها. لمزيد من التفاصيل، راجع القسم 6.3 في RFC 7230، بروتوكول نقل النص التشعبي (HTTP/1.1): بناء الجملة والتوجيه للرسائل.
عند تصميم وكتابة تطبيقك وبروتوكول طبقة التطبيق الخاص به، يُنصح بتحديد كيفية إغلاق الاتصالات المتوقعة. أحيانًا يكون هذا واضحًا وبسيطًا، أو قد يتطلب بعض النماذج الأولية والاختبارات. يعتمد ذلك على التطبيق وكيفية معالجة حلقة الرسائل بالبيانات المتوقعة.
فقط تأكد من أن المقابس مغلقة دائمًا في الوقت المناسب بعد الانتهاء من عملها.
لقد غطيت الكثير في هذا البرنامج التعليمي! الشبكات والمقابس موضوعان واسعان. إذا كنت جديدًا في الشبكات أو المقابس، فلا تقلق من كثرة المصطلحات والاختصارات.
هناك العديد من الأجزاء التي يجب التعرف عليها لفهم آلية عمل كل جزء معًا. ومع ذلك، وكما هو الحال في بايثون، سيتضح الأمر أكثر مع التعمق في فهم كل جزء على حدة وقضاء المزيد من الوقت في تعلمه.
من هنا، يمكنك استخدام فئتك المخصصة والبناء عليها للتعلم والمساعدة في جعل إنشاء تطبيقات المقبس الخاصة بك أسهل وأسرع.
اكتشاف المزيد من بايثون العربي
اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.