ตามคู่มือมาตรฐานสถาปัตยกรรมแอปพลิเคชัน 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]:
# ขั้นตอนที่ 1: เพิ่มไลบรารี dio เข้าสู่โปรเจกต์ Flutter:
$ flutter pub add dio
# ขั้นตอนที่ 2: ตรวจสอบเวอร์ชันที่ติดตั้งใน pubspec.yaml
$ cat pubspec.yaml | grep dio
แนะนำให้จัดสร้างอินสแตนซ์ของ 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
ในการรันจำลองระบบแอปพลิเคชัน 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
# 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
เราจะสร้างคลาสวิศวกรรมข้อมูลจำเพาะชื่อ 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);
}
}
<T> เพื่อรองรับทุกชนิดข้อมูลที่แปลงกลับแอปพลิเคชัน Compass ออกแบบตามคู่มือวิศวกรรมข้อมูลแนะนำให้ใช้งานคลาสผู้รับผิดชอบพิเศษคือ `Result` ในการเป็นกล่องห่อหุ้มค่าตอบรับแบบอะซิงโครนัส เพื่อลดโอกาสเกิดข้อผิดพลาดรุนแรงเมื่ออินเทอร์เน็ตหลุด [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);
}
}
switch (result) {
case Result.ok(): // แสดงข้อมูล
case Result.error(): // แสดงข้อผิดพลาด
}
เขียนคำสั่งและดักจับความคืบหน้าในการดึงข้อมูลทริปเดินทางจำลองจากเซิร์ฟเวอร์ โดยส่งพารามิเตอร์จำกัดข้อมูล และตรวจสอบสิทธิ์พิจารณาการตอบสนอง:
// 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>"
BookingSummary พร้อม factory fromJson()fetchBookings() จาก ApiClientstatusCode == 200 ว่าได้รับการตอบสนองสำเร็จ.map().toList() เข้าสู่ List ของ Domain ModelResult.ok() เพื่อส่งต่อให้ RepositoryResult.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);
}
}
// 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>"
200 OK — สำเร็จ • 201 Created — สร้างข้อมูลสำเร็จ • 204 No Content — สำเร็จแต่ไม่มีข้อมูล • 401 Unauthorized — Token หมดอายุ • 404 Not Found — ไม่พบ endpoint
ยินดีด้วยครับ! ตอนนี้นักศึกษาเข้าใจแก่นแท้ของ **Service Layer** สามารถใช้งานแพ็กเกจ `dio` ในการทำแทร็กคิวเรชันดึงผลลัพธ์เครือข่ายจำลอง และรับมือข้อผิดพลาดอัตราส่วนข้อมูลผ่านกล่อง `Result` ได้อย่างปลอดภัยแล้ว [9]
สถาปัตยกรรมระดับ 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);
}
เราจะสร้างคลาสลูกที่สืบทอดสัญญาจากคลาสหลัก ได้แก่ **`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);
}
BookingRepositoryRemote — ดึงข้อมูลจริงจาก Django Backend ผ่าน ApiClient
BookingRepositoryLocal — ใช้ข้อมูลจำลอง (Mock Data) จากไฟล์/หน่วยความจำ ใช้ตอนพัฒนา UI ที่ยังไม่มี Backend หรือเขียน Unit Test โดยใช้ FakeBookingRepository
เพื่อรักษาเสถียรภาพของ UI หน้าจอไม่ให้ปั่นป่วนเมื่อสคีมาของหลังบ้าน (Backend Database Schema) ปรับเปลี่ยนโครงสร้าง เราจะใช้แนวทาง **`Data Mapping`** สกัดกั้นโมเดลข้อมูลดิบจากเซิร์ฟเวอร์ แปลงมาเป็น Domain Model ที่ปลอดภัยและเป็นมิตรฝั่งหน้าแอปพลิเคชัน:
// 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?);
}
fullName, formattedPriceในสถาปัตยกรรมแบบแยกเลเยอร์ของแอป Compass **`ApiClient`** หรือ Service ติดต่อเครือข่ายจะต้องถูกกำหนดให้เป็นตัวแปรประเภท **`private`** เสมอ เพื่อห้ามไม่ให้ส่วนแสดงผล (UI Layer) ข้ามระดับลงมาเรียกใช้งาน Service โดยตรงเด็ดขาด:
View ↔ ViewModel ↔ Repository ↔ Service (ApiClient)
ทิศทางการไหลของข้อมูล: UI ส่งคำสั่ง → ViewModel เรียก Repository → Repository ใช้ Service ดึงข้อมูลดิบ → แปลงเป็น Domain Model → ส่งกลับผ่าน Result
การจัดเก็บ Token ยืนยันสิทธิ์ลงในเครื่องพกพา หากเก็บแบบลักไก่ผ่านระบบธรรมดา เช่น `SharedPreferences` (บน Android) หรือคุกกี้ทั่วไป จะเสี่ยงต่อการโดนฉกชิงถอนรหัสลับความปลอดภัยได้โดยง่ายจากการเจาะช่องโหว่ (เช่น Rooting หรือ XSS):
หน่วยความจำปกติไม่เคยทำการเข้ารหัสข้อมูลดิบ ทำให้ผู้มีสิทธิ์ผู้บริหารเครื่อง (Root/Jailbreak) หรือโปรแกรมประสงค์ร้ายแอบดึงไฟล์ข้อมูลออกไปสแกนและเข้าสิทธิ์ทำงานแทนผู้ใช้ได้ทันที
ระบบความปลอดภัยเชิงฮาร์ดแวร์ ได้แก่ Keychain (สำหรับ iOS) และ Keystore (สำหรับ Android) จะทำการกักเก็บข้อมูลความลับของ Token ทุกสายไว้หลังกำแพงการเข้ารหัสฮาร์ดแวร์โดยสมบูรณ์
| วิธี | ความปลอดภัย | ข้อควรระวัง |
|---|---|---|
| SharedPreferences | ต่ำ ❌ | ไม่ได้เข้ารหัส เก็บเป็น Text เปล่า |
| flutter_secure_storage | สูง ✅ | ใช้ Keychain (iOS) / Keystore (Android) |
| Hive / Isar | กลาง ⚠️ | เหมาะกับข้อมูลทั่วไป ไม่ใช่ Token |
ติดตั้งไลบรารีและสร้างคลาส 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();
}
await storage.write('access_token', accessToken);
await storage.write('refresh_token', refreshToken);
await storage.write('id_token', idToken);
// เมื่อล็อกเอาต์: await storage.clearAll();
เพื่อไม่ให้นักศึกษาต้องพิมพ์แนบ 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); // ปล่อยขบวนคำขอให้เดินทางต่อ
}
}
dio.interceptors.add(AuthInterceptor(storage)); // ตอนสร้าง Dio ใน dio_client.dart
onRequest → ก่อนส่งคำขอ (แนบ Token, Log)
onResponse → หลังได้รับคำตอบ (แปลงข้อมูล, Cache)
onError → เมื่อเกิดข้อผิดพลาด (Retry, Refresh Token)
เมื่อ 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 อีกต่อไป
refresh_token เก็บไว้ใน Secure Storageclient_id ของแอปที่ลงทะเบียนกับ OIDC Server_refreshDio ต้องเป็น Dio Instance ที่ไม่มี AuthInterceptor กัน loop วนซ้ำaccess_token ใหม่กลับในวันนี้เราได้สร้าง **Repositories & Services** ที่เป็นหัวใจของการเชื่อมต่อข้อมูล และได้เขียนโปรแกรมวางความมั่นคงปลอดภัยบนอุปกรณ์พกพาในการปกป้องและกู้คืน Access/Refresh Tokens ได้เสร็จสมบูรณ์เรียบร้อยแล้ว