介绍
从本章节开始(四期),我们将会陆陆续续的介绍一Nestjs的核心原理和底层实现,请大家动动发财的小手关注和订阅哈🤣。欢迎私信我 让我们共同进步
本文介绍了 有关Nestjs在微服务中常见的一些问题,包括自定义的序列化和自定义的协议;有的时候在我们的工作中必不可免的 会遇到与现有的基础设施做集成的场景,而nest官方提供的集成场景和例子相对来说比较的 标准 对于那些 非标 的场景缺少介绍,所以本文补充了这一方面;
本文我们将主要关注client 端的实现,同样的我们也需要不断的去参考Nestjs的源码,以保证我们在执行的路径上是正确且高效的。学会一种思想 💭 "从框架的使用者 变成框架的设计者"
概述
前面的文章我们详细的讨论了 server 是如何运作的,现在我们来讨论 client 如何运作 记住下面的图,它对我们的思路有指导作用
我们现在需要在 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
可以发现我们需要需要继承 ClientProxy ,然后实现它的一些方法,所以我们现在有思路了。
简单的实现一下
- 通过观察我们已经知道我们需要什么样的东西了
我们将构建如下的目录结构
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
- 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);
}),
);
}
- handleRequest
用于处理客户端发送的请求和处理从 Faye 服务器接收到的响应消息。具体的细节是:
用于处理客户端发送的请求
- 参数:
partialPacket
包含了发送请求所需的部分数据,如模式和数据。 - 构造请求:将
partialPacket
与一个唯一标识符组合,然后使用序列化器对其进行序列化,以准备发送到 Faye 服务器。序列化和反序列化 并且附加了一个id - 建立频道:根据模式构造请求频道和响应频道,以便 Faye 服务器可以通过这些频道发送响应。两个 Channel,req和res的
- 订阅处理程序:创建一个订阅处理程序,负责处理从响应频道收到的消息,并将其转换为 Observable 流。我们指的就是 subscriptionHandler 这个方法
- 执行请求:使用 Faye 客户端的
publish
方法将序列化后的请求发送到请求频道。 - 取消订阅:确保在请求处理完成后取消对响应频道的订阅,以释放资源。
- 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
- 解决多路复用问题
我们现在虽然实现了 信息 -> 流的转化
但我们现存的实现有如下的问题
- 无法处理同时发送多个请求的情况:在现存实现中,当同时发送多个请求时,由于共享的响应通道没有准备好处理多个重叠的请求,就会出现问题。特别是当第二个请求在第一个请求之前完成时,共享的响应通道无法正确处理这种情况。
- 单个响应通道对多个请求的处理不当:由于设计上将每个模式(pattern)绑定到一个响应通道,每次重新发出订阅调用时都会覆盖先前的 Faye 订阅处理程序,这会导致问题。这意味着无论发送多少个请求,每个模式(pattern)只能有一个活动的 Faye 订阅处理程序,这会导致对多个重叠请求的处理不当。
我们的具体解决方案是:
- 延迟绑定响应订阅处理程序:通过延迟绑定响应订阅处理程序,使其在请求发出时动态生成一个唯一的可观察订阅函数。具体来说,解决方案是在每次发送请求时创建一个唯一的响应订阅处理程序,并将其与请求关联起来,以便在接收到响应时能够准确地处理该请求的结果。
- 使用响应发射器工厂:介绍一下响应发射器工厂的概念,它是一个由框架提供的高阶函数,用于生成响应发射器。响应发射器是一个函数,用于处理实际的观察者发射调用,从而产生可观察流。通过使用响应发射器工厂,可以为每个请求创建一个唯一的响应发射器,从而克服多路复用问题。
- 关联标识符:为了将每个请求与其响应关联起来,文章还提到了关联标识符的概念。在请求发出时生成一个唯一的标识符,并将其与响应发射器关联起来。在接收到响应时,使用标识符来检索相应的响应发射器,并使用它来处理该请求的结果。
下面就是代码的实现了,注意我们在观察 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);
}
}
详细的分解一下这三个方法的协同处理流程:
-
createSubscriptionHandler函数:
createSubscriptionHandler
函数用于创建订阅处理程序,它返回一个函数。- 当调用返回的函数时,它将传入的原始数据包解析成消息,并调用
callback
函数。 - 如果消息中包含
id
并且与解析后的数据包的id
不同,则返回undefined
。 - 否则,从路由映射中获取回调函数,并根据消息中的属性执行回调函数。 this.routingMap.get(id); 是 父类的用来获取一个对应的回调
-
publish方法:
publish
方法用于发布消息到指定的频道,并设置一个回调函数用于处理响应。- 首先,为部分数据包分配一个唯一的
id
,然后将模式标准化。 - 序列化数据包,获取响应频道名称。
- 检查订阅计数以确定是否需要订阅该频道。如果订阅计数小于等于0,则订阅该频道,并在订阅完成后执行发布请求;否则,直接执行发布请求。
- 将响应处理程序与频道订阅关联,并将消息发布到对应频道。
- 返回一个取消订阅的函数,该函数将从订阅计数中减去该频道的计数,并在计数减少至0时取消订阅该频道。
-
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 去创建和注入
无论哪种方式他们都不复杂