В этой статье я бы хотел сделать несколько заметок по поводу построения приложений на вышеприведенном фреймворке: минимальный набор 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 — возникает вопрос, почему он? Ну что ж поясню на примере. Существует достаточное количество других схожих, превосходящих и пр. 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 — нужно быстро набросать 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();
    }
}

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

Ninject — лично мне не представляется возможность работать без абстракций. ASP.NET MVC имеет множество возможностей контроля/расширения функционала своих фабрик. Поэтому завязывание функционала на конкретных реализациях классов — плохой тон. Почему Ninject? Ответ прост — он легковесен, имеет множество расширений, активно развивается. Установим его, а также дополнение к нему MVC3: После этого появится папка 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 — как узнать, как работает приложение, успехи/неудачи при выполнении операций? Самое простое решение — использовать логирование. Писать свои велосипеды смысла нет. Из всех, думаю, можно выделить 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 — нужен алгоритм «листания страниц»? Да можно самому посидеть и придумать. Но зачем? В этой статье есть детальное описание работы с ним.

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

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 — должен быть основой клиента. Нет, честно, более плавного создания каркаса приложения на клиенте с возможностью юнит-тестирования надо еще поискать. Советую почитать мой пост по разработке на Rx.

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

Сразу же предупреждаю – никогда не используйте стандартный AspNetMembershipProvider! Если посмотреть на его монструозные хранимые процедуры из коробки, то просто захочется его выкинуть. Откройте в папке C:\Windows\Microsoft.NET\Framework\v4.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. Приведу лишь пример переписанного вышеприведенного кода. Для начала чуть изменим метод 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;
}

И в браузере, например, набрать следующее:

http://localhost/api/products?category=1&$orderby=Name

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