← Zurück zum Blog

Innerhalb der Rasepi-Architektur: Plugins, Action Guards und Pipelines

Ein tiefgehender technischer Überblick darüber, wie das Plugin-System, die Action Guard Pipeline und die Block-Level Translation Engine von Rasepi tatsächlich funktionieren - mit echtem Code aus der Codebasis.

Innerhalb der Rasepi-Architektur: Plugins, Action Guards und Pipelines

Die meisten Dokumentationsplattformen sprechen über "Erweiterbarkeit" so wie Fluggesellschaften über "Beinfreiheit" - technisch vorhanden, praktisch enttäuschend. Wir wollten, dass die Architektur von Rasepi wirklich erweiterbar ist, ohne unvorhersehbar zu werden, also haben wir drei ineinander greifende Systeme entwickelt: Plugins für Fähigkeiten, Action Guards für die Kontrolle und Pipelines für die deterministische Ausführung.

In diesem Beitrag wird erläutert, wie jedes dieser Systeme in unserer aktuellen Codebasis funktioniert.

Rasepi-Architektur: Plugins, Wächter und Pipelines arbeiten zusammen

Das Plugin-System: von vornherein modular

Jedes Plugin in Rasepi implementiert IPluginModule - ein einziges Interface, das deklariert, was das Plugin ist, welche Dienste es benötigt und welche Routen es bereitstellt:

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

Der PluginManifest ist ein reiner Datenblock. Er beschreibt das Plugin, ohne etwas auszuführen:

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; }
}

Beachten Sie UiContributions - dieses Wörterbuch ordnet Frontend-Erweiterungspunkte den Komponentennamen zu, so dass das Vue-Frontend weiß, welche UI-Komponenten jedes Plugin beiträgt (ein Toolbar-Button, ein Sidebar-Panel, eine Einstellungsseite).

Die Registrierung ist eine Zeile pro Plugin

Beim Start registrieren wir die Plugins über eine fließende API:

var pluginRegistry = new PluginRegistry();

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

Jeder Aufruf instanziiert das Modul, speichert es in der Registry und ruft RegisterServices() auf, um seine Abhängigkeiten zu verdrahten. Nachdem die App gebaut wurde, werden alle Plugin-Routen in einer einzigen Zeile zugeordnet:

app.MapPluginRoutes(pluginRegistry);

Unter der Haube erhält jedes Plugin eine skalierte Routengruppe bei /api/plugins/{pluginId}/, wobei die Autorisierung automatisch angewendet wird.

Reales Beispiel: das Workflow-Plugin

Hier sehen Sie, wie ein echtes Plugin aussieht - das Modul Workflow & Genehmigungen:

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);
    }
}

Die Kernplattform verweist niemals direkt auf WorkflowService oder WorkflowPublishGuard. Sie findet sie über den DI-Container. Das ist der Schlüssel zur Null-Kopplung - die Kernanwendung berührt niemals den Plugin-Code.

Aktionswächter: die Kontrollschicht

Plugins fügen Fähigkeiten hinzu. Aktionswächter entscheiden, ob diese Fähigkeit - oder irgendeine Kernaktion - ausgeführt werden darf. Sie sind synchrone Validatoren, die Operationen vor der Ausführung abfangen.

Aktionswächter-Bewertungsablauf

Die Schnittstelle ist absichtlich minimal:

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

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

Wenn ActionName auf null steht, läuft die Wache für jede Aktion. Wenn er auf etwas wie "Entry.Publish" gesetzt ist, fängt er nur diese spezielle Aktion ab.

Der Kontext und die Ergebnisverträge

Jeder Guard erhält einen typisierten Kontext mit dem Namen der Aktion, dem Mandanten, dem Benutzer, der Entität und einem Property Bag:

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;
}

Und jeder Guard liefert ein vorhersehbares Ergebnis - allow, deny oder 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 };
}

Das Feld Modifications ist wichtig - ein Guard kann eine Aktion genehmigen, aber einen Teil des Inhalts umschreiben (z. B. Geheimnisse vor der Veröffentlichung herausnehmen).

Kanonische Aktionsnamen

Wir definieren alle abfangbaren Aktionen als String-Konstanten, damit es keine Unklarheiten darüber gibt, worauf ein Guard abzielen kann:

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";
    }
}

Reales Beispiel: Sperren der Veröffentlichung ohne Genehmigung

Das Workflow-Plugin registriert einen Guard, der Entry.Publish abfängt:

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.");
    }
}

Die Kernplattform weiß nichts über Genehmigungs-Workflows. Sie ruft lediglich Entry.Publish über die Pipeline auf, und der Guard blockiert ihn, wenn der Workflow noch nicht abgeschlossen ist.

Die Aktionspipeline: wo alles zusammenläuft

Der ActionPipeline ist der einzige Ausführungspfad für alle bewachten Operationen. Sie löst auf, welche Schutzmaßnahmen gelten, wertet sie aus und blockiert oder führt die Aktion aus.

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
    }
}

Die EvaluateAsync-Methode erledigt die schwere Arbeit:

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();
}

Drei wichtige Design-Entscheidungen hier:

  1. Per-Tenant-Auflösung - der TenantPluginResolver überprüft, welche Plugins jeder Tenant installiert und aktiviert hat. Ein Guard für ein deaktiviertes Plugin läuft nie.
  2. All-must-pass - wenn ein Guard verweigert, wird die Aktion blockiert. Dies ist eine absichtliche Sicherheitsmaßnahme.
  3. Wächterfehler scheitern beim Öffnen - wenn ein Wächter eine Ausnahme auslöst, wird diese protokolliert und als Allow() behandelt. Dies verhindert, dass ein fehlerhaftes Plugin die gesamte Plattform sperrt.

Plugin-Auflösung pro Mandant

Der Resolver fragt die Tabelle TenantPluginInstallations ab (automatisch auf den aktuellen Mandanten durch die globalen EF-Abfragefilter beschränkt):

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;
    }
}

Ereignisgesteuerte Seiteneffekte

Aktionen sind synchron. Seiteneffekte sind es nicht. Nachdem eine Aktion abgeschlossen ist, veröffentlicht der Dienst ein Domänenereignis:

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

Die Ereignisse werden in einen speicherinternen Kanal eingereiht und von einem EventConsumerWorker im Hintergrund verarbeitet. Der Worker leitet die Ereignisse an mehrere Systeme weiter:

  • Aktivitätsverfolgung - protokolliert, wer was wann getan hat
  • Übersetzungsabrechnung - verfolgt die Kosten pro Anbieter
  • Plugin-Ereignishandler** - jedes Plugin kann Domain-Ereignisse abonnieren

Plugin-Ereignishandler implementieren 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);
}

Der Worker ruft nur Handler auf, deren Plugin für den Tenant aktiviert ist. Dies bedeutet, dass die Seiteneffekte von Plugin A niemals in einen Tenant gelangen, in dem nur Plugin B installiert ist.

Die Übersetzungsmaschine auf Blockebene

Hier macht sich die Architektur am deutlichsten bemerkbar.

Übersetzung auf Blockebene: nur geänderte Blöcke werden neu übersetzt

Herkömmliche Plattformen übersetzen ganze Dokumente. Wir übersetzen einzelne Blöcke - Absätze, Überschriften, Listenelemente. Wenn ein Benutzer einen Absatz in einem Dokument mit 50 Blöcken bearbeitet, muss nur dieser Absatz neu übersetzt werden. Das ist die Quelle unserer 94 %igen Kosteneinsparungen.

Wie werden Blöcke aus TipTap JSON erstellt?

Wenn ein Benutzer ein Dokument speichert, sendet der TipTap-Editor JSON wie folgt:

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

Der BlockTranslationService parst dieses JSON und erstellt einzelne EntryBlock Datensätze:

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;
}

SHA256-Hashing für die Erkennung veralteter Daten

Der Inhalts-Hash ist das Herzstück der Stale-Erkennung. Wir hashen den Blockinhalt (nachdem wir Metadatenattribute wie blockId und deleted entfernt haben) mit SHA256:

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

Wenn sich ein Quellblock ändert, ändert sich auch sein Hash. Das System vergleicht dann den SourceContentHash jedes Übersetzungsblocks mit dem aktuellen Quell-Hash - Unstimmigkeiten werden als Stale markiert:

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();
}

Strukturanpassung

Übersetzer können die Blocktypen in verschiedenen Sprachen ändern. Eine englische Aufzählungsliste kann zu einer deutschen nummerierten Liste werden - eine kulturelle Präferenz. Das System verfolgt dies:

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

Übersetzungsanbieter als Plugins

Externe Übersetzungsdienste (DeepL, Google Translate, etc.) werden über ITranslationProviderPlugin eingebunden:

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);
}

Die Batch-Methode empfängt ein Wörterbuch mit Block-IDs zum Inhalt, übersetzt sie alle und gibt die Übersetzungen mit einer berechneten Zeichenanzahl zurück. Da wir nur veraltete Blöcke und nicht das gesamte Dokument senden, bleiben die Kosten minimal.

Tenant Isolation: das unsichtbare Sicherheitsnetz

Jedes der oben beschriebenen Systeme läuft innerhalb einer strikten Tenant Isolation.

Der TenantContextMiddleware löst bei jeder Anfrage den Mandanten aus dem JWT auf und verifiziert die Mitgliedschaft:

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
    }
}

Die globalen Abfragefilter von Entity Framework stellen sicher, dass die Datenbankschicht die Filterung nach Mandanten automatisch vornimmt, selbst wenn ein Entwickler sie vergisst:

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

Das Ergebnis: db.Hubs.ToListAsync() gibt immer nur die Hubs des aktuellen Mandanten zurück. Datenlecks erfordern eine aktive Umgehung des Abfragefilters - was in unserer Codebasis verboten ist.

Das vollständige Bild

Wenn ein Benutzer bei einem Eintrag auf "Veröffentlichen" klickt, passiert folgendes:

  1. Anfrage wird eingegeben - Authentifizierung validiert den JWT, TenantContextMiddleware löst auf und verifiziert den Mandanten
  2. Controller ruft Pipeline auf - IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
  3. Pipeline löst Guards auf - fragt ab, welche Plugins der Tenant aktiviert hat, wählt die entsprechenden Guards aus
  4. Guards evaluieren - der Workflow-Guard prüft auf Genehmigungen, der Retention-Guard prüft auf Richtlinien, der Rules-Guard validiert den Inhalt
  5. Alle bestehen? Aktion wird ausgeführt - der Eintrag wird veröffentlicht
  6. Ereignisse werden ausgelöst - das Ereignis Entry.Published wird in die Warteschlange aufgenommen
  7. Hintergrundarbeitsprozesse - Aktivität wird protokolliert, Übersetzungsabrechnung wird aktualisiert, Plugin-Ereignishandler werden aufgerufen
  8. Überprüfung von Blockübersetzungen - veraltete Blöcke werden zur Neuübersetzung identifiziert

Jede Schicht erledigt ihre Aufgabe. Keine Schicht greift in eine andere ein. Das ist die Architektur.

Wir haben sie nicht entwickelt, weil Erweiterbarkeit in Mode ist. Wir haben sie entwickelt, weil eine Dokumentationsplattform, die sich nicht an die Arbeitsabläufe der einzelnen Teams anpassen kann, irgendwann durch eine ersetzt wird, die das kann - und eine Plattform, die sich ohne Leitplanken anpasst, wird irgendwann etwas Wichtiges kaputt machen.

Halte deine Doku aktuell. Automatisch.

Rasepi erzwingt Überprüfungstermine, verfolgt die Inhaltsqualität und veröffentlicht in über 40 Sprachen.

Kostenlos starten →