65.9K
CodeProject 正在变化。 阅读更多。
Home

MVC 4 聊天室

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (27投票s)

2014年7月7日

CPOL

4分钟阅读

viewsIcon

103017

downloadIcon

8592

使用 MVC 4 / C# / Razor / JQuery 实现的单页应用程序,演示了一个聊天室。

引言

本文是关于 MVC 和 jQuery 的文章,面向初学者到中级开发人员。它演示了如何轻松实现一个任务,而该任务在 MVC 和 jQuery 出现之前,ASP.NET 会被认为是糟糕的选择。Flash 和其他类似技术直到最近才是一个更好的选择,但 MVC 和 jQuery 消除了执行多个 AJAX 操作的复杂性和易错性,让我们能够专注于业务需求。

这个示例中可能的一个新颖之处是使用单个隐藏的 Action Link 来实现多个 Ajax 操作的想法: 

@Ajax.ActionLink("ActionLink", "Index", new { user = "", logOn="",logOff="",chatMessage = "" }, new AjaxOptions { UpdateTargetId = "RefreshArea", OnSuccess = "ChatOnSuccess", OnFailure = "ChatOnFailure" }, new { @id = "ActionLink", @style = "visibility:hidden;" })

并使用 JQuery 对其进行操作

//call the Index method of the controller and pass the attribute "chatMessage"
var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
href = href + "&chatMessage=" + encodeURIComponent(text);
$("#ActionLink").attr("href", href).click();

这样,我们的客户端代码就能轻松地以最小的麻烦执行多个 AJAX 操作。

另外,我们的服务器端代码也得到了简化。各种可能操作的处理都位于同一个控制器方法(Index)中,也可以轻松地将其隔离到服务层。每次回调执行的操作之间的区别取决于控制器方法中是否存在参数。

本质上,我们拥有一个最简代码的、基础的单页应用程序。

Controllers/ChatController.cs
public ActionResult Index(string user, bool? logOn, bool? logOff, string chatMessage)
{
    try
    {
        ...

        if (!Request.IsAjaxRequest())
        {
            //1st possible action : user just navigated to the chat room
            return View(chatModel);
        }
        else if (logOn != null && (bool)logOn)
        {
            //2nd possible action : used logged on 
            ... 
            return PartialView("Lobby", chatModel);
        }
        else if (logOff != null && (bool)logOff)
        {
            //3nd possible action : user logged off
            ...
            return PartialView("Lobby", chatModel);
        }
        else
        {
            //4th possible action : user typed a message
            ...
            return PartialView("ChatHistory", chatModel);
        }
    }
    catch (Exception ex)
    {
        //return error to AJAX function
        Response.StatusCode = 500;
        return Content(ex.Message);
    }
}

正如上面的部分代码所示,用户只能执行四种操作:

  1. 用户导航到页面。
    显示视图 Index.cshtml,允许用户输入昵称并登录。
  2. 用户点击登录。
    部分视图 Lobby.cshtml 显示在线用户列表和聊天记录。用户还可以输入新消息并点击“注销”。
  3. 用户点击注销。
    初始视图页面在浏览器窗口顶部重新加载,有效地将用户返回到登录屏幕。
  4. 用户在聊天框中输入消息。
    部分视图 ChatMessage.cshtml 重新加载。这也会每 5 秒自动完成一次,通过 JavaScript 定时器。

使用代码

聊天室的状态通过 ChatModel 类的 **静态** 变量来持久化。它包含一个在线用户列表(ChatUser 类)和一个聊天消息列表(ChatMessage 类)。

Models/ChatModel.cs
public class ChatModel
{

    /// <summary>
    /// Users that have connected to the chat
    /// </summary>
    public List<ChatUser> Users;

    /// <summary>
    /// Messages by the users
    /// </summary>
    public List<ChatMessage> ChatHistory;

    public ChatModel()
    {
        Users = new List<ChatUser>();
        ChatHistory = new List<ChatMessage>();

        ChatHistory.Add(new ChatMessage() {
            Message="The chat server started at " + DateTime.Now });
    }

    public class ChatUser
    {
        public string NickName;
        public DateTime LoggedOnTime;
        public DateTime LastPing;
    }

    public class ChatMessage
    {
        /// <summary>
        /// If null, the message is from the server
        /// </summary>
        public ChatUser ByUser;

        public DateTime When = DateTime.Now;

        public string Message = "";

    }
}

Index.cshtml 是我们最初且唯一的一个非部分视图。它包含 CSS 样式表、JavaScript 文件,并包含顶级的 DIV 元素。

Views/Chat/Index.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<!DOCTYPE html>
<html lang="en">
<head>
    ...
    <script src="../../Scripts/Chat.js"></script>
    
    <style type="text/css">
    ....
    </style>

</head>
<body>

    <div id="YourNickname">
    </div>

    <div id="LastRefresh">
    </div>

    <div id="container">
        <div class="box" id="LoginPanel">
            Nick name :
            <input type="text" id="txtNickName" />
            <button id="btnLogin" value="Start">
                Start</button>
        </div>
    </div>

    <div id="Error">
    </div>

    @Ajax.ActionLink("Login", "Index", new { thisUserLoggedOn = "" }, new AjaxOptions { UpdateTargetId = "container", OnFailure = "LoginOnFailure", OnSuccess = "LoginOnSuccess" }, new { @id = "LoginButton", @style = "visibility:hidden;" })

</body>
</html>

成功登录后,部分视图 Lobby.cshtml 加载到 DIV 容器内。

Views/Chat/Lobby.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<style type="text/css">
    ...
</style>
<div id="RefreshArea">
    @{
        Html.RenderPartial("ChatHistory", Model);
    }
</div>

@Ajax.ActionLink("ActionLink", "Index", new { user = "", logOn="",logOff="",chatMessage = "" }, new AjaxOptions { UpdateTargetId = "RefreshArea", OnSuccess = "ChatOnSuccess", OnFailure = "ChatOnFailure" }, new { @id = "ActionLink", @style = "visibility:hidden;" })

<div id="Speak">
    <table border="0" width="100%">
        <tr>
            <td rowspan="2">
                <textarea id="txtSpeak" style="width: 100%" rows="3"></textarea>
            </td>
            <td>
                <button id="btnSpeak" value="Speak" style="font-weight: bold; width: 80px">
                    Speak</button>
            </td>
        </tr>
        <tr>
            <td>
                <button id="btnLogOff" value="LogOff" style="width: 80px">
                    LogOff</button>
            </td>
        </tr>
    </table>
</div>

 

实际的用户列表和聊天消息嵌入在另一个部分视图 ChatHistory.cshtml 中。这样做的原因是,当聊天刷新时,我们不希望聊天框被重新加载。如果每次定时器刷新聊天内容时,你都必须匆忙输入消息,以免丢失部分输入的消息,那将很糟糕!

Views/Chat/ChatHistory.cshtml
@model MVC_JQuery_Chat.Models.ChatModel
<div id="Lobby">
    <p>
        <b>@Model.Users.Count online users</b></p>
    @foreach (MVC_JQuery_Chat.Models.ChatModel.ChatUser user in Model.Users)
    {
        @user.NickName<br />
    }
</div>
<div id="ChatHistory">
    @foreach (MVC_JQuery_Chat.Models.ChatModel.ChatMessage msg in Model.ChatHistory)
    {
        <p>
            <i>@msg.When</i><br />
            @if (msg.ByUser != null)
            {
                <b>@(msg.ByUser.NickName + ":")</b> @msg.Message
            }
            else
            {
                <span style="color: Red">@msg.Message</span>
            }
        </p>
    }
</div>

 

Lobby.cshtml 中的 Action Link (id 为 ActionLink) 被 Chat.js 中的 jQuery 代码操作,以准备执行前面提到的四种可能的操作。这是包含在主视图 Index.cshtml 中的 Chat.js 的完整代码。值得注意的是,在登录成功时会安装一个 JavaScript 定时器。它每隔几秒刷新一次聊天内容(用户、消息)。

Scripts/Chat.js
$(document).ready(function () {

    $("#txtNickName").val("").focus();

    $("#btnLogin").click(function () {
        var nickName = $("#txtNickName").val();
        if (nickName) {
            //call the Index method of the controller and pass the attribute "logOn"
            var href = "/Chat?user=" + encodeURIComponent(nickName);
            href = href + "&logOn=true";
            $("#LoginButton").attr("href", href).click();

            //the nickname is persisted here
            $("#YourNickname").text(nickName);
        }
    });

    //auto click when enter is pressed
    $('#txtNickName').keydown(function (e) {
        if (e.keyCode == 13) {
            e.preventDefault();
            $("#btnLogin").click();
        }
    })

});

//the login was successful. Setup events for the lobby and prepare other UI items
function LoginOnSuccess(result) {

    ScrollChat();
    ShowLastRefresh();

    $("#txtSpeak").val('').focus();

    //the chat state is fetched from the server every 5 seconds (ping)
    setTimeout("Refresh();", 5000);

    //auto post when enter is pressed
    $('#txtSpeak').keydown(function (e) {
        if (e.keyCode == 13) {
            e.preventDefault();
            $("#btnSpeak").click();
        }
    });

    //setup the event for the "Speak" button that is rendered in the partial view 
    $("#btnSpeak").click(function () {
        var text = $("#txtSpeak").val();
        if (text) {

            //call the Index method of the controller and pass the attribute "chatMessage"
            var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
            href = href + "&chatMessage=" + encodeURIComponent(text);
            $("#ActionLink").attr("href", href).click();

            $("#txtSpeak").val('').focus();
        }
    });

    //setup the event for the "Speak" button that is rendered in the partial view 
    $("#btnLogOff").click(function () {

        //call the Index method of the controller and pass the attribute "logOff"
        var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());
        href = href + "&logOff=true";
        $("#ActionLink").attr("href", href).click();

        document.location.href = "Chat";
    });

}

//briefly show login error message
function LoginOnFailure(result) {
    $("#YourNickname").val("");
    $("#Error").text(result.responseText);
    setTimeout("$('#Error').empty();", 2000);
}

//called every 5 seconds
function Refresh() {
    var href = "/Chat?user=" + encodeURIComponent($("#YourNickname").text());

    //call the Index method of the controller
    $("#ActionLink").attr("href", href).click();
    setTimeout("Refresh();", 5000);
}

//Briefly show the error returned by the server
function ChatOnFailure(result) {
    $("#Error").text(result.responseText);
    setTimeout("$('#Error').empty();", 2000);
}

//Executed when a successful communication with the server is finished
function ChatOnSuccess(result) {
    ScrollChat();
    ShowLastRefresh();
}

//scroll the chat window to the bottom
function ScrollChat() {
    var wtf = $('#ChatHistory');
    var height = wtf[0].scrollHeight;
    wtf.scrollTop(height);
}

//show the last time the chat state was fetched from the server
function ShowLastRefresh() {
    var dt = new Date();
    var time = dt.getHours() + ":" + dt.getMinutes() + ":" + dt.getSeconds();
    $("#LastRefresh").text("Last Refresh - " + time);
}

最后,这是项目中单个控制器的代码。

它实现了“输入逻辑”,并且包含保存聊天状态(用户、消息)的模型对象的静态实例。有趣的是,模型如何记住每个客户端上次“ping”服务器的时间(通过 JavaScript 定时器),并确保“注销”超过 15 秒未 ping 的客户端。这是一种简单的克服我们基于 Web 的 SPA 缺少“unload”事件的方法。

Controllers/ChatController.cs
public class ChatController : Controller
{

    static ChatModel chatModel;

    /// <summary>
    /// When the method is called with no arguments, just return the view
    /// When argument logOn is true, a user logged on
    /// When argument logOff is true, a user closed their browser or navigated away (log off)
    /// When argument chatMessage is specified, the user typed something in the chat
    /// </summary>
    public ActionResult Index(string user,bool? logOn, bool? logOff, string chatMessage)
    {
        try
        {
            if (chatModel == null) chatModel = new ChatModel();
                
            //trim chat history if needed
            if (chatModel.ChatHistory.Count > 100)
                chatModel.ChatHistory.RemoveRange(0, 90);

            if (!Request.IsAjaxRequest())
            {
                //first time loading
                return View(chatModel);
            }
            else if (logOn != null && (bool)logOn)
            {
                //check if nickname already exists
                if (chatModel.Users.FirstOrDefault(u => u.NickName == user) != null)
                {
                    throw new Exception("This nickname already exists");
                }
                else if (chatModel.Users.Count > 10)
                {
                    throw new Exception("The room is full!");
                }
                else
                {
                    #region create new user and add to lobby
                    chatModel.Users.Add( new ChatModel.ChatUser()
                    {
                        NickName = user,
                        LoggedOnTime = DateTime.Now,
                        LastPing = DateTime.Now
                    });

                    //inform lobby of new user
                    chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
                    {
                        Message = "User '" + user + "' logged on.",
                        When = DateTime.Now
                    });
                    #endregion

                }

                return PartialView("Lobby", chatModel);
            }
            else if (logOff != null && (bool)logOff)
            {
                LogOffUser( chatModel.Users.FirstOrDefault( u=>u.NickName==user) );
                return PartialView("Lobby", chatModel);
            }
            else
            {

                ChatModel.ChatUser currentUser = chatModel.Users.FirstOrDefault(u => u.NickName == user);

                //remember each user's last ping time
                currentUser.LastPing = DateTime.Now;

                #region remove inactive users
                List<ChatModel.ChatUser> removeThese = new List<ChatModel.ChatUser>();
                foreach (Models.ChatModel.ChatUser usr in chatModel.Users)
                {
                    TimeSpan span = DateTime.Now - usr.LastPing;
                    if (span.TotalSeconds > 15)
                        removeThese.Add(usr);
                }
                foreach (ChatModel.ChatUser usr in removeThese)
                {
                    LogOffUser(usr);
                }
                #endregion

                #region if there is a new message, append it to the chat
                if (!string.IsNullOrEmpty(chatMessage))
                {
                    chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
                    {
                        ByUser = currentUser,
                        Message = chatMessage,
                        When = DateTime.Now
                    });
                }
                #endregion

                return PartialView("ChatHistory", chatModel);
            }
        }
        catch (Exception ex)
        {
            //return error to AJAX function
            Response.StatusCode = 500;
            return Content(ex.Message);
        }
    }

    /// <summary>
    /// Remove this user from the lobby and inform others that he logged off
    /// </summary>
    /// <param name="user"></param>
    public void LogOffUser(ChatModel.ChatUser user)
    {
        chatModel.Users.Remove(user);
        chatModel.ChatHistory.Add(new ChatModel.ChatMessage()
        {
            Message = "User '" + user.NickName + "' logged off.",
            When = DateTime.Now
        });
    }

}

结论

手头的任务(聊天室)绝不是一个困难的编程任务,因为业务逻辑仅限于几个一元操作。但就技术而言,在 Ajax 友好的服务器端技术和 JavaScript 框架出现之前,这将是一项具有挑战性的任务。这是该任务的一个有意简单的实现,可能呈现了一些可以应用于其他地方的有趣想法,例如:

  • 使用隐藏的 Action Links 以最少的客户端和服务器端代码实现多个 AJAX 操作。
  • 使用 JavaScript 定时器和每个用户的服务器端时间戳来实现浏览器关闭时的“注销”。

顺便说一句,这是 WebSockets 示例的一个绝佳想法,但不幸的是,我的应用程序服务器目前还不支持这项技术 ;) 使用 WebSockets,聊天室将完全没有延迟,并且我们的应用程序将无需处理用户登录/注销,因为这些是 WebSockets 实现的一部分。

欢迎从 https://github.com/TheoKand 拉取此项目,并以任何可能的方式对其进行扩展。

历史

  • 2014 年 7 月 7 日:第一个版本

屏幕截图

 

© . All rights reserved.