19
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【C#】TextFieldParserで100万行のCSVファイルに挑む。

Last updated at Posted at 2018-08-17

イントロダクション

どうやら続きものっぽい

こちらの記事の続きです。
【C#】TextFieldParserで郵便番号データを処理する【ほぼパクリ】 - Qiita

このコードで100万行のCSVファイルに耐えられるか…というかもっと高速で実行できる処理はないんだろか。と考えて色々実験してみました。

※今回のコード全て、実行にはMicrosoft.VisualBasicの参照設定が必要です☆
参照設定.png

100万本のバラの花を

もとい、100万行のCSVファイルを手っ取り早くつくるのに、前回使った郵便番号データ(約12万行)をサクラエディタ上でコピペして10倍に増やしました。あと、実データを考えたときにはこいつはフィールド数が少ないな、と思ったのでこれもサクラエディタ上で矩形貼り付けを使って70項目に増やしました。ファイル容量的には約600MBとまあまあの量です。

環境

こんな感じです。まあ普通じゃない?

caa50054-e470-1679-1243-ea106e248ee6.png

FileStream全体をTextFieldParserで処理(speedtest1)

まずは前回記事のコードをちょっとだけ改変。単一のクラスにまとめて実行開始時間・実行終了時間の表示を追加しました。基本的な処理は変えてない。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using Microsoft.VisualBasic.FileIO;

namespace speedtest1
{
    class Program
    {
        static void Main(string[] args)
        {
            //開始時の現在時刻を取得しておく
            DateTime dt = DateTime.Now;

            // 郵便番号データのコンテキストを生成
            IEnumerable<string[]> context = Context(
                @"C:\うんたらかんたら\KEN_ALL.CSV", ",", Encoding.GetEncoding(932));

            // 結果をコンソールに出す
            foreach (string[] result in context.AsParallel())
            {
                foreach (string field in result)
                {
                    Console.Write(field + " ");
                }
                Console.WriteLine("");
            }

            //開始時の現在時刻を表示
            Console.WriteLine(dt);
            //終了時の現在時刻を表示
            Console.WriteLine(DateTime.Now);

            Console.WriteLine("Enterで終了");
            Console.ReadLine();

        }

        // 指定されたCSVファイルへのコンテキストを生成する
        public static IEnumerable<string[]> Context(
            string path, string separator = ",", Encoding encoding = null)
        {
            using (Stream stream =
                new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                using (TextFieldParser parser =
                    new TextFieldParser(stream, encoding ?? Encoding.UTF8, true, false))
                {
                    parser.TextFieldType = FieldType.Delimited;
                    parser.Delimiters = new[] { separator };
                    parser.HasFieldsEnclosedInQuotes = true;
                    parser.TrimWhiteSpace = true;
                    while (parser.EndOfData == false)
                    {
                        string[] fields = parser.ReadFields();
                        yield return fields;
                    }
                }
            }
        }
    }
}

System.IO.Fileクラスでいろいろ(speedtest2)

高速化を目指していろいろやってみました。

まずはFile.ReadAllTextに挫折。

単純に「c# テキスト 読み込み 最速」とかそういうキーワードで調べると、.net系の場合テキストをLine読み込みするよりもテキスト全体をガツンと読み込んでから処理するほうが早い!と言われるのでやってみようとしました。

………………「型 'System.OutOfMemoryException' の初回例外が mscorlib.dll で発生しました」と怒られてしまいました。メモリ不足?どうも、環境的に力不足でファイル全体を処理しきれないみたいです。
…?いやもしかして環境に関係なくそもそも100万件のテキストだとFile.ReadAllTextで処理し切れんのだろか。例外の内容を細かく解析してそこらへんハッキリさせてみたい気もするものの、他に試したいこともあるので放っておいて次へ。

File.ReadAllLines + forでループ

しかたないのでFile.ReadAllLinesっすよ。

作戦としてはFile.ReadAllLinesで読み込んだ一行のテキストを、一行ずつ個別に無理やっこTextFieldParserで処理していこうと。…それで速くなるかはまあ、やってみましょうっていう。

さらに。foreachではなくforでループしてみたい!と思ったのですがそれにはまず、本処理の前にファイルの「行数」を確保しておかないといけないですよね…?

行数取得するだけでえらい時間かかるんじゃね?と思っていたら**意外と速い。**今回のテスト環境だと数秒で行数を取得できてしまう。というわけで最初に行数を取得して(そして列数は固定値にして)forでループを回す処理がこちら。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using Microsoft.VisualBasic.FileIO;

namespace speedtest2
{
    class Program
    {
        static void Main(string[] args)
        {
            //開始時の現在時刻を取得しておく
            DateTime dt = DateTime.Now;

            Console.WriteLine("ファイルの行数を取得しています....(数分かかる場合があります)");
            var readToEnd = File.ReadAllLines(@"C:\なんたらかんたら\KEN_ALL.CSV", Encoding.GetEncoding(932));
            int lines = readToEnd.Length;

            // 結果をコンソールに出す
            for (int i = 0; i < lines; i++)
            {
                //1行のstringをstream化してTextFieldParserで処理する
                using (Stream stream = new MemoryStream(Encoding.Default.GetBytes(readToEnd[i])))
                {
                    using (TextFieldParser parser = new TextFieldParser(stream, Encoding.GetEncoding(932)))
                    {
                        parser.TextFieldType = FieldType.Delimited;
                        parser.Delimiters = new[] { "," };
                        parser.HasFieldsEnclosedInQuotes = true;
                        parser.TrimWhiteSpace = false;
                        string[] fields = parser.ReadFields();
                        // 結果をコンソールに出す(列数70固定と仮定する)
                        for (int j = 0; j < 70; j++)
                        {
                            Console.Write(fields[j] + " ");
                        }
                        Console.WriteLine("");
                    }
                }
            }

            //開始時の現在時刻を表示
            Console.WriteLine(dt);
            //終了時の現在時刻を表示
            Console.WriteLine(DateTime.Now);

            Console.WriteLine("Enterで終了");
            Console.ReadLine();
        }
    }
}

System.IO.StreamReaderクラスも使ってみる。

同じようなこと(定番のファイル読み込み処理をしつつ一行のテキストを無理矢理TextFieldParse)をStreamReaderでもやってみようかと

StreamReader.ReadToEndで挫折アゲイン。

StreamReaderでやってもファイル全体読み込みは同じくコケるだろうな、と思っていたら案の定。

forで出来ないじゃん

「System.IO.StreamReaderクラスには行数を取得するメソッドが用意されていない」そうなんだ…。

StreamReader.ReadLine + whileでループ(speedtest3)

というわけでwhileループの中でTextFieldParser。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using Microsoft.VisualBasic.FileIO;

namespace speedtest3
{
    class Program
    {
        static void Main(string[] args)
        {
            //開始時の現在時刻を取得しておく
            DateTime dt = DateTime.Now;

            using (var sr = new StreamReader(@"C:\なんとかかんとか\KEN_ALL.CSV", Encoding.GetEncoding(932)))
            {
                while (sr.Peek() > -1)
                {
                    //1行のstringをstream化してTextFieldParserで処理する
                    using (Stream stream = new MemoryStream(Encoding.Default.GetBytes(sr.ReadLine())))
                    {
                        using (TextFieldParser parser = new TextFieldParser(stream, Encoding.GetEncoding(932)))
                        {
                            parser.TextFieldType = FieldType.Delimited;
                            parser.Delimiters = new[] { "," };
                            parser.HasFieldsEnclosedInQuotes = true;
                            parser.TrimWhiteSpace = false;
                            string[] fields = parser.ReadFields();
                            // 結果をコンソールに出す(列数70固定と仮定する)
                            for (int j = 0; j < 70; j++)
                            {
                                Console.Write(fields[j] + " ");
                            }
                            Console.WriteLine("");
                        }
                    }
                }
            }

            //開始時の現在時刻を表示
            Console.WriteLine(dt);
            //終了時の現在時刻を表示
            Console.WriteLine(DateTime.Now);

            Console.WriteLine("Enterで終了");
            Console.ReadLine();
        }
    }
}

そして結果。

計測結果その1。

プログラム名 実行時間
speedtest1 52分
speedtest2  26分
speedtest3 29分

最初のコードだけ妙に時間かかってるな…?

蛇足・最初のコードをさらに改良(speedtest4)

最初のコード、他のコードと同じようにフィールドなめるところで固定値の列数分forで回すように改良したら高速化するかなー、と思ってやってみました。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using Microsoft.VisualBasic.FileIO;

namespace speedtest4
{
    class Program
    {
        static void Main(string[] args)
        {
            //開始時の現在時刻を取得しておく
            DateTime dt = DateTime.Now;

            // 郵便番号データのコンテキストを生成
            IEnumerable<string[]> context = Context(
                @"C:\なんでんかんでん\KEN_ALL.CSV", ",", Encoding.GetEncoding(932));

            // 結果をコンソールに出す
            foreach (string[] result in context.AsParallel())
            {
                // 結果をコンソールに出す(列数70固定と仮定する)
                for (int j = 0; j < 70; j++)
                {
                    Console.Write(result[j] + " ");
                }
                Console.WriteLine("");
            }

            //開始時の現在時刻を表示
            Console.WriteLine(dt);
            //終了時の現在時刻を表示
            Console.WriteLine(DateTime.Now);

            Console.WriteLine("Enterで終了");
            Console.ReadLine();

        }

        // 指定されたCSVファイルへのコンテキストを生成する
        public static IEnumerable<string[]> Context(
            string path, string separator = ",", Encoding encoding = null)
        {
            using (Stream stream =
                new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                using (TextFieldParser parser =
                    new TextFieldParser(stream, encoding ?? Encoding.UTF8, true, false))
                {
                    parser.TextFieldType = FieldType.Delimited;
                    parser.Delimiters = new[] { separator };
                    parser.HasFieldsEnclosedInQuotes = true;
                    parser.TrimWhiteSpace = true;
                    while (parser.EndOfData == false)
                    {
                        string[] fields = parser.ReadFields();
                        yield return fields;
                    }
                }
            }
        }
    }
}

計測結果その2。

プログラム名 実行時間
speedtest1 52分
speedtest4 29分

列に対するループをforeachからforに変えただけでこの違い。たかだか要素数70に対してforeachするのと、forを70回するのとでは微々たる差でしょうが、それが×100万回となると大差になるということのようです。

結論。

今回の結果としては「どれもそんなに変わらんけどFile.ReadAllLines + forでループが多少速いようですね」ってことにはなるんですけど、単純に取得したフィールドをコンソールに出すだけでコレですから。いずれの方法でも実処理は結構な時間かかるということになります。

なので。
実用プログラムに応用する上でも、全体の行数を最初に取得してしまう「File.ReadAllLines + forでループ方式」を応用して、全体の行数から見た現在の処理行数のパーセンテージなど取得してプログレスバー表示するとか、そういうインターフェイスをかませた方が無難かと。
そういう点でも、**「File.ReadAllLines + forでループ方式」**が良さそうです。

読み込み時点でファイル全体をなんらか処理(集計・ソート)したい場合は最初の、FileStream全体をTextFieldParserで処理する方式で、結果をLINQ操作するやり方でもいいかもしれないですが、ファイルそのものが巨大だったらそのへんの処理はやはりいったんDBに格納してからやった方がいいのかな…などと考えたりもしています。まあそこらへんまでいくと今回のテーマからだんだん離れていくので、今回はこのへんで。

以上です。

19
19
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?