Уже, наверное, раза три подбираюсь к 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 (стандартно располагается в каталоге Views\Shared и в дальнейшем используется для всех страниц данного проекта) можно теперь использовать эти переменные (см. например,
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, расположенный в каталоге Views\Shared. Создаем представление
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 в каталоге Views\Shared. Собственно оно представляет из себя именно таблицу-грид для отображения данных из нашей таблицы БД 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 в папке Views\Shared\EditorTemplates:@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 в папке Views\Shared\EditorTemplates:
@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. И напоследок: посмотреть, как работает данный проект можно по этому адресу. А вот тут можно скачать весь проект. Ну и до кучи — ссылка на мой блог.
Комментарии (40)
Severus1992
29.12.2019 09:01Спасибо за путешествие в прошлое. Как будто на 5 лет назад откатился. Не надо так.
Duke Автор
29.12.2019 09:25Спасибо за отзыв. Вообще-то писал для новичков в ASP.NET MVC.
Если не сложно — поделитесь ссылкой как именно надо. Под EF я тоже писал, но была интересна реализация под ADO.NET для MySQL. Неужели EF в любом случае лучше и оптимальнее прямой реализации через драйвер ADO.NET?yarosroman
29.12.2019 11:24Ну EF не всегда оптимальные запросы строит, но EF не единственный ORM. Есть например Dapper от stackoverflow. Тут выбирайте или большее удобство или быстрые запросы.
igoriok
29.12.2019 09:56Если начинать изучать, то лучше сразу с ASP.NET Core и Entity Framework Core. Для MySQL лучше использовать MySqlConnector.
Duke Автор
29.12.2019 10:16Спасибо. Покопаем еще и туда. Изначально хотелось понять, можно всё-таки работать через ADO.NET со стандартной библиотекой MySQL от Oracle. До этого писал проект, с базой данных размером в 10 гигов и таблицами в 1-2 гига и весьма сложными запросами. Даже не знаю пока, как это в ORM уложилось бы.
alemiks
29.12.2019 11:30если у вас «весьма сложные запросы», то это не проблема ORM, а, возможно, проблема этих запросов (кривая архитектура бд, как вариант)
Duke Автор
29.12.2019 11:44Вряд ли. Вполне обычная 4-я нормальная форма реляционной БД. Но тут надо отдельно копаться, чтобы предметно получилось…
13cyberpunk02
29.12.2019 10:13-1Да сейчас все по другому выглядит, кнопку удаления/редактирования записи можно было в таблицу добавить в интерфейсе(как пример datatables jquery), можно было сделать один view для добавления и редактирования записи по сути они ничем не отличаются. Тема хорошая, чтобы отвлечься, но вряд ли, кто-то будет использовать ADO.NET в наше время, когда уже есть десятки различных ORM фреймворков.
alemiks
29.12.2019 11:32зачем «переползать» на мёртвый фреймворк, интересно? en.wikipedia.org/wiki/ASP.NET_MVC
The ASP.NET MVC is a discontinued web application framework developed by Microsoft
Duke Автор
29.12.2019 11:35А как же последовательность? :) И мёртвым он сделается только тогда, когда на нём перестанут работать реальные проекты.
yarosroman
29.12.2019 15:18Последовательность чего? Изучения фреймворков? В чем смысл? Да и думаю с выпуском net 5 его вообще с поддержки снимут, так, что мертв. Можно на файлопомойках турбо Паскаль откопать и писать на нем, только есть ли в этом здравый смысл. Вон товарищ выше, WebForms оды поет, когда есть такие вещи как Angular, React, Vue, RestAPI. Не спорю, есть куча старого кода, который надо поддерживать, но писать на этом новый код, как минимум странно.
leotsarev
Статья неплохая, но устарела на 10 лет:-)
Оба компонента Obsolete
Duke Автор
Спасибо. Лет 10 — это, надеюсь, гипербола :) Вроде как только .NET 3.5 вышел, а я во всю писал под WebForms. Какие два компонента устарели? ADO.NET и MySQL? Или что? Сортировку и постраничное отображение специально писал, чтобы доработать те примеры, которые были приведены в литературе и, которые нашел в интернете.
Хотел написать статью для новичков в ASP.NET MVC и с какими вопросами приходится сталкиваться, когда читаешь книги и «этот самый интернет» в поисках работающих решений. На том же Хабре я что-то не нашел статьи с цельным решением.
Изначально, думал написать только про работу с MySQL через ADO.NET, но пока писал, решил и небольшой вводный курс для новичков сделать в рамках маленького проекта. Чем это отличается от тех же книг про ASP.NET MVC я не понял.
Налетели профи и наставили минусов, даже в карму капнули — за что, я пока не понял. Я что-то сделал плохо? Написать статью про конкретную технологию, привел пример работающего проекта, объяснил почему писал конкретно так (в данном примере, естественно).
Подход устарел? Но писать-то так можно в конкретных случаях? Или не в каноне MVC? И профи настолько не одобряют, что и статью на Хабр писать не стоит? Пока не понял, если объясните, могу с Хабра статью убрать, мне она тут не принципиальна, может и в моём блоге полежать. Хотел просто новичкам или тем же студентам помочь.
yarosroman
net core уже третий вышел, на старый asp.net уже забили (так заплатки только делают), net 5 на подходе уже, увы про 10 лет не гипербола.
Duke Автор
Надо же, пока я собирался на ASP.NET MVC переползать — он уже устарел :) Никогда не надо торопиться :)))
На мой взгляд, это странный подход. Такое впечатление, что хорошую технологию списывают в утиль, как только она обрастёт нормальными подходами и возможностью писать нормальные приложения без разгребания косяков новой технологии.
Да тот же ASP.NET WebForms уже с .NET 3.5 не дорабатывается, но кто сказал, что на нём нельзя писать отличные веб-приложения? Так можно договориться, что C++ нафиг не нужен, уже куча новых языков после него вышла.
На мой взгляд, подход должен быть один — если ты с помощью этой технологии способен заработать денег — значит она применимая.
Лет 10 назад — это 2009 год, тогда только .NET 4 анонсировали, основной технологией была WebForms. ASP.NET MVC 3 (который уже реально было использовать) выпущен, если мне не изменяет память, в 2011 году. В 2012 вроде 4-й вышел, я как раз пытался на нём что-то писать, но WebForms затянул меня обратно, потому что книг и документов по MVC у меня в доступе было маловато…
yarosroman
Ну видать у вас инет 2012 года, раз вы не слышали про net core и то, что сейчас это просто asp.net приложение называется. И под капотом это всё тот же mvc. И удивительно, что вы про net core не слышали, уже 3 версия вышла.
Duke Автор
Слышал, конечно, но еще не копал в этом направлении. Я пока не видел, что он может дать такого, что не могут другие технологии. Буду читать и смотреть.
Если честно, то у меня и под WebForms неплохо получалось. Кто-то её уже закопал, но по сути — что нового придумано за эти годы? В итоге всё равно генерируется HTML + javascript. Круто было, когда появился AJAX — вот это был прорыв — и сколько лет назад он был? В MVC добавили по сути ровно одно — он стал позволять более удобно проводить компонентное тестирование и (с более предсказуемым результатом) тестирование интерфейса. Это тоже круто. Но в итоге всё равно всё сводится к генерации старого как говно мамонта HTML и Javascript. Просто на всё это навешали ORM, библиотек JQuery и прочего — чисто для ускорения и «упрощения» разработки.
yarosroman
Вы ещё один прорыв упустили. Single Page Application. WebForms сами майки и закопали же.
Duke Автор
Да, они молодцы закапывать. По-моему, они бросили WebForms, когда они решили догонять Ruby on Rails. А сейчас они бегут в сторону Linux'а и Android'а. Пока что, с точки зрения финансов, у них всё хорошо.
Barbaresk
У меня не очень много опыта в веб-разработке (примерно года 3). Но так получилось, что в ускоренном темпе я прошёл путь из основных фреймворков MS для веба: (Active X, к счастью, не пробовал) — Silverlight — ASP NET Forms — ASP NET MVC — ASP NET Core. И вот как по мне, там всё довольно логично развивалось и «закапывалось». Я не люблю переходить с одной технологии на другую только из-за хайпа, но вот тут всё было логично. ASP NET MVC имеет преимущество над ASP NET Forms в области архитектуры. И вот это преимущество настолько подавляющее, что ASP NET Forms по факту был обречен сразу после ввыхода ASP NET MVC. Аналогично и с переходом ASP NET MVC — ASP NET Core. Добавлена кроссплатформенность, сильно изменен способ построения страниц (улучшен Razor), убрано бесячое различие между Http и MVC контроллерами (для меня это один из основных плюсов Core) и много чего ещё. В общем, я бы не сказал, что MS «закапывают» технологии в Вебе. Скорее, стараются соответствовать современным требованиям и итерационно улучшают свои продукты. Да, иногда это требует полное переобучение (asp net forms — asp net mvc), иногда частичное (asp net mvc — asp net core), но оно того стоит.
yarosroman
Да и с точки не финансов тоже. Выход в open source, выпуск net core, покупка xamarin, привели к резкой популярности шарпа. Да, сейчас на помойку ещё и долбаный IIS пойдет.
GennPen
Duke Автор
Ага, есть такое понятие в философии как «снятие». Но надо ж и понимать всё-таки откуда и что сняли.
Кстати, WebForms вообще откинули и ничего толком не взяли, технология настолько офигенная получилась, что дорабатывать оказалось нечего и Microsoft побежала догонять Ruby on Rails. Главное в процессе — это процесс :)
Я понимаю, почему все переходят на фреймворки, в том числе и по взаимодействию с СУБД — нужна бесконечно увеличивающаяся скорость разработки и внедрения и снижение стоимости программистов (в идеале — нужен архитектор, который опишет модель, а она будет работать). Так же забыли ассемблер и почти уже вышел из использования C++ (только низкоуровневое программирование железяк разве что еще осталось). Также уходит и знание SQL — программист теперь вообще не знает архитектуру СУБД и не понимает как надо оптимизировать запросы и как одним запросом можно обойтись вместо десяти. Но есть еще места, где он еще нужен, как бы его не убирали «под капот».
yarosroman
WebForms офигенная? Да убогая технология. Уж как пользователь системы сделанной на WF говорю. Откройте для себя новые технологии.
Duke Автор
Уговорили, черти языкастые :)
yarosroman
Сарказм?
Duke Автор
Ни в коем разе. Был бы сарказм — поставил бы тег Новые технологии надо изучать — это однозначно.
yarosroman
Реально, за 3 года разработчики net core совершили чудо, фактически они переписали платформу с нуля, так они столько изменений в нее внесли, столько не было с .net 1.0, я про само ядро. Просто знаю всего 2 языка которые очень стремительно развиваются, C# и Kotlin. В общем, майки не зря похоронили все прежнее, это была их проблема, совместимость со старым. Новое это хорошо.
Siemargl
Фронт это вообще новая тема и меняется 3 раза за 2 года.
Я бы сказал, что это ни хорошо ни плохо, просто сочувствую всем, кто вляпался.
Потому что уже через год про тебя не вспомнят хорошим словом, aka «устаревший говнокод».
staticlab
Причём тут фронт? .NET Core — это же бэк.
vdasus
Если начинаете новый проект — не вижу ни одной причины изучать то что в статье. Потому что с .core это пишется проще, приятнее, «правильнее», современнее, держит большую нагрузку на меньшем железе, кроссплатформенно и много других плюсов. Очень настоятельно рекомендую копать именно туда. (Не ставил ни минусов ни плюсов)
Duke Автор
Да, будем посмотреть. Однако, хотелось бы присмотреться к фреймворкам с прямой работой с СУБД.
vdasus
asp.mvc, .core итп не имеют никакой связи со способом доступа к СУБД. Они дают каркас на котором вы можете строить логику работы. Можно работать напрямую с базой, но чаще всего достаточно использовать что-то легковесное, но скрывающее ненужные телодвижения там где нет необходимости в супертюнинге. Выше уже упомянули dapper — могу подтвердить что он отлично работает на достаточно серьезных нагрузках и достаточно удобен и стабилен.
Из доступа к базе в последнее время чаще всего использовал dapper, ef, efcore, nhibernate, родное для баз,… и кучу всего разного за 30+ лет активного программирования. Обычно подбираю инструмент под задачу.
(Кроме того — рекомендую посмотреть в сторону CQRS где способы доступа к СУБД могут быть различными и часто это оправданно)
vabka
Кстати про ORM. Вы пробовали Linq2db? Мне кажется, он тоже вполне неплох. И SQL писать не заставляет, и кучу тяжести не тащит, как EF и Nhibernate
vdasus
Не пробовал, к сожалению. Отзывы хорошие слышал, но самому не приходилось и не вникал. Мне проще написать оттюненый селект если нужна производительность или ресурсы либо использовать dapper либо то что я написал из монстров. Этого было достаточно, а linq2db в моих задачах ничего принципиально более полезного не давал. Скорее всего он будет более полезен в местах где нет сильных базистов.
somebody4
Одно из основных преимуществ использования ORM (очень часто даже не упомянаемое) это то что большинство потенциальных ошибок будет выявлено на этапе компиляции.
Потому что в коде типа
dr.GetString("Language")
можно сделать ошибкуdr.GetString("Langage")
и компилятору будет без разницы. Завтра это поле удалят и компилятор опять не почешится. Или если поле «Language» это не строка, а например число, то ему опять же продольно-поперечно.Хуже этого только способ, горячо любимый моими индусскими друзьями. Что-то типа
dr[3].ToString()
Для примера можно посмотреть как используется type provider в F#, это впечатляет, как будто магия какая-то.
Ну и надо понимать что как бы не хотелось чтобы ваш проект был high-load/sub-second response, но в реальности, в 99.9% случаев это не так и любая современная ORM покроет все ваши потребности, без ручной оптимизации. Особенно если структура базы более-менее адекватная и индексы правильно созданы.
На счёт самой статьи — есть достаточно много legacy проектов которые еще очень долго будет использовать старые технологии и весь зоопарк возможностей.
Единственно что было бы неплохо сделать дисклаймер что это статья как раз для legacy проектов, а то новичёк начнёт руководствоваться данными принципами и притащит это в новый проект, потому что ему надо побыстренькому таск закрыть.
Duke Автор
1) То, что ошибку можно сделать, это понятно, и что компилятор её не выловит. Но это как правило, маленькая часть проекта и очень просто проверяется.
2) Добавил примечание в начале статьи.
somebody4
1. Для CRUD проекта это значительная часть и ваша статья как раз сконцентрированна вокруг этой части. К тому же это не только имена и типы полей. Отношения между таблицами тоже отлавливаются. Даже такие банальные вещи как отсутсвующая процедура.
Плюс за счёт того что в ORM вы например можете написать какой-то фильтр который принимает и возвращает IQueryable (например для текущего пользователя только его авторизированные данные) и потом его применять везде, очень сужает вероятность трудноуловимых ошибок где правильно реализованно в 20-ти местах, а в 21-м попутал.
Использовние partial классов очень помогает для модели установить необходимые интерфейсы и аттрибуты. Что потом позволяет создавать унифицированные способы их обработки. И опять же всё отлавливается на этапе компиляции. Звучит банально, но на более-менее большом проекте или это или тонны dumb тестов, чисто для проверки таких смешных случаев.
Даже UI часть может быть существенно автоматизированна, если в схеме db описаны constraints и/или используется система именования, можно крутые штуки делать. Всякого рода валидаторы, автоматическая привязка редоктирования к справочникам, генерация контролов и т.п.
2. «Устаревшие» звучит грусно, надо написать «Legacy»