← Torna al blog

All'interno dell'architettura Rasepi: Plugin, protezioni d'azione e pipeline

Un approfondimento tecnico su come funzionano il sistema di plugin, la pipeline di action guard e il motore di traduzione a livello di blocco di Rasepi, con codice reale tratto dalla base di codice.

Sotto il cofano
All'interno dell'architettura Rasepi: Plugin, protezioni d'azione e pipeline

La maggior parte delle piattaforme di documentazione parla di "estensibilità" come le compagnie aeree parlano di "spazio per le gambe". Tecnicamente presente, praticamente deludente. Volevo che l'architettura di Rasepi fosse veramente estensibile senza diventare imprevedibile, quindi abbiamo costruito tre sistemi interconnessi: plugins per le capacità, action guards per il controllo e pipelines per l'esecuzione deterministica.

Questo post illustra il funzionamento di ciascuno di essi nella nostra base di codice reale.

Architettura Rasepi: Plugin, Guardie e Pipeline che lavorano insieme

Il sistema di plugin: modulare per design

Ogni plugin in Rasepi implementa IPluginModule, un'unica interfaccia che dichiara che cos'è il plugin, di quali servizi ha bisogno e quali percorsi espone:

public interface IPluginModule
{
    PluginManifest Manifest { get; }
    void RegisterServices(IServiceCollection services);
    void MapRoutes(IEndpointRouteBuilder routes);
}

Il PluginManifest è un dato puro. Descrive il plugin senza eseguire nulla:

public sealed class PluginManifest
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public required string Version { get; init; }
    public string Description { get; init; }
    public string Category { get; init; }
    public IReadOnlyDictionary<string, string> UiContributions { get; init; }
    public bool HasSettings { get; init; }
    public bool HasEndpoints { get; init; }
    public IReadOnlyList<string> Dependencies { get; init; }
}

Notare UiContributions. Questo dizionario mappa i punti di estensione del frontend con i nomi dei componenti, in modo che il frontend Vue sappia quali componenti dell'interfaccia utente contribuisce a ciascun plugin (un pulsante della barra degli strumenti, un pannello della barra laterale, una pagina di impostazioni).

La registrazione è una riga per ogni plugin

All'avvio, registriamo i plugin attraverso un'API fluente:

var pluginRegistry = new PluginRegistry();

pluginRegistry
    .AddPlugin<WorkflowPluginModule>(builder.Services)
    .AddPlugin<RulesPluginModule>(builder.Services)
    .AddPlugin<RetentionPluginModule>(builder.Services)
    .AddPlugin<ClassificationPluginModule>(builder.Services);

Ogni chiamata istanzia il modulo, lo memorizza nel registro e chiama RegisterServices() per collegare le sue dipendenze. Dopo la creazione dell'applicazione, una singola riga mappa tutti i percorsi dei plugin:

app.MapPluginRoutes(pluginRegistry);

Sotto il cofano, ogni plugin riceve un gruppo di rotte con scopa in /api/plugins/{pluginId}/ con l'autorizzazione applicata automaticamente.

Esempio reale: il plugin Workflow

Ecco come appare un plugin reale, il modulo Workflow & Approvals:

public sealed class WorkflowPluginModule : IPluginModule
{
    public const string PluginId = "workflow";

    public PluginManifest Manifest { get; } = new()
    {
        Id = PluginId,
        Name = "Workflow & Approvals",
        Version = "1.0.0",
        Description = "Adds approval workflows to entry publishing.",
        Category = "Workflow",
        HasSettings = true,
        HasEndpoints = true,
        UiContributions = new Dictionary<string, string>
        {
            ["entry.toolbar.publish"] = "WorkflowPublishButton",
            ["entry.sidebar.status"]  = "WorkflowStatusPanel",
            ["hub.admin.settings"]    = "WorkflowHubSettings",
        }
    };

    public void RegisterServices(IServiceCollection services)
    {
        services.AddScoped<IWorkflowService, WorkflowService>();
        services.AddScoped<IActionGuard, WorkflowPublishGuard>();
    }

    public void MapRoutes(IEndpointRouteBuilder routes)
    {
        WorkflowEndpoints.Map(routes);
    }
}

La piattaforma principale non fa mai riferimento a WorkflowService o WorkflowPublishGuard direttamente. Li scopre attraverso il contenitore DI. Questa è la chiave per azzerare l'accoppiamento. L'applicazione principale non tocca mai il codice dei plugin.

Guardie d'azione: il livello di controllo

I plugin aggiungono capacità. Le guardie d'azione decidono se questa capacità, o qualsiasi azione del nucleo, può procedere. Sono validatori sincroni che intercettano le operazioni prima dell'esecuzione.

Flusso di valutazione delle guardie d'azione

L'interfaccia è volutamente minimale:

public interface IActionGuard
{
    string PluginId { get; }
    string? ActionName { get; }  // null means guard ALL actions

    Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default);
}

Quando ActionName è null, la guardia viene eseguita per ogni azione. Quando è impostato su qualcosa come "Entry.Publish", intercetta solo quell'azione specifica.

I contratti di contesto e di risultato

Ogni guardia riceve un contesto tipizzato con il nome dell'azione, il tenant, l'utente, l'entità e un bagaglio di proprietà:

public sealed record ActionGuardContext(
    string ActionName,
    Guid TenantId,
    Guid UserId,
    Guid EntityId,
    IReadOnlyDictionary<string, object?> Properties)
{
    public T? Get<T>(string key) =>
        Properties.TryGetValue(key, out var v) && v is T typed
            ? typed : default;
}

E ogni guardia restituisce un risultato prevedibile: allow, deny o allow-with-modifications:

public sealed record ActionGuardResult
{
    public bool IsAllowed { get; init; }
    public string? ReasonCode { get; init; }
    public string? Message { get; init; }
    public IReadOnlyDictionary<string, object?>? Modifications { get; init; }

    public static ActionGuardResult Allow() =>
        new() { IsAllowed = true };

    public static ActionGuardResult Deny(
        string reasonCode, string message) =>
        new() { IsAllowed = false, ReasonCode = reasonCode, Message = message };
}

Il campo Modifications è importante. Un guardiano può approvare un'azione ma riscrivere parte del contenuto (ad esempio, redigere i segreti prima della pubblicazione).

Nomi canonici delle azioni

Definiamo tutte le azioni intercettabili come costanti di stringa, in modo che non ci siano ambiguità su ciò che un guardiano può indirizzare:

public static class ActionNames
{
    public static class Entry
    {
        public const string Create  = "Entry.Create";
        public const string Save    = "Entry.Save";
        public const string Publish = "Entry.Publish";
        public const string Delete  = "Entry.Delete";
        public const string Archive = "Entry.Archive";
        public const string Renew   = "Entry.Renew";
    }

    public static class Hub
    {
        public const string Create = "Hub.Create";
        public const string Delete = "Hub.Delete";
        public const string TransferOwnership = "Hub.TransferOwnership";
    }

    public static class Translation
    {
        public const string Create  = "Translation.Create";
        public const string Publish = "Translation.Publish";
    }
}

Esempio reale: bloccare la pubblicazione senza approvazione

Il plugin Workflow registra una guardia che intercetta Entry.Publish:

public sealed class WorkflowPublishGuard : IActionGuard
{
    public string PluginId => WorkflowPluginModule.PluginId;
    public string? ActionName => ActionNames.Entry.Publish;

    public async Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var db = services.GetRequiredService<RasepiDbContext>();
        var entry = await db.Entries
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.Id == context.EntityId, ct);

        if (entry is null)
            return ActionGuardResult.Allow();

        var workflowService = services.GetRequiredService<IWorkflowService>();
        var check = await workflowService
            .CheckPublishAllowedAsync(entry.Id, entry.HubId);

        if (check.IsAllowed)
            return ActionGuardResult.Allow();

        return ActionGuardResult.Deny(
            "workflow.approval_required",
            check.Message ?? "Approval required before publishing.");
    }
}

La piattaforma principale non sa nulla dei flussi di lavoro di approvazione. Chiama semplicemente Entry.Publish attraverso la pipeline e la guardia lo blocca se il flusso di lavoro non è stato completato.

La pipeline di azione: dove tutto converge

Il ActionPipeline è il percorso di esecuzione unico per tutte le operazioni protette. Risolve quali protezioni si applicano, le valuta e blocca o esegue l'azione.

public sealed class ActionPipeline : IActionPipeline
{
    public async Task<ActionPipelineResult> ExecuteAsync(
        string actionName,
        ActionGuardContext context,
        Func<Task> action,
        CancellationToken ct = default)
    {
        var result = await EvaluateAsync(actionName, context, ct);
        if (!result.IsAllowed) return result;

        await action();  // All guards passed — execute

        return result;   // Return modifications for caller
    }
}

Il metodo EvaluateAsync fa il lavoro pesante:

public async Task<ActionPipelineResult> EvaluateAsync(
    string actionName,
    ActionGuardContext context,
    CancellationToken ct = default)
{
    // 1. Which plugins are enabled for this tenant?
    var enabledPlugins = await _resolver.GetEnabledPluginIdsAsync();

    // 2. Which guards match this action?
    var applicable = _guards
        .Where(g => enabledPlugins.Contains(g.PluginId))
        .Where(g => g.ActionName == null || g.ActionName == actionName)
        .ToList();

    // 3. Evaluate each guard
    var denials = new List<ActionGuardResult>();
    var modifications = new List<ActionGuardResult>();

    foreach (var guard in applicable)
    {
        try
        {
            var guardResult = await guard.EvaluateAsync(context, _services, ct);
            if (!guardResult.IsAllowed)
                denials.Add(guardResult);
            else if (guardResult.Modifications?.Count > 0)
                modifications.Add(guardResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Guard threw. Treating as Allow.");
        }
    }

    // 4. Any denial blocks the whole action
    if (denials.Count > 0)
        return ActionPipelineResult.Blocked(denials);

    return modifications.Count > 0
        ? ActionPipelineResult.Allowed(modifications)
        : ActionPipelineResult.Allowed();
}

Qui ci sono tre importanti decisioni di progettazione:

  1. Risoluzione per inquilino. Il TenantPluginResolver controlla quali plugin ogni inquilino ha installato e abilitato. Una protezione per un plugin disabilitato non viene mai eseguita.
  2. Tutti devono passare. Se una guardia nega, l'azione viene bloccata. Si tratta di una posizione di sicurezza intenzionale.
  3. **Se una guardia lancia un'eccezione, viene registrata e trattata come Allow(). Questo impedisce a un plugin non funzionante di bloccare l'intera piattaforma.

Risoluzione dei plugin per inquilino

Il resolver interroga la tabella TenantPluginInstallations (automaticamente ottimizzata per il tenant corrente dai filtri di interrogazione globale EF):

public sealed class TenantPluginResolver : ITenantPluginResolver
{
    public async Task<IReadOnlySet<string>> GetEnabledPluginIdsAsync(
        CancellationToken ct = default)
    {
        if (_cache is not null) return _cache;

        var ids = await _db.TenantPluginInstallations
            .Where(i => i.IsEnabled)
            .Select(i => i.PluginId)
            .ToListAsync(ct);

        _cache = ids.ToHashSet();
        return _cache;
    }
}

Effetti collaterali guidati dagli eventi

Le azioni sono sincrone. Gli effetti collaterali non lo sono. Dopo il completamento di un'azione, il servizio pubblica un evento di dominio:

await _eventPublisher.PublishAsync(
    EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });

Gli eventi vengono inseriti in un canale in memoria ed elaborati da un EventConsumerWorker in background. Il worker instrada gli eventi a più sistemi:

  • Tracciamento dell'attività. Registra chi ha fatto cosa, quando
  • Fatturazione delle traduzioni.** Traccia i costi per ogni fornitore
  • Gestori di eventi dei plugin. Qualsiasi plugin può sottoscrivere gli eventi del dominio

I gestori di eventi dei plugin implementano IPluginEventHandler:

public interface IPluginEventHandler
{
    string PluginId { get; }
    IReadOnlyList<string> SubscribedEvents { get; }

    Task HandleAsync(
        string eventName, Guid entityId,
        Guid? tenantId, Guid? userId,
        string payloadJson, IServiceProvider services,
        CancellationToken ct = default);
}

Il worker invoca solo i gestori il cui plugin è abilitato per il tenant. Ciò significa che gli effetti collaterali del plugin A non si disperdono in un inquilino che ha installato solo il plugin B.

Il motore di traduzione a livello di blocco

Questo è il punto in cui l'architettura dà i suoi frutti in modo più evidente.

Traduzione a livello di blocco: solo i blocchi modificati vengono ritradotti

Le piattaforme tradizionali traducono interi documenti. Noi traduciamo singoli blocchi: paragrafi, intestazioni, voci di elenco. Quando un utente modifica un paragrafo in un documento di 50 blocchi, solo quel paragrafo deve essere ritradotto. Questa è la fonte del nostro 94% di risparmio sui costi.

Come vengono creati i blocchi da TipTap JSON

Quando un utente salva un documento, l'editor TipTap invia JSON come questo:

{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "attrs": { "blockId": "a1b2c3d4-..." },
      "content": [{ "type": "text", "text": "Hello world" }]
    }
  ]
}

Il BlockTranslationService analizza questo JSON e crea singoli record EntryBlock:

public async Task<List<EntryBlock>> CreateBlocksFromDocumentAsync(
    Guid entryId, string language, string contentJson,
    int version, Guid userId)
{
    var doc = JsonDocument.Parse(contentJson);
    var content = doc.RootElement.GetProperty("content");

    int position = 0;
    foreach (var node in content.EnumerateArray())
    {
        var blockType = node.GetProperty("type").GetString();
        var blockJson = JsonSerializer.Serialize(node);

        // Strip metadata attrs before hashing
        var hashInput = StripBlockMetaAttrs(blockJson);

        var block = new EntryBlock
        {
            Id = ExtractOrGenerateBlockId(node),
            EntryId = entryId,
            Language = language,
            Position = position++,
            BlockType = blockType,
            ContentJson = blockJson,
            ContentHash = CalculateContentHash(hashInput),
            IsNoTranslate = ExtractNoTranslateFlag(node),
            Version = version,
        };

        _context.EntryBlocks.Add(block);
    }

    await _context.SaveChangesAsync();
    return blocks;
}

Hashing SHA256 per il rilevamento delle stalle

L'hash del contenuto è il cuore del rilevamento delle stalle. Eseguiamo l'hash del contenuto del blocco (dopo aver eliminato gli attributi dei metadati come blockId e deleted) utilizzando SHA256:

private string CalculateContentHash(string content)
{
    using var sha256 = SHA256.Create();
    var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
    return Convert.ToHexString(hashBytes);
}

Quando un blocco sorgente cambia, il suo hash cambia. Il sistema confronta quindi il SourceContentHash di ogni blocco di traduzione con l'hash corrente della sorgente, e le discrepanze vengono contrassegnate con Stale:

public async Task MarkTranslationsAsStaleAsync(List<Guid> changedBlockIds)
{
    var affected = await _context.TranslationBlocks
        .Where(t => changedBlockIds.Contains(t.SourceBlockId))
        .ToListAsync();

    foreach (var translation in affected)
    {
        translation.Status = TranslationStatus.Stale;
        translation.UpdatedAt = DateTime.UtcNow;
    }

    await _context.SaveChangesAsync();
}

Adattamento della struttura

I traduttori possono cambiare i tipi di blocco tra le varie lingue. Un elenco puntato inglese potrebbe diventare un elenco numerato tedesco, una preferenza culturale. Il sistema ne tiene traccia:

var translation = new TranslationBlock
{
    SourceBlockId = sourceBlockId,
    Language = targetLanguage,
    BlockType = translatedBlockType,
    SourceBlockType = sourceBlock.BlockType,
    IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
    SourceContentHash = sourceBlock.ContentHash,
    Status = TranslationStatus.UpToDate,
};

Fornitori di traduzione come plugin

I servizi di traduzione esterni (DeepL, Google Translate, ecc.) si collegano tramite ITranslationProviderPlugin:

public interface ITranslationProviderPlugin : IRasepiPlugin
{
    string[] GetSupportedLanguages();

    Task<string> TranslateAsync(
        string text, string sourceLanguage, string targetLanguage);

    Task<TranslationBatchResult> TranslateBatchAsync(
        Dictionary<string, string> texts,
        string sourceLanguage, string targetLanguage);
}

Il metodo batch riceve un dizionario di ID di blocchi da contenere, li traduce tutti e restituisce le traduzioni con un conteggio di caratteri. Dato che inviamo solo i blocchi non completi, e non l'intero documento, i costi rimangono minimi.

Isolamento degli inquilini: la rete di sicurezza invisibile

Tutti i sistemi sopra descritti funzionano all'interno di un rigoroso isolamento degli inquilini.

Il TenantContextMiddleware risolve l'inquilino dal JWT su ogni richiesta e verifica l'appartenenza:

public async Task InvokeAsync(
    HttpContext context, TenantContext tenantContext, RasepiDbContext db)
{
    var tenantIdClaim = context.User.FindFirstValue("tenant_id");
    var userIdClaim = context.User.FindFirstValue(ClaimTypes.NameIdentifier);

    // Populate scoped context
    tenantContext.TenantId = Guid.Parse(tenantIdClaim);
    tenantContext.UserId = Guid.Parse(userIdClaim);

    // Verify membership — fail closed
    var membership = await db.TenantMemberships
        .Where(m => m.TenantId == tenantContext.TenantId
                  && m.UserId == tenantContext.UserId)
        .FirstOrDefaultAsync();

    if (membership == null)
    {
        context.Response.StatusCode = 401;
        return;  // No membership = no access
    }
}

I filtri globali delle query di Entity Framework assicurano che, anche se uno sviluppatore dimentica di filtrare per tenant, il livello del database lo faccia automaticamente:

modelBuilder.Entity<Hub>()
    .HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);

Il risultato: db.Hubs.ToListAsync() restituisce sempre solo gli hub del tenant corrente. Le fughe di dati richiedono l'aggiramento attivo del filtro di query, che è vietato nella nostra base di codice.

Il quadro completo

Quando un utente fa clic su "Pubblica" su una voce, ecco cosa succede:

  1. **L'autenticazione convalida il JWT, TenantContextMiddleware risolve e verifica l'inquilino.
  2. Il controllore chiama la pipeline. IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
  3. La pipeline risolve le guardie. Interroga i plugin abilitati dall'inquilino e seleziona le guardie applicabili.
  4. Le guardie valutano. La guardia del flusso di lavoro verifica le approvazioni, la guardia della conservazione verifica i criteri, la guardia delle regole convalida il contenuto. Tutti passano? La voce viene pubblicata.
  5. Gli eventi si attivano. L'evento Entry.Published viene richiesto. Un lavoratore in background registra l'attività, aggiorna la fatturazione delle traduzioni e chiama i gestori di eventi del plugin.
  6. Verifica delle traduzioni dei blocchi. I blocchi obsoleti vengono identificati per essere ritradotti.

Ogni livello svolge il proprio lavoro. Nessun livello si inserisce in un altro. Questa è l'architettura.

Non abbiamo costruito questo perché l'estensibilità è di moda. L'abbiamo costruita perché una piattaforma di documentazione che non può adattarsi al flusso di lavoro di ogni team, alla fine sarà sostituita da una che può farlo. E una piattaforma che si adatta senza guardrail finirà per rompere qualcosa di importante.

Mantieni la tua documentazione aggiornata. Automaticamente.

Rasepi impone date di revisione, monitora la qualità dei contenuti e pubblica in oltre 40 lingue.

Inizia gratis →