实现一个完整的Node.js RESTful API的示例
前言
这篇文章算是对building apis with node.js这本书的一个总结。用node.js写接口对我来说是很有用的,比如在项目初始阶段,可以快速的模拟网络请求。正因为它用js写的,跟ios直接的联系也比其他语言写的后台更加接近。
这本书写的极好,作者编码的思路极其清晰,整本书虽说是用英文写的,但很容易读懂。同时,它完整的构建了restful api的一整套逻辑。
我更加喜欢写一些函数响应式的程序,把函数当做数据或参数进行传递对我有着莫大的吸引力。
从程序的搭建,到设计错误捕获机制,再到程序的测试任务,这是一个完整的过程。这边文章将会很长,我会把每个核心概念的代码都黏贴上来。
环境搭建
下载并安装node.js
安装npm
下载演示项目
git clone https://github.com/agelessman/ntask-api
进入项目文件夹后运行
npm install
上边命令会下载项目所需的插件,然后启动项目
npm start
访问接口文档 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....
模型设计
在我看来,在开始做任何项目前,需求分析是最重要的,经过需求分析后,我们会有一个关于代码设计的大的概念。
编码的实质是什么?我认为就是数据的存储和传递,同时还需要考虑性能和安全的问题
因此我们第二部的任务就是设计数据模型,同时可以反应出我们需求分析的成果。在该项目中有两个模型, user 和 task ,每一个 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); };
hasmany 和 belongsto 表示一种关联属性, 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是一个对象,他存储了所有的模型,在这里是 user 和 task 。通过 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); } }; };
这就用到了 passport 和 passport-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的关键就在于证书,使用授权机构的证书是最好的,但该项目中,我们使用这个网站自动生成了一组证书,然后启用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的代码连接
总结
我觉得这本书写的非常好,我收获很多。它虽然并不复杂,但是该有的都有了,因此我可以*的往外延伸。同时也学到了作者驾驭代码的能力。
我觉得我还达不到把所学所会的东西讲明白。有什么错误的地方,还请给予指正。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。