不用Blazor WebAssembly,開發在瀏覽器端編譯和執行C#程式碼的網站

楊中科發表於2023-02-10

本文中,我將會為大家分享一個如何用.NET技術開發“在瀏覽器端編譯和執行C#程式碼的工具”,核心的技術就是用C#編寫不依賴於Blazor框架的WebAssembly以及Roslyn技術。

一、 為什麼要開發這樣的工具?

對於程式設計初學者來講,開發環境的安裝配置是一個令人頭疼的事情,如果能讓初學者不用做任何的安裝配置,直接開啟瀏覽器就能編寫、執行程式碼,那麼這將會大大降低程式設計初學者的學習門檻。

目前已經有一些可以線上編寫、執行C#程式碼的網站了,這些網站的實現思路有如下兩種:

思路1把程式碼從前端提交到在後端伺服器上,然後在伺服器上進行編譯、執行,然後把執行結果再顯示到前端。這樣做的缺點是無法完成複雜的輸入輸出、介面互動等。

思路2用Mono技術編寫WebAssembly。這樣做的缺點是對於C#語法的跟進不及時,一些新的C#語法不被支援。

因此,開發一個能在瀏覽器端編譯執行C#程式碼,並且支援最新C#語法的工具就很重要了。要開發這樣的工具,WebAssembly是一個繞不過去的技術。

二、 什麼是WebAssembly

傳統的前端開發都是使用JavaScript來編寫邏輯,而WebAssembly讓我們可以用其他程式設計序言編寫在瀏覽器中執行的程式。由於WebAssembly屬於現代瀏覽器的標準,所以在瀏覽器中執行WebAssembly程式並不需要安裝額外的外掛。現在Java、Go、Python等主流的程式語言都已經支援編譯為WebAssembly。

三、 Blazor WebAssembly的缺點

 .NET 中的Blazor WebAssembly技術可以把C#程式碼編譯為WebAssembly執行在瀏覽器端。但是傳統的Blazor WebAssembly是一個侵入性很強的框架,也就是整個系統都必須使用C#技術進行開發,而不能選擇只是其中一個元件使用C#程式碼,其他地方仍然使用傳統的JavaScript進行開發。當然,透過Microsoft.AspNetCore.Components.CustomElements,我們可以只把介面的一小塊使用C#進行開發,但是這種方式仍然是“在頁面上留一個用C#寫的區域”,非常的重量級,而不能實現“只用C#寫一個函式”這樣輕量級的元件,也就是用C#寫一個非侵入性、依賴性很低的輕量級WebAssembly元件。

四、 不用Blazor WebAssembly,用.NET技術開發WebAssembly

從.NET 6開始,我們可以使用C#編寫輕量級的WebAssembly,生成的WebAssembly只需要使用Blazor提供的基礎執行環境,而不需要引入整個Blazor WebAssembly技術。

                下面,我將會透過一個簡單的“用C#計算兩個數的和”的例子來演示這個技術的用法。當然,這只是一個簡單的演示,實際專案中肯定不會用C#完成這麼簡單的功能。下面的專案用.NET 7進行演示,其他版本使用可能會略有不同。

1、 建立一個.NET普通類庫專案,然後透過Nuget安裝如下兩個元件:Microsoft.AspNetCore.Components.WebAssembly、Microsoft.AspNetCore.Components.WebAssembly.DevServer,然後把類庫專案的csproj檔案的根節點中Sdk屬性的值修改為"Microsoft.NET.Sdk.BlazorWebAssembly"。

修改後的檔案類似如程式碼 1所示。

 

程式碼 1  csproj專案檔案

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.2" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.2" />
  </ItemGroup>
</Project>

 

2、 在類庫專案中建立一個檔案Program.cs,內容如程式碼 2所示。

 

程式碼 2 Program.cs

using Microsoft.JSInterop;

namespace Demo1
{
    public class Program
    {
        private static async Task Main(string[] args)
        {
        }

        [JSInvokable]
        public static int Add(int i,int j)
        {
            return i + j;
        }
    }
}

 

                這裡Main方法目前是空的,但是不能被省略。Add方法上的[JSInvokable]表示這個方法可以被JavaScript呼叫,也就是這個方法屬於一個可以被呼叫的Web Assembly方法。

3、 編譯專案,生成資料夾下的wwwroot資料夾中的_framework資料夾中就是生成的Web Assembly和相關檔案。

4、 用任何你喜歡的前端技術建立一個前端專案。我這裡不使用任何的前端框架,而是直接用普通的HTML+Javascript來編寫前端專案。

首先,我們要把上一步生成的_framework資料夾複製到前端專案的根資料夾下。

然後,我們編寫index.html檔案,內容如程式碼 3所示。

程式碼 3 index.html

<html lang="en">
    <head>
        <meta charset="UTF-8" />
    </head>
    <body></body>    
    <script src="_framework/blazor.webassembly.js" autostart="false"></script>    
    <script>    
        window.onload = async function () {
            await Blazor.start();
            const r = await DotNet.invokeMethodAsync(
                'Demo1',//程式集的名字
                'Add',//要呼叫的標註了[JSInvokable]方法的名字
                666,//若干引數
                333
            );
            alert(r);
        };    
    </script>
</html>

 

                接下來解釋一下上面的程式碼,<script src="_framework/blazor.webassembly.js" autostart="false"></script>用來引入相關的檔案。Blazor.start();用來啟動Blazor執行時環境; DotNet.invokeMethodAsync用來呼叫WebAssembly中的方法,第一個引數為被呼叫的程式集的名字,第二個引數為被呼叫的方法的名字,之後的引數為給被呼叫的方法傳遞的引數值。

                可以看到,這裡我們就是把WebAssembly當成一個元件在用,完全不對頁面有其他特殊的要求。所以這個元件可以在任何前端框架中使用,也可以和其他前端的庫一起使用。

                最後,我們執行這個前端專案. 由於Blazor會生成blat、dll等不被Web伺服器預設接受的檔案型別,所以請確保在Web伺服器上為如下格式的檔案配置MimeType:.pdb、.blat、.dat、.dll、.json、.wasm、.woff、.woff2。我這裡測試用的Web伺服器是IIS,所以在網站根資料夾下建立如所示的Web.config檔案即可,如程式碼 4所示,使用其他Web伺服器的開發者請參考所使用的Web伺服器的手冊進行MimeType的配置。

程式碼 4 Web.config

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
    <staticContent>
    <remove fileExtension=".pdb" />
    <remove fileExtension=".blat" />
    <remove fileExtension=".dat" />
    <remove fileExtension=".dll" />
    <remove fileExtension=".json" />
    <remove fileExtension=".wasm" />
    <remove fileExtension=".woff" />
    <remove fileExtension=".woff2" />            
    <mimeMap fileExtension=".pdb" mimeType="application/octet-stream" />
    <mimeMap fileExtension=".blat" mimeType="application/octet-stream" />
    <mimeMap fileExtension=".dll" mimeType="application/octet-stream" />
    <mimeMap fileExtension=".dat" mimeType="application/octet-stream" />
    <mimeMap fileExtension=".json" mimeType="application/json" />
    <mimeMap fileExtension=".wasm" mimeType="application/wasm" />
    <mimeMap fileExtension=".woff" mimeType="application/font-woff" />
    <mimeMap fileExtension=".woff2" mimeType="application/font-woff" />            
    </staticContent>
</system.webServer>
</configuration>

5、 在瀏覽器端訪問Web伺服器中的index.html,如果看到如Figure 1所示的彈窗,就說明Javascript成功了呼叫了C#編寫的Add方法。

 

Figure 1 程式執行彈窗

五、 C#編寫WebAssembly的應用場景

C#編寫的WebAssembly預設佔的流量比較大,大約要佔到30MB。我們可以透過BlazorLazyLoad、啟用Brotli演算法等方式把流量降到5MB以下,具體用法請網上搜尋相關資料。

在我看來,用C#編寫WebAssembly有包含但不限於如下的場景。

場景1複用一些.NET元件或者C#程式碼。這些已經存在的.NET元件或者C#程式碼雖然也能用Javascript重寫,但是這樣增加了額外的工作量。而透過WebAssembly就可以直接重用這些元件。比如,我曾經在後端開發用到過一個PE檔案解析的Nuget包,這個包採用的.NET Standard標準開發,而且全部是在記憶體中進行檔案內容的處理,因此我就可以直接在WebAssembly中繼續使用這個包在前端對PE檔案進行處理。

場景2使用一些WebAssembly元件。因為C/C++等語言編寫的程式可以移植為WebAssembly版本,因此很多經典的C/C++開發的軟體也可以繼續在前端使用。比如音影片處理的ffmpeg已經有了WebAssembly版本,因此我們就可以用C#呼叫它進行音影片的處理;再比如,著名的計算機視覺庫OpenCV也被移植到了WebAssembly中,因此我們也可以使用C#在前端進行影像識別、影像處理等操作。WebAssembly非常適合開發“線上影像處理、線上音影片、線上遊戲”等工具類應用的開發。

場景3開發一些複雜度高的前端元件。我們知道,在開發複雜度高的專案的時候,Javascript經常是力不從心,即使是Typescript也並不會比Javascript有更根本性的改善。相比起來,C#等更適合工程化的開發,因此一些複雜度非常高的前端元件用C#編寫為WebAssembly有可能更合適。

上面提到的這些場景下,我們可以只把部分元件用C#開發為WebAssembly,其他部分以及專案整體仍然可以繼續用Javascript進行開發,這樣各個語言可以發揮各自的特色。

六、 C#回撥JavaScript中的方法

在編寫WebAssembly的時候,我們可能需要在C#中呼叫Javascript中的方法。我們可以透過IJSRuntime、IJSInProcessRuntime分別呼叫Javascript中的非同步方法和同步方法。

再建立一個類庫專案Demo2,首先按照上面第四節中對專案進行配置,不再贅述。

index.html和Program.cs的程式碼如程式碼 5和程式碼 6所示。

程式碼 5 index.html

<html lang="en">
<head>
    <meta charset="UTF-8" />
</head>
<body>
    <div id="divDialog" style="display:none">
        <div id="divMsg"></div>
        <input type="text" id="txtValue" />
        <button id="btnClose">close</button>
    </div>
    <ul id="ulMsgs">        
    </ul>
</body>
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<script>
    function showMessage(msg) {
        const divDialog = document.getElementById("divDialog");
        divDialog.style.display = "block";
        const divMsg = document.getElementById("divMsg");
        divMsg.innerHTML = msg;
        const txtValue = document.getElementById("txtValue");
        const btnClose = document.getElementById("btnClose");
        return new Promise(resolve => {            
            btnClose.onclick = function () {
                divDialog.style.display = "none";
                resolve(txtValue.value);
            };
        });
    }
    function appendMessage(msg) {
        const li = document.createElement("li");
        li.innerHTML = msg;
        const ulMsgs = document.getElementById("ulMsgs");
        ulMsgs.appendChild(li);
    }
    window.onload = async function () {
        await Blazor.start();
        await DotNet.invokeMethodAsync('Demo2','Count');
    };
</script>
</html>

 

程式碼 6 Program.cs

using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.JSInterop;

namespace Demo2;

public class Program
{
    private static IJSRuntime js;

    private static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        var host = builder.Build();
        js = host.Services.GetRequiredService<IJSRuntime>();
        await host.RunAsync();
    }

    [JSInvokable]
    public static async Task Count()
    {
        string strCount = await js.InvokeAsync<string>("showMessage","Input Count");
        for(int i=0;i<int.Parse(strCount);i++)
        {
            ((IJSInProcessRuntime)js).InvokeVoid("appendMessage",i);
        }
        ((IJSInProcessRuntime)js).InvokeVoid("alert", "Done");
    }
}

       Index.html中定義的appendMessage方法是一個同步方法,用於把給定的訊息附加到ul中;showMessage是一個非同步方法,用於顯示一個用html模擬的輸入對話方塊,當使用者點選【Close】按鈕以後關閉對話方塊,並且返回使用者輸入的內容,這個操作涉及Javascript中的Promise相關概念,對這個不瞭解的請檢視相關資料。

                Program.cs中,在Main方法中獲取用於呼叫Javascript程式碼的IJSRuntime服務。IJSRuntime介面中提供了InvokeAsync、InvokeVoidAsync兩個方法,分別用於非同步地呼叫JavaScript中的有返回值和無返回值的方法。如果想同步地呼叫Javascript中的方法,則需要把IJSRuntime型別的物件轉型為IJSInProcessRuntime型別,然後呼叫它的InvokeVoid、Invoke方法。標註了[JSInvokable]的Count()方法是非同步方法,在Javascript中呼叫C#中的非同步方法的方式是一樣的。

七、 執行時編譯C#程式碼:Roslyn

.NET中的Roslyn用於在執行時編譯C#程式碼,Roslyn支援在WebAssembly中使用,所以我們這裡就用這個元件來完成C#程式碼的編譯。Roslyn的用法在網上的資料很多,我這裡不再詳細講解。

唯一需要注意的就是:Roslyn編譯的預設是並行編譯的,因為這樣可以提高編譯速度。但是受限於瀏覽器沙盒環境的限制,WebAssembly不支援並行操作,因此如果用預設的Roslyn編譯設定,在執行編譯操作的時候,Roslyn會丟擲“System.PlatformNotSupportedException: Cannot wait on monitors on this runtime”這個錯誤資訊。因此,需要在CSharpCompilationOptions中設定concurrentBuild=false,如程式碼 7所示。

程式碼 7 關閉concurrentBuild

var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, concurrentBuild: false);
var scriptCompilation = CSharpCompilation.CreateScriptCompilation("main.dll", syntaxTree, options: compilationOptions).AddReferences(references);

 

八、 替換預設Console類的實現

由於瀏覽器執行環境的限制,並不是所有.NET類都可以呼叫的,或者其功能是受限的。比如WebAssembly中呼叫IO類的時候,並不能隨意的讀寫使用者磁碟上的檔案,只能受限於瀏覽器沙盒環境的安全限制。再比如WebAssembly中可以呼叫HttpClient類發出Http請求,但是同樣受瀏覽器的跨域訪問的限制。WebAssembly很強大,但是再強大也跳不出瀏覽器的限制。

在這個線上編譯、執行C#程式碼的工具中,我希望使用者可以使用Console.WriteLine()、Console.ReadLine()來和使用者進行輸出和輸入的互動。然而,在Web Assembly中,Console.WriteLine()是在開發人員工具的控制檯中輸出,相當於執行JavaScript中的console.log();在Web Assembly中,Console.ReadLine()無法使用。因此,我編寫了一個同名的Console類,並且提供了WriteLine()、ReadLine()方法的實現,把它們分別用JavaScript中的alert()、prompt()兩個函式來實現。在使用Roslyn編譯使用者編寫的程式碼的時候,使用我這個自定義的Console類的程式集來代替System.Console.dll,這樣使用者編寫的程式碼中的Console類就呼叫我自定義的類了。

九、 專案的演示和程式碼地址

我把這個專案部署到了網際網路上,大家可以訪問https://block.youzack.com/editor.html來使用它。效果如Figure 2所示。

 

Figure 2執行效果

在程式碼編輯器中編寫C#程式碼,然後點選【Run】按鈕就可以看到程式碼的編譯、執行效果。如果程式碼有編譯問題,介面也會顯示出來詳細的編譯錯誤資訊。

專案的開源地址為:https://github.com/yangzhongke/WebCSC

十、 總結

自從.NET 6開始,我們可以脫離傳統的侵入性強的Blazor WebAssembly框架,從而使用C#編寫輕量級、無侵入的WebAssembly程式,從而和Javascript一起協同開發,讓專案在開發效率、工程化管理等方面取得更好的效果。

本文介紹了用C#開發無侵入的WebAssembly元件,而且分享了一個在瀏覽器端編寫、編譯和執行C#開發的開源專案。

相關文章