Уже, наверное, раза три подбираюсь к ASP.NET MVC. После десяти лет с ASP.NET WebForms немного сложно переходить именно к технологии MVC, поскольку отличий столько, что скорее проще перечислить, что у этих технологий общего – это разве что библиотеки .NET Framework. Я не буду писать тут – лучше или хуже MVC чем WebForms, просто они обе хороши, и на обеих технологиях можно построить хорошее приложение. Свои мысли по поводу необходимости TDD я тоже пока оставлю при себе, хотя их есть у меня.
А сейчас я буду говорить о стандартнейшей задаче – обычной работе с данными: просмотре в табличном виде списка записей, добавлении, изменении и удалении данных (операции CRUD). Однако практически во всех книгах и во многих решениях в интернете для ASP.NET MVC почему-то рассматривается вариант исключительно через ORM (Object Relation Mapping): или Entity Framework (EF) или LINQ для SQL. Технологии отличные, спору нет, наконец-то программист может и не разбираться – а как вообще эта самая реляционная СУБД (которой он, скорее всего, пользуется) вообще работает, и даже SQL, по идее, знать уже необязательно: прокладка в виде EF и коннектора для СУБД разберутся между собой. «Вот оно счастье – нет его краше». Но тем программистам, которые не боятся прямой работы с базой данных через механизм ADO.NET, зачастую непонятно – а с чего вообще начинать в ASP.NET MVC и надо ли.
Плюс к этому, у меня, например, вначале вызвало дикую ломку отсутствие удобного компонента для отображения данных в таблице-гриде. Подразумевается, что это всё должен реализовывать сам разработчик или брать в менеджере пакетов что-то подходящее. Если вас, как и меня, компонент GridView в ASP.NET WebForms устраивал более чем, то для MVC сложно найти что-то более-менее сопоставимое, разве что Grid.mvc. Но вот доверия у меня к подобным компонентам маловато, чтобы их использовать для достаточно крупного проекта. В случае их использования, программист начинает зависеть от другого разработчика, который, во-первых, неизвестно как написал этот компонент (качественно ли?), а, во-вторых, неизвестно когда и как будет его дорабатывать. Расширять компонент и пилить дальше иногда вроде бы даже можно, но, в случае доработки оного разработчиком, мы вынуждены либо опять перелопачивать свой код, либо замораживать обновление компонента на определенной версии. А если разработчик устранит какую-либо уязвимость – всё равно придется переходить на новую версию. Даже переход на новые версии коннектора MySQL вызывает определенные проблемы, хотя его всё-таки крупная компания разрабатывает, а что там с многочисленными «велосипедами» в менеджере пакетов Nuget?
Итак, попытаемся научиться писать под ASP.NET MVC, при этом будем работать с данными, которые обрабатываются СУБД MySQL. Весь код можно взять вот по этому адресу, ниже этот код будет частично презентован с небольшими ссылками и пояснениями. А здесь можно посмотреть на работу приложения.
Создаем базу данных в MySQL
CREATE SCHEMA `example`;
USE `example`;
CREATE TABLE `users` (
`UserID` int(4) unsigned NOT NULL AUTO_INCREMENT,
`SupporterTier` enum('None','Silver','Gold','Platinum') NOT NULL DEFAULT 'None',
`Loginname` varchar(255) NOT NULL,
`LanguageID` int(4) unsigned NOT NULL DEFAULT '2',
`Email` varchar(255) DEFAULT NULL,
`LastLoginDate` datetime DEFAULT NULL,
PRIMARY KEY (`UserID`),
KEY `i_Loginname` (`Loginname`),
KEY `i_Language_idx` (`LanguageID`),
CONSTRAINT `i_Language` FOREIGN KEY (`LanguageID`) REFERENCES `languages` (`LanguageID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE `languages` (
`LanguageID` int(4) unsigned NOT NULL AUTO_INCREMENT,
`LanguageName` varchar(50) NOT NULL DEFAULT '',
`Culture` varchar(10) DEFAULT NULL,
PRIMARY KEY (`LanguageID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
База данных (БД) будет простая и представлена всего двумя таблицами, со одной связью «один-ко-многим» между ними по полю LanguageID
. Этим я собираюсь усложнить ситуацию для необходимости использования выпадающего списка выбора одного из языков для пользователя. Также, для усложнения, для пользователя введём еще поле SupporterTier
, которое будет определять уровень поддержки пользователя посредством перечисления (enum). А для того, чтобы у нас в проекте использовались почти все типы данных, добавим ещё поле LastLoginDate
типа «дата/время», которое будет заполняться самим приложением при входе пользователя (в данном проекте не отражено).
Создаем проект
Выбираем «MVC». Можно и «Пустой», но у нас учебное, а не реальное приложение, поэтому это нам поможет сразу, без лишних телодвижений, интегрировать в наше приложение Bootstrap и JQuery.
Получаем уже заполненные папки Content, Fonts, Scripts, а также файлы BundleConfig.cs и FilterConfig.cs в каталоге App_Start с регистрациями связок и фильтров ASP.NET MVC. В пустом проекте там есть только регистрация маршрутов в файле RouteConfig.cs. В Global.asax.cs также добавятся вызовы методов, описанных в этих файлах:
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
Настраиваем инфраструктуру и всю обвязку
Для работы с СУБД MySQL добавляем библиотеку MySql.Data: либо вручную, если коннектор mysql-connector-net-8.0.18 уже установлен на компьютер, либо из менеджера пакетов Nuget:
Добавляем в файл Web.config конфигурацию строки подключения к СУБД MySQL:
<connectionStrings>
<add name="example" providerName="MySql.Data.MySqlClient" connectionString="server=localhost;Port=3306;user id=develop;Password=develop;persistsecurityinfo=True;database=example;CharSet=utf8;SslMode=none" />
</connectionStrings>
Добавляем в раздел <appSettings>
строку со ссылкой на добавленную строку подключения: <add key="ConnectionString" value="example" />
Добавляем в приложение новый каталог Domain, в нём создаем новый статический класс Base
(в файле Base.cs), в котором идут обращения к этим параметрам:
public static class Base
{
private static string ConnectionString
{
get
{ return System.Configuration.ConfigurationManager.AppSettings["ConnectionString"]; }
}
public static string strConnect
{
get
{ return System.Configuration.ConfigurationManager.ConnectionStrings[ConnectionString].ConnectionString; }
}
}
Мне нравится иметь в приложении некий базовый класс со ссылками на параметры приложения и какими-нибудь стандартными функциями, которые можно было бы вызывать из всего приложения.
Название строки подключения определено в параметрах приложения, чтобы в дальнейшем было проще работать со строкой подключения к СУБД: быстро переключаться между разными базами данных и менять параметры подключения. Кроме этого, использование названия строки подключения в параметре приложения, удобно использовать для публикации приложения в Microsoft Azure – там можно задать параметр для службы приложения, которая используется для публикации, и в нём определить нужную строку подключения, которая заранее определена в <connectionStrings>
. Тогда при публикации можно не использовать трансформацию файла web.config.
Также мне нравится в тексте использовать значения из глобального файла ресурсов, чтобы не переписывать их в нескольких местах, если это вдруг потребуется. Например:
В файле-макете страницы _Layout.cshtml (стандартно располагается в каталоге ViewsShared и в дальнейшем используется для всех страниц данного проекта) можно теперь использовать эти переменные (см. например, Example_Users.Properties.Resources.Title
):
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewBag.Title – @Example_Users.Properties.Resources.Title</title>
@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/modernizr")
</head>
<body>
<div class="container body-content">
<h1 class="page-header"><a href="/">@Example_Users.Properties.Resources.Title</a></h1>
@RenderBody()
<hr />
<footer>
<p> @DateTime.Now.Year – @Example_Users.Properties.Resources.Author</p>
</footer>
</div>
@Scripts.Render("~/bundles/jquery")
@Scripts.Render("~/bundles/bootstrap")
@RenderSection("scripts", required: false)
</body>
</html>
Также в этом файле видим прикрепление каскадной таблицы стилей подключенного Bootstrap и библиотеки скриптов JQuery. Содержимое всех представлений будет генерироваться в месте размещения вызова функции RenderBody()
.
M — значит модель
Добавляем файл UserClass.cs в каталог Domain:
[DisplayName("User")]
public class UserClass
{
[Key]
[HiddenInput(DisplayValue=false)]
public int UserID { get; set; }
[Required(ErrorMessage="Please enter a login name")]
[Display(Name = "Login")]
public string Loginname { get; set; }
public virtual LanguageClass Language { get; set; }
[EmailAddress(ErrorMessage = "Please enter a valid email")]
public string Email { get; set; }
[UIHint("Enum")]
[EnumDataType(typeof(Supporter))]
[Required(ErrorMessage = "Please select supporter tier")]
[Display(Name = "Supporter")]
public Supporter SupporterTier { get; set; }
[HiddenInput(DisplayValue = true)]
[ScaffoldColumn(false)]
[Display(Name = "Last login")]
public DateTime? LastLoginDate { get; set; }
[HiddenInput(DisplayValue = false)]
public bool IsLastLogin
{
get { return LastLoginDate != null && LastLoginDate > DateTime.MinValue; }
}
public UserClass() {}
public UserClass(int UserID, string Loginname, LanguageClass Language, string Email, DateTime? LastLoginDate, Supporter SupporterTier)
{
this.UserID = UserID;
this.Loginname = Loginname;
this.Language = Language;
this.Email = Email;
this.LastLoginDate = LastLoginDate;
this.SupporterTier = SupporterTier;
}
}
public enum Supporter
{
[Display(Name="")]
None = 1,
Silver = 2,
Gold = 3,
Platinum = 4
}
А также файл LanguageClass.cs в тот же каталог:
[DisplayName("Language")]
public class LanguageClass
{
[Key]
[HiddenInput(DisplayValue = false)]
[Required(ErrorMessage = "Please select a language")]
[Range(1, int.MaxValue, ErrorMessage = "Please select a language")]
public int LanguageID { get; set; }
[Display(Name = "Language")]
public string LanguageName { get; set; }
public LanguageClass() {}
public LanguageClass(int LanguageID, string LanguageName)
{
this.LanguageID = LanguageID;
this.LanguageName = LanguageName;
}
}
Тут можно видеть, что свойства классов повторяют структуру таблицы Users
и Languages
в СУБД. Для типа перечисления создан enum Supporter
, чтобы его можно было использовать для свойства класса SupporterTier
аналогичного поля таблицы БД. Для полей UserID
, LanguageID
можно видеть, что они заданы, как первичный ключ, точно так же, как и в БД. Для этого использован атрибут [Key]
.
Все остальные атрибуты имеют отношение скорее к представлениям (view), использующим этот класс. И если мы собираемся использовать хелперы для формирования HTML-тегов для этих свойств (что я лично однозначно рекомендовал бы), то нам придется очень тщательно устанавливать эти атрибуты, чтобы получить то, что нам надо. В частности, вот то, что понадобилось для этого проекта:
[DisplayName]
– используется как отображаемое на экран имя для класса. Иногда может быть полезно, в данном проекте специально добавил использование хелпераHtml.DisplayNameForModel
для демонстрации.[Display]
со свойствомName
– используется как отображаемое на экран название свойства класса. Еще есть полезное свойство Order, позволяющее упорядочить последовательность отображения свойств класса в форме с использованием хелперов (по умолчанию сортировка по порядку определения свойств класса, поэтому в данном проекте свойство Order не использовалось).[HiddenInput]
со свойствомDisplayValue
. Используется для свойств, которые либо не надо показывать в формах и списках вообще (DisplayValue=false
, отрисовываются как тегиinput
с типомhidden
), либо для свойств, которые всё-таки надо отображать, но в виде неизменяемого текста (DisplayValue=true
, отрисовывается как чистый текст, без тегов)[ScaffoldColumn]
– указывает, отображать ли поле в хелперах редактирования (например,Html.EditorForModel
). Еслиfalse
– то в форме не будут отображены ни описание свойства класса, ни его значение. Тут нельзя использовать[HiddenInput(DisplayValue = false)]
, потому что в этом случае значения данного свойства класса вообще не будут отображены не только в формах ввода информации, но и в табличных отображениях. В данном случае это потребовалось для свойстваLastLoginDate
, которое не вводится вручную, а заполняется где-то автоматически, но видеть нам его всё-таки надо.[Required]
– для проверки того, введено ли значение для свойства класса, с текстом сообщения об ошибке в свойствеErrorMessage
и свойствомAllowEmptyStrings
позволяющим вводить пустые строки.[EmailAddress]
– аналогичный, по сути, атрибут для проверки корректности почтового адреса.
Классы модели БД готовы, переходим к представлениям (классы моделей именно для представлений опишем ниже).
V — значит вендетта представление
В каталоге Views создаем каталог Users для наших представлений. Все наши представления используют стандартный (определен в файле _ViewStart.cshtml в каталоге Views) макет _Layout.cshtml, расположенный в каталоге ViewsShared. Создаем представление Index
(файл Index.cshtml в каталоге Users):
И пишем код:
@model Example_Users.Models.UsersGrid
@{
ViewBag.Title = "Users page";
}
@using (@Html.BeginForm())
{
<div>
<h3>Users list:</h3>
@if (TempData["message"] != null)
{<div class="text-success">@TempData["message"]</div>}
@if (TempData["error"] != null)
{<div class="text-warning"><span class="alert">ERROR:</span> @TempData["error"]</div>}
@Html.Partial("List")
<p>
<input type="submit" name="onNewUser" value="New user" />
@*@Html.ActionLink("New user", "New", "Users")*@
</p>
</div>
}
Если мы хотим, чтобы данное представление запускалось по умолчанию, то в файл RouteConfig.cs вносим изменение для Default
:
routes.MapRoute(name: "Default", url: "{controller}/{action}/{id}",
defaults: new { controller = "Users", action = "Index", id = UrlParameter.Optional });
В самом представлении надо обратить внимание на строчку с Html.Partial("List")
. Это нужно для отрисовки в данном месте специального отдельного общего частичного представления, расположенного в файле List.cshtml в каталоге ViewsShared. Собственно оно представляет из себя именно таблицу-грид для отображения данных из нашей таблицы БД users
:
@model Example_Users.Models.UsersGrid
@using Example_Users.Domain
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Loginname).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Loginname).ToString())))
@Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Loginname).ToString())
</th>
<th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Language.LanguageName).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Language).ToString())))
@Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Language).ToString())
</th>
<th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Email).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Email).ToString())))
@Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Email).ToString())
</th>
<th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().SupporterTier).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().SupporterTier).ToString())))
@Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().SupporterTier).ToString())
</th>
<th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().LastLoginDate).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().LastLoginDate).ToString())))
@Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().LastLoginDate).ToString())
</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Users)
{
<tr>
<td>@Html.ActionLink(item.Loginname, "Edit", "Users", new { UserID = item.UserID }, null)</td>
<td>@Html.DisplayFor(modelitem => item.Language.LanguageName)</td>
<td>@Html.DisplayFor(modelitem => item.Email)</td>
<td class="@Html.DisplayFor(modelitem => item.SupporterTier)">@if (item.SupporterTier != Supporter.None) {@Html.DisplayFor(modelitem => item.SupporterTier);}</td>
<td>@if (item.IsLastLogin) {@Html.DisplayFor(modelitem => item.LastLoginDate)}</td>
</tr>
}
</tbody>
</table>
</div>
@if (Model.PagingInfo.totalPages > 1)
{
<ul class="pagination">
@Html.PageLinks(Model.PagingInfo, x => Url.Action("Index", new { page = x, sortOrder = Model.SortingInfo.currentSort }))
</ul>
}
Видно, что в заголовке таблицы данных используются хелперы Html.DisplayNameFor
для отображения названий колонок и для этого приходится указывать ссылку на свойство объекта класса. Поскольку при формировании заголовка таблицы у нас есть только объект Model.Users
, который представляет собой список объектов типа UserClass
, то приходится применять следующий способ: выбирать первую строку этого списка, как объект класса UserClass
. Например, для имени пользователя: Model.Users.First().Loginname
. Поскольку у свойства Loginname
класса Users
указан атрибут [Display(Name = "Login")]
, то в названии колонки будет выведено именно «Login»:
Что еще интересно в представлении List
? Блок с foreach
, понятно, отрисовывает объекты класса UserClass
, которые находятся в списке Users
, полученного из контроллера. А интересны тут объекты SortingInfo
и PagingInfo
в нашей модели UsersGrid
. А эти объекты нужны нам для организации сортировки данных (используется в заголовке таблицы в тегах <th>
) и организации постраничного вывода информации (используется внизу страницы, под таблицей). Именно поэтому мы не используем в качестве модели чисто список объектов типа IEnumerable<UserClass>
. А в качестве модели используем класс UsersGrid
, который расположили в файле UsersGrid.cs в каталоге Model.
public class UsersGrid
{
public IEnumerable<UserClass> Users { get; set; }
public PagingInfo PagingInfo { get; set; }
public SortingInfo SortingInfo { get; set; }
}
И сами классы PagingInfo
и SortingInfo
в файле GridInfo.cs
в том же месте.
public class PagingInfo
{
// всего строк в выборке
public int totalItems { get; set; }
// сколько отображать на страницу
public int itemsPerPage { get; set; }
// текущая страница
public int currentPage { get; set; }
// сколько максимально можно отобразить ссылок на страницы таблицы
public int showPages { get; set; }
// всего страниц
public int totalPages
{
get { return (int)Math.Ceiling((decimal)totalItems / itemsPerPage); }
}
// сколько отобразить ссылок на страницы слева и справа от текущей
public int pagesSide
{
get { return (int)Math.Truncate((decimal)showPages / 2); }
}
}
public class SortingInfo
{
// название поля, по которому идёт сортировка
public string currentOrder { get; set; }
// направление сортировки
public SortDirection currentDirection { get; set; }
// получение строки параметра для передачи
public string currentSort
{
get { return currentDirection != SortDirection.Descending ? currentOrder : currentOrder + "_desc"; }
}
// генерация нового порядка сортировки для столбцов (если уже была сортировка по столбцу - делаем обратную сортировку)
public string NewOrder(string columnName)
{
return columnName == currentOrder && currentDirection != SortDirection.Descending ? columnName + "_desc" : columnName;
}
}
А для использования в представлениях добавлены специальные хелперы в файле GridHelpers.cs (каталог HtmlHelpers):
public static class GridHelpers
{
// Отображаем пейджер в виде 1 ... 3 4 5 ... Last
public static MvcHtmlString PageLinks(this HtmlHelper html, PagingInfo pagingInfo, Func<int, string> pageUrl)
{
StringBuilder result = new StringBuilder();
if (pagingInfo.currentPage > pagingInfo.pagesSide + 1)
{// первая страница
TagBuilder li = new TagBuilder("li");
li.AddCssClass("page-item");
TagBuilder tag = new TagBuilder("a");
tag.MergeAttribute("href", pageUrl(1));
tag.InnerHtml = "1";
li.InnerHtml = tag.ToString();
result.Append(li.ToString());
}
int page1 = pagingInfo.currentPage - pagingInfo.pagesSide;
int page2 = pagingInfo.currentPage + pagingInfo.pagesSide;
if (page1 < 1)
{
page2 = page2 - page1 + 1;
page1 = 1;
}
if (page2 > pagingInfo.totalPages) page2 = pagingInfo.totalPages;
if (page1 > 2)
{// ...
TagBuilder li = new TagBuilder("li");
li.AddCssClass("page-item");
TagBuilder tag = new TagBuilder("span");
tag.InnerHtml = "...";
tag.AddCssClass("page-item");
tag.AddCssClass("disabled");
li.InnerHtml = tag.ToString();
result.Append(li.ToString());
}
for (int i = page1; i <= page2; i++)
{// страницы
TagBuilder li = new TagBuilder("li");
li.AddCssClass("page-item");
if (i == pagingInfo.currentPage) li.AddCssClass("active");
TagBuilder tag = new TagBuilder("a");
tag.AddCssClass("page-link");
tag.MergeAttribute("href", pageUrl(i));
tag.InnerHtml = i.ToString();
li.InnerHtml = tag.ToString();
result.Append(li.ToString());
}
if (page2 < pagingInfo.totalPages)
{// ... и последняя страница
TagBuilder li = new TagBuilder("li");
li.AddCssClass("page-item");
TagBuilder tag = new TagBuilder("span");
tag.InnerHtml = "...";
tag.AddCssClass("page-item");
tag.AddCssClass("disabled");
li.InnerHtml = tag.ToString();
result.Append(li.ToString());
li = new TagBuilder("li");
li.AddCssClass("page-item");
tag = new TagBuilder("a");
tag.MergeAttribute("href", pageUrl(pagingInfo.totalPages));
tag.InnerHtml = pagingInfo.totalPages.ToString();
li.InnerHtml = tag.ToString();
result.Append(li.ToString());
}
return MvcHtmlString.Create(result.ToString());
}
public static IHtmlString SortIdentifier(this HtmlHelper htmlHelper, string sortOrder, string field)
{
if (string.IsNullOrEmpty(sortOrder) || (sortOrder.Trim() != field && sortOrder.Replace("_desc", "").Trim() != field)) return null;
string glyph = "glyphicon glyphicon-chevron-up";
if (sortOrder.ToLower().Contains("desc"))
{
glyph = "glyphicon glyphicon-chevron-down";
}
var span = new TagBuilder("span");
span.Attributes["class"] = glyph;
return MvcHtmlString.Create(span.ToString());
}
public static RouteValueDictionary ToRouteValueDictionary(this NameValueCollection collection, string newKey, string newValue)
{
var routeValueDictionary = new RouteValueDictionary();
foreach (var key in collection.AllKeys)
{
if (key == null) continue;
if (routeValueDictionary.ContainsKey(key))
routeValueDictionary.Remove(key);
routeValueDictionary.Add(key, collection[key]);
}
if (string.IsNullOrEmpty(newValue))
{
routeValueDictionary.Remove(newKey);
}
else
{
if (routeValueDictionary.ContainsKey(newKey))
routeValueDictionary.Remove(newKey);
routeValueDictionary.Add(newKey, newValue);
}
return routeValueDictionary;
}
}
Поскольку грид с данными без сортировки и без постраничного вывода информации достаточно бесполезная вещь, а стандартного хелпера для целой таблицы данных в ASP.NET MVC нет, то приходится их создавать самостоятельно (либо брать созданный кем-то другим). В данном случае, я подсмотрел несколько реализаций в книгах по ASP.NET MVC и решений представленных в интернете. Причем, почему-то решений, объединяющих вместе хотя бы и сортировку данных и постраничный вывод, либо вовсе нет, либо я не нашел. Пришлось всё это дело осмысливать, объединять и дорабатывать до нормального состояния. Например, постраничный вывод в тех реализациях зачастую не предусматривает вывод более-менее длинного списка страниц – ну а вдруг будет несколько тысяч страниц? Привожу пример отображения для представленного выше решения:
Также нам потребуются представления для создания и модификации данных. Представление для создания объекта типа UserClass
:
И код представления будет выглядеть следующим образом:
@model Example_Users.Models.UserModel
@{
ViewBag.Title = "New " + Html.DisplayNameForModel().ToString().ToLower();
}
<h2>@ViewBag.Title</h2>
@using (@Html.BeginForm("New", "Users", FormMethod.Post))
{
@Html.EditorFor(m => m.User);
@Html.LabelFor(m => Model.User.Language)<br />
@Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages())
<span/>@Html.ValidationMessageFor(m => Model.User.Language.LanguageID)<br />
<br />
<p><input type="submit" name="action" value="Add" /> <input type="button" onclick="history.go(-1)" value="Cancel" /></p>
@*<p>@Html.ActionLink("Back to list", "Index")</p>*@
}
В этом представлении для примера показано использование хелпера Html.EditorFor
в качестве средства для генерации тегов для редактирования всех свойств объектов класса UserClass
. Оно отображается следующим образом:
В этом представлении используется в качестве модели класс UserModel
, а не UserClass
непосредственно. Сам класс UserModel
размещен в файле UserModel.cs в каталоге Models:
public class UserModel
{
public UserClass User { get; set; }
private IList<LanguageClass> Languages { get; set; }
public UserModel() {}
public UserModel(UserClass user, IList<LanguageClass> languages)
{
this.User = user;
this.Languages = languages;
}
public IEnumerable<SelectListItem> SelectLanguages()
{
if (Languages != null) { return new SelectList(Languages, "LanguageID", "LanguageName"); }
return null;
}
}
В этот класс включен собственно сам объект UserClass
и дополнительный список объектов типа LanguageClass
. Этот список нам потребовался для создания выпадающего списка языков, с выбором в нём текущего языка пользователя: @Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages(), "")
В этом хелпере используется вызов функции SelectLanguages()
, которая преобразует список языков в объект типа SelectList
с уже установленными параметрами идентификатора и названия строки. Выносить генерацию этого объекта в представление было бы неверным, потому что представление по идее не должно знать об этих привязках к названиям полей. Можно было бы, конечно, сразу в контроллере сгенерировать готовый SelectList
, но мне вариант с приватным списком объектов доменного класса и функцией нравится больше.
Для генерации выпадающего списка нам приходится использовать отдельные хелперы, потому что хелпер Html.EditorFor(m => m.User)
не будет генерировать разметку редактирования для вложенного объекта типа LanguageClass
(это можно было бы обойти написав общий шаблон для выпадающих списков, но тут мы это делать не будем…).
И поскольку у нас в представлении используется объект класса UserModel
, который включает в себя еще один объект класса UserClass
, то и использовать хелпер Html.EditorForModel()
не удастся, поскольку хелперы не являются рекурсивными и не будут работать в данной ситуации, поэтому используется хелпер Html.EditorFor()
для объекта User
.
Также хочется обратить внимание на закомментированный тэг: Html.ActionLink("Back to list", "Index")
Обычно подобным образом реализуется возврат из представления для редактирования обратно в список данных. На самом деле, во-первых, на мой взгляд, это смотрится странно – когда в форме у тебя используются кнопки типа button
, а кнопка возврата почему-то реализована ссылкой. Во-вторых, если мы будем использовать сортировку и постраничный вывод – придется мудрить с возвратом в тот же вид на ту же страницу – и передавать в представление не только объект UserClass
, но и параметры для возврата обратно на страницу. Есть гораздо более простой способ – воспользоваться вариантом с кнопкой вида: />
, которая и отправит пользователя обратно на страницу по истории браузера. Тут, безусловно, есть нюансы (например, вы уже на этой странице пытались сохранить объект, не получилось, и вот – вам уже надо дважды щелкать кнопку отмены), но данный вариант в целом неплохо работает.
И представление для редактирования объекта типа UserClass
:
И его код:
@model Example_Users.Models.UserModel
@{
ViewBag.Title = "Edit " + Html.DisplayNameForModel().ToString().ToLower();
}
<h2>@ViewBag.Title @Model.User.Loginname</h2>
@using (@Html.BeginForm("Edit", "Users", FormMethod.Post))
{
@Html.HiddenFor(m => Model.User.UserID)
<div>
@Html.LabelFor(m => Model.User.Loginname)
@Html.EditorFor(m => Model.User.Loginname)
@Html.ValidationMessageFor(m => Model.User.Loginname)
</div>
<div>
@Html.LabelFor(m => Model.User.Language)
@Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages())
@Html.ValidationMessageFor(m => Model.User.Language.LanguageID)
</div>
<div>
@Html.LabelFor(m => Model.User.Email)
@Html.EditorFor(m => Model.User.Email)
@Html.ValidationMessageFor(m => Model.User.Email)
</div>
<div>
@Html.LabelFor(m => Model.User.SupporterTier)
@Html.EditorFor(m => Model.User.SupporterTier)
@*@Html.EnumDropDownListFor(m => Model.User.SupporterTier)*@
@*@Html.DropDownListFor(m => m.Model.User.SupporterTier, new SelectList(Enum.GetNames(typeof(Example_Users.Domain.Supporter))))*@
@Html.ValidationMessageFor(m => Model.User.SupporterTier)
</div>
<br />
<p><input type="submit" name="action" value="Save" /> <input type="submit" name="action" value="Remove" onclick="javascript:return confirm('Are you sure?');" /> <input type="submit" name="action" value="Cancel" /></p>
}
А в этом представлении представлен вариант использования разных хелперов для генерации тегов отдельно для каждого нужного свойства объекта класса. В данном случае можно уже сделать отображение в несколько другом – более симпатичном виде:
И в этом представлении используется другой способ возврата обратно в список. Вместо использования кнопки вида: />
используется такая же кнопка, как и остальные кнопки действия (Save, Remove): />
и возврат обратно осуществляется внутри метода контроллера, обрабатывающего действия этих кнопок (реализацию метода см. ниже).
Enum
И тут еще возник нюанс, связанный с использованием свойства класса типа enum. Дело в том, что если мы просто будем использовать хелпер Html.EditorFor()
, то на форме отобразится поле ввода текстовой информации (тег вида <input type=”text”/>
), а нам вообще-то нужно поле с выбором из набора значений (т.е. тег <select>
с набором опций <option>
).
1. Прямолинейно это решается использованием хелпера типа Html.DropDownListFor()
или Html.ListBoxFor()
, например, в нашем случае: @Html.DropDownListFor(m => m.Model.User.SupporterTier, new SelectList(Enum.GetNames(typeof(Example_Users.Domain.Supporter))))
. Тут два минуса – каждый раз это придется прописывать индивидуально и для хелпера Html.EditorForModel()
или Html.EditorFor()
это не подойдет.
2. Можно создать пользовательский шаблон типа Editor
. Создаем файл Supporter.cshtml в папке ViewsSharedEditorTemplates:
@model Example_Users.Domain.Supporter
@Html.DropDownListFor(m => m, new SelectList(Enum.GetNames(Model.GetType()), Model.ToString()))
Название файла должно соответствовать названию типа, либо придется писать не Html.EditorFor(m => Model.SupporterTier)
, а Html.EditorFor(m => Model.SupporterTier, "Supporter")
. Если файл назвать по-другому, то перед описанием свойства в классе надо будет добавить подсказку [UIHint("Supporter")]
. И это также придется делать, если планируется использовать Html.EditorForModel()
– для него решение вида Html.EditorFor(m => Model.SupporterTier, "Supporter")
не подойдет.
Мне этот вариант не подошел из-за того что, при попытке создания пользователя (запуск представления New
) программа для хелперов Html.EditorFor()
и Html.EditorForModel()
выпадала с ошибкой: «Элемент модели, переданный в словарь, имеет значение NULL, но для этого словаря требуется элемент модели типа «Example_Users.Domain.Supporter», не имеющий значение NULL.» Причина понятна – значение для перечисления не может быть пустым, но решить проблему так и не удалось. Поэтому стал разбираться дальше.
3. Можно использовать хелпер Html.EnumDropDownListFor()
, специально сделанный для перечислений. Вот тут всё хорошо, ничего дополнительно писать не нужно, отображается всё корректно, работает и при редактировании и при создании объекта. Кроме одного «но»: хелпер Html.EditorForModel()
использует для отображения всех свойств хелперы Html.EditorFor()
и, соответственно, не использует Html.EnumDropDownListFor()
. И, как я понял, это нельзя обойти с помощью атрибутов для свойств класса – [UIHint]
, [DataType]
и [EnumDataType]
тут не сработают. Также не будут работать атрибуты для значений перечисления, то есть вместо None не получится, например, вывести пустую строку, как это определено в описании перечисления Supporter
.
4. В итоге, мне подошел вариант решения, найденный на просторах интернета: создание общего шаблона для перечислений. Создаем файл Enum.cshtml в папке ViewsSharedEditorTemplates:
@using System.ComponentModel.DataAnnotations
@model Enum
@{
Func<object, string> GetDisplayName = o =>
{
var result = null as string;
var display = o.GetType()
.GetMember(o.ToString()).First()
.GetCustomAttributes(false)
.OfType<DisplayAttribute>()
.LastOrDefault();
if (display != null) result = display.GetName();
return result ?? o.ToString();
};
var values = Enum.GetValues(ViewData.ModelMetadata.ModelType).Cast<object>()
.Select(v => new SelectListItem
{
Selected = v.Equals(Model),
Text = GetDisplayName(v), // v.ToString(),
Value = v.ToString()
});
}
@Html.DropDownList("", values)
Вот тут всё вообще хорошо получилось: шаблон замечательно работает везде, где только можно. Даже [UIHint("Enum")]
можно не добавлять. Причем данный общий шаблон читает атрибут [Display(Name)]
для значений перечислений с помощью специальной функции.
C — значит контроллер
Добавляем файл UsersController.cs в каталог Controllers.
public class UsersController : Controller
{
public int pageSize = 10;
public int showPages = 15;
public int count = 0;
// отображение списка пользователей
public ViewResult Index(string sortOrder, int page = 1)
{
string sortName = null;
System.Web.Helpers.SortDirection sortDir = System.Web.Helpers.SortDirection.Ascending;
sortOrder = Base.parseSortForDB(sortOrder, out sortName, out sortDir);
UsersRepository rep = new UsersRepository();
UsersGrid users = new UsersGrid {
Users = rep.List(sortName, sortDir, page, pageSize, out count),
PagingInfo = new PagingInfo
{
currentPage = page,
itemsPerPage = pageSize,
totalItems = count,
showPages = showPages
},
SortingInfo = new SortingInfo {
currentOrder = sortName,
currentDirection = sortDir
}
};
return View(users);
}
[ReferrerHold]
[HttpPost]
public ActionResult Index(string onNewUser)
{
if (onNewUser != null) {
TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
return View("New", new UserModel(new UserClass(), Languages()));
}
return View();
}
[ReferrerHold]
public ActionResult New()
{
TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
return View("New", new UserModel(new UserClass(), Languages()));
}
[HttpPost]
public ActionResult New(UserModel model)
{
if (ModelState.IsValid)
{
if (model.User == null || model.User.Language == null || model.User.Language.LanguageID == 0) RedirectToAction("Index");
UsersRepository rep = new UsersRepository();
if (rep.AddUser(model.User)) TempData["message"] = string.Format("{0} has been added", model.User.Loginname);
else TempData["error"] = string.Format("{0} has not been added!", model.User.Loginname);
if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
return RedirectToAction("Index");
}
else
{
model = new UserModel(model.User, Languages()); // почему-то при невалидной модели в данный метод приходит пустой список model.Languages, приходится перезаполнять
return View(model);
}
}
[ReferrerHold]
public ActionResult Edit(int UserID)
{
UsersRepository rep = new UsersRepository();
UserClass user = rep.FetchByID(UserID);
if (user == null) return HttpNotFound();
TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
return View(new UserModel(user, Languages()));
}
[HttpPost]
public ActionResult Edit(UserModel model, string action)
{
if (action == "Cancel")
{
if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
return RedirectToAction("Index");
}
if (ModelState.IsValid)
{
if (model.User == null || model.User.Language == null || model.User.Language.LanguageID == 0) RedirectToAction("Index");
UsersRepository rep = new UsersRepository();
if (action == "Save")
{
if (rep.ChangeUser(model.User)) TempData["message"] = string.Format("{0} has been saved", model.User.Loginname);
else TempData["error"] = string.Format("{0} has not been saved!", model.User.Loginname);
}
if (action == "Remove")
{
if (rep.RemoveUser(model.User)) TempData["message"] = string.Format("{0} has been removed", model.User.Loginname);
else TempData["error"] = string.Format("{0} has not been removed!", model.User.Loginname);
}
if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
return RedirectToAction("Index");
}
else
{
model = new UserModel(model.User, Languages());
return View(model);
}
}
public IList<LanguageClass> Languages()
{
IList<LanguageClass> languages = new List<LanguageClass>();
LanguagesRepository rep = new LanguagesRepository();
languages = rep.List();
return languages;
}
}
Разберём методы контроллера:
1) Методы Index
Отображение страницы со списком пользователей: public ViewResult Index(string sortOrder, int page = 1)
Тут два входных параметра sortOrder
и page
. С page
всё более-менее понятно, а вот через sortOrder
из адресной строки можно передать произвольную строку, которую нам потом придется запихивать в SQL-запрос и делать это напрямую нельзя. Поэтому разбираем эту строку с помощью функции (здесь приводить саму функцию не буду, можно посмотреть в файле Base.cs): sortOrder = Base.parseSortForDB(sortOrder, out sortName, out sortDir);
Далее всё банально, создаем объект репозитария UsersRepository
(для работы с СУБД, разобран в следующей главе), вызываем метод List
для получения списка пользователей и формируем объект класса UsersGrid
из собственно полученного списка пользователей и информации необходимой для организации постраничного вывода информации и сортировки. Причём значение totalItem получаем при вызове метода List
, одновременно с созданием объекта класса UsersGrid
. Далее этот объект передается для отображения представлению.
Также есть другой метод Index с атрибутом [HttpPost]
, который нам потребовался для отработки нажатия кнопки New
в представлении Index
: public ActionResult Index(string onNewUser)
Входной параметр onNewUser
перекликается с элементом />
в представлении Index
и при клике на этой кнопке передает в функцию значение, которое мы сверяем с null. Если бы кнопок типа submit
было бы в представлении Index
несколько, пришлось бы проверять и само значение (в данном случае значение было бы «New user»).
После проверки мы формируем объект класса UserModel
, состоящий из нового объекта UserClass
и списка языков для выбора их из выпадающего списка и передаем его представлению New
. Список языков получается из репозитария LanguageRepository
вызовом метода List
следующим образом:
public IList<LanguageClass> Languages()
{
LanguagesRepository rep = new LanguagesRepository();
return rep.List();
}
2) Методы New
Метод New
без параметра сделан для отработки клика по ссылке New User для закомментированой строки Html.ActionLink("New user", "New", "Users")
в представлении Index
. Действие (открытие представления New
) осуществляется аналогично предыдущему, только проверки на нажатую кнопку нет, потому что отрабатывается нажатие на ссылку.
Метод New
с параметром model типа UserModel
для отработки события отправки данных формы с целью сохранения нового пользователя в БД: public ActionResult New(UserModel model)
Получает из представления New заполненный объект класса UserModel
, проверяет корректность данных (согласно требованиям, описанным в UserClass
и LanguageClass
), создает объект репозитария UsersRepository
и пытается добавить новую строку в БД, вызвав функцию AddUser(model.User)
. Потом осуществляется возврат на предыдущую страницу (откуда был вызов представления New
) и там отображается сообщение об успехе операции или о неудаче.
3) Методы Edit
Метод Edit
с входным параметром UserID
(идентификатор пользователя) отрабатывает щелчок по имени пользователя в списке для открытия представления редактирования данных пользователя: public ActionResult Edit(int UserID)
В методе опять же создается репозитарий UsersRepository
и из него вызывается функция FetchByID(UserID)
для получения объекта типа UserClass
. В случае удачи создается модель – объект класса UserModel
из полученного объекта и списка языков и передается в представление Edit
для отображения.
Метод с входным параметром модели типа UserModel
и строки action
: public ActionResult Edit(UserModel model, string action)
В данный метод передается объект типа UserModel
и он отрабатывает действия при нажатии на кнопки (отработка событий отправки данных формы) представления Edit
. Для того чтобы понять, какая кнопка нажата используется входной параметр action
, который указан в имени HTML-тегов типа input
. Поэтому в методе идет сравнение значения данного параметра со значениями параметра value
этих кнопок. До проверки модели на валидность проверяется значение Cancel
, для выполнения действий по отмене редактирования пользователя. Этот вариант используется вместо возврата назад по истории браузера, примененный в представлении New
и использующий запоминание адреса страницы, с которой перешли к представлению Edit
. Для этого в контроллере продемонстрирована технология использования своего атрибута ActionFilter
: класс ReferrerHoldAttribute
в файле ReferrerHoldAttribute.cs (каталог HtmlAttribute):
public class ReferrerHoldAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var referrer = filterContext.RequestContext.HttpContext.Request.UrlReferrer;
if (referrer != null) filterContext.RouteData.Values.Add("referrer", referrer);
base.OnActionExecuting(filterContext);
}
}
Он используется для хранения информации о той странице, куда надо вернуться после нажатия кнопки «New user» или ссылки на изменение пользователя: TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
Нам он нужен, чтобы каждый раз не писать одинаковый код в разных методах. Можно было бы написать отдельную функцию в контроллере, но если используется несколько контроллеров, то практичнее использовать специальный тип атрибута.
В дальнейшем, в теле метода, обрабатывающего действия представлений New
и Edit
, извлекается запомненная информация и используется для переадресации обратно на страницу, откуда были вызваны эти представления: if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
Для определения необходимости сохранения или удаления пользователя происходит сравнение параметра action
со значениями Save и Remove соответственно, и вызываются из репозитария UsersRepository
функции ChangeUser(model.User)
и RemoveUser(model.User)
. Потом происходит возврат на предыдущую страницу (откуда был вызов представления Edit
) и там отображается сообщение об успехе операции или о неудаче.
ADO.NET и MySQL – репозитарий
Вот тут мы наконец-то добрались до работы с СУБД. Нам надо реализовать функции добавления пользователя в таблицу Users
, внесение изменений в данные пользователя, удаление пользователя из таблицы, получения пользователя по идентификатору, получения списка пользователей и получения списка языков из таблицы Languages
.
В основном используются стандартные конструкции для класса MySQL.Data с использованием MySqlCommand
:
using (MySqlConnection connect = new MySqlConnection(строка подключения))
{
string sql = "текст запроса";
using (MySqlCommand cmd = new MySqlCommand(sql, connect))
{
cmd.Parameters.Add("название параметра", тип параметра).Value = значение параметра;
connect.Open();
result = cmd.ExecuteNonQuery() >= 0; // выполняем запрос и получаем количество затронутых записей (INSERT, UPDATE, DELETE) или используем cmd.ExecuteScalar() для получения одиночного значения в результате выполнения запроса SELECT
}
}
или MySqlDataReader
для чтения строк таблицы, как результата запроса:
using (MySqlConnection connect = new MySqlConnection(строка подключения))
{
string sql = "текст запроса";
using (MySqlDataReader dr = cmd.ExecuteReader())
{
cmd.Parameters.Add("название параметра", тип параметра).Value = значение параметра;
objConnect.Open();
while (dr.Read())
{// тут читаем строку по названиям столбцов
}
}
}
Создаем файл UserRepository.cs в каталоге Models:
public class UsersRepository
{
public bool AddUser(UserClass user)
{
user.UserID = AddUser(Name: user.Loginname, LanguageID: user.Language.LanguageID, Email: user.Email, SupporterTier: user.SupporterTier);
return user.UserID > 0;
}
public int AddUser(string Name, int LanguageID, string Email, Supporter SupporterTier)
{
int ID = 0;
using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
{
string sql = "INSERT INTO `Users` (`Loginname`, `LanguageID`, `Email`, `SupporterTier`) VALUES (@Loginname, @LanguageID, @Email, @SupporterTier)";
using (MySqlCommand cmd = new MySqlCommand(sql, connect))
{
cmd.Parameters.Add("Loginname", MySqlDbType.String).Value = Name;
cmd.Parameters.Add("LanguageID", MySqlDbType.Int32).Value = LanguageID;
cmd.Parameters.Add("Email", MySqlDbType.String).Value = Email;
cmd.Parameters.Add("SupporterTier", MySqlDbType.Int32).Value = SupporterTier;
connect.Open();
if (cmd.ExecuteNonQuery() >= 0)
{
sql = "SELECT LAST_INSERT_ID() AS ID";
cmd.CommandText = sql;
int.TryParse(cmd.ExecuteScalar().ToString(), out ID);
}
}
}
return ID;
}
public bool ChangeUser(UserClass user)
{
return ChangeUser(ID: user.UserID, Name: user.Loginname, LanguageID: user.Language.LanguageID, Email: user.Email, SupporterTier: user.SupporterTier);
}
public bool ChangeUser(int ID, string Name, int LanguageID, string Email, Supporter SupporterTier)
{
bool result = false;
if (ID > 0)
{
using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
{
string sql = "UPDATE `Users` SET `Loginname`=@Loginname, `LanguageID`=@LanguageID, `Email`=@Email, `SupporterTier`=@SupporterTier WHERE UserID=@UserID";
using (MySqlCommand cmd = new MySqlCommand(sql, connect))
{
cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
cmd.Parameters.Add("Loginname", MySqlDbType.String).Value = Name;
cmd.Parameters.Add("LanguageID", MySqlDbType.Int32).Value = LanguageID;
cmd.Parameters.Add("Email", MySqlDbType.String).Value = Email;
cmd.Parameters.Add("SupporterTier", MySqlDbType.Int32).Value = SupporterTier;
connect.Open();
result = cmd.ExecuteNonQuery() >= 0;
}
}
}
return result;
}
public bool RemoveUser(UserClass user)
{
return RemoveUser(user.UserID);
}
public bool RemoveUser(int ID)
{
using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
{
string sql = "DELETE FROM `Users` WHERE `UserID`=@UserID";
using (MySqlCommand cmd = new MySqlCommand(sql, connect))
{
cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
connect.Open();
return cmd.ExecuteNonQuery() >= 0;
}
}
}
public UserClass FetchByID(int ID)
{
UserClass user = null;
using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
{
string strSQL = "SELECT u.`UserID`, u.`Loginname`, l.`LanguageID`, l.`LanguageName`, u.`Email`, u.`LastLoginDate`, CAST(u.`SupporterTier` AS UNSIGNED) as `SupporterTier` FROM `Users` u LEFT JOIN `Languages` l ON l.LanguageID=u.LanguageID WHERE `UserID`=@UserID";
using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
{
objConnect.Open();
int UserID = 0, LanguageID = 0;
string Loginname = null, LanguageName= null, Email = String.Empty;
Supporter SupporterTier = Supporter.None;
DateTime? LastLoginDate = null;
cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
using (MySqlDataReader dr = cmd.ExecuteReader())
{
if (dr.Read())
{
UserID = dr.GetInt32("UserID");
Loginname = dr.GetString("Loginname").ToString();
LanguageID = dr.GetInt32("LanguageID");
LanguageName = dr.GetString("LanguageName").ToString();
if (!dr.IsDBNull(dr.GetOrdinal("Email"))) Email = dr.GetString("Email").ToString();
if (!dr.IsDBNull(dr.GetOrdinal("LastLoginDate"))) LastLoginDate = dr.GetDateTime("LastLoginDate");
if (!dr.IsDBNull(dr.GetOrdinal("SupporterTier"))) SupporterTier = (Supporter)dr.GetInt32("SupporterTier");
}
LanguageClass language = null;
if (LanguageID > 0) language = new LanguageClass(LanguageID: LanguageID, LanguageName: LanguageName);
if (UserID > 0 && language != null && language.LanguageID > 0) user = new UserClass(UserID: UserID, Loginname: Loginname, Language: language, Email: Email, LastLoginDate: LastLoginDate, SupporterTier: (Supporter)SupporterTier);
}
}
}
return user;
}
// Стандартная и очень привлекательная практика для ASP.NET WebForms, поскольку позволяет в компонентах для отображения данных напрямую обращаться к данным в ObjectDataSource без образования специальной типизированной модели
// Но так писать не надо, потому что в представлении мы вынуждены будем использовать "магические строки" для получения доступа к значениям в строке данных
//public IEnumerable<DataRow> List()
//{
// using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
// {
// string strSQL = "select * from users";
// using (MySqlCommand objCommand = new MySqlCommand(strSQL, objConnect))
// {
// objConnect.Open();
// using (MySqlDataAdapter da = new MySqlDataAdapter(objCommand))
// {
// DataTable dt = new DataTable();
// da.Fill(dt);
// return dt.AsEnumerable();
// }
// }
// }
//}
public IList<UserClass> List(string sortOrder, System.Web.Helpers.SortDirection sortDir, int page, int pagesize, out int count)
{
List<UserClass> users = new List<UserClass>();
using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
{
// добавляем в запрос сортировку
string sort = " ORDER BY ";
// это плохая практика, потому что запрос может быть взломан при удачном встраивании в него некоей текстовой строки (inject)
// но, к сожалению, MySQL не дает возможности использовать параметры для сортировки
// поэтому надо экранировать кавычками, но перед этим обеспечить сначала проверку входного значения (чтобы тех же кавычек в нём не было)
// в нашем проекте проверка значения идет в контроллере, перед построением модели
if (sortOrder != null && sortOrder != String.Empty)
{
sort += "`" + sortOrder + "`";
if (sortDir == System.Web.Helpers.SortDirection.Descending) sort += " DESC";
sort += ",";
}
sort += "`UserID`"; // по умолчанию
// добавляем в запрос отображение только части записей (отображение страницами)
string limit = "";
if (pagesize > 0)
{
int start = (page - 1) * pagesize;
limit = string.Concat(" LIMIT ", start.ToString(), ", ", pagesize.ToString());
}
string strSQL = "SELECT SQL_CALC_FOUND_ROWS u.`UserID`, u.`Loginname`, l.`LanguageID`, l.`LanguageName` as `Language`, u.`Email`, u.`LastLoginDate`, CAST(u.`SupporterTier` AS UNSIGNED) as `SupporterTier` FROM `Users` u LEFT JOIN `Languages` l ON l.LanguageID=u.LanguageID" + sort + limit;
using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
{
objConnect.Open();
cmd.Parameters.Add("page", MySqlDbType.Int32).Value = page;
cmd.Parameters.Add("pagesize", MySqlDbType.Int32).Value = pagesize;
using (MySqlDataReader dr = cmd.ExecuteReader())
{
while (dr.Read())
{
LanguageClass language = new LanguageClass(LanguageID: dr.GetInt32("LanguageID"), LanguageName: dr.GetString("Language").ToString());
users.Add(new UserClass(
UserID: dr.GetInt32("UserID"),
Loginname: dr.GetString("Loginname"),
Language: language,
Email: dr.IsDBNull(dr.GetOrdinal("Email")) ? String.Empty : dr.GetString("Email"),
LastLoginDate: dr.IsDBNull(dr.GetOrdinal("LastLoginDate")) ? (DateTime?) null : dr.GetDateTime("LastLoginDate"),
SupporterTier: dr.IsDBNull(dr.GetOrdinal("SupporterTier")) ? (Supporter) Supporter.None : (Supporter)dr.GetInt32("SupporterTier")));
}
}
}
using (MySqlCommand cmdrows = new MySqlCommand("SELECT FOUND_ROWS()", objConnect))
{
int.TryParse(cmdrows.ExecuteScalar().ToString(), out count);
}
}
return users;
}
}
Он содержит метод AddUser
для добавления пользователя, обновления данных пользователя (ChangeUser
), удаления (RemoveUser
), поиска пользователя (FetchByID
) по идентификатору и наиболее интересный метод List
для вывода постраничного списка пользователей с сортировкой. Хотелось бы дополнительно прокомментировать функцию List
:
- Не стоит возвращать именно таблицу (
DataTable
), как таковую – в этом случае мы потеряем в представлении возможность обращаться к элементам класса и будем вынуждены употреблять строковые константы для обращения к значениям в строке таблицы. Т.е. это будет потенциальное место для возникновения ошибок при изменении SQL-запроса. Поэтому создается список из элементов классаUserClass
. - Для добавления ограничения выборки строк из запроса используется конструкция
LIMIT
в SQL-оператореSELECT
. К сожалению, в MySQL не поддерживаются переменные в этой части, и конструкциюLIMIT
приходится конструировать вручную и добавлять к запросу. Но это полбеды, поскольку там используются целочисленные значения, а вот подобная практика для конструкцииORDER BY
уже чревата нюансами в части инъекции в наш запрос вредоносного кода. Например, злоумышленник может подсунуть в качестве параметра сортировки некий SQL-конструкт, который прервет нашу команду SQL точкой с запятой, а дальше будет идти уже его команда, которая также может быть выполнена. Необходима проверка и чистка входного параметра сортировки, которая у нас осуществляется в контроллере, поэтому в данном методе мы принимаем уже два разобранных значения: название столбца по которому идёт сортировка и направление сортировки. - После получения списка из БД мы должны запустить ещё команду SQL вида
SELECT FOUND_ROWS()
, которая должна нам дать общее количество строк получаемых предыдущим запросом, в котором была конструкцияSELECT SQL_CALC_FOUND_ROWS
без учета ограничения вLIMIT
.
Все остальные методы совершенно обычные, соответствуют приведенному выше канону.
И файл LanguageRepository.cs в каталоге Models:
public class LanguagesRepository
{
public IList<LanguageClass> List()
{
List<LanguageClass> languages = new List<LanguageClass>();
using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
{
string strSQL = "SELECT `LanguageID`, `LanguageName` as `Language` FROM `Languages` ORDER BY `LanguageName`";
using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
{
objConnect.Open();
using (MySqlDataReader dr = cmd.ExecuteReader())
{
while (dr.Read())
{
LanguageClass language = new LanguageClass(LanguageID: dr.GetInt32("LanguageID"), LanguageName: dr.GetString("Language").ToString());
languages.Add(language);
}
}
}
}
return languages;
}
}
В нём есть только одна функция для получения списка языков из БД, создающая список из элементов класса LanguageClass
.
Итого
Вот и всё – поставленная задача решена. Понятно, что не были затронуты многие вопросы: контейнер внедрения зависимостей, модульное тестирование, инструменты для мокинга, локализация и т.д. и т.п. Какие-то вещи можно было сделать по-другому или просто лучше. Но «кирпичик» который получился, вышел достаточно цельным, чтобы понять как работает ASP.NET MVC и что не так уж страшно работать с ADO.NET и с СУБД, которые не MS SQL.
P.S. И напоследок: посмотреть, как работает данный проект можно по этому адресу. А вот тут можно скачать весь проект. Ну и до кучи — ссылка на мой блог.
Автор: Duke