一個完整的Node.js RESTful API

前言

這篇文章算是對Building APIs with Node.js這本書的一個總結。用Node.js寫接口對我來講是頗有用的,好比在項目初始階段,能夠快速的模擬網絡請求。正由於它用js寫的,跟iOS直接的聯繫也比其餘語言寫的後臺更加接近。前端

這本書寫的極好,做者編碼的思路極其清晰,整本書雖然說是用英文寫的,但很容易讀懂。同時,它完整的構建了RESTful API的一整套邏輯。node

我更加喜歡寫一些函數響應式的程序,把函數當作數據或參數進行傳遞對我有着莫大的吸引力。git

從程序的搭建,到設計錯誤捕獲機制,再到程序的測試任務,這是一個完整的過程。這邊文章將會很長,我會把每一個核心概念的代碼都黏貼上來。github

環境搭建

下載並安裝Node.jshttps://nodejs.org/en/sql

安裝npm數據庫

下載演示項目express

git clone https://github.com/agelessman/ntask-api

進入項目文件夾後運行npm

npm install

上邊命令會下載項目所需的插件,而後啓動項目json

npm start

訪問接口文檔api

http://localhost:3000/apidoc

程序入口

Express這個框架你們應該都知道,他提供了很豐富的功能,我在這就不作解釋了,先看該項目中的代碼:

import express from "express"
import consign from "consign"

const app = express();

/// 在使用include或者then的時候,是有順序的,若是傳入的參數是一個文件夾
/// 那麼他會按照文件夾中文件的順序進行加載
consign({verbose: false})
    .include("libs/config.js")
    .then("db.js")
    .then("auth.js")
    .then("libs/middlewares.js")
    .then("routers")
    .then("libs/boot.js")
    .into(app);

module.exports = app;

不論是models,views仍是routers都會通過Express的加工和配置。在該項目中並無使用到views的地方。Express經過app對整個項目的功能進行配置,但咱們不能把全部的參數和方法都寫到這一個文件之中,不然當項目很大的時候將急難維護。

我使用Node.js的經驗是不多的,但上面的代碼給個人感受就是極其簡潔,思路極其清晰,經過consign這個模塊導入其餘模塊在這裏就讓代碼顯得很優雅。

@note:導入的順序很重要。

在這裏,app的使用很像一個全局變量,這個咱們會在下邊的內容中展現出來,按序導入後,咱們就能夠經過這樣的方式訪問模塊的內容了:、

app.db
app.auth
app.libs....

模型設計

在我看來,在開始作任何項目前,需求分析是最重要的,通過需求分析後,咱們會有一個關於代碼設計的大的概念。

編碼的實質是什麼?我認爲就是數據的存儲和傳遞,同時還須要考慮性能和安全的問題

所以咱們第二部的任務就是設計數據模型,同時能夠反應出咱們需求分析的成果。在該項目中有兩個模型,UserTask,每個task對應一個user,一個user能夠有多個task

用戶模型:

import bcrypt from "bcrypt"

module.exports = (sequelize, DataType) => {
    "use strict";
    const Users = sequelize.define("Users", {
        id: {
            type: DataType.INTEGER,
            primaryKey: true,
            autoIncrement: true
        },
        name: {
            type: DataType.STRING,
            allowNull: false,
            validate: {
                notEmpty: true
            }
        },
        password: {
            type: DataType.STRING,
            allowNull: false,
            validate: {
                notEmpty: true
            }
        },
        email: {
            type: DataType.STRING,
            unique: true,
            allowNull: false,
            validate: {
                notEmpty: true
            }
        }
    }, {
        hooks: {
            beforeCreate: user => {
                const salt = bcrypt.genSaltSync();
                user.password = bcrypt.hashSync(user.password, salt);
            }
        }
    });
    Users.associate = (models) => {
        Users.hasMany(models.Tasks);
    };
    Users.isPassword = (encodedPassword, password) => {
        return bcrypt.compareSync(password, encodedPassword);
    };

    return Users;
};

任務模型:

module.exports = (sequelize, DataType) => {
    "use strict";
    const Tasks = sequelize.define("Tasks", {
        id: {
            type: DataType.INTEGER,
            primaryKey: true,
            autoIncrement: true
        },
        title: {
            type: DataType.STRING,
            allowNull: false,
            validate: {
                notEmpty: true
            }
        },
        done: {
            type: DataType.BOOLEAN,
            allowNull: false,
            defaultValue: false
        }
    });
    Tasks.associate = (models) => {
        Tasks.belongsTo(models.Users);
    };
    return Tasks;
};

該項目中使用了系統自帶的sqlite做爲數據庫,固然也可使用其餘的數據庫,這裏不限制是關係型的仍是非關係型的。爲了更好的管理數據,咱們使用sequelize這個模塊來管理數據庫。

爲了節省篇幅,這些模塊我就都不介紹了,在google上一搜就出來了。在我看的Node.js的開發中,這種ORM的管理模塊有不少,好比說對MongoDB進行管理的mongoose。不少不少,他們主要的思想就是Scheme。

在上邊的代碼中,咱們定義了模型的輸出和輸入模板,同時對某些特定的字段進行了驗證,所以在使用的過程當中就有可能會產生來自數據庫的錯誤,這些錯誤咱們會在下邊講解到。

Tasks.associate = (models) => {
        Tasks.belongsTo(models.Users);
};

Users.associate = (models) => {
    Users.hasMany(models.Tasks);
};
Users.isPassword = (encodedPassword, password) => {
    return bcrypt.compareSync(password, encodedPassword);
};

hasManybelongsTo表示一種關聯屬性,Users.isPassword算是一個類方法。bcrypt模塊能夠對密碼進行加密編碼。

數據庫

在上邊咱們已經知道了,咱們使用sequelize模塊來管理數據庫。其實,在最簡單的層面而言,數據庫只須要給咱們數據模型就好了,咱們拿到這些模型後,就可以根據不一樣的需求,去完成各類各樣的CRUD操做。

import fs from "fs"
import path from "path"
import Sequelize from "sequelize"

let db = null;


module.exports = app => {
    "use strict";
    if (!db) {
        const config = app.libs.config;
        const sequelize = new Sequelize(
            config.database,
            config.username,
            config.password,
            config.params
        );

        db = {
            sequelize,
            Sequelize,
            models: {}
        };

        const dir = path.join(__dirname, "models");

        fs.readdirSync(dir).forEach(file => {
            const modelDir = path.join(dir, file);
            const model = sequelize.import(modelDir);
            db.models[model.name] = model;
        });

        Object.keys(db.models).forEach(key => {
            db.models[key].associate(db.models);
        });
    }
    return db;
};

上邊的代碼很簡單,db是一個對象,他存儲了全部的模型,在這裏是UserTask。經過sequelize.import獲取模型,而後又調用了以前寫好的associate方法。

上邊的函數調用以後呢,返回db,db中有咱們須要的模型,到此爲止,咱們就創建了數據庫的聯繫,做爲對後邊代碼的一個支撐。

CRUD

CRUD在router中,咱們先看看router/tasks.js的代碼:

module.exports = app => {
    "use strict";
    const Tasks = app.db.models.Tasks;

    app.route("/tasks")
        .all(app.auth.authenticate())

        .get((req, res) => {
            console.log(`req.body: ${req.body}`);
            Tasks.findAll({where: {user_id: req.user.id} })
                .then(result => res.json(result))
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        })

        .post((req, res) => {
            req.body.user_id = req.user.id;
            Tasks.create(req.body)
                .then(result => res.json(result))
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        });

    app.route("/tasks/:id")
        .all(app.auth.authenticate())

        .get((req, res) => {
            Tasks.findOne({where: {
                id: req.params.id,
                user_id: req.user.id
            }})
                .then(result => {
                    if (result) {
                        res.json(result);
                    } else {
                        res.sendStatus(412);
                    }
                })
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        })

        .put((req, res) => {
            Tasks.update(req.body, {where: {
                id: req.params.id,
                user_id: req.user.id
            }})
                .then(result => res.sendStatus(204))
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        })

        .delete((req, res) => {
            Tasks.destroy({where: {
                id: req.params.id,
                user_id: req.user.id
            }})
                .then(result => res.sendStatus(204))
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        });
};

再看看router/users.js的代碼:

module.exports = app => {
    "use strict";
    const Users = app.db.models.Users;

    app.route("/user")
        .all(app.auth.authenticate())

    .get((req, res) => {
            Users.findById(req.user.id, {
                attributes: ["id", "name", "email"]
            })
                .then(result => res.json(result))
                .catch(error => {
                    res.status(412).json({msg: error.message});
                });
        })

     .delete((req, res) => {
        console.log(`delete..........${req.user.id}`);
         Users.destroy({where: {id: req.user.id}})
             .then(result => {
                 console.log(`result: ${result}`);
                 return res.sendStatus(204);
             })
             .catch(error => {
                 console.log(`resultfsaddfsf`);
                 res.status(412).json({msg: error.message});
             });
     });

    app.post("/users", (req, res) => {
        Users.create(req.body)
            .then(result => res.json(result))
            .catch(error => {
                res.status(412).json({msg: error.message});
            });
    });
};

這些路由寫起來比較簡單,上邊的代碼中,基本思想就是根據模型操做CRUD,包括捕獲異常。可是額外的功能是作了authenticate,也就是受權操做。

這一塊好像沒什麼好說的,基本上都是固定套路。

受權

在網絡環境中,不能總是傳遞用戶名和密碼。這時候就須要一些受權機制,該項目中採用的是JWT受權(JSON Wbb Toknes),有興趣的同窗能夠去了解下這個受權,它也是按照必定的規則生成token。

所以對於受權而言,最核心的部分就是如何生成token。

import jwt from "jwt-simple"

module.exports = app => {
    "use strict";
    const cfg = app.libs.config;
    const Users = app.db.models.Users;

    app.post("/token", (req, res) => {
        const email = req.body.email;
        const password = req.body.password;
        if (email && password) {
            Users.findOne({where: {email: email}})
                .then(user => {
                    if (Users.isPassword(user.password, password)) {
                        const payload = {id: user.id};
                        res.json({
                            token: jwt.encode(payload, cfg.jwtSecret)
                        });
                    } else {
                        res.sendStatus(401);
                    }
                })
                .catch(error => res.sendStatus(401));
        } else {
            res.sendStatus(401);
        }
    });
};

上邊代碼中,在獲得郵箱和密碼後,再使用jwt-simple模塊生成一個token。

JWT在這也很少說了,它由三部分組成,這個在它的官網中解釋的很詳細。

我以爲老外寫東西一個最大的優勢就是文檔很詳細。要想弄明白全部組件如何使用,最好的方法就是去他們的官網看文檔,固然這要求英文水平還能夠。

受權通常分兩步:

  • 生成token
  • 驗證token

若是從前端傳遞一個token過來,咱們怎麼解析這個token,而後獲取到token裏邊的用戶信息呢?

import passport from "passport";
import {Strategy, ExtractJwt} from "passport-jwt";

module.exports = app => {
    const Users = app.db.models.Users;
    const cfg = app.libs.config;
    const params = {
        secretOrKey: cfg.jwtSecret,
        jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()
    };
    var opts = {};
    opts.jwtFromRequest = ExtractJwt.fromAuthHeaderWithScheme("JWT");
    opts.secretOrKey = cfg.jwtSecret;

    const strategy = new Strategy(opts, (payload, done) => {
        Users.findById(payload.id)
            .then(user => {
                if (user) {
                    return done(null, {
                        id: user.id,
                        email: user.email
                    });
                }
                return done(null, false);
            })
            .catch(error => done(error, null));
    });
    passport.use(strategy);

    return {
        initialize: () => {
            return passport.initialize();
        },
        authenticate: () => {
            return passport.authenticate("jwt", cfg.jwtSession);
        }
    };
};

這就用到了passportpassport-jwt這兩個模塊。passport支持不少種受權。不論是iOS仍是Node中,驗證都須要指定一個策略,這個策略是最靈活的一層。

受權須要在項目中提早進行配置,也就是初始化,app.use(app.auth.initialize());

若是咱們想對某個接口進行受權驗證,那麼只須要像下邊這麼用就能夠了:

.all(app.auth.authenticate())

.get((req, res) => {
    console.log(`req.body: ${req.body}`);
    Tasks.findAll({where: {user_id: req.user.id} })
        .then(result => res.json(result))
        .catch(error => {
            res.status(412).json({msg: error.message});
        });
})

配置

Node.js中一個頗有用的思想就是middleware,咱們能夠利用這個手段作不少有意思的事情:

import bodyParser from "body-parser"
import express from "express"
import cors from "cors"
import morgan from "morgan"
import logger from "./logger"
import compression from "compression"
import helmet from "helmet"

module.exports = app => {
    "use strict";
    app.set("port", 3000);
    app.set("json spaces", 4);
    console.log(`err  ${JSON.stringify(app.auth)}`);
    app.use(bodyParser.json());
    app.use(app.auth.initialize());
    app.use(compression());
    app.use(helmet());
    app.use(morgan("common", {
        stream: {
            write: (message) => {
                logger.info(message);
            }
        }
    }));
    app.use(cors({
        origin: ["http://localhost:3001"],
        methods: ["GET", "POST", "PUT", "DELETE"],
        allowedHeaders: ["Content-Type", "Authorization"]
    }));
    app.use((req, res, next) => {
        // console.log(`header: ${JSON.stringify(req.headers)}`);
        if (req.body && req.body.id) {
            delete req.body.id;
        }
        next();
    });

    app.use(express.static("public"));
};

上邊的代碼中包含了不少新的模塊,app.set表示進行設置,app.use表示使用middleware。

測試

寫測試代碼是我平時很容易疏忽的地方,說實話,這麼重要的部分不該該被忽視。

import jwt from "jwt-simple"

describe("Routes: Users", () => {
    "use strict";
    const Users = app.db.models.Users;
    const jwtSecret = app.libs.config.jwtSecret;
    let token;

    beforeEach(done => {
        Users
            .destroy({where: {}})
            .then(() => {
                return Users.create({
                    name: "Bond",
                    email: "Bond@mc.com",
                    password: "123456"
                });
            })
            .then(user => {
                token = jwt.encode({id: user.id}, jwtSecret);
                done();
            });
    });

    describe("GET /user", () => {
        describe("status 200", () => {
            it("returns an authenticated user", done => {
                request.get("/user")
                    .set("Authorization", `JWT ${token}`)
                    .expect(200)
                    .end((err, res) => {
                        expect(res.body.name).to.eql("Bond");
                        expect(res.body.email).to.eql("Bond@mc.com");
                        done(err);
                    });
            });
        });
    });

    describe("DELETE /user", () => {
        describe("status 204", () => {
            it("deletes an authenticated user", done => {
                request.delete("/user")
                    .set("Authorization", `JWT ${token}`)
                    .expect(204)
                    .end((err, res) => {
                        console.log(`err: ${err}`);
                        done(err);
                    });
            });
        });
    });

    describe("POST /users", () => {
        describe("status 200", () => {
            it("creates a new user", done => {
                request.post("/users")
                    .send({
                        name: "machao",
                        email: "machao@mc.com",
                        password: "123456"
                    })
                    .expect(200)
                    .end((err, res) => {
                        expect(res.body.name).to.eql("machao");
                        expect(res.body.email).to.eql("machao@mc.com");
                        done(err);
                    });
            });
        });
    });
});

測試主要依賴下邊的這幾個模塊:

import supertest from "supertest"
import chai from "chai"
import app from "../index"

global.app = app;
global.request = supertest(app);
global.expect = chai.expect;

其中supertest用來發請求的,chai用來判斷是否成功。

使用mocha測試框架來進行測試:

"test": "NODE_ENV=test mocha test/**/*.js",

生成接口文檔

接口文檔也是很重要的一個環節,該項目使用的是ApiDoc.js。這個沒什麼好說的,直接上代碼:

/**
 * @api {get} /tasks List the user's tasks
 * @apiGroup Tasks
 * @apiHeader {String} Authorization Token of authenticated user
 * @apiHeaderExample {json} Header
 *  {
 *      "Authorization": "xyz.abc.123.hgf"
 *  }
 * @apiSuccess {Object[]} tasks Task list
 * @apiSuccess {Number} tasks.id Task id
 * @apiSuccess {String} tasks.title Task title
 * @apiSuccess {Boolean} tasks.done Task is done?
 * @apiSuccess {Date} tasks.updated_at Update's date
 * @apiSuccess {Date} tasks.created_at Register's date
 * @apiSuccess {Number} tasks.user_id The id for the user's
 * @apiSuccessExample {json} Success
 *  HTTP/1.1 200 OK
 *  [{
 *      "id": 1,
 *      "title": "Study",
 *      "done": false,
 *      "updated_at": "2016-02-10T15:46:51.778Z",
 *      "created_at": "2016-02-10T15:46:51.778Z",
 *      "user_id": 1
 *  }]
 * @apiErrorExample {json} List error
 *  HTTP/1.1 412 Precondition Failed
 */
 
 /**
 * @api {post} /users Register a new user
 * @apiGroup User
 * @apiParam {String} name User name
 * @apiParam {String} email User email
 * @apiParam {String} password User password
 * @apiParamExample {json} Input
 *  {
 *      "name": "James",
 *      "email": "James@mc.com",
 *      "password": "123456"
 *  }
 * @apiSuccess {Number} id User id
 * @apiSuccess {String} name User name
 * @apiSuccess {String} email User email
 * @apiSuccess {String} password User encrypted password
 * @apiSuccess {Date} update_at Update's date
 * @apiSuccess {Date} create_at Rigister's date
 * @apiSuccessExample {json} Success
 *  {
 *      "id": 1,
 *      "name": "James",
 *      "email": "James@mc.com",
 *      "updated_at": "2016-02-10T15:20:11.700Z",
 *      "created_at": "2016-02-10T15:29:11.700Z"
 *  }
 * @apiErrorExample {json} Rergister error
 *  HTTP/1.1 412 Precondition Failed
 */

大概就相似與上邊的樣子,既能夠作註釋用,又能夠自動生成文檔,一石二鳥,我就不上圖了。

準備發佈

到了這裏,就只剩下發佈前的一些操做了,

有的時候,處於安全方面的考慮,咱們的API可能只容許某些域名的訪問,所以在這裏引入一個強大的模塊cors,介紹它的文章,網上有不少,你們能夠直接搜索,在該項目中是這麼使用的:

app.use(cors({
    origin: ["http://localhost:3001"],
    methods: ["GET", "POST", "PUT", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"]
}));

這個設置在本文的最後的演示網站中,會起做用。

打印請求日誌一樣是一個很重要的任務,所以引進了winston模塊。下邊是對他的配置:

import fs from "fs"
import winston from "winston"

if (!fs.existsSync("logs")) {
    fs.mkdirSync("logs");
}

module.exports = new winston.Logger({
    transports: [
        new winston.transports.File({
            level: "info",
            filename: "logs/app.log",
            maxsize: 1048576,
            maxFiles: 10,
            colorize: false
        })
    ]
});

打印的結果大概是這樣的:

{"level":"info","message":"::1 - - [26/Sep/2017:11:16:23 +0000] \"GET /tasks HTTP/1.1\" 200 616\n","timestamp":"2017-09-26T11:16:23.089Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:16:43 +0000] \"OPTIONS /user HTTP/1.1\" 204 0\n","timestamp":"2017-09-26T11:16:43.583Z"}
{"level":"info","message":"Tue Sep 26 2017 19:16:43 GMT+0800 (CST) Executing (default): SELECT `id`, `name`, `password`, `email`, `created_at`, `updated_at` FROM `Users` AS `Users` WHERE `Users`.`id` = 342;","timestamp":"2017-09-26T11:16:43.592Z"}
{"level":"info","message":"Tue Sep 26 2017 19:16:43 GMT+0800 (CST) Executing (default): SELECT `id`, `name`, `email` FROM `Users` AS `Users` WHERE `Users`.`id` = 342;","timestamp":"2017-09-26T11:16:43.596Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:16:43 +0000] \"GET /user HTTP/1.1\" 200 73\n","timestamp":"2017-09-26T11:16:43.599Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:16:49 +0000] \"OPTIONS /user HTTP/1.1\" 204 0\n","timestamp":"2017-09-26T11:16:49.658Z"}
{"level":"info","message":"Tue Sep 26 2017 19:16:49 GMT+0800 (CST) Executing (default): SELECT `id`, `name`, `password`, `email`, `created_at`, `updated_at` FROM `Users` AS `Users` WHERE `Users`.`id` = 342;","timestamp":"2017-09-26T11:16:49.664Z"}
{"level":"info","message":"Tue Sep 26 2017 19:16:49 GMT+0800 (CST) Executing (default): DELETE FROM `Users` WHERE `id` = 342","timestamp":"2017-09-26T11:16:49.669Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:16:49 +0000] \"DELETE /user HTTP/1.1\" 204 -\n","timestamp":"2017-09-26T11:16:49.714Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:17:04 +0000] \"OPTIONS /token HTTP/1.1\" 204 0\n","timestamp":"2017-09-26T11:17:04.905Z"}
{"level":"info","message":"Tue Sep 26 2017 19:17:04 GMT+0800 (CST) Executing (default): SELECT `id`, `name`, `password`, `email`, `created_at`, `updated_at` FROM `Users` AS `Users` WHERE `Users`.`email` = 'xiaoxiao@mc.com' LIMIT 1;","timestamp":"2017-09-26T11:17:04.911Z"}
{"level":"info","message":"::1 - - [26/Sep/2017:11:17:04 +0000] \"POST /token HTTP/1.1\" 401 12\n","timestamp":"2017-09-26T11:17:04.916Z"}

性能上,咱們使用Node.js自帶的cluster來利用機器的多核,代碼以下:

import cluster from "cluster"
import os from "os"

const CPUS = os.cpus();

if (cluster.isMaster) {
    // Fork
    CPUS.forEach(() => cluster.fork());

    // Listening connection event
    cluster.on("listening", work => {
        "use strict";
        console.log(`Cluster ${work.process.pid} connected`);
    });

    // Disconnect
    cluster.on("disconnect", work => {
        "use strict";
        console.log(`Cluster ${work.process.pid} disconnected`);
    });

    // Exit
    cluster.on("exit", worker => {
        "use strict";
        console.log(`Cluster ${worker.process.pid} is dead`);
        cluster.fork();
    });

} else {
    require("./index");
}

在數據傳輸上,咱們使用compression模塊對數據進行了gzip壓縮,這個使用起來比較簡單:

app.use(compression());

最後,讓咱們支持https訪問,https的關鍵就在於證書,使用受權機構的證書是最好的,但該項目中,咱們使用http://www.selfsignedcertificate.com這個網站自動生成了一組證書,而後啓用https的服務:

import https from "https"
import fs from "fs"

module.exports = app => {
    "use strict";
    if (process.env.NODE_ENV !== "test") {

        const credentials = {
            key: fs.readFileSync("44885970_www.localhost.com.key", "utf8"),
            cert: fs.readFileSync("44885970_www.localhost.com.cert", "utf8")
        };

        app.db.sequelize.sync().done(() => {

            https.createServer(credentials, app)
                .listen(app.get("port"), () => {
                console.log(`NTask API - Port ${app.get("port")}`);
            });
        });
    }
};

固然,處於安全考慮,防止攻擊,咱們使用了helmet模塊:

app.use(helmet());

前端程序

爲了更好的演示該API,我把前段的代碼也上傳到了這個倉庫https://github.com/agelessman/ntaskWeb,直接下載後,運行就好了。

API的代碼鏈接https://github.com/agelessman/ntask-api

總結

我以爲這本書寫的很是好,我收穫不少。它雖然並不複雜,可是該有的都有了,所以我能夠自由的往外延伸。同時也學到了做者駕馭代碼的能力。

我以爲我還達不到把所學所會的東西講明白。

有什麼錯誤的地方,還請給予指正。

相關文章
相關標籤/搜索