MVC 数独 4
一个基于ASP.NET MVC 4实现的数独,附带棋盘生成器
引言
当我最初决定深入研究ASP.NET MVC时,我的创造力受到了Han Hung用VB.NET编写的数独实现的启发。他写得非常好,还配有流程图,可以在这里找到。我第一个想法是,我可以只通过将他的VB.NET作品移植到C#来涉足MVC领域。
几天后,我恢复了理智。试图通过移植来学习MVC,就像试图通过观看宝莱坞电影来学习印地语一样;这只能让你走得这么远。所以,我从Han的应用程序中汲取了最初的灵感,然后走向了一个不同的方向。结果就是这个使用MVC 4的基于Web的数独实现。
这个故事是我能提供的唯一解释,说明为什么我又在CodeProject库中添加了一个数独游戏。
项目
尽管它没有Visual Studio Express一样的功能集,但我对开源IDE SharpDevelop非常着迷。我使用SharpDevelop开发了这个ASP .NET网站,并使用IIS Express进行托管。如果您下载了这个项目并运行它,请务必在您的Web服务器上为它创建一个虚拟目录。我随机选择使用端口7070。
在这个项目中,我使用了传统的模型-视图-控制器(MVC)模式。MVC有很多变体,包括Model-View-Adapter、Model-View-Presenter和Model-View-Whatever;但我坚持使用MVC。在严格的MVC模式下,控制器填充模型,模型向视图暴露数据,视图将用户操作传达给控制器。这张来自维基百科的图表展示了MVC各部分之间的交互。
MVC是一种在表示层中暴露数据和处理用户响应的模式。表示层不应承担应用程序的繁重工作。我将应用程序逻辑放在服务层,将数据持久化逻辑放在数据层。MSDN有一个很棒的关于MVC应用程序中不同层应如何组织的文章。
领域模型和视图模型
够了,理论设计。让我们看看代码。我创建的第一项是一个Cell
类。
public class Cell
{
public int XCoordinate { get; set; }
public int YCoordinate { get; set; }
public int BlockNumber { get; set; }
[Range(1, Constants.BoardSize, ErrorMessage = Constants.CellErrorMessage)]
public int? Value { get; set; }
public Cell()
{
Value = null;
}
}
Cell
类是我的领域模型。为了跟踪Cell
在笛卡尔平面棋盘上的位置,我使用了x和y坐标。在游戏的某个处理阶段,我需要知道一个单元格属于哪个3x3的区块。经过一些试验,我发现给Cell
一个区块编号是处理和验证棋盘最简单的方法。最终结果类似于Han Hung在他的数独项目中采取的方法。
由于标准的9x9数独棋盘可以是空的,或者包含数字1到9,所以我将Cell
的值设为可空整数。在这里,NULL值代表棋盘上的空白。通过使用数据验证属性,我能够将非NULL值限制在1到9之间。1到9中的“9”是在Constants.cs中定义的,以及Cell.cs验证的错误消息。
使用Cell
类,我能够创建一个我称之为FullBoard
的视图模型。
public class FullBoard
{
public List<Cell> BoardList { get; set; }
public int BoardNumber { get; set; }
public PuzzleStatus Status { get; set; }
public FullBoard()
{
BoardList = new List<Cell>();
Status = PuzzleStatus.Normal;
}
}
棋盘被设置为容纳一个Cell
元素的列表。尽管将棋盘看作一个二维数组更自然,但在ASP.NET中,列表更容易传递。我还为棋盘添加了一个状态字段。模型通常不包含任何复杂的处理,因此与设置新棋盘或确定谜题状态相关的所有处理都已移至服务层。
视图
FullBoard
模型被两个主要视图使用:GameView和BuilderView。GameView用于玩预加载的游戏。BuilderView用于自己构建游戏,然后保存它以便将来玩。我还有一个Index页面,但它仅用于调用承载GameView和BuilderView的屏幕。这是GameView。
@model MVCSuDoku4.Model.FullBoard
<body>
<div>
<table>
<tr>
<td valign="top" rowspan="2">
@Html.Partial("_StatusView")
</td>
<td rowspan="2">
<center>
@{
ViewData["updateAction"] = "UpdateGame";
ViewData["updateView"] = "Game";
}
@Html.Partial("_BoardView")
</center>
</td>
<td valign="top">
<div style="height: 5px"></div>
<button type="button" class="bigbutton" onclick="location.href='@Url.Action("Index", "Home")'">Back to Menu</button><br/>
</td>
</tr>
<tr>
<td valign="bottom">
@Html.Partial("_ResetButtonView")
<button type="button" class="bigbutton" onclick="location.href='@Url.Action("NewGame", "Game")'">New Game</button><br/>
@{
ViewData["saveAction"] = "SaveGame";
ViewData["saveView"] = "Game";
}
@Html.Partial("_SaveButtonView")
<button type="button" class="bigbutton" onclick="location.href='@Url.Action("LoadGame", "Game")'">Load Saved Game</button>
<br/><br/>
</td>
</tr>
</table>
</div>
</body>
由于每个视图中的一些元素是相同的,特别是状态、棋盘和保存按钮,所以我为这些元素创建了部分视图。部分视图生成可以在多个视图中使用的控件或UI元素。这是_BoardView
部分视图。
{
int boardSize = (int)(ViewData["BoardSize"]);
int blockSize = (int)(ViewData["BlockSize"]);
var tbConfig = new { maxlength="1", size="1", autocomplete="off", @onkeyup = "SubmitValidCellValue(event)" };
@Html.HiddenFor(m => m.BoardNumber)
for (int x = 0; x < boardSize; x++)
{
if ((x != 0) && (x % blockSize == 0))
{
@:<br/>
}
for (int y = 0; y < boardSize; y++)
{
if ((y != 0) && (y % blockSize == 0))
{
@:
}
@Html.TextBoxFor(m => m.BoardList[x * boardSize + y].Value, tbConfig)
@Html.HiddenFor(m => m.BoardList[x * boardSize + y].XCoordinate)
@Html.HiddenFor(m => m.BoardList[x * boardSize + y].YCoordinate)
@Html.HiddenFor(m => m.BoardList[x * boardSize + y].BlockNumber)
}
<br/>
}
}
棋盘部分视图包含一些巧妙但简单的JavaScript,稍后将讨论。
对于我的保存按钮部分视图,我希望根据按钮创建的父视图不同而有不同的行为。我使用了ViewData
将一个参数从父视图传递到部分视图。(上面显示的_BoardView.cshtml遵循相同的模式。)
@{
string saveActionName = ViewData["saveAction"].ToString();
string saveView = ViewData["saveView"].ToString();
}
@using (Html.BeginForm(@saveActionName, @saveView, FormMethod.Post, new { name = "saveForm", autocomplete="off" }))
{
for (int i = 0; i < Model.BoardList.Count; i++)
{
@Html.HiddenFor(m => m.BoardList[i].Value)
@Html.HiddenFor(m => m.BoardList[i].XCoordinate)
@Html.HiddenFor(m => m.BoardList[i].YCoordinate)
@Html.HiddenFor(m => m.BoardList[i].BlockNumber)
}
@Html.HiddenFor(m => m.BoardNumber)
<button type="button" class="bigbutton" name="action" onclick="document.saveForm.submit()">Save Game</button><br/>
}
控制器
视图和部分视图将操作发送到控制器。我的控制器相对简单。它们主要负责组织数据并将其传递给服务层类,大部分逻辑都保存在这些类中。我有三个控制器对应我的三个视图:HomeController
、GameController
和BuilderController
。这是GameController
中的主要方法。
public class GameController : Controller
{
...
public ActionResult NewGame()
{
FullBoard board = new FullBoard() { BoardList = puzzleService.SetupBoard() };
int puzzleNumber;
puzzleLoader.LoadNewPuzzle(board.BoardList, out puzzleNumber);
board.BoardNumber = puzzleNumber;
return View("GameView", board);
}
public ActionResult UpdateGame(FullBoard board)
{
board.Status = puzzleService.GetPuzzleStatus(board.BoardList);
return View("GameView", board);
}
public ActionResult ResetGame(FullBoard board)
{
board.BoardList = puzzleService.SetupBoard();
puzzleLoader.ReloadPuzzle(board.BoardList, board.BoardNumber);
return View("GameView", board);
}
public ActionResult SaveGame(FullBoard board)
{
puzzleSaver.SaveGame(board.BoardList, board.BoardNumber);
board.Status = puzzleService.GetPuzzleStatus(board.BoardList);
return View("GameView", board);
}
public ActionResult LoadGame()
{
FullBoard board = new FullBoard() { BoardList = puzzleService.SetupBoard() };
int puzzleNumber;
puzzleLoader.LoadSavedPuzzle(board.BoardList, out puzzleNumber);
board.Status = puzzleService.GetPuzzleStatus(board.BoardList);
board.BoardNumber = puzzleNumber;
return View("GameView", board);
}
}
值得注意的是,我的BuilderController
有两个SavePuzzleSetup
方法,一个带有HttpPost
属性,另一个带有HttpGet
属性。HttpPost
用于将数据从视图提交到控制器;HttpGet
用于将数据返回给视图。这两个方法一起接收模型,保存棋盘并分配BoardNumber
(如果棋盘是首次保存),然后将更新传递回视图。
public class BuilderController : Controller
{
...
[HttpPost]
public ActionResult SavePuzzleSetup(FullBoard board)
{
int puzzleNumber = board.BoardNumber;
puzzleSaver.SavePuzzleSetup(board.BoardList, ref puzzleNumber);
board.BoardNumber = puzzleNumber;
board.Status = puzzleService.GetPuzzleStatus(board.BoardList);
// Return the model's updated BoardNumber by redirecting to an HttpGet method
TempData["Board"] = board;
return RedirectToAction("SavePuzzleSetup");
}
[HttpGet]
public ActionResult SavePuzzleSetup()
{
return View("BuilderView", (FullBoard)TempData["Board"]);
}
}
两个SavePuzzleSetup
方法利用了TempDataDictionary
,将FullBoard
存储在TempData
中。HttpPost
方法将更新后的模型存储在TempDataDictionary
中,然后调用HttpGet
方法进行重定向。HttpGet
方法反过来从TempDataDictionary
中读取模型,并将模型传递回视图。TempDataDictionary
就是为了这种需要短期存储数据的场景而设计的。(请参阅Rachel Appel关于此主题的博客。)
服务层
控制器通过调用服务层中的复杂方法来处理用户操作。GameView
和BuilderView
都调用PuzzleService
类公开的方法,该类负责创建棋盘并确定谜题的状态。即使SetupBoard()
总是在构造FullBoard视图模型时被调用,我也将其移至服务层,因为即使我创建的数独应用程序不使用MVC(例如,如果我使用WPF创建游戏),相同的逻辑也适用。
public class PuzzleService : IPuzzleService
{
public List<Cell> SetupBoard()
{
List<Cell> board = new List<Cell>();
for (int x = 0; x < Constants.BoardSize; x++)
{
for (int y = 0; y < Constants.BoardSize; y++)
{
Cell newCell = new Cell()
{
XCoordinate = x + 1,
YCoordinate = y + 1,
BlockNumber = Constants.BlockSize * (x / Constants.BlockSize) + (y / Constants.BlockSize) + 1
};
board.Add(newCell);
}
}
return board;
}
Cell
的设计使得验证非常简单,因为我可以轻松地对行、列和区块进行分组。我可以通过几个Linq语句来验证每个组。检查行中的重复项很容易,但判断棋盘是否已满甚至更容易。请注意,只有当棋盘有效时,状态才能设置为“Complete”。
...
private static bool AreRowsValid(List<Cell> cellList)
{
bool isValid = true;
cellList.GroupBy(c => c.XCoordinate).Select(g => g.ToList()).ToList().ForEach(s => isValid &= IsValueUniqueInSet(s));
return isValid;
}
private static bool AreColumnsValid(List<Cell> cellList)
{
bool isValid = true;
cellList.GroupBy(c => c.YCoordinate).Select(g => g.ToList()).ToList().ForEach(s => isValid &= IsValueUniqueInSet(s));
return isValid;
}
private static bool AreBlocksValid(List<Cell> cellList)
{
bool isValid = true;
cellList.GroupBy(c => c.BlockNumber).Select(g => g.ToList()).ToList().ForEach(s => isValid &= IsValueUniqueInSet(s));
return isValid;
}
private static bool IsValueUniqueInSet(List<Cell> cellGroup)
{
// Validate that each non-NULL value in this group is unique. Ignore NULL values.
return cellGroup.Where(c => c.Value.HasValue).GroupBy(c => c.Value.Value).All(g => g.Count() <= 1);
}
// Must be called after IsBoardValid(). A board can be completely filled in, but invalid.
private static bool IsPuzzleComplete(List<Cell> cellList)
{
return cellList.All(c => c.Value.HasValue);
}
}
控制器还调用XDocPuzzleLoader
和XDocPuzzleSaver
类。这些类负责将棋盘的单元格列表转换为XML,反之亦然。
public class XDocPuzzleLoader : IPuzzleLoader
{
...
public void ReloadPuzzle(List<Cell> cellList, int puzzleNumber)
{
LoadPuzzleFromSetupXDoc(puzzleNumber, cellList);
}
...
private void LoadPuzzleFromSetupXDoc(int puzzleNumber, List<Cell> cellList)
{
XDocument puzzleSetupXDoc = xDocPuzzleRepository.LoadPuzzleSetupXDoc();
XElement x = puzzleSetupXDoc.Descendants("Puzzle").First(b => (int)b.Element("Number") == puzzleNumber);
LoadCellListFromPuzzleXElement(x, cellList);
}
private void LoadCellListFromPuzzleXElement(XElement puzzleXElement, List<Cell> cellList)
{
var y = puzzleXElement.Descendants("Cells").Descendants("Cell").ToList();
y.ForEach(c => cellList[(int)c.Attribute("index")].Value = (int?)c.Attribute("value"));
}
}
数据层
XDocPuzzleLoader
和XDocPuzzleSaver
服务层类依赖于一个存在于数据层中的puzzle repository类。数据层类通过构造函数注入到服务类中。
public class XDocPuzzleLoader : IPuzzleLoader
{
private IPuzzleRepository xDocPuzzleRepository = null;
public XDocPuzzleLoader(IPuzzleRepository puzzleRepository)
{
xDocPuzzleRepository = puzzleRepository;
}
...
}
最后,数据在数据层类中保存或加载。数据层负责保存和加载用户正在玩的游戏。它还用于创建和持久化用户构建的新棋盘。目前,数据层包含一个接口IPuzzleRepository
,以及一个实现该接口的类XmlFilePuzzleRepository
。将数据持久化到XML文件是存储和检索不同棋盘的最简单方法。
public class XmlFilePuzzleRepository : IPuzzleRepository
{
...
public XDocument LoadPuzzleSetupXDoc()
{
return XDocument.Load(puzzleSetupXmlPath);
}
public XDocument LoadSavedGameXDoc()
{
return XDocument.Load(savedGameXmlPath);
}
public void SavePuzzleSetupXDoc(XDocument xDoc)
{
xDoc.Save(puzzleSetupXmlPath);
}
public void SaveSavedGameXDoc(XDocument xDoc)
{
xDoc.Save(savedGameXmlPath);
}
}
依赖注入
上面显示的数据层类使用了依赖注入(DI)。对于如此简单的程序,为什么要使用DI呢?我不是那种仅仅为了模式而使用任何模式(包括DI)的粉丝。但是,通过将服务类与数据类解耦,这种模式提供了灵活切换不同数据存储库类的能力。将来我可能想将数据保存到PostgreSQL或SQL Server。也许我会尝试Cassandra。好吧,这个应用程序可能不会。但是通过在我的服务层注入方法中接受一个接口,我能够灵活地轻松切换数据存储方式。
要在中小型项目实现DI,我通常会手动进行依赖注入,这有时会被贬称为“穷人的依赖注入”。然而,.NET Framework提供了一个简单而优雅的替代方案,即Unity Application Block容器。只需要大约五行代码,我就可以为DI设置好Unity。好吧——五行有点夸张,但创建容器和工厂非常容易!看看我来自UnityControllerFactory.cs和Global.asax.cs的代码。
public class UnityControllerFactory : DefaultControllerFactory
{
private readonly IUnityContainer container;
public UnityControllerFactory(IUnityContainer unityContainer)
{
container = unityContainer;
}
protected override IController GetControllerInstance(RequestContext context, Type controllerType)
{
return (controllerType == null) ? null : container.Resolve(controllerType) as IController;
}
}
// Note: The Global.asax.cs holds the MvcApplication class.
public class MvcApplication : HttpApplication
{
...
private static void SetupUnityFactory()
{
IUnityContainer container = new UnityContainer();
container.RegisterType<IPuzzleRepository, XmlFilePuzzleRepository>();
container.RegisterType<IPuzzleLoader, XDocPuzzleLoader>();
container.RegisterType<IPuzzleSaver, XDocPuzzleSaver>();
container.RegisterType<IPuzzleService, PuzzleService>();
UnityControllerFactory factory = new UnityControllerFactory(container);
ControllerBuilder.Current.SetControllerFactory(factory);
}
}
就这样。DI框架已经设置好了。
客户端验证
还有一点值得一提。ASP.NET MVC是一个建立在ASP.NET之上的框架。它不会改变ASP.NET工作原理的基础。即使MVC可以注入JavaScript验证代码,C#控制器代码仍然在服务器端执行。因此,每当用户点击UI上的按钮或输入一个数字时,网页都需要进行一次往返来处理用户操作。在往返结束时,服务器会将一个postback发送到客户端,并且默认情况下整个屏幕都会被重新绘制。
这个行为引发了一个问题:“当用户在一个单元格中输入一个数字时,何时以及如何将操作发送到服务器端控制器代码?”为了更新棋盘状态以指示单元格输入无效,或者谜题已成功完成,我希望对进入单元格的每个值做出响应。经过一些试验,我发现最好在JavaScript的`onkeyup`事件上触发一个事件(在_BoardView
部分视图中)。
function SubmitValidCellValue(event) {
var value = event.currentTarget.value;
if ((value == "") || (1 <= value && value <= 9)) {
document.updateForm.submit();
} else {
// If invalid, clear out value on screen
event.currentTarget.value = "";
}
}
这个JavaScript函数在客户端运行,以检查输入值是空白还是单个数字。有效的输入随后被发送到服务器端以更新棋盘并检查棋盘的状态。在快速的系统上,所有这些操作都在用户能够移动鼠标并点击另一个单元格之前完成。这个恰到好处的JavaScript函数给了我想要的一切:简单即时的客户端验证,以及几乎无法察觉但更复杂的服务器端更新棋盘状态。
结论
最终结果是,我的朋友们,是畅销的、票房过亿的、备受赞誉的iStore应用程序,它……哦,等等。划掉最后一部分。我当时在想别的事情。
最终结果是一个标准的数独应用程序,它恰好使用了ASP.NET MVC,但仍然创造了一个用户友好、可玩性强的体验。请享用!
历史
版本 1.0 - 首次发布到Code Project