Как можно улучшить архитектуру моего парсера?
У меня есть 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; выглядит грязновато. Я буду думать еще как это можно все улучшить и хотел бы услышать какие-нибудь советы по улучшению, потому что может тут есть какие-то фундаментальные ошибки, которых я просто не способен еще увидеть самостоятельно.
Ответы (1 шт):
По поводу архитектуры мало что смогу посоветовать, а вот по поводу технологий могу.
Ниже привожу простой пример, в котором использую 2 NuGet пакета
using HtmlAgilityPack;
using Fizzler.Systems.HtmlAgilityPack;
Для хранения такого рода данных, очень хорошо подходит DataTable, с которой кстати я возможно не очень умело обращаюсь, но люди, кто дружит с базами данных, знают этот класс намного лучше.
Преимущество DataTable в том, что она позволяет быстро оперировать всей пачкой данных, а еще ее нативно можно засунуть в тот же DataGrid (WPF) или DataGridView (WinForms), или вообще скормить SQL базе через Entity Framework.
Пример на базе консольного приложения
class Program
{
private static readonly HttpClient client = new HttpClient();
static async Task Main(string[] args) => await new Program().Start();
private async Task Start()
{
try
{
// загрузка данных
HtmlDocument page = await GetPageAsync("https://www.audit-it.ru/currency/daily_curs.php?monthStart=5&yearStart=2007&monthEnd=8&yearEnd=2020¤cy=USD¤cyTable=USD%2CEUR%2CGBP");
// парсер
DataTable data = new DataTable();
IEnumerable<HtmlNode> headers = page.DocumentNode.QuerySelectorAll(".tableData thead td");
foreach (HtmlNode node in headers)
{
string columnName = node.ChildNodes.First().InnerText;
string currencyName = node.ChildNodes.FirstOrDefault(x => x is HtmlTextNode textNode && textNode.Text.Trim().Length > 0)?.InnerText;
bool isDate = columnName == "Дата";
Type dataType = isDate ? typeof(DateTime) : typeof(double);
string dataColumnName = isDate ? columnName : currencyName;
data.Columns.Add(dataColumnName, dataType);
}
data.PrimaryKey = new[] { data.Columns[0] };
IEnumerable<HtmlNode> rows = page.DocumentNode.QuerySelectorAll(".tableData tbody tr");
foreach (HtmlNode node in rows)
{
IEnumerable<string> cells = node.ChildNodes.Select(x => x.InnerText.Trim()).Where(x => x?.Length > 0);
object[] rowData = new[] { (object)DateTime.Parse(cells.First()) }.Concat(cells.Skip(1).Select(x => (object)double.Parse(x, CultureInfo.InvariantCulture))).ToArray();
data.Rows.Add(rowData);
}
// примеры использования данных
foreach (DataColumn c in data.Columns)
Console.Write(c.ColumnName.PadLeft(12));
Console.WriteLine();
foreach (DataRow row in data.Rows.Cast<DataRow>().Take(10)) // вывод 10 первых строк
{
foreach (object o in row.ItemArray)
{
switch (o)
{
case DateTime date:
Console.Write(date.ToString("d").PadLeft(12));
break;
case double value:
Console.Write(value.ToString("F2").PadLeft(12));
break;
}
}
Console.WriteLine();
}
Console.Write("Введите дату: ");
DateTime d = DateTime.Parse(Console.ReadLine());
DataRow r = data.Rows.Find(d);
Console.Write("Введите валюту: ");
string currency = Console.ReadLine().ToUpper();
Console.WriteLine(r[currency]);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Console.ReadKey();
}
private async Task<HtmlDocument> GetPageAsync(string url)
{
using (HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead))
using (Stream responseStream = await response.EnsureSuccessStatusCode().Content.ReadAsStreamAsync())
{
HtmlDocument doc = new HtmlDocument();
doc.Load(responseStream);
return doc;
}
}
}
Вывод в консоль.
Дата USD EUR GBP
18.09.2020 75,19 88,63 97,38
17.09.2020 74,93 88,86 96,66
16.09.2020 75,19 89,36 96,97
15.09.2020 74,71 88,58 96,02
12.09.2020 74,89 88,68 96,05
11.09.2020 75,53 89,30 98,26
10.09.2020 76,07 89,57 98,55
09.09.2020 75,96 89,74 99,70
08.09.2020 75,59 89,48 99,96
05.09.2020 75,18 89,04 99,94
Введите дату: 10.09.2020
Введите валюту: GBP
98,5504
Как вы наверное уже заметили: данные в DataTable уже хранятся в целевом формате, первая колонка в имеет тип DateTime, остальные - double.