Node.js 实现全面的 RESTful 服务详解

到目前为止,我们已经创建了 RESTful 服务的第二个版本,这两个版本通过不同的 URL 公开,确保了向后兼容性。我们为其数据库层实现了单元测试,并讨论了如何适当地使用 HTTP 状态代码。在本章中,我们将通过向服务的第二个版本提供非文档二进制数据的处理并将其相应地链接到与之相关的文档来扩展该实现。

我们将研究一种向消费者展示大型结果集的便捷方法。为此,我们将在 API 中引入分页以及进一步的过滤功能。

在某些情况下,应将缓存数据响应视为一种选项。我们将研究它的优点和缺点,并在必要时决定启用缓存。

最后,我们将深入研究 REST 服务的发现和探索。

总而言之,为了将目录数据服务转变为一个成熟的 RESTful 服务,应该进一步实现以下内容:

MongoDB 使用 BSON(二进制 JSON)作为主要数据格式。它是一种二进制格式,将键/值对存储在一个名为文档的实体中。例如,样本 JSON{"hello":"world"}在 BSON 中编码时变为\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00

BSON 存储数据而不是文字。例如,如果图像是文档的一部分,则不必将其转换为 base64 编码字符串;相反,它将直接存储为二进制数据,与普通 JSON 不同,普通 JSON 通常将这些数据表示为 base64 编码的字节,但这显然不是最有效的方法。

Mongoose 模式允许通过模式类型-缓冲区以 BSON 格式存储二进制内容。它可以存储高达 16MB 的二进制内容(图像、ZIP 存档等)。相对较小的存储容量背后的原因是为了防止在传输过程中过度使用内存和带宽。

GridFS规范解决了 BSON 的这一限制,使您能够处理大于 16MB 的数据。GridFS 将数据分成块,存储为单独的文档条目。默认情况下,每个块的大小最大为 255 KB。当从数据存储中请求数据时,GridFS 驱动程序检索所有所需的块并按组合顺序返回它们,就好像它们从未被分割一样。这种机制不仅允许存储大于 16MB 的数据,还允许使用者按部分检索数据,这样就不必将数据完全加载到内存中。因此,该规范隐式地支持流式传输。

GridFS 实际上提供了更多功能,它支持存储给定二进制数据的元数据,例如,其格式、文件名、大小等。元数据存储在单独的文件中,可用于更复杂的查询。有一个非常有用的 Node.js 模块,名为gridfs-stream。它可以方便地将数据流输入和输出 MongoDB,就像在所有其他模块上一样,它是作为一个npm包安装的。所以,让我们在全球范围内安装它,看看它是如何使用的;我们还将使用-s选项来确保更新项目package.json中的依赖项:

    npm install -g -s gridfs-stream

要创建Grid实例,需要打开与数据库的连接:

const mongoose = require('mongoose')
const Grid = require('gridfs-stream');

mongoose.connect('mongodb://localhost/catalog');
var connection = mongoose.connection;
var gfs = Grid(connection.db, mongoose.mongo);   

通过createReadStream()createWriteStream()函数读取和写入流。流入数据库的每一条数据都必须有一个ObjectId属性集。ObjectId唯一地标识二进制数据条目,就像它标识 MongoDB 中的任何其他文档一样;使用此ObjectId,我们可以通过此标识符从 MongoDB 集合中查找或删除它。

让我们使用用于获取、添加和删除分配给项目的图像的函数来扩展 catalog 服务。为简单起见,该服务将支持每个项目一个图像,因此将有一个函数负责添加图像。它将在每次调用时覆盖现有图像,因此它的适当名称为saveImage

exports.saveImage = function(gfs, request, response) {

    var writeStream = gfs.createWriteStream({
            filename : request.params.itemId,
            mode : 'w'
        });

        writeStream.on('error', function(error) {
            response.send('500', 'Internal Server Error');
            console.log(error);
            return;
        })

        writeStream.on('close', function() {
            readImage(gfs, request, response);
        });

    request.pipe(writeStream);
}

如您所见,在 MongoDB 中刷新数据所需要做的就是创建一个 GridFS 写流实例。它需要一些选项来提供 MongoDB 条目的ObjectId和一些额外的元数据,例如标题和书写模式。然后,我们只需调用请求的管道函数。管道将导致将数据从请求刷新到写入流,这样,数据将安全地存储在 MongoDB 中。一旦存储,与writeStream关联的close事件将发生,此时我们的函数将读回它存储在数据库中的任何内容,并在 HTTP 响应中返回该图像。

**检索图像的方法与此相反—创建一个带有选项的readStream,并且_id参数的值应该是任意数据的ObjectId、可选文件名和读取模式:

function readImage(gfs, request, response) {

  var imageStream = gfs.createReadStream({
      filename : request.params.itemId,
      mode : 'r'
  });

  imageStream.on('error', function(error) {
    console.log(error);
    response.send('404', 'Not found');
    return;
  });

  response.setHeader('Content-Type', '/github/nodejs/rest/img/jpeg');
  imageStream.pipe(response);
}

在将读取流输送到响应之前,必须设置适当的Content-Type头,以便在我们的情况下,可以使用适当的图像媒体类型/github/nodejs/rest/img/jpeg将任意数据呈现给客户端。

最后,我们从模块中导出一个函数,用于从 MongoDB 取回图像。我们将使用该函数将其绑定到从数据库读取图像的快速路径:

exports.getImage = function(gfs, itemId, response) {
     readImage(gfs, itemId, response);
};

从 MongoDB 中删除任意数据也很简单。您必须从保存所有文件的两个内部 MongoDB 集合fs.filesfs.files.chunks中删除条目:

exports.deleteImage = function(gfs, mongodb, itemId, response) {
  console.log('Deleting image for itemId:' + itemId);

    var options = {
            filename : itemId,
    };

    var chunks = mongodb.collection('fs.files.chunks');
    chunks.remove(options, function (error, image) {
        if (error) {
            console.log(error);
            response.send('500', 'Internal Server Error');
            return;
       } else {
           console.log('Successfully deleted image for item: ' + itemId);
       }
    });

    var files = mongodb.collection('fs.files');
    files.remove(options, function (error, image) {
        if (error) {
            console.log(error);
            response.send('500', 'Internal Server Error');
            return;
        }

        if (image === null) {
            response.send('404', 'Not found');
            return;
        } else {
           console.log('Successfully deleted image for primary item: ' + itemId);
           response.json({'deleted': true});
        }
    });
}

让我们将新功能绑定到适当的项目路由并测试它:

router.get('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.getImage(gfs, request, response);
});

router.get('/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.getImage(gfs, request, response);
});

router.post('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage(gfs, request, response);
});

router.post('/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage(gfs, request.params.itemId, response);
});

router.put('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage (gfs, request.params.itemId, response);
});

router.put('/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.saveImage(gfs, request.params.itemId, response);
});

router.delete('/v2/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.deleteImage(gfs, model.connection,
  request.params.itemId, response);
});

router.delete('/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.deleteImage(gfs, model.connection,  request.params.itemId, response);
});

Since, at the time of writing, Version 2 is the latest version of our API, any new functionality exposed by it should be available at both locations: /catalog and /v2/catalog.

让我们启动 Postman 并将图像发布到现有项目,假设我们有一个 ID 为 14/catalog/v2/item/14/image的项目:

Post request assigning an image to an item using Postman. This is a screenshot for Postman. The individual settings are not important here. The purpose of the image is just to show how the window looks.

处理请求后,二进制数据存储在网格数据存储中,图像在响应中返回。

在上一章的“链接数据”部分中,我们定义了如果目录中的某个项目有一个分配给它的图像,那么这将由一个名为 image URL 的 HTTP 头指示。

让我们修改目录 V2 中的findItemById函数。我们将使用 GridFS 的现有函数来检查是否有图像绑定到所选项目;如果有分配给项目的图像,则其 URL 将可用于带有图像 URL 标题的响应:

exports.findItemById = function (gfs, request, response) {
    CatalogItem.findOne({itemId: request.params.itemId}, function(error, result) {
        if (error) {
            console.error(error);
            response.writeHead(500,    contentTypePlainText);
            return;
        } else {
            if (!result) {
                if (response != null) {
                    response.writeHead(404, contentTypePlainText);
                    response.end('Not Found');
                }
                return;
            }

            var options = {
                filename : result.itemId,
            };
            gfs.exist(options, function(error, found) {
                if (found) {
                    response.setHeader('Content-Type', 'application/json');
                    var imageUrl = request.protocol + '://' + request.get('host') + request.baseUrl + request.path + '/image';
                    response.setHeader('Image-Url', imageUrl);
                    response.send(result);
                } else {
                    response.json(result);
                }
            });
        }
    });
}

到目前为止,我们将一个项目链接到它的图像;然而,这使得我们的数据部分链接,因为从一个项目到它的图像有一个链接,而不是相反。让我们对此进行更改,并通过修改readImage函数为图像响应提供标题项 Url:

function readImage(gfs, request, response) {

  var imageStream = gfs.createReadStream({
      filename : request.params.itemId,
      mode : 'r'
  });

  imageStream.on('error', function(error) {
    console.log(error);
    response.send('404', 'Not found');
    return;
  });

  var itemImageUrl = request.protocol + '://' + request.get('host') + request.baseUrl+ request.path;
  var itemUrl = itemImageUrl.substring(0, itemImageUrl.indexOf('/image'));
  response.setHeader('Content-Type', '/github/nodejs/rest/img/jpeg');
  response.setHeader('Item-Url', itemUrl);

  imageStream.pipe(response);
}

现在在http://localhost:3000/catalog/v2/item/3/请求项目将返回 JSON 格式编码的项目:

GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1 
Accept-Encoding: gzip,deflate 
Host: localhost:3000 

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: application/json; charset=utf-8 
Image-Url: http://localhost:3000/catalog/v2/item/3/image 
Content-Length: 137 
Date: Tue, 03 Apr 2018 19:47:41 GMT 
Connection: keep-alive 

{
   "_id": "5ab827f65d61450e40d7d984",
   "itemId": "3",
   "itemName": "Sports Watch 11",
   "price": 99,
   "currency": "USD",
   "__v": 0,
   "categories": ["Watches"]
}

查看响应头,我们发现Image-Url头的值为http://localhost:3000/catalog/v2/item/3/image提供链接到该项的图像的 URL。

请求该图像会导致以下结果:

GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1 
Host: localhost:3000 
Connection: Keep-Alive 

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: /github/nodejs/rest/img/jpeg 
Item-Url: http://localhost:3000/catalog/v2/item/3 
Connection: keep-alive 
Transfer-Encoding: chunked 

<BINARY DATA>

这一次,响应提供链接到项目的图像的有效负载和一个特殊的标题项目 Url。其值-http://localhost:3000/catalog/v2/item/3-是项目资源可用的地址。现在,如果项目图像出现,例如,在图像搜索结果中,与图像链接的项目的 URL 也将是结果的一部分。通过这种方式,我们在语义上链接了这两个数据,而没有修改或损害它们的有效负载。

一旦部署到 web 上,每项服务都可以供大量消费者使用。他们不仅将使用它来获取数据,还将插入新数据。在某些时候,这将不可避免地导致数据库中存在大量可用数据。为了保持服务的用户友好性并保持合理的响应时间,我们需要注意以合理的部分提供大数据,确保在请求/catalogURI 时不需要返回几十万项。

Web 数据使用者习惯于具有各种分页和过滤功能。在本章前面,我们实现了findIfindItemsByAttribute()函数,该函数可以根据项目的任何属性进行过滤。现在,是引入分页功能的时候了,可以借助 URI 参数在resultset中进行导航。

mongoose.js型号可以利用不同的插件模块在其上提供额外的功能。这样的插件模块是mongoose-paginate。Express 框架还提供了一个名为express-paginate的分页中间件。它为 Mongoose 的结果页面提供现成的链接和导航:

  1. 在开始开发分页机制之前,我们应该安装以下两个有用的模块:
npm install -g -s express-paginate
npm install -g -s mongoose-paginate
  1. 下一步是在我们的应用中创建express-paginate中间件的实例:

expressPaginate = require('express-paginate'); 
  1. 通过调用其middleware()函数,初始化应用中的分页中间件。其参数指定每页结果的默认限制和最大限制:
app.use(expressPaginate.middleware(limit, maxLimit); 
  1. 然后,在创建模型之前,提供mongoose-pagination实例作为CatalogItem模式的插件。以下是item.js模块将如何将其与模型一起导出:
var mongoose = require('mongoose');
var mongoosePaginate = require('mongoose-paginate');
var Schema = mongoose.Schema;

mongoose.connect('mongodb://localhost/catalog');

var itemSchema = new Schema ({
    "itemId" : {type: String, index: {unique: true}},
    "itemName": String,
    "price": Number,
    "currency" : String,
    "categories": [String]
});
console.log('paginate');
itemSchema.plugin(mongoosePaginate);
var CatalogItem = mongoose.model('Item', itemSchema);

module.exports = {CatalogItem : CatalogItem, connection : mongoose.connection};
  1. 最后,调用模型的paginate()函数,以分页方式获取请求的条目:

CatalogItem.paginate({}, {page:request.query.page, limit:request.query.limit},
    function (error, result){
        if(error) {
            console.log(error);
            response.writeHead('500',
               {'Content-Type' : 'text/plain'});
            response.end('Internal Server Error');
         } else {
           response.json(result);
         }
});

第一个参数是 Mongoose 应用于其查询的过滤器。第二个参数是一个对象,指定请求的页面和每页的条目。第三个参数是回调处理程序函数,通过其参数提供结果和任何可用的错误信息:

  • error:指定查询是否成功执行
  • result:这是从数据库中检索到的数据

express-paginate中间件通过丰富 Express handler 函数的requestresponse对象,实现了mongoose-paginate模块在 web 环境中的无缝集成。

request对象获得两个新属性:query.limit告诉中间件页面上的条目数;query.page指定请求的页面。请注意,中间件将忽略大于中间件初始化时指定的maxLimit值的query.limit值。这可以防止使用者覆盖最大限制,并让您完全控制应用。

下面是目录模块第二版中paginate功能的实现:

exports.paginate = function(model, request, response) {
    var pageSize = request.query.limit;
    var page = request.query.page;
    if (pageSize === undefined) {
        pageSize = 100;
    }
    if (page === undefined) {
        page = 1;
    }

    model.paginate({}, {page:page, limit:pageSize},
            function (error, result){
                if(error) {
                    console.log(error);
                    response.writeHead('500',
                        {'Content-Type' : 'text/plain'});
                    response.end('Internal Server Error');
                }
                else {
                    response.json(result);
                }
            });
}

以下是查询包含 11 项的数据集(每页限制 5 项)的响应:

{
  "docs": [
    {
      "_id": "5a4c004b0eed73835833cc9a",
      "itemId": "1",
      "itemName": "Sports Watch 1",
      "price": 100,
      "currency": "EUR",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a4c0b7aad0ebbce584593ee",
      "itemId": "2",
      "itemName": "Sports Watch 2",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64d7ecfa1b585142008017",
      "itemId": "3",
      "itemName": "Sports Watch 3",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64d9a59f4dc4e34329b80f",
      "itemId": "8",
      "itemName": "Sports Watch 4",
      "price": 100,
      "currency": "EUR",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64da377d25d96e44c9c273",
      "itemId": "9",
      "itemName": "Sports Watch 5",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    }
  ],
  "total": 11,
  "limit": "5",
  "page": "1",
  "pages": 3
}

docs属性包含作为结果一部分的所有项目。其大小与选定的限制值相同。pages属性提供总页数;在这里的示例中,其值为 3,因为在三个页面中容纳 11 个项目,每个页面包含五个项目。Total属性为我们提供项目总数。

启用分页的最后一步是修改/v2/路由以开始使用新创建的函数:

  router.get('/v2/', function(request, response) {
    var getParams = url.parse(request.url, true).query;
    if (getParams['page'] !=null) {
      catalogV2.paginate(model.CatalogItem, request, response);
    } else {
      var key = Object.keys(getParams)[0];
      var value = getParams[key];
      catalogV2.findItemsByAttribute(key, value, response);
    }
});

我们将使用 HTTP302 Found状态作为默认路由/catalog。这样,所有传入请求都将重定向到/v2/

router.get('/', function(request, response) {
  console.log('Redirecting to v2');
  response.writeHead(302, {'Location' : '/catalog/v2/'});
  response.end('Version 2 is is available at /catalog/v2/: ');
});

在这里使用适当的状态代码进行重定向对于任何 RESTful web 服务的生命周期都是至关重要的。返回302 Found,然后重定向,可确保 API 使用者始终在该位置提供其最新版本。此外,从开发的角度来看,在这里使用重定向而不是代码复制也是一种很好的做法。

当你在两个版本之间时,你应该总是考虑使用 HTTP MULT T0 状态来显示先前版本的移动位置和 HTTP MULT T1 状态,以显示当前版本的实际 URI。

现在,回到分页,由于请求的页面和限制编号是作为GET参数提供的,我们不想将其与过滤功能混淆,因此对它们进行了显式检查。仅当页面或限制GET参数在请求中可用时,才会使用分页。否则,将进行搜索。

最初,我们将最大限制设置为 100 个结果,默认限制设置为 10 个,因此,在尝试新的分页功能之前,请确保在数据库中插入的项目多于默认限制。这将使测试结果更加明显。

现在,让我们试一试。请求/catalog?limit=3将导致返回仅包含两项的列表,如下所示:

Pagination enabled results. This is a screenshot for Postman. The individual settings are not important here. The purpose of the image is just to show how the window looks.

从示例中可以看到,总页数为 4 页。项目总数存储在数据库 11 中。由于我们没有在请求中指定页面参数,分页隐式返回第一个页面。要导航到下一页,只需在 URI 中添加&page=2

另外,尝试更改limit属性,请求/catalog/v2?limit=4。这将返回前四项,响应将显示总页数为三页。

当我们讨论 Roy Fielding 定义的 REST 原则时,我们提到缓存是一个相当敏感的话题。最后,我们的消费者在执行查询时会期望得到最新的结果。然而,从统计的角度来看,网络上公开的数据更容易被读取,而不是更新或删除。

因此,考虑到将部分负载从服务器转移到缓存,公共 URL 公开的某些资源成为数百万请求的主题是合理的。HTTP 协议允许我们在给定的时间段内缓存一些响应。例如,当在短时间内收到多个请求时,查询给定组的目录中的所有项目,例如/catalog/v2,我们的服务可以利用特殊的 HTTP 头,强制 HTTP 服务器在定义的时间段内缓存响应。这将防止对底层数据库服务器的冗余请求。

HTTP 服务器级别的缓存是通过特殊的响应头实现的。HTTP 服务器使用Cache-Control头指定给定响应的缓存时间。缓存需要失效前的时间段通过其max-age属性设置,其值以秒为单位提供。当然,有一个很好的 Node.js 模块,它提供了一个用于缓存的中间件功能,称为express-cache-control

让我们用 NPM 包管理器安装它;再次,我们将全局安装它,并使用-s选项,该选项将自动用新的express-cache-control依赖项更新package.json文件:

    npm install -g -s express-cache-control

使用express-cache-control中间件启用缓存需要三个简单的步骤:

  1. 获取模块:
      CacheControl = require("express-cache-control") 
  1. 创建CacheControl中间件的实例:
 var cache = new CacheControl().middleware;
  1. 要将中间件实例绑定到要启用缓存的路由,请执行以下操作:
router.get('/v2/', cache('minutes', 1), function(request, response) {
    var getParams = url.parse(request.url, true).query;
    if (getParams['page'] !=null || getParams['limit'] != null) {
      catalogV2.paginate(model.CatalogItem, request, response);
    } else {
      var key = Object.keys(getParams)[0];
      var value = getParams[key];
      catalogV2.findItemsByAttribute(key, value, response);
    }
});

Usually, common URIs that provide many result entries should be the subject of caching, rather than URIs providing data for a concrete entry. In our application, only the /catalog URI will make use of caching. The max-age attribute must be selected according to the load of your application to minimize inaccurate responses.

让我们通过在邮递员中请求/catalog/v2来测试我们的更改:

Cache-control header indicating that caching is enabled. This is a screenshot for Postman. The individual settings are not important here. The purpose of the image is just to show how the window looks.

正如所料,express-cache-control中间件已经完成了它的工作,Cache-Control头现在包含在响应中。must-revalidate选项确保在max-age间隔到期后缓存内容失效。现在,如果您对特定项目发出另一个请求,您将看到响应没有使用express-cache-control中间件,这是因为它需要在每个单独的路由中显式提供。它不会用于相互派生的 URI 中。

针对任何路由/v1/GET请求的响应将不包含Cache-Control头,因为它仅在我们的 API 版本 2 中受支持,Cache-Control中间件仅在主目录路由/catalog/v2//catalog中使用。

祝贺在本章中,您成功地将一个支持 REST 的端点示例转换为一个成熟的 RESTful web 服务,该服务支持可用性过滤和方便导航的分页。该服务同时提供任意数据和 JSON 数据,并为高负载场景做好准备,因为它在其关键部分启用缓存。有一件事应该引起您的注意,就是在任何公共 API 的新版本和过时版本之间进行重定向时,HTTP 状态码的正确使用。

实现适当的 HTTP 状态对于 REST 应用来说非常重要,因此我们使用了非常奇特的状态,例如301 Moved Permanently302 Found。在下一章中,我们将在应用中引入授权的概念。**

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

软件工程之美 -〔宝玉〕

Elasticsearch核心技术与实战 -〔阮一鸣〕

分布式技术原理与算法解析 -〔聂鹏程〕

MongoDB高手课 -〔唐建法(TJ)〕

微信小程序全栈开发实战 -〔李艺〕

手把手教你玩音乐 -〔邓柯〕

结构会议力 -〔李忠秋〕

程序员职业规划手册 -〔雪梅〕

互联网人的数字化企业生存指南 -〔沈欣〕