ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • graphst decorator 살펴보기
    graphst프로젝트 2023. 8. 8. 00:32

    이전 포스팅에선 컨테이너를 만드는 코드를 살펴봤습니다. 이번 포스팅에선 데코레이터를 사용해 schemaresolver를 자동으로 생성해 주는 코드를 살펴보기 전에 graphst에서 사용하는 데코레이터의 사용법을 간단히 확인하겠습니다.

     

     

    들어가기 전에...

    graphql엔진 은 gql요청을 해석후 schema와 대조해 해당 schema와 쌍을 이루는 resolver 메서드를 호출합니다.

    req.on('end', async () => {
      const { query, variables } = JSON.parse(body);
    
      const result = await graphql(
        graphqlSchema,
        query,
        null,
        context,
        variables
      );
      
      ...
      
      res.end(JSON.stringify(result));
    });

    API server는 리퀘스트를  위같이 처리할 겁니다.

    graphql엔진이 처리할 때 사용할 schema, resolver가 담겨있는 graphqlSchema를 전달해 줄 겁니다.

     

    Resolver

    const resolvers = {
      Query: {
        posts() {
          return posts
        }
        getAuthor() {
          return {
            id: 1
            ...
          }
        }
      },
      Mutation: {
        upvotePost(_, { postId }) {
          const post = find(posts, { id: postId })
          if (!post) {
            throw new Error(`Couldn't find post with id ${postId}`)
          }
          post.votes += 1
          return post
        }
      },
      Author: {
        posts(author) {
          return filter(posts, { authorId: author.id })
        }
      },
      Post: {
        author(post) {
          return find(authors, { id: post.authorId })
        }
      }
    }

    resolver를 만들어 전달해 준다고 하는데 별거 없이 위 같은 형식처럼 단순한 object로 전달되면 됩니다. Author의 프로퍼티는 전달하지 않아도 됩니다.

    resolver는 어디까지나 "동작"을 정의하는 일종의 계약으로 Author의 프로퍼티는 query를 리턴할 때(위 코드의 getAuthor참고) 작성해주어야 하는 부분입니다.

     

    Schema

    const typeDefs =
      type Author {
        id: ID! # the ! means that every author object _must_ have an id
        firstName: String
        lastName: String
        """
        the list of Posts by this author
        """
        posts: [Post]
      }
    
      type Post {
        id: ID!
        title: String
        author: Author
        votes: Int
      }
    
      # the schema allows the following query:
      type Query {
        posts: [Post]
      }
    
      # this schema allows the following mutation:
      type Mutation {
        upvotePost(postId: ID!): Post
      }

    schema도 별거 없이 위처럼만 만들어주면 됩니다. resolver가 "계약"이라면 schema는 일종의 "명세"입니다.

    grapqhsl엔진은 return값이 Post라는 것을 인지 후 해당하는 명세(schema)를 확인해 어떤 계약(resolver)을 이행할 수 있을지 판단 후 parent, args, context, info순서대로 props를 삽입해 호출합니다.

     

    이제 진짜로 데코레이터를 보겠습니다.

     

    @ObjectType

    @ObjectType()
    class Project {
      @Field(() => GraphQLInt)
      id!: number;
    
      @Field(() => GraphQLString)
      name!: string;
    }

    위 코드에서 사용한 @ObejctType데코레이터는 graphqsl에서 GraphqlObject type으로 취급하겠다는 의미입니다.

    이전 포스팅에서 보았던 Injectable의 기능 또한 포함되어 있습니다.

     

    schema는 아래처럼 만들어질 것입니다.

    type Project {
      id: Int
      name: String
    }

     

    @Query

    ...
    @Query({
      returnType: () => Project,
    })
    getProject(): Project {
      return {
        id: 1,
        name: '테스트 프로젝트',
      };
    }

     

    schema로 변환되면 아래처럼

    type Project {
      id: Int
      name: String
    }

    type Query {
      // returnType에서 리턴한 Project를 사용해 리턴타입을 지정해 줍니다.
      getProject: Project
    }

     

     

    resolver로 변환되면 아래처럼 보입니다.

    Query: {
      getProject() {
        return {
          id: 1,
          name: '테스트 프로젝트',
        };
      }
    },

     

    @Query 데코레이터를 사용해 query에 담길 메서드를 지정해 줄 수 있습니다. schema에서 getProject를 확인할 수 있기 때문에 graphql엔진은 getProject를 호출합니다. 이때 resolver의 리턴값과 schema의 리턴값이 다르다면 에러를 리턴해주는 것도 graphql엔진이 해주는 일입니다.

     

    @FieldResolver

    @FieldResolver({
      parent: () => Project,
      returnType: () => GraphQLBoolean,
      // 기본적으로 메서드이름을 따르지만 옵션으로 다른 이름을 줄수도 있습니다.
      name: 'hasProject',
      args: {
        keys: () => GraphQLList(GraphQLInt),
      },
    })
    isProject(parent: Project, args: { keys?: number[] }): boolean {
      if ((args.keys ?? []).length > 0 && parent.id === 1) {
        return true;
      }
      return false;
    }
    
    // 스키마로 치환하면 이렇게
    type Project {
      id: Int
      name: String
      // args에서 작성한 값을 맵핑
      hasProject(keys: [Int]): Boolean
    }
    
    type Query {
      // returnType에서 리턴한 Project를 사용해 리턴타입을 지정해줍니다.
      getProject: Project
    }
    
    // resolver로 치환하면 이렇게
    Query: {
      getProject() {
        return {
          id: 1,
          name: '테스트 프로젝트',
        };
      }
    },
    
    Project: {
      hasProject(parent: Project, args: { keys?: number[] }) {
        if ((args.keys ?? []).length > 0 && parent.id === 1) {
          return true;
        }
        return false;
      }
    }

    @FieldResolver 데코레이터는 type의 field를 추가할 수 있는 또 다른 방법입니다.

    어떤 클래스의 field로 들어갈지 parent값을 리턴해주면 어느 schema타입 resolver객체에 들어갈지 알 수 있습니다.

     

    여기까지 데코레이터를 대강 봤습니다. 물론 @Mutation도 있지만 @Query와 동작방식이 똑같기 때문에 스킵했습니다.

    이제 위에서 언급한 모든 데코레이터에 공통적으로 사용할 수 있는 middlewares기능에 대해 살펴보겠습니다.

     

    Middlewares

    MiddlewareClass의 배열을 받도록 타입이 지정되어 있습니다.

    graphst의 middlewares는 nest.js로 치면 Interceptors, middleware, Extensions, Filters, Pipes, Guards처럼 요청 중간에 동작하는 기능을 지원하는 옵션입니다.

    사실 위의 Interceptors, middleware, Extensions, Filters, Pipes, Guards들은 graphst뿐 아니라 koa같은 서버 빌드 프레임워크에선 딱히 쓰지 않는 기능입니다. 그 이유는 아래 코드에서 살펴볼 수 있습니다.

    export interface MiddlewareInterface {
      handle(
        context: GraphstProps,
        next: (context?: GraphstProps) => void
      ): void | Promise<void>;
    }
    
    export type MiddlewareClass = new () => MiddlewareInterface;

    MiddlewareInterface는 handle을 구현하도록 인터페이스가 지정되어 있습니다.

    @Injectable()
    export class JwtMiddleware implements MiddlewareInterface {
      @Inject(() => JwtService)
      jwtService!: JwtService;
    
      handle(
        props: GraphstProps,
        next: (props?: GraphstProps | undefined) => void
      ): void | Promise<void> {
        const authorization = props.context.req.headers.authorization ?? '';
        const token = authorization.replace('Bearer ', '');
    
        try {
          const decodedToken = this.jwtService.verify(token);
          const propWithAuth = {
            ...props,
            context: {
              ...props.context,
              auth: decodedToken,
            },
          };
    
          return next(propWithAuth);
        } catch (error: any) {
          if (error instanceof JsonWebTokenError) {
          	...
            if (
              ignoreParents.has(`${props.info.parentType}`) ||
              ignorePaths.has(props.info.fieldName)
            ) {
              return next();
            }
            throw new GraphstError('token is not valid');
          }
          throw new Error(error);
        }
      }
    }

    위 코드를 간략히 살펴보자면 MiddlewareInterface인터페이스를 구현해 Jwt토큰 검사를 하는 간단한 코드입니다.

    context도 수정할 수 있고 Guards처럼 다음 코드를 막을 수도 있습니다. 또한 next() 전후로 코드를 작성해 resolver가 호출되고 난 전후로 logger기능 또한 넣을 수 있을 겁니다.

    위 코드 하나로 사실상 nest에서 제공하는 기능들을 다 구현할 수 있습니다.

    이전 포스팅에서 봤던 것처럼 미들웨어에서도 @Inject데코레이터를 사용해 주입받아 사용할 수 있게 만들었습니다.

     

    middleware작동원리

    export async function middlewareExecute(
      context: GraphstProps,
      middlewares: MiddlewareClass[],
      resolver: Function
    ) {
      if (middlewares.length > 0) {
        const [middleware, ...nextMiddlewares] = middlewares;
        const middlewareInstance =
          Container.getProvider(middleware) ?? new middleware();
        return await middlewareInstance.handle(context, (handlerContext) =>
          middlewareExecute(handlerContext ?? context, nextMiddlewares, resolver)
        );
      }
      return resolver(context.parent, context.args, context.context, context.info);
    }

    middlewareExecute는 콜백함수로 미들웨어가 모두 소비될 때까지 자기 자신을 계속 호출할 겁니다.

    resolver가 가지고 있는 메서드 즉 함수를 콜백함수로 취하게 됩니다. 위 코드에서 제공된 next가 다음 미들웨어를 콜 하는 원리를 가지고 있기 때문에 next를 호출하지 않으면 resolver가 호출되지 않습니다. 

    또한 context를 수정해 인자로 전달해 준다면 다음 미들웨어뿐 아니라 최종적으로 호출될 resolver 메서드에도 변경된 context를 전달할 수 있게 됩니다.

    resolverMethods[methodName] = (
      parent: any,
      args: any,
      context: any,
      info: any
    ) => middlewareExecute(
      {
        parent,
        args,
        context,
        info,
      },
      ...,
      fn //이게 원래 메서드입니다.
    )

    처음에 graphql엔진은 "parent, args, context, info순서대로 props를 삽입해 호출합니다."라고 했습니다. graphql엔진은 "schema name에 맞는 resolver에 4가지 props를 넣어 호출할게! 리턴타입만 맞춰줘!"라는 자기 일을 그저 열심히 할 뿐입니다.  이제 graphql은 정확한 내부구현은 모른 채 위 코드를 실행할 겁니다. 

     

    마치며...

    이번 포스팅에선 graphst에서 사용한 데코레이터를 살펴봤습니다.

    query, mutation, 말고도 subscription라는 친구도 있는데 이번 프로젝트에선 구현하지 않았습니다... 앞으로 추가해야겠죠

     

    추가로 nest의 Guards 같은 기능들은 위 같은 미들웨어를 잘라 분기별로 나눠 구현되어 있다는 느낌입니다. 딱히 nest가 이상하게 구현했다 그런 건 아닙니다. 다만... "Koa처럼 middleware하나 가지고 다 구현할 수 있는 기능이지 않나???"가 nest스터디원들의 의견이였고 저도 그 부분을 적극 반영해서 만들어봤습니다!

    graphst는 사실 굉장히 불친절한 프레임워크로 만드는게 목표였습니다. nest가 웬만한 기능을 추상화해 구현해 놓은것에 비해 graphst는 확장 가능성만 제공하고 "필요한건 니가 직접 만들어라" 가 모토입니다. 위에 JwtMiddleware만 봐도 그렇죠.

     

    다음포스팅에선 위 데코레이터로 수집한 데이터를 어떤 방식으로 schema를 만드는지 뜯어보겠습니다.

    많이 부족한글 끝까지 읽어주셔서 감사합니다!!

Designed by Tistory.