在一个空ASP.NET Web项目上创建一个ASP.NET Web API 2.0应用

时间:2022-04-21
本文章向大家介绍在一个空ASP.NET Web项目上创建一个ASP.NET Web API 2.0应用,主要内容包括一、构建解决方案、二、定义Web API、三、以Web Host方式寄宿Web API、四、 以Self Host方式寄宿Web API、五、利用HttpClient调用Web API、六、创建一个“联系人管理器”应用、ViewModel、HTML、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

由于ASP.NET Web API具有与ASP.NET MVC类似的编程方式,再加上目前市面上专门介绍ASP.NET Web API 的书籍少之又少(我们看到的相关内容往往是某本介绍ASP.NET MVC的书籍“额外奉送”的),以至于很多人会觉得ASP.NET Web API仅仅是ASP.NET MVC的一个小小的扩展而已,自身并没有太多“大书特书”的地方。而真实的情况下是:ASP.NET Web API不仅仅具有一个完全独立的消息处理管道,而且这个管道比为ASP.NET MVC设计的管道更为复杂,功能也更为强大。虽然被命名为“ASP.NET Web API”,但是这个消息处理管道却是独立于ASP.NET平台的,这也是为什么ASP.NET Web API支持多种寄宿方式的根源所在。[本文已经同步到《How ASP.NET Web API Works?》]

为了让读者朋友们先对ASP.NET Web API具有一个感性认识,接下来我们以实例演示的形式创建一个简单的ASP.NET Web API应用。这是一个用于实现“联系人管理”的单页Web应用,我们以Ajax的形式调用Web API实现针对联系人的CRUD操作。[源代码从这里下载]

目录 构建解决方案 定义Web API 以Web Host方式寄宿Web API 以Self Host方式寄宿Web API 利用HttpClient调用Web API 创建一个“联系人管理器”应用

一、构建解决方案

Visual Studio为我们提供了专门用于创建ASP.NET Web API应用的项目模板,借助于此项目模板提供的向导,我们可以“一键式”创建一个完整的ASP.NET Web API项目。在项目创建过程中,Visual Studio会自动为我们添加必要的程序集引用和配置,甚至会为我们自动生成相关的代码,总之一句话:这种通过向导生成的项目在被创建之后其本身就是一个可执行的应用。

对于IDE提供的这种旨在提高生产效率的自动化机制,我个人自然是推崇的,但是我更推荐读者朋友们去了解一下这些自动化机制具体为我们做了什么?做这些的目的何在?哪些是必需的,哪些又是不必要的?正是基于这样的目的,在接下来演示的实例中,我们将摒弃Visual Studio为我们提供的向导,完全在创建的空项目中编写我们的程序。这些空项目体现在如右图所示的解决方案结构中。

如右图所示,整个解决方案一共包含6个项目,上面介绍的作为“联系人管理器”的单页Web应用对应着项目WebApp,下面的列表给出了包括它在内的所有项目的类型和扮演的角色。

  • ·Common:这是一个空的类库项目,仅仅定义了表示联系人的数据类型而已。之所以将数据类型定义在独立的项目中,只要是考虑到它会被多个项目(WebApi和ConsoleApp)所使用。
  • WebApi:这是一个空的类库项目,表现为HttpController类型的Web API就定义在此项目中,它具有对Common的项目引用。
  • WebHost:这是一个空的ASP.NET Web应用,它实现了针对ASP.NET Web API的Web Host寄宿,该项目具有针对WebApi的项目引用。
  • SelfHost:这是一个空的控制台应用,旨在模拟ASP.NET Web API的Self Host寄宿模式,它同样具有针对WebApi的项目引用。
  • WebApp:这是一个空的ASP.NET Web应用,代表“联系人管理器”的网页就存在于该项目之中,至于具体的联系人管理功能,自然通过以Ajax的形式调用Web API来完成。
  • ConsoleApp:这是一个空的控制台应用,我们用它来模拟如何利用客户端代理来实现对Web API的远程调用,它具有针对Common的项目引用。

二、定义Web API

在正式定义Web API之前,我们需要在项目Common中定义代表联系人的数据类型Contact。简单起见,我们仅仅为Contact定义了如下几个简单的属性,它们分别代表联系人的ID、姓名、联系电话、电子邮箱和联系地址。

   1: public class Contact
   2: {
   3:     public string Id { get; set; }
   4:     public string Name { get; set; }
   5:     public string PhoneNo { get; set; }
   6:     public string EmailAddress { get; set; }
   7:     public string Address { get; set; }
   8: }

表现为HttpController的Web API定义在WebApi项目之中,我们一般将ApiController作为继承的基类。ApiController定义在“System.Web.Http.dll”程序集中,我们可以在目录“%ProgramFiles%Microsoft ASP.NETASP.NET Web Stack 5Packages”中找到这个程序集。具体来说,该程序集存在于子目录“Microsoft.AspNet.WebApi.Core.5.0.0libnet45”中。

Web API体现在如下所示的ContactsController类型中。在该类型中,我们定义了Get、Post、Put和Delete这4个Action方法,它们分别实现了针对联系人的查询、添加、修改和删除操作。Action方法Get具有一个表示联系人ID的可缺省参数,如果该参数存在则返回对应的联系人,否则返回整个联系人列表。由于ASP.NET Web API默认实现了Action方法与HTTP方法的映射,所以方法名也体现了它们各自所能处理请求必须采用的HTTP方法。

   1: public class ContactsController: ApiController
   2: {
   3:     static List<Contact> contacts;
   4: static int counter = 2;
   5:  
   6:     static ContactsController()
   7:     {
   8:         contacts = new List<Contact>();
   9:         contacts.Add(new Contact { Id = "001", Name = "张三", 
  10:             PhoneNo = "0512-12345678", EmailAddress = "zhangsan@gmail.com", 
  11:             Address = "江苏省苏州市星湖街328号" });
  12:         contacts.Add(new Contact { Id = "002", Name = "李四", 
  13:             PhoneNo = "0512-23456789", EmailAddress = "lisi@gmail.com", 
  14:             Address = "江苏省苏州市金鸡湖大道328号" });
  15: }
  16:  
  17:     public IEnumerable<Contact> Get(string id = null)
  18:     {
  19:         return from contact in contacts
  20:             where contact.Id == id || string.IsNullOrEmpty(id)
  21:             select contact;
  22: }
  23:  
  24:     public void Post(Contact contact)
  25:     {
  26:         Interlocked.Increment(ref counter);
  27:         contact.Id = counter.ToString("D3");
  28:         contacts.Add(contact);
  29:     }
  30:  
  31:     public void Put(Contact contact)
  32:     {
  33:         contacts.Remove(contacts.First(c => c.Id == contact.Id));
  34:         contacts.Add(contact);
  35:     }
  36:  
  37:     public void Delete(string id)
  38:     {
  39:         contacts.Remove(contacts.First(c => c.Id == id));        
  40:     }
  41: }

简单起见,我们利用一个静态字段(contacts)表示存储的联系人列表。当ContactsController类型被加载的时候,我们添加了两个ID分别为“001”和“002”的联系人记录。至于实现联系人CRUD操作的Action方法,我们也省略了必要的验证,对于本书后续的演示的实例,我们基本上也会采用这种“简写”的风格。

三、以Web Host方式寄宿Web API

我们在上面已经提到过了,虽然被命名为ASP.NET Web API,但是其核心的消息处理管道却是独立于ASP.NET平台的,所以我们可以对相同的Web API实施不同的寄宿方式。寄宿的本质就是利用一个具体的应用程序为Web API提供一个运行的环境,并最终解决“请求的接收和响应的回复”问题。作为寄宿的一种主要形式,Web Host就是创建一个ASP.NET Web应用作为Web API的宿主。

采用Web Host方式寄宿Web API的宿主程序WebHost是一个空的ASP.NET应用。除了让它引用定义ContactsController的WebApi项目之外,我们还需要为其添加如下这些必需的程序集引用。除了程序集“System.Net.Http.dll”(它属于.NET Framework 原生的程序集)之外,其余3个均可以在目录“%ProgramFiles%Microsoft ASP.NETASP.NET Web Stack 5Packages”中找到。

  • System.Web.Http.dll( Microsoft.AspNet.WebApi.Core.5.0.0libnet45)
  • System.Net.Formatting.Http.dll(Microsoft.AspNet.WebApi.Client.5.0.0libnet45)
  • System.Web.Http.WebHost.dll(Microsoft.AspNet.WebApi.WebHost.5.0.0libnet45)
  • System.Net.Http.dll

与ASP.NET MVC一样,如果采用Web Host的方式来寄宿Web API,ASP.NET自身的路由系统会成为接收请求的第一道屏障。在将请求递交给ASP.NET Web API自己的消息处理管道之前,路由系统会解析出当前请求访问的目标HttpController和Action的名称。我们需要做的就是根据需求注册相应的路由,这也是采用Web Host寄宿方式所需的唯一操作。

我们在WebHost项目中添加一个Global.asax文件,并按照如下的形式在其Application_Start方法中注册了一个模板为“api/{controller}/{id}”的路由。此模板由3部分组成,静态文本“api”表示其前缀,后面是两个路由参数。前者({controller})表示目标HttpController的名称,后者({id})可以映射为目标Action方法的同名参数(比如ContractsController的Get方法的参数id),这是一个可以缺省的路由参数(RouteParameter.Optional)。

   1: public class Global : System.Web.HttpApplication
   2: {
   3:     protected void Application_Start(object sender, EventArgs e)
   4:     {
   5:         GlobalConfiguration.Configuration.Routes.MapHttpRoute(
   6:             Name            : "DefaultApi",
   7:             routeTemplate   : "api/{controller}/{id}",
   8:             defaults        : new { id = RouteParameter.Optional });
   9:     }
  10: }

如上面的代码片断所示,路由注册是通过调用代表全局路由表的HttpRouteCollection对象的扩展方法MapHttpRoute来完成的。GlobalConfiguration的静态属性Configuration返回一个代表当前配置的HttpConfiguration对象,全局路由表就注册在它的Routes属性上。

如果你了解ASP.NET MVC的路由注册,可能觉得奇怪:注册路由的模板中并没有表示目标Action的路由参数,ASP .NET Web API如何根据请求确定哪个Action方法应该被调用呢?答案其实很简单:它能根据请求采用HTTP方法来确定目标Action方法。当然,在注册路由模板中提供代表Action名称的路由参数({action})也是支持的。

在默认情况下,通过Visual Studio(VS 2012或者VS 2013,本书采用的是后者)创建的Web应用总是使用IIS Express作为服务器,它会自动为我们指定一个可用的端口号。为了更好地模拟真实发布环境,同时避免“跨域资源共享”带来的困扰,我们采用本地IIS作为服务器。如下图所示,WebHost项目在IIS中映射的Web应用采用的URL为“http://localhost/webhost”。

实际上到此为止,Web API的Web Host寄宿工作就已经完成,我们可以利用浏览器来调用寄宿的Web API来判断寄宿工作是否成功。由于浏览器在默认情况下访问我们在地址栏中输入的地址总是采用HTTP-GET请求,所以我们只能利用它来调用支持HTTP-GET的Action方法,即定义在ContactsController中的Get方法。

根据我们注册的路由,如果我们访问目标地址“http://localhost/webhost/api/contacts”可以获得所有联系人列表;如果目标地址为“http://localhost/webhost/api/contacts/001”,则可以得到ID为“001”的联系人信息,右图证实了这一点。

从右图可以看到,我们采用的浏览器为Chrome,获取的联系人列表总是表示为XML,这是为什么呢?在前面介绍REST的时候,我们曾经提及一种旨在识别客户端期望的资源表示形式并被称为“内容协商”的机制,它可以根据请求携带的相关信息来判断客户端所期望的响应资源表现形式。

对于ASP.NET Web API来说,它会优先利用请求报头“Accept”携带的媒体类型来确定响应内容采用的表现形式。如下所示的是Chrome访问“http://localhost/webhost/api/contacts/001”发送请求的内容,它之所以会得到以XML表示的响应是因为“Accept”报头指定的媒体类型列表中只有“application/xml”被ASP.NET Web API支持。如果我们采用IE,请求的“Accept”报头将携带不同的媒体类型列表,我们实际上会得到以JSON格式表示的响应结果。

   1: GET http://localhost/webhost/api/contacts/001 HTTP/1.1
   2: Host: localhost
   3: Connection: keep-alive
   4: Cache-Control: max-age=0
   5: Accept: text/html,application/xhtml+xml,application/xml ;q=0.9,image/webp,*/*;q=0.8
   6: User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36
   7: Accept-Encoding: gzip,deflate,sdch
   8: Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh-TW;q=0.4

为了进一步验证并演示ASP.NET Web API的内容协商机制,我们现在改用Fiddler来发送调用Web API的HTTP请求。如左图所示,我们利用Fiddler发送了一个针对目标地址“http://localhost/webhost/api/contacts/001”的HTTP-GET请求,并添加了一个值为“application/json”的“Accept”报头,请求发送之后确实得到了以JSON格式表示的联系人列表。

支持PUT和DELETE请求

在定义ContactsController的时候,我们严格按照RESTful Web API关于“使用标准的HTTP方法”的指导方针,分别采用GET、POST、PUT和DELETE作为获取、创建、修改和删除联系人的操作所支持的HTTP方法。但是IIS在默认情况下并不提供针对 PUT和DELETE请求的支持。

如右图所示,我们利用Fiddler发送了一个针对地址“http://localhost/webhost/api/contacts/001”的HTTP-DELETE请求,旨在删除ID为“001”的联系人。但是遗憾的是,我们得到了一个状态为“405,Method Not Allowed”的响应,意味着服务端并不支持HTTP-DELETE方法。

IIS拒绝PUT和DELETE请求是由默认注册的一个名为“WebDAVModule”的自定义HttpModule导致的。WebDAV的全称为“Web-based Distributed Authoring and Versioning”,它是一个在多用户之间辅助协同编辑和管理在线文档的HTTP扩展。该扩展使应用程序可以直接将文件写到 Web Server 上,同时支持文件的加锁和版本控制。

微软是推动WebDAV成为一个标准的主导力量,它自己利用自定义的HttpModule实现了IIS针对WebDAV的支持。但是这个默认注册(注册名称为“WebDAVModule”)会拒绝HTTP方法为PUT和DELETE的请求,如果我们的站点不需要提供针对WebDAV的支持,解决这个问题最为直接的方式就是利用如下的配置将注册的HttpModule移除。

   1: <configuration>
   2:   ...
   3:   <system.webServer>
   4:     <modules runAllManagedModulesForAllRequests="true">
   5:       <remove name="WebDAVModule" />
   6:     </modules>
   7:   </system.webServer>
   8: </configuration>

四、 以Self Host方式寄宿Web API

与WCF类似,寄宿Web API不一定需要IIS的支持,我们可以采用Self Host的方式使用任意类型的应用程序(控制台、Windows Forms应用、WPF应用甚至是Windows Service)作为宿主。对于我们演示的实例来说,项目SelfHost代表的控制台程序就是一个采用Self Host寄宿模式的宿主。

对于SelfHost这么一个空的控制台应用来说,除了需要添加针对WebApi的项目引用之外,还需要添加如下4个程序集引用。除了程序集“System.Net.Http.dll”(它属于.NET Framework 原生的程序集)之外,其余3个均可以在目录“%ProgramFiles%Microsoft ASP.NETASP.NET Web Stack 5Packages”中找到。

  • System.Web.Http.dll( Microsoft.AspNet.WebApi.Core.5.0.0libnet45)
  • System.Net.Formatting.Http.dll(Microsoft.AspNet.WebApi.Client.5.0.0libnet45)
  • System.Web.Http.SelfHost.dll(Microsoft.AspNet.WebApi.SelfHost.5.0.0libnet45)
  • System.Net.Http.dll

通过上面的介绍我们可以看到以Web Host的方式寄宿Web API需要做的唯一一件事情是路由注册。但是对于Self Host来说,除了必需的路由注册外,我们还需要完成额外的一件事情,即手工加载定义了HttpController类型的程序集。整个寄宿工作通过如下几行简单的代码就可以实现。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         Assembly.Load("WebApi, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
   6:             
   7:         HttpSelfHostConfiguration configuration = new HttpSelfHostConfiguration("http://localhost/selfhost");
   8:         using (HttpSelfHostServer httpServer = new HttpSelfHostServer(configuration))
   9:         {
  10:             httpServer.Configuration.Routes.MapHttpRoute(
  11:                 name            : "DefaultApi",
  12:                 routeTemplate   : "api/{controller}/{id}",
  13:                 defaults        : new { id = RouteParameter.Optional });
  14:  
  15:             httpServer.OpenAsync();
  16:             Console.Read();
  17:         }
  18:     }
  19: }

ASP.NET Web API的Self Host寄宿方式通过HttpSelfHostServer来完成。如上面的代码片断所示,在手工加载了定义ContactsController类型的程序集“WebApi.dll”之后,我们根据指定的基地址(“http://localhost/selfhost”),注册路由的URL模板将是以此作为基地址的相对地址)创建了一个HttpSelfHostConfiguration对象,HttpSelfHostServer由该对象创建。接下来,我们利用创建的HttpSelfHostConfiguration对象(对应着HttpSelfHostServer的Configuration属性)的Routes得到全局路由表,并调用扩展方法MapHttpRoute注册了与Web Host寄宿方式一样的路由。当我们调用OpenAsync方法成功开启HttpSelfHostServer之后,服务器开始监听来自网络的调用请求。

如果读者朋友们对WCF比较熟悉的话,应该清楚在进行WCF服务寄宿的时候我们必须指定寄宿服务的类型,但是对于ASP.NET Web API的寄宿来说,不论是Web Host还是Self Host,我们都无需指定HttpController的类型。换句话说,WCF服务寄宿是针对具体某个服务类型的,而ASP.NET Web API的寄宿则是批量进行的。

ASP.NET Web API的批量寄宿源自它对HttpController类型的智能解析,它会从“提供的”的程序集列表中解析出所有HttpController类型(所有实现了IHttpController接口的类型)。对于Web Host来说,它会利用BuildManager获得当前项目直接或者间接引用的程序集,但是对于Self Host来说,HttpController类型的解析在默认情况下只会针对加载到当前应用程序域中的程序集列表,这也是我们为何需要手工加载定义了ContactsController类型的程序集的原因所在。

如果现在运行这个作为宿主的控制台程序,我们依然可以对寄宿其中的Web API发起调用。同样采用浏览器作为测试工具,在分别访问目标地址“http://localhost/selfhost/api/contacts”和“http://localhost/selfhost/api/contacts/001”后,我们依然会得到上面的结果。

五、利用HttpClient调用Web API

对于一个.NET客户端程序,它可以利用HttpClient来进行Web API的调用。由于Web API的调用本质上就是一次普通的发送请求/接收响应的过程,所以HttpClient其实可以作为一般意义上发送HTTP请求的工具。在ConsoleApp代表的控制台应用中,我们利用HttpClient来调用以Self Host方式寄宿的Web API。

由于我们需要使用到代表联系人的数据类型Contact,所以需要为该项目添加针对Common的项目引用。HttpClient定义在程序集“System.Net.Http.dll”中,所以针对该程序集的引用也是必需的。除此之外,我们还需要添加针对程序集“System.Net.Formatting.Http.dll”的引用,因为序列化请求和反序列化响应的相关类型定义在此程序集中。

如下所示的是整个Web API调用程序的定义,我们利用HttpClient调用Web API实现了针对联系人的获取、添加、修改和删除。由于HttpClient提供的大部分方法都采用针对Task的异步编程形式,所以我们将所有的操作定义在一个标记为“async”的静态方法Process中,以便我们可以使用“await”关键字编写同步代码。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         Process();
   6:         Console.Read();
   7:     }
   8:  
   9:     private async static void Process()
  10:     {
  11:         //获取当前联系人列表
  12:         HttpClient httpClient = new HttpClient();
  13:         HttpResponseMessage response = await httpClient.GetAsync("http://localhost/selfhost/api/contacts");
  14:         IEnumerable<Contact> contacts = await response.Content.ReadAsAsync<IEnumerable<Contact>>();
  15:         Console.WriteLine("当前联系人列表:");
  16:         ListContacts(contacts);
  17:  
  18:         //添加新的联系人
  19:         Contact contact = new Contact { Name = "王五", PhoneNo = "0512-34567890", EmailAddress = "wangwu@gmail.com" };
  20:         await httpClient.PostAsJsonAsync<Contact>("http://localhost/selfhost/api/contacts", contact);
  21:         Console.WriteLine("添加新联系人“王五”:");
  22:         response = await httpClient.GetAsync("http://localhost/selfhost/api/contacts");
  23:         contacts = await response.Content.ReadAsAsync<IEnumerable<Contact>>();
  24:         ListContacts(contacts);
  25:  
  26:         //修改现有的某个联系人
  27:         response = await httpClient.GetAsync("http://localhost/selfhost/api/contacts/001");
  28:         contact = (await response.Content.ReadAsAsync<IEnumerable<Contact>>()).First();
  29:         contact.Name         = "赵六";
  30:         contact.EmailAddress     = "zhaoliu@gmail.com";
  31:         await httpClient.PutAsJsonAsync<Contact>("http://localhost/selfhost/api/contacts/001", contact);
  32:         Console.WriteLine("修改联系人“001”信息:");
  33:         response = await httpClient.GetAsync("http://localhost/selfhost/api/contacts");
  34:         contacts = await response.Content.ReadAsAsync<IEnumerable<Contact>>();
  35:         ListContacts(contacts);
  36:  
  37:         //删除现有的某个联系人
  38:         await httpClient.DeleteAsync("http://localhost/selfhost/api/contacts/002");
  39:         Console.WriteLine("删除联系人“002”:");
  40:         response = await httpClient.GetAsync("http://localhost/selfhost/api/contacts");
  41:         contacts = await response.Content.ReadAsAsync<IEnumerable<Contact>>();
  42:         ListContacts(contacts);
  43: }
  44:  
  45:     private static void ListContacts(IEnumerable<Contact> contacts)
  46:     {           
  47:         foreach (Contact contact in contacts)
  48:         {
  49:             Console.WriteLine("{0,-6}{1,-6}{2,-20}{3,-10}", contact.Id, contact.Name, contact.EmailAddress, contact.PhoneNo);
  50:         }
  51:         Console.WriteLine();
  52:     }
  53: }

如上面的代码片段所示,我们创建了一个HttpClient对象并调用其GetAsync方法向目标地址“http://localhost/selfhost/api/contacts”发送了一个GET请求,返回的对象HttpResponseMessage表示接收到的响应。该HttpResponseMessage对象的Content属性返回一个表示响应主体内容的HttpContent对象,我们调用其ReadAsAsync<T>方法读取响应主体内容并将其反序列化成一个Contact集合。我们将表示当前联系人列表的Contact集合输出在控制台上。

我们接下来调用HttpClient的PostAsJsonAsync<T>方法向目标地址“http://localhost/selfhost/api/contacts”发送一个POST请求以添加一个新的联系人。正如方法名称所体现的,作为参数的Contact对象将以JSON格式被写入请求的主体部分。请求被正常发送并接收到响应之后,我们会打印出当前联系人列表。

在此之后,我们向目标地址“http://localhost/selfhost/api/contacts/001”发送一个GET请求以获取ID为“001”的联系人。在修改了联系人的姓名(“赵六”)和电子邮箱(“zhaoliu@gmail.com”)之后,我们将其作为参数调用HttpClient的PutAsJsonAsync<T>方法,以此向目标地址“http://localhost/selfhost/api/contacts/001”发送一个PUT请求以更新对应联系人的相关信息。联系人信息是否正常更新同样通过输出当前所有联系人列表来证实。

我们最后调用HttpClient的DeleteAsync方法向地址“http://localhost/selfhost/api/contacts/002”发送一个DELETE请求以删除ID为“002”的联系人并通过输出当前所有联系人列表来证实删除参数是否成功完成。

我们在运行宿主程序SelfHost之后启动此ConsoleApp程序,会在控制台上得到下所示的输出结果,由此可以看出通过调用HttpClient的GetAsync、PostAsJsonAsync、PutAsJsonAsync和DeleteAsync方法帮助我们成功完成了针对联系人的获取、添加、修改和删除。

   1: 当前联系人列表:
   2: 001   张三    zhangsan@gmail.com  0512-12345678
   3: 002   李四    lisi@gmail.com      0512-23456789
   4:  
   5: 添加新联系人“王五”:
   6: 001   张三    zhangsan@gmail.com  0512-12345678
   7: 002   李四    lisi@gmail.com      0512-23456789
   8: 003   王五    wangwu@gmail.com    0512-34567890
   9:  
  10: 修改联系人“001”信息:
  11: 002   李四    lisi@gmail.com      0512-23456789
  12: 003   王五    wangwu@gmail.com    0512-34567890
  13: 001   赵六    zhaoliu@gmail.com   0512-12345678
  14:  
  15: 删除联系人“002”:
  16: 003   王五    wangwu@gmail.com    0512-34567890
  17: 001   赵六    zhaoliu@gmail.com   0512-12345678

六、创建一个“联系人管理器”应用

我们最后来创建一个叫做“联系人管理器”的Web应用。这是一个单网页应用,我们采用Ajax的请求的形式调用以Web Host模式寄宿的Web API实现针对联系人的CRUD操作。在正式介绍编程实现之前,我们不妨来看看该应用运行起来的效果。

如右图所示,当页面被加载之后,当前联系人列表会以表格的形式呈现出来。我们可以利用每条联系人记录右侧的“修改”和“删除”链接实现针对当前联系人的编辑和删除。除此之外,我们还可以点击左下方的“添加联系人”按钮添加一个新的联系人。

如果我们点击“删除”链接,当前联系人会直接被删除。如果我们点击了“修改”链接或者“添加联系人”按钮,被修改或者添加的联系人信息会显示在如左图所示的一个弹出的“模态”对话框中。在我们输入联系人相关资料后点击“保存”按钮,联系人会被成功修改或者添加。被修改的现有联系人信息或者被添加的联系人会立即体现在列表之中。

虽然这仅仅是一个简单的Web应用,但是我刻意使用了3个主流的Web前端开发框架,它们分别是jQuery、Bootstrap和KnockOut,这三个框架的使用体现在页面引用的CSS和JavaScript文件上。

   1: <!DOCTYPE html>
   2: <html xmlns="http://www.w3.org/1999/xhtml">
   3: <head>
   4:     <title>联系人管理器</title>
   5:     <link href="css/bootstrap.min.css" rel="stylesheet">
   6: </head>
   7: <body>
   8:     ...
   9:     <script src="Scripts/jquery-1.10.2.min.js"></script>
   1:  

   2:     <script src="Scripts/bootstrap.min.js"></script>

   2:     <script src="Scripts/knockout-3.0.0.js"></script>

   2:     <script src="Scripts/viewmodel.js"></script>

  10: </body>
  11: </html>

jQuery,这个“地球人都知道”的JavaScript框架,我们无须对它作任何介绍了。Bootstrap 是集 HTML、CSS 和 JavaScript 于一体,是由微博的先驱 Twitter 在2011年8月开源的整套前端解决方案,Web 开发人员利用它能够轻松搭建出具有清爽风格的界面以及实现良好的交互效果的Web应用。Bootstrap是ASP.NET MVC 5默认支持的框架,当我们利用Visual Stduio创建一个ASP.NET MVC项目时,项目目录下就包含了Bootstrap相关的CSS和JavaScript文件。

在本例中,我们主要利用jQuery来实现以Ajax方式调用Web API,同时它也是其他两个框架(Bootstrap和KnockOut)的基础框架。至于Bootstrap,我们则主要使用它的页面布局功能和它提供的CSS。除此之外,“编辑联系人”对话框就是利用Bootstrap提供的JavaScript组件实现的。

MVVM与Knockout

考虑到可能有人对Knockout(以下简称KO)这个JavaScript框架不太熟悉,在这里我们对它作一下概括性的介绍。KO是微软将应用于WPF/Silverlight的MVVM模式在Web上的尝试,这是一个非常有用的JavaScript框架。对于面向数据的Web应用来说,MVVM模式是一项不错的选择,它借助框架提供的“绑定”机制使我们无需过多关注UI(HTML)的细节,只需要操作绑定的数据源。MVVM最早被微软应用于WPF/SL的开发,所以针对Web的MVVM框架来说,Knockout(以下简称KO)无疑是“根正苗红”。

MVVM可以看成是MVC模式的一个变体,Controller被View Model取代,但两者具有不同的职能,三元素之间的交互也不相同。以通过KO实现的MVVM为例,其核心是“绑定”,我个人又将其分为“数据的绑定”和“行为的绑定”。所谓数据的绑定,就是将View Model定义的数据绑定到View中的UI元素(HTML元素)上,KO同时支持单向和双向绑定。行为绑定体现为事件注册,即View中UI元素的事件(比如某个<button>元素的click事件)与View Model定义的方法(function)进行绑定。

如右图所示,用户行为(比如某个用户点击了页面上的某个按钮)首先触发View的某个事件,与之绑定的定义在View Model中的EventHandler(View Model的某个方法成员)被自动执行。它可以执行Model,并修改自身维护的数据,如果View和View Model的数据绑定是双向的,用户在界面上输入的数据可以被View Model捕获,View Model对数据的更新可以自动反映在View上。这样的好处显而易见:我们在通过JavaScript定义UI处理逻辑的时候,无需关注View的细节(View上的HTML),只需要对自身的数据进行操作即可。

我们通过一个简单的例子来说明两种绑定在KO中的实现。假设我们需要设计如左图所示的“地址编辑器页面”,在页面加载的时候它会将默认的地址信息绑定到表示省、市、区和街道的文本框和显示完整地址信息的<span>元素上,当用户在文本框中输入新的值并点击“确认”按钮后,显示的完整地址会相应的变化。

我们可以利用KO按照如下的方式来实现地址信息的绑定和处理用户提交的编辑确认请求。我们首先需要通过一个函数来创建表示View Model的“类”,需要绑定的数据和函数将作为该类的成员,组成View的HTML元素则通过内联的“data-bind”属性实现数据绑定和事件注册。我们最终需要创建View Model对象,并将其作为参数调用ko.applyBindings方法将绑定应用到当前页面。

   1: <div>
   2:     <div><label>省:</label><input data-bind="value: province" /></div>
   3:     <div><label>市:</label><input data-bind="value: city" /></div>
   4:     <div><label>区:</label><input data-bind="value: district" /></div>
   5:     <div><label>街道:</label><input data-bind="value: street"/>
   6:     <div><label>地址:</label><span data-bind="text: address"></span></div>
   7:     <div><input type="button" data-bind="click: format" value="确定"/></div>
   8: </div>
   9:  
  10: <script type="text/javascript" >
   1:  

   2:     function AddressModel() {

   3:         var self = this;

   4:         self.province     = ko.observable("江苏省");

   5:         self.city         = ko.observable("苏州市");

   6:         self.district     = ko.observable("工业园区");

   7:         self.street       = ko.observable("星湖街328号");

   8:         self.address      = ko.observable();

   9:  

  10:         self.format     = function () {

  11:             if (self.province() && self.city() && self.district() && self.street()){

  12:                 var address = self.province() + " " + self.city() + " " + self.district() + " " + self.street();

  13:                 self.address(address);

  14:             }

  15:             else {

  16:                 alert("请提供完整的地址信息");

  17:             }

  18:     };

  19:  

  20:         self.format();

  21: }

  22:  

  23:     ko.applyBindings(new AddressModel());
</script>

如上面的代码片段所示,我们定义了一个名为AddressModel的类作为整个“地址编辑”页面的View Model,AddressModel的五个数据成员(province、city、district、street和address)表示地址的四个组成部分和格式化的地址。它们都是基于双向绑定的Observable类型成员,意味着用户的输入能够即时改变绑定的数据源,而数据源的改变也能即时地反映在绑定的HTML元素上。Observable数据成员是一个通过调用ko.observable方法创建的函数,方法调用指定的参数表示更新的数据。

AddressModel的另一个成员format是一个自定义的函数,该函数进行地址格式化并用格式化的地址更新address字段。由于address字段是一个Observable成员,一旦它的值发生改变,被绑定的HTML元素的值将会自动更新。

AddressModel的六个字段分别绑定在六个HTML元素上,其中province、city、district和street字段绑定到代表对应文本框的Value属性上(data-bind="value: {成员名称}"),而address字段则绑定到用于显示格式化地址的<span>元素的Text属性上(data-bind="text: {成员名称}"),用于格式化地址的format字段则与“确定”按钮的click事件进行绑定(data-bind="click: {成员名称}")。真正的绑定工作发生在ko.applyBindings方法被调用的时候。

ViewModel

接下来我们来看看“联系人管理器”这个Web页面究竟如何来定义。具体来说,该页面的内容包含两个部分,HTML标签和JavaScript代码。对于后者,其主要体现在具有如下定义的View Model上,我们将它定义在独立的JavaScript文件(viewmodel.js)中。

   1: function ViewModel() {
   2:     self             = this;
   3:     self.contacts    = ko.observableArray(); //当前联系人列表
   4:     self.contact     = ko.observable(); //当前编辑联系人
   5:  
   6:     //获取当前联系人列表
   7:     self.load = function () {
   8:         $.ajax({
   9:             url        : "http://localhost/webhost/api/contacts",
  10:             type       : "GET",
  11:             success    : function (result) {
  12:                 self.contacts(result);
  13:             }
  14:         });
  15:     };
  16:  
  17:     //弹出编辑联系人对话框
  18:     self.showDialog = function (data) {
  19:         //通过Id判断"添加/修改"操作
  20:         if (!data.Id) {
  21:             data = { ID: "", Name: "", PhoneNo: "", EmailAddress: "", 
  22:                 Address: "" }
  23:         }
  24:         self.contact(data);
  25:         $(".modal").modal('show');
  26:     };
  27:  
  28:     //调用Web API添加/修改联系人信息
  29:     self.save = function () {
  30:         $(".modal").modal('hide');
  31:         if (self.contact().Id) {
  32:             $.ajax({
  33:                 url    : "http://localhost/webhost/api/contacts/" + self.contact.Id,
  34:                 type    : "PUT",
  35:                 data    : self.contact(),
  36:                 success    : function () {self.load();}
  37:             });
  38:         }
  39:         else {
  40:             $.ajax({
  41:                 url   : "http://localhost/webhost/api/contacts",
  42:                 type    : "POST",
  43:                 data    : self.contact(),
  44:                 success : function () {self.load();}
  45:             });
  46:         }
  47:     };
  48:  
  49:     //删除现有联系人
  50:     self.delete = function (data) {
  51:         $.ajax({
  52:             url        : "http://localhost/webhost/api/contacts/" + data.Id,
  53:             type       : "DELETE",
  54:             success    : function () {self.load();}
  55:         });
  56:     };
  57:  
  58:     self.load();
  59: }
  60:  
  61: $(function () {
  62:     ko.applyBindings(new ViewModel());
  63: });

对于上面定义的作为整个页面View Model的“类型”(ViewModel)来说,它具有两个“数据”成员(其实是函数)contacts和contact,前者表示当前联系人列表,后者则表示当前修改或者添加的联系人。contacts和contact分别通过调用方法observableArray和observable创建,所以它们均支持双向绑定。这两个数据成员分别被绑定到呈现当前联系人的表格和用于编辑联系人信息的对话框中。除了这两个数据成员之外,我们还定义了4个方法成员。

  • load:发送Ajax请求调用Web API以获取当前联系人列表,并将得到的联系人列表“赋值”给contacts属性。
  • showDialog:弹出“编辑联系人信息”对话框。我们通过指定的联系人对象是否具有Id来判断当前操作是“修改”还是“添加”。对于后者,我们会创建一个新的对象作为添加的联系人对象。被修改或者添加的联系人对象被“赋值”给contact属性。对话框的弹出通过调用表示对话框的<div>的modal方法实现,该方法是由Bootstrap提供的。
  • save:发送Ajax请求调用Web API以添加新的联系人或者修改现有某个联系人的信息。contact属性作为提交的数据,至于“添加”还是“修改”,同样是通过它是否具有相应的Id来决定。联系人成功添加或者修改之后,load方法被调用以刷新当前联系人列表。
  • delete:发送Ajax请求调用Web API以删除指定的联系人。联系人成功删除之后,load方法被调用以刷新当前联系人列表。

HTML

如下所示的是页面主体部分包含的HTML,ViewModel的相关成员会绑定到相应HTML元素上。整个内容大体包含两个部分,第一部分用于呈现当前联系人列表,第二部分在用于定义弹出的对话框。

   1: <!--当前联系人列表-->
   2: <div id="content">
   3:     <table class="table table-striped">
   4:         <thead>
   5:             <tr>
   6:                 <th>姓名</th>
   7:                 <th>联系电话</th>
   8:                 <th>电子邮件</th>
   9:                 <th></th>
  10:             </tr>
  11:         </thead>
  12:         <tbody data-bind="foreach: contacts">
  13:             <tr>
  14:                 <td data-bind="text: Name"></td>
  15:                 <td data-bind="text: PhoneNo"></td>
  16:                 <td data-bind="text: EmailAddress"></td>
  17:                 <td>
  18:                     <a href="#" data-bind="click: $root.showDialog">修改</a>
  19:                     <a href="#" data-bind="click: $root.delete">删除</a>
  20:                 </td>
  21:             </tr>
  22:         </tbody>
  23:     </table>
  24:     <a href="#" class="btn btn-primary" data-bind="click: showDialog">添加新联系人</a>
  25: </div>
  26:  
  27: <!--添加/修改联系人对话框-->
  28: <div class="modal fade">
  29:     <div class="modal-dialog">
  30:         <div class="modal-content">
  31:             <div class="modal-header">
  32:                 <button type="button" class="close" data-dismiss="modal"  aria-hidden="true">&times;</button>
  33:                 <h4 class="modal-title">编辑联系人信息</h4>
  34:             </div>
  35:             <div class="modal-body form-horizontal" data-bind="with: contact">
  36:                 <div class="form-group">
  37:                     <label for="name" class="col-sm-2 control-label">姓名:</label>
  38:                     <div class="col-sm-10">
  39:                         <input type="text" class="form-control" id="name"  placeholder="姓名" data-bind="value:Name">
  40:                     </div>
  41:                 </div>
  42:                 <div class="form-group">
  43:                     <label for="phoneNo" class="col-sm-2 control-label"> 联系电话:</label>
  44:                     <div class="col-sm-10">
  45:                         <input type="text" class="form-control" id="phoneNo"  placeholder="联系电话" data-bind="value:PhoneNo">
  46:                     </div>
  47:                 </div>
  48:                 <div class="form-group">
  49:                     <label for="emailAddress" class="col-sm-2 control-label"> 电子邮箱:</label>
  50:                     <div class="col-sm-10">
  51:                         <input type="text" class="form-control"  id="emailAddress" placeholder="电子邮箱"  data-bind="value:EmailAddress">
  52:                     </div>
  53:                 </div>
  54:                 <div class="form-group">
  55:                     <label for="address" class="col-sm-2 control-label"> 地址:</label>
  56:                     <div class="col-sm-10">
  57:                         <input type="text" class="form-control" id="address"  placeholder="地址" data-bind="value:Address">
  58:                     </div>
  59:                 </div>
  60:             </div>
  61:             <div class="modal-footer">
  62:               <a href="#" class="btn btn-default" data-dismiss="modal">关闭</a>
  63:               <a href="#" class="btn btn-primary" data-bind="click: save">保存</a>
  64:             </div>
  65:         </div>
  66:     </div>
  67: </div>

第一部分的核心是呈现联系人列表的<table>元素,其主体具有一个针对contacts成员的foreach绑定(<tbody data-bind="foreach: contacts">),该绑定利用内嵌的<tr>元素绑定列表中的每个联系人。至于联系人的具体某个属性,则对应着相应的<td>元素,两者之间是一个text绑定(<td data-bind="text: Name"></td>)。

表格中的每行右侧的“修改”和“删除”链接各自具有一个针对showDialog 和delete方法成员的click绑定(<a href="#" data-bind="click: $root.showDialog">修改</a>)。之所以需要在成员名称前面添加“$root”前缀,是因为KO总是会从当前绑定上下文中去获取绑定的成员。由于这两个链接HTML内嵌于foreach绑定之中,所以当前绑定上下文实际上是contacts属性中某个联系人对象。“$root”前缀的目的在于告诉KO绑定的是ViewModel自身的成员。值得一提的是,当绑定的方法被执行时,KO会将当前绑定上下文作为参数。

在表示“编辑联系人信息”对话框的主体部分,我们通过一个with绑定(<div data-bind="with: contact">)将绑定上下文设定为ViewModel的contact属性,内嵌其中的4个文本框分别利用一个value绑定(比如<input type="text" data-bind="value:Name">)与对应的成员进行关联。ViewModel中最终用于添加/修改联系人的方法save则通过一个click绑定(<三data-bind="click: save">保存</a>)与“保存”按钮关联在一起。