在 NestJS 中面向接口开发

「当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。」

相信很多人都知道「鸭子类型(Duck typing)」这个东西,它可以说是对面向接口开发最形象的一个例子了。在 NestJS 中,可以通过模块轻松实现「面向接口」编程。

本文中,假定了一个「发送通知」的业务场景,并在此实现相应的功能。

接口定义与实现

既然面向接口,那么首先便需要「接口」。它定义了一个库应该实现的接口,并让调用该库(程序或开发人员)可以按照接口的定义直接使用该库。

从发送通知的功能考虑,一个能够发送通知的库应该是什么样子呢?首先需要一个publish() 方法,同时我们还需要一个方法 history() 能够获取历史发送的消息,所以接口的定义是这样的:

1
2
3
4
5
6
// src/app/message/message.interface.ts

export interface MessageProviderImpl {
publish(config: any, payload: any): Promise<boolean>;
history(): Promise<MessageHistory>;
}

这样,任何实现了这个接口的类我们都可以发送消息和查看历史记录了,前提是库正确地实现了这个接口。例如:

1
2
3
4
5
6
7
8
9
10
11
12
// src/app/message/dingtalk.message.ts

@Injectable()
export class DingtalkMessage implements MessageProviderImpl {
publish(config, payload) {
// 对接钉钉群推送的接口
}

history() {
// 拉取钉钉推送的历史消息,如钉钉未提供此接口,那自己实现存到数据库中
}
}

如果再需要实现一个 Slack 的消息推送,那么再加一个类即可:

1
2
3
4
5
6
7
8
9
10
11
12
// src/app/message/slack.message.ts

@Injectable()
export class SlackMessage implements MessageProviderImpl {
publish(config, payload) {
// 对接 Slack 推送的接口
}

history() {
// 拉取 Slack 推送的历史消息,如 Slack 未提供此接口,那自己实现存到数据库中
}
}

同理,还可以实现邮件推送、微信推送等等。

使用实现了接口的类

很自然地,我们在 NestJS 中定义了服务类的类,那么便交给 NestJS,让它实例化后丢进容器池里:

1
2
3
4
5
6
7
// src/app/message/message.module.ts

@Module({
controllers: [MessageController],
providers: [MessageService, DingtalkMessage, SlackMessage],
})
export class MessageModule {}

上述代码中的 MessageService 便是调用我们实现的类的地方了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/app/message/message.service.ts

@Injectable()
export class MessageService {
constructor(
private dingtalkMessage: DingtalkMessage,
private slackMessage: SlackMessage,
) {}

async publish(config, payload) {
await this.dingtalkMessage.publish(config, payload);
await this.slackMessage.publish(config, payload);
}
}

现在看起来这么调用有点蠢,我们不可能在代码中硬编码这种东西,不然这样子「面向接口」也没意义了。接下来对它进行简单的改造,要知道第一个参数 config 一直没有提到过,这里它开始派上用场了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Injectable()
export class MessageService {
constructor(
private dingtalkMessage: DingtalkMessage,
private slackMessage: SlackMessage,
) {}

async publish(config, payload) {
switch(config.type) {
case 'dingtalk':
await this.dingtalkMessage.publish(config, payload);
break;
case 'slack':
await this.slackMessage.publish(config, payload);
break;
}
}
}

这样子便可以通过调用时传入的参数切换不同的发布方式了,至于参数是从哪来的呢?可以是用户通过 HTTP 请求传递上来的,也可以是数据库中的一条记录。这个 config 中还可以包含不同发布方式所需要的不同参数,如密钥、token 之类。

讲到这里似乎跟 NestJS 还扯不上太多不一般的关系,不过我们试想,这种写法在每增加一种发布类型的时候,除了实现对应的类,在 MessageModule 中交给 NestJS 管理,还需要对 MessageService 的代码进行改动。

了解过 NestJS 如何处理循环依赖的同学应该对 ModuleRef 不陌生,这里我们便通过它来进一步简化这部分功能代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Injectable()
export class MessageService {
constructor(
private moduleRef: ModuleRef,
private slackMessage: SlackMessage,
) {}

async publish(config, payload) {
const instance = this.moduleRef.get(
`${config.type.replace(/^\S/, (s) => s.toUpperCase())}Message`,
{ strict: false },
);

await instance.publish(config, payload);
}
}

使用 ModuleRef 从 NestJS 容器池内根据 Provider Name 找到对应的实例,而这个 Provider Name 是通过计算得到的:将 config.type 的首字母大写,然后与 Message 拼接起来得到类名,当然这要求我们按照这一规则对类进行命名。不过这不是什么大问题。

最后提一下,上述功能是在一个「模块」中实现的,实际上它们并不需要一定在一个模块中,在外部引入的模块中只要可以正常注入的,都可以通过 ModuleRef 获取:

1
2
3
4
5
6
7
8
9
10
11
// src/app/message/dingtalk/dingtalk.message.ts

@Injectable()
export class DingtalkMessage implements MessageProviderImpl {}

// src/app/message/dingtalk/dingtalk.module.ts
@Module({
providers: [DingtalkMessage],
exports: [DingtalkMessage], // 必须要导出才能在其他模块中使用
})
export class DingtalkMessageModule {}

这种形式我通常用于后续需要添加平行的功能(适配器)来进行外部服务与内部核心服务对接的的时候,方便日后扩展。