LOGO

MIS 腳印

記錄 IT 學習的軌跡

Node.js RESTful Web API 範例 for MySQL

在 Linux (CentOS 7) 使用 Node.js 搭配 Express 和 MySQL,建置 MVC 模式設計的 RESTful Web API 程式碼範例教學,並詳述 RESTful Web API 與 HTTP 方法和 HTTP 狀態碼的關係。

Node.js

RESTful Web API 與 HTTP

REST (Representational State Transfer,表現層狀態轉換) 是架構在 HTTP (HyperText Transfer Protocol,超文本傳輸協定) 之上的設計,提供服務軟體的構建風格。

符合 REST 設計風格的 Web API 即稱為 RESTful API,它允許客戶端發出 URL (Uniform Resource Locator,統一資源定位符)存取或操作網路資源的請求。

HTTP 方法

依使用的 HTTP 方法 (method),來識別請求 (request) 資源 (source) 時要進行的操作。RESTful Web API 使用到的方法如下表:

方法 一組資源 URL:http://192.168.0.200:3000/accounts 單個資源 URL:http://192.168.0.200:3000/accounts/2
GET 取得所有資源 取得指定的一筆資源
POST 新增一筆資源  
PUT   覆蓋指定的一筆資源
PATCH   更新指定的一筆資源 (部份更新)
DELETE   刪除指定的一筆資源

HTTP 狀態碼

狀態碼 (Status Code) 是用來表示客戶端 (client) 發送請求至伺服器 (server) 進行處理後,伺服器回應 (response) 給客戶端的狀態,也就是讓客戶端用來識別請求是否被伺服器正確地處理。RESTful Web API 使用到的狀態碼如下表:

狀態碼是回應給客戶端,放在 HTTP 首部 (header) 開頭必填的 3 位數字
狀態碼 名稱 說明 備註
200 OK 請求成功 狀態碼第一位數 2xx 類型為「成功」
201 201 Created 新的資源已建立
204 No Content 沒有內容
400 Bad Request 請求不正確 狀態碼第一位數 4xx 類型為「客戶端原因引起的錯誤」
404 Not Found 沒有找到指定的資源
410 Gone 指定的資源已不存在
500 Internal Server Error 伺服器發生錯誤 狀態碼第一位數 5xx 類型為「伺服器原因引起的錯誤」

HTTP 方法匹配的狀態碼與內容

RESTful Web API 使用 HTTP 方法,請求成功 or 失敗匹配的狀態碼與內容如下表:

內容是回應給客戶端,放在 HTTP 主體 (body) 的資料
方法 請求成功 請求失敗
狀態碼 內容
GET 200 資源資料 404  
POST 201 新增資源的 id 400  
PUT 200 該筆資源的完整資料 400 410
PATCH 200 該筆資源更動的資料 400 410
DELETE 204 沒有內容 400 410

資料庫

結構

本範例資料表結構如下表:

名稱 型態 空值 預設值 AUTO_INCREMENT
id (Primary Key) int(11)    
username char(30)      
password char(40)      
role enum(‘admin’, ‘user’, ‘guest’) NULL  

SQL 語句

可執行以下 SQL 語法來建立本範例的資料表:

CREATE TABLE IF NOT EXISTS `accounts` (
  `id` int(11) NOT NULL,
  `username` char(30) COLLATE utf8_unicode_ci NOT NULL,
  `password` char(40) COLLATE utf8_unicode_ci NOT NULL,
  `role` enum('admin','user','guest') COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '角色權限'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

--
-- 資料表索引 `accounts`
--
ALTER TABLE `accounts`
  ADD PRIMARY KEY (`id`);

--
-- 使用資料表 AUTO_INCREMENT `accounts`
--
ALTER TABLE `accounts`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

Node.js 前置作業

詳細使用說明可參考 Node.js 套件管理器 NPM 使用

初始化專案

在專案目錄 (此例為 webapi) 自動生成 package.json 檔案:

[root@localhost webapi]# npm init --yes

安裝模組

[root@localhost webapi]# npm install body-parser express mysql --save

Node.js 程式架構

程式架構使用類似 MVC (Model–View–Controller) 模式設計,對應如下:

  • models/ 目錄:MVC 模式的 Model (模型)
  • routes/ 目錄: MVC 模式的 Controller (控制器)
由於 RESTful Web API 並無 UI (User Interface,使用者介面),因此就沒有 MVC 模式的 View (視圖)
webapi/             (專案目錄)
│
├── models/         (程式業務邏輯與資料庫存取)
│   │
│   └── accounts.js
│
├── routes/         (負責轉發請求並回應結果)
│   │
│   └── accounts.js
│
├── app.js          (應用程式進入點)
├── conf.js         (設定檔)
└── functions.js    (自訂 function)

Node.js 程式碼

設定檔

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

自訂 function

var crypto = require('crypto'); // 加解密軟體 (內建模組)
var conf = require('./conf');

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

        next();
    }
};

應用程式進入點

var bodyparser = require('body-parser');    // 解析 HTTP 請求主體的中介軟體
var express = require('express');

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

var app = express();

// 使用 bodyparser.json() 將 HTTP 請求方法 POST、DELETE、PUT 和 PATCH,放在 HTTP 主體 (body) 發送的參數存放在 req.body
app.use(bodyparser.urlencoded({ extended: false }));
app.use(bodyparser.json());

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

app.listen(conf.port, function () {
    console.log('app listening on port ' + conf.port + '!');
});

routes

routes/ 目錄下的檔案程式:

var express = require('express');
var accounts = require('../models/accounts');

var router = express.Router();

// 獲取 /accounts 請求
router.route('/')
    // 取得所有資源
    .get(function (req, res) {
        accounts.items(req, function (err, results, fields) {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }

            // 沒有找到指定的資源
            if (!results.length) {
                res.sendStatus(404);
                return;
            }

            res.json(results);
        });
    })
    // 新增一筆資源
    .post(function (req, res) {        
        accounts.add(req, function (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(function (req, res) {
        accounts.item(req, function (err, results, fields) {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }

            if (!results.length) {
                res.sendStatus(404);
                return;
            }

            res.json(results);
        });
    })
    // 刪除指定的一筆資源
    .delete(function (req, res) {        
        accounts.delete(req, function (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;
            }

            // 沒有內容 (成功)
            res.sendStatus(204);
        });
    })
    // 覆蓋指定的一筆資源
    .put(function (req, res) {
        accounts.put(req, function (err, results) {
            if (err) {
                res.sendStatus(500);
                return console.error(err);
            }

            if (results === 410) {
                res.sendStatus(410);
                return;
            }
            
            accounts.item(req, function (err, results, fields) {
                res.json(results);
            });
        });
    })
    // 更新指定的一筆資源 (部份更新)
    .patch(function (req, res) {
        accounts.patch(req, function (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;

models

models/ 目錄下的檔案程式:

var mysql = require('mysql');
var conf = require('../conf');

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

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

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

程式測試

安裝測試軟體

使用 Chrome 擴充功能安裝應用程式 Advanced REST client,可用它來測試伺服器提供的 RESTful Web API Service (服務)

啟動程式並測試

執行 node 指令來啟動 Node.js 應用程式:

[root@localhost webapi]# node app.js
app listening on port 3000

使用 Chrome 應用程式 Advanced REST client,發送 HTTP POST 請求方法,新增一筆資源 (這裡執行兩次,新增二筆資源):

HTTP POST 請求方法
HTTP POST 請求方法
HTTP POST 請求方法
HTTP POST 請求方法

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

HTTP GET 請求方法
HTTP GET 請求方法

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

HTTP GET 請求方法
HTTP GET 請求方法

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

HTTP PUT 請求方法
HTTP PUT 請求方法

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

HTTP PATCH 請求方法
HTTP PATCH 請求方法

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

HTTP DELETE 請求方法
HTTP DELETE 請求方法

錯誤排除

Error: listen EADDRINUSE

執行 node 指令啟動 Node.js 應用程式,顯示 Error: listen EADDRINUSE :::3000,表示網路通訊埠 (port) 3000 已被佔用,造成無法再使用相同的埠來啟動 Node.js 應用程式:

[root@localhost webapi]# node app.js
events.js:183
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::3000
    at Object._errnoException (util.js:1022:11)
    at _exceptionWithHostPort (util.js:1044:20)
    at Server.setupListenHandle [as _listen2] (net.js:1351:14)
    at listenInCluster (net.js:1392:12)
    at Server.listen (net.js:1476:7)
    at Function.listen (/var/www/web-dev/webapi/node_modules/express/lib/application.js:618:24)
    at Object. (/var/www/web-dev/webapi/app.js:14:5)
    at Module._compile (module.js:643:30)
    at Object.Module._extensions..js (module.js:654:10)
    at Module.load (module.js:556:32)

使用 ss 指令查看網路狀態:

[root@localhost webapi]# ss -ltnp
State      Recv-Q Send-Q
 Local Address:Port
           Peer Address:Port
LISTEN     0      128                                                                                    :::22                                                                                                 :::*
users:(("sshd",pid=876,fd=4))
LISTEN     0      128                                                                                    :::3000                                                                                               :::*
users:(("node" ,pid=3804,fd=11))

由上述得知網路埠 3000 被 node 佔用了,可使用 kill 指令的 -15 選項來正常終止一個 PID (Process IDentifier,程序 ID):

[root@localhost webapi]# kill -15 3804

即可正常啟動 Node.js 應用程式:

[root@localhost webapi]# node app.js
app listening on port 3000

參考


發表迴響