MVC 4 聊天室
使用 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);
}
}
正如上面的部分代码所示,用户只能执行四种操作:
-
用户导航到页面。
显示视图Index.cshtml
,允许用户输入昵称并登录。 - 用户点击登录。
部分视图Lobby.cshtml
显示在线用户列表和聊天记录。用户还可以输入新消息并点击“注销”。 - 用户点击注销。
初始视图页面在浏览器窗口顶部重新加载,有效地将用户返回到登录屏幕。 - 用户在聊天框中输入消息。
部分视图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 日:第一个版本
屏幕截图