记录我的NestJS探究历程(四)

在上一篇文章中分析了NestJS的DI的依赖关系解析,依赖注入流程之后,本文开始分析NestJS路由相关的内容。

初探NestJS的运行原理之路由

NestJS宣称的是底层提供了一层抽象,没有跟特定的框架绑定,但是默认是使用的是Express

Nest provides a level of abstraction above these common Node.js frameworks (Express/Fastify), but also exposes their APIs directly to the developer. This gives developers the freedom to use the myriad of third-party modules which are available for the underlying platform.

在我们的项目中,调用NestFactory.create的时候,我们不指定底层的http实现,NestJS就如上图所示选择了默认的Express框架。 于是,我们的目光就可以移步到@nestjs/platform-express这个包中了。

打开到pacakges/platform-express/adapters/express-adapter.ts,此时我们发现一切熟悉的东西都来了,因为NestJS在这个Adapter里仅仅是对Express进行了包装。

NestJS首先用一个适配器来抹平ExpressAPI本身和NestJS通用实现的差异。

到这个时候,Express这边看起来活儿就搞完了,但是我们的Controller是怎么跟Express的路由绑定起来的呢,还需要一些必要的操作才行。为了简单起见,我们仍然采用施加断点来查看系统的调用堆栈。 上图的listen方法,就是我们在项目里的bootstrap方法里面调用的,然后这个init方法里面调用了NestApplication的init方法,准备开始进行路由的注册。 在之前的时候,IoC容器,已经帮我们把所有的Controller全部都创建好了。 此时我们只需要映射路由和Controller的方法的关系了,但是还不能着急,因为路由的信息全部是配置在Controller那个类上面的,首先得先解元数据才能知道映射关系。

然后调用PathsExplorer类的方法,将路由path和处理的方法绑定。

此时还剩下一个问题,通俗的讲Controller只是拿到了offer,它还没有办理入职,还没有跟Express进行绑定,绑定了之后才算完成了入职,我们接下来看它是怎么进行绑定的。

回到routes-explorer.ts中,在scanForPaths下面的applyPathsToRouterProxy方法调用就是进行这个关联操作的,我们可以一直往它的调用链追踪,可以追踪到它最终绑定完成路由,如下:

至此,我们还没有考虑路由参数,及控制器方法参数装饰器等其它更加细节的技术,也没有详细探究我们编写的控制器的方法的返回内容是怎么映射回Express的(比如怎么样让Express知道我们是在渲染页面还是直接返回JSON,返回怎么样的http状态码相关细节等)我们现在只探究了NestJS路由处理流程,这些知识点在即将后续章节继续阐述。

可以看到,最后的流程被NestJS用一个Proxy包起来了,为什么要包起来呢,因为框架的设计者不能指望用户给出的内容一定能按预期运行,NestJS设计了一个全局的异常捕获器,这个可以实现统一的全局错误捕获。

总来的说,相比于启动流程的DI流程,路由处理还是很简单了,只需要将Controller的方法和Express的API绑定起来即可。

初探NestJS的运行原理之路由参数

在上一节中,我们探究了NestJS的路由处理过程,但是并没有详细的探究它是怎么映射数据到我们Controller的某个方法,本节开始探究这个过程。

在这之前,我们需要先留意一下NestJS提供的关于路由参数的装饰器,我们也挑几个比较关键的装饰器来看看。

  • Query
  • Body
  • Param
  • Req
  • Res

以上5个装饰器是我们常用的装饰器,因此我们来看看它们的实现。

Body,Query,Param:

ts 复制代码
// 参数合并
function assignMetadata<TParamtype = any, TArgs = any>(
  args: TArgs,
  paramtype: TParamtype,
  index: number,
  data?: ParamData,
  ...pipes: (Type<PipeTransform> | PipeTransform)[]
) {
  return {
    ...args,
    [`${paramtype}:${index}`]: {
      index,
      data,
      pipes,
    },
  };
}

const createPipesRouteParamDecorator =
  (paramtype: RouteParamtypes) =>
  (
    data?: any,
    ...pipes: (Type<PipeTransform> | PipeTransform)[]
  ): ParameterDecorator =>
  (target, key, index) => {
    const args =
      Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) || {};
    const hasParamData = isNil(data) || isString(data);
    const paramData = hasParamData ? data : undefined;
    const paramPipes = hasParamData ? pipes : [data, ...pipes];
    // 定义合并之后的路由数据解析
    Reflect.defineMetadata(
      ROUTE_ARGS_METADATA,
      assignMetadata(args, paramtype, index, paramData, ...paramPipes),
      target.constructor,
      key,
    );
  };

export function Query(
  property?: string | (Type<PipeTransform> | PipeTransform),
  ...pipes: (Type<PipeTransform> | PipeTransform)[]
): ParameterDecorator {
  // 从querystrting上解析路由数据
  return createPipesRouteParamDecorator(RouteParamtypes.QUERY)(
    property,
    ...pipes,
  );
}

export function Body(
  property?: string | (Type<PipeTransform> | PipeTransform),
  ...pipes: (Type<PipeTransform> | PipeTransform)[]
): ParameterDecorator {
  // 从body上解析路由数据  
  return createPipesRouteParamDecorator(RouteParamtypes.BODY)(
    property,
    ...pipes,
  );
}

export function Param(
  property?: string | (Type<PipeTransform> | PipeTransform),
  ...pipes: (Type<PipeTransform> | PipeTransform)[]
): ParameterDecorator {
  // 从请求路径上解析路由数据  
  return createPipesRouteParamDecorator(RouteParamtypes.PARAM)(
    property,
    ...pipes,
  );
}

Request和Response相关:

ts 复制代码
function createRouteParamDecorator(paramtype: RouteParamtypes) {
  return (data?: ParamData): ParameterDecorator =>
    (target, key, index) => {
      const args =
        Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) || {};
      Reflect.defineMetadata(
        ROUTE_ARGS_METADATA,
        assignMetadata<RouteParamtypes, Record<number, RouteParamMetadata>>(
          args,
          paramtype,
          index,
          data,
        ),
        target.constructor,
        key,
      );
    };
}

export const Request: () => ParameterDecorator = createRouteParamDecorator(
  RouteParamtypes.REQUEST,
);

export const Response: (
  options?: ResponseDecoratorOptions,
) => ParameterDecorator =
  (options?: ResponseDecoratorOptions) => (target, key, index) => {
    if (options?.passthrough) {
      Reflect.defineMetadata(
        RESPONSE_PASSTHROUGH_METADATA,
        options?.passthrough,
        target.constructor,
        key,
      );
    }
    return createRouteParamDecorator(RouteParamtypes.RESPONSE)()(
      target,
      key,
      index,
    );
  };

这些装饰器注入的元数据是什么时候被解析的呢?我们得把目光回归到这个位置才行。 这个地方引入了一个RouterExecutionContext类,追踪进去看一下它做了什么,这个方法很长,我们只需要先关注getMetadata方法。 然后又进入到了一个很关键的exchangeKeysForValues方法,此刻就有点儿快接近字段映射真相了。 这个exchangeKeyForValue方法就有点儿像我们经常写的数组的map方法那样,只不过这个位置它处理的RequestResponse对象,Next方法。这个位置的paramsFactory是外界传递进来的,那就得回到RouterExecutionContext类初始化的位置,得回到RouterExplorer的constructor的位置了。 查看这个方法的定义,那也确实简单,即从某个对象上取相应的字段。

最后,我们再来看NestJS官方给我们的自定义装饰器的例子。

ts 复制代码
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const User = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    return data ? user?.[data] : user;
  },
);

NestJS发现是自行实现的参数映射逻辑,就走我们定义的取值逻辑。 多记录了一个自定义转换函数 这儿有些同学可能看不太懂,给大家解释一下,getCustomFactory方法得到是我们传递进去的factory转换函数,contextFactory的执行结果是ExecutionContextHost,那不就是如NestJS官方文档写的那样了吗,第一个参数是装饰器配置的路由元信息,第二个参数最终得到的就是执行上下文,就可以转换成我们自定义的结果了。

初探NestJS的运行原理之Action的返回类型

之前我们就已经留下了疑问,NestJS是怎么知道我们返回的内容该怎么样映射成用户请求的HTTP资源呢? 我们得再把目光切回RouterExecutionContext这个类上,核心逻辑在这个fnHandlerResponse上: 首先是处理模板引擎的渲染逻辑 比如这种用法:

ts 复制代码
import { Get, Controller, Render } from '@nestjs/common';

@Controller()
export class AppController {
  @Get()
  @Render('index')
  root() {
    return { message: 'Hello world!' };
  }
}

Render装饰器的逻辑如下: NestJS读取Render装饰器定义的元数据 responseController的render方法调用了更加底层的render,来源于applicationRef,它的类型是HttpServer 追踪这个方法,可以看到是在http-server.interface.ts里面,那就基本上明确了,NestJS定义了一个规范,@nestjs/platform-express包的Adaptor实现了这个方法,在这个位置,result就是Action方法返回的供模板引擎渲染页面的数据,res是透传过来的ExpressResponse对象。

ts 复制代码
class RouterExecutionContext {
    
  public create(
    instance: Controller,
    callback: (...args: any[]) => unknown,
    methodName: string,
    moduleKey: string,
    requestMethod: RequestMethod,
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ) {
    const {
      // 省略了很多无关代码
      fnHandleResponse,
    } = this.getMetadata(
      instance,
      callback,
      methodName,
      moduleKey,
      requestMethod,
      contextType,
    );
    const interceptors = this.interceptorsContextCreator.create(
      instance,
      callback,
      moduleKey,
      contextId,
      inquirerId,
    );
    // 省略了很多暂时还不需要过多关注的代码
    const handler =
      <TRequest, TResponse>(
        args: any[],
        req: TRequest,
        res: TResponse,
        next: Function,
      ) =>
      async () => {
        fnApplyPipes && (await fnApplyPipes(args, req, res, next));
        return callback.apply(instance, args);
      };

    return async <TRequest, TResponse>(
      req: TRequest,
      res: TResponse,
      next: Function,
    ) => {
      const args = this.contextUtils.createNullArray(argsLength);
      this.responseController.setStatus(res, httpStatusCode);
      hasCustomHeaders &&
        this.responseController.setHeaders(res, responseHeaders);
      // 执行拦截器得到控制器的方法返回的内容
      const result = await this.interceptorsConsumer.intercept(
        interceptors,
        [req, res, next],
        instance,
        callback,
        handler(args, req, res, next),
        contextType,
      );
      // 删除了类型映射,便于阅读
      await fnHandleResponse(result, res, req);
    };
  }
  
  public getMetadata<TContext extends ContextType = ContextType>(
    instance: Controller,
    callback: (...args: any[]) => any,
    methodName: string,
    moduleKey: string,
    requestMethod: RequestMethod,
    contextType: TContext,
  ): HandlerMetadata {
    // 省略了很多代码,只展示了fnHandleResponse函数的来源逻辑
    const fnHandleResponse = this.createHandleResponseFn(
      callback,
      isResponseHandled,
      httpRedirectResponse,
    );
    const handlerMetadata: HandlerMetadata = {
      // 省略了无关内容
      fnHandleResponse,
    };
    return handlerMetadata;
  }
  
  public createHandleResponseFn(
    callback: (...args: unknown[]) => unknown,
    isResponseHandled: boolean,
    redirectResponse?: RedirectResponse,
    httpStatusCode?: number,
  ): HandleResponseFn {
    // 处理模板引擎的逻辑
    const renderTemplate = this.reflectRenderTemplate(callback);
    if (renderTemplate) {
      return async <TResult, TResponse>(result: TResult, res: TResponse) => {
        return await this.responseController.render(
          result,
          res,
          renderTemplate,
        );
      };
    }
    // 已省略无关的代码
    // 处理一般返回
    return async <TResult, TResponse>(result: TResult, res: TResponse) => {
      result = await this.responseController.transformToResult(result);
      !isResponseHandled &&
        (await this.responseController.apply(result, res, httpStatusCode));
      return res;
    };
  }
}

搞明白了模板引擎是怎么渲染的了,再看一下返回JSON是怎么给前端的。

也是来源于@nestjs/platform-express注入的Express的方法。

其它的情况也就基本上类似了,最后再看一下我们通过@Header是怎么设置返回内容的HTTP的Response Header的基本上就差不多了。

先看一下Header装饰器的定义: 通过扫描装饰器的元数据得到返回的Header 最终设置到Response对象上即可。

总结

至此,我们可以给NestJS的路由处理流程来做个总结(暂时不考虑中间件,拦截器,守卫):

NestJS在程序启动的时候会注入一个更底层HTTP实现类,(比如默认是Express,正是因为这样的设计,也给了我们能够自定义底层Http框架的实现的能力)这个适配器必须遵循NestJS的适配器的规则;IoC容器在创建好了Controller的实例之后,NestJS开始去找什么样的路由该映射到哪个控制器的哪个方法上;并且,控制器的方法的参数的映射规则(怎么样从原始的request对象,response对象,next方法转换成控制器方法需要的数据),通过解析装饰器的元数据得到,通过解析装饰器的元数据,知道控制器方法的返回内容该怎么样对应上最终的Http资源,最后将控制器方法跟Express的实现进行绑定,实现将执行结果响应给浏览器。

在下一节内容,我们将开始分析NestJS的中间件,拦截器,守卫等内容的技术细节,敬请期待!

相关推荐
向前看-2 分钟前
验证码机制
前端·后端
燃先生._.1 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖2 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235242 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240253 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar3 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人4 小时前
前端知识补充—CSS
前端·css
GISer_Jing4 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245524 小时前
吉利前端、AI面试
前端·面试·职场和发展