.NET 6 預覽版 7 發佈——最後一個預覽版

原文:bit.ly/2VJxjxQ
作者:Richard
翻譯:精緻碼農-王亮
說明:文中有大量的超鏈接,這些鏈接在公衆號文章中被自動剔除,一部分包含超鏈接列表的小段落被我刪減了,如果你對此感興趣,請參考閱讀原文。

我們很高興地發佈了 .NET 6 預覽版 7。這是我們進入(兩個)候選發佈版(RC)之前的最後一個預覽版。在我們放慢發佈速度之前,團隊一直在螢窗雪案,以完成最後一組功能。在這個版本中,你將看到各功能的最後一次拋光,一次到位地整合整個版本的大型功能。從此時起,團隊將專注於使所有的功能達到統一的(高)質量,以便 .NET 6 爲你的生產工作做好準備。

關於生產工作的話題,值得提醒大家的是,.NET 官網[1]和 Bing.com 從預覽版 1 開始就一直運行在 .NET 6 上。我們正在與不同的團隊(微軟和其他公司)商談有關進入生產的 .NET 6 RC 版本。如果你對此感興趣,並希望得到相關的指導,請聯繫 [email protected]。我們始終很樂意與早期採用者交流。

你可以在這下載[2] Linux、macOS 和 Windows 的 .NET 6 預覽版 7。

- 安裝程序和二進制文件
  https://dotnet.microsoft.com/download/dotnet/6.0
- 容器鏡像
  https://hub.docker.com/_/microsoft-dotnet
- Linux 包
  https://github.com/dotnet/core/blob/main/release-notes/6.0/install-linux.md
- 發佈說明
  https://github.com/dotnet/core/blob/main/release-notes/6.0/README.md
- API 差異
  https://github.com/dotnet/core/tree/main/release-notes/6.0/preview/api-diff/preview7
- 已知問題
  https://github.com/dotnet/core/blob/main/release-notes/6.0/known-issues.md
- GitHub issue 跟蹤
  https://github.com/dotnet/core/issues/6554

請參閱 .NET MAUI[3] 和 ASP.NET Core[4],瞭解更多關於客戶端和 Web 應用場景的新內容。

.NET 6 預覽版 7 已經在 Visual Studio 2022 預覽版 3 中測試通過並得到支持。Visual Studio 2022 使你能夠利用爲 .NET 6 開發的 Visual Studio 工具,如 .NET MAUI 的開發、C# 應用程序的 Hot Reload、WebForms 的 Web Live,以及 IDE 體驗中的其他性能改進。Visual Studio Code 也支持 .NET 6。

請查看我們新的對話帖[5],瞭解工程師之間關於最新的 .NET 功能的深入討論。我們還發表了關於 C# 10 中的字符串插值和 .NET 6 中的預覽功能 - 泛型 Math[6]

.NET SDK:現代化的 C# 項目模板

我們更新了 .NET SDK 的模板,以使用最新的 C# 語言特性和模式。我們已經有一段時間沒有在新的語言特性方面重新審視這些模板了。現在是時候了,我們將確保模板在未來使用新的功能。

以下是新模板中使用的語言特性:

  • 頂層語句
  • async Main
  • 全局 using 指令(通過 SDK 驅動的默認值)
  • File-scoped 命名空間
  • 目標類型 new 表達式
  • 可空(Nullable)引用類型

你可能會問,爲什麼我們要通過模板啓用某些功能,而不是在項目以 .NET 6 爲 Target 時默認啓用這些功能。儘管我們可以要求你在升級應用程序到新版本的 .NET 時做一些工作,作爲改善平臺默認行爲的交換條件,這使我們能夠改進產品,而不會使項目文件隨着時間的推移而變得複雜。然而,有些功能對於這種模式來說可能是相當具有破壞性的,比如可空(Nullable)的引用類型。無論是在什麼時候,我們都不想把這些功能與升級體驗聯繫在一起,而是想把這個選擇權留給你。模板是一個風險更低的支點,在那裏我們能夠爲新的代碼設置新的“好的默認模型”,而不會產生那麼多下游的後果。通過項目模板啓用這些功能,我們得到了兩全其美的結果:新代碼開始時啓用了這些功能,但現有的代碼在你升級時不會受到影響。

控制檯模板

控制檯模板變化最大,通過頂層語句和全局引用指令,它現在是一個單行代碼:

// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

而以前的同一模板的 .NET 5 版本是這樣的:

using System;

namespace Company.ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
        }
    }
}

控制檯模板的項目文件也發生了變化,啓用了可空(Nullable)引用類型的功能,例如:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

其他模板也可以實現可空(Nullable)引用類型、隱式全局引用和 File-scoped 命名空間,包括 ASP.NET Core 和 類庫。

ASP.NET Web 模板

Web 模板也同樣減少了代碼行數,使用同樣的功能:

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

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.MapGet("/", () => "Hello World!");

app.Run();

ASP.NET MVC 模板

MVC 模板的結構也類似。在這種情況下,我們將 Program.csStartup.cs 合併爲一個文件(Program.cs),形成了進一步的簡化:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

模板兼容性

關於使用新模板的兼容性問題,請參見以下內容:

  • 模板中的 C# 代碼不被早期的 .NET 版本所支持[7]
  • 隱式命名空間引入[8]

類庫:反射 API 的可空性信息

可空引用類型[9]是編寫可靠代碼的一個重要特徵。它在編寫代碼時非常有用,但在檢查代碼時卻沒有(直到現在)。新的反射 API[10] 使你能夠確定一個給定方法的參數和返回值的可空性。這些新的 API 對於基於反射的工具和序列化來說是至關重要的,比如說:

就上下文而言,我們在 .NET 5 中爲 .NET 庫添加了可空標註[11](在.NET 6 中完成),並且正在爲 ASP.NET Core 的這個版本做同樣的工作。我們也看到開發者在他們的項目中採用了可空性(Nullability)[12]

可空性(Nullability)信息存在於使用自定義屬性的元數據中[13]。原則上,任何人都已經可以讀取自定義屬性,然而,這並不理想,因爲編碼的消耗非同小可。

下面的例子演示了在幾個不同的場景中使用新的 API。

獲取頂層的可空性信息

想象一下,你正在實現一個序列化器。使用這些新的 API,序列化器可以檢查一個給定的屬性是否可以被設置爲 null

private NullabilityInfoContext _nullabilityContext = new NullabilityInfoContext();

private void DeserializePropertyValue(PropertyInfo p, object instance, object? value)
{
    if (value is null)
    {
        var nullabilityInfo = _nullabilityContext.Create(p);
        if (nullabilityInfo.WriteState is not NullabilityState.Nullable)
        {
            throw new MySerializerException($"Property '{p.GetType().Name}.{p.Name}'' cannot be set to null.");
        }
    }

    p.SetValue(instance, value);
}

獲取嵌套的可空性信息

可空性對可以持有其他對象的對象有特殊處理,比如數組和元組。例如,你可以指定一個數組對象(作爲一個變量,或者作爲一個類型成員簽名的一部分)必須是非空的,但是元素可以是空的,或者相反。這種額外的特殊性是可以通過新的反射 API 來檢查的,例如:

class Data
{
    public string?[] ArrayField;
    public (string?, object) TupleField;
}
private void Print()
{
    Type type = typeof(Data);
    FieldInfo arrayField = type.GetField("ArrayField");
    FieldInfo tupleField = type.GetField("TupleField");

    NullabilityInfoContext context = new ();

    NullabilityInfo arrayInfo = context.Create(arrayField);
    Console.WriteLine(arrayInfo.ReadState);        // NotNull
    Console.WriteLine(arrayInfo.Element.State);    // Nullable

    NullabilityInfo tupleInfo = context.Create(tupleField);
    Console.WriteLine(tupleInfo.ReadState);                      // NotNull
    Console.WriteLine(tupleInfo.GenericTypeArguments [0].State); // Nullable
    Console.WriteLine(tupleInfo.GenericTypeArguments [1].State); // NotNull
}

類庫:ZipFile 遵循 Unix 文件權限

System.IO.Compression.ZipFile 類現在可以在創建過程中捕獲 Unix 文件權限,並在類似 Unix 的操作系統上提取壓縮文件時設置文件權限。這一變化允許可執行文件在壓縮包中被循環使用,這意味着你不再需要修改文件權限來使文件在解壓縮包後可執行。它也同樣遵循 usergroupother 讀/寫權限。

如果一個壓縮包不包含文件權限(因爲它是在 Windows 上創建的,或者使用了一個沒有捕獲權限的工具,比如早期的 .NET 版本),那麼解壓縮的文件就會得到默認的文件權限,就像其他新創建的文件一樣。

Unix 的文件權限也適用於其他壓縮工具,包括:

  • Info-ZIP
  • 7-Zip

早期 .NET 7 功能預覽:泛型 Math

對於 .NET 6,我們已經建立了將 API 標記爲“預覽”的能力[14]。這種新方法將使我們能夠在多個主要版本中提供和發展預覽功能。爲了使用預覽 API,項目需要明確選擇使用的預覽功能。如果你在沒有明確選擇的情況下使用預覽功能,從 .NET 6 RC1 開始,你會看到帶有可操作信息的構建錯誤。預覽功能預計將在以後的版本中發生變化,可能會有破壞性的變化。這就是爲什麼他們要選擇加入。

我們在 .NET 6 中預覽的這些功能之一是靜態抽象接口成員。這些允許你在接口中定義靜態的抽象方法(包括操作符)。例如,現在可以實現代數泛型方法。對於一些人來說,這個功能將是我們今年交付的絕對突出的改進。這也許是自 Span<T> 以來最重要的新類型系統特性。

下面的例子是一個 IEnumerable<T>,由於 T 被限制爲 INumber<T>,可能是一個 INumber<int>,所以能夠對所有的數值進行求和。

public static T Sum<T>(IEnumerable<T> values)
    where T : INumber<T>
{
    T result = T.Zero;

    foreach (var value in values)
    {
        result += value;
    }

    return result;
}

這是因爲 INumber<T> 定義了各種(靜態)操作符重載,必須由接口實現者來滿足。IAdditionOperators 也許是最容易理解的新接口,INumber<T> 本身就是派生自這個接口。

這都是由一個新的功能提供的,它允許在接口中聲明靜態抽象成員。這使得接口可以公開運算符和其他靜態方法,比如 ParseCreate,並且這些方法可以由派生類型實現。更多細節請參見我們的相關博文[15]

所有提到的功能都是 .NET 6 的預覽版,不支持在生產中使用。我們將感謝您在使用中提供反饋。我們打算在 .NET 7 中繼續發展和改進泛型 Math 功能以及支持它們的運行時和 C# 功能。我們希望對當前的體驗進行突破性的改變,這也是爲什麼新的 API 被標記爲“預覽”的部分原因。

類庫:NativeMemory API

我們增加了新的本地內存分配 API[16],通過 System.Runtime.InteropServices.NativeMemory 公開。這些 API 相當於 C 語言中的 mallocfreerealloccalloc API,還包括用於進行對齊分配的 API。

你可能想知道如何看待這些 API。首先,它們是低級別的 API,是爲低級別的代碼和算法準備的。應用程序開發人員很少會用到這些。另一種思考這些 API 的方式類似於平臺內部的 API,它們是用於 CPU 指令的低級別 .NET API。這些 API 是類似的,但它是爲內存相關的操作暴露的低級別的 API。

類庫:System.Text.Json 序列化通知

System.Text.Json 序列化器現在將通知作爲(反)序列化操作的一部分公開。它們對於默認值和驗證非常有用。要使用它們,請在 System.Text.Json.Serialization 命名空間中實現一個或多個接口 IJsonOnDeserializedIJsonOnDeserializingIJsonOnSerialized 或 IJsonOnSerializing`。

這裏有一個例子,在 JsonSerializer.Serialize()JsonSerializer.Deserialize() 中都進行驗證,以確保 FirstName 屬性不是 null

public class Person : IJsonOnDeserialized, IJsonOnSerializing
{
    public string FirstName{ get; set; }

    void IJsonOnDeserialized.OnDeserialized() => Validate(); // Call after deserialization
    void IJsonOnSerializing.OnSerializing() => Validate(); // Call before serialization

    private void Validate()
    {
        if (FirstName is null)
        {
            throw new InvalidOperationException("The 'FirstName' property cannot be 'null'.");
        }
    }
}

以前,你需要實現一個自定義轉換器來實現這一功能。

類庫:System.Text.Json 序列化屬性排序

我們使用 System.Text.Json.Serialization.JsonPropertyOrderAttribute 特性增加了控制屬性序列化順序的能力,用一個整數指定了順序,較小的整數先被序列化;沒有該特性的屬性有一個默認的排序值 0

這裏有一個例子,指定 JSON 應該按照 Id, City, FirstName, LastName 的順序進行序列化:

public class Person
{
    public string City { get; set; } // No order defined (has the default ordering value of 0)

    [JsonPropertyOrder(1)] // Serialize after other properties that have default ordering
    public string FirstName { get; set; }

    [JsonPropertyOrder(2)] // Serialize after FirstName
    public string LastName { get; set; }

    [JsonPropertyOrder(-1)] // Serialize before other properties that have default ordering
    public int Id { get; set; }
}

以前,序列化順序是由反射順序決定的,而反射順序既不是確定的,也不會導致特定的預期順序。

類庫:System.Text.Json.Utf8JsonWriter

在用 Utf8JsonWriter 編寫 JSON payloads 時,有時你需要嵌入“原始”JSON。

比如:

  • 我有一個設計好的字節序列,如下例所示。
  • 我有一個 blob,我認爲它代表 JSON 內容,我想把它包起來,我需要確保包和它的內部保持良好的格式。
JsonWriterOptions writerOptions = new() { WriteIndented = true, };

using MemoryStream ms = new();
using UtfJsonWriter writer = new(ms, writerOptions);

writer.WriteStartObject();
writer.WriteString("dataType", "CalculationResults");

writer.WriteStartArray("data");

foreach (CalculationResult result in results)
{
    writer.WriteStartObject();
    writer.WriteString("measurement", result.Measurement);

    writer.WritePropertyName("value");
    // Write raw JSON numeric value using FormatNumberValue (not defined in the example)
    byte[] formattedValue = FormatNumberValue(result.Value);
    writer.WriteRawValue(formattedValue, skipValidation: true);

    writer.WriteEndObject();
}

writer.WriteEndArray();
writer.WriteEndObject();

以下是對上述代碼--特別是FormatNumberValue--的描述。爲了提高性能,System.Text.Json 在數字爲整數時省略了小數點/值,如 1.0。其理由是,寫的字節數越少越好,有利於提高性能。在某些情況下,保留小數點可能很重要,因爲消費者將沒有小數點的數字視爲整數,否則視爲浮點數。這種新的“原始值”模型允許你在任何需要的地方擁有這種程度的控制。

類庫:JsonSerializer 同步流重載

我們爲 JsonSerializer 添加了新的同步 API[17],用於將 JSON 數據序列化和反序列化到一個流。你可以在下面的例子中看到這個演示。

using MemoryStream ms = GetMyStream();
MyPoco poco = JsonSerializer.Deserialize<MyPoco>(ms);

這些新的同步 API 包括與新的 System.Text.Json source generator[18] 兼容和可用的重載,通過接受 JsonTypeInfo<T>JsonSerializerContext 實例。

類庫:System.Diagnostics Propagators

在過去的幾年裏,我們一直在改進對 OpenTelemetry[19] 的支持。實現該支持的一個關鍵點是確保所有需要參與遙測生產的組件以正確的格式輸出到網絡頭。要做到這一點真的很難,特別是隨着 OpenTelemetry 規範的變化。OpenTelemetry 定義了傳播(propagation)[20]的概念來幫助解決這種情況。我們正在採用傳播的方式來實現頭的定製的一般模型。

關於更廣泛的概念背景:

  • OpenTelemetry 規範 - 分佈式跟蹤數據結構的內存表示。
  • OpenTelemetry Span - 追蹤構建塊,在 .NET 中由 System.Diagnostics.Activity 表示。
  • W3C TraceContext - 關於如何通過衆所周知的 HTTP 頭傳播這些分佈式跟蹤數據結構的規範。

下面的代碼演示了使用傳播的一般方法:

DistributedContextPropagator propagator = DistributedContextPropagator.Current;
propagator.Inject(activity, carrier, (object theCarrier, string fieldName, string value) =>
{
   // Extract the context from the activity then inject it to the carrier.
});

你也可以選擇使用不同的傳播器(propagator):

// Set the current propagation behavior to not transmit any distributed context information in outbound network messages.
DistributedContextPropagator.Current = DistributedContextPropagator.CreateNoOutputPropagator();

DistributedContextPropagator 抽象類決定了分佈式上下文信息在網絡傳輸時是否以及如何被編碼和解碼。編碼可以通過任何支持字符串鍵/值對的網絡協議進行傳輸。DistributedContextPropagator 以字符串鍵/值對的形式向載體注入數值並從載體中提取數值。通過添加對傳播者的支持,我們實現了兩件事。

  • 你不再需要使用 W3C 的 TraceContext 頭文件。你可以編寫一個自定義的傳播器(甚至用你自己的頭文件名稱),而不需要 HttpClient、ASP.NET Core 等庫對這種自定義格式有預先的瞭解。
  • 如果你實現了一個帶有自定義傳輸的庫(如消息隊列),只要你支持發送和接收文本映射(如 Dictionary<string, string>),你現在可以支持各種格式。

大多數應用程序代碼不需要直接使用這個功能,然而,如果你使用 OpenTelemetry,你很可能會在調用棧中看到它。一些庫的代碼如果關心跟蹤和因果關係,可能會需要使用這個模型。

類庫:加密操作調用模式簡化

.NET 的加密和解密部件是圍繞着流設計的,沒有真正的概念來定義什麼時候有效載荷已經在內存中(already in memory)。SymmetricAlgorithm 上新的 Encrypt-Decrypt- 方法加速了 already in memory 的進展,目的是爲調用者和代碼審查者提供清晰的信息。此外,它們還支持從 span 中讀取和寫入。

新的簡化方法爲使用加密 API 提供了一個直接的方法:

private static byte[] Decrypt(byte[] key, byte[] iv, byte[] ciphertext)
{
    using (Aes aes = Aes.Create())
    {
        aes.Key = key;

        return aes.DecryptCbc(ciphertext, iv);
    }
}

在新的 Encrypt-Decrypt- 方法中,只使用 SymmetricAlgorithm 實例中的 Key 屬性。新的 DecryptCbc 方法支持選擇填充算法,但是 PKCS#7 經常與 CBC 一起使用,所以它是一個默認參數。如果你喜歡這種清晰的感覺,就指定它吧:

private static byte[] Decrypt(byte[] key, byte[] iv, byte[] ciphertext)
{
    using (Aes aes = Aes.Create())
    {
        aes.Key = key;

        return aes.DecryptCbc(ciphertext, iv, PaddingMode.PKCS7);
    }
}

你可以看到,現有的模式--使用 .NET 5--明顯需要更多的管道來實現同樣的結果:

private static byte[] Decrypt(byte[] key, byte[] iv, byte[] ciphertext)
{
    using (Aes aes = Aes.Create())
    {
        aes.Key = key;
        aes.IV = iv;

        // These are the defaults, but let's set them anyways.
        aes.Padding = PaddingMode.PKCS7;
        aes.Mode = CipherMode.CBC;

        using (MemoryStream destination = new MemoryStream())
        using (ICryptoTransform transform = aes.CreateDecryptor())
        using (CryptoStream cryptoStream = new CryptoStream(destination, transform, CryptoStreamMode.Write))
        {
            cryptoStream.Write(ciphertext, 0, ciphertext.Length);
            cryptoStream.FlushFinalBlock();
            return destination.ToArray();
        }
    }
}

運行時:支持所有平臺和架構的 W^X

運行時現在有一種模式,它不創建或使用任何同時可寫和可執行的內存頁。所有可執行的內存都被映射爲只讀不執行。這項功能在該版本的早期僅在 macOS 上啓用--針對 Apple Silicon。在 Apple Silicon 機器上,禁止同時進行可寫和可執行的內存映射。

這一功能現在在所有其他平臺上被啓用並支持。在這些平臺上,可執行代碼的生成/修改是通過單獨的讀寫內存映射完成的,這對 JIT 代碼和運行時生成的輔助程序都是如此。這些映射是在與可執行代碼地址不同的虛擬內存地址上創建的,並且只在進行寫入時存在非常短暫的時間。例如,JIT 現在生成代碼到一個從頭開始的緩衝區,在整個方法被 jitted 後,使用一個內存拷貝函數調用將其複製到可執行內存中。而可寫映射的壽命只跨越了內存拷貝的時間。

這個新功能可以通過設置環境變量 DOTNET_EnableWriteXorExecute 爲 1 來啓用。這個功能在 .NET 6 中是可選的,因爲它有一個啓動時的退步(除了在 Apple Silicon 上)。在我們的 ASP.NET 基準測試中,當用 Ready To Run(R2R)編譯時,退步了 ~10%。然而,在啓用和未啓用該功能的情況下,測得的穩態性能是一樣的。對於啓動性能並不重要的應用程序,我們建議啓用該功能,因爲它能提高安全性。我們打算作爲 .NET 7 的一部分解決性能退步問題,屆時默認啓用該功能。

結束

我們認爲,我們已經到了新功能和改進已經完成的發佈點。爲團隊點贊!

我們繼續期待你們的反饋。我們將把 .NET 6 的其餘部分放在完善(功能和性能)和新功能中發現的錯誤上。在大多數情況下,功能改進需要等到 .NET 7。請分享你的任何反饋,我們將很高興對其進行分類。

感謝所有爲 .NET 6 做出貢獻的人,使其成爲另一個偉大的版本。

感謝你成爲一名 .NET 開發者。

文中相關鏈接:

[1].https://dotnet.microsoft.com/
[2].https://dotnet.microsoft.com/download/dotnet/6.0
[3].https://devblogs.microsoft.com/dotnet/announcing-net-maui-preview-7/
[4].https://devblogs.microsoft.com/aspnet/asp-net-core-updates-in-net-6-preview-7/
[5].https://devblogs.microsoft.com/dotnet/category/conversations/
[6].https://devblogs.microsoft.com/dotnet/string-interpolation-in-c-10-and-net-6/
[7].https://docs.microsoft.com/dotnet/core/compatibility/sdk/6.0/csharp-template-code
[8].https://docs.microsoft.com/dotnet/core/compatibility/sdk/6.0/implicit-namespaces
[9].https://docs.microsoft.com/dotnet/csharp/nullable-references
[10].https://github.com/dotnet/runtime/issues/29723
[11].https://twitter.com/JeffHandley/status/1424846146850131968
[12].https://github.com/jellyfin/jellyfin/blob/c07e83fdf87e61f30e4cca4e458113ac315918ae/Directory.Build.props#L5
[13].https://github.com/dotnet/roslyn/blob/main/docs/features/nullable-metadata.md
[14].https://github.com/dotnet/designs/blob/main/accepted/2021/preview-features/preview-features.md
[15].https://devblogs.microsoft.com/dotnet/preview-features-in-net-6-generic-math/
[16].https://github.com/dotnet/runtime/pull/54006
[17].https://github.com/dotnet/runtime/issues/1574
[18].https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/
[19].https://devblogs.microsoft.com/dotnet/opentelemetry-net-reaches-v1-0/
[20].https://opentelemetry.lightstep.com/core-concepts/context-propagation/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章