APIs e Microsserviços

APIs e Microsserviços

Arquitetura de micro serviços em Node.js + MongoDB – Parte 05

11 jun, 2018
Publicidade

E chegamos à quinta parte da nossa série de artigos sobre como implementar na prática um projeto de sistema usando arquitetura de microservices com Node.js e MongoDB.

Segue o índice da série até o momento, caso tenha caído de paraquedas no portal só agora:

Nesta quinta parte, vamos fazer o desenvolvimento das demais funções do repository.js do micro serviço cinema-catalog-service, lembrando que queremos fornecer, através deste micro serviço, acesso ao front-end mobile, funções como consulta de cinemas por cidade, de filmes em exibição por cinema, sessões dos filmes, e por aí vai.

Consulta de Lançamentos do Cinema

1 – Finalizando o repositório

Onde paramos no artigo anterior? Ah sim, tínhamos a seguinte lista de funções a serem criadas no repository.js:

  • Pesquisar cidades em que a rede possui cinema
  • Pesquisar cinemas por id da cidade
  • Pesquisar filmes disponíveis em um cinema
  • Pesquisar filmes disponíveis em uma cidade
  • Pesquisar sessões disponíveis para um filme em uma cidade
  • Pesquisar sessões disponíveis para um filme em um cinema

Enquanto as duas primeiras foram feitas ainda no artigo anterior, as demais não. Elas guardam uma complexidade que ainda não tivemos de lidar no nosso projeto: agregações. Como nosso documento é relativamente complexo, com mais de um nível de profundidade, se faz necessário navegar e agrupar dados entre os níveis para oferecer informação relevante e fácil de ser manipulada pela camada de API que ainda nem construímos.

Assim, abra o seu cinema-microservice/cinema-catalog-service/src/repository/repository.js e adicione a seguinte função:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function getMoviesByCinemaId(cinemaId, callback){
    var objCinemaId = ObjectId(cinemaId);
    mongodb.connect((err, db) => {
        db.collection("cinemaCatalog").aggregate([
            {$match: {"cinemas._id": objCinemaId}},
            {$unwind: "$cinemas"},
            {$unwind: "$cinemas.salas"},
            {$unwind: "$cinemas.salas.sessoes"},
            {$group: {_id: { filme: "$cinemas.salas.sessoes.filme", idFilme: "$cinemas.salas.sessoes.idFilme"}}}
        ]).toArray(callback);
    });
}

Respire por um momento; aqui temos uma série de novos operadores e a novíssima função aggregate.

A função aggregate substitui a função find quando nossa consulta manipulará os dados resultantes para realizar agregações de diversos tipos. A função aggregate executa um pipeline de comandos contidos no array que ela recebe por parâmetro, seguindo exatamente a ordem do array. Assim, do primeiro operador do nosso pipeline de agregação, temos:

  • $match: este operador é um filtro, tal qual os possíveis de serem utilizados no find. Aqui, estou dizendo para filtrar apenas pelos documentos cujo id do cinema seja igual ao recebido
  • $unwind: este operador desconstrói campos multivalorados em novos documentos, repetindo os demais dados do documento pai do array. Ou seja, se eu tinha uma cidade com dois cinemas dentro, o primeiro $unwind vai fazer com que eu tenha duas cidades repetidas, mas cada uma com um cinema apenas (o campo multivalorado vira univalorado)
  • Os $unwinds seguintes vão desconstruindo os demais arrays do documento
  • $group: uma vez que eu tenho todos os documentos com apenas documentos e subdocumentos (sem arrays), é hora de agrupar eles para evitar as repetições e também para pegar apenas os dados que me interessam, que são os ids e nomes dos filmes que estão em exibição no cinema em questão

Essa agregação toda, no final das contas, gera um array de filmes no formato id/nome bem simples de ser recebido e utilizado pela API.

Na mesma linha da função anterior, vamos criar mais três delas, finalizando nosso repository.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function getMoviesByCityId(cityId, callback){
    var objCityId = ObjectId(cityId);
    mongodb.connect((err, db) => {
        db.collection("cinemaCatalog").aggregate([
            {$match: {"_id": objCityId}},
            {$unwind: "$cinemas"},
            {$unwind: "$cinemas.salas"},
            {$unwind: "$cinemas.salas.sessoes"},
            {$group: {_id: { filme: "$cinemas.salas.sessoes.filme", idFilme: "$cinemas.salas.sessoes.idFilme"}}}
        ]).toArray((err, sessions) => {
            if(err) return callback(err, null);
            callback(err, sessions.map(item => { return {idFilme: item._id.idFilme, filme: item._id.filme } } ));
        });
    });
}

function getMovieSessionsByCityId(movieId, cityId, callback){
    var objMovieId = ObjectId(movieId);
    var objCityId = ObjectId(cityId);

    mongodb.connect((err, db) => {
        db.collection("cinemaCatalog").aggregate([
            {$match: {"_id": objCityId}},
            {$unwind: "$cinemas"},
            {$unwind: "$cinemas.salas"},
            {$unwind: "$cinemas.salas.sessoes"},
            {$match: {"cinemas.salas.sessoes.idFilme": objMovieId}},
            {$group: {_id: { filme: "$cinemas.salas.sessoes.filme", idFilme: "$cinemas.salas.sessoes.idFilme", idCinema: "$cinemas._id", sala: "$cinemas.salas.nome", sessao: "$cinemas.salas.sessoes"}}}
        ]).toArray((err, sessions) => {
            if(err) return callback(err, null);
            callback(err, sessions.map(item => { return {idFilme: item._id.idFilme, filme: item._id.filme, idCinema: item._id.idCinema, sala: item._id.sala, sessao: item._id.sessao } } ));
        });
    });
}

function getMovieSessionsByCinemaId(movieId, cinemaId, callback){
    var objCinemaId = ObjectId(cinemaId);
    var objMovieId = ObjectId(movieId);
    mongodb.connect((err, db) => {
        db.collection("cinemaCatalog").aggregate([
            {$match: {"cinemas._id": objCinemaId}},
            {$unwind: "$cinemas"},
            {$unwind: "$cinemas.salas"},
            {$unwind: "$cinemas.salas.sessoes"},
            {$match: {"cinemas.salas.sessoes.idFilme": objMovieId}},
            {$group: {_id: { filme: "$cinemas.salas.sessoes.filme", idFilme: "$cinemas.salas.sessoes.idFilme", sala: "$cinemas.salas.nome", sessao: "$cinemas.salas.sessoes"}}}
        ]).toArray((err, sessions) => {
            if(err) return callback(err, null);
            callback(err, sessions.map(item => { return {idFilme: item._id.idFilme, filme: item._id.filme, sala: item._id.sala, sessao: item._id.sessao } } ));
        });
    });
}

Note que nestas funções ainda temos mais alguns complicadores.

Primeiro, todas elas estão tratando o retorno da função to Array para passar para o callback objetos mais “mastigados”, uma vez que o operador $group do MongoDB costuma deixar um nível desnecessário dentro dos documentos que ele retorna, fora nomenclaturas e etc, que podemos tratar melhor usando a boa e velha função ‘map’ do JavaScript.

Além disso, as duas últimas funções usaram um operador $match adicional para fazer um novo filtro após os $unwinds, diminuindo ainda mais a carga do operador $group.

Usar o operador $match o quanto antes do final do pipeline de agregação é sempre uma boa prática para diminuir a carga da função aggregate, que é bem pesada. Para finalizar esse arquivo, não esqueça de colocar o nome de todas funções no module.exports no final do mesmo:

1
module.exports = { getAllCities, getCinemasByCityId, getMoviesByCityId, getMoviesByCinemaId, getMovieSessionsByCityId, getMovieSessionsByCinemaId, disconnect }

E ‘bora criar novos testes unitários para cobrir estas novas funções no repository.test.js, como abaixo. Coloque estes testes após o teste da função “Repository getCinemasByCityId” e antes do teste da função “disconnect”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
test('Repository getMoviesByCinemaId', (t) => {
        repository.getMoviesByCinemaId(cinemaId, (err, movies) => {
            t.assert(!err && movies && movies.length > 0, "Movies By Cinema Id Returned");
            t.end();
        });
    })

    test('Repository getMoviesByCityId', (t) => {
        repository.getMoviesByCityId(cityId, (err, movies) => {
            if(movies && movies.length > 0) movieId = movies[1].idFilme;//Era de Ultron
            t.assert(!err && movies && movies.length > 0, "Movies By City Id Returned");
            t.end();
        });
    })

    test('Repository getMovieSessionsByCityId', (t) => {
        repository.getMovieSessionsByCityId(movieId, cityId, (err, sessions) => {
            t.assert(!err && sessions && sessions.length > 0, "Movie Sessions By City Id Returned");
            t.end();
        });
    })

    test('Repository getMovieSessionsByCinemaId', (t) => {
        repository.getMovieSessionsByCinemaId(movieId, cinemaId, (err, sessions) => {
            t.assert(!err && sessions && sessions.length > 0, "Movie Sessions By Cinema Id Returned");
            t.end();
        });
    })

Isso nos dará uma cobertura de 100% das nossas funções, nos dando a garantia que podemos avançar para a camada de API do nosso microservice, sem medo de ter deixado alguma ponta solta para trás!

2 – Programando a API Cinema-Catalog-Service

Nosso próximo passo é criar a API propriamente dita. Seguiremos a mesma arquitetura da API anterior, a movies-service, apenas definindo um novo conjunto de rotas coerente com a responsabilidade desse microservice novo, que é a localização de salas e sessões de cinema a partir de informações como cidade, filme, etc.

Baseado no que temos de funções em nosso repository.js, podemos definir que devemos fornecer o tratamento das seguintes rotas na nossa API:

  • GET /cities
    • lista todas as cidades em que a rede possui cinema
  • GET /cities/:city/movies
    • lista todos os filmes em exibição na cidade especificada
  • GET /cities/:city/movies/:movie
    • lista todos as sessões do filme escolhido na cidade especificada
  • GET /cities/:city/cinemas
    • lista todos os cinemas em determinada cidade
  • GET /cinemas/:cinema/movies
    • lista todos os filmes em exibição no cinema especificado
  • GET /cinemas:cinema/movies/:movie
    • lista todas as sessões do filme escolhido no cinema especificado

Novamente adotaremos a abordagem de não fazer o CRUD completo, mas apenas as chamadas que possibilitem ao front-end poder construir suas telas e seu funcionamento de navegação.

Obviamente você pode pensar em rotas adicionais à estas, ou até em uma “pureza” maior do conceito de RESTful.

Com esse mapeamento das rotas em mente, vamos criar nosso arquivo cinema-microservice/cinema-catalog-service/src/api/cinema-catalog.js com o seguinte conteúdo, que não tem nada demais em relação à API anterior que já criamos (movies-service):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//cinema-catalog.js
module.exports = (app, repository) => {

    app.get('/cities', (req, res, next) => {
      repository.getAllCities((err, cities) => {
        if(err) return next(err);
        res.json(cities);
      });
    })

    app.get('/cities/:city/movies', (req, res, next) => {
        repository.getMoviesByCityId(req.params.city, (err, movies) => {
          if(err) return next(err);
          res.json(movies)
        });
      })

    app.get('/cities/:city/movies/:movie', (req, res, next) => {
        repository.getMovieSessionsByCityId(req.params.movie, req.params.city, (err, sessions) => {
            if(err) return next(err);
            res.json(sessions)
        });
    })
  
    app.get('/cities/:city/cinemas', (req, res, next) => {
      repository.getCinemasByCityId(req.params.city, (err, cinemas) => {
        if(err) return next(err);
        res.json(cinemas)
      });
    })
  
    app.get('/cinemas/:cinema/movies', (req, res, next) => {
      repository.getMoviesByCinemaId(req.params.cinema, (err, movies) => {
        if(err) return next(err);
        res.json(movies)
      });
    })

    app.get('/cinemas/:cinema/movies/:movie', (req, res, next) => {
        repository.getMovieSessionsByCinemaId(req.params.movie, req.params.cinema, (err, sessions) => {
            if(err) return next(err);
            res.json(sessions)
        });
    })
  }

Note que, como o trabalho pesado ficou todo no repository.js e suas inúmeras agregações, aqui basta receber a requisição, pegar os parâmetros adequados e chamar a função correspondente no repositório – simples assim.

Para escrevermos nossos testes, usaremos como base, as mesmas ferramentas e princípios dos testes de API que fizemos na Parte 03 usando a biblioteca supertest como apoio (lembrando que instalamos todas as dependências deste projeto no artigo anterior). Assim, crie o arquivo cinema-catalog.test.js e adicione os seguintes testes dentro dele:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//cinema-catalog.test.js
const test = require('tape');
const supertest = require('supertest');
const movies = require('./cinema-catalog');
const server = require("../server/server");
const repository = require("../repository/repository");

function runTests(){

    var app = null;
    server.start(movies, repository, (err, app) => { 
        var cityId = null;
        var movieId = null;
        var cinemaId = null;

        test('GET /cities', (t) => {
            supertest(app)
                .get('/cities')
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    if(res.body && res.body.length > 0) cityId = res.body[1]._id;
                    t.error(err, 'No errors')
                    t.assert(res.body && res.body.length > 0, "All Cities returned")
                    t.end()  
                })
        })
        
        test('GET /cities/:city/movies', (t) => {
            supertest(app)
                .get('/cities/' + cityId + "/movies")
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    if(res.body && res.body.length > 0) movieId = res.body[0].idFilme;
                    t.error(err, 'No errors')
                    t.assert(res.body, "Movies By City Id returned")
                    t.end()  
                })
        })

        test('GET /cities/:city/movies/:movie', (t) => {
            supertest(app)
                .get('/cities/' + cityId + '/movies/' + movieId)
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    if(res.body && res.body.length > 0) cinemaId = res.body[0].idCinema;
                    t.error(err, 'No errors')
                    t.assert(res.body && res.body.length > 0, "Movie Sessions by City Id returned")
                    t.end()  
                })
        })

        test('GET /cities/:city/cinemas', (t) => {
            supertest(app)
                .get('/cities/' + cityId + '/cinemas')
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    t.error(err, 'No errors')
                    t.assert(res.body, "Cinemas By City Id returned")
                    t.end()  
                })
        })

        test('GET /cinemas/:cinema/movies', (t) => {
            supertest(app)
                .get('/cinemas/' + cinemaId + "/movies")
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    t.error(err, 'No errors')
                    t.assert(res.body, "Movies By Cinema Id returned")
                    t.end()  
                })
        })

        test('GET /cinemas/:cinema/movies/:movie', (t) => {
            supertest(app)
                .get('/cinemas/' + cinemaId + "/movies/" + movieId)
                .expect('Content-Type', /json/)
                .expect(200)
                .end((err, res) =>{
                    t.error(err, 'No errors')
                    t.assert(res.body, "Movie Sessions By Cinema Id returned")
                    t.end()  
                })
        })

        repository.disconnect();
        server.stop();
     })
}

module.exports = { runTests }

O que tem de novo aqui que valha a pena ser explicado? Absolutamente nada, e espero que a esta altura do campeonato você consiga criar este arquivo e seus testes sem ter de necessariamente copiar e colar este bloco inteiro de código. Caso ainda não consiga, tente ao menos copiar apenas um teste e escrever de próprio punho os demais, para ir pegando prática.

Para verificar se os testes estão funcionando, apenas adicione mais uma linha no nosso índice de testes:

1
2
3
4
5
6
//index.test.js
require("dotenv-safe").load();
require("./config/mongodb.test").runTests();
require("./server/server.test").runTests();
require("./repository/repository.test").runTests();
require("./api/cinema-catalog.test").runTests();

Rode com um ‘npm test’, e se fez tudo certinho, verá uma beleza de bateria de testes tendo sucesso.

Todos testes OK

Para finalizar nosso micro serviço, precisamos construir o nosso index.js, que é praticamente idêntico ao do outro projeto, mudado apenas o módulo da API:

1
2
3
4
5
6
7
8
9
//index.js
require("dotenv-safe").load();
const cinemaCatalog = require('./api/cinema-catalog');
const server = require("./server/server");
const repository = require("./repository/repository");

server.start(cinemaCatalog, repository, (err, app) => { 
    console.log("just started");
});

Isso deve ser o suficiente para fazer o nosso serviço cinema-catalog-service funcionar. Com a criação deste segundo micro serviço, uma série de dúvidas devem surgir. Por exemplo, como manter controle dos múltiplos endpoints entre múltiplos microservices? Como garantir segurança de maneira uniforme entre os microservices sem ter de compartilhar dados de usuários autorizados entre todas bases de dados? E por que cinema-catalog-service não consome movies-service como imaginamos inicialmente?

Eu começo a responder estas e outras perguntas no próximo artigo, sobre API Gateways!