Funzioni di entità

Le funzioni di entità definiscono le operazioni per la lettura e l'aggiornamento di piccole parti di stato, note come entità durevoli. In modo analogo alle funzioni dell'agente di orchestrazione, le funzioni di entità sono funzioni con un tipo di trigger speciale, ovvero il trigger di entità. A differenza delle funzioni di orchestrazione, le funzioni di entità gestiscono lo stato in modo esplicito anziché in modo implicito, rappresentandolo tramite flusso di controllo. Le entità consentono di aumentare le istanze delle applicazioni distribuendo il lavoro tra molte entità, ognuna con uno stato di dimensioni moderate.

Nota

Le funzioni di entità e le funzionalità correlate sono disponibili solo in Durable Functions 2.0 e versioni successive. Sono attualmente supportati in .NET in-proc, in .NET isolated worker, JavaScript e Python, ma non in PowerShell o Java.

Importante

Le funzioni di entità non sono attualmente supportate in PowerShell e Java.

Concetti generali

Il comportamento delle entità è simile a quello di servizi minuscoli che comunicano tramite messaggi. Ogni entità ha un'identità univoca e uno stato interno (se esistente). Analogamente ai servizi o agli oggetti, le entità eseguono operazioni quando viene richiesto. Quando un'operazione viene eseguita, potrebbe aggiornare lo stato interno dell'entità, nonché chiamare servizi esterni e attendere la risposta. Le entità comunicano con altre entità, orchestrazioni e client usando messaggi che vengono inviati implicitamente tramite code affidabili.

Per evitare conflitti, tutte le operazioni in una singola entità vengono eseguite in seriale, ossia una dopo l'altra.

Nota

Quando un'entità viene richiamata, elabora il payload fino al completamento e quindi pianifica una nuova esecuzione per l'attivazione dopo l'arrivo degli input futuri. Di conseguenza, i log di esecuzione dell'entità potrebbero mostrare un'esecuzione aggiuntiva dopo ogni chiamata di entità; questo è previsto.

ID entità

Le entità sono accessibili tramite un identificatore univoco, l'ID entità. Un ID entità è semplicemente una coppia di stringhe che identifica in modo univoco un'istanza di entità. È costituita dagli elementi seguenti:

  • Nome entità, ovvero un nome che identifica il tipo dell'entità. Un esempio è "Counter". Questo nome deve corrispondere al nome della funzione di entità che implementa l'entità. Non prevede la distinzione tra maiuscole e minuscole.
  • Chiave di entità, ovvero una stringa che identifica in modo univoco l'entità tra tutte le altre con lo stesso nome. Un esempio è un identificatore GUID.

È possibile usare ad esempio una funzione di entità Counter per tenere il punteggio in un gioco online. Ogni istanza del gioco ha un ID entità univoco, ad esempio @Counter@Game1 e @Counter@Game2. Per tutte le operazioni destinate a una determinata entità è necessario specificare un ID entità come parametro.

Operazioni delle entità

Per richiamare un'operazione su un'entità, specificare gli elementi seguenti:

  • ID entità dell'entità di destinazione.
  • Nome dell'operazione, ovvero una stringa che specifica l'operazione da eseguire. L'entità Counter, ad esempio, potrebbe supportare le operazioni add, get o reset.
  • Input dell'operazione, ovvero un parametro di input facoltativo per l'operazione. L'operazione di addizione, ad esempio, può accettare come input un valore intero.
  • Ora pianificata, un parametro facoltativo che consente di specificare l'ora di recapito dell'operazione. È ad esempio possibile pianificare con la massima affidabilità un'operazione in modo che venga eseguita diversi giorni dopo.

Le operazioni possono restituire un valore di risultato oppure un risultato di errore, ad esempio un errore JavaScript o un'eccezione .NET. Questo risultato o errore si verifica nelle orchestrazioni che hanno chiamato l'operazione.

Un'operazione di entità può anche creare, leggere, aggiornare ed eliminare lo stato dell'entità. Lo stato dell'entità è sempre persistente nell'archiviazione.

Definire le entità

Le entità vengono definite usando una sintassi basata su funzioni, in cui le entità vengono rappresentate come funzioni e operazioni vengono inviate in modo esplicito dall'applicazione.

Attualmente sono disponibili due API distinte per la definizione di entità in .NET:

Quando si usa una sintassi basata su funzioni, le entità vengono rappresentate come funzioni e operazioni vengono inviate in modo esplicito dall'applicazione. Questa sintassi è valida per le entità con uno stato semplice, poche operazioni o un set dinamico di operazioni, ad esempio i framework di applicazioni. Questa sintassi può essere noiosa da gestire perché non rileva gli errori di tipo in fase di compilazione.

Le API specifiche dipendono dal fatto che le funzioni C# vengano eseguite in un processo di lavoro isolato (scelta consigliata) o nello stesso processo dell'host.

Il codice seguente è un esempio di una semplice entità Counter implementata come funzione durevole. Tale funzione definisce tre operazioni, ovvero add, reset e get, ognuna delle quali opera con uno stato intero.

[FunctionName("Counter")]
public static void Counter([EntityTrigger] IDurableEntityContext ctx)
{
    switch (ctx.OperationName.ToLowerInvariant())
    {
        case "add":
            ctx.SetState(ctx.GetState<int>() + ctx.GetInput<int>());
            break;
        case "reset":
            ctx.SetState(0);
            break;
        case "get":
            ctx.Return(ctx.GetState<int>());
            break;
    }
}

Per altre informazioni sulla sintassi basata su funzione e su come usarla, vedere Sintassi basata su funzione.

Le entità durevoli sono disponibili in JavaScript a partire dalla versione 1.3.0 del pacchetto npm durable-functions. Il codice seguente è l'entità Counter implementata come funzione durevole scritta in JavaScript.

Counter/function.json

{
  "bindings": [
    {
      "name": "context",
      "type": "entityTrigger",
      "direction": "in"
    }
  ],
  "disabled": false
}

Counter/index.js

const df = require("durable-functions");

module.exports = df.entity(function(context) {
    const currentValue = context.df.getState(() => 0);
    switch (context.df.operationName) {
        case "add":
            const amount = context.df.getInput();
            context.df.setState(currentValue + amount);
            break;
        case "reset":
            context.df.setState(0);
            break;
        case "get":
            context.df.return(currentValue);
            break;
    }
});

Nota

Per altre informazioni sul funzionamento del modello V2, vedere la guida per sviluppatori Python Funzioni di Azure.

Il codice seguente è l'entità Counter implementata come funzione durevole scritta in Python.

import azure.functions as func
import azure.durable_functions as df

# Entity function called counter
@myApp.entity_trigger(context_name="context")
def Counter(context):
    current_value = context.get_state(lambda: 0)
    operation = context.operation_name
    if operation == "add":
        amount = context.get_input()
        current_value += amount
    elif operation == "reset":
        current_value = 0
    elif operation == "get":
        context.set_result(current_value)
    context.set_state(current_value)

Accedere alle entità

Per accedere alle entità, è possibile usare la comunicazione unidirezionale o bidirezionale. La terminologia seguente distingue le due forme di comunicazione:

  • La chiamata a un'entità usa una comunicazione bidirezionale (round trip). Viene inviato un messaggio dell'operazione all'entità e quindi si attende il messaggio di risposta prima di continuare. Il messaggio di risposta può restituire un valore di risultato o un risultato di errore, ad esempio un errore JavaScript o un'eccezione .NET. Questo risultato o errore viene quindi osservato dal chiamante.
  • La segnalazione di un'entità usa una comunicazione unidirezionale (Fire and Forget). Viene inviato un messaggio dell'operazione, ma non si attende una risposta. Anche se il recapito del messaggio è garantito, il mittente non sa quando e non può osservare i valori dei risultati o gli errori.

È possibile accedere alle entità all'interno di funzioni client, funzioni di orchestrazione o funzioni di entità. Non tutte le forme di comunicazione sono supportate da tutti i contesti:

  • Nei client è possibile segnalare le entità e leggerne lo stato.
  • Nelle orchestrazioni è possibile segnalare le entità e chiamarle.
  • Nelle entità è possibile segnalare le entità.

Gli esempi seguenti illustrano le diverse modalità di accesso alle entità.

Esempio: il client segnala un'entità

Per accedere alle entità da una funzione di Azure ordinaria, detta anche funzione client, usare l'associazione client di entità. L'esempio seguente illustra una funzione attivata da una coda che segnala un'entità usando questa associazione.

Nota

Per semplicità, negli esempi seguenti viene illustrata la sintassi a tipizzazione debole per l'accesso alle entità. In generale, è consigliabile accedere alle entità tramite interfacce per un maggiore controllo del tipo.

[FunctionName("AddFromQueue")]
public static Task Run(
    [QueueTrigger("durable-function-trigger")] string input,
    [DurableClient] IDurableEntityClient client)
{
    // Entity operation input comes from the queue message content.
    var entityId = new EntityId(nameof(Counter), "myCounter");
    int amount = int.Parse(input);
    return client.SignalEntityAsync(entityId, "Add", amount);
}
const df = require("durable-functions");

module.exports = async function (context) {
    const client = df.getClient(context);
    const entityId = new df.EntityId("Counter", "myCounter");
    await client.signalEntity(entityId, "add", 1);
};
import azure.functions as func
import azure.durable_functions as df

# An HTTP-Triggered Function with a Durable Functions Client to set a value on a durable entity
@myApp.route(route="entitysetvalue")
@myApp.durable_client_input(client_name="client")
async def http_set(req: func.HttpRequest, client):
    logging.info('Python HTTP trigger function processing a request.')
    entityId = df.EntityId("Counter", "myCounter")
    await client.signal_entity(entityId, "add", 1)
    return func.HttpResponse("Done", status_code=200)

Il termine segnalazione indica che la chiamata dell'API di entità è unidirezionale e asincrona. Una funzione client non è in grado di riconoscere il momento in cui l'entità ha elaborato l'operazione. Inoltre, la funzione client non può osservare valori di risultati o eccezioni.

Esempio: il client legge uno stato dell'entità

Le funzioni client possono anche eseguire query sullo stato delle entità, come illustrato nell'esempio seguente:

[FunctionName("QueryCounter")]
public static async Task<HttpResponseMessage> Run(
    [HttpTrigger(AuthorizationLevel.Function)] HttpRequestMessage req,
    [DurableClient] IDurableEntityClient client)
{
    var entityId = new EntityId(nameof(Counter), "myCounter");
    EntityStateResponse<JObject> stateResponse = await client.ReadEntityStateAsync<JObject>(entityId);
    return req.CreateResponse(HttpStatusCode.OK, stateResponse.EntityState);
}
const df = require("durable-functions");

module.exports = async function (context) {
    const client = df.getClient(context);
    const entityId = new df.EntityId("Counter", "myCounter");
    const stateResponse = await client.readEntityState(entityId);
    return stateResponse.entityState;
};
# An HTTP-Triggered Function with a Durable Functions Client to retrieve the state of a durable entity
@myApp.route(route="entityreadvalue")
@myApp.durable_client_input(client_name="client")
async def http_read(req: func.HttpRequest, client):
    entityId = df.EntityId("Counter", "myCounter")
    entity_state_result = await client.read_entity_state(entityId)
    entity_state = "No state found"
    if entity_state_result.entity_exists:
      entity_state = str(entity_state_result.entity_state)
    return func.HttpResponse(entity_state)

Le query sullo stato dell'entità vengono inviate all'archivio di rilevamento durevole e restituiscono lo stato persistente più recente dell'entità. Lo stato è sempre sottoposto a "commit", ossia non è mai uno stato intermedio temporaneo presupposto durante l'esecuzione di un'operazione. È tuttavia possibile che questo stato sia obsoleto rispetto allo stato in memoria dell'entità. Solo le orchestrazioni possono leggere lo stato in memoria di un'entità, come descritto nella sezione seguente.

Esempio: segnali di orchestrazione e chiama un'entità

Le funzioni dell'agente di orchestrazione possono accedere alle entità mediante API nel binding del trigger di orchestrazione. L'esempio di codice seguente illustra una funzione dell'agente di orchestrazione che chiama e segnala un'entità Counter.

[FunctionName("CounterOrchestration")]
public static async Task Run(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var entityId = new EntityId(nameof(Counter), "myCounter");

    // Two-way call to the entity which returns a value - awaits the response
    int currentValue = await context.CallEntityAsync<int>(entityId, "Get");
    if (currentValue < 10)
    {
        // One-way signal to the entity which updates the value - does not await a response
        context.SignalEntity(entityId, "Add", 1);
    }
}
const df = require("durable-functions");

module.exports = df.orchestrator(function*(context){
    const entityId = new df.EntityId("Counter", "myCounter");

    // Two-way call to the entity which returns a value - awaits the response
    currentValue = yield context.df.callEntity(entityId, "get");
});

Nota

JavaScript attualmente non supporta la segnalazione di un'entità da un agente di orchestrazione. Utilizzare invece callEntity.

@myApp.orchestration_trigger(context_name="context")
def orchestrator(context: df.DurableOrchestrationContext):
    entityId = df.EntityId("Counter", "myCounter")
    context.signal_entity(entityId, "add", 3)
    logging.info("signaled entity")
    state = yield context.call_entity(entityId, "get")
    return state

Solo le orchestrazioni sono in grado di chiamare entità e ottenere una risposta, che può essere un valore restituito o un'eccezione. Le funzioni client che usano il binding client possono solo segnalare entità.

Nota

La chiamata a un'entità da una funzione dell'agente di orchestrazione è simile alla chiamata a una funzione di attività da una funzione dell'agente di orchestrazione. La differenza principale consiste nel fatto che le funzioni di entità sono oggetti durevoli con un indirizzo, ovvero l'ID entità. Le funzioni di entità supportano la specifica del nome di un'operazione. Le funzioni di attività sono invece senza stato e sono prive del concetto di operazioni.

Esempio: l'entità segnala un'entità

Una funzione di entità può inviare segnali ad altre entità (o anche a se stessa) durante l'esecuzione di un'operazione. È ad esempio possibile modificare l'esempio di entità Counter precedente in modo da inviare un segnale "milestone-reached" a un'entità di monitoraggio quando il contatore raggiunge il valore 100.

   case "add":
        var currentValue = ctx.GetState<int>();
        var amount = ctx.GetInput<int>();
        if (currentValue < 100 && currentValue + amount >= 100)
        {
            ctx.SignalEntity(new EntityId("MonitorEntity", ""), "milestone-reached", ctx.EntityKey);
        }

        ctx.SetState(currentValue + amount);
        break;
    case "add":
        const amount = context.df.getInput();
        if (currentValue < 100 && currentValue + amount >= 100) {
            const entityId = new df.EntityId("MonitorEntity", "");
            context.df.signalEntity(entityId, "milestone-reached", context.df.instanceId);
        }
        context.df.setState(currentValue + amount);
        break;

Nota

Python non supporta ancora i segnali da entità a entità. Usare invece un agente di orchestrazione per segnalare le entità.

Coordinamento delle entità

In alcuni casi può essere necessario coordinare le operazioni tra più entità. In un'applicazione bancaria, ad esempio, potrebbero essere presenti entità che rappresentano singoli conti bancari. Quando si trasferiscono i fondi da un conto a un altro, è necessario verificare che il conto di origine disponga di fondi sufficienti. È anche necessario verificare che gli aggiornamenti per i conti di origine e di destinazione vengano eseguiti in modo coerente a livello di transazione.

Esempio: Trasferire fondi

Il codice di esempio seguente trasferisce i fondi tra due entità account usando una funzione dell'agente di orchestrazione. Per il coordinamento degli aggiornamenti delle entità è necessario usare il metodo LockAsync per creare una sezione critica nell'orchestrazione.

Nota

Per semplicità, in questo esempio viene riutilizzata l'entità Counter definita in precedenza. In un'applicazione reale, è preferibile definire un'entità BankAccount più dettagliata.

// This is a method called by an orchestrator function
public static async Task<bool> TransferFundsAsync(
    string sourceId,
    string destinationId,
    int transferAmount,
    IDurableOrchestrationContext context)
{
    var sourceEntity = new EntityId(nameof(Counter), sourceId);
    var destinationEntity = new EntityId(nameof(Counter), destinationId);

    // Create a critical section to avoid race conditions.
    // No operations can be performed on either the source or
    // destination accounts until the locks are released.
    using (await context.LockAsync(sourceEntity, destinationEntity))
    {
        ICounter sourceProxy = 
            context.CreateEntityProxy<ICounter>(sourceEntity);
        ICounter destinationProxy =
            context.CreateEntityProxy<ICounter>(destinationEntity);

        int sourceBalance = await sourceProxy.Get();

        if (sourceBalance >= transferAmount)
        {
            await sourceProxy.Add(-transferAmount);
            await destinationProxy.Add(transferAmount);

            // the transfer succeeded
            return true;
        }
        else
        {
            // the transfer failed due to insufficient funds
            return false;
        }
    }
}

In .NET LockAsync restituisce un oggetto IDisposable che termina la sezione critica quando viene eliminato. Questo risultato IDisposable può essere usato insieme a un blocco using per ottenere una rappresentazione sintattica della sezione critica.

Nell'esempio precedente, una funzione dell'agente di orchestrazione trasferisce fondi da un'entità di origine a un'entità di destinazione. Il metodo LockAsync ha bloccato le entità account sia di origine sia di destinazione. Questo blocco ha assicurato che nessun altro client potesse eseguire query o modificare lo stato dei due conti fino a quando la logica di orchestrazione non fosse uscita dalla sezione critica alla fine dell'istruzione using. Questo comportamento impedisce la possibilità di sovrascrivere dal conto di origine.

Nota

Quando un'orchestrazione termina, normalmente o con un errore, le eventuali sezioni critiche in corso vengono terminate implicitamente e tutti i blocchi vengono rilasciati.

Comportamento della sezione critica

Il metodo LockAsync crea una sezione critica in un'orchestrazione. Tali sezioni critiche impediscono ad altre orchestrazioni di apportare modifiche in sovrapposizione a un set di entità specificato. Internamente, l'API LockAsync invia operazioni di "blocco" alle entità e restituisce un risultato quando riceve un messaggio di risposta di "blocco acquisito" da ognuna di queste stesse entità. Le operazioni di blocco e sblocco sono operazioni predefinite supportate da tutte le entità.

Su un'entità in stato di blocco non sono consentite operazioni da parte di altri client. Questo comportamento garantisce che solo un'istanza di orchestrazione possa bloccare un'entità alla volta. Se un chiamante tenta di richiamare un'operazione su un'entità mentre è bloccata da un'orchestrazione, tale operazione viene inserita in una coda di operazioni in sospeso. Nessuna operazione in sospeso viene elaborata finché l'orchestrazione che ha determinato il blocco non lo rilascia.

Nota

Questo comportamento è leggermente diverso dalle primitive di sincronizzazione usate nella maggior parte dei linguaggi di programmazione, ad esempio l'istruzione lock in C#. In C# l'istruzione lock deve essere usata, ad esempio, da tutti i thread per garantire la corretta sincronizzazione tra più thread. Nel caso delle entità, tuttavia, non è necessario che tutti i chiamanti blocchino in modo esplicito un'entità. Se un chiamante blocca un'entità, tutte le altre operazioni sull'entità vengono bloccate e accodate dietro il blocco.

I blocchi sulle entità sono durevoli, quindi vengono mantenuti anche se il processo in esecuzione viene riciclato. I blocchi vengono mantenuti internamente come parte dello stato durevole di un'entità.

A differenza delle transazioni, le sezioni critiche non esegue automaticamente il rollback delle modifiche quando si verificano errori. Al contrario, qualsiasi gestione degli errori, ad esempio il rollback o la ripetizione dei tentativi, deve essere codificata in modo esplicito, ad esempio rilevando errori oppure eccezioni. Questa scelta di progettazione è intenzionale. Il rollback automatico di tutti gli effetti di un'orchestrazione è in generale difficile o impossibile da implementare, perché le orchestrazioni potrebbero eseguire attività ed effettuare chiamate a servizi esterni che non possono essere annullate. Gli stessi tentativi di rollback possono anche non riuscire e richiedere un'ulteriore gestione degli errori.

Regole per le sezioni critiche

A differenza delle primitive di blocco di basso livello della maggior parte dei linguaggi di programmazione, le sezioni critiche sono garantite contro i deadlock. Per evitare deadlock vengono applicate le restrizioni seguenti:

  • Le sezioni critiche non possono essere annidate.
  • Le sezioni critiche non possono creare orchestrazioni secondarie.
  • Le sezioni critiche possono chiamare solo le entità che hanno bloccato.
  • Le sezioni critiche non possono chiamare la stessa entità usando più chiamate parallele.
  • Le sezioni critiche possono segnalare solo le entità che non hanno bloccato.

Qualsiasi violazione di queste regole genera un errore di runtime, ad esempio LockingRulesViolationException in .NET, che include un messaggio con l'indicazione della regola violata.

Confronto con gli attori virtuali

Molte funzionalità delle entità durevoli sono ispirate al modello di attore. Se si ha già familiarità con gli attori, è possibile riconoscere molti concetti descritti in questo articolo. Le entità durevoli sono simili a attori virtuali, o grani, come diffuso dal progetto Orleans. Ad esempio:

  • Le entità durevoli sono indirizzabili tramite un ID entità.
  • Le operazioni di entità durevoli vengono eseguite in modo seriale, una alla volta, per evitare race condition.
  • Le entità durevoli vengono create implicitamente quando vengono chiamate o segnalate.
  • Le entità durevoli vengono scaricate automaticamente dalla memoria quando non eseguono operazioni.

Esistono alcune importanti differenze da tenere in considerazione:

  • Le entità durevoli favoriscono la durabilità rispetto alla latenza e quindi potrebbero non essere appropriate per le applicazioni con requisiti di latenza rigidi.
  • Le entità durevoli non includono timeout predefiniti per i messaggi. In Orleans tutti i messaggi raggiungono il timeout dopo un tempo configurabile. Il valore predefinito è 30 secondi.
  • I messaggi inviati tra entità vengono recapitati in modo affidabile e nell'ordine corretto. In Orleans il recapito affidabile o ordinato è supportato per il contenuto inviato tramite flussi, ma non è garantito per tutti i messaggi tra granelli.
  • I modelli di richiesta/risposta nelle entità sono limitati alle orchestrazioni. All'interno delle entità sono consentiti solo messaggi unidirezionali ("segnalazione") come nel modello di attore originale, a differenza dei granelli di Orleans.
  • Per le entità durevoli non si verifica alcun deadlock. In Orleans si possono verificare deadlock, che si risolvono solo dopo il timeout dei messaggi.
  • Le entità durevoli possono essere usate con orchestrazioni durevoli e supportano meccanismi di blocco distribuiti.

Passaggi successivi