สร้างแอป Dart แบบ Full Stack ด้วย Cloud Functions for Firebase

1. บทนำ

ใน Codelab นี้ คุณจะได้สร้างแอปตัวนับแบบผู้เล่นหลายคน และเรียนรู้วิธีใช้ Dart สำหรับทั้งส่วนหน้าของ Flutter และส่วนหลังของ Firebase

นอกจากนี้ คุณยังได้เรียนรู้วิธีแชร์โมเดลข้อมูลระหว่างแอปกับเซิร์ฟเวอร์ ซึ่งช่วยลดความจำเป็นในการทำซ้ำตรรกะ

คุณดูซอร์สโค้ดที่สมบูรณ์สำหรับ Codelab นี้ได้ในที่เก็บตัวอย่าง Cloud Functions for Firebase

สิ่งที่คุณจะได้เรียนรู้

  • แยกตรรกะทางธุรกิจที่ใช้ร่วมกันเป็นแพ็กเกจ Dart แบบสแตนด์อโลน
  • เขียนและทำให้ Cloud Functions for Firebase ใช้งานได้ใน Dart โดยตรง
  • ใช้ประโยชน์จากการคอมไพล์ Ahead-of-Time (AOT) ของ Dart เพื่อลด Cold Start แบบ Serverless
  • ทดสอบสแต็กในเครื่องโดยใช้ Firebase Emulator Suite

2. ข้อกำหนดเบื้องต้น

  • Flutter SDK (เวอร์ชันเสถียรล่าสุด)
  • Firebase CLI (ต้องใช้ v15.15.0 ขึ้นไป)
  • ตัวแก้ไขโค้ด เช่น Antigravity, Visual Studio Code, IntelliJ หรือ Android Studio ที่ติดตั้งปลั๊กอิน Dart และ Flutter
  • มีความคุ้นเคยพื้นฐานกับ Flutter และ Firebase

3. เหตุใดจึงควรใช้ Dart สำหรับแบ็กเอนด์

แอปพลิเคชันระบบคลาวด์จำนวนมากใช้ Dart สำหรับ UI ส่วนหน้า และใช้ภาษาอื่น เช่น TypeScript, Python หรือ Go สำหรับส่วนหลัง ซึ่งต้องดูแลรักษารูปแบบข้อมูล 2 ชุดแยกกัน เมื่อสคีมาฐานข้อมูลมีการเปลี่ยนแปลง คุณต้องอัปเดตทั้ง 2 ฐานโค้ด

หมายเหตุ: การใช้ 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++;
  });
}

วิธีนี้ใช้ได้กับสถานะในเครื่อง แต่ไม่สามารถปรับขนาดให้เหมาะกับแอปพลิเคชันแบบผู้เล่นหลายคนซึ่งเซิร์ฟเวอร์ต้องทำหน้าที่เป็นแหล่งข้อมูลที่ถูกต้อง เราจะย้ายตรรกะนี้ไปไว้ที่แบ็กเอนด์ในขั้นตอนต่อไปนี้เพื่อรองรับผู้เล่นหลายคน

5. สร้างแพ็กเกจที่แชร์

สร้างแพ็กเกจ Dart ที่แชร์ไว้ในที่เก็บโปรเจ็กต์เพื่อหลีกเลี่ยงการทำซ้ำโมเดลในส่วนหน้าและส่วนหลัง ทั้งแอป Flutter และฟังก์ชันสำหรับ Firebase ต่างก็ขึ้นอยู่กับแพ็กเกจนี้

จากรูทของโปรเจ็กต์ my_counter ให้เรียกใช้คำสั่งต่อไปนี้

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

เพิ่มทรัพยากร Dependency

ใน packages/shared/pubspec.yaml ให้เพิ่มเครื่องมือการซีเรียลไลซ์ JSON ดังนี้

dependencies:
  json_annotation: ^4.11.0

dev_dependencies:
  build_runner: ^2.13.1
  json_serializable: ^6.13.1

กำหนดโมเดลที่แชร์

สร้าง 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 สำหรับ Firebase เป็นเฟรมเวิร์กแบบ Serverless ที่ช่วยให้คุณเรียกใช้โค้ดแบ็กเอนด์ได้โดยอัตโนมัติโดยไม่ต้องจัดการและปรับขนาดเซิร์ฟเวอร์ของคุณเอง Dart เหมาะอย่างยิ่งเนื่องจากคอมไพล์ Ahead-of-Time (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 ให้เพิ่ม Relative Path ไปยังแพ็กเกจที่แชร์

dependencies:
  firebase_admin_sdk: ^0.5.6
  firebase_functions: ^0.8.0
  google_cloud_firestore: ^0.5.5
  shared:
    path: ../packages/shared

7. เขียนฟังก์ชัน

หากต้องการเขียนตรรกะแบ็กเอนด์ ให้เปิด 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() {
  runFunctions((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 =
              int.tryParse(request.url.queryParameters['step'] ?? '') ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw HttpResponseException.badRequest(
            message: '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 Emulator Suite

คุณเรียกใช้ทั้งส่วนหน้าและส่วนหลังได้ในเครื่องโดยไม่ต้องติดตั้งใช้งาน

จากรูทของโปรเจ็กต์ ให้เริ่มชุดโปรแกรมจำลอง 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 ให้เพิ่ม Relative Path ไปยังแพ็กเกจที่แชร์และเพิ่มแพ็กเกจ http ดังนี้

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

ในโปรเจ็กต์ Flutter ให้เปิด lib/main.dart แล้วแทนที่เนื้อหาด้วยโค้ดต่อไปนี้ โค้ดส่วนหน้าใช้คลาส IncrementResponse เดียวกันกับแบ็กเอนด์

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 เมื่อคลิกปุ่มการทำงานแบบลอย แอปจะเรียกแบ็กเอนด์ Dart ในเครื่อง ดึงข้อมูลจำนวนใหม่ และอัปเดต UI

9. ติดตั้งใช้งานใน Firebase

ใน Codelab นี้ คุณได้ลองใช้ฟังก์ชันโดยไม่ต้องมีโปรเจ็กต์ Firebase หรือบัญชีสำหรับการเรียกเก็บเงินโดยใช้ชุดโปรแกรมจำลองภายในของ Firebase หากต้องการใช้ฟังก์ชันในสภาพแวดล้อมจริง (เช่น สภาพแวดล้อมฮาร์ดแวร์และซอฟต์แวร์) คุณจะต้องตั้งค่าโปรเจ็กต์ 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 ของคุณต้องอยู่ในแพ็กเกจราคาแบบจ่ายเมื่อใช้ (Blaze) ซึ่งหมายความว่าต้องลิงก์กับบัญชีสำหรับการเรียกเก็บเงินใน Cloud

หากต้องการอัปเกรดโปรเจ็กต์เป็นแพ็กเกจ Blaze ให้ทำตามขั้นตอนต่อไปนี้

  1. ในคอนโซล Firebase ให้เลือกอัปเกรดแพ็กเกจ
  2. เลือกแพ็กเกจ Blaze ทำตามวิธีการบนหน้าจอเพื่อลิงก์บัญชีสำหรับการเรียกเก็บเงินใน Cloud กับโปรเจ็กต์
    • หากคุณใช้เครดิต Google Cloud สำหรับ Codelab นี้ บัญชีสำหรับการเรียกเก็บเงินน่าจะมีชื่อว่า Google Cloud Platform Trial Billing Account หรือ My Billing Account
    • หากคุณต้องสร้างบัญชีสำหรับการเรียกเก็บเงินใน Cloud เป็นส่วนหนึ่งของการอัปเกรดนี้ คุณอาจต้องกลับไปที่ขั้นตอนการอัปเกรดในคอนโซล Firebase เพื่อทำการอัปเกรดให้เสร็จสมบูรณ์

ทําการติดตั้งใช้งานในโปรเจ็กต์ Firebase

หากต้องการติดตั้งใช้งานแบ็กเอนด์ Dart ให้เรียกใช้คำสั่งต่อไปนี้โดยใช้ Firebase CLI

firebase use <PROJECT_ID>
firebase deploy --only functions

หลังจากเรียกใช้คำสั่งแล้ว ให้คัดลอก URL และแทนที่ FIREBASE_FUNCTIONS_URL_HERE ในซอร์สโค้ดของแอป Flutter ที่เราเพิ่มไว้ก่อนหน้านี้

10. การแก้ปัญหา

firebase: command not found

ตรวจสอบว่าได้ติดตั้ง Firebase CLI แล้วและPATHได้รับการอัปเดต คุณติดตั้งได้โดยใช้ npm: npm install -g firebase-tools

ไม่มี Dart ในเทมเพลตฟังก์ชัน init

หากต้องการให้ Dart แสดงเป็นรายการตัวเลือกในการติดตั้งใช้งานและสร้างโค้ดเทมเพลตเมื่อเรียกใช้ firebase init functions คุณต้องตั้งค่า Flag การทดสอบโดยเรียกใช้ firebase experiments:enable dartfunctions

โปรแกรมจำลองฟังก์ชันไม่เชื่อมต่อ

ตรวจสอบว่าคุณใช้ localhost และพอร์ต 5001 หากคุณกำลังทดสอบในโปรแกรมจำลองของ Android อุปกรณ์จะไม่สามารถแก้ไข localhost ไปยังเครื่องโฮสต์ของคุณได้ อัปเดตการกำหนดค่าโปรแกรมจำลองใน main.dart เพื่อใช้ 10.0.2.2

ไม่พบแพ็กเกจที่แชร์

ยืนยัน Relative Path ใน functions/pubspec.yaml หากโครงสร้างโฟลเดอร์แตกต่างจากในโค้ดแล็บ ให้ปรับ path: ../packages/shared เพื่อชี้ไปยังไดเรกทอรีที่ถูกต้อง

ฉันต้องใช้ json_serializable ไหม

แม้จะไม่ใช่ข้อกำหนดที่เข้มงวด แต่การใช้ json_serializable จะช่วยป้องกันข้อผิดพลาดที่เกิดจากการเขียนเมธอด fromJson และ toJson ด้วยตนเอง ซึ่งจะช่วยให้มั่นใจได้ว่าส่วนหน้าและส่วนหลังคาดหวังรูปแบบข้อมูลที่เหมือนกันทุกประการ

11. ขอแสดงความยินดี

คุณสร้างแอปพลิเคชัน Dart แบบ Full-Stack เรียบร้อยแล้ว การดูแลโมเดลข้อมูลในแพ็กเกจที่แชร์จะช่วยให้มั่นใจได้ว่าการตอบกลับจาก API และ UI ของไคลเอ็นต์จะยังคงซิงค์กัน โดยใช้ภาษาโปรแกรมเดียวในทั้งสแต็ก

หากต้องการดูโค้ดที่เสร็จสมบูรณ์ทั้งหมดสำหรับโปรเจ็กต์นี้ โปรดดูตัวอย่าง Codelab ในที่เก็บ firebase/functions-samples