3. Respuestas basadas en bases de datos

Lo que aprenderás :

  • Instalar drivers de bases de datos que actuara como un intermediario entre MySQL y tu aplicación de Go.
  • Conectarte a MySQL desde nuestra aplicación web.
  • Crear paquetes de modelos aislados, de manera que la lógica sea reusable y desacoplada de nuestra aplicación.
  • Usar de manera apropiada las funciones de Go que se encuentran en el paquete database/sql para ejecutar diferentes tipos de consultas SQL y como evitar errores comunes que pueden hacer que tu servidor se quede sin recursos.
  • Prevenir ataques de SQL injection usando placeholder parameters.
  • Usar transacciones, para que puedas ejecutar múltiples consultas SQL en una sola acción atómica.

3.1 Configurando MySQL

Primero necesitaras instalar MySQL en tu computadora, la documentación oficial contiene instrucciones para cualquier tipo de sistema operativo.

Para instalarlo en Mac OS tenemos que ejecutar :

brew install mysql

En linux como Debian o Ubuntu :

sudo apt install mysql-server

Puede que al estar instalando MySQL se te pregunte por un password para el usuario root, asegurate de recordarlo!

3.1 Comenzando con la base de datos

Una vez tengamos instalado todo deberiamos poder conectarnos desde la terminal como root :

$ sudo mysql
mysql >

Pero si eso no funciona proba con el siguiente comando, poniendo el password que seteaste en la instalación:

$ mysql -u root -p
Enter password:
mysql>

Una vez conectados, lo primero que tenemos que hacer es crear una base de datos que contendrá todas las tablas para nuestro proyecto. Copia y pega los siguientes comandos para crear una nueva database snippetbox usando el encondig UTF8.

-- Create a new UTF-8 `snippetbox` database.
CREATE DATABASE snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Switch to using the `snippetbox` database.
USE snippetbox;

Copia y pega el siguiente código para crear una nueva tabla llamada snippets que contendrá la información de nuestra aplicación.

-- Create a `snippets` table.
CREATE TABLE snippets (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created DATETIME NOT NULL,
expires DATETIME NOT NULL
);
-- Add an index on the created column.
CREATE INDEX idx_snippets_created ON snippets(created);

Cada registro en la tabla tiene un id que lo identifica de manera unica, tambien tendra un texto mas corto que es el title y el contenido en si, ira dentro de content . A demas tendremos algunos metadatos para saber cuando fue creado , created y cuando expira , expires.

Vamos a agregar algunos snippets de relleno, el cual usaremos mas adelante.

-- Add some dummy records (which we'll use in the next couple of chapters).
INSERT INTO snippets (title, content, created, expires) VALUES (
'An old silent pond',
'An old silent pond...\nA frog jumps into the pond,\nsplash! Silence again.\n\n– Matsuo Bashō',
UTC_TIMESTAMP(),
DATE_ADD(UTC_TIMESTAMP(), INTERVAL 365 DAY)
);
INSERT INTO snippets (title, content, created, expires) VALUES (
'Over the wintry forest',
'Over the wintry\nforest, winds howl in rage\nwith no leaves to blow.\n\n– Natsume Soseki',
UTC_TIMESTAMP(),
DATE_ADD(UTC_TIMESTAMP(), INTERVAL 365 DAY)
);
INSERT INTO snippets (title, content, created, expires) VALUES (
'First autumn morning',
'First autumn morning\nthe mirror I stare into\nshows my father''s face.\n\n– Murakami Kijo',
UTC_TIMESTAMP(),
DATE_ADD(UTC_TIMESTAMP(), INTERVAL 7 DAY)
);

3.1 Creando un nuevo usuario

Desde el punto de vista de la seguridad, no es buena idea conectarnos a MySQL como root desde una aplicación web. En su lugar es mejor crear una base de datos user con permisos restringidos sobre la DB.

Mientras todavía estas conectado a MySQL desde la terminal, corre el siguiente comando para crear un nuevo web user con privilegios solo para hacer un CRUD sobre registros. Pero que no pueda hacer un TRUNCATE sobre la tabla.

CREATE USER 'web'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON snippetbox.* TO 'web'@'localhost';
-- Important: Make sure to swap 'pass' with a password of your own choosing.
ALTER USER 'web'@'localhost' IDENTIFIED BY 'pass';

Una vez hayas terminado escribí exit para salir de la terminal de MySQL.

3.1 Testeando nuestro nuevo usuario

$ mysql -D snippetbox -u web -p
Enter password:
mysql>

Si los permisos están funcionando correctamente , deberías poder usar SELECT e INSERT en la DB, pero otros comandos como lo son DROP TABLE y GRANT deberían fallar.

mysql> SELECT id, title, expires FROM snippets;
+----+------------------------+---------------------+
| id | title
| expires
|
+----+------------------------+---------------------+
|1 | An old silent pond
|2 | Over the wintry forest | 2023-04-05 07:20:05 |
| 2023-04-05 07:20:05 |
|3 | First autumn morning
| 2022-04-12 07:20:05 |
+----+------------------------+---------------------+
3 rows in set (0.00 sec)
mysql> DROP TABLE snippets;
ERROR 1142 (42000): DROP command denied to user 'web'@'localhost' for table 'snippets'

3.2 Instalando el driver de la base de datos

Para usar MySQL desde nuestra aplicación Go necesitamos instalar un driver de base de datos. Esto basicamente sirve como intermediario, transladando comandos entre Go y la DB MySQL.

Podes encontrar una lista de drivers en la wiki de Go, pero para nuestra aplicación usaremos el driver go-sql-driver/mysql

Para descargarlo en nuestro proyecto usaremos go get asi:

$ cd $HOME/code/snippetbox
$ go get github.com/go-sql-driver/mysql@v1
go: downloading github.com/go-sql-driver/mysql v1.7.0

Fijate que estamos poniendo el prefijo @v1 para indicar que queremos descargar la ultima versión disponible con el major release numero 1. Lo importante cuando sigas este post es que la versión este en “1.x.x”.

Si quisieras descargar la ultima version independientemente del numero de version solo emitirias @version:

$ go get github.com/go-sql-driver/mysql

O si queres una versión especfica:

$ go get github.com/go-sql-driver/mysql@v1.0.3

3.3 Modulos y compilaciones

Ahora que tenemos el driver instalado, vamos a poder ver que dentro de go.mod se agrego una linea que hace un require con el path especifico del paquete que acabamos de traernos usando go get con el numero especifico de la versión que queríamos.

module github.com/nahueldev23/snippetbox
go 1.20
require github.com/go-sql-driver/mysql v1.7.0

Este sera usado cuando utilicemos go run, go test, o go build .

Esto hace que podamos tener en multiples proyectos en la misma maquina, varias versiones del mismo paquete, por ejemplo usamos la version 1.7.0 pero podemos llegar a tener otro codigo que necesite usar la versión 1.2.0 y no habría problema con eso.

También podrás notar que se creo un archivo nuevo llamado go.sum, este contiene las representaciones cyptograficas del contenido de los paquetes, si lo abrimos veremos algo como esto:

github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=

go.sum no esta diseñado para ser editable por humanos y genenralmente no nscesitas abrirlo, pero tiene dos funciones utiles:

  • Si corres go mod verify desde la terminal, verificara que los checksum que descargaste de los paquetes que están en tu maquina hagan match con las entradas que están en go.sum por lo cual podes estar seguro de que esos archivos no fueron alterados.

    $ go mod verify
    all modules verified
    
  • Si alguien necesita descargar todas las dependencias del proyecto (go mod download) esa persona obtendrá un error si hay alguna inconsistencia con los checksums del archivo.

En resumen:

  • Vos o alguien mas pueden correr el comando go mod download para descargar exactamente la versión exacta de cada uno de los paquetes que tu proyecto necesita.
  • Podes correr go mod verify para asegurarte que ninguna de los paquetes que descargaste fueron alterados.
  • Cuando uses go run,go test o go build se usara exactamente la versión que se encuentre en el archivo go.mod

Todas estas cosas juntas hacen que sea mucho mas fácil crear builds reproducibles de tu aplicación en Go.

3.3 Información adicional

3.3 Actualizando paquetes

Una vez que un paquete fue descargado y agregado al archivo go.mod , el paquete y la version quedan fijadas. Pero hay varias razones por las que querrías actualizarlas.

Para actualizar a la ultima versión disponible solo tenes que usar go get -u con -u logramos el resultado esperado:

go get -u github.com/foo/bar

O si queres actualizar a una versión especifica seria:

$ go get -u github.com/foo/bar@v2.0.0
3.3 Quitando paquetes que no usamos

Aveces hacemos go get de paquetes que luego no queremos mas. Cuando esto pasa tenemos dos opciones.

  1. Podes hacer un go get con el prefijo @none:

    $ go get github.com/foo/bar@none
    
  2. O Si removiste todas las referencias del paquete en tu código , podes correr go mod tidy el cual removerá automáticamente cualquier paquete que no sea usado de go.mod y go.sum.:

    $ go mod tidy -v
    

3.4 Creando un pool de conexiones a la DB

Ahora que tenemos todo configurado el siguiente paso es conectar la DB con nuestra app.

Para hacer esto necesitamos usar la función de Go sql.Open() . La cual usaremos así:

// The sql.Open() function initializes a new sql.DB object, which is essentially a
// pool of database connections.
db, err := sql.Open("mysql", "web:pass@/snippetbox?parseTime=true")
if err != nil {
...
}

Hay algunas cosas que explicar sobre esto:

  • El primer parámetro de sql.Open() es el nombre del driver y el segundo parámetro es la data source name también llamado connection string o DSN, el cual describe como se conecta la DB.
  • El formato del data source name dependerá de cual DB y que driver estés usando , por lo general encontraras esta información en la documentación del driver ,para el driver que estamos usando encontramos la documentación acá.
  • La parte del DSN parseTime=true es una especificación del driver el cual convierte el TIME de SQL y el DATE al tipo de objeto de Go time.Time.
  • La función sql.Open() retorna un objecto sql.DB, la cual no es un conexión a una DB, es un pool de muchas conexiones .Esta diferencia es importante entenderla. Go maneja las conexiones en esos pools a medida que es necesario, automáticamente abre y cierra conexiones de la DB a través del driver.
  • Las conexiones a través de pools son seguras para el acceso concurrente , así que podes usarlas desde los handlers de la aplicación web sin problemas.
  • Las conexiones pool están pensadas para ser de larga duración. En una aplicación web es normal inicializar la conexion pool en el main() y luego pasar el pool por los handlers . No deberías llamar a sql.Open() dentro de ellos , seria un gasto de memoria y de recursos de red.

3.4 Conexión de Base de Datos:

  1. Una conexión individual: Cuando un programa o aplicación necesita interactuar con una base de datos, generalmente debe establecer una conexión a esa base de datos. Esta conexión individual es una comunicación directa entre la aplicación y la base de datos.
  2. Uso exclusivo: Durante el tiempo que la conexión está activa, es exclusiva para la aplicación que la ha establecido. Esto significa que no puede ser utilizada simultáneamente por otras partes del programa.
  3. Recursos: Cada conexión de base de datos consume recursos, como memoria y CPU, y establecer y cerrar conexiones puede ser costoso en términos de rendimiento.

3.4 Pool de conexiones:

  1. Un conjunto de conexiones reutilizables: En lugar de abrir y cerrar una conexión cada vez que se necesita interactuar con la base de datos, se crea un grupo (pool) de conexiones previamente establecidas y se mantienen abiertas en espera.
  2. Reutilización: Cuando una parte del programa necesita interactuar con la base de datos, toma prestada una conexión del pool y la utiliza. Una vez que se completa la tarea, la conexión se devuelve al pool en lugar de cerrarse.
  3. Beneficios: Los pools de conexiones son más eficientes en términos de rendimiento ya que evitan el costo de abrir y cerrar conexiones repetidamente. También ayudan a evitar problemas de congestión y límites en el número de conexiones que una base de datos puede manejar simultáneamente.

3.4 Diferencias clave:

  • Una conexión de base de datos es una conexión individual y exclusiva entre la aplicación y la base de datos, mientras que un pool de conexiones es un conjunto de conexiones reutilizables compartidas entre varias partes de una aplicación.
  • Las conexiones de base de datos se crean y cierran según sea necesario, mientras que las conexiones en un pool se mantienen abiertas y se reutilizan.
  • Los pools de conexiones ayudan a mejorar la eficiencia y el rendimiento al reducir la sobrecarga de crear y cerrar conexiones repetidamente.

En resumen, una conexión de base de datos es una instancia única de comunicación con la base de datos, mientras que un pool de conexiones es una colección de conexiones reutilizables que se gestionan de manera eficiente para optimizar el rendimiento y la utilización de recursos.

3.4 Usando la conexión en nuestra aplicación web.

Vamos a ver como usar sql.Open() en la practica. Vamos a main.go y agregamos lo siguiente:

package main
import (
+    "database/sql" // New import
    "flag"
    "log"
    "net/http"
    "os"
 +   _ "github.com/go-sql-driver/mysql" // New import
)
...
func main() {
        addr := flag.String("addr", ":4000", "HTTP network address")
        // Define a new command-line flag for the MySQL DSN string.
+        dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
        flag.Parse()

        infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
        errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
        // To keep the main() function tidy I've put the code for creating a connection
        // pool into the separate openDB() function below. We pass openDB() the DSN
        // from the command-line flag.
+        db, err := openDB(*dsn)

+        if err != nil {
+            errorLog.Fatal(err)
+        }

+        // We also defer a call to db.Close(), so that the connection pool is closed
+        // before the main() function exits.
+       defer db.Close()

        app := &application{
            errorLog: errorLog,
            infoLog:infoLog,
        }

        srv := &http.Server{
            Addr:*addr,
            ErrorLog: errorLog,
            Handler:app.routes(),
        }
        infoLog.Printf("Starting server on %s", *addr)
        // Because the err variable is now already declared in the code above, we need
        // to use the assignment operator = here, instead of the := 'declare and assign'
        // operator.
+        err = srv.ListenAndServe()
        errorLog.Fatal(err)
    }

+ // The openDB() function wraps sql.Open() and returns a sql.DB connection pool
+ // for a given DSN.
+ func openDB(dsn string) (*sql.DB, error) {
+    db, err := sql.Open("mysql", dsn)
+    if err != nil {
+    	return nil, err
+    }
+    if err = db.Ping(); err != nil {
+   		return nil, err
+    }
+    return db, nil
+}

Hay alguno detalles que mencionar:

  • Nota que al importar nuestro driver usamos el guion bajo como prefijo, eso es porque nuestro main.go no esta usando nada del paquete mysql , pero si intentamos importarlo normalmente el compilador de Go nos arrojara un error , nosotros lo que necesitamos es que se ejecute el init() de ese paquete para que se registre en el paquete database/sql . El truco es crear un alias sin nombre con el guion bajo, esto es un estandar para la mayoria de los drivers SQL en Go. El paquete "github.com/go-sql-driver/mysql" en Go utiliza una técnica llamada “import side effect” o “init side effect” para registrar el controlador MySQL con el paquete database/sql. Al importar este paquete con _, estás diciendo que solo deseas que se ejecute el código de inicialización (init()) del paquete, que se encarga de registrar el controlador MySQL con database/sql.
  • sql.Open() no crea ninguna conexión , lo único que hace es inicializar el pool para un futuro uso y están a la espera del primer llamado, entonces para verificar que que este todo funcionando correctamente usamos db.Ping() para crear una conexión con estos pool y ver si hay algún error.
  • Nuestra aplicacion solo terminara si la interrumpimos con Ctrl+c o por errorLog.Fatal() en ambos casos el programa termina inmediatamente y las funciones defer no se ejecutaran, lo que dará un mal cierra a la DB, pero es un buen habito incluir db.Close() y nos servirá mas adelante cuando hagamos de manera mas correcta el cierre de nuestra app.

3.5 Diseñando el modelo de base de datos

La idea principal es tener encapsulado todo el código que trabaje con MySQL en un paquete separado del resto de nuestra aplicación.

Por ahora crearemos el esqueleto del modelo de la DB y devolveremos un poco de datos ficticios, no sera mucho pero no servirá para entender el patrón antes de entrar mas en detalle a las consultas SQL.

Comencemos por crear la carpeta y el archivo en internal/models/snippets.go:

$ cd $HOME/code/snippetbox
$ mkdir -p internal/models
$ touch internal/models/snippets.go

Recuerda que la carpeta internal es usada para mantener código que no sea especifico solo para nuestra aplicación, sino que podamos usarlo tambien en otros contextos, en otras palabras código reutilizable. Un modelo de base de datos que podría ser usada por otra aplicación en el futuro como por ejemplo un command line , puede ir perfectamente acá.

Vamos a nuestro archivo snippetets.go y creemos la struct que represente la data por cada snippet individual.

package models
import (
    "database/sql"
    "time"
	)
// Define a Snippet type to hold the data for an individual snippet. Notice how
// the fields of the struct correspond to the fields in our MySQL snippets
// table?
type Snippet struct {
    ID      int
    Title   string
    Content string
    Created time.Time
    Expires time.Time
}
// Define a SnippetModel type which wraps a sql.DB connection pool.
type SnippetModel struct {
	DB *sql.DB
}
// This will insert a new snippet into the database.
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
	return 0, nil
}
// This will return a specific snippet based on its id.
func (m *SnippetModel) Get(id int) (*Snippet, error) {
	return nil, nil
}
// This will return the 10 most recently created snippets.
func (m *SnippetModel) Latest() ([]*Snippet, error) {
	return nil, nil
}

3.5 Usando SnippetModel

Para usar este modelo en nuestros handlers necesitamos establecer una nueva structura SnippetModel en nuestro main() y luego inyectarlo como dependecia a traves de la estructura application tal cual lo venimos haciendo con las otras dependencias.

 package main
 import (
 "database/sql"
 "flag"
 "log"
 "net/http"
 "os"
+ // Import the models package that we just created. You need to prefix this with
+ // whatever module path you set up back in chapter 02.01 (Project Setup and Creating
+ // a Module) so that the import statement looks like this:
+ // "{your-module-path}/internal/models". If you can't remember what module path you
+ // used, you can find it at the top of the go.mod file.
+ "snippetbox.alexedwards.net/internal/models"
_ "github.com/go-sql-driver/mysql"
)
// Add a snippets field to the application struct. This will allow us to
// make the SnippetModel object available to our handlers.
type application struct {
    errorLog *log.Logger
    infoLog *log.Logger
    snippets *models.SnippetModel
}

func main() {
    addr := flag.String("addr", ":4000", "HTTP network address")
    dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
    flag.Parse()
    infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
    errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
    db, err := openDB(*dsn)
    if err != nil {
    	errorLog.Fatal(err)
    }
    defer db.Close()
    // Initialize a models.SnippetModel instance and add it to the application
    // dependencies.
    app := &application{
        errorLog: errorLog,
        infoLog:infoLog,
+      snippets: &models.SnippetModel{DB: db},
    }
    
    srv := &http.Server{
        Addr:*addr,
        ErrorLog: errorLog,
        Handler:app.routes(),
    }

    infoLog.Printf("Starting server on %s", *addr)
    err = srv.ListenAndServe()
    errorLog.Fatal(err)
}
...

3.5 Información adicional

3.5 Los beneficios de esta estructura
  • Hay una separación clara, Nuestra lógica de la Base de Datos no esta vinculada a nuestros handlers, lo que significa que las responsabilidades de los handlers están limitadas a cosas de HTTP (validar request y escribir respuestas), esto hará que sea fácil hacer test unitarios en el futuro.
  • Al crear la estructura SnippetModel y asignarle metodos hace que sea un objeto único y encapsulado, lo cual hace que sea facil inicializarlo y pasarlo a los handlers como dependencia. Una vez mas esto hace que nuestro código sea mas fácil de testear.
  • Debido a que las acciones de lo modelos fueron definios como métodos de un objeto , en nuestro caso SnippetModel , nos da la oportunidad de crear una interface y hacer un mock para los test unitarios.
  • Tenemos total control sobre cual DB esta siendo usada en runtime simplemente usando -dsn en la linea de comandos.

3.6 Ejecutando consultas SQL

Vamos a actualizar SnippetModel.Insert() para crear un nuevo registro en nuestra tabla snippets y luego retornar el id de ese nuevo registro.

Para lograrlo usaremos la siguiente query:

INSERT INTO snippets (title, content, created, expires)
VALUES(?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))

Nota como usamos ? para indicar un placeholder para los datos que queremos insertar, Los datos se insertaremos en ultima instancia no sera confiables ya que son enviados por el usuario, es buena practica usar los placeholders en lo parámetros en vez de interpolarlos en la consulta SQL.

3.6 Ejecutando la consulta

Go provee tres métodos para ejecutar las consultas a las DB

  • DB.Query() es usado para SELECT que retornan muchas filas.
  • DB.QueryRow() es usado para SELECT que retornan una sola fila.
  • DB.Exec() es usado para consultas que no retornan filas (INSERT y DELETE).

Para nuestro caso, el mas apropiado es DB.Exect() , vamos a ver como usarlo en SnippetModel.Insert()

Vamos a internal/models/snippets.go y actualizamos:

package models
...
type SnippetModel struct {
	DB *sql.DB
}
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
    // Write the SQL statement we want to execute. I've split it over two lines
    // for readability (which is why it's surrounded with backquotes instead
    // of normal double quotes).
    stmt := `INSERT INTO snippets (title, content, created, expires)
    VALUES(?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))`
    // Use the Exec() method on the embedded connection pool to execute the
    // statement. The first parameter is the SQL statement, followed by the
    // title, content and expiry values for the placeholder parameters. This
    // method returns a sql.Result type, which contains some basic
    // information about what happened when the statement was executed.
    result, err := m.DB.Exec(stmt, title, content, expires)
    if err != nil {
    	return 0, err
    }
    // Use the LastInsertId() method on the result to get the ID of our
    // newly inserted record in the snippets table.
    id, err := result.LastInsertId()
    if err != nil {
    	return 0, err
    }
    // The ID returned has the type int64, so we convert it to an int type
    // before returning.
    return int(id), nil
}
...

Veamos rapidamente el type sql.Result que devuelve DB.Exec() .Con el tenemos dos metodos:

  • LastInsertedId() el cual retorna un numero de tipo int64, normalmente es el id generado por la DB el cual es definido por el campo auto increment cuando se inserta un nuevo registro.

  • RowsAffected() El cual retorna un numero de tipo int64 que dice cuantas filas fueron afectadas.

    Importante: No todos los drivers y bases de datos soportan LastInsertId y RowsAffected() , por ejmplo PostgreSQL, asi que si estas pensando usar estos métodos es importante que leas la documentación de cada driver en particular primero.

También podemos no usar el valor del resultado asi:

_, err := m.DB.Exec("INSERT INTO ...", ...)

3.6 Usar el modelo en nuestros handlers

vamos a cmd/web/handlers.go y actualizamos el método snippetCreate asi:

package main
...
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.Header().Set("Allow", http.MethodPost)
        app.clientError(w, http.StatusMethodNotAllowed)
    	return
    }
+    // Create some variables holding dummy data. We'll remove these later on
+    // during the build.
+    title := "O snail"
+    content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa"
+    expires := 7
+    // Pass the data to the SnippetModel.Insert() method, receiving the
+    // ID of the new record back.
+    id, err := app.snippets.Insert(title, content, expires)
+    if err != nil {
+        app.serverError(w, err)
+        return
+    }
+    // Redirect the user to the relevant page for the snippet.
+    http.Redirect(w, r, fmt.Sprintf("/snippet/view?id=%d", id), http.StatusSeeOther)
-    w.Write([]byte("Create a new snipet..."))
}

Inicia la app y mediante curl hagamos una petición POST /snippet/create (usamos -L para que automáticamente siga la dirección):

$ curl -iL -X POST http://localhost:4000/snippet/create
HTTP/1.1 303 See Other
Location: /snippet/view?id=4
Date: Mon, 31 Jan 2022 16:36:18 GMT
Content-Length: 0
HTTP/1.1 200 OK
Date: Mon, 31 Jan 2022 16:36:18 GMT
Content-Length: 40
Content-Type: text/plain; charset=utf-8
Display a specific snippet with ID 4...

Podes ver el registo desde la terminal :

sudo mysql
>mysql use snippetbox;
>mysq SELECT * FROM snippets;

3.6 Información adicional

3.6 Placeholder parameters

En el código que fuimos escribiendo usamos ? para crear placeholders en nuestras consultas, hicimos esto en vez de interpolarlas para evitar ataques de SQL injection de algún usuario mal intencionado que quiera enviar datos que puedan corromper nuestro sistema.

Por detrás , DB.Exect() funciona en tres pasos:

  1. Crea un nuevo prepare statement en la DB usando la consulta sql que le pasamos. La DB parsea y compila la declaración, luego la almacena y la deja lista para su ejecución.
  2. En el segundo paso, Exect() pasa los valores de los parámetros a la DB, la base de datos entonces ejecuta las declaraciones preparadas usando esos parámetros. Lo que en realidad pasa es que los parámetros se transmiten mas tarde, luego de que se haya compilado la declaración, entonces al hacerlo después de ese proceso la DB los trata como datos puros y no pueden cambiar la intención de la declaración, por lo que la inyección no puede ocurrir.
  3. Finalmente cierra ( o des asigna) la declaración preparada en la DB.

Los placeholders cambian dependiendo de la db, MYSQL,SQL SERVER Y SQLite usan ? mientras que Postgresql usa $n así:

_, err := m.DB.Exec("INSERT INTO ... VALUES ($1, $2, $3)", ...)

3.7 Obtener un único registro

Para lograrlo haremos un consulta que traiga un registro basado en su id único y nos aseguraremos de que no este expirado.

SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() AND id = ?

Vamos a internal/models/snippets.go y agregamos el siguiente código:

package models
import (
"database/sql"
"errors" // New import
"time"
)
...
func (m *SnippetModel) Get(id int) (*Snippet, error) {
    // Write the SQL statement we want to execute. Again, I've split it over two
    // lines for readability.
    stmt := `SELECT id, title, content, created, expires FROM snippets
    WHERE expires > UTC_TIMESTAMP() AND id = ?`
    // Use the QueryRow() method on the connection pool to execute our
    // SQL statement, passing in the untrusted id variable as the value for the
    // placeholder parameter. This returns a pointer to a sql.Row object which
    // holds the result from the database.
    row := m.DB.QueryRow(stmt, id)
    // Initialize a pointer to a new zeroed Snippet struct.
    s := &Snippet{}
    // Use row.Scan() to copy the values from each field in sql.Row to the
    // corresponding field in the Snippet struct. Notice that the arguments
    // to row.Scan are *pointers* to the place you want to copy the data into,
    // and the number of arguments must be exactly the same as the number of
    // columns returned by your statement.
    err := row.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
    if err != nil {
        // If the query returns no rows, then row.Scan() will return a
        // sql.ErrNoRows error. We use the errors.Is() function check for that
        // error specifically, and return our own ErrNoRecord error
        // instead (we'll create this in a moment).
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNoRecord
        } else {
            return nil, err
        }
    }
    // If everything went OK then return the Snippet object.
    return s, nil
}
...

Por detrás rows.Scan() automaticamente convierte los datos crudos de salida desde la DB SQL a tipos de datos nativos en Go , siempre y cuando los tipos de datos de la struct tengan sentido, por lo general funcionan asi:

  • CHAR, VARCHAR y TEXT lo mapea a string.
  • BOOLEAN mapea a bool.
  • INT mapea a int;BIGINT mapea a int64.
  • DECIMAL y NUMERIC mapea a float
  • TIME, DATE and TIMESTAMP mapea a time.Time.

Un detalle de nuestro driver MySQL es que nos pide que usemos parseTime=true como parametro de nuestro DSN para que fuerce la conversion de TIME y DATE a time.Time. De otra manera retornara como []byte

Vamos a crear el error ErrNoRecord , para eso nos dirigimos a internal/models/errors.go

$ touch internal/models/errors.go
package models
import (
	"errors"
)
var ErrNoRecord = errors.New("models: no matching record found")

Te estaras preguntando porque estamos retornando ErrNoRecord desde nuestro metodo SnippetModel.Get() en vez de devolver directamente sql.ErrNoRows . La razon es que ayuda a encapsular nuestro modelo completamente y hace que a nuestra aplicación no le importe como maneja los errores la DB que estemos usando.

3.7 Usando el modelo en nuestros handlers.

Vamos a cmd/web/handlers.go y actualizamos el handler snippetView para que retorne los datos a través de la respuesta HTTP.

package main
import (
+    "errors" // New import
    "fmt"
    "html/template"
    "net/http"
    "strconv"
+    "snippetbox.alexedwards.net/internal/models" // New import
)
...
func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.URL.Query().Get("id"))
    if err != nil || id < 1 {
        app.notFound(w)
        return
    }
    // Use the SnippetModel object's Get method to retrieve the data for a
    // specific record based on its ID. If no matching record is found,
    // return a 404 Not Found response.
    snippet, err := app.snippets.Get(id)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
        	app.notFound(w)
        } else {
        	app.serverError(w, err)
        }
        return
    }
    // Write the snippet data as a plain-text HTTP response body.
    fmt.Fprintf(w, "%+v", snippet)
}
...

Vamos a nuestro navegador y ponemos http://localhost:4000/snippet/view?id=1 deberías poder ver la respuesta en el.

3.7 Información adicional

3.7 Revisar errores específicos

Usamos un par de veces errors.Is() para revisar cuando un error hace match con un valor especifico asi:

if errors.Is(err, models.ErrNoRecord) {
	app.notFound(w)
} else {
	app.serverError(w, err)
}

Antes de Go 1.13 la manera común era usar el operador == así:

if err == models.ErrNoRecord {
	app.notFound(w)
} else {
	app.serverError(w, err)
}

Aunque ese código compila , es mas seguro y mejor practica usar error.Is() .

Esto es así porque Go 1.13 introdujo la posibilidad de agregar información adicional a los errores envolviéndolos .Si un error sucede se envuelve y crea un error completamente nuevo, lo cual hace que no sea posible revisar el error original que quedo por debajo usando == .

Por otro lado, errors.Is() funciona desenvolviendo los errores según sea necesario antes de buscar la coincidencia.

También hay otra función errors.As() el cual se usa para revisar si un erro tiene un typo especifico.

3.8 Múltiples registros SQL.

Vamos a ver como es el patron para ejecutar declaraciones SQL que retornen muchas filas. Lo demostrare actualizando SnippetModel.Latest() que retorna los diez snippets mas recientes ( que no hayan expirado) usando la siguiente consulta.

SELECT id, title, content, created, expires FROM snippets
WHERE expires &gt; UTC_TIMESTAMP() ORDER BY id DESC LIMIT 10

Vamos a internal/models/snippets.go y agregamos el siguiente código.

package models
...
func (m *SnippetModel) Latest() ([]*Snippet, error) {
    // Write the SQL statement we want to execute.
    stmt := `SELECT id, title, content, created, expires FROM snippets
    WHERE expires > UTC_TIMESTAMP() ORDER BY id DESC LIMIT 10`
    // Use the Query() method on the connection pool to execute our
    // SQL statement. This returns a sql.Rows resultset containing the result of
    // our query.
    rows, err := m.DB.Query(stmt)
    if err != nil {
    	return nil, err
    }
    // We defer rows.Close() to ensure the sql.Rows resultset is
    // always properly closed before the Latest() method returns. This defer
    // statement should come *after* you check for an error from the Query()
    // method. Otherwise, if Query() returns an error, you'll get a panic
    // trying to close a nil resultset.
    defer rows.Close()
    // Initialize an empty slice to hold the Snippet structs.
    snippets := []*Snippet{}
    // Use rows.Next to iterate through the rows in the resultset. This
    // prepares the first (and then each subsequent) row to be acted on by the
    // rows.Scan() method. If iteration over all the rows completes then the
    // resultset automatically closes itself and frees-up the underlying
    // database connection.
    for rows.Next() {
        // Create a pointer to a new zeroed Snippet struct.
        s := &Snippet{}
        // Use rows.Scan() to copy the values from each field in the row to the
        // new Snippet object that we created. Again, the arguments to row.Scan()
        // must be pointers to the place you want to copy the data into, and the
        // number of arguments must be exactly the same as the number of
        // columns returned by your statement.
        err = rows.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
        if err != nil {
            return nil, err
        }
        // Append it to the slice of snippets.
        snippets = append(snippets, s)
    }
    // When the rows.Next() loop has finished we call rows.Err() to retrieve any
    // error that was encountered during the iteration. It's important to
    // call this - don't assume that a successful iteration was completed
    // over the whole resultset.
    if err = rows.Err(); err != nil {
    	return nil, err
    }
    // If everything went OK then return the Snippets slice.
    return snippets, nil
}

Importante : Cerrar el resultset con defer rows.Close() es critico, Si el resultset se mantiene abierto mantendrá la conexión con la base de datos abierta, lo que hará que rápidamente se agoten las conexiones disponibles para el pool.

3.8 Usando el modelo en nuestros handlers

Volvamos a cmd/web/handlers y actualicemos el handler de home para que use SnippetModel.Latest() . Cambiare el contenido para que sea una respuesta HTTP, para eso comentare el código que renderiza los templates html así:

package main
import (
    "errors"
    "fmt"
    // "html/template"
    "net/http"
    "strconv"
    "snippetbox.alexedwards.net/internal/models"
)
func (app *application) home(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
    app.notFound(w)
    	return
    }
    snippets, err := app.snippets.Latest()
    if err != nil {
        app.serverError(w, err)
        return
    }
    for _, snippet := range snippets {
    	fmt.Fprintf(w, "%+v\n", snippet)
    }
    // files := []string{
    // "./ui/html/base.tmpl",
    //"./ui/html/partials/nav.tmpl",
    //"./ui/html/pages/home.tmpl",
    // }
    // ts, err := template.ParseFiles(files...)
    // if err != nil {
    //app.serverError(w, err)
    //return
    // }
    // err = ts.ExecuteTemplate(w, "base", nil)
    // if err != nil {
    // app.serverError(w, err)
    // }
}
...

Si vas a localhost:4000 deberías ver el resultado como texto plano.

3.9 Transacciones y otros detalles

3.9 El paquete database/sql

Como te habrás dato cuenta el paquete database/sql principalmente provee una interface estandar entre tu aplicación Go y el mundo de las DB SQL.

Siempre que utilices el paquete database/sql el codigo en Go generalmente sera portable y funcionara con cualquier base de datos relacional (MySQL, PostgreSQL,SQLite). Esto significa que tu aplicación tiene bajo acoplamiento a la DB que estés usando actualmente y en teoría podrías intercambiar las DB sin tener que reescribir todo el código (dejando de lado los requerimientos de cada driver en su implementacion).

3.9 Manejando null values

Algo que Go no hace muy bien es manejar los NULL en los registros de las DB.

Supongamos que alguno de los title de nuestros snippets contenga NULL si usamos rows.Scan() nos retornaria un error porque no puede convertir NULL a string

sql: Scan error on column index 1: unsupported Scan, storing driver.Value type
&lt;nil&gt; into type *string

En términos generales, la solución a esto es cambiar el campo que estamos escaneando de string a sql.NullString

type Snippet struct {
    ...
    title sql.NullString
    ...
}

La manera mas fácil es evitar usar los campos NULL en nuestras DB y dejar un valor predeterminado que tenga sentido.

3.9 Trabajando con transacciones

Es importante darse cuenta que las llamadas a Exec() , Query() y QueryRow() pueden usar cualquier conexión del pool de sql.DB, Incluso si llamas dos veces Exec() inmediatamente una después de otra ,no es garantía de que usen la misma conexión a la DB.

Para garantizar que la misma conexión es usada podes envolver varias declaraciones en una transacción, asi:

type ExampleModel struct {
	DB *sql.DB
}

func (m *ExampleModel) ExampleTransaction() error {
    // Calling the Begin() method on the connection pool creates a new sql.Tx
    // object, which represents the in-progress database transaction.
    tx, err := m.DB.Begin()
    if err != nil {
        return err
    }
    // Defer a call to tx.Rollback() to ensure it is always called before the
    // function returns. If the transaction succeeds it will be already be
    // committed by the time tx.Rollback() is called, making tx.Rollback() a
    // no-op. Otherwise, in the event of an error, tx.Rollback() will rollback
    // the changes before the function returns.
    defer tx.Rollback()
    // Call Exec() on the transaction, passing in your statement and any
    // parameters. It's important to notice that tx.Exec() is called on the
    // transaction object just created, NOT the connection pool. Although we're
    // using tx.Exec() here you can also use tx.Query() and tx.QueryRow() in
    // exactly the same way.
    _, err = tx.Exec("INSERT INTO ...")
    if err != nil {
    	return err
    }
    // Carry out another transaction in exactly the same way.
    _, err = tx.Exec("UPDATE ...")
    if err != nil {
        return err
    }
    // If there are no errors, the statements in the transaction can be committed
    // to the database with the tx.Commit() method.
    err = tx.Commit()
    return err
}

Importante siempre se debe llamar a Rollback() o Commit antes del return de la función. Si no lo haces la conexión se mantendrá abierta y no se devolverá al pool de conexiones, esto puede agotar tu máximo de conexiones , la manera mas fácil de evitarlo es usar defer tx.Rollback() como hicimos en el ejemplo de arriba.

Las transacciones son muy utiles si queres ejecutar muchas declaraciones en una sola acción atómica. Siempre que uses tx.Rollback() en caso de que haya un error , la transacción asegura que:

  • Todas las declaraciones son ejecutadas con éxito.
  • Ninguna declaración fue ejecutada ni que se haya guardado en la DB.

3.9 Declaraciones preparadas

Como dijimos antes Exec() , Query() y QueryRow() todos usan declaraciones preparadas, para evitar ataques de inyección SQL , una vez preparadas las corren con los parámetros que se les proveyeron y luego cierran esas declaraciones preparadas.

Esto puede sonar ineficiente porque estamos creando y recreando la misma declaración cada vez.

En teoria un mejor enfoque seria usar DB.Prepare() para crear nuestro propia declaración preparada una sola vez, y reusarla.Esto es cierto para declaraciones complejas SQL ( donde hay multiples joins) y que se repiten muy a menudo (una inserción masiva de miles de registros), en esos casos el costo de re-preparar las declaraciones pueden tener un efecto notable en tiempo de ejecución de nuestro programa.

Así se ve un uso básico de nuestro propio own prepared statemen en una app web:

// We need somewhere to store the prepared statement for the lifetime of our
// web application. A neat way is to embed in the model alongside the connection
// pool.
type ExampleModel struct {
    DB*sql.DB
    InsertStmt *sql.Stmt
}
// Create a constructor for the model, in which we set up the prepared
// statement.
func NewExampleModel(db *sql.DB) (*ExampleModel, error) {
    // Use the Prepare method to create a new prepared statement for the
    // current connection pool. This returns a sql.Stmt object which represents
    // the prepared statement.
    insertStmt, err := db.Prepare("INSERT INTO ...")
    if err != nil {
    	return nil, err
    }
    // Store it in our ExampleModel object, alongside the connection pool.
    return &ExampleModel{db, insertStmt}, nil
    }
    // Any methods implemented against the ExampleModel object will have access to
    // the prepared statement.
    func (m *ExampleModel) Insert(args...) error {
        // Notice how we call Exec directly against the prepared statement, rather
        // than against the connection pool? Prepared statements also support the
        // Query and QueryRow methods.
        _, err := m.InsertStmt.Exec(args...)
        return err
  }
// In the web application's main function we will need to initialize a new
// ExampleModel struct using the constructor function.
func main() {
    db, err := sql.Open(...)
    if err != nil {
        errorLog.Fatal(err)
    }
    defer db.Close()
    // Create a new ExampleModel object, which includes the prepared statement.
    exampleModel, err := NewExampleModel(db)
    if err != nil {
        errorLog.Fatal(err)
    }
    // Defer a call to Close() on the 		prepared statement to ensure that it is
    // properly closed before our main function terminates.
    defer exampleModel.InsertStmt.Close()
}

Hay algunas cosas en las que hay que tener cuidado.

Las declaraciones preparadas existen en las conexiones de las bases de datos. Como Go usa un pool de conexiones lo que realmetne pasa es que la primera vez utiliza una conexion expecifica luego sql.Stmt recuerda cual fue la conexion en el pool que fue usada, la siguiente vez sql.Stmt intentara usar la misma conexión. si esa conexión esta cerra o en uso , la declaración tendra que ser re-preparada en otra conexión.

En cargas pesadas , es posible que muchas declaraciones sean creadas en múltiples conexiones. Esto puede provocar que las declaraciones sean re-preparadas mas a menudo de lo que esperamos , incluso llegando al limite del servidor (en MYSQL por default el maximo es de 16.382 declaraciones preparadas).

El código ademas es mas complicado que cuando no usamos declaraciones preparadas.

Existe un equilibrio entre el rendimiento y la complejidad al usar declaraciones preparadas en Go. Deberías medir el beneficio real de rendimiento al implementar tus propias declaraciones preparadas para determinar si vale la pena. En muchos casos, el uso de los métodos regulares Query(), QueryRow(), y Exec() sin preparar declaraciones puede ser un punto de partida razonable.