วันพุธ, เมษายน 1

มาพัฒนา DApp ง่าย ๆ ด้วย Truffle กันเถอะ ตอนที่ 4: Create Your Own Dapp

หัวข้อ: มาพัฒนา DApp ง่าย ๆ ด้วย Truffle กันเถอะ ตอนที่ 4: Create Your Own Dapp

สวัสดีค่ะ กลับมาพบกันอีกครั้งกับ “มาพัฒนา DApp ง่าย ๆ ด้วย Truffle กันเถอะ” ตอนที่ 4 เป็นตอนสุดท้ายแล้วค่ะ 😄

จาก ตอนที่ 3 เราได้ส่ง Smart Contract ที่เราเขียนไปเก็บไว้ใน Ethereum ด้วย Truffle แล้ว ในตอนนี้เราจะเรียกใช้งาน Smart Contract ผ่านแอปพลิเคชันที่เราสร้างขึ้นมาเองกันค่ะ 😉

Goal

ในบทความตอนนี้ เราจะทำแอปพลิเคชัน Ecommerce ที่ทำงานร่วมกับ Blockchain ดังภาพข้างล่าง โดยใช้ Node.js (หากคุณผู้อ่านยังไม่มีพื้นฐาน JavaScript และ Node.js มาก่อน ก็ขอให้อ่านเรื่องเกี่ยวกับสิ่งเหล่านี้ก่อนที่จะอ่านบทความนี้นะคะ เพราะผู้เขียนจะไม่ลงรายละเอียดเกี่ยวกับสิ่งเหล่านี้มากนัก) ร่วมกับ Package ที่ชื่อว่า Web3 และ Truffle Contract

แอปพลิเคชันนี้จะมีฟังก์ชันการทำงานตามที่ได้ประกาศไว้ใน Smart Contract ชื่อว่า shop (ที่ถูกสร้างในตอนที่ 2) ซึ่งมีทั้งหมด 4 ฟังก์ชันดังนี้

  1. ฟังก์ชันเพิ่มสินค้า (ฟังก์ชันใน Smart Contract: addProduct)
  2. ฟังก์ชันซื้อสินค้า (ฟังก์ชันใน Smart Contract: buyProduct)
  3. ฟังก์ชันแสดงรายละเอียดของสินค้า (ฟังก์ชันใน Smart Contract: getProducts)
  4. ฟังก์ชันแสดงสินค้าทั้งหมด

สำหรับฟังก์ชันที่ 4 จะใช้ประโยชน์จาก Event แหล่งเก็บข้อมูลต่าง ๆ ที่แอปพลิเคชันภายนอก Smart Contract เท่านั้นสามารถเอาไปใช้ได้ หากคุณผู้อ่านไม่รู้ว่ามันจะมีหน้าตาและเอาไปใช้อย่างไรนั้น บทความนี้มีคำตอบให้ค่ะ 😉

ก่อนที่จะไปต่อ ผู้เขียนขอให้คุณผู้อ่านเปิดการเชื่อมต่อกับ Ethereum ที่เคย Deploy Smart Contract ในตอนที่แล้ว คุณผู้อ่านที่ใช้ Ganache ขอให้ทำการ Deploy Smart Contract ใหม่อีกครั้ง เนื่องจาก Ganache ถูกออกแบบให้เก็บข้อมูลชั่วคราว (เหมาะสำหรับทดสอบแอปพลิเคชันเท่านั้น) เมื่อโปรแกรมปิดตัวลง Smart Contract และ Transaction ต่าง ๆ ที่เกิดขึ้นจะหายไปค่ะ

Setting up

ให้คุณผู้อ่านเปลี่ยนโฟลเดอร์เป็นโปรเจคที่ทำอยู่ผ่าน Command Line โดยพิมพ์คำสั่ง

cd simple_ecommerce_contract

จากนั้นในโฟลเดอร์ simple_ecommerce_contract ให้สร้างไฟล์ package.json ที่มีเนื้อหาดังนี้

{
  "name": "simple_ecommerce_contract",
  "version": "1.0.0",
  "description": "Simple Ecommerce Worked With Blockchain",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@truffle/contract": "^4.0.34",
    "body-parser": "^1.18.3",
    "bootstrap": "^4.3.1",
    "ejs": "^2.6.1",
    "express": "^4.16.4",
    "holderjs": "^2.9.6",
    "http-errors": "^1.7.2",
    "jquery": "^3.4.0",
    "multer": "^1.4.1",
    "toastr": "^2.1.4",
    "web3": "^1.0.0-beta.46"
  }
}

จากไฟล์ package.json จะเห็นได้ว่ามีในส่วนของ dependencies เอาไว้เก็บ Package ต่าง ๆ สำหรับใช้ประกอบในการทำแอปพลิเคชันของเรา โดย Package ที่เป็นพระเอกของเราคือ @truffle/contract และ web3 เป็น Package ที่เอาไว้ติดต่อกับ Ethereum และ Smart Contract นั่นเองค่ะ 😄

กลับไปที่ Command Line แล้วพิมพ์คำสั่งนี้เพื่อติดตั้ง Dependencies หรือ Package ที่ใช้กับแอปพลิเคชัน

npm install

Create Main Script

ทีนี้ เราจะสร้างไฟล์แรกชื่อ index.js ไฟล์นี้จะเป็นตัวเริ่มทำงานของแอปพลิเคชันค่ะ

const express = require('express');
const bodyParser = require('body-parser');

const app = express();
const appPort = 3000;

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.get('/', (request, response, next) => {
   response.send('Hello! Welcome to Simple E-commerce Application!');
});

app.listen(appPort, () => console.log(`Ecommerce server is running! it is listening on port ${appPort}...`));

จาก Code ด้านบน เป็นการสร้าง Web Application ในเบื้องต้น โดยให้มี Route แรกคือ GET / ซึ่งเมื่อเข้าไปควรจะขึ้นข้อความว่า Hello! Welcome to Simple E-commerce Application!

เสร็จแล้ว ลองให้ทำงานโดยพิมพ์คำสั่งนี้ลงใน Command Line

npm start

หากขึ้นแบบนี้ แสดงว่าแอปพลิเคชันทำงานเรียบร้อยแล้ว

> [email protected] start /mnt/data/truffle-example-win/ecommerce
> node index.js

Ecommerce server is running! it is listening on port 3000...

เพื่อให้เกิดความมั่นใจมากขึ้นไปอีก ขอให้คุณผู้อ่านเข้า Web Browser แล้วเข้าไปที่ URL http://localhost:3000 หากขึ้นข้อความว่า Hello! Welcome to Simple E-commerce Application! แสดงว่า เราได้ทำการ Setup สำเร็จแล้ว 😁

ต่อไปให้ย้อนกลับมาที่ Command Line แล้วกดคีย์ Ctrl + C เพื่อหยุดทำงานของแอปพลิเคชันก่อน เราจะเพิ่มฟังก์ชันให้แอปพลิเคชันของเรากัน

Add more routes …

ระบบ Ecommerce ของเราจะต้องมีฟังก์ชันพื้นฐานคือ ฟังก์ชันขายสินค้า และซื้อสินค้า เพราะฉะนั้นเราจะเพิ่มความสามารถให้ Web Application โดยอย่างแรกที่ต้องทำคือ เพิ่ม Route ที่ทำให้เข้าถึงทั้ง 2 ฟังก์ชันได้

app.post('/products/add', (request, response) => {
  response.send('Add a product');
});

app.post('/products/buy', (request, response) => {
  response.send('Buy a product');
});

Route ทั้งสองจะต้องประกาศในไฟล์ index.js หลังจากเปิดให้แอปพลิเคชันทำงานใหม่ แอปพลิเคชันจะสร้างเพิ่มอีก 2 Route ตามที่เราประกาศ

Route ทั้งสองจะแตกต่างจาก Route GET / คือ เป็น Route ที่มีการใช้ HTTP Method แบบ POST เราไม่สามารถเข้าไปที่ URL เหล่านี้ผ่าน Web browser ได้โดยตรง ซึ่ง POST นิยมใช้เป็นช่องรับข้อมูลจากแบบฟอร์ม หากต้องการเข้า Route ทั้งสองโดยตรง จะต้องเข้าผ่านโปรแกรมที่ใช้จัดการกับ API Service โดยเฉพาะ เช่น Postman, Insomnia เป็นต้น

ต่อไป เราจะทำให้ Route ทั้งสองไปเรียกใช้งานฟังก์ชันใน Smart Contract กันค่ะ

Connect to Ethereum with Web3

มาถึงส่วนที่สำคัญของแอปพลิเคชันของเรากันแล้ว นั่นก็คือ การเชื่อมต่อไปที่ Ethereum นั่นเอง ซึ่งการติดต่อจากแอปพลิเคชันของเรานั้น เราสามารถใช้ Package ชื่อว่า web3 ค่ะ

ให้คุณผู้อ่านสร้างไฟล์ชื่อว่า blockchain.js และเรียกใช้ Package web3 แบบดังตัวอย่างนี้

const Web3 = require('web3');

const web3Provider = new Web3.providers.HttpProvider('http://localhost:7545');
const web3 = new Web3(web3Provider);

การติดต่อ Blockchain จากแอปพลิเคชันของเรา เราต้องกำหนด Address ที่ติดต่อได้ในตัวแปร web3Provider รูปแบบ http(s)://domain:port ให้ถูกต้องค่ะ อย่างในที่นี้ เราจะติดต่อ Blockchain ในเครื่องเราเอง เราจะกำหนด domain เป็น http://localhost หรือ http://127.0.0.1 ส่วน Port หรือเลขหลัง : (colon) ขึ้นอยู่กับ Client ที่เราใช้ว่าจะเปิดที่เลขใด หากคุณผู้อ่านใช้ Ganache โปรแกรมจะเปิด Port 7545 สำหรับ Client อื่น ๆ เช่น Geth จะเปิด Port ที่ 8545

หากคุณผู้อ่านได้กำหนด Port ที่แตกต่างจากที่บอกมาใน Client ที่ใช้งานอยู่แล้ว ก็ให้ใช้เลขตัวนั้นเลยนะคะ

หลังจากที่เราติดต่อกับ Ethereum ได้แล้ว ขั้นตอนต่อไป เราต้องสร้างตัวแทนของ Smart Contract หรือ Contract Instance เพื่อเรียกใช้ฟังก์ชันต่าง ๆ ตามที่กำหนดไว้ใน Smart Contract ของเรา

เราสามารถสร้างตัวแทนได้โดยเรียกใช้ Dependency ชิ่อว่า @truffle/contract ในที่นี้ให้สร้างฟังก์ชันต่อจาก Code ที่เราเขียนไว้เมื่อสักครู่ ดังนี้

const fs = require('fs');
const path = require('path');
const Web3 = require('web3');
const TruffleContract = require('@truffle/contract');

const web3Provider = new Web3.providers.HttpProvider('http://localhost:7545');
const web3 = new Web3(web3Provider);

const createContractInstance = async artifactName => {
 const artifact = JSON.parse(fs.readFileSync(path.join(__dirname, 'build/contracts', `${artifactName}.json`)));
  const contract = TruffleContract(artifact);
  contract.setProvider(web3Provider);
  return contract.deployed();
};

หากคุณอ่านจำกันได้ เมื่อเรา Deploy ตัว Smart Contract ของเราด้วย Truffle จะเกิดสิ่งที่เรียกว่า “Truffle Artifact” อยู่ในโฟลเดอร์ build/contracts ซึ่ง Truffle Artifact จะมีข้อมูลต่าง ๆ ที่มีประโยชน์ในการเรียกใช้งาน Smart Contract ในแอปพลิเคชันค่ะ

ดังนั้นจาก Code ด้านบน จะดึงข้อมูลทั้งหมดในไฟล์ Truffle Artifact มาทำให้เกิด Contract Instance ด้วยฟังก์ชัน TruffleContract นั่นเอง

หากคุณผู้อ่านต้องการดูโครงสร้างของ Truffle Artifact ทั้งหมด สามารถเข้าไปดูได้ที่ https://github.com/trufflesuite/truffle/tree/develop/packages/contract-schema

เมื่อเราสร้างฟังก์ชันเสร็จแล้ว ให้ใช้ฟังก์ชันนี้สร้าง Contract Instance ของ Smart Contract ที่ชื่อว่า Shop ดังนี้

let shop;
createContractInstance('Shop').then(instance => {
  shop = instance;
  console.log(shop);
});

เมื่อเราลองใช้คำสั่ง npm start ใน Command Line หากติดต่อกับ Blockchain สำเร็จ โปรแกรมจะแสดงข้อมูลของ Smart Contract และฟังก์ชันที่เรียกใช้งานได้ทั้งหมดขึ้นมา

แต่ถ้าหากติดต่อกับ Blockchain ไม่สำเร็จ โปรแกรมจะแสดง Error ขึ้นมาแบบนี้

> [email protected] start /mnt/data/truffle-example-win/ecommerce
> node index.js

Ecommerce server is running! it is listening on port 3000...
(node:9857) UnhandledPromiseRejectionWarning: Error: Invalid JSON RPC response: ""
    at Object.InvalidResponse (/mnt/data/truffle-example-win/ecommerce/node_modules/web3-core-helpers/src/errors.js:42:16)
    at XMLHttpRequest.request.onreadystatechange (/mnt/data/truffle-example-win/ecommerce/node_modules/web3-providers-http/src/index.js:92:32)
    at XMLHttpRequestEventTarget.dispatchEvent (/mnt/data/truffle-example-win/ecommerce/node_modules/xhr2-cookies/dist/xml-http-request-event-target.js:34:22)
    at XMLHttpRequest._setReadyState (/mnt/data/truffle-example-win/ecommerce/node_modules/xhr2-cookies/dist/xml-http-request.js:208:14)
    at XMLHttpRequest._onHttpRequestError (/mnt/data/truffle-example-win/ecommerce/node_modules/xhr2-cookies/dist/xml-http-request.js:349:14)
    at ClientRequest.<anonymous> (/mnt/data/truffle-example-win/ecommerce/node_modules/xhr2-cookies/dist/xml-http-request.js:252:61)
    at ClientRequest.emit (events.js:189:13)
    at Socket.socketErrorListener (_http_client.js:392:9)
    at Socket.emit (events.js:189:13)
    at emitErrorNT (internal/streams/destroy.js:82:8)
    at emitErrorAndCloseNT (internal/streams/destroy.js:50:3)
    at process._tickCallback (internal/process/next_tick.js:63:19)
(node:9857) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:9857) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Call Smart Contract Functions

เมื่อติดต่อกับ Smart Contract ได้แล้ว ขั้นตอนต่อไป เราจะประกาศฟังก์ชัน JavaScript ใน blockchain.js เพิ่มอีก 4 ฟังก์ชัน คือ

  1. ฟังก์ชันเพิ่มสินค้า
  2. ฟังก์ชันซื้อสินค้า
  3. ฟังก์ชันแสดงรายละเอียดของสินค้า
  4. ฟังก์ชันแสดงสินค้าทั้งหมด

ภายในจะเรียกใช้ฟังก์ชัน addProduct, buyProduct, และ getProducts และ Event AddedProduct ตามลำดับใน Smart Contract shop ค่ะ

ใน Ethereum การเรียกใช้ฟังก์ชันที่ประกาศใน Smart Contract จะมีวิธีการเรียกใช้อยู่ 2 แบบ คือ

  1. แบบ Call – ใช้กับฟังก์ชันที่ทำหน้าที่ดึงข้อมูลอย่างเดียว (ใช้ keyword constant หรือ view ในการประกาศฟังก์ชัน) เมื่อเรียกแล้วจะไม่เกิด Transaction ขึ้น
  2. แบบ Send Transaction – ใช้กับฟังก์ชันที่ทำหน้าที่แก้ไขข้อมูลในระบบ เมื่อเรียกแล้วจะเกิด Transaction ขึ้น (เพื่อเป็นหลักฐานว่ามีการเปลี่ยนแปลงข้อมูลใน Smart Contract นี้ด้วย)

เมื่อต้องการจะเรียกใช้ฟังก์ชัน addProduct หรือฟังก์ชันเพิ่มสินค้าในแอปพลิเคชันของเราจะต้องเรียกฟังก์ชันแบบ Send Transaction ค่ะ

const addProduct = async (name, price, quantity, imgPath, seller) => {
  const timestamp = Date.now();
  const pid = timestamp;
  const receipt = await shop.addProduct(pid, name, price, quantity, imgPath, timestamp, { from: seller, gas: 1000000 });
  return { receipt: receipt, pid: pid };
};

จาก Code ด้านบนจะเห็นได้ว่า การเรียกแบบ Send Transaction ของ @truffle/contract จะทำได้โดย

  1. พิมพ์ชื่อฟังก์ชันลงไป
  2. หลังจากนั้นก็นำ Parameter ใส่ลงไปตามที่กำหนดใน Smart Contract
  3. หลังจาก Parameter ตัวสุดท้าย จะต้องใส่ข้อมูลของ Transaction ที่จะส่งไปยัง Contract นี้ด้วย เช่น ส่งจาก Address ใด (from), จะใช้ Gas เท่าไรในการประมวลผล Transaction หรือฟังก์ชันนี้ (gas) เป็นต้น

ฟังก์ชัน buyProduct ก็ต้องใช้วิธีการเรียกแบบนี้เช่นกัน เพราะมีการเปลี่ยนแปลงข้อมูลภายใน Smart Contract คือ มีการส่ง Token ไปยังผู้ขาย และเปลี่ยนแปลงจำนวนสินค้า

const buyProduct = async (pid, buyer) => {
  try {
    const receipt = await shop.buyProduct(pid, Date.now(), { from: buyer, gas: 1000000 });
    return receipt;
  } catch (error) {
    if (error.reason) throw new Error(error.reason);
    throw error;
  }
};

ฟังก์ชันนี้จะมีการเขียนที่แตกต่างจาก addProduct เพียงเล็กน้อย เนื่องจากภายในฟังก์ชันที่ประกาศใน Smart Contract นี้มีการตรวจเช็คด้วยว่า มีสินค้าเหลืออยู่ไหม ดังโค้ดด้านล่าง หากสินค้าหมดแล้ว Smart Contract จะยกเลิกการทำงานของฟังก์ชันและส่งข้อความว่า “Product is sold out” กลับไปให้แอปพลิเคชันผ่านตัวแปร error.reason

require(products[_pid].quantity > 0, "Product is sold out");

สุดท้ายคือฟังก์ชัน getProduct ฟังก์ชันนี้ทำหน้าที่ดึงข้อมูลของสินค้าออกมา เพราะฉะนั้นการเรียกใช้ฟังก์ชันในแอปพลิเคชันต้องใช้การเรียกแบบ Call ค่ะ

การเรียกแบบ Call ของ @truffle/contract จะทำได้โดยพิมพ์ชื่อฟังก์ชันลงไป ตามด้วย .call() ภายในวงเล็บนั้นจะใส่ Parameter ตามที่กำหนดใน Smart Contract ส่วนข้อมูลของ Transaction ไม่จำเป็นต้องใส่ค่ะ ดังโค้ดด้านล่างนี้

const getProduct = async pid => {
  const product = await shop.getProduct.call(pid);
  product.id = pid;
  return product;
};

เมื่อประกาศฟังก์ชันทั้ง 3 ฟังก์ชันเรียบร้อยแล้ว ให้ประกาศใน module.export แบบด้านล่างเพื่อให้ไฟล์อื่นสามารถนำฟังก์ชันไปใช้ได้

module.exports = {
 addProduct,
 buyProduct,
 getProduct
}

และในไฟล์เดียวกัน (lib/blockchain.js) ให้เพิ่มอีก 2 ฟังก์ชันขึ้นมา เป็นฟังก์ชันดึงรายการ Address และช่วย Unlock Address ก่อนส่ง Transaction ตามลำดับ

const getAccounts = () => web3.eth.getAccounts();

const unlockAccount = (address, password) => web3.eth.personal.unlockAccount(address, password);

module.exports = {
  addProduct,
  getProduct,
  getAllProducts,
  buyProduct,
  // เพิ่ม 2 ฟังก์ชัน
  getAccounts,
  unlockAccount,
};

กลับไปที่ไฟล์ index.js แก้ไขให้ Route POST /products/add, POST /products/buy และ GET /products/:idเรียกฟังก์ชันที่ export ออกมาตามหน้าที่ของมัน ดังนี้

const fs = require('fs');
const Multer = require('multer');
const {
   addProduct,
   buyProduct,
   getProduct,
   getAccounts,
   unlockAccount, 
} = require('./blockchain');

// IMAGE UPLOADER SESSING
const upload = Multer({ dest: 'public/images/', limits: { files: 1 } });
const MAX_IMAGE_SIZE_BYTES = 10485760;

// ADD PRODUCT
app.post('/products/add', upload.single('productImageInput'), async (request, response) => {
  try {
    const { productNameInput, productPriceInput, productQtyInput, productSeller, accountPassword } = request.body;
    const { file } = request;
    if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.mimetype)) {
      fs.unlinkSync(file.path);
      return response.json({
        success: false,
        error: 'Please upload a file as jpeg, png, or gif.',
      });
    }

    if (file.size > MAX_IMAGE_SIZE_BYTES) {
      fs.unlinkSync(file.path);
      return response.json({
        success: false,
        error: 'Please upload smaller file. (10MB)',
      });
    }

    if (!productNameInput || !productPriceInput || !productQtyInput || !productSeller) {
      return response.json({
        success: false,
        error: 'Please fill the form.',
      });
    }

    if (!Number.isInteger(Number(productPriceInput))) {
      return response.json({
        success: false,
        error: 'Plese enter product price in number',
      });
    }

    if (!Number.isInteger(Number(productQtyInput))) {
      return response.json({
        success: false,
        error: 'Plese enter product quantity in number',
      });
    }

    const productImagePath = 'images/' + file.filename;
    const unlocked = await unlockAccount(productSeller, accountPassword);
    if (!unlocked) {
      return response.json({
        success: false,
        error: 'Please type correct account password.',
      });
    }
    const dataResult = await addProduct(productNameInput, productPriceInput, productQtyInput, productImagePath, productSeller);
    return response.json({
      success: true,
      data: { pid: dataResult.pid, transactionReceipt: dataResult.receipt }, 
      error: null,
    });
  } catch (error) {
    return response.json({
      success: false,
      error: error.message,
    });
  }
});

// BUY PRODUCT
app.post('/products/buy', async (request, response) => {
  try {
    const { pid, buyer, password } = request.body;
    if (!pid) {
      return response.json({
        success: false,
        error: 'Please select product ID.',
      });
    }

    if (!buyer) {
      return response.json({
        success: false,
        error: 'Please select address to be buyer.',
      });
    }

    const unlocked = await unlockAccount(buyer, password);
    if (!unlocked) {
      return response.json({
        success: false,
        error: 'Please type correct account password.',
      });
    }

    await buyProduct(pid, buyer);
    return response.json({
      success: true,
      error: null,
    });
  } catch (error) {
    return response.json({
      success: false,
      error: error.message,
    });
  }
});

// GET PRODUCT
app.post('/products/:pid', async (request, response) => {
  try {
     const { pid } = request.params;
      const result = await getProduct(pid);
      return response.json({
         success: true,
         data: result,
         error: null,
      });
  }
  catch (error) {
    return response.json({
      success: false,
      error: error.message,
    });
  }
});

เมื่อเสร็จแล้ว ก็ลองใช้โปรแกรม Insomnia ทดสอบทั้ง 3 Route เลยค่ะ

ภาพนี้แสดงการเรียกใช้ฟังก์ชันเพิ่มสินค้า ผลลัพธ์ที่ได้จะมี ID ของ Product (pid) และข้อมูลของการประมวลผล Transaction (transactionReceipt) ซึ่งมีข้อมูลอย่างเช่น ผู้ส่ง (from), ผู้รับ (to ในที่นี้จะเป็น Smart Contract เสมอ) เป็นต้น ออกมา
ภาพนี้แสดงวิธีและผลลัพธ์จากการเรียกใช้ฟังก์ชันแสดงข้อมูลสินค้าตาม ID ของ Product (ในที่นี้แสดงข้อมูลของ Product ที่มี ID เป็น 1572794453812)
ภาพนี้แสดงวิธีและผลลัพธ์จากการเรียกใช้ฟังก์ชันซื้อสินค้า

Get Data From Events

ตอนนี้ฟังก์ชัน getProduct ดึงข้อมูลของสินค้าได้เพียงชิ้นเดียวเท่านั้น แล้วจะแสดงสินค้าทั้งหมดเป็น Catalog ได้อย่างไร?

หากไปดู Smart Contract shop จะเห็นว่าที่ฟังก์ชัน addProduct มีการเรียกใช้งาน Event AddedProduct ในตอนท้ายด้วย นั่นแปลว่าการเพิ่มสินค้า 1 ครั้งจะนับเป็น 1 เหตุการณ์

function addProduct(
        uint256 _pid,
        string memory _name,
        uint256 _price,
        uint256 _quantity,
        string memory _imgPath,
        uint256 timestamp
    ) public {
        products[_pid] = Product({
            name: _name,
            imgPath: _imgPath,
            price: _price,
            quantity: _quantity,
            seller: msg.sender
        });
        emit AddedProduct(_pid, msg.sender, timestamp);
    }

ดังนั้นทุกครั้งที่เราเพิ่มสินค้าด้วยการเรียกฟังก์ชัน addProduct ใน Blockchain จะสร้าง Transaction ที่มี Log พ่วงท้าย ซึ่งนั่นเป็นผลมาจากการใช้ Event นั่นเอง ภายใน Log จะมีข้อมูลตามที่ใส่ไว้ใน Event ที่เราเรียกใช้ในฟังก์ชันนั้น ๆ

แม้เราจะอ่านข้อมูลใน Log ไม่รู้เรื่องเนื่องจาก Ethereum ได้ทำการ Encode ไว้ แต่ในตัว Contract Instance จะมีฟังก์ชันชื่อว่า getPastEvents ไว้เรียกเหตุการณ์ที่บันทึกไว้ตั้งแต่ในบล็อกหมายเลขที่กำหนดไว้ใน fromBlock ออกมา ซึ่งข้อมูลที่ได้จะถูก Decode ให้สามารถอ่านและนำไปใช้ได้ทันที

เมื่อรู้ความจริงตรงนี้ เราสามารถใช้ประโยชน์โดยการทำเป็นฟังก์ชันที่ดึง ID ของ Product ทั้งหมดมาใช้ได้เลย

const getAllProducts = async () => {
  const events = await shop.getPastEvents('AddedProduct', { fromBlock: 0 });
  const allPids = events.map(item => item.returnValues.pid);
  return allPids;
};

จากนั้นไปแก้ไข Route GET / ในไฟล์ index.js

 const {
   addProduct,
   buyProduct,
   getProduct,
   getAccounts,
   unlockAccount, 
   getAllProducts, // Import ฟังก์ชันเพิ่ม
} = require('./blockchain');   

app.get('/', async (request, response) => {
  try {
    const result = await getAllProducts();
    return response.json({
      success: true,
      data: result,
    });
  } catch (error) {
    return response.json({
      success: false,
      error: error.message,
    });
  }
});

ตอนนี้แอปพลิเคชันสามารถแสดง ID ของ Product (pid) ที่เราเพิ่มทั้งหมดได้แล้วค่ะ

ลอง Copy สัก ID หนึ่งไปใส่ใน Parameter ของ GET products/:pid จะได้ข้อมูลสินค้า ดังรูป

จากตรงนี้จะเห็นได้ว่า เรามีฟังก์ขันที่ควรมีใน Ecommerce ครบแล้วนะคะ แต่ว่าเราไม่มีหน้าตาของ Ecommerce ให้ใช้งานเลย

ขั้นตอนต่อไป เรามาทำหน้าตาของ Ecommerce กันดีกว่า

Create User Interface

สำหรับหน้าตาของ Ecommerce ผู้เขียนสร้างเป็น Template ด้วย EJS และเรียกใช้งานใน Node.js ตรงนี้ผู้เขียนขอไม่ลงรายละเอียดเกี่ยวกับเรื่องนี้นะคะ ตรงนี้คุณผู้อ่านสามารถใช้ทักษะด้าน UI/UX และ Frontend ทำหน้าตาของเว็บไซต์ให้สวยงามตามที่ต้องการได้เลยค่ะ

เริ่มต้นที่ไฟล์ index.js ให้เพิ่มคำสั่งด้านล่างก่อนคำสั่งประกาศ Route เพื่อเปิดใช้งาน EJS รวมถึง CSS, และ JavaScript ต่าง ๆ

จากตรงนี้ ไฟล์ index.js ควรมีหน้าตาดังนี้

const path = require('path');
const express = require('express');
const bodyParser = require('body-parser');

const app = express();
const appPort = 3000;

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

const bootstrapJS = express.static(path.join(__dirname, 'node_modules/bootstrap/dist/js/bootstrap.min.js'));
const bootstrapCSS = express.static(path.join(__dirname, 'node_modules/bootstrap/dist/css/bootstrap.min.css'));
const toastrCSS = express.static(path.join(__dirname, 'node_modules/toastr/build/toastr.min.css'));
const toastrJS = express.static(path.join(__dirname, 'node_modules/toastr/build/toastr.min.js'));
const jquery = express.static(path.join(__dirname, 'node_modules/jquery/dist/jquery.min.js'));
const holderjs = express.static(path.join(__dirname, 'node_modules/holderjs/holder.min.js'));

app.use('/js/jquery.min.js', jquery);
app.use('/js/holder.min.js', holderjs);
app.use('/js/bootstrap.min.js', bootstrapJS);
app.use('/css/bootstrap.min.css', bootstrapCSS);
app.use('/js/toastr.min.js', toastrJS);
app.use('/css/toastr.min.css', toastrCSS);
app.use(express.static(path.join(__dirname, 'public'))); 

... route ต่าง ๆ ที่เพิ่มในหัวข้อก่อนหน้า ...

app.listen(appPort, () => console.log(`Ecommerce server is running! it is listening on port ${appPort}...`));

เปลี่ยนการทำงานของ GET / เพื่อให้ Route นี้ Render หน้าเว็บจากไฟล์ชื่อว่า index.ejs แทน โดยส่งข้อมูล Account และ Product ทั้งหมดไปให้ Template นี้ด้วย

const {
  getAccounts,
  unlockAccount,
} = require('./blockchain');

app.get('/', async (request, response) => {
  const accounts = await getAccounts();
  const products = await Promise.all((await getAllProducts()).map(pid => getProduct(pid)));
  return response.render('index', { accounts, products });
});

จากนั้นสร้างไฟล์ .ejs 4 ไฟล์ในโฟลเดอร์ views ดังนี้

index.ejs

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Welcome to Block Store!</title>
  <link rel="stylesheet" href="css/bootstrap.min.css" />
  <link rel="stylesheet" href="css/toastr.min.css" />
  <script src="js/jquery.min.js"></script>
  <script src="js/bootstrap.min.js"></script>
  <script src="js/holder.min.js"></script>
  <script src="js/toastr.min.js"></script>
</head>

<body>
  <!-- Header -->
  <nav class="bg-warning sticky-top navbar-light navbar">
    <span class="navbar-brand mb-0 h1">Block Store</span>
    <form class="form-inline">
      <label for="addressSelector">Address:</label>
      <select id="addressSelector" class="ml-2 form-control custom-select">
        <!-- All address will stay here -->
        <% if (accounts) { %>
        <% accounts.forEach((account) => { %>
        <option><%= account %></option>
        <% }); %>
        <% } else { %>
        <option>No account found. Please connect to blockchain.</option>
        <% } %>
      </select>
    </form>
  </nav>
  <!-- Body -->
  <div class="container">
    <div id="actionRow" class="p-2 row">
      <button type="button" class="m-1 btn btn-add-product btn-dark" data-toggle="modal"
        data-target="#addProductModal">Add Product</button>
    </div>
    <div id="productRow" class="p-2 row">
      <!-- Real data will stay here -->
      <% products.forEach((item) => { %>
      <%- include('partial/product_showcase', { product: item }); %>
      <% }); %>
    </div>
    <!-- Modals -->
    <%- include('partial/add_product_modal'); %>
    <%- include('partial/buy_product_modal'); %>

    <script src="js/custom.js"></script>
  </div> <!-- End of Container -->
</body>
</html>

partial/add_product_modal.ejs

<div class="modal fade" id="addProductModal" tabindex="-1" role="dialog" aria-hidden="true">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Add Product</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">×</span>
        </button>
      </div>
      <form id="addProductForm" method="post" enctype="multipart/form-data">
        <div class="modal-body">
          <div class="form-group">
            <label for="productImageInput">Product Image: </label>
            <div class="custom-file">
              <input type="file" class="custom-file-input" name="productImageInput" id="productImageInput"
                aria-describedby="uploadProductImageHelp">
              <label class="custom-file-label" for="productImageInput">Choose file...</label>
              <small id="uploadProductImageHelp" class="form-text text-muted">Product image must be jpg, png, or
                gif, and not larger than 10MB.</small>
            </div>
          </div>
          <div class="form-group">
            <label for="productNameInput">Product Name:</label>
            <input type="text" class="form-control" name="productNameInput" id="productNameInput" placeholder="">
          </div>
          <div class="form-group">
            <label for="productPriceInput">Product Price:</label>
            <input type="text" class="form-control" name="productPriceInput" id="productPriceInput" placeholder="">
          </div>
          <div class="form-group">
            <label for="productQtyInput">Product Quantity:</label>
            <input type="text" class="form-control" name="productQtyInput" id="producttQtyInput" placeholder="">
          </div>
          <hr />
          <div class="form-group">
            <input type="password" class="form-control" name="accountPassword" id="accountPassword" placeholder="Address password">
          </div>
          <input type="hidden" name="productSeller">
        </div>
        <div class="modal-footer">
          <input type="submit" class="btn btn-primary" value="Submit" />
        </div>
      </form>
    </div>
  </div>
</div>

partial/buy_product_modal.ejs (ไฟล์ชื่อ buy_product_modal.ejs อยู่ในโฟลเดอร์ชื่อ partial อีกที)

<div class="modal fade" id="buyProductModal" tabindex="-1" role="dialog" aria-hidden="true">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Buy Product "<span id="confirmedProductName">{Product Name}</span>"?</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">×</span>
        </button>
      </div>
      <div class="modal-body">
        <p>Are you sure you want to buy this product?</p>
        <p class="text-secondary">Please enter your address password and click "Yes" to buy</p>
        <input type="password" id="confirmedAddressPassword" class="form-control" placeholder="Address Password" />
        <input type="hidden" id="confirmedProductId" value="{Product ID}">
        <input type="hidden" id="confirmedProductQty" value="{Product Quantity}">
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-warning btn-confirm-buy">Yes</button>
        <button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
      </div>
    </div>
  </div>
</div>

partial/product_showcase.ejs

<div class="col">
  <div class="product panel panel-default">
    <div class="panel-heading">
      <h3 class="panel-title"><%= product.name %></h3>
    </div>
    <div class="panel-body">
      <div class="product-description">
        <img alt="140x140" srcset="<%= product.imgPath %>" class="img-rounded img-center" data-src="holder.js/140x140"
          data-holder-rendered="true">
        <br /><br />
        <span class="product-name" style="display: none;" ><%= product.name %></span>
        <strong>ID</strong>: <span class="product-id"><%= product.id %></span><br />
        <strong>Price</strong>: 🤑 <span class="product-price"><%= product.price %></span><br />
        <strong>Quantity</strong>: <span class="product-quantity"><%= product.quantity %></span><br />
      </div>
      <button type="button" class="m-2 btn btn-buy btn-warning" data-id="0">Buy</button>
    </div>
  </div>
</div>

ขั้นตอนสุดท้าย ให้สร้างไฟล์ custom.js อยู่ในโฟลเดอร์ public/js ดังนี้

const domain = 'http://localhost:3000';

// Display file name on file input when user selected file
$('.custom-file-input').on('change', function() {
  const fileName = $(this)
    .val()
    .split('\\\\')
    .pop();
  $(this)
    .siblings('.custom-file-label')
    .addClass('selected')
    .html(fileName);
});

// Change value of product seller in `Add Product` form when
// user selected address in navbar
const selected = $('#addressSelector')
  .find(':selected')
  .text();
$('#addProductForm input[name=productSeller]').val(selected);

$('#addressSelector').on('change', function() {
  const selected = $(this)
    .find(':selected')
    .text();
  $('#addProductForm input[name=productSeller]').val(selected);
});

$('#addProductForm').on('submit', function(event) {
  event.preventDefault();
  const submitButton = $(this).find('input[type=submit]');

  submitButton.val('Proceeding...');
  submitButton.prop('disabled', true);

  const data = new FormData($(this)[0]);
  $.ajax({
    url: `${domain}/products/add`,
    type: 'POST',
    data: data,
    processData: false,
    contentType: false,
    cache: false,
    success: function(result) {
      submitButton.val('Submit');
      submitButton.prop('disabled', false);
      if (result.success) {
        toastr.success(
          'Please wait, this page will be refresh automatically.',
          'Success',
          {
            onHidden: function() {
              location.reload();
            }
          }
        );
      } else {
        toastr.error(result.error, `Failed`);
      }
    }
  });
});

// When user click `Buy` button
$('.btn-buy').on('click', function(event) {
  event.preventDefault();
  const productDescElement = $(this).prev();
  const pid = productDescElement.find('.product-id').text();
  const pn = productDescElement.find('.product-name').text();
  const pqElement = productDescElement.find('.product-quantity');
  const pq = pqElement.text();

  if (pq == 0) {
    toastr.error('Product is sold out', `Failed`);
    return;
  }

  $('#buyProductModal #confirmedProductName').text(pn);
  $('#buyProductModal #confirmedProductId').val(pid);
  $('#buyProductModal #confirmedProductQty').val(pq);

  $('#buyProductModal').modal('show');
});

// When click "Yes" on buy confirmation dialog
$('.btn-confirm-buy').on('click', function(event) {
  event.preventDefault();
  const pn = $('#buyProductModal #confirmedProductName').text();
  const pid = $('#buyProductModal #confirmedProductId').val();
  const buyerPassword = $('#buyProductModal #confirmedAddressPassword').val();
  const buyer = $('#addressSelector')
    .find(':selected')
    .text();

  $.post(`${domain}/products/buy`, {
    pid: pid,
    buyer: buyer,
    password: buyerPassword
  }).done(function(result) {
    if (result.success) {
      toastr.success(`You bought ${pn} 1 piece.`, 'Success', {
        onHidden: function() {
          location.reload();
        }
      });
    } else {
      toastr.error(result.error, `Failed`);
    }
  });
});

Run!

ทำคำสั่งให้แอปพลิเคชันทำงานกันเลย

npm start

จากนั้นเปิด Web Browser แล้วเข้าไปที่ URL http://localhost:3000 ถึงตรงนี้ ถ้าไม่มี Error ใด ๆ ก็เป็นอันเสร็จสิ้นโปรเจคแล้ว เย้! 😆

Conclusion

บทความนี้แสดงการสร้างแอปพลิเคชันที่ติดต่อกับ Blockchain ได้ มาถึงตอนนี้เราก็ได้ DApp ตัวหนึ่ง ที่มีครบ 3 องค์ประกอบคือ UI + Smart Contract + Blockchain

ในแอปพลิเคชันที่สร้างด้วย JavaScript หรือ Node.js เราจะติดต่อกับ Ethereum ได้โดยใช้ Package ชื่อว่า web3 หลังจากติดต่อสำเร็จ เราสามารถดึงข้อมูลต่าง ๆ เช่น Account (web3.eth.accounts()) เป็นต้น รวมถึงสร้าง Transaction ส่งเงินและข้อมูลไปมาระหว่าง Account (web3.eth.sendTransaction()) ได้

สำหรับ Smart Contract ในที่นี้ได้ใช้ Package ที่ชื่อว่า @truffle/contract (TruffleContract) มาช่วยสร้าง Contract instance ซึ่งเป็น Object ที่ทำให้เราสามารถเรียกใช้ฟังก์ชันต่าง ๆ ตามที่ประกาศใน Smart Contract ด้วย ซึ่ง Package นี้ทำให้เราสามารถสร้าง Instance ได้อย่างสะดวก เพราะมันต้องการข้อมูลเพียงตัวเดียว คือ Truffle Artifact เท่านั้น

นอกจากนี้ TruffleContract ยังช่วยดึง Address ของ Smart Contract จาก Truffle Artifact มาใช้ให้เราอัตโนมัติ ในกรณีที่เราได้ Deploy Contract ใน Blockchain มากกว่า 1 ตัว เราสามารถเลือก Network ให้ TruffleContract ดึง Address ไปใช้อย่างถูกต้องโดยส่ง Provider ที่สร้างจาก web3 ไปให้ฟังก์ชัน contractInstance.setProvider()

สำหรับการเรียกใช้ฟังก์ชันที่ประกาศใน Smart Contract ต้องเรียกใช้ให้ถูกต้อง เพราะว่ามีวิธีการเรียกใช้อยู่ 2 แบบ คือ แบบ Call ใช้กับฟังก์ชันที่ทำหน้าที่ดึงข้อมูลอย่างเดียว และแบบ Send Transaction ใช้กับฟังก์ชันที่ทำหน้าที่แก้ไขข้อมูลในระบบ หากเรียกใช้ไม่ถูกต้อง จะได้ผลลัพธ์ที่ไม่ได้เป็นไปตามที่ต้องการ อย่างการเรียกฟังก์ชันแก้ไขข้อมูลแบบ Call ข้อมูลใน Smart Contract จะไม่ถูกเปลี่ยนแปลง และการเรียกใช้ฟังก์ชันดึงข้อมูลแบบ Send Transaction จะทำให้เกิด Transaction ขึ้น แต่เราจะไม่สามารถรับค่าได้เลย

หากใครต้องการดู Source code ที่เสร็จแล้ว สามารถดูได้ที่ลิงก์ข้างล่างนี้ได้เลยค่ะ

https://github.com/icegotcha/simple-ecommerce-dapp

มาถึงตรงนี้ บทความซีรีย์ “มาพัฒนา DApp ง่าย ๆ ด้วย Truffle กันเถอะ” ก็จบลงเพียงเท่านี้นะคะ ขอบคุณทุกอ่านที่ติดตามอ่านบทความซีรีย์นี้มาถึงตอนสุดท้ายด้วยค่ะ
และสุดท้าย หากมีข้อสงสัยประการใด สามารถติดต่อได้ที่ Facebook Blockchain.fish นะคะ แล้วพบกันใหม่ในโอกาสหน้า สวัสดีค่ะ 😀