ตามคู่มือมาตรฐานสถาปัตยกรรมแอปพลิเคชัน Flutter ชั้นข้อมูลหรือ Data Layer จะทำหน้าที่ห่อหุ้มตรรกะการจัดเตรียม เข้าถึง และอัปเดตข้อมูลของแอปพลิเคชัน โดยแยกออกเป็น 2 คอมโพเนนต์หลักที่มีหน้าที่ต่างกันชัดเจน [1, 2]:
ทำหน้าที่ติดต่อสื่อสารรับส่งข้อมูลดิบกับแหล่งข้อมูลภายนอกแบบไร้สถานะ (Stateless) เช่น การทำ HTTP Requests คุยกับ Django REST API Backend หรือการดึงข้อมูลจาก Local Database [2, 3]
ทำหน้าที่ประสานงานระหว่าง Services ดึงข้อมูลดิบมาล้าง ขัดเกลาแปลงเป็น Domain Models รวมถึงควบคุมลอจิกธุรกิจ เช่น การทำ Caching หรือการกักเก็บความจริงหนึ่งเดียว (SSOT) ของข้อมูล [1-4]
สำหรับการพัฒนาแอปพลิเคชัน Compass เราเลือกใช้แพ็กเกจ `dio` ซึ่งเป็นคลังไลบรารีเครือข่ายยอดนิยมที่มีลูกเล่นขั้นสูง เช่น การตั้งค่า Base Options, การแนบไฟล์ และการทำ Interceptors ดักจับข้อมูลอัตโนมัติ [5]:
# เพิ่มไลบรารี dio เข้าสู่โปรเจกต์ Flutter:
$ flutter pub add dio
แนะนำให้จัดสร้างอินสแตนซ์ของ Dio เป็นแบบ Singleton เพื่อความสะดวกคงที่ในการจัดการชุดคอนฟิกูเรชันหลัก เช่น พอร์ตเส้นทางฐาน (Base URL) และจำกัดระยะเวลาดึงข้อมูล (Timeout Limits) ร่วมกันทั่วแอปพลิเคชัน [6]:
ในการรันจำลองระบบแอปพลิเคชัน Compass เพื่อดึงข้อมูลจริงจากเซิร์ฟเวอร์หลังบ้าน Django REST Framework นักศึกษาต้องคำนึงถึงเป้าหมายการรันและสิทธิ์ความปลอดภัยให้ถูกต้อง:
ระบบจำลองของ Android (Android Emulator) มักจะใช้งานระบบพอร์ตเซิร์ฟเวอร์ฐานผ่านทางไอพีวงในจำเพาะคือ http://10.0.2.2:8000 แทนการชี้เป้าหา localhost ตรงๆ เพื่อเลี่ยงข้อจำกัดวงลูปเน็ตเวิร์กของเครื่องจำลอง.
เมื่อรันแอปบน 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
เราจะสร้างคลาสวิศวกรรมข้อมูลจำเพาะชื่อ 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) async {
return _dio.get<T>(path);
}
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);
}
}
แอปพลิเคชัน Compass ออกแบบตามคู่มือวิศวกรรมข้อมูลแนะนำให้ใช้งานคลาสผู้รับผิดชอบพิเศษคือ `Result` ในการเป็นกล่องห่อหุ้มค่าตอบรับแบบอะซิงโครนัส เพื่อลดโอกาสเกิดข้อผิดพลาดรุนแรงเมื่ออินเทอร์เน็ตหลุด [9]:
พฤติกรรมการควบคุมลักษณะนี้ช่วยให้สามารถเขียนประโยคควบคุมสวิตช์เงื่อนไข (Switch Case Statement) ในการตรวจดูสถานะสิทธิ์ได้อย่างรวดเร็วและปลอดภัยแบบไร้ช่องโหว่ [9].
เขียนคำสั่งและดักจับความคืบหน้าในการดึงข้อมูลทริปเดินทางจำลองจากเซิร์ฟเวอร์ โดยส่งพารามิเตอร์จำกัดข้อมูล และตรวจสอบสิทธิ์พิจารณาการตอบสนอง:
// 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>"
Result.ok ทันที [10].
การบันทึกทริปใหม่หรือลบทริปท่องเที่ยวสำเร็จต้องการการเรียกใช้คำสั่งระบุสิทธิ์จำเพาะผ่าน 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);
}
}
# 🧪 ทดสอบคำสั่งลบด้วย HTTPie (ต้องได้กลับมา 200 หรือ 204 No Content)
$ http DELETE :8000/api/bookings/1/ "Authorization: Bearer <access_token>"
ยินดีด้วยครับ! ตอนนี้นักศึกษาเข้าใจแก่นแท้ของ **Service Layer** สามารถใช้งานแพ็กเกจ `dio` ในการทำแทร็กคิวเรชันดึงผลลัพธ์เครือข่ายจำลอง และรับมือข้อผิดพลาดอัตราส่วนข้อมูลผ่านกล่อง `Result` ได้อย่างปลอดภัยแล้ว [9]
สถาปัตยกรรมระดับ Production ต้องการความยืดหยุ่นในการสลับเปลี่ยนสภาพแวดล้อมทำโครงงาน (Environments) เราจึงเริ่มจากการออกแบบสัญญากลางหรืออินเทอร์เฟซฐานด้วยคำสั่ง **`abstract class`** เพื่อกำหนดพฤติกรรมการทำงานของข้อมูลที่ทุกสภาพแวดล้อมต้องเขียนรองรับเหมือนกัน:
// lib/data/repositories/booking.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<void>> delete(int id);
}
ช่วยให้เราเขียนแอปแยกเลเยอร์ได้ง่าย โดยเลเยอร์ UI ด้านบนไม่จำเป็นต้องรู้ว่าข้อมูลมาจากไหน รู้เพียงสัญญาการมีอยู่ของคำสั่งเรียกใช้งานเท่านั้น และทำให้สลับไปเทสด้วย Fake คลาสจำลองได้สะดวกรวดเร็ว
เราจะสร้างคลาสลูกที่สืบทอดสัญญาจากคลาสหลัก ได้แก่ **`BookingRepositoryLocal`** (เพื่อดึงข้อมูลออฟไลน์จำลองจากไฟล์ดิบในเครื่องตอนเขียนโปรแกรม) และ **`BookingRepositoryRemote`** (ส่งมอบคำสั่งดึงข้อมูลเครือข่ายจริงข้ามพอร์ตเซิร์ฟเวอร์):
// lib/data/repositories/booking_repository_remote.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);
}
เพื่อรักษาเสถียรภาพของ UI หน้าจอไม่ให้ปั่นป่วนเมื่อสคีมาของหลังบ้าน (Backend Database Schema) ปรับเปลี่ยนโครงสร้าง เราจะใช้แนวทาง **`Data Mapping`** สกัดกั้นโมเดลข้อมูลดิบจากเซิร์ฟเวอร์ แปลงมาเป็น Domain Model ที่ปลอดภัยและเป็นมิตรฝั่งหน้าแอปพลิเคชัน:
// คลาสโครงสร้างข้อมูลที่มีคุณสมบัติคงที่ (Immutable Domain Model)
class BookingSummary {
final int id;
final String destinationName;
final double price;
BookingSummary({required this.id, required this.destinationName, required this.price});
// ตัวถอดค่าจาก JSON Map สรุปแปลงผลอย่างแม่นยำ
factory BookingSummary.fromJson(Map<String, dynamic> json) =>
BookingSummary(id: json['id'], destinationName: json['destination_name'], price: json['price'].toDouble());
}
ในสถาปัตยกรรมแบบแยกเลเยอร์ของแอป Compass **`ApiClient`** หรือ Service ติดต่อเครือข่ายจะต้องถูกกำหนดให้เป็นตัวแปรประเภท **`private`** เสมอ เพื่อห้ามไม่ให้ส่วนแสดงผล (UI Layer) ข้ามระดับลงมาเรียกใช้งาน Service โดยตรงเด็ดขาด:
การจัดเก็บ Token ยืนยันสิทธิ์ลงในเครื่องพกพา หากเก็บแบบลักไก่ผ่านระบบธรรมดา เช่น `SharedPreferences` (บน Android) หรือคุกกี้ทั่วไป จะเสี่ยงต่อการโดนฉกชิงถอนรหัสลับความปลอดภัยได้โดยง่ายจากการเจาะช่องโหว่ (เช่น Rooting หรือ XSS):
หน่วยความจำปกติไม่เคยทำการเข้ารหัสข้อมูลดิบ ทำให้ผู้มีสิทธิ์ผู้บริหารเครื่อง (Root/Jailbreak) หรือโปรแกรมประสงค์ร้ายแอบดึงไฟล์ข้อมูลออกไปสแกนและเข้าสิทธิ์ทำงานแทนผู้ใช้ได้ทันที
ระบบความปลอดภัยเชิงฮาร์ดแวร์ ได้แก่ Keychain (สำหรับ iOS) และ Keystore (สำหรับ Android) จะทำการกักเก็บข้อมูลความลับของ Token ทุกสายไว้หลังกำแพงการเข้ารหัสฮาร์ดแวร์โดยสมบูรณ์
ติดตั้งไลบรารีและสร้างคลาส Service เป็นผู้ทำหน้าที่หลักในการจัดเก็บ อ่านค่า และทำลายความลับโทเค็นผ่านหน่วยความจำมั่นคงของโทรศัพท์พกพาโดยอัตโนมัติ:
# 1. รันคำสั่งเพิ่มคลังความมั่นคงเข้าระบบโปรเจกต์ Flutter:
$ flutter pub add flutter_secure_storage
// 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);
}
เพื่อไม่ให้นักศึกษาต้องพิมพ์แนบ 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); // ปล่อยขบวนคำขอให้เดินทางต่อ
}
}
เมื่อ 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);
}
POST /token/ ของ django-oidc-provider (grant_type=refresh_token) — ไม่ใช่ /api/token/refresh/ แบบ Simple JWT อีกต่อไป
ในวันนี้เราได้สร้าง **Repositories & Services** ที่เป็นหัวใจของการเชื่อมต่อข้อมูล และได้เขียนโปรแกรมวางความมั่นคงปลอดภัยบนอุปกรณ์พกพาในการปกป้องและกู้คืน Access/Refresh Tokens ได้เสร็จสมบูรณ์เรียบร้อยแล้ว