Tutorial

Entendendo a visibilidade de pacotes em Go

GoDevelopment

Introdução

Ao criar um pacote em Go, normalmente, o objetivo final é tornar o pacote acessível para uso por outros desenvolvedores, seja em pacotes de ordem mais elevada ou em programas inteiros. Ao importar o pacote, seu código poderá servir como o 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.

Neste contexto, a visibilidade representa o espaço do arquivo a partir do qual é possível referenciar um pacote ou outro contructo. Por exemplo, se definirmos uma variável em uma função, a visibilidade (escopo) daquela variável fica apenas dentro da função na qual ela foi definida. Analogamente, se você definir uma variável em um pacote, você poderá torná-la visível apenas para aquele pacote ou permitir que ela fique visível também fora do pacote.

Desse modo, é importante controlar cuidadosamente da visibilidade do pacote ao se escrever códigos ergonômicos, especialmente levando-se em conta as futuras alterações que possam vir a ser feitas no pacote. Se você precisar corrigir um erro, melhorar o desempenho ou alterar funcionalidades, você irá querer fazer a mudança de maneira a não quebrar o código de quem estiver usando o seu pacote. Uma maneira de minimizar alterações problemáticas é permitir o acesso somente às partes do pacote necessárias para que seja usado corretamente. Ao limitar o acesso, você pode alterar o pacote internamente, com menor chance de afetar a forma como outros desenvolvedores o estão usando.

Neste artigo, você aprenderá como controlar a visibilidade de pacotes, além de como proteger as partes do seu código que devem ser usadas apenas dentro do seu pacote. Para fazer isso, vamos criar um agente básico para registrar e depurar mensagens, usando pacotes com graus diferentes de visibilidade dos itens.

Pré-requisitos

Para seguir os exemplos neste artigo, será necessário:

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

Itens exportados e não exportados

Ao contrário do que ocorre em outras linguagens de programação,como Java e Python - que usam modificadores de acesso, como public, private ou protected para especificar o escopo, a linguagem Go determina se um item é exported [exportado] e unexported [não exportado] pela forma como é declarado. Exportar um item neste caso torna ele visible [visível] fora do pacote atual. Caso não seja exportado, ele fica apenas visível e utilizável de dentro do pacote em que foi definido.

Esta visibilidade externa é controlada colocando-se a primeira letra do item declarado em maiúscula. Todas as declarações, tais como Types, Variables, Constants, Functions etc., que começam com uma letra maiúscula, ficam visíveis fora do pacote atual.

Vejamos o código a seguir, prestando muita atenção ao uso de letras maiúsculas e minúsculas:

greet.go
package greet

import "fmt"

var Greeting string

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

Esse código declara que ele está no pacote greet. Depois, ele declara dois símbolos, uma variável chamada Greeting e uma função chamada Hello. Como ambas começam com uma letra maiúscula, as duas são exported e disponibilizadas para qualquer programa exterior. Como indicado anteriormente, a criação de um pacote que limita o acesso permitirá um melhor design da API, além de facilitar a atualização do seu pacote internamente sem quebrar o código de quem estiver contando com o seu pacote.

Definindo a visibilidade de pacotes

Para examinar melhor como a visibilidade de pacotes funciona em um programa, vamos criar um pacote logging, tendo em mente o que queremos tornar visível fora do nosso pacote e o que não vamos tornar visível. Esse pacote de registro será responsável por registrar todas as mensagens de nosso programa no console. Ele também examinará em qual nível estamos registrando. Um nível descreve o tipo de registro e tem um destes três status: info, warning ou error.

Primeiro, dentro do seu diretório src, vamos criar um diretório chamado logging, onde colocaremos nossos arquivos de registro:

  • mkdir logging

Acesse aquele diretório em seguida:

  • cd logging

Então, usando um editor como o nano, crie um arquivo chamado logging.go:

  • nano logging.go

Coloque o código a seguir 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 desse código declarou um pacote chamado logging. Nesse pacote, há duas funções exported: Debug e Log. Essas funções podem ser chamadas por qualquer outro pacote que importe o pacote logging. Também há uma variável privada chamada debug. Essa variável é acessível apenas de dentro do pacote logging. É importante notar que, apesar da função Debug e da variável debug terem a mesma grafia, a função tem letra maíuscula e a variável não. Isso as torna declarações distintas com escopos diferentes.

Salve e saia do arquivo.

Para usar esse pacote em outras áreas do nosso código, podemos usar o import nele em um novo pacote. Criaremos esse novo pacote, mas vamos precisar de um novo diretório para armazenar esses arquivos fonte em primeiro lugar.

Vamos sair do diretório logging, criar um novo diretório chamado cmd e ir até esse novo diretório:

  • cd ..
  • mkdir cmd
  • cd cmd

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

  • 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 nosso programa escrito por completo. No entanto, antes de executarmos este programa, também precisaremos criar alguns dos arquivos de configuração para que o nosso código funcione corretamente. A linguagem Go usa Módulos Go para configurar as dependências de pacotes para a importação de recursos. Os módulos Go são arquivos de configuração colocados no seu diretório de pacotes que dizem ao compilador de onde importar os pacotes. Embora o aprendizado acerca dos módulos ultrapasse o âmbito deste artigo, podemos escrever apenas algumas linhas de configuração para fazer com que este exemplo funcione localmente.

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

  • nano go.mod

Então, coloque o seguinte conteúdo no arquivo:

go.mod
module github.com/gopherguides/cmd

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

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

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

  • cd ../logging
  • nano go.mod

Adicione o conteúdo a seguir ao arquivo:

go.mod
module github.com/gopherguides/logging

Isso diz ao compilador que o pacote logging que criamos é, na verdade, o pacote github.com/gopherguides/logging. Isso torna possível 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 arquivo:

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

Agora que temos toda a configuração completa, podemos executar o programa main a partir do pacote cmd, com os seguintes comandos:

  • cd ../cmd
  • go run main.go

Você irá obter um resultado similar ao seguinte:

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

O programa imprimirá o tempo atual no formato RFC 3339, seguido de qualquer instrução que tivermos enviado ao agente de log. RFC 3339 é um formato de hora que foi concebido para representar a hora na internet e é comumente usado em arquivos de registro.

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

Adicione a seguinte linha destacada ao 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 similar ao seguinte:

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

Agora que vimos como se comportam os itens exported e unexported dos pacotes, vamos examinar, a seguir, como os fields [campos] e methods [métodos] podem ser exportados de structs.

Visibilidade dentro de structs

Embora o esquema de visibilidade no agente de log - que construímos na última seção - possa funcionar para programas simples, ele compartilha estados demais para ser útil dentro de vários pacotes. Isso acontece porque as variáveis exportadas ficam acessíveis a vários pacotes que poderiam modificar as variáveis em estados contraditórios. Permitir que o estado do seu pacote seja alterado dessa maneira torna difícil prever como seu programa se comportará. Com o design atual, por exemplo, um pacote poderia definir a variável Debug como true [verdadeiro] e outro poderia defini-la como false [falso] na mesma instância. Isso criaria um problema, uma vez que ambos os pacotes que estão importando o pacote logging são afetados.

Podemos deixar o agente de log isolado, criando uma struct e, na sequência, recuando os métodos para longe dela. Isso nos permitirá criar uma instance [instância] de um agente de log a ser usada de maneira independente em cada pacote que a consome.

Altere o pacote logging para o seguinte, no intuito de refatorar o código e isolar o agente de log.

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 struct chamada Logger. Ela irá abrigar nosso estado não exportado, incluindo o formato de hora a ser impresso e a definição da variável debug como true ou false. A função New define o estado inicial para usar na criação do agente de log, como o formato de hora e o estado de depuração. Então, ele armazena os valores que demos a ele internamente para as variáveis não exportadas timeFormat e debug. Também criamos um método chamado Log no tipo Logger que usa uma instrução que queremos imprimir. Dentro do método Log há uma referência à sua variável de método local l para retomar o acesso aos seus campos internos, como a l.timeFormat e l.debug.

Essa abordagem nos permitirá criar uma struct Logger em diversos pacotes e usá-la independentemente da forma como os outros pacotes a estiverem usando.

Para usá-la em outro pacote, vamos alterar o cmd/main.go para que se pareça com o seguinte:

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...")
}

Ao executar esse programa, você terá o seguinte resultado:

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

Nesse código, criamos uma instância do agente de log ao chamar a função exportada New. Armazenamos a referência a essa instância na variável logger. Agora, podemos chamar logging.Log para imprimir as instruções.

Se tentarmos referenciar um campo não exportado do Logger, como o campo timeFormat, receberemos um erro de tempo de compilação. Tente adicionar a seguinte linha destacada e executar o 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 dará o seguinte erro:

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

O compilador reconhece que o logger.timeFormat não foi exportado, e, desta forma, não pode ser recuperado do pacote logging.

Visibilidade dentro de métodos

Assim como ocorre com os campos struct, os métodos também podem ser exportados ou não exportados.

Para ilustrar isso, vamos adicionar registro nivelado ao nosso agente de log. O registro nivelado é um meio de categorizar seus registros para que você possa procurar seus registros para tipos específicos de eventos. Os níveis que vamos colocar no nosso agente de log são:

  • O nível info, que representa eventos do tipo informação que informam o usuário de uma ação, como Program started [Programa iniciado] ou Email sent [E-mail enviado]. Isso nos ajuda a depurar e rastrear partes do nosso programa para ver se o comportamento esperado está acontecendo.

  • O nível warning [aviso]. Esses tipos de eventos identificam quando algo inesperado está acontecendo e que não é um erro, como Email failed to send, retrying [Falha ao enviar e-mail, tentando novamente]. Eles nos ajudam a ver partes do nosso programa que não estão rodando tão bem quanto esperávamos que estivessem.

  • O nível error [erro], que significa que o programa encontrou um problema, como File not found [Arquivo não encontrado]. Frequentemente, isso resultará na falha da operação do programa.

Você também pode querer ligar e desligar certos níveis de registro, especialmente se o seu programa não estiver com o desempenho esperado e você quiser depurar o programa. Vamos adicionar essa funcionalidade, alterando o programa para que - quando debug for definido como true - ele imprima todos os níveis de mensagens. Caso contrário, se for false, imprimirá apenas mensagens de erro.

Adicione o registro nivelado, 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)
}

Nesse exemplo, introduzimos um novo argumento ao método Log. Agora, podemos enviar o level [nível] da mensagem de registro. O método Log determina o nível em que a mensagem está. Se for uma mensagem de status info ou warning e o campo debug for true, então, ele escreve a mensagem. Caso contrário, a mensagem é ignorada. Se estiver em qualquer outro nível, como error, a mensagem será escrita de qualquer forma.

Grande parte da lógica - para determinar se a mensagem será impressa, encontra-se no método Log. Também introduzimos um método não exportado chamado write. O método write é o que, de fato, produz o registro.

Agora, podemos usar o registro nivelado em nosso outro pacote, mudando o cmd/main.go para que se pareça com o seguinte:

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")

}

Ao executar isso, você terá:

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, o cmd/main.go usou o método exportado Log.

Agora, podemos enviar o level de cada mensagem, alterando o 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 do nível error foram 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 de 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 vê que você está tentando referenciar algo de outro pacote - que começa com uma letra em caixa baixa, ele sabe que se trata de algo que não está exportado e, portanto, gera um erro de compilação.

O agente de log neste tutorial ilustra como podemos escrever códigos que expõem apenas as partes que queremos que outros pacotes consumam. Como controlamos quais partes do pacote ficam visíveis fora do pacote, agora conseguiremos fazer alterações no futuro sem afetar nenhum código que dependa do nosso pacote. Por exemplo, se quiséssemos desligar apenas mensagens do nível info quando o debug estiver definido como falso, você poderia fazer essa mudança sem afetar nenhuma outra parte da sua API. Também podemos fazer alterações na mensagem de registro para incluir mais informações com segurança, tal como o diretório do qual o programa está sendo executado.

Conclusão

Este artigo mostrou como compartilhar códigos entre os pacotes, ao mesmo tempo protegendo os detalhes da implementação do seu pacote. Isso permite que você exporte uma API simples que raramente será alterada a título de compatibilidade com versões anteriores, mas que permitirá alterações de maneira reservada, dentro do seu pacote e conforme necessário, para que ele funcione melhor no futuro. Tal procedimento é considerado como prática recomendada na criação de pacotes e suas APIs correspondentes.

Para aprender mais sobre os pacotes em Go, leia nossos artigos Importando pacotes em Go e Como escrever pacotes em Go, ou explore toda a nossa série de artigos sobre Como programar em Go.

0 Comments

Creative Commons License