JSON: קריאה, כתיבה ושמירת נתונים

מטרות השיעור
  • להסביר מהו JSON ולמה הוא עדיף על קובץ טקסט “חופשי” לשמירת נתונים
  • להכיר את ההתאמה בין טיפוסי JSON לטיפוסי Python
  • לשמור ולטעון נתונים מקובץ עם json.dump ו-json.load
  • להמיר בין מחרוזת JSON לאובייקט Python עם json.dumps ו-json.loads, ולטפל בקובץ חסר או פגום

מה נלמד?

  • מהו JSON ואיך הוא נראה.
  • איך JSON מתאים ל-dict ול-list של Python.
  • שמירה לקובץ וטעינה ממנו.
  • המרה בין מחרוזת JSON לנתונים.

למה צריך את זה?

בשיעור הקודם שמרנו טקסט, ולשם כך המצאנו “פורמט” משלנו: Dana,90 בכל שורה. עכשיו נניח שלכל סטודנט יש רשימת ציונים, כתובת עם עיר ורחוב, ודגל is_active. איך שומרים את זה בשורת טקסט? כל אחד ימציא פורמט אחר, ונצטרך לכתוב קוד פענוח מסובך.

JSON הוא פורמט סטנדרטי לנתונים מובנים. כל שפת תכנות יודעת לקרוא ולכתוב אותו. ובעיקר:

  • נושא OOP — הפרויקט יישמר כ-JSON.
  • נושא Backend ומסדי נתונים — כל בקשה ותשובה ב-API נשלחת כ-JSON. FastAPI ממיר אוטומטית dict של Python ל-JSON ולהפך.

הסבר מהבסיס

מהו JSON?

JSON = JavaScript Object Notation. נולד ב-JavaScript, אבל היום הוא פורמט כללי. קובץ JSON הוא קובץ טקסט עם מבנה מוגדר, שנראה כמעט בדיוק כמו dict ו-list של Python:

{
  "name": "Dana",
  "age": 24,
  "is_active": true,
  "grades": [90, 85, 100],
  "address": {"city": "Haifa", "street": "Herzl 10"},
  "phone": null
}

התאמה בין JSON ל-Python

JSONPython
object { }dict
array [ ]list
string "text"str
number 42, 3.5int, float
true / falseTrue / False
nullNone

כללים חשובים של JSON (נבדלים מ-Python!)

  • מחרוזות ומפתחות רק במירכאות כפולות "...". מירכאות בודדות '...' לא חוקיות.
  • true, false, null באותיות קטנות.
  • אין פסיק אחרי האיבר האחרון.
  • אין הערות.
  • מפתחות של object הם תמיד מחרוזות.

Serialization ו-Deserialization

מונחכיווןפונקציה
Serialization (סריאליזציה)נתוני Python ‏←‏ טקסט JSONjson.dump / json.dumps
Deserializationטקסט JSON ‏←‏ נתוני Pythonjson.load / json.loads

איך זוכרים: ה-s בסוף (dumps, loads) = string, עבודה מול מחרוזת. בלי s (dump, load) = עבודה מול קובץ.

אנלוגיה: אריזת רהיטים להובלה

ספה בסלון (dict בזיכרון) אי אפשר להעביר כמו שהיא בדלת. Serialization = לפרק אותה ולארוז בקרטון שטוח עם הוראות הרכבה (טקסט JSON), שאפשר לשמור במחסן (קובץ) או לשלוח במשאית (רשת). Deserialization = להרכיב אותה מחדש בבית החדש, ולקבל ספה זהה.

תחביר

import json

data = {"name": "Dana", "grades": [90, 85]}

# file
with open(path, "w", encoding="utf-8") as file:
    json.dump(data, file, indent=2, ensure_ascii=False)   # save

with open(path, "r", encoding="utf-8") as file:
    data = json.load(file)                                # load

# string
text = json.dumps(data)        # dict -> JSON string
data = json.loads(text)        # JSON string -> dict
  • indent=2 — כותב JSON “יפה”, עם ירידות שורה והזחה. בלי זה, הכול בשורה אחת.
  • ensure_ascii=False — טקסט שאינו באנגלית (עברית למשל) נשמר כמו שהוא, ולא כקודים כמו ד.

דוגמת קוד ראשונה

week-2/files/save_profile.py:

import json
from pathlib import Path

PROFILE_FILE = Path(__file__).parent / "profile.json"

profile = {
    "name": "Dana",
    "age": 24,
    "is_active": True,
    "grades": [90, 85, 100],
    "address": {"city": "Haifa", "street": "Herzl 10"},
    "phone": None,
}

with open(PROFILE_FILE, "w", encoding="utf-8") as file:
    json.dump(profile, file, indent=2, ensure_ascii=False)

with open(PROFILE_FILE, "r", encoding="utf-8") as file:
    loaded = json.load(file)

print("Loaded type:", type(loaded))
print("City:", loaded["address"]["city"])
print("Average:", sum(loaded["grades"]) / len(loaded["grades"]))
print("Same as original?", loaded == profile)

פלט:

Loaded type: <class 'dict'>
City: Haifa
Average: 91.66666666666667
Same as original? True

profile.json שנוצר:

{
  "name": "Dana",
  "age": 24,
  "is_active": true,
  "grades": [
    90,
    85,
    100
  ],
  "address": {
    "city": "Haifa",
    "street": "Herzl 10"
  },
  "phone": null
}

פירוק הקוד שורה אחר שורה

שורההסבר
import jsonמודול סטנדרטי. אין צורך בהתקנה.
json.dump(profile, file, indent=2, ...)Serialization: הופך את ה-dict לטקסט JSON וכותב לקובץ. True הפך ל-true, ו-None ל-null.
json.load(file)Deserialization: קורא את הטקסט ובונה ממנו dict חדש.
type(loaded)dict רגיל לגמרי. אפשר לעבוד איתו כמו עם כל dict.
loaded["address"]["city"]גישה למבנה מקונן, כמו בנושא מבני נתונים ופונקציות.
loaded == profileTrue: הנתונים שרדו את “הסיבוב” לקובץ וחזרה בשלמותם. (אבל loaded is profile הוא False, כי זה אובייקט חדש.)

דוגמה נוספת

“מסד נתונים” קטן בקובץ JSON — טעינה עם ברירת מחדל, הוספה, שמירה וטיפול בקובץ פגום. בדיוק התבנית שתשמש בפרויקט של נושא OOP. week-2/files/contacts_db.py:

import json
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

DB_FILE = Path(__file__).parent / "contacts.json"


def load_contacts():
    if not DB_FILE.exists():
        logging.info("No database file yet - starting empty")
        return []
    try:
        with open(DB_FILE, "r", encoding="utf-8") as file:
            return json.load(file)
    except json.JSONDecodeError:
        logging.error("Database file is corrupted - starting empty")
        return []


def save_contacts(contacts):
    with open(DB_FILE, "w", encoding="utf-8") as file:
        json.dump(contacts, file, indent=2, ensure_ascii=False)
    logging.info("Saved %s contacts", len(contacts))


def add_contact(contacts, name, phone):
    contact = {"id": len(contacts) + 1, "name": name, "phone": phone}
    contacts.append(contact)
    return contact


def main():
    contacts = load_contacts()
    new_contact = add_contact(contacts, "Dana", "050-1234567")
    print("Added:", new_contact)
    save_contacts(contacts)
    print("Total contacts in file:", len(contacts))


if __name__ == "__main__":
    main()

הרצה ראשונה:

INFO No database file yet - starting empty
Added: {'id': 1, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 1 contacts
Total contacts in file: 1

הרצה שנייה:

Added: {'id': 2, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 2 contacts
Total contacts in file: 2

פירוק:

  • הקובץ מחזיק list של dicts: בדיוק “טבלה” של רשומות.
  • load_contacts מטפלת בשני מקרים: אין קובץ (הרצה ראשונה) ‏←‏ רשימה ריקה. קובץ פגום (json.JSONDecodeError) ‏←‏ רישום שגיאה ורשימה ריקה. בכל מקרה התוכנית לא קורסת.
  • "id": len(contacts) + 1 — מזהה ייחודי פשוט. (שימו לב: אם נמחק איש קשר, המזהה הבא יכול לחזור על מזהה קיים. ב-Database אמיתי בנושא Backend ומסדי נתונים, המסד ייתן מזהים בעצמו, וזו בדיוק אחת הסיבות שמשתמשים בו.)
  • טעינה ‏←‏ שינוי בזיכרון ‏←‏ שמירה: התבנית הבסיסית של עבודה עם קובץ נתונים.

מחרוזת JSON — dumps / loads

לא תמיד יש קובץ. ברשת, למשל, מקבלים מחרוזת JSON:

import json

response_text = '{"id": 7, "name": "Dana", "active": true, "tags": ["new", "vip"]}'

student = json.loads(response_text)
print(student["name"], student["active"], student["tags"][1])

reply = {"status": "ok", "count": 3, "error": None}
print(json.dumps(reply))
Dana True vip
{"status": "ok", "count": 3, "error": null}

זה בדיוק מה ש-FastAPI יעשה בשבילנו בנושא Backend ומסדי נתונים: loads על הבקשה הנכנסת, ו-dumps על התשובה היוצאת.

טעות נפוצה

הטעות 1: JSON לא חוקי שנכתב ביד.

json.loads("{'name': 'Dana'}")
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

ב-JSON רק מירכאות כפולות. הסיבה השכיחה: מעתיקים פלט של print(dict) מ-Python, שמשתמש במירכאות בודדות, ולכן הוא לא JSON תקין. הדרך הנכונה ליצור JSON היא תמיד json.dumps, ולא str(dict).

הטעות 2: לבלבל בין dump ל-dumps.

with open(path, "w", encoding="utf-8") as file:
    json.dumps(data, file)
TypeError: dumps() takes 1 positional argument but 2 were given

לקובץ משתמשים ב-dump (בלי s). dumps מחזיר מחרוזת, ולא כותב לשום מקום.

הטעות 3: לשמור משהו ש-JSON לא מכיר.

json.dumps({"tags": {"a", "b"}})
TypeError: Object of type set is not JSON serializable

ל-JSON אין set (ואין tuple: tuple נשמר כ-array ונטען בחזרה כ-list). ממירים קודם: list(my_set). אותו דבר יקרה בנושא OOP עם אובייקטים של מחלקות, ושם נלמד להמיר אותם ל-dict.

הטעות 4: מפתחות מספריים חוזרים כמחרוזות.

data = {1: "one"}
loaded = json.loads(json.dumps(data))
print(loaded)        # {'1': 'one'}
print(loaded[1])     # KeyError: 1

ב-JSON מפתחות הם תמיד מחרוזות, ולכן 1 הפך ל-"1". ניגשים עם loaded["1"], או מתכננים מראש מפתחות טקסט.

תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

תרגיל 1: מ-Python ל-JSON

לכל ערך Python — איך הוא ייראה ב-JSON?

True        None        "Dana"        [1, 2]        {"age": 24}
הצג פתרון
PythonJSON
Truetrue
Nonenull
"Dana""Dana" — תמיד מירכאות כפולות
[1, 2][1, 2] — מערך
{"age": 24}{"age": 24} — אובייקט; המפתחות תמיד מחרוזות

תרגיל 2: שמירה וטעינה

שמרו את המילון לקובץ profile.json (עם הזחה, ובלי לקודד עברית), טענו אותו בחזרה, והדפיסו את השם ואת הציון הגבוה.

profile = {"name": "Dana", "grades": [90, 85, 100]}

פלט צפוי:

Dana
100
הצג פתרון
import json

profile = {"name": "Dana", "grades": [90, 85, 100]}

with open("profile.json", "w", encoding="utf-8") as file:
    json.dump(profile, file, indent=2, ensure_ascii=False)

with open("profile.json", "r", encoding="utf-8") as file:
    loaded = json.load(file)

print(loaded["name"])
print(max(loaded["grades"]))
Dana
100
  • dump / load — מול קובץ.
  • indent=2 — קובץ קריא לבני אדם. ensure_ascii=False — עברית נשמרת כמו שהיא.

תרגיל 3: מחרוזת JSON

התשובה הבאה הגיעה משרת כמחרוזת. המירו אותה למילון והדפיסו את השם ואם הסטודנט פעיל. אחר כך המירו את המילון reply למחרוזת JSON.

text = '{"id": 7, "name": "Dana", "active": true}'
reply = {"status": "ok", "error": None}

פלט צפוי:

Dana True
{"status": "ok", "error": null}
הצג פתרון
import json

text = '{"id": 7, "name": "Dana", "active": true}'
student = json.loads(text)
print(student["name"], student["active"])

reply = {"status": "ok", "error": None}
print(json.dumps(reply))
Dana True
{"status": "ok", "error": null}

loads / dumps (עם s — string) עובדות מול מחרוזת, לא מול קובץ. כך נראית תקשורת עם שרת.

תרגיל 4: הגדרות שנשמרות

כתבו load_settings(path) שמחזירה את ברירות המחדל {"theme": "light", "font_size": 14}, ואם הקובץ קיים — מעדכנת אותן לפי מה ששמור בו. כתבו גם save_settings(path, settings).

print(load_settings("settings.json"))
save_settings("settings.json", {"theme": "dark"})
print(load_settings("settings.json"))

פלט צפוי:

{'theme': 'light', 'font_size': 14}
{'theme': 'dark', 'font_size': 14}
הצג פתרון
import json
from pathlib import Path

DEFAULTS = {"theme": "light", "font_size": 14}


def load_settings(path):
    settings = DEFAULTS.copy()
    if Path(path).exists():
        with open(path, "r", encoding="utf-8") as file:
            saved = json.load(file)
        for key, value in saved.items():
            settings[key] = value
    return settings


def save_settings(path, settings):
    with open(path, "w", encoding="utf-8") as file:
        json.dump(settings, file, indent=2)


print(load_settings("settings.json"))
save_settings("settings.json", {"theme": "dark"})
print(load_settings("settings.json"))
{'theme': 'light', 'font_size': 14}
{'theme': 'dark', 'font_size': 14}

DEFAULTS.copy() — כדי לא לשנות את ברירות המחדל עצמן (זוכרים את ההפניות מהשיעור על Mutability?).

תרגיל 5: רשימת אנשי קשר

כתבו שתי פונקציות: add_contact(path, name, phone) שמוסיפה איש קשר לרשימה ששמורה בקובץ JSON (ויוצרת את הקובץ אם אינו קיים), ו-find_phone(path, name) שמחזירה את הטלפון או None.

add_contact("contacts.json", "Dana", "050-1111111")
add_contact("contacts.json", "Avi", "052-2222222")
print(find_phone("contacts.json", "Avi"))
print(find_phone("contacts.json", "Noa"))

פלט צפוי:

052-2222222
None
הצג פתרון
import json
from pathlib import Path


def load_contacts(path):
    if not Path(path).exists():
        return []
    with open(path, "r", encoding="utf-8") as file:
        return json.load(file)


def add_contact(path, name, phone):
    contacts = load_contacts(path)
    contacts.append({"name": name, "phone": phone})
    with open(path, "w", encoding="utf-8") as file:
        json.dump(contacts, file, indent=2, ensure_ascii=False)


def find_phone(path, name):
    for contact in load_contacts(path):
        if contact["name"] == name:
            return contact["phone"]
    return None


add_contact("contacts.json", "Dana", "050-1111111")
add_contact("contacts.json", "Avi", "052-2222222")
print(find_phone("contacts.json", "Avi"))
print(find_phone("contacts.json", "Noa"))
052-2222222
None

הקובץ מחזיק רשימה של מילונים — בדיוק המבנה מהשיעור על dict. כל שינוי: טוענים, משנים, שומרים.

סיכום

  • JSON = פורמט טקסט סטנדרטי לנתונים מובנים. object ↔ dict, array ↔ list, true/false/null ↔ True/False/None.
  • כללים: מירכאות כפולות בלבד, בלי פסיק אחרון, בלי הערות. מפתחות הם תמיד מחרוזות.
  • קובץ: json.dump(data, file) / json.load(file). מחרוזת: json.dumps(data) / json.loads(text).
  • indent=2 לקריאוּת, ensure_ascii=False לעברית.
  • תבנית: טען (עם ברירת מחדל) ‏←‏ שנה בזיכרון ‏←‏ שמור. מטפלים ב-קובץ חסר וב-JSONDecodeError.
  • set ואובייקטים צריך להמיר לפני השמירה. JSON הוא השפה של כל API בנושא Backend ומסדי נתונים.

דוקומנטציה רשמית