W niniejszym artykule przedstawiamy „od kuchni” funkcjonalność, mechanikę działania oraz technologie stworzonego przez nas edytora kodu / reguł w ramach Platformy Ferryt. Edytor jest wykorzystywany między innymi w aktywności „Reguła”, którą można stosować w diagramach workflow oraz logiki akcji. Nasz zespół, składający się z przedstawicieli Pionu Rozwoju Oprogramowania w DomData, opisał sposób wykorzystania technologii frontendowych i backendowych przy tworzeniu mechanizmów pisania kodu w edytorze reguł. Oprócz tego nasi eksperci podzielili się swoim wartościowym doświadczeniem z oprogramowaniem Omisharp, kompilatorem Rosylin i platformą Blazor. Z artykułu dowiesz się także jak używać edytora kodu MonacoEditor oraz w jaki sposób zbudować mechanizmy funkcji IntelliSence w technologii webowej.

BPMN (ang. Business Process Modelling Notation) to notacja graficzna do modelowania przepływów pracy procesów lub schematów blokowych procesów biznesowych. Ferryt jest platformą służącą modelowaniu procesów biznesowych zgodnie z notacją BPMN 2.0. Platforma Ferryt udostępnia graficzny designer przepływu – edytor BPMN, w którym twórca procesu ma do dyspozycji zestaw narzędzi do modelowania zdarzeń, aktywności, bramek logicznych i przepływu.

W niniejszym artykule skupiamy się na technicznych aspektach implementacji jednej z aktywności standardu BPMN 2.0   – Business Rule Task.

Aktywność Reguła

Implementacją aktywności Business Rule Task w Ferryt jest aktywność Reguła.

Artykuł nie ma na celu szczegółowego opisu działania aktywności Reguła. Skupia się na aspektach technologicznych rozwiązania. Nadmienić trzeba tylko, iż na platformie Ferryt treść reguły pisze się w języku C#.

Postawione cele

Jednym z celów postawionych w pracach nad rozwojem platformy Ferryt było stworzenie wygodnego i funkcjonalnego narzędzia do pisania kodu w C#. Tak określony cel przełożony został na bardziej szczegółowe wymagania. Aby pisanie kodu C# było wygodne i funkcjonalne postawiliśmy sobie za cel wytworzenie edytora, który:

  • Pozwala na pisanie składni C# z kolorowaniem kodu
  • W trybie online kontroluje poprawność składni i podkreślać błędy
  • Dostarcza mechanizm IntelliSense z podpowiadaniem składni
  • Pozwola na porównywanie wersji procesów, w tym uwidoczni różnice w kodach reguł pomiędzy wersjami
  • Pozwala na tworzenie rozbudowanych reguł z użyciem funkcji
  • Pozwala na wprowadzanie komentarzy do kodu

Wszystkie narzędzia Ferryt w tym także edytor BPMN są narzędziami webowym wytworzonymi w technologii React. Dlatego też pracując nad zagadnieniem musieliśmy znaleźć rozwiązania technologiczne i funkcjonalne, które również będą działać w przeglądarce internetowej.

Aktywność Reguła – technologie implementacji i doświadczenia projektowe

Jakie wymagania zostały zrealizowane? Poniżej przedstawiamy krok po kroku realizację postawionych wymagań.

Kolorowanie kodu

Po reaserachu w kontekście rozwiązania kolorowania kodu została podjęta decyzja o użyciu do edycji i prezentacji kodu MonacoEditor, znanego choćby z Visual Studio Code.

Zastosowaliśmy  plugin monaco-editor, który to jest pakietem npm dla projektów reactowych. Działanie to pozwoliło na zintegrowanie edytora Visual Studio Code z platformą Ferryt w roli edytora kodu.

Wykorzystanie zaimportowanego komponentu wygląda tak:

<MonacoEditor
	language={this.props.codeEditor.language}
	theme={this.props.codeEditor.theme}
	options={options}
	editorDidMount={this.handleEditorDidMount}
	ref={this.setMonacoRef}
/>

Podstawowe opcje konfiguracyjne tego edytora to:

  • Język klienta Monaco – domyślnym językiem dla edytora jest JavaScript, jednakże możliwe jest operowanie na większości współcześnie używanych języków programowania, jak i metajęzyków.
  • Motyw – pozwala na wybór motywu jasnego lub ciemnego edytora.

Domyślną funkcją edytora dla wybranego języka programowania jest kolorowanie składni kodu a także podpowiadanie słów kluczowych i tych, które zostały użyte wcześniej w pisanym przez użytkownika kodzie. Aby zapewnić funkcje znane ze standardowych IDE (takie jak uzupełnianie kodu, listy elementów, informacje o elementach kodu i walidacja w czasie rzeczywistym), należy podpiąć pod edytor zewnętrzny provider, który zwróci takie informacje.

Funkcja podłączająca poszczególne możliwości do MonacoEditor wygląda tak:

private createLanguageClient(monaco: typeof MonacoEditorAPI): void {
	if (CodeEditorRoslyn.isRegistered) {
		return;
	}
	else {
		CodeEditorRoslyn.isRegistered = true;
	}
	monaco.languages.registerCompletionItemProvider(Constants.csharpName, {
		triggerCharacters: [Constants.dot, Constants.bracket],
		provideCompletionItems: this.provideCompletionItems.bind(this)
	});

	monaco.languages.registerHoverProvider(Constants.csharpName, {
		provideHover: (model: MonacoEditorAPI.editor.ITextModel, position: MonacoEditorAPI.Position, token: MonacoEditorAPI.CancellationToken) => this.provideHover(monaco, model, position, token)
	});

	monaco.editor.onDidCreateModel((model) => this.onDidCreateModel(model, monaco));
}

W pierwszej kolejności następuje weryfikacja, czy code editor został zarejestrowany. Jest to konieczne do uruchomienia komunikacji z API. Następnie podpięte zostają dwie podstawowe funkcje z providera (dla kontekstu języka C#), czyli autouzupełnianie kodu (registerCompletionItemProvider) oraz zwracanie opisów elementów w momencie najechania na niego kursorem (registerHoverProvider).

Edytor posiada jeszcze kilka przydatnych dla nas opcji konfiguracyjnych takich jak:

  • readOnly – umożliwia pokazanie edytora w trybie odczytu (bez możliwości edycji).
  • minimap – jej włączenie pokazuje w prawej części edytora (obok scroll’a) minimapę kodu, czyli zminimalizowaną jego wersję, która ułatwia poruszanie się po dłuższych blokach kodu.
  • selectOnLineNumbers – steruje możliwością wyboru całej linii poprzez naciśnięcie jej numeru.
  • wordWrap – steruje zawijaniem linii kodu.
  • colorDecorators – udostępnia kolorowanie kodu według wybranego języka.

Porównanie różnic w wersjach procesów

Nowa generacja platformy Ferryt dostarcza moduł porównywarki procesów, który m.in. pozwala pokazać dla danej reguły nowy kod, usunięty kod, a także różnice w istniejącym już kodzie.

W zaprezentowaniu różnic w kodach reguł pomiędzy obiema wersjami wykorzystaliśmy również MonacoEditor.

Implementacja tego komponentu wygląda tak:

<MonacoDiffEditor
	language={props.settings.language}
	theme={props.settings.theme}
	options={props.settings.options}
	original={props.settings.original}
	value={props.settings.modified}
	editorDidMount={handleDiffEditorDidMount}
	ref={setMonacoRef}
/>

Istotne dla użycia komponentu MonacoDiffEditor są przede wszystkim dwa parametry:

  • original – wartość oryginalnego kodu, który zostanie użyty do porównania.
  • value – wartość aktualnego kodu, z którym porównamy kod oryginalny.

IntelliSense i walidacja kodu

Dostarczenie mechanizmów podpowiadania składni i walidacji kodów okazało się dla nas jednak większym wyzwaniem, niż początkowo zakładaliśmy. Ze wszystkiego jednak można wyciągać wnioski. Początkowo zdecydowaliśmy się na użycie komponentu  MonacoLanguageClient w połączeniu z oprogramowaniem OmniSharp uruchamianym na serwerze.

Rozwiązanie z użyciem OmniSharp  działało sprawnie, jednak charakteryzowało się:

  • Koniecznością utrzymywania połączenia WebSocket pomiędzy przeglądarką a serwerem
  • Ciągłą wymianą danych za pomocą tego łącza
  • Koniecznością uruchamiania dodatkowego procesu OmniSharp po stronie serwera

Ze względu na wymienione wyżej czynniki podjęliśmy próbę zmiany podejścia i przebudowy rozwiązania tak, by wyeliminować konieczność ciągłej komunikacji z serwerem.

Rezygnacja z OmniSharpa okazała się być łatwym zadaniem, ponieważ potrzebne funkcje dla języka C# są dostarczane bezpośrednio przez kompilator Roslyn. Do całkowitego rozwiązania sprawy pozostawała jednak nadal kwestia przesyłania żądań z edytora do kompilatora Roslyn na serwerze a następnie jego odpowiedzi z powrotem. Z pomocą przyszedł nam Blazor – platforma do tworzenia interaktywnego internetowego interfejsu użytkownika po stronie klienta za pomocą platformy .NET. Pozwala ona na tworzenie zaawansowanych aplikacji webowych bez użycia JavaScriptu, a z wykorzystaniem języków dostępnych w platformie .NET, np. C#.

Dokonywane jest to poprzez kompilację kodu .NET do webassembly w formacie WASM, przez co może być uruchomiony natywnie w przeglądarce internetowej. To właśnie ta funkcja Blazora została  wykorzystana do rozwiązania. Szybka próba potwierdziła, że Roslyn można skompilować do formatu WASM oraz to że działa on poprawnie, gdy jest uruchomiony w przeglądarce.

Implementacja rozwiązania z użyciem Roslyn i Blazor

Proces implementacji zespół rozpoczął od utworzenia projektu typu Blazor WebAssembly App dla platformy .NET6.

Poniżej postać początkowa pliku projektu:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="6.0.11" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="6.0.11" PrivateAssets="all" />
  </ItemGroup>

</Project>

Usuwamy z projektu dodane automatycznie pliki. Następnie dodajemy referencje do niezbędnych pakietów kompilatora Roslyn:

  • Microsoft.CodeAnalysis
  • Microsoft.CodeAnalysis.CSharp
  • Microsoft.CodeAnalysis.Features
  • Microsoft.CodeAnalysis.CSharp.Features
  • Microsoft.VisualStudio.Composition

Ostatnim krokiem jest implementacja punktu startowego projektu, czyli metody Main.

public class Program
{
    private static async Task Main( string[] args )
    {
        var builder = WebAssemblyHostBuilder.CreateDefault( args );
        var host = builder.Build();
        await host.RunAsync();
    }
}

Użycie wskazanych na początku funkcji kompilatora Roslyn wymagało jednak przygotowania projektu, który zawierałby wszystkie pliki niezbędne do przygotowania danych przez te funkcje. Budowanie projektu rozpoczęto od utworzenia obszaru roboczego (workplace), czyli kontenera, który miał zawierać projekty, dokumenty i biblioteki stanowiące wsad dla kompilatora Roslyn. Skorzystaliśmy w tej sytuacji z klasy AdhocWorkspace, która pozwala na tworzenie projektów i dokumentów bez potrzeby ich materializacji na dysku:

IExportProviderFactory factory = await GetExportProviderFactory();
ExportProvider exportProvider = factory.CreateExportProvider();
MefHostServices host = MefHostServices.Create( exportProvider.AsCompositionContext() );
var workplace = new AdhocWorkspace( host );

Implementacja metod GetExportProviderFactory oraz AsCompositionContext pochodzi z dyskusji na Githubie, więc zostanie tu pominięta.

Do tak utworzonego obszaru roboczego dodany został projekt:

IEnumerable references = [Lista referencji do bibliotek, które będą mogły być użyte w kodzie];

ProjectInfo projectInfo =
	ProjectInfo.Create(
		ProjectId.CreateNewId(),
		VersionStamp.Create(),
		"Project",
		"Project", LanguageNames.CSharp )
	.WithMetadataReferences( references );

Projekt zawiera dwa pliki:

  • Globals.cs – plik będzie zawierał listę globalnych importów przestrzeni nazw oraz listę obiektów reprezentujących model danych edytowanego procesu. Przykładowa zawartość tego pliku może wyglądać tak:
global using Ferryt.Flow.Core;
global using Ferryt.Flow.Core.Root.Fields;
global using Ferryt.Flow.Runtime.Legacy;
global using System;
global using System.IO;
global using System.Collections.Generic;
global using System.Diagnostics;
global using System.Dynamic;
global using System.Linq;
global using System.Linq.Expressions;
global using System.Text;
global using System.Threading.Tasks;
global using Ferryt.Core.Utils;
global using Ferryt.Flow.Core.Linq;
global using static Globals.Fields;

namespace Globals
{
	public static class Fields
	{
		public static Ferryt.Flow.Core.Root.CF CF = new();
		public static Ferryt.Flow.Core.Root.PF PF = new();
		public static Ferryt.Flow.Core.Root.Fields.Users USER = new();
		public static Ferryt.Flow.Core.Root.Fields.Environment ENV = new();
		public static Ferryt.Flow.Core.Root.Fields.Graphics GRAPHICS = new();
		public static Ferryt.Flow.Core.Info INFO = new();
		public static Loops LOOP = new();
		public static Ferryt.Flow.Core.Root.Fields.Emails MAIL = new();
		public static Ferryt.Flow.Core.Root.Fields.FormScreens G = new()
		public static Ferryt.Flow.Core.Root.Fields.Separators SEP = new();
		public static Ferryt.Flow.Core.Check CHECK = new();
		public static States S = new();
		public static Actions ACTION = new();
	}
}
  • Main.cs – plik będzie zawierał kod aktualnie edytowany przez użytkownika w przeglądarce. Zespół wykorzystał tu wprowadzony w C#9.0 mechanizm instrukcji najwyższego poziomu, co pozwala na uniknięcie konieczności owinięcia kodu w definicję metody Main programu.

Oba pliki są dodawane do projektu, wykorzystując metodę AddOrUpdateDocument:

private async Task<Document> AddOrUpdateDocument( string name, string code )
{
	SourceText text = SourceText.From( code );

	if ( documents.TryGetValue( name, out Document currentDocument ) )
	{
		workspace.TryApplyChanges( currentDocument.WithText( text ).Project.Solution );
		currentDocument = workspace.CurrentSolution.GetDocument( currentDocument.Id );
	}
	else
	{
		currentDocument = workspace.AddDocument( _project.Id, name, text );
	}

	SyntaxTree syntaxTree = await currentDocument.GetSyntaxTreeAsync();
	syntaxTrees[name] = syntaxTree;

	return currentDocument;
}

Metoda pozwala na dodanie dokumentu do projektu, a także jego aktualizację. Jest to niezwykle istotne, gdyż przed każdym pobraniem danych z serwisów kompilatora trzeba zaktualizować zawartość pliku Main.cs tekstem wpisanym przez użytkownika w przeglądarce.

Po przygotowaniu obszaru roboczego możemy przystąpić do implementacji funkcji dla edytora Monaco:

  • Uzupełnianie kodu
    Wykorzystujemy tu klasę CompletionService, który zwraca listę możliwych uzupełnień w oparciu u treść kodu wprowadzanego przez użytkownika (parametr code) oraz pozycję kursora w edytowanym tekście (parametr position).
public async Task<CompletionItem[]> Complete( string code, int position )
{
	Document document = await AddOrUpdateDocument( "Main.cs", code );
	CompletionService completionService = CompletionService.GetService( document );

	CompletionList results = await completionService.GetCompletionsAsync( document, position );
	CompletionItem[] completions = ConvertCompletionList( results );
	return completions;
}
  • Informacja o wskazanym elemencie kodu.
    Skorzystamy z możliwości oferowanych przez klasę QuickInfoProvider, która zwróci informację na temat elementu oraz pozycję początku i końca tekstu elementu w treści kodu.
public async Task<HoverInfo> Hover( string code, int position )
{
	Document document = await AddOrUpdateDocument( "Main.cs", code );
	SyntaxNode syntaxRoot = await document.GetSyntaxRootAsync();
	SyntaxNode expressionNode = syntaxRoot.FindToken( position ).Parent;
	string result = await new QuickInfoProvider().Handle( document, position );
	Location location = expressionNode.GetLocation();
	return
		new HoverInfo()
		{
			Information = result,
			OffsetFrom = location.SourceSpan.Start,
			OffsetTo = location.SourceSpan.End
		};
}
  • Walidacja kodu w czasie rzeczywistym.
    Ta funkcja polega na skompilowaniu aktualnie wprowadzonego kodu i zwróceniu listy błędów kompilacji ze wskazaniem linii kodu, w których wystąpiły.
public async Task<CodeCheckResultItem[]> CodeCheck( string code )
{
	Document document = await AddOrUpdateDocument( "Main.cs", code );
	CSharpCompilation compilation = CSharpCompilation.Create( "Temp", syntaxTrees.Values, options: new CSharpCompilationOptions( OutputKind.ConsoleApplication ), references: _metadataReferences );
	SemanticModel semanticModel = compilation.GetSemanticModel( syntaxTrees["Main.cs"], true );
	ImmutableArray<Diagnostic> diagnostics = semanticModel.GetDiagnostics();
	List<FM.CodeCheckResultItem> result = new( 5 );
	foreach ( Diagnostic diagnostic in diagnostics )
	{
		if ( diagnostic.Location.Kind == LocationKind.SourceFile && diagnostic.Location.SourceTree.FilePath != "Main.cs" )
		{
			// błędy poza plikiem Main.cs ignorujemy
			continue;
		}

		result.Add(
			new()
			{
				Message = diagnostic.GetMessage(),
				OffsetFrom = diagnostic.Location.SourceSpan.Start,
				OffsetTo = diagnostic.Location.SourceSpan.End,
				Severity = ( int ) ConvertSeverity( diagnostic.Severity ),
				Code = diagnostic.Id
			} );
	}
	return result.ToArray();
}

Uruchomienie

Rozwiązanie zostało uruchomione w przeglądarce w osobnym webworkerze by nie obciążać wątku strony. Komunikację z webworkerem jest obsłużone z pomocą pakietu Comlink.

Do wywoływania metod platformy .NET z kodu JavaScript służy funkcja DotNet.invokeMethodAsync, która przyjmuje parametry:

  • Nazwa assembly, które zawiera metodę .NET do uruchomienia
  • Nazwa metody do uruchomienia
  • Argumenty wywołania metody

Wskazana w parametrze metoda musi być metodą statyczną oznaczoną dodatkowo atrybutem Microsoft.JSInterop.JSInvokable.

Zaczynamy od zaimportowania skryptu pakietu Comlink:

importScripts('comlink.js');

Następnie importujemy skrypt ładujący Blazora wygenerowany podczas kompilacji naszego projektu:

importScripts('blazor.webassembly.js');

W kolejnym kroku startujemy silnik Blazora:

Blazor.start();

Na koniec definiujemy metody owijające wywołania kodu .NET:

const conn = {
    complete: async function (code, position) {
        const result = await DotNet.invokeMethodAsync("Ferryt.Flow.Design.Wasm", "Complete", code, position);

        return result;
    },

    hover: async function (code, position) {
        const result = await DotNet.invokeMethodAsync("Ferryt.Flow.Design.Wasm", "Hover", code, position);

        return result;
    },

    codeCheck: async function (code) {
        const result = await DotNet.invokeMethodAsync("Ferryt.Flow.Design.Wasm", "CodeCheck", code);

        return result;
    },
}

…i przygotowujemy je do użycia w wątku strony:

Comlink.expose(conn);

Efekty prac

W wyniku wyżej opisanych prac zespołu w systemie Ferryt 2.0 dostarczony został edytor kodu C#, który:

  • Koloruje składnię kodu
  • Podpowiada w regułach model danych procesu
  • Podpowiada model danych także w strukturach i innych elementach złożonych
  • Waliduje w trybie online wpisany kod
  • Pozwala wprowadzać komentarze do reguł wartości domyślnej
  • W module porównywarki wersji procesów pozwala na wizualizację zmian w regule

Podsumowując

Implementacja mechanizmów tworzenia reguł w edytorze BPMN była dużym wyzwaniem. Na początku mierzyliśmy się z obawami czy w ogóle w technologii webowej uda się zrealizować płynny mechanizm IntelliSense z walidacją kodu w czasie rzeczywistym. Po wdrożeniu rozwiązania z  Omnisharp udało się wypracować docelowe rozwiązanie z użyciem Roslyn.

Z kolei decyzja o wyborze MonacoEditor wydaje się optymalna.  Zaimplementowane mechanizmy pozwalają na płynną pracę z edytorem reguł w BPMN. Wyeliminowanie ciągłych odwołań do serwera pozwala zmitygować ryzyka związane z problemami sieciowym, np. przy pracy zdalnej.

Designer BPMN, jak i całe narzędzie eArchitekt, jest aplikacją webową, uruchamianą w przeglądarce. Finalnie, architekt tworzący proces na platformie Ferryt ma do dyspozycji narzędzie wspierające pisanie kodu źródłowego reguł w języku C#. Zawiera  ono wiele mechanizmów wspierającymi tworzenie kodu podobnych do tych, które są dostępne w profesjonalnych narzędziach developerskich typu Visual Studio.

Autorzy:

Tomasz Jądrzak – Senior Developer, BPMN Architect

Sławek Wenclik – Senior Developer, Team Leader

Dorota Wichowska – Senior Ferryt Manager, Team Leader