Skip to main content

2.11 — Mở rộng và đào sâu

Summary

Những chủ đề nối tiếp Module 2, kèm mức độ ưu tiên. Hai mục nên học sớm vì ảnh hưởng trực tiếp tới công việc backend hàng ngày: caching HTTP (biết ETag và Cache-Control giúp giảm tải rõ rệt mà gần như không tốn công) và container (mọi hệ thống hiện đại đều triển khai bằng Docker). Hai mục còn lại — HTTP/2, HTTP/3 và WebSocket — hữu ích để hiểu nhưng ít khi bạn phải cấu hình trực tiếp, vì reverse proxy hoặc framework đã lo phần lớn. Và một điểm hay bị hiểu nhầm: HTTP/2 không tự động làm ứng dụng nhanh hơn — nó giải quyết một vấn đề rất cụ thể, và với API trả JSON thì vấn đề đó thường không tồn tại.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Biết những chủ đề tiếp theo và thứ tự ưu tiên.
  • Hiểu HTTP/2 và HTTP/3 giải quyết vấn đề gì.
  • Dùng caching HTTP để giảm tải.
  • Biết khi nào cần WebSocket.

Nội dung bài học​

2.11.1 — Caching HTTP: học sớm​

Ưu tiên cao. Rẻ để làm, hiệu quả rõ ràng.

GET /api/products/123

HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "a3f8b2c1"
GET /api/products/123
If-None-Match: "a3f8b2c1"

HTTP/1.1 304 Not Modified <- KHÔNG có body, tiết kiệm băng thông

Hai cơ chế bổ sung nhau:

Cơ chếCách hoạt độngKhi nào dùng
Cache-ControlTrình duyệt không gửi request trong thời hạnDữ liệu ít đổi, biết trước thời hạn
ETagVẫn gửi request nhưng chỉ nhận 304 nếu không đổiDữ liệu có thể đổi bất cứ lúc nào

ETag tiết kiệm băng thông nhưng vẫn tốn một lượt đi về; Cache-Control tiết kiệm cả lượt đi về nhưng có rủi ro dữ liệu cũ.

Chỉ thị quan trọng nhất cần phân biệt:

Cache-Control: public          # CDN và proxy được phép cache
Cache-Control: private # CHỈ trình duyệt của người dùng
Cache-Control: no-store # KHÔNG được lưu ở đâu cả

Nhầm private thành public cho dữ liệu riêng của người dùng là lỗ hổng thật: CDN có thể phục vụ dữ liệu của người này cho người khác.

ASP.NET Core có sẵn output caching (bài 9.8).

2.11.2 — Container: học sớm​

Ưu tiên cao. Mọi hệ thống hiện đại triển khai bằng container.

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY ./publish .
ENTRYPOINT ["dotnet", "Crm.Api.dll"]

Container giải quyết một vấn đề rất cụ thể: "máy tôi chạy được mà máy anh không". Nó đóng gói ứng dụng cùng runtime và mọi phụ thuộc thành một ảnh chạy giống hệt nhau ở mọi nơi.

Ba khái niệm đủ để bắt đầu: image (bản đóng gói), container (một thực thể đang chạy của image), volume (nơi lưu dữ liệu bền, vì container mất dữ liệu khi bị xoá).

Đây là chủ đề của Module 15.

2.11.3 — HTTP/2 và HTTP/3​

Ưu tiên trung bình. Hiểu để biết, ít khi phải tự cấu hình.

Vấn đề HTTP/2 giải quyết:

HTTP/1.1: mỗi kết nối TCP xử lý MỘT request tại một thời điểm
-> trình duyệt mở 6 kết nối song song
-> tải trang có 50 file phải chờ theo đợt

HTTP/2: MỘT kết nối, NHIỀU request song song (multiplexing)
+ nén header
+ server đẩy trước tài nguyên

Điểm hay bị hiểu nhầm: lợi ích của HTTP/2 chủ yếu dành cho trang web có nhiều tài nguyên nhỏ (ảnh, CSS, JavaScript). Với một API trả về JSON theo từng request riêng lẻ, vấn đề "50 file phải chờ theo đợt" không tồn tại, nên cải thiện là không đáng kể.

HTTP/3 thay TCP bằng QUIC trên UDP, giải quyết vấn đề còn lại của HTTP/2: khi một gói tin bị mất, TCP chặn mọi luồng cho tới khi gói đó được gửi lại. QUIC chỉ chặn luồng bị ảnh hưởng — lợi ích rõ trên mạng di động hay mất gói.

Thực tế: bạn hiếm khi cấu hình trực tiếp. Reverse proxy hoặc CDN nói HTTP/2 với client và HTTP/1.1 với backend, và mọi thứ hoạt động.

gRPC dùng HTTP/2 làm nền, và đó là nơi bạn gặp nó rõ nhất (bài 18.5).

2.11.4 — WebSocket​

Ưu tiên trung bình. Cần khi có tính năng thời gian thực.

HTTP:       Client hỏi -> Server trả lời -> kết thúc
WebSocket: Kết nối MỞ liên tục, CẢ HAI bên gửi bất cứ lúc nào

Cần khi: chat, thông báo đẩy, bảng giá cập nhật liên tục, hiển thị ai đang online.

Không cần khi dữ liệu đổi vài phút một lần — khi đó gọi lại API định kỳ đơn giản hơn nhiều và không phải lo kết nối đứt, scale nhiều instance, hay xử lý kết nối lại.

Trên .NET, SignalR bọc WebSocket và tự chuyển sang cơ chế khác nếu WebSocket không dùng được (Module 11).

Cái bẫy khi scale: với nhiều instance, tin nhắn gửi từ instance A không tới client đang kết nối vào instance B, trừ khi có backplane (bài 11.11).

2.11.5 — Những chủ đề khác​

Chủ đềNội dungƯu tiên
IPv6Địa chỉ 128 bit, đang dần thay IPv4Thấp — hạ tầng lo
TLS 1.3Bắt tay nhanh hơn, bỏ thuật toán yếuThấp — bật mặc định
Load balancerPhân phối request cho nhiều serverTrung bình
CDNCache tài nguyên tĩnh gần người dùngTrung bình
DNS nâng caoBản ghi CNAME, A, MX, TXT; TTLTrung bình
gRPCGiao tiếp nhị phân trên HTTP/2Trung bình

Trong bảng này, DNS nâng cao là mục hay dùng bất ngờ: hiểu TTL giúp bạn biết vì sao đổi bản ghi DNS mà nửa số người dùng vẫn vào địa chỉ cũ trong vài giờ.

2.11.6 — Thứ tự nên học​

Ưu tiênChủ đềVì sao
CaoCaching HTTPRẻ, hiệu quả rõ, dùng hàng ngày
CaoContainer cơ bảnMọi hệ thống hiện đại đều dùng
Trung bìnhHTTP/2, load balancer, DNSHiểu để chẩn đoán, ít khi tự cấu hình
Trung bìnhWebSocketChỉ cần khi có tính năng thời gian thực
ThấpIPv6, TLS chi tiếtHạ tầng lo, mặc định đã đúng

Sau Module 2, bước tiếp theo là Module 3 — Git và Developer Workflow.

2.11.7 — Rà lại code của bạn​

Danh sách rà soát sau Module 2

  • •Phân biệt được Cache-Control public, private và no-store.
  • •Dữ liệu riêng của người dùng không bao giờ dùng Cache-Control public.
  • •Biết ETag tiết kiệm băng thông nhưng vẫn tốn một lượt đi về.
  • •Hiểu ba khái niệm image, container và volume.
  • •Biết HTTP/2 không tự động làm API nhanh hơn.
  • •Chỉ dùng WebSocket khi thật sự cần thời gian thực.
  • •Biết SignalR nhiều instance cần backplane.

Bài tập áp dụng​

Bài 1 — Thử ETag​

Gọi một endpoint hai lần, lần hai kèm If-None-Match, và xác nhận nhận được 304 không có body.

Tiêu chí hoàn thành: bạn thấy 304 và body rỗng, và giải thích được ETag tiết kiệm cái gì — cũng như không tiết kiệm cái gì.

Gợi ý và lời giải — Bài 1

Gợi ý. curl -D - in ra header phản hồi, đủ để thấy toàn bộ cơ chế mà không cần trình duyệt.

Lời giải — endpoint có ETag:

var noiDung = """{"id":8842,"hoTen":"Nguyễn Văn An","trangThai":"DangXuLy"}""";
var etag = "\"" + Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(noiDung)))[..16].ToLowerInvariant() + "\"";

app.MapGet("/api/leads/8842", (HttpContext ctx) =>
{
var gui = ctx.Request.Headers.IfNoneMatch.ToString();

ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "private, max-age=60";

if (gui == etag)
return Results.StatusCode(StatusCodes.Status304NotModified);

return Results.Content(noiDung, "application/json");
});
curl -s -D - -o /dev/null http://127.0.0.1:5288/api/leads/8842

Chạy trên ASP.NET Core 9 (.NET 9.0.203):

HTTP/1.1 200 OK
Content-Length: 58
Content-Type: application/json
Server: Kestrel
Cache-Control: private, max-age=60
ETag: "861c1e9392a9c077"
curl -s -D - -o /dev/null -H 'If-None-Match: "861c1e9392a9c077"' \
http://127.0.0.1:5288/api/leads/8842
HTTP/1.1 304 Not Modified
Server: Kestrel
Cache-Control: private, max-age=60
ETag: "861c1e9392a9c077"
Kích thước body lần 1: 58 byte
Kích thước body lần 2: 0 byte
Và phản hồi 304 KHÔNG có Content-Length — vì không có gì để gửi.

ETag tiết kiệm cái gì:

Băng thông:  toàn bộ body không được gửi
-> với một danh sách JSON 200 KB, tiết kiệm gần như trọn vẹn
Thời gian: ít byte đi qua mạng -> phản hồi về nhanh hơn
Pin và dữ liệu di động: đáng kể với ứng dụng điện thoại

Và đây là phần quan trọng hơn — ETag KHÔNG tiết kiệm cái gì:

Vẫn có một vòng khứ hồi mạng đầy đủ
-> độ trễ mạng không giảm chút nào

Máy chủ VẪN phải tính ra ETag
-> nếu ETag được tính từ nội dung, máy chủ phải DỰNG nội dung đó trước
-> truy vấn database vẫn chạy
-> chỉ khâu gửi đi là được bỏ qua
// Kiểu này KHÔNG giảm tải cho database chút nào
var lead = await _db.Leads.FindAsync(id, ct); // vẫn truy vấn
var etag = TinhEtag(lead); // vẫn tuần tự hoá
if (ctx.Request.Headers.IfNoneMatch == etag)
return Results.StatusCode(304); // chỉ tiết kiệm băng thông

Muốn tiết kiệm cả công việc của máy chủ, ETag phải tính được mà KHÔNG dựng nội dung:

// Lấy MỘT cột thay vì cả bản ghi
var phienBan = await _db.Leads
.Where(l => l.Id == id)
.Select(l => new { l.NgayCapNhat, l.RowVersion })
.FirstOrDefaultAsync(ct);

if (phienBan is null) return Results.NotFound();

var etag = $"\"{Convert.ToHexString(phienBan.RowVersion)}\"";
if (ctx.Request.Headers.IfNoneMatch == etag)
return Results.StatusCode(304); // chưa dựng DTO, chưa tuần tự hoá

var lead = await _db.Leads.FindAsync(id, ct); // chỉ dựng khi thật sự cần
Truy vấn một cột RowVersion: rất rẻ
So với: nạp cả entity, ánh xạ sang DTO, tuần tự hoá JSON

Hai kiểu ETag, và cách chọn:

KiểuCách tínhDùng khi
Mạnh — "abc123"Băm nội dung, hoặc RowVersionByte phải giống hệt nhau
Yếu — W/"abc123"Theo phiên bản logicNội dung "tương đương về mặt ngữ nghĩa" là đủ
ETag yếu hữu ích khi phản hồi có phần thay đổi mà không quan trọng,
ví dụ một dấu thời gian sinh ra lúc trả về.

Và một cạm bẫy cần tránh: Cache-Control: private so với public.

ctx.Response.Headers.CacheControl = "private, max-age=60";
private -> chỉ trình duyệt của người dùng được lưu
public -> proxy và CDN trung gian cũng được lưu

Với dữ liệu riêng của từng người dùng, dùng public là một lỗ hổng:
một proxy có thể trả dữ liệu của người A cho người B.

Với API trả dữ liệu theo người dùng, mặc định nên là private — và nếu có phân quyền theo tenant, thêm cả Vary: Authorization để bộ nhớ đệm không trộn lẫn phản hồi của hai token khác nhau.


Bài 2 — So sánh giao thức​

Mở DevTools tab Network, cột Protocol, và xem trang nào dùng h2 hay h3.

Tiêu chí hoàn thành: bạn quan sát được cả ba phiên bản, và giải thích được vì sao HTTP/2 không giúp gì cho một API chỉ gọi một request.

Gợi ý và lời giải — Bài 2

Gợi ý. Cột Protocol không hiện mặc định — bấm chuột phải vào hàng tiêu đề của bảng để bật nó.

Lời giải — quan sát trong DevTools:

Chrome DevTools -> Network -> chuột phải hàng tiêu đề -> tích "Protocol"
Name                    Protocol   Size      Time
------------------ -------- ------- -------
index.html h2 12,4 kB 142 ms
main.css h2 8,1 kB 18 ms
app.js h2 284,0 kB 96 ms
api/leads?page=1 h2 4,2 kB 68 ms
fonts/inter.woff2 h3 38,0 kB 24 ms
tracking.js http/1.1 14,2 kB 180 ms

Kiểm tra bằng dòng lệnh, không cần trình duyệt:

curl -sI --http2 https://example.com | head -1
curl -sI --http3 https://cloudflare.com | head -1
HTTP/2 200
HTTP/3 200

Ba phiên bản khác nhau ở đâu:

HTTP/1.1HTTP/2HTTP/3
Tầng vận chuyểnTCPTCPQUIC trên UDP
Nhiều request trên một kết nốiKhông (xếp hàng)CóCó
Đầu dòng bị chặnỞ tầng HTTPỞ tầng TCPKhông có
Nén headerKhôngHPACKQPACK
Đổi mạng giữa chừngMất kết nốiMất kết nốiGiữ được

Vì sao HTTP/2 không giúp gì cho một API chỉ gọi một request:

Lợi ích chính của HTTP/2 là ghép nhiều request vào MỘT kết nối.
Một request -> không có gì để ghép.

Thời gian của một lời gọi API:
bắt tay TCP + TLS ~40 ms (giống nhau ở cả hai)
gửi request ~1 ms
máy chủ xử lý ~150 ms (phần lớn thời gian)
nhận phản hồi ~5 ms

-> HTTP/2 tiết kiệm được vài byte header. Không đáng kể.
HTTP/2 có ích rõ khi: một trang tải 40–80 tài nguyên
-> HTTP/1.1 mở 6 kết nối và xếp hàng
-> HTTP/2 dùng một kết nối cho tất cả

Và điều HTTP/3 giải quyết mà HTTP/2 không giải quyết được — đầu dòng bị chặn ở tầng TCP:

HTTP/2: nhiều luồng trên MỘT kết nối TCP
-> một gói tin TCP bị mất -> TCP dừng MỌI luồng để chờ gửi lại
-> ba request còn lại vẫn phải đợi, dù dữ liệu của chúng đã tới

HTTP/3: mỗi luồng độc lập trong QUIC
-> mất gói của luồng 1 -> chỉ luồng 1 chờ
Khác biệt này gần như không thấy được trên mạng dây ổn định.
Nó rõ rệt trên mạng di động, nơi tỉ lệ mất gói cao hơn hẳn.

Bật HTTP/3 trong ASP.NET Core 9:

builder.WebHost.ConfigureKestrel(o =>
{
o.ListenAnyIP(443, l =>
{
l.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
l.UseHttps();
});
});
// Báo cho trình duyệt biết có HTTP/3 — nếu không, nó không tự thử
app.Use(async (ctx, next) =>
{
ctx.Response.Headers.AltSvc = "h3=\":443\"; ma=86400";
await next();
});
Header Alt-Svc là bắt buộc: request ĐẦU TIÊN luôn đi qua TCP,
trình duyệt chỉ chuyển sang HTTP/3 cho những request sau.

Và một điều cần biết trước khi bật: HTTP/3 dùng UDP cổng 443.

Nhiều tường lửa doanh nghiệp chặn UDP 443
-> trình duyệt thử HTTP/3, thất bại, rồi quay về HTTP/2
-> thêm độ trễ cho lần thử đầu

Nên bật HTTP/3 là một cải thiện có điều kiện, không phải mặc định tốt hơn.

Với phần lớn API backend, thứ tự ưu tiên đúng là: giảm số request, giảm kích thước phản hồi, rồi mới tới chọn phiên bản giao thức. Một endpoint trả 200 KB JSON không cần thiết sẽ chậm trên mọi phiên bản.


Bài 3 — Dựng container​

Đóng gói một ứng dụng .NET nhỏ thành image và chạy nó bằng Docker.

Tiêu chí hoàn thành: container chạy được và trả lời request, và bạn giải thích được vì sao Dockerfile cần nhiều tầng thay vì một.

Gợi ý và lời giải — Bài 3

Gợi ý. Thứ tự các lệnh trong Dockerfile quyết định lần build thứ hai mất 3 giây hay 3 phút.

Lời giải — Dockerfile nhiều giai đoạn:

# Giai đoạn 1: build — có SDK, nặng khoảng 800 MB
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Sao chép RIÊNG file project trước, rồi restore
COPY ["Crm.Api/Crm.Api.csproj", "Crm.Api/"]
RUN dotnet restore "Crm.Api/Crm.Api.csproj"

# Rồi mới sao chép toàn bộ mã nguồn
COPY . .
RUN dotnet publish "Crm.Api/Crm.Api.csproj" -c Release -o /app/publish --no-restore

# Giai đoạn 2: chạy — chỉ có runtime, nhẹ hơn nhiều
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

# Không chạy bằng root
USER $APP_UID

EXPOSE 8080
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
docker build -t crm-api:thu-nghiem .
docker run --rm -p 8080:8080 crm-api:thu-nghiem
curl -s http://localhost:8080/health/live

Vì sao cần nhiều tầng — hai lý do khác nhau:

1. Nhiều GIAI ĐOẠN (FROM ... AS) để giảm kích thước image.

Image chỉ dùng SDK:      khoảng 800 MB
Image dùng aspnet runtime: khoảng 220 MB

Image cuối KHÔNG cần: trình biên dịch, NuGet, mã nguồn, tệp trung gian
-> chỉ cần thư mục publish
Và đây không chỉ là chuyện dung lượng:
- image nhỏ hơn -> deploy nhanh hơn, khởi động pod nhanh hơn
- ít phần mềm hơn -> ít lỗ hổng bảo mật hơn
- không có mã nguồn trong image -> không rò rỉ nếu image bị lộ

2. Nhiều TẦNG (mỗi lệnh COPY, RUN) để tận dụng bộ nhớ đệm build.

# SAI — sửa một dòng code cũng phải restore lại toàn bộ gói
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# ĐÚNG — restore chỉ chạy lại khi file .csproj đổi
COPY ["Crm.Api/Crm.Api.csproj", "Crm.Api/"]
RUN dotnet restore "Crm.Api/Crm.Api.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
Docker lưu đệm theo tầng. Một tầng đổi -> mọi tầng SAU nó phải chạy lại.

File .csproj đổi vài tuần một lần.
Mã nguồn đổi vài chục lần mỗi ngày.

-> đặt phần ít đổi TRƯỚC phần hay đổi
-> lần build thứ hai: restore lấy từ đệm, chỉ publish chạy lại

Đây là thứ tự chung cho mọi Dockerfile, không riêng .NET: ít thay đổi ở trên, hay thay đổi ở dưới.

Bốn lỗi thường gặp:

1. Không có .dockerignore.

# .dockerignore
bin/
obj/
.git/
**/node_modules
*.user
Không có nó: COPY . . sao chép cả bin, obj và .git
-> ngữ cảnh build phình từ vài MB lên hàng trăm MB
-> chậm, và tệ hơn: bin/obj của máy bạn có thể ghi đè kết quả build trong container

2. Chạy bằng root.

USER $APP_UID
Image .NET chính thức có sẵn biến APP_UID (người dùng không đặc quyền).
Chạy bằng root nghĩa là: nếu ứng dụng bị chiếm quyền, kẻ tấn công có root
trong container — và từ đó dễ đi xa hơn.

3. Ghim thẻ không đủ cụ thể.

FROM mcr.microsoft.com/dotnet/aspnet:9.0          # thay đổi theo thời gian
FROM mcr.microsoft.com/dotnet/aspnet:9.0.4-noble # cụ thể hơn, lặp lại được

4. Quên đặt cổng cho Kestrel.

Image .NET 8 trở lên mặc định lắng nghe cổng 8080 (không phải 80),
vì người dùng không đặc quyền không mở được cổng dưới 1024.

-> EXPOSE 8080, và map đúng cổng đó khi chạy

Kiểm chứng image đã gọn chưa:

docker images crm-api:thu-nghiem --format "{{.Size}}"
docker history crm-api:thu-nghiem
docker history cho thấy từng tầng chiếm bao nhiêu
-> một tầng bất thường lớn thường là dấu hiệu quên .dockerignore,
hoặc sao chép nhầm thư mục

Nối với mục 2.11.2 ở trên: container giải quyết vấn đề "chạy trên máy tôi thì được" bằng cách đóng gói cả môi trường, không chỉ ứng dụng. Nhưng nó chỉ giữ được lời hứa đó khi image thật sự tự chứa — nên mọi phụ thuộc bên ngoài (chuỗi kết nối, biến môi trường, tệp cấu hình) phải được truyền vào lúc chạy, không nướng vào image.

Tự kiểm tra​

Frequently asked questions

Cache-Control và ETag khác nhau thế nào?

Cache-Control cho trình duyệt không gửi request trong thời hạn nên tiết kiệm cả lượt đi về, nhưng có rủi ro dữ liệu cũ. ETag vẫn gửi request nhưng chỉ nhận 304 không có body nếu dữ liệu chưa đổi, nên tiết kiệm băng thông mà luôn đúng.

Vì sao nhầm private thành public là lỗ hổng?

Vì public cho phép CDN và proxy lưu phản hồi, nên dữ liệu riêng của một người dùng có thể được phục vụ cho người khác. Dữ liệu cá nhân phải dùng private hoặc no-store.

Vì sao HTTP/2 không tự động làm API nhanh hơn?

Vì lợi ích chính của nó là multiplexing nhiều request trên một kết nối, giải quyết vấn đề trang web có nhiều tài nguyên nhỏ. Với API trả JSON theo từng request riêng lẻ, vấn đề đó không tồn tại.

HTTP/3 giải quyết vấn đề gì của HTTP/2?

Khi một gói tin TCP bị mất, HTTP/2 bị chặn mọi luồng cho tới khi gói đó được gửi lại. HTTP/3 dùng QUIC trên UDP nên chỉ luồng bị ảnh hưởng phải chờ, lợi ích rõ trên mạng di động hay mất gói.

Khi nào không cần WebSocket?

Khi dữ liệu chỉ đổi vài phút một lần. Gọi lại API định kỳ đơn giản hơn nhiều và không phải lo kết nối đứt, scale nhiều instance hay xử lý kết nối lại.

Vì sao hiểu TTL của DNS lại hữu ích?

Vì nó giải thích tại sao sau khi đổi bản ghi DNS, một phần người dùng vẫn vào địa chỉ cũ trong vài giờ: các resolver còn giữ giá trị cũ cho tới khi hết TTL.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Caching HTTP và container nên học sớm — cả hai dùng hàng ngày.
  2. HTTP/2 giải một vấn đề cụ thể, và API trả JSON thường không có vấn đề đó.
  3. WebSocket chỉ cần khi thật sự cần thời gian thực. Gọi lại định kỳ đơn giản hơn nhiều.

Tham khảo​

Điều hướng​