- PVSM.RU - https://www.pvsm.ru -

Непутевые заметки о ASP.NET MVC. Часть 1 (и единственная)

В последнее время на Хабре часто начали появляться стати о ASP.NET MVC. Однако в этой статье я бы хотел сделать несколько заметок по поводу построения приложений на вышеприведенном фреймворке: минимальный набор NuGet-packages (без которых грех начинать работу), логирование, подводные камни при использовании стандартных membership-, profile- провайдеров. И, напоследок, почему Web API из MVC 4 — то, что так долго мы все ждали.

NuGet-packages

Итак, определимся без каких пакетов нельзя начинать разрабатывать веб-приложение на ASP.NET MVC. В нижеприведенном списке хоть и находятся те [пакеты], которые по-умолчанию ставятся при создании решения, но я их все же включу.

  • Entity Framework 4.1 (вместе с Code First) — доступ к данным
  • jQuery (UI, Validation) — [no comments]
  • Microsoft Web Helpers
  • MvcScaffolding — кодогенерация
  • Ninject (MVC3) — dependency injection
  • NLog (Config, Extended, Schema) — логирование
  • PagedList (MVC3) — очень удобный пакет для «листания страниц»
  • Lucene (SimpleLucene) — поиск
  • Reactive Extensions for JS — клиент

Entity Framework 4.1 [1] — возникает вопрос, почему он? Ну что ж поясню на примере. Существует достаточное количество других схожих, превосходящих и пр. ORM-фреймворков (один NHibernate чего стоит). Еще пару лет назад я бы рекомендовал для начала использовать легковесный (относительно, судя по синтетическим тестам) LINQ to SQL. НО! Выход Entity Framework 4.1 вместе с Code First перевесил все минусы: прототипирование слоя данных приложений стало одним удовольствием. Если для первого нужно работать в дизайнере, иметь дело с DBML-файлами, то здесь лишь работаем с POCO. Например, модель данных для магазина:

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

    public string Name { get; set; }

    public int CategoryId { get; set; }

    public virtual Category Category { get; set; }

    public int Price { get; set; }

    public DateTime CreationDate { get; set; }

    public string Description { get; set; }
}

public class Category
{
    public int CategoryId { get; set; }

    public string Name { get; set; }

    public virtual ICollection<Product> Products { get; set; }
}

public class ProductsContext : DbContext
{
    public DbSet<Category> Categories { get; set; }
}

MvcScaffolding [2] — нужно быстро набросать CRUD-панель? Уже есть модель EF, либо LINQ to SQL? Тогда введите данную команду в окно NuGet и возрадуйтесь кодогенерации:
Scaffold Controller [имя модели] –Repository
Флаг –Repository позволяет заодно и создать репозиторий для работы со слоем данных.
Для примера используем вышеприведенную модель.
После ввода
Scaffold Controller Product –Repository
будут сгенерированы следующие CRUD-страницы и абстрактный репозиторий:

public interface IProductRepository
{
    IQueryable<Product> All { get; }
    IQueryable<Product> AllIncluding(params Expression<Func<Product, object>>[] includeProperties);
    Product Find(int id);
    void InsertOrUpdate(Product product);
    void Delete(int id);
    void Save();
}

А также его реализация:

public class ProductRepository : IProductRepository
{
    ProductsContext context = new ProductsContext();

    public IQueryable<Product> All
    {
        get { return context.Products; }
    }

    public IQueryable<Product> AllIncluding(params Expression<Func<Product, object>>[] includeProperties)
    {
        IQueryable<Product> query = context.Products;
        foreach (var includeProperty in includeProperties) {
            query = query.Include(includeProperty);
        }
        return query;
    }

    public Product Find(int id)
    {
        return context.Products.Find(id);
    }

    public void InsertOrUpdate(Product product)
    {
        if (product.ProductId == default(int)) {
            // New entity
            context.Products.Add(product);
        } else {
            // Existing entity
            context.Entry(product).State = EntityState.Modified;
        }
    }

    public void Delete(int id)
    {
        var product = context.Products.Find(id);
        context.Products.Remove(product);
    }

    public void Save()
    {
        context.SaveChanges();
    }
}

Для более детального ознакомления советую почитать серию статей [3] от самих создателей.

Ninject [4] — лично мне не представляется возможность работать без абстракций. ASP.NET MVC имеет множество возможностей контроля/расширения функционала своих фабрик. Поэтому завязывание функционала на конкретных реализациях классов — плохой тон. Почему Ninject? Ответ прост — он легковесен, имеет множество расширений, активно развивается.
Установим его, а также дополнение к нему MVC3 [5]:
После этого появится папка App_Start, где будет располагаться файл NinjectMVC3.cs.
Для реализации DI создадим модуль:

class RepoModule : NinjectModule
{
    public override void Load()
    {
        Bind<ICategoryRepository>().To<CategoryRepository>();

        Bind<IProductRepository>().To<ProductRepository>();
    }
}

В файле NinjectMVC3.cs в методе CreateKernel запишем:

var modules = new INinjectModule[]
            {
                new RepoModule()
            };
            var kernel = new StandardKernel(modules);
            RegisterServices(kernel);
            return kernel;

Теперь напишем наш контроллер:

public class ProductsController : Controller
{
    private readonly IProductRepository productRepository;

    public ProductsController(IProductRepository productRepository)
    {
        this.productRepository = productRepository;
    }
}

NLog [6] — как узнать, как работает приложение, успехи/неудачи при выполнении операций? Самое простое решение — использовать логирование. Писать свои велосипеды смысла нет. Из всех, думаю, можно выделить NLog и log4net. Последний является прямым портом с Java (log4j). Но его развитие не очень активное, если не заброшено вообще. NLog наоборот активно развивается, имеет богатый функционал и простой API.
Как быстро добавить логгер:

public class ProductController : Controller
{
    private static Logger log = LogManager.GetCurrentClassLogger();

    public ActionResult DoStuff()
    {
        //very important stuff
        log.Info("Everything is OK!");

        return View();
    }
}

PagedList [7] — нужен алгоритм «листания страниц»? Да можно самому посидеть и придумать. Но зачем? В этой [8] статье есть детальное описание работы с ним.

Lucene.NET [9] — Вы все еще стираете используете поиск самой БД? Забудьте! Пара минут и у Вас появится сверхскоростной поиск.
Установим его, а также дополнение к нему SimpleLucene [10]:
Первым делом автоматизируем работу с созданием индекса:

public class ProductIndexDefinition : IIndexDefinition<Product>
{
    public Document Convert(Product entity)
    {
        var document = new Document();
        document.Add(new Field("ProductId", entity.ProductId.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));
        document.Add(new Field("Name", entity.Name, Field.Store.YES, Field.Index.ANALYZED));
        if (!string.IsNullOrEmpty(entity.Description))
        {
            document.Add(new Field("Description", entity.Description, Field.Store.YES, Field.Index.ANALYZED));
        }
        
        document.Add(new Field("CreationDate", DateTools.DateToString(entity.CreationDate, DateTools.Resolution.DAY),
                 Field.Store.YES, Field.Index.NOT_ANALYZED));

        if (entity.Price != null)
        {
            var priceField = new NumericField("Price", Field.Store.YES, true);
            priceField.SetIntValue(entity.Price);
            document.Add(priceField);
        }

        document.Add(new Field("CategoryId", entity.CategoryId.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));

        return document;
    }

    public Term GetIndex(Product entity)
    {
        return new Term("ProductId", entity.ProductId.ToString());
    }
}

Как видно в методе Convert мы сериализуем POCO в Lucene Document.
Код контроллера:

public ActionResult Create(Product product)
{
    if (ModelState.IsValid) {
        product.CreationDate = DateTime.Now;

        productRepository.InsertOrUpdate(product);
        productRepository.Save();

        // index location
        var indexLocation = new FileSystemIndexLocation(new DirectoryInfo(Server.MapPath("~/Index")));
        var definition = new ProductIndexDefinition();
        var task = new EntityUpdateTask<Product>(product, definition, indexLocation);
                task.IndexOptions.RecreateIndex = false;
        task.IndexOptions.OptimizeIndex = true;
        //IndexQueue.Instance.Queue(task);
        var indexWriter = new DirectoryIndexWriter(new DirectoryInfo(Server.MapPath("~/Index")), false);

        using (var indexService = new IndexService(indexWriter))
        {
            task.Execute(indexService);
        }

        return RedirectToAction("Index");
    } else {
        ViewBag.PossibleCategories = categoryRepository.All;
			return View();
		}
}

Для вывода результатов создадим ResultDefinition:

public class ProductResultDefinition : IResultDefinition<Product>
{
    public Product Convert(Document document)
    {
        var product = new Product();
        product.ProductId = document.GetValue<int>("ProductId");
        product.Name = document.GetValue("Name");
        product.Price = document.GetValue<int>("Price");
        product.CategoryId = document.GetValue<int>("CategoryId");
        product.CreationDate = DateTools.StringToDate(document.GetValue("CreationDate"));
        product.Description = document.GetValue("Description");
        return product;
    }
}

Здесь происходит десериализация POCO.
И, наконец, автоматизируем работу с запросами:

public class ProductQuery : QueryBase
{
    public ProductQuery(Query query) : base(query) { }

    public ProductQuery() { }

    public ProductQuery WithKeywords(string keywords)
    {
        if (!string.IsNullOrEmpty(keywords))
        {
            string[] fields = { "Name", "Description" };
            var parser = new MultiFieldQueryParser(Lucene.Net.Util.Version.LUCENE_29,
                    fields, new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_29));
                Query multiQuery = parser.Parse(keywords);
 
                this.AddQuery(multiQuery);
            }
            return this;
        }
    }
}

Теперь перейдем к контроллеру:

public ActionResult Search(string searchText, bool? orderByDate)
{
    string IndexPath = Server.MapPath("~/Index");
    var indexSearcher = new DirectoryIndexSearcher(new DirectoryInfo(IndexPath), true);
    using (var searchService = new SearchService(indexSearcher))
    {
        var query = new ProductQuery().WithKeywords(searchText);
        var result = searchService.SearchIndex<Product>(query.Query, new ProductResultDefinition());

        if (orderByDate.HasValue)
        {
            return View(result.Results.OrderBy(x => x.CreationDate).ToList())
        }
        return View(result.Results.ToList());
     }
}

Reactive Extensions for JS [11] — должен быть основой клиента. Нет, честно, более плавного создания каркаса приложения на клиенте с возможностью юнит-тестирования надо еще поискать [12]. Советую почитать мой пост [13] по разработке на Rx.

Аутентификация и авторизация

Сразу же предупреждаю – никогда не используйте стандартный AspNetMembershipProvider! Если посмотреть на его монструозные хранимые процедуры из коробки, то просто захочется его выкинуть.
Откройте в папке C:WindowsMicrosoft.NETFrameworkv4.0.30319 файлы InstallMembership.sql и InstallProfile.SQL.
Например, вот так выглядит SQL-код для FindUsersByName из InstallMembership.sql:

CREATE PROCEDURE dbo.aspnet_Membership_FindUsersByName
    @ApplicationName       nvarchar(256),
    @UserNameToMatch       nvarchar(256),
    @PageIndex             int,
    @PageSize              int
AS
BEGIN
    DECLARE @ApplicationId uniqueidentifier
    SELECT  @ApplicationId = NULL
    SELECT  @ApplicationId = ApplicationId FROM dbo.aspnet_Applications WHERE LOWER(@ApplicationName) = LoweredApplicationName
    IF (@ApplicationId IS NULL)
        RETURN 0

    -- Set the page bounds
    DECLARE @PageLowerBound int
    DECLARE @PageUpperBound int
    DECLARE @TotalRecords   int
    SET @PageLowerBound = @PageSize * @PageIndex
    SET @PageUpperBound = @PageSize - 1 + @PageLowerBound

    -- Create a temp table TO store the select results
    CREATE TABLE #PageIndexForUsers
    (
        IndexId int IDENTITY (0, 1) NOT NULL,
        UserId uniqueidentifier
    )

    -- Insert into our temp table
    INSERT INTO #PageIndexForUsers (UserId)
        SELECT u.UserId
        FROM   dbo.aspnet_Users u, dbo.aspnet_Membership m
        WHERE  u.ApplicationId = @ApplicationId AND m.UserId = u.UserId AND u.LoweredUserName LIKE LOWER(@UserNameToMatch)
        ORDER BY u.UserName


    SELECT  u.UserName, m.Email, m.PasswordQuestion, m.Comment, m.IsApproved,
            m.CreateDate,
            m.LastLoginDate,
            u.LastActivityDate,
            m.LastPasswordChangedDate,
            u.UserId, m.IsLockedOut,
            m.LastLockoutDate
    FROM   dbo.aspnet_Membership m, dbo.aspnet_Users u, #PageIndexForUsers p
    WHERE  u.UserId = p.UserId AND u.UserId = m.UserId AND
           p.IndexId >= @PageLowerBound AND p.IndexId <= @PageUpperBound
    ORDER BY u.UserName

    SELECT  @TotalRecords = COUNT(*)
    FROM    #PageIndexForUsers
    RETURN @TotalRecords
END

А вот так Profile_GetProfiles из InstallProfile.SQL:

CREATE PROCEDURE dbo.aspnet_Profile_GetProfiles
    @ApplicationName        nvarchar(256),
    @ProfileAuthOptions     int,
    @PageIndex              int,
    @PageSize               int,
    @UserNameToMatch        nvarchar(256) = NULL,
    @InactiveSinceDate      datetime      = NULL
AS
BEGIN
    DECLARE @ApplicationId uniqueidentifier
    SELECT  @ApplicationId = NULL
    SELECT  @ApplicationId = ApplicationId FROM aspnet_Applications WHERE LOWER(@ApplicationName) = LoweredApplicationName
    IF (@ApplicationId IS NULL)
        RETURN

    -- Set the page bounds
    DECLARE @PageLowerBound int
    DECLARE @PageUpperBound int
    DECLARE @TotalRecords   int
    SET @PageLowerBound = @PageSize * @PageIndex
    SET @PageUpperBound = @PageSize - 1 + @PageLowerBound

    -- Create a temp table TO store the select results
    CREATE TABLE #PageIndexForUsers
    (
        IndexId int IDENTITY (0, 1) NOT NULL,
        UserId uniqueidentifier
    )

    -- Insert into our temp table
    INSERT INTO #PageIndexForUsers (UserId)
        SELECT  u.UserId
        FROM    dbo.aspnet_Users u, dbo.aspnet_Profile p
        WHERE   ApplicationId = @ApplicationId
            AND u.UserId = p.UserId
            AND (@InactiveSinceDate IS NULL OR LastActivityDate <= @InactiveSinceDate)
            AND (     (@ProfileAuthOptions = 2)
                   OR (@ProfileAuthOptions = 0 AND IsAnonymous = 1)
                   OR (@ProfileAuthOptions = 1 AND IsAnonymous = 0)
                 )
            AND (@UserNameToMatch IS NULL OR LoweredUserName LIKE LOWER(@UserNameToMatch))
        ORDER BY UserName

    SELECT  u.UserName, u.IsAnonymous, u.LastActivityDate, p.LastUpdatedDate,
            DATALENGTH(p.PropertyNames) + DATALENGTH(p.PropertyValuesString) + DATALENGTH(p.PropertyValuesBinary)
    FROM    dbo.aspnet_Users u, dbo.aspnet_Profile p, #PageIndexForUsers i
    WHERE   u.UserId = p.UserId AND p.UserId = i.UserId AND i.IndexId >= @PageLowerBound AND i.IndexId <= @PageUpperBound

    SELECT COUNT(*)
    FROM   #PageIndexForUsers

    DROP TABLE #PageIndexForUsers
END

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

ASP.NET MVC4 Web API

ASP.NET MVC – прекрасный фреймворк для создания RESTful-приложений. Для предоставления API мы могли, например, написать такой код:

public class AjaxProductsController : Controller
{
    private readonly IProductRepository productRepository;

    public AjaxProductsController(IProductRepository productRepository)
    {
        this.productRepository = productRepository;
    }

    public ActionResult Details(int id)
    {
        return Json(productRepository.Find(id));
    }

    public ActionResult List(int category)
    {
        var products = from p in productRepository.All
                       where p.CategoryId == category
                       select p;

        return Json(products.ToList());
    }
}

Да, одним из выходов было написание отдельного контроллера для обслуживания AJAX-запросов.
Другим – спагетти-код:

public class ProductsController : Controller
{
    private readonly IProductRepository productRepository;

    public ProductsController(IProductRepository productRepository)
    {
        this.productRepository = productRepository;
    }

    public ActionResult List(int category)
    {
        var products = from p in productRepository.All
                       where p.CategoryId == category
                       select p;

        if (Request.IsAjaxRequest())
        {
            return Json(products.ToList());
        }
        return View(products.ToList());
    }
}

А если еще и необходимо добавить CRUD-операции, то:

[HttpPost]
public ActionResult Create(Product product)
{
    if (ModelState.IsValid)
    {
        productRepository.InsertOrUpdate(product);
        productRepository.Save();
        return RedirectToAction("Index");
    }
    return View();
}

Как видно атрибуты, детектирование AJAX в коде – не самый чистый код. Мы же пишем API, верно?
Выход MVC4 ознаменовал новый функционал Web API. На первый взгляд – это смесь MVC-контроллеров и WCF Data Services.
Не буду приводить туториал на тему Web API, их много на самом сайте ASP.NET MVC [14].
Приведу лишь пример переписанного вышеприведенного кода.
Для начала чуть изменим метод InsertOrUpdate из ProductRepository:

public Product InsertOrUpdate(Product product)
{
    if (product.ProductId == default(int)) {
        // New entity
        return context.Products.Add(product);
    }
    // Existing entity
    context.Entry(product).State = EntityState.Modified;
    return context.Entry(product).Entity;
}

И напишем сам контроллер:

public class ProductsController : ApiController
{
    /*
     * инициализация
     */

    public IEnumerable<Product> GetAllProducts(int category)
    {
        var products = from p in productRepository.All
                       where p.CategoryId == category
                       select p;
        return products.ToList();
    }

    // Not the final implementation!
    public Product PostProduct(Product product)
    {
        var entity = productRepository.InsertOrUpdate(product);
        return entity;
    }
}

Итак, пара моментов, что же изменилось и как оно работает:

  • Теперь контроллеры наследуются от ApiController
  • Больше никаких ActionResult и т.п. – только чистый код
  • Больше никаких HttpPost и т.п. атрибутов
  • Имя метода должно начинаться с Get для get-запросов, POST – для post-запросов.
  • Аналог метода Index в Web API – GetAll{0} – имя контроллера

Чуть выше я указал, что Web API – смесь MVC и WCF Data Services. Но где это выражено? Все просто – новый API поддерживает OData! И работает по схожему принципу.
Например, для указания сортировки необходимо было указывать параметр в самом методе:

public ActionResult List(string sortOrder, int category)
{
    var products = from p in productRepository.All
                   where p.CategoryId == category
                   select p;

    switch (sortOrder.ToLower())
    {
        case "name":
            products = products.OrderBy(x => x.Name);
            break;
        case "desc":
            products = products.OrderBy(x => x.Description);
            break;
    }

    return Json(products.ToList());
}

То сейчас необходимо лишь изменить метод GetAllProducts:

public IQueryable<Product> GetAllProducts(int category)
{
    var products = from p in productRepository.All
                   where p.CategoryId == category
                   select p;
    return products;
}

И в браузере, например, набрать следующее:
localhost/api/products?category=1& [15]$orderby=Name

Таким образом, мы избавились от отвлекающих моментов и можем теперь сосредоточиться на создании самого API.

Спасибо за внимание!

Автор: szKarlen


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/net/6530

Ссылки в тексте:

[1] Entity Framework 4.1: http://nuget.org/packages/entityframework

[2] MvcScaffolding: http://nuget.org/packages/MvcScaffolding

[3] серию статей: http://blog.stevensanderson.com/2011/01/13/scaffold-your-aspnet-mvc-3-project-with-the-mvcscaffolding-package/

[4] Ninject: http://nuget.org/packages/Ninject

[5] MVC3: http://nuget.org/packages/Ninject.MVC3

[6] NLog: http://nuget.org/packages/NLog

[7] PagedList: http://nuget.org/packages/PagedList

[8] этой: http://habrahabr.ru/company/microsoft/blog/133845/

[9] Lucene.NET: http://nuget.org/packages/Lucene.Net

[10] SimpleLucene: http://nuget.org/packages/SimpleLucene

[11] Reactive Extensions for JS: http://nuget.org/packages/RxJS-Main/1.0.10621.0

[12] поискать: http://nuget.org/packages/Backbone.js

[13] мой пост: http://habrahabr.ru/post/132463/

[14] ASP.NET MVC: http://www.asp.net/web-api/overview

[15] localhost/api/products?category=1&: http://localhost/api/products?category=1&