.NET

La importancia de usar CancellationToken en nuestras APIs

Hoy vengo a contaros por qué es importante utilizar tokens de cancelación en los endpoints de nuestras APIs.

(Puedes visitar el post original en el blog de Jorge Diego Crespo)

Primero de todo. ¿Qué es un token de cancelación (CancellationToken de aquí en adelante)?

Un CancellationToken es un mecanismo de cancelación proporcionado por dotNet que se utiliza para solicitar la cancelación de una operación en progreso. Por ejemplo, supongamos que tenemos un endpoint que realiza una tarea larga o costosa, como realizar una consulta a una base de datos o llamar a un servicio externo. Si un cliente realiza una solicitud y luego cambia de opinión o cierra la conexión, puede ser deseable cancelar la tarea en curso para liberar recursos y evitar un trabajo innecesario.

Una vez que sabemos qué es un CancellationToken toca contestar la siguiente pregunta. ¿Por qué debo usarlo?

Existen varias razones para usarlo. Aquí enumero algunas de ellas:

    • Mejora la experiencia del cliente: Especialmente en escenarios donde las solicitudes son costosas o requieren mucho tiempo. Si un usuario cancela una de esas costosas operaciones, no tiene sentido que el servidor siga realizando un trabajo innecesario. De esta manera el servidor queda liberado para contestar otras peticiones de forma más rápida.
    • Ahorro de recursos: Muy relacionada con la anterior. Al permitir la cancelación, se pueden liberar recursos valiosos en el servidor si una solicitud ya no es necesaria. Esto ayuda a utilizar de manera más eficiente los recursos del servidor.
    • Prevención de operaciones innecesarias: Al utilizar CancellationToken, se pueden interrumpir operaciones que se vuelven innecesarias si el cliente decide cancelar la solicitud. Esto ayuda a evitar que se completen tareas que puede incluso hacer modificaciones en los datos de nuestra aplicación, provocando errores, dado que la operación debería haberse cancelado.

A continuación se muestran los endpoints que he creado. Uno recibe un CancellationToken y otro no.

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Post([FromBody]CreatingTask item, CancellationToken cancellationToken = default)
{
    if (item == null)
        return BadRequest();

    if (!ModelState.IsValid)
        return BadRequest();

    int createdId = await _service.AddAsync(item, cancellationToken);
    return Created(nameof(GetById), new { id = createdId });
}

[HttpPost]
[Route("nonCancellable")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> PostNonCancellable([FromBody]CreatingTask item)
{
    if (item == null)
        return BadRequest();

    if (!ModelState.IsValid)
        return BadRequest();

    int createdId = await _service.AddAsync(item);
    return Created(nameof(GetById), new { id = createdId });
}

La gran diferencia entre los dos endpoints, es que uno recibe un CancellationToken que pasa al servicio, por lo que la operación es cancelable. Y el otro no, por lo que, aunque cancelemos la operación desde el lado cliente, el servidor seguirá realizando la tarea y guardará los valores de la tarea en la base de datos.

Para probar todo esto, he creado la siguiente batería de tests.

private static readonly string url = "/api/task";
private HttpClient _client;

[TestInitialize]
public async Task InitDbContext()
{
    //Creates a temporary database and inserts some data to make tests
    WebApplicationFactory<Program> factory = await BuildWebApplicationFactory(Guid.NewGuid().ToString());

    _client = factory.CreateClient();
    AddApiKeyHeader(_client);
}

[TestCleanup]
public async Task RemoveDbContext()
{
    //Removes the temporary database create in the test initialization
    await DeleteDatabase();
    _client.Dispose();
    await Task.CompletedTask;
}

[TestMethod]
public async Task post_created()
{
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 8" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    HttpResponseMessage response = await _client.PostAsync(url, content);

    Assert.AreEqual(System.Net.HttpStatusCode.Created, response.StatusCode);
}

[TestMethod]
public async Task post_cancelled()
{
    CancellationTokenSource source = new CancellationTokenSource(3000);
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 9" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    await Assert.ThrowsExceptionAsync<OperationCanceledException>(async() => await _client.PostAsync(url, content, source.Token));
}

[TestMethod]
public async Task post_non_cancellable_created()
{
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 10" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    HttpResponseMessage response = await _client.PostAsync($"{url}/nonCancellable", content);

    Assert.AreEqual(System.Net.HttpStatusCode.Created, response.StatusCode);
}

[TestMethod]
public async Task post_non_cancellable_cancelled()
{
    CancellationTokenSource source = new CancellationTokenSource(3000);
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 11" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    await Assert.ThrowsExceptionAsync<OperationCanceledException>(async() => await _client.PostAsync($"{url}/nonCancellable", content, source.Token));
}

Todos los tests anteriores se completan correctamente, pero si echamos un vistazo al resultado de la base de datos en los dos métodos que se cancelan, vemos algo que marca la diferencia.

post_cancelled llama al endpoint que permite cancelación y cancela la petición a los 3 segundos, por lo que la tarea cuya descripción es Task 9 no se añade a la base de datos. Recordemos que el servicio materializa la transacción que guarda los valores en la base de datos tras un delay de 5 segundos.

En cambio, post_non_cancellable_cancelled llama al endpoint que no permite cancelación, por lo que, aunque cancelemos la petición, la transacción se materializa en la base de datos y la tarea cuya descripción es Task 11 se guarda.

Con todo lo anterior, queda demostrado que el uso de CancellationToken es una práctica recomendada, sobretodo en escenarios donde las operaciones pueden ser costosas o tomar un tiempo significativo para completarse.

Puedes visitar el post original en el blog de Jorge Diego Crespo

Sobre este último punto, vamos a ver un ejemplo a continuación.

Utilizando CancellationToken

Este ejemplo lo vamos a hacer sobre un endpoint que llama a un servicio que lo único que hace es guardar un registro en una base de datos y esperar 5 segundos antes de materializar la transacción.

public async Task<int> AddAsync(CreatingTask businessModel, CancellationToken cancellationToken = default)
{
    return await unitOfWork.SaveChangesInTransactionAsync(async () =>
    {
        await ValidateEntityToAddAsync(businessModel);

        var entity = MapCreating(businessModel);
        await _repository.AddAsync(entity, cancellationToken);
        await unitOfWork.SaveChangesAsync(cancellationToken);

        await Task.Delay(5000, cancellationToken);

        return entity.Id;
    }, cancellationToken);
}

En el código anterior se muestra el servicio que os he comentado. Como puede apreciarse, lo que hace es lanzar algunas validaciones sobre el DTO recibido (ValidateEntityToAddAsync), mapear a la entidad utilizada por el repositorio (MapCreating) y añadir la entidad a la base de datos. Todo ello encapsulado en una transacción que se materializa 5 segundos después de haberse guardado la entidad. La espera de 5 segundos, obviamente no se verá en un escenario real, pero lo hago para simular una tarea larga.

A continuación se muestran los endpoints que he creado. Uno recibe un CancellationToken y otro no.

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Post([FromBody]CreatingTask item, CancellationToken cancellationToken = default)
{
    if (item == null)
        return BadRequest();

    if (!ModelState.IsValid)
        return BadRequest();

    int createdId = await _service.AddAsync(item, cancellationToken);
    return Created(nameof(GetById), new { id = createdId });
}

[HttpPost]
[Route("nonCancellable")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> PostNonCancellable([FromBody]CreatingTask item)
{
    if (item == null)
        return BadRequest();

    if (!ModelState.IsValid)
        return BadRequest();

    int createdId = await _service.AddAsync(item);
    return Created(nameof(GetById), new { id = createdId });
}

La gran diferencia entre los dos endpoints, es que uno recibe un CancellationToken que pasa al servicio, por lo que la operación es cancelable. Y el otro no, por lo que, aunque cancelemos la operación desde el lado cliente, el servidor seguirá realizando la tarea y guardará los valores de la tarea en la base de datos.

Para probar todo esto, he creado la siguiente batería de tests.

private static readonly string url = "/api/task";
private HttpClient _client;

[TestInitialize]
public async Task InitDbContext()
{
    //Creates a temporary database and inserts some data to make tests
    WebApplicationFactory<Program> factory = await BuildWebApplicationFactory(Guid.NewGuid().ToString());

    _client = factory.CreateClient();
    AddApiKeyHeader(_client);
}

[TestCleanup]
public async Task RemoveDbContext()
{
    //Removes the temporary database create in the test initialization
    await DeleteDatabase();
    _client.Dispose();
    await Task.CompletedTask;
}

[TestMethod]
public async Task post_created()
{
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 8" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    HttpResponseMessage response = await _client.PostAsync(url, content);

    Assert.AreEqual(System.Net.HttpStatusCode.Created, response.StatusCode);
}

[TestMethod]
public async Task post_cancelled()
{
    CancellationTokenSource source = new CancellationTokenSource(3000);
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 9" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    await Assert.ThrowsExceptionAsync<OperationCanceledException>(async() => await _client.PostAsync(url, content, source.Token));
}

[TestMethod]
public async Task post_non_cancellable_created()
{
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 10" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    HttpResponseMessage response = await _client.PostAsync($"{url}/nonCancellable", content);

    Assert.AreEqual(System.Net.HttpStatusCode.Created, response.StatusCode);
}

[TestMethod]
public async Task post_non_cancellable_cancelled()
{
    CancellationTokenSource source = new CancellationTokenSource(3000);
    CreatingTask creatingTask = new CreatingTask { TaskListId = 2, Description = "Task 11" };
    StringContent content = new StringContent(JsonConvert.SerializeObject(creatingTask), Encoding.UTF8, "application/json");
    await Assert.ThrowsExceptionAsync<OperationCanceledException>(async() => await _client.PostAsync($"{url}/nonCancellable", content, source.Token));
}

Todos los tests anteriores se completan correctamente, pero si echamos un vistazo al resultado de la base de datos en los dos métodos que se cancelan, vemos algo que marca la diferencia.

post_cancelled llama al endpoint que permite cancelación y cancela la petición a los 3 segundos, por lo que la tarea cuya descripción es Task 9 no se añade a la base de datos. Recordemos que el servicio materializa la transacción que guarda los valores en la base de datos tras un delay de 5 segundos.

En cambio, post_non_cancellable_cancelled llama al endpoint que no permite cancelación, por lo que, aunque cancelemos la petición, la transacción se materializa en la base de datos y la tarea cuya descripción es Task 11 se guarda.

Con todo lo anterior, queda demostrado que el uso de CancellationToken es una práctica recomendada, sobretodo en escenarios donde las operaciones pueden ser costosas o tomar un tiempo significativo para completarse.

Puedes visitar el post original en el blog de Jorge Diego Crespo

Compartir
Publicado por
Jorge Diego

Este sitio web utiliza cookies para que tengas la mejor experiencia de usuario. Si continuas navegando, estás dando tu consentimiento para aceptar las cookies y también nuestra política de cookies (esperemos que no te empaches con tanta cookie 😊)