สร้างแอป 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 ชุดแยกกัน เมื่อสคีมาของฐานข้อมูลมีการเปลี่ยนแปลง คุณต้องอัปเดตทั้งโค้ดเบส

หมายเหตุ: การใช้ 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.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 เป็นเฟรมเวิร์กแบบ 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

ใน pubspec.yaml ให้เพิ่มเส้นทางที่เกี่ยวข้องกับแพ็กเกจที่แชร์และเพิ่มแพ็กเกจ 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

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

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

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

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

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

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

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

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