Trabalho para uma empresa que desenvolve IDEs para interação com banco de dados há mais de cinco anos. Antes de começar a escrever este artigo, eu não tinha ideia de quantos contos chiques viriam pela frente.
Minha equipe desenvolve e oferece suporte a recursos de linguagem IDE, e o preenchimento automático de código é o principal. Enfrentei muitas coisas interessantes acontecendo. Algumas coisas fizemos muito bem desde a primeira tentativa, e outras falharam mesmo depois de vários tiros.
Análise de SQL e dialetos
SQL é uma tentativa de parecer uma linguagem natural, e a tentativa é bem sucedida, devo dizer. Dependendo do dialeto, existem vários milhares de palavras-chave. Para distinguir uma afirmação da outra, muitas vezes você precisa procurar uma ou duas palavras (tokens) à frente. Essa abordagem é chamada de previsão .
Há uma classificação de analisador dependendo de quão longe eles podem olhar adiante:LA(1), LA(2) ou LA(*), o que significa que um analisador pode olhar o quanto for necessário para definir a bifurcação correta.
Às vezes, um final de cláusula opcional corresponde ao início de outra cláusula opcional. Essas situações tornam a análise muito mais difícil de executar. O T-SQL não facilita as coisas. Além disso, algumas instruções SQL podem ter, mas não necessariamente, finais que podem entrar em conflito com o início de instruções anteriores.
Você não acredita? Existe uma maneira de descrever linguagens formais via gramática. Você pode gerar um analisador usando esta ou aquela ferramenta. As ferramentas e linguagens mais notáveis que descrevem a gramática são YACC e ANTLR.
YACC Os analisadores gerados são usados nos mecanismos MySQL, MariaDB e PostgreSQL. Poderíamos tentar tirá-los diretamente do código-fonte e desenvolver o completar código e outras funções baseadas na análise SQL usando esses analisadores. Além disso, este produto receberia atualizações de desenvolvimento gratuitas e o analisador se comportaria da mesma forma que o mecanismo de origem.
Então, por que ainda estamos usando o ANTLR ? Ele suporta firmemente C#/.NET, tem um kit de ferramentas decente, sua sintaxe é muito mais fácil de ler e escrever. A sintaxe ANTLR tornou-se tão útil que a Microsoft agora a usa em sua documentação oficial em C#.
Mas vamos voltar à complexidade do SQL quando se trata de análise. Gostaria de comparar os tamanhos gramaticais dos idiomas disponíveis publicamente. No dbForge, usamos nossos pedaços de gramática. Eles são mais completos que os outros. Infelizmente, eles estão sobrecarregados com as inserções do código C# para dar suporte a diferentes funções.
Os tamanhos gramaticais para diferentes idiomas são os seguintes:
JS – 475 linhas de analisador + 273 lexers =748 linhas
Java – 615 linhas do analisador + 211 lexers =826 linhas
C# – 1159 linhas do analisador + 433 lexers =1592 linhas
С++ – 1933 linhas
MySQL – 2515 linhas do analisador + 1189 lexers =3704 linhas
T-SQL – 4035 linhas do analisador + 896 lexers =4931 linhas
PL SQL – 6719 linhas do analisador + 2366 lexers =9085 linhas
As terminações de alguns lexers apresentam as listas dos caracteres Unicode disponíveis no idioma. Essas listas são inúteis para a avaliação da complexidade da linguagem. Assim, o número de linhas que tirei sempre terminava antes dessas listas.
Avaliar a complexidade da análise de linguagem com base no número de linhas na gramática da linguagem é discutível. Ainda assim, acredito que é importante mostrar os números que mostram uma grande discrepância.
Isso não é tudo. Como estamos desenvolvendo um IDE, devemos lidar com scripts incompletos ou inválidos. Tivemos que inventar muitos truques, mas os clientes ainda enviam muitos cenários de trabalho com scripts inacabados. Precisamos resolver isso.
Guerras de predicados
Durante a análise do código, a palavra às vezes não informa qual das duas alternativas escolher. O mecanismo que resolve esse tipo de imprecisão é lokahead em ANTLR. O método analisador é a cadeia inserida de if's , e cada um deles olha um passo à frente. Veja o exemplo da gramática que gera a incerteza desse tipo:
rule1:
'a' rule2 | rule3
;
rule2:
'b' 'c' 'd'
;
rule3:
'b' 'c' 'e'
;
No meio da regra1, quando o token 'a' já foi passado, o analisador analisará duas etapas à frente para escolher a regra a seguir. Esta verificação será realizada mais uma vez, mas esta gramática pode ser reescrita para excluir o lokahead . A desvantagem é que tais otimizações prejudicam a estrutura, enquanto o aumento de desempenho é bastante pequeno.
Existem maneiras mais complexas de resolver esse tipo de incerteza. Por exemplo, o predicado de sintaxe (SynPred) mecanismo em ANTLR3 . Ajuda quando o final opcional de uma cláusula cruza o início da próxima cláusula opcional.
Em termos de ANTLR3, um predicado é um método gerado que realiza uma entrada de texto virtual de acordo com uma das alternativas . Quando bem-sucedido, ele retorna o true valor e a conclusão do predicado é bem-sucedida. Quando é uma entrada virtual, é chamado de retrocesso entrada de modo. Se um predicado funcionar com sucesso, a entrada real acontece.
É apenas um problema quando um predicado começa dentro de outro predicado. Então uma distância pode ser cruzada centenas ou milhares de vezes.
Vamos rever um exemplo simplificado. Existem três pontos de incerteza:(A, B, C).
- O analisador insere A, lembra sua posição no texto, inicia uma entrada virtual de nível 1.
- O analisador insere B, lembra sua posição no texto e inicia uma entrada virtual de nível 2.
- O analisador insere C, lembra sua posição no texto e inicia uma entrada virtual de nível 3.
- O analisador conclui uma entrada virtual de nível 3, retorna ao nível 2 e passa C novamente.
- O analisador conclui uma entrada virtual de nível 2, retorna ao nível 1 e passa B e C novamente.
- O analisador conclui uma entrada virtual, retorna e executa uma entrada real por meio de A, B e C.
Como resultado, todas as verificações dentro de C serão feitas 4 vezes, dentro de B – 3 vezes, dentro de A – 2 vezes.
Mas e se uma alternativa adequada estiver na segunda ou na terceira da lista? Em seguida, um dos estágios de predicado falhará. Sua posição no texto será revertida e outro predicado começará a ser executado.
Ao analisar os motivos do congelamento do aplicativo, muitas vezes nos deparamos com o rastro do SynPred executado milhares de vezes. SynPred s são especialmente problemáticos em regras recursivas. Infelizmente, o SQL é recursivo por natureza. A capacidade de usar subconsultas em quase todos os lugares tem seu preço. No entanto, é possível manipular a regra para fazer um predicado desaparecer.
SynPred prejudica o desempenho. Em algum momento, seu número foi colocado sob rígido controle. Mas o problema é que quando você escreve o código gramatical, o SynPred pode parecer não óbvio para você. Mais ainda, alterar uma regra pode fazer com que o SynPred apareça em outra regra, e isso torna o controle sobre elas praticamente impossível.
Criamos uma expressão regular simples ferramenta para controlar o número de predicados executados pela Tarefa MSBuild especial . Se o número de predicados não corresponder ao número especificado em um arquivo, a tarefa falhou imediatamente na compilação e alertou sobre um erro.
Ao ver o erro, um desenvolvedor deve reescrever o código da regra várias vezes para remover os predicados redundantes. Se não for possível evitar predicados, o desenvolvedor o adicionaria a um arquivo especial que chamaria atenção extra para a revisão.
Em raras ocasiões, até escrevemos nossos predicados usando C# apenas para evitar os gerados pelo ANTLR. Felizmente, este método também existe.
Herança gramatical
Quando houver alguma alteração em nossos SGBDs suportados, temos que atendê-la em nossas ferramentas. O suporte para construções de sintaxe gramatical é sempre um ponto de partida.
Criamos uma gramática especial para cada dialeto SQL. Ele permite alguma repetição de código, mas é mais fácil do que tentar encontrar o que eles têm em comum.
Optamos por escrever nosso próprio pré-processador gramatical ANTLR que faz a herança gramatical.
Também ficou óbvio que precisávamos de um mecanismo para polimorfismo – a capacidade de não apenas redefinir a regra no descendente, mas também chamar a regra básica. Também gostaríamos de controlar a posição ao chamar a regra base.
As ferramentas são uma vantagem definitiva quando comparamos o ANTLR com outras ferramentas de reconhecimento de linguagem, Visual Studio e ANTLRWorks. E você não quer perder essa vantagem ao implementar a herança. A solução foi especificar a gramática básica em uma gramática herdada em um formato de comentário ANTLR. Para ferramentas ANTLR é apenas um comentário, mas podemos extrair dele todas as informações necessárias.
Escrevemos uma tarefa MsBuild que foi incorporada ao sistema de compilação inteira como ação de pré-compilação. A tarefa era fazer o trabalho de um pré-processador para a gramática ANTLR gerando a gramática resultante de sua base e de seus pares herdados. A gramática resultante foi processada pelo próprio ANTLR.
Pós-processamento ANTLR
Em muitas linguagens de programação, palavras-chave não podem ser usadas como nomes de assunto. Pode haver de 800 a 3000 palavras-chave no SQL, dependendo do dialeto. A maioria deles está ligada ao contexto dentro dos bancos de dados. Assim, proibi-los como nomes de objetos frustraria os usuários. É por isso que o SQL tem palavras-chave reservadas e não reservadas.
Você não pode nomear seu objeto como a palavra reservada (SELECT, FROM, etc.) sem citá-lo, mas você pode fazer isso com uma palavra não reservada (CONVERSATION, AVAILABILITY, etc.). Essa interação dificulta o desenvolvimento do analisador.
Durante a análise léxica, o contexto é desconhecido, mas um analisador já requer números diferentes para o identificador e a palavra-chave. É por isso que adicionamos outro pós-processamento ao analisador ANTLR. Ele substituiu todas as verificações óbvias de identificadores pela chamada de um método especial.
Este método tem uma verificação mais detalhada. Se a entrada chama um identificador e esperamos que o identificador seja atendido em diante, tudo bem. Mas se uma palavra não reservada for uma entrada, devemos verificar novamente. Essa verificação extra analisa a pesquisa de ramificação no contexto atual em que essa palavra-chave não reservada pode ser uma palavra-chave. Se não houver tais ramificações, ele pode ser usado como um identificador.
Tecnicamente, esse problema poderia ser resolvido por meio do ANTLR, mas essa decisão não é a ideal. A maneira ANTLR é criar uma regra que liste todas as palavras-chave não reservadas e um identificador de lexema. Mais adiante, uma regra especial servirá em vez de um identificador de lexema. Esta solução faz com que o desenvolvedor não esqueça de adicionar a palavra-chave onde ela é usada e na regra especial. Além disso, otimiza o tempo gasto.
Erros na análise de sintaxe sem árvores
A árvore de sintaxe geralmente é resultado do trabalho do analisador. É uma estrutura de dados que reflete o texto do programa através da gramática formal. Se você deseja implementar um editor de código com o preenchimento automático de linguagem, provavelmente obterá o seguinte algoritmo:
- Analisar o texto no editor. Então você obtém uma árvore de sintaxe.
- Encontre um nó sob o carro e compare-o com a gramática.
- Descubra quais palavras-chave e tipos de objetos estarão disponíveis no Point.
Neste caso, a gramática é fácil de imaginar como um Grafo ou uma Máquina de Estados.
Infelizmente, apenas a terceira versão do ANTLR estava disponível quando o IDE dbForge iniciou seu desenvolvimento. No entanto, não era tão ágil e, embora você pudesse dizer ao ANTLR como construir uma árvore, o uso não era suave.
Além disso, muitos artigos sobre esse tópico sugeriram o uso do mecanismo de 'ações' para executar o código quando o analisador estava passando pela regra. Esse mecanismo é muito útil, mas levou a problemas de arquitetura e tornou o suporte a novas funcionalidades mais complexo.
O problema é que um único arquivo de gramática começou a acumular ‘ações’ devido ao grande número de funcionalidades que deveriam ter sido distribuídas para diferentes compilações. Conseguimos distribuir manipuladores de ações para diferentes compilações e fazer uma variação sorrateira do padrão de notificação de assinante para essa medida.
O ANTLR3 funciona 6 vezes mais rápido que o ANTLR4 de acordo com nossas medições. Além disso, a árvore de sintaxe para scripts grandes poderia consumir muita RAM, o que não era uma boa notícia, então precisávamos operar dentro do espaço de endereço de 32 bits do Visual Studio e do SQL Management Studio.
Pós-processamento do analisador ANTLR
Ao trabalhar com strings, um dos momentos mais críticos é a fase de análise lexical onde dividimos o script em palavras separadas.
O ANTLR usa como gramática de entrada que especifica o idioma e gera um analisador em um dos idiomas disponíveis. Em algum momento, o analisador gerado cresceu a tal ponto que ficamos com medo de depurá-lo. Se você pressionar F11 (entrar) ao depurar e ir para o arquivo do analisador, o Visual Studio simplesmente travará.
Acontece que ele falhou devido a uma exceção OutOfMemory ao analisar o arquivo do analisador. Este arquivo continha mais de 200.000 linhas de código.
Mas depurar o analisador é uma parte essencial do processo de trabalho e você não pode omiti-lo. Com a ajuda de classes parciais de C#, analisamos o analisador gerado usando expressões regulares e o dividimos em alguns arquivos. O Visual Studio funcionou perfeitamente com ele.
Análise léxica sem substring antes da API Span
A principal tarefa da análise lexical é a classificação – definir os limites das palavras e compará-los com um dicionário. Se a palavra for encontrada, o lexer retornará seu índice. Caso contrário, a palavra é considerada um identificador de objeto. Esta é uma descrição simplificada do algoritmo.
Lexing em segundo plano durante a abertura do arquivo
O realce de sintaxe é baseado na análise léxica. Essa operação geralmente leva muito mais tempo em comparação com a leitura de texto do disco. Qual é a pegadinha? Em um thread, o texto está sendo lido do arquivo, enquanto a análise léxica é realizada em um thread diferente.
O lexer lê o texto linha por linha. Se ele solicitar uma linha que não existe, ele irá parar e esperar.
BlockingCollection
- Ler de um arquivo é um produtor, enquanto lexer é um consumidor.
- Lexer já é um produtor e o editor de texto é um consumidor.
Esse conjunto de truques nos permite reduzir significativamente o tempo gasto na abertura de arquivos grandes. A primeira página do documento é mostrada muito rapidamente, no entanto, o documento pode congelar se os usuários tentarem ir para o final do arquivo nos primeiros segundos. Isso acontece porque o leitor de segundo plano e o lexer precisam chegar ao final do documento. No entanto, se o usuário trabalhar lentamente do início do documento para o final, não haverá congelamentos perceptíveis.
Otimização ambígua:análise léxica parcial
A análise sintática é geralmente dividida em dois níveis:
- o fluxo de caracteres de entrada é processado para obter lexemas (tokens) com base nas regras da linguagem – isso é chamado de análise léxica
- o analisador consome fluxo de token verificando-o de acordo com as regras gramaticais formais e geralmente cria uma árvore de sintaxe.
O processamento de strings é uma operação cara. Para otimizá-lo, decidimos não realizar uma análise léxica completa do texto todas as vezes, mas reanalisar apenas a parte que foi alterada. Mas como lidar com construções de várias linhas, como comentários em bloco ou linhas? Armazenamos um estado de final de linha para cada linha:“sem tokens de várias linhas” =0, “o início de um comentário de bloco” =1, “o início de um literal de string de várias linhas” =2. A análise léxica começa na seção alterada e termina quando o estado final da linha é igual ao armazenado.
Houve um problema com esta solução:é extremamente inconveniente monitorar números de linha em tais estruturas, enquanto o número de linha é um atributo obrigatório de um token ANTLR porque quando uma linha é inserida ou excluída, o número da próxima linha deve ser atualizado de acordo. Resolvemos isso definindo um número de linha imediatamente, antes de entregar o token ao analisador. Os testes que realizamos posteriormente mostraram que o desempenho melhorou em 15-25%. A melhoria real foi ainda maior.
A quantidade de RAM necessária para tudo isso acabou sendo muito maior do que esperávamos. Um token ANTLR consistia em:um ponto inicial – 8 bytes, um ponto final – 8 bytes, um link para o texto da palavra – 4 ou 8 bytes (sem mencionar a string em si), um link para o texto do documento – 4 ou 8 bytes, e um tipo de token – 4 bytes.
Então, o que podemos concluir? Focamos no desempenho e conseguimos um consumo excessivo de RAM em um lugar que não esperávamos. Não assumimos que isso aconteceria porque tentamos usar estruturas leves em vez de classes. Ao substituí-los por objetos pesados, conscientemente optamos por despesas adicionais de memória para obter melhor desempenho. Felizmente, isso nos ensinou uma lição importante, então agora cada otimização de desempenho termina com o perfil do consumo de memória e vice-versa.
Esta é uma história com uma moral. Alguns recursos começaram a funcionar quase instantaneamente e outros um pouco mais rápido. Afinal, seria impossível realizar o truque de análise léxica em segundo plano se não houvesse um objeto onde uma das threads pudesse armazenar tokens.
Todos os outros problemas se desdobram no contexto de desenvolvimento de desktop na pilha .NET.
O problema de 32 bits
Alguns usuários optam por usar versões independentes de nossos produtos. Outros continuam trabalhando dentro do Visual Studio e do SQL Server Management Studio. Muitas extensões são desenvolvidas para eles. Uma dessas extensões é o SQL Complete. Para esclarecer, ele fornece mais poderes e recursos do que o SSMS de conclusão de código padrão e VS para SQL.
A análise SQL é um processo muito caro, tanto em termos de recursos de CPU quanto de RAM. Para solicitar a lista de objetos nos scripts do usuário, sem chamadas desnecessárias ao servidor, armazenamos o cache de objetos na RAM. Muitas vezes, não ocupa muito espaço, mas alguns de nossos usuários têm bancos de dados que contêm até um quarto de milhão de objetos.
Trabalhar com SQL é bem diferente de trabalhar com outras linguagens. Em C#, praticamente não há arquivos mesmo com mil linhas de código. Enquanto isso, no SQL, um desenvolvedor pode trabalhar com um dump de banco de dados que consiste em vários milhões de linhas de código. Não há nada de incomum nisso.
DLL-Inferno dentro do VS
Existe uma ferramenta útil para desenvolver plugins no .NET Framework, é um domínio de aplicação. Tudo é feito de forma isolada. É possível descarregar. Na maioria das vezes, a implementação de extensões é, talvez, a principal razão pela qual os domínios de aplicação foram introduzidos.
Além disso, existe o MAF Framework, que foi desenvolvido pela MS para resolver o problema de criação de complementos para o programa. Ele isola esses complementos de tal forma que pode enviá-los para um processo separado e assumir todas as comunicações. Francamente falando, esta solução é muito complicada e não ganhou muita popularidade.
Infelizmente, o Microsoft Visual Studio e o SQL Server Management Studio construídos sobre ele implementam o sistema de extensão de maneira diferente. Ele simplifica o acesso a aplicativos de hospedagem para plugins, mas os força a se encaixar em um processo e domínio com outro.
Assim como qualquer outro aplicativo do século 21, o nosso tem muitas dependências. A maioria delas são bibliotecas conhecidas, comprovadas e populares no mundo .NET.
Puxar mensagens dentro de um cadeado
Não é amplamente conhecido que o .NET Framework irá bombear o Windows Message Queue dentro de cada WaitHandle. Para colocá-lo dentro de cada bloqueio, qualquer manipulador de qualquer evento em um aplicativo pode ser chamado se esse bloqueio tiver tempo para alternar para o modo kernel e não for liberado durante a fase de espera de rotação.
Isso pode resultar em reentrada em alguns lugares muito inesperados. Algumas vezes isso levou a problemas como “A coleção foi modificada durante a enumeração” e vários ArgumentOutOfRangeException.
Adicionar um assembly a uma solução usando SQL
Quando o projeto cresce, a tarefa de adicionar assemblies, simples no início, se desenvolve em uma dúzia de etapas complicadas. Uma vez, tivemos que adicionar uma dúzia de assemblies diferentes à solução, realizamos uma grande refatoração. Cerca de 80 soluções, incluindo produtos e testes, foram criadas com base em cerca de 300 projetos .NET.
Com base nas soluções do produto, escrevemos os arquivos Inno Setup. Eles incluíam listas de assemblies empacotados na instalação que o usuário baixou. O algoritmo de adição de um projeto foi o seguinte:
- Crie um novo projeto.
- Adicione um certificado a ele. Configure a tag da compilação.
- Adicione um arquivo de versão.
- Reconfigure os caminhos por onde o projeto está indo.
- Renomeie a pasta para corresponder à especificação interna.
- Adicione o projeto à solução novamente.
- Adicione algumas montagens para as quais todos os projetos precisam de links.
- Adicione a versão a todas as soluções necessárias:teste e produto.
- Para todas as soluções de produtos, adicione os conjuntos à instalação.
Estes 9 passos tiveram que ser repetidos cerca de 10 vezes. As etapas 8 e 9 não são tão triviais e é fácil esquecer de adicionar compilações em todos os lugares.
Diante de uma tarefa tão grande e rotineira, qualquer programador normal gostaria de automatizá-la. Isso é exatamente o que queríamos fazer. Mas como indicamos exatamente quais soluções e instalações adicionar ao projeto recém-criado? Existem tantos cenários e, além disso, é difícil prever alguns deles.
Nós tivemos uma ideia maluca. As soluções estão conectadas a projetos como muitos para muitos, projetos com instalações da mesma maneira, e o SQL pode resolver exatamente o tipo de tarefa que tínhamos.
Criamos um aplicativo .Net Core Console que verifica todos os arquivos .sln na pasta de origem, recupera a lista de projetos deles com a ajuda do DotNet CLI e os coloca no banco de dados SQLite. O programa tem alguns modos:
- Novo – cria um projeto e todas as pastas necessárias, adiciona um certificado, configura uma tag, adiciona uma versão, assemblies essenciais mínimos.
- Add-Project – adiciona o projeto a todas as soluções que satisfaçam a consulta SQL que será fornecida como um dos parâmetros. Para adicionar o projeto à solução, o programa interno usa o DotNet CLI.
- Add-ISS – adiciona o projeto a todas as instalações, que satisfaçam as consultas SQL.
Embora a ideia de indicar a lista de soluções através da consulta SQL possa parecer complicada, ela fechou completamente todos os casos existentes e provavelmente todos os casos possíveis no futuro.
Deixe-me demonstrar o cenário. Crie um projeto “A” e adicioná-lo a todas as soluções em que projetos “B” é usado:
dbforgeasm add-project Folder1\Folder2\A "SELECT s.Id FROM Projects p JOIN Solutions s ON p.SolutionId = s.Id WHERE p.Name = 'B'"
Um problema com o LiteDB
Alguns anos atrás, recebemos a tarefa de desenvolver uma função em segundo plano para salvar documentos do usuário. Ele tinha dois fluxos principais de aplicativos:a capacidade de fechar instantaneamente o IDE e sair, e ao retornar ao início de onde você parou, e a capacidade de restaurar em situações urgentes, como apagões ou falhas de programa.
Para implementar essa tarefa, era necessário salvar o conteúdo dos arquivos em algum lugar ao lado, e fazê-lo com frequência e rapidez. Além do conteúdo, foi necessário salvar alguns metadados, o que tornou inconveniente o armazenamento direto no sistema de arquivos.
Nesse ponto, encontramos a biblioteca LiteDB, que nos impressionou com sua simplicidade e desempenho. LiteDB é um banco de dados embutido leve e rápido, que foi inteiramente escrito em C#. A velocidade e a simplicidade geral nos conquistaram.
No decorrer do processo de desenvolvimento, toda a equipe ficou satisfeita em trabalhar com o LiteDB. Os principais problemas, no entanto, começaram após o lançamento.
A documentação oficial garantia que o banco de dados assegurava o funcionamento adequado com acesso simultâneo de vários threads, bem como vários processos. Testes sintéticos agressivos mostraram que o banco de dados não funciona corretamente em um ambiente multithread.
Para corrigir o problema rapidamente, sincronizamos os processos com a ajuda do interprocesso auto-escrito ReadWriteLock. Agora, depois de quase três anos, o LiteDB está funcionando muito melhor.
StreamStringList
Este problema é o oposto do caso da análise lexical parcial. Quando trabalhamos com um texto, é mais conveniente trabalhar com ele como uma lista de strings. Strings podem ser solicitadas em ordem aleatória, mas certa densidade de acesso à memória ainda está presente. Em algum momento, foi necessário executar várias tarefas para processar arquivos muito grandes sem carga total de memória. A ideia era a seguinte:
- Para ler o arquivo linha por linha. Lembre-se de deslocamentos no arquivo.
- A pedido, emita a próxima linha, defina um deslocamento necessário e retorne os dados.
A tarefa principal está concluída. Essa estrutura não ocupa muito espaço em comparação com o tamanho do arquivo. Na fase de teste, verificamos minuciosamente o consumo de memória para arquivos grandes e muito grandes. Arquivos grandes foram processados por um longo tempo e os pequenos serão processados imediatamente.
Não houve referência para verificar o tempo de execução . A RAM é chamada de Memória de Acesso Aleatório – é sua vantagem competitiva sobre o SSD e especialmente sobre o HDD. Esses drivers começam a funcionar mal para acesso aleatório. Descobriu-se que essa abordagem atrasou o trabalho em quase 40 vezes, em comparação com o carregamento completo de um arquivo na memória. Além disso, lemos o arquivo 2,5 -10 vezes, dependendo do contexto.
A solução foi simples, e a melhoria foi suficiente para que a operação demorasse apenas um pouco mais do que quando o arquivo está totalmente carregado na memória.
Da mesma forma, o consumo de RAM também foi insignificante. Encontramos inspiração no princípio de carregar dados da RAM em um processador de cache. Quando você acessa um elemento de matriz, o processador copia dezenas de elementos vizinhos para seu cache porque os elementos necessários geralmente estão próximos.
Muitas estruturas de dados usam essa otimização do processador para obter o melhor desempenho. É por causa dessa peculiaridade que o acesso aleatório aos elementos do array é muito mais lento que o acesso sequencial. Implementamos um mecanismo semelhante:lemos um conjunto de mil strings e lembramos seus deslocamentos. Quando acessamos a 1001ª string, descartamos as primeiras 500 strings e carregamos as próximas 500. Caso precisemos de alguma das primeiras 500 linhas, então vamos a ela separadamente, pois já temos o offset.
O programador não precisa necessariamente formular e verificar cuidadosamente os requisitos não funcionais. Como resultado, lembramos para casos futuros, que precisamos trabalhar sequencialmente com memória persistente.
Analisando as exceções
Você pode coletar dados de atividade do usuário facilmente na web. No entanto, não é o caso da análise de aplicativos de desktop. Não existe essa ferramenta capaz de fornecer um conjunto incrível de métricas e ferramentas de visualização como o Google Analytics. Por quê? Aqui estão minhas suposições:
- Durante a maior parte da história do desenvolvimento de aplicativos para desktop, eles não tiveram acesso estável e permanente à Web.
- Existem muitas ferramentas de desenvolvimento para aplicativos de desktop. Portanto, é impossível criar uma ferramenta de coleta de dados de usuário multifuncional para todas as estruturas e tecnologias de interface do usuário.
Um aspecto fundamental da coleta de dados é rastrear exceções. Por exemplo, coletamos dados sobre falhas. Anteriormente, nossos usuários precisavam escrever para o e-mail de suporte ao cliente, adicionando um Stack Trace de um erro, que era copiado de uma janela especial do aplicativo. Poucos usuários seguiram todas essas etapas. Os dados coletados são totalmente anonimizados, o que nos priva da oportunidade de conhecer as etapas de reprodução ou qualquer outra informação do usuário.
Por outro lado, os dados de erro estão no banco de dados Postgres, e isso abre caminho para uma verificação instantânea de dezenas de hipóteses. Você pode obter as respostas imediatamente simplesmente fazendo consultas SQL ao banco de dados. Muitas vezes não é claro a partir de apenas uma pilha ou tipo de exceção como a exceção ocorreu, é por isso que todas essas informações são críticas para estudar o problema.
Além disso, você tem a oportunidade de analisar todos os dados coletados e encontrar os módulos e aulas mais problemáticos. Com base nos resultados da análise, você pode planejar a refatoração ou testes adicionais para cobrir essas partes do programa.
Serviço de decodificação de pilha
As compilações .NET contêm código IL, que pode ser facilmente convertido novamente em código C#, preciso para o operador, usando vários programas especiais. Uma das maneiras de proteger o código do programa é sua ofuscação. Os programas podem ser renomeados; métodos, variáveis e classes podem ser substituídos; código pode ser substituído por seu equivalente, mas é realmente incompreensível.
A necessidade de ofuscar o código-fonte aparece quando você distribui seu produto de forma a sugerir que o usuário obtenha as compilações de seu aplicativo. Os aplicativos de desktop são esses casos. Todas as compilações, incluindo compilações intermediárias para testadores, são cuidadosamente ofuscadas.
Nossa Unidade de Garantia de Qualidade usa ferramentas de pilha de decodificação do desenvolvedor do ofuscador. Para iniciar a decodificação, eles precisam executar o aplicativo, localizar mapas de desofuscação publicados pelo CI para uma compilação específica e inserir a pilha de exceção no campo de entrada.
Diferentes versões e editores foram ofuscados de maneira diferente, o que dificultou para um desenvolvedor estudar o problema ou poderia até colocá-lo no caminho errado. Era óbvio que este processo tinha que ser automatizado.
O formato do mapa de desofuscação acabou sendo bastante direto. Nós o desanalisamos facilmente e escrevemos um programa de decodificação de pilha. Pouco antes disso, uma interface do usuário da web foi desenvolvida para renderizar exceções por versões de produtos e agrupá-las pela pilha. Era um site .NET Core com um banco de dados em SQLite.
SQLite é uma ferramenta legal para pequenas soluções. Tentamos colocar mapas de desofuscação lá também. Cada compilação gerou aproximadamente 500 mil pares de criptografia e descriptografia. SQLite não poderia lidar com uma taxa de inserção tão agressiva.
Enquanto os dados de uma compilação foram inseridos no banco de dados, mais dois foram adicionados à fila. Pouco antes desse problema, eu estava ouvindo uma reportagem sobre o Clickhouse e estava ansioso para experimentá-lo. Provou ser excelente, a taxa de inserção acelerou em mais de 200 vezes.
Dito isso, a decodificação da pilha (leitura do banco de dados) diminuiu quase 50 vezes, mas como cada pilha levava menos de 1 ms, era ineficaz em termos de custo gastar tempo estudando esse problema.
ML.NET para classificação de exceções
Em relação ao processamento automático de exceções, fizemos mais algumas melhorias.
Já tínhamos o Web-UI para uma revisão conveniente de exceções agrupadas por pilhas. Tínhamos um Grafana para análise de alto nível de estabilidade do produto ao nível de versões e linhas de produtos. But a programmer’s eye, constantly craving optimization, caught another aspect of this process.
Historically, dbForge line development was divided among 4 teams. Each team had its own functionality to work on, though the borderline was not always obvious. Our technical support team, relying on their experience, read the stack and assigned it to this or that team. They managed it quite well, yet, in some cases, mistakes occurred. The information on errors from analytics came to Jira on its own, but the support team still needed to classify tasks by team.
In the meantime, Microsoft introduced a new library – ML.NET. And we still had this classification task. A library of that kind was exactly what we needed. It extracted stacks of all resolved exceptions from Redmine, a project management system that we used earlier, and Jira, which we use at present.
We obtained a data array that contained some 5 thousand pairs of Exception StackTrace and command. We put 100 exceptions aside and used the rest of the exceptions to teach a model. The accuracy was about 75%. Again, we had 4 teams, hence, random and round-robin would only attain 25%. It sufficiently saved up their time.
To my way of thinking, if we considerably clean up incoming data array, make a thorough examination of the ML.NET library, and theoretical foundation in machine learning, on the whole, we can improve these results. At the same time, I was impressed with the simplicity of this library:with no special knowledge in AI and ML, we managed to gain real cost-benefits in less than an hour.
Conclusion
Hopefully, some of the readers happen to be users of the products I describe in this article, and some lines shed light on the reasons why this or that function was implemented this way.
And now, let me conclude:
We should make decisions based on data and not assumptions. It is about behavior analytics and insights that we can obtain from it.
We ought to constantly invest in tools. There is nothing wrong if we need to develop something for it. In the next few months, it will save us a lot of time and rid us of routine. Routine on top of time expenditure can be very demotivating.
When we develop some internal tools, we get a super chance to try out new technologies, which can be applied in production solutions later on.
There are infinitely many tools for data analysis. Still, we managed to extract some crucial information using SQL tools. This is the most powerful tool to formulate a question to data and receive an answer in a structured form.