[ PART 6 ] GraphQL, Typescript 및 React로 Twitter 복제본 만들기( 트윗 추가 )

안녕하세요 여러분! 트윗 작업을 시작하겠습니다 ;)

데이터베이스 다이어그램 링크: Twitter DbDiagram

먼저 마이그레이션을 생성합니다.

knex migrate:make add_tweets_table -x ts



import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.createTable('tweets', (t) => {
    t.increments('id')
    t.text('body').notNullable()
    t.integer('user_id').unsigned().notNullable()
    t.integer('parent_id').unsigned()
    t.enum('visibility', ['public', 'followers']).defaultTo('public')
    t.enum('type', ['tweet', 'retweet', 'comment']).defaultTo('tweet')
    t.timestamps(false, true)

    t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
    t.foreign('parent_id')
      .references('id')
      .inTable('tweets')
      .onDelete('CASCADE')
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.raw('DROP TABLE tweets CASCADE')
}




knex migrate:latest


더 쉽게 작업할 수 있도록 데이터베이스에 트윗도 추가하겠습니다. 이를 위해 faker 라이브러리를 추가합니다.

yarn add -D faker
yarn add -D @types/faker



knex seed:make seed -x ts


*src/db/seeds/seed.ts

import * as Knex from 'knex'
import faker from 'faker'
import argon2 from 'argon2'
import User from '../../entities/User'

export async function seed(knex: Knex): Promise<void> {
  await knex('users').del()
  await knex('tweets').del()

  for (let user of await createUsers()) {
    const [insertedUser] = await knex('users').insert(user).returning('*')

    const tweetsToInsert = await createTweets(insertedUser)
    await knex('tweets').insert(tweetsToInsert)
  }
}

const createUsers = async () => {
  let users = []
  const hash = await argon2.hash('password')
  for (let i = 0; i < 10; i++) {
    users.push({
      username: faker.internet.userName(),
      display_name: faker.name.firstName(),
      email: faker.internet.email(),
      avatar: faker.internet.avatar(),
      password: hash,
    })
  }
  return users
}

const createTweets = async (user: User) => {
  let tweets = []

  for (let i = 0; i < 20; i++) {
    tweets.push({
      body: faker.lorem.sentence(),
      type: 'tweet',
      user_id: user.id,
      visibility: faker.random.arrayElement(['public', 'followers']),
    })
  }
  return tweets
}




knex seed:run


우리 데이터베이스에는 이제 우리가 즐길 수 있는 데이터가 있습니다 ;)

먼저 Tweet 엔터티를 생성해 보겠습니다.

src/entities/Tweet.ts

import { Field, ID, ObjectType } from 'type-graphql'
import User from './User'

@ObjectType()
class Tweet {
  @Field((type) => ID)
  id: number

  @Field()
  body: string

  @Field()
  visibility: string

  @Field()
  type: string

  @Field()
  user: User

  user_id: number

  @Field()
  created_at: Date

  @Field()
  updated_at: Date
}

export default Tweet



트윗 작성자를 검색할 수 있는 사용자 속성이 있습니다. 노출하지 않는 user_id 속성도 있습니다. 각 트윗으로 반드시 사용자를 검색해야 한다는 점을 감안할 때 user_id를 노출할 시점이 보이지 않습니다. 그러면 최악의 경우 마음이 바뀌거나 생각이 나지 않으면 바뀌기 쉽습니다 ;).

이제 TweetResolver에서 작업해 보겠습니다.

src/resolvers/TweetResolver.ts

import { Ctx, Query, Resolver } from 'type-graphql'
import Tweet from '../entities/Tweet'
import { MyContext } from '../types/types'

@Resolver()
class TweetResolver {
  @Query(() => [Tweet])
  async feed(@Ctx() ctx: MyContext) {
    const { db } = ctx

    const tweets = await db('tweets').limit(50)

    return tweets
  }
}

export default TweetResolver



테스트를 위해 데이터베이스의 모든 트윗을 검색하기만 하면 됩니다. 우리는 나중에 로직(우리가 팔로우하는 사람들의 트윗만 검색, 페이지 매김 등 ...)에 대해 볼 것입니다.

리졸버에 리졸버를 추가하는 것을 잊지 마십시오.

src/server.ts

export const schema = async () => {
  return await buildSchema({
    resolvers: [AuthResolver, TweetResolver],
    authChecker: authChecker,
  })
}


여기서 첫 번째 "문제"가 발생하기 시작합니다 ;). 연결된 사용자를 가져오지 않고 쿼리를 실행하면 아무런 문제 없이 작동합니다.



내 콘솔에는 다음 SQL 쿼리가 있습니다.

SQL (8.414 ms) select * from "tweets"


이제 연결된 사용자를 검색하여 시도해 보겠습니다.



예상대로 내 SQL 쿼리는 사용자 속성을 반환하지 않으므로 오류가 발생합니다. 이를 해결하기 위해 몇 가지 선택이 있습니다. 예를 들어 메서드 피드에서 직접 사용자를 검색할 수 있습니다. 쿼리 빌더인 Knex를 사용하면 약간의 코드를 작성해야 하지만 ORM을 사용하면 훨씬 쉽게 할 수 있습니다. 예를 들어 Laravel( PHP )을 사용하면 다음과 같은 결과를 얻을 수 있습니다. $tweets = Tweet::with('author')->get(); 나는 아직 Node.js 세계에서 어떤 ORM도 사용하지 않았지만 의심할 여지 없이 같은 것이 있습니다 ;).

하지만 지금은 Knex.js를 사용합니다.

@Query(() => [Tweet])
  async feed(@Ctx() ctx: MyContext) {
    const { db } = ctx
    // Fetch the tweets
    const tweets = await db('tweets').limit(50)

    // Get the userIds from the tweets and remove duplicates.
    // Array.from is used for the whereIn below ;)
    const userIds = Array.from(new Set(tweets.map((t) => t.user_id)))

    // Fetch the users needed
    const users = await db('users').whereIn('id', userIds)

    // Remap the tweets array to add the user property
    return tweets.map((t) => {
      return {
        ...t,
        user: users.find((u) => u.id === t.user_id),
      }
    })
  }


예상대로 작동합니다 ;).



그러나 우리가 바로 보게 될 또 다른 진행 방법이 있습니다 ;)

@FieldResolver를 사용하여 사용자를 복구하는 방법을 알려줍니다.

src/resolvers/TweetResolver.ts

import { Ctx, FieldResolver, Query, Resolver, Root } from 'type-graphql'
import Tweet from '../entities/Tweet'
import User from '../entities/User'
import { MyContext } from '../types/types'

@Resolver((of) => Tweet)
class TweetResolver {
  @Query(() => [Tweet])
  async feed(@Ctx() ctx: MyContext) {
    const { db } = ctx

    const tweets = await db('tweets').limit(50)

    return tweets
  }

  @FieldResolver(() => User)
  async user(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const { db } = ctx
    const [user] = await db('users').where('id', tweet.user_id)

    return user
  }
}

export default TweetResolver



하지만 내 요청을 다시 시작하면 작동하지만 내 로그를 보면 작은 문제가 표시됩니다. ;)



실제로 사용자를 검색하기 위해 각 트윗에 대한 쿼리를 만들 것입니다. 별로 ;). 이 문제를 극복하기 위해 우리는 dataloader 라이브러리를 사용할 것입니다.

yarn add dataloader


GraphQL도 배우면서 데이터 로더와 관련된 파일을 어떻게 구성해야 할지 잘 모르겠습니다. 제안할 제안이나 리포지토리가 있으면 자유롭게 공유하세요 ;).

src/dataloaders/dataloaders.ts

import DataLoader from 'dataloader'
import db from '../db/connection'
import User from '../entities/User'

export const dataloaders = {
  userDataloader: new DataLoader<number, any, unknown>(async (ids) => {
    const users = await db('users').whereIn('id', ids)

    return ids.map((id) => users.find((u) => u.id === id))
  }),
}



Dataloader는 매개변수에서 키를 수신합니다. 여기에서 whereIn을 사용하여 사용자를 검색합니다.
남은 것은 ID를 매핑하여 해당 사용자를 검색하는 것입니다.

그런 다음 액세스할 수 있도록 컨텍스트에 데이터 로더를 추가합니다.

src/server.ts

import { dataloaders } from './dataloaders/dataloaders'

export const defaultContext = ({ req, res }: any) => {
  return {
    req,
    res,
    db,
    dataloaders,
  }
}


남은 일은 @FieldResolver 사용자를 업데이트하는 것입니다.

src/resolvers/TweetResolver.ts

@FieldResolver(() => User)
  async user(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      db,
      dataloaders: { userDataloader },
    } = ctx

    return await userDataloader.load(tweet.user_id)
  }


쿼리를 실행하면 모든 것이 다시 작동하고 내 콘솔에서 작성된 SQL 쿼리를 확인하면 다음과 같이 됩니다.



우리는 훨씬 더 합리적인 수의 요청으로 끝납니다 ;). 반면에 dataloader는 요청을 캐시하므로 예를 들어 트윗을 추가할 때 캐시를 지우는 것을 잊지 말아야 합니다. 하지만 이에 대해서는 나중에 다시 다루겠습니다.

보고 싶다면 테스트도 추가했습니다 ;).

src/tests/tweets.test.ts

import db from '../db/connection'
import { FEED } from './queries/tweets.queries'
import { testClient } from './setup'

describe('Tweets', () => {
  beforeEach(async () => {
    await db.migrate.rollback()
    await db.migrate.latest()
    await db.seed.run()
  })

  afterEach(async () => {
    await db.migrate.rollback()
  })

  test('it should fetch the tweets with user', async () => {
    const { query } = await testClient()

    const res = await query({
      query: FEED,
    })

    expect(res.data.feed[0]).toMatchSnapshot()
  })
})



글쎄, 나는이 기사에 충분하다고 생각합니다 ;). 트윗을 삽입하는 방법을 볼 수 있는 다음 에피소드에서 뵙겠습니다 ;).

안녕히 계세요! 🍹

당신은 2-3 가지를 배우고 나에게 커피를 사주고 싶어;)?
https://www.buymeacoffee.com/ipscoding

좋은 웹페이지 즐겨찾기