طلبات HTTP وواجهات API
في الفصل السابق انتظرت اللوحة من يسألها. الآن تسأل هي: تطلب حالة الطقس من خدمة بعيدة، وتضبط ساعتها من الشبكة، وترسل قراءاتها إلى خادم. والأمر الجديد نفسه سطر واحد؛ أما الفصل فيدور حول ما يحدث حين لا يسير ذلك السطر كما نتمنى.
- نرسل طلبات
getوpostونقرأ رمز الحالة والنص وJSON. - نغلق كل استجابة، ونعرف ماذا يتسرّب إن لم نفعل.
- نعالج انقطاع الشبكة والمهلة والخطأ من الخادم والجواب غير السليم.
- نضبط الساعة عبر NTP ونحوّلها إلى الوقت المحلي.
- نقدّر حجم الجواب قبل طلبه، ونعرف ما يضيفه HTTPS وما لا يضيفه.
15.1 الطلب الأول – הבקשה הראשונה (The First Request)
اللوحة الآن عميل. والمكتبة requests موجودة مسبقا في نسخ مايكروبايثون ذات الشبكة على اللوحتين، وهي تفتح المقبس وتكتب الطلب وتقرأ الاستجابة عنا.
import requests
import wifi
wifi.connect()
r = requests.get("http://api.example.com/v1/hello")
print(r.status_code)
print(r.text)
r.close()200 hello from the server
العدد الأول هو رمز الحالة (קוד סטטוס). هو أول ما نقرؤه، لأن الخادم يجيب حتى حين يرفض طلبنا:
| الرمز | معناه | ماذا نفعل |
|---|---|---|
| 200 | نجح الطلب | نقرأ الجواب |
| 201 | نجح وأُنشئ سجل جديد | نكمل |
| 400 | طلبنا غير سليم | نصلح البرنامج |
| 401 | ينقصنا إذن أو مفتاح | نفحص المفتاح |
| 404 | العنوان غير موجود | نفحص العنوان |
| 429 | طلبات كثيرة جدا | نبطئ |
| 500 | خطأ عند الخادم | نحاول لاحقا |
r.close() يعمل جيدا في أول الطلبات، ثم يتوقف بخطأ OSError بعد عدد منها. ولا يظهر هذا في تجربة قصيرة، وإنما بعد ساعات من التشغيل.r.status_code هو 429. هل المشكلة في الشبكة؟ وما التغيير في البرنامج؟15.2 خدمة الطقس: قراءة JSON – שירות מזג אוויר: קריאת JSON (A Weather Service: Reading JSON)
تعرّفنا إلى JSON في فصل الملفات. والأمر r.json() يحوّل الجواب إلى قاموس جاهز. نضع هنا timeout، ونغلق الاستجابة في finally، وهذا الشكل سنكرره دائما. عنوان الخدمة في المثال ليس خدمة فعلية، والجواب من العالم المُحاكى.
import requests
import wifi
URL = "http://api.example.com/v1/weather?city=Haifa"
wifi.connect()
r = requests.get(URL, timeout=5)
try:
if r.status_code == 200:
data = r.json()
print(data["city"])
print("temp: %.1f C" % data["temp_c"])
print("wind: %.1f km/h" % (data["wind_ms"] * 3.6))
else:
print("server said", r.status_code)
finally:
r.close()Haifa temp: 24.5 C wind: 18.0 km/h
wind_ms في بعض الأوقات. ماذا يحدث للبرنامج؟ وكيف نكتب القراءة بأمان؟15.3 إرسال البيانات والمفاتيح – שליחת נתונים ומפתחות (Sending Data, API Keys)
الأمر post يرسل بيانات في جسم الطلب. والمعامل json= يحوّل القاموس إلى نص ويضع الترويسة المناسبة.
import board
import requests
import wifi
URL = "http://api.example.com/v1/readings"
wifi.connect()
pot = board.analog_in(board.POT)
reading = {"sensor": "pot", "raw": pot.read_u16()}
r = requests.post(URL, json=reading, timeout=5)
try:
print(r.status_code, r.text)
finally:
r.close()201 {"id":17}
[http_sent]
POST http://api.example.com/v1/readings {'raw': 32776, 'sensor': 'pot'}أغلب الخدمات الحقيقية تطلب مفتاحا (מפתח API) يعرّف صاحب الحساب. نضيفه إلى secrets.py سطرا جديدا، ونقرؤه كما نقرأ كلمة مرور الشبكة. المقطع التالي مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن ملف الأسرار في الكتاب لا يحوي مفتاحا:
import requests
from secrets import API_KEY
headers = {"Authorization": "Bearer " + API_KEY}
r = requests.get(URL, headers=headers, timeout=5)15.4 حين لا يسير الطلب كما نتمنى – טיפול בשגיאות ובפסק זמן (Errors and Timeouts)
على المتحكّم الذي يعمل وحده لا أحد يعيد تشغيل البرنامج. لذلك نميّز أربع حالات:
| الحالة | كيف تظهر | مثال |
|---|---|---|
| لا طريق إلى الخادم | OSError عند الطلب | الشبكة منقطعة، أو انتهت المهلة |
| الخادم أجاب برفض | رمز حالة غير 200 | 404، 500 |
| الجواب ليس JSON | ValueError عند r.json() | صفحة خطأ بدل البيانات |
| البيانات ناقصة | KeyError | حقل غير موجود |
نجمع المعالجة في دالة واحدة تعيد البيانات أو None، فلا يحتاج باقي البرنامج إلى التفكير في ذلك:
import requests
import wifi
BASE = "http://api.example.com/v1/"
def get_json(url):
try:
r = requests.get(url, timeout=5)
except OSError as e:
print("network error:", e)
return None
try:
if r.status_code != 200:
print("status:", r.status_code)
return None
return r.json()
except ValueError:
print("bad JSON")
return None
finally:
r.close()
wifi.connect()
for name in ("hello", "busy", "broken", "gone"):
print(name, "->", get_json(BASE + name))hello -> {'ok': True}
status: 500
busy -> None
bad JSON
broken -> None
network error: -202
gone -> Noneفي هذا الزمن لا يعمل شيء آخر في البرنامج. فإذا كان يقود مشغّلا، نضعه أولا في حالة آمنة، ثم نطلب.
timeout قد ينتظر الطلب طويلا جدا إذا صمت الخادم. لذلك نضعها في كل طلب.try مرتين في get_json، بدل واحدة كبيرة؟15.5 الساعة من الشبكة: NTP – שעון מהרשת: NTP (Network Time)
لا تعرف اللوحة كم الساعة حين تبدأ، إذ لا بطارية عادة لساعتها الداخلية. والبروتوكول NTP يسأل خادم وقت في الإنترنت ويضبطها. الأمر ntptime.settime() يضع في الساعة التوقيت العالمي UTC، وعلينا نحن إضافة فرق المنطقة: في إسرائيل ساعتان في الشتاء، وثلاث ساعات في الصيف.
import ntptime
import time
import wifi
OFFSET_S = 3 * 3600
wifi.connect()
try:
ntptime.settime()
except OSError as e:
print("no time:", e)
t = time.gmtime()
print("UTC: %04d-%02d-%02d %02d:%02d" % t[:5])
t = time.gmtime(time.time() + OFFSET_S)
print("local: %04d-%02d-%02d %02d:%02d" % t[:5])UTC: 2027-01-01 00:00 local: 2027-01-01 03:00
time.time() لنحصل على فرق ثلاث ساعات؟15.6 الجواب الكبير والذاكرة – תשובה גדולה וזיכרון (Large Responses and Memory)
الجواب كله يدخل إلى الذاكرة: r.content يقرأ الجواب كاملا مرة واحدة، وr.json() يبني منه قاموسا يأخذ مكانا إضافيا. وعلى المتحكّم تنتهي الذاكرة بخطأ MemoryError. كمية الذاكرة الحرة نقيسها على لوحتنا بـgc.mem_free()، فهي تتغيّر بحسب البرنامج.
import gc
import requests
import wifi
URL = "http://api.example.com/v1/forecast?days=7"
wifi.connect()
gc.collect()
r = requests.get(URL, timeout=5)
try:
print("bytes:", len(r.content))
hours = r.json()["hours"]
print("items:", len(hours))
print("first: %.1f C" % hours[0]["temp_c"])
finally:
r.close()
del hours
gc.collect()bytes: 6285 items: 168 first: 20.5 C
{"t":100,"temp_c":21.5,"wind_ms":5.5} وطوله 37 بايتا. ما الحجم التقريبي لتوقّعات أسبوع، ساعة بساعة؟أفضل علاج هو أن نطلب أقل: كثير من الخدمات تقبل معاملا يحدّد الأيام أو الحقول. وإن لم يكن، نقرأ الجواب قطعا بقطع من r.raw دون تخزينه كله. وهذا المقطع مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن العالم المُحاكى لا يوفّر r.raw:
r = requests.get(URL, timeout=5)
total = 0
while True:
chunk = r.raw.read(256)
if not chunk:
break
total += len(chunk)
r.close()
print(total)15.7 HTTPS وخدمات أخرى – HTTPS ושירותים אחרים
حسب العنوان يختار requests الطريق: يكفي أن نكتب https:// ليصبح الطلب مشفّرا، وهذا يحمي المحتوى، بما فيه المفتاح، ممن يتنصّت على الشبكة. لكن له ثمن: مصافحة التشفير تأخذ زمنا وذاكرة أكبر بكثير من طلب عادي.
ssl من أن بعض التنفيذات لا تتحقق من شهادة الخادم. فلا نعامل الاتصال على المتحكّم كأنه مصرف، ولا نرسل عبره ما يضر كشفه.وكثيرا ما يُطلب إرسال القراءات إلى جدول، مثل Google Sheets، أو إلى منصة بيانات. الفكرة في كل هذه الخدمات واحدة: تعطينا الخدمة عنوانا ومفتاحا، وترسل اللوحة post بصيغة JSON عبر HTTPS كل فترة، بالشكل الذي عرفناه في هذا الفصل. والتفاصيل تتغيّر من خدمة إلى أخرى، فنتبع توثيقها، ولا نعرضها هنا.
وعلى Raspberry Pi 4/5 البرنامج أقصر: نظام التشغيل هو الذي يتصل بالشبكة، ولذا لا نحتاج إلى wifi.py، والمكتبة requests هي الأصل الذي تقلّده مكتبة مايكروبايثون. نثبّتها في بيئة خاصة. البرنامج التالي مرّ بالترجمة فقط ولم يُشغَّل في بناء الكتاب، لأن مُحاكاة الباي لا تحوي شبكة:
python3 -m venv ~/lab ~/lab/bin/pip install requests ~/lab/bin/python weather_pi.py
with يغلق الاستجابة بنفسهimport requests
URL = "http://api.example.com/v1/weather?city=Haifa"
with requests.get(URL, timeout=5) as r:
r.raise_for_status()
data = r.json()
print("temp: %.1f C" % data["temp_c"])أهمّ أفكار الفصل:
- نقرأ رمز الحالة أولا: الخادم يجيب حتى حين يرفض.
- كل استجابة تُغلق في
finally، وكل طلب لهtimeout. - أربع حالات فشل مختلفة، ونجمع معالجتها في دالة واحدة.
- المفاتيح في secrets.py وحده، والمفتاح المتسرّب يُلغى.
- NTP يعطي UTC، وفرق المنطقة علينا.
- نقدّر حجم الجواب قبل طلبه، ونطلب أقل ما نحتاج إليه.
- HTTPS يخفي المحتوى، وهو أثقل، ولا يضمن وحده هوية الخادم.
أوامر الفصل:
| الأمر | عمله |
|---|---|
| requests.get(url, timeout=5) | طلب يعيد استجابة. |
| requests.post(url, json=d, timeout=5) | إرسال قاموس بصيغة JSON. |
| requests.get(url, headers=h) | طلب مع ترويسات، مثل مفتاح الخدمة. |
| r.status_code | رمز الحالة. |
| r.text, r.content | الجواب نصا أو بايتات. |
| r.json() | الجواب قاموسا. ValueError إن لم يكن سليما. |
| r.close() | إغلاق الاستجابة وتحرير المقبس. |
| ntptime.settime() | ضبط الساعة على UTC. |
| time.gmtime(s) | تحويل الثواني إلى تاريخ ووقت. |
| gc.mem_free() | الذاكرة الحرة بالبايتات. |
قاموس المصطلحات – מילון מונחים:
| بالعربية | بالعبرية | English |
|---|---|---|
| واجهة API | ממשק תכנות | API |
| رمز الحالة | קוד סטטוס | Status Code |
| مفتاح API | מפתח API | API Key |
| جسم الطلب | גוף הבקשה | Request Body |
| التوقيت العالمي | זמן אוניברסלי | UTC |
| خادم الوقت | שרת זמן | Time Server |
| الشهادة | תעודה | Certificate |
KeyError. نكتب data.get("wind_ms") ونفحص إن كانت النتيجة None.try الأولى لا توجد استجابة بعد فلا شيء نغلقه. والثانية لها finally يغلق الاستجابة بعد أن وُجدت.أسئلة للتمرين:
r.close() ليعمل حتى لو حدث خطأ في قراءة JSON؟finally.from machine import Pin
import board
import requests
import wifi
URL = "http://api.example.com/v1/weather?city=Haifa"
LIMIT_KMH = 30
led = Pin(board.LED, Pin.OUT)
wifi.connect()
r = requests.get(URL, timeout=5)
try:
kmh = r.json()["wind_ms"] * 3.6
finally:
r.close()
led.value(1 if kmh > LIMIT_KMH else 0)
print("%.1f km/h, LED %d" % (kmh, led.value()))get_json عدة مرات مع انتظار بين المحاولات.import time
def get_retry(url, tries=3, wait_s=2):
for k in range(tries):
data = get_json(url)
if data is not None:
return data
if k < tries - 1:
time.sleep(wait_s)
return NoneOSError، ولا مشكلة في الشبكة. ما أول ما نفحصه؟r.json() رغم نجاح r.content؟r.json() بجانبه قاموسا له أيضا حجم، فلا تكفي الذاكرة لهما معا.