Normalmente per leggere i file contenuti in una folder SharePoint Online con la libreria PnP.Core in .NET 10 si usa questo codice:

C#: Esempio classico

using var pnpContext = await pnpContextFactory.CreateAsync(new Uri("https://mioSito.sharepoint.com"));

string folderUrl = "/Shared documents/Flow";

// Recupera la cartella
IFolder folder = await pnpContext.Web.GetFolderByServerRelativeUrlAsync(
    folderUrl, 
    p => p.Files.QueryProperties(f => f.ServerRelativeUrl));

// Itera sui file
foreach (IFile file in folder.Files)
{
    Console.WriteLine($"URL File: {file.ServerRelativeUrl}");
}
Il codice funziona perfettamente ma risulta molto inefficiente dal punto di vista del numero di chiamate che deve fare per avere l'elenco dei files.

Questo perchè deve fare 2 chiamate REST, una per recuperare la folder e una per l'elenco dei files.

Entrando nel dettaglio della libreria, si vede che, dietro le quinte, effettua queste 2 chiamate alle API:

Text: Chiamate REST

GET https://mioSito.sharepoint.com/_api/Web/getFolderByServerRelativePath(decodedUrl=@u)?@u='%2FShared documents%2FFlow'&$select=UniqueId%2cFiles%2fServerRelativeUrl%2cFiles%2fUniqueId&$expand=Files

GET https://mioSito.sharepoint.com/_api/Web/getFolderById('52d377af-5bfd-4458-9abf-69df64bc0544')/Files?$top=100
Nel caso di molte chiamate consecutive, si rischia di raggiungere subito il limiti della libreria e ricevere un errore di throttling (HTT 429, limitazione della velocità di trasmissione), oltre ad aumentare i tempi di esecuzione.

Alternativa

Un miglioramento a questo codice C# consiste fare una sola chiamata REST alle API, dimezzando quindi le chiamate necessarie.

Si può raggiungere lo scopo componendo manualmente la URL della chiamata REST alle API:

Text

GET https://mioSito.sharepoint.com/_api/Web/getFolderByServerRelativePath(decodedUrl='%2FShared documents%2FFlow')/Files?$select=ServerRelativeUrl
A questo punto il codice C# diventa:

C#

using var pnpContext = await pnpContextFactory.CreateAsync(new Uri("https://mioSito.sharepoint.com"));

string folderUrl = "/Shared documents/Flow";

// Costruisco l'endpoint con l'encoding della url
string restUrl = $"_api/Web/getFolderByServerRelativePath(decodedUrl='{Uri.EscapeDataString(folderUrl)}')/Files?$select=ServerRelativeUrl";

// Rendo la risposta la più breve possibile usando nometadata
Dictionary<string, string> headers = new() { { "Accept", "application/json;odata=nometadata" } };

// creo la richiesta
var apiRequest = new ApiRequest(HttpMethod.Get, ApiRequestType.SPORest, restUrl, string.Empty, headers);

// Invia l'unica chiamata HTTP sfruttando il contesto PnP
ApiRequestResponse response = await pnpContext.Web.ExecuteRequestAsync(apiRequest);

IEnumerable<FileUrl> files = JsonHelper.DeserializeSPResponseValue<FileUrl>(response.Response);
foreach (var file in files)
{
    Console.WriteLine($"URL File: {file.ServerRelativeUrl}");
}

Classi di supporto

Per semplificare la gestione della risposta ho creato dei DTO di tipo record

C#: SPResponseValue.cs

internal record SPResponseValue<T>
{
    [JsonPropertyName("value")]
    public IEnumerable<T>? Value { get; set; }
}
e

C#: FileUrl.cs

internal record FileUrl
{
    public required string ServerRelativeUrl { get; set; }
}
Oltre ad una classe helper statica per la deserializazione delle risposta JSON

C#: JsonHelper.cs

internal static class JsonHelper
{
    public static IEnumerable<T> DeserializeSPResponseValue<T>(string json)
    {
        var response = System.Text.Json.JsonSerializer.Deserialize<SPResponseValue<T>>(json);
        return response?.Value ?? [];
    }
}
L'inizializzazione della console application:

C#: Program.cs

#  <PackageReference Include="PnP.Core.Auth" Version="1.18.0" />

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        // Registra l'handler per stampare le chiamate HTTP
        services.AddTransient<PnPLoggingHandler>();

        var pnpCoreSection = context.Configuration.GetSection("PnPCore");
        var appOptions = context.Configuration.GetSection(AppOptions.SECTION_NAME).Get<AppOptions>();

        services.Configure<PnPCoreOptions>(pnpCoreSection);

        services.AddPnPCore(
            options => { },
            httpClientBuilder =>
            {
                httpClientBuilder.AddHttpMessageHandler<PnPLoggingHandler>();
            }
        );

        services.AddPnPCoreAuthentication(options =>
        {
            // Load the certificate that will be used to authenticate
            var certificate = X509CertificateLoader.LoadPkcs12FromFile(appOptions.SharePoint.CertificatePath, appOptions.SharePoint.CertificatePassword);

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

            // Configure the default authentication provider
            options.Credentials.DefaultConfiguration = "CertAuth";
        });

        services.AddHttpClient("SharePointRestClient")
        .AddHttpMessageHandler<PnPLoggingHandler>();

        services.AddHttpClient("MicrosoftGraphClient")
                .AddHttpMessageHandler<PnPLoggingHandler>();
    })
    .Build();

infine l'handler per intercettare e stampare le chiamate HTTP:

C#: PnPLoggingHandler .cs

public class PnPLoggingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine($"PNPDEBUG: \n[→ HTTP REQ] {request.Method} {request.RequestUri}");

        if (request.Content != null)
        {
            var body = await request.Content.ReadAsStringAsync(cancellationToken);
            Console.WriteLine($"PNPDEBUG: [Payload] {body}");
        }

        var stopwatch = Stopwatch.StartNew();
        var response = await base.SendAsync(request, cancellationToken);
        stopwatch.Stop();

        Console.WriteLine($"PNPDEBUG: [← HTTP RES] {(int)response.StatusCode} {response.ReasonPhrase} ({stopwatch.ElapsedMilliseconds}ms)");

        return response;
    }
}

Morale

Lavorare con SharePoint Online è molto diverso in termini di prestazioni rispetto a lavorare con uno SharePoint 2016 Onprem.
Onprem si usava il modello a oggetti di SharePoint perchè tipicamente si lavorava sulle macchine della farm, quindi tutte operazioni in memoria e/o su SQL Server.
Online si deve tenere conto del tempo necessario a costruire la chiamata https verso SharePoint Online, inviare le richiesta e attendere la risposta.
Pur essendo tempi che sembrano trascurabili, sono N volte più lenti dell'approccio onprem.

Risparmiare anche una sola chiamata, su centinaia o migliaia di richieste può fare le differenza, in base alla situazione, di secondi / minuti / ore. Soprattutto se si incorre anche nel throttling.

Questo vuol dire che con SharePoint Online, deve esserci sempre una maggiore attenzione alle prestazione e all'ottimizzazione del codice/chiamate.
Tags:
SharePoint Online87 SharePoint508 C#245
Potrebbe interessarti anche: