Compartir a través de



Marzo de 2019

Volumen 34, número 3

[Web Development]

C# de pila completa con Blazor

Por Jonathan C. Miller | Marzo de 2019 | Obtener el código

Blazor, el marco experimental de Microsoft que incorpora C# en el explorador, es la pieza que faltaba en el rompecabezas de C#. Actualmente, un programador de C# puede crear aplicaciones para escritorio, web del lado servidor, nube, teléfono, tableta, reloj, TV e IoT. Blazor completa el rompecabezas, ya que permite a los desarrolladores de C# compartir código y lógica de negocios directamente en el explorador del usuario. Esto supone una capacidad eficaz y una mejora de productividad gigantesca para los desarrolladores de C#.

En este artículo, voy a mostrar un caso práctico común de uso compartido de código. Mostraré cómo compartir la lógica de validación entre un cliente Blazor y una aplicación de servidor WebAPI. Hoy en día, se espera que valide la entrada no solo en el servidor, sino también en el explorador del cliente. Los usuarios de aplicaciones web modernas esperan comentarios casi en tiempo real. Los días de rellenar un formulario largo y hacer clic en Enviar solo para obtener un error en rojo como respuesta ya forman parte del pasado, mayoritariamente.

Una aplicación web Blazor que se ejecute en el explorador puede compartir código con un servidor back-end C#. Puede colocar la lógica en una biblioteca compartida y utilizarla tanto en el front-end como en el back-end. Esto tiene muchas ventajas. Puede colocar todas las reglas en un sitio y saber que solo tendrán que actualizarse en un sitio. Sabrá que funcionarán igual porque tienen el mismo código. Podrá ahorrar mucho tiempo en pruebas y solución de problemas de casos en que la lógica del cliente y del servidor no coinciden exactamente.

Quizás lo más importante es que puede usar una biblioteca para la validación en el cliente y en el servidor. Tradicionalmente, un front-end JavaScript obliga a los desarrolladores a escribir dos versiones de las reglas de validación: una en JavaScript para el front-end y otra en el lenguaje usado en el back-end. Los intentos por resolver esta discrepancia implican marcos de trabajo de reglas complejos y capas de abstracción adicionales. Con Blazor, la misma biblioteca .NET Core se ejecuta en el cliente y en el servidor.

Blazor sigue siendo un marco de trabajo experimental, pero avanza rápidamente. Antes de compilar este ejemplo, asegúrese de que tiene la versión correcta de Visual Studio, el SDK de .NET Core y los servicios de lenguaje de Blazor instalados. Revise los pasos de introducción en blazor.net.

Crear una nueva aplicación Blazor

En primer lugar, vamos a crear una nueva aplicación Blazor. En el cuadro de diálogo Nuevo proyecto, haga clic en Aplicación web ASP.NET Core, haga clic en Aceptar y, a continuación, seleccione el icono de Blazor en el cuadro de diálogo que se muestra en la figura 1. Haga clic en Aceptar. Se creará la aplicación Blazor de ejemplo predeterminada. Si ya ha experimentado con Blazor, esta aplicación predeterminada le resultarán familiar.

Elegir una aplicación Blazor
Figura 1 Elegir una aplicación Blazor

La lógica compartida que valida las reglas de negocio se muestra en un nuevo formulario de registro. La figura 2 muestra un formulario sencillo con campos para Nombre, Apellido, Correo electrónico y Teléfono. En este ejemplo, validará que todos los campos son obligatorios, que los campos de nombre tienen una longitud máxima y que los campos de correo electrónico y número de teléfono tienen el formato correcto. Mostrará un mensaje de error bajo cada campo, y los mensajes se actualizarán a medida que escriba el usuario. Por último, el botón Registrar solo estará habilitado si no hay errores.

Formulario de registro
Figura 2 Formulario de registro

Biblioteca compartida

Todo el código que necesite compartirse entre el servidor y el cliente Blazor se colocará en un proyecto de biblioteca compartida independiente. La biblioteca compartida contendrá la clase del modelo y un motor de validación muy simple. La clase del modelo contendrá los campos de datos del formulario de registro. Tiene el siguiente aspecto:

public class RegistrationData : ModelBase
{
  [RequiredRule]
  [MaxLengthRule(50)]
  public String FirstName { get; set; }
 
  [RequiredRule]
  [MaxLengthRule(50)]
  public String LastName { get; set; }
 
  [EmailRule]
  public String Email { get; set; }
 
  [PhoneRule]
  public String Phone { get; set; }
 
}

La clase RegistrationData hereda de una clase ModelBase, que contiene toda la lógica que se puede usar para validar las reglas y devolver mensajes de error enlazados con la página Blazor. Cada campo está decorado con atributos que se asignan a reglas de validación. Opté por crear un modelo muy sencillo que se parece mucho al modelo de anotación de datos de Entity Framework (EF). Toda la lógica para este modelo se encuentra en la biblioteca compartida.

La clase ModelBase contiene métodos que puede usar la aplicación cliente Blazor o la aplicación de servidor para determinar si hay errores de validación. También desencadenará un evento cuando cambie el modelo para que el cliente pueda actualizar la UI. Cualquier clase de modelo puede heredarlo y obtener toda la lógica del motor de validación automáticamente.

Para empezar, crearé una nueva clase ModelBase dentro del proyecto SharedLibrary, como se indica a continuación:

public class ModelBase
{
}

Errores y reglas

Ahora agregaré un diccionario privado a la clase ModelBase que contiene una lista de errores de validación. El diccionario _errors contiene código con el nombre de campo y el nombre de regla. El valor es el mensaje de error real que se mostrará. Esta configuración facilita la tarea de determinar si existen errores de validación para un campo concreto y recuperar rápidamente los mensajes de error. Este es el código:

private Dictionary<String, Dictionary<String, String>> _errors =
  new Dictionary<string, Dictionary<string, string>>();

Ahora, agregaré el método AddError para introducir errores en el diccionario de errores internos. AddError tiene parámetros para fieldName, ruleName y errorText. Busca en el diccionario de errores internos y quita las entradas si ya existen. A continuación, agrega la nueva entrada de error, como se muestra en este código:

private void AddError(String fieldName, String ruleName, String errorText)
{
  if (!_errors.ContainsKey(fieldName)) { _errors.Add(fieldName,
    new Dictionary<string, string>()); }
  if (_errors[fieldName].ContainsKey(ruleName))
     { _errors[fieldName].Remove(ruleName); }
  _errors[fieldName].Add(ruleName, errorText);
  OnModelChanged();
}

Por último, agregaré el método RemoveError, que acepta los parámetros fieldName y ruleName, busca en el diccionario de errores internos un error coincidente y lo quita. Este es el código:

private void RemoveError(String fieldName, String ruleName)
{
  if (!_errors.ContainsKey(fieldName)) { _errors.Add(fieldName,
    new Dictionary<string, string>()); }
  if (_errors[fieldName].ContainsKey(ruleName))
     { _errors[fieldName].Remove(ruleName);
    OnModelChanged();
  }
}

El siguiente paso es agregar las funciones de CheckRules que realizan el trabajo de buscar las reglas de validación adjuntas al modelo y ejecutarlas. Existen dos funciones CheckRules diferentes: una a la que le falta un parámetro y comprueba todas las reglas en todos los campos y otra que tiene un parámetro fieldName y solo valida un campo específico. Esta segunda función se usa cuando se actualiza un campo y las reglas para ese campo se validan inmediatamente.

La función CheckRules usa la reflexión para encontrar la lista de atributos adjuntos a un campo. A continuación, comprueba cada atributo para ver si es un tipo de IModelRule. Cuando se encuentra un elemento IModelRule, llama al método Validate y devuelve el resultado, como se muestra en la figura 3.

Figura 3 La función CheckRules

public void CheckRules(String fieldName)
{
  var propertyInfo = this.GetType().GetProperty(fieldName);
  var attrInfos = propertyInfo.GetCustomAttributes(true);
  foreach (var attrInfo in attrInfos)
  {
    if (attrInfo is IModelRule modelrule)
    {
      var value = propertyInfo.GetValue(this);
      var result = modelrule.Validate(fieldName, value);
      if (result.IsValid)
      {
        RemoveError(fieldName, attrInfo.GetType().Name);
      }
      else
      {
        AddError(fieldName, attrInfo.GetType().Name, result.Message);
      }
    }
  }
}
 
public bool CheckRules()
{
  foreach (var propInfo in this.GetType().GetProperties(
    System.Reflection.BindingFlags.Public |
      System.Reflection.BindingFlags.Instance))
    CheckRules(propInfo.Name);
 
  return HasErrors();
}

A continuación, agrego la función Errors. Esta función toma un nombre de campo como parámetro y devuelve una cadena que contiene la lista de errores para el campo. Usa el diccionario interno _errors para determinar si hay errores para ese campo, como se muestra aquí:

public String Errors(String fieldName)
{
  if (!_errors.ContainsKey(fieldName)) { _errors.Add(fieldName,
    new Dictionary<string, string>()); }
  System.Text.StringBuilder sb = new System.Text.StringBuilder();
  foreach (var value in _errors[fieldName].Values)
    sb.AppendLine(value);
 
  return sb.ToString();
}

Ahora, tengo que agregar la función HasErrors, que devuelve true si hay errores en cualquier campo del modelo. El cliente usa este método para determinar si se debe habilitar el botón Registrar. El servidor WebAPI también lo usa para determinar si los datos del modelo entrantes tienen errores. Este es el código de función:

public bool HasErrors()
{
  foreach (var key in _errors.Keys)
    if (_errors[key].Keys.Count > 0) { return true; }
  return false;
}

Valores y eventos

Ha llegado el momento de agregar el método GetValue, que toma un parámetro de nombre de campo y usa la reflexión para buscar el campo en el modelo y devolver su valor. El cliente Blazor lo usa para recuperar el valor actual y mostrarlo en el cuadro de entrada, como se muestra aquí:

public String GetValue(String fieldName)
{
  var propertyInfo = this.GetType().GetProperty(fieldName);
  var value = propertyInfo.GetValue(this);
 
  if (value != null) { return value.ToString(); }
  return String.Empty;           
}

Ahora, agregue el método SetValue. Usa la reflexión para buscar el campo en el modelo y actualizar su valor. Después, desactiva el método CheckRules que valida todas las reglas en el campo. Se usa en el cliente Blazor para actualizar el valor a medida que el usuario escribe en el cuadro de texto de entrada. Este es el código:

public void SetValue(String fieldName, object value)
{
  var propertyInfo = this.GetType().GetProperty(fieldName);
  propertyInfo.SetValue(this, value);
  CheckRules(fieldName);
}

Por último, agrego el evento para ModelChanged, que se desencadena cuando se cambia un valor del modelo, o se agrega o quita una regla de validación del diccionario interno de errores. El cliente Blazor escucha este evento y actualiza la UI cuando se activa. Esto es lo que hace que los errores mostrados se actualicen, tal como se muestra en este código:

public event EventHandler<EventArgs> ModelChanged;
 
protected void OnModelChanged()
{
  ModelChanged?.Invoke(this, new EventArgs());
}

Este motor de validación tiene un diseño muy sencillo que presenta una gran cantidad de oportunidades de mejora. En una aplicación empresarial de producción, sería útil tener niveles de gravedad para los errores, como Información, Advertencia y Error. En determinados escenarios, sería útil poder cargar las reglas dinámicamente desde un archivo de configuración sin necesidad de modificar el código. No soy partidario de crear un motor de validación propio, puesto que ya existen muchas opciones. Este está diseñado para ser lo suficientemente bueno para mostrar un ejemplo real, pero lo suficientemente sencillo para adaptarse a este artículo y ser fácil de entender.

Crear las reglas

En este punto, hay una clase RegistrationData que contiene los campos del formulario. Los campos de la clase se decoran con atributos como RequiredRule y EmailRule. La clase RegistrationData hereda de una clase ModelBase que contiene toda la lógica para validar las reglas y notificar cambios al cliente. La última parte del motor de validación es la propia lógica de la regla. A continuación la exploraré.

Para empezar, crearé una nueva clase en SharedLibrary llamada IModelRule. Esta regla está formada por un único método Validate que devuelve un valor para ValidationResult. Cada regla debe implementar la interfaz IModelRule, como se muestra aquí:

public interface IModelRule
{
  ValidationResult Validate(String fieldName, object fieldValue);
}

A continuación, creo una nueva clase en SharedLibrary llamada ValidationResult, que consta de dos campos. El campo IsValid indica si la regla es válida o no, mientras que el campo Message contiene el mensaje de error que se mostrará cuando la regla no sea válida. Este es el código:

public class ValidationResult
{
  public bool IsValid { get; set; }
  public String Message { get; set; }
}

La aplicación de ejemplo utiliza cuatro reglas diferentes, todas ellas clases públicas que heredan de la clase Attribute e implementan la interfaz IModelRule.

Ahora es el momento de crear las reglas. Tenga en cuenta que todas las reglas de validación son, simplemente, clases que heredan de la clase Attribute e implementan el método Validate de la interfaz IModelRule. La regla de longitud máxima de la figura 4 devuelve un error si el texto escrito supera la longitud máxima especificada. El resto de reglas, para Required, Phone e Email funcionan de forma similar, pero con una lógica diferente para el tipo de datos que validan.

Figura 4 La clase MaxLengthRule

public class MaxLengthRule : Attribute, IModelRule
{
  private int _maxLength = 0;
  public MaxLengthRule(int maxLength) { _maxLength = maxLength; }
 
  public ValidationResult Validate(string fieldName, object fieldValue)
  {
    var message = $"Cannot be longer than {_maxLength} characters";
    if (fieldValue == null) { return new ValidationResult() { IsValid = true }; }
 
    var stringvalue = fieldValue.ToString();
    if (stringvalue.Length > _maxLength )
    {
      return new ValidationResult() { IsValid = false, Message = message };
    }
    else
    {
      return new ValidationResult() { IsValid = true };
    }
  }
}

Crear el formulario de registro Blazor

Ahora que el motor de validación se ha completado en la biblioteca compartida, se puede aplicar a un nuevo formulario de registro en la aplicación Blazor. Comenzaré por agregar primero una referencia al proyecto de biblioteca compartida desde la aplicación Blazor. Puede hacerlo desde la ventana Solución del cuadro de diálogo Administrador de referencias, como se muestra en la figura 5.

Agregar una referencia a la biblioteca compartida
Figura 5 Agregar una referencia a la biblioteca compartida

A continuación, agrego un nuevo vínculo de navegación en el elemento NavMenu de la aplicación. Después, abro el archivo Shared\NavMenu.cshtml y agrego un nuevo vínculo del formulario de registro a la lista, como se muestra en la figura 6.

Figura 6 Agregar un vínculo al formulario de registro

<div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu>
  <ul class="nav flex-column">
    <li class="nav-item px-3">
      <NavLink class="nav-link" href="" Match=NavLinkMatch.All>
        <span class="oi oi-home" aria-hidden="true"></span> Home
      </NavLink>
    </li>
    <li class="nav-item px-3">
      <NavLink class="nav-link" href="counter">
        <span class="oi oi-plus" aria-hidden="true"></span> Counter
      </NavLink>
    </li>
    <li class="nav-item px-3">
      <NavLink class="nav-link" href="fetchdata">
        <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
      </NavLink>
    </li>
    <li class="nav-item px-3">
      <NavLink class="nav-link" href="registrationform">
        <span class="oi oi-list-rich" aria-hidden="true"></span> Registration Form
      </NavLink>
    </li>
  </ul>
</div>

Por último, agrego el archivo RegistrationForm.cshtml en la carpeta Pages. Para hacer esto, se usa el código de la figura 7.

El código cshtml de la figura 7 incluye cuatro campos <TextInput> dentro de la etiqueta <form>. La etiqueta <TextInput> es un componente Blazor personalizado que controla el enlace de datos y la lógica de presentación de errores para el campo. El componente solo necesita tres parámetros para funcionar:

  • Campo Model: identifica la clase con la que se enlazan los datos.
  • FieldName: identifica el miembro de datos con el que enlazar datos.
  • Campo DisplayName: permite que el componente muestre mensajes descriptivos.

Figura 7 Agregar el archivo RegistrationForm.cshtml

@page "/registrationform"
@inject HttpClient Http
@using SharedLibrary
 
<h1>Registration Form</h1>
 
@if (!registrationComplete)
{
  <form>
    <div class="form-group">
      <TextInput Model="model" FieldName="FirstName" DisplayName="First Name" />
    </div>
    <div class="form-group">
      <TextInput Model="model" FieldName="LastName" DisplayName="Last Name" />
    </div>
    <div class="form-group">
      <TextInput Model="model" FieldName="Email" DisplayName="Email" />
    </div>
    <div class="form-group">
      <TextInput Model="model" FieldName="Phone" DisplayName="Phone" />
    </div>
 
    <button type="button" class="btn btn-primary" onclick="@Register"
      disabled="@model.HasErrors()">Register</button>
  </form>
}
else
{
  <h2>Registration Complete!</h2>
}
 
@functions {
  bool registrationComplete = false;
  RegistrationData model { get; set; }
 
  protected override void OnInit()
  {
    base.OnInit();
    model = new RegistrationData() { FirstName =
      "test", LastName = "test", Email = "test@test.com", Phone = "1234567890" };
    model.ModelChanged += ModelChanged;
    model.CheckRules();
  }
 
  private void ModelChanged(object sender, EventArgs e)
  {
    base.StateHasChanged();
  }
 
  async Task Register()
  {
    await Http.PostJsonAsync<RegistrationData>(
      "https://localhost:44332/api/Registration", model);
    registrationComplete = true;
  }
}

Dentro del bloque @functions de la página, el código es mínimo. El método OnInit inicializa la clase del modelo con algunos datos de prueba dentro. Se enlaza al evento ModelChanged y llama al método CheckRules para validar las reglas. El controlador ModelChanged llama al método base.StateHasChanged para forzar una actualización de la UI. El método Register se invoca cuando se hace clic en el botón Registrar. A continuación, envía los datos de registro a un servicio WebAPI de back-end.

El componente TextInput contiene la etiqueta de entrada, el cuadro de texto de entrada, el mensaje de error de validación y la lógica para actualizar el modelo a medida que el usuario escribe. Los componentes Blazor son muy fáciles de escribir y proporcionan una forma eficaz de descomponer una interfaz en elementos reutilizables. Los miembros del parámetro se decoran con el atributo Parameter, lo que indica a Blazor que se trata de parámetros de componente.

El evento oninput del cuadro de texto de entrada está conectado al controlador OnFieldChanged. Se activa cada vez que cambia la entrada. El controlador OnFieldChanged, a continuación, llama al método SetValue, que provoca que se ejecuten las reglas para ese campo y que se actualice el mensaje de error en tiempo real a medida que el usuario escribe. La figura 8 muestra el código.

Figura 8 Actualización del mensaje de error

@using SharedLibrary
 
<label>@DisplayName</label>
<input type="text" class="form-control" placeholder="@DisplayName"
  oninput="@(e => OnFieldChanged(e.Value))"
  value="@Model.GetValue(FieldName)" />
<small class="form-text" style="color:darkred;">@Model.Errors(FieldName)
  </small>
 
@functions {
 
  [Parameter]
  ModelBase Model { get; set; }
 
  [Parameter]
  String FieldName { get; set; }
 
  [Parameter]
  String DisplayName { get; set; }
 
  public void OnFieldChanged(object value)
  {
    Model.SetValue(FieldName, value);
  }
}

Validación en el servidor

Ahora, el motor de validación está conectado y funciona en el cliente. El siguiente paso es usar la biblioteca compartida y el motor de validación en el servidor. Para hacerlo, empiezo agregando otro proyecto de aplicación web ASP.NET Core a la solución. Esta vez, elijo API en lugar de Blazor en el cuadro de diálogo Nueva aplicación web ASP.NET Core que se muestra en la figura 1.

Una vez creado el nuevo proyecto de API, agrego una referencia al proyecto compartido, tal como lo hice en la aplicación cliente Blazor (consulte la figura 5). A continuación, agrego un nuevo controlador al proyecto de API. El nuevo controlador aceptará la llamada RegistrationData del cliente Blazor, como se muestra en la figura 9. El controlador de registro se ejecuta en el servidor y es típico de un servidor de API de back-end. La diferencia es que ahora ejecuta las mismas reglas de validación que se ejecutan en el cliente.

Figura 9 El controlador de registro

[Route("api/Registration")]
[ApiController]
public class RegistrationController : ControllerBase
{
  [HttpPost]
  public IActionResult Post([FromBody] RegistrationData value)
  {
    if (value.HasErrors())
    {
      return BadRequest();
    }
    // TODO: Save data to database
    return Created("api/registration", value);
  }
}

El controlador de registro tiene un único método POST que acepta RegistrationData como valor. Llama al método HasErrors, que valida todas las reglas y devuelve un valor booleano. Si hay errores, el controlador devuelve una respuesta BadRequest; en caso contrario, devuelve una respuesta correcta. Omití intencionadamente el código que guardaría los datos de registro en una base de datos para centrarme en el escenario de validación. Ahora, la lógica de validación compartida se ejecuta en el cliente y el servidor.

Visión global

Este sencillo ejemplo del uso compartido de la lógica de validación en el explorador y el back-end apenas muestra la superficie de las posibilidades que ofrece un entorno C# de pila completa. La magia de Blazor es que permite al ejército de desarrolladores de C# existente crear aplicaciones de página única eficaces y modernas, con una gran capacidad de respuesta y un período de arranque mínimo. Permite a las empresas reutilizar y volver a empaquetar el código existente para que pueda ejecutarse directamente en el explorador. La capacidad de compartir código C# entre el explorador, el escritorio, el servidor, la nube y las plataformas móviles aumentará considerablemente la productividad del desarrollador. También permitirá a los desarrolladores ofrecer más características y más valor empresarial a los clientes más rápidamente.

 


Jonathan Milleres arquitecto jefe. Ha estado desarrollando productos en la pila de Microsoft durante una década y programando en .NET desde sus inicios. Miller es desarrollador de producto de pila completa con experiencia en tecnologías de front-end (Windows Forms, Windows Presentation Foundation, Silverlight, ASP.NET, Angular/Bootstrap), software intermedio (servicios de Windows, API web) y back-ends (SQL Server, Azure).

Gracias a Dino Esposito, experto técnico, por revisar este artículo.