Každý vývojář již pravděpodobně někdy nějakým způsobem řešil zpracování textového souboru např. ve formátu CSV nebo podobném. Právě řešení takovéto úlohy si ukážeme. A ačkoliv je to záležitost na první pohled poměrně jednoduchá, uděláme to ze dvou důvodů. Zaprvé to uděláme obecně tj. navrhneme znovupoužitelné řešení, ve kterém oddělíme veškeré infrastrukturní záležitosti od vlastní “logiky” zpracování nějakého konkrétního souboru a zadruhé to uděláme správně resp. tím chci říct, že celé řešení budeme připravovat postupně tak, aby výsledný kód byl snadno udržovatelný, rozšiřovatelný, odladitelný a přehledný.
Ještě než budeme moct začít potřebujeme nějakou formální specifikaci zadání, mohlo by asi stačit toto:
Implementujte čtení informací o platných kurzech vydávané českou národní bankou. Soubor s daty se bude stahovat přímo z internetu z adresy:
http://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_ostatnich_men/kurzy.txt
Pro zpracování musí být spolu z daty jednotlivých kurzů k dispozici i datum platnosti kurzu z hlavičky souboru. Vlastní zpracování jednotlivých kurzu (zápis do databáze apod.) v řešení obsaženo nebude, může být simulováno např. pouze výpisem do konzole.
Čtení textového souboru po jednotlivých řádcích (implementace jako enumerátor, nikoliv přečtení celého souboru najednou) nám přibylo v .NET Frameworku ve verzi 4.0. Jedná se o metodu ReadLines() ve třídě File . První co ale uděláme je, že si tuto metodu naimplementujeme sami znovu, například předpokládejme, že naše řešení má fungovat i na .NET Frameworku 3.5 SP1.
Metoda ReadLines() bude umístěná ve statické třídě, kterou nazveme FileUtilities. Metodu uděláme ve dvou variantách, s explicitním určením encodingu a s encodingem výchozím. Výchozí encoding bude stejný jako u standardních funkcí přímo v .NET Frameworku tj. UTF-8. Také nesmíme zapomenout na provedení kontroly vstupních argumentů.
internal static class FileUtilities
{
#region action methods
#if FRAMEWORK35
public static IEnumerable<string> ReadLines(string path)
{
if (path == null)
{
throw new ArgumentNullException("path");
}
if (path.Length == 0)
{
throw new ArgumentException("path is empty string.", "path");
}
//TODO: Use path, Encoding.UTF8
}
public static IEnumerable<string> ReadLines(string path, Encoding encoding)
{
if (path == null)
{
throw new ArgumentNullException("path");
}
if (encoding == null)
{
throw new ArgumentNullException("encoding");
}
if (path.Length == 0)
{
throw new ArgumentException("path is empty string.", "path");
}
//TODO: Use path, encoding
}
#endif
#endregion
}
Dále také budeme chtít, aby se určený nebo výchozí encoding použil jen tehdy, když se nepodaří encoding zjistit přímo z vlastního souboru – viz můj komentář u tohoto příspěvku. Toto chování zajistíme otevřením vstupního souboru pomoci třídy StreamReader. Pomoci této třídy budeme také postupně provádět čtení souboru po jednotlivých řádcích. Protože ale implementujeme enumerátor, jistě víme , že musíme tuto část umístit do samostatné pomocné metody (alespoň jí budeme volat z obou variant naší metody). Také se na to můžeme dívat jako že implementujeme obyčejnou metodu, která vrací enumerátor.
Update: Byl jsem požádán (někým jiným než v případě níže) o vysvětlení i tohoto:
Každá metoda, co obsahuje klíčová slova yield break nebo yield return je kompilátorem automaticky chápána (již od .NET Frameworku 2.0) jako implementace enumerátoru/iterátoru tj. při kompilaci je vygenerován state machine, jehož implementace zaobaluje logiku iterátoru popsanou v původním kódu (detaily o tomto rewritingu lze nalézt např. zde nebo zde). Jedním z důsledků je pak i to, že volání dané metody v konečném výsledku pouze vytvoří a vrátí instanci třídy iterátoru a žádný kód napsaný uvnitř této metody se ještě v daný moment neprovádí. Ten se provádí až když “někdo“ na vrácený objekt volá metodu MoveNext(). Z tohoto důvodu nelze v implementaci iterátoru přímo provádět např. kontrolu vstupních argumentů apod. Řešení je umístit kód vlastního enumerátoru do samostatné metody, která je volána z metody původní.
internal static class FileUtilities
{
#region action methods
#if FRAMEWORK35
public static IEnumerable<string> ReadLines(string path)
{
if (path == null)
{
throw new ArgumentNullException("path");
}
if (path.Length == 0)
{
throw new ArgumentException("path is empty string.", "path");
}
return ReadLinesInternal(new StreamReader(path, Encoding.UTF8));
}
public static IEnumerable<string> ReadLines(string path, Encoding encoding)
{
if (path == null)
{
throw new ArgumentNullException("path");
}
if (encoding == null)
{
throw new ArgumentNullException("encoding");
}
if (path.Length == 0)
{
throw new ArgumentException("path is empty string.", "path");
}
return ReadLinesInternal(new StreamReader(path, encoding));
}
#endif
#endregion
#region private member functions
private static IEnumerable<string> ReadLinesInternal(TextReader reader)
{
//TODO: yield the lines, dispose reader
}
#endregion
}
Vstupní argument pomocné metody uděláme typu TextReader, aby byla metoda co nejobecnější. Použití konkrétní třídy místo základního typu nebo jen interfacu zbytečně degraduje použitelnost metody ze strany volajícího.
Dále je třeba ošetřit, aby jak po skončení čtení celého souboru tak i v případě přerušení čtení souboru (např. při vzniku výjimky během zpracovávání nějakého řádku) následně došlo ke korektnímu uvolnění používaného objektu StreamReader a tím k uzavření otevřeného souboru na disku. Zde nelze dělat nic jiného, než se spolehnout, že uživatel API zajistí (např. pomoci foreach) volání Dispose() metody enumerátoru, do které jsou kompilátorem převedeny všechny finally bloky enumerátoru.
Update: Byl jsem upozorněn, že si výše uvedená věta zaslouží vysvětlení:
Jde o to, že u enumerátoru (přesněji u “pull” modelu obecně) je velmi obtížné zajistit, aby se nějaká akce vykonala vždy po skončení nebo přerušení používání enumerátoru. Při implementaci enumerátoru v jazyku C# takové akci právě sémanticky odpovídá např. kód ve finally bloku. Problém spočívá v tom, že kód enumerátoru je spouštěn pouze voláním metody MoveNext() a tudíž když je exception vyhozena např. při zpracování dat vracených pomoci yield return (což není kód uvnitř MoveNext()) nemá nad tím enumerátor naprosto žádnou kontrolu. A řeší se to právě tím, že se kód z finally bloku umístí do Dispose() metody enumerátoru, jejíž volání musí kód, který enumerátor používá, zajistit (samozřejmě při použití foreach je toto zajištěno automaticky).
private static IEnumerable<string> ReadLinesInternal(TextReader reader)
{
using (reader)
{
while (true)
{
string line = reader.ReadLine();
if (line == null)
{
yield break;
}
yield return line;
}
}
}
Tím máme první část hotovou, my ale potřebujeme ještě doplnit podporu pro načtení hlavičky.
K tomu připravíme metodu:
public static IEnumerable<string> ReadHeaderAndLines(string path, out string header)
public static IEnumerable<string> ReadHeaderAndLines(string path, Encoding encoding, out string header)
Tato metoda bude podobná první metodě ReadLines(), pouze s tím rozdílem, že před voláním ReadLinesInternal() provede rovnou načtení prvního řádku tj. hlavičky (a případně vyhození výjimky pokud řádek neexistuje). A opět nesmíme zapomenout správně ošetřit případné chybové stavy, pro které musí dojít k volání Dispose() na již inicializovaném readeru.
public static IEnumerable<string> ReadHeaderAndLines(string path, Encoding encoding, out string header)
{
if (path == null)
{
throw new ArgumentNullException("path");
}
if (encoding == null)
{
throw new ArgumentNullException("encoding");
}
if (path.Length == 0)
{
throw new ArgumentException("path is empty string.", "path");
}
var reader = new StreamReader(path, encoding);
try
{
header = reader.ReadLine();
if (header == null)
{
throw new EndOfStreamException("Header row not found.");
}
}
catch
{
reader.Dispose();
throw;
}
return ReadLinesInternal(reader);
}
public static IEnumerable<string> ReadHeaderAndLines(string path, out string header)
{
return ReadHeaderAndLines(path, Encoding.UTF8, out header);
}
Ještě nám chybí vyřešit download souboru a ukázat vlastní použití naší třídy, to si ale necháme až na příště.