NestJS middleware는 어떻게 작동할까 (2)

들어가며


  • 목표는 이전 글과 동일합니다.
    • nestJS 미들웨어의 코드가 어떻게 구성되어 있는지 확인해본다.
    • 직접 사용해본다.
    • 사용해보며 문제가 있거나 / 이슈가 열려있다면 기여해본다.
  • 이번 글에서는 MiddlewareBuilder에 대해 알아보겠습니다.

기준코드


import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}

MiddlewareBuilder (MiddlewareConsumer)


// packages/core/middleware/builder.ts
export class MiddlewareBuilder implements MiddlewareConsumer {
  private readonly middlewareCollection = new Set<MiddlewareConfiguration>();

  constructor(
    private readonly routesMapper: RoutesMapper,
    private readonly httpAdapter: HttpServer,
    private readonly routeInfoPathExtractor: RouteInfoPathExtractor,
  ) {}

  public apply(
    ...middleware: Array<Type<any> | Function | any>
  ): MiddlewareConfigProxy {
    //code
  }

  public build(): MiddlewareConfiguration[] {
    //code
  }

  public getHttpAdapter(): HttpServer {
	  //code
  }

  private static readonly ConfigProxy = class implements MiddlewareConfigProxy {
    private excludedRoutes: RouteInfo[] = [];

    constructor(
      private readonly builder: MiddlewareBuilder,
      private readonly middleware: Array<Type<any> | Function | any>,
      private routeInfoPathExtractor: RouteInfoPathExtractor,
    ) {}

    public getExcludedRoutes(): RouteInfo[] {
		  //code
    }

    public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
	    //code
    }

    public forRoutes(
      ...routes: Array<string | Type<any> | RouteInfo>
    ): MiddlewareConsumer {
	    //code
    }

    private getRoutesFlatList(
      routes: Array<string | Type<any> | RouteInfo>,
    ): RouteInfo[] {
      //code
    }

    private removeOverlappedRoutes(routes: RouteInfo[]) {
			//code
    }
  };
}

  • 해당 객체는 미들웨어를 컨트롤하기 위해 여러 메소드를 제공하는 helper class 입니다.
  • 예제 코드에서 봤던 메서드들이 보입니다. (apply, forRoutes, 등등)

1. apply


 public apply(
    ...middleware: Array<Type<any> | Function | any>
  ): MiddlewareConfigProxy {
    return new MiddlewareBuilder.ConfigProxy(
      this,
      flatten(middleware),
      this.routeInfoPathExtractor,
    );
  }
  • apply 메소드 부터 확인하겠습니다.
  • 해당 메소드는 매개변수로 미들웨어를 받고, ConfigProxy를 새로운 인스턴스로 생성하여 반환합니다.
  • 가장 먼저 apply가 호출되고, 체이닝 되어 반환된 해당 ConfigProxy 인스턴스의 exclude, forRoute 메서드를 사용할 수 있는 것입니다.

2. configProxy 클래스


private static readonly ConfigProxy = class implements MiddlewareConfigProxy {
    private excludedRoutes: RouteInfo[] = [];

    constructor(
      private readonly builder: MiddlewareBuilder,
      private readonly middleware: Array<Type<any> | Function | any>,
      private routeInfoPathExtractor: RouteInfoPathExtractor,
    ) {}

    public getExcludedRoutes(): RouteInfo[] {
      return this.excludedRoutes;
    }

    public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        (excludedRoutes, route) => {
          for (const routePath of this.routeInfoPathExtractor.extractPathFrom(
            route,
          )) {
            excludedRoutes.push({
              ...route,
              path: routePath,
            });
          }

          return excludedRoutes;
        },
        [] as RouteInfo[],
      );

      return this;
    }

    public forRoutes(
      ...routes: Array<string | Type<any> | RouteInfo>
    ): MiddlewareConsumer {
      const { middlewareCollection } = this.builder;

      const flattedRoutes = this.getRoutesFlatList(routes);
      const forRoutes = this.removeOverlappedRoutes(flattedRoutes);
      const configuration = {
        middleware: filterMiddleware(
          this.middleware,
          this.excludedRoutes,
          this.builder.getHttpAdapter(),
        ),
        forRoutes,
      };
      middlewareCollection.add(configuration);
      return this.builder;
    }

    private getRoutesFlatList(
      routes: Array<string | Type<any> | RouteInfo>,
    ): RouteInfo[] {
      const { routesMapper } = this.builder;

      return iterate(routes)
        .map(route => routesMapper.mapRouteToRouteInfo(route))
        .flatten()
        .toArray();
    }

    private removeOverlappedRoutes(routes: RouteInfo[]) {
      const regexMatchParams = /(:[^\/]*)/g;
      const wildcard = '([^/]*)';
      const routesWithRegex = routes
        .filter(route => route.path.includes(':'))
        .map(route => ({
          method: route.method,
          path: route.path,
          regex: new RegExp(
            '^(' + route.path.replace(regexMatchParams, wildcard) + ')$',
            'g',
          ),
        }));

      return routes.filter(route => {
        const isOverlapped = (item: { regex: RegExp } & RouteInfo): boolean => {
          if (route.method !== item.method) {
            return false;
          }
          const normalizedRoutePath = stripEndSlash(route.path);
          return (
            normalizedRoutePath !== item.path &&
            item.regex.test(normalizedRoutePath)
          );
        };
        const routeMatch = routesWithRegex.find(isOverlapped);
        return routeMatch === undefined;
      });
    }
  };
  • apply하여 반환된 ConfigProxy 객체에 미들웨어에 옵션을 적용을 할 수 있습니다.

3. exclude


public exclude(
      ...routes: Array<string | RouteInfo>
    ): MiddlewareConfigProxy {
      this.excludedRoutes = this.getRoutesFlatList(routes).reduce(
        (excludedRoutes, route) => {
          for (const routePath of this.routeInfoPathExtractor.extractPathFrom(
            route,
          )) {
            excludedRoutes.push({
              ...route,
              path: routePath,
            });
          }

          return excludedRoutes;
        },
        [] as RouteInfo[],
      );

      return this;
    }
  • 미들웨어 적용을 제외할 route들을 적용하는 메서드입니다.
  • 매개변수로 전달받은 routes를 바탕으로 제외할 route들의 배열을 configProxy 객체의 필드인 **excludedRoutes: RouteInfo[] = []**에 저장합니다.
  • routes의 형태는 string, RouteInfo 두가지입니다.
    • string은 /user 와 같은 문자열로 된 route입니다.
    • RouteInfo는 다음과 같습니다.
    RouteInfo {
      path: string; // path 문자열
      method: RequestMethod; // http Method (e.g GET, POST)
      version?: VersionValue; // 필수값은 아님
    }
    

getRoutesFlatList


private getRoutesFlatList(
	routes: Array<string | Type<any> | RouteInfo>,
): RouteInfo[] {
  const { routesMapper } = this.builder;

	return iterate(routes)
	  .map(route => routesMapper.mapRouteToRouteInfo(route))
    .flatten()
    .toArray();
}
  • 전달받았던 route들을 올바르게 변환하여 반환합니다.
  • 예를 들어, 다음과 같이 exclude 메소드가 수행되었다고 가정하겠습니다.
exclude('cat', { path: 'dog', method: 'GET' });
  • 가변인자 …routes 배열에 요소로서 추가가되어, routes 배열은 다음과 같이 구성됩니다.
routes = ['cat', { path: 'dog', method: 'GET' }];
  • routesMapper.mapRouteToRouteInfo 의 경우 각 항목을 RouteInfo 객체로 변환하는 역할을 하는데요, 위의 routes를 기준으로 한다면, 문자열 cat은 RouteInfo 객체로 변환되고, 두번째 객체는 이미 RouteInfo 객체이기 때문에 그대로 유지됩니다.
  • 변환된 결과는 다음과 같습니다.
[{ path: 'cat', method: RequestMethod.ALL }, { path: 'dog', method: 'GET' }]

reduce


  • 이제 reduce를 돌면서 가공된 정보들을 excludedRoutes 배열에 저장합니다.
  • 아래 코드가 실행됩니다.
this.getRoutesFlatList(routes).reduce((excludedRoutes, route) => {...}, [] as RouteInfo[]) 
  • route 들에 대해 **routeInfoPathExtractor.extractPathFrom(route)**가 호출됩니다. 이 메서드는 route 객체에서 경로를 추출합니다.
  • 첫 번째 route: { path: 'cat', method: RequestMethod.ALL }
    • ['/cat']를 반환합니다.
    • excludedRoutes.push({ ...route, path: 'test' })가 실행되어 { path: '/cat', method: RequestMethod.ALL }가 추가됩니다.
  • 두 번째 route: { path: 'dog', method: 'GET' }
    • ['/dog']를 반환합니다.
    • excludedRoutes.push({ ...route, path: 'test2' })가 실행되어 { path: '/dog', method: 'GET' }가 추가됩니다.
  • 최종적으로 아래와 같은 배열로 excludedRoutes 가 저장됩니다.
[
	{ path: '/cat', method: RequestMethod.ALL },
	{ path: '/dog', method: 'GET' }
]
  • 마지막으로 return this;를 통해 MiddlewareConfigProxy 를 반환해서 체이닝이 가능하게 합니다.

4. forRoutes


  public forRoutes(
      ...routes: Array<string | Type<any> | RouteInfo>
    ): MiddlewareConsumer {
      const { middlewareCollection } = this.builder;

      const flattedRoutes = this.getRoutesFlatList(routes);
      const forRoutes = this.removeOverlappedRoutes(flattedRoutes);
      const configuration = {
        middleware: filterMiddleware(
          this.middleware,
          this.excludedRoutes,
          this.builder.getHttpAdapter(),
        ),
        forRoutes,
      };
      middlewareCollection.add(configuration);
      return this.builder;
    }
  • 미들웨어를 적용할 route들을 반영하는 메서드입니다.
  • 해당 메서드도 똑같이 routes를 매개변수로 받는데요, 타입은 string, RouteInfo, any 입니다.
const { middlewareCollection } = this.builder;
  • builder의 middlewareCollection필드를 가져옵니다.
const flattedRoutes = this.getRoutesFlatList(routes);
  • routes를 평탄화해서 flattedRoutes 변수에 저장합니다.
    • exclude에서도 사용된 메소드로, routes를 RouteInfo 타입으로 변환해서, flatten해서 반환하는 메소드입니다.

4.1 removeOverlappedRoutes

 const forRoutes = this.removeOverlappedRoutes(flattedRoutes);
 private removeOverlappedRoutes(routes: RouteInfo[]) {
      const regexMatchParams = /(:[^\/]*)/g;
      const wildcard = '([^/]*)';
      const routesWithRegex = routes
        .filter(route => route.path.includes(':'))
        .map(route => ({
          method: route.method,
          path: route.path,
          regex: new RegExp(
            '^(' + route.path.replace(regexMatchParams, wildcard) + ')$',
            'g',
          ),
        }));
        
      return routes.filter(route => {
        const isOverlapped = (item: { regex: RegExp } & RouteInfo): boolean => {
          if (route.method !== item.method) {
            return false;
          }
          const normalizedRoutePath = stripEndSlash(route.path);
          return (
            normalizedRoutePath !== item.path &&
            item.regex.test(normalizedRoutePath)
          );
        };
        const routeMatch = routesWithRegex.find(isOverlapped);
        return routeMatch === undefined;
      });
}
  • 평탄화된 경로 리스트에서 중첩된 경로를 제거하고 결과를 forRoutes 변수에 저장합니다.
const regexMatchParams = /(:[^\/]*)/g;
const wildcard = '([^/]*)';
const routesWithRegex = routes
  .filter((route) => route.path.includes(':'))
  .map((route) => ({
    method: route.method,
    path: route.path,
    regex: new RegExp('^(' + route.path.replace(regexMatchParams, wildcard) + ')$', 'g'),
}));
  • regexMatchParams → 경로에서 콜론으로 시작하는 모든 매개변수 부분을 찾습니다. (e.g /user/:id)
  • wildcard → 와일드카드 정규 표현식을 정의합니다. ([^/]*)는 슬래시(/)를 제외한 모든 문자를 매칭합니다.
  • 우선 routes를 돌며, 경로에 콜론(:)이 포함된 RouteInfo 객체만 필터링합니다.
  • 필터링된 객체에 대해서 path는 그대로 두고, regex 필드에 route.path에서 매개변수 부분을 와일드카드 정규 표현식으로 대체하여 새로운 정규 표현식을 만듭니다.
    • e.g) 경로 /user/:id는 정규 표현식 ^(/user/([^/]*))$로 변환됩니다.
  • 해당 메소드를 통해 경로에 매개변수가 포함된 경우(:) 이를 정규 표현식으로 변환하여 중첩된 경로를 찾기 쉽게 하는데요, 이렇게 변환된 정규 표현식을 사용해서 중복된 경로를 제거할 수 있습니다.
return routes.filter((route) => {
  const isOverlapped = (item: { regex: RegExp } & RouteInfo): boolean => {
    if (route.method !== item.method) {
      return false;
    }
    const normalizedRoutePath = stripEndSlash(route.path);
    return normalizedRoutePath !== item.path && item.regex.test(normalizedRoutePath);
  };
  const routeMatch = routesWithRegex.find(isOverlapped);
  return routeMatch === undefined;
});
  • 원래 경로 배열에서 중복된 경로를 제거하여 필터링합니다.
  • isOverlapped 함수
    • 경로의 HTTP 메서드가 다르면 중복된 경로가 아니므로 false를 반환합니다.
    • 경로의 끝에 있는 슬래시를 제거하여 normalizedRoutePath에 저장합니다.
    • normalizedRoutePath가 다른 경로와 일치하지 않으며, 정규 표현식에 일치하면 true를 반환합니다.
    • 중복된 경로인 경우 true, 그렇지 않으면 false를 반환합니다.
  • routesWithRegex 배열에서 중복된 경로를 찾습니다.
  • 중복된 경로가 없으면 true를 반환하여 해당 경로를 필터링된 결과에 포함시킵니다.
const routes = [
  { path: '/user/:id', method: 'GET' },
  { path: '/user/test', method: 'GET' }
];
  • 위와 같이 아래와 같은 경로가 들어올 경우를 가정하겠습니다.
[
  {
    method: 'GET',
    path: '/user/:id',
    regex: /^\/user\/([^/]*)$/
  }
]
  • routesWithRegex 배열
  • /:id의 경우 매개변수 :id를 포함하므로 필터링 배열에 들어갑니다.
[
  { path: '/user/:id', method: 'GET' }
]
  • 최종결과는 배열은 다음과 같습니다.
    • 첫 번째 경로 /user/:id
      • route.path는 /user/:id입니다.
      • isOverlapped 함수는 routesWithRegex 배열에서 경로가 중복되는지 확인합니다.
      • /user/:id는 자기 자신이므로 중복되지 않습니다.
    • 두 번째 경로 /user/test
    • route.path는 /user/test입니다.
    • isOverlapped 함수는 routesWithRegex 배열에서 경로가 중복되는지 확인합니다.
    • /user/test는 /user/:id의 정규 표현식 ^/user/([^/]*)$에 매칭됩니다. /user/test는 /user/:id와 중복되므로 필터링됩니다.

4.2 filterMiddleware, configuration


const configuration = {
		middleware: filterMiddleware(
		    this.middleware,
        this.excludedRoutes,
	      this.builder.getHttpAdapter(),
    ),
    forRoutes,
};
middlewareCollection.add(configuration);
  • 미들웨어 설정 객체를 만듭니다, 미들웨어와 경로 리스트를 포함합니다.
  • filterMiddleware 함수로 미들웨어를 필터링하여 설정 객체에 추가합니다.
export const filterMiddleware = <T extends Function | Type<any> = any>(
  middleware: T[],
  routes: RouteInfo[],
  httpAdapter: HttpServer,
) => {
  const excludedRoutes = mapToExcludeRoute(routes);
  return iterate([])
    .concat(middleware)
    .filter(isFunction)
    .map((item: T) => mapToClass(item, excludedRoutes, httpAdapter))
    .toArray();
};
  • mapToExcludeRoute 함수를 사용하여 routes를 제외할 경로로 매핑합니다.
  • 빈 배열을 시작점으로 하여 middleware 배열을 연결합니다.
  • isFunction 함수를 사용하여 함수 타입인 미들웨어만 필터링합니다.
  • mapToClass 함수를 사용하여 각 미들웨어를 클래스 형태로 변환하고, 제외할 경로와 HTTP 어댑터를 전달합니다.
  • 최종 결과를 배열로 변환하여 반환합니다.

configuration 객체

  • forRoutes 메서드에서 configuration 객체는 특정 경로에 대해 적용할 미들웨어 설정을 저장합니다.
  • middlewareCollection.add(configuration)을 통해 middlewareCollection에 생성된 configuration 객체를 추가합니다.
  • 이를 통해 미들웨어 설정을 내부 컬렉션에 저장하여 나중에 (필요할때) 사용할 수 있게 합니다.
  • 미들웨어 설정을 사용하고 체이닝 하는데 있어 꼭 forRoutes() 메소드가 마지막으로 와야한다.
    • exclude, apply등을 반영하고 마지막으로 middlewareCollection에 저장하는 역할을 하기 때문이다.
    • 또한 builder를 마지막으로 반환하기 때문이다.
    // 사용시 호출 순서를 지킬 것 
    configure(consumer: MiddlewareConsumer) {
        consumer
          .apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
          .exclude('test', 'overview/:id', 'wildcard/(.*)', {
              path: 'middleware',
              method: RequestMethod.POST,
           }) // ignored
          .exclude('multiple/exclude')
          .forRoutes('*');
     }
    

5. build


public build(): MiddlewareConfiguration[] {
    return [...this.middlewareCollection];
}
  • 간단하게 위 과정들을 통해서 생성된 middlewareCollection를 배열에 감싸서 반환하는 메서드입니다.

이어가며


  • middlewareBuilder 에 대해서는 알아봤는데요, 다시 첫번째 글로 돌아가서 이렇게 생성 middlewareBuilder 를 사용하는 부분부터 이어서 보겠습니다.