← Voltar ao blog

Por dentro da arquitetura Rasepi: Plugins, Action Guards e Pipelines

Um passo a passo técnico profundo sobre como o sistema de plugins do Rasepi, o pipeline de proteção de acções e o motor de tradução ao nível do bloco funcionam realmente - com código real da base de código.

Por dentro da arquitetura Rasepi: Plugins, Action Guards e Pipelines

A maioria das plataformas de documentação fala de "extensibilidade" da mesma forma que as companhias aéreas falam de "espaço para as pernas" - tecnicamente presente, praticamente dececionante. Queríamos que a arquitetura do Rasepi fosse genuinamente extensível sem se tornar imprevisível, por isso construímos três sistemas interligados: plugins para capacidade, action guards para controlo e pipelines para execução determinística.

Este post mostra como cada um deles funciona em nossa base de código atual.

Arquitetura Rasepi: Plugins, Guardas, e Pipelines a trabalhar em conjunto

O sistema de plugins: modular por design

Cada plugin no Rasepi implementa IPluginModule - uma única interface que declara o que o plugin é, quais serviços ele precisa, e quais rotas ele expõe:

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

O PluginManifest é puro dado. Descreve o plugin sem executar nada:

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

Observe o UiContributions - esse dicionário mapeia os pontos de extensão do frontend para os nomes dos componentes, então o frontend do Vue sabe quais componentes de UI cada plugin contribui (um botão da barra de ferramentas, um painel da barra lateral, uma página de configurações).

O registo é uma linha por cada plugin

Na inicialização, nós registramos plugins através de uma API fluente:

var pluginRegistry = new PluginRegistry();

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

Cada chamada instancia o módulo, armazena-o no registo e chama RegisterServices() para ligar as suas dependências. Depois que o aplicativo é construído, uma única linha mapeia todas as rotas de plugins:

app.MapPluginRoutes(pluginRegistry);

Por baixo do capô, cada plugin recebe um grupo de rotas com escopo em /api/plugins/{pluginId}/ com autorização aplicada automaticamente.

Exemplo real: o plugin Workflow

Aqui está o aspeto de um plugin real - o módulo 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);
    }
}

A plataforma principal nunca faz referência a WorkflowService ou WorkflowPublishGuard diretamente. Descobre-os através do contentor DI. Essa é a chave para o acoplamento zero - a aplicação principal nunca toca no código do plugin.

Guardas de ação: a camada de controlo

Os plugins adicionam capacidades. Os guardas de ação decidem se essa capacidade - ou qualquer ação do núcleo - tem permissão para prosseguir. Eles são validadores síncronos que interceptam operações antes da execução.

Fluxo de avaliação dos guardas de ação](/pt/blog/img/action-guard-flow.svg)

A interface é deliberadamente mínima:

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, o guarda é executado para cada ação. Quando está definido para algo como "Entry.Publish", apenas intercepta essa ação específica.

O contexto e os contratos de resultado

Cada guard recebe um contexto tipado com o nome da ação, inquilino, utilizador, entidade e um saco de propriedades:

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 cada guarda retorna um resultado previsível - permitir, negar ou permitir-com-modificações:

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

O campo Modifications é importante - um guard pode aprovar uma ação mas reescrever parte do conteúdo (por exemplo, redigir segredos antes de publicar).

Nomes de acções canónicas

Nós definimos todas as ações interceptáveis como constantes de string para que não haja nenhuma ambiguidade sobre o que um guard pode ter como alvo:

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

Exemplo real: bloquear publicação sem aprovação

O plugin Workflow regista um guard que intercepta 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.");
    }
}

A plataforma principal não sabe nada sobre workflows de aprovação. Apenas chama Entry.Publish através do pipeline, e o guard bloqueia-o se o workflow não tiver sido concluído.

O pipeline de ação: onde tudo converge

O ActionPipeline é o único caminho de execução para todas as operações protegidas. Ele resolve quais guardas se aplicam, avalia-os, e bloqueia ou executa a ação.

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

O método EvaluateAsync faz o trabalho pesado:

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

Três importantes decisões de design aqui:

  1. Resolução por inquilino - o TenantPluginResolver verifica quais os plugins que cada inquilino tem instalados e activados. Um guarda para um plugin desativado nunca é executado.
  2. All-must-pass - se algum guarda negar, a ação é bloqueada. Esta é uma postura de segurança deliberada.
  3. Guard errors fail open - se um guard lança uma exceção, esta é registada e tratada como Allow(). Isso evita que um plugin quebrado bloqueie toda a plataforma.

Resolução de plugins por inquilino

O resolvedor consulta a tabela TenantPluginInstallations (com escopo automático para o locatário atual pelos filtros de consulta global 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;
    }
}

Efeitos colaterais orientados por eventos

As acções são síncronas. Os efeitos secundários não o são. Depois que uma ação é concluída, o serviço publica um evento de domínio:

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

Os eventos são enfileirados num canal na memória e processados por um EventConsumerWorker em segundo plano. O trabalhador encaminha os eventos para vários sistemas:

  • Activity tracking - regista quem fez o quê, quando
  • Faturação da tradução** - regista os custos por fornecedor
  • Manipuladores de eventos de plugins** - qualquer plugin pode subscrever eventos do domínio

Os manipuladores de eventos de plugins implementam 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);
}

O trabalhador só invoca manipuladores cujo plugin está habilitado para o locatário. Isso significa que os efeitos colaterais do plugin A nunca vazam para um locatário que só tem o plugin B instalado.

O motor de tradução a nível de bloco

É aqui que a arquitetura compensa de forma mais visível.

Tradução por blocos: apenas os blocos alterados são retraduzidos](/pt/blog/img/block-translation.svg)

As plataformas tradicionais traduzem documentos inteiros. Nós traduzimos blocos individuais - parágrafos, títulos, itens de lista. Quando um utilizador edita um parágrafo num documento de 50 blocos, apenas esse parágrafo precisa de ser traduzido novamente. Esta é a fonte da nossa poupança de custos de 94%.

Como os blocos são criados a partir do TipTap JSON

Quando um utilizador guarda um documento, o editor TipTap envia um JSON como este:

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

O BlockTranslationService analisa este JSON e cria registos EntryBlock individuais:

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 para deteção de desatualização

O hash de conteúdo é o núcleo da deteção de desatualização. Fazemos o hash do conteúdo do bloco (depois de retirar os atributos de metadados como blockId e deleted) usando SHA256:

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

Quando um bloco de origem muda, seu hash muda. O sistema compara então o SourceContentHash de cada bloco de tradução com o hash atual do bloco de origem - as discrepâncias são marcadas como 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();
}

Adaptação da estrutura

Os tradutores podem alterar os tipos de blocos entre línguas. Uma lista de pontos em inglês pode tornar-se numa lista numerada em alemão - uma preferência cultural. O sistema regista este facto:

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

Fornecedores de tradução como plugins

Os serviços de tradução externos (DeepL, Google Translate, etc.) ligam-se através de 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);
}

O método batch recebe um dicionário de IDs de blocos para o conteúdo, traduz todos eles e retorna as traduções com uma contagem de caracteres facturados. Como só enviamos blocos antigos, e não o documento inteiro, os custos são mínimos.

Isolamento do inquilino: a rede de segurança invisível

Todos os sistemas descritos acima funcionam dentro de um isolamento rigoroso do locatário.

O TenantContextMiddleware resolve o inquilino a partir do JWT em cada pedido e verifica a adesão:

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

Os filtros de consulta globais do Entity Framework garantem que, mesmo que um programador se esqueça de filtrar por inquilino, a camada de base de dados fá-lo automaticamente:

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

O resultado: db.Hubs.ToListAsync() retorna sempre apenas os hubs do locatário atual. As fugas de dados exigem que se contorne ativamente o filtro de consulta - o que é proibido na nossa base de código.

A imagem completa

Quando um utilizador clica em "Publicar" numa entrada, eis o que acontece:

  1. O pedido entra - a autenticação valida o JWT, TenantContextMiddleware resolve e verifica o inquilino
  2. O controlador chama o pipeline - IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
  3. O pipeline resolve os guardas - consulta quais os plugins que o inquilino activou, seleciona os guardas aplicáveis
  4. Os guardas avaliam - o guarda de Fluxo de trabalho verifica as aprovações, o guarda de Retenção verifica a política, o guarda de Regras valida o conteúdo
  5. Todos passam? A ação é executada - a entrada é publicada
  6. Eventos disparam - o evento Entry.Published é colocado na fila
  7. Processos de trabalho em segundo plano - a atividade é registada, a faturação da tradução é actualizada, os manipuladores de eventos do plugin são chamados
  8. Tradução de blocos verificada - blocos obsoletos são identificados para retradução

Cada camada faz o seu trabalho. Nenhuma camada se estende a outra. Esta é a arquitetura.

Não construímos isto porque a extensibilidade está na moda. Construímo-la porque uma plataforma de documentação que não se pode adaptar ao fluxo de trabalho de cada equipa acabará por ser substituída por uma que o possa fazer - e uma plataforma que se adapta sem protecções acabará por quebrar algo importante.

Mantenha a sua documentação atualizada. Automaticamente.

O Rasepi impõe datas de revisão, monitoriza a qualidade do conteúdo e publica em mais de 40 idiomas.

Comece gratuitamente →