들어가며
- 목표는 이전 글과 동일합니다.
- 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;
}
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 를 사용하는 부분부터 이어서 보겠습니다.