[recovery mode] VueJs + MVC минимум кода максимум функциональности

Добрый день.

Я много лет использовал 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

    @foreach (var item in ViewBag.Orders)
    {
        
    }
@item.OrderID

Теперь я хочу реализовать редактирование существующего объекта.

Первое, что необходимо сделать, это описать метод в контроллере который бы по идентификатору возвращал бы на клиент 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



    
    редактирование 
    


    

Aвто генерация формы

@*создание ряда по каждому свойству объекта ордер*@
{{i}}

Если все сделано правильно, то в браузере должен отобразиться прототип будущей формы.


zlk2agxgeaiut07j2vbriwafvia.png

Как мы видим 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();
        }

Этого достаточно что бы данные начитывались при загрузке.

Настало время кастомизировать форму.

Что бы не перегружать статью я не стал вывел минимум полей. Предлагаю для начал разобраться как работать с связанными списками.

  
Стоимость перевозки
Старана приписки корабля
Город корабля
Адрес корабля

Поля ShipCountry и ShipAddress лучшие кандидаты на связанные списки.

Вот методы контроллера. Как видите все довольно просто.Вся фильтрация осуществляется с помощью Linq.

       /// 
        /// Список доступных городов c учетом региона и страны
        /// если регион или страна не заданы , то все города 
        /// 
        /// 
        /// 
        /// 
        [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");
        }

        /// 
        /// Список доступных стран c учетом региона
        /// если регион не задан, то все страны
        /// 
        /// 
        /// 
        [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



    
    редактирование 
    


    
Cтоимость перевозки
Старана приписки корабля
Город корабля
Адрес корабля

Отдельная тема Валидация. С точки зрения оптимизации скорости выполнения конечно надо сделать валидацию на клиенте. Но это приведет к дублированию кода, поэтому я показываю пример с валидацией на уровне 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 errors,bool changed)
        {
            dynamic dynamic = new ExpandoObject();
            dynamic.IsChanged = changed;//Создание свойства IsChanged
            var errProperty = new Dictionary();//Создание массива с будущими свойствами ошибки
            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 _properties;

        public DynObject(Dictionary properties)
        {
            _properties = properties;
        }

        public override IEnumerable 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 я создал кастомный атрибут

   /// 
    /// Custom Attribute Example
    /// 
    [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



    
    редактирование id=@ViewBag.Id
    
    


    
Cтоимость перевозки {{errors.Freight}}
Старана приписки корабля
Город корабля {{errors.ShipCity}}
Адрес корабля {{errors.ShipAddress}}

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
}

А вот так результат работы.


xq9eoeq9fkzka2jhlkakiijahl8.png

Что бы разобраться как работает валидация давайте внимательно посмотрим на разметку описывающую одно поле:


                    
                    {{errors.Freight}}
                

Здесь 2 важных ключевых момента:

Эта часть разметки подключает стиль ответственный за красную рамку вокруг элемента v-bind: class=»{error:! errors.Freight==''} тут vue подключает по условию css класс .

А вот эта за всплывающее окно показываемое когда курсор мыши над над элементом:

  {{errors.Freight}}

кроме этого элемент родительский элемент должен содержать атрибут class=«tooltip».

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

Вот собственно и все что я хотел рассказать.

Разработка сводится к расположению полей на форме, настройке валидации в Entyty и формированию списков. Если списки статичные и не большие, то их вполне можно задавать в коде.

C# часть кода отлично тестируется. В ближайших планах разобраться с тестированием Vue.

Буду очень признателен за конструктивную критику.

Вот ссылка на исходный код.

В примере форма называется SimpleEdit и содержит последнюю версию. Кому интересны предварительные варианты можно пройти по комитам.

© Habrahabr.ru