今天我想和你聊一聊怎么基于 Serverless 构建弹性可扩展的 Restful API。

API 是使用 Serverless 最常见,也是最适合的场景之一。和 Serverful 架构的  API 相比,用 Serverless 开发 API 好处很多:

不用购买、管理服务器等基础设施,不用关心服务器的运维,节省人力成本;

基于 Serverless 的 API,具备自动弹性伸缩的能力,能根据请求流量弹性扩缩容,让你不再担心流量波峰、波谷;

基于 Serverless 的 API 按实际资源使用量来付费,节省财务成本。

因为好处很多,很多开发者跃跃欲试,但在实践过程中却遇到了很多问题,比如怎么设计最优的架构?怎么组织代码?怎么管理多个函数?所以今天我就以开发一个内容管理系统为例,带你学习怎么基于 Serverless 去开发一个 Restful API,解决上述共性问题。

首先,我们需要对内容管理系统进行架构设计。

内容管理系统的架构设计

在进行架构设计前,你要明确系统的需求。对于一个内容管理系统,最核心的功能(也是这一讲要实现的功能),主要有这样几个:

用户注册;

用户登录;

发布文章;

修改文章;

删除文章;

查询文章。

这 6 个功能分别对应了我们要实现的 Restful API。为了方便统一管理 API,在 Serverless 架构中我们通常会用到 API 网关,通过 API 网关触发函数执行,并且基于  API 网关我们还可以实现参数控制、超时时间、IP 黑名单、流量控制等高级功能。

对于文章管理相关的 Restful API,用户发布文章前需要先登录,在 15 讲,你已经知道在 Serverless 中可以用 JWT 进行身份认证,咱们的管理系统中的登录注册功能也将沿用上一讲的内容。

在传统的 Serverful 架构中,通常会用 MySQL 等关系型数据库存储数据,但因为关系型数据库要在代码中维护连接状态及连接池,且一般不能自动扩容,并不适合 Serverless 应用,所以在 Serverless 架构中,通常选用表格存储等 Serverless NoSQL 数据来存储数据。

基于 JWT 的身份认证方案、数据存储方案,我们可以画出 Serverless 的内容管理系统架构图:

图中主要表达的意思是: 通过 API 网关承接用户请求,并驱动函数执行。每个函数分别实现一个具体功能,并通过 JWT 实现身份认证,最后表格存储作为数据库。

其中,数据库中存储的数据主要是用户数据和文章数据。假设用户有 username(用户名) 和 password(密码) 两个属性;文章有 article_id(文章 ID)、username(创建者)、title(文章标题)、content(文章内容)、create_date(创建时间)、update_date(更新时间)这几个属性。

接下来,你可以在表格存储中创建对应的数据表(你可以在表格存储控制台创建,也可以直接用我提供的这段代码进行创建):

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
// index.js
const TableStore = require("tablestore");
// 初始化 TableStore client
const client = new TableStore.Client({
  accessKeyId: '<your access key>',
  accessKeySecret: '<your access secret>',
  endpoint: "https://serverless-app.cn-shanghai.ots.aliyuncs.com",
  instancename: "serverless-cms",
});
/**
 * 创建 user 
 *
 * 参考文档: https://help.aliyun.com/document_detail/100594.html
 */
async function createUserTable() {
  const table = {
    tableMeta: {
      tableName: "user",
      primaryKey: [
        {
          name: "username", // 用户名
          type: TableStore.PrimaryKeyType.STRING,
        },
      ],
      definedColumn: [
        {
          name: "password", // 密码
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
      ],
    },
    // 为数据表配置预留读吞吐量或预留写吞吐量。0 表示不预留吞吐量,完全按量付费
    reservedThroughput: {
      capacityUnit: {
        read: 0,
        write: 0,
      },
    },
    tableOptions: {
      // 数据的过期时间,单位为秒,-1表示永不过期
      timeToLive: -1,
      // 保存的最大版本数,1 表示每列上最多保存一个版本即保存最新的版本
      maxVersions: 1,
    },
  };
  await client.createTable(table);
}
/**
 * 创建文章表
 */
async function createArticleTable() {
  const table = {
    tableMeta: {
      tableName: "article",
      primaryKey: [
        {
          name: "article_id", // 文章 ID,唯一字符串
          type: TableStore.PrimaryKeyType.STRING,
        },
      ],
      definedColumn: [
        {
          name: "title",
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
        {
          name: "username",
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
        {
          name: "content",
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
        {
          name: "create_date",
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
        {
          name: "update_date",
          type: TableStore.DefinedColumnType.DCT_STRING,
        },
      ],
    },
    // 为数据表配置预留读吞吐量或预留写吞吐量。0 表示不预留吞吐量,完全按量付费
    reservedThroughput: {
      capacityUnit: {
        read: 0,
        write: 0,
      },
    },
    tableOptions: {
      // 数据的过期时间,单位为秒,-1表示永不过期
      timeToLive: -1,
      // 保存的最大版本数,1 表示每列上最多保存一个版本即保存最新的版本
      maxVersions: 1,
    },
  };
  await client.createTable(table);
}
(async function () {
    await createUserTable();
  await createArticleTable();
})();

这段代码主要创建了 user 和 article 两张表,其中 user 表的主键是 username,article 表的主键是 article_id,主键的作用是方便查询。除了主键,我还定义了几个列。其实对于表格存储,默认也可以不创建列,表格存储是宽表,除主键外,数据列可以随意扩展。

在完成了数据库表的创建后,我们就可以开始进行系统实现了。

内容管理系统的实现

为了方便你学习,我为你提供了完整代码(代码地址),你可以参考着学习。

1
2
$ git clone https://github.com/nodejh/serverless-class
$ cd 15/cms

整个代码目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.
├── package.json
├── src
│   ├── config
│   │   └── index.js
│   ├── db
│   │   └── client.js
│   ├── function
│   │   ├── article
│   │   │   ├── create.js
│   │   │   ├── delete.js
│   │   │   ├── detail.js
│   │   │   └── update.js
│   │   └── user
│   │       ├── login.js
│   │       └── register.js
│   └── middleware
│       └── auth.js
└── template.yml

其中,所有业务代码都放在 src 目录中:

config/index.js 是配置文件,里面包含身份凭证等配置信息;

db/client.js 对表格存储的增删改查操作进行了封装,方便在函数中使用(将数据库的操作封装还有一个好处是,如果你之后想要迁移到其他数据库,只要修改 db/client.js 中的逻辑,不用修改业务代码);

middleware 目录中是一些中间件,比如 auth.js,用于身份认证;

functions 目录中就是所有函数,登录、注册、创建文章等,每个功能分别对应一个函数;

template.yaml 是应用配置文件,包括函数和 API 网关的配置。

根据前面梳理的系统功能,我们需要实现以下几个 API:

用户注册

POST /user/register

用户登录

POST /user/login

发布文章

POST /article/create

查询文章

GET /article/detail/[article_id]

更新文章

POST /article/update

删除文章

PUT /article/delete/[article_id]

每个 API 对应一个具体的函数,每个函数也都有一个与之对应的 API 网关触发器。由于这些函数属于同一个应用,所以我们可以通过一个 template.yaml 来定义所有函数。同时也可以在 template.yaml 中定义函数的 API 网关触发器,这样部署函数时,就会自动创建 API 网关。

内容管理系统的 template.yaml 格式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  # 函数服务,该服务中的函数都是内容管理系统的函数
  serverless-cms:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'Serverless 内容管理系统'
    # 函数名称
    [functionName]:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        # 函数路径
        Handler: <functionPath>.handler
        Runtime: nodejs12
        CodeUri: './'
  # API 网关分组,分钟中的所有 API 都是内容管理系统的 API
  ServerlessCMSGroup: 
    Type: 'Aliyun::Serverless::Api'
    Properties:
      StageName: RELEASE
      DefinitionBody:
        <Path>: # 请求的 path
          post: # 请求的 method
            x-aliyun-apigateway-api-name: user_register # API 名称
            x-aliyun-apigateway-fc: # 当请求该 API 时,要触发的函数,
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${<functionName>.Arn}/
              timeout: 3000

template.yaml  主要分为两部分: 函数定义和 API 网关定义,每个函数都有一个与之对应的 API 网关。我们用 serverless-cms 服务来表示内容管理系统这个应用,服务内的所有函数都是内容管理系统的函数。同理,ServerlessCMSGroup 这个 API 网关分组中的所有 API 都是内容管理系统的 API。

完整的 template.yaml 配置如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  # 函数服务
  serverless-cms:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'Serverless 内容管理系统'
    user-register:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/user/register.handler
        Runtime: nodejs12
        CodeUri: './'
    user-login:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/user/login.handler
        Runtime: nodejs12
        CodeUri: './'
    article-create:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/article/create.handler
        Runtime: nodejs12
        CodeUri: './'
    article-detail:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/article/detail.handler
        Runtime: nodejs12
        CodeUri: './'
    article-update:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/article/update.handler
        Runtime: nodejs12
        CodeUri: './'
    article-delete:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: src/function/article/delete.handler
        Runtime: nodejs12
        CodeUri: './'
  # API 网关分组
  ServerlessCMSGroup: 
    Type: 'Aliyun::Serverless::Api'
    Properties:
      StageName: RELEASE
      DefinitionBody:
        '/user/register': # 请求的 path
          post: # 请求的 method
            x-aliyun-apigateway-api-name: user_register # API 名称
            x-aliyun-apigateway-fc: # 当请求该 API 时,要触发的函数,
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${user-register.Arn}/
              timeout: 3000
        '/user/login':
          post:
            x-aliyun-apigateway-api-name: user_login
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${user-login.Arn}/
              timeout: 3000
        '/article/create':
          post:
            x-aliyun-apigateway-api-name: article_create
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${article-create.Arn}/
              timeout: 3000
        '/article/detail/[article_id]':
          GET:
            x-aliyun-apigateway-api-name: article_detail
            x-aliyun-apigateway-request-parameters:
              - apiParameterName: 'article_id'
                location: 'Path'
                parameterType: 'String'
                required: 'REQUIRED'
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${article-detail.Arn}/
              timeout: 3000
        '/article/update/[article_id]':
          PUT:
            x-aliyun-apigateway-api-name: article_update
            x-aliyun-apigateway-request-parameters:
              - apiParameterName: 'article_id'
                location: 'Path'
                parameterType: 'String'
                required: 'REQUIRED'
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${article-update.Arn}/
              timeout: 3000
        '/article/delete/[article_id]':
          DELETE:
            x-aliyun-apigateway-api-name: article_update
            x-aliyun-apigateway-request-parameters:
              - apiParameterName: 'article_id'
                location: 'Path'
                parameterType: 'String'
                required: 'REQUIRED'
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${serverless-cms.Arn}/functions/${article-delete.Arn}/
              timeout: 3000
            

在这份配置中,需要注意两个地方:

函数的 Handler 配置,Handler 可以写函数路径,比如 src/function/user/register.handler 表示 src/function/user/ 目录中的 register.js 文件中的 handler 方法;

API 网关配置中的 /article/detail/[article_id] Path,这种带参数的 PATH,必须使用 x-aliyun-apigateway-request-parameters 指定 Path 参数。

接下来,我们就来实现内容管理系统的各个 API,也就是 template.yaml 中定义的各个函数。

用户注册

用户注册接口定义如下。

请求方法:POST。

Path: /user/register

Body参数:username 用户名、password 密码。

整体代码很简单,在入口函数 handler 中,通过 event 得到 API 网关传递过来的 HTTP 请求 body 数据,然后从中得到 username、password,再将用户信息写入数据库。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// src/function/user/register
const client = require("../../db/client");
/**
 * 用户注册
 * @param {string} username 用户名
 * @param {string} password 密码
 */
async function register(username, password) {
  await client.createRow("user", { username }, { password });
}
module.exports.handler = function (event, context, callback) {
  //  event 中获取 API 网关传递 HTTP 请求 body 数据
  const body = JSON.parse(JSON.parse(event.toString()).body);
  const { username, password } = body;
  register(username, password)
    .then(() => callback(null, { success: true }))
    .catch((error) =>
      callback(error, { success: false, message: "用户注册失败" })
    );
};

代码完成后,就可以将应用部署到函数计算:

1
2
3
4
5
6
7
8
# 部署应用
$ fun deploy
Waiting for service serverless-cms to be deployed...
...
service serverless-cms deploy success
Waiting for api gateway ServerlessCMSGroup to be deployed...
...
api gateway ServerlessCMSGroup deploy success

部署过程中,如果看到函数服务 serverless-cms  和 API 网关 ServerlessCMSGroup 都成功部署了,就说明应用部署完成。部署完成后,API 网关会提供一个用来测试的 API Endpoint,当然你也可以绑定自定义域名。

我们可以通过 curl 测试一下:

1
2
3
4
$ curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/user/register \
-X POST \
-d "username=Jack&password=123456"
{"success":true}

返回 {“success”: true} ,说明用户注册成功。这时在表格存储控制台也可以看到刚注册的用户。

用户登录

完成用户注册函数开发后,就可以接着开发登录。用户登录的接口定义如下。

请求方法:POST。

Path: /user/login

Body 参数:username 用户名、password 密码。

登录的逻辑就是根据用户输入的密码是否正确,如果正确就生成一个 token 返回给调用方。代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/function/user/login
const assert = require("assert");
const jwt = require('jsonwebtoken');
const { jwt_secret } = require("../../config");
const client = require("../../db/client");
/**
 * 用户登录
 * @param {string} username 用户名
 * @param {string} password 密码
 */
async function login(username, password) {
  const user = await client.getRow("user", { username });
  assert(user && user.password === password);
  const token = jwt.sign({ username: user.username }, jwt_secret);
  return token;
}
module.exports.handler = function (event, context, callback) {
  const body = JSON.parse(JSON.parse(event.toString()).body);
  const { username, password } = body;
  login(username, password)
    .then((token) => callback(null, { success: true, data: { token } }))
    .catch((error) =>
      callback(error, { success: false, message: "用户登录失败" })
    );
};

将其部署到函数计算后,我们也可以使用 curl 命令进行测试:

1
2
3
4
$ curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/user/login \
-X POST \
-d "username=Jack&password=123456"
{"success":true,"data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkphY2siLCJpYXQiOjE2MTE0OTI2ODF9.c56Xm4RBLYl5yVtR_Vk0IZOL0yijofcyE-P7vjKf4nA"}}

身份认证

在完成了注册登录接口后,我们再来看一下内容管理系统中,身份认证应该怎么实现。

在 15 讲,我们实现了一个 Express.js 框架的身份认证中间件,用来拦截所有请求,身份认证通过后才能进执行后面的代码逻辑。在内容管理系统中,你也可以参考 Express.js 的思想,实现一个 auth.js 专门用于身份认证,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
const { jwt_secret } = require("../config/index");
/**
 * 身份认证
 * @param {object} event API 网关的 event 对象
 * @return {object} 认证通过后返回 user 信息;认证失败则返回 false
 */
const auth = function (event) {
  try {
    const data = JSON.parse(event.toString());
    if (data.headers && data.headers.Authorization) {
      const token = JSON.parse(event.toString())
        .headers.Authorization.split(" ")
        .pop();
      const user = jwt.verify(token, jwt_secret);
      return user;
    }
    return false;
  } catch (error) {
    return false;
  }
};
module.exports = auth;

其原理很简单,就是从 API 网关的 event 对象中获取 token,然后验证 token 是否正常。如果认证通过,就返回 user 信息,失败就返回 false。

这样在需要身份认证的函数中,你只引入 auth.js 并传入 event 对象就可以了。下面是一个简单的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const auth = require('./middleware/auth');
module.exports.handler = function (event, context, callback) {
  // 使用 auth 进行身份认证
  const user = auth(event);
  if (!user) {
    // 若认证失败则直接返回
    return callback('身份认证失败!')
  }
  // 通过身份认证后的业务逻辑
  // ...
  callback(null);
};

除了登录注册,其他接口都需要身份认证,所以接下来我们就通过实现“发布文章”函数来实际使用 auth.js。

发布文章

发布文章的接口定义如下。

请求方法:POST。

Path: /article/create

Headers 参数: Authorization token。

Body 参数:title、content。

由于登录后才能发布文章,所以要先通过登录接口获取 token,然后调用 /article/create 接口时,再将 token 放在 HTTP Headers 参数中。发布文章的代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// src/function/article/auth
const uuid = require("uuid");
const auth = require("../../middleware/auth");
const client = require("../../db/client");
/**
 * 创建文章
 * @param {string} username 用户名
 * @param {string} title 文章标题
 * @param {string} content 文章内容
 */
async function createArticle(username, title, content) {
  const article_id = uuid.v4();
  const now = new Date().toLocaleString();
  await client.createRow(
    "article",
    {
      article_id,
    },
    {
      username,
      title,
      content,
      create_date: now,
      update_date: now,
    }
  );
  return article_id;
}
module.exports.handler = function (event, context, callback) {
  // 身份认证
  const user = auth(event);
  if (!user) {
    // 若认证失败则直接返回
    return callback("身份认证失败");
  }
  //  user 中获取 username
  const { username } = user;
  const body = JSON.parse(JSON.parse(event.toString()).body);
  const { title, content } = body;
  createArticle(username, title, content)
    .then(() =>
      callback(null, {
        success: true,
      })
    )
    .catch((error) =>
      callback(error, {
        success: false,
        message: "创建文章失败",
      })
    );
};

首先是使用 auth.js 进行身份认证,认证通过后就可以从 user 中获取 username。然后再从请求体中获取文章标题和文章内容数据,将其存入数据库。

接下来我们依旧可以将函数部署和使用 curl 进行测试:

1
2
3
4
5
$ curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/article/create \
-X POST \
-d "title=这是文章标题&content=内容内容内容......" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkphY2siLCJpYXQiOjE2MTE0OTI2ODF9.c56Xm4RBLYl5yVtR_Vk0IZOL0yijofcyE-P7vjKf4nA"
{"success":true,"data":{"article_id":"d4b9bad8-a0ed-499d-b3c6-c57f16eaa193"}}

在测试时,我们需要将 token 放在 HTTP 请求头的 Authorization 属性中。文章发布成功后,你就可以在表格存储中看到对应的数据了。

查询文章

发布文章的接口开发完成后,我们继续开发一个查询文章的接口,这样就可以查询出刚才创建的文章。查询文章接口定义如下。

请求方法:GET。

Path: /article/detail/[article_id]

Headers 参数: Authorization token。

在查询文章接口中,我们需要在 Path 中定义文章 ID 参数,即 article_id。这样在函数代码中,你就可以通过 event 对象的 pathParameters 中获取 article_id 参数,然后根据 article_id 来查询文章详情了。完整代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const uuid = require("uuid");
const auth = require("../../middleware/auth");
const client = require("../../db/client");
/**
 * 获取文章详情
 * @param {string} title 文章 ID
 */
async function getArticle(article_id) {
  const res = await client.getRow(
    "article",
    {
      article_id,
    },
  );
  return res;
}
module.exports.handler = function (event, context, callback) {
  // 身份认证
  const user = auth(event);
  if (!user) {
    // 若认证失败则直接返回
    return callback("身份认证失败");
  }
  
  //  event 对象中获取文章 ID
  const article_id = JSON.parse(event.toString()).pathParameters['article_id'];
  getArticle(article_id)
    .then((detail) =>
      callback(null, {
        success: true,
        data: detail
      })
    )
    .catch((error) =>
      callback(error, {
        success: false,
        message: "创建文章失败",
      })
    );
};

开发完成后,我们可以将其部署到函数计算,再用 curl 命令进行测试:

1
2
3
$ curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/article/detail/d4b9bad8-a0ed-499d-b3c6-c57f16eaa193 \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkphY2siLCJpYXQiOjE2MTE0OTI2ODF9.c56Xm4RBLYl5yVtR_Vk0IZOL0yijofcyE-P7vjKf4nA"
{"success":true,"data":{"article_id":"d4b9bad8-a0ed-499d-b3c6-c57f16eaa193","content":"内容内容内容......","create_date":"1/24/2021, 2:05:46 PM","title":"这是文章标题","update_date":"1/24/2021, 2:05:46 PM","username":"Jack"}}

如上所示,查询文章的接口按照预期返回了文章详情。

更新文章

更新文章的 API Path 参数和查询文章一样,都需要 Path 中定义 article_id。而其 body 参数则与创建文章相同。此外,更新文章的请求 method 是 PUT,因为在 Restful API 规范中,我们通常使用 POST 来表示创建, 使用 PUT 来表示更新。

更新文章的接口定义如下。

请求方法:PUT。

Path: /article/update/[article_id]

Headers 参数: Authorization token。

Body 参数:title、content。

更新文章的逻辑就是根据  article_id 去更新一行数据。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const auth = require("../../middleware/auth");
const client = require("../../db/client");
/**
 * 更新文章
 * @param {string} article_id 待更新的文章 ID
 * @param {string} title 文章标题
 * @param {string} content 文章内容
 */
async function updateArticle(article_id, title, content) {
  const now = new Date().toLocaleString();
  await client.updateRow(
    "article",
    {
      article_id,
    },
    {
      title,
      content,
      update_date: now,
    }
  );
}
module.exports.handler = function (event, context, callback) {
  // 身份认证
  const user = auth(event);
  if (!user) {
    // 若认证失败则直接返回
    return callback("身份认证失败");
  }
  const eventObject = JSON.parse(event.toString())
  //  event 对象的 pathParameters 中获取 Path 参数
  const article_id = eventObject.pathParameters['article_id'];
  const body = JSON.parse(eventObject.body);
  //  event 对象的 body 中获取请求体参数
  const { title, content } = body;
  updateArticle(article_id, title, content)
    .then(() =>
      callback(null, {
        success: true,
      })
    )
    .catch((error) =>
      callback(error, {
        success: false,
        message: "更新文章失败",
      })
    );
};

开发并部署完成后,使用 curl 命令进行测试:

1
2
3
4
5
$ curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/article/update/d4b9bad8-a0ed-499d-b3c6-c57f16eaa193 \
-X PUT \
-d "title=这是文章标题&content=更新的内容......" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkphY2siLCJpYXQiOjE2MTE0OTI2ODF9.c56Xm4RBLYl5yVtR_Vk0IZOL0yijofcyE-P7vjKf4nA"
{"success":true}

返回 {“success”:true} 则说明更新成功。

删除文章

最后就还是一个删除文章的 API 了。删除文章的 API 也需要在 Path 中定义 article_id 参数,并且其 HTTP method 是 DELETE。具体接口定义如下。

请求方法:DELETE。

Path: /article/delete/[article_id]

Headers 参数: Authorization token,

删除文章很简单,就是根据 article_id 删除一行数据,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const uuid = require("uuid");
const auth = require("../../middleware/auth");
const client = require("../../db/client");
/**
 * 删除文章
 * @param {string} title 文章 ID
 */
async function deleteArticle(article_id) {
  const res = await client.deleteRow(
    "article",
    {
      article_id,
    },
  );
  return res;
}
module.exports.handler = function (event, context, callback) {
  // 身份认证
  const user = auth(event);
  if (!user) {
    // 若认证失败则直接返回
    return callback("身份认证失败");
  }
  
  //  event 对象中获取文章 ID
  const article_id = JSON.parse(event.toString()).pathParameters['article_id'];
  deleteArticle(article_id)
    .then(() =>
      callback(null, {
        success: true,
      })
    )
    .catch((error) =>
      callback(error, {
        success: false,
        message: "删除文章失败",
      })
    );
};

同样我们可以通过 curl 命令进行测试:

1
2
3
4
curl http://a88f7e84f71749958100997b77b3e2f6-cn-beijing.alicloudapi.com/article/delete/d4b9bad8-a0ed-499d-b3c6-c57f16eaa193 \
-X DELETE \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkphY2siLCJpYXQiOjE2MTE0OTI2ODF9.c56Xm4RBLYl5yVtR_Vk0IZOL0yijofcyE-P7vjKf4nA"
{"success":true}

删除成功后,再去表格存储中就找不到这行记录了。至此,内容管理系统的 Restful API 就开发完毕了。

总结

可以看到,基于 Serverless 开发 Restful API 的整个代码非常简单,每个函数只负责一个独立的业务,职责单一、逻辑清晰。关于这一讲,我想强调这样几个重点:

基于 Serverless 开发 API 时,建议你使用 API 网关进行 API 的管理;

对于数据库等第三方服务,建议对其基本操作进行封装,这样更方便进行扩展;

Serverless 函数需要保持简单、独立、单一职责。

最后,我留给你的作业就是,亲自动手实现一个基于 Serverless 的具有 Restful API 的内容管理系统。我们下一讲见。

-– ### 精选评论 ##### **栋: > 修改代码之后, 如何进行升级发布呢? 我继续用fun deploy 返回下列错误PUT /services/serverless-cms failed with 400. requestid: 8f9453e8-86bc-42da-8f53-98c7f7bfe74f, message: Both project and logstore are required for enabling request metrics. ######     讲师回复: >     您好,这个报错可能是您之前在控制台中为函数服务(Service)配置了日志服务,但项目的 template.yaml 中没有配置日志服务。可以参考这个文档,为服务添加 LogConfig 配置 https://github.com/alibaba/funcraft/blob/master/docs/specs/2018-04-03-zh-cn.md#aliyunserverlessservice