Entendendo a Visibilidade de Pacotes em Go

Introdução

Ao criar um pacote em Go, o objetivo final geralmente é tornar o pacote acessível para outros desenvolvedores utilizarem, seja em pacotes de ordem superior ou em programas completos. Ao importar o pacote, seu código pode servir como um bloco de construção para outras ferramentas mais complexas. No entanto, apenas certos pacotes estão disponíveis para importação. Isso é determinado pela visibilidade do pacote.

Visibilidade neste contexto significa o espaço de arquivo a partir do qual um pacote ou outro constructo pode ser referenciado. Por exemplo, se definirmos uma variável em uma função, a visibilidade (escopo) dessa variável está apenas dentro da função em que foi definida. Da mesma forma, se você definir uma variável em um pacote, pode torná-la visível apenas para aquele pacote ou permitir que ela seja visível fora do pacote também.

Controlar cuidadosamente a visibilidade do pacote é importante ao escrever código ergonómico, especialmente ao considerar futuras mudanças que você possa querer fazer no seu pacote. Se precisar corrigir um bug, melhorar o desempenho ou alterar a funcionalidade, você desejará fazer a mudança de uma maneira que não quebre o código de ninguém que esteja usando seu pacote. Uma forma de minimizar mudanças disruptivas é permitir o acesso apenas às partes do seu pacote que são necessárias para seu uso adequado. Ao limitar o acesso, você pode fazer alterações internas no seu pacote com menos chances de afetar como outros desenvolvedores estão usando-o.

Neste artigo, você aprenderá como controlar a visibilidade do pacote, bem como como proteger partes do seu código que devem ser usadas apenas dentro do seu pacote. Para isso, criaremos um logger básico para registrar e depurar mensagens, usando pacotes com diferentes graus de visibilidade de itens.

Pré-requisitos

Para seguir os exemplos neste artigo, você precisará:

.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides

Itens Exportados e Não Exportados

Ao contrário de outras linguagens de programação como Java e Python que utilizam modificadores de acesso como public, private, ou protected para especificar o escopo, Go determina se um item é exportado e não exportado através de como ele é declarado. Exportar um item neste caso o torna visível fora do pacote atual. Se não for exportado, ele só é visível e utilizável dentro do pacote em que foi definido.

Essa visibilidade externa é controlada pelo uso de letras maiúsculas na primeira letra do item declarado. Todas as declarações, como Tipos, Variáveis, Constantes, Funções, etc., que começam com uma letra maiúscula são visíveis fora do pacote atual.

Vamos analisar o seguinte código, prestando atenção especial à capitalização:

greet.go
package greet

import "fmt"

var Greeting string

func Hello(name string) string {
	return fmt.Sprintf(Greeting, name)
}

Este código declara que está no pacote greet. Em seguida, declara dois símbolos, uma variável chamada Greeting e uma função chamada Hello. Como ambos começam com uma letra maiúscula, são ambos exportados e disponíveis para qualquer programa externo. Como mencionado anteriormente, criar um pacote que limita o acesso permitirá um melhor design da API e facilitará a atualização do seu pacote internamente sem quebrar o código de ninguém que dependa do seu pacote.

Definindo Visibilidade do Pacote

Para dar uma olhada mais detalhada em como a visibilidade do pacote funciona em um programa, vamos criar um pacote logging, mantendo em mente o que queremos tornar visível fora do nosso pacote e o que não tornaremos visível. Este pacote de logging será responsável por registrar qualquer mensagem do nosso programa no console. Também analisará em qual nível estamos registrando. Um nível descreve o tipo de log e será um dos três status: info, warning ou error.

Primeiro, dentro do seu diretório src, vamos criar um diretório chamado logging para colocar nossos arquivos de logging:

  1. mkdir logging

Em seguida, mova-se para esse diretório:

  1. cd logging

Depois, usando um editor como nano, crie um arquivo chamado logging.go:

  1. nano logging.go

Coloque o seguinte código no arquivo logging.go que acabamos de criar:

logging/logging.go
package logging

import (
	"fmt"
	"time"
)

var debug bool

func Debug(b bool) {
	debug = b
}

func Log(statement string) {
	if !debug {
		return
	}

	fmt.Printf("%s %s\n", time.Now().Format(time.RFC3339), statement)
}

A primeira linha deste código declara um pacote chamado logging. Neste pacote, existem duas funções exportadas: Debug e Log. Estas funções podem ser chamadas por qualquer outro pacote que importe o pacote logging. Há também uma variável privada chamada debug. Esta variável só é acessível de dentro do pacote logging. É importante notar que, embora a função Debug e a variável debug tenham a mesma grafia, a função está em maiúscula e a variável não. Isso as torna declarações distintas com escopos diferentes.

Salve e saia do arquivo.

Para usar este pacote em outras áreas do nosso código, podemos importá-lo em um novo pacote. Criaremos este novo pacote, mas precisaremos de um novo diretório para armazenar esses arquivos de origem primeiro.

Vamos sair do diretório logging, criar um novo diretório chamado cmd e entrar nesse novo diretório:

  1. cd ..
  2. mkdir cmd
  3. cd cmd

Crie um arquivo chamado main.go no diretório cmd que acabamos de criar:

  1. nano main.go

Agora podemos adicionar o seguinte código:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")
}

Agora temos todo o nosso programa escrito. No entanto, antes de podermos executar este programa, precisaremos também criar alguns arquivos de configuração para que o nosso código funcione corretamente. Go usa Go Modules para configurar dependências de pacotes para importar recursos. Go modules são arquivos de configuração colocados no diretório do seu pacote que informam ao compilador de onde importar pacotes. Embora aprender sobre módulos esteja além do escopo deste artigo, podemos escrever apenas algumas linhas de configuração para fazer este exemplo funcionar localmente.

Abra o seguinte arquivo go.mod no diretório cmd:

  1. nano go.mod

Em seguida, coloque o seguinte conteúdo no arquivo:

go.mod
module github.com/gopherguides/cmd

replace github.com/gopherguides/logging => ../logging

A primeira linha deste arquivo informa ao compilador que o pacote cmd tem um caminho de arquivo de github.com/gopherguides/cmd. A segunda linha informa ao compilador que o pacote github.com/gopherguides/logging pode ser encontrado localmente no disco no diretório ../logging.

Também precisaremos de um arquivo go.mod para o nosso pacote logging. Vamos voltar para o diretório logging e criar um arquivo go.mod:

  1. cd ../logging
  2. nano go.mod

Adicione o seguinte conteúdo ao arquivo:

go.mod
module github.com/gopherguides/logging

Isso informa ao compilador que o pacote logging que criamos é na verdade o pacote github.com/gopherguides/logging. Isso possibilita importar o pacote no nosso pacote main com a seguinte linha que escrevemos anteriormente:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")
}

Agora você deve ter a seguinte estrutura de diretório e layout de arquivos:

├── cmd
│   ├── go.mod
│   └── main.go
└── logging
    ├── go.mod
    └── logging.go

Agora que concluímos todas as configurações, podemos executar o programa main do pacote cmd com os seguintes comandos:

  1. cd ../cmd
  2. go run main.go

Você obterá uma saída semelhante à seguinte:

Output
2019-08-28T11:36:09-05:00 This is a debug statement...

O programa exibirá a hora atual no formato RFC 3339, seguido de qualquer declaração que enviamos ao logger. RFC 3339 é um formato de hora projetado para representar a hora na internet e é comumente usado em arquivos de log.

Como as funções Debug e Log são exportadas do pacote de logging, podemos usá-las em nosso pacote main. No entanto, a variável debug no pacote logging não é exportada. Tentar referenciar uma declaração não exportada resultará em um erro de compilação.

Adicione a seguinte linha destacada a main.go:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")

	fmt.Println(logging.debug)
}

Salve e execute o arquivo. Você receberá um erro semelhante ao seguinte:

Output
. . . ./main.go:10:14: cannot refer to unexported name logging.debug

Agora que vimos como os itens exportados e não exportados nos pacotes se comportam, veremos a seguir como campos e métodos podem ser exportados de structs.

Visibilidade Dentro de Structs

Embora o esquema de visibilidade no logger que construímos na última seção possa funcionar para programas simples, ele compartilha muito estado para ser útil dentro de múltiplos pacotes. Isso ocorre porque as variáveis exportadas são acessíveis a múltiplos pacotes que poderiam modificar as variáveis em estados contraditórios. Permitir que o estado do seu pacote seja alterado dessa maneira dificulta a previsão de como seu programa se comportará. Com o design atual, por exemplo, um pacote poderia definir a variável Debug como true, e outro poderia defini-la como false na mesma instância. Isso criaria um problema, já que ambos os pacotes que estão importando o pacote logging seriam afetados.

Podemos isolar o logger criando uma struct e, em seguida, pendurando métodos nela. Isso nos permitirá criar uma instância de um logger para ser usada independentemente em cada pacote que o consuma.

Altere o pacote logging para o seguinte para refatorar o código e isolar o logger:

logging/logging.go
package logging

import (
	"fmt"
	"time"
)

type Logger struct {
	timeFormat string
	debug      bool
}

func New(timeFormat string, debug bool) *Logger {
	return &Logger{
		timeFormat: timeFormat,
		debug:      debug,
	}
}

func (l *Logger) Log(s string) {
	if !l.debug {
		return
	}
	fmt.Printf("%s %s\n", time.Now().Format(l.timeFormat), s)
}

Neste código, criamos uma estrutura Logger. Esta estrutura abrigará nosso estado não exportado, incluindo o formato de tempo para imprimir e a variável debug configurada como true ou false. A função New define o estado inicial para criar o logger, como o formato de tempo e o estado de depuração. Em seguida, armazena os valores que lhe foram fornecidos internamente nas variáveis não exportadas timeFormat e debug. Também criamos um método chamado Log no tipo Logger que recebe uma declaração que queremos imprimir. Dentro do método Log há uma referência à sua variável de método local l para acessar novamente seus campos internos, como l.timeFormat e l.debug.

Essa abordagem permitirá criar um Logger em muitos pacotes diferentes e usá-lo independentemente de como os outros pacotes estão usá-lo.

Para usá-lo em outro pacote, vamos alterar cmd/main.go para ficar assim:

cmd/main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("This is a debug statement...")
}

Executar este programa fornecerá a seguinte saída:

Output
2019-08-28T11:56:49-05:00 This is a debug statement...

Neste código, criamos uma instância do logger chamando a função exportada New. Armazenamos a referência a esta instância na variável logger. Agora podemos chamar logging.Log para imprimir declarações.

Se tentarmos referenciar um campo não exportado do Logger, como o campo timeFormat, receberemos um erro em tempo de compilação. Tente adicionar a seguinte linha destacada e execute cmd/main.go:

cmd/main.go

package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("This is a debug statement...")

	fmt.Println(logger.timeFormat)
}

Isso resultará no seguinte erro:

Output
. . . cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)

O compilador reconhece que logger.timeFormat não é exportado e, portanto, não pode ser recuperado do pacote logging.

Visibilidade Dentro de Métodos

Da mesma forma que os campos de struct, os métodos também podem ser exportados ou não exportados.

Para ilustrar isso, vamos adicionar log de níveis ao nosso logger. O log de níveis é uma forma de categorizar seus logs para que você possa pesquisar seus logs por tipos específicos de eventos. Os níveis que vamos colocar em nosso logger são:

  • O nível info, que representa eventos do tipo informação que informam ao usuário uma ação, como Programa iniciado ou Email enviado. Eles nos ajudam a depurar e rastrear partes do nosso programa para ver se o comportamento esperado está ocorrendo.

  • O nível warning. Esses tipos de eventos identificam quando algo inesperado está acontecendo e não é um erro, como Falha no envio de email, tentando novamente. Eles nos ajudam a ver partes do nosso programa que não estão indo tão suavemente quanto esperávamos.

  • O nível error, que significa que o programa encontrou um problema, como Arquivo não encontrado. Isso geralmente resultará na falha da operação do programa.

Você também pode desejar ativar e desativar certos níveis de registro, especialmente se seu programa não estiver performando como esperado e você desejar depurar o programa. Adicionaremos essa funcionalidade alterando o programa para que, quando debug estiver definido como true, ele imprimirá todos os níveis de mensagens. Caso contrário, se for false, ele só imprimirá mensagens de erro.

Adicione o registro em níveis fazendo as seguintes alterações em logging/logging.go:

logging/logging.go

package logging

import (
	"fmt"
	"strings"
	"time"
)

type Logger struct {
	timeFormat string
	debug      bool
}

func New(timeFormat string, debug bool) *Logger {
	return &Logger{
		timeFormat: timeFormat,
		debug:      debug,
	}
}

func (l *Logger) Log(level string, s string) {
	level = strings.ToLower(level)
	switch level {
	case "info", "warning":
		if l.debug {
			l.write(level, s)
		}
	default:
		l.write(level, s)
	}
}

func (l *Logger) write(level string, s string) {
	fmt.Printf("[%s] %s %s\n", level, time.Now().Format(l.timeFormat), s)
}

Neste exemplo, introduzimos um novo argumento ao método Log. Agora podemos passar o level da mensagem de log. O método Log determina qual nível de mensagem é. Se for uma mensagem info ou warning, e o campo debug for true, então ele escreve a mensagem. Caso contrário, ignora a mensagem. Se for qualquer outro nível, como error, ele escreverá a mensagem independentemente.

A maior parte da lógica para determinar se a mensagem é impressa está no método Log. Também introduzimos um método não exportado chamado write. O método write é o que realmente envia a mensagem de log.

Agora podemos usar esse log em níveis em nosso outro pacote alterando cmd/main.go para ficar assim:

cmd/main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

}

Executar isso lhe dará:

Output
[info] 2019-09-23T20:53:38Z starting up service [warning] 2019-09-23T20:53:38Z no tasks found [error] 2019-09-23T20:53:38Z exiting: no work performed

Neste exemplo, cmd/main.go usou com sucesso o método Log exportado.

Podemos agora passar o level de cada mensagem trocando debug para false:

main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, false)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

}

Agora veremos que apenas as mensagens de nível error são impressas:

Output
[error] 2019-08-28T13:58:52-05:00 exiting: no work performed

Se tentarmos chamar o método write de fora do pacote logging, receberemos um erro em tempo de compilação:

main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

	logger.write("error", "log this message...")
}
Output
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)

Quando o compilador percebe que você está tentando referenciar algo de outro pacote que começa com uma letra minúscula, ele sabe que não é exportado e, portanto, lança um erro de compilador.

O logger neste tutorial ilustra como podemos escrever código que expõe apenas as partes que desejamos que outros pacotes consumam. Como controlamos quais partes do pacote são visíveis fora do pacote, agora somos capazes de fazer alterações futuras sem afetar nenhum código que dependa do nosso pacote. Por exemplo, se quiséssemos desativar apenas mensagens de nível info quando debug é falso, você poderia fazer essa mudança sem afetar qualquer outra parte da sua API. Também poderíamos fazer alterações com segurança nas mensagens de log para incluir mais informações, como o diretório de onde o programa estava sendo executado.

Conclusão

Este artigo mostrou como compartilhar código entre pacotes ao mesmo tempo em que protege os detalhes de implementação do seu pacote. Isso permite que você exporte uma API simples que raramente mudará para compatibilidade com versões anteriores, mas permitirá mudanças privadas no seu pacote conforme necessário para melhorar seu funcionamento no futuro. Isso é considerado uma prática recomendada ao criar pacotes e suas respectivas APIs.

Para saber mais sobre pacotes em Go, confira nossos artigos Importando Pacotes em Go e Como Escrever Pacotes em Go, ou explore nossa série completa Como Programar em Go.

Source:
https://www.digitalocean.com/community/tutorials/understanding-package-visibility-in-go