Introdução

Uma das principais entidades no modelo de software como serviço (SaaS) é o locatário, ou seja, qualquer cliente que se inscreve para usar o serviço fornecido.

O termo inquilino deriva do conceito de aluguel de propriedade física. Os inquilinos pagam aluguel para ocupar um espaço, mas não são proprietários do imóvel. Da mesma forma, com produtos SaaS, os clientes pagam para ter acesso ao serviço, mas não são donos do software que oferece o serviço.

Existem vários modelos de locação disponíveis, a saber:

  1. Único locatário : uma única instância dedicada de um aplicativo é implantada para cada cliente
  2. Multi-tenant : uma única instância do aplicativo é implantada para todos os clientes e compartilhada entre eles
  3. Locatário misto : uma ou mais partes de um aplicativo são implantadas como dedicadas para cada cliente e o restante é compartilhado entre todos os clientes

Usando os princípios que abordei em duas postagens anteriores (com links abaixo), vamos nos concentrar no modelo multilocatário e usar a AWS para configurar um aplicativo SaaS multilocatário com um único banco de dados multilocatário. Usaremos os seguintes recursos da AWS:

Arquitetura

Olhar para um repositório de código enquanto tenta descobrir como um aplicativo funciona é uma tarefa entediante, independentemente do seu nível de experiência. E, como os humanos se relacionam muito mais facilmente com o conteúdo visual, esbocei o seguinte diagrama de arquitetura para mostrar como nosso aplicativo de tarefas funcionará:

Diagrama de nosso aplicativo de tarefas multilocatário
Multilocação para-fazer o diagrama da arquitetura do aplicativo.

Em teoria, o cliente React contém a funcionalidade de login que é executada usando a biblioteca Amplify. Depois que um usuário é registrado com sucesso, o gatilho de pós-confirmação do Cognito executa uma função Lambda que recebe uma carga contendo as informações sobre o usuário recém-inscrito.

O código Lambda salva o usuário recém-criado no DynamoDB, permitindo-nos armazenar todos os perfis de usuário recém-criados no DynamoDB ao usar o Cognito para autorização. O item DynamoDB terá a seguinte estrutura:

 Item: { criado em: { S:"carimbo de data/hora aqui", }, updatedAt: { S:"carimbo de data/hora aqui", }, typeName: {S:"USER"}, id: {S:"id único"}, cognitoId: {S:"cognito id obtido da carga útil do gatilho pós-confirmação"}, e-mail: {S:"e-mail do usuário"}, phoneNumber: {S:"número de telefone do usuário"},
} 

Quando o novo usuário fizer login, ele terá acesso à API AppSync GraphQL no front-end React, que permite operações CRUD em itens de tarefas. Os itens criados são salvos no DynamoDB usando modelos de mapeamento criados no AppSync. Eles permitem o mapeamento da carga útil da solicitação de método para a solicitação de integração correspondente e de uma resposta de integração para a resposta de método correspondente.

Projeto de banco de dados

O esquema de um banco de dados multilocatário deve ter uma ou mais colunas de identificador de locatário para que os dados de qualquer locatário possam ser recuperados seletivamente. Para tanto, podemos usar o design de mesa única que o DynamoDB oferece para atingir nosso objetivo de configurar um banco de dados multilocatário com uma chave primária composta como identificador exclusivo.

O DynamoDB tem dois tipos diferentes de chaves primárias , ou seja, chave de partição e chave primária composta (chave de partição e chave de classificação). Definiremos uma chave primária composta com id como a chave de partição e typeName como a chave de classificação.

O DynamoDB não é exatamente a solução ideal para lidar com dados relacionais, mas conforme descrito no artigo de Alex DeBrie em modelagem de relacionamentos um para muitos no DynamoDB :

Às vezes, o DynamoDB é considerado apenas um armazenamento de valor-chave simples, mas nada poderia estar mais longe da verdade. O DynamoDB pode lidar com padrões de acesso complexos, de modelos de dados altamente relacionais a dados de séries temporais ou até mesmo dados geoespaciais.

Em nosso caso, há uma relação um-para-muitos em que um Usuário pode possuir muitos itens ToDo .

Ligado ao código

Agora que cobrimos a parte teórica do artigo, podemos prosseguir para o código.

Conforme mencionado na introdução, usaremos o que aprendemos em meus dois artigos anteriores para criar um exemplo do mundo real para nosso aplicativo. Para evitar a duplicação, incluí apenas novas funcionalidades que adicionaremos neste artigo e omiti algumas partes que já foram abordadas nos artigos anteriores.

Configuração do projeto

Adicione uma nova pasta para nosso projeto em seu destino preferido e crie um novo projeto sem servidor chamado backend . Em seguida, inicialize um aplicativo React usando Criar aplicativo React no mesmo diretório e chame-o de cliente . Isso resulta na seguinte estrutura de diretório:

 $ árvore.-L 2-a
.
├── backend
└── cliente 

Navegue até a pasta sem servidor e instale estas dependências:

 $ yarn add serverless-appsync-plugin serverless-stack-output serverless-pseudo-parâmetros serverless-webpack 

Ainda dentro da pasta backend , crie um arquivo schema.yml e adicione o seguinte esquema:

 tipo ToDo { Eu fiz descrição: String! completado: booleano createdAt: AWSDateTime updatedAt: AWSDateTime usuário: usuário
} tipo User { Eu fiz cognitoId: ID! firstName: String lastName: String email: AWSEmail phoneNumber: AWSPhone createdAt: AWSDateTime updatedAt: AWSDateTime
} input ToDoUpdateInput { Eu fiz! descrição: String completado: booleano
} type Mutation { createTodo (input: ToDoCreateInput): ToDo updateTodo (input: ToDoUpdateInput): ToDo deleteTodo (id: ID!): ToDo
} tipo Query { listToDos: [ToDo!] listUserTodos (id: ID): [ToDo!] getToDo (id: ID): ToDo perfil: usuário!
} schema { consulta: consulta mutação: mutação
} 

Provisionando e criando nossos recursos sem servidor

DynamoDB

Crie um novo arquivo dentro de uma pasta chamada recursos :

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

Abra o arquivo e adicione o seguinte modelo CloudFormation, que define nossa configuração do DynamoDB:

Recursos

: PrimaryDynamoDBTable: Tipo: AWS:: DynamoDB:: Table Propriedades: AttributeDefinitions: -AttributeName: typeName AttributeType: S -AttributeName: id AttributeType: S KeySchema: # Chave de intervalo de hash -AttributeName: typeName KeyType: HASH -AttributeName: id KeyType: RANGE BillingMode: PAY_PER_REQUEST Nome da tabela: $ {self: custom.resources.PRIMARY_TABLE} TimeToLiveSpecification: Nome do atributo: TimeToLive, Habilitado: Verdadeiro GlobalSecondaryIndexes: -IndexName: GSI1 KeySchema: -AttributeName: typeName KeyType: HASH Projeção: ProjectionType: ALL 

Pool de usuários do Cognito

Crie um novo arquivo de configuração para o pool de usuários do Cognito dentro da pasta de recursos:

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

Abra o arquivo e adicione o seguinte modelo CloudFormation, que define a configuração do pool de usuários:

Recursos

: CognitoUserPoolToDoUserPool: Digite: AWS:: Cognito:: UserPool Propriedades: AdminCreateUserConfig: AllowAdminCreateUserOnly: FALSE AutoVerifiedAttributes: -o email Políticas: PasswordPolicy: Comprimento mínimo: 7 RequireLowercase: True RequireNumbers: True RequireSymbols: True RequireUppercase: True Esquema: -Nome: email AttributeDataType: String Mutável: falso Requerido: verdadeiro -Nome: phone_number Mutável: verdadeiro Requerido: verdadeiro UserPoolName: $ {self: service}-$ {self: provider.stage}-user-pool CognitoUserPoolClient: Digite:"AWS:: Cognito:: UserPoolClient" Propriedades: Nome do cliente: $ {self: service}-$ {self: provider.stage}-user-pool-client GenerateSecret: false UserPoolId: Ref: CognitoUserPoolToDoUserPool
Saídas: UserPoolId: Valor: Ref: CognitoUserPoolToDoUserPool UserPoolClientId: Valor: Ref: CognitoUserPoolClient 

Modelos de mapeamento

A seguir, detalharei a nova funcionalidade que vem com a adição de autorização ao aplicativo de tarefas criado anteriormente. Você pode verificar o restante dos modelos de mapeamento aqui desde eles são bastante autoexplicativos.

create_todo.vtl

Olhando para trás em nosso esquema, o item de tarefa tem um campo chamado usuário , que conterá o Cognito ID do usuário que possui o item. Obtemos o id do objeto identidade , que é o perfil Cognito do usuário.

Crie o arquivo de modelo de mapeamento:

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

Adicione o seguinte código:

 $ util.qr ($ ctx.args.input.put ("createdAt", $ util.time.nowISO8601 ()))
$ util.qr ($ ctx.args.input.put ("updatedAt", $ util.time.nowISO8601 ()))
{ "versão":"28/02/2017", "operação":"PutItem", "chave": { "id": $ util.dynamodb.toDynamoDBJson ($ util.autoId ()), "typeName": $ util.dynamodb.toDynamoDBJson ("TODO"), "usuário": {"S":"$ {context.identity.sub}"} }, "attributeValues": $ util.dynamodb.toMapValuesJson ($ ctx.args.input)
} 

get_user_todos.vtl

Crie o arquivo de modelo de mapeamento:

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

Adicione o seguinte código:

 { "versão":"28/02/2017", "operação":"GetItem", "chave": { "id": {"S":"$ {context.source.user}"}, "typeName": $ util.dynamodb.toDynamoDBJson ("USER") },
} 

list_user_todos.vtl

Mais uma vez, crie o arquivo de modelo de mapeamento:

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

E adicione o seguinte código:

 { "versão":"28/02/2017", "operação":"Consulta", "inquerir": { "expression":"#typeName=: typeName", "expressionNames": { "#typeName":"typeName" }, "expressionValues": { ": typeName": $ util.dynamodb.toDynamoDBJson ("TODO") } }, "filtro": { "expressão":"# usuário=: usuário", "expressionNames": { "#user":"usuário" }, "expressionValues": { ": usuário": {"S":"$ {context.identity.sub}"} } },
} 

Porque temos uma relação um-para-muitos entre os itens User e ToDo , a fim de obter todos os itens de tarefas que foram criados por um determinado usuário, obtemos todos os itens no banco de dados usando o método Query e, em seguida, filtramos os itens e retornamos itens de tarefas que contêm o mesmo atributo de usuário que o ID do Cognito do usuário.

Função Lambda

A seguir, configuraremos a função Lambda responsável por salvar um usuário recém-inscrito no DynamoDB. A função é executada quando o gatilho de confirmação de postagem do Cognito é chamado depois que o usuário confirma seu e-mail.

Crie o arquivo:

 $ touch handler.ts 

Adicione o seguinte código:

 importar * como momento a partir de"momento";
importar {v4 como uuidv4} de"uuid";
importar {DynamoDB} de"aws-sdk"; const ddb=novo DynamoDB ({apiVersion:"2012-10-08"}); export const cognitoPostConfirmation=async (evento, contexto, retorno de chamada)=> { tentar { const userParams={ TableName: process.env.PRIMARY_TABLE,//obtido da implantação sem servidor Item: { criado em: { S: momento (). Formato ("AAAA-MM-DDThh: mm: ssZ"), }, updatedAt: { S: momento (). Formato ("AAAA-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 esperar ddb.putItem (userParams).promise (); retorno de chamada de retorno (nulo, evento); } catch (erro) { retorno de chamada de retorno (erro); }
}; 

Adicionar suporte TypeScript

Como criamos um arquivo .ts para nossa função Lambda, precisamos adicionar suporte TypeScript ao projeto sem servidor criando um arquivo tsconfig.json e um arquivo webpack.config.js :

 $ touch tsconfig.json webpack.config.js 
//tsconfig.json { "compilerOptions": { "allowSyntheticDefaultImports": true, "módulo":"commonjs", "removeComments": falso, "preserveConstEnums": true, "sourceMap": verdadeiro, "skipLibCheck": verdadeiro, "resolveJsonModule": true, "lib": ["esnext"] }
} 
//webpack.config.js const slsw=require ("serverless-webpack");
const nodeExternals=require ("webpack-node-externals");
module.exports={ entrada: slsw.lib.entries, alvo:"nó", //Gere mapas de origem para mensagens de erro adequadas devtool:"mapa-fonte", //Como"aws-sdk"não é compatível com webpack, //excluímos todas as dependências do nó externos: [nodeExternals ()], modo: slsw.lib.webpack.isLocal?"desenvolvimento":"produção", otimização: { //Não queremos minimizar nosso código. minimizar: falso, }, atuação: { //Desligue avisos de tamanho para pontos de entrada dicas: falso, }, resolver: { extensões: [".ts"], }, //Execute o babel em todos os arquivos.js e pule aqueles em node_modules módulo: { as regras: [ { teste:/\.ts(x?)$/, excluir:/node_modules/, usar: [ { carregador:"ts-loader", }, ], }, ], },
}; 

Implantar o projeto sem servidor

Agora que terminamos de criar todos os recursos, vamos reunir tudo e adicioná-lo ao arquivo serverless.yml da seguinte maneira:

 serviço: react-amplify-multi-tenant
app: amplify-multi-tenant
frameworkVersion:"2"
fornecedor: nome: aws tempo de execução: nodejs12.x lambdaHashingVersion: 20201221 região: eu-west-1 stage: $ {opt: stage,'dev'} meio Ambiente: PRIMARY_TABLE: $ {self: custom.resources.PRIMARY_TABLE}
plugins: -plugin-serverless-appsync -serverless-stack-output -pseudo-parâmetros sem servidor -serverless-webpack
personalizadas: webpack: webpackConfig:./webpack.config.js # suporte a typescript includeModules: true Recursos: 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} resultado: handler:./scripts/output.handler arquivo:../client/src/aws-exports.json appSync: # configuração do plug-in appsync nome: $ {self: service}-appsync-$ {self: provider.stage} authenticationType: AMAZON_COGNITO_USER_POOLS additionalAuthenticationProviders: -authenticationType: API_KEY fontes de dados: -tipo: AMAZON_DYNAMODB nome: PrimaryTable descrição:"Tabela Primária" config: tableName: $ {self: custom.resources.PRIMARY_TABLE} serviceRoleArn: {Fn:: GetAtt: [AppSyncDynamoDBServiceRole, Arn]} userPoolConfig: awsRegion: $ {self: provider.region} defaultAction: ALLOW userPoolId: {Ref: CognitoUserPoolToDoUserPool} # nome do recurso logConfig: loggingRoleArn: {Fn:: GetAtt: [AppSyncLoggingServiceRole, Arn]} nível: TODOS mappingTemplates: -dataSource: PrimaryTable tipo: mutação campo: createTodo solicitação:"create_todo/request.vtl" resposta:"common-item-response.vtl" -dataSource: PrimaryTable tipo: mutação campo: updateTodo solicitação:"update_todo/request.vtl" resposta:"common-item-response.vtl" -dataSource: PrimaryTable tipo: mutação campo: deleteTodo solicitação:"delete_todo/request.vtl" resposta:"common-item-response.vtl" -dataSource: PrimaryTable tipo: Consulta campo: getToDo solicitação:"get_todo/request.vtl" resposta:"common-item-response.vtl" -dataSource: PrimaryTable tipo: Consulta campo: getUser pedido:"get_user/request.vtl" resposta:"common-item-response.vtl" -dataSource: PrimaryTable tipo: Consulta campo: listUserTodos solicitação:"list_user_todos/request.vtl" resposta:"common-items-response.vtl" -dataSource: PrimaryTable tipo: ToDo campo: usuário solicitação:"get_todo_user/request.vtl" resposta:"common-item-response.vtl"
funções: cognitoPostConfirmation: handler: handler.cognitoPostConfirmation eventos: # cognito pós-confirmação de gatilho -cognitoUserPool: pool: CognitoUserPoolToDoUserPool gatilho: PostConfirmation
Recursos: -$ {arquivo (./resources/appsync-dynamo-role.yml)} -$ {arquivo (./resources/dynamo-table.yml)} -$ {file (./resources/web-hosting-bucket.yml)} -$ {file (./resources/cognito-userpool.yml)} 

E então implantamos:

 $ sls deploy--stage=dev 

Construindo o cliente front-end

Agora que nosso back-end está todo configurado e implantado, prosseguiremos para o cliente de front-end para demonstrar como a lógica acima é reunida.

Estaremos usando Ant Design para componentes de IU e, para validar a senha do usuário, usaremos um validador de senha. Adicionamos requisitos de senha ao configurar nosso pool de usuários, que deve ser o seguinte:

  • Mínimo de oito caracteres
  • Pelo menos uma letra maiúscula
  • Pelo menos uma letra minúscula
  • Pelo menos um símbolo
  • Pelo menos um dígito

Após a validação bem-sucedida de todos os detalhes do usuário necessários, enviamos a carga útil para a API Cognito, que envia um código de verificação para o e-mail do usuário e cria um novo usuário no UserPool :

 const onFinish=(valores: qualquer)=> { const {firstName, lastName, email, phoneNumber, password}=valores; //ocultar carregador toggleLoading (false); Auth.signUp ({ nome de usuário: email, senha, atributos: { o email, nome: `$ {firstName} $ {lastName}`, phone_number: phoneNumber, }, }) .então (()=> { notificação.success ({ mensagem:"Usuário inscrito com sucesso!", Descrição: "Conta criada com sucesso, redirecionando você em alguns minutos!", posicionamento:"topRight", duração: 1,5, onClose: ()=> { updateUsername (email); toggleRedirect (true); }, }); }) .catch ((errar)=> { notificação.error ({ mensagem:"Erro", descrição:"Erro ao inscrever o usuário", posicionamento:"topRight", duração: 1,5, }); toggleLoading (false); }); }; 

Navegue até a rota de inscrição e crie um novo usuário:

Registrando um novo usuário para nosso aplicativo
Página de registro do usuário.

Verifique seu e-mail para obter um novo código de confirmação e adicione-o da seguinte maneira:

Digitando o código de confirmação de e-mail
Inserindo código de confirmação de e-mail.

Após a verificação, seu pool de usuários agora deve ter uma lista de novos usuários em usuários e grupos:

O pool de usuários do Cognito para nosso aplicativo
Pool de usuários do Cognito.

Quando um novo usuário é inscrito, o gatilho de pós-confirmação que configuramos recebe uma carga contendo os dados de inscrição do usuário, que então salvamos no DynamoDB como um registro do usuário. Abra seu console AWS, navegue até DynamoDB e selecione a tabela recém-criada. Você deve ter um novo registro de usuário salvo com detalhes do processo de inscrição:

Visualizando Recentemente Criado Registro do usuário

Em seguida, agora você pode fazer login usando as novas credenciais, após o que será redirecionado para a página do painel, onde pode criar, editar e excluir novos itens de tarefas. Como este artigo é para fins de demonstração, adicionaremos um arquivo de componente que contém toda a lógica CRUD:

 const DataList=()=> { const [descrição, updateDescription]=React.useState (""); const [updateToDoMutation]=useMutation (updateToDo); const [createToDoMutation]=useMutation (createToDo); const [deleteToDoMutation]=useMutation (deleteToDo); const {carregamento, erro, dados}=useQuery (listUserToDos); function handleCheck (event: CheckboxChangeEvent, item: ToDo) { updateToDoMutation ({ variáveis: {entrada: {concluído, id: item.id}}, refetchQueries: [ { query: listUserToDos, }, ], }) .então ((res)=> mensagem.success ("Item atualizado com sucesso")) .catch ((errar)=> { message.error ("Ocorreu um erro ao atualizar o item"); }); } function handleSubmit (event: React.FormEvent) { event.preventDefault (); createToDoMutation ({ variáveis: {entrada: {descrição}}, refetchQueries: [ { query: listUserToDos, }, ], }) .então ((res)=> mensagem.success ("Item criado com sucesso")) .catch ((errar)=> { message.error ("Ocorreu um erro ao criar o item"); }); } function handleKeyPress (event: React.KeyboardEvent) { if (event.key==="Enter") { //usuário pressionou enter createToDoMutation ({ variáveis: {entrada: {descrição}}, refetchQueries: [ { query: listUserToDos, }, ], }) .então ((res)=> { message.success ("Item criado com sucesso"); }) .catch ((errar)=> { message.error ("Ocorreu um erro ao criar o item"); }); } } function handleDelete (item: ToDo) { deleteToDoMutation ({ variáveis: {id: item.id}, refetchQueries: [ { query: listUserToDos, }, ], }) .então ((res)=> { message.success ("Excluído com sucesso"); }) .catch ((errar)=> { message.error ("Ocorreu um erro ao deletar o item"); }); } if (carregando) { Retorna (    ); } if (erro) { return 
{`Erro! $ {error.message} `}
; } Retorna ( updateDescription (event.target.value)} estilo={{marginRight:"10px"}} onKeyDown={handleKeyPress} />
} com bordas dataSource={data.listUserTodos} renderItem={(item: ToDo)=> ( handleCheck (evento, item) } > {descrição do item} handleDelete (item)} okText="Sim" cancelText="Não" > Excluir )} /> ); };

Agora, adicione um novo item:

Adicionando uma nova tarefa em nosso aplicativo de demonstração
Painel para adicionar novo item de tarefa.

Navegue até o painel do DynamoDB para visualizar os itens de pendências recém-criados. Como estamos usando o design de tabela única para nosso banco de dados, os registros do usuário e de tarefas são todos armazenados na mesma tabela, conforme mostrado abaixo:

Visualizando Tabela Única Exibindo Usuários e Tarefas

In order to test out the multi-tenancy model for the above application, navigate to your terminal and deploy a new instance with a different stage name. The deployment will provision new resources that are independent, with a new database and Cognito user pool.

$ sls deploy--stage=new_stage_name

Conclusion

I hope you enjoyed the article and that you’ve learned something new. As demonstrated, building a multi-tenant app can be quite challenging since there is no one-size-fits-all approach; it requires a lot of pre-planning and choosing what works best for your solution.

I had to omit some of the code in order to keep the article short and readable, but you can view the repo here, and in case anything doesn’t work as you expect it, kindly raise an issue and I will take time to look into it. Happy coding!

The post Building a multi-tenant Amplify app with a React frontend appeared first on LogRocket Blog.