Как можно использовать .NET из Javascript (React) в 2023 году
Мотивация
Я поддерживаю небольшой проект с диковинными инструментами для динозавров. Код проекта полностью открыт и доступен на GitHub. Он умеет обрабатывать файлы офиса (Microsoft Visio) без установки оного (с помощью библиотек для работы с офисными документами OpenXml, PdfSharp). Обработка файлов ведется на .NET (C#).
Раньше обработка документов производилась на сервере (т.е. файл отправлялся на сервер через интернет). Однако это имеет, как минимум, следующие недостатки:
Отправка файлов и по сети занимает время,
Хостинг сервера небесплатен, даже если это Azure Function или AWS Lambda,
Пользователи боятся отправлять свои драгоценные файлы не пойми куда
Посему решил попробовать использовать новые возможности компиляции кода .NET в WebAssembly, добавленные в .NET 7. Раньше что-то похожее тоже было можно сделать, но там отовсюду торчали уши Blazor, а это значит специальный тип приложения, в общем не так просто добавить в уже существующее. К тому же, назовите меня субъективным, но мне не очень нравятся «хайповые» штуки. Сейчас, в .NET 7 Blazor «отцепили», и оно научилось компилировать в WASM без странных довесков.
По сути все что мне требовалось сделать это заменить вызов API сервера на локальный вызов кода из WASM (из компонента React). Далее процесс по шагам.
Проект для .NET
Подробную инструкцию можно найти на сайте Microsoft или вот например в этом блоге. Здесь небольшая выжимка. Предполагается, что вы используете командную строку (я сам использую Visual Studio Code в качестве редактора). Итак,
Устанавливаем поддержку компиляции в WASM. Устанавливается как отдельный workload. Работает начиная с .NET 7
dotnet workload install wasm-tools
Создаем новый проект
dotnet new console
Подправляем его, чтобы он компилировал в WASM. Альтернативно, можно установить `wasm-experimetal`, который добавит шаблон проекта. Но по сути, для изменения проекта консольного приложения на компиляцию в WASM достаточно добавить в проект несколько строк. Детали можно прочитать на сайте Microsoft. Кроме изменения проекта, нужно еще создать (можно пустой) файл «main.js» (нужен для нормальной работы тулсета, думаю в бедующем починят и необходимость в нем отпадет). В моем случае, я просто создал пустой файл.
Exe
net7.0
browser-wasm
true
main.js
enable
enable
Далее, пишем собственно код в Program.cs. Для этого примера я просто возвращаю длину переданных данных (условного «фала»), понятно что на самом деле в реализации должна быть какая-то разумная обработка. В моем случае можно посмотреть что там на самом деле в репозитории GitHub (файлы разбираются/собираются с помощью OpenXml и PdfSharp).
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
// Еще один "артефакт". Создает Main, который нуже ндля работы тулсета
return;
public partial class FileProcessor
{
// Экспортируем метод
[JSExport]
internal static async Task ProcessFile(byte[] file)
{
await Task.Delay(100); // эмулируем работу
return file.Length;
}
}
В общем этого достаточно, компилируем (можно сразу publish, размер будет поменьше)
dotnet publish -c Release
На выходе получаем папку с файлами «bin/Release/net7.0/browser-wasm/AppBundle». Все DLL файлы лежат в «managed». Из всего этого добра на фронтенде нас интересует только файл «dotnet.js», который собственно и предназначен для интеграции в приложение на javascript. Но на хостинг заливается вся папка, т.е. все файлы в ней, включая вложенные директории. Размер не такой страшный, для «Hello World» это несколько мегабайт.
файлы после компиляции
Веб-проект (vite/react)
Для VisioWebTools у меня используется Astro, но для статьи, для простоты, предположим что фронт у нас это vite + react. Ниже я просто делаю новый проект «myapp» для иллюстрации.
npm create vite@latest -- myapp --template react-ts
cd myapp
npm install
npm run dev
Теперь собственно дорисовываем оставшуюся часть совы вызываем нашу функцию из .NET. Код просто выводит размер выбранного файла, вызывая .NET функцию из проекта выше. На время загрузки кнопка выбора файла блокируется.
import { useDotNet } from './useDotNet'
function App() {
const { dotnet, loading } = useDotNet('/path/to/your/AppBundle/dotnet.js')
const fileSelected = async (e: any) => {
const file = e.target.files[0];
const data = new Uint8Array(await file.arrayBuffer());
const result = await dotnet.FileProcessor.ProcessFile(data)
alert(`Result: ${result}`);
}
return (
<>
>
)
}
export default App
Для удобства использования я сделал кастомный хук («загрузчик»), useDotNet (код ниже). Я не смог заставить бандлер (ни vite, ни webpack) нормально паковать код из AppBundle (включая DLL и другие «интересные» файлы), поэтому используется динамическая загрузка всего этого добра через await import (url). Параметр «url» должен указывать на «dotnet.js», сгенерированный .NET
import { useEffect, useRef, useState } from 'react';
export const useDotNet = (url: string) => {
const dotnetUrl = useRef('');
const [dotnet, setDotNet] = useState(null);
const [loading, setLoading] = useState(true);
const load = async (currentUrl: string): Promise => {
const module = await import(/* @vite-ignore */ currentUrl);
const { getAssemblyExports, getConfig } = await module
.dotnet
.withDiagnosticTracing(false)
.create();
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
return exports;
}
useEffect(() => {
if (dotnetUrl.current !== url) { // safeguard to prevent double-loading
setLoading(true);
dotnetUrl.current = url;
load(url)
.then(exports => setDotNet(exports))
.finally(() => setLoading(false))
}
}, [url]);
return { dotnet, loading };
}
Сборка (CI) и хостинг проекта на GitHub
Моя цель частично состояла в том, чтобы обеспечить проекту VisioWebTools полностью бесплатный хостинг, но все еще иметь возможность выполнять код .NET.
Для сборки проекта я использовал GitHub Actions, для хостинга — GitHub Pages. Бесплатно на сборку оно дает 2000 минут в месяц, что для меня более чем достаточно. Логика такая:
Собираем проект .NET,
Содержимое папки «AppBundle» выкладываем в папку «public» проекта React/Vite. Содержимое этой папки будет просто скопировано в корень собранного приложения (т.е. в корень папки «dist» в данном конкретном случае),
Собираем React приложение в режиме «продакшен». При этом в качестве URL, который используется для загрузки dotnet.js подставляется »/AppBundle/dotnet.js», т.е. ссылка на корень приложения.
GitHub Pages обрабатывает такое нормально, т.е. не ругается на странные типы файлов ».dll» или ».blat» (IIS ругается, для него их надо явно добавлять). Пример конкретной конфигурации для GitHub Actions можно посмотреть здесь.
Заключение
Тестовый («игрушечный») репозиторий здесь. На моей машине все работает :) На безошибочность не претендую, буду рад любым замечаниям и предложениям. На вопросы, абы такие будут, постараюсь отвечать в меру своего понимания. Буду рад если данная статья окажется вам полезна.