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

YouGrade - Asp.NET MVC 多媒体考试套件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (97投票s)

2011年5月30日

CPOL

15分钟阅读

viewsIcon

253046

downloadIcon

6356

基于 Asp.NET 和 Youtube 构建的多媒体考试套件

目录

引言

去年我展示了一个名为 YouGrade 的 Silverlight 应用程序。当时它在 Code Project 社区获得了不错的反响,甚至超出了我的预期,我想把它重写成 Asp.NET MVC 平台会是一个不错的编程练习。虽然人们可能会争论“哪个更好”,但事实是,Silverlight 和 Asp.NET 都是 Web 生态系统中的一流公民,值得我尊重。我之所以发布这篇关于 Asp.NET 的文章,还有一个原因是,我还没有找到类似的(至少到目前为止是这样),所以我希望 Asp.NET 社区会喜欢它。

除了探索这类应用程序的潜在用途外,本文还描述了各种编程技术的运用,例如 MVC 模式编程、用于客户端开发的 jQuery 和 jQuery-UI JavaScript 框架、AJAX 技术、YouTube API 编程基础、用于对象关系映射和查询的 Entity Framework、以及用于映射实体和普通数据传输对象类的 AutoMapper。

系统要求

要使用本文提供的 YouGrade 应用程序,如果您已有 **Visual Studio 2010 和 Asp.NET MVC 3.0**,那就足够了。如果没有,您可以直接从 Microsoft 下载以下 100% 免费的开发工具

YouGrade背后的理念

本文的目标是提供一个多功能的在线多媒体考试套件。它之所以是“多媒体”,是因为它允许您使用 YouTube 视频作为试题的资源材料。简而言之,参加测试的人可以观看/收听 YouTube 视频,阅读试题,然后相应地作答。本文展示了如何利用 YouTube 的一些独特优势:您可以创建自己的视频用于自己的测试,并将其免费上传到 YouTube,然后让它们为您的测试服务。或者,您可以在考试中使用已有的 YouTube 视频。这取决于您的需求。正如读者稍后将在文章中看到的,YouTube 为 JavaScript 提供了一个 API,使 YouTube 成为一个完全可编程的工具。

模型-视图-控制器(MVC)模式

在我看来,MVC 是近年来 Asp.NET 开发领域最伟大的进步。我认为它是一种美观而优雅的模式。我一直不太喜欢 Asp.NET WebForms。即便如此,我很高兴 Asp.NET MVC 绝不是 WebForms 的替代品:它只是另一种做事方式。它遵循关注点分离的原则,确实迫使经验丰富的 Asp.NET WebForms 开发人员以不同的方式思考问题,但这并不意味着开发人员必须面临更大的困难。

这意味着视图端不再有用于执行业务逻辑的代码隐藏类。不再有 ViewState,也不再需要处理 PostBack。一旦您在 Visual Studio 中安装了 Asp.NET MVC 项目模板,您会注意到创建了一些专门用于 MVC 开发的文件夹:有一个 Models 文件夹,然后是 Views 文件夹,最后是 Controllers 文件夹。这是 MVC 项目的约定,意思是“最好将您的视图放在 Views 文件夹中,模型放在 Models 文件夹中,控制器放在 Controllers 文件夹中”。当然,您可以将控制器移动到其他文件夹,甚至移到另一个程序集(后者非常常见)。但这些只是约定,所以如果您不打算将代码拆分到多个程序集中,那么坚持约定是个好主意。这种方法称为“约定优于配置”,并且适用于许多现代框架(如 MonoRail、Asp.NET MVC 和 Ruby on Rails),它让开发人员不必设置多个配置文件。也就是说,Asp.NET MVC 是一个灵活的框架,只有当您的需求对于 Asp.NET MVC 框架来说是非传统的时,您才需要进行配置。例如:如果您想要一个新的控制器,只需创建一个控制器类并让它继承自 Controller 类,然后就可以了。但如果您想将控制器类保留在不同的程序集中,那么您必须自己进行配置,以便 MVC 框架能够找到控制器。

模型

YouGrade 应用程序的数据模型使用 Entity Framework 提供的对象关系映射器持久化到本地数据库中。下图显示了实体及其关系。

模型中的实体是为了表示运行考试所需的最小数据结构而创建的。

  • User 代表参加测试的人。
  • ExamDef 代表考试定义,即一个可以供许多用户应用的考试模板。
  • QuestionDef 是包含考试中每个问题的实体。
  • Alternative 描述了每个有效选项以及一个或多个正确选项。
  • ExamTake 代表用户参加考试的每一次尝试。
  • Answer 代表用户对每个问题中的每个选项的回答。

View

MVC 的 View 部分确实非常简单:只有一个视图,它负责显示考试标题、当前问题编号、当前问题文本、当前关联视频、提供的选项。此外,它还为用户提供界面控件,例如问题导航按钮,以及用于问题有效选项的复选框/单选按钮。

控制器

HomeControllerHome 视图的对应部分,并提供了客户端和服务器端操作之间交互所需的所有功能。

为了与 View 交互,HomeController 必须公开一组操作。这些操作由浏览器或 View 本身(通过 AJAX 调用)调用,用于导航问题或保存用户的答案。

  • 当应用程序启动时,**Index** 操作会立即被调用,并指示 Asp.NET MVC 框架使用考试数据渲染 Index
  • GetQuestion 操作由 Index 视图(通过 AJAX 调用)调用,以便将问题、视频和选项渲染到屏幕上。
  • SaveAnswer 是一个 POST 操作,它接收问题 ID 和用户对该问题的答案。然后,这些答案被保存在一个内存对象中,该对象临时存储用户的答案,然后再持久化到数据库。
  • MoveToPreviousQuestion 操作由 Index 视图(通过 AJAX 调用)调用,以导航到前一个问题,并且它还将该问题数据返回给视图。
  • MoveToNextQuestion 操作由 Index 视图(通过 AJAX 调用)调用,以导航到下一个问题,并且它还将该问题数据返回给视图。
  • EndExam 操作由 Index 视图(通过 AJAX 调用)调用,以计算用户的得分(从 0 到 100 分的正确率范围)。

使用 jQuery 处理事件、AJAX 调用和绑定

我敢说,在 jQuery 引入之后,JavaScript 编程变成了一件令人愉快的事情。我尽可能地使用了 jQuery,它为我提供了一套简洁而易读的指令集。

这就是我们如何使用 jQuery 处理窗口加载事件。

$(window).load(function () {
...

为了处理视图按钮的悬停事件,我们这样处理悬停 jQuery 事件。

	$('.button').hover(
		function () {
			$(this).removeClass('ui-state-default');
			$(this).addClass('ui-state-hover');
		},
		function () {
			$(this).addClass('ui-state-default');
			$(this).removeClass('ui-state-hover');
		});

AJAX 调用以清晰简单的方式完成:在这里,我们提供了 HomeControllerGetQuestion 操作的 URL、类型(GET)、返回类型(json - JavaScript 简单对象表示法)以及错误和成功事件的处理程序。如果成功,问题将通过 JSON 对象检索并由视图渲染。

	$.ajax({
		url: '/Home/GetQuestion',
		type: 'GET',
		cache: false,
		dataType: 'json',
		error: function (jqXHR, textStatus, errorThrown) {
			alert(errorThrown);
		},
		success: function (json) {
			question = json;
			renderQuestion(json);
		}
	});
...

这是我如何遍历选项的。请注意,智能的 **$.each($('.alternatives > input')** jQuery 语法,它允许以干净可读的方式迭代 alternative 类元素内的每个 input 元素。

	$.each($('.alternatives > input'), function (key, value) {
		if ($(this).attr('value') == 'on') {
			answers = answers + String.fromCharCode(65 + key);
		}
	});
...

以下代码片段展示了如何将 HTML 代码渲染到属于“questionTitle”和“questionText”类的 div 中。

	$('.questionTitle').html('Question ' + q.Id);
	$('.questionText').html(q.Text);
...

使用 YouTube API 进行 JavaScript 开发

我认为 YouTube API 是这个应用程序的点睛之笔。

如今,有许多网站和博客使用嵌入的 YouTube 视频。这个应用程序也不例外。

但这里有一个真正的不同之处:我们不仅嵌入了视频,还使用了一组指令来控制嵌入的视频。这得益于 YouTube API。

为了实现这一点,需要一些简单的步骤。首先,我们创建一个 div 元素来嵌入我们的视频。

	<div id="videoDiv" style="z-index: -1;">
		You need Flash player 8+ and JavaScript enabled to view this video.
	</div>

然后,我们使用 "swfobject.embedSWF" 方法,传递一些参数,例如包含视频的 div 名称、视频尺寸和窗口模式。

        var question;
        // The video to load.
        var videoID = "iapcKVn7DdY"
        http: //www.youtube.com/watch?v=
        // Lets Flash from another domain call JavaScript
        var params = { allowScriptAccess: "always", wmode: "transparent" };
        // The element id of the Flash embed
        var atts = { id: "ytPlayer" };
        // All of the magic handled by SWFObject 
		//(http://code.google.com/p/swfobject/)
        swfobject.embedSWF("http://www.youtube.com/v/" + 
		videoID + "&enablejsapi=1&playerapiid=ytPlayer&wmode=opaque",
                   "videoDiv", "480", "295", "8", null, null, params, atts);
        var ytplayer;

以下是完整语法。

swfobject.embedSWF(swfUrlStr, replaceElemIdStr, widthStr, heightStr, swfVersionStr, xiSwfUrlStr, flashvarsObj, parObj, attObj)

  • swfUrlStr - 这是 SWF 的 URL。请注意,我们在标准的 YouTube SWF URL 中附加了 enablejsapi 和 playerapiid 参数,以启用 JavaScript API 调用。
  • replaceElemIdStr - 这是要用嵌入内容替换的 HTML DIV 的 id。在上例中,它是 ytapiplayer。
  • widthStr - 播放器的宽度。
  • heightStr - 播放器的高度。
  • swfVersionStr - 用户查看内容所需的最低版本。在这种情况下,需要版本 8 或更高版本。如果用户没有 8 或更高版本,他们将在 HTML DIV 中看到默认的文本行。
  • xiSwfUrlStr - (可选) 指定您的 Express Install SWF 的 URL。在此示例中未使用。
  • flashVarsObj - (可选) 以 name:value 对的形式指定您的 FlashVars。在此示例中未使用。
  • parObj - (可选) embed 对象的参数。在这种情况下,我们设置了 allowScriptAccess。
  • attObj - (可选) embed 对象的属性。在这种情况下,我们将 id 设置为 myytplayer。

一旦视频嵌入并且播放器准备就绪,YouTube API 将调用 onYouTubePlayerReady 函数,并为您提供视频的控制权。所以,如果您想使用该 API,**必须**有这段代码。

	function onYouTubePlayerReady(playerId) {
		ytplayer = document.getElementById(playerId);
		getQuestion();
	}

以下代码摘自 renderQuestion 函数,并展示了 YouTube API 中的三个函数:stopVideoloadVideoByIdplayVideo。请注意,ytplayer 是我们在上面函数中实例化的对象。loadVideo 函数以视频 ID 作为第一个参数,以视频必须开始的位置(以秒为单位)作为第二个参数。这在您有一个长视频并且希望用户从某个特定点开始观看时尤其有用。

	function renderQuestion(q) {
		question = q;

		ytplayer.stopVideo();
		ytplayer.loadVideoById(q.Url, q.StartSeconds);
		ytplayer.playVideo();
		...

YouTube 时间链接

YouTube 视频链接很酷,我认为为这个项目添加一个不错的增强功能会很好。想法是在问题文本中查找任何 **mm:ss** 匹配项,并将这些时间标记替换为时间链接,以便用户可以“跳转”到视频中的特定时间。

首先,我们必须在数据库中创建带有时间标记的问题文本。我们可以创建任意数量的时间标记。由应用程序来处理它们。请注意,我们希望保持数据库中的问题文本干净且易读。所以,我们不往里面放 HTML 标签。

其次,我们在服务器端处理问题文本,以便将时间标记替换为适当的 HTML 标签。时间链接标签应该看起来像:

<a href="#" onclick="ytplayer.seekTo( 60 * mm * 0 + ss);return false;">mm:ss</a>

其中

  • ytplayer 是我们的播放器对象名称。
  • seekTo 是内置的 YouTube API 方法。此方法使播放器跳转到指定的秒数。请注意,我们使用 **60 * mm * 0 + ss** 作为计算总秒数的公式。
  • mm:ss 是我们试图替换为 HTML 链接的时间标记。

为了将普通文本转换为 HTML 链接,我们在服务器端的 GetQuestion 中添加了几行代码,使用正则表达式 "(\d|\d\d):(\d{2})"(查找 mm:ss 模式)来相应地替换问题文本。

public QuestionDefDto GetQuestion()
{
	...
	... some code here
	...
	//Here we create time links for the video, wherever the question text
	//matches the time regular expression
	var regexTime = new Regex(@"(\d|\d\d):(\d{2})");
	string newQuestionText = regexTime.Replace(questionDefDto.Text, 
		new MatchEvaluator(
			(target) => 
				{ 
					var timeSplit = target.ToString().Split(':');
					return string.Format("{0}:{1}", 
						timeSplit[0], timeSplit[1]);
				}
			));

	questionDefDto.Text = newQuestionText;

	return questionDefDto;
}

如果我们没有犯任何错误,上面的代码应该就足够了。现在我们运行应用程序,看看链接是否有效。

好的,现在两个链接都有效了,我们检查浏览器元素,看看我们的正则表达式替换生成的 HTML 代码。

多选题

有时问题需要多个答案。在这种情况下,您可以使用**多选题**。

多选题是指其 IsMultiSelect 属性设置为 **true** 的问题。

与单选题(其选项是 radio buttons)不同,多选题的选项在浏览器端通过此 JavaScript 代码渲染为 check boxes

	for (var i = 0; i < q.Alternatives.length; i++) {
		var checked = q.Alternatives[i].IsChecked 
			'? 'checked="true"' : '';
		var type = q.IsMultiSelect ? 'checkbox' : 'radio';
		$('.alternatives').append('<input id="alt' + q.Alternatives[i].Id + 
		'" name="alternatives" type="' + type + '" ' + checked + ' />' + 
		q.Alternatives[i].Id + '. ' + q.Alternatives[i].Text + '<br />');
	}

显示考试结果

当用户完成考试时,他/她必须结束考试才能看到结果。

连同结果,用户还会获得考试的最低分数,以便两者可以进行比较。这是通过一对 progress bars 完成的,该进度条由 **jQuery-ui** 提供,这是一个 jQuery 插件。

再次,jQuery 是我们的朋友,我们很幸运它带有漂亮的 AJAX 命令语法。

	function endExam() {
		ytplayer.pauseVideo();
		saveAnswer(function () {
			$('#endExamDialog').dialog('open');
			$.ajax({
				url: '/Home/EndExam',
				type: 'GET',
				cache: false,
				dataType: 'json',
				data: ({}),
				error: function (jqXHR, textStatus, errorThrown) {
					alert(errorThrown);
				},
				success: function (json) {
					$("#progressbarYourResults").progressbar({
						value: json.result
					});
					$('#yourResult').html(json.result);

					$("#progressbarMinimum").progressbar({
						value: json.minimum
					});
					$('#minimum').html(json.minimum);
				}
			});
		});
	}

请注意,上面的代码显示 AJAX 命令仅在 saveAnswer 完成后才被调用。这是因为 saveAnswer 本身也进行另一个 AJAX 调用。由于 AJAX 是异步调用服务器,因此在请求结果之前**必须**等待 saveAnswer 的结果。否则,我们可能会得到不一致的结果。

进度条是通过 EndExam 操作返回的结果创建的。

	[HttpGet]
	public ActionResult EndExam()
	{
		var result = ExamManager.Instance.EndExam();
		var examDefDto = ExamManager.Instance.GetExam();

		return Json(
			new { 
				success = true,
				result = result,
				minimum = (int)((100 * examDefDto.MinimumOfCorrectAnswers) / examDefDto.Questions.Count())
			}, 
			JsonRequestBehavior.AllowGet);
	}

使用 Entity Framework

ADO.NET Entity Framework 在我们的应用程序中扮演着重要角色。这个对象关系映射(ORM)框架抽象了我们 **YouGrade.mdf** 本地数据库中存在的关系数据,并将概念模型呈现给应用程序。

通过从本地数据库生成概念模型,我们现在有一组映射到相应表的实体。通过向导的帮助,数据库架构的任何更改都可以更新到概念模型中。同样,概念实体的更改也可以传播到底层数据库表中。这使得开发速度很快,并且对于我们的 YouGrade 应用程序来说效果很好。

正如您在下面看到的,YouGradeService 类中只有 2 个方法使用了 Entity Framework 实体。简单来说:第一个方法从数据库检索考试数据。另一个方法将用户的答案保存回数据库。

YouGradeService 类中的 GetExamDef 方法检索包含考试定义的实体。此外,所有相关的问题和选项也通过“Include”方法包含在结果中。如果没有这个方法,返回的实体将只包含与考试本身相关的数据(例如 Id、名称和 Description)。

	public ExamDef GetExamDef()
	{
		using (YouGradeEntities1 ctx = new YouGradeEntities1())
		{
			return ctx.ExamDef.Include("QuestionDef.Alternative").First();
		}
	}

SaveExamTake 方法接收一个 ExamTakeDto 参数,并将数据持久化到数据库。起初,这个方法看起来有点复杂,但它只是保存考试记录数据,以及用户提供的答案。

public double SaveExamTake(ExamTakeDto examTakeTO)
	{
		double grade = 0;
		try
		{
			using (YouGradeEntities1 ctx = new YouGradeEntities1())
			{
				var user = ctx.User.Where(e => (e.Id == examTakeTO.UserId)).First();
				ExamDef examDef = ctx.ExamDef.Where(e => e.Id == examTakeTO.ExamId).First();

				ExamTake newExamTake = ExamTake.CreateExamTake
					(
					0,
					examDef.Id,
					examTakeTO.UserId,
					examTakeTO.StartDateTime,
					examTakeTO.Duration,
					examTakeTO.Grade,
					examTakeTO.Status.ToString()
					);

				newExamTake.User = user;
				newExamTake.ExamDef = examDef;

				ctx.AddToExamTake(newExamTake);

				ctx.SaveChanges();

				foreach (AnswerDto a in examTakeTO.Answers)
				{
					ExamTake examTake = ctx.ExamTake
						.Where(e => e.Id == newExamTake.Id).First();
					Alternative alternative = ctx.Alternative.Where
					(e => e.QuestionId == 
						a.QuestionId).Where(e => e.Id == a.AlternativeId).First();
					Answer newAnswer = Answer
						.CreateAnswer(newExamTake.Id, a.QuestionId, a.AlternativeId, a.IsChecked);
					newAnswer.ExamTake = examTake;
					newAnswer.Alternative = alternative;
					ctx.AddToAnswer(newAnswer);
				}

				ctx.SaveChanges();

				foreach (QuestionDef q in ctx.QuestionDef)
				{
					var query = from qd in ctx.QuestionDef
						join a in ctx.Answer on qd.Id equals a.QuestionId
						join alt in ctx.Alternative on new 
						{ qId = a.QuestionId, aId = a.AlternativeId } 
						equals new { qId = alt.QuestionId, aId = alt.Id }
						where qd.Id == q.Id
						where a.ExamTakeId == newExamTake.Id
						select new { alt.Correct, a.IsChecked };

					bool correct = true;
					foreach (var v in query)
					{
						if (v.Correct != v.IsChecked)
						{
							correct = false;
							break;
						}
					}
					grade += correct ? 1 : 0;
				}

				int examTakeId = examTakeTO.Id;
			}

			using (YouGradeEntities1 ctx = new YouGradeEntities1())
			{
				ExamTake et = ctx.ExamTake.First();
				string s = et.Status;
			}

			return grade;
		}
		catch (Exception exc)
		{
			string s = exc.ToString();
			throw;
		}
	}

使用 AutoMapper

如果我们能够直接序列化 Entity Framework 生成的实体并直接在视图中使用它们作为 JSON 对象,那就太好了。

不幸的是,这并不那么简单。如果您尝试序列化这个问题定义实体,您会得到一个错误,指出问题定义对象中存在循环引用。为什么会发生这种情况?在我们的例子中,实体具有“导航属性”,这意味着,例如,对于给定的问题定义,有一个属性指向子选项的列表。并且每个选项也有一个指向父问题的导航属性。为了解决这个问题,我为每个实体创建了一个相应的 **Data Transfer Object** (DTO) 类,以便我可以将数据从实体对象“传输”到这些 POCO(Plain Old CLR Objects),然后将它们序列化到视图中。

但是我们如何进行这种映射呢?创建 DTO 对象的新实例并传输数据可能是一项繁琐的任务。相反,我们可以使用一些自动映射技术。在这个项目中,我使用了 AutoMapper,它被证明是友好而强大的。

以下是 AutoMapper 的描述。

AutoMapper 使用流畅的配置 API 来定义对象-对象映射策略。AutoMapper 使用基于约定的匹配算法来匹配源值和目标值。目前,AutoMapper 专注于模型投影场景,将复杂对象模型展平为 DTO 和其他简单对象,这些对象的结构更适合序列化、通信、消息传递,或者简单地作为域和应用程序层之间的反腐败层。

在开始映射之前,我们必须先配置 AutoMapper。我们通过向 Global.asax.cs 类添加一些代码来实现此目的。

	protected void Application_Start()
	{
		...
		//AutoMapper settings
		Mapper.CreateMap>ExamDef, ExamDefDto>()
			.ForMember(e => e.Questions, options => options.MapFrom(e => e.QuestionDef));

		Mapper.CreateMap<QuestionDef, QuestionDefDto>()
			.ForMember(e => e.Alternatives, options => options.MapFrom(e => e.Alternative));
		Mapper.CreateMap<Alternative, AlternativeDto>();
		Mapper.CreateMap<ExamTake, ExamTakeDto>();
		Mapper.CreateMap<Answer, AnswerDto>();
		...
	}

上面的说明告诉 AutoMapper 哪些实体类映射到哪些 DTO 类。下面的代码展示了如何实际地将一个对象映射到另一个对象。

	var examDefDto = new ExamDefDto();
	Mapper.Map(examDef, examDefDto);

	return examDefDto;

最终思考

就是这样!希望您喜欢这篇文章,和我一样。请在下方评论,任何建议、抱怨和想法都将受到欢迎。

历史

  • 2011-05-30:初始版本。
  • 2011-06-01:添加了 YouTube 时间链接。
  • 2011-06-05:添加了多选题。
  • 2011-06-06:添加了考试结果。
© . All rights reserved.