Práctica: consejos para escribir programas compatibles en el mundo real

Este artículo se centra en las mejores prácticas para escribir el código Go. Está compuesto en el estilo de presentación, pero sin las diapositivas habituales. Intentaremos revisar breve y claramente cada elemento.

Primero debe acordar qué significan las mejores prácticas para un lenguaje de programación. Aquí puede recordar las palabras de Russ Cox, director técnico de Go:

La ingeniería de software es lo que sucede con la programación, si agrega el factor tiempo y otros programadores.

Por lo tanto, Russ distingue entre los conceptos de programación e ingeniería de software . En el primer caso, usted escribe un programa para usted, en el segundo crea un producto en el que otros programadores trabajarán con el tiempo. Los ingenieros van y vienen. Los equipos crecen o se reducen. Se agregan nuevas funciones y se corrigen errores. Esta es la naturaleza del desarrollo de software.

Contenido



1. Principios fundamentales


Puedo ser uno de los primeros usuarios de Go entre ustedes, pero esta no es mi opinión personal. Estos principios básicos son la base del propio Go:

  1. Simplicidad
  2. Legibilidad
  3. Productividad

Nota Tenga en cuenta que no mencioné "rendimiento" o "concurrencia". Hay idiomas más rápidos que Go, pero ciertamente no se pueden comparar en simplicidad. Hay lenguajes que priorizan el paralelismo, pero no se pueden comparar en términos de legibilidad o productividad de programación.

El rendimiento y la concurrencia son atributos importantes, pero no tan importantes como la simplicidad, la legibilidad y la productividad.

Simplicidad


"La simplicidad es un requisito previo para la fiabilidad" - Edsger Dijkstra

¿Por qué luchar por la simplicidad? ¿Por qué es importante que los programas Go sean simples?

Cada uno de nosotros se encontró con un código incomprensible, ¿verdad? Cuando tiene miedo de hacer un cambio porque romperá otra parte del programa que no comprende y no sabe cómo solucionar. Esta es la dificultad.

“Hay dos formas de diseñar software: la primera es hacerla tan simple que no haya fallas obvias, y la segunda es hacerla tan compleja que no haya fallas obvias. El primero es mucho más difícil ”. - C.E. R. Hoar

La complejidad convierte el software confiable en poco confiable. La complejidad es lo que mata los proyectos de software. Por lo tanto, la simplicidad es el objetivo final de Go. Cualesquiera que sean los programas que escribamos, deberían ser simples.

1.2. Legibilidad


"La legibilidad es una parte integral de la mantenibilidad" - Mark Reinhold, Conferencia JVM, 2018

¿Por qué es importante que el código sea legible? ¿Por qué debemos luchar por la legibilidad?

"Los programas deben estar escritos para personas, y las máquinas simplemente los ejecutan" - Hal Abelson y Gerald Sassman, "Estructura e interpretación de programas de computadora"

No solo los programas Go, sino que generalmente todo el software está escrito por personas para personas. El hecho de que las máquinas también procesen código es secundario.

Una vez que el código escrito sea leído repetidamente por la gente: cientos, si no miles de veces.

"La habilidad más importante para un programador es la capacidad de comunicar ideas de manera efectiva". - Gaston Horker

La legibilidad es la clave para comprender lo que hace un programa. Si no puede entender el código, ¿cómo mantenerlo? Si el software no puede ser soportado, será reescrito; y esta puede ser la última vez que su empresa usa Go.

Si está escribiendo un programa para usted, haga lo que funcione para usted. Pero si esto es parte de un proyecto conjunto o si el programa se utilizará el tiempo suficiente para cambiar los requisitos, las funciones o el entorno en el que funciona, su objetivo es hacer que el programa sea sostenible.

El primer paso para escribir software compatible es asegurarse de que el código sea claro.

1.3. Productividad


"El diseño es el arte de organizar el código para que funcione hoy, pero siempre apoya el cambio". - Sandy Mets

Como último principio básico, quiero nombrar la productividad del desarrollador. Este es un gran tema, pero se reduce a la proporción: cuánto tiempo pasas en un trabajo útil y cuánto, esperando una respuesta de las herramientas o deambulaciones desesperadas en una base de código incomprensible. Los programadores de Go deberían sentir que pueden manejar mucho trabajo.

Es una broma que el lenguaje Go se desarrolló mientras se compilaba el programa C ++. La compilación rápida es una característica clave de Go y un factor clave para atraer nuevos desarrolladores. Aunque se están mejorando los compiladores, en general, la compilación de minutos en otros idiomas lleva unos segundos en Go. Entonces, los desarrolladores de Go se sienten tan productivos como los programadores en lenguajes dinámicos, pero sin ningún problema con la confiabilidad de esos lenguajes.

Si hablamos fundamentalmente sobre la productividad de los desarrolladores, entonces los programadores de Go comprenden que leer el código es esencialmente más importante que escribirlo. En esta lógica, Go incluso llega a usar las herramientas para formatear todo el código en un estilo determinado. Esto elimina la más mínima dificultad para aprender el dialecto específico de un proyecto en particular y ayuda a identificar errores porque simplemente se ven mal en comparación con el código normal.

Los programadores de Go no pasan días depurando errores de compilación extraños, scripts de compilación complejos o implementando código en un entorno de producción. Y lo más importante, no pierden el tiempo tratando de entender lo que escribió un colega.

Cuando los desarrolladores de Go hablan de escalabilidad , se refieren a la productividad.

2. Identificadores


El primer tema que discutiremos: identificadores , es sinónimo de nombres : nombres de variables, funciones, métodos, tipos, paquetes, etc.

"El mal nombre es un síntoma de mal diseño" - Dave Cheney

Dada la sintaxis limitada de Go, los nombres de los objetos tienen un gran impacto en la legibilidad del programa. La legibilidad es un factor clave en un buen código, por lo que elegir buenos nombres es crucial.

2.1. Identificadores de nombre basados ​​en la claridad en lugar de la brevedad


“Es importante que el código sea obvio. Lo que puedes hacer en una línea, debes hacerlo en tres. ” - Ukia Smith

Go no está optimizado para frases complicadas o el número mínimo de líneas en un programa. No optimizamos el tamaño del código fuente en el disco, ni el tiempo requerido para escribir el programa en el editor.

“Un buen nombre es como un buen chiste. Si necesita explicarlo, ya no es divertido ". - Dave Cheney

La clave para la máxima claridad son los nombres que elegimos para identificar los programas. ¿Qué cualidades son inherentes a un buen nombre?

  • Un buen nombre es conciso . No tiene que ser el más corto, pero no contiene exceso. Tiene una alta relación señal / ruido.
  • Un buen nombre es descriptivo . Describe el uso de una variable o constante, no los contenidos. Un buen nombre describe el resultado de una función o el comportamiento de un método, no una implementación. El propósito del paquete, no su contenido. Cuanto más exactamente el nombre describa lo que identifica, mejor.
  • Un buen nombre es predecible . Por un nombre debes entender cómo se usará el objeto. Los nombres deben ser descriptivos, pero también es importante seguir la tradición. A eso se refieren los programadores de Go cuando dicen "idiomático" .

Consideremos con más detalle cada una de estas propiedades.

2.2. ID longitud


A veces, el estilo de Go es criticado por nombres cortos de variables. Como dijo Rob Pike, "los programadores de Go quieren identificadores de la longitud correcta ".

Andrew Gerrand ofrece identificadores más largos para indicar importancia.

"Cuanto mayor sea la distancia entre la declaración de un nombre y el uso de un objeto, más largo debe ser el nombre" - Andrew Gerrand

Por lo tanto, se pueden hacer algunas recomendaciones:

  • Los nombres cortos de variables son buenos si la distancia entre la declaración y el último uso es pequeña.
  • Los nombres largos de variables deben justificarse a sí mismos; cuanto más largos sean, más importantes deberían ser. Los títulos detallados contienen poca señal en relación con su peso en la página.
  • No incluya el nombre del tipo en el nombre de la variable.
  • Los nombres constantes deben describir el valor interno, no cómo se usa el valor.
  • Prefiera variables de una sola letra para bucles y ramas, palabras separadas para parámetros y valores de retorno, palabras múltiples para funciones y declaraciones a nivel de paquete.
  • Prefiere palabras simples para métodos, interfaces y paquetes.
  • Recuerde que el nombre del paquete es parte del nombre que usa la persona que llama como referencia.

Considera un ejemplo.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

En la décima línea, p declara una variable de rango p , y se llama solo una vez desde la siguiente línea. Es decir, la variable vive en la página durante muy poco tiempo. Si el lector está interesado en el papel de p en el programa, solo necesita leer dos líneas.

A modo de comparación, las people declaran en los parámetros de la función y viven siete líneas. Lo mismo ocurre con la sum y el count , por lo que justifican sus nombres más largos. El lector necesita escanear más código para encontrarlos: esto justifica los nombres más distinguidos.

Puede elegir s para sum c (o n ) para count , pero esto reduce la importancia de todas las variables en el programa al mismo nivel. Puede reemplazar people con p , pero habrá un problema, cómo llamar a la variable de iteración for ... range . Una sola person se verá extraña, porque una variable de iteración de corta duración obtiene un nombre más largo que varios valores de los que se deriva.

Consejo Separe la secuencia de funciones con líneas vacías, ya que las líneas vacías entre párrafos interrumpen el flujo de texto. En AverageAge , tenemos tres operaciones consecutivas. Primero, verificando la división por cero, luego la conclusión de la edad total y el número de personas, y el último: el cálculo de la edad promedio.

2.2.1. Lo principal es el contexto.


Es importante comprender que la mayoría de los consejos de nombres son específicos del contexto. Me gusta decir que este es un principio, no una regla.

¿Cuál es la diferencia entre i e index ? Por ejemplo, no puede decir con certeza que dicho código

 for index := 0; index < len(s); index++ { // } 

fundamentalmente más legible que

 for i := 0; i < len(s); i++ { // } 

Creo que la segunda opción no es peor, porque en este caso la región i o index limitada por el cuerpo del ciclo for , y la verbosidad adicional agrega poco a la comprensión del programa.

Pero, ¿cuál de estas funciones es más legible?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

o

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

En este ejemplo, oid es una abreviatura de ID de objeto SNMP, y la abreviatura adicional o obliga a cambiar de una notación documentada a una más corta en el código al leer el código. Del mismo modo, reducir el index a i hace que sea más difícil de entender, porque en los mensajes SNMP, el subvalor de cada OID se denomina índice.

Consejo No combine parámetros formales largos y cortos en un anuncio.

2.3. No nombrar variables por tipo


No llamas a tus mascotas "perro" y "gato", ¿verdad? Por la misma razón, no debe incluir el nombre del tipo en el nombre de la variable. Debe describir el contenido, no su tipo. Considere un ejemplo:

 var usersMap map[string]*User 

¿De qué sirve este anuncio? Vemos que este es un mapa, y tiene algo que ver con el *User Tipo de *User : probablemente sea bueno. Pero usersMap es realmente un mapa, y Go, como lenguaje estáticamente tipado, no permitirá usar accidentalmente dicho nombre cuando se requiera una variable escalar, por lo que el sufijo Map es redundante.

Considere una situación en la que se agregan otras variables:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

Ahora tenemos tres variables de tipo de mapa: usersMap , companiesMap y productsMap , y todas las líneas se asignan a diferentes tipos. Sabemos que estos son mapas, y también sabemos que el compilador arrojará un error si tratamos de usar companiesMap donde el código espera map[string]*User . En esta situación, está claro que el sufijo Map no mejora la claridad del código, estos son solo caracteres adicionales.

Sugiero evitar cualquier sufijo que se parezca al tipo de una variable.

Consejo Si el nombre users no describe la esencia con suficiente claridad, entonces usersMap también.

Este consejo también se aplica a los parámetros de la función. Por ejemplo:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

El nombre de config para el parámetro *Config es redundante. Ya sabemos que esto es *Config , se escribe inmediatamente al lado.

En este caso, considere conf o c si la vida útil de la variable es lo suficientemente corta.

Si en algún punto de nuestra área hay más de una *Config , los nombres conf1 y conf2 menos significativos que los original y updated , ya que estos últimos son más difíciles de mezclar.

Nota No permita que los nombres de paquetes roben buenos nombres de variables.

El nombre del identificador importado contiene el nombre del paquete. Por ejemplo, el tipo de Context en el paquete de context se llamará context.Context . Esto hace que sea imposible usar una variable o tipo de context en su paquete.

 func WriteLog(context context.Context, message string) 

Esto no se compilará. Es por eso que al declarar el context.Context tipos de context.Context localmente, por ejemplo, nombres como ctx se usan tradicionalmente.

 func WriteLog(ctx context.Context, message string) 

2.4. Use un solo estilo de nomenclatura


Otra propiedad de un buen nombre es que debe ser predecible. El lector debe entenderlo de inmediato. Si este es un nombre común , entonces el lector tiene el derecho de asumir que no ha cambiado el significado del tiempo anterior.

Por ejemplo, si el código va alrededor del descriptor de la base de datos, cada vez que se muestra el parámetro, debe tener el mismo nombre. En lugar de todo tipo de combinaciones como d *sql.DB , d *sql.DB dbase *sql.DB , DB *sql.DB y la database *sql.DB , es mejor usar una cosa:

 db *sql.DB 

Es más fácil entender el código. Si ve db , entonces sabe que es *sql.DB y que la persona que llama la declara localmente.

Consejos similares con respecto a los destinatarios de un método; use el mismo nombre de destinatario para cada método de este tipo. Por lo tanto, será más fácil para el lector aprender el uso del destinatario entre los diversos métodos de este tipo.

Nota El acuerdo de nombre corto de destinatario Go contradice las recomendaciones expresadas anteriormente. Este es uno de esos casos donde la elección realizada en una etapa temprana se convierte en el estilo estándar, como usar CamelCase lugar de snake_case .

Consejo El estilo Ir apunta a nombres de una sola letra o abreviaturas para destinatarios derivados de su tipo. Puede resultar que el nombre del destinatario a veces entra en conflicto con el nombre del parámetro en el método. En este caso, se recomienda hacer que el nombre del parámetro sea un poco más largo y no olvide usarlo secuencialmente.

Finalmente, algunas variables de una letra se asocian tradicionalmente con bucles y recuento. Por ejemplo, i , j y k suelen ser variables inductivas en bucles for , n suele asociarse con un contador o sumador acumulativo, v es una abreviatura típica de valor en una función de codificación, k suele utilizarse para una clave de mapa y s menudo se utiliza como abreviatura para parámetros de tipo string .

Al igual que con el ejemplo db anterior, los programadores esperan que i sea ​​una variable inductiva. Si lo ven en código, esperan ver un bucle pronto.

Consejo Si tiene tantos bucles anidados que se ha quedado sin variables i , j y k , es posible que desee dividir la función en unidades más pequeñas.

2.5. Use un estilo de declaración único


Go tiene al menos seis formas diferentes de declarar una variable.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

Estoy seguro de que aún no lo recuerdo todo. Los desarrolladores de Go probablemente consideran esto un error, pero es demasiado tarde para cambiar algo. Con esta elección, ¿cómo garantizar un estilo uniforme?

Quiero proponer un estilo de declaración de variables que yo mismo trato de usar siempre que sea posible.

  • Al declarar una variable sin inicialización, use var .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

    var actúa como una pista de que esta variable se declara intencionalmente como un valor nulo del tipo especificado. Esto es coherente con el requisito de declarar variables a nivel de paquete con var en oposición a la sintaxis de declaración corta, aunque argumentaré más adelante que las variables de nivel de paquete no deberían usarse en absoluto.
  • Al declarar con inicialización, use := . Esto deja en claro al lector que la variable a la izquierda de := inicializa intencionalmente.

    Para explicar por qué, veamos el ejemplo anterior, pero esta vez inicializamos especialmente cada variable:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

Como Go no tiene conversiones automáticas de un tipo a otro, en el primer y tercer ejemplo, el tipo en el lado izquierdo del operador de asignación debe ser idéntico al tipo en el lado derecho. El compilador puede inferir el tipo de la variable declarada del tipo de la derecha, por lo que el ejemplo se puede escribir de manera más concisa:

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

Aquí, los players inicializan explícitamente a 0 , lo cual es redundante, porque el valor inicial de los players es cero en cualquier caso. Por lo tanto, es mejor dejar en claro que queremos usar un valor nulo:

 var players int 

¿Qué pasa con el segundo operador? No podemos determinar el tipo y escribir

 var things = nil 

Porque nil no nil tipo . En cambio, tenemos una opción: o usamos un valor cero para cortar ...

 var things []Thing 

... o crear un segmento con cero elementos?

 var things = make([]Thing, 0) 

En el segundo caso, el valor para el segmento no es cero, y lo dejamos claro para el lector usando una forma corta de declaración:

 things := make([]Thing, 0) 

Esto le dice al lector que decidimos inicializar things explícitamente.

Entonces llegamos a la tercera declaración:

 var thing = new(Thing) 

Aquí, tanto la inicialización explícita de la variable como la introducción de la palabra clave "única" new , que a algunos programadores de Go no les gusta. Usando los rendimientos recomendados de sintaxis corta

 thing := new(Thing) 

Esto deja en claro que la thing inicializa explícitamente al resultado de new(Thing) , pero aún deja una new atípica. El problema podría resolverse usando un literal:

 thing := &Thing{} 

Lo cual es similar a new(Thing) , y esa duplicación molesta a algunos programadores de Go. Sin embargo, esto significa que inicializamos explícitamente la thing con un puntero a Thing{} y un valor de Thing de cero.

Pero es mejor tener en cuenta el hecho de que la thing declara con un valor cero, y usar la dirección del operador para pasar la dirección de la thing en json.Unmarshall .

 var thing Thing json.Unmarshall(reader, &thing) 

Nota Por supuesto, hay excepciones a cualquier regla. Por ejemplo, a veces dos variables están estrechamente relacionadas, por lo que será extraño escribir

 var min int max := 1000 

Declaración más legible:

 min, max := 0, 1000 

Para resumir:

  • Al declarar una variable sin inicialización, use la sintaxis var .
  • Al declarar e inicializar explícitamente una variable, use := .

Consejo Señale explícitamente cosas complejas.

 var length uint32 = 0x80 

Aquí, la length se puede usar con la biblioteca, que requiere un tipo numérico específico, y esta opción indica más claramente que la longitud del tipo se elige específicamente como uint32 que en la declaración breve:

 length := uint32(0x80) 

En el primer ejemplo, rompo intencionalmente mi regla al usar la declaración var con inicialización explícita. Una desviación del estándar hace que el lector entienda que algo inusual está sucediendo.

2.6. Trabajar para el equipo


Ya he dicho que la esencia del desarrollo de software es la creación de código legible y compatible. La mayor parte de su carrera probablemente trabajará en proyectos conjuntos. Mi consejo en esta situación: sigue el estilo adoptado en el equipo.

Cambiar estilos en el medio del archivo es molesto. La consistencia es importante, aunque en detrimento de la preferencia personal. Mi regla de oro es: si el código se ajusta a través de gofmt , entonces el problema generalmente no merece la discusión.

Consejo Si desea cambiar el nombre en toda la base de código, no mezcle esto con otros cambios. Si alguien usa git bisect, no le gustará buscar miles de cambios de nombre para encontrar otro código modificado.

3. Comentarios


Antes de pasar a puntos más importantes, quiero tomarme un par de minutos para comentar.

"Un buen código tiene muchos comentarios, y un código malo necesita muchos comentarios". - Dave Thomas y Andrew Hunt, Programador pragmático

Los comentarios son muy importantes para la legibilidad del programa. Cada comentario debe hacer una, y solo una, de tres cosas:

  1. Explica qué hace el código.
  2. Explica cómo lo hace.
  3. Explica por qué .

La primera forma es ideal para comentar sobre personajes públicos:

 // Open     . //           . 

El segundo es ideal para comentarios dentro de un método:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

La tercera forma ("por qué") es única en el sentido de que no reemplaza ni reemplaza las dos primeras. Dichos comentarios explican los factores externos que llevaron a la escritura del código en su forma actual. A menudo, sin este contexto, es difícil entender por qué el código está escrito de esta manera.

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

En este ejemplo, puede que no esté claro de inmediato qué sucede cuando HealthyPanicThreshold se establece en cero por ciento. El comentario pretende aclarar que un valor de 0 deshabilita el umbral de pánico.

3.1. Los comentarios en variables y constantes deben describir su contenido, no su propósito


Anteriormente dije que el nombre de una variable o constante debería describir su propósito. Pero un comentario sobre una variable o constante debe describir exactamente el contenido , no el propósito .

 const randomNumber = 6 //     

En este ejemplo, un comentario describe por qué randomNumber en 6 y de dónde proviene. El comentario no describe dónde se randomNumber . Aquí hay algunos ejemplos más:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

En el contexto de HTTP, el número 100 conoce como StatusContinue , como se define en RFC 7231, sección 6.2.1.

Consejo Para las variables sin un valor inicial, el comentario debe describir quién es responsable de inicializar esta variable.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

Aquí un comentario le dice al lector que la función dowidth responsable de mantener el estado de sizeCalculationDisabled .

Consejo Esconderse a la vista. Este es un consejo de Kate Gregory . A veces, el mejor nombre para una variable está oculto en los comentarios.

 //   SQL var registry = make(map[string]*sql.Driver) 

El autor agregó un comentario porque el registry nombres no explica suficientemente su propósito: este es un registro, pero ¿cuál es el registro?

Si cambia el nombre de una variable a sqlDrivers, queda claro que contiene controladores SQL.

 var sqlDrivers = make(map[string]*sql.Driver) 

Ahora el comentario se ha vuelto redundante y se puede eliminar.

3.2. Siempre documente personajes disponibles públicamente


Godoc genera la documentación de su paquete, por lo que debe agregar un comentario a cada carácter público declarado en el paquete: una variable, constante, función y método.

Aquí hay dos pautas de la Guía de estilo de Google:

  • Cualquier función pública que no sea obvia y concisa debe ser comentada.
  • Cualquier función en la biblioteca debe comentarse, independientemente de su longitud o complejidad.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

Hay una excepción a esta regla: no necesita documentar los métodos que implementan la interfaz. Específicamente, no hagas esto:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

Este comentario no significa nada. No dice qué hace el método: peor, envía a algún lado a buscar documentación. En esta situación, propongo eliminar completamente el comentario.

Aquí hay un ejemplo del paquete io .

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

Tenga en cuenta que la declaración de LimitedReader está precedida inmediatamente por la función que la utiliza, y la declaración de LimitedReader.Read sigue a la declaración de LimitedReader . Aunque LimitedReader.Read sí no está documentado, se puede entender que se trata de una implementación de io.Reader .

Consejo Antes de escribir una función, escriba un comentario describiéndola. Si le resulta difícil escribir un comentario, entonces esta es una señal de que el código que está a punto de escribir será difícil de entender.

3.2.1. No comente sobre el código incorrecto, vuelva a escribirlo


"No comente el código incorrecto - Reescríbalo" - Brian Kernighan

No es suficiente indicar en los comentarios la dificultad del fragmento de código. Si encuentra alguno de estos comentarios, debe comenzar un ticket con un recordatorio de refactorización. Puede vivir con deudas técnicas siempre que se conozca su monto.

En la biblioteca estándar, se acostumbra dejar comentarios en el estilo TODO con el nombre del usuario que notó el problema.

 // TODO(dfc)  O(N^2),     . 

Esto no es una obligación para solucionar el problema, pero el usuario indicado puede ser la mejor persona para hacer una pregunta. Otros proyectos acompañan a TODO con una fecha o número de boleto.

3.2.2. En lugar de comentar el código, refactorizarlo


“Un buen código es la mejor documentación. Cuando esté a punto de agregar un comentario, hágase la pregunta: "¿Cómo mejorar el código para que este comentario no sea necesario?" Refactorice y deje un comentario para hacerlo aún más claro. ” - Steve McConnell

Las funciones deben realizar solo una tarea. Si desea escribir un comentario porque algún fragmento no está relacionado con el resto de la función, considere extraerlo en una función separada.

Las funciones más pequeñas no solo son más claras, sino también más fáciles de probar por separado. Cuando aisló el código en una función separada, su nombre puede reemplazar un comentario.

4. estructura del paquete


"Escriba un código modesto: módulos que no muestran nada superfluo a otros módulos y que no dependen de la implementación de otros módulos" - Dave Thomas

Cada paquete es esencialmente un pequeño programa Go separado. Así como la implementación de una función o método no es importante para la persona que llama, la implementación de las funciones, métodos y tipos que conforman la API pública de su paquete tampoco es importante.

Un buen paquete Go se esfuerza por una conectividad mínima con otros paquetes en el nivel del código fuente, de modo que a medida que el proyecto crece, los cambios en un paquete no se conectan en cascada en toda la base del código. Tales situaciones inhiben en gran medida a los programadores que trabajan en esta base de código.

En esta sección, hablaremos sobre el diseño del paquete, incluido su nombre y consejos para escribir métodos y funciones.

4.1. Un buen paquete comienza con un buen nombre


Un buen paquete Go comienza con un nombre de calidad. Piense en ello como una breve presentación limitada a una sola palabra.

Al igual que los nombres de variables en la sección anterior, el nombre del paquete es muy importante. No es necesario pensar en los tipos de datos en este paquete, es mejor hacer la pregunta: "¿Qué servicio proporciona este paquete?" Por lo general, la respuesta no es "Este paquete proporciona el tipo X", sino "Este paquete le permite conectarse a través de HTTP".

Consejo . Elija un nombre de paquete por su funcionalidad, no por su contenido.

4.1.1 Los buenos nombres de paquetes deben ser únicos


Cada paquete tiene un nombre único en el proyecto. No hay dificultad si seguiste el consejo de dar nombres para el propósito de los paquetes. Si resulta que los dos paquetes tienen el mismo nombre, lo más probable:

  1. .
  2. . , .

4.2. base , common util


Una razón común para los malos nombres son los llamados paquetes de servicio , donde con el tiempo se acumulan varios ayudantes y códigos de servicio. Dado que es difícil encontrar un nombre único allí. Esto a menudo lleva al hecho de que el nombre del paquete se deriva de lo que contiene : utilidades.

Los nombres como utilso helpersgeneralmente se encuentran en proyectos grandes, en los que se arraiga una jerarquía profunda de paquetes, y se comparten funciones auxiliares. Si extrae alguna función en un nuevo paquete, la importación se descompone. En este caso, el nombre del paquete no refleja el propósito del paquete, sino solo el hecho de que la función de importación falló debido a una organización incorrecta del proyecto.

En tales situaciones, recomiendo analizar desde dónde se llaman los paquetes.utils helpersy, si es posible, mueva las funciones correspondientes al paquete de llamada. Incluso si esto implica la duplicación de algún código auxiliar, es mejor que introducir una dependencia de importación entre dos paquetes.

"[Una pequeña] duplicación es mucho más barata que una abstracción incorrecta" - Sandy Mets

Si las funciones de utilidad se utilizan en muchos lugares, en lugar de un paquete monolítico con funciones de utilidad, es mejor hacer varios paquetes, cada uno de los cuales se centra en un aspecto.

Consejo . Use el plural para paquetes de servicios. Por ejemplo, stringspara las utilidades de procesamiento de cadenas.

Los paquetes con nombres como baseo a commonmenudo se encuentran cuando una cierta funcionalidad común de dos o más implementaciones o tipos comunes para un cliente y un servidor se fusiona en un paquete separado. Creo que en tales casos es necesario reducir la cantidad de paquetes combinando el cliente, el servidor y el código común en un paquete con un nombre que corresponda a su función.

Por ejemplo, para net/httpno hacer los paquetes individuales clienty server, en cambio, hay archivos client.goy server.gocon los tipos de datos correspondientes, así como transport.gopara el transporte global.

Consejo . Es importante recordar que el nombre del identificador incluye el nombre del paquete.

  • Una función Getde un paquete se net/httpconvierte en un http.Getenlace desde otro paquete.
  • Un tipo Readerde un paquete se stringstransforma cuando se importa a otros paquetes strings.Reader.
  • La interfaz Errordel paquete está netclaramente asociada con errores de red.

4.3. Regresa rápidamente sin zambullirte profundamente


Desde Go utiliza excepciones en el flujo de control no es necesario cortar profundamente en el código para proporcionar una estructura de nivel superior para las unidades tryy catch. En lugar de una jerarquía de niveles múltiples, el código Go baja la pantalla a medida que avanza la función. Mi amigo Matt Ryer llama a esta práctica una "línea de visión" .

Esto se logra utilizando operadores de límite : bloques condicionales con una condición previa en la entrada a la función. Aquí hay un ejemplo del paquete bytes:

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

Al ingresar a la función UnreadRune, se verifica el estado b.lastReady si la operación anterior no fue así ReadRune, entonces se devuelve un error inmediatamente. El resto de la función funciona en función de lo que es b.lastReadmayor que opInvalid.

Compare con la misma función, pero sin el operador de límite:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

El cuerpo de una rama exitosa más probable está incrustado en la primera condición if, y la condición para una salida exitosa return nildebe descubrirse haciendo coincidir cuidadosamente los corchetes de cierre . La última línea de la función ahora devuelve un error, y debe rastrear la ejecución de la función hasta el corchete de apertura correspondiente para averiguar cómo llegar a este punto.

Esta opción es más difícil de leer, lo que degrada la calidad de la programación y el soporte de código, por lo que Go prefiere usar operadores de límite y devolver errores en una etapa temprana.

4.4. Hacer que el valor nulo sea útil


Cada declaración de variable, suponiendo la ausencia de un inicializador explícito, se inicializará automáticamente con un valor correspondiente al contenido de la memoria puesta a cero, es decir, cero . El tipo de valor está determinado por una de las opciones: para tipos numéricos: cero, para tipos de puntero: nulo, lo mismo para sectores, mapas y canales.

La capacidad de establecer siempre un valor predeterminado conocido es importante para la seguridad y corrección de su programa y puede hacer que sus programas Go sean más fáciles y compactos. Esto es lo que los programadores de Go tienen en mente cuando dicen: "Dale a las estructuras un valor cero útil".

Considere un tipo sync.Mutexque contiene dos campos enteros que representan el estado interno del mutex. Estos campos son automáticamente nulos en cualquier declaración.sync.Mutex. Este hecho se tiene en cuenta en el código, por lo que el tipo es adecuado para su uso sin una inicialización explícita.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

Otro ejemplo de un tipo con un valor nulo útil es bytes.Buffer. Puede declarar y comenzar a escribir en él sin una inicialización explícita.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

El valor cero de esta estructura significa que lenambos capson iguales 0, e y array, el puntero a la memoria con el contenido de la matriz de segmentos de respaldo, valor nil. Esto significa que no necesita cortar explícitamente, simplemente puede declararlo.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

Nota . var s []stringsimilar a las dos líneas comentadas en la parte superior, pero no idénticas a ellas. Hay una diferencia entre un valor de corte de cero y un valor de corte de longitud cero. El siguiente código se imprimirá falso.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

Una propiedad útil, aunque inesperada, de las variables de puntero no inicializadas (punteros nulos) es la capacidad de invocar métodos en tipos que son nulos. Esto se puede usar para proporcionar fácilmente valores predeterminados.

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5. Evitar estado de nivel de paquete


La clave para escribir programas fáciles de soportar que están débilmente conectados es que cambiar un paquete debería tener una baja probabilidad de afectar a otro paquete que no depende directamente del primero.

Hay dos excelentes maneras de lograr una conectividad débil en Go:

  1. Utilice interfaces para describir el comportamiento requerido por funciones o métodos.
  2. Evitar el estado global.

En Go, podemos declarar variables en el alcance de una función o método, así como en el alcance de un paquete. Cuando una variable está disponible públicamente, con un identificador con una letra mayúscula, su alcance es realmente global para todo el programa: cualquier paquete en cualquier momento ve el tipo y el contenido de esta variable.

El estado global mutable proporciona una estrecha relación entre las partes independientes del programa, ya que las variables globales se convierten en un parámetro invisible para cada función en el programa. Cualquier función que se base en una variable global se puede violar cuando cambia el tipo de esta variable. Cualquier función que dependa del estado de una variable global puede ser violada si otra parte del programa cambia esta variable.

Cómo reducir la conectividad que crea una variable global:

  1. Mueva las variables correspondientes como campos a las estructuras que las necesitan.
  2. Utilice interfaces para reducir la conexión entre el comportamiento y la implementación de este comportamiento.

5. Estructura del proyecto


Hablemos de cómo se combinan los paquetes en un proyecto. Este suele ser un único repositorio de Git.

Al igual que el paquete, cada proyecto debe tener un objetivo claro. Si es una biblioteca, debe hacer una cosa, por ejemplo, análisis XML o registro en diario. No debe combinar varios objetivos en un proyecto, esto ayudará a evitar una biblioteca aterradora common.

Consejo . En mi experiencia, el repositorio en commonúltima instancia está estrechamente relacionado con el consumidor más grande, y esto hace que sea difícil hacer correcciones a versiones anteriores (correcciones de puerto posterior) sin actualizar tanto commonel consumidor como el consumidor en la etapa de bloqueo, lo que conduce a muchos cambios no relacionados, además de que se rompen en el camino API

Si tiene una aplicación (aplicación web, controlador Kubernetes, etc.), el proyecto puede tener uno o más paquetes principales. Por ejemplo, en mi controlador Kubernetes hay un paquete cmd/contourque sirve como servidor implementado en un clúster de Kubernetes y como cliente de depuración.

5.1. Menos paquetes pero más grandes


En la revisión del código, noté uno de los errores típicos de los programadores que cambiaron a Go desde otros idiomas: tienden a abusar de los paquetes.

Go no proporciona el elaborado sistema de visibilidad: el idioma no es suficiente modificadores de acceso como en el de Java ( public, protected, privatee implícito default). No existe un análogo de clases amigables de C ++.

En Go, solo tenemos dos modificadores de acceso: estos son identificadores públicos y privados, que se indican mediante la primera letra del identificador (mayúsculas / minúsculas). Si el identificador es público, su nombre comienza con una letra mayúscula, puede ser referenciado por cualquier otro paquete Go.

Nota . Podía escuchar las palabras "exportado" o "no exportado" como sinónimos de público y privado.

Dadas las funciones de control de acceso limitado, ¿qué métodos se pueden usar para evitar jerarquías de paquetes demasiado complejas?

Consejo . En cada paquete, además de cmd/y internal/debe estar presente el código fuente.

He dicho repetidamente que es mejor preferir menos paquetes más grandes. Su posición predeterminada debe ser no crear un nuevo paquete. Esto hace que demasiados tipos se hagan públicos, creando un amplio y pequeño alcance de API disponible. A continuación consideramos esta tesis con más detalle.

Consejo . Vino de Java?

Si vienes del mundo de Java o C #, recuerda la regla tácita: un paquete de Java es equivalente a un único archivo fuente .go. El paquete Go es equivalente a todo el módulo Maven o ensamblado .NET.

5.1.1 Ordenar código por archivo usando las instrucciones de importación


Si organiza paquetes por servicio, ¿debería hacer lo mismo con los archivos del paquete? ¿Cómo saber cuándo dividir un archivo .goen varios? ¿Cómo sabe si ha ido demasiado lejos y necesita pensar en fusionar archivos?

Aquí están las recomendaciones que uso:

  • Comience cada paquete con un archivo .go. Dé a este archivo el mismo nombre que el directorio. Por ejemplo, el paquete httpdebe estar en un archivo http.goen un directorio http.
  • A medida que crece el paquete, puede dividir las diversas funciones en varios archivos. Por ejemplo, el archivo messages.gocontendrá tipos Requesty Response, client.gotipo de Clientarchivo server.go, servidor de tipo de archivo .
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2. Prefiero pruebas internas a externas


La herramienta goadmite el paquete testingen dos lugares. Si tiene un paquete http2, puede escribir un archivo http2_test.goy usar la declaración del paquete http2. Se compila el código http2_test.go, que es parte del paquete http2. En el habla coloquial, tal prueba se llama interna.

La herramienta gotambién admite una declaración de paquete especial que termina con test , es decir http_test. Esto permite que los archivos de prueba vivan en el mismo paquete con el código, pero cuando se compilan tales pruebas, no forman parte del código de su paquete, sino que viven en su propio paquete. Esto le permite escribir pruebas como si otro paquete estuviera invocando su código. Tales pruebas se llaman externas.

Recomiendo el uso de pruebas internas para pruebas unitarias unitarias. Esto le permite probar cada función o método directamente, evitando la burocracia de las pruebas externas.

Pero es necesario colocar ejemplos de funciones de prueba ( Example) en un archivo de prueba externo . Esto asegura que cuando se ve en godoc, los ejemplos recibirán el prefijo de paquete apropiado y se pueden copiar fácilmente.

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


Si su proyecto tiene múltiples paquetes, puede encontrar funciones exportadas que están destinadas a ser utilizadas por otros paquetes, pero no para la API pública. En tal situación, la herramienta goreconoce un nombre de carpeta especial internal/que se puede usar para colocar el código que está abierto para su proyecto, pero cerrado para otros.

Para crear dicho paquete, colóquelo en un directorio con un nombre internal/o en su subdirectorio. Cuando el equipo gove la importación del paquete con la ruta internal, verifica la ubicación del paquete de llamada en un directorio o subdirectorio internal/.

Por ejemplo, un paquete .../a/b/c/internal/d/e/fpuede importar solo un paquete desde un árbol de directorios .../a/b/c, pero no puede hacerlo en absoluto .../a/b/go en cualquier otro repositorio (consultedocumentación ).

5.2. El paquete principal más pequeño


Una función mainy un paquete maindeben tener una funcionalidad mínima, porque main.mainactúa como un singleton: un programa solo puede tener una función main, incluidas las pruebas.

Como main.maines un singleton, existen muchas restricciones sobre los objetos llamados: se llaman solo durante main.maino main.init, y solo una vez . Esto dificulta la escritura de pruebas de código main.main. Por lo tanto, debe esforzarse por obtener la mayor lógica posible de la función principal e, idealmente, del paquete principal.

Consejo . func main()debe analizar indicadores, abrir conexiones a bases de datos, registradores, etc., y luego transferir la ejecución a un objeto de alto nivel.

6. estructura API


El último consejo de diseño para el proyecto lo considero el más importante.

Todas las oraciones anteriores son, en principio, no vinculantes. Estas son solo recomendaciones basadas en la experiencia personal. No inserto demasiado estas recomendaciones en una revisión de código.

La API es otro asunto, aquí tomamos los errores más en serio, porque todo lo demás se puede arreglar sin romper la compatibilidad con versiones anteriores: en su mayor parte, estos son solo detalles de implementación.

Cuando se trata de API públicas, vale la pena considerar seriamente la estructura desde el principio, porque los cambios posteriores serán destructivos para los usuarios.

6.1. API de diseño que son difíciles de abusar por diseño


"Las API deben ser simples para un uso adecuado y difíciles para incorrectas" - Josh Bloch

El consejo de Josh Bloch es quizás el más valioso en este artículo. Si la API es difícil de usar para cosas simples, entonces cada llamada a la API es más complicada de lo necesario. Cuando una llamada API es compleja y no obvia, es probable que se pase por alto.

6.1.1. Tenga cuidado con las funciones que aceptan múltiples parámetros del mismo tipo.


Un buen ejemplo de una API simple a primera vista, pero difícil de usar es cuando requiere dos o más parámetros del mismo tipo. Compare dos firmas de funciones:

 func Max(a, b int) int func CopyFile(to, from string) error 

¿Cuál es la diferencia entre estas dos funciones? Obviamente, uno devuelve un máximo de dos números y el otro copia el archivo. Pero este no es el punto.

 Max(8, 10) // 10 Max(10, 8) // 10 

Max es conmutativo : el orden de los parámetros no importa. Un máximo de ocho y diez es diez, independientemente de si se comparan ocho y diez o diez y ocho.

Pero en el caso de CopyFile, esto no es así.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

¿Cuál de estos operadores respaldará su presentación y cuál la sobrescribirá con la versión de la semana pasada? No puede saberlo hasta que verifique la documentación. En el curso de la revisión del código, no está claro si el orden de los argumentos es correcto o no. Nuevamente, mira la documentación.

Una posible solución es introducir un tipo auxiliar que sea responsable de la llamada correcta CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

No CopyFilesiempre se llama correctamente - se puede argumentar por una prueba de unidad - y se puede hacer en privado, lo que reduce aún más la probabilidad de mal uso.

Consejo . Una API con múltiples parámetros del mismo tipo es difícil de usar correctamente.

6.2. Diseñar una API para un caso de uso básico


Hace unos años, hice una presentación sobre el uso de opciones funcionales para facilitar la API por defecto.

La esencia de la presentación fue que debe desarrollar una API para el caso de uso principal. En otras palabras, la API no debería requerir que el usuario proporcione parámetros adicionales que no le interesen.

6.2.1. No se recomienda usar nil como parámetro


Comencé diciendo que no debe forzar al usuario a proporcionar parámetros de API que no le interesen. Esto significa diseñar las API para el caso de uso principal (opción predeterminada).

Aquí hay un ejemplo del paquete net / http.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServeacepta dos parámetros: una dirección TCP para escuchar las conexiones entrantes y http.Handlerpara procesar una solicitud HTTP entrante. Servepermite que el segundo parámetro sea nil. Las notas de comentario que normalmente la persona que llama realmente dan nilque indica un deseo de utilizar http.DefaultServeMuxcomo un parámetro implícito.

Ahora la persona que llama Servetiene dos formas de hacer lo mismo.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Ambas opciones hacen lo mismo.

Esta aplicación se nilpropaga como un virus. El paquete también httptiene un ayudante http.Serve, por lo que puede imaginar la estructura de la función ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

Como ListenAndServepermite que la persona que llama pase nilel segundo parámetro, http.Servetambién admite este comportamiento. De hecho, está en la http.Servelógica implementada "si el manejador es igual nil, use DefaultServeMux". La aceptación nilde un parámetro puede hacer que la persona que llama piense que se puede pasar nilpara ambos parámetros. Pero talServe

 http.Serve(nil, nil) 

conduce a un pánico terrible.

Consejo . No mezcle parámetros en la misma firma de función nily no nil.

El autor http.ListenAndServeintentó simplificar la vida de los usuarios de API para el caso predeterminado, pero la seguridad se vio afectada.

En presencia, nilno hay diferencia en el número de líneas entre el uso explícito e indirecto DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

comparado con

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

¿Valió la pena mantener una línea?

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

Consejo . Piense seriamente en cuánto tiempo las funciones de ayuda ahorrarán al programador. La claridad es mejor que la brevedad.

Consejo . Evite las API públicas con parámetros que solo las pruebas necesitan. Evite exportar API con parámetros cuyos valores difieran solo durante las pruebas. En cambio, exporte las funciones de contenedor que ocultan la transferencia de dichos parámetros, y en las pruebas utilice funciones auxiliares similares que pasen los valores necesarios para la prueba.

6.2.2. Utilice argumentos de longitud variable en lugar de [] T


Muy a menudo, una función o método toma una porción de valores.

 func ShutdownVMs(ids []string) error 

Este es solo un ejemplo inventado, pero es muy común. El problema es que estas firmas suponen que se llamarán con más de un registro. Como muestra la experiencia, a menudo se les llama con un solo argumento, que debe "empaquetarse" dentro del segmento para cumplir con los requisitos de la firma de la función.

Además, dado que el parámetro idses un segmento, puede pasar un segmento vacío o cero a la función, y el compilador estará contento. Esto agrega una carga de prueba adicional ya que las pruebas deberían cubrir tales casos.

Para dar un ejemplo de dicha clase de API, recientemente refactoré la lógica que requería la instalación de algunos campos adicionales si al menos uno de los parámetros no era cero. La lógica se parecía a esto:

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

Como el operador se estaba ifalargando mucho, quería incorporar la lógica de validación a una función separada. Esto es lo que se me ocurrió:

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

Esto permitió establecer claramente la condición bajo la cual se ejecutará la unidad interior:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

Sin embargo, hay un problema con anyPositivealguien que accidentalmente podría llamarlo así:

 if anyPositive() { ... } 

En ese caso, anyPositivevolveremos false. Esta no es la peor opción. Peor si se anyPositivedevuelve trueen ausencia de argumentos.

Sin embargo, sería mejor poder cambiar la firma de anyPositive para garantizar que se pase al menos un argumento a la persona que llama. Esto se puede hacer combinando parámetros para argumentos normales y argumentos de longitud variable (varargs):

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

Ahora anyPositiveno puede llamar con menos de un argumento.

6.3. Deje que las funciones determinen el comportamiento deseado.


Supongamos que se me encomendó la tarea de escribir una función que conserve la estructura Documenten el disco.

 // Save      f. func Save(f *os.File, doc *Document) error 

Podría escribir una función Saveque escriba Documenten un archivo *os.File. Pero hay algunos problemas.

La firma Saveelimina la posibilidad de registrar datos a través de la red. Si tal requisito aparece en el futuro, la firma de la función tendrá que cambiarse, lo que afectará a todos los objetos que llaman.

SaveTambién es desagradable probarlo, ya que funciona directamente con archivos en el disco. Por lo tanto, para verificar su funcionamiento, la prueba debe leer el contenido del archivo después de la escritura.

Y tengo que asegurarme de que esté fescrito en una carpeta temporal y posteriormente eliminado.

*os.Filetambién define muchos métodos que no están relacionados con Save, por ejemplo, leer directorios y verificar si una ruta es un enlace simbólico. Bueno, si la firmaSavedescribe solo las partes relevantes *os.File.

Que se puede hacer

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

Con la ayuda de io.ReadWriteClosereste, puede aplicar el principio de separación de interfaz y redefinirlo Saveen una interfaz que describa las propiedades más generales del archivo.

Después de tal cambio, cualquier tipo que implemente la interfaz io.ReadWriteCloserpuede ser reemplazado por el anterior *os.File.

Esto amplía simultáneamente el alcance Savey aclara al llamante qué métodos de tipo *os.Fileestán relacionados con su funcionamiento.

Y el autor Saveya no puede llamar a estos métodos no relacionados *os.File, porque está oculto detrás de la interfaz io.ReadWriteCloser.

Pero podemos extender el principio de separación de la interfaz aún más.

En primer lugar siSave sigue el principio de responsabilidad única, es poco probable que lea el archivo que acaba de escribir para verificar su contenido; otro código debería hacerlo.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

Por lo tanto, puede reducir las especificaciones de la interfaz para Saveescribir y cerrar.

En segundo lugar, el mecanismo de cierre de subprocesos y Savees un legado de la época en que funcionaba con el archivo. La pregunta es, ¿bajo qué circunstancias wcse cerrará?

Si la Savecausa Closesin condiciones, ya sea en el caso de éxito.

Esto presenta un problema para la persona que llama porque puede querer agregar datos a la secuencia después de escribir el documento.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

La mejor opción es redefinir Guardar para trabajar solo io.Writer, salvando al operador de todas las demás funciones, excepto para escribir datos en la transmisión.

Después de aplicar el principio de separación de interfaz, la función al mismo tiempo se volvió más específica en términos de requisitos (solo necesita un objeto donde se pueda escribir) y más general en términos de funcionalidad, ya que ahora podemos usarla Savepara guardar datos en cualquier lugar donde se implemente io.Writer.

7. Manejo de errores


Di varias presentaciones y escribí mucho sobre este tema en el blog, así que no lo repetiré. En cambio, quiero cubrir otras dos áreas relacionadas con el manejo de errores.



7.1. Elimine la necesidad de manejar errores eliminando los errores mismos


Hice muchas sugerencias para mejorar la sintaxis de manejo de errores, pero la mejor opción es no manejarlas en absoluto.

Nota . No digo "eliminar el manejo de errores". Sugiero cambiar el código para que no haya errores de procesamiento.

El reciente libro de filosofía de desarrollo de software de John Osterhout me inspiró a hacer esta sugerencia . Uno de los capítulos se titula "Eliminar errores de la realidad". Intentemos aplicar este consejo.

7.1.1 Recuento de filas


Escribiremos una función para contar el número de líneas en un archivo.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

A medida que seguimos los consejos de las secciones anteriores, CountLinesacepta io.Reader, no *os.File; ya es tarea de la persona que llama proporcionar el io.Readercontenido de quién queremos contar.

Creamos bufio.Reader, y luego llamamos al método en un bucle ReadString, aumentando el contador, hasta llegar al final del archivo, luego devolvemos el número de líneas leídas.

Al menos queremos escribir dicho código, pero la función está cargada de manejo de errores. Por ejemplo, hay una construcción tan extraña:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

Aumentamos el número de líneas antes de buscar errores, esto parece extraño.

La razón por la que deberíamos escribirlo de esta manera es porque ReadStringdevolverá un error si encuentra el final del archivo antes que el carácter de nueva línea. Esto puede suceder si no hay una nueva línea al final del archivo.

Para intentar solucionar esto, cambie la lógica del contador de filas y luego vea si necesitamos salir del bucle.

Nota . Esta lógica aún no es perfecta, ¿puedes encontrar un error?

Pero no hemos terminado de buscar errores. ReadStringregresará io.EOFcuando encuentre el final del archivo. Esta es la situación esperada, por lo ReadStringque debe hacer alguna forma de decir "detente, no hay nada más que leer". Por lo tanto, antes de devolver el error al objeto que realiza la llamada CountLine, debe verificar que el error no esté relacionado io.EOFy luego transmitirlo; de lo contrario, volveremos nily diremos que todo está bien.

Creo que este es un buen ejemplo de la tesis de Russ Cox sobre cómo el manejo de errores puede ocultar la función. Veamos la versión mejorada.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

Esta versión mejorada utiliza en su bufio.Scannerlugar bufio.Reader.

Under the hood bufio.Scannerusa bufio.Reader, pero agrega un buen nivel de abstracción, lo que ayuda a eliminar el manejo de errores.

. bufio.Scanner , .

El método sc.Scan()devuelve un valor truesi el escáner encontró una cadena y no encontró un error. Por lo tanto, el cuerpo del bucle se forllama solo si hay una línea de texto en el búfer del escáner. Esto significa que el nuevo CountLinesmaneja casos cuando no hay una nueva línea o cuando el archivo está vacío.

En segundo lugar, dado que sc.Scanregresa falsecuando se detecta un error, el ciclo forfinaliza cuando llega al final del archivo o se detecta un error. El tipo bufio.Scannerrecuerda el primer error que encontró y, utilizando el método sc.Err(), podemos restaurar ese error tan pronto como salgamos del ciclo.

Finalmente, se sc.Err()encarga del procesamiento io.EOFy lo convierte nilsi se llega al final del archivo sin errores.

Consejo . Si encuentra un manejo excesivo de errores, intente extraer algunas operaciones en un tipo auxiliar.

7.1.2. Escribir respuesta


Mi segundo ejemplo está inspirado en la publicación "Los errores son valores" .

Anteriormente vimos ejemplos de cómo se abre, escribe y cierra un archivo. Hay manejo de errores, pero no es demasiado, porque las operaciones se pueden encapsular en ayudantes, como ioutil.ReadFiley ioutil.WriteFile. Pero cuando se trabaja con protocolos de red de bajo nivel, es necesario crear una respuesta directamente utilizando primitivas de E / S. En este caso, el manejo de errores puede volverse intrusivo. Considere un fragmento de un servidor HTTP que crea una respuesta HTTP.

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

Primero, construya la barra de estado con fmt.Fprintfy verifique el error. Luego, para cada encabezado, escribimos una clave y un valor de encabezado, cada vez que verificamos un error. Finalmente, completamos la sección del encabezado con una adicional \r\n, verificamos el error y copiamos el cuerpo de la respuesta al cliente. Finalmente, aunque no necesitamos verificar el error io.Copy, debemos traducirlo de dos valores devueltos al único que retorna WriteResponse.

Este es un trabajo monótono. Pero puede facilitar su tarea aplicando un pequeño tipo de envoltorio errWriter.

errWritersatisface el contrato io.Writer, por lo que puede usarse como envoltorio. errWriterpasa registros a través de la función hasta que se detecta un error. En este caso, rechaza las entradas y devuelve el error anterior.

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

Si se aplica errWritera WriteResponsela claridad del código mejoró significativamente. Ya no necesita verificar errores en cada operación individual. El mensaje de error se mueve al final de la función como una comprobación de campo ew.err, evitando la molesta traducción de los valores devueltos de io.Copy.

7.2. Manejar el error solo una vez


Finalmente, quiero señalar que los errores deben manejarse solo una vez. Procesar significa verificar el significado del error y tomar una sola decisión.

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

Si toma menos de una decisión, ignora el error. Como vemos aquí, se w.WriteAllignora el error de .

Pero tomar más de una decisión en respuesta a un error también está mal. A continuación se muestra el código que a menudo encuentro.

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

En este ejemplo, si se produce un error durante el tiempo w.Write, la línea se escribe en el registro y también se devuelve a la persona que llama, que también puede registrarlo y pasarlo al nivel superior del programa.

Lo más probable es que la persona que llama haga lo mismo:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Por lo tanto, se crea una pila de líneas repetidas en el registro.

 unable to write: io.EOF could not write config: io.EOF 

Pero en la parte superior del programa obtienes un error original sin ningún contexto.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

Quiero analizar este tema con más detalle, porque no considero el problema de devolver simultáneamente un error y registrar mis preferencias personales.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

A menudo encuentro un problema que un programador olvida regresar de un error. Como dijimos anteriormente, el estilo de Go es usar operadores de límites, verificar los requisitos previos a medida que se ejecuta la función y regresar temprano.

En este ejemplo, el autor verificó el error, lo registró, pero olvidó regresar. Debido a esto, surge un problema sutil.

El contrato de manejo de errores de Go dice que en presencia de un error, no se pueden hacer suposiciones sobre el contenido de otros valores de retorno. Dado que el cálculo de referencias JSON falló, el contenido es bufdesconocido: puede no contener nada, pero lo que es peor, puede contener un fragmento JSON medio escrito.

Como el programador olvidó regresar después de verificar y registrar el error, se transferirá el búfer dañado WriteAll. Es probable que la operación tenga éxito y, por lo tanto, el archivo de configuración no se escribirá correctamente. Sin embargo, la función se completa normalmente, y la única señal de que se ha producido un problema es una línea en el registro donde falló el cálculo de referencias JSON, y no un error de registro de configuración.

7.2.1 Agregar contexto a los errores


Se produjo un error porque el autor intentaba agregar contexto al mensaje de error. Trató de dejar una marca para indicar la fuente del error.

Veamos otra forma de hacer lo mismo fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

Si combina el registro de error con el retorno en una línea, es más difícil olvidar regresar y evitar la continuación accidental.

Si se produce un error de E / S al escribir el archivo, el método Error()producirá algo como esto:

 could not write config: write failed: input/output error 

7.2.2 Error al envolver con github.com/pkg/errors


El patrón fmt.Errorffunciona bien para grabar mensajes de error, pero el tipo de error se deja de lado. Argumenté que manejar los errores como valores opacos es importante para los proyectos acoplados libremente , por lo que el tipo de error de origen no debería importar si solo necesitamos trabajar con su valor:

  1. Asegúrese de que no sea cero.
  2. Visualízalo en la pantalla o regístralo.

Sin embargo, sucede que necesita restaurar el error original. Para anotar tales errores, puede usar algo como mi paquete errors:

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

Ahora el mensaje se convierte en un buen error de estilo K&D:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

y su valor contiene un enlace al motivo original.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

Por lo tanto, puede restaurar el error original y mostrar el seguimiento de la pila:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

El paquete le errorspermite agregar contexto a los valores de error en un formato conveniente tanto para una persona como para una máquina. En una presentación reciente, les dije que en la próxima versión de Go, tal contenedor aparecerá en la biblioteca estándar.

8. Concurrencia


Go a menudo se elige debido a sus capacidades de concurrencia. Los desarrolladores han hecho mucho para aumentar su eficiencia (en términos de recursos de hardware) y rendimiento, pero las funciones de paralelismo de Go se pueden usar para escribir código que no sea productivo ni confiable. Al final del artículo, quiero dar un par de consejos sobre cómo evitar algunas de las dificultades de las funciones de concurrencia de Go.

El soporte de concurrencia de primer nivel de Go es proporcionado por canales, así como instrucciones selectygo. Si estudiaste la teoría de Go en libros de texto o en una universidad, es posible que hayas notado que la sección de paralelismo siempre es una de las últimas del curso. Nuestro artículo no es diferente: decidí hablar sobre paralelismo al final, como algo adicional a las habilidades habituales que el programador Go debería aprender.

Aquí hay una cierta dicotomía, porque la característica principal de Go es nuestro modelo de paralelismo simple y fácil. Como producto, nuestro lenguaje se vende a expensas de casi esta función. Por otro lado, la concurrencia en realidad no es tan fácil de usar, de lo contrario los autores no lo habrían convertido en el último capítulo de sus libros, y no habríamos mirado con pesar nuestro código.

En esta sección se analizan algunas de las trampas del uso ingenuo de las funciones de concurrencia de Go.

8.1. Haz algo de trabajo todo el tiempo.


¿Cuál es el problema con este programa?

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

El programa hace lo que pretendíamos: sirve un servidor web simple. Al mismo tiempo, pasa el tiempo de la CPU en un bucle infinito, porque for{}en la última línea mainbloquea gorutin main, sin realizar ninguna E / S, no hay que esperar para bloquear, enviar o recibir mensajes, o algún tipo de conexión con el programador.

Dado que el tiempo de ejecución de Go generalmente es atendido por un programador, este programa se ejecutará sin sentido en el procesador y puede terminar en un bloqueo activo (bloqueo en vivo).

¿Cómo arreglarlo? Aquí hay una opción.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

Puede parecer una tontería, pero esta es una solución común que se me ocurre en la vida real. Este es un síntoma de un malentendido del problema subyacente.

Si tienes un poco más de experiencia con Go, puedes escribir algo como esto.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

Una declaración vacía se selectbloquea para siempre. Esto es útil, porque ahora no hacemos girar todo el procesador solo por una llamada runtime.GoSched(). Sin embargo, tratamos solo el síntoma, no la causa.

Quiero mostrarte otra solución que, espero, ya se te haya ocurrido. En lugar de correr http.ListenAndServeen goroutine, dejando el problema principal de goroutine, solo corre http.ListenAndServeen la goroutine principal.

Consejo . Si sale de la función main.main, el programa Go finaliza incondicionalmente, independientemente de lo que hagan otras rutinas que se ejecuten durante la ejecución del programa.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

Así que este es mi primer consejo: si goroutine no puede avanzar hasta que reciba un resultado de otro, entonces a menudo es más fácil hacer el trabajo usted mismo, en lugar de delegarlo.

Esto a menudo elimina una gran cantidad de rastreo de estado y manipulación de canales necesarios para transferir el resultado de la rutina al iniciador del proceso.

Consejo . Muchos programadores de Go abusan de las gorutinas, especialmente al principio. Como todo lo demás en la vida, la clave del éxito es la moderación.

8.2. Deja paralelismo a la persona que llama


¿Cuál es la diferencia entre las dos API?

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

Mencionamos las diferencias obvias: el primer ejemplo lee el directorio en un segmento y luego devuelve todo el segmento o error si algo salió mal. Esto ocurre sincrónicamente, la persona que llama se bloquea ListDirectoryhasta que se hayan leído todas las entradas del directorio. Dependiendo de qué tan grande sea el directorio, puede llevar mucho tiempo y potencialmente mucha memoria.

Considere el segundo ejemplo. Es un poco más como la programación clásica de Go, aquí ListDirectorydevuelve el canal a través del cual se transmitirán las entradas del directorio. Cuando el canal está cerrado, esto es una señal de que no hay más entradas de catálogo. Como el llenado del canal ocurre después del regreso ListDirectory, se puede suponer que las gorutinas comienzan a llenar el canal.

Nota . En la segunda opción, no es necesario usar goroutine: puede seleccionar un canal suficiente para almacenar todas las entradas del directorio sin bloquear, completarlo, cerrarlo y luego devolver el canal a la persona que llama. Pero esto es poco probable, ya que en este caso surgirán los mismos problemas al usar una gran cantidad de memoria para almacenar todos los resultados en el canal.

La versión del ListDirectorycanal tiene dos problemas más:

  • El uso de un canal cerrado como señal de que no hay más elementos para procesar, ListDirectoryno puede informar al llamador de un conjunto incompleto de elementos debido a un error. La persona que llama no tiene forma de transmitir la diferencia entre un directorio vacío y un error. En ambos casos, parece que el canal se cerrará inmediatamente.
  • La persona que llama debe continuar leyendo desde el canal cuando está cerrado, porque esta es la única manera de entender que el canal que llena la rutina ha dejado de funcionar. Esta es una restricción seria de uso ListDirectory: la persona que llama pasa tiempo leyendo el canal, incluso si recibió todos los datos necesarios. Esto es probablemente más eficiente en términos de uso de memoria para directorios medianos y grandes, pero el método no es más rápido que el método original basado en el segmento.

En ambos casos, la solución es usar una devolución de llamada: una función que se llama en el contexto de cada entrada de directorio a medida que se ejecuta.

 func ListDirectory(dir string, fn func(string)) 

Como era de esperar, la función filepath.WalkDirfunciona de esa manera.

Consejo . Si su función inicia goroutine, debe proporcionar a la persona que llama una forma de detener explícitamente esta rutina. A menudo es más fácil dejar el modo de ejecución asíncrono en la persona que llama.

8.3. Nunca ejecute goroutine sin saber cuándo se detendrá


En el ejemplo anterior, la gorutina se usó innecesariamente. Pero una de las principales fortalezas de Go es su capacidad de concurrencia de primera clase. De hecho, en muchos casos el trabajo paralelo es bastante apropiado, y luego es necesario usar gorutinas.

Esta sencilla aplicación sirve el tráfico http en dos puertos diferentes: el puerto 8080 para el tráfico de la aplicación y el puerto 8001 para acceder al punto final /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

Aunque el programa no es complicado, es la base de una aplicación real.

La aplicación en su forma actual tiene varios problemas que aparecerán a medida que crecen, así que veamos de inmediato algunos de ellos.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

manipuladores de ruptura serveAppy serveDebugde funciones separadas, hemos separado de ellos main.main. También seguimos el consejo anterior y nos aseguramos serveAppy serveDebugdejamos la tarea de asegurar el paralelismo de la persona que llama.

Pero hay algunos problemas con el rendimiento de dicho programa. Si salimos serveAppy luego salimos main.main, el programa termina y el administrador de procesos lo reiniciará.

Consejo . Así como las funciones en Go dejan paralelismo a la persona que llama, las aplicaciones deberían dejar de monitorear su estado y reiniciar el programa que las llamó. No responsabilice a sus aplicaciones de reiniciarse: este procedimiento se maneja mejor desde fuera de la aplicación.

Sin embargo, serveDebugcomienza en una rutina diferente, y en caso de su lanzamiento, la rutina termina, mientras que el resto del programa continúa. A sus desarrolladores no les gustará el hecho de que no puedan obtener estadísticas de la aplicación porque el controlador /debugha dejado de funcionar por mucho tiempo.

Necesitamos asegurarnos de que la aplicación esté cerrada si se detiene cualquier rutina que la atienda.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

Ahora serverAppy serveDebugerrores de comprobación de ListenAndServey si es causa necesaria log.Fatal. Dado que ambos manejadores trabajan en goroutines, elaboramos la rutina principal en select{}.

Este enfoque tiene varios problemas:

  1. Si ListenAndServeregresa con un error nil, no habrá llamada log.Fataly el servicio HTTP en este puerto se cerrará sin detener la aplicación.
  2. log.Fatalllamadas os.Exitque salen incondicionalmente del programa; las llamadas diferidas no funcionarán, otras gorutinas no serán notificadas del cierre, el programa simplemente se detendrá. Esto hace que sea difícil escribir pruebas para estas funciones.

Consejo . Usar solo log.Fatalen funciones main.maino init.

De hecho, queremos transmitir cualquier error que ocurra al creador de la gorutina, para que pueda descubrir por qué se detuvo y completó limpiamente el proceso.

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

El estado de devolución de Goroutine se puede obtener a través del canal. El tamaño del canal es igual a la cantidad de gorutinas que queremos controlar, por lo que el envío al canal doneno se bloqueará, ya que esto bloqueará el cierre de las gorutinas y provocará una fuga.

Como el canal doneno puede cerrarse de manera segura, no podemos usar el idioma para el ciclo del canal for rangehasta que todas las gorutinas hayan informado. En cambio, ejecutamos todas las goroutines en ejecución en un ciclo, que es igual a la capacidad del canal.

Ahora tenemos una manera de salir limpiamente de cada rutina y corregir todos los errores que encuentren. Solo queda enviar una señal para completar el trabajo desde la primera gorutina a todos los demás.

El llamamiento ahttp.Serversobre la finalización, así que envolví esta lógica en una función auxiliar. El ayudante serveacepta la dirección y http.Handler, del mismo modo http.ListenAndServe, el canal stopque usamos para ejecutar el método Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

Ahora, para cada valor en el canal , cerramos doneel canal stop, lo que hace que cada gorutina en este canal cierre por su cuenta http.Server. A su vez, esto lleva a un retorno de todas las goroutinas restantes ListenAndServe. Cuando todas las gorutinas en ejecución se han detenido, main.mainfinaliza y el proceso se detiene limpiamente.

Consejo . Escribir esa lógica por su cuenta es un trabajo repetitivo y el riesgo de errores. Mire algo como este paquete que hará la mayor parte del trabajo por usted.

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


All Articles