หัดใช้ GraphQL ตอนที่ 3 Apollo Client (ฉบับโค้ดๆไปเหอะ)

Part นี้จะเป็นเรื่องของ Front-End ส่วน Back-end เราจะใช้ Repository ของ part 2 ไปเลย

สำหรับ Part นี้ ใครที่ไม่มีความรู้เรื่อง Front-end เลย ก็คงเหนื่อยหน่อย เราจะต้องเจอพวก webpack , babel, vuejs นิดหน่อย

โดยทั่วไปตอนเราใช้ Postman ยิงข้อมูลไปได้ ก็แปลว่า เราสามารถเขียน หน้าเว็บโง่ๆตัวหนึ่งแล้วยิง Ajax ไปที่ Url ที่ถูกต้องและเปลี่ยน Header Content-Type เป็น application/graphql แค่นั้น ซึ่งใครอยากลองทำสดๆเองก่อนก็ลองได้ก็จะเจอความวุ่นวายระดับหนึ่ง

แต่เขาก็มีตัวช่วยสำหรับ Graphql Client ที่เขาออกแบบมาให้แล้วอยู่เช่น Relay(React), Apollo Client, lokka อะไรพวกนี้ ซึ่งผมจะขอใช้ Apollo Client เพราะว่ามันเป็น javascripts ทั่วไป และ community เยอะอยู่ ทีมพี่แกก็ update เรื่อยๆ

ก่อนจะ Code Front-end ขอกลับไป Back-end นิดหนึ่ง ลืมเรื่องเปิด cors ไปจะได้ไม่มีปัญหา

ไปที่ server.js ของ Part2 ใส่โค้ดนี้

// server.js
var express = require('express');
var graphqlHTTP = require('express-graphql');
var app = express();
var PORT = process.env.port || 3000
var MyGraphQLSchema = require('./graphql/schema');
var cors = require('cors');
app.use(cors())
app.use('/graphql', graphqlHTTP({
schema: MyGraphQLSchema,
graphiql: true
}));
app.listen(PORT);
console.log("Server running on localhost:", PORT);
server.js

แต่ทำการทำแบบนี้จะส่งผลเรื่อง security ด้วย จะทำให้ใครก็ได้มายิง API เรา แต่เราก็สามารถ config การทำ cors ได้เหมือนกัน เพิ่มเติมที่ https://github.com/expressjs/cors

เสร็จแล้วก็เหลือแต่ Front-end จริงๆละ

เริ่มจากสร้าง Folder และไฟล์เปล่าๆ ที่จะใช้ไว้แบบนี้

- src
- apolloconf.js
- app.js
- documents.js
- index.html
index.html

ต่อไปเราจะลง Package กัน

NOTE:  npm install webpack -g  //สำหรับใครยังไม่เคยลง webpack เลย

ส่วนพวก local package ลงพวกนี้ // Yarn หรือ Npm ก็เหมือนๆกันนะครับ

yarn init -y
yarn add babel-core babel-loader babel-preset-es2015 babel-preset-stage-0 webpack --dev
yarn add apollo-client graphql-tag

หน้าตา package.json จะประมาณนี้

package.json

NOTE : ปัจจุบันถ้า apollo-client version 1 โค้ดตัวอย่างข้างล่างจะพังนะครับ ถ้าจะลองหัดควรใช้ version ให้ตรงกัน ส่วน version ไอเดียโค้ดยังเหมือนเดิมแค่ต้องเพิ่ม config ตรง documents

ถ้า package.json มี package เท่ากันก็ OK

ต่อไปสร้างไฟล์ .babelrc และใส่โค้ดนี้ babel จะเป็นตัวแปล Es6 ให้เป็น Es5

{
"presets": ["es2015", "stage-0"]
}

แล้วก็สร้างไฟล์ webpack.config.js ใส่โค้ดนี้

var path = require('path');
module.exports = {
devtool: 'eval',
entry: [
'./src/app'
],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [{
test: /\.js$/,
use: ['babel-loader'],
include: path.join(__dirname, 'src')
}
]
}
};

จากนั้นไปที่ไฟล์ index.html มาเริ่มสร้าง UI กันก่อน ใส่โค้ดนี้ลงไป

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<script src="https://unpkg.com/vue/dist/vue.js"></script>
</head>
<body>
<div class="container">
<div class="row">
<div class="jumbotron">
<h1>GraphQL Client Test !</h1>
<div class="form-inline">
<div class="form-group">
<label>Name : </label>
<input type="text" class="form-control" placeholder="Name">
</div>
<div class="form-group">
<label>Price : </label>
<input type="text" class="form-control" placeholder="10xx">
</div>
<div class="form-group">
<label>Category</label>
<input type="text" class="form-control" placeholder="separate by commma ',' ">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
<div class="row">
<table class="table table-striped">
<thead>
<tr>
<th>No.</th>
<th>Name</th>
<th>Price</th>
<th>Category</th>
<th>Tools</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/javascript" src="./dist/bundle.js"></script>
</body>
</html>

เปิดเว็บขึ้นมา ซึ่งผมใช้ http-server https://www.npmjs.com/package/http-server จะได้หน้านี้

ต่อไป ไปที่ Terminal แล้วไปที่ path folder หลักที่เราอยู่และใช้คำสั่งนี้

webpack -w

คำสั่ง Webpack คือมันจะรวม module ต่างๆมาอยู่ในไฟล์เดียวกันซึ่งใน Code นี้จะรวมอยู่ในไฟล์ bundle.js แล้วเราจะเห็นว่ามี Folder Dist โผล่ขึ้นมาเนื่องจากโค้ดในไฟล์ webpack.config.js เรานั่นเอง

แล้ว index.html เราก็เรียกใช้ไฟล์ bundle.js ใน dist อยู่ด้วย

ส่วนการ -w (w = watch) ก็คือการเฝ้ามองว่า เมื่อมีการเปลี่ยนแปลงอะไรก็ตามไปในไฟล์ app.js มันก็จะ Build bundle.js ใหม่ให้เราตลอด

ตอนนี้หน้า Repo เราจะเป็นแบบนี้ละมี ไฟล์ bundle.js โผล่ขึ้นมาใน Folder dist

index.html

ต่อไปจะมาลุยอีก 3 ไฟล์กัน คือ app.js, apolloconf.js documents.js

เริ่มที่ apolloconf.js ก่อนเลยเราจะเอาไว้ config ไว้ติดต่อกับ GraphQL Server ที่เราสร้างไว้ใน Part 2 เลย

โค้ดจะเป็นดังนี้

// apolloconf.js
import ApolloClient, { createNetworkInterface } from 'apollo-client'
const client = new ApolloClient({
networkInterface: createNetworkInterface({ uri: 'http://localhost:3000/graphql'})
})
export default client

เป็นการเชื่อมต่อกับ GraphQL Server ของเราที่ http://localhost:3000/graphql Note: อย่าลืม Run Back-End ที่ part2 ค้างไว้นะครับ

ต่อไป ที่ไฟล์ documents.js ผมจะเอาไว้เก็บพวก query ต่างๆ ทีี่เอาไว้ยิง Request

// documents.js
import gql from 'graphql-tag'
export const getProductsQuery = {
query : gql`
query {
getProducts {
_id
name
price
category
}
}
`
}

จะเห็นว่าจะคล้ายๆ Query ที่เราใช้ใน GraphiQL

ต่อไปที่ไฟล์ app.js ก็จะเป็นฟังชั่นการทำงานหลักๆเก็บพวก insert , delete

import client from './apolloconf'
import { getProductsQuery } from './documents'
let app = new Vue({
el: '#app',
data: {
products: [],
},
methods: {
getProducts: function() {
client.query(getProductsQuery).then( gqlResult => {
const {errors, data} = gqlResult
this.products = data.getProducts
}).catch( (error) => {
console.error(error)
});
}
}
})
app.getProducts()

โค้ดนี้โดยรวมคือการไป Get ค่า products จาก GraphQL Server เราจะใช้ vuejs มาช่วยจัดการเรื่องการ Render, Event Handler ต่างๆ

สำคัญคือ บรรทัด client.query(getProductsQuery).then( gqlResult ) ก็็คือการ Request ไปยัง GraphQL Server ของเราโดยใช้ Query ของ “getProductsQueryก็จะได้ ผลลัพธ์มาคือ gqlResult เอาไปใช้งานได้

จากนั้นกลับไปที่ไฟล์ index.html เปลี่ยนโค้ดเป็นแบบนี้

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<script src="https://unpkg.com/vue/dist/vue.js"></script>
</head>
<body>
<div class="container" id="app">
<div class="row">
<div class="jumbotron">
<h1>GraphQL Client Test !</h1>
<div class="form-inline">
<div class="form-group">
<label>Name : </label>
<input type="text" class="form-control" placeholder="Name">
</div>
<div class="form-group">
<label>Price : </label>
<input type="text" class="form-control" placeholder="10xx">
</div>
<div class="form-group">
<label>Category</label>
<input type="text" class="form-control" placeholder="separate by commma ',' ">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
<div class="row">
<table class="table table-striped">
<thead>
<tr>
<th>No.</th>
<th>Name</th>
<th>Price</th>
<th>Category</th>
<th>Tools</th>
</tr>
</thead>
<tbody>
<tr v-for="(product, index) in products">
<td>{{ index +1 }}</td>
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td>{{ product.category }}</td>
<td>
<button class="btn btn-danger">Delete</button>
</td>
</tr>

</tbody>
</table>
</div>
</div>
<script type="text/javascript" src="./dist/bundle.js"></script>
</body>
</html>

ได้เพิ่ม syntax ของ vuejs เข้าไป loop เพื่อโชว์ products ทั้งหมดใน table

ทีนี้ถ้าเรา Refresh หน้าเว็บละก็ ใครที่ข้อมูลใน Database มันก็โชว์ของ ถ้ายังไม่มีก็ว่างเปล่าเหมือนเดิม

ผมจะลองไปเพิ่มข้อมูลลง Database ดู

เปิดหน้า GraphiQL ใส่ Query นี้

mutation {
addProduct(
name: "RPG",
price: 12345,
category: ["Weapon"]
) {
_id
name
price
category
}
}

จะได้ผลลัพธ์ดังนี้

กลับไป Refresh หน้าเว็บเรา จะมีของโผล่มาแล้ว

ต่อไปอีก 2 function สุดท้าย Add กับ Delete

เริิ่มจากไฟล์ documents.js ก่อนเลย เราจะไปเพิ่ม query สำหรับ Add และ Delete

// documents.js
import gql from 'graphql-tag'
export const getProductsQuery = {
query : gql`
query {
getProducts {
_id
name
price
category
}
}
`,
forceFetch: true

}
export const createProduct = (variables) => {
return {
mutation: gql`
mutation addProduct($name: String, $price: Int, $category: [String]) {
addProduct(
name: $name,
price: $price,
category: $category
) {
_id
name
price
category
}
}
`,
variables: variables
}
}
export const deleteProduct = (variables) => {
return {
mutation: gql`
mutation ($id: String!) {
deleteProduct(id: $id) {
_id
name
price
category
}
}
`,
variables: variables
}
}

เพิ่ม query สำหรับ add กับ delete และจะเห็นว่าใน query getProduct จะมีคำสั่ง forceFetch: true อยู่ใช้สำหรับ เมื่อเราเรียก getProduct graphql มันจะส่งข้อมูลใหม่ให้เราเมื่อมีการเปลี่ยนแปลงใดๆ ผมก็ไม่รู้เหมือนกันว่าถ้าไม่ใส่ forceFetch: true ทำไมมันไม่อัพเดทชุดข้อมูลใหม่ให้เรา ส่วน Variables ก็คือ Parameters หรือ Arguments ที่เราส่งมา

ต่อไปที่ไฟล์ app.js

// app.js
import client from './apolloconf'
import { getProductsQuery, createProduct, deleteProduct} from './documents'
let app = new Vue({
el: '#app',
data: {
prouductName: "",
prouductPrice: "",
prouductCategory: "",

products: []
},
methods: {
getProducts: function() {
client.query(getProductsQuery).then( gqlResult => {
const {errors, data} = gqlResult
this.products = data.getProducts
}).catch( (error) => {
console.error(error)
});
},
addProduct: function() {
const variables = {
name: this.prouductName,
price: parseInt(this.prouductPrice),
category: this.prouductCategory.split(",")
}
client.mutate(createProduct(variables)).then( gqlResult => {
this.getProducts()
}).catch( (error) => {
console.error(error)
});
},
deleteProduct: function(productID) {
const variables = {
id: productID
}
client.mutate(deleteProduct(variables)).then( gqlResult => {
this.getProducts()
}).catch( (error) => {
console.error(error)
});
}

}
})
app.getProducts()

เพิ่ม method สำหรับ Add กับ Delete Product และตัวแปรสำหรับส่งข้อมูล

และสุดท้ายที่ไฟล์ index.html เปลี่ยนโค้ดเป็นแบบนี้

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<script src="https://unpkg.com/vue/dist/vue.js"></script>
</head>
<body>
<div class="container" id="app">
<div class="row">
<div class="jumbotron">
<h1>GraphQL Client Test !</h1>
<div class="form-inline">
<div class="form-group">
<label>Name : </label>
<input type="text" class="form-control" placeholder="Name" v-model="prouductName">
</div>
<div class="form-group">
<label>Price : </label>
<input type="text" class="form-control" placeholder="10xx" v-model="prouductPrice">
</div>
<div class="form-group">
<label>Category</label>
<input type="text" class="form-control" placeholder="separate by commma ','" v-model="prouductCategory">
</div>
<button type="submit" class="btn btn-primary" v-on:click="addProduct">Submit</button>
</div>
</div>
</div>
<div class="row">
<table class="table table-striped">
<thead>
<tr>
<th>No.</th>
<th>Name</th>
<th>Price</th>
<th>Category</th>
<th>Tools</th>
</tr>
</thead>
<tbody>
<tr v-for="(product, index) in products">
<td>{{ index +1 }}</td>
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td>{{ product.category }}</td>
<td>
<button class="btn btn-danger" v-on:click="deleteProduct(product._id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/javascript" src="./dist/bundle.js"></script>
</body>
</html>

เพิ่มโค้ดเกี่ยวกับการ Add และ Delete เป็น Syntax Event Handle ของ Vuejs

เสร็จเรียบร้อยแล้วๆ ก็มาลองใช้งานกันได้เลย อย่าลืมดูนะว่าคำสั่ง webpack -w ยังทำงานอยู่เปล่า ถ้าไม่ก็ Run คำสั่งใหม่

เปิดเว็บขึ้นมา ใส่ข้อมูล กด Submit

ข้อมูลที่ใส่โผล่มาแล้ว ลองกด Delete ดู

เสร็จเรียบร้อยสำหรับ Workshop GraphQL สำหรับ Front-end จะเห็นว่า หลักๆ ที่เปลี่ยนก็แค่วิธีส่ง Request ไปหา Back-end เราส่งเป็นแบบ Document แทน URL แบบตะก่อน

สรุป

ถ้าจะดูข้อดีข้อเสียก็อยากให้ไปหากันเองจากการลงมือทำ สำหรับผมแล้วถ้าดูแค่เรื่องการทำงานพื้นฐานละก็ เมื่อ API มันใช้งานได้แล้วจะรู้สึก Manage อะไรได้ง่ายขึ้น (ถึงจะยุ่งยากในการสร้างมากกว่า) แต่ถ้าจะ Advance หน่อยก็เริ่มมีเวียนหัวสำหรับการ Research ตอนนี้ สำหรับ Apollo ตัว lib มันเองก็เปลี่ยนเรื่อยๆ

ความรู้เพิ่มเติม

  • Fragment คือการลดโค้ดที่ซ้ำๆกันให้เรียกใช้ที่เดียว เหมือนโค้ดที่ Duplicate กันแล้วเราแยกออกมาเป็น Function
  • InputType ตรงนี้ถ้า product เราไปใช้หลายหน้า เราสามารถที่จะ Reuse ใช้ได้ และสามารถทำ Validate ให้เราจัดการง่ายๆด้วยที่ชั้น GraphQL API เลยก่อนที่จะถึง Back-end เช่น Field ต้อง Not Null นะ
  • Subscription คือการทำ Real Time App นั่นแหละใช้ Websocket คู่กัน เหมือนการเฝ้ามองตลอดเวลาว่าถ้า Field นี้ถูกเรียกใช้ จะทำอะไรต่อซึ่งผมก็พยายามทำแต่ไม่สำเร็จ TT เลยช่างหัวมันและ
  • Authentication ตัว Apollo Client จะมี Middleware ให้เราใส่อยู่ก่อนจะยิง Request ถ้าทำ JWT ก็ง่ายเลย ทุกครั้งก่อนยิง Request มันก็จะแทรก Token เข้าให้ก่อน
  • Caching เรื่องนี้ level ผมยังไม่ถึงแฮะ แต่เท่าที่อ่านๆ ฟังๆ มา เหมือนจะเป็นอีกหนึ่งจุดเด่น

จบแล้วครับ

โค้ด https://github.com/kenshero/learn-graphql/tree/master/part3