שמירת נתונים

במסמך הזה מפורטות ארבע השיטות לכתיבה של נתונים ב-Firebase Realtime Database: הגדרה, עדכון, דחיפה ותמיכה בטרנזקציות.

דרכים לחיסכון בנתונים

הגדרה כתיבת או החלפת נתונים בנתיב מוגדר, כמו messages/users/<username>
עדכון עדכון חלק מהמפתחות לנתיב מוגדר בלי להחליף את כל הנתונים
דחיפת הודעות הוספה לרשימה של נתונים במסד הנתונים. בכל פעם שדוחפים צומת חדש לרשימה, מסד הנתונים יוצר מפתח ייחודי, כמו messages/users/<unique-user-id>/<username>
טרנזקציה להשתמש בטרנזקציות כשעובדים עם נתונים מורכבים שעלולים להיפגם בעקבות עדכונים בו-זמנית

שמירת נתונים

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

Java
final FirebaseDatabase database = FirebaseDatabase.getInstance();
DatabaseReference ref = database.getReference("server/saving-data/fireblog");
Node.js
// Import Admin SDK
const { getDatabase } = require('firebase-admin/database');

// Get a database reference to our blog
const db = getDatabase();
const ref = db.ref('server/saving-data/fireblog');
Python
# Import database module.
from firebase_admin import db

# Get a database reference to our blog.
ref = db.reference('server/saving-data/fireblog')
Go
// Create a database client from App.
client, err := app.Database(ctx)
if err != nil {
	log.Fatalln("Error initializing database client:", err)
}

// Get a database reference to our blog.
ref := client.NewRef("server/saving-data/fireblog")

נתחיל בשמירת נתוני משתמשים. כל משתמש יישמר לפי שם משתמש ייחודי, וגם השם המלא ותאריך הלידה שלו יישמרו. מכיוון שלכל משתמש יהיה שם משתמש ייחודי, הגיוני להשתמש בשיטה המוגדרת כאן במקום בשיטת הדחיפה כי כבר יש לך את המפתח ואתה לא צריך ליצור אותו.

קודם צריך ליצור הפניה למסד נתונים של נתוני המשתמשים. לאחר מכן משתמשים ב-set() / setValue() כדי לשמור אובייקט משתמש במסד הנתונים עם שם המשתמש, השם המלא ותאריך הלידה של המשתמש. אפשר להעביר מחרוזת, מספר, ערך בוליאני, null, מערך או כל אובייקט JSON. העברה של null תוביל להסרת הנתונים מהמיקום שצוין. במקרה כזה, מעבירים לו אובייקט:

Java
public static class User {

  public String date_of_birth;
  public String full_name;
  public String nickname;

  public User(String dateOfBirth, String fullName) {
    // ...
  }

  public User(String dateOfBirth, String fullName, String nickname) {
    // ...
  }

}

DatabaseReference usersRef = ref.child("users");

Map<String, User> users = new HashMap<>();
users.put("alanisawesome", new User("June 23, 1912", "Alan Turing"));
users.put("gracehop", new User("December 9, 1906", "Grace Hopper"));

usersRef.setValueAsync(users);
Node.js
const usersRef = ref.child('users');
usersRef.set({
  alanisawesome: {
    date_of_birth: 'June 23, 1912',
    full_name: 'Alan Turing'
  },
  gracehop: {
    date_of_birth: 'December 9, 1906',
    full_name: 'Grace Hopper'
  }
});
Python
users_ref = ref.child('users')
users_ref.set({
    'alanisawesome': {
        'date_of_birth': 'June 23, 1912',
        'full_name': 'Alan Turing'
    },
    'gracehop': {
        'date_of_birth': 'December 9, 1906',
        'full_name': 'Grace Hopper'
    }
})
Go
// User is a json-serializable type.
type User struct {
	DateOfBirth string `json:"date_of_birth,omitempty"`
	FullName    string `json:"full_name,omitempty"`
	Nickname    string `json:"nickname,omitempty"`
}

usersRef := ref.Child("users")
err := usersRef.Set(ctx, map[string]*User{
	"alanisawesome": {
		DateOfBirth: "June 23, 1912",
		FullName:    "Alan Turing",
	},
	"gracehop": {
		DateOfBirth: "December 9, 1906",
		FullName:    "Grace Hopper",
	},
})
if err != nil {
	log.Fatalln("Error setting value:", err)
}

כשאובייקט JSON נשמר במסד הנתונים, מאפייני האובייקט ממופים באופן אוטומטי למיקומי צאצא של מסד הנתונים בתצוגת עץ. עכשיו, אם נעבור לכתובת ה-URL https://docs-examples.firebaseio.com/server/saving-data/fireblog/users/alanisawesome/full_name, נראה את הערך 'Alan Turing'. אפשר גם לשמור נתונים ישירות במיקום של הילד או הילדה:

Java
usersRef.child("alanisawesome").setValueAsync(new User("June 23, 1912", "Alan Turing"));
usersRef.child("gracehop").setValueAsync(new User("December 9, 1906", "Grace Hopper"));
Node.js
const usersRef = ref.child('users');
usersRef.child('alanisawesome').set({
  date_of_birth: 'June 23, 1912',
  full_name: 'Alan Turing'
});
usersRef.child('gracehop').set({
  date_of_birth: 'December 9, 1906',
  full_name: 'Grace Hopper'
});
Python
users_ref.child('alanisawesome').set({
    'date_of_birth': 'June 23, 1912',
    'full_name': 'Alan Turing'
})
users_ref.child('gracehop').set({
    'date_of_birth': 'December 9, 1906',
    'full_name': 'Grace Hopper'
})
Go
if err := usersRef.Child("alanisawesome").Set(ctx, &User{
	DateOfBirth: "June 23, 1912",
	FullName:    "Alan Turing",
}); err != nil {
	log.Fatalln("Error setting value:", err)
}

if err := usersRef.Child("gracehop").Set(ctx, &User{
	DateOfBirth: "December 9, 1906",
	FullName:    "Grace Hopper",
}); err != nil {
	log.Fatalln("Error setting value:", err)
}

שתי הדוגמאות שלמעלה – כתיבת שני הערכים בו-זמנית כאובייקט וכתיבתם בנפרד למיקומי צאצאים – יובילו לשמירת אותם נתונים במסד הנתונים שלכם:

{
  "users": {
    "alanisawesome": {
      "date_of_birth": "June 23, 1912",
      "full_name": "Alan Turing"
    },
    "gracehop": {
      "date_of_birth": "December 9, 1906",
      "full_name": "Grace Hopper"
    }
  }
}

הדוגמה הראשונה תפעיל רק אירוע אחד אצל לקוחות שצופים בנתונים, ואילו הדוגמה השנייה תפעיל שני אירועים. חשוב לציין שאם כבר היו נתונים ב-usersRef, הגישה הראשונה תכסה אותם, אבל השיטה השנייה תשנה רק את הערך של כל צומת צאצא נפרד, בלי לשנות את הצאצאים האחרים של usersRef.

עדכון הנתונים השמורים

אם רוצים לכתוב בו-זמנית למספר צאצאים של מיקום במסד נתונים בלי לשכתב צמתים צאצאים אחרים, אפשר להשתמש בשיטת העדכון כפי שמתואר בהמשך:

Java
DatabaseReference hopperRef = usersRef.child("gracehop");
Map<String, Object> hopperUpdates = new HashMap<>();
hopperUpdates.put("nickname", "Amazing Grace");

hopperRef.updateChildrenAsync(hopperUpdates);
Node.js
const usersRef = ref.child('users');
const hopperRef = usersRef.child('gracehop');
hopperRef.update({
  'nickname': 'Amazing Grace'
});
Python
hopper_ref = users_ref.child('gracehop')
hopper_ref.update({
    'nickname': 'Amazing Grace'
})
Go
hopperRef := usersRef.Child("gracehop")
if err := hopperRef.Update(ctx, map[string]interface{}{
	"nickname": "Amazing Grace",
}); err != nil {
	log.Fatalln("Error updating child:", err)
}

הפעולה הזו תעדכן את הנתונים של גרציה כך שיכללו את הכינוי שלה. אם השתמשת בהגדרה הזו במקום לעדכן, הפעולה הייתה מוחקת גם את full_name וגם את date_of_birth מה-hopperRef.

Firebase Realtime Database תומך גם בעדכונים במספר נתיבים. המשמעות היא שאפשר עכשיו לעדכן ערכים במספר מיקומים במסד הנתונים בו-זמנית. זו תכונה חזקה שמאפשרת לבטל את הנורמליזציה של הנתונים. באמצעות עדכונים של מספר נתיבים, אפשר להוסיף כינויים גם לגרייס וגם לאלן בו-זמנית:

Java
Map<String, Object> userUpdates = new HashMap<>();
userUpdates.put("alanisawesome/nickname", "Alan The Machine");
userUpdates.put("gracehop/nickname", "Amazing Grace");

usersRef.updateChildrenAsync(userUpdates);
Node.js
const usersRef = ref.child('users');
usersRef.update({
  'alanisawesome/nickname': 'Alan The Machine',
  'gracehop/nickname': 'Amazing Grace'
});
Python
users_ref.update({
    'alanisawesome/nickname': 'Alan The Machine',
    'gracehop/nickname': 'Amazing Grace'
})
Go
if err := usersRef.Update(ctx, map[string]interface{}{
	"alanisawesome/nickname": "Alan The Machine",
	"gracehop/nickname":      "Amazing Grace",
}); err != nil {
	log.Fatalln("Error updating children:", err)
}

אחרי העדכון הזה, גם לאלון וגם לגרציה נוספו הכינוי שלהם:

{
  "users": {
    "alanisawesome": {
      "date_of_birth": "June 23, 1912",
      "full_name": "Alan Turing",
      "nickname": "Alan The Machine"
    },
    "gracehop": {
      "date_of_birth": "December 9, 1906",
      "full_name": "Grace Hopper",
      "nickname": "Amazing Grace"
    }
  }
}

שימו לב שניסיון לעדכן אובייקטים על ידי כתיבת אובייקטים עם הנתיבים הכלולים, יגרום להתנהגות שונה. בואו נראה מה קורה אם מנסים לעדכן את אורנה ואלון בדרך הזו:

Java
Map<String, Object> userNicknameUpdates = new HashMap<>();
userNicknameUpdates.put("alanisawesome", new User(null, null, "Alan The Machine"));
userNicknameUpdates.put("gracehop", new User(null, null, "Amazing Grace"));

usersRef.updateChildrenAsync(userNicknameUpdates);
Node.js
const usersRef = ref.child('users');
usersRef.update({
  'alanisawesome': {
    'nickname': 'Alan The Machine'
  },
  'gracehop': {
    'nickname': 'Amazing Grace'
  }
});
Python
users_ref.update({
    'alanisawesome': {
        'nickname': 'Alan The Machine'
    },
    'gracehop': {
        'nickname': 'Amazing Grace'
    }
})
Go
if err := usersRef.Update(ctx, map[string]interface{}{
	"alanisawesome": &User{Nickname: "Alan The Machine"},
	"gracehop":      &User{Nickname: "Amazing Grace"},
}); err != nil {
	log.Fatalln("Error updating children:", err)
}

כתוצאה מכך, מתקבלת התנהגות שונה, כלומר כתיבה מחדש של כל צומת /users:

{
  "users": {
    "alanisawesome": {
      "nickname": "Alan The Machine"
    },
    "gracehop": {
      "nickname": "Amazing Grace"
    }
  }
}

הוספת שיחה חוזרת בסיום הטיפול

ב-Node.js וב-Java Admin SDKs, אם רוצים לדעת מתי הנתונים הוגדרו, אפשר להוסיף קריאה חוזרת (callback) להשלמה. גם שיטות ההגדרה וגם שיטות העדכון ב-SDKs האלה מקבלות קריאה חוזרת אופציונלית בסיום, שנקראת אחרי שהכתיבה בוצעה במסד הנתונים. אם הקריאה נכשלה מסיבה כלשהי, פונקציית ה-callback תקבל אובייקט שגיאה שמציין את הסיבה לכישלון. ב-SDK של Admin ל-Python ול-Go, כל שיטות הכתיבה הן חסימניות. כלומר, שיטות הכתיבה לא מוחזרות עד שהכתיבה מבוצעת למסד הנתונים.

Java
DatabaseReference dataRef = ref.child("data");
dataRef.setValue("I'm writing data", new DatabaseReference.CompletionListener() {
  @Override
  public void onComplete(DatabaseError databaseError, DatabaseReference databaseReference) {
    if (databaseError != null) {
      System.out.println("Data could not be saved " + databaseError.getMessage());
    } else {
      System.out.println("Data saved successfully.");
    }
  }
});
Node.js
dataRef.set('I\'m writing data', (error) => {
  if (error) {
    console.log('Data could not be saved.' + error);
  } else {
    console.log('Data saved successfully.');
  }
});

שמירת רשימות נתונים

כשיוצרים רשימות של נתונים, חשוב לזכור את אופי השימוש הרב-משתמש של רוב האפליקציות ולהתאים את מבנה הרשימה בהתאם. בהמשך לדוגמה שלמעלה, נוסיף לאפליקציה פוסטים בבלוג. התגובה הראשונה שלכם עשויה להיות להשתמש ב-set כדי לאחסן צאצאים עם אינדקסים שלמים שמתווספים באופן אוטומטי, כמו:

// NOT RECOMMENDED - use push() instead!
{
  "posts": {
    "0": {
      "author": "gracehop",
      "title": "Announcing COBOL, a New Programming Language"
    },
    "1": {
      "author": "alanisawesome",
      "title": "The Turing Machine"
    }
  }
}

אם משתמש מוסיף פוסט חדש, הוא יישמר בתור /posts/2. הפתרון הזה יתאים אם רק מחבר אחד מוסיף פוסטים, אבל באפליקציה של הבלוג המשותף יכולים להוסיף פוסטים הרבה משתמשים בו-זמנית. אם שני מחברים כותבים ב-/posts/2 בו-זמנית, אחד מהפוסטים יימחק על ידי המחבר השני.

כדי לפתור את הבעיה הזו, לקוחות Firebase מספקים פונקציית push() שיוצרת מפתח ייחודי לכל צאצא חדש. כשמשתמשים במפתחות צאצאים ייחודיים, כמה לקוחות יכולים להוסיף צאצאים לאותו מיקום באותו זמן בלי לדאוג לסכסוכים בכתיבה.

Java
public static class Post {

  public String author;
  public String title;

  public Post(String author, String title) {
    // ...
  }

}

DatabaseReference postsRef = ref.child("posts");

DatabaseReference newPostRef = postsRef.push();
newPostRef.setValueAsync(new Post("gracehop", "Announcing COBOL, a New Programming Language"));

// We can also chain the two calls together
postsRef.push().setValueAsync(new Post("alanisawesome", "The Turing Machine"));
Node.js
const newPostRef = postsRef.push();
newPostRef.set({
  author: 'gracehop',
  title: 'Announcing COBOL, a New Programming Language'
});

// we can also chain the two calls together
postsRef.push().set({
  author: 'alanisawesome',
  title: 'The Turing Machine'
});
Python
posts_ref = ref.child('posts')

new_post_ref = posts_ref.push()
new_post_ref.set({
    'author': 'gracehop',
    'title': 'Announcing COBOL, a New Programming Language'
})

# We can also chain the two calls together
posts_ref.push().set({
    'author': 'alanisawesome',
    'title': 'The Turing Machine'
})
Go
// Post is a json-serializable type.
type Post struct {
	Author string `json:"author,omitempty"`
	Title  string `json:"title,omitempty"`
}

postsRef := ref.Child("posts")

newPostRef, err := postsRef.Push(ctx, nil)
if err != nil {
	log.Fatalln("Error pushing child node:", err)
}

if err := newPostRef.Set(ctx, &Post{
	Author: "gracehop",
	Title:  "Announcing COBOL, a New Programming Language",
}); err != nil {
	log.Fatalln("Error setting value:", err)
}

// We can also chain the two calls together
if _, err := postsRef.Push(ctx, &Post{
	Author: "alanisawesome",
	Title:  "The Turing Machine",
}); err != nil {
	log.Fatalln("Error pushing child node:", err)
}

המפתח הייחודי מבוסס על חותמת זמן, כך שפריטי הרשימה ימוינו באופן כרונולוגי באופן אוטומטי. מכיוון ש-Firebase יוצר מפתח ייחודי לכל פוסט בבלוג, לא יהיו התנגשויות בכתיבה אם כמה משתמשים יוסיפו פוסט בו-זמנית. נתוני מסד הנתונים ייראו עכשיו כך:

{
  "posts": {
    "-JRHTHaIs-jNPLXOQivY": {
      "author": "gracehop",
      "title": "Announcing COBOL, a New Programming Language"
    },
    "-JRHTHaKuITFIhnj02kE": {
      "author": "alanisawesome",
      "title": "The Turing Machine"
    }
  }
}

ב-JavaScript, ב-Python וב-Go, התבנית של קריאה ל-push() ואז קריאה מיידית ל-set() היא כה נפוצה, ש-Firebase SDK מאפשר לשלב אותן על ידי העברת הנתונים להגדרה ישירות ל-push() באופן הבא:

Java
// No Java equivalent
Node.js
// This is equivalent to the calls to push().set(...) above
postsRef.push({
  author: 'gracehop',
  title: 'Announcing COBOL, a New Programming Language'
});;
Python
# This is equivalent to the calls to push().set(...) above
posts_ref.push({
    'author': 'gracehop',
    'title': 'Announcing COBOL, a New Programming Language'
})
Go
if _, err := postsRef.Push(ctx, &Post{
	Author: "gracehop",
	Title:  "Announcing COBOL, a New Programming Language",
}); err != nil {
	log.Fatalln("Error pushing child node:", err)
}

אחזור המפתח הייחודי שנוצר על ידי push()‎

קריאה ל-push() תחזיר הפניה לנתיב הנתונים החדש, שאפשר להשתמש בה כדי לקבל את המפתח או להגדיר לו נתונים. הקוד הבא יניב את אותם נתונים כמו בדוגמה שלמעלה, אבל עכשיו תהיה לנו גישה למפתח הייחודי שנוצר:

Java
// Generate a reference to a new location and add some data using push()
DatabaseReference pushedPostRef = postsRef.push();

// Get the unique ID generated by a push()
String postId = pushedPostRef.getKey();
Node.js
// Generate a reference to a new location and add some data using push()
const newPostRef = postsRef.push();

// Get the unique key generated by push()
const postId = newPostRef.key;
Python
# Generate a reference to a new location and add some data using push()
new_post_ref = posts_ref.push()

# Get the unique key generated by push()
post_id = new_post_ref.key
Go
// Generate a reference to a new location and add some data using Push()
newPostRef, err := postsRef.Push(ctx, nil)
if err != nil {
	log.Fatalln("Error pushing child node:", err)
}

// Get the unique key generated by Push()
postID := newPostRef.Key

כפי שאפשר לראות, אפשר לקבל את הערך של המפתח הייחודי מההפניה push().

בקטע הבא בנושא אחזור נתונים נסביר איך לקרוא את הנתונים האלה ממסד נתונים של Firebase.

שמירת נתוני עסקאות

כשעובדים עם נתונים מורכבים שעלולים להיפגם כתוצאה משינויים בו-זמנית, כמו ספירות מצטברות, ה-SDK מספק פעולת עסקה.

ב-Java וב-Node.js, נותנים לפעולת הטרנזקציה שתי פונקציות קריאה חוזרת: פונקציית עדכון ופונקציית קריאה חוזרת אופציונלית לסיום. ב-Python וב-Go, פעולת העסקה היא חסימה ולכן היא מקבלת רק את פונקציית העדכון.

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

Java
DatabaseReference upvotesRef = ref.child("server/saving-data/fireblog/posts/-JRHTHaIs-jNPLXOQivY/upvotes");
upvotesRef.runTransaction(new Transaction.Handler() {
  @Override
  public Transaction.Result doTransaction(MutableData mutableData) {
    Integer currentValue = mutableData.getValue(Integer.class);
    if (currentValue == null) {
      mutableData.setValue(1);
    } else {
      mutableData.setValue(currentValue + 1);
    }

    return Transaction.success(mutableData);
  }

  @Override
  public void onComplete(
      DatabaseError databaseError, boolean committed, DataSnapshot dataSnapshot) {
    System.out.println("Transaction completed");
  }
});
Node.js
const upvotesRef = db.ref('server/saving-data/fireblog/posts/-JRHTHaIs-jNPLXOQivY/upvotes');
upvotesRef.transaction((current_value) => {
  return (current_value || 0) + 1;
});
Python
def increment_votes(current_value):
    return current_value + 1 if current_value else 1

upvotes_ref = db.reference('server/saving-data/fireblog/posts/-JRHTHaIs-jNPLXOQivY/upvotes')
try:
    new_vote_count = upvotes_ref.transaction(increment_votes)
    print('Transaction completed')
except db.TransactionAbortedError:
    print('Transaction failed to commit')
Go
fn := func(t db.TransactionNode) (interface{}, error) {
	var currentValue int
	if err := t.Unmarshal(&currentValue); err != nil {
		return nil, err
	}
	return currentValue + 1, nil
}

ref := client.NewRef("server/saving-data/fireblog/posts/-JRHTHaIs-jNPLXOQivY/upvotes")
if err := ref.Transaction(ctx, fn); err != nil {
	log.Fatalln("Transaction failed to commit:", err)
}

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

אם הקוד שלמעלה היה פועל ללא פונקציית טרנזקציה ושני לקוחות היו מנסים להגדיל אותו בו-זמנית, שניהם היו כותבים את הערך 1 בתור הערך החדש, וכתוצאה מכך תהיה עלייה אחת במקום שתיים.

קישוריות רשת וכתיבה אופליין

לקוחות Firebase Node.js ו-Java שומרים גרסה פנימית משלהם של כל הנתונים הפעילים. כשכותבים נתונים, הם נכתבים קודם בגרסה המקומית הזו. לאחר מכן הלקוח מסנכרן את הנתונים האלה עם מסד הנתונים ועם לקוחות אחרים על בסיס 'לפי יכולת'.

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

אחרי שהקישור יופעל מחדש, נקבל את קבוצת האירועים המתאימה כדי שהלקוח 'ידפוק' עם מצב השרת הנוכחי, בלי צורך לכתוב קוד מותאם אישית.

אבטחת הנתונים

ל-Firebase Realtime Database יש שפת אבטחה שמאפשרת להגדיר לאילו משתמשים תהיה גישה לקריאה ולכתיבה בצמתים שונים של הנתונים. מידע נוסף זמין במאמר אבטחת הנתונים.