Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Добрый день.
Я много лет использовал WPF. Паттерн MVVC наверное один из наиболее удобных архитектурных паттернов. Я предполагал что MVC почти то же самое. Когда я на новом месте работы я увидел использование MVC на практике, то был удивлен запутанностью и одновременно отсутствием элементарной Юзабилити. Больше всего раздражает то, что валидация происходит только при перегрузки формы. Нет красных рамок подсвечивающих поле в котором ошибка, а просто выводится Alert со списком ошибок. Если ошибок много, то приходится исправлять часть ошибок и жать сохранить, что бы повторить валидацию. Кнопка сохранить всегда активна. Связанные списки правда реализованы через js, но сложно и запутанно. Модель, представление и контроллер сильно связаны поэтому протестировать все это великолепие весьма сложно.
Как с этим бороться ?? Кому интересно прошу под кат.
Полноценное использование Reart,Angular,Vue и переход на SinglePageApplicatrion в принципе не возможно в рамках данного проекта:
1) Много кода написано, принято и ни кто не даст переделывать.
2) Мы в программисты С#. Изучать Js хотим только в минимально необходимом размере, а изучать можно бесконечно (по себе знаю).
Кроме этого фреймворки Reart,Angular,Vue заточены под написание сложной логики на клиенте, что на мой WPF-ный взгляд не правильно. Вся логика должна быть в одном месте и это бизнес объект и(или) класс модели. View должно всего лишь отображать состояние модели не более того.
Исходя из вышесказанного я постарался найти подход позволяющий с минимум кода на js получить максимум функциональности. В первую очередь минимуме пользовательского кода, то есть кода который нужно писать для вывода и обновления конкретного поля.
Предлагаемая мной связка VueJs+ MVC обеспечивает подход к написанию форм частично напоминающий WPF.
При вызове формы страница загружается целиком. VueJs подключается через cdn. При каждом изменении формы Vue отправляет на сервер все изменения. На сервере через механизм Entity происходит валидация и на клиент возвращаются невалидные поля и признак что состояние модели изменилось по отношению к базе данных.
MVC модель не используется, так как в данном примере она лишняя. Функция ViewModel в WPF-ном понимании здесь размазана между vue и контроллером.
Итак поехали.
В качестве базы данных я использовал учебную базу данных Northwind которую скачал с одним из примеров Devextreem.
Создание приложения, подключение Entity и создание DbContext я оставлю за кадром. Ссылка на github с примером в конце статьи.
Создаем новый пустой контроллер MVC 5. Назовем его OrdersController. В нем пока один метод.
public ActionResult Index()
{
return View();
}
Добавим еще один
public ActionResult Edit()
{
return View();
}
Теперь надо перейти в папку Views/Orders и добавить две страницы Index.cshtml и Edit.cshtml
Важное замечание, что бы cshtml страница работала без модели надо обязательно добавить в начало страницы inherits System.Web.Mvc.WebViewPage
Предполагается, что Index.cshtml содержит таблицу из которой по выделенной строке будет осуществляться переход на страницу редактирования. Пока создадим просто ссылки которые будут вести на страницу редактирования.
@inherits System.Web.Mvc.WebViewPage
<table >
@foreach (var item in ViewBag.Orders)
{
<tr><td><a href="Edit?id=@item.OrderID">@item.OrderID</a></td></tr>
}
</table>
Теперь я хочу реализовать редактирование существующего объекта.
Первое, что необходимо сделать, это описать метод в контроллере который бы по идентификатору возвращал бы на клиент Json описание объекта.
[HttpGet]
public ActionResult GetById(int id)
{
var order = _db.Orders.Find(id);//Получили объект
string orderStr = JsonConvert.SerializeObject(order);//Сериализовали его
return Content(orderStr, "application/json");//отправили
}
Проверить, что все работает можно набрав в браузере (номер порта естественно ваш) http://localhost:63164/Orders/GetById?id=10501
Вы должны получить в браузере что то вроде
{
"OrderID": 10501,
"CustomerID": "BLAUS",
"EmployeeID": 9,
"OrderDate": "1997-04-09T00:00:00",
"RequiredDate": "1997-05-07T00:00:00",
"ShippedDate": "1997-04-16T00:00:00",
"ShipVia": 3,
"Freight": 8.85,
"ShipName": "Blauer See Delikatessen",
"ShipAddress": "Forsterstr. 57",
"ShipCity": "Mannheim",
"ShipRegion": null,
"ShipPostalCode": "68306",
"ShipCountry": "Germany"
}
Ну и (или) написав простейший тест. Однако оставим тестирование за рамками данной статьи
[Test]
public void OrderControllerGetByIdTest()
{
var bdContext = new Northwind();
var id = bdContext.Orders.First().OrderID; //получил первый существующий идентификатор
var orderController = new OrdersController();
var json = orderController.GetById(id) as ContentResult;
var res = JsonConvert.DeserializeObject(json.Content,typeof(Order)) as Order;
Assert.AreEqual(id, res.OrderID);
}
Далее необходимо создать Vue форму.
@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>редактирование </title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="app">
<h1>Aвто генерация формы</h1>
<table >
<tr v-for="(item,i) in order"> @*создание ряда по каждому свойству объекта ордер*@
<td> {{i}}</td>
<td>
<input type="text" v-model="order[i]"/>
</td>
</tr>
</table>
</div>
<script>
new Vue({
el: "#app",
data: {
order: {
OrderID: 10501,
CustomerID: "BLAUS",
EmployeeID: 9,
OrderDate: "1997-04-09T00:00:00",
RequiredDate: "1997-05-07T00:00:00",
ShippedDate: "1997-04-16T00:00:00",
ShipVia: 3,
Freight: 8.85,
ShipName: "Blauer See Delikatessen",
ShipAddress: "Forsterstr. 57",
ShipCity: "Mannheim",
ShipRegion: null,
ShipPostalCode: "68306",
ShipCountry: "Germany"
}
}
});
</script>
</body>
</html>
Если все сделано правильно, то в браузере должен отобразиться прототип будущей формы.
Как мы видим Vue отобразил все поля ровно так, как было модели. Но данные в модели пока статические и первое что нужно сделать дальше, это реализовать загрузку данных из базы через только что написанный метод.
Для этого добавим метод fetchOrder() и будем вызывать его в секции mounted:
new Vue({
el: "#app",
data: {
id: @ViewBag.Id,
order: {
OrderID: 0,
CustomerID: "",
EmployeeID: 0,
OrderDate: "",
RequiredDate: "",
ShippedDate: "",
ShipVia: 0,
Freight: 0,
ShipName: "0",
ShipAddress: "",
ShipCity: "",
ShipRegion: null,
ShipPostalCode: "",
ShipCountry: ""
},
},
methods: {
//читаем объект
fetchOrder() {
var path = "../Orders/GetById?key=" + this.id;
console.log(path);
this.fetchJson(path, json => this.order = json);
},
//обертка над стандартной функцией fetch
fetchJson(path, collback) {
try {
fetch(path, { mode: 'cors' })
.then(response => response.json())
.then(function(json) { collback(json); }
);
} catch (ex) {
alert(ex);
}
}
},
mounted: function() {
this.fetchOrder();
}
});
Ну и так как идентификатор объекта теперь должен приходить из контроллера, то в контроллере надо передавать идентификатор в динамический объект ViewBag, что бы его можно было получить во View.
public ActionResult SimpleEdit(int id = 0)
{
ViewBag.Id = id;
return View();
}
Этого достаточно что бы данные начитывались при загрузке.
Настало время кастомизировать форму.
Что бы не перегружать статью я не стал вывел минимум полей. Предлагаю для начал разобраться как работать с связанными списками.
<table >
<tr>
<td>Стоимость перевозки</td>
<td >
<input type="number" v-model="order.Freight" />
</td>
</tr>
<tr>
<td>Старана приписки корабля</td>
<td>
<input type="text" v-model="order.ShipCountry" />
</td>
</tr>
<tr>
<td>Город корабля</td>
<td>
<input type="text" v-model="order.ShipCity" />
</td>
</tr>
<tr>
<td>Адрес корабля</td>
<td>
<input type="text" v-model="order.ShipAddress" />
</td>
</tr>
</table>
Поля ShipCountry и ShipAddress лучшие кандидаты на связанные списки
Вот методы контроллера. Как видите все довольно просто.Вся фильтрация осуществляется с помощью Linq.
/// <summary>
/// Список доступных городов c учетом региона и страны
/// если регион или страна не заданы , то все города
/// </summary>
/// <param name="country"></param>
/// <param name="region"></param>
/// <returns></returns>
[HttpGet]
public ActionResult AvaiableCityList( string country,string region=null)
{
var avaiableCity = _db.Orders.Where(c => ((c.ShipRegion == region) || region == null)&& (c.ShipCountry == country) || country == null).Select(a => a.ShipCity).Distinct();
var jsonStr = JsonConvert.SerializeObject(avaiableCity);
return Content(jsonStr, "application/json");
}
/// <summary>
/// Список доступных стран c учетом региона
/// если регион не задан, то все страны
/// </summary>
/// <param name="region"></param>
/// <returns></returns>
[HttpGet]
public ActionResult AvaiableCountrys(string region=null)
{
var resList = _db.Orders.Where(c => (c.ShipRegion == region)||region==null).Select(c => c.ShipCountry).Distinct();
var json = JsonConvert.SerializeObject(resList);
return Content(json, "application/json");
}
А вот во View кода прибавилось значительно больше.
Кроме собственно функций начитки стран и городов приходится добавить watch который следит за изменениями объекта, к сожалению старое значение сложного объекта vue не сохраняет поэтому нужно сохранять его в ручную, для чего я придумал метод saveOldOrderValue: пока я сохраняю в нем только страну. Это позволяет перечитывать список городов только при изменении страны. В остальном код то же думаю понятен. В примере я показал только одноуровневый связанный список ( по этому принципу не сложно сделать вложенность любого уровня).
@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>редактирование </title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="app">
<table>
<tr>
<td>Cтоимость перевозки</td>
<td>
<input type="number" v-model="order.Freight" />
</td>
</tr>
<tr>
<td>Старана приписки корабля</td>
<td>
<select v-model="order.ShipCountry" class="input">
<option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option>
</select>
</td>
</tr>
<tr>
<td>Город корабля</td>
<td>
<select v-model="order.ShipCity" >
<option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option>
</select>
</td>
</tr>
<tr>
<td>Адрес корабля</td>
<td>
<input type="text" v-model="order.ShipAddress" />
</td>
</tr>
</table>
</div>
<script>
new Vue({
el: "#app",
data: {
id: @ViewBag.Id,
order: {
OrderID: 0,
CustomerID: "",
EmployeeID: 0,
OrderDate: "",
RequiredDate: "",
ShippedDate: "",
ShipVia: 0,
Freight: 0,
ShipName: "0",
ShipAddress: "",
ShipCity: "",
ShipRegion: null,
ShipPostalCode: "",
ShipCountry: ""
},
oldOrder: {
ShipCountry: ""
},
AvaialbeCitys: [],
AvaialbeCountrys: []
},
methods: {
//читаем объект
fetchOrder() {
var path = "../Orders/GetById?Id=" + this.id;
this.fetchJson(path, json => this.order = json);
},
fetchCityList() {
//город зависит от выбраной страны
var country = this.order.ShipCountry;
if (country == null || country === "") {
country = '';
}
var path = "../Orders/AvaiableCityList?country=" + country;
this.fetchJson(path, json => {this.AvaialbeCitys = json;});
},
fetchCountrys() {
var path = "../Orders/AvaiableCountrys";
this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;});
},
//обертка над стандартной функцией fetch
fetchJson(path, collback) {
try {
fetch(path, { mode: 'cors' })
.then(response => response.json())
.then(function(json) { collback(json); }
);
} catch (ex) {
alert(ex);
}
},
saveOldOrderValue:function(){
this.oldOrder.ShipCountry = this.order.ShipCountry;
}
},
watch: {
order: {
handler: function (after) {
if (this.oldOrder.ShipCountry !== after.ShipCountry)//Только если изменилась страна
{
this.fetchCityList();//Перечитываю список городов с учетом выбранной страны
}
this.saveOldOrderValue();
},
deep: true
}
},
mounted: function () {
this.fetchCountrys();//начитываю список стран
//начитывать список городов здесь излишне, он начитается когда начитается объект
this.fetchOrder();//читаю объект
this.saveOldOrderValue();//запоминаю старое значение
}
});
</script>
</body>
</html>
Отдельная тема Валидация. С точки зрения оптимизации скорости выполнения конечно надо сделать валидацию на клиенте. Но это приведет к дублированию кода, поэтому я показываю пример с валидацией на уровне Entity (Как собственно и должно быть в идеале). Кода при этом минимум, сама валидация происходит достаточно быстро и к тому же асинхронно. Как показала практика даже при весьма медленном интернете все работает более чем нормально.
Небольшие проблемы возникают только, если надо набрать достаточно большой текст в текстовом поле, а скорость набора текста этак символов 260 в минуту. Это место для дальнейшей оптимизации.
Методы обработки валидации в контроле у меня получились вот такие.
[HttpGet]
public ActionResult Validate(int id, string json)
{
var order = _db.Orders.Find(id);
JsonConvert.PopulateObject(json, order);
var errorsD = GetErrorsJsArrey();
return Content(errorsD.ToString(), "application/json");
}
private String GetErrorsAndChanged()
{
var changed= _db.ChangeTracker.HasChanges();
var errors = _db.GetValidationErrors();
return GetErrorsAndChanged(errors,changed);
}
private static string GetErrorsAndChanged(IEnumerable<DbEntityValidationResult> errors,bool changed)
{
dynamic dynamic = new ExpandoObject();
dynamic.IsChanged = changed;//Создание свойства IsChanged
var errProperty = new Dictionary<string, object>();//Создание массива с будущими свойствами ошибки
dynamic.Errors = new DynObject(errProperty);//Создание объекта у которого свойства задаются в массиве
foreach (DbEntityValidationResult validationError in errors)//Заполнение массива ошибками
{
foreach (DbValidationError err in validationError.ValidationErrors)//Заполнение массива ошибками
{
errProperty.Add(err.PropertyName,err.ErrorMessage);
}
}
var json = JsonConvert.SerializeObject(dynamic); return json;
}
И еще использую класс DynObject
public sealed class DynObject : DynamicObject
{
private readonly Dictionary<string, object> _properties;
public DynObject(Dictionary<string, object> properties)
{
_properties = properties;
}
public override IEnumerable<string> GetDynamicMemberNames()
{
return _properties.Keys;
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
if (_properties.ContainsKey(binder.Name))
{
result = _properties[binder.Name];
return true;
}
else
{
result = null;
return false;
}
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
if (_properties.ContainsKey(binder.Name))
{
_properties[binder.Name] = value;
return true;
}
else
{
return false;
}
}
}
Довольно многословно, но данный код пишется один раз на все приложение и не требует донастройки под конкретный объект или поле. В результате работы метода на клиент json объект со свойствами IsChanded и Errors. Эти свойства естественно нужно создать в нашем Vue и заполнять их при каждом изменении объекта.
Что бы получить ошибки валидации нужно эту валидацию где то задать. Самое время сейчас в нашем описании Entity объекта Order добавить несколько атрибутов валидации.
[MinLength(10)]
[StringLength(60)]
public string ShipAddress { get; set; }
[CheckCityAttribute("Поле ShipCity обязательно для заполнения")]
public string ShipCity { get; set; }
MinLength и StringLength стандартные атрибуты, а вот для ShipCity я создал кастомный атрибут
/// <summary>
/// Custom Attribute Example
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class CheckCityAttribute : ValidationAttribute
{
public CheckCityAttribute(string message)
{
this.ErrorMessage = message;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
ValidationResult result = ValidationResult.Success;
string[] memberNames = new string[] { validationContext.MemberName };
string val = value?.ToString();
Northwind _db = new Northwind();
Order order = (Order)validationContext.ObjectInstance;
bool exsist = _db.Orders.FirstOrDefault(o => o.ShipCity == val && o.ShipCountry == order.ShipCountry)!=null;
if (!exsist)
{
result = new ValidationResult(string.Format(this.ErrorMessage,order.ShipCity , val), memberNames);
}
return result;
}
}
Впрочем давайте оставим тему валидация Entity тоже за рамками этой статьи
Кроме того что бы отображать ошибки нужно добавить ссылку на Css и слегка доработать форму.
Вот так должна теперь выглядеть наша доработанная форма
@inherits System.Web.Mvc.WebViewPage
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>редактирование id=@ViewBag.Id</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<link rel="stylesheet" type="text/css" href="~/Content/vueError.css" />
</head>
<body>
<div id="app">
<table>
<tr>
<td>Cтоимость перевозки</td>
<td class="tooltip">
<input type="number" v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " class="input" />
<span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span>
</td>
</tr>
<tr>
<td>Старана приписки корабля</td>
<td>
<select v-model="order.ShipCountry" class="input">
<option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option>
</select>
</td>
</tr>
<tr>
<td>Город корабля</td>
<td class="tooltip">
<select v-model="order.ShipCity" v-bind:class="{error:!errors.ShipCity==''}" class="input">
<option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option>
</select>
<span v-if="!errors.ShipCity==''" class="tooltiptext">{{errors.ShipCity}}</span>
</td>
</tr>
<tr>
<td>Адрес корабля</td>
<td class="tooltip">
<input type="text" v-model="order.ShipAddress" v-bind:class="{error:!errors.ShipAddress=='' }" class="input" />
<span v-if="!errors.ShipAddress==''" class="tooltiptext">{{errors.ShipAddress}}</span>
</td>
</tr>
<tr>
<td> </td>
<td>
<button v-on:click="Save()" :disabled="IsChanged===false" class="alignRight">Save</button>
</td>
</tr>
</table>
</div>
<script>
new Vue({
el: "#app",
data: {
id: @ViewBag.Id,
order: {
OrderID: 0,
CustomerID: "",
EmployeeID: 0,
OrderDate: "",
RequiredDate: "",
ShippedDate: "",
ShipVia: 0,
Freight: 0,
ShipName: "0",
ShipAddress: "",
ShipCity: "",
ShipRegion: null,
ShipPostalCode: "",
ShipCountry: ""
},
oldOrder: {
ShipCountry: ""
},
errors: {
OrderID: null,
CustomerID: null,
EmployeeID: null,
OrderDate: null,
RequiredDate: null,
ShippedDate: null,
ShipVia: null,
Freight: null,
ShipName: null,
ShipAddress: null,
ShipCity: null,
ShipRegion: null,
ShipPostalCode: null,
ShipCountry: null
},
IsChanged: false,
AvaialbeCitys: [],
AvaialbeCountrys: []
},
methods: {
//читаем объект
fetchOrder() {
var path = "../Orders/GetById?Id=" + this.id;
this.fetchJson(path, json => this.order = json);
},
fetchCityList() {
//город зависит от выбраной страны
var country = this.order.ShipCountry;
if (country == null || country === "") {
country = '';
}
var path = "../Orders/AvaiableCityList?country=" + country;
this.fetchJson(path, json => {this.AvaialbeCitys = json;});
},
fetchCountrys() {
var path = "../Orders/AvaiableCountrys";
this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;});
},
//обертка над стандартной функцией fetch
Validate() {this.Action("Validate");},
Save() {this.Action("Save");},
Action(action) {
var myJSON = JSON.stringify(this.order);
var path = "../Orders/" + action + "?id=" + this.id + "&json=" + myJSON;
this.fetchJson(path, jsonResult => {
this.errors = jsonResult.Errors;
this.IsChanged = jsonResult.IsChanged;
});
},
fetchJson(path, collback) {
try {
fetch(path, { mode: 'cors' })
.then(response => response.json())
.then(function(json) { collback(json); }
);
} catch (ex) {
alert(ex);
}
},
saveOldOrderValue:function(){
this.oldOrder.ShipCountry = this.order.ShipCountry;
}
},
watch: {
order: {
handler: function (after) {
if (this.oldOrder.ShipCountry !== after.ShipCountry)//Только если изменилась страна
{
this.fetchCityList();//Перечитываю список городов с учетом выбранной страны
}
this.saveOldOrderValue();
this.Validate();
},
deep: true
}
},
mounted: function () {
this.fetchCountrys();//начитываю список стран
//начитывать список городов здесь излишне, он начитается когда начитается объект
this.fetchOrder();//читаю объект
this.saveOldOrderValue();//запоминаю старое значение
}
});
</script>
</body>
</html>
Tак выглядит CSS
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 120px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px 0;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -60px;
opacity: 0;
transition: opacity 0.3s;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #555 transparent transparent transparent;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
.error {
color: red;
border-color: red;
border-style: double;
}
.input {
width: 200px ;
}
.alignRight {
float: right
}
А вот так результат работы.
Что бы разобраться как работает валидация давайте внимательно посмотрим на разметку описывающую одно поле
<td class="tooltip">
<input type="number" **v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " **class="input" />
<span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span>
</td>
Здесь 2 важных ключевых момента:
Эта часть разметки подключает стиль ответственный за красную рамку вокруг элемента v-bind:class="{error:!errors.Freight==''} тут vue подключает по условию css класс.
А вот эта за всплывающее окно показываемое когда курсор мыши над над элементом
<span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span>
кроме этого элемент родительский элемент должен содержать атрибут class="tooltip".
Кроме этого, тут добавлена кнопка сохранить настроенная что, бы быть доступной только если сохранение возможно.
Вот собственно и все что я хотел рассказать.
Разработка с сводится к расположению полей на форме и настройке валидации.
C# часть кода отлично тестируется. В ближайших планах разобраться с тестированием Vue.
Буду очень признателен за конструктивную критику.
Вот ссылка на исходный код
https://github.com/SergiyShest/vue-Working