翻译 - ASP.NET Core 模型绑定

翻译自 Model Binding in ASP.NET Core | Microsoft Docs

什么是模型绑定?

控制器和 Razor 页面处理来自 HTTP 请求的数据。例如

public class Pet
{
    public string Name { get; set; } = null!;

    [FromQuery] // Attribute is ignored.
    public string Breed { get; set; } = null!;
}

 

,路由数据可能提供了一条记录的键值,提交的表单字段里可能提供了模型的属性值。编写代码获取每一个值,并把它们从字符串转换为 .NET 类型是繁琐和容易出错的。模型绑定会自动处理这些过程。模型绑定系统:

  • 从不同的源获取数据,例如路由数据,表单字段和查询字符串
  • 为控制器和 Razor 页面的方法参数和公共属性提供数据
  • 转换字符串数据为 .NET 类型数据
  • 更新复杂类型的属性

示例

假设你有以下的操作方法:

[HttpGet("{id}")]
public ActionResult<Pet> GetById(int id, bool dogsOnly)

应用程序接收到了使用以下这样一个 URL 的请求:

https://contoso.com/api/pets/2?DogsOnly=true

模型绑定在路由系统选择了操作方法之后会经过以下步骤:

  • 查找 GetById 方法的第一个参数,名称为 id 的整数
  • 查询 HTTP 请求中可用的源,在路由数据中查找到 id = "2"
  • 转换字符串 "2" 为整数 2
  • 查找 GetById 的下一个参数,名称为 dogsOnly 的布尔类型的值
  • 查询在查询字符串源中并查找到 "DogsOnly=true"。名称匹配不是大小写敏感的
  • 转换字符串 "true" 为布尔 true

然后框架会调用 GetById 方法,为参数 id 赋值 2,为参数 dogsOnly 赋值 true。

上面这个例子中,模型绑定的目标是简单类型的方法参数。目标也可以是复杂类型的属性。在每一个属性绑定成功后,会为每个属性进行模型验证(model validation)。哪些数据被绑定到了模型,以及所有绑定或者验证出现的错误都被存储在 ControllerBase.ModelState 或者 PageModel.ModelState。为了检查这个过程是否成功,应用程序会检查 ModelState.IsValid 标记。

目标

模型绑定试图为以下几类目标查找值:

  • 一个请求路由到的,控制器操作方法的参数
  • 一个请求路由到的,Razors 页面处理方法的参数
  • 通过属性指定的控制器或者 PageModel 类的公开属性

[BindProperty] 属性

可以被应用到一个控制器或者 PageModel 类的一个公开属性上,以把模型绑定在这个属性上起作用:

public class EditModel : PageModel
{
    [BindProperty]
    public Instructor? Instructor { get; set; }
}

[BindProperties] attribute

可以被应用到控制器或者 PageModel 类来告诉模型绑定应用到类的所有公开属性上:

[BindProperties]
public class CreateModel : PageModel
{
    public Instructor? Instructor { get; set; }

    // ...
}

HTTP GET 请求的模型绑定

默认情况下,属性不会绑定到 HTTP GET 请求上。通常,一个 GET 请求的所有需求是一条记录的 ID 参数。记录的 ID 用来在数据库中查询项目。因此,没有必要绑定持有一个模型实例的属性。在你想要绑定来自 HTTP GET 青丘的数据的情况下,设置 SupportsGet 属性为 true:

[BindProperty(Name = "ai_user"), SupportsGet = true]
public string? ApplicationInsigthsCooks { get; set; }

默认情况下,模型绑定以键值对的形式从一个 HTTP 请求中下列源中获取数据:

  1. 表单字段
  2. 请求体(对于有 [ApiController] 属性的控制器)
  3. 路由数据
  4. 查询字符串参数
  5. 上传的文件

对于每一个目标参数或者属性,将会按照上面列表显示的顺序扫描所有源。有以下几种异常:

  • 路由数据和查询字符串的值仅仅能为简单类型使用
  • 上传的文件仅仅能够绑定到实现了 IFormFile 或者 IEnumerable<IFormFile> 的目标类型

如果默认的源不正确,使用下列的其中的属性来指定源:

这些属性:

  • 单独被添加到模型属性上,而不是模型类上,就像下面的示例:
    public class Instructor
    {
        public int Id { get; set; }
    
        [FromQuery(Name = "Note")]
        public string? NoteFromQueryString { get; set; }
    
        // ...
    }

     

  • 在构造方法中接受一个可选模型名称值。这个选项在属性名称不匹配请求中的值的时候被提供。举个例子,请求中的值可能是一个名称带有横杠的头部,就像下面这个例子:
public void OnGet([FromHeader(Name = "Accept-Language")] string language)

[FromBody] 属性

将 [FromBody] 属性应用到参数上,以从 HTTP 请求体中获取数据来填充它的属性。ASP.NET Core 运行时通过代理负责读取请求体到一个输入格式化中。输入格式化在后面的文章中解释(later in this article)。

当 [FromBody] 被应用到一个复杂类型的参数时,任何应用到它的属性的绑定源属性都被会被忽略。例如,下面的 Create 方法指定了它的 pet 参数从请求体中填充:

public ActionResult<Pet> Create([FromBody] Pet pet)

Pet 类指定了它的 Breed 属性从一个查询字符串中填充值:

public class Pet
{
    public string Name { get; set; } = null!;

    [FromQuery] // Attribute is ignored.
    public string Breed { get; set; } = null!;
}

上面的示例中:

  • [FromQuery] 属性被忽略
  • Breed 属性不会从查询字符串的参数中填充属性值

输入格式化器只会读取请求体,并不会去理会绑定源属性。如果一个合适的值在请求体中被发现,它的值将会用来填充 Breed 属性的值。

对于一个操作方法,不要把 [FromBody] 应用与多个参数。一旦通过输入格式化器读取过请求流,将不在能为应用了 [FromBody] 参数绑定。

其它源

源数据通过值提供器为模型绑定系统提供数据。你可以编写和注册自定义的值提供器,用来从其它数据源为模型绑定获取数据。例如,你可能想从 cookies 或者 会话状态获取数据。从一个新的源获取数据:

  • 创建一个实现 IValueProvider 的类
  • 创建一个实现 IValueProviderFactory 的类
  • 在 Program.cs 中注册工厂类

例子包含了一个 value provider 和 factory, 展示了从 cookies 中获取值。在 Program.cs 中注册了自定义的值提供器工厂方法:

builder.Services.AddControllers(options =>
{
    options.ValueProviderFactories.Add(new CookieValueProviderFactory());
});

上面的代码把自定义的值提供器放在了所有内置值提供器的之后。要实现它处于列表的最前面,调用 Insert(0, new CookieValueProviderFactory()) 替换 Add 即可。

模型属性没有源

默认情况下,一个模型的状态错误不会创建,如果模型属性的值没有被找到。属性值被设置为 null 或者一个默认的值:

  • 可空的简单类型被设置为 null
  • 可空的值类型被设置为 default(T)。例如,一个 int 类型的参数 id 被设置为 0
  • 对于复杂类型,模型绑定使用默认的构造方法创建一个实例,不会设置实例属性的值
  • 数组被设置为 Array.Empty<T>(),除了 byte[] 数组会被设置为 null

对于一个模型属性,在没有任何值在表单字段中被找到,如果要单独设置模型状态,可以使用 [BindRequired] 属性。

注意,只有从提交的表单中获取数据时,[BindRequired] 属性的行为才被应用到模型绑定,而从请求体中的 JSON 或者 XML 数据则不会。请求体的数据通过 input formatters 处理。

类型转换错误

如果一个源被发现,但是不能够被转换为目标类型,模型状态就被标记为无效。如前一节所述,目标参数或者属性被设置为 null 或者默认值。

在一个拥有 [ApiController] 属性的 API 控制器中,无效的模型状态将会产生一个自动的 HTTP 400 响应。

在一个 Razor 页面中,会重新展示带有一个错误信息的页面:

public IActionResult OnPost()
{
     if (!ModelState.IsValid)
     {
         return Page();
    }  

    // ...
    return RedirectToPage("./Index");
}

当页面通过前面的代码重新显示时,无效的输入没有在表单字段中显示。这是由于模型的属性已经被设置为 null 或者默认值。无效的输入不会出现在一个错误信息中。如果你想要在表单字段中重新显示错误的数据,可以考虑将模型属性设置为字符串,并手动进行数据转换。

如果你不想类型转换错误导致模型状态错误,建议使用相同的策略。在这种情况下,设置模型属性为字符串。

简单类型

模型绑定器可以将源字符串转换为以下简单类型:

复杂类型

一个复杂类型必须有一个公开默认的构造方法和公开的可写的属性去绑定。当模型绑定发生时,将会使用公开默认的构造方法实例化类。

对于复杂类型的每一个属性,模型绑定将在源中查找名称模式(model binding looks through the sources for the name pattern) prefix.property_name。如果什么都没有找到,它只查找没有前缀的 property_name。使用查询的决定不是根据每个属性做出的。例如,使用包含 ?Instructor.Id=100&Name=foo 的查询字符串,绑定到方法 OnGet(Instructor instructor),产生的类型 Instructor 的对象包含:

  •  Id 被设置为 100
  • Name 被设置为 null。模型绑定期望 Instructor.Name,因为 Instructor.Id 在前面的查询参数中被使用

对于绑定到一个参数,前缀是参数的名称。对于绑定到一个 PageModel 公开属性,前缀就是公开属性的名称。有些属性有一个 Prefix 属性,该属性允许你覆盖参数或者属性名称的默认用法。

例如,假设一个复杂类型是以下的 Instructor 类:

public class Instructor
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstName { get; set; }
}

Prefix = parameter name

如果要绑定的模型是一个名称为 instructorToUpdate 的参数:

public IActionResult OnPost(int? id, Instructor instructorToUpdate)

模型绑定开始在源中为键 instructorToUpdate.ID 查找值。如果没有找到,它将为没有前缀的键 ID 查找。

Prefix = property name

如果要绑定的模型是控制器或者 PageModel 类的一个名称为 Instructor 的属性:

[BindProperty]
public Instructor Instructor { get; set; }

模型绑定开始在源中为键 Instructor.ID 查找值。如果没有找到,它将为没有前缀的键 ID 查找。

自定义前缀

如果要绑定的模型是一个名称为 instructorToUpdate 的参数,并且绑定属性指定 Instructor 作为前缀:

public IActionResult OnPost(
    int? id, [Bind(Prefix = "Instructor")] Instructor instructorToUpdate)

模型绑定开始在源中为键 Instructor.ID 查找值。如果没有找到,它将查找没有前缀的 ID。

复杂类型目标属性

几个内置的属性可以用来为控制器绑定复杂类型:

警告

当提交的表单数据作为值的来源时,这些属性将会影响模型绑定。当处理通过 JSON 和 XML 请求体提交的数据时,这些属性不会产生影响。输入格式化器会在之后的文章中讲到。

[Bind] 属性

可以应用到一个类或者一个方法参数。用来指定模型的那些属性应该被包含在模型绑定中。[Bind] 不会影响输入格式化器。

在下面的例子中,当任何处理或者操作方法被调用时,只有被指定的 Instructor 模型的属性会被绑定:

[Bind("LastName,FirstMidName,HireDate")]
public class Instructor

在下面的例子中,当 OnPost 方法被调用时,只有被指定的 Instructor 模型的属性会被绑定:

[HttpPost]
public IActionResult OnPost(
    [Bind("LastName,FirstMidName,HireDate")] Instructor instructor)

[Bind] 属性可用于在创建场景中防止重复发布。它在编辑场景中工作的不是很好,因为被排除的属性被设置为 null 或者一个默认值,而不是保持不变。对于防止重复发布,建议使用视图模型而不是 [Bind] 属性。更多信息,查看 Security note about overposting

[ModelBinder] 属性

ModelBinderAttribute 可以被应用到类型,属性或者参数。它允许指定用于绑定特定实例或类型的模型绑定器的类型。例如:

[HttpPost]
public IActionResult OnPost(
    [ModelBinder(typeof(MyInstructorModelBinder))] Instructor instructor)

[ModelBinder] 属性在它是模型绑定时,也可以用于改变属性或者参数的名称:

public class Instructor
{
    [ModelBinder(Name = "instructor_id")]
    public string Id { get; set; }

    // ...
}

[BindRequired] 属性

仅仅可以应用到模型属性,不能应用到方法参数。如果一个模型的属性绑定不成功,将会产生一个添加模型状态的错误。例如:

public class InstructorBindRequired
{
    // ...

    [BindRequired]
    public DateTime HireDate { get; set; }
}

在 Model validation 中查看有关 [Required] 属性的讨论。

[BindNever] 属性

可以应用到属性或类型。阻止模型绑定设置模型的属性值。当应用到一个类型时,模型绑定系统将排除类型定义的所有属性。例如:

public class InstructorBindNever
{
    [BindNever]
    public int Id { get; set; }

    // ...
}

集合

对于目标是简单类型的情况,模型绑定查找匹配 parameter_name 或 property_name。如果没有匹配,它将查找一种支持的没有前缀的格式。例如:

  • 假设要绑定的参数是一个名称为 selectedCourses 的数组
    public IActionResult OnPost(int? id, int[] selectedCourses)
  • 表单或查询字符串数据可以是以下一种格式:
    selectedCourses=1050&selectedCourses=2000
    selectedCourses[0]=1050&selectedCourses[1]=2000
    [0]=1050&[1]=2000
    selectedCourses[a]=1050&selectedCourses[b]=2000&selectedCourses.index=a&selectedCourses.index=b
    [a]=1050&[b]=2000&index=a&index=b

    避免绑定一个名称为 index 或 Index 的参数或属性,如果该参数或属性与集合值相邻。模型绑定试图使用 index 作为集合的索引,这会导致不正确的绑定。例如,考虑一下方法:

    public IActionResult Post(string index, List<Product> products)
    在上面的代码中,index 查询参数绑定到了方法参数 index,同时也被用作绑定 product 集合。重命名 index 参数或使用模型绑定属性配置绑定来避免这个问题:
    public IActionResult Post(string productIndex, List<Product> products)
    selectedCourses[]=1050&selectedCourses[]=2000
  • 对于前面所有的示例格式,模型绑定传递了包含两个项目的数组给 selectedCaourses 参数:

  selectedCourses[0]=1050

      selectedCourses[1]=2000

使用下标数字的数据格式必须保证它们是从0开始的数字序列。如果下标编号有空格,则忽略空格后的所有项目。例如,如果下面使用 0 和 2 ,而不是 0 和 1,则第二个项目会被忽略。

字典

对于字典目标,模型绑定查找匹配 parameter_name 或 property_name 的目标。如果没有发现目标,它将查找一种受支持的没有前缀的格式。例如:

  • 假设目标参数是一个名称为 selectedCourses 的字典 Dictionary<int, string>:
    public IActionResult OnPost(int? id, Dictionary<int, string> selectedCourses)
  • 提交的表单或查询字符串数据可能看起来是下列示例中的一种:
    selectedCourses[1050]=Chemistry&selectedCourses[2000]=Economics
    [1050]=Chemistry&selectedCourses[2000]=Economics
    selectedCourses[0].Key=1050&selectedCourses[0].Value=Chemistry&
    selectedCourses[1].Key=2000&selectedCourses[1].Value=Economics
    [0].Key=1050&[0].Value=Chemistry&[1].Key=2000&[1].Value=Economics

    对于上面所有示例的格式,模型绑定传递了一个包含两个项目的字典到参数 selectedCourses:

    selectedCourses["1050"]="Chemistry"

            selectedCourses["2000"]="Economics"

构造方法绑定和记录类型

复杂类型的模型绑定要求有一个无参的构造方法。基于 System.Text.Json 和 Newtsoft.Json 的输入格式化器支持对没有无参构造方法的类的反序列化。

记录类型是一种简要的代表在网络上的数据的好方式。ASP.NET Core 支持模型绑定和使用单个构造方法验证记录类型:

public record Person(
    [Required] string Name, [Range(0, 150)] int Age, [BindNever] int Id);

public class PersonController
{
    public IActionResult Index() => View();

    [HttpPost]
    public IActionResult Index(Person person)
    {
        // ...
    }
}

Person/Index.cshtml:

@model Person

Name: <input asp-for="Name" />
<br />
Age: <input asp-for="Age" />

在验证记录类型时,运行时会在参数上搜索绑定的和验证元数据,而不是在属性上。

框架允许绑定和验证记录数据:

public record Person([Required] string Name, [Range(0, 100)] int Age);

要想前面的代码能够工作,类型必须:

  • 是一个记录类型
  • 只有一个公共构造方法
  • 包含具有相同名称和类型属性的参数。名称必须不能因为大小写而不同

没有无参构造方法的 POCOs

没有无参构造方法的 POCOs 不能被绑定。

下面的代码会导致异常,因为类型必须有一个无参构造方法:

public class Person(string Name)

public record Person([Required] string Name, [Range(0, 100)] int Age)
{
    public Person(string Name) : this (Name, 0);
}

手动创建构造方法的记录类型

使用手动构造方法的记录类型就像主构造方法一样工作:

public record Person
{
    public Person([Required] string Name, [Range(0, 100)] int Age)
        => (this.Name, this.Age) = (Name, Age);

    public string Name { get; set; }
    public int Age { get; set; }
}

记录类型,验证和绑定元数据

对于记录类型,参数上验证和绑定的元数据被使用。任何属性上的元数据都被忽略。

public record Person (string Name, int Age)
{
   [BindProperty(Name = "SomeName")] // This does not get used
   [Required] // This does not get used
   public string Name { get; init; }
}

验证和元数据

验证使用参数上的元数据,但是使用属性读取值。使用主构造方法的一般情况下,这两者是相同的。然而,在一些情况下并不是这样的:

public record Person([Required] string Name)
{
    private readonly string _name;

    // The following property is never null.
    // However this object could have been constructed as "new Person(null)".
    public string Name { get; init => _name = value ?? string.Empty; }
}

TryUpdateModel 并不会更新记录类型的参数

public record Person(string Name)
{
    public int Age { get; set; }
}

var person = new Person("initial-name");
TryUpdateModel(person, ...);

在这个例子中,MVC 不会再次绑定 Name。然而,Age 是允许被更新的。

模型绑定路由数据和查询字符串的国际化行为

  • 认为值是和区域没有关系的
  • 期望 URLs 是和区域没有关系的

相反,来自表单数据的值要经过文化敏感的转换。这是为了让 URL 可以跨地区而设计的。

为了使 ASP.NET Core 路由值提供器和查询字符串值提供器能够实现文化敏感的转换:

public class CultureQueryStringValueProviderFactory : IValueProviderFactory
{
    public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
    {
        _ = context ?? throw new ArgumentNullException(nameof(context));

        var query = context.ActionContext.HttpContext.Request.Query;
        if (query?.Count > 0)
        {
            context.ValueProviders.Add(
                new QueryStringValueProvider(
                    BindingSource.Query,
                    query,
                    CultureInfo.CurrentCulture));
        }

        return Task.CompletedTask;
    }
}
builder.Services.AddControllers(options =>
{
    var index = options.ValueProviderFactories.IndexOf(
        options.ValueProviderFactories.OfType<QueryStringValueProviderFactory>()
            .Single());

    options.ValueProviderFactories[index] =
        new CultureQueryStringValueProviderFactory();
});

特殊数据类型

有一些模型绑定可以处理的特殊数据类型

IFormFile 和 IFormFileCollection

HTTP 请求中的上传的文件。同时也支持多个文件的类型是 IEnumerable<IFormFile>。

CancellationToken

操作可以选择性的绑定一个 CancellationToken 作为一个参数。这绑定的是一个当 HTTP 请求下的连接断开时的一个信号 RequestAborted。操作可以使用这个参数来取消作为控制器操作一部分的长时间异步运行的操作。

FormCollection

用来获取提交的表单数据中的所有值。

输入格式器

请求体中的数据可能是 JSON,XML,或者其他格式。为了解析这些数据,模型绑定会用 input formatter 来配置处理一个特定的内容类型。默认的,ASP.NET Core 包含基于 JSON 的输入格式器来处理 JSON 数据。你可以添加其他格式器来处理其它内容类型。

ASP.NET Core 基于 Consumes 属性来选择输入格式化器。如果没有指定属性,它将使用  Content-Type header

使用内置的 XML 输入格式化器:

使用输入格式化器自定义模型绑定

输入格式化器负责从请求体中读取数据。为了自定义这个过程,需要配置输入格式化器使用的 APIs。这部分描述了如何自定义一个基于 System.Text.Json 的输入格式化器,用来理解一个名称为 ObjectId 的自定义类型。

考虑下面的模型,包含了一个名称为 Id ,类型为自定义类型 ObjectId 的属性:

public class InstructorObjectId
{
    [Required]
    public ObjectId ObjectId { get; set; } = null!;
}

在使用 System.Text.Json 时自定义模型绑定的过程,创建一个继承自 JsonConverter<T> 的类:

internal class ObjectIdConverter : JsonConverter<ObjectId>
{
    public override ObjectId Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => new(JsonSerializer.Deserialize<int>(ref reader, options));

    public override void Write(
        Utf8JsonWriter writer, ObjectId value, JsonSerializerOptions options)
        => writer.WriteNumberValue(value.Id);
}

要使用一个自定义的转换,应用 JsonConverterAttribute 到类型。在下面的例子中,类型 ObjectId 类型使用了 ObjectIdConverter 作为自定义的转换器:

[JsonConverter(typeof(ObjectIdConverter))]
public record ObjectId(int Id);

更多信息,查看 How to write custom converters

从模型绑定中排除指定的类型

模型绑定和验证系统的行为由 ModelMetadata 驱动。你可以通过往 MvcOptions.ModelMetadataDetailsProviders 中添加一个详细的提供器来自定义 ModelMetadata。内置的提供器可以用来为指定类型禁用模型绑定或者验证。

要想禁用一个指定类型所有模型的模型绑定,在 Program.cs 中添加一个 ExcludeBindingMetadataProvider。例如,为了禁用类型 System.Version 的模型绑定:

builder.Services.AddRazorPages()
    .AddMvcOptions(options =>
    {
        options.ModelMetadataDetailsProviders.Add(
            new ExcludeBindingMetadataProvider(typeof(Version)));
        options.ModelMetadataDetailsProviders.Add(
            new SuppressChildValidationMetadataProvider(typeof(Guid)));
    });

要想禁用指定类型的属性的验证,在 Program.cs 中添加一个 SuppressChildValidationMetadataProvider。例如,禁用 System.Guid 类型的属性验证:

builder.Services.AddRazorPages()
    .AddMvcOptions(options =>
    {
        options.ModelMetadataDetailsProviders.Add(
            new ExcludeBindingMetadataProvider(typeof(Version)));
        options.ModelMetadataDetailsProviders.Add(
            new SuppressChildValidationMetadataProvider(typeof(Guid)));
    });

自定义模型绑定器

你可以通过编写一个自定义的模型绑定器,然后使用 [ModelBinder] 属性为一个给定的目标选择它来扩展模型绑定。更多信息,查看 custom model binding

手动模型绑定

模型绑定可以通过使用 TryUpdateModelAsync 方法手动的调用。这个方法在 ControllerBase 和 PageModel 类中都有定义。方法重载允许你指定前缀和值提供器来使用。如果模型绑定失败,方法返回 false。这里有一个例子:

if (await TryUpdateModelAsync(
    newInstructor,
    "Instructor",
    x => x.Name, x => x.HireDate!))
{
    _instructorStore.Add(newInstructor);
    return RedirectToPage("./Index");
}

return Page();

TryUpdateModelAsync 使用了值提供器从请求体,查询字符串和路由数据中获取数据。TryUpdateModelAsync 通常的:

  • 在使用控制器和视图的 Razor Pages 及 MVC 应用程序中来阻止重复提交
  • 不与 web API 一起使用,除非从表单数据,查询字符串和路由数据中获取数据。Web API 中使用 JSON 的重点使用 Input formatters 来解析请求体到一个对象中

更多信息,查看 TryUpdateModelAsync

[FromServices] 属性

该属性的名称遵循指定数据源的模型绑定的属性。但是并不是关于来自值提供器的绑定的数据。它从依赖注入(dependency injection)容器中获取一个类型的实例。它的目的是只在为一个特定的方法被调用你需要一个服务时为构造注入提供一个替代方法。

如果一个实例的类型没有在依赖注入容器中注册,应用程序将会在试图绑定参数的时候抛出一个异常。要使参数为可选的,可以使用下列的方法:

  • 使参数为可空
  • 为参数设置一个默认值

对于可空参数,保证在访问它之前不为 null。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章