בקשות HTTP וממשקי API
בפרק הקודם הלוח חיכה שישאלו אותו. עכשיו הוא זה ששואל: הוא מבקש משירות רחוק את מזג האוויר, מכוונן את השעון שלו מהרשת, ושולח את הקריאות שלו לשרת. הדבר החדש עצמו הוא שורה אחת; הפרק עוסק במה שקורה כשהשורה הזאת לא מתנהגת כפי שרצינו.
- נשלח בקשות
getו-postונקרא את קוד המצב, את הטקסט ואת ה-JSON. - נסגור כל תגובה, ונדע מה דולף אם לא נעשה זאת.
- נטפל בניתוק רשת, בפסק זמן, בשגיאה מצד השרת ובתשובה פגומה.
- נכוונן את השעון בעזרת NTP ונהפוך אותו לזמן מקומי.
- נעריך את גודל התשובה לפני שמבקשים אותה, ונדע מה HTTPS מוסיף ומה הוא לא מוסיף.
15.1 הבקשה הראשונה (The First Request)
הלוח הוא עכשיו לקוח. הספרייה requests כלולה מראש בגרסאות MicroPython שיש בהן רשת בשני הלוחות, והיא פותחת את השקע, כותבת את הבקשה וקוראת את התגובה במקומנו.
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 (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 (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 ושירותים אחרים
לפי הכתובת requests בוחרת את הדרך: מספיק לכתוב https:// והבקשה הופכת למוצפנת, וזה מגן על התוכן, כולל המפתח, ממי שמאזין ברשת. אבל יש לזה מחיר: לחיצת היד של ההצפנה לוקחת זמן וזיכרון רבים בהרבה מבקשה רגילה.
ssl מזהיר שיש מימושים שאינם בודקים את תעודת השרת. לכן לא מתייחסים לחיבור של מיקרו-בקר כאילו הוא בנק, ולא שולחים דרכו דבר שחשיפתו מזיקה.פעמים רבות רוצים לשלוח את הקריאות לטבלה, כמו Google Sheets, או לפלטפורמת נתונים. הרעיון בכל השירותים האלה זהה: השירות נותן לנו כתובת ומפתח, והלוח שולח post בפורמט JSON דרך HTTPS מדי פעם, בצורה שהכרנו בפרק הזה. הפרטים משתנים משירות לשירות, ולכן נלך לפי התיעוד שלו ולא נציג אותם כאן.
וב-Raspberry Pi 4/5 התוכנית קצרה יותר: מערכת ההפעלה היא שמתחברת לרשת, ולכן אין צורך ב-wifi.py, והספרייה requests היא המקור שהספרייה של MicroPython מחקה. מתקינים אותה בסביבה ייעודית. התוכנית הבאה עברה הידור בלבד ולא הורצה בבניית הספר, כי הדמיית הפאי אינה כוללת רשת:
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 Key | مفتاح API |
| גוף הבקשה | 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() בונה לצדו מילון שגם לו יש גודל, ולכן הזיכרון אינו מספיק לשניהם יחד.