В этой статье я бы хотел сделать несколько заметок по поводу построения приложений на вышеприведенном фреймворке: минимальный набор 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.