La maggior parte delle piattaforme di documentazione parla di "estensibilità" come le compagnie aeree parlano di "spazio per le gambe": tecnicamente presente, praticamente deludente. Volevamo che l'architettura di Rasepi fosse realmente 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 attuale.
Il sistema di plugin: modulare per design
Ogni plugin in Rasepi implementa IPluginModule - un'unica interfaccia che dichiara 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 puro dato. 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; }
}
Notate UiContributions: questo dizionario mappa i punti di estensione del frontend con i nomi dei componenti, in modo che il frontend di 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 compilazione dell'applicazione, una singola riga mappa tutti i percorsi dei plugin:
app.MapPluginRoutes(pluginRegistry);
Sotto il cofano, ogni plugin ottiene un gruppo di rotte con scope a /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 dell'accoppiamento zero: l'applicazione principale non tocca mai il codice dei plugin.
Protezioni delle azioni: il livello di controllo
I plugin aggiungono funzionalità. Le action guards decidono se tale capacità, o qualsiasi azione del nucleo, è autorizzata a procedere. Sono validatori sincroni che intercettano le operazioni prima dell'esecuzione.
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 a 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 (per 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 una guardia 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. Si limita a chiamare Entry.Publish attraverso la pipeline e la guardia lo blocca se il flusso di lavoro non è stato completato.
La pipeline delle azioni: dove tutto converge
Il ActionPipeline è il percorso di esecuzione unico per tutte le operazioni protette. Determina quali sono le protezioni applicabili, le valuta e blocca o esegue l'azione.
CODICE_BLOCCO_10__
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();
}
Tre importanti decisioni di progettazione:
- Risoluzione per inquilino - il
TenantPluginResolvercontrolla quali plugin ogni inquilino ha installato e abilitato. Una protezione per un plugin disabilitato non viene mai eseguita. - Tutti devono passare - se una guardia nega, l'azione viene bloccata. Si tratta di una posizione di sicurezza deliberata.
- Gli errori della guardia non riescono ad aprire - se una guardia lancia un'eccezione, questa viene registrata e trattata come
Allow(). Questo impedisce a un plugin non funzionante di bloccare l'intera piattaforma.
Risoluzione dei plugin per locatario
Il resolver interroga la tabella TenantPluginInstallations (automaticamente con lo scope del tenant corrente grazie ai 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 worker in background EventConsumerWorker. Il worker instrada gli eventi a più sistemi:
- Tracciabilità dell'attività - registra chi ha fatto cosa e quando
- Fatturazione delle traduzioni - tiene traccia dei 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. Questo significa che gli effetti collaterali del plugin A non si disperdono mai in un tenant che ha installato solo il plugin B.
Il motore di traduzione a livello di blocco
È qui che l'architettura dà i suoi frutti più evidenti.
Le piattaforme tradizionali traducono interi documenti. Noi traduciamo singoli blocchi - paragrafi, titoli, 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. L'hash del contenuto del blocco (dopo aver eliminato gli attributi dei metadati come blockId e deleted) viene eseguito con 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, cambia anche il suo hash. Il sistema confronta quindi il SourceContentHash di ogni blocco di traduzione con l'hash corrente dell'origine; le discrepanze sono 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 conto:
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 blocco per il contenuto, 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 descritti in precedenza funzionano in un rigoroso isolamento degli inquilini.
Il TenantContextMiddleware risolve il tenant dal JWT a 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 garantiscono 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 e solo gli hub del tenant corrente. Le fughe di dati richiedono l'aggiramento attivo del filtro delle query, vietato nella nostra base di codice.
L'immagine completa
Quando un utente fa clic su "Pubblica" su una voce, ecco cosa succede:
- Richiesta inserita - l'autenticazione convalida il JWT,
TenantContextMiddlewarerisolve e verifica l'inquilino - Il controllore chiama la pipeline -
IActionPipeline.ExecuteAsync("Entry.Publish", context, action) - La pipeline risolve le protezioni - interroga i plugin abilitati dal tenant, seleziona le protezioni applicabili.
- Le guardie valutano - la guardia del flusso di lavoro controlla le approvazioni, la guardia della conservazione controlla i criteri, la guardia delle regole convalida il contenuto.
- Tutti passano? L'azione viene eseguita - la voce viene pubblicata
- Gli eventi si attivano - l'evento
Entry.Publishedviene richiesto. - Lavoratore in background - l'attività viene registrata, la fatturazione delle traduzioni viene aggiornata, i gestori di eventi del plugin vengono chiamati
- Controllo 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 l'abbiamo costruita perché l'estensibilità è di moda. L'abbiamo costruita perché una piattaforma di documentazione che non può adattarsi al flusso di lavoro di ciascun team sarà sostituita da una che può farlo, e una piattaforma che si adatta senza barriere finirà per rompere qualcosa di importante.