0

У меня есть html-файл, содержащий курс валют за много лет и я пишу парсер, который приводит эти данные в пригодный для обработки вид. Я сделал некоторую реализацию и хотел бы получить советы по улучшению архитектуры.

О структуре файла: html как есть, со множеством ненужной информации. Есть строка с датой, потом идут строки со значением разных валют: доллар, евро, немецкая марка и т.д. Этот паттерн повторяется.

Я создал два класса для хранения будущей информации:

  1. Класс для хранения наименования валюты и значения:

    public class Currency
    {
        public string Name { get; }
        public decimal Value { get; }
    
    public Currency(string name, decimal value)
    {
        Name = name;
        Value = value;
    }
    

    }

  2. Класс для хранения значений нескольких валют на определенную дату:

    public class CurrencyRecord
    {
        private List<Currency> _currency = new List<Currency>();
        public List<Currency> Currency { 
            get => new List<Currency>(_currency);
        }
        public DateTime ActualDate { get; }
    
    public CurrencyRecord(DateTime actualDate)
    {
        ActualDate = actualDate;
    }
    
    public void AddCurrency(string name, decimal value)
    {
        _currency.Add(new Currency(name, value));
    }
    

    }

Разбил задачу на три подзадачи:

  1. Считать из html все строки с полезной информацией, проигнорировав шум. Для этого определил вот такой интерфейс:

    public interface ICurrencyDataProvider
    {
        IEnumerable<string> GetCurrencyData();
    }
    

    public interface IIgnorePolicy { bool Ignore(string source); }

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

    public interface ICurrencyParser
    {
        IEnumerable<CurrencyRecord> Parse(IEnumerable<string> rawData);
    }
    
  3. Сохранить в удобный формат для упрощения последующей работы:

    public interface ICurrencyStorage
    {
        void Save(IEnumerable<CurrencyRecord> currencyRecords);
    }
    

Оформил процесс в целом в виде отдельного класса:

public class CurrencyProcessor
{
    private readonly ICurrencyDataProvider _currencyDataProvider;
    private readonly ICurrencyParser _currencyParser;
    private readonly ICurrencyStorage _currencyStorage;
public CurrencyProcessor(
    ICurrencyDataProvider currencyDataProvider,
    ICurrencyParser currencyParser,
    ICurrencyStorage currencyStorage)
{
    _currencyDataProvider = currencyDataProvider;
    _currencyParser = currencyParser;
    _currencyStorage = currencyStorage;
}

public void ProcessCurrency()
{
    var raw = _currencyDataProvider.GetCurrencyData();
    var parsed = _currencyParser.Parse(raw);
    _currencyStorage.Save(parsed);
}

}

Затем написал реализации. План был такой: из html брать несколько интересующих валют, а уже при парсинге решать, какие из них забирать в конечную выборку и сохранять в итоговый файл.

Реализация для чтения получилась такая:

public class StreamCurrencyDataProvider : ICurrencyDataProvider
{
    private readonly Stream _stream;
    private readonly IIgnorePolicy _ignorePolicy;
public StreamCurrencyDataProvider(Stream stream)
{
    _stream = stream;
}

public StreamCurrencyDataProvider(Stream stream, IIgnorePolicy ignorePolicy)
{
    _stream = stream;
    _ignorePolicy = ignorePolicy;
}

public IEnumerable&lt;string&gt; GetCurrencyData()
{
    var rawCurrency = new List&lt;string&gt;();

    using (var reader = new StreamReader(_stream))
    {
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            if (_ignorePolicy != null &amp;&amp; _ignorePolicy.Ignore(line))
                continue;

            rawCurrency.Add(line);
        }
    }

    return rawCurrency;
}

}

Для игнорирования используются регулярные выражения, которые пропускают только строки с датой и долларами с евро:

public class AuditIgnorePolicy : IIgnorePolicy
{
    public bool Ignore(string line)
    {
        if (Regex.IsMatch(line, @"<span>[0-9]{2}\.[0-9]{2}\.[0-9]{4}</span>"))
            return false;
    if (Regex.IsMatch(line, @&quot;scal\(.+'(USD|EUR)'\)&quot;))
        return false;

    return true;
}

}

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

10.02.2020
86.2343 EUR
72.3234 USD
11.02.2020
88.2343 EUR
74.3234 USD

То можно было бы обрабатывать как-нибудь "пачкой" по три строки. Но я решил предусмотреть (скорее для тренировки, нежели для реальной ситуации) случаи, когда дата может быть некорректной, например, 30.02.2020. Или в какой-нибудь из строк будет сначала идти USD, а потом EUR или вообще одна из валют будет отсутствовать.

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

public class AuditCurrencyParser : ICurrencyParser
{
    private readonly ILogger _logger;
    private readonly IToken _startToken;
    private readonly IParseLogic _actualDate;
    private readonly List<IParseLogic> _valueParsers;
public AuditItCurrencyParser(
    ILogger logger,
    IToken startToken,
    IParseLogic actualDate,
    params IParseLogic[] elementParsers
    )
{
    _logger = logger;
    _startToken = startToken;
    _actualDate = actualDate;
    _valueParsers = new List&lt;IParseLogic&gt;(elementParsers);
}

public IEnumerable&lt;CurrencyRecord&gt; Parse(IEnumerable&lt;string&gt; rawData)
{
    var records = new List&lt;CurrencyRecord&gt;();

    CurrencyRecord rec = null;
    int givenParsers = _valueParsers.Count;
    bool recordDetected = false;

    Func&lt;int, bool&gt; allParsersWorkedOut = (p) =&gt; p == 0 ? true : false;
    Func&lt;CurrencyRecord, bool&gt; recordCreatedAndNotAdded = (rec) =&gt; rec != null ? true : false;

    foreach (var line in rawData)
    {
        if (_startToken.IsNewElement(line))
        {
            if (recordCreatedAndNotAdded(rec))
            {
                if (allParsersWorkedOut(givenParsers))
                {
                    records.Add(rec);
                }
                else
                {
                    _logger.LogError($&quot;Данные за {rec.ActualDate} повреждены и не были добавлены&quot;);
                }
            }

            DateTime date;
            recordDetected = _actualDate.TryParse(line, out date);

            if (!recordDetected) continue;

            rec = new CurrencyRecord(date);
            givenParsers = _valueParsers.Count;
        }
        else
        {
            if (!recordDetected || allParsersWorkedOut(givenParsers)) continue;

            foreach (var p in _valueParsers)
            {
                decimal currencyValue;
                bool valOk = p.TryParse&lt;decimal&gt;(line, out currencyValue);

                if (valOk)
                {
                    rec.AddCurrency(p.WhatIsIt, currencyValue);
                    --givenParsers;
                    break;
                }
            }
        }
    }

    return records;
}

}

Заодно хочу узнать, насколько в целом приемлем такой формат метода Parse - в принципе нормально или слишком длинно? Здесь есть отдельный класс, который отвечает за обнаружение дата-подобной строки:

public interface IToken
{
    bool IsNewElement(string line);
}

public class DateTimeToken : IToken { public bool IsNewElement(string line) { return Regex.IsMatch(line, @"\D[0-9]{2}.[0-9]{2}.[0-9]{4}\D"); } }

А также несколько классов, которые уже непосредственно пытаются из строки вытащить данные, на которые они заточены:

public interface IParseLogic
{
    public string WhatIsIt { get; }
    public bool TryParse<T>(string line, out T result);
}

public class DateParseLogic : IParseLogic { public string WhatIsIt { get; } = "ActualDate";

public bool TryParse&lt;T&gt;(string line, out T result)
{
    Match match = Regex.Match(line, @&quot;\D*([0-9]{2}\.[0-9]{2}\.[0-9]{4})\D*&quot;);
    if (!match.Success)
    {
        result = default(T);
        return false;
    }

    DateTime date;
    var success = DateTime.TryParse(match.Groups[1].Value, out date);

    if (!success)
    {
        result = default(T);
        return false;
    }

    result = (T)(object)date;
    return true;
}

}

public class UsdParseLogic : IParseLogic { public string WhatIsIt { get; } = "USD";

public bool TryParse&lt;T&gt;(string line, out T result)
{
    if (!Regex.IsMatch(line, @&quot;scal\(.+'USD'\)&quot;))
    {
        result = default(T);
        return false;
    }

    Match match = Regex.Match(line, @&quot;([0-9]{2}\.[0-9]{4})&quot;);

    decimal usd;
    var success = decimal.TryParse(match.Groups[1].Value.Replace('.', ','), out usd);

    if (!success)
    {
        result = default(T);
        return false;
    }

    result = (T)(object)usd;
    return true;
}

}

public class EurParseLogic : IParseLogic { public string WhatIsIt { get; } = "EUR";

public bool TryParse&lt;T&gt;(string line, out T result)
{
    if (!Regex.IsMatch(line, @&quot;scal\(.+'EUR'\)&quot;))
    {
        result = default(T);
        return false;
    }

    Match match = Regex.Match(line, @&quot;([0-9]{2}\.[0-9]{4})&quot;);

    decimal eur;
    var success = decimal.TryParse(match.Groups[1].Value.Replace('.', ','), out eur);

    if (!success)
    {
        result = default(T);
        return false;
    }

    result = (T)(object)eur;
    return true;
}

}

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

// Только доллар
var parser = new AuditItCurrencyParser(logger, new DateTimeToken(),
                    new DateParseLogic(), new UsdParseLogic());

// Только евро var parser = new AuditItCurrencyParser(logger, new DateTimeToken(), new DateParseLogic(), new EurParseLogic());

// Обе var parser = new AuditItCurrencyParser(logger, new DateTimeToken(), new DateParseLogic(), new UsdParseLogic(), new EurParseLogic());

В целом, задача достигнута, но мне честно говоря не особо нравится метод Parse в контейнере, да и конструкция вроде result = (T)(object)date; выглядит грязновато. Я буду думать еще как это можно все улучшить и хотел бы услышать какие-нибудь советы по улучшению, потому что может тут есть какие-то фундаментальные ошибки, которых я просто не способен еще увидеть самостоятельно.

  • @Alexander Petrov я оформил задачу отдельным вопросом и попытался написать все как можно подробнее –  Sep 16 '20 at 13:40
  • Почему вы используете регулярки, для обработки HTML? Есть вполне хорошие, готовые решения, которые сильно вам упростят жизнь. Сейчас как по мне, вы написали много лишнего. Также HTML, может есть эти данные, в удобном для работе виде (JSON/XML)? Сервера редко отдают такой объем данных как чистый HTML. – EvgeniyZ Sep 16 '20 at 13:46
  • советую для парсинга хтмл использовать AngleSharp. Регулярки для этого не подходят вообще. Это значительно упростит код. – Andrew Stop_RU_war_in_UA Sep 16 '20 at 13:58
  • Смысл не в том, чтобы получить эти данные уже в каком-то виде более удобном или использовать готовые библиотеки для извлечения, а в самой архитектуре программы. Я ее пишу, чтобы перейти от скриптово-процедурного стиля, когда все решается "в лоб" и набить руку в разработке через абстракции. Соответственно, не особо важно, как данные извлекаются - регулярками или еще как, главное что извлекаются. –  Sep 16 '20 at 15:12
  • Первые пару классов можно реально заменить парой строк кода. Например, я использую HtmlAgilityPack+Fizzler, что позволяет мне выбирать нужное с веб-страницы так, как если бы я это писал на JavaScript. Вот вам и упрощение архитектуры. Полученные строки с валютами тоже можно расковырять без регулярок, потому что структура фиксированная. Итого, все что выложено выше превратится в десяток строк кода, и будет работать не менее надежно. Так что, не брезгуйте технологиями. Лучше тратить силы на написание полезного кода, а не велосипедов. – aepot Sep 16 '20 at 21:59
  • Хотите, могу пример написать, но мне нужен либо url веб-страницы, либо ее HTML. А с точки зрения код-ревью, у вас явно нарушен принцип DRY - Don't Repeat Yourself (Не повторяй себя/Не пиши повторяющийся код). И тег архитектура я бы предложил заменить на инспекция кода – aepot Sep 16 '20 at 22:02
  • 1
    @aepot было бы здорово посмотреть на ваш пример, а данные я брал отсюда view-source:https://www.audit-it.ru/currency/daily_curs.php?monthStart=5&yearStart=2007&monthEnd=8&yearEnd=2020¤cy=USD¤cyTable=USD%2CEUR%2CGBP –  Sep 17 '20 at 06:03

0 Answers0