本文中,我將會為大家分享一個如何用.NET技術開發“在瀏覽器端編譯和運行C#代碼的工具”,核心的技術就是用C#編寫不依賴於Blazor框架的WebAssembly以及Roslyn技術。 一、 為什麼要開發這樣的工具? 對於編程初學者來講,開發環境的安裝配置是一個令人頭疼的事情,如果能讓初學者不用做任 ...
本文中,我將會為大家分享一個如何用.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#開發的開源項目。