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

1. מבוא

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

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

מה תלמדו

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

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 תלויות בחבילה הזו.

מהרמה הבסיסית (root) של פרויקט 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, מריצים את הכלי ליצירת build כדי ליצור את קוד הסריאליזציה של 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

אתם יכולים להריץ את הקצה הקדמי והבק-אנד באופן מקומי בלי לפרוס אותם.

מתיקיית הבסיס של הפרויקט, מפעילים את 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

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

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 וממשק המשתמש של הלקוח יישארו מסונכרנים, באמצעות שפת תכנות אחת בכל המערכת.