La plupart des plateformes de documentation parlent d'"extensibilité" comme les compagnies aériennes parlent d'"espace pour les jambes". Techniquement présent, pratiquement décevant. Je voulais que l'architecture de Rasepi soit réellement extensible sans devenir imprévisible, c'est pourquoi nous avons construit trois systèmes interdépendants : plugins pour les capacités, action guards pour le contrôle, et pipelines pour l'exécution déterministe.
Ce billet explique comment chacun de ces systèmes fonctionne dans notre base de code actuelle.
Le système de plugins : modulaire par conception
Chaque plugin de Rasepi implémente IPluginModule, une interface unique qui déclare ce qu'est le plugin, les services dont il a besoin et les routes qu'il expose :
public interface IPluginModule
{
PluginManifest Manifest { get; }
void RegisterServices(IServiceCollection services);
void MapRoutes(IEndpointRouteBuilder routes);
}
Le PluginManifest est une pure donnée. Il décrit le plugin sans rien exécuter :
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; }
}
Remarquez UiContributions. Ce dictionnaire associe les points d'extension du frontend aux noms des composants, de sorte que le frontend Vue sait à quels composants de l'interface utilisateur chaque plugin contribue (un bouton de la barre d'outils, un panneau de la barre latérale, une page de configuration).
L'enregistrement se fait sur une ligne par plugin
Au démarrage, nous enregistrons les plugins par le biais d'une API fluide :
var pluginRegistry = new PluginRegistry();
pluginRegistry
.AddPlugin<WorkflowPluginModule>(builder.Services)
.AddPlugin<RulesPluginModule>(builder.Services)
.AddPlugin<RetentionPluginModule>(builder.Services)
.AddPlugin<ClassificationPluginModule>(builder.Services);
Chaque appel instancie le module, le stocke dans le registre et appelle RegisterServices() pour connecter ses dépendances. Après la construction de l'application, une seule ligne permet de cartographier toutes les routes des plugins :
app.MapPluginRoutes(pluginRegistry);
Sous le capot, chaque plugin obtient un groupe de routes à /plugins/{pluginId}/ avec une autorisation automatiquement appliquée.
Exemple concret : le plugin Workflow
Voici à quoi ressemble un vrai plugin, le module 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 plateforme principale ne fait jamais référence à WorkflowService ou WorkflowPublishGuard directement. Elle les découvre à travers le conteneur DI. C'est la clé du couplage zéro. L'application principale ne touche jamais au code des plugins.
Action guards : la couche de contrôle
Les plugins ajoutent des capacités. Les gardiens d'action décident si cette capacité, ou toute autre action de base, est autorisée à être exécutée. Ce sont des validateurs synchrones qui interceptent les opérations avant leur exécution.
L'interface est délibérément 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);
}
Lorsque ActionName est null, la garde s'exécute pour chaque action. Lorsqu'il est défini à quelque chose comme "Entry.Publish", il n'intercepte que cette action spécifique.
Les contrats de contexte et de résultat
Chaque gardien reçoit un contexte typé avec le nom de l'action, le locataire, l'utilisateur, l'entité et un sac de propriétés :
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;
}
Et chaque gardien renvoie un résultat prévisible : autoriser, refuser ou autoriser avec 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 };
}
Le champ Modifications est important. Un gardien peut approuver une action mais réécrire une partie du contenu (par exemple, expurger des secrets avant la publication).
Noms canoniques des actions
Nous définissons toutes les actions interceptables comme des constantes de chaîne afin qu'il n'y ait aucune ambiguïté sur ce qu'un gardien peut cibler :
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";
}
}
Exemple réel : bloquer la publication sans approbation
Le plugin Workflow enregistre une garde qui intercepte 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 plateforme principale ne sait rien des workflows d'approbation. Elle appelle simplement Entry.Publish à travers le pipeline, et le gardien le bloque si le workflow n'a pas été complété.
Le pipeline d'actions : là où tout converge
Le ActionPipeline est le chemin d'exécution unique pour toutes les opérations surveillées. Il détermine quelles gardes s'appliquent, les évalue et bloque ou exécute l'action.
CODEBLOCK_10__
La méthode EvaluateAsync fait le gros du travail :
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();
}
Trois décisions importantes en matière de conception :
- **Le
TenantPluginResolvervérifie quels sont les plugins installés et activés par chaque locataire. Un garde pour un plugin désactivé ne s'exécute jamais. - Tout doit passer. Si un garde refuse, l'action est bloquée. Il s'agit d'une position de sécurité délibérée.
- Guard errors fail open. Si une garde lance une exception, elle est enregistrée et traitée comme
Allow(). Cela empêche un plugin défectueux de verrouiller toute la plateforme.
Résolution des plugins par locataire
Le résolveur interroge la table TenantPluginInstallations (automatiquement limitée au locataire actuel par les filtres de requête globaux 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;
}
}
Effets secondaires liés aux événements
Les actions sont synchrones. Les effets secondaires ne le sont pas. Lorsqu'une action est terminée, le service publie un événement de domaine :
await _eventPublisher.PublishAsync(
EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });
Les événements sont mis en file d'attente dans un canal en mémoire et traités par un EventConsumerWorker en arrière-plan. Le travailleur achemine les événements vers plusieurs systèmes :
- Suivi d'activité. Enregistre qui a fait quoi et quand.
- Facturation de la traduction. Suivi des coûts par fournisseur
- Les gestionnaires d'événements des plugins.** N'importe quel plugin peut s'abonner aux événements du domaine.
Les gestionnaires d'événements des plugins implémentent 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);
}
Le travailleur n'invoque que les gestionnaires dont le plugin est activé pour le locataire. Cela signifie que les effets secondaires du plugin A ne fuient jamais dans un locataire qui n'a que le plugin B d'installé.
Le moteur de traduction au niveau du bloc
C'est ici que l'architecture porte ses fruits de la manière la plus visible.
Traduction au niveau des blocs : seuls les blocs modifiés sont retraduits](/fr/blog/img/block-translation.svg)
Les plateformes traditionnelles traduisent des documents entiers. Nous traduisons des blocs individuels : paragraphes, titres, éléments de liste. Lorsqu'un utilisateur modifie un paragraphe dans un document de 50 blocs, seul ce paragraphe doit être retraduit. C'est la source de nos 94 % d'économies.
Comment les blocs sont créés à partir du JSON TipTap
Lorsqu'un utilisateur enregistre un document, l'éditeur TipTap envoie le JSON suivant :
{
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": { "blockId": "a1b2c3d4-..." },
"content": [{ "type": "text", "text": "Hello world" }]
}
]
}
Le BlockTranslationService analyse ce JSON et crée des enregistrements EntryBlock individuels :
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;
}
hachage SHA256 pour la détection des données périmées
Le hachage du contenu est au cœur de la détection des blocs périmés. Nous hachons le contenu du bloc (après avoir supprimé les attributs de métadonnées tels que blockId et deleted) à l'aide de SHA256 :
private string CalculateContentHash(string content)
{
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hashBytes);
}
Lorsqu'un bloc source est modifié, son hachage change. Le système compare alors le SourceContentHash de chaque bloc de traduction au hachage actuel de la source, et les non-concordances sont marquées 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();
}
Adaptation de la structure
Les traducteurs peuvent modifier les types de blocs d'une langue à l'autre. Une liste à puces en anglais peut devenir une liste numérotée en allemand, une préférence culturelle. Le système en tient compte :
var translation = new TranslationBlock
{
SourceBlockId = sourceBlockId,
Language = targetLanguage,
BlockType = translatedBlockType,
SourceBlockType = sourceBlock.BlockType,
IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
SourceContentHash = sourceBlock.ContentHash,
Status = TranslationStatus.UpToDate,
};
Les fournisseurs de traduction en tant que plugins
Les services de traduction externes (DeepL, Google Translate, etc.) se connectent via 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);
}
La méthode batch reçoit un dictionnaire d'identifiants de blocs, les traduit tous et renvoie les traductions avec le nombre de caractères facturés. Comme nous n'envoyons que les blocs périmés, et non le document entier, les coûts restent minimes.
L'isolement des locataires : le filet de sécurité invisible
Tous les systèmes décrits ci-dessus fonctionnent dans le cadre d'une isolation stricte du locataire.
Le TenantContextMiddleware détermine le locataire à partir du JWT à chaque demande et vérifie l'appartenance :
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
}
}
Les filtres de requête globaux d'Entity Framework garantissent que même si un développeur oublie de filtrer par locataire, la couche de base de données le fait automatiquement :
modelBuilder.Entity<Hub>()
.HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);
Le résultat : db.Hubs.ToListAsync() ne renvoie toujours que les hubs du locataire actuel. Les fuites de données nécessitent de contourner activement le filtre de requête, ce qui est interdit dans notre base de code.
L'image complète
Lorsqu'un utilisateur clique sur "Publier" pour une entrée, voici ce qui se passe :
- **L'authentification valide le JWT,
TenantContextMiddlewarerésout et vérifie le locataire. - Le contrôleur appelle le pipeline.
IActionPipeline.ExecuteAsync("Entry.Publish", context, action) - Le pipeline résout les gardes. Interroge les plugins activés par le locataire et sélectionne les gardes applicables.
- **La garde Workflow vérifie les approbations, la garde Retention vérifie la politique, la garde Rules valide le contenu. Tout est validé ? L'entrée est publiée.
- **L'événement
Entry.Publishedest mis en file d'attente. Un travailleur en arrière-plan enregistre l'activité, met à jour la facturation des traductions et appelle les gestionnaires d'événements du plugin. - **Les blocs périmés sont identifiés pour être retraduits.
Chaque couche fait son travail. Aucune couche n'empiète sur une autre. Telle est l'architecture.
Nous n'avons pas conçu cette solution parce que l'extensibilité est à la mode. Nous l'avons construit parce qu'une plateforme de documentation qui ne peut pas s'adapter au flux de travail de chaque équipe finira par être remplacée par une plateforme qui le peut. Et une plateforme qui s'adapte sans garde-fou finira par casser quelque chose d'important.