[四期 - 3] 探索系列 - Nest 中如何自定义协议?(P3-Client端)

279 阅读11分钟

介绍

从本章节开始(四期),我们将会陆陆续续的介绍一Nestjs的核心原理和底层实现,请大家动动发财的小手关注和订阅哈🤣。欢迎私信我 让我们共同进步

本文介绍了 有关Nestjs在微服务中常见的一些问题,包括自定义的序列化和自定义的协议;有的时候在我们的工作中必不可免的 会遇到与现有的基础设施做集成的场景,而nest官方提供的集成场景和例子相对来说比较的 标准 对于那些 非标 的场景缺少介绍,所以本文补充了这一方面;

本文我们将主要关注client 端的实现,同样的我们也需要不断的去参考Nestjs的源码,以保证我们在执行的路径上是正确且高效的。学会一种思想 💭 "从框架的使用者 变成框架的设计者"

本期仓库:github.com/BM-laoli/ne…

概述

前面的文章我们详细的讨论了 server 是如何运作的,现在我们来讨论 client 如何运作 记住下面的图,它对我们的思路有指导作用

image.png

我们现在需要在 HttpApp中发送请求使其能够送到我们的 microservice ,让其为我们做派发。

观察一下nest 源码的实现

在nest官方文档中 以MQTT为例子,我们看到它需要注入client

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'MATH_SERVICE',
        transport: Transport.MQTT,
        options: {
          url: 'mqtt://localhost:1883',
        }
      },
    ]),
  ]
  ...
})

之后才能够在代码中使用

constructor(
  @Inject('MATH_SERVICE') private client: ClientProxy,
) {}
// 
client.send() 

为了简单起见我们直接在constructor new 也是一样的效果。

再观察其实现,以MQTT为例子,我们发现在 它是通过 ClientsModule.register 注入的(有关注入IOC/DI 我们将不再赘述,在我们之前的文章中已经有详细的说明了),我们可以在源码中找到它的具体的 class

image.png

可以发现我们需要需要继承 ClientProxy ,然后实现它的一些方法,所以我们现在有思路了。

简单的实现一下

  • 通过观察我们已经知道我们需要什么样的东西了

我们将构建如下的目录结构

image.png

import {
  ClientFaye,
  InboundResponseIdentityDeserializer,
  OutboundMessageIdentitySerializer,
} from '@faye-tut/nestjs-faye-transporter';


import { tap, reduce, filter } from 'rxjs/operators';

@Controller()
export class AppController {
  logger = new Logger('AppController');
  client: ClientFaye;

  constructor() {
  // 为了确保 client 和 server 有一致的序列化方式,也为了方便我们观察日志,我们需要吧序列化给弄上去
    this.client = new ClientFaye({
      url: 'http://localhost:8000/faye',
      serializer: new OutboundMessageIdentitySerializer(),
      deserializer: new InboundResponseIdentityDeserializer(),
    });
  }

// 对于ClientFaye Class 我们也大概了解它需要什么样的 “形状”(类型定义)
import { EventEmitter } from 'events';
// 注意这个EventEmitter 是nodejs的lib,为什么要这么做?因为它和 MQTT源码是一致的。

export interface FayeClient extends EventEmitter {
  publish(subject: string, msg?: string | Buffer): void;
  // eslint-disable-next-line @typescript-eslint/ban-types
  subscribe(subject: string, callback: Function): Promise<any>;
  unsubscribe(subject: string): void;
  connect(): void;
  disconnect(): void;
}

  • 具体的类实现
export class ClientFaye {
  private readonly serializer;
  private readonly deserializer;
  protected fayeClient;

  constructor(protected readonly options) {
    this.fayeClient = this.connect();
    this.serializer = options.serializer || new IdentitySerializer();
    this.deserializer =
      options.deserializer || new IncomingResponseDeserializer();
  }
  
  
 public send(pattern, data): Observable<any> {
    return new Observable(observer => {
      return this.handleRequest({ pattern, data }, observer);
    });
 }
 
 public connect() {
    if (this.fayeClient) {
      return this.fayeClient;
    }
    const { url, serializer, deserializer, ...options } = this.options;
    this.fayeClient = new faye.Client(url, options);
    this.fayeClient.connect();
    return this.fayeClient;
  }
  
  public handleRequest(partialPacket, observer): Function {
    const packet = Object.assign(partialPacket, { id: uuid() });
    const serializedPacket = this.serializer.serialize(packet);

    const requestChannel = `${packet.pattern}_ack`;
    const responseChannel = `${packet.pattern}_res`;

    const subscriptionHandler = rawPacket => {
      const message = this.deserializer.deserialize(rawPacket);
      const { err, response, isDisposed } = message;
      if (err) {
        return observer.error(err);
      } else if (response !== undefined && isDisposed) {
        observer.next(response);
        return observer.complete();
      } else if (isDisposed) {
        return observer.complete();
      }
      observer.next(response);
    };

    const subscription = this.fayeClient.subscribe(
      responseChannel,
      subscriptionHandler,
    );

    subscription.then(() => {
      this.fayeClient.publish(requestChannel, serializedPacket);
    });

    return () => {
      this.fayeClient.unsubscribe(responseChannel);
    };
   }
   
}

我们详细的研究的分解一下这个 Class

this.connect() 和 serializer,deserializer 我想我们不必多说 它和server是类似的,在我们的源代码仓库中可以看到他们的定义

我们重点聊聊 send 和 handleRequest subscriptionHandler

  1. send

我们将返回一个rxjs流(冷的Observable,什么?你不知道什么是冷的和热的?好吧😑请你自行补充rxjs知识) , 它会在 外部通过 subscribe 注册回掉 ,而具体的什么时候“执行”,就看 handleRequest 如何处理这个 observer 了。这就是下个方法的内容 ,当我们这样实现之后,你就能够像下面这样的使用的形式调用了

@Get('jobs-stream1/:duration')
  stream(@Param('duration') duration) {
    return this.client.send('/jobs-stream1', duration).pipe(
    // 注册 subscribe
    // do notification 
      tap(step => {
        this.notify(step);
      }),
    );
  }
  1. handleRequest

用于处理客户端发送的请求和处理从 Faye 服务器接收到的响应消息。具体的细节是:

用于处理客户端发送的请求

  • 参数partialPacket 包含了发送请求所需的部分数据,如模式和数据。
  • 构造请求:将 partialPacket 与一个唯一标识符组合,然后使用序列化器对其进行序列化,以准备发送到 Faye 服务器。序列化和反序列化 并且附加了一个id
  • 建立频道:根据模式构造请求频道和响应频道,以便 Faye 服务器可以通过这些频道发送响应。两个 Channel,req和res的
  • 订阅处理程序:创建一个订阅处理程序,负责处理从响应频道收到的消息,并将其转换为 Observable 流。我们指的就是 subscriptionHandler 这个方法
  • 执行请求:使用 Faye 客户端的 publish 方法将序列化后的请求发送到请求频道。
  • 取消订阅:确保在请求处理完成后取消对响应频道的订阅,以释放资源。
  1. subscriptionHandler

用于处理客户端发送的请求和处理从 Faye 服务器接收到的响应消息。具体的细节是:

函数中用于处理订阅响应的回调函数。

  • 参数rawPacket 是从 Faye 服务器接收到的原始消息数据。
  • 解析消息:使用反序列化器将原始消息解析为可处理的格式,其中包括错误信息、响应数据和是否已完成的标志。
  • 处理错误:如果消息中包含错误信息,则调用 Observable 的 error 方法,以将错误传播到 Observable 流中。
  • 处理完成:如果消息表示已完成,并且包含了响应数据,则调用 Observable 的 next 方法传递响应数据,并调用 complete 方法以表示 Observable 流已完成。
  • 取消订阅:如果消息表示已完成但未包含响应数据,则仅调用 complete 方法,表示 Observable 流已完成。

我们需要详细的关注这个核心重点:

  • Observable 生成handleRequest 函数创建了一个 Observable 对象,并在其中定义了订阅处理程序,以便在 Faye 服务器发送响应时触发 Observable 流的更新。
  • 取消订阅机制handleRequest 函数返回一个函数,该函数用于取消对响应频道的订阅。这是为了在 Observable 流结束时释放资源。
  • 消息处理逻辑subscriptionHandler 函数负责解析 Faye 服务器发送的原始消息,并根据消息内容执行适当的操作。这包括处理错误信息、传递响应数据以及标识 Observable 流的完成状态。
  • 回调函数传递subscriptionHandler 函数作为参数传递给 fayeClient.subscribe 方法,以便在接收到响应消息时自动触发该函数。

虽然以上已经可以完成✅必须要的功能了 ,但是它还不完善,有下面的几个缺点

  • 解决多路复用问题
  • 客户端库连接管理
  • 客户端的事件处理

继续完善 这个 class

  • 解决多路复用问题

我们现在虽然实现了 信息 -> 流的转化

image.png

但我们现存的实现有如下的问题

  1. 无法处理同时发送多个请求的情况:在现存实现中,当同时发送多个请求时,由于共享的响应通道没有准备好处理多个重叠的请求,就会出现问题。特别是当第二个请求在第一个请求之前完成时,共享的响应通道无法正确处理这种情况。
  2. 单个响应通道对多个请求的处理不当:由于设计上将每个模式(pattern)绑定到一个响应通道,每次重新发出订阅调用时都会覆盖先前的 Faye 订阅处理程序,这会导致问题。这意味着无论发送多少个请求,每个模式(pattern)只能有一个活动的 Faye 订阅处理程序,这会导致对多个重叠请求的处理不当。

我们的具体解决方案是:

  1. 延迟绑定响应订阅处理程序:通过延迟绑定响应订阅处理程序,使其在请求发出时动态生成一个唯一的可观察订阅函数。具体来说,解决方案是在每次发送请求时创建一个唯一的响应订阅处理程序,并将其与请求关联起来,以便在接收到响应时能够准确地处理该请求的结果。
  2. 使用响应发射器工厂:介绍一下响应发射器工厂的概念,它是一个由框架提供的高阶函数,用于生成响应发射器。响应发射器是一个函数,用于处理实际的观察者发射调用,从而产生可观察流。通过使用响应发射器工厂,可以为每个请求创建一个唯一的响应发射器,从而克服多路复用问题。
  3. 关联标识符:为了将每个请求与其响应关联起来,文章还提到了关联标识符的概念。在请求发出时生成一个唯一的标识符,并将其与响应发射器关联起来。在接收到响应时,使用标识符来检索相应的响应发射器,并使用它来处理该请求的结果。

下面就是代码的实现了,注意我们在观察 nest源码之后 发现 send方法实际上不需要我们重写,我们只需要重写pubsh 方法就好了

public send<TResult = any, TInput = any>(
    pattern: any,
    data: TInput,
  ): Observable<TResult> {
    if (isNil(pattern) || isNil(data)) {
      return _throw(new InvalidMessageException());
    }
    return defer(async () => this.connect()).pipe(
      mergeMap(
        () =>
          new Observable((observer: Observer<TResult>) => {
            const callback = this.createObserver(observer);
            return this.publish({ pattern, data }, callback);
          }),
      ),
    );
  }

我们把 handleRequest 拆成了两个独立的方法

  protected readonly subscriptionsCount = new Map<string, number>();
  protected fayeClient: FayeClient;
  protected connection: Promise<any>;
  /**
   *
   */
  public createSubscriptionHandler(packet: ReadPacket & PacketId): Function {
    return (rawPacket: unknown) => {
      const parsedPacket = this.parsePacket(rawPacket);
      const message = this.deserializer.deserialize(parsedPacket);
      // @ts-ignore
      if (message.id && message.id !== parsedPacket.id) {
        return undefined;
      }
      // @ts-ignore
      const { err, response, isDisposed, id } = message;

      const callback = this.routingMap.get(id);
      if (isDisposed || err) {
        return callback({
          err,
          response,
          isDisposed: true,
        });
      }
      callback({
        err,
        response,
      });
    };
  }

  // @ts-ignore
  protected publish(
    partialPacket: ReadPacket,
    callback: (packet: WritePacket) => any,
  ): Function {
    try {
      const packet = this.assignPacketId(partialPacket);
      const pattern = this.normalizePattern(partialPacket.pattern);
      const serializedPacket = this.serializer.serialize(packet);
      const responseChannel = this.getResPatternName(pattern);

      let subscriptionsCount =
        this.subscriptionsCount.get(responseChannel) || 0;

      const publishRequest = () => {
        subscriptionsCount = this.subscriptionsCount.get(responseChannel) || 0;
        this.subscriptionsCount.set(responseChannel, subscriptionsCount + 1);
        this.routingMap.set(packet.id, callback);
        this.fayeClient.publish(
          this.getAckPatternName(pattern),
          serializedPacket,
        );
      };

      const subscriptionHandler = this.createSubscriptionHandler(packet);

      if (subscriptionsCount <= 0) {
        const subscription = this.fayeClient.subscribe(
          responseChannel,
          subscriptionHandler,
        );
        subscription.then(() => publishRequest());
      } else {
        publishRequest();
      }

      return () => {
        this.unsubscribeFromChannel(responseChannel);
        this.routingMap.delete(packet.id);
      };
    } catch (err) {
      callback({ err });
    }
  }

  protected dispatchEvent(packet: ReadPacket): Promise<any> {
    const pattern = this.normalizePattern(packet.pattern);
    const serializedPacket = this.serializer.serialize(packet);

    return new Promise((resolve, reject) =>
      this.fayeClient.publish(pattern, JSON.stringify(serializedPacket)),
    );
  }

  protected unsubscribeFromChannel(channel: string) {
    const subscriptionCount = this.subscriptionsCount.get(channel);
    this.subscriptionsCount.set(channel, subscriptionCount - 1);

    if (subscriptionCount - 1 <= 0) {
      this.fayeClient.unsubscribe(channel);
    }
  }

详细的分解一下这三个方法的协同处理流程:

  1. createSubscriptionHandler函数

    • createSubscriptionHandler 函数用于创建订阅处理程序,它返回一个函数。
    • 当调用返回的函数时,它将传入的原始数据包解析成消息,并调用 callback 函数。
    • 如果消息中包含 id 并且与解析后的数据包的 id 不同,则返回 undefined
    • 否则,从路由映射中获取回调函数,并根据消息中的属性执行回调函数。 this.routingMap.get(id); 是 父类的用来获取一个对应的回调
  2. publish方法

    • publish 方法用于发布消息到指定的频道,并设置一个回调函数用于处理响应。
    • 首先,为部分数据包分配一个唯一的 id,然后将模式标准化。
    • 序列化数据包,获取响应频道名称。
    • 检查订阅计数以确定是否需要订阅该频道。如果订阅计数小于等于0,则订阅该频道,并在订阅完成后执行发布请求;否则,直接执行发布请求。
    • 将响应处理程序与频道订阅关联,并将消息发布到对应频道。
    • 返回一个取消订阅的函数,该函数将从订阅计数中减去该频道的计数,并在计数减少至0时取消订阅该频道。
  3. unsubscribeFromChannel方法

    • unsubscribeFromChannel 方法用于取消订阅指定频道。
    • 减少频道的订阅计数,并根据计数是否为0来决定是否取消订阅该频道。

这些代码中还有一些辅助 function 比如 getAckPatternName getResPatternName什么的,这里不是重点就不说了


  public parsePacket(content: any): ReadPacket & PacketId {
    try {
      return JSON.parse(content);
    } catch (e) {
      return content;
    }
  }

  public getAckPatternName(pattern: string): string {
    return `${pattern}_ack`;
  }

  public getResPatternName(pattern: string): string {
    return `${pattern}_res`;
  }
  • 解决客户端链接管理
public async connect(): Promise<any> {
    if (this.fayeClient) {
      return this.connection;
    }
    const { url, serializer, deserializer, ...options } = this.options;
    this.fayeClient = new faye.Client(url, options);
    this.fayeClient.connect();
    this.connection = await this.connect$(
      this.fayeClient,
      ERROR_EVENT,
      CONNECT_EVENT,
    )
      .pipe(share())
      .toPromise();
    this.handleError(this.fayeClient);
    return this.connection;
  }

我们依然把它 ‘流’ 化。

this.connect$()(如上所示)通过框架来运行它,它是nest 源代码中的 ClientProxy 提供的。对于 Faye,我们在文件 中定义它们nestjs-faye-transporter/src/constants.ts,并将这些常量导入到该faye-client.ts文件中。

通过这种方式,框架会使用连接(如果存在),或者在需要时创建连接,并以统一且高效的方式处理连接生命周期事件。

  • 解决客户端事件处理问题

对于事件的处理 实际上我们仅需要重写dispatchEvent 方法就好了,因为在Nest中 ClientProxy emit会调用它,

  public emit<TResult = any, TInput = any>(
    pattern: any,
    data: TInput,
  ): Observable<TResult> {
    if (isNil(pattern) || isNil(data)) {
      return _throw(new InvalidMessageException());
    }
    const source = defer(async () => this.connect()).pipe(
      mergeMap(() => this.dispatchEvent({ pattern, data })),
      publish(),
    );
    (source as ConnectableObservable<TResult>).connect();
    return source;
  }
  protected dispatchEvent(packet: ReadPacket): Promise<any> {
    const pattern = this.normalizePattern(packet.pattern);
    const serializedPacket = this.serializer.serialize(packet);

    return new Promise((resolve, reject) =>
      this.fayeClient.publish(pattern, JSON.stringify(serializedPacket)),
    );
  }

额外的注意:如果你使用有使用 上下文ctx的需求,请把装饰器 @Ctx 带上, 见nestjs 文档 docs.nestjs.com/microservic…

到此为止我们就把自定义的 transporter 完全搞定了。如果你希望client 能够像 其他 nest transporter 一样注入使用,那么你可以参考 nest的源代码实现,或者直接 参考我的这篇文章,通过useFacrt 去创建和注入

无论哪种方式他们都不复杂

image.png

image.png