Capita a tutti di invecchiare e rincoglionirsi, per me il processo è iniziato intorno all’adolescenza, ma è una sorte che prima o poi spetta a tutti quanti. Oddio sto iniziando con una digressione, ma se è all’inizio di un testo, può ancora essere chiamata digressione oppure può benissimo essere definita apertura a vuoto o ad-cazzum? Oh my God, sto introducendo una nuova digressione, sarà una re-digressione o una meta-digressione?!?!?!? OK stop…
Come scritto in precedenza ho ricominciato a scrivere codice su Blazor, non ne comprendo neanche io il reale motivo, forse non voglio essere uno sviluppatore à la page ma non voglio neanche diventare un dinosauro, mi piace piuttosto rimanere fra “tra incudine e martello”, non riscendo a decidere di quale morte voglia morire. Ho iniziato a sviluppare una serie di involucri (wrapper) per librerie API JavaScript, così da studiare le nuove strategie di sviluppo introdotte su Blazor, visto che ero rimasto fermo alle versione .NET 4.x, imparando a mie spese le peculiarità del nuovo JS Interop sviluppato da Microsoft. Uno dei primi problemi che mi sono trovato ad affrontare sin da (praticamente) subito è stato quello del dover creare script .JS personalizzati anche solo per leggere/scrivere una misera proprietà o invocare il costruttore di una classe JavaScript. Questo ha introdotto il “nuovo” (che poi tanto nuovo non era) problema del dover gestire il caricamento del modulo, coi relativi script JavaScript annessi, ed in modalità on-demand: ovvero solo quando ce ne fosse stato realmente bisogno. Questo ha introdotto una nuova problematica del dover gestire lo smaltimento del modulo una volta che…
… eh no, caro Zio Billy*, stavolta mi sono fatto furbo! 😏
(* Zio Billy per me sarebbe Bill Gates che non è più in Microsoft da 20 anni, ecco sono così vecchio io)
All’inizio mi sono portato dietro questa triste storia del modulo, ovvero un’istanza di IJSObjectReference ottenuta tramite il comando ES6 “import“, per diverso tempo, ed ho inventato un sistema di self-scoped module che si caricava automaticamente e dinamicamente, per ogni istanza di oggetto/wrapper nel .NET, tramite la system class Lazy<T>. L’idea mi era venuta studiando il codice di un’altra libreria wrapper su GitHub e credevo fosse una best-practice del mondo Blazor contemporaneo, ma ben presto m’è iniziata ad essere ingombrante. Successivamente ho implementato un servizio singleton/scoped per centralizzare la gestione del modulo in un unico punto, mantenendo libera l’opzione del self-scoped module qualora fosse stato necessario (ma sconsigliato). Poi mi è iniziata a stare stretta anche questa soluzione. Da quando ho escogitato un sistema di Factory pattern simile alla dependency inection, la situazione è diventata claustrofobicamente insostenibile.
Premetto che il percorso non è stato poi così semplice e lineare come lo sto romanzando ora, anzi, si è rivelato piuttosto irto di ostacoli. Il primo passo è stato aggiornare al .NET 10 SDK (inizialmente BETA ora RC1) e questo mi ha consentito di poter manipolare proprietà su istanze JavaScript e invocare costruttori senza più la necessità di un modulo JS esterno e personalizzato: era ora, mannaggia il clero, dopo anni!!! Ma questo non mi ha permesso di liberarmi completamente dal modulo JavaScript esterno, ci sono dei metodi e delle funzioni che devono necessariamente passare ancora per l’interop di JavaScript … sostanzialmente bisogna sporcarsi le manine col codice JScript, che ci piaccia o no. Ma questo modulo ce lo avevo sempre in mezzo ai piedi, mi molestava oltremodo con la sua presenza, ed alla fine ho deciso di passare ad una vecchia pratica che si seguiva inizialmente su Blazor: un modulo JavaScript importato staticamente al caricamento iniziale (cioè sull’index.html) e che contiene tutto il codice JavaScript fondamentale richiesto dai wrapper. “Oldie but goldie” dicono gli Americani e bisogna dargli ragione! Caricare codice JavaScript sulla globalThis (o window che dir si voglia) sarà anche un anti-pattern, una porcata, un barbatrucco, chiamatelo pure come volete: ma è terribilmente comodo! Si trasforma come una sorta di DLL caricata al lancio di un EXE, c’hai tutte le funzioni in esso contenute disponibili, accessibili da ogni parte dell’applicazione da cui le invochi 🤤
L’altro grosso problema che tutti gli sviluppatori Blazor si trovano ad affrontare è (anche) quella del dover gestire la versione asincrona delle chiamate, Out-of-Process potrebbe essere definita, che sono quelle consentite sia in modalità Blazor WebAssembly stand-alone sia in modalità Blazor Server, dove in quest’ultimo caso sono imprescindibili, dato che la comunicazione client-server avviene tramite SignalR. E la versione sincrona delle chiamate, ovvero quelle InProcess, permessa solo in modalità WebAssembly browser. Pure in questo caso, non sto qui a raccontarvi le peripezie, non riuscivo a trovare una soluzione che realmente mi soddisfasse, ho approntato diverse strategie per la loro gestione comune ed alla fine ero riuscito a realizzare un espediente che però era talmente cervellotico, talmente fuori dagli schemi, che io stesso ogni 30 minuti mi domandavo: ma che cazzo stai affà? Un mantra trasformatosi in una specie di pomodoro-time de’ Noantri, soprattutto perchè nel mio dialogo interiore queste parole risuonavano con l’inflessione di Alberto Sordi. Però anche se la mia poco-geniale-benchè-funzionale soluzione richiedeva fottilioni di righe di codice boiler-plate, abusava eccessivamente della parola chiave “new” e si arrotolava sulle gerarchie di ereditarietà che manco nella teoria delle superstringhe, faceva comunque il suo porco lavoro…
La mia vita scorreva giuliva e pasquosa, fino a quando la gerarchia delle API JavaScript con cui mi sono dovuto scontrare non s’è complicata. A quel punto ho riscoperto la suprema arte della bestemmia in Aramaico antico, visto che anni di meditazione mindfulness, pratiche zen e gestione dello stress sono andati totalmente a puttane a velocità warp. Sono finito in una situazione talmente ingarbugliata, chiuso in un vicolo cieco talmente buio da cui non sapevo come trovare una via d’uscita. Avere una doppia gerarchia parallela fra classi Out-of-Process e InProcess è una buona pratica nel pagadigma OOP ma diventa terribilmente difficile da gestire quando i loro livelli si inestricano, specialmente con l’arrivo delle classi generiche. A quel punto semplificare il core della gerarchia principale, per mantenerla lineare e separata nettamente fra InProcess e Out-of-Process, diventa una necessità tecnica piuttosto che un capriccio da sviluppatori evangelisti o fricchettoni.
Rimpiango i cari vecchi tempi del C++, *IL* linguaggio di programmazione che consentiva l’eredità multipla fra le classi, ma che sfortunatamente portava al problema dei diamanti. Per tale sfrontatezza è stato prima declassato a linguaggio morto, successivamente accusato di eresia, poi scomunicato dalle religioni di tutto il mondo ed infine marchiato a fuoco come il linguaggio del diavolo. Con il suo aiuto avrei risolto il problema delle classi Out-Of-Process/InProcess in maniera elegante ed in pochissimo tempo, ma quel periodo della nostra vita è ormai superato, come è accaduto con il GameBoy o le macchine fotografiche Polaroid.
Non sapevo, non riuscito, non potevo … ero smarrito😵💫
Finchè un bel giorno non mi arriva Grok! Quell’intelligenza artificiale là, sarà pure stata creata stronza esattamente come il padrone, proprio a sua immagine e somiglianza, ma Cristo di Dio quanto sta avanti rispetto alle altre! Un giorno discutendo con lui/lei riguardo la tecnica migliore per adattare un sistema di proxy partern al mio cervellotico e poco-geniale-benchè-funzionale™ engine (eh si ™ perchè ormai sono in fase di registrazione del marchio) è uscita fuori la parolina magica: Source Code Generator di Roslyn 🤩
Sapete questi sono di quegli insight significativi della vita che una volta manifestati, vi cominciano a frullare nel cervello come un tarlo, un assillo, un tormento. Un po’ come succede quando vi passa accanto la 20enne gnocca col pap test in bella vista o la 60enne pseudo-intellettuale coi capelli color lillà; ed è così che è stato per me da quel “Source Generator” buttato là dal rampollo di Elon Musk. Ho iniziato a ragionarci sopra ed ho pensato: ma perchè non usare il source generator per creare una gerarchia separata fra Out-of-Process e InProcess dei wrapper Blazor/JS?
Ed eccomi qui oggi a scrivere riguardo le prodezze del source code generator di Roslyn. Se pensate che Satana sia una brutta persona, molto probabilmente non avete mai avuto a che fare con l’informatico che ha delirato concepito le API Roslyn del pacchetto Microsoft.CodeAnalysis.CSharp. La mia idea era molto semplice: creare 2 gerarchie di classi completamente separate. Nella versione Out-of-Process delle classi ci scrivo il codice sorgente asincrono, che come detto sarà compatibile per tutti i modelli di hosting (Server o WASM). Mentre la gerarchia di classi InProcess deriva dalle classi Out-of-Process, ma solo a livello interfaccia, non di classi (le cui gerarchie, ripeto, ora sono separate), e conterrà ANCHE il codice sincrono. Le classi/interfacce InProcess derivano perchè DEVONO DERIVARE dalle Out-of-Process, e questo in quanto alcune chiamate JavaScript sono intrinsecamente asincrone, come ad esempio le promises, e non è possibile (in Blazor) poterle invocare sincronicamente, neanche smadonnando con arcaici rituali oscuri tipo GetAwaiter ().GetResult () – semplicemente non funzioneno!!!
Quindi cosa ho fatto, le classi Out-of-Process sono state il riferimento principale del mio coding, coloro che contengono tutta la business-logic cicciosa dei wrapper, mentre le InProcess contengono solo metodi e proprietà sincrone. Beh in verità le cose non stanno esattamente così, perchè alcune chiamate che possono essere sincrone sarebbe meglio chiamarle comunque asincronicamente vicerversa alcune chiamate asincrone volendo possono essere anche invocate sincronicamente, è un ratatouille pazzesco! Ma quello che conta è che ho creato una mia convenzione stilistica nello scrivere le classi che mi ha consentito di implementare un source generator che riempisse le differenze nelle classi InProcess, colmandole col codice non-scritto prelevato direttamente dalle classi Out-of-Process. Fa tutto in automatico ed ottimizza su classi partial là dove il mio meraviglioso ingegno non è intervenuto.
Partiamo dall’inizio: definire l’attributo. Ho creato un attributo chiamato [GenerateMissingFrom] che va posizionato sulla testa della definizione di una classe InProcess, specificando l’interfaccia Out-of-Process che funge da guida per l’elenco dei metodi asincroni da clonare, come primo parametro, e la relativa classe Out-of-Process che implementa quella specifica interfaccia e che è la sorgente (fisica) da cui prelevare il testo del codice sorgente C# che manca dalla classe InProcess.
using System;
namespace it.medieval.Wrappers.WrapperGenerator
{
[AttributeUsage (AttributeTargets.Class, Inherited = false)]
public sealed class GenerateMissingFromAttribute : Attribute
{
public Type InterfaceType { get; }
public Type SourceType { get; }
public GenerateMissingFromAttribute (Type interfaceType, Type sourceType)
{
InterfaceType = interfaceType;
SourceType = sourceType;
}
}
}
Poi ho iniziato a comporre il vero e proprio source generator ed è stato uno degli sviluppi di codice più difficili che abbia mai condotto. Non conoscevo affatto questo tipo di API, in 30 anni di mestiere non mi era mai capitato, se non un paio di volte con le vecchie T4 (Text Template Transformation Toolkit) ma poca roba. Ovviamente ho fatto un (ab)uso estremo delle intelligenze artificiali, senza le quali non sarei mai riuscito con le mie sole forze a venirne a capo, se non forse dopo mesi/anni di studio intensivo. Ma vi confesso che anche le IA hanno iniziato a prendere i loro tipici svarioni molto velocemente, ne ho usate ben 5 (Copilot, Grok, Gemini, DeepSeek e Perplexity) per giungere ad una prima proto-versione funzionante (ALPHA), Ben presto ho dovuto dedicarmici in totale autonomia, e sempre con un supporto IA secondario, perchè i riferimenti sono oggettivamente molto complessi da seguire. Rocambolescamente, alla fine sono arrivato a questa BETA per ora abbastanza funzionante, almeno per le attuali necessità:
using System;
using System.Linq;
using System.Text;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace it.medieval.Wrappers.WrapperGenerator
{
[Generator]
public class WrapperGenerator : IIncrementalGenerator
{
private const string SUFFIX = "Async";
private static readonly string[] ExtraInterfaceMetadataNames = new[]
{
"it.medieval.Wrappers.IJSStreamableInProcess",
"it.medieval.Wrappers.IBinaryConvertibleInProcess",
};
public void Initialize (IncrementalGeneratorInitializationContext ctx)
{
var candidates = ctx.SyntaxProvider
.CreateSyntaxProvider (predicate: static (node, _) => node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0,
transform: static (gsctx, _) => GetGenerationInfo (gsctx))
.Where(info => info is not null)
.Collect();
ctx.RegisterSourceOutput(candidates, (spc, infos) =>
{
foreach (var info in infos!)
GenerateFor(spc, info!);
});
}
private record GenerationInfo (
string TargetNamespace,
string TargetClassName,
INamedTypeSymbol InterfaceSymbol,
INamedTypeSymbol SourceSymbol,
INamedTypeSymbol TargetSymbol,
ImmutableArray<INamedTypeSymbol> ClosedExtraInterfaces);
private static GenerationInfo? GetGenerationInfo (GeneratorSyntaxContext gsctx)
{
// a) Prendo la dichiarazione e il simbolo
var classDecl = (ClassDeclarationSyntax)gsctx.Node;
var targetSym = gsctx.SemanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
if (targetSym == null) return null;
// b) Cerco [GenerateMissingFrom(interfaceType, sourceType)]
var attr = targetSym.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "GenerateMissingFromAttribute");
if (attr == null) return null;
// c) Leggo i due argomenti Type: IBlob e JBlob
var ifaceType = attr.ConstructorArguments[0].Value as INamedTypeSymbol;
var srcType = attr.ConstructorArguments[1].Value as INamedTypeSymbol;
if (ifaceType == null || srcType == null) return null;
// 1) Risolvo le extra aperte
var compilation = gsctx.SemanticModel.Compilation;
var extraOpens = ExtraInterfaceMetadataNames
.Select(n => compilation.GetTypeByMetadataName(n))
.Where(s => s != null)
.Cast<INamedTypeSymbol>()
.ToArray();
// 2) Trovo quali extra sono implementate da targetSym
var closedExtras = extraOpens
.Select(eOpen =>
targetSym.AllInterfaces
.FirstOrDefault(i =>
SymbolEqualityComparer.Default.Equals(
i.OriginalDefinition, eOpen)))
.Where(i => i != null)
.ToImmutableArray()!;
return new GenerationInfo(
TargetNamespace: targetSym.ContainingNamespace.ToDisplayString(),
TargetClassName: targetSym.Name,
InterfaceSymbol: ifaceType,
SourceSymbol: srcType,
TargetSymbol: targetSym,
ClosedExtraInterfaces: closedExtras);
}
private static bool IsAsyncReturn (ITypeSymbol ret)
{
var fullName = ret.OriginalDefinition.ToDisplayString ();
return fullName == "System.Threading.Tasks.Task"
|| fullName.StartsWith ("System.Threading.Tasks.Task<")
|| fullName == "System.Threading.Tasks.ValueTask"
|| fullName.StartsWith ("System.Threading.Tasks.ValueTask<");
}
public static string ToFromResult (IMethodSymbol ifaceM)
{
var isTask = ifaceM.ReturnType.OriginalDefinition.ToDisplayString ().StartsWith ("System.Threading.Tasks.Task", StringComparison.Ordinal);
var result = isTask ? "Task.FromResult" : "ValueTask.FromResult";
if ((ifaceM.ReturnType is INamedTypeSymbol named) && (named.TypeArguments.Length == 1))
{
var inner = named.TypeArguments[0];
var innerName = inner.ToDisplayString (SymbolDisplayFormat.MinimallyQualifiedFormat);
result += $"<{innerName}>";
}
return result;
}
private static void GenerateFor (SourceProductionContext spc, GenerationInfo info)
{
// 1) Trovo tutti i membri async dell’interfaccia principale, su istanza diretta non per i suoi genitori
var asyncIfaceMethods = info.InterfaceSymbol.GetMembers ()
.OfType<IMethodSymbol> ()
.Where (m => (m.MethodKind == MethodKind.Ordinary) && IsAsyncReturn (m.ReturnType))
.ToList ();
// 2) Prendo tutti i membri delle interfacce extra, includendo tutta la gerarchia dei genitori
var extraAll = info.ClosedExtraInterfaces
// per ogni extra: prendo lei stessa e tutte le sue super‐interface
.SelectMany (iface => iface.AllInterfaces.Append (iface))
// ricavo i method symbols
.SelectMany (i => i.GetMembers ().OfType<IMethodSymbol> ())
.Where (m =>
m.MethodKind == MethodKind.Ordinary &&
!m.IsStatic &&
m.DeclaredAccessibility == Accessibility.Public)
.Distinct (SymbolEqualityComparer.Default)
.Cast<IMethodSymbol> ()
.ToList ();
// 3) unisco e deduplico
var ifaceMethods = asyncIfaceMethods
.Concat (extraAll)
.Distinct (SymbolEqualityComparer.Default)
.Cast<IMethodSymbol> ()
.ToList ();
// 4) Scarto quelli già implementati nella classe target
var missing = ifaceMethods
.Where (ifm => info.TargetSymbol.FindImplementationForInterfaceMember (ifm) == null)
.ToList ();
if (!missing.Any ()) return;
// 5) Preparo il StringBuilder
var sb = new StringBuilder ();
sb.AppendLine ($"namespace {info.TargetNamespace}");
sb.AppendLine ( "{");
sb.AppendLine ($" public partial class {info.TargetClassName}");
sb.AppendLine ( " {");
// 6) Per ogni membro mancante, copio il MethodDeclarationSyntax dal sourceType
var allMembers = info.TargetSymbol.GetMembers ();
var allProperties = allMembers.OfType<IPropertySymbol> ();
var allMethods = allMembers.OfType<IMethodSymbol> ();
foreach (var ifaceM in missing)
{
// Gestione custom di proprietà e metodi async -> sync...
if (ifaceM.Name.EndsWith (SUFFIX, StringComparison.Ordinal))
{
var baseName = ifaceM.Name.Remove (ifaceM.Name.Length - SUFFIX.Length);
// Cerco la property (sync) nella main interface...
if (ifaceM.Parameters.Length == 0)
{
var prop = allProperties.FirstOrDefault (p => p.Name == baseName);
if (prop != null)
{
var retType = ifaceM.ReturnType.ToDisplayString (SymbolDisplayFormat.MinimallyQualifiedFormat);
var factory = ToFromResult (ifaceM);
sb.AppendLine($" // Wrapped '{ifaceM.Name}' via property (by {nameof (WrapperGenerator)})");
sb.AppendLine($" public {retType} {ifaceM.Name} () => {factory} ({baseName});");
sb.AppendLine();
continue;
}
}
// Cerco un metodo (sync) con stesso nome, numero e tipi di parametri
var syncMeth = allMethods.FirstOrDefault (m =>
m.Name == baseName &&
m.MethodKind == MethodKind.Ordinary &&
m.Parameters.Length == ifaceM.Parameters.Length &&
m.Parameters
.Zip (ifaceM.Parameters, (tgt, src) => tgt.RefKind == src.RefKind && SymbolEqualityComparer.Default.Equals (tgt.Type, src.Type))
.All (valid => valid));
if (syncMeth != null)
{
// Ricostruisco la firma dei parametri
var paramList = string.Join(", ",
ifaceM.Parameters.Select(p => $"{p.Type.ToDisplayString (SymbolDisplayFormat.MinimallyQualifiedFormat)} {p.Name}"));
// Ricostruisco l'invocazione del metodo sync
var argList = string.Join (", ",
ifaceM.Parameters.Select (p =>
p.RefKind != RefKind.None
? $"{p.RefKind.ToString ().ToLower ()} {p.Name}"
: p.Name));
// Decido Task/ValueTask in base al ReturnType di ifaceM
if (ifaceM.ReturnType is INamedTypeSymbol named)
{
sb.AppendLine ($" // Wrapped {ifaceM.Name} via sync‐method {baseName} (by {nameof(WrapperGenerator)})");
var retType = named.ToDisplayString (SymbolDisplayFormat.MinimallyQualifiedFormat);
if (named.Arity == 1)
{
var factory = ToFromResult (ifaceM);
sb.AppendLine ($" public {retType} {ifaceM.Name} ({paramList}) => " + $"{factory} ({baseName} ({argList}));");
}
else
{
sb.AppendLine ($" public {named} {ifaceM.Name} ({paramList})");
sb.AppendLine ($" {{");
sb.AppendLine ($" {baseName} ({argList});");
sb.AppendLine ($" return {retType}.CompletedTask;");
sb.AppendLine ($" }}");
}
sb.AppendLine ();
continue;
}
}
}
// Fallback di copia diretta del metodo 1:1...
var implSym = info.SourceSymbol.FindImplementationForInterfaceMember (ifaceM) ?? throw new InvalidOperationException ($"Nessuna implementazione trovata per {ifaceM.Name}");
var syntaxRef = implSym.DeclaringSyntaxReferences.FirstOrDefault ();
if (syntaxRef == null) continue;
var methodNode = syntaxRef.GetSyntax() as MethodDeclarationSyntax;
if (methodNode == null || (methodNode.Body == null && methodNode.ExpressionBody == null)) continue;
var methodText = methodNode.ToFullString().Trim ('\r', '\n');
sb.AppendLine($" // Copied from {info.SourceSymbol.Name} (by {nameof (WrapperGenerator)})");
sb.AppendLine($"{methodText}");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
// 7) Aggiungo la sorgente generata
spc.AddSource ($"{info.TargetClassName}.MissingAsync.g.cs", SourceText.From (sb.ToString (), Encoding.UTF8));
}
}
}
Funziona dannatamente bene! Non sono ancora riuscito a fargli elaborare le classi generic, e fortunatamente per il momento ho una sola grande classe di tipo generica che ho gestito manualmente sin dall’inizo quindi non mi ha creato particolari problemi col source generator.
Ed ora, tutto tronfio della mia baldanza codereccia, sono persino pronto a creare un nuovo source generator per il proxy pattern che volevo tanto implementare nei mei wrapper 😁
Gerçekten çok faydalı ve bilgilendirici bir içerik olmuş, emeğinize sağlık.