Sử dụng union và phân cấp đóng trong ASP.NET Core

Đôi khi một hợp đồng API quy định rằng một giá trị có thể có nhiều kiểu JSON khác nhau. Kubernetes có một ví dụ thực tế: maxUnavailable có thể là một số tuyệt đối, chẳng hạn 2, hoặc một tỷ lệ phần trăm, chẳng hạn "25%". Hãy hình dung một endpoint ASP.NET Core cung cấp cùng hợp đồng đó:

public union IntOrString(int, string);

app.MapGet("/deployments/{name}/max-unavailable", IntOrString (string name) => Deployments.GetMaxUnavailable(name));

Tùy theo deployment, phản hồi có thể là 2 hoặc "25%".

IntOrString sử dụng khai báo union gốc được giới thiệu trong C# 15 và .NET 11. Một union là một kiểu có tên, đại diện cho một giá trị thuộc danh sách kiểu trường hợp cố định. Union này chấp nhận int hoặc string, nhưng không chấp nhận bool, DateTime hay bất kỳ kiểu nào khác. Các trường hợp của union không bị giới hạn ở những lớp trong cùng một phân cấp; chúng có thể bao gồm kiểu nguyên thủy, lớp, giao diện và kiểu nullable.

Mỗi trường hợp được chuyển trực tiếp thành union mà không cần ép kiểu:

IntOrString absolute = 2;
IntOrString percentage = "25%";

Bạn cũng có thể dùng câu lệnh hoặc biểu thức switch để xử lý trường hợp đang hoạt động. Cơ chế pattern matching thông thường của C# vẫn hoạt động:

static string Describe(IntOrString value) => value switch
{
    int count => $"{count} pods",
    string percentage => percentage,
};

Lưu ý rằng không có nhánh dự phòng như _ => .... Trình biên dịch biết tất cả trường hợp được phép nên có thể kiểm tra switch đã xử lý đầy đủ hay chưa. Nếu thêm một trường hợp mới vào IntOrString, những biểu thức switch hiện có chưa xử lý trường hợp đó sẽ phát sinh cảnh báo.

Ví dụ, giả sử thêm trường hợp string vào một union trước đây chỉ chứa bool và decimal, nhưng bộ định dạng chưa được cập nhật:

public union SettingValue(bool, decimal, string);

static string Describe(SettingValue value) => value switch
{
    bool enabled => enabled ? "enabled" : "disabled",
    decimal number => number.ToString(),
    // Thiếu trường hợp string
};

Trình biên dịch sẽ xác định trường hợp bị thiếu:

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'string' is not covered.

Phản hồi này xuất hiện ở mọi nơi union được xử lý. Vì vậy, việc thêm một trường hợp không thể âm thầm khiến một switch hiện có trở nên thiếu sót. Đây là một lợi ích chính của union so với object hoặc một phân cấp mở.

Trước khi có union gốc, lập trình viên thường dùng object, một kiểu cơ sở chung hoặc một lớp bao tùy chỉnh. object chấp nhận quá nhiều kiểu, còn kiểu cơ sở không thể gom những kiểu không liên quan như int và string. Lớp bao tùy chỉnh có thể giới hạn tập kiểu, nhưng cũng cần API riêng để khởi tạo và đối sánh. Union gốc giữ hợp đồng ngay trong chữ ký phương thức, hoạt động với pattern thông thường của C# và được System.Text.Json hỗ trợ tích hợp.

Các cách khác để mô hình hóa những lựa chọn thay thế

Trước khi chọn union, cần phân biệt union với hai tính năng liên quan.

Đa hình

Đa hình thông thường của C# mô hình hóa các kiểu liên quan thông qua kế thừa. Ví dụ, Circle và Square có thể kế thừa từ lớp cơ sở Shape dùng chung thành viên và hành vi.

System.Text.Json hỗ trợ tuần tự hóa một phân cấp như vậy bằng bộ phân biệt kiểu. Đánh dấu kiểu cơ sở bằng [JsonPolymorphic] và đăng ký từng kiểu dẫn xuất được hỗ trợ bằng [JsonDerivedType]. JSON kết quả xác định rõ kiểu đang hoạt động, chẳng hạn:

{"$type":"circle","radius":5}

System.Text.Json luôn làm việc với tập kiểu đã đăng ký rõ ràng; nó không tự động bao gồm mọi kiểu có thể kế thừa từ kiểu cơ sở trong tương lai. Nếu lớp cơ sở C# vẫn mở, ngôn ngữ không thực thi cùng một tập kiểu và switch trên lớp đó cần có nhánh dự phòng.

Phân cấp đóng

C# 15 bổ sung hỗ trợ cho phân cấp lớp đóng. Thêm từ khóa closed vào lớp cơ sở sẽ ngăn các assembly khác kế thừa trực tiếp từ lớp đó. Khi ấy, trình biên dịch có thể xem các kiểu dẫn xuất đã biết là danh sách đầy đủ và kiểm tra tính bao quát của switch:

public closed record class PaymentEvent(string PaymentId);

public sealed record class PaymentInitiated(string PaymentId) : PaymentEvent(PaymentId);
public sealed record class PaymentAuthorized(string PaymentId, decimal Amount) : PaymentEvent(PaymentId);
public sealed record class PaymentFailed(string PaymentId, string Reason) : PaymentEvent(PaymentId);

Phân cấp đóng có tính chất giống union vì nó đại diện cho một tập lựa chọn đã biết. Tuy nhiên, trong C#, đây vẫn là một phân cấp kế thừa: các trường hợp kế thừa từ lớp cơ sở và có thể dùng chung thành viên, hành vi. Khi kiểm soát được phân cấp, closed thực thi tập kiểu cố định ở cấp ngôn ngữ và cho phép System.Text.Json suy ra các kiểu dẫn xuất thay vì phải đăng ký từng kiểu.

Bộ bổ nghĩa closed chỉ ảnh hưởng đến quan hệ kiểu trong C#; tự nó không thay đổi JSON. Có thể bật hành vi đa hình khi cần bằng [JsonPolymorphic(InferClosedTypePolymorphism = true)].

Chọn mô hình cho API

Union không phải lúc nào cũng là lựa chọn tốt nhất khi API có nhiều hình dạng dữ liệu. Hãy bắt đầu bằng việc xem bạn có kiểm soát các kiểu trường hợp và hợp đồng JSON hay không.

Khi thiết kế API mới và tất cả lựa chọn đều là các lớp do bạn kiểm soát, hãy ưu tiên phân cấp đóng đi kèm bộ phân biệt JSON. Ví dụ, PaymentInitiated, PaymentAuthorized và PaymentFailed có thể kế thừa từ PaymentEvent. Bộ phân biệt giúp JSON tự mô tả, còn closed cho phép trình biên dịch kiểm tra mọi sự kiện đã biết đều được xử lý.

Chỉ giữ lớp cơ sở ở trạng thái mở khi mô hình thực sự cần mở rộng, chẳng hạn một phân cấp hiện có được thiết kế để các assembly khác bổ sung kiểu. System.Text.Json vẫn yêu cầu đăng ký rõ ràng mọi kiểu dẫn xuất được hỗ trợ, và bên gọi cần nhánh dự phòng vì trình biên dịch không thể xem một phân cấp mở là đầy đủ.

Hãy chọn union khi cần duy trì một hợp đồng đã tồn tại, không có bộ phân biệt, hoặc khi các trường hợp không thể kế thừa từ cùng một lớp cơ sở. Trường hợp này bao gồm kiểu nguyên thủy và những kiểu hiện có mà bạn không kiểm soát. Union giữ nguyên hình dạng JSON riêng của từng trường hợp, đồng thời cung cấp một tập kiểu cố định để bên gọi xử lý.

Định dạng không có bộ phân biệt vừa là lợi ích vừa là đánh đổi. Ghi trường hợp đang hoạt động khá đơn giản. Khi đọc dữ liệu, hai trường hợp có thể trông giống nhau trong JSON và cần được phân loại rõ ràng. Với một hợp đồng đa hình mới do bạn kiểm soát, phân cấp đóng kèm bộ phân biệt sẽ tránh được sự mơ hồ này.

Cũng cần cân nhắc cách hợp đồng sẽ phát triển. Việc thêm trường hợp union hoặc kiểu dẫn xuất vào một phân cấp đóng có thể tạo cảnh báo trong các biểu thức switch đầy đủ hiện có, ngay lập tức cho bên gọi biết cần xử lý thêm điều gì. Phân cấp mở tránh sự ràng buộc này bằng cách yêu cầu nhánh dự phòng ngay từ đầu.

Hỗ trợ union trong ASP.NET Core đến từ System.Text.Json. Vì vậy, union hoạt động ở những nơi ASP.NET Core sử dụng System.Text.Json: thân request và response JSON, giao thức JsonHubProtocol của SignalR, cùng cơ chế gọi JavaScript, trạng thái thành phần được lưu và tham số thành phần prerender của Blazor. Union không được hỗ trợ cho chuỗi truy vấn, giá trị route, header hoặc trường biểu mẫu.

Tuần tự hóa và giải tuần tự hóa union

Một union được khai báo bằng từ khóa union cùng danh sách kiểu trường hợp:

public union UnionIntString(int, string);
public union UnionBoolString(bool, string);
public union UnionNullableIntString(int?, string);

public record Cat(string Name, string Coat);
public record Dog(string Name, string Breed);
public union UnionPet(Cat, Dog);

System.Text.Json tuần tự hóa giá trị union thành trường hợp đang hoạt động, không thêm thông tin đại diện cho bản thân union. Lớp bao union được tháo ra và chỉ trường hợp hiện tại được ghi bằng hợp đồng JSON riêng của trường hợp đó. Không có đối tượng bao ngoài, trường $type hay bộ phân biệt:

JsonSerializer.Serialize(new UnionIntString(42));      // 42
JsonSerializer.Serialize(new UnionIntString("hello")); // "hello"
JsonSerializer.Serialize(new UnionPet(new Cat("Whiskers", "Tabby"))); // { "name": "Whiskers", "coat": "Tabby" }

System.Text.Json có thể tự chọn trường hợp union khi các trường hợp dùng kiểu JSON khác nhau. Ví dụ, UnionBoolString(bool, string) không mơ hồ vì giá trị Boolean JSON ánh xạ tới bool, còn chuỗi JSON ánh xạ tới string.

Khi nhiều trường hợp có thể khớp cùng một giá trị JSON, System.Text.Json cần trợ giúp để chọn một trường hợp. UnionPet(Cat, Dog) mơ hồ vì cả hai trường hợp đều là đối tượng JSON. Nếu payload tuân theo hợp đồng không có bộ phân biệt và không thể thay đổi, bộ phân loại cấu trúc tích hợp có thể phân biệt các đối tượng dựa trên tên thuộc tính:

[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union UnionPet(Cat, Dog);

Đánh đổi khi phân loại theo cấu trúc

Bộ phân loại cấu trúc phải quét đối tượng JSON trước khi System.Text.Json có thể giải tuần tự hóa. Vì vậy, việc phân loại tạo thêm công việc tỷ lệ với kích thước payload. Quyết định của nó phụ thuộc vào tên thuộc tính của từng trường hợp; thay đổi hình dạng dữ liệu có thể làm thay đổi cách payload hiện có được phân loại hoặc khiến chúng trở nên mơ hồ. Với hợp đồng mới do bạn kiểm soát, nên ưu tiên phân cấp đóng có bộ phân biệt.

Nếu các trường hợp không thể phân biệt bằng cấu trúc, một kịch bản nâng cao có thể cung cấp JsonTypeClassifier tùy chỉnh.

Endpoint IntOrString ở phần đầu là ví dụ cho dữ liệu đầu ra. Khi union này được dùng làm thân request HTTP, thiết lập JSON cho web cũng cho phép đọc số từ chuỗi, nên một chuỗi JSON có thể khớp với cả hai trường hợp. Giải tuần tự hóa hợp đồng đó cần cơ chế phân loại tùy chỉnh rõ ràng.

Tuần tự hóa và giải tuần tự hóa phân cấp đóng

Bộ bổ nghĩa closed không bắt buộc phải dùng tuần tự hóa đa hình. Khi mã sử dụng một kiểu dẫn xuất cụ thể, System.Text.Json tuần tự hóa và giải tuần tự hóa kiểu đó như mọi kiểu khác, không thêm bộ phân biệt:

var json = JsonSerializer.Serialize(new PaymentAuthorized("p-123", 42.5m), JsonSerializerOptions.Web);
var payment = JsonSerializer.Deserialize<PaymentAuthorized>(json, JsonSerializerOptions.Web);
{"paymentId":"p-123","amount":42.5}

Vòng lặp này hoạt động vì cả bên ghi và bên đọc đều sử dụng kiểu cụ thể PaymentAuthorized. Nếu API sử dụng kiểu cơ sở PaymentEvent, JSON cần xác định kiểu dẫn xuất cần tạo. Có thể bật tuần tự hóa đa hình trên kiểu cơ sở đóng:

[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record class PaymentEvent(string PaymentId);

Khi đó, System.Text.Json suy ra các kiểu dẫn xuất trong phân cấp đóng và sử dụng tên kiểu làm bộ phân biệt. Endpoint có thể giải tuần tự hóa request PaymentEvent rồi tuần tự hóa lại kiểu dẫn xuất đang hoạt động:

app.MapPost("/payment-event", (PaymentEvent paymentEvent) => paymentEvent);
{"$type":"PaymentAuthorized","paymentId":"p-123","amount":42.5}

Cũng có thể bật tùy chọn này trong pipeline JSON bằng JsonSerializerOptions.InferClosedTypePolymorphism. Khi đã cấu hình, các phân cấp đóng hoạt động qua cùng những đường dẫn dựa trên System.Text.Json của Minimal API, MVC, SignalR và Blazor.

Minimal API

Union hoạt động với tham số thân request và kiểu trả về, cả trong đường dẫn thời gian chạy thông qua RequestDelegateFactory lẫn bộ sinh mã Request Delegate Generator. Hành vi của hai đường dẫn là giống nhau.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// UnionBoolString không mơ hồ nên liên kết mà không cần bộ phân loại.
app.MapPost("/flag", (UnionBoolString flag) => flag);

// Hai trường hợp của UnionPet đều là đối tượng nên dùng bộ phân loại tích hợp.
app.MapPost("/pet", ([FromBody] UnionPet pet) => TypedResults.Ok(pet));

// Chỉ trường hợp đang hoạt động được tuần tự hóa.
app.MapGet("/value", () => new UnionIntString(42));
app.MapGet("/pet", () => new UnionPet(new Cat("Whiskers", "Tabby")));

app.Run();

Union kết hợp với các kiểu trả về quen thuộc của Minimal API. Union có thể được trả về bất đồng bộ, bao trong kiểu nullable hoặc trả về thông qua TypedResults:

app.MapGet("/maybe", () => new UnionNullableIntString((int?)null));
app.MapGet("/typed", () => TypedResults.Ok(new UnionPet(new Cat("Whiskers", "Tabby"))));

Union cũng có thể là thuộc tính của một mô hình khác, một phần tử được phát trực tuyến từ IAsyncEnumerable<T> hoặc vị trí thân request trong một vùng chứa [AsParameters]. Việc tuần tự hóa union cũng tôn trọng các tùy chọn được cấu hình qua ConfigureHttpJsonOptions.

Controller MVC

Union đi qua các formatter đầu vào và đầu ra của System.Text.Json. Vì vậy, controller hỗ trợ union dưới dạng tham số action và kiểu trả về, bao gồm kết quả Task<TUnion> và ValueTask<TUnion>:

[ApiController]
[Route("[controller]/[action]")]
[Produces("application/json")]
public class PetsController : ControllerBase
{
    [HttpPost]
    public UnionBoolString Echo([FromBody] UnionBoolString value) => value;

    [HttpGet("{kind}")]
    public UnionIntString Primitive(string kind) => kind switch
    {
        "value" => new UnionIntString(42),
        _ => new UnionIntString("hi"),
    };
}

Việc tuần tự hóa và giải tuần tự hóa union tuân theo các quy tắc đã nêu ở trên. Controller sử dụng JsonSerializerDefaults.Web giống Minimal API, vì vậy lưu ý về đầu vào IntOrString cũng áp dụng cho action của controller.

SignalR

JsonHubProtocol chuyển việc đọc và ghi cho System.Text.Json. Vì vậy, union hoạt động với tham số phương thức hub, giá trị trả về và phần tử stream mà không cần cấu hình thêm:

public class ChatHub : Hub
{
    // Tham số union từ client tới server.
    public Task Send(UnionIntString message) => Clients.All.SendAsync("Receive", message);

    // Giá trị union từ server tới client.
    public UnionPet GetPet() => new UnionPet(new Cat("Whiskers", "Tabby"));

    // Phần tử union trong stream từ server tới client.
    public async IAsyncEnumerable<UnionIntString> Stream()
    {
        yield return 1;
        yield return "two";
    }
}

Ở đường dẫn đọc, thuộc tính Type của tham số, giá trị trả về hoặc phần tử stream được bộ liên kết lời gọi xác định sẽ điều khiển bộ chuyển đổi union, bao gồm cả bộ phân loại được khai báo bằng [JsonUnion].

Khác với liên kết JSON HTTP trong Minimal API và MVC, JsonHubProtocol không xem token chuỗi JSON là mơ hồ đối với các trường hợp số. Vì vậy, union như UnionIntString(int, string) có thể tuần tự hóa và giải tuần tự hóa cả trường hợp int lẫn string mà không cần bộ phân loại. Những union có các trường hợp cùng dùng token StartObject, chẳng hạn UnionPet(Cat, Dog), vẫn mơ hồ khi đọc và cần bộ phân loại.

Union chỉ được hỗ trợ với JsonHubProtocol. Giao thức hub MessagePack và Newtonsoft.Json không hỗ trợ union vì bộ tuần tự hóa bên dưới của chúng không có hỗ trợ tương ứng.

Blazor

Blazor làm việc với union theo hai cách, tùy việc giá trị chỉ nằm trong tiến trình hay đi qua ranh giới tuần tự hóa. Tham số thành phần trong cùng tiến trình được gán trực tiếp và không cần tuần tự hóa. Trong khi đó, gọi JavaScript, trạng thái thành phần được lưu và tham số prerender được tuần tự hóa bằng System.Text.Json, tuân theo các quy tắc đã nêu.

Tham số thành phần

Tham số thành phần được gán trực tiếp khi thành phần được render từ cú pháp Razor hoặc thông qua RenderTreeBuilder.AddComponentParameter. Vì render trong cùng tiến trình không tuần tự hóa tham số, tham số union hoạt động mà không cần cấu hình thêm:

<PetCard Pet="@(new UnionPet(new Cat("Whiskers", "Tabby")))" />
public class PetCard : ComponentBase
{
    [Parameter]
    public UnionPet Pet { get; set; }
}

Gọi JavaScript

Gọi JavaScript thông qua IJSRuntime tuần tự hóa đối số và giá trị trả về bằng System.Text.Json. Điều này hữu ích khi API JavaScript đã chấp nhận một hợp đồng có hình dạng union. Ví dụ, Element.scrollIntoView chấp nhận dạng viết tắt Boolean để căn chỉnh hoặc một đối tượng tùy chọn:

public sealed record ScrollIntoViewOptions(string Behavior, string Block, string Inline);

public union ScrollIntoViewArgument(bool, ScrollIntoViewOptions);

private ValueTask ScrollAsync(
    ElementReference element,
    ScrollIntoViewArgument argument) =>
    JS.InvokeVoidAsync("scrollElementIntoView", element, argument);

await ScrollAsync(target, false);
await ScrollAsync(target, new ScrollIntoViewOptions("instant", "start", "nearest"));
window.scrollElementIntoView = (element, argument) =>
    element.scrollIntoView(argument);

Trường hợp union đang hoạt động được tuần tự hóa bằng biểu diễn mà JavaScript mong đợi: một Boolean JSON hoặc một đối tượng tùy chọn. Không có lớp bao union hay bộ phân biệt nào được thêm vào.

Trạng thái thành phần được lưu

PersistentComponentState tuần tự hóa trạng thái bằng System.Text.Json. Do đó, union có thể đi qua vòng tuần tự hóa và giải tuần tự hóa bằng PersistAsJson và TryTakeFromJson, kể cả union có trường hợp đang hoạt động là null.

Prerender

Khi một thành phần được prerender bằng Blazor Server hoặc Blazor WebAssembly, các tham số được tuần tự hóa vào marker của thành phần bằng System.Text.Json và được giải tuần tự hóa khi thành phần khởi tạo ở client. Union được hỗ trợ qua ranh giới này, kể cả union có trường hợp được tuần tự hóa thành JSON null, chẳng hạn UnionNullableIntString đang chứa int? bằng null.

OpenAPI

Tài liệu OpenAPI biểu diễn một union bằng lược đồ anyOf, với một mục cho mỗi kiểu trường hợp:

"UnionIntString": {
  "anyOf": [
    { "type": "integer", "format": "int32" },
    { "type": "string" }
  ]
},
"UnionPet": {
  "anyOf": [
    { "$ref": "#/components/schemas/Cat" },
    { "$ref": "#/components/schemas/Dog" }
  ]
}

Vì một trường hợp union không có bộ phân biệt và có cấu trúc giống hệt kiểu độc lập, mỗi lược đồ trường hợp sử dụng lại tên component độc lập. Các lược đồ Cat và Dog được UnionPet tham chiếu chính là những component mà endpoint độc lập của Cat hoặc Dog tạo ra. Điều này khác với kiểu đa hình, trong đó lược đồ dẫn xuất được đưa vào các component có tên tiền tố vì chúng mang bộ phân biệt $type.

Một endpoint cũng có thể tạo nhiều kiểu response cho cùng mã trạng thái và kiểu nội dung. Không gian tên Microsoft.AspNetCore.Mvc.ApiExplorer giữ lại mọi kiểu response đã khai báo, còn tài liệu được sinh ra sẽ phát hành lược đồ anyOf khi nhiều kiểu dùng chung một kiểu nội dung:

public record Tyrannosaurus(string Name, double BiteForceNewtons);
public record Triceratops(string Name, int HornCount);
public record Velociraptor(string Name, double TopSpeedKmh);
public union UnionDinosaur(Tyrannosaurus, Triceratops, Velociraptor);

app.MapGet("/any-of", () => Results.Ok())
    .Produces<UnionPet>(StatusCodes.Status200OK, "application/json")
    .Produces<UnionDinosaur>(StatusCodes.Status200OK, "application/json");

Hỗ trợ tương tự cũng áp dụng cho MVC controller khai báo nhiều thuộc tính ProducesResponseType cho cùng một mã trạng thái và kiểu nội dung.

Giới hạn

Hỗ trợ union yêu cầu System.Text.Json. Những nguồn liên kết không đi qua System.Text.Json không hỗ trợ union:

  • Giá trị chuỗi truy vấn
  • Giá trị route
  • Giá trị header
  • Trường biểu mẫu

Các nguồn này liên kết một token chuỗi với kiểu đích mà không phân tích JSON, nên không có cách đáng tin cậy để chọn trường hợp union. Một giá trị truy vấn như ?id=42 không cho biết cần liên kết thành int, string, Guid hay trường hợp khác. Vì sự mơ hồ này, union được chủ ý không hỗ trợ ở các nguồn liên kết trên.

Trong Blazor, giới hạn tương tự áp dụng cho tham số thành phần đến từ các nguồn không phải thân request, bao gồm [SupplyParameterFromQuery] và liên kết biểu mẫu với [SupplyParameterFromForm]. Các nguồn này liên kết giá trị chuỗi hoặc biểu mẫu mà không phân tích JSON nên không hỗ trợ union.

Việc liên kết tham số union từ các nguồn không phải thân request vẫn đang được nghiên cứu. Nếu có kịch bản cần tính năng này, bạn có thể gửi phản hồi cho nhóm phát triển thông qua vấn đề liên quan đến liên kết tham số union trên kho mã ASP.NET Core.

Tóm tắt

Hai ví dụ mở đầu cho thấy cách lựa chọn. maxUnavailable của Kubernetes phù hợp với union vì đây là một hợp đồng hiện có, không có bộ phân biệt, chấp nhận int hoặc string. PaymentEvent phù hợp với phân cấp đóng vì các trường hợp tạo thành một nhóm liên quan và JSON có thể xác định từng sự kiện bằng bộ phân biệt.

Cả hai mô hình đều cho phép trình biên dịch kiểm tra các biểu thức switch đã xử lý đầy đủ. Hãy lựa chọn dựa trên mối quan hệ giữa các phương án thay thế và hợp đồng JSON mà bạn cần duy trì.

Tài nguyên bổ sung