1. مقدمة
تاريخ آخر تعديل: 2023-01-27
ما هي المتطلبات لإنشاء قائمة صدارة؟
في الأساس، لوحات الصدارة هي مجرد جداول للنتائج مع عامل معقّد واحد: قراءة ترتيب أي نتيجة معيّنة تتطلّب معرفة جميع النتائج الأخرى بترتيب معيّن. بالإضافة إلى ذلك، إذا حققت لعبتك نجاحًا كبيرًا، ستتضخم قوائم الصدارة وسيتم قراءتها والكتابة فيها بشكل متكرر. ولإنشاء قائمة صدارة ناجحة، يجب أن تكون قادرة على التعامل مع عملية الترتيب هذه بسرعة.
ما ستنشئه
في هذا الدرس العملي، ستنفّذ لوحات صدارة مختلفة، كلّ منها مناسب لسيناريو مختلف.
أهداف الدورة التعليمية
ستتعرّف على كيفية تنفيذ أربع قوائم صدارة مختلفة:
- تنفيذ بسيط باستخدام عملية عدّ السجلات لتحديد الترتيب
- قائمة صدارة رخيصة يتم تعديلها بشكل دوري
- قائمة صدارة في الوقت الفعلي تتضمّن بعض المعلومات غير المهمة عن الأشجار
- قائمة صدارة عشوائية (احتمالية) للترتيب التقريبي لقواعد اللاعبين الكبيرة جدًا
المتطلبات
- إصدار حديث من Chrome (الإصدار 107 أو الإصدارات الأحدث)
- Node.js 16 أو إصدار أحدث (نفِّذ الأمر
nvm --version
للاطّلاع على رقم الإصدار إذا كنت تستخدم nvm) - خطة Blaze المدفوعة في Firebase (اختيارية)
- الإصدار 11.16.0 أو إصدار أحدث من Firebase CLI
لتثبيت واجهة سطر الأوامر، يمكنك تنفيذnpm install -g firebase-tools
أو الرجوع إلى مستندات واجهة سطر الأوامر للاطّلاع على المزيد من خيارات التثبيت. - معرفة JavaScript وCloud Firestore وCloud Functions وأدوات مطوّري البرامج في Chrome
2. بدء الإعداد
حصول على الشفرة
لقد وضعنا كل ما تحتاج إليه لهذا المشروع في مستودع Git. للبدء، عليك الحصول على الرمز وفتحه في بيئة التطوير المفضّلة لديك. في هذا الدرس التطبيقي حول الترميز، استخدمنا VS Code، ولكن يمكن استخدام أي محرّر نصوص.
وفك ضغط ملف ZIP الذي تم تنزيله.
أو يمكنك استنساخها في الدليل الذي تختاره:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
ما هي نقطة البداية؟
مشروعنا حاليًا عبارة عن صفحة فارغة تتضمّن بعض الدوال الفارغة:
- يحتوي
index.html
على بعض النصوص البرمجية التي تتيح لنا استدعاء الدوال من "وحدة أدوات المطوّرين" والاطّلاع على نتائجها. سنستخدم هذا الإعداد للتفاعل مع الخلفية والاطّلاع على نتائج استدعاءات الدالة. في سيناريو واقعي، ستُجري عمليات طلب الخلفية هذه من لعبتك مباشرةً. ونحن لا نستخدم لعبة في هذا الدرس العملي لأنّ لعب لعبة في كل مرة تريد فيها إضافة نتيجة إلى قائمة الصدارة سيستغرق وقتًا طويلاً. - تحتوي
functions/index.js
على جميع دوال Cloud. ستظهر لك بعض دوال الأدوات المساعدة، مثلaddScores
وdeleteScores
، بالإضافة إلى الدوال التي سننفّذها في هذا الدرس العملي، والتي تستدعي دوال الأدوات المساعدة في ملف آخر. - يحتوي الملف
functions/functions-helpers.js
على الدوال الفارغة التي سننفذها. بالنسبة إلى كل قائمة صدارة، سننفّذ وظائف القراءة والإنشاء والتعديل، وستلاحظ كيف يؤثر اختيارنا لطريقة التنفيذ في كلّ من تعقيد التنفيذ وأداء التوسيع. - يحتوي
functions/utils.js
على المزيد من وظائف الأداة المساعدة. لن نتناول هذا الملف في هذا الدرس العملي.
إنشاء مشروع Firebase وإعداده
إنشاء مشروع جديد على Firebase
- سجِّل الدخول إلى وحدة تحكّم Firebase باستخدام حسابك على Google.
- انقر على الزر لإنشاء مشروع جديد، ثم أدخِل اسم المشروع (على سبيل المثال،
Leaderboards Codelab
).
- انقر على متابعة.
- إذا طُلب منك ذلك، راجِع بنود Firebase واقبلها، ثم انقر على متابعة.
- (اختياري) فعِّل ميزة "المساعدة المستندة إلى الذكاء الاصطناعي" في وحدة تحكّم Firebase (المعروفة باسم "Gemini في Firebase").
- في هذا الدرس العملي، لا تحتاج إلى "إحصاءات Google"، لذا أوقِف خيار "إحصاءات Google".
- انقر على إنشاء مشروع، وانتظِر إلى أن يتم توفير مشروعك، ثم انقر على متابعة.
إعداد منتجات Firebase
- من قائمة إنشاء، انقر على الدوالّ، وإذا طُلب منك ذلك، يمكنك ترقية مشروعك لاستخدام خطة التسعير Blaze.
- من قائمة إنشاء، انقر على قاعدة بيانات Firestore.
- في مربّع الحوار إنشاء قاعدة بيانات الذي يظهر، انقر على البدء في وضع الاختبار، ثم انقر على التالي.
- اختَر منطقة من القائمة المنسدلة الموقع الجغرافي لخدمة Cloud Firestore، ثم انقر على تفعيل.
ضبط لوحة الصدارة وتشغيلها
- في الوحدة الطرفية، انتقِل إلى جذر المشروع وشغِّل
firebase use --add
. اختَر مشروع Firebase الذي أنشأته للتو. - في جذر المشروع، شغِّل
firebase emulators:start --only hosting
. - في المتصفّح، انتقِل إلى
localhost:5000
. - افتح وحدة تحكّم JavaScript في "أدوات مطوّري البرامج في Chrome" واستورِد
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- تشغيل
leaderboard.codelab();
في وحدة التحكّم إذا ظهرت لك رسالة ترحيب، يعني هذا أنّك قد انتهيت من عملية الإعداد. إذا لم يكن الأمر كذلك، أغلِق المحاكي وأعِد تنفيذ الخطوات من 2 إلى 4.
لنبدأ بتنفيذ لوحة الصدارة الأولى.
3- تنفيذ لوحة صدارة بسيطة
في نهاية هذا القسم، سنتمكّن من إضافة نتيجة إلى قائمة الصدارة ومعرفة ترتيبنا.
قبل أن نبدأ، دعنا نشرح طريقة تنفيذ قائمة الصدارة هذه: يتم تخزين جميع اللاعبين في مجموعة واحدة، ويتم تحديد ترتيب اللاعب من خلال استرداد المجموعة واحتساب عدد اللاعبين الذين يسبقونه. يسهّل ذلك إدراج نتيجة وتعديلها. لإدراج نتيجة جديدة، ما علينا سوى إلحاقها بالمجموعة، ولتعديلها، نُجري فلترة للمستخدم الحالي ثم نعدّل المستند الناتج. لنرى كيف يبدو ذلك في الرمز.
في functions/functions-helper.js
، نفِّذ الدالة createScore
التي لا يمكن أن تكون أبسط من ذلك:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
لتعديل الدرجات، ما علينا سوى إضافة عملية التحقّق من الخطأ للتأكّد من أنّ الدرجة التي يتم تعديلها موجودة من قبل:
async function updateScore(playerID, newScore, firestore) {
const playerSnapshot = await firestore.collection("scores")
.where("user", "==", playerID).get();
if (playerSnapshot.size !== 1) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
const doc = firestore.doc(player.id);
return doc.update({
score: newScore,
});
}
وأخيرًا، دالة الترتيب البسيطة ولكن الأقل قابلية للتوسّع:
async function readRank(playerID, firestore) {
const scores = await firestore.collection("scores")
.orderBy("score", "desc").get();
const player = `${playerID}`;
let rank = 1;
for (const doc of scores.docs) {
const user = `${doc.get("user")}`;
if (user === player) {
return {
user: player,
rank: rank,
score: doc.get("score"),
};
}
rank++;
}
// No user found
throw Error(`User not found in leaderboard: ${playerID}`);
}
لنجرّب ذلك. يمكنك نشر الدوال من خلال تنفيذ ما يلي في الوحدة الطرفية:
firebase deploy --only functions
بعد ذلك، في وحدة تحكّم JavaScript في Chrome، أضِف بعض النتائج الأخرى حتى نتمكّن من رؤية ترتيبنا بين اللاعبين الآخرين.
leaderboard.addScores(); // Results may take some time to appear.
يمكننا الآن إضافة النتيجة الخاصة بنا إلى المزيج:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
عند اكتمال عملية الكتابة، من المفترض أن يظهر ردّ في وحدة التحكّم يقول "تم إنشاء النتيجة". هل تظهر لك رسالة خطأ بدلاً من ذلك؟ افتح سجلّات Functions من خلال وحدة تحكّم Firebase لمعرفة الخطأ.
وأخيرًا، يمكننا جلب نتيجتنا وتعديلها.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
ومع ذلك، يفرض هذا التنفيذ متطلبات غير مرغوب فيها من حيث الوقت والذاكرة لجلب ترتيب نتيجة معيّنة. بما أنّ وقت تنفيذ الدالة والذاكرة محدودان، لن يؤدي ذلك إلى إبطاء عمليات الجلب فحسب، بل بعد إضافة عدد كافٍ من النتائج إلى قائمة الصدارة، ستنتهي مهلة الدوال أو ستتعطّل قبل أن تتمكّن من عرض نتيجة. من الواضح أنّنا سنحتاج إلى شيء أفضل إذا أردنا التوسّع إلى ما هو أبعد من عدد قليل من اللاعبين.
إذا كنت من محبّي Firestore، قد تكون على دراية باستعلامات تجميع COUNT، ما يجعل قائمة الصدارة هذه أكثر فعالية. وهذا صحيح. باستخدام طلبات البحث COUNT، يمكن توسيع نطاق هذا الإجراء بشكل جيد إلى أقل من مليون مستخدم أو نحو ذلك، على الرغم من أنّ الأداء يظل خطيًا.
ولكن قد يخطر ببالك سؤال: إذا كنا سنحصي جميع المستندات في المجموعة على أي حال، يمكننا منح كل مستند ترتيبًا، وعندما نحتاج إلى استرجاعه، ستكون عمليات الاسترجاع لدينا ذات تعقيد زمني ومكاني ثابت. يقودنا ذلك إلى النهج التالي، وهو قائمة الصدارة التي يتم تعديلها بشكل دوري.
4. تنفيذ قائمة صدارة يتم تعديلها بشكل دوري
يكمن مفتاح هذا الأسلوب في تخزين الترتيب في المستند نفسه، لذا فإنّ استرجاعه يمنحنا الترتيب بدون أي عمل إضافي. لتحقيق ذلك، سنحتاج إلى نوع جديد من الدوال.
في index.js
، أضِف ما يلي:
// Also add this to the top of your file
const admin = require("firebase-admin");
exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
// Schedule this when most of your users are offline to avoid
// database spikiness.
.timeZone("America/Los_Angeles")
.onRun((context) => {
const scores = admin.firestore().collection("scores");
scores.orderBy("score", "desc").get().then((snapshot) => {
let rank = 1;
const writes = [];
for (const docSnapshot of snapshot.docs) {
const docReference = scores.doc(docSnapshot.id);
writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
rank++;
}
Promise.all(writes).then((result) => {
console.log(`Writes completed with results: ${result}`);
});
});
return null;
});
الآن، أصبحت عمليات القراءة والتعديل والكتابة بسيطة وسهلة. لم يتغير كل من الإذنَين "الكتابة" و"التعديل"، ولكن أصبح الإذن "القراءة" (في functions-helpers.js
) كما يلي:
async function readRank(playerID, firestore) {
const scores = firestore.collection("scores");
const playerSnapshot = await scores
.where("user", "==", playerID).get();
if (playerSnapshot.size === 0) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
if (player.get("rank") === undefined) {
// This score was added before our scheduled function could run,
// but this shouldn't be treated as an error
return {
user: playerID,
rank: null,
score: player.get("score"),
};
}
return {
user: playerID,
rank: player.get("rank"),
score: player.get("score"),
};
}
لا يمكنك نشر هذا التطبيق واختباره بدون إضافة حساب فوترة إلى مشروعك. إذا كان لديك حساب فوترة، يمكنك تقصير الفاصل الزمني للدالة المجدوَلة ومشاهدة الدالة وهي تحدّد الترتيبات بشكل سحري لنتائج لوحة الصدارة.
إذا لم يكن الأمر كذلك، احذف الدالة المجدوَلة وانتقِل إلى عملية التنفيذ التالية.
احذف النتائج في قاعدة بيانات Firestore من خلال النقر على النقاط الثلاث بجانب مجموعة النتائج للتحضير للقسم التالي.
5- تنفيذ قائمة صدارة للأشجار في الوقت الفعلي
يعمل هذا الأسلوب من خلال تخزين بيانات البحث في مجموعة قاعدة البيانات نفسها. بدلاً من الحصول على مجموعة موحّدة، هدفنا هو تخزين كل شيء في بنية شجرية يمكننا التنقّل فيها من خلال الانتقال بين المستندات. يتيح لنا ذلك إجراء بحث ثنائي (أو بحث n-ary) عن ترتيب نتيجة معيّنة. كيف يمكن أن يبدو ذلك؟
في البداية، نريد أن نتمكّن من توزيع النتائج في مجموعات متساوية تقريبًا، الأمر الذي سيتطلّب بعض المعرفة بقيم النتائج التي يسجّلها المستخدمون. على سبيل المثال، إذا كنت بصدد إنشاء قائمة صدارة لتقييم المهارات في لعبة تنافسية، ستكون تقييمات المهارات لدى المستخدمين موزّعة بشكل طبيعي دائمًا تقريبًا. تستخدم دالة إنشاء النتائج العشوائية Math.random()
في JavaScript، ما يؤدي إلى توزيع متساوٍ تقريبًا، لذا سنقسم المجموعات بالتساوي.
في هذا المثال، سنستخدم 3 مجموعات لتسهيل الأمر، ولكن من المحتمل أن تجد أنّه إذا استخدمت هذا التنفيذ في تطبيق حقيقي، ستؤدي المزيد من المجموعات إلى نتائج أسرع، أي أنّ الشجرة الأقل عمقًا تعني في المتوسط عددًا أقل من عمليات جلب المجموعات وعددًا أقل من حالات تعارض القفل.
يتم تحديد ترتيب اللاعب من خلال مجموع عدد اللاعبين الذين حصلوا على نقاط أعلى، بالإضافة إلى نقطة واحدة للاعب نفسه. ستخزّن كل مجموعة ضمن scores
ثلاثة مستندات، يحتوي كل منها على نطاق وعدد المستندات ضمن كل نطاق، ثم ثلاث مجموعات فرعية مقابلة. لقراءة ترتيب، سنتنقّل في هذه الشجرة بحثًا عن نتيجة مع تتبُّع مجموع النتائج الأكبر. عندما نعثر على النتيجة، سنحصل أيضًا على المجموع الصحيح.
الكتابة أكثر تعقيدًا بكثير. أولاً، علينا إجراء جميع عمليات الكتابة ضمن معاملة واحدة لمنع حدوث تناقضات في البيانات عند إجراء عمليات كتابة أو قراءة متعددة في الوقت نفسه. سنحتاج أيضًا إلى الحفاظ على جميع الشروط التي وصفناها أعلاه أثناء التنقّل في الشجرة لكتابة مستنداتنا الجديدة. وأخيرًا، بما أنّنا نملك كل تعقيد الشجرة في هذا النهج الجديد إلى جانب الحاجة إلى تخزين جميع مستنداتنا الأصلية، ستزداد تكلفة التخزين قليلاً (ولكنها ستظل خطية).
في functions-helpers.js
:
async function createScore(playerID, score, firestore) {
/**
* This function assumes a minimum score of 0 and that value
* is between min and max.
* Returns the expected size of a bucket for a given score
* so that bucket sizes stay constant, to avoid expensive
* re-bucketing.
* @param {number} value The new score.
* @param {number} min The min of the previous range.
* @param {number} max The max of the previous range. Must be greater than
* min.
* @return {Object<string, number>} Returns an object containing the new min
* and max.
*/
function bucket(value, min, max) {
const bucketSize = (max - min) / 3;
const bucketMin = Math.floor(value / bucketSize) * bucketSize;
const bucketMax = bucketMin + bucketSize;
return {min: bucketMin, max: bucketMax};
}
/**
* A function used to store pending writes until all reads within a
* transaction have completed.
*
* @callback PendingWrite
* @param {admin.firestore.Transaction} transaction The transaction
* to be used for writes.
* @return {void}
*/
/**
* Recursively searches for the node to write the score to,
* then writes the score and updates any counters along the way.
* @param {number} id The user associated with the score.
* @param {number} value The new score.
* @param {admin.firestore.CollectionReference} coll The collection this
* value should be written to.
* @param {Object<string, number>} range An object with properties min and
* max defining the range this score should be in. Ranges cannot overlap
* without causing problems. Use the bucket function above to determine a
* root range from constant values to ensure consistency.
* @param {admin.firestore.Transaction} transaction The transaction used to
* ensure consistency during tree updates.
* @param {Array<PendingWrite>} pendingWrites A series of writes that should
* occur once all reads within a transaction have completed.
* @return {void} Write error/success is handled via the transaction object.
*/
async function writeScoreToCollection(
id, value, coll, range, transaction, pendingWrites) {
const snapshot = await transaction.get(coll);
if (snapshot.empty) {
// This is the first score to be inserted into this node.
for (const write of pendingWrites) {
write(transaction);
}
const docRef = coll.doc();
transaction.create(docRef, {exact: {score: value, user: id}});
return;
}
const min = range.min;
const max = range.max;
for (const node of snapshot.docs) {
const data = node.data();
if (data.exact !== undefined) {
// This node held an exact score.
const newRange = bucket(value, min, max);
const tempRange = bucket(data.exact.score, min, max);
if (newRange.min === tempRange.min &&
newRange.max === tempRange.max) {
// The scores belong in the same range, so we need to "demote" both
// to a lower level of the tree and convert this node to a range.
const rangeData = {
range: newRange,
count: 2,
};
for (const write of pendingWrites) {
write(transaction);
}
const docReference = node.ref;
transaction.set(docReference, rangeData);
transaction.create(docReference.collection("scores").doc(), data);
transaction.create(
docReference.collection("scores").doc(),
{exact: {score: value, user: id}},
);
return;
} else {
// The scores are in different ranges. Continue and try to find a
// range that fits this score.
continue;
}
}
if (data.range.min <= value && data.range.max > value) {
// The score belongs to this range that may have subvalues.
// Increment the range's count in pendingWrites, since
// subsequent recursion may incur more reads.
const docReference = node.ref;
const newCount = node.get("count") + 1;
pendingWrites.push((t) => {
t.update(docReference, {count: newCount});
});
const newRange = bucket(value, min, max);
return writeScoreToCollection(
id,
value,
docReference.collection("scores"),
newRange,
transaction,
pendingWrites,
);
}
}
// No appropriate range was found, create an `exact` value.
transaction.create(coll.doc(), {exact: {score: value, user: id}});
}
const scores = firestore.collection("scores");
const players = firestore.collection("players");
return firestore.runTransaction((transaction) => {
return writeScoreToCollection(
playerID, score, scores, {min: 0, max: 1000}, transaction, [],
).then(() => {
transaction.create(players.doc(), {
user: playerID,
score: score,
});
});
});
}
هذا الإجراء أكثر تعقيدًا من الإجراء السابق الذي كان عبارة عن استدعاء طريقة واحدة وستة أسطر فقط من الرمز البرمجي. بعد تنفيذ هذه الطريقة، جرِّب إضافة بعض النتائج إلى قاعدة البيانات ومراقبة بنية الشجرة الناتجة. في وحدة تحكّم JavaScript:
leaderboard.addScores();
يجب أن يبدو هيكل قاعدة البيانات الناتج على النحو التالي، مع ظهور بنية الشجرة بوضوح وأوراق الشجرة التي تمثّل النتائج الفردية.
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
بعد أن انتهينا من الجزء الصعب، يمكننا قراءة النتائج من خلال الانتقال إلى الشجرة كما هو موضح سابقًا.
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = firestore.collection("scores");
/**
* Recursively finds a player score in a collection.
* @param {string} id The player's ID, since some players may be tied.
* @param {number} value The player's score.
* @param {admin.firestore.CollectionReference} coll The collection to
* search.
* @param {number} currentCount The current count of players ahead of the
* player.
* @return {Promise<number>} The rank of the player (the number of players
* ahead of them plus one).
*/
async function findPlayerScoreInCollection(id, value, coll, currentCount) {
const snapshot = await coll.get();
for (const doc of snapshot.docs) {
if (doc.get("exact") !== undefined) {
// This is an exact score. If it matches the score we're looking
// for, return. Otherwise, check if it should be counted.
const exact = doc.data().exact;
if (exact.score === value) {
if (exact.user === id) {
// Score found.
return currentCount + 1;
} else {
// The player is tied with another. In this case, don't increment
// the count.
continue;
}
} else if (exact.score > value) {
// Increment count
currentCount++;
continue;
} else {
// Do nothing
continue;
}
} else {
// This is a range. If it matches the score we're looking for,
// search the range recursively, otherwise, check if it should be
// counted.
const range = doc.data().range;
const count = doc.get("count");
if (range.min > value) {
// The range is greater than the score, so add it to the rank
// count.
currentCount += count;
continue;
} else if (range.max <= value) {
// do nothing
continue;
} else {
const subcollection = doc.ref.collection("scores");
return findPlayerScoreInCollection(
id,
value,
subcollection,
currentCount,
);
}
}
}
// There was no range containing the score.
throw Error(`Range not found for score: ${value}`);
}
const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
return {
user: playerID,
rank: rank,
score: score,
};
}
يتم ترك التحديثات كتمرين إضافي. جرِّب إضافة الدرجات واسترجاعها في وحدة تحكّم JavaScript باستخدام الطريقتَين leaderboard.addScore(id, score)
وleaderboard.getRank(id)
، واطّلِع على كيفية تغيُّر قائمة الصدارة في وحدة تحكّم Firebase.
ومع ذلك، فإنّ التعقيد الذي أضفناه لتحقيق الأداء اللوغاريتمي له تكلفة.
- أولاً، يمكن أن يؤدي تنفيذ قائمة الصدارة هذه إلى حدوث مشاكل في تنازع الأقفال، لأنّ المعاملات تتطلّب قفل عمليات القراءة والكتابة في المستندات للتأكّد من أنّها تظل متسقة.
- ثانيًا، يفرض Firestore حدًا أقصى لعمق المجموعة الفرعية يبلغ 100، ما يعني أنّه عليك تجنُّب إنشاء أشجار فرعية بعد 100 نتيجة مرتبطة، وهو ما لا يفعله هذا التنفيذ.
- وأخيرًا، لا يتوسّع جدول قائمة الصدارة هذا بشكل لوغاريتمي إلا في الحالة المثالية التي تكون فيها الشجرة متوازنة. أما إذا كانت غير متوازنة، فسيكون أسوأ أداء لجدول قائمة الصدارة هذا خطيًا مرة أخرى.
بعد الانتهاء، احذف المجموعتَين scores
وplayers
من خلال وحدة تحكّم Firebase، وسننتقل إلى آخر عملية تنفيذ لقائمة الصدارة.
6. تنفيذ قائمة صدارة عشوائية (احتمالية)
عند تشغيل رمز الإدراج، قد تلاحظ أنّه في حال تشغيله عدة مرات بالتوازي، ستبدأ وظائفك في التعطّل مع ظهور رسالة خطأ متعلقة بتعارض قفل المعاملة. هناك طرق لتجنُّب ذلك لن نستكشفها في هذا الدرس العملي، ولكن إذا لم تكن بحاجة إلى ترتيب دقيق، يمكنك التخلّي عن كل تعقيدات الطريقة السابقة واستخدام طريقة أبسط وأسرع. لنلقِ نظرة على الطريقة التي يمكننا من خلالها عرض ترتيب تقديري لنتائج اللاعبين بدلاً من ترتيب دقيق، وكيف يؤثر ذلك في منطق قاعدة البيانات.
في هذا النهج، سنقسّم قائمة الصدارة إلى 100 مجموعة، تمثّل كل مجموعة منها حوالي واحد بالمائة من النتائج التي نتوقّع تلقّيها. يعمل هذا النهج حتى بدون معرفة توزيع النتائج، وفي هذه الحالة ليس لدينا طريقة لضمان توزيع النتائج بالتساوي تقريبًا في جميع الأقسام، ولكن سنحقق دقة أكبر في تقديراتنا إذا عرفنا كيفية توزيع النتائج.
ويتمثل نهجنا في ما يلي: كما كان من قبل، يخزّن كل قسم عدد النتائج ضمن النطاق المحدد. عند إدراج نتيجة جديدة، سنبحث عن المجموعة المناسبة للنتيجة ونزيد عددها. عند جلب ترتيب، سنجمع فقط الحِزم التي تسبقه ثم نقدّر الترتيب ضمن الحزمة بدلاً من البحث أكثر. يمنحنا ذلك عمليات بحث وإدراج ثابتة الوقت، ويتطلب قدرًا أقل بكثير من الرموز البرمجية.
أولاً، الإدراج:
// Add this line to the top of your file.
const admin = require("firebase-admin");
// Implement this method (again).
async function createScore(playerID, score, firestore) {
const scores = await firestore.collection("scores").get();
if (scores.empty) {
// Create the buckets since they don't exist yet.
// In a real app, don't do this in your write function. Do it once
// manually and then keep the buckets in your database forever.
for (let i = 0; i < 10; i++) {
const min = i * 100;
const max = (i + 1) * 100;
const data = {
range: {
min: min,
max: max,
},
count: 0,
};
await firestore.collection("scores").doc().create(data);
}
throw Error("Database not initialized");
}
const buckets = await firestore.collection("scores")
.where("range.min", "<=", score).get();
for (const bucket of buckets.docs) {
const range = bucket.get("range");
if (score < range.max) {
const writeBatch = firestore.batch();
const playerDoc = firestore.collection("players").doc();
writeBatch.create(playerDoc, {
user: playerID,
score: score,
});
writeBatch.update(
bucket.ref,
{count: admin.firestore.FieldValue.increment(1)},
);
const scoreDoc = bucket.ref.collection("scores").doc();
writeBatch.create(scoreDoc, {
user: playerID,
score: score,
});
return writeBatch.commit();
}
}
}
ستلاحظ أنّ رمز الإدراج هذا يتضمّن بعض المنطق لتهيئة حالة قاعدة البيانات في الأعلى مع تحذير بعدم إجراء شيء من هذا القبيل في مرحلة الإنتاج. لا تتم حماية رمز التهيئة على الإطلاق من حالات التزامن، لذا في حال تنفيذ هذا الإجراء، ستؤدي عمليات الكتابة المتزامنة المتعددة إلى إتلاف قاعدة البيانات من خلال منحك مجموعة من الحِزم المكررة.
يمكنك المتابعة ونشر الدوال ثم تنفيذ عملية إدراج لتهيئة جميع الحِزم بعدد صفر. سيؤدي ذلك إلى ظهور خطأ يمكنك تجاهله بأمان.
leaderboard.addScore(999, 0); // The params aren't important here.
بعد إعداد قاعدة البيانات بشكل صحيح، يمكننا تشغيل addScores
والاطّلاع على بنية بياناتنا في وحدة تحكّم Firebase. البنية الناتجة أبسط بكثير من البنية التي استخدمناها سابقًا، على الرغم من تشابههما ظاهريًا.
leaderboard.addScores();
ولقراءة النتائج، اتّبِع الخطوات التالية:
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = await firestore.collection("scores").get();
let currentCount = 1; // Player is rank 1 if there's 0 better players.
let interp = -1;
for (const bucket of scores.docs) {
const range = bucket.get("range");
const count = bucket.get("count");
if (score < range.min) {
currentCount += count;
} else if (score >= range.max) {
// do nothing
} else {
// interpolate where the user is in this bucket based on their score.
const relativePosition = (score - range.min) / (range.max - range.min);
interp = Math.round(count - (count * relativePosition));
}
}
if (interp === -1) {
// Didn't find a correct bucket
throw Error(`Score out of bounds: ${score}`);
}
return {
user: playerID,
rank: currentCount + interp,
score: score,
};
}
بما أنّنا جعلنا الدالة addScores
تنشئ توزيعًا موحّدًا للنتائج ونستخدم الاستيفاء الخطي داخل الحِزم، سنحصل على نتائج دقيقة جدًا، ولن يتدهور أداء قائمة الصدارة عند زيادة عدد المستخدمين، ولن نقلق بشأن تعارض القفل (بقدر كبير) عند تعديل الأعداد.
7. ملحق: الغش
قد تتساءل الآن: إذا كنت أكتب قيمًا في برنامج التدريب العملي من خلال وحدة تحكّم JavaScript في علامة تبويب المتصفّح، ألا يمكن لأي من اللاعبين الكذب على قائمة الصدارة والإبلاغ عن تسجيل نقاط عالية لم يحقّقها بشكل عادل؟
نعم، يمكنهم ذلك. إذا كنت تريد منع الغش، فإنّ أفضل طريقة للقيام بذلك هي إيقاف عمليات الكتابة من جانب العميل إلى قاعدة البيانات من خلال قواعد الأمان، وتأمين الوصول إلى Cloud Functions حتى لا يتمكّن العملاء من طلبها مباشرةً، ثم التحقّق من صحة الإجراءات داخل اللعبة على الخادم قبل إرسال تحديثات النتائج إلى قائمة الصدارة.
من المهم ملاحظة أنّ هذه الاستراتيجية ليست حلاً سحريًا لمكافحة الغش، فمع وجود حافز كبير بما يكفي، يمكن للغشاشين إيجاد طرق لتجاوز عمليات التحقّق من صحة البيانات من جهة الخادم، وتلعب العديد من ألعاب الفيديو الكبيرة والناجحة باستمرار لعبة القط والفأر مع الغشاشين لتحديد أساليب الغش الجديدة ومنع انتشارها. من النتائج الصعبة لهذه الظاهرة أنّ عملية التحقّق من صحة البيانات من جهة الخادم لكل لعبة تكون مخصّصة بطبيعتها. وعلى الرغم من أنّ Firebase توفّر أدوات لمكافحة إساءة الاستخدام، مثل App Check، التي تمنع المستخدم من نسخ لعبتك من خلال برنامج نصي بسيط للعميل، لا توفّر Firebase أي خدمة تهدف إلى مكافحة الغش بشكل شامل.
إذا لم يتم التأكّد من صحة البيانات من جهة الخادم، سيؤدي ذلك إلى ظهور لوحة صدارة تتضمّن جميع القيم العليا التي حقّقها الغشاشون، وذلك في حال كانت اللعبة رائجة أو كان من السهل الغش فيها.
8. تهانينا
تهانينا، لقد نجحت في إنشاء أربع قوائم صدارة مختلفة على Firebase. واستنادًا إلى احتياجات لعبتك من حيث الدقة والسرعة، ستتمكّن من اختيار ما يناسبك بتكلفة معقولة.
بعد ذلك، اطّلِع على مسارات التعلّم الخاصة بالألعاب.