פיתוח אפליקציית פול סטאק ב-Dart באמצעות Cloud Functions for Firebase

1. מבוא

ב-Codelab הזה תבנו אפליקציית מונה מרובת משתתפים. תלמדו איך להשתמש ב-Dart גם בחלק הקדמי של Flutter וגם בחלק האחורי של Firebase.

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

מה תלמדו

  • לחלץ את הלוגיקה העסקית המשותפת לחבילת Dart עצמאית.
  • כתיבה ופריסה של Cloud Functions for Firebase באופן מקורי ב-Dart.
  • כדאי להשתמש בהידור מראש (AOT) של Dart כדי לצמצם את ההפעלות במצב התחלתי (cold start) של פונקציות בלי שרת (serverless).
  • בודקים את הסטאק באופן מקומי באמצעות כלי האמולטור של Firebase.

2. דרישות מוקדמות

  • Flutter SDK (הגרסה היציבה האחרונה).
  • Firebase CLI (נדרשת גרסה 15.15.0 ומעלה).
  • עורך קוד, כמו Antigravity,‏ Visual Studio Code,‏ IntelliJ או Android Studio, עם הפלאגינים Dart ו-Flutter מותקנים.
  • היכרות בסיסית עם Flutter ו-Firebase.

3. למה כדאי להשתמש ב-Dart עבור ה-Backend?

הרבה אפליקציות בענן משתמשות ב-Dart לממשק המשתמש של הקצה הקדמי ובשפה אחרת, כמו TypeScript,‏ Python או Go, לבק-אנד. לשם כך צריך לתחזק שתי קבוצות נפרדות של מודלים של נתונים. כשסכימת מסד הנתונים משתנה, צריך לעדכן את שני בסיסי הקוד.

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

4. יצירת אפליקציית Flutter

יוצרים אפליקציית Flutter רגילה:

flutter create my_counter
cd my_counter
# Run the app to see the default counter example
flutter run

באפליקציית Flutter רגילה, lib/main.dart מטפל במצב של המונה באופן מקומי:

int _counter = 0;

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

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

5. יצירת החבילה המשותפת

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

מריצים את הפקודות הבאות מהספרייה הראשית של פרויקט my_counter:

mkdir -p packages
cd packages
dart create -t package shared

הוספת יחסי תלות

ב-packages/shared/pubspec.yaml, מוסיפים את כלי הסריאליזציה של JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

הגדרת המודלים המשותפים

יצירת packages/shared/lib/src/models.dart. בקובץ הזה מוגדר מבנה הנתונים שמשמש את האפליקציה ואת השרת.

import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable()
class IncrementResponse {
  final bool success;
  final String? message;
  final int? newCount;

  const IncrementResponse({required this.success, this.message, this.newCount});

  factory IncrementResponse.fromJson(Map<String, dynamic> json) =>
      _$IncrementResponseFromJson(json);

  Map<String, dynamic> toJson() => _$IncrementResponseToJson(this);
}

// Store the function name as a constant to ensure consistency between client and server.
const incrementCallable = 'increment';

ב-packages/shared/lib/shared.dart, מייצאים את המודלים:

library shared;

export 'src/models.dart';

בספרייה packages/shared, מריצים את הכלי ליצירת קוד כדי ליצור את קוד הסריאליזציה של JSON:

dart run build_runner build

6. הגדרת Cloud Functions for Firebase

‫Cloud Functions for Firebase הוא פריימוורק ללא שרתים שמאפשר להריץ קוד בקצה העורפי באופן אוטומטי, בלי צורך לנהל ולהרחיב את השרתים שלכם. שפת Dart מתאימה מאוד כי היא עוברת הידור מראש (AOT) לקובץ בינארי, והיא לא דורשת סביבת זמן ריצה כבדה כמו Node.js או Java. הפעולה הזו מקצרת באופן משמעותי את זמן ההפעלה מההתחלה (cold start) של הפונקציות.

עוברים אל שורש הפרויקט ומפעילים את Cloud Functions for Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • כשמופיעה בקשה לבחירת שפה, בוחרים באפשרות Dart.

ב-functions/pubspec.yaml, מוסיפים נתיב יחסי לחבילה המשותפת:

dependencies:
  firebase_functions:
  google_cloud_firestore:
  shared:
    path: ../packages/shared

7. כתיבת הפונקציה

כדי לכתוב את הלוגיקה של ה-Backend, פותחים את functions/bin/server.dart ומחליפים את התוכן בקוד הבא:

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart'
    show FieldValue;
import 'package:shared/shared.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {

    // Listen for calls to the http request and name defined in the shared package.
    firebase.https.onRequest(name: incrementCallable, (request) async {

      // In a production app, verify the user with request.auth?.uid here.
      print('Incrementing counter on the server...');

      // Get firestore database instance
      final firestore = firebase.adminApp.firestore();

      // Get a reference to the counter document
      final counterDoc = firestore.collection('counters').doc('global');

      // Get the current snapshot for the count data
      final snapshot = await counterDoc.get();

      // Increment response we will send back
      IncrementResponse incrementResponse;

      // Check for the current count and if the snapshot exists
      if (snapshot.data() case {'count': int value} when snapshot.exists) {
        if (request.method == 'GET') {
          // Get the current result
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Read-only sync complete',
            newCount: value,
          );
        } else if (request.method == 'POST') {
          // Increment count by one
          final step = request.url.queryParameters['step'] as int? ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw FailedPreconditionError(
            'only GET and POST requests are allowed',
          );
        }
      } else {
        // Create a new document with a count of 1
        await counterDoc.set({'count': 1});
        incrementResponse = const IncrementResponse(
          success: true,
          message: 'Cloud-sync complete',
          newCount: 1,
        );
      }

      // Return the response as JSON
      return Response(
        200,
        body: jsonEncode(incrementResponse.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    });

  });
}

8. בדיקה מקומית באמצעות כלים לאמולטור של Firebase

אתם יכולים להריץ את ה-frontend ואת ה-backend באופן מקומי בלי לפרוס אותם.

מתיקיית הבסיס של הפרויקט, מפעילים את כלים לאמולטור של Firebase:

# Enable functions and firestore for the emulators
firebase init emulators
# Start the emulators and optionally open up the Admin UI
firebase emulators:start

ב-pubspec.yaml, מוסיפים נתיב יחסי לחבילה המשותפת ומוסיפים את חבילת ה-HTTP:

dependencies:
  http: ^1.6.0
  shared:
    path: /packages/shared

בפרויקט Flutter, פותחים את lib/main.dart ומחליפים את התוכן שלו בקוד הבא. קוד ה-frontend הזה משתמש באותה מחלקה IncrementResponse כמו ה-backend.

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';

/// Get from emulator output when running or when deploying:
/// ✔ functions[us-central1-increment]: http function initialized
///  (http://127.0.0.1:5001/demo-no-project/us-central1/increment).
const incrementUrl = 'FIREBASE_FUNCTIONS_URL_HERE';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
    home: const CounterPage(),
  );
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    // Fetch the current count
    _increment(readOnly: true).ignore();
  }

  Future<void> _increment({bool readOnly = false}) async {
    setState(() => _loading = true);
    try {
       // Call the Dart function.
      final uri = Uri.parse(incrementUrl);
      final response = readOnly ? await http.get(uri) : await http.post(uri);

      // Parse the response back into the shared Dart object.
      final responseData = jsonDecode(response.body);
      final incrementResponse = IncrementResponse.fromJson(responseData);

      if (incrementResponse.success) {
        setState(() => _count = incrementResponse.newCount ?? _count);
      }
    } catch (e) {
      print("Error calling function: $e");
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multiplayer Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('You have pushed the button this many times:'),
            Text(
              '$_count',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _loading ? null : _increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

מריצים את אפליקציית Flutter. כשלוחצים על כפתור פעולה צף (FAB), האפליקציה קוראת ל-backend המקומי של Dart, מאחזרת את המספר החדש ומעדכנת את ממשק המשתמש.

9. פריסה ב-Firebase

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

יצירת פרויקט Firebase

  1. נכנסים למסוף Firebase באמצעות חשבון Google.
  2. לוחצים על הלחצן כדי ליצור פרויקט חדש, ואז מזינים שם לפרויקט.
  3. לוחצים על המשך.
  4. אם מוצגת בקשה לעשות זאת, קוראים ומאשרים את התנאים של Firebase, ואז לוחצים על המשך.
  5. (אופציונלי) מפעילים את העזרה מבוססת-AI במסוף Firebase (שנקראת 'Gemini ב-Firebase').
  6. ב-codelab הזה לא צריך להשתמש ב-Google Analytics, ולכן משביתים את האפשרות של Google Analytics.
  7. לוחצים על יצירת פרויקט, מחכים שהפרויקט יוקצה ולוחצים על המשך.

שדרוג תוכנית התמחור של Firebase

כדי להשתמש בשירותי Firebase ב-codelab הזה, הפרויקט שלכם ב-Firebase צריך להיות בתוכנית התמחור pay-as-you-go (Blaze), כלומר הוא צריך להיות מקושר לחשבון לחיוב ב-Cloud.

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

כדי לשדרג את הפרויקט לתוכנית Blaze, פועלים לפי השלבים הבאים:

  1. במסוף Firebase, בוחרים באפשרות שדרוג התוכנית.
  2. בוחרים בתוכנית Blaze. פועלים לפי ההוראות במסך כדי לקשר חשבון לחיוב ב-Cloud לפרויקט.
    • אם אתם משתמשים בקרדיטים של Google Cloud בשביל ה-codelab הזה, סביר להניח שהחשבון לחיוב נקרא Google Cloud Platform Trial Billing Account או My Billing Account.
    • אם הייתם צריכים ליצור חשבון לחיוב ב-Cloud כחלק מהשדרוג, יכול להיות שתצטרכו לחזור לתהליך השדרוג במסוף Firebase כדי להשלים את השדרוג.

פריסה לפרויקט Firebase

כדי לפרוס את ה-backend של Dart, מריצים את הפקודה הבאה באמצעות Firebase CLI:

firebase use <PROJECT_ID>
firebase deploy --only functions

אחרי שהפקודה רצה, מעתיקים את כתובת ה-URL ומחליפים את FIREBASE_FUNCTIONS_URL_HERE בקוד המקור של אפליקציית Flutter שהוספנו קודם.

10. פתרון בעיות

firebase: command not found

מוודאים שה-CLI של Firebase מותקן ושהגרסה של PATH מעודכנת. אפשר להתקין אותו באמצעות npm: ‏npm install -g firebase-tools.

חסר Dart בתבניות של פונקציות init

כדי ש-Dart יוצג כרשימת אפשרויות לפריסה וליצירת קוד התבנית כשמריצים את הפקודה firebase init functions, צריך להגדיר את דגל הניסוי על ידי הרצת הפקודה firebase experiments:enable dartfunctions.

האמולטור של הפונקציות לא מתחבר

מוודאים שמשתמשים ב-localhost וביציאה 5001. אם אתם בודקים באמולטור Android, המכשיר לא פותר את localhost למחשב המארח. מעדכנים את הגדרות האמולטור ב-main.dart כדי להשתמש ב-10.0.2.2.

החבילה המשותפת לא נמצאה

מאמתים את הנתיב היחסי ב-functions/pubspec.yaml. אם מבנה התיקיות שונה מזה שמופיע ב-codelab, צריך לשנות את path: ../packages/shared כך שיצביע על הספרייה הנכונה.

האם צריך להשתמש ב-json_serializable?

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

11. מזל טוב

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