
Nell‘ultimo articolo sull’Actor Model, abbiamo mostrato come implementare questo pattern usando gli Akka Actors.
Oggi, invece, vedremo come usare Coyote, un framework che implementa l’Actor Model ma che viene utilizzato principalmente per i test.
I programmi deterministici sono facili da testare, perché sai che il programma viene eseguito allo stesso modo se viene fornito lo stesso input. Ma quando non lo sono potrebbe essere più difficile, e forse un semplice test non può ottenere tutti i casi di esecuzione possibili al fine di trovare qualche errore o magari un risultato imprevisto.
Nel nostro scenario, stiamo sviluppando un sottosistema per gestire le thumbnails dei prodotti su una piattaforma di e-commerce e il nostro ProductsController contiene il seguente metodo:
[HttpPut]
public async Task<ActionResult<Image>> CreateOrUpdateImageAsync(string productId, Image image)
{
var imageItem = image.ToItem();
await this.BlobContainer.CreateOrUpdateBlobAsync(“products”, productId, image.Content);
imageItem = await this.ImageContainer.UpsertItem(imageItem);
await this.MessagingClient.SubmitMessage(new GenerateProductThumbnailMessage()
{
ProductId = productId,
ImageStorageName = image.Name
});
return this.Ok(imageItem.ToImage());
}
In questo metodo, in primo luogo, carichiamo il contenuto dell’immagine in un Azure Storage, usando this.BlobContainer.CreateOrUpdateBlobAsync. Quindi scriviamo i metadati dell’immagine in un database Azure Cosmos DB, usando this.CosmosContainer.UpsertItem e, infine, viene inviato un messaggio di GenerateProductThumbnailMessage ad un Azure Service Bus, tramite this.MessagingClient.SubmitMessage che restituisce uno status code HTTP 200 (OK).
La generazione di thumbnails potrebbe richiedere del tempo, quindi preferiamo ottenerla in modo asincrono, con un worker che viene eseguito in background.
Accodiamo una richiesta di generazione di un thumbnail in una coda di messaggi dell’Azure Service Bus, che viene quindi gestita in maniera asincrona dal worker.
MessagingClient è l’interfaccia della coda di messaggi del’Azure Service Bus che consente ai client di inviare messaggi asincroni alla coda.
Scriviamo quindi un unit test per verificare che il metodo funzioni correttamente:
[Test]
public static async Task TestProductImageUpdate()
{
var cosmosContainer = new CosmosContainerMock();
var blobContainer = new BlobContainerMock();
var productsControllerClient = new TestProductsControllerClient(cosmosContainer, blobContainer);
var productId = Guid.NewGuid().ToString();
var image = new Image()
{
Name = "image.jpg",
ImageType = "JPEG",
Content = new byte[] { 0, 1, 2, 3, 4 }
};
var result = await productsControllerClient.CreateOrUpdateImageAsync(productId, image);
if (result.StatusCode == HttpStatusCode.OK)
{
var imageResponse = await productsControllerClient.GetImageContentsAsync(productId);
Assert.IsTrue(imageResponse.StatusCode == HttpStatusCode.OK);
}
}
Il test è formalmente corretto e darà sempre esito positivo, ma basta per testare il nostro metodo?
Le domande principali sono: cosa succede se gli operatori aggiornano l’immagine del prodotto due o più volte? Qual è il risultato finale? Come siamo in grado di gestire la concorrenza nella nostra piattaforma?
Per rispondere a queste domande possiamo ricorrere a Coyote.
Coyote è uno strumento di integrazione, utile durante lo sviluppo di software asincrono, che ti aiuta sia con i test che infine con lo sviluppo, fornendo astrazioni di programmazione di alto livello, supporto per scrivere specifiche di sistema dettagliate e uno strumento di test molto efficace.
Troviamo i test con Coyote molto utili, perché aiutano a trovare bug di concorrenza che altrimenti non potrebbero essere riprodotti.
Tale strumento di test può anche essere integrato con il framework di unit test standard fornito da .NET.
L’installazione del framework Coyote è facile perché abbiamo solo bisogno di eseguire due comandi tramite la cli di .NET:
dotnet install Microsoft.Coyote
dotnet install Microsoft.Coyote.Test
Possiamo anche installare il tool dotnet coyote eseguendo il comando:
dotnet tool install --global Microsoft.Coyote.CLI
In questo modo, possiamo utilizzare lo strumento Coyote per eseguire la riscrittura e il test della DLL.
Quindi, partendo dal test base scritto precedentemente, scriviamo un test di concorrenza, utilizzando Coyote, per esercitare lo scenario in cui due richieste vengono presentate sull’aggiornamento dell’immagine dello stesso prodotto:
[TestMethod]
public async Task TestConcurrentProductImageUpdate()
{
var cosmosState = new MockCosmosState();
var database = new MockCosmosDatabase(cosmosState);
var imageContainer = (MockCosmosContainer)await database.CreateContainerAsync(Constants.ImageContainerName);
var blobContainer = new MockBlobContainerProvider();
var messagingClient = new MockMessagingClient(blobContainer);
var productsControllerClient = new TestProductsControllerClient(imageContainer, blobContainer, messagingClient);
var productId = Guid.NewGuid().ToString();
var image1 = new Image()
{
Name = "image1.jpg",
ImageType = "JPEG",
Content = new byte[] { 0, 1, 2, 3, 4 }
};
var image2 = new Image()
{
Name = "image2.jpg",
ImageType = "JPEG",
Content = new byte[] { 5, 6, 7, 8, 9 }
};
var task1 = productsControllerClient.CreateOrUpdateImageAsync(productId, image1);
var task2 = productsControllerClient.CreateOrUpdateImageAsync(productId, image2);
await Task.WhenAll(task1, task2);
Assert.IsTrue(task1.Result.StatusCode == HttpStatusCode.OK);
Assert.IsTrue(task1.Result.StatusCode == HttpStatusCode.OK);
var imageResult = await productsControllerClient.GetImageAsync(productId);
Assert.IsTrue(imageResult.StatusCode == HttpStatusCode.OK);
byte[] image = imageResult.Resource;
byte[] thumbnail;
while (true)
{
var thumbnailResult = await productsControllerClient.GetImageThumbnailAsync(productId);
if (thumbnailResult.StatusCode == HttpStatusCode.OK)
{
thumbnail = thumbnailResult.Resource;
break;
}
}
Assert.IsTrue(image.SequenceEqual(thumbnail));
}
Se si tenta di eseguire questo test di concorrenza, è molto probabile che l’asserzione abbia esito negativo. Il fallimento del test non è sempre sempre garantito, perché ci sono alcuni task interconnessi dove passa correttamente ed altri dove invece fallisce.
E’ molto importante, durante la scrittura dei nostri test, configurare correttamente I mock, dal momento che devono simulare il comportamento reale e allo stesso tempo devono aiutare a scovare I bug relativi alla concorrenza.
Ad esempio, il nostro MockBlobContainer, introdotto nel test precedente, contiene un dictionary di containers e un metodo di utility per aggiungerli ed eliminarli:
internal class MockBlobContainerProvider : IBlobContainer
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte[]>> Containers;
internal MockBlobContainerProvider()
{
this.Containers = new ConcurrentDictionary<string, ConcurrentDictionary<string, byte[]>>();
}
public Task CreateContainerAsync(string containerName)
{
return Task.Run(() =>
{
this.Containers.TryAdd(containerName, new ConcurrentDictionary<string, byte[]>());
});
}
//…
public Task DeleteBlobAsync(string containerName, string blobName)
{
return Task.Run(() =>
{
this.Containers[containerName].TryRemove(blobName, out byte[] _);
});
}
}
Quindi il test precedente è corretto ma probabilmente inutile, poiché riesce a trovare bug solamente in circostanze fortuite. Se modifichiamo leggermente il test aggiungendo un ritardo di un millisecondo tra le due chiamate al metodo CreateOrUpdateImageAsync:, ed eseguendo questo test cento volte, probabilmente non fallirà mai:
var task1 = productsControllerClient.CreateOrUpdateImageAsync(productId, image1);
await Task.Delay(1);
var task2 = productsControllerClient.CreateOrUpdateImageAsync(productId, image2);
Questo nostro unit test è inefficace nel catturare le condizioni che potrebbero portare ad un errore o a un risultato diverso da quello atteso.
Gli sviluppatori spesso scrivono stress test, in cui il sistema è bombardato da migliaia di richieste simultanee per scoprire bug non deterministici, ma lo stress test può essere complesso da configurare e non sempre trova i bug più complicati.
Utilizzare Coyote nel tuo programma basato su task è molto facile nella maggior parte dei casi. Tutto quello che devi fare è eseguire il tool coyote per riscrivere l’assembly in modo che Coyote possa iniettare una logica che gli consenta di assumere il controllo dei task C#.
Quindi, è possibile richiamare lo strumento di test coyote che esplora sistematicamente le interconnessioni tra le attività per scoprire il bug di concorrenza. Ciò che è ancora meglio è che, se un bug viene scoperto, Coyote ti consente di riprodurlo deterministicamente ogni volta.
Ora possiamo eseguire il test sotto il controllo di Coyote, ma, come primo passo, dobbiamo riscrivere la DLL. La riscrittura è un processo che carica uno o più assembly e li riscrive per il test. Il codice riscritto mantiene la stessa semantica della versione originale, ma ha diversi stub e hooks iniettati che consentono a Coyote di prendere il controllo della concorrenza e dei costrutti non deterministici all’interno del programma.
Quindi, per riscrivere la DLL, dobbiamo eseguire:
coyote rewrite .\ProductManager.dll
Questo comando inietta la logica che consente a Coyote di assumere il pieno controllo dei task C# e di altre parti non deterministiche nel programma.
Infine possiamo invocare lo strumento coyote per testare il nostro programma esplorando sistematicamente le diverse interconnessioni di attività.
Nel nostro caso testiamo il metodo TestConcurrentProductImageUpdate per 100 iterazioni.
test coyote .\ ProductManager.dll -m TestConcurrentProductImageUpdate -i 100
Coyote trova quasi immediatamente le condizioni che potrebbero generare problemi di concorrenza nel metodo, esattamente in 3 iterazioni di test.
... Elapsed 2.3226862 sec.19
... Testing statistics:20
..... Found 1 bug21
... Scheduling statistics:22
..... Explored 3 schedules: 3 fair and 0 unfair.23
..... Found 100.00% buggy schedules24
..... Number of scheduling points in fair terminating schedules 34 (min), 34(avg), 34 (max).25
In ogni iterazione di test, Coyote eseguirà lo unit test dall’inizio alla fine più volte, esplorando ogni volta interconnessioni di task potenzialmente differenti e altre scelte non deterministiche, alla ricerca di bug.
Cosa ancora più importante, genera un file .schedule, che ci consente di riprodurre esattamente lo stesso percorso che ha portato al bug. Dopo aver riprodotto il bug, possiamo cercare di risolverlo.
Nel nostro caso, modifichiamo il metodo CreateOrUpdateImageAsync in questo modo:
[HttpPut]
public async Task<ActionResult<Image>> CreateOrUpdateImageAsync (string productId, Image image)
{
var imageItem = image.ToItem();
var uniqueId = Guid.NewGuid().ToString();
imageItem.StorageName = uniqueId;
await this.BlobContainer.CreateOrUpdateBlobAsync("products", imageItem.StorageName, image.Content);
imageItem = await this.ImageContainer.UpsertItem(imageItem);
await this.MessagingClient.SubmitMessage(new GenerateProductThumbnailMessage()
{
ProductId = productId,
ImageStorageName = imageItem.StorageName
});
return this.Ok(imageItem.ToImage());
}
Per correggere questo bug, possiamo generare nel controller un GUID univoco per ogni richiesta. L’immagine e l’anteprima utilizzano questo GUID come chiave quando vengono archiviati nel blob container dell’Azure Storage, anziché utilizzare il nome fornito dall’utente.
Questo approccio garantisce che due handler CreateOrUpdateImageAsync concorrenti non interferiscano mai tra loro.
Conclusioni
In questo articolo, ti abbiamo mostrato come usare Coyote per testare la concorrenza e
speriamo di aver suscitato il tuo interesse per l’argomento.
Il progetto di esempio con il codice utilizzato in questo articolo è disponibile qui.
Al prossimo articolo!