Цель урока. Отследить весь путь создания записи в БД и вывода его. Вывод ошибок. Валидация. Мапперы. Написание атрибута валидации. Капча. Создание данных в БД.
Введение
Наконец, переходим к одному из самых важных уроков, в котором будет рассказано про создание записей. Любое действие на сайте, от сложных, когда мы заполняем регистрационную анкету, до простых, когда ставим лайк, – происходит следующим образом:
- Postget запрос на сайт
- Авторизация и аутентификация
- Проверка введенных данных (валидация) на правильность
- Если проверка введенных данных показала, что введенные данные неверны, то в заполняемую форму выводится предупреждение.
- Если проверка введенных данных показала, что эти данные верны, то они сохраняются в БД и выводится страница с подтверждением.
Регистрация
Сделаем форму для регистрации пользователя. При регистрации, пользователь должен распознать капчу и повторить ввод пароля. Но начнем без этого. Создадим метод Register в контроллере UserController и View.
public ActionResult Register()
{
var newUser = new User();
return View(newUser);
}
Создаем и передаем во View новый объект User. Так как полей у нас пока только два, для заполнения создаем View:
@using (Html.BeginForm("Register", "User", FormMethod.Post, new { @class = "form-horizontal" }))
{
<fieldset>
<div class="control-group">
<label class="control-label" for="Email">
Email
</label>
<div class="controls">
@Html.ValidationMessage("Email")
@Html.TextBox("Email", Model.Email)
</div>
</div>
<div class="control-group">
<label class="control-label" for="FirstName">
Password
</label>
<div class="controls">
@Html.ValidationMessage("Password")
@Html.Password("Password", Model.Password)
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Register
</button>
@Html.ActionLink("Cancel", "Index", null, null, new { @class = "btn" })
</div>
</fieldset>
}
Все эти дивы, fieldset'ы и button’ы сделаны по подобию, как это описано в фреймворке bootstrap (далее будем изучать).
Изучим основные Html-вставки:
Html.BeginForm("Register", "User", FormMethod.Post, new { @class = "form-horizontal" })
— формирует тег <form action=”/User/Register” method=”post” class=”form-horizontal”>
и закрывает его после вызова Dispose()
(закрытие кавычек using() {}
)
@Html.TextBox("Email", Model.Email)
— формирует тег <input type=”text” name=”Email” value=”@Model.Email”>
(т.е. в значение тега записывается значение Email переданного объекта)
@Html.ValidationMessage("Password")
— выводит тег ошибки если такая есть
@Html.Password("Password", Model.Password)
— выводит тег <input type=”password” name=”Password” value=”@Model.Password”>
После нажатия на кнопку Register идет Http-запрос типа POST (так как FormMethod.Post
и передает данные Email=&Password=.
Создадим метод Register, принимающий в качестве параметра тип User, и пометим его атрибутом HttpPost, а предыдущий — атрибутом HttpGet. Контроллер различает, какой из типов запроса сейчас происходит и перенаправляет на тот, который необходим:
[HttpGet]
public ActionResult Register()
{
var newUser = new User();
return View(newUser);
}
[HttpPost]
public ActionResult Register(User user)
{
return View(user);
}
Сделаем точку останова на втором методе Register и проверим, какой объект приходит к нам:
Видим, что поля Email и Password заполнены, остальные остались нулевыми или по умолчанию (default).
Так как мы должны принять еще 2 поля (повтор пароля и капчу), то добавим эти поля в наш User partial class:
public partial class User
{
public static string GetActivateUrl()
{
return Guid.NewGuid().ToString("N");
}
public string ConfirmPassword { get; set; }
public string Captcha { get; set; }
}
Добавим поля во View:
<div class="control-group">
<label class="control-label" for="FirstName">
Confirm Password
</label>
<div class="controls">
@Html.ValidationMessage("ConfirmPassword")
@Html.Password("ConfirmPassword", Model.ConfirmPassword)
</div>
</div>
<div class="control-group">
<label class="control-label" for="FirstName">
Captcha
</label>
</div>
<div class="control-group">
<label class="control-label" for="FirstName">
Тут картинка 1234
</label>
<div class="controls">
@Html.ValidationMessage("Captcha")
@Html.TextBox("Captcha", Model.Captcha)
</div>
</div>
Капчу пока не будем делать, просто она будет равна 1234.
Валидация
Условия для правильности данных:
- Поле email не нулевое
- Email – это корректно введенный адрес почты, т.е. с собачкой
- Email добавляемый в БД — уникальный
- Пароль не нулевой
- Пароли совпадают
- Капча равна 1234
Если какое-то из этих условий не соблюдается, то выдается ошибка.
IValidatableObject
Так как у нас класс User — partial, то мы можем реализовать для него IValidatableObject интерфейс, для этого, правда, придется добавить в проект System.Component.DataAnnotation. Это не очень хорошо, так как эта сборка необходима для валидации, а валидация – это прерогатива контроллеров в MVC. Так что мы тут немного нарушаем принцип.
Класс User:
public partial class User : IValidatableObject
{
public static string GetActivateUrl()
{
return Guid.NewGuid().ToString("N");
}
public string ConfirmPassword { get; set; }
public string Captcha { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
//Не нулевой Email
if (string.IsNullOrWhiteSpace(Email))
{
yield return new ValidationResult("Введите email", new string[] {"Email"});
}
//корректный Email
var regex = new Regex(@"w+([-+.']w+)*@w+([-.]w+)*.w+([-.]w+)*", RegexOptions.Compiled);
var match = regex.Match(Email);
if (!(match.Success && match.Length == Email.Length))
{
yield return new ValidationResult("Введите корректный email", new string[] { "Email" });
}
//пароль не нулевой
if (string.IsNullOrWhiteSpace(Password))
{
yield return new ValidationResult("Введите пароль", new string[] { "Password" });
}
//пароли совпадают
if (Password != ConfirmPassword)
{
yield return new ValidationResult("Пароли не совпадают", new string[] { "ConfirmPassword" });
}
}
}
Мы смогли сделать проверку 4 из 6 правил валидации, но оставим пока так, а остальные добавим непосредственно в контроллере.
Выполняем форму, получаем:
Видим, что обе наши ошибки были отловлены.
Есть два стандартных метода вывести ошибку: это Html.ValidationMessage(“ErrorField”) и Html.ValidationSummary(). Первый выводит ошибку, связанную с конкретным неверновведенным полем, а второе — выведет все (или все оставшиеся) ошибки.
Добавляем в контроллер проверку на капчу и проверку на существование Email в БД (/Areas/Default/UserController.cs:Register):
if (user.Captcha != "1234")
{
ModelState.AddModelError("Captcha", "Текст с картинки введен неверно");
}
var anyUser = Repository.Users.Any(p => string.Compare(p.Email, user.Email) == 0);
if (anyUser)
{
ModelState.AddModelError("Email", "Пользователь с таким email уже зарегистрирован");
}
И результат:
Что ж, с задачей мы справились, но в дальнейшем, используя такой способ, мы получим несколько проблем:
- Класс User всегда будет содержать проверку на необходимость введения пароля и идентичность паролей, а, например, при изменении данных в личном кабинете, мы вообще не должны вводить пароль. Т.е. необходимо будет вводить другие поля, которые будут обозначать: это регистрация, это смена пароля, это изменение данных.
- Валидацию мы сделали частично в Model-части и частично в Controller-части – это не совсем хрестоматийно.
Но есть решение, мы создаем класс, который является представлением класса User, организующим валидацию. Мы назовем его UserView и создадим в папке Models/ViewModels:
public class UserView
{
public int ID { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public string Captcha { get; set; }
public string AvatarPath { get; set; }
}
Automapping
Прежде чем приступить к использованию этого класса, стоит заметить, что это не совсем удобно. Мы создали совершенно другой класс, но добавлять в БД мы должны класс User, а это означает, что в каком-то месте программы мы должны передавать от объекта UserView в User поля, так и наоборот. А при большом количестве объектов и полей – это рутинно, к тому же, подобное у нас уже есть в функции Update[Table] в репозитории. Для решения этой задачи существуют так называемые мапперы object-to-object.
Одним из самых популярных, является automapper (http://automapper.org/). Собственно, эта библиотека берет на себя работу по переводу одного объекта в другой, и, как мы дальше увидим, там еще есть много других вкусных плюшек.
Устанавливаем Automapper:
Install-Package AutoMapper
Так как при разработке программы мы избегаем сильную связность, то организуем интерфейс + реализацию и зарегистрируем это в Ninject, после чего выведем использование в контроллер.
Создаем в /Mappers:
public interface IMapper
{
object Map(object source, Type sourceType, Type destinationType);
}
Реализация:
public class CommonMapper : IMapper
{
static CommonMapper()
{
Mapper.CreateMap<User, UserView>();
Mapper.CreateMap<UserView, User>();
}
public object Map(object source, Type sourceType, Type destinationType)
{
return Mapper.Map(source, sourceType, destinationType);
}
}
Регистрация (пусть будет как объект-одиночка) (/App_Start/NinjectWebCommon.cs):
kernel.Bind<IMapper>().To<CommonMapper>().InSingletonScope();
В BaseController (/Controllers/BaseController.cs):
public abstract class BaseController : Controller
{
[Inject]
public IRepository Repository { get; set; }
[Inject]
public IMapper ModelMapper { get; set; }
}
Теперь изменим UserController (и View) с использованием UserView:
[HttpGet]
public ActionResult Register()
{
var newUserView = new UserView();
return View(newUserView);
}
[HttpPost]
public ActionResult Register(UserView userView)
{
if (userView.Captcha != "1234")
{
ModelState.AddModelError("Captcha", "Текст с картинки введен неверно");
}
var anyUser = Repository.Users.Any(p => string.Compare(p.Email, userView.Email) == 0);
if (anyUser)
{
ModelState.AddModelError("Email", "Пользователь с таким email уже зарегистрирован");
}
if (ModelState.IsValid)
{
var user = (User)ModelMapper.Map(userView, typeof(UserView), typeof(User));
//TODO: Сохранить
}
return View(userView);
}
И в Register.cshtml изменится первая строка:
@model LessonProject.Models.ViewModels.UserView
Атрибуты
Для UserView будем использовать для валидации атрибуты.
Добавим сборку:
using System.ComponentModel.DataAnnotations;
public class UserView
{
public int ID { get; set; }
[Required(ErrorMessage="Введите email")]
public string Email { get; set; }
[Required(ErrorMessage="Введите пароль")]
public string Password { get; set; }
[Compare("Password", ErrorMessage="Пароли должны совпадать")]
public string ConfirmPassword { get; set; }
public string Captcha { get; set; }
public string AvatarPath { get; set; }
}
Проверяем:
Мы смогли описать тут 5 из 6 правил валидации. Правила, касающегося верного введенного email – нет. Напишем для этого свой класс-атрибут, проверяющий корректность введенного email:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class ValidEmailAttribute : ValidationAttribute
{
public override bool IsValid(object value)
{
if (value == null)
{
return true;
}
if (!(value is string))
{
return true;
}
var source = value as string;
if (string.IsNullOrWhiteSpace(source))
{
return true;
}
var regex = new Regex(@"w+([-+.']w+)*@w+([-.]w+)*.w+([-.]w+)*", RegexOptions.Compiled);
var match = regex.Match(source);
return (match.Success && match.Length == source.Length);
}
}
Вначале проверяем, что полученный объект есть строка, и строка не пустая, иначе возвращаем значение «истина» в проверке. Тут срабатывает правило, что «мы у инопланетян документы не проверяем», т.е. пока нет достаточных условий для проверки – мы не проверяем, а проверять будут другие атрибуты. Потом же, с помощью регулярного выражения, проверяем. При желании, в интернете можно найти более полную проверку регулярным выражением с использованием всех доменов первого уровня.
Примечание: Можно подключить DataAnnotationsExtensions, чтобы не писать самому нужные атрибуты (http://dataannotationsextensions.org/)
Добавим пользователю поле дня рождения. Да, прямо сейчас, и посмотрим, как можно реализовать выбор даты.
- Добавляем поле в БД. Birthdate datetime null.
Примечание: возможно, надо будет снять эту галочку, чтобы спокойно изменять структуру БД:
- В данных выставим всем записям значения 2012-1-1
- Изменим поле Birthdate на datetime not null
- Удаляем из
LessonProjectDb.dbml
таблицу User и заново переносим из Server Explorer - В SqlRepository/User.cs добавляем строку в UpdateUser():
public bool UpdateUser(User instance) { User cache = Db.Users.Where(p => p.ID == instance.ID).FirstOrDefault(); if (cache != null) { cache.Birthdate = instance.Birthdate; cache.AvatarPath = instance.AvatarPath; cache.Email = instance.Email; Db.Users.Context.SubmitChanges(); return true; } return false; }
- В UserView у нас будет совершенно другое представление о поле Bithdate. И об этом чуть подробнее отдельно.
Выбор дня рождения у нас будет таким:
Тут надо решить несколько задач. Первая из них – создание и организация выпадающего списока. В Html (который мы еще позже рассмотрим подробнее) есть DropDownList, который реализует выпадающий список.
Параметры такие:
@Html.DropDownList(string name, IEnumerable<SelectListItem> selectList)
Смотрим SelectListItem:
public class SelectListItem
{
public SelectListItem();
public bool Selected { get; set; }
public string Text { get; set; }
public string Value { get; set; }
}
Для выбора, например, из 1 — apple, 2 – orange (выбран), 3 — banana мы должны написать следующий код:
public IEnumerable<SelectListItem> SelectFruit
{
get
{
yield return new SelectListItem() { Value = "1", Text = "apple", Selected = false };
yield return new SelectListItem() { Value = "2", Text = "orange", Selected = true };
yield return new SelectListItem() { Value = "3", Text = "banana", Selected = false };
}
}
И передать в DropDownList()
вторым параметром, первый параметр – name, которому присвоится значение Value при подтверждении (сабмите) формы.
Cоздадим реализацию для выбора дня рождения:
public int BirthdateDay { get; set; }
public int BirthdateMonth { get; set; }
public int BirthdateYear { get; set; }
public IEnumerable<SelectListItem> BirthdateDaySelectList
{
get
{
for (int i = 1; i < 32; i++)
{
yield return new SelectListItem
{
Value = i.ToString(),
Text = i.ToString(),
Selected = BirthdateDay == i
};
}
}
}
public IEnumerable<SelectListItem> BirthdateMonthSelectList
{
get
{
for (int i = 1; i < 13; i++)
{
yield return new SelectListItem
{
Value = i.ToString(),
Text = new DateTime(2000, i, 1).ToString("MMMM"),
Selected = BirthdateMonth == i
};
}
}
}
public IEnumerable<SelectListItem> BirthdateYearSelectList
{
get
{
for (int i = 1910; i < DateTime.Now.Year; i++)
{
yield return new SelectListItem
{
Value = i.ToString(),
Text = i.ToString(),
Selected = BirthdateYear == i
};
}
}
}
И во View:
<div class="control-group">
<label class="control-label" for="FirstName">
Birth date
</label>
<div class="controls">
@Html.DropDownList("BirthdateDay", Model.BirthdateDaySelectList)
@Html.DropDownList("BirthdateMonth", Model.BirthdateMonthSelectList)
@Html.DropDownList("BirthdateYear", Model.BirthdateYearSelectList)
</div>
</div>
Запустим приложение и поставим брейк-поинт point на приеме данных. Проверим, как мы получаем данные для полей даты рождения для объекта UserView:
Теперь осталось правильно передать их в объект User. Опишем эту передачу в описании маппинга (/Mappers/CommonMapper.cs):
Mapper.CreateMap<User, UserView>()
.ForMember(dest => dest.BirthdateDay, opt => opt.MapFrom(src => src.Birthdate.Day))
.ForMember(dest => dest.BirthdateMonth, opt => opt.MapFrom(src => src.Birthdate.Month))
.ForMember(dest => dest.BirthdateYear, opt => opt.MapFrom(src =>src.Birthdate.Year));
Mapper.CreateMap<UserView, User>()
.ForMember(dest => dest.Birthdate, opt => opt.MapFrom(src => new DateTime(src.BirthdateYear, src.BirthdateMonth, src.BirthdateDay)));
Здесь мы задаем правила однозначного перевода из свойств BirthdateDay, BirthdateMonth, BirthdateYear в Birthdate и обратно.
Captcha
Для создания капчи, мы используем отдельный класс, который создаст нам картинку с цифрами и выведет как картинку. Сами цифры будут сохранены в сессионные данные. Про сессию мы дальше еще поговорим. Сейчас надо знать только, что сессия однозначно определяет пользователя.
/// <summary>
/// Генерация капчи
/// </summary>
public class CaptchaImage
{
public const string CaptchaValueKey = "CaptchaImageText";
public string Text
{
get { return text; }
}
public Bitmap Image
{
get { return image; }
}
public int Width
{
get { return width; }
}
public int Height
{
get { return height; }
}
// Internal properties.
private string text;
private int width;
private int height;
private string familyName;
private Bitmap image;
// For generating random numbers.
private Random random = new Random();
public CaptchaImage(string s, int width, int height)
{
text = s;
SetDimensions(width, height);
GenerateImage();
}
public CaptchaImage(string s, int width, int height, string familyName)
{
text = s;
SetDimensions(width, height);
SetFamilyName(familyName);
GenerateImage();
}
// ====================================================================
// This member overrides Object.Finalize.
// ====================================================================
~CaptchaImage()
{
Dispose(false);
}
// ====================================================================
// Releases all resources used by this object.
// ====================================================================
public void Dispose()
{
GC.SuppressFinalize(this);
Dispose(true);
}
// ====================================================================
// Custom Dispose method to clean up unmanaged resources.
// ====================================================================
protected virtual void Dispose(bool disposing)
{
if (disposing)
// Dispose of the bitmap.
image.Dispose();
}
// ====================================================================
// Sets the image aWidth and aHeight.
// ====================================================================
private void SetDimensions(int aWidth, int aHeight)
{
// Check the aWidth and aHeight.
if (aWidth <= 0)
throw new ArgumentOutOfRangeException("aWidth", aWidth, "Argument out of range, must be greater than zero.");
if (aHeight <= 0)
throw new ArgumentOutOfRangeException("aHeight", aHeight, "Argument out of range, must be greater than zero.");
width = aWidth;
height = aHeight;
}
// ====================================================================
// Sets the font used for the image text.
// ====================================================================
private void SetFamilyName(string aFamilyName)
{
// If the named font is not installed, default to a system font.
try
{
Font font = new Font(aFamilyName, 12F);
familyName = aFamilyName;
font.Dispose();
}
catch (Exception)
{
familyName = FontFamily.GenericSerif.Name;
}
}
// ====================================================================
// Creates the bitmap image.
// ====================================================================
private void GenerateImage()
{
// Create a new 32-bit bitmap image.
Bitmap bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
// Create a graphics object for drawing.
Graphics g = Graphics.FromImage(bitmap);
g.SmoothingMode = SmoothingMode.AntiAlias;
Rectangle rect = new Rectangle(0, 0, width, height);
// Fill in the background.
HatchBrush hatchBrush = new HatchBrush(HatchStyle.SmallConfetti, Color.LightGray, Color.White);
g.FillRectangle(hatchBrush, rect);
// Set up the text font.
SizeF size;
float fontSize = rect.Height + 1;
Font font;
// Adjust the font size until the text fits within the image.
do
{
fontSize--;
font = new Font(familyName, fontSize, FontStyle.Bold);
size = g.MeasureString(text, font);
} while (size.Width > rect.Width);
// Set up the text format.
StringFormat format = new StringFormat();
format.Alignment = StringAlignment.Center;
format.LineAlignment = StringAlignment.Center;
// Create a path using the text and warp it randomly.
GraphicsPath path = new GraphicsPath();
path.AddString(text, font.FontFamily, (int)font.Style, font.Size, rect, format);
float v = 4F;
PointF[] points =
{
new PointF(random.Next(rect.Width) / v, random.Next(rect.Height) / v),
new PointF(rect.Width - random.Next(rect.Width) / v, random.Next(rect.Height) / v),
new PointF(random.Next(rect.Width) / v, rect.Height - random.Next(rect.Height) / v),
new PointF(rect.Width - random.Next(rect.Width) / v, rect.Height - random.Next(rect.Height) / v)
};
Matrix matrix = new Matrix();
matrix.Translate(0F, 0F);
path.Warp(points, rect, matrix, WarpMode.Perspective, 0F);
// Draw the text.
hatchBrush = new HatchBrush(HatchStyle.LargeConfetti, Color.LightGray, Color.DarkGray);
g.FillPath(hatchBrush, path);
// Add some random noise.
int m = Math.Max(rect.Width, rect.Height);
for (int i = 0; i < (int)(rect.Width * rect.Height / 30F); i++)
{
int x = random.Next(rect.Width);
int y = random.Next(rect.Height);
int w = random.Next(m / 50);
int h = random.Next(m / 50);
g.FillEllipse(hatchBrush, x, y, w, h);
}
// Clean up.
font.Dispose();
hatchBrush.Dispose();
g.Dispose();
// Set the image.
image = bitmap;
}
}
Суть такова, что в свойство Image генерируется картинка, состоящая из цифр (которые как бы сложно распознать) методом GenerateImage().
Теперь сделаем метод вывода UserController.Captcha():
public ActionResult Captcha()
{
Session[CaptchaImage.CaptchaValueKey] = new Random(DateTime.Now.Millisecond).Next(1111, 9999).ToString();
var ci = new CaptchaImage(Session[CaptchaImage.CaptchaValueKey].ToString(), 211, 50, "Arial");
// Change the response headers to output a JPEG image.
this.Response.Clear();
this.Response.ContentType = "image/jpeg";
// Write the image to the response stream in JPEG format.
ci.Image.Save(this.Response.OutputStream, ImageFormat.Jpeg);
// Dispose of the CAPTCHA image object.
ci.Dispose();
return null;
}
Что здесь происходит:
- В сессии создаем случайное число от 1111 до 9999.
- Создаем в ci объект CatchaImage
- Очищаем поток вывода
- Задаем header для mime-типа этого http-ответа будет “image/jpeg” т.е. картинка формата jpeg.
- Сохраняем bitmap в выходной поток с форматом ImageFormat.Jpeg
- Освобождаем ресурсы Bitmap
- Возвращаем null, так как основная информация уже передана в поток вывода
Запрашиваем картинку из Register.cshtml (/Areas/Default/View/User/Register.cshtml):
<label class="control-label" for="FirstName">
<img src="@Url.Action("Captcha", "User")" alt="captcha" />
</label>
Проверка (/Areas/Default/Controllers/UserController.cs):
if (userView.Captcha != (string)Session[CaptchaImage.CaptchaValueKey])
{
ModelState.AddModelError("Captcha", "Текст с картинки введен неверно");
}
Вот и всё, закончили. Добавляем создание записи и проверяем, как она работает:
if (ModelState.IsValid)
{
var user = (User)ModelMapper.Map(userView, typeof(UserView), typeof(User));
Repository.CreateUser(user);
return RedirectToAction("Index");
}
Все исходники находятся по адресу https://bitbucket.org/chernikov/lessons
Автор: chernikov