Cache em hapi.js utilizando Redis

As vezes precisamos reduzir o tempo de uma requisição, seja lá qual for o motivo. Após algumas horas de pesquisas encontrei várias soluções, mas estava querendo algo bem simples, foi ai que o Redis entrou na jogada, por ser uma estrutura de dados de chave e valor na memória.

Encontrei alguns exemplos, mas, ainda assim, queria melhorar, graças ao fantástico esquema de plugins do hapi.js resolvi desenvolver um plugin. Inclusive esse foi meu primeiro plugin, espero que seja o primeiro de muitos.

Não tenho muita criatividade para nomes, então resolvi chamar o plugin de hapi-slap

Para o projeto de exemplo utilizaremos o banco de dados Postgres com ORM(sequelize), foi implementado um CRUD para um model People para demonstração do cache.

  const People = sequelize.define('People', {
    first_name: {
      type: DataType.STRING(120)
    },
    last_name: {
      type: DataType.STRING(120)
    },
    email: {
      type: DataType.STRING(120)
    },
    gender: {
      type: DataType.STRING(120)
    }
  }, {
    createdAt: 'created_at',
    updatedAt: 'update_at',
    tableName: 'people'
  });

Caso queira acompanhar o código estará disponível no github slap-caching.

Implementação

Vamos registrar o plugin no hapi.js

server.register([{
    register: require('hapi-slap'),
    options: {
        url: 'redis://127.0.0.1:6379/0',
        expireIn: 300
    }    
}], (err) => {
  if (err) {
    return console.error(err);
  }
  server.start(() => {
    console.info(`Server started at ${server.info.uri}`);
  });
});

Podemos informar uma propriedade url de conexão com Redis e qual o tempo de expiração global para o cache, o padrão é 300 segundos (5 minutos)

Vamos configurar o plugin para uma endpoint

server.route({
  method: 'GET',
  path: '/people',
  config: {
    plugins: {
      slap: {
        rule: 'people'
      }
    },
    handler: handlers.all,
    validate: validators.all()
  }
});

Vamos implementar o handler

async function all (request, reply) {
  try {
    const cache = await request.getCache(); // retornando cache

    if (cache) {
      return reply(cache);
    }

    const db = request.getDb('slap');

    const where = request.query.gender ? {where: {gender: request.query.gender}} : {};

    const values = await db.models.People.findAll(where);

    request.addCache(values); // adicionando cache

    return reply(values);
  } catch (err) {
    return reply.badImplementationCustom(err);
  }
}

Por padrão o plugin disponibiliza um método chamado getCache, no qual é responsável por retornar o cache, neste caso, não precisaríamos ir ao banco de dados e fazer nossa consulta. Caso contrário, consultaremos em nosso banco de dados e adicionaremos no cache através do método addCache. Assim se houver uma nova requisição já estará em cache.

Outro detalhe interessante é com relação a query, neste caso temos uma query referente ao gênero, podemos fazer a seguinte requisição /people?gender=Female ou /people?gender=Male ambas serão cacheadas de acordo com seu filtro.

Podemos ter os seguintes caches:
/people
/people?gender=Male
/people?gender=Female

Também podemos configurar de acordo com um determinado parâmetro.
Vamos configurar um endpoint GET /people/{id}, nele informamos a regra rule neste caso demos o nome de people-id, não vamos utilizar o tempo padrão para expiração, vamos informar 60 segundos (1 minuto) na propriedade expireIn.

server.route({
  method: 'GET',
  path: '/people/{id}',
  config: {
    plugins: {
      slap: {
        rule: 'people-id',
        expireIn: 60
      }
    },
    handler: handlers.show,
    validate: validators.show()
  }
});

Vamos implementar o handler

async function show (request, reply) {
  try {
    const id = request.params.id;
    const cache = await request.getCache(id); // retornando cache

    if (cache) {
      return reply(cache);
    }

    const db = request.getDb('slap');

    const value = await db.models.People.findOne({where: {id: id}});
    if (!value) {
      return reply.notFound();
    }

    request.addCache(value, id); // adicionando cache

    return reply(value);
  } catch (err) {
    return reply.badImplementationCustom(err);
  }
}

Neste caso teremos duas diferenças com relação ao primeiro exemplo. No método getCache adicionamos nosso parâmetro, assim saberemos localizar o cache com relação a regra configurada. Para adicionar ao cache utilizamos o método addCache e passamos nosso parâmetro.

Agora imagine a seguinte situação, os dados estão em cache, só que o mesmo sofreu alguma alteração ou houve uma inclusão de um registro ou até mesmo foi excluído, como ficaria?

Vamos configurar o slap para os seguintes endpoints POST /people, PUT /people/{id} e DELETE /people/{id}

server.route({
  method: 'POST',
  path: '/people',
  config: {
    plugins: {
      slap: {
        clear: ['people', 'people-id']
      }
    },
    handler: handlers.create,
    validate: validators.create()
  }
});

server.route({
  method: 'PUT',
  path: '/people/{id}',
  config: {
    plugins: {
      slap: {
        clear: ['people', 'people-id']
      }
    },
    handler: handlers.update,
    validate: validators.update()
  }
});

server.route({
  method: 'DELETE',
  path: '/people/{id}',
  config: {
    plugins: {
      slap: {
        clear: ['people', 'people-id']
      }
    },
    handler: handlers.remove,
    validate: validators.remove()
  }
});

Existe uma propriedade chamada clear, nela especificamos quais regras queremos deletar, como nos exemplos acima criamos duas regras, people e people-id vamos adicionar na propriedade clear.

Vamos implementar o handler

async function create (request, reply) {
  try {
    const db = request.getDb('slap');

    request.clearCache(); // limpando o cache

    const value = await db.models.People.create(request.payload);

    return reply({id: value.id});
  } catch (err) {
    return reply.badImplementationCustom(err);
  }
}

async function update (request, reply) {
  try {
    const db = request.getDb('slap');
    const id = request.params.id;

    const value = await db.models.People.findOne({where: {id: id}});
    if (!value) {
      return reply.notFound();
    }

    request.clearCache(); // limpando o cache

    await value.update(request.payload, {where: {id: id}});

    return reply({id: id});
  } catch (err) {
    return reply.badImplementationCustom(err);
  }
}

async function remove (request, reply) {
  try {
    const id = request.params.id;
    const db = request.getDb('slap');

    const value = await db.models.People.findOne({where: {id: id}});
    if (!value) {
      return reply.notFound();
    }

    request.clearCache(); // limpando o cache

    return reply({ok: true});
  } catch (err) {
    return reply.badImplementationCustom(err);
  }
}

Para deletar os caches que estão configurados na propriedade clear utilizaremos o método clearCache.

Neste caso, se houver alguma inclusão, alteração ou exclusão limparemos o cache, assim não corremos o risco do cache estar desatualizado.

Fiz alguns testes local, e conseguimos perceber a diferença em utilizar cache, segue abaixo alguns logs.

Foram inseridos 1000 registros na tabela People no banco de dados.

Log sem utilizar o plugin de cache

$ 171018/110640.157, [response] http://localhost:3000: get /people {} 200 (94ms)
$ 171018/110640.939, [response] http://localhost:3000: get /people {} 200 (45ms)
$ 171018/110641.515, [response] http://localhost:3000: get /people {} 200 (41ms)
$ 171018/110642.107, [response] http://localhost:3000: get /people {} 200 (43ms)
$ 171018/110642.588, [response] http://localhost:3000: get /people {} 200 (64ms)
$ 171018/110643.017, [response] http://localhost:3000: get /people {} 200 (36ms)
$ 171018/110643.476, [response] http://localhost:3000: get /people {} 200 (44ms)
$ 171018/110643.898, [response] http://localhost:3000: get /people {} 200 (42ms)
$ 171018/110644.309, [response] http://localhost:3000: get /people {} 200 (38ms)
$ 171018/110644.711, [response] http://localhost:3000: get /people {} 200 (41ms)
$ 171018/111116.247, [response] http://localhost:3000: get /people/1 {} 200 (50ms)
$ 171018/111116.810, [response] http://localhost:3000: get /people/1 {} 200 (13ms)
$ 171018/111117.554, [response] http://localhost:3000: get /people/1 {} 200 (12ms)
$ 171018/111118.098, [response] http://localhost:3000: get /people/1 {} 200 (12ms)
$ 171018/111118.754, [response] http://localhost:3000: get /people/1 {} 200 (13ms)
$ 171018/111119.274, [response] http://localhost:3000: get /people/1 {} 200 (12ms)
$ 171018/111119.954, [response] http://localhost:3000: get /people/1 {} 200 (12ms)
$ 171018/111120.634, [response] http://localhost:3000: get /people/1 {} 200 (11ms)
$ 171018/111121.189, [response] http://localhost:3000: get /people/1 {} 200 (11ms)
$ 171018/111121.809, [response] http://localhost:3000: get /people/1 {} 200 (11ms)

Observação: O próprio banco faz cache de suas query, podemos perceber a diferença do primeiro retorno para os demais.

Log utilizando cache

$ 171018/111328.677, [response] http://localhost:3000: get /people {} 200 (74ms)
$ 171018/111330.794, [response] http://localhost:3000: get /people {} 200 (10ms)
$ 171018/111331.621, [response] http://localhost:3000: get /people {} 200 (8ms)
$ 171018/111332.273, [response] http://localhost:3000: get /people {} 200 (10ms)
$ 171018/111333.001, [response] http://localhost:3000: get /people {} 200 (10ms)
$ 171018/111333.721, [response] http://localhost:3000: get /people {} 200 (13ms)
$ 171018/111334.465, [response] http://localhost:3000: get /people {} 200 (10ms)
$ 171018/111335.241, [response] http://localhost:3000: get /people {} 200 (9ms)
$ 171018/111336.034, [response] http://localhost:3000: get /people {} 200 (9ms)
$ 171018/111336.873, [response] http://localhost:3000: get /people {} 200 (10ms)
$ 171018/114222.465, [response] http://localhost:3000: get /people/1 {} 200 (41ms)
$ 171018/114223.539, [response] http://localhost:3000: get /people/1 {} 200 (3ms)
$ 171018/114224.307, [response] http://localhost:3000: get /people/1 {} 200 (3ms)
$ 171018/114225.131, [response] http://localhost:3000: get /people/1 {} 200 (3ms)
$ 171018/114225.883, [response] http://localhost:3000: get /people/1 {} 200 (3ms)
$ 171018/114226.523, [response] http://localhost:3000: get /people/1 {} 200 (2ms)
$ 171018/114227.227, [response] http://localhost:3000: get /people/1 {} 200 (3ms)
$ 171018/114227.955, [response] http://localhost:3000: get /people/1 {} 200 (2ms)
$ 171018/114228.579, [response] http://localhost:3000: get /people/1 {} 200 (2ms)
$ 171018/114229.371, [response] http://localhost:3000: get /people/1 {} 200 (1ms)

Retornando 1000 registros:
– Sem cache, média de: 40 ~ 30ms
– Com cache, média de: 13 ~ 8ms

Retornando 1 registro em específico:
– Sem cache, média de: 13 ~ 11ms
– Com cache, média de: 3 ~ 2ms

Com esse simples teste, já conseguimos perceber uma diferença significante.

Espero que tenham gostado, façam alguns testes e tirem suas próprias conclusões!

Github do plugin: hapi-slap

Até a próxima!