Pasar al contenido principal
Descuento de lanzamiento Angular desde Cero y CSS Avanzado - Animaciones El descuento termina en

Golang, ORMs y por qué todavía no uso uno

Golang, ORMs y por qué todavía no uso uno

Golang, ORMs y por qué todavía no uso uno
 
 
 
 
 
 

¡Hola gente del futuro!

En esta ocasión he traducido para ustedes un artículo de Eric Urban titulado Golang, ORMs, and why I am still not using one, disfrútenlo.

Golang, ORMs y por qué todavía no uso uno

En el año pasado más del 90% de mi producción se ha dirigido a escribir software escrito en Golang. En ese marco de tiempo todos los proyectos relacionados con el empleo en los que he trabajado implican una base de datos relacional.

Hasta ahora estoy contento utilizando el paquete database/sql incorporado con el paquete driver github.com/lib/pq para PostgreSQL. He resistido la necesidad de pasar a un ORM o paquete similar por completo hasta ahora.

El coste de entrada bajo del paquete sql

La primera razón por la cual no estoy usando un ORM es porque el paquete incorporado database/sql te da muchas funcionalidades sin agregar complejidad significativa a tu programa. Una vez que lo tienes trabajando consigues inmediatamente el pooling de la conexión y gorutinas seguras compartiendo conexiones. Ejecutar una consulta en la base de datos es tan simple como llamar a la función correcta para su consulta. Éstos se dividen aproximadamente en los siguientes grupos:

  1. Una consulta que no devuelve ninguna fila.
  2. Una consulta que devuelve una fila.
  3. Una consulta que devuelve cero o más filas.

Después de obtener el resultado de la consulta, la función Scan es llamada en el tipo sql.Row para leer el resultado de la consulta en tipos nativos en Go. Si pasas un puntero a un tipo que es de alguna manera incompatible, obtendrás un error de tiempo de ejecución.

Todo tu SQL está parametrizado, no hay razón para usar una cadena interpolada para generar consultas.

También obtendrás transacciones, que superficialmente es igual a usar el objeto de conexión de base de datos. El soporte para consultas preparadas también existe, cosa que puede hacer tu vida más fácil.

Desventajas al paquete sql

Definitivamente hay lugares donde el paquete database/sql queda corto de mis deseos.

Dado que los parámetros de consulta se pasan alrededor como interfaz {}, puede ejecutar fácilmente una consulta en tiempo de ejecución que siempre fallará.

Lo mismo ocurre con la exploración de resultados en tipos.

Curiosamente, si consulta una columna que podría ser null, tiene que elaborar su consulta para devolver un valor predeterminado o pasar un puntero a un tipo de puntero al escanear los resultados.

Por otra parte, cuando seleccione filas con entradas no nulas, la consulta se ejecutará correctamente, pero fallará cuando se encuentre null.

--Table with null columns
CREATE TABLE expense_reports (
    id int,
    trip_id int,
    title varchar not null
);

--This row doesn't cause problems for the code below
INSERT INTO expense_reports (id,trip_id,title) VALUES(1,591628,'First expense report');
--This row causes the code below to fail
INSERT INTO expense_reports (id,trip_id,title) VALUES(2,null,'Second expense report');
package main

import "database/sql"
import _ "github.com/lib/pq"
import "os"
import "fmt"

type ExpenseReport struct {
    Id     int
    TripId int
    Title  string
}

func GetExpenseReportById(db *sql.DB, id int) (*ExpenseReport, error) {
    const query = `SELECT trip_id,title from expense_reports where id = $1 `
    var retval ExpenseReport
    err := db.QueryRow(query, id).Scan(&retval.TripId, &retval.Title)
    retval.Id = id
    return &retval, err
}

func main() {
    db, err := sql.Open("postgres", "dbname=example sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()
    for i := 1; i != 3; i++ {
        expenseReport, err := GetExpenseReportById(db, i)
        if err != nil {
            fmt.Fprintf(os.Stdout, "Error:%s\n", err)
        } else {
            fmt.Fprintf(os.Stdout, "Expense Report:%v\n", expenseReport)
        }
    }
}

Cuando se utiliza la función GetExpenseReportById para recuperar el informe de gastos con una columna id de 2, este código falla. La salida del ejemplo se muestra aquí.

[email protected]:~$ go run ./sql_null_column_example.go
Expense Report:&{1 591628 First expense report}
Error:sql: Scan error on column index 0: converting string "" to a int: strconv.ParseInt: parsing "": invalid syntax

Como se puede ver las sentencias SQL anteriores son sólo algunas cadenas enviadas a diferentes piezas de software. No hay manera de verificar que tu SQL es válido, mucho menos que devuelve el resultado correcto sin utilizar una base de datos real.

Todo esto significa que has renunciado a una de las cosas más impresionantes de Golang: la verificación en tiempo de compilación de los tipos.

Realmente no puedo decir que alguna vez me haya emocionado encontrar un rastro de pila que involucre AtributeError de Python porque me olvidé de implementar algún método intermitentemente llamado en un objeto.

La comprobación de los tipos por Golang en tiempo de compilación es a veces descartada como de poco valor.

Es importante darse cuenta de que la comprobación en tiempo de compilación de los tipos sólo puede verificar la validez sintáctica del código.

Por supuesto, es casi imposible producir un programa con una sintaxis no válida que tenga la semántica correcta.

La comprobación de tipo de tiempo de compilación proporciona una primera capa de defensa contra dichos errores.

Mi solución a esto era para asegurarse de que cada función que contiene SQL es completamente probado en una base de datos real.

Parece existir algún sentimiento contra la necesidad de una base de datos para ejecutar pruebas unitarias.

Algunos podrían incluso argumentar que tales pruebas no son ni siquiera pruebas unitarias.

Nunca he entendido personalmente el deseo de esto, ya que SQL es sólo más código fuente en lo que a mí respecta.

Rara vez es un error probar el código.

No planeo burlarme de un RDBMS completo sólo para poder afirmar que estoy haciendo pruebas de unidad "reales".

El segundo problema con el paquete database/sql es que si tienes algún objeto que podría logicamente ser devuelto desde muchas consultas diferentes, terminas duplicando la lógica para escanear las filas de resultados en el objeto en todo el lugar.

Yo he mitigado esto mediante la definición de funciones que son sólo visibles para el paquete, como scanCustomer que es llamdado como un ayudante de otras funciones.

Esto funciona, pero acabé definiendo mi propia interfaz llamada scanner para capturar la firma de la función (*sql.Row).Scan.

Esto requiere que el llamador use el mismo conjunto y orden de columnas al consultar la fila de la base de datos.

Por supuesto, no tengo la intención de dejar de lado que el uso del paquete database/sql tiende a implicar un montón de boilerplate. Tú terminas con cosas como sql.ErrNoRows en muchos lugares.

Hay buenos argumentos en contra de boilerplate, pero no me parece que sea un obstáculo para el desarrollo de software. La cantidad de tiempo que paso realmente escribiendo código tiende a ser muy pequeño, por lo que gastar 20 segundos en obtener el boilerplate en lugar de una función no es un problema para mí.

Boilerplate se convierte en un gran problema en el mantenimiento del código existente. Es posible que de repente necesites tocar todas las funciones de un paquete si cambia su forma general de hacer las cosas. También se muestra como un problema real si actúa como una barrera para el prototipado de nuevas cosas en una aplicación. El gasto de 2 minutos para implementar un nuevo widget en una interfaz de usuario y 2 horas para obtener el código de acceso de datos para rellenar el widget nunca es divertido. Eventualmente esto resulta en los desarrolladores que no quieren trabajar en el proyecto en absoluto. Aquellos que trabajan en el proyecto activamente intentan buscar nuevas y mejores maneras de hacer las cosas.

Golang & ORMs

No hay una oferta corta de paquetes para Golang que hagan algo con sus objetos y una base de datos. Falta por supuesto en todos estos la "R" de relación. No soy la primera persona en poner este punto de vista adelante. De hecho, esta presentación da un gran resumen del estado de ORMs en Golang.

Fundamentalmente, un paquete en Golang es un asunto de tiempo de ejecución. Si esperas el nivel de integración que ActiveRecord proporciona con Ruby, te sentirás decepcionado. No hay manera de implementar un ORM en tiempo de ejecución en Go que se integre con el lenguaje. Desde la perspectiva del diseñador de lenguaje, esto es realmente una cosa buena. Pero lo que esto significa para mí es que las deficiencias antes mencionadas del paquete sql todavía existen cuando se utiliza uno de los paquetes similares a ORM disponibles para Go.

En otras palabras, en realidad no solucionan ninguno de mis problemas. Así que en realidad no tiene sentido tomar el tiempo para aprender los patrones de uso y añadir otra dependencia de software. Después de todo, si no puedo expresar la manipulación de datos que necesito con SQL tengo un problema serio. Cualquier paquete similar a ORM que seleccione es probable que el uso de algunas características de SQL sea más difícil que usar SQL. Si se exponen todas las características de SQL, probablemente lo haga mal. Esto se conoce como el efecto de la plataforma interna. Un paquete de software que intenta abstraer los hechos de la implementación subyacente sólo se convierte en una copia pobre de la implementación subyacente.

¿Por qué me molestan los ORMs?

Los problemas de los ORM no son únicos para Golang. Cualquier lenguaje de tipo estático eventualmente se encuentra con este tipo de problema. El entorno Java es capaz de lidiar con esto ligeramente mejor que C, pero sigue siendo horrible. La única razón por la que los lenguajes de tipo dinámico terminan mejor es su capacidad para dictar y alterar el comportamiento de los objetos en tiempo de ejecución. Ciertos conceptos RDBMS simplemente no "mapean" en absoluto a la programación orientada a objetos. Unicidad y restricciones de clave primaria en las tablas son un excelente ejemplo de esto. Cualquier libro medio decente sobre el desarrollo de software orientado a objetos cubre la diferencia entre una relación "has-a" y una "is-a" entre objetos. Pero ninguna de ellas cubre la identidad del objeto. La programación orientada a objetos tiene ciertamente el concepto de la equivalencia del objeto, pero ése es realmente el contrario de la unicidad del objeto. Si tienes dos objetos que son equivalentes, entonces explícitamente no impone la unicidad del objeto. La idea de unicidad de objeto no existe en lenguajes de programación funcionales o imperativos. Sin duda podría emularlo en Python, por ejemplo, con código algo como esto.

already_expensed = set()
class ExpenseReport(object):
    def __init__(self,for_trip):
        global already_expensed
        if for_trip in already_expensed:
            raise ValueError("You can't submit two expense reports for the same trip")

        already_expensed.add(for_trip)

Vamos a ignorar el hecho por un momento que already_expensed no es persistente. Este código ciertamente impone que no se pueden crear dos instancias de ExpenseReport para el mismo viaje. Esto ciertamente refleja una restricción de columna de base de datos real en una tabla que está en uso en algún momento ahora. Pero la implementación no tiene nada que ver con la programación orientada a objetos. Simplemente estamos comprobando una variable global en un constructor. El mismo concepto podría aplicarse a una función para almacenar en caché el valor de retorno para un conjunto dado de entradas, pero utilizando un mapa en lugar de un conjunto.

Pero esto no es realmente el comportamiento que un desarrollador estaba apuntando cuando agregaron la restricción de columna en la no hipotética tabla expense_reports. El objetivo real era tener una sola fuente de información sobre un informe de gastos para un solo viaje. El software que el departamento de marketing usa para reportar los gastos podría ser muy diferente del software de informes de gastos usado por un técnico que realiza soporte de producto en el sitio. El punto fundamental de esto es que nadie se preocupa de cuántos o qué objetos se crean siempre y cuando la base de datos sea coherente internamente.

Tu ORM es realmente un application framework

La mayoría de software a los que te refieres como ORM son realmente una aplicación a nivel de framework. El trabajo de un ORM es proporcionar algún tipo de puente entre las relaciones de base de datos y los objetos. Ciertamente ActiveRecord es un ORM, pero también es un framework. Esto se debe a que los ORM casi siempre crecen para cubrir ciertas otras áreas, como las migraciones de esquemas y la serialización de objetos. ActiveRecord también se coloca en la parte superior de la jerarquía de objetos. Esto está más allá del alcance de unir objetos y relaciones. ActiveRecord realmente es un ORM y un application framework.

Lo que me parece particularmente extraño son los frameworks que esperan que escribas migraciones de esquema en algún lenguaje que no sea SQL. Todavía no puedo entender por qué esto es supuestamente una ventaja. Si quiero cambiar mis relaciones de base de datos, ya hay un lenguaje para eso: SQL.

¿Por qué todos usan ORMs?

Las razones por las que los ORMs se utilizan parecen variar enormemente. Esta es una lista abreviada de las razones que he escuchado.

  • Yo no sé SQL.
  • Yo no tengo tiempo para escribir SQL.
  • Si no usas un framework, tu código es inmantenible.

Elija uno o todos, realmente no me importa. Nunca he entendido estos puntos hasta hace poco. Resulta, por supuesto, que voy a usar un RDBMS de una manera fundamentalmente diferente a la mayoría de la gente. No fue hasta que leí los comentarios de Jeff Atwood que esto se hizo evidente para mí. El problema de la mayoría es el "object/relational impedance mismatch" que se puede resumir de la siguiente manera:

Los sistemas de objetos se caracterizan típicamente por cuatro componentes básicos: identidad, estado, comportamiento y encapsulación. (...) Los sistemas relacionales describen una forma de almacenamiento y recuperación de conocimientos basada en la lógica predictiva y en las declaraciones de verdad.

Este extracto proviene de The Vietnam of Computer Science de Ted Neward.

Además, continúa:

En verdad, este acercamiento básico -esclavizar un modelo en los términos y el acercamiento del otro- ha sido la respuesta tradicional al desajuste de la impedancia, "solucionando" realmente el problema ignorando una mitad de él.

El resumen al final capta perfectamente los posibles resultados de "resolver" este problema. Mi enfoque actual se incluye en la siguiente categoría.

3. Mapeo manual. Los desarrolladores simplemente aceptan que no es un problema tan difícil de resolver manualmente después de todo y escriben código de acceso relacional directo para devolver las relaciones al lenguaje, acceder a las tuplas y rellenar objetos según sea necesario. En muchos casos, este código puede incluso ser generado automáticamente por una herramienta que examina los metadatos de la base de datos, eliminando algunas de las críticas principales de este enfoque (es decir, "Es demasiado código para escribir y mantener").

La razón por la que estoy tan cómodo con esto es porque escribo las instrucciones SQL DDL necesarias primero para capturar la información que necesito almacenar. Más tarde, me imagino cómo hacer que mi software funcione con eso. Esto está en completo contraste con el enfoque que más eligen, que es tratar de forzar a la base de datos a ser un cuadro en el que se pueden almacenar los objetos que ya tienen.

Considero que este enfoque es tremendamente menos doloroso. Si tuviera el conjunto ideal de objetos para capturar el dominio de datos en cualquier espacio problemático en el que esté trabajando, dudo que terminaría con un campo id en cada objeto. Además, no hay forma de que yo termine con un campo trip_id en mi objeto ExpenseReport. Yú simplemente almacena una referencia o un puntero en la instancia de Trip. Es justo allí para que todos puedan acceder. Intentar forzar a un RDBMS a capturar todo esto parece una práctica excepcionalmente tortuosa.

Por otro lado, si diseñaste el esquema SQL, la idea de crear objetos relativamente simples que te permitan interactuar con el esquema debería ser increíblemente sencilla. Si tienes un informe de gastos y deseas obtener el viaje asociado con él, llama Trip.find_by_id(an_expense_report.trip_id). Hay un olor a código entero en esto, pero el olor está allí porque estás utilizando la base de datos como se pretende. Además, el código es realmente más fácil de razonar. Cualquier persona que tenga una comprensión vaga de cómo funciona un RDBMS sabe al instante que se trata de una consulta de base de datos, que el resultado podría cambiar en las llamadas posteriores y que toda la operación puede fallar si la red está inactiva.

Dos extremos de un continuo

Lo que he descubierto es que existe un continuo con dos extremos. En el lado izquierdo del continuo tienes conceptos de relación. En el lado derecho del continuo están los conceptos orientados a objetos. Cuando una base de datos es incorporada en el software por un desarrollador, un punto en este continuo debe ser elegido. Esto siempre se hace, incluso si la decisión se toma inconscientemente.

Si elige el lado derecho del continuo, la base de datos terminará siendo utilizada como capa de persistencia. Los objetos se crean, se modifican y se eliminan. Eso es básicamente todo. Estos son los problemas obvios con este enfoque.

  1. La sobrecarga del acceso a objetos puede llegar a ser inaceptablemente alta. Si necesita recuperar y modificar miles de objetos de la base de datos, encontrará que es demasiado lento para utilizar métodos CRUD básicos que operan en un solo objeto a la vez. La solución a esto es exponer la funcionalidad que le permite cambiar recuperar o modificar objetos con un solo viaje a la base de datos.
  2. El problema de la identidad del objeto y la unicidad del objeto no se aborda. Si crea tablas que simplemente almacenan la información asociada con cada tipo de objeto, es posible obtener objetos duplicados en su base de datos. Un enfoque de mitigación de este problema es incluir comprobaciones de software que se invocan cuando se crea o actualiza un objeto. Cada chequeo tiene la opción de decir sí o no si el objeto puede ser guardado. Estos controles siempre generan falsos positivos y falsos negativos debido a las condiciones de carrera. Los falsos negativos son menos problemáticos que los falsos positivos. La solución a esto es agregar restricciones de columna a las tablas.
  3. El RDBMS falla al captar la relación "is-a" entre los tipos. Hay muchas soluciones posibles a este problema. Puede crear una sola tabla plana que capture todos los atributos de los tipos de elemento secundario como columnas. Esto significa que cada fila de la tabla tiene más columnas nulas que aquellas con datos. Esta tabla puede volverse inaceptablemente grande. Puede generar tablas separadas para cada tipo secundario, pero esto significa que el problema de unicidad de objeto ya no es solucionable con restricciones de columna. Se elige un enfoque híbrido, donde hay una tabla de un solo padre que tiene las columnas con restricciones de unicidad en él. También necesita una columna para almacenar el tipo secundario y una clave externa en la tabla secundaria. Como mínimo, esto impone una penalización de rendimiento, ya que cada lectura o escritura en la base de datos termina siendo un JOIN entre dos tablas.
  4. El comportamiento lectura-modificación-escritura de las operaciones CRUD da lugar a la pérdida de datos. Si es posible tener dos hilos de ejecución diferentes modificando el mismo objeto los cambios de uno sobrescriben los cambios del otro. Esto realmente es direccionable usando una columna de versión o una columna de marca de tiempo en cada tabla. Siempre que se actualiza el objeto, se comprueba la columna. Si ha cambiado desde que se ha leído el objeto, se rechaza la escritura. El problema viene con la elección de la estrategia correcta para manejar los rechazos de escritura. La elección debe hacerse entre abandono y reintentos, tampoco es universalmente apropiado.

Mi solución propuesta a cada uno de estos problemas permite que los conceptos relacionales se conviertan en un mundo orientado a objetos. Hay más problemas a este acercamiento, no me he encontrado en todos ellos todavía. Vale la pena señalar que si realmente sólo necesitas una tienda de objetos, un RDBMS es una solución excesiva. Si puedes serializar sus objetos y asignarles un identificador único, un almacén de clave-valor debería funcionar muy bien.

El extremo izquierdo del espectro también causa problemas.

  1. Una vez que se crea un esquema con fuertes restricciones de integridad, una representación en memoria es de utilidad limitada. Puede crear objetos que muestren información almacenada en el esquema. Pero tan pronto como empiece a modificar esos datos, puede crear objetos imposibles en la memoria. La representación en memoria puede hacerse consistente duplicando las restricciones en el software, pero esto crea una pesadilla de mantenimiento.
  2. Los tipos de base de datos pueden ser demasiado rígidos para su dominio. Este problema se ve parcialmente atenuado por la presencia de extensiones como hstore y json en PostgreSQL.
  3. Es posible que un esquema simple no se correlacione con la forma en que desea mostrar los datos. Los poderosos conceptos de álgebra relacional en SQL le permiten mostrar sus datos en casi cualquier formato que necesite. El problema con esto es que el rendimiento sufre bajo la presencia de un gran número de instrucciones JOIN. Para abordar esto, se pueden agregar más índices de columnas. Esto tiene un rendimiento negativo en las sentencias UPDATE e INSERT porque los índices deben actualizarse como parte de cada consulta.

Hay más problemas que esto, pero una vez más no me he encontrado con ellos todavía.

Mi enfoque del uso de RDBMS

Lo importante para alejar de la comparación de los dos extremos del espectro es que el extremo izquierdo renuncia a la flexibilidad de los datos y la facilidad de acceso mientras que el extremo derecho deja de ser linealizable. Si bien puede ser una molestia trabajar dentro de las representaciones de datos de SQL, la linealización es mucho más valiosa que cualquier otra cosa que pierda.

Mi razón detrás de esta elección es que cuando su manipulación de datos es linearizable la construcción de un sistema distribuido es más simple. La frase "sistema distribuido" invoca inmediatamente la idea de escala horizontal con una noción de escala increíblemente grande. En realidad, estoy mucho más interesado en ser capaz de diseñar arquitecturas que se distribuyen en el sentido de que hay múltiples procesos separados todos cumpliendo un papel dentro de un sistema. Cada proceso no necesita ser idéntico a los demás. Cualquier estado compartido entre los procesos es de alguna manera en la base de datos. Esta decisión de diseño no se presta automáticamente a un diseño escalable o tolerante a fallos. Lo que sí me permite crear es un sistema en el que cada componente tiene un conjunto limitado de responsabilidades. Al colocar un conjunto limitado de responsabilidades en cada componente, obtienes varias ventajas.

  1. Cada componente es más fácil de desarrollar y probar.
  2. Los desarrolladores pueden comprender y mantener los componentes de forma aislada.
  3. Se puede añadir una nueva funcionalidad al sistema añadiendo nuevos componentes.
  4. Cada componente puede usar la arquitectura de software que tenga más sentido para el dominio de sus responsabilidades.

Si tu sistema tiene una gran cantidad de estado compartido que se accede o se manipula con frecuencia, esto es una señal de que la división de responsabilidades es escasamente elegida. Cuando se minimiza la cantidad de estado compartido, es posible construir un sistema sin ejecutar barreras de escalabilidad asociadas con un RDBMS.

Mi meta es usar el RDBMS como la fuente inmutable de la verdad en cuanto al estado de un sistema de software. Las restricciones de integridad de datos y las garantías de linealización de un RDBMS son adecuadas para esto. Incluso si el software va completamente mal, la base de datos debe ser diseñado con suficientes garantías de integridad para permitir la recuperación del desastre.

Cuando los tipos de base de datos no son suficientes para capturar la información que necesita almacenar hay dos soluciones fáciles a este problema. Si los datos nunca se utilizan como una restricción o un parámetro de consulta, puede serializar los datos de forma segura. La elección del formato de serialización es principalmente de portabilidad. Si tiene varios proyectos de software mirando todos los mismos datos, entonces tiene sentido elegir un formato de serialización como JSON.

A dónde ir desde aquí

El hecho más desconcertante en todo esto es que no parece haber un framework que intente utilizar la generación de código como una estrategia para acceder a un RDBMS de Golang.

Dada mi estrategia "SQL First", tiene sentido generar código Golang desde el esquema de la base de datos. Para ello, debes ser capaz de analizar las instrucciones SQL DDL. Descubrí que el proyecto Vitess incluye un analizador de SQL. Desafortunadamente descubrí que la biblioteca no soporta declaraciones DDL. FoundationDB ofrece un analizador SQL de código abierto en Java. Parece estar bien documentado y capaz de analizar todas las sentencias de SQL. Hay algo un poco extraño en la idea de requerir Java como una dependencia del tiempo de construcción de un proyecto de Golang.

Todavía no me he comprometido a escribir una biblioteca de generación de código para solucionar este problema. Si lo hago, mi preferencia es estar muy cerca del lado relacional. Mis objetivos para esta biblioteca son:

  1. Generar código analizando las instrucciones SQL DDL de la base de datos.
  2. Exponer conceptos relacionales en cualquier objeto generado.
  3. Utilizar una interfaz fluida para exponer un constructor de consultas. El SQL resultante no debe ser ambiguo.
  4. Facilitar una mayor corrección del código de tiempo de compilación.

Cuando desarrolle una biblioteca, quiero lograr ganancias inmediatamente como resultado de mi trabajo. Por lo tanto, necesitaré desarrollar una biblioteca que pueda brindar una funcionalidad básica útil inmediatamente y tener nuevas características agregadas que vaya necesitando.