チャットアプリケーションの構築によるGraphSQLの学習-パート1


私がGraphqlを学ぶことを決めたとき、私はそれをする最善の方法がその概念を実装しているということを知っていました、したがって、私はチャットアプリケーションを開発することが私がすべてのGraphSQL機能を実践するのを許すので、私のゴールを達成する方法であることを理解します.
我々のアプリケーションは、2つの部分、バックエンドとフロントエンドだけでなく、これらの記事では、これらの記事では、この最初の投稿で分割されると、サーバー側を開発するので、我々はnodejs、アポロサーバともちろんGraphSQLを使用しますので、我々はまた、データベースとクエリビルダのモジュールを使用する必要があります、私はknexとMySQLを使用します.
我々が続く前に、すべてのコードは、これですrepository .

初期設定
まず最初に、プロジェクトの作成と依存関係のインストールによって始めましょう.
プロジェクトフォルダnpm initおよびnpm i apollo-server bcrypt dotenv graphql jsonwebtoken knex lodash mysql npm i --save-dev @babel/cli @babel/core @babel/node @babel/plugin-transform-runtime @babel/preset-env babel-jest jest nodemon standardスクリプトセクションpackage.json 次のコマンドを実行します.
   "start": "nodemon --exec babel-node ./src/index.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "migrate": "knex migrate:latest",
    "unmigrate": "knex migrate:rollback",
    "seed": "knex seed:run",
    "lint": "standard",
    "lint:fix": "standard --fix"
ルートフォルダで.babelrc ファイル
{
  "presets": [
    "@babel/preset-env"
  ],
  "env": {
    "test": {
      "plugins": [
        "@babel/plugin-transform-runtime"
      ]
    }
  }
}
また、ルートフォルダで.env このファイルには、プロジェクトの環境変数が含まれています
NODE_ENV=development

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=toor
DB_NAME=chat

SECRET=secret
最初の変数は環境ですdevelopment 今のところ、次の4つの変数は、データベースホスト、ユーザー、パスワード、および名前です.これらのために、データベース構成に応じて値を設定できます.最後のものは認証で後で使う秘密の値です.
リレーショナルデータベースを設定してください.私はMySQLを使用しています.もしPostgreSQLのように別のものを使いたいなら、knexfile.js .

データベースとモデル
このセクションでは、データベースを構成し、モデルを実装しますknexfile.js ファイル、開発、テスト、および生産環境のデータベース構成が含まれます.
require('dotenv').config()

module.exports = {

  development: {
    client: 'mysql',
    connection: {
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME
    },
    migrations: {
      directory: './src/data/migrations'
    },
    seeds: { directory: './src/data/seeds' }
  },

  test: {
    client: 'mysql',
    connection: {
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME
    },
    migrations: {
      directory: './src/data/migrations'
    },
    seeds: { directory: './src/data/seeds' }
  },

  production: {
    client: 'mysql',
    connection: {
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME
    },
    migrations: {
      directory: './src/data/migrations'
    },
    seeds: { directory: './src/data/seeds' }
  }
}
インsrc/data/ データベースの移動、種子、およびデータベースオブジェクトをエクスポートするファイルを格納することができますknexfile.js :
// src/data/db.js

import knex from 'knex'
import knexfile from '../../knexfile'

const env = process.env.NODE_ENV || 'development'
const configs = knexfile[env]
const database = knex(configs)

export default database
では移動を作成しましょう.knex migrate:make user knex migrate:make message生成されたファイルはknexfile.js , 次のコンテンツが必要です.
// src/data/migrations/20200107121031_user.js

exports.up = (knex) =>
  knex.schema.createTable('user', table => {
    table.bigIncrements('id').unsigned()
    table.string('name').notNullable()
    table.string('email').notNullable()
    table.string('password').notNullable()
  })

exports.down = (knex) => knex.schema.dropSchemaIfExists('user')
// src/data/migrations/20200107121034_message.js

exports.up = (knex) =>
  knex.schema.createTable('message', table => {
    table.bigIncrements('id').unsigned()
    table.string('message').notNullable()
    table.bigInteger('senderId').unsigned().references('id').inTable('user')
    table.bigInteger('receiverId').unsigned().references('id').inTable('user')
  })

exports.down = function (knex) {
  knex.schema.dropSchemaIfExists('message')
}
次のコマンドが作成されますuser and message データベース内のテーブル.npm run migrate次のモデルを作成しますModel クラスは、それを拡張する別のモデルで使用される一般的なメソッドを含みます.
// src/model/Model.js

export default class Model {
  constructor (database, table) {
    this.database = database
    this.table = table
  }

  all () {
    return this.database(this.table).select()
  }

  find (conditions) {
    return this.database(this.table).where(conditions).select()
  }

  findOne (conditions) {
    return this.database(this.table).where(conditions).first()
  }

  findById (id) {
    return this.database(this.table).where({ id }).select().first()
  }

  insert (values) {
    return this.database(this.table).insert(values)
  }
}
その後、我々は作成User and Message モデルは、User モデルは、環境変数を使用してトークンを生成するメソッドですSECRET 以前に定義したのは、トークンを使ってユーザを見つけ、ユーザのメッセージを取得する方法もあります.
// src/model/User.js

import Model from './Model'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'

export class User extends Model {
  constructor (database) {
    super(database, 'user')
  }

  async hash (password) {
    return bcrypt.hash(password, 10)
  }

  async compare (hash, password) {
    return bcrypt.compare(password, hash)
  }

  generateToken (user) {
    /* knex return a RowDataPacket object and jwt.sign function
      expects a plain object, stringify and parse it back does the trick */
    return jwt.sign(
      JSON.parse(JSON.stringify(user)),
      process.env.SECRET,
      {
        expiresIn: 86400
      }
    )
  }

  async getUserByToken (token) {
    try {
      const decoded = jwt.verify(token, process.env.SECRET)
      return decoded
    } catch (error) {
      console.log(error)
      return null
    }
  }

  async getMessages(senderId, lastId) {
    return this.database('message')
      .where('id', '>', lastId)
      .andWhere(q => q.where({ senderId: senderId })
        .orWhere({ receiverId: senderId }))
      .limit(10)
  }
// src/model/Message.js

import Model from './Model'

export class Message extends Model {
  constructor (database) {
    super(database, 'message')
  }

  async getConversation (senderId, receiverId, lastId) {
    return this.database('message')
      .where('id', '>', lastId)
      .andWhere({ senderId })
      .andWhere({ receiverId })
      .limit(10)
  }

}
今、私たちはすべてのモデルをエクスポートする必要がありますindex.js ファイルsrc/model オブジェクトをエクスポートするmodels すべてのモデルを含む.
// src/model/index.js

import database from '../data/db'
import { User } from '../model/User'
import { Message } from '../model/Message'

const user = new User(database)
const message = new Message(database)

const models = {
  user,
  message
}

export default models

スキーマ
最後に、GraphSQLに対処しましょう.スキーマを使用するGraphSQLスキーマ言語は、アプリケーションが提供する型のセットを定義するために、型、クエリ、突然変異、サブスクリプション、オブジェクト型またはスカラー型を定義できます.
クエリ型は、アプリケーションが提供するクエリを定義します.たとえば、すべてのメッセージを取得します.
突然変異タイプは質問のようですが、例えばデータを送るのを許します.
サブスクリプションでは、クライアントがイベントが発生したときにクライアントにデータを送信することができます.通常、WebSocketで実装されています.たとえば、クライアントがメッセージを送信するときに、チャットクライアントでは、受信側クライアントがサーバーに要求せずにメッセージを受信する必要があります.
Object Typeは、ユーザーまたはメッセージのように、アプリケーションが取得できるオブジェクトを定義します.
そして、スカラー型、よく、オブジェクトタイプはフィールドを持ちます、そして、これらのフィールドはストリングまたはintのようないくつかのタイプの値を持っていなければなりません、これらのタイプはスカラー型です、可能なスカラータイプはint、String、float、BooleanとIdです.我々が使用するとき!フィールドが非nullableであることを意味し、私たちのサービスはnullの許容値を返すことを約束します.我々のサービスが配列を返すと指定するならば、我々は[]を使用します.[String]! .
私たちのGraphSQLスキーマは、単一のファイルで完全に定義することができますが、私たちのアプリケーションが大きくなると、そのファイルが混乱になるので、私は、エンティティを表すファイルにスキーマを分離することを決定するので、ユーザースキーマを定義するためのファイルと、メッセージスキーマを定義するための別のファイルがあります.また、すべてのスキーマを一緒に持ってくるファイルがあります.
// src/schema/index.js

import { merge } from 'lodash'
import { gql, makeExecutableSchema } from 'apollo-server'
import {
  typeDef as User,
  resolvers as userResolvers
} from './user'

import {
  typeDef as Message,
  resolvers as messageResolvers
} from './message'

const Query = gql`
  type Query {
    _empty: String
  }
  type Mutation {
    _empty: String
  }
  type Subscription {
    _empty: String
  }
`
export const schema = makeExecutableSchema({
  typeDefs: [Query, User, Message],
  resolvers: merge(userResolvers, messageResolvers)
})

次に、ユーザとメッセージスキーマを作成すると、それぞれのファイルにオブジェクトがあることに気づくでしょうresolvers 私たちはそれについて少し話します.また、constのスキーマを定義するときにも注意してくださいtypeDef 私たちは、型クエリ、突然変異と購読を拡張しています.
// src/schema/message.js

import { gql } from 'apollo-server'

export const subscriptionEnum = Object.freeze({
  MESSAGE_SENT: 'MESSAGE_SENT'
})

export const typeDef = gql`
  extend type Query {
    messages(cursor: String!): [Message!]!
    conversation(cursor: String!, receiverId: ID!): [Message!]!
  }
  extend type Subscription {
    messageSent: Message
  }
  extend type Mutation {
    sendMessage(sendMessageInput: SendMessageInput!): Message!
  }
  type Message {
    id: ID!
    message: String!
    sender: User!
    receiver: User!
  }
  input SendMessageInput {
    message: String!
    receiverId: ID!
  }
`

export const resolvers = {
  Query: {
    messages: async (parent, args, { models, user }, info) => {
      if (!user) { throw new Error('You must be logged in') }

      const { cursor } = args
      const users = await models.user.all()
      const messages = await models.user.getMessages(user.id, cursor)

      const filteredMessages = messages.map(message => {
        const sender = users.find(user => user.id === message.senderId)
        const receiver = users.find(user => user.id === message.receiverId)
        return { ...message, sender, receiver }
      })

      return filteredMessages
    },

    conversation: async (parent, args, { models, user }, info) => {
      if (!user) { throw new Error('You must be logged in') }

      const { cursor, receiverId } = args
      const users = await models.user.all()
      const messages = await models.message.getConversation(user.id, receiverId, cursor)

      const filteredMessages = messages.map(message => {
        const sender = users.find(user => user.id === message.senderId)
        const receiver = users.find(user => user.id === message.receiverId)
        return { ...message, sender, receiver }
      })

      return filteredMessages
    }
  },

  Subscription: {
    messageSent: {
      subscribe: (parent, args, { pubsub, user }, info) => {
        if (!user) { throw new Error('You must be logged in') }

        return pubsub.asyncIterator([subscriptionEnum.MESSAGE_SENT])
      }
    }
  },

  Mutation: {
    sendMessage: async (parent, args, { models, user, pubsub }, info) => {
      if (!user) { throw new Error('You must be logged in') }

      const { message, receiverId } = args.sendMessageInput

      const receiver = await models.user.findById(receiverId)

      if (!receiver) { throw new Error('receiver not found') }

      const result = await models.message.insert([{
        message,
        senderId: user.id,
        receiverId
      }])

      const newMessage = {
        id: result[0],
        message,
        receiver,
        sender: user
      }

      pubsub.publish(subscriptionEnum.MESSAGE_SENT, { messageSent: newMessage })

      return newMessage
    }
  }
}
// src/schema/user.js

import { gql } from 'apollo-server'

export const typeDef = gql`
  extend type Query {
    users: [User!]!
  }
  extend type Mutation {
    createUser(createUserInput: CreateUserInput!): User!
    login(email: String!, password: String!): String!
  }
  type User {
    id: ID!
    name: String!
    email: String!
    password: String!
  }
  input CreateUserInput {
    name: String!
    email: String!
    password: String!
  }
`

export const resolvers = {
  Query: {
    users: async (parent, args, { models, user }, info) => {
      if (!user) { throw new Error('You must be logged in') }

      const users = await models.user.all()
      return users
    }
  },

  Mutation: {
    createUser: async (parent, args, { models }, info) => {
      const { name, email, password } = args.createUserInput
      const user = await models.user.findOne({ email })

      if (user) { throw new Error('Email already taken') }

      const hash = await models.user.hash(password)

      const result = await models.user.insert([{
        name,
        email,
        password: hash
      }])

      return {
        id: result[0],
        password: hash,
        name,
        email
      }
    },

    login: async (parent, args, { models }, info) => {
      const { email, password } = args

      const user = await models.user.findOne({ email })

      if (!user) { throw new Error('Invalid credentials') }

      if (!await models.user.compare(user.password, password)) { throw new Error('Invalid credentials') }

      return models.user.generateToken(user)
    }
  }
}
各ファイルにはconsttypeDef このスキーマのリゾルバは、リゾルバーオブジェクトにあります.
それでは、そのリゾルバのオブジェクトは何ですか?リゾルバには、アプリケーションスキーマで定義されたクエリ、突然変異またはサブスクリプションが呼び出されたときに実行されるロジックが含まれます.以下の引数を受け入れる関数です.
親フィールド上のレゾルバから返された結果を含むオブジェクト
args引数への引数、例えばログイン突然変異を受け取りますemail and password 引数
コンテキストは、すべてのリゾルバによって共有されるオブジェクトです.アプリケーションでは、以前に定義されたモデルオブジェクトとログインしているユーザーが含まれます.
infoクエリの実行状態に関する情報を含む
したがって、型クエリーのレゾルバを定義する場合、Query , 突然変異型を定義したいなら、中に入れてくださいMutation オブジェクトなど.
ページ化については、カーソルベースのページ化を使用することを選びました.メッセージスキーマでのメッセージクエリーで参照できます.このクエリでは、カーソルを引数として受け取ります.そうです.
今、我々は1つの最後のことをしている、それはアプリケーションのエントリポイントを定義することですsrc/index.js ):
//src/index.js

import { ApolloServer, PubSub } from 'apollo-server'

import { schema } from './schema'
import models from './model/index'

const pubsub = new PubSub()

const getUser = async (req, connection) => {
  let user = null

  if (req && req.headers.authorization) {
    const token = req.headers.authorization.replace('Bearer ', '')
    user = await models.user.getUserByToken(token)
  } else if (connection && connection.context.Authorization) {
    const token = connection.context.Authorization.replace('Bearer ', '')
    user = await models.user.getUserByToken(token)
  }

  return user
}

const server = new ApolloServer({
  schema,
  context: async ({ req, res, connection }) => {
    return {
      models,
      pubsub,
      user: await getUser(req, connection)
    }
  }
})

server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`)
})


ここでは、オプションで前に定義したスキーマを使用して、アポロサーバのインスタンスを作成しますcontext 我々は、どのリソースがコンテキスト引数のリゾルバで利用可能になるかを設定します.このリソースを返す前に、リクエストから受け取るトークンを使ってログインしているユーザがいるかどうかをチェックします.example
サーバはデフォルトのURLで動作しますhttp://localhost:4000/ , そこには、アプリケーションをテストすることができますいくつかのクエリでは、Graphicshere .
私たちはアポロクライアントとreactjsを使用してフロントエンドを開発します.