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

MVC 数独 4

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.47/5 (6投票s)

2016年8月19日

CPOL

9分钟阅读

viewsIcon

15460

downloadIcon

708

一个基于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 Diagram

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/>
	}

控制器

视图和部分视图将操作发送到控制器。我的控制器相对简单。它们主要负责组织数据并将其传递给服务层类,大部分逻辑都保存在这些类中。我有三个控制器对应我的三个视图:HomeControllerGameControllerBuilderController。这是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关于此主题的博客。)

服务层

控制器通过调用服务层中的复杂方法来处理用户操作。GameViewBuilderView都调用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);
		}
	}

控制器还调用XDocPuzzleLoaderXDocPuzzleSaver类。这些类负责将棋盘的单元格列表转换为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"));
		}
	}

数据层

XDocPuzzleLoaderXDocPuzzleSaver服务层类依赖于一个存在于数据层中的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.csGlobal.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

© . All rights reserved.