REST API & HTTP trong Flutter
1. REST API và HTTP
1.1. Khái niệm REST API

REST API là một phương thức thiết kế giao tiếp giữa các hệ thống phần mềm thông qua giao thức HTTP. REST là viết tắt của Representational State Transfer, còn API là viết tắt của Application Programming Interface. REST API cho phép các ứng dụng trao đổi dữ liệu với nhau thông qua những quy tắc thống nhất, giúp ứng dụng phía Client có thể truy xuất, tạo mới, cập nhật hoặc xóa dữ liệu được quản lý bởi Server.
Trong phát triển ứng dụng Flutter, REST API thường được sử dụng để kết nối ứng dụng di động với Backend. Thay vì lưu trữ và xử lý toàn bộ dữ liệu ngay trên thiết bị, ứng dụng có thể gửi yêu cầu đến Server để lấy danh sách sản phẩm, thông tin tài khoản, lịch sử đơn hàng, dữ liệu bài viết hoặc các thông tin khác. Server tiếp nhận yêu cầu, thực hiện xử lý nghiệp vụ, truy vấn cơ sở dữ liệu và trả kết quả về cho ứng dụng.
Ví dụ, trong một ứng dụng thương mại điện tử, khi người dùng mở trang danh sách sản phẩm, Flutter gửi yêu cầu đến REST API. Backend nhận yêu cầu, truy vấn cơ sở dữ liệu và trả về danh sách sản phẩm dưới dạng JSON. Flutter chuyển đổi dữ liệu JSON thành các đối tượng Dart, sau đó sử dụng những đối tượng này để hiển thị giao diện.
REST API không phải là một ngôn ngữ lập trình, một thư viện Flutter hay một giao thức truyền tải riêng biệt. Đây là cách tổ chức API dựa trên các nguyên tắc kiến trúc REST, thường được triển khai bằng HTTP và sử dụng JSON làm định dạng dữ liệu trao đổi.
1.2. Khái niệm HTTP
HTTP là viết tắt của Hypertext Transfer Protocol, một giao thức được sử dụng để trao đổi thông tin giữa Client và Server. HTTP xác định cách một bên gửi yêu cầu, cách bên còn lại phản hồi và cách biểu diễn những thông tin cần thiết trong quá trình giao tiếp.
Trong ứng dụng Flutter, Client thường là ứng dụng đang chạy trên điện thoại hoặc trình giả lập. Server là hệ thống Backend cung cấp API. Hai bên giao tiếp thông qua các yêu cầu HTTP, trong đó mỗi yêu cầu có thể chứa phương thức HTTP, địa chỉ URL, Header và phần dữ liệu gửi đi.
Một yêu cầu HTTP cơ bản có thể được biểu diễn như sau:
GET /api/products HTTP/1.1
Host: example.com
Accept: application/json
Trong ví dụ này:
-
GETlà phương thức yêu cầu lấy dữ liệu. -
/api/productslà đường dẫn đến tài nguyên sản phẩm. -
HTTP/1.1xác định phiên bản giao thức HTTP. -
Hostxác định máy chủ nhận yêu cầu. -
Acceptcho biết Client mong muốn nhận dữ liệu JSON.
Sau khi xử lý yêu cầu, Server có thể trả về phản hồi như sau:
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"id": 1,
"name": "Áo thun",
"price": 199000
},
{
"id": 2,
"name": "Quần jeans",
"price": 399000
}
]
Phản hồi gồm mã trạng thái HTTP, các Header và phần Body chứa dữ liệu. Flutter đọc nội dung phản hồi, kiểm tra kết quả và chuyển dữ liệu thành cấu trúc mà ứng dụng có thể sử dụng.
HTTP hoạt động theo mô hình Request–Response. Client gửi yêu cầu và chờ Server phản hồi. Tuy nhiên, điều này không có nghĩa mọi yêu cầu đều thành công hoặc luôn nhận được dữ liệu ngay lập tức. Server có thể trả về lỗi, yêu cầu xác thực, thông báo không tìm thấy tài nguyên hoặc không phản hồi do sự cố mạng.
1.3. Mối quan hệ giữa REST API và HTTP
REST API và HTTP có mối quan hệ chặt chẽ nhưng không đồng nghĩa với nhau.
HTTP là giao thức truyền thông, quy định cách các hệ thống gửi và nhận thông tin. REST là phong cách kiến trúc quy định cách tổ chức API, tài nguyên và các thao tác trên tài nguyên. REST API thường sử dụng HTTP để truyền tải yêu cầu và phản hồi.
Ví dụ, một Backend có thể cung cấp những Endpoint sau:
GET /api/products
GET /api/products/10
POST /api/products
PUT /api/products/10
PATCH /api/products/10
DELETE /api/products/10
Các Endpoint này sử dụng những phương thức HTTP khác nhau để thực hiện thao tác trên tài nguyên sản phẩm.
Nếu chỉ sử dụng HTTP để gửi một yêu cầu đến Server, ứng dụng chưa chắc đang giao tiếp với một REST API. HTTP còn được dùng để tải trang web, gửi biểu mẫu, tải hình ảnh và giao tiếp với nhiều loại dịch vụ khác.
Ngược lại, REST API không bắt buộc phải sử dụng JSON, dù JSON là định dạng phổ biến trong ứng dụng hiện đại. API có thể trao đổi dữ liệu bằng những định dạng khác, tùy theo yêu cầu hệ thống.
Trong Flutter, việc hiểu rõ hai khái niệm này giúp lập trình viên phân biệt được trách nhiệm của từng thành phần. Thư viện HTTP hoặc Dio đảm nhiệm việc gửi yêu cầu và nhận phản hồi; còn cấu trúc Endpoint, phương thức, dữ liệu và quy tắc xử lý phụ thuộc vào thiết kế API của Backend.
2. Kiến trúc Client–Server trong ứng dụng Flutter
2.1. Thành phần Client
Client là thành phần khởi tạo yêu cầu đến Server. Trong ứng dụng Flutter, Client bao gồm giao diện người dùng, các thành phần quản lý trạng thái và những lớp chịu trách nhiệm giao tiếp với API.
Ví dụ, khi người dùng nhấn nút đăng nhập, giao diện thu thập email và mật khẩu. Sau đó, ứng dụng gửi thông tin đến API xác thực. Khi nhận được phản hồi, ứng dụng kiểm tra kết quả và cập nhật giao diện tương ứng.
Client thường đảm nhiệm những công việc sau:
-
Thu thập dữ liệu từ người dùng.
-
Kiểm tra dữ liệu đầu vào trước khi gửi.
-
Tạo yêu cầu HTTP với URL, phương thức, Header và Body phù hợp.
-
Nhận và phân tích dữ liệu từ Server.
-
Chuyển đổi dữ liệu thành các đối tượng Dart.
-
Cập nhật trạng thái giao diện.
-
Hiển thị trạng thái tải dữ liệu, kết quả thành công hoặc thông báo lỗi.
-
Quản lý thông tin xác thực và xử lý những tình huống như hết hạn phiên đăng nhập.
Client không nên tự quyết định những nghiệp vụ quan trọng chỉ dựa trên dữ liệu hiển thị. Ví dụ, ứng dụng có thể kiểm tra sơ bộ số lượng sản phẩm trong giỏ hàng, nhưng Server vẫn phải xác minh giá bán, tồn kho và quyền thực hiện đơn hàng trước khi chấp nhận giao dịch.
2.2. Thành phần Server
Server là hệ thống tiếp nhận và xử lý yêu cầu từ Client. Server có thể được xây dựng bằng Node.js, NestJS, ASP.NET Core, Laravel, Spring Boot hoặc những công nghệ Backend khác.
Một hệ thống Backend thường bao gồm các thành phần:
-
Routing: Xác định Endpoint tương ứng với yêu cầu.
-
Controller: Tiếp nhận thông tin yêu cầu và điều phối quá trình xử lý.
-
Middleware: Xử lý các công việc dùng chung như xác thực, ghi log hoặc kiểm tra yêu cầu.
-
Service: Thực hiện nghiệp vụ của hệ thống.
-
Repository hoặc Data Access Layer: Tổ chức việc truy cập dữ liệu.
-
Database: Lưu trữ tài khoản, sản phẩm, đơn hàng và các dữ liệu khác.
Ví dụ, khi Flutter gửi yêu cầu lấy chi tiết sản phẩm, Server tiếp nhận yêu cầu, xác định mã sản phẩm, truy vấn cơ sở dữ liệu, xử lý kết quả và trả về phản hồi JSON.
Việc tách các thành phần giúp Backend dễ bảo trì, kiểm thử và mở rộng. Flutter chỉ cần tuân theo hợp đồng API đã được thống nhất mà không cần biết toàn bộ cách Server truy vấn cơ sở dữ liệu.
2.3. Luồng giao tiếp tổng quát
Quá trình giao tiếp giữa Flutter và Backend có thể được mô tả theo trình tự sau:
-
Người dùng thực hiện thao tác trên giao diện Flutter.
-
Widget gọi hàm xử lý hoặc thông báo sự kiện cho lớp quản lý trạng thái.
-
Lớp quản lý trạng thái gọi Repository hoặc Service để lấy dữ liệu.
-
Lớp giao tiếp API tạo yêu cầu HTTP.
-
Yêu cầu được truyền qua mạng đến Backend.
-
Backend kiểm tra yêu cầu, xác thực quyền truy cập và xử lý nghiệp vụ.
-
Backend truy vấn hoặc cập nhật cơ sở dữ liệu khi cần thiết.
-
Backend tạo phản hồi HTTP chứa mã trạng thái và dữ liệu.
-
Flutter nhận phản hồi, kiểm tra lỗi và phân tích dữ liệu.
-
Repository hoặc Service trả kết quả cho lớp quản lý trạng thái.
-
Giao diện được cập nhật để hiển thị dữ liệu mới.
Ví dụ, khi người dùng mở màn hình sản phẩm, giao diện có thể hiển thị trạng thái đang tải. Trong thời gian đó, ứng dụng gửi yêu cầu đến Server. Khi dữ liệu được trả về thành công, trạng thái chuyển sang hoàn tất và danh sách sản phẩm xuất hiện. Nếu xảy ra lỗi mạng, giao diện có thể hiển thị thông báo và nút thử lại.
Luồng này cho thấy giao tiếp API không chỉ là thao tác gửi một yêu cầu HTTP. Nó còn liên quan đến quản lý trạng thái bất đồng bộ, chuyển đổi dữ liệu, xử lý lỗi và cập nhật giao diện.
3. Nguyên tắc thiết kế REST API
3.1. Resource-oriented design
REST API tổ chức dữ liệu xoay quanh các tài nguyên, thường được biểu diễn bằng danh từ trong URL. Tài nguyên có thể là người dùng, sản phẩm, đơn hàng, bài viết hoặc danh mục.
Ví dụ:
/api/users
/api/products
/api/categories
/api/orders
Để truy xuất một tài nguyên cụ thể, API thường bổ sung định danh vào đường dẫn:
/api/users/15
/api/products/10
/api/orders/1001
Trong đó, 15, 10 và 1001 là những định danh của tài nguyên tương ứng.
Cách thiết kế này giúp URL thể hiện rõ đối tượng đang được thao tác. Thay vì tạo những đường dẫn như /getProducts, /createProduct hoặc /deleteProduct, API thường sử dụng /products kết hợp với phương thức HTTP phù hợp.
Việc đặt tên Endpoint thống nhất giúp Frontend dễ đọc, dễ ghi nhớ và thuận tiện hơn khi mở rộng API.
3.2. Sử dụng đúng phương thức HTTP
Mỗi phương thức HTTP thể hiện ý nghĩa khác nhau trong quá trình thao tác với tài nguyên.
GET được sử dụng để lấy dữ liệu. Ví dụ, GET /api/products trả về danh sách sản phẩm, còn GET /api/products/10 lấy chi tiết sản phẩm có mã 10. Theo quy ước HTTP, GET là phương thức an toàn, nghĩa là yêu cầu không được thiết kế để thực hiện thay đổi trạng thái nghiệp vụ trên Server.
POST thường được sử dụng để tạo tài nguyên mới hoặc thực hiện một thao tác mà Server cần xử lý theo quy tắc riêng. Ví dụ, POST /api/products tạo sản phẩm mới; POST /api/orders có thể tạo đơn hàng.
PUT thường được sử dụng để thay thế toàn bộ biểu diễn của tài nguyên tại một địa chỉ xác định. Ví dụ, PUT /api/products/10 gửi thông tin cập nhật đầy đủ cho sản phẩm có mã 10. Hành vi cụ thể vẫn phụ thuộc vào hợp đồng API của Backend.
PATCH được sử dụng để cập nhật một phần tài nguyên. Ví dụ, nếu chỉ muốn thay đổi giá sản phẩm, Client có thể gửi một yêu cầu PATCH chứa trường price thay vì gửi lại toàn bộ thông tin sản phẩm.
DELETE được sử dụng để yêu cầu xóa một tài nguyên. Ví dụ, DELETE /api/products/10 yêu cầu xóa sản phẩm có mã 10.
Ví dụ về các yêu cầu:
GET /api/products
POST /api/products
Content-Type: application/json
{
"name": "Áo khoác",
"price": 450000
}
PATCH /api/products/10
Content-Type: application/json
{
"price": 420000
}
DELETE /api/products/10
Việc sử dụng đúng phương thức giúp API thể hiện ý nghĩa rõ ràng, đồng thời hỗ trợ những cơ chế HTTP như caching, retry và kiểm soát tính lặp lại của yêu cầu.
Một điểm cần phân biệt là GET và PUT thường có tính idempotent, trong khi POST thường không có tính chất này. Idempotent có nghĩa là việc thực hiện nhiều lần cùng một yêu cầu có tác dụng tương đương với việc thực hiện một lần, xét trên trạng thái nghiệp vụ dự kiến. Điều này không đồng nghĩa phản hồi HTTP phải giống hệt nhau ở mọi lần thực hiện.
3.3. Tính Stateless
Stateless là một nguyên tắc quan trọng của REST, theo đó Server không dựa vào trạng thái phiên giao tiếp được lưu giữ giữa các yêu cầu để hiểu một yêu cầu tiếp theo. Mỗi yêu cầu phải chứa đủ thông tin cần thiết để Server xử lý yêu cầu đó.
Ví dụ, nếu API yêu cầu xác thực bằng Access Token, Flutter thường gửi Token trong Header của mỗi yêu cầu cần được bảo vệ:
GET /api/profile
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
Server sử dụng Token để xác định danh tính và quyền truy cập theo cơ chế xác thực của hệ thống.
Stateless không có nghĩa Server không được lưu dữ liệu. Server vẫn có thể lưu tài khoản, đơn hàng, dữ liệu nghiệp vụ và thông tin cần thiết trong cơ sở dữ liệu. Nguyên tắc này chủ yếu nói về cách Server xử lý trạng thái của từng yêu cầu giao tiếp.
Đối với Flutter, cách tiếp cận này yêu cầu ứng dụng quản lý thông tin xác thực phù hợp và gửi kèm thông tin cần thiết khi gọi API. Nó cũng giúp hệ thống Backend dễ mở rộng hơn vì các yêu cầu có thể được xử lý bởi nhiều máy chủ, miễn là chúng có chung cơ chế xác thực và dữ liệu cần thiết.
3.4. Client–Server separation
REST khuyến khích phân tách Client và Server thành các thành phần có trách nhiệm riêng biệt.
Flutter tập trung vào giao diện, trải nghiệm người dùng và cách dữ liệu được trình bày. Backend chịu trách nhiệm xử lý nghiệp vụ, kiểm soát quyền truy cập, xác minh dữ liệu và quản lý cơ sở dữ liệu.
Ví dụ, Flutter có thể hiển thị nút thêm sản phẩm vào giỏ hàng. Khi người dùng nhấn nút, ứng dụng gửi yêu cầu đến API. Backend kiểm tra sản phẩm có tồn tại hay không, giá bán hiện tại và các điều kiện liên quan trước khi cập nhật giỏ hàng.
Nếu nghiệp vụ thay đổi, Backend có thể cập nhật cách xử lý mà không nhất thiết phải thay đổi giao diện Flutter, miễn là hợp đồng API vẫn tương thích.
Sự phân tách này giúp nhiều Client khác nhau như ứng dụng Flutter, website và ứng dụng quản trị cùng sử dụng một Backend.
3.5. Uniform Interface
Uniform Interface là nguyên tắc xây dựng giao diện giao tiếp thống nhất giữa các thành phần của hệ thống REST. Nguyên tắc này thường được thể hiện qua việc xác định tài nguyên bằng URL, sử dụng các phương thức HTTP có ý nghĩa rõ ràng và biểu diễn dữ liệu theo định dạng thống nhất.
Ví dụ, những API trong cùng hệ thống có thể sử dụng JSON, tên trường nhất quán, cấu trúc phản hồi dễ dự đoán và cách biểu diễn lỗi tương tự nhau.
Một phản hồi thành công có thể có dạng:
{
"success": true,
"data": {
"id": 10,
"name": "Áo thun",
"price": 199000
}
}
Một phản hồi lỗi có thể có dạng:
{
"success": false,
"message": "Không tìm thấy sản phẩm",
"code": "PRODUCT_NOT_FOUND"
}
Đây chỉ là ví dụ về cấu trúc phản hồi do ứng dụng tự quy định, không phải định dạng bắt buộc của REST. Điều quan trọng là Frontend và Backend thống nhất cấu trúc để Flutter có thể xử lý dữ liệu một cách nhất quán.
3.6. Layered System
Layered System cho phép hệ thống REST được tổ chức qua nhiều lớp trung gian. Một yêu cầu có thể đi qua Load Balancer, API Gateway, Middleware, dịch vụ nghiệp vụ và hệ thống cơ sở dữ liệu trước khi tạo phản hồi.
Flutter thường không cần biết toàn bộ những lớp này. Ứng dụng chỉ cần gửi yêu cầu đến địa chỉ API được cung cấp.
Cách tổ chức theo nhiều lớp hỗ trợ phân chia trách nhiệm, tăng cường bảo mật, quản lý lưu lượng và mở rộng hệ thống. Tuy nhiên, nhiều lớp trung gian cũng có thể làm tăng độ trễ hoặc khiến việc tìm nguyên nhân lỗi phức tạp hơn nếu hệ thống không được giám sát tốt.
3.7. Cacheable
HTTP hỗ trợ caching, cho phép lưu lại một số phản hồi để sử dụng trong những lần truy cập tiếp theo khi các điều kiện cho phép. Cơ chế này có thể giảm số lượng yêu cầu đến Server và cải thiện tốc độ tải dữ liệu.
Ví dụ, dữ liệu danh mục sản phẩm ít thay đổi có thể phù hợp với caching hơn dữ liệu số dư tài khoản hoặc thông tin đơn hàng cần cập nhật liên tục.
Một số Header thường liên quan đến caching gồm:
Cache-Control: max-age=300
ETag: "products-version-12"
Cache-Control xác định các chỉ thị về lưu trữ và thời gian sử dụng phản hồi trong những điều kiện nhất định. ETag là mã định danh phiên bản của biểu diễn tài nguyên, có thể được sử dụng trong yêu cầu xác thực lại bộ nhớ đệm.
Trong Flutter, caching không tự động xuất hiện chỉ vì ứng dụng sử dụng REST API. Lập trình viên cần xác định dữ liệu nào có thể lưu, thời gian lưu, cách làm mới và cách xử lý khi dữ liệu trên Server thay đổi. Việc sử dụng thư viện lưu trữ cục bộ hoặc bộ nhớ đệm ở tầng ứng dụng có thể hỗ trợ cơ chế này.
4. URL, Endpoint, Query Parameters và Path Parameters
4.1. Cấu trúc URL
URL là địa chỉ xác định vị trí của tài nguyên hoặc điểm truy cập dịch vụ. Một URL API thường có cấu trúc như sau:
https://api.example.com/v1/products/10?category=clothing&page=2
Các thành phần chính gồm:
-
https: giao thức được sử dụng để giao tiếp. -
api.example.com: tên miền của máy chủ API. -
/v1: phiên bản API. -
/products/10: đường dẫn đến tài nguyên sản phẩm có mã 10. -
category=clothing: tham số truy vấn xác định danh mục. -
page=2: tham số truy vấn xác định trang dữ liệu.
HTTPS là phiên bản HTTP được bảo vệ bằng TLS. Trong ứng dụng thực tế, HTTPS nên được sử dụng để bảo vệ dữ liệu trên đường truyền, đặc biệt đối với thông tin đăng nhập và dữ liệu cá nhân.
4.2. Endpoint
Endpoint là điểm truy cập cụ thể của API, thường được xác định bởi đường dẫn và phương thức HTTP.
Ví dụ:
GET /api/products
GET /api/products/10
POST /api/products
Mặc dù ba yêu cầu trên có thể sử dụng các đường dẫn tương tự, chúng phục vụ những mục đích khác nhau.
Việc xác định rõ Endpoint giúp lập trình viên biết cần gửi yêu cầu đến đâu, sử dụng phương thức nào, dữ liệu đầu vào gồm những trường nào và kết quả trả về có cấu trúc ra sao.
Trong một dự án Flutter, các Endpoint thường được tập trung trong một lớp cấu hình hoặc lớp Service để tránh việc viết URL rải rác ở nhiều Widget.
4.3. Path Parameters
Path Parameters là các giá trị được đặt trực tiếp trong đường dẫn, thường dùng để xác định tài nguyên cụ thể.
Ví dụ:
GET /api/products/10
GET /api/users/25/orders
GET /api/categories/3/products
Trong đó, 10, 25 và 3 là những giá trị nằm trong đường dẫn.
Khi xây dựng ứng dụng Flutter, lập trình viên có thể truyền định danh vào hàm tương ứng:
Future<Product> getProductById(int id) async {
final response = await dio.get('/products/$id');
return Product.fromJson(
response.data as Map<String, dynamic>,
);
}
Hàm này nhận mã sản phẩm, tạo đường dẫn tương ứng, gửi yêu cầu và chuyển dữ liệu nhận được thành đối tượng Product.
Path Parameters phù hợp khi cần xác định một tài nguyên hoặc mối quan hệ cụ thể trong hệ thống.
4.4. Query Parameters
Query Parameters là các tham số được đặt sau dấu ? trong URL. Chúng thường được sử dụng để tìm kiếm, lọc, sắp xếp và phân trang dữ liệu.
Ví dụ:
GET /api/products?keyword=shirt
GET /api/products?category=clothing&sort=price
GET /api/products?page=2&limit=20
Các tham số này không nhất thiết xác định một tài nguyên riêng biệt. Chúng thường điều chỉnh tập dữ liệu được trả về.
Với Dio, Query Parameters có thể được truyền thông qua thuộc tính queryParameters:
final response = await dio.get(
'/products',
queryParameters: {
'keyword': 'shirt',
'page': 2,
'limit': 20,
},
);
Thư viện sẽ hỗ trợ mã hóa các tham số thành URL phù hợp. Cách này rõ ràng và an toàn hơn so với việc tự ghép chuỗi URL trong nhiều trường hợp, đặc biệt khi giá trị có dấu cách hoặc ký tự đặc biệt.
4.5. Phân biệt Path Parameters và Query Parameters
Path Parameters xác định tài nguyên mà Client muốn truy cập. Query Parameters thường xác định điều kiện lọc hoặc cách tổ chức kết quả.
Ví dụ:
/api/products/10
Đường dẫn này biểu diễn sản phẩm có mã 10.
/api/products?category=clothing&page=2
Đường dẫn này yêu cầu danh sách sản phẩm thuộc danh mục quần áo ở trang thứ hai.
Việc phân biệt hai loại tham số giúp thiết kế API rõ ràng hơn và giúp Frontend truyền dữ liệu đúng vị trí.
5. Các phương thức HTTP phổ biến
5.1. GET – Truy xuất dữ liệu
GET là phương thức thường được sử dụng khi Flutter cần lấy dữ liệu từ Backend. Yêu cầu GET không được thiết kế để thay đổi trạng thái nghiệp vụ của tài nguyên trên Server.
Ví dụ, khi người dùng mở trang chủ của ứng dụng bán hàng, Flutter có thể gọi API lấy danh sách sản phẩm nổi bật. Server trả về danh sách sản phẩm, hình ảnh, giá bán và những thông tin liên quan.
final response = await dio.get('/products');
print(response.data);
Trong đó, response.data chứa phần dữ liệu phản hồi mà Dio đã xử lý theo cấu hình mặc định. Nếu Server trả JSON hợp lệ, dữ liệu thường được chuyển thành Map hoặc List trong Dart.
GET thường được sử dụng trong các chức năng:
-
Lấy danh sách sản phẩm.
-
Lấy chi tiết sản phẩm.
-
Tìm kiếm dữ liệu.
-
Lấy thông tin hồ sơ người dùng.
-
Lấy lịch sử đơn hàng.
-
Lấy thông tin thống kê.
GET không nên được sử dụng để thực hiện những thao tác thay đổi dữ liệu như xóa sản phẩm hoặc tạo đơn hàng. Ngoài ra, không nên đưa mật khẩu hoặc thông tin nhạy cảm vào Query Parameters vì URL có thể xuất hiện trong log, lịch sử hoặc hệ thống giám sát.
5.2. POST – Tạo mới hoặc xử lý nghiệp vụ
POST thường được dùng để tạo tài nguyên hoặc gửi dữ liệu để Server thực hiện một thao tác nghiệp vụ.
Ví dụ, khi người dùng đăng ký tài khoản, Flutter gửi email, mật khẩu và những thông tin cần thiết đến API đăng ký.
final response = await dio.post(
'/auth/register',
data: {
'email': '[email protected]',
'password': 'StrongPassword123',
},
);
Dio chuyển dữ liệu trong data thành phần Body của yêu cầu theo cấu hình và kiểu dữ liệu được cung cấp.
POST cũng có thể được dùng để tạo đơn hàng, gửi đánh giá, đăng nhập hoặc tải dữ liệu lên Server. Tuy nhiên, POST không phải lúc nào cũng có nghĩa là tạo tài nguyên mới; ý nghĩa cụ thể phụ thuộc vào thiết kế API.
Vì POST thường không có tính idempotent, việc gửi lại một yêu cầu có thể tạo ra nhiều tài nguyên hoặc thực hiện cùng một nghiệp vụ nhiều lần. Đối với các thao tác như thanh toán hoặc tạo đơn hàng, cần có cơ chế chống xử lý trùng phù hợp ở Backend, chẳng hạn sử dụng Idempotency Key nếu API hỗ trợ.
5.3. PUT – Thay thế tài nguyên
PUT thường được sử dụng khi Client muốn cập nhật toàn bộ biểu diễn của tài nguyên tại một địa chỉ xác định.
Ví dụ, API cập nhật sản phẩm có thể nhận đầy đủ tên, giá và mô tả:
final response = await dio.put(
'/products/10',
data: {
'name': 'Áo thun mới',
'price': 219000,
'description': 'Áo thun cotton',
},
);
Backend sẽ xử lý dữ liệu theo hợp đồng API. Một số hệ thống sử dụng PUT để thay thế toàn bộ tài nguyên, trong khi những API không tuân thủ chặt chẽ quy ước HTTP có thể triển khai hành vi khác. Vì vậy, Frontend cần kiểm tra tài liệu API trước khi sử dụng.
5.4. PATCH – Cập nhật một phần dữ liệu
PATCH phù hợp khi chỉ cần thay đổi một số trường của tài nguyên.
Ví dụ, nếu chỉ thay đổi giá sản phẩm, Client có thể gửi:
final response = await dio.patch(
'/products/10',
data: {
'price': 229000,
},
);
Cách này giúp giảm lượng dữ liệu gửi đi và thể hiện rõ trường nào cần thay đổi. Tuy nhiên, ý nghĩa của việc bỏ qua trường, gửi null hoặc gửi giá trị rỗng cần được thống nhất với Backend.
PATCH không mặc định có tính idempotent trong mọi cách triển khai. Tính chất này phụ thuộc vào loại thao tác được định nghĩa.
5.5. DELETE – Xóa tài nguyên
DELETE được sử dụng để yêu cầu Server xóa tài nguyên hoặc đánh dấu tài nguyên không còn được sử dụng.
Ví dụ:
final response = await dio.delete('/products/10');
Sau khi nhận yêu cầu, Backend cần xác minh tài nguyên tồn tại, kiểm tra quyền truy cập và thực hiện các quy tắc nghiệp vụ liên quan.
Một số hệ thống sử dụng xóa mềm, nghĩa là dữ liệu vẫn tồn tại trong cơ sở dữ liệu nhưng được đánh dấu đã xóa. Một số hệ thống khác xóa dữ liệu trực tiếp hoặc từ chối xóa khi tài nguyên đang được sử dụng.
Flutter không nên tự giả định việc xóa đã hoàn tất chỉ vì đã gửi yêu cầu. Ứng dụng cần kiểm tra kết quả phản hồi và cập nhật giao diện theo trạng thái thực tế.
6. HTTP Request và HTTP Response
6.1. Cấu trúc HTTP Request
HTTP Request là thông điệp do Client gửi đến Server. Một yêu cầu HTTP thường gồm phương thức, URL, Header và Body.
Ví dụ:
POST /api/auth/login HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
{
"email": "[email protected]",
"password": "StrongPassword123"
}
Method xác định loại thao tác mà Client muốn thực hiện.
URL xác định máy chủ và Endpoint cần truy cập.
Header chứa các thông tin mô tả yêu cầu như kiểu dữ liệu, thông tin xác thực hoặc những chỉ thị liên quan.
Body chứa dữ liệu gửi lên Server. Body thường xuất hiện trong những yêu cầu như POST, PUT hoặc PATCH, nhưng cách sử dụng phụ thuộc vào phương thức và hợp đồng API.
Không phải mọi HTTP Request đều có Body. Ví dụ, GET thường không cần Body; dữ liệu tìm kiếm và phân trang thường được truyền bằng Query Parameters.
6.2. Cấu trúc HTTP Response
HTTP Response là thông điệp do Server gửi về sau khi xử lý yêu cầu. Một phản hồi thường gồm Status Code, Header và Body.
Ví dụ:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"id": 10,
"name": "Áo thun",
"price": 199000
}
Status Code cho biết kết quả xử lý ở mức giao thức HTTP. Header cung cấp thông tin về phản hồi, còn Body chứa dữ liệu hoặc thông báo liên quan.
Flutter cần kiểm tra phản hồi thay vì chỉ đọc dữ liệu. Một yêu cầu có thể thất bại vì dữ liệu không hợp lệ, không đủ quyền, tài nguyên không tồn tại hoặc Server gặp sự cố.
Với Dio, phản hồi thành công thường được nhận thông qua đối tượng Response. Nếu yêu cầu không đáp ứng cấu hình validateStatus, Dio có thể phát sinh DioException chứa thông tin về phản hồi lỗi, nếu Server đã trả về phản hồi.
6.3. HTTP Headers
HTTP Headers là những trường thông tin bổ sung cho yêu cầu và phản hồi. Chúng đóng vai trò quan trọng trong việc xác định kiểu dữ liệu, xác thực, caching và cách diễn giải thông điệp.
Một số Header thường gặp:
Content-Type xác định kiểu dữ liệu của phần Body.
Content-Type: application/json
Header này cho biết Body được gửi dưới dạng JSON. Khi gửi dữ liệu biểu mẫu, kiểu nội dung có thể là application/x-www-form-urlencoded hoặc multipart/form-data, tùy theo cách truyền dữ liệu.
Accept cho biết Client mong muốn nhận kiểu dữ liệu nào.
Accept: application/json
Authorization thường được dùng để truyền thông tin xác thực.
Authorization: Bearer ACCESS_TOKEN
Cache-Control xác định những chỉ thị liên quan đến caching.
Cache-Control: no-cache
Cần lưu ý rằng no-cache không đồng nghĩa với việc tuyệt đối không lưu dữ liệu. Chỉ thị này thường yêu cầu xác thực lại trước khi sử dụng phản hồi được lưu. Nếu muốn hạn chế lưu trữ, no-store có ý nghĩa khác và thường phù hợp hơn cho những phản hồi không nên được lưu trong bộ nhớ đệm HTTP.
Trong Flutter, Header có thể được thiết lập trực tiếp cho từng yêu cầu hoặc cấu hình mặc định cho toàn bộ Dio Client. Việc cấu hình tập trung giúp tránh lặp lại thông tin và đảm bảo các yêu cầu sử dụng cùng quy tắc giao tiếp.
7. HTTP Status Codes

7.1. Nhóm mã 2xx – Thành công
Các mã trạng thái thuộc nhóm 2xx cho biết yêu cầu đã được tiếp nhận và xử lý thành công theo ý nghĩa của từng mã.
-
200 OK: Yêu cầu thành công, thường dùng khi lấy dữ liệu hoặc thao tác hoàn tất. -
201 Created: Tài nguyên mới đã được tạo thành công. -
202 Accepted: Yêu cầu đã được chấp nhận để xử lý, nhưng quá trình xử lý có thể chưa hoàn tất. -
204 No Content: Yêu cầu thành công nhưng phản hồi không có nội dung Body.
Ví dụ, API tạo sản phẩm có thể trả về 201 Created, còn API xóa sản phẩm có thể trả về 204 No Content.
Khi nhận mã 204, Flutter không nên mặc định rằng phản hồi luôn chứa JSON. Việc cố gắng phân tích một Body không tồn tại có thể gây lỗi trong ứng dụng.
7.2. Nhóm mã 3xx – Chuyển hướng
Nhóm 3xx liên quan đến chuyển hướng hoặc những hành vi cần thiết để hoàn tất yêu cầu.
Một số mã phổ biến gồm:
-
301 Moved Permanently: Tài nguyên đã được chuyển vĩnh viễn sang địa chỉ khác. -
302 Found: Tài nguyên tạm thời được chuyển hướng. -
304 Not Modified: Tài nguyên không thay đổi so với phiên bản được xác định thông qua cơ chế caching có điều kiện.
Trong ứng dụng Flutter, chuyển hướng có thể được xử lý bởi HTTP Client hoặc chịu ảnh hưởng từ cấu hình của nền tảng. Đối với API, việc chuyển hướng không phù hợp có thể khiến Client nhận HTML thay vì JSON hoặc gặp vấn đề khi xử lý xác thực.
Vì vậy, Backend nên cung cấp URL API ổn định và sử dụng cơ chế chuyển hướng một cách nhất quán.
7.3. Nhóm mã 4xx – Lỗi từ yêu cầu phía Client
Nhóm 4xx cho biết yêu cầu có vấn đề hoặc Client không thể thực hiện thao tác theo điều kiện hiện tại.
Các mã thường gặp:
-
400 Bad Request: Yêu cầu không hợp lệ. -
401 Unauthorized: Thiếu thông tin xác thực hợp lệ hoặc thông tin xác thực không được chấp nhận. -
403 Forbidden: Server từ chối cho phép thực hiện thao tác. -
404 Not Found: Không tìm thấy tài nguyên hoặc Endpoint. -
405 Method Not Allowed: Phương thức HTTP không được hỗ trợ cho tài nguyên đó. -
409 Conflict: Yêu cầu xung đột với trạng thái hiện tại. -
422 Unprocessable Content: Nội dung được gửi lên không thể xử lý theo các quy tắc của hệ thống. -
429 Too Many Requests: Client gửi quá nhiều yêu cầu trong một khoảng thời gian.
Ví dụ, nếu người dùng nhập sai mật khẩu, Backend có thể trả về lỗi xác thực theo hợp đồng API. Nếu người dùng cố gắng truy cập chức năng dành riêng cho quản trị viên, Server có thể trả về 403.
Ứng dụng Flutter cần phân biệt các trường hợp này để hiển thị thông báo phù hợp. Lỗi 401 có thể yêu cầu đăng nhập lại; lỗi 403 có thể yêu cầu thông báo không đủ quyền; lỗi 404 có thể hiển thị thông báo tài nguyên không tồn tại.
Tuy nhiên, mã trạng thái cụ thể còn phụ thuộc vào cách Backend thiết kế API. Frontend cần tuân thủ tài liệu API thay vì tự suy đoán tất cả trường hợp nghiệp vụ từ Status Code.
7.4. Nhóm mã 5xx – Lỗi phía Server
Nhóm 5xx biểu thị Server không thể hoàn thành yêu cầu do lỗi hoặc điều kiện bất thường ở phía máy chủ.
Một số mã thường gặp:
-
500 Internal Server Error: Lỗi nội bộ không được xử lý thành công. -
501 Not Implemented: Server không hỗ trợ chức năng cần thiết. -
502 Bad Gateway: Máy chủ trung gian nhận phản hồi không hợp lệ từ hệ thống phía sau. -
503 Service Unavailable: Dịch vụ tạm thời không khả dụng. -
504 Gateway Timeout: Máy chủ trung gian không nhận được phản hồi kịp thời từ hệ thống phía sau.
Khi gặp lỗi 5xx, Flutter có thể hiển thị thông báo chung và cho phép người dùng thử lại. Tuy nhiên, ứng dụng không nên tự động gửi lại mọi yêu cầu mà không xem xét tác động nghiệp vụ.
Ví dụ, nếu yêu cầu tạo đơn hàng đã được Backend xử lý nhưng phản hồi bị lỗi trên đường truyền, gửi lại POST có thể tạo thêm một đơn hàng. Với những thao tác quan trọng, cần sử dụng cơ chế chống trùng hoặc xác minh trạng thái trước khi thử lại.
8. JSON và quá trình chuyển đổi dữ liệu
8.1. Khái niệm JSON
JSON là viết tắt của JavaScript Object Notation, một định dạng trao đổi dữ liệu phổ biến trong các hệ thống REST API. JSON có cấu trúc dễ đọc, hỗ trợ các kiểu dữ liệu cơ bản như chuỗi, số, Boolean, giá trị null, mảng và đối tượng.
Ví dụ:
{
"id": 10,
"name": "Áo thun",
"price": 199000,
"isAvailable": true,
"category": {
"id": 2,
"name": "Thời trang"
},
"tags": ["cotton", "casual"]
}
JSON phù hợp với các API cần trao đổi dữ liệu giữa những công nghệ khác nhau. Backend có thể được viết bằng Node.js hoặc ASP.NET, trong khi Client được xây dựng bằng Flutter, nhưng cả hai vẫn có thể trao đổi dữ liệu thông qua cùng một cấu trúc JSON.
8.2. Phân tích JSON trong Dart
Dart cung cấp thư viện dart:convert để chuyển đổi giữa chuỗi JSON và các cấu trúc dữ liệu Dart.
Ví dụ:
import 'dart:convert';
void main() {
const jsonString = '''
{
"id": 10,
"name": "Áo thun",
"price": 199000
}
''';
final Map<String, dynamic> data =
jsonDecode(jsonString) as Map<String, dynamic>;
print(data['name']);
print(data['price']);
}
Hàm jsonDecode() chuyển chuỗi JSON thành các kiểu dữ liệu tương ứng trong Dart. Đối với một đối tượng JSON, kết quả thường là Map<String, dynamic>. Đối với một mảng JSON, kết quả thường là List<dynamic>.
Kiểu dynamic cho phép dữ liệu có nhiều kiểu khác nhau, nhưng nếu sử dụng trực tiếp ở nhiều nơi sẽ khiến việc kiểm soát kiểu dữ liệu trở nên khó khăn. Vì vậy, ứng dụng thực tế thường chuyển JSON thành các Model Dart có cấu trúc rõ ràng.
8.3. Model và JSON Serialization
Model là lớp Dart biểu diễn một đối tượng dữ liệu trong ứng dụng. Ví dụ, lớp Product mô tả những thuộc tính của một sản phẩm như mã, tên và giá bán.
class Product {
final int id;
final String name;
final double price;
const Product({
required this.id,
required this.name,
required this.price,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
};
}
}
Trong ví dụ này, fromJson() chuyển dữ liệu JSON thành đối tượng Product, còn toJson() chuyển đối tượng Dart thành một Map có thể được mã hóa thành JSON.
Việc sử dụng Model mang lại nhiều lợi ích:
-
Hạn chế truy cập dữ liệu thông qua các chuỗi khóa rải rác.
-
Giúp kiểm soát kiểu dữ liệu.
-
Làm rõ cấu trúc dữ liệu trong ứng dụng.
-
Giảm lỗi khi dữ liệu được sử dụng ở nhiều màn hình.
-
Tách biệt cấu trúc dữ liệu từ API với cách hiển thị giao diện.
Tuy nhiên, Model phải phù hợp với dữ liệu Backend thực tế. Nếu trường dữ liệu có thể thiếu hoặc nhận null, lập trình viên cần xử lý trường hợp đó thay vì ép kiểu một cách thiếu kiểm soát.
8.4. Dữ liệu lồng nhau và danh sách JSON
Trong các ứng dụng thực tế, dữ liệu thường có cấu trúc phức tạp hơn một đối tượng đơn giản. Ví dụ, một sản phẩm có thể chứa danh mục, danh sách hình ảnh và thông tin người bán.
{
"id": 10,
"name": "Áo thun",
"price": 199000,
"images": [
"https://example.com/a.jpg",
"https://example.com/b.jpg"
],
"category": {
"id": 2,
"name": "Thời trang"
}
}
Khi xử lý cấu trúc này, Flutter cần chuyển đổi các đối tượng và danh sách lồng nhau thành những Model phù hợp. Nếu không xử lý đúng kiểu dữ liệu hoặc trường hợp thiếu dữ liệu, ứng dụng có thể gặp lỗi khi hiển thị.
Với các dự án lớn, có thể sử dụng những công cụ sinh mã như json_serializable để tự động tạo các hàm chuyển đổi JSON. Cách này giảm lượng mã viết thủ công, đồng thời giúp việc duy trì Model thuận tiện hơn khi API có nhiều trường dữ liệu.
9. Tích hợp REST API vào Flutter
9.1. Sử dụng HTTP Client
Flutter có thể gửi HTTP Request bằng nhiều thư viện khác nhau. Một lựa chọn phổ biến là package http, phù hợp với những ứng dụng có nhu cầu giao tiếp API tương đối đơn giản.
Để sử dụng, thêm package vào dự án:
flutter pub add http
Ví dụ lấy danh sách sản phẩm:
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProductService {
final String baseUrl = 'https://api.example.com';
Future<List<Product>> getProducts() async {
final uri = Uri.parse('$baseUrl/api/products');
final response = await http.get(
uri,
headers: {
'Accept': 'application/json',
},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList =
jsonDecode(response.body) as List<dynamic>;
return jsonList
.map(
(item) => Product.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
}
throw Exception(
'Không thể tải danh sách sản phẩm: ${response.statusCode}',
);
}
}
Ví dụ trên giả định API trả về một mảng JSON trực tiếp khi thành công. Nếu Backend trả dữ liệu trong cấu trúc như {"data": [...]}, phần phân tích JSON cần được điều chỉnh cho phù hợp.
HTTP Client nên được sử dụng thông qua lớp Service hoặc Repository thay vì gửi yêu cầu trực tiếp trong mọi Widget. Cách tổ chức này giúp tái sử dụng mã nguồn và tách biệt trách nhiệm giữa giao diện với dữ liệu.
9.2. Sử dụng Dio
Dio là thư viện HTTP Client phổ biến trong Flutter, cung cấp nhiều tính năng như cấu hình Base URL, thiết lập Header, Interceptor, Timeout, tải lên và tải xuống tệp, xử lý lỗi cùng những tùy chọn nâng cao khác.
Cài đặt Dio:
flutter pub add dio
Khởi tạo HTTP Client:
import 'package:dio/dio.dart';
class ApiClient {
late final Dio dio;
ApiClient() {
dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com/api',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
headers: {
'Accept': 'application/json',
},
),
);
}
}
Trong cấu hình trên:
-
baseUrllà địa chỉ gốc của API. -
connectTimeoutgiới hạn thời gian thiết lập kết nối. -
receiveTimeoutgiới hạn thời gian chờ nhận dữ liệu theo cơ chế của Dio. -
sendTimeoutgiới hạn thời gian gửi dữ liệu. -
headersthiết lập Header mặc định cho các yêu cầu.
Các giá trị Timeout nên được điều chỉnh theo yêu cầu của hệ thống. Một thao tác tải tệp lớn có thể cần thời gian dài hơn một yêu cầu lấy thông tin sản phẩm.
Sử dụng Dio giúp chuẩn hóa cách gửi yêu cầu và xử lý phản hồi. Tuy nhiên, thư viện không thay thế thiết kế API của Backend, không tự đảm bảo dữ liệu hợp lệ và cũng không tự động làm cho toàn bộ ứng dụng hoạt động ngoại tuyến.
9.3. Xây dựng Service giao tiếp API
Service là lớp chịu trách nhiệm thực hiện những thao tác giao tiếp với API. Ví dụ, ProductService có thể cung cấp các hàm lấy danh sách, lấy chi tiết, tạo mới, cập nhật và xóa sản phẩm.
import 'package:dio/dio.dart';
class ProductService {
final Dio dio;
ProductService(this.dio);
Future<List<Product>> getProducts() async {
final response = await dio.get('/products');
final data = response.data;
if (data is! List) {
throw const FormatException(
'Dữ liệu sản phẩm không hợp lệ',
);
}
return data.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException(
'Một sản phẩm có định dạng không hợp lệ',
);
}
return Product.fromJson(item);
}).toList();
}
Future<Product> getProductById(int id) async {
final response = await dio.get('/products/$id');
if (response.data is! Map<String, dynamic>) {
throw const FormatException(
'Chi tiết sản phẩm không hợp lệ',
);
}
return Product.fromJson(
response.data as Map<String, dynamic>,
);
}
Future<void> deleteProduct(int id) async {
await dio.delete('/products/$id');
}
}
Đoạn mã này giả định Endpoint /products trả về danh sách JSON và /products/{id} trả về một đối tượng JSON trực tiếp. Nếu API có cấu trúc phản hồi khác, Service cần được điều chỉnh theo tài liệu Backend.
Việc tập trung các thao tác API vào Service giúp những màn hình khác nhau có thể sử dụng chung logic giao tiếp. Ví dụ, màn hình trang chủ và màn hình tìm kiếm đều có thể gọi các hàm thuộc lớp Service mà không cần tự tạo Dio Client riêng.
9.4. Phân tách ApiClient, Service và Model
Trong một dự án Flutter có quy mô vừa hoặc lớn, việc tổ chức mã nguồn thành nhiều lớp giúp hệ thống dễ bảo trì.
ApiClient quản lý cấu hình giao tiếp chung như Base URL, Header, Timeout và Interceptor.
Service hoặc Repository cung cấp các thao tác liên quan đến dữ liệu, chẳng hạn lấy sản phẩm hoặc tạo đơn hàng.
Model biểu diễn dữ liệu và chuyển đổi giữa JSON với đối tượng Dart.
Provider, Riverpod, BLoC hoặc ViewModel quản lý trạng thái và điều phối dữ liệu giữa giao diện với tầng dữ liệu.
Widget tập trung vào hiển thị thông tin và tiếp nhận tương tác từ người dùng.
Một cấu trúc thư mục có thể được tổ chức như sau:
lib/
├── core/
│ └── network/
│ └── api_client.dart
├── features/
│ └── products/
│ ├── models/
│ │ └── product.dart
│ ├── services/
│ │ └── product_service.dart
│ ├── repositories/
│ │ └── product_repository.dart
│ ├── providers/
│ │ └── product_provider.dart
│ └── screens/
│ └── product_screen.dart
└── main.dart
Đây là một cách tổ chức tham khảo, không phải cấu trúc bắt buộc cho mọi dự án. Với ứng dụng nhỏ, có thể dùng ít lớp hơn. Với ứng dụng lớn, việc chia theo tính năng giúp nhóm phát triển dễ mở rộng và quản lý mã nguồn.
10. Xử lý bất đồng bộ khi gọi API
10.1. Future trong Dart
Giao tiếp API là một tác vụ bất đồng bộ vì ứng dụng phải chờ dữ liệu được truyền qua mạng và Server xử lý yêu cầu.
Dart sử dụng Future để biểu diễn kết quả có thể xuất hiện trong tương lai. Khi gửi yêu cầu HTTP, hàm thường trả về Future<Response> hoặc một kiểu dữ liệu Future khác.
Ví dụ:
Future<List<Product>> getProducts() async {
final response = await dio.get('/products');
final List<dynamic> data =
response.data as List<dynamic>;
return data
.map(
(item) => Product.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
}
Từ khóa async cho phép sử dụng await bên trong hàm. Từ khóa await tạm dừng việc thực thi phần tiếp theo của hàm bất đồng bộ cho đến khi tác vụ được hoàn tất, nhưng không chặn toàn bộ giao diện Flutter theo cách một thao tác đồng bộ kéo dài có thể gây ra.
Điều này rất quan trọng đối với ứng dụng di động. Nếu giao diện phải chờ mạng theo cách chặn luồng giao diện, ứng dụng có thể mất phản hồi và gây trải nghiệm không tốt.
10.2. Quản lý trạng thái tải dữ liệu
Khi gọi API, giao diện thường có ít nhất ba trạng thái cơ bản:
-
Loading: đang tải dữ liệu.
-
Success: tải dữ liệu thành công.
-
Error: tải dữ liệu thất bại.
Một số ứng dụng còn có trạng thái rỗng khi yêu cầu thành công nhưng không có dữ liệu, hoặc trạng thái làm mới khi người dùng tải lại danh sách.
Ví dụ sử dụng StatefulWidget:
import 'package:flutter/material.dart';
class ProductScreen extends StatefulWidget {
const ProductScreen({super.key});
@override
State<ProductScreen> createState() =>
_ProductScreenState();
}
class _ProductScreenState extends State<ProductScreen> {
late Future<List<Product>> productsFuture;
@override
void initState() {
super.initState();
productsFuture = ProductService(
ApiClient().dio,
).getProducts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sản phẩm'),
),
body: FutureBuilder<List<Product>>(
future: productsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Không thể tải sản phẩm: ${snapshot.error}',
),
);
}
final products = snapshot.data ?? [];
if (products.isEmpty) {
return const Center(
child: Text('Chưa có sản phẩm'),
);
}
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text(
'${product.price.toStringAsFixed(0)} VNĐ',
),
);
},
);
},
),
);
}
}
Ví dụ trên minh họa cách kết nối Future với giao diện. Các lớp Product, ApiClient và ProductService cần được khai báo trong dự án như những phần trước.
FutureBuilder theo dõi Future và xây dựng giao diện theo trạng thái của kết quả. Tuy nhiên, Future nên được khởi tạo bên ngoài hàm build() trong trường hợp này. Nếu tạo Future mới mỗi lần build() chạy, ứng dụng có thể liên tục gửi yêu cầu ngoài ý muốn.
Trong ứng dụng lớn, việc quản lý trạng thái bằng Riverpod, BLoC hoặc một giải pháp tương tự có thể giúp xử lý dữ liệu, lỗi, làm mới và phân trang rõ ràng hơn.
10.3. Tránh cập nhật Widget sau khi bị hủy
Một vấn đề thường gặp là yêu cầu API hoàn thành sau khi người dùng đã rời khỏi màn hình. Nếu đoạn mã bất đồng bộ gọi setState() trên một State không còn được gắn với cây Widget, ứng dụng có thể gặp cảnh báo hoặc lỗi.
Ví dụ:
Future<void> loadData() async {
final result = await service.getProducts();
if (!mounted) return;
setState(() {
products = result;
});
}
Thuộc tính mounted giúp kiểm tra State còn tồn tại trong cây Widget hay không. Nếu không còn tồn tại, hàm nên dừng trước khi cập nhật giao diện.
Tuy nhiên, kiểm tra mounted không hủy yêu cầu mạng. Nếu cần hủy yêu cầu khi người dùng rời màn hình, có thể sử dụng cơ chế hủy tương ứng của HTTP Client. Với Dio, CancelToken hỗ trợ hủy những yêu cầu được liên kết với Token đó.
11. Xử lý lỗi REST API
11.1. Phân loại lỗi
Lỗi trong quá trình gọi API không chỉ xuất phát từ một nguyên nhân. Có thể chia thành những nhóm chính:
Lỗi mạng: Thiết bị không có Internet, DNS không phân giải được tên miền, kết nối bị gián đoạn hoặc máy chủ không thể truy cập.
Lỗi Timeout: Yêu cầu không hoàn thành trong thời gian cho phép.
Lỗi HTTP: Server trả về mã trạng thái như 400, 401, 403, 404 hoặc 500.
Lỗi dữ liệu: JSON không hợp lệ, kiểu dữ liệu không đúng hoặc thiếu trường bắt buộc.
Lỗi nghiệp vụ: Server từ chối thao tác do điều kiện hệ thống, chẳng hạn sản phẩm hết hàng hoặc tài khoản không đủ quyền.
Lỗi lập trình: Sai URL, sai phương thức HTTP, cấu hình Header không chính xác hoặc chuyển đổi kiểu dữ liệu không phù hợp.
Việc phân loại giúp xác định cách xử lý tương ứng. Lỗi mạng có thể cần thử lại; lỗi dữ liệu có thể cần kiểm tra hợp đồng API; lỗi xác thực có thể cần làm mới Token; còn lỗi nghiệp vụ cần thông báo rõ ràng cho người dùng.
11.2. Xử lý lỗi với Dio
Dio cung cấp DioException để biểu diễn nhiều tình huống lỗi liên quan đến yêu cầu HTTP.
import 'package:dio/dio.dart';
Future<void> loadProducts(Dio dio) async {
try {
final response = await dio.get('/products');
print(response.data);
} on DioException catch (e) {
if (e.response != null) {
print('HTTP Status: ${e.response?.statusCode}');
print('Response: ${e.response?.data}');
} else {
print('Lỗi kết nối: ${e.message}');
}
} catch (e) {
print('Lỗi không xác định: $e');
}
}
Trong đoạn mã trên, e.response chứa thông tin phản hồi nếu Server đã trả về phản hồi có liên quan. Nếu không có phản hồi, nguyên nhân có thể liên quan đến kết nối, Timeout hoặc quá trình thiết lập yêu cầu.
Ứng dụng thực tế nên chuyển lỗi kỹ thuật thành thông báo phù hợp cho người dùng. Ví dụ, không nên hiển thị toàn bộ thông tin nội bộ của Exception trong giao diện chính thức.
11.3. Xây dựng cơ chế xử lý lỗi thống nhất
Trong dự án lớn, nếu mỗi Service tự xử lý lỗi theo cách khác nhau, mã nguồn sẽ dễ bị lặp lại và khó bảo trì. Vì vậy, có thể xây dựng một lớp hoặc cơ chế dùng chung để chuẩn hóa lỗi.
Ví dụ, ứng dụng có thể quy định:
-
Lỗi kết nối được chuyển thành thông báo không thể kết nối máy chủ.
-
Lỗi Timeout được chuyển thành thông báo yêu cầu quá thời gian.
-
Lỗi xác thực được chuyển thành trạng thái cần đăng nhập lại.
-
Lỗi dữ liệu đầu vào được chuyển thành thông báo xác thực phù hợp.
-
Lỗi Server được ghi nhận và hiển thị thông báo chung.
Cách xử lý này giúp giao diện không cần hiểu chi tiết từng loại Exception từ thư viện mạng.
Ngoài ra, việc ghi log nên tránh lưu mật khẩu, Access Token, Refresh Token hoặc dữ liệu nhạy cảm. Thông tin log cần đủ để hỗ trợ tìm lỗi nhưng không làm lộ dữ liệu của người dùng.
12. Authentication và Authorization trong REST API
12.1. Authentication
Authentication là quá trình xác minh danh tính của người dùng hoặc hệ thống đang gửi yêu cầu.
Ví dụ, khi người dùng đăng nhập bằng email và mật khẩu, Flutter gửi thông tin đến API đăng nhập. Backend xác minh thông tin và có thể trả về Access Token nếu đăng nhập thành công.
Một phản hồi minh họa:
{
"accessToken": "example_access_token",
"tokenType": "Bearer",
"expiresIn": 3600
}
Cấu trúc trên chỉ là ví dụ; tên trường và thời hạn Token phụ thuộc vào Backend.
Flutter cần lưu thông tin xác thực theo cơ chế bảo mật phù hợp. Không nên lưu mật khẩu người dùng dưới dạng văn bản thuần trong bộ nhớ lưu trữ lâu dài.
12.2. Authorization
Authorization là quá trình xác định người dùng đã được xác thực có quyền thực hiện một thao tác cụ thể hay không.
Ví dụ, người dùng đã đăng nhập có thể xem sản phẩm nhưng không được phép xóa sản phẩm. Quản trị viên có thể có quyền tạo, cập nhật hoặc xóa sản phẩm theo chính sách hệ thống.
Flutter có thể ẩn hoặc vô hiệu hóa những nút mà người dùng không có quyền sử dụng để cải thiện trải nghiệm. Tuy nhiên, Backend vẫn phải kiểm tra quyền truy cập cho mọi thao tác được bảo vệ. Không được xem việc ẩn nút ở giao diện là cơ chế bảo mật đầy đủ.
12.3. Gửi Access Token qua Header
Với API sử dụng Bearer Token, Flutter thường gửi Token trong Header:
Authorization: Bearer example_access_token
Với Dio, có thể truyền Header trực tiếp:
final response = await dio.get(
'/profile',
options: Options(
headers: {
'Authorization': 'Bearer $accessToken',
},
),
);
Nếu nhiều Endpoint đều yêu cầu Token, có thể sử dụng Interceptor để thêm Header tự động. Cách này giảm lặp mã và tập trung logic xác thực.
Token chỉ nên được gửi đến những máy chủ đáng tin cậy qua HTTPS. Ứng dụng không nên ghi Token vào log hoặc chia sẻ Token cho những Endpoint không cần xác thực.
12.4. Access Token và Refresh Token
Access Token thường được sử dụng để truy cập API trong một khoảng thời gian giới hạn. Refresh Token được sử dụng để yêu cầu cấp Access Token mới khi Access Token hết hạn, nếu hệ thống hỗ trợ cơ chế này.
Một luồng hoạt động phổ biến:
-
Người dùng đăng nhập thành công.
-
Backend cấp Access Token và có thể cấp thêm Refresh Token.
-
Flutter lưu Token theo chính sách bảo mật của ứng dụng.
-
Flutter gửi Access Token khi gọi API được bảo vệ.
-
Khi Access Token hết hạn, Backend trả về lỗi xác thực theo hợp đồng API.
-
Flutter sử dụng Refresh Token để yêu cầu cấp Token mới.
-
Nếu làm mới thành công, ứng dụng có thể gửi lại yêu cầu ban đầu.
-
Nếu làm mới thất bại, ứng dụng yêu cầu người dùng đăng nhập lại.
Luồng này cần được thiết kế cẩn thận để tránh gửi nhiều yêu cầu làm mới Token đồng thời. Nếu nhiều yêu cầu cùng nhận lỗi xác thực, ứng dụng nên phối hợp quá trình Refresh Token thay vì thực hiện một yêu cầu làm mới độc lập cho từng Request.
Không phải API nào cũng sử dụng Refresh Token. Frontend cần triển khai theo cơ chế xác thực mà Backend thực sự hỗ trợ.
13. Interceptor trong HTTP Client
13.1. Khái niệm Interceptor
Interceptor là cơ chế cho phép can thiệp vào quá trình gửi yêu cầu hoặc nhận phản hồi HTTP. Với Dio, Interceptor có thể xử lý Request, Response và Error theo một quy tắc tập trung.
Interceptor thường được sử dụng để:
-
Thêm Access Token vào Header.
-
Ghi log yêu cầu trong môi trường phát triển.
-
Chuẩn hóa một số thao tác xử lý lỗi.
-
Theo dõi mã trạng thái phản hồi.
-
Thực hiện quy trình Refresh Token khi được thiết kế phù hợp.
Nhờ Interceptor, lập trình viên không cần lặp lại những logic dùng chung ở mọi hàm API.
13.2. Ví dụ thêm Token bằng Interceptor
import 'package:dio/dio.dart';
class ApiClient {
late final Dio dio;
ApiClient({
required Future<String?> Function() getAccessToken,
}) {
dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com/api',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await getAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] =
'Bearer $token';
}
handler.next(options);
},
onResponse: (response, handler) {
handler.next(response);
},
onError: (error, handler) {
handler.next(error);
},
),
);
}
}
Hàm getAccessToken được truyền vào để lấy Token từ nguồn lưu trữ do ứng dụng quản lý. Cách thiết kế này giúp ApiClient không phụ thuộc trực tiếp vào một thư viện lưu trữ cụ thể.
Đoạn mã minh họa việc thêm Token và chuyển tiếp yêu cầu, phản hồi cùng lỗi. Nó chưa triển khai Refresh Token, xử lý đồng thời hoặc cơ chế loại trừ những Endpoint không cần xác thực.
13.3. Nguyên tắc sử dụng Interceptor
Interceptor nên tập trung vào những công việc dùng chung, tránh chứa toàn bộ nghiệp vụ của từng tính năng.
Ví dụ, Interceptor phù hợp để thêm Header xác thực, nhưng không nên quyết định cách xử lý mọi trường hợp nghiệp vụ của đơn hàng hoặc sản phẩm.
Trong môi trường phát triển, có thể ghi log URL, phương thức và mã trạng thái để tìm lỗi. Tuy nhiên, cần loại bỏ hoặc che những dữ liệu nhạy cảm trước khi ghi log. Trong môi trường sản xuất, việc ghi log phải được kiểm soát để tránh rò rỉ thông tin.
14. Timeout, Retry và Cancel Request
14.1. Timeout
Timeout là giới hạn thời gian cho phép một giai đoạn của yêu cầu hoàn thành. Nếu quá thời gian quy định, Client có thể dừng chờ và xử lý lỗi.
Timeout giúp ứng dụng không phải chờ vô thời hạn khi Server phản hồi chậm hoặc kết nối gặp vấn đề.
Với Dio:
final dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),
);
Các thông số này có ý nghĩa khác nhau và không nên được hiểu đơn giản là tổng thời gian tối đa của toàn bộ quá trình xử lý trong mọi tình huống.
Giá trị Timeout nên được chọn dựa trên loại API. Yêu cầu lấy thông tin tài khoản có thể cần thời gian ngắn hơn yêu cầu tải một tệp lớn.
14.2. Retry
Retry là cơ chế thử gửi lại yêu cầu sau khi xảy ra lỗi. Cơ chế này có thể hữu ích với những lỗi tạm thời như mất kết nối ngắn hạn hoặc một số phản hồi 5xx.
Tuy nhiên, không phải yêu cầu nào cũng nên được thử lại tự động. Nếu gửi lại một yêu cầu tạo đơn hàng hoặc thanh toán, ứng dụng có thể gây ra thao tác trùng lặp khi yêu cầu trước đã được xử lý nhưng phản hồi bị thất lạc.
Khi triển khai Retry, cần cân nhắc:
-
Loại yêu cầu và tính chất idempotent.
-
Những mã lỗi có thể thử lại.
-
Số lần thử tối đa.
-
Khoảng thời gian chờ giữa các lần thử.
-
Thời gian chờ tổng thể.
-
Cơ chế chống xử lý trùng của Backend.
Có thể sử dụng exponential backoff, nghĩa là tăng dần khoảng thời gian giữa các lần thử, để hạn chế gửi quá nhiều yêu cầu khi hệ thống đang gặp sự cố.
14.3. Cancel Request
Cancel Request là việc hủy một yêu cầu đang thực hiện. Cơ chế này hữu ích khi người dùng chuyển màn hình, thay đổi từ khóa tìm kiếm hoặc không còn cần dữ liệu đang tải.
Ví dụ, trong màn hình tìm kiếm sản phẩm, người dùng có thể nhập liên tục các ký tự. Nếu mỗi lần nhập đều gửi một yêu cầu và không hủy những yêu cầu cũ, ứng dụng có thể tiêu tốn tài nguyên mạng và nhận các kết quả không còn phù hợp.
Dio hỗ trợ CancelToken:
final cancelToken = CancelToken();
try {
final response = await dio.get(
'/products',
queryParameters: {
'keyword': 'shirt',
},
cancelToken: cancelToken,
);
print(response.data);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
print('Yêu cầu đã bị hủy');
} else {
rethrow;
}
}
// Khi không còn cần kết quả:
// cancelToken.cancel('Không còn cần dữ liệu');
Hủy yêu cầu ở Client không đồng nghĩa với việc chắc chắn Server chưa xử lý yêu cầu đó. Trong một số trường hợp, Server có thể đã nhận và thực hiện thao tác trước khi Client hủy kết nối. Vì vậy, Cancel Request không thay thế các cơ chế bảo vệ nghiệp vụ ở Backend.
15. Pagination, Filtering và Sorting
15.1. Pagination
Pagination là kỹ thuật chia dữ liệu thành nhiều trang nhỏ thay vì tải toàn bộ dữ liệu trong một yêu cầu.
Ví dụ:
GET /api/products?page=1&limit=20
GET /api/products?page=2&limit=20
GET /api/products?page=3&limit=20
Mỗi yêu cầu trả về tối đa 20 sản phẩm theo quy tắc phân trang của API.
Phân trang giúp giảm lượng dữ liệu truyền qua mạng, giảm áp lực xử lý trên Server và cải thiện trải nghiệm người dùng khi danh sách có hàng nghìn hoặc hàng chục nghìn phần tử.
Backend có thể trả về dữ liệu cùng thông tin phân trang:
{
"data": [
{
"id": 1,
"name": "Áo thun",
"price": 199000
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}
Đây là cấu trúc minh họa. API thực tế có thể sử dụng những tên trường khác hoặc áp dụng cursor pagination thay vì page-based pagination.
15.2. Filtering
Filtering là kỹ thuật lọc dữ liệu theo những điều kiện nhất định.
Ví dụ:
GET /api/products?category=clothing
GET /api/products?minPrice=100000&maxPrice=500000
GET /api/products?inStock=true
Thay vì tải toàn bộ sản phẩm rồi lọc trên điện thoại, Flutter có thể truyền điều kiện lọc để Backend chỉ trả về dữ liệu phù hợp.
Filtering đặc biệt hữu ích khi dữ liệu lớn hoặc điều kiện lọc cần dựa trên dữ liệu được lưu trữ tại Server.
Các giá trị lọc phải được xác minh ở Backend. Client không nên được phép truyền những điều kiện tùy ý có thể làm lộ dữ liệu hoặc truy cập những thông tin không được phép.
15.3. Sorting
Sorting là kỹ thuật sắp xếp kết quả theo một hoặc nhiều tiêu chí.
Ví dụ:
GET /api/products?sort=price&order=asc
GET /api/products?sort=createdAt&order=desc
Trong đó, price có thể đại diện cho giá sản phẩm và createdAt có thể đại diện cho thời điểm tạo dữ liệu. asc biểu thị thứ tự tăng dần, còn desc biểu thị thứ tự giảm dần theo quy ước của API.
Backend nên giới hạn những trường có thể sử dụng để sắp xếp. Việc cho phép Client truyền trực tiếp các biểu thức truy vấn tùy ý có thể tạo ra rủi ro bảo mật.
Khi kết hợp Pagination, Filtering và Sorting, ứng dụng cần đảm bảo các yêu cầu được thực hiện nhất quán. Nếu dữ liệu thay đổi trong lúc người dùng chuyển trang, một số bản ghi có thể xuất hiện trùng hoặc bị bỏ qua tùy theo chiến lược phân trang. Với dữ liệu thay đổi liên tục, cursor pagination có thể là lựa chọn phù hợp hơn.
16. Upload và Download dữ liệu
16.1. Upload File
Upload là quá trình gửi tệp từ thiết bị lên Server. Trong Flutter, chức năng này thường được sử dụng để tải ảnh đại diện, ảnh sản phẩm, tài liệu hoặc tệp đính kèm.
Một phương thức phổ biến là gửi dữ liệu theo định dạng multipart/form-data.
Với Dio, có thể sử dụng FormData và MultipartFile:
import 'package:dio/dio.dart';
Future<void> uploadImage(
Dio dio,
String filePath,
) async {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: 'image.jpg',
),
});
final response = await dio.post(
'/upload',
data: formData,
);
print(response.data);
}
Ví dụ trên giả định filePath là đường dẫn đến một tệp hợp lệ trên thiết bị và Backend chấp nhận trường file. Trong ứng dụng thực tế, việc lấy đường dẫn tệp cần được xử lý theo nền tảng và thư viện chọn tệp được sử dụng.
Không nên tự đặt Header Content-Type: application/json cho yêu cầu gửi FormData. Dio cần tạo Header multipart cùng thông tin boundary tương ứng.
Backend cần kiểm tra kích thước, loại tệp và quyền tải lên. Chỉ dựa vào phần mở rộng của tên tệp là chưa đủ để đảm bảo tệp an toàn.
16.2. Download File
Download là quá trình nhận tệp từ Server về thiết bị. Ví dụ, ứng dụng có thể tải hóa đơn PDF, hình ảnh hoặc tài liệu.
Dio hỗ trợ tải nội dung về một đường dẫn được chỉ định:
final response = await dio.download(
'/files/invoice.pdf',
'/path/to/invoice.pdf',
);
print(response.statusCode);
Đường dẫn đích trong ví dụ chỉ mang tính minh họa. Ứng dụng thực tế cần xác định thư mục lưu trữ phù hợp theo nền tảng, quyền truy cập và mục đích sử dụng.
Với tệp lớn, nên xem xét tiến trình tải xuống, khả năng tiếp tục tải nếu được hỗ trợ và cách xử lý khi mất kết nối.
17. Bảo mật khi giao tiếp REST API
17.1. Sử dụng HTTPS
HTTPS bảo vệ dữ liệu truyền giữa Client và Server bằng TLS, giúp hạn chế nguy cơ bị đọc hoặc sửa đổi dữ liệu trên đường truyền.
Ứng dụng không nên gửi mật khẩu, Token hoặc dữ liệu cá nhân qua kết nối HTTP không được bảo vệ trong môi trường thực tế.
HTTPS không tự giải quyết tất cả vấn đề bảo mật. Backend vẫn phải xác thực người dùng, kiểm tra quyền, xác minh dữ liệu đầu vào và bảo vệ thông tin được lưu trữ.
17.2. Quản lý Token an toàn
Access Token và Refresh Token có thể cho phép truy cập những tài nguyên được bảo vệ. Nếu Token bị lộ, người khác có thể sử dụng nó trong phạm vi quyền và thời hạn tương ứng.
Flutter nên sử dụng giải pháp lưu trữ phù hợp với mức độ nhạy cảm của dữ liệu. Các giải pháp như flutter_secure_storage có thể tận dụng cơ chế lưu trữ bảo mật của nền tảng, nhưng không nên xem bất kỳ giải pháp lưu trữ phía Client nào là tuyệt đối an toàn.
Không nên lưu Token trong mã nguồn, đẩy Token lên GitHub hoặc ghi Token đầy đủ vào log. Khi Token bị thu hồi hoặc hết hạn, ứng dụng cần xử lý theo chính sách xác thực của Backend.
17.3. Xác thực dữ liệu đầu vào
Dữ liệu do Flutter gửi lên cần được kiểm tra ở cả Client và Server.
Client có thể kiểm tra email, số lượng sản phẩm hoặc định dạng dữ liệu để cung cấp phản hồi nhanh cho người dùng. Tuy nhiên, Backend phải thực hiện xác thực độc lập vì Client có thể bị chỉnh sửa hoặc gửi yêu cầu trực tiếp mà không thông qua giao diện Flutter.
Ví dụ, khi tạo đơn hàng, Backend cần xác minh giá sản phẩm, số lượng, tồn kho, quyền mua và những điều kiện nghiệp vụ liên quan. Không nên tin tưởng giá tiền do Client gửi lên nếu giá chính thức được quản lý ở Server.
17.4. Không để lộ thông tin nhạy cảm
Thông tin nhạy cảm không nên xuất hiện trong URL, log, thông báo lỗi công khai hoặc mã nguồn ứng dụng.
Ví dụ, mật khẩu không nên được đưa vào Query Parameters. Token không nên được in đầy đủ ra Console. Thông báo lỗi từ Backend cũng cần được kiểm soát trước khi hiển thị cho người dùng.
Đối với ứng dụng phát hành chính thức, cần tách cấu hình môi trường phát triển và sản xuất. Địa chỉ API, thông tin cấu hình và các khóa bí mật phải được quản lý phù hợp; các bí mật thực sự không nên được nhúng trực tiếp vào ứng dụng di động vì người dùng có thể phân tích gói ứng dụng để tìm chúng.
17.5. CORS và Flutter
CORS là viết tắt của Cross-Origin Resource Sharing, một cơ chế do trình duyệt thực thi để kiểm soát những yêu cầu tài nguyên giữa các Origin khác nhau.
CORS thường ảnh hưởng đến Flutter Web vì ứng dụng chạy trong trình duyệt. Nếu Backend không cho phép Origin của ứng dụng, trình duyệt có thể chặn phản hồi hoặc yêu cầu theo chính sách CORS.
Đối với Flutter chạy trên Android hoặc iOS, cơ chế CORS của trình duyệt không được áp dụng theo cùng cách. Tuy nhiên, ứng dụng vẫn chịu ảnh hưởng của cấu hình mạng, chứng chỉ TLS, chính sách bảo mật nền tảng và những giới hạn khác.
Vì vậy, không nên mặc định mọi lỗi API đều do CORS. Cần xác định ứng dụng đang chạy trên nền tảng nào và kiểm tra thông báo lỗi tương ứng.
18. REST API trong kiến trúc Flutter hiện đại
18.1. Data Layer
Data Layer chịu trách nhiệm truy xuất và quản lý dữ liệu. Tầng này có thể bao gồm HTTP Client, Service, Repository, Model và những nguồn dữ liệu khác như bộ nhớ cục bộ.
Ví dụ, ProductService thực hiện yêu cầu HTTP, trong khi ProductRepository có thể quyết định lấy dữ liệu từ API hay bộ nhớ đệm cục bộ.
Cách tổ chức này giúp những phần còn lại của ứng dụng không phải phụ thuộc trực tiếp vào chi tiết thư viện HTTP.
18.2. Repository Pattern
Repository Pattern cung cấp một lớp trung gian để các thành phần của ứng dụng truy cập dữ liệu thông qua một giao diện thống nhất.
Ví dụ:
abstract class ProductRepository {
Future<List<Product>> getProducts();
Future<Product> getProductById(int id);
}
Lớp triển khai có thể sử dụng REST API:
class ApiProductRepository
implements ProductRepository {
final ProductService service;
ApiProductRepository(this.service);
@override
Future<List<Product>> getProducts() {
return service.getProducts();
}
@override
Future<Product> getProductById(int id) {
return service.getProductById(id);
}
}
Ví dụ này minh họa cách tách giao diện Repository khỏi cách lấy dữ liệu cụ thể. Trong dự án lớn, Repository còn có thể xử lý caching, ánh xạ lỗi và kết hợp nhiều nguồn dữ liệu.
Một lợi ích quan trọng của cách tiếp cận này là có thể thay đổi cách lấy dữ liệu mà không phải sửa toàn bộ giao diện. Ví dụ, ứng dụng có thể chuyển từ dữ liệu giả lập sang API thật bằng cách thay đổi phần triển khai Repository.
18.3. Kết hợp REST API với State Management
State Management quản lý trạng thái dữ liệu được sử dụng trong giao diện. Những thư viện như Provider, Riverpod và BLoC có thể kết hợp với Repository để tổ chức luồng dữ liệu rõ ràng hơn.
Ví dụ, khi người dùng mở màn hình danh sách sản phẩm:
-
Widget yêu cầu trạng thái sản phẩm.
-
State Management gọi Repository.
-
Repository lấy dữ liệu từ Service.
-
Service gửi HTTP Request.
-
Dữ liệu phản hồi được chuyển thành Model.
-
Repository trả kết quả.
-
State Management cập nhật trạng thái.
-
Widget hiển thị danh sách hoặc thông báo lỗi.
Khi sử dụng Riverpod, ứng dụng có thể khai báo Provider để quản lý việc truy xuất dữ liệu và tự động theo dõi những phụ thuộc cần thiết. Với BLoC, các sự kiện như LoadProducts có thể được chuyển thành những trạng thái như Loading, Loaded hoặc Error.
Điểm quan trọng là REST API chỉ cung cấp dữ liệu thông qua giao tiếp mạng. Nó không tự quản lý trạng thái giao diện Flutter. Việc tổ chức State Management giúp ứng dụng xử lý dữ liệu bất đồng bộ, làm mới giao diện và chia sẻ trạng thái giữa các màn hình một cách có hệ thống.
18.4. Phân biệt Service và Repository
Service thường tập trung vào việc thực hiện các thao tác giao tiếp cụ thể với API. Repository tập trung vào việc cung cấp dữ liệu cho các thành phần nghiệp vụ và có thể kết hợp nhiều nguồn dữ liệu.
Ví dụ:
-
ProductService: gửi yêu cầu GET đến/products. -
ProductRepository: quyết định dữ liệu được lấy từ API hay cache. -
ProductProvider: quản lý trạng thái tải sản phẩm. -
ProductScreen: hiển thị danh sách sản phẩm.
Không phải dự án nào cũng cần tách Service và Repository thành hai lớp riêng biệt. Với ứng dụng nhỏ, có thể kết hợp chúng để giảm độ phức tạp. Khi ứng dụng phát triển, việc phân tách trở nên hữu ích nếu mỗi lớp có trách nhiệm rõ ràng.
19. Các vấn đề thường gặp khi sử dụng REST API trong Flutter
19.1. Sai Base URL
Một lỗi phổ biến là cấu hình Base URL không đúng với địa chỉ Backend.
Ví dụ, ứng dụng Android chạy trên trình giả lập không phải lúc nào cũng truy cập được máy chủ phát triển thông qua localhost, vì địa chỉ này có thể trỏ đến chính thiết bị hoặc môi trường chạy ứng dụng.
Đối với Android Emulator, địa chỉ truy cập máy chủ phát triển trên máy tính thường sử dụng một địa chỉ đặc biệt như 10.0.2.2, tùy vào môi trường giả lập. Trên thiết bị thật, cần sử dụng địa chỉ IP có thể truy cập được của máy chủ trong mạng hoặc một địa chỉ máy chủ đã triển khai.
Ngoài ra, Base URL và đường dẫn Endpoint cần được ghép chính xác để tránh trường hợp lặp /api hoặc thiếu đường dẫn.
19.2. Sai kiểu dữ liệu JSON
Một lỗi khác là giả định dữ liệu luôn có kiểu giống như mong đợi.
Ví dụ, ứng dụng giả định price luôn là số nguyên nhưng Backend trả về số thập phân hoặc null. Việc ép kiểu trực tiếp có thể gây lỗi trong lúc chạy.
Cách xử lý phù hợp là xác định rõ hợp đồng dữ liệu, sử dụng Model có kiểu dữ liệu phù hợp và kiểm tra những trường có thể thiếu hoặc có giá trị null.
19.3. Xử lý lỗi không đầy đủ
Nếu chỉ xử lý trường hợp thành công mà bỏ qua lỗi mạng, Timeout hoặc phản hồi HTTP không thành công, ứng dụng có thể hiển thị màn hình trống hoặc phát sinh Exception không được xử lý.
Mỗi thao tác gọi API cần có chiến lược xử lý lỗi phù hợp. Với lỗi có thể phục hồi, ứng dụng nên cung cấp cách thử lại. Với lỗi nghiệp vụ, nên hiển thị thông báo dễ hiểu thay vì thông tin kỹ thuật khó diễn giải.
19.4. Gọi API lặp lại ngoài ý muốn
Nếu tạo yêu cầu HTTP trực tiếp trong build(), mỗi lần Widget được xây dựng lại có thể dẫn đến một yêu cầu mới.
Điều này có thể gây tăng lưu lượng mạng, tải dữ liệu nhiều lần và làm giao diện cập nhật không ổn định.
Để hạn chế vấn đề này, cần quản lý vòng đời yêu cầu phù hợp. Có thể khởi tạo Future ở initState(), sử dụng State Management hoặc dùng cơ chế caching và kiểm soát truy xuất dữ liệu theo từng tình huống.
19.5. Hiển thị dữ liệu cũ
Khi người dùng cập nhật hoặc xóa dữ liệu, giao diện có thể tiếp tục hiển thị kết quả cũ nếu ứng dụng không làm mới trạng thái sau khi thao tác thành công.
Ví dụ, sau khi xóa một sản phẩm, ứng dụng nên cập nhật danh sách hoặc tải lại dữ liệu từ API theo thiết kế hệ thống. Nếu có cache, cần đảm bảo cache được làm mới hoặc vô hiệu hóa phù hợp.
19.6. Quản lý nhiều yêu cầu đồng thời
Một màn hình có thể cần tải danh sách sản phẩm, danh mục và thông tin khuyến mãi cùng lúc. Nếu các yêu cầu không có quan hệ phụ thuộc, ứng dụng có thể thực hiện chúng đồng thời để giảm thời gian chờ tổng thể.
Dart cung cấp Future.wait() để chờ nhiều Future hoàn thành:
final results = await Future.wait([
dio.get('/products'),
dio.get('/categories'),
dio.get('/promotions'),
]);
final productsResponse = results[0];
final categoriesResponse = results[1];
final promotionsResponse = results[2];
Ví dụ trên phù hợp khi cả ba yêu cầu có thể chạy độc lập. Nếu một yêu cầu phụ thuộc kết quả của yêu cầu khác, cần thực hiện theo trình tự phù hợp.
Khi sử dụng Future.wait(), cũng cần xem xét cách xử lý khi một Future thất bại, cách hủy các yêu cầu còn lại và cách cập nhật từng phần giao diện nếu cần.
20. Kiểm thử REST API trong dự án Flutter
20.1. Kiểm thử Endpoint độc lập
Trước khi tích hợp API vào Flutter, lập trình viên có thể sử dụng công cụ như Postman hoặc Insomnia để gửi yêu cầu trực tiếp đến Backend.
Việc kiểm thử độc lập giúp xác định:
-
Endpoint có hoạt động hay không.
-
Phương thức HTTP có chính xác không.
-
Header và Body có đúng yêu cầu không.
-
Server trả về Status Code nào.
-
Dữ liệu JSON có đúng cấu trúc không.
-
API xác thực và phân quyền ra sao.
Nếu API không hoạt động khi kiểm thử độc lập, việc sửa giao diện Flutter thường không giải quyết được nguyên nhân gốc.
20.2. Unit Test cho Service và Repository
Unit Test có thể kiểm tra cách Service chuyển đổi dữ liệu, xử lý phản hồi hoặc ánh xạ lỗi. Để tránh phụ thuộc vào máy chủ thật, có thể giả lập HTTP Client hoặc sử dụng những cơ chế Mock phù hợp.
Ví dụ, có thể kiểm tra rằng khi API trả về một sản phẩm hợp lệ, Repository tạo ra đối tượng Product có mã, tên và giá chính xác. Đồng thời, cần kiểm tra những tình huống như JSON không hợp lệ, dữ liệu rỗng hoặc lỗi HTTP.
Việc kiểm thử này giúp phát hiện lỗi sớm và giảm phụ thuộc vào việc chạy ứng dụng thủ công.
20.3. Integration Test
Integration Test kiểm tra sự phối hợp giữa nhiều thành phần của ứng dụng. Ví dụ, kiểm tra quá trình mở màn hình sản phẩm, tải dữ liệu, hiển thị danh sách và xử lý lỗi.
Có thể sử dụng API giả lập hoặc môi trường Backend kiểm thử để kiểm tra luồng hoạt động. Không nên để kiểm thử phụ thuộc hoàn toàn vào một máy chủ bên ngoài thiếu ổn định nếu mục tiêu là đánh giá tính đúng đắn của mã nguồn.
20.4. Kiểm thử các tình huống bất thường
Ngoài trường hợp thành công, cần kiểm thử những tình huống như:
-
Thiết bị mất Internet.
-
Server phản hồi chậm.
-
API trả về 401 hoặc 403.
-
Endpoint không tồn tại.
-
Dữ liệu JSON thiếu trường.
-
Danh sách trả về rỗng.
-
Người dùng rời màn hình khi yêu cầu đang chạy.
-
Người dùng nhấn nút gửi nhiều lần.
-
Access Token hết hạn.
-
Yêu cầu tải tệp bị gián đoạn.
Kiểm thử những trường hợp này giúp ứng dụng hoạt động ổn định hơn trong điều kiện sử dụng thực tế.
21. Quy trình triển khai REST API trong dự án Flutter
Một quy trình triển khai có thể được tổ chức theo các bước sau.
Bước 1: Phân tích yêu cầu nghiệp vụ. Xác định dữ liệu cần lấy, những thao tác cần thực hiện và quyền truy cập của từng loại người dùng.
Bước 2: Xác định hợp đồng API. Thống nhất Endpoint, phương thức HTTP, Query Parameters, Path Parameters, Header, cấu trúc JSON và mã trạng thái phản hồi.
Bước 3: Kiểm thử Backend. Gửi yêu cầu bằng Postman hoặc công cụ tương tự để xác minh API hoạt động đúng trước khi tích hợp.
Bước 4: Cấu hình HTTP Client. Khởi tạo Dio hoặc HTTP Client với Base URL, Timeout và các Header mặc định phù hợp.
Bước 5: Xây dựng Model. Tạo những lớp Dart tương ứng với dữ liệu mà API trả về và hỗ trợ chuyển đổi JSON.
Bước 6: Xây dựng Service hoặc Repository. Tổ chức những hàm truy xuất dữ liệu, tạo mới, cập nhật và xóa tài nguyên.
Bước 7: Kết nối State Management. Quản lý trạng thái Loading, Success, Error và những trạng thái cần thiết khác.
Bước 8: Hiển thị dữ liệu trên giao diện. Sử dụng Widget phù hợp để trình bày dữ liệu và xử lý tương tác.
Bước 9: Hoàn thiện xử lý lỗi. Bổ sung thông báo lỗi, Retry có kiểm soát, xác thực Token và những cơ chế cần thiết.
Bước 10: Kiểm thử toàn bộ luồng. Kiểm tra trường hợp thành công, lỗi mạng, dữ liệu không hợp lệ và những tình huống phát sinh khi sử dụng thực tế.
Quy trình này giúp tránh việc viết giao diện trước rồi mới phát hiện API có cấu trúc khác với giả định ban đầu. Việc thống nhất hợp đồng API giữa Frontend và Backend đặc biệt quan trọng khi nhiều thành viên cùng phát triển một dự án.
22. Kết luận
REST API và HTTP là nền tảng quan trọng trong quá trình phát triển ứng dụng Flutter có khả năng giao tiếp với Backend. HTTP cung cấp cơ chế truyền tải yêu cầu và phản hồi, trong khi REST định hướng cách tổ chức API dựa trên tài nguyên và các quy tắc giao tiếp nhất quán.
Để tích hợp REST API hiệu quả, lập trình viên cần nắm vững phương thức HTTP, URL, Endpoint, Header, Body, Status Code và cấu trúc dữ liệu JSON. Bên cạnh đó, việc sử dụng Future, async, await và một HTTP Client như Dio giúp ứng dụng thực hiện các yêu cầu mạng mà không làm gián đoạn luồng giao diện.
Trong các dự án thực tế, REST API không nên được triển khai rải rác trực tiếp trong Widget. Việc tổ chức ApiClient, Service, Repository, Model và State Management giúp mã nguồn có cấu trúc rõ ràng, dễ kiểm thử và thuận tiện mở rộng. Những cơ chế như Interceptor, Timeout, Retry, Cancel Request, Pagination và caching cũng đóng vai trò quan trọng trong việc cải thiện hiệu năng và trải nghiệm người dùng.
Cuối cùng, bảo mật và xử lý lỗi là những yêu cầu không thể bỏ qua. Ứng dụng cần sử dụng HTTPS, quản lý Token phù hợp, kiểm tra dữ liệu, xử lý trạng thái phản hồi và không tin tưởng hoàn toàn vào dữ liệu từ Client. Backend phải chịu trách nhiệm xác thực, phân quyền và bảo vệ các nghiệp vụ quan trọng.
Khi hiểu rõ các nguyên tắc trên, lập trình viên có thể xây dựng những ứng dụng Flutter kết nối API một cách có tổ chức, ổn định và dễ bảo trì, đồng thời tạo nền tảng để phát triển những hệ thống lớn hơn như thương mại điện tử, quản lý người dùng, quản lý đơn hàng và các ứng dụng sử dụng dữ liệu thời gian thực.








Khoá học lập trình game con rắn cho trẻ em




