以编程方式自动化 D365 CI/CD





0/5 (0投票)
以编程方式讨论 D365 插件注册
引言
最近,我被指派负责在 Azure DevOps 管道中自动化 D365 CRM 的构建和部署过程,本文将介绍相关的背景、高级策略以及为开发目的设置完全托管的 CRM 实例的技巧。
CRM 开发的痛点
在我加入目前的职位之前,已经基于常规项目模板设置了一些 ADO 构建和发布管道。也就是说,当源文件发生更改时,构建管道就会被触发,以构建可由发布管道部署到目标 CRM 环境的工件。
与传统的软件开发不同,以插件为中心的开发需要将二进制文件和元数据文件包含在存储库中,典型步骤如下:
- 更新 C# 插件源文件中的代码。
- 在将其构建为 DLL 二进制文件之前更改版本号。
- 使用 PluginRegistrationTool 将 DLL 二进制文件注册/更新到控制 CRM 环境。
- 将插件更改应用于控制 CRM 环境,然后导出该环境中的 *solution.zip*。
- 在解压到工作分支以覆盖现有二进制和元数据文件之前,更新/检查 solution zip 文件内容。
- 拉取请求将上述更改合并到 master-ca 分支。
- 然后可以使用常规的构建/发布管道进行构建和部署。
上述步骤 2) - 5) 非常繁琐且容易出错,即使是对于 *.dll* 和 *.xml* 文件的版本验证也是如此。任何缺失/不匹配的插件只能在整个过程完成后才能检测到,更不用说当多个开发人员尝试使用相同版本号时可能发生的冲突了。
这不仅与仅存储源代码的约定相悖,而且在开发人员必须手动编译代码到二进制文件、使用它们 注册它们、发布&导出&解压&提交这些手动、繁琐且容易出错的操作时,也带来了挑战。
说明
在充满 Microsoft 技术栈的环境中,团队倾向于使用 PowerShell,而 Microsoft.Xrm.Data.PowerShell 非常适合执行一些简单的任务,例如:
- 从 master-ca 分支获取插件项目单个文件,例如 *AssemblyInfo.cs*,以获取生产版本。
- 通过增加生产版本并构建来更新已更改插件项目的版本。
- 导入/导出解决方案 zip 文件到/从目标 CRM 环境。
然而,我找不到任何文档齐全的 PowerShell 模块来支持插件注册/更新,尽管有一些像 Xrm.Framework.CI.PowerShell.Cmdlets 这样的模块以前可能有效。幸运的是,XrmToolBox 套件中的 C# 开源 PluginRegistration 扩展似乎与 PluginRegistrationTool 具有相同的功能,它提供了一种反向工程的选项,用于了解 CRM 实体(如 `PluginAssembly` 和 `PluginType`)是如何注册和更新的。
有了 `Microsoft.CrmSdk.XrmTooling.CoreAssembly` NuGet 包中定义的模型和实用程序,显然我必须开发实用程序来启用步骤 3) 和 4),作为一个 .NET Framework 项目,我将在故事的第二部分发布。但在此之前,我需要一个游乐场环境来轻松开发和测试。
方法论
首先,我尝试寻找一个 PowerShell 模块来自动化 D365 CI/CD。不出所料,尽管有一些优秀的模块,如 Microsoft.Xrm.Data.PowerShell 和 xrm-ci-framework 支持导入/导出解决方案、查询/创建/更新/删除 CRM 记录等操作,但最关键的注册或更新 DLL 文件的功能尚未启用。当 PowerShell 脚本基于 Microsoft .NET 库构建时,调试它们也很不方便,所以我不得不转向 .NET 社区。
幸运的是,我找到了 PluginRegistration,它是 XrmToolBox 的一个扩展工具,其功能与 Microsoft 的 PluginRegistrationTool 相似甚至相同。调试插件程序集注册至少使我能够提取背后的业务逻辑,但我更愿意首先理解每个方法的因果关系。
这促使我订阅了 Azure 和 D365 的试用订阅,以便查询只读数据库。SQL 查询显示数据库记录是理解不同实体之间关系的捷径,而 Microsoft Dataverse 开发几乎就是**在不同表中对实体执行 CRUD 操作**。
一旦我理解了这一点,阅读 PluginRegistration 的源代码 就变得相当直接,通过创建几个通用的扩展方法,可以轻松地注册/更新插件程序集,这些方法甚至可以在其他场景中使用。
完全托管的 CRM 环境
虽然我在组织中被分配了开发环境的系统管理员角色,但该环境仍与其他用户共享,而且我缺少连接字符串,无法在没有 MFA 身份验证的情况下执行管理操作。
我确实找到了一些出色的教程,例如这篇,展示了如何创建 30 天试用 CRM 实例和基于 OAuth2 的 `config connectionString`。然而,许多此类文章未能显示如何为 CRM 实例添加应用程序用户,因为 Microsoft 更改/移动了某些内容,似乎需要 Azure 全局管理员进行配置。
对我来说,很自然地利用 Microsoft 365 开发人员计划来创建我自己的组织,然后开始 Dynamics 365 免费试用并管理应用程序用户以创建简单的基于客户端密钥的连接字符串。
创建您自己的组织
加入 Microsoft 365 开发人员计划并使用您的电子邮件凭据后,我检查了以下区域,然后在下一页的“**实例沙盒**”中:
恭喜,上面显示的域名是您的试用组织。
立即转到 Azure 门户,使用上面显示的管理员电子邮件登录。
开始 D365 免费试用
使用先前创建的相同帐户登录 D365 免费试用页面。
点击“**免费试用**”中的任何一个,例如“**Dynamics 365 客户服务**”下的那个,您将被重定向到 CRM 实例(例如 https://orgxxxxxx.crm5.dynamics.com)进行 30 天试用。
注册您的 D365 应用
- 从顶部栏搜索并打开“**应用注册**”。
- 新注册选择“**仅此组织目录中的帐户(... 单租户)**”将使以下身份验证和授权更加容易。
- 我倾向于将上述 CRM 实例的 URL 添加为重定向 URI。
- 在“概述”页面保留应用程序(客户端)ID 以供以后使用。
- API 权限,点击“**添加权限**”,点击“**Dynamics CRM**”,选择“**委派权限**”,然后选中“**用户模拟**”,最后点击“**添加权限**”。
- 证书和密码,点击“**新建客户端密码**”,并确保安全地保存密码值以备后用。
创建应用程序用户
这是与之前教程相比的更改部分:我花了相当长的时间才意识到“**应用程序用户**”视图隐藏在 **CRM 设置** > **安全性** > **用户**中。
Power Platform 管理中心的“管理应用程序用户”页面(更新于 2022/02/16)显示了正确的步骤。由于 CRM 已在 Azure 中注册,“**创建新应用用户**”>“**添加应用**”将显示新注册的应用。
确保在“**创建**”之前添加正确的安全角色。就我而言,我只添加了“**系统管理员**”角色。
使用 ClientSecret 连接字符串访问 CRM
如示例客户端密钥身份验证所示,它应该如下组合:
"AuthType=ClientSecret;url=https://yourorg.crm4.dynamics.com;ClientId={AppId};
ClientSecret={ClientSecret}"
将组合的 `string` 保存为 `$con`,运行 `Connect-CrmOnline cmdlet` 证明其正常工作。
揭秘 Microsoft.Xrm.Sdk 实体
Entity 是 Microsoft Dynamics 365 中所有类型实体的基类。Entity 的子类具有固定的 `entityName` **或** `logicalName`,指向相应表的实际名称。 `Entity.Attributes` 类似于 `Dictionary
相关实体之间的关系被强制执行为主键/外键关系,表示为 EntityReference,通常由 **`logicalName`** 和 **`Guid`** 组成,它们分别是表名和关联实体的唯一标识符。
谈到插件注册过程,通常需要关注的实体类型包括 `pluginassembly`、`plugintype`、`sdkmessageprocessingstep`、`sdkmessageprocessingstepimage`、`sdkmessageprocessingstepsecureconfig` 以及 `solution`,这可以通过一个简单的查询得到证明。
插件注册或更新
通过遵循 Debug Xrm.Toolbox 来调试 PluginRegistration,我添加了命令行参数“`overridepath:absolutionPathOfDllFile”而不是“`overridepath:.”来加载正确的程序集文件 *Xrm.Sdk.PluginRegistration.dll*,并在修复了两个小问题后:
GenerateFriendlyName(string newName, out bool ignoreFriendlyName)
在 CrmPlugin.cs 中的逻辑,用于防止更新或显示实际的 `friendlyname` 属性。PluginAssembly.cs
中 PluginAssembly.cs 的 `Id` 属性未能显示 `Id`。
这表明注册过程相当直接:
- 从 DLL 文件加载程序集元数据和内容为 `BASE64 字符串`。
- 使用相应的 `PluginAssembly` 值组合实例。
- 使用 `IOrganizationService` 实例创建上述 `PluginAssembly` 实例。
- 通过反射获取所有 `IPlugin` 和 `WorkCode` 实现,以创建相应的 `PluginType` 实体。
- 使用 `IOrganizationService` 实例创建它们。
因此,自动化构建和部署以更新现有插件 DLL 的伪过程可以是:
- 从 DLL 文件加载程序集元数据和内容为 `BASE64 字符串`。
- 使用相应的 `PluginAssembly` 值组合实例。
- 查询 `IOrganizationService` 以获取同名的现有实例。
- 如果新实例未被修改、创建或版本不同,则使用现有实例的值刷新新组合的实例。
- 使用 `IOrganizationService` 实例更新已刷新的 `PluginAssembly` 实例。
- 通过反射获取所有 `IPlugin` 和 `WorkCode` 实现,以创建相应的
- 检索与现有 `PluginAssembly` 实例关联的所有现有 `PluginType` 实体。
- 比较 6) 和 7) 中的差异,然后执行 `Create`、`Delete` 或 `Update` 操作。
代码片段
为了访问 `IOrganizationService`,我正在使用 CrmServiceClient,它只需要在前一篇文章中配置的连接字符串,该字符串允许执行发布所有自定义等基本操作。
PublishAllXmlRequest publishAllXmlRequest = new PublishAllXmlRequest();
var response = service.Execute(publishAllXmlRequest);
获取子实体
由于命名约定结构良好,因此很容易定义通用方法来获取关联的实体。
public static IEnumerable<Entity> GetChildren(this IOrganizationService service,
string entityName, Entity parentEntity, string parentKeyName = null, params string[] keys)
{
//Get the attribute name referring parent id by the concerned
//children entity type if it is not given
string parentEntityTypeId = parentKeyName ?? $"{parentEntity.LogicalName}id";
QueryExpression queryExpression = new QueryExpression(entityName)
{
ColumnSet = keys.Length == 0 ? new ColumnSet(true) : new ColumnSet(keys),
Criteria = new FilterExpression()
{
Conditions = { new ConditionExpression(parentEntityTypeId,
ConditionOperator.Equal, parentEntity.Attributes[parentEntityTypeId]) }
}
};
var entities = service.RetrieveMultiple(queryExpression).Entities;
return entities;
}
public static IEnumerable<TEntity> GetChildren<TEntity>(this IOrganizationService service,
Entity parentEntity, string parentKeyName = null, params string[] keys)
where TEntity : Entity
{
string entityName = typeof(TEntity).Name.ToLower();
var entities = service.GetChildren(entityName, parentEntity, parentKeyName, keys)
.Select(entity => entity.CastTo<TEntity>());
return entities;
}
然后可以方便地检索与已知名称的 `PluginAssembly` 相关的所有实体。
public static IDictionary<Type, Entity[]>
GetPluginAssemblyEntities(this IOrganizationService service,
string assemblyName)
{
IDictionary<Type, Entity[]> result = new Dictionary<Type, Entity[]>();
// Retrieve the first PluginAssembly only
var plugin = service.EntityByName<PluginAssembly>(assemblyName);
if (plugin is null)
{
// Nothing found for PluginAssembly with given name, return empty dictionary instead?
return null;
}
// Save the first PluginAssembly
result.Add(typeof(PluginAssembly), new []{plugin});
// Retrieve all associated PluginType entities
var pluginTypes = service.GetChildren<PluginType>(plugin);
result.Add(typeof(PluginType), pluginTypes.Cast<Entity>().ToArray());
// Get all associated Steps of all PluginType entities
var steps = pluginTypes.SelectMany(
plugin => service.GetChildren<SdkMessageProcessingStep>(plugin));
result.Add(typeof(SdkMessageProcessingStep), steps.Cast<Entity>().ToArray());
var stepImages = steps.SelectMany(step =>
service.GetChildren<SdkMessageProcessingStepImage>(step));
result.Add(typeof(SdkMessageProcessingStepImage), stepImages.Cast<Entity>().ToArray());
return result;
}
将 Guid 包装为 EntityReference
虽然可以解析实体类型以获取 `EntityReference` 类型的属性EntityReference ,但一个 `static dictionary` 要高效得多。
private static Dictionary<Type, string[]>
TypeEntityReferenceAttributes = new Dictionary<Type, string[]>()
{
{typeof(PluginAssembly), new[] {"organizationid"}},
{typeof(PluginType), new[] {"pluginassemblyid", "solutionid", "organizationid"}},
{typeof(SdkMessage), new[] {"organizationid"}},
{typeof(SdkMessageFilter), new[] {"organizationid", "sdkmessageid"}},
{typeof(SdkMessageProcessingStepImage),
new[] {"organizationid", "sdkmessageprocessingstepid"}},
{typeof(SdkMessageProcessingStep), new[]
{ "eventhandler", "organizationid", "impersonatinguserid",
"plugintypeid", "sdkmessagefilterid",
"sdkmessageid", "sdkmessageprocessingstepsecureconfigid"}},
};
然后可以通过创建 `EntityReferences` 来关联一个或多个实体。
public static TEntity Associate<TEntity>(this TEntity entity, params Entity[] associations)
where TEntity : Entity
{
var entityReferenceAttributeNames = TypeEntityReferenceAttributes[typeof(TEntity)];
foreach (var association in associations)
{
if (entity == null) continue;
string associationIdName = $"{association.LogicalName}id";
//Some Guid shall be wrapped as EntityReference
entity[associationIdName] = entityReferenceAttributeNames.Contains(associationIdName)
? new EntityReference(association.LogicalName,
(Guid)association[associationIdName])
: association[associationIdName];
}
return entity;
}
更新插件
更新插件 DLL 基本可以遵循此模式:通过 `name` 获取 `PluginAssembly`,更新它及其 `PluginTypes`。
public static PluginAssembly UpdatePluginAssembly
(this IOrganizationService service, string assemblyPath, string solutionName = null)
{
var pluginAssembly = AssemblyHelper.LoadPluginAssembly(assemblyPath);
string assemblyName = pluginAssembly.Name;
// Retrieve solution entity by name, it can be null if solutionName
// is null or no such solution
var solution = solutionName is null ? null :
service.EntityByName("solution", solutionName);
pluginAssembly.Associate(solution);
// Ensure the pluginAssembly has been registered, otherwise throw Exception
var existingEntity = service.EntityByName<PluginAssembly>(assemblyName);
if (existingEntity == null)
{
throw new InvalidOperationException
($"Cannot found PluginAssembly with name {assemblyName}");
}
// Update pluginAssembly by inherit missing attributes from the existingEntity
pluginAssembly = pluginAssembly.Inherit(existingEntity);
//Update pluginAssembly and print out changes
service.Update(pluginAssembly);
pluginAssembly = service.EntityByName<PluginAssembly>(pluginAssembly.Name);
EntityHelper.PrintAttributeDifferences(existingEntity, pluginAssembly);
// Update PluginTypes one by one for simplicity
var existingPluginTypes = service.EntitiesByAssemblyName
<PluginType>(assemblyName).ToDictionary(p => p.Name, p => p);
var freshPluginTypes = AssemblyHelper.LoadPluginTypes(assemblyPath).ToDictionary
(p => p.Name, p => p);
string[] allPluginNames = existingPluginTypes.Keys.Concat
(freshPluginTypes.Keys).Distinct().ToArray();
foreach (var pluginName in allPluginNames)
{
// Get the current plugin entity, associate it to PluginAssembly and Solution
var plugin = freshPluginTypes[pluginName].Associate(pluginAssembly, solution);
if (!freshPluginTypes.ContainsKey(pluginName))
{
//Not included in the fresh PluginAssembly, delete it
service.Delete(plugin.TypeName, (Guid)plugin.PluginTypeId);
Debug.WriteLine($"PluginType deleted: {plugin.Name}{{{plugin.PluginTypeId}}}");
}
else if (!existingPluginTypes.ContainsKey(pluginName))
{
service.RegisterPluginType(plugin);
}
else
{
var updatedPlugin = service.UpdatePluginType
(plugin, existingPluginTypes[pluginName]);
Debug.WriteLine($"PluginType updated:
{updatedPlugin.Name}{{{updatedPlugin.PluginTypeId}}}");
}
}
return pluginAssembly;
}
验证
为了验证上述插件注册是否有效,我遵循了 教程:编写和注册插件。
- 编写插件。
- 通过运行单元测试注册 DLL。
- 手动注册步骤。
- 更新并重新构建插件代码。
- 通过相同的单元测试更新 DLL。
- 在 D365 中验证更改是否已应用。
进一步思考
令我感到奇怪的是,Microsoft 没有将解决方案的所有资产包含到源代码管理中。
例如,`SdkMessageProcessingStep` 实体可以定义为:
- 可以以 JSON 或 XML 格式定义,因为它们可以保存在 SQL 表中。
- 或者定义为具有 `PluginType` 实体属性或属性的强类型模型。
如果那样的话,*solution.zip* 中的内容就不需要放在源代码存储库中,项目版本始终可以轻松更新,具有依赖项已更新/创建的 DLL 上传到目标环境,并通过元数据或环境变量的说明可以节省大量时间和解决许多问题。
历史
- 2022 年 2 月 27 日:初始版本