среда, 19 января 2011 г.

Отображение иерархических данных с использованием asp.net mvc

Это первый технический пост, ради которого я решился завести свой блог.

Итак, в одном из проектов мне понадобилось реализовать функциональность отображения иерархических данных на веб-странице. Данные представленны в виде класса:

public class Product
{
  public int Id { get; set; }

  public string Name { get; set; }

  public ProductType Type { get; set; }

  public decimal Price { get; set; }

  public Product Parent { get; set; }
}

В бд это хранится в таком же виде. Задача - отображать на сайте иерархическую структуру продуктов с url'ами вида
/catalog/some/product/in/other/product/

т.е. юзер, зайдя по ссылке /catalog/some получит продукт с именем "some" самого верхнего уровня (у этого продукта нет родителя), зайдя по ссыке /catalog/some/product получит продукт с именем "product" и родителем у которого будет является "some" , ну и т.д...
Вообщем ничего сложного, в коде это реализуется примерно так:

Маршрутизация:
// маршрут /Catalog/*
routes.MapRoute(
  null,
  "Catalog/{*path}",
  new { controller = "Catalog", action = "Index", path = string.Empty}
);

объявляется маршрут с путевой частью "catalog/" и параметром path , который принимает любую строку, а по в случае отсутствия этой строки - является пустой строкой

Код тестов проверяющих заданное поведение будет выглядеть так (я использую NUnit и NMoq):
[Test]
// проверяем поведение при пустом значении path
public void CatalogIndexWithEmptyPathTest()
{
  // arrange
  var p = new Product(){Id = 1, Parent = null };
  var p1 = new Product(){Id = 2, Parent = p };
  var p2 = new Product(){Id = 3, Parent = p };
  var p3 = new Product(){Id = 4, Parent = p };
  var pChilds = new[]{p1,p2,p3};


  var mockProductService = new Mock<IProductService>();
  mockProductService.Setup(x => x.GetByParent(null)).Returns(new[]{p});
  mockProductService.Setup(x => x.GetByParent(p)).Returns(pChilds);

  var catalogController = new CatalogController(mockProductService.Object, null);


  // act
 var result = (ViewResult)catalogController.Index(string.Empty);

 // assert
 Assert.IsNotNull(result);

  var exceptedProduct = (Product)result.ViewData["Product"];
  Assert.AreEqual(p, exceptedProduct);

  var exceptedChilds = (IEnumerable<Product>) result.ViewData["Childs"];
  Assert.AreEqual(pChilds, exceptedChilds);
}

[Test]
// проверяем поведение при не правильном (не существующем) значении path
public void CatalogIndexWithNotExistsPathTest()
{
  // arrange
  var mockProductService = new Mock<IProductService>();
  mockProductService.Setup(x => x.GetByName(It.IsAny<string>())).Returns((Product) null);

  var catalogController = new CatalogController(mockProductService.Object, null);

  // act
  var result = (RedirectToRouteResult)catalogController.Index("not/exists/product");

  // assert
  Assert.IsNotNull(result);
  Assert.AreEqual("Index", result.RouteValues["action"]);
}

и наконец самый большой тест проверяющий поведение при правильных данных

[Test]
public void CatalogIndexWithSomePathTest()
{
  // arrange

  var p = new Product(){Id = 1, Parent = null, Name = "item" };
  var p1 = new Product(){Id = 2, Parent = p, Name = "in" };
  var p2 =  new Product(){Id = 3, Parent = p1, Name = "catalog" };
  var p21 = new Product(){Id = 4, Parent = p2 };
  var p22 = new Product(){Id = 5, Parent = p2 };
  var p23 = new Product(){Id = 6, Parent = p2 };
  var p2Childs = new[]{p21,p22,p23};

  var mockProductService = new Mock<IProductService>();
  mockProductService.Setup(x => x.GetByName(p.Name)).Returns(p);
  mockProductService.Setup(x => x.GetByName(p1.Name)).Returns(p1);
  mockProductService.Setup(x => x.GetByName(p2.Name)).Returns(p2);
  mockProductService.Setup(x => x.GetByParent(p2)).Returns(p2Childs);

  // act
  var result = (ViewResult)catalogController.Index("item/in/catalog");

  // assert
  Assert.IsNotNull(result);

  var exceptedProduct = (Product) result.ViewData["Product"];  
  Assert.AreEqual(p2, exceptedProduct);


  var exceptedChilds = (IEnumerable<Product>) result.ViewData["Childs"];
  Assert.AreEqual(p2Childs, exceptedChilds);
}

Итак, три теста покрывают три основных сценария работы действия Index контроллера Catalog

1) Пустое значение path
2) Не существующее значение path
3) Существующее значение path

В идеале нужно тестировать еще и входящие и исходящие маршруты .. но об этом в другой раз


Сам код метода Index контроллера Catalog выглядит примерно так:
public class CatalogController : Controller
{
  private readonly IProductService _productService;
  private readonly IProductTypeService _productTypeService; 

  public CatalogController(IProductService productService, IProductTypeService productTypeService)
  {
    _productService = productService;
    _productTypeService = productTypeService;
  }


  public ActionResult Index(string path)
  {
    Product product = null;
    if(!string.IsNullOrWhiteSpace(path))
    {
      var pathParts = path.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries);      
      foreach (var pathPart in pathParts)
      {
        var parent = product;
        product = _productService.GetByName(pathPart);

        // если продукт с именем pathPart не найден или для найденного продукта parent не является родительским
        if ((product == null) || (parent != null && parent != product.Parent))
          return RedirectToAction("Index", new { path = string.Empty }); // делаем редирект на корневую точку каталога
      }
    }

    if (product == null)
      product = _productService.GetByParent(null).FirstOrDefault();

    // получаем список продуктов для который product является родительским
    var childs = _productService.GetByParent(product);

    ViewData["Product"] = product;
    ViewData["Childs"] = childs;
  }
}

Код представления /Views/Catalog/Index.aspx будет выглядеть примерно так:
<%
  var product = (Product) ViewData["Product"];
  var childs = (IEnumerable<Product>)ViewData["Childs"];
%>

<p><%: product.Name %></p>
<ul>
<% foreach(var child in childs) { %>
<li><%: child.Name %></li>
<% } %>
</ul>

// на самом  деле на месте child.Name нужно, с помощью метода Html.ActionLink, генерировать ссылку на дочерний объект
Об этом я напишу в следующем посте.

В целом у нас получилось одно действие Index контроллера Catalog. Это хорошо, потому что это просто и понятно как работает. В моем проекте понадобилось для некоторых продуктов, расположенных близко к корню (с уровнем вложенности 1-2 относительно корня) кастомизировать представление - т.е. отображать не просто иерархию со ссылками, а выводить некоторый дополнительный html-контент в представлении Index. При этом не хотелось в коде представления писать условия вида:
<% if(product.Name == "product123") %>
<%= "Дополнительный конент" %>
<% if(product.Name == "product456") %>
<%= "Другой дополнительный конент" %>
<% else %>
<%= "Представление по умолчанию" %>

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

Спасибо, что дочитали до конца, первый пост получился достаточно длинный, видимо у меня стиль такой.. многословный

Комментариев нет:

Отправить комментарий