可以使用 SharePoint 客户端对象模型 (CSOM) 在 SharePoint 中检索、更新和管理数据。SharePoint 按以下几种形式提供此 CSOM:

  • .NET Framework 可再发行程序集
  • .NET Standard 可再发行程序集
  • JavaScript 库 (JSOM)
  • REST/OData 终结点

在本文中,我们将重点介绍 .NET Framework 版本和 .NET Standard 可再发行版本之间的区别。 这两个版本在许多方面完全相同,并且如果你一直在使用 .NET Framework 版本编写代码,那么在使用 .NET Standard 版本时,该代码和你学到的一切在很大程度上仍将是相关的。

.NET Framework 版本和 .NET Standard 版本之间的主要区别

下表概括了两个版本之间的区别,并提供了有关如何处理这些区别的指南。

CSOM 功能 .NET Framework 版本 .NET Standard 版本 准则
.NET 可支持性 .NET Framework 4.5+ .NET Framework 4.6.1+、.NET Core 2.0+、Mono 5.4+ (.NET docs) 建议针对所有 SharePoint Online CSOM 开发使用 CSOM for .NET Standard 版本
跨平台 是(可用于任何支持 .NET Standard 的平台) 对于跨平台,必须使用 CSOM for .NET Standard
本地 SharePoint 支持 CSOM .NET Framework 版本仍然完全受支持并且持续更新,因此请使用它们进行本地 SharePoint 开发
支持旧式身份验证流程(使用 SharePointOnlineCredentials 类的所谓基于 cookie 的身份验证) 请参阅 对 CSOM for .NET Standard 使用新式身份验证 一章。 建议使用 Azure AD 应用程序配置 SharePoint Online 的身份验证
SaveBinaryDirect / OpenBinaryDirect API(基于 webdav) 在 CSOM 中使用常规文件 API,因为不建议使用 BinaryDirect API,即使在使用 .NET Framework 版本时也不例外
Microsoft.SharePoint.Client.Utilities.HttpUtility 类 切换到 .NET 中的类似类,如 System.Web.HttpUtility
Microsoft.SharePoint.Client.EventReceivers 命名空间 切换到新式事件概念,如 Web 挂钩

 备注

.NET 标准版本的 CSOM 程序集包含在自版本 16.1.20211.12000 以来名为 Microsoft.SharePointOnline.CSOM 的现有 NuGet 包中。 以下示例需要此版本或更高版本才能在 .Net core/标准目标项目中工作。

对 CSOM for .NET Standard 使用新式身份验证

使用通过 SharePointOnlineCredentials 类实施的基于用户/密码的身份验证是使用 CSOM for .NET Framework 的开发人员的常用方法。 在 CSOM for .NET Standard 中,这种方法不再可行,由使用 CSOM for .NET Standard 的开发人员来获取 OAuth 访问令牌并在调用 SharePoint Online 时使用该令牌。 建议通过设置 Azure AD 应用程序来为 SharePoint Online 获取访问令牌。 对于 CSOM for .NET Standard,唯一重要的是获取有效的访问令牌,可以通过使用资源所有者密码凭据流、使用设备登录信息、使用基于证书的身份验证等等来实现。

在本章中,我们将使用 OAuth 资源所有者密码凭据流生成一个 OAuth 访问令牌,然后,CSOM 将其用于模仿 SharePointOnlineCredentials 类的行为,以对 SharePoint Online 进行身份验证请求。

在 Azure AD 中配置应用程序

以下步骤将帮助你在 Azure Active Directory 中创建和配置应用程序:

  • 通过 https://aad.portal.azure.com 转到 Azure AD 门户
  • 选择“Azure Active Directory”和左侧导航中的“应用注册
  • 选择“新注册
  • 输入应用程序的名称,然后选择“注册
  • 转到“API 权限”以授予应用程序权限,选择“添加权限”,选择“SharePoint”、“委派权限”,然后选择例如 AllSites.Manage
  • 选择“授予管理员许可”同意应用程序的请求权限
  • 选择左侧导航栏中的“身份验证
  • 将“允许公共客户端流”从“否”更改为“”。
  • 选择“概述”并将应用程序 ID 复制到剪贴板(稍后需要使用它)

从Azure AD 获取访问令牌,并在基于 CSOM for .NET Standard 的应用程序中使用该令牌

使用 CSOM for .NET Standard 时,开发人员有责任获取 SharePoint Online 的访问令牌,并确保将其插入到对 SharePoint Online 的每次调用中。实现此操作的常见代码模式如下所示:

C#
public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword)
{
    context.ExecutingWebRequest += (sender, e) =>
    {
        // Get an access token using your preferred approach
        string accessToken = MyCodeToGetAnAccessToken(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password);
        // Insert the access token in the request
        e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
    };
}

通过 GetContext 方法获取的 ClientContext 可以像其他任何 ClientContext 一样使用,并且可以与所有现有代码一起使用。 下面的代码段显示帮助程序类和使用该帮主程序类的控制台应用,重用这些类将能够轻松实施 SharePointOnlineCredentials 类的等效操作。

 备注

PnP 网站核心库具有类似的 AuthenticationManager 类,支持更多基于 Azure AD 的身份验证流。

控制台应用示例

C#
 p.Title);
        await context.ExecuteQueryAsync();
        Console.WriteLine($"Title: {context.Web.Title}");
    }
}
" style="box-sizing: inherit; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 1em; direction: ltr; outline-color: inherit; line-height: 1.3571; border: 0px; display: block; padding: 0px; position: relative;">public static async Task Main(string[] args)
{
    Uri site = new Uri("https://contoso.sharepoint.com/sites/siteA");
    string user = "joe.doe@contoso.onmicrosoft.com";
    SecureString password = GetSecureString($"Password for {user}");

    // Note: The PnP Sites Core AuthenticationManager class also supports this
    using (var authenticationManager = new AuthenticationManager())
    using (var context = authenticationManager.GetContext(site, user, password))
    {
        context.Load(context.Web, p => p.Title);
        await context.ExecuteQueryAsync();
        Console.WriteLine($"Title: {context.Web.Title}");
    }
}

AuthenticationManager 示例类

 备注

使用在 Azure AD 中注册的应用程序的应用程序 ID 更新 defaultAADAppId

 备注

如果将 CSOM for .NET Standard 与 Azure Functions v3 一起使用,则可能会遇到与 System.IdentityModel.Tokens.Jwt 相关的运行时错误。 可通过遵循此解决方法解决此问题。

C#
using Microsoft.SharePoint.Client;
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Security;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

namespace CSOMDemo
{
    public class AuthenticationManager: IDisposable
    {
        private static readonly HttpClient httpClient = new HttpClient();
        private const string tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token";

        private const string defaultAADAppId = "986002f6-c3f6-43ab-913e-78cca185c392";

        // Token cache handling
        private static readonly SemaphoreSlim semaphoreSlimTokens = new SemaphoreSlim(1);
        private AutoResetEvent tokenResetEvent = null;
        private readonly ConcurrentDictionary<string, string> tokenCache = new ConcurrentDictionary<string, string>();
        private bool disposedValue;

        internal class TokenWaitInfo
        {
            public RegisteredWaitHandle Handle = null;
        }

        public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword)
        {
            var context = new ClientContext(web);

            context.ExecutingWebRequest += (sender, e) =>
            {
                string accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password).GetAwaiter().GetResult();
                e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
            };

            return context;
        }


        public async Task<string> EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword)
        {
            string accessTokenFromCache = TokenFromCache(resourceUri, tokenCache);
            if (accessTokenFromCache == null)
            {
                await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                try
                {
                    // No async methods are allowed in a lock section
                    string accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword).ConfigureAwait(false);
                    Console.WriteLine($"Successfully requested new access token resource {resourceUri.DnsSafeHost} for user {userPrincipalName}");
                    AddTokenToCache(resourceUri, tokenCache, accessToken);

                    // Register a thread to invalidate the access token once's it's expired
                    tokenResetEvent = new AutoResetEvent(false);
                    TokenWaitInfo wi = new TokenWaitInfo();
                    wi.Handle = ThreadPool.RegisterWaitForSingleObject(
                        tokenResetEvent,
                        async (state, timedOut) =>
                        {
                            if (!timedOut)
                            {
                                TokenWaitInfo internalWaitToken = (TokenWaitInfo)state;
                                if (internalWaitToken.Handle != null)
                                {
                                    internalWaitToken.Handle.Unregister(null);
                                }
                            }
                            else
                            {
                                try
                                {
                                    // Take a lock to ensure no other threads are updating the SharePoint Access token at this time
                                    await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                    Console.WriteLine($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired");
                                }
                                catch (Exception ex)
                                {
                                    Console.WriteLine($"Something went wrong during cache token invalidation: {ex.Message}");
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                }
                                finally
                                {
                                    semaphoreSlimTokens.Release();
                                }
                            }
                        },
                        wi,
                        (uint)CalculateThreadSleep(accessToken).TotalMilliseconds,
                        true
                    );

                    return accessToken;

                }
                finally
                {
                    semaphoreSlimTokens.Release();
                }
            }
            else
            {
                Console.WriteLine($"Returning token from cache for resource {resourceUri.DnsSafeHost} and user {userPrincipalName}");
                return accessTokenFromCache;
            }
        }

        private async Task<string> AcquireTokenAsync(Uri resourceUri, string username, string password)
        {
            string resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}";

            var clientId = defaultAADAppId;
            var body = $"resource={resource}&client_id={clientId}&grant_type=password&username={HttpUtility.UrlEncode(username)}&password={HttpUtility.UrlEncode(password)}";
            using (var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"))
            {

                var result = await httpClient.PostAsync(tokenEndpoint, stringContent).ContinueWith((response) =>
                {
                    return response.Result.Content.ReadAsStringAsync().Result;
                }).ConfigureAwait(false);

                var tokenResult = JsonSerializer.Deserialize(result);
                var token = tokenResult.GetProperty("access_token").GetString();
                return token;
            }
        }

        private static string TokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string accessToken))
            {
                return accessToken;
            }

            return null;
        }

        private static void AddTokenToCache(Uri web, ConcurrentDictionary<string, string> tokenCache, string newAccessToken)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string currentAccessToken))
            {
                tokenCache.TryUpdate(web.DnsSafeHost, newAccessToken, currentAccessToken);
            }
            else
            {
                tokenCache.TryAdd(web.DnsSafeHost, newAccessToken);
            }
        }

        private static void RemoveTokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            tokenCache.TryRemove(web.DnsSafeHost, out string currentAccessToken);
        }

        private static TimeSpan CalculateThreadSleep(string accessToken)
        {
            var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(accessToken);
            var lease = GetAccessTokenLease(token.ValidTo);
            lease = TimeSpan.FromSeconds(lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds > 0 ? lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds : lease.TotalSeconds);
            return lease;
        }

        private static TimeSpan GetAccessTokenLease(DateTime expiresOn)
        {
            DateTime now = DateTime.UtcNow;
            DateTime expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn);
            TimeSpan lease = expires - now;
            return lease;
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    if (tokenResetEvent != null)
                    {
                        tokenResetEvent.Set();
                        tokenResetEvent.Dispose();
                    }
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}