čtvrtek 22. června 2017

Funkcionální programování podruhé


Jak implementovat funkcionální přístup ukážu na jednoduchém validátoru CSV souboru. Je to poměrně jednoduchá úloha s těmito třemi zásadními operacemi:

  • z prvního řádku je nutné získat seznam sloupců
  • z následujících pak
    • získat seznam hodnot název sloupce - hodnota
    • jeden ze sloupců obsahuje primární klíč, duplicitní řádky tedy musíme vyloučit


Jak by třeba vypadala třída na kontrolu duplicity řádka před zavedením Result?  Nemusí to být nic světoborného, prostě si zapamatujeme, co jsme už zpracovali a podle toho zpracování dat povolíme a nebo ne:

public class DuplicateChecker
{
    private readonly Dictionary<stringint> map = new Dictionary<stringint>();
 
    bool CanProcessLine(string id, int lineNumber)
    {
        if (map.ContainsKey(id))
        {
            var processedLine = map[id];
 
            if (processedLine != lineNumber)
            {
                return false;
            }
        }
        else
        {
            map.Add(id, lineNumber);
        }
 
        return true;
    }
}

Myslím, že princip je vcelku jednoduchý. Ale vřele doporučuji shlédnout nějaké video od pána, co se jmenuje Kevlin Henney a zjistíme, že vše lze zlepšit. Pro inspiraci nabízím například tento záznam jeho přednášky z IIT 2016:



A třídu pak můžeme upravit takto:

public class Condition
{
    private readonly Dictionary<stringint> alreadyProcessLines = new Dictionary<stringint>();
 
    public bool LineHasNotBeenProcessed(string id, int lineNumber)
    {
        if (alreadyProcessLines.ContainsKey(id))
        {
            var processedLineNumberWithSameId = alreadyProcessLines[id];
 
            if (processedLineNumberWithSameId != lineNumber)
            {
                return false;
            }
        }
        else
        {
            alreadyProcessLines.Add(id, lineNumber);
        }
 
        return true;
    }
}

Do projektu nyní přidám třídu Result a pomocné metody pro usnadnění práce:

public class Result
{
    protected Result(bool isSuccess, string error)
    {
        if (isSuccess && !string.IsNullOrWhiteSpace(error))
        {
            throw new InvalidOperationException();
        }
 
        if (!isSuccess && string.IsNullOrWhiteSpace(error))
        {
            throw new InvalidOperationException();
        }
 
        IsSuccess = isSuccess;
        Error = error;
    }
 
    public bool IsSuccess { get; }
 
    public string Error { getprivate set; }
 
    public bool IsFailure => !IsSuccess;
 
    public static Result Fail(string message)
    {
        return new Result(false, message);
    }
 
    public static Result Fail(string format, params object[] args)
    {
        return new Result(falsestring.Format(CultureInfo.InvariantCulture, format, args));
    }
 
    public static Result Ok()
    {
        return new Result(truestring.Empty);
    }
}

A původní metodu přepíšu tak, aby vracela instanci této nové třídy místo původní boolean  hodnoty:

public class Condition
{
    private readonly Dictionary<stringint> alreadyProcessLines = new Dictionary<stringint>();
 
    public Result LineHasNotBeenProcessed(string id, int lineNumber)
    {
        if (alreadyProcessLines.ContainsKey(id))
        {
            var processedLineNumberWithSameId = alreadyProcessLines[id];
 
            if (processedLineNumberWithSameId != lineNumber)
            {
                return Result.Fail(
                    "Line duplicated. Line #{1} processed instead",
                    lineNumber,
                    processedLineNumberWithSameId);
            }
        }
        else
        {
            alreadyProcessLines.Add(id, lineNumber);
        }
 
        return Result.Ok();
    }
}


Ve stejném duchu si napíši i ostatní metody nutné pro zpracování souboru - ale protože si už nevystačím jen s prostým návratovým typem, je nutné přidat i generickou podobu třídy Result:

public class Result<T> : Result
{
    private readonly T value;
 
    protected internal Result(T value, bool isSuccess, string error) : base(isSuccess, error)
    {
        this.value = value;
    }
 
    public T Value
    {
        get
        {
            if (!IsSuccess)
            {
                throw new InvalidOperationException();
            }
            return value;
        }
    }
}

A do původní třídy Result přidáme tyto statické metody, které usnadní vytváření nových generických instancí:

    public static Result<T> Fail<T>(string message)
    {
        return new Result<T>(default(T), false, message);
    }
 
    public static Result<T> Fail<T>(string format, params object[] args)
    {
        return new Result<T>(default(T), falsestring.Format(CultureInfo.InvariantCulture, format, args));
    }
 
    public static Result<T> Ok<T>(T value)
    {
        return new Result<T>(value, truestring.Empty);
    }

A tedy pro zpracování řádku z CSV souboru si zatím můžeme vystačit s touto třídou - jen upozornění, tohle je značně zjednodušený způsob zpracování, ve skutečnosti může být řádek v CSV souboru složitější a s pouhým splitem si nelze vystačit. 

public class LineParser
{
    public Result<List<string>> ToList(string content)
    {
        if (string.IsNullOrEmpty(content))
        {
            return Result.Fail<List<string>>("Line cannot be empty or null");
        }
 
        return Result.Ok(content.Split(',').ToList());
    }
 
    public Result<Dictionary<stringstring>> ToDictionary(List<string> header, string content)
    {
        var parsingResult = ToList(content);
 
        if (parsingResult.IsFailure)
        {
            return Result.Fail<Dictionary<stringstring>>(parsingResult.Error);
        }
 
        if (parsingResult.Value.Count != header.Count)
        {
            return Result.Fail<Dictionary<stringstring>>("Cannot parse to columns");
        }
 
        return Result.Ok(header.Select((s, i) => new { s, i }).ToDictionary(x => x.s, x => parsingResult.Value[x.i]));
    }
}

Seznam názvů sloupců z prvního řádku drží zase instance této třídy:

public class HeaderColumnNamesProvider
{
    private readonly LineParser lineParser = new LineParser();
    private Result<List<string>> headerNames;
 
    public Result<List<string>> Get(int number, string content)
    {
        if (headerNames == null)
        {
            if (number == 1)
            {
                headerNames = lineParser.ToList(content);
            }
            else
            {
                headerNames = Result.Fail<List<string>>("Only first line can be used as header");
            }
        }
 
        return headerNames;
    }
}

a nakonec ještě kód pro získání jedinečného identifikátoru ze seznamu hodnot na řádku CSV souboru:

public class IdentityProvider
{
    private readonly string identityColumnName = "Id";
 
    public Result<string> Get(Dictionary<stringstring> columnNameValues)
    {
        if (columnNameValues.ContainsKey(identityColumnName))
        {
            return Result.Ok(columnNameValues[identityColumnName]);
        }
 
        return Result.Fail<string>("No column {0} present.", identityColumnName);
    }
}


A pokud se to vše dá dohromady v další třídě, která zpracuje řádek, dopadne to takhle:

public class LineAction
{
    private readonly HeaderColumnNamesProvider headerColumNamesProvider = new HeaderColumnNamesProvider();
    private readonly IdentityValueProvider identityProvider = new IdentityValueProvider();
    private readonly Condition recurrenceCondition = new Condition();
    private readonly LineParser parser = new LineParser();
 
 
    public Result ProcessLine(int number, string content)
    {
        var headerInfoActionResult = headerColumNamesProvider.Get(number, content);
        if (headerInfoActionResult.IsFailure)
        {
            return Result.Fail(headerInfoActionResult.Error);
        }
 
        var parsingResult = parser.ToDictionary(headerInfoActionResult.Value, content);
        if (parsingResult.IsFailure)
        {
            return Result.Fail(parsingResult.Error);
        }
 
        var lineIdResult = identityProvider.Get(parsingResult.Value);
        if (lineIdResult.IsFailure)
        {
            return Result.Fail(lineIdResult.Error);
        }
 
        var duplicateCheckerResult = recurrenceCondition.LineHasNotBeenProcessed(lineIdResult.Value, number);
        if (duplicateCheckerResult.IsFailure)
        {
            return Result.Fail(duplicateCheckerResult.Error);
        }
 
        return Result.Ok();
    }

a to vše zavoláme  z metody Main, tedy za předpokladu, že pracujeme na konzolové aplikaci:

static void Main(string[] args)
{
    ProcessFile(@"D:\Temp\TestImport\Import_100.txt");
    Console.ReadLine();
}
 
static void ProcessFile(string fileName)
{
    string line;
    int lineNumber = 0;
    var lineProcessor = new LineAction();
    var result = Result.Ok();
 
    using (var reader = new StreamReader(fileName))
    {
        while ((line = reader.ReadLine()) != null && result.IsSuccess)
        {
            result = lineProcessor.ProcessLine(++lineNumber, line);
        }
    }
 
    if (result.IsSuccess)
    {
        Console.WriteLine("File is ok");
    }
    else
    {
        Console.WriteLine("File is wrong: {0}", result.Error);
    }
 
}

Zatím jsem jen napsal hromadu kódu a nijak ohromně to zatím nevypadá - ale v dalším pokračování provedu pár úprav a výhody funkcionálního přístup vyniknou více. Už nyní je výhoda,že pokud dojde k nějakému problému, je v chybové hlášce řečeno k jakému.

Žádné komentáře:

Okomentovat