基于 AWS IoT 构建 Serverless 的消息通知系统

这篇文章关于使用 Serverless 框架和 AWS IoT 进行 Serverless 通知的教程。

Demo 地址:serverless-notifications.zanon.io

Code:GitHub

Serverless Notifications

实时通知是现代应用程序的重要用例。例如,您可能需要通知用户他的社交 Feed 中有其他帖子,或者其他人在他的照片中添加了评论。

当你使用 WebSockets 并拥有专用服务器时,实现通知是一项简单的任务。您可以在用户和服务器之间建立永久链接,并使用发布/订阅(publish/subscribe)模式来共享消息。浏览器将订阅自动接收新消息,而不需要轮询机制来不断检查更新。

但是,如果我们用了 Serverless,我们没有专用的服务器。相反的,我们需要一个云服务,它将解决这个问题,并为我们提供可扩展性、高可用性,并且是按每个消息来收费,而不是每小时。

在这篇文章中,我将介绍如何使用 Serverless Framework 和 AWS IoT 进行浏览器的未经身份验证的用户实现通知系统。我知道 “物联网” 在网站上使用起来很奇怪,但它支持 WebSockets,非常易于使用。此外,Amazon SNS(Simple Notification Service,简单通知服务)具有更好的名称,但不支持 WebSockets。

IoT 由于其简单的消息系统而被使用。您创建一个 “主题” 并让用户订阅它。发送到此主题的邮件将自动与所有订阅的用户共享。一个常见的用例是聊天系统。

如果你想要私人消息,你只需要创建私人主题并限制访问。只要有一个用户订阅到此主题,您可以使您的系统(Lambda 函数)向本主题发送更新以通知此特定用户。

架构

在本玩法中,我们将实现以下架构。

Serverless 的消息通知系统架构

  1. 用户向 Route 53 发出一个请求,它被配置为引用到 S3 存储桶。
  2. S3 存储桶提供前端代码( HTML/CSS/JavaScript/图片)和 IoT 客户端代码。
  3. 在加载前端代码之后,Ajax 请求完成到 API 网关以接收临时密钥。
  4. API Gateway 重定向要由 Lambda 函数处理的请求。
  5. Lambda 函数连接到 IAM 以承担角色,并创建临时的 AWS 密钥。
  6. 前端代码使用临时密钥订阅 IoT 事件。

注意:您可以使用 AWS Cognito 来代替 API Gateway 和 Lambda 来检索 IAM 凭据。

前端

配置 Route 53 和 Amazon S3 来提供静态文件是一个常见的使用方式。我已经在另一篇博文中介绍了如何做到这一点。如果你愿意,你可以看看这里

在我们的前端代码中,我们开始为我们的布局创建 index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Serverless Notifications</title>
        <link href="bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="container">
            <div class="row">
                <h2>Serverless Notifications</h2>
            </div>
            <div class="row">
                <input id="btn-keys" type="button" class="btn btn-primary" value='Retrieve Keys'>
                <input id="btn-connect" type="button" class="btn btn-primary" value='Connect'>
            </div>
            <div class="row">
                <input id="message" type="text">
                <input id="btn-send" type="button" class="btn btn-primary" value='Send Message'>
            </div>
            <div class="row">
                <h3>Log Messages</h3>
                <ul id="log"></ul>
            </div>
        </div>    
        <script src="jquery.min.js"></script>
        <script type="text/javascript">
            window.lambdaEndpoint = 'https://abcdefghij.execute-api.us-east-1.amazonaws.com/dev/iot/keys';
        </script>
        <script src="bundle.js"></script> <!-- IoT -->
    </body>
</html>

注意:我创建了一个 window.lambdaEndpoint 的变量。稍后,您需要使用无服务器框架的输出来更改此值。该地址将是您的函数结点(PS,即 Lambda API 地址)。

HTML 的结果如下所示:

HTML 界面

在此演示中,用户将点击 “Retrieve Keys”,它将发一个 Ajax 请求来调用 Lambda。其目的是检索临时密钥,以用于与我们的 IoT 消息系统连接。 通过这些 keys,“Connect” 按钮将创建一个频道并订阅消息。 “Send Message” 按钮将与其他用户共享消息(打开另一个浏览器选项卡进行测试)。

向 Lambda 函数发送请求

接着,我们需要通过 Ajax 请求来发起对 Lambda 函数的调用,以检索临时的 AWS 密钥。

注意:我使用的是 ES6 语法,因为当我打包 IoT 客户端时,这个文件将由 Babel 处理。

$(document).ready(() => {

    let iotKeys;

    $('#btn-keys').on('click', () => {
        $.ajax({
            url: window.lambdaEndpoint,
            success: (res) => {
                addLog(`Endpoint: ${res.iotEndpoint},
                        Region: ${res.region},
                        AccessKey: ${res.accessKey},
                        SecretKey: ${res.secretKey},
                        SessionToken: ${res.sessionToken}`);

                iotKeys = res; // save the keys
            }
        });
    });     
});

const addLog = (msg) => {
    const date = (new Date()).toTimeString().slice(0, 8);
    $("#log").prepend(`<li>[${date}] ${msg}</li>`);
}

AWS IoT

为了构建我们的通知系统,我们需要使用一个 Node 模块 AWS IoT SDK,并打包到浏览器中运行。

在这个项目中,我创建了另一个名为 iot 的文件夹来开发 IoT 客户端代码。它有一个 package.json,所以运行 npm install 来安装 aws-iot-device-sdk 依赖。

这个 IoT 对象具有 connectsend 的功能。它还为其他功能(如 onConnectonMessage)提供处理程序。

const awsIot = require('aws-iot-device-sdk');

let client, iotTopic;
const IoT = {

    connect: (topic, iotEndpoint, region, accessKey, secretKey, sessionToken) => {

        iotTopic = topic;

        client = awsIot.device({
            region: region,
            protocol: 'wss',
            accessKeyId: accessKey,
            secretKey: secretKey,
            sessionToken: sessionToken,
            port: 443,
            host: iotEndpoint
        });

        client.on('connect', onConnect);
        client.on('message', onMessage);            
        client.on('close', onClose);     
    },

    send: (message) => {
        client.publish(iotTopic, message);
    }  
};

const onConnect = () => {
    client.subscribe(iotTopic);
    addLog('Connected');
};

const onMessage = (topic, message) => {
    addLog(message);
};

const onClose = () => {
    addLog('Connection failed');
};

正如我们开发的 IoT 客户端,我们现在可以完成 JavaScript 浏览器代码。添加以下内容:

$('#btn-connect').on('click', () => {
    const iotTopic = '/serverless/pubsub';        

    IoT.connect(iotTopic,
                iotKeys.iotEndpoint,
                iotKeys.region,
                iotKeys.accessKey,
                iotKeys.secretKey,
                iotKeys.sessionToken);
});    

$('#btn-send').on('click', () => {
    const msg = $('#message').val();
    IoT.send(msg);    
});

现在,我们来创建一个 bundle。 似乎目前这个模块不能与 webpack 捆绑在一起,但是通过 Browserify 它可以正常工作。我创建了一个名为 make-bundle.js 的文件,有助于此。你可以在这里查看这个文件。

只需在此文件夹中运行 npm 安装,并运行node make-bundle 来创建更新的版本。这个 bundle.js 将在我们的前端代码中使用,并且必须位于 index.html 文件的同一个文件夹中。

创建 IoT Role

我们的 Lambda 函数将负责创建临时的 AWS 密钥。然而,它需要一个角色来定义这些密钥将提供哪些访问权限。

您可以使用 IAM 控制台创建此角色,也可以执行 create-role 文件夹中的 index.js 文件来您创建一个。此软件包使用 AWS SDK,并且在使用之前需要 npm install

我使用角色名称是 serverless-notifications。如果需要,您可以在此处更改此名称,但您还需要更改 Lambda 函数中的名称。

创造角色:

const AWS = require('aws-sdk');
const iam = new AWS.IAM();
const sts = new AWS.STS();
const roleName = 'serverless-notifications';

// get the account id
sts.getCallerIdentity({}, (err, data) => {
  if (err) return console.log(err, err.stack);

  const createRoleParams = {
    AssumeRolePolicyDocument: `{
      "Version":"2012-10-17",
      "Statement":[{
          "Effect": "Allow",
          "Principal": {
            "AWS": "arn:aws:iam::${data.Account}:root"
          },
          "Action": "sts:AssumeRole"
        }
      ]
    }`,
    RoleName: roleName
  };

  // create role
  iam.createRole(createRoleParams, (err, data) => {
    if (err) return console.log(err, err.stack);

    const attachPolicyParams = {
      PolicyDocument: `{
        "Version": "2012-10-17",
        "Statement": [{
          "Action": ["iot:Connect", "iot:Subscribe", "iot:Publish", "iot:Receive"],
          "Resource": "*",
          "Effect": "Allow"
        }]
      }`,
      PolicyName: roleName,
      RoleName: roleName
    };

    // add iot policy
    iam.putRolePolicy(attachPolicyParams, (err, data) => {
      if (err) console.log(err, err.stack);
      else     console.log(`Finished creating IoT Role: ${roleName}`);          
    });
  });
});

Serverless Framework

要完成剩下的内容,我们创建一个 Lambda 函数,它将生成临时密钥(有效期为1小时)以连接到 IoT 服务。我们将使用 Serverless Framework 来帮助您。如果你不知道如何使用它,你可以看看我创建的另一个教程。

serverless.yml 必须为 iot:DescribeEndpoint(找到您的帐户端点)添加 Lambda 权限和 sts:AssumeRole(创建临时密钥)。

service: serverless-notifications

provider:
  name: aws
  runtime: nodejs4.3
  stage: dev
  region: us-east-1
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - 'iot:DescribeEndpoint'
      Resource: "*"
    - Effect: "Allow"
      Action:
        - 'sts:AssumeRole'
      Resource: "*"

functions:
  auth:
    handler: handler.auth
    events:
      - http: GET iot/keys
    memorySize: 128
    timeout: 10

package:
  exclude:
    - .git/**
    - create-role/**
    - frontend/**
    - iot/**

Lambda 函数:

'use strict';

const AWS = require('aws-sdk');
const iot = new AWS.Iot();
const sts = new AWS.STS();
const roleName = 'serverless-notifications';

module.exports.auth = (event, context, callback) => {

    // get the endpoint address
    iot.describeEndpoint({}, (err, data) => {
        if (err) return callback(err);

        const iotEndpoint = data.endpointAddress;
        const region = getRegion(iotEndpoint);

        // get the account id which will be used to assume a role
        sts.getCallerIdentity({}, (err, data) => {
            if (err) return callback(err);

            const params = {
                RoleArn: `arn:aws:iam::${data.Account}:role/${roleName}`,
                RoleSessionName: getRandomInt().toString()
            };

            // assume role returns temporary keys
            sts.assumeRole(params, (err, data) => {
                if (err) return callback(err);

                const res = buildResponseObject(iotEndpoint,
                                                region,
                                                data.Credentials.AccessKeyId,
                                                data.Credentials.SecretAccessKey,
                                                data.Credentials.SessionToken);
                callback(null, res);
            });
        });
    });
};

const buildResponseObject = (iotEndpoint, region, accessKey, secretKey, sessionToken) => {
    return {
        statusCode: 200,
        headers: {
            'Access-Control-Allow-Origin': '*'
        },  
        body: JSON.stringify({
            iotEndpoint: iotEndpoint,
            region: region,
            accessKey: accessKey,
            secretKey: secretKey,
            sessionToken: sessionToken
        })
    };
};

const getRegion = (iotEndpoint) => {
    const partial = iotEndpoint.replace('.amazonaws.com', '');
    const iotIndex = iotEndpoint.indexOf('iot');
    return partial.substring(iotIndex + 4);
};

// Get random Int
const getRandomInt = () => {
    return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
};

测试

测试

提升

此示例认为只有一个主题。没关系,每个人都订阅了同一个频道。但是,如果要向特定用户发送私人通知,则需要为每个用户创建一个新主题。

为了处理权限,我建议您继续创建一个角色,以访问所有主题(就像我们在 create-role 项目中所做的那样),并使用 putsRole 命令(Lambda 内部)创建具有特定主题的受限访问的密钥。 这个限制是将策略文档作为一个将来的参数传递给参数(参见 docs)。

对于经过身份验证的用户,我建议您使用 Cognito 凭据。

Cognito 示例

1).创建 Identity Pool

const AWS = require('aws-sdk');
const cognitoidentity = new AWS.CognitoIdentity();

const params = {
  AllowUnauthenticatedIdentities: true,
  IdentityPoolName: 'serverless-notifications'  
};

cognitoidentity.createIdentityPool(params, (err, data) => {
  if (err) console.log(err, err.stack);
  else     console.log(data.IdentityPoolId); // save the IdentityPoolId
});

2).为我们以前做过的这个身份池创建一个角色。但是,对于 AssumeRolPolicyDocument,请使用以下 JSON(将 IdentityPoolId 设置为上一个函数返回的值)。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "cognito-identity.amazonaws.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "cognito-identity.amazonaws.com:aud": ${IdentityPoolId}
        },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "unauthenticated"
        }
      }
    }
  ]
}

3).使用 Identity Pool 设置新的 Role。

const AWS = require('aws-sdk');
const cognitoidentity = new AWS.CognitoIdentity();

const params = {
  IdentityPoolId: 'IDENTITY_POOL_ID',
  Roles: { 
    unauthenticated: 'ROLE_ARN',
    authenticated: 'ROLE_ARN'
  }
};

cognitoidentity.setIdentityPoolRoles(params, (err, data) => {
  if (err) console.log(err, err.stack);
  else     console.log(data); // successful response returns an empty object
});

4).在浏览器里,请求 Congnito 凭证

const AWS = require('aws-sdk');

AWS.config.region = 'YOUR_COGNITO_REGION';
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
    IdentityPoolId: 'IDENTITY_POOL_ID',
});

AWS.config.credentials.get(() => {

    // Use these credentials with IoT
    const accessKeyId = AWS.config.credentials.accessKeyId;
    const secretAccessKey = AWS.config.credentials.secretAccessKey;
    const sessionToken = AWS.config.credentials.sessionToken;
});

原文链接:https://zanon.io/posts/serverless-notifications-on-aws

尚未评分
您的评分将帮助我们做出更好的玩法

观光\评论区

Copyright © 2017 玩点什么. All Rights Reserved.