Modulaire opzet in Blazor Webassembly

Nu Blazor steeds prominenter in het Microsoft landschap terechtkomt, zie ik de eerste teams productie applicaties opzetten in Blazor. Eén van de uitdagingen die je daarbij hebt, is hoe je de nieuwe applicatie structureert, zodat hij kan groeien zonder dat het een chaotische brij wordt. In de backend systemen hebben we tal van mogelijkheden om applicaties op te knippen. In de front-end zijn die opties lastiger. Ik hoop in dit artikel een paar handvatten te geven om je Blazor client modulair op te zetten.

Modulair versus “Micro” service

De aanpak die ik in dit document beschrijf leidt tot modules in een single page Blazor client. Dit levert meerwaarde in structuur en maakt het ook gemakkelijker om eventueel met meerdere teams aparte modules door te ontwikkelen. Alleen de uiteindelijke verwerking tot één single page client, maakt de applicatie per definitie monolithisch van aard. Dat gaat wringen als de client niet langer een mooi samenhangend geheel aan functionaliteit biedt. Als dat het geval is, overweeg dan om meerdere gespecialiseerde clients op te zetten, of duik eens in de mogelijkheden van piral (https://piral.io/), een framework om micro services in de front-end op te zetten. Voor dit document focus ik enkel op een beter gestructureerde “monoliet” in Blazor.

Basis opzet Blazor client template

Om een modulaire Blazor client op te zetten, starten we met een standaard Blazor client template:

Elke nieuwe client heeft dezelfde opbouw. De wwwroot bevat statische bestanden van het nieuwe client project. Je moet dan denken aan afbeeldingen, javascript, css en eventuele statische html pagina’s. Standaard staat hier ook de index.html als startpagina van de applicatie.

Naast de wwwroot staan de mappen en bestanden die gecompileerd worden tot je applicatie.

De program.cs is, zoals bij de veel dotnet templates, het startpunt van de applicatie. De app.razor bevat router informatie van de applicatie. Deze informatie zorgt ervoor dat de applicatie naar de juiste pagina navigeert op basis van de url in de browser.

De shared map bevat gedeelte (partial) views en layouts en de pages bevatten de razor pages van de applicatie. In de standaard opzet zitten code en de razor pages bij elkaar in één bestand. Eigenlijk elk team dat ik ken, splitst dat op in aparte bestanden voor de code (viewmodels) en de razor syntax (views). Daarmee volgt Blazor het MVVM model zoals je mogelijk van andere templates zoals WPF kent.

Je kunt je voorstellen dat deze standaard opzet van de applicatie al snel ongecontroleerd groeit, naarmate het aantal pagina’s toeneemt, omdat functionaliteit verdeelt kan zijn over razor pagina’s, mogelijke statische bestanden, gedeelde componenten enzovoorts.

Een modulaire opzet opbouwen

De modulaire opzet die we in dit project kiezen zorgt ervoor dat de Blazor client applicatie als een schil draait, terwijl de functionele componenten ieder hun eigen module krijgen:

In deze opzet zou je zover kunnen gaan dat aparte teams aan een module werken, maar ook als je maar één team hebt helpt het om je applicatie gestructureerd te houden. Er is in Visual Studio een standaard template om dit soort modules te bouwen voor Blazor: de “Razor class library”. Door een dergelijk project aan je solution toe te voegen komt je structuur er als volgt uit te zien:

Je ziet dat de nieuwe class library zijn eigen wwwroot heeft voor statische files, en je ziet dat er al een standaard razor component is toegevoegd. De wwwroot van de module en van het client project worden tijdens het bouwen of publiceren samengevoegd.

Uitdaging 1: Loosly coupled modules

Als je met losse modules werkt, wil je voorkomen dat je het hoofdproject moet aanpassen bij elke aanpassing in de module. Om dat te doen, moet de module zoveel als mogelijk losgekoppeld zijn van de clientapplicatie. De standaard program.cs van de clientapplicatie ziet er als volgt uit:

Je ziet hier dat bij builder.Services de registratie van objecten plaatsvindt. Meestal zal dit bij grotere projecten al lang verplaatst zijn naar een aparte plek in de applicatie, maar wat voor onze module belangrijk is, is dat je niet in deze program.cs wijzigingen wilt doorvoeren voor elke wijziging in de module.

Om dat voor elkaar te krijgen voegen we in de module, AppOne in dit voorbeeld, een class toe om de IOC registraties te doen. In dit voorbeeld is het een extension method, maar dat hoeft natuurlijk niet.

In het hoofdproject is het nu voldoende om de registratie van AppOne aan te roepen.  Vanaf dit moment kan AppOne groeien zonder dat er in de registratie in het hoofdproject iets hoeft te wijzigen. Bijkomend voordeel is dat objecten die alleen voor AppOne bedoeld zijn, als internal gedeclareerd kunnen worden.

Uitdaging 2: Routing

Binnen Blazor declareer je de routing meestal als decorator op de razor view:

 

In de app.razor initialiseer je vervolgens de routing:

Standaard zie je dat de routing alleen de assembly van het hoofdproject inleest. Het is vrij eenvoudig om de assemblies van de verschillende modules hieraan toe te voegen, maar het betekend wel een extra plek in de code waarop in het hoofdproject een wijziging nodig is als er een nieuwe module wordt gemaakt. Het aantal plekken waarop het hoofdproject wijzigt bij een wijziging in modules wil je tot een minimum beperken. Voor dit voorbeeld kies ik een eenvoudige opzet die in ieder geval het principe duidelijk maakt van zo min mogelijk afhankelijkheden. Mijn praktisch doel is dat de enige plek waar een module aangemeld moet worden, de program.cs van het hoofdproject is. Dat betekend dat ik een interface nodig heb die zowel in het hoofdproject als in de module bekend is. Met andere woorden, we hebben een eerste cross-cutting functionaliteit nodig die in een gedeelde library komt:

De assembly registratie implementatie kunnen we heel eenvoudig houden voor het doel wat we voor ogen hebben:

De program.cs van het hoofdproject passen we eenmalig aan om de nieuwe assembly registratie toe te voegen:

Je ziet hier een wat afwijkende vorm van registratie van de IAssemblyInterface. In dit geval wordt er bij het opstarten van de applicatie een instantie van de AssemblyRegistrationService geïnstantieerd. In de service provider wordt geregistreerd dat deze instantie van de registration service altijd teruggegeven moet worden indien deze opgevraagd wordt. De reden om dit op deze manier te doen, is dat de AssemblyRegistrationService al gebruikt wordt tijdens het initialiseren van de modules bij AddAppOne. We hebben dus al vroeg in het proces een concrete implementatie nodig.

Uitdaging 3: Navigatie

In dit voorbeeld gebruik ik de default implementatie van de navigatie die je meekrijgt in de template. In de praktijk ziet je navigatiemenu er waarschijnlijk heel anders uit. Het principe blijft echter gelijk: we willen niet dat we in de navigatie aanpassingen moeten doen bij de registratie van een module. Dit betekend dat we in de modules een navigatie structuur moeten definiëren die we kunnen meegeven aan de client applicatie, of die we in de module zelf in de html zetten. In dit voorbeeld breid ik de AssemblyRegistrationService uit met aanvullende functionaliteit om een navigatie item in het hoofdmenu te zetten:

Je kunt de strings in deze implementatie lelijk vinden, maar het gaat me hier enkel om het principe dat je de client applicatie onafhankelijk maakt van de module. In de setup van AppOne registreren we nu naast de assembly ook wat info voor de navigatie:

Als laatste passen we de hoofdnavigatie aan om de module linkjes te renderen:

Je ziet hier dat de pagina automatisch voor elke geregistreerde module een hoofdnavigatie link aanmaakt. Dit concept kun je volledig uitbreiden voor de use case die je voor ogen hebt.

In het model vul je de lijst met modules vanuit de AssemblyRegistration en klaar is kees:

Eindresultaat

Na deze stappen hebben we een opzet gemaakt van onze Blazor client waarbij de client applicatie zelf als host functioneert en alle functionaliteit in modules is gezet. Elke module kan zoveel pagina’s bevatten als nodig is, en doordat elke module zijn eigen wwwroot heeft kunnen ook statische bestanden bij de module worden opgeslagen. Gedeelde componenten worden in de client applicatie zelf, of in de shared library opgenomen.

Ja maar…

Natuurlijk kom je in de praktijk nog veel meer uitdagingen tegen, maar dan zou dit artikel een boek worden. Ik hoop vooral dat dit je een richting geeft die je gaat helpen om je Blazor client beter te structureren.