DEV Community

Romildo Junior
Romildo Junior

Posted on

Dynamodb em aplicações node

Recentemente, voltei a precisar utilizar dynamodb como base de dados em algumas aplicações. Há alguns anos, a abordagem mais simples que tinha encontrado, até mesmo por já possuir algum contato com mongodb, foi por meio do Dynamoose - que sim, é uma ferramenta fortemente inspirada no Mongoose, como o nome sugere.

Antes de partir para o que já era conhecido, decidi testar a sdk padrão da aws e cheguei a algo interessante, então vou deixar registrado por aqui.

Overview do DynamoDB

O dynamodb é um banco de dados não relacional, baseado em tabelas. É bem similar ao scylla/cassandra, no sentido de que a parte mais importante da modelagem é escolher bem as chaves da tabela.

Nesse caso, podem existir até duas chaves (algo que não é o caso no scylla): uma chave de partição (hash key) e outra chave de ordenação (sort key). Esses dois valores, além das configurações de capacidade para escrita e leitura e o próprio nome, são os únicos requisitos ao criar uma tabela.

Como a figura abaixo ilustra, a chave de partição pode ser ambigua, quando for utilizada em conjunto com a chave de odernação. Isso permite, por exemplo, filtrar todos os valores do estado de PE de forma performática (caso você precise de mais do que dois filtros, é necessário criar um índice secundário)

Exemplo de modelagem com o dynamodb

O restante dos dados não precisam ser definidos de forma rígida (a famosa magia do NoSQL), o que também traz a necessidade de verificar se o registro na base possui o formato esperado antes de utilizá-lo.

Por fim, os custos são definidos a partir da leitura (Read Capacity Unit, RCU) e escrita (Write Capacity Unit, WCU), com algumas pequenas variações relacionadas a provisionamento automático e consistência das operações.

Você pode ver os preços na página dynamodb: preços, bem como uma descrição de como as unidades são utilizadas em Capacity unit consumption for read operations (documentação da aws)

Um setup mínimo

A forma mais simples de subir um projeto é utilizar a propria SDK da aws, em conjunto com a versão local (para desenvolvimento) do dynamodb.

A SDK pode ser instalada a partir do npm:

npm i @aws-sdk/client-dynamodb
Enter fullscreen mode Exit fullscreen mode

Para o segundo item, recomendo o instructure/dynamo-local-admin-docker. Os autores do repositório disponibilizaram uma imagem docker com dynamodb-local e dynamoadmin, facilitando bastante o setup:

docker run -p 8000:8000 -it instructure/dynamo-local-admin
Enter fullscreen mode Exit fullscreen mode

caso você queira persistir os dados após finalizar o container, é necessário utilizar volumes

Interface do dynamo-admin localmente

No caso da minha aplicação, utilizei o fastify e o suporte nativo para typescript e execução de testes, disponível nas versões mais recentes do node. O projeto base pode ser obtida em jrmmendes/fastify-ts

Tendo um projeto como base, a inicialização pode ser feita com a classe DynamoDBClient:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

const client = new DynamoDBClient({
  endpoint: "http://localhost:8000",
  region: "localhost",
  credentials: {
    accessKeyId: "fakeMyKeyId",
    secretAccessKey: "fakeSecretAccessKey",
  },
})
Enter fullscreen mode Exit fullscreen mode

Em que utilizei os parâmetros necessários para uso local (em especial, endpoint). Dependendo da forma como a sua aplicação lida com acesso à AWS, esses valores são dispensáveis.

A SDK disponibiliza também uma série da classes, correspondentes às operações possíveis, que seguem o padrão *Command (e.g. QueryCommand, PutItemCommand, etc).

Image description

Uma dúvida comum está relacionada a dois comandos em particular: Query e Scan, uma vez que ambos permitem ler dados da base. Esse tópico sozinho já poderia render um novo artigo, mas trazendo de forma breve, as operações de Query sempre são mais eficientes (e baratas), uma vez que usam as chaves de hash e partição para reduzir a quantidade de dados consultados de forma performática.

Caso queira se aprofundar nesse tema, recomendo ler Dynamodb Scan vs Query (por Dynabase) e Best practices for querying and scanning data in DynamoDB (documentação da aws),

Exemplo mais estruturado

Para ter uma visão de como isso fica na prática, vamos construir uma API de listagem, que obtem uma lista de preferências do usuário. Cada preferência terá o seguinte formato:

class Preference {
  name: string;
  value: string | boolean | number;
}
Enter fullscreen mode Exit fullscreen mode

Outro ponto é que ela precisa ser atrelada a um usuário (por meio do seu ID) e uma plataforma (android, ios, web).

A partir disso, é possível criar a tabela preferences-db, com o seguinte conteúdo:

  • hash key: userId - id do usuário
  • sort key: platform - android, ios, web

Tabela

E adicionar um item que possua a propriedade preferences (array de objetos do tipo UserPreference:

Criação de item a partir do dynamodb local admin

Esses dados podem, então, ser obtidos dentro da aplicação:

const command = new QueryCommand({
  TableName: "preferences-db",
  KeyConditionExpression: ":userId = userId AND :platform = platform",
  ExpressionAttributeValues: {
    ":userId": { S: "123" }, 
    ":platform": { S: "web" },
  },
});

// os items retornados estarão em `response.Items`
const response = await this.client.send(command)
Enter fullscreen mode Exit fullscreen mode

Validação e formato dos dados

Caso você execute uma query diretamente, como no exemplo, vai perceber que o valor do response.Items é do tipo Record<string, AttributeValue>. Em outras palavras, o resultado será algo como o seguinte:

{
  "preferences": {
    "L": [{
      "M": {
        "name": { "S": "theme" },
        "value": { "S": "dark" }
      }
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

Em que todos os atributos são opicionais. Isso torna necessário realizar algumas verificações, para garantir que a aplicação não quebre ao tentar acessar algum dado.

Uma forma de resolver ambos os problemas (interpretar e validar os resultados) é definir um schema centralizado, uma ideia bem próxima ao que o dynamoose utiliza:

//schema.ts
export type Schema<T> = {
  table: string,
  keys: {
    hash: string,
    range: string,
  },
  parse: (items: Record<string, AttributeValue>[])=> T[]
}
Enter fullscreen mode Exit fullscreen mode

Seria possível ter, então, o seguinte schema para as preferências:

//preferences.schema.ts
const validate = (preference: any) => {
  return preference?.M?.name?.S && preference.M?.value ;
};

export const PreferencesSchema: Schema<Preference> = {
  table: "preferences",
  keys: {
    hash: "userId",
    range: "platform"
  },
  parse: (items: Record<string, AttributeValue>[]): Preference[] => {
    baseLogger.info(items);
    return items
      .filter((item) => item.preferences?.L)
      .flatMap((item) => item.preferences.L)
      .filter((pref) => !!pref?.M)
      .filter(validate)
      .map(
        (preference) =>
          new Preference({
            name: preference.M.name.S!,
            value: preference.M.value.BOOL!,
          }),
      );
  },
};
Enter fullscreen mode Exit fullscreen mode

Em que Preference é uma classe que criamos para manipular dentro da aplicação.

O fato de ter essa abstração (schema), permite que exista uma implementação unica para acesso ao banco de dados, independente da tabela, chaves e tipos de dados retornados:

export class DynamodbAdapter<Result> {
  client: DynamoDBClient;
  schema: Schema<Result>;

  constructor(schema: Schema<Result>) {
    this.schema = schema;
    this.client = new DynamoDBClient({
      endpoint: "http://localhost:8000",
      region: "localhost",
      credentials: {
        accessKeyId: "fakeMyKeyId",
        secretAccessKey: "fakeSecretAccessKey",
      },
    });
  }

  async listByKey(
    keys: Record<string, string>,
  ): Promise<Result[]> {
    const hasHashKey = Object.keys(keys).includes(this.schema.keys.hash);
    const hasRangeKey = Object.keys(keys).includes(this.schema.keys.range);

    const hashKey = this.schema.keys.hash;
    const rangeKey = this.schema.keys.range;

    if (!hasHashKey) {
      throw new Error("You must provide the hash key");
    }

    const condition =
      `:${hashKey} = ${hashKey}` +
      (hasRangeKey ? ` AND :${rangeKey} = ${rangeKey}` : "");

    const attributes = {
      [`:${hashKey}`]: { S: keys[hashKey] },
    };

    if (hasRangeKey) attributes[`:${rangeKey}`] = { S: keys[rangeKey] };

    const command = new QueryCommand({
      TableName: this.schema.table,
      KeyConditionExpression: condition,
      ExpressionAttributeValues: attributes,
    });

    const response = await this.client.send(command);

    if (!response?.Items) return [];

    return this.schema.parse(response.Items);
  }
}
Enter fullscreen mode Exit fullscreen mode

Que poderia ser utilizado de forma agnóstica em um repository, por exemplo:

export class DynamodbPreferencesRepository implements PreferencesRepository {
  adapter: DynamodbAdapter<Preference>;

  constructor(adapter: DynamodbAdapter<Preference>) {
    this.adapter = adapter;
  }

  async getConsultantPreferences(params: {
    userId: string;
    platform: string;
  }): Promise<Preference[]> {
    return this.adapter.listByKey({
      userId: params.userId:,
      platform: params.platform,
    });
  }
}

const repository = new DynamodbPreferencesRepository(
  new DynamodbAdapter(PreferencesSchema),
);

//... em algum handler/service
const preferences = await repository.getConsultantPreferences({
  platform: "web",
  userId: "123"
});
Enter fullscreen mode Exit fullscreen mode

Conclusão

Utilizar nosql é sempre uma experiência específica, uma vez que cada implementação possui seus proprios objetivos e particularidades. Um outro ponto que sempre acaba sendo relevante é como resolver a interpretação desses dados, bem como a definição de tipos, uma vez que não existem garantias sobre o formato dos dados - e dai surgem problemas, como o famoso Cannot read properties of undefined.

Nesse artigo trago uma possibilidade, fortemente inspirada no que algumas bibliotecas padrão fazem, bem como uma visão gerão de algumas particularidades do dynamodb, para os que estão fazendo aplicações simples. Em futuros artigos, devo abordar mais o uso do dynamoose, uma vez que ele resolve vários dos problemas comentados.

Top comments (0)