Arquitetura de Software

Arquitetura de projeto node.js à prova de balas

By blogadmin

February 10, 2022

Arquitetura de projeto node.js à prova de balas é um artigo feito a partir de uma tradução do material que foi escrito por Sam Quinn e está disponível na página do softwareontheroad.com no seguinte link.

Introdução

O Express.js é um ótimo framework para criar APIs REST com node.js, mas não nos dá nenhuma pista sobre como organizar nossa Arquitetura de projeto.

Embora possa parecer simples, este é um problema real.

Organizar a estrutura de um projeto em node.js irá evitar a duplicação de código, irá melhorar a estabilidade e, potencialmente, ajudará você a dimensionar seus serviços se for feito da forma certa.

Este artigo é uma extensa pesquisa dos meus anos de experiência lidando com projetos node.js mal estruturados, padrões ruins e incontáveis horas reescrevendo códigos e ajustando coisas.

A Estrutura de Pastas 🏢

Aqui está a arquitetura do projeto node.js da qual estou falando.

Eu uso isso em todos os serviços de node.js REST API que crio, vamos ver em detalhes o que cada componente faz.src │ app.js # App entry point └───api # Express route controllers for all the endpoints of the app └───config # Environment variables and configuration related stuff └───jobs # Jobs definitions for agenda.js └───loaders # Split the startup process into modules └───models # Database models └───services # All the business logic is here └───subscribers # Event handlers for async task └───types # Type declaration files (d.ts) for Typescript

É mais do que apenas uma maneira de ordenar arquivos javascript…

Arquitetura de 3 camadas 🥪

A ideia é usar o princípio da separação de interesses para afastar a lógica de negócios das rotas da API.

Porque algum dia, você vai querer usar sua lógica de negócios em uma ferramenta CLI, ou até mesmo algo mais próximo, como uma tarefa recorrente.

E fazer uma conexão da API do servidor node.js para ela mesmo, não é uma boa ideia…

☠️ Não coloque sua lógica de negócios dentro dos controllers!!☠️

Você pode ficar tentado usar os controllers para armazenar a lógica de negócios do seu aplicativo, mas isso rapidamente se torna um código sem estrutura e difícil de compreender, no momento em que precisar fazer testes de unidade, você acabará lidando com simulações complexas para objetos express.js como req ou res.

É complicado distinguir quando uma resposta deve ser enviada e quando continuar o processamento em ‘background’, digamos depois que a resposta é enviada ao cliente.

Aqui está um exemplo do que não fazer.

route.post('/', async (req, res, next) => {

// This should be a middleware or should be handled by a library like Joi. const userDTO = req.body; const isUserValid = validators.user(userDTO) if(!isUserValid) { return res.status(400).end(); }

// Lot of business logic here... const userRecord = await UserModel.create(userDTO); delete userRecord.password; delete userRecord.salt; const companyRecord = await CompanyModel.create(userRecord); const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

...whatever...

// And here is the 'optimization' that mess up everything. // The response is sent to client... res.json({ user: userRecord, company: companyRecord });

// But code execution continues :( const salaryRecord = await SalaryModel.create(userRecord, companyRecord); eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord); intercom.createUser(userRecord); gaAnalytics.event('user_signup',userRecord); await EmailService.startSignupSequence(userRecord) });

Use uma camada de serviço para sua lógica de negócios 💼

Essa camada é onde sua lógica de negócios deve residir.

É apenas uma coleção de classes com propósitos claros, seguindo os princípios do SOLID aplicados ao node.js.

Nesta camada não deve existir nenhuma forma de ‘SQL Querry’, use a camada de acesso a dados para isso.

Exemplo:

route.post('/', validators.userSignup, // this middleware take care of validation async (req, res, next) => { // The actual responsability of the route layer. const userDTO = req.body;

// Call to service layer. // Abstraction on how to access the data layer and the business logic. const { user, company } = await UserService.Signup(userDTO);

// Return a response to client. return res.json({ user, company }); });

Aqui está como seu serviço funcionará por de trás dos panos.

import UserModel from '../models/user'; import CompanyModel from '../models/company';

export default class UserService() {

async Signup(user) { const userRecord = await UserModel.create(user); const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

...whatever

await EmailService.startSignupSequence(userRecord)

...do more stuff

return { user: userRecord, company: companyRecord }; } }

Visit the example repository

Use uma camada Pub/Sub também 🎙️

O padrão pub/sub vai além da arquitetura clássica de 3 camadas proposta aqui, e é extremamente útil.

O endpoint simples da API node.js, que cria um usuário instantaneamente, pode querer chamar serviços de terceiros, talvez para um serviço de análise ou então iniciar uma sequência de e-mail.

Mais cedo ou mais tarde, essa simples operação de “criar” fará várias coisas e você terminará com 1.000 linhas de código, tudo em uma única função.

Isso viola o princípio da responsabilidade única.

Portanto, é melhor separar as responsabilidades desde o início, para que seu código permaneça sustentável.

import UserModel from '../models/user'; import CompanyModel from '../models/company'; import SalaryModel from '../models/salary';

export default class UserService() {

async Signup(user) { const userRecord = await this.userModel.create(user); const companyRecord = await this.companyModel.create(user); this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord }) return userRecord }

}

Uma chamada obrigatória para um serviço dependente não é a melhor maneira de fazer isso.

Uma abordagem melhor é emitir um evento, ou seja, ‘um usuário se inscreveu com este e-mail’.

E pronto, agora é responsabilidade dos listeners fazerem seu trabalho.

import UserModel from '../models/user'; import CompanyModel from '../models/company'; import SalaryModel from '../models/salary';

export default class UserService() {

async Signup(user) { const userRecord = await this.userModel.create(user); const companyRecord = await this.companyModel.create(user); this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord }) return userRecord }

}

Agora você pode dividir os eventos de handlers/listeners em vários arquivos.

eventEmitter.on('user_signup', ({ user, company }) => {

eventTracker.track( 'user_signup', user, company, );

intercom.createUser( user );

gaAnalytics.event( 'user_signup', user ); })

eventEmitter.on('user_signup', async ({ user, company }) => { const salaryRecord = await SalaryModel.create(user, company); })

eventEmitter.on('user_signup', async ({ user, company }) => { await EmailService.startSignupSequence(user) })

Você pode agrupar as instruções await em um bloco try-catch ou [você pode simplesmente deixá-lo falhar e lidar com o ‘unhandledPromise’ process.on(‘unhandledRejection’,cb)

Injeção de Dependência 💉

Injeção de Dependência (ID) ou inversão de controle (IoC) é um padrão comum que ajudará na organização do seu código, “injetando” ou passando pelo construtor as dependências de sua classe ou função.

Ao fazer isso, você terá a flexibilidade de injetar uma ‘dependência compatível’ quando, por exemplo, escrever os testes de unidade para o serviço ou quando o serviço for usado em outro contexto.

Código sem ID

import UserModel from '../models/user'; import CompanyModel from '../models/company'; import SalaryModel from '../models/salary'; class UserService { constructor(){} Sigup(){ // Calling UserModel, CompanyModel, etc ... } }

Código com injeção manual de dependência

export default class UserService { constructor(userModel, companyModel, salaryModel){ this.userModel = userModel; this.companyModel = companyModel; this.salaryModel = salaryModel; } getMyUser(userId){ // models available throug 'this' const user = this.userModel.findById(userId); return user; } }

Agora você pode injetar dependências personalizadas.

import UserService from '../services/user'; import UserModel from '../models/user'; import CompanyModel from '../models/company'; const salaryModelMock = { calculateNetSalary(){ return 42; } } const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock); const user = await userServiceInstance.getMyUser('12346');

A quantidade de dependências que um serviço pode ter é infinita, e refatorar cada instanciação dele quando você adiciona um novo é uma tarefa chata e que abre espaços para erros.

É por isso que os frameworks de injeção de dependência foram criados.

A ideia é você declarar suas dependências na classe, e quando precisar de uma instância dessa classe, basta chamar o ‘Service Locator’.

Vamos ver um exemplo usando typedi uma biblioteca npm que traz ID para node.js

Você pode ler mais sobre como usar o typedi na documentação oficial

AVISO exemplo de typescript

import { Service } from 'typedi'; @Service() export default class UserService { constructor( private userModel, private companyModel, private salaryModel ){}

getMyUser(userId){ const user = this.userModel.findById(userId); return user; } }

services/user.ts

Agora, o typedi irá resolver qualquer dependência exigida pelo UserService.

import { Container } from 'typedi'; import UserService from '../services/user'; const userServiceInstance = Container.get(UserService); const user = await userServiceInstance.getMyUser('12346');

Usando injeção de dependência com Express.js em Node.js

Usando Injeção de Dependencias. em express.js é a peça final do quebra-cabeça para esta arquitetura de projeto node.js.

Camada de roteamento

route.post('/', async (req, res, next) => { const userDTO = req.body;

const userServiceInstance = Container.get(UserService) // Service locator

const { user, company } = userServiceInstance.Signup(userDTO);

return res.json({ user, company }); });

Incrível, o projeto está ficando ótimo! É tão organizado que me faz querer codar agora mesmo.

Um exemplo de teste unitário 🕵🏻

Ao usar a injeção de dependência e esses padrões de organização, o teste unitário se torna realmente simples.

Você não precisa simular objetos req/res ou require(…) calls.

tests/unit/services/user.js

import UserService from '../../../src/services/user';

describe('User service unit tests', () => { describe('Signup', () => { test('Should create user record and emit user_signup event', async () => { const eventEmitterService = { emit: jest.fn(), };

const userModel = { create: (user) => { return { ...user, _id: 'mock-user-id' } }, };

const companyModel = { create: (user) => { return { owner: user._id, companyTaxId: '12345', } }, };

const userInput= { fullname: 'User Unit Test', email: 'test@example.com', };

const userService = new UserService(userModel, companyModel, eventEmitterService); const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

expect(userRecord).toBeDefined(); expect(userRecord._id).toBeDefined(); expect(eventEmitterService.emit).toBeCalled(); }); }) })

Cron Jobs and recurring task ⚡

Com isso, agora que a lógica de negócios está encapsulada na camada de serviço, é mais fácil usá-la a partir de um Cron Job.

Você nunca deve confiar em node.js setTimeout ou outra forma antiga de atrasar a execução do código, mas em uma arquitetura de projeto que mantenha seus jobs e a execução deles em um banco de dados.

Dessa forma, você terá controle sobre os jobs que falharam e feedback daqueles que obtiveram sucesso. Eu já escrevi sobre boas práticas para isso, confira meu guia sobre como usar agenda.js, o melhor gerenciador de tarefas para node.js.

Configurações e segredos 🤫

Seguindo os conceitos amplamente testados do Twelve-Factor App para node.js, a melhor abordagem para armazenar chaves de API e conexões de string de banco de dados, é usar o dotenv.

Coloque um arquivo .env , que nunca deve ser commitado (mas tem que existir com valores default em seu repositório) então, o pacote npm dotenv carrega o arquivo .env e insere os vars no processo . env de node.js.

Isso pode ser suficiente, mas eu gosto de adicionar um passo extra. Ter um arquivo config/index.ts onde o pacote dotenv npm carrega o arquivo .env e então eu uso um objeto para armazenar as variáveis, assim temos uma estrutura de código que se autocomplementa.

config/index.js

const dotenv = require('dotenv'); // config() will read your .env file, parse the contents, assign it to process.env. dotenv.config();

export default { port: process.env.PORT, databaseURL: process.env.DATABASE_URI, paypal: { publicKey: process.env.PAYPAL_PUBLIC_KEY, secretKey: process.env.PAYPAL_SECRET_KEY, }, paypal: { publicKey: process.env.PAYPAL_PUBLIC_KEY, secretKey: process.env.PAYPAL_SECRET_KEY, }, mailchimp: { apiKey: process.env.MAILCHIMP_API_KEY, sender: process.env.MAILCHIMP_SENDER, } }

Dessa forma, você evita floodar seu código com instruções process.env.MY_RANDOM_VAR, usando o preenchimento automático, você não precisa saber como nomear o env var.

Loaders🏗️

Peguei esse padrão de W3Tech microframework mas sem depender de seu package.

A ideia é que você divida o processo de inicialização do seu serviço node.js em módulos testáveis.

Vamos ver uma inicialização clássica do aplicativo express.js

const mongoose = require('mongoose'); const express = require('express'); const bodyParser = require('body-parser'); const session = require('express-session'); const cors = require('cors'); const errorhandler = require('errorhandler'); const app = express();

app.get('/status', (req, res) => { res.status(200).end(); }); app.head('/status', (req, res) => { res.status(200).end(); }); app.use(cors()); app.use(require('morgan')('dev')); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json(setupForStripeWebhooks)); app.use(require('method-override')()); app.use(express.static(__dirname + '/public')); app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })); mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

require('./config/passport'); require('./models/user'); require('./models/company'); app.use(require('./routes')); app.use((req, res, next) => { var err = new Error('Not Found'); err.status = 404; next(err); }); app.use((err, req, res) => { res.status(err.status || 500); res.json({'errors': { message: err.message, error: {} }}); });

... more stuff

... maybe start up Redis

... maybe add more middlewares

async function startServer() { app.listen(process.env.PORT, err => { if (err) { console.log(err); return; } console.log(`Your server is ready !`); }); }

// Run the async function to start our server startServer();

Como você pode ver, esta parte do seu aplicativo pode ser uma verdadeira bagunça.

Essa é uma forma eficiente de resolve-la

const loaders = require('./loaders'); const express = require('express');

async function startServer() {

const app = express();

await loaders.init({ expressApp: app });

app.listen(process.env.PORT, err => { if (err) { console.log(err); return; } console.log(`Your server is ready !`); }); }

startServer();

Agora os Loaders são apenas pequenos arquivos com um propósito conciso

loaders/index.js

import expressLoader from './express'; import mongooseLoader from './mongoose';

export default async ({ expressApp }) => { const mongoConnection = await mongooseLoader(); console.log('MongoDB Initialized'); await expressLoader({ app: expressApp }); console.log('Express Initialized');

// ... more loaders can be here

// ... Initialize agenda // ... or Redis, or whatever you want }

O express loader

import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as cors from 'cors';

export default async ({ app }: { app: express.Application }) => {

app.get('/status', (req, res) => { res.status(200).end(); }); app.head('/status', (req, res) => { res.status(200).end(); }); app.enable('trust proxy');

app.use(cors()); app.use(require('morgan')('dev')); app.use(bodyParser.urlencoded({ extended: false }));

// ...More middlewares

// Return the express app return app; })

O mongo Loader

import * as mongoose from 'mongoose' export default async (): Promise<any> => { const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true }); return connection.connection.db; }

Conclusão

Nós nos aprofundamos em uma arquitetura de projeto node.js testada em produção, aqui estão algumas dicas resumidas: