Výsledek: Znovupoužitelná základní třída pro D365 / Dataverse pluginy

Tento dokument shrnuje, co bylo vytvořeno v adresáři d365-plugin-base/, proč to tak je, a jak to použít ve vlastním plugin projektu pro Microsoft Dynamics 365 / Dataverse.

Vytvořené soubory

Soubor Účel
src/PluginBase.cs Abstraktní základní třída PluginBase + pomocný kontext LocalPluginContext.
src/ExampleAccountPlugin.cs Ukázkový plugin pro entitu account (Update / PreOperation), který dědí od PluginBase.
D365_PLUGIN_BASE_RESULT.md Tento popis — co bylo vytvořeno a jak to používat.

Soubory jsou napsané frameworkově neutrálně — neobsahují .csproj ani závislosti na konkrétní verzi nástrojů. Stačí je vložit do existujícího projektu, který referencuje Microsoft.Xrm.Sdk (typicky NuGet balíček Microsoft.CrmSdk.CoreAssemblies), a zkompilovat jako Class Library pro net462 (klasické pluginy) nebo netstandard2.0 (Dataverse plug-in package).

Veřejné odkazy na zdrojové soubory

Co obsahuje PluginBase

PluginBase : IPlugin

LocalPluginContext

Silně typovaný obal nad IServiceProvider. Drží a líně vytváří:

Pomocné metody:

Metoda Popis
Trace(format, args) Bezpečné trasování — nikdy nehází (ošetřuje null i špatný format string).
RequireInputParameter<T>(name) Vrátí input parametr daného jména a typu, jinak hodí InvalidPluginExecutionException se srozumitelnou hláškou.
TryGetInputParameter<T>(name) Měkká varianta — vrátí default(T), pokud parametr chybí.
RequireTargetEntity(expectedEntityName?) Vrátí Target jako Entity a volitelně ověří LogicalName.
RequireTargetReference(expectedEntityName?) Stejné, ale pro EntityReference (Delete / SetState atd.).
GetPreImage(name = "PreImage") Vrátí pre-image, nebo null, pokud nebyl zaregistrován.
GetPostImage(name = "PostImage") Totéž pro post-image.
EnsureMessage(expectedMessageName) Defenzivní kontrola, že plugin běží na očekávané zprávě (např. "Update").
EnsureStage(expectedStage) Defenzivní kontrola stage (10 / 20 / 40).

Zdrojový kód vložený přímo v markdownu

src/PluginBase.cs

using System;
using System.Globalization;
using System.ServiceModel;
using Microsoft.Xrm.Sdk;

namespace D365.PluginBase
{
    /// <summary>
    /// Reusable base class for Dataverse / Dynamics 365 plugins.
    /// Derived classes implement <see cref="ExecutePlugin"/> instead of <see cref="Execute"/>.
    /// </summary>
    public abstract class PluginBase : IPlugin
    {
        private readonly string _pluginClassName;

        protected PluginBase()
        {
            _pluginClassName = GetType().FullName;
        }

        /// <summary>
        /// Optional unsecure configuration string supplied during plugin step registration.
        /// </summary>
        protected string UnsecureConfig { get; }

        /// <summary>
        /// Optional secure configuration string supplied during plugin step registration.
        /// Available only to users with sufficient privileges.
        /// </summary>
        protected string SecureConfig { get; }

        /// <summary>
        /// Constructor used by Dataverse when the registered step has secure / unsecure configuration.
        /// </summary>
        protected PluginBase(string unsecureConfig, string secureConfig) : this()
        {
            UnsecureConfig = unsecureConfig;
            SecureConfig = secureConfig;
        }

        /// <summary>
        /// Entry point invoked by the Dataverse pipeline. Builds a <see cref="LocalPluginContext"/>
        /// and dispatches to <see cref="ExecutePlugin"/> while wrapping all unexpected exceptions
        /// into <see cref="InvalidPluginExecutionException"/>.
        /// </summary>
        public void Execute(IServiceProvider serviceProvider)
        {
            if (serviceProvider == null)
            {
                throw new InvalidPluginExecutionException("serviceProvider is null.");
            }

            var context = new LocalPluginContext(serviceProvider, _pluginClassName);

            try
            {
                context.Trace("Entered {0}.Execute() — Stage: {1}, Message: {2}, Entity: {3}, CorrelationId: {4}",
                    _pluginClassName,
                    context.PluginExecutionContext.Stage,
                    context.PluginExecutionContext.MessageName,
                    context.PluginExecutionContext.PrimaryEntityName,
                    context.PluginExecutionContext.CorrelationId);

                ExecutePlugin(context);

                context.Trace("Exiting {0}.Execute()", _pluginClassName);
            }
            catch (InvalidPluginExecutionException)
            {
                // Already a Dataverse-friendly exception, let the platform handle it.
                throw;
            }
            catch (FaultException<OrganizationServiceFault> ex)
            {
                context.Trace("OrganizationServiceFault: {0}", ex.Detail?.Message ?? ex.Message);
                throw new InvalidPluginExecutionException(
                    string.Format(CultureInfo.InvariantCulture,
                        "An organization service fault occurred in {0}: {1}",
                        _pluginClassName,
                        ex.Detail?.Message ?? ex.Message),
                    ex);
            }
            catch (Exception ex)
            {
                context.Trace("Unhandled exception: {0}", ex);
                throw new InvalidPluginExecutionException(
                    string.Format(CultureInfo.InvariantCulture,
                        "Unexpected error in {0}: {1}",
                        _pluginClassName,
                        ex.Message),
                    ex);
            }
        }

        /// <summary>
        /// Implemented by derived plugins to perform their work using the provided context.
        /// </summary>
        protected abstract void ExecutePlugin(LocalPluginContext context);
    }

    /// <summary>
    /// Strongly typed wrapper around the <see cref="IServiceProvider"/> passed to a plugin.
    /// Exposes the most frequently used Dataverse services and helper methods for
    /// tracing, parameter validation and image access.
    /// </summary>
    public sealed class LocalPluginContext
    {
        private readonly Lazy<IOrganizationService> _userService;
        private readonly Lazy<IOrganizationService> _adminService;

        public LocalPluginContext(IServiceProvider serviceProvider, string pluginClassName)
        {
            if (serviceProvider == null)
            {
                throw new ArgumentNullException(nameof(serviceProvider));
            }

            ServiceProvider = serviceProvider;
            PluginClassName = pluginClassName ?? string.Empty;

            TracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService))
                ?? throw new InvalidPluginExecutionException("ITracingService is not available.");

            PluginExecutionContext = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext))
                ?? throw new InvalidPluginExecutionException("IPluginExecutionContext is not available.");

            ServiceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory))
                ?? throw new InvalidPluginExecutionException("IOrganizationServiceFactory is not available.");

            // Lazily create services so plugins that don't need them avoid the cost.
            _userService = new Lazy<IOrganizationService>(
                () => ServiceFactory.CreateOrganizationService(PluginExecutionContext.UserId));

            _adminService = new Lazy<IOrganizationService>(
                () => ServiceFactory.CreateOrganizationService(null));
        }

        public IServiceProvider ServiceProvider { get; }

        public string PluginClassName { get; }

        public IPluginExecutionContext PluginExecutionContext { get; }

        public IOrganizationServiceFactory ServiceFactory { get; }

        public ITracingService TracingService { get; }

        /// <summary>
        /// Organization service running as the user that triggered the plugin.
        /// Respects that user's security roles. Use for the majority of operations.
        /// </summary>
        public IOrganizationService UserService => _userService.Value;

        /// <summary>
        /// Organization service running as the SYSTEM user (no security role checks).
        /// Use only when the operation must succeed regardless of caller privileges.
        /// </summary>
        public IOrganizationService AdminService => _adminService.Value;

        /// <summary>
        /// Safe tracing — never throws even when the message is null or formatting fails.
        /// </summary>
        public void Trace(string format, params object[] args)
        {
            if (TracingService == null || string.IsNullOrEmpty(format))
            {
                return;
            }

            try
            {
                var message = (args == null || args.Length == 0)
                    ? format
                    : string.Format(CultureInfo.InvariantCulture, format, args);
                TracingService.Trace(message);
            }
            catch (FormatException)
            {
                TracingService.Trace(format);
            }
        }

        /// <summary>
        /// Returns the InputParameter named <paramref name="parameterName"/> cast to <typeparamref name="T"/>,
        /// or throws <see cref="InvalidPluginExecutionException"/> if it is missing or the wrong type.
        /// </summary>
        public T RequireInputParameter<T>(string parameterName)
        {
            if (string.IsNullOrWhiteSpace(parameterName))
            {
                throw new ArgumentException("parameterName is required.", nameof(parameterName));
            }

            if (!PluginExecutionContext.InputParameters.Contains(parameterName))
            {
                throw new InvalidPluginExecutionException(
                    $"Required input parameter '{parameterName}' was not provided.");
            }

            var raw = PluginExecutionContext.InputParameters[parameterName];
            if (raw is T typed)
            {
                return typed;
            }

            throw new InvalidPluginExecutionException(
                $"Input parameter '{parameterName}' is of type '{raw?.GetType().FullName ?? "null"}', expected '{typeof(T).FullName}'.");
        }

        /// <summary>
        /// Returns the InputParameter named <paramref name="parameterName"/> cast to <typeparamref name="T"/>,
        /// or <c>default</c> if it is missing or the wrong type.
        /// </summary>
        public T TryGetInputParameter<T>(string parameterName)
        {
            if (string.IsNullOrWhiteSpace(parameterName) ||
                !PluginExecutionContext.InputParameters.Contains(parameterName))
            {
                return default;
            }

            return PluginExecutionContext.InputParameters[parameterName] is T typed ? typed : default;
        }

        /// <summary>
        /// Returns the "Target" input parameter as an <see cref="Entity"/>, validating that it
        /// matches <paramref name="expectedEntityName"/> when supplied.
        /// </summary>
        public Entity RequireTargetEntity(string expectedEntityName = null)
        {
            var target = RequireInputParameter<Entity>("Target");

            if (!string.IsNullOrEmpty(expectedEntityName) &&
                !string.Equals(target.LogicalName, expectedEntityName, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidPluginExecutionException(
                    $"Plugin registered for entity '{expectedEntityName}' but received '{target.LogicalName}'.");
            }

            return target;
        }

        /// <summary>
        /// Returns the "Target" input parameter as an <see cref="EntityReference"/>
        /// (used by Delete / SetState / Associate messages).
        /// </summary>
        public EntityReference RequireTargetReference(string expectedEntityName = null)
        {
            var target = RequireInputParameter<EntityReference>("Target");

            if (!string.IsNullOrEmpty(expectedEntityName) &&
                !string.Equals(target.LogicalName, expectedEntityName, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidPluginExecutionException(
                    $"Plugin registered for entity '{expectedEntityName}' but received '{target.LogicalName}'.");
            }

            return target;
        }

        /// <summary>
        /// Returns the named pre-image, or <c>null</c> if it was not registered.
        /// </summary>
        public Entity GetPreImage(string name = "PreImage")
            => PluginExecutionContext.PreEntityImages != null &&
               PluginExecutionContext.PreEntityImages.Contains(name)
                ? PluginExecutionContext.PreEntityImages[name]
                : null;

        /// <summary>
        /// Returns the named post-image, or <c>null</c> if it was not registered.
        /// </summary>
        public Entity GetPostImage(string name = "PostImage")
            => PluginExecutionContext.PostEntityImages != null &&
               PluginExecutionContext.PostEntityImages.Contains(name)
                ? PluginExecutionContext.PostEntityImages[name]
                : null;

        /// <summary>
        /// Throws when the plugin is not running for the expected message
        /// (useful as a defensive check inside <c>ExecutePlugin</c>).
        /// </summary>
        public void EnsureMessage(string expectedMessageName)
        {
            if (!string.Equals(PluginExecutionContext.MessageName, expectedMessageName, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidPluginExecutionException(
                    $"Plugin expected message '{expectedMessageName}' but was invoked for '{PluginExecutionContext.MessageName}'.");
            }
        }

        /// <summary>
        /// Throws when the plugin is not running in the expected stage
        /// (10 = PreValidation, 20 = PreOperation, 40 = PostOperation).
        /// </summary>
        public void EnsureStage(int expectedStage)
        {
            if (PluginExecutionContext.Stage != expectedStage)
            {
                throw new InvalidPluginExecutionException(
                    $"Plugin expected stage '{expectedStage}' but was invoked in stage '{PluginExecutionContext.Stage}'.");
            }
        }
    }
}

src/ExampleAccountPlugin.cs

using System;
using Microsoft.Xrm.Sdk;

namespace D365.PluginBase.Examples
{
    /// <summary>
    /// Example plugin demonstrating <see cref="PluginBase"/> usage.
    /// Recommended registration:
    ///   Message:    Update
    ///   Entity:     account
    ///   Stage:      PreOperation (20)
    ///   Mode:       Synchronous
    ///   PreImage:   alias = "PreImage", attributes include name, telephone1
    /// </summary>
    public sealed class ExampleAccountPlugin : PluginBase
    {
        private const string EntityName = "account";

        public ExampleAccountPlugin() { }

        public ExampleAccountPlugin(string unsecureConfig, string secureConfig)
            : base(unsecureConfig, secureConfig) { }

        protected override void ExecutePlugin(LocalPluginContext context)
        {
            context.EnsureMessage("Update");

            var target = context.RequireTargetEntity(EntityName);
            context.Trace("Updating account {0}", target.Id);

            // Trim the account name on the way through and stamp a timestamp.
            if (target.Contains("name") && target["name"] is string name)
            {
                var trimmed = name?.Trim();
                if (!string.Equals(trimmed, name, StringComparison.Ordinal))
                {
                    context.Trace("Trimming whitespace from name: '{0}' -> '{1}'", name, trimmed);
                    target["name"] = trimmed;
                }
            }

            // Cross-field validation using the pre-image to compare old vs. new values.
            var preImage = context.GetPreImage();
            if (preImage != null &&
                target.Contains("telephone1") &&
                target["telephone1"] is string newPhone &&
                !string.IsNullOrEmpty(newPhone))
            {
                var oldPhone = preImage.GetAttributeValue<string>("telephone1");
                if (!string.Equals(oldPhone, newPhone, StringComparison.Ordinal))
                {
                    context.Trace("Phone changed: '{0}' -> '{1}'", oldPhone, newPhone);
                }
            }

            // Stamp a "last touched by plugin" marker (assumes a custom datetime field exists).
            target["new_lasttouchedon"] = DateTime.UtcNow;

            context.Trace("ExampleAccountPlugin finished for {0}", target.Id);
        }
    }
}

Použití — krok za krokem

  1. Reference NuGet balíčku v plugin projektu: xml <PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.0.*" />
  2. Zkopírujte src/PluginBase.cs do svého projektu (případně si jej vyčleňte do sdílené knihovny Common).
  3. Vytvořte plugin děděním od PluginBase a přepsáním ExecutePlugin: csharp public sealed class MyContactPlugin : PluginBase { protected override void ExecutePlugin(LocalPluginContext context) { context.EnsureMessage("Create"); var target = context.RequireTargetEntity("contact"); // ... business logic ... } }
  4. Sestavte assembly, podepište silným jménem (vyžadováno pro on-premises i pro Dataverse plug-in package) a zaregistrujte krok přes Plugin Registration Tool nebo přes Dataverse plug-in package.
  5. Pre/Post images registrujte pod aliasy PreImage / PostImage (výchozí hodnoty pomocných metod), nebo si název předejte explicitně.

Ukázkový plugin — ExampleAccountPlugin

src/ExampleAccountPlugin.cs ukazuje typické vzory:

Doporučená registrace:

Položka Hodnota
Message Update
Primary entity account
Stage PreOperation (20)
Execution mode Synchronous
Pre-image alias PreImage, atributy name, telephone1

Plugin v PreOperation modifikuje samotnou Target entity, takže změny (trim jména, timestamp) se uloží spolu s ostatními změnami v rámci stejné databázové transakce — bez nutnosti dalšího Update volání.

Konvence a doporučené postupy

Co to záměrně neřeší