среда, 5 февраля 2014 г.

ASP.NET MVC: Writing realtime single page web application from scratch

Single page web applications are becoming more and more popular nowadays. Both server and client technologies are evolving so fast that it allows us implementing web application solutions in very efficient way. Let's consider each step of making such with ASP.NET MVC 4.

To Do List ASP.NET Sample on GitHub

I decided to introduce "To Do List" application, because it seems to be simple - moreover, it has to be, as it is going to be built with using:
  • Microsoft SQL Server 2008 database and accessing it with Entity Framework
  • jQuery as robust JavaScript framework
  • Underscore.js as library that simplifies a lot of generic stuff
  • Require.js as modular script loader
  • Backbone.js for giving strict structure to the application
  • SignalR for providing real-time operations

Step first: data


Application will be working with the data represented by Task and TaskState entities. To implement it:
  1. Create a database named ToDoListDB
  2. Run following query to create TaskState table:
    USE ToDoListDB;
    
    CREATE TABLE TaskState
    (
     [ALPHAID] NVARCHAR(128) PRIMARY KEY NOT NULL,
     [TITLE] NVARCHAR(128) NOT NULL
    )
    
  3. Run next query to create Task table and its reference to TaskState:
    USE ToDoListDB;
    
    CREATE TABLE Task
    (
     [ID] INT PRIMARY KEY IDENTITY(1,1) NOT NULL,
     [TASKSTATEALPHAID] NVARCHAR(128) NOT NULL,
     [CONTENT] NVARCHAR(128) NOT NULL
    )
    
    ALTER TABLE Task
    ADD CONSTRAINT FK_Task_TaskState_ALPHAID
    FOREIGN KEY ([TASKSTATEALPHAID])
    REFERENCES TaskState([ALPHAID])
    
  4. And finally, fill it with some random data:
    USE ToDoListDB;
    
    INSERT INTO TaskState([ALPHAID], [TITLE]) VALUES
     ('todo', 'To Do'),
     ('inprogress', 'In Progress'),
     ('done', 'Done')
     
    INSERT INTO Task([TASKSTATEALPHAID], [CONTENT]) VALUES
     ('todo', 'Do something useful'),
     ('inprogress', 'Do a barrell roll'),
     ('done', 'Foo Bar'),
     ('todo', 'Take some rest'),
     ('inprogress', 'Important task'),
     ('done', 'Urgent stuff'),
     ('todo', 'Some work'),
     ('inprogress', 'Read a book'),
     ('done', 'Play guitar')
    
  5. You can check data with the following approach:
    USE ToDoListDB;
    
    SELECT
     Task.[CONTENT] AS [Task],
     TaskState.[TITLE] AS [State]
    FROM
     Task INNER JOIN TaskState
      ON Task.[TASKSTATEALPHAID] = TaskState.[ALPHAID]
    

Step second: data access layer


As mentioned before, we will use Entity Framework to access data. To provide solution with data access layer, do the following:
  1. Create a separate class library project in Visual Studio.
  2. In project context menu choose "Add -> Create Element -> ADO.NET EDM Model"
  3. Proceed with entering required SQL Server credentials and choosing database. When completed, you will see entities diagram like this:
Now, when Entity Framework model is initialized, it is needed to create Repository interface and class for providing interaction layer.
  1. Interface contains all needed methods and properties signature:
    public interface ITaskRepository
    {
        IEnumerable<Task> Select();
        void Update(Task task);
        void Insert(Task task);
        void Delete(Task task);
    
        String AlphaIdToDo          { get; }
        String AlphaIdInProgress    { get; }
        String AlphaIdDone          { get; }
    }
    
  2. Its implementation looks as followed:
    public class TaskRepository : ITaskRepository
    {
        private ToDoListDBEntities _dbEntities;
    
        public TaskRepository()
        {
            _dbEntities = new ToDoListDBEntities();
        }
    
        #region ITaskRepository
    
        public IEnumerable<Task> Select()
        {
            return _dbEntities.Tasks;
        }
    
        public void Update(Task task)
        {
            ApplyTaskState(task, EntityState.Modified);
        }
    
        public void Insert(Task task)
        {
            ApplyTaskState(task, EntityState.Added);
        }
    
        public void Delete(Task task)
        {
            ApplyTaskState(task, EntityState.Deleted);
        }
    
        public string AlphaIdToDo
        {
            get { return "todo"; }
        }
    
        public string AlphaIdInProgress
        {
            get { return "inprogress"; }
        }
    
        public string AlphaIdDone
        {
            get { return "done"; }
        }
    
        #endregion ITaskRepository
    
        private void ApplyTaskState(Task task, EntityState state)
        {
            _dbEntities.Tasks.Attach(task);
            var entry = _dbEntities.Entry(task);
            entry.State = state;
            _dbEntities.SaveChanges();
        }
    }
    

Server side of the question


As application is positioned as single page one, server is not being busy with such things as Views, Action Links, Forms and so on - easier to say, it does only play a repository role and implements API controller with the following set of methods:
public class TasksController : ApiController
{
    private readonly ITaskRepository _taskRepository;

    public TasksController()
    {
        _taskRepository = new TaskRepository();
    }

    [HttpGet]
    public IEnumerable<TaskDTO> Select()
    {
        var tasks = _taskRepository.Select();
        return tasks.Select(ToTaskDTO);
    }

    [HttpPut]
    public TaskDTO Update(TaskDTO taskDto)
    {
        Task task = ToTask(taskDto);
        _taskRepository.Update(task);
        return taskDto;
    }

    [HttpPost]
    public TaskDTO Insert(TaskDTO taskDto)
    {
        Task task = ToTask(taskDto);
        _taskRepository.Insert(task);
        return taskDto;
    }

    [HttpDelete]
    public TaskDTO Delete(TaskDTO taskDto)
    {
        Task task = ToTask(taskDto);
        _taskRepository.Delete(task);
        return taskDto;
    }

    private TaskDTO ToTaskDTO(Task task)
    {
        return new TaskDTO
        {
            Id = task.ID,
            Content = task.CONTENT,
            TaskStateAlphaId = task.TASKSTATEALPHAID
        };
    }

    private Task ToTask(TaskDTO taskDto)
    {
        return new Task
        {
            ID = taskDto.Id,
            CONTENT = taskDto.Content,
            TASKSTATEALPHAID = taskDto.TaskStateAlphaId
        };
    }
}
As you see, server only implements interaction with data access layer throught specific HTTP wrappers. Also SignalR is used here as simple mediator: blank setup from NuGet and primitive Hub Class is all the stuff needed.
public class TaskHub : Hub
{
    public void Send()
    {
        Clients.All.broadcastMessage();
    }
}

Modular client application structure with RequireJS


Looks like we are already done with server. As I mentioned, client application is going to use a lot of frameworks and modules in order to provide a clear and strict structure to it. Using modules loader is the most approved way to put it all together.
  1. At first, include RequireJS main script in HTML head
  2. Then include following script with modules definitions. Look at project's scripts tree and the code written:
    require.config({
    shim: {
        //++jQuery stuff
        'jquery': { exports: '$' },
        'jquery.editable': { deps: ['jquery'] },
        'jquery.ui': { deps: ['jquery'] },
        'jquery.signalR': { deps: ['jquery'] },
        //--jQuery stuff
        'underscore': { deps: ['jquery'], exports: '_' },
        'bootstrap': { deps: ['jquery'] },
        'signalRHubs': { deps: ['jquery', 'jquery.signalR'] },
        //++Backbone stuff
        'backbone': { deps: ['underscore', 'jquery'], exports: 'Backbone' },
        'taskModel': { deps: ['backbone'], exports: 'Task' },
        'taskCollection': { deps: ['backbone', 'taskModel'], exports: 'Tasks' },
        'taskView': { deps: ['backbone'], exports: 'TaskView' }
        //--Backbone stuff
    },
    paths: {
        'noext': '/Scripts/Libraries/require/plugins/noext',
        //++jQuery stuff
        'jquery': '/Scripts/Libraries/jQuery/jquery-2.1.0',
        'jquery.editable': '/Scripts/Libraries/jQuery/plugins/jquery.editable.min',
        'jquery.ui': '/Scripts/Libraries/jQuery/ui/jquery-ui.min',
        'jquery.signalR': '/Scripts/Libraries/jQuery/signalr/jquery.signalR-2.0.2.min',
        //--jQuery stuff
        'underscore': '/Scripts/Libraries/Underscore/underscore',
        'bootstrap': '/Scripts/Libraries/Bootstrap/bootstrap.min',
        'signalRHubs': '/signalr/hubs?noext', //SignalR
        //++Backbone stuff
        'backbone': '/Scripts/Libraries/Backbone/backbone',
        'taskModel': '/Scripts/Application/Backbone/taskModel',
        'taskCollection': '/Scripts/Application/Backbone/taskCollection',
        'taskView': '/Scripts/Application/Backbone/taskView'
        //--Backbone stuff
    }
    });
    

Backbone: Model, View and Collection


Advantages of MVC pattern are widely proven and used in most applications. Let's consider the things this application is made of. Model is minimal and simple:
var Task = Backbone.Model.extend({
    idAttribute: "Id" //Set identity attribute to TaskDTO's "Id"
});
Collection defines the way client application interacts with server:
define(
    [
        'jquery',
        'backbone',
        'taskModel'
    ],
    function ($, Backbone, Task) {
        var Tasks = Backbone.Collection.extend({
            model: Task,
            url: "api/Tasks/Select",

            initialize: function (signalrHub) {
                this.signalrHub = signalrHub;
            },

            serverInsert: function (task) { //Insert on server and update collection
                var that = this;
                $.ajax({
                    type: "POST",
                    url: "api/Tasks/Insert",
                    contentType: "application/json; charset=utf-8",
                    dataType: "json",
                    data: JSON.stringify(task),
                    success: function (result) {
                        that.add(task);
                        that.signalrHub.server.send();
                    },
                    error: function (jqXHR, textStatus, errorThrown) {
                        alert('Insert error!')
                    }
                });
            },

            serverUpdate: function (task) { //Update changed item on server
                var that = this;
                $.ajax({
                    type: "PUT",
                    url: "api/Tasks/Update",
                    contentType: "application/json; charset=utf-8",
                    dataType: "json",
                    data: JSON.stringify(task),
                    success: function (result) {
                        that.signalrHub.server.send();
                    },
                    error: function (jqXHR, textStatus, errorThrown) {
                        alert('Update error!')
                    }
                });
            },

            serverDelete: function (task) { //Delete item on server
                var that = this;
                $.ajax({
                    type: "DELETE",
                    url: "api/Tasks/Delete",
                    contentType: "application/json; charset=utf-8",
                    dataType: "json",
                    data: JSON.stringify(task),
                    success: function (result) {
                        that.remove(task);
                        that.signalrHub.server.send();
                    },
                    error: function (jqXHR, textStatus, errorThrown) {
                        alert('Delete error!')
                    }
                });
            }
        });
        return Tasks;
    });
And View takes a responsibily of rendering page from template and handling its events. View code:
define(
    [
        'jquery',
        'underscore',
        'backbone',
        'jquery.editable',
        'jquery.ui',
        'bootstrap'
    ],
    function ($, _, Backbone) {
        var TaskView = Backbone.View.extend({
            el: $("body"), //Set rendering container to 
            templateHtml: $("script.tasks-board-template").html(), //Set template
            initialize: function (tasksCollection) { //Set collection and bind events for it
                var that = this; //Context holder
                this.tasksCollection = tasksCollection; //Assigning collection
            },

            render: function () {
                var that = this;

                this.tasksCollection.fetch({
                    success: function (fetchedCollection) {
                        onCollectionFetched(fetchedCollection);
                    }
                });

                function onCollectionFetched(fetchedCollection) {
                    var tasksArray = fetchedCollection.toArray();

                    //++Filling page with content
                    var content = _.template(that.templateHtml, {
                        tasks: tasksArray
                    });
                    that.$el.html(content);
                    //--Filling page with content

                    //++Making tasks content editable
                    that.$("div.content").editable({
                        callback: function (elementData) {
                            //Get edited task ID
                            var taskId = elementData.$el.parent().attr("task-id");
                            //Get task from collection by id
                            var task = fetchedCollection.get(taskId);
                            //Get element content (text edited)
                            var content = elementData.content;
                            //Set modified text to item
                            task.set("Content", content);
                            //Update item on server
                            fetchedCollection.serverUpdate(task);
                        }
                    });
                    //--Making tasks content editable

                    //++Event fired on task adding
                    $('button#buttonAddNewTask').click(function () {
                        //Retrieve value
                        var taskDescription = $('#inputTaskDescription').val();
                        if (taskDescription) { //If present
                            that.tasksCollection.serverInsert({
                                Id: null, //Insertion needs no ID
                                Content: taskDescription, //From input
                                TaskStateAlphaId: 'todo' //Queued in "To do"
                            }, that);
                        }
                    });
                    //--Event fired on task adding

                    //++Event fired on taks removal
                    $('div.remove-handler').click(function (elementData) {
                        //Get task ID
                        var taskId = $(this).parent().attr("task-id");
                        //Get task from collection by id
                        var task = fetchedCollection.get(taskId);
                        //Delete item from server
                        fetchedCollection.serverDelete(task);
                    });
                    //--Event fired on taks removal

                    $("div.draggable").draggable({
                        handle: ".drag-handler"
                    });

                    $("div.droppable").droppable({
                        //hoverClass: "tdHover",
                        drop: function (event, ui) {
                            var taskId = ui.draggable.attr('task-id');
                            var divId = event.target.id;
                            var task = fetchedCollection.get(taskId);
                            switch (divId) {
                                case 'divToDo':
                                    task.set('TaskStateAlphaId', 'todo');
                                    break;
                                case 'divInProgress':
                                    task.set('TaskStateAlphaId', 'inprogress');
                                    break;
                                case 'divDone':
                                    task.set('TaskStateAlphaId', 'done');
                                    break;
                                default:
                                    break;
                            }
                            ui.draggable.detach().css({ top: 0, left: 0 });
                            $(this).append(ui.draggable);
                            //Update item on server
                            fetchedCollection.serverUpdate(task);
                        }
                    });
                }
                return this;
            }
        });
        return TaskView;
    });
Template code:
<script type="text/template" class="tasks-board-template">
<nav class="navbar navbar-default" role="navigation">
<a class="navbar-brand">ASP.NET MVC Realtime Sample</a>
<form class="navbar-form navbar-left" role="search">
<div class="form-group">
<input type="text" id="inputTaskDescription" class="form-control" placeholder="Enter task description">
</div>
<button type="button" class="btn btn-default" id="buttonAddNewTask">Add new task</button>
</form>
</nav>
<div id="divToDo" class="divTaskContainer droppable">
<div class="divColumnHeader">To Do</div>
<% _.each(tasks, function(task) { %>
<% if(task.get("TaskStateAlphaId") == 'todo') { %>
<div class="task draggable" task-id="<%= task.id %>">
<div class="drag-handler"></div>
<div class="content"><%= task.get("Content")%></div>
<div class="remove-handler label label-danger">Remove</div>
</div>
<%} %>
<%}); %>
</div>
<div id="divInProgress" class="divTaskContainer droppable">
<div class="divColumnHeader">In Progress</div>
<% _.each(tasks, function(task) { %>
<% if(task.get("TaskStateAlphaId") == 'inprogress') { %>
<div class="task draggable" task-id="<%= task.id %>">
<div class="drag-handler"></div>
<div class="content"><%= task.get("Content")%></div>
<div class="remove-handler label label-danger">Remove</div>
</div>
<%} %>
<%}); %>
</div>
<div id="divDone" class="divTaskContainer droppable">
<div class="divColumnHeader">Done</div>
<% _.each(tasks, function(task) { %>
<% if(task.get("TaskStateAlphaId") == 'done') { %>
<div class="task draggable" task-id="<%= task.id %>">
<div class="drag-handler"></div>
<div class="content"><%= task.get("Content")%></div>
<div class="remove-handler label label-danger">Remove</div>
</div>
<%} %>
<%}); %>
</div>
</script>
Finally, all of that is required and instantiated in application's entry point:
require(
    [
        'jquery',
        'taskCollection',
        'taskView',
        'jquery.signalR',
        'signalRHubs',
    ],
    function ($, Tasks, TaskView) {
        //Instantiate hub
        var taskHub = $.connection.taskHub;
        //Pass it to collection
        var tasksCollection = new Tasks(taskHub);
        //Create a view with collection passed
        var taskView = new TaskView(tasksCollection);
        //Set render on message income
        taskHub.client.broadcastMessage = function () {
            taskView.render();
        };
        //Start connection and render when ready
        $.connection.hub.start().done(function () {
            taskView.render();
        });
    }
);

Launch


Application's main page looks as follows:

It is capable of:
  • Creating new record
  • Editing record by double-clicking on its content
  • Removing record
  • Performing it all real-time: when something is changed, all other client are notified

To Do List ASP.NET Sample on GitHub

Комментариев нет:

Отправить комментарий