七、创建 Web API 应用

你还不知道,但这一章就是你一直在等待的一章! 它的特殊有很多原因。

首先,我们将完成游戏部分,您将能够开始玩井字游戏。 是的,最后,整个应用将运行,您将能够与其他用户竞争。 非常激动人心!

其次,您将看到如何将您的应用与其他系统和服务集成。 这是非常重要的,因为应用不再是孤立的筒仓。 相反,他们相互沟通,不断交换数据,为客户提供更多的价值。 你是怎么做到的? 您提供了可互操作的 Web api,这些 api 允许将组件(有时基于完全不同的技术)连接在一起!

第三,使用 Web api 不仅允许您与其他系统集成; 它还将帮助您构建更灵活和可重用的应用组件,然后您可以组合这些组件来创建新的应用,以响应更高级的用例。

你将在本章中创建的 api 不仅对你正在开发的 MVC Web 前端有用,而且对你将来可能构建的新的移动前端也有用。 这将让你接触到更多的客户。 你将能够为你的客户提供全方位的体验,他们开始使用一种设备,结束在另一种。

在本章中,我们将涵盖以下主题:

  • 应用 Web API 概念和最佳实践
  • 构建 rpc 风格的 Web api
  • 构建 rest 风格的 Web api
  • 构建 hateoas 风格的 Web api

应用 Web API 概念和最佳实践

ASP.NET Core 2.0 结合了 ASP 的最佳特性.NET MVC 和 Web api 一起成为一个单一的框架。 这是完全有意义的,因为它们提供了许多类似的功能。

在合并之前,当开发人员需要通过 MVC 和 Web api 公开不同格式的数据时,他们必须重写代码。 他们必须同时使用多个框架和概念。 幸运的是,整个过程在 ASP 中已经完全简化了.NET Core 2.0,正如你将在本章中看到的。

下图说明了 ASP 如何处理客户端 HTTP 请求。 关于 Web api 和 MVC 的 NET Core 2.0:

Web api 通常使用 JSON 或 XML 作为响应格式。 JSON 将是首选格式,因为它已经成为市场上的准标准格式,而且由于其简单性和效率,每个人都在使用它。

此外,过滤器和中间件可以与 Web api 一起使用,因为 ASP.NET Core 2.0 以与标准 MVC 控制器相同的方式管理 Web api。 这在某些用例中非常方便,开发人员可以更广泛地应用他们的技能。

一般来说,使用 ASP 创建 Web api 有三种不同的风格.NET 2.0 核心:

  • rpc 样式
  • rest 样式的
  • hateoas 风格

Note that it is also possible to use SOAP for creating Web APIs, but it is not recommended. Instead, SOAP should be used in the context of standard web services, which is why it is not shown in the following examples.

我们将更详细地介绍每种风格,您将看到一些实际示例,这些示例将帮助您决定自己的集成策略。

构建 rpc 风格的 Web api

RPC风格基于远程过程调用范例,该范例已经存在很长一段时间了(从 1980 年代早期开始)。 它基于在 URL 中包含一个操作名称,因此它非常类似于标准 MVC 操作。

ASP 最大的优势之一.NET Core 2.0 是你不需要把 MVC 部分和 Web API 部分分开。 相反,您可以在控制器实现中使用这两种方法。

控制器现在能够呈现视图结果以及 JSON/XML API 响应,这使得从一个到另一个的迁移变得容易。 此外,您可以为您的 MVC 操作使用特定的路由路径或相同的路由路径。

在下面的例子中,你将把一个控制器动作从 MVC 视图结果转换成一个 rpc 风格的 Web API:

  1. UserRegistrationController中添加一个名为ConfirmEmail的新方法; 用于确认用户注册邮箱:
        [HttpGet] 
        public async Task<IActionResult> ConfirmEmail(string email) 
        { 
          var user = await _userService.GetUserByEmail(email); 
          if (user != null) 
          { 
            user.IsEmailConfirmed = true; 
            user.EmailConfirmationDate = DateTime.Now; 
            await _userService.UpdateUser(user); 
            return RedirectToAction("Index", "Home"); 
          } 
          return BadRequest(); 
        } 
  1. 更新GameInvitationController中的ConfirmGameInvitation方法,将受邀用户的电子邮件存储在一个会话变量中,并通过用户服务注册新用户:
        [HttpGet] 
        public async Task<IActionResult> ConfirmGameInvitation(Guid id,   
         [FromServices]IGameInvitationService gameInvitationService) 
        { 
          var gameInvitation = await gameInvitationService.Get(id); 
          gameInvitation.IsConfirmed = true; 
          gameInvitation.ConfirmationDate = DateTime.Now; 
          await gameInvitationService.Update(gameInvitation); 
          Request.HttpContext.Session.SetString("email",
           gameInvitation.EmailTo); 
          await _userService.RegisterUser(new UserModel 
          { 
            Email = gameInvitation.EmailTo, EmailConfirmationDate =
             DateTime.Now, IsEmailConfirmed =true 
          }); 
          return RedirectToAction("Index", "GameSession", new { id }); 
        }
  1. 更新Views/Shared/Components/GameSession/default.cshtml文件中GameSessionViewComponent中的 table 元素:
        @using Microsoft.AspNetCore.Http 
        @model TicTacToe.Models.GameSessionModel 
        @{ 
          var email = Context.Session.GetString("email"); 
        } 
        <div id="gameBoard"> 
          <table> 
            @for (int rows = 0; rows < 3; rows++) 
            { 
              <tr style="height:150px;"> 
                @for (int columns = 0; columns < 3; columns++) 
                { 
                  <td style="width:150px; border:1px 
                   solid #808080" id="@($"c_{rows}_{columns}")"> 
                    @{ 
                       var position = Model.Turns?.FirstOrDefault(
                         turn => turn.X == columns && turn.Y == rows); 
                       if (position != null) 
                       { 
                         if (position.User == Model.User1) 
                         { 
                           <i class="glyphicon glyphicon-unchecked"
                              style="width:100%;height:100%"></i> 
                         } 
                         else 
                         { 
                           <i class="glyphicon glyphicon-remove-circle"
                              style="width:100%;height:100%"></i> 
                          } 
                        } 
                        else 
                        { 
                          <a class="btn btn-default btn-SetPosition" 
                             style="width:150px; min-height:150px;"
                             data-X="@columns" data-Y="@rows"> 
                             &nbsp; 
                          </a> 
                        } 
                      } 
                    </td> 
                } 
              </tr> 
            } 
          </table> 
        </div> 
        <div class="alert" id="divAlertWaitTurn"> 
          <i class="glyphicon glyphicon-alert">Please wait until the
             other user has finished his turn.</i> 
        </div>
  1. wwwroot\app\js文件夹中添加一个新的 JavaScript 文件GameSession.js; 它将用于调用 Web API。 添加临时警告框用于测试:
        function SetGameSession(gdSessionId, strEmail) { 
          window.GameSessionId = gdSessionId; 
          window.EmailPlayer = strEmail; 
        } 

        $(document).ready(function () { 
          $(".btn-SetPosition").click(function () { 
            var intX = $(this).attr("data-X"); 
            var intY = $(this).attr("data-Y"); 
            SendPosition(window.GameSessionId, window.EmailPlayer,
             intX, intY); 
          }) 
        }) 

        function SendPosition(gdSession, strEmail, intX, intY) { 
          var port = document.location.port ? (":" +
           document.location.port) : ""; 
          var url = document.location.protocol + "//" +
           document.location.hostname + port +
           "/restApi/v1/SetGamePosition/" + gdSession; 
          var obj = { 
            "Email": strEmail, "x": intX, "y": intY 
          }; 

          var json = JSON.stringify(obj); 
          $.ajax({ 
            'url': url, 
            'accepts': "application/json; charset=utf-8", 
            'contentType': "application/json", 
            'data': json, 
            'dataType': "json", 
            'type': "POST", 
            'success': function (data) { 
              alert(data); 
            } 
          }); 
        } 
  1. 将上述新的 JavaScript 文件添加到bundleconfig.json文件中,以便将其与其他文件捆绑到site.js文件中:
        { 
          "outputFileName": "wwwroot/js/site.js", 
          "inputFiles": [ 
            "wwwroot/app/js/scripts1.js", 
            "wwwroot/app/js/scripts2.js", 
            "wwwroot/app/js/GameSession.js" 
          ], 
          "sourceMap": true, 
          "includeInProject": true 
        }, 
  1. TurnModel模型中添加一个名为Email的新属性:
        public string Email { get; set; } 
  1. 更新GameSessionController中的SetPosition方法,并将其作为 Web API 公开,以便能够从之前实现的 JavaScriptSendPosition函数接收 Ajax 调用:
        [Produces("application/json")] 
        [HttpPost("/restapi/v1/SetGamePosition/{sessionId}")] 
        public async Task<IActionResult> SetPosition(
         [FromRoute]Guid sessionId) 
        { 
          if (sessionId != Guid.Empty) 
          { 
            using (var reader = new StreamReader(Request.Body,
             Encoding.UTF8, true, 1024, true)) 
            { 
              var bodyString = reader.ReadToEnd(); 
              if (string.IsNullOrEmpty(bodyString)) 
                return BadRequest("Body is empty"); 

              var turn =
               JsonConvert.DeserializeObject<TurnModel>(bodyString); 

              turn.User =
               await HttpContext.RequestServices.GetService
               <IUserService>().GetUserByEmail(turn.Email); 
              turn.UserId = turn.User.Id; 
              if (turn == null) 
                return BadRequest("You must pass a TurnModel
                 object in your body"); 

              var gameSession =
               await _gameSessionService.GetGameSession(sessionId); 

              if (gameSession == null) 
                return BadRequest($"Cannot find Game Session {sessionId}"); 

              if (gameSession.ActiveUser.Email != turn.User.Email) 
                return BadRequest($"{turn.User.Email} cannot play
                this turn"); 

              gameSession =
               await _gameSessionService.AddTurn(
               gameSession.Id, turn.User.Email, turn.X, turn.Y); 
              if (gameSession != null &&
                  gameSession.ActiveUser.Email != turn.User.Email) 
                return Ok(gameSession); 
              else 
                return BadRequest("Cannot save turn"); 
            } 
          } 
          return BadRequest("Id is empty"); 
        }   

Note that it is best practice to prefix Web APIs with a meaningful name and a version number (for example, /restapi/v1) as well as to support JSON and XML.

  1. 更新Views文件夹中的游戏会话索引视图,并调用带有相应参数的 JavaScriptSetGameSession函数:
        @using Microsoft.AspNetCore.Http 
        @model TicTacToe.Models.GameSessionModel 
        @{ 
          var email = Context.Session.GetString("email"); 
        } 
        @section Desktop 
        { 
          <h1>Game Session @Model.Id</h1> 
          <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
          <div class="alert alert-info"> 
            <table class="table"> 
              <tr> 
                <td>User 1:</td> 
                <td>@Model.User1?.Email (<i class="glyphicon
                 glyphicon-unchecked"></i>)</td> 
              </tr> 
              <tr> 
                <td>User 2:</td> 
                <td>@Model.User2?.Email (<i class="glyphicon
                 glyphicon-remove-circle"></i>)</td> 
              </tr> 
            </table> 
          </div> 
        } 
        @section Mobile{ 
          <h1>Game Session @Model.Id</h1> 
          <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
          User 1: @Model.User1?.Email <i class="glyphicon
           glyphicon-unchecked"></i><br /> 
          User 2: @Model.User2?.Email (<i class="glyphicon
           glyphicon-remove-circle"></i>) 
        } 
        <h3>User Email @email</h3> 
        <h3>Active User <span id="activeUser">
         @Model.ActiveUser?.Email</span></h3>
        <vc:game-session game-session-id="@Model.Id"></vc:game-session> 
        @section Scripts{ 
          <script> 
            SetGameSession("@Model.Id", "@email"); 
          </script> 
        } 
  1. 更新通信中间件中 WebSockets 的ProcessEmailConfirmation方法:
        public async Task ProcessEmailConfirmation(HttpContext context,
         WebSocket currentSocket, CancellationToken ct, string email) 
        { 
          var user = await _userService.GetUserByEmail(email); 
          while (!ct.IsCancellationRequested &&
           !currentSocket.CloseStatus.HasValue &&
            user?.IsEmailConfirmed == false) 
          { 
            await SendStringAsync(currentSocket,
             "WaitEmailConfirmation", ct); 
            await Task.Delay(500); 
            user = await _userService.GetUserByEmail(email); 
          } 

          if (user.IsEmailConfirmed) 
          { 
            await SendStringAsync(currentSocket, "OK", ct); 
          } 
        } 
  1. 更新通信中间件中 WebSockets 的ProcessGameInvitationConfirmation方法:
        private async Task ProcessGameInvitationConfirmation(
         HttpContext context, WebSocket webSocket,
         CancellationToken ct, string parameters) 
        { 
          var gameInvitationService =
           context.RequestServices.GetService<IGameInvitationService>(); 
          var id = Guid.Parse(parameters); 
          var gameInvitationModel =
           await gameInvitationService.Get(id); 
          while (!ct.IsCancellationRequested &&
                 !webSocket.CloseStatus.HasValue &&
                  gameInvitationModel?.IsConfirmed == false) 
          { 
            await Task.Delay(500); 
            gameInvitationModel = await gameInvitationService.Get(id); 
            await SendStringAsync(webSocket, "WaitForConfirmation", ct); 
          } 

          if (gameInvitationModel.IsConfirmed) 
          { 
            await SendStringAsync(webSocket,
             JsonConvert.SerializeObject(new 
            { 
              Result = "OK", 
              Email = gameInvitationModel.InvitedBy, 
              gameInvitationModel.EmailTo, 
              gameInvitationModel.Id 
            }), ct); 
          } 
        } 
  1. 更新scripts2.jsJavaScript 文件中的CheckGameInvitationConfirmationStatus方法; 它现在必须验证返回的数据:
        function CheckGameInvitationConfirmationStatus(id) { 
          $.get("/GameInvitationConfirmation?id=" + id, function (data) { 
            if (data.result === "OK") { 
              if (interval !== null) { 
                clearInterval(interval); 
              } 
              window.location.href = "/GameSession/Index/" + id; 
            } 
          }); 
        }
  1. 更新 Gravatar 标签助手中的Process方法,并正确处理没有照片的情况:
        public override void Process(TagHelperContext context,
         TagHelperOutput output) 
        { 
          byte[] photo = null; 
          if (CheckIsConnected()) 
          { 
            photo = GetPhoto(Email); 
          } 
          else 
          { 
            string filePath =
             Path.Combine(Directory.GetCurrentDirectory(),
             "wwwroot", "images", "no-photo.jpg"); 
            if (File.Exists(filePath)) 
              photo = File.ReadAllBytes(filePath); 
          } 

          if(photo != null && photo.Length > 0) 
          { 
            output.TagName = "img"; 
            output.Attributes.SetAttribute("src",
             $"data:image/jpeg;base64,{Convert.ToBase64String(photo)}"); 
          } 
        } 
  1. 更新GameInvitationService中的Add方法:
        public Task<GameInvitationModel> Add(
         GameInvitationModel gameInvitationModel) 
        { 
          _gameInvitations.Add(gameInvitationModel); 
          return Task.FromResult(gameInvitationModel); 
        } 
  1. 更新桌面布局页面和移动布局页面; 通过删除两个页面底部包含script1.jsscript2.js的开发environment标记来清除。
  2. 更新scripts1.jsJavaScript 文件,删除所有显示 WebSockets 是否启用的警告框。

  3. 启动应用,注册新用户,邀请其他用户开始游戏会话,点击单元格,你现在会看到一个 JavaScript 警告框:

很好; 您已经看到了如何将现有的GameSessionController操作转换为 rpc 样式的 Web API。 因为所有不同的 ASP.NET web 框架已经集中到一个单一的 asp.net 框架中.NET Core 2.0,这可以轻松、快速地完成,而无需重写代码或对现有代码进行太多更改。

在接下来的步骤中,我们将看到如何在 rpc 风格的 Web API 中添加一个新方法,用于检查当前用户的回合是否已经完成,从而下一个用户可以开始他的回合:

  1. GameSessionModel中添加一个名为TurnNumber的新属性,用于跟踪当前的回合数:
        public int TurnNumber { get; set; } 
  1. TurnModel中添加一个名为IconNumber的新属性,以便能够定义什么图标(X 或 O)需要用于以后的显示:
        public string IconNumber { get; set; } 
  1. GameSessionController中添加一个名为GetGameSession的新方法; 它将专属于 Web API 调用:
        [Produces("application/json")] 
        [HttpGet("/restapi/v1/GetGameSession/{sessionId}")] 
        public async Task<IActionResult> GetGameSession(Guid sessionId) 
        { 
          if (sessionId != Guid.Empty) 
          { 
            var session =
             await _gameSessionService.GetGameSession(sessionId); 

            if (session != null) 
            { 
              return Ok(session); 
            } 
            else 
            { 
              return NotFound($"can not found session {sessionId}"); 
            } 
          } 
          else 
          { 
            return BadRequest("session id is null"); 
          } 
        } 
  1. 更新GameSessionService中的AddTurn方法,计算IconNumberTurnNumber:
        public async Task<GameSessionModel> AddTurn(Guid id,
         string email, int x, int y) 
        { 
          List<Models.TurnModel> turns; 
          var gameSession = _sessions.FirstOrDefault(
           session => session.Id == id); 
          if (gameSession.Turns != null && gameSession.Turns.Any()) 
            turns = new List<Models.TurnModel>(gameSession.Turns); 
          else 
            turns = new List<TurnModel>(); 

          turns.Add(new TurnModel 
          { 
            User = await _UserService.GetUserByEmail(email), 
             X = x, 
             Y = y, 
             IconNumber = email == gameSession.User1?.Email ? "1" : "2" 
          }); 

          gameSession.Turns = turns; 
          gameSession.TurnNumber = gameSession.TurnNumber + 1; 
          if (gameSession.User1?.Email == email) 
            gameSession.ActiveUser = gameSession.User2; 
          else 
            gameSession.ActiveUser = gameSession.User1; 

          gameSession.TurnFinished = true; 
          _sessions = new ConcurrentBag<GameSessionModel>
           (_sessions.Where(u => u.Id != id)) 
          { 
            gameSession 
          }; 
          return gameSession; 
        } 
  1. 更新游戏会话索引视图,使用图像,并添加启用和禁用 gameboard 的可能性:
        @using Microsoft.AspNetCore.Http 
        @model TicTacToe.Models.GameSessionModel 
        @{ 
          var email = Context.Session.GetString("email"); 
        } 
        @section Desktop 
        { 
          <h1>Game Session @Model.Id</h1> 
          <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
          <div class="alert alert-info"> 
          <table class="table"> 
            <tr> 
              <td>User 1:</td> 
              <td>@Model.User1?.Email (<i class="glyphicon
               glyphicon-unchecked"></i>)</td> 
            </tr> 
            <tr> 
              <td>User 2:</td> 
              <td>@Model.User2?.Email (<i class="glyphicon
               glyphicon-remove-circle"></i>)</td> 
            </tr> 
          </table> 
          </div> 

        } 
        @section Mobile{ 
         <h1>Game Session @Model.Id</h1> 
         <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
         User 1: @Model.User1 <i class="glyphicon
          glyphicon-unchecked"></i><br /> 
         User 2: @Model.User2 (<i class="glyphicon
          glyphicon-remove-circle"></i>) 
        } 
        <h3>User Email @email</h3> 
        <h3>Active User <span id="activeUser">
         @Model.ActiveUser?.Email</span></h3> 
        <vc:game-session game-session-id="@Model.Id"></vc:game-session> 
        @section Scripts{ 
          <script> 
          SetGameSession("@Model.Id", "@email"); 
          EnableCheckTurnIsFinished(); 
          @if(email != Model.ActiveUser?.Email) 
          { 
            <text>DisableBoard(@Model.TurnNumber);</text> 
          } 
          else 
          { 
            <text>EnableBoard(@Model.TurnNumber);</text> 
          } 
          </script> 
        } 
  1. wwwroot\app\js文件夹中添加一个新的 JavaScript 文件CheckTurnIsFinished.js; 相应更新bundleconfig.json文件:
        function EnableCheckTurnIsFinished() { 
          interval = setInterval(() => { 
            CheckTurnIsFinished(); 
          }, 2000); 
        } 

        function CheckTurnIsFinished() { 
          var port = document.location.port ? (":" +
           document.location.port) : ""; 
          var url = document.location.protocol + "//" +
           document.location.hostname + port +
           "/restapi/v1/GetGameSession/" + window.GameSessionId; 

          $.get(url, function (data) { 
            if (data.turnFinished === true &&
             data.turnNumber >= window.TurnNumber) { 
              CheckGameSessionIsFinished(); 
              ChangeTurn(data); 
            } 
          }); 
        } 

        function ChangeTurn(data) { 
          var turn = data.turns[data.turnNumber-1]; 
          DisplayImageTurn(turn); 

          $("#activeUser").text(data.activeUser.email); 
          if (data.activeUser.email !== window.EmailPlayer) { 
            DisableBoard(data.turnNumber); 
          }  
          else { 
            EnableBoard(data.turnNumber); 
          } 
        } 

        function DisableBoard(turnNumber) { 
          var divBoard = $("#gameBoard"); 
          divBoard.hide(); 
          $("#divAlertWaitTurn").show(); 
          window.TurnNumber = turnNumber; 
        } 

        function EnableBoard(turnNumber) { 
          var divBoard = $("#gameBoard"); 
          divBoard.show(); 
          $("#divAlertWaitTurn").hide(); 
          window.TurnNumber = turnNumber; 
        } 

        function DisplayImageTurn(turn) { 
          var c = $("#c_" + turn.y + "_" + turn.x); 
          var css; 

          if (turn.iconNumber === "1") { 
          css = 'glyphicon glyphicon-unchecked'; 
        } 
        else { 
          css = 'glyphicon glyphicon-remove-circle'; 
        } 

        c.html('<i class="' + css + '"></i>'); 
      } 
  1. 更新GameSession.jsJavaScript 文件中的SetGameSession方法; 默认设置TurnNumber为零:
        function SetGameSession(gdSessionId, strEmail) { 
          window.GameSessionId = gdSessionId; 
          window.EmailPlayer = strEmail; 
          window.TurnNumber = 0; 
        } 
  1. 更新GameSession.jsJavaScript 文件中的SendPosition方法,删除之前添加的临时测试警告框; 我们不再需要它了,游戏将在本节结束时功能完整:
        function SendPosition(gdSession, strEmail, intX, intY) { 
          var port = document.location.port ? (":" + 
           document.location.port) : ""; 
          var url = document.location.protocol + "//" +
           document.location.hostname + port +
           "/restApi/v1/SetGamePosition/" + gdSession; 
          var obj = { 
            "Email": strEmail, "x": intX, "y": intY 
          }; 

          var json = JSON.stringify(obj); 
          $.ajax({ 
            'url': url, 
            'accepts': "application/json; charset=utf-8", 
            'contentType': "application/json", 
            'data': json, 
            'dataType': "json", 
            'type': "POST" 
          }); 
        }
  1. GameSessionController中添加两个新方法,第一个叫CheckGameSessionIsFinished,第二个叫CheckIfUserHasWon:
        [Produces("application/json")] 
        [HttpGet("/restapi/v1/CheckGameSessionIsFinished/{sessionId}")] 
        public async Task<IActionResult> CheckGameSessionIsFinished(
         Guid sessionId) 
        { 
          if (sessionId != Guid.Empty) 
          { 
            var session =
             await _gameSessionService.GetGameSession(sessionId); 
            if (session != null) 
            { 
              if (session.Turns.Count() == 9) 
                return Ok("The game was a draw."); 

              var userTurns = session.Turns.Where(
                x => x.User == session.User1).ToList(); 
              var user1Won = CheckIfUserHasWon(session.User1?.Email,
                userTurns); 

              if (user1Won) 
              { 
                return Ok($"{session.User1.Email} has won the game."); 
              } 
              else 
              { 
                userTurns = session.Turns.Where(
                  x => x.User == session.User2).ToList(); 
                var user2Won = CheckIfUserHasWon(session.User2?.Email,
                  userTurns); 

                if (user2Won) 
                  return Ok($"{session.User2.Email} has won the game."); 
                else 
                  return Ok(""); 
              } 
            } 
            else 
            { 
              return NotFound($"Cannot find session {sessionId}."); 
            } 
          } 
          else 
          { 
            return BadRequest("SessionId is null."); 
          } 
        } 

        private bool CheckIfUserHasWon(string email,
         List<TurnModel> userTurns) 
        { 
          if (userTurns.Any(x => x.X == 0 && x.Y == 0) &&
            userTurns.Any(x => x.X == 1 && x.Y == 0) &&
            userTurns.Any(x => x.X == 2 && x.Y == 0)) 
           return true; 
          else if (userTurns.Any(x => x.X == 0 && x.Y == 1) &&
            userTurns.Any(x => x.X == 1 && x.Y == 1) &&
            userTurns.Any(x => x.X == 2 && x.Y == 1)) 
           return true; 
          else if (userTurns.Any(x => x.X == 0 && x.Y == 2) &&
            userTurns.Any(x => x.X == 1 && x.Y == 2) &&
            userTurns.Any(x => x.X == 2 && x.Y == 2)) 
           return true; 
          else if (userTurns.Any(x => x.X == 0 && x.Y == 0) &&
            userTurns.Any(x => x.X == 0 && x.Y == 1) &&
            userTurns.Any(x => x.X == 0 && x.Y == 2)) 
           return true; 
          else if (userTurns.Any(x => x.X == 1 && x.Y == 0) &&
            userTurns.Any(x => x.X == 1 && x.Y == 1) &&
            userTurns.Any(x => x.X == 1 && x.Y == 2)) 
           return true; 
          else if (userTurns.Any(x => x.X == 2 && x.Y == 0) &&
            userTurns.Any(x => x.X == 2 && x.Y == 1) &&
            userTurns.Any(x => x.X == 2 && x.Y == 2)) 
           return true; 
          else if (userTurns.Any(x => x.X == 0 && x.Y == 0) && 
            userTurns.Any(x => x.X == 1 && x.Y == 1) &&
            userTurns.Any(x => x.X == 2 && x.Y == 2)) 
           return true; 
          else if (userTurns.Any(x => x.X == 2 && x.Y == 0) &&
            userTurns.Any(x => x.X == 1 && x.Y == 1) &&
            userTurns.Any(x => x.X == 0 && x.Y == 2)) 
           return true; 
          else 
            return false; 
        }  
  1. wwwroot\app\js文件夹中添加一个新的 JavaScript 文件CheckGameSessionIsFinished.js,并相应地更新bundleconfig.json文件:
        function CheckGameSessionIsFinished() { 
          var port = document.location.port ? (":" + 
           document.location.port) : ""; 
          var url = document.location.protocol + "//" + 
           document.location.hostname + port + 
           "/restapi/v1/CheckGameSessionIsFinished/" + 
           window.GameSessionId; 

          $.get(url, function (data) { 
            debugger; 
            if (data.indexOf("won") > 0 || data == "The game
             was a draw.") { 
              alert(data); 
              window.location.href = document.location.protocol +
              "//" + document.location.hostname + port; 
            } 
          }); 
        } 
  1. 开始游戏,注册新账号,打开确认邮件,确认,发送游戏邀请邮件,确认游戏邀请,开始游戏。 现在一切都应该正常运行了,你应该能够一直玩游戏,直到用户获胜或游戏打成平局:

这是 rpc 风格的,非常接近标准的 MVC 控制器操作。 在下一节中,您将看到一种完全不同的方法,它基于资源和资源管理。

恭喜你; 现在,您已经完成了实现,并创建了一个漂亮的、现代的、基于浏览器的游戏,在这个游戏中,两个用户可以相互竞争。

做好准备,因为您将看到更高级的技术,并了解如何使用两种最著名的 API 通信风格 REST 和 HATEOAS 来提供用于互操作性的 Web API。

要玩这款游戏,你可以使用两个单独的私人浏览器窗口,或者使用两个不同的浏览器,如 Chrome、Edge 或 Firefox。 为了测试您的 Web api,建议安装并使用 Postman,但是您也可以使用任何其他与 HTTP rest 兼容的客户机,比如 Fiddler,甚至通过 Firefox 的高级特性。

构建 rest 风格的 Web api

rest 风格是由 Roy Fiedling 在 2000 年代发明的,它是在基于多种技术的系统之间提供互操作性的最佳方法之一,无论它是在您的网络中还是在 internet 上。

此外,REST 方法本身并不是一种技术,而是一些有效使用 HTTP 协议的最佳实践。

REST 使用 HTTP 协议的不同元素来提供服务,而不是添加像 SOAP 或 XML-RPC 这样的新层:

  • URI 标识一个资源
  • HTTP 动词标识一个动作
  • 响应不是资源,而是资源的表示
  • 客户端身份验证作为请求头中的参数传递

与 rpc 风格不同,其主要目的不再是提供操作,而是管理和操作资源。

To get even more information on the concepts and ideas behind REST, you should read the dissertation of Roy Fiedling, which you can find at http://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm.

如下图所示,在TicTacToe应用中主要有三种类型的资源:

  • 用户
  • 游戏邀请
  • 游戏会话

现在我们将演示如何使用 REST 风格来构建一个 Game Invitation REST API:

  1. GameInvitationService中增加两个新方法AllDelete,并相应更新游戏邀请服务接口:
        public Task<IEnumerable<GameInvitationModel>> All() 
        { 
          return Task.FromResult<IEnumerable<GameInvitationModel>>
           (_gameInvitations.ToList()); 
        } 

        public Task Delete(Guid id) 
        { 
          _gameInvitations = new ConcurrentBag<GameInvitationModel>
           (_gameInvitations.Where(x => x.Id != id)); 
          return Task.CompletedTask; 
        } 
  1. 添加一个名为GameInvitationApiController的新 API 控制器,右键单击Controllers文件夹,选择 Add | Controller,然后选择带有读写操作模板的 API 控制器:

  1. 删除自动生成的代码,并用下面的 REST API 实现替换它; 你会发现它是多么的简单:
        [Produces("application/json")] 
        [Route("restapi/v1/GameInvitation")] 
        public class GameInvitationApiController : Controller 
        { 
          private IGameInvitationService _gameInvitationService; 
          private IUserService _userService; 
          public GameInvitationApiController(IGameInvitationService
           gameInvitationService, IUserService userService) 
          { 
            _gameInvitationService = gameInvitationService; 
            _userService = userService; 
          } 

          [HttpGet] 
          public async Task<IEnumerable<GameInvitationModel>> Get() 
          { 
            return await _gameInvitationService.All(); 
          } 

          [HttpGet("{id}", Name = "Get")] 
          public async Task<GameInvitationModel> Get(Guid id) 
          { 
            return await _gameInvitationService.Get(id); 
          }  

          [HttpPost] 
          public IActionResult Post([FromBody]GameInvitationModel 
           invitation) 
          { 
            if (!ModelState.IsValid) 
              return BadRequest(ModelState); 

            var invitedPlayer =
              _userService.GetUserByEmail(invitation.EmailTo); 
            if (invitedPlayer == null) return BadRequest(); 

              _gameInvitationService.Add(invitation); 
              return Ok(); 
          } 

          [HttpPut("{id}")] 
          public IActionResult Put(Guid id,
           [FromBody]GameInvitationModel invitation) 
          { 
            if (!ModelState.IsValid) 
              return BadRequest(ModelState); 

            var invitedPlayer =
             _userService.GetUserByEmail(invitation.EmailTo); 
            if (invitedPlayer == null) return BadRequest(); 

            _gameInvitationService.Update(invitation); 
            return Ok(); 
          } 

          [HttpDelete("{id}")] 
          public void Delete(Guid id) 
          { 
            _gameInvitationService.Delete(id); 
          } 
        } 

Note that for learning purposes, we have just given a very basic example of what you could implement. Normally, you should provide the same functionalities as in your controller implementations (sending emails, confirming emails, verifying data, etc.) and some advanced error handling.

  1. 启动应用,安装并启动 Postman,以便对您现在提供的新 REST API 进行一些手动测试,并向http://<yourhost>/restapi/v1/GameInvitation发送 HTTP GET 请求。 不会有游戏邀请,因为你还没有创建任何:

  1. 创建一个新的游戏邀请,发送一个 HTTP POST 请求到http://<yourhost>/restapi/v1/GameInvitation,点击 Body,选择 raw 和 JSON,使用"id":"7223160d-6243-498b-9d35-81b8c947b5ca""EmailTo":"example@example.com""InvitedBy":"test@test.com"作为参数:

Note that we have added the automatic creation of a user if it does not exist for testing purposes in one of the previous chapters. In a real worked scenario, you will have to implement the user registration Web APIs and call them before the Game Invitation Web APIs. Otherwise, you will get a bad request, since we have added some code to assure data coherence and integrity.

  1. 你可以通过发送一个 HTTP GET Request 到http://<yourhost>/restapi/v1/GameInvitation或者更具体地说,通过发送一个 HTTP GET Request 到http://<yourhost>/restapi/v1/GameInvitation/7223160d-6243-498b-9d35-81b8c947b5ca来检索游戏邀请:

  1. 更新游戏邀请,向http://<yourhost>/restapi/v1/GameInvitation/7223160d-6243-498b-9d35-81b8c947b5ca发送 HTTP PUT 请求,点击 Body,选择 raw 和 JSON,使用"id":"7223160d-6243-498b-9d35-81b8c947b5ca""EmailTo":"updated@updated.com""InvitedBy":"test@test.com"作为参数:

  1. 查看更新后的游戏邀请,发送一个 HTTP GET 请求到http://<yourhost>/restapi/v1/GameInvitation/7223160d-6243-498b-9d35-81b8c947b5ca:

  1. 删除游戏邀请并发送 HTTP 删除请求到http://<yourhost>/restapi/v1/GameInvitation/7223160d-6243-498b-9d35-81b8c947b5ca:

  1. 验证游戏邀请删除,并发送一个 HTTP GET 请求到http://<yourhost>/restapi/v1/GameInvitation:

rest 样式是目前市场上最常见的 Web api 样式。 它很容易理解,并且非常适合于互操作性用例。

在下一节中,您将看到一种更高级的样式,称为 HATEOAS,它特别适合于不断发展的 Web api。

构建 hateoas 风格的 Web api

HATEOAS(超媒体作为应用状态引擎)风格是提供高效 Web api 的另一种方法。 然而,它与之前提到的另外两种风格完全不同。 使用这种方法,客户端可以通过遍历 HTTP 响应中提供的各种超媒体链接来动态导航到所需的资源。

这种样式的优点是服务器不再驱动应用状态; 相反,它是由服务器返回的超媒体链接监督。

此外,与其他样式相比,使用这种样式时 API 更改的处理要好得多,因为客户机不再硬编码动作(rpc 样式)或资源(rest 样式)的 uri。 相反,它们可以使用服务器随每个响应返回的超媒体链接,这是一个有趣的概念,它允许更灵活和可扩展的 Web api。

下面的图表展示了如何将 hateoa 风格应用于TicTacToe应用:

这个图的一个 JSON 表示示例可以是:

    { 
      "_links": { 
        "self": { "href": "/gameinvitations" }, 
        "next": { "href": "/gameinvitations?page=2" }, 
        "find": { 
          "href": "/gameinvitations{?Id}", 
          "templated": "true" 
        } 
      }, 
      "_embedded": { 
        "gameinvitations": [ 
          { 
            "_links": { 
              "self": { "href": "/gameinvitations/
               f1eaf6ac-c998-40da-8eb5-198eaa2cc96f" }, 
              "confirm": { "href": "/gameinvitations/
               f1eaf6ac-c998-40da-8eb5-198eaa2cc96f/confirm" } 
            }, 
            "isConfirmed": "false", 
            "confirmDate": "null", 
            "emailTo": { 
              "self": { "href": "/user/1" } 
            }, 
            "invitedBy": { 
              "self": "\"{\"href\":\"/user/2\"}" 
            } 
          } 
        ] 
      } 
    } 

让我们来看看如何从技术上为TicTacToe应用的游戏邀请执行 HATEOAS:

  1. 进入 NuGet 包管理器并添加Halcyon.Mvc包,这将允许你更快更容易地实现 HATEOAS Web api:

  1. 更新Startup类,使用 HAL Json 格式化器代替标准 Json 格式化器:
        services.AddMvc(o => 
        { 
          o.Filters.Add(typeof(DetectMobileFilter)); 

          o.OutputFormatters.RemoveType<JsonOutputFormatter>(); 
          o.OutputFormatters.Add(new JsonHalOutputFormatter(new 
           string[] { "application/hal+json",
           "application/vnd.example.hal+json",
           "application/vnd.example.hal.v1+json" })); 
        }).AddViewLocalization(
           LanguageViewLocationExpanderFormat.Suffix,
           options => options.ResourcesPath = 
            "Localization").AddDataAnnotationsLocalization(); 
  1. 更新GameInvitationAPiController中的Get方法,使用Halcyon.Mvc的特定特性,返回HAL结果:
        [HttpGet] 
        public async Task<IActionResult> Get() 
        { 
          var invitations = await _gameInvitationService.All(); 
          var responseConfig = new HALModelConfig 
          { 
            LinkBase = $"{Request.Scheme}://{Request.Host.ToString()}", 
            ForceHAL = Request.ContentType ==
            "application/hal+json" ? true : false 
          }; 

          var response = new HALResponse(responseConfig); 
          response.AddLinks(new Link("self", "/GameInvitation"), 
            new Link("confirm", "/GameInvitation/{id}/Confirm")); 

          List<HALResponse> invitationsResponses = new List<HALResponse>(); 
          foreach (var invitation in invitations) 
          { 
            var rInv = new HALResponse(invitation, responseConfig); 

            rInv.AddLinks(new Link("self", "/GameInvitation/" +
             invitation.Id)); 
            rInv.AddLinks(new Link("confirm",
             $"/GameInvitation/{invitation.Id}/confirm")); 

            var invitedPlayer =
             _userService.GetUserByEmail(invitation.EmailTo); 
            rInv.AddEmbeddedResource("invitedPlayer", invitedPlayer,
             new Link[] 
            { 
              new Link("self", $"/User/{invitedPlayer.Id}") 
            }); 

            var invitedBy =
             _userService.GetUserByEmail(invitation.InvitedBy); 
            rInv.AddEmbeddedResource("invitedBy", invitedBy, new Link[] 
            { 
              new Link("self", $"/User/{invitedBy.Id}") 
            }); 

            invitationsResponses.Add(rInv); 
          } 

          response.AddEmbeddedCollection("invitations",
           invitationsResponses); 
          return this.HAL(response); 
        } 
  1. 启动应用和 Postman,向http://<yourhost>/restapi/v1/GameInvitation发送 HTTP POST 请求,创建一个新的 Game Invitation,点击 Body,选择 raw 和 JSON,使用"id":"7223160d-6243-498b-9d35-81b8c947b5ca""EmailTo":"example@example.com""InvitedBy":"test@test.com"作为参数:

  1. 通过使用Content-Type: application/hal+jsonhttp://<yourhost>/restapi/v1/GameInvitation发送一个 HTTP GET 请求来获取游戏邀请; 你会看到 HTTP 响应现在包含了 HATEOAS 链接:

HATEOAS 提供了一些强大的特性,这些特性允许独立地演进组件。 客户端可以与运行在服务器上的业务工作流完全解耦,服务器通过使用链接和其他超媒体构件(如表单)来管理交互。

总结

在本章中,您学习了如何为您的应用构建 Web api,以实现集成目的和松散耦合的应用架构。

我们已经探讨了 Web api 的不同风格,比如 RPC、REST 和 HATEOAS。 每种风格都有特定的优势和用例。 您必须仔细选择,这取决于您的特定应用需求,因为没有任何一种样式比其他样式更好。

您已经看到了如何将现有控制器操作转换为 rpc 样式的 Web api,以及如何从头构建 rest 样式和 hateoas 样式的 Web api 的示例。

我们已经使用 Postman 手动测试我们的 Web api,并且您已经获得了足够的知识来将所有这些新概念应用到您自己的环境中。

在下一章中,我们将讨论如何在 ASP 中使用 Entity Framework Core 2 来访问数据.NET Core 2.0 应用。