将 SharePoint 外接程序模型远程事件接收器转换为 SharePoint Online Webhook

在 SharePoint 外接程序模型中,可以创建远程事件接收器,这些接收器可用于处理与列表项、列表、网站、应用程序、BCS 实体或安全配置相关的事件。 远程事件接收器依赖于允许外部 SOAP 服务获取事件通知的 SOAP 信道。 事件可以是同步的,也可以是异步的。

同步事件允许开发人员在事件发生时截获事件,并且远程事件接收器甚至可以通过自定义逻辑取消当前正在运行的操作。

异步事件允许开发人员在事件发生后收到事件通知,因此只能跟踪事件或对事件做出反应,但无法取消已发生的事件,除非实现自己的补偿逻辑。 由于其性质和逻辑,同步事件通常称为 -ing 事件 (ItemAdding、ItemUpdating、ItemDeleting 等 ) ,而异步事件通常称为 -ed 事件 (ItemAdded、ItemUpdated、ItemDeleted 等 ) 。

重要

远程事件接收器也可以在不依赖于 Azure ACS (() 已停用)的情况下使用远程事件接收器,检查使用没有 Azure ACS 依赖项的远程事件接收器一文了解详细信息。

重要

本文指的是所谓的 PnP 组件、示例和/或工具,它们是由提供支持的活动社区支持的开源资产。 没有来自 Microsoft 的官方支持渠道的开放源代码工具支持的 SLA。 但是,这些组件或示例使用的是 Microsoft 支持的现成 API 和 Microsoft 支持的功能。

如果愿意,可以watch以下视频,而不是阅读整篇文章,你仍然可以将其视为更详细的参考。

IMAGE_ALT

从技术角度来看,远程事件接收器作为 Windows Communication Framework (WCF) 服务实现。 事实上,在使用 Visual Studio 时,如果创建远程事件接收器,将获得一个 WCF 服务,该服务 (服务协定) 实现以下接口。

namespace Microsoft.SharePoint.Client.EventReceivers
{
    [ServiceContract(Namespace = "http://schemas.microsoft.com/sharepoint/remoteapp/")]
    public interface IRemoteEventService
    {
        [OperationContract]
        SPRemoteEventResult ProcessEvent(SPRemoteEventProperties properties);

        [OperationContract(IsOneWay = true)]
        void ProcessOneWayEvent(SPRemoteEventProperties properties);
    }
}

可以看到,服务协定只定义了两个操作,这两个操作对应于同步事件的通知和异步单向事件的通知。 操作 ProcessEvent 处理同步事件,而 ProcessOneWayEvent 用于异步处理。

这两个操作都接受 类型的 SPRemoteEventProperties参数,该参数定义用于实现远程事件接收器业务逻辑的所有有用信息。

新式 SharePoint Online 开发模型中的 Webhook

现在,SOAP 和 WCF 框架是相当旧的技术,你通常需要截获来自不一定基于 Windows 或 Microsoft 的外部平台的事件。

因此,Microsoft 引入了一个基于 Webhook 的新模型,该模型取代了“老派”远程事件接收器。 事实上,新的 Webhook 依赖于 REST 而不是 SOAP,可以面向任何平台。 在此新模型中,触发事件时,SharePoint 使用 REST 通过 HTTP 将 POST 请求发送到已注册的目标终结点。

比较 Webhook 与远程事件接收器时要强调的一个重要区别是,在 Webhook 中,你不再具有同步 事件 ,但只有异步通知模型 (-ed 事件) 。 此外,当 SharePoint Online 向外部 Webhook 发送通知时,通知正文中可能会有多个事件(出于性能原因组合在一起),并且通知正文不包括更改的实际数据,而仅包括对目标项的引用。

Webhook 代码负责从 SharePoint Online 检索实际数据。 最后但并非最不重要的一点是,远程事件接收器可用于跟踪与项目、列表、库、字段、网站和 Web、安全性等相关的事件,而 SharePoint Online Webhook 只能通知以下列表或文档库事件:

  • ItemAdded
  • ItemUpdated
  • ItemDeleted
  • ItemCheckedOut
  • ItemCheckedIn
  • ItemUncheckedOut
  • ItemAttachmentAdded
  • ItemAttachmentDeleted
  • ItemFileMoved
  • ItemVersionDeleted
  • ItemFileConverted

生成 SharePoint Online Webhook

Webhook 模型需要创建侦听器、注册目标事件的订阅、实现订阅验证过程以及实现订阅续订过程。 在以下部分中,你将了解如何实现和管理所有这些步骤。

实现 Webhook 侦听器

首先,若要创建 Webhook,需要实现一个 REST 终结点,该终结点将从 SharePoint Online 接收所有通知,作为通过 HTTP POST 发送的 JSON 请求。 在以下代码摘录中,可以看到 Webhook 通知消息的轮廓。

{
    "value": [
        {
            "subscriptionId":"724c2999-a35e-4415-a51a-d74682086ee1",
            "clientState":"00000000-0000-0000-0000-000000000000",
            "expirationDateTime":"2023-02-30T17:27:00.0000000Z",
            "resource":"07a1cd78-619b-480c-a285-86ff9e6a27f9",
            "tenantId":"00000000-0000-0000-0000-000000000000",
            "siteUrl":"/",
            "webId":"c60dad7d-3046-4057-b6ce-3e70fda2a708"
        }
    ]
}

可以看到,通知的正文由通知项数组组成。 可能只有一个通知项,也可能有多个通知项。 代码应已准备好支持任意数量的通知。

在每个通知项中,你将找到以下信息:

  • subscriptionId:目标订阅的 ID,它是每当注册新订阅时从 SharePoint Online 返回的值 (有关注册 Webhook) 的更多详细信息,请参阅下一部分。
  • clientState:注册订阅时提供的可选字符串值,如果注册期间提供,则 SharePoint Online 返回该字符串值。
  • expirationDateTime:订阅过期(如果未更新或续订)的日期和时间。
  • resource:发生事件的资源的 ID,可以是列表或库 ID。
  • tenantId:发生事件的租户的 ID。
  • siteUrl:包含发生事件的资源的站点的服务器相对 URL。
  • webId:包含事件发生的资源的 Web 的 ID。

在 Webhook 实现中,必须处理请求并检索受通知事件影响的实际数据/项目/文档。 但是,Webhook 的执行时间不能超过 5 秒,因此,应仔细设计解决方案原型,以遵守此要求。

一种选择是将 Webhook 用作事件的收集器,然后将事件排入异步队列 ((如 Azure Blob 存储 队列或Azure 服务总线) ),在后端和异步服务中处理实际请求。 在本指南中,你将生成一个依赖于Azure Blob 存储队列的 Webhook。

注意

阅读 文档 SharePoint Webhooks 示例参考实现,可以找到有关 Webhook 的可缩放体系结构的更多详细信息。

使用 Azure 函数创建 Webhook

让我们使用 .NET 和 C# 将 Webhook 实现为 Azure 函数。 创建一个文件夹,启动代码编辑器(例如,Visual Studio Code),并使用命令行使用 HTTP 触发的函数创建新的 Azure 函数应用。

注意

可以通过阅读文档快速入门:从命令行在 Azure 中创建 C# 函数,找到有关如何在 .NET 中创建 Azure 函数的分步说明。

使用 Visual Studio Code,可以按照以下步骤操作:

  • (CTRL+SHIFT+P) 显示命令面板
  • 选择“Azure Functions:创建新项目”
  • 选择目标文件夹
  • 选择“C#”作为目标语言
  • 选择“.NET 6.0 独立 LTS”作为目标 .NET 运行时
  • 选择“HTTP 触发器”作为函数的模板
  • 调用函数“ProcessEvent”
  • 为生成的代码提供所选的 .NET 命名空间
  • 选择“匿名”作为函数的 AccessRights 选项

将 Azure 函数应用项目搭建基架后,应具有如下图所示的项目大纲。

Visual Studio Code 中的 Azure 函数项目的大纲。

Program.cs 文件定义函数应用的启动,并负责创建和启动主机实例。 在以下代码摘录中,可以看到基架 Program.cs 文件。

using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .Build();

host.Run();

HostBuilder 初始化代码中,可以使用依赖项注入定义实际函数实现中所需的任何服务。 具体而言,要生成的 SharePoint Online Webhook 项目将依赖于新的 PnP 核心 SDK,该 SDK 完全适合具有依赖关系注入的新式 .NET 解决方案。 此外,Webhook 将使用 Azure Active Directory 注册的应用程序,该应用程序将通过仅限应用程序的安全上下文使用 SharePoint Online 数据。

若要将 PnP Core SDK 添加到 Azure 函数项目,只需从命令行执行以下语句。

dotnet add package PnP.Core.Auth

上述语句将添加对 PnP.Core.Auth 包的引用,该包在内部依赖于 PnP.Core main 包。 现在,需要在 Azure Active Directory 中注册一个应用程序,该应用程序将用于向 SharePoint Online 进行身份验证,以便获取有关受 Webhook 通知影响的资源的详细信息。

若要注册 AAD 应用程序,可以按照将 SharePoint 应用程序从 Azure 访问控制 服务升级到 Azure Active Directory 一文中的自动在 Azure AD 中使用 PnP PowerShell 注册新应用程序部分中提供的说明进行操作。 要使此方案正常工作,需要向应用程序授予 Sites.Manage.All 应用程序权限。

在 AAD 中注册应用程序后,将 ClientIdTenantId 和 PFX 证书文件保存在安全位置。 可以更新函数应用的启动代码和设置。 更新的启动代码应类似于以下代码摘录。

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PnP.Core.Auth.Services.Builder.Configuration;

public class Program {
    public static void Main()
    {
        AzureFunctionSettings azureFunctionSettings = null;

        var host = new HostBuilder()
            .ConfigureServices((context, services) =>
            {

                // Add the global configuration instance
                services.AddSingleton(options =>
                {
                    var configuration = context.Configuration;
                    azureFunctionSettings = new AzureFunctionSettings();
                    configuration.Bind(azureFunctionSettings);
                    return configuration;
                });

                // Add our custom configuration instance
                services.AddSingleton(options => { return azureFunctionSettings; });

                // Add PnP Core SDK with default configuration
                services.AddPnPCore();

                // Configure default authentication provider for PnP Core SDK 
                services.AddPnPCoreAuthentication(options =>
                {
                    // Load the certificate to use
                    X509Certificate2 cert = LoadCertificate(azureFunctionSettings);

                    // Configure certificate based auth
                    options.Credentials.Configurations.Add("CertAuth", 
                        new PnPCoreAuthenticationCredentialConfigurationOptions
                        {
                            ClientId = azureFunctionSettings.ClientId,
                            TenantId = azureFunctionSettings.TenantId,
                            X509Certificate = new PnPCoreAuthenticationX509CertificateOptions
                            {
                                Certificate = LoadCertificate(azureFunctionSettings),
                            }
                        });

                    // Set the above authentication provider as the default one
                    options.Credentials.DefaultConfiguration = "CertAuth";
                });
            })
            .ConfigureFunctionsWorkerDefaults()
            .Build();

        host.Run();
    }

    private static X509Certificate2 LoadCertificate(AzureFunctionSettings azureFunctionSettings)
    {
        // Remove from this excerpt for the sake of simplicity ...
    }
}

Program.cs 加载 PnP Core SDK 服务,并为 PnP Core SDK 配置基于证书的默认身份验证提供程序。 可以在 SPO Webhook 解决方案 中找到示例的整个源代码。 配置基于应用程序 JSON 设置,在本地开发环境中,这些设置由 local.settings.json 文件定义。 可在此处找到本地设置文件的示例摘录。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "ClientId": "<your-app-client-id>",
    "TenantId": "<your-app-tenant-id>",
    "TenantName": "<your-tenant>.sharepoint.com",
    "CertificateStoreName": "My",
    "CertificateStoreLocation": "CurrentUser",
    "CertificateThumbPrint": "<certificate-thumbprint>",
    "WEBSITE_LOAD_CERTIFICATES": "*"
  }
}

Program.cs只需将 JSON 设置加载到自定义类型为 AzureFunctionSettings 的完全类型对象中,该对象在以下代码摘录中定义。

using System.Security.Cryptography.X509Certificates;

public class AzureFunctionSettings
{
    public string TenantId { get; set; }
    public string TenantName { get; set; }
    public string ClientId { get; set; }
    public StoreName CertificateStoreName { get; set; }
    public StoreLocation CertificateStoreLocation { get; set; }
    public string CertificateThumbprint { get; set; }
}

真正的 Azure 函数是在 ProcessEvent.cs 文件中实现的,该文件的开箱即用类似于以下代码摘录。

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

namespace PnP.SPO.Webhooks
{
    public class ProcessEvent
    {
        private readonly ILogger _logger;

        public ProcessEvent(ILoggerFactory loggerFactory)
        {
            _logger = loggerFactory.CreateLogger<ProcessEvent>();
        }

        [Function("ProcessEvent")]
        public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req)
        {
            _logger.LogInformation("C# HTTP trigger function processed a request.");

            var response = req.CreateResponse(HttpStatusCode.OK);
            response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

            response.WriteString("Welcome to Azure Functions!");

            return response;
        }
    }
}

若要实现实际的 SharePoint Online Webhook 逻辑,需要替换函数类的构造函数,以便获取对 PnP Core SDK 对象的引用。 此外,必须更新 “ProcessEvent” 函数的签名及其实现。 在以下代码摘录中,可以看到函数代码的外观。

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using PnP.Core.Services;

namespace PnP.SPO.Webhooks
{
    public class ProcessEvent
    {
        private readonly ILogger _logger;
        private readonly IPnPContextFactory _pnpContextFactory;
        private readonly AzureFunctionSettings _settings;

        public ProcessEvent(IPnPContextFactory pnpContextFactory,
            AzureFunctionSettings settings,
            ILoggerFactory loggerFactory)
        {
            _pnpContextFactory = pnpContextFactory;
            _settings = settings;
            _logger = loggerFactory.CreateLogger<ProcessEvent>();
        }


        [Function("ProcessEvent")]
        public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
        string validationToken)
        {
            _logger.LogInformation("Webhook triggered!");

            // Prepare the response object
            HttpResponseData response = null;

            if (!string.IsNullOrEmpty(validationToken))
            {
                // If we've got a validationtoken query string argument
                // We simply reply back with 200 (OK) and the echo of the validationtoken
                response = req.CreateResponse(HttpStatusCode.OK);
                response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
                response.WriteString(validationToken);

                return response;
            }

            // Otherwise we need to process the event

            try 
            {
                // First of all, try to deserialize the request body
                using (var sr = new StreamReader(req.Body))
                {
                    var jsonRequest = sr.ReadToEnd();

                    var notifications = System.Text.Json.JsonSerializer.Deserialize<WebhookNotification>(jsonRequest, 
                        new System.Text.Json.JsonSerializerOptions {
                            PropertyNameCaseInsensitive = true
                        });

                    // If we have the input object
                    if (notifications != null)
                    {
                        // Then process every single event in the notification body
                        foreach (var notification in notifications.Value) 
                        {
                            _logger.LogInformation($"Notification for resource {notification.Resource} on site {notification.SiteUrl} for tenant {notification.TenantId}");
                        }
                    }
                }                
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            } 

            // We need to return an OK response within 5 seconds
            response = req.CreateResponse(HttpStatusCode.OK);
            return response;
        }
    }
}

函数类的构造函数依赖于依赖项 injeciton 并接受 接口的 IPnPContextFactory 实例,该实例表示 PnP Core SDK 的工厂服务,稍后会很有用。 构造函数还接受 AzureFunctionSettings 类型的参数,该参数提供为函数应用配置的所有自定义设置。 函数的主体(即 Run 方法)配置为通过 HTTP 接受 GET 和 POST 请求,以及接受具有名称 validationtoken 的查询字符串参数。

注册新的 Webhook 时,SharePoint Online 将验证发出 GET 请求的终结点,并在查询字符串中提供具有名称 validationtoken 的参数。 如果终结点在不超过 5 秒内回复到 SharePoint Online,并提供 200 个 (正常) 响应,并且响应的文本正文中具有 验证token 的值,则 SharePoint Online 将认为该终结点有效。 否则,不会注册 Webhook。 请参阅下一节“注册 Webhook”,以更好地了解注册和验证过程。

如果查询字符串中没有 validationtoken ,并且函数收到 POST 请求,则请求正文中应有一个 JSON 序列化的事件数组。 在以下代码摘录中,可以看到请求正文定义在 C# 中的外观。

public class WebhookNotification
{
    public WebhookNotificationEvent[] Value { get; set; }
}

public class WebhookNotificationEvent
{
    public string SubscriptionId { get; set; }

    public string ClientState { get; set; }

    public string ExpirationDateTime { get; set; }

    public string Resource { get; set; }

    public string TenantId { get; set; }

    public string SiteUrl { get; set; }

    public string WebId { get; set; }
}

可以清楚地看到它与本文开头所示的 JSON 匹配。 在上面的示例实现中,代码只是反序列化事件数组,然后在记录器上写入每个事件。

访问 SharePoint Online 中的实际更改

为了处理事件并获取有关目标项目/文档的信息,您需要依赖 GetChanges SharePoint Online 提供的功能。 事实上, GetChanges 方法允许你获取特定目标资源上发生的所有更改,因为上次向 GetChanges 方法本身发出请求。

为了能够了解你何时发出最后一个请求, GetChanges 该方法会为你提供更改和 ChangeToken,你必须在下一个 GetChanges 方法调用中提供这些更改。

注意

通过阅读文档枚举 SharePoint 中发生的更改,可以找到有关如何将 方法与 PnP Core SDK 配合使用GetChanges的其他详细信息。

此处遵循修订后的函数实现,使用 GetChanges 方法。

[Function("ProcessEvent")]
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
string validationToken)
{
    _logger.LogInformation("Webhook triggered!");

    // Prepare the response object
    HttpResponseData response = null;

    if (!string.IsNullOrEmpty(validationToken))
    {
        // If we've got a validationtoken query string argument
        // We simply reply back with 200 (OK) and the echo of the validationtoken
        response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
        response.WriteString(validationToken);

        return response;
    }

    // Otherwise we need to process the event

    try 
    {
        // First of all, try to deserialize the request body
        using (var sr = new StreamReader(req.Body))
        {
            var jsonRequest = sr.ReadToEnd();

            var notifications = System.Text.Json.JsonSerializer.Deserialize<WebhookNotification>(jsonRequest, 
                new System.Text.Json.JsonSerializerOptions {
                    PropertyNameCaseInsensitive = true
                });

            // If we have the input object
            if (notifications != null)
            {
                // Then process every single event in the notification body
                foreach (var notification in notifications.Value) 
                {
                    _logger.LogInformation($"Notification for resource {notification.Resource} on site {notification.SiteUrl} for tenant {notification.TenantId}");

                    using (var pnpContext = await _pnpContextFactory.CreateAsync(
                        new Uri($"https://{_settings.TenantName}/{notification.SiteUrl}"), 
                        CancellationToken.None))
                    {
                        pnpContext.GraphFirst = false;

                        // Define a query for the last 100 changes happened to list items,
                        // regardless the type of change (add, update, delete).
                        // Here code still does not provide the ChangeToken 
                        var changeQuery = new PnP.Core.Model.SharePoint.ChangeQueryOptions(false, true) {
                            Item = true,                                    
                            FetchLimit = 100,
                        };
                        // Use GetChanges against the list with ID notification.Resource, which is the target list
                        var targetList = pnpContext.Web.Lists.GetById(Guid.Parse(notification.Resource));
                        var changes = await targetList.GetChangesAsync(changeQuery);

                        // Get the change token, we should save it in a safe place
                        // and provide it back while configuring the ChangeQueryOptions
                        var lastChangeToken = changes.Last().ChangeToken;

                        // Process all the retrieved changes
                        foreach (var change in changes)
                        {
                            // Try to see if the current change is an IChangeItem
                            // meaning that it is a change that occurred on an item
                            if (change is IChangeItem changeItem)
                            {
                                // Get the date and time when the change happened
                                DateTime changeTime = changeItem.Time;
                                
                                // Check if we have the ID of the target item
                                if (changeItem.IsPropertyAvailable<IChangeItem>(i => i.ItemId))
                                {
                                    var itemId = changeItem.ItemId;

                                    // If that is the case, retrieve the item
                                    var targetItem = targetList.Items.GetById(itemId);

                                    if (targetItem != null)
                                    {
                                        // And log some information, just for the sake of making an example
                                        _logger.LogInformation($"Processing changes for item '{targetItem.Title}' happened on {changeTime}");
                                    }
                                }      
                            } 
                        }
                    }
                }
            }
        }                
    }
    catch (Exception ex)
    {
        _logger.LogError(ex.Message);
    } 

    // We need to return an OK response within 5 seconds
    response = req.CreateResponse(HttpStatusCode.OK);
    return response;
}

生成异步处理模型

从理论上讲,函数实现应该已准备就绪。 但是,您需要考虑到 SharePoint Online 要求 Webhook 在 5 秒内处理更改并回复 200 (正常) 。 显然,像前面演示的实现一样,并不能保证处理时间小于 5 秒,而且实际上很可能需要比这更多的时间。

因此,解决方案是依赖于异步后端函数,该函数将由 Webhook 排队的消息触发。 通过这种技术,Webhook 将非常快,所有处理时间都将转移到后端函数,这可能需要很长时间。

首先,让我们添加对某些包的引用,以使用 Azure 服务。 在命令提示符中,从 webhook 项目的 main 文件夹中运行以下命令。

dotnet add package Microsoft.Extensions.Azure
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Storage.Queues

上述命令将添加三个包,以便通过依赖项注入使用 Azure 服务、使用 Azure 存储 Blob、存储最新的 ChangeToken 值以及使用 Azure 存储队列,以便将通知排入队列。

然后,更新 Program.cs 文件,以便在通过依赖项注入加载的服务列表中包括Azure Blob 存储和 Azure 存储队列服务。 在以下代码摘录中,可以看到 Program.cs 文件的修订代码。

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PnP.Core.Auth.Services.Builder.Configuration;
using Microsoft.Extensions.Azure;

public class Program {
    public static void Main()
    {
        AzureFunctionSettings azureFunctionSettings = null;

        var host = new HostBuilder()
            .ConfigureServices((context, services) =>
            {
                // Add the Azure Storage services
                services.AddAzureClients(builder =>
                {
                    var blobConnectionString = context.Configuration["AzureStorage"];
                    builder.AddBlobServiceClient(blobConnectionString);
                    builder.AddQueueServiceClient(blobConnectionString);
                });

                // Add the global configuration instance
                services.AddSingleton(options =>
                {
                    var configuration = context.Configuration;
                    azureFunctionSettings = new AzureFunctionSettings();
                    configuration.Bind(azureFunctionSettings);
                    return configuration;
                });

                // Add our custom configuration instance
                services.AddSingleton(options => { return azureFunctionSettings; });

                // Add PnP Core SDK with default configuration
                services.AddPnPCore();

                // Configure default authentication provider for PnP Core SDK 
                services.AddPnPCoreAuthentication(options =>
                {
                    // Load the certificate to use
                    X509Certificate2 cert = LoadCertificate(azureFunctionSettings);

                    // Configure certificate based auth
                    options.Credentials.Configurations.Add("CertAuth", 
                        new PnPCoreAuthenticationCredentialConfigurationOptions
                        {
                            ClientId = azureFunctionSettings.ClientId,
                            TenantId = azureFunctionSettings.TenantId,
                            X509Certificate = new PnPCoreAuthenticationX509CertificateOptions
                            {
                                Certificate = LoadCertificate(azureFunctionSettings),
                            }
                        });

                    // Set the above authentication provider as the default one
                    options.Credentials.DefaultConfiguration = "CertAuth";
                });
            })
            .ConfigureFunctionsWorkerDefaults()
            .Build();

        host.Run();
    }

    private static X509Certificate2 LoadCertificate(AzureFunctionSettings azureFunctionSettings)
    {
        // Remove from this excerpt for the sake of simplicity ...
    }
}

可以注意到调用 services.AddAzureClients 部分来注册 Azure 服务。 本文稍后会通过依赖项注入在 Azure 函数类的构造函数中提供上述服务。

现在,让我们看看如何创建基于队列的后端函数。 使用 Visual Studio Code,可以按照以下步骤操作:

  • (CTRL+SHIFT+P) 显示命令面板
  • 选择“Azure Functions:创建函数”
  • 选择“Azure 队列存储触发器”
  • 调用函数“QueueProcessEvent”
  • 为生成的代码提供所选的 .NET 命名空间
  • 为目标 Azure 存储队列连接选择“+ 创建新的本地应用设置”
  • 选择要使用的 Azure 存储队列或创建新队列
  • 提供 Azure 存储队列的名称,例如“spo-webhook”

在这里,可以看到为新的 QueueProcessEvent 类生成的代码。

using System;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace PnP.SPO.Webhooks
{
    public class QueueProcessEvent
    {
        private readonly ILogger _logger;

        public QueueProcessEvent(ILoggerFactory loggerFactory)
        {
            _logger = loggerFactory.CreateLogger<QueueProcessEvent>();
        }

        [Function("QueueProcessEvent")]
        public void Run([QueueTrigger("spo-webhooks", Connection = "AzureStorage")] string myQueueItem)
        {
            _logger.LogInformation($"C# Queue trigger function processed: {myQueueItem}");
        }
    }
}

可以更新函数代码,以便它将处理所有通知事件。 例如,可以从 ProcessEvent 函数对每个事件进行排队,只需在 JSON 中序列化 类型为 WebhookNotificationEvent 的事件,并将其排队到 Azure 存储队列中即可。 还需要考虑最后一个通知的 ChangeToken ,以便只能处理新事件。 为简单起见,可以将 ChangeToken 存储在 Azure 存储表中,并利用用于队列的同一 Azure 存储服务实例。

在以下代码摘录中,可以看到 QueueProcessEvent 类的修订实现。

using System;
using Azure.Storage.Blobs;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using PnP.Core.Model.SharePoint;
using PnP.Core.Services;

namespace PnP.SPO.Webhooks
{
    public class QueueProcessEvent
    {
        private readonly ILogger _logger;
        private readonly IPnPContextFactory _pnpContextFactory;
        private readonly AzureFunctionSettings _settings;
        private readonly BlobServiceClient _blobServiceClient;

        public QueueProcessEvent(IPnPContextFactory pnpContextFactory,
            AzureFunctionSettings settings,
            BlobServiceClient blobServiceClient,
            ILoggerFactory loggerFactory)
        {
            _pnpContextFactory = pnpContextFactory;
            _settings = settings;
            _blobServiceClient = blobServiceClient;
            _logger = loggerFactory.CreateLogger<QueueProcessEvent>();
        }

        [Function("QueueProcessEvent")]
        public async Task Run([QueueTrigger("spo-webhooks", Connection = "AzureStorage")] string queueMessage)
        {
            if (!string.IsNullOrEmpty(queueMessage))
            {
                var notification = System.Text.Json.JsonSerializer.Deserialize<WebhookNotificationEvent>(queueMessage, 
                    new System.Text.Json.JsonSerializerOptions {
                        PropertyNameCaseInsensitive = true
                    });
                
                if (notification != null)
                {
                    _logger.LogInformation($"Notification for resource {notification.Resource} on site {notification.SiteUrl} for tenant {notification.TenantId}");

                    using (var pnpContext = await _pnpContextFactory.CreateAsync(
                        new Uri($"https://{_settings.TenantName}/{notification.SiteUrl}"), 
                        CancellationToken.None))
                    {
                        pnpContext.GraphFirst = false;

                        // Define a query for the last 100 changes happened, regardless the type of change (add, update, delete). Here code still does not provide the ChangeToken 
                        var changeQuery = new PnP.Core.Model.SharePoint.ChangeQueryOptions(false, true) {
                            Item = true,                                   
                            FetchLimit = 100,
                        };

                        var lastChangeToken = await GetLatestChangeTokenAsync();
                        if (lastChangeToken != null) {
                            changeQuery.ChangeTokenStart = new ChangeTokenOptions(lastChangeToken);
                        }

                        // Use GetChanges against the list with ID notification.Resource, which is the target list
                        var targetList = pnpContext.Web.Lists.GetById(Guid.Parse(notification.Resource));
                        var changes = await targetList.GetChangesAsync(changeQuery);

                        // Save the last change token
                        await SaveLatestChangeTokenAsync(changes.Last().ChangeToken);

                        // Process all the retrieved changes
                        foreach (var change in changes)
                        {
                            _logger.LogInformation(change.GetType().FullName);

                            // Try to see if the current change is an IChangeItem
                            // meaning that it is a change that occurred on an item
                            if (change is IChangeItem changeItem)
                            {
                                // Get the date and time when the change happened
                                DateTime changeTime = changeItem.Time;
                                
                                // Check if we have the ID of the target item
                                if (changeItem.IsPropertyAvailable<IChangeItem>(i => i.ItemId))
                                {
                                    var itemId = changeItem.ItemId;

                                    // If that is the case, retrieve the item
                                    var targetItem = targetList.Items.GetById(itemId);

                                    if (targetItem != null)
                                    {
                                        // And log some information, just for the sake of making an example
                                        _logger.LogInformation($"Processing changes for item '{targetItem.Title}' happened on {changeTime}");
                                    }
                                }      
                            } 
                        }
                    }
                }
            }
        }

        private async Task<string> GetLatestChangeTokenAsync()
        {
            // Code omitted for the sake of simplicity
        }

        private async Task SaveLatestChangeTokenAsync(IChangeToken changeToken)
        {            
            // Code omitted for the sake of simplicity
        }
    }
}

可以注意到构造函数通过依赖项注入接收的 BlobServiceClient 实例,以便代码能够依赖Azure Blob 存储来读取和写入 ChangeToken

然后,函数的正文将反序列化从队列接收的字符串消息,以便检索自定义 WebhookNotificationEvent 类型的实际实例。 然后,函数的实际实现的行为几乎与前面讨论的 ProcessEvent 函数类似。

但是,一个重要的区别是,现在新的 QueueProcessEvent 函数处理 ChangeToken 值,依赖于 GetLatestChangeTokenAsyncSaveLatestChangeTokenAsync 方法。 可以在与本文关联的示例项目中找到 QueueProcessEvent 的完整源代码。

重要

为了简单起见,本文中演示的解决方案在读取和写入 ChangeToken 值时,缺少在多线程环境中运行的多个事件的同步。 在实际解决方案中,在编写 ChangeToken 的新值时,应考虑到授予对 Azure Blob 存储 容器的独占访问权限。 另一种选择是依赖于不同的 Azure 服务,例如 Azure Redis 缓存,与在 Azure Blob 存储 中存储文件相比,它可能更快、更具可缩放性。

现在, ProcessEvent 函数可以简化,因为它只需在 Azure 存储队列中排队通知。 在以下代码摘录中,可以看到新的实现。

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using PnP.Core.Model.SharePoint;
using PnP.Core.Services;
using Azure.Storage.Queues;

namespace PnP.SPO.Webhooks
{
    public class ProcessEvent
    {
        private readonly ILogger _logger;
        private readonly IPnPContextFactory _pnpContextFactory;
        private readonly AzureFunctionSettings _settings;
        private readonly QueueServiceClient _queueServiceClient;

        public ProcessEvent(IPnPContextFactory pnpContextFactory,
            AzureFunctionSettings settings,
            QueueServiceClient queueServiceClient,
            ILoggerFactory loggerFactory)
        {
            _pnpContextFactory = pnpContextFactory;
            _settings = settings;
            _queueServiceClient = queueServiceClient;
            _logger = loggerFactory.CreateLogger<ProcessEvent>();
        }

        [Function("ProcessEvent")]
        public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
        string validationToken)
        {
            _logger.LogInformation("Webhook triggered!");

            // Prepare the response object
            HttpResponseData response = null;

            if (!string.IsNullOrEmpty(validationToken))
            {
                // If we've got a validationtoken query string argument
                // We simply reply back with 200 (OK) and the echo of the validationtoken
                response = req.CreateResponse(HttpStatusCode.OK);
                response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
                response.WriteString(validationToken);

                return response;
            }

            // Otherwise we need to process the event

            try 
            {
                // First of all, try to deserialize the request body
                using (var sr = new StreamReader(req.Body))
                {
                    var jsonRequest = sr.ReadToEnd();

                    var notifications = System.Text.Json.JsonSerializer.Deserialize<WebhookNotification>(jsonRequest, 
                        new System.Text.Json.JsonSerializerOptions {
                            PropertyNameCaseInsensitive = true
                        });

                    // If we have the input object
                    if (notifications != null)
                    {
                        // Then process every single event in the notification body
                        foreach (var notification in notifications.Value) 
                        {
                            var queue = _queueServiceClient.GetQueueClient("spo-webhooks");
                            if (await queue.ExistsAsync())
                            {
                                var message = System.Text.Json.JsonSerializer.Serialize(notification);
                                await queue.SendMessageAsync(
                                    System.Convert.ToBase64String(
                                        System.Text.Encoding.UTF8.GetBytes(message)));
                            }
                        }
                    }
                }                
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            } 

            // We need to return an OK response within 5 seconds
            response = req.CreateResponse(HttpStatusCode.OK);
            return response;
        }
    }
}

可以注意到构造函数通过依赖关系注入接收的 QueueServiceClient 实例,以便代码能够依赖 Azure 存储队列来排队通知。

注册 Webhook

现在,你已准备好将 Webhook 注册到 SharePoint Online。 为此,需要在 Azure Active Directory 中注册一个具有委派权限或应用程序权限 站点.Manage.All 的应用程序。

注意

如果要使用具有应用程序权限的仅限应用程序令牌,则需要为 Azure AD 应用身份验证配置 X.509 证书。 事实上,SharePoint Online 要求对仅限应用程序的令牌进行证书身份验证。

注册过程要求通过 HTTP POST 向 SharePoint Online 提供的 REST API 发送 JSON 消息,并在以下 URL 处提供:

https://<tenant-name>.sharepoint.com/sites/<site-relative-url>/_api/web/lists('<ID-of-the-target-list>')/subscriptions

例如,如果租户名称为“contoso”,目标站点 URL 为“TargetSite”,列表 ID 为“a0214797-db97-4d2c-bcd6-0e2a395d127b”,则 URL 将为:

https://contoso.sharepoint.com/sites/TargetSite/_api/web/lists('a0214797-db97-4d2c-bcd6-0e2a395d127b')/subscriptions

因此,首先定义目标网站集的 URL 和目标列表或库的 ID。 然后,通过 HTTP POST 发送一个采用类似以下结构的 JSON 请求。

 {
   "resource": "https://<tenant-name>.sharepoint.com/sites/<site-relative-url>/_api/web/lists('<ID-of-the-target-list>')",
   "notificationUrl": "<URL-of-your-webhook>",
   "expirationDateTime": "2023-02-16T22:30:00+00:00",
   "clientState": "Something-Unique"
 }

可以看到,注册请求定义了以下设置:

  • resource:目标资源 (列表或库)
  • notificationUrl:实际 Webhook 的 URL,需要通过 Internet 公开提供
  • expirationDateTime:Webhook 注册的到期日期和时间,最多可以自注册之日起 6 个月
  • clientState:一个可选的唯一字符串,可用于在 Webhook 上收到通知时对其进行验证。

一旦发送此类注册请求,SharePoint Online 就会向 webhool (的 URL 发送 HTTP GET 请求,即提供的 notificationUrl) ,在查询字符串中提供需要处理并回显到 SharePoint 的 验证, 如本文前面所述。

成功的注册请求将返回如下响应:

{
    "odata.metadata": "https://<tenant-name>.sharepoint.com/sites/<site-relative-url>/_api/$metadata#SP.ApiData.Subscriptions/@Element",
    "odata.type": "Microsoft.SharePoint.Webhooks.Subscription",
    "odata.id": "https://<tenant-name>.sharepoint.com/sites/<site-relative-url>/_api/web/lists('<ID-of-the-target-list>')/subscriptions",
    "odata.editLink": "web/lists('<ID-of-the-target-list>')/subscriptions",
    "clientState": "Something-Unique",
    "expirationDateTime": "2023-02-16T22:30:00Z",
    "id": "5c3af03a-3bec-4186-82f3-5c5c13bfa9b5",
    "notificationUrl": "<URL-of-your-webhook>",
    "resource": "<ID-of-the-target-list>",
    "resourceData": null
}

注册失败(例如,由于验证无效)将如下所示:

{
    "odata.error": {
        "code": "-1, System.InvalidOperationException",
        "message": {
            "lang": "en-US",
            "value": "Failed to validate the notification URL '<URL-of-your-webhook>'."
        }
    }
}

注册 Webhook 后,可以针对同一注册 URL 发出 GET 请求,以查看已注册的 Webhook 列表。

注意

请求订阅列表时,通常不仅要取回你的订阅,还要取回 Microsoft 本身定义的其他订阅。 例如,Microsoft Power Automate 依赖于 SharePoint Online Webhook 来触发其流的执行。 因此,可以在特定目标列表或库的订阅列表中找到与 Power Automate 相关的终结点。

注册续订过程

如前所述,Webhook 注册自注册之日起最长可以持续 6 个月。 注册过期后,SharePoint Online 会将其从目标列表或库中删除。

因此,在注册实际过期之前,需要不时续订注册。若要续订注册,只需针对现有 Webhook 注册的 URL 发出 PATCH 请求,并为 expirationDateTime 值提供扩展即可。

已存在的 Webhook 的 URL 定义如下例所示:

https://<tenant-name>.sharepoint.com/sites/<site-relative-url>/_api/web/lists('<ID-of-the-target-list>')/subscriptions('<ID-of-the-subscription>')

同样,如果租户名称为“contoso”, 目标站点 URL 为“TargetSite”,列表 ID 为“a0214797-db97-4d2c-bcd6-0e2a395d127b”,Webhook 注册 ID 为“5c3af03a-3bec-4186-82f3-5c5c13bfa9b5”,则 URL 将为:

https://contoso.sharepoint.com/sites/TargetSite/_api/web/lists('a0214797-db97-4d2c-bcd6-0e2a395d127b')/subscriptions('5c3af03a-3bec-4186-82f3-5c5c13bfa9b5')

若要延长到期日期时间,需要针对上述 URL 发出 HTTP PATCH 请求,并在请求正文中提供以下 JSON 内容。

{
    "clientState": "Something-Unique",
    "expirationDateTime": "2023-02-19T22:30:00Z",
    "notificationUrl": "<URL-of-your-webhook>",
    "resource": "a0214797-db97-4d2c-bcd6-0e2a395d127b"
}

处理 Webhook 续订

每当收到 Webhook 通知时,还会在通知正文中获取有关订阅 ID 和到期日期时间的信息。 双检查到期日期时间是一个明智的主意,如果订阅即将过期,只需发出 PATCH 请求即可从 Webhook 逻辑中扩展它。

请记住,您必须在不超过 5 秒的时间内回复回 SharePoint Online,以便最终可以排队另一个异步请求来延长到期日期时间,而不是尝试从通知中更新它,甚至处理代码。

在本地测试解决方案

Webhook 解决方案现已完全实现并准备好进行测试。 但是,如果只是从本地计算机运行 Azure Function App 项目,它将开始侦听 localhost,这显然不是公开提供的,SharePoint Online 无法访问。 一个选项可能是在真实的 Azure 函数应用实例上发布 Azure 函数。 但是,如果是这种情况,你将不得不依赖远程调试,一般来说,开发和调试体验会变慢。

另一种方法是依靠网络上提供的众多工具之一创建具有传入隧道的公共代理,该隧道会将请求从公共 URL 重定向到内部 localhost。 例如,可以使用 ngrok,这是在此方案中非常常用的工具。

无论使用哪种技术和工具将通知从 SharePoint Online 代理到您的 localhost,请记住 SharePoint Online Webhook 是一种异步通信技术,并且通知中可能存在延迟。 例如,在事件发生几秒甚至几分钟后收到事件通知是很常见的。 不要担心这种行为,实际上要准备以真正异步的方式处理事件。

使用 Microsoft Graph 的通知

处理 SharePoint Online 中发生的事件通知的另一个选项是依赖于 Microsoft Graph 通知 (或 webhook) 。 从体系结构的角度来看,Microsoft Graph 通知与 SharePoint Online Webhook 非常类似。 事实上,你需要注册订阅,必须为订阅实现验证终结点,需要在订阅过期之前续订订阅,并且当通知发生时,你只能获得对 targe 项的引用,而不是实际数据的引用,而需要通过显式请求进行检索。 让我们进一步探讨一下开发 Microsoft Graph 通知终结点。

注册 Microsoft Graph 通知订阅服务器

若要注册 Microsoft Graph 通知订阅服务器,只需向 Microsoft Graph 订阅终结点发出 HTTP POST 请求即可。

https://graph.microsoft.com/v1.0/subscriptions

在请求正文中,需要指定要通知的资源的相关信息。 在以下代码摘录中,可以看到示例请求。

{
   "changeType": "updated",
   "notificationUrl": "https://<your-host-url>/<your-notification-endpoint>",
   "resource": "sites/{site-id}/lists/{list-id}",
   "expirationDateTime":"2023-03-05T18:23:45.9356913Z",
   "clientState": "secretClientValue"
}

注意

若要注册订阅,需要特定于订阅目标的权限。 可以通过阅读 文档创建订阅 - 权限来查找每个受支持的目标实体所需的权限列表。

请求的 JSON 正文指定 changeType,可以是以下任何值:

  • created:创建新项时
  • 已更新:更新现有项时
  • deleted:删除现有项时

对于 SharePoint Online 列表,changeType 属性仅支持更新的值。

然后,它指定 notificationUrl ,它是将接收更改通知的终结点的 URL。 它必须是通过 HTTPS 发布的终结点。 资源属性定义要监视通知的目标资源。

在上面的示例中,可以看到 SharePoint Online 列表类型的资源,需要在其中指定 Microsoft Graph {site-id}{list-id}expirationDateTime 定义订阅的持续时间,并且需要遵守每个资源类型的最大订阅长度表中定义的支持的过期限制。 对于 SharePoint Online 列表,过期时间最长为 30 天。

clientState 是一个必需的字符串,允许通知终结点验证来自 Microsoft Graph 的请求。 它可以是不超过 128 个字符的字符串。

从成功注册中获得的响应类似于以下代码摘录。

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
  "id": "185e2379-e250-4a30-9bf4-32209164e3f4",
  "resource": "sites/{site-id}/lists/{list-id}",
  "applicationId": "c3ecbfd9-8178-4f59-bd47-e50ea2ebe9b0",
  "changeType": "created",
  "clientState": "secretClientValue",
  "notificationUrl": "https://<your-host-url>/<your-notification-endpoint>",
  "expirationDateTime": "2023-03-05T18:23:45.9356913Z",
  "creatorId": "2920e60f-a1d8-4175-ac5e-d83a05cc2a19",
  "latestSupportedTlsVersion": "v1_2",
  "notificationContentType": "application/json"
}

响应概述了刚刚注册的订阅的设置,包括稍后可用于续订的订阅 的 ID

注册验证

与 SharePoint Online Webhook 一样,Microsoft Graph 通过对终结点的 HTTP POST 请求验证 Microsoft Graph 通知终结点。 验证请求的 URL 如下所示。

https://<your-host-url>/<your-notification-endpoint>?validationToken={opaqueTokenCreatedByMicrosoftGraph}

通知终结点必须在不超过 10 秒内回复到 Microsoft Graph,提供 200 正常响应状态,并在响应正文中以文本/纯文本形式提供 validationToken 的 URL 解码值的内容。

注册续订

每当 Microsoft Graph 通知订阅即将过期但尚未过期时,可以使用如下所示的 URL 通过 ID 发出针对订阅的 HTTP PATCH 请求来续订它:

https://graph.microsoft.com/v1.0/subscriptions/{id}

例如,若要续订在上面的代码摘录中创建的订阅,URL 应如下所示。

https://graph.microsoft.com/v1.0/subscriptions/185e2379-e250-4a30-9bf4-32209164e3f4

续订请求的正文应指定新的到期日期和时间,如以下代码摘录中所示。

{
  "expirationDateTime": "2023-03-15T18:23:45.9356913Z",
}

如果你不会续订订阅,Microsoft Graph 会自动删除该订阅。 还可以通过向订阅终结点发出 HTTP DELETE 请求,在订阅过期之前显式删除订阅。

处理通知

每当终结点收到通知时,Microsoft Graph 都会发送一条 JSON 消息,其结构如下所示。

{
  "value": [
    {
      "id": "lsgTZMr9KwAAA",
      "subscriptionId":"{id}",
      "subscriptionExpirationDateTime":"2023-03-15T18:23:45.9356913Z",
      "clientState":"secretClientValue",
      "changeType":"updated",
      "resource":"sites/{site-id}/lists/{list-id}",
      "tenantId": "ff983742-8176-4a22-8141-5acde86f0902",
      "resourceData":
      {
      }
    }
  ]
}

可以看到,在通知正文中,你可获取有关订阅本身以及源 tenantId 的信息,以防已创建多租户订阅。 此外,在 resourceData 复杂属性中,可找到已收到通知的目标项的相关信息。

但是,如果收到 SharePoint Online 列表通知,则不会获取有关目标项的任何实际 resourceData ,并且与 SharePoint Online Webhook 一样,由您使用 GetChanges 方法检索实际更改。

为了完整起见,在以下代码摘录中,可以看到 Microsoft Graph 通知终结点的示例实现。

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Graph;

namespace MSGraphSDKNotifications
{
    public static class NotifyFunction
    {
        [Function("Notify")]
        public static async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
            FunctionContext executionContext)
        {
            // Prepare the response object
            HttpResponseData response = null;

            // Get the logger
            var log = executionContext.GetLogger("NotifyFunction");

            log.LogInformation("Notify function triggered!");

            // Graph Subscription validation logic, if needed
            var querystring = QueryHelpers.ParseQuery(req.Url.Query);
            string validationToken = null;
            if (querystring.ContainsKey("validationToken"))
            {
                validationToken = querystring["validationToken"];
            }
            if (!string.IsNullOrEmpty(validationToken))
            {
                response = req.CreateResponse(HttpStatusCode.OK);
                response.WriteString(validationToken);

                return response;
            }
            else
            {
                // Just output the body of the notification,
                // for the sake of understanding how Microsoft Graph notifications work
                using (var sr = new StreamReader(req.Body))
                {
                    log.LogInformation(sr.ReadToEnd());
                }
            }

            response = req.CreateResponse(HttpStatusCode.OK);

            return response;
        }
    }
}

Microsoft Graph 通知终结点必须在 3 秒内回复到 Microsoft Graph。 因此,应考虑使用队列实现异步模型,就像在上一节中有关 SharePoint Online Webhook 的模型一样。

有关本主题的其他信息,请参阅以下文档: