精华 Nodejs RESTFul架构实践
发布于 9 年前 作者 i5ting 29354 次浏览 最后一次编辑是 8 年前 来自 分享

Nodejs RESTFul架构实践

准备

如果你不了解http协议,先看一下https://github.com/i5ting/node-http

什么是REST?

RESTful架构,就是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便,所以正得到越来越多网站的采用。

以下内容摘自阮一峰的文章:

一、起源

REST这个词,是Roy Thomas Fielding在他2000年的博士论文中提出的。

二、名称

Fielding将他对互联网软件的架构原则,定名为REST,即Representational State Transfer的缩写。我对这个词组的翻译是"表现层状态转化"。 如果一个架构符合REST原则,就称它为RESTful架构。

三、资源(Resources)

REST的名称"表现层状态转化"中,省略了主语。“表现层"其实指的是"资源”(Resources)的"表现层"。

四、表现层(Representation)

“资源"是一种信息实体,它可以有多种外在表现形式。我们把"资源"具体呈现出来的形式,叫做它的"表现层”(Representation)。

五、状态转化(State Transfer)

访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。

六、综述

综合上面的解释,我们总结一下什么是RESTful架构:

(1)每一个URI代表一种资源; (2)客户端和服务器之间,传递这种资源的某种表现层; (3)客户端通过四个HTTP动词,对服务器端资源进行操作,实现"表现层状态转化"。

说的通俗一点,改变url写法,让它带有状态,即语义化

更多详见:

请求方法

一般会严格要求请求方法及其释义,下面给出常用的请求方法

  • 如果请求头中存在 X-HTTP-Method-Override 或参数中存在 _method(拥有更高权重),且值为 GET, POST, PUT, DELETE, PATCH, OPTION, HEAD 之一,则视作相应的请求方式进行处理
  • GET, DELETE, HEAD 方法,参数风格为标准的 GET 风格的参数,如 url?a=1&b=2
  • POST, PUT, PATCH, OPTION 方法
    • 默认情况下请求实体会被视作标准 json 字符串进行处理,当然,依旧推荐设置头信息的 Content-Typeapplication/json
    • 在一些特殊接口中(会在文档中说明),可能允许 Content-Typeapplication/x-www-form-urlencoded 或者 multipart/form-data ,此时请求实体会被视作标准 POST 风格的参数进行处理

关于方法语义的说明:

  • OPTIONS 用于获取资源支持的所有 HTTP 方法
  • HEAD 用于只获取请求某个资源返回的头信息
  • GET 用于从服务器获取某个资源的信息
    • 完成请求后返回状态码 200 OK
    • 完成请求后需要返回被请求的资源详细信息
  • POST 用于创建新资源
    • 创建完成后返回状态码 201 Created
    • 完成请求后需要返回被创建的资源详细信息
  • PUT 用于完整的替换资源或者创建指定身份的资源,比如创建 id 为 123 的某个资源
    • 如果是创建了资源,则返回 201 Created
    • 如果是替换了资源,则返回 200 OK
    • 完成请求后需要返回被修改的资源详细信息
  • PATCH 用于局部更新资源
    • 完成请求后返回状态码 200 OK
    • 完成请求后需要返回被修改的资源详细信息
  • DELETE 用于删除某个资源
    • 完成请求后返回状态码 204 No Content

上面是比较常见的,估计大部分人最常用的是2个,get和post,具体每个怎么玩,下面会给出实例

最常见的增删改查

以前大家都认为管理信息系统就是crud,认为没有啥技术含量,哎,真正能把crud写明白其实也不是一件容易的事儿

七个路由,见app/routes/users.js

其中4个路由是crud

  • GET /users[/] => user.list()
  • POST /users[/] => user.create()
  • PATCH /users/:id => user.update()
  • DELETE /users/:id => user.destroy()

另外3个是页面渲染用的

  • GET /users/new => user.new()
  • GET /users/:id => user.show()
  • GET /users/:id/edit => user.edit()

那么我们先来看一下crud对应的请求方法

  • get用于请求列表
  • post用于创建
  • patch用于更新,局部更新资源
  • delete用于删除

对比上一节的内容,你会发现他们的含义貌似真的对了,唯一可能有争议是更新,有的人用put有的用patch,推荐patch

以前做java的时候会认为,创建、删除和更新都用post,查询和搜索用get,这样做没问题,只是不符合rest风格而已。

很多人和我讨论过到该不该rest,我的回答是

express代码分层

默认的express生成器生成的只有routes和views文件夹,相对比较简单,做大型应用怎么能少了mvc呢?

于是我仿照rails写了如下代码分层

  • routes 路由层,只有url和中间件,不包含任何逻辑
  • controllers 业务逻辑控制层
  • models 模型层
  • views 视图层

如下图

Screen Shot 2015-06-09 at 9.27.49 PM.png

路由层

上代码

var express = require('express');
var router = express.Router();

var $ = require('../controllers/users_controller');


// -- custom


/**
 * Auto generate RESTful url routes.
 *
 * URL routes:
 *
 *  GET    /users[/]        => user.list()
 *  GET    /users/new       => user.new()
 *  GET    /users/:id       => user.show()
 *  GET    /users/:id/edit  => user.edit()
 *  POST   /users[/]        => user.create()
 *  PATCH  /users/:id       => user.update()
 *  DELETE /users/:id       => user.destroy()
 *
 */

router.get('/new', $.new);  
router.get('/:id/edit', $.edit);

router.route('/')
  .get($.list)
  .post($.create);

router.route('/:id')
  .patch($.update)
  .get($.show)
  .delete($.destroy);


module.exports = router;

和普通的路由代码一样,稍微改造了一下,统一了地址,应该是更清晰了。

这里只是引入了users_controller文件,完成请求地址和业务处理代码的映射而已。

这里还要讨论一个问题,每次增加一个接口就要加一个路由会比较烦,而且要在app.js里配置,能不能自动加载呢?比如app/routes目录下的所有js都可以挂载到app上

答案是可以的,使用我写的mount-routes即可,示例如下:

var express = require('express')
var app = express()

var mount = require('mount-routes');

// 简单用法,加载app/routes下的所有路由
// mount(app);

// 带路径的用法,加载routes2目录下的所有路由
// mount(app, 'routes2');

// 带路径的用法并且可以打印出路有表
mount(app, 'routes2', true);

// start server
app.listen(23018)

更多内容见

mount-routes

控制层

/**
 * Created by sang on 01/06/14.
 */

var User = require('../models/user');

首先,控制层是控制业务处理的,所以它和模型层打交道比较多,同时控制视图如何展示

exports.list = function (req, res, next) {
  console.log(req.method + ' /users => list, query: ' + JSON.stringify(req.query));
  User.getAll(function(err, users){
    console.log(users);
    res.render('users/index', {
      users : users
    })
  });
};

请求所有列表,很简单,获取所有用户即可

exports.new = function (req, res, next) {
  console.log(req.method + ' /users/new => new, query: ' + JSON.stringify(req.query));

  res.render('users/new', {
    user : {
      "_action" : "new"
    }
  })
};

新建用户,实际是render视图而已,没有啥逻辑,user参数是为了我生成代码方便用的,无他

exports.show = function (req, res, next) {
  console.log(req.method + ' /users/:id => show, query: ' + JSON.stringify(req.query) + 
    ', params: ' + JSON.stringify(req.params));
  var id = req.params.id;

  User.getById(id, function(err, user) {
    console.log(user);
    res.render('users/show', {
      user : user
    })
  });
};

同new,是render视图代码

exports.edit = function (req, res, next) {
  console.log(req.method + ' /users/:id/edit => edit, query: ' + JSON.stringify(req.query) + 
    ', params: ' + JSON.stringify(req.params));

  var id = req.params.id; 

  User.getById(id, function (err, user) {
    console.log(user);
    user._action = 'edit';

    res.render('users/edit', {
      user : user
    })
  });
};

同new,是render视图代码

exports.create = function (req, res, next) {
  console.log(req.method + ' /users => create, query: ' + JSON.stringify(req.query) + 
    ', params: ' + JSON.stringify(req.params) + ', body: ' + JSON.stringify(req.body));

    User.create({name: req.body.name,password: req.body.password}, function (err, user) {
      console.log(user);
      res.render('users/show', {
        user : user
      })
    });
};

这段是创建用户的代码,根据post参数,保存入库,跳转到展示详情页面

exports.update = function (req, res, next) {
  console.log(req.method + ' /users/:id => update, query: ' + JSON.stringify(req.query) + 
    ', params: ' + JSON.stringify(req.params) + ', body: ' + JSON.stringify(req.body));

    var id = req.params.id; 

    User.updateById(id,{name: req.body.name,password: req.body.password}, function (err, user) {
      console.log(user);

      res.json({
        data:{
          redirect : '/users/' + id
        },
        status:{
          code : 0,
          msg  : 'delete success!'
        }
      });
    });
};

和创建类似,它是根据id来更新内容

exports.destroy = function (req, res, next) {
  console.log(req.method + ' /users/:id => destroy, query: ' + JSON.stringify(req.query) + 
    ', params: ' + JSON.stringify(req.params) + ', body: ' + JSON.stringify(req.body));

  var id = req.params.id;
  User.deleteById(id, function (err) {
    console.log(err);
    res.json({
      data:{},
      status:{
        code : 0,
        msg  : 'delete success!'
      }
    });
  });
};

和创建类似,它是根据id来删除内容

以上代码形式都是一样的

exports.xxxx = function (req, res, next) {
  ...
}

他实际上connect中间件的标准写法,如果你熟悉express,可以非常简单的上手,看了这些代码,你一定很好奇,User模型是怎么工作的,而且mongoose里并没有这些方法啊,下面会详细说明

模型层

我们的模型层使用的是比较传统的mongoose,如果需要promise库,可以搭配bluebird

/**
 * Created by alfred on 01/06/14.
 */

var mongoose    = require('mongoose');
var Schema      = mongoose.Schema;
var MongooseDao = require('mongoosedao');

var userSchema = new Schema(
    {"name":"String","password":"String"}
);

var User = mongoose.model('User', userSchema);
var UserDao = new MongooseDao(User);

module.exports = UserDao;

这实际是最简单的定义mongoose模型的方法,我们唯一做的改进是增加了MongooseDao

DAO是java的概念,是data access object,即数据访问对象,就是传说的crud方法

你只要知道模型就好,为啥每个crud都要写呢?那得多烦啊

var UserDao = new MongooseDao(User);

这样就可以给user增加了基本的crud方法

所以在controller里我们看到了如下代码

  • User.getAll(function(err, users){
  • User.getById(id, function(err, user) {
  • User.create({name: req.body.name,password: req.body.password}, function (err, user) {
  • User.updateById(id,{name: req.body.name,password: req.body.password}, function (err, user) {
  • User.deleteById(id, function (err) {

这5个方法,完美的完成crud的所有操作,是不是很爽?至少少写了很多代码

而且当你想扩展的时候,你可以使用User.model来操作mongoose对象

比如login,我需要在User模型增加is_exist方法

var mongoose    = require('mongoose');
var Schema      = mongoose.Schema;
var MongooseDao = require('mongoosedao');

var userSchema = new Schema(
    {"name":"String","password":"String"}
);

userSchema.methods.is_exist = function(cb) {
  var query;
  query = {
    username: this.username,
    password: this.password
  };
  return this.model('User').findOne(query, cb);
};

var User = mongoose.model('User', userSchema);
var UserDao = new MongooseDao(User);

module.exports = UserDao;

这就是mongoose里的实例方法,static方法也是一样,你可以玩各种花样

然后控制层

exports.login = function (req, res, next) {
  username = req.body.username;
  password = req.body.password;

  user = new User.model({
    username: username,
    password: password
  });

  return user.is_exist(function(err, usr) {
     。。。
  });
}

是不是很简单?

视图层

视图层我们采用express默认的jade,无论各位怎么看,jade都可圈可点

1)extends 方式使用布局 2)include 复用模型代码 3)block 复用块代码

以new.jade和edit.jade为例,它们具有代表性

new.jade

extends ../layouts/layout

block content
  h1 New user

  include user

  a(href='/users') Back

edit.jade

extends ../layouts/layout

block content
  h1 Editing user

  include user

  a(href='/users/#{ user._id}') Show
  span |
  a(href='/users') Back

首先要说明的是include用法,include类似于partial概念,可以包含一个jade作为一部分

jade里有一个约定,你include了谁,它就要把这个对象传进去

所以user.jade里才是我们复用的重点

- var _action = user._action == 'edit' ? '#' : '/users/'
- var _method = user._action == 'edit' ? ""  : "post"
- var _type   = user._action == 'edit' ? "button"  : "submit"
- var onClick  = user._action == 'edit' ?  "click_edit('user-" + user._action + "-form','/users/" + user._id + "/')" : ""
form(id='user-#{ user._action}-form',action="#{_action}", method="#{_method}",role='form')
  each n in ['user.name','user.password']
    - m = eval(n);
    div(class="field")
      label #{n.split('.')[1]} #{m}
      br
      input(type='text',name="#{n.split('.')[1]}" ,value="#{ m == undefined ? '' : m }")
    
  div(class="actions") 
    input(type='#{_type}',value='Submit',onClick='#{onClick}')

这段是为了复用写的代码,可读性不强,但可以说明include用法,对于代码而言,达到了一定的复用

实际上我们自己jade的适合要尽可能的拆分成小块去复用,当需求变得时候更容易应变。提醒强迫症患者,当心物极必反,不要刻意去强求

总结一下

RESTful架构大势所趋,代码写的标准了让人觉得赞

Expressjs下可以写的很规范,可以有做大应用,可以有很好的分层

展示了MVC + Routes的标准rest写法

使用了几个开源代码

  • mount-routes
  • MongooseDao

完成了以上工作,我们还要继续反思一下,既然rest是标准,写法很固定,是不是可以量产呢?答案是可以的,我展示的所有代码是根据一条命令搞定的

moag user name:string password:string

这就是我目前在写的一个开源项目,待可用的时候会公开的,敬请期待

欢迎关注我的公众号【node全栈】

node全栈.png

40 回复

芋头的和我的想法差不多

  • 他的controller层还是有点重,我把c和routes分离的很彻底
  • 他的模型可能偏mysql一些,我的偏mongoose
  • 自动加载路径为路由,思路一样
  • 他没有处理rest和views里的处理,我做了
  • service层他抄的是spring,我暂时没有那么强的需要,如果项目特别复杂,可以考虑引入,多模型操作更合适
  • filter是中间件的内容,我这边之后会有$middlewares
  • util没啥讲的

木有人感冒?

@i5ting 大神论道,需要消化

我感冒! 一直纠结 restify 框架和 express 框架,到底哪个最适合我? 国外有对比两个框架的帖子说restify 框架对前 1000个并发访问有明显延迟。 你看呢?

另外,在Github上有没有比较大型的实例? 包括集成了redis \ mongodb数据库的?

为啥么说“RESTful架构大势所趋”,很多服务其实只需要有效的数据交互即可

ting哥的文章很有质量啊! 赞

不错,总结得很好,学习。

你的架构适合我,我也是mongoose

怒赞。就是太长了。马克了再细读

惨不忍睹,直接用strongloop不就好了吗?

@nonamestreet 哪里惨不忍睹了?求解

如果只是提供data的rest api的话,自动化程度还可以更高. 就像loopback, 基本上只要定义好模型schema, 就可以自动生成data rest api.

@macross2005 这个的最主要目的是提供类似于rails生成的脚手架一样的东西,接口采用的rest风格,而loopback是专门为mobile端的api做的框架

  • 模型操作基本一样,不过loopback支持的db更多而已
  • rest 基本一样

@i5ting 恩, 受教了. 期待大作

mongoose 支持 findById 之类的操作,如果只使用 mongodb 的话,Dao 是不是有点重复?

@booxood 是的,dao里的方法都是用的mongoose里方法,做成静态方法,对于crud更方便,对于模型通用的方法就稍微封了一层

从实践上看,可以减少我的非常多的代码的

今天增加了几个方法

  • one
  • all
  • query

很简单,很好用,哇哈哈

屏幕快照 2015-06-14 13.43.31.png @i5ting 你的结构是Horizontal folder structure 更大的项目里的目录结构用 Vertical folder structure (如图)是否更合适?

@SoaringTiger express项目可以起几个子项目,由根项目挂载的,所以这个问题就可以解决了

先Mark,在学习

关于此图:如果涉及到前端一套系统,后台管理一套系统时,那标准是怎么设计呢?

untitled1.png

@kylezhang 前后端分离,后端只提供api

前端可以参考https://github.com/moajs/moa-frontend

@pangguoming 主要看用的场景。如果你的 web resful 下,前后端 分离很彻底,那就用restify吧

render不一定 要用到,有替代方案的

@orangebook 前段我是用 Android 和IOS做的app,彻底分离

那登陆后和游客之间的权限怎样控制呢?

@ycjcl868 这是session干的事儿。和rest无关

如果接口的参数比较多,/:id/:limit/:lastid,…这样的话如果有不是必须要传的,改怎么处理,正则匹配吗

express.js 没有接触过,但是 RESTAPI 还是深入研究了的

http 中 POST 的界定还是很模糊的,并不是通常所说的创建资源;但PUT 和 PATCH 的定义及用途是比较清晰地。PUT、POST 是 HTTP/1.1规范中的方法。PATCH 是后续增加的。

  • POST: post方法起初是向服务器发送数据。通常 用作表单提交,然后服务器会将其发送到他要去的地方(一般是网关程序,然后由程序处理)*注:摘自《HTTP 权威指南》*但《HTTP 权威指南》中概括的并不全面。

  • PUT: The PUT method requests that the enclosed entity be stored under the supplied Request-URI. If the Request-URI refers to an already existing resource, the enclosed entity SHOULD be considered as a modified version of the one residing on the origin server. If the Request-URI does not point to an existing resource, and that URI is capable of being defined as a new resource by the requesting user agent, the origin server can create the resource with that URI.(与get从服务器读取文档相反,它是向服务器写入文档(摘自《HTTP 权威指南》,个人觉得包括替换和创建))

  • PATCH: Several applications extending the Hypertext Transfer Protocol (HTTP) require a feature to do partial resource modification. The existing HTTP PUT method only allows a complete replacement of a document. This proposal adds a new HTTP method, PATCH, to modify an existing HTTP resource.(分离 PUT 的职责,PUT 创建, PATCH 替换(修改))

  • HTTP/1.1 POST

  • HTTP/1.1 PUT

  • PATCH

@ycjcl868 还可以考虑使用Tocken + redis实现的session设置过期+用户字段添加权限标识常量。当然与rest无关的

@i5ting 死不要脸的at狼叔,现在看来高可靠性的RESTFul架构demo 可以举个栗子么,我想拿来学习下。

回到顶部