简介

软件即服务(SaaS)形式中的一个要害实体是租户–也就是任何注册运用特定服务的客户。

租户这个词来自于租赁物理产业的概念。租户付出租金以占有一个空间,但他们并不具有该产业。同样地,对于SaaS产品,客户付出费用以取得服务,但他们并不具有供给服务的软件。

有各种租赁形式可供挑选,即。

  1. 单租户:为每个客户布置一个运用程序的单一专用实例
  2. 多租户:为一切客户布置一个运用程序的实例,并在他们之间同享
  3. 混合租户:运用程序的一个或多个部分被布置为每个客户专用,其他部分在一切客户之间同享。

利用我在之前两篇文章(下面有链接)中所触及的原则,咱们将专注于多租户形式,并运用AWS树立一个多租户SaaS运用程序,并运用一个多租户数据库。咱们将运用以下AWS资源。

  • Cognito作为认证服务
  • 用于GraphQL API的AppSync
  • DynamoDB作为数据库

架构

不管你的经验水平怎么,在检查代码库的同时企图弄清楚一个运用程序是怎么工作的都是一项乏味的使命。由于人类更容易与视觉内容产生联系,我起草了以下架构图,以显示咱们的待办事项运用程序将怎么工作。

用React前端构建一个多租户的Amplify应用

多租户待办事项运用架构图。

理论上,React客户端包括运用Amplify库执行的登录功用。一旦用户成功注册,Cognito的承认后触发器就会执行一个Lambda函数,接收一个包括新注册用户信息的有效载荷。

Lambda代码将新创立的用户保存到DynamoDB,这样咱们就能够在运用Cognito进行授权的同时将一切新创立的用户资料保存在DynamoDB下。DynamoDB项将有以下结构。

Item: {
    createdAt: {
        S: "timestamp here",
    },
    updatedAt: {
        S: "timestamp here",
    },
    typeName: { S: "USER" },
    id: { S: "unique id" },
    cognitoId: { S: "cognito id gotten from the post confirmation trigger payload" },
    email: { S: "user email"},
    phoneNumber: { S: "user phone number" },
}

当新用户登录时,他们将能够拜访React前端的AppSync GraphQL API,这答应对待办事项进行CRUD操作。创立的项目运用AppSync中创立的映射模板保存到DynamoDB。这些能够完成从办法恳求到相应的集成恳求的有效载荷的映射,以及从集成呼应到相应的办法呼应的映射。

数据库规划

多租户数据库的形式必须有一个或多个租户标识符列,以便能够有挑选地检索任何特定租户的数据。为此,咱们能够运用DynamoDB供给的单表规划,以完成咱们树立多租户数据库的目标,用一个复合主键作为唯一的标识符。

DynamoDB有两种不同的主键,即分区键和复合主键(分区键和排序键)。咱们将界说一个复合主键,id 作为分区键,typeName 作为排序键。

DynamoDB并不完全是处理联系型数据的首选方案,但正如Alex DeBrie在DynamoDB中建模一对多联系的文章所描述的那样。

DynamoDB有时被认为只是一个简单的键值存储,但事实并非如此。DynamoDB能够处理复杂的拜访形式,从高度联系型数据模型到时刻序列数据甚至地舆空间数据。

在咱们的事例中,有一种一对多的联系,一个User ,能够具有许多ToDo

关于代码

现在咱们现已涵盖了文章的理论部分,咱们能够进入代码了。

正如在介绍中说到的,咱们将运用咱们在前两篇文章中所学到的常识,为咱们的运用想出一个实在国际的比如。为了避免重复,我只包括了咱们将在本文中增加的新功用,而省掉了一些在以前的文章中现已触及的部分。

项目设置

在你的首选目的地为咱们的项目增加一个新文件夹,并创立一个新的无服务器项目,名为backend 。然后,在同一目录下运用Create React App引导一个React运用,并将其称为client 。这样就形成了以下的目录结构。

$ tree . -L 2 -a
.
├── backend
└── client

导航到serverless文件夹并装置这些依赖项。

$ yarn add serverless-appsync-plugin serverless-stack-output serverless-pseudo-parameters serverless-webpack

当仍在backend 文件夹内时,创立一个schema.yml 文件并增加以下形式。

type ToDo {
  id: ID
  description: String!
  completed: Boolean
  createdAt: AWSDateTime
  updatedAt: AWSDateTime
  user: User
}
type User {
  id: ID
  cognitoId: ID!
  firstName: String
  lastName: String
  email: AWSEmail
  phoneNumber: AWSPhone
  createdAt: AWSDateTime
  updatedAt: AWSDateTime
}
input ToDoUpdateInput {
  id: ID!
  description: String
  completed: Boolean
}
type Mutation {
  createTodo(input: ToDoCreateInput): ToDo
  updateTodo(input: ToDoUpdateInput): ToDo
  deleteTodo(id: ID!): ToDo
}
type Query {
  listToDos: [ToDo!]
  listUserTodos(id: ID): [ToDo!]
  getToDo(id: ID): ToDo
  profile: User!
}
schema {
  query: Query
  mutation: Mutation
}

装备和创立咱们的无服务器资源

DynamoDB

在一个名为resources 的文件夹内创立一个新文件。

$ mkdir resources && touch resources/dynamo-table.yml

翻开该文件并增加以下CloudFormation模板,该模板界说了咱们的DynamoDB装备。

Resources:
  PrimaryDynamoDBTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions: 
        - AttributeName: typeName
          AttributeType: S
        - AttributeName: id
          AttributeType: S
      KeySchema: # Hash Range key
        - AttributeName: typeName
          KeyType: HASH
        - AttributeName: id
          KeyType: RANGE
      BillingMode: PAY_PER_REQUEST
      TableName: ${self:custom.resources.PRIMARY_TABLE}
      TimeToLiveSpecification:
        AttributeName: TimeToLive,
        Enabled: True
      GlobalSecondaryIndexes:
        - IndexName: GSI1
          KeySchema:
            - AttributeName: typeName
              KeyType: HASH
          Projection:
            ProjectionType: ALL

Cognito用户池

在资源文件夹内为Cognito用户池创立一个新的装备文件。

$ mkdir resources && touch resources/cognito-userpool.yml

翻开该文件并增加以下CloudFormation模板,它界说了用户池装备。

Resources:
  CognitoUserPoolToDoUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: FALSE
      AutoVerifiedAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 7
          RequireLowercase: True
          RequireNumbers: True
          RequireSymbols: True
          RequireUppercase: True
      Schema:
        - Name: email
          AttributeDataType: String
          Mutable: false
          Required: true
        - Name: phone_number
          Mutable: true
          Required: true
      UserPoolName: ${self:service}-${self:provider.stage}-user-pool
  CognitoUserPoolClient:
    Type: "AWS::Cognito::UserPoolClient"
    Properties:
      ClientName: ${self:service}-${self:provider.stage}-user-pool-client
      GenerateSecret: false
      UserPoolId:
        Ref: CognitoUserPoolToDoUserPool
Outputs:
  UserPoolId:
    Value:
      Ref: CognitoUserPoolToDoUserPool
  UserPoolClientId:
    Value:
      Ref: CognitoUserPoolClient

映射模板

下面,我将分化在先前构建的待办事项运用中增加授权的新功用。你能够在这里检查其他的映射模板,由于它们是不言自明的。

create_todo.vtl

回顾咱们的形式,待办事项有一个叫做user 的字段,它将包括具有该事项的用户的Cognito ID。咱们从identity 对象中取得id ,该对象是用户的Cognito装备文件。

创立映射模板文件。

$ mkdir mapping-templates/create_todo && touch mapping-templates/create_todo/request.vtl

增加以下代码。

$util.qr($ctx.args.input.put("createdAt", $util.time.nowISO8601()))
$util.qr($ctx.args.input.put("updatedAt", $util.time.nowISO8601()))
{
  "version" : "2017-02-28",
  "operation" : "PutItem",
  "key" : {
    "id": $util.dynamodb.toDynamoDBJson($util.autoId()),
    "typeName": $util.dynamodb.toDynamoDBJson("TODO"),
    "user" : { "S" : "${context.identity.sub}" }
  },
  "attributeValues" : $util.dynamodb.toMapValuesJson($ctx.args.input)
}

get_user_todos.vtl

创立映射模板文件。

$ mkdir mapping-templates/get_user_todos && touch mapping-templates/get_user_todos/request.vtl

增加下面的代码。

{
  "version" : "2017-02-28",
  "operation" : "GetItem",
  "key" : {
    "id" : { "S" : "${context.source.user}" },
    "typeName": $util.dynamodb.toDynamoDBJson("USER")
  },
}

list_user_todos.vtl

再一次,创立映射模板文件。

$ mkdir mapping-templates/list_user_todos && touch mapping-templates/list_user_todos/request.vtl

并增加下面的代码。

{
  "version" : "2017-02-28",
  "operation" : "Query",
  "query" : {
    "expression": "#typeName = :typeName",
    "expressionNames": {
      "#typeName": "typeName"
    },
    "expressionValues" : {
      ":typeName" : $util.dynamodb.toDynamoDBJson("TODO")
    }
  },
  "filter": {
    "expression": "#user = :user",
    "expressionNames": {
      "#user": "user"
    },
    "expressionValues": {
      ":user" : { "S" : "${context.identity.sub}" }
    }
  },
}

由于咱们在UserToDo 项目之间有一对多的联系,为了取得一切由特定用户创立的待办事项,咱们运用Query 办法取得数据库中的一切项目,然后过滤这些项目,返回包括与用户的Cognito ID相同的用户属性的待办事项。

Lambda函数

接下来,咱们将设置Lambda函数,担任将一个新注册的用户保存到DynamoDB。当用户承认了他们的邮件后,Cognito的post confirmation触发器被调用时,这个函数就会被执行。

创立该文件。

$ touch handler.ts

增加以下代码。

import * as moment from "moment";
import { v4 as uuidv4 } from "uuid";
import { DynamoDB } from "aws-sdk";
const ddb = new DynamoDB({ apiVersion: "2012-10-08" });
export const cognitoPostConfirmation = async (event, context, callback) => {
  try {
    const userParams = {
      TableName: process.env.PRIMARY_TABLE, // gotten from serverless deployment
      Item: {
        createdAt: {
          S: moment().format("YYYY-MM-DDThh:mm:ssZ"),
        },
        updatedAt: {
          S: moment().format("YYYY-MM-DDThh:mm:ssZ"),
        },
        typeName: { S: "USER" },
        id: { S: uuidv4() },
        cognitoId: { S: event.request.userAttributes.sub },
        email: { S: event.request.userAttributes.email },
        phoneNumber: { S: event.request.userAttributes.phone_number },
      },
    };
    // @ts-ignore
    await ddb.putItem(userParams).promise();
    return callback(null, event);
  } catch (error) {
    return callback(error);
  }
};

增加TypeScript支撑

由于咱们为Lambda函数创立了一个.ts 文件,因而咱们需求通过创立一个tsconfig.json 文件和一个webpack.config.js 文件来为无服务器项目增加TypeScript支撑。

$ touch tsconfig.json webpack.config.js
//tsconfig.json
{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "module": "commonjs",
    "removeComments": false,
    "preserveConstEnums": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "lib": ["esnext"]
  }
}
//webpack.config.js
const slsw = require("serverless-webpack");
const nodeExternals = require("webpack-node-externals");
module.exports = {
  entry: slsw.lib.entries,
  target: "node",
  // Generate sourcemaps for proper error messages
  devtool: "source-map",
  // Since "aws-sdk" is not compatible with webpack,
  // we exclude all node dependencies
  externals: [nodeExternals()],
  mode: slsw.lib.webpack.isLocal ? "development" : "production",
  optimization: {
    // We no not want to minimize our code.
    minimize: false,
  },
  performance: {
    // Turn off size warnings for entry points
    hints: false,
  },
  resolve: {
    extensions: [".ts"],
  },
  // Run babel on all .js files and skip those in node_modules
  module: {
    rules: [
      {
        test: /\.ts(x?)$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "ts-loader",
          },
        ],
      },
    ],
  },
};

布置无服务器项目

现在咱们现已完成了一切资源的创立,咱们将把一切东西集中起来,并按如下方式增加到serverless.yml 文件中。

service: react-amplify-multi-tenant
app: amplify-multi-tenant
frameworkVersion: "2"
provider:
  name: aws
  runtime: nodejs12.x
  lambdaHashingVersion: 20201221
  region: eu-west-1 
  stage: ${opt:stage, 'dev'}
  environment:
    PRIMARY_TABLE: ${self:custom.resources.PRIMARY_TABLE}
plugins:
  - serverless-appsync-plugin
  - serverless-stack-output
  - serverless-pseudo-parameters
  - serverless-webpack
custom:
  webpack:
    webpackConfig: ./webpack.config.js # typescript support
    includeModules: true
  resources:
    PRIMARY_TABLE: ${self:service}-dynamo-table-${self:provider.stage}
    PRIMARY_BUCKET: ${self:service}-primary-bucket-${self:provider.stage}
    WEB_HOSTING_BUCKET: ${self:service}-web-hosting-bucket-${self:provider.stage}
  output:
    handler: ./scripts/output.handler
    file: ../client/src/aws-exports.json
  appSync: # appsync plugin configuration
    name: ${self:service}-appsync-${self:provider.stage}
    authenticationType: AMAZON_COGNITO_USER_POOLS
    additionalAuthenticationProviders:
      - authenticationType: API_KEY
    dataSources:
      - type: AMAZON_DYNAMODB
        name: PrimaryTable
        description: "Primary Table"
        config:
          tableName: ${self:custom.resources.PRIMARY_TABLE}
          serviceRoleArn: { Fn::GetAtt: [AppSyncDynamoDBServiceRole, Arn] }
    userPoolConfig:
      awsRegion: ${self:provider.region}
      defaultAction: ALLOW
      userPoolId: { Ref: CognitoUserPoolToDoUserPool } # name of the resource
    logConfig:
      loggingRoleArn: { Fn::GetAtt: [AppSyncLoggingServiceRole, Arn] }
      level: ALL
    mappingTemplates:
      - dataSource: PrimaryTable
        type: Mutation
        field: createTodo
        request: "create_todo/request.vtl"
        response: "common-item-response.vtl"
      - dataSource: PrimaryTable
        type: Mutation
        field: updateTodo
        request: "update_todo/request.vtl"
        response: "common-item-response.vtl"
      - dataSource: PrimaryTable
        type: Mutation
        field: deleteTodo
        request: "delete_todo/request.vtl"
        response: "common-item-response.vtl"
      - dataSource: PrimaryTable
        type: Query
        field: getToDo
        request: "get_todo/request.vtl"
        response: "common-item-response.vtl"
      - dataSource: PrimaryTable
        type: Query
        field: getUser
        request: "get_user/request.vtl"
        response: "common-item-response.vtl"
      - dataSource: PrimaryTable
        type: Query
        field: listUserTodos
        request: "list_user_todos/request.vtl"
        response: "common-items-response.vtl"
      - dataSource: PrimaryTable
        type: ToDo
        field: user
        request: "get_todo_user/request.vtl"
        response: "common-item-response.vtl"
functions:
  cognitoPostConfirmation:
    handler: handler.cognitoPostConfirmation
    events: # cognito post confirmation trigger
      - cognitoUserPool:
          pool: CognitoUserPoolToDoUserPool
          trigger: PostConfirmation
resources:
  - ${file(./resources/appsync-dynamo-role.yml)}
  - ${file(./resources/dynamo-table.yml)}
  - ${file(./resources/web-hosting-bucket.yml)}
  - ${file(./resources/cognito-userpool.yml)}

然后咱们再进行布置。

$ sls deploy --stage=dev

构建前端客户端

现在,咱们的后端现已悉数准备就绪并布置完毕,咱们将着手开发前端客户端,以展现上述逻辑是怎么拼凑起来的。

咱们将运用Ant Design来制作UI组件,为了验证用户暗码,咱们将运用一个暗码验证器。咱们在设置用户池的时分加入了暗码要求,应该是这样的。

  • 至少有八个字符
  • 至少一个大写字母
  • 至少一个小写字母
  • 至少一个符号
  • 至少一个数字

在成功验证了一切需求的用户细节后,咱们将有效载荷发送到Cognito API,它将向用户的电子邮件发送一个验证码,并在UserPool 中创立一个新用户。

  const onFinish = (values: any) => {
    const { firstName, lastName, email, phoneNumber, password } = values;
    // hide loader
    toggleLoading(false);
    Auth.signUp({
      username: email,
      password,
      attributes: {
        email,
        name: `${firstName} ${lastName}`,
        phone_number: phoneNumber,
      },
    })
      .then(() => {
        notification.success({
          message: "Successfully signed up user!",
          description:
            "Account created successfully, Redirecting you in a few!",
          placement: "topRight",
          duration: 1.5,
          onClose: () => {
            updateUsername(email);
            toggleRedirect(true);
          },
        });
      })
      .catch((err) => {
        notification.error({
          message: "Error",
          description: "Error signing up user",
          placement: "topRight",
          duration: 1.5,
        });
        toggleLoading(false);
      });
  };

导航到注册道路并创立一个新的用户。

用React前端构建一个多租户的Amplify应用

用户注册页面。

检查你的电子邮件是否有新的承认码,并按如下方式增加。

用React前端构建一个多租户的Amplify应用

输入电子邮件承认码。

验证后,你的用户池现在应该在用户和组下有一个新用户的列表。

用React前端构建一个多租户的Amplify应用

Cognito用户池。

当一个新用户注册时,咱们设置的承认后触发器会收到一个包括用户注册数据的有效载荷,然后咱们将其作为用户记载保存到DynamoDB。翻开你的AWS控制台,导航到DynamoDB,并挑选新创立的表。你应该有一个新的用户记载,其中包括注册过程中的细节。

用React前端构建一个多租户的Amplify应用

接下来,你现在能够运用新的凭证登录,之后你将被重定向到仪表板页面,在那里你能够创立、编辑和删去新的待办事项。由于这篇文章是为了演示,咱们将增加一个包括一切CRUD逻辑的组件文件。

const DataList = () => {
  const [description, updateDescription] = React.useState("");
  const [updateToDoMutation] = useMutation(updateToDo);
  const [createToDoMutation] = useMutation(createToDo);
  const [deleteToDoMutation] = useMutation(deleteToDo);
  const { loading, error, data } = useQuery(listUserToDos);
  function handleCheck(event: CheckboxChangeEvent, item: ToDo) {
    updateToDoMutation({
      variables: { input: { completed, id: item.id } },
      refetchQueries: [
        {
          query: listUserToDos,
        },
      ],
    })
      .then((res) => message.success("Item updated successfully"))
      .catch((err) => {
        message.error("Error occurred while updating item");
      });
  }
  function handleSubmit(event: React.FormEvent) {
    event.preventDefault();
    createToDoMutation({
      variables: { input: { description } },
      refetchQueries: [
        {
          query: listUserToDos,
        },
      ],
    })
      .then((res) => message.success("Item created successfully"))
      .catch((err) => {
        message.error("Error occurred while creating item");
      });
  }
  function handleKeyPress(event: React.KeyboardEvent) {
    if (event.key === "Enter") {
      // user pressed enter
      createToDoMutation({
        variables: { input: { description } },
        refetchQueries: [
          {
            query: listUserToDos,
          },
        ],
      })
        .then((res) => {
          message.success("Item created successfully");
        })
        .catch((err) => {
          message.error("Error occurred while creating item");
        });
    }
  }
  function handleDelete(item: ToDo) {
    deleteToDoMutation({
      variables: { id: item.id },
      refetchQueries: [
        {
          query: listUserToDos,
        },
      ],
    })
      .then((res) => {
        message.success("Deleted successfully");
      })
      .catch((err) => {
        message.error("Error occurred while deleting item");
      });
  }
  if (loading) {
    return (
      <CenterContent>
        <LoadingOutlined style={{ fontSize: 50 }} spin />
      </CenterContent>
    );
  }
  if (error) {
    return <div>{`Error! ${error.message}`}</div>;
  }
  return (
    <ListContainer>
      <List
        header={
          <div style={{ display: "flex" }}>
            <Input
              placeholder="Enter todo name"
              value={description}
              onChange={(event) => updateDescription(event.target.value)}
              style={{ marginRight: "10px" }}
              onKeyDown={handleKeyPress}
            />
            <Button name="add" onClick={handleSubmit}>
              add
            </Button>
          </div>
        }
        bordered
        dataSource={data.listUserTodos}
        renderItem={(item: ToDo) => (
          <List.Item>
            <Checkbox
              checked={item.completed}
              onChange={(event: CheckboxChangeEvent) =>
                handleCheck(event, item)
              }
            >
              {item.description}
            </Checkbox>
            <Popconfirm
              title="Are you sure to delete this item?"
              onConfirm={() => handleDelete(item)}
              okText="Yes"
              cancelText="No"
            >
              <DeleteAction>Delete</DeleteAction>
            </Popconfirm>
          </List.Item>
        )}
      />
    </ListContainer>
  );
};

现在,增加一个新的项目。

用React前端构建一个多租户的Amplify应用

增加新待办事项的仪表板。

导航到DynamoDB仪表板,检查新创立的待办事项。由于咱们的数据库运用的是单表规划,所以用户和待办事项记载都存储在同一个表中,如下图所示。

用React前端构建一个多租户的Amplify应用

为了测试上述运用程序的多租户形式,请导航到你的终端,用不同的阶段称号布置一个新的实例。该布置将供给独立的新资源,有一个新的数据库和Cognito用户池。

$ sls deploy --stage=new_stage_name

总结

我期望你喜欢这篇文章,并期望你能学到一些新的东西。正如所展现的那样,构建一个多租户运用是适当具有挑战性的,由于没有一个放之四海而皆准的办法;它需求大量的预先计划和挑选最适合你的解决方案。

为了保持文章的简短和可读性,我不得不省掉了一些代码,但你能够在这里检查repo,如果有什么地方不符合你的期望,请提出问题,我会花时刻去研究它。编码愉快

The postBuilding a multi-tenant Amplify app with a React frontendappeared first onLogRocket Blog.