Параметры сопоставления CSV с .NET.

Я просматриваю параметры разделенного файла (например, CSV, вкладка и т.д.), основанные на MS-стеке в целом и .net. Единственная технология, которую я исключаю, - это SSIS, потому что я уже знаю, что она не удовлетворит мои потребности.

Итак, мои параметры выглядят следующим образом:

У меня есть два критерия, которые я должен выполнить. Во-первых, учитывая следующий файл, который содержит две логические строки данных (и пять физических строк):

101, Bob, "Keeps his house ""clean"".
Needs to work on laundry."
102, Amy, "Brilliant.
Driven.
Diligent."

Анализируемые результаты должны приводить к двум логическим "строкам", состоящим из трех строк (или столбцов). Третья строка строки/столбца должна сохранять символы новой строки! Говоря иначе, анализатор должен распознавать, когда строки "продолжают" на следующую физическую строку из-за "незакрытого" текстового классификатора.

Второй критерий заключается в том, что разделитель и текстовый определитель должны быть настраиваемыми для каждого файла. Вот две строки, взятые из разных файлов, которые я должен разбирать:

var first = @"""This"",""Is,A,Record"",""That """"Cannot"""", they say,"","""",,""be"",rightly,""parsed"",at all";
var second = @"~This~|~Is|A|Record~|~ThatCannot~|~be~|~parsed~|at all";

Собственный синтаксический анализ строки "first" будет выглядеть следующим образом:

  • Это
  • Есть, А, запись
  • То, что "не может", говорят,
  • _
  • _
  • будет
  • правильно
  • разобран
  • вообще

"_" просто означает, что был захвачен пробел - я не хочу, чтобы появился литерал.

Можно сделать одно важное предположение о анализируемых плоских файлах: будет фиксированное количество столбцов на файл.

Теперь для погружения в технические параметры.

REGEX

Во-первых, многие респонденты комментируют, что регулярное выражение "не лучший способ" для достижения цели. Однако я нашел комментатор который предложил превосходное регулярное выражение CSV:

var regex = @",(?=(?:[^""]*""[^""]*"")*(?![^""]*""))";
var Regex.Split(first, regex).Dump();

Результаты, примененные к строке "first", весьма замечательны:

  • "This"
  • "Есть, A, запись"
  • "Это" "Не могу", говорят они, "
  • ""
  • _
  • "быть"
  • правильно
  • "разобранный"
  • вообще

Было бы неплохо, если бы кавычки были очищены, но я могу легко справиться с этим как шаг после процесса. В противном случае этот подход может использоваться для синтаксического анализа строк выборки "первым" и "вторым" при условии, что регулярное выражение модифицировано для символов тильды и труб соответственно. Отлично!

Но реальная проблема относится к многострочным критериям. Прежде чем регулярное выражение может быть применено к строке, я должен прочитать полную логическую "строку" из файла. К сожалению, я не знаю, сколько физических строк нужно прочитать для завершения логической строки, если у меня нет регулярного выражения/конечного автомата.

Итак, это становится проблемой "курица и яйцо". Моим лучшим вариантом было бы прочитать весь файл в памяти как одну гигантскую строку, и пусть регулярное выражение будет сортировать несколько строк (я не проверял, может ли это обработать вышеописанное выражение). Если у меня 10-гигабайтный файл, это может быть немного опасно.

В следующий раз.

TextFieldParser

Три строки кода сделают проблему с этой возможностью очевидной:

var reader = new Microsoft.VisualBasic.FileIO.TextFieldParser(stream);
reader.Delimiters = new string[] { @"|" };
reader.HasFieldsEnclosedInQuotes = true;

Конфигурация Delimiters выглядит неплохо. Однако "HasFieldsEnclosedInQuotes" - это "игра". Я ошеломлен тем, что разделители произвольно конфигурируются, но, напротив, у меня нет другого параметра выбора, кроме котировок. Помните, мне нужна настраиваемость над спецификатором текста. Итак, если кто-то не знает трюк конфигурации TextFieldParser, это игра.

OLEDB

Коллега говорит мне, что этот вариант имеет два основных недостатка. Во-первых, он имеет ужасную производительность для больших (например, 10-гигабайтных) файлов. Во-вторых, поэтому мне говорят, что он предпочел типы данных ввода данных, а не позволял вам указывать. Нехорошо.

HELP

Поэтому я хотел бы знать факты, в которых я ошибался (если они есть), и другие варианты, которые я пропустил. Возможно, кто-то знает способ для присяжных - TextFieldParser использовать произвольный разделитель. И, возможно, OLEDB разрешил указанные проблемы (или, возможно, никогда их не видел?).

Что вы говорите?

Ответ 1

Вы пытались найти уже существующий .NET парсер CSV? Этот утверждает, что обрабатывает многострочные записи значительно быстрее, чем OLEDB.

Ответ 2

Я написал это некоторое время назад как легкий, автономный синтаксический анализатор CSV. Я считаю, что он отвечает всем вашим требованиям. Дайте ему попробовать, зная, что он, вероятно, не пуленепробиваемый.

Если это сработает для вас, не стесняйтесь изменять пространство имен и использовать без ограничений.

namespace NFC.Portability
{
    using System;
    using System.Collections.Generic;
    using System.Data;
    using System.IO;
    using System.Linq;
    using System.Text;

    /// <summary>
    /// Loads and reads a file with comma-separated values into a tabular format.
    /// </summary>
    /// <remarks>
    /// Parsing assumes that the first line will always contain headers and that values will be double-quoted to escape double quotes and commas.
    /// </remarks>
    public unsafe class CsvReader
    {
        private const char SEGMENT_DELIMITER = ',';
        private const char DOUBLE_QUOTE = '"';
        private const char CARRIAGE_RETURN = '\r';
        private const char NEW_LINE = '\n';

        private DataTable _table = new DataTable();

        /// <summary>
        /// Gets the data contained by the instance in a tabular format.
        /// </summary>
        public DataTable Table
        {
            get
            {
                // validation logic could be added here to ensure that the object isn't in an invalid state

                return _table;
            }
        }

        /// <summary>
        /// Creates a new instance of <c>CsvReader</c>.
        /// </summary>
        /// <param name="path">The fully-qualified path to the file from which the instance will be populated.</param>
        public CsvReader( string path )
        {
            if( path == null )
            {
                throw new ArgumentNullException( "path" );
            }

            FileStream fs = new FileStream( path, FileMode.Open );
            Read( fs );
        }

        /// <summary>
        /// Creates a new instance of <c>CsvReader</c>.
        /// </summary>
        /// <param name="stream">The stream from which the instance will be populated.</param>
        public CsvReader( Stream stream )
        {
            if( stream == null )
            {
                throw new ArgumentNullException( "stream" );
            }

            Read( stream );
        }

        /// <summary>
        /// Creates a new instance of <c>CsvReader</c>.
        /// </summary>
        /// <param name="bytes">The array of bytes from which the instance will be populated.</param>
        public CsvReader( byte[] bytes )
        {
            if( bytes == null )
            {
                throw new ArgumentNullException( "bytes" );
            }

            MemoryStream ms = new MemoryStream();
            ms.Write( bytes, 0, bytes.Length );
            ms.Position = 0;

            Read( ms );
        }

        private void Read( Stream s )
        {
            string lines;

            using( StreamReader sr = new StreamReader( s ) )
            {
                lines = sr.ReadToEnd();
            }

            if( string.IsNullOrWhiteSpace( lines ) )
            {
                throw new InvalidOperationException( "Data source cannot be empty." );
            }

            bool inQuotes = false;
            int lineNumber = 0;
            StringBuilder buffer = new StringBuilder( 128 );
            List<string> values = new List<string>();

            Action endSegment = () =>
            {
                values.Add( buffer.ToString() );
                buffer.Clear();
            };

            Action endLine = () =>
            {
                if( lineNumber == 0 )
                {
                    CreateColumns( values );
                    values.Clear();
                }
                else
                {
                    CreateRow( values );
                    values.Clear();
                }

                values.Clear();
                lineNumber++;
            };

            fixed( char* pStart = lines )
            {
                char* pChar = pStart;
                char* pEnd = pStart + lines.Length;

                while( pChar < pEnd ) // leave null terminator out
                {
                    if( *pChar == DOUBLE_QUOTE )
                    {
                        if( inQuotes )
                        {
                            if( Peek( pChar, pEnd ) == SEGMENT_DELIMITER )
                            {
                                endSegment();
                                pChar++;
                            }
                            else if( !ApproachingNewLine( pChar, pEnd ) )
                            {
                                buffer.Append( DOUBLE_QUOTE );
                            }
                        }

                        inQuotes = !inQuotes;
                    }
                    else if( *pChar == SEGMENT_DELIMITER )
                    {
                        if( !inQuotes )
                        {
                            endSegment();
                        }
                        else
                        {
                            buffer.Append( SEGMENT_DELIMITER );
                        }
                    }
                    else if( AtNewLine( pChar, pEnd ) )
                    {
                        if( !inQuotes )
                        {
                            endSegment();
                            endLine();
                            pChar++;
                        }
                        else
                        {
                            buffer.Append( *pChar );
                        }
                    }
                    else
                    {
                        buffer.Append( *pChar );
                    }

                    pChar++;
                }
            }

            // append trailing values at the end of the file
            if( values.Count > 0 )
            {
                endSegment();
                endLine();
            }
        }

        /// <summary>
        /// Returns the next character in the sequence but does not advance the pointer. Checks bounds.
        /// </summary>
        /// <param name="pChar">Pointer to current character.</param>
        /// <param name="pEnd">End of range to check.</param>
        /// <returns>
        /// Returns the next character in the sequence, or char.MinValue if range is exceeded.
        /// </returns>
        private char Peek( char* pChar, char* pEnd )
        {
            if( pChar < pEnd )
            {
                return *( pChar + 1 );
            }

            return char.MinValue;
        }

        /// <summary>
        /// Determines if the current character represents a newline. This includes lookahead for two character newline delimiters.
        /// </summary>
        /// <param name="pChar"></param>
        /// <param name="pEnd"></param>
        /// <returns></returns>
        private bool AtNewLine( char* pChar, char* pEnd )
        {
            if( *pChar == NEW_LINE )
            {
                return true;
            }

            if( *pChar == CARRIAGE_RETURN && Peek( pChar, pEnd ) == NEW_LINE )
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// Determines if the next character represents a newline, or the start of a newline.
        /// </summary>
        /// <param name="pChar"></param>
        /// <param name="pEnd"></param>
        /// <returns></returns>
        private bool ApproachingNewLine( char* pChar, char* pEnd )
        {
            if( Peek( pChar, pEnd ) == CARRIAGE_RETURN || Peek( pChar, pEnd ) == NEW_LINE )
            {
                // technically this cheats a little to avoid a two char peek by only checking for a carriage return or new line, not both in sequence
                return true;
            }

            return false;
        }

        private void CreateColumns( List<string> columns )
        {
            foreach( string column in columns )
            {
                DataColumn dc = new DataColumn( column );
                _table.Columns.Add( dc );
            }
        }

        private void CreateRow( List<string> values )
        {
            if( values.Where( (o) => !string.IsNullOrWhiteSpace( o ) ).Count() == 0 )
            {
                return; // ignore rows which have no content
            }

            DataRow dr = _table.NewRow();
            _table.Rows.Add( dr );

            for( int i = 0; i < values.Count; i++ )
            {
                dr[i] = values[i];
            }
        }
    }
}

Ответ 3

Взгляните на код, который я отправил на этот вопрос:

fooobar.com/info/390209/...

Он охватывает большинство ваших требований, и не потребуется многого для его обновления, чтобы поддерживать альтернативные разделители или текстовые классификаторы.