ASP.NET MVC лучшие практики

ASP.NET MVC лучшие практики

Для начала скажу, что я .NET приверженец до мозга костей, не скажу, что остальные технологии чужды, но то самое ламповое и теплое я нашел для себя в .NET и в ASP.NET MVC в частности. Но как бы не была прекрасна технология, она лишь инструмент, который при грамотном использовании и модернизации становится действительно мощным и полезным. Так же с ASP.NET MVC – существуют проблемы, которые не имеют решения «из коробки». Именно такие проблемы я выделил и сгруппировал по своему усмотрению.

Проблемы ASP.NET MVC

Это основной список задач, которые требуют особого внимания и практик решения. Именно о лучших практиках решения этих задач я расскажу подробнее.

Общие проблемы архитектуры

Данный вопрос включает в себя понимание многослойной архитектуры приложений и умение грамотно сформировать solution на уровне проектов и файлов. Пример хорошей организации:

Архитектура простого приложения

Данный пример показывает организацию solution'а в рамках проектов. Проект ядра содержит все доменные модели, интерфейсы, ключевые сущности. Данный проект составляет основу приложения. Проект бизнес логики содержит всю необходимую бизнес логику, которая инкапсулирована в менеджерах и которая не должна иметь платформенных зависимостей. Проект доступа к данным - рекомендую использовать паттерн репозиторий, который помогает инкапсулировать (скрывать) доступ к хранилищу данных. К примеру, вы можете реализовать работу с данными, используя различные ORM, либо использовать хранимые процедуры. Проект контейнера инверсии управления содержит непосредственно сам контейнер, а так же настройки зависимостей абстракций и реализаций (интерфейсов и реализаций интерфейсов). Проект веб проекта содержит все наши контроллеры, css, js код, вообщем основную ASP.NET MVC инфраструктуру приложения. Саб веб проект содержит модели представлений, дополнительные хелперы, сущности, которые расширяют функционал веб проекта. Для чего же это нужно? – для обеспечения легкости основного веб проекта, ведь согласитесь, намного проще воспринимать десяток строк и пару папок, чем огромные неповоротливые файлы с ужасной структурой файлов. Но данная архитектура и организация solution'а подходит для небольших проектов. Для больших систем больше подходит следующая схема:

Схема больших проектов

Большие системы имеют ядро - "точку входа" в ASP.NET MVC приложение, а остальная функциональность представлена в виде модулей, которые наращивают функционал. Очень важно анализировать требования проекта и подбирать хорошую архитектуру проекта, так как это очень большая часть хорошего проекта.

Толстые и/или кривые контроллеры

Толстые контроллеры – это бич многих проектов, так как за частую сжатые сроки или неопытность разработчика подталкивают его к написанию всей бизнес логики в контроллер и как итог выходят большие файлы, которые сложно читать и понимать. Контроллер должен выполнять роль посредника, который получил запрос, провалидировал, отдал на обработку и вернул финальное значение. Ни больше ни меньше. В этом и есть его принцип S.O.L.I.D. - единство ответственности. Идеальная ситуация, когда у нас есть менеджеры, которые отвечают за бизнес логику и мы валидируем данные, отдаем менеджерам на обработку, после чего возвращаем результат. Использование своего базового типа для контроллеров полезно когда нам необходимо инкапслуровать общее поведение для нескольких контроллеров, иными словами мы создаем базовый класс контроллера, который после наследуем и пропадает необходимость дублировать какой то общий код в наследуемых контроллерах.

Паттерн Post/Redirect/Get

Как гласит Wikipedia:

Post/Redirect/Get (PRG) — модель поведения веб-приложений, используемая разработчиками для защиты от повторной отправки данных веб-форм (от т. н. double submit problem). Модель PRG обеспечивает интуитивно понятное поведение веб-приложений при обновлении страниц в браузере и при использовании закладок в браузере.

Post Redirect Get паттерн

Иными словами используйте отдельные типы запросов для отображения данных и для модификации их. Существуют такие ситуации, когда отправка данных формы POST запросом на сервер прошла успешно, вернулась та же страница редактирования и если пользователь просто обновит страницу ему выпадет предупреждение "Подтвердите повторную отправку формы". Данная проблема решается тем, что после модификации данных в качестве результата действия пользователь не должен получить представление, а должен быть перенаправлен на получение данных, как если бы он только заходил в редактирование сущности. Таким образом можно избежать ситуации, когда пользователь может дублировать отправку формы, даже если просто захочет обновить страницу. Данный подход хорош для классической схемы приложения ASP.NET MVC, при асинхронном взаимодействии с сервером схема не применяется.

Cache

Кэширование это сложный вопрос по своей сути. И полностью его раскрыть можно с помощью отдельных статей, но если вкратце, то использование кеширования оправдано на страницах где нет динамических данных или где нет необходимости поддерживать постоянную актуальность данных. К примеру, много проектов имеют статическую главную страницу, которая не меняется так часто. Используя кеширование, можно увеличить нагрузку, которую сможет держать сервер. Кеширование имеет много граней, к примеру - где хранятся данные: сервер/клиент и т.д. , где кешировать данные, ведь можно использовать возможности ASP.NET, IISHTTP. Простой пример использования кеширования в ASP.NET MVC - это создать Cache профиль и использовать его на необходимых действиях контроллера.

<caching>
      <outputCacheSettings>
        <outputCacheProfiles>
          <add name="AllEvents" duration="15" varyByParam="*" location="Client"/>
        </outputCacheProfiles>
       </outputCacheSettings>
</caching>
[HttpGet, OutputCache(CacheProfile = "AllEvents")]
public ActionResult All()
{
    var promoterId = this.User.Identity.GetUserId<long>();
    var events = this.eventManager.GetAll(promoterId);
    return this.View(events);
}

Использование action filter

Ну хорошо, рассказал, что контроллеры могут быть жирными, что необходимо выносить логику в менеджеры , что можно не дублировать код контроллеров, используя супер типы и кешировать нужные ответы сервера, что же еще скрывается за "толстые" и/или кривые контроллеры? А есть еще такая вещь, как action filters, мощь и пользу которых мало кто из новичков ценит и понимает вообще. Очень распространенная практика, когда программист долго пишет, используя какой-то набор готовых action filtter : авторизации и тд., но своих фильтров не пишет и не задумывается об этом. Приведу пример, как можно, используя фильтры, реализовать задачу более круто, чем с подходом "в лоб". Такой задачей является создание транзакций данных. Для этого нам нужен менеджер транзакций который инкапсулирует работу с транзакциями, а так же создать наш action filter:

[System.AttributeUsage(System.AttributeTargets.All, AllowMultiple = false, Inherited = true)]
public class TransactionAttribute : ActionFilterAttribute
{
    private static ITransactionManager TransactionManager
    {
        get
        {
            return ServiceLocator.Current.GetInstance<ITransactionManager>();
        }
    }
    
    
	public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        TransactionManager.Begin();
    }
	
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        try
        {
            if (TransactionManager.IsActive)
            {
                if (((filterContext.Exception != null) && !filterContext.ExceptionHandled))
                {
                    TransactionManager.Rollback();
                }
                else
                {
                    TransactionManager.Commit();
                }
            }
        }
        finally
        {
            TransactionManager.Dispose();
        }
    }
}

А использование фильтра предельно простое, добавив атрибут к действию контроллера, можно сразу же обеспечить функциональность без явного кода реализации.

[Transaction]
public ActionResult DeleteAccount(string email)
{
    //Удаление элеммента
}

Если внимательнее разобраться в сути action filter, то можно понять, что данный инструмент позволяет вклиниться в процесс выполнения действия контроллера и влиять на выполнение действия в различных точках жизненного цикла запроса.

DTO (Data Transfer Objects)

Мир доменных моделей представляет собой набор всех сущностей, что представляют предметную область проекта, в свою же очередь view model – это мир представлений. Модели представлений должны полностью удовлетворять потребности представлений. В множествах источников рекомендуют использовать на каждое отдельное представление отдельную модель. С помощью view model мы можем передать представлению что-то сверх доменной модели. И все бы хорошо, но когда начинаешь использовать такой подход, то сталкиваешься с такими вещами как DTO (Data Transfer Objects) – конвертация из доменной модели в view model и обратно. Для многих это становится микро адом. Проблема всем известна и решение в виде mapper’ов все знают, я же хочу показать несколько интересных подходов для решения этого вопроса. В первом варианте решения используется ручной код, тут предельно просто, у нас всю ответственность за DTO берет на себя view model:

public class IssueDetailsViewModel
{
	public int IssueID { get; set; }
       public DateTime CreatedAt { get; set; }
       public string CreatorUserName { get; set; }
       public string Subject { get; set; }
       public IssueType IssueType { get; set; }
       public string AssignedToUserName { get; set; }
       public string Body { get; set; }

       public IssueDetailsViewModel(Domain.Issue model)
    {
        this.IssueID = model.IssueID;
        this.CreatedAt = model.CreatedAt;
        this.CreatorUserName = model.Creator.UserName;
        this.AssignedToUserName = model.AssignedTo.UserName;
        this.IssueType = model.IssueType;
        this.Subject = model.Subject;
        this.Body = model.Body;
    }

    public Domain.Issue GetDomain()
    {
        return new Domain.Issue
        {
               IssueID = this.IssueID,
               CreatedAt = this.CreatedAt,
               Subject = this.Subject,
               Body = this.Body,
               IssueType = this.IssueType
        };
    }
}

Второй вариант можно использовать с mapper’ами. Суть его в том, что мы используем реализацию двух интерфейсов для указания, от какой доменной модели «пошла» view model, и как реализовать сложный mapping сущности. Сами интерфейсы:

public interface IHaveCustomMappings
{
   void CreateMappings(IConfiguration configuration);
}

Реализации интерфейсов имеют следующий вид:

public class IssueDetailsViewModel : IMapFrom<Domain.Issue>
{
	public int IssueID { get; set; }
	public DateTime CreatedAt { get; set; }
	public string CreatorUserName { get; set; }
	public string Subject { get; set; }
	public IssueType IssueType { get; set; }
	public string AssignedToUserName { get; set; }
	public string Body { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; } 
}

public class AssignmentStatsViewModel : IHaveCustomMappings
{
	public string UserName { get; set; }
	public int Enhancements { get; set; }
	public int Bugs { get; set; }
	public int Support { get; set; }
	public int Other { get; set; }

	public void CreateMappings(IConfiguration configuration)
	{
		configuration.CreateMap<ApplicationUser, AssignmentStatsViewModel>()
			.ForMember(m => m.Enhancements, opt => 
				opt.MapFrom(u => u.Assignments.Count(i => i.IssueType == IssueType.Enhancement)))
			.ForMember(m => m.Bugs, opt =>
				opt.MapFrom(u => u.Assignments.Count(i => i.IssueType == IssueType.Bug)))
			.ForMember(m => m.Support, opt =>
				opt.MapFrom(u => u.Assignments.Count(i => i.IssueType == IssueType.Support)))
			.ForMember(m => m.Other, opt =>
				opt.MapFrom(u => u.Assignments.Count(i => i.IssueType == IssueType.Other)));
	}
}

Регистрацию мапингов сущностей можно произвести вручную, по-старинке, либо используя LINQ и рефлексию. Как реализовать автоматическую регистрацию:

public class AutoMapperConfig
{
	public void Register()
	{
		var types = Assembly.GetExecutingAssembly().GetExportedTypes();
           LoadStandardMappings(types);
           LoadCustomMappings(types);
	}

	private static void LoadCustomMappings(IEnumerable<Type> types)
	{
		var maps = (from t in types
					from i in t.GetInterfaces()
					where typeof(IHaveCustomMappings).IsAssignableFrom(t) &&
						  !t.IsAbstract &&
						  !t.IsInterface
					select (IHaveCustomMappings)Activator.CreateInstance(t)).ToArray();

		foreach (var map in maps)
		{
			map.CreateMappings(Mapper.Configuration);
		}
	}

	private static void LoadStandardMappings(IEnumerable<Type> types)
	{
		var maps = (from t in types
					from i in t.GetInterfaces()
					where i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>) &&
						  !t.IsAbstract &&
						  !t.IsInterface
					select new
					{
						Source = i.GetGenericArguments()[0], 
						Destination=t
					}).ToArray();

		foreach (var map in maps)
		{
			Mapper.CreateMap(map.Source, map.Destination);
		}
	}
}

Принцип предельно прост - мы проходим по сборке и забираем все типы, которые реализуют необходимые интерфейсы и для простого мапинга cопоставляем сущности, а для сложного - выполняем метод, который описан в интерфейсе IHaveCustomMappings.

Использование строковых значений

ASP.NET MVC содержит множество функций, которые строятся с использованием строковых значений. Вроде бы ничего плохого в этом нет, но строки не проверяются во время компиляции, и без спец средств понять сходу, что в коде есть ошибка в написании строкового значения сложно. Такие строки и называются магическими, вроде все хорошо, но работать может и не будет. Пример использования магический строк:

public ActionResult CompetencyFrameworkReport()
{
    return View("ReportContainer",
    new ReportViewModel
    {
        Params = "User.CompetencyFrameworkReport.Params",
        Result = "User.CompetencyFrameworkReport.Result"
    });
}

public ActionResult ViewProfileRole()
{
    return RedirectToAction("ReviewGroupNeeded", "Assessment");
}

Как можем видеть проблемы проявляются, когда происходит перенаправление к определенному действию контроллера, когда используем маршруты в наших представлениях. Хорошей практикой для решения вопросов маршрутов является следующее:

public static class UrlHelper
{
    public static string Login(this System.Web.Mvc.UrlHelper helper)
    {
        return helper.Action("Login", "Account");
    }
}

HTML helper, который будет инкапсулировать наши маршруты. И Использовать его будем так:

//Было
<a href="@Url.Action("Login", "Account")">Login</a>

//Стало
<a href="@Url.Login()">Login</a>

Получение доступа к view model из скрипта

На практике рекомендуется использовать для отображения данных модели представления, которые позволят строго типизировать представления и дать больше возможности для настройки и сохранить разделение слоев. И очень часто у неопытных разработчиков появляется задача получить данные из модели представления и отдать их на обработку javascript'у. Как итог - выходит, что в представление вклинивается большой блок javascript кода, который и будет "связующим звеном". Выглядит все это так:

app.controller('ConsultantInformationController', function($scope, $filter, $http) {
    $scope.user = {
        id: 1,
        firstName: @Model.UserBaseViewModel.FirstName,
        lastName: @Model.UserBaseViewModel.LastName
    };

    //other code is here
});

Примеры приведены для angular, но они так же будут актуальными для других js framewrk'ов, как к примеру knockout, backbone. Как же решить данные проблемы? А все предельно просто - мы создадим HTML helper, который преобразует наш объект в json и отдаст на обработку скрипту:

public static class JavaScriptHelper
{
	public static IHtmlString Json(this HtmlHelper helper, object obj)
	{
		var settings = new JsonSerializerSettings
		{
			ContractResolver = new CamelCasePropertyNamesContractResolver(),
			Converters = new JsonConverter[]
			{
				new StringEnumConverter(), 
			},
			StringEscapeHandling = StringEscapeHandling.EscapeHtml
		};

		return MvcHtmlString.Create(JsonConvert.SerializeObject(obj, settings));
	}
}

И использовать можно вот так:

<div ng-controller='editIssueController' ng-init='init(@Html.Json(Model))'></div>

И если уйти от js framework'ов, то реализация подобного подхода предельно проста - у нас должна быть функция, которая принимает json версию модели и обрабатывает ее далее. Данная практика идет тесно с рекомендацией использовать bundling и minification. Ведь скрипты, которые находятся в представлениях никоим образом не сжимаются и страница становится не оптимальной. Bundling позволяет решить порос минимизации объема кода css и js а так же количества подключений для скачивания файлов.

Взаимодействие с пользователем

Очень часто стоит задача отображать пользователю результат его действий: успешно добавлена новая сущность, сущность удалена, появление ошибок ввода пользователя, ошибки работы приложения. Для решения такого рода задач есть очень хороший подход.

Уведомление пользователю

Множество проектов сейчас основаны на bootstrap, поэтому, мы можем реализовать все следующим образом. Сперва добавим новую модель уведомления:

public class Alert
{
	public string AlertClass { get; set; }
	public string Message { get; set; }

	public Alert(string alertClass, string message)
	{
		AlertClass = alertClass;
		Message = message;
	}
}

Для хранения наших предупреждений мы будем использовать TempData. Для доступа к коллекции предупреждений реализуем следующее расширение:

public static class AlertExtensions
{
	const string Alerts = "_Alerts";

	public static List<Alert> GetAlerts(this TempDataDictionary tempData)
	{
		if (!tempData.ContainsKey(Alerts))
		{
			tempData[Alerts] = new List<Alert>();
		}

		return (List<Alert>) tempData[Alerts];
	}
}

Для создания расширения к результату действия мы создадим декоратор для ActionResult, который будет содержать результат действий, css класс для отображения и сообщение для пользователя.

public class AlertDecoratorResult : ActionResult
{
	public ActionResult InnerResult { get; set; }
	public string AlertClass { get; set; }
	public string Message { get; set; }

	public AlertDecoratorResult(ActionResult innerResult, string alertClass, string message)
	{
		InnerResult = innerResult;
		AlertClass = alertClass;
		Message = message;
	}

	public override void ExecuteResult(ControllerContext context)
	{
		var alerts = context.Controller.TempData.GetAlerts();
		alerts.Add(new Alert(AlertClass, Message));
		InnerResult.ExecuteResult(context);
	}
}

Так же добавим расширяющие методы , которые и будут возвращать сообщения о результате действия. Полный класс будет выглядеть следующим образом:

public static class AlertExtensions
{
	const string Alerts = "_Alerts";

	public static List<Alert> GetAlerts(this TempDataDictionary tempData)
	{
		if (!tempData.ContainsKey(Alerts))
		{
			tempData[Alerts] = new List<Alert>();
		}

		return (List<Alert>) tempData[Alerts];
	}

	public static ActionResult WithSuccess(this ActionResult result, string message)
	{
		return new AlertDecoratorResult(result, "alert-success", message);
	}

	public static ActionResult WithInfo(this ActionResult result, string message)
	{
		return new AlertDecoratorResult(result, "alert-info", message);
	}

	public static ActionResult WithWarning(this ActionResult result, string message)
	{
		return new AlertDecoratorResult(result, "alert-warning", message);
	}

	public static ActionResult WithError(this ActionResult result, string message)
	{
		return new AlertDecoratorResult(result, "alert-danger", message);
	}
}

Таким образом мы создали методы, которые будут добавлять уведомления пользователю и расширять наш ActionResult. Каждый метод возвращает результат + css класс для уведомления + сам текст уведомления. Для отображения сообщений создадим частичное представление и поместим его в шаблоне страницы.

return RedirectToAction<HomeController>(c => c.Index())
				       .WithSuccess("Issue created!");

return RedirectToAction<HomeController>(c => c.Index())
				       .WithError("Unable to find the issue.  Maybe it was deleted?");

return RedirectToAction<HomeController>(c => c.Index())
				       .WithInfo("Issue deleted!");

Результат отображен был на изображении выше.

Итог и финальный best practice

Приведенные примеры лишь малая часть из общей массы советов и практик решения различных проблем и задач. Главный best practice - это использовать мощь платформы на полную и знать все тонкости ее. Ведь тогда вы будете видеть все, как на ладони и дополнения, расширения каких то моментов вас не затруднит ни коем образом. Используйте голову и не бойтесь экспериментировать и пробовать новое. Если у вас есть предложения по каким то своим практикам или замечания к приведенным мной, с радостью обсужу с вами эти вопросы в комментариях статьи.

d2funlife | Даниил Павлов 2015-2020
Powered by ASP.NET Core 2.2, Entity Framework Core 2.2. Web + UI