能心平氣和的工作,應該是職場上最基本的需求之一吧!前一陣子在不適合自己的職場環境待了三個月,在試用期的最後一週提出離職,卻也發現原來自己多麼不喜歡那份工作:才三個月就讓人每天鬱鬱寡歡,知識技能也無成長,更領著感到「雞肋」的薪資,以及天天忍受塞車通勤的鬱悶。離職之後反而過得充實,一邊求職一邊充電,要更謹慎地做出職涯規劃。
轉職,真的是段讓人感到焦慮的過程,但是藉由觀察求職市場上的職缺,我們可以更清楚地了解資訊工作的趨勢為何。由於自己是資料科學相關背景的資訊學習者,因此求職初期,以「資料分析師」與「資料工程師」作為目標,然而透過每日檢視人力銀行的職缺配對信,我發現了「後端」、「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.js與RESTful 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
Code的Terminal下指令檢查:
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 Code的Terminal切換至index.js所在目錄,下指令啟動服務:
node index.js
l 在瀏覽器輸入localhost:3000或127.0.0.1:3000檢視成果。
Express框架
l npm套件管理工具與package.json:
# 在VS Code的Terminal下指令初始化專案,完成後會多了package.json
npm init -y
# 在VS Code的Terminal切換至package.json所在目錄,下指令把相依套件安裝回來
npm install
l 在VS Code的Terminal下指令安裝Express,Express是Node.js的前後端框架,包含MVC
Framework:
npm install express-generator
express --version
# 初始化專案,專案名稱命名為restful_api
express -f restful_api
cd .\restful_api\
npm install
l 解析Express專案資料夾內容:
bin/www是程式的進入點;
前端畫面是由public與views這兩個資料夾管理;
路由是由routes這個資料夾管理。
測試Express框架的路由
l 在routes/index.js增加以下路由:
/* index.js */
router.get('/test', function (req, res, next) {
res.send('this is localhost:3000/test')
});
l 在VS Code的Terminal下指令啟動服務:
cd .\restful_api\
# 觀察package.json,發現npm start指令等同node
./bin/www指令
npm start
l 在瀏覽器輸入不同路由檢視成果:
localhost:3000
localhost:3000/test
透過Webpack、Babel、Nodemon開發
l 在VS Code的Terminal下指令初始化專案:
npm init -y
l 在VS Code的Terminal下指令安裝Webpack,Webpack提供模組化開發方式,將各種靜態資源視為模組,再生成優化過的程式碼:
npm install webpack webpack-node-externals
l 在VS Code的Terminal下指令安裝Babel,Babel能讓開發者以偏好的風格寫作原始碼,再翻譯成標準JavaScript以讓瀏覽器看懂:
npm install @babel/core babel-loader
l 在VS Code的Terminal下指令安裝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.json的script,webpack -w的w代表持續監聽,程式碼有變動時會同步更新
npm run-script build
# 執行記載在package.json的script,執行打包後的檔案
npm run-script start
接續「透過Webpack、Babel、Nodemon開發」
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 Code的Terminal下指令安裝Express:
npm install express
l 在VS Code的Terminal下指令安裝Body-parser,Body-parser是HTTP請求解析的中介軟體,可以解析Json、Raw、Text、Xml、Url-encoded等格式的請求:
npm install body-parser
l 在VS Code的Terminal下指令安裝CORS,跨來源資源共享(Cross-Origin Resource Sharing, CORS)使用額外HTTP標頭讓瀏覽網站的User Agent能訪問不同網域伺服器之特定資源,用來建立讀取權限:
npm install cors
l 在VS Code的Terminal下指令安裝Morgan,Morgan是HTTP Request Logger,在存取API時,終端機會顯示存取結果,例如200、404狀態碼:
npm install morgan
l 在VS Code的Terminal下指令安裝Dotenv,Dotenv將.env環境參數加載到process.env,在其它文件引入後只要呼叫process.env.[變數名稱],就能撈出此環境參數:
npm install dotenv
l 在VS Code的Terminal下指令安裝Joi,Joi將限制資料格式,如果傳送錯誤格式資料,會在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 資料庫使用MySQL或Maria DB,需先準備好,接著在VS
Code的Terminal下指令安裝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 資料庫使用MySQL或Maria 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 Code的Terminal下指令初始化專案:
npm init -y
l 在VS Code的Terminal下指令安裝以下套件:
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請求方法,刪除指定的一筆資源:
沒有留言:
張貼留言