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
- Implementuje
Microsoft.Xrm.Sdk.IPlugin.Execute(IServiceProvider). - Má dva konstruktory:
- bezparametrický — pro kroky zaregistrované bez konfigurace,
(string unsecureConfig, string secureConfig)— Dataverse jej zavolá automaticky, pokud krok má vyplněnou konfiguraci. Hodnoty jsou zpřístupněny jako vlastnostiUnsecureConfig/SecureConfig.- Sestaví
LocalPluginContext, zaloguje úvodní informace (stage, message, entity, correlation id) a deleguje práci doExecutePlugin(LocalPluginContext), který přepisují odvozené pluginy. - Centrální zpracování chyb:
InvalidPluginExecutionExceptionse propaguje beze změny (nese smysluplnou hlášku pro koncového uživatele D365),FaultException<OrganizationServiceFault>se zaloguje a zabalí,- jakákoliv jiná
Exceptionse zaloguje a zabalí doInvalidPluginExecutionException, takže Dataverse pipeline nikdy neuvidí surové neočekávané výjimky a uživatel nedostane nesrozumitelný stacktrace.
LocalPluginContext
Silně typovaný obal nad IServiceProvider. Drží a líně vytváří:
IPluginExecutionContext PluginExecutionContextIOrganizationServiceFactory ServiceFactoryIOrganizationService UserService— služba běžící pod uživatelem, který akci vyvolal (respektuje jeho role a privilegia).IOrganizationService AdminService— služba běžící pod systémovým uživatelem (ignoruje role); použijte pouze tehdy, když operace musí uspět nezávisle na privilegiích volajícího.ITracingService TracingService
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
- Reference NuGet balíčku v plugin projektu:
xml <PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.0.*" /> - Zkopírujte
src/PluginBase.csdo svého projektu (případně si jej vyčleňte do sdílené knihovnyCommon). - Vytvořte plugin děděním od
PluginBasea přepsánímExecutePlugin:csharp public sealed class MyContactPlugin : PluginBase { protected override void ExecutePlugin(LocalPluginContext context) { context.EnsureMessage("Create"); var target = context.RequireTargetEntity("contact"); // ... business logic ... } } - 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.
- 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:
- pevné navázání na zprávu (
EnsureMessage("Update")), - získání cílové entity s ověřením názvu (
RequireTargetEntity("account")), - normalizaci dat na vstupu (trim u atributu
name), - porovnání staré a nové hodnoty pomocí pre-image (
telephone1), - stempl času na vlastní atribut
new_lasttouchedon.
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
Targetentity, 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šíhoUpdatevolání.
Konvence a doporučené postupy
- Pluginy mají být bezstavové. Dataverse instanci
IPlugincachuje a může ji sdílet mezi vlákny.PluginBaseproto neukládá žádný mutable stav; všechen kontext žije vLocalPluginContext, který se vytváří pro každéExecute. - Preferujte
UserServicepro běžné operace, aby zůstaly zachovány bezpečnostní role volajícího.AdminServicepoužijte jen tam, kde to je opravdu potřeba (např. čtení dat, ke kterým uživatel nemá přístup). - Hlášky pro uživatele patří do
InvalidPluginExecutionException(zobrazí se v dialogu chyby D365). Technické detaily patří doTrace(...). - Stage konstanty:
10PreValidation (mimo transakci, před bezpečnostními kontrolami),20PreOperation (v transakci, před zápisem),40PostOperation (v transakci, po zápisu). - Validace vstupů dělejte přes
RequireInputParameter/RequireTargetEntity— chyby jsou tak konzistentní a srozumitelné.
Co to záměrně neřeší
- Není přibalený
.csproj,.slnani build skript — soubory jsou navržené tak, aby šly bez úprav vložit do libovolného existujícího plugin projektu. - Není zde dependency injection kontejner; pluginy musí zůstat jednoduché a bezstavové, aby je platforma mohla bezpečně cachovat.
- Není zde wrapper kolem
IOrganizationService(např. fluent query). Záměrně — chceme zůstat blízko Microsoft SDK, ne nahrazovat jej.