アプリケーションと呼ぶには程遠いですが、ひとまず最低限の形になったのでメモ。
From REST to GraphQLを読んだのが、直接のきっかけっちゃあきっかけ。 JSだけで、一通り作ってみようと。
注意書き
筆者は、ReactもRelayもGraphQLも初心者です。このポストは自分の整理整頓のために書いており、何か間違いやベターな方法がありましたら、ぜひお教えください。
つくったもの
求人一覧のような感じで、職種がただ並んでいるだけの1枚ページを作りました。
設定まわり
package.json
{ "name": "realtime-app", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "babel-node devserver.js", "build": "NODE_ENV=production webpack", "update-schema": "babel-node ./scripts/updateSchema.js" }, "metadata": { "graphql": { "schema": "./data/schema.json" } }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^4.14.0", "express-graphql": "^0.5.3", "graphql": "^0.6.0", "graphql-relay": "^0.4.2", "react": "^15.1.0", "react-dom": "^15.1.0", "react-relay": "^0.9.1" }, "devDependencies": { "babel-cli": "^6.10.1", "babel-core": "^6.10.4", "babel-loader": "^6.2.4", "babel-polyfill": "^6.9.1", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", "babel-preset-react-hmre": "^1.1.1", "babel-preset-stage-0": "^6.5.0", "babel-preset-stage-1": "^6.5.0", "babel-preset-stage-2": "^6.11.0", "babel-preset-stage-3": "^6.11.0", "babel-relay-plugin": "^0.9.1", "chokidar": "^1.6.0", "json-loader": "^0.5.4", "webpack": "^1.13.1", "webpack-dev-server": "^1.14.1" } }
依存パッケージはこんな感じ。
scriptには、開発モードを起動するstartと、本番ビルド用のbuildと、スキーマ更新のupdate-schemaの3つを用意した。
metadataの項目に、schema.jsonを指定している。
.babelrc
{ "passPerPreset": true, "presets": [ { "plugins": [ "./build/babelRelayPlugin" ] }, "react", "react-hmre", "es2015", "stage-0", "stage-1", "stage-2", "stage-3" ] }
passPerPresetは良いことが起こるおまじないっぽい(よくわかっていない。) preset毎にtraversalが作られて処理出来るようになるらしい。
react-hmreは、WebpackをHMRで起動するんだけど、その際にReactも適用したいので指定。
presetsの項目については後述します。
stageの指定、雑でごめんなさい。。。
webpack.config.js
const webpack = require('webpack') const path = require('path') const isProduction = process.env.NODE_ENV === 'production' const commonLoaders = [ { test: /.js?$/, loader: 'babel', exclude: /node_modules/ }, { test: /\.json$/, loader: 'json-loader' } ] const clientConfig = { entry: { client: ["./src/client.js"] }, output: { filename: '[name].js', path: path.join(__dirname, 'public'), publicPath: '/public/' }, module: { loaders: commonLoaders }, plugins: [] } const serverConfig = { entry: { server: "./src/server.js" }, target: 'node', output: { filename: '[name].js', path: path.join(__dirname, 'public'), publicPath: '/public/' }, module: { loaders: commonLoaders } } const config = isProduction ? [clientConfig ,serverConfig] : clientConfig module.exports = config
webpackの設定は、開発と本番でやや分けている。
devserver.js
今回は、devserver.jsというファイルを用意した。こいつは、webpack.config.jsをimportして、いろいろ載っけてwebpack-dev-serverを起動している。webpack-dev-serverはアプリケーション用に立ち上げ、expressをGraphQLサーバとして立ち上げている。 webpack-dev-serverにproxyという設定項目があり、そこにGraphQLサーバを指定している。
// 外部パッケージをインストール import webpack from 'webpack' import WebpackDevServer from "webpack-dev-server" import express from 'express' import graphQLHTTP from 'express-graphql' import chokidar from 'chokidar' // 標準パッケージをインストール import path from 'path' import { exec } from 'child_process' // webpack config import config from './webpack.config.js' // posts const APP_PORT = 3000 const WEBPACK_GRAPHQL_PORT = 8080 // server variables let graphQLServer let appServer // add webpack dev server config config.entry.client.push(`webpack-dev-server/client?http://localhost:${APP_PORT}`) config.entry.client.push("webpack/hot/dev-server") config.plugins.push(new webpack.HotModuleReplacementPlugin()) // server where we develop function startAppServer(callback) { const compiler = webpack(config) appServer = new WebpackDevServer(compiler, { proxy: {'/graphql': `http://localhost:${WEBPACK_GRAPHQL_PORT}`}, publicPath: config.output.publicPath, hot: true, stats: {colors: true} }) appServer.use('/', express.static(path.resolve(__dirname, 'public'))) appServer.listen(APP_PORT, () => { console.log(`App is now running on http://localhost:${APP_PORT}`) if(callback) { callback() } }) } // server where we see GraphQL function startGraphQLServer(callback) { const { Schema } = require('./data/schema') const graphQLApp = express() graphQLApp.use('/', graphQLHTTP({ graphql: true, pretty: true, schema: Schema, })) graphQLServer = graphQLApp.listen(WEBPACK_GRAPHQL_PORT, () => { console.log(`GraphQL server is now running on http://localhost:${WEBPACK_GRAPHQL_PORT}`) if(callback) { callback() } }) } // start both servers and stay for listening function startServers(callback) { if(appServer) { appServer.listeningApp.close() } if(graphQLServer) { graphQLServer.close() } exec('npm run update-schema', (error, stdout) => { console.log(stdout) let doneTasks = 0 function handleTaskDone() { doneTasks++ if(doneTasks === 2 && callback) { callback() } } startGraphQLServer(handleTaskDone) startAppServer(handleTaskDone) }) // watch const watcher = chokidar.watch('./data/{database, schema}.js') watcher.on('change', path => { console.log(`\`${path}\` changed. Restarting.`); startServers(() => { console.log('Restart your browser to use the updated schema.') }) }) } // FIRE!!! startServers()
たぶん開発周りの設定はこれくらい。
Relay・GraphQLまわり
次に、RelayとGraphQLまわりのファイルについて。 まず、2つのツール系ファイルを用意する。
babelRelayPlugin
まず、bablercで指定したbabelRelayPluginについて。
const getBabelRelayPlugin = require('babel-relay-plugin') const schema = require('../data/schema.json') module.exports = getBabelRelayPlugin(schema.data)
RelayからGraphQLのクエリを発行する際に、Relay.QLと書いてテンプレート文字列でクエリを書くんだけど、babelRelayPluginはそれをJSのコードに変換してくれる。
requireしている'../data/schema.json'については、後述します。
updateSchema.js
#!/usr/bin/env babel-node --optional es7.asyncFunctions import fs from 'fs' import path from 'path' import { Schema } from '../data/schema.js' import { graphql } from 'graphql' import { introspectionQuery, printSchema } from 'graphql/utilities' (async () => { let result = await (graphql(Schema, introspectionQuery)) if(result.errors) { console.error( 'ERROR introspecting schema: ', JSON.stringify(result.errors, null, 2) ) } else { fs.writeFileSync( path.join(__dirname, '../data/schema.json'), JSON.stringify(result, null, 2) ) } })() fs.writeFileSync( path.join(__dirname, '../data/schema.graphql'), printSchema(Schema) )
schemaの更新を行うJSファイル。npm run update-schemaコマンドを通じて、実行される。 実行に成功すれば、後述するdatabase.jsとschema.jsを元にして、schame.jsonとschema.graphqlが生成される。
database.js
database.jsは、データ定義を擬似的に行っているファイルです。
export class Company {} export class Job {} let companyNames = ['aaa', 'bbb', 'ccc'] let companies = companyNames.map((name, id) => { let company = new Company() company.id = `${id}` company.name = name return company }) let jobList = [ [0, 'manager', 500, 'Tokyo'], [1, 'developer', 400, 'Tokyo'], [2, 'designer', 300, 'Okinawa'], [3, 'senior-designer', 500, 'Okinawa'], [4, 'ux-designer', 600, 'Tokyo'], [5, 'customer-support', 300, 'Ishikawa'] ] let jobs = jobList.map((job, id) => { let newJob = new Job() newJob.id = `${id}` newJob.name = job[1] newJob.salary = `${job[2]}` newJob.location = `${job[3]}` newJob.hasApplied = false return newJob }) export function getAAA() { return companies[0] } export function getCompany(id) { return companies.find((user) => user.id === id) } export function getCompanies() { return companies } export function getJob(id) { return jobs.find(j => j.id === id) } export function getJobs() { return jobs }
schema.js
schema.jsでは、database.jsを元にして、スキーマ定義を行います。
import { GraphQLBoolean, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, } from 'graphql' import { connectionArgs, connectionDefinitions, connectionFromArray, fromGlobalId, globalIdField, mutationWithClientMutationId, nodeDefinitions, } from 'graphql-relay' import { Company, Job, getAAA, getCompanies, getCompany, getJobs, getJob, getExperiencedJobs, } from './database' var { nodeInterface, nodeField } = nodeDefinitions( (globalId) => { var {type, id} = fromGlobalId(globalId) if(type === 'Company') { return getCompany(id) } else if(type === 'Job') { return getJob(id) } else { return null } }, (obj) => { if(obj instanceof User) { return companyType } else if(obj instanceof Job) { return jobType } else { return null } } ) var companyType = new GraphQLObjectType({ name: 'Company', description: 'A company', fields: () => ({ id: globalIdField('Company'), name: { type: GraphQLString, description: 'The name of the company', resolve: (company) => company.name }, jobs: { type: jobConnection, description: 'A company\s collection of jobs', args: connectionArgs, resolve: (_, args) => connectionFromArray(getJobs(), args) } }), interfaces: [nodeInterface], }) // console.log(userType['_typeConfig']['fields']()['jobs']['args']) var jobType = new GraphQLObjectType({ name: 'Job', description: 'A job', fields: () => ({ id: globalIdField('Job'), name: { type: GraphQLString, description: 'The name of the job', resolve: (job) => job.name, }, salary: { type: GraphQLInt, description: 'The salary of the job', resolve: (job) => job.salary, }, location: { type: GraphQLString, description: 'The place of the job', resolve: (job) => job.location }, hasApplied: { type: GraphQLBoolean, description: 'If the user applied for this job or not', resolve: (job) => job.hasApplied } }), interfaces: [nodeInterface], }) var { connectionType: jobConnection } = connectionDefinitions({name: 'Job', nodeType: jobType}) var queryType = new GraphQLObjectType({ name: 'Query', fields: () => ({ node: nodeField, aaa: { type: companyType, resolve: () => getAAA(), } }) }) export var Schema = new GraphQLSchema({ query: queryType, })
以上の2つのファイルが完成したら、npm run update-schemaコマンドでスキーマの更新を行います。
ReactとRelay・GraphQLの結合
さて、RelayとGraphQLの設定が済んだので、Reactと結合します。
コンテナコンポーネントの作成
Relayを通じてGraphQLクエリを発行するコンポーネントを作成します。
import React, { Component } from 'react' import Relay from 'react-relay' import JobRow from './JobRow' class JobBoard extends Component { render() { return( <div> <h2>Job board</h2> <table> <thead> <th>Name</th> <td>Salary</td> <td>Location</td> <td>Status</td> </thead> <tbody> {this.props.aaa.jobs.edges.map(job => <JobRow job={job.node} /> )} </tbody> </table> </div> ) } } export default Relay.createContainer(JobBoard, { fragments: { aaa: () => Relay.QL` fragment on Company { name, jobs(first: 6) { edges { node { id, name, salary, location, hasApplied, }, } }, } ` } })
まず、通常通りにコンポーネントを作成して、それを、createContainerでラップしています。ラップ時の第二引数がクエリですね。 クエリ発行に結果が、propsとしてコンポーネント内で参照することができます。
プレゼンテーションコンポーネントの作成
次に、上記のコンポーネントから呼ばれる子コンポーネントを作成します。 このコンポーネントは、GraphQLとは無関係で、ただ渡されたデータをレンダーするだけのコンポーネントです。
import React, { Component } from 'react' const JobRow = ({job}) => <tr key={job.id}> <th>{job.name}</th> <td>{job.salary}</td> <td>{job.location}</td> <td>{job.hasApplied ? 'applied' : 'not applied'}</td> </tr> export default JobRow
Route
AppRoute.jsという名前で、ファイルを作成します。
import Relay from 'react-relay' export default class extends Relay.Route { static queries = { aaa: () => Relay.QL`query { aaa }`, } static routeName = 'HomeRoute' }
ルートコンポーネント
コンテナコンポーネントと、Routeを結合する、ルートコンポーネントを作成します。
import 'babel-polyfill' import React from 'react' import ReactDOM from 'react-dom' import Relay from 'react-relay' import JobBoard from './components/JobBoard' import AppRoute from './routes/AppRoute' ReactDOM.render( <Relay.RootContainer Component={JobBoard} route={new AppRoute()} />, document.querySelector('#app') )
これで、ReactとRelay・GraphQLの結合が終わりました。
まとめ
以上で一通りの解説はおしまいです。
Githubにソースコードを上げてありますので、ご参照ください。
また、順を追った作成手順については、個人の技術ブログに書いてあります。
- JS開発環境の構築(1)
- JS開発環境の構築(2) WebpackローカルサーバのHMR起動
- JS開発環境の構築(3) Expressを利用した、Reactのサーバサイドレンダリング
- JS開発環境の構築(4)GraphQLのインテグレーション
- JS開発環境の構築(5)Relayのインテグレーション
次にやること
- 今回は、インテグレーションが目的だったので、簡単なクエリしか使わなかった。次は、ミューテーションや難しいクエリを使いたい。
- SSR(サーバサイドレンダリング)が機能していないので、直す。
- テストを書きたい。
- react-router-relayというものがあるので、使ってみたい。
- どこかホスティングサービスにアップしたい。