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