Best Practices pro Dataverse a Power Platform pluginy

Onboarding příručka pro zkušeného .NET Framework / C# developera, který začíná s Microsoft Dataverse a Power Platform.

1. Úvod do Dataverse pluginů a plugin pipeline

Dataverse plugin je .NET třída implementující Microsoft.Xrm.Sdk.IPlugin, která se spouští na serveru Dataverse jako reakce na zprávu platformy, typicky Create, Update, Delete, Retrieve, RetrieveMultiple, Associate, Disassociate nebo custom API/action.

Plugin není webový endpoint ani background service pod vaší kontrolou. Je to rozšíření serverové pipeline Dataverse. Dataverse vytvoří instanci plugin třídy, předá jí IServiceProvider a očekává rychlé, deterministické dokončení.

Základní pojmy:

Typická pipeline pro operaci jako Update:

  1. PreValidation: před hlavní validací a často mimo databázovou transakci.
  2. PreOperation: před uložením, uvnitř transakce.
  3. Main Operation: Dataverse provede vlastní změnu.
  4. PostOperation: po hlavní operaci, synchronně uvnitř transakce nebo asynchronně mimo ni.

Praktické pravidlo:

2. Assembly/projekt pro pluginy

Doporučená struktura řešení

Příklad struktury pro týmový vývoj:

Contoso.Dataverse.Plugins.sln
src/
  Contoso.Dataverse.Plugins/
    Contoso.Dataverse.Plugins.csproj
    PluginBase.cs
    LocalPluginContext.cs
    Account/
      AccountPreOperationUpdatePlugin.cs
      AccountPostOperationCreatePlugin.cs
    Contact/
      ContactPreValidationCreatePlugin.cs
    Common/
      EntityExtensions.cs
      OptionSetValues.cs
      Constants.cs
  Contoso.Dataverse.Plugins.Tests/
    Contoso.Dataverse.Plugins.Tests.csproj
    Account/
      AccountPreOperationUpdatePluginTests.cs

Doporučení:

Target framework

Dataverse pluginy jsou tradičně .NET Framework pluginy. Pro klasické plugin assembly používejte target framework podporovaný Dataverse a vaším toolingem, typicky:

<TargetFramework>net462</TargetFramework>

V praxi se často používá .NET Framework 4.6.2 pro kompatibilitu s Dataverse plugin runtime a balíkem Microsoft.CrmSdk.CoreAssemblies.

Poznámka: Microsoft postupně rozšiřuje Power Platform tooling a existují i modernější scénáře okolo Power Platform CLI, ale pro standardní server-side Dataverse pluginy počítejte s klasickým .NET Framework modelem, pokud dokumentace vaší verze/tenant scénáře neříká jinak.

NuGet reference

Minimální reference:

<ItemGroup>
  <PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.*" />
</ItemGroup>

Často používané další balíky:

<ItemGroup>
  <PackageReference Include="Microsoft.CrmSdk.XrmTooling.CoreAssembly" Version="9.*" PrivateAssets="All" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
  <PackageReference Include="xunit" Version="2.*" />
  <PackageReference Include="FakeXrmEasy" Version="2.*" />
</ItemGroup>

Doporučení:

Strong-name signing

Plugin assembly musí být strong-name signed.

V .csproj:

<PropertyGroup>
  <SignAssembly>true</SignAssembly>
  <AssemblyOriginatorKeyFile>Contoso.Dataverse.Plugins.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

Vytvoření SNK:

sn -k Contoso.Dataverse.Plugins.snk

Doporučení:

Assembly metadata a verzování

Dataverse rozlišuje assembly podle jména, verze, culture a public key tokenu. Při deploymentu mějte jasnou strategii:

Praktický .csproj skeleton:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net462</TargetFramework>
    <LangVersion>latest</LangVersion>
    <SignAssembly>true</SignAssembly>
    <AssemblyOriginatorKeyFile>Contoso.Dataverse.Plugins.snk</AssemblyOriginatorKeyFile>
    <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.0.2.57" />
  </ItemGroup>
</Project>

Registrace assembly

Nejčastější možnosti:

Při registraci nastavujete:

Best practice:

3. Thread-safe a stateless pluginy

Dataverse může z důvodu výkonu cachovat a znovu používat instance plugin tříd. Proto plugin nesmí spoléhat na mutable instance state.

Špatně:

public class BadPlugin : IPlugin
{
    private IOrganizationService _service;
    private Entity _target;

    public void Execute(IServiceProvider serviceProvider)
    {
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        _service = factory.CreateOrganizationService(null);

        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        _target = (Entity)context.InputParameters["Target"];

        // Nebezpečné: instance může být znovu použita pro jiné spuštění.
    }
}

Správně:

public class GoodPlugin : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);

        if (!context.InputParameters.TryGetValue("Target", out var targetObj) || targetObj is not Entity target)
        {
            return;
        }

        tracing.Trace("Processing {0} {1} {2}", context.MessageName, target.LogicalName, context.CorrelationId);

        // Všechny hodnoty jsou lokální proměnné pro toto spuštění.
    }
}

Pravidla pro thread-safe plugin:

Správné použití služeb

var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));

// Operace jako aktuální uživatel
var userService = factory.CreateOrganizationService(context.UserId);

// Operace jako owner plugin step / system kontext podle konfigurace; používat opatrně
var systemService = factory.CreateOrganizationService(null);

Použití:

4. Reusable interface/base class pro pluginy

Cíl: odstranit boilerplate a vynutit jednotný pattern.

Návrh

Princip:

Ukázkový kód

using System;
using Microsoft.Xrm.Sdk;

namespace Contoso.Dataverse.Plugins
{
    public interface IPluginHandler
    {
        void Execute(LocalPluginContext localContext);
    }

    public sealed class LocalPluginContext
    {
        public LocalPluginContext(IServiceProvider serviceProvider)
        {
            ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));

            TracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            PluginExecutionContext = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
            OrganizationServiceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));

            UserService = OrganizationServiceFactory.CreateOrganizationService(PluginExecutionContext.UserId);
            SystemService = OrganizationServiceFactory.CreateOrganizationService(null);
        }

        public IServiceProvider ServiceProvider { get; }
        public ITracingService TracingService { get; }
        public IPluginExecutionContext PluginExecutionContext { get; }
        public IOrganizationServiceFactory OrganizationServiceFactory { get; }
        public IOrganizationService UserService { get; }
        public IOrganizationService SystemService { get; }

        public bool TryGetTargetEntity(out Entity target)
        {
            target = null;

            if (!PluginExecutionContext.InputParameters.TryGetValue("Target", out var targetObject))
            {
                return false;
            }

            target = targetObject as Entity;
            return target != null;
        }

        public Entity GetPreImage(string imageName = "PreImage")
        {
            return PluginExecutionContext.PreEntityImages.TryGetValue(imageName, out var image)
                ? image
                : null;
        }

        public Entity GetPostImage(string imageName = "PostImage")
        {
            return PluginExecutionContext.PostEntityImages.TryGetValue(imageName, out var image)
                ? image
                : null;
        }

        public void Trace(string message, params object[] args)
        {
            TracingService?.Trace(message, args);
        }
    }

    public abstract class PluginBase : IPlugin
    {
        protected PluginBase(string unsecureConfiguration = null, string secureConfiguration = null)
        {
            UnsecureConfiguration = unsecureConfiguration;
            SecureConfiguration = secureConfiguration;
        }

        protected string UnsecureConfiguration { get; }
        protected string SecureConfiguration { get; }

        public void Execute(IServiceProvider serviceProvider)
        {
            var localContext = new LocalPluginContext(serviceProvider);
            var context = localContext.PluginExecutionContext;

            localContext.Trace(
                "Start plugin {0}. Message={1}, Entity={2}, Stage={3}, Mode={4}, Depth={5}, CorrelationId={6}",
                GetType().FullName,
                context.MessageName,
                context.PrimaryEntityName,
                context.Stage,
                context.Mode,
                context.Depth,
                context.CorrelationId);

            try
            {
                ExecuteDataversePlugin(localContext);
            }
            catch (InvalidPluginExecutionException)
            {
                throw;
            }
            catch (Exception ex)
            {
                localContext.Trace("Unhandled exception: {0}", ex);
                throw new InvalidPluginExecutionException("Unexpected error in plugin. Contact support with CorrelationId: " + context.CorrelationId, ex);
            }
            finally
            {
                localContext.Trace("End plugin {0}. CorrelationId={1}", GetType().FullName, context.CorrelationId);
            }
        }

        protected abstract void ExecuteDataversePlugin(LocalPluginContext localContext);
    }
}

Konkrétní plugin s minimem boilerplate

using Microsoft.Xrm.Sdk;

namespace Contoso.Dataverse.Plugins.Account
{
    public sealed class AccountPreOperationUpdateNormalizeNamePlugin : PluginBase
    {
        protected override void ExecuteDataversePlugin(LocalPluginContext localContext)
        {
            var context = localContext.PluginExecutionContext;

            if (!string.Equals(context.MessageName, "Update", System.StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            if (!string.Equals(context.PrimaryEntityName, "account", System.StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            if (!localContext.TryGetTargetEntity(out var target))
            {
                return;
            }

            if (!target.Contains("name"))
            {
                return;
            }

            var name = target.GetAttributeValue<string>("name");
            if (string.IsNullOrWhiteSpace(name))
            {
                throw new InvalidPluginExecutionException("Account name is required.");
            }

            target["name"] = name.Trim();
            localContext.Trace("Normalized account name for Target Id={0}", target.Id);
        }
    }
}

Ještě lepší: oddělení byznys logiky od plugin wrapperu

public interface IAccountNameNormalizer
{
    string Normalize(string name);
}

public sealed class AccountNameNormalizer : IAccountNameNormalizer
{
    public string Normalize(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new InvalidPluginExecutionException("Account name is required.");
        }

        return name.Trim();
    }
}

Plugin pak jen orchestrace:

public sealed class AccountPreOperationUpdateNormalizeNamePlugin : PluginBase
{
    private static readonly IAccountNameNormalizer Normalizer = new AccountNameNormalizer();

    protected override void ExecuteDataversePlugin(LocalPluginContext localContext)
    {
        if (!localContext.TryGetTargetEntity(out var target) || !target.Contains("name"))
        {
            return;
        }

        target["name"] = Normalizer.Normalize(target.GetAttributeValue<string>("name"));
    }
}

Tady je static readonly bezpečné, protože AccountNameNormalizer je stateless.

5. Plugin stages, sync/async, images, depth, filtering attributes

Stages

Dataverse používá číselné stage hodnoty:

PreValidation

Použití:

Pozor:

PreOperation

Použití:

Best practice:

Špatně:

// Zbytečný extra update a riziko rekurze
var account = new Entity("account", target.Id);
account["name"] = normalizedName;
service.Update(account);

Správně v PreOperation:

target["name"] = normalizedName;

PostOperation

Použití:

Pozor:

Sync vs async

Synchronní plugin:

Asynchronní plugin:

Rozhodovací pravidlo:

Entity images

U Update obsahuje Target pouze změněné atributy. Pokud potřebujete původní nebo finální hodnoty, registrujte images.

Příklad použití:

var preImage = localContext.GetPreImage();
var oldCreditLimit = preImage?.GetAttributeValue<Money>("creditlimit")?.Value;

var target = localContext.TryGetTargetEntity(out var t) ? t : null;
var newCreditLimit = target?.GetAttributeValue<Money>("creditlimit")?.Value ?? oldCreditLimit;

Best practice:

Depth a prevence rekurze

context.Depth říká, jak hluboko je aktuální pipeline v řetězu následných operací. Pokud plugin aktualizuje stejnou nebo související entitu, může spustit další pluginy a vznikne rekurze.

Jednoduchá ochrana:

if (context.Depth > 1)
{
    localContext.Trace("Skipping plugin because Depth={0}", context.Depth);
    return;
}

Pozor: samotné Depth > 1 return není architektura, jen pojistka. Lepší je:

Filtering attributes

U Update nastavte filtering attributes na konkrétní sloupce, které plugin zajímají.

Příklad:

Plugin normalizující account name má filtering attributes:

name

Ne:

všechny atributy

Výhody:

Execution order

Pokud je více steps ve stejné stage, Dataverse je řadí podle execution order.

Doporučení:

6. Error handling a tracing best practices

Kdy házet výjimku

Pro business validaci používejte InvalidPluginExecutionException.

if (creditLimit > 100000 && !userCanApprove)
{
    throw new InvalidPluginExecutionException("Credit limit above 100,000 requires approval.");
}

Doporučení:

Špatně:

try
{
    service.Update(entity);
}
catch
{
    // chyba zmizí, data jsou nekonzistentní
}

Správně:

try
{
    service.Update(entity);
}
catch (Exception ex)
{
    tracing.Trace("Failed to update account {0}. Exception: {1}", entity.Id, ex);
    throw new InvalidPluginExecutionException("Account update failed. Contact support with the operation time and correlation id.", ex);
}

Tracing

Používejte ITracingService.Trace.

Logujte:

Neloggovat:

Příklad:

localContext.Trace(
    "Account credit validation. AccountId={0}, OldLimit={1}, NewLimit={2}, CorrelationId={3}",
    target.Id,
    oldLimit,
    newLimit,
    context.CorrelationId);

Výkon a limity

Plugin by měl být rychlý. V sandboxu existují časové a bezpečnostní limity.

Best practices:

Špatně:

var account = service.Retrieve("account", accountId, new ColumnSet(true));

Správně:

var account = service.Retrieve("account", accountId, new ColumnSet("name", "creditlimit"));

7. Debugging

Plugin Registration Tool

Plugin Registration Tool slouží k:

Doporučený postup kontroly registrace:

  1. Ověř assembly a plugin type.
  2. Ověř step message/entity/stage/mode.
  3. U Update ověř filtering attributes.
  4. Ověř image aliasy a sloupce.
  5. Ověř user/context, ve kterém step běží.
  6. Ověř, že plugin trace logging je povolený.

Plug-in Profiler

Profiler zachytí execution context a umožní lokální replay/debug.

Typický postup:

  1. Nainstalovat Profiler přes Plugin Registration Tool.
  2. Profilovat konkrétní step.
  3. Reprodukovat akci v aplikaci/API.
  4. Stáhnout profile log.
  5. Otevřít plugin projekt ve Visual Studiu.
  6. Spustit debug replay s profile logem.
  7. Po dokončení profiling vypnout/odinstalovat.

Pozor:

Plugin Trace Logs

Trace logy jsou základní diagnostika v Dataverse.

Nastavení se dělá v environment/system settings podle aktuálního UI. Typické režimy:

Doporučení:

8. Deployment: solutions, managed/unmanaged, versioning, registration

Solutions

Power Platform solution je balíček komponent: plugin assembly, steps, tables, columns, flows, model-driven app atd.

Typický ALM model:

Doporučení:

Managed vs unmanaged

Unmanaged:

Managed:

Secure a unsecure configuration

Plugin step může mít unsecure a secure configuration.

Použití:

Příklad konstruktoru pluginu:

public sealed class MyConfiguredPlugin : PluginBase
{
    public MyConfiguredPlugin(string unsecureConfiguration, string secureConfiguration)
        : base(unsecureConfiguration, secureConfiguration)
    {
    }

    protected override void ExecuteDataversePlugin(LocalPluginContext localContext)
    {
        localContext.Trace("Plugin has configuration: {0}", !string.IsNullOrWhiteSpace(UnsecureConfiguration));
    }
}

Versioning a release

Best practice:

CI/CD checklist

9. Checklist pro nového developera

Projekt a build

Kód

Registrace

Debugging a provoz

Deployment

10. Zdroje z Microsoft Learn

Doporučené oficiální zdroje:

Shrnutí pro .NET developera

Největší rozdíl proti běžné .NET aplikaci je hosting model. Plugin běží uvnitř Dataverse pipeline, ve sdíleném serverovém runtime, s krátkou životností operace a s možností reuse instance. Proto: