接上文,本文开始阐述NestJS中我看到的一些设计模式和思想。
Solid架构设计原则
Solid,说起来是一串比较高大上的设计原则,其实用简单点儿的思维方式来理解就没有那么抽象了。
首先,S代表的是单一职责原则,简单的来说,就是你的类在设计的时候只需要做一件事儿,不能越俎代庖,我们就用一个大白话例子来聊它。
假设我们有一个目标,厨师需要做北京烤鸭
,那么此时你应该怎么设计?
先看一下简单版本的设计:
ts
// 定义一个鸭子类
class Duck {}
// 厨师类
class Cook {
constructor() {
// 准备烤鸭的原材料,初始化鸭子类
this.duck = new Duck();
}
// 厨师拥有一项技能,可以烤鸭
public bake() {
console.log('==================开始烤鸭==================')
console.log(this.duck)
console.log('==================烤鸭熟了,可以出炉了========')
}
}
乍一看,上面的代码好像是没有问题的,但是某一天,餐厅的老板觉得之前的鸭子的品质太低,需要提高鸭子的品质,好了,厨师的行为就需要修改了。
其实,这儿普遍存在一个错误认知,我们仔细想一下,你是一个厨师,你的职责是不是只需要专心致志的制作烤鸭就行,反正你的上游供货商给你的是鸭子,那么你是否就可以正常工作了,既然这样,那生产鸭子的这个过程是不是就是你操心的问题了?
在意识到了这个问题之后,就可以改善一下我们的代码设计了。
ts
// 定义鸭子类
class Duck{}
// 定义一个供货商
class Provider {
public static createDuck() {
return new Duck();
}
}
// 厨师类
class Cook {
constructor() {
// 准备烤鸭的原材料,由外界的供货商提供
this.duck = Provider.createDuck();
}
// 厨师拥有一项技能,可以烤鸭
public bake() {
console.log('==================开始烤鸭==================')
console.log(this.duck)
console.log('==================烤鸭熟了,可以出炉了========')
}
}
L(Liskov Substitution Principle
),里氏代换原则,用大白话阐述就是说,子类应该遵守父类的接口规范、行为约束和约定,不应该修改父类的已有行为。
比如:
ts
// 基类
class Base{
sayHello() {
console.log('hello world')
}
}
// 子类
class Sub extends Base {
// 子类重写了父类的方法,子类仅仅是增强了父类的行为,仍然能够满足父类的接口规范
sayHello() {
super.sayHello();
console.log('child class override parent action');
}
}
D(Dependencies Inversion Principle
),依赖倒置原则,高层策略性的代码不应该依赖实现底层细节的代码,那些实现底层细节的代码应该依赖高层策略性的代码。
O(Open Closed Principle
),开闭原则,指的是软件设计应该面向扩展是开发的,面向修改是封闭的。
所以我个人的感觉就是,里氏代换原则、依赖倒置原则、开闭原则在实际开发中其实是相辅相成,关系密切的。用大白话说就是面向抽象编程,面向接口编程。
结合以上三种设计原则来重构之前的厨师烤鸭的例子,代码如下:
ts
interface IFoodMaterial {
name: string;
}
class Duck implements IFoodMaterial {
name = 'duck';
}
class Shelduck extends Duck {
description = '麻鸭肉质好';
}
class WhiteFeatherDuck extends Duck {
description = '白羽鸭便宜';
}
interface ICook {
makeFood(mt: IFoodMaterial): void;
}
class DuckProvider {
static getDuck(type: string): Duck {
switch (type) {
case 'A':
return new Shelduck();
case 'B':
return new WhiteFeatherDuck();
}
}
}
class BakeDuckCook implements ICook {
makeFood(duck: Duck): void {
console.log('==================开始烤鸭==================');
console.log(duck);
console.log('==================烤鸭熟了,可以出炉了========');
}
}
class HotpotCook implements ICook {
makeFood(mt: IFoodMaterial): void {
console.log('==================开始制作重庆火锅==================');
console.log(mt);
console.log('==================重庆火锅出锅========');
}
}
(function kitchen() {
const cook = new BakeDuckCook();
// const cook = new HotpotCook();
// 为了提高烤鸭的品质,本店选用的是麻鸭,肉质好,更加容易得到顾客的青睐
cook.makeFood(DuckProvider.getDuck('A'));
})();
上述代码的好处就是,如果餐厅想节约成本,那就不用麻鸭了,直接用便宜的白羽肉鸭即可(改策略),烤鸭师傅是不知道的;或者更有甚者,如果餐厅将来不想卖烤鸭了,想要卖重庆火锅,直接把烤鸭师傅炒鱿鱼,再招一个做重庆火锅的厨师即可,至于供货商,就看老板的心情了,反正它最终只需要提供食材即可。
最后,还有一个没有提到的I(Interface segregation principle
),代表的是接口隔离原则,它的3个要点,一个类对另一个类的依赖应该建立在最小的接口上;一个类对外的接口应该是它的客户端所需要的,而不应该强迫客户端依赖于它不需要的接口;不应该强迫一个类去实现它用不到的方法;其实这个原则也非常好理解,我个人感觉就像是单一职责原则。因为一旦你的类需要确定实现某个接口的话,那你这个类就明确的需要承担某个职责了,这个职责是不是你确定需要的,不是,那么就你就不应该实现相应的接口。至于类与类的依赖要建立在最小的接口上,也挺好理解,在设计的时候事先就明确好职责,就不会出现比较胖的接口,在使用的时候就可以相对灵活的对代码进行组织。
NestJS的核心概念------DI
在上一节我们阐述Solid原则的时候聊到了依赖倒置原则,进而提到了面向抽象编程,面向接口编程。当我们把变化的内容交给外界实现,将传递到内部运行,内部留下来其它的设计的基本上就是稳定的设计了,一个程序,不就是基础框架+业务,就像Webpack的插件,每个插件负责它感兴趣的事件,Webpack只需要把它们调度起来就能完成各种各样的任务。
由此引入一个概念叫做,控制反转(Inversion of Control
,IoC)是一种软件设计思想,它是面向对象编程中的一种概念,目的是通过将控制流程的权力从程序代码本身转移到外部容器或框架,实现程序设计的松耦合和可扩展性。
既然程序代码的核心已经交到了外部管理,这些代码什么时候应该到它该去的位置运行呢,它一定会有一个被依赖的位置的,由此引入了另一个概念,DI(Dependencies Inject
),依赖注入。
在NestJS中,我们写的最多的就是各种各样的类,但是你发现我们从来没有自己去写过任何创建这些类的过程控制代码,这些类的创建和销毁其实是被NestJS的IoC
容器管理起来了,它IoC
容器的运行流程是一个相对复杂的流程,在本系列文章后半部分,我们会花大量的篇幅来追踪它的运行原理,本节仅作为一个引子。(因为整个NestJS框架就建立在IoC
这个基石上,若不事先向大家提到IoC
和DI
的概念,对于NestJS的研究无法阐述下去了)
我在NestJS中Solid原则的实践。
1、使用面向Service编程代替面向API编程
先给大家看一个错误的场景:
ts
import { Controller, Get, Header, Injectable, Query } from '@nestjs/common';
import { SiteService } from '../sites/site.service';
import { AuthCodeService } from '../authCode/authCode.service';
import { UserService } from 'src/user/user.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly siteService: SiteService,
private readonly authCodeService: AuthCodeService,
private readonly userService: UserService
) {}
/**
* 暴露给系统的接口
* 用于获取用户信息
*/
@Get('getUserInfo')
@Header('Access-Control-Allow-Origin', '*')
async getUserInfo(@Query('code') code): Promise<any> {
// 校验code
const checkCode = await this.authCodeService.checkCode(code);
if (checkCode !== 0) {
const errmsgArr = {
1: 'code已过期',
2: 'appid和来源不符',
3: '未注册的来源',
4: '不存在的code'
};
return {
errcode: checkCode,
errmsg: errmsgArr[checkCode]
};
}
const data = await this.authCodeService.getAuthInfo(code);
// 查询用户
const user = await this.userService.getUserById(data.userid);
let thirdUser = {};
if (data.type == 'dd') {
thirdUser = await this.userService.getDDUserById(user.ddId);
} else if (data.type == 'fs') {
thirdUser = await this.userService.getFSUserById(user.fsId);
}
// 返回用户信息
return {
errcode: 0,
errmsg: '',
data: thirdUser
};
}
}
上述代码,如果你觉得没有问题,哈哈哈,那你也跟他犯了同样的错误。这段代码的问题是他对Controller
和Service
的职责认知还不够,Controller额外承担了Service的业务逻辑,将来存在潜在的修改的风险。
首先,我们明确一个问题,在NestJS的控制器的职责是什么?在NestJS控制器的职责是对外暴露HTTP API(gRPC控制器不算入)的一个网关,那么控制器该干什么呢,说的难听一点儿控制器就像一条看门的狗狗一样,只有经过校验的请求才能放行至后续的业务处理,否则应该直接拦截。被放行的合法的请求,进行了对应的业务处理,控制器拿到返回结果,将用户预期的数据响应即可。
上述代码,目前对于HTTP处理尚还能运行,假设我们现在需要接入Cron
(定时任务)或者接入Kafka
(一种消息队列技术),那上述根据业务逻辑查找对应的用户的逻辑是不是就得再写一遍?既然需要再写一遍的话,那这段代码为啥不直接写入到UserService里面。
在我们分析清楚了职责以后,上述代码可以简单的重构成以下的样子:
ts
import { Controller, Get, Header, Injectable, Query } from '@nestjs/common';
import { SiteService } from '../sites/site.service';
import { AuthCodeService } from '../authCode/authCode.service';
import { UserService } from 'src/user/user.service';
@Controller('auth')
@Injectable()
export class AuthController {
constructor(
private readonly siteService: SiteService,
private readonly authCodeService: AuthCodeService,
private readonly userService: UserService
) {}
/**
* 暴露给系统的接口
* 用于获取用户信息
*/
@Get('getUserInfo')
@Header('Access-Control-Allow-Origin', '*')
async getUserInfo(@Query('code') code): Promise<any> {
// 校验code
const checkCode = await this.authCodeService.checkCode(code);
if (checkCode !== 0) {
const errmsgArr = {
1: 'code已过期',
2: 'appid和来源不符',
3: '未注册的来源',
4: '不存在的code'
};
return {
errcode: checkCode,
errmsg: errmsgArr[checkCode]
};
}
const data = await this.authCodeService.getAuthInfo(code);
// 具体service就不展示了,大家明白这个意思即可
const user = await this.userService.getUserById(data.userid, data.type);
// 返回用户信息
return {
errcode: 0,
errmsg: '',
data: user
};
}
}
所以在NestJS中,我们一定要遵循单一职责的原则,在将来应对业务的扩展可以变得游刃有余。
2、使用单例模式来实现链路追踪的日志
有的同学可能会觉得这个事儿很简单,不就是打个日志嘛,调用一个console.log
就可以搞定的,你告诉我很难?哈哈哈,企业级项目打个日志真的不是一件简单的事儿。因为可能我们编写的Nest服务作为微服务的一环运行,在打印日志之后还需继续向后续的服务节点传递。再者,企业级项目的日志会把当前用户请求的地址,用户的信息,调用者,以及请求结果,若产生了错误,还需把错误信息也记录下来。
关键的问题就是请求相关的信息,很明显这类日志就需要注入Request
对象,怎么注入呢?如果按照NestJS传统的DI方式注入Request
对象,那一定会存在一个问题,为业务Service不一定是仅仅为Http服务的,如果通过注入Request
对象的方式,那就势必会造成会将Request
对象向业务逻辑传递,对于业务逻辑来说,其实它是不知道将来会被谁调用的,那这个代码简直就没法写了。
既然用传统的DI注入Request
对象搞不定,那么,换个思路,使用中间件的方式注入Request
对象,每次请求将Request
对象传递到单例的日志管理器上,对于业务来说,只需要调用日志管理器打印日志即可。
因为我们反复提到了一个问题,这个日志对象不一定是仅为Http服务的日志管理器,所以,我们可以引入简单工厂模式,根据当前服务运行的环境判断是Http还是Cron
环境。
于是代码就可以被设计成如下的样子:
基类:
ts
import * as dayjs from 'dayjs';
import { FormattedLog, RequestLogCommonStructure } from '@/common/types';
import { APP_VERSION } from '@/common/constants';
export abstract class LoggerService {
private version = APP_VERSION;
/**
* 获取请求上下文的公共数据
* @returns
*/
protected abstract getRequestCommonBodyLog(): RequestLogCommonStructure;
private getLogBody(level: string, logContext: Partial<FormattedLog>) {
const { traceId, method, url, args, userId, actKey } =
this.getRequestCommonBodyLog();
return {
_LEVEL_: level,
_TS_: dayjs(new Date()).format('YYYY/MM/DD HH:mm:ss'),
_CALLER_: logContext.caller || 'unknown',
_MSG_: '',
_VER_: `v${this.version}`,
_DEPLOY_: process.env.NODE_ENV,
_APP_: 'act-bff',
_COMP_: 'server',
_TYPE_: logContext.type || 'general',
traceID: traceId,
// TODO: 待定
spanID: '',
operation: '',
method: method || 'unknown',
uri: url || 'unknown',
args: logContext.args || args || 'unknown',
code: logContext.code || -1,
message: logContext.message || 'unknown',
spend: logContext.spend || '',
};
}
public log(message: Partial<FormattedLog>) {
const formattedLog = this.getLogBody('log', message);
console.log(JSON.stringify(formattedLog));
}
}
实现类:
ts
import { Request } from 'express';
import { LoggerService } from './logger.service';
import { RequestLogCommonStructure } from '@/common/types';
/**
* 标准日志打印器
*/
@Singleton
class SingletonLoggerService extends LoggerService {
private constructor() {
super();
}
private request: Request;
public setRequest(request: Request): SingletonLoggerService {
this.request = request;
return this;
}
protected getRequestCommonBodyLog(): RequestLogCommonStructure {
const req = this.request as Request & { traceId: string };
return {
// 省略
};
}
}
@Singleton
class CronSingletonLoggerService extends LoggerService {
protected getRequestCommonBodyLog(): RequestLogCommonStructure {
return {
// 省略
};
}
}
/*
* 单例装饰器
*/
function Singleton<T extends new (...args: any[]) => any>(
targetClass: T,
): T & { getInstance(): InstanceType<T> } {
return class SingletonClass extends targetClass {
private static _instance: InstanceType<T> | null = null;
private constructor(...args: any[]) {
super(...args);
}
public static getInstance(): InstanceType<T> {
if (!SingletonClass._instance) {
SingletonClass._instance = new SingletonClass();
}
return SingletonClass._instance;
}
};
}
// 根据运行环境取得不同的Logger管理器
function loggerFactory(): LoggerService {
return /^cron-/.test(process.env.NODE_ENV)
? (SingletonLoggerService as any).getInstance()
: (CronSingletonLoggerService as any).getInstance();
}
export const logger = loggerFactory();
3、使用面向接口编程的方式来编写服务
在Solid设计原则部分我们已经阐述过面向接口编程了,对于NestJS的代码组织来说,这是一个天然的使用场景。
我的业务场景Service和业务后端的通信方式可能有两种,一种是gRPC,还有一种是Http,这两种通信方式可能是随意切换的,那么我如果直接在代码里面写死的话,可能会因为业务的变动而调整,而我不愿意做这样的调整,于是我首先将通信方式进行抽象,用接口来描述这些行为,根据业务选择Http和gRPC去实现它即可。
编写接口定义:
ts
import { AxiosResponse } from 'axios';
import {
MemberProductResponse,
NoticeRequest,
TopUpParams,
TopUpResponse,
} from './interfaces';
export interface ITopUpService {
/**
* 通用充值参数
* @param data 充值通用参数
*/
pay(data: TopUpParams): Promise<AxiosResponse<TopUpResponse, any>>;
}
编写实现:
ts
import { ConfigService } from '@/common/config';
import { AutoBind } from '@/common/decorators/auto-bind.decorator';
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { AxiosResponse } from 'axios';
import { ITopUpService } from './top-up.service';
import {
MemberProductResponse,
NoticeRequest,
TopUpParams,
TopUpResponse,
} from './interfaces';
@Injectable()
export class TopUpHttpService implements ITopUpService {
@AutoBind
public async pay(
data: TopUpParams,
): Promise<AxiosResponse<TopUpResponse, any>> {
return this.httpService
.get(this.url, {
params: {
...data,
ac: 'payCoins',
secret: SECRET_KEY,
},
})
.toPromise();
}
}
模块导出:
ts
import { Module } from '@nestjs/common';
import { TopUpHttpService } from './top-up.http.service';
import { HttpModule } from '@nestjs/axios';
import { TOP_UP_TOKEN } from './top-up.constants';
@Module({
imports: [HttpModule],
providers: [
{
provide: TOP_UP_TOKEN,
// 注意,此处划重点,向模块中依赖的是实现类而不是接口
useClass: TopUpHttpService,
},
],
exports: [
{
provide: TOP_UP_TOKEN,
/ 注意,此处划重点,模块对外导出是实现类而不是接口
useClass: TopUpHttpService,
},
// 此数可以直接写成TOP_UP_TOKEN也是可以的,NestJS会去找到TopUpHttpService
],
})
export class TopUpModule {}
在使用的时候:
ts
import { ITopUpService, TOP_UP_TOKEN } from '@/modules/top-up';
@Injectable()
export class PeakTokenService extends BaseService {
// 此处标记的类型使用的是接口,而不是TopUp的实现类
@Inject(TOP_UP_TOKEN)
protected readonly payService: ITopUpService;
}
为什么可以这样用呢,因为TS的协变
,如果你觉得协变
听起来难以接受,那就用OOP
的多态
来理解也可以的。
因为TS语言的关系,我们无法做到使用反射加载类,因此在编写模块的providers需要手动指明实现类,这是一个不足,也是一点儿小小的遗憾。
考虑到篇幅和大家都阅读难度,本文暂时先阐述这么多内容,第三篇将和大家补充C#的反射的知识点,并且第三篇开始将会开始阐述NestJS的运行原理,敬请期待。