Migrando do Express para o Nest.js+TS

Apr 26 2023
Nos últimos 3 meses, trabalhei na migração do aplicativo Express (vanilla JS) existente para Nest.js e Typescript.

Nos últimos 3 meses, trabalhei na migração do aplicativo Express (vanilla JS) existente para Nest.js e Typescript. DB é Sequelize. Não havia testes, linters/embelezadores, então era minha tarefa adicioná-los também.

Durante esse tempo apliquei ou criei algumas técnicas que agregaram mais consistência e clareza ao backend. Outras atualizações estão relacionadas à organização do projeto e escalabilidade da lógica do domínio.

Avançando, estou muito feliz com o resultado final e com o Nest.js em particular. Acontece que era uma estrutura muito flexível, mesmo que eu esteja inclinado para o GraphQL devido à natureza de autodocumentação do último.

Itens de ação, técnicas e atualizações

  1. Estrutura de pastas do projeto;
  2. Testes de integração semiautomáticos baseados em especificações OpenAPI;
  3. Sistema detalhado de tratamento de erros;
  4. Construtor de configuração;
  5. Atualizações de pipeline de CI;
  6. Configuração de linter/embelezador com pré-confirmação;
  7. Configuração de texto datilografado;
  8. Solicitar Contexto;
  9. documentação da API;
  10. migração de modelos de banco de dados;
  11. roteamento aninhado Nest.js;
  12. Migração perfeita para Nest.js

Quando comecei, a estrutura era apenas uma lista de arquivos agrupados funcionalmente.

controllers/**
models/**
services/**
migrations/**
seeders/**
routes/**
middlewares/**
app.js

// inside src folder
application/
  config/**
  middleware/**
  routes/**

domain/
  user/
    service.ts
    controller.ts
    ...
  3rdparty/
    google/
    meta/

database/
  models/**
  migrations/**
  seeders/**

main/
  app.js // starting app file

Nosso BE tem muitos pedidos GET (são 5/6 de todos eles), então decidi cobri-los primeiro e deixar os pedidos de alteração de banco de dados (como POST ou PUT) para unidades/integrações escritas à mão Jest com mais clichês de banco de dados como acessórios e redefinição do banco de dados.

O Nest.js tem um bom suporte OpenAPI que permite obter o esquema OpenAPI gerado a partir do roteamento Nest.js e DTOs.https://docs.nestjs.com/openapi/introduction

const config = new DocumentBuilder()
    .setTitle('Cats example')
    .setDescription('The cats API description')
    .setVersion('1.0')
    .addTag('cats')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  // Generating JSON spec file
  await writeFileSync('openapi.json', JSON.stringify(document));
  SwaggerModule.setup('api', app, document);

{
  paths: {
     "/users/endpoint":
     {
         tags: ['Users'],
         get: {
            responses: {
               200: {
                  examples: {
                    "example-name": {
                       // What API endpoint should return
                       value: 'api response output',
                       // Hacky way to add request params, it used to get
                       // awaited result from side URL
                       externalValues: {
                          route: // route params,
                          get: 
                          body: 
                       }
                    }
                  }
               }
            }
         }
         post: 
         ...
     }
  }
}

@ApiResponse({
   status: 200,
   description: "Successful GET",
   content: {
      "application/json": {
         examples: {
            "example-name": {...}
         }
      }
   }
})

É onde tagsaplicado a partir da estrutura acima.

// controller.spec.ts
import openApiSpec from 'spec.json';

describe("testing users", () => {
  for (let path in openApiSpec.paths) {
     const pathTags = openApiSpec.paths[path].tags;
     if (pathTags.includes("Users")) {
        it("Should test user endpoints", () => {...});
     }
  }
});

// users.controller.ts
@ApiTags('Users')
@Controller()
export class UsersControler {...}

Sistema de tratamento de erros detalhado

Na maioria dos aplicativos, o tratamento de erros se parece com try..catchuma instrução, lançando uma exceção genérica e manipulando-a com uma chamada de sistema de monitoramento externo.

Mas não está respondendo a perguntas como:

  • Em que contexto ocorreu o erro, ou seja, cabeçalhos, tokens, parâmetros?
  • O que o usuário verá no lado FE?
  • Quão detalhada e relacionada à tecnologia a mensagem de erro deve ser para o usuário?
  • Como identificar rapidamente o motivo de um problema?
  • Como o usuário pode explicar exatamente o que foi recebido?

export class BaseErrors {
  constructor() {
    return new Proxy(this, this);
  }

  get(target: any, prop: keyof BaseErrors) {
    const message = this[prop];
    const errorCode = prop.split('_')[0];

    return [
      `${errorCode} - ${message[0]}`,
      `${errorCode} - ${message[1]}`
    ];
  }
}

class UsersErrors extends BaseErrors {
   U0001_UserFetch_Failed = ["Developer message", "User message"];
}

function logError(errorMessage, context) {
   // Developers can see detailed errors in server logs
   console.log(errorMessage[1], context);

   // 3rd party service show detailed error with contexts
   my3rdPartyService.log(
      errorMessage[1], 
      {user: context.user, data: context.data}
   );
   
   // returning for exception catching
   return errorMessage[0];
}

// usage example 
logError(
   // Here Proxy returns array with messages generated from real field on 
   // BaseErrors.get()
   Users.U0001_UserFetch_Failed, 
   {user: Context.user, data: {foo: "bar"}}
);

...

// Somewhere in Nest.js app service
getUser(id) {
  try {...} 
  catch (error) {
     throw new HttpException(logError(
       Users.U0001_UserFetch_Failed, 
       {user: Context.user, data: {foo: "bar"}}
     ));
  }
}

// global exception catcher as an ExceptionFilter in Nest.js
catch(exception: HttpException) {
  const ctx = host.switchToHttp();
  const response = ctx.getResponse<Response>();
  const status = exception.getStatus();
  const errorMessage = exception.getResponse();

  response
    .status(status)
    .json({
      statusCode: status,
      error: errorMessage
    });
}

Como resultado, o usuário verá U0001 - User message, que pode ser relatado à equipe de suporte ao cliente e claro para a equipe DEV. App console terá U0001 - Dev messagecom mais dados. Como um bom recurso, o local de erro é facilmente pesquisável por código de erro que é considerado único em todos os BE.

Construtor de configuração

É fácil perder algum ENV VAR em seu aplicativo quando você tem , , prode stageimagem docker para testes e2e. Para deixar claro o que exatamente está faltando, criei o construtor de configuração:devlocal

function checkAndThrow(
  variable: string | undefined,
  variableName: string | boolean | number
): string {
  if (!variable) {
    throw new Error(`env variable "${variableName}" is required`);
  }
  return variable;
}

class ConfigBuilder {
   public NODE_ENV = "";
   public DB_USER = "";
   
   constructor() {
      Object.keys(this).forEach((key: string) => {
        Object.assign(this, {
          [key]: checkAndThrow(process.env[key], key),
        });
      });
   }
}

Resultados da auditoria de IC:

  1. Não execute e2e em cada push FE PR, geralmente eles levam ~ 1h para um projeto de tamanho médio. Em vez disso, torne-o manual. Você economizará alguns dólares para sua empresa e manterá a fila do corredor de trabalho curta.https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
  2. Tente falhar rápido. Fundamentos antes dos detalhes, verificação rápida antes dos pesos pesados. Construa, typecheck TS (SWC, por favor, faça um typechecker em breve) e teste a execução de seu aplicativo antes do teste de unidade/integração.

É aí que pode ficar complicado com os manuais encenados Husky + lint. Eu prefiro npm i <tool> + package.json changeconfigurações. Outros parecem muito complicados, então, com meus amigos, fizemos uma configuração de plataforma cruzada melhor e mais fácil. Para instalar o linter de pré-confirmação, você precisa executar npm run install-precommit.

<npm i lint-staged, prettier, eslint> + setup prettier and eslint

// package.json
"scripts": {
    "install-precommit": "bash console/postinstall.sh"
},
"lint-staged": {
  "*.js|*.ts": [
    "eslint",
    "prettier --write"
  ]
},

// console/postinstall.sh
mkdir -p .git/hooks/ && cp console/pre-commit .git/hooks/ 
   && chmod +x .git/hooks/pre-commit && echo 'Pre-commit hook copied'

// console/pre-commit
#!/bin/sh

npx lint-staged

Peguei TS + SWC para transpilação e processo de desenvolvimento, TSC para typecheck em CI. Existem dezenas de manuais explicando como configurar TS+SWC, eu usei essehttps://mosano.eu/post/node-swc-ts/#

Solicitar Contexto

É muito conveniente ter variáveis ​​globais relacionadas apenas à solicitação atual, como usuário autorizado, caminho, token de autenticação, etc. Felizmente, existe uma API chamada AsyncLocalStorage. Também o usei para armazenar dados para tratamento de erros. Minha logErrorfunção usa o objeto Context para obter mais dados para monitoramento.

O melhor lugar para configurar um contexto no meu caso foi logo após o usuário ser autorizado no middleware de autenticação.

Configuração do AsyncLocalStoragehttps:///trabe/asynclocalstorage-for-easy-context-passing-in-node-js-e33c84679516

Documentação da API

O Nest.js tem @Api*uma família de decoradores que adiciona documentos de endpoint ao seu BE no SwaggerUI.https://docs.nestjs.com/openapi/decorators

examplesseção na estrutura JSON consumida pelo SwaggerUI e é mostrada como um exemplo de saída do servidor.https://swagger.io/docs/specification/adding-examples/

Para manter os controladores limpos, movi a descrição das respostas para um arquivo separado:

domain/
   users/
      UsersController.ts
      UsersController.spec.sets.ts

Não havia mágica. Acabamos de reescrever todos os mais de 50 modelos Sequelize em TS, mas fizemos modelos antigos e novos para compartilhar a mesma conexão Sequelize. Sugerindo escrever testes de fumaça para todas as relações durante a reescrita, para que você não perca um momento em que o smth seja quebrado.

Roteamento aninhado Nest.js

Nossa API possui estrutura em camadas para que possa ser verificada em diferentes camadas — se o usuário tiver acesso a livros ou páginas relacionadas a um livro específico, por exemplo, /api/user/1/book/23/page/45. Para preservar essa lógica, peguei o módulo Router

https://docs.nestjs.com/recipes/router-module

@Module({ controllers: [BooksController] })
class BooksModule {}

@Module({ controllers: [PagesController] })
class PagesModule {}

@Module({
  imports: [
    BooksModule,
    PageModule,
    RouterModule.register([
      {
        path: '/api/user/:userId',
        children: [
          {
            path: 'book/:bookId',
            module: BooksModule,
            children: [
              {
                path: 'page/:pageId/',
                module: PagesModule,
              },
            ],
          },
        ],
      },
    ]),
  ],
})
export class NestJSModule {}

Você nunca pode migrar tudo de uma só vez, melhor fazê-lo gradualmente. Instalei o Nest.js para trabalhar em paralelo com o Express para poder migrar sem problemas para a nova estrutura.

const subApp = await NestFactory.create(NestJSModule);
await subApp.init();
// old Express app
app.use('/', subApp.getHttpAdapter().getInstance());

Não foi uma viagem fácil. Muitas opções foram tentadas para evitar a mudança total para injeção de dependência semelhante ao Angular, mas ainda pegando as melhores partes do Nest.js. Longas horas gastas investigando e discutindo.

Mas vale totalmente a pena no final. No momento, estamos migrando todo o nosso código para Nest/TS, tendo mais confiança no que escrevemos e implantando todos os dias na produção.