Marzo de 2016

Volumen 31, número 3

Aplicaciones modernas: análisis de archivos CSV en aplicaciones para UWP

Por Frank La La

Analizar un archivo de valores separados por comas (CSV) puede parecer a priori sencillo. Sin embargo, la tarea se vuelve rápidamente más y más complicada a medida que se desvelan los aspectos complejos de los archivos CSV. Si no está familiarizado con el formato, los archivos CSV almacenan datos en texto sin formato. Cada línea del archivo constituye un registro. Cada registro tiene sus campos, normalmente definidos mediante una coma; de ahí su nombre.

Hoy en día, los desarrolladores aprecian los estándares entre los formatos de intercambio de datos. El "formato" del archivo CSV se remonta a un tiempo pasado en la industria del software anterior a JSON y a XML. Aunque existen solicitudes de comentarios (RFC) para los archivos CSV (bit.ly/1NsQlvw), no dispone de un estado oficial. Además, se creó en 2005, décadas después de las primeras apariciones de los archivos CSV, allá por los años 70. Como resultado, existe una gran variación en los archivos CSV y las reglas no están definidas claramente. Por ejemplo, un archivo CSV podría tener campos separados por tabulaciones, un punto y coma o cualquier otro carácter.

En términos prácticos, la implementación de la importación y exportación de CSV de Excel se ha convertido en el estándar de facto y se ve de forma más extendida en la industria, incluso fuera del ecosistema de Microsoft. Por consiguiente, los supuestos que establezco en este artículo sobre lo que constituye un formato y análisis "correcto" se basarán en la forma en que Excel importa y exporta archivos CSV. Aunque la mayoría de archivos CSV están en sintonía con la implementación de Excel, no todos lo están. En la parte final de esta columna, presentaré una estrategia para controlar esa incertidumbre.

Una pregunta razonable sería "¿por qué molestarse en escribir un analizador en un cuasiformato con décadas de antigüedad en una plataforma muy reciente?". La respuesta es sencilla: muchas organizaciones tienen sistemas de datos heredados. Gracias a la prolongada duración del formato de archivo, casi todos esos sistemas de datos heredados pueden exportar a CSV. Además, su costo es muy reducido en términos de tiempo y esfuerzo necesarios para exportar datos a CSV. Por consiguiente, hay una gran cantidad de archivos con formato CSV en conjuntos de datos más grandes de empresas y gobiernos.

Diseño de un analizador de CSV para todos los fines

A pesar de la falta de un estándar oficial, los archivos CSV normalmente comparten algunos rasgos comunes.

En términos generales, los archivos CSV: consisten en texto plano, contienen un registro por línea, tienen registros en cada línea separados por un delimitador, tienen delimitadores de un carácter y presentan los campos en el mismo orden.

Estos rasgos comunes trazan un algoritmo general, que constaría de tres pasos:

  1. Dividir una cadena en los delimitadores de línea.
  2. Dividir cada línea en los delimitadores de campo.
  3. Asignar el valor de cada campo a una variable.

Esto es bastante simple de implementar. El código de la Figura 1 analiza la cadena de entrada CSV en un elemento List<Dictionary<string, string>>.

Figura 1. Análisis de la cadena de entrada CSV en un elemento List<Dictionary<string,string>>

var parsedResult = new List<Dictionary<string, string>>();
var records = RawText.Split(this.LineDelimiter);
foreach (var record in records)
  {
    var fields = record.Split(this.Delimiter);
    var recordItem = new Dictionary<string, string>();
    var i = 0;
    foreach (var field in fields)
    {
      recordItem.Add(i.ToString(), field);
      i++;
    }
    parsedResult.Add(recordItem);
  }

Este enfoque funciona estupendamente en un ejemplo como el siguiente, con divisiones de oficinas y sus cifras de ventas:

East, 73, 8300
South, 42, 3000
West, 35, 4250
Mid-West, 18, 1200

Para recuperar valores de la cadena, iteraría a través del elemento List y extraería los valores del elemento Dictionary con el índice de campo con base cero. Por ejemplo, la recuperación del campo de la división de oficina sería tan simple como lo siguiente:

foreach (var record in parsedData)
{
  string fieldOffice = record["0"];
}

Aunque funciona, el código no es tan legible como podría ser.

Un mejor diccionario

Muchos archivos CSV incluyen una fila de encabezado para el nombre del campo. Los desarrolladores tendrían más fácil consumir el analizador si usara el nombre de campo como clave para el diccionario. Como un archivo CSV determinado podría no tener una fila de encabezado, debería agregar una propiedad para transmitir esta información:

public bool HasHeaderRow { get; set; }

Por ejemplo, un archivo CSV de ejemplo con una fila de encabezado podría parecerse a lo siguiente:

Office Division, Employees, Unit Sales
East, 73, 8300
South, 42, 3000
West, 35, 4250
Mid-West, 18, 1200

De forma ideal, el analizador CSV debería poder aprovechar estos metadatos. Esto haría que el código fuera más legible. La recuperación del campo de división de oficina tendría un aspecto similar al siguiente:

foreach (var record in parsedData)
{
  string fieldOffice = record["Office Division"];
}

Campos en blanco

Los campos en blanco se dan a menudo en los conjuntos de datos. En los archivos CSV, un campo en blanco se representa mediante un campo vacío en un registro. El delimitador sigue siendo necesario. Por ejemplo, si no hubiera datos de Employees para la oficina East, el registro tendría el siguiente aspecto:

East,,8300

Si no hubiera datos de Unit Sales ni de Employees, el registro sería así:

East,,

Todas las organizaciones tienen sus estándares propios de calidad de datos. Algunas podrían elegir colocar un valor predeterminado en un campo en blanco para que las personas puedan leer mejor el archivo CSV. Los valores predeterminados normalmente serían 0 o NULL para los números y "" o NULL para las cadenas.

Mantener la flexibilidad

Dadas todas las ambigüedades relacionadas con el formato de archivo CSV, el código no puede suponer nada. No hay garantías de que el delimitador de campo sea una coma y tampoco las hay de que el delimitador de registro sea una nueva línea.

Por lo tanto, los dos serán propiedades de la clase CSVParser:

public char Delimiter { get; set; }
public char LineDelimiter { get; set; }

Para que los desarrolladores puedan consumir este componente más fácilmente, querrá establecer una configuración predeterminada que se aplique en la mayoría de casos:

private const char DEFAULT_DELIMITER = ',';
private const char DEFAULT_LINE_DELIMITER = '\n';

Si alguien quisiera cambiar el delimitador predeterminado por un carácter de tabulación, el código sería muy simple:

CsvParser csvParser = new CsvParser();
csvParser.Delimiter = '\t';

Caracteres escapados

¿Qué sucedería si el propio campo contuviera el carácter delimitador, como una coma? Por ejemplo, en lugar de referirse a las ventas por región, ¿qué pasaría si los datos tuvieran ciudad y estado? Normalmente, los archivos CSV solucionan este problema mediante la inclusión del campo completo entre comillas, como se muestra a continuación:

Office Division, Employees, Unit Sales
"New York, NY", 73, 8300
"Richmond, VA", 42, 3000
"San Jose, CA", 35, 4250
"Chicago, IL", 18, 1200

Este algoritmo convertiría el valor de campo único "New York, NY" en dos campos discretos con valores divididos en la coma, "New York" y "NY".

En este caso, la separación de los valores de ciudad y estado podría no ser perjudicial, pero sigue habiendo caracteres de comillas extra que contaminan los datos. Aunque es muy fácil quitarlos en este caso, con datos más complejos limpiarlos podría no ser tan fácil.

Ahora se complica

Este método para escapar comas dentro de campos introduce otra característica que debe escaparse: el carácter de comillas doble. ¿Qué pasaría si, por ejemplo, hubiera comillas dobles en los datos originales, como se muestra en la Figura 2?

Figura 2. Datos originales con comillas

Office Division Employees Unit Sales Office Motto
Nueva York, NY 73 8300 “We sell great products”
Richmond, VA 42 3000 “Try it and you'll want to buy it”
San José, California 35 4250 “Powering Silicon Valley!”
Chicago, IL 18 1200 “Great products at great value”

El texto sin formato del propio archivo CSV tendría el siguiente aspecto:

Office Division, Employees, Unit Sales, Office Motto
"New York, NY",73,8300,"""We sell great products"""
"Richmond, VA",42,3000,"""Try it and you'll want to buy it"""
"San Jose, CA",35,4250,"""Powering Silicon Valley!"""
"Chicago, IL",18,1200,"""Great products at great value"""

La marca de comillas dobles (") se escapa en tres comillas dobles ("""), que agrega una interesante vuelta de tuerca al algoritmo. Por supuesto, la primera pregunta razonable que cabe preguntarse es: ¿por qué una comilla doble se cambia a tres? Por lo mismo que en el campo Office Division el contenido se incluye entre comillas. Para escapar los caracteres de comillas dobles que forman parte del contenido, se duplican. Y por tanto, " se convierte en "".

Otro ejemplo (Figura 3) podría mostrar el proceso con más claridad.

Figura 3. Datos de citas

Cita
"The only thing we have to fear is fear itself." -President Roosevelt
"Logic will get you from A to B. Imagination will take you everywhere." -Albert Einstein

Los datos de la Figura 3 se representarían en CSV como se muestra a continuación:

Cita

"""The only thing we have to fear is fear itself."" -President Roosevelt"
"""Logic will get you from A to B. Imagination will take you everywhere."" -Albert Einstein"

Podría parecer más claro ahora que el campo está encapsulado entre comillas y que las comillas individuales del contenido del campo están duplicadas.

Casos límite

Como mencioné en la sección inicial, no todos los archivos siguen la implementación de CSV de Excel. La falta de una verdadera especificación para CSV hace que sea difícil escribir un analizador para controlar todos los archivos CSV que existan. Existirán con toda seguridad casos límite, lo que implica que el código debe dejar una puerta abierta a la interpretación y personalización.

Inversión de control al rescate

Dado el difuso estándar del formato CSV, no es práctico escribir un analizador completo para todos los casos imaginables. Podría ser más adecuado escribir un analizador que cubra una necesidad concreta de una aplicación. El uso de inversión de control permite personalizar un motor de análisis para una necesidad concreta.

Para conseguirlo, crearé una interfaz para trazar las dos funciones principales del análisis: la extracción de registros y la extracción de campos. Decidí que la interfaz IParserEngine fuera asincrónica. Esto garantiza que cualquier aplicación que utilice este componente mantendrá la capacidad de respuesta, sin importar lo grande que sea el archivo CSV:

public interface IParserEngine
{
  IAsyncOperation<IList<string>> ExtractRecords(char lineDelimiter, string csvText);
  IAsyncOperation<IList<string>> ExtractFields(char delimiter, char quote,
    string csvLine);
}

Después, agrego la propiedad siguiente a la clase CSVParser:

public IParserEngine ParserEngine { get; private set; }

Luego, ofrezco a los desarrolladores la posibilidad de elegir si usar el analizador predeterminado o insertar el suyo propio. Para que sea simple, sobrecargaré el constructor:

public CsvParser()
{
  InitializeFields();
  this.ParserEngine = new ParserEngines.DefaultParserEngine();
}
public CsvParser(IParserEngine parserEngine)        
{
  InitializeFields();
  this.ParserEngine = parserEngine;
}

La clase CSVParser ofrece ahora la infraestructura básica, pero la lógica de análisis real está contenida dentro de la interfaz IParserEngine. Por comodidad para los desarrolladores, creé DefaultParserEngine, que puede procesar la mayoría de archivos CSV. Tuve en cuenta los escenarios más probables que los desarrolladores se encontrarán.

El desafío del lector

He tenido en cuenta el grueso de escenarios que los desarrolladores se encontrarán con los archivos CSV. Sin embargo, la naturaleza indefinida del formato CSV hace que crear un analizador universal para todos los casos no sea práctico. La factorización de todas las variantes y casos límites agregaría un costo y una complejidad de costo significativos junto con el impacto en el rendimiento.

Estoy seguro de que hay por ahí archivos CSV "salvajes" que DefaultParserEngine no podrá controlar. Esto es lo que hace que el patrón de inserción de dependencias encaje estupendamente. Si los desarrolladores tienen la necesidad de un analizador que pueda controlar un caso límite extremo o escribir algo con un mejor rendimiento, les invito amablemente a hacerlo. Los motores analizadores se pueden intercambiar sin cambios en el código que consume.

El código de este proyecto está disponible en bit.ly/1To1IVI.

Resumen

Los archivos CSV son restos del pasado y, a pesar de los importantes esfuerzos de XML y JSON, continúan siendo un formato de intercambio de datos habitualmente utilizado. A los archivos CSV les falta un estándar o una especificación común y, aunque a menudo tienen rasgos comunes, no existe la certeza de que se den en un archivo determinado. Esto hace que analizar un archivo CSV no sea un ejercicio trivial.

Si tienen la opción, la mayoría de desarrolladores probablemente excluirían los archivos CSV de sus soluciones. Sin embargo, su presencia extendida en conjuntos de datos heredados de empresas y gobiernos puede descartar esa opción en muchos escenarios.

Sencillamente, existe la necesidad de un analizador de CSV para las aplicaciones para la Plataforma universal de Windows (UWP) y un analizador de CSV para el mundo real debe ser flexible y robusto. En el proceso, he mostrado un uso práctico para la inserción de dependencias a fin de ofrecer esa flexibilidad. Aunque esta columna y su código asociado tienen las aplicaciones para UWB como destino, el concepto y el código se aplican a otras plataformas capaces de ejecutar C#, como Microsoft Azure o el desarrollo del escritorio de Windows.


Frank La Vigne* es un experto en tecnología del equipo Microsoft Technology and Civic Engagement, donde ayuda a que los usuarios aprovechen la tecnología a fin de crear una comunidad mejor. Escribe publicaciones con regularidad en FranksWorld.com y tiene un canal de YouTube llamado Frank’s World TV (youtube.com/FranksWorldTV).*

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Rachel Appel