Hacer el bien haciendo el mal: escribir código malvado con Go, Parte 1

Malos consejos para un programador de Go


imagen

Después de décadas de programación en Java, los últimos años trabajé principalmente en Go. Trabajar con Go es excelente, principalmente porque el código es muy fácil de seguir. Java ha simplificado el modelo de programación C ++ al eliminar la herencia múltiple, la administración manual de memoria y la sobrecarga del operador. Go hace lo mismo, continúa avanzando hacia un estilo de programación simple y directo, eliminando por completo la herencia y la sobrecarga de funciones. El código simple es un código legible y el código legible es un código compatible. Y esto es excelente para la empresa y mis empleados.

Como en todas las culturas, el desarrollo de software tiene sus propias leyendas, historias que son contadas por el enfriador de agua. Todos escuchamos acerca de desarrolladores que, en lugar de enfocarse en crear un producto de calidad, se enfocan en proteger su propio trabajo de personas externas. No necesitan código compatible, porque significa que otras personas podrán comprenderlo y modificarlo. ¿Es posible en Go? ¿Es posible hacer que el código Go sea tan complicado? Diré de inmediato: esta no es una tarea fácil. Veamos las posibles opciones.

Piensa: “ ¿Cuánto puede corroer el código en un lenguaje de programación? ¿Es posible escribir un código tan horrible en Go que su autor se vuelva indispensable en la empresa? »No te preocupes. Cuando era estudiante, tenía un proyecto en el que apoyaba el código Lisp-e de otra persona escrito por un estudiante graduado. De hecho, logró escribir código Fortran-e usando Lisp. El código se parecía a esto:

(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d ) 

Había docenas de archivos de dicho código. Era absolutamente terrible y absolutamente brillante al mismo tiempo. Pasé meses tratando de resolverlo. Comparado con esto, escribir código incorrecto en Go es solo una saliva.

Hay muchas formas diferentes de hacer que su código no sea compatible, pero solo veremos algunas. Para hacer el mal, primero debes aprender a hacer el bien. Por lo tanto, primero observamos cómo escriben los "buenos" programadores de Go, y luego vemos cómo hacer lo contrario.

Mal embalaje


Los paquetes son un tema útil para comenzar. ¿Cómo puede la organización del código perjudicar la legibilidad?

En Go, el nombre del paquete se usa para referirse a la entidad exportada (por ejemplo, ` fmt.Println` o` http.RegisterFunc` ). Como podemos ver el nombre del paquete, los programadores "buenos" de Go se aseguran de que este nombre describa cuáles son las entidades exportadas. No deberíamos tener paquetes de utilidades , porque nombres como ` util.JSONMarshal` no funcionarán para nosotros, necesitamos` json.Marshal` .

Los desarrolladores "buenos" de Go tampoco crean un paquete separado para el DAO o el modelo. Para aquellos que no están familiarizados con este término, un DAO es un " objeto de acceso a datos ", una capa de código que interactúa con su base de datos. Solía ​​trabajar para una compañía donde 6 servicios Java importaron la misma biblioteca DAO para acceder a la misma base de datos, que compartieron, porque " ... bueno, ya sabes, los microservicios son los mismos ... ".

Si tiene un paquete separado con todos sus DAO, entonces es más probable que obtenga una dependencia circular entre paquetes, lo cual está prohibido en Go. Y si tiene varios servicios que incluyen este paquete DAO como biblioteca, también puede encontrar una situación en la que un cambio en un servicio requiere la actualización de todos sus servicios, de lo contrario, algo se romperá. Esto se llama un monolito distribuido y es increíblemente difícil de actualizar.

Cuando sabes cómo debería funcionar el empaque y lo que lo empeora, "comenzar a servir al mal" se vuelve simple. Organice mal su código y dé a sus paquetes nombres incorrectos. Divide tu código en paquetes como model , util y dao . Si realmente quieres comenzar a crear caos, intenta crear paquetes en honor a tu gato o tu color favorito. Cuando las personas se enfrentan a dependencias cíclicas o monolitos distribuidos debido a que intentan usar su código, tienen que suspirar, poner los ojos en blanco y decirles que simplemente hacen mal ...

Interfaces inapropiadas


Ahora que todos nuestros paquetes están dañados, podemos pasar a las interfaces. Las interfaces en Go no son como las interfaces en otros idiomas. El hecho de que no declare explícitamente que este tipo implementa la interfaz al principio parece insignificante, pero de hecho revierte completamente el concepto de interfaces.

En la mayoría de los idiomas con tipos abstractos, una interfaz se define antes o al mismo tiempo que la implementación. Tendrá que hacer esto al menos para las pruebas. Si no crea la interfaz por adelantado, no puede insertarla más tarde sin romper todo el código que usa esta clase. Porque debe reescribirlo con un enlace a la interfaz en lugar de un tipo específico.

Por esta razón, el código Java a menudo tiene interfaces de servicio gigantescas con muchos métodos. Las clases que implementan estas interfaces utilizan los métodos que necesitan e ignoran el resto. Es posible escribir pruebas, pero agrega un nivel adicional de abstracción, y al escribir pruebas, a menudo recurre al uso de herramientas para generar implementaciones de esos métodos que no necesita.

En Go, las interfaces implícitas determinan qué métodos necesita usar. El código posee una interfaz, no al revés. Incluso si usa un tipo con muchos métodos definidos en él, puede especificar una interfaz que incluya solo los métodos que necesita. Otro código que use campos separados del mismo tipo definirá otras interfaces que cubren solo la funcionalidad que se necesita. Por lo general, estas interfaces tienen solo un par de métodos.

Esto facilita la comprensión de su código, ya que una declaración de método no solo determina qué datos necesita, sino que también indica con precisión qué funcionalidad va a utilizar. Esta es una de las razones por las que los buenos desarrolladores de Go siguen el consejo: " Aceptar interfaces, devolver estructuras ".

Pero solo porque esta sea una buena práctica no significa que debas hacer eso ...
La mejor manera de hacer que sus interfaces sean "malvadas" es volver a los principios del uso de interfaces de otros idiomas, es decir. Defina las interfaces de antemano como parte del código que se llama. Defina interfaces enormes con muchos métodos que utilizan todos los clientes de servicio. No queda claro qué métodos son realmente necesarios. Esto complica el código, y la complicación, como saben, es la mejor amiga de un programador "malvado".

Pasar punteros de montón


Antes de explicar lo que esto significa, debes filosofar un poco. Si distrae y piensa, cada programa escrito hace lo mismo. Recibe datos, los procesa y luego envía los datos procesados ​​a otra ubicación. Esto es así, independientemente de si escribe un sistema de nómina, acepta solicitudes HTTP y devuelve páginas web, o incluso comprueba el joystick para rastrear el clic de un botón: los programas procesan los datos.

Si observamos los programas de esta manera, lo más importante es asegurarnos de que nos sea fácil entender cómo se convierten los datos. Por lo tanto, es una buena práctica mantener los datos sin cambios durante el mayor tiempo posible durante el programa. Porque los datos que no cambian son datos fáciles de rastrear.

En Go, tenemos tipos de referencia y tipos de valores. La diferencia entre los dos es si la variable se refiere a una copia de los datos o a la ubicación de los datos en la memoria. Los punteros, sectores, mapas, canales, interfaces y funciones son tipos de referencia, y todo lo demás es un tipo de valor. Si asigna una variable de tipo de valor a otra variable, se crea una copia del valor; cambiar una variable no cambia el valor de otra.

Asignar una variable de un tipo de referencia a otra variable de un tipo de referencia significa que ambas comparten la misma área de memoria, por lo que si cambia los datos a los que apunta el primero, cambia los datos a los que apunta el segundo. Esto es cierto tanto para las variables locales como para los parámetros de la función.

 func main() { //  a := 1 b := a b = 2 fmt.Println(a, b) // prints 1 2 //  c := &a *c = 3 fmt.Println(a, b, *c) // prints 3 2 3 } 

Los desarrolladores de Kind Go quieren que sea más fácil entender cómo se recopilan los datos. Intentan utilizar el tipo de valores como parámetros de funciones con la mayor frecuencia posible. No hay forma en Ir de marcar campos en estructuras o parámetros de funciones como finales. Si una función usa parámetros de valor, cambiar los parámetros no cambiará las variables en la función de llamada. Todo lo que puede hacer la función llamada es devolver el valor a la función de llamada. Por lo tanto, si completa una estructura llamando a una función con parámetros de valor, no puede temer transferir datos a la estructura, porque comprende de dónde proviene cada campo de la estructura.

 type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA) //  ,    f fmt.Println(f) } 

Bueno, ¿cómo nos convertimos en "malvados"? Muy simple: darle la vuelta a este modelo.

En lugar de llamar a funciones que devuelven los valores deseados, pasa un puntero a la estructura en la función y les permite realizar cambios en la estructura. Como cada función tiene su propia estructura, la única forma de descubrir qué campos están cambiando es mirar el código completo. También puede tener dependencias implícitas entre funciones: la primera función transfiere los datos que necesita la segunda función. Pero en el código en sí, nada indica que primero debe llamar a la primera función. Si construye sus estructuras de datos de esta manera, puede estar seguro de que nadie entenderá lo que está haciendo su código.

 type Foo struct { A int B string } func setA(f *Foo) { fA = 20 } //   fA! func setB(f *Foo) { fB = fmt.Sprintf("%d", fA*2) } func main() { f := Foo{} setA(&f) setB(&f) // ,  setA  setB //    ? fmt.Println(f) } 

Superficie de pánico


Ahora estamos comenzando a manejar los errores. Probablemente piense que es malo escribir programas que manejan errores en aproximadamente un 75%, y no diré que está equivocado. El código Go a menudo se completa con el manejo de errores de pies a cabeza. Y, por supuesto, sería conveniente procesarlos de manera no tan sencilla. Los errores suceden, y manejarlos es lo que diferencia a los profesionales de los principiantes. El manejo de errores difusos conduce a programas inestables que son difíciles de depurar y difíciles de mantener. A veces ser un "buen" programador significa "esforzarse".

 func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil } 

Muchos lenguajes, como C ++, Python, Ruby y Java, usan excepciones para manejar errores. Si algo sale mal, los desarrolladores en estos idiomas lanzan o lanzan una excepción, esperando que algún código lo maneje. Por supuesto, el programa espera que el cliente esté al tanto de un posible error que se produce en una ubicación determinada para que sea posible lanzar una excepción. Porque, excepto (sin un juego de palabras) excepciones marcadas en Java, no hay nada en la firma del método en idiomas o funciones que indique que puede ocurrir una excepción. Entonces, ¿cómo saben los desarrolladores de qué excepciones preocuparse? Tienen dos opciones:

  • En primer lugar, pueden leer todo el código fuente de todas las bibliotecas a las que llama su código, y todas las bibliotecas que llaman a las bibliotecas llamadas, etc.
  • En segundo lugar, pueden confiar en la documentación. Puedo ser parcial, pero la experiencia personal no me permite confiar plenamente en la documentación.

Entonces, ¿cómo llevamos este mal a Go? Abusando del pánico ( pánico ) y la recuperación ( recuperación ), por supuesto. El pánico está diseñado para situaciones como "la unidad se cayó" o "la tarjeta de red explotó". Pero no para eso: "alguien pasó cadena en lugar de int".

Desafortunadamente, otros "desarrolladores menos ilustrados" devolverán errores de su código. Por lo tanto, aquí hay una pequeña función auxiliar de PanicIfErr. Úselo para convertir los errores de otros desarrolladores en pánico.

 func PanicIfErr(err error) { if err != nil { panic(err) } } 

Puede usar PanicIfErr para envolver los errores de otras personas, comprimir el código. ¡No más manejo de errores feos! Cualquier error es ahora un pánico. ¡Es muy productivo!

 func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} } 

Puede colocar la recuperación en algún lugar más cercano al comienzo del programa, tal vez en su propio middleware . Y luego diga que no solo procesa errores, sino que también limpia el código de otra persona. Hacer el mal haciendo el bien es el mejor tipo de maldad.

 func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) } 

Establecer efectos secundarios


A continuación crearemos un efecto secundario. Recuerde, el desarrollador "bueno" de Go quiere comprender cómo pasan los datos a través del programa. La mejor manera de saber por qué pasan los datos es configurar dependencias explícitas en la aplicación. Incluso las entidades que corresponden a la misma interfaz pueden variar mucho en el comportamiento. Por ejemplo, un código que almacena datos en la memoria y un código que accede a la base de datos para el mismo trabajo. Sin embargo, hay formas de instalar dependencias en Go sin llamadas explícitas.

Al igual que muchos otros idiomas, Go tiene una forma de ejecutar código mágicamente sin invocarlo directamente. Si crea una función llamada init sin parámetros, se iniciará automáticamente cuando se cargue el paquete. Y, para confundir aún más, si en un archivo hay varias funciones con el nombre init o varios archivos en un paquete, todos comenzarán.

 package account type Account struct{ Id int UserId int } func init() { fmt.Println("  !") } func init() { fmt.Println("   ,     init()") } 

Las funciones de inicio a menudo se asocian con importaciones vacías. Go tiene una forma especial de declarar importaciones, que se ve como `import _" github.com / lib / pq`. Cuando configura un identificador de nombre vacío para un paquete importado, el método init se ejecuta en él, pero no muestra ninguno de los identificadores de paquete. Para algunas bibliotecas Go, como controladores de bases de datos o formatos de imagen, debe cargarlos habilitando la importación de paquetes vacíos, solo para llamar a la función init para que el paquete pueda registrar su código.

 package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") } 

Y esta es claramente una opción "malvada". Cuando usa la inicialización, el código que funciona mágicamente está completamente fuera del control del desarrollador. Las mejores prácticas no recomiendan usar las funciones de inicialización: estas son características no obvias, confunden el código y son fáciles de ocultar en la biblioteca.

En otras palabras, las funciones init son ideales para nuestros propósitos malvados. En lugar de configurar o registrar explícitamente entidades en paquetes, puede usar las funciones de inicialización e importación vacía para configurar el estado de su aplicación. En este ejemplo, ponemos la cuenta a disposición del resto de la aplicación a través del registro, y el paquete en sí se coloca en el registro utilizando la función init.

 package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) } 

Si desea utilizar una cuenta, coloque una importación vacía en su programa. No tiene que ser el código principal o relacionado, solo tiene que estar "en alguna parte". Esto es magico!

 package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) } 

Si usa inits en sus bibliotecas para configurar dependencias, verá de inmediato que otros desarrolladores están desconcertando cómo se instalaron estas dependencias y cómo cambiarlas. Y nadie será más sabio que tú.

Configuración complicada


Todavía hay mucho de todo lo que podemos hacer con la configuración. Si es un desarrollador de Go "bueno", querrá aislar la configuración del resto del programa. En la función main (), obtiene variables del entorno y las convierte a los valores necesarios para los componentes que están explícitamente relacionados entre sí. Sus componentes no saben nada acerca de los archivos de configuración o cómo se llaman sus propiedades. Para componentes simples, establece propiedades públicas, y para las más complejas, puede crear una función de fábrica que recibe información de configuración y devuelve un componente configurado correctamente.

 func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} } 

Pero los desarrolladores "malvados" saben que es mejor dispersar la información sobre la configuración en todo el programa. En lugar de tener una función en un paquete que defina los nombres y los tipos de valor para su paquete, use una función que tome la configuración como está y la convierta por sí sola.

Si esto parece demasiado "malo", use la función init para cargar el archivo de propiedades desde su paquete y establezca los valores usted mismo. Puede parecer que ha facilitado la vida de otros desarrolladores, pero usted y yo sabemos ...

Usando la función init, puede definir nuevas propiedades en la parte posterior del código, y nadie las encontrará hasta que entren en producción y todo se caiga, porque algo no entrará en una de las docenas de archivos de propiedades necesarios para ejecutarse. Si desea aún más "poder maligno", puede sugerir crear un wiki para realizar un seguimiento de todas las propiedades en todas las bibliotecas y para "olvidar" agregar periódicamente nuevas. Como Property Keeper, te conviertes en la única persona que puede ejecutar el software.

 func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) } 

Marcos de funcionalidad


Finalmente, llegamos al tema de frameworks vs bibliotecas. La diferencia es muy sutil. No se trata solo del tamaño; puede tener bibliotecas grandes y marcos pequeños. El marco llama a su código mientras usted llama al código de la biblioteca usted mismo. Los marcos requieren que escriba su código de cierta manera, ya sea nombrando sus métodos de acuerdo con reglas específicas, o que correspondan a interfaces específicas, o lo obliguen a registrar su código en el marco. Los marcos tienen sus propios requisitos para todo su código. Es decir, en general, los marcos lo mandan.

Go fomenta el uso de bibliotecas porque las bibliotecas están vinculadas. Aunque, por supuesto, cada biblioteca espera que los datos se transmitan en un formato específico, puede escribir algún código de conexión para convertir la salida de una biblioteca en entrada para otra.
Es difícil lograr que los marcos funcionen juntos sin problemas porque cada marco quiere un control completo sobre el ciclo de vida del código. A menudo, la única forma de lograr que los marcos funcionen juntos es que los autores del marco se reúnan y organicen claramente el apoyo mutuo. Y la mejor manera de usar los "marcos malvados" para obtener poder a largo plazo es escribir su propio marco, que se usa solo dentro de la empresa.

Mal actual y futuro


Después de dominar estos trucos, siempre se embarcará en el camino del mal. En la segunda parte, le mostraré cómo desplegar todo este "mal" y cómo convertir un código "bueno" en uno "malo".

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


All Articles