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:
- Simplicidad
- Legibilidad
- 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 }
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 {
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.
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:
- Explica qué hace el código.
- Explica cómo lo hace.
- Explica por qué .
La primera forma es ideal para comentar sobre personajes públicos:
El segundo es ideal para comentarios dentro de un método:
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{
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
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.
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.
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
Hay una excepción a esta regla: no necesita documentar los métodos que implementan la interfaz. Específicamente, no hagas esto:
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
.
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.
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:- .
- . , .
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 utils
o helpers
generalmente 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
helpers
y, 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, strings
para las utilidades de procesamiento de cadenas.
Los paquetes con nombres como base
o a common
menudo 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/http
no hacer los paquetes individuales client
y server
, en cambio, hay archivos client.go
y server.go
con los tipos de datos correspondientes, así como transport.go
para el transporte global.Consejo . Es importante recordar que el nombre del identificador incluye el nombre del paquete.
- Una función
Get
de un paquete se net/http
convierte en un http.Get
enlace desde otro paquete.
- Un tipo
Reader
de un paquete se strings
transforma cuando se importa a otros paquetes strings.Reader
.
- La interfaz
Error
del paquete está net
claramente 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 try
y 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.lastRead
y 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.lastRead
mayor 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 nil
debe 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.Mutex
que 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
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 len
ambos cap
son 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() {
Nota . var s []string
similar 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:- Utilice interfaces para describir el comportamiento requerido por funciones o métodos.
- 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:- Mueva las variables correspondientes como campos a las estructuras que las necesitan.
- 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 common
el 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/contour
que 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
, private
e 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 .go
en 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 http
debe estar en un archivo http.go
en un directorio http
.
- A medida que crece el paquete, puede dividir las diversas funciones en varios archivos. Por ejemplo, el archivo
messages.go
contendrá tipos Request
y Response
, client.go
tipo de Client
archivo 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 go
admite el paquete testing
en dos lugares. Si tiene un paquete http2
, puede escribir un archivo http2_test.go
y 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 go
tambié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 go
reconoce 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 go
ve 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/f
puede importar solo un paquete desde un árbol de directorios .../a/b/c
, pero no puede hacerlo en absoluto .../a/b/g
o en cualquier otro repositorio (consultedocumentación ).5.2. El paquete principal más pequeño
Una función main
y un paquete main
deben tener una funcionalidad mínima, porque main.main
actúa como un singleton: un programa solo puede tener una función main
, incluidas las pruebas.Como main.main
es un singleton, existen muchas restricciones sobre los objetos llamados: se llaman solo durante main.main
o 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)
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 CopyFile
siempre 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
acepta dos parámetros: una dirección TCP para escuchar las conexiones entrantes y http.Handler
para procesar una solicitud HTTP entrante. Serve
permite que el segundo parámetro sea nil
. Las notas de comentario que normalmente la persona que llama realmente dan nil
que indica un deseo de utilizar http.DefaultServeMux
como un parámetro implícito.Ahora la persona que llama Serve
tiene 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 nil
propaga como un virus. El paquete también http
tiene 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 ListenAndServe
permite que la persona que llama pase nil
el segundo parámetro, http.Serve
también admite este comportamiento. De hecho, está en la http.Serve
lógica implementada "si el manejador es igual nil
, use DefaultServeMux
". La aceptación nil
de un parámetro puede hacer que la persona que llama piense que se puede pasar nil
para 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 nil
y no nil
.
El autor http.ListenAndServe
intentó simplificar la vida de los usuarios de API para el caso predeterminado, pero la seguridad se vio afectada.En presencia, nil
no 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 ids
es 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 {
Como el operador se estaba if
alargando mucho, quería incorporar la lógica de validación a una función separada. Esto es lo que se me ocurrió:
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) {
Sin embargo, hay un problema con anyPositive
alguien que accidentalmente podría llamarlo así: if anyPositive() { ... }
En ese caso, anyPositive
volveremos false
. Esta no es la peor opción. Peor si se anyPositive
devuelve true
en 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):
Ahora anyPositive
no 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 Document
en el disco.
Podría escribir una función Save
que escriba Document
en un archivo *os.File
. Pero hay algunos problemas.La firma Save
elimina 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.Save
Tambié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é f
escrito en una carpeta temporal y posteriormente eliminado.*os.File
tambié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 firmaSave
describe solo las partes relevantes *os.File
.Que se puede hacer
Con la ayuda de io.ReadWriteCloser
este, puede aplicar el principio de separación de interfaz y redefinirlo Save
en una interfaz que describa las propiedades más generales del archivo.Después de tal cambio, cualquier tipo que implemente la interfaz io.ReadWriteCloser
puede ser reemplazado por el anterior *os.File
.Esto amplía simultáneamente el alcance Save
y aclara al llamante qué métodos de tipo *os.File
están relacionados con su funcionamiento.Y el autor Save
ya 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.
Por lo tanto, puede reducir las especificaciones de la interfaz para Save
escribir y cerrar.En segundo lugar, el mecanismo de cierre de subprocesos y Save
es un legado de la época en que funcionaba con el archivo. La pregunta es, ¿bajo qué circunstancias wc
se cerrará?Si la Save
causa Close
sin 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.
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 Save
para 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, CountLines
acepta io.Reader
, no *os.File
; ya es tarea de la persona que llama proporcionar el io.Reader
contenido 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 ReadString
devolverá 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. ReadString
regresará io.EOF
cuando encuentre el final del archivo. Esta es la situación esperada, por lo ReadString
que 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.EOF
y luego transmitirlo; de lo contrario, volveremos nil
y 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.Scanner
lugar bufio.Reader
.Under the hood bufio.Scanner
usa 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 true
si el escáner encontró una cadena y no encontró un error. Por lo tanto, el cuerpo del bucle se for
llama solo si hay una línea de texto en el búfer del escáner. Esto significa que el nuevo CountLines
maneja casos cuando no hay una nueva línea o cuando el archivo está vacío.En segundo lugar, dado que sc.Scan
regresa false
cuando se detecta un error, el ciclo for
finaliza cuando llega al final del archivo o se detecta un error. El tipo bufio.Scanner
recuerda 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.EOF
y lo convierte nil
si 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.ReadFile
y 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.Fprintf
y 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
.errWriter
satisface el contrato io.Writer
, por lo que puede usarse como envoltorio. errWriter
pasa 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 errWriter
a WriteResponse
la 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.
Si toma menos de una decisión, ignora el error. Como vemos aquí, se w.WriteAll
ignora 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)
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)
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)
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 buf
desconocido: 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.Errorf
funciona 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:- Asegúrese de que no sea cero.
- 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 errors
permite 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 select
ygo
. 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 main
bloquea 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 select
bloquea 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.ListenAndServe
en goroutine, dejando el problema principal de goroutine, solo corre http.ListenAndServe
en 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?
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 ListDirectory
hasta 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í ListDirectory
devuelve 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 ListDirectory
canal tiene dos problemas más:- El uso de un canal cerrado como señal de que no hay más elementos para procesar,
ListDirectory
no 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.WalkDir
funciona 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)
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 serveApp
y serveDebug
de funciones separadas, hemos separado de ellos main.main
. También seguimos el consejo anterior y nos aseguramos serveApp
y serveDebug
dejamos la tarea de asegurar el paralelismo de la persona que llama.Pero hay algunos problemas con el rendimiento de dicho programa. Si salimos serveApp
y 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, serveDebug
comienza 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 /debug
ha 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 serverApp
y serveDebug
errores de comprobación de ListenAndServe
y si es causa necesaria log.Fatal
. Dado que ambos manejadores trabajan en goroutines, elaboramos la rutina principal en select{}
.Este enfoque tiene varios problemas:- Si
ListenAndServe
regresa con un error nil
, no habrá llamada log.Fatal
y el servicio HTTP en este puerto se cerrará sin detener la aplicación.
log.Fatal
llamadas os.Exit
que 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.Fatal
en funciones main.main
o 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 done
no se bloqueará, ya que esto bloqueará el cierre de las gorutinas y provocará una fuga.Como el canal done
no puede cerrarse de manera segura, no podemos usar el idioma para el ciclo del canal for range
hasta 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.Server
sobre la finalización, así que envolví esta lógica en una función auxiliar. El ayudante serve
acepta la dirección y http.Handler
, del mismo modo http.ListenAndServe
, el canal stop
que 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
Ahora, para cada valor en el canal , cerramos done
el 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.main
finaliza 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.