El artículo ha sido escrito por Francisco Refoyo Andrés, desarrollador de software en Ilitia Technologies.

El código completo que referencia el artículo está disponible en GitHub.

Introducción

Un problema recurrente a la hora de exponer datos a través de un servicio web es la necesidad de que sean tratados a medida en el servidor para atender adecuadamente las peticiones. Esto implica una inversión de tiempo importante en backend.

Gracias a la aparición de OData años atrás, unido a los IQueriable de LinQ esta situación se puede solventar rápidamente en ecosistemas .Net.

El mundo del desarrollo del software suele ir guiado por conceptos teóricos que a menudo chocan con la realidad de las restricciones de los proyectos. Un servicio que se alimenta de dos orígenes de datos diferentes y sin conectividad entre ellos es un claro ejemplo. En esta situación no es factible utilizar OData, ¿qué podemos hacer?

Objetivo y premisas

Construcción de un marco de trabajo extensible que permita utilizar un subconjunto de la funcionalidad OData sobre dos orígenes de datos diferentes.

Con un esquema como el que se puede ver a continuación la solución implica la intercepción e interpretación de los parámetros OData que se envían sobre la Url en la petición al servicio web para devolver los datos solicitados.

  • Cada origen de datos devolverá un objeto de trasferencia de datos o entidad diferente. Ambos son complementarios y se utilizarán para la elaboración de un Dto que contiene la totalidad de los datos a devolver.
  • Uno de los orígenes de datos es el principal, denominado guía, y el otro es el complementario.
  • La operación implementada se corresponde con un left outter join de tal forma que si en el origen de datos complementarios no existe la entrada vinculada a la guía se devolverán los campos vacíos.
  • La unión de los dos orígenes de datos se realiza a través de una única propiedad.

Interfaz de acceso

El punto de entrada para lanzar una consulta es construir una instancia de ODataProvider. Dispone de tres tipos genéricos que se detallan a continuación:

public class ODataProvider<Guide, Complementary, TJoinProperty>
where Guide : class
where Complementary : class, new()
  • Guide. Tipo de datos principal de la consulta. Entidad principal del join.
  • Complementary. Tipo de datos complementario. La consulta final se construirá a partir de una instancia de Guide y opcionalmente otra de Complementary.
  • TJoinProperty. Tipo de datos simple de la propiedad que se utilizará para realizar el join. El tipo debe ser el mismo en el tipo guía y en el complementario.

Los parámetros de la consulta se indican en el constructor del objeto:

public ODataProvider(IEnumerable<KeyValuePair<string, StringValues>>                                                          oDataQueryValues,IQueryable<Guide> guideQuery,
IQueryable<Complementary> complementaryQuery,
string guideJoinProperty,
string complementaryJoinProperty)
  • ODataQueryValues. Parámetros de la consulta OData. En la práctica se puede obtener mediante la propiedad Request.Query disponible en ControllerBase de una API .Net Core.
  • GuideQuery. Consulta para obtener los datos guía. Se hace uso de los IQueryable<T> para detallar y construir las consultas que se lanzarán contra memoria o base de datos según corresponda.
  • ComplementaryQuery. Consulta complementaria.
  • GuideJoinProperty. Nombre de la propiedad con acceso público get de la entidad a utilizar como guía.
  • ComplementaryJoinProperty. Nombre de la propiedad con acceso público get de la entidad a utilizar como complementaria.

La recuperación del resultado de la consulta pasa por la ejecución del método Execute:

public QueryResponse<TResult> Execute<TResult>(Func<Guide,                                            Complementary, TResult> buildResultFunc)
where TResult : class

Este método dispone de un tipo genérico adicional que indica el objeto que se obtendrá como composición de Guide y Complementary.

El parámetro buildResultFunc expresa como se desea construir cada TResult a partir de una pareja Guide-Complementary recuperada con las consultas IQueriable<T> y las operaciones OData indicadas en el constructor.

Los resultados se devuelven encapsulados en una instancia de QueryResponse<TResult> que contendrá los resultados disponibles en memoria a través del IEnumerable<TResult> y opcionalmente (en función de si se ha solicitado a través de los parámetros OData) el número total de registros independientemente de si la respuesta es paginada.

public class QueryResponse<TResult>
where TResult : class
{
public IEnumerable<TResult> Results { get; set; }
public int? TotalCount { get; set; }
}

Operaciones implementadas

Se ha implementado un conjunto de operaciones OData para realizar las funciones más comunes:

  • $filter. Permite realizar cláusulas Where sobre los datos.
  • §Operadores de comparación: eq (igual), ne (diferente), gt (mayor que), ge (mayor o igual que), lt (menor que) y le (menor o igual que)
  • § Operadores lógicos: and y or
  • § Operaciones sobre cadenas: contains, startswith y endswith
  • $top. Acota el número de resultados a una cantidad prefijada
  • $skip. Excluye los n primeros elementos de los resultados. Tradicionalmente utilizado junto con $top para la implementación de sistemas de paginación.
  • $count. Indica que se desea conocer el número total de registros independientemente de las cláusulas $top y $skip. Importante para calcular el número de elementos dentro de la paginación.
  • $orderby. Campos sobre los que se desea ordenar los resultados. Soporta tanto asc como desc. Siguiendo la especificación OData, la omisión implica orden ascendente.

Ejemplos de uso

Para ilustrar los ejemplos se van a utilizar los siguientes modelos:

Person: Datos básicos. Tipo guía en la consulta principal. Id es la clave

public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
}

- AdditionalPersonData. Tipo complementario en la consulta secundaria. PersonId hace referencia al campo Id de Person.

public class AdditionalPersonData
{
public int Id { get; set; }
public int PersonId { get; set; }
public int? BirthYear { get; set; }
public string FavoriteColor { get; set; }
public float? Height { get; set; }
}
  • FullPerson. Totalidad de los datos que se construirá como resultado de la consulta.
public class FullPerson
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
public int? BirthYear { get; set; }
public string FavoriteColor { get; set; }
public float? Height { get; set; }
}

Estableciendo como contexto un controlador de una API .Net Core, el siguiente fragmento de código ilustra como obtener el resultado de la consulta.

ODataProvider<Person, AdditionalPersonData, int> oDataProvider = 
new ODataProvider<Person, AdditionalPersonData, int>(
Response.Query,
PersonQuery,
AdditionalDataQuery,
nameof(Person.Id),
nameof(AdditionalPersonData.PersonId));
QueryResponse<FullPerson> response = oDataProvider.Execute(buildFullPerson);

Los parámetros necesarios para la llamada son:

  • Response.Query. Parámetros estructurados recogidos de la QueryString. Contiene la petición OData.
  • PersonQuery. IQueryable<Person> con las potenciales restricciones sobre el origen de datos principal.
  • AdditionalDataQuery. IQueryable<AddiontalPersonData> sobre el origen de datos secundario.
  • Nameof(Person.Id). Nombre de la propiedad que actúa de clave sobre la entidad principal.
  • Nameof(AdditionalPersonData.PersonId). Nombre de la propiedad que actúa como foreing key sobre la entidad Person.

La construcción de cada uno de los elementos de tipo FullPerson a devolver se realiza mediante la Func buildFullPerson definida como:

private Func<Person, AdditionalPersonData, FullPerson> buildFullPerson
{
get
{
return (Person person, AdditionalPersonData additionalData) =>
{
return new FullPerson()
{
Id = person.Id,
Name = person.Name,
Surname = person.Surname,
BirthYear = additionalData?.BirthYear,
FavoriteColor = additionalData?.FavoriteColor,
Height = additionalData?.Height
};
};
}
}

A continuación, se muestran varios ejemplos de parámetros a enviar en la URL de petición al servicio:

Ejemplo 1. Petición con filtros a aplicar sobre los dos conjuntos y ordenados por la guía. Personas cuya fecha de nacimiento sea mayor que 1987 y cuyo nombre sea Bill:

  • $filter=BirthYear gt 1978 and Name eq ‘Bill’
  • $orderby=Name asc

Ejemplo 2. Tercera página (con tamaño 20); filtros a aplicar sobre la entidad complementaria y ordenado por la guía. Del 40º al 60º registro de personas cuya fecha de nacimiento esté entre 1978 y 1980 ordenadas por el nombre ascendente y el apellido descendente

  • $filter=BirthYear gt 1978 and BirthYear lt 1980
  • $orderby=Name asc, Surname desc
  • $skip=40
  • $top=20

Ejemplo 3. Filtros sobre los dos conjuntos ordenados por la entidad complementaria desechando los 10 primeros elementos y ordenados por la guía. Personas cuyo apellido contenga Mathews o su nombre empiece por Foster y su color favorito sea el verde ordenadas por el nombre descendente ignorando los 10 primeros registros.

  • $filter=contains(Surname, ‘Mathews’) or startswith(Name, ‘Foster’) and FavoriteColor = ‘green’
  • $orderby=Name desc
  • $skip=10

Extensión del sistema

El sistema se puede extender por varios puntos hasta cubrir el 100% de la funcionalidad OData.

Ampliar el abanico de filtros.

Es necesario realizar acciones en dos puntos.

  • Crear la definición del filtro heredando de FilterBase. Será requerido construir la Expression que define el filtro para que se pueda ejecutar dinámicamente a través de LinQ
  • Incluir la creación del nuevo filtro en el proceso de extracción de datos desde los parámetros OData. El siguiente diagrama de secuencia muestra, a alto nivel, el proceso:

El punto a modificar es el bucle de creación de filtros de la clase Extractor. Será necesario identificar el nuevo filtro a través de su representación como cadena bajo el protocolo OData, para posteriormente crearlo y que se incorpore a la instancia de ODataExpression.

Cuando se invoque Execute el sistema automáticamente leerá los datos de ODataExpression, junto con los parámetros de entrada para generar y ejecutar las consultas sobre los orígenes de datos.

Incorporar más funcionalidad odata

El protocolo OData es muy amplio y dispone de otros elementos que pueden ser interesantes como $expand, $search, etc.

La implementación del resto de los elementos pasará por:

  1. Identificación durante el proceso de extracción de metadatos.
  2. Incorporación en la instancia de ODataExpression.
  3. Modificación del método Execute de ODataProvider.

Recomendaciones

El sistema implementa parte del protocolo OData y permite lanzar left outter joins entre dos orígenes de datos pero el coste de recursos para realizar esta operación requiere de algunas recomendaciones cuando se actúa sobre SQL.

  • Usar conjuntos de datos potencialmente pequeños. Si los orígenes de datos contienen gran volumen de información es importante acotar lo máximo posible las búsquedas. Esto se puede hacer por dos vías:
  • o Filtrar lo máximo posible las consultas en la construcción de la instancia de ODataProvider.
  • o Aplicar filtros OData para que se generen conjuntos lo más pequeños posibles y se reduzca el tráfico de datos hacia y desde los dos orígenes.
  • Usar $top y $take para aplicar paginación a las consultas es otro medio eficaz de mejorar el rendimiento.

Escenario

La motivación fundamental para utilizar esta implementación de OData debe estar centrada en tener dos fuentes de datos accesibles mediante IQueryable y que no disponen de conectividad entre ambas.

Ejemplo: Un origen de datos SQL Server OnPremise y otro Azure SQL. Sin la posibilidad de establecer un linked server (por seguridad u otros motivos) tendríamos que acudir a artificios para recuperar información cruzada y perderíamos capacidades como la ordenación en la fuente de datos secundaria.

Conclusiones

Mediante una implementación como la expuesta podemos aislarnos de problemas de conectividad entre orígenes de datos con referencias cruzadas y centrar los esfuerzos en la lógica de negocio de la aplicación que se esté desarrollando.

Gracias a la implementación de un protocolo ampliamente conocido como es OData podemos incluir una solución personalizada en el backend sin que el frontend tenga conocimiento sobre ello más allá de una posible funcionalidad acotada.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store