使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API

    MongoDB Atlas 是一个在云端的数据库,免去了数据库的搭建、维护,通过其提供的 Web UI 能够让你在 5 分钟之内快速搭建一个 Clusters。Node.js 是一个 JavaScript 的运行时,在 JavaScript 中函数做为一等公民,享有着很高的待遇,通常使用 Node.js 我们可以快速的搭建一个服务,而 ServerLess 是一种 “无服务器架构”,从技术角度来讲是 FaaS 和 BaaS 的结合,FaaS(Function as a Service)是一些运行函数的平台。

    那么通过这些可以做些什么呢?本篇文章中我们将使用 ServerLess、MongoDB Atlas cloud 与 Node.js 的结合来快速构建一个 REST API,无论你是前端工程师还是后端工程师,只要你掌握一些 JavaScript 基础语法就可以完成。

    让我们先解决这一疑问, MongoDB Atlas cloud 是一个运行在云端的数据库,无需安装、配置,也无需在我们的机器上安装 Mongo 服务,只需要一个 URL 即可访问数据库,还提供了非常酷的 UI 界面,易于使用。最重要的一点对于我们初学者来说它提供了免费使用,最大限制为 512 MB,这对于小型项目是足够的。

    集群创建

    现在,开始跟随我的脚步,让我们在 5 分钟之内快速创建一个 MongoDB Cluster 已不再是难事了。

    1. 注册:
    2. 注册成功进入个人中心出现以下页面,选择 Build a Cluster 按钮 创建 Cluster

    使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API - 图2

    1. 以下提供了多种选择方案,对于初学者选择左侧免费版创建
    2. 可以看到免费的集群给我们提供了 512MB 存储、共享的 RAM,这对我们初学者是绰绰有余的,还有一些其它选项可以自主选择,使用默认值也可。使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API - 图4
    3. 点击下面的 Create Cluster 按钮,开始集群创建,大概需要等待几分钟
    4. 创建成功如下所示使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API - 图6

    链接到集群

    集群创建好之后如何选择一个集群链接字符串?跟随以下 3 个步骤即可完成。

    1. 链接到集群,第一步将您的 IP 地址加入白名单,第二步创建一个 MongoDB 用户,完成这两步操作之后,选择 “Choose a connection method” 进入下一步

    2. 选择第二个选项 “connect your application”使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API - 图8

    3. 驱动版本使用默认值 Node.js 3.0 or later,复制这个链接字符串,接下来的项目中会使用到

    什么是 Serverless?

    Serverless 意为 “无服务器架构”,但是这并不意味着真的就无需服务器了,这些服务器的管理由云计算平台提供,对于用户侧无须关注服务器配置、监控、资源状态等,可以将重点放在业务逻辑上。

    下图,将 Microservices 进一步细分为 Function as a Service(FaaS)函数即服务,相比微服务颗粒度更小。

    使用 ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API - 图10

    图片来源:stackify

    关于 ServerLess 的基础入门,可参考我之前的另一片入门实践文章

    1. 项目创建、插件安装

    2. 项目根目录下创建 db.js 文件

    数据库链接字符串就是上面 MongoDB Atlas cloud 链接集群中所讲的,注意替换你的用户名和密码,以下代码中 initialize 函数接收两个参数 dbName、dbCollectionName 用来初始化一个 connection。

    1. const MongoClient = require("mongodb").MongoClient;
    2. const dbConnectionUrl = 'mongodb+srv://<user>:<password>@cluster0-on1ek.mongodb.net/test?retryWrites=true&w=majority';
    3. async function initialize(
    4. dbName,
    5. dbCollectionName,
    6. ) {
    7. try {
    8. const dbInstance = await MongoClient.connect(dbConnectionUrl);
    9. const dbObject = dbInstance.db(dbName);
    10. const dbCollection = dbObject.collection(dbCollectionName);
    11. console.log("[MongoDB connection] SUCCESS");
    12. return dbCollection;
    13. } catch (err) {
    14. console.log(`[MongoDB connection] ERROR: ${err}`);
    15. throw err;
    16. }
    17. }
    18. module.exports = {
    19. initialize,
    20. }

    我们想要测试下 MongoDB 的链接,以下是一个 ServerLess Function 我们在该函数中初始化了一个 Connection 然后调用了 find() 方法查找集合数据

    1. // handler.js
    2. 'use strict';
    3. const db = require('./db');
    4. module.exports.find= async (event, context) => {
    5. const response = {
    6. statusCode: 200,
    7. };
    8. try {
    9. const dbCollection = await db.initialize('study', 'books2');
    10. const body = await dbCollection.find().toArray();
    11. response.body = JSON.stringify({
    12. code: 0,
    13. message: 'SUCCESS',
    14. data: body,
    15. });
    16. return response;
    17. } catch (err) {
    18. response.body = JSON.stringify({
    19. code: err.code || 1000,
    20. message: err.message || '未知错误'
    21. });
    22. return response;
    23. }
    24. };

    4. 测试

    1. # 启动本地调试
    2. $ serverless offline
    3. # 接口测试
    4. $ curl http://localhost:3000/find
    5. Serverless: GET /find (λ: find)
    6. [MongoDB connection] SUCCESS

    似乎一切都是 Ok 的,证明我们的集群创建、链接都是成功的,但是有时候你可能会遇到以下错误

    1. Error: querySrv ENODATA _mongodb._tcp.cluster0-on1ek.mongodb.net

    以上正是我在链接 MongoDB Alats 过程中遇到的问题,这里再多提下,希望能对你有帮助,因为这花费了我很长时间,尝试使用 Google、Stackoverflow … 来搜索,但并没有找到好的解决方案,通过报错大致确认可能是网络和 DNS 的问题,修改 DNS 之后还是没有结果,后来我切换了网络,这个问题解决了。。。如果你有答案欢迎和我讨论,另外也建议检查链接字符串和 MongoDB Alats 白名单是否设置的正确。

    两个问题

    以上例子虽然已经简单的完成了一个方法,但是它其实是糟糕的,从而引发以下两个问题:

    1. 业务逻辑与 FaaS、BaaS 严重的耦合不利于单元测试、平台迁移:上面这个例子是不好的,业务逻辑完全的写在了 handler.js 文件的 find 函数中,一方面 find 函数的 event、context 对象是由 FaaS 平台提供的,另一方面 db 属于后端服务,这就造成了业务逻辑与 FaaS、BaaS 严重的耦合。

    2. 不利于上下文重用:传动程序启动之后常驻内存,不存在冷启动问题,而 ServerLess 是基于事件驱动的,第一次请求来了之后会下载代码、启动容器、启动运行环境、执行代码,这个过程称为冷启动,但是以 AWS Lambda 为例,函数调用之后执行上下文会被冻结一段时间,在我们上面的例子中每次函数执行都会初始化数据库链接,这是一个很消时的操作,我们可以将这段逻辑放在函数之外,利用上下文重用,在开发层面可以做进一步优化。

    带着上面提出的几点问题,本节将对这个业务逻辑进行重构,开发一个 REST API 最佳实践。

    什么是 REST API?

    API 的设计要保证职责单一、清晰合理、便于他人快速理解使用等原则,而 REST 也是 API 设计的一种准则,同时它也是一种架构思想,用于客户端与服务端资源传递与交互。

    本节中我们将用到的是 GET、POST、PUT、DELETE 四个表示操作方式的动词,分别对应用于获取资源、新建资源、更新资源、删除资源。

    关于 RESTful 架构的更多理解,可参考阮一峰老师的博客 “理解RESTful架构”

    REST API 规划

    以下是我们将要完成的 REST API 规划,包含四个 CRUD 操作

    目录规划

    一个好的项目离不开一个好的目录规划,当然你也可以按照自己思路来做

    1. mongodb-serverless-node-rest-api
    2. ├── package.json
    3. ├── .env
    4. ├── serverless.yml
    5. ├── app
    6. | ├── handler.js
    7. ├── controller
    8. | └── books.js
    9. └── model
    10. | ├── db.js
    11. | ├── books.js (可选)
    12. └── utils
    13. ├── message.js
    14. └── test
    15. └── controller
    16. └── books.test.js

    这一次我没有直接使用 MongoDB 驱动,而用的 mongoose 来代替 MongoDB 操作。

    创建 .env 配置文件

    将配置独立出来放入 .env 配置文件,统一管理。

    1. DB_URL=mongodb+srv://admin:admin123456@cluster0-on1ek.mongodb.net/test?retryWrites=true&w=majority
    2. DB_NAME=study1
    3. DB_BOOKS_COLLECTION=books

    创建 Model

    1. const mongoose = require('mongoose');
    2. dbName: process.env.DB_NAME,
    3. });

    app/model/books.js

    Mongoose 的一切始于 Schema。每个 schema 都会映射到一个 MongoDB collection ,定义这个 collection 里的文档构成。

    1. const mongoose = require('mongoose');
    2. const BooksSchema = new mongoose.Schema({
    3. name: String,
    4. id: { type: Number, index: true, unique: true },
    5. createdAt: { type: Date, default: Date.now },
    6. });
    7. module.exports = mongoose.models.Books || mongoose.model('Books', BooksSchema, process.env.DB_BOOKS_COLLECTION);

    使用 mongoose 创建 model,serverless-offline 运行之后调用多次,可能会出现以下问题

    1. OverwriteModelError: Cannot overwrite `Books` model once compiled.

    这个错误是因为你已经定义了一个 Schema,之后又重复定义该 Scheme 导致的,错误代码如下所示:

    1. module.exports = mongoose.model('Books', BooksSchema, process.env.DB_BOOKS_COLLECTION);

    解决这个问题,一种方案是要保证仅实例化一次,正确代码如下所示,另一种是在 serverless offline 之后加上 —skipCacheInvalidation 参数跳过 require 缓存无效操作,详情可参见 serverless-offline/issues/258

    编写业务逻辑 Books

    将业务逻辑处理放在 Books 这个类里面,并且可以不依赖于外部的任何服务,this.BooksModel 这个在测试时可以模拟数据进行传入。做到 业务逻辑与 FaaS、BaaS 的分离

    app/controller/books.js

    1. const message = require('../utils/message');
    2. class Books {
    3. constructor(BooksModel) {
    4. this.BooksModel = BooksModel;
    5. }
    6. /**
    7. * 创建 Book 数据
    8. * @param {*} event
    9. */
    10. async create(event) {
    11. const params = JSON.parse(event.body);
    12. try {
    13. const result = await this.BooksModel.create({
    14. name: params.name,
    15. id: params.id,
    16. });
    17. return message.success(result);
    18. } catch (err) {
    19. console.error(err);
    20. return message.error(err.code, err.message);
    21. }
    22. }
    23. /**
    24. * 更新
    25. * @param {*} event
    26. */
    27. async update(event) {
    28. try {
    29. const result = await this.BooksModel.findOneAndUpdate({ id: event.pathParameters.id }, {
    30. $set: JSON.parse(event.body),
    31. }, {
    32. $upsert: true,
    33. new: true
    34. });
    35. return message.success(result);
    36. } catch (err) {
    37. console.error(err);
    38. return message.error(err.code, err.message);
    39. }
    40. }
    41. /**
    42. * 查找所有 Books 数据
    43. * @param {*} event
    44. */
    45. async find() {
    46. try {
    47. const result = await this.BooksModel.find();
    48. return message.success(result);
    49. } catch (err) {
    50. console.error(err);
    51. return message.error(err.code, err.message);
    52. }
    53. }
    54. /**
    55. * 删除一条数据
    56. * @param {*} event
    57. */
    58. async deleteOne(event) {
    59. const result = await this.BooksModel.deleteOne({
    60. id: event.pathParameters.id
    61. });
    62. if (result.deletedCount === 0) {
    63. return message.error(1010, '数据未找到!可能已被删除!');
    64. }
    65. return message.success(result);
    66. } catch (err) {
    67. console.error(err);
    68. return message.error(err.code, err.message);
    69. }
    70. }
    71. }
    72. module.exports = Books;

    编写 handler

    在 handler 里 event、context 这些参数是由 FaaS 平台提供,上面我们又把业务逻辑单独放置于 Controller 下的 Books.js 里,这样做好处是假如我们要从一个平台迁移到另一个平台,只需要修改 handler.js 里 Books 的调用方式即可,业务逻辑是不受影响的。

    对于这种初始化链接的操作,尽量放在函数之外,避免每次函数来临都要去初始化这样一个耗时的操作,我们可以利用函数的执行上下文重用,在启动环境执行代码时去初始化我们的数据库链接,例如 handler.js 头部的 require(‘./model/db’)。

    app/handler.js

    1. require('dotenv').config();
    2. require('./model/db');
    3. const BooksModel = require('./model/books');
    4. const BooksController = require('./contrller/books');
    5. const booksController = new BooksController(BooksModel);
    6. module.exports = {
    7. create: event => booksController.create(event),
    8. update: event => booksController.update(event),
    9. find: () => booksController.find(),
    10. deleteOne: event => booksController.deleteOne(event),
    11. }

    Serverless 配置文件

    这个也是重点,plugins 插件的 serverless-offline 是为了本地调试用,functions 里面则定义了函数文件的路径和路由规则,注意如果是 /books/:id 这样的路由在 serverless.yml 里的路由规则为 books/{id}

    1. service: mongodb-serverless-node-rest-api
    2. provider:
    3. name: aws
    4. runtime: nodejs12.x
    5. plugins:
    6. - serverless-offline
    7. functions:
    8. create:
    9. handler: app/handler.create
    10. events:
    11. - http:
    12. path: books
    13. method: post
    14. update:
    15. handler: app/handler.update
    16. events:
    17. - http:
    18. path: books/{id}
    19. method: put
    20. find:
    21. handler: app/handler.find
    22. events:
    23. - http:
    24. path: books
    25. method: get
    26. deleteOne:
    27. handler: app/handler.deleteOne
    28. events:
    29. - http:
    30. path: books/{id}
    31. method: delete

    部署这是我们完成 REST API 的最后一步,就是这么简单一条命令 serverless deploy 就可将我们的服务部署在云端。

    1. $ serverless deploy
    2. Serverless: Packaging service...
    3. Serverless: Excluding development dependencies...
    4. Serverless: Uploading CloudFormation file to S3...
    5. Serverless: Uploading artifacts...
    6. Serverless: Uploading service mongodb-serverless-node-rest-api.zip file to S3 (2.17 MB)...
    7. Serverless: Stack update finished...
    8. ...
    9. endpoints:
    10. POST - https://******.execute-api.us-east-1.amazonaws.com/dev/books
    11. PUT - https://******.execute-api.us-east-1.amazonaws.com/dev/books/{id}
    12. GET - https://******.execute-api.us-east-1.amazonaws.com/dev/books
    13. GET - https://******.execute-api.us-east-1.amazonaws.com/dev/books/{id}

    endpoints 列举了服务的接口调用地址,现在你可以在 POSTMAN 进行调试了。

    完整代码参考源码地址

    1. https://github.com/Q-Angelo/project-training/tree/master/serverless/mongodb-serverless-node-rest-api

    ServerLess 是一种全新的技术体系,降低了服务端研发成本,而 Node.js 使用起来很轻量级,对前端开发者也很友好,但是前端开发者对服务端运维还是相对陌生的,使用了 ServerLess 可以帮助开发者隔离服务器的运维、环境搭建等一系列操作,把更多时间聚焦在业务开发中。本文中在数据存储方面结合了 MongoDB Alats Cloud 免去了数据库的搭建、维护工作,现在只要你掌握一些 JavaScript 基础语法通过本文的讲解就可轻松的完成一个 REST API,这是多么 Nice 的事情呀,快来实践下吧!