У меня есть html-файл, содержащий курс валют за много лет и я пишу парсер, который приводит эти данные в пригодный для обработки вид. Я сделал некоторую реализацию и хотел бы получить советы по улучшению архитектуры.
О структуре файла: html как есть, со множеством ненужной информации. Есть строка с датой, потом идут строки со значением разных валют: доллар, евро, немецкая марка и т.д. Этот паттерн повторяется.
Я создал два класса для хранения будущей информации:
Класс для хранения наименования валюты и значения:
public class Currency { public string Name { get; } public decimal Value { get; }public Currency(string name, decimal value) { Name = name; Value = value; }}
Класс для хранения значений нескольких валют на определенную дату:
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)); }}
Разбил задачу на три подзадачи:
Считать из html все строки с полезной информацией, проигнорировав шум. Для этого определил вот такой интерфейс:
public interface ICurrencyDataProvider { IEnumerable<string> GetCurrencyData(); }public interface IIgnorePolicy { bool Ignore(string source); }
Обойти получившиеся строки и извлечь из них данные
public interface ICurrencyParser { IEnumerable<CurrencyRecord> Parse(IEnumerable<string> rawData); }Сохранить в удобный формат для упрощения последующей работы:
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<string> GetCurrencyData()
{
var rawCurrency = new List<string>();
using (var reader = new StreamReader(_stream))
{
string line;
while ((line = reader.ReadLine()) != null)
{
if (_ignorePolicy != null && _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, @"scal\(.+'(USD|EUR)'\)"))
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<IParseLogic>(elementParsers);
}
public IEnumerable<CurrencyRecord> Parse(IEnumerable<string> rawData)
{
var records = new List<CurrencyRecord>();
CurrencyRecord rec = null;
int givenParsers = _valueParsers.Count;
bool recordDetected = false;
Func<int, bool> allParsersWorkedOut = (p) => p == 0 ? true : false;
Func<CurrencyRecord, bool> recordCreatedAndNotAdded = (rec) => 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($"Данные за {rec.ActualDate} повреждены и не были добавлены");
}
}
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<decimal>(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<T>(string line, out T result)
{
Match match = Regex.Match(line, @"\D*([0-9]{2}\.[0-9]{2}\.[0-9]{4})\D*");
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<T>(string line, out T result)
{
if (!Regex.IsMatch(line, @"scal\(.+'USD'\)"))
{
result = default(T);
return false;
}
Match match = Regex.Match(line, @"([0-9]{2}\.[0-9]{4})");
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<T>(string line, out T result)
{
if (!Regex.IsMatch(line, @"scal\(.+'EUR'\)"))
{
result = default(T);
return false;
}
Match match = Regex.Match(line, @"([0-9]{2}\.[0-9]{4})");
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; выглядит грязновато. Я буду думать еще как это можно все улучшить и хотел бы услышать какие-нибудь советы по улучшению, потому что может тут есть какие-то фундаментальные ошибки, которых я просто не способен еще увидеть самостоятельно.
HtmlAgilityPack+Fizzler, что позволяет мне выбирать нужное с веб-страницы так, как если бы я это писал на JavaScript. Вот вам и упрощение архитектуры. Полученные строки с валютами тоже можно расковырять без регулярок, потому что структура фиксированная. Итого, все что выложено выше превратится в десяток строк кода, и будет работать не менее надежно. Так что, не брезгуйте технологиями. Лучше тратить силы на написание полезного кода, а не велосипедов. – aepot Sep 16 '20 at 21:59архитектурая бы предложил заменить наинспекция кода– aepot Sep 16 '20 at 22:02