Análise estática de grandes volumes de código Python: experiência no Instagram. Parte 1

O código do servidor do Instagram é escrito exclusivamente em Python. Bem, basicamente é. Utilizamos um pouco de Cython, e as dependências incluem muito código C ++ - que pode ser operado a partir do Python como nas extensões C.



Nosso aplicativo de servidor é um monólito, que é uma grande base de código que consiste em vários milhões de linhas e inclui vários milhares de terminais do Django ( aqui está uma palestra sobre o uso do Django no Instagram). Tudo isso é carregado e servido como uma única entidade. Vários serviços foram alocados a partir do monólito, mas nosso plano não inclui uma forte separação do monólito.

Nosso sistema de servidores é um monólito que muda com muita frequência. Todos os dias, centenas de programadores fazem centenas de confirmações no código. Implantamos continuamente essas alterações, fazendo isso a cada sete minutos. Como resultado, o projeto é implantado na produção cerca de cem vezes por dia. Nós nos esforçamos para garantir que menos de uma hora se passe entre obter uma confirmação no ramo mestre e implantar o código correspondente na produção ( aqui está uma palestra sobre isso feita no PyCon 2019).

É muito difícil manter essa enorme base de código monolítico, fazendo centenas de confirmações diariamente, e ao mesmo tempo não trazê-la a um estado de caos completo. Queremos fazer do Instagram um lugar onde os programadores possam ser produtivos e capazes de preparar rapidamente novos recursos úteis do sistema.

Este material se concentra em como usamos o linting e a refatoração automática para facilitar o gerenciamento de uma base de código Python.

Se você estiver interessado em experimentar algumas das idéias mencionadas neste material, deve saber que recentemente transferimos para a categoria de projetos de código aberto LibCST , que é a base de muitas de nossas ferramentas internas para refração e refatoração automática de código.

A segunda parte

Linting: documentação que aparece onde é necessária


O Linting ajuda os programadores a encontrar e diagnosticar problemas e antipadrões que os próprios desenvolvedores podem não conhecer sem notá-los no código. Isso é importante para nós, devido ao fato de que as idéias relevantes sobre o design do código são mais difíceis de distribuir, mais programadores trabalham no projeto. No nosso caso, estamos falando de centenas de especialistas.


Variedades de fiapos

Linting é apenas um dos muitos tipos de análise de código estático que usamos no Instagram.

A maneira mais primitiva de implementar regras de aprendizagem é usar expressões regulares. Expressões regulares são fáceis de escrever, mas Python não é uma linguagem "regular" . Como resultado, é muito difícil (e às vezes impossível) procurar padrões de forma confiável no código Python usando expressões regulares.

Se falamos das maneiras mais complexas e avançadas de implementar o linter, existem ferramentas como mypy e Pyre . Estes são dois sistemas para verificar estaticamente os tipos de código Python que podem executar uma análise profunda do programa. O Instagram usa Pyre. Essas são ferramentas poderosas, mas são difíceis de expandir e personalizar.

Quando falamos sobre linting no Instagram, geralmente queremos dizer trabalhar com regras simples baseadas em uma árvore de sintaxe abstrata. É precisamente algo assim que subjaz às nossas próprias regras de aprendizagem para o código do servidor.

Quando o Python executa um módulo, ele inicia iniciando o analisador e passando o código-fonte para ele. Graças a isso, uma árvore de análise é criada - um tipo de árvore de sintaxe concreta (CST). Esta árvore é uma representação sem perdas do código fonte de entrada. Todos os detalhes são salvos nesta árvore, como comentários, colchetes e vírgulas. Com base no CST, você pode restaurar completamente o código original.


Árvore de Análise Python (uma variação do CST) gerada pela lib2to3

Infelizmente, essa abordagem leva à criação de uma árvore complexa, o que dificulta a extração de informações semânticas de seu interesse.

O Python compila a árvore de análise em uma árvore de sintaxe abstrata (AST). Algumas informações sobre o código fonte são perdidas durante essa conversão. Estamos falando de "informações sintáticas adicionais" - como comentários, colchetes, vírgulas. No entanto, a semântica do código no AST é mantida.


Árvore de sintaxe abstrata do Python gerada pelo módulo ast

Desenvolvemos o LibCST - uma biblioteca que nos oferece o melhor dos mundos da CST e AST. Ele fornece uma representação do código no qual todas as informações sobre ele são armazenadas (como no CST), mas é fácil extrair informações semânticas sobre ele a partir dessa representação do código (como ao trabalhar com o AST).


Representação de uma árvore de sintaxe LibCST específica

Nossas regras de uso de linting usam a árvore de sintaxe LibCST para encontrar padrões no código. Essa árvore de sintaxe, em um nível alto, é fácil de explorar, pois permite livrar-se dos problemas que acompanham o trabalho com a linguagem "irregular".

Suponha que em um determinado módulo haja uma dependência cíclica devido ao tipo import. Python resolve esse problema colocando comandos de importação de tipo em um bloco if TYPE_CHECKING . Isso é proteção contra a importação de qualquer coisa em tempo de execução.

 #    from typing import TYPE_CHECKING from util import helper_fn #    if TYPE_CHECKING:    from circular_dependency import CircularType 

Mais tarde, alguém adicionou outro tipo import e outro if block ao código. No entanto, quem fez isso pode não saber que esse mecanismo já existe no módulo.

 #    from typing import TYPE_CHECKING from util import helper_fn #    if TYPE_CHECKING:    from circular_dependency import CircularType if TYPE_CHECKING: #   !    from other_package import OtherType 

Você pode se livrar dessa redundância usando a regra linter!

Vamos começar inicializando o contador de blocos "protetores" encontrados no código.

 class OnlyOneTypeCheckingIfBlockLintRule(CstLintRule):    def __init__(self, context: Context) -> None:        super().__init__(context)        self.__type_checking_blocks = 0 

Em seguida, atendendo à condição correspondente, incrementamos o contador e verificamos que não haveria mais de um bloco no código. Se essa condição não for atendida, geramos um aviso no local apropriado no código, chamando o mecanismo auxiliar usado para gerar esses avisos.

 def visit_If(self, node: cst.If) -> None:    if node.test.value == "TYPE_CHECKING":        self.__type_checking_blocks += 1        if self.__type_checking_blocks > 1:            self.context.report(                node,                "More than one 'if TYPE_CHECKING' section!"            ) 

Regras semelhantes de aprendizado funcionam olhando para a árvore do LibCST e coletando informações. No nosso linter, isso é implementado usando o padrão Visitor. Como você deve ter notado, as regras substituem os métodos de visit e deixam os métodos associados ao tipo de nó. Esses "visitantes" são chamados em uma ordem específica.

 class MyNewLintRule(CstLintRule):    def visit_Assign(self, node):        ... #      def visit_Name(self, node):        ... #        def leave_Assign(self, name):        ... #      


Os métodos de visita são chamados antes de visitar descendentes de nós. Os métodos de licença são chamados depois de visitar todos os descendentes

Aderimos aos princípios do trabalho, de acordo com os quais tarefas simples são resolvidas primeiro. Nossa primeira regra de linter foi implementada em um único arquivo, continha um "visitante" e usava um estado compartilhado.


Um arquivo, um "visitante", usando o estado compartilhado

A classe Single Visitor deve ter informações sobre o estado e a lógica de todas as nossas regras de aprendizagem que não estão relacionadas a ela. Além disso, nem sempre é óbvio qual estado corresponde a uma regra específica. Essa abordagem mostra-se bem em uma situação em que existem literalmente algumas de suas próprias regras de fiapos, mas temos cerca de cem dessas regras, o que complica bastante o suporte ao padrão de single-visitor .


É difícil saber qual estado e lógica estão associados a cada uma das verificações.

Obviamente, como uma das soluções possíveis para esse problema, pode-se considerar a definição de vários “visitantes” e a organização de um esquema de trabalho que cada um deles olhe para a árvore inteira de cada vez. No entanto, isso levaria a uma queda séria na produtividade, e o linter é um programa que deve funcionar rapidamente.


Cada regra de linter pode atravessar repetidamente uma árvore. Ao processar um arquivo, as regras são executadas seqüencialmente. No entanto, essa abordagem, que geralmente atravessa a árvore, levaria a uma queda séria no desempenho.

Em vez de implementar algo semelhante em nós mesmos, fomos inspirados pelos linters usados ​​em ecossistemas de outras linguagens de programação - como ESLint, do JavaScript, e criamos um registro centralizado de “visitors” (Visitor Registry).


Registro centralizado de "visitantes". Podemos determinar efetivamente qual nó está interessado em cada regra do linter, economizando tempo em nós que não estão interessados ​​nele.

Quando a regra de linter é inicializada, todas as substituições dos métodos de regra são armazenadas no registro. Quando contornamos a árvore, olhamos para todos os "visitantes" registrados e os chamamos. Se o método não for implementado, significa que você não precisa chamá-lo.

Isso reduz o consumo de recursos de computação do sistema quando novas regras de adição são adicionadas a ele. Normalmente, verificamos com um linter um pequeno número de arquivos modificados recentemente. Mas podemos verificar todas as regras em toda a base de código do servidor do Instagram em paralelo em apenas 26 segundos.

Depois de resolvermos os problemas de desempenho, criamos uma estrutura de teste que visava aderir a técnicas avançadas de programação, exigindo testes em situações em que algo deveria ter alguma qualidade e em situações em que algo não deveria ter alguma qualidade deveria.

 class MyCustomLintRuleTest(CstLintRuleTest):    RULE = MyCustomLintRule       VALID = [        Valid("good_function('this should not generate a report')"),        Valid("foo.bad_function('nor should this')"),    ]       INVALID = [        Invalid("bad_function('but this should')", "IG00"),    ] 

Continuação → segunda parte

Caros leitores! Você usa linters?


Source: https://habr.com/ru/post/pt473766/


All Articles