Crea un'app Dart full-stack con Cloud Functions for Firebase

1. Introduzione

In questo codelab creerai un'app contatore multigiocatore. Imparerai a utilizzare Dart sia per il frontend Flutter sia per il backend Firebase.

Imparerai anche a condividere i modelli di dati tra l'app e il server, eliminando la necessità di duplicare la logica.

Obiettivi didattici

  • Estrarre la logica di business condivisa in un pacchetto Dart autonomo.
  • Scrivere ed eseguire il deployment di Cloud Functions for Firebase in modo nativo in Dart.
  • Sfruttare la compilazione Ahead-of-Time (AOT) di Dart per ridurre gli avvii a freddo serverless.
  • Testare lo stack localmente utilizzando Firebase Emulator Suite.

2. Prerequisiti

3. Perché utilizzare Dart per il backend?

Molte applicazioni cloud utilizzano Dart per l'interfaccia utente del frontend e un altro linguaggio, come TypeScript, Python o Go, per il backend. Ciò richiede la gestione di due set separati di modelli di dati. Quando lo schema di un database cambia, devi aggiornare entrambi i codebase.

Nota: l'utilizzo di Dart sul backend ti consente di combinare l'esperienza utente reattiva di Flutter sul client con la convalida sicura sul server, senza duplicare il codice.

4. Creare l'app Flutter

Crea un'app Flutter standard:

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

In un'app Flutter standard, lib/main.dart gestisce lo stato del contatore localmente:

int _counter = 0;

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

Questo approccio funziona per lo stato locale, ma non è scalabile per un'applicazione multigiocatore in cui il server deve fungere da fonte di verità. Per supportare più giocatori, sposteremo questa logica nel backend nei passaggi seguenti.

5. Creare il pacchetto condiviso

Per evitare di duplicare i modelli sul frontend e sul backend, crea un pacchetto Dart condiviso all'interno del repository del progetto. Sia l'app Flutter sia le funzioni per Firebase dipendono da questo pacchetto.

Dalla radice del progetto my_counter, esegui i seguenti comandi:

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

Aggiungere dipendenze

In packages/shared/pubspec.yaml, aggiungi gli strumenti di serializzazione JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Definire i modelli condivisi

Crea packages/shared/lib/src/models.dart. Questo file definisce la struttura dei dati utilizzata sia dall'app sia dal server.

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';

In packages/shared/lib/shared.dart, esporta i modelli:

library shared;

export 'src/models.dart';

Nella directory packages/shared, esegui il generatore di build per generare il codice di serializzazione JSON:

dart run build_runner build

6. Configurare Cloud Functions for Firebase

Cloud Functions for Firebase è un framework serverless che ti consente di eseguire automaticamente codice di backend senza dover gestire e scalare i tuoi server. Dart è una soluzione ideale perché esegue la compilazione Ahead-of-Time (AOT) in un file binario e non richiede un ambiente di runtime pesante come Node.js o Java. In questo modo, i tempi di avvio a freddo delle funzioni vengono ridotti in modo significativo.

Vai alla radice del progetto e inizializza Cloud Functions for Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Quando ti viene chiesto di selezionare la lingua, scegli Dart.

In functions/pubspec.yaml, aggiungi un percorso relativo al pacchetto condiviso:

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

7. Scrivere la funzione

Per scrivere la logica di backend, apri functions/bin/server.dart e sostituisci i contenuti con il seguente codice:

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. Testare localmente con Firebase Emulator Suite

Puoi eseguire il frontend e il backend localmente senza eseguire il deployment.

Dalla radice del progetto, avvia Firebase Emulator Suite:

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

In pubspec.yaml, aggiungi un percorso relativo al pacchetto condiviso e aggiungi il pacchetto http:

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

Nel progetto Flutter, apri lib/main.dart e sostituisci i contenuti con il seguente codice. Questo codice frontend utilizza la stessa classe IncrementResponse del 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),
      ),
    );
  }
}

Esegui l'app Flutter. Quando fai clic sul pulsante di azione mobile, l'app chiama il backend Dart locale, recupera il nuovo conteggio e aggiorna l'interfaccia utente.

9. Eseguire il deployment su Firebase

In questo codelab, hai potuto provare le funzioni senza un progetto Firebase o un account di fatturazione utilizzando Firebase Local Emulator Suite. Se vuoi utilizzare le funzioni in un ambiente reale (ad esempio un ambiente di produzione), devi configurare un progetto Firebase e la fatturazione.

Creare un progetto Firebase

  1. Accedi alla console Firebase con il tuo Account Google.
  2. Fai clic sul pulsante per creare un nuovo progetto, quindi inserisci un nome del progetto.
  3. Fai clic su Continua.
  4. Se ti viene richiesto, esamina e accetta i Termini di Firebase, quindi fai clic su Continua.
  5. (Facoltativo) Abilita l'assistenza AI nella console Firebase (denominata "Gemini in Firebase").
  6. Per questo codelab, non hai bisogno di Google Analytics, quindi disattiva l'opzione Google Analytics.
  7. Fai clic su Crea progetto, attendi il provisioning del progetto, quindi fai clic su Continua.

Eseguire l'upgrade del piano tariffario Firebase

Per utilizzare i servizi Firebase in questo codelab, il progetto Firebase deve essere incluso nel piano tariffario con pagamento a consumo (Blaze), il che significa che è collegato a un account di fatturazione Cloud.

  • Un account di fatturazione Cloud richiede un metodo di pagamento, ad esempio una carta di credito.
  • Durante le promozioni speciali o se stai seguendo questo codelab nell'ambito di un evento, potrebbero essere disponibili crediti Google Cloud.
  • Se non hai mai utilizzato Firebase e Google Cloud, verifica se hai diritto a un credito di 300$e a un account di fatturazione Cloud per la prova senza costi.

Per eseguire l'upgrade del progetto al piano Blaze:

  1. Nella console Firebase, seleziona l'opzione per eseguire l'upgrade del piano.
  2. Seleziona il piano Blaze. Segui le istruzioni sullo schermo per collegare un account di fatturazione Cloud al progetto.
    • Se stai utilizzando i crediti Google Cloud per questo codelab, l'account di fatturazione è probabilmente denominato Google Cloud Platform Trial Billing Account o My Billing Account.
    • Se hai dovuto creare un account di fatturazione Cloud nell'ambito di questo upgrade, potresti dover tornare al flusso di upgrade nella console Firebase per completare l'upgrade.

Eseguire il deployment nel progetto Firebase

Per eseguire il deployment del backend Dart, esegui il seguente comando utilizzando l'interfaccia a riga di comando di Firebase:

firebase use <PROJECT_ID>
firebase deploy --only functions

Dopo l'esecuzione del comando, copia l'URL e sostituisci FIREBASE_FUNCTIONS_URL_HERE nel codice sorgente dell'app Flutter che abbiamo aggiunto in precedenza.

10. Risoluzione dei problemi

firebase: command not found

Assicurati che l'interfaccia a riga di comando di Firebase sia installata e che il PATH sia aggiornato. Puoi installarla utilizzando npm: npm install -g firebase-tools.

Dart non è presente nei modelli di funzioni di inizializzazione

Affinché Dart venga visualizzato come elenco di opzioni per il deployment e per creare il codice del modello quando esegui firebase init functions, è necessario impostare un flag di esperimento eseguendo firebase experiments:enable dartfunctions.

L'emulatore di funzioni non si connette

Verifica di utilizzare localhost e la porta 5001. Se stai eseguendo test su un emulatore Android, il dispositivo non risolve localhost sulla macchina host. Aggiorna la configurazione dell'emulatore in main.dart per utilizzare 10.0.2.2.

Il pacchetto condiviso non è stato trovato

Verifica il percorso relativo in functions/pubspec.yaml. Se la struttura delle cartelle è diversa da quella del codelab, modifica path: ../packages/shared in modo che punti alla directory corretta.

Devo utilizzare json_serializable?

Sebbene non sia strettamente necessario, l'utilizzo di json_serializable impedisce gli errori causati dalla scrittura manuale dei metodi fromJson e toJson. Garantisce che il frontend e il backend si aspettino esattamente lo stesso formato di dati.

11. Complimenti

Hai creato correttamente un'applicazione Dart full-stack. Mantenendo i modelli di dati in un pacchetto condiviso, ti assicuri che le risposte API e l'interfaccia utente del client rimangano sincronizzate, utilizzando un unico linguaggio di programmazione in tutto lo stack.