经过前三节的学习和磨练,使用 Entity Framework 进行数据库的增改查,以及 ASP.NET MVC 框架和各类第三方类库的使用想必大家已经轻车熟路了。本节中,我们将改进列表页面——列表页面现在比较难看,也不支持搜索功能,用户体验非常不好,当 MyUser 中有许多记录时,现在的列表页面也没法分页,嗯,甚至也没法按某一列排序……为此,我们的目标是让 MyUser 的列表页可以按 Username 进行模糊搜索、支持分页,并能按照 Username、Birthday 排序,并且这些都要是无刷新的!
列表页面我们将使用 DataTables 这款 jQuery 库,它可以很方便的将一个普通的 HTML <table> 转换为支持排序、分页等功能的表格,也可以将 JSON 类型的数据以表格的形式展现到页面上!
本节最终完成的效果如下:
将普通表格转换为 DataTables
先不要急于求成,第一步让我们看看 DataTables 能实现的效果,先将列表页的普通 HTML 表格给转换成 DataTables 表格吧。因为 DataTables 是一款依赖于 jQuery 的类库,也就是说要想使用它必须编写 JavaScript 代码。相信你已经会如何往页面上添加编写 JS 代码的区域了,将它添加到 List.cshtml 的最后面。
这里为了方便起见为用于展示 MyUser 的 <table> 加上 id="MyUserTable",这样就能通过 jQuery 的 Id 选择器选中它了。DataTables 提供的方法是基于 jQuery 对象的实例的,也就是说通过选择器获取到 jQuery 对象后,就可以使用 DataTables 提供的方法了。
$("#MyUserTable").DataTable();
这还没完,由于我们是将一个现有的 HTML 表格转换为 DataTable 表格,所以需要对 DataTable 进行一些配置,将“由服务器端填充数据”设为 false,这些配置以对象的形式传递给 DataTable() 方法。
$("#MyUserTable").DataTable({ serverSide: false });
然后刷新页面,我们能看到简陋的表格成为了带分页、表头可以排序的 DataTable 表格(因为我们数据太少,看不到分页效果):
这里看到的分页、排序是基于客户端内存的,通过浏览器的开发人员工具可以看到,不论是排序还是翻页,都没有任何请求产生。这种基于客户端内存的分页、排序严重依赖于数据的数量,如果有上万行数据,JavaScript 处理起来会非常慢,浏览器甚至会假死;而通过服务器端进行分页、排序就不会有这样的问题,因为服务器端每次只会返回上万条数据中当前这一页所需的数十条数据。
DataTables 提供了两个方法用于创建 DataTable 表格,一个是 DataTable()方法,另一个是 dataTable() 方法,他们都定义在 jQuery 对象下。这两个方法最大的区别在于,DataTable() 返回的是 DataTable 对象,而 dataTable() 返回的是 jQuery 对象。我们更常用 DataTable() 方法,因为 DataTable 对象可以实现重新加载表格数据的功能。这两个方法返回值虽然不同,但作用是完全一样的,都是用来创建 DataTable 表格的。
使用 JSON 来填充表格
DataTables 相关的类
到现在为止,表格的内容是通过 C# 代码以循环的方式生成到页面上的,一方面这样不便于做无刷新的分页和查询,另一方面当数据量较大时容易造成页面加载缓慢。所以我们要将表格改为使用 JSON 来填充。JSON 是一种数据格式,如果没有见过它长什么样那么后面的例子中会见到。JSON 常被用来做通信时的数据结构,它的内容是纯文本,可以轻松的转换为 JavaScript 对象。
说到底,就是我们需要新增一个 Action,这个 Action 被 DataTables 调用并返回 JSON 格式的 MyUser 数据!同时为了下一步增加搜索功能,这个 Action 只有当 POST 时才会被调用。一般它被命名为XXXList,用在这里就是 MyUserList()。
[HttpPost] public ActionResult MyUserList() { return Json(); }
接下来要到 Service 中添加一个方法,它能返回所有的 MyUser,并且,需要支持分页。要想支持分页,需要两样必不可少的前提:一是能从传入参数中获取到当前是第几页、每页显示多少条数据、按哪一列排序;二是能返回一个包含结果集(真正的数据内容)、当前第几页、每页需要显示多少条(因为 DataTables 也需要知道这些)。对于第二个前提,我们封装好了 PagedList<T> 类可以使用,它是 IPagedList<T> 的实现;而第一个前提只需要在传入的参数列表中加上这些参数即可。
public IPagedList<MyUser> GetList(int pageIndex = 0, int pageSize = 2147483647, string sortExpression = "") { var query = _myUserRepository.Table; return new PagedList<MyUser>(query, pageIndex, pageSize, sortExpression); }
参数列表中有些参数后面带了 = 等号,这样的参数叫做可选参数,如果调用该方法的时候没有传递这个参数的值,那么这个参数的值将会是等号后的值。这三个参数分别是当前页数、每页显示多少条、按哪列排序。(这里的可选参数其实没有什么太大作用,不用多想)
因为需要对所有的 MyUser 数据进行分页和排序,所以要使用仓储类获取 MyUser 表的所有数据。PagedList<T> 内部会自动帮我们分页和排序,这里切记不要将 _myUserRepository.Table 的结果转换为 List。PagedList<T> 还要求传入当前第几页、每页显示多少条数据以及排序列,按照参数列表的要求提供给它即可。
虽然将 List 传入 PagedList<T> 并不能编译通过,不过我还是想说一下仓储类 Table 属性的数据类型 IQueryable<T>。我们在第一节中也提到了这个类型,当时是因为我们更习惯用 List<T> 来承载数据,并且方法返回值为 List<T>,所以我们将 IQueryable<T> 通过 ToList() 方法转换为了 List<T>。实际上从类型名就能看出,List<T> 只是一个列表,而 IQueryable<T> 是一个可查询的集合。这个下文会详细说明,只要记住:IQueryable<T> 是可查询的!
之后回到 MyUserController 中继续编写 MyUserList Action,在 return 之前调用刚才写好的 GetList() 方法。现在问题来了,这个 Action 并不知道当前第几页等信息……其实 DataTables 会在请求这个 Action 时自动带上那些信息,我们也贴心的封装好了 PageInfo 类,它能自动接收 DataTables 发回来的各种信息(当前为第几页、每页显示多少天数据、按哪一列排序…)。
另外在返回 JSON 之前,需要将所有信息存入 DataTable<T> 对象,这个对象序列化为 JSON 之后直接是 DataTables.js 所识别的格式。这四个属性需要为 DataTable<T> 赋值:
- Draw: 绘制次数,没有什么用,用原有值+1即可
- RecordsTotal、RecordsFiltered:没有特殊情况均设为返回数据的总条目数,在这里就是 GetList() 方法返回的数据的总数
- Data: 用于填充表格的数据,这里就是 GetList() 方法返回的数据并映射为 IList<ViewModelType> 后的内容
[HttpPost] public ActionResult MyUserList(PageInfo pageInfo) { var myUserList = _myUserService.GetList(pageInfo.PageIndex, pageInfo.PageSize, pageInfo.sortExpression); var result = new DataTable<MyUserModel> { Draw = pageInfo.Draw + 1, RecordsTotal = myUserList.TotalCount, RecordsFiltered = myUserList.TotalCount, Data = myUserList.MapTo<IList<MyUser>, IList<MyUserModel>>() }; return Json(new PlainJsonResponse(result)); }
请千万记得要将实体类映射为 View Model!注意看为 Data 属性赋值的地方,IPagedList<MyUser> 类型的 myUserList 直接被当作 IList<MyUser>类型进行映射了,因为 IPagedList<T> 是从 IList<T> 派生的,所以这样没有任何问题。
生成 JSON
ASP.NET MVC 提供了 Json() 方法可以很容易创建 JSON 格式的内容。在我们项目中,提供了两个类来包装 JSON 返回值:JsonResponse 和 PlainJsonResponse:
- JsonResponse: 标准 JSON 返回对象,除了返回给定的内容 content 外,还返回了 status 消息状态、message 附加消息。手工创建的 Ajax 请求使用该类型
- PlainJsonResponse: 纯净的 JSON 返回对象,不做任何包装。作为 DataTable 的数据源、Kendo 控件的数据源时,使用该类型
当选用 JsonResponse 时,除了可以返回对象外,还可以添加状态和描述,或者不添加返回内容,直接将 status 为 error 返回给前台,能很容易的标识出某个请求的处理结果。而 PlainJsonResponse 它不做任何包装,适用于作为第三方类库的数据源。这两种类型在序列化为 JSON 后,JSON 属性的首字母都会变为小写。
我们来看看当使用 JsonResponse 时,赋值和最终生成的 JSON 都是什么样的:
return Json(new JsonResponse(JsonResponseStatus.error, "找不到该记录"), JsonRequestBehavior.AllowGet);
这行代码可以生成一段 JSON,返回包含内容为“找不到该记录”的消息。Json() 方法的第二个参数为 JSON 请求的行为,如果不传该参数那么方法只允许 POST 请求,当 GET 请求发来时,调用方会收到一个错误
JsonRequest GetNotAllowed
通过给 Json() 方法传递 JsonRequestBehavior.AllowGet (该枚举的意思为:允许GET)参数,即可正确响应 GET 请求。下面是返回的 JSON,如果以前没见过那请记住 JSON 其实长这样:
{"status":"error","message":"找不到该记录"}
这段 JSON 包括一个大括号,内容由 "属性名": "属性值" 的形式组成,当然,属性值可以是字符串,也可以是另一个类(也就是一对大括号)、一个数组(一对中括号)等 JavaScript 基本类型的内容。
前文提到过 JSON 能很容易的转换为 JavaScript 对象,有多容易呢,容易到不用写一行代码,这段 JSON 就能变成 JavaScript 的对象。让我们打开浏览器(这里以 Chrome 为例)的 F12 工具试一试,切换到 Console 控制台,在这里可以执行 JavaScript 代码,将 JSON 粘贴进去然后按下回车键
Json 变为了 Javascript 对象!这也是为什么我们更倾向于使用 JSON 作为 Ajax 请求的响应数据类型的原因。
让 DataTables 发起 Ajax 请求
后台代码的改造已经完毕,接下来需要修改 View 中的代码。因为数据源改为了 JSON,那么 View 中 C# 代码 foreach 生成 <tr> 的部分就可以完全删除了,但 <thead> 表头部分需要保留。
下面的代码展示了修改后的 <table> 和 DataTables 有关的 js 代码,并演示了如何设置 DataTables 的数据源、列的顺序:
<table class="table table-hover" id="MyUserTable"> <thead> <tr> <th>Id</th> <th>用户名</th> <th>性别</th> <th>生日</th> <th>上次登陆时间</th> <th>操作</th> </tr> </thead> </table> @section scripts{ <script> $("#MyUserTable").DataTable({ ajax: { url: "@Url.Action("MyUserList")", type: "POST" }, columns: [ { data: "id" }, { data: "username" }, { data: "gender" }, { data: "birthday" }, { data: "lastLoginDate" }, { data: "id" } ] }); </script> }
DataTable() 方法中的 ajax 属性用于配置 AJAX 请求的地址和方式;columns 定义了那一列需要显示什么内容。按照上面的配置,第一列是 Id、第二列为 Username …… 这些列的名字是和 View Model(DataTable<View Model Type> 中的 View Model)的属性名一致的,只不过首字母变为了小写。
保存后到浏览器中刷新页面,表格并没有如期显示,而是弹出了一个对话框,最关键的信息是对话框中描述的“Ajax error”,说明在处理 Ajax 响应时出现了问题。之前大家成功解决过在网页中报错(由红色标题和黄色的错误堆栈组成的错误页,应该还有映像吧?),与那时不同,我们现在并不能直接看到错误原因。下面介绍几种方法可以快速定位故障原因。
确认 Ajax 请求中后台错误的原因
通过 F12 工具
首先让我们判断一下是否是后台报错。打开浏览器 F12 工具,切换到 Network 界面,这里可以看到当前页面所有的请求。
如果内容很多可以点击“过滤”栏上的“XHR”,因为我们这里是 Ajax 请求,XHR 就是 XML HttpRequest,Ajax 就是靠它发送的。过滤后可以看到一行红色的记录(如果什么也没有,请确认左上角红色的“记录”按钮是否点亮,点亮后再次刷新 List 页面),注意这条记录的 Status 为 500,也就是“内部错误”的意思,500 是 HTTP 的状态码,用来表示响应的状态。通过这行记录我们可以确定是后台报错了。
点击这条记录,右侧会弹出一个窗口,选择窗口中的“Preview”即可预览服务器发回的内容,又能见到我们熟悉的错误页面了~原来又是这个老问题——没有将防伪标记发给后台。别忘了 MyUserList() Action 需要 POST 请求!
先介绍另一种排查问题的方式我们再回头添加防伪标记。
通过日志
如果是后台报错,最简单的办法就是查看日志了,程序中的一切未捕获的异常都会被记录到日志中(日志除了包含错误日志还有其它的日志内容)。日志位于 Web 项目所在目录的 Log 文件夹中,我们采用的日志组件为 Log4net。进入 Log 文件夹后可能会有很多文件,没关系,将它们全部删除然后重新访问一下 List 页面。这时会生成 trace.log 和 error.log 两个文件。我们用文本编辑工具(比如记事本)打开 trace.log:
日志中记录了记录日志的时间、日志类型(ERROR 表示错误),如果是错误日志还包含错误描述和堆栈信息。通过错误描述就能很快锁定问题了!
查看请求
找到了错误原因就到 View 上去解决吧,到 <table> 的上方添加防伪标记。不过此时在请求中还是没有防伪标记的,使用 F12 工具可以很容易的看出来:打开 F12 工具 - Network,选中出错的请求,在弹出的窗口中选择“Headers”也就是头信息。前几节介绍过,要想让后台收到数据,必须通过表单来传递,在 Headers 界面的最下方有所有表单的内容(Form Data),防伪标记的名字叫做RequestVerificationToken,在这里显然没有。
DataTables 在发起 Ajax 请求时可以设置将哪些值传递给服务器:
$("#MyUserTable").DataTable({ ajax: { url: "@Url.Action("MyUserList")", type: "POST", // 添加 data 属性 data: function (d) { // 对 d 参数进行操作 addAntiForgeryToken(d); } }, // 后略
DataTables 会自动指定 data 方法,并将发送给服务器的数据存放到 d 变量中,我们直接在 d 参数上操作即可。现在刷新页面,列表页面就能正确呈现了。
自定义列内容
现在表格中有几列不太正常,性别、生日和操作列都没有正确显示,让我们逐一击破。
先来看看“操作”列,原来这里显示的是一个 HTML 按钮,我们通过 DataTables 的自定义列功能让它变为按钮。在初始化 DataTables 时新增 columDefs 属性并配置如下:
columnDefs: [ { // 目标列数,从 0 开始数 targets: [5], // 这里返回的内容将作为这一列的内容 // data: 该列的数据(这里为 id,请看 columns 的第六个值) // full: 通过这个属性可以获取到整行数据,每一列的数据通过属性的方式访问,比如 full["id"] 可以获取到一行数据的 id render: function (data, type, full) { // 构造 URL,注意末尾的问号 var url = "@Url.Action("Edit")?"; // 参数,Edit 页面需要一个名为 id 的参数 var param = $.param({ id: data }); // 构造 HTML 按钮(实际上是个超链接,通过 Bootstrap 的样式显示为按钮) return '<a href="' + url + param + '" class="btn btn-xs btn-default" ><span class="fa fa-pencil"></span> @T("Edit")</a>'; } } ]
刷新页面,发现编辑按钮回来了,并且也能正确跳转。
接下来处理生日列,同样的方法,我们是用 Kendo 提供的一个帮助方法来转换时间,以下代码为 render 部分:
render: function (data, type, full) { // 将日期转换为 js 的 datetime 类型 var date = kendo.parseDate(data); // 再将 datetime 按指定格式转换为字符串 return kendo.toString(date, "yyyy年MM月dd日") }
现在日期也能正确的显示了,“性别”列也大同小异,就不多赘述了。
设定和取消排序
现在“操作列”居然是可以排序的,这显然不太合理。要想取消某一列的排序,只需要在 columnDefs 的那一列的设置中,新增 orderable: false 参数:
// 前略.. columnDefs: [ { targets: [5], // orderable 属性与 targets、render 属性同级 orderable: false, render: function (data, type, full) { var url = "@Url.Action("Edit")?"; var param = $.param({ id: data }); return '<a href="' + url + param +'" class="btn btn-xs btn-default" ><span class="fa fa-pencil"></span> @T("Edit")</a>'; } }] // 后略..
如果这一列没有自定义内容,要想取消排序也很容易,用同样的方式修改 columns 的内容就可以了,以“性别”列为例:
// 前略.. columns: [ { data: "id" }, { data: "username" }, // 设置 orderable 属性 { data: "gender", orderable: false }, { data: "birthday" }, { data: "lastLoginDate" }, { data: "id" } ] // 后略..
默认情况下,DataTables 会按第一列来排序,在我们的表格中就是 Id,要想修改默认排序列可以通过设置 order 属性来实现,order 属性与 ajax、columns属性同级。这里修改为按 Username 升序排列:
$("#MyUserTable").DataTable({ ajax: { url: "@Url.Action("MyUserList")", type: "POST", data: function (d) { addAntiForgeryToken(d); } }, order: [1, "asc"], columns: [ // 后略..
隐藏列
表格中的 Id 其实不是必须的,显示出来也没有人关注,我们将它隐藏掉。和上面设置 orderable 属性一样,可以在列声明和自定义列两个位置,配置 visible: false 属性就可以隐藏某一列。
实现搜索功能
这里以按 Username 进行模糊查询为例,实现搜索功能。需要美化一下 View,加上 Username 的文本框。因为 View 上新增了 Username,还需要到 MyUserListModel 这个 View Model 中加上 Username 属性。根据 HTML 推荐的做法,文本框(表单元素)应该放在表单之中(之前添加的隐藏域也应该放在表单之中),所以还需要添加一个 <form>。修改好的 View(不含 js)如下:
<div class="box box-solid"> <div class="box-header with-border"> @using (Html.BeginForm()) { @Html.AntiForgeryToken() <div class="row"> <div class="col-md-3 col-sm-5 col-xs-12"> <div class="form-group"> @Html.LabelFor(m => m.Username) @Html.TextBoxFor(m => m.Username, new { @class = "form-control", placeholder = T("Username").Text }) </div> </div> <div class="col-md-1 col-sm-1 col-xs-1"> <div class="col-md-1 col-sm-2 col-xs-12"> <div class="form-group"> @HiddenLabel() <button id="Search" type="submit" class="btn btn-default"><i class="fa fa-search"></i> @T("Search")</button> </div> </div> </div> </div> } <div class="box-body"> <div class="mailbox-controls no-padding"> <div class="pull-right"> <a href="@Url.Action("Add")" class="btn btn-primary"><i class="fa fa-plus"></i> @T("Add")</a> </div> </div> </div> <table class="table table-hover" id="MyUserTable"> <thead> <tr> <th>Id</th> <th>用户名</th> <th>性别</th> <th>生日</th> <th>上次登陆时间</th> <th>操作</th> </tr> </thead> </table> </div> </div>
使用 LINQ 追加过滤条件
让我们先把后台代码完成。要想按照 Username 进行查询,肯定需要 Service 支持。获取列表数据我们使用的是 GetList() 方法,现在在他的参数列表的最开头添加上 string 类型的 username 参数:
public IPagedList<MyUser> GetList(string username, int pageIndex = 0, int pageSize = 2147483647, string sortExpression = "") { /* ... */ }
接着将 username 查询条件应用到搜索中,这就轮到 IQueryable<T> 出场了。之前介绍过 IQueryable<T> 是可查询的,也就是说我可以将查询条件追加在它上面。通过 Lambda 表达式很容易做到:
public IPagedList<MyUser> GetList(string username, int pageIndex = 0, int pageSize = 2147483647, string sortExpression = "") { var query = _myUserRepository.Table; if (username != null) { query = query.Where(myUser => myUser.Username.Contains(username)); } return new PagedList<MyUser>(query, pageIndex, pageSize, sortExpression); }
通过 Where() 方法指定 MyUser 的 Username 属性,必须包含 username 参数。除了 Where() 方法,还有很多有用的方法,例如获取第一条数据 First()、排序 OrderBy()等,这是 C# 的LINQ(语言集成查询)特性。
必须在这里强调,LINQ 不光能用于 IQueryable<T>,所有的 IEnumerable<T> 都可以使用(LINQ 实际上是对 IEnumerable<T> 的一堆扩展方法)。最大的区别在于,使用 Entity Framework 时,对 IQueryable<T> 的操作最终会生成 SQL 语句。当我们对 IQueryable<T> 的值进行访问时,例如调用 ToList()、First(), IQueryable<T> 才会生成 SQL 语句并到数据库中执行,如果不访问它的内容,即便进行了 Where() 操作,也不会发生任何事情。
Where() 方法最终会解析为 SQL 的 WHERE 命令,而 Username.Contains(username) 方法会被解析为 username IN Username。
接下来到调用这个 Service 方法的 Action 中,先为 Action 的参数列表添加上 string username,然后将 username 传递给 GetList() 方法。这意味着请求 MyUserList 时需要添加上 username 参数。
回到 View 来修改 DataTables 的 ajax 部分,一切顺理成章。通过 Id 选择器选中页面上的 Username 文本框,并在 ajax 属性 data() 方法的 d 参数赋值:
$("#MyUserTable").DataTable({ ajax: { url: "@Url.Action("MyUserList")", type: "POST", data: function (d) { // 通过 Id 选择器获取 Username 文本框的值 d.username = $("#Username").val(); addAntiForgeryToken(d); } }, // 后略 ..
添加 jQuery ready 方法
在做下一步操作之前,先在现有的 js 代码上方加上这一部分代码,并将 DataTable 初始化代码移动到里面:
$(function () { // 将 DataTable 初始化代码移动到这里面 });
这段代码表示当页面载入完成后立即执行 function() 中的代码,是 jQuery 提供的 ready() 方法的简便写法。至于 jQuery ready() 方法与 window.onload 事件的区别可以参考这篇文章。我们项目中要求所有 js 中的事件绑定都放到 ready() 方法中。
接着为“搜索”按钮添加一个点击事件。注意,添加的按钮类型必须是 submit 的(参见上面美化 HTML 的部分)。同时这个搜索按钮 Id 为 Search。
下面是重头戏了,将 DataTable() 方法的返回值赋值给一个变量,这里称之为 grid:
$(function () { var grid = $("#MyUserTable").DataTable({ ajax: { // 后略 ..
在 ready() 方法的末尾添加搜索按钮的点击事件,实际上只需要调用一下 DataTable 的重绘方法即可。这也是为什么初始化 DataTable 时我们使用的是大写的版本(DataTable()),它返回的是 DataTable 对象,可移执行 DataTable 提供的方法:
$("#Search").click(function () { grid.page("first").draw(); return false; });
方法最后一行的 return false 是用于阻止表单提交的,搜索按钮是 submit 类型,当点击时会导致表单提交。为什么不直接将搜索作为普通按钮呢?因为在表单的文本框中按下回车键也会触发表单提交,这里为了当按下回车键时也能触发搜索。
总结
- DataTables 类库可以将 HTML 普通表格转换为带有复杂功能的表格,也可以用 JSON 来填充表格数据
- 初始化 DataTables 时有两种方法,它们执行结果相同但是返回的内容不同,DataTable() 方法返回 DataTable 对象,dataTable() 方法返回 jQuery 对象
- 项目中通过 JsonResponse 和 PlainJsonResponse 类返回 JSON,前者带有 message 等描述性属性,后者是纯净的没有任何包装,返回的 JSON 中,属性的首字母都是小写的
- 分页涉及到 PagedList<T> 和 DataTable<T>,前者用作 Service 层的返回,后者用来返回给 DataTable
- 通过浏览器 F12 工具查看请求、执行 javascript
- 阅读日志文件分析错误原因
- 为 Data Table 做各种自定义操作:隐藏列、设置和取消排序、修改列中的内容等等
- LINQ 查询
本教程到此就告一段落了,大致将项目中可能用到的技术要点都罗列了一遍,希望能对大家有帮助。