Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
Orleans supporta transazioni ACID distribuite rispetto allo stato granulare persistente. Le transazioni vengono implementate tramite il pacchetto NuGet delle Microsoft.Orleans.Transactions. Il codice sorgente per l'app di esempio in questo articolo è costituito da quattro progetti:
- Astrazioni: libreria di classi contenente le interfacce grain e le classi condivise.
- Grani: una libreria di classi che contiene le implementazioni dei grani.
- Server: un'applicazione console che utilizza le astrazioni e le librerie di classi grains e funge da Orleans silo.
- Client: un'applicazione console che utilizza la libreria di classi di astrazioni che rappresenta il Orleans client.
Configurazione
Orleans le transazioni sono facoltative. Sia il silo che il client devono essere configurati per l'uso delle transazioni. Se non sono configurati, le chiamate ai metodi transazionali su un'implementazione di grain ricevono un OrleansTransactionsDisabledException. Per abilitare le transazioni in un silo, chiamare SiloBuilderExtensions.UseTransactions sul generatore di host silo:
var builder = Host.CreateDefaultBuilder(args)
.UseOrleans((context, siloBuilder) =>
{
siloBuilder.UseTransactions();
});
Analogamente, per abilitare le transazioni nel client, chiamare ClientBuilderExtensions.UseTransactions sul builder dell'host client.
var builder = Host.CreateDefaultBuilder(args)
.UseOrleansClient((context, clientBuilder) =>
{
clientBuilder.UseTransactions();
});
Archiviazione dello stato transazionale
Per usare le transazioni, è necessario configurare un archivio dati. Per supportare vari archivi dati con transazioni, Orleans usa l'astrazione di archiviazione ITransactionalStateStorage<TState>. Questa astrazione è specifica per le esigenze delle transazioni, a differenza dell'archiviazione granulare generica (IGrainStorage). Per usare l'archiviazione specifica della transazione, configurare il silo usando qualsiasi implementazione di ITransactionalStateStorage, ad esempio Azure (AddAzureTableTransactionalStateStorage).
Si consideri ad esempio la configurazione del generatore host seguente:
using Azure.Data.Tables;
await Host.CreateDefaultBuilder(args)
.UseOrleans((_, silo) =>
{
silo.UseLocalhostClustering();
if (Environment.GetEnvironmentVariable(
"ORLEANS_STORAGE_CONNECTION_STRING") is { } connectionString)
{
silo.AddAzureTableTransactionalStateStorage(
"TransactionStore",
options => options.TableServiceClient = new TableServiceClient(connectionString));
}
else
{
silo.AddMemoryGrainStorageAsDefault();
}
silo.UseTransactions();
})
.RunConsoleAsync();
Ai fini dello sviluppo, se l'archiviazione specifica della transazione non è disponibile per l'archivio dati necessario, è possibile usare invece un'implementazione IGrainStorage . Per qualsiasi stato transazionale senza un archivio configurato, le transazioni tentano di eseguire il failover nello spazio di archiviazione granulare usando un bridge. L'accesso allo stato transazionale tramite un bridge per l'archiviazione granulare è meno efficiente e potrebbe non essere supportato in futuro. Pertanto, è consigliabile usare questo approccio solo a scopo di sviluppo.
Interfacce dei cereali
Per consentire a un grain di supportare le transazioni, è necessario contrassegnare i metodi transazionali sulla sua interfaccia di granularità come parte di una transazione utilizzando TransactionAttribute. L'attributo deve indicare il comportamento della chiamata granulare in un ambiente transazionale, come descritto nei valori seguenti TransactionOption :
- TransactionOption.Create: la chiamata è transazionale e creerà sempre un nuovo contesto di transazione (avvia una nuova transazione), anche se viene chiamato all'interno di un contesto di transazione esistente.
- TransactionOption.Join: la chiamata è transazionale, ma può essere chiamata solo all'interno del contesto di una transazione esistente.
- TransactionOption.CreateOrJoin: La chiamata è transazionale. Se viene chiamato all'interno del contesto di una transazione, userà tale contesto, altrimenti creerà un nuovo contesto.
- TransactionOption.Suppress: la chiamata non è transazionale, ma può essere chiamata dall'interno di una transazione. Se viene chiamato all'interno del contesto di una transazione, il contesto non sarà incluso nella chiamata.
- TransactionOption.Supported: la chiamata non è transazionale, ma supporta le transazioni. Se viene chiamato all'interno di una transazione, il contesto verrà trasmesso alla chiamata.
- TransactionOption.NotAllowed: la chiamata non è transazionale e non può essere chiamata dall'interno di una transazione. Se viene chiamato all'interno del contesto di una transazione, genererà il NotSupportedException.
È possibile contrassegnare le chiamate come TransactionOption.Create, ovvero la chiamata avvia sempre la relativa transazione. Ad esempio, l'operazione Transfer nel granulare ATM seguente avvia sempre una nuova transazione che coinvolge i due conti a cui si fa riferimento.
namespace TransactionalExample.Abstractions;
public interface IAtmGrain : IGrainWithIntegerKey
{
[Transaction(TransactionOption.Create)]
Task Transfer(string fromId, string toId, decimal amountToTransfer);
}
Le operazioni transazionali Withdraw e Deposit sul grano dell'account sono contrassegnate TransactionOption.Join. Ciò indica che possono essere chiamati solo all'interno del contesto di una transazione esistente, che sarebbe il caso se chiamato durante IAtmGrain.Transfer. La chiamata GetBalance è contrassegnata come CreateOrJoin, quindi puoi chiamarla dall'interno di una transazione esistente (ad esempio tramite IAtmGrain.Transfer) oppure da sola.
namespace TransactionalExample.Abstractions;
public interface IAccountGrain : IGrainWithStringKey
{
[Transaction(TransactionOption.Join)]
Task Withdraw(decimal amount);
[Transaction(TransactionOption.Join)]
Task Deposit(decimal amount);
[Transaction(TransactionOption.CreateOrJoin)]
Task<decimal> GetBalance();
}
Considerazioni importanti
Non è possibile contrassegnare OnActivateAsync come transazionale perché una chiamata di questo tipo richiede una configurazione corretta prima della chiamata. Esiste solo per l'API dell'applicazione granulare. Ciò significa che il tentativo di leggere lo stato transazionale come parte di questi metodi genera un'eccezione nel runtime.
Implementazioni granulari
Un'implementazione granulare deve usare un ITransactionalState<TState> facet per gestire lo stato di granularità tramite transazioni ACID.
public interface ITransactionalState<TState>
where TState : class, new()
{
Task<TResult> PerformRead<TResult>(
Func<TState, TResult> readFunction);
Task<TResult> PerformUpdate<TResult>(
Func<TState, TResult> updateFunction);
}
Esegui tutti gli accessi di lettura o scrittura allo stato persistente tramite funzioni sincrone che vengono passate all'aspetto dello stato transazionale. In questo modo il sistema delle transazioni può eseguire o annullare queste operazioni in modo transazionale. Per usare lo stato transazionale all'interno di un grain, definire una classe di stato serializzabile da rendere persistente e dichiarare lo stato transazionale nel costruttore del grain utilizzando TransactionalStateAttribute. Questo attributo dichiara il nome dello stato e, facoltativamente, l'archiviazione dello stato transazionale da usare. Per ulteriori informazioni, vedere Setup.
[AttributeUsage(AttributeTargets.Parameter)]
public class TransactionalStateAttribute : Attribute
{
public TransactionalStateAttribute(string stateName, string storageName = null)
{
// ...
}
}
Ad esempio, l'oggetto Balance di stato viene definito come segue:
namespace TransactionalExample.Abstractions;
[GenerateSerializer]
public record class Balance
{
[Id(0)]
public decimal Value { get; set; } = 1_000;
}
L'oggetto di stato precedente:
- Viene decorato con il GenerateSerializerAttribute per istruire il generatore di codice Orleans a generare un serializzatore.
- Dispone di una
Valueproprietà decorata con IdAttribute per identificare in modo univoco il membro.
L'oggetto Balance stato viene quindi usato nell'implementazione AccountGrain come segue:
namespace TransactionalExample.Grains;
[Reentrant]
public class AccountGrain : Grain, IAccountGrain
{
private readonly ITransactionalState<Balance> _balance;
public AccountGrain(
[TransactionalState(nameof(balance))]
ITransactionalState<Balance> balance) =>
_balance = balance ?? throw new ArgumentNullException(nameof(balance));
public Task Deposit(decimal amount) =>
_balance.PerformUpdate(
balance => balance.Value += amount);
public Task Withdraw(decimal amount) =>
_balance.PerformUpdate(balance =>
{
if (balance.Value < amount)
{
throw new InvalidOperationException(
$"Withdrawing {amount} credits from account " +
$"\"{this.GetPrimaryKeyString()}\" would overdraw it." +
$" This account has {balance.Value} credits.");
}
balance.Value -= amount;
});
public Task<decimal> GetBalance() =>
_balance.PerformRead(balance => balance.Value);
}
Importante
Una granularità di transazione deve essere contrassegnata con ReentrantAttribute per garantire che il contesto della transazione venga passato correttamente alla chiamata del granulo.
Nell'esempio precedente, dichiara TransactionalStateAttribute che il parametro del balance costruttore deve essere associato a uno stato transazionale denominato "balance". Con questa dichiarazione, Orleans inserisce un'istanza ITransactionalState<TState> con stato caricato dalla risorsa di archiviazione dello stato transazionale denominata "TransactionStore". È possibile modificare lo stato tramite PerformUpdate o leggerlo tramite PerformRead. L'infrastruttura delle transazioni garantisce che tutte le modifiche eseguite come parte di una transazione (anche tra più grani distribuiti in un Orleans cluster) vengano sottoposte a commit o tutte annullate al completamento della chiamata granulare che ha creato la transazione (IAtmGrain.Transfer nell'esempio precedente).
Chiamare i metodi di transazione da un client
Il modo consigliato per chiamare un metodo granulare transazionale consiste nell'usare .ITransactionClient
Orleans registra ITransactionClient automaticamente con il provider di servizi di inserimento delle dipendenze quando si configura il Orleans client. Usare ITransactionClient per creare un contesto di transazione e chiamare metodi di granularità transazionale all'interno di tale contesto. L'esempio seguente mostra come usare ITransactionClient per chiamare metodi granuli transazionali.
using IHost host = Host.CreateDefaultBuilder(args)
.UseOrleansClient((_, client) =>
{
client.UseLocalhostClustering()
.UseTransactions();
})
.Build();
await host.StartAsync();
var client = host.Services.GetRequiredService<IClusterClient>();
var transactionClient= host.Services.GetRequiredService<ITransactionClient>();
var accountNames = new[] { "Xaawo", "Pasqualino", "Derick", "Ida", "Stacy", "Xiao" };
var random = Random.Shared;
while (!Console.KeyAvailable)
{
// Choose some random accounts to exchange money
var fromIndex = random.Next(accountNames.Length);
var toIndex = random.Next(accountNames.Length);
while (toIndex == fromIndex)
{
// Avoid transferring to/from the same account, since it would be meaningless
toIndex = (toIndex + 1) % accountNames.Length;
}
var fromKey = accountNames[fromIndex];
var toKey = accountNames[toIndex];
var fromAccount = client.GetGrain<IAccountGrain>(fromKey);
var toAccount = client.GetGrain<IAccountGrain>(toKey);
// Perform the transfer and query the results
try
{
var transferAmount = random.Next(200);
await transactionClient.RunTransaction(
TransactionOption.Create,
async () =>
{
await fromAccount.Withdraw(transferAmount);
await toAccount.Deposit(transferAmount);
});
var fromBalance = await fromAccount.GetBalance();
var toBalance = await toAccount.GetBalance();
Console.WriteLine(
$"We transferred {transferAmount} credits from {fromKey} to " +
$"{toKey}.\n{fromKey} balance: {fromBalance}\n{toKey} balance: {toBalance}\n");
}
catch (Exception exception)
{
Console.WriteLine(
$"Error transferring credits from " +
$"{fromKey} to {toKey}: {exception.Message}");
if (exception.InnerException is { } inner)
{
Console.WriteLine($"\tInnerException: {inner.Message}\n");
}
Console.WriteLine();
}
// Sleep and run again
await Task.Delay(TimeSpan.FromMilliseconds(200));
}
Nel codice client precedente:
- L'oggetto IHostApplicationBuilder è configurato con UseOrleansClient.
- Il IClientBuilder utilizza il clustering localhost e le transazioni.
- Le interfacce IClusterClient e ITransactionClient sono ottenute dal provider di servizi.
- Alle
fromvariabili etovengono assegnati i relativiIAccountGrainriferimenti. - Viene
ITransactionClientusato per creare una transazione, chiamando:-
Withdrawnel riferimento granulare dell'accountfrom. -
Depositnel riferimento granulare dell'accountto.
-
Le transazioni vengono sempre effettuate, a meno che non venga generata un'eccezione nel transactionDelegate o venga specificato un transactionOption contraddittorio. Usare ITransactionClient è il modo consigliato per chiamare i metodi dei grain transazionali, ma è anche possibile chiamarli direttamente da un altro grain.
Chiamare metodi di transazione da un'altra granularità
Chiamare metodi transazionali su un'interfaccia granulare come qualsiasi altro metodo granulare. In alternativa all'uso di ITransactionClient, l'implementazione AtmGrain seguente chiama il metodo Transfer (che supporta le transazioni) sull'interfaccia IAccountGrain.
Si consideri l'implementazione AtmGrain , che risolve i due grani di account a cui si fa riferimento e effettua le chiamate appropriate a Withdraw e Deposit:
namespace TransactionalExample.Grains;
[StatelessWorker]
public class AtmGrain : Grain, IAtmGrain
{
public Task Transfer(
string fromId,
string toId,
decimal amount) =>
Task.WhenAll(
GrainFactory.GetGrain<IAccountGrain>(fromId).Withdraw(amount),
GrainFactory.GetGrain<IAccountGrain>(toId).Deposit(amount));
}
Il codice dell'app client può chiamare AtmGrain.Transfer transazionalemente come indicato di seguito:
IAtmGrain atmOne = client.GetGrain<IAtmGrain>(0);
Guid from = Guid.NewGuid();
Guid to = Guid.NewGuid();
await atmOne.Transfer(from, to, 100);
uint fromBalance = await client.GetGrain<IAccountGrain>(from).GetBalance();
uint toBalance = await client.GetGrain<IAccountGrain>(to).GetBalance();
Nelle chiamate precedenti, viene utilizzato un IAtmGrain per trasferire 100 unità di valuta da un conto a un altro. Al termine del trasferimento, entrambi i conti vengono interrogati per ottenere il saldo corrente. Il trasferimento di valuta, nonché entrambe le query di conto, vengono eseguite come transazioni ACID.
Come illustrato nell'esempio precedente, le transazioni possono restituire valori all'interno di un Task, come altre chiamate di grani. Tuttavia, in caso di errore di chiamata, non generano eccezioni dell'applicazione, ma piuttosto un OrleansTransactionException o un TimeoutException. Se l'applicazione genera un'eccezione durante la transazione e tale eccezione causa l'esito negativo della transazione (anziché non riuscire a causa di altri errori di sistema), l'eccezione dell'applicazione diventa l'eccezione interna di OrleansTransactionException.
Se viene generata un'eccezione di transazione di tipo OrleansTransactionAbortedException, la transazione non è riuscita e può essere ritentata. Qualsiasi altra eccezione generata indica che la transazione è stata terminata con uno stato sconosciuto. Poiché le transazioni sono operazioni distribuite, una transazione in uno stato sconosciuto potrebbe avere avuto esito positivo, non riuscito o ancora in corso. Per questo motivo, è consigliabile consentire il passaggio di un periodo di timeout di chiamata (SiloMessagingOptions.SystemResponseTimeout) prima di verificare lo stato o ritentare l'operazione per evitare interruzioni a catena.