MEAN Stack:创建RESTful web service

前段时间做了DTREE项目中的前后端数据存储功能,在原有的ngController上进行HTTP请求,后端接受到请求后再存储到mongoDB上。现将学习所得记录成这篇文章。大致内容为REST的相关概念的介绍,以及结合项目实践的一些实战经验,最后一个RESTful的Web Service就成功开发出来了(大雾)。


  1. REST
    REST(Representational State Transfer)是一种软件设计架构风格,它定义了一堆概念,这些抽象的概念和相关规则能有效地降低业务复杂度。而日常上网使用HTTP就是REST风格最好的践行,RESTful Web服务就是将HTTP当做应用层的协议来遵守使用,而不像其他(比如SOA)将HTTP当做传输层的工具,然后在HTTP之上建立自己的一套应用层协议。

    当我们在浏览器地址栏键入的URL/URI就是REST提供的资源(resource)定义,而资源在REST中是一个的概念(concepts),当浏览器发出请求时,它想要得到的是一个概念的特定表示(representation),我们常见的网页就是资源的具体表示。REST这一些定义的概念、原则为了就是最小化服务与使用服务的应用程序之间的耦合。我们设计RESTful Web服务的原则也只是遵守了REST的部分原则。图片来自"浅谈REST",我们可以在REST Triangle图中看出,URL就是REST所谓的名词(资源的位置),一些HTTP方法就是动词,JSON、XML数据格式充当资源的具体表示。

    alt

    下面简述4个RESTful Web Service基本原则,摘自IBM的"基于REST的 Web 服务基础"

    显式地使用 HTTP 方法

    设计RESTful时,使用4种比较多态的方法(POST、GET、PUT、DELETE)实现数据存储的CRUD操作。HTTP协议已经对这些动词(POST、GET、PUT 和 DELETE)进行了定义。

    • HTTP GET方法用于获取资源,无论调用的次数都不会改变资源的状态,可能会得到不同的结果,但它本身不产生副作用(即对存储在source服务器的数据不产生的修改)。
    • HTTP DELETE方法用于删除资源虽然有副作用,但多次调用时产生的副作用应该是相同的,即URI所对应资源的删除。
    • HTTP POST方法用于接受创建的资源,如果资源已经在服务器上创建,服务器响应应该是表示Created的201状态以及资源的URI,避免POST请求会在服务器创建多份相同的资源。
    • HTTP PUT方法用于创建或更新资源,多次操作的副作用与一个PUT操作是相同的。即若服务器没有PUT的资源则创建一个,否则更新已有资源即可。

    这些动作的结果是必须有预期,不应该出现语义问题,很典型的例子就是在应用中只使用GET充当一切前后端交互的方法,导致的结果就是一些Web爬虫的行为无意间导致服务器端的资源更改。

    无状态

    网络传输的无状态使得每个请求是独立完整的,这样能够将请求从一个服务器路由到另一个服务器而无状态上的协调。当然请求是有状态的,将大部分状态维护职责转移给客户端应用程序,能够节省带宽和最小化服务器端应用程序状态改进了性能。

    公开目录结构式的URI

    URI是具有在节点上连接在一起的下级和上级分支的树,这种树形结构能够直观地表达交互类型和资源名称,也可将URI看做文档说明的接口。

    传输格式使用XML、JavaScript Object Notation(JSON)等数据格式

    这些数据格式能够使得服务可以运行在不同平台和设备上,并采用不同的语言编写。

  2. ngResource
    一直以来在前端进行向后端数据交互都是使用$http服务,这次师哥教育到要学习使用ngResource,提倡"keeping $http out of the controllers and leave that job to services"。使用ngResource就不用去关心更底层的$http服务,它帮我们封装好用一种更简单的方式来发送XHR请求。

    ngResource提供$resource服务(service)来与RESTful后端交互。首先我们需要将angular-resource.js引入,然后建立工厂方法,在里面返回$resource('URI',,params, methods)获得的值,这样一个自定义的服务就产生了。使得用短短的几行代码就可以创建一个RESTful客户端,简化controllers。

  3. Code

    首先我们设计Node接受前端发来的请求时的路由分配,暂时只提供了下列针对单个dtree对象的CRUD操作。

    //store API
    app.post('/dtree', dtreeCtrl.createDTree);//create
    app.get('/dtree/:dtree_id', dtreeCtrl.readDTree);//read
    app.put('/dtree/:dtree_id', dtreeCtrl.updateDTree);//update
    app.delete('/dtree/:dtree_id', dtreeCtrl.deleteDTree); // delete
    

    我们在dtreeCtrl暴露出处理请求API,对于POST操作处理如下。

    //execute route POST /dtree
    exports.createDTree = function(req, res){
        //存储到mongoDB前的预处理
        //...
        Dtree.create(frontData, function(err, dtree, numAffected){
            if(err){
                console.log('Error: createDTree: DB failed to create due to ', err);
                res.send({'success':false,'err':err});
            }else{
                console.log('Info: createDTree: DB created successfully dtree = ', dtree);
                res.send({'success':true});
            }
        });
    };
    

    对于GET操作,我们使用dtree_id当做_id进行搜索,调用mongoose API .lean() 将结果转换为plain javascript objects。

    //execute route GET /dtree/:dtree_id
    exports.readDTree = function (req, res) {
        var checkId = new ObjectId(req.params.dtree_id);
        //存储到mongoDB前的预处理
        //..
        Dtree.findById(checkId).lean().exec(function (err, dtree) {
            if(err){
                console.log('Error: readDTree: DB failed to findById due to ', err);
                res.send({'success':false, 'err': err});
            }else{
                console.log('Info: readDTree: DB findById successfully dtree = ', dtree);
                //发送到客户端的预处理
                //...
                res.send({'success':true, dtree: dtree});
            }
        });
    };
    

    对于PUT操作,以前我写过v为versionKey,可以充当数据库文档的版本控制flag,但mongoose的.update()方法有些许bug,执行操作后并没有修改v的值,暂时的解决方法是通过$inc: {key: value}来手动设置。

    //execute route PUT /dtree/:dtree_id
    exports.updateDTree = function (req, res) {
        //存储到mongoDB前的预处理
        //..
        Dtree.update({"_id": id},  {
             modifiedKey: modifiedData,
             $inc: {__v: 1}
        }, function(err){
            if(err){
                console.log('Error: updateDTree: DB failed to update due to ', err);
                res.send({'success':false, 'err':err});
            }else{
                console.log('Info: updateDTree: DB updated successully dtree');
                res.send({'success':true});
            }
        });
    };  
    

    DELETE操作相对就比较简单了。

    //execute route DELETE /dtree/:dtree_id
    exports.deleteDTree = function(req, res){
        Dtree.findByIdAndRemove(req.params.dtree_id, function(err, dropDtree){
            if(err){
                console.log('Error: deleteDTree: DB failed to delete due to ', err);
                res.send({'success': false, 'err': err});
            }else{
                console.log('Info: deleteDTree: DB deleted successfully dtree = ', dropDtree);
                res.send({'success': true});
            }
        });
    }
    

    至此,后端已经建立以一套RESTful的API提供给客户端HTTP请求调用。我们首先需要使用前面提到的$resource服务定义决策树CRUD操作的服务dtreeCrudService,这里URI使用的是cors的写法,其实没有必要,单独使用/dtree/:dtree_id也可,这就是访问本机的资源。CORS(Cross Origin Resource Sharing)是与SOP(Same Origin Policy,指的是在客户端上只能访问服务器自身域的文档或脚本,不能获取或修改另一个域的文档的属性)相对,它支持跨域请求。NodeJS通过cors依赖包的调用开启CORS。如果浏览器端检测到相应的设置,就可以允许XHR进行跨域的访问。

    $resource('http://localhost\\:4000/dtree/:dtree_id', {},{
            'get': {
                method:'GET'
            },
            update: {
                method: 'PUT' //a PUT request
            },
            'delete': {
                method:'DELETE'
            }
    });
    

    这里定义了HTTP操作的方法,在controller中正确"注入"服务即可调用服务的方法。

    app.controller('createDTreeCtrl', [
        '$scope',
        'dtreeCrudService'
        function (
            $scope,
            dtreeCrudService
        ) {
        //...具体实现
    ]);
    

    这里通过定义CRUD的方法来实现业务逻辑的划分。

    //将决策树数据传到后端存入数据库
    $scope.createDtreeData = function(){
        //前端处理后数据
        var data = someOperate(data);
        console.log('Info: createDtreeData: data = ', data);
        dtreeCrudService.save(data, function(res){
            if(res.success){
                console.log('Info: createDtreeData: Back-end successfully saved dtree = ', data);
            }else{
                console.log('Error: createDtreeData: Back-end failed to save dtree due to ', res.err);
            }
        }, function(error){
            console.log("Error: createDtreeData: Fail to create due to ", error);
        });
    }
    //读取数据
    $scope.readDtreeData = function (){
        dtreeCrudService.get({dtree_id: '54a4c0947752adcc1764f0d4'}, function(res) {
            if(res.success){
                console.log("Info: readDtreeData: Back-end successfully read dtree = ", res.dtree);
            }else{
                console.log('Error: readDtreeData: Back-end failed to read dtree due to ', res.err);
            }
        }, function(error){
            console.log("Error: readDtreeData: Fail to read due to ", error);
        });
    }
    //更新数据
        $scope.updateDtreeData = function () {
            //数据规整成json对象。
            var data = someChange(data);
            console.log('Info: updateDtreeData: data = ', data);
            dtreeCrudService.update({dtree_id: data._id},data, function(res){
                if(res.success){
                    console.log("Info: updateDtreeData: Back-end successfully update dtree = ", data);
                }else{
                    console.log('Error: updateDtreeData: Back-end failed to read dtree due to ', res.err);
                }
            }, function(error){
                console.log("Error: updateDtreeData: Fail to update due to ", error);
            });
        }
    //删除某个ID的决策树
    $scope.deleteDtreeData = function () {
        dtreeCrudService.delete({dtree_id: $scope.operate_dtreeId}, function(res){
            if(res.success){
                console.log("Info: deleteDtreeData: Back-end successfully delete dtree");
            }else{
                console.log('Error: updateDtreeData: Back-end failed to read dtree due to ', res.err);
            }
        }, function(error){
            console.log("Error: deleteDtreeData: Fail to delete due to ", error);
        });
    }
    

    这4个方法对应的请求如图,使用ng-click指令即可调用这些方法。

    ngResource的CRUD方法的使用就是这么的方便,以后修改也特别方便。

    $resource(url, [paramDefaults], [actions], options);

    再提及一下这个$resource方法中的pramas参数的作用,当这个参数不为空时就会在运行HTTP请求方法被覆盖掉。举个例子对于 $resource("/dtree/:id", {id: @dtreeid}, methods) 这一段资源服务,我们POST的数据为{"dtreeid": 2333, "dtreeData": YoYo }时,这个URL就会成为 /dtree/2333。而当没有这个@符号时,URL就是直接为的 /dtree/dtree_id。

    还有一点就是我们前后端传输的数据采用的是JSON格式,而JSON是不支持循环结构,即 a.b = c; c.d = b; 这种数据结构,但在前端使用D3画图过程中,有所需要保持这种结构才能实时更新界面上的图片。若强行使用$resource传递,Javascript会先将数据转换为JSON对象,然后就报TypeError的错误。

    这时我们不用慌张,直接遍历决策树数据将循环结构干掉就可以了。


总结

这次功能模块的实现分为前后端,后端的Node提供RESTful的资源获取API,AngularJS在前端使用$resource进行HTT请求的封装来获取资源。这一套HTTP + CRUD Method + URL只是REST部分概念的实现,REST的真正价值在于低耦合的设计理念。

References:

REST相关

  1. 浅谈REST
  2. 基于REST的 Web 服务基础
  3. HTTP幂等性概念和应用

MEAN

  1. simple-device-management-app
  2. tv-traker
  3. update操作无法增加"__v"
  4. 貌似需翻墙的$resource文档