【ASP.NET Core】Blazor+MiniAPI完成文件下載

今天老周要說的內容比較簡單,所以大夥伴們不必緊張,能識字的都能學會。

在開始之前先來一段廢話。

許多人都很關心,blazor 用起來如何?其實也沒什麼,做Web的無非就是後台代碼+前台HTML(包含JS+CSS等)。Blazor 的初衷就是給咱們寫C#的人用的,儘管不能完全代替 JS,但起碼大多數情況下是可以的。某些特定情況下非用JS不可了,就使用.NET 與 JS 互操作就行了。不必大量使用,只在需要時用就行,不然會影響性能。這是什麼樣的場景呢?嗯,很熟悉的情場。

只要你以前寫過 Windows Forms 窗體項目就懂了。這就跟.NET 調用 Win32 API 一樣,大多數時候,你直接用.NET封裝的類型就能搞定,但某些情況下你還得調用Win32 API,一樣的道理。

雖然這幾年,JS的語法也有所增強,也有TS的擴展,但寫起來還是沒有C#爽。這是照顧咱們大多數「全能程序猿」而推出的,有幾家公司會專招一邦人來為你寫前端(更別指望會給你招個妹子),這麼人性化的公司可不多了。因此,Blazor 也不是什麼高大上的神器,但可以為咱們這些「萬能勞動力」減減壓而已。

———————————————————————————————————————-

老周今天說的是 Blazor 中的文件下載功能。其實,官方文檔也給出了示例,你在開發過程完全可以照抄。抄代碼也不是說一定是壞事,能夠利用現有資源就盡情地用,不要猶豫。你不可能自己生產出汽車然後才開車的,不然汽車工廠幹嗎去?所以,以前有一位黑客級大神總結出:

1、能用 Excel 解決的問題你寫個龜代碼;

2、能用 PPT 解決的問題,你做啥視頻特效;

3、別人都做出來的軟件,你就用唄,何必自己造輪子;

4、借鑒(「抄」的雅稱)別人的代碼前最好先摸清楚人家的思路,大概弄懂是個啥原理再用。

其實,Blazor只不過把一些常用的JS實現的功能用C#替代而已,Web 應用的基本原理是不變的。也就是說,在Blazor應用中,做出文件下載功能的方法是很多滴。

官方示例的思路是:

A、服務器生成 Stream 對象;

B、對生成的.NET 流對象進行封送,傳輸到客戶端(通過singalR),數據包裝進 Blob 對象中;

C、互操作方式調用預先定義好的 JS 函數,提取 Blob 中的數據(模擬點擊 document 生成的 <a>標籤激活下載)。

不管是 blazor server 還是 blazor webassembly 原理一樣。

 

老周補充一下這下方案,都是可行的。

A、寫一個MVC控制器(其實理解為 API 控制器也一樣,沒有View罷了),返迴文件內容,這個不難吧,然後在 Blazor 中只要利用一下指向此控制器的URL就行了,至於怎麼做嘛,你喜歡咋弄都行;

B、原理和上面一樣,只是不用寫個MVC控制器,咱們何不發揮一下那個簡練好用的 Mini-API 功能呢。

 

好了,前方精彩預警!

步驟1:我們建一個空白的 ASP.NET Core 應用項目。老周比較喜歡這個空白項目模板,靈活好用。ASP.NET Core 中所有技術都可以在同一個項目中融合使用。

步驟2:相信大家知道,C# 程序現在可以省略 Main 方法的定義,讓編譯器去生成默認代碼。所以,ASP.NET Core 項目的代碼比起過去版本一下子精簡了很多。打開 Program.cs 文件(項目生成的是這名字,若你有強迫症,可以改名)。在調用 Build 方法之前,為應用程序註冊以下服務。

var builder = WebApplication.CreateBuilder(args);
// 這些服務是必要的
builder.Services.AddServerSideBlazor();
// 我是圖方便,讓Razor頁的目錄直接設定於內容根目錄
builder.Services.AddRazorPages().WithRazorPagesAtContentRoot();
var app = builder.Build();

Blazor 應用優先選用服務器端的,有特殊需求才考慮 Web Assembly。雖然不是什麼硬規矩,但 Web 應用的優良傳統都是服務器承擔性能消耗,讓客戶端當上帝。故而,咱們要傳承 Web 應用的奉獻精神。

如果你剛接觸 Blazor,可能會疑惑,為什麼還要啟用 Razor Pages 功能呢?因為 Blazor 也是Web應用是吧,它是在HTML頁中加載的。嗯,你想一下,要是不先加載一個完整的HTML頁,Blazor 怎麼冒出來呢?所以,我們的應用程序要先加載一個「外殼」頁,然後再通過它來加載 Blazor 應用。

從這個模式咱們就知道了,Blazor 應用其實是單個HTML頁上的應用,Blazor 應用內的頁面切換隻是這個HTML頁面內部一些標籤的「輪換」罷了。即:Blazor 中的「頁」本質上是一個HTML組件;而HTML組件就是把一堆HTML標籤包起來,可以作為模板到處使用。這好比你的PC主機,有個機箱,把裏面的主板、處理器、硬盤、內存、顯卡什麼的全部裝好,當你要換個地方工作時,你只要搬動主機就行了,你不需要把內存、網卡的都拆出來又重新組裝。

既然一個 Blazor 頁是一個組件,那麼,Blazor 應用在啟動後,是不是應該要有一個「控制中心」,來操縱不同組件之間的切換?雖然普通的組件也能作為 Blazor 應用加載,但不能在多個組件中導航了。所以,我們要先編寫這個「控制中心」,有了它,你就能到處穿越了,就像多拉B夢的時空門一樣。

一般,我們把這個充當「主謀」的組件命名為 App,Razor 組件的文件擴展名是.razor。所以,文件名就是 App.razor。來,咱們動手寫一下。

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using System.Reflection

<Router AppAssembly="typeof(Program).Assembly" Context="routedata">
    <Found>
        <RouteView RouteData="routedata" />
    </Found>
    <NotFound>
        <p>應用程序掛了……</p>
    </NotFound>
</Router>

前面的三個 @using 和 C# 中的 using 一個意思,引入咱們用到的命名空間。當然了,如果你不想在每個組件文件中都寫一遍,還可以在 App.razor 同級目錄下建一個名為 _Imports.razor 的文件(首字母可大寫可小寫),然後把 @using 寫進去。

App 組件的根元素不是HTML元素,而是 Router 類,它可以根據應用內部的URL在不同組件間導航,客戶端瀏覽器的地址欄不會變(前面說了,Blazor 是單頁面的)。AppAssembly 屬性指定 Blazor 組件要在哪個程序集中查找,99.9996% 情況下都是我們當前項目所在程序集。Context 是個很有意思的屬性,它的功能是為當前元素(這裡是Router)所關聯的上下文件對象分配一個變量名,這個名字你可以隨便取,這裡我命名為「routedata」,如果不指定,默認名字是「context」。

這裡頭啥意思呢?原來啊,組件中呈現元素是用一個叫「幀」的玩意兒來表示的。對應兩個委託類型:

delegate void RenderFragment(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
delegate Microsoft.AspNetCore.Components.RenderFragment RenderFragment<TValue>(TValue value)

注意到第二個委託有些意思,它返回了第一個委託類型的實例,但咱們最該關心的是它有個泛型參數 TValue,咱們上面所說的那個 Context 屬性,所關聯的上下文對象就是通過這個泛型參數來傳遞的。

傳遞上下文對象後能幹些啥呢?還是以咱們這個 App 組件來舉例。Router 接收到上下文對象(在運行的時候實際接收了被路由處理後的URL)後,Router 元素下面的子元素就可以訪問這個上下文對象了,而訪問方法就是引用 Context 屬性分配的變量名(此處是 routedata)。

Router 元素必須包含兩個子元素:

Found:如果從 AppAssembly 屬性所指定的程序集中找到了與路由規則匹配的 Blazor 組件,那麼,就把這個組件呈現在 RouteView 元素中;

NotFound:如果找不到匹配的組件,那就呈現它的子元素,這裡是一個「屁」元素,文本是「應用程序掛了……」。

 

步驟3:建一個新 Blazor 組件,名為 Home.razor,作為此 Blazor 應用的真正主頁。

@page "/"

<div>
    <p>
        下載文件:
    </p>
    <a href="/download" target="_blank">點這裡</a>
</div>

作為 Blazor 的組件,要在首行明確標註 @page,「/」表示URL的根路徑,即默認打開的「頁面」。

為了簡單演示,此處<a>元素指向了下載文件的地址,點一下就開始下載。/download 指向一個 Mini-API,這個咱們到最後再寫。

 

步驟4:Blazor 組件完工了,接下來要弄一個 Razor 頁,它是一個完整的HTML文檔,用來加載 Blazor 應用。命名為 appLoader.cshtml。注意,文件擴展名不同,不是 Razor 組件。

@page
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<html lang="zh-cn">
    <head>
        <meta charset="utf-8" />
        <base href="~/" />
    </head>
    <body>
        @*相關腳本*@
        <script src="_framework/blazor.server.js"></script>
        @*加載啟動組件*@
        <component type="typeof(XXX.App)" render-mode="ServerPrerendered" />
    </body>
</html>

作為 Razor Page ,你懂的,首行要註明 @page,第二行是標記要使用 Tag Helper(標記幫助器)。因為稍後咱們要用 component 元素來加載 App 組件。

XXX是你那個 App 組件所在的命名空間。有個重要的 JS 腳本—— blazor.server.js,絕對不能忘了,否則客戶端無法啟動 Blazor 專用的 singnalR 連接。這個腳本不在我們項目中,而包裝在.NET 類庫中,所以我們不用管它,記得引用就行。

 

步驟5:最後,咱們補全 Program.cs 中的代碼。

// Blazor需要靜文件的訪問
app.UseStaticFiles();
app.UseRouting();
// 此處比5.0簡練,不必通過Endpoint來添加映射
app.MapBlazorHub();
// blazor app 第一次訪問時,應用尚未加載,會404的
// 所以要先訪問一下某個page,讓這個page去加載app
app.MapFallbackToPage("/appLoader");

app.Run();

雖然咱們這項目中沒有 wwwroot 中的靜態資源,但JS要加載 blazor.server.js,獲取這個腳本需要靜態文件功能來支持。

MapBlazorHub 方法要記得調用,否則客戶端進來的 HTTP 請求無法由 Blazor 類庫來處理。

最下面一句 MapFallbackToPage 也很重要。前面咱們分析過,Blazor 應用需要一個完整的 HTML 頁面來加載,所以,當客戶端首次訪問根 URL(或其他組件URL)時,由於 Blazor 未啟動,組件無法加載。

所以,當首次訪問失敗時轉到 /appLoader 來加載並啟動 Blazor 應用。

 

步驟6:實現下載文件的 Mini-API。

app.MapGet("/download", () =>
{
    // 隨機弄些玩意兒
    byte[] data = null;
    string txt = "床前明月光\n有逼就能裝\n手持玩具槍\n喝辣又吃香";
    data = System.Text.Encoding.UTF8.GetBytes(txt);
    return Results.File(data, "application/octet-stream", "abc.txt");
});

 

Program.cs 完整代碼如下:

var builder = WebApplication.CreateBuilder(args);
// 這些服務是必要的
builder.Services.AddServerSideBlazor();
// 我是圖方便,讓Razor頁的目錄直接設定於內容根目錄
builder.Services.AddRazorPages().WithRazorPagesAtContentRoot();
var app = builder.Build();

// Mini-API,簡單文件下載
app.MapGet("/download", () =>
{
    ……
});

// Blazor需要靜文件的訪問
app.UseStaticFiles();
app.UseRouting();
// 此處比5.0簡練,不必通過Endpoint來添加映射
app.MapBlazorHub();
// blazor app 第一次訪問時,應用尚未加載,會404的
// 所以要先訪問一下某個page,讓這個page去加載app
app.MapFallbackToPage("/appLoader");

app.Run();

 

運行起來,測測效果。

 

 

點一下頁面上的鏈接,嗯,Perfect !

 

 

記事本打開看看下載的文件。

 

 

 當然了,你也可以像官方示例那樣,用 JS 動態創建個<a>標籤,然後模擬 Click。

來,咱們改一下。

在項目中新建一個目錄,命名為 wwwroot,然後在wwwroot下建一個腳本文件,命名為 test.js。用JS寫個函數。

function demoDown() {
    // 動態創建元素
    var ele = document.createElement("a");
    // 設置下載URL
    ele.href = '/download';
    ele.target = '_blank';
    // 模擬點擊
    ele.click();
    ele.remove(); //沒有利用價值了,殺!
}

待會兒,我們得用互操作來調用這個JS函數。

打開 appLoader.cshtml,改一下HTML,引用 test.js。

    <body>
        @*相關腳本*@
        <script src="_framework/blazor.server.js"></script>
        <script src="~/test.js"></script>
        @*加載啟動組件*@
        <component type="typeof(SuatApp.App)" render-mode="ServerPrerendered" />
    </body>

 

再打開 Home.razor 組件,改一下,把 a 元素改成 button。

@page "/"
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.JSInterop
@inject IJSRuntime JS

<div>
    <p>
        下載文件:
    </p>
    <button @onclick="OnClick">點這裡領取美人一名</button>
</div>

@code {
    private async Task OnClick()
    {
        // 互操作,調用JS函數
        await JS.InvokeVoidAsync("demoDown");
    }
}

@inject 用來獲取依賴注入的 JsRuntime 對象,在 OnClick 方法中用它來調用JS函數。被調用的 JS 函數就是我們剛剛寫的 demoDown。

 

可以了,再次運行,看效果。

 

 然後點一下頁面上那個充滿誘惑的按鈕,下載文件。

 

 

好了,這樣弄基本咱們日常開發需求了。