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

1. บทนำ

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

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

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

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

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

  • Flutter SDK (เวอร์ชันเสถียรล่าสุด)
  • Firebase CLI (ต้องใช้เวอร์ชัน 15.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

เพิ่มการขึ้นต่อกัน

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

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

เรียกใช้ Build Runner ในไดเรกทอรี packages/shared เพื่อสร้างโค้ดการซีเรียลไลซ์ JSON

dart run build_runner build

6. ตั้งค่า Cloud Functions for Firebase

Cloud Functions for 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

dependencies:
  firebase_functions:
  google_cloud_firestore:
  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(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 Emulator Suite

คุณสามารถเรียกใช้ทั้งฟรอนท์เอนด์และแบ็กเอนด์ในเครื่องได้โดยไม่ต้องทำให้ใช้งานได้

เริ่ม 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

เพิ่มเส้นทางแบบสัมพัทธ์ไปยังแพ็กเกจที่แชร์และเพิ่มแพ็กเกจ HTTP ใน pubspec.yaml

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

เปิด lib/main.dart ในโปรเจ็กต์ Flutter แล้วแทนที่เนื้อหาด้วยโค้ดต่อไปนี้ โค้ดส่วนหน้านี้ใช้คลาส 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 คุณต้องตั้งค่าแฟล็กการทดสอบโดยเรียกใช้ 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 แบบ Full-Stack ได้สำเร็จ การดูแลรักษาโมเดลข้อมูลในแพ็กเกจที่แชร์จะช่วยให้มั่นใจได้ว่าการตอบสนองของ API และ UI ไคลเอ็นต์จะซิงค์กันอยู่เสมอ โดยใช้ภาษาโปรแกรมภาษาเดียวในสแต็กทั้งหมด