[四期 - 2] 探索系列 - Nest 中如何自定义协议?(P2-把Service端完善)

241 阅读7分钟

介绍

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

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

本文主将会深入的构造 自定义 transporter 的 Server端, 注意本文有那么一点点🤏 难度。

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

仔细规划一下后续的功能需要做

饭🍚要一口口吃,东西要一点一点做,

我们的大概思路是这样的哈:

  1. 翻一下 @nestjs/micoreserve 包 发现里面允许我们自定义 TR,所以先按照它的要求配置一个
  2. 基于1 中的要求 我们参考 正常的 nestjs TR (比如MQTT)这种使用方式 实现 ,1中的两端,并且完成基于req/res的模式
  3. 完成emit 模式的实现,并且🤔️思考一下如何做到RXJS功能的集成
  4. 思考如何做到多路复用

文件夹和目录结构组织

在下文具体的实现中,我们将会创建三个新的目录

image.png

他们依次的作用是:

  1. 我们此次的CS-TR 将独立的做成一个独立的包来使用,这样的话方便你以后扩展特定条件下的CS-TR
  2. Http应用程序,它面向业务的开发者
  3. Nest的微服务部分,这里不再赘述

具体实现

仓库初始化

我们需要先把我们的仓库初始化,并且把相关的包link 上,这样后续的工作才能进行下去

  1. 一开始这三个工程都是由nest cli 自动初始化的很简单,不赘述

当然了因为我们希望 能够作为一个独立的包存在,所以你可以使用nest cli的 命令创建一个 lib工程,这也是可以的,但我没有这么做。 docs.nestjs.com/cli/librari…

  1. link操作

我们看看如何把我的独立的包给他link 出去,并且install 到其他的两个nest工程中去

我们来到 nest-faye-transporter 工程中(以下简称 faye-TR) ,

  • 配置它的packjson
{
 "main": "dist/index.js",
 "files": [
    "dist/**/*",
    "*.md"
  ],
  "scripts": {
    "build": "tsc"
   }
}
  • 进行yarn link 操作
$ yarn link
**yarn link v1.22.22**
# 提升你本地注册包成功
success Registered "nest-faye-transporter".
# 教你如何去别的项目引入我们的包
info You can now run `yarn link "nest-faye-transporter"` in the projects where you want to use this package and it will be used instead.
✨  Done in 0.03s.

cd 到我们的其他工程中去(举例子我就在nest-http-app)(以下简称 HttpApp)

$ yarn link nest-faye-transporter

在代码里直接引入就好了,

注意:

有的时候这个link的依赖不会出现在package中,而在项目中使用却不会异常是因为node_modules的查找逻辑在做怪,本地项目找不到就会去全局找,然后发现我们的全局中有link 它就用了.....,如果你想明确的link ,可以直接在packjson中添加绝对路径就可以,也不需要link,或者你link之后,用 npm root -g 找到 全局node_modules 的目录,把他作为路径也是可以的。(为什么不用yarn gloabl link 呢?因为这个东西要配置🤣麻烦)

"nest-faye-transporter": "link:/Users/joneysli/.nvm/versions/node/v18.19.1/lib/node_modules/nest-faye-transporter"

观察一下Nest官方源码

接下来我们观察一下 一般的MQTT的 TR是如何做的

const app = await NestFactory.createMicroservice(AppModule, {
  transport: Transport.MQTT,
  options: {
    host: 'localhost',
    port: 1883,
    serializer: new OutboundResponseIdentitySerializer(),
    deserializer: new InboundMessageIdentityDeserializer(),
  },
});

我们找到 nestjs的源代码进去看看,发现它提供了一个自定义的选项

image.png

这个就是我们要用到的,可是如何写呢?

我们可以观察一下MQTT是怎么写的,追一下createMicroservice源代码,发现他是调用 @nestjs/microservices 里的东西在创建

image.png

然后看看这个 NestMicroservice 是个啥,发现了其中关键的一些变量比如 this.server

image.png

然后我们就能够找到这些 server 各自的实现了,

image.png

于是我们就有了🤔️想法,如果我们参考这样的模式 就能够创建一个自定义的TR了,比如下面这样的

const app = await NestFactory.createMicroservice(AppModule, {
  strategy: new ServerFaye({
    url: 'http://localhost:8000/faye'
  })
});

我们先简单的把Req/Res的模式先实现

  • 进入 faye-TR 工程

构建我们的 ServerFaye , 参考Nest源码中的MQTT 就好了,基本上它怎么写 我们就怎么写

import { Server, CustomTransportStrategy } from '@nestjs/microservices';

import * as faye from 'faye';

export class ServerFaye extends Server implements CustomTransportStrategy {
  // Holds our client interface to the Faye broker.
  private fayeClient;

  constructor(private readonly options) {
    super();
    // super class establishes the serializer and deserializer; sets up
    // defaults unless overridden via `options`
    // 我们也可以去参考 MQTT相关的写法,我这里直接放出来了
    // 在另外的文件夹
    this.initializeSerializer(options);
    this.initializeDeserializer(options);
  }

  /**
   * 实现CustomTransportStrategy 必须要写的方法
   * listen() is required by `CustomTransportStrategy` It's called by the
   * framework when the transporter is instantiated, and kicks off a lot of
   * the machinery.
   */
  public listen(callback: () => void) {
    this.fayeClient = this.createFayeClient();
    this.start(callback);
  }

  /**
   * 初始化
   */
  public createFayeClient() {
    // pull out url, and strip serializer and deserializer properties
    // from options so we conform to the `faye.Client()` interface
    const { url, serializer, deserializer, ...options } = this.options;
    return new faye.Client(url, options);
  }

  /**
   * kick things off
   */
  public start(callback) {
    // register faye message handlers
    this.bindHandlers();
    // call any user-supplied callback from `main.ts` `app.listen()` call
    callback();
  }

  /**
   * 重点:这个是在客户端使用 `@MessageHandler()` or `@EventHandler`, 时绑定的东西通过这里来处(也就是处理 channel;)
   */
  public bindHandlers() {
    /**
     * messageHandlers is populated by the Framework (on the `Server` superclass)
     *
     * It's a map of `pattern` -> `handler` key/value pairs
     * `handler` is the handler function in the user's controller class, decorated
     * by `@MessageHandler()` or `@EventHandler`, along with an additional boolean
     * property indicating its Nest pattern type: event or message (i.e.,
     * request/response)
     */
    this.messageHandlers.forEach((handler, pattern) => {
      // only handling `@MessagePattern()`s for now
      if (!handler.isEventHandler) {
        this.fayeClient.subscribe(
          `${pattern}_ack`,
          this.getMessageHandler(pattern, handler),
        );
      }
    });
  }

  public getMessageHandler(pattern: string, handler: Function): Function {
    return async message => {
      const inboundPacket = this.deserializer.deserialize(message);
      const response = await handler(inboundPacket.data);
      const outboundRawPacket = {
        err: null,
        response,
        isDisposed: true,
        id: (message as any).id,
      };
      const outboundPacket = this.serializer.serialize(outboundRawPacket);
      this.fayeClient.publish(`${pattern}_res`, outboundPacket);
    };
  }

  /**
   * close() is required by `CustomTransportStrategy`
   */
  public close() {
    this.fayeClient = null;
  }
}

这个主要的server类,我们还需要把 序列化放序列化 写上,保证传输过程中的统一,为简单期间 我就洗一个反序列化的,序列化也是一样的写法

import { ConsumerDeserializer, IncomingRequest } from '@nestjs/microservices';
import { Logger } from '@nestjs/common';

export class InboundMessageIdentityDeserializer
  implements ConsumerDeserializer
{
  private readonly logger = new Logger('InboundMessageIdentityDeserializer');

  deserialize(value: any, options?: Record<string, any>): IncomingRequest {
    this.logger.verbose(
      `<<-- deserializing inbound message:\n${JSON.stringify(
        value,
      )}\n\twith options: ${JSON.stringify(options)}`,
    );
    return value;
  }
}

为了准确的描述 这个 sever 类我们还需要定一个 interface 把faye支持的option 也一起带过来

import { Serializer, Deserializer } from '@nestjs/microservices';

export interface FayeOptions {
  /**
   * faye server mount point (e.g., http://localhost:8000/faye)
   */
  url?: string;
  /**
   * time in seconds to wait before assuming server is dead and attempting reconnect
   */
  timeout?: number;
  /**
   * time in seconds before attempting a resend a message when network error detected
   */
  retry?: number;
  /**
   * connections to server routed via proxy
   */
  proxy?: string;
  /**
   * per-transport endpoint objects; e.g., endpoints: { sebsocket: 'http://ws.example.com./'}
   */
  endpoints?: any;
  /**
   * backoff scheduler: see https://faye.jcoglan.com/browser/dispatch.html
   */
  // tslint:disable-next-line: ban-types
  // eslint-disable-next-line @typescript-eslint/ban-types
  scheduler?: Function;
  /**
   * instance of a class implementing the serialize method
   */
  serializer?: Serializer;
  /**
   * instance of a class implementing the deserialize method
   */
  deserializer?: Deserializer;
}

最后的文件夹是这样的

image.png

这是Serve 哈,别忘记了我们还需要搞定 client ,一般的参考MQTT的做法,我们需要 使用prodiver方式注入这个依赖,但本次的重点是 server 所以 clinet 我们直接new 就好了 ,不注入。

接下来我们试一下看看行不行哈! (注意client 暂时不需要处理 )

  • 进入 nest-microservice 工程
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';

import {
  ServerFaye,
  OutboundResponseIdentitySerializer,
  InboundMessageIdentityDeserializer,
} from 'nest-faye-transporter';

async function bootstrap() {
  const logger = new Logger('Main:bootstrap');
  const app = await NestFactory.createMicroservice(AppModule, {
    strategy: new ServerFaye({
      url: 'http://localhost:8000/faye',
      serializer: new OutboundResponseIdentitySerializer(),
      deserializer: new InboundMessageIdentityDeserializer(),
    }),
  });
  app.listen();
  logger.verbose('Microservice is listening...');
}

bootstrap();


// controller
import { Controller, Logger } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';

interface Customer {
  id: number;
  name: string;
}

const customerList: Customer[] = [{ id: 1, name: 'nestjs.com' }];

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

  /**
   * Register a message handler for 'get-customers' requests
   */
  @MessagePattern('/get-customers')
  async getCustomers(data: any): Promise<any> {
    const customers =
      data && data.customerId
        ? customerList.filter(
            (cust) => cust.id === parseInt(data.customerId, 10),
          )
        : customerList;
    return { customers };
  }
}

  • run 看看效果
  1. 我们依然需要把 faye-server 启动
  2. 当nest-micoreserver 连接之后(subscription就生效了)
^^ client connect (# 0w5s )
++ New subscription from 0w5s on "/get-customers_ack"
  1. 启动 customerApp 发起一个get请求

image.png

目前来看一切正常,但是它还是缺少对 rxjs 流支持和 多路复用支持,接下来我们继续完善它. 下图表示在目前的架构下我们的req/res 流转

image.png

处理rxjs流和event

  • 聊聊潜在的问题
  1. @EventPattern(...) 好像还没处理
  2. rxjs 流还不支持
  • 看看如何做
  1. 处理流
// 既然要处理流信息,那么我们需要从消息入手,原来的处理很简单
  public getMessageHandler(pattern: string, handler: Function): Function {
    return async (message) => {
      const inboundPacket = this.deserializer.deserialize(message);
      // @ts-ignore
      const response = await handler(inboundPacket.data);
      const outboundRawPacket = {
        err: null,
        response,
        isDisposed: true,
        id: (message as any).id,
      };
      const outboundPacket = this.serializer.serialize(outboundRawPacket);
      this.fayeClient.publish(`${pattern}_res`, outboundPacket);
    };
  }

// 现在我们要玩花活儿 -> 
 public getMessageHandler(pattern: string, handler: Function): Function {
    return async (message: ReadPacket) => {
      const inboundPacket = this.deserializer.deserialize(message, {
        channel: pattern,
      });
      // 上下午参数主机,先不用管,要管也有它和 nest源码里的做法是一样的。
      const fayeCtx = new FayeContext([pattern]);

      // 直接转换成 流(Observable),这个方法是 父类 Server 自己本身就有的
      const response$ = this.transformToObservable(
        // @ts-ignore
        await handler(inboundPacket.data, fayeCtx),
      ) as Observable<any>;

      const publish = (response: any) => {
        Object.assign(response, { id: (message as any).id });
        const outgoingResponse = this.serializer.serialize(response);
        return this.fayeClient.publish(`${pattern}_res`, outgoingResponse);
      };

      // 框架会自动处理 这个流
      response$ && this.send(response$, publish);
    };
  }

我们解读一下源码是如何处理这个流的

// from https://github.com/nestjs/nest/blob/master/packages/microservices/server/server.ts
  public send(
    stream$: Observable<any>,
    respond: (data: WritePacket) => void,
  ): Subscription {
    let dataBuffer: WritePacket[] = null;
    // 我们正在订阅从用户域处理程序返回的响应。如果订阅产生多个值(一个流),我们缓冲它们(`dataBuffer`)
    const scheduleOnNextTick = (data: WritePacket) => {
      if (!dataBuffer) {
        dataBuffer = [data];
        process.nextTick(() => {
        // 然后使用`publish()`我们刚才构建的函数发布每个值
        // (在该方法中,`publish()`函数作为参数访问`respond`)。
        // 当流完成时,我们通过`isDisposed`在最终发出的响应上设置为 true 来表示这一点
          dataBuffer.forEach(buffer => respond(buffer));
          dataBuffer = null;
        });
      } else if (!data.isDisposed) {
        dataBuffer = dataBuffer.concat(data);
      } else {
        dataBuffer[dataBuffer.length - 1].isDisposed = data.isDisposed;
      }
    };
    return stream$
      .pipe(
        catchError((err: any) => {
          scheduleOnNextTick({ err, response: null });
          return empty;
        }),
        finalize(() => scheduleOnNextTick({ isDisposed: true })),
      )
      .subscribe((response: any) =>
        scheduleOnNextTick({ err: null, response }),
      );
  }

这样流的部分我们就处理完了

  1. 关于上下文

当我们通过Proxy API 订阅主题时会发生什么? 对于 Faye,一切都很简单。我们在某个主题上注册一个Faye 订阅处理程序,当与该主题匹配的消息到达时,我们的处理程序将被调用,消息负载仅包含发布者发送的内容

对于某些Proxy,订阅处理程序可以接收有关消息的其他上下文 - 有时在回调参数中,有时在消息有效负载中。例如,使用 NATS,消息有效负载将发布者发送的实际内容打包在顶级data属性中,并添加两个带有上下文元数据的附加顶级属性:一个描述调用者订阅的主题,另一个包含可选reply主题。我们将此元数据称为Context,Nest 允许您将此信息传递给用户态处理程序(由@MessagePattern()或修饰的方法@EventPattern())。

由于 Faye 根本不提供任何此类元数据,因此我们将通过在此上下文对象中传递通道来演示此行为。在这种情况下,这并不是特别有用,因为我们实际上并没有提供任何信息,但对于 Faye,这是我们能做的最好的事情来演示对象的概念Context

  1. 处理event

接下来我们来处理 event ,主要做法还是一样的 按照 MQTT 类似的做法就好

public bindHandlers() {
    /**
     * messageHandlers is populated by the Framework (on the `Server` superclass)
     *
     * It's a map of `pattern` -> `handler` key/value pairs
     * `handler` is the handler function in the user's controller class, decorated
     * by `@MessageHandler()` or `@EventHandler`, along with an additional boolean
     * property indicating its Nest pattern type: event or message (i.e.,
     * request/response)
     */
    this.messageHandlers.forEach((handler, pattern) => {
      // In this version (`part3`) we add the handler for events
      if (handler.isEventHandler) {
        // The only thing we need to do in the Faye subscription callback for
        // an event, since it doesn't return any data to the caller, is read
        // and decode the request, pass the inbound payload to the user-land
        // handler, and await its completion.  There's no response handling,
        // hence we don't need all the complexity of `getMessageHandler()`
        this.fayeClient.subscribe(pattern, async (rawPacket: ReadPacket) => {
          const fayeCtx = new FayeContext([pattern]); // 注意传递 上下文
          const packet = this.parsePacket(rawPacket);
          const message = this.deserializer.deserialize(packet, {
            channel: pattern,
          });
          await handler(message.data, fayeCtx);
        });
      } else {
        this.fayeClient.subscribe(
          `${pattern}_ack`,
          this.getMessageHandler(pattern, handler),
        );
      }
    });
  }