пятница, 29 апреля 2011 г.

Допиливаем HtmlAgilityPack. Часть 2

У класса HtmlNode есть свойство NextSibling, возращающее следующий элемент того же уровня (далее - сиблинг). Однако зачастую нужно получить следующий сиблинг с определенным тегом. Даже в самой простой ситуации:

anchor1
NextSibling для a1 вернет пробельный символ, а не a2. Поэтому я посчитал целесообразным добавить метод NextSiblingWithName (предложите лучшее название):

public HtmlNode NextSiblingWithName(string name)
{
var node = _nextnode;
while (node != null)
{
if (node.Name == name)
return node;

node = node.NextSibling;
}

return node;
}

Следующий повод для расширения дал метод SelectNodes, которого мы уже касались в первой части. Часто при использовании SelectNodes нужно проверить количество выбранных нод. Например:

var nodes = document.DocumentNode.SelectNodes("xpath query");
if (nodes.Count == 0)
throw new Exception("Expected at least one node");

Для того чтобы не захламлять код парсеров подобными проверками, я написал ряд методов - расширений:

public static List CountIsEqualTo(this List list, int count)
{
if (list.Count != count)
throw new Exception(String.Format("Expected count = {0} but was {1}", count, list.Count));

return list;
}

public static List CountIsNotEqualTo(this List list, int count)
{
if (list.Count == count)
throw new Exception(String.Format("Expected count != {0} but was {1}", count, list.Count));

return list;
}

public static List CountIsNotZero(this List list)
{
return CountIsNotEqualTo(list, 0);
}

public static List CountIsMoreThan(this List list, int count)
{
if (list.Count <= count)
throw new Exception(String.Format("Expected count > {0} but was {1}", count, list.Count));

return list;
}

public static List CountIsEqualOrMoreThan(this List list, int count)
{
if (list.Count < count)
throw new Exception(String.Format("Expected count >= {0} but was {2}", count, list.Count));

return list;
}

Самый популярный метод в моих парсерах - CountIsNotZero:

var nodes = document.DocumentNode.SelectNodes("xpath query").CountIsNotZero();

Отмечу, что для случаев, когда возвращается один элемент, все же удобнее использовать методы SelectSingleNode, SelectSingleNodeOrDefault, SelectFirstNode, SelectFirstNodeOrDefault, которые мы реализовали в первой части.

вторник, 26 апреля 2011 г.

Dependency Inversion Principle и инкапсуляция

При всей моей любви к Dependency Inversion Principle, меня всегда немного смущало то, как он соотносится с инкапсуляцией.

Давайте разберемся на примерах. И вот первый из них:

public class Car : ICar
{
private readonly IEngine _engine;

public Car(IEngine engine)
{
_engine = engine;
}

...
}

Есть машина, и у нее есть двигатель. Причем на разные машины могут ставиться разные двигатели. Пока все хорошо, да? Тогда перейдем ко второму примеру:

public class MyValidator : IValidator
{
private readonly IChecksumGenerator _checksumGenerator;

public MyValidator(IChecksumGenerator checksumGenerator)
{
_checksumGenerator = checksumGenerator;
}

...
}

В чем отличие от предыдущего примера? В том, что MyValidator завязан на использование конкретной реализации IChecksumGenerator (давайте возьмем это за данность), и программа отработает правильно только с конкретной реализацией. А раз я позволяю заинжектить, IChecksumGenerator то
1. может быть передана другая реализация IChecksumGenerator
2. нарушается инкапсуляция, и знание о требуемой реализации IChecksumGenerator выходит за рамки класса.

Прежде чем я опубликую свои подходы к решению этой проблемы, мне хотелось бы ознакомиться с предложениями читателей этого блога. С нетерпением жду ваших комментариев.

Update.
В комментариях возникли сомнения по поводу того, нарушает ли второй пример инкапсуляцию. Чтобы разобраться с этим вопросом, давайте рассмотрим второй пример без инъекции IChecksumGenerator (то, как это обычно и делается без SOLID'а):

public class MyValidator : IValidator
{
private readonly IChecksumGenerator _checksumGenerator;

public MyValidator()
{
_checksumGenerator = new MyChecksumGenerator();
}

...
}

Теперь привязка к конкретному типу IChecksumGenerator инкапсулирована внутри MyValidator. В предыдущей же реализации IChecksumGenerator прокидывался в публичный конструктор, и по-моему это самое настоящее нарушение инкапсуляции.

Update 2.
Я задал такой же вопрос на stackoverflow, и Mark Seemann (автор книги Dependency Injection in .NET) дал очень точный диагноз моему коду - нарушение Liskov Substitution Principle. Действительно! Если мой валидатор будет работать только с определенными наследниками IChecksumGenerator, нужно инжектить зависимость по более специфичному интерфейсу. И тогда любая реализация данного интерфейса, переденная в качестве зависимости, отработает корректно. Ума не приложу, почему у меня из головы вылетел LSP, ведь это так просто и очевидно!

Мораль: Все принципы Object-Oriented Design очень тесно связаны - вспоминая один, не забывайте о других.

понедельник, 25 апреля 2011 г.

Расширяем Regex

90% моей работы с регулярными выражениями происходит по следующему сценарию:
var match = Regex.Match(input, pattern);
if (!match.Success)
throw new Exception("Can't match input to pattern");
...

Написав в очередной раз эти 3 строчки, я понял, что дальше так жить нельзя - нужно реализовать метод MatchForSure (наверное, не самое удачное название - предложите свой вариант).

Печально, но C# не поддерживает extension methods, которые можно было бы вызывать как статические методы. Поэтому мы можем лишь сделать обертку или вспомогательный класс, например, вот так:
public static class RegexExtensions
{
public static Match MatchForSure(string input, string pattern)
{
var match = Regex.Match(input, pattern);
if (!match.Success)
throw new Exception(String.Format("Can't match {0} to regex pattern {1}", input, pattern));
return match;
}
}

Три строчки из начала поста сократятся до одной:

var match = RegexExtensions.MatchForSure(input, pattern);
...

Если же вы предпочитаете использовать Regex через инстанс, то можно реализовать полноценный extension method:
public static class RegexExtensions
{
public static Match MatchForSure(this Regex regex, string input)
{
var match = regex.Match(input);
if (!match.Success)
throw new Exception(String.Format("Can't match {0} to regex pattern {1}", input, pattern));
return match;
}
}

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

var match = new Regex(pattern).MatchForSure(input);
...

Мораль в стиле капитана очевидность: Любая повторяющаяся логика должна быть выявлена и выделена в отдельную сущность. Чем раньше, тем лучше.

воскресенье, 24 апреля 2011 г.

Допиливаем HtmlAgilityPack. Часть 1

HtmlAgilityPack - пожалуй, самый популярный парсер HTML под .NET. В транке SVN'а лежит версия 1.4, но я бы посоветовал использовать версию 2 из бранчи. Я внес некоторые изменения в эту ветку и зарепортил их в виде патча. Пока неизвестно, попадут эти изменения в репозиторий или нет, поэтому вкратце опишу их в этом посте: может, тебе, мой дорогой читатель, эти изменения тоже пригодятся.

Центром библиотеки является класс HtmlDocument. В юнит-тестах у меня сплошь да рядом встречалась такая коснтрукция

[Test]
public void Test1()
{
var doc = new HtmlDocument();
doc.LoadHtml(SomeResource.SomePredefinedHtmlString);
...
}

Чтобы избавиться от лишней строчки, я добавил в HtmlDocument статический метод CreateFromHtml:

public static HtmlDocument CreateFromHtml(string html)
{
var document = new HtmlDocument();
document.LoadHtml(html);
return document;
}

Тогда тест приобретет следующий вид:

[Test]
public void Test1()
{
var doc = HtmlDocument.CreateFromHtml(SomeResource.SomePredefinedHtmlString);
...
}

Каждая нода документа позволяет выполнить XPath-запрос посредством методов SelectNodes и SelectSingleNode. Давайте остановимся на текущей реализации второго метода:

public IHtmlBaseNode SelectSingleNode(string xpath)
{
if (xpath == null)
{
throw new ArgumentNullException("xpath");
}

HtmlNodeNavigator nav = new HtmlNodeNavigator(_ownerdocument, this);
XPathNodeIterator it = nav.Select(xpath);
if (!it.MoveNext())
{
return null;
}

HtmlNodeNavigator node = (HtmlNodeNavigator)it.Current;
return node.CurrentNode;
}

Хм... Так не пойдет. Single - это один и только один. Давайте исправим логику метода SelectSingleNode, а для удобства реализуем еще и SelectSingleNodeOrDefault, SelectFirstNode и SelectFirstNodeOrDefault - в лучших традициях LINQ:

public IHtmlBaseNode SelectSingleNode(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator)it.Current;
var currentNode = nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectSingleNodeOrDefault(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
return null;

var nodeNavigator = (HtmlNodeNavigator)it.Current;
var currentNode = nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectFirstNode(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator)it.Current;
return nodeNavigator.CurrentNode;
}

public IHtmlBaseNode SelectFirstNodeOrDefault(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
return null;

var nodeNavigator = (HtmlNodeNavigator)it.Current;
return nodeNavigator.CurrentNode;
}

private XPathNodeIterator CreateXPathNodeIterator(string xpath)
{
if (xpath == null)
{
throw new ArgumentNullException("xpath");
}

var nav = new HtmlNodeNavigator(_ownerdocument, this);
return nav.Select(xpath);
}

Обратите внимание на то, что методы SelectNodes, SelectSingleNode, SelectSingleNodeOrDefault, SelectFirstNode и SelectFirstNodeOrDefault возвращают объекты типа IHtmlBaseNode. Однако зачастую после выполнения запроса приходится приводить IHtmlBaseNode к HtmlNode (тег), AttributeNode (атрибут) или HtmlTextNode (текст). Давайте сделаем так, чтобы можно было задавать тип возвращаемой ноды/нод. Для примера возьмем SelectSingleNode, сделаем его generic и создадим ряд методов-оберток:

public T SelectSingleNode(string xpath) where T: IHtmlBaseNode
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator) it.Current;
var currentNode = (T)nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectSingleNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlNode SelectSingleHtmlNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlAttribute SelectSingleAttributeNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlTextNode SelectSingleTextNode(string xpath)
{
return SelectSingleNode(xpath);
}

Аналогичный рефакторинг необходимо провести и для оставшихся четырех методов.

P. S. Из уважения к законам жанра я оставлю здесь эту ссылку на тему того, почему не стоит парсить HTML при помощи регулярных выражений.