Нужны ли нам ASP.NET и IIS? Или обходимся без них и создаём веб-приложение реального времени с использованием Katana, Web API, SignalR и AngularJS

Напомню, что в данной статье были изложены теоретические основы спецификации OWIN (Open Web Server Interface for .NET). Но, как известно, на одной теории далеко не уедешь. Поэтому в данной статье я покажу как создать что-то полезное на основе этой специификации, используя уже готовые библиотеки от Microsoft и проект Katana. При это вообще не используя службы IIS и технологию ASP.NET.  Для того, чтобы иметь возможность сравнить пример из данной статьи, с тем который будет создан, я позаимствую идею именно оттуда, но приложение будет создано с использование вышеперечисленных технологий, при этом часть кода будет сохранена. И так приступим. Создадим простое консольное приложение на C# в Visual Studio 2013, выбрав в качестве платформы .NET 4.5.1.



Структура файлов приложения будет такой.



Также, понадобится установить следующие библиотеки, используя NuGet.
<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="AngularJS.Core" version="1.2.15" targetFramework="net451" />
  <package id="jQuery" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.AspNet.SignalR" version="2.0.3" targetFramework="net451" />
  <package id="Microsoft.AspNet.SignalR.Core" version="2.0.3" targetFramework="net451" />
  <package id="Microsoft.AspNet.SignalR.JS" version="2.0.3" targetFramework="net451" />
  <package id="Microsoft.AspNet.SignalR.Owin" version="1.2.1" targetFramework="net451" />
  <package id="Microsoft.AspNet.SignalR.SystemWeb" version="2.0.3" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Client" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Core" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.Owin" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.AspNet.WebApi.WebHost" version="5.1.2" targetFramework="net451" />
  <package id="Microsoft.Owin" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.Owin.FileSystems" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.Owin.Host.HttpListener" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.Owin.Host.SystemWeb" version="2.0.1" targetFramework="net451" />
  <package id="Microsoft.Owin.Hosting" version="2.1.0" targetFramework="net451" />
  <package id="Microsoft.Owin.Security" version="2.0.1" targetFramework="net451" />
  <package id="Microsoft.Owin.StaticFiles" version="2.1.0" targetFramework="net451" />
  <package id="Newtonsoft.Json" version="5.0.1" targetFramework="net451" />
  <package id="Owin" version="1.0" targetFramework="net451" />
</packages>

Когда нужные компоненты установлены, нужно что-то, в  чём будет работать серверное приложение или говоря по другому – хост. Напомню, что в IIS используется для этого процесс пула w3wp.exe. Именно для этого было создано консольное приложение, на него будет возложена эта задача. Но этого не достаточно, еще нужен сервер (условно говоря), который будет слушать протокол HTTP и принимать и отправлять запросы. Для этого будет использован HttpListener из проекта Katana. Код запуска, приведён ниже.
namespace SignalROwinApplication
{
    using System;
    using Microsoft.Owin.Hosting;
 
    public class Program
    {
        public static void Main(string[] args)
        {
            const string Uri = "http://localhost:8088/";
 
            using (WebApp.Start<Startup>(Uri))
            {
                Console.WriteLine("Server started.");
                Console.ReadKey();
                Console.WriteLine("Server stoped.");
            }
        }
    }
}
Сервер и хост готовы. Но как же без конфигурации? В IIS/ASP.NET для этого в основном используется файл web.config. Тут подход другой. Используется специальный класс Startup, для конфигурации модулей OWIN.
[assembly: Microsoft.Owin.OwinStartup(typeof(SignalROwinApplication.Startup))]
 
namespace SignalROwinApplication
{
    using System.Diagnostics.Contracts;
    using System.IO;
    using System.Net.Http.Formatting;
    using System.Web.Http;
    using Microsoft.Owin.FileSystems;
    using Microsoft.Owin.StaticFiles;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Serialization;
    using Owin;
 
    public class Startup
    {
        public void Configuration(IAppBuilder appBuilder)
        {
            appBuilder.UseFileServer(new FileServerOptions()
            {
                FileSystem = new PhysicalFileSystem(GetRootDirectory()),
                EnableDirectoryBrowsing = true,
                RequestPath = new Microsoft.Owin.PathString("/html")
            });
 
            appBuilder.UseFileServer(new FileServerOptions()
            {
                FileSystem = new PhysicalFileSystem(GetScriptsDirectory()),
                EnableDirectoryBrowsing = true,
                RequestPath = new Microsoft.Owin.PathString("/scripts")
            });
 
            appBuilder.MapSignalR();
 
            var httpConfiguration = new HttpConfiguration();
 
            httpConfiguration.Formatters.Clear();
            httpConfiguration.Formatters.Add(new JsonMediaTypeFormatter());
 
            httpConfiguration.Formatters.JsonFormatter.SerializerSettings = 
                new JsonSerializerSettings
            {
                ContractResolver = new CamelCasePropertyNamesContractResolver()
            };
 
            httpConfiguration.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional });
 
            appBuilder.UseWebApi(httpConfiguration);
        }
 
        private static string GetRootDirectory()
        {
            var currentDirectory = Directory.GetCurrentDirectory();
            var rootDirectory = Directory.GetParent(currentDirectory).Parent;
            Contract.Assume(rootDirectory != null);
            return Path.Combine(rootDirectory.FullName, "WebContent");
        }
 
        private static string GetScriptsDirectory()
        {
            var currentDirectory = Directory.GetCurrentDirectory();
            var rootDirectory = Directory.GetParent(currentDirectory).Parent;
            Contract.Assume(rootDirectory != null);
            return Path.Combine(rootDirectory.FullName, "Scripts");
        }
    }
}
В данном случае используются три модуля: модуль для обработки статических файлов (.html, .css, .jpg, .js и т.п), модуль SignalR и модуль Web API.



Получается будут две директории: в одной будут храниться файлы скриптов, а в другой остальной контент для браузера. Причём, для последнего будет использоваться не прямое проецирование путей к файлу. Путям с "/html" из адреса будет сопоставлена директория с названием – "WebContent". Добавим туда Html разметку для страницы
<!DOCTYPE html>
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Products page</title>
    <script src="../../scripts/jquery-2.1.0.js"></script>
    <script src="../../Scripts/jquery.signalR-2.0.3.js"></script>
    <script src="/signalr/hubs"></script>
    <script src="../../scripts/angular.js"></script>
    <script src="../../scripts/application/application.js"></script>
    <script src="../../scripts/application/controllers/productscontroller.js"></script>
    <script src="../../scripts/application/services/productsservice.js"></script>
    <link href="../StylesContent/ProductsPage.css" rel="stylesheet" />
</head>
< data-ng-app="application" data-ng-controller="productsController">
    <form id="form1">
        <div class="tdiv">
            <span id="webSocketStatusSpan"></span>
            <table id="ProdTable" class="itable">
                <tr id="ProdTableHd">
                    <th>Ид</th>
                    <th>Имя</th>
                    <th>Описание</th>
                </tr>
                <tr data-ng-model="product" data-ng-repeat="product in products"
                    data-ng-click="tableRowClick(product)"
                    data-ng-class="{srow: product.id === selectedProduct.id}">
                    <td>
                        {{product.id}}
                    </td>
                    <td>
                        {{product.name}}
                    </td>
                    <td>
                        {{product.description}}
                    </td>
                </tr>
            </table>
        </div>
        <br />
        <div class="ediv">
            <ul class="elist">
                <li>
                    <span>Ид</span>
                    <input type="text" data-ng-disabled="!newProductAdded" 
                           data-ng-model="selectedProduct.id" />
                </li>
                <li>
                    <span>Имя</span>
                    <input type="text" data-ng-model="selectedProduct.name" 
                           id="ProductNameText" />
                </li>
                <li>
                    <span>Описание</span>
                    <input type="text" data-ng-model="selectedProduct.description" 
                           id="ProductDescriptionText" />
                </li>
            </ul>
            <input type="button" data-ng-disabled="tableBlocked == true" 
                   data-ng-click="addNewProduct()" 
                   value="Добавить запись" />
            <input type="button" data-ng-disabled="!selectedProduct" 
                   data-ng-click="saveProduct()"
                   value="Сохранить запись" />
            <input type="button" data-ng-disabled="selectedProduct == null" 
                   data-ng-click="deleteProduct()"
                   value="Удалить запись" />
        </div>
        <br />
        <div class="mdiv">
            <ul>
                <li data-ng-model="message" data-ng-repeat="message in messagesList">
                    {{message.dateString + ' ' + message.statusString}}
                </li>
            </ul>
        </div>
    </form>
    <div data-ng-class="{updprocess: applicationBlocked}"></div>
</>
</html>

Разметка страницы не генерируется на сервере, она просто хранится в статическом файле. Времена, когда нужно генерировть разметку на сервере постепенно проходят. А имея такие мощные клиентские библиотеки как AngularJS, это уже попросту неразумно. Если нет каких-либо веских причин для этого. Для приложения используется следующая структура клиентских библиотек JS.



Главный модуль приложения выглядит так.

var applicationModule = angular.module('application', []);
 
//Объект концентратора.
var productMessageHub = $.connection.productMessageHub;
 
$(function () {
    $.connection.hub.logging = true;
    $.connection.hub.start();
});
 
angular.module('application').value('productMessageHub', productMessageHub);

Сердце клиентского приложения:

applicationModule.controller('productsController',
    function ($scope, productsService, productMessageHub) {
    productsService.get().then(function (products) { $scope.products = products; });
 
    $scope.applicationBlocked = false;
    $scope.tableBlocked = false;
    $scope.selectedProduct = null;
    $scope.newProductAdded = false;
 
    $scope.messagesList = [];
 
    //Метод который будет получать приходящие сообщения.
    productMessageHub.client.handleProductMessage = function(message) {
        //Метод обрабатывающий сообщения.
        $scope.receivedMessageHandler(message);
    };
 
    $scope.tableRowClick = function(product) {
        if ($scope.tableBlocked === true) {
            return;
        }
 
        if ($scope.newProductAdded === true && $scope.tableBlocked === false) {
            $scope.tableBlocked = true;
            return;
        }
 
        $scope.selectedProduct = product;
    };
 
    $scope.addNewProduct = function () {
        var newProduct = { id: null, name: null, description: null };
        $scope.products.push(newProduct);
        $scope.selectedProduct = newProduct;
        $scope.newProductAdded = true;
        $scope.tableBlocked = true;
    };
 
    $scope.saveProduct = function () {
        $scope.applicationBlocked = true;
 
        if ($scope.newProductAdded == true) {
            // Тип сообщения – 1, данные для вставки.
            $scope.sendProductDataMessage(1);
        } else {
            // Тип сообщения – 2, данные для обновления.
            $scope.sendProductDataMessage(2);
        }
    };
 
    $scope.deleteProduct = function () {
        if ($scope.newProductAdded === true) {
            $scope.removeProductById($scope.selectedProduct.id);
            $scope.resetState();
        } else {
            //Тип сообщения – 3, данные для удаления.
            $scope.sendProductDataMessage(3);
        }
    };
 
    $scope.sendProductDataMessage = function(messageType) {
 
        // Создаём новое сообщение для отправки.
        var productDataMessage = new Object();
        productDataMessage.Product = new Object();
 
        // Устанавливаем тип сообщения.
        productDataMessage.MessageType = messageType;
 
        // Устанавливаем введённые данные.
        productDataMessage.Product.Id = $scope.selectedProduct.id;
        productDataMessage.Product.Name = $scope.selectedProduct.name;
        productDataMessage.Product.Description = $scope.selectedProduct.description;
 
        // Отправляет данные на сервер.
        productMessageHub.server.handleProductMessage(JSON.stringify(productDataMessage));
    };
 
    $scope.receivedMessageHandler = function (productDataMessageJsonString) {
        var productDataMessage = JSON.parse(productDataMessageJsonString);
        $scope.applicationBlocked = false;
 
        if (productDataMessage.DataProcessedSuccessfully) {
 
            switch (productDataMessage.MessageType) {
            case 1: // Новая запись.
                $scope.insertProduct(productDataMessage.Product);
                break;
            case 2: // Обновление текущей записи.
                $scope.updateProduct(productDataMessage.Product);
                break;
            case 3: // Удаление записи.
                $scope.removeProductById(productDataMessage.Product.Id);
                $scope.resetState();
                break;
            default:
                return;
            }
        }
 
        $scope.setOperationResulStatus(productDataMessage.ResponseMessage);
    };
 
    $scope.resetState = function() {
        $scope.tableBlocked = false;
        $scope.selectedProduct = null;
        $scope.newProductAdded = false;
    };
 
    $scope.setOperationResulStatus = function (statusString) {
        var date = new Date();
        var dateString = date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds();
        $scope.messagesList.push({ dateString: dateString, statusString: statusString });
        $scope.$apply();
    }
 
    $scope.insertProduct = function (product) {
        if ($scope.getProductById(product.Id) == null) {
            var newProduct = {
                id: product.Id,
                name: product.Name,
                description: product.Description
            };
            $scope.products.push(newProduct);
        } else {
            $scope.updateProduct(product);
            $scope.tableBlocked = false;
            $scope.newProductAdded = false;
        }
    };
 
    $scope.updateProduct = function (updatedProduct) {
        var product = $scope.getProductById((updatedProduct.Id));
        product.name = updatedProduct.Name;
        product.description = updatedProduct.Description;
    };
 
    $scope.removeProductById = function (productId)
    {
        var i = $scope.products.length;
 
        while (i--) {
            if ($scope.products[i].id == productId) {
                $scope.products.splice(i, 1);
                $scope.$apply();
                return;
            }
        }
    }
 
    $scope.getProductById = function(productId)
    {
        for (var i = 0; i < $scope.products.length; i++) {
            if ($scope.products[i].id == productId) {
                return $scope.products[i];
            }
        }
 
        return null;
    }
});
Сервис для получения первичных данных, остальной код будет работать через SignalR в режиме реального времени.
applicationModule.factory('productsService', function ($http, $q) {
    return {
        get: function() {
            var deferred = $q.defer();
            $http.get('/api/Products').success(deferred.resolve).error(deferred.reject);
            return deferred.promise;
        }
    };
});
Ему будет отвечать контроллер Web API.
namespace SignalROwinApplication.Api
{
    using System.Collections.Generic;
    using System.Web.Http;
    using SignalROwinApplication.DomainModel;
 
    public class ProductsController : ApiController
    {
        private readonly IProductsRepository productsRepository = new ProductsRepository();
 
        public IEnumerable<Product> Get()
        {
            return this.productsRepository.GetAllProducts();
        }
    }
}
 
А что касается кода доменной модели, то он почти не изменился, а остался тем, что и был в примере из данной статьи. Я перенёс его без изменений. И чтобы не занимать слишком много места, не буду его приводить. Фактически, у нас получилось аналогичное приложение, но без использования IIS/ASP.NET и такого костыля, как jQuery (почти без использования, так как SignalR использует её, а маршруты из Web API определены в System.Web). Вы можете скачать и сравнить оба приложения. Осталось скомпилировать и запустить приложение. Запускаем сервер:



и два браузера. И видим, что всё работает.



Что мы получили в итоге: приложение, построенное на основе новейших технологий, при этом, каждыми компонентами которого управляем мы сами. Это даёт очень большую гибкость. Остальные выводы насчёт спецификации OWIN были сделаны в этой статье. Полный пример всего приложения можно скачать отсюда.
Валерий
20.05.2014 17:54
У меня вопрос.

А можно ли с помощью катаны собрать self-hosted приложение ASP.NET MVC хотя бы с урезанными возможностями? Если нет, предвидится ли такая возможность в ближайшем будущем?
В сети я не нашел однозначного аргументированн­ого ответа на данный вопрос.
20.05.2014 18:02
На данный момент, нет. В будущем появится подобная возможность. Можно используя сторонние фреймворки: Nancy, правда я с ними не работал.
19.10.2014 14:11
По сути ASP.NET vNext является продолжением и в нём добавлены все возможности которые вам нужны.
Роман
17.06.2014 19:01
как бы сделать так чтоб не пришлось переходить на /html а сразу на localhost:port получать index.html ?
По идее можно делать редирект, но это так не красиво!
18.06.2014 9:49
Можно делать редирект. Можно перехватывать дефолтный URL при помощи маршрутизации, а потом назначить соответствующий обработчик. Но наиболее лучший вариант это создать собственный модуль и делать в нём всё, что захочется.