Vamos lá! Três abordagens para estruturar seu código Go

Olá, Habr! Recentemente, lançamos um novo livro sobre Golang , e seu sucesso é tão impressionante que decidimos publicar aqui um artigo muito importante sobre abordagens para projetar aplicativos Go. As idéias apresentadas neste artigo obviamente não se tornarão obsoletas no futuro próximo. Talvez o autor tenha até conseguido antecipar algumas diretrizes para trabalhar com Go, que podem se tornar generalizadas em um futuro próximo.



A linguagem Go foi anunciada pela primeira vez no final de 2009 e lançada oficialmente em 2012, mas apenas nos últimos anos ela começou a ganhar um sério reconhecimento. Go foi uma das linguagens de crescimento mais rápido em 2018 e a terceira linguagem de programação mais popular em 2019 .



Como a linguagem Go em si é relativamente nova, a comunidade de desenvolvedores não é muito rígida sobre como escrever código. Se olharmos para convenções semelhantes nas comunidades de linguagens mais antigas, como Java, descobrimos que a maioria dos projetos tem uma estrutura semelhante. Isso pode ser muito útil ao escrever grandes bases de código; no entanto, muitos podem argumentar que seria contraproducente em contextos práticos modernos. À medida que avançamos para escrever microssistemas e manter bases de código relativamente compactas, a flexibilidade de Go na estruturação de projetos se torna muito atraente.



Todo mundo conhece um exemplo com hello world http no Golang , e pode ser comparado com exemplos semelhantes em outras linguagens, por exemplo, em Java... Não há diferença significativa entre o primeiro e o segundo, nem na complexidade nem na quantidade de código que precisa ser escrito para implementar o exemplo. Mas há uma diferença fundamental de abordagem. Go nos incentiva a " escrever código simples sempre que possível ". Além dos aspectos orientados a objetos do Java, acho que a lição mais importante desses snippets de código é esta: Java requer uma instância separada para cada operação (instância HttpServer), enquanto Go nos incentiva a usar o singleton global.



Dessa forma, você precisa manter menos código e passar menos links nele. Se você sabe que só precisa criar um servidor (e isso geralmente acontece), por que se preocupar com isso? Essa filosofia parece ainda mais atraente à medida que sua base de código cresce. No entanto, a vida às vezes traz surpresas :(. O fato é que você ainda tem vários níveis de abstração para escolher e, se combiná-los incorretamente, pode criar sérias armadilhas para si mesmo.



É por isso que quero chamar sua atenção para três abordagens para organizar e estruturar o código Go. Cada uma dessas abordagens implica um nível diferente de abstração. Para concluir, compararei as três e direi em quais casos de aplicação cada uma dessas abordagens é mais apropriada.



Vamos implementar um servidor HTTP que contém informações sobre os usuários (denotado como Banco de Dados Principal na figura a seguir), onde cada usuário recebe uma função (digamos, básico, moderador, administrador), e também implementar um banco de dados adicional (na próxima figura, denotado como DB de configuração), que especifica o conjunto de direitos de acesso atribuídos a cada uma das funções (por exemplo, ler, gravar, editar). Nosso servidor HTTP deve implementar um endpoint que retorne o conjunto de direitos de acesso que o usuário com o ID fornecido possui.







Em seguida, vamos supor que o banco de dados de configuração não muda com frequência e leva muito tempo para carregar, portanto, vamos mantê-lo na RAM, carregá-lo quando o servidor for iniciado e atualizá-lo a cada hora.



Todo o código está no repositório deste artigo localizado no GitHub.



Abordagem I: Pacote único



A abordagem de pacote único usa uma hierarquia de camada única em que todo o servidor é implementado em um único pacote. Todo o código .

Atenção: os comentários no código são informativos, importantes para a compreensão dos princípios de cada abordagem.
/main.go
package main

import (
	"net/http"
)

//    ,         
//    ,   -,
//  ,         .
var (
	userDBInstance   userDB
	configDBInstance configDB
	rolePermissions  map[string][]string
)

func main() {
	// ,      
	// ,     
	// .
	//        
	// ,   ,     ,
	//    .
	userDBInstance = &someUserDB{}
	configDBInstance = &someConfigDB{}
	initPermissions()
	http.HandleFunc("/", UserPermissionsByID)
	http.ListenAndServe(":8080", nil)
}

//    ,   ,   .
func initPermissions() {
	rolePermissions = configDBInstance.allPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			rolePermissions = configDBInstance.allPermissions()
		}
	}()
}
/database.go
package main

//          ,
//         .
type userDB interface {
	userRoleByID(id string) string
}

//     `someConfigDB`.    
//          
// ,   MongoDB,     
// `mongoConfigDB`.         
//   `mockConfigDB`.
type someUserDB struct {}

func (db *someUserDB) userRoleByID(id string) string {
	//     ...
}

type configDB interface {
	allPermissions() map[string][]string //       
}

type someConfigDB struct {}

func (db *someConfigDB) allPermissions() map[string][]string {
	// 
}
/handler.go
package main

import (
	"fmt"
	"net/http"
	"strings"
)

func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := userDBInstance.userRoleByID(id)
	permissions := rolePermissions[role]
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


Observação: ainda usamos arquivos diferentes, isso é para separação de preocupações. Isso torna o código mais legível e fácil de manter.



Abordagem II: Pacotes emparelhados



Nesta abordagem, vamos aprender o que é batching. O pacote deve ser o único responsável por algum comportamento específico. Aqui, permitimos que os pacotes interajam uns com os outros - portanto, temos que manter menos código. No entanto, precisamos ter certeza de que não violamos o princípio da responsabilidade exclusiva e, portanto, garantir que cada parte da lógica seja totalmente implementada em um pacote separado. Outra diretriz importante para essa abordagem é que, como Go não permite dependências circulares entre pacotes, você precisa criar um pacote neutro que contenha apenas definições de interface simples e instâncias singleton . Isso eliminará as dependências do anel. Todo o código...



/main.go
package main

//  :  main – ,  
//      .
import (
	"github.com/myproject/config"
	"github.com/myproject/database"
	"github.com/myproject/definition"
	"github.com/myproject/handler"
	"net/http"
)

func main() {
	//       , ,
	//  ,    ,  
	//  .
	definition.UserDBInstance = &database.SomeUserDB{}
	definition.ConfigDBInstance = &database.SomeConfigDB{}
	config.InitPermissions()
	http.HandleFunc("/", handler.UserPermissionsByID)
	http.ListenAndServe(":8080", nil)
}
/definition/database.go
package definition

//  ,       , 
//         . 
// ,        ; 
//    , ,    ,
//      .
var (
	UserDBInstance   UserDB
	ConfigDBInstance ConfigDB
)

type UserDB interface {
	UserRoleByID(id string) string
}

type ConfigDB interface {
	AllPermissions() map[string][]string //      
}
/definition/config.go
package definition

var RolePermissions map[string][]string
/database/user.go
package database

type SomeUserDB struct{}

func (db *SomeUserDB) UserRoleByID(id string) string {
	// 
}
/database/config.go
package database

type SomeConfigDB struct{}

func (db *SomeConfigDB) AllPermissions() map[string][]string {
	// 
}
/config/permissions.go
package config

import (
	"github.com/myproject/definition"
	"time"
)

//         ,
//      config.
func InitPermissions() {
	definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			definition.RolePermissions = definition.ConfigDBInstance.AllPermissions()
		}
	}()
}
/handler/user_permissions_by_id.go
package handler

import (
	"fmt"
	"github.com/myproject/definition"
	"net/http"
	"strings"
)

func UserPermissionsByID(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := definition.UserDBInstance.UserRoleByID(id)
	permissions := definition.RolePermissions[role]
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


Abordagem III: Pacotes independentes



Com essa abordagem, o projeto também é organizado em pacotes. Nesse caso, cada pacote deve integrar todas as suas dependências localmente , via interfaces e variáveis . Portanto, ele não sabe absolutamente nada sobre outros pacotes . Com esta abordagem, o pacote com as definições mencionadas na abordagem anterior será realmente espalhado entre todos os outros pacotes; cada pacote declara sua própria interface para cada serviço. À primeira vista, pode parecer uma duplicação irritante, mas na realidade não é. Cada pacote que usa um serviço deve declarar sua própria interface, que especifica apenas o que precisa desse serviço e nada mais. Todo o código...



/main.go
package main

//  :   – ,  
//   .
import (
	"github.com/myproject/config"
	"github.com/myproject/database"
	"github.com/myproject/handler"
	"net/http"
)

func main() {
	userDB := &database.SomeUserDB{}
	configDB := &database.SomeConfigDB{}
	permissionStorage := config.NewPermissionStorage(configDB)
	h := &handler.UserPermissionsByID{UserDB: userDB, PermissionsStorage: permissionStorage}
	http.Handle("/", h)
	http.ListenAndServe(":8080", nil)
}
/database/user.go
package database

type SomeUserDB struct{}

func (db *SomeUserDB) UserRoleByID(id string) string {
	// 
}
/database/config.go
package database

type SomeConfigDB struct{}

func (db *SomeConfigDB) AllPermissions() map[string][]string {
	// 
}
/config/permissions.go
package config

import (
	"time"
)

//    ,    ,
//    ,  ,
//  `AllPermissions`.
type PermissionDB interface {
	AllPermissions() map[string][]string //     
}

//    ,   
//    , ,    ,  
//     
type PermissionStorage struct {
	permissions map[string][]string
}

func NewPermissionStorage(db PermissionDB) *PermissionStorage {
	s := &PermissionStorage{}
	s.permissions = db.AllPermissions()
	go func() {
		for {
			time.Sleep(time.Hour)
			s.permissions = db.AllPermissions()
		}
	}()
	return s
}

func (s *PermissionStorage) RolePermissions(role string) []string {
	return s.permissions[role]
}
/handler/user_permissions_by_id.go
package handler

import (
	"fmt"
	"net/http"
	"strings"
)

//         
type UserDB interface {
	UserRoleByID(id string) string
}

// ...          .
type PermissionStorage interface {
	RolePermissions(role string) []string
}

//        ,
//     ,   .
type UserPermissionsByID struct {
	UserDB             UserDB
	PermissionsStorage PermissionStorage
}

func (u *UserPermissionsByID) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query()["id"][0]
	role := u.UserDB.UserRoleByID(id)
	permissions := u.PermissionsStorage.RolePermissions(role)
	fmt.Fprint(w, strings.Join(permissions, ", "))
}


Isso é tudo! Vimos três níveis de abstração, o primeiro dos quais é o mais fino, contendo estado global e lógica fortemente acoplada, mas fornecendo a implementação mais rápida e a menor quantidade de código para escrever e manter. A segunda opção é moderadamente híbrida, e a terceira é totalmente independente e adequada para uso repetido, mas vem com o máximo de esforço com suporte.



Prós e contras



Abordagem I: Pacote Único



para



  • Menos código, implementação muito mais rápida, menos trabalho de manutenção
  • Sem pacotes, o que significa que você não precisa se preocupar com dependências de anel
  • Fácil de testar, pois existem interfaces de serviço. Para testar uma parte da lógica, você pode especificar qualquer implementação de sua escolha (concreta ou simulada) para o singleton e, em seguida, executar a lógica de teste.


Contra



  • O único pacote também não prevê acesso privado, tudo é aberto de qualquer lugar. Como resultado, a responsabilidade do desenvolvedor aumenta. Por exemplo, lembre-se de que você não pode instanciar diretamente uma estrutura quando uma função de construtor é necessária para executar alguma lógica de inicialização.
  • O estado global (instâncias singleton) pode criar suposições não atendidas, por exemplo, uma instância singleton não inicializada pode disparar um pânico de ponteiro nulo em tempo de execução.
  • Como a lógica é fortemente acoplada, nada neste projeto pode ser facilmente reutilizado e será difícil extrair quaisquer componentes dele.
  • Quando você não tem pacotes que gerenciam independentemente cada parte da lógica, o desenvolvedor deve ser muito cuidadoso e colocar todas as partes do código corretamente, caso contrário, podem ocorrer comportamentos inesperados.




Abordagem II: Pacotes emparelhados



por



  • Ao empacotar um projeto, é mais conveniente garantir a responsabilidade por uma lógica específica dentro do pacote, e isso pode ser reforçado usando o compilador. Além disso, poderemos usar o acesso privado e controlar quais elementos do código estão abertos para nós.
  • Usar um pacote com definições permite que você trabalhe com instâncias singleton enquanto evita dependências circulares. Dessa forma, você pode escrever menos código, evitar passar referências ao gerenciar instâncias e evitar perder tempo com problemas que podem surgir durante a compilação.
  • Essa abordagem também conduz a testes, porque existem interfaces de serviço. Com essa abordagem, o teste interno de cada pacote é possível.


Contra



  • Existem algumas sobrecargas ao organizar um projeto em pacotes - por exemplo, a implementação inicial deve levar mais tempo do que com uma abordagem de pacote único.
  • Usar o estado global (instâncias singleton) com essa abordagem também pode causar problemas.
  • O projeto é dividido em embalagens, o que facilita muito a extração e reaproveitamento de elementos individuais. No entanto, os pacotes não são completamente independentes, pois todos interagem com um pacote de definição. Com essa abordagem, a extração e a reutilização de código não são totalmente automáticas.




Abordagem III:



Profissionais Independentes



  • Ao usar pacotes, garantimos que uma lógica específica seja implementada em um único pacote e temos controle de acesso completo.
  • Não deve haver dependências circulares em potencial, pois os pacotes são totalmente autocontidos.
  • Todos os pacotes são altamente recuperáveis ​​e reutilizáveis. Em todos os casos em que precisamos de um pacote em outro projeto, simplesmente o transferimos para um espaço compartilhado e o usamos sem alterar nada nele.
  • Se não houver um estado global, não haverá comportamentos indesejados.
  • Essa abordagem é melhor para teste. Cada pacote pode ser totalmente testado sem a preocupação de depender de outros pacotes por meio de interfaces locais.


Contra



  • Essa abordagem é muito mais lenta de implementar do que as duas anteriores.
  • Muito mais código precisa ser mantido. Como os links estão sendo transferidos, muitos lugares precisam ser atualizados depois que grandes mudanças são feitas. Além disso, quando temos várias interfaces que fornecem o mesmo serviço, temos que atualizar essas interfaces sempre que fazemos alterações nesse serviço.


Conclusões e exemplos de uso



Dada a falta de diretrizes para escrever código em Go, ele assume muitas formas e formatos diferentes, e cada opção tem seus próprios méritos interessantes. No entanto, misturar diferentes padrões de projeto pode causar problemas. Para se ter uma ideia deles, cobri três abordagens diferentes para escrever e estruturar o código Go.



Então, quando cada abordagem deve ser usada? Eu sugiro este arranjo:



Abordagem I : A abordagem de pacote único talvez seja mais apropriada ao trabalhar em equipes pequenas e altamente experientes em pequenos projetos onde resultados rápidos são necessários. Esta abordagem é mais simples e confiável para um início rápido, embora requeira muita atenção e coordenação na fase de apoio ao projeto.



Abordagem II: A abordagem de pacotes emparelhados pode ser chamada de síntese híbrida das outras duas abordagens: entre suas vantagens estão o início relativamente rápido e a facilidade de suporte, ao mesmo tempo que cria condições para o cumprimento estrito das regras. É apropriado para projetos e equipes relativamente grandes, mas tem capacidade de reutilização limitada do código e há certas dificuldades de manutenção.



Abordagem III : A abordagem de pacotes independentes é mais apropriada para projetos que são complexos em si mesmos, são de longo prazo, desenvolvidos por grandes equipes e para projetos que têm peças de lógica que são criadas com um olho para reutilização posterior. Essa abordagem leva muito tempo para ser implementada e é difícil de manter.



All Articles