Hoje, temos cerca de 1,1 milhão de linhas de código TypeScript comprometido em nosso monorepo. O dimensionamento trouxe uma série de desafios, como verificação de tipos lenta, importações inchadas e rastreabilidade de código cada vez mais precária. Nos últimos anos, exploramos e iteramos vários padrões de design para escalar melhor nossa base de código. Um padrão que descobrimos ajudou a aliviar muitas das nossas crescentes dificuldades de organização de código: registros. É simples e um dos padrões mais escaláveis que adotamos.
Nosso código interno original de tratamento de eventos é um bom exemplo de onde esse padrão fez uma enorme diferença. Ele começou simples, mas com o tempo levou a uniões de tipos gigantescas, causando lentidão na verificação de tipos, arquivos barril que importavam código em excesso e código difícil de rastrear devido à concatenação excessiva de strings. Por fim, ele introduziu atrito entre os desenvolvedores na manutenção e adição de novos manipuladores de eventos.
Design original
Em um nível elevado, nosso serviço de eventos é construído sobre uma fila de mensagens (MQ). Os eventos são registrados e enviados para a MQ de qualquer lugar em nosso backend (pods de API, pods de trabalho, etc.) e processados por um trabalhador exatamente uma vez.
O design em si é muito simples; a única restrição principal é que todos os eventos devem ser totalmente serializáveis pela rede. A maior parte da complexidade e manutenção decorre da segurança de tipos e da experiência do desenvolvedor.
1. Defina um conjunto de auxiliares básicos:
2. Defina os tipos de eventos em um arquivo compartilhado
3. Defina manipuladores de eventos em seu próprio arquivo
4. Defina um arquivo de rollup
5. Defina manipuladores de eventos
6. Defina uma função comum para registrar eventos
7. Exporte uma função que processa o manipulador de eventos para a fila em um trabalhador.
À primeira vista, esse design parece bastante adequado. É seguro em termos de tipos, fácil de entender, simples de trabalhar e claro sobre como criar novos eventos. No entanto, havia vários problemas:
Complexidade da verificação de tipos: Para cada novo evento, o AppEvent O tipo fica maior. Individualmente, uma única união não é problemática, mas à medida que o uso desse padrão aumenta em toda a base de código (várias uniões de tipos), o desempenho da verificação de tipos se degrada rapidamente.
Carregamento antecipado do módulo: Nosso padrão de arquivos rollup significava que quase todos os módulos importavam toda a base de código na inicialização, tornando impossível o carregamento lento. Isso também nos impedia de dividir facilmente o servidor em pacotes separados para diferentes implantações. Embora o carregamento antecipado da base de código completa fosse aceitável na produção, isso afetava gravemente o desenvolvimento e os testes, levando mais de 20 a 30 segundos apenas para iniciar o servidor de desenvolvimento.
Rastreabilidade deficiente: Responder a perguntas simples como “onde está a implementação deste evento?” ou “onde este manipulador é chamado?” era difícil. Tínhamos que confiar em pesquisas de texto completo de literais de string. Por exemplo, se um engenheiro decidisse fazer algo assim:
Seria muito difícil rastrear que o wire_sent e wire_criado os eventos são acionados a partir daqui. Pesquisar pela string “wire_sent” no código-fonte não revelaria esse uso, já que o nome é construído dinamicamente. Como resultado, essa informação se torna um conhecimento “tribal” obscuro que acaba ficando na cabeça de alguns poucos engenheiros.
Falta de limites claros entre os domínios: À medida que introduzimos interfaces mais homogêneas (manipuladores de eventos, entidades de banco de dados, verificações de integridade), isso incentivou a colocação de interfaces semelhantes na mesma pasta, em vez de agrupá-las por lógica de domínio. Isso fragmentou nossa lógica de negócios, tornando a troca de contexto específico do domínio mais frequente e complexa.
Iterando neste design
Na configuração acima, colocamos todos os manipuladores de eventos em ./app-event-handlers/<event_type>.ts</event_type> arquivos. Embora tê-los todos em uma única pasta facilitasse a localização, isso não refletia a forma como realmente trabalhávamos. Na prática, agrupar os manipuladores de eventos com o restante da lógica relevante da aplicação se mostrou muito mais útil do que agrupá-los com outros manipuladores.
Foi aí que surgiu a ideia de adicionar subextensões aos arquivos (.event-handler.ts) surgiu. Eles nos permitiram agrupar por domínio, ao mesmo tempo em que possibilitaram uma fácil localização através da pesquisa pela extensão. A extensão do arquivo nos permitiu ainda remover arquivos rollup mantidos manualmente, já que podíamos procurar todos os arquivos correspondentes à extensão no repositório em tempo de execução.
Aqui está uma versão resumida do código básico do registro e como ele funciona. carregarMódulos irá verificar todos os arquivos e registrar todos os objetos exportados com um $discriminador propriedade correspondente ao mesmo símbolo passado para criarRegistro.
Agora, veja como fica a criação do nosso manipulador de eventos usando Registros:
1. Defina um registro em um <name>.registro.ts</name> arquivo:
2. Defina os manipuladores de eventos reais em .app-event-handler.ts arquivos
3. Defina uma função comum para registrar eventos
4. Exportar uma função que processa o manipulador de eventos para a fila em um trabalhador
Algumas diferenças importantes a serem observadas:
A rastreabilidade do código é muito melhorSempre que você registrar um evento, faça-o desta forma:
Isso significa que é fácil rastrear todos os locais onde cardCreatedEventHandler é usado com ferramentas AST como “Encontrar todas as referências” (no VS Code). Por outro lado, quando você vê um registrarEvento chamada, você pode “Ir para implementação” com um clique para encontrar a definição do evento e seu manipulador.
Chega de sindicatos de tipos: Em vez disso, estamos usando tipos básicos, que é algo que TypeScript incentiva a evitar problemas de desempenho na verificação de tipos que grandes uniões podem causar.
Os manipuladores de eventos estão localizados junto com a lógica específica do domínio.Os manipuladores de eventos do aplicativo não são mais armazenados em uma única pasta. Em vez disso, eles são colocados junto com a lógica de negócios relevante. Por exemplo, um serviço específico de domínio pode ter a seguinte aparência:
Trabalhando com registros hoje
Hoje, trabalhamos com dezenas de registros para manter todo o código colocalizado com sua lógica de aplicação. Alguns dos mais notáveis incluem:
.db.tspara registrar entidades do banco de dados.workflows.tse.atividades.tspara se registrar Temporal fluxos de trabalho.checks.tspara registrar exames de saúde (postagem no blog).main.tspara registrar serviços que agrupam lógicas de negócios específicas do domínio.permissão-função.tse.chave-de-permissão.tspara definir permissões RBAC em nosso produto.email-box.tspara registrar manipuladores que analisam e-mails em uma conta do Gmail.cron.tspara registrar tarefas cron.saldo-do-livro-razão.tspara definir nossa primitiva financeira interna "livro-razão".metricas.tspara definir métricas do Datadog
e várias outras extensões específicas do domínio.
No momento, ainda não disponibilizamos esse padrão como código aberto, mas esperamos que este post tenha esclarecido como ele pode ser implementado em outras bases de código. Se você achou isso útil, tente implementá-lo em seus próprios projetos e conte-nos como foi!