Pull to refresh

OData контроллеры в .NET MVC

Reading time10 min
Views40K
В недавно вышедшем ASP.NET and Web Tools 2012.2 Update заявлена частичная поддержка протокола oData в ASP.NET Web API. Решил сам попробовать, да и с коллегами поделиться. В статье опишу как использовать запросы и CRUD операции по протоколу oData с несколькими связанными объектами модели данных. В качестве front-end клиента использован Kendo UI framework.

Предисловие.

Статья написана с целью закрепления пройденного материала по изучению новой технологии. В связи с полным отсутствием реального опыта создания приложения на платформе .NET MVC, заранее прошу прощения за возможные огрехи. Итак…

Начальная установка.

Качаем и устанавливаем ASP.NET and Web Tools 2012.2 Update (если нет желания, можно просто в созданный проект установить пакет Microsoft ASP.NET Web API OData). Создаем новый проект ASP.NET MVC 4 Web Application и назовем «ODataSample». В диалоге New ASP.NET MVC 4 Project выбираем шаблон Web API.
Сразу же поставим пакет KendoUIWeb и сделаем необходимые настройки: в файл _Layout.cshtml необходимо вклбчить ссылки на kendo.common.min.css, kendo.default.min.css, kendo.web.min.js.

Модель.

Соответствуя соглашениям, создадим в папке Models класс Category и добавим поля:
    public class Category
    {
        public int ID { get; set; }
        [Required]
        [StringLength(50)]
        public string Name { get; set; }

    }

В той же папке создаем файл класса контекста ODataSampleContext:
    public class ODataSampleContext : DbContext
    {
        public ODataSampleContext() : base("name=ODataSampleContext")
        {
        }
        public DbSet<Category> Categories { get; set; }
    }

И обязательно воспользуемся очень полезным функционалом Entity Framework Migration – выполняем в Package manager console:
  • Enable-Migrations
  • Add-Migration Init
  • Update-Database

В результате получаем таблицу в базе с нужными полями. Можно конечно было не использовать Migration, но это очень удобно. Я даже не представляю, как можно работать по-другому. Но я что-то отвлекаюсь. Теперь переходим к самому интересному.

OData контроллер.

Пока еще не создали шаблон для OData контролера, поэтому выбираем шаблон «Empty API Controller» и меняем код на следующий:
public class CategoryController : EntitySetController<Category, int>
    {
        private ODataSampleContext db = new ODataSampleContext();

        public override IQueryable<Category> Get()
        {
            return db.Categories; ;
        }

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }

Вообще, EntitySetController унаследован от ODataController, который в свою очередь от ApiController. Получается, что все это надстройка над WebApi. Так что вообще можно самому реализовать любой протокол, тем более, что есть все исходные коды на ASP.NET CodePlex project. EntitySetController принимает два основных типа: тип сущности и тип ключа сущности.
И последний штрих: необходимо внести некоторые изменения в файл WebApiConfig:
public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
		// ...
            config.EnableQuerySupport();
		// ...
            ODataModelBuilder modelBuilder = new ODataConventionModelBuilder();
            modelBuilder.EntitySet<Category>("Category");

            Microsoft.Data.Edm.IEdmModel model = modelBuilder.GetEdmModel();
            config.Routes.MapODataRoute("ODataRoute", "odata", model);

        }
    }

Метод EnableQuerySupport включает параметры запросов(query options) OData для методов, которые возвращают тип IQueryable. Если такой необходимости нет, то можно просто пометить атрибутом [Queryable] те методы, которые Вам нужны. Кстати, на данный момент реализованы следующие параметры: $filter, $inlinecount, $orderby, $skip, $top; маловато, но достаточно для базовых целей, например, постраничная разбиение на стороне сервера (server-side paging). Остальной код создает модель сущности данных ( Entity Data Model -EDM). Метод EntitySet добавляет набор сущностей в EDM. Метод MapODataRoute устанавливает URI и настраивает конечную точку(endpoint). Это все для того, чтобы мы могли воспользоваться ссылкой: http://localhost:52864/odata/Category. Результат JSON:
{  "odata.metadata":"http://localhost:52864/odata/$metadata#Category","value":[
    {
      "ID":1,"Name":"Categoty1"
    },{
      "ID":2,"Name":"Category2"
    }
  ]
}

Для получения определенной записи по ссылке http://localhost:52864/odata/Category(1), где параметр – это ключ, необходимо переопределить метод GetEntityByKey:
        //...
        protected override Category GetEntityByKey(int key)
        {
            return db.Categories.FirstOrDefault(c => c.ID == key);
        }
        //...


Усложняем задачу.

Теперь создадим модель Product, связанную с Category.
public class Product
    {
        public int ProductID { get; set; }
        [Required]
        public string Name { get; set; }
        public decimal Price { get; set; }
        [Required]
        public int CategoryID { get; set; }
        public virtual Category Category { get; set; }
    }

Не забываем добавить в контекст строку
 public DbSet Products { get; set; } и выполняем стандартные команды: Add-Migration AddProduct, Update-Database. Вот и появилась таблица в базе. Кстати, таблицу можно легко заполнить начальными данными в методе Seed в классе Migrations/Configuration.cs вот таким методом: context.Products.AddOrUpdate(…).
Теперь необходимо создать контроллер OData. И тут возникает проблема, если мы захотим отобразить в списке продукты и названия категорий одним простым запросом. На данный момент в OData ответе будут отображаться только структурные типы. В протоколе OData есть возможность получать связанные объекты используя параметр $expand, которого обещают в скором будущем реализовать; но все рекомендуют использовать в данном случае Data Tranfer Object (DTO):
public class ProductDTO { public ProductDTO() { } public ProductDTO(Product product) { ProductID = product.ProductID; Name = product.Name; Price = product.Price; CategoryID = product.CategoryID; CategoryName = product.Category.Name; } [Key] public int ProductID { get; set; } [Required] public string Name { get; set; } public decimal Price { get; set; } public int CategoryID { get; set; } public string CategoryName { get; set; } public Product ToEntity() { return new Product { ProductID = ProductID, Name = Name, Price = Price, CategoryID = CategoryID }; } }


Контроллер с CRUD операциями

Создаем пустой контроллер и меняем на следующее:
  public class ProductController : EntitySetController<ProductDTO, int>
    {
        ODataSampleContext _context = new ODataSampleContext();
      
        public override IQueryable<ProductDTO> Get()
        {
            return _context.Products.Include(p => p.Category)
                .Select(product => new ProductDTO
                {
                    ProductID = product.ProductID,
                    Name = product.Name,
                    Price = product.Price,
                    CategoryID = product.CategoryID,
                    CategoryName = product.Category.Name,
                });
        }

        protected override ProductDTO GetEntityByKey(int key)
        {
            return new ProductDTO(_context.Products.FirstOrDefault(p => p.ProductID == key));
        }

        protected override void Dispose(bool disposing)
        {
            _context.Dispose();
            base.Dispose(disposing);
        }
}

Единственным отличием он предыдущего примера – это использование DTO. В виде домашнего задания можно реализовать то же самое, но с использование "маппера".
Для реализации CRUD операция необходимо просто переопределить методы. Для создания нового продукта добавляем в класс метод:
protected override ProductDTO CreateEntity(ProductDTO entityDto)
        {
            if (ModelState.IsValid)
            {
                var entity = entityDto.ToEntity();
                _context.Products.Add(entity);
                _context.SaveChanges();
                return new ProductDTO(_context.Products.Include(p => p.Category).FirstOrDefault(p => p.ProductID == entity.ProductID));
            }
            else
            {
                HttpResponseMessage response = null;
                response = Request.CreateResponse(HttpStatusCode.BadRequest, new ODataError
                {
                    ErrorCode = "ValidationError",
                    Message = String.Join(";", ModelState.Values.First().Errors.Select(e => e.ErrorMessage).ToArray())

                });
                throw new HttpResponseException(response);
            }
        }

Сначала проверяем модель на правильность и добавляем в базу запись. В случае несоответствия формируем ответ со статусом HttpStatusCode.BadRequest и описываем ошибку через ODataError.
В параметрах указываем код и сообщение ошибки, который клиент получает в таком виде JSON. Дополнительно можно указать язык сообщения. Напоследок генерируем исключение HttpResponseException для генерации сообщения об ошибке.
Для остальных операций добавляем в класс методы:
        protected override ProductDTO UpdateEntity(int key, ProductDTO updateDto)
        {
            
            if (!_context.Products.Any(p => p.ProductID == key))
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            var update = updateDto.ToEntity();
            _context.Products.Attach(update); 
            _context.Entry(update).State = System.Data.EntityState.Modified;
            _context.SaveChanges();
            return new ProductDTO(_context.Products.Include(p => p.Category).FirstOrDefault(p => p.ProductID == key));
           
        }
        protected override ProductDTO PatchEntity(int key, Delta<ProductDTO> patch)
        {
            Product product = _context.Products.FirstOrDefault(p => p.ProductID == key);
            if (product == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            ProductDTO ProductDTO = new ProductDTO(product);

            patch.Patch(ProductDTO);
            _context.Products.Attach(ProductDTO.ToEntity()); 
            _context.SaveChanges();
            return new ProductDTO(product);
        }
        public override void Delete([FromODataUri] int key)
        {
            Product product = _context.Products.FirstOrDefault(p => p.ProductID == key);
            if (product == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            _context.Products.Remove(product);
            _context.SaveChanges();
        }

        protected override int GetKey(ProductDTO entity)
        {
            return entity.ProductID;
        }

Из названий методов понятно, что делается. Обратите внимание на метод PatchEntity, параметром которого является объект Delta, который может хранить часть свойств, переданных для обновления.

Клиент – KendoUI Grid

Добавим в HomeController метод:
public ActionResult Products()
{
            return View();
}

И создадим очень простую View Products.cshtml в папке View/Home, в котором просто определяем div с идентификатором Grid:
@{
    ViewBag.Title = "Product";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<div id="body">
    <section class="featured">
        <div class="content-wrapper">
            <hgroup class="title">
                <h1>Product</h1>
            </hgroup>
            <div id="Grid" style="height: 380px"></div>
        </div>
    </section>
</div>

А дальше только JavaScript:
Script таблицы продуктов
<script>
    $(document).ready(function () {
        $("#Grid").kendoGrid({
            columns: [
                { field: "Name", title: "Product Name", width: "100px" },
                { field: "CategoryName", title: "Category", width: "150px", editor: categoryDropDownEditor, template: "#=CategoryName#" },
                { field: "Price", title: "Price", width: "100px" },
                { command: "edit", title: "Edit", width: "110px" },
                { command: "destroy", title: "Delete", width: "110px" },
            ],
            pageable: true,
            pageSize: 5,
            sortable: true,
            filterable: true,
            editable: "popup", 
            toolbar: ["create"], 
            dataSource: {
                serverPaging: true,
                serverFiltering: true,
                serverSorting: true,
                pageSize: 5,
                type: "odata",
                schema: {
                    data: function (response) {
                        if (response.value !== undefined)
                            return response.value; 
                        else{
                            delete response["odata.metadata"];
                            return response;
                        }
                    },
                    total: function (response) {
                        return response['odata.count'];
                    },
                    model: {
                        id: "ProductID",
                        fields: {
                            ProductID: { editable: false, type: "number" },
                            Name: {  type: "string", nullable: false },
                            Price: { nullable: false, type: "number" },
                            CategoryID: {  type: "number",validation: { required: true }, editable: true},
                            CategoryName: { validation: { required: true }, editable: true },
                        }
                    },
                },
                batch: false,
                error: error,
                transport: {
                    create: {
                        url: "/odata/Products",
                        contentType: "application/json",
                        type: "POST",
                    },
                    read: {
                        url: "/odata/Products",
                        dataType: "json",
                        contentType: "application/json",

                    },
                    update: {
                        url: function (record) {
                            return "/odata/Products" + "(" + record.ProductID + ")";
                        },
                        dataType: "json",
                        contentType: "application/json",
                        type: "PUT",
                        headers: { Prefer: "return-content" }
                    },
                    destroy: {
                        url: function (record) {
                            return "/odata/Products" + "(" + record.ProductID + ")";
                        },
                        contentType: "application/json",
                        type: "DELETE"
                    },
                    parametermap: function (data, operation) {
                        console.log(data);
                        if (operation === "read") {
                            var parammap = kendo.data.transports.odata.parametermap(data);
                            return parammap;
                        }
                        return json.stringify(data);
                    }
                }
            }
        });


    });

    function categoryDropDownEditor(container, options) {
        $('<input data-bind="value:CategoryID"/>')
            .appendTo(container)
            .kendoDropDownList({
                dataTextField: "Name",
                dataValueField: "ID",
                optionLabel: "--Select Value--",
                dataSource: {
                    schema: {
                        data: "value",
                        total: function (response) {
                            return response['odata.count'];
                        },
                        model: {
                            id: "ID",
                            fields: {
                                ID: { editable: false, type: "number" },
                                Name: { type: "string", nullable: false },

                            }
                        },
                    },
                    type: "odata",
                    serverFiltering: true,
                    serverPaging: true,
                    pageSize: 20,
                    transport: {
                        read: {
                            url: "/odata/Categories",
                            dataType: "json",
                            contentType: "application/json"
                        }
                    },
                    parametermap: function (data, operation) {
                        if (operation === "read") {
                            var parammap = kendo.data.transports.odata.parametermap(data);
                            return parammap;
                        }
                        return json.stringify(data);
                    }
                },

            });
    }
</script>


Большая часть понятна и всю информацию о Kendo можно легко найти, поэтому остановлюсь только на некоторых моментах. За всю работу с данными отвечает параметр dataSource. В schema указываем в каких значениях идут данные (data), общее количество записей (total), а также описывается модель данных. «total» необходим для постраничной навигации, например запрос GET /odata/Products?%24inlinecount=allpages&%24top=5 выдаст первые 5 записей. В transport описываются все CRUD операции и дополнительные параметры заголовка запроса. Хочу обратить внимание на строку headers: { Prefer: "return-content" } в методе update, которая обязует сервер вернуть ответ с изменёнными данными, для качественного обновления клиента. Если этот параметр не указать, то сервер вернет пустой ответ только о успешном выполнении. Для поля "CategoryName" указан свой editor: categoryDropDownEditor, позволяющий пользователю выбирать категорию с выпадающего списка.
Для обработки ошибок применим вот это:
script обработки ошибок
<script>
    function error(e) {
        if (e.errorThrown === "Bad Request") {
            var response = JSON.parse(e.xhr.responseText);
            console.log(response);
            if (response['odata.error'] != undefined) {
                alert(response['odata.error'].message.value)
            }
        }
        else {
            alert(e.status + ": " + e.errorThrown)
        }
    };
</script>


Как сказано выше, информация о ошибке выводится классом ODataError, пример:
{
  "odata.error":{
    "code":"ValidationError","message":{
      "lang":"en-US","value":"Требуется поле Name."
    }
  }
}

В перспективе можно разработать красивый обработчик ошибок.

Резюме

image
Мы получили REST сервис с CRUD операциями, валидацией, сортировкой, фильтрацией, постраничной навигацией, при чем все на стороне сервера, двух связанных объектов. Все это работает в архитектуре .net MVC, все преимущества которого можно смело использовать.
image
Материалы, которые можно использовать:
Официальный сайт
Блог разработчика
Обучение

P.S. Это моя первая статья. В случае интереса со стороны хабравчан планирую развивать данную тему и создать SPA (Single page application) приложение для мобильных устройств. Но вот в чем загвоздка – не нравится мне заниматься беспредметно. Если у кого есть интересная идея, хотел бы сотрудничать, а все результаты выкладывать на Хабр.
Tags:
Hubs:
+18
Comments14

Articles