Los 10 errores más comunes que he encontrado en Go-projects

Esta publicación es mi principal de los errores más comunes que encontré en Go-projects. El orden no importa.

imagen

Valor desconocido de Enum


Echemos un vistazo a un ejemplo simple:

type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown ) 

Aquí creamos un enumerador usando iota, lo que conducirá a este estado:

 StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2 

Ahora imaginemos que este tipo de estado es parte de la solicitud JSON que se empaquetará / desempaquetará. Podemos diseñar la siguiente estructura:

 type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` } 

Luego obtenemos el resultado de esta consulta:

 { "Id": 1234, "Timestamp": 1563362390, "Status": 0 } 

En general, nada especial: el estado se descomprimirá en StatusOpen.
Ahora, obtengamos otra respuesta en la que el valor de estado no esté establecido:

 { "Id": 1235, "Timestamp": 1563362390 } 

En este caso, el campo Estado de la estructura Solicitud se inicializará a cero (para uint32 es 0). Por lo tanto, nuevamente obtenemos StatusOpen en lugar de StatusUnknown.

En este caso, es mejor establecer primero el valor desconocido del enumerador, es decir 0:

 type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed ) 

Si el estado no es parte de la solicitud JSON, se inicializará en StatusUnknown, como esperamos.

Benchmarking


La evaluación comparativa correcta es bastante difícil. Demasiados factores pueden influir en el resultado.

Un error común es ser engañado por las optimizaciones del compilador. Veamos un ejemplo específico de la biblioteca teivah / bitvector :

 func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n } 

Esta función borra bits en un rango dado. Podemos probar el rendimiento de esta manera:

 func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } } 

En esta prueba, el compilador notará que clear no llama a ninguna otra función, por lo que simplemente lo incrusta como está. Una vez que está integrado, el compilador verá que no se producen efectos secundarios. Por lo tanto, la llamada clara simplemente se eliminará, lo que conducirá a resultados inexactos.

Una solución puede ser establecer el resultado en una variable global, como esta:

 var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r } 

Aquí el compilador no sabrá si la llamada crea un efecto secundario. Por lo tanto, el punto de referencia será preciso.

Punteros! Los punteros están en todas partes!


Pasar una variable por valor creará una copia de esta variable. Mientras pasa por el puntero, simplemente copie la dirección en la memoria.

En consecuencia, pasar un puntero siempre será más rápido, ¿verdad?

Si crees que sí, mira este ejemplo . Este es un punto de referencia para una estructura de datos de 0.3 KB que primero transmitimos y recibimos por puntero, y luego por valor. 0.3 KB es un poco, sobre las estructuras de datos habituales con las que trabajamos todos los días que ocupan tanto.

Cuando ejecuto estas pruebas en un entorno local, la transmisión de valor por valor es más de 4 veces más rápida. Bastante inesperado, ¿verdad?

La explicación de este resultado está relacionada con la comprensión de cómo se produce la gestión de la memoria en Go. No puedo explicarlo tan brillantemente como William Kennedy , pero intentemos resumirlo en pocas palabras.

Se puede colocar una variable en el montón o pila:
  • La pila contiene las variables actuales de este programa. Tan pronto como la función regrese, las variables se sacan de la pila.
  • El montón contiene variables comunes (variables globales, etc.).

Veamos un ejemplo simple donde devolvemos un valor:

 func getFooValue() foo { var result foo // Do something return result } 

Aquí la variable de resultado es creada por la rutina actual. Esta variable se inserta en la pila actual. Tan pronto como la función regrese, el cliente recibirá una copia de esta variable. La variable en sí se saca de la pila. Todavía existe en la memoria hasta que se sobrescriba otra variable, pero ya no se puede acceder a ella.
Ahora el mismo ejemplo, pero con un puntero:

 func getFooPointer() *foo { var result foo // Do something return &result } 

La variable de resultado todavía es creada por la rutina actual, pero el cliente recibirá un puntero (una copia de la dirección de la variable). Si la variable de resultado se extrajo de la pila, el cliente de esta función no podrá acceder a ella.

En este escenario, el compilador Go generará la variable de resultado donde las variables se pueden compartir, es decir. en un montón

Otro script para pasar punteros:

 func main() { p := &foo{} f(p) } 

Como llamamos a f en el mismo programa, la variable p no necesita ser acumulada. Simplemente se empuja a la pila, y una subfunción puede acceder a ella.

Por ejemplo, de esta forma se obtiene un corte en el método de lectura de io.Reader. Devolver un segmento (que es un puntero) lo coloca en un montón.

¿Por qué la pila es tan rápida? Hay dos razones:
  • No es necesario usar el recolector de basura en la pila. Como ya dijimos, una variable simplemente se empuja después de ser creada, y luego se saca de la pila cuando la función regresa. No es necesario agitar un proceso complicado para devolver variables no utilizadas, etc.
  • La pila pertenece a una rutina, por lo que no es necesario sincronizar el almacenamiento de la variable, como sucede con el almacenamiento en el montón, lo que también conduce a un aumento del rendimiento.

En conclusión, cuando creamos una función, nuestra acción predeterminada debería ser usar valores en lugar de punteros. Un puntero solo debe usarse si queremos compartir una variable.

Además, si sufrimos problemas de rendimiento, ¿una de las posibles optimizaciones podría ser verificar si los punteros ayudan en situaciones específicas? Se puede averiguar si el compilador genera una variable en el montón con el siguiente comando:
 go build -gcflags "-m -m" 
.
Pero, nuevamente, para la mayoría de nuestras tareas diarias, usar valores es lo mejor.

Anular para / cambiar o para / seleccionar


¿Qué sucede en el siguiente ejemplo si f devuelve verdadero?

 for { switch f() { case true: break case false: // Do something } } 

Llamamos descanso. Solo esta ruptura rompe el interruptor, no el bucle for.

Mismo problema aquí:

 for { select { case <-ch: // Do something case <-ctx.Done(): break } } 

La ruptura está asociada con una instrucción select, no con un bucle for.

Una posible solución para interrumpir for / switch o for / select es usar una etiqueta:

 loop: for { select { case <-ch: // Do something case <-ctx.Done(): break loop } } 

Manejo de errores


Go todavía es joven, especialmente en el área de manejo de errores. Superar esta deficiencia es una de las innovaciones más esperadas en Go 2.

La biblioteca estándar actual (anterior a Go 1.13) ofrece solo funciones para construir errores. Por lo tanto, será interesante echar un vistazo al paquete pkg / errors .

Esta biblioteca es una buena manera de seguir una regla que no siempre se respeta:
El error debe procesarse solo una vez. El registro de errores es el manejo de errores
. Por lo tanto, el error debe registrarse o lanzarse más alto.

En la biblioteca estándar actual, este principio es difícil de observar, ya que es posible que queramos agregar contexto al error y tener algún tipo de jerarquía.

Veamos un ejemplo con una llamada REST que conduce a un error en la base de datos:

 unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction 

Si usamos pkg / errors, podemos hacer lo siguiente:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error { // Do something then fail return errors.New("unable to commit transaction") } 

El error inicial (si la biblioteca externa no lo devuelve) se puede crear utilizando errores. Nuevo. La capa intermedia, insertar, envuelve este error y le agrega más contexto. Entonces el padre lo registra. Por lo tanto, cada nivel devuelve o procesa un error.

También podemos querer encontrar la causa del error, por ejemplo, para volver a llamar. Supongamos que tenemos un paquete db de una biblioteca externa que tiene acceso a una base de datos. Esta biblioteca puede devolver un error temporal llamado db.DBError. Para determinar si necesitamos volver a intentarlo, debemos establecer la causa del error:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } 

Esto se hace usando errores. Causa, que también se incluye en pkg / errors :

Uno de los errores comunes que encontré fue el uso de pkg / errors solo parcialmente. Una verificación de error, por ejemplo, se realizó de la siguiente manera:

 switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } 

En este ejemplo, si db.DBError está envuelto, nunca hará una segunda llamada.

Inicialización de rebanada


A veces sabemos cuál será la longitud final de la porción. Por ejemplo, supongamos que queremos convertir un segmento Foo en un segmento Bar, lo que significa que estos dos segmentos tendrán la misma longitud.

A menudo encuentro trozos inicializados de esta manera:

 var bars []Bar bars := make([]Bar, 0) 

Slice no es una estructura mágica. Debajo del capó, implementa una estrategia para aumentar el tamaño si no hay más espacio libre. En este caso, se crea automáticamente una nueva matriz (con una mayor capacidad) y todos los elementos se copian en ella.

Ahora imaginemos que necesitamos repetir esta operación de aumentar el tamaño varias veces, ya que nuestro [] Foo contiene miles de elementos. La complejidad del algoritmo de inserción seguirá siendo O (1), pero en la práctica esto afectará el rendimiento.

Por lo tanto, si conocemos la longitud final, podemos:

  • Inicialícelo con una longitud predefinida:

 func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } 

  • O inicialícelo con una longitud de 0 y una capacidad predeterminada:

 func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars } 

¿Cuál es la mejor opción? El primero es un poco más rápido. Sin embargo, puede preferir este último porque es más consistente: independientemente de si conocemos el tamaño inicial, la adición de un elemento al final del segmento se realiza mediante el agregado.

Gestión del contexto


contexto. El contexto a menudo es mal entendido por los desarrolladores. Según la documentación oficial:
El contexto lleva la fecha límite, la señal de cancelación y otros valores a través de los límites de la API.
Esta descripción es bastante general, por lo tanto, puede confundir al programador cómo usarla correctamente.

Tratemos de resolverlo. El contexto puede llevar:
  • Fecha límite: significa la duración (por ejemplo, 250 ms) o la fecha y hora (por ejemplo, 2019-01-08 01:00:00), según la cual creemos que si se alcanza, la acción actual debe cancelarse (solicitud de E / S ), esperando la entrada del canal, etc.).
  • Cancelar señal (básicamente <-chan struct {}). Aquí el comportamiento es similar. Tan pronto como recibamos una señal, debemos detener el trabajo actual. Por ejemplo, supongamos que recibimos dos solicitudes. Uno para insertar datos y el otro para cancelar la primera solicitud (porque ya no es relevante, por ejemplo). Esto se puede lograr utilizando el contexto cancelado en la primera llamada, que luego se cancelará tan pronto como recibamos la segunda solicitud.
  • Lista de clave / valor (ambas basadas en el tipo de interfaz {}).

Dos puntos más. Primero, el contexto es componible. Por lo tanto, podemos tener un contexto que lleve la fecha límite y la lista de clave / valor, por ejemplo. Además, múltiples goroutines pueden compartir el mismo contexto, por lo que una señal de cancelación puede potencialmente detener varios trabajos.

Volviendo a nuestro tema, aquí hay un error que conocí.

La aplicación Go se basó en urfave / cli (si no lo sabe, esta es una buena biblioteca para crear aplicaciones de línea de comandos en Go). Una vez iniciado, el desarrollador hereda un tipo de contexto de aplicación. Esto significa que cuando la aplicación se detiene, la biblioteca utilizará el contexto para enviar una señal de cancelación.

Noté que este contexto se transmitió directamente, por ejemplo, cuando se llamó a un punto final gRPC. Esto no es en absoluto lo que necesitamos.

En cambio, queremos decirle a la biblioteca gRPC: cancele la solicitud cuando la aplicación se detenga, o después de 100 ms, por ejemplo.

Para lograr esto, simplemente podemos crear un contexto compuesto. Si padre es el nombre del contexto de la aplicación (creado por urfave / cli ), entonces simplemente podemos hacer esto:

 ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request) 

Los contextos no son tan difíciles de entender y, en mi opinión, esta es una de las mejores características del lenguaje.

No usar la opción -race


Probar una aplicación Go sin la opción -race es un error que constantemente encuentro.

Como está escrito en este artículo , aunque Go fue " diseñado para hacer que la programación paralela sea más simple y menos propensa a errores ", todavía sufrimos grandes problemas de concurrencia.

Obviamente, el detector de carrera Go no ayudará con ningún problema. Sin embargo, es una herramienta valiosa, y siempre debemos incluirla al probar nuestras aplicaciones.

Usando el nombre del archivo como entrada


Otro error común es pasar el nombre del archivo a una función.

Supongamos que necesitamos implementar una función para contar el número de líneas vacías en un archivo. La implementación más natural se vería así:

 func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil } 

El nombre del archivo se establece como una entrada, por lo que lo abrimos y luego implementamos nuestra lógica, ¿verdad?

Ahora supongamos que queremos cubrir esta función con pruebas unitarias. Probamos con un archivo normal, un archivo vacío, un archivo con un tipo diferente de codificación, etc. Puede ser muy difícil de administrar.

Además, si queremos implementar la misma lógica, por ejemplo, para el cuerpo HTTP, necesitaremos crear otra función para esto.

Go viene con dos grandes abstracciones: io.Reader y io.Writer. En lugar de pasar el nombre del archivo, simplemente podemos pasar io.Reader, que abstraerá la fuente de datos.
¿Es esto un archivo? Cuerpo HTTP? Byte buffer? No importa, ya que seguiremos utilizando el mismo método de lectura.

En nuestro caso, incluso podemos almacenar la entrada para leerla línea por línea. Para hacer esto, puede usar bufio.Reader y su método ReadLine:

 func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } } 

Ahora la responsabilidad de abrir el archivo ha sido delegada al cliente de conteo:

 file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file)) 

En una segunda implementación, se puede llamar a una función independientemente de la fuente de datos real. Mientras tanto, esto facilitará nuestras pruebas unitarias, ya que simplemente podemos crear bufio.Reader desde la línea:

 count, err := count(bufio.NewReader(strings.NewReader("input"))) 

Goroutinas y variables del ciclo


El último error común que encontré fue al usar goroutines con variables de bucle.

¿Cuál será la conclusión del siguiente ejemplo?

 ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() } 

1 2 3 al azar? No

En este ejemplo, cada gorutina usa la misma instancia de una variable, por lo que generará 3 3 3 (muy probablemente).

Hay dos soluciones a este problema. El primero es pasar el valor de la variable i al cierre (función interna):

 ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) } 

El segundo es crear otra variable dentro del ciclo for:

 ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() } 

Asignar i: = puede parecer un poco extraño, pero este diseño es perfectamente válido. Estar en un bucle significa estar en un alcance diferente. Por lo tanto, i: = i crea otra instancia de la variable i. Por supuesto, podemos llamarlo con un nombre diferente para facilitar la lectura.

Si conoce otros errores comunes, no dude en escribir sobre ellos en los comentarios.

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


All Articles