แผนการเรียนรู้
/
Flutter Mobile Dev | Week 3 • Parts 1 & 2

พัฒนา Flutter Data Layer & Service Integration

🎯 สารบัญโครงสร้างเนื้อหาหมวดที่ 1 และ 2

🔵 หมวดที่ 1: แนะนำสถาปัตยกรรมชั้นข้อมูล (Data Layer Architecture)
🟢 หมวดที่ 2: พัฒนา Service Layer และจัดการข้อมูล (Network Clients)
💡 คำชี้แนะ: คลิกหัวข้อบนเมนูเพื่อข้ามไปยังเนื้อหาสไลด์ที่ต้องการศึกษา หรือใช้ปุ่มควบคุมด้านล่างเพื่อไล่ดูเนื้อหาทีละหน้า
Slide 1 • Data Layer Architecture

บทบาทหน้าที่ของชั้นข้อมูล (Data Layer Role) 📦

ตามคู่มือมาตรฐานสถาปัตยกรรมแอปพลิเคชัน Flutter ชั้นข้อมูลหรือ Data Layer จะทำหน้าที่ห่อหุ้มตรรกะการจัดเตรียม เข้าถึง และอัปเดตข้อมูลของแอปพลิเคชัน โดยแยกออกเป็น 2 คอมโพเนนต์หลักที่มีหน้าที่ต่างกันชัดเจน [1, 2]:

🔌 1. Services (ผู้ติดต่อระบบภายนอก)

ทำหน้าที่ติดต่อสื่อสารรับส่งข้อมูลดิบกับแหล่งข้อมูลภายนอกแบบไร้สถานะ (Stateless) เช่น การทำ HTTP Requests คุยกับ Django REST API Backend หรือการดึงข้อมูลจาก Local Database [2, 3]

📂 2. Repositories (ผู้ดูแลลอจิกการเข้าถึงข้อมูล)

ทำหน้าที่ประสานงานระหว่าง Services ดึงข้อมูลดิบมาล้าง ขัดเกลาแปลงเป็น Domain Models รวมถึงควบคุมลอจิกธุรกิจ เช่น การทำ Caching หรือการกักเก็บความจริงหนึ่งเดียว (SSOT) ของข้อมูล [1-4]

Data Layer Architecture
📊 แผนภาพแสดงการแบ่งสัดส่วนเลเยอร์ข้อมูล และทิศทางไหลเวียนสถานะขึ้นสู่ UI แบบทิศทางเดียว (UDF)
Slide 2 • dio Client Setup

การติดตั้งและตั้งค่า HTTP Client ด้วยแพ็กเกจ dio 🛠️

สำหรับการพัฒนาแอปพลิเคชัน Compass เราเลือกใช้แพ็กเกจ `dio` ซึ่งเป็นคลังไลบรารีเครือข่ายยอดนิยมที่มีลูกเล่นขั้นสูง เช่น การตั้งค่า Base Options, การแนบไฟล์ และการทำ Interceptors ดักจับข้อมูลอัตโนมัติ [5]:

# ขั้นตอนที่ 1: เพิ่มไลบรารี dio เข้าสู่โปรเจกต์ Flutter:

$ flutter pub add dio

# ขั้นตอนที่ 2: ตรวจสอบเวอร์ชันที่ติดตั้งใน pubspec.yaml

$ cat pubspec.yaml | grep dio

💡 แนวทางปฏิบัติดีที่สุด (Best Practice):

แนะนำให้จัดสร้างอินสแตนซ์ของ Dio เป็นแบบ Singleton เพื่อความสะดวกคงที่ในการจัดการชุดคอนฟิกูเรชันหลัก เช่น พอร์ตเส้นทางฐาน (Base URL) และจำกัดระยะเวลาดึงข้อมูล (Timeout Limits) ร่วมกันทั่วแอปพลิเคชัน [6]:

// lib/data/services/dio_client.dart

import 'package:dio/dio.dart';

final options = BaseOptions(
  baseUrl: 'http://localhost:8000/api', // Django Server API Port
  connectTimeout: Duration(seconds: 5), // ระยะเวลาเชื่อมต่อระบบสูงสุด
  receiveTimeout: Duration(seconds: 3), // ระยะเวลารอคอยรับข้อมูล
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
);

// สร้าง Instance เดียวใช้ร่วมทั่วแอป
final dio = Dio(options);

📋 โครงสร้างไฟล์ที่แนะนำ:

lib/
└── data/
    ├── services/
    │   ├── dio_client.dart   // Dio Instance Config
    │   ├── api_client.dart    // API Methods
    │   └── secure_storage.dart // Token Storage
    └── repositories/
        └── booking_repository.dart

🔗 ดูรายละเอียดปลั๊กอิน dio เพิ่มเติม: 📦 dio Package — pub.dev 📖 dio API Documentation — pub.dev 💻 dio GitHub Repository & Examples
Slide 3 • Simulation & CORS Handling

ระบบเครือข่ายจำลองและสิทธิ์ดึงข้อมูล CORS บนหน้าเว็บ 🌐

ในการรันจำลองระบบแอปพลิเคชัน Compass เพื่อดึงข้อมูลจริงจากเซิร์ฟเวอร์หลังบ้าน Django REST Framework นักศึกษาต้องคำนึงถึงเป้าหมายการรันและสิทธิ์ความปลอดภัยให้ถูกต้อง:

📱 เครือข่ายจำลองอุปกรณ์ (Emulators):

ระบบจำลองของ Android (Android Emulator) มักจะใช้งานระบบพอร์ตเซิร์ฟเวอร์ฐานผ่านทางไอพีวงในจำเพาะคือ http://10.0.2.2:8000 แทนการชี้เป้าหา localhost ตรงๆ เพื่อเลี่ยงข้อจำกัดวงลูปเน็ตเวิร์กของเครื่องจำลอง.

🌐 ความปลอดภัย Cross-Origin (CORS):

เมื่อรันแอปบน Web Browser เบราว์เซอร์จะส่งคำขอทดสอบความสอดคล้อง CORS Preflight ซึ่งต้องการให้ฝั่งเซิร์ฟเวอร์หลังบ้าน Django มีการติดตั้งชุด Middleware เปิดใช้งาน django-cors-headers เพื่อยินยอมและอนุญาตให้เข้าถึงข้อมูลข้ามโดเมนได้ [7, 8].

# Terminal 2 — เริ่ม Backend ที่สร้างไว้ในสัปดาห์ที่ 12 (ต้องรันคู่กันเสมอ)

$ cd oidc-backend && uv run manage.py runserver

# ตรวจความพร้อมของ CORS + endpoints ด้วย HTTPie ก่อนเขียนแอป

$ http :8000/.well-known/openid-configuration

🛠️ ขั้นตอนติดตั้ง CORS ที่ฝั่ง Django (ถ้ายังไม่ได้ทำในสัปดาห์ที่ 12):

# 1. ติดตั้งแพ็กเกจ django-cors-headers

$ uv add django-cors-headers

# 2. เพิ่มใน INSTALLED_APPS ของ settings.py

'corsheaders',

# 3. เพิ่ม Middleware (วางไว้ก่อน CommonMiddleware)

'corsheaders.middleware.CorsMiddleware',

# 4. กำหนดอนุญาตให้ Flutter Web เข้าถึงได้ (settings.py)

CORS_ALLOWED_ORIGINS = ['http://localhost:3000']

CORS_ALLOW_CREDENTIALS = True

🔗 ศึกษากลไกความรอบคอบ CORS บนเว็บ (MDN):

📖 CORS — MDN Web Docs 📦 django-cors-headers — GitHub ⚠️ CORS Error Reference — MDN
Slide 4 • Define ApiClient Service

การสร้าง Stateless Network Client (ApiClient) 🔌

เราจะสร้างคลาสวิศวกรรมข้อมูลจำเพาะชื่อ ApiClient จัดให้อยู่ในชั้น Service Layer เพื่อเป็นศูนย์กลางติดต่อสื่อสารอินเทอร์เน็ตในการยิง API แลกเปลี่ยนค่าดิบ [1, 2]:

// lib/data/services/api_client.dart

import 'package:dio/dio.dart';

class ApiClient {

final Dio _dio;

ApiClient({required Dio dio}) : _dio = dio;

// ยิงคำขอดึงข้อมูลดิบจากเซิร์ฟเวอร์แบบไร้สถานะ (Stateless HTTP GET)

Future<Response<T>> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {

return _dio.get<T>(path, queryParameters: queryParameters);

}

Future<Response<T>> post<T>(String path, {dynamic data}) async {

return _dio.post<T>(path, data: data);

}

Future<Response<T>> delete<T>(String path) async {

return _dio.delete<T>(path);

}

Future<Response<T>> put<T>(String path, {dynamic data}) async {

return _dio.put<T>(path, data: data);

}

}

📘 หลักการออกแบบ ApiClient (Design Principles):

  • ไร้สถานะ (Stateless): ไม่เก็บข้อมูลผู้ใช้หรือ Token ภายในคลาส
  • Generic Methods: ใช้ <T> เพื่อรองรับทุกชนิดข้อมูลที่แปลงกลับ
  • รับ Dio ผ่าน Constructor: เพื่อให้ส่ง Interceptor หรือ Fake Dio ในการเทสต์ได้
🔗 ดูโค้ด ApiClient ของโครงการตัวอย่างทางการบน GitHub และเอกสาร: 💻 Compass App Official — github.com/flutter/samples 📖 Services Layer Guide — docs.flutter.dev
Slide 5 • Error Handling & Result Pattern

การรับมือข้อผิดพลาดอย่างปลอดภัยด้วย Result Pattern 🛡️

แอปพลิเคชัน Compass ออกแบบตามคู่มือวิศวกรรมข้อมูลแนะนำให้ใช้งานคลาสผู้รับผิดชอบพิเศษคือ `Result` ในการเป็นกล่องห่อหุ้มค่าตอบรับแบบอะซิงโครนัส เพื่อลดโอกาสเกิดข้อผิดพลาดรุนแรงเมื่ออินเทอร์เน็ตหลุด [9]:

🧩 โครงสร้างกล่อง Result (Sealed Class Concept):

  • Result.ok(T value): คืนค่าข้อมูลที่ขัดเกลาแล้วสำเร็จเมื่อดึงข้อมูลผ่าน API ไร้ปัญหา [9].
  • Result.error(Exception error): ส่งต่อชุดข้อผิดพลาดดักจับขึ้นไปประมวลผลต่อเพื่อแสดงหน้าต่าง Warning บน UI โดยที่โปรแกรมระบบไม่ล่มดับกลางคัน [9].

พฤติกรรมการควบคุมลักษณะนี้ช่วยให้สามารถเขียนประโยคควบคุมสวิตช์เงื่อนไข (Switch Case Statement) ในการตรวจดูสถานะสิทธิ์ได้อย่างรวดเร็วและปลอดภัยแบบไร้ช่องโหว่ [9].

// lib/utils/result.dart — นิยามคลาส Result ด้วย sealed class (Dart 3)

sealed class Result<T> {

const Result();

// ฟังก์ชันโรงงาน (Factory) สร้างสถานะสำเร็จ

factory Result.ok(T value) => Ok(value);

// ฟังก์ชันโรงงานสร้างสถานะผิดพลาด

factory Result.error(Exception error) => Failure(error);

class Ok<T> extends Result<T> {

final T value;

const Ok(this.value);

}

class Failure<T> extends Result<T> {

final Exception error;

const Failure(this.error);

}

}

📌 วิธีใช้งานใน ViewModel:

switch (result) {
  case Result.ok(): // แสดงข้อมูล
  case Result.error(): // แสดงข้อผิดพลาด
}

🔗 ศึกษารายละเอียดแนวคิด Result Design Pattern และ sealed class ทางการ: 📖 Result Pattern — docs.flutter.dev 📖 Dart Sealed Class Modifiers — dart.dev
Slide 6 • Implementing GET Requests

ขั้นตอนปฏิบัติ: การดึงข้อมูลทริปการท่องเที่ยว (GET Request) 📡

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

// lib/data/services/api_client.dart (GET Method Extension)

Future<Result<List<BookingSummary>>> fetchBookings() async {

try {

final response = await _dio.get('/bookings/');

if (response.statusCode == 200) {

// แปลงค่า JSON ขาเข้าเป็นประเภท List ของอ็อบเจกต์โมเดลข้อมูลจริง

final list = (response.data['bookings'] as List)

.map((json) => BookingSummary.fromJson(json))

.toList();

return Result.ok(list);

} else {

return Result.error(Exception('Failed to load bookings'));

}

} on Exception catch (e) {

return Result.error(e);

}

}

# 🧪 ทดสอบ endpoint เดียวกันด้วย HTTPie ก่อนเขียนโค้ด Dart

$ http :8000/api/bookings/ "Authorization: Bearer <access_token>"

📘 ขั้นตอนปฏิบัติ (Step-by-Step):

  1. สร้าง Model โมเดล BookingSummary พร้อม factory fromJson()
  2. เรียก Method fetchBookings() จาก ApiClient
  3. ตรวจสอบ statusCode == 200 ว่าได้รับการตอบสนองสำเร็จ
  4. แปลง JSON ด้วย .map().toList() เข้าสู่ List ของ Domain Model
  5. ห่อด้วย Result.ok() เพื่อส่งต่อให้ Repository
💡 คำแนะนำเชิงปฏิบัติ: การประมวลผลดึงค่า JSON จะตอบกลับสถานะเรียบร้อยผ่านทางรหัส HTTP 200 OK สกัดชุดโครงร่างมาห่อไว้ในกล่อง Result.ok ทันที [10].
🔗 อ้างอิงเพิ่มเติม: 📖 HTTP 200 OK — MDN Web Docs
Slide 7 • Implementing POST & DELETE Requests

ขั้นตอนปฏิบัติ: การบันทึกและลบข้อมูลแบบระบุสิทธิ์ 🔄

การบันทึกทริปใหม่หรือลบทริปท่องเที่ยวสำเร็จต้องการการเรียกใช้คำสั่งระบุสิทธิ์จำเพาะผ่าน POST หรือ DELETE เพื่อกระตุ้นระบบปรับเปลี่ยนข้อมูลฝั่งฐานข้อมูล [9]:

// lib/data/services/api_client.dart (Mutation Methods)

Future<Result<void>> deleteBooking(int id) async {

try {

final response = await _dio.delete('/bookings/$id/');

if (response.statusCode == 204 || response.statusCode == 200) {

return Result.ok(null);

} else {

return Result.error(Exception('Failed to delete booking'));

}

} on Exception catch (e) {

return Result.error(e);

}

}

// lib/data/services/api_client.dart (POST Booking Method)

Future<Result<BookingSummary>> createBooking(Map<String, dynamic> data) async {

try {

final response = await _dio.post('/bookings/', data: data);

if (response.statusCode == 201) {

return Result.ok(BookingSummary.fromJson(response.data));

} else {

return Result.error(Exception('Failed to create booking'));

}

} on Exception catch (e) {

return Result.error(e);

}

}

# 🧪 ทดสอบคำสั่งลบด้วย HTTPie (ต้องได้กลับมา 200 หรือ 204 No Content)

$ http DELETE :8000/api/bookings/1/ "Authorization: Bearer <access_token>"

# 🧪 ทดสอบการสร้างข้อมูลด้วย POST (ต้องได้กลับมา 201 Created)

$ http POST :8000/api/bookings/ destination_name="Chiang Mai" price:=2500 "Authorization: Bearer <access_token>"

⚠️ คำชี้แจง: การลบทริปจะเดินทางเริ่มจากการสไลด์หน้าจอปัดทิ้ง (Dismissible Widget Event) ฝั่ง UI แล้วส่งพาสมาให้เลเยอร์ข้อมูลเพื่อส่งคำสั่งไปทำลายสถิติฝั่ง Backend ของ Django และรายงานสถานะกลับ [9, 11].

📘 รหัสสถานะ HTTP ที่ควรรู้:

200 OK — สำเร็จ  •  201 Created — สร้างข้อมูลสำเร็จ  •  204 No Content — สำเร็จแต่ไม่มีข้อมูล  •  401 Unauthorized — Token หมดอายุ  •  404 Not Found — ไม่พบ endpoint

Slide 8 • Parts 1 & 2 Summary

บทสรุปการเชื่อมโยงระบบ Services และเป้าหมายต่อไป 🏁

ยินดีด้วยครับ! ตอนนี้นักศึกษาเข้าใจแก่นแท้ของ **Service Layer** สามารถใช้งานแพ็กเกจ `dio` ในการทำแทร็กคิวเรชันดึงผลลัพธ์เครือข่ายจำลอง และรับมือข้อผิดพลาดอัตราส่วนข้อมูลผ่านกล่อง `Result` ได้อย่างปลอดภัยแล้ว [9]

🚀 สิ่งที่เราจะทำต่อในหมวดที่ 3 ของสัปดาห์นี้:

  • สร้าง Abstract Repositories: ออกโครงร่างหน้ากากเป็นแนวทางฐานของข้อมูล
  • แยกโมเดลขอบเขตทริป: สกัดความต่างคลาสข้อมูล API Model สู่ Pure Domain Model
  • ปกป้อง Service เป็นส่วนตัว (Private): ดำเนินการล็อคสิทธิ์ปิดไม่ให้ UI ชั้นนอกข้ามมาติดต่อ Services โดยตรงเด็ดขาดเพื่อความเสถียรสูงสุด [3]
📚 ศึกษาการสื่อสารสัมพันธ์ระหว่างเลเยอร์และสถาปัตยกรรมล่วงหน้า: https://docs.flutter.dev/app-architecture/guide 🚀
Flutter Mobile Dev | Week 3 • Parts 3 & 4

หมวดที่ 3 และ 4: Repository Layer, Secure Storage & dio Interceptors

🎯 สารบัญสไลด์ปฏิบัติการหมวดที่ 3 และ 4

📂 หมวดที่ 3: พัฒนา Repository Layer
🔐 หมวดที่ 4: ความปลอดภัยและการจัดการ Token
💡 คำชี้แนะ: คลิกที่รายการเมนูหัวข้อด้านบนเพื่อข้ามไปยังหน้าสไลด์ที่สนใจ หรือใช้ปุ่มควบคุมถัดไปด้านล่างได้ทันที
Slide 10 • Defining Repository Interfaces

การออกแบบคลาสฐานด้วยอินเทอร์เฟซ (Abstract Repositories) 📐

สถาปัตยกรรมระดับ Production ต้องการความยืดหยุ่นในการสลับเปลี่ยนสภาพแวดล้อมทำโครงงาน (Environments) เราจึงเริ่มจากการออกแบบสัญญากลางหรืออินเทอร์เฟซฐานด้วยคำสั่ง **`abstract class`** เพื่อกำหนดพฤติกรรมการทำงานของข้อมูลที่ทุกสภาพแวดล้อมต้องเขียนรองรับเหมือนกัน:

// lib/domain/repositories/booking_repository.dart (สัญญาฐานร่วมกัน)

import 'package:compass_booking_app/domain/models/booking.dart';

import 'package:compass_booking_app/utils/result.dart';

abstract class BookingRepository {

Future<Result<List<BookingSummary>>> loadBookings();

Future<Result<BookingSummary>> create(BookingSummary booking);

Future<Result<void>> delete(int id);

}

💡 ทำไมต้องออกแบบแบบ Abstract Class?

  • ช่วยให้เราเขียนแอปแยกเลเยอร์ได้ง่าย โดยเลเยอร์ UI ด้านบนไม่จำเป็นต้องรู้ว่าข้อมูลมาจากไหน
  • รู้เพียงสัญญาการมีอยู่ของคำสั่งเรียกใช้งานเท่านั้น
  • ทำให้สลับไปเทสด้วย Fake คลาสจำลองได้สะดวกรวดเร็ว (Loose Coupling)
  • รองรับการเพิ่มแหล่งข้อมูลใหม่ (เช่น SQLite) โดยไม่ต้องแก้ UI
🔗 ศึกษาหลักการจัดทำคลังข้อมูลและ Dependency Inversion: 📖 Compass Data Layer — docs.flutter.dev 📖 Dependency Inversion Principle — Wikipedia
Slide 11 • Local and Remote Implementations

พัฒนา BookingRepositoryLocal และ BookingRepositoryRemote 📂

เราจะสร้างคลาสลูกที่สืบทอดสัญญาจากคลาสหลัก ได้แก่ **`BookingRepositoryLocal`** (เพื่อดึงข้อมูลออฟไลน์จำลองจากไฟล์ดิบในเครื่องตอนเขียนโปรแกรม) และ **`BookingRepositoryRemote`** (ส่งมอบคำสั่งดึงข้อมูลเครือข่ายจริงข้ามพอร์ตเซิร์ฟเวอร์):

// lib/data/repositories/booking_repository_remote.dart (ระบบรันงานจริง)

import '../../domain/repositories/booking_repository.dart';

class BookingRepositoryRemote implements BookingRepository {

final ApiClient _apiClient; // รับตัวติดต่อ Service เครือข่ายผ่าน Constructor

BookingRepositoryRemote({required ApiClient apiClient}) : _apiClient = apiClient;

@override

Future<Result<List<BookingSummary>>> loadBookings() async {

try {

final response = await _apiClient.get('/bookings/');

final list = (response.data['bookings'] as List).map((e) => BookingSummary.fromJson(e)).toList();

return Result.ok(list);

} on Exception catch (e) {

return Result.error(e);

}

}

@override

Future<Result<void>> delete(int id) async => _apiClient.deleteBooking(id);

}

🔀 Local vs Remote Repository:

BookingRepositoryRemote — ดึงข้อมูลจริงจาก Django Backend ผ่าน ApiClient
BookingRepositoryLocal — ใช้ข้อมูลจำลอง (Mock Data) จากไฟล์/หน่วยความจำ ใช้ตอนพัฒนา UI ที่ยังไม่มี Backend หรือเขียน Unit Test โดยใช้ FakeBookingRepository

🔗 ตรวจสอบสลักโค้ดการแยก Local & Remote เพิ่มเติมของแอปพลิเคชันต้นแบบ Compass และตัวอย่าง Repository Pattern: 💻 Compass Repositories — github.com/flutter/samples 📖 The Clean Architecture — Uncle Bob
Slide 12 • Data Mapping & Translation

การเชื่อมโยงข้อมูล: API Model สู่ Pure Domain Model 🔄

เพื่อรักษาเสถียรภาพของ UI หน้าจอไม่ให้ปั่นป่วนเมื่อสคีมาของหลังบ้าน (Backend Database Schema) ปรับเปลี่ยนโครงสร้าง เราจะใช้แนวทาง **`Data Mapping`** สกัดกั้นโมเดลข้อมูลดิบจากเซิร์ฟเวอร์ แปลงมาเป็น Domain Model ที่ปลอดภัยและเป็นมิตรฝั่งหน้าแอปพลิเคชัน:

🛠️ ขั้นตอนการสกัดล้างข้อมูล (Data Translation Flow)

📡 JSON ขาเข้าดิบ (Raw JSON Map)
➡️ [ mapping.dart ] ➡️
🧩 Pure Domain Model (Immutable Object)

// lib/domain/models/booking_summary.dart — คลาสโครงสร้างข้อมูลที่มีคุณสมบัติคงที่ (Immutable Domain Model)

class BookingSummary {

final int id;

final String destinationName;

final double price;

final String? imageUrl; // ฟิลด์เสริมจาก Domain Logic

BookingSummary({required this.id, required this.destinationName, required this.price, this.imageUrl});

// ตัวถอดค่าจาก JSON Map สรุปแปลงผลอย่างแม่นยำ

factory BookingSummary.fromJson(Map<String, dynamic> json) =>

BookingSummary(id: json['id'], destinationName: json['destination_name'], price: json['price'].toDouble(), imageUrl: json['image_url'] as String?);

}

📘 ทำไมต้องแยก API Model ออกจาก Domain Model?

  • กัน UI ไม่พัง: เมื่อ Backend เปลี่ยนชื่อฟิลด์ เราแก้เฉพาะ mapping.dart ไม่ต้องแก้ Views
  • เป็นอิสระจากภายนอก: Domain Model ไม่มี dependency กับ dio/http หรือ JSON
  • รองรับ Logic ของแอป: เพิ่ม computed property เช่น fullName, formattedPrice
🔗 ศึกษารูปแบบสถาปัตยกรรมโมเดลระบายข้อมูลเพิ่มเติม: 📖 Domain Models — docs.flutter.dev 📖 JSON Serialization in Flutter — docs.flutter.dev 📦 json_serializable (Code Generation) — pub.dev
Slide 13 • Rules of Engagement

กฎเหล็ก: ทำไม UI และ ViewModel ห้ามคุยกับ Service โดยตรง? 🛡️

ในสถาปัตยกรรมแบบแยกเลเยอร์ของแอป Compass **`ApiClient`** หรือ Service ติดต่อเครือข่ายจะต้องถูกกำหนดให้เป็นตัวแปรประเภท **`private`** เสมอ เพื่อห้ามไม่ให้ส่วนแสดงผล (UI Layer) ข้ามระดับลงมาเรียกใช้งาน Service โดยตรงเด็ดขาด:

🛑 ผลเสียร้ายแรงหาก UI ข้ามระดับ:

  • สูญเสียการทำ Caching: ข้อมูลดิบจะไม่ผ่านระบบประเมินดักกรองของคลังเก็บข้อมูล
  • เทสระบบยากขึ้น: ไม่สามารถเขียนชุดทดสอบแบบจำลอง (Mock/Fake Tests) แยกสลับเฉพาะเลเยอร์ได้
  • บำรุงรักษาระบบติดขัด: หาก API โครงสร้างเปลี่ยน โค้ดฝั่งหน้าจอ View ทั้งแอปจะล่มทันที

✅ กฎการเชื่อมโยงระหว่างเลเยอร์ที่ถูกต้อง (Rules of Engagement):

View ↔ ViewModel ↔ Repository ↔ Service (ApiClient)

ทิศทางการไหลของข้อมูล: UI ส่งคำสั่ง → ViewModel เรียก Repository → Repository ใช้ Service ดึงข้อมูลดิบ → แปลงเป็น Domain Model → ส่งกลับผ่าน Result

🔗 เจาะลึกการจัดโครงสร้าง Layered Architecture (Flutter Docs): 📖 Compass Data Layer — docs.flutter.dev 📖 App Architecture Guide — docs.flutter.dev
Layered Architecture Diagram
📐 รูปแบบทิศทางการไหลเวียนข้อมูลที่ถูกต้อง: หน้าจอ UI เข้าถึง Repositories เพื่อประมวลผล ส่วน Repositories ทำหน้าที่ประสาน Service ด้านล่างอย่างเป็นความลับ
Slide 14 • Safe Token Persistence

สถาปัตยกรรมกักเก็บ Token อย่างมั่นคงบนอุปกรณ์มือถือ 🔐

การจัดเก็บ Token ยืนยันสิทธิ์ลงในเครื่องพกพา หากเก็บแบบลักไก่ผ่านระบบธรรมดา เช่น `SharedPreferences` (บน Android) หรือคุกกี้ทั่วไป จะเสี่ยงต่อการโดนฉกชิงถอนรหัสลับความปลอดภัยได้โดยง่ายจากการเจาะช่องโหว่ (เช่น Rooting หรือ XSS):

🔑 คีย์ที่เราจะเก็บ (ได้จาก OIDC Server สัปดาห์ที่ 12): 🪪 id_token 🎫 access_token ♻️ refresh_token

❌ ระบบจัดเก็บที่ไม่ปลอดภัย

หน่วยความจำปกติไม่เคยทำการเข้ารหัสข้อมูลดิบ ทำให้ผู้มีสิทธิ์ผู้บริหารเครื่อง (Root/Jailbreak) หรือโปรแกรมประสงค์ร้ายแอบดึงไฟล์ข้อมูลออกไปสแกนและเข้าสิทธิ์ทำงานแทนผู้ใช้ได้ทันที

✅ ระบบการจัดเก็บระดับสากล

ระบบความปลอดภัยเชิงฮาร์ดแวร์ ได้แก่ Keychain (สำหรับ iOS) และ Keystore (สำหรับ Android) จะทำการกักเก็บข้อมูลความลับของ Token ทุกสายไว้หลังกำแพงการเข้ารหัสฮาร์ดแวร์โดยสมบูรณ์

💡 ทางเลือกที่ดีที่สุดของทีม Flutter: นำแพ็กเกจระดับความน่าเชื่อถือสูงอย่าง `flutter_secure_storage` มาเขียนครอบการประมวลผล เพื่อเรียกใช้คำสั่งความมั่นคงร่วมกันแบบข้ามค่ายได้ทันที

📋 เปรียบเทียบวิธีจัดเก็บ Token ใน Flutter:

วิธี ความปลอดภัย ข้อควรระวัง
SharedPreferences ต่ำ ❌ ไม่ได้เข้ารหัส เก็บเป็น Text เปล่า
flutter_secure_storage สูง ✅ ใช้ Keychain (iOS) / Keystore (Android)
Hive / Isar กลาง ⚠️ เหมาะกับข้อมูลทั่วไป ไม่ใช่ Token
🔗 อ้างอิงความปลอดภัยการจัดเก็บข้อมูล: 📖 Android Keystore System — developer.android.com 📖 Apple Keychain Services — developer.apple.com 📖 OWASP Mobile Top 10 — owasp.org
Slide 15 • Implementing Secure Storage

ขั้นตอนปฏิบัติ: เขียน Secure Storage จัดเก็บ JWT 🧪

ติดตั้งไลบรารีและสร้างคลาส Service เป็นผู้ทำหน้าที่หลักในการจัดเก็บ อ่านค่า และทำลายความลับโทเค็นผ่านหน่วยความจำมั่นคงของโทรศัพท์พกพาโดยอัตโนมัติ:

# 1. รันคำสั่งเพิ่มคลังความมั่นคงเข้าระบบโปรเจกต์ Flutter:

$ flutter pub add flutter_secure_storage

# 2. (Android เท่านั้น) ตั้งค่า minSdkVersion ≥ 18

# android/app/build.gradle → minSdkVersion 18

# 3. (macOS) เปิด Keychain Sharing ใน Xcode ตาม README ของแพ็กเกจ

// lib/data/services/secure_storage_service.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {

final _storage = const FlutterSecureStorage();

// บันทึกและเข้ารหัส Token ลงเครื่องอย่างถาวร

Future<void> write(String key, String value) async => await _storage.write(key: key, value: value);

// ดึงคีย์ลับเพื่อเอาออกมาใช้อ่านยืนยันระบบ

Future<String?> read(String key) async => await _storage.read(key: key);

// ทำลายข้อมูลในระบบเพื่อใช้ลบตอนล็อกเอาต์ออกจากหน้าจอ

Future<void> delete(String key) async => await _storage.delete(key: key);

// เคลียร์ Token ทั้งหมดเมื่อล็อกเอาต์

Future<void> clearAll() async => await _storage.deleteAll();

}

📌 ตัวอย่างการใช้งานจริงใน Auth Flow:

await storage.write('access_token', accessToken);
await storage.write('refresh_token', refreshToken);
await storage.write('id_token', idToken);
// เมื่อล็อกเอาต์: await storage.clearAll();

🔗 ดูโครงร่างคลาสฉบับทางการและคู่มือการติดตั้งแพลตฟอร์ม: 📦 flutter_secure_storage — pub.dev 💻 flutter_secure_storage GitHub (Platform Setup Guide) 📦 openid_client (OIDC Flow) — pub.dev
Slide 16 • Network Interceptors Concept

แนบ Token อัตโนมัติด้วยระบบ dio Interceptors 📡

เพื่อไม่ให้นักศึกษาต้องพิมพ์แนบ Access Token ลงในส่วนหัวของคำขอ API ทุกๆ เส้นทางด้วยตนเอง เราจะนำกลไก `Interceptors` ของแพ็กเกจ `dio` มาตั้งดักจับเพื่อสแกนและแนบข้อมูลสิทธิ์เข้าถึงลงไปให้โดยอัตโนมัติก่อนส่งข้อความออกอินเทอร์เน็ต:

// lib/data/services/api_interceptors.dart

class AuthInterceptor extends Interceptor {

final SecureStorageService _storage;

AuthInterceptor(this._storage);

@override

void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {

final accessToken = await _storage.read('access_token');

if (accessToken != null) {

// ดักจับคำขอขาออก และสลัก Bearer Token ลงใน Header ทันที

options.headers['Authorization'] = 'Bearer $accessToken';

}

return handler.next(options); // ปล่อยขบวนคำขอให้เดินทางต่อ

}

}

🔗 วิธีผูก Interceptor เข้ากับ Dio Instance:

dio.interceptors.add(AuthInterceptor(storage)); // ตอนสร้าง Dio ใน dio_client.dart

🎯 วงจรการทำงานของ Interceptor (Lifecycle Hooks):

onRequest → ก่อนส่งคำขอ (แนบ Token, Log)
onResponse → หลังได้รับคำตอบ (แปลงข้อมูล, Cache)
onError → เมื่อเกิดข้อผิดพลาด (Retry, Refresh Token)

🔗 เจาะลึกการดักจับ Interceptors ของแพ็กเกจ dio: 📦 dio Package (Interceptors Section) — pub.dev 📖 Flutter Design Patterns — docs.flutter.dev
Slide 17 • Advanced Auto-Refresh Interception

ปฏิบัติการขั้นโปร: กู้สิทธิ์อัตโนมัติหลังตรวจรับรหัส HTTP 401 ⚙️⚡

เมื่อ Access Token หมดอายุ เซิร์ฟเวอร์ Django หลังบ้านจะปฏิเสธคำขอและตอบกลับรหัส **`HTTP 401 Unauthorized`** ระบบ Interceptor ขั้นสูงของเราจะทำการดักจับข้อผิดพลาดนั้น สั่งหยุดการรันไว้ชั่วคราว แล้วยิงขอ Token คู่ใหม่โดยใช้ Refresh Token มาสับเปลี่ยนทันทีเพื่อประสบการณ์การทำงานที่ไม่มีวันสะดุด:

// ตรวจดักจับข้อผิดพลาดระหว่างทาง (401 Interception)

@override

void onError(DioException err, ErrorInterceptorHandler handler) async {

if (err.response?.statusCode == 401) {

final refreshToken = await _storage.read('refresh_token');

if (refreshToken != null) {

// ยิงไปที่ OIDC Token Endpoint ของ django-oidc-provider (สัปดาห์ที่ 12)

final refreshResponse = await _refreshDio.post('http://localhost:8000/token/', data: {'grant_type': 'refresh_token', 'refresh_token': refreshToken, 'client_id': 'flutter-web-app'});

final newAccess = refreshResponse.data['access_token'];

await _storage.write('access_token', newAccess);

// แนบตัวเข้าถึงตัวใหม่ และสั่งยิงคำขอดั้งเดิมออกไปซ่อมรอบสองทันที

err.requestOptions.headers['Authorization'] = 'Bearer $newAccess';

final retryResponse = await _refreshDio.fetch(err.requestOptions);

return handler.resolve(retryResponse); // สรุปสมานรอยต่อให้สำเร็จหมดจด

}

}

return handler.next(err);

}

🔁 อัปเดตจากสัปดาห์ที่ 12: endpoint กู้คืนสิทธิ์คือ OIDC Token Endpoint POST /token/ ของ django-oidc-provider (grant_type=refresh_token) — ไม่ใช่ /api/token/refresh/ แบบ Simple JWT อีกต่อไป

⚙️ เงื่อนไขที่ต้องมีก่อน Auto-Refresh ทำงาน:

  1. ต้องมี refresh_token เก็บไว้ใน Secure Storage
  2. ต้องรู้ client_id ของแอปที่ลงทะเบียนกับ OIDC Server
  3. _refreshDio ต้องเป็น Dio Instance ที่ไม่มี AuthInterceptor กัน loop วนซ้ำ
  4. POST ไปที่ Token Endpoint แล้วบันทึก access_token ใหม่กลับ
🔗 เจาะลึกวงจรการกู้สิทธิ์ Token และอ้างอิง OIDC Flow: 💻 Compass App — github.com/flutter/samples 📖 OIDC Core Spec — openid.net 📖 django-oidc-provider Docs — readthedocs.io 📖 HTTP 401 Unauthorized — MDN
Slide 18 • Week 3 Summary & Preview

สรุปบทเรียนและภารกิจการเตรียมพร้อมสู่สัปดาห์ที่ 4 🏁

ในวันนี้เราได้สร้าง **Repositories & Services** ที่เป็นหัวใจของการเชื่อมต่อข้อมูล และได้เขียนโปรแกรมวางความมั่นคงปลอดภัยบนอุปกรณ์พกพาในการปกป้องและกู้คืน Access/Refresh Tokens ได้เสร็จสมบูรณ์เรียบร้อยแล้ว

🔥 ภารกิจจัดเตรียมความพร้อมก่อนเข้าสู่สัปดาห์ที่ 4:

  • ทบทวนขั้นตอนและวิธีการตรวจสอบรูปแบบข้อมูลเบื้องต้นของคลาส Form, TextFormField และ GlobalKey
  • ทำความคุ้นเคยกับคำสั่งในการดึงข้อมูลส่งค่าอัปเดต และแสดงข้อความตอบรับผ่าน Snackbars
  • รันระบบ API บนคอมพิวเตอร์และเชื่อมมือถือส่วนตัวเพื่อจำลองสถานการณ์ความมั่นคงในห้องปฏิบัติการจริงนอกเวลา
🔗 คู่มือและข้อมูลสนับสนุนการจัดทำสถาปัตยกรรมระดับโปร (Flutter App Architecture): 📖 App Architecture Guide — docs.flutter.dev 📖 Flutter Testing — docs.flutter.dev 📖 Result Pattern Recap — docs.flutter.dev