A técnica de desenvolver servidores altamente confiáveis ​​no Go

De tempos em tempos, programadores da Web enfrentam tarefas que podem até assustar profissionais. Estamos falando sobre o desenvolvimento de aplicativos de servidor que não têm o direito de cometer erros, sobre projetos nos quais o custo da falha é extremamente alto. O autor do material, cuja tradução publicamos hoje, falará sobre como abordar essas tarefas.



Qual o nível de confiabilidade que seu projeto precisa?


Antes de se aprofundar nos detalhes do desenvolvimento de aplicativos de servidor altamente confiáveis, você deve se perguntar se o seu projeto realmente precisa do mais alto nível possível de confiabilidade. O processo de desenvolvimento de sistemas projetados para cenários de trabalho nos quais o erro é semelhante a uma catástrofe universal pode ser irracionalmente complicado para a maioria dos projetos nos quais as consequências de possíveis erros não são particularmente assustadoras.

Se o custo do erro não for extremamente alto, é aceitável uma abordagem, na implementação em que o desenvolvedor faz os esforços mais razoáveis ​​para garantir a operacionalidade do projeto e, se surgirem problemas, ele simplesmente os entende. Ferramentas modernas de monitoramento e processos contínuos de implantação de software permitem identificar rapidamente problemas de produção e corrigi-los quase instantaneamente. Em muitos casos, isso é suficiente.

No projeto em que estou trabalhando hoje, não é assim. Estamos falando da implementação do blockchain - uma infraestrutura de servidor distribuído para a execução segura de código em um ambiente com baixo nível de confiança e consenso. Uma aplicação dessa tecnologia são as moedas digitais. Este é um exemplo clássico de um sistema com um custo de erro extremamente alto. Nesse caso, os desenvolvedores do projeto realmente precisam torná-lo muito, muito confiável.

No entanto, em alguns outros projetos, mesmo que não estejam relacionados a finanças, faz sentido a busca pela maior confiabilidade do código. O custo de manutenção de uma base de código que freqüentemente quebra pode atingir rapidamente valores astronômicos. A capacidade de identificar problemas nos estágios iniciais do processo de desenvolvimento, quando o custo para corrigi-los ainda é baixo, parece uma recompensa muito real pelo investimento oportuno de tempo e esforço na metodologia de desenvolvimento de sistemas altamente confiáveis.

Talvez a solução seja TDD?


O desenvolvimento por meio de testes ( Test Driven Development , TDD) é frequentemente considerado a melhor solução para códigos ruins. TDD é uma metodologia de desenvolvimento purista, na aplicação da qual os testes são escritos primeiro, e somente então - código que é adicionado ao projeto somente quando os testes que o verificam param de gerar erros. Esse processo garante 100% de cobertura do código com testes e geralmente dá a ilusão de que o código é testado em todas as variantes possíveis de seu uso.

No entanto, isso não é verdade. O TDD é uma ótima metodologia que funciona bem em algumas áreas, mas para desenvolver um código realmente confiável, mas não o suficiente. Pior ainda, o TDD inspira o desenvolvedor com falsa confiança e a aplicação dessa metodologia pode levar ao fato de que ele simplesmente não irá, por preguiça, escrever testes para verificar se há falhas no sistema em situações cuja ocorrência, do ponto de vista do senso comum, é quase impossível. Falaremos sobre isso mais tarde.

Os testes são a chave para a confiabilidade


Na verdade, não importa se você cria testes antes de escrever o código ou depois, se usa uma metodologia de desenvolvimento como TDD ou não. O principal é o fato de ter testes. Os testes são a melhor fortificação defensiva que protege seu código contra problemas de produção.

Como vamos executar nossos testes com muita frequência, idealmente após adicionar cada nova linha ao código, é necessário que os testes sejam automatizados. Nossa confiança na qualidade do código não deve se basear em suas verificações manuais. O problema é que as pessoas tendem a cometer erros. A atenção de uma pessoa aos detalhes é enfraquecida depois que ela realiza a mesma tarefa assustadora várias vezes seguidas.

Os testes devem ser rápidos. Muito rapido

Se levar mais de alguns segundos para concluir o conjunto de testes, os desenvolvedores provavelmente serão preguiçosos e adicionarão código ao projeto sem testá-lo. A velocidade é um dos maiores pontos fortes de Go. O kit de ferramentas de desenvolvimento nesse idioma é um dos mais rápidos entre os existentes. Compilar, reconstruir e testar projetos é feito em segundos.

Além disso, os testes são uma das principais forças motrizes dos projetos de código aberto. Por exemplo, isso se aplica a tudo relacionado à tecnologia blockchain. O código aberto aqui é quase uma religião. A base de código para ganhar confiança naqueles que a usarão deve estar aberta. Isso permite, por exemplo, conduzir sua auditoria, cria uma atmosfera de descentralização, na qual não existem certas entidades que controlam o projeto.

Não faz sentido esperar por uma contribuição significativa para o projeto de código aberto de desenvolvedores externos, se esse projeto não incluir testes de qualidade. Os participantes externos do projeto precisam de mecanismos para verificar rapidamente a compatibilidade do que escreveram com o que já foi adicionado ao projeto. De fato, todo o conjunto de testes deve ser realizado automaticamente após o recebimento de cada solicitação para adicionar novo código ao projeto. Se algo que deve ser adicionado ao projeto por meio de uma solicitação desse tipo quebra algo, o teste deve reportar isso imediatamente.

A cobertura completa da base de código com testes é uma métrica enganosa, mas importante. O objetivo de atingir 100% de cobertura de código com testes pode parecer excessivo, mas se você pensar bem, acontece que, se o código não for totalmente coberto por testes, parte do código será enviada para produção sem verificação, o que nunca foi executado antes.

A cobertura completa do código com testes não significa necessariamente que há testes suficientes no projeto e não significa que esses são testes que fornecem absolutamente todas as opções para o uso do código. Com confiança, podemos dizer apenas que, se o projeto não for 100% coberto em testes, o desenvolvedor não poderá ter certeza da confiabilidade absoluta do código, pois algumas partes do código nunca são testadas.

Apesar do exposto, há situações em que há muitos testes. Idealmente, todo erro possível deve levar à falha de um teste. Se o número de testes for excessivo, ou seja, testes diferentes verificam os mesmos fragmentos de código, modificar o código existente e alterar o comportamento do sistema existente levará ao fato de que, para que os testes existentes correspondam ao novo código, levará muito tempo para processá-los .

Por que a Go é uma ótima opção para projetos altamente confiáveis?


Go é uma linguagem estática digitada. Tipos são um contrato entre várias partes do código que são executadas juntas. Sem a verificação automática de tipo durante o processo de montagem do projeto, se você precisar seguir regras estritas para cobrir o código com testes, teremos que implementar testes que verifiquem esses "contratos" por conta própria. Isso, por exemplo, acontece em projetos de servidor e cliente baseados em JavaScript. Escrever testes complexos destinados apenas a verificar tipos significa muito trabalho adicional, que, no caso de Go, pode ser evitado.

Go é uma linguagem simples e dogmática. Como você sabe, o Go inclui muitas idéias tradicionais para linguagens de programação, como a herança clássica de POO. A complexidade é o pior inimigo do código confiável. Os problemas tendem a se esconder nas articulações de estruturas complexas. Isso se expressa no fato de que, embora as opções típicas para o uso de um determinado design sejam fáceis de testar, há casos de fronteira bizarros nos quais o desenvolvedor do teste pode nem pensar. O projeto, no final, derrubará apenas um desses casos. Nesse sentido, o dogmatismo também é útil. No Go, geralmente há apenas uma maneira de executar uma ação. Isso pode parecer um fator que retém o espírito livre do programador, mas quando algo pode ser feito de apenas uma maneira, é difícil fazer algo errado.

Ir é conciso, mas expressivo. O código legível é mais fácil de analisar e auditar. Se o código for muito detalhado, seu principal objetivo pode se afogar no "ruído" das construções auxiliares. Se o código for muito conciso, pode ser difícil ler e entender os programas nele. Go mantém um equilíbrio entre concisão e expressividade. Por exemplo, não há muitas construções auxiliares nele, como em linguagens como Java ou C ++. Ao mesmo tempo, as construções Go, relacionadas, por exemplo, a áreas como tratamento de erros, são muito claras e bastante detalhadas, o que simplifica o trabalho do programador, ajudando-o a garantir, por exemplo, que ele verificou tudo o que é possível.

O Go possui mecanismos claros de tratamento e recuperação de erros após falhas. Mecanismos de manipulação de erros de tempo de execução bem ajustados são a pedra angular de um código altamente confiável. O Go possui regras rígidas para retornar e distribuir erros. Em ambientes como o Node.js, a combinação de abordagens para controlar o fluxo de um programa, como retornos de chamada, promessas e funções assíncronas, geralmente leva a erros não tratados, como a rejeição não tratada de uma promessa . Restaurar o programa após eventos semelhantes é quase impossível .

Go tem uma extensa biblioteca padrão. Dependências são um risco, especialmente quando sua fonte é de projetos nos quais atenção insuficiente é dada à confiabilidade do código. Um aplicativo de servidor que entra em produção contém todas as dependências. Além disso, se algo der errado, o desenvolvedor do aplicativo final será responsável por isso, e não aquele que criou uma das bibliotecas usadas por ele. Como resultado, em ambientes onde os projetos escritos para os quais são sobrecarregados com pequenas dependências, é mais difícil criar aplicativos confiáveis.

Dependências também são um risco de segurança, pois o nível de vulnerabilidade de um projeto corresponde ao nível de vulnerabilidade de sua dependência mais insegura . A extensa biblioteca padrão Go é mantida por seus desenvolvedores em muito boas condições, sua existência reduz a necessidade de dependências externas.

Alta velocidade de desenvolvimento. Um recurso importante de ambientes como o Node.js é seu ciclo de desenvolvimento extremamente curto. Escrever código leva menos tempo, como resultado, o programador se torna mais produtivo.

Go também tem uma alta velocidade de desenvolvimento. Um conjunto de ferramentas para criar projetos é rápido o suficiente para poder olhar instantaneamente o código em ação. O tempo de compilação é extremamente curto; como resultado, a execução do código no Go é percebida como se não tivesse sido compilada, mas interpretada. Além disso, a linguagem possui abstrações suficientes, como um sistema de coleta de lixo, que permite que os desenvolvedores direcionem esforços para implementar a funcionalidade de seu projeto, e não para resolver tarefas auxiliares.

Experiência prática


Agora que já expressamos pontos gerais suficientes, é hora de dar uma olhada no código. Precisamos de um exemplo simples o suficiente para que, ao estudá-lo, possamos nos concentrar na metodologia de desenvolvimento, mas, ao mesmo tempo, ela deve ser avançada o suficiente para que, ao explorá-la, tenhamos algo sobre o que conversar. Decidi que seria mais fácil tirar algo do que faço diariamente. Portanto, proponho analisar a criação de um servidor que processa algo semelhante a transações financeiras. Os usuários deste servidor poderão verificar os saldos das contas associadas às suas contas. Além disso, eles poderão transferir fundos de uma conta para outra.

Vamos tentar não complicar este exemplo. Nosso sistema terá um servidor. Não entraremos em contato com sistemas de autenticação e criptografia. Essas são partes integrais dos projetos de trabalho. Mas precisamos nos concentrar no núcleo de um projeto, para mostrar como torná-lo o mais confiável possível.

▍Divisão de um projeto complexo em partes convenientes para gerenciar


A complexidade é o pior inimigo da confiabilidade. Uma das melhores abordagens ao trabalhar com sistemas complexos é aplicar o princípio conhecido de "dividir e conquistar". A tarefa precisa ser dividida em pequenas subtarefas e resolver cada uma delas separadamente. Qual lado abordar a partição de nossa tarefa? Seguiremos o princípio da responsabilidade compartilhada . Cada parte do nosso projeto deve ter sua própria área de responsabilidade.

Essa idéia se encaixa perfeitamente na arquitetura popular de microsserviço . Nosso servidor consistirá em serviços separados. Cada serviço terá uma área de responsabilidade claramente definida e uma interface claramente descrita para interagir com outros serviços.

Depois de estruturarmos o servidor dessa maneira, poderemos tomar decisões sobre como cada um dos serviços deve funcionar. Todos os serviços podem ser executados juntos, no mesmo processo, de cada um deles, você pode criar um servidor separado e estabelecer sua interação usando o RPC, separar os serviços e executar cada um deles em um computador separado.

Não complicaremos a tarefa, escolheremos a opção mais simples. Ou seja, todos os serviços serão executados no mesmo processo, eles trocarão informações diretamente, como bibliotecas. Se necessário, no futuro, essa solução arquitetônica poderá ser facilmente revisada e alterada.

Então, de quais serviços precisamos? Nosso servidor talvez seja simples demais para dividi-lo em partes, mas, para fins educacionais, nós, no entanto, o dividiremos. Precisamos responder às solicitações HTTP do cliente destinadas a verificar saldos e executar transações. Um dos serviços pode funcionar com uma interface HTTP para clientes. PublicApi chamá-lo de PublicApi . Outro serviço possui informações sobre o estado do sistema - o balanço patrimonial. StateStorage chamá-lo StateStorage . O terceiro serviço combinará os dois descritos acima e implementará a lógica dos “contratos” destinados a alterar os saldos. A tarefa do terceiro serviço será a execução de contratos. VirtualMachine chamá-lo de VirtualMachine .


Arquitetura do servidor de aplicativos

Coloque o código desses serviços nas pastas do projeto /services/publicapi , /services/virtualmachine e /services/statestorage .

▍ Definição clara de responsabilidades de serviço


Durante a implementação dos serviços, queremos poder trabalhar com cada um deles individualmente. É ainda possível dividir o desenvolvimento desses serviços entre diferentes programadores. Como os serviços são interdependentes e queremos paralelizar seu desenvolvimento, precisamos começar a trabalhar com uma definição clara das interfaces que eles usam para interagir entre si. Usando essas interfaces, podemos testar serviços autonomamente, preparando stubs para tudo o que está fora de cada uma delas.

Como descrever a interface? Uma das opções é documentar tudo, mas a documentação tem a propriedade de se tornar obsoleta. No processo de trabalhar em um projeto, as diferenças começam a se acumular entre a documentação e o código. Além disso, podemos usar as declarações da interface Go. Essa é uma opção interessante, mas é melhor descrever a interface para que essa descrição não dependa de uma linguagem de programação específica. Isso nos será útil em uma situação muito real, se, no processo de trabalhar em um projeto, for decidido implementar alguns de seus serviços em outros idiomas, cujas capacidades são mais adequadas para resolver seus problemas.

Uma opção para descrever interfaces é usar o protobuf . Este é um protocolo simples de idioma e idioma independente para descrever mensagens e pontos de extremidade de serviço.

Vamos começar com a interface para o serviço StateStorage . Apresentaremos o estado do aplicativo na forma de uma estrutura de exibição de valor-chave. Aqui está o código para o arquivo statestorage.proto :

 syntax = "proto3"; package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput); } message WriteKeyInput { string key = 1; int32 value = 2; } message WriteKeyOutput { } message ReadKeyInput { string key = 1; } message ReadKeyOutput { int32 value = 1; } 

Embora os clientes usem HTTP por meio do serviço PublicApi , ele também não interfere na interface clara descrita pelos mesmos meios acima (o arquivo publicapi.proto ):

 syntax = "proto3"; package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput); } message TransferInput { protocol.Transaction transaction = 1; } message TransferOutput { string success = 1; int32 result = 2; } message GetBalanceInput { protocol.Address from = 1; } message GetBalanceOutput { string success = 1; int32 result = 2; } 

Agora precisamos descrever as estruturas de dados Transaction e Address (arquivo transactions.proto ):

 syntax = "proto3"; package protocol; message Address { string username = 1; } message Transaction { Address from = 1; Address to = 2; int32 amount = 3; } 

No projeto, proto-descrições de serviços são colocadas na pasta /types/services e descrições de estruturas de dados de uso geral estão na pasta /types/protocol .

Quando as descrições da interface estiverem prontas, elas poderão ser compiladas no código Go.

As vantagens dessa abordagem são que o código que não corresponde à descrição da interface simplesmente não aparece nos resultados da compilação. O uso de métodos alternativos exigiria a criação de testes especiais para verificar se o código corresponde às descrições da interface.

Definições completas, arquivos Go gerados e instruções de compilação podem ser encontradas aqui . Isso é possível graças à Square Engineering e ao desenvolvimento de goprotowrap .

Observe que, em nosso projeto, a RPC da camada de transporte não é implementada e a troca de dados entre serviços se parece com chamadas de biblioteca comuns. Quando estamos prontos para distribuir serviços em diferentes servidores, podemos adicionar uma camada de transporte como o gRPC ao sistema.

▍ Tipos de testes usados ​​no projeto


Como os testes são a chave para um código altamente confiável, sugiro que primeiro falemos sobre quais testes escreveremos para o nosso projeto.

Testes unitários


Os testes de unidade são o núcleo da pirâmide de testes . Testaremos cada módulo isoladamente. O que é um módulo? No Go, podemos perceber os módulos como arquivos separados em um pacote. Por exemplo, se tivermos o arquivo /services/publicapi/handlers.go , colocaremos o teste de unidade no mesmo pacote em /services/publicapi/handlers_test.go .

É melhor colocar testes de unidade no mesmo pacote que o código de teste, o que permite que os testes tenham acesso a variáveis ​​e funções não exportadas.

Testes de Serviço


O seguinte tipo de teste é conhecido por vários nomes. Estes são os chamados testes de serviço, integração ou componente. Sua essência é pegar vários módulos e testar seu trabalho conjunto. Esses testes são um nível acima dos testes de unidade na pirâmide de testes. No nosso caso, usaremos testes de integração para testar todo o serviço. Esses testes determinam as especificações para o serviço. Por exemplo, os testes para o serviço StateStorage serão colocados na pasta /services/statestorage/spec .

É melhor colocar esses testes em um pacote diferente daquele em que o código testado está localizado, para que o acesso aos recursos desse código seja realizado apenas por meio de interfaces exportadas.

Testes de ponta a ponta


Esses testes estão no topo da pirâmide de testes, com sua ajuda para verificar todo o sistema e todos os seus serviços. Esses testes descrevem a especificação e2e de ponta a ponta para o sistema, portanto, os colocaremos na pasta /e2e/spec .

Testes de ponta a ponta, bem como testes de serviço, devem ser colocados em um pacote diferente daquele em que o código testado está localizado, para que o sistema possa ser operado apenas através de interfaces exportadas.

Quais testes devem ser escritos primeiro? Comece com a fundação da "pirâmide" e suba? Ou começar por cima e descer? Qualquer uma dessas abordagens tem direito à vida. Os benefícios de uma abordagem de cima para baixo estão na criação da especificação primeiro para todo o sistema. Geralmente, é mais fácil discutir no início do trabalho sobre os recursos do sistema como um todo. Mesmo se dividirmos o sistema em serviços separados incorretamente, as especificações do sistema permanecerão inalteradas. Além disso, isso nos ajudará a entender que algo, em um nível inferior, é feito incorretamente.

O ponto negativo da abordagem de cima para baixo é que os testes de ponta a ponta são aqueles usados ​​depois de todos os outros, quando todo o sistema que está sendo desenvolvido é criado. Isso significa que eles irão gerar erros por um longo tempo. Ao escrever testes para o nosso projeto, usaremos essa mesma abordagem.

Desenvolvimento de teste


Desenvolvimento de teste de ponta a ponta


Antes de criar testes, precisamos decidir se os escreveremos sem o uso de ferramentas auxiliares ou com algum tipo de estrutura. Confiar na estrutura, usá-la como uma dependência de desenvolvimento, é menos perigoso do que confiar na estrutura no código que entra em produção. No nosso caso, como a biblioteca Go padrão não tem suporte decente ao BDD e esse formato é ótimo para descrever especificações, escolheremos uma opção de trabalho que inclua o uso de uma estrutura.

Existem muitas estruturas excelentes que fornecem o que precisamos. Entre eles estão GoConvey e Ginkgo .

Pessoalmente, gosto de usar uma combinação de Ginkgo e Gomega (nomes terríveis, mas o que fazer) que usam construções sintáticas como Describe() e It() .

Como serão os nossos testes? Por exemplo, aqui está um teste para o mecanismo de verificação do saldo do usuário (arquivo sanity.go ):

 package spec import ... var _ = Describe("Sanity", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) }) 

Como o servidor é acessível do mundo exterior via HTTP, trabalharemos com sua API da web usando http.Get . E o teste transacional? Aqui está o código para o teste correspondente:

 It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17")) }) 

O código de teste descreve perfeitamente sua essência, podendo até substituir a documentação. Como você pode ver, admitimos a presença de saldos negativos da conta do usuário. Esta é uma característica do nosso projeto. Se fosse proibido, essa decisão seria refletida no teste.

Aqui está o código de teste completo

Desenvolvimento de Teste de Serviço


Agora, depois de desenvolver testes de ponta a ponta, descemos a pirâmide de testes e continuamos a criar testes de serviço. Tais testes são desenvolvidos para cada serviço individual. Escolhemos um serviço que depende de outro serviço, pois esse caso é mais interessante do que desenvolver testes para um serviço independente.

Vamos começar com o serviço VirtualMachine . Aqui você encontra a interface com proto-descrições para este serviço. Como o serviço VirtualMachine conta com o serviço StateStorage e faz chamadas para ele, precisaremos criar um objeto simulado para o serviço StateStorage para testar o serviço VirtualMachine isoladamente. O objeto stub nos permite controlar respostas StateStorage durante o teste.

Como implementar um objeto stub no Go? Isso pode ser feito exclusivamente por meio da linguagem, sem ferramentas auxiliares, ou você pode recorrer à biblioteca apropriada, que, além disso, tornará possível trabalhar com as instruções no processo de teste. Para esse fim, prefiro usar a biblioteca go-mock .

/services/statestorage/mock.go o código stub no arquivo /services/statestorage/mock.go . É melhor colocar objetos de stub no mesmo local que as entidades que eles imitam para fornecer acesso a variáveis ​​e funções não exportadas. O stub nesse estágio é uma implementação esquemática do serviço, mas, à medida que o serviço se desenvolve, podemos precisar desenvolver a implementação do stub. Aqui está o código para o objeto stub (arquivo mock.go ):

 package statestorage import ... type MockService struct { mock.Mock } func (s *MockService) Start() { s.Called() } func (s *MockService) Stop() { s.Called() } func (s *MockService) IsStarted() bool { return s.Called().Bool(0) } func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1) } func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1) } 

Se você der o desenvolvimento de serviços individuais a diferentes programadores, faz sentido primeiro criar stubs e passá-los para a equipe.

Vamos voltar ao desenvolvimento de um teste de serviço para o VirtualMachine . Que cenário devo verificar aqui? É melhor se concentrar na interface de serviço e nos testes de design para cada nó de extremidade. Implementamos um teste para o terminal CallContract() com um argumento que representa o método "GetBalance" . Aqui está o código correspondente (arquivo contracts.go ):

 package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) }) 

Observe que o serviço que estamos testando, VirtualMachine , obtém um ponteiro para sua dependência, StateStorage , no método Start() através de um mecanismo simples de injeção de dependência. É aqui que passamos a instância do objeto stub. Além disso, preste atenção à linha stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… , onde dizemos ao objeto stub como ele deve se comportar ao acessá-lo. Quando o método ReadKey é ReadKey , ele deve retornar um valor 100. Em seguida, na linha Expect(stateStorage).To(ExecuteAsPlanned()) , verificamos se esse comando é chamado exatamente uma vez.

Testes similares tornam-se especificações para o serviço. O conjunto completo de testes para o serviço VirtualMachine pode ser encontrado aqui . Os conjuntos de testes para outros serviços do nosso projeto podem ser encontrados aqui e aqui .

Desenvolvimento de Teste de Unidade


Talvez a implementação do contrato para o método "GetBalance" seja muito simples, então "GetBalance" falar sobre a implementação de um método de "Transfer" um pouco mais complexo. O contrato de transferência de fundos de uma conta para outra representado por esse método precisa ler dados sobre os saldos do remetente e destinatário dos fundos, calcular novos saldos e registrar o que aconteceu no estado do aplicativo. O teste de serviço para tudo isso é muito semelhante ao que acabamos de implementar (arquivo transactions.go ):

 It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned()) }) 

No processo de trabalho no projeto, finalmente criamos seus mecanismos internos e criamos um módulo localizado no arquivo processor.go , que contém a implementação do contrato. Aqui está a versão original (arquivo processor.go ):

 package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil } 

Esse design satisfaz o teste de serviço, mas, no nosso caso, o teste de integração contém apenas um teste do cenário básico. E os casos limítrofes e possíveis falhas? Como você pode ver, qualquer uma das chamadas que fazemos ao StateStorage pode falhar. Se for necessária uma cobertura de 100% do código com testes, precisamos verificar todas essas situações. O teste de unidade é ótimo para implementar esses testes.

Como vamos chamar a função várias vezes com dados de entrada diferentes e simular os parâmetros para alcançar todas as ramificações do código, a fim de tornar esse processo mais eficiente, podemos recorrer a testes baseados em tabela. Go tende a evitar estruturas de teste de unidade exóticas. Podemos recusar o Ginkgo , mas provavelmente devemos deixar o Gomega . Como resultado, as verificações realizadas aqui serão semelhantes às verificadas em testes anteriores. Aqui está o código de teste (arquivo processor_test.go ):

 package virtualmachine import ... var transferTable = []struct{ to string //  ,    read1Err error //       read2Err error //       write1Err error //       write2Err error //       output int32 //   errs bool //        }{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false}, } func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } } } 

«Ω» — , — ( Gomega ). .

, TDD, , , . processTransfer() .

VirtualMachine . .

100% . , . .

, ? . , , , .

▍ -


. ? HTTP- Go (goroutine). , — , . , , , .

- . , , , , . - /e2e/stress . - ( stress.go ):

 package stress import ... const NUM_TRANSACTIONS = 20000 const NUM_USERS = 100 const TRANSACTIONS_PER_BATCH = 200 const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { //  HTTP-     transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} //      ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } //     HTTP   rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } //   for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1) } func randomizeAmount() int32 { return rand.Int31n(1000)+1 } func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err } 

, - . ( rand.Seed(42) ) , . . , , — , .

- HTTP , TCP- ( , , ). , , 200 IdleConnection TCP- . , 100.

… :

 fatal error: concurrent map writes goroutine 539 [running]: runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01 runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879 services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s Test Suite Failed 

? StateStorage ( map ), . , , . , map sync.map . .

processTransfer() . , — . , , , , . , processTransfer() . .

, . , , .

 e2e/stress/transactions.go:44 Expected <string>: -7498 to equal <string>: -7551 e2e/stress/transactions.go:82 ------------------------------ Ginkgo ran 1 suite in 5.251593179s Test Suite Failed 

, . , , ( , ). , , .

— . TDD . ? , 100%?! , — . processTransfer() , , .

. , , . .

Sumário


, , , -, , , ? ? — .

, -. , «» processTransfer() . , , . , — . , - . , , .

. , . , StateStorage WriteKey , , , , WriteKeys , , .

, : . « ». -, , , , , . — . , , — .

, — GitHub. . , , , , , , .

Caros leitores! ?

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


All Articles