本文中,我將會為大家分享一個如何用.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#開發的開源專案。