这是我的第447篇原创文章,写于2021年06月26日。

以前我们定义消息的方法就是使用操作(Action),操作可以是无代码的(使用Workflow设计器配置)也可以是使用代码,比如在Workflow设计器中调用自定义工作流活动,或者在这个操作(Action)的Post Operation阶段注册插件步骤。这些操作没有权限限制,也可以比较方便的通过Web API或者SOAP终结点(组织服务)或者Workflow/Cloud flow调用。我以前也不少文章介绍,这里列出来供大家参考:

现在微软扩展了操作,提供了一种新的Feature叫做Custom API,比较早的介绍文章参考 Introducing Custom API – The New Way of Creating Custom Actions in Dataverse .

目前的官方文档包括但不限于如下:

估计你首先关心的是,Custom API和操作(Action)有啥不同,我在项目实践的时候才好选择,官方Compare Custom Process Action and Custom API列了一个比较如下,我就不一一翻译了,我摘要点说一下:Custom API是代码优先的,一定要写代码,不能像操作那样可以无代码,Custom API可以做权限控制,限制用户能否调用(通过要求用户具有某种权限才能调用),而操作则不行。Custom API支持本地化的名称和描述,而操作则不行。Custom API可以是Function或者Action,而操作只能是Action。Custom API可以通过修改Solution中文件而直接编辑,但是对于Action来讲则是不受支持的开发方法。Custom API可以绑定至Table Collection,而操作则不行。Custom API的执行时间受2分钟限制,而操作则可以更长,当然操作中的单个插件/自定义工作流活动也受两分钟的限制。

Capability

Custom Process Action

Custom API

Description

Declarative logic with workflow

Yes

No

Workflow Actions can have logic defined without writing code using the Classic Workflow designer.

Custom APIs require a plug-in written in .NET to implement logic that is applied on the server.

Require specific privilege

No

Yes

With Custom API you can designate that a user must have a specific privilege to call the message. If the user doesn’t have that privilege through their security roles or team membership, an error will be returned.

Define main operation logic with code

Yes

Yes

With Custom Process Actions the main operation processes the Workflow definition which may include custom workflow activities. The code in these custom workflow activities is processed in the main operation together with any other logic in the workflow.


With Custom API the message creator simply associates their plug-in type with the Custom API to provide the main operation logic.

Block Extension by other plug-ins

Yes

Yes

With Custom Process actions set the IsCustomProcessingStepAllowedForOtherPublishers managed property to true if you wish to allow 3rd party plug-ins to run when registered on the message for your custom process action. When set to false, only plug-ins from the same solution publisher will run when a plug-in step is registered for the message.


For Custom API, set the AllowedCustomProcessingStepType to control whether any plug-ins steps may be registered, or if only asynchronous plug-ins may be registered.

Make message private

No

Yes

When you create a message using a Custom Process Action, it is exposed publicly in the endpoint for anyone else to discover and use. If someone else takes a dependency on the message you created, their code will be broken if you remove, rename, or change the input or output parameter signature in the future.


If you do not intend for your message to be used by anyone else, you can mark it as a private message. This will indicate that you do not support others using the message you create, and it will not be included in definitions of available functions or actions exposed by the Web API $metadata service definition. Classes for calling these messages will not be generated using code generation tools, but you will still be able to use it.

Localizable names and descriptions

No

Yes

While Custom Process Actions provide for a friendly name for the custom action and any input and output parameters it uses, these values are not localizable. With Custom API you can provide localizable names and descriptions. These localized strings can then be bound to controls that provide a UI to use the message.

Create OData Function

No

Yes

The Dataverse Web API is an OData web service. OData provides for two types of operations: Actions & Functions.

  • An Action is an operation that makes changes to data in the system. It is invoked using the Http POST method and parameters are passed in the body of the request.
  • Function is an operation that makes no change to data, for example an operation that simply retrieves data. It is invoked using an Http GET method and the parameters are passed in the URL of the request


Custom Process Actions are always Actions. Custom API provides the option to define custom Functions.

There is nothing to prevent you from defining all operations as Actions if you wish. But some operations may be best expressed using a GET request available by defining a function.

Note: The Power Automate Common Data Service (current environment) connector only exposes Actions currently.

Create a global operation not bound to a table

Yes

Yes

Both provide the ability to define a global message not bound to a table.

Bind an operation to a table

Yes

Yes

Both provide the ability to pass a reference to a specific table record by binding it to a table.

Bind an operation to a table collection

No

Yes

Binding an operation to a table collection allows for another way to define the signature for the Custom API. While this does not pass a collection of tables as an input parameter, is restricts the context of the operation to that type of table collection. Use this when your operation works with a collection of a specific type of tables or your operation will return a collection of that type.

Compose or modify a custom API by editing a solution

No

Yes

ISVs who build and maintain products that work with the Power Platform apply ALM practices that involve solutions. The data within a solution is commonly checked into a source code repository and checked out by a developer applying changes.


A Custom Process Action is defined by a XAML Windows Workflow Foundation document which is transported as part of a solution. However, creating new or editing existing workflow definitions outside of the workflow designer is not supported.


Custom API definitions are solution aware components included in a solution through a set of folders and XML documents. These files and the file structure enable transport the API from one environment to another. Because these are plain text files, changes can be made to them, or new APIs can be defined by working with these files. This method of defining Custom APIs is supported. More information: Create a Custom API with solution files.

Subject to 2 minute time limit

No

Yes

A plug-in that implements the main operation for a Custom API is subject to the 2 minute time limit to complete execution.


A Custom Process Action is not technically limited to two minutes. If a step in the Workflow logic contains a custom workflow activity, that part will be limited to two minutes. But the entire workflow cannot run indefinitely. There are other limitations that will cause long-running Custom Process Actions to fail. More information: Watch out for long running actions


不讲那么多了,我们来做个简单的例子,一个动手的例子胜过千言万语。需要通过 https://make.powerapps.com 来创建Custom API.我这里先做个Function类型的Custom API,接收一个文本参数城市名称,返回这个城市的所有客户。

打开某个解决方案,点击 New > Custom API,如下图。

Dynamics 365的Custom API介绍_Dynamics 365


我这里设置如下,这些字段的含义请参考官方文档CustomAPI Table Columns ,我就不一一翻译了,值得一提的是Allowed Custom Processing Step Type、Binding Type、Bound Entity Logical Name、Is Function、Unique Name 这些列(以前叫字段)的值保存后不能更改,且设置且珍惜吧,也不用特别记忆,你会发现保存后这些字段变成只读了。我这里将Is Funciton设置为True,因为我这个只是涉及到获取数据,不涉及到更新数据,用Function调用起来等都更加方便,我这里将Is Private设置为No,这样元数据中可以看到。Unique Name要用上开发者前缀,比如我这里是ly,不然不让保存。Bound Type我设置围为Global,这样灵活些。Plugin Type这个列的值我没有设置,因为我还没有开发,晚点设置。

Dynamics 365的Custom API介绍_Custom API_02


保存后继续通过解决方案中的 New > Custom API Request Parameter 来添加输入参数,我设置的如下,Custom API当然要选,我选前面创建的Custom API,Unique Name当然也要带前缀,Type可以看到有很多种,我这里用常用的String,我将Is Optional设置为No,因为我要求必须输入,具体的每个列的含义请参考 CustomAPIRequestParameter Table Columns 。

Dynamics 365的Custom API介绍_Custom API_03


保存后继续通过解决方案中的 New > Custom API Response Property 来添加输出参数,我设置的如下,Custom API当然要选,我选前面创建的Custom API,Unique Name当然也要带前缀,Type可以看到有很多种,我这里用常用的String,具体的每个列的含义请参考CustomAPIResponseProperty Table Columns 。

Dynamics 365的Custom API介绍_Custom API_04


然后就是写代码了,类似插件,基本知识可以参考我前面的博文 Dynamics 365中开发和注册插件介绍 ,我这里不罗嗦了直接上代码如下:

using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization.Json;
using System.ServiceModel;
using System.Text;

namespace D365.Plugins
{
    public class CustomAPIGetAccountsByCityName : IPlugin
    {
        public void Execute(IServiceProvider serviceProvider)
        {
            //获取日志服务
            ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            //写一些日志,方便跟踪
            tracingService.Trace($"Enter CustomAPIGetAccountsByCityName on {DateTime.UtcNow.ToString()}");
            IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
            IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService adminOrgSvc = serviceFactory.CreateOrganizationService(null);
            var returnVal = string.Empty;
            if (context.InputParameters.Contains("ly_CityName"))
            {
                //我这里是演示性质代码,认为返回的记录最多只有5000行,但是实际项目中要考虑,如果可能需要分页查询
                string fetchXml = string.Format(@"<fetch versinotallow='1.0' mapping='logical' distinct='false' no-lock='true'>
  <entity name='account'>
    <attribute name='name' />
    <attribute name='telephone1' />
    <filter type='and'>
      <condition attribute='address1_city' operator='eq' value='{0}' />
    </filter>
  </entity>
</fetch>", EncodeforXml(context.InputParameters["ly_CityName"].ToString()));
                var accountEC = adminOrgSvc.RetrieveMultiple(new FetchExpression(fetchXml));
                if (accountEC.Entities.Any())
                {
                    var lsResults = new List<Account>();
                    foreach(var entity in accountEC.Entities)
                    {
                        lsResults.Add(new Account()
                        {
                            Name = entity.GetAttributeValue<string>("name"),
                            MainPhone = entity.GetAttributeValue<string>("telephone1")
                        });
                    }
                    returnVal = GetJsonString(lsResults, typeof(List<Account>));
                }
            }
            context.OutputParameters["ly_Accounts"] = returnVal;
        }

        public string GetJsonString(object value, Type type)
        {
            string result = string.Empty;
            using (MemoryStream stream = new MemoryStream())
            {
                DataContractJsonSerializerSettings serializerSettings = new DataContractJsonSerializerSettings
                {
                    UseSimpleDictionaryFormat = true
                };

                DataContractJsonSerializer ser = new DataContractJsonSerializer(type, serializerSettings);
                ser.WriteObject(stream, value);
                stream.Position = 0;
                using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
                {
                    result = reader.ReadToEnd();
                }
            }

            return result;
        }

        public string EncodeforXml(string content)
        {
            if (!string.IsNullOrEmpty(content))
            {
                return content.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("'", "'").Replace("\"", """);
            }
            else
            {
                return string.Empty;
            }
        }
    }

    public class Account {
        public string Name { get; set; }

        public string MainPhone { get; set; }
    }
}

然后我将这个程序集注册到Dynamics 365中,如下:

Dynamics 365的Custom API介绍_Dynamics 365_05


下面就是与插件不同的地方,插件还需要Register New Step,而对于Custom API不需要,它需要的是打开对应的Custom API,将其Plugin Type设置为刚才注册Plugin Type,如下图,保存。

Dynamics 365的Custom API介绍_Custom API_06


在调用之前我们可以去看看这个Customer API的metadata,打开类似的的 https://luoyongdemo.crm5.dynamics.com/api/data/v9.2/$metadata url可以看到,因为我们这个Custom API的Is Private为No,若是为Yes,则看不到,但是并不影响调用。

Dynamics 365的Custom API介绍_Dynamics 365_07

<FunctionImport Name="ly_GetAccountsByCityName" Functinotallow="Microsoft.Dynamics.CRM.ly_GetAccountsByCityName"/>

然后我通过Web API来调用它,因为是Function,所以是用Get的方法来调用,示例代码如下:我返回的数据是JSON格式,这里最好再parse一下就是JSON了。

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
req.open("GET", clientUrl + "/api/data/v9.2/ly_GetAccountsByCityName(ly_CityName=@p1)?@p1='Redmond'", true);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 200) {
            var responseJSON = JSON.parse(this.responseText);
            console.log(responseJSON);
            var data =JSON.parse(responseJSON.ly_Accounts);
        console.log(data);
        }
    }
}
req.send();

返回的示例如下:

{
 "@odata.context":"https://luoyongdemo.crm5.dynamics.com/api/data/v9.2/$metadata#Microsoft.Dynamics.CRM.ly_GetAccountsByCityNameResponse",
    "ly_Accounts":"[{\"MainPhone\":\"555-0155\",\"Name\":\"City Power & Light (sample)\"},{\"MainPhone\":\"555-0156\",\"Name\":\"Contoso Pharmaceuticals (sample)\"},{\"MainPhone\":\"555-0158\",\"Name\":\"A. Datum Corporation (sample)\"},{\"MainPhone\":\"+1-425-555-0120\",\"Name\":\"Fabrikam, Inc.\"},{\"MainPhone\":\"+1-425-555-3499\",\"Name\":\"Graphic Design Institute\"},{\"MainPhone\":\"425-555-0182\",\"Name\":\"A Datum Corporation\"},{\"MainPhone\":\"425-555-5816\",\"Name\":\"Contoso Engineering\"},{\"MainPhone\":\"425-555-9590\",\"Name\":\"Contoso Instrumentation\"},{\"MainPhone\":\"425-555-8536\",\"Name\":\"Contoso Pharma\"},{\"MainPhone\":\"425-555-0668\",\"Name\":\"Contoso Pharma Electronics\"}]"
}


也可以调用时候不用声明参数,直接将参数值传入,示例如下:

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
var ly_lastName = "张";
var ly_firstName = "三";
var ly_CompanyId = "0641819b-fb49-ee11-be6d-0017fa065095";//此参数类型为GUID
//注意下面的传递方法,参数类型为GUID的时候,不要加上单引号,但是字符串类型的参数需要加上单引号
req.open("GET", `${clientUrl}/api/data/v9.2/ly_GetContacts(ly_lastName='${ly_lastName}',ly_firstName='${ly_firstName}',ly_CompanyId=${ly_CompanyId})`);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 200) {
            console.log(this.response);
        }
        else {
            var error = JSON.parse(this.response).error;
            Xrm.Navigation.openErrorDialog({ message: error.message });
        }
    }
}
req.send();


下面是调用绑定Function的示例:

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
req.open("GET", `${clientUrl}/api/data/v9.2/account(4373087f-951c-ee11-8f6d-0017fa035e09)/Microsoft.Dynamics.CRM.ly_BoundedFunctionName`);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 200) {
            console.log(this.response);
        }
        else {
            var error = JSON.parse(this.response).error;
            Xrm.Navigation.openErrorDialog({ message: error.message });
        }
    }
}
req.send();


如果Custom API设置的不是Function,也就是Is Function 字段的值设置为No呢,开发并没有多大不同我就不写例子了,使用Web API调用稍有不同,需要用Post来调用,我这里举个例子如下:

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
req.open("POST", clientUrl + "/api/data/v9.2/ly_NonFunctionCustomAPI", true);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 200) {
            var responseJSON = JSON.parse(this.responseText);
            console.log(responseJSON);
            var data =JSON.parse(responseJSON.outputpara);
       console.log(data);
        }
    }
}
var requestData={
   "para1":1
}
req.send(JSON.stringify(requestData));


下面是另外一个调用示例代码:

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
req.open("POST", clientUrl + "/api/data/v9.2/ly_UpsertRecord");
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 204) {
            console.log("操作成功!");
        }
        else {
            var error = JSON.parse(this.response).error;
            Xrm.Navigation.openErrorDialog({ message: error.message });
        }
    }
}
var content = {
    "statuscode": "437100000"
};
var requestData = {
    "jjmc_EntityName": "ly_test",
    "jjmc_EntityId": "c143bcd6-8d17-ec11-b6e6-0017fa03f54a",
    "jjmc_Content": JSON.stringify(content)
}
req.send(JSON.stringify(requestData));

下面是调用一个绑定Custom API的实例代码:

var clientUrl = Xrm.Utility.getGlobalContext().getClientUrl();
var req = new XMLHttpRequest();
req.open("POST", `${clientUrl}/api/data/v9.2/accounts('35dbd67a-f299-ec11-b3fe-0017fa03e49f')/Microsoft.Dynamics.CRM.ly_customapi`);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
    if (this.readyState == 4) {
        req.onreadystatechange = null;
        if (this.status == 200) {
            console.log(this.response);
        }
        else {
            var error = JSON.parse(this.response).error;
            Xrm.Navigation.openErrorDialog({ message: error.message });
        }
    }
}
req.send();

不是Function的Custom API就可能是bounded action后者unbounded aciton,这个是可以通过Cloud Flow来调用。

开发完成了,我初次写作本文时有个limit,解决方案中看不到这个Custom API及其相应输入输出参数,这时候需要通过解决方案中的 Add existing 将Custom API及其输入,输出参数添加进来,我这里添加后效果如下,记得要添加啊,不然通过解决方案部署不到新环境。此limit在2022年或者更早时候已经消除了。

Dynamics 365的Custom API介绍_Dynamics 365_08

如果通过组织服务来调用呢,我这里就摘录官方文档中的示例代码如下:

var req = new OrganizationRequest("myapi_EscalateCase")
{
  ["Target"] = new EntityReference("incident", guid),
  ["Priority"] = new OptionSetValue(1)
};
var resp = svc.Execute(req);
var newOwner = (EntityReference) resp["AssignedTo"];

你可能会问,可以禁用Custom API吗?答案是不可以,对Custom API执行Deactivate(禁用)操作没有效果,但是可以删除。

还有插件中调用不了Private类型的Custom API。

如果Custom API有参数定义为EntityReference类型,但是这个类型对应的实体被删除了(此处不会互相依赖,实体可以删除),可能就会导致这个Custom API无法调用,调用的时候报404 ,错误信息类似如下:

{
    "error": {
        "code": "0x80060888",
        "message": "Resource not found for the segment 'ly_CustomAPIName'."
    }
}

这时候如果查看元数据,会发现这个Custom API不在元数据中,我估计是重新生成这个Custom API元数据的时候失败了导致的。这个时候如果用组织服务去调用这个Custom API,大概率会成功的。解决办法也很简单,删除这个参数后保存即可。