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

使用 ASP.NET Core SignalR 和 Typescript 的 React 创建实时应用

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.18/5 (9投票s)

2020年4月7日

CPOL

6分钟阅读

viewsIcon

28661

downloadIcon

856

使用 ASP.NET Core SignalR 3.1 和 Typescript 的 React 创建的 Scrum poker 应用

引言

在本教程中,我将指导你完成创建实时应用所需的主要步骤。我不会在这里写完整的代码。你可以在 github 上找到完整的源代码。

ScrumPoker 应用

在本教程中,我们将创建一个名为 ScrumPoker 的有趣应用。我们生活在一个敏捷的世界中,因此在我们的开发或每个冲刺周期中进行故事估算和打分非常普遍。过去,我们曾经使用计划扑克卡,团队通过这些卡来进行故事估算,但现在一切都在线上,而且我们经常远程工作。

用户可以创建一个 ScrumBoard 并与同事分享链接。团队成员可以在那里输入并开始对故事进行打分。团队给出的分数只有在创建 ScrumBoard 的用户允许他们查看时,才会显示在仪表板上。

用户实时添加到仪表板,他们提交的分数也实时反映出来。

源代码

├───clientapp
├───Contracts
├───Controllers
├───Infrastructure
│ ├───NotificationHub
│ └───Persistence

你可以从我的 github 下载完整的源代码。下载、克隆、fork 自 https://github.com/vikas0sharma/ScrumPoker

开发工具

我们将使用 ASP.NET Core 3.1、React 16.3+、Bootstrap 4、Node 10.13+、create-react-app、Redis、Visual Studio 2019、Visual Studio Code、Yarn 包管理器。

在这里,我假设你熟悉 ASP.NET Core 环境和 React。我将指导你完成使 SignalR 与 React 协同工作的特殊步骤。

如果你是 SignalR 新手,我建议你查阅 Microsoft 的官方文档。

如果你喜欢 React,那么设置 React 开发环境对你来说肯定很容易。

基本步骤

  • 首先,你需要创建一个 ASP.NET Core Web API 项目。在这里,你将创建一个控制器来处理来自 React 应用的请求。
  • 对于我们的持久化,我们将使用 Redis。为什么是 Redis?因为我想让我的应用保持简单,而且它是一个只需要在应用运行时持久化其数据的应用。
  • 在 ASP.NET Core 项目文件夹中,你需要为客户端应用创建一个单独的文件夹,我们所有的 React 应用代码都将驻留在其中。
  • 我使用 Yarn 作为包管理器。如果你喜欢 NPM 进行开发,那是你的选择。
  • 我相信你已经熟悉 create-react-app。它为我们完成了所有繁重的工作,并创建了一个基本的应用结构。需要注意的是,我们将使用 Typescript 编写我们的 React 应用。为什么是 Typescript?因为它通过在开发时捕获低级错误,使开发人员的生活更轻松。
    yarn create react-app my-app --template typescript
  • 你可以从我的源代码中使用 package.json 文件,它将帮助你设置所有必需的包。

后端代码

让我们先设置服务器端代码。在我们的应用中,我们只有两个模型,即 ScrumBoardUser

创建 Hub

SignalR 通过 Hub 在客户端和服务器之间进行通信。它是我们存放通信逻辑的中心位置。在这里,我们指定哪些客户端将被通知。

using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading.Tasks;

namespace API.Infrastructure.NotificationHub
{
    public class ScrumBoardHub : Hub
    {
        public async override Task OnConnectedAsync()
        {
            await base.OnConnectedAsync();
            await Clients.Caller.SendAsync("Message", "Connected successfully!");
        }

        public async Task SubscribeToBoard(Guid boardId)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, boardId.ToString());
            await Clients.Caller.SendAsync("Message", "Added to board successfully!");
        }
    }
}

正如你所看到的,我们继承了 SignalR 的 Hub 类。当客户端成功连接时,将调用 OnConnectedAsync。每当客户端连接到 hub 时,都会将消息推送到客户端。

我们公开了一个名为 ‘SubscribeToBoard’ 的方法,客户端可以通过提供 scumboard ID 来调用该方法以订阅一个 scumboard。如果你注意到,我们使用了 Hub 的 ‘Groups’ 属性来为特定板创建一组客户端。我们将按板 ID 创建一个组,并将所有请求该板更新的客户端添加到其中。

Dashboard 上,用户可以实时看到谁加入了该板以及他们在仪表板上正在做什么。

在 Startup 中注册 Hub

在 Startup 的 ConfigureServices 方法中,添加 AddSignalR

services.AddSignalR();

Configure 方法中,注册你的 Hub 类。

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapHub<ScrumBoardHub>("/scrumboardhub");// Register Hub class
});

创建持久化

正如我之前所说,我使用 Redis 服务器来存储用户执行的临时数据/活动。让我们创建一个类来使用 Redis 执行 CRUD 操作。我们将使用 StackExchange nuget 包。

<PackageReference Include="StackExchange.Redis" Version="2.1.28" />

Startup 类中设置 Redis 连接。

services.Configure<APISettings>(Configuration);

services.AddSingleton<ConnectionMultiplexer>(sp =>
{
     var settings = sp.GetRequiredService<IOptions<APISettings>>().Value;
     var configuration = ConfigurationOptions.Parse(settings.ConnectionString, true);
     
     configuration.ResolveDns = true;

     return ConnectionMultiplexer.Connect(configuration);
});

Repository

using API.Contracts;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

namespace API.Infrastructure.Persistence
{
    public class ScrumRepository : IScrumRepository
    {
        private readonly IDatabase database;

        public ScrumRepository(ConnectionMultiplexer redis)
        {
            database = redis.GetDatabase();
        }

        public async Task<bool> AddBoard(ScrumBoard scrumBoard)
        {
            var isDone = await database.StringSetAsync
                         (scrumBoard.Id.ToString(), JsonSerializer.Serialize(scrumBoard));

            return isDone;
        }

        public async Task<bool> AddUserToBoard(Guid boardId, User user)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return false;
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            board.Users.Add(user);

            return await AddBoard(board);
        }

        public async Task<bool> ClearUsersPoint(Guid boardId)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return false;
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            board.Users.ForEach(u => u.Point = 0);

            return await AddBoard(board);
        }

        public async Task<List<User>> GetUsersFromBoard(Guid boardId)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return new List<User>();
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);

            return board.Users;
        }

        public async Task<bool> UpdateUserPoint(Guid boardId, Guid userId, int point)
        {
            var data = await database.StringGetAsync(boardId.ToString());
            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            var user = board.Users.FirstOrDefault(u => u.Id == userId);
            if (user != null)
            {
                user.Point = point;
            }

            return await AddBoard(board);
        }
    }
}

用户可以创建一个 ScrumBoard,其他用户可以在其中创建自己的个人资料并开始在仪表板上投票或估算故事。

让我们为客户端应用公开一些端点

我们将创建一个 controller 类,并公开一些我们的 React 客户端应用将用于发送其请求的 REST API。

using API.Contracts;
using API.Infrastructure.NotificationHub;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace API.Controllers
{
    [Route("scrum-poker")]
    [ApiController]
    public class ScrumPokerController : ControllerBase
    {
        private readonly IScrumRepository scrumRepository;
        private readonly IHubContext<ScrumBoardHub> hub;

        public ScrumPokerController(IScrumRepository scrumRepository, 
                                    IHubContext<ScrumBoardHub> hub)
        {
            this.scrumRepository = scrumRepository;
            this.hub = hub;
        }

        [HttpPost("boards")]
        public async Task<IActionResult> Post([FromBody] ScrumBoard scrumBoard)
        {
            var boardId = Guid.NewGuid();
            scrumBoard.Id = boardId;

            var isCreated = await scrumRepository.AddBoard(scrumBoard);
            if (isCreated)
            {
                return Ok(boardId);
            }

            return NotFound();
        }

        [HttpPost("boards/{boardId}")]
        public async Task<IActionResult> UpdateUsersPoint(Guid boardId)
        {
            var isAdded = await scrumRepository.ClearUsersPoint(boardId);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));
            if (isAdded)
            {
                return Ok(isAdded);
            }
            return NotFound();
        }

        [HttpPost("boards/{boardId}/users")]
        public async Task<IActionResult> AddUser(Guid boardId, User user)
        {
            user.Id = Guid.NewGuid();
            var isAdded = await scrumRepository.AddUserToBoard(boardId, user);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));
            if (isAdded)
            {
                return Ok(user.Id);
            }
            return NotFound();
        }

        [HttpGet("boards/{boardId}/users")]
        public async Task<IActionResult> GetUsers(Guid boardId)
        {
            var users = await scrumRepository.GetUsersFromBoard(boardId);

            return Ok(users);
        }

        [HttpGet("boards/{boardId}/users/{userId}")]
        public async Task<IActionResult> GetUser(Guid boardId, Guid userId)
        {
            var users = await scrumRepository.GetUsersFromBoard(boardId);
            var user = users.FirstOrDefault(u => u.Id == userId);
            return Ok(user);
        }

        [HttpPut("boards/{boardId}/users")]
        public async Task<IActionResult> UpdateUser(Guid boardId, User user)
        {
            var isUpdated = 
                await scrumRepository.UpdateUserPoint(boardId, user.Id, user.Point);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));

            return Ok(isUpdated);
        }
    }
}

如果你注意到,我们的控制器通过依赖注入在其构造函数中请求 IHubContext<ScrumBoardHub>。当用户添加到板上、用户提交其分数或管理员清除所有用户提交的分数时,此上下文类将用于通知组中的所有连接的客户端。SendAsync 方法会发送通知以及更新的用户列表给客户端。在这里,消息 ‘UsersAdded’ 可能具有误导性,但它可以是任何你喜欢的名称,只需记住 React 应用会使用此消息执行某些操作,因此请确保与 React 应用保持同步。

启用 CORS

启动 SignalR 连接的请求会被 CORS 策略阻止,因此我们需要配置我们的 ASP.NET 以允许来自 React 应用的请求,因为它们将托管在不同的域上。

ConfigureServices 方法

services.AddCors(options =>
                options.AddPolicy("CorsPolicy",
                    builder =>
                        builder.AllowAnyMethod()
                        .AllowAnyHeader()
                        .WithOrigins("https://:3000")
                        .AllowCredentials()));

Configure 方法

app.UseCors("CorsPolicy");

前端代码

我们将为板创建、用户个人资料创建、仪表板、用户列表、标题、导航等创建单独的组件。但重要的是,我们将把 SignalR 客户端逻辑保留在 UserList 组件中,因为每当其他用户执行某些活动时,都需要刷新用户列表。

让我们编写 SignalR 连接代码,但在此之前,我们需要在我们的 React 应用中添加 SignalR 包。

yarn add @microsoft/signalr

UserList 组件

import React, { useState, useEffect, FC } from 'react';
import { User } from './user/User';
import { UserModel } from '../../models/user-model';
import { useParams } from 'react-router-dom';
import {
  HubConnectionBuilder,
  HubConnectionState,
  HubConnection,
} from '@microsoft/signalr';
import { getBoardUsers } from '../../api/scrum-poker-api';

export const UserList: FC<{ state: boolean }> = ({ state }) => {
  const [users, setUsers] = useState<UserModel[]>([]);
  const { id } = useParams();
  const boardId = id as string;
  useEffect(() => {
    if (users.length === 0) {
      getUsers();
    }
    setUpSignalRConnection(boardId).then((con) => {
      //connection = con;
    });
  }, []);

  const getUsers = async () => {
    const users = await getBoardUsers(boardId);
    setUsers(users);
  };

  const setUpSignalRConnection = async (boardId: string) => {
    const connection = new HubConnectionBuilder()
      .withUrl('https://:5001/scrumboardhub')
      .withAutomaticReconnect()
      .build();

    connection.on('Message', (message: string) => {
      console.log('Message', message);
    });
    connection.on('UsersAdded', (users: UserModel[]) => {
      setUsers(users);
    });

    try {
      await connection.start();
    } catch (err) {
      console.log(err);
    }

    if (connection.state === HubConnectionState.Connected) {
      connection.invoke('SubscribeToBoard', boardId).catch((err: Error) => {
        return console.error(err.toString());
      });
    }

    return connection;
  };
  return (
    <div className="container">
      {users.map((u) => (
        <User key={u.id} data={u} hiddenState={state}></User>
      ))}
    </div>
  );
};

我们创建了 setUpSignalRConnection 方法,该方法使用 HubConnectionBuilder 创建连接。它还监听来自服务器的 ‘UserAdded’ 消息,并决定收到服务器发来的消息+payload 时该做什么。它基本上是用服务器发送的更新数据刷新用户列表。

在我们的 React 应用中,我们有不同的组件,但它们都非常简单易懂,因此我在这里不再赘述。

结论

设置 SignalR 与 React 集成并为我们的应用提供实时功能非常容易。我只提到了设置 SignalR 所需的重要步骤。你可以查看完整的源代码来理解所有零散的部分如何协同工作。当然,我们可以在应用中进行改进,例如可以使用 Redux 来实现组件之间的通信。

历史

  • 2020年4月7日:文章发布
© . All rights reserved.