六、使用 Socket.IO 构建聊天室应用

在本章中,我们将介绍如何使用 Socket 构建 Angular 聊天室应用。 IO,以便深入研究无需建立 REST api 或使用 GraphQL 查询就能在客户机和服务器之间来回发送消息的能力。 我们将要使用的技术包括建立从客户机到服务器的长时间连接,使通信像传递消息一样简单。

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

  • 使用 Socket 进行长时间运行的客户机/服务器通信。 IO
  • 创建一个套接字。 IO 服务器
  • 创建 Angular 客户端并添加 Socket。 IO 支持
  • 使用装饰器添加客户端日志记录
  • 在我们的客户端使用 Bootstrap
  • 添加引导导航
  • 注册到 Auth0 来验证我们的客户端
  • 添加 Auth0 支持到我们的客户端
  • 添加安全的 Angular 路由
  • 连接到 Socket。 我们的客户端和服务器的 IO 消息
  • 使用套接字。 分隔消息的 IO 名称空间
  • 添加房间支持
  • 接收和发送信息

技术要求

完成的项目可从https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter06下载。

下载项目后,您必须使用npm install命令安装软件包要求。

使用 Socket 进行长时间运行的客户机/服务器通信。 IO

到目前为止,我们已经介绍了客户机和服务器之间来回通信的各种方式,但它们都有一个共同点——它们都对某种交互形式做出反应,以触发数据传输。 不管我们是否点击了链接或按下了按钮,都有一些用户输入触发了这一过程。

然而,在某些情况下,我们希望保持客户机和服务器之间的通信线路永久打开,以便数据可以在可用时立即推送。 例如,如果我们正在玩一款在线游戏,我们就不希望为了更新屏幕上其他玩家的状态而去按一个按钮。 我们需要的是一种技术,它能帮我们保持联系,让我们可以毫无问题地传递信息。

在过去的几年里,已经发展了许多技术来解决这个问题。 其中一些技术,如闪存插座,已经不再受欢迎,因为它们依赖于专有系统。 总的来说,这些技术被称为push 技术,一种叫做WebSocket的标准已经出现并变得普遍,所有主流浏览器都支持它。 值得知道的是,WebSocket 与 HTTP 是一个合作协议。

Here is a piece of WebSocket trivia for you. While HTTP uses HTTP or HTTPS to identify the protocol, the specification for WebSockets defines WS or WSS (short for WebSocket Secure) as the protocol identifier.

在节点世界中,Socket。 IO 已经成为 WebSocket 通信的事实上的标准。 我们将使用它来构建一个聊天室应用,为所有连接的用户保持聊天开放。

项目概述

经典的基于套接字的应用正在创建一个聊天室。 它几乎是套接字应用的Hello World。 聊天室之所以如此有用,是因为它允许我们探索一些技术,比如向其他用户发送消息、对来自其他用户的消息作出反应,以及使用房间来区分聊天的发送位置。

在过去的几章中,材料设计在它的开发中扮演了重要的角色,所以现在是时候回到 Bootstrap 4,看看如何在 Angular 应用中使用它来布局接口了。 我们还将使用 Socket。 客户端和服务器端的 IO,以实现双向通信。 在前几章中缺少的是用户身份验证的能力。 在本章中,我们将通过注册使用 Auth0(https://auth0.com/)来引入认证支持。

与 GitHub 代码一起工作,本章应该需要大约两个小时来完成。 填妥后,申请书应显示如下:

现在,我们知道了要构建的应用的类型,以及它的外观,就可以开始构建应用了。 在下一节中,我们将了解如何使用 Auth0 向应用添加外部身份验证。

开始使用 Socket。 IO 和角

大多数需求,如 Node.js 和 Mongoose,与前面章节相同,所以我们不再列出额外的组件。 在我们浏览本章的过程中,我们将调用我们需要的任何新组件。 和往常一样,要想知道我们在使用什么,最规范的地方就是 GitHub 中的代码。

作为本章的一部分,我们将使用 Auth0(https://auth0.com)来验证我们的用户。 Auth0 是最受欢迎的身份验证选择之一,因为它负责所有的基础设施。 我们需要提供的只是一个安全的登录和信息存储机制。 使用 Auth0 我们背后的想法是,我们将利用他们的 api 来验证身份的人使用我们的应用通过使用打开认证(OAuth)框架,它允许我们自动显示或隐藏我们的应用的访问部分在此基础上验证。 对于 OAuth 及其后续的 OAuth 2,我们使用了一个标准的授权协议,允许经过身份验证的用户访问我们的应用的特性,而无需在我们的站点上注册并提供登录信息。

Initially, this chapter was going to use a passport to provide authentication support but, given recent high-profile security issues from companies such as Facebook, I decided that we would look at Auth0 to take care of and manage our authentication. With authentication, I find that it's best to make sure I'm using the best of breed when it comes to security.

在我们编写任何代码之前,我们要先注册到 Auth0,并创建一个单页 web 应用所需的基础设施。 首先点击注册按钮,这将重定向到以下 URL:https://auth0.com/signup? &signUpData=%7B%22category%22 3A%22button%22 7D。 我选择用我的 GitHub 账户注册,但你可以选择任何可用的选项。

Auth0 provides us with a variety of premium paid-for services as well as the free version. We only require the basic features, so the free version is perfect for our needs.

注册完成后,需要按下 Create Application 按钮,这将弹出 Create Application 对话框。 给它一个名字,选择 Single Page Web App,然后点击 CREATE 按钮来创建 Auth0 应用:

如果你点击设置选项卡,你会看到如下内容:

有回调 url、允许的 web 源、注销 url、CORS 等选项。

关于 Auth0 的全部内容超出了本书的范围,但我建议阅读所提供的文档,并为您创建的任何应用设置这些设置。

Security note: Where I am providing details about client IDs or similar unique identifiers in this book, these are purely for the purpose of illustrating the code. Any live IDs will be deactivated as a matter of security. I would advise you to adopt similar good practices and not commit live identifiers or passwords in a public location such as GitHub.

使用 Socket 创建聊天室应用。 IO、Angular 和 Auth0

在我们开始开发之前,我们应该弄清楚我们想要构建什么。 由于聊天室是一个非常常见的应用,因此我们很容易提出一组标准的需求,这些需求将帮助我们练习 Socket.IO 的不同方面。 我们要构建的应用的要求如下:

  • 用户可以在普通聊天页面发送消息给所有用户
  • 用户将能够登录到应用,此时将有一个安全页面可用
  • 已登录用户将能够发送仅对其他已登录用户可见的消息
  • 当用户连接时,旧消息将被检索并显示给用户

创建应用

到目前为止,创建节点应用应该是很自然的事情,所以我们不再讨论如何创建节点应用。 我们将要使用的tsconfig文件如下:

{
  "compileOnSave": true,
  "compilerOptions": {
    "incremental": true,
    "target": "es5",
    "module": "commonjs",
    "outDir": "./dist",
    "removeComments": true,
    "strict": true,
    "esModuleInterop": true,
    "inlineSourceMap": true,
    "experimentalDecorators": true,
  }
}

The incremental flag in the settings is a new feature introduced in TypeScript 3.4 that allows us to perform incremental builds. What this feature does is build something called a project graph when the code is compiled. The next time the code is built, the project graph is used to identify code that hasn't changed, meaning that it doesn't need to be rebuilt. In bigger applications, this can save a lot of time in terms of compiling.

我们将把消息保存到数据库中,所以我们将从数据库连接代码开始,这一点也不奇怪。 在这种情况下,我们要做的是将数据库连接移动到一个接受数据库名称作为装饰器工厂参数的类装饰器:

export function Mongo(connection: string) {
  return function (constructor: Function) {
    mongoose.connect(connection, { useNewUrlParser: true}, (e:unknown) => {
      if (e) {
        console.log(`Unable to connect ${e}`);
      } else {
        console.log(`Connected to the database`);
      }
    });
  }
}

Don't forget to install mongoose and @types/mongoose before creating this.

有了这个,当我们创建我们的server类时,我们只需要简单地装饰它,像这样:

@Mongo('mongodb://localhost:27017/packt_atp_chapter_06')
export class SocketServer {
}

就是这样。 当实例化SocketServer时,数据库将自动连接。 我不得不承认,我真的很喜欢这种方法的简单性。 这是一种优雅的技术,可以应用到其他应用中。

在前一章中,我们构建了一个DataAccessBase类来简化我们处理数据的方式。 我们将取那个类并删除一些我们不会在这个应用中使用的方法。 同时,我们将看到如何消除硬模型约束。 让我们从类定义开始:

export abstract class DataAccessBase<T extends mongoose.Document>{
  private model: Model;
  protected constructor(model: Model) {
    this.model = model;
  }
}

Add方法也应该与前一章相似:

Add(item: T): Promise<boolean> {
  return new Promise<boolean>((callback, error) => {
    this.model.create(item, (err: unknown, result: T) => {
      if (err) {
        error(err);
      }
      callback(!result);
    });
  });
}

在前一章中,我们有一个约束,即查找记录需要有一个名为Id的字段。 虽然这是一个可以接受的限制,但我们真的不想强迫应用将Id作为一个字段。 我们将提供一个更开放的实现,它将允许我们指定检索记录所需的任何标准,以及选择返回哪些字段的能力:

GetAll(conditions: unknown, fields: unknown): Promise<unknown[]> {
  return new Promise<T[]>((callback, error) => {
    this.model.find(conditions, fields, (err: unknown, result: T[]) => {
      if (err) {
        error(err);
      }
      if (result) {
        callback(result);
      }
    });
  });
}

就像在前一章中一样,我们将创建一个基于mongoose.Document的接口和一个Schema类型。 这将形成消息契约,并存储关于房间、消息文本和我们收到消息时的日期的详细信息。 然后将它们组合起来创建我们需要用作数据库的物理模型。 让我们来看看:

  1. 首先,我们定义mongoose.Document实现:
export interface IMessageSchema extends mongoose.Document{
  room: string;
  messageText: string;
  received: Date;
}
  1. 对应的Schema类型如下所示:
export const MessageSchema = new Schema({
  room: String,
  messageText: String,
  received: Date
});
  1. 最后,我们创建一个MessageModel实例,我们将使用它来创建数据访问类,我们将使用它来保存和检索数据:
export const MessageModel = mongoose.model<IMessageSchema>('message', MessageSchema, 'messages', false);
export class MessageDataAccess extends DataAccessBase<IMessageSchema> {
  constructor() {
    super(MessageModel);
  }
}

添加插座。 IO 支持到我们的服务器

现在我们已经准备好引入 Socket 了。 IO 进入我们的服务器,并创建一个运行的服务器实现。 运行以下命令合并 Socket。 IO 及相关DefinitelyTyped定义:

npm install --save socket.io @types/socket.io

有了这些可用的定义,我们将引入 Socket。 IO 支持到我们的服务器,并开始运行它,准备接收和发送消息:

export class SocketServer {
  public Start() {
    const appSocket = socket(3000);
    this.OnConnect(appSocket);
  }

  private OnConnect(io: socket.Server) {
  }
}
new SocketServer.Start();

我们的OnConnect方法接收的参数是 Socket.IO 中接收和响应消息的起始点。 我们使用它监听,以获取表明客户端已连接的连接消息。 当客户端连接时,它为我们打开了相当于套接字的东西,我们可以在这个套接字上开始接收和发送消息。 当我们想要将消息直接发送到特定的客户端时,我们将使用以下代码片段中返回的socket中可用的方法:

io.on('connection', (socket:any) => {
});

At this point, we need to understand that even though the name of the technology is Socket.IO, this is not a WebSocket implementation. While it can use web sockets, there is no guarantee that it actually will; for instance, corporate policies might prohibit the use of sockets. So, how does Socket.IO actually work? Well, Socket.IO is made up of a number of different cooperating technologies, one of which is called Engine.IO, and this provides the underlying transport mechanism. The first type of connection it takes, when connecting, is an HTTP long poll, which is a fast and efficient transport mechanism to open. During idle periods, Socket.IO attempts to determine whether the transport can be changed over to a socket and, if it can use a socket, it seamlessly and invisibly upgrades the transport to use sockets. As far as the client is concerned, they connect quickly, and messages are reliable since the Engine.IO part establishes connections even if firewalls and load balancers are present.

我们想为客户提供的其中一件事是事先进行的对话记录。 这意味着我们希望读取消息并将其保存到数据库中。 在我们的连接中,我们将读取用户当前所在房间的所有消息,并将它们返回给用户。 如果用户没有登录,他们只能看到没有设置房间的信息:

this.messageDataAccess.GetAll({room: room}, {messageText: 1, _id: 0}).then((msgs: string[]) =>{
  socket.emit('allMessages', msgs);
});

语法看起来有点奇怪,所以我们将逐步分解它。 对GetAll的调用是从DataAccessBase类中调用通用GetAll方法。 在创建该实现时,我们讨论了使其更通用的必要性,并允许调用代码指定要过滤哪些字段以及要返回哪些字段。 当我们说{room: room}时,我们是在告诉 Mongo,我们想根据房间来过滤我们的结果。 我们可以把等价的 SQL 子句看作是WHERE room = roomVariable。 我们还想指出我们想要返回的结果; 在本例中,我们只需要messageText而不需要_id字段,所以我们使用{messageText: 1, _id: 0}语法。 当结果返回时,我们需要使用socket.emit将消息数组发送给客户端。 该命令使用allMessages作为键,将这些消息发送到打开连接的客户端。 如果客户端有接收allMessages的代码,它将能够对这些消息作出反应。

The event name that we choose as the message leads us on to one of the limitations of Socket.IO. There are certain event names that we cannot use as a message because they have been restricted due to them having a special meaning to Socket.IO. These are error, connect, disconnect, disconnecting, newListener, removeListener, ping, and pong.

如果我们在客户端没有任何东西来接收消息,那么创建服务器并发送消息就没有什么意义了。 尽管我们还没有准备好所有的消息,但是我们已经准备好了足够的基础设施来开始编写客户机。

创建我们的聊天室客户端

同样,我们将使用ng new命令创建 Angular 应用。 我们将提供路由支持,但当我们开始做路由部分时,我们将看到如何确保用户不能绕过我们的身份验证:

ng new Client --style scss --prefix atp --routing true

因为我们的 Angular 客户端会经常使用 Socket。 IO,我们将引入对 Socket 的支持。 使用特定于 angular 的 Socket 进行 IO。 IO 模块:

npm install --save ngx-socket-io

app.module.ts中,我们将创建一个到 Socket 的连接。 IO 服务器创建一个指向服务器 URL 的配置:

import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
const config: SocketIoConfig = { url: 'http://localhost:3000', options: {}}

当我们导入模块时,将此配置传递给静态SocketIoModule.forRoot方法,该方法将为我们配置客户端套接字。 一旦我们的客户端启动,它就会建立一个连接,触发我们在服务器代码中描述的连接消息序列:

imports: [
  BrowserModule,
  AppRoutingModule,
  SocketIoModule.forRoot(config),

使用装饰器添加客户端日志记录

我们希望在客户端代码中使用的特性之一是能够记录方法调用,以及传递给它们的参数。 我们以前在创建装饰器时遇到过这种类型的特性。 在这个例子中,我们想要创建一个Log装饰器:

export function Log() {
  return function(target: Object,
                  propertyName: string,
                  propertyDesciptor: PropertyDescriptor): PropertyDescriptor {
    const method = propertyDesciptor.value;
    propertyDesciptor.value = function(...args: unknown[]) {
      const params = args.map(arg => JSON.stringify(arg)).join();
      const result = method.apply(this, args);
      if (args && args.length > 0) {
        console.log(`Calling ${propertyName} with ${params}`);
      } else {
        console.log(`Calling ${propertyName}. No parameters present.`)
      }
      return result;
    };
    return propertyDesciptor;
  }
}

Log装饰器的工作方式是,它从propertyDescriptor.value复制方法开始。 然后,我们通过创建一个函数来替换这个方法,该函数接受传递给该方法的任何参数。 在这个内部函数中,我们使用args.map创建参数和值的字符串化表示,然后将它们连接在一起。 在调用method.apply来运行该方法之后,我们将与该方法和参数有关的详细信息写入控制台。 通过前面的代码,我们现在有了一个简单的机制,可以通过使用@Log自动记录方法和参数。

在 Angular 中设置引导

除了在 Angular 中使用 Material,我们还可以选择使用 Bootstrap 来设置页面的样式。 添加支持是一项非常简单的任务。 像往常一样,我们首先安装相关的软件包。 在这种情况下,我们将安装 Bootstrap:

npm install bootstrap --save

一旦我们安装了 Bootstrap,我们只需要在angular.jsonstyles部分添加一个 Bootstrap 的引用,如下所示:

"styles": [
  "src/styles.scss",
  "node_modules/bootstrap/dist/css/bootstrap.min.css"
],

在适当的位置,我们将创建一个navigation条,将位于我们的页面的顶部:

ng g c components/navigation

在我们添加navigation组件主体之前,我们应该替换app.component.html文件的内容,以便它能在每个页面上提供导航:

<atp-navigation></atp-navigation>
<router-outlet></router-outlet>

引导导航

Bootstrap 提供了nav组件,我们可以添加navigation组件。 在其中,我们将创建一系列链接。 和上一章一样,我们将使用routerLink来说明 Angular 应该路由到什么:

<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
  <a class="navbar-brand" href="#">Navbar</a>
  <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
    <div class="navbar-nav">
      <a class="nav-item nav-link active" routerLink="/general">General</a>
      <a class="nav-item nav-link" routerLink="/secret" *ngIf="auth.IsAuthenticated">Secret</a>
      <a class="nav-item nav-link active" (click)="auth.Login()" routerLink="#" *ngIf="!auth.IsAuthenticated">Login</a>
      <a class="nav-item nav-link active" (click)="auth.Logout()" routerLink="#" *ngIf="auth.IsAuthenticated">Logout</a>
    </div>
  </div>
</nav>

关于路由的有趣之处在于使用身份验证来显示和隐藏链接。 如果用户通过了身份验证,我们希望他们能够看到 Secret 和 Logout 链接。 如果用户没有通过身份验证,我们希望他们看到 Login 链接。

在导航栏中,我们可以看到许多 auth 引用。 在幕后,这些都映射回OauthAuthorizationService。 当我们在本章开始时注册到 Auth0 时,我们使用了这个。 现在,是时候添加将用户连接到 Auth0 的授权服务了。

使用 Auth0 对用户进行授权和认证

我们的授权将由两部分组成—执行授权的服务和简化授权工作的模型。 我们将首先创建我们的Authorization模型,其中包含从成功登录返回的详细信息。 注意,构造函数引入了Socket实例:

export class Authorization {
  constructor(private socket: Socket);
  public IdToken: string;
  public AccessToken: string;
  public Expired: number;
  public Email: string;
}

我们可以使用它来创建一系列有用的帮助器方法。 我们要创建的第一个方法是在用户登录时设置公共属性。 我们将成功登录识别为我们接收一个访问令牌和一个 ID 令牌作为结果的一部分:

@Log()
public SetFromAuthorizationResult(authResult: any): void {
  if (authResult && authResult.accessToken && authResult.idToken) {
    this.IdToken = authResult.idToken;
    this.AccessToken = authResult.accessToken;
    this.Expired = (authResult.expiresIn * 1000) + Date.now();
    this.Email = authResult.idTokenPayload.email;
    this.socket.emit('loggedOn', this.Email);
  }
}

当用户登录时,我们将向服务器发送一条loggedOn消息,并传递Email地址。 当我们讨论向服务器发送消息和处理返回的响应时,我们将很快回到这条消息。 注意,我们正在记录方法和属性。

当用户退出时,我们希望清除这些值并向服务器发送loggedOff消息:

@Log()
public Clear(): void {
  this.socket.emit('loggedOff', this.Email);
  this.IdToken = '';
  this.AccessToken = '';
  this.Expired = 0;
  this.Email = '';
}

最后一个助手通过检查AccessToken字段是否存在,以及票证的到期日期是否超过我们执行检查的时间,来告诉我们用户是否已经通过了身份验证:

public get IsAuthenticated(): boolean {
  return this.AccessToken && this.Expired > Date.now();
}

在我们创建我们的OauthAuthorizationService服务之前,我们需要一些与 Auth0 通信的方式,所以我们将引入对它的支持:

npm install --save auth0-js

在适当的位置,我们添加一个引用auth0.js作为script标签:

<script type="text/javascript" src="node_modules/auth0-js/build/auth0.js"></script>

现在我们已经具备了创建服务的所有要素:

ng g s services/OauthAuthorization

我们的服务从一开始就很简单。 当我们构造服务时,我们实例化了刚刚创建的 helper 类:

export class OauthAuthorizationService {
  private readonly authorization: Authorization;
  constructor(private router: Router, private socket: Socket) {
    this.authorization = new Authorization(socket);
  }
}

现在我们准备好连接到 Auth0 了。 你可能还记得,当我们注册到 Auth0 时,我们得到了一系列设置。 在设置中,我们需要客户端 ID 和域。 我们将在从auth0-js实例化WebAuth时使用这些参数,以便唯一地标识我们的应用。 responseType告诉我们,在成功登录后,需要返回用户的身份验证令牌和 ID 令牌。 告诉用户当他们登录时我们想要访问哪些功能。 例如,如果我们想要配置文件,我们可以将范围设置为openid email profile。 最后,我们提供redirectUri告诉 Auth0 我们想回到什么页面以下成功登录:

auth0 = new auth0.WebAuth({
  clientID: 'IvDHHA20ZKx7zvUQWNPrMy15vLTsFxx4',
  domain: 'dev-gdhoxa3c.eu.auth0.com',
  responseType: 'token id_token',
  redirectUri: 'http://localhost:4200/callback',
  scope: 'openid email'
});

redirectUri must match precisely what is contained in the Auth0 settings section. I prefer to set it to a page that doesn't exist on the site and control the redirection manually, so callback is a useful one for me because I can apply conditional logic to determine the page the user is redirected to if needs be.

现在,我们可以添加我们的Login方法。 使用authorize方法加载认证页面:

@Log()
public Login(): void {
  this.auth0.authorize();
}

注销就像调用 helper 类上的logout,然后调用Clear来重置过期点和清除其他属性一样简单:

@Log()
public Logout(): void {
  this.authorization.Clear();
  this.auth0.logout({
    return_to: window.location.origin
  });
}

显然,我们需要一种检查身份验证的方法。 下面的方法在 URL 哈希中检索身份验证,并使用parseHash方法解析它。 如果身份验证不成功,用户将被重定向回通用页面,该页面不需要登录。 另一方面,如果用户成功地通过了身份验证,则用户被定向到一个只有经过身份验证的用户可用的秘密页面。 注意,我们正在调用前面编写的SetFromAuthorizationResult方法来设置访问令牌、过期时间等:

@Log()
public CheckAuthentication(): void {
  this.auth0.parseHash((err, authResult) => {
    if (!err) {
      this.authorization.SetFromAuthorizationResult(authResult);
      window.location.hash = '';
      this.router.navigate(['/secret']);
    } else {
      this.router.navigate(['/general']);
      console.log(err);
    }
  });
}

当用户返回站点时,最好让他们再次访问,而不需要他们重新验证自己。 下面的Renew方法检查他们的会话,如果他们成功,重置他们的身份验证状态:

@Log()
public Renew(): void {
  this.auth0.checkSession({}, (err, authResult) => {
    if (authResult && authResult.accessToken && authResult.idToken) {
      this.authorization.SetFromAuthorizationResult(authResult);
    } else if (err) {
      this.Logout();
    }
  });
}

这些代码都很好,但是我们在哪里使用它呢? 在app.component.ts中,我们引入了我们的授权服务,检查用户身份验证:

constructor(private auth: OauthAuthorizationService) {
  this.auth.CheckAuthentication();
}

ngOnInit() {
  if (this.auth.IsAuthenticated) {
    this.auth.Renew();
  }
}

不要忘记添加一个参考到NavigationComponent,以连接OauthAuthorizationService:

constructor(private auth: OauthAuthorizationService) {
}

使用安全路由

身份验证就绪后,我们希望确保用户不能仅仅通过输入页面的 URL 来绕过它。 如果用户可以很容易地绕过它,特别是在我们费尽心思提供安全授权之后,我们不会设置太多的安全设置。 我们要做的是放置另一个服务,路由器将使用它来确定它是否可以激活路由。 首先,我们创建如下服务:

ng g s services/Authorization

服务本身将实现CanActivate接口,路由器将使用该接口来确定路由是否可以被激活。 这个服务的构造函数简单地接收路由器和我们的OauthAuthorizationService服务:

export class AuthorizationService implements CanActivate {
  constructor(private router: Router, private authorization: OauthAuthorizationService) {}
}

canActivate签名的样板代码看起来比我们所需要的要复杂得多。 这里我们真正要做的是检查身份验证状态,如果用户没有经过身份验证,我们将把用户重新路由回常规页面。 如果用户通过了认证,我们返回true,用户继续进入安全页面:

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
  Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
  if (!this.authorization.IsAuthenticated) {
    this.router.navigate(['general']);
    return false;
  }
  return true;
}

我们有两条路径要遵循,就像我们在导航链接中看到的那样。 在我们添加路由之前,让我们创建将要显示的组件:

ng g c components/GeneralChat
ng g c components/SecretChat

最后,我们已经到了要把路线连接起来的地方。 正如我们在前一章中看到的,添加路由很简单。 我们要加的秘密酱是canActivate。 在我们的路由中,用户不能绕过我们的身份验证:

const routes: Routes = [{
  path: '',
  redirectTo: 'general',
  pathMatch: 'full'
}, {
  path: 'general',
  component: GeneralchatComponent
}, {
  path: 'secret',
  component: SecretchatComponent,
  canActivate: [AuthorizationService]
}];

Even though we have to supply a callback URL in our Auth0 configuration, we don't include it in our routes because we want to control the page—we do it to navigate to and from our authorization service.

此时,我们希望开始从客户机写入消息到服务器,并从服务器接收消息。

添加客户端聊天功能

在编写身份验证代码时,我们严重依赖于将服务放在适当的位置来处理它。 以类似的方式,我们将提供一个聊天服务,它提供客户端套接字消息传递的中心点:

ng g s services/ChatMessages

不出所料,该服务也将在构造函数中包含Socket:

export class ChatMessagesService {
  constructor(private socket: Socket) { }
}

当我们从客户端向服务器发送消息时,我们在套接字上使用emit方法。 我们想从用户发送的文本将通过message键发送过来:

public SendMessage = (message: string) => {
  this.socket.emit('message', message);
};

在房间工作

在套接字。 在 IO 中,我们使用房间来隔离消息,将它们只发送给特定的用户。 当客户端加入一个房间时,发送到该房间的任何消息都是可用的。 一个有用的思考方法是把这些房间想象成一个关着门的房子里的房间。 当有人想告诉你一件事的时候,他们必须和你在一个房间里才能告诉你。

我们的普通联系和秘密联系都会在房间里联系。 通用页面将使用等同于默认套接字的空白房间名称。 IO 的房间。 该秘密链接将加入一个名为secret的房间,因此任何发送到secret的消息将自动出现在该页面上的任何用户。 为了简化我们的工作,我们将从客户端到服务器端为emit``joinRoom方法提供一个辅助方法:

private JoinRoom = (room: string) => {
  this.socket.emit('joinRoom', room);
};

当我们加入一个房间,任何信息,我们发送使用socket.emit自动发送到正确的房间。 自从 Socket 以来,我们不需要做任何聪明的事情。 IO 会自动为我们处理这个。

得到的消息

对于一般消息和秘密消息页面,我们将获得相同的数据。 我们将使用 RxJS 创建一个可观察对象,它封装了从服务器获取单个消息以及从服务器获取当前发送的所有消息。

根据传入的房间字符串,GetMessages方法可以加入一个仅供登录用户使用的秘密房间,也可以加入对所有用户可用的通用房间。 加入房间后,我们返回一个Observable实例,在其中,对一个特定的事件,我们作出反应。 在接收到单个消息的情况下,我们调用Observable实例的next方法。 这将被客户端组件订阅,客户端组件将把它写出来。 同样,我们也在套接字上订阅allMessages,以便在加入房间时接收之前发送的所有消息。 同样,我们遍历消息并使用next将消息写出来。

这个部分我最喜欢的部分是fromEvent。 这与userLogOn消息的socket.on方法是同义的,并允许我们写出关于谁在会话期间登录的详细信息:

public GetMessages = (room: string) => {
  this.JoinRoom(room);
  return Observable.create((ob) => {
    this.socket.fromEvent<UserLogon>('userLogOn').subscribe((user:UserLogon) => {
      ob.next(`${user.user} logged on at ${user.time}`);
    });
    this.socket.on('message', (msg:string) => {
      ob.next(msg);
    });
    this.socket.on('allMessages', (msg:string[]) => {
      msg.forEach((text:any) => ob.next(text.messageText));
    });
  });
}

到目前为止,在使用术语消息事件来帮助阅读本章时,我已经相当松散了。 在这个例子中,它们指的是同一件事。

完成服务器 sockets

在添加实际的组件实现之前,我们将添加服务器端套接字行为的其余部分。 您可能还记得,我们添加了读取所有历史记录并将它们发送回新连接的客户端的功能:

socket.on('joinRoom', (room: string) => {
  if (lastRoom !== '') {
    socket.leave(lastRoom);
  }
  if (room !== '') {
    socket.join(room);
  }
  this.messageDataAccess.GetAll({room: room}, {messageText: 1, _id: 0}).then((msgs: string[]) =>{
    socket.emit('allMessages', msgs);
  });
  lastRoom = room;
});

我们在这里看到的是服务器对来自客户端的joinRoom做出的反应。 当我们收到这个事件时,如果最后一个房间已经设置好了,我们就离开它,然后加入从客户端传递过来的房间; 同样,只有当它已经设定好了。 这允许我们获得所有的记录,然后emit它们回到当前套接字连接上。

当客户端向服务器发送message事件时,我们要将消息写入数据库,以便稍后可以检索到:

socket.on('message', (msg: string) => {
  this.WriteMessage(io, msg, lastRoom);
});

这个方法首先将消息保存到数据库。 如果房间设置好了,我们使用io.sockets.inemit消息给房间中所有活跃的客户。 如果没有房间设置,我们想将消息发送到通用页的所有客户端,使用io.emit:

private WriteMessage(io: socket.Server, message: string, room: string) {
  this.SaveToDatabase(message, room).then(() =>{
    if (room !== '') {
      io.sockets.in(room).emit('message', message);
      return;
    }
    io.emit('message', message);
  });
}

在这里,我们看到了io.socket.的主要区别。 当我们只想将消息发送到当前连接的客户端时,我们使用socket部分。 当我们需要将消息发送给更多的客户时,我们使用io部分。

保存消息就像做下面这样简单:

private async SaveToDatabase(message: string, room: string) {
  const model: IMessageSchema = <IMessageSchema>{
    messageText: message,
    received: new Date(),
    room: room
  };
  try{
    await this.messageDataAccess.Add(model);
  }catch (e) {
    console.log(`Unable to save ${message}`);
  }
}

Something you might be asking yourself is why we allocate the date on the server rather than when we create the message at the client end. When we are running the client and the server on the same machine, it doesn't really matter which way we do it, but when we build distributed systems, we should always refer to a centralized time. Use of the centralized date and time means that events from all over the world will be coordinated as the same time zone.

在客户端,我们对一个稍微复杂一点的登录事件作出了反应。 当我们接收到loggedOn事件时,我们创建如下对等的服务器端事件,并将其发送给在秘密房间中监听的任何人:

socket.on('loggedOn', (msg: any) => {
  io.sockets.in('secret').emit('userLogOn', { user: msg, time: new Date() });
});

我们现在已经有了客户端基础设施,服务器也已经完成。 我们现在需要做的就是添加服务器端组件。 从功能上讲,由于GeneralChatSecretChat组件几乎是相同的(唯一的区别是他们正在听的房间),我们将集中在其中一个。

名称空间在套接字。 IO

假设我们正在编写一个服务器,它可以被任意数量的客户端应用使用,而这些客户端应用可以使用任意数量的其他 Socket。 IO 服务器也是如此。 如果我们使用与来自另一个 Socket 的消息相同的消息名,那么可能会给客户端应用引入错误。 IO 服务器。 为了规避这个问题,Socket。 IO 使用了一个叫做命名空间的概念来分隔消息,这样它们就不会与其他应用发生冲突。

命名空间是一种方便的方式来提供唯一的端点来连接,我们使用如下代码来连接它:

const socket = io.of('/customSocket');
socket.on('connection', function(socket) {
  ...
});

这段代码看起来应该很熟悉,因为除了io.of(...)之外,它与我们以前用于连接套接字的代码相同。 令人惊讶的是,我们的代码已经使用了名称空间,尽管我们自己没有指定它。 除非我们自己指定一个名称空间,否则套接字将连接到默认名称空间,这相当于io.of('/)

When coming up with a name for your namespace, try to think of something that would be unique and meaningful. One standard I have seen adopted in the past utilizes the company name and the project to create the namespace. So, if your company was called WonderCompany and you were working on Project Antelope, you might use /wonderCompany_antelope as the namespace. Don't just assign random characters since they are hard for people to remember, and this would increase the possibility that they will make mistakes typing it in, meaning that the sockets would not connect.

用 GeneralchatComponent 完成我们的应用

让我们从添加用于显示消息的 Bootstrap 代码开始。 我们将row消息包装在 Bootstrap 容器中,在本例中更确切地说,是container-fluid。 在我们的组件中,我们将从套接字接收的消息数组中读取消息:

<div class="container-fluid">
  <div class="row">
    <div *ngFor="let msg of messages" class="col-12">
      {{msg}}
    </div>
  </div>
</div>

我们还将在屏幕底部的navigation栏中添加一个文本框。 这与组件中的CurrentMessage域绑定。 我们使用SendMessage()发送消息:

<nav class="navbar navbar-dark bg-dark mt-5 fixed-bottom">
  <div class="navbar-expand m-auto navbar-text">
    <div class="input-group mb-6">
      <input type="text" class="form-control" placeholder="Message" aria-label="Message" 
        aria-describedby="basic-addon2" [(ngModel)]="CurrentMessage" />
      <div class="input-group-append">
        <button class="btn btn-outline-secondary" type="button" (click)="SendMessage()">Send</button>
      </div>
    </div>
  </div>
</nav>

在这个 HTML 后面的组件中,我们需要连接到ChatMessageService。 我们将使用一个Subscription实例,并使用它来填充messages数组:

export class GeneralchatComponent implements OnInit, OnDestroy {
  private subscription: Subscription;
  constructor(private chatService: ChatMessagesService) { }

  CurrentMessage: string;
  messages: string[] = [];
}

当用户输入消息并按下 Send 按钮时,我们将使用聊天服务的SendMessage方法将其发送到服务器。 我们之前做的基础工作在这里开始得到回报:

SendMessage(): void {
  this.chatService.SendMessage(this.CurrentMessage);
  this.CurrentMessage = '';
}

在组件初始化中,我们将从GetMessagessubscribe中检索Observable实例给它。 当消息在这个订阅中出现时,我们把它推到消息中,Angular 绑定的魔力就真正发挥作用了,接口会用最新的消息更新:

ngOnInit() {
  this.subscription = this.chatService.GetMessages('').subscribe((msg: string) =>{
    this.messages.push(msg);
  });
}

注意,GetMessages方法是我们在房间中连接的点。 在SecretchatComponent中,这将变成this.chatService.GetMessages('secret')

我们做的其中一件事是引用订阅。 当我们销毁当前页面时,我们将清理订阅,这样我们就不会泄漏内存:

ngOnDestroy() {
  if (this.subscription) {
    this.subscription.unsubscribe();
  }
}

A final note on this implementation. When we started writing the code here, we had to make a conscious decision about how to populate the current screen with the message when the user pressed Send. Effectively, we had two choices. We could either choose to add the current message to the end of the messages array directly and not send it back from the server to the client, or we could send it to the server and then let the server send it back to us. We could choose either method, so why did I choose to send it to the server and then round trip it back to the client? The answer to this has to do with sequencing. In most chat applications I have used, the messages are seen by each user in exactly the same order. The easiest way to do this is to let the server coordinate the messages for us.

总结

在本章中,我们发现了如何编写代码来建立客户端和服务器之间的永久连接,使我们能够来回传递消息以响应消息。 我们还了解了如何注册到 Auth0,并将其用作应用的身份验证机制。 然后,我们学习了如何编写客户端身份验证。 在花了前几章的时间研究了 Angular 中的 Material 之后,我们回到了使用 Bootstrap,并看到了在 Angular 中使用它是多么简单。

在下一章中,我们将学习如何应用 Bing 地图来创建一个基于地图的自定义应用,让我们可以选择并保存感兴趣的点到一个基于云的数据库中,同时使用基于位置的搜索来检索商业信息。

问题

  1. 我们如何向所有用户发送消息?
  2. 我们如何只向特定房间的用户发送消息?
  3. 我们如何向除发送原始消息的用户外的所有用户发送消息?
  4. 为什么我们不应该使用一个叫做 connect 的消息呢?
  5. Engine.IO 是什么?

在我们的应用中,我们只使用了一个房间。 添加在使用之前不需要用户进行身份验证的其他房间,并添加需要用户进行身份验证的房间。 我们也没有存储发送信息的人的细节。 增强应用以存储这些细节并将它们作为消息的一部分以两种方式传输。

进一步的阅读