2021年7月23日 星期五

提供數據與接收數據的後端工具-初探Node.js與RESTful API

  能心平氣和的工作,應該是職場上最基本的需求之一吧!前一陣子在不適合自己的職場環境待了三個月,在試用期的最後一週提出離職,卻也發現原來自己多麼不喜歡那份工作:才三個月就讓人每天鬱鬱寡歡,知識技能也無成長,更領著感到「雞肋」的薪資,以及天天忍受塞車通勤的鬱悶。離職之後反而過得充實,一邊求職一邊充電,要更謹慎地做出職涯規劃。

  轉職,真的是段讓人感到焦慮的過程,但是藉由觀察求職市場上的職缺,我們可以更清楚地了解資訊工作的趨勢為何。由於自己是資料科學相關背景的資訊學習者,因此求職初期,以「資料分析師」與「資料工程師」作為目標,然而透過每日檢視人力銀行的職缺配對信,我發現了「後端」、「DevOps」、「SRE」相關的工作反而會主動找上門,而且這些職缺與數據工作都有或多或少的關係,因此此篇學習筆記,我將整理這段求職期,自學Node.js實作RESTful API的相關內容。

  當工作中需要收集資料時,API往往是我們重要的幫手,例如我之前寫的文章「News API新聞擷取的好幫手 - 使用Python」;但是大家有沒有想過,自己也會碰到需要提供他人資料的狀況,這種時候我們就需要有實作API的概念囉!但是為何在資料科學領域,甚至是前陣子我參加的「巨量資料分析就業養成班(BDSE)」,都沒有API相關的課程教學呢?主要原因是實作API的過程,屬於後端與資料庫的領域,這次我所學習的Node.js,是使用JavaScript而非過往用習慣的Python,因此多數資料科學學習者應該沒接觸過這部分。

  目前我的部落格沒有JavaScript的基礎教學,事實上我的JavaScript能力也是前陣子在BDSE課程才建立的,加上ECMAScript近來每年都釋出新的撰寫規範,有鑑於更新相當快速,因此我也暫不打算將JavaScript的基礎寫法重新整理為網誌文章。

  為了快速養成求職需要的資訊技能,我讀了@andy6804tw的「無到有,打造一個漂亮乾淨俐落的 RESTful API」並將其內容整理如下,這系列文章淺顯易懂地列舉了眾多好用的套件,並給出了簡單的實作範例,還包含簡單的JavaScript基礎教學,但是我在修Bug(原因為這是2017年的文章,部分套件已更換撰寫方式)的時候,發現這系列文章關於RESTful API的程式碼已無法執行,故另外找了腳印哥的「Node.js RESTful Web API 範例 for MySQL」這篇文章繼續研讀實作,才終於實驗成功。

  程式碼的部分是兩位原作者撰寫、整理的,我僅做整理微調,是站在巨人的肩膀才得以完成Node.jsRESTful API的初探,若大家想要將範例的MySQL/ Maria DB改成其它資料庫如PostgreSQL等,就得再額外花時間研究相應資料庫套件的用法囉。

Node.js

環境準備

l   下載、安裝Visual Studio Code
https://code.visualstudio.com/

l   下載、安裝Node.js
https://nodejs.org/en/

l   安裝Node.js時應該也安裝了npm套件管理工具,可在VS CodeTerminal下指令檢查:
node -v
npm -v

l   下載、安裝Postman,是模擬HTTP Request的工具,能夠測試API
https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=zh-TW

 

測試Node.js服務

l   新增index.js
/* index.js */
//
透過http模組啟動web server服務
const http = require('http')

//
設定回應為text文件,並回應Hello Kitty
const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'})
    res.end('Hello Kitty')
})

//
設定服務監聽localhost:3000(127.0.0.1:3000)
server.listen('3000', () => {
    console.log('server start on 3000 port')
})

l   VS CodeTerminal切換至index.js所在目錄,下指令啟動服務:
node index.js

l   在瀏覽器輸入localhost:3000127.0.0.1:3000檢視成果。

 

Express框架

l   npm套件管理工具與package.json
#
VS CodeTerminal下指令初始化專案,完成後會多了package.json
npm init -y
#
VS CodeTerminal切換至package.json所在目錄,下指令把相依套件安裝回來
npm install

l   VS CodeTerminal下指令安裝ExpressExpressNode.js的前後端框架,包含MVC Framework
npm install express-generator
express --version
#
初始化專案,專案名稱命名為restful_api
express -f restful_api
cd .\restful_api\
npm install

l   解析Express專案資料夾內容:
bin/www
是程式的進入點;
前端畫面是由publicviews這兩個資料夾管理;
路由是由routes這個資料夾管理。

 

測試Express框架的路由

l   routes/index.js增加以下路由:
/* index.js */
router.get('/test', function (req, res, next) {
  res.send('this is localhost:3000/test')
});

l   VS CodeTerminal下指令啟動服務:
cd .\restful_api\
#
觀察package.json,發現npm start指令等同node ./bin/www指令
npm start

l   在瀏覽器輸入不同路由檢視成果:
localhost:3000
localhost:3000/test

 

透過WebpackBabelNodemon開發

l   VS CodeTerminal下指令初始化專案:
npm init -y

l   VS CodeTerminal下指令安裝WebpackWebpack提供模組化開發方式,將各種靜態資源視為模組,再生成優化過的程式碼:
npm install webpack webpack-node-externals

l   VS CodeTerminal下指令安裝BabelBabel能讓開發者以偏好的風格寫作原始碼,再翻譯成標準JavaScript以讓瀏覽器看懂:
npm install @babel/core babel-loader

l   VS CodeTerminal下指令安裝Nodemon,使用Nodemon自動化reload,未來就不用一直下node指令,只要重新整理瀏覽器即可:
npm install nodemon

l   修改package.json
"scripts": {
    "build": "webpack -w",
    "start": "nodemon dist/index.bundle.js"
    }

l   新增webpack.config.js,是Webpack的設定檔:
/* webpack.config.js */
const nodeExternals = require('webpack-node-externals');
const path = require('path');

module.exports = {
    target: 'node',
    externals: [nodeExternals()],
    // entry
是程式的進入點
    entry: {
        'index': './src/index.js',
    },
    output: {
        // path
是打包後的路徑,filename是打包後的檔名([name]預設是index)
        path: path.join(__dirname, 'dist'),
        filename: '[name].bundle.js',
        libraryTarget: 'commonjs2',
    },
    //
設定檔案選項,babel將程式轉換為瀏覽器能懂的語法
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            },
        ],
    },
    mode: 'development',
}

l   webpack.config.js記載的路徑,在src資料夾新增index.js
/* index.js */
//
透過http模組啟動web server服務
const http = require('http')

//
設定回應為text文件,並回應Hello Kitty
const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'})
    res.end('Hello Kitty')
})

//
設定服務監聽localhost:3000(127.0.0.1:3000)
server.listen('3000', () => {
    console.log('server start on 3000 port')
})

l   下指令打包與執行優化的程式碼:
#
執行記載在package.jsonscriptwebpack -ww代表持續監聽,程式碼有變動時會同步更新
npm run-script build
#
執行記載在package.jsonscript,執行打包後的檔案
npm run-script start

 

接續「透過WebpackBabelNodemon開發」

l   作者(@andy6804tw)MVC範例資料夾框架:
src
┌── config
   ├── config.js  // joi驗證與匯出全域變數
   └── express.js  // express與其它middleware設定
├── server
   ├── controllers  // 處理控制流程和回應
   ├── helper  // 處理例外錯誤
   ├── modules  // 後端資料庫進行運作
   └── routes  // 各路徑的設定點
       └── index.route.js  // 主路由
├── index.js  // 程式進入點
.env  //
全域變數的設定檔

l   VS CodeTerminal下指令安裝Express
npm install express

l   VS CodeTerminal下指令安裝Body-parserBody-parserHTTP請求解析的中介軟體,可以解析JsonRawTextXmlUrl-encoded等格式的請求:
npm install body-parser

l   VS CodeTerminal下指令安裝CORS,跨來源資源共享(Cross-Origin Resource Sharing, CORS)使用額外HTTP標頭讓瀏覽網站的User Agent能訪問不同網域伺服器之特定資源,用來建立讀取權限:
npm install cors

l   VS CodeTerminal下指令安裝MorganMorganHTTP Request Logger,在存取API時,終端機會顯示存取結果,例如200404狀態碼:
npm install morgan

l   VS CodeTerminal下指令安裝DotenvDotenv.env環境參數加載到process.env,在其它文件引入後只要呼叫process.env.[變數名稱],就能撈出此環境參數:
npm install dotenv

l   VS CodeTerminal下指令安裝JoiJoi將限制資料格式,如果傳送錯誤格式資料,會在Middleware拋出錯誤:
npm install joi

l   新增config.js
/* config.js */
import Joi from 'joi';

require('dotenv').config();

//
建立每個變數 joi 驗證規則
const envVarSchema = Joi.object({
    PORT: Joi.number().default(8080),  //
數字且預設值為'8080'
    NODE_ENV: Joi.string().default('development'),  //
字串且預設值為'development'
    VERSION: Joi.string()  //
字串
});

// process.env
撈取.env內的變數做joi驗證
const envVars = envVarSchema.validate(process.env);

const config = {
    port: envVars.value.PORT,  //
埠號'3000'
    env: envVars.value.NODE_ENV,  //
開發模式'development'
    version: envVars.value.VERSION  //
版本'1.0.0'
};

export default config;

l   新增express.js
/* express.js */
import express from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import morgan from 'morgan';
import config from './config.js';
import router from '../server/routes/index.route.js';

const app = express();

//
使用bodyparser.json()HTTP請求方法放在req.body
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(cors());
app.use(morgan('dev'));

//
使用get請求方式
app.get('/', (req, res) => {
    res.send(`server started on port http://127.0.0.1:${config.port} (${config.env})`);
});

//
宣告路由
app.use('/api', router);

export default app;

l   新增index.route.js
/* index.route.js */
import express from 'express';
import config from './../../config/config.js';

const router = express.Router();

// GET localhost:[port]/api page
router.get('/', (req, res) => {
    res.send(`
此路徑是: localhost:${config.port}/api`);
});

export default router;

l   修改index.js
/* index.js */
import config from './config/config.js';
import app from './config/express.js';

if (!module.parent) {
    app.listen(config.port, () => {
        console.log(`server started on port http://127.0.0.1:${config.port} (${config.env})`);
    });
}

export default app;

l   新增.env
PORT = 3000
NODE_ENV = development
VERSION = 1.0.0

l   下指令打包與執行優化的程式碼:
npm run-script build
npm run-script start

 

測試MySQL連線

l   資料庫使用MySQLMaria DB,需先準備好,接著在VS CodeTerminal下指令安裝MySQL套件:
npm install mysql

l   修改.env,增加紅字部分:
PORT = 3000
NODE_ENV = development
MYSQL_HOST = 127.0.0.1
MYSQL_PORT = 3306
MYSQL_USER = root
MYSQL_PASS =
MYSQL_DATABASE = community
VERSION = 1.0.0

l   修改config.js,增加紅字部分:
/* config.js */
import Joi from 'joi';

require('dotenv').config();

//
建立每個變數 joi 驗證規則
const envVarSchema = Joi.object({
    PORT: Joi.number().default(8080),  //
數字且預設值為'8080'
    NODE_ENV: Joi.string().default('development'),  //
字串且預設值為'development'
    MYSQL_HOST: Joi.string().default('127.0.0.1'),  //
字串且預設值為'127.0.0.1'
    MYSQL_PORT: Joi.number().default(3306),  //
數字且預設值為'3306'
    MYSQL_USER: Joi.string(),  //
字串
    MYSQL_PASS: Joi.string(),  //
字串
    MYSQL_DATABASE: Joi.string(),  //
字串
    VERSION: Joi.string()  // 字串
});

// process.env
撈取.env內的變數做joi驗證
const envVars = envVarSchema.validate(process.env);

const config = {
    port: envVars.value.PORT,  //
埠號'3000'
    env: envVars.value.NODE_ENV,  //
開發模式'development'
    mysqlHost: envVars.value.MYSQL_HOST,  //
主機名稱
    mysqlPort: envVars.value.MYSQL_PORT,  //
連接埠號
    mysqlUserName: envVars.value.MYSQL_USER,  //
用戶名稱
    mysqlPass: envVars.value.MYSQL_PASS,  //
資料庫密碼
    mysqlDatabase: envVars.value.MYSQL_DATABASE,  //
資料庫名稱
    version: envVars.value.VERSION  // 版本'1.0.0'
};

export default config;

l   修改index.route.js,增加紅字部分:
/* index.route.js */
import express from 'express';
import mysql from 'mysql';
import config from './../../config/config';

const router = express.Router();

// GET localhost:[port]/api page.
router.get('/', (req, res) => {
    res.send(`
此路徑是: localhost:${config.port}/api`);
});

// MySQL
連線測試
router.get('/sqlTest', (req, res) => {
    const connectionPool = mysql.createPool({  //
建立一個連線池
        connectionLimit: 10,  //
限制池子連線人數
        host: config.mysqlHost,  //
主機名稱
        user: config.mysqlUserName,  //
用戶名稱
        password: config.mysqlPass,  //
資料庫密碼
        database: config.mysqlDatabase  //
資料庫名稱
    });
    connectionPool.getConnection((err, connection) => {  //
建立一個連線若錯誤回傳err
        if (err) {
            res.send(err);
            console.log('
連線失敗!');
        } else {
            res.send('
連線成功!');
            console.log(connection);
        }
    });
});

export default router;

l   下指令打包與執行優化的程式碼:
npm run-script build
npm run-script start

l   在瀏覽器輸入localhost:3000/api/sqlTest檢視成果。

 

RESTful API

實作RESTful API的準備工作

l   資料庫使用MySQLMaria DB,在名字為test的資料庫建立資料表:
CREATE TABLE IF NOT EXISTS `accounts` (
  `id` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `username` char(30) NOT NULL COLLATE utf8_unicode_ci,
  `password` char(40) NOT NULL COLLATE utf8_unicode_ci,
  `role` enum('admin','user','guest') DEFAULT NULL COMMENT '
角色權限' COLLATE utf8_unicode_ci
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

l   作者(腳印哥)MVC範例資料夾框架:
webapi
┌── models
   └── accounts.js  // 程式業務邏輯與資料庫存取
├── routes
   └── accounts.js  // 負責轉發請求並回應結果
├── app.js  // 程式進入點
├── conf.js  // 設定檔
└── functions.js  // 自訂function

l   移至webapi資料夾,在VS CodeTerminal下指令初始化專案:
npm init -y

l   VS CodeTerminal下指令安裝以下套件:
npm install webpack webpack-node-externals
npm install @babel/core babel-loader
npm install nodemon
npm install express
npm install body-parser
npm install cors
npm install morgan
npm install mysql

l   修改package.json
"scripts": {
    "build": "webpack -w",
    "start": "nodemon dist/index.bundle.js"
    }

l   新增webpack.config.js
/* webpack.config.js */
const nodeExternals = require('webpack-node-externals');
const path = require('path');

module.exports = {
    target: 'node',
    externals: [nodeExternals()],
    entry: {
        'index': './app.js',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].bundle.js',
        libraryTarget: 'commonjs2',
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            },
        ],
    },
    mode: 'development',
}

l   新增app.js
/* app.js */
const bodyparser = require('body-parser');
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');

const app = express();

app.use(bodyparser.urlencoded({extended: false}));
app.use(bodyparser.json());
app.use(cors());
app.use(morgan('dev'));

app.listen(3000, () => {
    console.log(`server started on http://127.0.0.1:3000`);
});

app.get('/', (req, res) => {
    res.send(`server started on http://127.0.0.1:3000`);
});

l   下指令打包與執行優化的程式碼:
npm run-script build
npm run-script start

l   在瀏覽器輸入localhost:3000檢視成果。

 

實作RESTful API

l   修改app.js
/* app.js */
const bodyparser = require('body-parser');
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');

const conf = require('./conf');
const functions = require('./functions');
const accounts = require('./routes/accounts');

const app = express();

app.use(bodyparser.urlencoded({extended: false}));
app.use(bodyparser.json());
app.use(cors());
app.use(morgan('dev'));

app.use(functions.passwdCrypto);
app.use('/accounts', accounts);

app.listen(conf.port, () => {
    console.log(`server started on http://127.0.0.1:${conf.port}`);
});

app.get('/', (req, res) => {
    res.send(`server started on http://127.0.0.1:${conf.port}`);
});

l   新增conf.js
/* conf.js */
module.exports = {
    db: {
        host: 'localhost',
        user: 'root',
        password: '',
        database: 'test'
    },
    port: 3000,
    //
自訂密碼的加鹽
    salt: '@2#!A9x?3'
};

l   新增functions.js
/* functions.js */
const crypto = require('crypto');  //
加解密軟體(內建模組)
const conf = require('./conf');

module.exports = {
    //
將明文密碼加密
    passwdCrypto: (req, res, next) => {
        if (req.body.password) {
            req.body.password = crypto.createHash('md5')
                .update(req.body.password + conf.salt)
                .digest('hex');
        }
        next();
    }
};

l   新增routes/accounts.js
/* routes/accounts.js */
const express = require('express');
const accounts = require('../models/accounts');

const router = express.Router();

//
獲取 /accounts 請求
router.route('/')
    //
取得所有資源
    .get((req, res) => {
        accounts.items(req, (err, results, fields) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            //
沒有找到指定的資源
            if (!results.length) {
                res.sendStatus(404);
                return;
            }
            res.json(results);
        });
    })
    //
新增一筆資源
    .post((req, res) => {
        accounts.add(req, (err, results, fields) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            //
新的資源已建立,並回應新增資源的id
            res.status(201).json(results.insertId);
        });
    });

//
獲取如 /accounts/1 請求
router.route('/:id')
    //
取得指定的一筆資源
    .get((req, res) => {
        accounts.item(req, (err, results, fields) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            if (!results.length) {
                res.sendStatus(404);
                return;
            }
            res.json(results);
        });
    })
    //
刪除指定的一筆資源
    .delete((req, res) => {
        accounts.delete(req, (err, results, fields) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            //
指定的資源已不存在
            //
SQL DELETE成功,results.affectedRows會返回1,反之會返回0
            if (!results.affectedRows) {
                res.sendStatus(410);
                return;
            }
            //
沒有內容(SQL DELETE成功)
            res.sendStatus(204);
        });
    })
    //
覆蓋指定的一筆資源
    .put((req, res) => {
        accounts.put(req, (err, results) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            if (results == 410) {
                res.sendStatus(410);
                return;
            }
            accounts.item(req, (err, results, fields) => {
                res.json(results);
            });
        });
    })
    //
更新指定的一筆資源(部份更新)
    .patch((req, res) => {
        accounts.patch(req, (err, results, fields) => {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }
            if (!results.affectedRows) {
                res.sendStatus(410);
                return;
            }
            // response
被更新的資源欄位,但因request主體的欄位不包含id,因此需自行加入
            req.body.id = req.params.id;
            res.json([req.body]);
        });
    });

module.exports = router;

l   新增models/accounts.js
/* models/accounts.js */
const mysql = require('mysql');

const conf = require('../conf');

const connection = mysql.createConnection(conf.db);
var sql = '';

module.exports = {
    items: (req, callback) => {
        sql = 'SELECT * FROM accounts';
        return connection.query(sql, callback);
    },
    add: (req, callback) => {
        sql = mysql.format('INSERT INTO accounts SET ?', req.body);
        return connection.query(sql, callback);
    },
    item: (req, callback) => {
        sql = mysql.format('SELECT * FROM accounts WHERE id = ?', [req.params.id]);
        return connection.query(sql, callback);
    },
    delete: (req, callback) => {
        sql = mysql.format('DELETE FROM accounts WHERE id = ?', [req.params.id]);
        return connection.query(sql, callback);
    },
    put: (req, callback) => {
        //
使用SQL交易功能實現資料回滾,因為是先刪除資料在新增,且Key值須相同,如刪除後發現要新增的資料有誤,則使用rollback()回滾
        connection.beginTransaction((err) => {
            if (err) throw err;

            sql = mysql.format('DELETE FROM accounts WHERE id = ?', [req.params.id]);

            connection.query(sql, (err, results, fields) => {
                //
SQL DELETE成功,results.affectedRows會返回1,反之會返回0
                if (results.affectedRows) {
                    req.body.id = req.params.id;
                    sql = mysql.format('INSERT INTO accounts SET ?', req.body);

                    connection.query(sql, (err, results, fields) => {
                        //
請求不正確
                        if (err) {
                            connection.rollback(() => {
                                callback(err, 400);
                            });
                        } else {
                            connection.commit((err) => {
                                if (err) callback(err, 400);
                                callback(err, 200);
                            });
                        }
                    });
                } else {
                    //
指定的資源已不存在
                    callback(err, 410);
                }
            });
        });
    },
    patch: (req, callback) => {
        sql = mysql.format('UPDATE accounts SET ? WHERE id = ?', [req.body, req.params.id]);
        return connection.query(sql, callback);
    }
};

l   下指令打包與執行優化的程式碼:
npm run-script build
npm run-script start

l   發送HTTP POST請求方法,新增二筆資源:


l   發送HTTP GET請求方法,取得所有資源:


l   發送HTTP GET請求方法,取得指定的一筆資源:


l   發送HTTP PUT請求方法,覆蓋指定的一筆資源:


l   發送HTTP PATCH請求方法,部份更新指定的一筆資源:


l   發送HTTP DELETE請求方法,刪除指定的一筆資源:

沒有留言:

張貼留言