Veuillez activer Javascript pour voir le contenu

[.NET] Comment lire un très gros fichier csv

· ☕ 10 min de lecture

Le traitement d’un fichier csv de plusieurs Go peut vite être coûteux en terme de performance.
Pour rappel un fichier csv n’est pas seulement un format qui sépare ses colonnes par un caractère, mais c’est aussi:

  • des entêtes présentes ou non,
  • des colonnes parfois inexistantes,
  • des lignes vides,
  • des guillemets pour représenter une colonne,
  • des guillemets dans des guillemets pour représenter des guillemets dans une colonne…

Bref, la liste est encore longue et ça peut vite devenir un vrai casse tête de gérer tous les cas.
A cela s’ajoute qu’il faut la plupart du temps lier les colonnes à des objets de notre code, que dans le cas d’un très gros fichier il n’est pas envisageable de charger le fichier en mémoire et qu’il faudra lire à même le stream ce qui peut apporter d’autres problématiques (heureusement en .NET cette dernière problématique reste extrêmement simple à solutionner) cela peut devenir relativement compliqué.

Bien entendu d’autres se sont pris la tête là-dessus, c’est pour cette raison qu’il existe un grand nombre de framework de lecture/écriture de fichier csv qui gère la totalité des problématiques liées à ce format.

TextFieldParser

Le framework .NET propose une solution de base avec TextFieldParser.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[Benchmark]
public void TextFieldParser()
{
    using var streamReader = new StreamReader(_csvFilePath);
    TextFieldParser parser = new TextFieldParser(_csvFilePath)
    {
        HasFieldsEnclosedInQuotes = true,
        Delimiters = new[] { "," }
    };
    string[] fields;
    while ((fields = parser.ReadFields()) != null)
    {
        Foo.CreateFromFields(fields);
        // ...
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Foo
{
    public string Prop0 { get; set; }
    public string Prop1 { get; set; }
    public string Prop2 { get; set; }

    public static Foo CreateFromFields(string[] fields)
    {
        return new Foo
        {
            Prop0 = fields[0],
            Prop1 = fields[1],
            Prop2 = fields[2],
        };
    }
}

CsvHelper

sources: https://github.com/JoshClose/CsvHelper

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[Benchmark]
public void CsvHelper()
{
    using var streamReader = new StreamReader(_csvFilePath);
    var csvconfig = new CsvConfiguration(CultureInfo.CurrentCulture) { Delimiter = ",", HasHeaderRecord = false };
    csvconfig.RegisterClassMap<CsvHelperFooMapping>();
    var csv = new CsvReader(streamReader, csvconfig);
    using var records = csv.GetRecords<Foo>().GetEnumerator();
    while (records.MoveNext())
    {
        // ...
    }
}
1
2
3
4
5
6
7
8
9
public sealed class CsvHelperFooMapping: ClassMap<Foo>
{
    public CsvHelperFooMapping()
    {
        Map(x => x.Prop0).Index(0);
        Map(x => x.Prop1).Index(1);
        Map(x => x.Prop2).Index(2);
    }
}

TinyCsvParser

sources: https://github.com/bytefish/TinyCsvParser.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public void TinyCsvParser()
{
    var csvParserOptions = new CsvParserOptions(false, ',', Environment.ProcessorCount, false);
    var csvMapper = new TinyFooMapping();
    var csvParser = new CsvParser<Foo>(csvParserOptions, csvMapper);
    using var records = csvParser.ReadFromFile(_csvFilePath, Encoding.UTF8).GetEnumerator();
    while (records.MoveNext())
    {
        // ...
    }
}
1
2
3
4
5
6
7
8
9
public sealed class TinyFooMapping : CsvMapping<Foo>
{
    public TinyFooMapping()
    {
        MapProperty(0, x => x.Prop0);
        MapProperty(1, x => x.Prop1);
        MapProperty(2, x => x.Prop2);
    }
}

Résultat (100 000 lignes + ssd)

Method Mean %
TextFieldParser 6,617.43 ms 0%
CsvHelper 4,018.37 ms -39 %
TinyCsvParser 1,062.29 ms -84 %

Doit-on d’office exclure CsvHelper?
Non, comme toujours en développement rien n’est blanc ou noir.
Dans ce cas spécifique (un csv propre, une configuration de base et une lecture d’un gros fichier) TinyCsvParser est préférable en revanche dans d’autres scénarii CsvHelper apporte des fonctionnalités très intéressantes (comme l’auto-mapping) qui peuvent faire gagner un précieux temps de développement et de maintenance.
Bref cela dépend de la situation comme toujours.

Mais comment expliquer de tels décalages ?

Principalement cela est dû au traitement des lignes du CSV pour gérer tous les cas et la création des entités.
Nous avons tendance à se ruer sur des framework pour des choses simples, ces framework sont là pour gérer tous les cas ce qui apporte un confort de développement, de la fiabilité mais malheureusement aussi une lourdeur dans les traitements.
J’ai récemment dû parser un fichier de 30Go “propre” (virgule + parfois des guillemets) dont je connais parfaitement le format, et j’ai voulu voir combien coûte “la gestion de tous les cas”.

Solution personnalisée

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[Benchmark]
public void Custom()
{
    using var streamReader = new StreamReader(_csvFilePath);
    string line;
    while ((line = streamReader.ReadLine()) != null)
    {
        Foo.CreateFromCsvLine(line);
        // ...
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Foo
{
  ...

  public static Foo CreateFromCsvLine(string line)
  {
      return CreateFromFields(ExtractFields(line, 3));
  }

  private static string[] ExtractFields(string line, int propertyCount)
  {
      var result = new string[propertyCount];
      var index = 0;
      bool isInQuotes = false;
      var chars = line.ToCharArray();
      StringBuilder str = new StringBuilder(string.Empty);
      foreach (var t in chars)
      {
          if (t == '"')
          {
              isInQuotes = !isInQuotes;
          }
          else if (t == ',' && !isInQuotes)
          {
              result[index++] = str.ToString();
              str.Clear();
          }
          else
          {
              str.Append(t);
          }
      }
      result[index] = str.ToString();

      return result;
  }
}

L’algorithme d’analyse des lignes csv est simple et limitée mais correspond à mon besoin.
Et voici le résultat :

Method Mean %
TextFieldParser 6,617.43 ms 0%
CsvHelper 4,018.37 ms -39 %
TinyCsvParser 1,062.29 ms -84 %
Custom 1,083.97 ms -83 %

Comment TinyCsvParser peut-il encore être plus performant ? Simplement car ce framework utilise de base le parallélisme. Nous allons en faire de même pour la science.

Solution personnalisée avec parallélisme

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[Benchmark]
public void CustomParallel()
{
    using var streamReader = new StreamReader(_csvFilePath);
    var enumerator = new StreamReaderEnumerable(streamReader);
    var po = new ParallelOptions
    {
        // just for demo
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };

    var action = new Action<string>(line =>
    {
        Foo.CreateFromCsvLine(line);
        // ...
    });

    Parallel.ForEach(enumerator, po, action);
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
/// <summary>
/// This method comes from <see href="!:https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.ienumerable-1.etenumerator">msdn</see>
/// </summary>
public class StreamReaderEnumerable : IEnumerable<string>
{
    private readonly StreamReader _sr;
    public StreamReaderEnumerable(StreamReader streamReader)
    {
        _sr = streamReader;
    }

    // Must implement GetEnumerator, which returns a new StreamReaderEnumerator.
    public IEnumerator<string> GetEnumerator()
    {
        return new StreamReaderEnumerator(_sr);
    }

    // Must also implement IEnumerable.GetEnumerator, but implement as a private method.
    private IEnumerator GetEnumerator1()
    {
        return GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator1();
    }
}

/// <summary>
/// This method comes from <see href="!:https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.ienumerable-1.etenumerator">msdn</see>
/// </summary>
public class StreamReaderEnumerator : IEnumerator<string>
{
    private readonly StreamReader _sr;

    public StreamReaderEnumerator(StreamReader streamReader)
    {
        _sr = streamReader;
    }

    private string _current;
    // Implement the IEnumerator(T).Current publicly, but implement
    // IEnumerator.Current, which is also required, privately.
    public string Current
    {

        get
        {
            if (_sr == null || _current == null)
            {
                throw new InvalidOperationException();
            }

            return _current;
        }
    }

    private object Current1 => this.Current;

    object IEnumerator.Current => Current1;

    // Implement MoveNext and Reset, which are required by IEnumerator.
    public bool MoveNext()
    {
        _current = _sr.ReadLine();

        return _current != null;
    }

    public void Reset()
    {
        _sr.DiscardBufferedData();
        _sr.BaseStream.Seek(0, SeekOrigin.Begin);
        _current = null;
    }

    // Implement IDisposable, which is also implemented by IEnumerator(T).
    private bool _disposedValue;
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                // Dispose of managed resources.
            }
            _current = null;
            if (_sr != null)
            {
                _sr.Close();
                _sr.Dispose();
            }
        }

        _disposedValue = true;
    }

    ~StreamReaderEnumerator()
    {
        Dispose(false);
    }
}
Method Mean %
TextFieldParser 6,617.43 ms 0%
CsvHelper 4,018.37 ms -39 %
TinyCsvParser 1,062.29 ms -84 %
Custom 1,083.97 ms -83 %
CustomParallel 632.97 ms -90 %
Le temps est divisé par 2 par rapport à TinyCsvParser. Au final, sur mon fichier de 30Go je suis passé de 7min à 3min20 de traitement de manière simple.

Conclusion

Les performances d’une solution personnalisée laissent rêveur, néanmoins ce code ne répond qu’à un seul et unique cas: les très gros fichiers csv propre et simple.
Mais dans le cas où vous ayez un grand nombre de fichier csv avec des “qualités” variables, “il est fortement recommandé” de passer par un framework tel que CsvHelper ou TinyCsvParser où un grand nombre de bons développeurs ont pu analyser les performances de chaque ligne de code permettant de gérer tout les cas.

Method Mean %
TextFieldParser 253.18 us 0%
CsvHelper 991.40 us +291 %
TinyCsvParser 653.78 us +158 %
Custom 50.99 us -79 %
CustomParallel 113.25 us -55 %
Ce qui prouve encore qu’il n’y a pas de magie en développement, que tout dépend du contexte et annexement que le parallélisme est très souvent profitable et recommandé mais peut aussi être un piège.

Mise à jour (06/08/2020)

J’ai eu le plaisir d’avoir un retour volontaire du développeur principal de TinyCsvParser, ce dernier apporte une solution intéressante pour utiliser le framework avec un analyseur personnalisé (Tokenizer), vous pouvez voir plus de détails directement sur le repo du projet ou l’exemple de ce post a été inclus et fournit plus de détail sur comment son framework fonctionne.

Au delà de ça, son retour (présent ici) me fait revoir/nuancer le message de ce post.
En effet la réponse pour savoir si oui ou non un framework est à utiliser pour ce besoin spécifique est plus compliqué que de premier abord (plus compliqué que beaucoup = framework / un cas = personnalisé ou framework).
J’ai voulu dans un premier temps traiter le sujet ici, mais après réflexion beaucoup de paramètres rentrent en jeu (le contexte, le budget, les compétences, le risque, les resources, le besoin…), cela fera l’objet d’un post annexe plus globale qui apportera non pas une réponse (je pense que cela est impossible) mais un point de vue sur la question.

Sources

Documentation

Partager sur

Jérémy Landon
ÉCRIT PAR
Jérémy Landon
Freelance / Author / Speaker / Open source contributor

Contenu de cette page