Tutorial

Información sobre la visibilidad de paquetes en Go

GoDevelopment

Introducción

Cuando se crea un paquete en Go, el objetivo final suele ser hacer que sea accesible para que otros desarrolladores lo utilicen, ya sea en paquetes de orden superior o en programas completos. Al importar el paquete, su pieza de código puede servir como bloque de creación para otras herramientas más complejas. Sin embargo, solo se pueden importar determinados paquetes. Esto se determina por la visibilidad del paquete.

El término visibilidad en este contexto se refiere al espacio de archivo desde el que se puede hacer referencia a un paquete o a otra creación. Por ejemplo, si definimos una variable en una función, la visibilidad (el ámbito) de dicha variable solo se encuentra dentro de la función en la que se definió. De modo similar, si define una variable en un paquete, puede hacerla visible solamente a ese paquete o permitir que también sea visible fuera de él.

El control cuidadoso de la visibilidad de los paquetes es importante al escribir código ergonómico, sobre todo cuando se deben tener en cuenta los cambios futuros que podría querer realizar en su paquete. Si necesita corregir un error, mejorar el rendimiento o cambiar una funcionalidad, le convendrá realizar la modificación de una forma que no rompa el código de nadie que utilice su paquete. Una manera de reducir al mínimo los cambios que provocan rupturas es permitir el acceso a las partes de su paquete que son necesarias para que se utilice de forma adecuada. Al limitar el acceso, puede realizar cambios de forma interna en su paquete con menos posibilidades de afectar la manera en que otros desarrolladores utilizan su paquete.

A través de este artículo, aprenderá a controlar la visibilidad de los paquetes y a proteger partes de su código que solo se deben usar dentro de su paquete. Para hacerlo, crearemos un registrador básico a fin de registrar y depurar mensajes usando paquetes con diferentes grados de visibilidad de elementos.

Requisitos previos

Para seguir los ejemplos que se presentan en este artículo, necesitará lo siguiente:

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

Elementos exportados y no exportados

A diferencia de otros lenguajes de programación, como Java y Python, que utilizan* modificadores de acceso* como public, private o protected para especificar el ámbito, Go determina si un elemento es exported o unexported según la forma en que se declara. Exportar un elemento, en este caso, lo hace visible fuera del paquete actual. Si no se exportó, solo es visible y utilizable dentro del paquete en el que se definió.

Esta visibilidad externa se controla escribiendo en mayúscula la primera letra del elemento declarado. Todas las declaraciones, como Types, Variables, Constants, Functions y demás que comienzan con una letra en mayúscula son visibles fuera del paquete actual.

Veamos el siguiente código, prestando especial atención al uso de mayúsculas:

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á en el paquete greet. Luego, declara dos símbolos, una variable llamada Greeting y una función denominada Hello. Debido a que ambos comienzan con una letra mayúscula, ambos son exported y están disponibles para cualquier programa externo. Como se indicó anteriormente, crear un paquete que limite el acceso permitirá un mejor diseño de API y facilitará la actualización de su paquete a nivel interno sin interrumpir código que dependa de su paquete.

Definir la visibilidad de paquetes

Para analizar de forma más detallada el funcionamiento de la visibilidad de paquetes en un programa, crearemos un paquete logging y tendremos en cuenta lo que queremos que sea y no sea visible fuera de él. Este paquete logging se encargará de registrar en la consola todos los mensajes de nuestro programa. También analizará el nivel en el que realizamos el registro. El nivel describe el tipo de registro y será uno de tres estados: info, warning o error.

Primero, dentro de su directorio src, crearemos un directorio llamado logging en el que dispondremos nuestros archivos de registro:

  • mkdir logging

A continuación, posiciónese en ese directorio:

  • cd logging

Luego, usando un editor como nano, cree un archivo llamado logging.go:

  • nano logging.go

Disponga el siguiente código en el archivo logging.go que acabamos de crear:

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

La primera línea de este código declaró un paquete llamado logging. En este paquete, hay dos funciones exported: Debug y Log. Cualquier otro paquete que importe el paquete logging puede invocar estas funciones. También hay una variable privada llamada debug. El acceso a esta variable solo es posible desde el paquete logging. Es importante observar que, si bien la función Debug y la variable debug tienen el mismo nombre, la función se escribe con la primera letra en mayúscula y la variable no. Esto las convierte en distintas declaraciones con diferentes ámbitos.

Guarde y cierre el archivo.

Para usar este paquete en otras áreas de nuestro código, podemos importarlo con import a un nuevo paquete. Crearemos este nuevo paquete, pero necesitaremos un directorio nuevo para almacenar esos archivos de origen primero.

Saldremos del directorio logging, crearemos un nuevo directorio llamado cmd y nos posicionaremos en él:

  • cd ..
  • mkdir cmd
  • cd cmd

Cree un archivo llamado main.go en el directorio cmd que acabamos de crear:

  • nano main.go

Ahora, podemos añadir el siguiente código:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

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

Con esto, nuestro programa quedará totalmente escrito. Sin embargo, para poder ejecutar este programa, también debemos crear algunos archivos de configuración a fin de que nuestro código funcione correctamente. Go utiliza Go Modules para configurar las dependencias de paquetes para la importación de recursos. Estos módulos son archivos de configuración que se disponen en su directorio de paquetes e indican al compilador las ubicaciones desde las cuales se deben importar los paquetes. Si bien en este artículo no obtendrá información sobre los módulos, podemos escribir algunas líneas de configuración para que este ejemplo funcione a nivel local.

Abra el siguiente archivo go.mod en el directorio cmd:

  • nano go.mod

A continuación, disponga el siguiente contenido en el archivo:

go.mod
module github.com/gopherguides/cmd

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

La primera línea de este archivo indica al compilador que el paquete cmd tiene la ruta de archivo github.com/gopherguides/cmd. La segunda línea indica al compilador que github.com/gopherguides/logging se encuentra a nivel local en el disco, en el directorio ../logging.

También necesitaremos un archivo go.mod para nuestro paquete logging. Ingresaremos de nuevo en el directorio logging y crearemos un archivo go.mod:

  • cd ../logging
  • nano go.mod

Añada el siguiente contenido al archivo:

go.mod
module github.com/gopherguides/logging

Esto indica al compilador que el paquete logging que creamos, en realidad, es el paquete github.com/gopherguides/logging. Esto permite importar el paquete en nuestro paquete main con la siguiente línea que escribimos anteriormente:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

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

Con esto, debería disponer de la siguiente estructura de directorios y distribución de archivos:

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

Ahora que completamos toda la configuración, podemos ejecutar el programa main desde el paquete cmd con el siguiente comando:

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

Obtendrá un resultado similar al siguiente:

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

El programa imprimirá la hora actual en formato RFC 3339 seguida de cualquier instrucción que enviamos al registrador. RFC 3339 es un formato de hora que se diseñó para representar la hora en Internet y se utiliza comúnmente en archivos de registro.

Debido a que las funciones Debug y Log se exportan desde el paquete logging, podemos usarlas en nuestro paquete main. Sin embargo, la variable debug que se muestra en el paquete logging no se exporta. Intentar hacer referencia a una declaración no exportada provocará un error en el tiempo de compilación.

Añada la siguiente línea resaltada 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)
}

Guarde y ejecute el archivo. Verá un error similar al siguiente:

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

Ahora que vimos cómo se comportan los elementos exported y unexported en paquetes, veremos cómo se pueden exportar fields y methods desde structs.

Visibilidad dentro de estructuras

Si bien el esquema de visibilidad del registrador que creamos en la última sección puede funcionar con programas simples, comparte demasiado estado para ser útil desde el interior de varios paquetes. Esto se debe a que varios paquetes que podrían aplicar a las variables modificaciones que derivarían en estados contradictorios pueden acceder a las variables exportadas. Permitir que el estado de su paquete se modifique de esta manera hace que sea difícil predecir el comportamiento del programa. Con el diseño actual, por ejemplo, un paquete podría fijar la variable Debug en true y otro en false en la misma instancia. Esto generaría un problema porque los dos paquetes que importan el paquete logging se verían afectados.

Podemos aislar el registrador creando una estructura y, luego, métodos que se desprendan de ella. Esto nos permitirá crear una instance de un registrador que se usará de forma independiente en cada paquete que la emplee.

Aplique el siguiente cambio al paquete logging para volver a factorizar y aislar el registrador:

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

En este código, creamos una estructura de Logger. Esta estructura alojará nuestro estado no exportado con el formato de hora para imprimir y la variable debug fijada en true o false. La función New establece el estado inicial con el que se crea el registrador, como el formato de hora y el estado de depuración. Luego, almacena los valores que asignamos a las variables no exportadas timeFormat y debug. También creamos un método llamado Log, con el tipo Logger, que toma una instrucción que queremos imprimir. Dentro del método Log hay una referencia a su variable de método local l para que se restablezca el acceso a sus campos internos, como l.timeFormat y l.debug.

Este enfoque nos permitirá crear un Logger en muchos paquetes diferentes y utilizarlo independientemente de la forma en que se utilicen los demás paquetes.

Para utilizarlo en otro paquete, modificaremos cmd/main.go de la siguiente manera:

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

Al ejecutar este programa, obtendrá el siguiente resultado:

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

En este código, creamos una instancia del registrador invocando la función exportada New. Almacenamos la referencia a esta instancia en la variable logger. Ahora, podemos invocar logging.Log para imprimir instrucciones.

Si intentamos hacer referencia a un campo no exportado desde Logger, como el campo timeFormat, veremos un error en el tiempo de compilación. Intente añadir la siguiente línea resaltada y ejecutar 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)
}

Esto generará el siguiente error:

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

El compilador reconoce que logger.timeFormat no se exporta y, por lo tanto, no se puede obtener desde el paquete logging.

Visibilidad dentro de métodos

Al igual que los campos de estructura, los métodos también se pueden exportar o no.

Para ilustrar esto, añadiremos un registro nivelado a nuestro registrador. El registro nivelado es una forma de clasificar sus registros para poder buscar tipos específicos de eventos en ellos. Estos son los niveles que disponemos en nuestro registrador:

  • El nivel info, que representa eventos de tipo de información que informan acciones al usuario, como Program started o Email sent. Nos ayudan a depurar y controlar partes de nuestro programa para ver si el comportamiento es el previsto.

  • El nivel warning. Estos tipos de eventos detectan aspectos inesperados que no constituyen errores, como Email failed to send, retrying. Nos permiten ver partes de nuestro programa que no funcionan tan bien como esperamos.

  • El nivel error, que indica que el programa encontró un problema, como File not found. A menudo, esto generará fallas de funcionamiento en el programa.

También es posible que desee activar y desactivar ciertos niveles de registro, en particular si su programa no funciona de la manera prevista y desea depurarlo. Añadiremos esta funcionalidad modificando el programa para que, cuando el valor de debug se fije en true, imprima todos los niveles de mensajes. De lo contrario, si se fija en false, solo imprimirá mensajes de error.

Añada un registro nivelado realizando los siguientes cambios en 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)
}

En este ejemplo, introdujimos un nuevo argumento en el método Log. Ahora, podemos pasar el level del mensaje de registro. El método Log determina el nivel de mensaje que tiene. Si se trata de un mensaje info o warning, y el campo debug es true, escribe el mensaje. De lo contrario, ignora el mensaje. Si se trata de cualquier otro nivel, como error, escribirá el mensaje de todos modos.

En el método Log, se encuentra la mayor parte de la lógica para determinar si el mensaje se imprime. También introdujimos un método no exportado llamado write. El método write es el elemento que, de hecho, muestra el mensaje de registro.

Ahora, podemos usar este registro nivelado en nuestro otro paquete cambiando cmd/main.go para que tenga el siguiente aspecto:

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

}

Al ejecutar esto, obtendrá lo siguiente:

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

En este ejemplo, cmd/main.go utilizó de forma correcta el método Log exportado.

Ahora, podemos establecer el level de cada mensaje haciendo que el valor de debug cambie a 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")

}

Veremos que solo se imprimen los mensajes de nivel error:

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

Si intentamos invocar el método write desde fuera del paquete logging, observaremos un error de tiempo de compilación

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)

Cuando el compilador ve que usted intenta hacer referencia a algo de otro paquete que comienza con una letra minúscula, sabe que no se exporta y, por lo tanto, muestra un error del compilador.

El registrador de este tutorial muestra la forma en que podemos escribir código que solo exponga las partes que queremos que los demás paquetes consuman. Dado que controlamos las partes del paquete que son visibles fuera de este, ahora podemos realizar cambios futuros sin afectar ningún código que dependa de nuestro paquete. Por ejemplo, si desea desactivar únicamente los mensajes de nivel de info cuando el valor de debug sea “false”, podría realizar este cambio sin afectar a ninguna otra parte de su API. También podría realizar cambios en el mensaje de registro para incluir más información, como el directorio desde el cual se ejecutaba el programa.

Conclusión

En este artículo, se mostró la forma de compartir código entre paquetes protegiendo, a su vez, los detalles de implementación de su paquete. Esto le permite exportar una API sencilla que en pocas ocasiones cambiará para ofrecer compatibilidad con versiones anteriores, pero permitirá que se realicen cambios en su paquete de forma privada, según sea necesario, para que funcione mejor en el futuro. Esta práctica se considera recomendada al crear paquetes y sus API correspondientes.

Para obtener más información sobre paquetes en Go, consulte Importar paquetes en Go y Escribir paquetes en Go o vea toda nuestra serie Programar en Go.

0 Comments

Creative Commons License